--- 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 非流式恒为空、正文无 `` 标签)。因此 **#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 漂移哨兵是发现失真的第一道防线。 **新增两处失败面,其中一处会立刻打挂 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`,正文无 `` 标签),无害死代码,可顺带清理或另记。