Files
PolyGateway/research-wiki/designs/2026-08-25-thinking-observability-design.md
T
iomgaa 37b4a557c2 docs: disprove the issue #16/#17 diagnosis with live gateway probes
The four red e2e cases were blamed on MiniMax-M3 no longer reasoning.
Raw gateway probes show the opposite: M3 reasons fine (124 chars of
reasoning_content, prompt 194 to 216, completion 3 to 60). What changed
is that the MiniMax route stopped returning completion_tokens_details,
while qwen and deepseek still do on the same gateway and key. The
library already holds 185 chars of proof in LLMResponse.thinking and
never feeds it into any verdict.

The design turns that verdict into a first-class return value judged
from multiple signals, says UNKNOWN when a single response cannot tell,
and reconciles it against the capability table so a stale declaration
becomes a warning instead of a silent illusion.
2026-08-25 22:02:03 -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 而非 1.3.1:把"深路径 import 断裂"藏在 patch 版号里,本身就是一笔留给下游的技术债——下游看 1.3.0→1.3.1 不会去读 CHANGELOG。版号是给人读的第一份变更说明。

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 非流式付费不可见推理这一事实