22 KiB
1.3.5:逻辑调用统计与结构化失败诊断
状态:独立审查及定向复审通过,人类已于 2026-09-09 正式批准(§10 六项批准项全数获批,可据此实施公共 API)。实施计划见
research-wiki/plans/2026-09-09-135-call-observability.md。 日期:2026-09-09。范围:issue #19、#23。基线源码 HEAD e71a623。 复用现有 Emitter / schema / 三条治理循环,不引入追踪平台、不做 deadline / hedging、不在库内复制上下游关联状态。
1. 问题与边界
治理单位是一次逻辑调用,但当前 latency_ms / call_id 只描述单次尝试:重试几次、等了多久、缓存有没有参与,SQL 答不出来。诊断侧,transport 抛出的领域错误已带 status_code / operation / body_text(errors.py:52),进 emitter 前却被 str() 压平成一列自由文本。终态失败可能没有选中源,但 scope 始终已知。
中转把供应商 529 改写成 503 后,库只能如实记 503,不能猜回 529;未知成本、未知状态一律 NULL。本版不改重试预算、429 语义、stall、推理能力表与取消结算算法,不新增核心依赖。
2. 方案
| 方案 | 收益 | 代价 |
|---|---|---|
| A:只在 RetryMW 加两个计数、错误列细分 | 补丁最小 | 漏缓存命中、结构化重问、embedding 分批与三类失败终态,回答不了"整次调用" |
| B:每调用局部统计 + 领域异常下沉到单一出口,复用现有遥测行 | 统一边界、字段保真、不默认加成功行、不双计费用 | 增加内部上下文、响应字段与 recorder 字段;须补齐缺失的失败终态行 |
| C:新增独立逻辑调用表 / 通用事件端口 | 完整追踪与任意事件分析 | 新存储与运维面,超出当前需求 |
推荐 B。显式传递局部对象,不用 client 共享可变计数、不用模块级 ContextVar;不统一三条治理循环,只统一计数与诊断出口。
3. 逻辑边界与公开统计
四种响应(LLMResponse、EmbeddingResponse、OcrTextResult、OcrLayoutResult)追加 call_stats: CallStats | None = None。新增 frozen CallStats 并由包根导出——四份平铺字段会立刻漂移。
| CallStats 字段 | 语义 |
|---|---|
logical_call_id: str |
每次公开调用一个 UUID;重试、重问、分批共享;不占用既有 parent_call_id |
attempts: int |
准入后实际调用 transport 端口的次数;含免预算 429 与端口本地拒绝;不是 HTTP 请求条数 |
total_latency_ms: int |
从输入校验通过到返回/异常传播前的单调时钟快照;含缓存、等待、重问、分批、内联记账与资源收尾 |
输入校验异常发生在统计边界之外,保持原行为。第三方合成响应的 None 表示未知,不得默认伪造 0。
- 缓存命中:
attempts=0、新 logical ID、本次缓存路径耗时;不回放历史统计。缓存持久化排除call_stats(_serialize显式剔除),_rehydrate显式覆盖为None——_RESPONSE_FIELDS过滤会放行历史 dict,不覆盖就会有 dict 冒充CallStats。缓存 key 白名单不变。 - embed 空输入(embedding.py:180-190):合法零尝试,返回真实统计(
attempts=0),不写任何遥测行——与 cache_hit 不同,不要按"必录"推断它有台账行。 - OCR layout 的 POST + ZIP GET 在同一 transport 调用内(monkey_ocr.py:262-296),计 1 次尝试。
- chat 重问 / embedding 多批计入同一上下文,不重置计数(重问经
call_next重入 RetryMW,已核对)。
latency_ms / call_id / parent_call_id 语义不变;"总耗时减最后一次尝试耗时"不等于纯等待(含其他本地工作)。不在响应里挂每次尝试的明细列表,避免公共响应无界增长。
3.1 失败与取消
本版不向异常对象附加可变 call_stats:第三方可能复用同一异常实例,first-write-wins 会把首次调用的统计误读成本次,覆盖写则串扰;复制任意异常又保证不了构造签名与自定义属性。异常类型与分类原样保留,失败侧的统计走 §6 的终态行。
- 取消:
CancelledError保持原类型与语义,不在其上加字段;取消路径只尽力写一条终态,不 shield、不开新后台任务。 - 任意内部非领域异常(编程错)原样传播,本版不承诺为其提供任何统计或终态行,也不偷偷改分类。
4. 最小内部接缝
CallStats 与私有可变 _CallContext 都落 types.py:types.py 不反向依赖实现层,不产生循环,也不动 import-linter 分层(ports : types : errors 并列最内层)。私有上下文只持计数、单调时钟与必要去重状态,不做 I/O。
ChatRequest 追加内部上下文字段(default=None, compare=False, repr=False);StructuredMW 的 dataclasses.replace 保留同一引用(structured.py:96-110,已核对)。响应统计只在公共出口经 replace 附加。GatewayClient 需自存注入的 now(client.py 现未保存),冻结快照是同步动作,不 await。
- 上下文创建/冻结:
GatewayClient在公开入口创建、finally冻结;RetryMW只在 transport 调用前登记一次尝试。 - OCR 例外(M1):
image的类型/空校验在_call内(ocr.py:238-241)而非公开方法,故上下文在该校验通过之后创建,§3 的"校验在边界外"对 OCR 才成立。 EmbeddingClient/OcrClient显式把同一上下文传到每批/每次尝试;统计生效与否不由 telemetry 是否启用决定。- 每次尝试 ID 仍在当前循环产生,与上下文的 logical ID 一起交给 Emitter。不持久化断点,不引入任务恢复。
5. 诊断字段与归因方式
保留现有 error 字符串供人阅读;Emitter 改为接受领域异常对象而非调用方先 str(),由单一 helper 提取有限诊断字段。普通超时/网络翻译在 transport 侧保留直接 __cause__ 类型,空 str() 退回类名;不遍历任意异常对象、不猜测正文。
| 新增 INSERT 列 | 值域 / 来源 |
|---|---|
scope |
配置池名,构造期注入 Emitter(见下);不拿 source_name 顶替 |
operation |
公开方法固定四值:chat / embed / recognize_text / parse_layout;由三个 client 在调用点给定 |
logical_call_id |
当前调用上下文的 UUID;上下文缺席(库内现场构造的 ChatRequest)→ NULL,不造 ID |
event_kind |
attempt / cache_hit / terminal_failure;旧行 NULL,不回填 |
http_status_code |
仅 attempt 行:失败异常实收状态;无 HTTP 或未知 NULL;成功行不统一填 200 |
error_type |
该行自身错误的领域类名;成功行 NULL |
cause_type |
仅 attempt 行:transport 直接捕获的底层异常类名,未知 NULL |
error_body |
仅 attempt 行:既有 summarize_body 有界摘要,未知 NULL;不存 raw_text、不存全量原文 |
attempts / total_latency_ms |
仅 terminal_failure 行填逻辑快照,其余行 NULL;快照在写入前冻结 |
全部可空,追加到物理列末尾(与旧表 ALTER 追加位置一致,schema.py 的 DDL / BACKFILL / COLUMNS 三处同改)。
归因方式(C1,决定性):GatewayUnavailableError 家族从不携带 status_code / body_text(errors.py:118-166),终态行的 http_status_code / cause_type / error_body 因此保持 NULL,这是它自身的真实状态——不把最后一次 attempt 的状态码与正文搬上来伪装成整池诊断(那正是"不拿最后一个源冒充整池归因"的同一条红线)。终态行的 error_type 落它自己的类名(AllSourcesExhausted / CircuitOpenError / …),scope 级 reason 沿用已有 error 文案(str(exc) 已是 "{scope} 网关暂时不可用: {reason}",不新增列)。逐源现场由同一 logical_call_id 的 attempt 行给出。
-- #19 验收:一次逻辑失败调用的完整现场(终态 + 各次尝试)
SELECT event_kind, source_name, http_status_code, error_type, cause_type, error_body, error
FROM llm_calls WHERE logical_call_id = :lcid ORDER BY created_at;
该查询必须同时给出"整池为何失败"(终态行 error 文案)与"每个源怎么死的"(attempt 行状态码/正文),测试按它断言。因此本版不再为 reason / per_source_reasons 扩列。
结构化耗尽的可定位性(C2):ResultInvalidError("结构化输出阶梯耗尽") 的 message 不含 validation_errors / repair_error(structured.py:80-86),而该失败发生在 StructuredMW 之上——RetryMW 侧的 attempt 行全是成功行,终态行是唯一记录。做法:不写 error_body(该列只属 attempt),由 Emitter 的单一 helper 对 ResultInvalidError 生成有界结构化说明并入现有 error 字符串,复用 structured.py 已有的取材口径(至多 3 条、每条 200 字符,与 _format_errors 同参数,拼装函数收敛在 helper 一处)。raw_text 不重复落库(它是模型正文,attempt 行的 response 列已按 text_cap 记过一份;再存一份等于绕过既有正文预算)。该说明可能包含模型输出片段,故遵循与 error 现有正文相同的隐私边界,不额外扩大保留范围。
operation 的数据源(I1/I2):openai_compat.py:169 的 _status_to_error 硬编码 operation="chat",而 embed() 的非 200 分支(:512)也走它 → 现存所有 embedding HTTP 失败的 exc.operation 都是错的。(行号勘误 2026-09-09:本句原写 "(:512、:527)",实测 :527 属 _complete_stream即流式 chat,embed 只有 :512 一处;归属以实施计划 §2 表为准,决策未变。)修正:_status_to_error 增 keyword operation,embed 传 "embedding"(沿用该异常侧既有词表,不改 chat / ocr_text / parse / download_result 四值)。新列 operation 与 exc.operation 是两个语义:前者是公开方法,后者是 HTTP 子操作;新列由调用点给定,绝不读 exc.operation,两者不做自动转换。OCR 两个公开方法各自在调用级给定自己的值。
scope 注入(M6):TelemetryEmitter 现在不知道 scope,CacheMW / TelemetryMW 自己也拿不到。构造期注入(三个 client 各一行),使 attempt / cache_hit / terminal_failure 三类行都带 scope,避免改三条调用链。model / provider / source 未选出时仍留原空值。
成功侧不加承诺:成功行不承诺 HTTP 状态与错误体;本版不宣称 SQL 可直接统计所有成功逻辑调用的总耗时(成功不加终态行)。error_body 沿用 summarize_body 的既有上限,不纳入 PGW_TELEMETRY_TEXT_CAP 覆盖面(该键现覆盖四处,详见 §8)。
6. 行语义与终态:只补确实缺失的失败
保留每次 attempt 与 cache_hit 的既有行,不为成功新增终态行。终态行不得复制已有 attempt 的 token 与成本。所有统计边界内的领域失败均尝试写终态,包括已有 attempt 错误行的直接 RequestRejectedError/ResultInvalidError;不再沿用“该类错误已录所以外层不录”的旧假设,400 密集负载的错误行可能翻倍,调用失败计数必须只取 terminal_failure。
不变量(I3,统一措辞):
| 结束形态 | 终态行数 |
|---|---|
| 以领域错误结束的逻辑调用 | 每次调用尝试写一条;持久化 best effort(recorder 写失败按既有降级只落 warning),故 SQL 可见行数 ≤ 1 |
| 取消 | 三个 client 同策略尽力写一条,允许 0 条 |
| 非领域异常(编程错) | 0 条,原样传播,本版无统计保证 |
需要补的路径:chat 结构化耗尽(发生在 transport 成功之后,现无任何失败行)、embedding / OCR 的无源、准入拒绝、重试耗尽与尝试外取消。取消口径统一(I4):chat 现由 TelemetryMW 对任何取消补终态(telemetry.py:487-495,含尝试内取消),embedding / OCR 按同一口径尽力补,避免下游按 event_kind 统计取消时拿到路径相关的结果。
终态与 attempt 不是重复事实(前者描述逻辑终态,后者描述尝试),用 event_kind 区分;禁止按 error IS NOT NULL 跨两类直接计失败调用次数。chat 现有 TelemetryMW 终态路径收敛到公开边界的单一 helper,避免两处同时写;embedding / OCR 复用该 helper。
终态行的请求摘要(M4):复用既有 200 字符输入摘要口径,描述本次调用的整体输入,但不扩大单行正文预算——embedding 终态取 <embed texts=N batches=M> 计数占位 + 第一批(至多 batch_size 条、每条 200 字符,与逐批行同款构造);OCR 终态沿用 <ocr:{kind} image_bytes=…> 占位,图像 bytes 永不入库。失败批的具体文本由同 logical_call_id 的 attempt 行给出,终态行不保存全量原输入。
错误文本口径(I7):OCR 现落 "类名: msg"(ocr.py:445-449,按类名归组的既有 metric 口径),chat / embed 落裸 str(exc)。改成"Emitter 收异常对象"后,该差异由统一出口的显式文本策略参数保留(OCR 保留类名前缀),不再在三处复制参数列表。
取消时的终态写(残余风险,显式定策):该 await 本身是新的取消点。策略是取消优先、不屏蔽:外部取消落在这一 await 上时,CancelledError 照常传播(调用方可能因此看到 CancelledError 而非领域错误,与 TelemetryMW 现有行为同款);不 shield、不建新后台任务。冻结统计快照是同步动作,不 await。
终态行成本 NULL、usage unavailable;聚合费用仍只由 attempt / cache_hit 行决定。终态快照在写入前冻结,故不含自身写入耗时;成功响应快照包含其返回前已完成的内联遥测耗时。失败异常不附快照,不为对齐再 UPDATE 旧行。
取消 attempt 既有字符串 "cancelled" 保留:Emitter接收 PolyGatewayError | str | None,字符串不解析猜测诊断,error_type/cause_type/http_status_code/error_body均NULL;终态取消同样使用明确取消文案。终态既有latency_ms与新增total_latency_ms取同一冻结快照,避免双时钟微差。
7. recorder 兼容:装配期机械闸(C3)
TelemetryEmitter._record 的 except Exception 会把旧 recorder 的 TypeError 吞成 warning(telemetry.py:463),后果是自定义 recorder 在下游升级后100% 丢遥测且调用照常成功——正是"遥测必录"要防的形态。文档级迁移清单挡不住它。
机械闸:在 TelemetryEmitter.__init__(三个 client 的唯一汇流点,与 text_cap 值域校验同处)对 recorder.record_llm_call 做一次 inspect.signature(...).bind(**<完整新 kwargs 形状>),不执行写入;含 **kwargs(VAR_KEYWORD)者自动通过。校验失败 → 装配期抛错。若目标不可 inspect(C 实现等),同样按配置错误当场报错,不进入"运行期静默丢行"。
边界诚实声明:签名 bind 只证明该形状能被接受,不能证明函数体真的落这些列;这是一道装配闸,不是行为验证。它既不是被否掉的 runtime_checkable 判定,也不是捕 TypeError 重试写入。是否扩主 TelemetryRecorder Protocol(备选:独立扩展端口保留旧实现)是 §10 的人类批准项;本草案不同时实现两套接口,自带两个 recorder 的 **fields 签名可接受新增参数,但其 schema、INSERT 字段与契约测试仍须同步,不能称后端完全不受影响。
校验所用参数名从 TelemetryRecorder.record_llm_call 的协议签名派生,不手抄第四份字段清单;只用哨兵值做bind形状校验,不读取真实请求数据。结构化说明的限条数/限长复用现有常量,若需命名常量则在既有规则所有者中定义并由两个消费者引用,不复制数值。
8. 下游可见变更与文档同步
SQL 迁移清单(I6,批准项须按此逐条看):
| 影响面 | 变化 | 下游动作 |
|---|---|---|
| 失败行数 | 新增 terminal_failure 行(每失败调用至多 1) |
计失败调用改 WHERE event_kind = 'terminal_failure' |
error IS NOT NULL |
同时命中 attempt 与 terminal 两类 | 不再作为"失败调用数"的判据 |
AVG(latency_ms) |
terminal 行携带逻辑总耗时,量级大于单次尝试 | 时延看板一律按 event_kind 分组或过滤 |
| 费用聚合 | terminal 行 cost 恒 NULL、usage unavailable |
费用仍只由 attempt / cache_hit 行决定,口径不变 |
| 成功侧 | 不加任何成功汇总行 | 成功逻辑调用总耗时仍从响应 call_stats 读,不从 SQL 读 |
http_status_code |
失败行上可能是 200(monkey_ocr.py:265-270 的 success != true 带 200 上抛) |
该列不可作失败判据 |
文档同步(M5,发布前必须同批):README:23 的"必录 26 字段"、README:399 与 ARCHITECTURE.md:592 的 PGW_TELEMETRY_TEXT_CAP 覆盖面四处枚举(须明确:error_body 沿用 summarize_body 上限,error 保留既有文本口径并仅对新增结构化说明限长;二者不在 cap 覆盖内)、ARCHITECTURE.md:565 的必录字段清单与 §7.8 补列一节、.env.example 相关注释、CHANGELOG 与 wiki(docs-convention §2)。字段数与物理列数一律以 inspect.signature / len(COLUMNS) 实测改写,不凭记忆(现状:26 个 INSERT 字段 + created_at = 27 物理列;本版新增 10 列)。
9. 非功能与测试矩阵
| 维度 | 要求 |
|---|---|
| 并发 | 每调用独立对象;同一 client 并发不串 logical ID / 计数 / 统计;无全局状态 |
| 取消 | 各等待点穿透;finally 释放既有资源;终态写取消优先;不新增 shield 与后台任务 |
| 降级 | recorder 写失败不改统计与主结果;准入后端仍 fail-closed;收尾失败仍原 warning |
| 持久化 | schema 单一事实源;SQLite auto / PG manual 裁剪 INSERT 保持;不 ALTER 默认生产 PG、不改旧列、不回填旧行 |
| 幂等 | attempt ID 唯一,终态独立 ID,不重复写同一终态;缓存命中不复制历史统计;不 UPDATE 计费 |
验收优先离线:真实 client + 内存后端 + FakeClock + MockTransport + 临时 SQLite,复用 1.3.4 设施;不重跑未变的模型能力矩阵。
| 测试族 | 必须证明 |
|---|---|
| logical 计数 | 一次成功、失败重试、免预算 429、多源拒绝、缓存命中、结构化重问、embedding 多批、OCR 双 HTTP、空输入(0 尝试且 0 遥测行) |
| 计时 | 缓存 IO、退避、准入等待、重问、收尾均计入;关闭 recorder 仍正确;毫秒/秒不混用 |
| 失败与取消 | 领域失败恰一次尝试写终态;取消三条路径同策略(允许 0 行);终态写 await 上被取消 → CancelledError 传播;permit / 探针释放不变;非领域异常 0 行且原样传播 |
| 保真诊断 | 503 包装不改回 529;直接 529 记 529;空 Connect/Read/Write/PoolTimeout 文案有类型;embedding HTTP 失败的 exc.operation 为 embedding;新列 operation 恒为四值之一且不随异常变化 |
| 归因 SQL | §5 那条按 logical_call_id 的查询同时给出终态 reason 文案与逐源状态码/正文;终态行三列为 NULL;结构化耗尽的 error 含有界 validation/repair 说明且不含 raw_text |
| 行语义 | 三类行均带 scope;每失败调用至多一条终态;attempt 与 terminal 区分;费用不重复;AVG(latency_ms) 按 event_kind 分组的断言 |
| 装配闸 | 旧签名 recorder → 装配期报错(非 warning);**kwargs recorder 通过;不可 inspect → 配置错误 |
| 存储兼容 | SQLite 新旧表、PG manual 缺列裁剪 / auto 追加、旧行 NULL、新旧进程混写 |
| 变异 | 计数位置、上下文复制、缓存历史回放、提前 str() 压平、终态双计费用分别红→绿;PG 真实集成 + 常规全套 + 独立验证 |
10. 集中人类批准项
| # | 决策 | 推荐 |
|---|---|---|
| 1 | 公开响应结构 | 一个 CallStats 对象而非四类响应各铺三字段;需确认命名与消费便利性 |
| 2 | 失败侧统计范围 | 不改/不复制异常;失败统计只落终态行,调用方仅在成功响应读 call_stats;需确认该取舍可接受 |
| 3 | recorder 兼容路线 | 扩主 TelemetryRecorder Protocol + 装配期 bind 闸;备选独立扩展端口保留旧实现;需确认是否存在必须兼容的自定义 recorder |
| 4 | 新增失败终态行 | 补漏但不加成功汇总;须批准 §8 表中全部五项下游可见变化(不止行数) |
| 5 | 取消时终态写取消优先 | 调用方可能看到 CancelledError 而非领域错误(同 TelemetryMW 现状);需确认接受 |
| 6 | operation 值域与异常侧修正 |
新列固定四值;_status_to_error 增 operation 参数、embed 传 embedding(修正现存误标,属下游可见的历史数据口径变化) |
11. 独立审查处理表
| 项 | 结论 | 落点 |
|---|---|---|
| C1 终态缺诊断来源 | 不采纳"定向读 __cause__ / per_source_reasons 扩列";终态保留自身 NULL 状态,归因由 logical_call_id 关联 attempt 行 + 既有 reason 文案完成,并写死 SQL 验收 |
§5 |
| C2 结构化耗尽可定位 | 采纳(变形):有界说明并入现有 error 字符串,不写 error_body、不重复存 raw_text |
§5 |
| C3 recorder 静默失败 | 采纳:装配期一次 signature.bind 形状校验,含 **kwargs,不可 inspect 即配置错误;明确不验证函数体 |
§7 |
| I1 / I2 operation 污染与未归一 | 采纳:新列固定四值由调用点给定,绝不读 exc.operation;只修 embedding 误标,其余异常侧词表不动 |
§5 |
| I3 / I4 终态不变量与取消口径 | 采纳(定稿措辞):领域失败每调用尝试写一条、持久化 best effort;取消三路同策略尽力允许 0;非领域异常 0 条 | §6 |
| I5 上下文缺席语义 | 采纳:None → NULL,不造 ID |
§5 |
| I6 迁移影响不止行数 | 采纳:列全五项 SQL 影响并进批准项 | §8、§10 |
| I7 OCR error 文本口径 | 采纳:保留类名前缀,由统一出口的显式文本策略参数承载,不复制参数列表 | §6 |
| M1 OCR 校验与统计边界 | 采纳:上下文在 image 校验通过后创建 |
§4 |
| M2 embed 空输入 | 采纳:0 尝试且不写任何遥测行 | §3 |
| M3 失败行可能带 200 | 采纳:写明该列不可作失败判据 | §8 |
| M4 终态请求摘要未定 | 采纳:复用 200 字符口径,终态描述整体输入,不扩预算,不存全量原输入 | §6 |
| M5 文档同步缺项 | 采纳:列全六处并要求实测改数字 | §8 |
| M6 Emitter 不知 scope | 采纳:构造期注入 | §5 |
| 残余风险(终态写成新取消点) | 采纳:显式定策"取消优先不屏蔽",并进测试族与批准项 | §6、§9、§10 |
自审:方案 B 复用既有 Emitter / schema 与三条循环,不需要 #22 / #24 的新调度。未采纳的两项(异常 first-write-wins、终态搬运最后一次 attempt 的状态与正文)理由已写在正文,不是遗漏。本文档无代码实现与测试通过声明;§10 六项已于 2026-09-09 获人类批准,实施边界与红绿证据要求以上述实施计划为准。