api-error-stream-mid-flight · ZH · 2026-11-02

错误混在流里来了:从 SSE 响应内部的 error 事件读出问题而不是干净地拿到 HTTP 失败

当 stream:true 的响应头已经发出,上游失败往往不再以 HTTP 状态码体现,而是藏在 SSE chunk 序列内部。本文讲清楚这类内嵌 error 事件的表现形式、判定方法,以及如何把它映射成可处理的业务错误。

为什么流式请求的失败“看不见”

在非流式请求里,错误很干脆:连接失败、状态码 4xx/5xx、body 里一段 JSON error。你的 HTTP 客户端一层就能拦住。

一旦开启 stream: true,情况就变了。服务端必须在第一个 token 之前就把响应头写出去(通常是 200 OK + text/event-stream),否则客户端会一直等不到首字节。响应头一旦发出,状态码就锁死了——即使上游在生成到一半时超时、限流或被拒绝,你也无法再把它改成 500。

于是错误只能“降级”为数据:以一条或多条事件的形式,混在正常的 chunk 流里发给你。此时 HTTP 层看起来一切正常,你的 client.post(...) 也会成功返回一个流对象。真正的失败要你自己在流里读出来。

内嵌错误常见的三种形态

不同上游、不同聚合层的实现细节不同,但形态大致可归为几类。具体字段名要以你所用服务的文档为准,下面只是常见的结构示意。

1. 独立的 error 事件

SSE 是 event: + data: 的文本协议。有的服务会发一条事件类型为 error 的记录:

event: error
data: {"error":{"type":"upstream_error","message":"..."}}

这种最好识别:看 event: 字段是不是 error,再看 data 里有没有 error 对象。

2. 混在 data 载荷里的 error 键

更多时候事件类型仍是默认的 message,错误藏在 data 的 JSON 结构里:

data: {"error":{"type":"rate_limit","message":"..."}}

注意它与你正常业务数据的区别:正常 chunk 通常带 choices、delta、usage 之类的字段,而错误 chunk 只有 error。所以判定逻辑不能只看事件名,必须解析 data。

3. 流被提前截断

还有一类更隐蔽:没有显式 error 事件,连接直接结束,但流里缺少“完成”标记。比如 OpenAI 风格的流会在最后发 data: [DONE];如果流在你收到 [DONE] 之前就 EOF,那这就是一次未完成的生成,应视为失败而不是“空结果”。

在代码里怎么判

核心思路:把“流解析”和“错误判定”放在同一层做,不要让它们分家。伪代码大致如下:

for event in sse_events(resp):
    if event.event == "error":
        raise StreamError(parse(event.data))

    if event.data == "[DONE]":
        finished = True
        break

    payload = json.loads(event.data)
    if "error" in payload:
        raise StreamError(payload["error"])

    yield payload  # 正常内容

if not finished:
    raise StreamError("stream ended without completion marker")

几个容易踩的点:

  • 不要只 catch HTTP 异常。你的 try/except 必须包住整个迭代过程,因为异常来自迭代,不来自初次请求。
  • 不要把 JSON 解析失败当成普通错误吞掉。半截 JSON 往往意味着流被截断,应该报错。
  • [DONE] 只适用于采用该约定的接口。如果你的上游用别的方式表示结束(例如 finish_reason 字段),就以那个为准;关键是维护一个“是否正常完成”的布尔量。
  • 区分 error 与“内容里的 error”。模型生成的文本里可能出现“error”字样,但那在 delta.content 里,不是协议层错误。只检查顶层结构,不要全文搜字符串。

把内嵌错误映射成业务语义

读到 error 之后,还要决定怎么处理。常见区分维度:

  • 可重试:上游瞬时故障、连接中断。可以换 key 或短暂退避后重发整个请求——注意流式重发通常意味着丢弃已输出的部分。
  • 不可重试:请求参数非法、内容被拒绝。重试只会得到同样的结果,应该直接反馈给用户。
  • 部分可重试:限流。等一段时间再试,或降级到别的模型。

在多模型、多 key 的聚合场景下,这个判断尤其重要:如果错误来自某个贡献者的 key 被判为不可用,换一个 key 重试往往就能成功,而用户几乎无感。这也是聚合层相对直连的一个实际价值——但前提是你在客户端正确识别了“这是流内错误,值得换路重试”,而不是把它当成一次“正常但很短的回复”。

日志与可观测性

流内错误最容易在监控里消失,因为 HTTP 指标全是 200。建议:

  • 记录每次流式请求的完成状态(正常完成 / 中途 error / 截断),而不只是状态码。
  • 把 error 载荷里的 type 或 code 单独打标签,方便聚合统计。
  • 区分“首字节前的失败”和“首字节后的失败”——前者还来得及返回 HTTP 错误,后者必须靠流内协议。

小结

stream: true 把错误的表达位置从 HTTP 头搬到了数据流里。识别它的关键不是猜字段名,而是建立三个习惯:解析每一条事件的 data、维护“是否正常完成”的状态、把整个迭代过程纳入异常处理。做到这三点,内嵌 error 就不再是“神秘的半截回复”,而是一个可以分类、可以重试、可以观测的普通错误。