--- type: finding node_id: finding:2026-08-02-thinking-switch-and-reasoning-tokens title: "推理开关与 reasoning_tokens: 供应商实测与业界做法" date: 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`(**唯一可靠的判别量**,见 §2.5) | | 旁证 | `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.5 输出长度不是有效判别量(2026-08-02 e2e 补测,各 15 轮) 初版判据用 `completion_tokens` 阈值区分推理开关,被自己的数据证伪: | 档位 | `completion_tokens` 观测范围 | `reasoning_tokens` | |---|---|---| | 关闭(`reasoning_effort=none`) | 4 – **46** | 15/15 轮为 `None` | | 开启(`medium`) | **13** – 186 | 15/15 轮 > 0 | **两档的输出长度分布重叠**:关闭档偶尔到 46(模型没照做「只输出两个数字」,把解题过程写进了正文——那是正文不是推理);开启档最低到 13(medium 档想得少的轮次)。按长度阈值判,两个方向都会误判。 而 `reasoning_tokens` 在同一批 30 轮里干净分开。**这条对下游同样成立**:想判断某次调用是否发生了推理,只能看 `reasoning_tokens`,不能看输出长度。 另有一个不含魔数的确定性锚点:同一模型上关闭档的 `prompt_tokens` 严格小于开启档(实测 194 < 207),因为供应商在开启时向模板注入了推理指令。这是相对比较,供应商改模板也不会失效。 ### 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` 字段,正文中无 `` 标签。无害,但属于死代码。 - **非流式没有 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. 未能证实 1. **MiniMax 官方文档对 `reasoning_effort` 的一手定义**:官方文档站三次抓取均失败。M2.x 关不掉有三处佐证,但官方原文未取得。另有二手来源称 MiniMax 原生开关是 `thinking:{type:"adaptive"/"disabled"}`——**该说法已被本次实测证伪**(M2.7/M2.5 上两种写法均无效),但"中转是否对 `reasoning_effort` 做了改写"仍未排除。直连官方端点复测可彻底澄清。 2. **qwen 直连 DashScope 时非流式 `enable_thinking` 是否仍报 400**:仅验证了经中转的行为。 3. **new-api 走本地补算的确切触发条件**:读到了补算分支与 `local_count_tokens` 标记,未逐条比对所有渠道类型。双峰现象与该解释高度吻合,但未在日志中直接验证。 4. **能力表条目对非本次实测模型的正确性**:qwen / deepseek 只测了各一个型号,同系其他型号未验证。 ## 8. 对后续开发的指导 1. **判定参数是否生效,优先看 `prompt_tokens` 而非输出长度**(§1)。 2. **排除"无效值被静默丢弃"必须做反证实验**:传一个乱码值,看它的行为是否与目标值不同(§2.2)。 3. **经中转做的任何实测都要标注"经中转,直连未验证"**,并写进注释(§3、§7)。 4. **新增供应商或模型前,先查 OpenRouter `/api/v1/models` 与 models.dev**——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。 5. **能力表条目必须附实测证据与日期**;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。 6. **`reasoning_tokens` 缺失只能记 `None`,绝不可记 `0`**(§4c)——"观测不到"与"没发生"是两件事。 7. **判断"是否发生了推理"只能看 `reasoning_tokens`,不能看输出长度**(§2.5)——两档的 `completion_tokens` 分布是重叠的,长度阈值两个方向都会误判。