流式 API 的 finish_reason / stop_reason 字段在每个响应中都存在,但大多代码忽略了它,导致截断的 JSON 传入下游后才报语法错误,调试时追错追了三天。
答案在半句就断掉并不是什么神秘现象,因为 API 已经告诉你原因了。这个字段出现在每一条响应里,然而几乎所有集成代码都忽略了它——所以截断问题被当作 prompt 问题来排查,一排查就是两天。
OpenAI 兼容的响应携带 choices[0].finish_reason;Anthropic 的响应则在 message 上携带 stop_reason。概念相同,词汇不同,而且只要是非流式响应,这两个字段在每条返回里都会填充。在边界处加一条断言,是大多数代码库在这个领域能做出的最高价值改动:
OK = {"stop", "end_turn", "tool_calls", "tool_use", "function_call", "stop_sequence"}
def unwrap(response):
reason = (getattr(response.choices[0], "finish_reason", None)
or getattr(response, "stop_reason", None))
if reason is None:
raise IncompleteResponse("no stop reason -- stream ended early")
if reason not in OK:
raise IncompleteResponse(f"finish_reason={reason}")
return response.choices[0].message.content
没有这层检查,截断的 JSON 对象会直接到达你的解析器,解析器抛出语法错误,而你排查的那个错误其实发生在三层之外。
把这个值也写入遥测数据,作为每个请求的一个维度,不仅在错误路径里才读它。一周内 stop reason 的分布是最有价值又最廉价的指标之一:length 占比上升意味着你的预算已经装不下当前的工作了,content_filter 非零意味着某条策略正在和你的流量交互,而没有人去看是哪些 prompt 触发的,missing reason 有占比则意味着你的传输层在丢弃流。这三种情况在只有在出错了才读这个字段的情况下全都是不可见的。
三种不同的原因会产生这同一个值,需要用不同的方式处理。
max_tokens 确实太小了。 最明显的情况。注意许多 SDK 和网关在你省略参数时会应用默认值,所以"我没有设限制"并不等于"没有限制"。
Prompt 加上输出超过了上下文窗口。 对大多数模型来说窗口是共享的,所以一个长的 prompt 会压缩可用于回答的空间。在这里增大 max_tokens 会得到错误而不是更长的回答。修复要发生在输入侧。
推理 tokens 消耗了预算。 这个会让很多人意外。在推理模型上,内部思考 tokens 是按输出 token 计费的,同样计入上限,所以模型可能把整个预算都花在推理上,返回一个空的或几乎为空的可见回答,同时 stop reason 是 length。如果你看到内容为空而输出 token 数非零,就是这个问题。大幅提高预算,或者在 API 提供了这项控制的地方降低推理努力(reasoning effort)。
还有一个值得排除的非原因:重复循环跑到上限。stop reason 报告的是 length,实际的 bug 在重复循环那边,提高预算只会让账单更大而不会让回答更好。
没有任何错误附着的故障。在 server-sent-events 流式响应中,响应是一系列 chunk,完成由一个携带 finish reason 的最终 chunk 发出信号(在 OpenAI 方言中还携带一个 [DONE] 标记)。如果连接断开、中间代理超时、负载均衡器关闭了空闲连接,或者客户端库在一个它吞掉的异常上退出了循环,你的流就直接结束了。你积累的文本看起来像是一个短回答。什么都不会抛出。
明确地防护它:追踪是否观察到了终止事件,并把缺失视为失败。常见原因值得了解——代理和负载均衡器的空闲超时短于你的最长生成时间、缓冲层持有 chunk 直到超时、还有 serverless 平台有硬性的响应时长上限。如果截断与长回答相关,而当你直接调用提供商时就消失了,问题在你自己的基础设施里,没有模型参数能修复它。
推理模型的变种值得单独提一下,因为它看起来像挂起而不是截断。一个模型在发出可见 token 前思考了三十秒,在这段时间里不会发送任何东西,如果空闲连接超时是按聊天流来调的,会在第一个内容 chunk 到达之前就关闭连接。从客户端这边看,请求只是以空内容结束了。要按首个可见 token 的时间设置超时,而不是按典型的流式间隔,并优先选用会发送 keepalive 事件的提供商流,而不是不会发送的。
还有另一个不对称性需要在设计时考虑:使用流式传输时,在你检测到问题的时候,部分回答已经到达用户那里了。你不能静默重试并替换它。要么在渲染前缓冲完整响应——失去流式传输本应提供的延迟优势——要么渐进渲染,并定义一个追加更正或标记回答为不完整的方式。在上线之后才做这个决定,通常意味着用户看到半截回答而没有任何提示表明它是不完整的。
永远不要用字符串手术来修复截断的 JSON。追加闭合括号会产生一个可解析的对象,但数据丢失了,这比解析错误更糟糕,因为下游会静默失败。重试,或者失败。
在任务允许的情况下,用 continue 而不是重新生成。把部分输出连同一条指令发回去,让它从中断的地方继续。比重新生成便宜,虽然有产生接缝的风险;对于结构化输出不可行,应该用更大的预算重试。
问少一点,而不是要更多预算。长期截断通常意味着任务太大,一趟调用装不下。拆分它会产生更好的回答,同时也能得到完整的回答——长生成在末尾会质量下降,这和这个领域里其他所有问题一样的自我条件作用原因。
从测量出的分布来设置预算。记录输出 token 计数,取第 99 百分位,加上缓冲空间。从教程里的示例复制过来的默认值是生产环境中 length 截断最常见的原因。
把截断排除在质量指标之外。被截断的响应被评定为错误答案会污染测量页面上的每一个数字。先按 stop reason 过滤,再做评定。
各提供商的 stop reason 词汇不同,网关要么规范化它们,要么留给你一个需要处理的联合类型。无论你用哪个,检查文档中记录响应字段,而不是从文本推断完成状态,因为截断回答的文本和短回答的文本是无法区分的。
A Field Guide to LLM API Error Messages
Repetition Loops and Degenerate Output
Non-Determinism: Why Temperature 0 Isn't Deterministic