Files
PolyGateway/research-wiki/designs/2026-08-02-thinking-capability-design.md
T
iomgaa 5eb01a0096 chore: release 1.0.6 and keep the live matrix out of the CI gate
The thinking matrix had been running inside make ci all along, which is
not what the design claimed. It takes seven minutes, spends 137 real
calls, and its criteria are statistical, so a network hiccup fails the
build for reasons unrelated to the change under test -- one run died on
three consecutive network errors exhausting the source.

The project already has the mechanism for this: the slow marker, which
addopts excludes by default and the config comments describe as "CI runs
it on demand". Marking the matrix slow brings make ci back down from
seven minutes to ninety seconds while the matrix stays a merge
requirement via -m slow.

The design also claimed e2e does not run in CI. It does: make test runs
pytest over tests/, e2e included, and the existing smoke tests really
call the gateway whenever .env has credentials. Only slow-marked tests
are excluded. Both documents now say so.

Version sources are pyproject and __init__; a test enforces they agree,
and it caught the second one being missed.
2026-08-02 08:12:01 -04:00

20 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 slow 标记被默认排除;合并前必须 -m slow 真跑并存档报告

不让本组阻断 CI 的理由是外部不可用会误伤:实测中 kimi 渠道在 429 后被中转下线并返回 404,另有一次 network_error 连续三次耗尽源导致 L2 假红。让外部波动阻断合并,会把测试变成噪声源。

实现机制:给本组打项目既有的 slow 标记。pyproject.tomladdopts = "-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 < 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_tokens2026-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=0400),而 400 归 RequestRejectedError 不重试不换源,下游统一下发 temperature=0 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。

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