48805cb9fb
The verifier caught that the disable-direction evidence only proved "no regression", not "actually took effect": on M3 the disabled runs and the no-opinion baseline are identically distributed, because that model does not reason by default anyway. So the disable runs alone cannot rule out the very failure mode issue #5 is about -- the parameter being silently dropped upstream. The bogus-value experiment that does rule it out was sitting in the findings document instead of the test suite; it is now case L3b, and the L3 assertion that could never fail is gone. Also from the review: the e2e helper caught bare Exception, which would have disguised a library bug as an unavailable source, exactly the silence the reporting discipline exists to prevent; the unregistered model warning fired on every request instead of once per source; and the transport caught ValueError broadly enough to mislabel unrelated errors, now narrowed to a dedicated ThinkingUnsupportedError. The design and plan still described the original judgement criteria, which the measurements had already overturned. Both now match what the tests actually do, and the design no longer claims the only new failure surface is the openai one -- dissect configures MiniMax-M2.7 with ENABLE_THINKING=false and will fail at assembly, which has to be coordinated before this merges.
261 lines
19 KiB
Markdown
261 lines
19 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`。**(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 漂移哨兵是发现失真的第一道防线。
|
||
|
||
**新增两处失败面,其中一处会立刻打挂 dissect。**(2026-08-02 独立核验修正:本节初稿只列了 `openai` 那一处,遗漏了 M2.x。)其一是 `provider=openai` + 配了 `ENABLE_THINKING`,经全仓与 dissect 检索当前无此用法(dissect 的 K3 scope 用 `provider=openai` 但未配该项)。其二是**关不掉推理的模型 + `ENABLE_THINKING=false`**,而 `dissect/.env:80,85` 正是 `MiniMax-M2.7` + `false` —— 合并后该 scope 装配即抛 `ValueError`,实验链启动就挂。这是本设计的**预期行为**(给不了语义保证就必须说),但必须与 dissect 协调后再合并,不能突然打挂它。
|
||
|
||
**三个参考下游零破坏**: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>` 标签),无害死代码,可顺带清理或另记。
|