跳到主要内容

先返回 200 然后失败的流式响应:中途错误如何破坏你的 SLO

· 阅读需 11 分钟
Tian Pan
Software Engineer

你的可用性仪表盘显示 99.95%。你的用户却说回答在句子中间停住了。两者都是正确的,而这正是问题所在。

HTTP 时代的可靠性技术栈建立在一个假设之上:状态码在请求结束时到达,并总结其命运。200 意味着成功。5xx 意味着重试。负载均衡器计算比例,SLO 仪表盘进行聚合,告警则根据消耗率(burn rate)触发。技术栈的每一层都会读取并信任这个 Header。

流式传输(Streaming)反转了这一假设。当你的服务器刷新第一个 token 的那一刻,它就已经承诺了一个 200 状态码。在那之后发生的任何错误 —— 在第 400 个 token 时的供应商超时、段落中间触发的内容审查过滤、TCP 连接断开、格式错误的工具调用(tool-call)片段 —— 都是在结果已经判定且无法撤回之后发生的。请求失败了,但状态码却说它成功了。而你的可靠性工具中没有任何一部分是为了察觉这种差异而构建的。

判定权从 Header 转移到了 Body

在传统的请求/响应周期中,服务器完成所有工作,判断是否成功,然后写入状态行。状态码是在充分了解结果后计算出的摘要。这就是为什么重试中间件、熔断器和错误率 SLO 都以此为基准 —— 当你看到代码时,故事已经结束了。

Server-Sent Events 和分块流式传输打破了这种时序。为了进行流式传输,服务器必须在生成正文(body)之前刷新响应 Header —— 包括状态行。一旦字节发送出去,HTTP 没有任何机制可以修改状态码。因此,一个流式端点在承诺 开始 的那一瞬间,也就承诺了 "200 OK",而这恰恰是它对请求能否真正完成了解最少的一刻。

这不是 LLM 特有的怪相;任何长生命周期的流式响应都存在这个问题。但 LLM 的特性由于三个原因使其变得尤为尖锐:生成过程很长,因此在第一个 token 之后有很大的失败窗口;它们依赖于你无法控制其可靠性的上游供应商;且失败模式多种多样:频率限制、上下文长度超限、安全过滤器以及推理服务器崩溃,所有这些都可能发生在流式传输的过程中,而不是开始。

其结果是,从协议的角度来看,这类请求与成功请求无法区分;而从用户的角度来看,这显然是一次失败。可靠性工程师对这两个视角之间的差距有一个称呼,叫做“盲点”,而流式传输在结构上制造了一个盲点。

失败是如何避开每一层监控的

让我们追踪一个“先返回 200 后失败”的请求经过典型技术栈的过程,看看每一层是如何记录错误信息的。

负载均衡器看到一个返回了 200 并关闭连接的请求。它增加了成功计数器。至于 body 中包含的是完整的答案,还是三个 token 加上一段堆栈跟踪,这超出了它的解析能力 —— 它只读取 Header,而 Header 显示的是成功。

SLO 仪表盘消耗负载均衡器的指标。它将可用性计算为非错误响应的比例,而此请求被计为非错误。错误预算(error budget)没有被扣除。消耗率告警(burn-rate alert)没有激活。

重试中间件正在等待状态码来做出反应。503 会触发重试,Header 之前的超时会触发重试。但一个带有破损 body 的 200 不会触发任何操作,因为中间件的整个契约是“在错误状态下重试”,而这里根本没有错误状态。最需要重试的请求,反而是重试层在结构上无法察觉的请求。

用户看到一个在词中间停止的段落,或者一个没有闭合的代码块,或者一个从未执行的工具调用。他们手动重试 —— 重新发送提示词,再次支付 token 费用,再次等待。

如果你有 流量评估流水线(eval-on-traffic pipeline),它会做一件最隐蔽的事情。它对截断的输出进行采样,通过质量评判模型运行,评判模型(正确地)将其评为低质量。这个分数进入了你的模型质量仪表盘。现在,你的基础设施故障被记录为了 模型退化(model regression)。一周后,有人开始调查为什么答案质量下降,而他们完全是在调试错误的系统。

五个层级,五个错误的结论,所有这些都可以追溯到同一个根源:每一层都读取 Header,而 Header 只是一个谎言。

Header 在撒谎,那就让 Body 说实话

如果状态码不再能承载判定结果,那么流内部的某些内容必须承载。解决方法是采用 应用级完成协议(application-level completion protocol):流必须以客户端验证的显式终止事件结束,缺少该事件必须被视为失败。

具体来说,一个健康的流最后发出的是一个哨兵(sentinel) —— 一个 done 事件,一个带有已知标记的最终块,或者像 OpenAI 风格的 response.completed 事件。客户端的契约从“连接关闭,所以我们完成了”变为“我们收到了终止事件,所以我们完成了”。这两者并不是一回事。连接关闭可能是因为生成完成,也可能是因为代理对长连接超时,或者推理服务器挂了。只有终止事件能区分它们。如果流在没有该事件的情况下结束,即使传输层报告了干净的 200,客户端也知道响应是不完整的。

终止事件还应该携带 原因,因为并非所有不完整的流都是平等的。stop(模型自然结束)、length(达到 token 上限)、content_filter(安全系统截断)以及纯粹的传输中断之间有着本质区别。这里有一个非常有用的实践建议:当供应商向流中注入错误时,它通常会将其作为一个带有 HTTP 200 状态码的块发送 —— 一些推理服务器字面上返回 "HTTP 200",但将错误详情嵌入在流式块中。你的解析器必须检查块的 内容,而不仅仅是块的 送达,否则它会将序列化的错误对象当作模型输出交给你的 UI。

这种终止事件约束的添加成本很低,却改变了整个下游的故事,因为现在有了一个除了 HTTP 层之外的其他层可以利用的信号。

中途错误分类法:因为“失败”一词过于宽泛

一旦客户端能够检测到不完整的流,它必须决定该 如何做 —— 而这个决定完全取决于流中断的原因。将每一次流中途失败都归入一个单一的“错误”类别,会丢掉你正确响应所需的信息。

一个可行的分类法包含三个分支:

  • 可重试的传输故障 —— 掉线、流中途出现的提供商 503 错误、推理服务器崩溃。这些可以安全地重试;请求本身是格式正确的,失败是偶然发生的。它们应当扣除错误预算(error budget) 触发自动重新请求。
  • 不可重试的语义中断 —— 内容过滤拦截(content-filter truncation)、上下文长度溢出(context-length overflow)。重试完全相同的请求会产生完全相同的失败。这些需要不同的处理路径:显示特定消息、调整参数或逐级上报 —— 但不要静默地重新生成。
  • 模糊的截断 —— 流直接停止了,既没有结束事件(terminal event),也没有错误数据块(error chunk)。将这些视为可重试,但要限制尝试次数并详细记录日志,因为高频率的模糊截断通常意味着代理或 CDN 正在缓存并导致你的长连接超时,这是一个基础设施 Bug,而不是模型行为。

分类法之所以重要,是因为重试决策并非非黑即白。如果一个团队重试所有请求,会因为不断向提供商请求那些永远无法成功的内容过滤拦截而造成资源浪费。如果一个团队不重试任何请求,则会迫使用户在出现瞬时波动后手动重新输入提示词。你采取的分支必须是结束事件原因码(reason code)的函数 —— 而这个代码之所以存在,是因为你在上一节中添加了补全协议。

围绕结束事件重新定义 SLO

在你改变 SLO 的衡量指标之前,这一切都不会反映在你的仪表盘上。只要可用性(availability)仍是根据 HTTP 状态码计算的,那么无论你的客户端处理得多么好,那个“先返回 200 然后失败”的请求都将一直被计为绿色。

解决办法是基于结束事件而非状态码来定义可用性 SLI。当客户端收到带有健康状态原因的结束事件时,请求才算成功。当流在没有收到结束事件的情况下结束,或者以传输故障原因结束时,请求即为失败。这个数值是由客户端或能够实际看到响应体末尾的边缘层(edge layer)发送的 —— 而不是由负载均衡器发送的,因为负载均衡器在架构上无法感知这一点。

这还允许你拆分一个由于流式传输而被悄悄合二为一的指标。首字延迟(Time-to-first-token)和完成时间(time-to-completion)是具有不同心理权重的不同延迟,而单一的“请求耗时”数值会将它们平均成一团乱账。此外,还有一个状态码完全无法让你看到的第三个维度:完成率(completion rate) —— 即已开始的流中真正达到健康结束事件的比例。一个流式功能可能具有极佳的延迟,但完成率却暗中糟糕得离谱;除非 SLO 挂钩到结束事件,否则这个数字在你的可观测性栈中根本不存在。

对于重试路径,恢复策略(resumption strategy)值得深思熟虑。幼稚的做法是从头开始重新生成整个内容,这会让成本和延迟翻倍,且对于非确定性模型会产生不同的答案。更高级的方法 —— 越来越多推理服务器和使用 last-event ID 的可恢复流库已支持此功能 —— 是从最后一个正常的 token 开始继续生成,将已传输的前缀视为固定。构建恢复机制的工作量更大,但对于长篇生成来说,它决定了重新连接是仅消耗几百个 token 还是消耗两倍的完整响应成本。

这对你运行 AI 功能意味着什么

更深层的教训是,流式传输不仅仅改变了传输细节。它将请求的判定结果从你所有工具都设计用来读取的地方 —— 请求头 —— 移到了没有任何工具设计用来读取的地方 —— 响应体的末尾。你从前流式时代继承的所有可靠性检测手段,现在都在读取一个不再包含答案的字段。

如果你发布一个流式 AI 功能,必须满足三点。首先,流必须发送显式的结束事件,且客户端必须将缺失结束事件视为失败。其次,中途错误分类法必须区分可重试故障与语义中断,以便重试路径不会浪费请求或让用户陷入困境。最后,可用性 SLO 必须根据结束事件计算,从而让“先返回 200 然后失败”的情况像真正的失败一样扣除错误预算。

在这些机制就位之前,你那显示绿色的仪表盘衡量的并不是可靠性。它衡量的是首个 token 刷出的频率 —— 然后悄悄假设剩下的部分也都顺利完成了。

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