跳到主要内容

止于供应商边界的链路追踪

· 阅读需 12 分钟
Tian Pan
Software Engineer

你做了追踪(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-remaininganthropic-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.modelgen_ai.usage.input_tokensgen_ai.usage.output_tokensgen_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_readgen_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)。

每个客户端都应具备的侧信道 (Side-Channels)

除了单次调用埋点之外,还有三种模式可以让你洞察作为系统整体的供应商,而不仅仅是单个响应的来源。

合成探测 (Synthetic probes)。定期(每 30 秒是一个很好的起点)针对供应商的边缘节点运行固定 Prompt。记录 TTFT、总延迟和错误指标。三个属性至关重要:Prompt 是固定的(因此结果具有跨时可比性),流量独立于你的真实用户(因此你可以区分是“供应商变慢了”还是“我们的用户发送了更难的 Prompt”),以及区域与你的生产流量匹配(从而使区域性故障可见)。当客户报告速度慢时,你检查的第一件事就是该区域的合成曲线是否也变慢了。如果是,请携带合成数据向供应商升级报障。如果不是,问题出在你这边。

针对样本请求的扇出影子请求 (Fan-out shadowing)。对于极小比例的真实请求(1% 就足够了),并行地将相同 Prompt 发送给第二个供应商,并记录两者的延迟,丢弃第二个响应。这不是负载均衡,也不是故障转移;它是一个对比器。当在输入和时间完全相同的情况下,主供应商的 TTFT 为 1.5s 而影子供应商为 400ms 时,你拥有的证据就不是“与历史平均水平对比”,而是针对另一家供应商的实时 A/B 测试。利用这一点进行升级,不要让它变成一个功能点。

在每个面向客户的错误中包含供应商请求 ID。当 LLM 调用失败或超时时,用户看到的错误、结构化日志中的日志行以及追踪 span 都应携带供应商的请求 ID。这是整篇文章中成本最低且最常被遗漏的埋点。做错的代价是,每次供应商侧的调查都要从你试图反向工程——在数百万个请求中找出客户投诉的到底是哪一个——开始。

评估纪律:追踪完整性作为一项指标

只有当你衡量埋点是否在衰退时,这些工作才是持久的。捕捉这一点的指标是“追踪完整性” (trace completeness)——即可归因于命名原因的总请求延迟比例,相对于未计入部分的比例。

按追踪进行计算:将你拥有意义细分的叶子 span 时长求和,除以总追踪时长,并将剩余部分称为“未计入” (unaccounted)。对于一个健康的追踪,未计入部分应该是一个很小的个位数百分比——握手开销、调度抖动、埋点延迟。当它在某个租户或路由上爬升到 30% 时,说明发生了一些你没有 span 覆盖的情况,正确的做法是在下一次事故发生前添加该 span。

该指标的一个特性是:当你的系统产生新行为时——新增加的重试循环、新增加的回退路径、Agent 调用的新工具——而你的埋点没有跟上,它就会变差。它是可观测性腐化 (observability rot) 的领先指标,并且它能在模型升级、供应商变更和代码重写中幸存下来,而这些情况通常会让你的追踪质量默默下降。

一个补充实践:每周挑选一个“每日追踪” (trace of the day)——一个真实的生产追踪,最好是来自一个缓慢的请求——并逐个 span 地阅读它。如果你无法清晰地解释时间花在了哪里,那么无论请求是否成功,该追踪都是失败的。

架构层面的认知

这一问题的本质是结构性的,而非偶然。AI 系统中最重要的延迟,往往存在于你无法掌控的多租户推理集群内部,隐藏在仅返回最终字符串和 Token 计数的 API 接口之后。供应商合约为你提供答案,但并不提供产生答案的过程。默认的 SDK 集成是围绕合约而非运维现实构建的,因此它只呈现答案,却丢弃了过程。

解决方法不是等待供应商提供更多。解决方法是将线路中已传输的每一个字节——每一个流式数据块(Chunk)、每一个请求头、每一个响应元数据字段——都视为可观测数据(Telemetry),而非仅仅是基础组件。每个 Token 的时间戳是免费的;你需要选择去记录它们。请求 ID 是免费的;你需要选择去透传它们。缓存命中率是免费的;你需要选择将它们作为 Span 属性体现出来,而不是作为被 SDK 随手丢弃的响应对象字段。

做到这一点的团队,会在一个他们并不拥有的系统中构建出可见性。而做不到这一点的团队,无异于在为一个黑盒系统值班,下一次故障发生时,当客户追问原因,你的链路追踪(Trace)将无法给出答案。

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