Files
PolyGateway/research-wiki/plans/2026-08-25-thinking-observability-plan.md
T
iomgaa 758229bda9 docs: fold what implementation found back into the plan
The README carries a hand-written production DDL template that no test
ever compares against COLUMNS, so it can fall a column behind and stay
green. Downstream deploying from it would get a table without the new
column and the library would silently trim it — the same silence this
issue exists to remove. Task 9 now fixes the template and adds the
same-source assertion.

Also records two things the implementation disproved: caplog cannot see
loguru output, and reconcile_thinking has to be defined after the
dataclass it annotates, since this module evaluates annotations eagerly.
And the column-count table was incomplete — six more spots go red.
2026-08-26 00:32:41 -04:00

31 KiB
Raw Blame History

type, node_id, title, date
type node_id title date
plan plan:2026-08-25-thinking-observability-plan 推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1) 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 新增 ThinkingObservationLLMResponse / 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.pypostgres.py 实现新参
src/polygateway/client.py import 路径
src/polygateway/__init__.py 包根导出 + 版本号
pyproject.toml import-linter 契约加层 + 版本号
测试 9 个、文档 5 个 见各任务

Task 1ThinkingObservation 与裁定纯函数

文件:创建 src/polygateway/thinking.pytests/unit/test_thinking.py;修改 src/polygateway/types.pypyproject.toml

行为

types.py 新增(放在 LLMResponse 定义之前,因为它是其字段类型):

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

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

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

在新建的 thinking.py 实现(本任务只放这一个函数,搬迁留给 Task 2):

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 之间:

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.pyProviderProfile(故在其上),被 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 → OBSERVEDreasoning_tokens=0 → ABSENTreasoning_tokens=None 且正文空 → UNKNOWN。再加一条优先级用例:正文非空且 reasoning_tokens=0 → OBSERVED(正文压倒转述)。

tests/unit/test_types.py 加一条:ThinkingObservation 定义在 polygateway.types 模块内(ThinkingObservation.__module__ == "polygateway.types"),防止后续任务把它挪回决策层。

验证

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

预期:新测试全 PASSlint-imports 全部契约 KEPT。

  • Task 1 提交:feat: judge whether reasoning actually happened from multiple signals

Task 2:把推理决策从 providers.py 搬进 thinking.py

文件:修改 src/polygateway/thinking.pysrc/polygateway/providers.pysrc/polygateway/client.pysrc/polygateway/transports/openai_compat.pysrc/polygateway/__init__.pytests/unit/test_providers.pytests/unit/test_package.py

行为

providers.py 原样移入 thinking.py(纯移动,不改逻辑):ThinkingUnsupportedErrorThinkingCapabilityDEFAULT_CAPABILITIESget_capabilityregister_capabilityresolve_thinking_warn_unregistered

providers.py 保留:ProviderProfileDEFAULT_PROFILESget_providerregister_provider。其模块 docstring 改为只讲注册表职责;thinking.py 的模块 docstring 说明它承载推理的全部决策而枚举归 types.py

更新 importclient.pyfrom polygateway.providers import get_capability, get_provider, resolve_thinking 拆成两行)、transports/openai_compat.pyclient.pyTYPE_CHECKING 块里 ThinkingCapability 的来源。

__init__.py 新增包根导出并加进 __all__(该列表不是严格字母序——DEFAULT_PROFILES 现在就排在 AllSourcesExhausted 前面;沿用文件既有排列,把新符号插到同类符号附近即可):ThinkingCapabilityThinkingObservationThinkingUnsupportedErrorget_capabilityregister_capabilityresolve_thinking

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

测试要求(先失败后通过)

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

搬迁本身的回归证据:搬迁前后 pytest tests/unit -q 通过数不减(搬迁是纯移动,任何行为差异都是 bug)。

验证

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;契约 KEPTimport 成功。

  • Task 2 提交:refactor: give reasoning decisions their own module

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

文件:修改 src/polygateway/types.pysrc/polygateway/transports/openai_compat.pysrc/polygateway/middleware/retry.py;测试 tests/unit/test_types.pytests/unit/test_openai_compat.pytests/unit/test_retry.py

行为

TransportResultLLMResponse 各新增字段,必须加在各自字段列表末尾且带默认值LLMResponse 是被三项目消费的公共类型,只增不删且不得改变既有位置参数顺序):

    thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN

LLMResponse 侧补 docstringUNKNOWN = 本次无信号判不出,不是"没推理";非流式路径下部分模型推理已计费却不回传正文(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_response372-393 行)透传 thinking_observation=result.thinking_observation

测试要求(先失败后通过)

tests/unit/test_types.py:两个类型的默认值均为 ThinkingObservation.UNKNOWNLLMResponse 既有位置构造方式不破(沿用文件内既有的构造用例形态)。

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

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

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

验证

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.pysrc/polygateway/transports/openai_compat.py;测试 tests/unit/test_thinking.pytests/unit/test_openai_compat.py

行为

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

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.evidenceregister_capability 指路
False OBSERVED None 关闭请求未被满足,且该模型能力未登记。指路实测后 register_capability
True ABSENT 任意 注入了开启参数,上游明确上报未推理
True UNKNOWN 任意 推理参数已注入但本路径观测不到,无法确认是否生效;若为非流式路径,推理内容可能已计费却不回传
其余组合(含 False×UNKNOWN、None×任意) None

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

transports/openai_compat.py 在组装完 TransportResult 后调用它,非 Nonelogger.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

验证

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

行为

_rehydrateLLMResponse(**fields)JSON 里的 "observed" 会复活成str 而非枚举实例,类型与注解分叉。在 fields.update(...) 之前显式转换:

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

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

_serialize 无需改动:StrEnumstr 子类,dataclasses.asdict + json.dumps 直接可序列化。

测试要求(先失败后通过)

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

验证

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.pysrc/polygateway/telemetry/schema.pysrc/polygateway/telemetry/sqlite.pysrc/polygateway/telemetry/postgres.pysrc/polygateway/middleware/telemetry.py;测试 tests/unit/test_ports.pytests/unit/test_telemetry.pytests/integration/test_postgres_telemetry.py

行为

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

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

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

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

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

recorder 收到的必须是裸 str,不是枚举实例_AttemptUsage.thinking_observation 内部用 ThinkingObservation 类型,但 _record 下沉给 recorder 时取 .valueStrEnum 虽是 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) == 2425
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]TestBackendColumnParityCOLUMNS[-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 用例覆盖旧表补列后新列存在且可写读。

验证

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 7e2e 判据重建

文件:修改 tests/e2e/test_thinking_live.py

行为

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

判据函数改写:

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 之前无法表达(字段不存在),是纯新增覆盖。

验证

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_CAPABILITIESMiniMax-M3can_disable 保持 True2026-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 取值)须保持绿,作为回归证据。

验证

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.mdresearch-wiki/ARCHITECTURE.mdresearch-wiki/schemas/llm-calls.mdresearch-wiki/index.mdCHANGELOG.md

行为

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

README.md<!-- pg-template:table --> 生产部署 DDL 模板(Task 6 实施时发现,计划原本完全没覆盖):那段 SQL 是独立于 schema.py 手写的另一份,而 TestProductionTemplate 只跑它建表与权限/RLS/分区行为,从不拿它与 COLUMNS 比对——少一列照样全绿。照该模板部署的下游会得到一张没有 thinking_observation 的表,manual 档下库会静默裁掉这一列,正是本 issue 要消灭的那种静默。补上该列,并加一条同源断言(模板里的列集合必须等于 COLUMNS)把这个缺口机械化堵住——它不是顺手清理,是本次改动直接暴露的风险,且没有断言就一定会再次漂移。

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 注册

.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/

验证

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.tomlsrc/polygateway/__init__.py

行为

版本号两处改 1.3.1pyproject.toml__init__.py.__version__ 必须一致);CHANGELOG.md 的"未发布"定版为 ## 1.3.1(2026-08-25)

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

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

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

第二段:外发且难以撤销的动作,一律等确认——合并 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——分支上跑过不算,合并本身可能引入差异。

验证

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)。

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