Files
PolyGateway/research-wiki/plans/2026-09-09-135-call-observability.md
T

456 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 基线验证与其后各任务的红绿证据由执行者在自己的会话内出示。