It records the asked-for one. CacheMW sits outside the transport in the onion, so at lookup time the nearest-mapping has not happened yet and the applied tier does not exist. Telemetry's success rows do record the mapped tier, which is where the confusion came from — the warning conflated the two and would have sent anyone debugging a cache miss the wrong way. Also repairs the design doc: the 2026-09-05 rollback note had been spliced into the equivalence table, orphaning its last row, and §3.1 still said seven tiers after `auto` made it eight.
31 KiB
推理档位一等化设计(issue #20 及其一般形式)
- 日期: 2026-09-04
- 状态: 2026-09-04 人类已批准(经 Claude 自审 → Codex 独立审 → 人类审批门)
- 触发: issue #20 —— 智谱无 profile,下游只能手写
extra_body,本库为推理准备的三道机制被静默绕过 - 影响面:
SourceConfig/ChatRequest公共类型、ProviderProfile/ThinkingCapability公共类型、resolve_thinking/reconcile_thinking公共函数、缓存 key 公式(ARCH §7.5)、遥测端口(25 → 26 字段)、.env键 - 人类拍板(2026-09-04): 作用域取「源级默认 + 请求级覆盖」;档位不支持时「默认报错、可显式开映射」;不可关闭时「报错并给可执行替代」
- 人类复核(2026-09-04,针对 Codex 异议):
effort_fallback的最近档映射要实现,不因当前无已知消费者而推迟;方案选择标准 = 架构可维护性/清晰度 > 代码简洁 > 鲁棒性
1. 问题不是 issue #20 说的那个
issue #20 的字面诉求是补一条 zhipu profile。补上它不能解决它自己描述的失败,因为二态 bool 在新一代模型上无档可填。
2026-09-04 调研,四份独立注册表(cherry-studio 客户端注册表、OpenRouter /models 的 reasoning 字段、LiteLLM 模型元数据、我们自己的网关 new-api relaykit/relayconvert/reasoning/)与六家官方文档,三条结论直接推翻 issue #20 的建议:
| # | 结论 | 证据 |
|---|---|---|
| 1 | GLM-5.3 官方强制推理,thinking.type 只接受 enabled;官方档位 low/high/max,none 不是它的档位 |
智谱官方文档;cherry toggle:false;OpenRouter mandatory:true 三源一致 |
| 2 | medium 只在 GPT-5.x / Claude 5 / Gemini 3 三家存在 |
见 §8 档位表 |
| 3 | 不可关闭不是孤例: GLM-5.3 系、Gemini 3 Pro / 3.1 Pro 为 mandatory;MiniMax M2.x 接受 disabled 但不生效 |
官方文档;与本库 2026-08-02 实测一致 |
第 1 条意味着 issue #20 建议的 can_disable=True 不能登记:我们发出去的 reasoning_effort:"none" 是个未定义值,智谱按自己的方式处理(多半当最低档)。这正好解释 issue #20 自己观测到的「短提示词 rt≈1.2,5552 token 长上下文跳到 0/54/167」——低档本来就要想,只是短提示词下想得少。
第 2 条意味着现有 minimax profile 那条「thinking_on 统一取 medium」的约定,推广到 GLM/kimi/deepseek 上全部是空档。
真实缺口: enable_thinking: bool | None 这个类型表达不了现实。补数据不能修复类型。
2. 现状审计(旧行为逐条处置)
替换 thinking.py 的请求侧决策,响应侧与对账基本保留。逐条声明:
| # | 现有行为 | 处置 |
|---|---|---|
| 1 | enable_thinking 三态: None 不注入 / True 注入 on / False 注入 off |
保留语义,降为 reasoning_effort 的语法糖(§4.2) |
| 2 | ProviderProfile.thinking_on/off 两个固定片段,None=形态未知 |
替换为 ThinkingWire(§3.3);None=未知的语义保留。判据须按请求档位取相关字段(旧版 slot = thinking_on if enable_thinking else thinking_off 即如此)——初稿 §4.1 Phase 2 写成「只看 on_base」是错的: 那会让「关闭形态已知、开启形态未知」的自定义 provider 在请求 none 时被误拒,且指路指向它已经做过的 register_provider,比不指更糟(2026-09-05 独立验证查出) |
| 3 | ThinkingCapability.can_disable: bool |
替换为 supported_efforts;can_disable 成为 'none' in supported_efforts 的派生(§3.2) |
| 4 | evidence: str 强制附实测出处 |
保留,且强化: 初始表全部标注「文档推定,待实测」 |
| 5 | resolve_thinking 四道关卡(不表态/形态未知/能力未登记/不可关闭) |
保留四关的顺序与语义,判据从 bool 换成档位(§4.1) |
| 6 | 能力未登记 → warning 后尽力注入 | 保留(新模型不该被库挡住,ARCH §5 R4) |
| 7 | observe_thinking 多信号裁定三态 |
保留,不改一行 |
| 8 | reconcile_thinking 声明 × 观测对账,矛盾返回文案、不抛错 |
保留,判据扩展到档位(§4.3) |
| 9 | transport 按 (source, model, direction) 节流告警 |
替换: 节流键的 direction 换成生效档位——同一模型 low 与 max 是两个独立的矛盾 |
| 10 | ThinkingUnsupportedError(ValueError),由 transport 翻译为 RequestRejectedError |
保留,新增的档位错误走同一条路 |
| 11 | _build_payload 中 resolve_thinking 结果先于 extra_body/overlay |
保留(顺序即优先级,issue #4 决策 A) |
| 12 | 缓存 key 不含任何推理参数 | 修复(§5,现存缺口) |
| 13 | 遥测无档位列 | 新增一列(§6) |
有意放弃: 无。第 3 条的 can_disable 是唯一的破坏性变更,迁移见 §12。
3. 数据模型
3.1 档位词汇
八档封闭枚举,取四家参考实现共同收敛的词汇(cherry / OpenRouter / LiteLLM / new-api 用的是同一套):
class Effort(StrEnum):
NONE = "none"; AUTO = "auto" # 不推理 / 推理但档位由模型自定
MINIMAL = "minimal"; LOW = "low"; MEDIUM = "medium"
HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
none 即「不推理」,与强度档同处一个词汇表——这是关键的表达力来源: 「能不能关」不再是独立的布尔,而是 none 在不在该模型的支持列表里。
auto 不可省(自审补): newapi 上 26 个模型里有 9 个是纯开关型(qwen 五个、MiniMax-M3、glm-5/5.1/4.6v),它们能开推理但没有档位名可填。没有 auto 就只能拿某个强度档冒充「开」,而那正是现有 thinking_on 硬编码 medium 的病根。auto 的 wire = on_base 不附 effort_key,恰好等于旧的 thinking_on 行为。四家参考实现都有这一档(cherry 的 canonical selection 'default'|'none'|'auto'|Effort;new-api 的 ModeAdaptive)。
Effort 归 types.py(最内层纯值类型),与 ThinkingObservation 同处一处,理由相同: 它是 SourceConfig/ChatRequest 的字段类型,定义在决策模块会让 types.py 反向 import。
3.2 ThinkingCapability: 能力(按 model)
@dataclass(frozen=True)
class ThinkingCapability:
supported_efforts: tuple[Effort, ...] # 顺序 = 由弱到强
evidence: str
不设 default_effort 字段(Codex 审查采纳): 初稿有此字段,唯一消费者是「enable_thinking=True 等价于哪档」;自审把该语法糖改成 Effort.AUTO 后它就没有消费方了——§4 不用它决策,§6 遥测在不表态时记 NULL(库并不观测模型内部默认档,记推定值等于把「没看见」说成「发生了」,违既有纪律)。厂商默认档是文档知识,写进 evidence 文本即可,不必升格为必须逐模型维护的 API 字段(P1 YAGNI)。
三个派生量,不单独存字段(存了就会漂移):
| 派生 | 定义 | 用途 |
|---|---|---|
can_disable |
Effort.NONE in supported_efforts |
兼容旧语义 |
cheapest_effort |
除 none 外的第一档 |
不可关闭时的可执行替代(§4.1 Phase 5) |
| 是否档位型 | 除 none/auto 外仍有 ≥1 档 |
决定告警文案(纯开关型不该说「可选档位」) |
OpenRouter 与 LiteLLM 两家独立收敛到了同一形状(supported_efforts+default_effort / reasoning_effort_levels+default_reasoning_effort),这是「档位清单即能力」这一形状可靠的旁证。我们只取其前半——两家都是面向展示的目录(要在 UI 上显示默认档),本库是执行路径,默认档不参与任何判定,故不设该字段。
3.3 ProviderProfile: 形态(按 provider)
@dataclass(frozen=True)
class ThinkingWire:
off: Mapping[str, Any] | None # 关闭档的片段;None = 该 provider 无关闭形态
on_base: Mapping[str, Any] | None # 开启档的固定部分;None = 形态未知
effort_key: str | None # 档位写进哪个键;None = 该 provider 无档位概念
ProviderProfile.thinking_on/thinking_off 由 thinking: ThinkingWire 取代。None 表示「未知」这一语义原样保留(issue #5 的核心成果,不可退回)。
四个形态样例(经 new-api 中转的口径):
| provider | off | on_base | effort_key |
|---|---|---|---|
| zhipu | {"thinking":{"type":"disabled"}} |
{"thinking":{"type":"enabled"}} |
reasoning_effort |
| qwen | {"enable_thinking": False} |
{"enable_thinking": True} |
None(无档位,只有 toggle) |
| openai / anthropic / google | {"reasoning_effort":"none"} |
{} |
reasoning_effort |
| minimax | {"reasoning_effort":"none"} |
{} |
reasoning_effort |
3.4 为什么不需要 cherry 的 endpoint contract 与 wireDialect
cherry 有两层我们明确不做:
- endpoint-keyed 的 per-model wire 覆盖。它需要这层,是因为同一模型在
openai-chat/openai-responses/anthropic-messages/google-generate-content四种协议下形态不同。本库只有一个 chat transport(openai_compat.py),所有请求都是 OpenAI 兼容形态,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent,见relaykit/relayconvert/reasoning/intent.go)。一个协议 = 一层形态。 wireDialect代际方言(Claude 4.6+adaptivevs ≤4.5budget_tokens;Gemini 3thinkingLevelvs 2.xthinkingBudget)。这是原生协议才有的问题;我们发 OpenAI 形态的reasoning_effort,代际差异由网关吸收。
同理,glm-5.2(有 none 档)与 glm-5.3(无 none 档)共用同一份 wire——差别落在 capability 的 supported_efforts 上。本库既有的「形态按 provider、能力按 model」分层,恰好容纳档位而无需新增一层。
4. 解析
4.1 resolve_thinking: 五道关卡
判定顺序即语义。前三关是既有的,判据从 bool 换成档位;Phase 4「可执行替代」是新增的,Phase 5 是既有第 4 关的档位化推广。
| Phase | 条件 | 结果 |
|---|---|---|
| 1 | 生效档位为 None(调用方不表态) |
返回 {},不注入 |
| 2 | 该请求档所需的形态未知(请求 none 看 wire.off,其余档看 wire.on_base;none 方向须 off 与 on_base 皆为 None 才算「整体形态未知」——单 off is None 是「该 provider 关不掉」,归 _inject 说清缺的是哪半边,2026-09-05 实现时补正) |
ThinkingUnsupportedError,指路 register_provider/extra_body |
| 3 | 能力未登记 | warning 后按 wire 尽力注入,不校验档位 |
| 4 | 请求 none 而该模型无 none 档 |
ThinkingUnsupportedError,给出 cheapest_effort 作为替代 |
| 5 | 其余档位不在 supported_efforts 且未开映射 |
ThinkingUnsupportedError,列出该模型可选档 |
4 必须先于 5(自审补): none 只是 5 的一个特例,若让它落进 5 的通用分支,报错就退化成「不支持 none,可选 low/high/max」——丢掉了「这个模型根本关不掉」这个关键信息与可执行替代。
Phase 4 的文案是本设计的一个交付物,而非装饰:
模型 'glm-5.3' 无法关闭推理(官方
thinking.type只接受 enabled);最省的档是 'low',请配LLM__ZHIPU__1__REASONING_EFFORT=low或调用时传reasoning_effort=Effort.LOW。evidence: ...
理由: 该分支若只报错不给出路,下游会去找 extra_body 那条绕过的路——那正是 issue #20 的成因。报错必须带可执行替代,否则等于把用户推回起点。
映射(Phase 5 的逃生口)默认关闭,由 SourceConfig.effort_fallback="nearest" 显式开启,按 supported_efforts 的顺序取最近档并 warning。
Codex 审查异议与人类复核: Codex 指出本项当前无可复验的消费者,引入它要带来配置项、映射算法、warning 口径与测试面。人类 2026-09-04 复核后确认实现——理由是这条逃生口的价值不取决于今天有没有人用它:换模型是常态,而「换完就跑不起来」与「换完静默涨价」之间需要一个下游可以显式选择的中间档。故本项随本期一并实现,含映射方向、warning 口径与测试。默认关闭的理由是钱: 一次静默的
medium→max在 GLM-5.3 上是数倍账单,「严禁默认值掩盖错误」(P5)在此有真金白银的含义。
4.2 生效档位的优先级
request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语法糖) > None
enable_thinking 保留不删(它已被三项目消费,迁移兼容约束见 ARCH §5.1),降级为语法糖:
| 旧写法 | 等价于 |
|---|---|
enable_thinking=False |
reasoning_effort=Effort.NONE |
enable_thinking=True |
reasoning_effort=Effort.AUTO(注入 on_base,不附档位),不依赖能力表 |
True 的等价性分两种(2026-09-04 实现时发现,更正初稿「与旧行为逐字节等价」的说法):
| provider 类型 | 旧 thinking_on |
新 AUTO 注入 |
是否等价 |
|---|---|---|---|
on_base 完整表达「开」(qwen/deepseek/zhipu/moonshot) |
{"enable_thinking": True} 等 |
同左 | 逐字节等价 |
| 靠档位表达「开」(openai/anthropic/google) | {"reasoning_effort": "medium"} |
{}(不注入) |
行为变更 |
| 同上但默认不推理(minimax) | {"reasoning_effort": "medium"} |
同左(2026-09-05 回退) | 逐字节等价 |
第一行是有意的: 旧版那个 medium 是库替下游做的档位判断(profile 注释自己承认「取 medium 是因为它是五档里语义最接近厂商正常强度的一档」),而 medium 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本设计要消灭的东西。语义仍是「开」(这三家的模型经 OpenRouter 登记默认即推理),只是不再强制一个档;要指定强度请显式配 REASONING_EFFORT。须进 CHANGELOG 的行为变更条目。
第三行是 2026-09-05 的回退(issue #21,人类拍板的最小修复): 上述「语义仍是开」依赖「模型默认就推理」这个前提,T10 真实网关实测证明 MiniMax-M3 不满足它——不发任何推理参数时 5/5 轮不推理。故 minimax 段的 on_base 改回 {"reasoning_effort": "medium"},存量 ENABLE_THINKING=true 的行为逐字恢复。这是权宜之计: 正解是让 auto 受能力表约束(模型不支持「由模型自定」时报错并指路显式档位),属公共行为变更,下一版处理。
同源同时配 enable_thinking 与 reasoning_effort 且语义矛盾(如 True + none)→ 构造期 ValueError。不做「后者赢」的静默兜底: 两个字段表达同一件事时,矛盾是配置错误,不是优先级问题。
4.3 reconcile_thinking: 对账扩展
现有对账只判「要求关闭却观测到推理」与「要求开启却未推理」。档位化后新增一类可判定的矛盾:
- 请求
none、模型登记can_disable=True、却观测到OBSERVED→ 既有文案,保留(这正是 issue #20 第 3 条要恢复的机制)。 - 请求非
none档、观测到ABSENT→ 既有文案,保留。 - 不做「档位高低与
reasoning_tokens多少的对账」: 档位与 token 数没有可判定的函数关系(issue #20 自己的数据里 glm-5.3-flash 的 medium 档 rt 在 8~56 之间跳),拿它报警必然是噪声。这条留给 §11 的压测,不进库。
4.4 归一化不变式(实现期补,2026-09-05)
库内一切档位判定都是 is Effort.X 的身份比较,故每条能让档位进入库内的入口都必须先归一
(types.coerce_effort)。裸字符串不归一的后果不是报错而是静默判否——("none" is Effort.NONE)
恒假,于是「已关闭」被当成「没表态」。
已知三条入口,缺一即漏:
| # | 入口 | 归一点 |
|---|---|---|
| 1 | .env / from_env() / from_settings() |
config._cast 委托 coerce_effort |
| 2 | 构造函数全量注入 SourceConfig(...) 与 chat(reasoning_effort=...) |
SourceConfig.__post_init__ / chat() 入口 |
| 3 | 缓存命中回放 LLMResponse.applied_effort |
CacheMW._coerce_applied_effort |
| 4 | 公共函数 resolve_thinking() 直调 |
函数入口自行 coerce_effort(2026-09-05 独立验证查出) |
第 3 条是 T8 加 applied_effort 字段时才浮现的: 响应进 Redis 走 JSON,StrEnum 存成裸串,
命中回放时类型已丢。与 thinking_observation 当年的坑逐字相同(见 issue #16/#17),故按同一
先例处置: 域外取值降级为 None 且不作废整条缓存——多项目共用 Redis 时互相打缓存是老问题,
为一个可观测字段丢掉整条响应不划算。
第 4 条是本次换代自己造出来的: 该函数在 __all__ 里,第三参数由 bool 换成 Effort 后,下游最自然的写法就是从 JSON/配置读出来的裸串 "low"。不归一则 _inject 撞 .value 抛 AttributeError——一个未文档化、不属四分类的异常。
新增第五条入口时(新工厂、新 transport 参数、新的反序列化路径)必须同样过 coerce_effort。
5. 缓存 key
更正一个误判(Codex 审查指出): 源级 extra_body 与 enable_thinking 早已进 key——经 build_model_fingerprint 的 _fingerprint_mark(client.py),由 issue #4/#5 落地,ARCH §7.5 有明文。本设计不存在先前稿本断言的「现存毒化缺口」,那是把 CacheMW 只读 request.sampling 误当成了全部 key 来源。
真正需要处置的是两处,均因请求级档位而新增:
| 层 | 处置 | 理由 |
|---|---|---|
源级 reasoning_effort |
并入 _fingerprint_mark,与 enable_thinking 同规则(仅表态时追加) |
与既有一致;全源不表态时指纹字面量不变,存量缓存不冷启动 |
请求级 reasoning_effort |
进 build_cache_key,仅非 None 时参与 |
model_fingerprint 是装配期算的集合级指纹,覆盖不到逐调用变化的值。不进 key 则同 messages 跑 low 与 max 会互相命中——issue #4「5 个 seed 全命中同一响应」的逐字翻版 |
已知取舍原样延续: ARCH §7.5 已记载 model_fingerprint 是集合级而非本次选中源的指纹,同 scope 各源配置不同时仍可能返回另一源的响应;要求逐源可复现应让每源独享 scope 或 namespace。加入 reasoning_effort 后该取舍不变,本设计不扩大战线去改它。
冷启动代价: 只有新配 REASONING_EFFORT 的源冷启动一次;存量只配 ENABLE_THINKING 的源字面量逐字不变。
key 用请求档,不用 nearest 映射后的生效档(T6 实现时定,理由在此补正): 决定性的原因是
CacheMW 位于洋葱中比 transport 更外的一层,查缓存时 resolve_thinking 尚未执行,生效档
根本拿不到。副作用是被映射到同一档的两个请求(minimal 与 low 都映射到 low)各占一个
缓存槽,存两份相同响应——浪费但不毒化,可接受。
由此带来一条已知边界(与 ARCH §7.5 既有两条并列,不在本设计处理): 能力表更新导致映射结果
变化时(如某模型新增 minimal 档),请求档 minimal 算出的 key 不变而实际发出的字节变了,
会命中按旧映射存下的响应。能力表版本不进 model_fingerprint 是既有取舍的延续(provider 表
与能力表都不在指纹里),要求严格隔离的调用方应换 cache_namespace 或 cache_salt。
6. 遥测
llm_calls 新增一列 reasoning_effort TEXT(INSERT 字段 25 → 26,物理列 26 → 27;两套口径的区分见 telemetry/schema.py 模块 docstring)。
记的是本次调用生效的档位,不是配置值——None(不表态)与 'low' 必须能区分,故可空。
不加此列则你要做的压测「不同档位是不是真有用」在数据侧无法分组: 现在 25 列里没有任何一列能回答「这一行用的是哪档」。补列走既有的 PGW_TELEMETRY_SCHEMA_MODE 机制,两端 DDL 与 COLUMNS 同源(schema.py 是单一事实源)。
7. 备选方案对比
| 方案 | 改动面 | 权衡 | |
|---|---|---|---|
| A | 最小补丁: 只补 zhipu profile,thinking_on 填一个档,维持 bool |
providers.py 一条 + thinking.py 两条 |
issue #20 字面满足。但 §1 三条结论全部无解: GLM-5.3 填什么档都是错(medium 是空档、none 是未定义值);can_disable 只能在「让下游跑不起来」与「登记一个官方否认的能力」之间二选一。治标 |
| B | 能力表档位化 + 源级/请求级双入口(本设计) | types.py 加 Effort、两个公共类型重构、resolve_thinking 加两关、缓存 key、遥测加列、.env 加键 |
表达力对齐现实;下游不必再走 extra_body;压测可按档位分组。代价是公共类型破坏性变更 + 一次缓存冷启动 |
| C | 照抄 cherry 的完整 wire DSL: closed operation 集合、effortMap、budgetWire、endpoint-keyed contract |
B 的全部 + 一套 wire 解释器 + per-model wire 覆盖表 | 能表达 budget 型(qwen thinking_budget)与原生协议代际差异。但本库只有一个 OpenAI 兼容 transport(§3.4),这层复杂度当前无消费者——违 P1 YAGNI |
推荐 B。A 治不了 issue #20 描述的病;C 的两项额外能力(多协议 wire、token 预算)在本库当前没有消费者,等真出现 budget 型需求时,ThinkingWire 增一个 budget_key 字段即可增量抵达,不必现在就上解释器。
8. 初始能力表(全部标注「文档推定,待实测」)
来源: 官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉。这是待验证的假设,不是结论——LiteLLM 里同一个 kimi-k3 在 moonshot/ 下是三档、在 perplexity/ 下是六档,中转会改档位有第三方证据。人类已定:能力表数据以后统一经 new-api 实测。
落库规则(Codex 审查补): 本表是调研素材,不是可直接转代码的表。只有 supported_efforts 能写成合法 Effort 元组的条目才进 DEFAULT_CAPABILITIES。分三档处置:
| 情形 | 处置 |
|---|---|
| 档位清单与「能否关闭」皆无冲突 | 直接登记 |
档位清单三源一致,仅「能否关闭」存疑(如 kimi-k3: 官方档位无 none,OpenRouter 却标 mandatory:false) |
按保守方向登记(不含 none),evidence 注明存疑点。理由: 不登记会退回 Phase 3 的「尽力注入」,下游配 none 时静默失效——那正是 issue #20 的病;保守登记则报错并给出最低档,明确且有出路 |
档位清单本身无该型号直接证据(未查到,或仅由同系推定如 推定同上) |
不登记,走 Phase 3 |
第二档与第三档的分界是有没有该型号自己的档位证据,不是「关不关得掉存不存疑」: kimi-k3 进第二档,因为月之暗面官方文档直接写明它的三档是 low/high/max,只有「能否关」两源分歧;而 gemini-3-flash、claude-haiku-5 的档位清单是从同系型号(3.1-pro / opus-5)推来的,没有该型号自己的文档,故进第三档。Phase 3 并非静默——它会 warning 指路「实测后用 register_capability 登记」,且未登记模型的运行期对账文案也专门写了这一句;登记一个纯推定值反而会让下游以为库确认过。T10 实测时这三个型号优先补。default 列只是调研记录,按 §3.2 并入 evidence 文本,不进字段。
| 模型 | supported_efforts(推定) | 厂商默认(入 evidence) | 关? |
|---|---|---|---|
| glm-5.3, glm-5.3-flash | low, high, max | max | ✗ |
| glm-5.2 | none, high, max | max | ✓ |
| kimi-k3 | low, high, max | max | ?(OR 标可关,但官方档位无 none——待实测) |
| kimi-for-coding | 未查到 | — | ? |
| deepseek-v4-pro / -flash / -flash-vision-exp | none, high, max | high | ✓ |
| gpt-5.4, gpt-5.5 | none, low, medium, high, xhigh | medium | ✓ |
| claude-opus-5, claude-sonnet-5 | low, medium, high, xhigh, max(+none 经网关转 thinking 关闭) |
high | ✓ |
| claude-haiku-5 | 推定同上 | — | ? |
| gemini-3.1-pro | low, medium, high | 官说 high / OR 说 medium(打架) | ✗ |
| gemini-3-flash | low, medium, high | — | ? |
| MiniMax-M3 | none, auto | auto | ✓ |
| MiniMax-M2.5, M2.7 | auto(仅此一档) | auto | ✗ |
| glm-5, glm-5.1, glm-4.6v | none, auto | auto | ✓ |
| qwen-plus-latest, qwen3.5-flash, qwen3.6-plus, qwen3.7-max, qwen3.7-plus | none, auto | auto | ✓ |
三个 embedding 模型(text-embedding-v2/v4、qwen3-vl-embedding)无推理语义,不入表。
9. 非功能维度
| 维度 | 回答 |
|---|---|
| 并发 | 两张表仍是 MappingProxyType + 纯函数查找,无共享可变状态。transport 的 _warned_models/_warned_mismatches 是实例级 set,读写之间无 await,单事件循环内原子。节流键加入生效档位后基数上升(源×模型×档位),仍为有界小集合 |
| 取消 | 档位解析全部是同步纯函数,不含 await,不改变 CancelledError 的穿透路径。既有保证不受影响 |
| 降级方向 | 推理档位属请求正确性而非资源闸,故一律报错不放行(Phase 2/4/5(下同)),与「限流/熔断后端不可用须报错」同向。能力未登记是唯一例外——warning 后尽力注入,理由是新模型上线不该被库挡住(既有决策,保留) |
| 幂等 | 纯函数,无副作用,同输入恒同输出。重复调用安全 |
| 持久化 | 两处一次性影响: ① 缓存 key 变化 → 已配推理参数的 namespace 冷启动一次;② 遥测补列 → 走既有 PGW_TELEMETRY_SCHEMA_MODE,补列语句与 DDL 同源。均无部分写入风险(补列是 DDL 原子操作,缓存 miss 不损坏数据) |
10. 错误处理与测试策略
错误分类: 全部落 RequestRejectedError(不重试、不换源、不计熔断)。理由: 档位不支持是确定性的配置/参数问题,重试与换源都不会让它变对。路径与既有一致——thinking.py 抛 ThinkingUnsupportedError(ValueError),transport 在请求期翻译。
装配期 vs 运行期: 源级配置(SourceConfig.reasoning_effort)在构造期校验并报错;请求级(ChatRequest.reasoning_effort)只能在运行期校验,落 RequestRejectedError 上抛。
测试策略(先失败后通过,每条对应一个行为):
| 层 | 用例 |
|---|---|
| unit | 五道关卡各自的触发与不触发;enable_thinking 语法糖的三种等价;矛盾配置构造期报错;nearest 映射的取档方向;派生量(can_disable/cheapest_effort)与 supported_efforts 一致 |
| unit | Phase 4 文案含 cheapest_effort 与 env 键名(这是交付物,要断言内容而非只断言抛错) |
| unit | 缓存 key: 同 messages 不同档位 → key 不同;不表态时 key 与存量形状一致(回归) |
| integration | 遥测 reasoning_effort 列在两端(sqlite/pg)落值正确,不表态时为 NULL |
e2e(slow) |
经 new-api 对 §8 表逐模型实测,校正 supported_efforts;标 slow(成败取决于外部服务当下状态) |
11. 明确不做
- 档位与
reasoning_tokens的运行期对账(§4.3): 无可判定的函数关系,拿它报警是噪声。 - token 预算型控制(
thinking_budget/budget_tokens): qwen 系支持,但当前无下游需求;ThinkingWire可增量加budget_key抵达。 - 原生协议 wire 与代际方言(§3.4): 本库只有一个 OpenAI 兼容 transport。
- 档位对采样参数的联动: DeepSeek 思考模式不支持
temperature/top_p,Moonshot kimi-k2.5+ 固定采样参数,传别的值 400。本设计不代下游做参数裁剪——这是模型的约束,应由 evidence 记录并让 400 如实抛出,库替下游删参数是「默认值掩盖错误」。记入能力表 evidence,不写进代码逻辑。 - 压测本身: 「不同档位是不是真有用」是
harness-eval范畴,依赖本设计的遥测列,不属于本设计。
12. 迁移与兼容
破坏性变更五处(初稿只列了第 1 条,其余四条为 2026-09-05 独立验证实测补全——照初稿写 CHANGELOG 会让下游撞上没有预告的 TypeError):
| # | 位置 | 变更 | 谁会断 |
|---|---|---|---|
| 1 | ThinkingCapability |
构造签名 can_disable → supported_efforts |
自建能力表的调用方 |
| 2 | ports.Transport.complete() |
新增无默认值参数 reasoning_effort |
任何自建 transport 实现 |
| 3 | ports.TelemetryRecorder.record_llm_call() |
新增无默认值参数 reasoning_effort |
任何自建 recorder 实现 |
| 4 | thinking.resolve_thinking() |
第三参数换语义(bool → Effort),返回类型由 Mapping 改为 ThinkingResolution |
读侧代码一律断 |
| 5 | providers.ProviderProfile |
thinking_on/thinking_off → thinking: ThinkingWire |
自建 profile 的调用方 |
五者都在包根导出面或端口面上。
先更正(Codex 审查指出): 初稿称「已核实 reference/ 三项目无调用点,实际影响面为零」——该结论不成立。reference/ 下当前没有 GovDoc-SaaS / Video-Tree-TRM5 / CHSAnalyzer 三个目录(工作区实际只有本次调研克隆的四个开源项目),此前的 grep 因目录不存在而输出空,被误读成「无匹配」。
真实的库内调用点(可复验):
| 位置 | 用法 | 处置 |
|---|---|---|
thinking.py:169 |
读 capability.can_disable |
改读派生属性,行为不变 |
tests/unit/test_thinking.py:128 |
ThinkingCapability(True, "实测") 位置参数构造 |
随实现同步改——这是不可兼容的部分 |
tests/e2e/test_thinking_live.py:455 |
读 can_disable |
派生属性覆盖 |
__init__.py |
包根导出 ThinkingCapability/register_capability |
符号名不变,构造形态变 |
兼容策略: 保留 can_disable 为只读派生属性(Effort.NONE in supported_efforts),读侧代码一律不改;位置参数构造无法兼容,库内三处随实现同步修改。
下游影响面: 推断而非核实。三项目尚未迁移接入本库(M4 才做),ThinkingCapability 是 2026-08-02 才加入的库内表,下游调用它的可能性低——但工作区读不到三项目源码,这条只能是推断。须人类在审批时确认,或在实现计划里加一步「三项目可读时复验调用点」。版本号取 1.3.3(2026-09-05 人类指令,不走 minor)。
非破坏: SourceConfig.enable_thinking 保留,行为等价(§4.2);.env 的 ENABLE_THINKING 键保留;新增键 {SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT。三项目不改配置即可继续跑,除非它们配的是「关闭一个官方不可关的模型」——那种情况本来就是静默失效,现在会明确报错并给出替代档。
13. 验收标准
- 五道关卡各有先失败后通过的测试证据;Phase 5 文案内容被断言。
- 同 messages 不同档位不再互相命中缓存。
- 遥测能按档位分组(压测的前置条件)。
.env只配ENABLE_THINKING的存量下游行为不变(回归测试)。- 进
DEFAULT_CAPABILITIES的条目仅限 §8 中无?/无冲突者,每条evidence标注「文档推定,待实测」并附出处;其余条目留在设计文档里等实测,不登记。 - import-linter 契约不破(
Effort落types.py,不产生反向依赖)。