跳到主要内容

你的 stop_reason 在说谎:构建生产环境故障排查真正需要的停止分类法

· 阅读需 14 分钟
Tian Pan
Software Engineer

运维工程师调出一个 trace。模型已返回,span 正常关闭,API 调用显示 stop_reason: end_turn。从平台提供的各种信号来看,这是一次成功的生成。3 分钟后,客户报告说 Agent 煞有介事地写了半个配置文件,宣布操作完成,然后就继续下一步了。Trace 里没有任何预警信号,因为预警信号不在 API 协议里 —— 供应商提供的停止原因只有四到七类,而你的事故排查所需要的答案,恰恰隐藏在这些类别的缝隙之中。

停止原因(Stop reasons)是工程师在故障排查时最先查看的字段,也是最具误导性的字段。这些值是为运行时(runtime)设计的,旨在决定下一步该做什么:这一轮是否完成了?是否请求了工具?是否超出了预算?安全检查是否介入了?它们并不是为了让开发者重建答案出错的原因而设计的,而这两者之间的差异,正是生产团队耗费整个下午的时间所在。

解决方法并非抛弃供应商提供的值,而是要意识到 stop_reason供应商 解码器(decoder)的抽象,而你的产品需要针对 你的 输出建立 第二层 抽象:一个通过最终状态检查推导出来的并行停止分类体系(parallel stop-taxonomy)。它应该作为独立的 span 属性出现在 API 值旁边(而不是取而代之),并作为一级信号进行告警。如果没有这套二阶分类体系,在那些排查成本最高的事故中,你就像是在盲目飞行。

API 告诉了你什么,以及它没告诉你什么

如今,Anthropic 的 Messages API 可以返回 7 个值之一:end_turnmax_tokensstop_sequencetool_usepause_turnrefusalmodel_context_window_exceeded。OpenAI 的 finish_reason 暴露的集合更小:stoplengthtool_callscontent_filter 以及已弃用的 function_call。其他供应商也有各自的变体。在所有这些供应商中,覆盖面都很窄,因为这些值只是解码循环退出条件的分类标签,而不是对答案发生了什么的诊断。

当你第一次排查真实的线上事故时,这种不匹配就会显现出来。

end_turn 是其中含义最模糊的值。它声称模型认为自己已经完成了。在实践中,它至少涵盖了四种截然不同的状态:一个满足用户要求的完整答案;一个类似“没问题,我可以帮忙”的开场白(模型本以为会有后续轮次);一个只有 2-3 个 token 的空响应(这通常发生在消息结构训练模型在每个工具结果后期待用户回复时);以及一种在没有触发 refusal 标志的情况下通过模棱两可的方式停止的“软拒绝”(soft refusal)。Anthropic 官方文档明确指出了空响应这种失败模式。你的排查流程需要区分这四种情况。

max_tokens 是最容易出现隐性错误的值。模型一直在写,直到空间耗尽,而截断发生在句子中途、JSON 对象中途或工具参数中途,这恰恰是下游代码很少检查的失败情况。曾记录过一起 Agent 循环事故:一个被截断的 write_file 工具调用写入了一个部分文件且没有报错,Agent 假定操作成功,结果在任何人察觉之前,20 小时的子 Agent 工作都堆叠在了一个损坏的产物之上。供应商的 stop_reason 准确报告了 max_tokens,但 Agent 循环从未对其进行分支处理。Bug 就存在于这个缝隙中。

在你查看 Bug 追踪器之前,tool_use 看起来似乎没有歧义。但已有记录表明,在某些情况下,模型输出了 tool_calls 内容,但 finish_reason 返回的却是 stop。如果生产代码根据 finish_reason 而不是检查内容块来进行分支判断,就会默默丢弃工具调用。将 stop_reason 视为“模型是否调用了工具”的唯一事实来源,会让你在最关键的时刻犯错。

pause_turn 是最近才出现的,以至于现场大多数 Agent 循环根本无法处理它。当服务器端工具(如网页搜索、网页抓取)触及 Anthropic 侧的迭代限制时,它会触发并返回一个部分完成的轮次,需要将其放回续接请求中重新运行。如果你的循环只在 tool_useend_turn 上进行分支处理,那么 pause_turn 就会变成一次丢失的对话。

refusal 看起来很干净 —— 模型拒绝了,你处理它。但问题是,基于分类器的拒绝仅在较新的 Claude 模型流式响应中触发,而“软”拒绝(模型生成了含糊其辞的非回答,但没有分类器标记它)会以 end_turn 的形式返回。你通过 API 字段衡量的拒绝率,并不是用户实际感受到的拒绝率。

每个值的模式都是一样的:API 给你一个分类标签来帮助运行时决定该做什么;它不会给你故障排查所需的 8 位诊断信息。

并行的停止分类法 (The Parallel Stop-Taxonomy)

这种准则要求你在处理每一个响应时,根据最终状态的检查,自行推导出第二个分类。供应商提供的值告诉你他们的解码器是如何判断的;而你计算出的值则告诉你 输出结果 实际上是什么样的。将两者都呈现出来,并在它们不一致时进行对比 (Diff)。

一套起步阶段可用的分类法包含五个值得在每次补全时计算的信号:

尾部完整性签名 (Trailing-completeness signature)。检查输出的最后 32 个字符。它是否以完整的句子结束(句号、问号、闭合引号、闭合括号)?还是在单词中途、括号中途或引号中途结束?无论 stop_reason 如何显示,中途截断的响应几乎肯定是被截断了。这能捕捉到 max_tokens 报告正确但下游代码未处理的情况,以及运行时声称是 end_turn 但输出实际上以逗号结尾的情况。

结构化输出有效性 (Structured-output validity)。如果提示词要求 JSON,就对其进行解析。如果要求工具调用,就根据工具的 Schema 验证参数。“按停止原因分类的 JSON 有效性率”是你能够提供的杠杆率最高的可观测性指标之一,因为“模型在闭合括号处达到 max_tokens 并写出了 3.5 KB 无法解析的垃圾内容”这种失败模式在你不检查之前是不可见的。

拒绝短语指纹 (Refusal-phrase fingerprint)。维护一个包含软拒绝开头的小字典——“我无法”、“我不能帮上忙”、“作为一个语言模型”、“我建议咨询专业人士”,以及你的实际流量中出现的特定模型的委婉语。为每个响应计算得分。这个得分本身可能有噪点,但系统提示词更改或模型升级后软拒绝率的 变化量 (Delta) 就是你需要的警报。关于拒绝 Token 的研究主张在训练期间进行显式的拒绝校准;在这一机制普及之前,你的指纹就是生产环境中的替代方案。

长度与分布的 z-score (Length-vs-distribution z-score)。维护每个路由的输出长度分布。标记那些低于第 5 百分位数的响应,尤其是当它们与 end_turn 同时出现时。“用 3 个 Token 回答”的空响应失败模式从 API 的角度看是一个干净的结束,但从分布的角度看则是一个巨大的疑点。

末尾 Token 熵代理 (Last-token entropy proxy)。在生产环境中你通常拿不到 logprobs,但你有代理指标。一个以高频连接词 (andbutbecause) 结尾的响应,比以 Done.Let me know if you have questions. 结尾的响应更有可能被切断。跟踪末尾 Token 的双词频 (Bigram-frequency);如果移动平均值激增,这就是模型推理超出预算的早期预警。

单个指标都不足以支撑全局。但结合起来,它们能为你提供 8 位的状态信息,而原本的 4 位 API 字段总会将这些信息压缩掉。

通过 Span 进行串联

实施的纪律性决定了这套分类法在凌晨两点的故障排查中是否真的有用。有三条原则。

将增强后的停止原因作为单独的 Span 属性呈现。OpenTelemetry GenAI 语义规范在输出消息结构上预留了 finish_reason 字段用于存储供应商的值。在它旁边添加你自己的属性——随你怎么命名,但让它成为一个并列项而非替代项。当进行追踪 (Trace) 查询时,两个字段同时可见,它们之间的差异就是最显眼的信息。

永远不要覆盖供应商的值。将所有内容“归一化”到你自己的分类法中很有诱惑力。但不要这样做。供应商的值是工具、仪表板和 SDK 行为所依赖的契约表面。保持它的原样。并行分类法的意义在于供应商值与推导值之间的 差异 本身就是一个信号。

在 Span 边界计算分类,而非在 Agent 循环中。Agent 循环需要在微秒级内做出决策;尾部完整性检查和拒绝短语评分可以在可观测性路径上花费几毫秒,而不会阻塞核心路径。这也意味着分类逻辑只会在一个地方按统一定义计算一次,而不是演变成三个团队的三个不同版本。

一个有用的副作用:一旦增强后的停止原因作为 Span 属性存在,你就可以基于它构建仪表板。按路由统计的拒绝率。按模型版本统计的截断率。每次系统提示词发布后的空响应率。这些仪表板能让你在用户提交工单前捕捉到事故,而你无法基于那个只有四个值的字段构建这些功能。

值得警惕的慢性失败模式

这种分类法能解锁的价值最高的告警是针对系统提示词更改后出现的“慢性 max_tokens 退化”。

这种模式非常固定,足以列入检查清单。有人扩展了系统提示词:增加了一条新指令,扩充了 Few-shot 代码块,或者附加了更长的工具描述。评估集上的质量有所提升,因为模型现在有了更多上下文。没人动过 max_tokens。一周后,长度排在倒数 5% 的响应在中途被截断了,某个路由的 JSON 有效性率下降了两个百分点,客户支持开始收到“机器人突然停止”的工单。厂商的 stop_reason: max_tokens 触发频率有所升高,但仍仅占总流量的个位数百分比,因此不会触发明显的告警。与此同时,你的增强型停止原因显示 max_tokens 加上“无效 JSON”的 组合 增加了 3 倍,而这正是告警应该触发的组合。

其他值得告警的组合:

  • end_turn + 低于 p5 长度 + 拒绝短语指纹阳性:软拒绝退化。
  • end_turn + 内容块包含 tool_use:SDK 的 Bug 导致工具调用被报告为普通停止。
  • tool_use + 工具参数 JSON 无效:模型调用了带有畸形参数的工具,而运行时仍将其发出了。
  • end_turn + 工具结果后只有 0-3 个输出 Token:Anthropic 官方文档中提到的空响应反模式。
  • 每个 Agent 路由的 pause_turn 率:Agent 循环不知道如何继续的预警。

如果你直接针对 API 的 stop_reason 设置告警,上述情况都是不可见的。一旦并行的分类法串联起来,它们都会变得显而易见。

为什么这是一个架构问题,而非观测问题

很容易诱发的一种冲动是将此视为一次日志升级 —— 增加几个字段,发布一个仪表板,然后就此结束。但这忽略了更深层次的重点。供应商提供的 stop_reason他们对其解码循环(decoder loop)的抽象。对于他们的目的(告知 SDK 下一步该做什么)来说它是正确的,但对于的目的(告知工程师为什么答案出错了)来说,无论他们增加多少个值,它的形态永远都是错误的。每一个新模型的发布都会引入一个新的值,而每一个新值都会压缩掉你真正需要的诊断信息。

架构层面的认知是,你必须拥有第二层抽象。你的产品界面不是解码器;而是用户收到的答案,而“答案是否成功”是一个产品问题,任何解码器级别的字段都无法回答。平行分类法(parallel taxonomy)是你基于自己的输出构建的类型系统,一旦你致力于维护它,你就不再受供应商字段选择的挟持。

这也重构了你阅读供应商发布说明的方式。当 Anthropic 增加 pause_turnmodel_context_window_exceeded 时,这些并不是需要处理的新 Bug —— 它们是新的信息,是供应商现在愿意分享的细粒度信号,你可以将这些信号集成到你的分类法中。当 OpenAI 澄清了 tool_callsstop 之间的歧义时,意味着你又少了一个需要通过代码绕过的非一致性问题。供应商的字段随着时间的推移变得越来越丰富;而你的派生字段则变得越来越简洁。这是正确的演进方向,而且只有当你首先拥有派生字段时,这种情况才会发生。

明天从哪里开始

如果你目前一无所有,那么明天首先发布“末尾完整性检查”(trailing-completeness check)。它只有二十行代码,却能捕捉到影响最大的失败模式(无声的句中截断),并为团队在争取后续投入时提供具体的论据。后天增加 JSON 合法性检查,因为一旦涉及工具调用(tool use),向下游系统发送无效结构化输出的代价往往会超过所有其他类型的失败。

在那之后,顺序取决于你的流量情况。重度聊天产品?“软拒绝指纹”(soft-refusal fingerprint)的投资回报最快。重度 Agent 产品?则是 pause_turn 和工具参数合法性检查。重度结构化提取产品?则是长度分布 Z 分数(length-distribution z-score)。分类法是可组合的;根据事故的需求发布信号,并将它们统一放在一个 span 属性下,这样仪表板的故事线就能保持连贯。

供应商的 stop_reason 是一个 4 位字段,而它在生产环境中被问及的却是一个 8 位问题。你既可以等待供应商扩展该字段 —— 然后在下次凌晨两点的事故中发现他们并没有在你需要的地方进行扩展 —— 也可以现在就构建第二层抽象。那些在事故发生时还能安稳入睡的团队已经构建好了它。

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