止于供应商边界的链路追踪
你做了追踪(tracing)工作。检索有 span。工具调用有 span。编排循环有 span。Trace ID 贯穿每一个内部跳跃,记录在 W3C 的 traceparent 请求头中,正如 SRE 手册所说。然后请求到达 messages.create,SDK 记录了一个名为 llm.call 的单一 span,接着你流水线中接下来的 2.8 秒在火焰图上变成了一个没有任何内部结构的黑色矩形。首个 token 出现前的 800 毫秒:不透明。之后的 2 秒解码过程:不透明。你的追踪无法得知网络、队列等待、Prefill 或单 token 解码在总耗时中所占的比例。
当客户报告“今天助手感觉很慢”时,你的仪表板可以证实这种缓慢,但无法定位它。你流水线中最昂贵的一分钟——以美元、p95 以及用户感知的延迟来衡量——发生在供应商的数据中心内部,而你签约时接受的合同几乎没有给你任何可见性。你正在为一个黑盒值班(on call)。
这篇文章讨论的是那个黑盒里到底装了什么,供应商通过裂缝泄露了哪些大多数客户端都忽略了的信息,以及如何合成你的追踪原本应该提供的可见性。
单 Span LLM 调用的剖析
任何主流 LLM SDK 的默认集成都只给你一个 span:调用 API 时的开始时间,以及响应完成时的结束时间。该 span 具有一些属性——模型名称、token 计数,可能还有一个停止原因——但没有内部事件。从你的追踪角度来看,调用内部是未定义的行为。
内部并非未定义。它至少可以分解为四个不同的阶段,每个阶段都有自己的失败模式和动态规律:
- 网络流出与 TLS —— 从你的进程到供应商边缘的时间。受地域影响,但并非为零。在冷连接上的 50ms TLS 握手与 50ms 的模型延迟是完全不同的问题。
- 供应商侧队列 —— 你的请求在共享推理集群中排在其他租户之后的时间。这取决于你看不见的流量,而不是你的客户端做了什么。
- Prefill(预填充) —— 模型处理你的输入 token 以生成用于推理的内部状态。受计算限制,与 prompt 长度大致呈线性关系,且对供应商的 KV 缓存是否命中了你的输入非常敏感。
- Decode(解码) —— 逐个生成输出 token。受内存带宽限制,与输出长度大致呈线性关系,且对供应商侧的批处理大小(batch size)敏感。
一个耗时 3 秒的 llm.call,如果是由 100ms 网络、200ms 队列、600ms prefill 和 2.1s decode 组成的,那么它与由 100ms 网络、2.4s 队列、100ms prefill 和 400ms decode 组成的 3 秒调用是完全不同的问题。前者是长输出问题,后者是“嘈杂邻居”(noisy-neighbor)问题。默认情况下,你的追踪无法告诉你属于哪种情况。
供应商实际交付给你的信息
之所以这是可以解决的,是因为供应商泄露的信息比典型的 SDK 集成所展示的要多。信号就在那里,只是大多被丢弃了。
流式传输免费为你提供了每 token 的时间戳。 Anthropic 和 OpenAI 都以 Server-Sent Events 的形式发送流式响应。如果你的客户端正在读取流,你已经知道每个数据块(chunk)到达的时间。从发送请求到第一个 content_block_delta 的挂钟时间就是你真实的 TTFT(首个 token 时间)。连续数据块之间的 delta 是你的 token 间延迟序列。大多数集成将这些汇总成最终响应并丢弃了时间信息。不要这样做。
请求 ID 是关联的原语。 Anthropic 在每个响应中返回 request-id 标头。SDK 将其作为响应对象的一个属性显现出来。OpenAI 返回 x-request-id。这些 ID 是供应商支持工程师在日志中查找你特定调用的唯一方式。在面对客户的事故中,你告诉支持人员“请求在 UTC 14:23 变慢了”是无法操作的;而如果你说“请求 ID req_01abc... 耗时 4.1s,而我们预期在 2s 以内”,就能得到真正的答复。大多数追踪从未记录此 ID,大多数错误报告也从未引用它。
频率限制标头是健康信号。 两位供应商都会返回诸如 anthropic-ratelimit-requests-remaining、anthropic-ratelimit-tokens-reset 等标头,OpenAI 侧也有等效项。你的客户端可能只有在发生 429 错误时才会读取这些内容。其实它们在此之前就很有用——如果剩余 token 预算下降得比平时快, 这是你尚未察觉到的服务变暗(brownout)的前导指标。
缓存状态在启用缓存时的响应中。 当你使用 prompt 缓存时,响应会告诉你输入中有多少比例是缓存读取,多少是缓存写入。在你预期命中的情况下发生缓存未命中,比任何其他信号都能更清晰地解释 600ms 的 TTFT 退化。如果你的追踪没有记录每次调用的缓存命中/未命中情况,那么在控制 prefill 延迟的最大杠杆上,你就是在盲目飞行。
这种模式是:供应商通过 HTTP 标头、流式事件和响应元数据暴露健康和时间信号,而标准的 SDK 返回值将它们剥离了。你的工作就是将它们重新放回追踪中。
一个有用的 LLM 调用追踪 (Trace)
能够真正定位成本的追踪与默认的单 span (one-span) 追踪完全不同。OpenTelemetry GenAI 语义约定(目前为 v1.37)定义了大部分你需要的属性名称:gen_ai.request.model、gen_ai.usage.input_tokens、gen_ai.usage.output_tokens、gen_ai.response.finish_reasons。但这些约定只是一个骨架,而非填充内容。真正有趣的工作在于你放入 span 的内容。
一个具有可定位延迟特征的追踪包括:
- Span 开始于你构建请求时,而非调用 SDK 时。 否则你无法看到 Prompt 构建时间,当检索结果被格式化为一个数千 token 的数据块时,这段时间是不可忽视的。
- 一个
gen_ai.ttft_ms属性,计算从发送请求到接收到第一个流式块之间的时间。这是 span 上 最具诊断意义的单个数字。 gen_ai.inter_token_latency_ms_p50和_p99属性,由块间增量计算得出。如果 p99 ITL 突然从 40ms 跳升到 200ms,而 p50 保持平稳,这是供应商侧批处理竞争 (batch contention) 的典型特征。- 一个
gen_ai.provider_request_id属性,记录请求 ID 标头。在你的追踪存储中对其进行索引,以便你可以从支持工单直接跳转到精确的 span。 gen_ai.cache.input_tokens_read和gen_ai.cache.input_tokens_written属性,使缓存未命中在追踪中可见,而不是被埋没在响应负载中。- 一个
gen_ai.queue_indicator属性,当供应商暴露该信息时——一些网关会返回队列深度或负载提示标头。Anthropic 和 OpenAI 在这方面有所不同,但如果你通过 Envoy AI Gateway 等推理网关进行代理,你可以从网关的视角合成一个指标。 - 调用内部的子 span,只要你能合成它们。例如,用于 TLS 握手的
network.connect子 span(你的 HTTP 客户端可以通过其自身的回调提供此信息);从发送请求到第一个块的provider.ttft子 span;从第一个块到最后一个块的provider.decode子 span。即使供应商内部保持不透明,你也可以将你的视图分解为你真正关心的阶段。
这一切都不需要供应商暴露任何他们尚未提供的信息。这完全是基于流中已有信号的客户端埋点 (instrumentation)。
