一封两行的客服邮件,把整条流水线的底裤掀了:“我的报告停在第6题,一共9题。另外‘下一步建议’那一节也没了?”
打开报告看,格式干净、分数齐全、评语完整,没有报错横幅。它只是提前结束了——像一本书最后三章被撕掉,封面又原样粘了回去。查下去,原因是一个从来没被读过的字段:Claude 的 stop_reason。
当它返回 max_tokens,意思是模型没写完。代码不在乎,一个 JSON 修复工具又确保别人也看不出来。这是一次解剖:一个字段如何让 83 份报告静默截断,以及把它降到零的四项改动。
先看结论
- Claude 的响应里 stop_reason == "max_tokens",输出就是不完整的。解析之前必须先查它。
- JSON 修复库会很乐意给截断的 JSON 补上括号,交给你一个合法但错误的对象。命中 token 上限的输出,永远不要修。
- 这条流水线里,1,140 份报告中有 83 份(7.3%)被截断。83 份全部来自 8 题以上的会话;7 题及以下的会话一份都没有。
- 调高 max_tokens 本身不花钱。你为实际生成的 token 付费,不为上限付费。
- 把不能丢的字段(分数)放在 schema 最前面。模型按顺序生成,截断吃掉的永远是尾巴。
这套系统是什么
系统是 Preterview 内部的报告生成器,一个面试练习平台,由作者本人开发和运营。它跑三种面试官风格的语音面试,然后把完整转录文本发给 Claude,要求产出一份结构化书面报告:逐题反馈、评分表分数、下一步建议。
报告调用是整条链路里输出长度波动最大的地方。5 题的会话报告很短,9 题、候选人又话多的会话报告很长。当初每个会话都是 5 题时设了 max_tokens=1500,然后就把这个数字忘了。
max_tokens 到底意味着什么
stop_reason: "max_tokens" 表示 Claude 停下是因为撞到了你设的上限,不是因为它写完了。你拿回的文本只是模型本打算写的内容的前缀。对散文来说,是戛然而止;对 JSON 来说,是一份括号没闭合的文档。
正常的值是 "end_turn",模型自己写完了。还有 "tool_use"、"stop_sequence" 等取值,但对一个“给我写份 JSON 报告”的普通调用来说,任何不是 end_turn 的值都值得怀疑。
原始代码大致是这样:调用 messages.create,设 max_tokens=1500,取 resp.content[0].text,然后 json.loads(repair_json(raw)),注释还写着“以防万一”。
缺了什么?没有任何一行去读 resp.stop_reason。而那个 repair_json 调用才是真正的元凶。
截断的 JSON 为什么没报错
因为解析器外面包了一层 JSON 修复助手(json_repair 包),几个月前加进来,是为了扛住偶尔出现的多余逗号和跑偏的 markdown 代码围栏。修复库的职责就是把坏 JSON 变成合法 JSON。截断的文档只是缺了结尾的坏 JSON,于是它补上未闭合的字符串、数组和对象,返回一个能解析的东西。
截断处的尾巴长这样:第 6 题的 feedback 字符串停在半句话上。修复之后,第 6 题的评语在半句处结束,第 7 到 9 题不存在,next_steps 数组消失。然后 Pydantic 校验通过——因为 next_steps 给了默认空数组,逐题反馈又允许任意长度。
每一层都干了它该干的事。结果是一份自信满满的错误报告。
83 份截断响应里,修复助手“修好”了 79 份。只有 4 份硬失败,死法是在 key 中间断掉,修复器猜不出来。于是错误监控三周只显示 4 次失败,看起来像噪声。真实失败率是它的 20 倍。
83 份是怎么数出来的
靠数日志里的 stop_reason。这些日志一直在存,只是从来没被读过。每次调用都记录了原始 API 响应元数据,一条查询覆盖三周,答案就出来了。
这个分布就是全部故事。1,500 的 token 上限是按当初发布的产品定的,不是按正在跑的产品定的。加上更长的会话之后,大约每 10 个长会话就有 4 个报告超出上限,而流水线里没有任何环节负责说出来。
被打得最惨的用户,恰恰是练得最长、最认真的那批人。对产品投入最多的人拿到了最差的报告。这一点最扎心。
四项改动
- 用 stop_reason 做闸门。解析前先判断,不等于 "end_turn" 就抛 TruncatedOutput,带上 stop_reason 和 output_tokens,触发重试路径并发出告警。截断的报告从此是响亮的失败,不是安静的谎言。
- 抬高上限,它是免费的。max_tokens 不是预算,是防止输出失控的安全栏。报告需要 1,100 token 时,上限 4,096 和上限 1,500 花掉的钱完全一样。选 4,096,是因为之后观察到的最长报告在 p99 上是 2,350 token,留出了真实余量。
- 重排 schema,分数放最前。Claude 从上到下写 JSON,schema 末尾就是被切掉的地方。原顺序是摘要、逐题反馈、分数、下一步建议——分数这个用户会截图、会逐周对比的部分,正好在危险区。新顺序:分数、摘要、下一步建议,逐题反馈放最后。再截断,用户丢的是最后一题的细节,不是分数。
- 只修干净收尾的输出。repair_json 保留,但只在 stop_reason == "end_turn" 且 json.loads 失败时运行。那时它修的是完整答案的格式毛刺,正是它擅长的活。被切断的响应,它永远不碰。
万一还是撞上限
重试路径把它拆成两次更小的调用,而不是一次更大的。第一次要分数、摘要和下一步建议,第二次要后半段题目的逐题反馈,然后合并。每一半都稳稳落在上限内,拆分多花约 4 秒,对一份面试结束后才读的报告来说可以接受。
到目前为止,重试路径跑了 0 次。修复之后的 1,300 份报告里,stop_reason 每一次都是 end_turn。拆分调用之所以存在,是因为“这不可能发生”正是当初对原 bug 的信念。
代价
总输出 token 涨了约 5%,全部来自 8 到 9 题的报告现在能写完而不是提前停。长报告的延迟增加了大约生成缺失尾巴所需的时间。短会话没有变化。另外给 83 份截断报告背后的 61 位用户发了邮件,用存下来的转录文本重新生成了报告,花了一个脚本和一段略显尴尬的道歉。
值得刻进 CI 配置的几条:每次调用都记录 stop_reason 和 usage.output_tokens,数据躺了三周,它本可以在第一天就告诉你;在期望完整输出的流水线里,任何一次 max_tokens 停止都要告警,那是一次 bug 报告,不是噪声;输入分布一变,就重新检查你的上限,那个数字在写下的当天是对的;把“宽容”的解析当成 LLM 流水线里的负债,每一层原谅错误,就是一层掩盖错误;错误率只和你的最严检查一样诚实——监控说 0.35%,真相是 7.3%。
热门跟贴