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

40 KiB
Raw Blame History

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。 基线 HEADa81cc91(分支 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-plansT1T3 行为变更执行 test-driven-development(先失败后通过的证据须落在本会话工具输出里);T1 触及运行时数据落库,执行 structured-logging;每次提交执行 commit(英文祈使标题、无 AI 签名、显式路径暂存);T4 前执行 requesting-code-reviewverification-before-completion,收到意见执行 receiving-code-review;异常先 systematic-debugging 定根因。

2. 文件职责与不变接缝

动作 精确路径 职责
修改 src/polygateway/types.py 新增 CallStats、私有 _CallContextCallOperation/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_BACKENDtelemetry_schema_sql 自动派生,不手改)
修改 src/polygateway/middleware/telemetry.py Emitter 构造期注入 scope + 装配闸;三个 emit 入口收 operation 与领域异常对象;诊断字段单一提取 helper;event_kind;终态出口 helperTelemetryMW 只保留 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/emitterchat 创建上下文、附加统计、领域失败与取消经单一终态出口
修改 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;三个调用点显式给值::512embed 非 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.pytests/unit/test_ports.py CallStats/上下文纯行为、端口签名实测(含无默认值与 keyword-only
修改 tests/unit/test_telemetry.py 列序/列数、三类行语义、诊断保真、装配闸、SQLite 新旧表、_record_minimal 字段字典
修改 tests/unit/test_retry.pytest_client.pytest_cache.pytest_structured.py 计数/计时/缓存命中/结构化耗尽终态与历史 dict 防护
修改 tests/unit/test_embedding.pytest_ocr_client.pytest_openai_compat.pytest_monkey_ocr.py 多批、空输入、双 HTTP 一次尝试、exc.operation 修正、200 失败行
修改 tests/unit/test_pricing.pytests/unit/test_usage_source_domain.py 仅 Emitter 调用点机械迁移TelemetryEmitter(scope=emit_*operation=emit_terminal_failurelatency_ms 改传 stats);两文件的 _MemoryRecorder**fields 形态,recorder 签名不动
修改 tests/integration/test_postgres_telemetry.py PG auto 追加 / manual 缺列裁剪 / 旧行 NULL / 新旧进程混写(真实 pg_sandbox
修改 README.mdCHANGELOG.md.env.exampleresearch-wiki/ARCHITECTURE.mdresearch-wiki/schemas/llm-calls.mdresearch-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.pymiddleware/ratelimit.pymiddleware/breaker.pysources.pythinking.pyproviders.pytelemetry/sqlite.pytelemetry/postgres.py(两个 recorder 靠 **fields + schema.COLUMNS 自动吃到新列,逻辑零改动)、transports/monkey_ocr.py(双 HTTP 仍在同一 transport 调用内,不拆)。若实现时发现必须突破本清单,先说明设计依据与最小原因交父会话核定,不顺手改。

3. 跨任务接口(可执行定义,禁止占位)

3.1 统计内核(types.py

types.py 需新增 import uuidfrom collections.abc import Callablefrom typing import LiteralMapping/Any 已在;全文现无 Literal)。

运行时求值约束(types.pyfrom __future__ import annotations,实测 :1-17CallableLiteral 必须是运行时 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。"""

LLMResponseEmbeddingResponseOcrTextResultOcrLayoutResult 各追加末尾字段:

    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 与其余三列均 NULLResultInvalidError 且为终态时并入有界结构化说明:

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):

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=Noneusage_source='unavailable'、token 0,不复制 attempt 的用量与费用。

3.4 schema 与端口(telemetry/schema.pyports.py

10 列按同一顺序追加进五处(SQLITE_DDLPG_DDLSQLITE_BACKFILL_PG_BACKFILL_DECLSCOLUMNS),全部可空、无默认值,物理位置在现有末列 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_at27 → 37。不建索引、不改旧列、不回填旧行、不 ALTER 默认生产 PGmanual 档仍裁剪写入)。ports.py 按同序追加 10 个 keyword-only 无默认值参数,docstring 更新为"36 字段冻结",并说明 error_body 沿用 summarize_body 上限、不进 PGW_TELEMETRY_TEXT_CAP 覆盖面。

3.5 三条链路的上下文所有权

链路 创建点 传递 附加统计 终态
chat GatewayClient.chatoverlay/维度/档位三项校验之后(校验异常保持原行为,在统计边界外) 放进 ChatRequest.call_context,洋葱各层经 replace 保留同一引用;RetryMW._attempttransport.completeregister_attempt()上下文为 None 时跳过——库内现场构造的 ChatRequest 不得因此报错) 返回前 dataclasses.replace(response, call_stats=ctx.snapshot())(含缓存命中路径与内联遥测耗时) except PolyGatewayError / except CancelledError 各调 emit_terminal_once,随后原样 raise;非领域异常不捕、无终态
embed EmbeddingClient.embedtexts 类型校验与调用方维度校验validate_caller_dimensions)之后;expected_dim 校验在 _attempt 内,本就在统计边界内 显式参数传到 _embed_batch_attempt_emit 现场构造的 ChatRequestcall_context=ctxtransport.embed 前登记 合并结果 replace(..., call_stats=ctx.snapshot())空输入返回真实 attempts=0 统计且不写任何遥测行 同上,operation="embed";终态 messages = <embed texts=N batches=M> 占位 + 第一批(至多 batch_size 条、每条 200 字符,与逐批行同款构造)
OCR OcrClient._callimagebytes/非空校验通过之后M1 例外) _call 拥有上下文并返回 tuple[_AttemptOutcome, CallStats],两个公开方法据此附加统计;transport 调用前登记(layout 的 POST+ZIP GET 计 1 次) OcrTextResult / OcrLayoutResultcall_stats _callexcept PolyGatewayError / except CancelledErroremit_terminal_onceoperation 由公开方法传入(recognize_text / parse_layout),class_prefixed_error=Trueno_sourcesraise 在循环之前,try 必须包住该 raise,否则无源终态行写不出

GatewayClient 需自存 self._nowself._emitter(现未保存);统计生效与否不由 telemetry 是否启用决定emitter is None 时统计照常,仅无行可写)。

4. 任务与提交点

T0:设计批准状态、计划与基线(本任务)

  • 设计文档状态行改为"人类已批准(2026-09-09",新建本计划(≤600 行)。
  • 记录基线:git status --short --branchgit log --oneline -3、HEAD a81cc91;确认源码零差异,CLAUDE.md 既有 markdown 差异与 .pi/ 一律不动、不暂存。
  • 基线验证(由执行者在 T1 之前跑一次,作为"先失败"的对照底):make checkconda run -n PolyGateway pytest tests/unit -q → 预期全绿。基线计数以本会话实跑输出为准(近期会话记录为 1385 项量级),不拿计划里的数字当期待值;实际失败照录,不改期待绕过。
  • 计划自审 + 独立模型审查(替代 Codex,用户已同意);意见逐条核验后就地修订。
  • wiki 登记:.claude/tools/research_wiki.py add_entity/add_edge/rebuild_indexdesign ↔ plan implements),登记前先确认工具不覆盖同路径已有文档。
  • 提交点:docs: record approved call observability design and plan(生产改动前的回滚点)。

T1:统计内核与三条链路的逻辑边界(不动遥测列)

文件types.py__init__.pyclient.pyembedding.pyocr.pymiddleware/retry.pymiddleware/cache.py;测试 tests/unit/test_types.pytest_client.pytest_retry.pytest_cache.pytest_embedding.pytest_ocr_client.pytest_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 Nonedict 不得冒充 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-lintertypes.py 不得引入实现层 import)。

  • 提交点:feat: track logical call statistics across governed calls

T2:遥测 10 列、诊断保真、scope/operation 与装配闸

文件telemetry/schema.pyports.pymiddleware/telemetry.pymiddleware/structured.pymiddleware/retry.pytransports/openai_compat.py、三个 client 的 Emitter 构造行;测试 tests/unit/test_telemetry.pytest_ports.pytest_openai_compat.pytest_monkey_ocr.pytest_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_bodysummarize_body 摘要且不等于 raw_text;成功行五列 NULL(不填 200
operation embed 非 200 → exc.operation == "embedding"(历史误标修正);新列 operation 恒为四值之一,与 exc.operation 无关、不随异常变化;monkey_ocrsuccess != true200 的失败行 → 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 -qmake check

  • 提交点:feat: record scope, operation and failure diagnostics per row

T3:终态行、取消口径与统一出口

文件middleware/telemetry.py(终态 helper 与 TelemetryMW 收缩)、client.pyembedding.pyocr.py;测试 tests/unit/test_telemetry.pytest_client.pytest_embedding.pytest_ocr_client.pytest_structured.pytest_retry.py

按 §3.2 的 emit_terminal_once 与 §3.5 的三条链路实现。先写测试确认旧实现红:结构化耗尽当前没有任何失败行embedding/OCR 的无源、准入拒绝、重试耗尽与尝试外取消同样无终态行。

红绿组 必须证明
补漏 chat 结构化耗尽、embedding/OCR 的 no_sources、准入拒绝、retry_exhausted、尝试外取消各恰有 1terminal_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
行语义 三类行均带 scopeevent_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_* 新增必填 operationemit_terminal_failurelatency_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.pytests/unit/test_usage_source_domain.pyEmitter 调用点 须改TelemetryEmitter(scope=emit_attempt(/emit_cache_hit(operation=test_usage_source_domain.py:297emit_terminal_failure(...) 同时缺 scope/operation/stats 且多传 latency_ms,不改必 TypeErrorrecorder 形态兼容 ≠ emitter 调用点兼容)

存储兼容(真实 PG,复用 tests/integration/conftest.pypg_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/455ARCHITECTURE.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、operationexc.operation 是两个语义
CHANGELOG.md 未发布段 公共面四项 + 下游动作清单(点名"计失败调用改 WHERE event_kind = 'terminal_failure'"与自定义 recorder 的装配期报错)
research-wiki/schemas/llm-calls.mdmetrics/call-telemetry-coverage.md 复用既有实体登记新列与三类行口径;真实覆盖基线待首次运行填,不写伪百分比

验收命令与证据

检查 命令 / 要求
静态 make checkgit diff --checkconda 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 已有有效证据直接引用

长跑用 tmuxPYTHONUNBUFFERED=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:26tests/unit/test_ocr_client.py:344FakeClock(注入 now/sleep,确定性计时);各测试文件既有的 _MemoryRecorder**fields)与脚本化 MockTransporttmp_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_shapeemit_terminal_once)、10 列取值表、10 列 DDL 类型均已写出可执行定义;被引用的 PolyGatewayErrorResultInvalidErrorsummarize_bodyformat_bounded_errorsFakeClockpg_sandbox 全部指向既有实现或本计划已定义项。
  • 一致性核对:emit_terminal_failure 去掉 latency_ms 后,唯一调用者是 emit_terminal_onceTelemetryMW 的终态分支删除后 chat 终态只剩客户端边界一处;operation 只由调用点给定,链路上无任何位置读 exc.operation
  • 未采纳项(异常上挂可变统计、终态搬运最后一次 attempt 的状态与正文、成功侧汇总行)理由在设计正文,本计划不复活。
  • 本计划编写过程未运行 pytest、未做变异、未调用任何模型、未提交;T0 基线验证与其后各任务的红绿证据由执行者在自己的会话内出示。