diff --git a/research-wiki/designs/2026-08-25-thinking-observability-design.md b/research-wiki/designs/2026-08-25-thinking-observability-design.md new file mode 100644 index 0000000..bded727 --- /dev/null +++ b/research-wiki/designs/2026-08-25-thinking-observability-design.md @@ -0,0 +1,209 @@ +--- +type: design +node_id: design:2026-08-25-thinking-observability-design +title: "推理可观测性一等化(issue #16 + #17)" +date: 2026-08-25 +--- + +# 推理可观测性一等化(issue #16 + #17) + +> 类型:design|日期:2026-08-25|状态:待人类确认 +> 事实基础见 `findings/2026-08-25-thinking-observability-regression.md`(本文所有实测引用均出自该文)。 +> 沿用 `2026-08-02-thinking-capability-design.md` 的先例:经充分实测后直接给出单一方案,不列备选;被否决的路见 §9。 + +## 1. 问题不是 issue 说的那个 + +issue #16/#17 判定"MiniMax-M3 开启推理静默失效,模型不推理"。**实测推翻了这个诊断**:M3 的推理完全正常——流式路径下 `reasoning_content` 有 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。 + +真正发生的是:**MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**(qwen 与 deepseek 在同一网关同一 key 上照常返回),于是 `reasoning_tokens` 恒为 NULL;而 e2e 的四条用例把 `reasoning_tokens` 当作唯一判据,于是集体判红。 + +**库自己握着决定性证据却没用它**:`LLMResponse.thinking` 在同一次调用里是 185 字符的实打实推理正文,从未参与任何"推理是否发生"的判定。 + +所以这是一次**可观测性缺口**,不是功能故障。而缺口的形态——库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`——正是 P5 要消灭的静默掩盖。 + +## 2. 根因三层 + +| # | 缺陷 | 只修外层会留下什么 | +|---|---|---| +| ① | `reasoning_tokens=None` 同时承载"没推理"与"没上报"两个语义,不可区分。`types.py` 的 docstring **已经写明这个歧义,但只是描述它,没有解决它** | 换个供应商停报 ctd,同样的红再来一次 | +| ② | 解析出的 `thinking` 文本从未接入任何判定:e2e、遥测、下游看的都只有 `reasoning_tokens` | 库继续把手里的硬证据丢在地上 | +| ③ | 能力表是**静态单向**声明(只有 `can_disable`),且没有任何机制把声明与运行时观测对账 | **下一个同构故障已在等着** | + +第 ③ 层最要紧。设想某天 M3 变成不能关推理:库照常注入 `reasoning_effort=none`,模型照常推理,下游拿到推理内容却以为关了,而库全程不吭声——与本次同构,且更隐蔽(本次至少有测试变红,那次连测试都是绿的,因为 L1 的判据同样只看 `reasoning_tokens`)。能力表过期是**必然事件**(M3 的 evidence 停在 8-02 整整 23 天),设计必须把它当常态处理,而不是靠人记得去复测。 + +## 3. 设计主张 + +一句话:**把"这次推理到底发生没发生"从下游的猜测变成库的一等返回值,由多信号裁定;单次响应判不出来时如实说"未知",绝不伪装成"没有";并用它与能力表持续对账,让声明过期成为可报警事件。** + +三条纪律贯穿全文: + +- **能从数据可靠推断的,绝不进静态表。** 静态表必然过期,这次就是。 +- **判不出来就叫"未知",不许折叠进"没有"。** 折叠是 ① 的病根。 +- **最硬的证据优先。** 推理正文是事实本身,token 计数是对事实的转述;转述缺失时事实仍然作数。 + +## 4. 数据模型 + +### 4.1 `ThinkingObservation` 三态(新增,响应侧) + +```python +class ThinkingObservation(StrEnum): + OBSERVED = "observed" # 确证推理发生 + ABSENT = "absent" # 确证未推理(正面证据) + UNKNOWN = "unknown" # 无任何信号,判不出来 +``` + +裁定纯函数 `observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation`,四条分支按顺序: + +| 条件 | 结果 | 理由 | +|---|---|---| +| `thinking` 非空 | OBSERVED | 推理正文是事实本身,压倒一切 | +| `reasoning_tokens > 0` | OBSERVED | 上游明确上报了推理用量 | +| `reasoning_tokens == 0` | ABSENT | 上报了且为零 = "未推理"的正面证据 | +| 其余(`None`) | UNKNOWN | 无信号,不猜 | + +映射到实测: + +| 场景 | observation | 是否诚实 | +|---|---|---| +| M3 开启,流式 | OBSERVED | ✅ 有 185 字符正文 | +| M3 开启,非流式 | UNKNOWN | ✅ 确实观测不到(正文与 ctd 双缺) | +| M3 关闭 | UNKNOWN | ✅ 判不出,但 §5 的对账仍能抓住"该关没关" | +| qwen 开启 | OBSERVED | ✅ 两个信号都在 | + +`ABSENT` 这一支在当前三家供应商上**实测永不触发**(未推理时都是整个容器缺失,无人报 `0`)。仍然保留:协议允许上报 `0`,而一旦有供应商这么做,它就是唯一能把"没推理"与"没上报"分开的信号——为一个已知会出现的未来留一个空槽,不是 YAGNI 违例。 + +### 4.2 明确不做:不把"可观测性"写进能力表 + +诱惑很大:给 `ThinkingCapability` 加一个 `reports_reasoning_usage: bool` 或 `observable_in_non_stream: bool`。**否决**。理由是本次故障的教训本身——静态声明会过期,而过期表现为静默错觉。可观测性每次响应都能直接看出来,把它冻进静态表等于再造一个 8-02 版本的定时炸弹。 + +同理否决"看 `completion_tokens_details` 容器在不在"这一判据:实测三家在未推理时都是容器整体缺失,该信号与真实信号高度混淆,用它裁定等于把噪声当信号。 + +## 5. 对账:声明 × 观测 + +在 transport 拿到结果处做一次比较,矛盾即 warning: + +| 请求方向 | 观测 | 处置 | +|---|---|---| +| `enable_thinking=False` | OBSERVED | **warning**:能力表声称可关闭,实测推理了 → 附 model 与 evidence,指路 `register_capability` | +| `enable_thinking=True` | ABSENT | **warning**:注入了开启参数,上游明确上报未推理 | +| `None`(不干预) | 任意 | 不表态——调用方没提要求,无从谈"违背" | +| 任意 | UNKNOWN | 不表态——不能证伪 | + +**不抛错**,三条理由:一次观测不足以否决一次成功的调用;P5 的降级方向铁律只对限流/熔断要求"报错而非放行",可观测性属遥测方向,降级即 warning;矛盾结果已随 `LLMResponse` 与遥测落地,处置权归下游。 + +**节流**:per transport 实例的 `set[(model, direction)]`,同一组合只喊一次,与既有 `_warned_models` 同款形态与同款理由(逐次调用刷屏会把告警变成噪声,噪声等于没有告警)。 + +这一条是本设计的灵魂:它把"能力表过期"从**静默错觉**变成**日志里的显式告警**,成本是一次枚举比较。 + +## 6. 落点清单 + +| 文件 | 变更 | +|---|---| +| `thinking.py`(**新建**) | 承载推理这件事的全部决策,见 §7 | +| `providers.py` | 收缩为纯注册表:`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`/`register_provider` | +| `types.py` | `LLMResponse` 增 `thinking_observation: ThinkingObservation = UNKNOWN`(只增不删,迁移兼容);`TransportResult` 同增(带默认值,与 `cached_prompt_tokens` 等既有先例同形态) | +| `transports/openai_compat.py` | 组装 `TransportResult` 时调 `observe_thinking`;对账告警落此处(唯一同时握有请求方向与响应结果的地方) | +| `middleware/retry.py` | 透传新字段 | +| `middleware/telemetry.py` | `_AttemptUsage` 与三个 `emit_*` 各加一行 | +| `telemetry/schema.py` | 新列 `thinking_observation TEXT`,两端 DDL + 两份 backfill + `COLUMNS`;INSERT 字段 24→25,物理列 25→26 | +| `client.py` | import 路径改指 `thinking.py` | +| `__init__.py` | 新增包根导出,见 §7 | +| `tests/e2e/test_thinking_live.py` | 判据重建,见 §8 | + +`thinking_observation` **不进缓存 key**:它是结果不是请求。缓存回放的历史响应带回历史 observation,与 `reasoning_tokens`/`cached_prompt_tokens` 的既有回放口径一致。 + +默认值取 `UNKNOWN` 使得任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。 + +## 7. 模块边界:为什么新建 `thinking.py` + +现状 `providers.py` 装着两件事:provider 注册表(形态)与推理决策(`resolve_thinking` + 能力表)。加入响应侧裁定与对账后它会变成"推理这件事的一切",一句话说不清职责(P3)。 + +| 模块 | 职责 | 内容 | +|---|---|---| +| `providers.py` | **provider 是什么** | `ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`、`register_provider` | +| `thinking.py` | **推理这件事的全部决策** | `ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`(请求侧注入)、`ThinkingUnsupportedError`、`ThinkingObservation`、`observe_thinking`(响应侧裁定)、对账告警 | + +符合 P7"决策逻辑与状态存储分离":注册表存声明,`thinking.py` 做决策。未来任何推理相关能力都有唯一归属,不必再挑"放哪个文件"。 + +**同时把公共符号提升到包根导出**:`ThinkingCapability`、`ThinkingObservation`、`register_capability`、`get_capability`、`resolve_thinking`、`ThinkingUnsupportedError`。`__init__.py` 的 docstring 早已写明"顶层导出即公共 API 面",而这些符号此前只能深路径 import——**给下游一个稳定引用点,才是模块重组不再破坏下游的前提**。这是本次一并消除的第四项债务。 + +破坏面:`from polygateway.providers import ThinkingCapability / resolve_thinking / get_capability / DEFAULT_CAPABILITIES` 会断。这些符号不在包根 `__all__` 内,且三个参考项目尚未迁移接入(M4 未完成),实际下游为零。CHANGELOG 显式列出并给出改法。 + +## 8. e2e 判据重建 + +四条红用例的病根是判据盲区,不是被测行为。逐条重建: + +| 用例 | 旧判据 | 新判据 | +|---|---|---| +| L1 关闭 | 每轮 `reasoning_tokens in (None,0)` | 每轮**不是 OBSERVED**。证伪力不减反增:模型若偷偷推理,流式必带出正文 → OBSERVED → 红 | +| L2 开启 | 多数轮 `reasoning_tokens>0`,退路 `completion>100` | 多数轮 **OBSERVED**;**删除 `_ON_MIN_COMPLETION` 魔数退路** | +| L2b 锚点 | `prompt_tokens` 两档分开 | 不变——它一直是对的,也是本次开启方向唯一没红的证据 | +| L3b 非法值反证 | 非法值多数轮推理 | 同 L2 判据;补注 provider 不可移植性(minimax 返 200 照常推理,qwen 返 400) | +| L4 extra_body 覆盖 | 多数轮推理 | 同 L2 判据 | +| L5 非流式 | 非流式重跑 L1/L2,要求开启档观测到推理 | **重新定义**,见下 | + +删掉 `_ON_MIN_COMPLETION` 是有意的。它是"`reasoning_tokens` 被中转吃掉时的退路",而实测两档的 completion 分布重叠(关闭档最高 46、开启档最低 13),这个退路从一开始就不成立——它让判据看起来有兜底,实则在噪声里画了条线。有了 `thinking` 正文这个真信号,魔数退路失去存在理由。 + +**L5 是本次改动里最重要的一条。** M3 非流式下推理正文与 ctd 双双缺失(实测),旧断言"非流式开启档应观测到推理"**永远不可能成立**——它断言的是一件事实上不发生的事。新断言改为两条:其一 `prompt_tokens` 锚点在非流式下仍然分开(证明参数确实到达了模型),其二 observation 为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。 + +**从"断言一件不成立的事"变成"断言库对这件事的诚实"**——这正是本设计要立的规矩。 + +同时在 e2e 报告与 `DEFAULT_CAPABILITIES` 的 evidence 里登记:M3 非流式路径推理不可观测,下游用非流式开推理会**付费买看不见的推理**(completion 53 vs 关闭档 3)。库修不了上游,但必须让它可见。 + +## 9. 被否决的路 + +| 备选 | 否决原因 | +|---|---| +| 只把 e2e 判据从 `reasoning_tokens` 改成"看 `thinking` 非空" | 能让四条转绿,但 ① ③ 两层一个不动:下游拿到的仍是歧义的 `None`,能力表过期仍然静默。修的是测试不是库 | +| 给 `ThinkingCapability` 加可观测性字段 | 静态声明必然过期,等于再造一个 8-02 版定时炸弹(§4.2) | +| 用"`completion_tokens_details` 容器在不在"区分 ABSENT/UNKNOWN | 实测三家未推理时都是容器整体缺失,该信号与真实信号混淆(§4.2) | +| transport 内维护"该源历史上是否上报过推理信号"的学习态 | 行为依赖历史 → 不可复现、难测试;与"纯 asyncio 中立、无隐式状态"相抵 | +| 观测与声明矛盾时抛错 | 一次观测不足以否决一次成功调用;且与降级方向铁律的分工不符(§5) | +| 顺手把遥测四处复制的参数列表收敛为单一 helper | 见 §12 | + +## 10. 非功能维度 + +**并发与取消**:裁定是纯函数,无 I/O、无状态;对账节流集合是 per-transport-instance 的 set,无跨实例共享、无模块级单例。`CancelledError` 路径完全不变(新增代码不在任何 await 之间持有资源)。 + +**降级方向**:可观测性属遥测方向 → 静默降级(warning),不报错、不阻断调用。遥测新列走既有 backfill;旧表缺列时既有的"缺列告警 + 降级写入"逻辑原样覆盖。 + +**幂等与重复**:纯函数,重复调用同结果。遥测 INSERT 仍走 `ON CONFLICT DO NOTHING` / `INSERT OR IGNORE`。 + +**持久化与原子性**:仅增一列,无写入路径变化。新列排在 `created_at` 之后(旧表只能 ALTER 追加到末尾,新建库若插在前面则两条路径的物理列序分叉——既有列序纪律,不可违)。PG 侧 `TEXT` 可空、无默认值,补列只改 catalog 不重写全表。 + +**零业务假设**:新增词汇全部是模型调用领域术语(thinking/reasoning/observation),无业务领域词。 + +## 11. 错误处理与测试策略 + +新增裁定不产生新的失败模式,**不进四分类**。`ThinkingUnsupportedError`(装配期配置错误,`ValueError` 子类)的语义与抛出位置不变,只换模块归属。 + +| 层 | 覆盖 | +|---|---| +| 单元 | `observe_thinking` 四条分支;对账三种组合(False×OBSERVED、True×ABSENT、UNKNOWN 不表态);节流只喊一次;`LLMResponse`/`TransportResult` 默认值为 UNKNOWN;schema 列数与列序断言(既有测试自动抓) | +| 集成 | SQLite/PG 新列 backfill 与回读(既有测试模式) | +| e2e | §8 判据重建,合并前 `pytest -m slow` 真跑并存档报告 | + +**先失败后通过的证据**:`observe_thinking` 与对账的单测在字段落地前必然红;e2e 的 L2/L4 在判据改完、字段落地后应从当前 main 的 FAIL 转绿(库本来就拿到了 `thinking`,只是没人看)。L5 的新断言在旧代码上无法表达(`thinking_observation` 不存在),是纯新增覆盖。 + +## 12. 明确不做 + +**不收敛遥测的四处参数列表复制。** CLAUDE.md 铁律点名"遥测调用点收敛为单一 helper,禁止复制参数列表",而 `middleware/telemetry.py` 现在正是 `_AttemptUsage` + 三个 `emit_*` 各持一份,本次加字段会让它变成四处各加一行。这是**既有债务,不是本次引入**;反 gold-plating 明文禁止任务外重构,且 1.3.0 刚动过遥测路径,同版再改组装逻辑是叠加风险。**另立 issue 单独处理**。 + +**不改 M3 的 `can_disable`**:2026-08-25 复测 `reasoning_effort=none` → prompt 194(= 基线)、completion 3、无正文,声明依然成立。只刷新 evidence 日期并补记两条新限制(非流式不可观测、仅 `reasoning_effort` 有效)。 + +**不追 MiniMax 为何停报 ctd**:那是上游的事,库无从干预,也不该把自己的正确性押在它身上——本设计的全部要点正是让库在它停报时依然说得清话。 + +## 13. 版本号 + +本次含:`LLMResponse` 新增公共字段、新增模块 `thinking.py`、新增包根导出、遥测新增一列、`providers.py` 深路径 import 断裂。按语义化版本这是 **minor**。1.3.0 仅新增一个 `TelemetryStatus` 导出即定为 minor,本次变更面更大。 + +**建议发 1.4.0 而非 1.3.1**:把"深路径 import 断裂"藏在 patch 版号里,本身就是一笔留给下游的技术债——下游看 1.3.0→1.3.1 不会去读 CHANGELOG。版号是给人读的第一份变更说明。 + +## 14. 验收标准 + +- `observe_thinking` 四条分支与对账三种组合有单测,节流经测试确认只喊一次 +- `LLMResponse.thinking_observation` 在 M3 开启流式档实测为 `OBSERVED`、非流式档为 `UNKNOWN`、qwen 开启档为 `OBSERVED` +- 遥测 SQLite/PG 两端新列均可写可读,旧表 backfill 通过,列序断言绿 +- `tests/e2e/test_thinking_live.py` 全类绿(`pytest -m slow` 真跑,报告存档 `tests/outputs/e2e/`) +- `make lint`(含 import-linter 契约)与全套件绿 +- CHANGELOG 显式列出深路径 import 断裂与改法、M3 非流式付费不可见推理这一事实 diff --git a/research-wiki/findings/2026-08-25-thinking-observability-regression.md b/research-wiki/findings/2026-08-25-thinking-observability-regression.md new file mode 100644 index 0000000..82bdfb8 --- /dev/null +++ b/research-wiki/findings/2026-08-25-thinking-observability-regression.md @@ -0,0 +1,94 @@ +--- +type: finding +node_id: finding:2026-08-25-thinking-observability-regression +title: "issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号" +date: 2026-08-25 +--- + +# issue #16/#17 实测:M3 推理正常,失效的是推理的**可观测信号** + +> 类型:finding|日期:2026-08-25|网关 `newapi.iomgaa.online` +> 本文推翻 issue #16/#17 的原始诊断("模型不再推理"),是 `designs/2026-08-25-thinking-observability-design.md` 的事实基础。 + +## 1. 为什么要重测 + +issue #16/#17 判定 MiniMax-M3 的开启推理"静默失效:模型没有推理",依据是 `tests/e2e/test_thinking_live.py` 的 L2/L3b/L4/L5 四条全红,四条的共同判据是 `reasoning_tokens > 0`。issue 自己留了一个未区分的岔路:网关侧模型行为变了,还是库的注入失效了。区分方法写得很清楚——抓一次真实请求体与原始响应。本文就是那次抓取。 + +## 2. 方法 + +两层探针,都不走 slow 套件: + +其一**绕开库**,用裸 `httpx` 直接 POST `/chat/completions`,矩阵化七种参数形态 × 流式/非流式,记录完整 `usage` 与 `message` 的键集合。绕开库是必要的——要证的命题之一正是"库有没有把参数弄丢",用库测这一条是循环论证。 + +其二**用库本身**跑 `GatewayClient.chat`,记录 `LLMResponse` 的 `reasoning_tokens` 与 `thinking` 两个字段。两层对照才能定位缺口落在哪一层。 + +对照组取 `qwen3.7-plus` 与 `deepseek-v4-pro`——同一网关、同一 key,用来区分"MiniMax 这一路变了"与"网关全局变了"。 + +## 3. 原始观测 + +### 3.1 MiniMax-M3,裸 httpx,非流式 + +| 变体 | prompt | completion | `completion_tokens_details` | `reasoning_content` | +|---|---|---|---|---| +| 不注入(基线) | 194 | 3 | **整个容器缺失** | 无 | +| `reasoning_effort=medium` | **216** | **48** | 整个容器缺失 | 无 | +| `reasoning_effort=high` | **216** | **65** | 整个容器缺失 | 无 | +| `reasoning_effort=none` | 194 | 3 | 整个容器缺失 | 无 | +| `thinking={"type":"enabled"}` | 194 | 3 | 整个容器缺失 | 无 | +| `enable_thinking=true` | 194 | 3 | 整个容器缺失 | 无 | +| 非法值 `definitely-not-a-real-level` | 207 | 87 | 整个容器缺失 | 无 | + +### 3.2 MiniMax-M3,裸 httpx,流式 + +| 变体 | delta 的键集合 | `reasoning_content` 累计 | usage | +|---|---|---|---| +| 不注入 | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd | +| `reasoning_effort=medium` | `content`,**`reasoning_content`**,`role` | **124 字符,完整推理过程** | prompt 216 / completion 60,无 ctd | +| `reasoning_effort=none` | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd | + +流式 medium 档抓到的推理正文(前 120 字符):`We need answer Chinese, only two digits. Chickens x rabbits y. x+y=35,2x+4y=94 => x+y*? 2*35+2y=94 y=12, x=23. Output 23` + +### 3.3 对照组(流式) + +| 模型 | 变体 | `reasoning_content` | `completion_tokens_details.reasoning_tokens` | +|---|---|---|---| +| deepseek-v4-pro | 不注入 | 135 字符 | **88** | +| deepseek-v4-pro | `effort=medium` | 134 字符 | **89** | +| deepseek-v4-pro | `effort=none` | 0 | 容器缺失 | +| qwen3.7-plus | 不注入 | 350 字符 | **158** | +| qwen3.7-plus | `effort=medium` | 606 字符 | **229** | +| qwen3.7-plus | `effort=none` | 0 | 容器缺失 | +| qwen3.7-plus | 非法值 | — | **HTTP 400** | + +### 3.4 用库跑(`LLMResponse` 字段) + +| 场景 | `reasoning_tokens` | `thinking` 字符数 | completion | +|---|---|---|---| +| M3 开启,流式 | None | **185** | 69 | +| M3 开启,非流式 | None | **0** | 53 | +| M3 关闭,流式/非流式 | None | 0 | 3 | +| M3 不干预 | None | 0 | 3 | +| qwen 开启,流式 | **205** | 484 | 213 | +| qwen 关闭,流式 | None | 0 | 5 | + +## 4. 五条结论 + +**① M3 的推理完全正常,issue 的诊断是错的。** 流式 medium 档抓到 124 字符完整推理过程;`prompt_tokens` 194→216(供应商注入推理指令)、`completion_tokens` 3→60(推理段被计费)。三个独立信号一致。 + +**② 真正变的是 MiniMax 这一路不再返回 `usage.completion_tokens_details`。** 而 qwen 与 deepseek 在同一网关同一 key 上照常返回。所以这不是网关全局改了 usage 处理,是 MiniMax 这一路上游的 usage 形态变了。`reasoning_tokens` 恒 NULL 由此而来。 + +**③ 库自己已经握有决定性证据,却没有用。** `LLMResponse.thinking` 在 M3 开启档流式路径下是 185 字符的实打实推理正文。e2e 的 `_reasoning_on` 只看 `reasoning_tokens` 与 `completion_tokens` 长度,从不看 `thinking`——四条红是判据的盲区,不是功能的失效。 + +**④ M3 非流式路径下推理内容整体丢失,且下游在付费。** `completion_tokens` 53 vs 关闭档 3,说明推理段确实产生并计费;而 `message` 的键集合只有 `content`/`role`,`reasoning_content` 不存在。下游用非流式调 M3 开推理 = 付钱买看不见的东西,且当前库不告诉它。这不是库能修的(上游不返回),但库必须让它可见。 + +**⑤ 三家供应商在"未推理"时都是整个 `completion_tokens_details` 缺失,无人上报 `0`。** 与 2026-08-02 findings §4c 的记录一致。推论:**"容器在不在"不能当作"有没有推理"的判据**——它与真实信号高度混淆,拿它做裁定等于把噪声当信号。 + +## 5. 顺带纠正的两处既有认识 + +**`enable_thinking` / `thinking:{type:enabled}` 对 M3 无效这一条仍然成立**(prompt 恒 194 = 基线),只有 `reasoning_effort` 是真开关。`providers.py` 的 minimax profile 用的正是 `reasoning_effort`,选型至今正确。 + +**L3b 的"非法值反证"手法只对不校验值的 provider 成立。** minimax 对非法 `reasoning_effort` 返回 200 且照常推理(prompt 207,介于基线 194 与 medium 216 之间,说明走了第三条模板路径);qwen 对同样的非法值直接 **HTTP 400**。这条手法写进测试时只在 minimax 上验过,它不可移植——若哪天把 L3b 套到别的 provider 上会得到假红。 + +## 6. `can_disable` 复测 + +M3 的 `ThinkingCapability(can_disable=True)` 的 evidence 停在 2026-08-02。2026-08-25 复测:`reasoning_effort=none` → prompt 194(= 基线)、completion 3、无 `reasoning_content`。**声明依然成立**,只需刷新 evidence 日期并补记本文新发现的两条限制(非流式不可观测、仅 `reasoning_effort` 有效)。