The enum belonged in types.py all along: making LLMResponse field-typed on a symbol defined in thinking.py would have had the innermost layer import a decision module, and import-linter would have caught it only after the code was written. The 4.1 table claimed reconciliation could still catch a failed disable while section 5 said UNKNOWN never speaks. UNKNOWN has no falsifying power; the guarantee only covers observable paths, and the doc now says so instead of pretending otherwise. Section 12 was written against a misreading: _record already is the single helper the ironclad rule asks for, so there was no debt to decline. Landing sites had missed ports.py, whose record_llm_call freezes 24 explicit params with no defaults, and cache.py, where _rehydrate revives the enum as a bare string.
22 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| design | design:2026-08-25-thinking-observability-design | 推理可观测性一等化(issue #16 + #17) | 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 三态(新增,响应侧)
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_call25 参,两个 recorder 与全部测试替身同步,签名冻结测试绿 - 缓存回放的
thinking_observation是ThinkingObservation实例而非裸字符串 make lint(含 import-linter 契约,须确认types.py未 importthinking.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)