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.
18 KiB
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.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 两档 |
"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 级,新增)
@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)
单一判定函数收口,形态层与能力层在此相遇:
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 会被 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. 验收标准
ENABLE_THINKING=false+ MiniMax-M3 → 请求体含reasoning_effort: none,响应reasoning_tokens is None,真实 API 多轮验证ENABLE_THINKING=false+ MiniMax-M2.7 → 装配期报错,文案说明该模型无法关闭推理ENABLE_THINKING任意非None+provider=openai→ 装配期报错,指路register_provider/extra_body- 未登记模型 + 任意
enable_thinking→ 正常注入 + 一条 warning extra_body={"reasoning_effort":"high"}仍覆盖 profile 注入- 流式与非流式均能采到
reasoning_tokens;打捞路径记None而非0 - 改
enable_thinking→ 缓存 key 变化;不配该项的存量 scope key 逐字不变 - 遥测两后端新列可写、旧库经 ALTER 迁移后可写
- 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=0(400),而 400 归 RequestRejectedError 不重试不换源,下游统一下发 temperature=0 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。
qwen 的 strip_think_tags=True 已过时(实测走 reasoning_content,正文无 <think> 标签),无害死代码,可顺带清理或另记。