From 37b4a557c295c03012df73d441271d1c2b631823 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Tue, 25 Aug 2026 22:02:03 -0400 Subject: [PATCH] docs: disprove the issue #16/#17 diagnosis with live gateway probes The four red e2e cases were blamed on MiniMax-M3 no longer reasoning. Raw gateway probes show the opposite: M3 reasons fine (124 chars of reasoning_content, prompt 194 to 216, completion 3 to 60). What changed is that the MiniMax route stopped returning completion_tokens_details, while qwen and deepseek still do on the same gateway and key. The library already holds 185 chars of proof in LLMResponse.thinking and never feeds it into any verdict. The design turns that verdict into a first-class return value judged from multiple signals, says UNKNOWN when a single response cannot tell, and reconciles it against the capability table so a stale declaration becomes a warning instead of a silent illusion. --- ...026-08-25-thinking-observability-design.md | 209 ++++++++++++++++++ ...08-25-thinking-observability-regression.md | 94 ++++++++ 2 files changed, 303 insertions(+) create mode 100644 research-wiki/designs/2026-08-25-thinking-observability-design.md create mode 100644 research-wiki/findings/2026-08-25-thinking-observability-regression.md 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` 有效)。