---
type: design
node_id: design:2026-08-02-thinking-capability-design
title: "推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)"
date: 2026-08-02
---

# 推理开关能力建模与 reasoning_tokens 采集（issue #5 + #6）

> 类型：design｜日期：2026-08-02｜状态：待人类确认
> 事实基础见 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`（本文所有实测引用均出自该文）。
> 本设计经 2026-08-02 充分讨论后直接给出单一方案，不列备选。

## 1. 问题

**issue #5——静默失效。** `SourceConfig.enable_thinking` 是给上层的统一推理开关，靠 `providers.py` 的 `ProviderProfile.thinking_on/thinking_off` 落地。`minimax` 与 `openai` 两格皆为空 dict，`_build_payload` 的 `payload.update({})` 是空操作：`enable_thinking=False` 对这两类源**完全不产生效果**，而配置方以为关掉了。

这不是理论缺陷。`dissect/.env:84,99` 两个 scope 均写 `ENABLE_THINKING=false`，并在 `:67-70` 记为明确阻塞项——Phase-0 要求关闭思维链以隔离变量。

**issue #6——归因缺口。** `usage.completion_tokens_details.reasoning_tokens` 未被采集。成本总额正确（推理 token 已含在 `completion_tokens` 内），但"本次调用有多少钱花在推理上"无法区分，而这正是 dissect 要测的因子的主要成本通道。

**两者的耦合。** #6 是 #5 的验收仪器：修完 #5 后判断"这次是否真的没推理"，靠正文长度不可靠，靠 `reasoning_content` 也不行（MiniMax 非流式恒为空、正文无 `<think>` 标签）。因此 **#6 先落地，#5 的测试断言它**。

## 2. 根因

空 dict 同时承载了两种语义：「本 provider 无需注入任何参数」与「我们不知道本 provider 怎么表达」。二者混同，就只能靠"表里没有 = 不发"兜底，静默失效随之产生。

更深一层：`ProviderProfile` 的注册单位是 **provider**，而"能否关闭推理"是 **model** 的属性。实测证明同一 provider 内部代际差异是决定性的——MiniMax-M3 可关，M2.7 / M2.5 **固有不可关**（三种参数形态实测全部无效，OpenRouter 与 models.dev 独立登记为 mandatory）。provider 级的表在物理上表达不了这件事。

业界佐证：注册单位下沉到 model 级的（LiteLLM、models.dev、LangChain、OpenRouter、Helicone）都有显式失败通道；仍停在 provider 级的（Portkey、LlamaIndex）恰是失败语义最差的两家，均静默丢弃。**注册粒度与失败语义是同一个问题的两面。**

## 3. 决策摘要

| # | 决策 |
|---|---|
| D1 | **形态留 provider 级，能力下沉 model 级**。形态 = 参数长什么样（数年不变）；能力 = 能否关闭（每代都变） |
| D2 | **「未知 / 不支持 / 不干预」必须是三个不同的值**，落在三个不同层次 |
| D3 | **遇到"关不掉"的模型报错，不静默放行**；报错在装配期，请求期兜底 |
| D4 | **「开」的默认档定 `medium`，允许 per-source 覆盖**（经已有 `extra_body`，不新增字段） |
| D5 | `enable_thinking` **纳入缓存指纹**（配套，必做） |
| D6 | `reasoning_tokens` 的文档措辞为「**本次调用**未上报」，非「该源未上报」（配套，必做） |

D4 的依据：业界对「开」映射到哪一档**无语义共识**（LiteLLM 用 2 的幂、OpenRouter 用百分比、Helicone 一律折半），唯一的工程共识是**该映射必须是可覆盖的常量**。选 `medium` 是因为 qwen 的 `enable_thinking:true` 与 deepseek 的 `thinking:{enabled}` 都不指定预算、由模型自定，`medium` 是五档中语义最接近"厂商正常强度"的一档；选 `high` 等于库替所有下游做"加钱换质量"的业务判断，违反零业务假设。

## 4. 数据模型

### 4.1 形态层（provider 级）

`ProviderProfile` 两档由 `dict` 放宽为 `dict | None`：

| 值 | 含义 | 当前实例 |
|---|---|---|
| `{...}` | 已知的注入片段 | qwen / deepseek / minimax |
| `{}` | 已知**无需注入**即处于该档 | 无（保留为自然零值） |
| `None` | **未知**：库不知道该 provider 如何表达 | `openai` 两档 |

```python
"minimax": ProviderProfile(
    name="minimax",
    thinking_on={"reasoning_effort": "medium"},
    thinking_off={"reasoning_effort": "none"},
    strip_think_tags=False,
),
"openai": ProviderProfile(
    name="openai", thinking_on=None, thinking_off=None, strip_think_tags=False,
),
```

`openai` 填 `None` 而非补 `reasoning_effort`，理由是该段名在实践中已被复用为**任意 OpenAI 兼容厂商的兜底**（`dissect/.env:116` 把 `kimi-k3` 挂在 `provider=openai` 下）。向未知厂商下发 `reasoning_effort` 会招致 400；标为未知则让误配在装配期显式暴露。真·OpenAI 推理模型的使用者走 `register_provider`——这正是 D11 承诺的"新 provider = 一个条目"。

qwen / deepseek 两条实测正确，**不动**。

### 4.2 能力层（model 级，新增）

```python
@dataclass(frozen=True)
class ThinkingCapability:
    """某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
    can_disable: bool
    evidence: str
```

登记表键为模型名精确匹配，**只登记在用的模型**，未登记即"未知"并走退化路径：

| 模型 | `can_disable` | 证据 |
|---|---|---|
| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10，`reasoning_effort=none` 稳定关闭 |
| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效；OpenRouter `mandatory:true` |
| `MiniMax-M2.5` | `False` | 同上 |
| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 |
| `deepseek-v4-pro` | `True` | 实测 `thinking:{disabled}` 关闭 |

注入方式沿用 D11 的纯函数注册纪律：`get_capability(model, *, table=None)` 与 `register_capability(...)` 返回新表，经 `capabilities` 参数注入，与现有 `registry` 参数同形，**不引入模块级可变状态**。

**不引入 models.dev / LiteLLM 的 JSON 作为运行时依赖**——违反依赖极简与纯 asyncio 中立（import 期发网络请求）。二者仅作为写表时的对照参考；本次三条 MiniMax 实测与它们的登记 100% 吻合，这本身就是表可信的旁证。

### 4.3 三个值的层次归属（D2）

| 语义 | 载体 | 层次 |
|---|---|---|
| **不干预**（调用方不表态） | `SourceConfig.enable_thinking is None` | 调用方意图 |
| **未知**（库不知道怎么表达） | `ProviderProfile` 该档为 `None` | 形态层 |
| **不支持**（模型做不到） | `ThinkingCapability.can_disable is False` | 能力层 |

三者不可互相替代：不干预是意图缺失，未知是知识缺失，不支持是能力缺失。当前实现把后两者塌缩成空 dict，是 issue #5 的根因。

## 5. 判定与失败语义（D3）

单一判定函数收口，形态层与能力层在此相遇：

```python
def resolve_thinking(profile, capability, enable_thinking) -> Mapping[str, Any]:
    """三态 + 两层能力 → 注入片段;不可满足时 ValueError(由调用点翻译为领域错误)。"""
```

真值表：

| # | 条件 | 行为 |
|---|---|---|
| R1 | `enable_thinking is None` | 不注入。与 `False` 严格区分 |
| R2 | 形态层该档为 `None` | **报错**，文案指路 `register_provider` 或 `extra_body` |
| R3 | `enable_thinking is False` 且 `can_disable is False` | **报错**：调用方要的是"不推理"的语义保证，给不了必须说 |
| R4 | 模型未登记（能力未知） | 按形态层注入 + `loguru.warning`，不阻断 |
| R5 | 其余 | 按形态层注入 |

R3 与 R4 的极性相反，这是刻意的，借鉴 LiteLLM 的两极性纪律：**"关不掉"用错的后果是下游带着错误前提做实验（opt-in，从严）；"未登记"多为新模型上线（opt-out，从宽）**，误拒会让库成为升级路上的绊脚石。

### 5.1 报错位置：两处，共用同一份判定

| 位置 | 异常 | 覆盖 |
|---|---|---|
| `client.py:from_settings`（`:248` 已在此解析 profiles） | `ValueError`（装配期） | `from_env` / `from_settings` 两条工厂路径，即 90% 场景 |
| `OpenAICompatTransport` | `RequestRejectedError`（四分类之一，不重试不换源） | 构造函数全量注入路径 |

这不是重复判定：`get_provider` 现在就是同一形态（`client.py:248` + `openai_compat.py:313`）。双点校验的必要性来自 issue #1 的教训——**装配守卫必须任何构造路径都生效**。

**绝不在 `_build_payload` 里抛裸 `ValueError`**：该处位于 RetryMW 内侧，裸异常不属错误四分类、`TelemetryMW` 也不捕，会导致一行遥测都没有就逃出 `chat()`。

## 6. reasoning_tokens 采集（issue #6）

照搬 issue #3 的 `_coerce_cached_tokens` 形态：只收非负整数，显式排除 `bool`（`isinstance(True, int)` 为真，放行会把 `True` 记成 1）。

`LLMResponse` / `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`——字段顺序是公共承诺（`types.py:1-5`），只增不删不改名。

流式与非流式对称取值：`completion_tokens_details` 在最后的 usage 帧里，`missing_done="salvage"` 打捞路径拿不到时记 `None` 而非 `0`（现有代码天然满足：`sink` 无 usage 时 `_coerce_*` 返回 `None`）。

**`pricing.py` 一行不改**：推理 token 已含在 `completion_tokens` 内，单列计价即重复计费。这是归因缺口，不是计费缺口。

**缓存路径无需改动**：`CacheMW._rehydrate` 按 `_RESPONSE_FIELDS` 动态过滤（`cache.py:28,133`），旧条目缺该字段自动落 `None`，语义正确。

### 6.1 语义澄清（D6）

实测三家在未推理时都是**整个 `completion_tokens_details` 对象缺失**，无一上报 `0`。且 new-api 在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage，把 ctd 一并吃掉（实测同一请求 10 轮呈 6:4 双峰）。因此：

- docstring 写「**本次调用**未上报」，**不可**写「该源未上报」
- 下游判据必须是 `reasoning_tokens in (None, 0)`，写 `== 0` 的条件永远不成立
- 这三句要同时进 docstring、CHANGELOG 与 wiki

## 7. 缓存指纹配套（D5）

`build_model_fingerprint`（`client.py:63-80`）当前只摘要 `(model, extra_body)`。#5 一旦让 thinking 真正改变请求体，就会出现"关掉推理后重启读到开着推理时的旧缓存"——issue #4 为 `temperature` 写过逐字相同的理由。

做法：marks 的判据由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`，摘要对象并入该值。**全源不配 `enable_thinking` 时字面量与现值逐字相同，不触发存量缓存冷启动**；dissect 会有一次性冷启动，这是正确行为（旧缓存来自推理开着的调用）。

## 8. 落点清单

| 文件 | 改动 |
|---|---|
| `providers.py` | 两档放宽为 `dict \| None`；填 minimax、`openai` 改 `None`；新增 `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability` / `resolve_thinking` |
| `transports/openai_compat.py` | `_build_payload` 两分支收敛为一行 `resolve_thinking(...)`；新增 `_coerce_reasoning_tokens`；流式 `:401` 与非流式 `:485` 填值；构造函数收 `capabilities` |
| `client.py` | `from_settings` / `from_env` 加 `capabilities`；`:248` 后加装配守卫；`build_model_fingerprint` 纳入 `enable_thinking` |
| `types.py` | `LLMResponse` / `TransportResult` 尾部加 `reasoning_tokens` |
| `middleware/retry.py` | `_build_response` 透传 |
| `ports.py` | `record_llm_call` 21 → 22 字段 |
| `telemetry/{sqlite,postgres}.py` | 建表列 + `_BACKFILL_COLUMNS` 迁移 + `_COLUMNS`，**新列排末尾**（两处注释均有明文要求） |
| `middleware/telemetry.py` | `_record` + 三个 `emit_*` 入口 |

## 9. 测试策略

本次改动的正确性**与具体模型强相关**，mock 只能验证代码路径、无法验证"这个参数在这个模型上是否真的关掉了推理"。因此核心行为**必须由真实 API 多轮调用验证**。

### 9.1 三层分工

| 层 | 内容 | 是否门控合并 |
|---|---|---|
| unit | `resolve_thinking` 真值表（R1–R5）、`_coerce_reasoning_tokens` 形态防御、注入优先级、装配守卫报错、缓存指纹变化与不变性 | **是**（CI 可跑） |
| integration | 遥测两后端新列写入与 ALTER 迁移 | **是** |
| **e2e（真实 API）** | 见 9.2 | 打 `slow` 标记被默认排除；**合并前必须 `-m slow` 真跑并存档报告** |

不让本组阻断 CI 的理由是外部不可用会误伤：实测中 kimi 渠道在 429 后被中转下线并返回 404，另有一次 `network_error` 连续三次耗尽源导致 L2 假红。让外部波动阻断合并，会把测试变成噪声源。

**实现机制**：给本组打项目既有的 `slow` 标记。`pyproject.toml` 的 `addopts = "-m 'not slow'"` 默认排除它（该配置的注释原文：「慢速测试，CI 按需跑」），合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。实测效果：`make ci` 由 7 分钟降至 91 秒。

**一处必须澄清的事实**：`make test` 跑的是 `pytest tests/`，**包含 `tests/e2e/`**——只要 `.env` 有凭据，既有的轻量 e2e 冒烟就会真跑。所以「e2e 不进 CI」这句对本项目**并不成立**，只有打了 `slow` 的才被排除；本节初稿写成前者，是错的。「不自动门控」也不等于「可跳过」——沿用既有口径（`tests/e2e/test_smoke_gateway.py:22` 的 reason 写着「验收前必须真跑」）。

### 9.2 e2e 覆盖矩阵

沿用既有 e2e 约定：`dotenv_values(".env")` + `pytestmark = pytest.mark.skipif(not _HAS_SOURCE, ...)`，结构化报告输出至 `tests/outputs/e2e/`。

| # | 场景 | 源 | 轮数 | 判据 |
|---|---|---|---|---|
| L1 | `enable_thinking=False` | MiniMax-M3 | ≥10 | 每轮 `completion_tokens < 30` 且 `reasoning_tokens` 恒 `None` |
| L2 | `enable_thinking=True` | MiniMax-M3 | ≥10 | 多数轮 `completion_tokens > 100`；请求体实发 `reasoning_effort=medium` |
| L3 | `enable_thinking=None` | MiniMax-M3 | ≥10 | 不注入任何 thinking 参数（基线） |
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | ≥5 | 实发 `high`，profile 的 `medium` 被覆盖 |
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 ≥10 | 同 L1 / L2（库默认 `stream=True`，这是主路径） |
| L6 | `enable_thinking=False` | qwen | ≥10 | 关闭 |
| L7 | `enable_thinking=False` | deepseek | ≥10 | 关闭 |
| L8 | **能力表漂移哨兵** | 全部登记模型 | 各 ≥5 | 实测行为与 `can_disable` 声明一致 |
| L9 | `enable_thinking=False` + M2.7 → 装配期报错 | — | — | 纯本地，无需真实调用 |

轮数由环境变量可调高，默认 ≥10。总量约 100–150 次调用。

### 9.3 三条必须遵守的测试纪律

**（a）判别量只能是 `reasoning_tokens`。**（2026-08-02 e2e 实测修正：本节初稿写的是"主判据用 `completion_tokens`"，被数据推翻。）两档的输出长度分布**重叠**——关闭档实测最高 46（模型偶尔把解题过程写进正文），开启档最低 13（medium 档想得少的轮次），按长度阈值判两个方向都会误判；而 `reasoning_tokens` 在同一批 30 轮里干净分开。`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路。另配一个不含魔数的确定性锚点：关闭档 `prompt_tokens` 严格小于开启档（实测 194 < 207）。

**（b）多轮 + 计数判定，不用单轮判定。** 关闭方向要求**每轮**都满足（关掉后 `completion_tokens` 极稳定，实测 4–10）；开启方向只要求**多数轮**满足（推理量方差大）。

**（c）源不可用必须跳过并显式记录为"未覆盖"，不得静默计入通过。** 报告里要能一眼看出哪些矩阵行没跑到。

### 9.4 漂移哨兵（L8）的定位

能力表过期是必然事件（LiteLLM 有过 `gpt-5.1-mini` 漏登记导致误拒的真实事故）。L8 用真实调用反向校验每条登记，是这张表的**过期告警**——模型升级后若 `can_disable` 声明失真，这里会先炸。建议纳入发版前清单定期执行。

## 10. 明确不做

不为中转的观测漂移在库内加任何机制（多轮取众数、渠道探测、重试到拿到 `reasoning_tokens`）——中转路由不受请求参数影响，探测结果不可迁移，属 YAGNI 违规；该问题在运维侧解决，写入 wiki 前提。

不改 `SourceConfig` 的公开字段形态：`enable_thinking` 保持 `bool | None`。分档需求走已有的 `extra_body` / `overlay`，两条路径已进缓存 key 与 `sampling` 遥测列，新增字段则要额外接这两处，是隐藏成本。

不动 qwen / deepseek 的 profile；不碰 `pricing.py`；不引入任何新依赖。

## 11. 验收标准

1. `ENABLE_THINKING=false` + MiniMax-M3 → 请求体含 `reasoning_effort: none`，响应 `reasoning_tokens is None`，真实 API 多轮验证
2. `ENABLE_THINKING=false` + MiniMax-M2.7 → **装配期报错**，文案说明该模型无法关闭推理
3. `ENABLE_THINKING` 任意非 `None` + `provider=openai` → **装配期报错**，指路 `register_provider` / `extra_body`
4. 未登记模型 + 任意 `enable_thinking` → 正常注入 + 一条 warning
5. `extra_body={"reasoning_effort":"high"}` 仍覆盖 profile 注入
6. 流式与非流式均能采到 `reasoning_tokens`；打捞路径记 `None` 而非 `0`
7. 改 `enable_thinking` → 缓存 key 变化；不配该项的存量 scope key 逐字不变
8. 遥测两后端新列可写、旧库经 ALTER 迁移后可写
9. e2e 报告存档于 `tests/outputs/e2e/`，矩阵覆盖情况可核

每条均需"先失败后通过"的证据（测试结果门）。

## 12. 影响与风险

**这是行为变更，不是纯修复。** MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效"，CHANGELOG 须醒目标注；dissect 会有一次性缓存冷启动。

**dissect 的 Phase-0 实验设计需调整。** M2.7 上做不了"开思考 vs 关思考"的对照——这是模型固有属性，任何库层改动都无法改变。可行替代是只在 M3 上做该对照，或将因子改为"高档 vs 低档"。此结论须同步给 dissect。

**能力表的正确性依赖实测，且经中转。** 三条 MiniMax 结论均在自建 new-api 中转下取得，直连官方端点未验证；表中每条 `evidence` 须写明这一点。若下游改为直连，L8 漂移哨兵是发现失真的第一道防线。

**新增两处失败面。**（本段两次修正：初稿只列了 `openai` 那一处、遗漏 M2.x；二稿又把 M2.x 那处写成「合并即打挂 dissect」，同样不准确——见下。）

其一是 `provider=openai` + 配了 `ENABLE_THINKING`，经全仓与 dissect 检索当前无此用法（dissect 的 K3 scope 用 `provider=openai` 但未配该项）。

其二是**关不掉推理的模型 + `ENABLE_THINKING=false`**，而 `dissect/.env:80,85` 正是 `MiniMax-M2.7` + `false`。准确的影响是：**dissect 升到 1.0.6 之后**，该 scope 装配会抛 `ValueError`；它当前跑着的版本不受本次发布影响。但 `dissect/requirements.txt:7` 声明的是 `polygateway>=1.0.1,<1.1` —— 一个**范围**而非精确 pin，`1.0.6` 落在范围内，所以任何一次 `pip install -U`、重建环境或 CI 重装依赖都会**自动**装上它，无需谁刻意升级。换言之不是「突然挂」，而是「下次装依赖时挂」。

这是本设计的**预期行为**（给不了「不推理」的语义保证就必须说），dissect 侧的处置是改配置：该对照只能在 M3 上做，或把因子改为「高档 vs 低档」。

**三个参考下游零破坏**：VT / CHS / GovDoc 的 thinking 用法均为二元，本方案不改公开字段形态。

## 13. 另立 issue（不在本次范围）

`kimi-k3` 拒绝 `temperature=0`（400），而 400 归 `RequestRejectedError` 不重试不换源，下游统一下发 `temperature=0` 会导致此类源 100% 硬失败。与本次两条 issue 同源（供应商能力差异未被建模），但属采样参数域，独立处理。

`qwen` 的 `strip_think_tags=True` 已过时（实测走 `reasoning_content`，正文无 `<think>` 标签），无害死代码，可顺带清理或另记。
