跳到主要内容

你的 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 变慢了,却看不到提供商的队列深度在六分钟内翻了一倍。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates