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

22 KiB
Raw Blame History

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. 逻辑边界与公开统计

四种响应(LLMResponseEmbeddingResponseOcrTextResultOcrLayoutResult)追加 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);StructuredMWdataclasses.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 四值)。新列 operationexc.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 错误行的直接 RequestRejectedErrorResultInvalidError;不再沿用“该类错误已录所以外层不录”的旧假设,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._recordexcept 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.operationembedding;新列 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_erroroperation 参数、embedembedding(修正现存误标,属下游可见的历史数据口径变化)

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 获人类批准,实施边界与红绿证据要求以上述实施计划为准。