40 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| plan | plan:2026-09-09-135-call-observability | 1.3.5 逻辑调用统计与结构化失败诊断实施计划 | 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 实例属性。
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 已核对不重建它):
call_context: _CallContext | None = field(default=None, compare=False, repr=False)
"""库内部逻辑调用上下文;`None` = 库内现场构造的请求,遥测 logical_call_id 落 NULL。"""
LLMResponse、EmbeddingResponse、OcrTextResult、OcrLayoutResult 各追加末尾字段:
call_stats: CallStats | None = None
3.2 遥测出口(middleware/telemetry.py)
Emitter 是全库唯一 record_llm_call 调用点,本版新增的一切诊断/归因取值也只在这里定型。
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,只读领域异常的既有属性,不遍历任意对象、不猜正文:
@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 且为终态时并入有界结构化说明:
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):
def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
"""装配期一次 `signature.bind` 形状校验:不执行写入,只证明该形状能被接受。
参数名从 `TelemetryRecorder.record_llm_call` 的协议签名派生(不手抄第四份清单),
绑定用哨兵 `None`,不读真实请求数据。`**kwargs`(VAR_KEYWORD)自动通过;
`TypeError` → `ValueError` 装配期报错;不可 inspect(C 实现等)同样按配置错误报错。
"""
终态唯一出口(公开边界 helper,三个 client 共用):
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:设计批准状态、计划与基线(本任务)
- 设计文档状态行改为"人类已批准(2026-09-09)",新建本计划(≤600 行)。
- 记录基线:
git status --short --branch、git log --oneline -3、HEADa81cc91;确认源码零差异,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 ↔ planimplements),登记前先确认工具不覆盖同路径已有文档。 - 提交点:
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。故全量清单用:
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 基线验证与其后各任务的红绿证据由执行者在自己的会话内出示。