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,258 @@
---
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` 真值表(R1R5)、`_coerce_reasoning_tokens` 形态防御、注入优先级、装配守卫报错、缓存指纹变化与不变性 | **是**CI 可跑) |
| integration | 遥测两后端新列写入与 ALTER 迁移 | **是** |
| **e2e(真实 API** | 见 9.2 | 不进 CI 自动门,但**合并前必须真跑并存档报告** |
e2e 不进 CI 自动门的理由是外部不可用会误伤:实测中 kimi 渠道在 429 后被中转下线并返回 404。让外部波动阻断合并,会把测试变成噪声源。但"不自动门控"不等于"可跳过"——沿用项目既有 e2e 的口径(`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` 会被 new-api 的本地补算吃掉(实测 6:4 随机),单轮断言必然 flaky;而 `completion_tokens` 在补算路径下依然有值。因此**"是否关闭"的主判据用 `completion_tokens` 阈值,`reasoning_tokens` 作辅助**。这是本次实测最重要的工程教训之一。
**(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 漂移哨兵是发现失真的第一道防线。
**三个下游零破坏**VT / CHS / GovDoc 的 thinking 用法均为二元,本方案不改公开字段形态。新增的失败面仅有 `provider=openai` + 配了 `ENABLE_THINKING` 这一组合,经全仓与 dissect 检索当前无此用法。
## 13. 另立 issue(不在本次范围)
`kimi-k3` 拒绝 `temperature=0`400),而 400 归 `RequestRejectedError` 不重试不换源,下游统一下发 `temperature=0` 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。
`qwen``strip_think_tags=True` 已过时(实测走 `reasoning_content`,正文无 `<think>` 标签),无害死代码,可顺带清理或另记。