From 6e205e938201d45e65dce9d3a27042a58020c801 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 26 Aug 2026 02:40:22 -0400 Subject: [PATCH] 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. --- CHANGELOG.md | 6 +++--- research-wiki/ARCHITECTURE.md | 4 ++-- research-wiki/ROADMAP.md | 2 +- .../2026-08-25-thinking-observability-design.md | 8 ++++---- src/polygateway/types.py | 7 ++++++- tests/integration/test_postgres_telemetry.py | 10 ++++++---- 6 files changed, 22 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a336297..de00d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 | 开启 | `absent` | 已注入开启参数,上游却明确上报未推理 | | 开启 | `unknown` | 已注入开启参数,但本路径观测不到;若为非流式,推理内容可能已计费却不回传 | -`关闭 × unknown` 与「调用方没提要求」两类**有意不表态**: 前者没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。同一 `(模型, 方向)` 只喊一次。 +`关闭 × unknown` 与「调用方没提要求」两类**有意不表态**: 前者没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。同一 `(源, 模型, 方向)` 只喊一次,文案点名出问题的源——多源多账号下同一模型跨 N 个源是常态,键漏掉源名会让第一个出问题的源喊完之后其余源永久静音,而告警也定位不到该查哪个网关。 **保障的覆盖面必须说清楚**: 对账只在可观测路径上成立(推理若真的发生,流式路径会带出正文,翻成 `observed` 触发告警);M3 非流式那种两个信号双缺的路径,没有任何保障——本版让它可见,但不能让它可判。 @@ -80,10 +80,10 @@ issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了 ### 其他 -- 缓存回放的 `thinking_observation` 是 `ThinkingObservation` 枚举实例而非裸字符串: JSON 复活出来的是 `str`,与字段注解分叉,`CacheMW._rehydrate` 现在显式转换。旧版本写入的缓存值若不在三态值域内,转换失败由既有降级路径吞成「按未命中回源」——方向正确,不会把坏缓存当好数据用。 +- 缓存回放的 `thinking_observation` 是 `ThinkingObservation` 枚举实例而非裸字符串: JSON 复活出来的是 `str`,与字段注解分叉,`CacheMW._rehydrate` 现在显式转换。取值不在本版三态值域内时(多个项目共用同一 Redis、先升级的那个写入了新态)**降级为 `unknown` 并单独告警,响应内容照常复活**——一个纯可观测性字段不该有能力作废内容完好的缓存,否则未升级的项目会在这些 key 上每次真打网关、随后覆写回旧值,两个版本互相打对方的缓存;「整条作废」只留给真正破坏内容完整性的失败。 - M3 的推理能力 `evidence` 刷新到 2026-08-25 复测。`can_disable` **仍为 `True`**(`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立),同时补记两条限制: 推理信号在非流式路径不可观测;`enable_thinking` 与 `thinking={"type":"enabled"}` 对该模型无效,只有 `reasoning_effort` 是真开关。 - `TransportResult` 同步新增该字段并由 `RetryMW` 透传;裁定在 `openai_compat` 的流式与非流式**两条**组装路径各做一次。 -- 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。 +- 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。 ## 1.3.0(2026-08-24) diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index 7fbab35..1ac83da 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -393,9 +393,9 @@ flowchart TB 三态**不可折叠为布尔**: `unknown`(判不出)与 `absent`(确证没有)语义不同,把前者读作后者正是 `reasoning_tokens=None` 制造的那个歧义——MiniMax-M3 非流式开启推理时,推理内容已计费却不回传正文(2026-08-25 实测 completion 53 vs 关闭档 3),该档只能判 `unknown`,宣称「没推理」即撒谎。缺省取 `UNKNOWN` 使任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。 -判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),非法值转换失败由既有 try/except 吞成「按未命中回源」。该字段**不进缓存 key**——它是结果不是请求。 +判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。 -**声明 × 观测对账(同批)**: `reconcile_thinking` 把请求方向(`enable_thinking`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。`False × unknown` 与 `None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(model, direction)` 集合,与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同,混用会互相压制)。 +**声明 × 观测对账(同批)**: `reconcile_thinking` 把请求方向(`enable_thinking`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。`False × unknown` 与 `None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(source, model, direction)` 集合,与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同——一个记「未登记能力已告警过」,一个记「某源某方向的矛盾已告警过」,共用会让两种告警的生命周期纠缠;键空间本就不相交,故不是碰撞问题)。键含源名是因为多源多账号是本库的核心场景: 同一 model 跨 N 个源常态,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警定位不到该查哪个网关(源名在调用点拼进文案,不进纯判定函数的签名)。 这条对账的价值在于把「能力表过期」从**静默错觉**变成日志里的显式告警——能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),成本是一次枚举比较。但**保障只覆盖可观测路径**: M3 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。 diff --git a/research-wiki/ROADMAP.md b/research-wiki/ROADMAP.md index eb723ee..e80cc2b 100644 --- a/research-wiki/ROADMAP.md +++ b/research-wiki/ROADMAP.md @@ -28,7 +28,7 @@ |---|---|---| | 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 | | 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 | -| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(thinking 注入/思考流字段声明) | +| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(思考流字段声明;thinking 注入的**决策**已于 issue #16/#17 搬到 `thinking.py`,这里只留形态声明) | | 3 | `transports/openai_compat.py`(SSE 解析、非流式快路径、错误翻译 §6.2) | 依赖 1/2a/2b;错误翻译是中间件的语义地基 | | 4a | `middleware/retry.py`(D13 自研,单层原则)+ `sources.py`(SourceConfig、round_robin/least_inflight 选源、源冷却备忘) | 依赖错误分类;先于限流接入便于独立测试。**多源完整行为(换源/冷却/多源行为测试)2026-07-20 人类拍板自 M2 提前进 M1**——重试循环每次尝试都要选源,签名与行为一并钉死 | | 4b | `backends/memory/`(limiter + breaker)+ 对应中间件 | 语义契约(permit/settle、状态机)在内存版上钉死,契约测试同步交付 | diff --git a/research-wiki/designs/2026-08-25-thinking-observability-design.md b/research-wiki/designs/2026-08-25-thinking-observability-design.md index de19201..2c46127 100644 --- a/research-wiki/designs/2026-08-25-thinking-observability-design.md +++ b/research-wiki/designs/2026-08-25-thinking-observability-design.md @@ -105,7 +105,7 @@ class ThinkingObservation(StrEnum): **不抛错**,三条理由:一次观测不足以否决一次成功的调用;P5 的降级方向铁律只对限流/熔断要求"报错而非放行",可观测性属遥测方向,降级即 warning;矛盾结果已随 `LLMResponse` 与遥测落地,处置权归下游。 -**节流**:per transport 实例的 `set[(model, direction)]`,同一组合只喊一次,与既有 `_warned_models` 同款形态与同款理由(逐次调用刷屏会把告警变成噪声,噪声等于没有告警)。 +**节流**:per transport 实例的 `set[(source, model, direction)]`,同一组合只喊一次,与既有 `_warned_models` 同款形态与同款理由(逐次调用刷屏会把告警变成噪声,噪声等于没有告警)。键含**源名**是因为多源多账号是本库的核心场景:同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警文案定位不到该查哪个网关(源名在调用点拼进文案,不进 `reconcile_thinking` 的签名——那是纯判定函数,源名是定位信息而非判据)。两个 set 分开维护的理由是**语义不同**(一个记"未登记能力已告警过",一个记"某源某方向的矛盾已告警过"),共用会让两种告警的生命周期纠缠在一起;不是键会碰撞——两者键空间本就不相交。 这一条是本设计的灵魂:它把"能力表过期"从**静默错觉**变成**日志里的显式告警**,成本是一次枚举比较。 @@ -122,7 +122,7 @@ class ThinkingObservation(StrEnum): | `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 吞成"按未命中回源",降级方向正确 | +| **`middleware/cache.py`** | `_rehydrate` 走 `LLMResponse(**fields)`,JSON 复活的是**裸字符串**而非枚举实例:须显式转 `ThinkingObservation(...)`。域外取值(多版本共用同一 Redis 时,更新版本写入的新态)降级为 `UNKNOWN` 并单独告警,内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存响应;"整条作废"只留给真正破坏内容完整性的失败(JSON 坏了、结构化重建不过) | | `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` | @@ -130,7 +130,7 @@ class ThinkingObservation(StrEnum): **测试** -`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)。 +`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 步要求构建前改完) @@ -205,7 +205,7 @@ class ThinkingObservation(StrEnum): | 层 | 覆盖 | |---|---| -| 单元 | `observe_thinking` 四条分支 + 空白串不算 OBSERVED;对账四类告警(False×OBSERVED 已登记 / False×OBSERVED 未登记 / True×ABSENT / True×UNKNOWN)与两类不表态;节流只喊一次;`LLMResponse`/`TransportResult` 默认值为 UNKNOWN 且位置构造不破;端口签名冻结(25 参);缓存回放后仍是枚举实例、非法值按未命中;schema 列数与列序断言(既有测试自动抓);包根导出面 | +| 单元 | `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` 真跑并存档报告 | diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 47ed4e8..56d6af0 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -224,7 +224,12 @@ class LLMResponse: usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈 6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`, - 故下游判据须为 `in (None, 0)`,写 `== 0` 的条件永远不成立。""" + 故下游判据须为 `in (None, 0)`,写 `== 0` 的条件永远不成立。 + + **该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报 + `completion_tokens_details`(MiniMax 这一路实测已停),此时 `None` 只意味着 + 「没上报」而非「没推理」——同一次调用里库拿得到 185 字符推理正文。判「有没有 + 推理」一律改读 `thinking_observation`,上面那段只用于解读本版之前的历史数据。""" thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN """本次调用"推理是否真的发生"的三态裁定(issue #16/#17)。 diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index bd84f53..77a01ee 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -949,10 +949,10 @@ class TestManualSchemaModeAcceptance: async def test_manual_leaves_the_stale_table_untouched( self, pre_tenant_schema, captured_warnings ): - """22 字段旧表 + manual: 列一个不加,行照常落库,缺的两维度静默不写。 + """22 字段旧表 + manual: 列一个不加,行照常落库,缺的三维度静默不写。 与 `test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照: - 同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 25 之别。 + 同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 26 之别。 """ schema_dsn, schema = pre_tenant_schema recorder = _recorder(schema_dsn, auto_migrate=False) @@ -981,7 +981,8 @@ class TestManualSchemaModeAcceptance: assert [m for m in captured_warnings if "补列失败" in m] == [] notices = [m for m in captured_warnings if "auto_migrate=False" in m] assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏 - assert "以下维度不会被记录: tenant_id, meta" in notices[0] + # 逐字钉住三个维度: 前缀断言会让将来漏进告警的新列照样绿 + assert "以下维度不会被记录: tenant_id, meta, thinking_observation。" in notices[0] finally: await recorder.aclose() @@ -1007,7 +1008,8 @@ class TestManualSchemaModeAcceptance: assert recorder.telemetry_status.degraded is False notices = [m for m in captured_warnings if "auto_migrate=False" in m] assert len(notices) == 1 # 准备期一次,第二行不再重复 - assert "以下维度不会被记录: tenant_id, meta" in notices[0] + # 逐字钉住三个维度: 前缀断言会让将来漏进告警的新列照样绿 + assert "以下维度不会被记录: tenant_id, meta, thinking_observation。" in notices[0] # 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名 assert ( "ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0]