Findings: live-API measurements across MiniMax M3/M2.7/M2.5, qwen and deepseek, plus a survey of how nine unified gateways model per-model parameter divergence. Key facts: reasoning_effort is MiniMax's real switch, M2.x reasoning is mandatory and cannot be disabled, and the relay's local token-count fallback silently drops reasoning_tokens. Design: keep the parameter shape at provider level, push capability down to model level, split "unknown" / "unsupported" / "no opinion" into three distinct values, and fail at assembly time when a model cannot honour enable_thinking=False.
13 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| finding | finding:2026-08-02-thinking-switch-and-reasoning-tokens | 推理开关与 reasoning_tokens: 供应商实测与业界做法 | 2026-08-02 |
推理开关与 reasoning_tokens:供应商实测与业界做法
类型:findings(事实基础)|日期:2026-08-02|来源:issue #5 / #6 调研 本文只记录已验证的事实与其证据,设计取舍见
designs/2026-08-02-thinking-capability-design.md。 本文的价值不限于这两条 issue——「同一语义、形态因模型而异」是本库长期要面对的一类问题,此处的结论与方法可复用。
1. 实验环境与方法
| 项 | 值 |
|---|---|
| 端点 | 自建 new-api 中转(newapi.iomgaa.online/v1,OpenAI 兼容) |
| 参数 | temperature=0、max_tokens=800、非流式为主,流式单独验证 |
| 题目 | 固定一道鸡兔同笼题,要求"只输出两个数字" |
| 判据 | 首选 usage.completion_tokens_details.reasoning_tokens;该字段缺失时以 completion_tokens 兜底(关闭推理应 <30,推理中 >150) |
| 旁证 | prompt_tokens 变化——注入生效的参数会改变模型侧模板,输入侧 token 数随之变化 |
方法论要点(可复用):判断一个参数"是否被上游真正消费",prompt_tokens 比输出长度可靠得多。输出长度受采样影响、方差大;而输入侧 token 数在同一请求体下是确定的,一旦变化就说明服务端换了模板,即参数确实到达了模型。本次三条关键结论全部由这个旁证锁定。
2. MiniMax:真开关是 reasoning_effort
2.1 M3 参数矩阵(非流式)
| 注入参数 | prompt | completion | reasoning_tokens | 判定 |
|---|---|---|---|---|
| 默认(不传) | 194 | 4 | 无 ctd | 不推理 |
reasoning_effort=none |
194 | 10 | 无 ctd | 不推理 |
reasoning_effort=minimal |
207 | 129 | 123 | 推理 |
reasoning_effort=low |
207 | 98 | 93 | 推理 |
reasoning_effort=medium |
207 | 183 | 177 | 推理 |
reasoning_effort=high |
207 | 158 | 142 | 推理 |
thinking={"type":"enabled"} |
194 | 5 | 无 ctd | 被静默丢弃 |
thinking={"type":"disabled"} |
194 | 4 | 无 ctd | 被静默丢弃 |
enable_thinking=true |
194 | 5 | 无 ctd | 被静默丢弃 |
enable_thinking=false |
194 | 5 | 无 ctd | 被静默丢弃 |
prompt_tokens 194→207 的 13 token 差是硬证据:reasoning_effort 被消费时模型注入了推理指令;另四种写法 prompt 恒为 194,参数根本没到达模型。
2.2 none 是被识别的真值,不是被当非法值丢弃
这是一个必须排除的伪解释——若中转把不认识的值直接丢掉,none 的表现会与"不传"无异,我们就会误以为它生效。
反证实验:传乱码值 reasoning_effort="xyzzy" → 返回 200、prompt=207、reasoning_tokens=180。未知值不但没被丢弃,反而开启了推理。 既然无效值的行为是"开推理",而 none 的行为是"不推理",两者不同,none 就必然是被识别的枚举值。
对照组:完全未知的键 zzz_bogus_param=1 → prompt=194、无 ctd、无报错,确认未知键才会被静默吞掉。
2.3 M2.7 / M2.5 的推理关不掉
三种参数形态各 3 次,completion_tokens 全部落在推理区间:
| 模型 | 默认(基线) | reasoning_effort=none |
thinking:{disabled} |
thinking:{adaptive} |
|---|---|---|---|---|
| MiniMax-M2.7 | 372/283/285 | 275/301/248 | 310/190/219 | 299/269/246 |
| MiniMax-M2.5 | 273/–/256 | 363/353/264 | 286/278/320 | 278/228/259 |
真关闭应为 5–10("23 12" 两个数字),实测无一接近。
三个独立外部来源与实测完全吻合:
| 来源 | M3 | M2.7 / M2.5 |
|---|---|---|
OpenRouter /api/v1/models 的 reasoning 描述符 |
mandatory: false |
mandatory: true |
models.dev 的 reasoning_options |
[{"type":"toggle"}](二元可控) |
[](有推理但无控制手段) |
| MiniMax 官方仓库 issue #121 | — | "M2.7 不允许关闭思考",无官方回复 |
结论:M2.x 的推理是模型固有属性,不是参数没找对。 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。
2.4 M3 的稳定性
同一请求打 10 次,(prompt_tokens, 是否上报 ctd) 全部为 (194, False),零跳变——enable_thinking=False 的修复可以建立在 M3 上。
3. qwen / deepseek:现有 profile 正确
| 模型 | enable_thinking=false |
thinking:{disabled} |
reasoning_effort=none |
现有 profile |
|---|---|---|---|---|
| qwen3.7-plus | ✅ 关闭(compl 5) | ✅ 关闭 | ✅ 关闭 | enable_thinking — 正确 |
| deepseek-v4-pro | ❌ 无效(仍推理 198) | ✅ 关闭(compl 3) | ✅ 关闭 | thinking:{type} — 正确 |
两点附带事实:
reasoning_effort=none在三家都有效,但这很可能是中转做了参数归一化。不可据此认为可以统一发一个参数——下游若直连供应商官方端点,该假设大概率不成立。翻译表必须一家一行。- qwen 的
strip_think_tags=True已过时:实测 qwen 走reasoning_content字段,正文中无<think>标签。无害,但属于死代码。 - 非流式没有 400:DashScope 系"
enable_thinking仅支持流式"的限制经中转不存在。直连时是否仍存在未验证。
4. new-api 中转的三个行为(会污染观测)
这一节对任何经中转做实测的场景都适用,值得单独记住。
(a)不校验参数值。 reasoning_effort="xyzzy" 返回 200 并当作"开推理"处理。意味着"靠上游报错兜底"的设计模式在此失效——Bedrock 式的"最小交集 + 裸逃生口"在这里等于零保护。
(b)静默丢弃未知键。 默认路径是 struct round-trip(ConvertRequest 返回 struct 再 json.Marshal),未知键在第一次序列化就消失。new-api 有 per-channel 的 pass_through_body_enabled 开关可改变此行为。
(c)上游不返回 usage 时用本地 tokenizer 补算并整体替换。 补算出的 usage 只有三个标量,completion_tokens_details 为零值。这直接解释了实测中的双峰现象:
| 现象 | 解释 |
|---|---|
同一请求 10 次:prompt=74 者 6 次不上报 reasoning_tokens,prompt=72 者 4 次上报,从不交叉 |
74 = 本地估算值,72 = 上游真值;补算路径吃掉了 ctd |
这不是多渠道路由(MiniMax 侧为单渠道单密钥),也不是配置错误,而是上游偶发不返回 usage 时的兜底逻辑。中转日志中的 local_count_tokens 标志可现场确认。
对库的直接影响:reasoning_tokens 缺失不能解释为"该源不上报这个字段",只能解释为"本次调用未上报"。下游若按前者建立统计口径会算错。
5. 业界如何建模"同一语义、形态因模型而异"
调研覆盖 LiteLLM、OpenRouter、models.dev、LangChain、Vercel AI SDK、AWS Bedrock Converse、Portkey、Helicone、LlamaIndex、new-api/one-api。
5.1 核心共识:形态按 provider,能力按 model
| 概念 | 变化频率 | 应归属层次 |
|---|---|---|
形态:参数长什么样(enable_thinking / thinking.type / reasoning_effort) |
协议方言,一个供应商数年不变 | provider 级 |
| 能力:能否关闭、有几档、默认开不开 | 模型属性,同一供应商每代都变 | model 级 |
注册单位的分布很能说明问题:LiteLLM(2986 条目)、models.dev(5949 条)、LangChain、OpenRouter(细到 endpoint)、Helicone 全部下沉到 model 级;仍停在 provider 级的只有 Portkey 与 LlamaIndex,而这两家恰是失败语义最差的两家(均静默丢弃)。二者相关不是偶然:注册单位不够细,就只能靠"表里没有 = 不发"来兜底,而这正是静默失效的成因。
5.2 失败语义的四种谱系
| 语义 | 代表 | 适用前提 |
|---|---|---|
| 默认报错 + 可配置降级开关 | LiteLLM(UnsupportedParamsError + drop_params) |
有 model 级能力表可依据 |
| 软降级 + 显式 warning 通道 | Vercel AI SDK(丢弃参数并 push warnings[]) |
调用方愿意读 warning |
| 静默忽略 + 可选路由过滤 | OpenRouter(默认忽略;require_parameters:true 改为排除不支持的上游) |
网关自己拥有路由权 |
| 硬失败(透传给上游报错) | Bedrock(inferenceConfig 4 字段交集 + additionalModelRequestFields 裸透传) |
上游会诚实报错 |
选型时先问"我的上游会不会诚实报错"。若不会(如本项目的中转),最后一种直接出局,静默类也不能选。
5.3 表会过期,这是公理
LiteLLM 有过真实事故(issue #27351:gpt-5.1-mini 漏登记导致 temperature 被误拒)。它的应对是两种相反极性,值得直接借鉴:
- opt-in 能力(用错会 400 或悄悄花钱):未登记 → 视作不支持 → 拒绝
- opt-out 能力(多半支持,误拒代价大):未登记 → 放行 → 只有表里显式写
false才拒
维护方式上,LiteLLM/models.dev 靠社区 PR + CI 校验,LangChain 靠"上游拉取 + 本地增补 + 代码生成"。对内部库而言唯一现实的答案是:谁实测出来谁登记,登记必须附实测证据与日期。
5.4 「布尔开关 → 多档旋钮」无语义共识
| 系统 | effort → 预算的换算 |
|---|---|
| LiteLLM | 一组 2 的幂(1024/2048/4096/8192/16384),全部可用环境变量覆盖;gemini 各型号还另有分叉 |
| OpenRouter | max_tokens 的百分比(≈80%/50%/20%) |
| Helicone | 一律 max_tokens/2,完全不看档位 |
| LangChain | 明确不保证跨 provider 可比 |
唯一对齐的是"关":none / disabled / thinking:{type:"disabled"} / OpenRouter effort:"none" 语义一致。"开"那一端没有任何标准。
工程共识只有一条:这个映射必须是可覆盖的常量,不是可推导的公式。 业界所有人都在拍脑袋,区别只在拍完让不让调用方改。
5.5 Vercel AI SDK 的一处设计值得单记
它的推理档位枚举里有一个 'provider-default',与 'none'(明确关闭)严格区分。这与本库 enable_thinking 的三态(None 不干预 / True / False)是同一思想——"调用方不表态"必须是一个独立的值,不能与任何具体档位混同。本库这一点原本就做对了,应保持。
6. 附带发现(不属本次范围,建议另立 issue)
kimi-k3 拒绝 temperature=0:返回 400 invalid temperature: only 1 is supported(另有渠道回 only 0.6)。本库把 400 归入 RequestRejectedError——不重试、不换源。若下游统一下发 temperature=0,此类源会 100% 硬失败。这与本次两条 issue 同源:供应商能力差异未被建模。
中转渠道可用性会波动:kimi 渠道在 429 后被中转下线,随后返回 404 Model not supported by any channel。任何依赖真实 API 的测试都必须容忍源不可用(跳过并给出明确原因),而不是失败。
7. 未能证实
- MiniMax 官方文档对
reasoning_effort的一手定义:官方文档站三次抓取均失败。M2.x 关不掉有三处佐证,但官方原文未取得。另有二手来源称 MiniMax 原生开关是thinking:{type:"adaptive"/"disabled"}——该说法已被本次实测证伪(M2.7/M2.5 上两种写法均无效),但"中转是否对reasoning_effort做了改写"仍未排除。直连官方端点复测可彻底澄清。 - qwen 直连 DashScope 时非流式
enable_thinking是否仍报 400:仅验证了经中转的行为。 - new-api 走本地补算的确切触发条件:读到了补算分支与
local_count_tokens标记,未逐条比对所有渠道类型。双峰现象与该解释高度吻合,但未在日志中直接验证。 - 能力表条目对非本次实测模型的正确性:qwen / deepseek 只测了各一个型号,同系其他型号未验证。
8. 对后续开发的指导
- 判定参数是否生效,优先看
prompt_tokens而非输出长度(§1)。 - 排除"无效值被静默丢弃"必须做反证实验:传一个乱码值,看它的行为是否与目标值不同(§2.2)。
- 经中转做的任何实测都要标注"经中转,直连未验证",并写进注释(§3、§7)。
- 新增供应商或模型前,先查 OpenRouter
/api/v1/models与 models.dev——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。 - 能力表条目必须附实测证据与日期;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。
reasoning_tokens缺失只能记None,绝不可记0(§4c)——"观测不到"与"没发生"是两件事。