docs: record thinking-switch findings and capability design (issue #5, #6)

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.
This commit is contained in:
2026-08-02 05:42:05 -04:00
parent ce630a37ef
commit 781579bf36
6 changed files with 455 additions and 4 deletions
@@ -0,0 +1,179 @@
---
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`;该字段缺失时以 `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 |
真关闭应为 510"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 级** |
注册单位的分布很能说明问题:LiteLLM2986 条目)、models.dev5949 条)、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)——"观测不到"与"没发生"是两件事。