LLM 流式输出中断:区分 SSE、超时和完成状态
排查 LLM 流式输出提前停止,区分网络分片、协议事件、结束原因和用量记录,避免把部分文字当成成功答案。
发布 2026-09-29 · 更新 2026-09-29 · KeepRouter Editorial · 5 分钟阅读

LLM 流式响应只有到达对应协议的有效终态,才算完成。收到文字或 HTTP 200 都不够:连接可能在中途断开、输出达到上限,也可能是解析器丢掉了跨网络分片的数据。
已经能显示文字、但偶尔截断的应用,应先定位证据在哪一层消失。
网络分片不是协议事件
MDN 的 SSE 说明定义了事件边界。一次网络读取可能包含半个事件或多个事件,UTF-8 字符也可能跨读取。
使用流式解码和协议解析,或官方 SDK。不要对每个网络块直接 JSON.parse;未完整数据需要缓存。本地能工作,并不能证明不同网络分片下也正确。
| 信号 | 能说明什么 | 不能说明什么 |
|---|---|---|
| HTTP 200 | 初始响应被接受 | 生成结束 |
| 网络读取 | 收到字节 | 收到完整事件 |
| 文本增量 | 有部分内容 | 答案完整 |
| 结束事件 | 协议到达某种终态 | 所有终态都算成功 |
| 用量记录 | 提供了测量字段 | 缺失应填成零 |
按实际 API 识别结束状态
Chat Completions、Responses 和 Anthropic Messages 使用不同事件格式。Claude 流式文档描述了消息块和错误处理,不能直接沿用另一种协议的结束标志。
分别测试正常结束、输出上限、拒答、工具移交和显式错误。工具移交可以结束一次模型请求,但用户任务尚未结束。
def result_state(text, finish_reason, transport_error=False):
if transport_error:
return "interrupted"
if finish_reason == "length":
return "truncated"
if finish_reason == "tool_calls":
return "needs_tool"
if finish_reason == "stop":
return "complete"
return "incomplete"
assert result_state("半段答案", None) == "incomplete"
assert result_state("完整答案", "stop") == "complete"
assert result_state("半个 JSON", "length") == "truncated"这是本地状态示例,不是 SSE 解析器,也不是真实供应商响应。具体实现应按 API 协议适配;空的正常响应仍需业务层判断是否有用。
一层一层复现
先对同一短问题关闭流式。如果成功,再在后端消费流但不渲染,最后加入前端显示。这样可以区分模型、后端解析、代理与 UI 状态。
记录事件类型和时间,不记录私有文本。分别观察请求开始、首事件、首段文字和终态。代理缓冲、空闲超时与总请求截止时间也是不同问题;提高超时不会修复解析器。
如实处理用户取消
用户停止时,通过客户端中止请求,并把回答标为已取消。保留部分草稿可以,但不能称为完整答案。
取消不代表上游计算和计费在同一瞬间停止。若断流前未收到最终用量,标为未知,等待可靠记录,不补零。工具参数不完整时不能执行,见工具循环指南。
重试时分开两次结果
部分输出后重试可能产生两个重叠答案,应保留独立尝试,由应用决定采用哪一个。不能把第二个 JSON 直接拼到第一个被截断的对象后面。
涉及副作用时,先确认动作是否已完成。模型请求重试与业务动作重试不是同一件事。
从错误参考和 API 文档确认线路行为,再沿真实部署路径复现。用延迟指南区分首字时间与完整完成时间,最后比较合格回答及总费用。
常见问题
HTTP 200 表示流式回答完成了吗?
不表示。它只说明初始响应,需要继续检查结束事件与原因,区分截断、工具移交和取消。
可以把每个网络分片直接当 JSON 解析吗?
不能。网络分片不对应 SSE 事件边界,需要缓存完整事件或使用相应 SDK。
取消流式请求就不收费了吗?
不能这样假设。计算可能已经发生,应按服务计费规则和可靠用量记录核对,而不是只看可见文字长度。
参考的一手资料
本文最近复核 2026-09-29