作者通过 Cloudflare Worker 网关统一接入多种模型,却发现限制推理的参数可能反而触发思考,造成延迟甚至空响应。部分模型的推理与正文共用 token 预算,预算耗尽后即使没有正文仍会计费。
我让一个模型少思考一点,结果它反而开始思考了。
整个 bug 就是这么回事。不过,我是怎么踩进这个坑的,值得写下来。因为只要你在同一个 endpoint 后面接入多个模型家族,大概率也会遇到类似的问题。
先说一下我的架构:我在开发一个桌面应用,它可以通过三种方式调用模型:通过 Ollama 调用本地模型、使用用户自己的 API key,或者消耗云端额度,经由我运行的一个小型 gateway(部署在 OpenRouter 前面的 Cloudflare Worker)调用模型。在最后一种模式下,应用并不知道自己实际使用的是哪个模型。它请求的是 cuepreem-cloud-fast 这样的虚拟标签,由 gateway 决定这个标签当前对应什么模型。因此,所有针对具体模型的处理都必须放在 gateway 中,因为只有它知道真实的模型名称。
gateway 针对具体模型所做的一件事,就是限制 reasoning。一些模型默认会先思考再回答,而思考和回答共用同一份 max_tokens 预算。如果给这类模型的预算很紧,它可能把预算全部花在思考上,最后返回一个空字符串,但这些 token 仍然照常计费。所以,gateway 会检查模型名称,为会进行 reasoning 的模型加上限制。精简后的代码是这样的:
function buildUpstreamBody(body, spec, model) {
return {
...body,
model,
// The client only sends a virtual label, so only the gateway can decide this.
...(isReasoningModel(model) ? { reasoning: { effort: 'low' } } : {}),
};
}
而 isReasoningModel 用的是一个匹配模型名称的正则表达式:
// before (trimmed)
return /gpt-oss|deepseek-r1|qwq|magistral|gemini-2\.5/i.test(model);
我当时想到的那些 Gemini 2.5 模型默认都会思考,所以写成 gemini-2\.5 看起来没什么问题。
后来,我把快速通道切换到了 Gemini 2.5 Flash-Lite。它便宜、响应快,而且除非你明确要求,否则它不会思考。问题恰恰就出在这里。google/gemini-2.5-flash-lite 包含 gemini-2.5,所以 gateway 给它发送了 reasoning: { effort: "low" }。
我之前理解错的地方就在这里。我一直把 effort 当成一个只会往下调的旋钮:"low" 就是“比你平时少思考一点”。对于默认会思考的模型,这么理解大致没错。但对于默认不思考的模型,根本没有什么可往下调的。设置任何 effort 值,都是在要求它开始思考。"low" 的意思是“思考一点”,而一点总比完全不思考多。
为了确认这一点,我发起了一次小测试,把 max_tokens 设为 16。响应中报告了 12 个 reasoning token,回复却是空的。大部分预算花在了思考上,用户能看到的答案一片空白。
如果预算宽裕一些,思考结束后可能还装得下答案,问题就没那么明显:你明明因为模型速度快才选了它,回复却比应有的时间更晚到达。如果预算很紧,也就是你想尽快拿到一个简短回答时通常会采用的设置,思考就可能把预算全部吃光。
这也是它难以排查的原因。没有任何报错,上游调用返回的也是 200。症状只有“比预期慢”,以及有时什么都没有。
修复只需要加一个负向前瞻:
// after (trimmed)
return /gpt-oss|deepseek-r1|qwq|magistral|gemini-2\.5(?!-flash-lite)/i.test(model);
再加一个测试,把这个行为固定下来。旁边还有另一个测试,确保不会进行 reasoning 的模型也不会收到这个字段:
it('never sends an effort to Gemini 2.5 Flash-Lite — it would switch thinking ON', () => {
const body = buildUpstreamBody({ messages: [] }, spec, 'google/gemini-2.5-flash-lite');
expect(body.reasoning).toBeUndefined();
});
it('leaves a non-reasoning model alone', () => {
const body = buildUpstreamBody({ messages: [] }, spec, 'meta-llama/llama-3.3-70b-instruct');
expect(body.reasoning).toBeUndefined();
});
另外,gateway 也不再仅凭 200 就认定调用成功。去掉所有 <think> 块之后,如果用户可见的回复为空,就应当算作调用失败,而不是已经交付了答案。
我知道,这个前瞻只是改动最小的修复,并不是最好的方案。真正的教训是,我之前问错了问题。该问的不是“这个模型支持 reasoning 吗?”Flash-Lite 确实支持,而问题恰恰就在这里。该问的是:“在没有任何明确指示的情况下,这个模型会自行思考吗?”同一个模型家族里的答案也不一致。某个 -lite 或 -mini 变体的默认行为,可能与同家族其他模型完全相反。而基于家族名称的正则,会在你切换模型的那一天,悄悄把它也匹配进去。
更好的规则是:依据你已经核实过的完整模型 ID 来决定是否加上限制,而不是依据那些看起来像已核实模型的名称。还有一个成本很低、只需调用一次就能发现这个问题的测试:在把模型放进对延迟敏感的通道之前,先用一个很小的 max_tokens 调用它,读取 reasoning token 数量,并断言用户可见的回复不为空。
我是在开发 CuePreem 这款面试准备桌面应用时遇到这个问题的(利益披露:这是我自己的产品,本文讨论的就是其中的云端 gateway)。
如果你也在同一个 endpoint 后面接入了多个模型家族,你是怎么记录哪些模型默认会思考的?我目前只有一个手动维护的正则表达式。经过这次,我已经不信任它了。
如果还想采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。