跳到主要内容

Agent 延迟预算是树而非线 —— 你一直在错误的维度进行调试

· 阅读需 14 分钟
Tian Pan
Software Engineer

用户报告“今天早上助手感觉很慢”。值班工程师调出火焰图,按持续时间降序排列工具调用,找到了最慢的一个——耗时 2.1 秒的向量搜索——将其优化到 900ms,发布修复补丁,并将事件标记为已解决。一周后,同样的投诉再次出现。向量搜索仍然是 900ms,但该查询类型的端到端延迟实际上变得更糟了。火焰图中没有任何内容能解释原因。

这就是当工程师在“线”轴上调试一棵“树”时所发生的情况。Agent 延迟不是一系列顺序步骤的瀑布——它是一个由规划调用、工具子树、并行扇出、重试和递归子 Agent 组成的嵌套树。当预算是结构化的,而工具却将其视为线性的,局部优化就会错过真正的违规点,而违规点存在于时间如何分布在各分支中,而不是任何单个调用耗时多久。你可以让每个叶子节点都变得更快,但交付的 p99 却仍在恶化。

症状通常是:团队不断在火焰图优化上取得进展,但面向用户的延迟仪表盘却保持平稳或呈上升趋势。这就是把树的问题当作线的问题来调试的典型特征。如果你不止一次遇到这种情况,那么可观测性和预算模型都需要改变——而不仅仅是针对那个缓慢的工具。

线性思维模型在 Agent 循环中宣告失效

当工程师思考请求延迟时,我们中的大多数人仍然带有来自请求-响应 API 的线形思维模型:用户发送查询 → 网关 → 服务 A → 服务 B → 数据库 → 返回。延迟是关键路径上的总和。你绘制火焰图,找到耗时长的 span,然后修复它。

Agent 系统表面看起来很相似——边缘仍然存在“请求”和“响应”——但中间部分的形状截然不同。一个典型的用户回合通常会触发:

  • 一个或多个规划 LLM 调用来决定要做什么。
  • 规划器发出的一组工具调用,其中一些 SDK 可以并行运行。
  • 每个工具的子执行,其本身也会扇出(例如,一个检索工具会调用嵌入 API、重排序器和向量数据库)。
  • 子 Agent,它们是具有自己的规划轮次和工具子树的嵌套循环。
  • 瞬时故障的重试,这会在各层级之间成倍增加。
  • 最后是一个合成 LLM 调用,将所有内容缝合在一起。

Claude Agent SDK 明确说明了这一点:在单个回合中请求的工具如果被标记为只读,则可以并发运行,否则将顺序运行。编排者-工作者(orchestrator-worker)模式现在已成为标准——主 Agent 生成用于并行探索的工作者。扇出(Fan-out)是常态,而非例外。

贯穿该结构的关键路径不是一条线。它是一个通过一棵树的最长根到叶路径,这棵树的形状在运行时由规划器部分决定。总的挂钟延迟受相对于最深分支的每个兄弟分支上有多少空余(slack)控制。优化短分支上的叶子节点,数值变动为零;优化关键分支上的叶子节点,数值变动正好是你缩减的量。

团队误判的原因在于工具。大多数追踪 UI 默认显示按持续时间排序的扁平 span 列表。这种视图隐藏了深度、隐藏了兄弟节点、隐藏了空余,并让“最长的条”看起来像是因果关系,而实际上它往往根本不在关键路径上。

按照系统的实际执行方式分解预算

修复从预算开始。为树中的每个节点提供明确的 SLO,而不仅仅是根节点。针对一个用户感知预算为 6 秒的客户支持 Agent,具体的分解可能如下所示:

  • 根节点 (Root): 总计 6000ms
    • 规划器调用 1: 800ms
    • 工具扇出(并行,由最慢的兄弟节点决定): 3000ms
      • 检索子树: 2500ms
        • 嵌入 (Embedding): 150ms
        • 向量搜索: 400ms
        • 重排序 (Rerank): 600ms
        • 工具封装开销 + 网络: 350ms
        • 预留(空余): 1000ms
      • CRM 查询: 1500ms
      • 库存检查: 800ms
    • 规划器调用 2(合成): 1500ms
    • 框架开销、序列化、流式设置: 700ms

从这个练习中可以得出两点结论,而这是任何火焰图都不会告诉你的:

第一,检索子树和规划器合成步骤在结构上每次都在关键路径上,因为它们是顺序且深层的。CRM 查询和库存检查位于短分支上——只要检索更慢,在那里进行的 400 毫秒改进对总延迟的贡献就为零。每个团队都有过“我们优化了 800 毫秒的 CRM 调用”的故事,但根节点的 p95 却纹丝不动。

第二,每个节点都有兄弟空余(sibling slack)——即其自身持续时间与所有兄弟节点中的最大持续时间之间的差距。兄弟空余是树状延迟工作中最重要的量,但它几乎从未出现在任何仪表盘上。如果 CRM 调用耗时 1500ms,但其最慢的兄弟节点耗时 2500ms,那么你在该分支上就有 1000ms 的免费裕量。你可以将其用于第二次重试、对冲请求、或者更好但更慢的模型——任何能在不改变关键路径的情况下提高可靠性或质量的事情。相反,如果你的关键路径子树超出了预算,你需要攻击那个特定的分支,而火焰图通常会指向另一个看起来更可怕的分支,因为那里的条形图更长,但它其实位于空余时间内。

每个节点的 SLO 也改变了告警的意义。“检索子树超出了 2500ms 的预算”是具有可操作性的——它告诉你应该传呼哪个子系统。没有分支归因的“p95 上升了”会同时瞄准每个团队,通常以长达一周的紧急会议告终。

期限传播是使树可执行的契约

缺乏强制执行的预算仅仅是文档。分布式系统已经使用了十年的强制执行机制是期限传播 (deadline propagation) —— 而在智能体堆栈 (agent stacks) 中,它的使用率低得离谱。

gRPC 社区将这一模式规范化了:当客户端发起带有期限 (deadline) 的 RPC 时,服务器继承一个等于剩余时间的预算,减去自身工作耗时,并将缩减后的期限传递给任何下游调用。如果任何节点发现期限已过,它会快速失败 (fail fast) 而不是浪费资源。这种做法非常成熟,以至于 Java 和 Go 中的 gRPC 实现会自动传播期限;userver、微软的 gRPC 指南以及无数生产系统都将其视为基准卫生习惯 (baseline hygiene)。

智能体框架 (Agent frameworks) 大多尚未采用这种做法。典型的设置是为每个工具调用提供一个固定的单次调用超时 —— 例如 10 秒 —— 而不考虑已经消耗了多少用户的总预算。如果规划器 (planner) 思考耗费了 4 秒,然后发起三个超时时间为 10 秒的工具调用,你就暗中授权了 34 秒的墙上时钟时间 (wall clock time),而用户服务等级目标 (SLO) 仅为 6 秒。重试层让情况变得更糟 —— 工具的重试预算是基于工具局部时间的,而不是请求树时间的,因此单个缓慢的工具可能会使请求超过期限,而每个独立组件却都“符合规范”。

智能体循环所需的最低契约:

  • 每一个用户请求都开启一个带有绝对期限的预算令牌(不是持续时间,而是墙上时钟时间戳)。
  • 每个工具调用、LLM 调用和子智能体都会收到一个携带剩余预算的上下文 (context)。
  • 每个节点在将期限转发给子节点之前,都会减去自身的估计成本。
  • 任何观察到期限已过的节点都会立即中止,而不消耗模型令牌或工具配额。
  • 重试策略受剩余预算限制,而不是受单次调用重试次数限制。

一旦这套机制就位,“期限已过”就成了一等信号,而不是神秘的超时。你可以观察到哪个子树消耗了预算,这才是你采取行动真正需要的数据。

可观测性必须呈现空余时间,而不仅仅是持续时间

如果预算是一棵树,仪表板也必须是一棵树。真正重要的可观测性模式不是“最慢的跨度 (span)”,而是“随时间变化的执行关键路径的所有权”以及“每个子树的空余时间 (slack) 分布”。

四个改变团队调试方式的视图:

每个子树的空余时间直方图。 对于每个命名的子树(检索、CRM 查询、库存、综合),绘制 subtree_duration / subtree_budget 的分布图。健康的子树处于 0.5–0.8 左右。持续处于 0.95+ 的子树是需要优化的对象。处于 0.2 的子树则为你提供了可以花在质量上的空间。大多数团队在构建此视图后发现,他们直觉中认为的“哪个子系统慢”其实完全偏离了分支。

按查询类别划分的关键路径归因。 并非每个请求在树中走的路径都相同。按查询类别(如账户问题 vs. 产品问题 vs. 退款请求)分组,并显示哪个子树拥有每类查询的关键路径。当“检索是瓶颈”仅对 40% 的流量成立时,这就是个谎言。

期限超限分解。 当请求超出预算时,将超出的部分归因于特定节点:“检索在其 2500ms 的配额中超出了 600ms”是一份错误报告,“请求花费了 7s”则不是。

兄弟分支竞速图。 当发生分叉 (fan-out) 时,显示每个兄弟分支相对于限制父节点的、最慢兄弟分支的完成时间。这是对空余时间最清晰的可视化呈现,能立即揭示哪些分支可以进行更多重试、对冲请求 (hedged requests) 或质量升级。

这些视图都不需要自定义遥测 —— 具有父子关系的 OpenTelemetry spans 携带了足够的信息。但默认的追踪 UI 不会渲染它们。要么扩展你的可观测性平台的仪表板,要么将 spans 导出到能够处理它们的工具中。

早期取消和兄弟分支空余时间是两个最大的杠杆

有了预算、传播和树感知可观测性后,两种优化方式就变得唾手可得。

对注定失败的分支进行早期取消。 如果规划器并行发起一个检索调用和一个 CRM 查询,而检索在 100ms 时发生硬故障,那么就没有理由让 CRM 查询再运行 1400ms —— 综合步骤需要这两者。大多数智能体框架在兄弟分支失败时不会通过工具子树传播取消信号,因此系统会在其输出将被丢弃的工作上浪费预算和配额。通过并行分叉传递取消令牌 (cancellation tokens)。仅此一项就可以显著降低工具错误相关的查询类别的 p95。

将兄弟分支的空余时间花在质量或可靠性上,而不是速度上。 当你发现一个分支有 1000ms 的空余时间时,本能反应往往是缩减它。在智能体系统中,这是错误的。利用这些空余时间。针对备份提供商进行对冲调用。运行第二个检索变体并采用更好的结果。升级到更昂贵的重排序器 (reranker)。增加一个轻量级的验证步骤。未使用的空余时间就是你未能交付的质量。关键路径分支进行速度优化;短分支进行质量优化。

关于投机性工具执行 (speculative tool execution) 有一条研究思路 —— PASTE 及其后继者 —— 它走得更远,在规划器仍在生成其推理逻辑时就执行可能发生的下一步工具调用。在具有稳定控制流的智能体工作负载上,感知模式的投机已被证明能将平均任务完成时间减少近 50%。这是同一原则的更激进版本:如果你有空余时间,就把它花在规划器即将要求的任务上。

组织失效模式:无人负责的共享预算

树状预算暴露了一个被线性思维所掩盖的组织问题:谁拥有每个节点的 SLO?平台团队通常拥有根节点,基础设施团队负责模型调用,工具团队拥有各自的工具节点,而 Agent 团队则负责规划和编排。当检索子树超出预算时,该由谁负责?当 CRM 查询有 1500ms 的裕度时,由谁决定如何使用它?

在这种分解模式下保持健康的团队通常会做三件事:

  • 将每个子树的预算视为具有明确所有者和审查周期的第一类 SLI。不要让“Agent 响应慢”变成一种针对第一个回复的人的、缺乏预算依据的指责。
  • 将“预算 vs 实际”视为常规的容量规划产物,而非仅在事故时使用的工具。一个子树在六周内预算占用从 60% 趋势性增长到 85%,这是一个规划信号,而非一次紧急救火。
  • 像审查 Schema 变更一样审查预算变更。新工具在 PR 审查时就应获得预算分配,这部分预算应明确从父级的预留中划拨——而不是直接累加到请求的自然总时长中。

调试实践也随之反转。问题不再是“哪个 span 最慢”,而是“哪个子树超出了预算,以及它的哪个子节点导致了这种情况?”这是一个具备树状感知能力的仪表盘能够真正回答的问题。

问题始终出在坐标轴上

火焰图会继续将 Agent 追踪绘制成一系列长条,而按时长排序并优化最长的那一项,这种诱惑会让你一直觉得很有成效。但你正在优化的长条通常位于一个并非关键路径的分支上,或者位于一个本可以更好地利用其裕度的分支上,又或者位于一个违反了从未明确过的预算的子树上。

解决方案不是一个新工具,而是心理模型的转变——预算是树状的,截止日期会传播,裕度是一种资产,而关键路径是从根到叶的路径,而不是一个排序列表。一旦你的仪表盘和 Agent 循环在形状上达成一致,那些“早晨响应慢”的复盘就不再神秘。你可以指着超出分配的分支、被浪费的同级裕度,以及导致树的深度超出预算的规划决策。这是一个你能够真正解决的调试故事。

References:Let's stay in touch and Follow me for more thoughts and updates