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

21 KiB
Raw Blame History

type, node_id, title, date
type node_id title date
plan plan:2026-08-02-thinking-capability 推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6) 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 新增:

@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(= 能力未知,由调用方决定退化)。"""
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 Falsecapability.can_disable is False ValueError:该模型无法关闭推理
5 其余 返回 slot

第 2 步必须先于第 4 步:形态未知时无从注入,能力如何无关紧要。第 3 步先于第 4 步:未登记模型无 can_disable 可读。

transports/openai_compat.py 新增:

def _coerce_reasoning_tokens(usage: Any) -> int | None:
    """取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。"""

任务清单

T1 — reasoning_tokens 进入类型与采集路径

  • 文件:改 src/polygateway/types.pysrc/polygateway/transports/openai_compat.pysrc/polygateway/middleware/retry.py;改测 tests/unit/test_types.pytests/unit/test_openai_compat.pytests/unit/test_retry.py

行为LLMResponseTransportResult 尾部各加 reasoning_tokens: int | None = None(字段顺序是公共承诺,见 types.py:1-5,只增不删不改名)。新增 _coerce_reasoning_tokens,语义与 _coerce_cached_tokensopenai_compat.py:161-177)逐条对齐:非 dict 返回 Nonecompletion_tokens_detailsdict 返回 Nonebool 显式排除(isinstance(True, int) 为真,放行会把 True 记成 1);负数返回 None0 如实保留。流式(: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: 7reasoning_tokens == 7;该键为 00(不与 None 混同);completion_tokens_details 缺失 / 非 dict / 值为 True / 值为 -1 → 均为 Nonemissing_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.pysrc/polygateway/telemetry/sqlite.pysrc/polygateway/telemetry/postgres.pysrc/polygateway/middleware/telemetry.py;改测 tests/unit/test_ports.pytests/unit/test_telemetry.pytests/integration/test_postgres_telemetry.py

行为record_llm_callsampling 之后追加 reasoning_tokens: int | None(不设默认值——库外无第三方实现者,见 ports.py:250 注释)。两个后端在建表 DDL、_BACKFILL_COLUMNSsqlite/ 迁移语句列表(postgres)、_COLUMNS 三处各加一项,新列必须排在末尾(两文件均有明文注释:旧表只能 ALTER 追加,新建库若插在前面会与迁移路径的物理列序分叉)。middleware/telemetry.py_record 加参数,三个 emit_* 入口按 cached_prompt_tokens 的既有形态填值:emit_attemptresponse.reasoning_tokens if response else Noneemit_cache_hit 原样回放,emit_terminal_failureNone

不改 pricing.py:推理 token 已含在 completion_tokens 内,单列计价即重复计费。

验收:新建库与经 ALTER 迁移的旧库物理列序一致;reasoning_tokens=7 / 0 / None 三种值各自如实落库(0NULL 可区分);遥测写失败仍降级为 warning 不冒泡。

测试证据:先扩字段清单断言 → 失败 → 实现 → 通过。

验证conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v → PASSconda 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)——它正是把「不支持」与「未知」编码成同一个值的根因。

minimaxthinking_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 Noneqwen / 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

行为:按「关键接口」一节的签名实现 ThinkingCapabilityDEFAULT_CAPABILITIESget_capabilityregister_capabilityresolve_thinking。注册表用 MappingProxyType 只读,注册走纯函数返回新表(不修改共享状态,纯 asyncio 中立铁律),与既有 register_providerproviders.py:84-90)同形。

DEFAULT_CAPABILITIES 首发五条,evidence 逐条写明实测日期与样本量:

can_disable evidence 要点
MiniMax-M3 True 2026-08-02 实测 N=10reasoning_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 并产生一条 warningloguru 不经标准 loggingpytest 的 caplog 抓不到——必须复用项目既有写法 logger.add(messages.append, level="WARNING"),见 tests/unit/test_config.py:29-31);enable_thinking=False + MiniMax-M2.7ValueError 且消息含模型名与"无法关闭"字样;enable_thinking 任意非 None + openai profile 抛 ValueError 且消息含 register_providerextra_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_settingsfrom_env 各增加 capabilities 参数并透传给 OpenAICompatTransportfrom_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_providerclient.py:248openai_compat.py:313 双点调用的既有形态一致。

其二,build_model_fingerprintclient.py:63-80)把 enable_thinking 纳入摘要。实现必须保持既有不变量——全源不配 enable_thinking 时指纹字面量与改动前逐字相同

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.7GatewaySettingsfrom_settingsValueErrorprovider=openai + 任意非 Noneenable_thinkingValueError

验收:装配期报错两例;改 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 / L2stream=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_tokensreasoning_tokens 只作辅助。中转在上游不返回 usage 时会本地补算并吃掉 completion_tokens_detailsfindings §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.mdresearch-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 / 新能力」一行,需改 参考-公共APILLMResponse 新字段)与相关指南页;该表把同步绑定在变更上而非发版上,不可推迟。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。