跳到主要内容

你的 LLM Span 在撒谎:APM 工具没告诉你的推理延迟真相

· 阅读需 10 分钟
Tian Pan
Software Engineer

你的 LLM 调用耗时 2,340 毫秒。你的 APM Span 是这么记录的。这个数字是你可观测性堆栈中最昂贵的谎言,因为四种完全不同的故障模式都被渲染成了同一个不透明的紫色条块。长提示词下的 Prefill(预填充)浪涌。一个一小时没访问的租户导致的冷 KV 缓存。提供商连续批处理(continuous batching)中的“吵闹邻居”。一次无声的路由变更将你的流量导向了不同的区域。同样的 Span。同样的耗时。同样的 p99 告警。却是四个截然不同的复盘分析。

适用于微服务的分布式追踪准则 —— 每个网络跳数一个 Span、一个时长、几个标签 —— 在面对托管推理时失效了。LLM 调用并非单一实体。它是一个由多个阶段组成的流水线,每个阶段都有截然不同的扩展特性,运行在行为取决于队列中其他人的共享硬件上。将其视为一个单一且不透明的 Span,会导致你花费三天时间去调试“模型变慢了”,而实际上模型根本没变。

单一时长下的“双重体制”问题

每次 LLM 调用其实是两个操作的合体。Prefill(预填充) 是对每个提示词词元进行的一次前向传递,用于构建 KV 缓存并生成第一个输出词元。这是一个大型矩阵乘法,属于 计算受限(compute-bound),其规模大致随“提示词长度 × 模型 FLOPs”增长。Decode(解码) 是生成后续每个词元的自回归循环。每一步都需要从 GPU 显存中重新加载完整的权重张量以及不断增长的 KV 缓存,因此 Decode 属于 内存带宽受限(memory-bandwidth bound),其规模随“输出长度 × KV 缓存大小”增长。

这些阶段受不同的参数调节。增加批处理大小(batch size)会使 Prefill 变差(一次迭代中需要处理更多词元),而使 Decode 变好(摊薄了权重重新加载的成本)。采用分块预填充(Chunked prefills),你可以改善词元间延迟,但代价是增加了首词时间。启用投机解码(speculative decoding)可以加速 Decode,而 Prefill 则不受影响。像 vLLM 这样的引擎直接暴露了这些权衡,但单一的 Span 时长却抹杀了它们。

用户可见的指标是 TTFT(首词时间)和 ITL(词元间延迟,有时也称为 TPOT)。OpenTelemetry 目前的生成式 AI 语义约定承认 gen_ai.response.time_to_first_chunk,但对于 Decode 侧的分布则没有定义。所以你的仪表盘可以告诉你 TTFT 退化了,但它无法告诉你 ITL 是否也随之退化,这意味着它无法区分是“提示词变长了”还是“批处理变拥挤了”。这两者需要不同的修复方案。

API 提供了缓存命中率,但 Span 隐藏了它

提示词缓存(Prompt caching)是大多数生产系统中最大的延迟调节杠杆。Anthropic 声称长提示词的延迟最高可降低 85%;OpenAI 声称最高可降低 80%。传输格式会准确地告诉你发生了什么:Anthropic 上的 usage.cache_read_input_tokensusage.cache_creation_input_tokens,OpenAI 上的 usage.prompt_tokens_details.cached_tokens,以及 Gemini 上的 cachedContentTokenCount。这些字段是进行容量规划时最有用的信号,但几乎没有人将它们包含在 Span 中。

OTel 生成式 AI 规范确实包含了 Anthropic 风格计数器的插槽。但它没有推导出来的是比例 —— 而这个比例正是触发告警的关键。当你的 5.0 万词元系统提示词在周二下午从 99% 的缓存读取率下降到 60% 时,你的 p95 延迟将增加一秒,账单将增加两倍,但除非你自己计算了比例并将其放入 Span,否则你的日志和仪表盘将显示不出任何异常。

更糟糕的是,缓存未命中可能有多种重要的原因。是提示词变了吗?是提供商因为其他租户的压力驱逐了你的数据块吗?还是冷启动?Span 需要一个带有 reason 属性的 cache_miss 事件,因为“缓存命中率下降 30%”在“你自己代码更改”和“提供商重新平衡容量”两种情况下,解读方式完全不同。

你所在的批处理,以及你看不到的邻居

每一个值得使用的托管推理端点都在运行 连续批处理(continuous batching)。vLLM、TGI、SGLang、TensorRT-LLM 以及所有基于它们构建的提供商都会将你的词元与同一迭代中到达的其他人的词元交错处理。这就是 PagedAttention 如何在原生服务的基础上实现 20 倍吞吐量的秘诀。这也是为什么你的 p99 延迟取决于你的邻居,而不是你的提示词。

当一个巨大的 Prefill 进入批处理时,活跃的 Decode 会在引擎处理它时停滞。Anyscale 关于连续批处理的原始文章明确指出了这一点:随着系统饱和,新请求注入的时间会变晚,因此即使吞吐量看起来很健康,请求延迟也会上升。BentoML 的推理手册也提出了同样的警告 —— 更大的批处理虽然提高了利用率,但会推高尾部延迟,有时当 Prefill 挤占了活跃的 Decode 时,这种影响是巨大的。

你的 Span 中完全没有这些信息。没有 gen_ai.server.batch_size,没有 gen_ai.server.batch_position,也没有 gen_ai.server.queue_wait_ms。这些属性存在于服务器的内部计数器中 —— vLLM 在 Prometheus 中暴露了它们 —— 但提供商不会将它们传递给你,OTel 生成式 AI 规范也没有预留位置。因此,当客户抱怨 14:07 出现峰值时,你只能看到你的 Span 变慢了,却看不到提供商的队列深度在六分钟内翻了一倍。

供应商需要的 Request ID,以及他们不索引的 Trace ID

你打开了一个支持工单。供应商要求提供响应头中的 x-request-id。你手里有一个 trace ID。但它们不是一回事,而且在任何地方都没有关联。

OpenAI 和 Anthropic 都会在每次响应中返回一个 request ID —— OpenAI 使用 x-request-id 和 SDK 字段 _request_id,Anthropic 使用 request-idmessage._request_id。这些是供应商内部索引的标识符。没有它们,联合复盘(post-mortem)就变成了:“我们在 UTC 时间 14:07 左右看到了延迟,也许这有帮助” —— 而这实际上毫无帮助。

OTel 将 gen_ai.response.id 保留给供应商的响应 ID(响应体中的 id 字段,例如 chatcmpl-... 字符串)。这与 HTTP request ID 是不同的值,而支持工程师通常需要后者。增加一个 gen_ai.provider.request_id 属性只需五行埋点代码的改动,就能将一个模糊的工单转化为一个有用的工单。然而大多数团队从未部署过它。

推测解码带来的变动,以及没人记录的采纳率

如果你的供应商使用了推测解码(speculative decoding) —— 供应商们正越来越多地采用这种技术,且通常是静默开启的 —— 你的延迟就会出现一个新的变动维度。一个小型草稿模型(draft model)建议 γ 个 token,大模型对其进行验证,被采纳的 token 相当于“免费”传输。NVIDIA 发布的数据显示,在采纳率 α≥0.6 且 γ≥5 时,可以实现 2–3× 的提速。低于这个水平,加速效果就会瓦解,有时甚至会退回到基准线水平。

采纳率取决于工作负载。代码生成和分布内的散文能达到很高的采纳率;而分布外任务和对抗性提示词则不然。你的流量组合在一个季度内发生漂移,你的 p95 也会随之漂移。目前无论是传输格式还是任何 APM 都没有体现采纳率,因此在销售工程师运行一个曾经感觉很快但现在不再敏捷的演示之前,这种漂移是不可见的。

提议的属性 —— gen_ai.speculative.accepted_tokensgen_ai.speculative.proposed_tokensgen_ai.speculative.acceptance_rate —— 尚未进入规范。运行自己推理服务的团队可以从 vLLM 的指标中提取它们。使用托管 API 的团队则只能受限于供应商选择在响应头中暴露的内容,而目前通常什么都没有。

理想的追踪层级应该是怎样的

一个真正能让你排查 p99 峰值的 span 应该有大约 30 个属性,而不是 7 个。按用途分组的最小有用模式(schema)如下:

  • 耗时拆分time_to_first_token(TTFT)、decode_duration 以及作为独立属性的 inter_token_latency(ITL)分布(p50/p95/p99)。仅有 TTFT 是不够的。
  • 缓存统计cache_read.input_tokenscache_creation.input_tokens、计算出的 cache_hit_ratio(缓存命中率),以及在未命中时带有 reason(原因,如 prompt_changed / evicted / cold)的 span 事件。
  • 批处理状态(供应商侧,如果供应商配合则通过标头传递):batch_sizebatch_positionqueue_wait_msrunning_requestswaiting_requests
  • 推测解码accepted_tokensproposed_tokensacceptance_rate
  • 供应商关联provider.request_id(HTTP 标头,而非响应体 ID)、provider.regionprovider.pool_id,以便检测静默的路由更改。

有了这些,延迟峰值就能得到清晰的解释。TTFT 上升而 ITL 保持不变 → 预填充(prefill)激增,可能是长提示词或缓存未命中;检查缓存命中率。ITL 上升而 TTFT 保持不变 → 解码竞争,可能是受到“吵闹邻居”影响或批次拥挤;检查 queue_wait_ms。两者同时上升 → 供应商容量事件;使用 provider.request_id 提交工单。采纳率下降 → 你的流量组合发生了变化,或者供应商更换了草稿模型。四个假设,四个查询,四十分钟的调试,而不是三天。

容量规划的鸿沟

现有的 LLM 可观测性工具 —— Langfuse、LangSmith、Traceloop、OpenLLMetry、Arize Phoenix、Helicone —— 在其设计初衷上表现出色:成本调试和质量调试。它们会告诉你哪些提示词昂贵,哪些输出未通过评估,哪些租户占用了大量 token。但它们无法告诉你延迟退化是因为你的代码、你的提示词、供应商的批处理还是供应商的基础设施,因为回答这些问题所需的属性在它们所继承的规范中并不存在。

容量规划 —— “我的服务在当前的延迟 SLO 下能否承受 2× 的流量?” —— 是这些工具无法回答的问题。答案取决于你提示词分布中的预填充/解码比例、你未记录的缓存命中率、你看不见的批次位置惩罚,以及没人报告的推测解码采纳率。能在下一次使用量激增中幸存下来的团队,是那些意识到自己的 span 覆盖范围太窄,并亲手构建了缺失埋点的团队。OTel 规范会赶上来的,可能在 2026 年左右。但你的生产事故可等不了那么久。

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