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.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:2026-08-02-thinking-capability-design
|
||||
title: "推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)"
|
||||
date: 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` 两档 |
|
||||
|
||||
```python
|
||||
"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 级,新增)
|
||||
|
||||
```python
|
||||
@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)
|
||||
|
||||
单一判定函数收口,形态层与能力层在此相遇:
|
||||
|
||||
```python
|
||||
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. 验收标准
|
||||
|
||||
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=0`(400),而 400 归 `RequestRejectedError` 不重试不换源,下游统一下发 `temperature=0` 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。
|
||||
|
||||
`qwen` 的 `strip_think_tags=True` 已过时(实测走 `reasoning_content`,正文无 `<think>` 标签),无害死代码,可顺带清理或另记。
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
type: finding
|
||||
node_id: finding:2026-08-02-thinking-switch-and-reasoning-tokens
|
||||
title: "推理开关与 reasoning_tokens: 供应商实测与业界做法"
|
||||
date: 2026-08-02
|
||||
---
|
||||
|
||||
# 推理开关与 reasoning_tokens:供应商实测与业界做法
|
||||
|
||||
> 类型:findings(事实基础)|日期:2026-08-02|来源:issue #5 / #6 调研
|
||||
> 本文只记录**已验证的事实与其证据**,设计取舍见 `designs/2026-08-02-thinking-capability-design.md`。
|
||||
> 本文的价值不限于这两条 issue——「同一语义、形态因模型而异」是本库长期要面对的一类问题,此处的结论与方法可复用。
|
||||
|
||||
## 1. 实验环境与方法
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 端点 | 自建 new-api 中转(`newapi.iomgaa.online/v1`,OpenAI 兼容) |
|
||||
| 参数 | `temperature=0`、`max_tokens=800`、非流式为主,流式单独验证 |
|
||||
| 题目 | 固定一道鸡兔同笼题,要求"只输出两个数字" |
|
||||
| 判据 | 首选 `usage.completion_tokens_details.reasoning_tokens`;该字段缺失时以 `completion_tokens` 兜底(关闭推理应 <30,推理中 >150) |
|
||||
| 旁证 | `prompt_tokens` 变化——注入生效的参数会改变模型侧模板,输入侧 token 数随之变化 |
|
||||
|
||||
**方法论要点(可复用)**:判断一个参数"是否被上游真正消费",`prompt_tokens` 比输出长度可靠得多。输出长度受采样影响、方差大;而输入侧 token 数在同一请求体下是确定的,一旦变化就说明服务端换了模板,即参数确实到达了模型。本次三条关键结论全部由这个旁证锁定。
|
||||
|
||||
## 2. MiniMax:真开关是 `reasoning_effort`
|
||||
|
||||
### 2.1 M3 参数矩阵(非流式)
|
||||
|
||||
| 注入参数 | prompt | completion | reasoning_tokens | 判定 |
|
||||
|---|---|---|---|---|
|
||||
| 默认(不传) | 194 | 4 | 无 ctd | 不推理 |
|
||||
| `reasoning_effort=none` | 194 | 10 | 无 ctd | 不推理 |
|
||||
| `reasoning_effort=minimal` | **207** | 129 | 123 | 推理 |
|
||||
| `reasoning_effort=low` | **207** | 98 | 93 | 推理 |
|
||||
| `reasoning_effort=medium` | **207** | 183 | 177 | 推理 |
|
||||
| `reasoning_effort=high` | **207** | 158 | 142 | 推理 |
|
||||
| `thinking={"type":"enabled"}` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||
| `thinking={"type":"disabled"}` | 194 | 4 | 无 ctd | **被静默丢弃** |
|
||||
| `enable_thinking=true` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||
| `enable_thinking=false` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||
|
||||
`prompt_tokens` 194→207 的 13 token 差是硬证据:`reasoning_effort` 被消费时模型注入了推理指令;另四种写法 prompt 恒为 194,参数根本没到达模型。
|
||||
|
||||
### 2.2 `none` 是被识别的真值,不是被当非法值丢弃
|
||||
|
||||
这是一个必须排除的伪解释——若中转把不认识的值直接丢掉,`none` 的表现会与"不传"无异,我们就会误以为它生效。
|
||||
|
||||
反证实验:传乱码值 `reasoning_effort="xyzzy"` → 返回 200、prompt=207、reasoning_tokens=180。**未知值不但没被丢弃,反而开启了推理。** 既然无效值的行为是"开推理",而 `none` 的行为是"不推理",两者不同,`none` 就必然是被识别的枚举值。
|
||||
|
||||
对照组:完全未知的**键** `zzz_bogus_param=1` → prompt=194、无 ctd、无报错,确认未知**键**才会被静默吞掉。
|
||||
|
||||
### 2.3 M2.7 / M2.5 的推理关不掉
|
||||
|
||||
三种参数形态各 3 次,`completion_tokens` 全部落在推理区间:
|
||||
|
||||
| 模型 | 默认(基线) | `reasoning_effort=none` | `thinking:{disabled}` | `thinking:{adaptive}` |
|
||||
|---|---|---|---|---|
|
||||
| MiniMax-M2.7 | 372/283/285 | 275/301/248 | 310/190/219 | 299/269/246 |
|
||||
| MiniMax-M2.5 | 273/–/256 | 363/353/264 | 286/278/320 | 278/228/259 |
|
||||
|
||||
真关闭应为 5–10("23 12" 两个数字),实测无一接近。
|
||||
|
||||
**三个独立外部来源与实测完全吻合**:
|
||||
|
||||
| 来源 | M3 | M2.7 / M2.5 |
|
||||
|---|---|---|
|
||||
| OpenRouter `/api/v1/models` 的 `reasoning` 描述符 | `mandatory: false` | **`mandatory: true`** |
|
||||
| models.dev 的 `reasoning_options` | `[{"type":"toggle"}]`(二元可控) | `[]`(有推理但无控制手段) |
|
||||
| MiniMax 官方仓库 issue #121 | — | "M2.7 不允许关闭思考",无官方回复 |
|
||||
|
||||
**结论:M2.x 的推理是模型固有属性,不是参数没找对。** 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。
|
||||
|
||||
### 2.4 M3 的稳定性
|
||||
|
||||
同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。
|
||||
|
||||
## 3. qwen / deepseek:现有 profile 正确
|
||||
|
||||
| 模型 | `enable_thinking=false` | `thinking:{disabled}` | `reasoning_effort=none` | 现有 profile |
|
||||
|---|---|---|---|---|
|
||||
| qwen3.7-plus | ✅ 关闭(compl 5) | ✅ 关闭 | ✅ 关闭 | `enable_thinking` — **正确** |
|
||||
| deepseek-v4-pro | ❌ 无效(仍推理 198) | ✅ 关闭(compl 3) | ✅ 关闭 | `thinking:{type}` — **正确** |
|
||||
|
||||
两点附带事实:
|
||||
|
||||
- **`reasoning_effort=none` 在三家都有效**,但这很可能是中转做了参数归一化。**不可据此认为可以统一发一个参数**——下游若直连供应商官方端点,该假设大概率不成立。翻译表必须一家一行。
|
||||
- **qwen 的 `strip_think_tags=True` 已过时**:实测 qwen 走 `reasoning_content` 字段,正文中无 `<think>` 标签。无害,但属于死代码。
|
||||
- **非流式没有 400**:DashScope 系"`enable_thinking` 仅支持流式"的限制经中转不存在。直连时是否仍存在未验证。
|
||||
|
||||
## 4. new-api 中转的三个行为(会污染观测)
|
||||
|
||||
这一节对任何经中转做实测的场景都适用,值得单独记住。
|
||||
|
||||
**(a)不校验参数值。** `reasoning_effort="xyzzy"` 返回 200 并当作"开推理"处理。**意味着"靠上游报错兜底"的设计模式在此失效**——Bedrock 式的"最小交集 + 裸逃生口"在这里等于零保护。
|
||||
|
||||
**(b)静默丢弃未知键。** 默认路径是 struct round-trip(`ConvertRequest` 返回 struct 再 `json.Marshal`),未知键在第一次序列化就消失。new-api 有 per-channel 的 `pass_through_body_enabled` 开关可改变此行为。
|
||||
|
||||
**(c)上游不返回 usage 时用本地 tokenizer 补算并整体替换。** 补算出的 usage 只有三个标量,`completion_tokens_details` 为零值。这直接解释了实测中的双峰现象:
|
||||
|
||||
| 现象 | 解释 |
|
||||
|---|---|
|
||||
| 同一请求 10 次:`prompt=74` 者 6 次不上报 `reasoning_tokens`,`prompt=72` 者 4 次上报,从不交叉 | `74` = 本地估算值,`72` = 上游真值;补算路径吃掉了 ctd |
|
||||
|
||||
**这不是多渠道路由**(MiniMax 侧为单渠道单密钥),也不是配置错误,而是上游偶发不返回 usage 时的兜底逻辑。中转日志中的 `local_count_tokens` 标志可现场确认。
|
||||
|
||||
**对库的直接影响**:`reasoning_tokens` 缺失**不能**解释为"该源不上报这个字段",只能解释为"**本次调用未上报**"。下游若按前者建立统计口径会算错。
|
||||
|
||||
## 5. 业界如何建模"同一语义、形态因模型而异"
|
||||
|
||||
调研覆盖 LiteLLM、OpenRouter、models.dev、LangChain、Vercel AI SDK、AWS Bedrock Converse、Portkey、Helicone、LlamaIndex、new-api/one-api。
|
||||
|
||||
### 5.1 核心共识:形态按 provider,能力按 model
|
||||
|
||||
| 概念 | 变化频率 | 应归属层次 |
|
||||
|---|---|---|
|
||||
| **形态**:参数长什么样(`enable_thinking` / `thinking.type` / `reasoning_effort`) | 协议方言,一个供应商数年不变 | provider 级 |
|
||||
| **能力**:能否关闭、有几档、默认开不开 | 模型属性,同一供应商每代都变 | **model 级** |
|
||||
|
||||
注册单位的分布很能说明问题:LiteLLM(2986 条目)、models.dev(5949 条)、LangChain、OpenRouter(细到 endpoint)、Helicone 全部下沉到 model 级;**仍停在 provider 级的只有 Portkey 与 LlamaIndex,而这两家恰是失败语义最差的两家(均静默丢弃)**。二者相关不是偶然:注册单位不够细,就只能靠"表里没有 = 不发"来兜底,而这正是静默失效的成因。
|
||||
|
||||
### 5.2 失败语义的四种谱系
|
||||
|
||||
| 语义 | 代表 | 适用前提 |
|
||||
|---|---|---|
|
||||
| 默认报错 + 可配置降级开关 | LiteLLM(`UnsupportedParamsError` + `drop_params`) | 有 model 级能力表可依据 |
|
||||
| 软降级 + 显式 warning 通道 | Vercel AI SDK(丢弃参数并 push `warnings[]`) | 调用方愿意读 warning |
|
||||
| 静默忽略 + 可选路由过滤 | OpenRouter(默认忽略;`require_parameters:true` 改为排除不支持的上游) | 网关自己拥有路由权 |
|
||||
| 硬失败(透传给上游报错) | Bedrock(`inferenceConfig` 4 字段交集 + `additionalModelRequestFields` 裸透传) | **上游会诚实报错** |
|
||||
|
||||
**选型时先问"我的上游会不会诚实报错"**。若不会(如本项目的中转),最后一种直接出局,静默类也不能选。
|
||||
|
||||
### 5.3 表会过期,这是公理
|
||||
|
||||
LiteLLM 有过真实事故(issue #27351:`gpt-5.1-mini` 漏登记导致 `temperature` 被误拒)。它的应对是**两种相反极性**,值得直接借鉴:
|
||||
|
||||
- **opt-in 能力**(用错会 400 或悄悄花钱):未登记 → 视作不支持 → 拒绝
|
||||
- **opt-out 能力**(多半支持,误拒代价大):未登记 → 放行 → 只有表里显式写 `false` 才拒
|
||||
|
||||
维护方式上,LiteLLM/models.dev 靠社区 PR + CI 校验,LangChain 靠"上游拉取 + 本地增补 + 代码生成"。**对内部库而言唯一现实的答案是:谁实测出来谁登记,登记必须附实测证据与日期。**
|
||||
|
||||
### 5.4 「布尔开关 → 多档旋钮」无语义共识
|
||||
|
||||
| 系统 | effort → 预算的换算 |
|
||||
|---|---|
|
||||
| LiteLLM | 一组 2 的幂(1024/2048/4096/8192/16384),全部可用环境变量覆盖;gemini 各型号还另有分叉 |
|
||||
| OpenRouter | `max_tokens` 的百分比(≈80%/50%/20%) |
|
||||
| Helicone | 一律 `max_tokens/2`,完全不看档位 |
|
||||
| LangChain | 明确不保证跨 provider 可比 |
|
||||
|
||||
**唯一对齐的是"关"**:`none` / `disabled` / `thinking:{type:"disabled"}` / OpenRouter `effort:"none"` 语义一致。"开"那一端没有任何标准。
|
||||
|
||||
**工程共识只有一条:这个映射必须是可覆盖的常量,不是可推导的公式。** 业界所有人都在拍脑袋,区别只在拍完让不让调用方改。
|
||||
|
||||
### 5.5 Vercel AI SDK 的一处设计值得单记
|
||||
|
||||
它的推理档位枚举里有一个 `'provider-default'`,与 `'none'`(明确关闭)严格区分。这与本库 `enable_thinking` 的三态(`None` 不干预 / `True` / `False`)是同一思想——**"调用方不表态"必须是一个独立的值,不能与任何具体档位混同**。本库这一点原本就做对了,应保持。
|
||||
|
||||
## 6. 附带发现(不属本次范围,建议另立 issue)
|
||||
|
||||
**kimi-k3 拒绝 `temperature=0`**:返回 `400 invalid temperature: only 1 is supported`(另有渠道回 `only 0.6`)。本库把 400 归入 `RequestRejectedError`——不重试、不换源。若下游统一下发 `temperature=0`,此类源会 100% 硬失败。这与本次两条 issue 同源:**供应商能力差异未被建模**。
|
||||
|
||||
**中转渠道可用性会波动**:kimi 渠道在 429 后被中转下线,随后返回 `404 Model not supported by any channel`。任何依赖真实 API 的测试都必须容忍源不可用(跳过并给出明确原因),而不是失败。
|
||||
|
||||
## 7. 未能证实
|
||||
|
||||
1. **MiniMax 官方文档对 `reasoning_effort` 的一手定义**:官方文档站三次抓取均失败。M2.x 关不掉有三处佐证,但官方原文未取得。另有二手来源称 MiniMax 原生开关是 `thinking:{type:"adaptive"/"disabled"}`——**该说法已被本次实测证伪**(M2.7/M2.5 上两种写法均无效),但"中转是否对 `reasoning_effort` 做了改写"仍未排除。直连官方端点复测可彻底澄清。
|
||||
2. **qwen 直连 DashScope 时非流式 `enable_thinking` 是否仍报 400**:仅验证了经中转的行为。
|
||||
3. **new-api 走本地补算的确切触发条件**:读到了补算分支与 `local_count_tokens` 标记,未逐条比对所有渠道类型。双峰现象与该解释高度吻合,但未在日志中直接验证。
|
||||
4. **能力表条目对非本次实测模型的正确性**:qwen / deepseek 只测了各一个型号,同系其他型号未验证。
|
||||
|
||||
## 8. 对后续开发的指导
|
||||
|
||||
1. **判定参数是否生效,优先看 `prompt_tokens` 而非输出长度**(§1)。
|
||||
2. **排除"无效值被静默丢弃"必须做反证实验**:传一个乱码值,看它的行为是否与目标值不同(§2.2)。
|
||||
3. **经中转做的任何实测都要标注"经中转,直连未验证"**,并写进注释(§3、§7)。
|
||||
4. **新增供应商或模型前,先查 OpenRouter `/api/v1/models` 与 models.dev**——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。
|
||||
5. **能力表条目必须附实测证据与日期**;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。
|
||||
6. **`reasoning_tokens` 缺失只能记 `None`,绝不可记 `0`**(§4c)——"观测不到"与"没发生"是两件事。
|
||||
@@ -223,6 +223,13 @@
|
||||
"relation": "implements",
|
||||
"evidence": "11 个任务逐条覆盖设计的决策 A-G 与 §5 的 14 条测试清单",
|
||||
"added": "2026-07-31T16:59:35.657367+00:00"
|
||||
},
|
||||
{
|
||||
"source": "finding:2026-08-02-thinking-switch-and-reasoning-tokens",
|
||||
"target": "design:2026-08-02-thinking-capability-design",
|
||||
"relation": "supports",
|
||||
"evidence": "供应商实测与业界调研为该设计的形态/能力分层与失败语义提供事实依据",
|
||||
"added": "2026-08-02T09:38:57.033054+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
# Research Wiki 索引
|
||||
|
||||
> 自动生成,更新时间:2026-08-01 01:58 UTC
|
||||
> 自动生成,更新时间:2026-08-02 09:38 UTC
|
||||
|
||||
## design (20)
|
||||
## design (21)
|
||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||
@@ -22,9 +22,10 @@
|
||||
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
||||
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
||||
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||
|
||||
## finding (11)
|
||||
## finding (12)
|
||||
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
||||
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
||||
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
||||
@@ -36,6 +37,7 @@
|
||||
- [M4 迁移验收(GovDoc+CHS)](findings/m4-acceptance.md) `finding:m4-acceptance`
|
||||
- [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline`
|
||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||
|
||||
## plan (16)
|
||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||
|
||||
@@ -73,3 +73,8 @@
|
||||
- [2026-07-31 16:59 UTC] 重建索引: 50 篇页面
|
||||
- [2026-07-31 17:01 UTC] 重建索引: 50 篇页面
|
||||
- [2026-08-01 01:58 UTC] 重建索引: 50 篇页面
|
||||
- [2026-08-02 09:38 UTC] 重建索引: 52 篇页面
|
||||
- [2026-08-02 09:38 UTC] 新增边: finding:2026-08-02-thinking-switch-and-reasoning-tokens --supports--> design:2026-08-02-thinking-capability-design
|
||||
- [2026-08-02 09:38 UTC] 新增 finding: 推理开关与 reasoning_tokens 供应商实测与业界做法 (finding:2026-08-02-thinking-switch-and-reasoning-tokens)
|
||||
- [2026-08-02 09:38 UTC] 新增 design: 推理开关能力建模与 reasoning_tokens 采集 issue #5+#6 (design:2026-08-02-thinking-capability-design)
|
||||
- [2026-08-02 09:39 UTC] 重建 Query Pack: 29 字符
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Query Pack
|
||||
|
||||
> 尚无数据。运行 research-lit 或 idea-creator 后自动生成。
|
||||
> 自动生成,请勿手动编辑。
|
||||
|
||||
Reference in New Issue
Block a user