指南/流式响应完整性
流式响应返回 200,为什么仍然可能失败
流式请求刚收到 HTTP 200,响应头就已经结束;模型的正文、工具参数、用量和错误还在后续 SSE 事件里。连接中途断开时,客户端可能已经显示了一段通顺文字,却从未收到协议规定的结束信号。把 200 记成成功,会把半截响应写进业务数据。
200 只确认响应已经开始
HTTP 状态码位于响应头。服务端开始返回 Server-Sent Events(SSE)之前,必须先发送状态行和响应头;后续模型生成失败时,已经无法把最初的 200 改成 5xx。错误只能作为流内事件发出,或者直接中断连接。
Anthropic 的流式文档给出了这个场景:高负载时,流内可能收到 overloaded_error;若是非流式请求,同类错误通常对应 HTTP 529。客户端若只看 response.ok,会漏掉这类失败。
同理,收到几段文字也不能证明完成。模型可能在生成中途遇到超时,代理可能切断空闲连接,用户也可能主动取消。已显示的文字只是部分结果。
SSE 有自己的消息边界
SSE 是 UTF-8 文本协议,媒体类型为 text/event-stream。WHATWG HTML 标准规定,事件由空行分隔;一个事件可以有多行 data:,解析器要按顺序拼接。末尾一块若没有以空行结束,不能当成已经分派的完整事件。
直接按网络 chunk 调用 JSON.parse 会出错。TCP、HTTP/2 或运行时交给应用的 chunk 边界不等于 SSE 事件边界:半个 JSON 可以分成两次到达,多条 SSE 事件也可能一次到达。可靠的顺序是先按 SSE 规则组装事件,再解析 data 中的 JSON。
还要允许心跳和新事件类型。Claude 流中可以出现任意数量的 ping,并提醒客户端按版本策略忽略未知事件类型。把「未知」当成正文,或者收到未知类型就终止,都会让未来的协议扩展变成线上故障。
每家 API 的完成信号不同
完整性判断必须跟接口绑定,不能统一成「连接关闭就是成功」。
| 接口 | 正常完成需要看到什么 | 失败或未完成的信号 |
|---|---|---|
| OpenAI Responses | response.completed,且响应状态为 completed | error、response.failed、response.incomplete,或连接结束前没有终态事件 |
| OpenAI Chat Completions | 完整解析所有 chunk,并看到协议结束标记;每个 choice 的 finish_reason 要按业务处理 | 未见结束标记、底层读取错误或取消 |
| Claude Messages | message_delta 给出 stop_reason,随后收到 message_stop | error 事件,或缺少 message_stop |
| Gemini GenerateContent | 最终候选出现 finishReason,并按该枚举处理 | 连接结束时仍无 finishReason,或结束原因是安全、格式、工具等异常分支 |
OpenAI 的流式指南列出的 Responses 常用事件包括 response.created、response.output_text.delta、response.completed 和 error;API 参考还定义了 response.failed 与 response.incomplete。完整结果必须进入 completed,达到 Token 上限等情况则可能以 incomplete 结束。
Claude 规定的事件顺序是 message_start,若干 content block 的 start/delta/stop,顶层 message_delta,并以 message_stop 结束。官方示例在 message_delta 中返回 stop_reason 和累计用量。缺少 message_stop 时,不能把已累积文本当成完整 Message。
Gemini 的 Candidate.finishReason 定义写得很直接:字段为空表示模型尚未停止生成。它也不只有 STOP 与 MAX_TOKENS,还包括 SAFETY、RECITATION、MALFORMED_FUNCTION_CALL、UNEXPECTED_TOOL_CALL 等结果。客户端需要按业务区分「自然完成」「达到上限」「内容被拦截」和「工具或响应格式有误」。
工具参数要等完整块再解析
流式工具调用的参数通常也是增量。Claude 的 input_json_delta 提供 partial_json,官方示例会把 {"location": "San Fra 和后面的字符分开发送。单个 delta 本来就不是有效 JSON。
正确做法是按内容块的 index 或调用 ID 分别缓存参数片段,在对应 content block 结束后再解析完整 JSON,随后执行本地 schema 校验。若连接在参数中间断开,该工具调用应标为不完整,不能尝试「补一个右括号」后执行。
并行工具调用还要求每个缓冲区相互隔离。只用一个字符串累加所有参数,会把交错到达的多个调用拼成一段无效 JSON。这类问题在纯文本流里看不出来,因此「兼容 OpenAI API」的服务必须单测工具增量,检查范围见兼容性契约测试。
usage 常在末尾,断流时可能拿不到
用量统计依赖完整生成,往往出现在流的后段。OpenAI Chat Completions 只有在请求设置 stream_options: {"include_usage": true} 时才发送全量 usage;API 参考明确提醒,流被中断或取消时,最终 usage chunk 可能收不到。
这会产生两个独立状态:
- 业务结果不完整:缺少终止事件,正文或工具参数不能作为最终结果。
- 本地用量未知:最终 usage 未到达,不能把 0 写进成本报表。
「未知」必须单独存,不能用 0 代替。0 表示服务明确报告没有使用 Token;空缺表示客户端没有拿到最终统计。两者混在一起,会把断流高发时段伪装成成本下降。
Claude 的流式 usage 是累计值,出现在 message_start 和后续 message_delta 中;累积时应使用截至断流前收到的权威值,不要把每个事件里的累计数相加。三家用量字段的包含关系见Token 用量字段对照。
客户端需要一台小状态机
只用 try/catch 包住读取循环不够。客户端应为每笔请求保存以下状态:
headers_received:收到状态码与响应头,只表示流已建立。streaming:已解析至少一个合法事件,可以更新临时显示。completed:收到该接口规定的完成信号,最终状态和终止原因可接受。failed:收到流内错误、失败事件、解析错误或不允许的终止原因。interrupted:连接关闭、超时或取消时尚未进入completed。
每次状态变化同时记录请求 ID、最近事件类型、已收字节数、已收正文长度、工具块完成情况、终止原因和 usage 是否到达。排查时才能分清服务端拒绝、代理断流、客户端取消与解析器缺陷。
上线前的故障测试
正常请求之外,还要人为覆盖四类失败:在正文中间关闭连接、在工具 JSON 中间关闭连接、返回一个合法 SSE error 事件、遗漏终止事件后正常关闭传输。这四种情况都不能进入 completed,不能执行半截工具参数,也不能把缺失 usage 记为 0。
还要测试慢连接和多字节中文跨 chunk。SSE 解析器必须按 UTF-8 流式解码,不能逐块把字节强转字符串;否则一个汉字刚好跨边界时会出现替换字符,最终文本即使有结束事件也已经损坏。
流式请求的成功条件很具体:事件能完整解析,工具块完整,终止原因可接受,并且收到接口规定的完成信号。HTTP 200 只是这段过程的起点;要判断断流是孤立噪声还是持续变化,还要把完成事件与错误类型纳入中转站监控基线。
LinkyMonitor 记录首事件、终止事件、错误、停止原因与用量是否到达,区分慢响应、正常截断和连接中断。