456 lines
40 KiB
Markdown
456 lines
40 KiB
Markdown
---
|
||
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`;T1–T3 行为变更执行 `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` 两个消费者共同引用。
|
||
|
||
装配闸(设计 §7,C3):
|
||
|
||
```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 三批=3;OCR 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 行都带 scope;model/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 基线验证与其后各任务的红绿证据由执行者在自己的会话内出示。
|