Files
PolyGateway/research-wiki/designs/2026-08-25-thinking-observability-design.md
T
iomgaa 5cf225481c docs: fix the four blockers Codex found in the design
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.
2026-08-25 22:16:41 -04:00

22 KiB
Raw Blame History

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/#17Gitea iomgaa/PolyGateway,原文经 tea issues 16 / 17 读取;本仓库 remote 非 GitHubgh 读不到)判定"MiniMax-M3 开启推理静默失效,模型不推理"。实测推翻了这个诊断M3 的推理完全正常——流式路径下 reasoning_content 有 124 字符完整推理过程,prompt_tokens 194→216、completion_tokens 3→60,三个独立信号一致。

真正发生的是:MiniMax 这一路上游不再返回 usage.completion_tokens_detailsqwen 与 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 任何具体实现(P7import-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 时只判 truthyopenai_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: boolobservable_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);LLMResponsethinking_observation: ThinkingObservation = UNKNOWN(只增不删,迁移兼容);TransportResult 同增
thinking.py新建 推理这件事的全部决策,见 §7
providers.py 收缩为纯注册表:ProviderProfileDEFAULT_PROFILESget_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 _rehydrateLLMResponse(**fields)JSON 复活的是裸字符串而非枚举实例:须显式转 ThinkingObservation(...)。非法值(旧缓存/污染)转换失败由既有 try/except 吞成"按未命中回源",降级方向正确
telemetry/schema.py 新列 thinking_observation TEXT,两端 DDL + 两份 backfill + COLUMNSINSERT 字段 24→25,物理列 25→26
telemetry/sqlite.pytelemetry/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 是什么 ProviderProfileDEFAULT_PROFILESget_providerregister_provider
thinking.py 推理这件事的全部决策 ThinkingCapabilityDEFAULT_CAPABILITIESget_capabilityregister_capabilityresolve_thinking(请求侧注入)、ThinkingUnsupportedErrorobserve_thinking(响应侧裁定)、对账告警。不含 ThinkingObservation 定义——纯值类型归 types.py(§4.1

符合 P7"决策逻辑与状态存储分离":注册表存声明,thinking.py 做决策。未来任何推理相关能力都有唯一归属,不必再挑"放哪个文件"。

同时把公共符号提升到包根导出ThinkingCapabilityThinkingObservationregister_capabilityget_capabilityresolve_thinkingThinkingUnsupportedError__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_disable2026-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_observationThinkingObservation 实例而非裸字符串
  • make lint(含 import-linter 契约,须确认 types.py 未 import thinking.py)与全套件绿
  • README 的遥测字段数经 inspect.signature 实测更新为 25ARCHITECTURE §8 模块结构含 thinking.pyschemas/llm-calls.md 由过期的"22 字段"订正为 25;本 design 与 finding 进 research-wiki/index.md
  • CHANGELOG 本版条目最前列出深路径 import 断裂与改法、端口签名变更、M3 非流式付费不可见推理这一事实(§13)