Files
PolyGateway/research-wiki/plans/2026-08-02-thinking-capability.md
T
iomgaa e5871cccd2 docs: add implementation plan for thinking capability and reasoning tokens
Ten tasks in a fixed order: land reasoning_tokens first so it can serve
as the acceptance instrument for the thinking-switch fix, then reshape
the provider profile, add the model-level capability table, wire the
assembly guard, fold enable_thinking into the cache fingerprint, and
verify the whole thing against the live API.

Incorporates a read-only Codex review: resolve_thinking now takes the
model name so its errors can name it, and the warning assertion uses a
loguru sink because caplog cannot see loguru output.
2026-08-02 05:49:55 -04:00

275 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: plan
node_id: plan:2026-08-02-thinking-capability
title: "推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)"
date: 2026-08-02
---
# 推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6
**目标**:让 `enable_thinking` 对每个源要么真实生效、要么显式报错,并采集 `reasoning_tokens` 以区分推理开销与生成开销。
**方案概述**`ProviderProfile` 保留为「形态层」(参数长什么样,按 provider),新增 model 级「能力层」声明该模型能否关闭推理;两层在单一判定函数 `resolve_thinking` 相遇,装配期与请求期共用。同时照搬 issue #3`_coerce_cached_tokens` 采集 `reasoning_tokens`,并把 `enable_thinking` 纳入缓存指纹。
**涉及技术**Python 3.11 frozen dataclass、`MappingProxyType` 只读注册表、httpx、SQLite/PostgreSQL DDL 迁移、pytest。
**依据文档**:设计 `designs/2026-08-02-thinking-capability-design.md`;事实基础 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`
**保真校验**:本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/types.py` | 修改 | `LLMResponse` / `TransportResult` 尾部各加 `reasoning_tokens` |
| `src/polygateway/providers.py` | 修改 | 形态层放宽为 `dict \| None`;新增能力层与 `resolve_thinking` |
| `src/polygateway/transports/openai_compat.py` | 修改 | 采集 `reasoning_tokens``_build_payload` 接入 `resolve_thinking`;收 `capabilities` |
| `src/polygateway/middleware/retry.py` | 修改 | `_build_response` 透传 `reasoning_tokens` |
| `src/polygateway/ports.py` | 修改 | `record_llm_call` 21 → 22 字段 |
| `src/polygateway/telemetry/sqlite.py` | 修改 | 建表列 + `_BACKFILL_COLUMNS` + `_COLUMNS`(新列排末尾) |
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上 |
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` + 三个 `emit_*` 入口 |
| `src/polygateway/client.py` | 修改 | `capabilities` 参数贯通;装配守卫;缓存指纹纳入 `enable_thinking` |
| `tests/e2e/test_thinking_live.py` | 新建 | 真实 API 矩阵 L1L9 |
| `CHANGELOG.md` / `research-wiki/schemas/llm-calls.md` | 修改 | 行为变更说明与字段表 21 → 22 |
**任务顺序不可调换**T1T3 先把 `reasoning_tokens` 打通(#6#5 的验收仪器),T4–T7 再改推理开关,T8 用真实 API 验证,T9 收尾文档。
## 关键接口(跨任务消费,此处给出实际代码)
`providers.py` 新增:
```python
@dataclass(frozen=True)
class ThinkingCapability:
"""某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
can_disable: bool
evidence: str
def get_capability(
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
) -> ThinkingCapability | None:
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定退化)。"""
```
```python
def register_capability(
model: str,
capability: ThinkingCapability,
*,
base: Mapping[str, ThinkingCapability] | None = None,
) -> dict[str, ThinkingCapability]:
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
enable_thinking: bool | None,
*,
model: str,
) -> Mapping[str, Any]:
"""三态 + 两层能力 → 注入片段;不可满足时 ValueError(调用点翻译为领域错误)。
model 只用于错误与告警文案: 报错必须能定位到具体模型才有可操作性,
而 capability 为 None(未登记)时无从从别处取得模型名。
"""
```
`resolve_thinking` 的判定顺序(**顺序即语义,不可调换**):
| 步 | 条件 | 行为 |
|---|---|---|
| 1 | `enable_thinking is None` | 返回 `{}`(不干预) |
| 2 | 对应档 `slot is None` | `ValueError`:形态未知,指路 `register_provider` / `extra_body` |
| 3 | `capability is None` | `loguru.warning` 后返回 `slot`(能力未登记,从宽放行) |
| 4 | `enable_thinking is False``capability.can_disable is False` | `ValueError`:该模型无法关闭推理 |
| 5 | 其余 | 返回 `slot` |
第 2 步必须先于第 4 步:形态未知时无从注入,能力如何无关紧要。第 3 步先于第 4 步:未登记模型无 `can_disable` 可读。
`transports/openai_compat.py` 新增:
```python
def _coerce_reasoning_tokens(usage: Any) -> int | None:
"""取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。"""
```
## 任务清单
### T1 — `reasoning_tokens` 进入类型与采集路径
- [ ] **文件**:改 `src/polygateway/types.py``src/polygateway/transports/openai_compat.py``src/polygateway/middleware/retry.py`;改测 `tests/unit/test_types.py``tests/unit/test_openai_compat.py``tests/unit/test_retry.py`
**行为**`LLMResponse``TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`(字段顺序是公共承诺,见 `types.py:1-5`,只增不删不改名)。新增 `_coerce_reasoning_tokens`,语义与 `_coerce_cached_tokens``openai_compat.py:161-177`)逐条对齐:非 `dict` 返回 `None``completion_tokens_details``dict` 返回 `None``bool` 显式排除(`isinstance(True, int)` 为真,放行会把 `True` 记成 1);负数返回 `None``0` 如实保留。流式(`:401` 附近)取 `sink.get("usage")`、非流式(`:485` 附近)取 `body.get("usage")`,与 `cached_prompt_tokens` 同处填值。`retry.py:_build_response` 透传。
**docstring 措辞**(必须逐字,理由见 findings §4c):`None` = **本次调用**未上报,**不可**写「该源未上报」——中转在上游不返回 usage 时会本地补算并吃掉该字段。
**验收**:非流式与流式响应含 `completion_tokens_details.reasoning_tokens: 7``reasoning_tokens == 7`;该键为 `0``0`(不与 `None` 混同);`completion_tokens_details` 缺失 / 非 dict / 值为 `True` / 值为 `-1` → 均为 `None``missing_done="salvage"` 打捞路径(无 usage 帧)→ `None` 而非 `0`
**测试证据**:先加断言 → 失败(字段不存在)→ 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v` → 全部 PASS。
### T2 — 遥测端口 21 → 22 字段与两后端落库
- [ ] **文件**:改 `src/polygateway/ports.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``src/polygateway/middleware/telemetry.py`;改测 `tests/unit/test_ports.py``tests/unit/test_telemetry.py``tests/integration/test_postgres_telemetry.py`
**行为**`record_llm_call``sampling` 之后追加 `reasoning_tokens: int | None`(不设默认值——库外无第三方实现者,见 `ports.py:250` 注释)。两个后端在建表 DDL、`_BACKFILL_COLUMNS`sqlite/ 迁移语句列表(postgres)、`_COLUMNS` 三处各加一项,**新列必须排在末尾**(两文件均有明文注释:旧表只能 ALTER 追加,新建库若插在前面会与迁移路径的物理列序分叉)。`middleware/telemetry.py``_record` 加参数,三个 `emit_*` 入口按 `cached_prompt_tokens` 的既有形态填值:`emit_attempt``response.reasoning_tokens if response else None``emit_cache_hit` 原样回放,`emit_terminal_failure``None`
**不改 `pricing.py`**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。
**验收**:新建库与经 ALTER 迁移的旧库物理列序一致;`reasoning_tokens=7` / `0` / `None` 三种值各自如实落库(`0``NULL` 可区分);遥测写失败仍降级为 warning 不冒泡。
**测试证据**:先扩字段清单断言 → 失败 → 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS 或按既有约定 SKIP(无 PG 凭据时)。
### T3 — 提交点:issue #6 完整可用
- [ ] 运行 `conda run -n PolyGateway make ci`,确认全绿后提交。提交信息类型 `feat`,正文说明 `LLMResponse` 新增字段与遥测端口 21 → 22。此处独立成一个提交,便于 #6 单独回滚。
**测试证据**:本任务不引入新行为,证据即 T1 与 T2 各自的「先失败后通过」记录;提交前需确认这两组记录都已产生,不得以 `make ci` 全绿代替。
### T4 — 形态层放宽与 profile 修正
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
**行为**`ProviderProfile.thinking_on` / `thinking_off` 类型由 `dict[str, Any]` 改为 `Mapping[str, Any] | None`。三值语义写进类 docstring`{...}` = 已知注入片段;`{}` = 已知无需注入即处于该档;`None` = **未知**(库不知道该 provider 如何表达)。删除现有 docstring 里「两档皆空 ⇒ 不产生任何效果」那段(`providers.py:23-25``:50-51`)——它正是把「不支持」与「未知」编码成同一个值的根因。
`minimax``thinking_on={"reasoning_effort": "medium"}``thinking_off={"reasoning_effort": "none"}``openai` 两档改 `None`。qwen / deepseek **不动**(实测正确)。两处均加注释写明:取值依据 2026-08-02 经自建 new-api 中转的实测,直连官方端点未验证。
**验收**`get_provider("minimax").thinking_off == {"reasoning_effort": "none"}``get_provider("openai").thinking_on is None`qwen / deepseek 两档与改动前逐字相同。
**测试证据**`tests/unit/test_providers.py:29,34` 现有断言锁的是空字典,先改成新期望 → 失败 → 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
### T5 — 能力层与 `resolve_thinking`
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
**行为**:按「关键接口」一节的签名实现 `ThinkingCapability``DEFAULT_CAPABILITIES``get_capability``register_capability``resolve_thinking`。注册表用 `MappingProxyType` 只读,注册走纯函数返回新表(不修改共享状态,纯 asyncio 中立铁律),与既有 `register_provider``providers.py:84-90`)同形。
`DEFAULT_CAPABILITIES` 首发五条,`evidence` 逐条写明实测日期与样本量:
| 键 | `can_disable` | `evidence` 要点 |
|---|---|---|
| `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:{"type":"disabled"}` 关闭 |
**验收**`resolve_thinking` 五条判定各有一例;未登记模型返回 `slot` 并产生一条 warning**loguru 不经标准 loggingpytest 的 `caplog` 抓不到**——必须复用项目既有写法 `logger.add(messages.append, level="WARNING")`,见 `tests/unit/test_config.py:29-31`);`enable_thinking=False` + `MiniMax-M2.7``ValueError` 且消息含模型名与"无法关闭"字样;`enable_thinking` 任意非 `None` + `openai` profile 抛 `ValueError` 且消息含 `register_provider``extra_body` 两个指路词;`register_capability` 不修改 `DEFAULT_CAPABILITIES`
**测试证据**:先写五条判定的参数化测试 → 失败(函数不存在)→ 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
### T6 — transport 接入与请求期兜底
- [ ] **文件**:改 `src/polygateway/transports/openai_compat.py`;改测 `tests/unit/test_openai_compat.py`
**行为**`OpenAICompatTransport.__init__` 增加 `capabilities: Mapping[str, ThinkingCapability] | None = None`,与既有 `registry` 参数同形并存于 `self._capabilities``_build_payload` 的两个 `if` 分支(`:293-296`)收敛为两行——先 `capability = get_capability(source.model, table=self._capabilities)`,再 `payload.update(resolve_thinking(profile, capability, source.enable_thinking, model=source.model))`;其后 `payload.update(source.extra_body)``payload.update(overlay)` 两行**顺序不变**(顺序即优先级,issue #4 决策 A)。`complete()` 中把构造 payload 的 `ValueError` 翻译为 `RequestRejectedError`(四分类之一,不重试不换源)。
**绝不在 `_build_payload` 内抛裸 `ValueError` 让它冒泡**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`
**验收**`enable_thinking=True` + minimax 源 → 请求体含 `reasoning_effort: "medium"``False``"none"``None` → 请求体无 `reasoning_effort` 键;`extra_body={"reasoning_effort":"high"}` 时实发 `high`(覆盖 profile);`enable_thinking=False` + M2.7 源经 transport 调用 → `RequestRejectedError` 而非裸 `ValueError`
**测试证据**:扩 `tests/unit/test_openai_compat.py:418-431` 的三态参数化,加 minimax 用例 → 失败 → 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → PASS。
### T7 — 装配守卫、参数贯通与缓存指纹
- [ ] **文件**:改 `src/polygateway/client.py`;改测 `tests/unit/test_cache.py`(指纹相关)、新增装配守卫测试至 `tests/unit/test_config.py`
**行为**(三件事,同一文件):
其一,`from_settings``from_env` 各增加 `capabilities` 参数并透传给 `OpenAICompatTransport``from_settings` 在已解析 `profiles` 之后(`client.py:248`)加装配守卫:对 `zip(sources, profiles, strict=True)` 的每一对,先 `get_capability(src.model, table=capabilities)` 取能力,再调用一次 `resolve_thinking(prof, cap, src.enable_thinking, model=src.model)` 并丢弃返回值——只为让配置错误在装配期即抛 `ValueError`。守卫与 transport 内的判定共用同一函数,不复制逻辑——这与 `get_provider``client.py:248``openai_compat.py:313` 双点调用的既有形态一致。
其二,`build_model_fingerprint``client.py:63-80`)把 `enable_thinking` 纳入摘要。实现必须保持既有不变量——**全源不配 `enable_thinking` 时指纹字面量与改动前逐字相同**
```python
def _fingerprint_mark(s: SourceConfig) -> str:
parts: list[Any] = [s.model, dict(s.extra_body)]
if s.enable_thinking is not None: # 仅在表态时追加,保证存量指纹字面量不变
parts.append(s.enable_thinking)
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
```
筛选条件由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`
其三,为守卫补测:`enable_thinking=False` + `provider=minimax` + `model=MiniMax-M2.7``GatewaySettings``from_settings``ValueError``provider=openai` + 任意非 `None``enable_thinking``ValueError`
**验收**:装配期报错两例;改 `enable_thinking` → 指纹变化;只配 `extra_body`、不配 `enable_thinking` 的源 → 指纹与改动前逐字相同(用硬编码的历史字面量断言,防回归)。
**测试证据**:先写三条断言 → 失败 → 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_config.py -v` → PASS;随后 `conda run -n PolyGateway make ci` → 全绿。
### T8 — 真实 API e2e 矩阵
- [ ] **文件**:新建 `tests/e2e/test_thinking_live.py`
**行为**:沿用既有 e2e 约定(`tests/e2e/test_smoke_gateway.py:19-26`)——`dotenv_values(".env")` 读凭据、`pytestmark = pytest.mark.skipif(not _HAS_SOURCE, ...)`、结构化报告写入 `tests/outputs/e2e/`。**不新造开关机制**。
**源映射**L1L5、L8 的 MiniMax 行用现有的 `LLM__MINIMAX__1__*``MODEL=MiniMax-M3`);M2.7 / M2.5 行经 `dataclasses.replace(source, model=...)` 派生,不新增 `.env` 键。**L6 / L7 目前无对应源**——`.env` 里只有 MINIMAX 与 MONKEY 两类;需新增 `{SCOPE}__QWEN__1__*``{SCOPE}__DEEPSEEK__1__*`(同一中转 `BASE_URL` 与密钥,仅 `MODEL` 不同)。未配置时按既有 `skipif` 约定跳过,并在报告中记为「未覆盖」,**不得静默计入通过**。
覆盖矩阵(轮数经环境变量可调,默认值如下):
| # | 场景 | 源 | 轮数 | 判据 |
|---|---|---|---|---|
| L1 | `enable_thinking=False` | MiniMax-M3 | 10 | **每轮** `completion_tokens < 30`(主判据)且 `reasoning_tokens in (None, 0)`(辅判据,与下游口径一致) |
| L2 | `enable_thinking=True` | MiniMax-M3 | 10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` |
| L3 | `enable_thinking=None` | MiniMax-M3 | 10 | 请求体无 `reasoning_effort` 键 |
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | 5 | 实发 `high` |
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 10 | 同 L1 / L2`stream=True` 是库的默认主路径) |
| L6 | `enable_thinking=False` | qwen | 10 | 每轮 `completion_tokens < 30` |
| L7 | `enable_thinking=False` | deepseek | 10 | 每轮 `completion_tokens < 30` |
| L8 | 能力表漂移哨兵 | 全部登记模型 | 各 5 | 实测行为与 `can_disable` 声明一致 |
| L9 | M2.7 + `enable_thinking=False` → 装配期报错 | — | — | 纯本地,无需真实调用 |
**三条必须遵守的测试纪律**
其一,**主判据用 `completion_tokens``reasoning_tokens` 只作辅助**。中转在上游不返回 usage 时会本地补算并吃掉 `completion_tokens_details`findings §4c 实测同一请求 10 轮呈 6:4 双峰),拿它做单轮断言必然 flaky;而 `completion_tokens` 在补算路径下依然有值。
其二,**关闭方向要求每轮满足,开启方向只要求多数轮满足**。关掉后 `completion_tokens` 极稳定(实测 4–10),推理量则方差大。
其三,**源不可用必须跳过并在报告中显式记为「未覆盖」**,不得静默计入通过(实测中 kimi 渠道 429 后被中转下线并返回 404)。报告要能一眼看出哪些矩阵行没跑到。
**报告内容**`tests/outputs/e2e/test_thinking_live_<ts>.md`):逐轮记录实际注入的 thinking 片段、`prompt_tokens` / `completion_tokens` / `reasoning_tokens`、单轮判定结果;逐行记录矩阵编号、通过或跳过及其原因;文末给出总调用次数与时间戳。原始数字必须落盘——结论可以复核,才算证据。
**验收**:矩阵九行全部有结论(通过 / 明确跳过),报告落盘 `tests/outputs/e2e/`
**验证**`conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -v -s` → PASS,人工核对报告。
### T9 — 文档同步与收尾
- [ ] **文件**:改 `CHANGELOG.md``research-wiki/schemas/llm-calls.md`
**行为**CHANGELOG 必须醒目标注这是**行为变更而非纯修复**——MiniMax 源的 `ENABLE_THINKING` 从「无效」变为「生效」,且配了该项的 scope 会有一次性缓存冷启动。同时写明 `reasoning_tokens` 的语义:`None` = 本次调用未上报,下游判据须为 `in (None, 0)`,写 `== 0` 永远不成立。`schemas/llm-calls.md` 的字段表由 21 改 22,新增行说明该列。
Gitea Wiki(独立仓库)**本任务内必须同步**:按 `docs-convention.md` §2「新公共 API / 新能力」一行,需改 `参考-公共API``LLMResponse` 新字段)与相关指南页;该表把同步绑定在**变更**上而非发版上,不可推迟。`Home.md` 的版本号与安装命令等发版项不在本计划范围。
**验收**:CHANGELOG 含行为变更与冷启动两处提示;schema 文档字段数与 `_COLUMNS` 长度一致。
**验证**:人工核对;`conda run -n PolyGateway make ci` → 全绿。
## 完成后的独立验证
`verification-before-completion` 的强制档,本计划跨多文件,合并前须派**全新上下文**的 verifier subagent 逐条核对设计 §11 的九条验收标准与本计划各任务的测试证据,不得自审代替。
### T10 — 同步结论给 dissect
- [ ] **动作**:在本分支合并时,向 dissect 提一条 issue 或在其 `ROADMAP` 风险表中记录下述结论,并确认对方已读。
**验收**:dissect 侧存在可追溯的记录(issue 编号或文档行号),不以口头告知为准。
## 需要同步给下游的结论
`MiniMax-M2.7` / `M2.5` 的推理**关不掉**是模型固有属性,任何库层改动都无法改变。dissect 的 Phase-0 若要做「开思考 vs 关思考」对照,只能在 M3 上做,或把因子改为「高档 vs 低档」。此结论须在本分支合并时同步给 dissect。