Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bff962e48 | |||
| 6e205e9382 | |||
| 1307a02b92 | |||
| c0b544d233 | |||
| 578a144231 | |||
| 1921a067a1 | |||
| bd95a05c30 | |||
| 758229bda9 | |||
| 56acb8f3ac | |||
| ab1c47ebcc | |||
| 20a4a9ae47 | |||
| 3e869b9b39 | |||
| 8c5c23ae72 | |||
| 59d2e442e6 | |||
| a2b319f250 | |||
| 7622eb0402 | |||
| e90bb3d6a4 | |||
| 85bcc23a6b | |||
| 626bbdcc83 | |||
| 5cf225481c | |||
| e03b2afd8c | |||
| 37b4a557c2 |
@@ -1,5 +1,91 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.1(2026-08-26)
|
||||
|
||||
「这次调用到底推理没推理」从此是库的**一等返回值**(issue #16 + #17): `LLMResponse.thinking_observation` 三态如实作答,判不出来时说 `unknown` 而不是伪装成「没推理」,并与推理能力表持续对账。
|
||||
|
||||
**版号是 patch,但本版含三处会影响下游的变更**——深路径 import 断裂、端口签名扩参、一条新告警。patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故三条置于最前。
|
||||
|
||||
### 请先读这一条(一): `polygateway.providers` 的深路径 import 断了
|
||||
|
||||
推理相关的**六个符号**从 `providers.py` 移进新模块 `polygateway.thinking`。`from polygateway.providers import ...` 引用其中任何一个,升级后当场 `ImportError`:
|
||||
|
||||
| 从 `providers` 断掉的符号 | 改成(**推荐**) | 或 |
|
||||
|---|---|---|
|
||||
| `ThinkingCapability`、`ThinkingUnsupportedError` | `from polygateway import ...` | `from polygateway.thinking import ...` |
|
||||
| `get_capability`、`register_capability`、`resolve_thinking` | `from polygateway import ...` | `from polygateway.thinking import ...` |
|
||||
| `DEFAULT_CAPABILITIES` | `from polygateway.thinking import DEFAULT_CAPABILITIES` | — |
|
||||
|
||||
**前五个请改用包根 import**: 它们此前只能深路径引用,而深路径引用正是模块重组会打断下游的原因——本版一并把它们提升到包根导出(连同本版新增的 `ThinkingObservation`,共六个新导出),给的就是一个此后不会因内部重组而变的引用点。`DEFAULT_CAPABILITIES` 有意不进包根: 它是可变注册表的当前快照,不是稳定 API 面。
|
||||
|
||||
`providers.py` 保留的 `ProviderProfile` / `DEFAULT_PROFILES` / `get_provider` / `register_provider` 逐字未动。
|
||||
|
||||
拆分本身不是顺手重构: 推理这件事从「请求侧注入什么参数」长成了「注入 + 响应侧裁定 + 两者对账」三件事,再留在 provider 注册表里,那个文件的职责就得用「和」来描述。
|
||||
|
||||
### 请先读这一条(二): `TelemetryRecorder.record_llm_call` 从 24 参变 25 参
|
||||
|
||||
新增 keyword-only 参数 `thinking_observation: str`,**且按该 Protocol 的既有纪律不设默认值**(库外没有第三方实现者,带默认值只会让 emitter 漏传时静默落一个默认值)。**自定义 recorder 实现必须同步补这个参数**,否则调用时 `TypeError`。库自带的 `SQLiteRecorder` / `PostgresRecorder` 已同步,不受影响。
|
||||
|
||||
`TelemetryRecorder` 之外的端口逐字未变;`TelemetryStatusProvider` 不受影响。
|
||||
|
||||
### 请先读这一条(三): MiniMax-M3 非流式开推理 = 付费买看不见的推理,库现在会说出来
|
||||
|
||||
2026-08-25 实测: M3 非流式开启推理时 `completion_tokens` 从 3 涨到 53(推理段确实产生并计费),而响应里既没有 `reasoning_content` 正文、也没有 `usage.completion_tokens_details`——**钱花了,东西一个字都拿不到**。这是上游行为,库修不了,但从本版起不再默不作声: 该档观测判为 `unknown`,并按 `(模型, 方向)` 发**一次** warning,说明「已注入开启参数,但本路径观测不到,推理内容可能已计费却不回传」。
|
||||
|
||||
要拿到推理正文,该模型请走**流式**路径(实测 185 字符正文完整)。
|
||||
|
||||
### 诊断纠正: 不是模型不推理,是 MiniMax 停报 `completion_tokens_details`
|
||||
|
||||
issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了这个诊断——绕开库用裸 `httpx` 抓真实响应,M3 流式开启档拿到 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
|
||||
|
||||
真正变的是 **MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**(qwen 与 deepseek 在同一网关、同一 key 上照常返回),`reasoning_tokens` 因此恒为 `None`。而库把「推理是否发生」全押在这一个字段上,于是**手里握着 185 字符推理正文,却对外报告「没推理」**。
|
||||
|
||||
缺口的形态是本版真正要修的东西: 库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`。
|
||||
|
||||
### 三态,以及它为什么不能折叠成布尔
|
||||
|
||||
`LLMResponse.thinking_observation`(类型 `ThinkingObservation`,`StrEnum`,缺省 `unknown`)由多信号裁定,判据按**证据硬度**排序:
|
||||
|
||||
| 值 | 判据 |
|
||||
|---|---|
|
||||
| `observed` | 推理正文 `thinking` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
|
||||
| `absent` | `reasoning_tokens == 0`——上游明确上报本次未推理,是正面证据 |
|
||||
| `unknown` | 两个信号双缺,判不出来 |
|
||||
|
||||
**`unknown` 与 `absent` 不是一回事**,把前者折叠进后者正是本次故障的病根。`unknown` 没有证伪力: 它不能用来声称推理关掉了,也不能用来报警「没推理」。缺省取 `unknown` 使任何填不了这个字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——默认值本身不撒谎。
|
||||
|
||||
对下游的口径变化: 统计「未推理」**不要再写 `reasoning_tokens IS NULL OR = 0`**,那个条件在供应商停报 usage 明细后会把推理了的调用一并算进去。改按 `thinking_observation` 分组,`unknown` 独立成一档。
|
||||
|
||||
### 声明 × 观测对账: 能力表过期从静默错觉变成日志里的告警
|
||||
|
||||
推理能力表(`can_disable`)是静态声明,而静态声明**必然过期**——M3 的 evidence 曾停在 8-02 整整 23 天。过期的表现是静默错觉: 库照常注入关闭参数,模型照常推理,下游拿到推理内容却以为关了,全程无人吭声。
|
||||
|
||||
本版在 transport 拿到结果处做一次比较,矛盾即 warning(**不抛错**——一次观测不足以否决一次成功的调用,矛盾结果已随响应与遥测落地,处置权归下游):
|
||||
|
||||
| 请求方向 | 观测 | 告警内容 |
|
||||
|---|---|---|
|
||||
| 关闭 | `observed` | 关闭请求未被满足。能力表已登记则点出 `evidence` 日期并指路复测更新;未登记则说明本次是按 provider 形态尽力注入 |
|
||||
| 开启 | `absent` | 已注入开启参数,上游却明确上报未推理 |
|
||||
| 开启 | `unknown` | 已注入开启参数,但本路径观测不到;若为非流式,推理内容可能已计费却不回传 |
|
||||
|
||||
`关闭 × unknown` 与「调用方没提要求」两类**有意不表态**: 前者没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。同一 `(源, 模型, 方向)` 只喊一次,文案点名出问题的源——多源多账号下同一模型跨 N 个源是常态,键漏掉源名会让第一个出问题的源喊完之后其余源永久静音,而告警也定位不到该查哪个网关。
|
||||
|
||||
**保障的覆盖面必须说清楚**: 对账只在可观测路径上成立(推理若真的发生,流式路径会带出正文,翻成 `observed` 触发告警);M3 非流式那种两个信号双缺的路径,没有任何保障——本版让它可见,但不能让它可判。
|
||||
|
||||
### 遥测新增一列 `thinking_observation`
|
||||
|
||||
`llm_calls` 加一列 `thinking_observation TEXT`(可空,取值 `observed` / `absent` / `unknown`),排在最末,SQLite 与 Postgres 两端 DDL 与补列语句同步。旧表按既有 backfill 路径补列: sqlite→auto 档自动补,postgres→manual 档点名缺列并给出可执行 SQL、同时按现有列裁剪 `INSERT` 继续写(不补列不会让遥测整体失效,只是少这一列)。补列失败仍只逐行降级、绝不判死。
|
||||
|
||||
照 README「生产部署 DDL 模板」部署的下游**不需要改模板**: 那份模板用 `LIKE llm_calls_seed` 从库自己建出的表派生列,与 `telemetry/schema.py` 同源,不存在手抄漂移(本版加了一条测试断言把这个同源性钉死)。
|
||||
|
||||
### 其他
|
||||
|
||||
- 缓存回放的 `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 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。
|
||||
|
||||
|
||||
## 1.3.0(2026-08-24)
|
||||
|
||||
遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
||||
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
|
||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
||||
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;请求方向与实测观测矛盾时按 `(模型, 方向)` 各告警一次(能力表过期、开启未生效、注入了却观测不到);裁定结果随遥测落库 |
|
||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 25 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
||||
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
|
||||
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
||||
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "polygateway"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
||||
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
||||
@@ -81,6 +81,7 @@ layers = [
|
||||
"polygateway.config",
|
||||
"polygateway.middleware",
|
||||
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
|
||||
"polygateway.thinking",
|
||||
"polygateway.providers : polygateway.sources",
|
||||
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
|
||||
]
|
||||
|
||||
@@ -219,6 +219,8 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
||||
|
||||
**决策**: 消灭 `"qwen" in provider`、`model.split("-")[0]` 式字符串猜测。显式 provider 注册表,每个 provider 声明:thinking 参数注入方式(deepseek `{"thinking":{"type":"enabled"}}` / qwen `{"enable_thinking": True}`)、思考流字段(`reasoning_content` / `<think>` 标签剥离)、原生 schema 能力(供 D7 策略选择)、默认错误翻译细则。新 provider = 注册一个条目,不改核心类。
|
||||
|
||||
**职责拆分(2026-08-25,issue #16/#17)**: 上面这条决策里的**推理**部分已从 `providers.py` 移出,落进新模块 `thinking.py`。起因是推理这件事从「请求侧注入什么参数」长成了「请求侧注入 + 响应侧裁定 + 两者对账」三件事,留在注册表里会让 `providers.py` 变成「推理的一切」,一句话说不清职责(P3)。拆后 `providers.py` 只回答**provider 是什么**(`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`/`register_provider`),`thinking.py` 承载**推理这件事的全部决策**(`ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`/`register_capability`、`resolve_thinking`、`observe_thinking`、`reconcile_thinking`、`ThinkingUnsupportedError`);纯值类型 `ThinkingObservation` 归最内层 `types.py`(§5.1)。六个公共符号同批提升到包根导出——此前只能深路径 import,而深路径引用正是模块重组会打断下游的原因。
|
||||
|
||||
### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
|
||||
|
||||
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
|
||||
@@ -370,7 +372,7 @@ flowchart TB
|
||||
| `cache_hit` | bool | 是否缓存命中 |
|
||||
| `call_id` | str | UUID,每次**尝试**独立 |
|
||||
|
||||
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens` 与 `model_reported`(2026-07-31,issue #3,见下)。
|
||||
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens` 与 `model_reported`(2026-07-31,issue #3,见下)、`thinking_observation`(2026-08-25,issue #16/#17,见下)。
|
||||
|
||||
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
|
||||
|
||||
@@ -381,6 +383,22 @@ flowchart TB
|
||||
|
||||
`cache_hit` 指的始终是 **PolyGateway 自身响应缓存**,与供应商 prompt cache 无关;两者语义不同但名字相近,docstring 已消歧(改名会破坏迁移兼容,故只注释)。
|
||||
|
||||
**推理观测三态 `thinking_observation`(2026-08-25,issue #16/#17)**: 类型 `ThinkingObservation`(`StrEnum`),缺省 `UNKNOWN`。回答的问题是「这次调用到底推理没推理」,由多信号裁定:
|
||||
|
||||
| 值 | 含义 | 判据(按证据硬度排序) |
|
||||
|---|---|---|
|
||||
| `observed` | 确证本次推理发生 | 推理正文 `thinking.strip()` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
|
||||
| `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) |
|
||||
| `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 |
|
||||
|
||||
三态**不可折叠为布尔**: `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`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。
|
||||
|
||||
**声明 × 观测对账(同批)**: `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 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。
|
||||
|
||||
**缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。
|
||||
|
||||
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
|
||||
@@ -533,7 +551,7 @@ flowchart TB
|
||||
|
||||
### 7.8 遥测与成本
|
||||
|
||||
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**。
|
||||
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**。
|
||||
|
||||
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
|
||||
|
||||
@@ -541,6 +559,10 @@ flowchart TB
|
||||
|
||||
**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。
|
||||
|
||||
**`thinking_observation` 列(2026-08-25,issue #16/#17,端口 24 → 25)**: 落 `LLMResponse.thinking_observation` 的裸取值(`observed` / `absent` / `unknown`,两端均为可空 `TEXT`),语义见 §5.1。它补的是 `reasoning_tokens` 补不上的那一格: 后者为 NULL 时「没推理」与「没上报」不可区分,而供应商停报 `completion_tokens_details` 是会真实发生的事(MiniMax 这一路 2026-08-25 实测已停报,qwen 与 deepseek 在同一网关同一 key 上照常返回),届时按 `reasoning_tokens IS NULL OR = 0` 统计「未推理」会把推理了的调用一并算进去。有了本列,口径改为按本列取值分组,`unknown` 独立成一档而不再被并进「未推理」。
|
||||
|
||||
**recorder 收到的必须是裸 `str` 而非枚举实例**: `TelemetryEmitter` 的 `_AttemptUsage` 内部持 `ThinkingObservation` 类型,`_record` 下沉时取 `.value`。`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只降级为一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一分工(recorder 只落库,不做语义判断)。列序纪律同上: 新列排在最末,两端 DDL 与两份 backfill 同步。
|
||||
|
||||
(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
|
||||
|
||||
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
|
||||
@@ -622,7 +644,8 @@ src/polygateway/
|
||||
├── config.py # GatewaySettings: 多源/韧性/装配键族聚合与装配守卫(M1 增补)
|
||||
├── middleware/ # retry.py / ratelimit.py / breaker.py / cache.py / telemetry.py / structured.py
|
||||
├── transports/ # openai_compat.py / openai_sdk.py / monkey_ocr.py
|
||||
├── providers.py # D11 provider 注册表
|
||||
├── providers.py # D11 provider 注册表(只回答 provider 是什么)
|
||||
├── thinking.py # 推理这件事的全部决策: 能力表 + 请求侧注入 + 响应侧裁定 + 对账
|
||||
├── sources.py # SourceConfig + 选源策略
|
||||
├── backends/ # memory/ 与 redis/(limiter、breaker、cache 状态实现)
|
||||
├── telemetry/ # sqlite.py / postgres.py / pricing.py
|
||||
@@ -630,7 +653,7 @@ src/polygateway/
|
||||
└── streaming.py # 三层活性看门狗(纯函数)
|
||||
```
|
||||
|
||||
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/`、`backends/`、`telemetry/`、`structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
|
||||
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/`、`backends/`、`telemetry/`、`structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。`thinking.py`(2026-08-25)夹在**实现层与 `providers` 之间**: 它 import `providers.py` 的 `ProviderProfile`(故在其上),被 `transports/` 与 `client.py` import(故在其下);契约里写作独立一层 `polygateway.thinking`,插在 `transports | backends | telemetry | structured` 与 `providers : sources` 中间。**枚举 `ThinkingObservation` 因此必须留在 `types.py`**——它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让最内层反向依赖决策层,契约当场判红。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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、状态机)在内存版上钉死,契约测试同步交付 |
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:2026-08-25-thinking-observability-design
|
||||
title: "推理可观测性一等化(issue #16 + #17)"
|
||||
date: 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(Gitea `iomgaa/PolyGateway`,原文经 `tea issues 16` / `17` 读取;本仓库 remote 非 GitHub,`gh` 读不到)判定"MiniMax-M3 开启推理静默失效,模型不推理"。**实测推翻了这个诊断**:M3 的推理完全正常——流式路径下 `reasoning_content` 有 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
|
||||
|
||||
真正发生的是:**MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**(qwen 与 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` 三态(新增,响应侧)
|
||||
|
||||
```python
|
||||
class ThinkingObservation(StrEnum):
|
||||
OBSERVED = "observed" # 确证推理发生
|
||||
ABSENT = "absent" # 确证未推理(正面证据)
|
||||
UNKNOWN = "unknown" # 无任何信号,判不出来
|
||||
```
|
||||
|
||||
**枚举定义在 `types.py`,裁定逻辑在 `thinking.py`——两者必须分开。** 它是 `LLMResponse`/`TransportResult` 的字段类型,而 `types.py` 是最内层、不得 import 任何具体实现(P7,import-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` 时只判 truthy(`openai_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: bool` 或 `observable_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);`LLMResponse` 增 `thinking_observation: ThinkingObservation = UNKNOWN`(只增不删,迁移兼容);`TransportResult` 同增 |
|
||||
| `thinking.py`(**新建**) | 推理这件事的全部**决策**,见 §7 |
|
||||
| `providers.py` | 收缩为纯注册表:`ProviderProfile`、`DEFAULT_PROFILES`、`get_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`** | `_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` |
|
||||
| `__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 是什么** | `ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`、`register_provider` |
|
||||
| `thinking.py` | **推理这件事的全部决策** | `ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`(请求侧注入)、`ThinkingUnsupportedError`、`observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义**——纯值类型归 `types.py`(§4.1) |
|
||||
|
||||
符合 P7"决策逻辑与状态存储分离":注册表存声明,`thinking.py` 做决策。未来任何推理相关能力都有唯一归属,不必再挑"放哪个文件"。
|
||||
|
||||
**同时把公共符号提升到包根导出**:`ThinkingCapability`、`ThinkingObservation`、`register_capability`、`get_capability`、`resolve_thinking`、`ThinkingUnsupportedError`。`__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_disable`**:2026-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_observation` 是 `ThinkingObservation` 实例而非裸字符串
|
||||
- `make lint`(含 import-linter 契约,须确认 `types.py` 未 import `thinking.py`)与全套件绿
|
||||
- README 的遥测字段数经 `inspect.signature` 实测更新为 25;ARCHITECTURE §8 模块结构含 `thinking.py`;`schemas/llm-calls.md` 由过期的"22 字段"订正为 25;本 design 与 finding 进 `research-wiki/index.md`
|
||||
- CHANGELOG 本版条目**最前**列出深路径 import 断裂与改法、端口签名变更、M3 非流式付费不可见推理这一事实(§13)
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: finding
|
||||
node_id: finding:2026-08-25-thinking-observability-regression
|
||||
title: "issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号"
|
||||
date: 2026-08-25
|
||||
---
|
||||
|
||||
# issue #16/#17 实测:M3 推理正常,失效的是推理的**可观测信号**
|
||||
|
||||
> 类型:finding|日期:2026-08-25|网关 `newapi.iomgaa.online`
|
||||
> 本文推翻 issue #16/#17 的原始诊断("模型不再推理"),是 `designs/2026-08-25-thinking-observability-design.md` 的事实基础。
|
||||
|
||||
## 1. 为什么要重测
|
||||
|
||||
issue #16/#17 判定 MiniMax-M3 的开启推理"静默失效:模型没有推理",依据是 `tests/e2e/test_thinking_live.py` 的 L2/L3b/L4/L5 四条全红,四条的共同判据是 `reasoning_tokens > 0`。issue 自己留了一个未区分的岔路:网关侧模型行为变了,还是库的注入失效了。区分方法写得很清楚——抓一次真实请求体与原始响应。本文就是那次抓取。
|
||||
|
||||
## 2. 方法
|
||||
|
||||
两层探针,都不走 slow 套件:
|
||||
|
||||
其一**绕开库**,用裸 `httpx` 直接 POST `/chat/completions`,矩阵化七种参数形态 × 流式/非流式,记录完整 `usage` 与 `message` 的键集合。绕开库是必要的——要证的命题之一正是"库有没有把参数弄丢",用库测这一条是循环论证。
|
||||
|
||||
其二**用库本身**跑 `GatewayClient.chat`,记录 `LLMResponse` 的 `reasoning_tokens` 与 `thinking` 两个字段。两层对照才能定位缺口落在哪一层。
|
||||
|
||||
对照组取 `qwen3.7-plus` 与 `deepseek-v4-pro`——同一网关、同一 key,用来区分"MiniMax 这一路变了"与"网关全局变了"。
|
||||
|
||||
## 3. 原始观测
|
||||
|
||||
### 3.1 MiniMax-M3,裸 httpx,非流式
|
||||
|
||||
| 变体 | prompt | completion | `completion_tokens_details` | `reasoning_content` |
|
||||
|---|---|---|---|---|
|
||||
| 不注入(基线) | 194 | 3 | **整个容器缺失** | 无 |
|
||||
| `reasoning_effort=medium` | **216** | **48** | 整个容器缺失 | 无 |
|
||||
| `reasoning_effort=high` | **216** | **65** | 整个容器缺失 | 无 |
|
||||
| `reasoning_effort=none` | 194 | 3 | 整个容器缺失 | 无 |
|
||||
| `thinking={"type":"enabled"}` | 194 | 3 | 整个容器缺失 | 无 |
|
||||
| `enable_thinking=true` | 194 | 3 | 整个容器缺失 | 无 |
|
||||
| 非法值 `definitely-not-a-real-level` | 207 | 87 | 整个容器缺失 | 无 |
|
||||
|
||||
### 3.2 MiniMax-M3,裸 httpx,流式
|
||||
|
||||
| 变体 | delta 的键集合 | `reasoning_content` 累计 | usage |
|
||||
|---|---|---|---|
|
||||
| 不注入 | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
|
||||
| `reasoning_effort=medium` | `content`,**`reasoning_content`**,`role` | **124 字符,完整推理过程** | prompt 216 / completion 60,无 ctd |
|
||||
| `reasoning_effort=none` | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
|
||||
|
||||
流式 medium 档抓到的推理正文(前 120 字符):`We need answer Chinese, only two digits. Chickens x rabbits y. x+y=35,2x+4y=94 => x+y*? 2*35+2y=94 y=12, x=23. Output 23`
|
||||
|
||||
### 3.3 对照组(流式)
|
||||
|
||||
| 模型 | 变体 | `reasoning_content` | `completion_tokens_details.reasoning_tokens` |
|
||||
|---|---|---|---|
|
||||
| deepseek-v4-pro | 不注入 | 135 字符 | **88** |
|
||||
| deepseek-v4-pro | `effort=medium` | 134 字符 | **89** |
|
||||
| deepseek-v4-pro | `effort=none` | 0 | 容器缺失 |
|
||||
| qwen3.7-plus | 不注入 | 350 字符 | **158** |
|
||||
| qwen3.7-plus | `effort=medium` | 606 字符 | **229** |
|
||||
| qwen3.7-plus | `effort=none` | 0 | 容器缺失 |
|
||||
| qwen3.7-plus | 非法值 | — | **HTTP 400** |
|
||||
|
||||
### 3.4 用库跑(`LLMResponse` 字段)
|
||||
|
||||
| 场景 | `reasoning_tokens` | `thinking` 字符数 | completion |
|
||||
|---|---|---|---|
|
||||
| M3 开启,流式 | None | **185** | 69 |
|
||||
| M3 开启,非流式 | None | **0** | 53 |
|
||||
| M3 关闭,流式/非流式 | None | 0 | 3 |
|
||||
| M3 不干预 | None | 0 | 3 |
|
||||
| qwen 开启,流式 | **205** | 484 | 213 |
|
||||
| qwen 关闭,流式 | None | 0 | 5 |
|
||||
|
||||
## 4. 五条结论
|
||||
|
||||
**① M3 的推理完全正常,issue 的诊断是错的。** 流式 medium 档抓到 124 字符完整推理过程;`prompt_tokens` 194→216(供应商注入推理指令)、`completion_tokens` 3→60(推理段被计费)。三个独立信号一致。
|
||||
|
||||
**② 真正变的是 MiniMax 这一路不再返回 `usage.completion_tokens_details`。** 而 qwen 与 deepseek 在同一网关同一 key 上照常返回。所以这不是网关全局改了 usage 处理,是 MiniMax 这一路上游的 usage 形态变了。`reasoning_tokens` 恒 NULL 由此而来。
|
||||
|
||||
**③ 库自己已经握有决定性证据,却没有用。** `LLMResponse.thinking` 在 M3 开启档流式路径下是 185 字符的实打实推理正文。e2e 的 `_reasoning_on` 只看 `reasoning_tokens` 与 `completion_tokens` 长度,从不看 `thinking`——四条红是判据的盲区,不是功能的失效。
|
||||
|
||||
**④ M3 非流式路径下推理内容整体丢失,且下游在付费。** `completion_tokens` 53 vs 关闭档 3,说明推理段确实产生并计费;而 `message` 的键集合只有 `content`/`role`,`reasoning_content` 不存在。下游用非流式调 M3 开推理 = 付钱买看不见的东西,且当前库不告诉它。这不是库能修的(上游不返回),但库必须让它可见。
|
||||
|
||||
**⑤ 三家供应商在"未推理"时都是整个 `completion_tokens_details` 缺失,无人上报 `0`。** 与 2026-08-02 findings §4c 的记录一致。推论:**"容器在不在"不能当作"有没有推理"的判据**——它与真实信号高度混淆,拿它做裁定等于把噪声当信号。
|
||||
|
||||
## 5. 顺带纠正的两处既有认识
|
||||
|
||||
**`enable_thinking` / `thinking:{type:enabled}` 对 M3 无效这一条仍然成立**(prompt 恒 194 = 基线),只有 `reasoning_effort` 是真开关。`providers.py` 的 minimax profile 用的正是 `reasoning_effort`,选型至今正确。
|
||||
|
||||
**L3b 的"非法值反证"手法只对不校验值的 provider 成立。** minimax 对非法 `reasoning_effort` 返回 200 且照常推理(prompt 207,介于基线 194 与 medium 216 之间,说明走了第三条模板路径);qwen 对同样的非法值直接 **HTTP 400**。这条手法写进测试时只在 minimax 上验过,它不可移植——若哪天把 L3b 套到别的 provider 上会得到假红。
|
||||
|
||||
## 6. `can_disable` 复测
|
||||
|
||||
M3 的 `ThinkingCapability(can_disable=True)` 的 evidence 停在 2026-08-02。2026-08-25 复测:`reasoning_effort=none` → prompt 194(= 基线)、completion 3、无 `reasoning_content`。**声明依然成立**,只需刷新 evidence 日期并补记本文新发现的两条限制(非流式不可观测、仅 `reasoning_effort` 有效)。
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
{
|
||||
"id": "schema:llm-calls",
|
||||
"label": "表结构: llm_calls(遥测 18 字段)",
|
||||
"label": "表结构: llm_calls(遥测 25 字段)",
|
||||
"type": "schema"
|
||||
},
|
||||
{
|
||||
@@ -384,6 +384,27 @@
|
||||
"relation": "implements",
|
||||
"evidence": "八任务实现四组改动(池语义/失败三分/状态可见/所有权纪律)",
|
||||
"added": "2026-08-24T12:05:46.300738+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:2026-08-25-thinking-observability-plan",
|
||||
"target": "design:2026-08-25-thinking-observability-design",
|
||||
"relation": "implements",
|
||||
"evidence": "本计划 Task 1-10 实现该设计的全部落点与 §14 验收标准",
|
||||
"added": "2026-08-26T04:49:16.312785+00:00"
|
||||
},
|
||||
{
|
||||
"source": "finding:2026-08-25-thinking-observability-regression",
|
||||
"target": "design:2026-08-25-thinking-observability-design",
|
||||
"relation": "supports",
|
||||
"evidence": "裸 httpx 与库两层实测(M3 推理正常、MiniMax 停报 completion_tokens_details)是该设计三层根因与三态裁定的事实基础",
|
||||
"added": "2026-08-26T04:49:17.481308+00:00"
|
||||
},
|
||||
{
|
||||
"source": "finding:2026-08-25-thinking-observability-regression",
|
||||
"target": "design:2026-08-02-thinking-capability-design",
|
||||
"relation": "refines",
|
||||
"evidence": "复测确认 M3 can_disable 仍成立,并补记非流式不可观测、仅 reasoning_effort 有效两条限制",
|
||||
"added": "2026-08-26T04:49:18.648857+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
# Research Wiki 索引
|
||||
|
||||
> 自动生成,更新时间:2026-08-24 15:51 UTC
|
||||
> 自动生成,更新时间:2026-08-26 04:49 UTC
|
||||
|
||||
## design (37)
|
||||
## design (38)
|
||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||
@@ -36,17 +36,19 @@
|
||||
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
|
||||
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
||||
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
||||
- [推理可观测性一等化(issue #16 + #17)](designs/2026-08-25-thinking-observability-design.md) `design:2026-08-25-thinking-observability-design`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
||||
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
|
||||
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||
|
||||
## finding (12)
|
||||
## finding (13)
|
||||
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
||||
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
||||
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
||||
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
|
||||
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
|
||||
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
|
||||
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
|
||||
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
|
||||
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
|
||||
@@ -55,7 +57,7 @@
|
||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||
|
||||
## plan (32)
|
||||
## plan (33)
|
||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||
@@ -85,6 +87,7 @@
|
||||
- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode`
|
||||
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
||||
- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle`
|
||||
- [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
||||
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
|
||||
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||
@@ -93,7 +96,7 @@
|
||||
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
|
||||
|
||||
## schema (1)
|
||||
- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
||||
- [表结构: llm_calls(遥测 25 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
||||
|
||||
## metric (2)
|
||||
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
||||
|
||||
@@ -140,3 +140,7 @@
|
||||
- [2026-08-24 15:48 UTC] issue15 独立验证 5 问题处置: 日志级别决策收敛到 tracker(fatal=error)并补执法用例、is not None 所有权纪律补 falsy 用例、更正两处过时吞吐数字、acquire 预算措辞对齐代码、登记页状态与行数校正
|
||||
- [2026-08-24 15:49 UTC] 重建索引: 85 篇页面
|
||||
- [2026-08-24 15:51 UTC] 重建索引: 85 篇页面
|
||||
- [2026-08-26 04:49 UTC] 新增边: plan:2026-08-25-thinking-observability-plan --implements--> design:2026-08-25-thinking-observability-design
|
||||
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --supports--> design:2026-08-25-thinking-observability-design
|
||||
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --refines--> design:2026-08-02-thinking-capability-design
|
||||
- [2026-08-26 04:49 UTC] 重建索引: 88 篇页面
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:2026-08-25-thinking-observability-plan
|
||||
title: "推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)"
|
||||
date: 2026-08-25
|
||||
---
|
||||
|
||||
# 推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)
|
||||
|
||||
> 类型:plan|日期:2026-08-25|实现设计:`designs/2026-08-25-thinking-observability-design.md`(已经人类批准)
|
||||
> 事实基础:`findings/2026-08-25-thinking-observability-regression.md`
|
||||
> **保真校验不适用**:本计划不涉及 `reference/` 三项目的迁移,推理开关是库自有子系统,不在 ARCHITECTURE.md §1.4 关键资产索引的移植蓝本内。
|
||||
|
||||
## 目标
|
||||
|
||||
让"这次推理到底发生没发生"成为库的一等返回值,由多信号裁定,判不出来时如实说 UNKNOWN,并与能力表持续对账。
|
||||
|
||||
## 方案概述
|
||||
|
||||
新增 `ThinkingObservation` 三态枚举(定义在最内层 `types.py`)与裁定纯函数 `observe_thinking`(决策层 `thinking.py`),由 transport 在组装结果时裁定并与请求方向对账,结果随 `LLMResponse` 返回、随遥测落库。同时把推理决策从 `providers.py` 拆进新模块 `thinking.py`,并把公共符号提升到包根导出。
|
||||
|
||||
涉及技术:Python 3.12 `StrEnum`、frozen dataclass、`inspect.signature` 冻结测试、import-linter 分层契约、SQLite/PG schema backfill。
|
||||
|
||||
## 文件结构
|
||||
|
||||
**新建**
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `src/polygateway/thinking.py` | 推理这件事的全部**决策**:能力表、`resolve_thinking`(请求侧注入)、`observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义** |
|
||||
| `tests/unit/test_thinking.py` | 裁定与对账的单元测试 |
|
||||
|
||||
**修改**
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `src/polygateway/types.py` | 新增 `ThinkingObservation`;`LLMResponse` / `TransportResult` 各增一字段 |
|
||||
| `src/polygateway/providers.py` | 收缩为纯注册表 |
|
||||
| `src/polygateway/ports.py` | `record_llm_call` 24 参 → 25 参 |
|
||||
| `src/polygateway/transports/openai_compat.py` | 裁定 + 对账 |
|
||||
| `src/polygateway/middleware/retry.py` | 透传 |
|
||||
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage` + 三个 `emit_*` + `_record` |
|
||||
| `src/polygateway/middleware/cache.py` | `_rehydrate` 枚举复活 |
|
||||
| `src/polygateway/telemetry/schema.py` | 新列 + 两端 DDL + 两份 backfill |
|
||||
| `src/polygateway/telemetry/sqlite.py`、`postgres.py` | 实现新参 |
|
||||
| `src/polygateway/client.py` | import 路径 |
|
||||
| `src/polygateway/__init__.py` | 包根导出 + 版本号 |
|
||||
| `pyproject.toml` | import-linter 契约加层 + 版本号 |
|
||||
| 测试 9 个、文档 5 个 | 见各任务 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1:`ThinkingObservation` 与裁定纯函数
|
||||
|
||||
**文件**:创建 `src/polygateway/thinking.py`、`tests/unit/test_thinking.py`;修改 `src/polygateway/types.py`、`pyproject.toml`
|
||||
|
||||
### 行为
|
||||
|
||||
在 `types.py` 新增(放在 `LLMResponse` 定义**之前**,因为它是其字段类型):
|
||||
|
||||
```python
|
||||
class ThinkingObservation(StrEnum):
|
||||
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
|
||||
|
||||
三态不可折叠为布尔: `UNKNOWN` 是"本次无任何信号,判不出来",与
|
||||
`ABSENT`("上游明确上报未推理")语义不同。把前者折叠进后者,正是
|
||||
`reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",
|
||||
而实际可能推理了且已计费(MiniMax-M3 非流式实测)。
|
||||
"""
|
||||
|
||||
OBSERVED = "observed"
|
||||
ABSENT = "absent"
|
||||
UNKNOWN = "unknown"
|
||||
```
|
||||
|
||||
在新建的 `thinking.py` 实现(本任务只放这一个函数,搬迁留给 Task 2):
|
||||
|
||||
```python
|
||||
def observe_thinking(
|
||||
*, thinking: str, reasoning_tokens: int | None
|
||||
) -> ThinkingObservation:
|
||||
"""由多信号裁定推理是否发生;判据按证据硬度排序。
|
||||
|
||||
推理正文是事实本身,token 计数是对事实的转述——转述缺失时事实仍然作数。
|
||||
"""
|
||||
if thinking.strip():
|
||||
return ThinkingObservation.OBSERVED
|
||||
if reasoning_tokens is None:
|
||||
return ThinkingObservation.UNKNOWN
|
||||
return (
|
||||
ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
|
||||
)
|
||||
```
|
||||
|
||||
`pyproject.toml` 的 import-linter 契约 `layers` 插入一层,位置在实现层与 `providers` 之间:
|
||||
|
||||
```toml
|
||||
layers = [
|
||||
"polygateway.client",
|
||||
"polygateway.config",
|
||||
"polygateway.middleware",
|
||||
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
|
||||
"polygateway.thinking",
|
||||
"polygateway.providers : polygateway.sources",
|
||||
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
|
||||
]
|
||||
```
|
||||
|
||||
层序理由:`thinking.py` 要 import `providers.py` 的 `ProviderProfile`(故在其上),被 `transports/` 与 `client.py` import(故在其下)。**枚举放 `types.py` 而非 `thinking.py`,正是为了让最内层不反向依赖决策层**——这是本任务最容易做错的一步,写反了 import-linter 会判红。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_thinking.py` 覆盖裁定五种输入:正文非空 → OBSERVED;**纯空白正文 + `reasoning_tokens=None` → UNKNOWN**(不得因 truthy 判成 OBSERVED);`reasoning_tokens=5` → OBSERVED;`reasoning_tokens=0` → ABSENT;`reasoning_tokens=None` 且正文空 → UNKNOWN。再加一条优先级用例:正文非空且 `reasoning_tokens=0` → OBSERVED(正文压倒转述)。
|
||||
|
||||
`tests/unit/test_types.py` 加一条:`ThinkingObservation` 定义在 `polygateway.types` 模块内(`ThinkingObservation.__module__ == "polygateway.types"`),防止后续任务把它挪回决策层。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_types.py -v
|
||||
conda run -n PolyGateway lint-imports
|
||||
```
|
||||
|
||||
预期:新测试全 PASS;`lint-imports` 全部契约 KEPT。
|
||||
|
||||
- [ ] Task 1 提交:`feat: judge whether reasoning actually happened from multiple signals`
|
||||
|
||||
---
|
||||
|
||||
## Task 2:把推理决策从 `providers.py` 搬进 `thinking.py`
|
||||
|
||||
**文件**:修改 `src/polygateway/thinking.py`、`src/polygateway/providers.py`、`src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/__init__.py`、`tests/unit/test_providers.py`、`tests/unit/test_package.py`
|
||||
|
||||
### 行为
|
||||
|
||||
从 `providers.py` **原样移入** `thinking.py`(纯移动,不改逻辑):`ThinkingUnsupportedError`、`ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`、`_warn_unregistered`。
|
||||
|
||||
`providers.py` 保留:`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`、`register_provider`。其模块 docstring 改为只讲注册表职责;`thinking.py` 的模块 docstring 说明它承载推理的全部决策而枚举归 `types.py`。
|
||||
|
||||
更新 import:`client.py`(`from polygateway.providers import get_capability, get_provider, resolve_thinking` 拆成两行)、`transports/openai_compat.py`、`client.py` 的 `TYPE_CHECKING` 块里 `ThinkingCapability` 的来源。
|
||||
|
||||
`__init__.py` 新增包根导出并加进 `__all__`(该列表**不是严格字母序**——`DEFAULT_PROFILES` 现在就排在 `AllSourcesExhausted` 前面;沿用文件既有排列,把新符号插到同类符号附近即可):`ThinkingCapability`、`ThinkingObservation`、`ThinkingUnsupportedError`、`get_capability`、`register_capability`、`resolve_thinking`。
|
||||
|
||||
`tests/unit/test_providers.py` 里针对被搬走符号的测试,整体移入 `tests/unit/test_thinking.py`。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_package.py` 比照既有 `TelemetryStatus` 用例,加一条断言六个新符号可从包根 import 且在 `__all__` 内——该测试在导出落地前必然红。
|
||||
|
||||
搬迁本身的回归证据:搬迁前后 `pytest tests/unit -q` 通过数不减(搬迁是纯移动,任何行为差异都是 bug)。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit -q
|
||||
conda run -n PolyGateway lint-imports
|
||||
conda run -n PolyGateway python -c "from polygateway import ThinkingObservation, ThinkingCapability, resolve_thinking; print('ok')"
|
||||
```
|
||||
|
||||
预期:全 PASS;契约 KEPT;import 成功。
|
||||
|
||||
- [ ] Task 2 提交:`refactor: give reasoning decisions their own module`
|
||||
|
||||
---
|
||||
|
||||
## Task 3:字段落到响应类型并贯通调用链
|
||||
|
||||
**文件**:修改 `src/polygateway/types.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/middleware/retry.py`;测试 `tests/unit/test_types.py`、`tests/unit/test_openai_compat.py`、`tests/unit/test_retry.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`TransportResult` 与 `LLMResponse` 各新增字段,**必须加在各自字段列表末尾且带默认值**(`LLMResponse` 是被三项目消费的公共类型,只增不删且不得改变既有位置参数顺序):
|
||||
|
||||
```python
|
||||
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||
```
|
||||
|
||||
`LLMResponse` 侧补 docstring:`UNKNOWN` = 本次无信号判不出,**不是**"没推理";非流式路径下部分模型推理已计费却不回传正文(M3 实测 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。
|
||||
|
||||
`transports/openai_compat.py` 的两条组装路径(流式 `_complete_stream` 的 463-475 行、非流式 `_complete_once` 的 548-560 行)在构造 `TransportResult` 时调 `observe_thinking(thinking=thinking, reasoning_tokens=...)` 填入。两条路径都要填——**只填一条正是 L5 要抓的那类分叉**。
|
||||
|
||||
`middleware/retry.py` 的 `_build_response`(372-393 行)透传 `thinking_observation=result.thinking_observation`。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_types.py`:两个类型的默认值均为 `ThinkingObservation.UNKNOWN`;`LLMResponse` 既有位置构造方式不破(沿用文件内既有的构造用例形态)。
|
||||
|
||||
`tests/unit/test_openai_compat.py`:用既有的 SSE / JSON 响应装置,构造三种响应各断言一次——含 `reasoning_content` 增量 → `OBSERVED`;无推理信号 → `UNKNOWN`;`usage.completion_tokens_details.reasoning_tokens=0` → `ABSENT`。流式与非流式各一组。
|
||||
|
||||
`tests/unit/test_retry.py`:比照既有透传测试,断言 transport 返回的 `thinking_observation` 原样出现在 `LLMResponse` 上。
|
||||
|
||||
以上在字段落地前全部红(属性不存在)。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v
|
||||
```
|
||||
|
||||
预期:全 PASS。
|
||||
|
||||
- [ ] Task 3 提交:`feat: carry the reasoning verdict through to LLMResponse`
|
||||
|
||||
---
|
||||
|
||||
## Task 4:对账告警(声明 × 观测)
|
||||
|
||||
**文件**:修改 `src/polygateway/thinking.py`、`src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_thinking.py`、`tests/unit/test_openai_compat.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`thinking.py` 新增对账纯函数,返回告警文案或 `None`(**判定与日志分离**,这样告警内容可被单测直接断言,不必去解析日志):
|
||||
|
||||
```python
|
||||
def reconcile_thinking(
|
||||
*,
|
||||
enable_thinking: bool | None,
|
||||
observation: ThinkingObservation,
|
||||
capability: ThinkingCapability | None,
|
||||
model: str,
|
||||
) -> str | None:
|
||||
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
|
||||
|
||||
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
|
||||
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
|
||||
"""
|
||||
```
|
||||
|
||||
判定矩阵(设计 §5):
|
||||
|
||||
| `enable_thinking` | observation | capability | 返回 |
|
||||
|---|---|---|---|
|
||||
| `False` | OBSERVED | 已登记 | 能力表漂移:声明可关闭,实测推理了。附 `capability.evidence` 与 `register_capability` 指路 |
|
||||
| `False` | OBSERVED | `None` | 关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
|
||||
| `True` | ABSENT | 任意 | 注入了开启参数,上游明确上报未推理 |
|
||||
| `True` | UNKNOWN | 任意 | 推理参数已注入但本路径观测不到,无法确认是否生效;若为非流式路径,推理内容可能已计费却不回传 |
|
||||
| 其余组合(含 `False`×UNKNOWN、`None`×任意) | | | `None` |
|
||||
|
||||
`False`×UNKNOWN 返回 `None` 是刻意的:`UNKNOWN` 没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。
|
||||
|
||||
`transports/openai_compat.py` 在组装完 `TransportResult` 后调用它,非 `None` 则 `logger.warning`,并按 `(model, enable_thinking)` 节流——新增实例级 `set`,与既有 `_warned_models` 同款形态,**不可复用同一个 set**(那个 set 语义是"未登记能力已告警过",混用会互相压制)。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_thinking.py`:矩阵四行各断言返回非 `None` 且文案含模型名;三种不表态组合(`False`×UNKNOWN、`None`×OBSERVED、`True`×OBSERVED)断言返回 `None`;已登记 vs 未登记两行的文案**必须不同**(不得对未登记模型说"能力表声称可关闭")。
|
||||
|
||||
`tests/unit/test_openai_compat.py`:断言同一 `(model, direction)` 连调两次只出现一条 warning;换 direction 后再出一条。**不能用 `caplog`**——本项目日志走 loguru,不经标准 `logging`,`caplog` 抓不到;复用 `tests/unit/test_thinking.py` 的 `_warnings()`(`logger.add` 收集)。
|
||||
|
||||
> `reconcile_thinking` 必须定义在 `ThinkingCapability` **之后**:本模块没有 `from __future__ import annotations`,注解在 `def` 时求值,放在文件上部会 `NameError`。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_openai_compat.py -v
|
||||
```
|
||||
|
||||
预期:全 PASS。
|
||||
|
||||
- [ ] Task 4 提交:`feat: warn when the capability table and reality disagree`
|
||||
|
||||
---
|
||||
|
||||
## Task 5:缓存回放复活枚举
|
||||
|
||||
**文件**:修改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`_rehydrate` 走 `LLMResponse(**fields)`,JSON 里的 `"observed"` 会复活成**裸 `str`** 而非枚举实例,类型与注解分叉。在 `fields.update(...)` 之前显式转换:
|
||||
|
||||
```python
|
||||
if "thinking_observation" in fields:
|
||||
fields["thinking_observation"] = ThinkingObservation(
|
||||
fields["thinking_observation"]
|
||||
)
|
||||
```
|
||||
|
||||
非法值(旧版本缓存、人为污染)会抛 `ValueError`,由既有的 `except Exception` 吞成"按未命中回源"并 warning——降级方向正确,不需额外处理。
|
||||
|
||||
`_serialize` 无需改动:`StrEnum` 是 `str` 子类,`dataclasses.asdict` + `json.dumps` 直接可序列化。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_cache.py`:写入一条 `thinking_observation=OBSERVED` 的响应后命中回放,断言 `isinstance(resp.thinking_observation, ThinkingObservation)`(改动前必然红——回放出来的是 `str`);再造一条 `thinking_observation` 为 `"bogus"` 的缓存值,断言按未命中回源。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_cache.py -v
|
||||
```
|
||||
|
||||
预期:全 PASS。
|
||||
|
||||
- [ ] Task 5 提交:`fix: revive the reasoning verdict as an enum, not a bare string`
|
||||
|
||||
---
|
||||
|
||||
## Task 6:遥测新增一列(端口 → schema → recorder → emitter)
|
||||
|
||||
**文件**:修改 `src/polygateway/ports.py`、`src/polygateway/telemetry/schema.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`;测试 `tests/unit/test_ports.py`、`tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py`
|
||||
|
||||
### 行为
|
||||
|
||||
**端口**:`TelemetryRecorder.record_llm_call` 增 `thinking_observation: str`,**不设默认值**(该 Protocol 的既有纪律,docstring 已写明理由:库外无第三方实现者,带默认值会让 emitter 漏传时静默落默认)。参数加在 `meta` 之后。docstring 的"24 字段冻结"改为 25。
|
||||
|
||||
**schema**:`SQLITE_DDL` / `PG_DDL` 末尾加 `thinking_observation TEXT`;`SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` 各加 `("thinking_observation", "TEXT")`;`COLUMNS` 末尾加同名项。**新列必须排在最末**——旧表只能 ALTER 追加到末尾,插在中间会让新建库与补列库的物理列序分叉(该纪律的注释就在这两个常量上方)。
|
||||
|
||||
**recorder**:两个 recorder 的 `record_llm_call` 都是 `(self, **fields: object)` 形态(**不是**显式参数列表),按 `COLUMNS` / `self._columns` 从 `fields` 取值——新列因此**不需要改签名**,只要 `COLUMNS` 里有、emitter 传了,取值就自动到位。要做的是核对两处:取值是否严格按列序、manual 档列裁剪路径是否覆盖新列。`sqlite.py:146` docstring 的"24 字段冻结签名"改 25。
|
||||
|
||||
> 端口 `ports.py` 的 Protocol 是**显式 25 参**,而实现是 `**fields`——这不矛盾:Protocol 声明的是调用契约(emitter 必须按名传全),实现选择用 kwargs 收。改端口签名仍然必要,它是 emitter 侧的编译期约束与冻结测试的锚点。
|
||||
|
||||
**emitter**:`_AttemptUsage` 增 `thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN`(**内部字段用枚举类型**,裸 `str` 归一化只发生在下沉 recorder 那一步),`of()` 从 response 取;三个 `emit_*` 各传一行(`emit_terminal_failure` 传 `ThinkingObservation.UNKNOWN`——无响应可言,默认值本身不撒谎);`_record` 签名增一参并下沉给 recorder。**所有新增字段只经 `_record` 这一个出口抵达 recorder,不新开调用点**(铁律:遥测调用点收敛为单一 helper,该出口已存在)。`middleware/telemetry.py:135` 的"组装 24 字段"改 25。
|
||||
|
||||
**recorder 收到的必须是裸 `str`,不是枚举实例**:`_AttemptUsage.thinking_observation` 内部用 `ThinkingObservation` 类型,但 `_record` 下沉给 recorder 时取 `.value`。`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只会被降级成一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一先例(`ports.py` docstring 明载该分工:recorder 只落库,不做语义判断)。
|
||||
|
||||
### 数字断言逐处更新(漏一处即红)
|
||||
|
||||
| 位置 | 现值 → 新值 |
|
||||
|---|---|
|
||||
| `tests/unit/test_telemetry.py:37` `_EXPECTED_COLUMNS` | 末尾加 `thinking_observation` |
|
||||
| `tests/unit/test_telemetry.py:184` INSERT 占位符串 | 补到 `$25` |
|
||||
| `tests/unit/test_telemetry.py:210` | `len(COLUMNS) == 24` → `25` |
|
||||
| `tests/unit/test_telemetry.py:633` docstring | 物理列 `23 → 25` 改为 `24 → 26` |
|
||||
| `tests/unit/test_telemetry.py:642` | `== 25` → `== 26` |
|
||||
| `tests/unit/test_telemetry.py:645` docstring | `25 个物理列` → `26 个` |
|
||||
| `tests/integration/test_postgres_telemetry.py:764` 注释 | `22 → 24 个 recorder 字段(加 created_at 共 25 个物理列)` 改为 `24 → 25 个(共 26 个物理列)` |
|
||||
|
||||
> 上表**不完整**——实施时实测另有 6 处漏改会当场把测试跑红:`_FROZEN_SQLITE_INSERT`(计划只点了 PG 那条)、`:586` 的 `_EXPECTED_COLUMNS[:-2]` → `[:-3]`、`TestBackendColumnParity` 的 `COLUMNS[-2:]` 断言、两处 `_CURRENT` 假列表(稳态不发 ALTER 的断言)、`PG_BACKFILL[-1]` 末位断言,以及 integration 侧 `:608` 的 `_PRE_TENANT_COLUMNS` 派生式。另有四处注释/docstring 的字段数会过期。**结论: 不要照表逐条打勾就收工,以"全套件绿"为准**。
|
||||
|
||||
> **不要改 `tests/unit/test_telemetry.py:1787`**:那里的"共 24 字"是 OCR 占位串 `<ocr:text image_bytes=3>` 的**字符数**,与遥测列数无关。全局替换"24"会误伤它。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_ports.py`:现有 `TestTelemetryRecorderSignature` **并不冻结完整参数列表**——它只 parametrize 了 `["tenant_id", "meta"]` 两项,断言其无默认值且为 KEYWORD_ONLY。把 `thinking_observation` 加进该 parametrize 列表,断言同样三条——改端口前必然红。
|
||||
|
||||
`tests/unit/test_telemetry.py`:列数与列序断言(上表);新增一条 round-trip——记录一条 `thinking_observation=OBSERVED` 的调用后从 SQLite 读回该列等于 `"observed"`。
|
||||
|
||||
`tests/integration/test_postgres_telemetry.py`:既有 backfill 用例覆盖旧表补列后新列存在且可写读。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v
|
||||
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
|
||||
conda run -n PolyGateway python -c "
|
||||
import inspect
|
||||
from polygateway.ports import TelemetryRecorder
|
||||
p = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
||||
print('recorder 参数数(不含 self):', len(p) - 1)"
|
||||
```
|
||||
|
||||
预期:全 PASS;最后一条打印 `25`(README 的字段数断言按此实测值填,见 Task 9)。
|
||||
|
||||
- [ ] Task 6 提交:`feat: record the reasoning verdict in telemetry`
|
||||
|
||||
---
|
||||
|
||||
## Task 7:e2e 判据重建
|
||||
|
||||
**文件**:修改 `tests/e2e/test_thinking_live.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`_run_rounds` 的逐轮观测字典增加两个键:`"thinking_observation": resp.thinking_observation` 与 `"thinking_chars": len(resp.thinking)`(报告里要能看见证据本身,而不只是结论)。
|
||||
|
||||
判据函数改写:
|
||||
|
||||
```python
|
||||
def _reasoning_on(obs: dict) -> bool:
|
||||
"""开启方向: 观测到推理即为真。
|
||||
|
||||
判据从 `reasoning_tokens` 换成三态裁定,因为 MiniMax 这一路已不再上报
|
||||
`completion_tokens_details`(2026-08-25 findings),而库在同一次调用里
|
||||
拿得到 185 字符推理正文——旧判据看不见它,四条用例因此假红。
|
||||
"""
|
||||
return obs["thinking_observation"] == ThinkingObservation.OBSERVED
|
||||
|
||||
|
||||
def _reasoning_off(obs: dict) -> bool:
|
||||
"""关闭方向: 只要没观测到推理即算满足。
|
||||
|
||||
`UNKNOWN` 计入满足是有意的: 它没有证伪力(设计 §4.1),不能拿它判红。
|
||||
本判据真正的证伪力在于——模型若偷偷推理了,可观测路径会翻成 OBSERVED。
|
||||
"""
|
||||
return obs["thinking_observation"] != ThinkingObservation.OBSERVED
|
||||
```
|
||||
|
||||
**删除 `_ON_MIN_COMPLETION` 常量及其全部引用**:两档 completion 分布实测重叠(关闭档最高 46、开启档最低 13),这个魔数退路从一开始就不成立。
|
||||
|
||||
**L5 重新定义**(当前实现断言"非流式开启档多数轮观测到推理",而 M3 非流式推理正文与 ctd 双缺,该断言永远不可能成立):改为断言两件真实成立的事——其一非流式下关闭档与开启档的 `prompt_tokens` 锚点仍然分开(证明参数确实到达模型,判据形态照抄 L2b);其二开启档观测为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。用例 docstring 写明:M3 非流式推理已计费却不回传正文,这是上游行为,库修不了但必须让它可见。
|
||||
|
||||
L3b 的 docstring 补一句不可移植性:minimax 对非法 `reasoning_effort` 返回 200 且照常推理,qwen 对同样的值返回 **HTTP 400**——该反证手法只对不校验值的 provider 成立。
|
||||
|
||||
模块顶部的判据纪律段与 `_write_report` 的报告表头同步改写为三态口径。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
本任务的证据是真跑:改前 `TestMiniMaxM3` 4 failed / 3 passed,改后全类 PASS。L5 的新断言在 Task 3 之前无法表达(字段不存在),是纯新增覆盖。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v
|
||||
```
|
||||
|
||||
预期:`TestMiniMaxM3` 7 passed;报告落 `tests/outputs/e2e/`。耗时约 7 分钟、约 137 次真实调用。
|
||||
|
||||
- [ ] Task 7 提交:`test: judge reasoning by what the library actually observed`
|
||||
|
||||
---
|
||||
|
||||
## Task 8:能力表 evidence 刷新
|
||||
|
||||
**文件**:修改 `src/polygateway/thinking.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`DEFAULT_CAPABILITIES` 中 `MiniMax-M3` 的 `can_disable` **保持 `True`**(2026-08-25 复测:`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立)。`evidence` 追加复测日期与两条新限制:推理信号在非流式路径不可观测;`enable_thinking` / `thinking:{type:enabled}` 对该模型无效,仅 `reasoning_effort` 是真开关。
|
||||
|
||||
`minimax` profile 上方的注入形态注释同步补记复测日期。
|
||||
|
||||
### 测试要求
|
||||
|
||||
**先失败后通过不适用于本任务,理由须写进提交信息**:本任务只改 `evidence` 字符串与注释,`can_disable` 取值不变,**没有行为变更**,因而没有可先失败的行为断言(`test-driven-development` 的结果门约束的是行为变更)。声明依然成立这一事实,其证据是 2026-08-25 的复测与 Task 7 的 e2e 真跑,不是本任务能自造的单测。
|
||||
|
||||
`tests/unit/test_thinking.py` 既有的能力表用例(`evidence` 非空、`can_disable` 取值)须保持绿,作为回归证据。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_thinking.py -q
|
||||
```
|
||||
|
||||
- [ ] Task 8 提交:`docs: refresh the M3 capability evidence with the 08-25 retest`
|
||||
|
||||
---
|
||||
|
||||
## Task 9:文档同步(构建前必须改完)
|
||||
|
||||
**文件**:修改 `README.md`、`research-wiki/ARCHITECTURE.md`、`research-wiki/schemas/llm-calls.md`、`research-wiki/index.md`、`CHANGELOG.md`
|
||||
|
||||
### 行为
|
||||
|
||||
**`README.md:21`**:`必录 24 字段` → `25 字段`。数字取 Task 6 验证步骤里 `inspect.signature` 的实测输出,**不凭记忆**(发布清单第 1 步点名的失败模式)。同时核对安装命令的版本约束是否需要跟进,以及能力表是否要提及推理裁定这一新行为。
|
||||
|
||||
**`README.md` 的 `<!-- pg-template:table -->` 生产部署 DDL 模板**——**本条计划原文是错的,已订正**。
|
||||
|
||||
原文断言该模板是"独立于 `schema.py` 手写的另一份 SQL",要求补上 `thinking_observation TEXT`。**事实相反**:该模板不含任何列定义,它是 `CREATE TABLE llm_calls (LIKE llm_calls_seed INCLUDING DEFAULTS, PRIMARY KEY (call_id, created_at)) PARTITION BY RANGE (created_at)`,列全部从上一步 `telemetry_schema_sql('postgres')` 建出的 seed 表派生,README 正文原本就写着"列不在这里重抄一份——抄了就会漂移"。照原文补列会让 PG 报列重复、`TestProductionTemplate` 全红、下游部署直接失败。
|
||||
|
||||
(这条错误的来路值得记下来: 它出自另一个任务的实施报告,写进计划时**没有自己打开 README 核实**。跨任务转述的"发现"必须当作待验证的线索,不是事实。)
|
||||
|
||||
正确的做法是加一条**形态断言**: 模板必须靠 `LIKE` 派生,且不得内联任何 `COLUMNS` 里的列名。它钉住的是"日后有人把列抄进模板"这个真实风险——比原计划想堵的缺口更贴合实际。断言落在 `tests/integration/test_postgres_telemetry.py` 的 `TestProductionTemplate`(**不在** `tests/unit/test_telemetry.py`,计划原文也指错了文件)。
|
||||
|
||||
**`research-wiki/ARCHITECTURE.md`**:§8 模块结构树补 `thinking.py` 一行并说明职责;§8 依赖纪律段补 `thinking.py` 的层位;D11 段说明推理决策已从 `providers.py` 拆出;§5.1 响应字段表补 `thinking_observation`;§7.8 遥测字段补新列。
|
||||
|
||||
**`research-wiki/schemas/llm-calls.md`**:标题与正文的"遥测 22 字段"已过期两轮,订正为 25;补 `thinking_observation` 的列定义与查询口径(示例:按模型统计各观测态占比,用于发现某模型何时开始观测不到推理)。
|
||||
|
||||
**`research-wiki/index.md`**:登记本 plan、design 与 finding。
|
||||
|
||||
**先失败后通过不适用于本任务**:纯文档同步,无行为变更。其验收是下方 grep 的可见输出——数字与模块名对不上就是没改完。
|
||||
|
||||
**`CHANGELOG.md`**:新增 1.3.1 条目。**断裂项置于条目最前**,沿用 1.3.0"请先读这一条"体例(设计 §13:版号既然不承担预警职责,预警由 CHANGELOG 独立扛)。三条必须显式列出——① `polygateway.providers` 的深路径 import 断裂(`ThinkingCapability` / `resolve_thinking` / `get_capability` / `register_capability` / `DEFAULT_CAPABILITIES` / `ThinkingUnsupportedError` 移入 `polygateway.thinking`,同时提升到包根,**推荐改用包根 import**);② `TelemetryRecorder.record_llm_call` 端口签名 24 参 → 25 参,自定义 recorder 实现须同步;③ M3 非流式开启推理时推理内容已计费却不回传,该档观测为 `UNKNOWN`,库现在会告警一次。
|
||||
|
||||
### Wiki 注册
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id 2026-08-25-thinking-observability-plan --title "推理可观测性一等化实现计划"
|
||||
.claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:2026-08-25-thinking-observability-plan" --to "design:2026-08-25-thinking-observability-design" --type implements --evidence "本计划实现该设计的全部落点"
|
||||
.claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||
```
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
grep -n '25 字段' README.md
|
||||
grep -n 'thinking.py' research-wiki/ARCHITECTURE.md
|
||||
grep -rn '22 字段' research-wiki/schemas/llm-calls.md # 预期无输出
|
||||
```
|
||||
|
||||
- [ ] Task 9 提交:`docs: sync the field counts and module map to 1.3.1`
|
||||
|
||||
---
|
||||
|
||||
## Task 10:合并前独立验证与发布 1.3.1
|
||||
|
||||
**文件**:修改 `pyproject.toml`、`src/polygateway/__init__.py`
|
||||
|
||||
### 行为
|
||||
|
||||
版本号两处改 `1.3.1`(`pyproject.toml` 与 `__init__.py.__version__` 必须一致);`CHANGELOG.md` 的"未发布"定版为 `## 1.3.1(2026-08-25)`。
|
||||
|
||||
本任务分两段,**中间是一道人类确认门**。
|
||||
|
||||
**第一段:分支内可自主完成的验证**——CHANGELOG 定版为 `## 1.3.1(2026-08-25)`;版本号两处改 `1.3.1`;`verification-before-completion` 派**全新上下文** verifier subagent 独立验证(跨 20+ 文件,属强制档);`requesting-code-review` 整分支审查;在分支上跑 `make ci` 与 `pytest -m slow`(约 20-40 分钟——四个 e2e 文件与 Redis 时间语义变体默认被 `-m 'not slow'` 排除,不显式跑等于没跑)。
|
||||
|
||||
**Gitea Wiki 文档站同步**(计划原本漏了,Task 9 实施时发现):`research-wiki/docs-convention.md` §2 明写"新公共 API / 新能力 → 对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG"、"发版(任何版本号) → `Home.md` 版本号与安装命令",且该文件第 26 行是一道门——**版本 bump 的提交不允许单独存在**。本版有 6 个新包根导出、1 个新公共字段、1 个端口签名变更,wiki 必须同步。wiki 是**独立 git 仓库**(需 clone),故拆成两半:**内容在第一段写好待推**,`git push` 归第二段(外发动作)。
|
||||
|
||||
**人类确认门**:以上全绿后停下,把验证结果交给人类,**取得明确同意后**才执行第二段。
|
||||
|
||||
**第二段:外发且难以撤销的动作,一律等确认**——合并 main(`--no-ff`)+ push → 打 tag 并 push → 构建 → 上传 registry → `pip download` 验证并解包确认新代码在内 → 建 Release + 挂仓库 + 核对包页面 → 关闭 issue #16 / #17 并附修复说明(诊断纠正 + 三层根因 + 落地形态)。顺序按 CLAUDE.md §4.4.1**不得跳步**:包上传与 tag 一旦推出去就收不回,registry 里的版本号也不能复用。
|
||||
|
||||
合并到 main 后须在 main 上**重跑** `make lint` 与全套件外加 `pytest -m slow`——分支上跑过不算,合并本身可能引入差异。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway make ci
|
||||
conda run -n PolyGateway pytest -m slow
|
||||
python -c "import tomllib,pathlib,re
|
||||
v=tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']
|
||||
i=re.search(r'__version__ = \"(.+?)\"', pathlib.Path('src/polygateway/__init__.py').read_text()).group(1)
|
||||
assert v == i == '1.3.1', (v, i); print('版本号一致:', v)"
|
||||
```
|
||||
|
||||
预期:`make ci` 绿;slow 全绿;版本号一致性检查通过。
|
||||
|
||||
- [ ] Task 10 提交:`chore: cut 1.3.1`
|
||||
|
||||
---
|
||||
|
||||
## 任务依赖
|
||||
|
||||
Task 1 → 2 → 3 是硬序(枚举 → 模块就位 → 字段贯通)。Task 4、5、6 都依赖 3,彼此独立可并行。Task 7 依赖 3(需要字段)。Task 8 依赖 2(能力表已搬)。Task 9 依赖 6(字段数实测值)。Task 10 最后。
|
||||
|
||||
## 全局纪律
|
||||
|
||||
不做计划外的重构与抽象——尤其**不重构遥测组装路径**:`TelemetryEmitter._record` 已经是铁律要求的单一出口,三个 `emit_*` 是三个语义不同的入口,各自组装参数是职责所在(设计 §12)。
|
||||
|
||||
每个任务独立提交,提交前跑该任务的验证命令。任何一步的完成声明必须对应本会话内的工具输出。
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
type: schema
|
||||
node_id: schema:llm-calls
|
||||
title: "表结构: llm_calls(遥测 22 字段)"
|
||||
title: "表结构: llm_calls(遥测 25 字段)"
|
||||
date: 2026-07-20
|
||||
---
|
||||
|
||||
# 表结构: llm_calls(遥测 22 字段)
|
||||
# 表结构: llm_calls(遥测 25 字段)
|
||||
|
||||
|
||||
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
||||
@@ -28,6 +28,9 @@ date: 2026-07-20
|
||||
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
|
||||
| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 |
|
||||
| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 |
|
||||
| tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 |
|
||||
| meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB |
|
||||
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
|
||||
|
||||
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
||||
|
||||
@@ -54,7 +57,9 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
|
||||
|
||||
## 采样参数口径(2026-07-31,issue #4)
|
||||
|
||||
`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故统计口径须为 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。
|
||||
`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故当时的统计口径是 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。
|
||||
|
||||
> **该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报 `completion_tokens_details`(MiniMax 这一路实测已停),此时 NULL 只意味着「没上报」而非「没推理」——同一次调用里库拿得到 185 字符推理正文。统计一律改按新列 `thinking_observation` 分组,见下方「推理观测口径」。
|
||||
|
||||
`sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。
|
||||
|
||||
@@ -77,6 +82,28 @@ SELECT DISTINCT sampling FROM llm_calls
|
||||
WHERE session_id = $1 AND cache_hit = false AND error IS NULL;
|
||||
```
|
||||
|
||||
## 推理观测口径(2026-08-25,issue #16/#17)
|
||||
|
||||
`thinking_observation` 是**响应侧的裁定结果**,不是请求侧的声明: 推理正文(`thinking`)非空即 `observed`(正文是事实本身,压倒 usage 明细这一转述);正文空而 `reasoning_tokens > 0` 亦 `observed`;`reasoning_tokens == 0` 为 `absent`(上游明确上报未推理);两个信号双缺为 `unknown`。
|
||||
|
||||
**`unknown` 不得并进「未推理」**。它是本列存在的全部理由: MiniMax 这一路上游 2026-08-25 起不再返回 `completion_tokens_details`,`reasoning_tokens` 因此恒 NULL,而同一次调用里库拿得到 185 字符推理正文——旧口径 `reasoning_tokens IS NULL OR = 0` 会把这类调用统计成「没推理」。**该旧口径自本版起作废**,统计一律按本列分组。M3 非流式档更极端: 推理已计费(completion 53 vs 关闭档 3)却不回传正文,该档只能是 `unknown`,任何把它读成「没推理」的报表都在撒谎。
|
||||
|
||||
按模型看各观测态占比,用于发现某模型从哪天起观测不到推理:
|
||||
|
||||
```sql
|
||||
SELECT model,
|
||||
thinking_observation,
|
||||
count(*) AS calls,
|
||||
round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY model), 1) AS pct
|
||||
FROM llm_calls
|
||||
WHERE cache_hit = false AND error IS NULL
|
||||
AND created_at >= now() - interval '7 days'
|
||||
GROUP BY model, thinking_observation
|
||||
ORDER BY model, calls DESC;
|
||||
```
|
||||
|
||||
三条限定各有理由: `cache_hit = false` 与 `cost`/`cached_prompt_tokens` 同源——缓存命中行原样回放历史观测值,计入即重复计数;`error IS NULL` 排除失败尝试与终态失败行,那些行的本列恒为 `unknown`(无响应可裁定,默认值本身不撒谎),混进来会把「观测不到」的占比整体抬高;时间窗是为了让**变化**可见——某模型的 `unknown` 占比从 0 跳到 100%,正是它停报推理信号的那一天。补列之前写入的历史行本列为 NULL,与 `unknown` 是两回事(前者是那时还没有这一列),跨版本对比须显式区分。
|
||||
|
||||
## 埋点位置(单一 helper 铁律)
|
||||
|
||||
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
|
||||
|
||||
@@ -24,6 +24,13 @@ from polygateway.ocr import OcrClient
|
||||
from polygateway.pricing import ModelPrice, PricingTable
|
||||
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
|
||||
from polygateway.telemetry.schema import telemetry_schema_sql
|
||||
from polygateway.thinking import (
|
||||
ThinkingCapability,
|
||||
ThinkingUnsupportedError,
|
||||
get_capability,
|
||||
register_capability,
|
||||
resolve_thinking,
|
||||
)
|
||||
from polygateway.types import (
|
||||
EmbeddingResponse,
|
||||
LLMResponse,
|
||||
@@ -32,9 +39,10 @@ from polygateway.types import (
|
||||
OcrTextResult,
|
||||
SourceConfig,
|
||||
TelemetryStatus,
|
||||
ThinkingObservation,
|
||||
)
|
||||
|
||||
__version__ = "1.3.0"
|
||||
__version__ = "1.3.1"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROFILES",
|
||||
@@ -63,9 +71,15 @@ __all__ = [
|
||||
"SourceDeadError",
|
||||
"SourceNotConfiguredError",
|
||||
"TelemetryStatus",
|
||||
"ThinkingCapability",
|
||||
"ThinkingObservation",
|
||||
"ThinkingUnsupportedError",
|
||||
"TransientError",
|
||||
"__version__",
|
||||
"gather_bounded",
|
||||
"get_capability",
|
||||
"register_capability",
|
||||
"register_provider",
|
||||
"resolve_thinking",
|
||||
"telemetry_schema_sql",
|
||||
]
|
||||
|
||||
@@ -26,7 +26,7 @@ from polygateway.middleware.structured import StructuredMW
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
||||
from polygateway.ports import TelemetryStatusProvider
|
||||
from polygateway.pricing import PricingTable
|
||||
from polygateway.providers import get_capability, get_provider, resolve_thinking
|
||||
from polygateway.providers import get_provider
|
||||
from polygateway.sources import (
|
||||
AdaptivePacer,
|
||||
HealthAwareSelector,
|
||||
@@ -34,6 +34,7 @@ from polygateway.sources import (
|
||||
RoundRobinSelector,
|
||||
SourceCooldownMemo,
|
||||
)
|
||||
from polygateway.thinking import get_capability, resolve_thinking
|
||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
@@ -58,7 +59,8 @@ if TYPE_CHECKING:
|
||||
TelemetryRecorder,
|
||||
Transport,
|
||||
)
|
||||
from polygateway.providers import ProviderProfile, ThinkingCapability
|
||||
from polygateway.providers import ProviderProfile
|
||||
from polygateway.thinking import ThinkingCapability
|
||||
from polygateway.types import (
|
||||
BackpressurePolicy,
|
||||
RetryPolicy,
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.types import ChatRequest, LLMResponse
|
||||
from polygateway.types import ChatRequest, LLMResponse, ThinkingObservation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
@@ -28,6 +28,31 @@ _KEY_PREFIX = "pgw:cache:"
|
||||
_RESPONSE_FIELDS = {f.name for f in dataclasses.fields(LLMResponse)}
|
||||
|
||||
|
||||
def _coerce_observation(raw: Any) -> ThinkingObservation:
|
||||
"""缓存里的三态取值 → 枚举;域外取值降级为 `UNKNOWN`,**不作废整条缓存**。
|
||||
|
||||
方向选择的理由: `_rehydrate` 对 JSON 里的**新字段**已经是宽容的(先按
|
||||
`_RESPONSE_FIELDS` 过滤),对同一字段的**新取值**却不该是致命的。真实场景是
|
||||
多个项目共用一个 Redis,先升级的那个写入了本版没有的取值,未升级的项目若把
|
||||
这些条目判成未命中,就会每次真打网关、随后覆写回旧值,两个版本互相打对方的
|
||||
缓存(表现是命中率莫名腰斩,而通用的"重建失败"文案给不出任何线索)。一个纯
|
||||
可观测性字段不该有能力废掉内容完好的缓存响应——"整条作废"留给真正破坏内容
|
||||
完整性的失败(JSON 坏了、结构化重建不过)。
|
||||
|
||||
降级到 `UNKNOWN` 而不是别的态: 它的语义恰好就是"本次判不出来",对一个本库
|
||||
读不懂的取值,这是唯一诚实的说法。
|
||||
"""
|
||||
try:
|
||||
return ThinkingObservation(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"缓存条目的 thinking_observation 取值 {!r} 不在本版取值域内(多半由更新版本的"
|
||||
"进程写入),已降级为 UNKNOWN;响应内容照常复活——可观测性字段不作废缓存",
|
||||
raw,
|
||||
)
|
||||
return ThinkingObservation.UNKNOWN
|
||||
|
||||
|
||||
def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
|
||||
|
||||
@@ -132,6 +157,11 @@ class CacheMW:
|
||||
data = json.loads(raw)
|
||||
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
|
||||
structured_data = self._rebuild_structured(fields.get("content", ""), request)
|
||||
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
|
||||
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
|
||||
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
|
||||
if "thinking_observation" in fields:
|
||||
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
|
||||
fields.update(
|
||||
cache_hit=True,
|
||||
latency_ms=0,
|
||||
|
||||
@@ -390,6 +390,8 @@ class RetryMW:
|
||||
cached_prompt_tokens=result.cached_prompt_tokens,
|
||||
model_reported=result.model_reported,
|
||||
reasoning_tokens=result.reasoning_tokens,
|
||||
# 裁定归 transport(它才见得到原始信号),本层只搬运不改判
|
||||
thinking_observation=result.thinking_observation,
|
||||
)
|
||||
|
||||
async def _emit(
|
||||
|
||||
@@ -23,7 +23,7 @@ from polygateway.errors import (
|
||||
SourceNotConfiguredError,
|
||||
)
|
||||
from polygateway.middleware.cache import digest_messages
|
||||
from polygateway.types import canonical_sampling_json, merge_sampling
|
||||
from polygateway.types import ThinkingObservation, canonical_sampling_json, merge_sampling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Mapping
|
||||
@@ -55,6 +55,31 @@ def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
|
||||
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
|
||||
|
||||
|
||||
def _normalize_observation(raw: object) -> str:
|
||||
"""三态裁定 → 落库用的裸 str;不是枚举也不在取值域时降级为 `unknown` 并告警。
|
||||
|
||||
**不写 `raw.value`**: `LLMResponse` 是无运行时校验的 frozen dataclass,下游
|
||||
(尤其迁移期的测试替身)写 `LLMResponse(..., thinking_observation="observed")`
|
||||
完全自然、`==` 比较照常成立,而 `.value` 会当场抛 `AttributeError`,被 `_record`
|
||||
的 `except Exception` 吞成一条泛化 warning —— 丢的不是这一列,是**整行**,而
|
||||
"遥测必录"是铁律。
|
||||
|
||||
域外取值同样只降级不抛: 直接 `ThinkingObservation(raw)` 会抛 `ValueError`,
|
||||
落到同一个 `except` 上、同样丢整行,那只修好了裸 str 一半(口误值对测试替身
|
||||
一样自然)。降级到 `unknown` 是诚实的——库确实判不出这个取值的含义,而单独
|
||||
一条点名取值的 warning 保证它不被掩盖(P5 不许默认值掩盖错误)。
|
||||
"""
|
||||
try:
|
||||
return ThinkingObservation(raw).value
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"thinking_observation 取值 {!r} 不在取值域内,本行降级记为 unknown"
|
||||
"(其余列照常落库);调用方应传 ThinkingObservation 成员",
|
||||
raw,
|
||||
)
|
||||
return ThinkingObservation.UNKNOWN.value
|
||||
|
||||
|
||||
def _cap_text(text: str, cap: int | None) -> str:
|
||||
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
||||
if cap is None or len(text) <= cap:
|
||||
@@ -111,6 +136,9 @@ class _AttemptUsage:
|
||||
cached_prompt_tokens: int | None = None
|
||||
model_reported: str | None = None
|
||||
reasoning_tokens: int | None = None
|
||||
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
|
||||
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
|
||||
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||
|
||||
@classmethod
|
||||
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
||||
@@ -128,11 +156,12 @@ class _AttemptUsage:
|
||||
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||
model_reported=response.model_reported,
|
||||
reasoning_tokens=response.reasoning_tokens,
|
||||
thinking_observation=response.thinking_observation,
|
||||
)
|
||||
|
||||
|
||||
class TelemetryEmitter:
|
||||
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
"""从请求与结果组装 25 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -185,6 +214,7 @@ class TelemetryEmitter:
|
||||
cached_prompt_tokens=usage.cached_prompt_tokens,
|
||||
model_reported=usage.model_reported,
|
||||
reasoning_tokens=usage.reasoning_tokens,
|
||||
thinking_observation=usage.thinking_observation,
|
||||
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
|
||||
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
|
||||
tenant_id=request.tenant_id,
|
||||
@@ -214,6 +244,8 @@ class TelemetryEmitter:
|
||||
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||
model_reported=response.model_reported,
|
||||
reasoning_tokens=response.reasoning_tokens,
|
||||
# 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果
|
||||
thinking_observation=response.thinking_observation,
|
||||
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
||||
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
@@ -247,6 +279,8 @@ class TelemetryEmitter:
|
||||
cached_prompt_tokens=None,
|
||||
model_reported=None,
|
||||
reasoning_tokens=None,
|
||||
# 无响应可言,故裁不出结果;UNKNOWN 正是"观测不到"本身,不是伪装的"没推理"
|
||||
thinking_observation=ThinkingObservation.UNKNOWN,
|
||||
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
|
||||
@@ -276,6 +310,10 @@ class TelemetryEmitter:
|
||||
model_reported: str | None,
|
||||
sampling: str | None,
|
||||
reasoning_tokens: int | None,
|
||||
# issue #16: 枚举形态进来,归一化成裸 str 后才下沉(收口在 `_record` 内)。
|
||||
# 注解是契约,但 `LLMResponse` 无运行时校验,故 `_normalize_observation`
|
||||
# 仍按外部输入防御——违约的代价不该是丢掉整行遥测
|
||||
thinking_observation: ThinkingObservation,
|
||||
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any],
|
||||
@@ -328,6 +366,10 @@ class TelemetryEmitter:
|
||||
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
|
||||
tenant_id=tenant_id or "",
|
||||
meta=_canonical_meta_json(meta),
|
||||
# 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对子类不
|
||||
# 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让
|
||||
# Postgres 那一路悄悄少一列数据
|
||||
thinking_observation=_normalize_observation(thinking_observation),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
@@ -260,13 +260,16 @@ class TelemetryStatusProvider(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class TelemetryRecorder(Protocol):
|
||||
"""遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。
|
||||
"""遥测后端;25 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16),唯一调用点是 TelemetryEmitter。
|
||||
|
||||
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||
|
||||
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
|
||||
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
|
||||
`thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value`
|
||||
的裸 `str`(`StrEnum` 是 `str` 子类,而 asyncpg 的参数编码对子类不保证接受,
|
||||
遥测写失败又只降级成 warning——PG 那一路会静默少一列数据)。
|
||||
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
|
||||
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。
|
||||
"""
|
||||
@@ -298,4 +301,5 @@ class TelemetryRecorder(Protocol):
|
||||
reasoning_tokens: int | None,
|
||||
tenant_id: str,
|
||||
meta: str,
|
||||
thinking_observation: str,
|
||||
) -> None: ...
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
|
||||
**精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享
|
||||
状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。
|
||||
|
||||
**本模块只存放声明,不做判断**: 拿这些声明去决定注入什么、响应算不算推理,
|
||||
全部在 `thinking.py`(P7 决策逻辑与状态存储分离)。
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
@@ -10,8 +13,6 @@ from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderProfile:
|
||||
@@ -71,8 +72,9 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
|
||||
strip_think_tags=False,
|
||||
),
|
||||
# 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2),
|
||||
# **直连官方端点未验证**。实测 enable_thinking / thinking 两种写法均被
|
||||
# 静默丢弃(prompt_tokens 恒定不变),reasoning_effort 才是真开关。
|
||||
# 2026-08-25 复测结论不变(findings 2026-08-25 §5);**直连官方端点未验证**。
|
||||
# 实测 enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens
|
||||
# 恒定等于基线 194),reasoning_effort 才是真开关——本片段的选型据此成立。
|
||||
# "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的
|
||||
# thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近
|
||||
# "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。
|
||||
@@ -87,144 +89,6 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
|
||||
)
|
||||
|
||||
|
||||
class ThinkingUnsupportedError(ValueError):
|
||||
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
|
||||
|
||||
是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置**
|
||||
不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期
|
||||
捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让
|
||||
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
|
||||
错误误贴成"推理开关无法满足")。
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingCapability:
|
||||
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。
|
||||
|
||||
与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变,
|
||||
数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者
|
||||
合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的
|
||||
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。
|
||||
|
||||
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。
|
||||
"""
|
||||
|
||||
can_disable: bool
|
||||
evidence: str
|
||||
|
||||
|
||||
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
|
||||
{
|
||||
"MiniMax-M3": ThinkingCapability(
|
||||
can_disable=True,
|
||||
evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变",
|
||||
),
|
||||
"MiniMax-M2.7": ThinkingCapability(
|
||||
can_disable=False,
|
||||
evidence=(
|
||||
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} "
|
||||
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
|
||||
),
|
||||
),
|
||||
"MiniMax-M2.5": ThinkingCapability(
|
||||
can_disable=False,
|
||||
evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理",
|
||||
),
|
||||
"qwen3.7-plus": ThinkingCapability(
|
||||
can_disable=True,
|
||||
evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)",
|
||||
),
|
||||
"deepseek-v4-pro": ThinkingCapability(
|
||||
can_disable=True,
|
||||
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)",
|
||||
),
|
||||
}
|
||||
)
|
||||
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
|
||||
|
||||
|
||||
def get_capability(
|
||||
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
||||
) -> ThinkingCapability | None:
|
||||
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
|
||||
|
||||
与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
|
||||
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。
|
||||
"""
|
||||
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
|
||||
|
||||
|
||||
def register_capability(
|
||||
model: str,
|
||||
capability: ThinkingCapability,
|
||||
*,
|
||||
base: Mapping[str, ThinkingCapability] | None = None,
|
||||
) -> dict[str, ThinkingCapability]:
|
||||
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
||||
table = dict(DEFAULT_CAPABILITIES if base is None else base)
|
||||
table[model] = capability
|
||||
return table
|
||||
|
||||
|
||||
def resolve_thinking(
|
||||
profile: ProviderProfile,
|
||||
capability: ThinkingCapability | None,
|
||||
enable_thinking: bool | None,
|
||||
*,
|
||||
model: str,
|
||||
warn_unregistered: bool = True,
|
||||
) -> Mapping[str, Any]:
|
||||
"""三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。
|
||||
|
||||
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
|
||||
`RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时
|
||||
无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有
|
||||
`can_disable` 可读,故 Phase 3 必须先于 Phase 4。
|
||||
|
||||
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
|
||||
`capability` 为 None(未登记)时无从从别处取得模型名。
|
||||
|
||||
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次
|
||||
调用再喊只会刷屏。判定结果不受此参数影响。
|
||||
"""
|
||||
# Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档
|
||||
if enable_thinking is None:
|
||||
return {}
|
||||
slot = profile.thinking_on if enable_thinking else profile.thinking_off
|
||||
direction = "thinking_on" if enable_thinking else "thinking_off"
|
||||
# Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方
|
||||
if slot is None:
|
||||
raise ThinkingUnsupportedError(
|
||||
f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): "
|
||||
f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态,"
|
||||
f"或改用 SourceConfig.extra_body 直接下发供应商参数"
|
||||
)
|
||||
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
|
||||
if capability is None:
|
||||
if warn_unregistered:
|
||||
_warn_unregistered(model, profile, slot)
|
||||
return slot
|
||||
# Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说
|
||||
if enable_thinking is False and not capability.can_disable:
|
||||
raise ThinkingUnsupportedError(
|
||||
f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: "
|
||||
f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——"
|
||||
f"需要关闭思维链请换用支持关闭的模型"
|
||||
)
|
||||
return slot
|
||||
|
||||
|
||||
def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None:
|
||||
logger.warning(
|
||||
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};"
|
||||
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
|
||||
model,
|
||||
profile.name,
|
||||
dict(slot),
|
||||
)
|
||||
|
||||
|
||||
def get_provider(
|
||||
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
|
||||
) -> ProviderProfile:
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||
|
||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
||||
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 24 个 INSERT 字段 +
|
||||
`created_at` = 25;列数断言一律按物理列数写,两套口径混用是最易错处。
|
||||
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 25 个 INSERT 字段 +
|
||||
`created_at` = 26;列数断言一律按物理列数写,两套口径混用是最易错处。
|
||||
|
||||
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
||||
互不依赖(import-linter 契约执法)。
|
||||
@@ -50,7 +50,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
thinking_observation TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -80,7 +81,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
thinking_observation TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -95,6 +97,9 @@ SQLITE_BACKFILL = (
|
||||
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
|
||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||
("meta", "TEXT NOT NULL DEFAULT '{}'"),
|
||||
# 可空: 补列之前的行没有裁定结果,NULL 如实表达"这行根本没记过这件事",
|
||||
# 与哨兵串 'unknown'(库确实裁过但判不出来)是两回事,不得混同
|
||||
("thinking_observation", "TEXT"),
|
||||
)
|
||||
|
||||
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
||||
@@ -107,6 +112,8 @@ _PG_BACKFILL_DECLS = (
|
||||
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
|
||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
|
||||
# 可空,理由同 SQLITE_BACKFILL 同名项
|
||||
("thinking_observation", "TEXT"),
|
||||
)
|
||||
|
||||
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
|
||||
@@ -143,6 +150,7 @@ COLUMNS = (
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
"thinking_observation",
|
||||
)
|
||||
|
||||
_COLUMN_SET = frozenset(COLUMNS)
|
||||
|
||||
@@ -143,7 +143,7 @@ class SQLiteRecorder:
|
||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。
|
||||
"""写一行遥测;字段集合即 25 字段冻结签名(ports.TelemetryRecorder)。
|
||||
|
||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""推理这件事的全部**决策**: 请求侧注入形态、响应侧结果裁定、二者的对账。
|
||||
|
||||
与 `providers.py` 的分工: 那里是**注册表**(provider 长什么样,静态声明的存放
|
||||
与查找),这里是**决策**(拿声明和响应做判断)。P7"决策逻辑与状态存储分离"。
|
||||
|
||||
本模块**不定义** `ThinkingObservation` —— 它是 `LLMResponse` 的字段类型,归最
|
||||
内层 `types.py`;定义在这里会让 `types.py` 反向 import 决策模块(依赖铁律)。
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.providers import ProviderProfile
|
||||
from polygateway.types import ThinkingObservation
|
||||
|
||||
|
||||
def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation:
|
||||
"""由多信号裁定推理是否发生;判据按**证据硬度**排序(issue #16/#17)。
|
||||
|
||||
推理正文是事实本身,`reasoning_tokens` 是对事实的转述——转述缺失时事实仍然
|
||||
作数。2026-08-25 实测: MiniMax 这一路已不再返回
|
||||
`usage.completion_tokens_details`,而同一次调用里库拿得到 185 字符推理正文;
|
||||
只认 token 数的判据会把这种情形误判成"没推理"。
|
||||
|
||||
正文判据取 `strip()` 而非 truthy: 网关响应是外部输入,纯空白串不是证据(P5)。
|
||||
|
||||
判不出来时返回 `UNKNOWN` 而非 `ABSENT`——**不许把"没看见"说成"没发生"**。
|
||||
"""
|
||||
if thinking.strip():
|
||||
return ThinkingObservation.OBSERVED
|
||||
# 负数与 None 同档: `ABSENT` 是"上游明确上报未推理"这个最强的正面结论,坏
|
||||
# 数据给不出它。当前 transport 已在边界把负数归 None,这里仍要自己闭合——本
|
||||
# 函数对外承诺"外部输入校验后使用",第二个 transport 直接填该值时,漏判会
|
||||
# 给出一个方向相反的强结论(P5)
|
||||
if reasoning_tokens is None or reasoning_tokens < 0:
|
||||
return ThinkingObservation.UNKNOWN
|
||||
return ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
|
||||
|
||||
|
||||
class ThinkingUnsupportedError(ValueError):
|
||||
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
|
||||
|
||||
是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置**
|
||||
不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期
|
||||
捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让
|
||||
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
|
||||
错误误贴成"推理开关无法满足")。
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingCapability:
|
||||
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。
|
||||
|
||||
与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变,
|
||||
数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者
|
||||
合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的
|
||||
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。
|
||||
|
||||
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。
|
||||
"""
|
||||
|
||||
can_disable: bool
|
||||
evidence: str
|
||||
|
||||
|
||||
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
|
||||
{
|
||||
"MiniMax-M3": ThinkingCapability(
|
||||
can_disable=True,
|
||||
evidence=(
|
||||
"2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变;"
|
||||
"2026-08-25 复测依然成立(prompt 194 = 基线、completion 3、无推理正文)。"
|
||||
"两条限制(findings 2026-08-25-thinking-observability-regression §3.1/§5): "
|
||||
"① 非流式路径观测不到推理信号——推理已计费,但正文与 usage 明细都不回传;"
|
||||
"② enable_thinking / thinking:{type:enabled} 对本模型无效,仅 reasoning_effort 是真开关"
|
||||
),
|
||||
),
|
||||
"MiniMax-M2.7": ThinkingCapability(
|
||||
can_disable=False,
|
||||
evidence=(
|
||||
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} "
|
||||
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
|
||||
),
|
||||
),
|
||||
"MiniMax-M2.5": ThinkingCapability(
|
||||
can_disable=False,
|
||||
evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理",
|
||||
),
|
||||
"qwen3.7-plus": ThinkingCapability(
|
||||
can_disable=True,
|
||||
evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)",
|
||||
),
|
||||
"deepseek-v4-pro": ThinkingCapability(
|
||||
can_disable=True,
|
||||
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)",
|
||||
),
|
||||
}
|
||||
)
|
||||
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
|
||||
|
||||
|
||||
def get_capability(
|
||||
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
||||
) -> ThinkingCapability | None:
|
||||
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
|
||||
|
||||
与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
|
||||
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。
|
||||
"""
|
||||
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
|
||||
|
||||
|
||||
def register_capability(
|
||||
model: str,
|
||||
capability: ThinkingCapability,
|
||||
*,
|
||||
base: Mapping[str, ThinkingCapability] | None = None,
|
||||
) -> dict[str, ThinkingCapability]:
|
||||
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
||||
table = dict(DEFAULT_CAPABILITIES if base is None else base)
|
||||
table[model] = capability
|
||||
return table
|
||||
|
||||
|
||||
def resolve_thinking(
|
||||
profile: ProviderProfile,
|
||||
capability: ThinkingCapability | None,
|
||||
enable_thinking: bool | None,
|
||||
*,
|
||||
model: str,
|
||||
warn_unregistered: bool = True,
|
||||
) -> Mapping[str, Any]:
|
||||
"""三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。
|
||||
|
||||
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
|
||||
`RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时
|
||||
无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有
|
||||
`can_disable` 可读,故 Phase 3 必须先于 Phase 4。
|
||||
|
||||
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
|
||||
`capability` 为 None(未登记)时无从从别处取得模型名。
|
||||
|
||||
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次
|
||||
调用再喊只会刷屏。判定结果不受此参数影响。
|
||||
"""
|
||||
# Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档
|
||||
if enable_thinking is None:
|
||||
return {}
|
||||
slot = profile.thinking_on if enable_thinking else profile.thinking_off
|
||||
direction = "thinking_on" if enable_thinking else "thinking_off"
|
||||
# Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方
|
||||
if slot is None:
|
||||
raise ThinkingUnsupportedError(
|
||||
f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): "
|
||||
f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态,"
|
||||
f"或改用 SourceConfig.extra_body 直接下发供应商参数"
|
||||
)
|
||||
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
|
||||
if capability is None:
|
||||
if warn_unregistered:
|
||||
_warn_unregistered(model, profile, slot)
|
||||
return slot
|
||||
# Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说
|
||||
if enable_thinking is False and not capability.can_disable:
|
||||
raise ThinkingUnsupportedError(
|
||||
f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: "
|
||||
f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——"
|
||||
f"需要关闭思维链请换用支持关闭的模型"
|
||||
)
|
||||
return slot
|
||||
|
||||
|
||||
def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None:
|
||||
logger.warning(
|
||||
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};"
|
||||
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
|
||||
model,
|
||||
profile.name,
|
||||
dict(slot),
|
||||
)
|
||||
|
||||
|
||||
def reconcile_thinking(
|
||||
*,
|
||||
enable_thinking: bool | None,
|
||||
observation: ThinkingObservation,
|
||||
capability: ThinkingCapability | None,
|
||||
model: str,
|
||||
) -> str | None:
|
||||
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
|
||||
|
||||
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
|
||||
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
|
||||
|
||||
**只判定、不打日志**: 文案作为返回值交给调用点,单测才能直接断言告警内容,
|
||||
而不必去解析日志格式;节流也才能留在握有实例状态的 transport 里。
|
||||
|
||||
**不抛错**: 一次观测不足以否决一次成功的调用;可观测性属遥测方向,降级即
|
||||
warning(P5 的"报错而非放行"只约束限流/熔断)。矛盾结果已随 `LLMResponse`
|
||||
与遥测落地,处置权归下游。
|
||||
"""
|
||||
# Phase 1: 调用方不表态 —— 没提要求就无从谈"违背"
|
||||
if enable_thinking is None:
|
||||
return None
|
||||
# Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警
|
||||
# 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警
|
||||
if enable_thinking is False:
|
||||
if observation is not ThinkingObservation.OBSERVED:
|
||||
return None
|
||||
return _off_but_observed(model, capability)
|
||||
# Phase 3: 要求开启 —— ABSENT 是正面证伪,UNKNOWN 是"看不见",两者文案不可混
|
||||
if observation is ThinkingObservation.ABSENT:
|
||||
return (
|
||||
f"模型 {model!r} 的 enable_thinking=True 未生效: 已注入开启参数,"
|
||||
f"上游却明确上报本次未推理(reasoning_tokens=0)"
|
||||
)
|
||||
if observation is ThinkingObservation.UNKNOWN:
|
||||
return (
|
||||
f"模型 {model!r} 的 enable_thinking=True 无法确认是否生效: 已注入开启参数,"
|
||||
f"但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。"
|
||||
f"若走的是非流式路径,推理内容可能已计费却不回传"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _off_but_observed(model: str, capability: ThinkingCapability | None) -> str:
|
||||
"""关闭请求未被满足的两种说法;登记与否决定该说哪一句。
|
||||
|
||||
两者必须分开: `resolve_thinking` 对未登记模型的告警是**事前猜测**,这里是
|
||||
**事后实证**。对未登记模型说"能力表声称可关闭"是错的——它根本没登记。
|
||||
"""
|
||||
if capability is None:
|
||||
return (
|
||||
f"模型 {model!r} 的 enable_thinking=False 未被满足: 实测观测到推理发生,"
|
||||
f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。"
|
||||
f"请实测后用 register_capability 登记其真实能力"
|
||||
)
|
||||
return (
|
||||
f"模型 {model!r} 的 enable_thinking=False 未被满足: 实测观测到推理发生,"
|
||||
f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。"
|
||||
f"能力表可能已过期——请复测后用 register_capability 更新登记"
|
||||
)
|
||||
@@ -14,6 +14,7 @@ import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.errors import (
|
||||
PolyGatewayError,
|
||||
@@ -22,15 +23,16 @@ from polygateway.errors import (
|
||||
SourceDeadError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.providers import (
|
||||
ProviderProfile,
|
||||
from polygateway.providers import ProviderProfile, get_provider
|
||||
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||
from polygateway.thinking import (
|
||||
ThinkingCapability,
|
||||
ThinkingUnsupportedError,
|
||||
get_capability,
|
||||
get_provider,
|
||||
observe_thinking,
|
||||
reconcile_thinking,
|
||||
resolve_thinking,
|
||||
)
|
||||
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
||||
|
||||
@@ -320,6 +322,12 @@ class OpenAICompatTransport:
|
||||
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
|
||||
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
|
||||
self._warned_models: set[str] = set()
|
||||
# 对账告警独立节流,**不复用** `_warned_models`: 两者语义不同(那个 set 记
|
||||
# 的是"未登记能力已告警过",这个记的是"某源某方向的矛盾已告警过"),共用
|
||||
# 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略,
|
||||
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
|
||||
# 分开维护的理由是语义,不是碰撞)
|
||||
self._warned_mismatches: set[tuple[str, str, bool | None]] = set()
|
||||
self._client_factory = client_factory or _default_client_factory
|
||||
self._clients: dict[str, httpx.AsyncClient] = {}
|
||||
|
||||
@@ -390,8 +398,9 @@ class OpenAICompatTransport:
|
||||
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
||||
try:
|
||||
if stream:
|
||||
return await self._complete_stream(client, url, payload, source, profile)
|
||||
return await self._complete_once(client, url, payload, source, profile)
|
||||
result = await self._complete_stream(client, url, payload, source, profile)
|
||||
else:
|
||||
result = await self._complete_once(client, url, payload, source, profile)
|
||||
except StreamLivenessTimeout as exc:
|
||||
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
@@ -399,6 +408,40 @@ class OpenAICompatTransport:
|
||||
except httpx.TransportError as exc:
|
||||
# VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8)
|
||||
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
|
||||
# 此处是唯一同时握有请求方向与响应结果的地方,对账只能落在这里
|
||||
self._warn_on_thinking_mismatch(source, result)
|
||||
return result
|
||||
|
||||
def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None:
|
||||
"""声明与观测矛盾即 warning;按 (source, model, direction) 节流,同组合只喊一次。
|
||||
|
||||
三段缺一不可。**方向**: 同一模型的开、关两档是两个独立的矛盾。**源名**:
|
||||
多源多账号是本库的核心场景,同一 model 跨 N 个源是常态,而每个源背后是
|
||||
独立的账号/网关,一个源的行为不代表另一个——漏掉源名,5 个源里第一个出
|
||||
问题的喊完一次,其余四个永久静音。逐次调用刷屏会把告警变成噪声,噪声等于
|
||||
没有告警。
|
||||
|
||||
**先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串,
|
||||
而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费。
|
||||
"""
|
||||
key = (source.name, source.model, source.enable_thinking)
|
||||
if key in self._warned_mismatches:
|
||||
return
|
||||
message = reconcile_thinking(
|
||||
enable_thinking=source.enable_thinking,
|
||||
observation=result.thinking_observation,
|
||||
capability=get_capability(source.model, table=self._capabilities),
|
||||
model=source.model,
|
||||
)
|
||||
if message is None:
|
||||
return
|
||||
self._warned_mismatches.add(key)
|
||||
# 源名拼在调用点而不是加进 `reconcile_thinking` 的签名: 那是纯判定函数,
|
||||
# 输入只该含判定依据(声明/观测/能力/模型),源名是**定位信息**,进不了判据。
|
||||
# 单参数传入 loguru: 文案里带 `thinking:{type:disabled}` 这类字面花括号
|
||||
# (能力表 evidence),将来有人给这行加个格式化参数就会炸在成功调用的返回
|
||||
# 路径上(与 telemetry/sqlite.py 的缺列告警同一先例)
|
||||
logger.warning("源 {} —— {}", source.name, message)
|
||||
|
||||
async def embed(
|
||||
self, *, texts: list[str], source: SourceConfig, call_id: str
|
||||
@@ -460,6 +503,7 @@ class OpenAICompatTransport:
|
||||
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
|
||||
self._reject_empty_completion(content, source)
|
||||
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
|
||||
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
|
||||
return TransportResult(
|
||||
content=content,
|
||||
thinking=thinking,
|
||||
@@ -471,7 +515,12 @@ class OpenAICompatTransport:
|
||||
raw={"usage": sink.get("usage")},
|
||||
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
||||
model_reported=_coerce_model_reported(sink.get("model")),
|
||||
reasoning_tokens=_coerce_reasoning_tokens(sink.get("usage")),
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
# 两条组装路径必须同口径裁定: 只在一条路径上给结论,下游就得靠
|
||||
# "这次是不是流式"去猜可观测性,那正是 issue #16/#17 的根因形态
|
||||
thinking_observation=observe_thinking(
|
||||
thinking=thinking, reasoning_tokens=reasoning_tokens
|
||||
),
|
||||
)
|
||||
|
||||
def _check_done(
|
||||
@@ -545,6 +594,7 @@ class OpenAICompatTransport:
|
||||
)
|
||||
self._reject_empty_completion(content, source)
|
||||
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
||||
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
|
||||
return TransportResult(
|
||||
content=content,
|
||||
thinking=thinking,
|
||||
@@ -556,7 +606,12 @@ class OpenAICompatTransport:
|
||||
raw={"usage": body.get("usage")},
|
||||
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
||||
model_reported=_coerce_model_reported(body.get("model")),
|
||||
reasoning_tokens=_coerce_reasoning_tokens(body.get("usage")),
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
# 本路径的裁定多半落 UNKNOWN(M3 实测: 推理已计费却正文与 details 双
|
||||
# 缺)。如实标记"观测不到",好过让下游误读成"没推理"
|
||||
thinking_observation=observe_thinking(
|
||||
thinking=thinking, reasoning_tokens=reasoning_tokens
|
||||
),
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@ import math
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
@@ -166,6 +167,27 @@ def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
|
||||
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
class ThinkingObservation(StrEnum):
|
||||
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
|
||||
|
||||
三态**不可折叠为布尔**: `UNKNOWN` 是"本次无任何信号,判不出来",与
|
||||
`ABSENT`("上游明确上报了未推理")语义不同。把前者折叠进后者,正是
|
||||
`reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",而实际
|
||||
可能推理了且已计费(MiniMax-M3 非流式实测: completion 53 vs 关闭档 3,
|
||||
推理正文与 usage 明细双双不回传)。
|
||||
|
||||
裁定由 `thinking.observe_thinking` 做,本类只是取值域。**枚举定义在最内层
|
||||
而非决策层**: 它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让
|
||||
`types.py` 反向 import 决策模块(P7 依赖铁律)。
|
||||
|
||||
取值进遥测落库,改名即造成历史数据断层。
|
||||
"""
|
||||
|
||||
OBSERVED = "observed"
|
||||
ABSENT = "absent"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMResponse:
|
||||
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
||||
@@ -202,7 +224,21 @@ 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)。
|
||||
|
||||
`UNKNOWN` = **本次无任何信号,判不出来**,**不是**"没推理"——把两者折叠
|
||||
是 `reasoning_tokens=None` 制造的老歧义。典型来源: 非流式路径下部分模型
|
||||
推理已计费却既不回传正文也不回传 `completion_tokens_details`(MiniMax-M3
|
||||
实测开启档 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。
|
||||
要判"确实没推理"只认 `ABSENT`(上游明确上报 0)。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -299,6 +335,11 @@ class TransportResult:
|
||||
cached_prompt_tokens: int | None = None
|
||||
model_reported: str | None = None
|
||||
reasoning_tokens: int | None = None
|
||||
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||
"""本次调用"推理是否真的发生"的裁定(issue #16/#17),由 transport 组装时填。
|
||||
|
||||
默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding 等)沉默
|
||||
时,不该替上游做出"没推理"这个它从未做过的声明。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
+123
-47
@@ -1,21 +1,27 @@
|
||||
"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。
|
||||
"""真实 API 验证推理开关与推理可观测性(issue #5 + #6;判据于 #16/#17 重建)。
|
||||
|
||||
本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径,
|
||||
验证不了"这个参数在这个模型上到底关没关掉推理"。
|
||||
|
||||
两条判据纪律(来自 findings §4c 的实测教训):
|
||||
三条判据纪律(第 1、2 条来自 findings §4c,第 1 条的推翻与第 3 条来自
|
||||
`findings/2026-08-25-thinking-observability-regression.md`):
|
||||
|
||||
1. **判别量只能是 `reasoning_tokens`,不能是 `completion_tokens`。** 两档的输出
|
||||
长度分布**是重叠的**: 实测关闭档最高 46 token(模型偶尔把解题过程写进正文),
|
||||
开启档最低 13 token(medium 档想得少的那几轮),按长度阈值判两边都会误判。
|
||||
而 `reasoning_tokens` 在同一批 30 轮里干净分开——关闭 15/15 为 None,
|
||||
开启 15/15 大于 0。
|
||||
2. **另配一个不含魔数的确定性锚点**(见 L2b): 同一模型上,关闭档的
|
||||
1. **判别量是库裁定的三态 `thinking_observation`,既不是 `reasoning_tokens`
|
||||
也不是 `completion_tokens`。** 长度判据早已排除: 两档的输出长度分布**是
|
||||
重叠的**(实测关闭档最高 46 token、开启档最低 13 token),按阈值判两边都会
|
||||
误判。而 `reasoning_tokens` 这个曾经"干净分开"的判据也已失效——MiniMax
|
||||
这一路上游不再返回 `usage.completion_tokens_details`,该字段恒 `None`;同一
|
||||
次调用里库明明拿得到 185 字符推理正文,单看 token 计数却把"推理正常"读成
|
||||
"没推理"(2026-08-25 findings §3.4/结论③,四条用例因此假红)。三态裁定同时
|
||||
看正文与计数: **正文是事实本身,token 计数只是对事实的转述**。
|
||||
2. **另配一个不含魔数的确定性锚点**(见 L2b、L5): 同一模型上,关闭档的
|
||||
`prompt_tokens` 严格小于开启档——供应商在开启时注入了推理指令,输入侧
|
||||
token 数随之变大。这是相对比较,不硬编码任何具体数值。
|
||||
3. **关闭方向要求每轮满足,开启方向只要求多数轮满足。** 中转在上游不返回
|
||||
usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c),
|
||||
开启方向因此可能偶尔观测不到;关闭方向不受影响。
|
||||
token 数随之变大。这是相对比较,不硬编码任何具体数值;且它不依赖上游是否
|
||||
回传推理正文,所以在"观测不到推理"的非流式路径上依然作数。
|
||||
3. **`UNKNOWN` 不等于"没推理",不能拿它判红。** 关闭方向要求每轮"未观测到
|
||||
推理"(`UNKNOWN` 计入满足——它没有证伪力),其证伪力来自: 模型若偷偷推理了,
|
||||
可观测路径会翻成 `OBSERVED`。开启方向只要求多数轮 `OBSERVED`;M3 非流式
|
||||
路径整片观测不到,该档由 L5 用另一套断言覆盖。
|
||||
|
||||
源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。
|
||||
"""
|
||||
@@ -30,22 +36,23 @@ from pathlib import Path
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from polygateway import GatewayClient, GatewaySettings
|
||||
from polygateway import GatewayClient, GatewaySettings, ThinkingObservation
|
||||
from polygateway.errors import (
|
||||
AllSourcesExhausted,
|
||||
RequestRejectedError,
|
||||
SourceDeadError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.providers import DEFAULT_CAPABILITIES, get_capability
|
||||
from polygateway.thinking import DEFAULT_CAPABILITIES, get_capability
|
||||
|
||||
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
|
||||
|
||||
# slow: 本组 137 次真实调用、约 7 分钟,且判据是统计性的——网络抖动会让它偶发
|
||||
# 失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci` 会把测试
|
||||
# 变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow` 显式真跑并
|
||||
# 存档报告。"不自动门控"不等于"可跳过"。
|
||||
# slow: 本组 92 次真实调用、约 4 分半(2026-08-26 判据换三态后实测;此前记的
|
||||
# "137 次、约 7 分钟"已被证伪,别照旧值估 CI 预算),且判据是统计性的——网络抖动
|
||||
# 会让它偶发失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci`
|
||||
# 会把测试变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow`
|
||||
# 显式真跑并存档报告。"不自动门控"不等于"可跳过"。
|
||||
pytestmark = [
|
||||
pytest.mark.slow,
|
||||
pytest.mark.skipif(
|
||||
@@ -60,9 +67,6 @@ _ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))
|
||||
# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里
|
||||
_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。"
|
||||
|
||||
_ON_MIN_COMPLETION = 100
|
||||
"""仅用于 `reasoning_tokens` 被中转吃掉时的退路;关闭方向不设长度门(见 `_reasoning_off`)。"""
|
||||
|
||||
_ROWS: list[dict] = []
|
||||
|
||||
# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。
|
||||
@@ -107,6 +111,10 @@ async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -
|
||||
"prompt_tokens": resp.prompt_tokens,
|
||||
"completion_tokens": resp.completion_tokens,
|
||||
"reasoning_tokens": resp.reasoning_tokens,
|
||||
# 结论与证据一起入报告: 只记 observation 会让"为什么这么判"
|
||||
# 不可复核,而 thinking_chars 正是本次改判的直接证据
|
||||
"thinking_observation": resp.thinking_observation,
|
||||
"thinking_chars": len(resp.thinking),
|
||||
"content": resp.content[:60],
|
||||
}
|
||||
)
|
||||
@@ -128,26 +136,32 @@ def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -
|
||||
|
||||
|
||||
def _reasoning_off(obs: dict) -> bool:
|
||||
"""关闭方向: 只看 reasoning_tokens。
|
||||
"""关闭方向: 只要没观测到推理即算满足。
|
||||
|
||||
`UNKNOWN` 计入满足是有意的: 它没有证伪力(本次无任何信号,判不出来),拿它
|
||||
判红等于每次关闭调用都喊一遍。本判据真正的证伪力在于——模型若偷偷推理了,
|
||||
可观测路径会把裁定翻成 `OBSERVED`。
|
||||
|
||||
**刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做
|
||||
"只输出两个数字",把解题过程写进了正文),而那是正文不是推理。加长度门只会
|
||||
把这种正常波动误判成"没关掉"。
|
||||
"""
|
||||
return obs["reasoning_tokens"] in (None, 0)
|
||||
return obs["thinking_observation"] != ThinkingObservation.OBSERVED
|
||||
|
||||
|
||||
def _reasoning_on(obs: dict) -> bool:
|
||||
"""开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据。
|
||||
"""开启方向: 观测到推理即为真。
|
||||
|
||||
不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大
|
||||
(实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理。
|
||||
仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据。
|
||||
判据从 `reasoning_tokens` 换成库的三态裁定,因为 MiniMax 这一路已不再上报
|
||||
`completion_tokens_details`(2026-08-25 findings 结论②),该字段恒 `None`;
|
||||
而库在同一次调用里拿得到 185 字符推理正文(findings §3.4)——旧判据看不见
|
||||
它,L2/L3b/L4/L5 四条因此假红。
|
||||
|
||||
也不能退回 completion_tokens 当判据: medium 档的推理量方差极大(实测 15 轮
|
||||
跨 7-170 token),两档分布还与关闭档重叠,按长度阈值判会把"推理了但想得少"
|
||||
误判成没推理。
|
||||
"""
|
||||
reasoning = obs["reasoning_tokens"]
|
||||
if reasoning is not None:
|
||||
return reasoning > 0
|
||||
return obs["completion_tokens"] > _ON_MIN_COMPLETION
|
||||
return obs["thinking_observation"] == ThinkingObservation.OBSERVED
|
||||
|
||||
|
||||
def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str):
|
||||
@@ -163,14 +177,18 @@ def _write_report():
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = _OUT_DIR / f"test_thinking_live_{ts}.md"
|
||||
lines = [
|
||||
"# 推理开关与 reasoning_tokens 真实 API 验证",
|
||||
"# 推理开关与推理可观测性真实 API 验证",
|
||||
"",
|
||||
f"- 时间: {ts}",
|
||||
f"- 每档轮数: {_ROUNDS}",
|
||||
"- 关闭判据: **每轮** reasoning_tokens in (None, 0);刻意不设输出长度上限"
|
||||
"(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
|
||||
f"- 开启判据: **多数轮** reasoning_tokens > 0(被中转吃掉时退回 completion > {_ON_MIN_COMPLETION})",
|
||||
"- 确定性锚点(L2b): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
|
||||
"- 判别量: 库裁定的三态 `thinking_observation`(OBSERVED/ABSENT/UNKNOWN),"
|
||||
"由推理正文与 reasoning_tokens 共同裁定 —— 正文是事实,token 计数只是转述",
|
||||
"- 关闭判据: **每轮** observation != OBSERVED(UNKNOWN 计入满足,它没有证伪力);"
|
||||
"刻意不设输出长度上限(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
|
||||
"- 开启判据: **多数轮** observation == OBSERVED",
|
||||
"- 确定性锚点(L2b、L5): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
|
||||
"- L5(非流式): M3 该路径推理已计费却不回传正文,故不断言「观测到推理」,"
|
||||
"改断锚点可分 + 开启档不被误判为 ABSENT",
|
||||
"",
|
||||
"## 矩阵结论",
|
||||
"",
|
||||
@@ -206,7 +224,7 @@ class TestMiniMaxM3:
|
||||
"L1",
|
||||
"enable_thinking=False(流式)",
|
||||
"PASS" if len(offs) == len(obs) else "FAIL",
|
||||
f"{len(offs)}/{len(obs)} 轮确认未推理",
|
||||
f"{len(offs)}/{len(obs)} 轮未观测到推理",
|
||||
obs,
|
||||
)
|
||||
assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}"
|
||||
@@ -256,7 +274,7 @@ class TestMiniMaxM3:
|
||||
"L3",
|
||||
"enable_thinking=None(不干预,基线)",
|
||||
"PASS" if len(quiet) == len(obs) else "FAIL",
|
||||
f"{len(quiet)}/{len(obs)} 轮未推理(M3 默认档本就不推理)",
|
||||
f"{len(quiet)}/{len(obs)} 轮未观测到推理(M3 默认档本就不推理)",
|
||||
obs,
|
||||
)
|
||||
assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}"
|
||||
@@ -271,6 +289,12 @@ class TestMiniMaxM3:
|
||||
判别方法: 发一个**非法值**。若未知值会被静默丢弃,它的表现应与"不注入"
|
||||
一致(不推理);实测它反而开启了推理,说明网关认这个键、只是不认这个值。
|
||||
既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值。
|
||||
|
||||
**该手法不可移植,只对"认这个键但不校验值"的 provider 成立**: minimax 对
|
||||
非法 `reasoning_effort` 返回 200 且照常推理(2026-08-25 findings §5:
|
||||
prompt 207,介于基线 194 与 medium 216 之间,走了第三条模板路径);而 qwen
|
||||
对同样的值直接返回 **HTTP 400**。把本用例套到 qwen 那类会校验值的 provider
|
||||
上,拿到的会是异常而非"不推理",是假红。
|
||||
"""
|
||||
rounds = max(3, _ROUNDS // 3)
|
||||
bogus = await _run_rounds(
|
||||
@@ -318,23 +342,50 @@ class TestMiniMaxM3:
|
||||
)
|
||||
assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}"
|
||||
|
||||
async def test_l5_non_stream_path_matches_stream(self):
|
||||
"""非流式快路径独立于流式实现,采集与注入都要各自验一遍。"""
|
||||
async def test_l5_non_stream_path_is_distinguishable_and_honestly_unknown(self):
|
||||
"""非流式快路径: 参数确实到达了模型,而推理信号被如实标成"观测不到"。
|
||||
|
||||
**本用例不能断言"非流式开启档观测到推理"——那永远不成立**: M3 在非流式
|
||||
路径下推理段确实产生并计费(2026-08-25 findings §3.4: 开启档 completion 53
|
||||
vs 关闭档 3),但 `message` 里没有 `reasoning_content`、`usage` 里也没有
|
||||
`completion_tokens_details`,推理内容整体不回传。**这是上游行为,库修不了;
|
||||
库能做也必须做的是让它可见**——下游在为看不见的东西付费,不该由库替它
|
||||
沉默。
|
||||
|
||||
故改断两件在非流式下真实成立的事:
|
||||
其一 `prompt_tokens` 锚点仍把两档分开(判据形态照抄 L2b,证明注入到达了模型,
|
||||
排除"非流式路径把参数弄丢了"这一伪解释);
|
||||
其二开启档的裁定**不是 `ABSENT`**——`ABSENT` 的语义是"上游明确上报未推理",
|
||||
而实情是"判不出来"(`UNKNOWN`),库若把后者伪装成前者,正是 issue #16/#17 里
|
||||
那个静默错觉。这里断 `!= ABSENT` 而非 `== UNKNOWN`,是为了留出上游哪天开始
|
||||
回传正文的余地: 那时裁定会翻成 `OBSERVED`,是好事,不该让它把测试判红。
|
||||
"""
|
||||
rounds = max(3, _ROUNDS // 2)
|
||||
off = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=False)
|
||||
on = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=True)
|
||||
offs = [o for o in off if _reasoning_off(o)]
|
||||
ons = [o for o in on if _reasoning_on(o)]
|
||||
ok = len(offs) == len(off) and len(ons) * 2 > len(on)
|
||||
off_max = max(o["prompt_tokens"] for o in off)
|
||||
on_min = min(o["prompt_tokens"] for o in on)
|
||||
not_absent = [o for o in on if o["thinking_observation"] != ThinkingObservation.ABSENT]
|
||||
on_states = Counter(str(o["thinking_observation"]) for o in on)
|
||||
ok = len(offs) == len(off) and off_max < on_min and len(not_absent) == len(on)
|
||||
_record(
|
||||
"L5",
|
||||
"非流式路径重跑 L1/L2",
|
||||
"非流式: prompt 锚点可分 + 开启档如实标 UNKNOWN 而非 ABSENT",
|
||||
"PASS" if ok else "FAIL",
|
||||
f"关闭 {len(offs)}/{len(off)} 轮,开启 {len(ons)}/{len(on)} 轮",
|
||||
f"关闭 {len(offs)}/{len(off)} 轮未观测到推理;"
|
||||
f"关闭档 prompt 最大 {off_max} < 开启档最小 {on_min};"
|
||||
f"开启档裁定分布 {dict(on_states)}",
|
||||
off + on,
|
||||
)
|
||||
assert len(offs) == len(off), f"非流式关闭方向未满足: {off}"
|
||||
assert len(ons) * 2 > len(on), f"非流式开启方向未满足: {on}"
|
||||
assert off_max < on_min, (
|
||||
f"非流式两档 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): "
|
||||
f"开启参数可能没到达模型"
|
||||
)
|
||||
assert len(not_absent) == len(on), (
|
||||
f"非流式开启档被裁成 ABSENT(声称上游明确上报未推理),而实情是观测不到: {on}"
|
||||
)
|
||||
|
||||
|
||||
class TestOtherProviders:
|
||||
@@ -357,11 +408,36 @@ class TestOtherProviders:
|
||||
matrix,
|
||||
desc,
|
||||
"PASS" if len(offs) == len(obs) else "FAIL",
|
||||
f"{len(offs)}/{len(obs)} 轮确认未推理",
|
||||
f"{len(offs)}/{len(obs)} 轮未观测到推理",
|
||||
obs,
|
||||
)
|
||||
assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}"
|
||||
|
||||
async def test_qwen_enabled_is_observed(self):
|
||||
"""设计 §14 验收: qwen 开启档必须裁定为 `OBSERVED`,不是 `UNKNOWN`。
|
||||
|
||||
本条是三态裁定的**跨供应商对照组**: MiniMax 这一路两个信号都可能缺失
|
||||
(非流式档整片 `UNKNOWN`),若只按它调判据,很容易把"观测不到"当成常态;
|
||||
qwen 在同一网关同一 key 上照常返回推理信号(findings 2026-08-25 §2),
|
||||
故这里能且必须要求正面结论——它一旦掉成 `UNKNOWN`,说明的是库的组装路径
|
||||
丢了信号,而不是上游行为变了。
|
||||
"""
|
||||
matrix, provider, model = "L6b", "qwen", "qwen3.7-plus"
|
||||
desc = f"{provider} enable_thinking=True"
|
||||
try:
|
||||
obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=True)
|
||||
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
|
||||
_skip_if_unreachable(exc, matrix, desc)
|
||||
ons = [o for o in obs if _reasoning_on(o)]
|
||||
_record(
|
||||
matrix,
|
||||
desc,
|
||||
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
|
||||
f"{len(ons)}/{len(obs)} 轮观测到推理(OBSERVED)",
|
||||
obs,
|
||||
)
|
||||
assert len(ons) * 2 > len(obs), f"{provider} 开启方向要求多数轮 OBSERVED: {obs}"
|
||||
|
||||
|
||||
class TestCapabilityDrift:
|
||||
"""L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。"""
|
||||
@@ -395,7 +471,7 @@ class TestCapabilityDrift:
|
||||
"L8",
|
||||
desc,
|
||||
"PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)",
|
||||
f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭",
|
||||
f"实测未观测到推理 {dict(verdict)}(True=满足);声明 can_disable=True 要求每轮满足",
|
||||
obs,
|
||||
)
|
||||
assert len(offs) == len(obs), (
|
||||
|
||||
@@ -52,6 +52,7 @@ _EXPECTED_COLUMNS = [
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
"thinking_observation",
|
||||
]
|
||||
|
||||
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
||||
@@ -125,6 +126,8 @@ async def _record_minimal(
|
||||
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
|
||||
"tenant_id": "",
|
||||
"meta": "{}",
|
||||
# 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str
|
||||
"thinking_observation": "unknown",
|
||||
}
|
||||
fields.update(overrides)
|
||||
await recorder.record_llm_call(**fields)
|
||||
@@ -602,10 +605,12 @@ _PRE_TENANT_INSERT = (
|
||||
)
|
||||
|
||||
|
||||
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉 issue #11 的两个新维度
|
||||
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的三列
|
||||
# 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。
|
||||
# 去掉后的顺序与 DDL 逐字一致(tenant_id/meta 在 DDL 里本就排在末尾)。
|
||||
_PRE_TENANT_COLUMNS = [c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta")]
|
||||
# 去掉后的顺序与 DDL 逐字一致(这三列在 DDL 里本就排在末尾)。
|
||||
_PRE_TENANT_COLUMNS = [
|
||||
c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta", "thinking_observation")
|
||||
]
|
||||
|
||||
# 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个
|
||||
_PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"]
|
||||
@@ -761,7 +766,7 @@ class TestCallerDimensionsAcceptance:
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||
schema,
|
||||
)
|
||||
# 22 → 24 个 recorder 字段(加 created_at 共 25 个物理列),且新列追加在末尾
|
||||
# 22 → 25 个 recorder 字段(加 created_at 共 26 个物理列),且新列追加在末尾
|
||||
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||
rows = await _fetch(
|
||||
schema_dsn,
|
||||
@@ -944,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)
|
||||
@@ -976,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()
|
||||
|
||||
@@ -1002,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]
|
||||
@@ -1060,7 +1067,7 @@ class TestPublishedSchemaScript:
|
||||
|
||||
await _execute_script(fresh_dsn, script)
|
||||
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
|
||||
# 物理列 = 24 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
|
||||
# 物理列 = 25 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
|
||||
assert set(actual) == set(COLUMNS) | {"created_at"}
|
||||
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
|
||||
assert actual == _EXPECTED_COLUMNS
|
||||
@@ -1271,6 +1278,19 @@ class TestProductionTemplate:
|
||||
# 占位符没了 = 受控替换静默失效,测试会去打真实的 polygateway_* 角色
|
||||
assert placeholder in joined, f"README 模板缺占位符 {placeholder!r}"
|
||||
|
||||
# 再钉死列的**同源性**: `table` 块必须靠 `LIKE llm_calls_seed` 从库自建的表派生
|
||||
# 列,绝不能手抄一份列定义。手抄的那份会与 telemetry/schema.py 各自漂移,而漂移
|
||||
# 的表现是照模板部署的下游少掉新增列——manual 档下库按现有列裁剪写入,那一列
|
||||
# 就此静默消失,正是可观测性 issue 要消灭的那类静默。
|
||||
table_sql = blocks["table"]
|
||||
assert "LIKE llm_calls_seed" in table_sql, "生产模板的列必须由 LIKE 派生,不得手抄"
|
||||
inlined = [
|
||||
column
|
||||
for column in COLUMNS
|
||||
if re.search(rf"^\s*{column}\s+[A-Z]", table_sql, re.MULTILINE)
|
||||
]
|
||||
assert not inlined, f"生产模板内联了列定义 {inlined},与 telemetry/schema.py 必然漂移"
|
||||
|
||||
async def test_app_can_insert_but_cannot_mutate(self, production_template):
|
||||
"""应用角色: INSERT 通过,UPDATE / DELETE 被权限层拒绝(不是被触发器拒)。
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.backends.memory.cache import InMemoryCache
|
||||
from polygateway.errors import ResultInvalidError, TransientError
|
||||
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation
|
||||
|
||||
_MSGS = [{"role": "user", "content": "hi"}]
|
||||
|
||||
@@ -249,6 +250,80 @@ class TestObservabilityFieldsOnHit:
|
||||
assert hit.cached_prompt_tokens is None and hit.model_reported is None
|
||||
|
||||
|
||||
class TestThinkingObservationRehydration:
|
||||
"""issue #16/#17: 命中回放必须复活成枚举实例,而不是 JSON 里的裸 str。
|
||||
|
||||
裸 str 与字段注解分叉,下游拿 `resp.thinking_observation is
|
||||
ThinkingObservation.OBSERVED` 判等会在缓存命中路径上静默为 False。
|
||||
"""
|
||||
|
||||
async def test_hit_replays_enum_instance_not_bare_str(self):
|
||||
backend = InMemoryCache()
|
||||
mw = _mw(backend)
|
||||
terminal = _Terminal(_resp(thinking_observation=ThinkingObservation.OBSERVED))
|
||||
await mw(ChatRequest(messages=_MSGS), terminal)
|
||||
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||
assert hit.cache_hit is True and terminal.calls == 1
|
||||
assert isinstance(hit.thinking_observation, ThinkingObservation)
|
||||
assert hit.thinking_observation is ThinkingObservation.OBSERVED
|
||||
|
||||
async def test_unknown_value_degrades_to_unknown_and_still_hits(self):
|
||||
"""域外取值降级为 UNKNOWN,内容照常复活——不得因此作废整条缓存。
|
||||
|
||||
真实场景: 三项目共用一个 Redis,先升级的项目写入了本版没有的第四态,
|
||||
未升级的两个项目若把它判成未命中,就会在这些 key 上每次真打网关、随后
|
||||
覆写回旧值,两个版本互相打对方的缓存(表现是命中率莫名腰斩)。一个纯
|
||||
可观测性字段不该有能力废掉内容完好的缓存响应。
|
||||
"""
|
||||
backend = InMemoryCache()
|
||||
mw = _mw(backend)
|
||||
key = build_cache_key("m", _MSGS, "proj", None)
|
||||
poisoned = dataclasses.asdict(_resp(content="from-a-newer-version"))
|
||||
poisoned["thinking_observation"] = "partially_observed"
|
||||
poisoned.pop("structured_data", None)
|
||||
await backend.set(key, json.dumps(poisoned), 3600)
|
||||
terminal = _Terminal(_resp())
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
try:
|
||||
resp = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
assert terminal.calls == 0 and resp.cache_hit is True
|
||||
assert resp.content == "from-a-newer-version" # 内容完好,照常复活
|
||||
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
|
||||
# 单独一条讲清原因的 warning: 通用的"重建失败"没有任何线索指向真因
|
||||
hits = [m for m in messages if "partially_observed" in m]
|
||||
assert len(hits) == 1, f"域外取值必须单独告警一次,实得 {len(hits)} 条: {messages}"
|
||||
assert "thinking_observation" in hits[0]
|
||||
assert [m for m in messages if "重建失败" in m] == []
|
||||
|
||||
async def test_a_broken_payload_still_falls_back_to_source(self):
|
||||
"""对照组: 内容完整性真被破坏时,仍必须按未命中回源(降级方向不变)。"""
|
||||
backend = InMemoryCache()
|
||||
mw = _mw(backend)
|
||||
key = build_cache_key("m", _MSGS, "proj", None)
|
||||
await backend.set(key, "{not json at all", 3600)
|
||||
terminal = _Terminal(_resp())
|
||||
resp = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||
assert terminal.calls == 1 and resp.cache_hit is False
|
||||
assert resp.content == "cached"
|
||||
|
||||
async def test_legacy_entry_without_key_rehydrates_to_default(self):
|
||||
"""升级前写入的条目没有该键,必须照常复活并落到默认 UNKNOWN。"""
|
||||
backend = InMemoryCache()
|
||||
mw = _mw(backend)
|
||||
key = build_cache_key("m", _MSGS, "proj", None)
|
||||
legacy = dataclasses.asdict(_resp(content="legacy"))
|
||||
legacy.pop("thinking_observation")
|
||||
legacy.pop("structured_data", None)
|
||||
await backend.set(key, json.dumps(legacy), 3600)
|
||||
terminal = _Terminal(_resp())
|
||||
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||
assert hit.content == "legacy" and terminal.calls == 0
|
||||
assert hit.thinking_observation is ThinkingObservation.UNKNOWN
|
||||
|
||||
|
||||
class _BrokenBackend:
|
||||
async def get(self, key):
|
||||
raise ConnectionError("redis down")
|
||||
|
||||
@@ -22,7 +22,7 @@ from polygateway.transports.openai_compat import (
|
||||
_iter_sse_deltas,
|
||||
_sse_data_payload,
|
||||
)
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation
|
||||
|
||||
|
||||
def _source(**overrides):
|
||||
@@ -471,6 +471,162 @@ class TestReasoningTokens:
|
||||
assert result.reasoning_tokens is None
|
||||
|
||||
|
||||
class TestThinkingObservationVerdict:
|
||||
"""issue #16/#17: 两条组装路径都必须裁定"推理到底发生没发生"。
|
||||
|
||||
流式与非流式各测一遍是刻意的——只填一条路径正是本 issue 的根因形态:
|
||||
库在其中一条路径上悄悄给出了不同的可观测性,下游无从分辨。
|
||||
"""
|
||||
|
||||
def _reasoning_usage(self, reasoning):
|
||||
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
|
||||
|
||||
async def test_stream_reasoning_content_is_observed(self):
|
||||
def handler(request):
|
||||
return _sse_stream(
|
||||
_chunk(reasoning="想一下"), _chunk(content="ok"), _chunk(usage=_USAGE)
|
||||
)
|
||||
|
||||
result = await _complete(_transport_for(handler), _source())
|
||||
assert result.thinking_observation is ThinkingObservation.OBSERVED
|
||||
|
||||
async def test_stream_without_any_signal_is_unknown(self):
|
||||
"""无正文、无 details: 库不知道,就如实说不知道。"""
|
||||
|
||||
def handler(request):
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||
|
||||
result = await _complete(_transport_for(handler), _source())
|
||||
assert result.thinking_observation is ThinkingObservation.UNKNOWN
|
||||
|
||||
async def test_stream_zero_reasoning_tokens_is_absent(self):
|
||||
"""上游明确上报 0 才算 ABSENT——这是唯一的"确实没推理"证据。"""
|
||||
|
||||
def handler(request):
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
|
||||
|
||||
result = await _complete(_transport_for(handler), _source())
|
||||
assert result.thinking_observation is ThinkingObservation.ABSENT
|
||||
|
||||
async def test_non_stream_reasoning_content_is_observed(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{"message": {"content": "42", "reasoning_content": "想一下"}}],
|
||||
"usage": _USAGE,
|
||||
},
|
||||
)
|
||||
|
||||
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||
assert result.thinking_observation is ThinkingObservation.OBSERVED
|
||||
|
||||
async def test_non_stream_without_any_signal_is_unknown(self):
|
||||
"""M3 非流式实测形态: 推理已计费却既不回传正文也不回传 details。"""
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
|
||||
)
|
||||
|
||||
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||
assert result.thinking_observation is ThinkingObservation.UNKNOWN
|
||||
|
||||
async def test_non_stream_zero_reasoning_tokens_is_absent(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{"message": {"content": "42"}}],
|
||||
"usage": self._reasoning_usage(0),
|
||||
},
|
||||
)
|
||||
|
||||
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||
assert result.thinking_observation is ThinkingObservation.ABSENT
|
||||
|
||||
|
||||
class TestThinkingReconciliation:
|
||||
"""对账告警按 (source, model, direction) 节流(设计 §5)。
|
||||
|
||||
键的三段缺一不可,理由同源: 合并任意一段,都会让先出现的那一组把另一组
|
||||
永久静音——同一模型的开/关两档是两个独立的矛盾,同一模型的两个源背后是
|
||||
两个独立的账号/网关。
|
||||
"""
|
||||
|
||||
def _handler(self, request):
|
||||
payload = json.loads(request.content)
|
||||
if payload.get("reasoning_effort") == "none":
|
||||
# 关闭档却回了推理正文 → OBSERVED,与"要求关闭"矛盾
|
||||
return _sse_stream(
|
||||
_chunk(reasoning="偷偷想了"), _chunk(content="ok"), _chunk(usage=_USAGE)
|
||||
)
|
||||
# 开启档却零信号 → UNKNOWN,无法确认是否生效(M3 实测形态)
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||
|
||||
def _minimax(self, enable_thinking, name="mm"):
|
||||
return _source(
|
||||
name=name, provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
|
||||
)
|
||||
|
||||
async def test_same_model_and_direction_warns_only_once(self):
|
||||
transport = _transport_for(self._handler)
|
||||
source = self._minimax(False)
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
try:
|
||||
await _complete(transport, source)
|
||||
await _complete(transport, source)
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
hits = [m for m in messages if "MiniMax-M3" in m]
|
||||
assert len(hits) == 1, f"同一 (model, direction) 应只告警一次,实得 {len(hits)} 次"
|
||||
|
||||
async def test_each_source_gets_its_own_warning(self):
|
||||
"""多源多账号是本库的核心场景: 同一 model 跨 N 个源不得只喊第一个。
|
||||
|
||||
节流键漏掉源标识时,5 个共用同一模型的源里第一个出问题的喊完一次,其余
|
||||
四个**永久静音**——而每个源背后是独立的账号/网关,它们的行为互不代表。
|
||||
"""
|
||||
transport = _transport_for(self._handler)
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
try:
|
||||
await _complete(transport, self._minimax(False, name="gw-a"))
|
||||
await _complete(transport, self._minimax(False, name="gw-b"))
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
hits = [m for m in messages if "MiniMax-M3" in m]
|
||||
assert len(hits) == 2, f"两个源各应告警一次,实得 {len(hits)} 次"
|
||||
|
||||
async def test_the_warning_names_the_source(self):
|
||||
"""拿到告警的人得知道该查哪个网关: 只报模型名定位不到源。"""
|
||||
transport = _transport_for(self._handler)
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
try:
|
||||
await _complete(transport, self._minimax(False, name="gw-a"))
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
hits = [m for m in messages if "MiniMax-M3" in m]
|
||||
assert len(hits) == 1
|
||||
assert "gw-a" in hits[0], f"告警未点名出问题的源: {hits[0]}"
|
||||
|
||||
async def test_switching_direction_earns_a_second_warning(self):
|
||||
transport = _transport_for(self._handler)
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
try:
|
||||
await _complete(transport, self._minimax(False))
|
||||
await _complete(transport, self._minimax(False))
|
||||
await _complete(transport, self._minimax(True))
|
||||
await _complete(transport, self._minimax(True))
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
hits = [m for m in messages if "MiniMax-M3" in m]
|
||||
assert len(hits) == 2, f"两个方向各应告警一次,实得 {len(hits)} 次"
|
||||
|
||||
|
||||
class TestNonStreamFastPath:
|
||||
async def test_non_stream_parses_message(self):
|
||||
def handler(request):
|
||||
|
||||
@@ -49,3 +49,28 @@ def test_telemetry_status_exported():
|
||||
assert "TelemetryStatus" in polygateway.__all__
|
||||
assert polygateway.TelemetryStatus is not None
|
||||
assert "TelemetryStatusProvider" not in polygateway.__all__
|
||||
|
||||
|
||||
def test_thinking_public_surface_exported():
|
||||
"""issue #16/#17: 推理决策搬进 `polygateway.thinking` 后,公共符号必须走顶层。
|
||||
|
||||
搬模块本身会断掉 `from polygateway.providers import ThinkingCapability` 这类
|
||||
深路径 import。给下游一个稳定引用点,是以后再重组不再破坏下游的前提——本库
|
||||
的约定是「顶层导出即公共 API 面」。
|
||||
|
||||
`observe_thinking` / `reconcile_thinking` **不**导出: 它们是 transport 内部
|
||||
的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份
|
||||
永久承诺。
|
||||
"""
|
||||
for name in (
|
||||
"ThinkingCapability",
|
||||
"ThinkingObservation",
|
||||
"ThinkingUnsupportedError",
|
||||
"get_capability",
|
||||
"register_capability",
|
||||
"resolve_thinking",
|
||||
):
|
||||
assert hasattr(polygateway, name), name
|
||||
assert name in polygateway.__all__, name
|
||||
assert "observe_thinking" not in polygateway.__all__
|
||||
assert "reconcile_thinking" not in polygateway.__all__
|
||||
|
||||
@@ -244,7 +244,7 @@ class TestTelemetryRecorderSignature:
|
||||
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
||||
assert {"tenant_id", "meta"} <= set(params)
|
||||
|
||||
@pytest.mark.parametrize("name", ["tenant_id", "meta"])
|
||||
@pytest.mark.parametrize("name", ["tenant_id", "meta", "thinking_observation"])
|
||||
def test_caller_dimensions_have_no_default(self, name):
|
||||
import inspect
|
||||
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.providers import (
|
||||
DEFAULT_CAPABILITIES,
|
||||
DEFAULT_PROFILES,
|
||||
ProviderProfile,
|
||||
ThinkingCapability,
|
||||
get_capability,
|
||||
get_provider,
|
||||
register_capability,
|
||||
register_provider,
|
||||
resolve_thinking,
|
||||
)
|
||||
|
||||
|
||||
@@ -75,83 +69,3 @@ class TestPureFunctionRegistration:
|
||||
def test_default_profiles_mapping_is_read_only(self):
|
||||
with pytest.raises(TypeError):
|
||||
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
|
||||
|
||||
|
||||
def _warnings():
|
||||
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
return messages, sink_id
|
||||
|
||||
|
||||
class TestThinkingCapability:
|
||||
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
|
||||
|
||||
def test_registered_models_carry_evidence(self):
|
||||
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
|
||||
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
|
||||
cap = get_capability(model)
|
||||
assert cap is not None and cap.evidence.strip()
|
||||
|
||||
def test_m3_can_disable_but_m2x_cannot(self):
|
||||
assert get_capability("MiniMax-M3").can_disable is True
|
||||
assert get_capability("MiniMax-M2.7").can_disable is False
|
||||
assert get_capability("MiniMax-M2.5").can_disable is False
|
||||
|
||||
def test_unregistered_model_is_unknown(self):
|
||||
assert get_capability("some-brand-new-model") is None
|
||||
|
||||
def test_register_capability_is_pure(self):
|
||||
table = register_capability("x-1", ThinkingCapability(True, "实测"))
|
||||
assert get_capability("x-1", table=table) is not None
|
||||
assert get_capability("x-1") is None # 默认表未被污染
|
||||
|
||||
def test_default_capabilities_mapping_is_read_only(self):
|
||||
with pytest.raises(TypeError):
|
||||
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
|
||||
|
||||
|
||||
class TestResolveThinking:
|
||||
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
|
||||
|
||||
def test_rule1_none_injects_nothing(self):
|
||||
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
|
||||
assert got == {}
|
||||
|
||||
@pytest.mark.parametrize("enable", [True, False])
|
||||
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
|
||||
with pytest.raises(ValueError, match="register_provider") as exc:
|
||||
resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3")
|
||||
assert "extra_body" in str(exc.value)
|
||||
|
||||
def test_rule3_unregistered_model_warns_but_passes(self):
|
||||
messages, sink_id = _warnings()
|
||||
try:
|
||||
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
assert got == {"reasoning_effort": "none"}
|
||||
assert any("MiniMax-M9" in m for m in messages)
|
||||
|
||||
def test_rule4_cannot_disable_raises_with_the_model_name(self):
|
||||
cap = get_capability("MiniMax-M2.7")
|
||||
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
||||
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
|
||||
|
||||
def test_rule4_only_blocks_the_off_direction(self):
|
||||
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
|
||||
cap = get_capability("MiniMax-M2.7")
|
||||
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
|
||||
assert got == {"reasoning_effort": "medium"}
|
||||
|
||||
def test_rule5_normal_path(self):
|
||||
cap = get_capability("MiniMax-M3")
|
||||
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
|
||||
"reasoning_effort": "none"
|
||||
}
|
||||
|
||||
def test_unknown_shape_beats_capability_check(self):
|
||||
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
|
||||
cap = ThinkingCapability(can_disable=False, evidence="构造")
|
||||
with pytest.raises(ValueError, match="register_provider"):
|
||||
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
|
||||
|
||||
@@ -27,6 +27,7 @@ from polygateway.types import (
|
||||
GlobalLimits,
|
||||
RetryPolicy,
|
||||
SourceConfig,
|
||||
ThinkingObservation,
|
||||
TransportResult,
|
||||
)
|
||||
from tests.contracts.conftest import FakeClock
|
||||
@@ -225,6 +226,29 @@ class TestObservabilityPassthrough:
|
||||
assert resp.cached_prompt_tokens is None and resp.model_reported is None
|
||||
assert resp.reasoning_tokens is None
|
||||
|
||||
async def test_thinking_observation_reaches_the_response(self):
|
||||
"""issue #16/#17: 裁定归 transport,中间件只透传,不得在途中改判。"""
|
||||
result = TransportResult(
|
||||
content="ok",
|
||||
thinking="想一下",
|
||||
prompt_tokens=10,
|
||||
completion_tokens=5,
|
||||
usage_source="measured",
|
||||
ttft_ms=12.0,
|
||||
max_inter_token_ms=3.0,
|
||||
raw={},
|
||||
thinking_observation=ThinkingObservation.OBSERVED,
|
||||
)
|
||||
mw, *_ = _harness([_src("a")], [result])
|
||||
resp = await mw(_REQ)
|
||||
assert resp.thinking_observation is ThinkingObservation.OBSERVED
|
||||
|
||||
async def test_unjudged_transport_result_stays_unknown(self):
|
||||
"""不裁定的 transport(如 OCR)透传出来仍是 UNKNOWN,不被默认成 ABSENT。"""
|
||||
mw, *_ = _harness([_src("a")], [_ok()])
|
||||
resp = await mw(_REQ)
|
||||
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
|
||||
|
||||
|
||||
class TestRetryAndFailover:
|
||||
async def test_transient_switches_source_then_succeeds(self):
|
||||
|
||||
+152
-16
@@ -9,6 +9,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.backends.memory.breaker import InMemoryGate
|
||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||
@@ -30,6 +31,7 @@ from polygateway.types import (
|
||||
OcrTextTransportResult,
|
||||
RetryPolicy,
|
||||
SourceConfig,
|
||||
ThinkingObservation,
|
||||
)
|
||||
|
||||
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}], session_id="sess-1")
|
||||
@@ -60,6 +62,7 @@ _EXPECTED_COLUMNS = [
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
"thinking_observation",
|
||||
]
|
||||
|
||||
|
||||
@@ -127,6 +130,8 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
||||
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
|
||||
"tenant_id": "",
|
||||
"meta": "{}",
|
||||
# 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str
|
||||
"thinking_observation": "unknown",
|
||||
}
|
||||
fields.update(overrides)
|
||||
await recorder.record_llm_call(**fields)
|
||||
@@ -172,16 +177,16 @@ _FROZEN_SQLITE_INSERT = (
|
||||
"INSERT OR IGNORE INTO llm_calls (call_id, parent_call_id, session_id, model, provider, "
|
||||
"source_name, messages, response, thinking, prompt_tokens, completion_tokens, usage_source, "
|
||||
"latency_ms, ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, "
|
||||
"model_reported, sampling, reasoning_tokens, tenant_id, meta) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"model_reported, sampling, reasoning_tokens, tenant_id, meta, thinking_observation) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
_FROZEN_PG_INSERT = (
|
||||
"INSERT INTO llm_calls (call_id, parent_call_id, session_id, model, provider, source_name, "
|
||||
"messages, response, thinking, prompt_tokens, completion_tokens, usage_source, latency_ms, "
|
||||
"ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, model_reported, "
|
||||
"sampling, reasoning_tokens, tenant_id, meta) "
|
||||
"sampling, reasoning_tokens, tenant_id, meta, thinking_observation) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, "
|
||||
"$19, $20, $21, $22, $23, $24) "
|
||||
"$19, $20, $21, $22, $23, $24, $25) "
|
||||
# 无冲突目标(issue #13 Task 2): 带 `(call_id)` 的版本在按 created_at 分区、
|
||||
# 主键为 (call_id, created_at) 的表上匹配不到约束,PG 直接拒收整条写入
|
||||
"ON CONFLICT DO NOTHING"
|
||||
@@ -207,7 +212,7 @@ class TestSchemaModule:
|
||||
|
||||
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
|
||||
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
|
||||
assert len(COLUMNS) == 24
|
||||
assert len(COLUMNS) == 25
|
||||
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
|
||||
for ddl in (SQLITE_DDL, PG_DDL):
|
||||
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
|
||||
@@ -221,8 +226,8 @@ class TestSchemaModule:
|
||||
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
|
||||
)
|
||||
assert PG_BACKFILL[-1] == (
|
||||
"meta",
|
||||
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb",
|
||||
"thinking_observation",
|
||||
"ALTER TABLE llm_calls ADD COLUMN thinking_observation TEXT",
|
||||
)
|
||||
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
|
||||
|
||||
@@ -270,7 +275,7 @@ class TestSchemaModule:
|
||||
pg = telemetry_schema_sql("postgres")
|
||||
lite = telemetry_schema_sql("sqlite")
|
||||
for script in (pg, lite):
|
||||
# 24 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致
|
||||
# 25 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致
|
||||
assert _first_occurrence_order(script, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
|
||||
assert "CREATE TABLE IF NOT EXISTS llm_calls" in script
|
||||
# 人执行的那份必须幂等: PG 用 ADD COLUMN IF NOT EXISTS(与库内那份有意不同)
|
||||
@@ -304,11 +309,11 @@ class TestBackendColumnParity:
|
||||
assert sqlite.COLUMNS is COLUMNS
|
||||
assert postgres.COLUMNS is COLUMNS
|
||||
|
||||
def test_caller_dimensions_are_appended_last(self):
|
||||
def test_new_columns_are_appended_last(self):
|
||||
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
|
||||
from polygateway.telemetry.schema import COLUMNS
|
||||
|
||||
assert COLUMNS[-2:] == ("tenant_id", "meta")
|
||||
assert COLUMNS[-3:] == ("tenant_id", "meta", "thinking_observation")
|
||||
|
||||
|
||||
class TestSQLiteRecorder:
|
||||
@@ -378,6 +383,28 @@ class TestSQLiteRecorder:
|
||||
assert rows["r-zero"] == 0 # 上报了且确实没推理
|
||||
assert rows["r-none"] is None # 本次调用未上报
|
||||
|
||||
async def test_thinking_observation_column_round_trips(self, tmp_path):
|
||||
"""issue #16: 三态裁定结果落库,事后才能按"这次到底推没推理"分组统计。
|
||||
|
||||
断言的是裸字符串 `"observed"` 而非枚举: 归一化在 emitter 侧完成
|
||||
(`_record` 取 `.value`),recorder 拿到的必须已经是 `str`——`StrEnum`
|
||||
虽是 `str` 子类,asyncpg 的参数编码对子类不保证接受,而遥测写失败只
|
||||
降级成一条 warning,PG 那一路会悄无声息地少一列数据。
|
||||
"""
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
await _record_minimal(recorder, call_id="t-obs", thinking_observation="observed")
|
||||
await _record_minimal(recorder, call_id="t-absent", thinking_observation="absent")
|
||||
await _record_minimal(recorder, call_id="t-unknown")
|
||||
recorder.close()
|
||||
rows = dict(
|
||||
sqlite3.connect(tmp_path / "t.db")
|
||||
.execute("SELECT call_id, thinking_observation FROM llm_calls")
|
||||
.fetchall()
|
||||
)
|
||||
assert rows["t-obs"] == "observed"
|
||||
assert rows["t-absent"] == "absent" # 观测到"确实没推理",与"看不出来"不是一回事
|
||||
assert rows["t-unknown"] == "unknown"
|
||||
|
||||
async def test_sampling_column_round_trips(self, tmp_path):
|
||||
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
@@ -541,7 +568,7 @@ class TestSQLiteCallerDimensionsAcceptance:
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
||||
assert cols == _EXPECTED_COLUMNS # 22 → 24 个 recorder 字段(+ created_at 共 25 物理列)
|
||||
assert cols == _EXPECTED_COLUMNS # 22 → 25 个 recorder 字段(+ created_at 共 26 物理列)
|
||||
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
|
||||
assert rows["new-row"] == "tenant-a"
|
||||
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
||||
@@ -583,7 +610,7 @@ class TestSQLiteCallerDimensionsAcceptance:
|
||||
|
||||
stale = sqlite3.connect(db)
|
||||
assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == (
|
||||
_EXPECTED_COLUMNS[:-2]
|
||||
_EXPECTED_COLUMNS[:-3]
|
||||
) # 补列确实没成功,用例不是在只读库上空转
|
||||
|
||||
|
||||
@@ -591,7 +618,7 @@ class TestSQLiteSchemaMode:
|
||||
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
|
||||
|
||||
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
|
||||
补齐后 24 + `created_at` = 25。混用 INSERT 字段数与物理列数是本处最易错的地方。
|
||||
补齐后 25 + `created_at` = 26。混用 INSERT 字段数与物理列数是本处最易错的地方。
|
||||
"""
|
||||
|
||||
def _physical_columns(self, db: Path) -> list[str]:
|
||||
@@ -630,7 +657,7 @@ class TestSQLiteSchemaMode:
|
||||
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
|
||||
|
||||
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
|
||||
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 25。"""
|
||||
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 26。"""
|
||||
db = tmp_path / "auto_legacy.db"
|
||||
_make_pre_tenant_db(db)
|
||||
|
||||
@@ -639,10 +666,10 @@ class TestSQLiteSchemaMode:
|
||||
recorder.close()
|
||||
|
||||
assert self._physical_columns(db) == _EXPECTED_COLUMNS
|
||||
assert len(self._physical_columns(db)) == 25
|
||||
assert len(self._physical_columns(db)) == 26
|
||||
|
||||
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
|
||||
"""manual 只管 ALTER,不管 CREATE: 全新库照建,25 个物理列齐全(设计 §4.2)。"""
|
||||
"""manual 只管 ALTER,不管 CREATE: 全新库照建,26 个物理列齐全(设计 §4.2)。"""
|
||||
db = tmp_path / "manual_fresh.db"
|
||||
recorder = SQLiteRecorder(db, auto_migrate=False)
|
||||
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
|
||||
@@ -866,6 +893,7 @@ class TestPostgresBackfillDiscipline:
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
"thinking_observation",
|
||||
]
|
||||
|
||||
def _recorder(self, conn):
|
||||
@@ -926,6 +954,7 @@ class TestPostgresTableProbe:
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
"thinking_observation",
|
||||
]
|
||||
|
||||
def _recorder(self, conn):
|
||||
@@ -1118,6 +1147,113 @@ class TestEmitterRecorderContract:
|
||||
assert set(rec.rows[0]) == set(COLUMNS)
|
||||
|
||||
|
||||
class TestEmitterThinkingObservation:
|
||||
"""issue #16: 三态裁定经 emitter 落库,且落的是**裸 str** 而非枚举实例。
|
||||
|
||||
类型断言不是洁癖: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str`
|
||||
子类不保证接受,而遥测写失败只降级成一条 warning——PG 那一路会静默少一列
|
||||
数据,本地 SQLite 测试全绿也发现不了。归一化因此固定在 emitter 侧,与
|
||||
`tenant_id`/`meta`/`sampling` 同一先例。
|
||||
"""
|
||||
|
||||
async def test_attempt_carries_the_verdict_as_a_plain_string(self):
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=_resp(thinking_observation=ThinkingObservation.OBSERVED),
|
||||
error=None,
|
||||
)
|
||||
value = rec.rows[0]["thinking_observation"]
|
||||
assert value == "observed"
|
||||
assert type(value) is str # 不是 ThinkingObservation: 子类实例不得下沉到 recorder
|
||||
|
||||
async def test_a_bare_string_verdict_still_lands(self):
|
||||
"""下游填裸 str 时**整行**不得丢失(遥测必录)。
|
||||
|
||||
`LLMResponse` 是无运行时校验的 frozen dataclass,写
|
||||
`LLMResponse(..., thinking_observation="observed")` 完全自然且 `==` 比较
|
||||
照常成立;若 emitter 直接取 `.value`,这里会抛 `AttributeError` 并被
|
||||
`_record` 的 `except Exception` 吞成一条泛化 warning——丢的不是这一列,
|
||||
是整行,正是 1.3.0 那次"19 次调用一行未落"的同款形态。
|
||||
"""
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=_resp(thinking_observation="observed"),
|
||||
error=None,
|
||||
)
|
||||
assert len(rec.rows) == 1, "整行被吞了"
|
||||
value = rec.rows[0]["thinking_observation"]
|
||||
assert value == "observed"
|
||||
assert type(value) is str
|
||||
|
||||
async def test_an_out_of_domain_verdict_degrades_but_keeps_the_row(self):
|
||||
"""域外取值挡在落库前,但**降级不丢行**: 列的取值域由库守,代价不是整行。
|
||||
|
||||
直接 `ThinkingObservation(x).value` 会在这里抛 `ValueError`,同样被
|
||||
`_record` 的 `except Exception` 吞成丢整行——那只修好了裸 str 一半,
|
||||
口误值(大小写不符、拼错)对测试替身同样自然。故降级为 `unknown`
|
||||
(对库而言本次确实判不出来)并单独告警,与缓存回放的方向选择一致。
|
||||
"""
|
||||
rec = _MemoryRecorder()
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
try:
|
||||
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=_resp(thinking_observation="OBSERVED"), # 大小写不符即域外
|
||||
error=None,
|
||||
)
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
assert len(rec.rows) == 1, "整行被吞了"
|
||||
assert rec.rows[0]["thinking_observation"] == "unknown"
|
||||
hits = [m for m in messages if "OBSERVED" in m]
|
||||
assert len(hits) == 1, f"域外取值必须单独告警: {messages}"
|
||||
assert [m for m in messages if "遥测记录失败" in m] == []
|
||||
|
||||
async def test_cache_hit_replays_the_recorded_verdict(self):
|
||||
"""缓存命中回放历史那次的裁定: 与 model/prompt_tokens 同一口径。"""
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec, text_cap=None).emit_cache_hit(
|
||||
request=_REQ,
|
||||
response=_resp(cache_hit=True, thinking_observation=ThinkingObservation.ABSENT),
|
||||
)
|
||||
assert rec.rows[0]["thinking_observation"] == "absent"
|
||||
|
||||
async def test_terminal_failure_records_unknown(self):
|
||||
"""终态失败无响应可言,记 `unknown`——它恰好就是"观测不到",不撒谎。"""
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec, text_cap=None).emit_terminal_failure(
|
||||
request=_REQ, call_id="c", latency_ms=1, error="dead"
|
||||
)
|
||||
value = rec.rows[0]["thinking_observation"]
|
||||
assert value == "unknown"
|
||||
assert type(value) is str
|
||||
|
||||
async def test_failed_attempt_records_unknown(self):
|
||||
"""失败尝试(response=None)同理: 默认视图即 UNKNOWN。"""
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=None,
|
||||
error="boom",
|
||||
)
|
||||
assert rec.rows[0]["thinking_observation"] == "unknown"
|
||||
|
||||
|
||||
class TestEmitterObservabilityFields:
|
||||
"""issue #3: 三个入口各自的取值口径(设计 §5 表)。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""推理裁定与对账的行为测试(issue #16/#17 设计 §4-§5)。
|
||||
|
||||
判据来自 2026-08-25 实测(findings): MiniMax-M3 在开启档流式路径下返回 185 字符
|
||||
推理正文却不上报 `completion_tokens_details`,而 qwen/deepseek 两者都报。库因此
|
||||
不能把任何单一信号当权威——本组用例逐条钉死"哪个信号该赢"。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.providers import get_provider
|
||||
from polygateway.thinking import (
|
||||
DEFAULT_CAPABILITIES,
|
||||
ThinkingCapability,
|
||||
get_capability,
|
||||
observe_thinking,
|
||||
reconcile_thinking,
|
||||
register_capability,
|
||||
resolve_thinking,
|
||||
)
|
||||
from polygateway.types import ThinkingObservation
|
||||
|
||||
|
||||
def _warnings():
|
||||
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
return messages, sink_id
|
||||
|
||||
|
||||
class TestObserveThinking:
|
||||
"""三态裁定: 证据硬度决定优先级,无信号一律 UNKNOWN。"""
|
||||
|
||||
def test_reasoning_text_alone_proves_it_happened(self):
|
||||
"""推理正文是事实本身: 上游不报 token 数也照样成立(M3 流式实测形态)。"""
|
||||
assert (
|
||||
observe_thinking(thinking="先解方程 x+y=35", reasoning_tokens=None)
|
||||
is ThinkingObservation.OBSERVED
|
||||
)
|
||||
|
||||
def test_blank_text_is_not_evidence(self):
|
||||
"""纯空白正文不算证据: 网关响应是外部输入,truthy 判据会把空格计成推理(P5)。"""
|
||||
assert (
|
||||
observe_thinking(thinking=" \n\t ", reasoning_tokens=None)
|
||||
is ThinkingObservation.UNKNOWN
|
||||
)
|
||||
|
||||
def test_positive_token_count_proves_it_happened(self):
|
||||
"""无正文但上游报了推理用量(qwen 非流式形态)。"""
|
||||
assert observe_thinking(thinking="", reasoning_tokens=205) is ThinkingObservation.OBSERVED
|
||||
|
||||
def test_zero_token_count_is_positive_evidence_of_absence(self):
|
||||
"""`0` 是"上报了且为零",与"没上报"语义不同,故是 ABSENT 而非 UNKNOWN。"""
|
||||
assert observe_thinking(thinking="", reasoning_tokens=0) is ThinkingObservation.ABSENT
|
||||
|
||||
def test_no_signal_at_all_stays_unknown(self):
|
||||
"""M3 非流式开启档的真实形态: 推理已计费却既无正文也无 token 数。
|
||||
|
||||
判成 ABSENT 就是伪装成"没推理"——正是 issue #16/#17 的病根。
|
||||
"""
|
||||
assert observe_thinking(thinking="", reasoning_tokens=None) is ThinkingObservation.UNKNOWN
|
||||
|
||||
def test_text_outranks_a_zero_count(self):
|
||||
"""转述与事实冲突时事实赢: 正文在,`reasoning_tokens=0` 不能翻案。"""
|
||||
assert (
|
||||
observe_thinking(thinking="想了想", reasoning_tokens=0) is ThinkingObservation.OBSERVED
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("negative", [-1, -205])
|
||||
def test_negative_token_count_is_not_evidence_of_absence(self, negative):
|
||||
"""负数是坏数据,不是"上游明确上报未推理"这个最强的正面结论。
|
||||
|
||||
当前 transport 已在边界把负数归 `None`,所以这条走不通;但本函数的
|
||||
docstring 自称"外部输入校验后使用",第二个 transport 直接填该值时,
|
||||
`> 0 else ABSENT` 会给出一个方向相反的强结论。函数自身必须闭合(P5)。
|
||||
"""
|
||||
assert observe_thinking(thinking="", reasoning_tokens=negative) is (
|
||||
ThinkingObservation.UNKNOWN
|
||||
)
|
||||
|
||||
|
||||
class TestThinkingObservationEnum:
|
||||
def test_values_are_stable_strings(self):
|
||||
"""取值进遥测落库,改名即历史数据断层。"""
|
||||
assert ThinkingObservation.OBSERVED == "observed"
|
||||
assert ThinkingObservation.ABSENT == "absent"
|
||||
assert ThinkingObservation.UNKNOWN == "unknown"
|
||||
|
||||
def test_enum_lives_in_the_innermost_layer(self):
|
||||
"""枚举必须定义在 `types.py`(最内层)。
|
||||
|
||||
它是 `LLMResponse` 的字段类型;定义在决策层 `thinking.py` 会让 `types.py`
|
||||
反向 import 决策模块,违反 P7 依赖铁律(import-linter 契约执法)。
|
||||
"""
|
||||
assert ThinkingObservation.__module__ == "polygateway.types"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bogus", ["", "OBSERVED", "yes", "none"])
|
||||
def test_unknown_strings_are_rejected(bogus):
|
||||
"""非法值必须抛 ValueError: 缓存回放与遥测归一化都靠它识别域外取值(设计 §6)。
|
||||
|
||||
两处接住这个 ValueError 后**降级而非作废**(缓存复活内容 + 记 UNKNOWN、遥测
|
||||
照常落行),但降级的前提是构造器真的会拒绝——它一旦放行,域外取值就会一路
|
||||
进到 `LLMResponse` 与遥测列里。
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
ThinkingObservation(bogus)
|
||||
|
||||
|
||||
class TestThinkingCapability:
|
||||
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
|
||||
|
||||
def test_registered_models_carry_evidence(self):
|
||||
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
|
||||
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
|
||||
cap = get_capability(model)
|
||||
assert cap is not None and cap.evidence.strip()
|
||||
|
||||
def test_m3_can_disable_but_m2x_cannot(self):
|
||||
assert get_capability("MiniMax-M3").can_disable is True
|
||||
assert get_capability("MiniMax-M2.7").can_disable is False
|
||||
assert get_capability("MiniMax-M2.5").can_disable is False
|
||||
|
||||
def test_unregistered_model_is_unknown(self):
|
||||
assert get_capability("some-brand-new-model") is None
|
||||
|
||||
def test_register_capability_is_pure(self):
|
||||
table = register_capability("x-1", ThinkingCapability(True, "实测"))
|
||||
assert get_capability("x-1", table=table) is not None
|
||||
assert get_capability("x-1") is None # 默认表未被污染
|
||||
|
||||
def test_default_capabilities_mapping_is_read_only(self):
|
||||
with pytest.raises(TypeError):
|
||||
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
|
||||
|
||||
|
||||
class TestResolveThinking:
|
||||
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
|
||||
|
||||
def test_rule1_none_injects_nothing(self):
|
||||
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
|
||||
assert got == {}
|
||||
|
||||
@pytest.mark.parametrize("enable", [True, False])
|
||||
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
|
||||
with pytest.raises(ValueError, match="register_provider") as exc:
|
||||
resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3")
|
||||
assert "extra_body" in str(exc.value)
|
||||
|
||||
def test_rule3_unregistered_model_warns_but_passes(self):
|
||||
messages, sink_id = _warnings()
|
||||
try:
|
||||
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
assert got == {"reasoning_effort": "none"}
|
||||
assert any("MiniMax-M9" in m for m in messages)
|
||||
|
||||
def test_rule4_cannot_disable_raises_with_the_model_name(self):
|
||||
cap = get_capability("MiniMax-M2.7")
|
||||
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
||||
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
|
||||
|
||||
def test_rule4_only_blocks_the_off_direction(self):
|
||||
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
|
||||
cap = get_capability("MiniMax-M2.7")
|
||||
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
|
||||
assert got == {"reasoning_effort": "medium"}
|
||||
|
||||
def test_rule5_normal_path(self):
|
||||
cap = get_capability("MiniMax-M3")
|
||||
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
|
||||
"reasoning_effort": "none"
|
||||
}
|
||||
|
||||
def test_unknown_shape_beats_capability_check(self):
|
||||
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
|
||||
cap = ThinkingCapability(can_disable=False, evidence="构造")
|
||||
with pytest.raises(ValueError, match="register_provider"):
|
||||
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
|
||||
|
||||
|
||||
class TestReconcileThinking:
|
||||
"""声明 × 观测对账(设计 §5): 矛盾出文案,不表态出 None。
|
||||
|
||||
文案本身是被断言对象——判定与日志分离正是为此: 告警内容可直接比对,不必
|
||||
去解析日志格式。
|
||||
"""
|
||||
|
||||
_CAP = ThinkingCapability(
|
||||
can_disable=True, evidence="2026-08-02 实测 reasoning_effort=none 可关闭"
|
||||
)
|
||||
|
||||
def test_off_but_observed_with_a_registered_capability_blames_the_table(self):
|
||||
"""已登记却实测推理了 = 能力表漂移: 必须附 evidence 与更新指路。"""
|
||||
msg = reconcile_thinking(
|
||||
enable_thinking=False,
|
||||
observation=ThinkingObservation.OBSERVED,
|
||||
capability=self._CAP,
|
||||
model="MiniMax-M3",
|
||||
)
|
||||
assert msg is not None
|
||||
assert "MiniMax-M3" in msg
|
||||
assert "2026-08-02 实测 reasoning_effort=none 可关闭" in msg
|
||||
assert "register_capability" in msg
|
||||
|
||||
def test_off_but_observed_unregistered_never_claims_a_table_entry(self):
|
||||
"""未登记模型没有"能力表声称"这回事——说它就是撒谎。"""
|
||||
msg = reconcile_thinking(
|
||||
enable_thinking=False,
|
||||
observation=ThinkingObservation.OBSERVED,
|
||||
capability=None,
|
||||
model="MiniMax-M9",
|
||||
)
|
||||
assert msg is not None
|
||||
assert "MiniMax-M9" in msg
|
||||
assert "能力表" not in msg
|
||||
assert "register_capability" in msg
|
||||
|
||||
def test_registered_and_unregistered_wordings_differ(self):
|
||||
registered = reconcile_thinking(
|
||||
enable_thinking=False,
|
||||
observation=ThinkingObservation.OBSERVED,
|
||||
capability=self._CAP,
|
||||
model="MiniMax-M3",
|
||||
)
|
||||
unregistered = reconcile_thinking(
|
||||
enable_thinking=False,
|
||||
observation=ThinkingObservation.OBSERVED,
|
||||
capability=None,
|
||||
model="MiniMax-M3",
|
||||
)
|
||||
assert registered != unregistered
|
||||
|
||||
@pytest.mark.parametrize("capability", [None, _CAP])
|
||||
def test_on_but_absent_is_a_contradiction(self, capability):
|
||||
"""上游明确上报未推理: 这是唯一的正面证伪,与能力表登记与否无关。"""
|
||||
msg = reconcile_thinking(
|
||||
enable_thinking=True,
|
||||
observation=ThinkingObservation.ABSENT,
|
||||
capability=capability,
|
||||
model="qwen3.7-plus",
|
||||
)
|
||||
assert msg is not None
|
||||
assert "qwen3.7-plus" in msg
|
||||
|
||||
@pytest.mark.parametrize("capability", [None, _CAP])
|
||||
def test_on_but_unknown_admits_it_cannot_confirm(self, capability):
|
||||
"""issue #17 的诚实版本: 明说"我注入了,但我看不见结果"。"""
|
||||
msg = reconcile_thinking(
|
||||
enable_thinking=True,
|
||||
observation=ThinkingObservation.UNKNOWN,
|
||||
capability=capability,
|
||||
model="MiniMax-M3",
|
||||
)
|
||||
assert msg is not None
|
||||
assert "MiniMax-M3" in msg
|
||||
|
||||
def test_off_and_absent_stays_silent(self):
|
||||
"""要求关闭 + 上游明确上报未推理 = 要求被满足,没有可报的矛盾。
|
||||
|
||||
这一格与 `test_off_and_unknown_stays_silent` 的沉默理由**不同**: 那里是
|
||||
"没有证伪力",这里是"正面证实要求已满足"。两者都必须沉默,漏测哪一格,
|
||||
把 Phase 2 的判据写成 `is ABSENT` 之类的反向条件都不会被抓住。
|
||||
"""
|
||||
assert (
|
||||
reconcile_thinking(
|
||||
enable_thinking=False,
|
||||
observation=ThinkingObservation.ABSENT,
|
||||
capability=self._CAP,
|
||||
model="qwen3.7-plus",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_off_and_unknown_stays_silent(self):
|
||||
"""UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。"""
|
||||
assert (
|
||||
reconcile_thinking(
|
||||
enable_thinking=False,
|
||||
observation=ThinkingObservation.UNKNOWN,
|
||||
capability=self._CAP,
|
||||
model="MiniMax-M3",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"observation",
|
||||
[ThinkingObservation.OBSERVED, ThinkingObservation.ABSENT, ThinkingObservation.UNKNOWN],
|
||||
)
|
||||
def test_no_request_no_grievance(self, observation):
|
||||
"""调用方不表态,就无从谈"违背"。"""
|
||||
assert (
|
||||
reconcile_thinking(
|
||||
enable_thinking=None,
|
||||
observation=observation,
|
||||
capability=self._CAP,
|
||||
model="MiniMax-M3",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_on_and_observed_is_exactly_what_was_asked_for(self):
|
||||
assert (
|
||||
reconcile_thinking(
|
||||
enable_thinking=True,
|
||||
observation=ThinkingObservation.OBSERVED,
|
||||
capability=self._CAP,
|
||||
model="MiniMax-M3",
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from polygateway.types import (
|
||||
LLMResponse,
|
||||
RetryPolicy,
|
||||
SourceConfig,
|
||||
ThinkingObservation,
|
||||
TransportResult,
|
||||
Usage,
|
||||
)
|
||||
@@ -33,6 +34,18 @@ def _make_source(**overrides):
|
||||
return SourceConfig(**base)
|
||||
|
||||
|
||||
class TestThinkingObservationLayering:
|
||||
"""枚举必须留在最内层,别被后来的重构挪进决策模块。"""
|
||||
|
||||
def test_defined_in_types_not_in_thinking(self):
|
||||
"""`LLMResponse` 拿它当字段类型,定义在 `thinking.py` 会让最内层反向依赖决策层。
|
||||
|
||||
这条不是风格洁癖: import-linter 会判红,但那要等代码写完才发现;本用例
|
||||
把约束前移到类型层面。
|
||||
"""
|
||||
assert ThinkingObservation.__module__ == "polygateway.types"
|
||||
|
||||
|
||||
class TestLLMResponse:
|
||||
def test_eleven_legacy_fields_positional(self):
|
||||
"""三项目 fake 的 11 参位置构造必须零改动成立(迁移兼容硬约束)。"""
|
||||
@@ -75,6 +88,30 @@ class TestLLMResponse:
|
||||
assert filled.model_reported == "MiniMax-Text-01-250321"
|
||||
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
|
||||
|
||||
def test_thinking_observation_defaults_to_unknown(self):
|
||||
"""issue #16/#17: 默认必须是 UNKNOWN——"没信号"不得被伪装成"没推理"。
|
||||
|
||||
默认值取 ABSENT 会让每个不填该字段的构造点(测试 fake、其他 transport)
|
||||
都在替上游做一个它没做过的声明,那正是本 issue 要消灭的静默错觉。
|
||||
"""
|
||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
|
||||
filled = LLMResponse(
|
||||
"c",
|
||||
"t",
|
||||
"m",
|
||||
"p",
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
"cid",
|
||||
thinking_observation=ThinkingObservation.OBSERVED,
|
||||
)
|
||||
assert filled.thinking_observation is ThinkingObservation.OBSERVED
|
||||
|
||||
def test_frozen(self):
|
||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
@@ -251,6 +288,8 @@ class TestAuxTypes:
|
||||
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
|
||||
assert s.cached_prompt_tokens is None and s.model_reported is None
|
||||
assert s.reasoning_tokens is None
|
||||
# issue #16/#17: 不裁定的 transport 只能说"不知道",不能替上游说"没推理"
|
||||
assert s.thinking_observation is ThinkingObservation.UNKNOWN
|
||||
|
||||
|
||||
class TestOcrTypes:
|
||||
|
||||
Reference in New Issue
Block a user