Files
PolyGateway/research-wiki/designs/2026-08-25-thinking-observability-design.md
T
iomgaa e03b2afd8c docs: record the human call to ship this as 1.3.1
The design argued for 1.4.0 because a broken deep-path import hidden
behind a patch bump is a debt handed to downstream. The call is 1.3.1.
Since the version number no longer carries the warning, the CHANGELOG
has to: breaking items and their fixes go first in the entry, following
the 1.3.0 read-this-first form.
2026-08-25 22:08:01 -04:00

17 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/#17 判定"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"    # 无任何信号,判不出来

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

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

映射到实测:

场景 observation 是否诚实
M3 开启,流式 OBSERVED 有 185 字符正文
M3 开启,非流式 UNKNOWN 确实观测不到(正文与 ctd 双缺)
M3 关闭 UNKNOWN 判不出,但 §5 的对账仍能抓住"该关没关"
qwen 开启 OBSERVED 两个信号都在

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 warning:能力表声称可关闭,实测推理了 → 附 model 与 evidence,指路 register_capability
enable_thinking=True ABSENT warning:注入了开启参数,上游明确上报未推理
None(不干预) 任意 不表态——调用方没提要求,无从谈"违背"
任意 UNKNOWN 不表态——不能证伪

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

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

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

6. 落点清单

文件 变更
thinking.py新建 承载推理这件事的全部决策,见 §7
providers.py 收缩为纯注册表:ProviderProfileDEFAULT_PROFILESget_provider/register_provider
types.py LLMResponsethinking_observation: ThinkingObservation = UNKNOWN(只增不删,迁移兼容);TransportResult 同增(带默认值,与 cached_prompt_tokens 等既有先例同形态)
transports/openai_compat.py 组装 TransportResult 时调 observe_thinking;对账告警落此处(唯一同时握有请求方向与响应结果的地方)
middleware/retry.py 透传新字段
middleware/telemetry.py _AttemptUsage 与三个 emit_* 各加一行
telemetry/schema.py 新列 thinking_observation TEXT,两端 DDL + 两份 backfill + COLUMNSINSERT 字段 24→25,物理列 25→26
client.py import 路径改指 thinking.py
__init__.py 新增包根导出,见 §7
tests/e2e/test_thinking_live.py 判据重建,见 §8

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(请求侧注入)、ThinkingUnsupportedErrorThinkingObservationobserve_thinking(响应侧裁定)、对账告警

符合 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 四条分支;对账三种组合(False×OBSERVED、True×ABSENT、UNKNOWN 不表态);节流只喊一次;LLMResponse/TransportResult 默认值为 UNKNOWN;schema 列数与列序断言(既有测试自动抓)
集成 SQLite/PG 新列 backfill 与回读(既有测试模式)
e2e §8 判据重建,合并前 pytest -m slow 真跑并存档报告

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

12. 明确不做

不收敛遥测的四处参数列表复制。 CLAUDE.md 铁律点名"遥测调用点收敛为单一 helper,禁止复制参数列表",而 middleware/telemetry.py 现在正是 _AttemptUsage + 三个 emit_* 各持一份,本次加字段会让它变成四处各加一行。这是既有债务,不是本次引入;反 gold-plating 明文禁止任务外重构,且 1.3.0 刚动过遥测路径,同版再改组装逻辑是叠加风险。另立 issue 单独处理

不改 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/
  • make lint(含 import-linter 契约)与全套件绿
  • CHANGELOG 显式列出深路径 import 断裂与改法、M3 非流式付费不可见推理这一事实