Files
PolyGateway/research-wiki/designs/2026-08-25-thinking-observability-design.md
T
iomgaa 6e205e9382 docs: retire the criterion this version disproved, everywhere it survived
The reasoning_tokens docstring was still teaching downstream to treat
None or 0 as no reasoning. The changelog and the schema page had both
been corrected; the docstring had not, and it is the copy that ships in
the wheel and shows up on hover. Someone writing a report from it would
have counted every real MiniMax reasoning call as not reasoning, which
is issue #16 all over again with the tests green.

The original wording stays, since reading pre-1.3.1 rows still needs
it. What follows it now says when it expired and what to read instead.

Two more places had drifted the same way: the changelog and the
architecture doc described the throttle and the cache fallback as they
were before this review, which is to say as the opposite of what the
code now does.

The claim that the two throttle sets would suppress each other does not
survive checking, as the mutation testing showed: their key spaces do
not overlap. Keeping them apart is still right, but for the honest
reason, which is that the two warnings have unrelated lifetimes.
2026-08-26 02:40:22 -04:00

23 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[(source, model, direction)],同一组合只喊一次,与既有 _warned_models 同款形态与同款理由(逐次调用刷屏会把告警变成噪声,噪声等于没有告警)。键含源名是因为多源多账号是本库的核心场景:同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警文案定位不到该查哪个网关(源名在调用点拼进文案,不进 reconcile_thinking 的签名——那是纯判定函数,源名是定位信息而非判据)。两个 set 分开维护的理由是语义不同(一个记"未登记能力已告警过",一个记"某源某方向的矛盾已告警过"),共用会让两种告警的生命周期纠缠在一起;不是键会碰撞——两者键空间本就不相交。

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

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(...)。域外取值(多版本共用同一 Redis 时,更新版本写入的新态)降级为 UNKNOWN 并单独告警,内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存响应;"整条作废"只留给真正破坏内容完整性的失败(JSON 坏了、结构化重建不过)
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(回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中、内容坏了才回源)、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 参);缓存回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中;遥测归一化对裸 str 与域外值都不丢整行;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)