docs: sync the field counts and module map to 1.3.1

The telemetry field count is taken from inspect.signature, not from
memory, because that is the one the release checklist keeps catching.
llm-calls.md said 22 and was two rounds stale; fixing the title alone
would have left the table contradicting it, so tenant_id and meta are
documented too.

The production template needed no new column — it derives them with
LIKE. What it gained is an assertion that it must keep deriving them
and must not inline a column name, which is the drift that could
actually happen.

The changelog leads with the three breaking items. A patch number
carries no warning by design, so the entry has to.
This commit is contained in:
2026-08-26 01:03:25 -04:00
parent 1921a067a1
commit 578a144231
8 changed files with 192 additions and 14 deletions
+86
View File
@@ -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 关闭档恒落此档),噪声即等于没有告警。同一 `(模型, 方向)` 只喊一次。
**保障的覆盖面必须说清楚**: 对账只在可观测路径上成立(推理若真的发生,流式路径会带出正文,翻成 `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` 现在显式转换。旧版本写入的缓存值若不在三态值域内,转换失败由既有降级路径吞成「按未命中回源」——方向正确,不会把坏缓存当好数据用。
- 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 那一路悄悄少一列数据。
## 1.3.0(2026-08-24)
遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。
+2 -1
View File
@@ -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` |
+27 -4
View File
@@ -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`),非法值转换失败由既有 try/except 吞成「按未命中回源」。该字段**不进缓存 key**——它是结果不是请求。
**声明 × 观测对账(同批)**: `reconcile_thinking` 把请求方向(`enable_thinking`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。`False × unknown``None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(model, direction)` 集合,与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同,混用会互相压制)。
这条对账的价值在于把「能力表过期」从**静默错觉**变成日志里的显式告警——能力表过期是必然事件(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"错误。
---
+22 -1
View File
@@ -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"
}
]
}
+8 -5
View File
@@ -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`
+4
View File
@@ -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 篇页面
+30 -3
View File
@@ -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` 调用点;
@@ -1276,6 +1276,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 被权限层拒绝(不是被触发器拒)。