跳到主要内容

流式响应追踪模式鸿沟:为什么你的 APM 在 LLM 延迟上撒了谎

· 阅读需 12 分钟
Tian Pan
Software Engineer

凌晨 02:14,报警器响了:客户反馈助手在回答长问题时“话说到一半就卡住了”。你打开追踪(trace)。LLM 调用的 span 显示为 8.4 秒 —— 绿色,在 SLO 范围内,没有错误属性,结束原因(finish reason)为 stop。仪表板上该端点的 p95 延迟聚合组件显示为 9.1s,与过去一个月的情况完全一致。根据 APM 显示的所有信号,该请求都成功了。

用户看到前 200 毫秒表现完美,接下来的四秒钟生成了一个连贯的段落,然后眼睁睁看着同样的三句话片段在剩下的四秒钟里不断重复,直到连接结束。这种卡住的内容循环(stuck content loop)是真实的故障,但追踪系统对此一无所知 —— 因为追踪系统是为“返回即结束”的系统设计的,而不是为了这种行为表现为生成过程中产生的中间状态之墙的系统。

这种差距是 2026 年 AI 工程团队最不该忽视的高昂代价。仪表板显示系统健康,用户却说系统坏了。两者都对,因为他们关注的是不同的事件,而仪表板将其折叠成持续时间指标的事件,恰恰是用户产生反应的那些事件。

为什么请求/响应 span 模型在流式处理中失效

分布式追踪是围绕契约构建的,这种契约非常适合微服务:一个 span 有开始时间、结束时间和一个状态。有趣的事件 —— 身份验证检查、缓存未命中、下游调用 —— 发生在看起来像嵌套在内部的其他 span 边界上,而开始和结束之间的持续时间是“工作花费了多长时间”的合理替代指标。加上 finish-reason 属性、token 计数、错误事件,你就拥有了接近生产工程师调试典型 RPC 所需的东西。

流式 LLM 响应以一种特殊的方式违反了这一契约。工作并不是有边界的;它是一个随时间推移而展开的过程,用户在中间状态到达时就在消费它。180ms 的首个 token 时间(Time-to-first-token)感觉是瞬间完成的。即使总耗时完全相同,2.4s 的首个 token 时间也会让人觉得系统出故障了。一个在 6 秒内均匀生成 600 个 token 的模型感觉很流畅。一个在前 1.2 秒生成了 540 个 token,然后在接下来的 4.8 秒内停滞的模型,其总延迟相同,但产品体验完全不同。span 承载的单一持续时间指标无法区分这两者。更糟糕的是,基于该指标构建的每个仪表板都会将它们报告为等效的。

在 2026 年初趋于稳定的 OpenTelemetry GenAI 语义规范(semantic conventions)落地了社区所需的大部分静态属性 —— 模型名称、输入和输出 token 计数、结束原因、作为事件的 prompt 和 completion。它们明确地没有解决时间形状(temporal-shape)问题。span 仍然在流结束时结束,属性仍然描述的是总量,而不是轨迹。

边界之间丢失了什么

在目前 span 视为不透明的窗口内,存在五种截然不同的失败模式:

第一种是 首个 token 时间(TTFT)退化。在许多实现中,180ms 与 2.4s 开始生成的模型具有相同的 gen_ai.response.duration,因为持续时间指标覆盖了整个流。你(用户)不同意这个持续时间;他们会告诉你,慢的那个感觉像是坏了,即使它平均每秒生成的 token 更多。TTFT 是流式 UI 中最大的感知延迟杠杆,而 span 模型将其视为第一个块处理器(chunk handler)的内部细节。

第二种是 流中途停滞和内容循环。每输出 token 耗时(TPOT)和 token 间时间(TBT)是有用的平均值,但平均值掩盖了长尾。在 8 秒响应中间发生的 4 秒停滞几乎不会影响 TPOT,因为 TPOT 被前后快速到达的大量 token 归一化了。来自推理服务社区的论文 Metron 已经明确指出这一点一年多:TPOT 和归一化延迟在统计上掩盖了用户可见的故障。尾部 TBT(Tail-TBT)才是揭示停滞的关键。在典型的观测栈中,没有人计算尾部 TBT。

第三种是 流中途的工具调用决策。在 Agent 驱动的流中,模型发射 token,决定调用工具,工具运行,模型恢复流。每一次转换都是一个状态变化,而 span 模型没有存放这种变化的一等公民(first-class)位置。一些团队通过为工具调用创建子 span 来近似处理,这对于调度有效,但丢失了输出流中决策发生的时刻与模型在此之前正在生成的内容之间的关系。“我们在 1.8 秒时调用了搜索工具”是一个比“我们调用了搜索工具”不同的调试信号。

第四种是 部分输出的中止信号。用户点击停止,客户端断开连接,模型因为中途耗尽了上下文配额而发出 length 的 finish_reason。对于 APM,这些通常被解析为“请求完成”,因为流在技术上结束了。对用户来说,三个这样的情况中有三个是失败的。理清它们需要将尾部事件记录为比“已完成”更细粒度的东西。

第五种是 窗口内的内容质量偏移。前 200 个 token 的输出看起来不错,接着陷入了模棱两可的套话,最后以一个幻觉出的引用结束。没有静态 span 属性可以捕获这一点,但以固定 token 间隔进行的检查点质量探测(checkpointed quality probe)—— 即使是廉价的探测 —— 也能让你二分定位偏移是 何时 开始的。如今,几乎没有生产环境的技术栈会这样做。团队在五个小时后通过一个差评才发现问题。

必须落实的工程规范

将流式传输(streaming)视为一种实时数据流,其可观测性原语(observability primitives)与传统的请求/响应(request/response)模式截然不同。这是架构层面的定调。在实践中,它可以分解为三个具体的动作。

将令牌时间轴事件(Token-time-axis events)作为一等 Span 属性。 不要再依赖单一的 duration(时长)字段。至少记录:首个 token 时间戳(TTFT)、结束时间戳、前 N 个 chunk 的逐个到达时间戳(以及之后权重的对数采样),以及每个时间点的累计输出长度。OpenTelemetry GenAI 规范已经鼓励将输入和输出作为事件(events)而非属性(attributes)发布;同样的机制自然也适用于逐个 chunk 的事件。大多数团队在流式 SDK 内部都拥有这些数据,但在数据跨越 Span 边界之前就将其丢弃了。

在固定间隔或关键 token 处捕获部分输出检查点(Partial-output checkpoints)。 每隔 N 个 token,或者在应用程序关心的每一次状态转换时(模型开始输出 JSON、出现工具调用分隔符、接近停止序列等),记录一个检查点事件,包含当前的部分内容长度、当前 token 计数和已耗时。这就是让“停顿”(stall)变得可调试的关键:你可以清楚地看到时间线——在 t=1.1s 时 length=412,在 t=2.0s 时 length=412,在 t=3.0s 时 length=412。一旦有了这些数据,检测“检查点停滞”的仪表盘组件就是一个单行代码的告警。

一套用于区分流实际结束方式的尾部事件分类法(tail-event taxonomy)。 仅有“已完成”是不够的。至少要区分:stream_completed_natural(模型输出了停止符)、stream_completed_length_cap(达到最大 token 数)、stream_stalled(连接正常但在 N 秒内无 token)、client_disconnect(流传输中途 TCP/SSE 关闭)、server_abort(上游中断)、tool_handoff(因工具调用而暂停)、safety_intervention(中途触发内容审查拒答)。在原始的 HTTP 语义中,其中一半会被解析为相同的状态码;但对于产品决策来说,这些差异至关重要。

一个 Trace 应包含内容的简短示例

以下是一个携带停顿响应的 Span 在属性形式下的压缩视图:

gen_ai.request.model = claude-opus-4-7
gen_ai.response.ttft_ms = 184
gen_ai.response.duration_ms = 8420
gen_ai.response.finish_reason = stop
gen_ai.tail_event = stream_stalled
gen_ai.response.tbt_p50_ms = 24
gen_ai.response.tbt_p99_ms = 31
gen_ai.response.tbt_max_ms = 4180
gen_ai.checkpoints = [
{ t: 184, tokens: 1, bytes: 4 },
{ t: 612, tokens: 38, bytes: 162 },
{ t: 1280, tokens: 102, bytes: 480 },
{ t: 2400, tokens: 188, bytes: 894 },
{ t: 3200, tokens: 188, bytes: 894 },
{ t: 4400, tokens: 188, bytes: 894 },
{ t: 8420, tokens: 188, bytes: 894 }
]

总耗时(duration)仍然是 8420ms,结束原因仍然是 stop。之前那个判定其为“健康”的仪表盘查询,现在拥有了将其标记为“停顿”的所有信号:尽管 p99 TBT(token 间隔时间)为 31ms,但最大 TBT 达到了 4.18 秒,检查点在最后 5 秒多一直处于冻结状态,且标记了尾部事件。SRE 团队针对此编写告警组件将变得非常直接;而在此之前,这些数据根本不存在。

忽视这一差距的代价

将流视为黑盒的隐形账单体现在三个地方,而这些在显示“一切正常”的仪表盘上都是不可见的。

首先是更换模型后无感知的质量退化。一个首个 token 响应时间(TTFT)更差但总 token 产出更优的新版本模型,在现有的延迟仪表盘上看起来是一样的,但在用户调查中体验会变差。团队会为满意度下降寻找借口(“季节性原因、无关的 UI 改动、近期产品发布”),因为他们没有手段将其归因于 TTFT。工程师们会花费数周时间去逆向工程那些 Trace 已经捕捉到却又被丢弃的客户反馈。

其次是不可见的可靠性事件。内容循环死循环、流中途停顿和工具切换失败累积成一类 Bug,它们不会出现在错误率中,不会触发任何人的告警,并且会慢慢让用户觉得产品不可靠。每一个用户投诉都需要工程师从 LLM 供应商那里提取字面上的原始事件日志(如果他们还保留着的话),因为 Trace 摘要抹除了这些信号。平均诊断时间(MTTD)从几分钟拉长到几天。

第三是错误的架构结论。盯着请求/响应式仪表盘的团队会不断优化总延迟(这是仪表盘显示的唯一指标)。他们会购买更快的推理集群,但不会在首个 token 时间(TTFT)上投资,不会在尾部 TBT 上投资,也不会构建部分输出的可观测性。六个月后,用户投诉依然如故,集群成本更高了,而团队一直在交付错误的修复方案,因为监控指标指向了错误的数字。

流式传输是一种不同类型的系统

底层的架构认知很简单:流式响应不是一种更快的请求/响应形式。它们是实时数据流,其核心事件具有时间性,其故障模式呈现为时序异常,其用户体验由中间状态的轨迹而非边界值决定。业界为无状态 RPC 构建的可观测性原语并不适用。用“带属性的 Span”来捕捉这种形态的效果非常糟糕,以至于团队即便上线了明显的退化也毫无察觉。

修复方案并不复杂。数据已经在 SDK 内部了。发布数据的机制(在同一个 Trace 中带时间戳的 Span 事件)在 OpenTelemetry 中也已经存在。缺失的是一种规范——将大量的中间状态视为关键承重结构,将 TTFT 和尾部 TBT 作为一等属性记录,对部分输出进行检查点记录,并区分流实际结束的方式。一旦团队做到了这一点,下一次值班人员在查看停顿的 Trace 时就能立即发现问题。这就是全部的门槛。这也是大多数生产环境 AI 技术栈在 2026 年仍未跨过的门槛,这种差距正体现在每一次“感觉比仪表盘显示的要差”的季度留存审查中。

率先弥补这一差距的团队看起来并不会有什么特别之处。他们的告警会在真实故障时触发,他们的模型切换不会导致满意度悬崖,他们的事故后分析(Post-mortem)会精确引用流出错的具体 token。而没有做到的团队将继续写报告讨论模型如何“退化”,却永远不知道是哪个 4.2 秒的窗口造成了伤害。

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