让廉价模型"稍微思考",结果更慢还返回空:一个网关正则引发的Bug

用模型名正则判断是否推理,误把"默认不思考"的 Flash-Lite 强制开启思考,导致变慢甚至空回复。
一位开发者在自建的多模型网关中,用正则匹配模型名来判断是否给上游请求注入 `reasoning: { effort: "low" }`。他以为这是对推理模型"少想一点"的上限控制,但当快速通道切到 `google/gemini-2.5-flash-lite` 后,这个默认不推理的模型被家族名正则 `gemini-2\.5` 命中,于是被强制开启思考。由于 effort 对非推理模型而言不是"调低"而是"开启",模型把 `max_tokens` 预算全花在内部思考上,导致响应变慢,预算吃紧时甚至返回空字符串,且 token 照常计费。修复方式是加入负向先行断言 `(?!-flash-lite)` 并补测试,同时把剥除思考块后的空回复视为失败。作者强调真正的教训是:应当区分"支持推理"与"默认推理",并把逻辑绑定在验证过的精确模型 ID 上,而非名字正则。
一场由正则引发的推理翻车
一位开发者在给自己的桌面应用搭模型网关时踩了个坑:他本想让一个廉价、快速的模型"少想一点",结果反而让这个原本不推理的模型开始推理,导致响应变慢,甚至返回空字符串。我把整个排查过程读了一遍,发现的根因并不复杂,但暴露出的问题非常典型——很多开发者在多模型网关里用模型名字做正则匹配来判断行为,这条路迟早会出事。

先交代背景。这位开发者做的桌面应用可以走三种方式调用模型:本地 Ollama、用户自己的 API Key,或者经由他自己部署在 Cloudflare Worker 上的小网关转发到 OpenRouter。在网关模式下,客户端并不知道背后用的是哪个真实模型,它只会发送一个虚拟标签,比如 cuepreem-cloud-fast,网关再决定今天的"fast"对应哪个模型。也就是因为这样,所有跟具体模型相关的逻辑都必须放在网关里。
推理模型的 token 预算陷阱
其中一个模型相关逻辑就是限制推理长度。有些模型默认会在回答前先"想一想",而这段思考消耗的是和最终答案共用的 max_tokens 预算。如果预算给得很紧,模型可能把所有 token 都花在思考上,最后交回一个空字符串,但 token 依然照常计费。
所以网关会检查模型名,对会推理的模型加一个上限。作者的代码大致长这样:
function buildUpstreamBody(body, spec, model) {
return {
...body,
model,
// 客户端只发虚拟标签,所以只有网关能决定这件事
...(isReasoningModel(model) ? { reasoning: { effort: 'low' } } : {}),
};
}
而 isReasoningModel 就是一个针对模型名字的正则:
// 修改前(已裁剪)
return /gpt-oss|deepseek-r1|qwq|magistral|gemini-2\.5/i.test(model);
他当时脑子里想的是 Gemini 2.5 系列默认会思考,所以 gemini-2\.5 这个匹配看起来是对的。
负向先行断言(negative lookahead) 是正则表达式中的一种零宽断言,写作 (?!pattern),表示"当前位置的后面不能匹配 pattern",但本身不消耗任何字符。它常用于在匹配某段文本的同时排除特定后缀,例如 /gemini-2\.5(?!-flash-lite)/ 会匹配 gemini-2.5-pro、gemini-2.5-flash,但不会匹配 gemini-2.5-flash-lite。相比枚举所有想要的变体,先行断言是修补"家族名正则"最省事的办法,但它本质上仍是字符串匹配——如果将来出现 gemini-2.5-flash-lite-preview 或别的默认不推理的兄弟型号,同一个正则又会犯错。这也是为什么作者强调它只是最小修复而非最佳实践。
reasoning tokens 是推理模型在生成最终答案之前产生的内部思考内容所消耗的 token。它们和普通输出 token 共享同一个 max_tokens 预算,但在 API 响应中通常以单独的字段(如 reasoning_tokens 或 completion_tokens_details.reasoning_tokens)暴露,也照常计费。这意味着即便最终可见回复是空字符串,只要模型"想过",调用方就要为这部分开销买单。理解这一点是排查本案例中"慢且空"症状的关键:问题不在于模型没干活,而在于它把全部预算都用在了看不见的地方。
effort: "low" 到底是关还是开
后来他把快速通道切到了 Gemini 2.5 Flash-Lite。这个模型便宜、快,而且除非你主动要求,它本来是不推理的。但问题恰恰就出在这里:google/gemini-2.5-flash-lite 这个字符串里包含 gemini-2.5,所以网关给它发了 reasoning: { effort: "low" }。
作者在这段反思里说得很清楚:他一直把 effort 当成一个"只能往下调"的旋钮,低意味着"比平时少做一些"。对一个默认会思考的模型来说,这大致成立;但对一个默认不思考的模型来说,没有"少"可以调,任何 effort 值都等于"开始思考"。低就是"一些",而"一些"大于"零"。
为了确认,他用 max_tokens 设为 16 做了一次小测试。响应报告了 12 个 reasoning tokens,可见回复是空的。预算基本都花在了思考上,用户看到的内容是空白。
如果预算宽松一些,答案还能在思考后面挤出来,失败就更隐蔽:回复只是比预期的来得更晚,你选它本来就是因为快。但如果预算很紧——也就是你想快速拿到短回答时设的那种——思考就能把预算全吃掉。
这就是这个问题棘手的地方:没有任何报错,上游调用返回 200。症状只是"比应该的更慢",以及有时候什么都没有。
在 OpenAI、Google、Anthropic 等厂商的推理模型中,reasoning effort(有的叫 thinking budget、reasoning_effort)是一个控制"思考多少"的参数,典型取值为 low/medium/high 或对应的 token 数值。它的语义是对已经默认推理的模型做相对调节,类似把收音机音量从 8 调到 3。但对默认不推理的模型,这个旋钮并不存在——一旦你在请求里显式传入任何 effort 字段,等于告诉模型"请开始思考,思考程度为 X"。因此同一个参数在不同默认行为的模型上含义相反:在推理模型上是"少想一点",在非推理模型上却是"开始想一点"。网关层对多个模型家族复用一个字段时,这个语义鸿沟正是最容易被忽略的雷区。
修复:一个负向先行断言
真正的修复是一个负向先行断言:
// 修改后(已裁剪)
return /gpt-oss|deepseek-r1|qwq|magistral|gemini-2\.5(?!-flash-lite)/i.test(model);
外加两个测试钉住行为,一个确保非推理模型也不会被加字段:
it('绝不向 Gemini 2.5 Flash-Lite 发送 effort —— 那会打开思考', () => {
const body = buildUpstreamBody({ messages: [] }, spec, 'google/gemini-2.5-flash-lite');
expect(body.reasoning).toBeUndefined();
});
it('不碰非推理模型', () => {
const body = buildUpstreamBody({ messages: [] }, spec, 'meta-llama/llama-3.3-70b-instruct');
expect(body.reasoning).toBeUndefined();
});
另外,网关也不再轻信 200。如果剥掉所有 <think> 块之后可见回复是空的,这次调用就算失败,而不是"已交付答案"。
真正的教训:别问"支持推理吗"
作者自己承认,先行断言只是最小的修复,不是最好的。真正的教训是他问错了问题。
问题不是"这个模型支持推理吗"——Flash-Lite 确实支持,而这正是麻烦所在。问题是"这个模型在没人告诉它任何东西时,会不会自己思考"。而模型家族在这一点上并不一致:一个 -lite 或 -mini 变体的默认行为可能和它的兄弟型号完全相反,而用家族名正则匹配的代码,会在你切过去的那天悄悄把它卷进来。
更好的规则是:把上限逻辑绑定在你验证过的精确模型 ID 上,而不是看起来像你验证过的名字上。还有一个只用一次调用就能抓住这种问题的廉价测试:在一个模型进入延迟敏感的通道之前,用极小的 max_tokens 调它一次,读取 reasoning token 数,并断言可见回复不是空的。
在 API 设计里,capability(能力) 与 default behavior(默认行为) 是两个独立维度:模型"支持"某功能,不代表它"默认启用"。绝大多数模型文档只声明前者,比如"supports reasoning"或"supports function calling",而不会强调它默认是否开启。这对网关尤其危险,因为网关通常只从客户端收到一个虚拟标签,无法让用户临时覆盖默认值。稳妥做法是为每个模型维护至少两个布尔或枚举标记:supportsReasoning 和 defaultsToReasoning,再由网关根据策略显式决定是否注入 reasoning 字段。此外,model ID(如 google/gemini-2.5-flash-lite)是上游 API 的稳定契约,而模型家族名只是人类可读的命名习惯,二者语义不同,用家族名做分支逻辑相当于把展示层字符串当协议用。
给多模型网关的实战建议
如果你在一个端点后面跑多个模型家族,这类坑几乎无法完全避免,但可以降低踩中的概率。
把"会用推理"和"默认会推理"分开建模。 很多 API 文档只告诉你模型"支持"推理,但不会强调默认行为。支持不等于默认开启,这个区分在网关层非常关键。不要用一个 boolean 打发,至少要为每个模型维护两个标记。
用显式 ID 列表替代名字正则。 正则在模型名字越来越像的今天非常脆弱,flash、flash-lite、flash-thinking 这类变体只要多一个后缀,默认行为就可能反过来。维护一份经过验证的 ID 白名单,比猜名字安全得多。
把空回复当作失败信号。 网关不应该把 200 和空字符串直接当成成功。剥掉思考块之后如果可见内容是空的,就应该重试、降级或报错,而不是把空白交给用户。
用一次小调用做上线前体检。 新模型进延迟敏感通道前,固定做一次 max_tokens 极小的探测,读 reasoning token 数,断言可见回复非空。这是一行就能写的"烟雾测试",但它能挡住的正是这类只在生产里发作的静默故障。
作者最后抛出的问题也值得每个做网关的人想一想:如果你运营着不止一个模型家族,你打算怎么跟踪哪些是默认会思考的?他手上只有一份手工维护的正则,而经过这件事之后,他已经不太信任它了。
相关推荐

uGAP:统一100+非洲地理数据集的AI平台
uGAP是开普敦大学团队开发的非洲地理空间AI平台,统一120余个非洲数据集并提供Python SDK,支持数据发现、处理与模型训练一站式工作流,已用于气旋洪水制图等实战场景。

Cursor能控制你的电脑了:AI代理可驱动桌面应用测试
Cursor 新增桌面控制能力,AI 代理可驱动 iOS 模拟器等桌面应用测试自己构建的功能。本文分析这一更新对开发工作流的影响、技术实现可能路径及可靠性考量。

Vibecoding自研推理引擎:理想丰满,现实骨感
Reddit开发者分享Vibecoding自研推理引擎的真实经历:用WebGL2和GLSL在浏览器跑GGUF Q4模型,却陷入tokenizer调试地狱。本文分析浏览器端LLM推理的工程难点,以及为何直接clone llama.cpp是更理性的选择。