---
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（Gitea `iomgaa/PolyGateway`，原文经 `tea issues 16` / `17` 读取；本仓库 remote 非 GitHub，`gh` 读不到）判定"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"    # 无任何信号，判不出来
```

**枚举定义在 `types.py`，裁定逻辑在 `thinking.py`——两者必须分开。** 它是 `LLMResponse`/`TransportResult` 的字段类型，而 `types.py` 是最内层、不得 import 任何具体实现（P7，import-linter 契约执法）。把枚举放进 `thinking.py` 会让最内层反向依赖决策模块，契约当场判红。纯值类型归最内层、决策逻辑归上层，是本设计的分层落法。

取 `StrEnum` 而非裸 `str` 常量：取值域显式、可类型检查，且它是 `str` 子类，`dataclasses.asdict` + `json.dumps` 天然可序列化（缓存回放路径见 §6）。

裁定纯函数 `observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation`，四条分支按顺序：

| 条件 | 结果 | 理由 |
|---|---|---|
| `thinking.strip()` 非空 | OBSERVED | 推理正文是事实本身，压倒一切 |
| `reasoning_tokens > 0` | OBSERVED | 上游明确上报了推理用量 |
| `reasoning_tokens == 0` | ABSENT | 上报了且为零 = "未推理"的正面证据 |
| 其余（`None`） | UNKNOWN | 无信号，不猜 |

**判据取 `bool(thinking.strip())` 而非 `bool(thinking)`**：transport 收集 `reasoning_content` 时只判 truthy（`openai_compat.py`），上游返回纯空白串就会被计成"观测到推理"。网关响应是外部输入，校验后使用（P5）。

映射到实测：

| 场景 | observation | 是否诚实 |
|---|---|---|
| M3 开启，流式 | OBSERVED | ✅ 有 185 字符正文 |
| M3 开启，非流式 | UNKNOWN | ✅ 确实观测不到（正文与 ctd 双缺） |
| M3 关闭 | UNKNOWN | ✅ 判不出——**且必须承认判不出**，见下 |
| qwen 开启 | OBSERVED | ✅ 两个信号都在 |

**`UNKNOWN` 不具证伪力，不得声称它能保障关闭方向。** M3 关闭档落在 `UNKNOWN`，这意味着库无法证明推理真的关掉了。对账（§5）能提供的保障只有一个方向：**若模型真的推理了，可观测路径会把结果翻成 `OBSERVED`，告警随之触发**——M3 流式正属此列（关闭档若失效，正文会冒出来）。而不可观测路径（M3 非流式）没有任何保障，这一点必须写在文档里而不是假装有。**告警覆盖的是可观测路径，不是全部路径**。

`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 | 已登记 `can_disable=True` | **warning**：能力表漂移——声明说可关闭，实测推理了。附 model 与 `evidence` 日期，指路 `register_capability` |
| `enable_thinking=False` | OBSERVED | 未登记 | **warning**：关闭请求未被满足，且该模型能力未登记。指路实测后 `register_capability` |
| `enable_thinking=True` | ABSENT | 任意 | **warning**：注入了开启参数，上游明确上报未推理 |
| `enable_thinking=True` | UNKNOWN | 任意 | **warning 一次**：推理参数已注入但本路径观测不到，无法确认是否生效；**若为非流式路径，推理内容可能已计费却不回传**（M3 实测 completion 53 vs 关闭档 3） |
| `False` | UNKNOWN | 任意 | 不表态——不能证伪（§4.1） |
| `None`（不干预） | 任意 | 任意 | 不表态——调用方没提要求，无从谈"违背" |

前两行必须分开：`resolve_thinking` 的 Phase 3 允许未登记模型按 provider 形态尽力注入并预先 warning，那是**事前猜测**；这里的对账是**事后实证**，两者文案不能混。对未登记模型说"能力表声称可关闭"是错的——它根本没登记。

第四行是 issue #17 关切的"静默失效"的诚实版本：库不再默不作声，而是明说"我注入了，但我看不见结果"。M3 非流式每次都落这一档，故节流不可少。

**不抛错**，三条理由：一次观测不足以否决一次成功的调用；P5 的降级方向铁律只对限流/熔断要求"报错而非放行"，可观测性属遥测方向，降级即 warning；矛盾结果已随 `LLMResponse` 与遥测落地，处置权归下游。

**节流**：per transport 实例的 `set[(model, direction)]`，同一组合只喊一次，与既有 `_warned_models` 同款形态与同款理由（逐次调用刷屏会把告警变成噪声，噪声等于没有告警）。

这一条是本设计的灵魂：它把"能力表过期"从**静默错觉**变成**日志里的显式告警**，成本是一次枚举比较。

## 6. 落点清单

**源码**

| 文件 | 变更 |
|---|---|
| `types.py` | 新增 `ThinkingObservation`（枚举归最内层，§4.1）；`LLMResponse` 增 `thinking_observation: ThinkingObservation = UNKNOWN`（只增不删，迁移兼容）；`TransportResult` 同增 |
| `thinking.py`（**新建**） | 推理这件事的全部**决策**，见 §7 |
| `providers.py` | 收缩为纯注册表：`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`/`register_provider` |
| **`ports.py`** | `TelemetryRecorder.record_llm_call` 24 参 → 25 参。该 docstring 明定"新增参数不设默认值"（库外无第三方实现者），故两个 recorder 与全部测试替身必须同步。**这是端口 Protocol 签名变更**，属 CLAUDE.md 强制人类确认档 |
| `transports/openai_compat.py` | 组装 `TransportResult` 时调 `observe_thinking`；对账告警落此处（唯一同时握有请求方向与响应结果的地方） |
| `middleware/retry.py` | 透传新字段 |
| `middleware/telemetry.py` | `_AttemptUsage` 增一字段；三个 `emit_*` 各传一行；`_record` 签名增一参——**全部经既有单一出口 `_record` 抵达 recorder**，不新开调用点（§12） |
| **`middleware/cache.py`** | `_rehydrate` 走 `LLMResponse(**fields)`，JSON 复活的是**裸字符串**而非枚举实例：须显式转 `ThinkingObservation(...)`。非法值（旧缓存/污染）转换失败由既有 try/except 吞成"按未命中回源"，降级方向正确 |
| `telemetry/schema.py` | 新列 `thinking_observation TEXT`，两端 DDL + 两份 backfill + `COLUMNS`；INSERT 字段 24→25，物理列 25→26 |
| `telemetry/sqlite.py`、`telemetry/postgres.py` | 实现新参 |
| `client.py` | import 路径改指 `thinking.py` |
| `__init__.py` | 新增包根导出，见 §7 |

**测试**

`tests/unit/` 下 `test_types.py`（默认值为 UNKNOWN、位置构造兼容、枚举归属模块）、`test_ports.py`（端口签名冻结测试与 recorder 替身）、`test_openai_compat.py`（裁定四分支、优先级、对账三类告警、节流只喊一次）、`test_retry.py`（透传）、`test_telemetry.py`（列数/列序/组装）、`test_cache.py`（回放后仍是枚举实例、非法值按未命中）、`test_package.py`（包根导出面，比照 `TelemetryStatus` 先例）、`test_providers.py`（拆分后的注册表）；`tests/integration/test_postgres_telemetry.py`（新列 backfill 与 round-trip）；`tests/e2e/test_thinking_live.py`（判据重建，§8）。

**文档**（发布清单第 1 步要求构建前改完）

`README.md` 的"必录 24 字段"→ 25，**须用 `inspect.signature` 实测而非凭记忆**；`research-wiki/ARCHITECTURE.md` 的 D11、§5.1 响应字段、§7.8 遥测字段、§8 模块结构（补 `thinking.py`）；`research-wiki/schemas/llm-calls.md`（标题仍写"22 字段"，已过期两轮，本次一并订正为 25）；`research-wiki/index.md`（登记本 design 与 finding）;`CHANGELOG.md`（断裂项置顶，§13）。

`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`、`observe_thinking`（响应侧裁定）、对账告警。**不含 `ThinkingObservation` 定义**——纯值类型归 `types.py`（§4.1） |

符合 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` 四条分支 + 空白串不算 OBSERVED；对账四类告警（False×OBSERVED 已登记 / False×OBSERVED 未登记 / True×ABSENT / True×UNKNOWN）与两类不表态；节流只喊一次；`LLMResponse`/`TransportResult` 默认值为 UNKNOWN 且位置构造不破；端口签名冻结（25 参）；缓存回放后仍是枚举实例、非法值按未命中；schema 列数与列序断言（既有测试自动抓）；包根导出面 |
| 集成 | SQLite/PG 新列 backfill 与 round-trip（既有测试模式） |
| e2e | §8 判据重建，合并前 `pytest -m slow` 真跑并存档报告 |

**先失败后通过的证据**：`observe_thinking` 与对账的单测在字段落地前必然红；e2e 的 L2/L4 在判据改完、字段落地后应从当前 main 的 FAIL 转绿（库本来就拿到了 `thinking`，只是没人看）。L5 的新断言在旧代码上无法表达（`thinking_observation` 不存在），是纯新增覆盖。

## 12. 明确不做

**不重构遥测组装路径。** 铁律"遥测调用点收敛为单一 helper"**当前已经满足**：`TelemetryEmitter._record` 是全库唯一调用 `record_llm_call` 的地方（`middleware/telemetry.py` 文件头即如此声明）。三个 `emit_*` 是三个语义不同的入口（逐次尝试 / 缓存命中 / 终态失败），各自组装参数是职责所在，不是复制粘贴债务——本次新增字段照样只经 `_record` 一个出口下沉。

**不改 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，理由是把"深路径 import 断裂"藏在 patch 版号里等于留债——下游看 1.3.0→1.3.1 不会去读 CHANGELOG。

**人类 2026-08-25 决定：发 1.3.1。** 决定已记录，实施按此执行。既然版号不再承担预警职责，预警必须由 CHANGELOG 独立扛起：断裂项与改法置于本版条目**最前**，沿用 1.3.0"请先读这一条"的体例，不得只在中段一笔带过。

## 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/`）
- 端口 `record_llm_call` 25 参，两个 recorder 与全部测试替身同步，签名冻结测试绿
- 缓存回放的 `thinking_observation` 是 `ThinkingObservation` 实例而非裸字符串
- `make lint`（含 import-linter 契约，须确认 `types.py` 未 import `thinking.py`）与全套件绿
- README 的遥测字段数经 `inspect.signature` 实测更新为 25；ARCHITECTURE §8 模块结构含 `thinking.py`；`schemas/llm-calls.md` 由过期的"22 字段"订正为 25；本 design 与 finding 进 `research-wiki/index.md`
- CHANGELOG 本版条目**最前**列出深路径 import 断裂与改法、端口签名变更、M3 非流式付费不可见推理这一事实（§13）
