781579bf36
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.
259 lines
18 KiB
Markdown
259 lines
18 KiB
Markdown
---
|
||
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 | 不进 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>` 标签),无害死代码,可顺带清理或另记。
|