跳到主要内容

30 秒都去哪了:APM 无法察觉的 Agent 步骤内部延迟归因

· 阅读需 13 分钟
Tian Pan
Software Engineer

仪表盘显示 p95 的 agent.run = 28s。用户反馈该功能感觉已经挂了。值班工程师打开 Trace(追踪),看到一个没有任何值得调查的子节点的“肥大”长条,然后开始盲猜。当有人重建出足够的心理模型,搞清楚瓶颈到底是模型、检索器,还是某个没人添加 Span 的工具调用时,故障已经变成了积压的任务单,而用户早已放弃了。

这就是 2026 年 Agent 运营核心的失败模式:传统的 APM 将 Agent 步骤视为一个黑盒,而“Agent 延迟”并不是一个单一指标——它是七个指标的总和,这些指标根据 Agent 在该轮次中的决策,以不同的方式分解实际用时 (Wall-clock time)。如果一个团队不暴露这七个数字,他们交付的功能虽然大家都能感觉到慢,但谁也无法修复。

隐藏在一个 Span 里的七个阶段

当面向用户的仪表盘报告 agent.step = 12s 时,实际用时几乎总是由以下阶段按顺序叠加而成的:

  1. 输入组装 (Input assembly) —— 构建消息列表、应用系统提示词、填充会话状态、获取先前的工具输出。在热启动时成本较低,但当会话滚动更新且缓存未命中强制每一轮都读取数据库时,成本高得惊人。
  2. 检索 (Retrieval) —— 对查询进行向量化、访问向量数据库、以及可选的重排序。生产环境中的向量数据库查询通常在每次 50–300ms 之间,而重排序根据模型和候选数量的不同,可能会再增加 100–500ms。
  3. 预填充 (Prefill) —— 模型处理整个输入上下文以生成第一个输出 Token。预填充受算力限制,并随输入长度增长;提示词长度增加一倍,首字延迟 (TTFT) 大致也会翻倍。
  4. 解码 (Decode) —— 模型逐个生成 Token,每一步都受 KV 缓存的内存带宽限制。这是推理工作量和输出长度线性消耗预算的地方,也是与其他流量共享 GPU 批处理导致竞争的地方,而应用团队对此一无所知。
  5. 工具分发 (Tool dispatch) —— 框架 (Harness) 解析模型的工具调用、验证参数并路由到正确的处理程序。通常很小,但如果框架通过队列进行往返调度,则很容易变得臃肿。
  6. 工具执行 (Tool execution) —— 实际的外部工作。可能是 50ms 的缓存读取,也可能是 9 秒的 LLM 作为工具的嵌套调用。这里的方差是“为什么 p99 比 p50 差得多”的最大来源。
  7. 重新预填充 (Re-prefill) —— 模型将工具结果重新处理回上下文中,以生成下一个响应 Token。在心理模型中经常被遗忘,因为它视觉上看起来像“模型再次思考”,但它是对不断增长的上下文进行的全新预填充过程,上下文会随着每一轮工具调用而增长。

一个普通的 Trace 会将步骤 1–7 压缩成一个单一的 agent.step Span,或者更糟,只拆分出两个(llm.calltool.call),而将其余五个归入父 Span 的时间区间内。在 2024–2025 年间通过规范制定的 OpenTelemetry GenAI 语义约定定义了诸如 chatexecute_toolinvoke_agent 之类的操作名称——但采用这些约定才是解锁分解视图的关键,仅发出父 Span 的团队得到的依然是和以前一样模糊的视图。

可见的 12 秒与不可见的 4 秒

在 Agent 产品上最常见的优化预算错配是:LLM 调用在 Trace 中显示为最大的可见块,因此团队花费两个冲刺的时间来削减提示词 Token、更换更快的模型并调整流式传输缓冲区。经过这一切,p95 从 28 秒降到了 24 秒。用户仍然在抱怨。

那没人去省下的 4 秒钟分散在其他阶段:

  • 600ms 的输入组装,因为每一轮都会重新获取用户的偏好文档,而不是在会话中缓存它。
  • 1.2s 的检索设置,因为嵌入模型运行在冷容器上,首个请求后才预热,且仅在会话活跃时保持预热。
  • 800ms 的工具分发,因为框架通过一个为批量吞吐量而非交互延迟调优的 Redis 流来排队工具调用。
  • 第二轮中 1.4s 的重新预填充,因为 Agent 将 4k Token 的工具结果放入上下文中,模型必须在生成下一个 Token 之前处理它。

这四项成本中没有一项会作为你可以注意到的单个 Span 出现在 Trace 中。它们表现为 Span 之间的微小间隙、归于通用“中间件”Span 的工作,或者父 Span 拥有但子 Span 未能解释的时间。如果你只看到已埋点的部分,你就会去优化已埋点的部分,而将那 4 秒钟白白流失。

诊断修复是程序化的:父 Span 开始与第一个子 Span 开始之间的每个间隙、任何两个相邻子 Span 之间的每个间隙,以及最后一个子 Span 与父 Span 结束之间的每个间隙,都必须是一个命名的阶段或一个明确的“未计算 (unaccounted)”Span。如果一个请求声称耗时 12 秒,而各 Span 之和仅为 9 秒,那么缺失的 3 秒就是 Bug 所在。

关键路径并非所有 span 的总和

这是团队在终于拆分阶段后犯的第二个错误:他们看到一个包含 9 秒工具调用和 8 秒并行的 LLM 调用的 trace,然后问“我应该先优化哪一个?” —— 而答案是 8 秒的 LLM 调用构成了全部的墙钟耗时(wall-clock cost),而 9 秒的工具调用位于一个不会延长请求时间的并行分支上。

关键路径追踪(Critical path tracing)是 Google 在其分布式延迟研究中正式提出的概念,自 2022 年以来被广泛的观测社区所采用。它对每个 span 提出一个问题:如果我能让这个 span 瞬间完成,整个请求会变快吗?如果是,该 span 就在关键路径上。如果不是 —— 因为并行运行的其他任务耗时更长 —— 该 span 就存在松弛量(slack),优化它就是浪费精力。

对于 agent 来说,这比传统的 RPC 扇出(fan-outs)更重要,因为当模型在单轮中发出多个工具请求时,agent 框架(harness)可以发起并行的工具调用,而且现代框架越来越多地默认这样做。如果一个 trace 显示“这一轮耗时 14 秒,六个工具调用分别耗时 2 到 9 秒”,在折叠并行分支并报告关键路径之前,它是没有任何行动指导意义的。值班人员需要看到的是:“9 秒的工具调用是关键路径;其他五个并行运行并提前结束了;如果你优化其他五个中的任何一个,p95 都不会改变。”

实现方式是一个针对每个请求的后处理步骤,它遍历 span 树,识别从请求开始到请求结束的最长依赖链,并在父 span 上输出 critical_path_msslack_ms 属性。在该功能上线后,“优化什么”的仪表盘挂件将变成按关键路径贡献度排序的列表,而不是按总 span 时长排序,团队也就停止了对松弛部分的无效优化。

解码时间不等于 LLM 时间

即使在工具化完善的 LLM span 中,也存在一种微妙但代价高昂的混淆:span 显示的是 LLM 调用的总时长,而工程师将其视为一个单一数字。但预填充(prefill)阶段和解码(decode)阶段具有不同的成本模型、不同的优化杠杆,以及对推理集群负载的不同敏感度。

预填充(Prefill)是计算密集型的(compute-bound),并随输入长度扩展。如果你的预填充很慢,优化杠杆包括:更短的 prompt、提供商侧的 prompt 缓存(这样重复的系统 prompt 就不用在每一轮都支付预填充成本)、更短的检索段落,以及避免填充从未调用的工具定义。

解码(Decode)是内存密集型的(memory-bound),并随输出长度扩展。如果你的解码很慢,优化杠杆包括:更短的输出(通常是通过改变 prompt 模式而非仅仅添加“简洁点”)、具有约束语法的结构化输出(解码速度更快)、减少支持该功能的模型上的推理努力预算,以及在保持慢速模型进行规划的同时,切换到更快的模型进行响应生成。

一个只看到 llm.call.duration_ms = 8200 的团队会花一整个 sprint 尝试“让 LLM 变快”,最后得出无计可施的结论。而一个看到 llm.prefill_ms = 1800llm.decode_ms = 6400 的团队会准确识别出输出太长,并花同样的 sprint 修改 prompt 的一个部分,将平均输出减少 40%,从而挽回 2.5 秒。区分这些信息所需的数据在每个现代提供商的响应中都有提供 —— time_to_first_token 和总时长都有报告 —— 但必须将其捕获到单独的 span 属性中,而不是合并在一起。

OpenTelemetry GenAI 规范明确指出 gen_ai.server.time_to_first_tokengen_ai.server.time_per_output_token 是正确的输出属性。尚未迁移到这些规范的团队在每次事故中都在支付调试税。

为什么这比常规服务的影响更大

在传统服务中,延迟是一个用户体验(UX)问题。在 agent 中,延迟也是一个成本问题,因为解码过程中每一秒的墙钟时间都是模型在活跃消耗 token 的时间 —— 在推理模型中,这些 token 包括那些被计费但从未到达用户的隐形“思考” token。一个包含 80% 思考 token 和 20% 输出 token 的 5 秒解码,相当于为用户看不到的内容支付了 4 秒的计算费用,而唯一的信号就是响应中 total_tokensoutput_tokens 之间的差距。

每秒成本的计算也让优化预算变得更加紧张。传统的 Web 服务可以花三周的工程师时间为每天调用百万次的请求削减 200 毫秒,这种计算是合理的,因为优化是永久性的且请求成本低廉。而 agent 的每步调用成本从几美分到几美元不等,调用量也低得多,如果一个“层级错误”的优化针对的是可见的 LLM span 而非隐藏的检索设置,那么两周的工程师时间将无法产生可衡量的用户改进或成本降低。调查成本与修复成本的比例比传统服务糟糕得多,这就是为什么对分阶段可见性的前期投入能更快获得回报。

此外还有安全和治理方面的考虑。当模型在运行时决定调用哪些工具以及调用多少次时,工具选择的回归可能会在不更改团队代码的情况下悄悄使单个请求的成本翻倍。这方面的第一个信号通常是数周后的预算警报。一个输出分阶段计时的团队也能从同样的工具化中免费获得分阶段的 token 成本明细,而“每轮平均工具调用次数”就成为了一个领先指标,能在几天内捕捉到回归,而不是等到计费周期结束。

“优秀”的插桩(Instrumentation)是什么样的

对于 Agent 产品团队来说,一个有用的目标是生成一份 where_did_the_budget_go(预算花在哪了)报告,以便当用户抱怨会话变慢时,任何产品工程师都能在 5 分钟内调出该报告。针对响应缓慢的那一轮对话,该报告需要显示:

  • 该轮对话的墙钟时间(Wall-clock duration)
  • 每个阶段对关键路径的贡献,按降序排列
  • 每次 LLM 调用的 Prefill(预填充)/ Decode(解码)拆分
  • 每次工具调用的并行与串行分类
  • 任何未说明的时间都应作为明确的行显示,而不是隐藏的间隙
  • 每个阶段的 Token 成本,以便延迟情况和成本情况能对齐

基于 OpenTelemetry GenAI 语义约定构建此报告需要几天的脚手架工作,而回报是下一次事故排查只需 5 分钟,而不是两天。尚未构建此类报告的团队,往往在客户成功沟通中第一次需要正面回答“为什么我周二下午的请求很慢?”时,才会意识到它的缺失。

架构维度的认识

Agent 延迟不是一个指标。它是七个指标的总和,每个指标都有不同的成本模型、一套不同的优化杠杆,以及与关键路径的不同关系。如果一个追踪工具只报告“Agent 步骤时长”这一个数字,那就相当于负载均衡器只报告“请求时长”,而没有分解上游后端时间、网络时间或排队时间——这对于发现问题有用,但对于解决问题毫无用处。

那些在 p95 延迟下仍能让产品感觉很快的团队都完成了这种拆解。他们发送每个阶段的 Span,计算关键路径,区分 Prefill 和 Decode,使未说明的时间可见,并将每个延迟数字与来自同一插桩的 Token 成本数字配对。他们之所以比同行更快,并不是因为在 Prompt 方面更聪明。他们之所以更快,是因为更清楚哪些秒数真正拖慢了用户体验,从而有针对性地进行优化,而不是在无关紧要的地方浪费功夫。

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