Ordered so the two type changes land first and everything else consumes them: capability and wire in parallel, then the five gates, then the two entry points, then cache key and telemetry, then the transport. Task 10 exists because the human settled that the capability table is measured through new-api, not read off a vendor page. Task 1 lands the documented guess; task 10 replaces it with what the gateway does.
24 KiB
实现计划: 推理档位一等化
- 设计:
research-wiki/designs/2026-09-04-reasoning-effort-design.md(2026-09-04 人类已批准) - 目标: 把
enable_thinking: bool | None升级为可表达厂商档位的Effort词汇,让「关不掉的模型」「打空的档位」从静默失效变成带出路的报错。 - 方案概述: 新增八档封闭枚举
Effort(含auto);能力表从can_disable: bool改为supported_efforts: tuple[Effort, ...];provider 的两个固定片段改为ThinkingWire(off / on_base / effort_key);档位入口取「源级默认 + 请求级覆盖」,进缓存 key 与遥测各一列。 - 涉及技术: Python 3.12
StrEnum、frozen dataclass、pydantic-settings env 解析、SQLite/Postgres DDL 补列、pytest。 - 保真校验: 不适用。本计划实现的是库自研的推理决策(
thinking.py系 2026-08-25 新建),不属 ARCHITECTURE §1.4 的移植蓝本;且reference/三项目当前不在工作区(见设计 §12),无可比对源。
文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
src/polygateway/types.py |
修改 | 新增 Effort 枚举;SourceConfig/ChatRequest 各加档位字段 |
src/polygateway/thinking.py |
修改 | ThinkingCapability 重构、resolve_thinking 五关、reconcile_thinking 判据、默认能力表重写 |
src/polygateway/providers.py |
修改 | ThinkingWire 新类型替换两个片段;DEFAULT_PROFILES 扩到 8 段 |
src/polygateway/config.py |
修改 | 两个新 env 键的解析与矛盾校验 |
src/polygateway/client.py |
修改 | chat() 签名加档位;_fingerprint_mark 纳入源级档位 |
src/polygateway/middleware/cache.py |
修改 | build_cache_key 纳入请求级档位 |
src/polygateway/middleware/telemetry.py |
修改 | _record 与三个 emit 入口传递生效档位 |
src/polygateway/ports.py |
修改 | TelemetryRecorder.record_llm_call 加一参(25 → 26 字段) |
src/polygateway/telemetry/schema.py |
修改 | COLUMNS、两端 DDL、补列声明 |
src/polygateway/telemetry/{sqlite,postgres}.py |
修改 | 落库新列 |
src/polygateway/transports/openai_compat.py |
修改 | 生效档位解析接线、告警节流键 |
src/polygateway/__init__.py |
修改 | 导出 Effort、ThinkingWire |
.env.example |
修改 | 两个新键的模板与注释 |
tests/unit/test_thinking.py |
修改 | 位置参数构造迁移 + 五关用例 |
tests/unit/test_providers.py |
修改 | ThinkingWire 用例 |
tests/unit/test_cache.py |
修改 | 档位进 key 的用例 |
tests/unit/test_openai_compat.py |
修改 | transport 接线与节流用例 |
tests/unit/test_telemetry.py、tests/integration/test_redis_cache.py |
修改 | 列数断言与缓存 key 回归 |
tests/e2e/test_thinking_live.py |
修改 | can_disable 读法迁移;新增逐模型档位实测(标 slow) |
关键接口(跨任务消费,此处定稿)
# types.py
class Effort(StrEnum):
NONE = "none"; AUTO = "auto"; MINIMAL = "minimal"; LOW = "low"
MEDIUM = "medium"; HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
_ORDER = (Effort.NONE, Effort.MINIMAL, Effort.LOW, Effort.MEDIUM,
Effort.HIGH, Effort.XHIGH, Effort.MAX) # auto 不参与强弱序
# providers.py
@dataclass(frozen=True)
class ThinkingWire:
off: Mapping[str, Any] | None
on_base: Mapping[str, Any] | None
effort_key: str | None
@dataclass(frozen=True)
class ProviderProfile:
name: str
thinking: ThinkingWire
strip_think_tags: bool
supports_native_schema: bool = False
# thinking.py
@dataclass(frozen=True)
class ThinkingCapability:
supported_efforts: tuple[Effort, ...]
evidence: str
@property
def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts
@property
def cheapest_effort(self) -> Effort | None: ... # 除 NONE 外按 _ORDER 最弱的一档
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
effort: Effort | None,
*,
model: str,
fallback: str = "error", # "error" | "nearest"
warn_unregistered: bool = True,
) -> Mapping[str, Any]: ...
def reconcile_thinking(
*,
effort: Effort | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None: ...
# types.py 字段追加(均追加在末尾,不扰动既有位置构造)
# SourceConfig: reasoning_effort: Effort | None = None
# effort_fallback: str = "error"
# ChatRequest: reasoning_effort: Effort | None = None
Task 1 — Effort 词汇与能力表重构
文件: src/polygateway/types.py(改)、src/polygateway/thinking.py(改)、src/polygateway/__init__.py(改)、tests/unit/test_thinking.py(改)、tests/e2e/test_thinking_live.py(改)
行为:
types.py新增Effort与_ORDER(见上)。放types.py而非thinking.py: 它是SourceConfig/ChatRequest的字段类型,定义在决策模块会让types.py反向 import(依赖铁律)。ThinkingCapability改为supported_efforts+evidence,加两个@property派生量。构造期校验:supported_efforts非空、元素唯一、全部属Effort,违反即ValueError。DEFAULT_CAPABILITIES按设计 §8 落库规则重写(见下表)。- 迁移三处既有读点:
thinking.py内部读capability.can_disable改为读派生属性(行为不变);tests/unit/test_thinking.py的ThinkingCapability(True, "实测")位置参数构造改为关键字构造;tests/e2e/test_thinking_live.py读can_disable处确认派生属性可用。 __init__.py导出Effort(包根导出是既有纪律: 深路径 import 正是模块重组会打断下游的原因,见 ARCH D11)。
初始 DEFAULT_CAPABILITIES(evidence 一律以 2026-09-04 文档推定(来源),待经 new-api 实测 开头):
| model | supported_efforts |
|---|---|
glm-5.3, glm-5.3-flash |
(LOW, HIGH, MAX) |
glm-5.2 |
(NONE, HIGH, MAX) |
glm-5, glm-5.1, glm-4.6v |
(NONE, AUTO) |
deepseek-v4-pro, deepseek-v4-flash, deepseek-v4-flash-vision-exp |
(NONE, HIGH, MAX) |
gpt-5.4, gpt-5.5 |
(NONE, LOW, MEDIUM, HIGH, XHIGH) |
claude-opus-5, claude-sonnet-5 |
(NONE, LOW, MEDIUM, HIGH, XHIGH, MAX) |
gemini-3.1-pro |
(LOW, MEDIUM, HIGH) |
kimi-k3 |
(LOW, HIGH, MAX) —— 保守登记,evidence 注明 OpenRouter 标可关但官方档位无 none |
MiniMax-M3 |
(NONE, AUTO) |
MiniMax-M2.7, MiniMax-M2.5 |
(AUTO,) |
qwen-plus-latest, qwen3.5-flash, qwen3.6-plus, qwen3.7-max, qwen3.7-plus |
(NONE, AUTO) |
claude-haiku-5、gemini-3-flash、kimi-for-coding 不登记(档位清单未知,走 Phase 3)。现有三条 MiniMax 条目的 evidence 原文保留并追加新形状说明——它们是实测得来的,比文档推定更硬,不得覆盖。
验收: can_disable 对 11 类模型的返回与上表一致;cheapest_effort 对 (LOW, HIGH, MAX) 返回 LOW、对 (NONE, AUTO) 返回 AUTO、对 (AUTO,) 返回 AUTO;空元组构造报 ValueError。
测试(先失败后通过): tests/unit/test_thinking.py::test_capability_derives_can_disable、::test_cheapest_effort_skips_none、::test_empty_efforts_rejected。
验证: conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_package.py -v → PASS;conda run -n PolyGateway make lint → PASS(含 import-linter: Effort 落 types.py 不得产生反向依赖,设计 §13 第 6 条)
- 提交:
refactor: make capability a tier list, since "can it be off" is one entry in it
Task 2 — ThinkingWire 与 8 段 provider 表
文件: src/polygateway/providers.py(改)、src/polygateway/__init__.py(改)、tests/unit/test_providers.py(改)
行为:
- 新增
ThinkingWire(见关键接口)。None的语义严格沿用 issue #5:on_base is None= 形态未知(报错),off is None= 该 provider 无关闭形态,effort_key is None= 该 provider 无档位概念。三者语义互不重叠,docstring 必须写明。 ProviderProfile.thinking_on/thinking_off两字段替换为thinking: ThinkingWire。DEFAULT_PROFILES由 4 段扩到 8 段:
| provider | off | on_base | effort_key |
|---|---|---|---|
qwen |
{"enable_thinking": False} |
{"enable_thinking": True} |
None |
deepseek |
{"thinking": {"type": "disabled"}} |
{"thinking": {"type": "enabled"}} |
"reasoning_effort" |
zhipu |
{"thinking": {"type": "disabled"}} |
{"thinking": {"type": "enabled"}} |
"reasoning_effort" |
moonshot |
{"thinking": {"type": "disabled"}} |
{"thinking": {"type": "enabled"}} |
"reasoning_effort" |
minimax |
{"reasoning_effort": "none"} |
{} |
"reasoning_effort" |
openai |
{"reasoning_effort": "none"} |
{} |
"reasoning_effort" |
anthropic |
{"reasoning_effort": "none"} |
{} |
"reasoning_effort" |
google |
{"reasoning_effort": "none"} |
{} |
"reasoning_effort" |
__init__.py 同步导出 ThinkingWire。openai 段的两档由 None(未知)改为 OpenAI 标准形态,是本任务唯一的语义变更,理由写进注释: gpt-5.x 的 reasoning_effort 是 OpenAI 官方字段而非厂商方言,兜底段发它不会打到不认识它的厂商;真正未知形态的 provider 仍应走 register_provider。
验收: get_provider("zhipu").thinking.effort_key == "reasoning_effort";未注册名仍报错且错误文案列出全部 8 段;register_provider 仍返回新表不改共享状态。
测试(先失败后通过): tests/unit/test_providers.py::test_all_eight_profiles_registered、::test_wire_none_semantics_distinct(三种 None 各自的含义不混淆)。
验证: conda run -n PolyGateway pytest tests/unit/test_providers.py -v → PASS
- 提交:
feat: give zhipu, moonshot, anthropic and google a wire of their own
Task 3 — resolve_thinking 五道关卡与 nearest 映射
文件: src/polygateway/thinking.py(改)、tests/unit/test_thinking.py(改)
行为: 按下表实现,顺序不可调换,每关的理由写进 docstring。
| Phase | 条件 | 结果 |
|---|---|---|
| 1 | effort is None |
返回 {} |
| 2 | profile.thinking.on_base is None |
ThinkingUnsupportedError,指路 register_provider/extra_body |
| 3 | capability is None |
warn_unregistered 为真时 warning,随后按 wire 注入,不校验档位 |
| 4 | effort is NONE 且 not capability.can_disable |
ThinkingUnsupportedError,文案含 cheapest_effort 与 env 键名 |
| 5 | effort not in supported_efforts 且 fallback == "error" |
ThinkingUnsupportedError,列出该模型可选档 |
Phase 4 必须先于 5: none 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。
注入形态:
effort is NONE→wire.off;wire.off is None时报错(该 provider 无关闭形态)。effort is AUTO→wire.on_base(不附档位)。这与旧thinking_on逐字节等价。- 其余档 →
{**wire.on_base, wire.effort_key: effort.value};effort_key is None时报错并说明该 provider 只有开关没有档位。
nearest 映射(fallback == "nearest",人类 2026-09-04 复核确认实现): 按 _ORDER 在 supported_efforts 中取距请求档位序最近者,等距时取弱侧(省钱优先,不替下游涨价);AUTO 不参与距离计算,仅当它是唯一候选时才被选中;映射发生时 warning 记明「请求档 → 实际档 → 模型」。effort is NONE 且不可关时不走映射——那是 Phase 4 的领域,必须报错给出路,否则又变成静默降级。
验收: 五关各自触发与不触发;medium 在 (LOW, HIGH, MAX) 上 nearest 映射到 LOW(等距取弱);minimal 映射到 LOW;xhigh 映射到 MAX。
测试(先失败后通过): ::test_phase4_before_phase5(请求 none 打到 glm-5.3,断言文案含 cheapest_effort 值与 REASONING_EFFORT)、::test_nearest_ties_go_cheaper、::test_none_never_maps、::test_auto_injects_on_base_only、::test_effort_key_none_rejects_tier。
验证: conda run -n PolyGateway pytest tests/unit/test_thinking.py -v → PASS
- 提交:
feat: refuse an impossible tier with the cheapest one that model does have
Task 4 — 源级配置入口
文件: src/polygateway/types.py(改)、src/polygateway/config.py(改)、.env.example(改)、tests/unit/test_config.py(改)
行为:
SourceConfig末尾追加reasoning_effort: Effort | None = None与effort_fallback: str = "error"。config.py的_SOURCE_FIELDS增两行:"REASONING_EFFORT": ("reasoning_effort", "effort")、"EFFORT_FALLBACK": ("effort_fallback", "str")。新增"effort"解析类型: 值必须属Effort取值域,否则报错并列出八档。effort_fallback值域{"error", "nearest"},越界即报错(与_SELECTORS/_QUOTA_FULL同款 frozenset 校验)。- 矛盾校验(构造期): 同源同时给出
enable_thinking与reasoning_effort且语义冲突时ValueError。冲突定义:enable_thinking is True且reasoning_effort is NONE;或enable_thinking is False且reasoning_effort not in (None, Effort.NONE)。二者一致(如False+none)则放行。 .env.example加两键模板,注释写明八档取值、与ENABLE_THINKING的等价关系及矛盾会报错。
验收: LLM__ZHIPU__1__REASONING_EFFORT=low 解析为 Effort.LOW;写 lowest 报错且文案列出八档;ENABLE_THINKING=true + REASONING_EFFORT=none 构造期报错。
测试(先失败后通过): tests/unit/test_config.py::test_effort_key_parsed、::test_invalid_effort_lists_vocabulary、::test_contradictory_thinking_flags_rejected、::test_consistent_flags_allowed。
验证: conda run -n PolyGateway pytest tests/unit/test_config.py -v → PASS
- 提交:
feat: let a source name its reasoning tier, and say so when it contradicts itself
Task 5 — 请求级入口与优先级
文件: src/polygateway/types.py(改)、src/polygateway/client.py(改)、tests/unit/test_client.py(改)
行为:
ChatRequest末尾追加reasoning_effort: Effort | None = None。GatewayClient.chat()增关键字参数reasoning_effort: Effort | None = None,存入ChatRequest。- 新增纯函数(放
thinking.py,与其余推理决策同处):
def effective_effort(
*, request_effort: Effort | None, source_effort: Effort | None,
enable_thinking: bool | None,
) -> Effort | None:
"""生效档位: 请求级 > 源级 > enable_thinking 语法糖 > None。"""
语法糖映射: True → Effort.AUTO(注入 on_base,与旧行为逐字节等价,且不依赖能力表);False → Effort.NONE;None → 不表态。
验收: 三层优先级各自生效;请求级 None 不会覆盖源级已配的档;只配 enable_thinking=True 的存量源解析为 AUTO 且最终 payload 与升级前逐字节相同。
测试(先失败后通过): ::test_request_effort_wins_over_source、::test_none_request_does_not_clear_source、::test_enable_thinking_true_is_auto、::test_legacy_payload_byte_identical(回归门: 存量下游行为不变)。
验证: conda run -n PolyGateway pytest tests/unit/test_client.py -v → PASS
- 提交:
feat: let one call ask for a different tier than its source defaults to
Task 6 — 缓存 key
文件: src/polygateway/client.py(改)、src/polygateway/middleware/cache.py(改)、tests/unit/test_cache.py(改)、tests/integration/test_redis_cache.py(改,该文件亦断言 key 形状)
行为:
_fingerprint_mark: 源级reasoning_effort仅在非None时追加,规则与enable_thinking完全一致——全源不表态时指纹字面量逐字不变,存量缓存不冷启动。build_cache_key增关键字参数reasoning_effort: Effort | None = None,仅非None时写入key_obj["reasoning_effort"]。CacheMW.__call__传request.reasoning_effort。
为什么两处都要(写进注释): model_fingerprint 是装配期算的集合级指纹,覆盖不到逐次调用变化的请求级档位;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。ARCH §7.5 记载的「集合级指纹仍可能返回另一源响应」这一既有取舍原样延续,本任务不扩大。
验收: 同 messages 不同请求级档位 → key 不同;两者皆不表态 → key 与升级前逐字相同(回归);源级档位变化 → fingerprint 变化。
测试(先失败后通过): ::test_request_tier_changes_key、::test_absent_tier_keeps_legacy_key(断言具体 key 字符串不变)、::test_source_tier_enters_fingerprint。
验证: conda run -n PolyGateway pytest tests/unit -k "cache or fingerprint" -v → PASS
- 提交:
fix: keep a low-tier answer out of the cache slot a max-tier one filled
Task 7 — 遥测新增 reasoning_effort 列
文件: src/polygateway/telemetry/schema.py、src/polygateway/ports.py、src/polygateway/telemetry/sqlite.py、src/polygateway/telemetry/postgres.py、src/polygateway/middleware/telemetry.py(均改)、tests/unit/test_telemetry.py(改,含列数断言)、tests/unit/test_ports.py(改,Protocol 签名断言)
行为:
schema.py:COLUMNS末尾加"reasoning_effort"(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加reasoning_effort TEXT(位置与 ALTER 追加一致);补列声明同步。列数断言按物理列写——两套口径混用是本模块最易错处(见其 docstring)。ports.py:record_llm_call加reasoning_effort: str | None(不设默认值,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。- 两个 recorder 落库新列。
middleware/telemetry.py:_record加参并传给 recorder(唯一record_llm_call调用点,不复制参数列表);三个入口取值口径分列:
| 入口 | 取值 | 理由 |
|---|---|---|
emit_attempt |
effective_effort(...) 的结果 |
有选中源,能算出真正生效的档 |
emit_cache_hit |
request.reasoning_effort |
缓存命中没有选中源,源级档位无从谈起 |
emit_terminal_failure |
request.reasoning_effort |
同上(可能根本没选出源) |
与 sampling 列的现有做法同构(emit_attempt 合并源级,另两处只取请求级)。
5. 值为 Effort 时取 .value 落库,None 落 NULL——与 thinking_observation 同一先例(StrEnum 是 str 子类,asyncpg 对子类编码不保证接受,遥测写失败只降级 warning,PG 那一路会静默少列)。
验收: 两端建表列数断言更新且通过;三个入口各自落值正确;不表态时为 NULL;telemetry_schema_sql 打印的 SQL 与库实际执行的 DDL 同源。
测试(先失败后通过): 既有遥测列数断言用例更新;::test_effort_column_records_effective_tier、::test_cache_hit_records_request_tier_only、::test_absent_tier_is_null。
验证: conda run -n PolyGateway pytest tests/unit tests/integration -k telemetry -v → PASS
- 提交:
feat: record which tier a call actually ran at
Task 8 — transport 接线与对账
文件: src/polygateway/transports/openai_compat.py(改)、src/polygateway/thinking.py(改)、tests/unit/test_openai_compat.py(改)
行为:
_build_payload: 用effective_effort(...)求生效档位后调resolve_thinking(..., fallback=source.effort_fallback)。注入结果仍先于source.extra_body与overlay(顺序即优先级,issue #4 决策 A,两行不可调换)。_warn_on_thinking_mismatch的节流键由(source.name, source.model, source.enable_thinking)改为(source.name, source.model, effective_effort)——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。reconcile_thinking签名的enable_thinking: bool | None改为effort: Effort | None,判据:effort is NONE对应原「要求关闭」分支,effort为其余档对应原「要求开启」分支,None仍返回None。不新增「档位高低 vsreasoning_tokens多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。ThinkingUnsupportedError的捕获与翻译路径不变(→RequestRejectedError,不重试不换源不计熔断)。
验收: 档位不支持时抛 RequestRejectedError 且不触发重试与熔断计数;同源同模型不同档各喊一次告警;reconcile 三类文案与既有逐字一致(除方向描述由 bool 改档位)。
测试(先失败后通过): ::test_unsupported_tier_is_request_rejected、::test_no_retry_on_tier_error、::test_throttle_key_separates_tiers、::test_reconcile_none_vs_observed。
验证: conda run -n PolyGateway pytest tests/unit -k "transport or openai_compat" -v → PASS
- 提交:
feat: wire the tier through the transport and keep each tier's warning distinct
Task 9 — 全套件、文档与 wiki
文件: CHANGELOG.md、.env.example(复核)、Gitea Wiki(按 research-wiki/docs-convention.md §2)、src/polygateway/__init__.py(版本号)、pyproject.toml(版本号)
行为:
make lint+make test全绿;make format。- CHANGELOG 加「未发布」段: 破坏性变更(
ThinkingCapability构造签名)、新增(八档Effort、两个 env 键、遥测新列、四个 provider 段)、行为变更(openai段两档由未知改为 OpenAI 标准形态)。 - 按 docs-convention §2 同步 wiki(公共行为变更必须同步,版本 bump 不得裸发)。
- 版本号
1.4.0(minor: 公共 API 破坏性变更),两处一致。本任务只 bump 不发布——发布走 CLAUDE.md §4.4.1 全清单。
验收: make ci 通过;CHANGELOG 与 wiki 均含破坏性变更条目。
验证: conda run -n PolyGateway make ci → PASS
- 提交:
docs: cut 1.4.0 notes for the tier work
Task 10 — e2e 实测校正初始能力表(标 slow)
文件: tests/e2e/test_thinking_live.py(改)
行为: 对 §Task 1 表中每个已登记模型,经 new-api 实测其 supported_efforts,方法论沿用 issue #20: 固定短提示词,逐档 N≥5,判据取 usage.completion_tokens_details.reasoning_tokens;对声明不可关的模型额外验证「请求 none 是否真被拒或真未关」。测试标 slow(成败取决于外部服务当下状态,默认不进日常套件)。实测结论逐条替换 evidence 中的「文档推定」。
为什么必须单列一个任务: 人类 2026-09-04 定「能力表数据统一自己经 new-api 实测」;Task 1 落的是文档推定值,不实测则整张表都是假设。
验收: 每个已登记模型有一条实测记录;与文档推定不符者更新 supported_efforts 并在 evidence 记明分歧(尤其 kimi-k3 的保守登记、gemini-3.1-pro 的默认档两源打架)。
验证: conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v → PASS(约 20-40 分钟,取决于网关)
- 提交:
test: replace the guessed tier table with what the gateway actually does
执行顺序与依赖
T1(词汇+能力表) ──┬─→ T3(五关) ──→ T8(transport)
T2(wire) ─────────┘ ↑
T4(源级) ──┬─→ T5(请求级) ──┬────────┘
│ ├─→ T6(缓存 key)
│ └─→ T7(遥测)
↓
T9(文档) → T10(实测)
T1/T2 可并行;T3 依赖两者;T5 依赖 T4(语法糖等价关系);T6/T7 依赖 T5(请求级字段);T8 依赖 T3+T5;T9 在功能任务全绿后;T10 最后且独立(标 slow)。
执行方式: 10 个任务耦合度中等(共享 Effort/ThinkingCapability/ThinkingWire 三个类型),直接按计划实现,不派 subagent-driven-development——跨任务共享类型多,独立上下文的 subagent 容易在签名上分叉。