推理模型正在悄悄吃掉你的答案
供应商把模型换成了推理模型,你的流水线依然返回 200 OK。没有崩溃,日志一片平静。而每一个回答都悄悄是空的。
我们在同一套系统里遇到了两次,相隔三周,出现在两个毫不相干的地方。本文讲清楚:这个问题长什么样,如何用一次 API 调用确认它,以及真正的修复是什么——包括几乎没人会检查的那一环:把空回答变成一个貌似合理的数字的兜底逻辑。
推理模型如何吃掉你的预算
推理模型在回答之前先思考。思考也是 token,而关键在于:这些 token 与你为回答设定的 max_tokens 共用同一份预算。
你设 max_tokens=4096,模型拿到的并不是 4096 个用于回答的 token,而是 4096 个用于思考加上回答的 token。如果思考拖得太长,预算在输出第一个字符之前就已经耗尽。API 返回 finish_reason: "length",消息体为空。
不是被截断,也不是格式错误。是空的。
以下是我们直接对 deepseek-v4-flash 的 API 实测结果。这个模型在我们自己的注册表里被描述为「快速通用模型」,实际上是一个推理模型:
| 提示词 | max_tokens | 结果 |
|---|---|---|
| 短提示,6 个片段 | 1500 | stop,1272 个思考 token,JSON 有效 |
| 完整批处理提示,6 个片段 | 4096 | length,思考 4095/4095,正文 0 |
| 完整批处理提示,6 个片段 | 16384 | length,思考 16384,正文 0 |
| 生产环境批次,10 个片段 | 10000 | length,思考 10000,正文 0 |
请把第二行和第三行放在一起看。我们把预算翻了四倍,得到的是同样的结果:模型把思考扩张到填满你给它的任何空间,始终没有走到回答那一步。调高 max_tokens 不是解决办法。 那是最自然的第一反应,除了更高的账单之外什么也换不来。
为什么没人发现
空回答很容易被错误处理,因为对于不仔细看的代码来说,「模型什么都没返回」和「模型什么都没找到」长得一模一样。
我们在两个子系统里发现了同一个缺陷,而它在每处都换了一副面孔。
伪装之一:看起来像容错的重试
我们的批量翻译器发送编号片段,期待返回一个编号数组。响应为空时索引对不上,代码就做了看起来很合理的事:把每个缺失的片段单独重试一遍。
它确实奏效了。所有片段都翻译回来了,文档也没问题。
但这同时意味着,每份文档都有 38 个片段中的 38 个退化成单条调用——批处理路径悄悄不再是批处理。重试把故障藏得太好,唯一可见的症状是账单和时钟。一份中国出口报关单:MT 阶段 133.6 秒,整份文档 359 秒,$0.0354。
修复之后:MT 阶段 5.1 秒,文档 47 秒,$0.00098。同一个模型、同一份文档、同样的输出质量——两次的数字完整性都是 9 比 9。成本相差 36 倍,全部来自那些用于思考却从未产出答案的 token。
从整套系统的开销历史看更直白:同一个模型某一天平均每次调用输出 34,234 个 token,第二天是 697 个——相差 49 倍——而唯一的变化就是这次修复。
伪装之二:会撒谎的兜底
第二次出现的这个案例,值得当作教训收好。
我们的质量评估使用 GEMBA-MQM:让模型按错误分类体系为机器翻译片段打分。它调用同一个 API 时没有带上关闭思考的开关,上限是 max(4096, n * 150)——也就是说,任何小于 27 个片段的批次都恰好是 4096。于是每次调用都返回空。
而每次调用都落入 _create_fallback_score,给出中性的 75 分。
质量评估看起来正常工作了好几周。它产出分数,分数看着合理,每个片段都有。没人会对 75 分多看一眼——一份中等水平的机器翻译,本来就该是这个分。
等我们真的去看时,破绽是:它永远正好是 75。从不是 74,也不是 78。真正的评估器会给出一个分布;一个常数是兜底逻辑的指纹。
修复之后,同样的 14 个片段用了 6.1 秒,得到十三个 100 分和一个 96 分——而那个 96 分附带一条真实的 style/awkward 发现。
返回貌似合理常数的兜底,比直接崩溃更糟。 崩溃会在周二被修好。貌似合理的常数会进入生产环境,变成人们据以决策的数字。
如何用一次调用确认
不需要埋点。用你真实的提示词和真实的 max_tokens 发一次请求,打印三样东西:
response = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": 你的真实提示词}],
max_tokens=4096,
)
choice = response.choices[0]
usage = response.usage
print("finish_reason:", choice.finish_reason)
print("content:", repr(choice.message.content))
print("思考 token:", getattr(usage, "reasoning_tokens", None)
or getattr(usage.completion_tokens_details, "reasoning_tokens", None))
如果 finish_reason 是 "length",而 content 是 None 或空字符串,同时思考 token 数正顶在你的上限上,那就是它。一次调用,大约半美分。
一定要用真实的提示词。我们那个短测试提示词在 1500 token 下表现完美——就是表格第一行——却在完整提示词上失败了。推理模型的思考量随任务看起来的难度而增长,所以玩具提示词证明不了任何关于线上提示词的事。
修复方案
分两部分,两部分都要。
在思考换不来任何东西的地方把它关掉。 对 OpenAI 兼容的供应商,参数是 reasoning_effort: "none":
response = await client.chat.completions.create(
model=model,
messages=messages,
max_tokens=max_tokens,
reasoning_effort="none",
)
部分供应商也接受 extra_body={"thinking": {"type": "disabled"}}。要当心:这片参数空间里全是「差一点就对」的写法,它们要么毫无作用,要么效果相反。在我们的测试中,reasoning_effort: "minimal" 反而增加了思考量;enable_thinking=False 和 reasoning.enabled=False 完全没有作用。开关要实测,不能想当然。
让预算随工作量增长。 固定的 4096 是一个等着更大批次来触发的缺陷。我们现在的上限随片段数量增长,40 个片段的批次不会和 4 个片段的批次拿到同样的天花板。
对于从没听说过这个参数的供应商,应该把参数丢掉而不是让请求失败:发出去,捕获关于未知字段的 400,然后不带该参数重试。Claude 和 Gemini 直接忽略我们的参数——这才是正确行为。
哪里该关掉思考,哪里不该
这个开关不是免费的质量。要按调用点逐个、有意识地决定。
机械性工作可以关掉。 翻译、字段抽取、转写、格式转换——凡是模型在变换输入而非做出判断的场合。「把这个表格单元格用俄语写出来」没有什么可推理的,而我们没有测到质量损失:开启和关闭思考时数字完整性都是 9 比 9。输出甚至更干净了——俄语译文中残留的源文字字符从 31 个降到 11 个,因为一个不把自己想进死角的模型,更可能直接把活干完。
需要判断的地方留着。 多步分析、含糊的分类——凡是你希望模型权衡多个选项的场合。只要给它一个能同时容纳思考和回答的预算。
陷阱在于:机械性工作通常正是高流量路径。也就是说,那个看不见的 36 倍成本乘数,恰恰在破坏力最大的地方。
要点回顾
- 思考 token 和回答 token 共用一份预算。 思考把它耗尽时,你得到的是
finish_reason: "length"加一个空消息体——不是错误,也不是截断。 - 调高
max_tokens没有用。 我们从 4096 调到 16384,得到的是同样的空回答:思考会扩张到填满给它的任何空间。 - 把空消息体当作预算耗尽,而不是「没有结果」。 单独打日志。我们把空消息体记作
[LLM_EMPTY],把被截断的 JSON 记作[LLM_TRUNCATED]。 - 审查每一个 LLM 调用点,而不只是发现症状的那个。 我们在相隔三周、互不相关的子系统里,两次发现了同一个缺陷。搜索那些没有传关闭思考标志的调用。
- 警惕返回貌似合理常数的兜底逻辑。 我们那个给每个片段都打中性的 75 分,让一个坏掉的质量评估看起来在正常工作。
常见问题
我怎么知道自己的模型是不是推理模型?
不要相信描述。我们那个在自家注册表里写的是「快速通用模型」。发一次真实请求,看 usage 对象是否报告思考 token——只有这个答案算数。
为什么模型返回空内容,而不是部分答案?
因为思考发生在前面。模型在开始输出回答之前就耗尽了预算,所以没有部分答案可以返回:消息体是真的空,而不是被截短了。
关闭思考会损害翻译质量吗?
在我们的测量中不会。开启与关闭时数字完整性一致,残留的源文字字符反而更少了。翻译是变换,不是思辨。对于真正需要分析的任务,请保留思考,改为提高预算。
在生产环境该记录什么来抓住它?
在每次调用上记录 finish_reason 和思考 token 用量,并在 finish_reason == "length" 与空消息体同时出现时告警。另外,对任何评分器异常恒定的输出也要告警——一个从不变化的指标,通常是兜底,而不是测量。
这个问题也影响 Claude 或 GPT 吗?
共用预算这一行为是推理模型的普遍特性,但控制方式因供应商而异,有些干脆忽略该参数。诊断方法各处相同:真实提示词、真实上限,把 finish_reason 和消息体对照着看。
结语
这个缺陷之所以危险,正是因为它不像缺陷。没有异常,没有 500,仪表盘上没有红线。有的只是变慢的流水线、涨上去的账单,以及——如果运气不好——一个报告着舒服数字、却从未真正测量过任何东西的指标。
诊断只需一次 API 调用。修复是一个参数加上一份随工作量增长的预算。难的是纪律:在一个地方发现它之后,当天就去审查其余所有调用点,并认真看一看任何「返回一个貌似合理的值」而不是大声失败的兜底逻辑。
KTTC 按模板翻译正式文件,在这里丢失一个编码或编造一个日期,不是文风问题,而是一份作废的文件。这就是我们如此细致地测量自家流水线、并公开测量结果的原因。试用 KTTC,在你自己的文件上看看这些数字。
