---
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.4 M3 的稳定性

同一请求打 10 次，`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`，零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。

### 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），因为供应商在开启时向模板注入了推理指令。这是相对比较，供应商改模板也不会失效。

## 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. 未能证实

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` 分布是重叠的，长度阈值两个方向都会误判。
