chore: merge release 1.3.5 call observability

This commit is contained in:
2026-09-09 13:26:21 -04:00
40 changed files with 3735 additions and 247 deletions
+3
View File
@@ -113,6 +113,9 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
# # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。 # # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。
# # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里, # # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里,
# # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。 # # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。
# # 1.3.5 补充: error_body(网关响应正文摘要)沿用库内 summarize_body 上限,
# # 结构化耗尽终态行的 error 说明自带有界限长(不含模型原始正文);
# # **两者都不在本键的覆盖面内**,估算留存面时要单独计。
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None # PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价; # # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高 # # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
+34
View File
@@ -1,5 +1,39 @@
# Changelog # Changelog
## 1.3.5(2026-09-09)
把治理单位从「一次尝试」补齐到「一次逻辑调用」(issue #19#23)。此前重试、换源、结构化重问、embedding 分批都各自独立可见,而「这一次调用总共打了几次、总共花了多久、最后为什么失败」在库外拼不出来;结构化耗尽、embedding/OCR 的无源与准入拒绝更是**一条遥测行都没有**。
### 公共面四项变更
| # | 位置 | 变更 | 谁会当场断 |
| --- | --- | --- | --- |
| 1 | `polygateway.CallStats` | 新导出的 frozen dataclass(`logical_call_id` / `attempts` / `total_latency_ms`) | 无(纯新增) |
| 2 | `LLMResponse` / `EmbeddingResponse` / `OcrTextResult` / `OcrLayoutResult` | 各追加**末尾**字段 `call_stats: CallStats \| None = None` | 按位置解包这四个类型的代码 |
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增 **10 个无默认值 keyword-only 参数**(26 → 36 参) | 任何自建 recorder——且**在装配期当场报错**,不再是运行期静默丢行 |
| 4 | 遥测表 `llm_calls` | 追加 10 列(INSERT 字段 26 → 36,物理列 27 → 37);新增 `event_kind='terminal_failure'` 行 | 按旧口径计失败调用数的 SQL |
第 3 条的装配期报错是有意的:`_record``except Exception` 会把旧签名 recorder 的 `TypeError` 吞成 warning,后果是下游升级后 **100% 丢遥测且调用照常成功**。降级方向的铁律管的是运行期写失败,不是装配错误。`**fields` 形态的 recorder 不受影响。
### 下游必须做的事
| 动作 | 说明 |
| --- | --- |
| **计失败调用改 `WHERE event_kind = 'terminal_failure'`** | 一次逻辑调用恰好一条终态行。`error IS NOT NULL` 跨尝试行与终态行,升级后计数会变大 |
| `AVG(latency_ms)``event_kind` 分组 | 终态行的 `latency_ms` 是整个逻辑调用的总耗时,与单次尝试不同量纲 |
| 自建 recorder 补齐 10 参 | 或改成 `**fields`;同时同步自己的 schema 与 INSERT 字段 |
| PG manual 档下游补列 | `telemetry_schema_sql("postgres")` 自取;不补则这 10 个维度按现有列裁剪后静默不落库(库发一条点名警告) |
| 注意失败行可能带 `http_status_code = 200` | MonkeyOCR 的 `success != true` 就是 200 下的失败,该列不可当成败判据 |
费用口径**不变**:终态行 `cost IS NULL``usage_source='unavailable'`、token 为 0,不参与 `SUM(cost)`。400 密集负载下错误行会翻倍(既有 attempt 错误行 + 新增终态行),这是已批准的下游可见变化。
### 其他
- 新列全部可空、无默认值、追加在现有末列之后;**旧行不回填**(NULL 表达「补列之前根本没记过这件事」),滚动升级期新旧进程可写同一张表。
- `error_body` 沿用 `summarize_body` 上限,结构化耗尽终态行的 `error` 带有界说明(不含模型原始正文);**两者都不在 `PGW_TELEMETRY_TEXT_CAP` 覆盖面内**。
- 修正历史误标:`embed()` 非 200 的失败现在报 `operation="embedding"`。遥测新列 `operation` 由调用点给定,与 `exc.operation` 是两个语义。
- 重试预算与退避、429 免预算与 stall 算法、取消结算、限流/熔断语义、缓存 key 公式一字未改。
## 1.3.4(2026-09-09) ## 1.3.4(2026-09-09)
> [!WARNING] > [!WARNING]
+2
View File
@@ -103,6 +103,8 @@ make ci # 只读验证(check + test)
Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI 元数据不含仓库字段,故不会自动挂到 `PolyGateway/packages`,需在包页面手动 Link to a repository。 Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI 元数据不含仓库字段,故不会自动挂到 `PolyGateway/packages`,需在包页面手动 Link to a repository。
发布验证补充(2026-09-09 实测):私有索引不镜像 setuptools,下载 sdist 时 pip 即使带 `--no-deps` 仍可能尝试安装构建依赖;已具备构建工具的 conda 环境可加 `--no-build-isolation`,仍须核对下载来源与散列。包关联 POST 本次返回400,但匿名包页面和认证 GET 包 API 均证实已关联目标仓库;**400本身不算成功,也不能直接推断已关联**,必须读取实际 `repository.full_name` 与页面链接。包 API 可能要求认证(匿名401),页面仍应匿名亲查;不要为重试关联先解绑现有正确关系。
### 4.5 配置管理 ### 4.5 配置管理
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。 - 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。 - 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
+42 -2
View File
@@ -20,7 +20,8 @@
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 | | 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 | | 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 26 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 | | 遥测与成本 | 每次调用(含缓存命中与失败)必录 36 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:四种响应(chat / embedding / OCR 两种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断) |
| 遥测的资源与降级 | 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` 配小了的唯一信号 | | 遥测的资源与降级 | 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、不建索引** | | 调用方维度 | 每次调用可带 `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` | | 遥测表治理 | `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` |
@@ -71,13 +72,50 @@ M8 包括 reasoning_effort、enable_thinking、thinking、thinking_budget、reas
**本版验收例外(2026-09-09 用户正式批准)**:不再补全模型矩阵;既有失败、UNKNOWN、不可达、缺轮及下游现行配置缺证据如实保留,不改成 PASS。M2 两型的定向成功不代表全模型通过;三项目实际配置迁移仍未核验,合成兼容测试不能代替,缓存迁移操作前置也未被豁免。逐项实测、网络诊断与证据索引见[1.3.4 验证记录](research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md)。 **本版验收例外(2026-09-09 用户正式批准)**:不再补全模型矩阵;既有失败、UNKNOWN、不可达、缺轮及下游现行配置缺证据如实保留,不改成 PASS。M2 两型的定向成功不代表全模型通过;三项目实际配置迁移仍未核验,合成兼容测试不能代替,缓存迁移操作前置也未被豁免。逐项实测、网络诊断与证据索引见[1.3.4 验证记录](research-wiki/findings/2026-09-09-134-thinking-contracts-validation.md)。
## 1.3.5 逻辑调用统计与失败诊断
> [!WARNING]
> **升级后失败行会变多,旧的"失败调用数" SQL 会多数。** 每次领域失败除逐次尝试行外另写一条 `terminal_failure` 行;自建 recorder 若未跟进 10 个新参数,会在**装配期**就报错(不是运行期静默丢行)。
本版把治理单位从"一次尝试"补齐到"一次逻辑调用"。两件事: 四种响应带上 `call_stats`;遥测表补 10 列并新增一类行。
### 读 `call_stats`
```python
resp = await client.chat([{"role": "user", "content": "hi"}])
stats = resp.call_stats # EmbeddingResponse / OcrTextResult / OcrLayoutResult 同名字段
stats.logical_call_id # 一次逻辑调用一个 ID:重试/换源/重问/分批共享
stats.attempts # 真实打出去的尝试次数(免预算 429 也计;缓存命中为 0)
stats.total_latency_ms # 含缓存 IO、退避、准入等待、重问与内联遥测收尾的墙钟
```
`call_stats``None` 意为**未知**(如第三方合成的响应、或旧缓存条目回放),不得读成 0。失败时异常对象上**不挂任何统计字段**——要归因请查遥测表。
### 下游 SQL 迁移五项
| # | 改什么 | 理由 |
| --- | --- | --- |
| 1 | 计失败调用改成 `WHERE event_kind = 'terminal_failure'` | 一次逻辑调用恰好一条终态行 |
| 2 | `error IS NOT NULL` 不再是失败调用的判据 | 它同时命中尝试错误行与终态行,升级后计数变大 |
| 3 | `AVG(latency_ms)` 须按 `event_kind` 分组 | 终态行的 `latency_ms` 是**整个逻辑调用**的总耗时,与单次尝试不同量纲 |
| 4 | 费用口径**不变** | 终态行 `cost IS NULL``usage_source='unavailable'`、token 为 0,不参与 `SUM(cost)` |
| 5 | 失败行可能带 `http_status_code = 200` | MonkeyOCR 的 `success != true` 就是 200 下的失败,该列不可当成败判据 |
归因查询的典型形态: 一条 `WHERE logical_call_id = :lcid` 同时拿到整池终态原因(`terminal_failure` 行的 `error` / `error_type`)与逐源现场(`attempt` 行的 `source_name` / `http_status_code` / `cause_type` / `error_body`)。**终态行的 `http_status_code`/`cause_type`/`error_body` 恒为 NULL**: 拿最后一个源的现场冒充整池归因是错的。
### 存储侧升级
新增 10 列全部可空、无默认值、追加在现有末列之后(`scope``operation``logical_call_id``event_kind``http_status_code``error_type``cause_type``error_body``attempts``total_latency_ms`),INSERT 字段 26 → 36、物理列 27 → 37。auto 档自动追加;manual 档一条 DDL 不发、按现有列裁剪写入并点名缺列(补列 SQL 由 `telemetry_schema_sql("postgres")` 自取)。**旧行的新列一律 NULL 且不回填**——NULL 表达的是"补列之前根本没记过这件事";滚动升级期新旧进程可写同一张表。
自建 `TelemetryRecorder` 的下游请同步补齐这 10 个 keyword-only 无默认值参数(或改成 `**fields` 形态),并同步自己的 schema 与 INSERT 字段。装配期形状闸只能证明形状能被接受,证不了函数体真的落这些列。
## 安装 ## 安装
发布在实验室 Gitea PyPI(公开包,匿名可装): 发布在实验室 Gitea PyPI(公开包,匿名可装):
```bash ```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]>=1.3.4,<2" "polygateway[redis,postgres,structured]>=1.3.5,<2"
``` ```
核心仅依赖 `httpx` + `pydantic`;按需选 extras: 核心仅依赖 `httpx` + `pydantic`;按需选 extras:
@@ -398,6 +436,8 @@ PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments``name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。 **`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments``name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
**1.3.5 新增的两处诊断文本同样不在 cap 覆盖内**:`error_body` 记网关响应正文,沿用库内既有的 `summarize_body` 上限(与 cap 无关,也不随它变化);`error` 在结构化耗尽的终态行上带一段有界说明(修复原因至多 200 字符 + 至多 3 条校验错误 × 200 字符,**不含模型原始正文**),自带独立限长。判断留存面时这两列按各自上限估算,不能算进 cap 的四处。
### 7. SQLite 侧的保留期 ### 7. SQLite 侧的保留期
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db``runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。 SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db``runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.3.4" version = "1.3.5"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
+17 -3
View File
@@ -562,9 +562,9 @@ flowchart TB
### 7.8 遥测与成本 ### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范;当前 26 个 INSERT 字段,物理表列 27 = 26 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): 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**、**reasoning_effort**。 **必录字段**(继承三项目 15 字段规范;当前 36 个 INSERT 字段,物理表列 37 = 36 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): 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**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**
**`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。 **`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 最外层调用,后者自 1.3.5 起由三个 client 的公开边界经 `emit_terminal_once` 统一写出)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
**`reasoning_tokens` 列(2026-08-11,issue #6,端口 21 → 22)**: 推理 token 已计入 `completion_tokens`,故成本总额一直是对的——这不是计费缺口而是**归因**缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分,也就无从判断某个 scope 该不该关推理。供应商不报时记 NULL 而非 0(不可得 ≠ 为零,与 `usage_source='unavailable'` 同一纪律)。 **`reasoning_tokens` 列(2026-08-11,issue #6,端口 21 → 22)**: 推理 token 已计入 `completion_tokens`,故成本总额一直是对的——这不是计费缺口而是**归因**缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分,也就无从判断某个 scope 该不该关推理。供应商不报时记 NULL 而非 0(不可得 ≠ 为零,与 `usage_source='unavailable'` 同一纪律)。
@@ -581,6 +581,20 @@ flowchart TB
实际档分析须 `cache_hit=false AND error IS NULL`。embeddingOCR textlayout 的成功与失败尝试由 reasoning_applies=False 保证 NULL,真实 client→emitter→临时 SQLite 与 chat 阳性共同守卫,不能用空行集合证明。生产 emitter 单一出口、端口字段数和 DDL 不变。 实际档分析须 `cache_hit=false AND error IS NULL`。embeddingOCR textlayout 的成功与失败尝试由 reasoning_applies=False 保证 NULL,真实 client→emitter→临时 SQLite 与 chat 阳性共同守卫,不能用空行集合证明。生产 emitter 单一出口、端口字段数和 DDL 不变。
**逻辑调用十列(2026-09-09,issue #19/#23,端口 26 → 36)**: 本版把治理单位从"一次尝试"补齐到"一次逻辑调用"。十列按同一顺序追加在 `reasoning_effort` 之后,全部可空、无默认值、不回填旧行(NULL 表达的是"补列之前根本没记过这件事",与任何哨兵值不同;这一点与 `tenant_id` 故意相反——后者是 RLS 可见性需要哨兵,前者是归因需要区分真实缺口)。
| 列 | 语义 |
| --- | --- |
| `scope` | 池名,`TelemetryEmitter` 构造期注入(三个 client 各一行),三类行都带;**不拿 `source_name` 顶替**(终态失败可能根本没选出源) |
| `operation` | `chat` / `embed` / `recognize_text` / `parse_layout`,**由调用点给定**。它与 `PolyGatewayError.operation` 是两个语义: 后者是异常自报的出错环节(可为 `download_result` 这类子步骤),链路上任何位置不得读它来填本列 |
| `logical_call_id` | 一次逻辑调用一个 ID;上下文缺席(库内现场构造的请求)落 NULL,**不造 ID** |
| `event_kind` | 三态: `attempt` / `cache_hit` / `terminal_failure`,三类行的唯一机械判据 |
| `http_status_code` / `cause_type` / `error_body` | 只在失败的 `attempt` 行上非空(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了");终态行三列恒 NULL(**C1 红线**) |
| `error_type` | 该行自身错误的类名;取消路径传字符串,故为 NULL |
| `attempts` / `total_latency_ms` | 只属终态行(同一份冻结快照,与该行 `latency_ms` 同值),其余两类行 NULL |
两条不变量: **I3** —— 每次领域失败至多一条终态行(`_CallContext.claim_terminal()` 去重;recorder 写失败仍只 warning,故 SQL 可见 ≤ 1);**I4** —— 非领域异常(编程错)**零条**终态行、原样传播、分类不被改写。终态行仍 `cost=NULL``usage_source='unavailable'`、token 0,费用聚合口径不变。诊断值的提取收敛在 Emitter 内一个纯 helper(`_error_fields`),只读领域异常的既有属性,**不遍历任意对象、不解析字符串猜诊断**。另立**装配期形状闸**: 旧签名 recorder 在构造 `TelemetryEmitter` 时当场 `ValueError`——降级方向的铁律管的是**运行期写失败**,装配错误放行的后果是下游 100% 丢遥测且调用照常成功。
**`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` 独立成一档而不再被并进「未推理」。 **`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 同步。 **recorder 收到的必须是裸 `str` 而非枚举实例**: `TelemetryEmitter``_AttemptUsage` 内部持 `ThinkingObservation` 类型,`_record` 下沉时取 `.value``StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只降级为一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一分工(recorder 只落库,不做语义判断)。列序纪律同上: 新列排在最末,两端 DDL 与两份 backfill 同步。
@@ -589,7 +603,7 @@ flowchart TB
**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` 本就无目标。 **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` 本就无目标。
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"``text``response``thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。 **正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"``text``response``thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。**1.3.5 新增的两处诊断文本同样不在 cap 覆盖内**: `error_body` 沿用 `summarize_body` 的既有上限,结构化耗尽终态行的 `error` 说明自带有界限长(`repair=` 至多 200 字符 + `validation=` 至多 3 条 × 200 字符,**不含 `raw_text`**——模型正文的预算已由 attempt 行的 `response` 列承担)。两段限长常量与 `StructuredMW` 重问反馈共用同一份(`format_bounded_errors`),数值只有一份定义。
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder` - 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。 - **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
@@ -0,0 +1,202 @@
# 1.3.5:逻辑调用统计与结构化失败诊断
> 状态:独立审查及定向复审通过,**人类已于 2026-09-09 正式批准**(§10 六项批准项全数获批,可据此实施公共 API)。实施计划见 `research-wiki/plans/2026-09-09-135-call-observability.md`
> 日期:2026-09-09。范围:issue #19#23。基线源码 HEAD e71a623。
> 复用现有 Emitter / schema / 三条治理循环,不引入追踪平台、不做 deadline / hedging、不在库内复制上下游关联状态。
## 1. 问题与边界
治理单位是一次逻辑调用,但当前 `latency_ms` / `call_id` 只描述**单次尝试**:重试几次、等了多久、缓存有没有参与,SQL 答不出来。诊断侧,transport 抛出的领域错误已带 `status_code` / `operation` / `body_text`(errors.py:52),进 emitter 前却被 `str()` 压平成一列自由文本。终态失败可能没有选中源,但 **scope 始终已知**
中转把供应商 529 改写成 503 后,库只能如实记 503,不能猜回 529;未知成本、未知状态一律 NULL。本版不改重试预算、429 语义、stall、推理能力表与取消结算算法,不新增核心依赖。
## 2. 方案
| 方案 | 收益 | 代价 |
| --- | --- | --- |
| A:只在 RetryMW 加两个计数、错误列细分 | 补丁最小 | 漏缓存命中、结构化重问、embedding 分批与三类失败终态,回答不了"整次调用" |
| B:每调用局部统计 + 领域异常下沉到单一出口,复用现有遥测行 | 统一边界、字段保真、不默认加成功行、不双计费用 | 增加内部上下文、响应字段与 recorder 字段;须补齐缺失的失败终态行 |
| C:新增独立逻辑调用表 / 通用事件端口 | 完整追踪与任意事件分析 | 新存储与运维面,超出当前需求 |
推荐 **B**。显式传递局部对象,不用 client 共享可变计数、不用模块级 ContextVar;不统一三条治理循环,只统一计数与诊断出口。
## 3. 逻辑边界与公开统计
四种响应(`LLMResponse``EmbeddingResponse``OcrTextResult``OcrLayoutResult`)追加 `call_stats: CallStats | None = None`。新增 frozen `CallStats` 并由包根导出——四份平铺字段会立刻漂移。
| CallStats 字段 | 语义 |
| --- | --- |
| `logical_call_id: str` | 每次公开调用一个 UUID;重试、重问、分批共享;不占用既有 `parent_call_id` |
| `attempts: int` | 准入后实际调用 transport 端口的次数;含免预算 429 与端口本地拒绝;**不是 HTTP 请求条数** |
| `total_latency_ms: int` | 从输入校验通过到返回/异常传播前的单调时钟快照;含缓存、等待、重问、分批、内联记账与资源收尾 |
输入校验异常发生在统计边界之外,保持原行为。第三方合成响应的 `None` 表示未知,不得默认伪造 0。
- **缓存命中**:`attempts=0`、新 logical ID、本次缓存路径耗时;不回放历史统计。缓存持久化排除 `call_stats`(`_serialize` 显式剔除),`_rehydrate` 显式覆盖为 `None`——`_RESPONSE_FIELDS` 过滤会放行历史 dict,不覆盖就会有 dict 冒充 `CallStats`。缓存 key 白名单不变。
- **embed 空输入**(embedding.py:180-190):合法零尝试,返回真实统计(`attempts=0`),**不写任何遥测行**——与 cache_hit 不同,不要按"必录"推断它有台账行。
- **OCR layout** 的 POST + ZIP GET 在同一 transport 调用内(monkey_ocr.py:262-296),计 1 次尝试。
- **chat 重问 / embedding 多批**计入同一上下文,不重置计数(重问经 `call_next` 重入 RetryMW,已核对)。
`latency_ms` / `call_id` / `parent_call_id` 语义不变;"总耗时减最后一次尝试耗时"不等于纯等待(含其他本地工作)。不在响应里挂每次尝试的明细列表,避免公共响应无界增长。
### 3.1 失败与取消
本版**不向异常对象附加可变 `call_stats`**:第三方可能复用同一异常实例,first-write-wins 会把首次调用的统计误读成本次,覆盖写则串扰;复制任意异常又保证不了构造签名与自定义属性。异常类型与分类原样保留,失败侧的统计走 §6 的终态行。
- 取消:`CancelledError` 保持原类型与语义,不在其上加字段;取消路径只**尽力**写一条终态,不 shield、不开新后台任务。
- 任意内部非领域异常(编程错)原样传播,本版**不承诺**为其提供任何统计或终态行,也不偷偷改分类。
## 4. 最小内部接缝
`CallStats` 与私有可变 `_CallContext` 都落 `types.py`:`types.py` 不反向依赖实现层,不产生循环,也不动 import-linter 分层(`ports : types : errors` 并列最内层)。私有上下文只持计数、单调时钟与必要去重状态,不做 I/O。
`ChatRequest` 追加内部上下文字段(`default=None, compare=False, repr=False`);`StructuredMW``dataclasses.replace` 保留同一引用(structured.py:96-110,已核对)。响应统计只在公共出口经 `replace` 附加。`GatewayClient` 需自存注入的 `now`(client.py 现未保存),**冻结快照是同步动作,不 await**。
- 上下文创建/冻结:`GatewayClient` 在公开入口创建、`finally` 冻结;`RetryMW` 只在 transport 调用前登记一次尝试。
- **OCR 例外(M1)**:`image` 的类型/空校验在 `_call` 内(ocr.py:238-241)而非公开方法,故上下文在该校验**通过之后**创建,§3 的"校验在边界外"对 OCR 才成立。
- `EmbeddingClient` / `OcrClient` 显式把同一上下文传到每批/每次尝试;统计生效与否**不由 telemetry 是否启用决定**。
- 每次尝试 ID 仍在当前循环产生,与上下文的 logical ID 一起交给 Emitter。不持久化断点,不引入任务恢复。
## 5. 诊断字段与归因方式
保留现有 `error` 字符串供人阅读;Emitter 改为接受**领域异常对象**而非调用方先 `str()`,由单一 helper 提取有限诊断字段。普通超时/网络翻译在 transport 侧保留直接 `__cause__` 类型,空 `str()` 退回类名;**不遍历任意异常对象、不猜测正文**。
| 新增 INSERT 列 | 值域 / 来源 |
| --- | --- |
| `scope` | 配置池名,构造期注入 Emitter(见下);不拿 `source_name` 顶替 |
| `operation` | **公开方法固定四值**:`chat` / `embed` / `recognize_text` / `parse_layout`;由三个 client 在调用点给定 |
| `logical_call_id` | 当前调用上下文的 UUID;上下文缺席(库内现场构造的 `ChatRequest`)→ **NULL,不造 ID** |
| `event_kind` | `attempt` / `cache_hit` / `terminal_failure`;旧行 NULL,不回填 |
| `http_status_code` | **仅 attempt 行**:失败异常实收状态;无 HTTP 或未知 NULL;成功行不统一填 200 |
| `error_type` | 该行自身错误的领域类名;成功行 NULL |
| `cause_type` | **仅 attempt 行**:transport 直接捕获的底层异常类名,未知 NULL |
| `error_body` | **仅 attempt 行**:既有 `summarize_body` 有界摘要,未知 NULL;不存 `raw_text`、不存全量原文 |
| `attempts` / `total_latency_ms` | **仅 terminal_failure 行**填逻辑快照,其余行 NULL;快照在写入前冻结 |
全部可空,追加到物理列末尾(与旧表 ALTER 追加位置一致,`schema.py` 的 DDL / BACKFILL / COLUMNS 三处同改)。
**归因方式(C1,决定性)**:`GatewayUnavailableError` 家族从不携带 `status_code` / `body_text`(errors.py:118-166),终态行的 `http_status_code` / `cause_type` / `error_body` 因此**保持 NULL,这是它自身的真实状态**——不把最后一次 attempt 的状态码与正文搬上来伪装成整池诊断(那正是"不拿最后一个源冒充整池归因"的同一条红线)。终态行的 `error_type` 落它自己的类名(`AllSourcesExhausted` / `CircuitOpenError` / …),scope 级 reason 沿用**已有 error 文案**(`str(exc)` 已是 `"{scope} 网关暂时不可用: {reason}"`,不新增列)。逐源现场由同一 `logical_call_id` 的 attempt 行给出。
```sql
-- #19 验收:一次逻辑失败调用的完整现场(终态 + 各次尝试)
SELECT event_kind, source_name, http_status_code, error_type, cause_type, error_body, error
FROM llm_calls WHERE logical_call_id = :lcid ORDER BY created_at;
```
该查询必须同时给出"整池为何失败"(终态行 error 文案)与"每个源怎么死的"(attempt 行状态码/正文),测试按它断言。因此**本版不再为 `reason` / `per_source_reasons` 扩列**。
**结构化耗尽的可定位性(C2)**:`ResultInvalidError("结构化输出阶梯耗尽")` 的 message 不含 `validation_errors` / `repair_error`(structured.py:80-86),而该失败发生在 StructuredMW 之上——RetryMW 侧的 attempt 行全是成功行,终态行是唯一记录。做法:**不写 `error_body`**(该列只属 attempt),由 Emitter 的单一 helper 对 `ResultInvalidError` 生成**有界结构化说明**并入现有 `error` 字符串,复用 structured.py 已有的取材口径(至多 3 条、每条 200 字符,与 `_format_errors` 同参数,拼装函数收敛在 helper 一处)。`raw_text` **不重复落库**(它是模型正文,attempt 行的 `response` 列已按 `text_cap` 记过一份;再存一份等于绕过既有正文预算)。该说明可能包含模型输出片段,故遵循与 `error` 现有正文相同的隐私边界,不额外扩大保留范围。
**operation 的数据源(I1/I2)**:openai_compat.py:169 的 `_status_to_error` 硬编码 `operation="chat"`,而 `embed()` 的非 200 分支(:512)也走它 → 现存所有 embedding HTTP 失败的 `exc.operation` 都是错的。(行号勘误 2026-09-09:本句原写 "(:512、:527)",实测 `:527``_complete_stream`即流式 chat,`embed` 只有 `:512` 一处;归属以实施计划 §2 表为准,决策未变。)修正:`_status_to_error` 增 keyword `operation`,`embed`**`"embedding"`**(沿用该异常侧既有词表,不改 `chat` / `ocr_text` / `parse` / `download_result` 四值)。新列 `operation``exc.operation` 是**两个语义**:前者是公开方法,后者是 HTTP 子操作;新列由调用点给定,**绝不读 `exc.operation`**,两者不做自动转换。OCR 两个公开方法各自在调用级给定自己的值。
**scope 注入(M6)**:`TelemetryEmitter` 现在不知道 scope,`CacheMW` / `TelemetryMW` 自己也拿不到。构造期注入(三个 client 各一行),使 attempt / cache_hit / terminal_failure 三类行都带 scope,避免改三条调用链。model / provider / source 未选出时仍留原空值。
**成功侧不加承诺**:成功行不承诺 HTTP 状态与错误体;**本版不宣称 SQL 可直接统计所有成功逻辑调用的总耗时**(成功不加终态行)。`error_body` 沿用 `summarize_body` 的既有上限,**不纳入 `PGW_TELEMETRY_TEXT_CAP` 覆盖面**(该键现覆盖四处,详见 §8)。
## 6. 行语义与终态:只补确实缺失的失败
保留每次 attempt 与 cache_hit 的既有行,**不为成功新增终态行**。终态行不得复制已有 attempt 的 token 与成本。所有统计边界内的领域失败均尝试写终态,**包括已有 attempt 错误行的直接 RequestRejectedErrorResultInvalidError**;不再沿用“该类错误已录所以外层不录”的旧假设,400 密集负载的错误行可能翻倍,调用失败计数必须只取 terminal_failure。
**不变量(I3,统一措辞)**:
| 结束形态 | 终态行数 |
| --- | --- |
| 以**领域错误**结束的逻辑调用 | 每次调用**尝试写一条**;持久化 best effort(recorder 写失败按既有降级只落 warning),故 SQL 可见行数 ≤ 1 |
| 取消 | 三个 client **同策略尽力写一条**,允许 0 条 |
| 非领域异常(编程错) | **0 条**,原样传播,本版无统计保证 |
需要补的路径:chat 结构化耗尽(发生在 transport 成功之后,现无任何失败行)、embedding / OCR 的无源、准入拒绝、重试耗尽与尝试外取消。**取消口径统一(I4)**:chat 现由 TelemetryMW 对任何取消补终态(telemetry.py:487-495,含尝试内取消),embedding / OCR 按同一口径尽力补,避免下游按 `event_kind` 统计取消时拿到路径相关的结果。
终态与 attempt **不是重复事实**(前者描述逻辑终态,后者描述尝试),用 `event_kind` 区分;**禁止按 `error IS NOT NULL` 跨两类直接计失败调用次数**。chat 现有 TelemetryMW 终态路径收敛到公开边界的单一 helper,避免两处同时写;embedding / OCR 复用该 helper。
**终态行的请求摘要(M4)**:复用既有 200 字符输入摘要口径,描述**本次调用的整体输入**,但不扩大单行正文预算——embedding 终态取 `<embed texts=N batches=M>` 计数占位 + 第一批(至多 `batch_size` 条、每条 200 字符,与逐批行同款构造);OCR 终态沿用 `<ocr:{kind} image_bytes=…>` 占位,图像 bytes 永不入库。**失败批的具体文本由同 `logical_call_id` 的 attempt 行给出**,终态行不保存全量原输入。
**错误文本口径(I7)**:OCR 现落 `"类名: msg"`(ocr.py:445-449,按类名归组的既有 metric 口径),chat / embed 落裸 `str(exc)`。改成"Emitter 收异常对象"后,该差异由统一出口的**显式文本策略参数**保留(OCR 保留类名前缀),**不再在三处复制参数列表**。
**取消时的终态写(残余风险,显式定策)**:该 `await` 本身是新的取消点。策略是**取消优先、不屏蔽**:外部取消落在这一 await 上时,`CancelledError` 照常传播(调用方可能因此看到 `CancelledError` 而非领域错误,与 TelemetryMW 现有行为同款);不 shield、不建新后台任务。冻结统计快照是同步动作,不 await。
终态行成本 NULL、usage `unavailable`;聚合费用仍只由 attempt / cache_hit 行决定。终态快照在写入前冻结,故不含自身写入耗时;成功响应快照包含其返回前已完成的内联遥测耗时。失败异常不附快照,不为对齐再 UPDATE 旧行。
取消 attempt 既有字符串 `"cancelled"` 保留:Emitter接收 `PolyGatewayError | str | None`,字符串不解析猜测诊断,error_type/cause_type/http_status_code/error_body均NULL;终态取消同样使用明确取消文案。终态既有latency_ms与新增total_latency_ms取同一冻结快照,避免双时钟微差。
## 7. recorder 兼容:装配期机械闸(C3)
`TelemetryEmitter._record``except Exception` 会把旧 recorder 的 `TypeError` 吞成 warning(telemetry.py:463),后果是自定义 recorder 在下游升级后**100% 丢遥测且调用照常成功**——正是"遥测必录"要防的形态。文档级迁移清单挡不住它。
机械闸:在 `TelemetryEmitter.__init__`(三个 client 的唯一汇流点,与 `text_cap` 值域校验同处)对 `recorder.record_llm_call` 做**一次** `inspect.signature(...).bind(**<完整新 kwargs 形状>)`,**不执行写入**;含 `**kwargs`(`VAR_KEYWORD`)者自动通过。校验失败 → 装配期抛错。若目标不可 inspect(C 实现等),同样按**配置错误**当场报错,不进入"运行期静默丢行"。
边界诚实声明:签名 bind 只证明该形状能被接受,**不能证明函数体真的落这些列**;这是一道装配闸,不是行为验证。它既不是被否掉的 `runtime_checkable` 判定,也不是捕 `TypeError` 重试写入。是否扩主 `TelemetryRecorder` Protocol(备选:独立扩展端口保留旧实现)是 §10 的人类批准项;本草案不同时实现两套接口,自带两个 recorder 的 `**fields` 签名可接受新增参数,但其 schema、INSERT 字段与契约测试仍须同步,不能称后端完全不受影响。
校验所用参数名从 `TelemetryRecorder.record_llm_call` 的协议签名派生,不手抄第四份字段清单;只用哨兵值做bind形状校验,不读取真实请求数据。结构化说明的限条数/限长复用现有常量,若需命名常量则在既有规则所有者中定义并由两个消费者引用,不复制数值。
## 8. 下游可见变更与文档同步
**SQL 迁移清单(I6,批准项须按此逐条看)**:
| 影响面 | 变化 | 下游动作 |
| --- | --- | --- |
| 失败行数 | 新增 `terminal_failure` 行(每失败调用至多 1) | 计失败调用改 `WHERE event_kind = 'terminal_failure'` |
| `error IS NOT NULL` | 同时命中 attempt 与 terminal 两类 | 不再作为"失败调用数"的判据 |
| `AVG(latency_ms)` | terminal 行携带**逻辑总耗时**,量级大于单次尝试 | 时延看板一律按 `event_kind` 分组或过滤 |
| 费用聚合 | terminal 行 `cost` 恒 NULL、usage `unavailable` | 费用仍只由 attempt / cache_hit 行决定,口径不变 |
| 成功侧 | **不加**任何成功汇总行 | 成功逻辑调用总耗时仍从响应 `call_stats` 读,不从 SQL 读 |
| `http_status_code` | 失败行上可能是 200(monkey_ocr.py:265-270 的 `success != true` 带 200 上抛) | 该列不可作失败判据 |
**文档同步(M5,发布前必须同批)**:README:23 的"必录 26 字段"、README:399 与 ARCHITECTURE.md:592 的 `PGW_TELEMETRY_TEXT_CAP` 覆盖面四处枚举(须明确:`error_body` 沿用 `summarize_body` 上限,`error` 保留既有文本口径并仅对新增结构化说明限长;二者**不在 cap 覆盖内**)、ARCHITECTURE.md:565 的必录字段清单与 §7.8 补列一节、`.env.example` 相关注释、CHANGELOG 与 wiki(docs-convention §2)。**字段数与物理列数一律以 `inspect.signature` / `len(COLUMNS)` 实测改写,不凭记忆**(现状:26 个 INSERT 字段 + `created_at` = 27 物理列;本版新增 10 列)。
## 9. 非功能与测试矩阵
| 维度 | 要求 |
| --- | --- |
| 并发 | 每调用独立对象;同一 client 并发不串 logical ID / 计数 / 统计;无全局状态 |
| 取消 | 各等待点穿透;`finally` 释放既有资源;终态写取消优先;不新增 shield 与后台任务 |
| 降级 | recorder 写失败不改统计与主结果;准入后端仍 fail-closed;收尾失败仍原 warning |
| 持久化 | schema 单一事实源;SQLite auto / PG manual 裁剪 INSERT 保持;不 ALTER 默认生产 PG、不改旧列、不回填旧行 |
| 幂等 | attempt ID 唯一,终态独立 ID,不重复写同一终态;缓存命中不复制历史统计;不 UPDATE 计费 |
验收优先离线:真实 client + 内存后端 + FakeClock + MockTransport + 临时 SQLite,复用 1.3.4 设施;不重跑未变的模型能力矩阵。
| 测试族 | 必须证明 |
| --- | --- |
| logical 计数 | 一次成功、失败重试、免预算 429、多源拒绝、缓存命中、结构化重问、embedding 多批、OCR 双 HTTP、空输入(0 尝试且 0 遥测行) |
| 计时 | 缓存 IO、退避、准入等待、重问、收尾均计入;关闭 recorder 仍正确;毫秒/秒不混用 |
| 失败与取消 | 领域失败恰一次尝试写终态;取消三条路径同策略(允许 0 行);**终态写 await 上被取消 → `CancelledError` 传播**;permit / 探针释放不变;非领域异常 0 行且原样传播 |
| 保真诊断 | 503 包装不改回 529;直接 529 记 529;空 Connect/Read/Write/PoolTimeout 文案有类型;embedding HTTP 失败的 `exc.operation``embedding`;新列 `operation` 恒为四值之一且不随异常变化 |
| 归因 SQL | §5 那条按 `logical_call_id` 的查询同时给出终态 reason 文案与逐源状态码/正文;终态行三列为 NULL;结构化耗尽的 `error` 含有界 validation/repair 说明且不含 `raw_text` |
| 行语义 | 三类行均带 scope;每失败调用至多一条终态;attempt 与 terminal 区分;费用不重复;`AVG(latency_ms)``event_kind` 分组的断言 |
| 装配闸 | 旧签名 recorder → 装配期报错(非 warning);`**kwargs` recorder 通过;不可 inspect → 配置错误 |
| 存储兼容 | SQLite 新旧表、PG manual 缺列裁剪 / auto 追加、旧行 NULL、新旧进程混写 |
| 变异 | 计数位置、上下文复制、缓存历史回放、提前 `str()` 压平、终态双计费用分别红→绿;PG 真实集成 + 常规全套 + 独立验证 |
## 10. 集中人类批准项
| # | 决策 | 推荐 |
| --- | --- | --- |
| 1 | 公开响应结构 | 一个 `CallStats` 对象而非四类响应各铺三字段;需确认命名与消费便利性 |
| 2 | 失败侧统计范围 | 不改/不复制异常;失败统计只落终态行,调用方仅在成功响应读 `call_stats`;需确认该取舍可接受 |
| 3 | recorder 兼容路线 | 扩主 `TelemetryRecorder` Protocol + 装配期 bind 闸;备选独立扩展端口保留旧实现;需确认是否存在必须兼容的自定义 recorder |
| 4 | 新增失败终态行 | 补漏但不加成功汇总;须批准 §8 表中**全部五项**下游可见变化(不止行数) |
| 5 | 取消时终态写取消优先 | 调用方可能看到 `CancelledError` 而非领域错误(同 TelemetryMW 现状);需确认接受 |
| 6 | `operation` 值域与异常侧修正 | 新列固定四值;`_status_to_error``operation` 参数、`embed``embedding`(修正现存误标,属下游可见的历史数据口径变化) |
## 11. 独立审查处理表
| 项 | 结论 | 落点 |
| --- | --- | --- |
| C1 终态缺诊断来源 | **不采纳"定向读 `__cause__` / `per_source_reasons` 扩列"**;终态保留自身 NULL 状态,归因由 `logical_call_id` 关联 attempt 行 + 既有 reason 文案完成,并写死 SQL 验收 | §5 |
| C2 结构化耗尽可定位 | 采纳(变形):有界说明并入现有 `error` 字符串,不写 `error_body`、不重复存 `raw_text` | §5 |
| C3 recorder 静默失败 | 采纳:装配期一次 `signature.bind` 形状校验,含 `**kwargs`,不可 inspect 即配置错误;明确不验证函数体 | §7 |
| I1 / I2 operation 污染与未归一 | 采纳:新列固定四值由调用点给定,绝不读 `exc.operation`;只修 embedding 误标,其余异常侧词表不动 | §5 |
| I3 / I4 终态不变量与取消口径 | 采纳(定稿措辞):领域失败每调用尝试写一条、持久化 best effort;取消三路同策略尽力允许 0;非领域异常 0 条 | §6 |
| I5 上下文缺席语义 | 采纳:`None` → NULL,不造 ID | §5 |
| I6 迁移影响不止行数 | 采纳:列全五项 SQL 影响并进批准项 | §8、§10 |
| I7 OCR error 文本口径 | 采纳:保留类名前缀,由统一出口的显式文本策略参数承载,不复制参数列表 | §6 |
| M1 OCR 校验与统计边界 | 采纳:上下文在 `image` 校验通过后创建 | §4 |
| M2 embed 空输入 | 采纳:0 尝试且不写任何遥测行 | §3 |
| M3 失败行可能带 200 | 采纳:写明该列不可作失败判据 | §8 |
| M4 终态请求摘要未定 | 采纳:复用 200 字符口径,终态描述整体输入,不扩预算,不存全量原输入 | §6 |
| M5 文档同步缺项 | 采纳:列全六处并要求实测改数字 | §8 |
| M6 Emitter 不知 scope | 采纳:构造期注入 | §5 |
| 残余风险(终态写成新取消点) | 采纳:显式定策"取消优先不屏蔽",并进测试族与批准项 | §6、§9、§10 |
自审:方案 B 复用既有 Emitter / schema 与三条循环,不需要 #22 / #24 的新调度。未采纳的两项(异常 first-write-wins、终态搬运最后一次 attempt 的状态与正文)理由已写在正文,不是遗漏。本文档无代码实现与测试通过声明;**§10 六项已于 2026-09-09 获人类批准**,实施边界与红绿证据要求以上述实施计划为准。
@@ -7,7 +7,7 @@ date: 2026-09-09
# 1.3.4 推理契约验证与发布准备 # 1.3.4 推理契约验证与发布准备
> 最新状态(2026-09-09):发布准备完成;用户已正式批准未补全模型矩阵、失败UNKNOWN/不可达/缺轮及缺下游现行配置证据作为本版验收例外,保留原始结论而非 PASS。可移交合并与包发布;本轮未 merge/push/tag/构建/上传,合并后门与外部产物验收仍待执行。以下各节是分阶段历史,不追改当时结论;最新证据与例外见文末。原始输出在 `tests/outputs/134/`,不提交。 > 最新状态(2026-09-09):**1.3.4 已发布并完成外部验收,#21/#25/#26 已评论关闭**。main/tag 指向 `af57f93adce24b43fd10b6d8e1281ab8ee43c0a8`;合并后门、下载独立安装及页面结果见文末。用户批准未补全矩阵、FAILUNKNOWN/不可达/缺轮及缺下游配置例外保持原结论,不冒充 PASS。以下为分阶段历史,不追改当时结论;原始输出在 `tests/outputs/134/`,不提交。
## 基线与修改边界 ## 基线与修改边界
@@ -233,3 +233,36 @@ pi-lens 仍报非 conda 解释器缺 httpxpytestdotenvpydantic 及旧 S
conda 启动器既有 RequestsDependencyWarning 保留,不宣称零告警。本轮 conda 内实际导入 dotenvhttpxredis.asyncio 成功;工具的非conda LSP旧诊断不转成代码修改或忽略规则。 conda 启动器既有 RequestsDependencyWarning 保留,不宣称零告警。本轮 conda 内实际导入 dotenvhttpxredis.asyncio 成功;工具的非conda LSP旧诊断不转成代码修改或忽略规则。
**可移交合并与包发布,不等于已发布。** 父会话按本版例外边界完成本次文档/版本差异审查,再执行合并后静态/日常门及未豁免的发布检查;不把本次例外解释为必须补全模型矩阵,也不把豁免项勾成已跑通过。merge/push/tag/构建/twine上传/下载解包独立安装/Release及registry页面检查均尚未执行;只能在实际完成后记录。不得覆盖已有同版本不同字节。 **可移交合并与包发布,不等于已发布。** 父会话按本版例外边界完成本次文档/版本差异审查,再执行合并后静态/日常门及未豁免的发布检查;不把本次例外解释为必须补全模型矩阵,也不把豁免项勾成已跑通过。merge/push/tag/构建/twine上传/下载解包独立安装/Release及registry页面检查均尚未执行;只能在实际完成后记录。不得覆盖已有同版本不同字节。
## 1.3.4 发布完成与外部验收(2026-09-09)
父会话已审 `dae12f9` 发布准备 diff,用户授权发布及指定例外;本轮无新生产/测试改动、不派子代理。已有红绿/变异与独立报告按适用范围复用,716份历史证据 SHA-256 复核不变。以下日志均相对 `tests/outputs/134/publish-20260909/`,命令无掩盖退出码管道;长跑由 tmux `pgw134-publish` 串行执行,`PYTHONUNBUFFERED=1`,每门独立 `.exit`
| 步骤 | 实际结果与证据 |
| --- | --- |
| 远端核验/合并 | fetch后 origin/main=`a716f12` 无新变更;精确文件名检查 registry 无1.3.4tag无占用、Release404。`--no-ff` 合并为 `af57f93adce24b43fd10b6d8e1281ab8ee43c0a8`,与批准 `dae12f9` 树零差异;`merge.log``merge-summary.log` |
| 合并后 `make lint` | exit0ruffimport-linter 1 kept、0 broken,无自动修改;`lint.log/.exit` |
| 合并后 `make test` | **1518 passed、23 skipped、108 deselected95%覆盖率,276.42秒,exit0**`daily.log/.exit`。skip与deselected不计通过 |
| M2 AUTO定向 slow | **2 passed、80 deselected46.85秒,exit0**;各5轮、并发1,空wire流式开启命题通过;`m2-auto.log/.exit`。M2.5 run `d4b158162ad543e5a4745d21b29ab03e`M2.7 run `07a388cafa9f4fcf95dc0c6876fd0751`,均 planned=completed=5、无缺轮;实际逐轮/命题报告已读取 |
| Redis时间语义 slow | contracts/integration选择 **18 passed、156 deselected1109.45秒,exit0**`redis-time.log/.exit``gates.exit=0`,未重启全型号矩阵 |
| push与tag | main推送成功;注释tag对象 `0bdb0f70e4667a2fe50a567f0809a5a421c5442a`,解引用同上述main`push-main.log``push-tag.log``remote-refs-after.log` |
| 构建/上传 | 核实项目绝对dist路径且不是链接后清旧1.3.3产物;conda `python -m build``twine check dist/*`、twine上传均exit0wheel/sdist各一份。凭据只从tea内存读取进入TWINE_PASSWORD,无token argv/日志;`build``twine-check``upload``.log/.exit` |
| 独立下载/解包 | registry下载wheel与sdist,与本地构建SHA-256逐字匹配;包内五个关键生产文件与发布提交相同,wheel METADATA版本/Markdown正文、sdist README均与已发布README相符;`download-final.log/.exit``download-verification.json` |
| 独立安装与行为 | `/tmp/pgw134-registry-eyges3d7/installed/polygateway/__init__.py`,仓库外 `python -I` 并显式target来源验证;M2空AUTO、M3 AUTO在error/nearest均拒绝、M3 medium、同值/新增raw冲突与raw-only保留全部通过 |
| Release/包页面 | Release POST201,正文逐字来自CHANGELOG本节。匿名实际读取包页面、单版Release及Releases列表,均HTTP200、正文含迁移/例外;包页面有目标仓库链接,认证包API的 repository.full_name=`iomgaa/PolyGateway``pages-final.log/.exit``external-verification.json`及HTML/可见文本原件 |
| issue收尾 | 仅#21/#25/#26评论后关闭,GET各自核实closed;评论明确验证范围、失败例外、缓存迁移及“不识别所有渠道故障”;`issues.log/.exit``issue*-after.json`。未改#19/#22/#23/#24 |
产物 SHA-256wheel `86ad5cc025bfda127c4f0e4bbe1252d1bb8943b7678f9ff5561200bac71fe2a2`sdist `85620f993ff9d19845088a071d251049e3dfae8d035c05652bdb62b4340a1a66`
外部亲查地址:[包页面](https://gitea.iomgaa.online/iomgaa/-/packages/pypi/polygateway/1.3.4)、[v1.3.4 Release](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.3.4)、[Releases列表](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases)。结果补记在独立 `docs/1.3.4-release-evidence` 分支,不改变已发布main/tag或覆盖同版本产物。
### 真实失败与恢复(原件保留,不改写成功)
| 检查失败 | 根因/处置与边界 |
| --- | --- |
| 最初索引grep误报占用 | `1.3.4` 被当正则匹配历史sha片段;改用完整字面 `polygateway-1.3.4`,并核对tag/Release均不存在。没有实际同版占用,没有覆盖 |
| 首次sdist下载exit1 | 私有索引无setuptoolspip即使 `--no-deps` 仍启构建隔离;`download.log/.exit`保留。复用已下载wheel,sdist加 `--no-build-isolation` 使用现有conda构建工具后exit0;不新增依赖、不重传包 |
| 包link POST400 | 返回 `invalid argument``release-create.log/.exit`保留exit1;不把400当成功/不猜根因。匿名页面已有仓库链接,认证GET包API证实本版正确关联,故无需解绑重挂;Release创建201不重复执行 |
| 首次页面验证exit1 | 三个匿名页面及Release正文均通过,但额外包API匿名GET401;`pages.log/.exit`保留。仅该API沿现有tea认证GET后200,最终所有页面/API核对exit0;不扩大为发布包匿名不可下载 |
上述操作差异已补入CLAUDE发布清单。conda启动器既有RequestsDependencyWarning仍保留。用户验收例外完全沿前节:embedding503与claude命题仍FAIL、M3非流UNKNOWN及其余未覆盖/缺下游资料未变;本轮成功不外推全矩阵或所有渠道。1.3.4发布任务到此停止,1.3.5由父会话继续。
@@ -0,0 +1,290 @@
---
type: finding
node_id: finding:2026-09-09-135-call-observability-validation
title: "1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵"
date: 2026-09-09
---
# 1.3.5 T2/T3 验收证据
> 范围:**仅 T2(遥测 10 列 / 诊断保真 / scope+operation / 装配闸)与 T3(终态行 / 取消 / 统一出口)**,
> 外加计划 T4 中"签名与列数机械迁移"那一片(与 schema 同批完成,避免先提交 schema 却留写入缺键)。
> **不含** T4 的 PG 集成、变异矩阵与文档同步——另任务承接,缺口见 §5。
> 设计:`designs/2026-09-09-135-call-observability-design.md`;计划:`plans/2026-09-09-135-call-observability.md`
> 基线 HEAD `87c261b`T1 已提交,1419 unit 全绿)。全部命令在 `PolyGateway` conda 环境执行。
## 1. 红绿证据链
TDD 纪律要求"先失败后通过",且红必须是**行为红**而非 import 红。下表每行都对应本会话的真实工具输出。
| # | 阶段 | 命令 | 结果 |
| --- | --- | --- | --- |
| 0 | 基线 | `pytest tests/unit -q` | **1419 passed**(对照底) |
| 1 | 红(T2/T3 目标行为) | `pytest tests/unit/test_telemetry.py -k "RowLevelObservability or RecorderShapeGate"` | **26 failed**,证据 `tests/outputs/135/red-01-emitter-rows.txt` |
| 2 | 实现后全量红面 | `pytest tests/unit -q` | **137 failed / 1308 passed**(机械迁移面暴露),`red-02-after-impl.txt` |
| 3 | 迁移中 | 同上 | 60 → 25 → 8 → 4 failed`red-03`/`red-04`/`red-05` |
| 4 | 绿 | `pytest tests/unit -q` | **1467 passed, 0 failed** |
| 5 | 静态门 | `make check`ruff lint+format + import-linter | **Contracts: 1 kept, 0 broken** |
第 1 步的红是行为红而非 import 红:新用例调用的是**已存在**的 `TelemetryEmitter`
失败形态是"缺 `scope=`/`operation=`/`stats=` 关键字"与"断言的列不存在",不是模块导不进来。
补测阶段另有两次真实红(均由 conda 实跑暴露、当场修正,非噪音):
`test_client.py``NameError: ResultInvalidError / ChatRequest`(漏 import)、
`TestTerminalRowSqlSemantics``'coroutine' object has no attribute 'execute'`async helper 漏 await)。
## 2. 核心要求逐条对应
### 2.1 36 列与 Emitter 完整一致(不留"schema 有列、写入缺键"
一次性同批改完五处列定义 + 端口签名 + Emitter 写入,故不存在中间态。
| 判据 | 证据 |
| --- | --- |
| `len(COLUMNS) == 36`、物理列 37 | `test_telemetry.py::TestSchemaModule::test_columns_and_ddl_are_frozen``TestSQLiteSchemaMode` 断言 23 → 37 |
| 五处列序一致(DDL/BACKFILL×2/COLUMNS | 新建库与 ALTER 追加列序同为 `_EXPECTED_COLUMNS` |
| 端口实测 36 字段、10 新参 keyword-only 且无默认值 | `test_ports.py::TestTelemetryRecorderSignature``inspect.signature` 实测,不凭记忆) |
| **Emitter 实参键集合 == `schema.COLUMNS`** | `TestEmitterRecorderContract` 三入口逐个断言 `set(rows[0]) == set(COLUMNS)` |
| 1.2.1 冻结 INSERT 未被改写 | 按 `_PRE_135_COLUMNS`(26 列)重现原文;全量 36 列另按占位符个数断言 |
装配闸(C3):`_assert_recorder_shape``TelemetryEmitter.__init__` 做一次 `signature.bind`
参数名**从协议签名派生**而非手抄第四份清单——`test_gate_derives_parameter_names_from_the_protocol`
用 monkeypatch 换掉协议后闸自动跟随,证明没有硬编码。旧签名 recorder / 无该方法 / 不可 inspect
一律装配期 `ValueError`(不是 warning:降级铁律管的是运行期写失败,不是配置错误)。
### 2.2 三个 client 的**真实链路**失败终态
不是只测 Emitter,而是驱动真实 client + MockTransport 到落库。
| 链路 | 用例 | 断言 |
| --- | --- | --- |
| chat 重试耗尽 | `TestChatTerminalFailureRows::test_retry_exhaustion_writes_exactly_one_terminal_row` | 3 条 attempt + **恰 1 条**终态;终态 `attempts == 3` |
| chat 结构化耗尽 | `test_structured_exhaustion_writes_the_only_failure_row` | attempt 行**全是成功行**,终态是唯一失败记录;error 含有界 `validation=`/`repair=` 且 < 1200 字符、不含 raw_text |
| chat 400 直拒 | `test_request_rejected_now_has_both_an_attempt_and_a_terminal_row` | 尝试行 + 终态行**各 1**(已批准的行数翻倍);两行共享同一 `logical_call_id` |
| embedding | `test_embedding.py::TestReasonlessTelemetryContract::test_failed_attempts_still_have_null_effort` | attempt 数 == 脚本长度,终态恰 1 |
| OCR 两方法 | `test_ocr_client.py` 同名用例(参数化 `recognize_text`/`parse_layout` | 终态恰 1,且 `operation` 为**公开方法名** |
去重:`test_terminal_row_is_written_once_per_logical_call` 连调出口 3 次,SQL 可见仍 1 条(`claim_terminal`)。
非领域异常:`test_non_domain_exception_writes_no_terminal_row` —— `KeyError` 原样传播、**0 条**终态、分类不被改写。
**双写已消除**`TelemetryMW` 的两个终态分支删除,改由三个 client 的公开边界经
`emit_terminal_once` 统一写出;`TestTelemetryMW::test_scope_level_failure_is_not_written_here_anymore`
`test_cancellation_is_not_written_here_anymore` 锁死"本层不再写终态",防回归双计。
### 2.3 取消口径
- chat 取消:`test_cancellation_writes_at_most_one_terminal_row` —— 恰 1 条,`error == "cancelled"`
`error_type` 为 NULL(**字符串不解析猜诊断**)。
- 三链路同策略"尽力写一条、允许 0"`emit_terminal_once` **不 shield、不开后台任务**
快照冻结是同步动作;写入 await 上再被取消则 `CancelledError` 原样传播(与 TelemetryMW 历史行为同款)。
### 2.4 SQL 不双计(按真实 SQLite 落库断言)
`TestTerminalRowSqlSemantics` 用真实 `SQLiteRecorder` 驱动一次失败 chat 后直接查表:
| 迁移影响 | 断言 |
| --- | --- |
| 失败计数判据 | `error IS NOT NULL`**3**2 尝试 + 1 终态),`event_kind='terminal_failure'`**1** |
| 费用不双计 | 终态行 `cost IS NOT NULL` 计数为 **0**;且 `usage_source='unavailable'`、token 全 0 |
| 时延分组 | 终态 `latency_ms` ≥ 任何单次尝试(含退避),故看板必须按 `event_kind` 分组 |
| 双时钟微差 | 终态 `latency_ms == total_latency_ms`(同一份冻结快照) |
| 逻辑两列归属 | 尝试行 `attempts`/`total_latency_ms` 恒 NULL |
| §5 归因查询 | 同一 `logical_call_id` 同时给出整池 reason 文案与逐源 503 现场;终态 `http_status_code` 为 NULLC1 不冒充) |
### 2.5 诊断保真与 operation 修正
- 中转把 529 改写成 503 → **记 503 不猜回 529**;直接 529 记 529。
- 空 `str()` 的 Connect/Read/Write/PoolTimeout → `cause_type` 落对应 httpx 类名,`error` 退回类名。
- 成功行五列全 NULL(**不统一填 200**)。
- `_status_to_error` 增 keyword `operation``embed()` 非 200 改传 `"embedding"`(修正历史误标),
流式与非流式 chat 两处仍 `"chat"`(按实施计划 §2 归属表,未按设计行号误标)。
- 新列 `operation` 恒为公开方法四值,**绝不读 `exc.operation`**
`test_operation_is_given_by_the_call_site_not_the_exception``exc.operation="download_result"` 反证。
- OCR `"类名: msg"` 前缀由出口的显式策略参数 `class_prefixed_error` 承载,不再三处各拼一遍。
## 3. 有界 validation 说明的单一所有者
`structured.py``_MAX_FEEDBACK_ERRORS`/`_MAX_ERROR_CHARS`/`_format_errors` 改名为
公开的 `MAX_FEEDBACK_ERRORS`/`MAX_ERROR_CHARS`/`format_bounded_errors`
由"重问反馈"与"终态结构化说明"两个消费者共同引用,**数值只有一份**。
行为逐字不变(`test_structured.py` 22 项全绿,含反馈文案用例)。
## 4. 未改动确认(防越界)
`errors.py``admission.py``ratelimit.py``breaker.py``sources.py``thinking.py`
`providers.py``telemetry/sqlite.py``telemetry/postgres.py``transports/monkey_ocr.py`
一字未动——两个 recorder 靠 `**fields` + `schema.COLUMNS` 自动吃到新列。
缓存 key 公式、重试预算与退避、429 免预算、stall 算法、取消结算、推理能力表均未触碰。
唯一顺带修正:`RetryMW.__init__``emitter` 注解由 `object | None` 收紧为
`TelemetryEmitter | None`TYPE_CHECKING 导入,同层不破分层契约),因本轮改了它的 `_emit`
## 5. T4 验收(PG 存储兼容、变异矩阵、文档同步)
基线 HEAD `393f2bf`(1467 unit 全绿),全部命令在 `PolyGateway` conda 环境执行。
### 5.1 PG 存储兼容(真实实验室 Postgres,复用 `pg_sandbox`
机械迁移:`_EXPECTED_COLUMNS` 27 → 37 列、`_record_minimal` 字段字典补 10 键(与单测同款)、
`_PRE_TENANT_COLUMNS` 的缺列集由4 列扩到 14 列。新十列一律由 `_CALL_OBSERVABILITY_COLUMNS` 派生,
两处 manual 档告警的逐字断言改成按 `COLUMNS` 序派生的 `_PRE_TENANT_MISSING_NOTICE`——
另抄一份列名必然漂移,而漂移的表现是“manual 档没补列”这条断言假绿。
| 阶段 | 命令 | 结果 |
| --- | --- | --- |
| 机械迁移前(已知必红) | `pytest tests/integration/test_postgres_telemetry.py -q -x` | **1 failed / 18 passed**manual 档列序断言 `Right contains 10 more items, first extra item: 'scope'` |
| 机械迁移后 | 同上(无 `-x` | **27 passed**EXIT=0`tests/outputs/135/pg-telemetry-after-mechanical.log` |
| 新增四项验收后 | 同上 | **30 passed**EXIT=0`tests/outputs/135/pg-telemetry-new-cases.log` |
新增 `TestCallObservabilityColumnsAcceptance`(对应计划 T4 的 PG 四项),共用一张 27 列的 1.3.4 形态旧表(`pre_135_schema`):
| 验收项 | 用例与关键断言 |
| --- | --- |
| auto 追加 10 列 + 旧行 NULL | `test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null`:物理列 27 → 37 且列序 == `_EXPECTED_COLUMNS`attempt 行与 terminal 行十列取值**整体比对**(不是逐条 in);历史行 `dict.fromkeys(...)` 十列全 NULL |
| manual 缺列裁剪 | `test_manual_trims_the_insert_on_a_pre_135_table`:表结构逐字不动(== `_PRE_135_COLUMNS`);裁剪后 26 列逐列等于提交值(防整体错位);恰 1 条告警且含可直接粘贴的首/末列 ALTER;无“写入失败”/“补列失败”,`degraded is False` |
| 新旧进程混写 | `test_old_and_new_writers_share_one_table`:新版建表写 36 列 → 旧版进程用 `insert_sql("postgres", 26 列集)` 写入 → 新版再写;三行共存、表结构不变、旧行新列全 NULL、无写入失败 warning |
| 下游口径验收 | 同上用例尾部:`WHERE event_kind = 'terminal_failure'` 计得 1`event_kind IS NULL` 计得 1(混写期旧行既不误计成失败也不误计成成功) |
纪律:未引用 `assert_no_leftovers`(它是 `test_pg_sandbox.py` 的模块级 fixture,对本文件不可见,上提它要改 `conftest.py`);
未新建沙箱设施、未碰共享表 `llm_calls`、未读或打印 DSN。新增的只有一个 `_execute_args`(带参数单语句)与
`_minimal_fields`(从 `_record_minimal` 拆出的字段字典,供“旧进程”复用同一份取值)。
### 5.2 变异矩阵(仓库外副本,主工作区生产代码零改动)
驱动脚本 `/tmp/pgw-135-mut/run_mutations.py`;每项“还原副本 → 施加单点变异(断言替换命中)→ 跑指定节点 → 还原”。
完整日志 `tests/outputs/135/mutations.log`,脚本总退出码 **EXIT=0**
| # | 变异 | exit | 被杀断言(节选) |
| --- | --- | --- | --- |
| M1 | `register_attempt()` 挪到 transport 成功之后 | 1 | `test_failed_retries_are_counted``test_budget_free_429_still_counts_as_an_attempt``test_retry_exhausted_counts_every_attempt` |
| M2 | `ChatRequest` 每次 `replace` 复制出新上下文 | 1 | `TestLogicalCallStats` 4 项 + `test_retry_exhaustion_writes_exactly_one_terminal_row` |
| M3 | `_rehydrate` 去掉 `call_stats=None` 覆盖 | 1 | `test_historic_dict_never_impersonates_call_stats` |
| M4 | Emitter 入口提前 `str(exc)` 压平 | 1 | 529/503 保真、4 个空超时文案 `cause_type`、类名前缀策略、结构化有界说明等 9 项 |
| M5 | 终态行复制最后一次 attempt 的 token/cost | 1 | `test_terminal_rows_never_contribute_to_cost``test_terminal_row_costs_nothing` |
| M6 | 去掉 `claim_terminal` 去重 | 1 | `test_terminal_row_is_written_once_per_logical_call``test_claim_terminal_is_true_once` |
| M7 | 装配闸改为捕获 `TypeError` 后 warning | 1 | `test_old_signature_recorder_is_refused_at_assembly``test_uninspectable_recorder_is_a_configuration_error` |
还原校验:副本 `.py` 文件集合散列与纯净态**逐字一致**(`66b58b9a…`),还原后同一批节点 **172 passed**
> **方法论陷阱(影响本仓所有变异证据的有效性)**:只设 `PYTHONPATH=<副本>/src` 是**无效的**——
> `pyproject.toml``[tool.pytest.ini_options] pythonpath = ["src"]` 会把**仓库内**的 `src` 抢先塞进 `sys.path[0]`
> 于是测的仍是原代码。首轮实跑七项变异**全部“存活”(exit=0)**就是这个坑;
> 改用 `-o pythonpath=<副本>` 覆盖后七项全部被杀。今后做变异必须先证“副本真的被导入”,
> 否则“变异存活”会被误读成“测试不够强”,而真相是变异根本没生效。
### 5.3 文档同步(字段数一律 `inspect` 实测,不凭记忆)
实测值:`len(inspect.signature(TelemetryRecorder.record_llm_call).parameters) - 1 == 36``len(COLUMNS) == 36`,物理列 37。
| 位置 | 改了什么 |
| --- | --- |
| `README.md` 能力表 | “必录 26 字段” → 36 字段 + 三类行与诊断列;新增“逻辑调用统计”一行 |
| `README.md` 新小节 | 《1.3.5 逻辑调用统计与失败诊断》:`call_stats` 读法、**SQL 迁移五项**、归因查询、存储侧升级 |
| `README.md` cap 覆盖面 | 补“`error_body` 沿用 `summarize_body` 上限、结构化说明自带限长,**两者都不在 cap 覆盖内**” |
| `ARCHITECTURE.md` §7.8 | 必录字段 26 → 36(十列逐个列出);新增“逻辑调用十列”段(列语义表 + 不变量 I3/I4 + `operation` vs `exc.operation` 两个语义 + 装配闸);cap 段补两处诊断文本不在覆盖面 |
| `CHANGELOG.md` | 新建《未发布》:公共面四项变更表 + 下游必须做的事(点名 `WHERE event_kind` 与装配期报错) |
| `.env.example` | `PGW_TELEMETRY_TEXT_CAP` 块补 1.3.5 覆盖面例外 |
| `schemas/llm-calls.md` | 标题 26 → 36 字段;十列逐行登记;新增《三类行与失败归因口径》(含归因 SQL 与迁移四条) |
| `metrics/call-telemetry-coverage.md` | 新增《1.3.5 三类行与逻辑调用覆盖》;live 基线列**不写伪百分比** |
版本号与发布步骤**未动**(任务边界);`pyproject.toml` / `__init__.py` 仍为 1.3.4。
### 5.4 本轮收口验证
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 静态 | `make check`ruff format+lint + import-linter | 见 §7 实跑记录 |
| 全量单测 | `pytest tests/unit -q` | 见 §7 |
| 目标集成 | `pytest tests/integration/test_postgres_telemetry.py -q` | **30 passed** |
## 6. 剩余缺口(不归本任务,不自称已关)
| 缺口 | 说明 |
| --- | --- |
| 独立验证 | 未派全新上下文 verifier(合并前硬门,由父会话前台派) |
| slow / e2e | 未跑,属发布清单第 4 步(CLAUDE §4.4.1 |
| live 覆盖基线 | `metrics/call-telemetry-coverage.md` 的实际基线列仍待首次生产运行填入 |
| 下游自建 recorder | 装配闸只能证明形状可被接受,证不了函数体真的落这些列(已写进 README/CHANGELOG |
## 7. 环境噪音记录(不是缺陷)
自动检查器用**系统解释器 Python 3.13.9**(无 `redis`/`pydantic`/`httpx`/`loguru`/`asyncpg` 等依赖),
持续报 `test_client.py` 2 项失败与大量 "Import could not be resolved"、`StrEnum is unknown import symbol`
已核实为环境问题、非本轮引入:
- 失败根因是 `ModuleNotFoundError: No module named 'redis'`optional extra),本轮 diff 对 redis 零改动;
- 在**未改动的 HEAD** 上用同一系统解释器复跑,同样 2 failed / 90 passed
- 项目强制环境 `conda run -n PolyGateway`Python 3.12.13)下:`test_client.py` 98 passed、全量 1467 passed。
判据以 CLAUDE.md §2 规定的 conda 环境与 `make check` 为准。
**T4 轮次同样现象(已逐项复现并关闭)**:检查器报 `tests/integration/test_postgres_telemetry.py` “2/3 failed”
与 6 处 `Import "asyncpg" could not be resolved`。根因与上同:`asyncpg>=0.29` 是 optional extra
`pyproject.toml:25``postgres`),检查器解释器里没装。已做确定性复现:
- 那 3 条恰是本文件**仅有的不依赖真实 PG 的用例**,其中 2 条要造 `PostgresRecorder`
- 用 `/home/iomgaa/miniconda3/bin/python`(无 asyncpg)跑这 3 条:**2 failed / 1 passed / 0.09s**,与检查器报告逐字吹合;
- 同 3 条在 `conda run -n PolyGateway` 下:**3 passed**;整文件 **30 passed**(真实 PG);
- 被标记的 6 行均为**本轮未触及的旧行**,本轮只新增 1 处同款函数内 `import asyncpg`
未为此修改代码:给旧行加 type-ignore 属任务外改动(反 gold-plating),且并非真修复。
## 8. 1.3.5 发布准备(2026-09-09,唯一 writer 会话)
范围仅**发布准备文档与版本号**:README 安装下界、CHANGELOG 定版、两处版本、本节记录。生产代码与测试**零改动**(`git diff` 只有 `CHANGELOG.md` / `README.md` / `pyproject.toml` / `__init__.py` 各 1 行,外加本文件追加的这一节)。未 merge/push/tag/构建/上传。
### 8.1 先查远端占用(改文件之前)
| 检查 | 结果 |
| --- | --- |
| `git ls-remote --tags origin` | 最高 `refs/tags/v1.3.4`**无 v1.3.5**;远端分支只有 `main``af57f93`)与 `docs/1.3.4-release-evidence` |
| 本地 tag | 同样止于 `v1.3.4` |
远端未被占用是**本次时点**的事实,父会话真正 push/tag 前仍须复查以防竞态。
### 8.2 版本与文档数字(数字一律实测)
| 项 | 结果 |
| --- | --- |
| 两处版本 | `pyproject.toml``src/polygateway/__init__.py` 同为 `1.3.5`;由 `tests/unit/test_package.py` 机械断言一致(**6 passed** |
| README 安装下界 | `>=1.3.4,<2``>=1.3.5,<2`(本版含装配期形状闸与 SQL 口径迁移,旧下界会让下游装到不含新列的包) |
| README 迁移醒目度 | 《1.3.5 逻辑调用统计与失败诊断》为顶层小节且带 `[!WARNING]`(点名"失败行变多、旧失败 SQL 会多数、自建 recorder 装配期报错"),能力表"逻辑调用统计"一行直链该锚点;沿用 1.3.4 的同款版式,未重排既有 1.3.4 迁移节 |
| CHANGELOG 定版 | `## 未发布``## 1.3.5(2026-09-09)`,日期取自本机 `date +%F` 实际值,正文未改 |
| 字段数复核 | `inspect.signature(TelemetryRecorder.record_llm_call)` 去 self **36 参**`len(schema.COLUMNS) == 36`、探针实测物理列 **37**——READMECHANGELOG 的 3637 与实测吻合,非凭记忆 |
README 静态数字未发现错误,故未做任何顺带修改。
### 8.3 复用既有证据的适用边界(不拿旧证据顶替本版统计)
| 证据 | 是否复用 | 边界 |
| --- | --- | --- |
| 独立验证(全新上下文 verifier,父会话前台派) | 复用结论:**0 阻塞** | 审的是本分支 `b4812e1` 的 1.3.5 变更面;本会话未重复派子代理,也未把它当作 live 覆盖或发布后检查的替代 |
| 本版最终全套件 `make test` | **本版实跑,不复用** | `final-gates/make-test.log/.exit`**1605 passed / 23 skipped / 108 deselected / 95%exit 0** |
| 本版真实 PG 集成 | **本版实跑,不复用** | `final-gates/pg-telemetry-verbose.log/.exit`**30 passedexit 0**(含新增四项列兼容验收) |
| 本版 unit | **本版实跑** | 本轮版本号改动后复跑 `pytest tests/unit -q`**1469 passedexit 0**`release/unit.log/.exit` |
| 1.3.4 模型矩阵/逐型号推理证据 | **按适用条件复用** | 1.3.5 对 `thinking.py`、能力表、wire 片段、缓存 key 公式、重试/限流/熔断语义**一字未改**(§4 已逐文件确认),故 1.3.4 的型号级结论在其原有边界内继续成立——连同它的 FAIL/UNKNOWN/不可达/缺轮**一并继承**,不因本版而升格。它**不能**充当 1.3.5 新增 API/schema 的证据,也不提供本版的测试统计数字 |
| 1.3.4 的全模型矩阵重跑 | **不跑** | 本版未改推理路径;用户已批准不再补全模型矩阵。本会话不发全型号请求 |
本版**改变的公共面**`CallStats` 与四类响应新字段、端口 10 新参、遥测 10 列与 `terminal_failure` 行)全部由新写的离线真链路用例(三个 client + MockTransport 直到落库)、真实 SQLite 断言、真实 PG 30 项承担,见 §2、§2.4、§5.1。
### 8.4 本轮受影响路径的真实现场(并发 1、超时不压、无全模型请求)
| 亲跑 | 命令与证据 | 结果 |
| --- | --- | --- |
| 真实网关冒烟(复用既有 e2e,不另造工具) | `pytest tests/e2e/test_smoke_gateway.py -m slow -v`tmux `pgw135-smoke``release/smoke-gateway.log/.exit` | **4 passedexit 0**(流式/非流式/结构化 json/结构化模型四节点,9.55s) |
| 有界现场探针(**恰 1 次**真实调用,全程走库) | `release/probe_call_stats.py``release/probe-call-stats.log/.exit`tmux `pgw135-probe` | **exit 0**`MiniMax-M3`/源 `minimax_1``call_stats.attempts=1``total_latency_ms=2581` ≥ 单次尝试 `2562``logical_call_id` 与落库行逐字一致;临时 SQLite 物理列 **37**、写入面 **36**、恰 1 条 `event_kind='attempt'` 行、`operation='chat'``scope='LLM'`、逻辑两列与四个诊断列全 NULL |
探针脚本只落在 `tests/outputs/135/release/`(该目录 gitignore,不入库),不新增生产代码、测试或平台设施,未发裸 HTTP,沿用 `.env` 现有超时与单源配置。
### 8.5 提交前收口门
| 门 | 证据 | 结果 |
| --- | --- | --- |
| `make check` | `release/check.log/.exit` | **exit 0**:94 文件格式通过、ruff 通过、import-linter **1 kept / 0 broken** |
| 包单测(两处版本一致) | `release/package.log/.exit` | **6 passedexit 0** |
| 全量 unit | `release/unit.log/.exit` | **1469 passedexit 0** |
23 项 skip 的理由已逐条留档(`final-gates/skip-reasons.log`):17 项 redis 时间语义由 integration 变体覆盖、6 项需实验室语料 `data/soak/chs_images`**skip 不计通过**。conda 启动器既有 `RequestsDependencyWarning` 保留,不宣称零告警。
### 8.6 尚未执行(不得当成已完成)
merge、push、`git tag -a v1.3.5``python -m build``twine check``upload``pip download` 解包验证、Gitea Release 与包页面/仓库关联检查**全部未执行**;`pytest -m slow` 的其余 e2e(本轮只跑了 `test_smoke_gateway.py` 四节点)与 live 覆盖基线同样未跑。§6 的四项缺口除"独立验证"已由父会话关闭外,其余保持开启。
+7
View File
@@ -465,6 +465,13 @@
"relation": "implements", "relation": "implements",
"evidence": "复用既有26字段,四种行来源与无推理成败NULL;不增加生产数据面", "evidence": "复用既有26字段,四种行来源与无推理成败NULL;不增加生产数据面",
"added": "2026-09-09T06:32:06.527808+00:00" "added": "2026-09-09T06:32:06.527808+00:00"
},
{
"source": "plan:2026-09-09-135-call-observability",
"target": "design:2026-09-09-135-call-observability-design",
"relation": "implements",
"evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计",
"added": "2026-09-09T13:42:00.298915+00:00"
} }
] ]
} }
+9 -6
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引 # Research Wiki 索引
> 自动生成,更新时间:2026-09-09 06:32 UTC > 自动生成,更新时间:2026-09-09 16:49 UTC
## design (42) ## design (43)
- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design` - [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design`
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
@@ -24,6 +24,7 @@
- [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design` - [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design`
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design` - [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design` - [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
- [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design`
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
@@ -47,9 +48,10 @@
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions` - [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (15) ## finding (16)
- [1.3.4 T0T4 与 T7 确定性验证](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation` - [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation`
- [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation`
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload` - [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-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-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
@@ -65,9 +67,10 @@
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [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` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (37) ## plan (38)
- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts` - [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts`
- [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability`
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [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-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` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
@@ -111,7 +114,7 @@
## schema (1) ## schema (1)
- [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls` - [表结构: llm_calls(遥测 36 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2) ## metric (2)
+3
View File
@@ -156,3 +156,6 @@
- [2026-09-09 05:39 UTC] 重建索引: 98 篇页面 - [2026-09-09 05:39 UTC] 重建索引: 98 篇页面
- [2026-09-09 06:32 UTC] 新增边: schema:llm-calls --implements--> design:2026-09-09-134-thinking-contracts-design - [2026-09-09 06:32 UTC] 新增边: schema:llm-calls --implements--> design:2026-09-09-134-thinking-contracts-design
- [2026-09-09 06:32 UTC] 重建索引: 98 篇页面 - [2026-09-09 06:32 UTC] 重建索引: 98 篇页面
- [2026-09-09 13:42 UTC] 新增边: plan:2026-09-09-135-call-observability --implements--> design:2026-09-09-135-call-observability-design
- [2026-09-09 13:42 UTC] 重建索引: 100 篇页面
- [2026-09-09 16:49 UTC] 重建索引: 101 篇页面
@@ -17,3 +17,17 @@ date: 2026-07-20
| live 覆盖 | 逐轮 PASSFAILUNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS | | live 覆盖 | 逐轮 PASSFAILUNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS |
复用 schema:llm-calls(无新字段/DDL),证据索引见 `findings/2026-09-09-134-thinking-contracts-validation.md`。独立错误取证只在 tests 内存,Markdown 只记录白名单安全摘要与布尔校验,不使用生产遥测旁路补失踪尝试。生产埋点仍是 TelemetryEmitter 单点出口。 复用 schema:llm-calls(无新字段/DDL),证据索引见 `findings/2026-09-09-134-thinking-contracts-validation.md`。独立错误取证只在 tests 内存,Markdown 只记录白名单安全摘要与布尔校验,不使用生产遥测旁路补失踪尝试。生产埋点仍是 TelemetryEmitter 单点出口。
## 1.3.5 三类行与逻辑调用覆盖
本版把覆盖度的计量单位从"一次尝试"改成"一次逻辑调用"——此前结构化耗尽、embedding/OCR 的无源与准入拒绝根本没有任何行,"每次调用必录"在这几条路径上是不成立的。
| 指标 | 确定性阈值/证据 | 实际 live 基线 |
| --- | --- | --- |
| 领域失败的终态行覆盖 | chat 结构化耗尽、embeddingOCR 的 `no_sources`、准入拒绝、`retry_exhausted`、尝试外取消各恰有 **1** 条;单测以真实临时 SQLite 抓实 | 待首次实际运行,不填伪百分比 |
| 至多一条(I3) | 同一逻辑调用重复进出口不产生第二条(`claim_terminal`);recorder 写失败仅 warning,故 SQL 可见 ≤ 1 | 待首次实际运行 |
| 非领域异常 0 条(I4) | 编程错(如 `KeyError`)不写终态行、原样传播、分类不被改写 | 待首次实际运行 |
| 诊断列保真 | 中转把 529 改写成 503 则记 503(**不猜回**);空 `str()` 的 httpx 超时类落 `cause_type`;成功行五列 NULL | 待首次实际运行 |
| 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) |
本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。
@@ -0,0 +1,455 @@
---
type: plan
node_id: plan:2026-09-09-135-call-observability
title: "1.3.5 逻辑调用统计与结构化失败诊断实施计划"
date: 2026-09-09
---
# 1.3.5 逻辑调用统计与结构化失败诊断实施计划
> 设计:`research-wiki/designs/2026-09-09-135-call-observability-design.md`**人类于 2026-09-09 正式批准**(§10 六项批准项全数获批)。
> 计划审核门:Claude 自审 + **独立模型替代审查**(用户已同意以独立模型替代 Codex 那一道,结论仍须逐条核验后就地修订);plan 无人类门,审毕直接执行。
> 目标:把治理单位从"一次尝试"补齐到"一次逻辑调用"——四种响应带 `CallStats`,遥测补 10 列诊断/归因字段与 `terminal_failure` 行(issue #19#23)。
> 方案:设计方案 B——每调用一个显式传递的私有可变上下文 + 领域异常下沉到单一遥测出口;复用既有 Emitter / schema / 三条治理循环,不新增表、不新增依赖、不引入追踪平台。
> 技术:Python 3.12+、asyncio、frozen dataclass、`inspect.signature`、pytest + FakeClock + MockTransport、临时 SQLite、真实 PG 沙箱、ruff、import-linter。
> 基线 HEAD`a81cc91`(分支 `feature/1.3.5-call-observability`,工作区仅 `CLAUDE.md` 既有 markdown 格式差异与未跟踪 `.pi/`,两者本计划一律不动、不暂存)。
本计划不涉及 `reference/` 参考实现迁移(治理主循环、Lua 限流、熔断状态机、退避公式一字不改),**保真校验不适用**。不得变更:重试预算与退避、429 免预算与 stall 算法、取消结算、限流/熔断语义、推理能力表与档位解析、缓存 key 公式、`#22`/`#24` 的调度机制。
## 1. 边界、授权与执行纪律
| 项目 | 固定边界 |
| --- | --- |
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。用户已授权本轮无需逐步请示,1.3.X 合并/发布授权沿用;跨到 1.4、新公共决策或验证豁免须停下确认 |
| 公共承诺 | 只做设计已批准的四项公共面变更:`CallStats` 导出、四响应新增字段、`TelemetryRecorder` 扩 10 参、`terminal_failure` 行;**不新增其它公共 API、不改既有字段名与位置** |
| 依赖铁律 | `ports.py`/`types.py`/`errors.py` 保持最内层,`types.py` 不 import 任何实现层;`middleware/` 只依赖端口与内核;`transports/``telemetry/``backends/` 互不依赖(import-linter 执法) |
| 取消 | `CancelledError` 永不吞没;终态写**取消优先、不 shield、不开后台任务**;快照冻结是同步动作,不 await |
| 降级方向 | recorder 写失败仍只 warning;限流/熔断后端仍 fail-closed;装配期签名不符 → **当场报错**(不是 warning |
| 证据与秘密 | 不打印 `.env`、token、Authorization;不提交 `.pi/``tests/outputs/`、reference;命令输出只记安全路径、状态与退出码 |
| 证据复用 | 已发布 1.3.4 的有效证据(能力矩阵、live 轮次、发布外部验证)**直接引用不重建**;本版**不补跑未变更的模型能力矩阵**,无需新增付费调用 |
Skill 纪律:T0 已执行 `writing-plans`T1T3 行为变更执行 `test-driven-development`(先失败后通过的证据须落在本会话工具输出里);T1 触及运行时数据落库,执行 `structured-logging`;每次提交执行 `commit`(英文祈使标题、无 AI 签名、显式路径暂存);T4 前执行 `requesting-code-review``verification-before-completion`,收到意见执行 `receiving-code-review`;异常先 `systematic-debugging` 定根因。
## 2. 文件职责与不变接缝
| 动作 | 精确路径 | 职责 |
| --- | --- | --- |
| 修改 | `src/polygateway/types.py` | 新增 `CallStats`、私有 `_CallContext``CallOperation`/`EventKind` 词表;`ChatRequest` 追加内部上下文字段;四种响应追加 `call_stats` |
| 修改 | `src/polygateway/__init__.py` | 导出 `CallStats``__all__` 保序插入);版本号在 T4 统一 |
| 修改 | `src/polygateway/ports.py` | `TelemetryRecorder.record_llm_call` 追加 10 个 keyword-only 无默认值参数(唯一签名事实源) |
| 修改 | `src/polygateway/telemetry/schema.py` | `SQLITE_DDL` / `PG_DDL` / `SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` / `COLUMNS` **五处**同序同增 10 列,追加在物理列末尾(`PG_BACKFILL``_ALTER_BY_BACKEND``telemetry_schema_sql` 自动派生,不手改) |
| 修改 | `src/polygateway/middleware/telemetry.py` | Emitter 构造期注入 `scope` + 装配闸;三个 emit 入口收 `operation` 与领域异常对象;诊断字段单一提取 helper;`event_kind`;终态出口 helper`TelemetryMW` 只保留 cache_hit |
| 修改 | `src/polygateway/middleware/structured.py` | 有界错误说明常量与拼装函数改为库内可复用(重问反馈与遥测说明同一口径,数值只一份) |
| 修改 | `src/polygateway/middleware/retry.py` | transport 调用前登记一次尝试;`_emit` 传异常对象与 `operation="chat"` |
| 修改 | `src/polygateway/middleware/cache.py` | `_serialize` 剔除 `call_stats``_rehydrate` 显式覆盖为 `None`(防历史 dict 冒充) |
| 修改 | `src/polygateway/client.py` | 自存 `now`/`emitter``chat` 创建上下文、附加统计、领域失败与取消经单一终态出口 |
| 修改 | `src/polygateway/embedding.py` | `embed` 拥有上下文(空输入 0 尝试 0 遥测行);逐批传递;终态请求摘要按 §3.5 |
| 修改 | `src/polygateway/ocr.py` | 上下文在 `image` 校验通过后创建;两公开方法各自给 `operation`;错误文本保留类名前缀(经出口的显式策略参数) |
| 修改 | `src/polygateway/transports/openai_compat.py` | `_status_to_error` 增 keyword-only `operation`;三个调用点显式给值:`:512``embed` 非 200)传 `"embedding"`(修正历史误标)、`:527`(流式 chat)与 `:625`(非流式 chat)传 `"chat"``ocr_text`/`parse`/`download_result` 词表不动。**归属以本表为准**:设计 §5 把 `:527` 也叙述成 `embed()` 的分支,实测 `:527``_complete_stream`(流式 chat)、`embed` 只有 `:512` 一处;勿按设计行号把两条 chat 失败回退误标成 embedding |
| 修改 | `tests/unit/test_types.py``tests/unit/test_ports.py` | `CallStats`/上下文纯行为、端口签名实测(含无默认值与 keyword-only |
| 修改 | `tests/unit/test_telemetry.py` | 列序/列数、三类行语义、诊断保真、装配闸、SQLite 新旧表、`_record_minimal` 字段字典 |
| 修改 | `tests/unit/test_retry.py``test_client.py``test_cache.py``test_structured.py` | 计数/计时/缓存命中/结构化耗尽终态与历史 dict 防护 |
| 修改 | `tests/unit/test_embedding.py``test_ocr_client.py``test_openai_compat.py``test_monkey_ocr.py` | 多批、空输入、双 HTTP 一次尝试、`exc.operation` 修正、200 失败行 |
| 修改 | `tests/unit/test_pricing.py``tests/unit/test_usage_source_domain.py` | **仅 Emitter 调用点机械迁移**`TelemetryEmitter(``scope=``emit_*``operation=``emit_terminal_failure``latency_ms` 改传 `stats`);两文件的 `_MemoryRecorder``**fields` 形态,recorder 签名不动 |
| 修改 | `tests/integration/test_postgres_telemetry.py` | PG auto 追加 / manual 缺列裁剪 / 旧行 NULL / 新旧进程混写(真实 `pg_sandbox` |
| 修改 | `README.md``CHANGELOG.md``.env.example``research-wiki/ARCHITECTURE.md``research-wiki/schemas/llm-calls.md``research-wiki/metrics/call-telemetry-coverage.md` | 字段数实测改写、cap 覆盖面澄清、SQL 迁移五项、终态行语义 |
| 新建(验收时) | `research-wiki/findings/2026-09-09-135-call-observability-validation.md` | 红绿、变异、失败与豁免索引,≤300 行;原始输出留 `tests/outputs/135/` |
**不改**`errors.py`(异常类与分类逐字不动,不加可变字段)、`middleware/admission.py``middleware/ratelimit.py``middleware/breaker.py``sources.py``thinking.py``providers.py``telemetry/sqlite.py``telemetry/postgres.py`(两个 recorder 靠 `**fields` + `schema.COLUMNS` 自动吃到新列,逻辑零改动)、`transports/monkey_ocr.py`(双 HTTP 仍在同一 transport 调用内,不拆)。若实现时发现必须突破本清单,先说明设计依据与最小原因交父会话核定,不顺手改。
## 3. 跨任务接口(可执行定义,禁止占位)
### 3.1 统计内核(`types.py`
`types.py` 需新增 `import uuid``from collections.abc import Callable``from typing import Literal``Mapping`/`Any` 已在;全文现无 `Literal`)。
**运行时求值约束(`types.py``from __future__ import annotations`,实测 :1-17**`Callable``Literal` 必须是**运行时 import**,不得放进 `TYPE_CHECKING``_CallContext.__init__` 的函数注解与 `CallOperation` 别名都在 def/赋值时求值);且 `_CallContext` 必须**定义在 `ChatRequest`:339)之前**,否则 `call_context: _CallContext | None` 的类注解在类创建时即 `NameError`
上下文是**每调用一个实例**的单任务对象:chat 重试、结构化重问、embedding 分批都在同一任务内串行推进,故计数无需锁;严禁提升为 client 实例属性。
```python
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"]
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout")
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
EVENT_KINDS: tuple[EventKind, ...] = ("attempt", "cache_hit", "terminal_failure")
@dataclass(frozen=True)
class CallStats:
"""一次公开调用的统计快照;第三方合成响应的 `None` 表示未知,不得伪造 0。"""
logical_call_id: str
attempts: int
total_latency_ms: int
class _CallContext:
"""私有可变逻辑调用上下文:只持计数、单调时钟与终态去重位,不做 I/O。"""
__slots__ = ("logical_call_id", "_now", "_started", "_attempts", "_terminal_claimed")
def __init__(self, *, now: Callable[[], float]) -> None: ...
def register_attempt(self) -> None:
"""transport 调用前登记一次尝试(含免预算 429 与端口本地拒绝)。"""
def snapshot(self) -> CallStats:
"""同步冻结当前快照;绝不 await,可多次调用。"""
def claim_terminal(self) -> bool:
"""首次 True、其后 False:保证每逻辑调用至多写一条终态行。"""
```
`ChatRequest` 追加(末尾,`dataclasses.replace` 保留同一引用;`StructuredMW` 的 replace 已核对不重建它):
```python
call_context: _CallContext | None = field(default=None, compare=False, repr=False)
"""库内部逻辑调用上下文;`None` = 库内现场构造的请求,遥测 logical_call_id 落 NULL。"""
```
`LLMResponse``EmbeddingResponse``OcrTextResult``OcrLayoutResult` 各追加**末尾**字段:
```python
call_stats: CallStats | None = None
```
### 3.2 遥测出口(`middleware/telemetry.py`
Emitter 是全库唯一 `record_llm_call` 调用点,本版新增的一切诊断/归因取值也只在这里定型。
```python
class TelemetryEmitter:
def __init__(
self,
recorder: TelemetryRecorder,
*,
scope: str,
pricing: PricingTable | None = None,
text_cap: int | None,
) -> None:
"""`scope` 构造期注入(三个 client 各一行),使三类行都带池名;不拿 source_name 顶替。
同处执行装配闸 `_assert_recorder_shape(recorder)`
"""
async def emit_attempt(
self,
*,
request: ChatRequest,
source: SourceConfig,
call_id: str,
latency_ms: int,
response: LLMResponse | None,
error: PolyGatewayError | str | None,
reasoning_applies: bool,
operation: CallOperation,
class_prefixed_error: bool = False,
) -> None:
"""`event_kind='attempt'`;失败异常对象在此提取 status/cause/body 四列。"""
async def emit_cache_hit(
self, *, request: ChatRequest, response: LLMResponse, operation: CallOperation
) -> None:
"""`event_kind='cache_hit'`;attempts/total_latency_ms 列恒 NULL。"""
async def emit_terminal_failure(
self,
*,
request: ChatRequest,
call_id: str,
error: PolyGatewayError | str,
operation: CallOperation,
stats: CallStats,
class_prefixed_error: bool = False,
) -> None:
"""`event_kind='terminal_failure'`;`latency_ms``total_latency_ms` 同取 `stats`。"""
```
`emit_terminal_failure` **不再收 `latency_ms`**(同一冻结快照供两列,避免双时钟微差)。诊断提取收敛为一个纯 helper,只读领域异常的既有属性,**不遍历任意对象、不猜正文**:
```python
@dataclass(frozen=True)
class _ErrorFields:
"""一行遥测的错误列;未知一律 None。"""
error: str | None
error_type: str | None
cause_type: str | None
http_status_code: int | None
error_body: str | None
def _error_fields(
error: PolyGatewayError | str | None,
*,
event_kind: EventKind,
class_prefixed: bool,
) -> _ErrorFields:
"""三种入参形态的唯一定型点(设计 §5/§6)。"""
```
判定规则(测试逐条钉死):
| 入参 | error | error_type | cause_type / http_status_code / error_body |
| --- | --- | --- | --- |
| `None` | None | None | 全 None |
| `str`(如既有 `"cancelled"` | 原样 | None | 全 None**不解析字符串猜诊断**) |
| 领域异常,`event_kind='attempt'` | 见下 | `type(exc).__name__` | `type(exc.__cause__).__name__ or None` / `exc.status_code` / `summarize_body` 已产出的 `exc.body_text or None` |
| 领域异常,`event_kind='terminal_failure'` | 见下 | `type(exc).__name__` | **全 None**(C1:不搬最后一次 attempt 的状态与正文冒充整池归因) |
error 文本:`str(exc)` 为空退回 `type(exc).__name__``class_prefixed=True` 时前置 `"{类名}: "`OCR 既有 metric 归组口径);取消路径一律传字符串(attempt 行沿用既有 `"cancelled"`,终态行用同一明确取消文案),故 `error_type` 与其余三列均 NULL`ResultInvalidError` 且为终态时并入有界结构化说明:
```python
def _structured_detail(exc: ResultInvalidError) -> str:
"""结构化耗尽的有界说明:`repair=` 至多 200 字符 + `validation=` 至多 3 条×200 字符;
**不含 raw_text**(模型正文预算已由 attempt 行的 response 列承担),两段以 ` | ` 拼接。"""
```
复用 `structured.py` 的既有规则所有者,不复制数值:把 `_MAX_FEEDBACK_ERRORS`/`_MAX_ERROR_CHARS` 改名为 `MAX_FEEDBACK_ERRORS`/`MAX_ERROR_CHARS``_format_errors` 改名 `format_bounded_errors(errors: Sequence[str]) -> str`(行为逐字不变),`StructuredMW``_structured_detail` 两个消费者共同引用。
装配闸(设计 §7C3):
```python
def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
"""装配期一次 `signature.bind` 形状校验:不执行写入,只证明该形状能被接受。
参数名从 `TelemetryRecorder.record_llm_call` 的协议签名派生(不手抄第四份清单),
绑定用哨兵 `None`,不读真实请求数据。`**kwargs`(VAR_KEYWORD)自动通过;
`TypeError``ValueError` 装配期报错;不可 inspect(C 实现等)同样按配置错误报错。
"""
```
终态唯一出口(公开边界 helper,三个 client 共用):
```python
async def emit_terminal_once(
emitter: TelemetryEmitter | None,
*,
request: ChatRequest,
context: _CallContext,
error: PolyGatewayError | str,
operation: CallOperation,
class_prefixed_error: bool = False,
) -> None:
"""去重(claim_terminal)+ 同步冻结快照 + best effort 写入。
`emitter is None` 或已写过 → 直接返回;写入侧异常按既有降级只落 warning;
**`CancelledError` 原样传播**(取消优先,不 shield、不开后台任务)。
"""
```
`TelemetryMW` 相应收缩:删除其 `except (GatewayUnavailableError, GovernanceBackendError, SourceNotConfiguredError)``except CancelledError` 两个终态分支(改由 `GatewayClient.chat` 的边界统一写,避免两处同时写),保留 `cache_hit` 记录并传 `operation="chat"`
### 3.3 `_record` 的 10 个新列取值(唯一定型点)
| 列 | attempt | cache_hit | terminal_failure |
| --- | --- | --- | --- |
| `scope` | 构造期注入值 | 同 | 同 |
| `operation` | 调用点给定的四值之一,**绝不读 `exc.operation`** | 同 | 同 |
| `logical_call_id` | `request.call_context.logical_call_id`,上下文缺席 → NULL(不造 ID) | 同 | 同 |
| `event_kind` | `'attempt'` | `'cache_hit'` | `'terminal_failure'` |
| `http_status_code` / `cause_type` / `error_body` | 见 §3.2 表(成功行全 NULL,成功不统一填 200 | 全 NULL | 全 NULL |
| `error_type` | 该行自身错误类名 | NULL | 自身类名(`AllSourcesExhausted` / `CircuitOpenError` / `ResultInvalidError` / …) |
| `attempts` / `total_latency_ms` | NULL | NULL | `stats` 两字段 |
其余 26 列口径逐字不变;终态行仍 `cost=None``usage_source='unavailable'`、token 0,不复制 attempt 的用量与费用。
### 3.4 schema 与端口(`telemetry/schema.py``ports.py`
10 列按**同一顺序**追加进五处(`SQLITE_DDL``PG_DDL``SQLITE_BACKFILL``_PG_BACKFILL_DECLS``COLUMNS`),全部可空、无默认值,物理位置在现有末列 `reasoning_effort` 之后:
| 顺序 | 列名 | SQLite | Postgres |
| --- | --- | --- | --- |
| 1 | `scope` | TEXT | TEXT |
| 2 | `operation` | TEXT | TEXT |
| 3 | `logical_call_id` | TEXT | TEXT |
| 4 | `event_kind` | TEXT | TEXT |
| 5 | `http_status_code` | INTEGER | INTEGER |
| 6 | `error_type` | TEXT | TEXT |
| 7 | `cause_type` | TEXT | TEXT |
| 8 | `error_body` | TEXT | TEXT |
| 9 | `attempts` | INTEGER | INTEGER |
| 10 | `total_latency_ms` | INTEGER | INTEGER |
`COLUMNS` 由 26 → 36,物理列(含 `created_at`)27 → 37。**不建索引、不改旧列、不回填旧行、不 ALTER 默认生产 PG**(manual 档仍裁剪写入)。`ports.py` 按同序追加 10 个 keyword-only 无默认值参数,docstring 更新为"36 字段冻结",并说明 `error_body` 沿用 `summarize_body` 上限、不进 `PGW_TELEMETRY_TEXT_CAP` 覆盖面。
### 3.5 三条链路的上下文所有权
| 链路 | 创建点 | 传递 | 附加统计 | 终态 |
| --- | --- | --- | --- | --- |
| chat | `GatewayClient.chat`overlay/维度/档位三项校验**之后**(校验异常保持原行为,在统计边界外) | 放进 `ChatRequest.call_context`,洋葱各层经 `replace` 保留同一引用;`RetryMW._attempt``transport.complete``register_attempt()`**上下文为 `None` 时跳过**——库内现场构造的 `ChatRequest` 不得因此报错) | 返回前 `dataclasses.replace(response, call_stats=ctx.snapshot())`(含缓存命中路径与内联遥测耗时) | `except PolyGatewayError` / `except CancelledError` 各调 `emit_terminal_once`,随后原样 `raise`;非领域异常不捕、无终态 |
| embed | `EmbeddingClient.embed``texts` 类型校验与**调用方维度校验**`validate_caller_dimensions`)之后;`expected_dim` 校验在 `_attempt` 内,本就在统计边界内 | 显式参数传到 `_embed_batch``_attempt``_emit` 现场构造的 `ChatRequest``call_context=ctx``transport.embed` 前登记 | 合并结果 `replace(..., call_stats=ctx.snapshot())`**空输入返回真实 `attempts=0` 统计且不写任何遥测行** | 同上,`operation="embed"`;终态 messages = `<embed texts=N batches=M>` 占位 + 第一批(至多 `batch_size` 条、每条 200 字符,与逐批行同款构造) |
| OCR | `OcrClient._call``image``bytes`/非空校验**通过之后**(M1 例外) | `_call` 拥有上下文并返回 `tuple[_AttemptOutcome, CallStats]`,两个公开方法据此附加统计;`transport` 调用前登记(layout 的 POST+ZIP GET 计 **1** 次) | `OcrTextResult` / `OcrLayoutResult``call_stats` | `_call``except PolyGatewayError` / `except CancelledError``emit_terminal_once``operation` 由公开方法传入(`recognize_text` / `parse_layout`),`class_prefixed_error=True``no_sources``raise` 在循环之前,`try` 必须**包住该 raise**,否则无源终态行写不出 |
`GatewayClient` 需自存 `self._now``self._emitter`(现未保存);统计生效与否**不由 telemetry 是否启用决定**`emitter is None` 时统计照常,仅无行可写)。
## 4. 任务与提交点
### T0:设计批准状态、计划与基线(本任务)
- [x] 设计文档状态行改为"人类已批准(2026-09-09",新建本计划(≤600 行)。
- [x] 记录基线:`git status --short --branch``git log --oneline -3`、HEAD `a81cc91`;确认源码零差异,`CLAUDE.md` 既有 markdown 差异与 `.pi/` 一律不动、不暂存。
- [ ] 基线验证(由执行者在 T1 之前跑一次,作为"先失败"的对照底):`make check``conda run -n PolyGateway pytest tests/unit -q` → 预期全绿。**基线计数以本会话实跑输出为准**(近期会话记录为 1385 项量级),不拿计划里的数字当期待值;实际失败照录,不改期待绕过。
- [ ] 计划自审 + 独立模型审查(替代 Codex,用户已同意);意见逐条核验后就地修订。
- [ ] wiki 登记:`.claude/tools/research_wiki.py add_entity/add_edge/rebuild_index`design ↔ plan `implements`),登记前先确认工具不覆盖同路径已有文档。
- [ ] 提交点:`docs: record approved call observability design and plan`(生产改动前的回滚点)。
### T1:统计内核与三条链路的逻辑边界(不动遥测列)
**文件**`types.py``__init__.py``client.py``embedding.py``ocr.py``middleware/retry.py``middleware/cache.py`;测试 `tests/unit/test_types.py``test_client.py``test_retry.py``test_cache.py``test_embedding.py``test_ocr_client.py``test_structured.py`
按 §3.1 与 §3.5 实现。先写测试并确认在旧实现上红(`AttributeError: 'LLMResponse' object has no attribute 'call_stats'` 不算行为红——先落一个"同一次调用的重试次数无处可读"的行为断言,再实现)。
| 红绿组 | 必须证明 |
| --- | --- |
| 计数 | 一次成功=1;失败重试=实际尝试数;免预算 429 计入;多源拒绝(端口本地拒绝)计入;结构化重问计入同一上下文且不重置;embedding 三批=3OCR layout 双 HTTP=1;缓存命中=0;空输入=0 |
| 逻辑 ID | 重试/重问/分批共享同一 `logical_call_id`;同一 client 并发两次调用互不串(`asyncio.gather` 两路各自计数与 ID);`parent_call_id` 语义不变 |
| 计时(FakeClock | 缓存 IO、退避 sleep、准入等待、重问、内联遥测收尾全部计入 `total_latency_ms`;毫秒/秒不混用(1.5s → 1500);`emitter=None` 时统计仍正确。**替身构造要求**:假缓存后端的 `get`/`set` 与假 recorder 的 `record_llm_call` 内部**必须主动推进 FakeClock**,否则"缓存 IO/内联遥测计入总耗时"的断言会退化成恒等于 0 的空转绿 |
| 缓存不毒化 | `_serialize` 输出无 `call_stats` 键;手写含 `"call_stats": {...}` 的历史条目命中后 `response.call_stats is None`**dict 不得冒充 `CallStats`**);缓存 key 逐字节不变(黄金值) |
| 附加点 | 失败异常上**不附任何统计字段**(`hasattr(exc, "call_stats") is False`);`CancelledError` 类型与语义不变;非领域异常原样传播 |
| 空输入 | `embed([])` 返回 `attempts=0`、真实 `logical_call_id`,且注入的内存 recorder **零行** |
**验证**`conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_client.py tests/unit/test_retry.py tests/unit/test_cache.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_structured.py -q` → 目标断言先红后绿,其余保留行为绿;`make check`(含 import-linter`types.py` 不得引入实现层 import)。
- [ ] 提交点:`feat: track logical call statistics across governed calls`
### T2:遥测 10 列、诊断保真、scope/operation 与装配闸
**文件**`telemetry/schema.py``ports.py``middleware/telemetry.py``middleware/structured.py``middleware/retry.py``transports/openai_compat.py`、三个 client 的 Emitter 构造行;测试 `tests/unit/test_telemetry.py``test_ports.py``test_openai_compat.py``test_monkey_ocr.py``test_structured.py`
按 §3.2–§3.4 实现(本任务只产出 attempt / cache_hit 两类行的新列,终态行留 T3)。先写测试确认旧实现红:现状下 `error` 列是被 `str()` 压平的自由文本、无 `scope`/`operation` 列、旧签名 recorder 只落 warning。
| 红绿组 | 必须证明 |
| --- | --- |
| 列与 SQL | `len(COLUMNS) == 36`、物理列 37、五处列序一致(新建库与 ALTER 追加列序相同);`insert_sql` 两端语句更新;`telemetry_schema_sql` 输出与库内 DDL 同源 |
| 端口 | `inspect.signature` 实测 10 个新参存在、keyword-only、无默认值;docstring 字段数与实测一致 |
| 诊断保真 | 中转把 529 改写成 503 → 记 503**不猜回 529**);直接 529 → 529;空 `str()` 的 Connect/Read/Write/PoolTimeout → error 落类名、`cause_type` 为对应 httpx 类名;`error_body``summarize_body` 摘要且不等于 `raw_text`;成功行五列 NULL(不填 200) |
| operation | `embed` 非 200 → `exc.operation == "embedding"`(历史误标修正);新列 `operation` 恒为四值之一,与 `exc.operation` 无关、不随异常变化;`monkey_ocr``success != true`**200** 的失败行 → `http_status_code == 200`(该列不可作失败判据) |
| scope | attempt 与 cache_hit 行都带 scopemodel/provider/source 未选出时仍留原空值 |
| 字符串入参 | 取消 attempt 的 `"cancelled"` 仍原样落 `error`,其余四列 NULL(**不解析字符串**) |
| 装配闸 | 旧签名 recorder → 构造 `TelemetryEmitter`(即三个 client 装配)**抛错**而非 warning`**kwargs` recorder 通过;不可 inspect 的对象 → 配置错误报错;参数名确由协议签名派生(改协议后闸自动跟随的断言) |
| 有界说明 | `format_bounded_errors` 重命名后 `StructuredMW` 反馈文案逐字不变(黄金串);常量只有一份定义 |
**验证**`conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_openai_compat.py tests/unit/test_monkey_ocr.py tests/unit/test_structured.py -q``make check`
- [ ] 提交点:`feat: record scope, operation and failure diagnostics per row`
### T3:终态行、取消口径与统一出口
**文件**`middleware/telemetry.py`(终态 helper 与 `TelemetryMW` 收缩)、`client.py``embedding.py``ocr.py`;测试 `tests/unit/test_telemetry.py``test_client.py``test_embedding.py``test_ocr_client.py``test_structured.py``test_retry.py`
按 §3.2 的 `emit_terminal_once` 与 §3.5 的三条链路实现。先写测试确认旧实现红:结构化耗尽当前**没有任何失败行**;embedding/OCR 的无源、准入拒绝、重试耗尽与尝试外取消同样无终态行。
| 红绿组 | 必须证明 |
| --- | --- |
| 补漏 | chat 结构化耗尽、embedding/OCR 的 `no_sources`、准入拒绝、`retry_exhausted`、尝试外取消各恰有 **1**`terminal_failure` |
| 不变量 I3 | 领域失败每调用至多 1 条(recorder 写失败仅 warning,SQL 可见 ≤1);重复调用出口不产生第二条(`claim_terminal`);直接 `RequestRejectedError` / `ResultInvalidError` 现在**既有 attempt 错误行也有终态行**(400 密集负载错误行翻倍是已批准的下游可见变化) |
| 非领域异常 | 编程错(如 `KeyError`)→ **0 条**终态行、原样传播、分类不被改写 |
| 取消三路 | chat / embed / OCR 同策略尽力写一条(允许 0);**终态写的 await 上被取消 → `CancelledError` 传播**(不 shield、无后台任务);permit 与探针释放行为不变(`inflight == 0` |
| 归因 SQL | 设计 §5 那条 `WHERE logical_call_id = :lcid` 查询同时给出终态 reason 文案与逐源状态码/正文;终态行 `http_status_code`/`cause_type`/`error_body` 三列 NULL;结构化耗尽的终态 `error` 含有界 validation/repair 说明且**不含 `raw_text`** |
| 行语义 | 三类行均带 scope;`event_kind` 可区分;`SUM(cost)` 不因终态行变化(终态 `cost IS NULL`、usage `unavailable`);`AVG(latency_ms)``event_kind` 分组的断言;终态 `latency_ms == total_latency_ms`(同一快照) |
| 快照时机 | 终态快照不含自身写入耗时(FakeClock:写入内推进时钟,列值不变);成功响应快照含返回前已完成的内联遥测耗时 |
| 摘要 | embedding 终态 messages 为占位 + 第一批截断文本,不含全量原输入;OCR 终态沿用 `<ocr:{kind} image_bytes=…>`,图像 bytes 不入库;OCR 终态 error 保留类名前缀 |
**验证**`conda run -n PolyGateway pytest tests/unit -q`(全量单测,含上述文件);`make check`
- [ ] 提交点:`feat: emit one terminal failure row per logical call`
### T4:旧测试机械核对、存储兼容、变异证据、文档与独立验证
**文件**:下述机械核对清单 + `tests/integration/test_postgres_telemetry.py` + 文档清单 + 验收 finding。
**一次机械核对(禁止分散反复修)**:本版真正的破坏面**不只是 recorder 签名,还有 Emitter 侧**`TelemetryEmitter.__init__` 新增必填 `scope`、三个 `emit_*` 新增必填 `operation``emit_terminal_failure``latency_ms` 改收 `stats`),这些关键词不含 `record_llm_call`/`COLUMNS`。故全量清单用:
```bash
grep -rn "record_llm_call\|COLUMNS\|_EXPECTED_COLUMNS\|TelemetryEmitter(\|emit_attempt(\|emit_cache_hit(\|emit_terminal_failure(" tests/ --include=*.py
```
一轮改完再跑,不逐个文件试错。已核实的免改项:`tests/unit/test_backpressure.py:231` 的假 emitter 是 `emit_attempt(self, *args, **kwargs)`,兼容;`tests/integration/` 无 Emitter 构造点。已知点:
| 位置 | 动作 |
| --- | --- |
| `tests/unit/test_ports.py:99` `_DummyRecorder` | 显式签名补齐 10 参(它是"新签名可实现"的活证据,不改成 `**kwargs` |
| `tests/unit/test_ports.py:264` `TestTelemetryRecorderSignature` | 新参进 `no default` / keyword-only 参数化 |
| `tests/unit/test_telemetry.py:40` `_EXPECTED_COLUMNS`、:220/:321/:597/:639/:647/:686-698 计数与尾部断言 | 26→36、27→37、尾部 10 列、旧表 backfill 目标列数 |
| `tests/unit/test_telemetry.py:108` `_record_minimal` | 字段字典补 10 键(默认 NULL 形态) |
| `tests/integration/test_postgres_telemetry.py:100` 字段字典 | 同上(该函数返回值被逐列断言消费,改动须与 `COLUMNS` 同序) |
| `test_client.py` / `test_embedding.py` / `test_ocr_client.py` / `test_openai_compat.py` / `test_pricing.py` / `test_cache.py` / `test_usage_source_domain.py``_MemoryRecorder` | 均为 `**fields` 形态,**recorder 签名无需改**;只需核对断言里的字段计数与新列期望 |
| `tests/unit/test_pricing.py``tests/unit/test_usage_source_domain.py`**Emitter 调用点** | **须改**`TelemetryEmitter(``scope=``emit_attempt(`/`emit_cache_hit(``operation=``test_usage_source_domain.py:297``emit_terminal_failure(...)` 同时缺 `scope`/`operation`/`stats` 且多传 `latency_ms`,不改必 `TypeError`recorder 形态兼容 ≠ emitter 调用点兼容) |
**存储兼容**(真实 PG,复用 `tests/integration/conftest.py``pg_sandbox` / `pg_catalog_probe`,不新建沙箱设施;**不引用 `assert_no_leftovers`**——它是 `tests/integration/test_pg_sandbox.py:19` 的模块级 fixture,对 `test_postgres_telemetry.py` 不可见,上提它要改 §2 清单外的 `conftest.py`。本版不新增沙箱资源创建路径,残留风险与 1.3.4 逐字相同,由该文件既有用例覆盖,属可复用的既有证据):
- SQLite:新建库 37 列;旧表(1.3.4 形态 27 列)auto 档补齐 10 列且列序与新建库一致;manual 档不发 DDL、按现有列裁剪写入并发一条点名缺列的 warning;旧行新列为 NULL。
- PG(验收取以下**四项**):auto 追加 10 列;**manual 缺列裁剪**写入成功且不抛(`_trim_columns` 路径);旧行新列为 NULL;新旧进程混写同一表(旧列集写入 + 新列集写入并存)。
**变异证据**(仓库外临时副本 + `PYTHONPATH=<副本>/src`,先确认 `polygateway.__file__` 指向副本;逐个变异 → 跑指定节点记 exit 1 与被杀断言 → 恢复校验散列 → exit 0;**绝不在主工作区改生产代码凑红**):
| 变异 | 必须被杀死的断言 |
| --- | --- |
| `register_attempt()` 移到 transport 调用之后的 `except` 分支外/内错位 | 失败重试与 429 计数断言 |
| `ChatRequest` 上下文字段改为 `replace` 时新建实例(模拟上下文复制) | 重问/分批共享同一 `logical_call_id` 的断言 |
| `_rehydrate` 去掉 `call_stats=None` 覆盖 | 历史 dict 冒充 `CallStats` 的断言 |
| Emitter 入口提前 `str(exc)` 压平 | `error_type`/`http_status_code`/`error_body` 保真断言 |
| 终态行复制最后一次 attempt 的 token/cost | 费用聚合与终态 `cost IS NULL` 断言 |
| 去掉 `claim_terminal` 去重 | 每失败调用至多一条终态的断言 |
| 装配闸改为捕获 `TypeError` 后 warning | 旧签名 recorder 装配期报错的断言 |
**文档同步(发布前必须同批;wiki 站点自 2026-08-02 下线,按 docs-convention §2 的下线期条款,承接方为 README / CHANGELOG / .env.example / ARCHITECTURE 四处)**
| 位置 | 改什么 |
| --- | --- |
| `README.md:23` | "必录 26 字段" → 实测值(`len(inspect.signature(TelemetryRecorder.record_llm_call).parameters) - 1`,预期 36),不凭记忆 |
| `README.md:26/399/455``ARCHITECTURE.md:592``.env.example:109` | `PGW_TELEMETRY_TEXT_CAP` 覆盖面仍是四处;明确 `error_body` 沿用 `summarize_body` 上限、`error` 的新增结构化说明另有独立限长,**二者都不在 cap 覆盖内** |
| `README.md` 能力表 + 新增小节 | 四响应的 `call_stats` 读法;设计 §8 的 SQL 迁移**五项**(失败行数改判据、`error IS NOT NULL` 不再是判据、`AVG(latency_ms)` 须按 `event_kind` 分组、费用口径不变、失败行可能带 200) |
| `ARCHITECTURE.md:565` 必录字段清单 + §7.8 补列一节 | 追加 10 列语义、`event_kind` 三态、终态行不变量 I3/I4、`operation``exc.operation` 是两个语义 |
| `CHANGELOG.md` 未发布段 | 公共面四项 + 下游动作清单(点名"计失败调用改 `WHERE event_kind = 'terminal_failure'`"与自定义 recorder 的装配期报错) |
| `research-wiki/schemas/llm-calls.md``metrics/call-telemetry-coverage.md` | 复用既有实体登记新列与三类行口径;真实覆盖基线待首次运行填,不写伪百分比 |
**验收命令与证据**
| 检查 | 命令 / 要求 |
| --- | --- |
| 静态 | `make check``git diff --check``conda run -n PolyGateway python -m compileall -q src/polygateway` |
| 日常全量 | `make test`(真实退出码、coverage ≥80%,连接依赖 skip 单列) |
| 集成 | `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py tests/integration/test_governance_stack.py -q` |
| 契约 | `conda run -n PolyGateway pytest tests/contracts -q`(限流契约随实现一起交付的既有套件) |
| 变异 | 上表七项逐条 exit 1 → 还原 exit 0,日志留 `tests/outputs/135/` |
| 独立验证 | 由**父会话前台派全新上下文 verifier**:只给批准设计、本计划、分支 diff 与命令,不给实现自评;至少覆盖"正确性/回归"与"下游可见变化/范围"两角度,Critical/Important 清零 |
| slow | `pytest -m slow` 属发布清单第 4 步(CLAUDE §4.4.1),在合并 main 之后统一跑;**本版不新增 live 轮次、不补跑未变更的模型能力矩阵**,1.3.4 已有有效证据直接引用 |
长跑用 tmux`PYTHONUNBUFFERED=1`,输出重定向到 `tests/outputs/135/`,命令后**不得接管道**(退出码失真),完成判定用 `wait`/PID 轮询,不用会自匹配的 `pgrep -f`
- [ ] 提交点:`docs: document logical call telemetry and migration impact`(机械核对与存储兼容若需单独回滚点,可先提 `test: align telemetry fixtures with the 36-field recorder`,仍在 5 个提交内)
## 5. 测试矩阵 → 任务映射(设计 §9 逐族落位)
| 设计测试族 | 任务 |
| --- | --- |
| logical 计数(含空输入 0 尝试 0 行) | T1 |
| 计时(缓存/退避/准入/重问/收尾、关 recorder) | T1(快照时机的终态部分在 T3) |
| 失败与取消(三路同策略、终态 await 取消传播、非领域异常 0 行) | T3 |
| 保真诊断(529/503、空超时文案、`exc.operation`、新列四值) | T2 |
| 归因 SQL(终态 reason + 逐源现场、结构化说明无 `raw_text` | T3 |
| 行语义(三类行 scope、至多一条终态、费用不重复、`AVG` 分组) | T2(前两项)+ T3 |
| 装配闸(旧签名报错、`**kwargs` 通过、不可 inspect | T2 |
| 存储兼容(SQLite 新旧表、PG manual/auto、旧行 NULL、混写) | T4 |
| 变异(计数位置、上下文复制、缓存回放、提前压平、终态双计费用) | T4 |
**共享测试设施一律复用,不新建**`tests/contracts/conftest.py:26``tests/unit/test_ocr_client.py:344``FakeClock`(注入 `now`/`sleep`,确定性计时);各测试文件既有的 `_MemoryRecorder``**fields`)与脚本化 MockTransport`tmp_path` + 真实 `SQLiteRecorder(auto_migrate=...)``tests/integration/conftest.py` 的 PG 沙箱三件套。真实 LLM 输出如产生,按 CLAUDE §4.6 落 `tests/outputs/<module>/`
## 6. 阻塞矩阵与发布交接
| 情形 | 本库可完成 | 不可自行宣称/处置 |
| --- | --- | --- |
| 无 PG 可用 | 单测与 SQLite 全部覆盖;PG 用例 skip 并单列 | 不得把 skip 记作通过;发布前须补跑或取人类具名豁免 |
| 下游自定义 recorder 未知 | 装配闸 + 迁移文档 + `**kwargs` 兼容路径 | 不能声称"后端完全不受影响":其 schema、INSERT 字段与契约测试仍须同步 |
| 设计外漏洞 | 独立记录实际文件与反例,交父会话核定 | 不顺手实施 #20 之外的 issue、不新增表/端口/deadline/hedging |
| 发布 | 门全绿后按 CLAUDE §4.4.1 逐步执行(文档先行 → CHANGELOG 定版 → 双处版本号 → 合并 main → `make lint`/`make test`/`pytest -m slow` → tag → 构建 → 上传 → 下载验证 → Release + 挂仓库 + 页面核对) | 本计划**不复制**该清单,也不预先勾选任何发布步骤;只 bump 版本号不叫发布 |
## 7. 自审
- 设计每节可指到任务:§3→T1、§4→T1、§5→T2/T3、§6→T3、§7→T2、§8→T4、§9→§5 映射表、§10 六项批准项全部落在 T1–T3 的公共面改动内。
- 无占位符与待定项:跨任务消费的类型(`CallStats``_CallContext``_ErrorFields`)、四个 emit 入口、两个 helper`_assert_recorder_shape``emit_terminal_once`)、10 列取值表、10 列 DDL 类型均已写出可执行定义;被引用的 `PolyGatewayError``ResultInvalidError``summarize_body``format_bounded_errors``FakeClock``pg_sandbox` 全部指向既有实现或本计划已定义项。
- 一致性核对:`emit_terminal_failure` 去掉 `latency_ms` 后,唯一调用者是 `emit_terminal_once``TelemetryMW` 的终态分支删除后 chat 终态只剩客户端边界一处;`operation` 只由调用点给定,链路上无任何位置读 `exc.operation`
- 未采纳项(异常上挂可变统计、终态搬运最后一次 attempt 的状态与正文、成功侧汇总行)理由在设计正文,本计划不复活。
- 本计划编写过程**未运行 pytest、未做变异、未调用任何模型、未提交**;T0 基线验证与其后各任务的红绿证据由执行者在自己的会话内出示。
+33 -2
View File
@@ -1,11 +1,11 @@
--- ---
type: schema type: schema
node_id: schema:llm-calls node_id: schema:llm-calls
title: "表结构: llm_calls(遥测 26 字段)" title: "表结构: llm_calls(遥测 36 字段)"
date: 2026-07-20 date: 2026-07-20
--- ---
# 表结构: llm_calls(遥测 26 字段) # 表结构: llm_calls(遥测 36 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
@@ -31,6 +31,37 @@ date: 2026-07-20
| meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB | | 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`。见下方口径 | | thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
| reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 | | reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
| scope | TEXT | 池名(2026-09-09,issue #19/#23);Emitter 构造期注入,三类行都带,**不拿 `source_name` 顶替** |
| operation | TEXT | `chat` / `embed` / `recognize_text` / `parse_layout`,由调用点给定;与 `PolyGatewayError.operation` 是两个语义,链路上不得读后者填本列 |
| logical_call_id | TEXT | 一次逻辑调用一个 ID(重试/换源/重问/分批共享);上下文缺席落 NULL,**不造 ID** |
| event_kind | TEXT | 三态 `attempt` / `cache_hit` / `terminal_failure`——三类行的唯一机械判据。见下方口径 |
| http_status_code | INTEGER | 失败 attempt 行的实收状态码(中转改写过就记改写后的,**不猜回原值**);成功行与终态行恒 NULL,且 200 也可能是失败行(MonkeyOCR `success != true`) |
| error_type | TEXT | 该行自身错误的类名;取消路径传字符串故为 NULL |
| cause_type | TEXT | `__cause__` 的类名(如 `ReadTimeout`)——httpx 超时类的 `str()` 为空,只靠 error 列分不出是哪种超时;仅失败 attempt 行非空 |
| error_body | TEXT | 网关响应正文摘要(`summarize_body` 上限,**不在 `PGW_TELEMETRY_TEXT_CAP` 覆盖面内**);仅失败 attempt 行非空 |
| attempts | INTEGER | 该逻辑调用真实打出去的尝试次数(免预算 429 也计);**只属终态行** |
| total_latency_ms | INTEGER | 该逻辑调用的总墙钟(含缓存 IO、退避、准入等待、重问);**只属终态行**,与该行 `latency_ms` 同取一份冻结快照 |
## 三类行与失败归因口径(2026-09-09,issue #19/#23)
遥测行不再只有"一次尝试"一种。`event_kind` 三态同时决定其余列的取值面:
| event_kind | 什么时候写 | 列取值 |
| --- | --- | --- |
| `attempt` | 每次真实尝试(含失败与取消) | 失败时诊断四列可非空;`attempts`/`total_latency_ms` NULL |
| `cache_hit` | 缓存命中 | 诊断四列与快照两列全 NULL |
| `terminal_failure` | 每次**领域失败**的整池终态,每逻辑调用至多一条 | `attempts`/`total_latency_ms` 非空;`http_status_code`/`cause_type`/`error_body` 恒 NULL;`cost` NULL、`usage_source='unavailable'`、token 0 |
两条不变量: 每次领域失败至多一条终态行(I3,`claim_terminal()` 去重);非领域异常(编程错)**零条**终态行、原样传播(I4)。
**终态行三列恒 NULL 是红线**: 把最后一次 attempt 的状态码与正文搬上来,等于拿最后一个源冒充整池归因。逐源现场由同一 `logical_call_id` 的 attempt 行给出:
```sql
SELECT event_kind, source_name, http_status_code, error_type, cause_type, error, error_body
FROM llm_calls WHERE logical_call_id = :lcid ORDER BY created_at;
```
下游口径迁移四条: ① 计失败调用改 `WHERE event_kind = 'terminal_failure'`;② `error IS NOT NULL` 不再是失败调用判据(跨两类行);③ `AVG(latency_ms)` 须按 `event_kind` 分组(终态行是整个逻辑调用的总耗时);④ 费用口径不变(终态行 cost 恒 NULL)。
## usage/成本口径(2026-07-30,est_tokens 解耦) ## usage/成本口径(2026-07-30,est_tokens 解耦)
+3 -1
View File
@@ -39,6 +39,7 @@ from polygateway.thinking import (
) )
from polygateway.types import ( from polygateway.types import (
EFFORT_ORDER, EFFORT_ORDER,
CallStats,
Effort, Effort,
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
@@ -50,13 +51,14 @@ from polygateway.types import (
ThinkingObservation, ThinkingObservation,
) )
__version__ = "1.3.4" __version__ = "1.3.5"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
"EFFORT_ORDER", "EFFORT_ORDER",
"Effort", "Effort",
"AllSourcesExhausted", "AllSourcesExhausted",
"CallStats",
"CircuitOpenError", "CircuitOpenError",
"EmbeddingClient", "EmbeddingClient",
"EmbeddingResponse", "EmbeddingResponse",
+35 -3
View File
@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import dataclasses
import hashlib import hashlib
import json import json
import random import random
@@ -19,11 +20,12 @@ from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.cache import InMemoryCache from polygateway.backends.memory.cache import InMemoryCache
from polygateway.backends.memory.limiter import InMemoryLimiter from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.config import GatewaySettings from polygateway.config import GatewaySettings
from polygateway.errors import PolyGatewayError
from polygateway.middleware.base import compose from polygateway.middleware.base import compose
from polygateway.middleware.cache import CacheMW from polygateway.middleware.cache import CacheMW
from polygateway.middleware.retry import RetryMW from polygateway.middleware.retry import RetryMW
from polygateway.middleware.structured import StructuredMW from polygateway.middleware.structured import StructuredMW
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW, emit_terminal_once
from polygateway.ports import TelemetryStatusProvider from polygateway.ports import TelemetryStatusProvider
from polygateway.pricing import PricingTable from polygateway.pricing import PricingTable
from polygateway.providers import get_provider from polygateway.providers import get_provider
@@ -46,6 +48,7 @@ from polygateway.types import (
Effort, Effort,
LLMResponse, LLMResponse,
TelemetryStatus, TelemetryStatus,
_CallContext,
coerce_effort, coerce_effort,
validate_caller_dimensions, validate_caller_dimensions,
validate_request_overlay, validate_request_overlay,
@@ -237,7 +240,7 @@ class GatewayClient:
rng: Any = random.random, rng: Any = random.random,
) -> None: ) -> None:
emitter = ( emitter = (
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
if telemetry is not None if telemetry is not None
else None else None
) )
@@ -289,6 +292,10 @@ class GatewayClient:
self._structured_available = structured_strategy is not None self._structured_available = structured_strategy is not None
self._terminal = terminal # 内部引用: 装配自省/测试用 self._terminal = terminal # 内部引用: 装配自省/测试用
self._handler = compose(middlewares, terminal) self._handler = compose(middlewares, terminal)
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
self._now = now
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter
self._emitter = emitter
self._transport = transport self._transport = transport
self._telemetry = telemetry self._telemetry = telemetry
self._cache = cache self._cache = cache
@@ -370,6 +377,8 @@ class GatewayClient:
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)") else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
) )
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay") validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
context = _CallContext(now=self._now)
request = ChatRequest( request = ChatRequest(
messages=messages, messages=messages,
session_id=session_id, session_id=session_id,
@@ -383,8 +392,31 @@ class GatewayClient:
reasoning_effort=effort, reasoning_effort=effort,
tenant_id=dimension_tenant_id, tenant_id=dimension_tenant_id,
meta=dimensions, meta=dimensions,
call_context=context,
) )
return await self._handler(request) try:
response = await self._handler(request)
except PolyGatewayError as exc:
# 统计边界内的一切领域失败均尝试写一条终态行(1.3.5 设计 §6 I3),
# 包括已有 attempt 错误行的 RequestRejected / ResultInvalid——两类行描述
# 的不是同一件事(尝试 vs 逻辑终态),由 `event_kind` 区分
await emit_terminal_once(
self._emitter, request=request, context=context, error=exc, operation="chat"
)
raise
except asyncio.CancelledError:
# 尽力而为且**取消优先**: 不 shield、不开后台任务;写入那一次 await 上
# 再被取消则 `CancelledError` 照常传播(与 TelemetryMW 历史行为同款)
await emit_terminal_once(
self._emitter,
request=request,
context=context,
error="cancelled",
operation="chat",
)
raise
# 快照在返回前冻结: 故它含缓存命中路径与已完成的内联遥测耗时
return dataclasses.replace(response, call_stats=context.snapshot())
async def aclose(self) -> None: async def aclose(self) -> None:
"""幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。 """幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。
+99 -8
View File
@@ -16,6 +16,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import dataclasses
import math import math
import random import random
import time import time
@@ -41,12 +42,13 @@ from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
from polygateway.types import ( from polygateway.types import (
ChatRequest, ChatRequest,
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
TelemetryStatus, TelemetryStatus,
_CallContext,
strip_unsupported_extra_body, strip_unsupported_extra_body,
validate_caller_dimensions, validate_caller_dimensions,
) )
@@ -127,7 +129,9 @@ class EmbeddingClient:
self._transport = transport self._transport = transport
self._retry = retry self._retry = retry
self._emitter = ( self._emitter = (
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None TelemetryEmitter(telemetry, scope=self._scope, pricing=pricing, text_cap=text_cap)
if telemetry
else None
) )
self._telemetry = telemetry self._telemetry = telemetry
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份 # 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
@@ -184,7 +188,11 @@ class EmbeddingClient:
dimension_tenant_id, dimensions = validate_caller_dimensions( dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="embed(tenant_id=..., meta=...)" tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
) )
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
context = _CallContext(now=self._now)
if not texts: if not texts:
# 合法零尝试: 返回真实统计(attempts=0),且**不写任何遥测行**
# ——与 cache_hit 不同,不要按"遥测必录"推断它有台账行(设计 §3 M2)
return EmbeddingResponse( return EmbeddingResponse(
vectors=[], vectors=[],
dim=0, dim=0,
@@ -195,7 +203,40 @@ class EmbeddingClient:
latency_ms=0, latency_ms=0,
call_id=str(uuid.uuid4()), call_id=str(uuid.uuid4()),
source_name="", source_name="",
call_stats=context.snapshot(),
) )
try:
return await self._embed_all(
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
)
except PolyGatewayError as exc:
await self._emit_terminal(
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context, exc
)
raise
except asyncio.CancelledError:
# 三条链路同一口径尽力写一条(允许 0 条);取消优先,不 shield
await self._emit_terminal(
texts,
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
context,
"cancelled",
)
raise
async def _embed_all(
self,
texts: list[str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> EmbeddingResponse:
"""切批串行执行并合并;无源的 raise 必须在本方法内——否则无源终态行写不出。"""
if not self._sources: if not self._sources:
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0) raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
outcomes = [] outcomes = []
@@ -205,11 +246,49 @@ class EmbeddingClient:
texts[start : start + self._batch_size], texts[start : start + self._batch_size],
session_id, session_id,
parent_call_id, parent_call_id,
dimension_tenant_id, tenant_id,
dimensions, meta,
context,
) )
) )
return self._merge(outcomes) # 全批共享同一上下文,故分批是实现细节而非 N 次独立逻辑调用
return dataclasses.replace(self._merge(outcomes), call_stats=context.snapshot())
async def _emit_terminal(
self,
texts: list[str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
error: PolyGatewayError | str,
) -> None:
"""终态行的请求摘要(设计 §6 M4): 计数占位 + 第一批截断文本。
描述的是**本次调用的整体输入**但不扩大单行正文预算: 失败批的具体文本由同
`logical_call_id` attempt 行给出,终态行不保存全量原输入
"""
batches = math.ceil(len(texts) / self._batch_size)
messages = [{"role": "user", "content": f"<embed texts={len(texts)} batches={batches}>"}]
# 与逐批行同款构造(至多 `batch_size` 条、每条 200 字符)
messages += [
{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in texts[: self._batch_size]
]
await emit_terminal_once(
self._emitter,
request=ChatRequest(
messages=messages,
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
call_context=context,
),
context=context,
error=error,
operation="embed",
)
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)—— # —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
@@ -220,6 +299,7 @@ class EmbeddingClient:
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
context: _CallContext,
) -> _BatchOutcome: ) -> _BatchOutcome:
fails = 0 fails = 0
reasons: dict[str, str] = {} reasons: dict[str, str] = {}
@@ -232,7 +312,7 @@ class EmbeddingClient:
continue continue
async with clock.attempting(): async with clock.attempting():
outcome = await self._attempt( outcome = await self._attempt(
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta, context
) )
if isinstance(outcome, _BatchOutcome): if isinstance(outcome, _BatchOutcome):
return outcome return outcome
@@ -258,10 +338,13 @@ class EmbeddingClient:
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
context: _CallContext,
) -> _BatchOutcome | _FailedBatch: ) -> _BatchOutcome | _FailedBatch:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
actual = 0 actual = 0
# 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了
context.register_attempt()
try: try:
result = await self._transport.embed(texts=batch, source=source, call_id=call_id) result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
if self._expected_dim is not None and result.dim != self._expected_dim: if self._expected_dim is not None and result.dim != self._expected_dim:
@@ -287,6 +370,7 @@ class EmbeddingClient:
parent_call_id, parent_call_id,
tenant_id, tenant_id,
meta, meta,
context,
result, result,
) )
return _BatchOutcome(result, source, call_id, latency_ms) return _BatchOutcome(result, source, call_id, latency_ms)
@@ -301,6 +385,7 @@ class EmbeddingClient:
parent_call_id, parent_call_id,
tenant_id, tenant_id,
meta, meta,
context,
error=exc, error=exc,
) )
raise raise
@@ -316,6 +401,7 @@ class EmbeddingClient:
parent_call_id, parent_call_id,
tenant_id, tenant_id,
meta, meta,
context,
error="cancelled", error="cancelled",
) )
raise raise
@@ -336,6 +422,7 @@ class EmbeddingClient:
parent_call_id, parent_call_id,
tenant_id, tenant_id,
meta, meta,
context,
error=exc, error=exc,
) )
return _FailedBatch(exc, immediate=dead) return _FailedBatch(exc, immediate=dead)
@@ -372,8 +459,9 @@ class EmbeddingClient:
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
context: _CallContext,
result: EmbeddingTransportResult | None = None, result: EmbeddingTransportResult | None = None,
error: object | None = None, error: PolyGatewayError | str | None = None,
) -> None: ) -> None:
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。""" """逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
if self._emitter is None: if self._emitter is None:
@@ -386,6 +474,7 @@ class EmbeddingClient:
parent_call_id=parent_call_id, parent_call_id=parent_call_id,
tenant_id=tenant_id, tenant_id=tenant_id,
meta=meta, meta=meta,
call_context=context,
) )
response = None response = None
if result is not None: if result is not None:
@@ -410,10 +499,12 @@ class EmbeddingClient:
call_id=call_id, call_id=call_id,
latency_ms=int((self._now() - started) * 1000), latency_ms=int((self._now() - started) * 1000),
response=response, response=response,
error=None if error is None else str(error), # 异常对象原样下传: 状态码/底层异常类型/网关正文在 Emitter 内定型
error=error,
# embedding payload 硬编码 {model, input},从不带推理参数;源上即便 # embedding payload 硬编码 {model, input},从不带推理参数;源上即便
# 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事 # 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事
reasoning_applies=False, reasoning_applies=False,
operation="embed",
) )
def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse: def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
+7
View File
@@ -208,6 +208,10 @@ class CacheMW:
max_inter_token_ms=None, max_inter_token_ms=None,
call_id=str(uuid.uuid4()), call_id=str(uuid.uuid4()),
structured_data=structured_data, structured_data=structured_data,
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
# 过滤**会放行它**——不覆盖就会有 dict 冒充 `CallStats` 漏给调用方。
# 本次调用的真实统计由公开边界在返回前追加(设计 §3)
call_stats=None,
) )
return LLMResponse(**fields) return LLMResponse(**fields)
except Exception as exc: except Exception as exc:
@@ -230,6 +234,9 @@ class CacheMW:
def _serialize(self, response: LLMResponse) -> str: def _serialize(self, response: LLMResponse) -> str:
data = dataclasses.asdict(response) data = dataclasses.asdict(response)
data.pop("structured_data", None) # pydantic 实例不可 JSON 往返(设计 §2.1) data.pop("structured_data", None) # pydantic 实例不可 JSON 往返(设计 §2.1)
# 统计描述**本次**调用,存进去再放出来等于向下一个调用方谎称
# 它重试了 N 次;`asdict` 会把 `CallStats` 摊成 dict,故必须显式剔除
data.pop("call_stats", None)
return json.dumps(data, ensure_ascii=False) return json.dumps(data, ensure_ascii=False)
async def _safe_get(self, key: str) -> str | None: async def _safe_get(self, key: str) -> str | None:
+16 -4
View File
@@ -42,6 +42,7 @@ from polygateway.types import LLMResponse
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.ports import ( from polygateway.ports import (
GateDecision, GateDecision,
Permit, Permit,
@@ -181,7 +182,7 @@ class RetryMW:
circuit_open: str = "fail_fast", circuit_open: str = "fail_fast",
cooldown_memo: SourceCooldownMemo | None = None, cooldown_memo: SourceCooldownMemo | None = None,
pacer: AdaptivePacer | None = None, pacer: AdaptivePacer | None = None,
emitter: object | None = None, emitter: TelemetryEmitter | None = None,
now: Callable[[], float] = time.monotonic, now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random, rng: Callable[[], float] = random.random,
@@ -278,6 +279,11 @@ class RetryMW:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
actual = 0 actual = 0
# 登记在 transport 调用**之前**(1.3.5 设计 §4): 失败与取消的尝试同样
# "真的打出去了",挪到成功之后会让诊断最需要看见的那几次从计数里消失。
# 上下文为 None = 库内现场构造的请求,跳过而不是报错
if request.call_context is not None:
request.call_context.register_attempt()
try: try:
result = await self._transport.complete( result = await self._transport.complete(
messages=request.messages, messages=request.messages,
@@ -408,9 +414,13 @@ class RetryMW:
started: float, started: float,
*, *,
response: LLMResponse | None = None, response: LLMResponse | None = None,
error: object | None = None, error: PolyGatewayError | str | None = None,
) -> None: ) -> None:
"""逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。""" """逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。
异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码底层异常
类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉
"""
if self._emitter is None: if self._emitter is None:
return return
try: try:
@@ -420,9 +430,11 @@ class RetryMW:
call_id=call_id, call_id=call_id,
latency_ms=int((self._now() - started) * 1000), latency_ms=int((self._now() - started) * 1000),
response=response, response=response,
error=None if error is None else str(error), error=error,
# chat 路径是唯一带推理参数的路径,故实发档由这里的响应说了算 # chat 路径是唯一带推理参数的路径,故实发档由这里的响应说了算
reasoning_applies=True, reasoning_applies=True,
# 公开方法四值之一;本中间件只服务 chat 洋葱
operation="chat",
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+16 -5
View File
@@ -14,6 +14,8 @@ from typing import TYPE_CHECKING
from polygateway.errors import ResultInvalidError from polygateway.errors import ResultInvalidError
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence
from polygateway.ports import CallNext, StructuredOutputStrategy from polygateway.ports import CallNext, StructuredOutputStrategy
from polygateway.types import ChatRequest, LLMResponse from polygateway.types import ChatRequest, LLMResponse
@@ -22,12 +24,18 @@ _FEEDBACK_TEMPLATE = (
"Your previous reply was not valid JSON matching the required schema. " "Your previous reply was not valid JSON matching the required schema. "
"Errors: {errors}. Reply with ONLY the corrected JSON object." "Errors: {errors}. Reply with ONLY the corrected JSON object."
) )
_MAX_FEEDBACK_ERRORS = 3 MAX_FEEDBACK_ERRORS = 3
_MAX_ERROR_CHARS = 200 MAX_ERROR_CHARS = 200
def _format_errors(errors: list[str]) -> str: def format_bounded_errors(errors: Sequence[str]) -> str:
clipped = [e[:_MAX_ERROR_CHARS] for e in errors[:_MAX_FEEDBACK_ERRORS]] """校验错误的有界拼装: 至多 3 条 × 每条 200 字符。
**本模块是这条规则的所有者**: 重问反馈文案与 1.3.5 终态行的结构化说明
两个消费者共用同一份实现与同一组数值数值复制成两份必然漂移,而漂移后
"模型看到的错误""台账里记的错误"就不再是同一件事行为与重命名前逐字相同
"""
clipped = [e[:MAX_ERROR_CHARS] for e in list(errors)[:MAX_FEEDBACK_ERRORS]]
return "; ".join(clipped) if clipped else "output could not be parsed" return "; ".join(clipped) if clipped else "output could not be parsed"
@@ -104,7 +112,10 @@ class StructuredMW:
messages = [ messages = [
*current.messages, *current.messages,
{"role": "assistant", "content": bad_content}, {"role": "assistant", "content": bad_content},
{"role": "user", "content": _FEEDBACK_TEMPLATE.format(errors=_format_errors(errors))}, {
"role": "user",
"content": _FEEDBACK_TEMPLATE.format(errors=format_bounded_errors(errors)),
},
] ]
reask = dataclasses.replace(current, messages=messages) reask = dataclasses.replace(current, messages=messages)
if self._escalation is not None: if self._escalation is not None:
+284 -48
View File
@@ -2,13 +2,19 @@
Emitter 是全库**唯一**调用 `record_llm_call` 的地方(三项目 4 处逐字复制 Emitter 是全库**唯一**调用 `record_llm_call` 的地方(三项目 4 处逐字复制
15 参调用的教训)分工: RetryMW Emitter 逐次记录每次尝试;TelemetryMW 15 参调用的教训)分工: RetryMW Emitter 逐次记录每次尝试;TelemetryMW
(最外层)只记尝试层看不见的事件缓存命中scope 级失败取消; (最外层)只记尝试层看不见的缓存命中;**终态失败行**由三个 client 的公开
RequestRejected/ResultInvalid 已被尝试层记录,最外层放行不重复记 边界经 `emit_terminal_once` 统一写出(1.3.5)两处同时写就会双计
一行遥测属于三类事件之一(`event_kind`): `attempt`(一次尝试)`cache_hit`
(未产生网关调用)`terminal_failure`(一次**逻辑调用**的失败终态)后两者与
前者**不是重复事实**,故统计失败调用次数只能取 `terminal_failure`,
不得按 `error IS NOT NULL` 跨两类直接计数(设计 §6/§8)
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import inspect
import json import json
import time import time
import uuid import uuid
@@ -17,12 +23,10 @@ from typing import TYPE_CHECKING
from loguru import logger from loguru import logger
from polygateway.errors import ( from polygateway.errors import PolyGatewayError, ResultInvalidError
GatewayUnavailableError,
GovernanceBackendError,
SourceNotConfiguredError,
)
from polygateway.middleware.cache import digest_messages from polygateway.middleware.cache import digest_messages
from polygateway.middleware.structured import MAX_ERROR_CHARS, format_bounded_errors
from polygateway.ports import TelemetryRecorder
from polygateway.thinking import effective_effort from polygateway.thinking import effective_effort
from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling
@@ -30,9 +34,17 @@ if TYPE_CHECKING:
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from typing import Any from typing import Any
from polygateway.ports import CallNext, TelemetryRecorder from polygateway.ports import CallNext
from polygateway.pricing import PricingTable from polygateway.pricing import PricingTable
from polygateway.types import ChatRequest, LLMResponse, SourceConfig from polygateway.types import (
CallOperation,
CallStats,
ChatRequest,
EventKind,
LLMResponse,
SourceConfig,
_CallContext,
)
def _canonical_meta_json(meta: Mapping[str, Any]) -> str: def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
@@ -225,26 +237,159 @@ class _AttemptUsage:
) )
@dataclass(frozen=True)
class _ErrorFields:
"""一行遥测的错误列;未知一律 `None`。
存在的理由是把"三种入参形态 × 两类行"的定型规则收敛到**一处**:
改前调用方先 `str(exc)` 压平,状态码底层异常类型与网关正文全部丢失
"""
error: str | None = None
error_type: str | None = None
cause_type: str | None = None
http_status_code: int | None = None
error_body: str | None = None
def _structured_detail(exc: ResultInvalidError) -> str:
"""结构化阶梯耗尽的**有界**说明(设计 §5 C2)。
`ResultInvalidError("结构化输出阶梯耗尽")` message 不含校验与修复错误,而该
失败发生在 StructuredMW 之上RetryMW 侧的 attempt 行全是**成功行**,终态行是
唯一记录故把说明并入现有 `error`
**不含 `raw_text`**: 它是模型正文,attempt 行的 `response` 列已按 `text_cap` 记过
一份;再存一份等于绕过既有的正文预算条数与限长复用 `structured.py` 的同一
套常量(重问反馈与本说明同一口径),数值只有一份
"""
parts: list[str] = []
if exc.repair_error:
parts.append(f"repair={exc.repair_error[:MAX_ERROR_CHARS]}")
if exc.validation_errors:
parts.append(f"validation={format_bounded_errors(exc.validation_errors)}")
return " | ".join(parts)
def _error_fields(
error: PolyGatewayError | str | None,
*,
event_kind: EventKind,
class_prefixed: bool,
) -> _ErrorFields:
"""三种入参形态的唯一定型点(设计 §5/§6)。
- `None` None(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了")
- `str`(取消路径的 `"cancelled"`) 原样落 `error`,**不解析字符串猜诊断**
- 领域异常 只读它既有的属性,不遍历任意对象不猜正文
**终态行的三列恒为 NULL(C1 红线)**: `GatewayUnavailableError` 家族从不携带
状态码与响应体,NULL 正是它自身的真实状态把最后一次 attempt 的状态码与正文
搬上来,就是拿最后一个源冒充整池归因逐源现场由同一 `logical_call_id`
attempt 行给出
"""
if error is None:
return _ErrorFields()
if isinstance(error, str):
return _ErrorFields(error=error)
name = type(error).__name__
# 空 `str()` 退回类名(httpx 的 Connect/Read/Write/PoolTimeout 文案就是空的);
# `class_prefixed` 是 OCR 的既有口径(按类名归组的 metric),故逐字保留它的拼法
text = f"{name}: {error}" if class_prefixed else (str(error) or name)
if event_kind == "terminal_failure":
if isinstance(error, ResultInvalidError):
detail = _structured_detail(error)
if detail:
text = f"{text} | {detail}"
return _ErrorFields(error=text, error_type=name)
cause = error.__cause__
return _ErrorFields(
error=text,
error_type=name,
cause_type=type(cause).__name__ if cause is not None else None,
# getattr 而非直读: 本函数在 `_record` 的降级 try **之外**求值,
# 一个非领域异常误传进来不得把一次真实失败换成 AttributeError
http_status_code=getattr(error, "status_code", None),
# 空串归 None: 既有 `body_text` 的缺省就是空串,而本列的语义是"未知"
error_body=getattr(error, "body_text", "") or None,
)
def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
"""装配期一次 `signature.bind` 形状校验: 不执行写入,只证明该形状能被接受。
`_record` `except Exception` 会把旧 recorder `TypeError` 吞成 warning,
后果是自定义 recorder 在下游升级后**100% 丢遥测且调用照常成功**正是
"遥测必录"要防的形态,而文档级迁移清单挡不住它故在装配期当场报错
(不是 warning: 降级方向的铁律管的是**运行期写失败**,不是装配错误)
参数名从 `TelemetryRecorder.record_llm_call` 的协议签名**派生**(不手抄第四份
字段清单),绑定用哨兵 `None`,不读任何真实请求数据;`**kwargs`
(VAR_KEYWORD)自动通过不可 inspect(C 实现等)同样按配置错误报错宁可
装配不起来,不进入"运行期静默丢行"
边界诚实声明: 它只证明该形状能被接受,**不能证明函数体真的落这些列**
Raises:
ValueError: 签名不符不可 inspect,或协议本身不可 inspect
"""
try:
# 模块全局查找而非常量快照: 协议改了,闸就跟着改(测试可据此机械验证)
protocol = inspect.signature(TelemetryRecorder.record_llm_call).parameters
except (TypeError, ValueError) as exc: # pragma: no cover - 协议一向可 inspect
raise ValueError(f"TelemetryRecorder.record_llm_call 签名不可读取: {exc}") from exc
sentinels = {name: None for name in protocol if name != "self"}
label = type(recorder).__name__
method = getattr(recorder, "record_llm_call", None)
if method is None:
# 连方法都没有: 比旧签名更明确的配置错误。不让它以裸 AttributeError
# 逆流而上——那不属错误四分类,且现场离"注错了东西"这个真因很远
raise ValueError(
f"注入的遥测 recorder {label} 没有 record_llm_call 方法,不满足 TelemetryRecorder 端口"
)
try:
signature = inspect.signature(method)
except (TypeError, ValueError) as exc:
raise ValueError(
f"遥测 recorder {label} 的 record_llm_call 不可 inspect(如 C 实现),"
"无法在装配期确认它接受当前字段形状;请换成 Python 实现或包一层"
) from exc
try:
signature.bind(**sentinels)
except TypeError as exc:
raise ValueError(
f"遥测 recorder {label} 的 record_llm_call 签名与 TelemetryRecorder 不符"
f"(当前 {len(sentinels)} 个字段): {exc}"
"这一条故意在装配期报错——放行的后果是每行遥测都被降级成 warning 后丢弃"
) from exc
class TelemetryEmitter: class TelemetryEmitter:
"""从请求与结果组装 26 字段并写入 recorder;一切写失败降级 warning。""" """从请求与结果组装 36 字段并写入 recorder;一切写失败降级 warning。"""
def __init__( def __init__(
self, self,
recorder: TelemetryRecorder, recorder: TelemetryRecorder,
*, *,
scope: str,
pricing: PricingTable | None = None, pricing: PricingTable | None = None,
text_cap: int | None, text_cap: int | None,
) -> None: ) -> None:
"""`text_cap` 无默认值是有意的: 是关键行为参数,漏传即静默改变落库正文 """`text_cap` 与 `scope` 无默认值是有意的: 两者都是关键行为参数。
`text_cap` 漏传即静默改变落库正文;`scope` 漏传则三类行都失去池名
终态失败可能根本没选出源, scope 始终已知,不拿 `source_name` 顶替
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传 本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传
同理,值域校验也放在这一处: 三个 Client `text_cap` 全部汇流到这里,
同理,值域校验与**装配闸**都放在这一处: 三个 Client 全部汇流到这里,
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一 `GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
条公共装配路`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默) 条公共装配路(`text_cap=0` 会让每条正文只剩一个省略标记;P5 不得静默)
""" """
if text_cap is not None and text_cap <= 0: if text_cap is not None and text_cap <= 0:
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}") raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
_assert_recorder_shape(recorder)
self._recorder = recorder self._recorder = recorder
self._scope = scope
self._pricing = pricing self._pricing = pricing
self._text_cap = text_cap self._text_cap = text_cap
@@ -256,8 +401,10 @@ class TelemetryEmitter:
call_id: str, call_id: str,
latency_ms: int, latency_ms: int,
response: LLMResponse | None, response: LLMResponse | None,
error: str | None, error: PolyGatewayError | str | None,
reasoning_applies: bool, reasoning_applies: bool,
operation: CallOperation,
class_prefixed_error: bool = False,
) -> None: ) -> None:
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。 """逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
@@ -266,7 +413,11 @@ class TelemetryEmitter:
共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` embedding 共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` embedding
会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的 会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的
**不设默认值**: `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传 **不设默认值**: `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传
当场 TypeError,好过被静默当成"没表态" 当场 TypeError,好过被静默当成"没表态"`operation` 同理且另有一层:
它只能由调用点给定,**绝不读 `exc.operation`**(后者是 HTTP 子操作)
`error` **领域异常对象**而非预先 `str()` 压平的文本: 状态码/底层异常类型/
网关正文在此提取成四列(设计 §5)取消路径仍传既有字符串 `"cancelled"`
""" """
usage = _AttemptUsage.of(response) usage = _AttemptUsage.of(response)
await self._record( await self._record(
@@ -284,7 +435,7 @@ class TelemetryEmitter:
ttft_ms=usage.ttft_ms, ttft_ms=usage.ttft_ms,
max_inter_token_ms=usage.max_inter_token_ms, max_inter_token_ms=usage.max_inter_token_ms,
cache_hit=False, cache_hit=False,
error=error, errors=_error_fields(error, event_kind="attempt", class_prefixed=class_prefixed_error),
cached_prompt_tokens=usage.cached_prompt_tokens, cached_prompt_tokens=usage.cached_prompt_tokens,
model_reported=usage.model_reported, model_reported=usage.model_reported,
reasoning_tokens=usage.reasoning_tokens, reasoning_tokens=usage.reasoning_tokens,
@@ -296,10 +447,19 @@ class TelemetryEmitter:
reasoning_effort=_attempt_effort( reasoning_effort=_attempt_effort(
request=request, source=source, response=response, applies=reasoning_applies request=request, source=source, response=response, applies=reasoning_applies
), ),
operation=operation,
event_kind="attempt",
attempts=None,
total_latency_ms=None,
) )
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None: async def emit_cache_hit(
"""缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。""" self, *, request: ChatRequest, response: LLMResponse, operation: CallOperation
) -> None:
"""缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。
逻辑计数两列恒 NULL: 本行描述的是"一次命中",不是一次逻辑调用的终态
"""
await self._record( await self._record(
request=request, request=request,
call_id=response.call_id, call_id=response.call_id,
@@ -315,7 +475,7 @@ class TelemetryEmitter:
ttft_ms=None, ttft_ms=None,
max_inter_token_ms=None, max_inter_token_ms=None,
cache_hit=True, cache_hit=True,
error=None, errors=_ErrorFields(),
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。 # 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。 # 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
cached_prompt_tokens=response.cached_prompt_tokens, cached_prompt_tokens=response.cached_prompt_tokens,
@@ -334,12 +494,28 @@ class TelemetryEmitter:
# 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射 # 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射
# 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的) # 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的)
reasoning_effort=_normalize_effort(request.reasoning_effort), reasoning_effort=_normalize_effort(request.reasoning_effort),
operation=operation,
event_kind="cache_hit",
attempts=None,
total_latency_ms=None,
) )
async def emit_terminal_failure( async def emit_terminal_failure(
self, *, request: ChatRequest, call_id: str, latency_ms: int, error: str self,
*,
request: ChatRequest,
call_id: str,
error: PolyGatewayError | str,
operation: CallOperation,
stats: CallStats,
class_prefixed_error: bool = False,
) -> None: ) -> None:
"""scope 级失败/取消记录: 无具体源,溯源字段置空标记。""" """一次**逻辑调用**的失败终态: 无具体源,溯源字段置空标记。
`latency_ms` `total_latency_ms` 同取**同一份冻结快照**,避免双时钟微差;
故本方法不再收 `latency_ms`token cost 一律不从 attempt 行复制
(费用聚合仍只由 attempt / cache_hit 行决定,口径不变)
"""
await self._record( await self._record(
request=request, request=request,
call_id=call_id, call_id=call_id,
@@ -351,11 +527,13 @@ class TelemetryEmitter:
prompt_tokens=0, prompt_tokens=0,
completion_tokens=0, completion_tokens=0,
usage_source="unavailable", usage_source="unavailable",
latency_ms=latency_ms, latency_ms=stats.total_latency_ms,
ttft_ms=None, ttft_ms=None,
max_inter_token_ms=None, max_inter_token_ms=None,
cache_hit=False, cache_hit=False,
error=error, errors=_error_fields(
error, event_kind="terminal_failure", class_prefixed=class_prefixed_error
),
cached_prompt_tokens=None, cached_prompt_tokens=None,
model_reported=None, model_reported=None,
reasoning_tokens=None, reasoning_tokens=None,
@@ -368,6 +546,10 @@ class TelemetryEmitter:
meta=request.meta, meta=request.meta,
# 可能根本没选出源,故与 sampling 同样只取请求档 # 可能根本没选出源,故与 sampling 同样只取请求档
reasoning_effort=_normalize_effort(request.reasoning_effort), reasoning_effort=_normalize_effort(request.reasoning_effort),
operation=operation,
event_kind="terminal_failure",
attempts=stats.attempts,
total_latency_ms=stats.total_latency_ms,
) )
async def _record( async def _record(
@@ -387,7 +569,9 @@ class TelemetryEmitter:
ttft_ms: float | None, ttft_ms: float | None,
max_inter_token_ms: float | None, max_inter_token_ms: float | None,
cache_hit: bool, cache_hit: bool,
error: str | None, # 1.3.5: 错误四列已由 `_error_fields` 定型(三种入参形态 × 两类行的唯一规则所有者),
# 本方法只搬运——拆成五个平铺参数就是把"一处定型"换回"三处各自拼"
errors: _ErrorFields,
cached_prompt_tokens: int | None, cached_prompt_tokens: int | None,
model_reported: str | None, model_reported: str | None,
sampling: str | None, sampling: str | None,
@@ -403,6 +587,11 @@ class TelemetryEmitter:
# 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把 # 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把
# "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制 # "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制
reasoning_effort: str | None, reasoning_effort: str | None,
# —— 1.3.5: 行形态与逻辑调用快照 ——
operation: CallOperation,
event_kind: EventKind,
attempts: int | None,
total_latency_ms: int | None,
) -> None: ) -> None:
try: try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -413,7 +602,7 @@ class TelemetryEmitter:
# 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。 # 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知 # 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
cost = None cost = None
elif error is None and model and self._pricing is not None: elif errors.error is None and model and self._pricing is not None:
cost = self._pricing.cost( cost = self._pricing.cost(
model, prompt_tokens, completion_tokens, cached_prompt_tokens model, prompt_tokens, completion_tokens, cached_prompt_tokens
) )
@@ -425,6 +614,7 @@ class TelemetryEmitter:
_cap_messages(digest_messages(request.messages), self._text_cap), _cap_messages(digest_messages(request.messages), self._text_cap),
ensure_ascii=False, ensure_ascii=False,
) )
context = request.call_context
await self._recorder.record_llm_call( await self._recorder.record_llm_call(
call_id=call_id, call_id=call_id,
parent_call_id=request.parent_call_id, parent_call_id=request.parent_call_id,
@@ -442,7 +632,7 @@ class TelemetryEmitter:
ttft_ms=ttft_ms, ttft_ms=ttft_ms,
max_inter_token_ms=max_inter_token_ms, max_inter_token_ms=max_inter_token_ms,
cache_hit=cache_hit, cache_hit=cache_hit,
error=error, error=errors.error,
cost=cost, cost=cost,
cached_prompt_tokens=cached_prompt_tokens, cached_prompt_tokens=cached_prompt_tokens,
model_reported=model_reported, model_reported=model_reported,
@@ -457,6 +647,19 @@ class TelemetryEmitter:
# Postgres 那一路悄悄少一列数据 # Postgres 那一路悄悄少一列数据
thinking_observation=_normalize_observation(thinking_observation), thinking_observation=_normalize_observation(thinking_observation),
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
# —— 1.3.5 十列 ——
scope=self._scope,
# 调用点给定的公开方法四值,**绝不读 `exc.operation`**(设计 §5 I1/I2)
operation=operation,
# 上下文缺席(库内现场构造的 ChatRequest)→ NULL,**不造 ID**(I5)
logical_call_id=None if context is None else context.logical_call_id,
event_kind=event_kind,
http_status_code=errors.http_status_code,
error_type=errors.error_type,
cause_type=errors.cause_type,
error_body=errors.error_body,
attempts=attempts,
total_latency_ms=total_latency_ms,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
@@ -464,36 +667,69 @@ class TelemetryEmitter:
logger.warning("遥测记录失败(降级不冒泡): {}", exc) logger.warning("遥测记录失败(降级不冒泡): {}", exc)
async def emit_terminal_once(
emitter: TelemetryEmitter | None,
*,
request: ChatRequest,
context: _CallContext,
error: PolyGatewayError | str,
operation: CallOperation,
class_prefixed_error: bool = False,
) -> None:
"""三个 client 共用的**终态唯一出口**: 去重 + 同步冻结快照 + best effort 写入。
去重由 `claim_terminal()` 承担(每逻辑调用至多一条终态行);`emitter is None`
或已写过 直接返回
**降级范围包含诊断字段的提取与构建**,不只是写入那一步: `_record` 内的
`except Exception` 只兜住落库, `_error_fields` / `canonical_sampling_json`
在它**之外**求值下游经公共端口(自实现 `StructuredOutputStrategy`
transport)构造出的 `ResultInvalidError(validation_errors=( str,))` 会让提取期
`TypeError` 顶替调用方本该收到的领域异常,错误四分类被击穿且终态行照样丢
故在此整段兜底, RetryMW attempt 出口(`retry.py::_emit`)同款写法
终态行按 best effort: 兜底命中时本次逻辑调用**没有**终态行(`claim_terminal()`
已消耗,不补写不重试重写一遍只会把同一个提取期异常再抛一次)
**`CancelledError` 原样传播**(取消优先, shield不开后台任务): 这一次
`await` 本身就是新的取消点,外部取消落在它上时调用方会看到 `CancelledError`
而非领域错误 TelemetryMW 的历史行为同款,已经人类批准(设计 §6/§10)
快照冻结是**同步**动作,故终态行不含它自身的写入耗时
"""
if emitter is None or not context.claim_terminal():
return
try:
stats = context.snapshot()
await emitter.emit_terminal_failure(
request=request,
call_id=str(uuid.uuid4()),
error=error,
operation=operation,
stats=stats,
class_prefixed_error=class_prefixed_error,
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("终态遥测记录失败(降级不冒泡): {}", exc)
class TelemetryMW: class TelemetryMW:
"""洋葱最外层: 观测尝试层看不见的路径,任何路径都留痕(遥测必录)。""" """洋葱最外层: 观测尝试层看不见的**缓存命中**。
1.3.5 起不再在此写终态失败行: 终态由 `GatewayClient.chat` 的公开边界经
`emit_terminal_once` 统一写出两处同时写会让同一次失败出两条终态行,
而下游正是按 `event_kind = 'terminal_failure'` 计失败调用次数的
"""
def __init__( def __init__(
self, emitter: TelemetryEmitter, now: Callable[[], float] = time.monotonic self, emitter: TelemetryEmitter, now: Callable[[], float] = time.monotonic
) -> None: ) -> None:
self._emitter = emitter self._emitter = emitter
self._now = now # `now` 自 1.3.5 起本类不再读取(终态行迁到公开边界后无耗时可测),但形参保留:
# 删它会平白打断 `TelemetryMW(emitter, now=...)` 这一既有装配写法
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse: async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
started = self._now()
try:
response = await call_next(request) response = await call_next(request)
except (GatewayUnavailableError, GovernanceBackendError, SourceNotConfiguredError) as exc:
await self._emitter.emit_terminal_failure(
request=request,
call_id=str(uuid.uuid4()),
latency_ms=int((self._now() - started) * 1000),
error=str(exc),
)
raise
except asyncio.CancelledError:
# 尽力而为: 取消也留痕(§5.1 约定④);随后立即重抛
await self._emitter.emit_terminal_failure(
request=request,
call_id=str(uuid.uuid4()),
latency_ms=int((self._now() - started) * 1000),
error="cancelled",
)
raise
if response.cache_hit: if response.cache_hit:
await self._emitter.emit_cache_hit(request=request, response=response) await self._emitter.emit_cache_hit(request=request, response=response, operation="chat")
return response return response
+153 -24
View File
@@ -37,15 +37,17 @@ from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
from polygateway.ports import OutcomeAwareSelector from polygateway.ports import OutcomeAwareSelector
from polygateway.types import ( from polygateway.types import (
CallStats,
ChatRequest, ChatRequest,
LLMResponse, LLMResponse,
OcrLayoutResult, OcrLayoutResult,
OcrTextResult, OcrTextResult,
TelemetryStatus, TelemetryStatus,
Usage, Usage,
_CallContext,
strip_unsupported_extra_body, strip_unsupported_extra_body,
validate_caller_dimensions, validate_caller_dimensions,
) )
@@ -65,6 +67,7 @@ if TYPE_CHECKING:
) )
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
CallOperation,
OcrLayoutTransportResult, OcrLayoutTransportResult,
OcrTextTransportResult, OcrTextTransportResult,
RetryPolicy, RetryPolicy,
@@ -126,7 +129,9 @@ class OcrClient:
self._breaker = BreakerGate(breaker, scope=self._scope) self._breaker = BreakerGate(breaker, scope=self._scope)
self._transport = transport self._transport = transport
self._retry = retry self._retry = retry
self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None self._emitter = (
TelemetryEmitter(telemetry, scope=self._scope, text_cap=text_cap) if telemetry else None
)
self._telemetry = telemetry self._telemetry = telemetry
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份 # 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
# 引用才关得到自建的 redis 客户端(设计 §3.4) # 引用才关得到自建的 redis 客户端(设计 §3.4)
@@ -176,8 +181,14 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions( dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)" tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
) )
outcome = await self._call( outcome, call_stats = await self._call(
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions "text",
"recognize_text",
image,
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
) )
result = outcome.result result = outcome.result
return OcrTextResult( return OcrTextResult(
@@ -187,6 +198,7 @@ class OcrClient:
latency_ms=outcome.latency_ms, latency_ms=outcome.latency_ms,
call_id=outcome.call_id, call_id=outcome.call_id,
raw=result.raw, raw=result.raw,
call_stats=call_stats,
) )
async def parse_layout( async def parse_layout(
@@ -206,8 +218,14 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions( dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)" tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
) )
outcome = await self._call( outcome, call_stats = await self._call(
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions "layout",
"parse_layout",
image,
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
) )
result = outcome.result result = outcome.result
return OcrLayoutResult( return OcrLayoutResult(
@@ -218,6 +236,7 @@ class OcrClient:
latency_ms=outcome.latency_ms, latency_ms=outcome.latency_ms,
call_id=outcome.call_id, call_id=outcome.call_id,
raw=result.raw, raw=result.raw,
call_stats=call_stats,
) )
async def check_health(self) -> dict[str, bool]: async def check_health(self) -> dict[str, bool]:
@@ -237,16 +256,60 @@ class OcrClient:
async def _call( async def _call(
self, self,
kind: _OcrKind, kind: _OcrKind,
operation: CallOperation,
image: bytes, image: bytes,
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
) -> _AttemptOutcome: ) -> tuple[_AttemptOutcome, CallStats]:
if not isinstance(image, bytes): if not isinstance(image, bytes):
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)") raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
if not image: if not image:
raise ValueError("image 不能为空") raise ValueError("image 不能为空")
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
context = _CallContext(now=self._now)
try:
return await self._run(
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context
)
except PolyGatewayError as exc:
await self._emit_terminal(
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context, exc
)
raise
except asyncio.CancelledError:
# 三条链路同一口径尽力写一条(允许 0 条);取消优先,不 shield
await self._emit_terminal(
kind,
operation,
image,
session_id,
parent_call_id,
tenant_id,
meta,
context,
"cancelled",
)
raise
async def _run(
self,
kind: _OcrKind,
operation: CallOperation,
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> tuple[_AttemptOutcome, CallStats]:
"""选源与重试循环。
无源的 raise 必须在本方法内(而非循环之前的调用方): 它得被 `_call`
`try` 包住,否则无源终态行根本写不出来(设计 §3.5)
"""
if not self._sources: if not self._sources:
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0) raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
fails = 0 fails = 0
@@ -260,10 +323,19 @@ class OcrClient:
continue continue
async with clock.attempting(): async with clock.attempting():
outcome = await self._attempt( outcome = await self._attempt(
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta kind,
operation,
image,
*picked,
reasons,
session_id,
parent_call_id,
tenant_id,
meta,
context,
) )
if isinstance(outcome, _AttemptOutcome): if isinstance(outcome, _AttemptOutcome):
return outcome return outcome, context.snapshot()
fails += 1 fails += 1
if fails >= self._retry.max_attempts: if fails >= self._retry.max_attempts:
raise AllSourcesExhausted( raise AllSourcesExhausted(
@@ -278,6 +350,7 @@ class OcrClient:
async def _attempt( async def _attempt(
self, self,
kind: _OcrKind, kind: _OcrKind,
operation: CallOperation,
image: bytes, image: bytes,
source: SourceConfig, source: SourceConfig,
permit: Permit, permit: Permit,
@@ -287,11 +360,14 @@ class OcrClient:
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
context: _CallContext,
) -> _AttemptOutcome | _FailedAttempt: ) -> _AttemptOutcome | _FailedAttempt:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度: # 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行 # 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次
context.register_attempt()
try: try:
result = await self._invoke(kind, image, source, call_id) result = await self._invoke(kind, image, source, call_id)
await self._record_quietly(self._breaker.record_success(entry)) await self._record_quietly(self._breaker.record_success(entry))
@@ -300,6 +376,7 @@ class OcrClient:
latency_ms = int((self._now() - started) * 1000) latency_ms = int((self._now() - started) * 1000)
await self._emit( await self._emit(
kind, kind,
operation,
image, image,
source, source,
call_id, call_id,
@@ -308,6 +385,7 @@ class OcrClient:
parent_call_id, parent_call_id,
tenant_id, tenant_id,
meta, meta,
context,
result, result,
) )
return _AttemptOutcome(result, source, call_id, latency_ms) return _AttemptOutcome(result, source, call_id, latency_ms)
@@ -315,6 +393,7 @@ class OcrClient:
await self._gate_on_terminal(exc, entry) await self._gate_on_terminal(exc, entry)
await self._emit( await self._emit(
kind, kind,
operation,
image, image,
source, source,
call_id, call_id,
@@ -323,6 +402,7 @@ class OcrClient:
parent_call_id, parent_call_id,
tenant_id, tenant_id,
meta, meta,
context,
error=exc, error=exc,
) )
raise raise
@@ -331,6 +411,7 @@ class OcrClient:
await self._record_quietly(self._breaker.release_probe(entry)) await self._record_quietly(self._breaker.release_probe(entry))
await self._emit( await self._emit(
kind, kind,
operation,
image, image,
source, source,
call_id, call_id,
@@ -339,6 +420,7 @@ class OcrClient:
parent_call_id, parent_call_id,
tenant_id, tenant_id,
meta, meta,
context,
error="cancelled", error="cancelled",
) )
raise raise
@@ -350,6 +432,7 @@ class OcrClient:
self._feed_outcome(source.name, ok=False) self._feed_outcome(source.name, ok=False)
await self._emit( await self._emit(
kind, kind,
operation,
image, image,
source, source,
call_id, call_id,
@@ -358,6 +441,7 @@ class OcrClient:
parent_call_id, parent_call_id,
tenant_id, tenant_id,
meta, meta,
context,
error=exc, error=exc,
) )
return _FailedAttempt(exc, immediate=dead) return _FailedAttempt(exc, immediate=dead)
@@ -399,9 +483,60 @@ class OcrClient:
except (GovernanceBackendError, SourceNotConfiguredError) as exc: except (GovernanceBackendError, SourceNotConfiguredError) as exc:
logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc) logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc)
def _request_for(
self,
kind: _OcrKind,
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> ChatRequest:
"""OCR 行的现场 ChatRequest: 占位摘要,**图像 bytes 永不入库**。
尝试行与终态行共用同一个构造点: 占位字面量复制成两份就会漂移
调用方维度与上下文必须显式填回(OCR 不走 chat 洋葱),否则 OCR 行的
维度恒为空`logical_call_id` 恒为 NULL
"""
return ChatRequest(
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
call_context=context,
)
async def _emit_terminal(
self,
kind: _OcrKind,
operation: CallOperation,
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
error: PolyGatewayError | str,
) -> None:
"""终态行: 沿用 `<ocr:{kind} image_bytes=…>` 占位,错误文本保留类名前缀。"""
await emit_terminal_once(
self._emitter,
request=self._request_for(
kind, image, session_id, parent_call_id, tenant_id, meta, context
),
context=context,
error=error,
operation=operation,
# metric ocr-call-success 的注册口径是按类名归组,终态行同款保留
class_prefixed_error=True,
)
async def _emit( async def _emit(
self, self,
kind: _OcrKind, kind: _OcrKind,
operation: CallOperation,
image: bytes, image: bytes,
source: SourceConfig, source: SourceConfig,
call_id: str, call_id: str,
@@ -410,20 +545,15 @@ class OcrClient:
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
context: _CallContext,
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None, result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
error: object | None = None, error: PolyGatewayError | str | None = None,
) -> None: ) -> None:
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。""" """逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
if self._emitter is None: if self._emitter is None:
return return
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱), request = self._request_for(
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空 kind, image, session_id, parent_call_id, tenant_id, meta, context
request = ChatRequest(
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
) )
latency_ms = int((self._now() - started) * 1000) latency_ms = int((self._now() - started) * 1000)
response = None response = None
@@ -443,20 +573,19 @@ class OcrClient:
source_name=source.name, source_name=source.name,
usage_source="measured", usage_source="measured",
) )
# 错误带异常类名前缀(metric ocr-call-success 注册口径: 按类名归组) # 错误文本的类名前缀现由出口的显式策略参数承担(设计 §6 I7):
if error is None or isinstance(error, str): # 三处各拼一遍才是下一次漂移的种子,而取消行传的是字符串,不受前缀影响
error_text = error
else:
error_text = f"{type(error).__name__}: {error}"
await self._emitter.emit_attempt( await self._emitter.emit_attempt(
request=request, request=request,
source=source, source=source,
call_id=call_id, call_id=call_id,
latency_ms=latency_ms, latency_ms=latency_ms,
response=response, response=response,
error=error_text, error=error,
# OCR 走 MonkeyOCR 自有端点,没有推理参数可言(理由同 embedding) # OCR 走 MonkeyOCR 自有端点,没有推理参数可言(理由同 embedding)
reasoning_applies=False, reasoning_applies=False,
operation=operation,
class_prefixed_error=True,
) )
@staticmethod @staticmethod
+20 -1
View File
@@ -271,7 +271,7 @@ class TelemetryStatusProvider(Protocol):
@runtime_checkable @runtime_checkable
class TelemetryRecorder(Protocol): class TelemetryRecorder(Protocol):
"""遥测后端;26 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20),唯一调用点是 TelemetryEmitter。 """遥测后端;36 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名 新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning
@@ -286,6 +286,15 @@ class TelemetryRecorder(Protocol):
recorder 只负责落库,不做任何语义判断, `sampling` 列由 recorder 只负责落库,不做任何语义判断, `sampling` 列由
`canonical_sampling_json()` emitter 侧定型是同一先例 `canonical_sampling_json()` emitter 侧定型是同一先例
1.3.5 新增十列同理已在 emitter 侧定型: `operation` **公开方法**四值之一
( `PolyGatewayError.operation` 这个 HTTP 子操作是两个语义);`event_kind` 区分
attempt / cache_hit / terminal_failure 三类行;`attempts` `total_latency_ms`
只在终态行非空;`error_body` 沿用 transport `summarize_body` 的上限,
**不进 `PGW_TELEMETRY_TEXT_CAP` 的覆盖面**
**本签名是装配闸的唯一事实源**: `TelemetryEmitter.__init__` 按它派生参数名做
一次 `signature.bind` 形状校验(设计 §7),改本签名即改闸的判据
""" """
async def record_llm_call( async def record_llm_call(
@@ -317,4 +326,14 @@ class TelemetryRecorder(Protocol):
meta: str, meta: str,
thinking_observation: str, thinking_observation: str,
reasoning_effort: str | None, reasoning_effort: str | None,
scope: str,
operation: str,
logical_call_id: str | None,
event_kind: str,
http_status_code: int | None,
error_type: str | None,
cause_type: str | None,
error_body: str | None,
attempts: int | None,
total_latency_ms: int | None,
) -> None: ... ) -> None: ...
+58 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列" 多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"
**`COLUMNS` INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带 **`COLUMNS` INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)物理表列 = 26 INSERT 字段 + `DEFAULT now()` / `datetime('now')`,库从不显式写它)物理表列 = 36 INSERT 字段 +
`created_at` = 27;列数断言一律按物理列数写,两套口径混用是最易错处 `created_at` = 37;列数断言一律按物理列数写,两套口径混用是最易错处
本模块只依赖标准库: `telemetry/` `backends/``transports/``structured/` 同层且 本模块只依赖标准库: `telemetry/` `backends/``transports/``structured/` 同层且
互不依赖(import-linter 契约执法) 互不依赖(import-linter 契约执法)
@@ -52,7 +52,17 @@ CREATE TABLE IF NOT EXISTS llm_calls (
tenant_id TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
thinking_observation TEXT, thinking_observation TEXT,
reasoning_effort TEXT reasoning_effort TEXT,
scope TEXT,
operation TEXT,
logical_call_id TEXT,
event_kind TEXT,
http_status_code INTEGER,
error_type TEXT,
cause_type TEXT,
error_body TEXT,
attempts INTEGER,
total_latency_ms INTEGER
); );
""" """
@@ -84,7 +94,17 @@ CREATE TABLE IF NOT EXISTS llm_calls (
tenant_id TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb, meta JSONB NOT NULL DEFAULT '{}'::jsonb,
thinking_observation TEXT, thinking_observation TEXT,
reasoning_effort TEXT reasoning_effort TEXT,
scope TEXT,
operation TEXT,
logical_call_id TEXT,
event_kind TEXT,
http_status_code INTEGER,
error_type TEXT,
cause_type TEXT,
error_body TEXT,
attempts INTEGER,
total_latency_ms INTEGER
); );
""" """
@@ -105,6 +125,18 @@ SQLITE_BACKFILL = (
# 同样可空,但这里 NULL 表达的是"调用方没表态"(issue #20): 它与 'none' # 同样可空,但这里 NULL 表达的是"调用方没表态"(issue #20): 它与 'none'
# (明确要求不推理)是两回事,折叠成任一档都等于替上游声称了它没说过的事 # (明确要求不推理)是两回事,折叠成任一档都等于替上游声称了它没说过的事
("reasoning_effort", "TEXT"), ("reasoning_effort", "TEXT"),
# 1.3.5 十列: 全部可空且无默认值——旧行的 NULL 表达的是"补列之前根本没记过
# 这件事",与任何哨兵值都不是一回事,故不回填(设计 §5)
("scope", "TEXT"),
("operation", "TEXT"),
("logical_call_id", "TEXT"),
("event_kind", "TEXT"),
("http_status_code", "INTEGER"),
("error_type", "TEXT"),
("cause_type", "TEXT"),
("error_body", "TEXT"),
("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"),
) )
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给 # PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -120,6 +152,17 @@ _PG_BACKFILL_DECLS = (
# 可空,理由同 SQLITE_BACKFILL 同名项 # 可空,理由同 SQLITE_BACKFILL 同名项
("thinking_observation", "TEXT"), ("thinking_observation", "TEXT"),
("reasoning_effort", "TEXT"), ("reasoning_effort", "TEXT"),
# 1.3.5 十列,列序与 SQLITE_BACKFILL 逐项对齐(两条路径的物理列序不许分叉)
("scope", "TEXT"),
("operation", "TEXT"),
("logical_call_id", "TEXT"),
("event_kind", "TEXT"),
("http_status_code", "INTEGER"),
("error_type", "TEXT"),
("cause_type", "TEXT"),
("error_body", "TEXT"),
("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"),
) )
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。 # 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -158,6 +201,17 @@ COLUMNS = (
"meta", "meta",
"thinking_observation", "thinking_observation",
"reasoning_effort", "reasoning_effort",
# —— 1.3.5 逻辑调用统计与结构化失败诊断(issue #19/#23)——
"scope",
"operation",
"logical_call_id",
"event_kind",
"http_status_code",
"error_type",
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
) )
_COLUMN_SET = frozenset(COLUMNS) _COLUMN_SET = frozenset(COLUMNS)
+1 -1
View File
@@ -143,7 +143,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None: async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 26 字段冻结签名(ports.TelemetryRecorder)。 """写一行遥测;字段集合即 36 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪), `self._insert` 取值按 `self._columns`(manual 档可能已被裁剪), `self._insert`
占位符同序两者必须一起改,分开改就是把值写进错位的列 占位符同序两者必须一起改,分开改就是把值写进错位的列
+22 -5
View File
@@ -154,18 +154,28 @@ def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
def _status_to_error( def _status_to_error(
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str] source: SourceConfig,
status: int,
body_text: str,
headers: Mapping[str, str],
*,
operation: str,
) -> Exception: ) -> Exception:
"""非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。 """非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
摘要只算一次,message `body_text` 共用同一份串: 两份不同长度会让"遥测里 摘要只算一次,message `body_text` 共用同一份串: 两份不同长度会让"遥测里
看到的""下游 catch 到的"对不上,排查时反而多一层困惑。 看到的""下游 catch 到的"对不上,排查时反而多一层困惑。
`operation` **HTTP 子操作**词表(`chat` / `embedding` / `ocr_text` / ...),
调用点显式给定它曾被硬编码成 `"chat"`, `embed()` 的非 200 分支也走它
于是现存所有 embedding HTTP 失败的 `exc.operation` 都是错的(1.3.5 设计 §5 I1)
注意它与遥测新列 `operation`(公开方法四值)**两个语义**,不做自动转换
""" """
summary = summarize_body(body_text) summary = summarize_body(body_text)
ctx: dict[str, Any] = { ctx: dict[str, Any] = {
"source_name": source.name, "source_name": source.name,
"status_code": status, "status_code": status,
"operation": "chat", "operation": operation,
"body_text": summary, "body_text": summary,
} }
if status == 429: if status == 429:
@@ -509,7 +519,10 @@ class OpenAICompatTransport:
except httpx.TransportError as exc: except httpx.TransportError as exc:
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
if resp.status_code != 200: if resp.status_code != 200:
raise _status_to_error(source, resp.status_code, resp.text, resp.headers) # 历史误标修正: 本分支属 `embed()`,与上方 ctx 同为 `"embedding"`
raise _status_to_error(
source, resp.status_code, resp.text, resp.headers, operation="embedding"
)
return _parse_embedding_payload(resp, source, len(texts)) return _parse_embedding_payload(resp, source, len(texts))
async def _complete_stream( async def _complete_stream(
@@ -524,7 +537,9 @@ class OpenAICompatTransport:
async with client.stream("POST", url, json=payload) as resp: async with client.stream("POST", url, json=payload) as resp:
if resp.status_code != 200: if resp.status_code != 200:
body = (await resp.aread()).decode("utf-8", errors="replace") body = (await resp.aread()).decode("utf-8", errors="replace")
raise _status_to_error(source, resp.status_code, body, resp.headers) raise _status_to_error(
source, resp.status_code, body, resp.headers, operation="chat"
)
sink: dict[str, Any] = {} sink: dict[str, Any] = {}
guarded = stream_with_liveness_timeouts( guarded = stream_with_liveness_timeouts(
_iter_sse_deltas(resp.aiter_lines(), sink), _iter_sse_deltas(resp.aiter_lines(), sink),
@@ -622,7 +637,9 @@ class OpenAICompatTransport:
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。""" """非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。"""
resp = await client.post(url, json=payload) resp = await client.post(url, json=payload)
if resp.status_code != 200: if resp.status_code != 200:
raise _status_to_error(source, resp.status_code, resp.text, resp.headers) raise _status_to_error(
source, resp.status_code, resp.text, resp.headers, operation="chat"
)
try: try:
body = resp.json() body = resp.json()
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
+107 -2
View File
@@ -8,11 +8,12 @@ import dataclasses
import json import json
import math import math
import re import re
from collections.abc import Mapping import uuid
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any, Literal
from loguru import logger from loguru import logger
@@ -274,6 +275,91 @@ class ThinkingObservation(StrEnum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"]
"""遥测 `operation` 列的值域: **公开方法**四值,由调用点给定。
`PolyGatewayError.operation`(HTTP 子操作, `download_result`)**两个语义**,
不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)"""
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout")
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
"""一行遥测描述的事件形态;旧行 NULL,不回填。
终态行与 attempt **不是重复事实**(前者描述逻辑终态,后者描述单次尝试),
故禁止按 `error IS NOT NULL` 跨两类直接计失败调用次数(设计 §6/§8)"""
EVENT_KINDS: tuple[EventKind, ...] = ("attempt", "cache_hit", "terminal_failure")
@dataclass(frozen=True)
class CallStats:
"""一次**公开调用**(而非单次尝试)的统计快照(设计 §3)。
四种响应各平铺三字段会立刻漂移,故收敛成单一对象并由包根导出
第三方合成响应的 `None` 表示**未知**,不得伪造 0
"""
logical_call_id: str
"""每次公开调用一个 UUID;重试、结构化重问、embedding 分批共享同一个。
不占用既有 `parent_call_id`(后者是调用方的业务关联,语义不变)"""
attempts: int
"""准入后实际调用 transport 端口的次数;含免预算 429 与端口本地拒绝。
**不是 HTTP 请求条数**: OCR layout POST + ZIP GET 在同一次 transport
调用内, 1 缓存命中与空输入是合法的零尝试"""
total_latency_ms: int
"""从输入校验通过到返回/异常传播前的单调时钟快照。
含缓存 IO退避等待准入等待重问分批与内联记账
"总耗时减最后一次尝试耗时"**不等于**纯等待(含其他本地工作)"""
class _CallContext:
"""私有可变逻辑调用上下文: 只持计数、单调时钟与终态去重位,不做 I/O。
**每调用一个实例**的单任务对象: chat 重试结构化重问embedding 分批
都在同一任务内串行推进,故计数无需锁**严禁提升为 client 实例属性**
那会让同一 client 的并发调用互相串掉计数与逻辑 ID(库铁律"纯 asyncio 中立"
VT `evolve_llm = llm` 教训的同一形态)
"""
__slots__ = ("_attempts", "_now", "_started", "_terminal_claimed", "logical_call_id")
def __init__(self, *, now: Callable[[], float]) -> None:
self.logical_call_id = str(uuid.uuid4())
self._now = now
self._started = now()
self._attempts = 0
self._terminal_claimed = False
def register_attempt(self) -> None:
"""transport 调用**前**登记一次尝试(含免预算 429 与端口本地拒绝)。
登记点在调用前而非成功后: 否则失败与取消的尝试会从计数里消失,
而那正是诊断时最需要看见的那几次
"""
self._attempts += 1
def snapshot(self) -> CallStats:
"""同步冻结当前快照;**绝不 await**,可多次调用。"""
return CallStats(
logical_call_id=self.logical_call_id,
attempts=self._attempts,
total_latency_ms=int((self._now() - self._started) * 1000),
)
def claim_terminal(self) -> bool:
"""首次 `True`、其后 `False`: 保证每逻辑调用至多写一条终态行。"""
if self._terminal_claimed:
return False
self._terminal_claimed = True
return True
@dataclass(frozen=True) @dataclass(frozen=True)
class LLMResponse: class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。""" """一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
@@ -336,6 +422,9 @@ class LLMResponse:
`None` 不是"没推理": 库不表态时也不推定模型自己的默认档"没看见"不许说成 `None` 不是"没推理": 库不表态时也不推定模型自己的默认档"没看见"不许说成
"发生了"( `thinking_observation` `UNKNOWN` 一脉)""" "发生了"( `thinking_observation` `UNKNOWN` 一脉)"""
call_stats: CallStats | None = None
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class ChatRequest: class ChatRequest:
@@ -382,6 +471,16 @@ class ChatRequest:
而档位要经能力表校验要进缓存 key要落遥测混进直通层等于放弃这三样, 而档位要经能力表校验要进缓存 key要落遥测混进直通层等于放弃这三样,
正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。""" 正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。"""
# —— 库内部逻辑调用上下文(1.3.5;追加在末尾,不扰动既有字段的位置构造)——
call_context: _CallContext | None = field(default=None, compare=False, repr=False)
"""库内部逻辑调用上下文;`None` = 库内现场构造的请求,遥测 `logical_call_id` 落 NULL。
`compare=False, repr=False` 不是洁癖: `compare` 会让两个内容相同的请求因
"不是同一次调用"而不相等, `repr` 则把库内部件泄进调用方的日志
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
重试/重问/分批才能共享同一个逻辑 ID 与计数"""
@dataclass(frozen=True) @dataclass(frozen=True)
class Usage: class Usage:
@@ -722,6 +821,8 @@ class OcrTextResult:
latency_ms: int latency_ms: int
call_id: str call_id: str
raw: dict[str, Any] raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -739,6 +840,8 @@ class OcrLayoutResult:
latency_ms: int latency_ms: int
call_id: str call_id: str
raw: dict[str, Any] raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -783,3 +886,5 @@ class EmbeddingResponse:
call_id: str call_id: str
source_name: str source_name: str
cost: float | None = None cost: float | None = None
call_stats: CallStats | None = None
"""本次逻辑调用(含全部分批)的统计快照(1.3.5);`None` = 未知。"""
+363 -26
View File
@@ -28,7 +28,7 @@ import pytest
from dotenv import dotenv_values from dotenv import dotenv_values
from polygateway.telemetry.postgres import PostgresRecorder from polygateway.telemetry.postgres import PostgresRecorder
from polygateway.telemetry.schema import COLUMNS, telemetry_schema_sql from polygateway.telemetry.schema import COLUMNS, insert_sql, telemetry_schema_sql
_EXPECTED_COLUMNS = [ _EXPECTED_COLUMNS = [
"call_id", "call_id",
@@ -58,8 +58,31 @@ _EXPECTED_COLUMNS = [
"meta", "meta",
"thinking_observation", "thinking_observation",
"reasoning_effort", "reasoning_effort",
# —— 1.3.5 逻辑调用统计与结构化失败诊断的十列(issue #19/#23)——
"scope",
"operation",
"logical_call_id",
"event_kind",
"http_status_code",
"error_type",
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
] ]
# 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片
# 而非 `[:-10]`: 后者会随下一次补列静默漂移到另一张表上,而漂移的表现是
# "旧表补列"用例悄悄改测了别的形态。
_PRE_135_COLUMNS = _EXPECTED_COLUMNS[:27]
# 1.3.4 版本的 recorder 实际写入的列(物理列去掉库从不显式写的 created_at)
_PRE_135_WRITTEN_COLUMNS = [c for c in _PRE_135_COLUMNS if c != "created_at"]
# 1.3.5 新增的十列,按 `COLUMNS`(即 INSERT 字段序)排列: manual 档告警逐字比对与
# "旧行新列为 NULL"两处共用同一份,免得两边各抄一份后各自漂移。
_CALL_OBSERVABILITY_COLUMNS = [c for c in COLUMNS if c not in _PRE_135_WRITTEN_COLUMNS]
def _dsn() -> str | None: def _dsn() -> str | None:
"""读 `.env` 的 DSN 并剥掉 SQLAlchemy 风格的 `+driver` 后缀;未配置返回 None。""" """读 `.env` 的 DSN 并剥掉 SQLAlchemy 风格的 `+driver` 后缀;未配置返回 None。"""
@@ -92,13 +115,13 @@ async def template_admin_dsn() -> str:
return value return value
async def _record_minimal( def _minimal_fields(call_id: str | None = None, **overrides) -> dict[str, object]:
recorder: PostgresRecorder, call_id: str | None = None, **overrides """一行最小遥测的**完整字段字典**(不写库),供 recorder 写入与旧版本进程模拟共用。
) -> dict[str, object]:
"""记一行最小遥测,并**返回实际提交的字段**供调用方逐列比对回读结果。
返回值不是顺手: 逐列断言若在测试里另抄一份期望值,抄错的那一列会以 独立出来不是顺手: "新旧进程混写"那条用例要以 1.3.4 的列集直接发 INSERT,
"库写错列位"的形态误报,而漏抄的列则悄悄不被验证 若它另抄一份取值,抄错的那一列会以"库写错列位"的形态误报
调用方随后逐列比对回读结果,故返回的就是实际提交的那一份
""" """
fields: dict[str, object] = { fields: dict[str, object] = {
"call_id": call_id if call_id is not None else "c1", "call_id": call_id if call_id is not None else "c1",
@@ -130,8 +153,30 @@ async def _record_minimal(
"thinking_observation": "unknown", "thinking_observation": "unknown",
# 同理: `Effort` 归一成裸 str,不表态则是 None(与 'low' 必须分得开) # 同理: `Effort` 归一成裸 str,不表态则是 None(与 'low' 必须分得开)
"reasoning_effort": None, "reasoning_effort": None,
# —— 1.3.5 十列: 默认形态即"一次普通尝试行"(与单测 `_record_minimal` 同款)——
"scope": "LLM",
"operation": "chat",
# 库内现场构造的请求没有上下文 → NULL,不造 ID
"logical_call_id": None,
"event_kind": "attempt",
# 诊断四列只在失败的 attempt 行上非空;成功行不统一填 200
"http_status_code": None,
"error_type": None,
"cause_type": None,
"error_body": None,
# 逻辑快照两列只属终态行
"attempts": None,
"total_latency_ms": None,
} }
fields.update(overrides) fields.update(overrides)
return fields
async def _record_minimal(
recorder: PostgresRecorder, call_id: str | None = None, **overrides
) -> dict[str, object]:
"""记一行最小遥测,并**返回实际提交的字段**供调用方逐列比对回读结果。"""
fields = _minimal_fields(call_id, **overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
return fields return fields
@@ -167,6 +212,17 @@ async def _fetch(dsn: str, sql: str, *args):
await conn.close() await conn.close()
async def _execute_args(dsn: str, sql: str, *args) -> None:
"""带参数执行单条语句(扩展协议);用于模拟旧版本进程按旧列集发出的 INSERT。"""
import asyncpg
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(sql, *args)
finally:
await conn.close()
async def _execute_script(dsn: str, sql: str) -> None: async def _execute_script(dsn: str, sql: str) -> None:
"""整段执行多语句脚本(不带参数,走简单查询协议)——模拟下游把脚本贴进 psql。""" """整段执行多语句脚本(不带参数,走简单查询协议)——模拟下游把脚本贴进 psql。"""
import asyncpg import asyncpg
@@ -588,14 +644,24 @@ _PRE_TENANT_INSERT = (
) )
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的 # `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的列
# 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。 # 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。
# 去掉后的顺序与 DDL 逐字一致(这列在 DDL 里本就排在末尾)。 # 去掉后的顺序与 DDL 逐字一致(这列在 DDL 里本就排在末尾)。
_PRE_TENANT_COLUMNS = [ _PRE_TENANT_ABSENT = (
c "tenant_id",
for c in _EXPECTED_COLUMNS "meta",
if c not in ("tenant_id", "meta", "thinking_observation", "reasoning_effort") "thinking_observation",
] "reasoning_effort",
*_CALL_OBSERVABILITY_COLUMNS,
)
_PRE_TENANT_COLUMNS = [c for c in _EXPECTED_COLUMNS if c not in _PRE_TENANT_ABSENT]
# 这张表缺的 14 个维度,按 `COLUMNS`(即告警的排列序)列出: manual 档告警逐字比对用。
# 逐字而非前缀断言,是为了让"将来漏进告警的新列"当场红(设计 §4.2 的纪律)。
_PRE_TENANT_MISSING_NOTICE = (
f"以下维度不会被记录: {', '.join(c for c in COLUMNS if c in _PRE_TENANT_ABSENT)}"
)
# 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个 # 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个
_PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"] _PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"]
@@ -703,7 +769,7 @@ class TestCallerDimensionsAcceptance:
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema, schema,
) )
# 22 → 26 个 recorder 字段(加 created_at 共 27 个物理列),且新列追加在末尾 # 22 → 36 个 recorder 字段(加 created_at 共 37 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch( rows = await _fetch(
schema_dsn, schema_dsn,
@@ -908,11 +974,8 @@ class TestManualSchemaModeAcceptance:
assert [m for m in captured_warnings if "补列失败" in m] == [] assert [m for m in captured_warnings if "补列失败" in m] == []
notices = [m for m in captured_warnings if "auto_migrate=False" in m] notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏 assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏
# 逐字钉住个维度: 前缀断言会让将来漏进告警的新列照样绿 # 逐字钉住缺的每一个维度: 前缀断言会让将来漏进告警的新列照样绿
assert ( assert _PRE_TENANT_MISSING_NOTICE in notices[0]
"以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。"
in notices[0]
)
finally: finally:
await recorder.aclose() await recorder.aclose()
@@ -938,11 +1001,8 @@ class TestManualSchemaModeAcceptance:
assert recorder.telemetry_status.degraded is False assert recorder.telemetry_status.degraded is False
notices = [m for m in captured_warnings if "auto_migrate=False" in m] notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次,第二行不再重复 assert len(notices) == 1 # 准备期一次,第二行不再重复
# 逐字钉住个维度: 前缀断言会让将来漏进告警的新列照样绿 # 逐字钉住缺的每一个维度: 前缀断言会让将来漏进告警的新列照样绿
assert ( assert _PRE_TENANT_MISSING_NOTICE in notices[0]
"以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。"
in notices[0]
)
# 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名 # 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名
assert ( assert (
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0] "ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0]
@@ -974,6 +1034,283 @@ class TestManualSchemaModeAcceptance:
await recorder.aclose() await recorder.aclose()
# 1.3.5 之前(1.3.4 发布形态)的表: 26 个 recorder 字段 + created_at = 27 个物理列,
# 没有本版新增的任何一列。裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行。
_PRE_135_DDL = """
CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms DOUBLE PRECISION,
max_inter_token_ms DOUBLE PRECISION,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
thinking_observation TEXT,
reasoning_effort TEXT
)
"""
# 一行 1.3.5 之前写下的历史数据(只列 NOT NULL 列,与当年 recorder 的写入等价)。
# 工厂的 `extra` 逐条裸执行、不接受查询参数,故 call_id 内联成字面量。
_PRE_135_INSERT = (
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
"VALUES ('pre135', 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
)
# 这张表缺的正是本版十列;manual 档告警要逐字比对的那句。
_PRE_135_MISSING_NOTICE = f"以下维度不会被记录: {', '.join(_CALL_OBSERVABILITY_COLUMNS)}"
@pytest.fixture
async def pre_135_schema(pg_sandbox) -> tuple[str, str]:
"""一次性沙箱里造一张 **1.3.4 形态的 27 列表**,并留一行本版之前的历史数据。
共享表 `llm_calls` 一个字节都不碰: 本机那张表升级一次就再也回不到旧形态,
指望它还是旧形态的测试第二次跑就会空转( `pre_tenant_schema` 同款理由)
"""
sandbox = await pg_sandbox(ddl=_PRE_135_DDL, extra=(_PRE_135_INSERT,))
return sandbox.dsn, sandbox.schema
class TestCallObservabilityColumnsAcceptance:
"""1.3.5(issue #19/#23)的 PG 存储兼容验收: auto 追加 / manual 裁剪 / 旧行 NULL / 混写。
单元层在 SQLite 上断的是同一族语义,这里断的是**真实 PG 上确实如此**
两端的 DDL补列语句与列序是两份文本(`SQLITE_BACKFILL` `_PG_BACKFILL_DECLS`),
只有真表能证明它们没有分叉
"""
async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema):
"""27 列旧表 auto 补齐到 37 列,新行两类取值读得回,**历史行十列一律 NULL**。
旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过
这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行
会被就地改写成"记过且值为 X",归因查询从此分不清真实缺口故这条断言的
方向是 NULL,不是空串也不是 0
"""
schema_dsn, schema = pre_135_schema
recorder = _recorder(schema_dsn, auto_migrate=True)
try:
# attempt 行: 诊断四列非空、逻辑快照两列 NULL
await _record_minimal(
recorder,
call_id="att",
scope="LLM",
operation="chat",
logical_call_id="lcid-1",
event_kind="attempt",
http_status_code=503,
error_type="TransientError",
cause_type="ReadTimeout",
error_body="upstream said 503",
)
# 终态行: 逻辑快照两列非空、诊断三列 NULL(不搬最后一次 attempt 的现场)
await _record_minimal(
recorder,
call_id="term",
scope="LLM",
operation="chat",
logical_call_id="lcid-1",
event_kind="terminal_failure",
error="AllSourcesExhausted: 全部源已耗尽",
error_type="AllSourcesExhausted",
attempts=3,
total_latency_ms=4200,
)
cols = await _fetch(
schema_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 26 → 36 个 recorder 字段(加 created_at 共 37 个物理列),新列追加在末尾:
# 列序与新建库一致才不会让两条升级路径分叉
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
names = ", ".join(_CALL_OBSERVABILITY_COLUMNS)
rows = await _fetch(
schema_dsn,
f"SELECT call_id, {names} FROM llm_calls "
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
["att", "pre135", "term"],
)
by_id = {r["call_id"]: r for r in rows}
assert dict(by_id["att"]) == {
"call_id": "att",
"scope": "LLM",
"operation": "chat",
"logical_call_id": "lcid-1",
"event_kind": "attempt",
"http_status_code": 503,
"error_type": "TransientError",
"cause_type": "ReadTimeout",
"error_body": "upstream said 503",
"attempts": None,
"total_latency_ms": None,
}
assert dict(by_id["term"]) == {
"call_id": "term",
"scope": "LLM",
"operation": "chat",
"logical_call_id": "lcid-1",
"event_kind": "terminal_failure",
"http_status_code": None,
"error_type": "AllSourcesExhausted",
"cause_type": None,
"error_body": None,
"attempts": 3,
"total_latency_ms": 4200,
}
# 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红)
assert dict(by_id["pre135"]) == {
"call_id": "pre135",
**dict.fromkeys(_CALL_OBSERVABILITY_COLUMNS),
}
finally:
await recorder.aclose()
async def test_manual_trims_the_insert_on_a_pre_135_table(
self, pre_135_schema, captured_warnings
):
"""27 列旧表 + manual: 一条 DDL 都不发,写入按现有列裁剪后照样落库。
与上一条恰成对照: 同一张表同一份负载,只有 `auto_migrate` 不同,列数就必须是
27 37 之别裁剪是关掉 ALTER 的前提不裁剪的话每行 INSERT 都撞缺列
(SQLSTATE 42703)而被整行丢弃,那是把自动补列换成遥测静默全失
"""
schema_dsn, schema = pre_135_schema
recorder = _recorder(schema_dsn, auto_migrate=False)
try:
recorded = await _record_minimal(
recorder, call_id="man135", scope="LLM", logical_call_id="lcid-x"
)
cols = await _fetch(
schema_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 表结构逐字不动: 既没多出十列,也没被顺手改了列序
assert [r["column_name"] for r in cols] == _PRE_135_COLUMNS
names = ", ".join(_PRE_135_WRITTEN_COLUMNS)
rows = await _fetch(
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", "man135"
)
assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收
# 其余 26 列逐列与提交值相等: 少写十列最容易引发的错是剩下的值整体错位
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_135_WRITTEN_COLUMNS}
assert [m for m in captured_warnings if "写入失败" in m] == []
assert [m for m in captured_warnings if "补列失败" in m] == []
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 _PRE_135_MISSING_NOTICE in notices[0]
# 提示里的 SQL 必须可直接粘贴执行(首列与末列各验一条,含类型)
assert "ALTER TABLE llm_calls ADD COLUMN scope TEXT;" in notices[0]
assert "ALTER TABLE llm_calls ADD COLUMN total_latency_ms INTEGER;" in notices[0]
finally:
await recorder.aclose()
async def test_old_and_new_writers_share_one_table(self, fresh_schema, captured_warnings):
"""滚动升级期的混写: 已补列的表上,旧版本进程按 26 列写、新版本按 36 列写。
这是升级窗口里必然出现的形态(先升一个 worker,其余仍是 1.3.4),而它的失败
方式是静默的: 若新列带了 NOT NULL 或旧列集的 INSERT 被拒, worker 的遥测
会整段消失而只留逐行 warning故这里既断三行都在也断没有写入失败 warning
旧进程用 `insert_sql("postgres", 旧列集)` 而不是另抄一条 SQL: 1.3.4
recorder 发出的正是同一函数按当年列集拼出的语句,另抄一份只会各自漂移
"""
fresh_dsn, schema = fresh_schema
recorder = _recorder(fresh_dsn, auto_migrate=True)
try:
# 新版本进程: 建表(37 列)并写一条带完整新列的终态行
await _record_minimal(
recorder,
call_id="new-1",
scope="LLM",
operation="chat",
logical_call_id="lcid-mix",
event_kind="terminal_failure",
error_type="AllSourcesExhausted",
attempts=2,
total_latency_ms=1500,
)
# 旧版本进程: 同一张表,按 1.3.4 的 26 列集写入
legacy_fields = _minimal_fields("old-1", response="from a 1.3.4 worker")
await _execute_args(
fresh_dsn,
insert_sql("postgres", _PRE_135_WRITTEN_COLUMNS),
*(legacy_fields[c] for c in _PRE_135_WRITTEN_COLUMNS),
)
# 新版本进程继续写: 旧进程的写入不得污染后续(列集是每进程各自探测的)
await _record_minimal(
recorder, call_id="new-2", scope="LLM", operation="embed", event_kind="attempt"
)
cols = await _fetch(
fresh_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS # 旧进程不改表结构
rows = await _fetch(
fresh_dsn,
"SELECT call_id, response, scope, operation, event_kind, attempts, "
"total_latency_ms FROM llm_calls ORDER BY call_id",
)
assert [r["call_id"] for r in rows] == ["new-1", "new-2", "old-1"]
by_id = {r["call_id"]: r for r in rows}
assert by_id["old-1"]["response"] == "from a 1.3.4 worker"
# 旧进程写下的行,新列一律 NULL——它没写,也不该被谁替它填
assert (by_id["old-1"]["scope"], by_id["old-1"]["event_kind"]) == (None, None)
assert (by_id["old-1"]["attempts"], by_id["old-1"]["total_latency_ms"]) == (None, None)
assert (by_id["new-1"]["attempts"], by_id["new-1"]["total_latency_ms"]) == (2, 1500)
assert by_id["new-2"]["operation"] == "embed"
assert [m for m in captured_warnings if "写入失败" in m] == []
# 下游可见变化的机械化依据: 新口径"计失败调用"按 event_kind 过滤,
# 混写期旧行(event_kind 为 NULL)既不会被误计成失败,也不会被误计成成功
terminal = await _fetch(
fresh_dsn,
"SELECT count(*) AS n FROM llm_calls WHERE event_kind = 'terminal_failure'",
)
assert terminal[0]["n"] == 1
unclassified = await _fetch(
fresh_dsn, "SELECT count(*) AS n FROM llm_calls WHERE event_kind IS NULL"
)
assert unclassified[0]["n"] == 1
finally:
await recorder.aclose()
_PHYSICAL_COLUMNS_SQL = ( _PHYSICAL_COLUMNS_SQL = (
"SELECT column_name FROM information_schema.columns " "SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position" "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position"
@@ -1000,7 +1337,7 @@ class TestPublishedSchemaScript:
await _execute_script(fresh_dsn, script) await _execute_script(fresh_dsn, script)
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)] actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
# 物理列 = 26 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份 # 物理列 = 36 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
assert set(actual) == set(COLUMNS) | {"created_at"} assert set(actual) == set(COLUMNS) | {"created_at"}
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉 # 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
assert actual == _EXPECTED_COLUMNS assert actual == _EXPECTED_COLUMNS
+74 -1
View File
@@ -570,7 +570,7 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
before = build_cache_key("m", messages, "proj", None) before = build_cache_key("m", messages, "proj", None)
rec = self._Rows() rec = self._Rows()
await TelemetryEmitter(rec, text_cap=8).emit_attempt( await TelemetryEmitter(rec, text_cap=8, scope="LLM").emit_attempt(
request=ChatRequest(messages=messages), request=ChatRequest(messages=messages),
source=SourceConfig( source=SourceConfig(
name="s1", name="s1",
@@ -585,6 +585,7 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True, reasoning_applies=True,
operation="chat",
) )
# 截断确实发生了(否则本用例恒真) # 截断确实发生了(否则本用例恒真)
logged = json.loads(rec.rows[0]["messages"]) logged = json.loads(rec.rows[0]["messages"])
@@ -818,3 +819,75 @@ class TestExplicitCacheMigration:
finally: finally:
for transport in transports: for transport in transports:
await transport.aclose() await transport.aclose()
class TestCallStatsNotPoisoned:
"""缓存不得回放历史统计(1.3.5 设计 §3)。
统计描述**本次**调用;把上次那条存进去再放出来,等于对调用方谎称这次
重试了 N 耗了 M 毫秒
"""
async def test_serialized_payload_carries_no_call_stats_key(self):
from polygateway.types import CallStats
backend = InMemoryCache()
mw = _mw(backend)
stats = CallStats(logical_call_id="lc-1", attempts=3, total_latency_ms=900)
terminal = _Terminal(_resp(call_stats=stats))
await mw(ChatRequest(messages=_MSGS), terminal)
key = build_cache_key("m", _MSGS, "proj", None)
stored = json.loads(await backend.get(key))
assert "call_stats" not in stored # asdict 会把它摊成 dict,必须显式剔除
async def test_historic_dict_never_impersonates_call_stats(self):
"""旧条目里的 `call_stats` dict 会被 `_RESPONSE_FIELDS` 放行,必须显式覆盖。
不覆盖就会有一个 dict 冒充 `CallStats` 从公共 API 漏给调用方,
`resp.call_stats.attempts` 当场 `AttributeError`
"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
poisoned = {
"content": "legacy",
"thinking": "",
"model": "m",
"provider": "p",
"prompt_tokens": 1,
"completion_tokens": 2,
"latency_ms": 30,
"ttft_ms": 5.0,
"max_inter_token_ms": 2.0,
"cache_hit": False,
"call_id": "orig",
"source_name": "s1",
"usage_source": "measured",
"call_stats": {
"logical_call_id": "stale-lc",
"attempts": 7,
"total_latency_ms": 9999,
},
}
await backend.set(key, json.dumps(poisoned), ttl_s=100)
terminal = _Terminal(_resp())
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.content == "legacy" and terminal.calls == 0 # 真的走了缓存
assert hit.call_stats is None # dict 不得冒充 CallStats
async def test_cache_key_is_unchanged_by_the_new_field(self):
"""新增内部字段不得扰动 key 公式,否则存量缓存全量冷启动(黄金值)。"""
from polygateway.types import _CallContext
class _Clock:
def __call__(self):
return 1000.0
ctx = _CallContext(now=_Clock())
bare = build_cache_key("m", _MSGS, "proj", None)
assert bare == build_cache_key("m", _MSGS, "proj", None)
# 带上下文的请求与不带的请求必须落在同一个 key 上
with_ctx = ChatRequest(messages=_MSGS, call_context=ctx)
without = ChatRequest(messages=_MSGS)
assert with_ctx.cache_namespace == without.cache_namespace
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
+356 -1
View File
@@ -13,6 +13,7 @@ from polygateway import (
GatewayClient, GatewayClient,
GatewaySettings, GatewaySettings,
RequestRejectedError, RequestRejectedError,
ResultInvalidError,
gather_bounded, gather_bounded,
) )
from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.breaker import InMemoryGate
@@ -25,6 +26,7 @@ from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
ChatRequest,
Effort, Effort,
GlobalLimits, GlobalLimits,
RetryPolicy, RetryPolicy,
@@ -846,11 +848,19 @@ _CACHE_ENV = dict(
class _Closable: class _Closable:
"""记 close 次数的假组件;所有权纪律的唯一观测点。""" """记 close 次数的假组件;所有权纪律的唯一观测点。
`record_llm_call(**fields)` 是因为它也被当作注入的 telemetry recorder :
1.3.5 的装配闸在构造期就会拒掉不满足 `TelemetryRecorder` 的对象
(否则下游升级后 100% 丢遥测而调用照常成功)`**fields` 形态天然兼容
"""
def __init__(self): def __init__(self):
self.closed = 0 self.closed = 0
async def record_llm_call(self, **fields):
pass
async def aclose(self): async def aclose(self):
self.closed += 1 self.closed += 1
@@ -861,6 +871,9 @@ class _SyncClosable:
def __init__(self): def __init__(self):
self.closed = 0 self.closed = 0
async def record_llm_call(self, **fields):
pass
def close(self): def close(self):
self.closed += 1 self.closed += 1
@@ -1362,3 +1375,345 @@ async def test_synthetic_runtime_protocol_and_legacy_call_signatures():
assert client._transport._clients == {} assert client._transport._clients == {}
finally: finally:
await client.aclose() await client.aclose()
class _StatsClock:
"""确定性单调钟;测试主动推进以断言"哪些区段计入了总耗时""""
def __init__(self, start=1000.0):
self.t = start
def __call__(self):
return self.t
def advance(self, seconds):
self.t += seconds
class _TickingCache:
"""假缓存后端: 每次 IO 推进注入钟。
不推进时钟的替身会让"缓存 IO 计入总耗时"的断言退化成恒等于 0 的空转绿
(计划 §T1 替身构造要求)
"""
def __init__(self, clock, tick=0.25):
self._clock = clock
self._tick = tick
self._data = {}
async def get(self, key):
self._clock.advance(self._tick)
return self._data.get(key)
async def set(self, key, value, ttl_s):
self._clock.advance(self._tick)
self._data[key] = value
class _TickingRecorder:
"""假 recorder: 写入时推进注入钟,用于断言内联遥测收尾计入总耗时。"""
def __init__(self, clock, tick=0.5):
self._clock = clock
self._tick = tick
self.rows = []
async def record_llm_call(self, **fields):
self._clock.advance(self._tick)
self.rows.append(fields)
class TestLogicalCallStats:
"""一次公开 chat 调用的统计(1.3.5 设计 §3)。"""
_MSG = [{"role": "user", "content": "hi"}]
async def test_success_reports_one_attempt(self):
async with _client() as client:
resp = await client.chat(self._MSG)
assert resp.call_stats is not None
assert resp.call_stats.attempts == 1
assert resp.call_stats.logical_call_id
async def test_concurrent_calls_do_not_share_counters_or_ids(self):
"""同一 client 并发两路必须各自计数与各自 ID(库铁律「纯 asyncio 中立」)。
上下文若被提升成 client 实例属性,这条就会红那正是 VT
`evolve_llm = llm` 教训的同一形态
"""
async with _client() as client:
a, b = await asyncio.gather(client.chat(self._MSG), client.chat(self._MSG))
assert a.call_stats.logical_call_id != b.call_stats.logical_call_id
assert a.call_stats.attempts == b.call_stats.attempts == 1
async def test_cache_hit_is_zero_attempts_with_a_fresh_logical_id(self):
"""命中不产生网关调用 → 0 尝试;且是**新**逻辑调用,不回放历史统计。"""
clock = _StatsClock()
cache = _TickingCache(clock)
async with _client(
cache=cache, cache_namespace="proj", cache_ttl_s=600, now=clock
) as client:
first = await client.chat(self._MSG)
second = await client.chat(self._MSG)
assert first.cache_hit is False and first.call_stats.attempts == 1
assert second.cache_hit is True
assert second.call_stats.attempts == 0
assert second.call_stats.logical_call_id != first.call_stats.logical_call_id
async def test_cache_io_counts_into_total_latency(self):
"""缓存读写是本次调用真实花掉的时间,必须进总耗时(设计 §3)。"""
clock = _StatsClock()
cache = _TickingCache(clock, tick=0.25)
async with _client(
cache=cache, cache_namespace="proj", cache_ttl_s=600, now=clock
) as client:
hit = (await client.chat(self._MSG), await client.chat(self._MSG))[1]
# 命中路径只有一次 get(0.25s),无网关调用
assert hit.call_stats.attempts == 0
assert hit.call_stats.total_latency_ms == 250
async def test_inline_telemetry_teardown_counts_into_total_latency(self):
"""成功响应的快照含返回前已完成的内联遥测耗时(设计 §6)。"""
clock = _StatsClock()
recorder = _TickingRecorder(clock, tick=0.5)
async with _client(telemetry=recorder, now=clock) as client:
resp = await client.chat(self._MSG)
assert recorder.rows # 确实写了行,否则本断言空转
assert resp.call_stats.total_latency_ms == 500
async def test_milliseconds_not_seconds(self):
"""毫秒/秒不混用: 1.5s 必须是 1500 而不是 1 或 1.5。"""
clock = _StatsClock()
recorder = _TickingRecorder(clock, tick=1.5)
async with _client(telemetry=recorder, now=clock) as client:
resp = await client.chat(self._MSG)
assert resp.call_stats.total_latency_ms == 1500
async def test_stats_work_without_any_telemetry(self):
"""统计生效与否**不由 telemetry 是否启用决定**(设计 §3.5)。"""
async with _client(telemetry=None) as client:
resp = await client.chat(self._MSG)
assert resp.call_stats is not None and resp.call_stats.attempts == 1
async def test_failure_exception_carries_no_stats_attribute(self):
"""本版**不向异常对象附加统计**(设计 §3.1): 第三方可能复用同一异常实例。"""
def reject(request):
return httpx.Response(400, json={"error": {"message": "bad"}})
async with _client(handler=reject) as client:
with pytest.raises(RequestRejectedError) as exc:
await client.chat(self._MSG)
assert hasattr(exc.value, "call_stats") is False
async def test_input_validation_stays_outside_the_stats_boundary(self):
"""校验异常保持原行为,发生在统计边界之外(设计 §3)。"""
async with _client() as client:
with pytest.raises(ValueError, match="meta"):
await client.chat(self._MSG, meta={"BAD-KEY": 1})
class TestChatTerminalFailureRows:
"""chat 链路的**真实**终态行(1.3.5 设计 §6;补漏而非改口径)。
这些路径改前一条失败行都没有(结构化耗尽)或只有尝试行,
"这次调用到底失败了几次"因此 SQL 答不出来
"""
_MSG = [{"role": "user", "content": "hi"}]
def _rows(self, recorder, kind):
return [r for r in recorder.rows if r["event_kind"] == kind]
async def test_structured_exhaustion_writes_the_only_failure_row(self):
"""结构化耗尽发生在 transport 成功之后: attempt 行全是成功行,终态是唯一记录。"""
from pydantic import BaseModel
class Answer(BaseModel):
answer: int
recorder = _MemoryRecorder()
async with _client(
handler=lambda request: _sse("not json at all"),
telemetry=recorder,
structured_max_retries=1,
) as client:
with pytest.raises(ResultInvalidError):
await client.chat(self._MSG, structured=Answer)
attempts = self._rows(recorder, "attempt")
terminals = self._rows(recorder, "terminal_failure")
assert [a["error"] for a in attempts] == [None, None] # 两次尝试都成功
assert len(terminals) == 1
row = terminals[0]
assert row["error_type"] == "ResultInvalidError"
# C2: 有界结构化说明并入 error,且不含 raw_text(正文预算已由 attempt 行承担)
assert "validation=" in row["error"] or "repair=" in row["error"]
assert len(row["error"]) < 1200
async def test_retry_exhaustion_writes_exactly_one_terminal_row(self):
"""重试耗尽: 逐次 attempt 行之外只能有**一条**终态行。
终态行的 `attempts` 是整次逻辑调用的真实尝试数这正是改前 SQL
答不出的"这次调用到底重试了几次"
"""
recorder = _MemoryRecorder()
async with _client(
handler=lambda request: httpx.Response(503),
telemetry=recorder,
retry=RetryPolicy(3, 0.001, 0.01),
) as client:
with pytest.raises(AllSourcesExhausted):
await client.chat(self._MSG)
attempts = self._rows(recorder, "attempt")
terminals = self._rows(recorder, "terminal_failure")
assert len(attempts) == 3
assert len(terminals) == 1
row = terminals[0]
assert row["error_type"] == "AllSourcesExhausted"
assert row["attempts"] == 3 # 整次逻辑调用的尝试数
assert row["scope"] == "llm" and row["operation"] == "chat"
# 终态行的 latency_ms 与 total_latency_ms 同取一份冻结快照
assert row["latency_ms"] == row["total_latency_ms"]
async def test_request_rejected_now_has_both_an_attempt_and_a_terminal_row(self):
"""已批准的下游可见变化: 400 密集负载的错误行翻倍,失败计数只能取终态。"""
recorder = _MemoryRecorder()
async with _client(
handler=lambda request: httpx.Response(400, json={"error": {"message": "bad"}}),
telemetry=recorder,
) as client:
with pytest.raises(RequestRejectedError):
await client.chat(self._MSG)
attempts = self._rows(recorder, "attempt")
terminals = self._rows(recorder, "terminal_failure")
assert len(attempts) == 1 and attempts[0]["http_status_code"] == 400
assert len(terminals) == 1
# C1: 终态不搬运最后一次 attempt 的状态码与正文
assert terminals[0]["http_status_code"] is None
assert terminals[0]["error_body"] is None
# 两类行共享同一 logical_call_id,归因查询才连得起来
assert attempts[0]["logical_call_id"] == terminals[0]["logical_call_id"]
async def test_non_domain_exception_writes_no_terminal_row(self):
"""编程错原样传播,本版**不承诺**任何统计或终态行,也不偷偷改分类。"""
recorder = _MemoryRecorder()
async def boom(request):
raise KeyError("programming error")
async with _client(telemetry=recorder) as client:
client._handler = boom
with pytest.raises(KeyError):
await client.chat(self._MSG)
assert self._rows(recorder, "terminal_failure") == []
async def test_cancellation_writes_at_most_one_terminal_row(self):
"""取消尽力写一条(允许 0),且 `CancelledError` 类型与语义不变。"""
recorder = _MemoryRecorder()
async def hang(request):
raise asyncio.CancelledError
async with _client(telemetry=recorder) as client:
client._handler = hang
with pytest.raises(asyncio.CancelledError):
await client.chat(self._MSG)
terminals = self._rows(recorder, "terminal_failure")
assert len(terminals) == 1
assert terminals[0]["error"] == "cancelled"
# 字符串不解析猜诊断
assert terminals[0]["error_type"] is None
async def test_terminal_row_is_written_once_per_logical_call(self):
"""`claim_terminal` 去重: 同一次调用即便出口被多次触达也只有一条。"""
from polygateway.middleware.telemetry import emit_terminal_once
from polygateway.types import _CallContext
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
context = _CallContext(now=client._now)
request = ChatRequest(messages=self._MSG, call_context=context)
for _ in range(3):
await emit_terminal_once(
client._emitter,
request=request,
context=context,
error=AllSourcesExhausted(
scope="llm", reason="retry_exhausted", retry_after_s=1.0
),
operation="chat",
)
assert len(self._rows(recorder, "terminal_failure")) == 1
@pytest.fixture
def captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。
名字避开裸 `warnings`: 那会遮蔽标准库模块名,本文件将来任何一次
`import warnings` 都会与它静默互相顶掉,而报错点离真因很远
"""
from loguru import logger
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
yield messages
logger.remove(sink_id)
class TestTerminalEmitDegradation:
"""终态出口的降级方向: 诊断字段的**提取**同样在降级范围内(铁律"遥测写失败降级不冒泡")。
改前 `_record` except 只包住写入本身,而错误诊断列在它之外求值
经公共扩展点(自实现 `StructuredOutputStrategy`/transport)构造出的
`ResultInvalidError(validation_errors=( str,))` 会让提取期抛 `TypeError`
顶替调用方本该收到的领域异常,错误四分类当场被击穿
"""
_MSG = [{"role": "user", "content": "hi"}]
def _rows(self, recorder, kind):
return [r for r in recorder.rows if r["event_kind"] == kind]
async def test_broken_validation_errors_keep_the_domain_error(self, captured_warnings):
"""自实现策略给出非 str 的 `validation_errors`: 领域异常必须原样上抛。"""
class _BadStrategy:
"""公共端口 `StructuredOutputStrategy` 的下游实现(库外没有类型执法)。"""
def request_overlay(self, schema):
return {}
def parse(self, text):
raise ResultInvalidError("模型返回不可解析", validation_errors=(object(),))
recorder = _MemoryRecorder()
async with _client(telemetry=recorder, structured_strategy=_BadStrategy()) as client:
with pytest.raises(ResultInvalidError): # 不是 TypeError
await client.chat(self._MSG, structured="json")
# 降级有声: 静默吞掉等于遥测缺口无人知道
assert [m for m in captured_warnings if "终态遥测记录失败" in m]
# 尝试行不受影响;终态行按 best effort 允许 0 条,但绝不能重复
assert len(self._rows(recorder, "attempt")) == 1
assert len(self._rows(recorder, "terminal_failure")) <= 1
async def test_cancellation_is_never_swallowed_by_the_degradation(self):
"""降级不得吞取消: 写入那一次 await 上被取消,`CancelledError` 照常传播。"""
from polygateway.middleware.telemetry import emit_terminal_once
from polygateway.types import _CallContext
class _CancellingEmitter:
async def emit_terminal_failure(self, **kwargs):
raise asyncio.CancelledError
context = _CallContext(now=asyncio.get_running_loop().time)
request = ChatRequest(messages=self._MSG, call_context=context)
with pytest.raises(asyncio.CancelledError):
await emit_terminal_once(
_CancellingEmitter(),
request=request,
context=context,
error=AllSourcesExhausted(scope="llm", reason="retry_exhausted", retry_after_s=1.0),
operation="chat",
)
+46 -1
View File
@@ -580,7 +580,12 @@ class TestReasonlessTelemetryContract:
client, _ = _embed_client([_src(enable_thinking=True)], script, telemetry=recorder) client, _ = _embed_client([_src(enable_thinking=True)], script, telemetry=recorder)
with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError): with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError):
await client.embed(["text"]) await client.embed(["text"])
assert len(recorder.rows) == len(script) # 1.3.5: 逐次 attempt 行之外,本次逻辑调用另有**一条**终态行
attempts = [r for r in recorder.rows if r["event_kind"] == "attempt"]
terminals = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"]
assert len(attempts) == len(script)
assert len(terminals) == 1
# 推理档在三类行上都必须是 NULL: embed payload 从不带推理参数
assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows) assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows)
async def test_embedding_wire_ignores_reasoning_configuration(self): async def test_embedding_wire_ignores_reasoning_configuration(self):
@@ -600,3 +605,43 @@ class TestReasonlessTelemetryContract:
assert seen == [{"model": "embed-1", "input": ["text"]}] assert seen == [{"model": "embed-1", "input": ["text"]}]
finally: finally:
await transport.aclose() await transport.aclose()
class TestEmbedLogicalCallStats:
"""分批共享同一逻辑调用(1.3.5 设计 §3/§3.5)。"""
async def test_three_batches_count_three_attempts(self):
"""分批是库的实现细节,但每批都真打了一次网关,故计 3 次尝试。"""
client, _ = _embed_client([_src()], ["ok", "ok", "ok"], batch_size=2)
resp = await client.embed(["a", "bb", "ccc", "dddd", "eeeee"])
assert resp.call_stats is not None
assert resp.call_stats.attempts == 3
async def test_separate_calls_get_distinct_logical_ids(self):
"""一次公开调用一个 ID: 两次 `embed` 不得共用同一个。
共用就意味着上下文被提升成了 client 实例属性(库铁律禁止的形态)
"""
client, _ = _embed_client([_src()], ["ok"] * 5, batch_size=2)
first = await client.embed(["a", "bb", "ccc", "dddd", "eeeee"]) # 3 批
second = await client.embed(["x", "y"]) # 1 批
assert first.call_stats.attempts == 3 and second.call_stats.attempts == 1
assert first.call_stats.logical_call_id != second.call_stats.logical_call_id
async def test_retry_within_a_batch_is_counted(self):
client, _ = _embed_client([_src(), _src(name="e2")], [TransientError("t1"), "ok"])
resp = await client.embed(["a"])
assert resp.call_stats.attempts == 2
async def test_empty_input_is_zero_attempts_and_writes_no_telemetry_row(self):
"""合法零尝试: 返回真实统计,且**不写任何遥测行**(设计 §3 M2)。
cache_hit 不同不要按"遥测必录"推断空输入也有台账行
"""
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], [], telemetry=rec)
resp = await client.embed([])
assert resp.call_stats is not None
assert resp.call_stats.attempts == 0
assert resp.call_stats.logical_call_id # 真实 ID,不是空串
assert rec.rows == [] # 零遥测行
+38 -1
View File
@@ -613,5 +613,42 @@ class TestReasonlessTelemetryContract:
client, _, _ = _client([_src(enable_thinking=True)], script, telemetry=recorder) client, _, _ = _client([_src(enable_thinking=True)], script, telemetry=recorder)
with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError): with pytest.raises(AllSourcesExhausted if exhausted else RequestRejectedError):
await getattr(client, method)(b"image") await getattr(client, method)(b"image")
assert len(recorder.rows) == len(script) # 1.3.5: 逐次 attempt 行之外,本次逻辑调用另有**一条**终态行
attempts = [r for r in recorder.rows if r["event_kind"] == "attempt"]
terminals = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"]
assert len(attempts) == len(script)
assert len(terminals) == 1
# 终态行的 operation 是**公开方法**名,与尝试行一致
assert terminals[0]["operation"] == method
assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows) assert all(r["error"] and r["reasoning_effort"] is None for r in recorder.rows)
class TestOcrLogicalCallStats:
"""OCR 两个公开方法各自拥有一次逻辑调用(1.3.5 设计 §3/§3.5)。"""
async def test_text_success_counts_one_attempt(self):
client, _, _ = _client([_src()], ["text"])
r = await client.recognize_text(b"jpg")
assert r.call_stats is not None and r.call_stats.attempts == 1
async def test_layout_two_http_calls_count_as_one_attempt(self):
"""POST + ZIP GET 在同一次 transport 调用内,计 **1** 次尝试而非 2。
`attempts` 的语义是"调用 transport 端口的次数",不是 HTTP 请求条数
"""
client, _, _ = _client([_src()], ["layout"])
r = await client.parse_layout(b"jpg")
assert r.call_stats is not None and r.call_stats.attempts == 1
async def test_retry_counts_every_attempt(self):
client, _, _ = _client([_src(), _src(name="m2")], [TransientError("t1"), "text"])
r = await client.recognize_text(b"jpg")
assert r.call_stats.attempts == 2
async def test_input_validation_stays_outside_the_stats_boundary(self):
"""`image` 类型/空校验先于上下文创建(M1 例外),保持原异常行为。"""
client, _, _ = _client([_src()], [])
with pytest.raises(TypeError):
await client.recognize_text("not-bytes")
with pytest.raises(ValueError):
await client.recognize_text(b"")
+2 -1
View File
@@ -116,7 +116,7 @@ async def _recorded_cost(result, source):
source_name=source.name, source_name=source.name,
usage_source=result.usage_source, usage_source=result.usage_source,
) )
await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None).emit_attempt( await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None, scope="LLM").emit_attempt(
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]), request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
source=source, source=source,
call_id="cid-1", call_id="cid-1",
@@ -124,6 +124,7 @@ async def _recorded_cost(result, source):
response=response, response=response,
error=None, error=None,
reasoning_applies=True, reasoning_applies=True,
operation="chat",
) )
return recorder.rows[0]["cost"] return recorder.rows[0]["cost"]
+52 -1
View File
@@ -124,6 +124,18 @@ class _DummyRecorder:
reasoning_tokens, reasoning_tokens,
tenant_id, tenant_id,
meta, meta,
thinking_observation,
reasoning_effort,
scope,
operation,
logical_call_id,
event_kind,
http_status_code,
error_type,
cause_type,
error_body,
attempts,
total_latency_ms,
) -> None: ... ) -> None: ...
@@ -275,8 +287,47 @@ class TestTelemetryRecorderSignature:
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
assert {"tenant_id", "meta"} <= set(params) assert {"tenant_id", "meta"} <= set(params)
def test_call_observability_fields_are_declared(self):
"""1.3.5 十列进协议(issue #19/#23);字段总数以实测为准不凭记忆。
本签名同时是装配闸的事实源(`_assert_recorder_shape` 按它派生参数名),
故它与实现一旦漂移,下游自定义 recorder 会在装配期就被拒
"""
import inspect
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
assert {
"scope",
"operation",
"logical_call_id",
"event_kind",
"http_status_code",
"error_type",
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
} <= set(params)
assert len(params) - 1 == 36 # 减掉 self
@pytest.mark.parametrize( @pytest.mark.parametrize(
"name", ["tenant_id", "meta", "thinking_observation", "reasoning_effort"] "name",
[
"tenant_id",
"meta",
"thinking_observation",
"reasoning_effort",
"scope",
"operation",
"logical_call_id",
"event_kind",
"http_status_code",
"error_type",
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
],
) )
def test_caller_dimensions_have_no_default(self, name): def test_caller_dimensions_have_no_default(self, name):
import inspect import inspect
+10 -6
View File
@@ -169,7 +169,7 @@ def _source(model="qwen-max"):
class TestEmitterCost: class TestEmitterCost:
async def test_success_row_costed(self): async def test_success_row_costed(self):
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None) emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_attempt( await emitter.emit_attempt(
request=_REQ, request=_REQ,
source=_source(), source=_source(),
@@ -178,18 +178,19 @@ class TestEmitterCost:
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True, reasoning_applies=True,
operation="chat",
) )
assert rec.rows[0]["cost"] == pytest.approx(7.2) assert rec.rows[0]["cost"] == pytest.approx(7.2)
async def test_cache_hit_row_costs_zero(self): async def test_cache_hit_row_costs_zero(self):
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None) emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True)) await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True), operation="chat")
assert rec.rows[0]["cost"] == 0.0 assert rec.rows[0]["cost"] == 0.0
async def test_failure_row_cost_none(self): async def test_failure_row_cost_none(self):
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None) emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_attempt( await emitter.emit_attempt(
request=_REQ, request=_REQ,
source=_source(), source=_source(),
@@ -198,12 +199,13 @@ class TestEmitterCost:
response=None, response=None,
error="TransientError: boom", error="TransientError: boom",
reasoning_applies=True, reasoning_applies=True,
operation="chat",
) )
assert rec.rows[0]["cost"] is None assert rec.rows[0]["cost"] is None
async def test_unknown_model_none_without_blocking(self): async def test_unknown_model_none_without_blocking(self):
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None) emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_attempt( await emitter.emit_attempt(
request=_REQ, request=_REQ,
source=_source(model="mystery"), source=_source(model="mystery"),
@@ -212,13 +214,14 @@ class TestEmitterCost:
response=_resp(model="mystery"), response=_resp(model="mystery"),
error=None, error=None,
reasoning_applies=True, reasoning_applies=True,
operation="chat",
) )
assert rec.rows[0]["cost"] is None assert rec.rows[0]["cost"] is None
async def test_no_pricing_keeps_none(self): async def test_no_pricing_keeps_none(self):
"""未注入价格表 = M1 现状: cost 恒 None(回归)。""" """未注入价格表 = M1 现状: cost 恒 None(回归)。"""
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, text_cap=None) emitter = TelemetryEmitter(rec, text_cap=None, scope="LLM")
await emitter.emit_attempt( await emitter.emit_attempt(
request=_REQ, request=_REQ,
source=_source(), source=_source(),
@@ -227,5 +230,6 @@ class TestEmitterCost:
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True, reasoning_applies=True,
operation="chat",
) )
assert rec.rows[0]["cost"] is None assert rec.rows[0]["cost"] is None
+58
View File
@@ -5,6 +5,7 @@
""" """
import asyncio import asyncio
import dataclasses
import pytest import pytest
@@ -776,3 +777,60 @@ class TestRateLimitPushback:
await mw(_REQ) await mw(_REQ)
assert ei.value.reason == "retry_exhausted" assert ei.value.reason == "retry_exhausted"
assert len(transport.calls) == 3 assert len(transport.calls) == 3
class TestLogicalAttemptCounting:
"""尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。
登记点若挪到成功之后,失败与取消的尝试就会从计数里消失而那正是
诊断时最需要看见的几次
"""
def _ctx(self, clock):
from polygateway.types import _CallContext
return _CallContext(now=clock)
async def test_single_success_counts_one(self):
mw, _, _, _, _, clock = _harness([_src("a")], [_ok()])
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 1
async def test_failed_retries_are_counted(self):
"""两次可重试失败 + 一次成功 = 3 次尝试,不是 1 次。"""
mw, _, _, transport, _, clock = _harness(
[_src("a")], [TransientError("t1"), TransientError("t2"), _ok()]
)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 3 == len(transport.calls)
async def test_budget_free_429_still_counts_as_an_attempt(self):
"""429 免的是重试预算,不是"没发生过"——它确实打到了网关。"""
mw, _, _, transport, _, clock = _harness(
[_src("a")],
[
TransientError("t1", status_code=429, retry_after_s=1.0),
TransientError("t2", status_code=429, retry_after_s=1.0),
_ok(),
],
)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 3 == len(transport.calls)
async def test_retry_exhausted_counts_every_attempt(self):
mw, _, _, transport, _, clock = _harness(
[_src("a")], [TransientError(str(i)) for i in range(5)], max_attempts=3
)
ctx = self._ctx(clock)
with pytest.raises(AllSourcesExhausted):
await mw(dataclasses.replace(_REQ, call_context=ctx))
assert ctx.snapshot().attempts == 3 == len(transport.calls)
async def test_absent_context_does_not_break_the_call(self):
"""库内现场构造的 `ChatRequest` 没有上下文,不得因此报错(设计 §3.5)。"""
mw, _, _, _, _, _ = _harness([_src("a")], [_ok()])
resp = await mw(_REQ)
assert resp.content == "ok" and _REQ.call_context is None
File diff suppressed because it is too large Load Diff
+141
View File
@@ -610,3 +610,144 @@ class TestSourceConfigEffortNormalization:
"""非字符串同样只能是 `ValueError`: 公共入口不许把类型错误漏成 `AttributeError`。""" """非字符串同样只能是 `ValueError`: 公共入口不许把类型错误漏成 `AttributeError`。"""
with pytest.raises(ValueError, match="推理档位"): with pytest.raises(ValueError, match="推理档位"):
_make_source(reasoning_effort=3) _make_source(reasoning_effort=3)
class TestCallStatsAndContext:
"""逻辑调用统计内核(1.3.5 设计 §3/§4)。"""
def test_call_stats_is_frozen_snapshot(self):
from polygateway.types import CallStats
stats = CallStats(logical_call_id="lc-1", attempts=2, total_latency_ms=15)
with pytest.raises(dataclasses.FrozenInstanceError):
stats.attempts = 3
def test_context_counts_attempts_and_freezes_elapsed(self):
"""快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。"""
from polygateway.types import _CallContext
clock = _FakeMonotonic()
ctx = _CallContext(now=clock)
clock.advance(1.5)
ctx.register_attempt()
ctx.register_attempt()
stats = ctx.snapshot()
assert stats.attempts == 2
assert stats.total_latency_ms == 1500 # 秒→毫秒,不混用单位
def test_snapshot_is_repeatable_and_tracks_later_time(self):
from polygateway.types import _CallContext
clock = _FakeMonotonic()
ctx = _CallContext(now=clock)
first = ctx.snapshot()
clock.advance(2.0)
second = ctx.snapshot()
assert first.total_latency_ms == 0 and second.total_latency_ms == 2000
assert first.logical_call_id == second.logical_call_id
def test_each_context_gets_its_own_logical_id(self):
from polygateway.types import _CallContext
clock = _FakeMonotonic()
assert _CallContext(now=clock).logical_call_id != _CallContext(now=clock).logical_call_id
def test_claim_terminal_is_true_once(self):
"""终态去重位: 保证每逻辑调用至多写一条终态行(设计 §6 不变量 I3)。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
assert ctx.claim_terminal() is True
assert ctx.claim_terminal() is False
def test_chat_request_context_does_not_affect_equality_or_repr(self):
"""上下文是库内部件: 进 `compare`/`repr` 会污染既有请求语义与日志。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
bare = ChatRequest(messages=[{"role": "user", "content": "hi"}])
with_ctx = dataclasses.replace(bare, call_context=ctx)
assert with_ctx.call_context is ctx
assert with_ctx == bare
assert "call_context" not in repr(with_ctx)
def test_replace_preserves_the_same_context_reference(self):
"""洋葱各层经 `replace` 派生请求,上下文必须是同一实例而非拷贝。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
req = ChatRequest(messages=[{"role": "user", "content": "hi"}], call_context=ctx)
derived = dataclasses.replace(req, stream=False)
assert derived.call_context is ctx
def test_four_responses_default_call_stats_to_none(self):
"""第三方合成响应的 `None` 表示未知,不得伪造 0(设计 §3)。"""
from polygateway.types import (
EmbeddingResponse,
OcrLayoutResult,
OcrTextResult,
)
llm = LLMResponse(
content="c",
thinking="",
model="m",
provider="p",
prompt_tokens=1,
completion_tokens=1,
latency_ms=1,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
call_id="c1",
)
emb = EmbeddingResponse(
vectors=[],
dim=0,
model="m",
provider="p",
prompt_tokens=0,
usage_source="measured",
latency_ms=0,
call_id="c2",
source_name="s",
)
text = OcrTextResult(
text="", source_name="s", usage=Usage(0, 0), latency_ms=0, call_id="c3", raw={}
)
layout = OcrLayoutResult(
elements=[],
page_sizes=[],
source_name="s",
usage=Usage(0, 0),
latency_ms=0,
call_id="c4",
raw={},
)
assert (llm.call_stats, emb.call_stats, text.call_stats, layout.call_stats) == (
None,
None,
None,
None,
)
def test_call_stats_is_exported_from_package_root(self):
"""四份平铺字段会漂移,故统计以单一对象出现在公共 API(设计 §3)。"""
import polygateway
from polygateway.types import CallStats
assert polygateway.CallStats is CallStats
assert "CallStats" in polygateway.__all__
class _FakeMonotonic:
"""确定性单调钟;不复用 contracts 的 FakeClock 以免 unit 反向依赖契约包。"""
def __init__(self, start: float = 1000.0) -> None:
self.t = start
def __call__(self) -> float:
return self.t
def advance(self, seconds: float) -> None:
self.t += seconds
+18 -6
View File
@@ -35,6 +35,7 @@ from polygateway.types import (
USAGE_SOURCES, USAGE_SOURCES,
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
CallStats,
ChatRequest, ChatRequest,
EmbeddingTransportResult, EmbeddingTransportResult,
GlobalLimits, GlobalLimits,
@@ -44,6 +45,9 @@ from polygateway.types import (
SourceConfig, SourceConfig,
) )
# 终态行的快照入参(1.3.5): `emit_terminal_failure` 不再收 `latency_ms`。
_MIGRATED_STATS = CallStats(logical_call_id="lcid-mig", attempts=1, total_latency_ms=1)
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}]) _REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
_DOMAIN = sorted(USAGE_SOURCES) _DOMAIN = sorted(USAGE_SOURCES)
@@ -255,7 +259,7 @@ def _resp(usage_source):
@pytest.mark.parametrize("emitted", _DOMAIN) @pytest.mark.parametrize("emitted", _DOMAIN)
async def test_emit_attempt_success_stays_in_domain(emitted): async def test_emit_attempt_success_stays_in_domain(emitted):
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
await TelemetryEmitter(recorder, text_cap=None).emit_attempt( await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_attempt(
request=_REQ, request=_REQ,
source=_src(), source=_src(),
call_id="cid", call_id="cid",
@@ -263,6 +267,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
response=_resp(emitted), response=_resp(emitted),
error=None, error=None,
reasoning_applies=True, reasoning_applies=True,
operation="chat",
) )
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
@@ -270,7 +275,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
async def test_emit_attempt_failed_attempt_stays_in_domain(): async def test_emit_attempt_failed_attempt_stays_in_domain():
"""失败尝试无 response,`usage_source` 取 emitter 自己的字面量。""" """失败尝试无 response,`usage_source` 取 emitter 自己的字面量。"""
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
await TelemetryEmitter(recorder, text_cap=None).emit_attempt( await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_attempt(
request=_REQ, request=_REQ,
source=_src(), source=_src(),
call_id="cid", call_id="cid",
@@ -278,6 +283,7 @@ async def test_emit_attempt_failed_attempt_stays_in_domain():
response=None, response=None,
error="boom", error="boom",
reasoning_applies=True, reasoning_applies=True,
operation="chat",
) )
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
@@ -285,8 +291,10 @@ async def test_emit_attempt_failed_attempt_stays_in_domain():
@pytest.mark.parametrize("emitted", _DOMAIN) @pytest.mark.parametrize("emitted", _DOMAIN)
async def test_emit_cache_hit_stays_in_domain(emitted): async def test_emit_cache_hit_stays_in_domain(emitted):
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
await TelemetryEmitter(recorder, text_cap=None).emit_cache_hit( await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_cache_hit(
request=_REQ, response=_resp(emitted) request=_REQ,
response=_resp(emitted),
operation="chat",
) )
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
@@ -294,7 +302,11 @@ async def test_emit_cache_hit_stays_in_domain(emitted):
async def test_emit_terminal_failure_stays_in_domain(): async def test_emit_terminal_failure_stays_in_domain():
"""终态失败无具体源,`usage_source` 同样取 emitter 字面量。""" """终态失败无具体源,`usage_source` 同样取 emitter 字面量。"""
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
await TelemetryEmitter(recorder, text_cap=None).emit_terminal_failure( await TelemetryEmitter(recorder, text_cap=None, scope="LLM").emit_terminal_failure(
request=_REQ, call_id="cid", latency_ms=10, error="cancelled" request=_REQ,
call_id="cid",
error="cancelled",
operation="chat",
stats=_MIGRATED_STATS,
) )
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES assert recorder.rows[0]["usage_source"] in USAGE_SOURCES