Files
PolyGateway/research-wiki/designs/2026-08-02-thinking-capability-design.md
T
iomgaa 781579bf36 docs: record thinking-switch findings and capability design (issue #5, #6)
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.
2026-08-02 05:42:05 -04:00

18 KiB
Raw Blame History

type, node_id, title, date
type node_id title date
design design:2026-08-02-thinking-capability-design 推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6) 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.pyProviderProfile.thinking_on/thinking_off 落地。minimaxopenai 两格皆为空 dict_build_payloadpayload.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 两档
"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,
),

openaiNone 而非补 reasoning_effort,理由是该段名在实践中已被复用为任意 OpenAI 兼容厂商的兜底dissect/.env:116kimi-k3 挂在 provider=openai 下)。向未知厂商下发 reasoning_effort 会招致 400;标为未知则让误配在装配期显式暴露。真·OpenAI 推理模型的使用者走 register_provider——这正是 D11 承诺的"新 provider = 一个条目"。

qwen / deepseek 两条实测正确,不动

4.2 能力层(model 级,新增)

@dataclass(frozen=True)
class ThinkingCapability:
    """某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
    can_disable: bool
    evidence: str

登记表键为模型名精确匹配,只登记在用的模型,未登记即"未知"并走退化路径:

模型 can_disable 证据
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:{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

单一判定函数收口,形态层与能力层在此相遇:

def resolve_thinking(profile, capability, enable_thinking) -> Mapping[str, Any]:
    """三态 + 两层能力 → 注入片段;不可满足时 ValueError(由调用点翻译为领域错误)。"""

真值表:

# 条件 行为
R1 enable_thinking is None 不注入。与 False 严格区分
R2 形态层该档为 None 报错,文案指路 register_providerextra_body
R3 enable_thinking is Falsecan_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 形态:只收非负整数,显式排除 boolisinstance(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_fingerprintclient.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、openaiNone;新增 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_envcapabilities: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 真值表(R1R5)、_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 < 30reasoning_tokensNone
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 实发 highprofile 的 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=0400),而 400 归 RequestRejectedError 不重试不换源,下游统一下发 temperature=0 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。

qwenstrip_think_tags=True 已过时(实测走 reasoning_content,正文无 <think> 标签),无害死代码,可顺带清理或另记。