跳到主要内容

你的代码从未检查过的 Finish Reason

· 阅读需 11 分钟
Tian Pan
Software Engineer

你的处理器(handler)做对了一切。HTTP 状态码是 200。Body 解析成功。文本字段里有内容。你增加了 responses_succeeded 的计数,将消息追加到对话中,把 JSON 返回给客户端,然后继续下一步。用户得到的是一个在句中戛然而止的句子,一个伪装成正常答案的经过编辑的回复,或者是一个被措辞为补全的礼貌拒绝。你的仪表盘对这一切一无所知。供应商已经告诉了你,但你没有读取那个字段。

每个主流的推理 API 都会在文本之外返回一个停止信号:OpenAI 称之为 finish_reason,Anthropic 称之为 stop_reason,Gemini 称之为 finishReason。这个字段很小,每个响应对应一个枚举值。它也是模型用来告诉你刚才发送的响应是一个完整答案还是一个碎片的唯一带外(out-of-band)通道。将其视为无关紧要的装饰,与忽略 HTTP 状态码属于同一种类型的 Bug —— 不同之处在于,你的监控系统在十年前就能捕捉到 HTTP 错误,但对这个错误却无动于衷。

这些值及其真实含义

这些值是不可互换的,而且不同供应商之间的差异往往小于同一供应商内部不同值之间的差异。Anthropic 发布了 end_turnmax_tokensstop_sequencetool_usepause_turnrefusal 以及 model_context_window_exceeded。OpenAI 发布了 stoplengthtool_callscontent_filter 以及旧版的 function_call。Gemini 发布了 STOPMAX_TOKENSSAFETYRECITATIONLANGUAGEBLOCKLISTPROHIBITED_CONTENTSPIIMALFORMED_FUNCTION_CALL。如果处理器将任何非空响应都视为成功,那么它实际上是将十种截然不同的结果混为一谈。

其中有三个值值得特别关注,因为它们最容易伪装成“成功”。

Length,或 max_tokens,或 MAX_TOKENS。 模型原本生成正常,但因耗尽了 Token 预算而被切断。响应中的文本是真实的,但也是不完整的。如果答案是一个 JSON 对象,它是格式错误的。如果答案是一个代码块,它会缺失闭合括号。如果模型正在输出工具调用(tool call),该调用会在参数中间被截断 —— Anthropic 的文档明确指出:当 stop_reason == "max_tokens" 且最后一个内容块是 tool_use 时,请使用更高的 max_tokens 重试。如果答案是纯文本,它会在句中结束,用户只能猜剩下的半句是什么。这个字段就是模型在向你传达这个信息,也是获知此信息的唯一途径。一个天真的解析器在遇到 JSON 格式错误时会大声报错,但在处理纯文本时却保持沉默,这是最糟糕的情况:易于监控的失败变成了异常,而难以监控的失败则直接变成了用户体验。

Content_filter,或 SAFETY,或 PROHIBITED_CONTENT,或 BLOCKLIST,或 refusal。 模型生成了一些内容,但供应商的安全层(或模型本身)拦截了它。响应字段通常为空,或者包含一段通用的推诿之词,或者包含一段在策略边界处被截断的部分生成内容。你的成功计数器在增加,因为请求没有报错。你的用户得到了一个礼貌的非答复。本应根据答案采取行动的下游系统收到了一个不符合预期的字符串。Anthropic 返回 refusal,并在较新的模型中提供 "cyber""bio"stop_details.category 来告诉你触发了什么 —— 这是一个应该分流到不同路由逻辑的结构化信号,而不是简单地坍缩为“响应非空,直接发布”。Anthropic 的 refusal 也是文档中唯一明确建议将更换模型作为补救措施的地方:Sonnet 4.5 和 Opus 4.1 的拒绝策略比 Haiku 4.5 更激进,而仅凭文本字段你是无法发现这一点的。

Tool_use,或 tool_calls,或 MALFORMED_FUNCTION_CALL。 模型想要调用一个工具。有时该工具存在于你的注册表中,你的编排循环会分发它。有时模型发明了一个工具名称,因为提示词暗示了你并未实际接入的功能。有时模型生成的参数 JSON 格式错误,而供应商的解析器在它到达你之前就捕捉到了。在这些情况下,响应的文本部分要么为空,要么是一个占位符。如果一个处理器在 finish_reason == "tool_calls" 时向用户返回文本,那么它返回的要么是空,要么是一个未完成的想法,或者是一个被序列化为纯文本的幻觉工具调用。这些都不是真正的答案,但它们都会增加你的成功计数。

其余的值单独看可能不那么重要,但产生的影响是类似的。stop_sequence 表示触及了自定义 Token 边界,这仅在你依赖该边界作为实际语义边缘的提示词中才有意义。pause_turn 是 Anthropic 的信号,表示服务器工具循环达到了迭代上限,其约定是你通过原样发送响应来继续对话 —— 这种延续流程在你的其他错误处理中没有类似案例。RECITATION 是 Gemini 的信号,表示模型开始逐字复制受版权保护的文本,并在复制中途被切断。model_context_window_exceeded 是 Anthropic 告知你响应符合现有上下文空间,但如果有更多空间,模型本会继续生成。每种情况都对应不同的补救措施,映射到不同的日志行、计数器,以及重试或路由逻辑中的不同决策。将其简化为“非空文本即成功”会丢失所有这些关键信息。

隐藏在 SDK 内部的契约字段

这个 Bug 如此常见的原因在于 SDK 的易用性引导你走向了它。Provider SDK 返回的是结构化响应。几乎每个教程的第一件事就是直接访问 response.content[0].textresponse.choices[0].message.content 并将该字符串视为答案。而停止原因(stop reason)就在旁边的一个字段里。它位于同一个对象中,有文档说明。但读取它需要多写两个字符的代码,处理它则需要多写十到二十个字符,而大多数生产环境的处理逻辑是由那些急于上线“正常路径”(happy path)且再也没有回头优化的开发者编写的。阻力最小的路径变成了 Bug 的温床,而语言设计奖励了这种做法。

这种模式在不同的 SDK 中表现如出一辙。Anthropic 的文档提供了一个 handle_response 函数,其前三个分支分别是 tool_usemax_tokensmodel_context_window_exceeded —— 这才是官方推荐的结构。OpenAI 的社区论坛上到处都是开发者的帖子,他们上线的代码在生产环境中第一次出现 finish_reason == "length" 时崩溃了,因为 JSON 解析器开始对截断的对象报错。LiteLLM 也有关于规范化不同 Provider 之间 finish_reason 的未决问题(Issue),因为这些值相似但不完全相同,且跨 Provider 的映射无法默认完成。Gemini CLI 有一个标题为“当达到 Gemini API 输出 Token 限制时静默终止”的未解决问题 —— 这是一个工具返回了截断文本却未暴露其已被截断的 Bug,因为 SDK 的封装层没有检查该字段。这种模式不断重复,是因为 SDK 让这个字段变成了“可选查看”,而人类默认会忽略可选内容。

将 finish_reason 视为成功的谓词,而非元数据

修复方法是结构性的,而非表面功夫。位于应用程序代码边界的处理程序应该像对待 HTTP 状态码一样对待停止信号:将其视为操作成功的必要谓词,而不是响应体中一个“如果你想记录就记录”的额外字段。具体而言:

让停止原因成为每个响应处理程序的必填输入。将模型响应转化为“答案”的函数不应被允许忽略它。在强类型语言中,将响应建模为基于停止原因的辨别联合(discriminated union)—— 这样类型系统就会禁止懒惰解包。在弱类型语言中,一个带有显式分支的薄封装层也可以在运行时实现同样的约束。

将每个值映射到你的重试和路由逻辑可以处理的特定错误类。由于 max_tokens 导致的截断应该触发一个续接请求(continuation request),而不是重试;输入没问题,只是预算给错了。由于 model_context_window_exceeded 导致的截断意味着输入太大,使用相同参数重试会产生相同的结果 —— 你需要的是摘要步骤或长上下文模型,而不是重新驱动。对于拒绝(refusal),根本不应该在同一个模型上重试;廉价的补救措施是将请求路由到具有不同安全校准的模型,这在 Anthropic 的文档中被明确建议。tool_usetool_calls 的值意味着编排循环欠模型一个工具结果,而不是一个新的请求。content_filterSAFETY 的值意味着提示词或用户输入跨越了策略边界,正确的做法是向用户展示这一点,而不是静默降级。

将每个值作为一等指标(first-class metric)绘制成图表,而不是作为成功计数器上的一个标签。静默失败之所以保持静默,是因为仪表盘汇总的是“请求成功”,而那个显示“但答案不完整”的字段藏在没人会告警的低基数日志中。提升它的地位。将 responses_truncated_by_max_tokens 作为独立的序列进行追踪。将 responses_refused 作为独立序列追踪,并在 Provider 提供时按 stop_details.category 拆分。当其中任何一个指标波动时发出告警。提示词更改后 responses_truncated 的激增是那种能在几小时内而非几周内传达给你的信号,但前提是它有自己的图表。

让这个值在你的追踪(traces)中可见。当你记录请求时,将停止原因记录为顶级属性,而不是埋在负载数据块(payload blob)中。一个以 stop_reason=refusal 结尾的追踪,以及一个将空文本字段视为答案处理的下游系统,这是一个应该能端到端读取的故事,而不需要对负载列进行正则匹配。

为什么这与忽略 HTTP 状态码是同一个 Bug

这里有一个有用的视角。二十年前,一代 HTTP 处理程序在编写时只读取响应体而不检查状态码,因为响应体不为空且 SDK 给了你响应体。结果生产环境崩溃了。行业吸取了教训,Linter 吸取了教训,SDK 开始让做错事变得更难。我们现在认为“检查状态码”是基本要求。

模型响应中的停止原因就是同样的字段。它是 Provider 的带外信号(out-of-band signal),提醒你响应表面上的成功是有条件的。Body 解析成功了。HTTP 状态码是 200。但语义存在于元数据中。一个只读取 response.content[0].text 而不读取 response.stop_reason 的处理程序,正在犯与 2005 年代只读取 response.body 而不读取 response.status 的客户端同类的错误 —— 区别仅在于失败模式更难被察觉,因为 Body 是一串看似合理的英文,而不是 HTML 错误页面。

信号就在契约中。Provider 已经告诉你了。今天编写了感知字段处理程序的团队不会得到任何表扬,因为没有什么明显的东西坏掉。而没有编写它的团队则要在接下来的六个月里向技术支持解释,为什么成功率是绿色的但用户体验却下降了。廉价的纪律是从第一次提交开始就将停止原因视为承重结构,甚至在仪表盘画出来之前 —— 并且拒绝发布任何不在其上进行分支处理的处理程序。

读取一个额外字段的回报是,那些本会埋藏在转化漏斗中的 Bug,在它们产生的层级变得清晰可见。代价是每个调用点多出两行代码。这个比例是不言而喻的。字段已经在响应中了。

References:Let's stay in touch and Follow me for more thoughts and updates