Claude Code 里的流式输出:SSE 分块为什么会中途卡住
从 SSE 分块语义、代理与网关行为、工具调用循环三个角度解释 Claude Code 通过聚合 API 站时流式输出中途卡住的原因,并给出可操作的排查顺序。
先说结论:卡住通常不在模型本身
Claude Code 的交互体验建立在流式输出之上。它一边接收 SSE 分块,一边把增量文本渲染到终端,同时识别工具调用片段。当流在中途停住,绝大多数情况发生在客户端与模型之间的链路上,而不是模型「算到一半不说了」。
理解这一点很关键:SSE 是单向的、基于普通 HTTP 长连接的文本协议。任何中间环节只要对「长时间没有新数据」的连接做了处理,就会表现为卡住。
SSE 分块到底在传什么
服务端把一次响应拆成若干个 data: 行,每行是一个 JSON 事件。Claude 风格的流里,你通常会看到几类事件交替出现:
- 消息开始:宣布一条助手消息的元信息
- 内容块开始:声明接下来是一个文本块还是工具调用块
- 内容块增量:真正的文本碎片,或工具调用参数的 JSON 片段
- 内容块结束:某个块收尾
- 消息结束:附带停止原因
注意「工具调用参数的 JSON 片段」这一项。它不是完整 JSON,而是被切碎的多段字符串,只有拼完才是一个合法对象。
心跳与空档
很多实现会周期性发送注释行(以冒号开头的 : 行)或空事件作为心跳,用来防止连接被判定为空闲。如果中间代理把这类不含 data: 的行过滤掉了,链路可能仍然活着,但客户端看到的就是长时间静默,视觉上等于卡住。
为什么会在中途断掉
1. 代理缓冲
反向代理或 CDN 默认可能开启响应缓冲:它要攒够一定字节才往下发。流式响应的体积往往很小、间隔很长,缓冲会把连续的碎块压成一坨,甚至一直等到上游结束才吐出来。表现就是「半天不动,然后一次性刷出全部」。
2. 空闲超时
网关、负载均衡、客户端 HTTP 库都可能设置读超时。如果模型在思考或在上游排队,几十秒没有新分块,超时就会把连接掐掉。掐断发生在HTTP 层,客户端往往只看到一个不完整的响应。
3. 中间层的重组与改写
如果链路中有组件尝试解析、验证或重写 SSE 事件,它必须理解跨分块的状态。SSE 的分块边界和语义边界并不对齐:一个 data: 行可能被 TCP 切成两半,也可能多个事件挤在一个包内。重组逻辑有 bug,就会出现丢事件、重复事件或提前判定结束。
4. 上传/下载不对称
部分网络环境对大响应体或长连接有额外干预。与其猜测,不如先用最小请求验证:一个短提示词、stream: true,看流是否稳定。稳定则说明问题只在长响应或工具调用场景。
半截响应在工具调用循环里意味着什么
这是最需要警惕的一类。Claude Code 的工具循环大致是:
- 模型输出一段推理文本,然后发起工具调用
- 客户端执行工具,把结果回传
- 模型基于结果继续输出,直到不再调用工具
如果第 1 步里的工具调用参数 JSON 只收到一半,客户端拿到的是无法解析的残缺对象。此时常见后果:
- 客户端判定解析失败,报错退出本轮循环
- 客户端选择重试,于是同一个工具被重复调用
- 客户端把残缺内容当作文本渲染,你会看到一段突然截断的 JSON
更隐蔽的情况是流在「文本块结束、工具块尚未开始」的间隙断掉。这时客户端可能认为本轮消息正常结束、没有工具调用,于是循环终止,任务静默失败——没有报错,只是不往下走了。
一个可复现的排查顺序
按成本从低到高:
- 换一个极短的请求,确认流式是否可用
- 直接对比不同模型:如果只有个别上游卡,问题可能在上游或对应通道
- 观察卡住的时间点:固定在几十秒,多半是超时;不固定且最后一次性吐出,多半是缓冲
- 长响应必现、短响应正常,优先怀疑缓冲与超时
- 只在工具调用时断,优先怀疑跨分块重组逻辑
- 把客户端日志里的原始
data:行留存下来,检查最后一条事件的类型,能直接区分「正常结束」和「被截断」
客户端侧可以先做的几件事
- 确认使用流式模式,非流式在长任务上更容易撞上整体超时
- 为工具调用参数保留累积缓冲,不要在第一个分块就尝试解析 JSON
- 区分「收到消息结束事件」和「连接关闭」:只有前者才代表本轮完整
- 对中途断开设置合理的重试策略,但避免无脑重试导致工具被重复执行
和聚合站的关系
聚合站在链路中多了一层转发:它要把你的请求发到对应上游,再把上游的流原样透传回来。这一层如果做了缓冲、聚合或事件改写,就会引入上面说的各类现象。
透明的转发应当保持分块边界、保留心跳、不缓存响应。当你遇到卡住时,把「原始事件序列」作为证据提供给服务方,比描述「它卡住了」有效得多。