---
type: plan
node_id: plan:2026-08-25-thinking-observability-plan
title: "推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)"
date: 2026-08-25
---

# 推理可观测性一等化实现计划（issue #16 + #17，发 1.3.1）

> 类型：plan｜日期：2026-08-25｜实现设计：`designs/2026-08-25-thinking-observability-design.md`（已经人类批准）
> 事实基础：`findings/2026-08-25-thinking-observability-regression.md`
> **保真校验不适用**：本计划不涉及 `reference/` 三项目的迁移，推理开关是库自有子系统，不在 ARCHITECTURE.md §1.4 关键资产索引的移植蓝本内。

## 目标

让"这次推理到底发生没发生"成为库的一等返回值，由多信号裁定，判不出来时如实说 UNKNOWN，并与能力表持续对账。

## 方案概述

新增 `ThinkingObservation` 三态枚举（定义在最内层 `types.py`）与裁定纯函数 `observe_thinking`（决策层 `thinking.py`），由 transport 在组装结果时裁定并与请求方向对账，结果随 `LLMResponse` 返回、随遥测落库。同时把推理决策从 `providers.py` 拆进新模块 `thinking.py`，并把公共符号提升到包根导出。

涉及技术：Python 3.12 `StrEnum`、frozen dataclass、`inspect.signature` 冻结测试、import-linter 分层契约、SQLite/PG schema backfill。

## 文件结构

**新建**

| 文件 | 职责 |
|---|---|
| `src/polygateway/thinking.py` | 推理这件事的全部**决策**：能力表、`resolve_thinking`（请求侧注入）、`observe_thinking`（响应侧裁定）、对账告警。**不含 `ThinkingObservation` 定义** |
| `tests/unit/test_thinking.py` | 裁定与对账的单元测试 |

**修改**

| 文件 | 变更 |
|---|---|
| `src/polygateway/types.py` | 新增 `ThinkingObservation`；`LLMResponse` / `TransportResult` 各增一字段 |
| `src/polygateway/providers.py` | 收缩为纯注册表 |
| `src/polygateway/ports.py` | `record_llm_call` 24 参 → 25 参 |
| `src/polygateway/transports/openai_compat.py` | 裁定 + 对账 |
| `src/polygateway/middleware/retry.py` | 透传 |
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage` + 三个 `emit_*` + `_record` |
| `src/polygateway/middleware/cache.py` | `_rehydrate` 枚举复活 |
| `src/polygateway/telemetry/schema.py` | 新列 + 两端 DDL + 两份 backfill |
| `src/polygateway/telemetry/sqlite.py`、`postgres.py` | 实现新参 |
| `src/polygateway/client.py` | import 路径 |
| `src/polygateway/__init__.py` | 包根导出 + 版本号 |
| `pyproject.toml` | import-linter 契约加层 + 版本号 |
| 测试 9 个、文档 5 个 | 见各任务 |

---

## Task 1：`ThinkingObservation` 与裁定纯函数

**文件**：创建 `src/polygateway/thinking.py`、`tests/unit/test_thinking.py`；修改 `src/polygateway/types.py`、`pyproject.toml`

### 行为

在 `types.py` 新增（放在 `LLMResponse` 定义**之前**，因为它是其字段类型）：

```python
class ThinkingObservation(StrEnum):
    """一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。

    三态不可折叠为布尔: `UNKNOWN` 是"本次无任何信号,判不出来",与
    `ABSENT`("上游明确上报未推理")语义不同。把前者折叠进后者,正是
    `reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",
    而实际可能推理了且已计费(MiniMax-M3 非流式实测)。
    """

    OBSERVED = "observed"
    ABSENT = "absent"
    UNKNOWN = "unknown"
```

在新建的 `thinking.py` 实现（本任务只放这一个函数，搬迁留给 Task 2）：

```python
def observe_thinking(
    *, thinking: str, reasoning_tokens: int | None
) -> ThinkingObservation:
    """由多信号裁定推理是否发生;判据按证据硬度排序。

    推理正文是事实本身,token 计数是对事实的转述——转述缺失时事实仍然作数。
    """
    if thinking.strip():
        return ThinkingObservation.OBSERVED
    if reasoning_tokens is None:
        return ThinkingObservation.UNKNOWN
    return (
        ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
    )
```

`pyproject.toml` 的 import-linter 契约 `layers` 插入一层，位置在实现层与 `providers` 之间：

```toml
layers = [
    "polygateway.client",
    "polygateway.config",
    "polygateway.middleware",
    "polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
    "polygateway.thinking",
    "polygateway.providers : polygateway.sources",
    "polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
]
```

层序理由：`thinking.py` 要 import `providers.py` 的 `ProviderProfile`（故在其上），被 `transports/` 与 `client.py` import（故在其下）。**枚举放 `types.py` 而非 `thinking.py`，正是为了让最内层不反向依赖决策层**——这是本任务最容易做错的一步，写反了 import-linter 会判红。

### 测试要求（先失败后通过）

`tests/unit/test_thinking.py` 覆盖裁定五种输入：正文非空 → OBSERVED；**纯空白正文 + `reasoning_tokens=None` → UNKNOWN**（不得因 truthy 判成 OBSERVED）；`reasoning_tokens=5` → OBSERVED；`reasoning_tokens=0` → ABSENT；`reasoning_tokens=None` 且正文空 → UNKNOWN。再加一条优先级用例：正文非空且 `reasoning_tokens=0` → OBSERVED（正文压倒转述）。

`tests/unit/test_types.py` 加一条：`ThinkingObservation` 定义在 `polygateway.types` 模块内（`ThinkingObservation.__module__ == "polygateway.types"`），防止后续任务把它挪回决策层。

### 验证

```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_types.py -v
conda run -n PolyGateway lint-imports
```

预期：新测试全 PASS；`lint-imports` 全部契约 KEPT。

- [ ] Task 1 提交：`feat: judge whether reasoning actually happened from multiple signals`

---

## Task 2：把推理决策从 `providers.py` 搬进 `thinking.py`

**文件**：修改 `src/polygateway/thinking.py`、`src/polygateway/providers.py`、`src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/__init__.py`、`tests/unit/test_providers.py`、`tests/unit/test_package.py`

### 行为

从 `providers.py` **原样移入** `thinking.py`（纯移动，不改逻辑）：`ThinkingUnsupportedError`、`ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`、`_warn_unregistered`。

`providers.py` 保留：`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`、`register_provider`。其模块 docstring 改为只讲注册表职责；`thinking.py` 的模块 docstring 说明它承载推理的全部决策而枚举归 `types.py`。

更新 import：`client.py`（`from polygateway.providers import get_capability, get_provider, resolve_thinking` 拆成两行）、`transports/openai_compat.py`、`client.py` 的 `TYPE_CHECKING` 块里 `ThinkingCapability` 的来源。

`__init__.py` 新增包根导出并加进 `__all__`（该列表**不是严格字母序**——`DEFAULT_PROFILES` 现在就排在 `AllSourcesExhausted` 前面；沿用文件既有排列，把新符号插到同类符号附近即可）：`ThinkingCapability`、`ThinkingObservation`、`ThinkingUnsupportedError`、`get_capability`、`register_capability`、`resolve_thinking`。

`tests/unit/test_providers.py` 里针对被搬走符号的测试，整体移入 `tests/unit/test_thinking.py`。

### 测试要求（先失败后通过）

`tests/unit/test_package.py` 比照既有 `TelemetryStatus` 用例，加一条断言六个新符号可从包根 import 且在 `__all__` 内——该测试在导出落地前必然红。

搬迁本身的回归证据：搬迁前后 `pytest tests/unit -q` 通过数不减（搬迁是纯移动，任何行为差异都是 bug）。

### 验证

```bash
conda run -n PolyGateway pytest tests/unit -q
conda run -n PolyGateway lint-imports
conda run -n PolyGateway python -c "from polygateway import ThinkingObservation, ThinkingCapability, resolve_thinking; print('ok')"
```

预期：全 PASS；契约 KEPT；import 成功。

- [ ] Task 2 提交：`refactor: give reasoning decisions their own module`

---

## Task 3：字段落到响应类型并贯通调用链

**文件**：修改 `src/polygateway/types.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/middleware/retry.py`；测试 `tests/unit/test_types.py`、`tests/unit/test_openai_compat.py`、`tests/unit/test_retry.py`

### 行为

`TransportResult` 与 `LLMResponse` 各新增字段，**必须加在各自字段列表末尾且带默认值**（`LLMResponse` 是被三项目消费的公共类型，只增不删且不得改变既有位置参数顺序）：

```python
    thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
```

`LLMResponse` 侧补 docstring：`UNKNOWN` = 本次无信号判不出，**不是**"没推理"；非流式路径下部分模型推理已计费却不回传正文（M3 实测 completion 53 vs 关闭档 3），该档即为 `UNKNOWN`。

`transports/openai_compat.py` 的两条组装路径（流式 `_complete_stream` 的 463-475 行、非流式 `_complete_once` 的 548-560 行）在构造 `TransportResult` 时调 `observe_thinking(thinking=thinking, reasoning_tokens=...)` 填入。两条路径都要填——**只填一条正是 L5 要抓的那类分叉**。

`middleware/retry.py` 的 `_build_response`（372-393 行）透传 `thinking_observation=result.thinking_observation`。

### 测试要求（先失败后通过）

`tests/unit/test_types.py`：两个类型的默认值均为 `ThinkingObservation.UNKNOWN`；`LLMResponse` 既有位置构造方式不破（沿用文件内既有的构造用例形态）。

`tests/unit/test_openai_compat.py`：用既有的 SSE / JSON 响应装置，构造三种响应各断言一次——含 `reasoning_content` 增量 → `OBSERVED`；无推理信号 → `UNKNOWN`；`usage.completion_tokens_details.reasoning_tokens=0` → `ABSENT`。流式与非流式各一组。

`tests/unit/test_retry.py`：比照既有透传测试，断言 transport 返回的 `thinking_observation` 原样出现在 `LLMResponse` 上。

以上在字段落地前全部红（属性不存在）。

### 验证

```bash
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v
```

预期：全 PASS。

- [ ] Task 3 提交：`feat: carry the reasoning verdict through to LLMResponse`

---

## Task 4：对账告警（声明 × 观测）

**文件**：修改 `src/polygateway/thinking.py`、`src/polygateway/transports/openai_compat.py`；测试 `tests/unit/test_thinking.py`、`tests/unit/test_openai_compat.py`

### 行为

`thinking.py` 新增对账纯函数，返回告警文案或 `None`（**判定与日志分离**，这样告警内容可被单测直接断言，不必去解析日志）：

```python
def reconcile_thinking(
    *,
    enable_thinking: bool | None,
    observation: ThinkingObservation,
    capability: ThinkingCapability | None,
    model: str,
) -> str | None:
    """把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。

    能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
    表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
    """
```

判定矩阵（设计 §5）：

| `enable_thinking` | observation | capability | 返回 |
|---|---|---|---|
| `False` | OBSERVED | 已登记 | 能力表漂移：声明可关闭，实测推理了。附 `capability.evidence` 与 `register_capability` 指路 |
| `False` | OBSERVED | `None` | 关闭请求未被满足，且该模型能力未登记。指路实测后 `register_capability` |
| `True` | ABSENT | 任意 | 注入了开启参数，上游明确上报未推理 |
| `True` | UNKNOWN | 任意 | 推理参数已注入但本路径观测不到，无法确认是否生效；若为非流式路径，推理内容可能已计费却不回传 |
| 其余组合（含 `False`×UNKNOWN、`None`×任意） | | | `None` |

`False`×UNKNOWN 返回 `None` 是刻意的：`UNKNOWN` 没有证伪力，拿它报警等于每次关闭调用都喊一遍（M3 关闭档恒落此档），噪声即等于没有告警。

`transports/openai_compat.py` 在组装完 `TransportResult` 后调用它，非 `None` 则 `logger.warning`，并按 `(model, enable_thinking)` 节流——新增实例级 `set`，与既有 `_warned_models` 同款形态，**不可复用同一个 set**（那个 set 语义是"未登记能力已告警过"，混用会互相压制）。

### 测试要求（先失败后通过）

`tests/unit/test_thinking.py`：矩阵四行各断言返回非 `None` 且文案含模型名；三种不表态组合（`False`×UNKNOWN、`None`×OBSERVED、`True`×OBSERVED）断言返回 `None`；已登记 vs 未登记两行的文案**必须不同**（不得对未登记模型说"能力表声称可关闭"）。

`tests/unit/test_openai_compat.py`：断言同一 `(model, direction)` 连调两次只出现一条 warning；换 direction 后再出一条。**不能用 `caplog`**——本项目日志走 loguru,不经标准 `logging`,`caplog` 抓不到;复用 `tests/unit/test_thinking.py` 的 `_warnings()`(`logger.add` 收集)。

> `reconcile_thinking` 必须定义在 `ThinkingCapability` **之后**:本模块没有 `from __future__ import annotations`,注解在 `def` 时求值,放在文件上部会 `NameError`。

### 验证

```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_openai_compat.py -v
```

预期：全 PASS。

- [ ] Task 4 提交：`feat: warn when the capability table and reality disagree`

---

## Task 5：缓存回放复活枚举

**文件**：修改 `src/polygateway/middleware/cache.py`；测试 `tests/unit/test_cache.py`

### 行为

`_rehydrate` 走 `LLMResponse(**fields)`，JSON 里的 `"observed"` 会复活成**裸 `str`** 而非枚举实例，类型与注解分叉。在 `fields.update(...)` 之前显式转换：

```python
            if "thinking_observation" in fields:
                fields["thinking_observation"] = ThinkingObservation(
                    fields["thinking_observation"]
                )
```

非法值（旧版本缓存、人为污染）会抛 `ValueError`，由既有的 `except Exception` 吞成"按未命中回源"并 warning——降级方向正确，不需额外处理。

`_serialize` 无需改动：`StrEnum` 是 `str` 子类，`dataclasses.asdict` + `json.dumps` 直接可序列化。

### 测试要求（先失败后通过）

`tests/unit/test_cache.py`：写入一条 `thinking_observation=OBSERVED` 的响应后命中回放，断言 `isinstance(resp.thinking_observation, ThinkingObservation)`（改动前必然红——回放出来的是 `str`）；再造一条 `thinking_observation` 为 `"bogus"` 的缓存值，断言按未命中回源。

### 验证

```bash
conda run -n PolyGateway pytest tests/unit/test_cache.py -v
```

预期：全 PASS。

- [ ] Task 5 提交：`fix: revive the reasoning verdict as an enum, not a bare string`

---

## Task 6：遥测新增一列（端口 → schema → recorder → emitter）

**文件**：修改 `src/polygateway/ports.py`、`src/polygateway/telemetry/schema.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`；测试 `tests/unit/test_ports.py`、`tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py`

### 行为

**端口**：`TelemetryRecorder.record_llm_call` 增 `thinking_observation: str`，**不设默认值**（该 Protocol 的既有纪律，docstring 已写明理由：库外无第三方实现者，带默认值会让 emitter 漏传时静默落默认）。参数加在 `meta` 之后。docstring 的"24 字段冻结"改为 25。

**schema**：`SQLITE_DDL` / `PG_DDL` 末尾加 `thinking_observation TEXT`；`SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` 各加 `("thinking_observation", "TEXT")`；`COLUMNS` 末尾加同名项。**新列必须排在最末**——旧表只能 ALTER 追加到末尾，插在中间会让新建库与补列库的物理列序分叉（该纪律的注释就在这两个常量上方）。

**recorder**：两个 recorder 的 `record_llm_call` 都是 `(self, **fields: object)` 形态（**不是**显式参数列表），按 `COLUMNS` / `self._columns` 从 `fields` 取值——新列因此**不需要改签名**，只要 `COLUMNS` 里有、emitter 传了，取值就自动到位。要做的是核对两处：取值是否严格按列序、manual 档列裁剪路径是否覆盖新列。`sqlite.py:146` docstring 的"24 字段冻结签名"改 25。

> 端口 `ports.py` 的 Protocol 是**显式 25 参**，而实现是 `**fields`——这不矛盾：Protocol 声明的是调用契约（emitter 必须按名传全），实现选择用 kwargs 收。改端口签名仍然必要，它是 emitter 侧的编译期约束与冻结测试的锚点。

**emitter**：`_AttemptUsage` 增 `thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN`（**内部字段用枚举类型**，裸 `str` 归一化只发生在下沉 recorder 那一步），`of()` 从 response 取；三个 `emit_*` 各传一行（`emit_terminal_failure` 传 `ThinkingObservation.UNKNOWN`——无响应可言，默认值本身不撒谎）；`_record` 签名增一参并下沉给 recorder。**所有新增字段只经 `_record` 这一个出口抵达 recorder，不新开调用点**（铁律：遥测调用点收敛为单一 helper，该出口已存在）。`middleware/telemetry.py:135` 的"组装 24 字段"改 25。

**recorder 收到的必须是裸 `str`，不是枚举实例**：`_AttemptUsage.thinking_observation` 内部用 `ThinkingObservation` 类型，但 `_record` 下沉给 recorder 时取 `.value`。`StrEnum` 虽是 `str` 子类，asyncpg 的参数编码对 `str` 子类不保证接受，而遥测写失败只会被降级成一条 warning——这类问题不会当场炸，只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧，与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一先例（`ports.py` docstring 明载该分工：recorder 只落库，不做语义判断）。

### 数字断言逐处更新（漏一处即红）

| 位置 | 现值 → 新值 |
|---|---|
| `tests/unit/test_telemetry.py:37` `_EXPECTED_COLUMNS` | 末尾加 `thinking_observation` |
| `tests/unit/test_telemetry.py:184` INSERT 占位符串 | 补到 `$25` |
| `tests/unit/test_telemetry.py:210` | `len(COLUMNS) == 24` → `25` |
| `tests/unit/test_telemetry.py:633` docstring | 物理列 `23 → 25` 改为 `24 → 26` |
| `tests/unit/test_telemetry.py:642` | `== 25` → `== 26` |
| `tests/unit/test_telemetry.py:645` docstring | `25 个物理列` → `26 个` |
| `tests/integration/test_postgres_telemetry.py:764` 注释 | `22 → 24 个 recorder 字段(加 created_at 共 25 个物理列)` 改为 `24 → 25 个(共 26 个物理列)` |

> 上表**不完整**——实施时实测另有 6 处漏改会当场把测试跑红:`_FROZEN_SQLITE_INSERT`(计划只点了 PG 那条)、`:586` 的 `_EXPECTED_COLUMNS[:-2]` → `[:-3]`、`TestBackendColumnParity` 的 `COLUMNS[-2:]` 断言、两处 `_CURRENT` 假列表(稳态不发 ALTER 的断言)、`PG_BACKFILL[-1]` 末位断言,以及 integration 侧 `:608` 的 `_PRE_TENANT_COLUMNS` 派生式。另有四处注释/docstring 的字段数会过期。**结论: 不要照表逐条打勾就收工,以"全套件绿"为准**。

> **不要改 `tests/unit/test_telemetry.py:1787`**：那里的"共 24 字"是 OCR 占位串 `<ocr:text image_bytes=3>` 的**字符数**，与遥测列数无关。全局替换"24"会误伤它。

### 测试要求（先失败后通过）

`tests/unit/test_ports.py`：现有 `TestTelemetryRecorderSignature` **并不冻结完整参数列表**——它只 parametrize 了 `["tenant_id", "meta"]` 两项，断言其无默认值且为 KEYWORD_ONLY。把 `thinking_observation` 加进该 parametrize 列表，断言同样三条——改端口前必然红。

`tests/unit/test_telemetry.py`：列数与列序断言（上表）；新增一条 round-trip——记录一条 `thinking_observation=OBSERVED` 的调用后从 SQLite 读回该列等于 `"observed"`。

`tests/integration/test_postgres_telemetry.py`：既有 backfill 用例覆盖旧表补列后新列存在且可写读。

### 验证

```bash
conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
conda run -n PolyGateway python -c "
import inspect
from polygateway.ports import TelemetryRecorder
p = inspect.signature(TelemetryRecorder.record_llm_call).parameters
print('recorder 参数数(不含 self):', len(p) - 1)"
```

预期：全 PASS；最后一条打印 `25`（README 的字段数断言按此实测值填，见 Task 9）。

- [ ] Task 6 提交：`feat: record the reasoning verdict in telemetry`

---

## Task 7：e2e 判据重建

**文件**：修改 `tests/e2e/test_thinking_live.py`

### 行为

`_run_rounds` 的逐轮观测字典增加两个键：`"thinking_observation": resp.thinking_observation` 与 `"thinking_chars": len(resp.thinking)`（报告里要能看见证据本身，而不只是结论）。

判据函数改写：

```python
def _reasoning_on(obs: dict) -> bool:
    """开启方向: 观测到推理即为真。

    判据从 `reasoning_tokens` 换成三态裁定,因为 MiniMax 这一路已不再上报
    `completion_tokens_details`(2026-08-25 findings),而库在同一次调用里
    拿得到 185 字符推理正文——旧判据看不见它,四条用例因此假红。
    """
    return obs["thinking_observation"] == ThinkingObservation.OBSERVED


def _reasoning_off(obs: dict) -> bool:
    """关闭方向: 只要没观测到推理即算满足。

    `UNKNOWN` 计入满足是有意的: 它没有证伪力(设计 §4.1),不能拿它判红。
    本判据真正的证伪力在于——模型若偷偷推理了,可观测路径会翻成 OBSERVED。
    """
    return obs["thinking_observation"] != ThinkingObservation.OBSERVED
```

**删除 `_ON_MIN_COMPLETION` 常量及其全部引用**：两档 completion 分布实测重叠（关闭档最高 46、开启档最低 13），这个魔数退路从一开始就不成立。

**L5 重新定义**（当前实现断言"非流式开启档多数轮观测到推理"，而 M3 非流式推理正文与 ctd 双缺，该断言永远不可能成立）：改为断言两件真实成立的事——其一非流式下关闭档与开启档的 `prompt_tokens` 锚点仍然分开（证明参数确实到达模型，判据形态照抄 L2b）；其二开启档观测为 `UNKNOWN` 而非 `ABSENT`（证明库如实标记"观测不到"而没有伪装成"没推理"）。用例 docstring 写明：M3 非流式推理已计费却不回传正文，这是上游行为，库修不了但必须让它可见。

L3b 的 docstring 补一句不可移植性：minimax 对非法 `reasoning_effort` 返回 200 且照常推理，qwen 对同样的值返回 **HTTP 400**——该反证手法只对不校验值的 provider 成立。

模块顶部的判据纪律段与 `_write_report` 的报告表头同步改写为三态口径。

### 测试要求（先失败后通过）

本任务的证据是真跑：改前 `TestMiniMaxM3` 4 failed / 3 passed，改后全类 PASS。L5 的新断言在 Task 3 之前无法表达（字段不存在），是纯新增覆盖。

### 验证

```bash
conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v
```

预期：`TestMiniMaxM3` 7 passed；报告落 `tests/outputs/e2e/`。耗时约 7 分钟、约 137 次真实调用。

- [ ] Task 7 提交：`test: judge reasoning by what the library actually observed`

---

## Task 8：能力表 evidence 刷新

**文件**：修改 `src/polygateway/thinking.py`

### 行为

`DEFAULT_CAPABILITIES` 中 `MiniMax-M3` 的 `can_disable` **保持 `True`**（2026-08-25 复测：`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文，声明依然成立）。`evidence` 追加复测日期与两条新限制：推理信号在非流式路径不可观测；`enable_thinking` / `thinking:{type:enabled}` 对该模型无效，仅 `reasoning_effort` 是真开关。

`minimax` profile 上方的注入形态注释同步补记复测日期。

### 测试要求

**先失败后通过不适用于本任务，理由须写进提交信息**：本任务只改 `evidence` 字符串与注释，`can_disable` 取值不变，**没有行为变更**，因而没有可先失败的行为断言（`test-driven-development` 的结果门约束的是行为变更）。声明依然成立这一事实，其证据是 2026-08-25 的复测与 Task 7 的 e2e 真跑，不是本任务能自造的单测。

`tests/unit/test_thinking.py` 既有的能力表用例（`evidence` 非空、`can_disable` 取值）须保持绿，作为回归证据。

### 验证

```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py -q
```

- [ ] Task 8 提交：`docs: refresh the M3 capability evidence with the 08-25 retest`

---

## Task 9：文档同步（构建前必须改完）

**文件**：修改 `README.md`、`research-wiki/ARCHITECTURE.md`、`research-wiki/schemas/llm-calls.md`、`research-wiki/index.md`、`CHANGELOG.md`

### 行为

**`README.md:21`**：`必录 24 字段` → `25 字段`。数字取 Task 6 验证步骤里 `inspect.signature` 的实测输出，**不凭记忆**（发布清单第 1 步点名的失败模式）。同时核对安装命令的版本约束是否需要跟进，以及能力表是否要提及推理裁定这一新行为。

**`README.md` 的 `<!-- pg-template:table -->` 生产部署 DDL 模板**——**本条计划原文是错的,已订正**。

原文断言该模板是"独立于 `schema.py` 手写的另一份 SQL",要求补上 `thinking_observation TEXT`。**事实相反**:该模板不含任何列定义,它是 `CREATE TABLE llm_calls (LIKE llm_calls_seed INCLUDING DEFAULTS, PRIMARY KEY (call_id, created_at)) PARTITION BY RANGE (created_at)`,列全部从上一步 `telemetry_schema_sql('postgres')` 建出的 seed 表派生,README 正文原本就写着"列不在这里重抄一份——抄了就会漂移"。照原文补列会让 PG 报列重复、`TestProductionTemplate` 全红、下游部署直接失败。

（这条错误的来路值得记下来: 它出自另一个任务的实施报告,写进计划时**没有自己打开 README 核实**。跨任务转述的"发现"必须当作待验证的线索,不是事实。）

正确的做法是加一条**形态断言**: 模板必须靠 `LIKE` 派生,且不得内联任何 `COLUMNS` 里的列名。它钉住的是"日后有人把列抄进模板"这个真实风险——比原计划想堵的缺口更贴合实际。断言落在 `tests/integration/test_postgres_telemetry.py` 的 `TestProductionTemplate`（**不在** `tests/unit/test_telemetry.py`,计划原文也指错了文件）。

**`research-wiki/ARCHITECTURE.md`**：§8 模块结构树补 `thinking.py` 一行并说明职责；§8 依赖纪律段补 `thinking.py` 的层位；D11 段说明推理决策已从 `providers.py` 拆出；§5.1 响应字段表补 `thinking_observation`；§7.8 遥测字段补新列。

**`research-wiki/schemas/llm-calls.md`**：标题与正文的"遥测 22 字段"已过期两轮，订正为 25；补 `thinking_observation` 的列定义与查询口径（示例：按模型统计各观测态占比，用于发现某模型何时开始观测不到推理）。

**`research-wiki/index.md`**：登记本 plan、design 与 finding。

**先失败后通过不适用于本任务**：纯文档同步，无行为变更。其验收是下方 grep 的可见输出——数字与模块名对不上就是没改完。

**`CHANGELOG.md`**：新增 1.3.1 条目。**断裂项置于条目最前**，沿用 1.3.0"请先读这一条"体例（设计 §13：版号既然不承担预警职责，预警由 CHANGELOG 独立扛）。三条必须显式列出——① `polygateway.providers` 的深路径 import 断裂（`ThinkingCapability` / `resolve_thinking` / `get_capability` / `register_capability` / `DEFAULT_CAPABILITIES` / `ThinkingUnsupportedError` 移入 `polygateway.thinking`，同时提升到包根，**推荐改用包根 import**）；② `TelemetryRecorder.record_llm_call` 端口签名 24 参 → 25 参，自定义 recorder 实现须同步；③ M3 非流式开启推理时推理内容已计费却不回传，该档观测为 `UNKNOWN`，库现在会告警一次。

### Wiki 注册

```bash
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id 2026-08-25-thinking-observability-plan --title "推理可观测性一等化实现计划"
.claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:2026-08-25-thinking-observability-plan" --to "design:2026-08-25-thinking-observability-design" --type implements --evidence "本计划实现该设计的全部落点"
.claude/tools/research_wiki.py rebuild_index research-wiki/
```

### 验证

```bash
grep -n '25 字段' README.md
grep -n 'thinking.py' research-wiki/ARCHITECTURE.md
grep -rn '22 字段' research-wiki/schemas/llm-calls.md   # 预期无输出
```

- [ ] Task 9 提交：`docs: sync the field counts and module map to 1.3.1`

---

## Task 10：合并前独立验证与发布 1.3.1

**文件**：修改 `pyproject.toml`、`src/polygateway/__init__.py`

### 行为

版本号两处改 `1.3.1`（`pyproject.toml` 与 `__init__.py.__version__` 必须一致）；`CHANGELOG.md` 的"未发布"定版为 `## 1.3.1(2026-08-25)`。

本任务分两段，**中间是一道人类确认门**。

**第一段：分支内可自主完成的验证**——CHANGELOG 定版为 `## 1.3.1(2026-08-25)`；版本号两处改 `1.3.1`；`verification-before-completion` 派**全新上下文** verifier subagent 独立验证（跨 20+ 文件，属强制档）；`requesting-code-review` 整分支审查；在分支上跑 `make ci` 与 `pytest -m slow`（约 20-40 分钟——四个 e2e 文件与 Redis 时间语义变体默认被 `-m 'not slow'` 排除，不显式跑等于没跑）。

**Gitea Wiki 文档站同步**（计划原本漏了,Task 9 实施时发现）：`research-wiki/docs-convention.md` §2 明写"新公共 API / 新能力 → 对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG"、"发版(任何版本号) → `Home.md` 版本号与安装命令",且该文件第 26 行是一道门——**版本 bump 的提交不允许单独存在**。本版有 6 个新包根导出、1 个新公共字段、1 个端口签名变更,wiki 必须同步。wiki 是**独立 git 仓库**(需 clone),故拆成两半：**内容在第一段写好待推**，`git push` 归第二段(外发动作)。

**人类确认门**：以上全绿后停下，把验证结果交给人类，**取得明确同意后**才执行第二段。

**第二段：外发且难以撤销的动作，一律等确认**——合并 main（`--no-ff`）+ push → 打 tag 并 push → 构建 → 上传 registry → `pip download` 验证并解包确认新代码在内 → 建 Release + 挂仓库 + 核对包页面 → 关闭 issue #16 / #17 并附修复说明（诊断纠正 + 三层根因 + 落地形态）。顺序按 CLAUDE.md §4.4.1**不得跳步**：包上传与 tag 一旦推出去就收不回，registry 里的版本号也不能复用。

合并到 main 后须在 main 上**重跑** `make lint` 与全套件外加 `pytest -m slow`——分支上跑过不算，合并本身可能引入差异。

### 验证

```bash
conda run -n PolyGateway make ci
conda run -n PolyGateway pytest -m slow
python -c "import tomllib,pathlib,re
v=tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']
i=re.search(r'__version__ = \"(.+?)\"', pathlib.Path('src/polygateway/__init__.py').read_text()).group(1)
assert v == i == '1.3.1', (v, i); print('版本号一致:', v)"
```

预期：`make ci` 绿；slow 全绿；版本号一致性检查通过。

- [ ] Task 10 提交：`chore: cut 1.3.1`

---

## 任务依赖

Task 1 → 2 → 3 是硬序（枚举 → 模块就位 → 字段贯通）。Task 4、5、6 都依赖 3，彼此独立可并行。Task 7 依赖 3（需要字段）。Task 8 依赖 2（能力表已搬）。Task 9 依赖 6（字段数实测值）。Task 10 最后。

## 全局纪律

不做计划外的重构与抽象——尤其**不重构遥测组装路径**：`TelemetryEmitter._record` 已经是铁律要求的单一出口，三个 `emit_*` 是三个语义不同的入口，各自组装参数是职责所在（设计 §12）。

每个任务独立提交，提交前跑该任务的验证命令。任何一步的完成声明必须对应本会话内的工具输出。
