六个后端的流式一致性:OpenAI 兼容流在哪一步开始分叉
文章从工程实践角度出发,对比聚合站中 Claude、GPT、DeepSeek、Qwen、GLM、Kimi 六个后端在 OpenAI 兼容流式接口上的 SSE 原始输出差异,聚焦 delta 结构、结束原因和用量上报三个分叉点,并给出客户端适配建议。
为什么流式一致性值得单独讨论
聚合站对外提供一个 OpenAI 兼容的 /v1/chat/completions 接口,但底层实际是多家不同厂商的模型服务。每个后端都有自己的原生流式协议,聚合层负责把它们翻译成 OpenAI 风格的 SSE 事件。翻译得再仔细,也总有一些细节无法完全抹平。
这些细节对非流式调用几乎没有影响,但对流式客户端却可能致命:一个字段缺失、一个结束原因取值不同,都可能让解析器抛异常或提前终止。本文不做模型对比,只讨论协议层面的分叉点。
先约定:什么是「原始 SSE 输出」
OpenAI 兼容的流式响应是一条条 data: 行,每行一个 JSON 对象,以 data: [DONE] 结尾。每个对象通常长这样(结构示意,非实际值):
data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}
观察分叉时,建议直接抓取原始字节流(例如用 curl -N 或浏览器的 EventSource),而不是先经过 SDK 解析。SDK 往往会补默认值,把差异掩盖掉。
分叉点一:delta 结构
delta 是流式传输的核心,承载增量内容。不同后端在翻译到 OpenAI 格式时,主要差异出现在以下几个位置:
- 首块是否携带 role:OpenAI 首个 chunk 通常带
delta.role = "assistant",但并非所有后端都会翻译出这一块。有的直接以delta.content开头。如果客户端依赖 role 来判断流已开始,就会误判。 - 内容与推理字段的分合:部分后端原生有「推理内容」和「正文内容」两个通道。翻译时可能把推理部分放进
delta.reasoning_content之类的非标准字段,也可能直接丢弃。客户端若只读delta.content,行为是一致的;若读取非标准字段,则要按后端分别处理。 - 工具调用(tool_calls)的增量方式:有的后端按 index 逐块追加参数片段,有的会一次性给出完整参数;
id和function.name可能只在第一块出现。组装工具调用时不能假设字段每块都有。 - 空 delta 块:有些后端会发送
delta: {}作为心跳或占位。客户端如果对空对象也触发渲染,会造成无意义的状态更新。
对客户端的影响:最稳妥的做法是只对 delta.content 做字符串拼接,对其他字段做可选读取,并容忍空块。
分叉点二:结束原因(finish_reason)
finish_reason 告诉客户端这一轮为什么结束。常见取值包括正常结束、达到长度上限、触发内容过滤、以及工具调用。分叉在于:
- 取值集合不完全一致:不同后端对「达到长度上限」的表达可能不同,翻译层需要映射到 OpenAI 的取值。映射不完整时,客户端可能收到意料之外的值。
- 结束块是否独立:OpenAI 习惯在最后一个 chunk 里给出非空的
finishreason。有的后端把它和最后一段内容放在同一块,有的单独发一个只含finishreason的块。客户端不应假设最后一块一定带内容。 [DONE]与 finishreason 的关系:data: [DONE]是流结束标记,finishreason是这一轮生成结束的原因。两者不一定在同一时刻到达,也不一定同时出现。只等[DONE]而忽略finish_reason,会丢失「为什么结束」的信息。
对客户端的影响:不要用「收到 [DONE]」代替「读到 finishreason」。需要区分正常结束和被截断时,必须显式检查 finishreason。
分叉点三:用量上报
非流式响应里,usage 字段通常直接出现在响应体中。流式场景下,OpenAI 引入了在流末尾附带用量块的做法,但各后端的支持程度不一:
- 有的后端在最后一个 chunk 里带
usage;有的完全不报;有的只在特定参数下才报。 - 聚合站按官方价 ×1.3 扣费,用量是计费依据,因此聚合层可能会自行统计或补齐用量。客户端看到的
usage未必等于后端原始上报值。 - 字段命名基本对齐(prompt/completion/total),但缓存命中、推理 token 等扩展字段各后端支持不一。
对客户端的影响:如果业务依赖流式响应里的 usage 做展示或对账,应把它当作「可能缺失」的字段处理,并准备好回退方案(例如以聚合站账单为准)。
这些差异如何打断流式客户端
把上面的分叉点串起来,常见的故障模式有三类:
- 解析崩溃:客户端假设每个 chunk 都带
choices[0].delta.content,遇到只含finish_reason的块或空 delta 块时取到undefined,拼接时报错。 - 提前终止:客户端只监听
[DONE],但某个后端在发送[DONE]前先断流或发送了错误事件,导致 UI 永远停在「生成中」。 - 状态错乱:工具调用的参数分片没有按 index 归并,或者 role 块缺失导致消息角色判断错误,最终组装出的对话历史不合法。
写一个后端无关的流式解析器
与其为每个后端写分支,不如按「最小公倍数」来解析:
- 逐行读取
data:负载,遇到[DONE]结束。 - 对每个 JSON:若
choices为空则跳过;取choices[0]。 delta.content存在则拼接;delta.tool_calls存在则按index归并。finish_reason非空则记录,但不立即关闭流,等[DONE]或连接关闭。usage存在则保存,不存在则留空。- 整个循环包在 try/catch 中,单块解析失败只记日志、不中断。
这样写出来的客户端对六个后端的原始输出都能消化,差异只影响「能拿到哪些可选信息」,不影响主流程。
实践建议
- 抓原始流做回归测试:为每个后端准备一条固定请求,保存原始 SSE 输出作为快照,协议层变动时能第一时间发现。
- 不要依赖非标准字段:
reasoning_content之类的字段在不同后端可用性不同,用之前先做存在性判断。 - 用量以账单为准:流式
usage适合做实时展示,不适合做计费对账。 - 超时与重连:流式连接可能因后端或网络中断,客户端应有空闲超时和有限重试。
小结
OpenAI 兼容流式接口在聚合场景下并不是一份完全统一的协议。delta 结构、结束原因和用量上报是三个主要分叉点,分叉的根源在于各后端原生协议的差异和翻译层的取舍。客户端只要按「字段可选、块可空、结束看 finish_reason 也看 [DONE]」的原则来写,就能在同一把 key 上稳定消费多个后端的流式输出。