sse 流式输出协议里最容易踩的细节

0 阅读6分钟

现象:流式输出读到末尾,客户端却一直不退出

把 stream=true 打开,用 requests.iter_lines() 读取模型输出,前面 token 正常往外蹦,到最后一行却卡住了。这现象在自建网关和调试脚本里很常见。典型触发条件是:经过一层 Nginx 反代,客户端代码把 data: [DONE] 当作流结束标志,最后收到一个 choices 为空的 chunk,里面只有 usage。程序不再报错,也不退出,就挂在那里。

我见过不少开发者开始怀疑服务端没有发完,或者怀疑自己超时设得太小。实际上把 timeout 调到几百秒也没用。因为问题不在网络延迟,而在解析逻辑和 sse 的事件边界没有对齐。还有些情况是:首字节等了十几秒,然后一瞬间全部内容吐出来,流式输出变成假流式。真流式输出的特征应该是 token 增量逐渐到达,而不是最后一次性送来。

为什么会这样:不是网络慢,是两种切分边界不重合

直觉上,按行读取似乎已经足够。HTTP 响应是以行文本返回的,读一行处理一行,不挺合理?但对 sse 流式输出来说,这种直觉是错的。SSE 的事件边界是空行,不是网络包也不是单个换行。一个事件可以由多行组成,data: 行可能有多条,它们必须拼接起来才能还原模型返回的 JSON。HTTP 层的 chunked 传输又会在任意位置切开响应体。两个边界叠加后,客户端每一次 read 拿到的内容,既可能包含多个不完整事件,也可能只包含半个 data: 行。

Nginx 的 proxy_buffering 默认开启,更会加剧这个误解。上游模型已经在推 token 了,Nginx 却把内容先写进缓冲区,等到缓冲达到阈值或连接关闭才 flush 给客户端。于是看起来前面的 token 迟迟不到,最后一刻全部到达。开发者如果只看客户端表现,会误判为模型生成很慢。关闭缓冲之后,流式输出才恢复成真正的增量到达。

内部机制:从 HTTP 分块到 sse 事件状态机

sse 协议的核心是事件流。响应头需要 Content-Type: text/event-stream。HTTP/1.1 下通常搭配 Transfer-Encoding: chunked,每个 chunk 只负责传输一段字节,不对应事件。事件内部有 id、event、data、retry 四个字段,data 可以出现多行。解析器遇到多行 data 时,要把相邻行合并,中间用换行符连接。空行是事件提交信号,客户端必须等到空行才能把一个事件交给上层。

客户端的状态机可以这样描述:维护一个 buffer,每次从网络读到 chunk 就 append 进去,然后在 buffer 里找两个连续换行,兼容 LF 和 CRLF。找到之后,取分隔符前的文本作为事件体,按字段逐行解析;分隔符后的剩余内容保留在 buffer 里,等待下一个事件。若没有找到分隔符,就继续读,绝不能急着处理。这个状态机是流式输出最容易忽略的协议细节。一个事件跨多个 TCP 包到达是完全正常的,处理半包时绝不能把半截 JSON 交给解析器。

在模型推理的流式输出链路里,每产生一个增量 token,服务端就包装成一个 data: 行推送出去。到流末尾时,不一定出现 data: [DONE]。一些 OpenAI 兼容端点会在最后发一个 choices 为空但 usage 非空的用量 chunk,然后关闭流。客户端如果只等 [DONE],就会漏掉最后的数据并挂起。正确做法是把底层流的 EOF 当成结束信号,[DONE] 只是可选的提示。以千木(SilvaMux)的响应为例,响应头里的 X-Request-Id 格式像 REQ-xxxx。模型侧返回 4xx/5xx 时,会脱敏透传,错误体里带 message、type、code 三个字段,type 一般是 gateway_error,HTTP 状态码保持一致。

边界条件:四种让常规写法失效的情况

第一种是反代缓冲开启。把 SSE 服务放在 Nginx 后面,如果 proxy_buffering on,响应体会被缓冲,客户端就看不到增量到达。失效原因是缓冲切断了 flush 时机。

第二种是空闲超时。模型在分析问题、调用工具或者等待上游时,可能十几秒没有输出。代理或客户端如果在空闲一段时间后断开连接,流式输出就中断了。sse 本身没有强制心跳,实践里可以发送注释行 : ping 来保持连接。

第三种是只依赖 data: [DONE] 判断结束。部分模型或中转端点最后不发 [DONE],而是发完 usage chunk 后直接 EOF;也有一些先发 [DONE] 但连接没立即关闭。如果代码只识别 [DONE],会出现漏读或误挂。

第四种是数据行被拆包。data: 后面的 JSON 很可能被拆到两个 TCP 包里。逐行 parse 时,第一行可能只到 choices 字段的一半,JSON 解析直接失败。这种情况必须靠空行聚合完整事件后再解析。

取舍结论:SDK 与手写解析各适合什么场景

使用官方 SDK 时,上述状态机和事件聚合都由库内部完成。业务代码只关心 delta.content 和 usage,写起来干净。代价是 SDK 把底层的 sse 协议细节藏了起来。遇到末尾 usage、代理缓冲、心跳等异常时,开发者不容易看到协议层出了什么状况。

自己解析 SSE 适合做网关、调试工具或需要统一处理多个模型调用名的时候。用 fetch 的 ReadableStream 或服务端的原始响应流,能精确控制结束时机,也能拿到每个事件的原始字段。代价是要自己维护 buffer,处理半包、超时、重连,还要防着各种边界条件。说白了,普通应用尽量直接用 SDK;需要控制协议层,就别依赖 [DONE],关闭代理缓冲,并把 EOF 作为最终完成信号。

下面是使用 OpenAI SDK 跑通 sse 流式输出的关键片段。注意 base_url 必须写完整,只改这一处和 api_key 就能从普通 OpenAI 端点迁过来:

import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ['SILVAMUX_API_KEY'],
    base_url='https://www.silvamux.com/api/v1',
)

stream = client.chat.completions.create(
    model='deepseek-v3.2',
    messages=[{'role': 'user', 'content': '解释一下 SSE 的空行语义'}],
    stream=True,
)

for chunk in stream:
    if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end='', flush=True)
    if getattr(chunk, 'usage', None):
        print('usage:', chunk.usage)

以上示例在千木(SilvaMux)的 OpenAI 兼容端点上验证,模型调用名为 deepseek-v3.2。参数与错误码字段可到 平台开发者文档 查看。

image.png