Compare commits
276 Commits
v1.0.0
..
a716f12483
| Author | SHA1 | Date | |
|---|---|---|---|
| a716f12483 | |||
| 9021425875 | |||
| bb9ef038c7 | |||
| 85892fb1b5 | |||
| e9607b2f0c | |||
| a194f4326e | |||
| f5e6fafe8d | |||
| f9b357b9d7 | |||
| 4866e6b858 | |||
| 32b92a8894 | |||
| e06cd8e8b7 | |||
| 9832dcee63 | |||
| bd9da4c911 | |||
| 848dc0aa7f | |||
| 5dfb15e6a2 | |||
| d1b3563183 | |||
| 468af53f51 | |||
| 81a901144e | |||
| 1a35d515d9 | |||
| 701a8a6841 | |||
| 78a578bf44 | |||
| 33c8e8274b | |||
| 80a8013642 | |||
| 1f13eb18ab | |||
| 603a835f60 | |||
| ed563b9ca0 | |||
| a1c4273a8b | |||
| 84230673b9 | |||
| 3cb5331950 | |||
| 7fabc792b2 | |||
| 2a50ddcf12 | |||
| de261e485d | |||
| e01420178f | |||
| abeb09f588 | |||
| 862fc3f5a9 | |||
| c920ab4b83 | |||
| 5577812a16 | |||
| 6ec9ec7056 | |||
| 5255f68900 | |||
| 58c4af28ea | |||
| bc0fcc4719 | |||
| ea9e5062e8 | |||
| c8746b1ca1 | |||
| 503c06327e | |||
| 064f22a0a0 | |||
| ea791c9f30 | |||
| 965938230a | |||
| 2bff962e48 | |||
| 6e205e9382 | |||
| 1307a02b92 | |||
| c0b544d233 | |||
| 578a144231 | |||
| 1921a067a1 | |||
| bd95a05c30 | |||
| 758229bda9 | |||
| 56acb8f3ac | |||
| ab1c47ebcc | |||
| 20a4a9ae47 | |||
| 3e869b9b39 | |||
| 8c5c23ae72 | |||
| 59d2e442e6 | |||
| a2b319f250 | |||
| 7622eb0402 | |||
| e90bb3d6a4 | |||
| 85bcc23a6b | |||
| 626bbdcc83 | |||
| 5cf225481c | |||
| e03b2afd8c | |||
| 37b4a557c2 | |||
| f5cf69a1ac | |||
| ef13ca7ea9 | |||
| 8e66a362f7 | |||
| 15f0c16782 | |||
| 28e0ea2442 | |||
| 1fb02a24e9 | |||
| 6d6b3cf59c | |||
| f90f7b036c | |||
| 9026acd7dc | |||
| 4e1f09d231 | |||
| 7834d751d0 | |||
| 69a5b5fadb | |||
| bfeda5b5e9 | |||
| eef2fdc5df | |||
| bc071c6f41 | |||
| 84c2cc11a4 | |||
| f958138e83 | |||
| e69ca4c82c | |||
| e7caa500e2 | |||
| 157a27f3bb | |||
| 59a4bc3d14 | |||
| 620b426ede | |||
| f31f7caf99 | |||
| 41bca375d2 | |||
| 84ee6dee84 | |||
| c5b2b3fade | |||
| 5a025b6e5d | |||
| d9ceaecf20 | |||
| 2a9bc44abf | |||
| 6edf4ac9de | |||
| eb956b2cdf | |||
| 8edd3fb2cd | |||
| 942af99856 | |||
| 0b3e84b3be | |||
| 296c765337 | |||
| 429d767737 | |||
| 91354e4e10 | |||
| 4b06093d6c | |||
| ea9b6fbcd9 | |||
| daf7ab3268 | |||
| 511aa4899c | |||
| c26b34e854 | |||
| 33ed7ecdfc | |||
| e0a33ecf93 | |||
| 0721cf60aa | |||
| ba4a138692 | |||
| 483683b834 | |||
| 7b49e580c0 | |||
| e17e1067a1 | |||
| 21a19ab374 | |||
| e949edb62a | |||
| ecc22b34fc | |||
| d4b40b0e64 | |||
| 1471e0a2c6 | |||
| e9adb36577 | |||
| 172f3180e5 | |||
| 8f792bc697 | |||
| 5b2e3ba82d | |||
| 39fcf2631d | |||
| 72b6b54719 | |||
| 2af445cfc2 | |||
| 8b0f66b1a6 | |||
| 9d9e4ee533 | |||
| 56f380534c | |||
| b6165ff438 | |||
| 9bdd312928 | |||
| 25cb0a6e0c | |||
| d553d142c3 | |||
| 6ad58a6553 | |||
| 702040d1a3 | |||
| 4be2b4f287 | |||
| dba706b59c | |||
| 6af4673534 | |||
| bf2fbd6c5e | |||
| 80aa2b216d | |||
| a052f3eb28 | |||
| b671fb629a | |||
| 61122ce437 | |||
| 4351e2be73 | |||
| 17dcff41c3 | |||
| fa4a7e220b | |||
| 658086e2c0 | |||
| 9dada0be9d | |||
| a3f4cc323f | |||
| 0edb9d397a | |||
| 484900d300 | |||
| e302247022 | |||
| 1489aab95d | |||
| c2dd4a1cf4 | |||
| 1801289277 | |||
| 7462cad166 | |||
| 3cbe8aab91 | |||
| 707f8f7317 | |||
| 10fbc5441e | |||
| 114fc8b1b3 | |||
| 4f1ab21562 | |||
| 2be89c47d8 | |||
| 7c60199680 | |||
| 2e028d38f2 | |||
| c2e9f5396c | |||
| d2cb8770df | |||
| 80bc94c42d | |||
| bb69ecf0da | |||
| 014fc2bfa7 | |||
| f3e06eac89 | |||
| a0a5cf7ecc | |||
| bc4683d1f5 | |||
| 3645e574d3 | |||
| d05114e895 | |||
| 0477d9534b | |||
| 6d0f3c9044 | |||
| 02c3d06ec6 | |||
| 573e505a4b | |||
| ce2dda7d45 | |||
| bfe423ddf8 | |||
| 9c2824ce8a | |||
| 5853c3f8ff | |||
| a57a5cea72 | |||
| 8ced49a515 | |||
| 77f9260189 | |||
| 45073486a7 | |||
| dd540496a1 | |||
| c634cab35e | |||
| 1fa91cf73d | |||
| 3a104fcce4 | |||
| a8cca51164 | |||
| b1109e9fe9 | |||
| 2f5abb6a55 | |||
| a1a9212ba1 | |||
| 7adcfff0fa | |||
| 5eb01a0096 | |||
| 48805cb9fb | |||
| 4c135075b3 | |||
| 82f4ec4910 | |||
| 89ff916bc8 | |||
| e5871cccd2 | |||
| 781579bf36 | |||
| acc419a29b | |||
| de7273598e | |||
| ce630a37ef | |||
| dfda59fec2 | |||
| 15b9b02e96 | |||
| cce7562d07 | |||
| 2958dc8231 | |||
| a5ebf72f17 | |||
| 4516761dbe | |||
| b6e4cc3f3b | |||
| c31cc1adad | |||
| 6bb64ca938 | |||
| 152fa264ed | |||
| 6023d11bfb | |||
| b12bf6ce79 | |||
| b24e224beb | |||
| 09e77f11f8 | |||
| 0cc89fb03c | |||
| 20fd899d93 | |||
| 486809b08b | |||
| 58cb55b869 | |||
| 86fb4d5536 | |||
| 32d7869043 | |||
| 966d548245 | |||
| c2fcd5b1f8 | |||
| 0ed9dc107c | |||
| c4eda119ac | |||
| cd1a9520ff | |||
| 0aa7202c87 | |||
| 4841d901af | |||
| 30d7ffd94a | |||
| 037e7a011e | |||
| 0e2f734b0b | |||
| 7ccb25e8f1 | |||
| 42d16919fc | |||
| 005a90ca19 | |||
| 8a824e2000 | |||
| 8495cea5dc | |||
| abca723d3d | |||
| 63b85508c7 | |||
| 4e06d5e801 | |||
| 4e5a91d802 | |||
| 9e2d8ee43c | |||
| d1520cc0a5 | |||
| ab496bb298 | |||
| cd8bebba00 | |||
| 195454d2e3 | |||
| 42e429eb58 | |||
| 76e7d9594c | |||
| d8e8fd8124 | |||
| e5dbcf5d33 | |||
| 61231f7f6e | |||
| 4534444ad8 | |||
| 9a8f5cea5a | |||
| 637ac51754 | |||
| ac7c86fdee | |||
| fd7d9d330b | |||
| afd6101c08 | |||
| 726f26d8bd | |||
| c9fdff9d55 | |||
| a65b504a3d | |||
| 8c9e1179bc | |||
| b693d442f5 | |||
| 64d0fac879 | |||
| 8b8f396486 | |||
| b8f738f8cb | |||
| 91671a77df | |||
| f92065bc0b | |||
| f17044dead | |||
| f9995ef61b |
+66
-6
@@ -2,22 +2,39 @@
|
||||
# 键名清单 = M1 设计文档 §8 定稿;缺关键配置直接报错,不做默认值兜底。
|
||||
|
||||
# ══ 多源配置: {SCOPE}__{PROVIDER}__{N}__{FIELD} ══
|
||||
# PROVIDER 必须是注册表键(qwen/deepseek/openai,或 register_provider 注册后经 registry 传入)。
|
||||
# PROVIDER 必须是注册表键(八段: qwen/deepseek/zhipu/moonshot/minimax/openai/anthropic/google,
|
||||
# 或 register_provider 注册后经 registry 传入)。
|
||||
# 必填: BASE_URL / API_KEY / MODEL / TIMEOUT_S(或用平铺 LLM_TIMEOUT 作缺省)。
|
||||
LLM__QWEN__1__BASE_URL=
|
||||
LLM__QWEN__1__API_KEY=
|
||||
LLM__QWEN__1__MODEL=
|
||||
LLM__QWEN__1__TIMEOUT_S=120
|
||||
# 可选(0 = 该闸不启用;TPM > 0 时 EST_TOKENS 必填 > 0):
|
||||
# 可选(0 = 该闸不启用):
|
||||
# LLM__QWEN__1__MAX_CONCURRENCY=8
|
||||
# LLM__QWEN__1__RPM=60
|
||||
# LLM__QWEN__1__TPM=100000
|
||||
# LLM__QWEN__1__EST_TOKENS=2000
|
||||
# LLM__QWEN__1__EST_TOKENS=2000 # 可选调优覆盖: TPM 入场预扣量;未填则库按 tpm//60 派生
|
||||
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
|
||||
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
|
||||
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
|
||||
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不表态 / true=要求开启 / false=要求关闭
|
||||
# 本键是 REASONING_EFFORT 的语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态
|
||||
# "要求开启"注入什么随 provider 段而定: openai/anthropic/google 三段的开启形态是
|
||||
# on_base={}——一个字节都不注入,走模型自己的默认档(该默认档若不推理,本键不会报错
|
||||
# 也不会开推理,见 CHANGELOG 1.3.3「已知限制」/ issue #21);要确保开启请配 REASONING_EFFORT
|
||||
# LLM__QWEN__1__REASONING_EFFORT=low # 本源默认推理档位;缺省=不表态(随模型自己的默认档)
|
||||
# 八档(封闭词汇): none | auto | minimal | low | medium | high | xhigh | max
|
||||
# none = 要求不推理(与"缺省不表态"是两回事);auto = 要求推理但不指定强度
|
||||
# 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low),
|
||||
# 不做"后者赢"的静默兜底——两个键说同一件事,矛盾就是配置错误
|
||||
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
|
||||
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
|
||||
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
|
||||
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
|
||||
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
||||
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
||||
# 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传
|
||||
# 禁用键 model/messages/stream/stream_options(会击穿治理),配了直接报错
|
||||
# OCR/EMBED scope 不消费该键: 配了会被忽略并 warning(见 issue #4 决策 G)
|
||||
|
||||
# ══ scope 级全局闸(跨源合计;0/缺省 = 不启用)══
|
||||
# LLM__GLOBAL__MAX_CONCURRENCY=8
|
||||
@@ -34,7 +51,7 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
|
||||
# LLM_TTFT_TIMEOUT=30 # 平铺看门狗缺省(成对生效)
|
||||
# LLM_INTER_TOKEN_TIMEOUT=15
|
||||
# LLM__BREAKER__PROBE_TTL_S=240 # 缺省派生: max(2×最大源超时, cooldown, 最大源超时+5);显式值须 ≥ 最大源超时+5
|
||||
# LLM__BACKPRESSURE__STALL_WINDOW_S=300 # stall 双条件判死窗口;须 ≥ 最大源 TTFT
|
||||
# LLM__BACKPRESSURE__STALL_WINDOW_S=300 # stall 双条件判死窗口;只计非生产性等待(429 退避/配额轮询/熔断冷却),与 TIMEOUT_S 无耦合,无需按 timeout×retries 放大
|
||||
# LLM__BACKPRESSURE__POLL_INTERVAL_S=0.05
|
||||
# LLM__SELECTOR=health_aware # health_aware(默认,M2.5) | round_robin | least_inflight
|
||||
# ── M2.5 失败率熔断通道(可选,缺省即生产推荐值)──
|
||||
@@ -44,7 +61,12 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
|
||||
# LLM__BREAKER__MAX_COOLDOWN_S=300 # 开路指数退避封顶(缺省 max(300, cooldown))
|
||||
# ── AIMD 自适应并发(M2.5,库常量非 env 键): 每源初始 8,429 ×0.5,成功 +1/limit,
|
||||
# ── ceiling = max(64, 源级 MAX_CONCURRENCY);禁用需构造函数注入自定义 pacer ──
|
||||
# LLM__QUOTA_FULL=wait # wait(默认) | fail_fast
|
||||
# LLM__QUOTA_FULL=wait # 配额满: wait(默认) | fail_fast
|
||||
# ── 熔断全拒时的处置(issue #14)。单源 scope 建议 wait: 只有一个源时
|
||||
# ── "停用这个源"等于"整个 scope 停服",fail_fast 会让开路期间的每次调用
|
||||
# ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样
|
||||
# ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ──
|
||||
# LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait
|
||||
|
||||
# ══ 装配选择(PGW_*)══
|
||||
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
|
||||
@@ -52,8 +74,46 @@ PGW_BREAKER_BACKEND=memory # memory | redis
|
||||
PGW_CACHE_BACKEND=none # redis | memory | none(必填,显式优于隐式)
|
||||
PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
||||
# PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
|
||||
# PGW_TELEMETRY_SCHEMA_MODE=manual # auto | manual;三态: 不设 = 按后端派生(sqlite→auto、postgres→manual),
|
||||
# # 显式设置则两侧都可覆盖。auto = 库给已存在的旧表自动 ALTER 补列;
|
||||
# # manual = 库不发 ALTER,只 warning 点名缺列并打印可执行 SQL,
|
||||
# # 按现有列裁剪 INSERT 继续写(遥测不会因缺列而全线丢失)。
|
||||
# # 缺省为何不对称: postgres 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁,
|
||||
# # 会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 await;
|
||||
# # 且这类部署有 DBA、有迁移工具、讲最小权限,DDL 该由他们择时执行。
|
||||
# # sqlite 则是下游自己的本地文件(runs/*.db):没有 DBA、没有迁移工具、
|
||||
# # 没有第二个系统碰它,ALTER 是毫秒级元数据操作,强加手工 SQL 步骤是净损失。
|
||||
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
|
||||
# PGW_TELEMETRY_PG_POOL_MAX=4 # postgres 遥测池的连接上限,须 >= 1;缺省 4。**闲时占 0 条**——
|
||||
# # 池按需建连(min_size=0),不预占;这一格是忙时的天花板,不是常驻量。
|
||||
# # 调参口径(以实测为准,不要按 pool_max/RTT 估算):跨内网 RTT ≈ 123ms 的
|
||||
# # 实验室 PG 上,pool_max=4 实测约 **15.6 行/秒**(50 行并发批耗时 3.2s),
|
||||
# # 即每条连接约 4 行/秒 —— 一次 INSERT 的实际往返比一次 `SELECT 1` 重一倍,
|
||||
# # 按单次 RTT 估会乐观一倍。要放大就按这个实测值线性折算(pool_max=8 ≈ 31 行/秒)。
|
||||
# # 缺省 4 在缺省 5s 预算下能吞下约 50 行的突发(余量约 1.5 倍);超预算的行被丢弃
|
||||
# # 并计入 telemetry_status.dropped_rows —— 丢一条遥测好过拖垮业务调用。
|
||||
# # 注意告警口径: 池饱和丢的行走**行级丢弃**,telemetry_status.degraded 保持
|
||||
# # False(后端并没有挂,是本进程并发超了),只有 dropped_rows 增长。只按
|
||||
# # degraded 告警会完全看不见这一类丢行 —— 对账要两个字段一起看。
|
||||
# # 什么时候该调大: 单进程遥测写入并发经常超过 4(高频短调用、批量并发),
|
||||
# # 或多个 client 显式共享同一个 recorder(并发在这里汇聚,应按 client 数放大)。
|
||||
# PGW_TELEMETRY_PG_WRITE_TIMEOUT_S=5.0 # 一次遥测写入的硬预算(秒),须 > 0;缺省 5.0。同时用作建连、
|
||||
# # acquire 与「准备 + 取连接 + 执行」整段的上界:超时即丢弃该行,
|
||||
# # 绝不让遥测无界地挂在业务路径上。实测参考: 稳态写入 123ms、
|
||||
# # 首次写入含建连 513ms —— 5s 对正常路径是极宽松的上限,它防的是
|
||||
# # 池满排队与后端假死这类"不会自己结束"的等待。
|
||||
# # 与之配套的两个不可配内部常量: 连接释放上界 1s(超时即 terminate)、
|
||||
# # 环境级降级的冷却期 60s(到期自动重试一次,成功即恢复)。
|
||||
# PGW_TELEMETRY_TEXT_CAP=2000 # 遥测落库正文的字符上限,须 > 0;**不设 = 不截断**(缺省,逐字节留全文)。
|
||||
# # 作用于 messages 的每条文本 content、多模态 text part、response 与 thinking;
|
||||
# # 超出部分头部保留、尾部换成 `…(略 N 字)`。多模态 image_url 的 sha256 摘要不受影响。
|
||||
# # 缺省为何是"不截断": 遥测被下游当**审计证据**用——出了问题要回答"当时到底发了什么",
|
||||
# # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。
|
||||
# # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里,
|
||||
# # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。
|
||||
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
|
||||
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
|
||||
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
|
||||
# PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化)
|
||||
# PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0
|
||||
# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略)
|
||||
|
||||
+622
@@ -1,5 +1,627 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.3(2026-09-05)
|
||||
|
||||
推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。
|
||||
|
||||
**版号是 patch(2026-09-05 人类指令,不因破坏性变更走 minor),但本版含五处破坏性变更与四条行为变更。** patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故全部置于最前。
|
||||
|
||||
### 请先读这一条(一):五处破坏性变更
|
||||
|
||||
| # | 位置 | 变更 | 谁会当场断 |
|
||||
|---|---|---|---|
|
||||
| 1 | `ThinkingCapability` | 构造签名 `can_disable: bool` → `supported_efforts: tuple[Effort, ...]` | 自建能力表的调用方(**关键字与位置两种构造都断**) |
|
||||
| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 |
|
||||
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort`(25 → 26 参) | 任何自建 recorder 实现 |
|
||||
| 4 | `thinking.resolve_thinking()` | 第三参数由 `bool` 换成 `Effort`,**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 直调它的读侧代码一律断 |
|
||||
| 5 | `providers.ProviderProfile` | 两个字段 `thinking_on` / `thinking_off` → 单字段 `thinking: ThinkingWire` | 自建 profile 的调用方 |
|
||||
|
||||
第 1 条的 `can_disable` **保留为只读派生属性**(`Effort.NONE in supported_efforts`),只读它的代码一行不用改;**构造则两种写法都断**:
|
||||
|
||||
| 1.3.2 的写法 | 升级后 |
|
||||
|---|---|
|
||||
| `ThinkingCapability(can_disable=True, evidence="…")`(库自己那张表用的就是它) | `TypeError: ... got an unexpected keyword argument 'can_disable'` |
|
||||
| `ThinkingCapability(True, "…")` | `TypeError: 'bool' object is not iterable`——断在 `__post_init__` 的去重校验里,错误信息看不出真实原因 |
|
||||
| 迁移写法 | `ThinkingCapability(supported_efforts=(Effort.NONE, Effort.AUTO), evidence="…")` |
|
||||
|
||||
第 2、3 条按这两个端口的既有纪律**不设默认值**:库外没有第三方实现者,带默认值只会让漏传时静默落一个默认值。第 4 条的新返回值是 `ThinkingResolution(payload, applied_effort)`——原来那个 mapping 现在是 `.payload`,多出来的 `.applied_effort` 是开了 `nearest` 映射后**真正发出去**的那一档。
|
||||
|
||||
### 请先读这一条(二):不改一行代码也会变的四条行为
|
||||
|
||||
| # | 变更 | 影响 |
|
||||
|---|---|---|
|
||||
| 1 | `glm-5.3` / `glm-5.3-flash` / `gemini-3.1-pro` **首次进入能力表**,且三者都登记为**关不掉推理** | **本版唯一会打断存量配置的一条。** 1.3.2 里这三个型号未登记,给它们配 `ENABLE_THINKING=false` 会按 provider 形态尽力注入并**放行**(只发一条 warning);本版在**装配期**抛 `ThinkingUnsupportedError`。并排实测:`deepseek/glm-5.3 + ENABLE_THINKING=false` 在 1.3.2 返回 `{"thinking": {"type": "disabled"}}`,在本版当场报错 |
|
||||
| 2 | `openai` 段的**开启**方向由「形态未知即装配期报错」放宽为 `on_base={}` | 把任意兼容厂商挂在 `openai` 段下并配 `ENABLE_THINKING=true` 的下游:1.3.2 在装配期报错,本版放行且**一个字节都不注入**——走模型自己的默认档。若该模型默认不推理,这个配置既不报错也不开推理(见下方「已知限制」) |
|
||||
| 3 | `openai` 段的**关闭**方向由「形态未知即装配期报错」放宽为 `{"reasoning_effort": "none"}` | 同上但配 `ENABLE_THINKING=false` 的下游:1.3.2 在装配期报错,本版下发这个片段。放宽的依据是 `reasoning_effort` 是 OpenAI **官方**字段而非厂商方言,经网关的兼容端点不会把它打到不认识它的厂商 |
|
||||
| 4 | 缓存 key 加入 `reasoning_effort` | 只有**新配** `REASONING_EFFORT` 的源冷启动一次;只配 `ENABLE_THINKING` 或什么都没配的源,key 字面量逐字不变(已按 1.3.2 的实现逐字比对) |
|
||||
|
||||
第 1 条是设计上有意为之:调用方要的是「不推理」的语义保证,给不了就必须说,而不是让它继续静默烧推理 token——升级后当场失败,正是这三个型号本来就关不掉推理的证据。报错文案带一条能立刻照做的替代(该模型最省的那一档 + 该配的 env 键名),不把人推回 `extra_body` 那条绕过库的路。
|
||||
|
||||
**`qwen` / `deepseek` / `minimax` 三段的注入形态逐字未变。** 全量比对(4 个 1.3.2 已有的 provider 段 × 25 个模型 × `ENABLE_THINKING` 三态 = 300 种组合)显示,本版与 1.3.2 的差异**只有上表第 1、2、3 条**。`minimax` 的「开」尤其值得点名:它维持 `{"reasoning_effort": "medium"}` 逐字不变,因为真实网关实测显示 MiniMax-M3 在不带任何推理参数时**不推理**(5/5 轮),把它改成「不注入即为开」会让存量 `ENABLE_THINKING=true` 的调用静默停止推理。
|
||||
|
||||
### 新增能力
|
||||
|
||||
| 新增 | 说明 |
|
||||
|---|---|
|
||||
| 八档 `Effort`:`none` / `auto` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max` | 封闭词汇,取四家参考实现共同收敛的那一套。`none` = 要求不推理(与「不表态」是两回事),`auto` = 要求推理但不指定强度 |
|
||||
| `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT` | 源级默认档。`ENABLE_THINKING` 保留,降为它的语法糖(`true` ≡ `auto`、`false` ≡ `none`、缺省 ≡ 不表态);两键语义矛盾(如 `true` + `none`)在**装配期**报错,不做「后者赢」的静默兜底 |
|
||||
| `{SCOPE}__{PROVIDER}__{N}__EFFORT_FALLBACK` | `error`(缺省,报错)或 `nearest`(映射到最近档并 warning)。默认报错的理由是钱:一次静默的 `medium → max` 在部分模型上是数倍账单 |
|
||||
| `chat(reasoning_effort=...)` | 请求级覆盖,优先级高于源级;裸字符串会在入口归一 |
|
||||
| `LLMResponse.applied_effort` | 本次**实际**跑在哪一档(开了 `nearest` 时与请求档分叉)。字段追加在末尾,既有字段只增不改名 |
|
||||
| 四个新 provider 段 `zhipu` / `moonshot` / `anthropic` / `google` | 连同 1.3.2 已有的 `qwen` / `deepseek` / `minimax` / `openai` 共**八段**。四段都是新增,不改变任何存量配置的行为 |
|
||||
| 能力表由 **5 条扩到 24 条** | 1.3.2 只登记 5 个型号,其余一律走「按 provider 形态尽力注入 + warning」。本版新登记 19 个:qwen 4 款、deepseek 2 款、GLM 6 款、kimi 2 款、gpt 2 款、claude 2 款、gemini 1 款 |
|
||||
| `kimi-k3` **首次登记**为可关闭 | 它在 1.3.2 未登记(配 `false` 走尽力注入 + warning,不报错)。本版实测坐实可关:请求 `none` 后短提示词 5/5 轮 + 长上下文 3/3 轮无任何推理信号、completion 恒 9 token,与同模型 max 档(rt 33-146)的锚点可分。两源分歧由此了结——OpenRouter 的 `mandatory:false` 是对的,官方档位表没列 `none` 只是没列 |
|
||||
| 包根新增导出 `Effort` / `EFFORT_ORDER` / `ThinkingWire` / `ThinkingResolution` | 深路径 import 会被内部重组打断,一律从 `polygateway` 包根取 |
|
||||
|
||||
档位不支持时**报错必带可执行替代**:模型关不掉推理时,错误文案直接给出该模型最省的那一档和该配的 env 键名。只报错不给出路,下游只会退回 `extra_body`——而那正是 issue #20 的成因。
|
||||
|
||||
### 遥测:第 26 个 INSERT 字段 `reasoning_effort`
|
||||
|
||||
`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27)。列可空,`NULL` 表示调用方**没表态**;它与 `'none'`(明确要求不推理)是两回事,折叠成任一档都等于替上游声称了一件它没说过的事。加这一列是为了让「不同档位是不是真有用」这类压测在数据侧能分组——此前 25 列里没有任何一列能回答「这一行跑在哪档」。
|
||||
|
||||
**成功行与失败行不是同一把尺子。** 开了 `EFFORT_FALLBACK=nearest` 的源上,成功行记的是**映射后的实发档**(读 `response.applied_effort`);失败尝试没有响应、实发档无从得知,记的是**请求档**。故 `GROUP BY reasoning_effort` 不带 `error IS NULL` 时,两种尺子会混进同一个分组。缓存命中行与终态失败行同样只记请求档——它们手上没有选中源,源级档位与 `nearest` 映射都无从谈起。embedding / OCR 两条路径没有推理语义,该列恒 `NULL`。
|
||||
|
||||
补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE`,两端 DDL 与 `COLUMNS` 同源。**manual 档的下游会看到一处文案变化**:旧表的缺列告警会多点名 `reasoning_effort` 这个维度,并附上对应的 `ALTER TABLE ADD COLUMN` 语句。
|
||||
|
||||
### 能力表口径:24 条里 17 条经 new-api 实测、7 条仍是文档推定
|
||||
|
||||
`DEFAULT_CAPABILITIES` 共 24 条,每条 `evidence` 自报家门(实测日期、轮数 N、判据、锚点,或「文档推定」及其四方出处)。**读能力表请以逐条 evidence 为准,本版不存在「能力表已全部实测」这回事。** 未能实测的 7 条与原因:
|
||||
|
||||
| 模型 | 未覆盖的原因 |
|
||||
|---|---|
|
||||
| `claude-opus-5`、`claude-sonnet-5` | 该渠道 claude 全系返回 429「api key 7 天限额已用完」,5/5 轮失败;`none` 档还额外依赖网关把 `reasoning_effort=none` 转成 `thinking` 关闭形态,同样未经验证 |
|
||||
| `gemini-3.1-pro` | 该渠道本型号上游报错(`bad_response_status_code` / `openai_error`),5/5 轮失败,连默认档基线都没取到。默认档「官方文档说 high、OpenRouter 说 medium」两源打架**仍未决**,本版不选边 |
|
||||
| `gpt-5.4` | 全账号限流(429 All available accounts are currently rate-limited),5/5 轮失败。同代的 `gpt-5.5` 已实测且与清单逐字相符,可作旁证但不是本型号的证据 |
|
||||
| `glm-5`、`glm-5.1`、`glm-5.2` | 请求这三个型号时,渠道 5/5 轮把流量路由到 `glm-5.3`(issue #20 记录的 6/6 复现);拿到的行为不属于本型号,整组数据作废 |
|
||||
|
||||
`glm-5.2` 的下游风险要单独说:在这条渠道上给它配 `none`,库会照文档推定放行,而真正服务请求的 `glm-5.3` **关不掉推理**;运行期对账会喊,但那是事后。
|
||||
|
||||
另有两条与实测相关的收获值得下游知道:同一批实测发现 `zhipu` / `moonshot` 这条渠道**不校验档位值**(未登记的 `medium` 也照单收下并返回 200),故「网关没报错」在这两家上**不构成**「该档受支持」的证据;而 `openai` 那条会校验(清单外的 `max` / `minimal` 被上游 400 拒)。
|
||||
|
||||
### 已知限制:`auto` 不等于「强制开推理」(issue #21)
|
||||
|
||||
`reasoning_effort=auto`(含它的语法糖 `ENABLE_THINKING=true`)在 `on_base={}` 的三个 provider 段(`openai` / `anthropic` / `google`)上表达的是「**用模型自己的默认档**」,库不注入任何字节。若某模型默认就不推理,这个配置**既不报错也不开推理**。正解是让 `auto` 受能力表约束——模型不支持「由模型自定」时报错并指路显式档位,属公共行为变更,留到下一版(gitea issue #21)。
|
||||
|
||||
与之相连有一处**刻意的不一致**,请勿误读:`DEFAULT_CAPABILITIES` 里 `MiniMax-M3` 的 `supported_efforts` **不含 `auto`**(实测结论——它的默认档不推理),而 `minimax` 段的 wire 会为 `auto` 注入 `{"reasoning_effort":"medium"}` 并被放行。`resolve_thinking` 的 Phase 5 对 `auto` 无条件放行(`auto` 不是写进 `effort_key` 的取值,而是「不写 `effort_key`」),**能力表拦不住这条路**;当前是由 wire 侧的权宜之计兜住的。别把它读成「能力表能挡住 auto」。
|
||||
|
||||
## 1.3.2(2026-08-28)
|
||||
|
||||
**本版不改库代码。** `tools/` 与 `tests/` 都不在 pip 包内(脚本随仓库分发,见 README),故 1.3.2 的 wheel 与 1.3.1 **除版本号外没有任何差异**(`__version__` 与包元数据是唯一的改动)。升级它不会改变任何库行为——本版的内容是运维脚本 `tools/telemetry_retention.py` 的一处契约扩展,以及测试隔离的重建。若你只用库本体,可以跳过本版。
|
||||
|
||||
### 运维脚本:`--table` 让删除目标不再由连接环境决定(issue #18)
|
||||
|
||||
`tools/telemetry_retention.py` 此前删哪张表,取决于连接的 `search_path`——它的首项是 `"$user"`,所以**换个角色跑同一条命令,目标可能就换了一张表**。脚本会把解析到的限定名打出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
|
||||
|
||||
新增可选参数 `--table <schema>.llm_calls`:给了它,目标由参数精确解析(`to_regclass` 走引号限定名),绕开 `search_path`。
|
||||
|
||||
| 情形 | 行为 |
|
||||
|---|---|
|
||||
| 不给 `--table` | **与 1.3.1 完全一致**,现有 cron 不受影响;但 `--apply` 时会多打印一行,提示目标是推断来的 |
|
||||
| 表名段不是 `llm_calls` | 退出 **1**。本脚本只清理遥测表,不是通用清理器——一次 `--table audit.events` 的手误,会对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE |
|
||||
| 显式指定的表不存在/不可见 | 退出 **2**,消息附一句"PG 中未加引号建的标识符在 catalog 里是小写"(大小写手误是这里的高频原因) |
|
||||
| 显式指定的是分区表 | 仍退出 **3** 让路给 `DROP PARTITION`,语义未变 |
|
||||
|
||||
退出码契约未新增也未改动。**建议 cron 一律带上 `--table`**:那一行配置从此自己说明删的是哪张表。
|
||||
|
||||
### 测试隔离:从"事后观测共享表"改成"权限上做不到"
|
||||
|
||||
issue #18 报的是一条 PG 集成测试偶发红。查下来失败的断言并不在测被测脚本——它比对的是一张**三个迁移项目也在写**的表的前后行数,而报错时(61 行变 12 行)脚本本身被证明只动了自己的临时 schema。
|
||||
|
||||
行数快照承载不了它想守的属性:别人一写就假红,而外部插入恰好抵消掉一次误删时又会假绿——后一半守的正是"审计表被删空"。现在这条属性交给数据库强制:跑脚本的测试角色拥有自己的临时表、对共享表**没有任何授权**,`search_path` 万一落空就是 `permission denied` 而不是"但愿有断言发现"。共享表 `llm_calls` 至此不再被本仓库任何测试读写,killed 的测试也不会再往里留孤儿行。
|
||||
|
||||
对下游没有影响(测试不进包),列在这里是因为它解释了本版为何存在。
|
||||
|
||||
## 1.3.1(2026-08-26)
|
||||
|
||||
「这次调用到底推理没推理」从此是库的**一等返回值**(issue #16 + #17): `LLMResponse.thinking_observation` 三态如实作答,判不出来时说 `unknown` 而不是伪装成「没推理」,并与推理能力表持续对账。
|
||||
|
||||
**版号是 patch,但本版含三处会影响下游的变更**——深路径 import 断裂、端口签名扩参、一条新告警。patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故三条置于最前。
|
||||
|
||||
### 请先读这一条(一): `polygateway.providers` 的深路径 import 断了
|
||||
|
||||
推理相关的**六个符号**从 `providers.py` 移进新模块 `polygateway.thinking`。`from polygateway.providers import ...` 引用其中任何一个,升级后当场 `ImportError`:
|
||||
|
||||
| 从 `providers` 断掉的符号 | 改成(**推荐**) | 或 |
|
||||
|---|---|---|
|
||||
| `ThinkingCapability`、`ThinkingUnsupportedError` | `from polygateway import ...` | `from polygateway.thinking import ...` |
|
||||
| `get_capability`、`register_capability`、`resolve_thinking` | `from polygateway import ...` | `from polygateway.thinking import ...` |
|
||||
| `DEFAULT_CAPABILITIES` | `from polygateway.thinking import DEFAULT_CAPABILITIES` | — |
|
||||
|
||||
**前五个请改用包根 import**: 它们此前只能深路径引用,而深路径引用正是模块重组会打断下游的原因——本版一并把它们提升到包根导出(连同本版新增的 `ThinkingObservation`,共六个新导出),给的就是一个此后不会因内部重组而变的引用点。`DEFAULT_CAPABILITIES` 有意不进包根: 它是可变注册表的当前快照,不是稳定 API 面。
|
||||
|
||||
`providers.py` 保留的 `ProviderProfile` / `DEFAULT_PROFILES` / `get_provider` / `register_provider` 逐字未动。
|
||||
|
||||
拆分本身不是顺手重构: 推理这件事从「请求侧注入什么参数」长成了「注入 + 响应侧裁定 + 两者对账」三件事,再留在 provider 注册表里,那个文件的职责就得用「和」来描述。
|
||||
|
||||
### 请先读这一条(二): `TelemetryRecorder.record_llm_call` 从 24 参变 25 参
|
||||
|
||||
新增 keyword-only 参数 `thinking_observation: str`,**且按该 Protocol 的既有纪律不设默认值**(库外没有第三方实现者,带默认值只会让 emitter 漏传时静默落一个默认值)。**自定义 recorder 实现必须同步补这个参数**,否则调用时 `TypeError`。库自带的 `SQLiteRecorder` / `PostgresRecorder` 已同步,不受影响。
|
||||
|
||||
`TelemetryRecorder` 之外的端口逐字未变;`TelemetryStatusProvider` 不受影响。
|
||||
|
||||
### 请先读这一条(三): MiniMax-M3 非流式开推理 = 付费买看不见的推理,库现在会说出来
|
||||
|
||||
2026-08-25 实测: M3 非流式开启推理时 `completion_tokens` 从 3 涨到 53(推理段确实产生并计费),而响应里既没有 `reasoning_content` 正文、也没有 `usage.completion_tokens_details`——**钱花了,东西一个字都拿不到**。这是上游行为,库修不了,但从本版起不再默不作声: 该档观测判为 `unknown`,并按 `(模型, 方向)` 发**一次** warning,说明「已注入开启参数,但本路径观测不到,推理内容可能已计费却不回传」。
|
||||
|
||||
要拿到推理正文,该模型请走**流式**路径(实测 185 字符正文完整)。
|
||||
|
||||
### 诊断纠正: 不是模型不推理,是 MiniMax 停报 `completion_tokens_details`
|
||||
|
||||
issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了这个诊断——绕开库用裸 `httpx` 抓真实响应,M3 流式开启档拿到 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
|
||||
|
||||
真正变的是 **MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**(qwen 与 deepseek 在同一网关、同一 key 上照常返回),`reasoning_tokens` 因此恒为 `None`。而库把「推理是否发生」全押在这一个字段上,于是**手里握着 185 字符推理正文,却对外报告「没推理」**。
|
||||
|
||||
缺口的形态是本版真正要修的东西: 库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`。
|
||||
|
||||
### 三态,以及它为什么不能折叠成布尔
|
||||
|
||||
`LLMResponse.thinking_observation`(类型 `ThinkingObservation`,`StrEnum`,缺省 `unknown`)由多信号裁定,判据按**证据硬度**排序:
|
||||
|
||||
| 值 | 判据 |
|
||||
|---|---|
|
||||
| `observed` | 推理正文 `thinking` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
|
||||
| `absent` | `reasoning_tokens == 0`——上游明确上报本次未推理,是正面证据 |
|
||||
| `unknown` | 两个信号双缺,判不出来 |
|
||||
|
||||
**`unknown` 与 `absent` 不是一回事**,把前者折叠进后者正是本次故障的病根。`unknown` 没有证伪力: 它不能用来声称推理关掉了,也不能用来报警「没推理」。缺省取 `unknown` 使任何填不了这个字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——默认值本身不撒谎。
|
||||
|
||||
对下游的口径变化: 统计「未推理」**不要再写 `reasoning_tokens IS NULL OR = 0`**,那个条件在供应商停报 usage 明细后会把推理了的调用一并算进去。改按 `thinking_observation` 分组,`unknown` 独立成一档。
|
||||
|
||||
### 声明 × 观测对账: 能力表过期从静默错觉变成日志里的告警
|
||||
|
||||
推理能力表(`can_disable`)是静态声明,而静态声明**必然过期**——M3 的 evidence 曾停在 8-02 整整 23 天。过期的表现是静默错觉: 库照常注入关闭参数,模型照常推理,下游拿到推理内容却以为关了,全程无人吭声。
|
||||
|
||||
本版在 transport 拿到结果处做一次比较,矛盾即 warning(**不抛错**——一次观测不足以否决一次成功的调用,矛盾结果已随响应与遥测落地,处置权归下游):
|
||||
|
||||
| 请求方向 | 观测 | 告警内容 |
|
||||
|---|---|---|
|
||||
| 关闭 | `observed` | 关闭请求未被满足。能力表已登记则点出 `evidence` 日期并指路复测更新;未登记则说明本次是按 provider 形态尽力注入 |
|
||||
| 开启 | `absent` | 已注入开启参数,上游却明确上报未推理 |
|
||||
| 开启 | `unknown` | 已注入开启参数,但本路径观测不到;若为非流式,推理内容可能已计费却不回传 |
|
||||
|
||||
`关闭 × unknown` 与「调用方没提要求」两类**有意不表态**: 前者没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。同一 `(源, 模型, 方向)` 只喊一次,文案点名出问题的源——多源多账号下同一模型跨 N 个源是常态,键漏掉源名会让第一个出问题的源喊完之后其余源永久静音,而告警也定位不到该查哪个网关。
|
||||
|
||||
**保障的覆盖面必须说清楚**: 对账只在可观测路径上成立(推理若真的发生,流式路径会带出正文,翻成 `observed` 触发告警);M3 非流式那种两个信号双缺的路径,没有任何保障——本版让它可见,但不能让它可判。
|
||||
|
||||
### 遥测新增一列 `thinking_observation`
|
||||
|
||||
`llm_calls` 加一列 `thinking_observation TEXT`(可空,取值 `observed` / `absent` / `unknown`),排在最末,SQLite 与 Postgres 两端 DDL 与补列语句同步。旧表按既有 backfill 路径补列: sqlite→auto 档自动补,postgres→manual 档点名缺列并给出可执行 SQL、同时按现有列裁剪 `INSERT` 继续写(不补列不会让遥测整体失效,只是少这一列)。补列失败仍只逐行降级、绝不判死。
|
||||
|
||||
照 README「生产部署 DDL 模板」部署的下游**不需要改模板**: 那份模板用 `LIKE llm_calls_seed` 从库自己建出的表派生列,与 `telemetry/schema.py` 同源,不存在手抄漂移(本版加了一条测试断言把这个同源性钉死)。
|
||||
|
||||
### 其他
|
||||
|
||||
- 缓存回放的 `thinking_observation` 是 `ThinkingObservation` 枚举实例而非裸字符串: JSON 复活出来的是 `str`,与字段注解分叉,`CacheMW._rehydrate` 现在显式转换。取值不在本版三态值域内时(多个项目共用同一 Redis、先升级的那个写入了新态)**降级为 `unknown` 并单独告警,响应内容照常复活**——一个纯可观测性字段不该有能力作废内容完好的缓存,否则未升级的项目会在这些 key 上每次真打网关、随后覆写回旧值,两个版本互相打对方的缓存;「整条作废」只留给真正破坏内容完整性的失败。
|
||||
- M3 的推理能力 `evidence` 刷新到 2026-08-25 复测。`can_disable` **仍为 `True`**(`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立),同时补记两条限制: 推理信号在非流式路径不可观测;`enable_thinking` 与 `thinking={"type":"enabled"}` 对该模型无效,只有 `reasoning_effort` 是真开关。
|
||||
- `TransportResult` 同步新增该字段并由 `RetryMW` 透传;裁定在 `openai_compat` 的流式与非流式**两条**组装路径各做一次。
|
||||
- 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。
|
||||
|
||||
|
||||
## 1.3.0(2026-08-24)
|
||||
|
||||
遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。
|
||||
|
||||
根因不是"asyncpg 的默认 `min_size=10` 太大"这一条,而是四层叠加,只改默认值会留下三层:
|
||||
|
||||
| # | 缺陷 | 本版 |
|
||||
|---|---|---|
|
||||
| ① | 库对自己的资源占用从未表态 —— `create_pool(dsn, timeout=10)` 继承第三方默认值,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限":要么一次拿到 10 条,要么建池失败。这是全库唯一一处预占资源的组件 | `min_size=0` + `max_size` 可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4)+ 每次写入硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5.0s) |
|
||||
| ② | 判死判据挂在"**哪一步**失败"(建池失败即永久判死),而那一步里同时藏着 DSN 写错(进程内不可能改变)与 `too many clients`(下一秒可能就好) | 判据改挂"失败是**什么性质**",永久失能收窄到只剩 DSN 不可解析一类,其余一律 60s 冷却后自动重试 |
|
||||
| ③ | 降级不可恢复也不可见 —— 全程只有一条 warning,SQLite 侧连 warning 都没有 | 进入/恢复各一条日志 + 降级期间节流复述 + `client.telemetry_status` 只读快照 |
|
||||
| ④ | "多个 client 共享一个 recorder"这条正道是坏的(第一个 `aclose()` 就把共享的 recorder 弄死),所以下游只能退回"每个 client 各占一份" | 全库统一"谁建的谁关"纪律,共享路径打通 |
|
||||
|
||||
真实实验室 PG 上的连接数实测,一眼可见差别: **修复前**建完 recorder 就是 **10** 条;**修复后**建完 recorder **0** 条 → 一次写入后 **1** 条 → 20 行并发后 **4** 条(= `pool_max`)→ `aclose()` 后回到 **0**。
|
||||
|
||||
### 请先读这一条(一): 最低 Python 版本提到 3.12,3.11 的部署装不上
|
||||
|
||||
`requires-python` 从 `>=3.11` 改为 `>=3.12`。这是本版四条要点里**唯一会让下游装不上**的变更——仍在 3.11 上的部署执行 `pip install` 会被 pip 直接拒绝,不是运行时报错,是装不了。升级 Python 或钉住 `polygateway<1.3` 二选一。
|
||||
|
||||
抬版本不是顺手做的: 本版的写入预算依赖 `asyncio.timeout`,而 3.11.0 / 3.11.1 的 `uncancel` 有已知缺陷,继续支持 3.11 就得退回 `wait_for` 并绕开那个缺陷。取舍是缩小支持面换掉一整块补丁代码。同批把三处泛型函数改成 PEP 695 语法(`def f[T](...)`,该语法在 3.11 是 `SyntaxError`)。
|
||||
|
||||
### 请先读这一条(二): 遥测的常驻连接数会从 `10 × client 数` 掉到 0,监控曲线会突变
|
||||
|
||||
这是纯改善,但**曲线会跳**,不要误判为故障: 连接不再于装配期预占,而是第一次写入时才建、忙时最多 `PGW_TELEMETRY_PG_POOL_MAX` 条(缺省 4)、空闲超过回收期后归 0。代价是首次写入多付一次建连(实测 ≈390ms,相对一次秒级 LLM 调用可忽略),稳态写入无差异(实测 123ms)。
|
||||
|
||||
`pool_max` 的调参口径请按实测折算,**不要按 `pool_max / RTT` 估算**——那会乐观一倍: 跨内网 RTT ≈ 123ms 的实验室 PG 上,`pool_max=4` 实测约 **15.6 行/秒**(50 行并发批耗时 3.2s),因为一次 `INSERT` 的实际往返比一次 `SELECT 1` 重。缺省 4 配缺省 5s 预算能吞下约 50 行的突发,余量约 1.5 倍;超预算的行被丢弃并计入 `telemetry_status.dropped_rows`——丢一条遥测好过拖垮业务调用。多个 client 共享同一个 recorder 时并发在这里汇聚,应相应放大。
|
||||
|
||||
### 请先读这一条(三): `aclose()` 不再关闭注入进来的组件
|
||||
|
||||
新纪律是**谁建的谁关,注入的一律不碰**: `from_env()` / `from_settings()` 自建的 transport / recorder / limiter / breaker / cache 照常被 `aclose()` 关掉;经构造函数**注入**进来的则一律不碰,由注入方自己关。`RedisCache` 同款(注入的 redis 客户端不再被误关)。
|
||||
|
||||
这修正的是一次越权——共享同一个 recorder 的多个 client 里,第一个 `aclose()` 会把其他 client 还在用的 recorder 弄死。但**若你的代码依赖了"注入之后由 client 代关",升级后会漏关**,请自行补上关闭。同一批还修掉了反方向的泄漏: 自建的 redis limiter / breaker 客户端此前**从来没有人关**(`aclose` 压根不持有它们的引用),现在会被关。
|
||||
|
||||
### 请先读这一条(四): 直接构造 `GatewaySettings` 的代码要补两个参数
|
||||
|
||||
`GatewaySettings` 新增 `telemetry_pg_pool_max: int` 与 `telemetry_pg_write_timeout_s: float` 两个**无默认值的必填**字段。走 `from_env()` / `from_settings()` 的调用方不受影响(两个新键都是可选的,env 装配路给缺省 4 与 5.0);**直接构造 `GatewaySettings(...)` 的代码——测试装配、配置改写脚本——升级后不补参数会当场 `TypeError`**。
|
||||
|
||||
这不是疏忽而是既有纪律: 相邻的 `telemetry_auto_migrate` / `telemetry_text_cap` 同样无默认值,缺省规则只写在 `_load_*` 一处,不与字段签名漂移(P4 显式优于隐式)。写默认值在此也不可能——这两个字段后面还跟着四个无默认值字段,加了就是 `TypeError: non-default argument follows default argument`。`dataclasses.replace(settings, ...)` 一路不受影响。
|
||||
|
||||
### 遥测失败的三分判据
|
||||
|
||||
判据两句话:**致命 = 失败原因完全在进程内部且不可变**;**行级 vs 环境级看"失败与这一行的数据有没有关系"**。
|
||||
|
||||
| 档 | 覆盖 | 处置 |
|
||||
|---|---|---|
|
||||
| 配置级致命 | DSN 不可解析(`ClientConfigurationError`)、建池参数非法 | 永久 no-op + 一条 **error**(这是人配错了,不是 warning) |
|
||||
| 环境级不可用 | 连接类 `08` / 资源不足 `53`(含 53300 too many connections)/ 管理干预 `57` / 认证 `28` / 库不存在 `3D`,以及 `42501` 无权限、`42P01` 表不存在;网络类异常;**超时类异常仅在准备期路径可达**(写入期的超时先被 `record_llm_call` 的 `except TimeoutError` 接住,按行级丢弃);表确定不存在且建不出来 | **冷却 60s 后自动重试一次**,成功即恢复。DBA 建完表、放开权限、PG 重启完毕,进程都不必重启 |
|
||||
| 行级拒绝 | 其余数据与约束类错误(`22`/`23` 等),外加**唯一具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级 |
|
||||
|
||||
`42703` 之所以是例外: issue #13 定了更高优先级的承诺——manual 档缺列时按现有列裁剪 `INSERT` 继续写、缺列以逐行 warning 暴露,"部分列写进去了"这件事本身有价值,不该被冷却掉。
|
||||
|
||||
### 新增公共 API
|
||||
|
||||
| 名字 | 内容 |
|
||||
|---|---|
|
||||
| `GatewayClient.telemetry_status` / `EmbeddingClient.telemetry_status` / `OcrClient.telemetry_status` | `TelemetryStatus \| None` 只读属性。`None` = 未启用遥测,或注入的 recorder 不提供状态 |
|
||||
| `polygateway.TelemetryStatus`(顶层导出) | frozen dataclass: `degraded` / `fatal` / `reason` / `degraded_for_s` / `dropped_rows` / `retry_after_s`。下游可据此对账或告警,不必再人工比对行数 |
|
||||
| `ports.TelemetryStatusProvider` | 新增的**独立**可选端口。`TelemetryRecorder` **逐字未变**——它是 `@runtime_checkable`,往里加成员会让所有只实现 `record_llm_call` 的对象当场不再满足协议,下游的同款 `isinstance` 断言升级即断 |
|
||||
|
||||
### 其他
|
||||
|
||||
- 两个新配置键 `PGW_TELEMETRY_PG_POOL_MAX`(缺省 4,须 ≥ 1)与 `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(缺省 5.0,须 > 0)。`GatewaySettings` 相应新增两个**无默认值的必填**字段,与相邻三个遥测键(`telemetry_auto_migrate` / `telemetry_text_cap` / `telemetry_sqlite_path`)完全一致——上面那两个"缺省"只存在于 env 装配路(`_load_*` 函数),直接构造 `GatewaySettings` 的调用点必须补这两个参数,见"请先读这一条(四)"。`PostgresRecorder` 的 `pool_max` / `write_timeout_s` 是 keyword-only **必填**参数(直接构造 recorder 的调用点需补,不传即 `TypeError`)。
|
||||
- `PostgresRecorder.aclose()` 现在是**有界且终局**的: 走 `asyncio.wait_for` + 超时 `terminate()`(`Pool.close()` 在 in-flight 连接未释放时会无限等,asyncpg 自己的文档就建议加 `wait_for`);关闭后写入短路且**不再复活**——此前关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入外部池的调用方以为自己管着全部连接、实际早已不是。
|
||||
- 降级日志的**级别由是否致命决定**: 配置级致命(DSN 写不对)发 **ERROR**——人配错了、本进程内不会自愈,运维必须看见;其余(后端挂了、权限被收、表被删)发 WARNING——外部状态,冷却到期会自己重试。级别只在 `TelemetryStatusTracker` 一处决定,两个 recorder 共用。
|
||||
- 对账请**同时看 `degraded` 与 `dropped_rows`**: 写入因本地池饱和超出预算被丢时走的是行级丢弃,`degraded` 保持 `False`(后端并没有挂,是本进程并发超了),只有 `dropped_rows` 增长。只按 `degraded` 配告警会完全看不见这一类丢行——而它恰是 `PGW_TELEMETRY_PG_POOL_MAX` 配小了的唯一信号。
|
||||
- SQLite 遥测初始化失败后终于有日志了。此前 `sqlite.py` 初始化失败直接 `return`,连一条 warning 都没有,整个进程零遥测且无任何痕迹。SQLite 侧本版**只做可见性**,不做 lazy 化与冷却重连(它的失败模式在装配期就会暴露,不是"跑到一半悄悄断")。
|
||||
- 写入路径不再用 `async with pool.acquire(...)`。`Pool.release()` 是 shielded 且默认复用 acquire 时记录的 timeout,预算到期时那次释放会正常等到完成——业务路径的真实上界因此是 ≈ 2 × 预算而不是一个预算。改为显式 acquire/release 后,承诺精确为"主写入尝试 ≤ 预算,释放路径独立有界(1s,超时即 terminate)"。
|
||||
|
||||
|
||||
## 1.2.4(2026-08-20)
|
||||
|
||||
熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。
|
||||
|
||||
**缺省是 `fail_fast`,即今天的行为**,存量部署无需改动任何配置。要改的是单源 scope:熔断的设计前提是"这个源坏了,把流量导到别的源",只配了一个源时这个前提不成立,同一段代码做的事就变成"这个源坏了,所以整个 scope 停止服务"。提交方实测:中转抖动 36 秒(22 次尝试 / 19 次 503)触发失败率通道开路,随后 30 次调用全部在 7-74 毫秒内失败,`MAX_ATTEMPTS=8` 一格没用上,一条跑了 3 小时 18 分钟的实验臂当场报废。配 `wait` 之后,熔断对配额和钱包的保护完整保留(等待期照样一个请求都不发),改变的只是调用方当场死还是排队等;代价是单次调用最坏墙钟被拉长——上限是 `STALL_WINDOW_S`(缺省 300 秒)。**但 `wait` 并不豁免重试预算**: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `MAX_ATTEMPTS`,所以密钥失效(401/403)这类一击即熔的源通常更早以 `reason=retry_exhausted` 失败,而不是等满窗口后的 `stalled`;两者哪个先到取决于 `MAX_ATTEMPTS` 与冷却时长、`STALL_WINDOW_S` 的相对大小。库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不当场死"。
|
||||
|
||||
### 请先读这一条: `retry_after_s` 在半开状态下的取值变了(缺省档同样生效)
|
||||
|
||||
`retry_after_s` 从来没有写下来的定义,于是两个后端各自发挥、互相漂移。现在它只回答一个问题:**距离确定可再试的时刻还有多久**。健康与准入允许 → `0.0`;开路 → 剩余冷却;**半开(探针在途)→ `0.0`**,因为探针随时可能出结果,不存在确定的时刻——而 `0 = 可立即重试` 本就是这个字段的既有约定。
|
||||
|
||||
变更点在半开:此前返回的是**探针租约剩余**。那是个死锁保护参数,派生自 `max(2 × 最慢源 TIMEOUT_S, COOLDOWN_S, TIMEOUT_S + 5)`,与"这个源多久能恢复"没有任何因果关系。`TIMEOUT_S=300` 的部署里它是 600 秒,而冷却期只有 60 秒。**照它延期重投的下游,等的是一个物理上无意义的数。**
|
||||
|
||||
更重的后果在库内,提交方也没发现:这个值被写进了源冷却备忘,而备忘的 `set_until` 取更晚者、不可回退。于是——源开路、冷却到期、调用①拿到探针、并发的调用②被拒并给该源记下 600 秒本地冷却、调用①的探针成功、门恢复 CLOSED——**本进程此后仍然跳过这个健康的源将近 10 分钟**。单源下每次调用照旧抛 `CircuitOpenError`;多源部署同样中招,只是别的源接住了流量,池子越大越隐蔽。修正后备忘写进的是一个已经过期的时刻,自动回到"只记开路的确定冷却期"。
|
||||
|
||||
同批统一了两个后端在**六个出口**上的口径。其中四处是既有的分叉:Redis 在授予探针时返回探针 TTL、在写回被 fencing 拒时返回租约剩余,而内存后端一直返回 0。契约测试此前只钉了"第二个进入者会被拒绝",从没钉过它拿到的是什么数,这个盲区把分叉掩护到了今天。
|
||||
|
||||
### 其他
|
||||
|
||||
- `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。
|
||||
- `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
|
||||
|
||||
|
||||
## 1.2.3(2026-08-19)
|
||||
|
||||
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
|
||||
|
||||
同一版里,issue #12 补上这条边界的另一半——**删数据**,并把它落成三样**手段**: 遥测正文的可配置上限、`tools/` 下的独立保留期脚本、README 里的一份生产部署 DDL 模板。三样**没有一样改变缺省行为**——不设 `PGW_TELEMETRY_TEXT_CAP` 即逐字节存全文,与今天完全一致。缺省不截断是刻意取舍: 截断之后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的用法;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。库本体同样不因此持有 `DELETE`/`DROP` 权限: 保留期是 `tools/` 下的独立脚本,库不 import 它。
|
||||
|
||||
### 请先读这一条: 照抄过 1.2.1 那份 RLS 模板的 Postgres 部署,遥测表很可能是空的
|
||||
|
||||
1.2.1 的 README 给的 RLS 模板把**写侧**也绑在了 `app.tenant_id` 这个 GUC 上:
|
||||
|
||||
```sql
|
||||
-- 1.2.1 的模板,有缺陷,勿用
|
||||
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
|
||||
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
```
|
||||
|
||||
但 `PostgresRecorder` 用**一个连接池给所有租户**写遥测,源码里从不发 `set_config('app.tenant_id', ...)`——库既拿不到也不该猜租户上下文该怎么设。于是 `WITH CHECK` 里的 `current_setting` 恒为 NULL、等值比较恒不为真,**库的每一条 `INSERT` 都被 policy 拒绝**。而遥测的失败方向是静默降级,所以表现不是报错,是**整张表零行**——业务调用一切正常,不看日志根本发现不了。
|
||||
|
||||
照抄过就请现在查这两条:
|
||||
|
||||
| 查什么 | 中招的样子 |
|
||||
|---|---|
|
||||
| `SELECT count(*) FROM llm_calls;`,且必须用能**绕过 RLS** 的角色(superuser 或带 `BYPASSRLS` 属性的角色)——`FORCE` 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" | 启用 RLS 之后一直是 0,或从某个时刻起不再增长 |
|
||||
| 应用日志里遥测写入的降级告警,前缀 `Postgres 遥测写入失败(丢弃该行):` | 每次调用刷一条,附带的 PG 原话是 `new row violates row-level security policy for table "llm_calls"` |
|
||||
|
||||
本版的新模板把写侧改为 `WITH CHECK (true)`,隔离交由**读侧**的 `USING` 承担: 在这个模型里写入方是库自己(可信),要隔离的是读取方。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(同样只留一条 warning)。完整理由与四个陷阱见 README「生产部署 DDL 模板(PostgreSQL)」第 4 小节。
|
||||
|
||||
### 破坏性变更(五项)
|
||||
|
||||
| # | 变更 | 影响与应对 |
|
||||
|---|---|---|
|
||||
| ① | **Postgres 侧不再自动补列**(缺省转为 manual 档) | 库升级带来新列时,旧表不会被自动 `ALTER`:库改为发**一条** warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 `INSERT` 继续写入——**缺的那几列静默不落库**,直到有人执行那几条 SQL。要恢复旧行为设 `PGW_TELEMETRY_SCHEMA_MODE=auto`。SQLite 侧缺省不变(仍 auto),理由见下 |
|
||||
| ② | 两个 recorder 新增 **keyword-only 必填**参数 `auto_migrate` | `SQLiteRecorder(db_path, *, auto_migrate)` 与 `PostgresRecorder(dsn, *, pool=None, auto_migrate)`;直接构造 recorder 的调用点必须补这个参数,不传即 `TypeError`。**故意不给默认值**:缺省规则只写在 config 一处,不与类签名漂移 |
|
||||
| ③ | `GatewaySettings` 新增**必填**字段 `telemetry_auto_migrate: bool` | 只影响「构造函数全量注入」这条装配路(测试/高级用法);`from_env()` / `from_settings()` 的用户零改动。`telemetry_backend="none"` 时该字段在 `__post_init__` 归一为 `False` |
|
||||
| ④ | `GatewaySettings` 再新增**必填**字段 `telemetry_text_cap: int \| None` | 同 ③,只影响直接构造这条路。`None`(不截断)是**取值**而不是默认值——字段本身没有默认值;`<= 0` 在 `__post_init__` 直接 `ValueError`,不会被当成"不截断" |
|
||||
| ⑤ | `TelemetryEmitter` 新增 **keyword-only 必填**参数 `text_cap` | 库内部类,库内唯一构造者是三个公共 Client(本版已全部接通);直接构造过它的测试/高级用法不传即 `TypeError`。同样**故意不给默认值**: 漏传会静默改变落库正文。它也是值域校验的收口处——三个 Client 的 `text_cap` 全汇流到这里,而 `GatewaySettings` 那道只管 env 一条路 |
|
||||
|
||||
### 新增
|
||||
|
||||
- **`PGW_TELEMETRY_SCHEMA_MODE`(可选键,值域 `auto` / `manual`)**,**三态**:不设 = 按后端派生,显式设置 = 两侧都可覆盖。派生规则**有意不对称**——`postgres` → `manual`,`sqlite` → `auto`。理由:PG 侧是共享的生产表,有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑;SQLite 侧是下游自己的本地文件(典型是 `runs/*.db`),没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。
|
||||
- **公共函数 `telemetry_schema_sql(backend) -> str`**(已进顶层 `__all__`):返回可直接粘进迁移文件的完整脚本——注释头 + `CREATE TABLE IF NOT EXISTS`(全量列)+ 各补列语句。PG 变体带 `ADD COLUMN IF NOT EXISTS`,整段**可重复执行**;SQLite 无该语法,以注释标明"仅当该列不存在时执行"。非法 `backend` 抛 `ValueError`。
|
||||
- **manual 档的缺列告警**逐列点名并写明后果(「以下维度不会被记录: tenant_id, meta」),附上可直接执行的 ALTER,且**只在准备期发一次**,不逐行刷屏。只说"缺列"是不够的:静默丢维度的后果是多租户账目全归空串且无任何报错。
|
||||
|
||||
issue #12 交付的三样手段列在下表——它们改变的是**能做什么**,不是**默认做什么**:
|
||||
|
||||
| 手段 | 内容 |
|
||||
|---|---|
|
||||
| **`PGW_TELEMETRY_TEXT_CAP`**(可选正整数键) | 遥测落库正文的字符上限;**不设 = 不截断**(缺省)。作用面正好四处: `messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列;超出部分头部保留、尾部换成 `…(略 N 字)`。**按每条文本切,而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**覆盖面到此为止**: 调用方塞进 `tool_calls.function.arguments`、`name` 等 `content` 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留 |
|
||||
| **`tools/telemetry_retention.py`**(独立运维脚本) | 按 `created_at` 清理过期行。**默认 dry-run**: 先打出将删行数、`created_at` 窗口与按 `tenant_id` 的分布,让运维先判断"要删的是不是我想删的",给了 `--apply` 才真动手。退出码是与调度器(cron/systemd)的契约: `0` 正常(含 dry-run)、`1` 参数错误、`2` 连接/权限/目标表不可用(**含缺 `asyncpg`**——明确报错退出,绝不静默变成"删了 0 行")、`3` 目标是 PostgreSQL 分区表,此时脚本**拒绝 DELETE**,让路给 O(1) 的 `DETACH` + `DROP PARTITION`。请用维护角色跑,不要用应用账号(模板已对它 `REVOKE UPDATE, DELETE`) |
|
||||
| **README 新增「生产部署 DDL 模板(PostgreSQL)」一节** | 三角色、`created_at` RANGE 分区与 `pg_partman` retention、`REVOKE UPDATE, DELETE` 加触发器兜底、RLS、**库自己需要的最小权限**、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 `<!-- pg-template:* -->` 锚点,由 `tests/integration/test_postgres_telemetry.py` 从 README 解析出来在真实 PG 上逐条执行——**模板只有这一份**,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物 |
|
||||
|
||||
### 变更
|
||||
|
||||
- **Postgres 的写入去掉了冲突目标**:`ON CONFLICT (call_id) DO NOTHING` → `ON CONFLICT DO NOTHING`。普通表上语义**逐字等价**(表上只有主键这一个唯一约束),但带目标的版本要求恰好匹配 `(call_id)` 的唯一约束,而 PostgreSQL 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区后主键变成 `(call_id, created_at)`,该语句会被 PG 直接拒收,且失败只逐行 warning,表现为分区部署下遥测全线静默丢数据。SQLite 的 `INSERT OR IGNORE` 本就无目标,未动。
|
||||
- **manual 档按现有列裁剪 `INSERT`**。这不是可选增强而是关掉 `ALTER` 的前提:旧表缺列时若仍发全量 `INSERT`,每一行都会因未知列被拒 → 遥测彻底丢失,比自动补列更严重地违反「遥测必录」。列探测失败、或探测结果与库认识的列毫无交集时,保守回落全量列(与今天的行为一致)。
|
||||
- **schema 常量收敛为单一事实源** `telemetry/schema.py`(内部模块):列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警此前在两个 recorder 各存一份。收敛的理由是**正确性**而非整洁——打印给下游的 SQL 必须与库真正执行的 DDL 同源,多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||
|
||||
### 不变
|
||||
|
||||
- **manual 档仍然建表**。issue 把建表列为现状描述而非指控(它已在 #9 收口为"PG 侧先 `to_regclass` 探测、表在就不发 DDL")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。
|
||||
- **auto 档行为与从前逐字相同**,包括补列失败时**不裁剪**:该档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
|
||||
- 降级方向不变:缺列、补列失败、写入失败一律只 warning,绝不冒泡打断业务调用;列名与列序不变;错误面零变更。
|
||||
- **遥测缺省不截断**: 不设 `PGW_TELEMETRY_TEXT_CAP` 时落库正文与今天逐字节相同。`digest_messages`(缓存 key 与遥测共用的那个摘要函数)一个字节没改,截断只发生在遥测分支、缓存路径不经过它;且截断**只产出新对象、绝不就地修改**——`digest_messages` 对非 list 的 `content` 是原样透传**同一个 dict 对象**,就地改会一并污染调用方持有的 messages、后续重试的请求体与缓存写入的 key,而且全程没有任何报错。两条红线测试分别钉死这两件事: 同一组 messages 在 cap 生效前后 `build_cache_key` 的输出逐字节相同、落库那份被截断而调用方持有的那份(含嵌套 part)一字未改。
|
||||
- embedding 与 OCR 两条链路各自既有的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系;多模态 `image_url` 早已是 sha256 摘要,不受 cap 影响。
|
||||
|
||||
### 库对下游数据库的承诺(Expand/Contract,本版成文)
|
||||
|
||||
以下五条此前已被实现满足,但从未写成承诺。本版起它们是**承诺**:新列**只增不删不改名**且一律追加在既有列之后;新列必**可空**或带**非易失常量默认值**(PG 11+ 补列不重写全表,SQLite 补列是元数据操作);`INSERT` **永远显式写出列名**;库**从不 `SELECT *`**、从不读回这张表的数据(库只写不读,连探测都只查 catalog);写入的**冲突处理不绑定具体约束**。
|
||||
|
||||
合起来它们保证:你可以自行给 `llm_calls` 加列、加索引、挂 RLS,乃至把它建成 `PARTITION BY RANGE (created_at)` 的分区表,库的探测、补列与写入都照常工作。完整说明见 README「遥测表 schema 与升级纪律」——那份随包分发,`research-wiki/` 不在 sdist 内。
|
||||
|
||||
同一条边界的另一半是**删数据**: 库不持有 `DELETE`/`DROP` 权限,保留期与访问控制以 README 模板加 `tools/` 独立脚本交付。这不是保守,是两条诉求的权限张力逼出来的唯一解——模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`(那是 DDL,同样不触发不可变性触发器)。分区在这里**不可替代**,不是性能偏好。
|
||||
|
||||
### 升级提示
|
||||
|
||||
- 用 `from_env()` / `from_settings()` 装配的下游**无需改代码**;Postgres 下游升级后建议执行一次 `python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))"` 的输出,把新列补齐(不补则新维度不落库,库会在首次写入前用一条 warning 点名)。
|
||||
- 直接构造 `SQLiteRecorder` / `PostgresRecorder` 或直接构造 `GatewaySettings` 的调用点必须补上新参数/新字段,否则 `TypeError`。
|
||||
- **截断不需要任何升级动作**: 不设 `PGW_TELEMETRY_TEXT_CAP` 就维持全文。真在意留存面的部署应显式设一个上限,并同时配上保留期与访问控制——三件事要一起上才有意义,README 给了可直接照抄的组合。
|
||||
- 已按 1.2.1 的 RLS 模板部署过 Postgres 的,请先做本版开头那两条自查,再换用新模板。该自查也进了 README 的 RLS 小节——CHANGELOG 不在 sdist 内,只读包内 README 的人否则看不到。
|
||||
- README 的安装 pin 由 `>=1.2.1,<2` 收紧为 `>=1.2.3,<2`。按旧 pin 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.1/1.2.2 就没有本版的 schema 档位与截断开关**,而包内那份 README 描述的正是它们。
|
||||
|
||||
## 1.2.1(2026-08-18)
|
||||
|
||||
每次调用现在可以带上**租户标识与任意调用方自定义维度**,并逐条落进遥测表(issue #11)。`llm_calls` 存的是**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列**没有任何租户维度**——能区分来源的只有 `session_id` / `parent_call_id` 两个调用方自填、库内不校验的自由字符串。
|
||||
|
||||
不可逆性是这个 issue 的核心论点,且成立: 先启用遥测再补列,补列之前写进去的每一行都没有归属,事后无法还原哪行属于谁。
|
||||
|
||||
### 新增
|
||||
|
||||
- **四个公共方法各增两个 keyword-only 参数 `tenant_id` 与 `meta`**,都带默认值 `None`,**既有调用点零改动**: `GatewayClient.chat()`、`EmbeddingClient.embed()`、`OcrClient.recognize_text()`、`OcrClient.parse_layout()`。issue 只诉求前两条链路;OCR 经同一个 `TelemetryEmitter` 写**同一张表**,只覆盖两条会让同表内一部分行有归属、一部分永远空白,故一并纳入(与 issue #10 同一判断)。
|
||||
- **遥测表 `llm_calls` 新增两列**,排在既有 22 列**末尾**,两端类型按各自后端的原生能力取:
|
||||
|
||||
| 列 | Postgres | SQLite |
|
||||
|---|---|---|
|
||||
| `tenant_id` | `TEXT NOT NULL DEFAULT ''` | `TEXT NOT NULL DEFAULT ''` |
|
||||
| `meta` | `JSONB NOT NULL DEFAULT '{}'::jsonb` | `TEXT NOT NULL DEFAULT '{}'` |
|
||||
|
||||
- **老表经现有 `_BACKFILL` 机制自动补列**(先探测再 `ALTER`,失败只逐行降级),补列后**老行的 `tenant_id` 读出是空串而非 NULL**。这个区别是刻意的: PG 的 RLS `USING` 表达式返回 false **或 null** 的行都不可见、且静默跳过不报错,所以 NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**;哨兵空串则显式可查,`COUNT(*) WHERE tenant_id = ''` 一条 SQL 就能审出还有多少行待归属。补列本身两端都不停机: PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作。
|
||||
- **`TelemetryRecorder.record_llm_call` 由 22 字段扩为 24**(`inspect.signature` 实测),`ChatRequest` 同步新增两个带默认值的字段。`meta` 以 `json.dumps(sort_keys=True, ensure_ascii=False, allow_nan=False)` 序列化,空 dict 落 `'{}'` 而非 NULL。
|
||||
|
||||
### 校验规则(超限报错,不静默丢弃)
|
||||
|
||||
校验在四个公共入口收口、进洋葱之前抛裸 `ValueError`,四条链路共用同一份实现:
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| `tenant_id` | 长度 ≤ **128**;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug |
|
||||
| `meta` 键数 | ≤ **16** |
|
||||
| `meta` 键 | 必须匹配 `[a-z0-9_.]{1,64}`;**`pg_` 前缀保留**给库将来的内建维度(本版库自身不写任何该前缀的键) |
|
||||
| `meta` 值 | 仅 `str` / `int` / `float` / `bool`,嵌套需调用方自行序列化;字符串值 ≤ **256** 字符;`float` 必须有限,`nan` / `inf` 报错(它们不是合法 JSON,PG 的 JSONB 会拒收) |
|
||||
|
||||
报错点选在入口而非遥测写入点: 遥测层的一切失败都按降级方向铁律吞成 warning,校验放那里等于没有校验。**超限一律报错**,不采用"超长就丢弃"的做法——那违反 P5「严禁默认值掩盖错误」,会把调用方的输入错误转化成静默丢数据。
|
||||
|
||||
### 不变
|
||||
|
||||
- **`tenant_id` 与 `meta` 都不进缓存 key**。租户级的缓存隔离由既有的 `cache_namespace` 负责,重复进 key 只会让全部存量缓存冷启动;且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
|
||||
- 既有 22 列的列名与列序、`ON CONFLICT (call_id) DO NOTHING` 幂等、单条写失败逐行丢弃的降级方向全部未动。**错误面零变更**,下游 `except` 写法不受影响。
|
||||
- 缓存命中行与终态失败行同样带维度,且读的是**本次** `request` 而不是缓存里的历史响应——这两类行恰恰是审计最需要的(命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
|
||||
|
||||
### 边界: 库只交付列,RLS 与索引由下游执行
|
||||
|
||||
**库不会执行 `ENABLE` / `FORCE ROW LEVEL SECURITY`,也不会建任何索引。** 需要数据库层的强制隔离,下游 DBA 必须自行执行 RLS DDL 与 `CREATE POLICY`(并建 `(tenant_id, created_at)` 复合索引——启用 RLS 后 policy 会给每条查询隐式追加 `tenant_id` 等值谓词,它必然是前导列);**不执行则 `tenant_id` 只是一个可查、可过滤的普通列,没有任何数据库层强制**。
|
||||
|
||||
不自动启用的首要理由是 **default-deny**: 启用 RLS 而无匹配 policy = 零行可写,且**静默不报错**。三个下游里只有一个是多租户,库若自动启用,其余部署升级后遥测**全量写失败**,再叠加遥测的静默降级铁律,就是无声全局丢数据——恰是本 issue 所担心的"不可逆"的最坏形态。其余理由: policy 必须绑定角色而库只拿到一条连接串;`CREATE POLICY` / `ALTER TABLE` 要求表属主,而按最佳实践部署时库的运行时角色恰好不是属主;SQLite 根本没有 RLS,承诺 RLS 会让两个后端语义不对等。
|
||||
|
||||
RLS 模板与三个陷阱(表属主默认豁免 RLS 需 `FORCE`;租户上下文必须在**显式事务内** `set_config(..., true)`,asyncpg 默认 autocommit 下单发 `SET LOCAL` 会当场失效而 PG 只发 warning;只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行)见 README「多租户与自定义维度」一节——那份模板随包分发,`research-wiki/` 不在 sdist 内。
|
||||
|
||||
### 升级提示
|
||||
|
||||
- **升级无需任何代码改动**: 两个新参数都是带默认值的 keyword-only,既有调用点原样工作;不传即写入哨兵空串与空 `{}`。
|
||||
- README 的安装 pin 由 `>=1.2,<2` 收紧为 `>=1.2.1,<2`。按 `>=1.2,<2` 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.0 就没有租户维度**。
|
||||
- README 的配置参考表此前漏列了源级 `MISSING_DONE` 与 `EXTRA_BODY`(正文别处却引用了后者)、`{SCOPE}__QUOTA_FULL`、embedding 专用键、`PGW_CACHE_BACKEND` 的 `memory` 档与三个可选 `PGW_*` 键,本版按 `config.py` 的 `_SOURCE_FIELDS` 与 `_load_pgw` 逐项补齐。代码零变更。
|
||||
|
||||
## 1.2.0(2026-08-16)
|
||||
|
||||
网关拒绝一次调用时,**它说的话不再丢失**(issue #10)。下游一轮 1050 张医学影像的批处理里,1 张在读表格这一步收到 400、被判确定性失败而放弃;事后想知道"这张图到底哪里不合规",无从查起——响应体在 transport 翻译层之后就不存在于进程任何位置了。
|
||||
|
||||
根因是三条留存通道同时为空: `_status_to_error` 手上握着 `body_text` 却只用于 429 的类型细分,该模块没有任何 logger 调用,异常类也没有承载响应体的字段。而库的逐次遥测写的是 `str(exc)`,即 message——所以**只给异常加字段并不能让它进遥测表**,必须两者都做。
|
||||
|
||||
### 新增
|
||||
|
||||
- **四分类错误新增 `body_text` 字段**(加在 `PolyGatewayError` 基类): 非 2xx 响应体的摘要。与 `ResultInvalidError.raw_text` 分工明确——前者是"对方拒绝的理由"(非 2xx),后者是"2xx 但内容不可解析时的模型输出"。scope 级错误(`GatewayUnavailableError` 一族)恒为空串: 它们没有单一响应体可言。
|
||||
- **同一份摘要同时进入异常 message**,故 SQLite/Postgres 遥测的 `error` 列里直接可查,下游不必为此单独埋点。
|
||||
|
||||
### 行为变更
|
||||
|
||||
- **非 2xx 的 message 末尾追加 ` | {响应体摘要}`**,覆盖两个 transport 的**全部**分支: chat 的 400 / 401·403 / 4xx 兜底 / 5xx / 429 两支(含 `insufficient_quota`),以及 OCR 的全部分支。issue 只报告了 chat 的 400,但 401 会 `force_open` 整个源、OCR 侧 message 原本只有一个状态码,是同一个缺陷的其余分支。
|
||||
- 摘要口径: 先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 **2048 字符**(对齐 Kubernetes client-go 同场景的 `maxUnstructuredResponseTextBytes`)。超长时**保留头 1400 + 尾 600**并记下省略字数——JSON 错误体的 `code` / `request_id` 收在尾部,头部硬切正好会切掉向网关方追查时唯一有用的那部分。
|
||||
- 遥测 `error` 列因此变长: 纯 ASCII 约 2KB/条,最坏(5xx 重试 3 次)一次调用约 6KB。
|
||||
|
||||
### 不变
|
||||
|
||||
- **状态码 → 错误分类的映射逐条未动**(ARCHITECTURE §6.2 表),`retry_after_s` 解析、429 免重试预算、`insufficient_quota` 细分全部保持——429 的类型判定仍解析**未截断的原文**,若改用摘要,超长 body 的配额耗尽会退化成普通限速、该源不再 `force_open`。
|
||||
- 异常类型树、`str(exc)` 之外的字段、遥测 22 字段与列序、DDL 全部未变。**错误面零变更**,下游 `except` 写法不受影响。
|
||||
- 400 仍按确定性失败处理(不重试不换源)。**但请注意**: 经第三方中转部署时,中转自身抖动也会回 400,从状态码上与"你的输入有问题"分不开(下游实测: 同一份字节 sha256 一致、重发 15 次全部成功,失败那次 `prompt_tokens=0` 且耗时远低于任何成功调用)。库不改默认语义——直连供应商时重试只会白烧配额——但 `body_text` 现在给了下游自行区分的判据。
|
||||
|
||||
### 升级提示
|
||||
|
||||
README 的安装 pin 由 `==1.1.*` 改为 `>=1.2,<2`。**仍按 `==1.1.*` 安装的下游会静默停在 1.1.2**,拿不到本次修复且没有任何报错,请同步改自己的依赖约束。
|
||||
|
||||
- 打包元数据补齐: `readme` 与 `[project.urls]`。1.1.2 及之前的包在 registry 页面上**没有任何说明正文**(缺 `readme` 时 twine 只警告不阻塞),也没有仓库链接。代码零变更,自本版生效。
|
||||
|
||||
## 1.1.2(2026-08-07)
|
||||
|
||||
Postgres 遥测撞上建表权限就整体判死的问题(issue #9)。**最小权限部署会静默丢掉全部遥测**: 应用账号有表级 `INSERT`、表也已存在,但没有 schema 的 `CREATE` 权限时,初始化的 `CREATE TABLE IF NOT EXISTS` 被拒 → recorder 永久 no-op,业务调用一切正常,只留一行 warning。下游 CHSAnalyzer3 首次端到端跑的 150+ 次调用耗时/token/成本因此全部丢失,且事后无法补回。
|
||||
|
||||
根因是 **PostgreSQL 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断**(PG 16.14 实测: 同一连接 `INSERT` 通过、`to_regclass` 看得见表,该 DDL 照样被拒)——与 issue #3 修过的 `ALTER TABLE` 是同一类问题,当时只修了补列那一半。
|
||||
|
||||
### 行为变更
|
||||
|
||||
- **PG 侧建表前先 `to_regclass` 探测,表已存在就一条 DDL 都不发**。探测不需要任何权限,且与 `INSERT` 走同一套 search_path 解析(比裸 DDL 更准: 裸 `CREATE TABLE` 落在首个**可建**的 schema,可能与写入命中的不是同一张表)。表不存在时才建,新建表列已齐全,顺带跳过补列。
|
||||
- **"结构性失能"的判据收窄为「确定写不进去」**,不再是「初始化时出过异常」。仅两种情形仍永久降级为 no-op: 建池失败(重试要在业务路径上内联吞掉连接超时)、表确定不存在且建不出来(后续 INSERT 必然全败)。探测失败、取连接失败改为**只跳过本条并 warning,下次调用重新准备**——初始化瞬间的一次抖动不再让整个进程失遥测。
|
||||
- 日志措辞随之细分: `建池失败` / `建表探测失败(跳过本条,下次重试)` / `建表失败(表不存在,记录无处可落)`,原先一律是 `初始化失败`。
|
||||
|
||||
### 不变
|
||||
|
||||
- SQLite 侧**一行未改**。实测其对已存在的表在解析期就把 `CREATE TABLE IF NOT EXISTS` 短路掉(另一连接持 `BEGIN EXCLUSIVE`、文件 `chmod 444` 时该语句均通过,而同条件的 `INSERT` 分别报 database is locked / readonly database),没有同款风险;加探测零收益,故有意不对称,只在 docstring 钉死实测结论。
|
||||
- 遥测端口签名、22 字段、列序、`ON CONFLICT DO NOTHING` 幂等、单条写失败逐行丢弃的降级方向全部未动。**错误面零变更**。
|
||||
|
||||
### 升级提示
|
||||
|
||||
若你的部署此前为了绕开本问题给应用账号授了 `CREATE ON SCHEMA`,现在可以收回——表存在时库不再需要该权限。
|
||||
|
||||
## 1.1.1(2026-08-06)
|
||||
|
||||
stall 判定改为非生产性等待口径(issue #8)。`timeout_s ≥ stall_window_s` 时,**一次耗满超时的请求就会让整个 scope 被判死,配置的重试次数一次都用不上**——而且没有任何报错或 warning,配置方以为自己配了 3 次重试。`stall_window_s` 默认 300 恰是个很容易被 `TIMEOUT_S` 追平的值,"只配 timeout、不配 stall"这种最常见的写法正好踩中。
|
||||
|
||||
根因是**两个预算重叠计费**: 真实尝试的耗时同时向重试预算(`max_attempts`)与 stall 预算(`stall_window_s`)计费,而后者更小,必然先耗尽。
|
||||
|
||||
### 行为变更(**请先读这一条**)
|
||||
|
||||
- **stall 判定的"本地超窗"条件现在只累计非生产性等待**——429 退避、配额 wait 轮询、熔断冷却;消耗重试预算的真实尝试不再计入。两个预算自此正交,划分依据是**谁消耗重试预算**: 烧 `max_attempts` 的时间不烧 `stall_window_s`,不烧 `max_attempts` 的时间(含 429 尝试本身)归 `stall_window_s` 治理。
|
||||
- **`stall_window_s` 与 `timeout_s` 不再有任何耦合**,无需按 `timeout × retries` 放大。若你此前为绕开本 bug 把 `STALL_WINDOW_S` 调大过,现在可以回到默认值。
|
||||
- **单次调用的最坏耗时由 `stall_window_s` 抬升到约 `max_attempts × timeout_s`**(默认配置下 3 × `TIMEOUT_S`,再加各次退避)。这是重试预算恢复生效的正确表现,但如果你的上游有调用超时,请据此复核。429 路径同样不突破这个量级——429 虽免重试预算,但其尝试耗时计入 stall 账。
|
||||
**上述量级的前提是 stall 判死能够触发**,即整个 scope 无进展(`progress_age_s() > stall_window_s`)。判死是**双条件合取**,这一条未变: 若同 scope 里其他调用仍在正常出餐,本调用会继续等待换源而不判死——这正是双条件的设计意图("别人还活着,不该因我一路不顺就宣告整个 scope 死亡")。**代价是这种情形下调用级没有硬上限**,持续遭遇慢 429 的调用可以等很久。该性质由条件 B 单独门控,**早于本次修复即如此**(旧口径实测同样无界),不是本次引入;但若你需要调用级硬上限,请在调用方用 `asyncio.wait_for` 自行设置。
|
||||
- 三条治理循环(chat / embedding / ocr)口径一致。**embedding 与 ocr 此前有同一缺陷**(经"先超时一次、再遇到无可用源"触发),issue 只记录了 chat 路径。
|
||||
- 遥测收尾属"真实尝试"边界之内,**遥测抖动不会把一次调用推进 stalled 判决**。
|
||||
|
||||
### 不变
|
||||
|
||||
- 双条件判死的结构、`progress_age_s()` 的 `inf` 语义(从未出餐 = 全局超窗)、429 免预算、退避与 jitter 公式、`fail_fast` 分支、`AllSourcesExhausted` 的字段与 `reason` 取值(仍是 `stalled`)全部未动。**错误面零变更**,下游 `except` 写法不受影响。
|
||||
- 装配期校验 `stall_window_s ≥ 最大源 ttft_timeout_s` 保留。新口径下它已是保守冗余(TTFT 等待属生产性时间),但无害且不误拒合理配置。
|
||||
|
||||
## 1.1.0(2026-08-06)
|
||||
|
||||
治理后端故障归位为 scope 级不可用(issue #7)。限流/熔断的状态后端(Redis 等)自身故障时,库按降级方向铁律 fail-closed——**整个 scope 一个请求都发不出去**,语义上就是"scope 级暂时不可用"。但 `GovernanceBackendError` 此前是 `PolyGatewayError` 的直接子类,只写 `except GatewayUnavailableError` 的调用方接不住,后果很具体: Redis 抖一下,积压任务一批批消耗业务失败预算,够到上限就进死信——**而那是运维重启一下就好的故障**。
|
||||
|
||||
### 行为变更(**请先读这一条**)
|
||||
|
||||
- **`GovernanceBackendError` 现在能被 `except GatewayUnavailableError` 捕获。** 它改为继承该类,`reason` 恒为新增的 `governance_backend_down`。**下游对后端故障的处置路线因此改变**: 从"落进兜底分支、按业务失败处置"变为"按 scope 级不可用延期重投、不消耗失败预算"。这正是本次修复的目标,但升级前请确认下游的兜底分支没有依赖旧行为(例如靠它触发告警)。既有的 `except GovernanceBackendError` **继续有效**——加父类是扩大捕获面,不是破坏。
|
||||
- **配置写错(源名与限流后端配置不匹配)现在抛 `SourceNotConfiguredError` 而非 `GovernanceBackendError`。** 该类**有意不在** `GatewayUnavailableError` 之下: 那是装配缺陷不是暂时故障,必须消耗失败预算、进死信、让人看见。若随整类归入可重投家族,配置写错的任务会永远重投且无人告警——恰是本次要修的 bug 的镜像。
|
||||
- **`GovernanceBackendError` 的构造签名增加必填 keyword `scope`。** 库内 20 处构造点已全部更新;若下游有自行构造该异常的代码(罕见)需同步补 `scope`。
|
||||
|
||||
### 新增
|
||||
|
||||
- **`SourceNotConfiguredError`**(公共导出)。源名不在限流后端配置字典中时抛出,正常不可达,属装配缺陷。
|
||||
- **`GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`**,`GovernanceBackendError.retry_after_s` 的默认值。**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),故取保守固定值。**不取 0**: 那会让积压任务零延迟同时冲击已挂掉的后端,把一次故障放大成一场风暴。
|
||||
- **scope 级 `reason` 值域增 `governance_backend_down`**(由 5 值扩为 6 值)。
|
||||
- **README 新增"哪些异常会到达调用方"两列表**。四分类里 `TransientError` / `SourceDeadError` 被重试循环接住、耗尽时包成 `AllSourcesExhausted`,**根本到不了调用方**,而这只看类型树与 docstring 读不出来——曾让下游据此写错整段设计文档。
|
||||
|
||||
### 下游请读
|
||||
|
||||
- **`GovernanceBackendError` 现携带 `scope` / `reason` / `retry_after_s`**,与 `AllSourcesExhausted` 同款(`per_source_reasons` 属性存在但恒为 `{}`——后端故障不针对具体某个源);`str(exc)` 仍是原来的诊断串(如 `限流后端 try_acquire 失败: ...`),结构化字段与诊断信息并存,排障不受影响。
|
||||
- **五条闸门路径**的后端故障会到达调用方: `QuotaGate` 的 `try_acquire` / `stats` / `progress_age_s`,`BreakerGate` 的 `try_enter` / `retry_after_s`。记账路径(`record_success` / `record_failure` / `release_probe` / `mark_progress`)仍被 `_record_quietly` 降级为 warning,这个分工不变。
|
||||
- **CHSAnalyzer 迁移**: `tracking.py` 一条 `except GatewayUnavailableError` 即覆盖完整,无需为后端故障单列分支(`migrations/chsanalyzer.md` G1 已补注)。
|
||||
|
||||
## 1.0.6(2026-08-02)
|
||||
|
||||
推理开关能力建模与 `reasoning_tokens` 采集。`enable_thinking=False` 此前对 `minimax` / `openai` 两类源**完全不产生效果**——两个 profile 的 thinking 两档皆为空字典,`payload.update({})` 是空操作,而配置方以为关掉了推理。这比"不提供这个开关"更危险:不提供的话调用方会去找别的办法,提供了但静默失效,调用方就带着一个错误的前提往下走。一个下游项目正卡在这上面。
|
||||
|
||||
### 行为变更(**请先读这一条**)
|
||||
|
||||
- **MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效"。** 经实测,MiniMax 认的开关是 `reasoning_effort` 而非 `enable_thinking` / `thinking`(后两者被静默丢弃);现在 `False` 注入 `reasoning_effort: none`、`True` 注入 `medium`。此前依赖"设了 false 但其实没关"这一实际行为的调用方,行为会变。
|
||||
- **`MiniMax-M2.7` / `MiniMax-M2.5` 配 `ENABLE_THINKING=false` 会在装配期报错。** 这两个模型的推理**关不掉**,是模型固有属性(三种参数形态各 15 轮实测全部无效,OpenRouter 与 models.dev 两个外部注册表独立登记为强制推理)。调用方要的是"不推理"的语义保证,给不了就必须说,而不是装出一个骗人的 client。
|
||||
- **`provider=openai` 的源配任何非 `None` 的 `ENABLE_THINKING` 会在装配期报错。** 该段名实践中被复用为任意 OpenAI 兼容厂商的兜底,向未知厂商下发厂商方言参数会 400。要控制推理请 `register_provider` 注册形态,或用 `SourceConfig.extra_body` 直接下发。
|
||||
- **`enable_thinking` 进入缓存指纹。** 它现在真的改变请求体,不进指纹就会出现"关掉推理后重启读到开着推理时的旧响应"。**配了该项的 scope 会有一次性冷启动**;未配的 scope 指纹字面量逐字不变,不受影响。
|
||||
|
||||
### 新增
|
||||
|
||||
- **`LLMResponse` / `TransportResult` 新增 `reasoning_tokens: int | None`**(issue #6)。推理 token 已计入 `completion_tokens`,故**成本总额一直是对的**——这不是计费缺口,是归因缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分。
|
||||
- **遥测表 `llm_calls` 新增 `reasoning_tokens` 列**,`TelemetryRecorder` 端口由 21 字段扩为 22;补列纪律与 issue #3/#4 逐字相同(排末尾、先探测再 ALTER、失败只逐行降级)。
|
||||
- **`ProviderProfile` 的 thinking 两档类型放宽为 `Mapping | None`**,三值语义互不重叠:`{...}` 已知注入片段 / `{}` 已知无需注入 / `None` **未知**。空字典曾同时承载后两种含义,那正是本次 bug 的根因。
|
||||
- **新增 model 级能力表** `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability`,以及单一判定函数 `resolve_thinking`。形态(参数长什么样)按 provider 变、数年不变一次;能力(能否关闭)按 model 变、每代都变——provider 级的表在物理上表达不了同厂代际差异。每条登记都附实测证据与日期。
|
||||
|
||||
### 下游请读
|
||||
|
||||
- **`reasoning_tokens` 的 `None` 是"本次调用未上报",不是"该源不上报"**,与 `cached_prompt_tokens` 的 NULL 语义**不同**。中转网关在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故判据须写 `in (None, 0)`;**写 `== 0` 的条件永远不成立**——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。
|
||||
- **不要用输出长度反推是否发生了推理。** 两档的 `completion_tokens` 分布是重叠的(实测关闭档最高 46、开启档最低 13),按阈值判两个方向都会误判。唯一可靠的判别量是 `reasoning_tokens`。
|
||||
- **`enable_thinking=True` 对 MiniMax 映射到 `medium` 档。** 它是五档旋钮而库给的是布尔开关,这个映射是库做的选择:`medium` 对应"厂商正常强度",与 qwen 的 `enable_thinking:true`、deepseek 的 `thinking:{enabled}` 同为"不指定预算、由模型自定"的语义。要精确控制档位用 `extra_body={"reasoning_effort": "..."}`,它的优先级高于 profile 注入。
|
||||
- **未登记的模型不会被挡住**,按 provider 形态尽力注入并发一条 warning。新模型上线不该被库拦下,但也不该假装成功;实测后请用 `register_capability` 登记。
|
||||
- **`pricing.py` 一行未改。** 推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。
|
||||
|
||||
## 1.0.5(2026-07-31)
|
||||
|
||||
采样参数透传(issue #4)。`chat()` 此前没有任何途径设置 `temperature` / `seed` / `max_tokens`——全库检索 `temperature` 零命中,`ChatRequest.overlay` 虽会被并进请求体却只由结构化中间件填充,调用方够不着。对受控实验而言这是阻塞性的:解码温度未知且可能随供应商默认值变化,每格配置跑 5 个 seed 报出的标准差无从解释。
|
||||
|
||||
### 新增(纯增,不破坏任何现有调用方)
|
||||
|
||||
- **`chat()` 新增 keyword-only 参数 `overlay: Mapping[str, Any] | None = None`**,承载逐次变化的采样参数(每个 rollout 不同的 `seed`)。带默认值的 keyword-only 参数不改变既有调用点。
|
||||
- **`SourceConfig` 新增 `extra_body` 字段**,对应环境键 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`(JSON **对象**串),承载全局恒定的参数(`temperature=0`)——免得每个调用点都要记得传,而漏传一次不会报错、只会让数字悄悄不可比。
|
||||
- **优先级为 结构化注入 > 调用级 `overlay` > 源级 `extra_body`。** 由现有层序天然给出,未引入新机制。
|
||||
- **遥测表 `llm_calls` 新增 `sampling` 列**,`TelemetryRecorder` 端口由 20 字段扩为 21;补列走 1.0.4 已建立的"先探测缺列再 ALTER、失败只逐行降级"套路。列语义是「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,**不含**结构化输出注入的 `response_format`(列名是采样参数,而数 KB 的 schema 逐行落库只会让审计表膨胀)。
|
||||
|
||||
### 下游请读
|
||||
|
||||
- **采样参数进缓存 key,所以逐次变化的 `seed` 天然全部 miss。** 这是正确语义而非缺陷:不进 key 的话,同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错。代价是缓存对这条路径不再省钱。**不传采样参数时 key 逐字不变**,存量缓存不受影响。
|
||||
- **`model_fingerprint` 是集合级指纹,不是本次选中源的指纹。** 同 scope 下各源 `extra_body` 不同时,缓存仍可能返回另一源、另一组解码参数下产生的响应(这是既有取舍的延续,`model` 一直如此)。要求逐源可复现的实验应让每个源独享 scope 或 namespace。
|
||||
- **`{model, messages, stream, stream_options}` 是保护键,配了直接报 `ValueError`。** 它们由治理层拥有:`model` 被覆盖会让成本按错单价算,`stream`/`stream_options` 会绕过流式看门狗、丢掉 usage 帧。不可 JSON 序列化的值(如 numpy 标量)同样在进洋葱之前报错——否则会在缓存层的降级保护之外抛裸 `TypeError`,连一行遥测都留不下。
|
||||
- **`SourceConfig` 不再 hashable**,`dataclasses.asdict()` / `copy.deepcopy()` 也不再适用(加任何 mapping 字段的固有代价,裸 dict 亦然)。要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace(source, ...)`。
|
||||
- **OCR / embedding 路径不消费 `extra_body`**:配了会被**剥离并 warning**,装配照常成功。这两条路径的 transport 根本不发这个值(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart 表单),剥离是为了让遥测不至于记录一个从未发出的参数。需要 `dimensions` 等 embedding 参数请提 issue。
|
||||
- **`enable_thinking` 对 `openai` / `minimax` 两个 provider 不产生任何效果**(它们的 thinking profile 两档皆空)。此前没有任何地方说明这一点,调用方可能以为自己关掉了推理。需要下发自定义参数请用 `extra_body`。
|
||||
|
||||
## 1.0.4(2026-07-31)
|
||||
|
||||
响应可观测字段扩展(issue #3)。下游 dissect 要把每次调用落成一行审计记录,其中两列拿不到值:供应商侧 prompt cache 命中了多少 token、这次调用实际跑的是哪个模型版本。前者关系到能否把「缓存命中率差异带来的成本」与「实验条件本身带来的成本」分开,后者关系到实验快照的可复现性。本次把两者暴露到公共类型与遥测表,并让成本换算认识缓存单价。
|
||||
|
||||
### 新增(纯增字段,不破坏任何现有调用方)
|
||||
|
||||
- **`LLMResponse` 新增 `cached_prompt_tokens: int | None` 与 `model_reported: str | None`。** 前者是供应商 prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`),后者是 API 响应体里的 `model` 字段(与 `.env` 配的别名可能分叉——供应商把别名指向新权重时,只有它认得出真正跑的那个版本)。两者均带默认值 `None`,逐字段传参的 fake 构造零改动。
|
||||
- **`None` 与 `0` 是两回事,不可混同。** `None` = 该源不上报这个数(下游据此声明「本源不可做缓存成本校正」);`0` = 该源上报了一次真实零命中。网关报文一律不可信:形态异常(负数、字符串、`bool`、`prompt_tokens_details` 非 dict)一律归 `None` 且绝不抛异常——可观测字段缺失不得打断调用。
|
||||
- **遥测表 `llm_calls` 新增 `cached_prompt_tokens` 与 `model_reported` 两列**,`TelemetryRecorder` 端口由 18 字段扩为 20。两个后端在初始化期对**已存在的旧表幂等补列**——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃、遥测静默全失。两侧都是**先探测缺列、只在真缺列时才 ALTER**(SQLite 查 `PRAGMA table_info`,Postgres 查 `pg_attribute`):`ADD COLUMN IF NOT EXISTS` 即使列已存在也会先取 ACCESS EXCLUSIVE 锁,而遥测是内联 await,让每个进程的首次写入都去锁共享审计表会拖垮业务调用;稳态下一条 ALTER 都不会发。**补列失败只降级为逐行丢弃,绝不会让 recorder 整体失能**(应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership 检查早于存在性判断,列齐全也会失败)。
|
||||
- **`PricingTable` 支持可选的缓存读取单价 `cached_input_per_1m`。** 配了该档且本次有命中时按 `(prompt - cached) × input + cached × cached_input` 分段计价,消除 cost 的系统性高估;**未配则不猜折扣率**,退化为现状全额输入价(P5 严禁默认值掩盖)。旧价格表文件与 embedding 侧的三参 `cost()` 调用零改动。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
|
||||
|
||||
### 下游请读
|
||||
|
||||
- **`cache_hit` 与新字段是两个不同的东西。** `cache_hit` 指的始终是 **PolyGateway 自身的响应缓存**(未产生网关调用),而 `cached_prompt_tokens` 指的是**供应商服务器**复用了提示词前缀、那部分按更低单价计费——真实调用里天天发生,`cache_hit` 永远看不见它。字段名保持不变(改名会破坏迁移兼容),语义已在 docstring 中消歧。
|
||||
- **统计供应商缓存命中率必须写 `WHERE cache_hit = false`。** 缓存命中行的这两个字段是**原样回放**的历史值(与 `model`、`prompt_tokens` 同一口径:`CacheMW` 只覆写与本次调用相关的时序字段),计入会重复计数。这与 1.0.3 里 `cost` 缺口口径的坑是同一类。
|
||||
- 缓存命中行的 `cost` 仍恒为 `0.0`(未产生新调用),该短路排在任何单价换算之前,不受缓存单价档影响。
|
||||
- 旧格式的缓存条目(缺这两个键)照常可重建为 `None`,不会回源;历史遥测行的新列为 NULL。
|
||||
|
||||
## 1.0.3(2026-07-30)
|
||||
|
||||
`est_tokens` 解耦(issue #2):一个常量此前被派了两份对"保守"定义相反的差事——TPM 入场预扣(押多了只是慢,安全)与 usage 缺失时的用量兜底(按上界记账只会账单虚高)。本次把两者拆开。
|
||||
|
||||
### 行为收紧/变更(下游请读)
|
||||
|
||||
- **`usage_source` 新增第三个值 `unavailable`。** 值域由 `measured`/`estimated` 两态变三态:`unavailable` 表示用量信息不可得(usage 帧缺失、失败尝试、终态失败),`estimated` 收窄为"有实测数字但可信度降级"(只剩打捞路径这一个生产者:收到 usage 帧但流被截断)。历史库里既有的 `estimated` 行语义不变、读兼容;按 `usage_source` 分支的下游代码需要认识新值。OCR 成功行**不受影响**,仍是 `measured`(0 token 是事实而非未知)。
|
||||
- **用量不可得的行,`cost` 由数值变 NULL。** 此前 usage 帧缺失时库拿 `est_tokens`(按定义是最坏情形上界)当实测值,又整块塞进 `completion_tokens` 换算——输出单价通常是输入的数倍,实测双重高估约 26 倍;`est_tokens=0` 时则算出 `0.0`,让"免费"与"未知"在数据上不可区分。现在这类行如实记 `0/0` + `unavailable` + `cost=NULL`。`SUM(cost)` 天然跳过 NULL,账目缺口用 `WHERE usage_source = 'unavailable' AND cache_hit = false` 量化(**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 仍是事实上的 `0.0`,本无缺口)。成本汇总若此前依赖"cost 非空"的隐含假设,请复核。
|
||||
- **`est_tokens` 由必填降为可选调优覆盖。** 装配校验 `tpm > 0 ⇒ est_tokens > 0` 已删除——它把供应商配额(运维能从配额页抄到)与库的实现细节(预扣量,无人能正确取值)绑死。未填时库按 `max(1, tpm // 60)` 派生("一次调用约占一秒钟的配额份额",尺度无关:任何配额规模都收敛到约 60 个在途)。字段与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键**保留不删不改名**,显式填值仍然优先。此前为绕开该校验而把 `tpm` 限死为 0 的调用方,现可填真实 TPM。
|
||||
|
||||
## 1.0.2(2026-07-30)
|
||||
|
||||
1.0.1 的续作:那一版把三条跨字段守卫收进构造期后,独立验证发现 `from_env` 上还留着同一类的 15 条校验与 4 条规范化,一并收拢。
|
||||
|
||||
### 修复
|
||||
|
||||
- **后端选择与条件必填项在任何构造路径上都校验。** 以下此前只有 `from_env` 拦得住,`from_settings()` 与直接构造一律放行:`limiter_backend`/`breaker_backend`/`cache_backend`/`telemetry_backend`/`selector`/`quota_full` 六个字段的合法域;取 `redis` 的后端必须有 `redis_url`;启用缓存必须有 `cache_namespace` 与正 `cache_ttl_s`;`telemetry_backend` 取 `sqlite`/`postgres` 时对应的路径/DSN 必填;`structured_max_retries` 非负;`scope` 非空。
|
||||
- **`client.py` 五处断言的前提现在真的成立。** `assert settings.redis_url is not None # 内部不变量: config 已校验` 之类的注释此前在 `from_settings` 路上是假的:断言开启时抛不含任何字段信息的 `AssertionError`,`python -O` 下断言被移除、错误退化为 redis 库抛出的连接串解析异常。注释已改为点明由哪个校验方法保证。
|
||||
- **构造路补齐了 `from_env` 一直在做的规范化**,两条装配路对同一输入产出同一个值:
|
||||
- `scope` 小写并去空白。它直接进 Redis key(`pgw:limit:{scope}:…`、`pgw:gate:{scope}:…`),此前一个进程走 `from_env("LLM")` 拿到 `llm`、另一个直接构造传 `"LLM"`,**同一逻辑 scope 的限流与熔断状态会分裂到两套命名空间**,各记各的配额与熔断状态,分布式治理静默失效且不报错。
|
||||
- `redis_url`、`pricing_path` 的空串归 `None`。留着空串会骗过 `is None` 判断,把错误推迟成 redis 客户端的连接串解析异常或 `Is a directory: '.'`。
|
||||
- Postgres DSN 剥掉 SQLAlchemy 驱动后缀(`postgresql+asyncpg://…` 的 `+asyncpg` asyncpg 不认)。这一条剥的时候会发一条 warning——库动了调用方给的值,不该静默;日志只出现 scheme 段,DSN 带密码,整串不进日志。经 `from_env` 装配的不受影响也不会有这条 warning(`_load_pg_dsn` 早就剥干净了)。
|
||||
- **`EmbeddingSettings` 的 `batch_size` / `expected_dim` 域校验也移入构造期**,此前只有 `EmbeddingSettings.from_env` 校验,直接构造出 `batch_size=-3` 要到 `EmbeddingClient` 构造时才 fail-loud。
|
||||
|
||||
### 行为收紧(下游请读)
|
||||
|
||||
同 1.0.1:经 `from_env()` 装配的调用方**不受影响**。手工构造 `GatewaySettings` 或对它 `dataclasses.replace` 的调用方,若配置组合非法,现在会在构造期抛 `ValueError` 并点出字段名,而不是留到运行时表现为静默不建后端、裸 `AssertionError` 或第三方库的天书报错。
|
||||
|
||||
**一处静默改值需要留意**:此前手工构造传 `scope="LLM"`(非全小写)的调用方,升级后 scope 会被规范化为 `llm`,**Redis key 随之从 `pgw:limit:LLM:…` 切到 `pgw:limit:llm:…`**。这正是本次要修的问题——旧行为下这批 key 与 `from_env` 装配的进程根本不在同一命名空间;但切换发生的那一刻,旧键上的在途租约会被遗弃,靠 TTL 自愈。滚动升级期间建议留意限流配额短暂偏松。
|
||||
|
||||
## 1.0.1(2026-07-30)
|
||||
|
||||
### 修复
|
||||
|
||||
- **装配守卫在任何构造路径上都生效,不再只在 `from_env` 上。** 三条跨字段不变量(源 `timeout_s` ≤ `lease_ttl_s`、`stall_window_s` ≥ 最大源 TTFT、`probe_ttl_s` ≥ 最慢源 `timeout_s` + 5)原先只在 `GatewaySettings.from_env` 里校验,而装配有两条官方路——走 `from_settings()` 或直接构造能装出违反不变量的配置且不报错,故障留到运行时才表现为:租约先于请求过期使并发悄悄超出配额、正常慢首包被误判卡死掐断、半开探针在途即被接管。守卫已收进 `GatewaySettings.__post_init__`,与 `types.py` 各子配置一致,三个 client(Gateway/Ocr/Embedding)的全部工厂一并覆盖。
|
||||
- 新增 `sources` 非空校验。此前零源配置只在 `from_env` 路径被拦,直接构造可装出必然选源失败的 client。
|
||||
|
||||
### 行为收紧(下游请读)
|
||||
|
||||
直接构造 `GatewaySettings` 或对它做 `dataclasses.replace` 时,若上述组合非法,**现在会在构造期抛 `ValueError`**,而不是留到运行时。经 `from_env()` 装配的调用方**不受影响**——那条路本就跑这些守卫。手工拼配置(如从 YAML 读出后构造)的调用方若此前撞上过上述任一故障,升级后会在启动时立即得到点名字段的报错。
|
||||
|
||||
守卫报错文案的**补救建议**改为点字段名(`lease_ttl_s`、`backpressure.stall_window_s`、`breaker.probe_ttl_s`)。原文案已点出字段名,但建议部分给的是环境变量键(如"调大 `PGW_LEASE_TTL_S`"),而不走 env 的调用方从没设过那些键。键名映射见 `.env.example` 与 wiki `参考-配置键`。
|
||||
|
||||
## 1.0.0(2026-07-22)
|
||||
|
||||
首个正式版。统一 LLM/VLM/OCR/Embedding 调度与中转库,治理单位为一次模型调用;经 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目全量迁移验收(ARCHITECTURE §11)。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。
|
||||
- **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。
|
||||
- **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。
|
||||
- **技术栈**: Python 3.11+,核心仅依赖 `httpx` + `pydantic`,其余(redis/sqlite/postgres/json_repair/openai)一律 optional extras。conda 环境 `PolyGateway`。
|
||||
- **技术栈**: Python 3.12+,核心仅依赖 `httpx` + `pydantic`,其余(redis/sqlite/postgres/json_repair/openai)一律 optional extras。conda 环境 `PolyGateway`。
|
||||
|
||||
## 2. 常用命令
|
||||
|
||||
@@ -28,6 +28,12 @@ make ci # 只读验证(check + test)
|
||||
|
||||
> **档位原则(Fable 5 适配,2026-07 调研决策)**: 约束"边界与验收",不规定思考步骤。强制档(MANDATORY)是硬门;其余由模型按 skill description 自判,自判标准是任务实质(规模/风险/是否触及公共承诺),不是省事。硬边界(reference/ 只读、危险命令、提交质量门)由 `.claude/settings.json` 注册的 hooks **确定性执行**,不依赖提示词自觉。
|
||||
|
||||
> [!CRITICAL]
|
||||
> **执行模式: subagent 与 Codex 一律前台(2026-08-06 人类指令)**
|
||||
> 一切 subagent(verifier、`subagent-driven-development` 执行器、Explore 等)与 Codex 调用**必须前台运行**——`Agent` 工具传 `run_in_background: false`,`/codex:rescue` 带 `--wait`,**禁止**后台派发后继续做别的事。
|
||||
> **理由(实测教训)**: 后台完成通知不可靠——管道会掩盖真实退出码(`pytest ... | tail` 让失败跑报成 exit 0),等待脚本的 `pgrep -f` 会自匹配成死循环,于是出现"任务早完成却没人知道"和"任务挂了也没人知道"两种失败,且两种都以"看起来还在跑"的形态呈现,无法从外部区分。前台运行牺牲并行度换取状态确定性,这个交换在本项目是划算的。
|
||||
> **同一理由适用于长跑命令**: 需要后台跑时(如全套件测试),命令末尾**不得接管道**,否则退出码失真;要判完成用 `wait`/轮询 PID,不要用会匹配到自身的 `pgrep -f "<完整命令串>"`。
|
||||
|
||||
### Phase 1: 规划与设计
|
||||
1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。
|
||||
2. 功能产生运行时数据时**必须**调用 `structured-logging`。
|
||||
@@ -72,6 +78,31 @@ make ci # 只读验证(check + test)
|
||||
### 4.4 Git 工作流
|
||||
- 一切开发在 feature 分支,严禁直改 main;频繁语义化提交;提交**必须**调用 `commit` skill;大改动前先提交回滚点。
|
||||
|
||||
### 4.4.1 发布流程(每步都是历史欠账换来的,不得跳步)
|
||||
|
||||
> [!CRITICAL]
|
||||
> **发布 = 合并 + push + tag + 构建 + 上传 registry。只 bump 版本号不叫发布。**
|
||||
> 教训: 1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry 长期停在 1.0.5——下游 `pip install` 拿不到任何修复,且无人发现。
|
||||
|
||||
按顺序执行,**构建之前**必须先改完所有文档:
|
||||
|
||||
| # | 动作 | 要点 |
|
||||
|---|---|---|
|
||||
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
|
||||
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
|
||||
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
|
||||
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件,**外加 `pytest -m slow`** ——真实网关 e2e 与 Redis 时间语义变体被 `addopts = "-m 'not slow'"` 默认排除,**不显式跑就等于没跑**(约 20-40 分钟,取决于网关快慢)。它们不进日常提交是有意的: pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把"测试红了先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉;代价是这道门必须由本清单兜住 |
|
||||
| 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 |
|
||||
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
|
||||
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
|
||||
| 8 | **验证已发布** | `pip download --no-deps --index-url .../pypi/simple/ "polygateway==X.Y.Z"`,并解包确认新代码在内。**不验证不算发布完成** |
|
||||
| 9 | **建 Release + 挂仓库 + 核对包页面** | `POST /api/v1/repos/iomgaa/PolyGateway/releases`(body 取 CHANGELOG 本版段;历史上只打 tag 不建 release,Releases 页长期为空);挂仓库走 `POST /api/v1/packages/iomgaa/pypi/polygateway/-/link/PolyGateway`(**2026-08-16 实测返 201 可用**,此前记录的"该实例 link API 返 404、只能网页手动"已过时);随后打开包页面确认有正文与仓库链接 |
|
||||
|
||||
> [!CRITICAL]
|
||||
> **发布完成的判据是外部可见结果,不是本地步骤跑通**: 收尾必须以下游视角逐一打开产物页面(registry 包页面正文与仓库链接、仓库 Releases 页、`pip install` 后包内文件),看到什么算什么,缺的当场补进本清单——1.1.2 三步全绿却出现包页面空白(`pyproject` 缺 `readme`)、Releases 页 0 条、包未挂仓库。
|
||||
|
||||
Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI 元数据不含仓库字段,故不会自动挂到 `PolyGateway/packages`,需在包页面手动 Link to a repository。
|
||||
|
||||
### 4.5 配置管理
|
||||
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
|
||||
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
|
||||
@@ -82,6 +113,7 @@ make ci # 只读验证(check + test)
|
||||
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
|
||||
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
|
||||
- 涉及真实 LLM 的测试输出结构化 Markdown 至 `tests/outputs/<module>/<test>_<ts>.md`。
|
||||
- **成败取决于外部服务当下状态的测试一律标 `slow`**(`tests/e2e/` 四个文件与 Redis 时间语义变体):它们默认不进日常套件,由发布清单第 4 步统一跑。判据是"重跑一次可能就绿了"——这种测试留在提交关卡里会污染信号。同理,给它们的超时不得紧于 `.env` 的生产配置,否则是设计上就会间歇红。
|
||||
|
||||
## 5. 项目结构
|
||||
|
||||
@@ -112,6 +144,7 @@ project_root/
|
||||
| 三项目迁移文档(ARCHITECTURE §11 的展开,库设计的常驻约束) | `research-wiki/migrations/`(govdoc-saas / video-tree-trm5 / chsanalyzer) |
|
||||
| 功能设计文档(每次实现新功能时新增) | `research-wiki/designs/` |
|
||||
| 实现计划 | `research-wiki/plans/` |
|
||||
| **用户文档站**(Gitea Wiki,Diátaxis 四区)结构/更新时机/写作纪律 | `research-wiki/docs-convention.md`;**发版或公共行为变更必须按其 §2 清单同步 wiki 与 CHANGELOG,版本 bump 提交不得裸发** |
|
||||
| 治理网关参考实现 | `reference/Video-Tree-TRM5/adapters/`(llm/breaker/streaming/redis_cache/telemetry) |
|
||||
| 分布式限流/熔断参考实现 | `reference/CHSAnalyzer/app/coordination/`(limiter+Lua/provider_gate)与 `app/providers/governance.py` |
|
||||
| 错误分类参考 | `reference/CHSAnalyzer/app/domain/errors.py` |
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
.PHONY: install test lint format check ci wiki
|
||||
.PHONY: install test lint format check ci wiki wiki-check shared-table-gate
|
||||
|
||||
ENV := PolyGateway
|
||||
|
||||
# 集成测试触碰共享表 llm_calls 的字面量门(issue #18)。
|
||||
# 这道门是**烟雾报警器,不是隔离证明**: 它拦不住 f"{schema}.{table}" 拼接、
|
||||
# 参数化查询,或不带限定名的 DELETE 配上 admin 的默认 search_path。真正的隔离
|
||||
# 来自两处——沙箱工厂不把管理连接交给用例,以及清理脚本以无权角色运行。
|
||||
# 留着它是因为字面量回归最常见、也最便宜拦。
|
||||
shared-table-gate:
|
||||
@if grep -rn --include='*.py' 'public\.llm_calls' tests/; then \
|
||||
echo ""; \
|
||||
echo "错误: 集成测试不得触碰共享表(见上面的命中行)。"; \
|
||||
echo "改用 tests/integration/conftest.py 的 pg_sandbox 工厂;注释里提到它请写「共享表 llm_calls」。"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
install:
|
||||
conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]"
|
||||
|
||||
test:
|
||||
conda run -n $(ENV) pytest tests/ --cov=src/polygateway --cov-report=term-missing
|
||||
|
||||
lint:
|
||||
lint: shared-table-gate
|
||||
conda run -n $(ENV) ruff check src/ tests/ --fix
|
||||
conda run -n $(ENV) lint-imports
|
||||
|
||||
format:
|
||||
conda run -n $(ENV) ruff format src/ tests/
|
||||
|
||||
check:
|
||||
check: shared-table-gate
|
||||
conda run -n $(ENV) ruff format --check src/ tests/
|
||||
conda run -n $(ENV) ruff check src/ tests/
|
||||
conda run -n $(ENV) lint-imports
|
||||
@@ -24,3 +37,10 @@ ci: check test
|
||||
|
||||
wiki:
|
||||
conda run -n $(ENV) python3 .claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||
|
||||
# 用户文档站(Gitea Wiki)与源码的机械对齐校验。wiki 是独立仓库,须显式给路径:
|
||||
# make wiki-check WIKI=~/PolyGateway.wiki
|
||||
# 不并入 ci: 仓库里没有 wiki,自动跳过等于静默降级(违 P5),宁可让人显式跑。
|
||||
wiki-check:
|
||||
@test -n "$(WIKI)" || (echo "用法: make wiki-check WIKI=<PolyGateway.wiki 克隆路径>" && exit 1)
|
||||
conda run -n $(ENV) python3 tools/check_wiki_alignment.py --wiki $(WIKI)
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
# PolyGateway
|
||||
|
||||
实验室统一的大语言模型调度与中转库:LLM / VLM / OCR / Embedding 四类调用共用同一套生产级治理栈——多源多账号、限流、错误分类重试、熔断、响应缓存、流式看门狗、遥测与成本。治理单位是**一次模型调用**;任务编排、业务解析、图像预处理都留在业务侧。
|
||||
|
||||
> 由三个真实项目(GovDoc-SaaS / CHSAnalyzer / Video-Tree-TRM5)各自手写的治理栈提炼而来,并以"能否全量迁移回这三个项目"作为验收标准。v1.0.0 已通过 GovDoc 与 CHSAnalyzer 两项目的全量迁移验收(约 −6800 行项目侧治理代码由本库继任)。
|
||||
|
||||
## 为什么需要它
|
||||
|
||||
每个接入大模型的项目都会重写同一批东西:重试循环、429 处理、熔断器、SSE 解析、遥测埋点——写三遍就有三份 bug。本库把这些收敛为一份经过压测验证的实现:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 |
|
||||
| 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) |
|
||||
| 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After |
|
||||
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
|
||||
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
|
||||
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
||||
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
|
||||
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
|
||||
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
|
||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 26 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
||||
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
|
||||
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
||||
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
|
||||
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
|
||||
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
|
||||
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
|
||||
|
||||
**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。
|
||||
|
||||
## 安装
|
||||
|
||||
发布在实验室 Gitea PyPI(公开包,匿名可装):
|
||||
|
||||
```bash
|
||||
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||
"polygateway[redis,postgres,structured]>=1.3.0,<2"
|
||||
```
|
||||
|
||||
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||
|
||||
| extra | 内容 | 何时需要 |
|
||||
|---|---|---|
|
||||
| `redis` | redis-py | Redis 限流/熔断/缓存后端 |
|
||||
| `postgres` | asyncpg | Postgres 遥测后端 |
|
||||
| `structured` | json-repair | 结构化输出的修复策略 |
|
||||
| `sdk` | openai | 可选的 SDK transport(默认手写 httpx,不需要) |
|
||||
|
||||
要求 Python ≥ 3.12。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 配置 `.env`
|
||||
|
||||
```bash
|
||||
LLM__MINIMAX__1__BASE_URL=https://your-gateway/v1
|
||||
LLM__MINIMAX__1__API_KEY=sk-xxx
|
||||
LLM__MINIMAX__1__MODEL=MiniMax-M3
|
||||
LLM__MINIMAX__1__TIMEOUT_S=120
|
||||
LLM_MAX_RETRIES=3
|
||||
LLM_RETRY_BASE_DELAY=2.0
|
||||
LLM_RETRY_MAX_DELAY=30.0
|
||||
LLM_CIRCUIT_BREAKER_THRESHOLD=5
|
||||
LLM_CIRCUIT_BREAKER_COOLDOWN=60
|
||||
PGW_LIMITER_BACKEND=memory
|
||||
PGW_BREAKER_BACKEND=memory
|
||||
PGW_CACHE_BACKEND=none
|
||||
PGW_TELEMETRY_BACKEND=none
|
||||
```
|
||||
|
||||
缺任何关键键都会在装配时报错——本库禁止默认值兜底掩盖配置缺失。
|
||||
|
||||
### 2. 发起治理调用
|
||||
|
||||
```python
|
||||
from polygateway import GatewayClient
|
||||
|
||||
async def main() -> None:
|
||||
client = GatewayClient.from_env("LLM") # 读 .env 装配整套治理栈
|
||||
try:
|
||||
resp = await client.chat([{"role": "user", "content": "你好"}])
|
||||
print(resp.content, resp.source_name, resp.latency_ms)
|
||||
finally:
|
||||
await client.aclose() # 归还连接与治理后端资源
|
||||
```
|
||||
|
||||
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制,`tenant_id` / `meta` 用于遥测归属(见下文「多租户与自定义维度」);`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
|
||||
|
||||
### 3. OCR 与 Embedding
|
||||
|
||||
```python
|
||||
from polygateway import EmbeddingClient
|
||||
from polygateway.ocr import OcrClient
|
||||
|
||||
ocr = OcrClient.from_env("OCR") # OCR__MONKEY__1__* 多源
|
||||
text = await ocr.recognize_text(image_bytes) # 文本转录
|
||||
layout = await ocr.parse_layout(image_bytes) # 版面解析(带 bbox 的元素列表)
|
||||
health = await ocr.check_health() # 逐源预检 {"monkey_1": True, ...}
|
||||
|
||||
embed = EmbeddingClient.from_env("EMBED") # EMBED__*__* + EMBED__BATCH_SIZE
|
||||
vectors = (await embed.embed(["文本 a", "文本 b"])).vectors
|
||||
```
|
||||
|
||||
### 4. 业务侧异常处理
|
||||
|
||||
```python
|
||||
from polygateway import GatewayUnavailableError, RequestRejectedError
|
||||
|
||||
try:
|
||||
resp = await client.chat(messages)
|
||||
except GatewayUnavailableError as exc:
|
||||
# 整个 scope 暂时无源可用: 延期重投,不消耗业务失败预算
|
||||
schedule_retry(after_s=exc.retry_after_s) # exc.reason / exc.per_source_reasons 供诊断
|
||||
except RequestRejectedError:
|
||||
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
|
||||
```
|
||||
|
||||
### 5. 多租户与自定义维度
|
||||
|
||||
```python
|
||||
resp = await client.chat(
|
||||
messages,
|
||||
tenant_id="acme-corp", # 遥测表的真实列,可挂 RLS
|
||||
meta={"batch_id": "b-42", "stage": "extract"}, # 任意自定义 KV,进 meta 列
|
||||
)
|
||||
```
|
||||
|
||||
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
|
||||
|
||||
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
|
||||
|
||||
**库只提供列,不启用 RLS、不建索引。** 数据库层的强制隔离是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。三角色、RLS policy、分区与保留期的完整可执行模板见[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)。
|
||||
|
||||
## 遥测表 schema 与升级纪律
|
||||
|
||||
`llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类,别的一概不发:
|
||||
|
||||
| 库会发 | 库不发 |
|
||||
|---|---|
|
||||
| 列/表探测:PG 走 `to_regclass` + `pg_attribute`,SQLite 走 `PRAGMA table_info`(都只读 catalog) | `SELECT` 表数据——**库只写不读**,故你加多少列、建多少索引、怎么分区都不影响它 |
|
||||
| `INSERT`,**永远显式列名**,冲突处理不绑定具体约束(PG `ON CONFLICT DO NOTHING` / SQLite `INSERT OR IGNORE`) | `UPDATE` / `DELETE` / `TRUNCATE` / `DROP`——保留期与清理全归下游 |
|
||||
| 表不存在时 `CREATE TABLE IF NOT EXISTS`(PG 侧先探测,表在就不发) | `ALTER TABLE`,**除非**该后端处于 auto 档(见下);manual 档一条 DDL 都不发 |
|
||||
|
||||
### 补列档位 `PGW_TELEMETRY_SCHEMA_MODE`
|
||||
|
||||
| 取值 | 含义 |
|
||||
|---|---|
|
||||
| 不设(**缺省**) | 按后端派生:`sqlite` → auto、`postgres` → **manual** |
|
||||
| `auto` | 旧表缺列时库逐列 `ALTER TABLE ADD COLUMN` 补齐 |
|
||||
| `manual` | 库一条 `ALTER` 都不发;缺列只发**一条** warning(点名缺的维度 + 附上可直接执行的 SQL),并按现有列裁剪 `INSERT` 继续写 |
|
||||
|
||||
**缺省为什么两端不对称**:PG 侧是共享的生产表,`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的**所有**查询,而遥测是业务路径上的内联 `await`;这类部署有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑。SQLite 侧是下游自己的本地文件(现有下游典型是 `runs/*.db`):没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里,**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。同一个键两侧都可显式覆盖。
|
||||
|
||||
| 表状态 | `auto` | `manual` |
|
||||
|---|---|---|
|
||||
| 不存在 | 建表 | **仍然建表**(新表无既有数据、无并发访问者,不存在锁队列风险;停掉它会让"零配置起步"断掉) |
|
||||
| 存在、列齐 | 不发任何 DDL | 不发任何 DDL |
|
||||
| 存在、缺列 | 逐列 `ALTER`;**失败不裁剪**,缺列以逐行 warning 暴露(承诺的是"把列补上",补不上就让问题可见;要降级写入请显式选 `manual`) | 不发 DDL,裁剪写入,缺的维度不落库 |
|
||||
|
||||
无论哪档,遥测的失败方向都是**静默降级**:缺列、补列失败、写入失败都只 warning,绝不冒泡打断业务调用。
|
||||
|
||||
### 自取建表脚本
|
||||
|
||||
`telemetry_schema_sql` 输出与库运行时执行的 DDL **同源**(同一份常量),照它建完表,库探测到的列就是齐的:
|
||||
|
||||
```python
|
||||
import polygateway
|
||||
|
||||
print(polygateway.telemetry_schema_sql("postgres")) # 或 "sqlite";非法值抛 ValueError
|
||||
```
|
||||
|
||||
```bash
|
||||
# 直接落成迁移文件:注释头 + CREATE TABLE IF NOT EXISTS(全量列)+ 各补列语句
|
||||
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
|
||||
> migrations/001_llm_calls.sql
|
||||
```
|
||||
|
||||
PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**(它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故请挑低峰);SQLite 没有该语法,脚本以注释标明"仅当该列不存在时执行"。注意这与库**内部**执行的 ALTER 是两份文本:库侧一律先探测后 ALTER,不用 `IF NOT EXISTS`,正是为了在稳态下一条排他锁都不取。
|
||||
|
||||
### Expand/Contract 承诺
|
||||
|
||||
这张表的演进只走 expand,不走 contract。以下五条既是当前实现,也是**库对下游的承诺**——库此后的演进受它们约束:
|
||||
|
||||
| 承诺 | 你可以据此做什么 |
|
||||
|---|---|
|
||||
| 新列**只增不删不改名**,一律追加在既有列**之后** | 已有的视图、报表、ETL 不会因升级而失效 |
|
||||
| 新列必**可空**,或带**非易失常量默认值** | PG 11+ 补列不重写全表,SQLite 补列是元数据操作——大表升级也是秒级 |
|
||||
| `INSERT` **永远显式写出列名** | 你可以自行加列(业务维度、生成列),库的写入不受影响 |
|
||||
| 库从不 `SELECT *`,也从不读回这张表的数据 | 库侧根本没有读路径,你加索引、加自己的列、挂 RLS 都影响不到它 |
|
||||
| 写入的冲突处理**不绑定具体约束** | 你可以把 `llm_calls` 建成 `PARTITION BY RANGE (created_at)` 的分区表(此时主键必须是 `(call_id, created_at)`,PG 要求分区表唯一约束含分区键),库的探测、补列与写入照常工作 |
|
||||
|
||||
## 生产部署 DDL 模板(PostgreSQL)
|
||||
|
||||
上一节讲的是**库怎么对待这张表**(只探测、只 INSERT、可选建表);本节讲的是**你该把这张表部署成什么样**:谁能读、谁能写、写进去的行能不能被改、存多久。这些库一件都不代劳——它没有、也不该有这些权限。
|
||||
|
||||
<!-- 下面带 `pg-template:*` 锚点的 SQL 块被 tests/integration/test_postgres_telemetry.py 逐条解析并在真实 PG 上执行;改动块内容或锚点名请同步该测试。 -->
|
||||
|
||||
模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。
|
||||
|
||||
| # | 锚点 | 做什么 |
|
||||
|---|---|---|
|
||||
| 1 | `roles` | 建三角色并授 schema 级权限 |
|
||||
| 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` |
|
||||
| 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) |
|
||||
| 4 | `grants` | 授表级权限并 `REVOKE UPDATE, DELETE` |
|
||||
| 5 | `immutable` | 触发器兜底(只防误操作) |
|
||||
| 6 | `rls` | 启用并 `FORCE` RLS + 两条 policy |
|
||||
| 7 | `index` | `(tenant_id, created_at)` 复合索引 |
|
||||
|
||||
### 1. 三角色
|
||||
|
||||
| 角色 | 拿到什么 | 谁在用 |
|
||||
|---|---|---|
|
||||
| `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** |
|
||||
| `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 |
|
||||
| `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 |
|
||||
|
||||
<!-- pg-template:roles -->
|
||||
|
||||
```sql
|
||||
CREATE ROLE polygateway_owner NOLOGIN;
|
||||
CREATE ROLE polygateway_app LOGIN PASSWORD 'CHANGE_ME_APP';
|
||||
CREATE ROLE polygateway_report LOGIN PASSWORD 'CHANGE_ME_REPORT';
|
||||
GRANT polygateway_owner TO CURRENT_USER; -- 下一块要把表属主改过去,须先成为它的成员
|
||||
GRANT USAGE ON SCHEMA public TO polygateway_owner, polygateway_app, polygateway_report;
|
||||
GRANT CREATE ON SCHEMA public TO polygateway_owner; -- 滚动分区要在该 schema 里建表
|
||||
```
|
||||
|
||||
### 2. 分区表
|
||||
|
||||
分区表**必须下游先手工建**:库的 `CREATE TABLE` 只会建普通表。列不在这里重抄一份——抄了就会漂移,故先用库自带脚本建出普通表,再原地改造:
|
||||
|
||||
```bash
|
||||
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
|
||||
| psql "$PGW_TELEMETRY_PG_DSN"
|
||||
```
|
||||
|
||||
<!-- pg-template:table -->
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls RENAME TO llm_calls_seed; -- 上一步建出的普通表当模子
|
||||
CREATE TABLE llm_calls (
|
||||
LIKE llm_calls_seed INCLUDING DEFAULTS, -- 列/类型/NOT NULL/DEFAULT 全照搬
|
||||
PRIMARY KEY (call_id, created_at) -- 分区表的唯一约束必须含分区键
|
||||
) PARTITION BY RANGE (created_at);
|
||||
DROP TABLE llm_calls_seed;
|
||||
ALTER TABLE llm_calls OWNER TO polygateway_owner;
|
||||
```
|
||||
|
||||
<!-- pg-template:partition -->
|
||||
|
||||
```sql
|
||||
CREATE TABLE llm_calls_2026_01 PARTITION OF llm_calls
|
||||
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');
|
||||
ALTER TABLE llm_calls_2026_01 OWNER TO polygateway_owner;
|
||||
```
|
||||
|
||||
生产不要手工滚月份,交给 [`pg_partman`](https://github.com/pgpartman/pg_partman):5.x 用 `create_parent(p_parent_table := 'public.llm_calls', p_control := 'created_at', p_interval := '1 month')`(4.x 的参数序不同,以你装的版本文档为准),再把 `part_config.retention` 设成 `'6 months'`、`retention_keep_table` 设成 `false`,`run_maintenance_proc()` 就会到期 `DROP` 整个分区。清理必须走 `DETACH`/`DROP PARTITION` 而**不是** `DELETE`——这不是性能偏好,是权限张力的唯一解:下一块要对应用角色 `REVOKE DELETE`,而 `DROP PARTITION` 是属主的 DDL,两者不冲突,`DELETE` 则必然冲突。
|
||||
|
||||
**分区部署改变了幂等键**,按 `cache_hit` 出报表的下游必须知道:普通表上主键是 `call_id`,分区表上是 `(call_id, created_at)`。库的写入是无冲突目标的 `ON CONFLICT DO NOTHING`,两种表形态都合法;但 `emit_cache_hit` 复用的是响应里的**历史** `call_id`,于是同一次缓存命中的重复回放,在普通表上第二次起被 `DO NOTHING` 吞掉、在分区表上**每次都落一行**(`created_at` 由 `DEFAULT now()` 生成,主键不再重复)。逐次尝试行不受影响(每次尝试都是新 `call_id`)。
|
||||
|
||||
### 3. 权限与不可变性
|
||||
|
||||
`llm_calls` 按**不可变审计表**对待:写进去的行谁都不许改、不许删,过期数据靠 `DROP PARTITION` 整块消失。
|
||||
|
||||
<!-- pg-template:grants -->
|
||||
|
||||
```sql
|
||||
GRANT INSERT, SELECT ON llm_calls TO polygateway_app;
|
||||
GRANT SELECT ON llm_calls TO polygateway_report;
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON llm_calls FROM polygateway_app, polygateway_report;
|
||||
```
|
||||
|
||||
<!-- pg-template:immutable -->
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION llm_calls_reject_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'llm_calls 是不可变审计表,% 被拒绝', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER llm_calls_immutable BEFORE UPDATE OR DELETE ON llm_calls
|
||||
FOR EACH ROW EXECUTE FUNCTION llm_calls_reject_mutation();
|
||||
```
|
||||
|
||||
触发器**只防误操作,不防恶意**:表属主可以 `ALTER TABLE llm_calls DISABLE TRIGGER llm_calls_immutable` 把它关掉。真正的强制是上一块的 `REVOKE`——权限检查发生在触发器之前,应用角色连触发器都碰不到。要防属主本人,需要的是数据库之外的手段(WAL 归档、只追加的外部存证),不是本表能解决的。
|
||||
|
||||
`DROP PARTITION` 与 `DETACH PARTITION` 是 DDL,**不会触发**行级触发器,故保留期清理不受这一块影响。
|
||||
|
||||
### 4. 行级安全与多租户隔离
|
||||
|
||||
> **照抄过 1.2.1 那份 RLS 模板的部署请先查一遍**:那份模板把**写侧**也绑在 `app.tenant_id` 这个 GUC 上,而库从不设这个 GUC,于是它的每一条 `INSERT` 都被 policy 拒绝——遥测的失败方向是静默降级,表现不是报错而是**整张表零行**。用能绕过 RLS 的角色(superuser 或带 `BYPASSRLS`)执行 `SELECT count(*) FROM llm_calls;`,并在应用日志里搜 `Postgres 遥测写入失败(丢弃该行):`。下面这份是修正后的模板。
|
||||
|
||||
<!-- pg-template:rls -->
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
|
||||
CREATE POLICY llm_calls_app_write ON llm_calls FOR INSERT TO polygateway_app
|
||||
WITH CHECK (true);
|
||||
CREATE POLICY llm_calls_app_read ON llm_calls FOR SELECT TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
CREATE POLICY llm_calls_report_read ON llm_calls FOR SELECT TO polygateway_report
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
```
|
||||
|
||||
<!-- pg-template:index -->
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
|
||||
```
|
||||
|
||||
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。分区表上**不能**用 `CREATE INDEX CONCURRENTLY`(PG 不支持在分区父表上并发建索引);父表此时还没有数据,直接建即可,给已有数据的普通表补索引才需要逐个分区 `CONCURRENTLY`。
|
||||
|
||||
**写侧 policy 为什么是 `WITH CHECK (true)` 而不是等值比较**:库用一个连接池给**所有**租户写遥测,且从不发 `set_config('app.tenant_id', ...)`(源码里没有这条语句)。把写侧也绑到 GUC 上,库的每一条 `INSERT` 都会被 policy 拒绝——而遥测的失败方向是静默降级,表现是逐行 warning + 整表零行。隔离在这个模型里由**读侧**承担:写入方是库自己(可信),读取方才是要隔离的人。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(只留一条 warning)。
|
||||
|
||||
四个陷阱,每一个的失败形态都是**静默的**:
|
||||
|
||||
| 陷阱 | 后果 |
|
||||
|---|---|
|
||||
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
|
||||
| `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 |
|
||||
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
|
||||
| 读侧 policy 漏写 `USING` | `FOR SELECT` 的 policy 只认 `USING`;写成 `WITH CHECK` 不报错也不生效,隔离直接落空 |
|
||||
|
||||
### 5. 库本身需要的最小权限
|
||||
|
||||
按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给:
|
||||
|
||||
| 库会发的语句 | 需要什么 |
|
||||
|---|---|
|
||||
| 连库 | 数据库 `CONNECT` + schema `USAGE` |
|
||||
| `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) |
|
||||
| `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy |
|
||||
| `CREATE TABLE IF NOT EXISTS`(**仅当表不存在**) | schema `CREATE`。生产建议**不给**:表由 `owner` 先建好,库探测到表在就不发这条 |
|
||||
| `ALTER TABLE ADD COLUMN`(**仅 `PGW_TELEMETRY_SCHEMA_MODE=auto`**) | 表**属主**——PG 的 `ALTER TABLE` 只认属主,这一项无法单独 `GRANT`。PG 侧缺省就是 `manual`,补列交给 DBA |
|
||||
|
||||
### 6. 合规下游的推荐配置
|
||||
|
||||
三件事(截断、保留期、访问控制)要一起上才有意义,故给一份可直接照抄的组合,而不是让你自己拼:
|
||||
|
||||
```dotenv
|
||||
PGW_TELEMETRY_BACKEND=postgres
|
||||
PGW_TELEMETRY_PG_DSN=postgresql://polygateway_app:...@db:5432/telemetry
|
||||
PGW_TELEMETRY_SCHEMA_MODE=manual # PG 侧本就是缺省;写出来是为了不依赖缺省
|
||||
PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全文
|
||||
```
|
||||
|
||||
| 层 | 配置 |
|
||||
|---|---|
|
||||
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
|
||||
| 保留期 | 上面的分区模板 + `pg_partman` 的 `retention`,过期分区整块 `DROP` |
|
||||
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
|
||||
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`;**`--table <schema>.llm_calls` 把目标钉死**,不给则由连接的 `search_path` 推断) |
|
||||
|
||||
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments`、`name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
|
||||
|
||||
### 7. SQLite 侧的保留期
|
||||
|
||||
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db`、`runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
|
||||
|
||||
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
|
||||
|
||||
**`--apply` 之前先把目标钉死。** 不给 `--table` 时,脚本删哪张表取决于连接的 `search_path`——它的首项是 `"$user"`,故换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。`--table <schema>.llm_calls` 让目标由参数精确解析、不再经 `search_path` 推断;表名段固定为 `llm_calls`(本脚本只清理遥测表,不是通用清理器),写别的名字会以退出码 1 被拒。cron 里跑 `--apply` 尤其该给它:那一行配置从此自己说明删的是哪张表。
|
||||
|
||||
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
|
||||
|
||||
## 错误模型(四分类)
|
||||
|
||||
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
|
||||
|
||||
| 分类 | 含义 | 库内行为 |
|
||||
|---|---|---|
|
||||
| `TransientError` | 超时/5xx/网络抖动/截断流 | 换源重试 + 退避 |
|
||||
| `SourceDeadError` | 401/403/欠费(429+insufficient_quota) | 立即熔断该源 + 换源 |
|
||||
| `RequestRejectedError` | 400/内容拒绝/本地格式拒绝 | 不重试不换源,快速失败 |
|
||||
| `ResultInvalidError` | 调用成功但结果不合格(坏 JSON/维度不符/坏 bbox) | 不熔断("坏结果 ≠ 坏服务"),按策略有界重问或上抛 |
|
||||
|
||||
预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。
|
||||
|
||||
**网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。
|
||||
|
||||
### 哪些异常会到达调用方
|
||||
|
||||
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
|
||||
|
||||
| 会到达调用方 | 库内吸收(不必 catch) |
|
||||
|---|---|
|
||||
| `GatewayUnavailableError` 族——`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` | `TransientError`(退避后换源重试,耗尽即转为 `AllSourcesExhausted`) |
|
||||
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
|
||||
| `ResultInvalidError` | |
|
||||
| `SourceNotConfiguredError` | |
|
||||
|
||||
**`GovernanceBackendError` 属于第一列**: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 `GatewayUnavailableError`,所以 §4 那段 `except GatewayUnavailableError` 一条即覆盖完整,无需为它单列分支。`retry_after_s` 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。
|
||||
|
||||
**`SourceNotConfiguredError` 有意不在第一列的族内**: 源名不在限流后端的配置字典中是**装配缺陷**而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。
|
||||
|
||||
## 配置参考
|
||||
|
||||
配置只有两条装配路径:`from_env()`(读 `.env`/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 [.env.example](.env.example),约定速览:
|
||||
|
||||
| 键形态 | 作用 |
|
||||
|---|---|
|
||||
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/REASONING_EFFORT/EFFORT_FALLBACK/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
|
||||
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
|
||||
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
|
||||
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) |
|
||||
| `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) |
|
||||
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
|
||||
| `PGW_TELEMETRY_SCHEMA_MODE` | 可选:`auto` / `manual`;**不设则按后端派生**(sqlite→`auto`、postgres→`manual`),显式设置则两侧都可覆盖。决定库是否给已存在的旧表自动 `ALTER` 补列,详见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律) |
|
||||
| `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text`、`response`、`thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) |
|
||||
| `PGW_TELEMETRY_PG_POOL_MAX` | 可选正整数(缺省 4):Postgres 遥测池的连接**上限**。池按需建连,闲时占 0 条,这一格是忙时天花板而非常驻量。调参按实测折算而非按 `pool_max / RTT` 估算——跨内网 RTT ≈ 123ms 上 `pool_max=4` 实测约 15.6 行/秒(一次 `INSERT` 的往返比一次 `SELECT 1` 重一倍);多个 client 共享同一 recorder 时并发在此汇聚,应相应放大 |
|
||||
| `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | 可选正数(缺省 5.0):**一次遥测写入的硬预算**,同时用作建连、`acquire` 与「准备 + 取连接 + 执行」整段的上界;超时即丢该行,绝不让遥测无界地挂在业务路径上 |
|
||||
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
|
||||
|
||||
**`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 `fail_fast`)——单源 scope 请配 `wait`**
|
||||
|
||||
熔断的设计前提是"这个源坏了,把流量导到别的源"。**只配了一个源时这个前提不成立**,同一段代码做的事变成"这个源坏了,所以整个 scope 停止服务":开路期间每一次调用都在几毫秒内失败,`MAX_ATTEMPTS` 一格用不上,一个网络包都没发出去。中转抖动几十秒就足以打断一条跑了几小时的长任务。
|
||||
|
||||
`wait` 档改变的**只是**"调用方当场失败还是排队等":等待期间照样一个请求都不发,熔断对配额和钱包的保护完整保留。代价是单次调用的最坏墙钟被拉长,上限为 `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S`(缺省 300 秒)。**`wait` 不豁免重试预算**——冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `MAX_ATTEMPTS`;因此密钥失效(401/403)这类一击即熔的源通常更早以 `reason=retry_exhausted` 失败,而非等满窗口的 `stalled`。库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不要当场死"。多源部署保持 `fail_fast`:有源可换时,换源比等待快。
|
||||
|
||||
该键与 `{SCOPE}__QUOTA_FULL` 同形但**不可互相替代**:配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),所以两者分开配置。
|
||||
|
||||
两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。
|
||||
|
||||
`SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。
|
||||
|
||||
## 架构
|
||||
|
||||
端口适配器 + 中间件洋葱:决策逻辑一份,状态存储可插拔。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[业务代码] --> B[GatewayClient]
|
||||
B --> C[缓存 MW] --> D[遥测 MW] --> E[重试/选源/限流/熔断 MW]
|
||||
E --> F[Transport httpx]
|
||||
F --> G[(上游网关)]
|
||||
E -.端口.-> H[(内存 / Redis 后端)]
|
||||
D -.端口.-> I[(SQLite / Postgres)]
|
||||
```
|
||||
|
||||
| 模块 | 职责 |
|
||||
|---|---|
|
||||
| `types.py` / `errors.py` / `ports.py` | 内核:冻结类型、四分类异常、全部 Protocol(最内层,不依赖任何实现) |
|
||||
| `middleware/` | 治理算法(重试/限流/熔断/缓存/遥测),只面向端口 |
|
||||
| `transports/` | 协议细节:OpenAI 兼容 SSE、MonkeyOCR 双端点;错误翻译在此层 |
|
||||
| `backends/` | 限流/熔断/缓存的内存与 Redis 实现(同一契约测试套件双后端共用) |
|
||||
| `telemetry/` | SQLite / Postgres 遥测后端 |
|
||||
| `structured/` | 结构化输出策略 |
|
||||
|
||||
依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D15 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。
|
||||
|
||||
## 可靠性证据
|
||||
|
||||
行为不是宣称出来的,是压测出来的(数字见 `research-wiki/findings/`):
|
||||
|
||||
| 场景 | 结果 |
|
||||
|---|---|
|
||||
| 故障混编 soak(坏 key/黑洞/慢源/限流源混合,8000 调用) | 成功率 98.96%,坏源吸流被压制,真实源零误熔 |
|
||||
| OCR 故障池 soak(1500 调用,redis 双后端跨进程) | 成功率 99.73%,13 项不变量全过(租约归零/探针不悬挂/零取消泄漏等) |
|
||||
| 两项目全量迁移回归 | 原测试全绿 + 真实链路冒烟 + 50 样本批跑 100% 解析 |
|
||||
|
||||
时间语义测试(租约过期、窗口滚动、半开探针)全部真实等待不缩放;Redis/Postgres 测试打真实实验室后端,不 mock Lua。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
conda create -n PolyGateway python=3.12 && conda activate PolyGateway
|
||||
make install # editable 安装(dev + 全部 extras)
|
||||
make test # pytest + 覆盖率(目标 ≥80%)
|
||||
make lint # ruff + import-linter
|
||||
make ci # 只读全量验证
|
||||
```
|
||||
|
||||
测试组织:`tests/{unit,integration,e2e}` + 双后端契约测试;并发/取消/降级方向是一等测试对象。压测 harness 在 `tools/soak/`。贡献流程与项目纪律见 [CLAUDE.md](CLAUDE.md)。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 想了解 | 看 |
|
||||
|---|---|
|
||||
| 全部架构决策及理由(单一事实源) | `research-wiki/ARCHITECTURE.md` |
|
||||
| 里程碑与状态 | `research-wiki/ROADMAP.md` |
|
||||
| 项目迁移指南(删除清单/组件映射/行为审计) | `research-wiki/migrations/` |
|
||||
| 每个功能的设计与验收记录 | `research-wiki/designs/`、`research-wiki/findings/` |
|
||||
| 版本变更 | [CHANGELOG.md](CHANGELOG.md) |
|
||||
|
||||
## 兼容性承诺
|
||||
|
||||
`LLMResponse` 等被下游消费的公共类型,字段**只增不删不改名**且新增字段必带默认值;`{SCOPE}__{PROVIDER}__{N}__{FIELD}` 与平铺韧性键名(`LLM_TIMEOUT` 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"MiniMax-M3": {
|
||||
"input_per_1m": 2.1,
|
||||
"output_per_1m": 8.4
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -4,9 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "polygateway"
|
||||
version = "1.0.0"
|
||||
version = "1.3.3"
|
||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||
requires-python = ">=3.11"
|
||||
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
||||
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.8",
|
||||
@@ -31,6 +34,11 @@ dev = [
|
||||
"import-linter>=2.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.iomgaa.online/iomgaa/PolyGateway"
|
||||
Changelog = "https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/CHANGELOG.md"
|
||||
Issues = "https://gitea.iomgaa.online/iomgaa/PolyGateway/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -48,7 +56,7 @@ markers = [
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
@@ -73,6 +81,7 @@ layers = [
|
||||
"polygateway.config",
|
||||
"polygateway.middleware",
|
||||
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
|
||||
"polygateway.thinking",
|
||||
"polygateway.providers : polygateway.sources",
|
||||
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
|
||||
]
|
||||
|
||||
+193
-14
@@ -119,7 +119,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构决策记录(D1–D14,含讨论过程与备选方案)
|
||||
## 3. 架构决策记录(D1–D15,含讨论过程与备选方案)
|
||||
|
||||
> 每条决策记录格式:**决策 / 背景与讨论 / 被否决的备选 / 影响**。这些决策已与人类逐条确认;推翻任何一条需要人类批准并修订本节。
|
||||
|
||||
@@ -219,6 +219,8 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
||||
|
||||
**决策**: 消灭 `"qwen" in provider`、`model.split("-")[0]` 式字符串猜测。显式 provider 注册表,每个 provider 声明:thinking 参数注入方式(deepseek `{"thinking":{"type":"enabled"}}` / qwen `{"enable_thinking": True}`)、思考流字段(`reasoning_content` / `<think>` 标签剥离)、原生 schema 能力(供 D7 策略选择)、默认错误翻译细则。新 provider = 注册一个条目,不改核心类。
|
||||
|
||||
**职责拆分(2026-08-25,issue #16/#17)**: 上面这条决策里的**推理**部分已从 `providers.py` 移出,落进新模块 `thinking.py`。起因是推理这件事从「请求侧注入什么参数」长成了「请求侧注入 + 响应侧裁定 + 两者对账」三件事,留在注册表里会让 `providers.py` 变成「推理的一切」,一句话说不清职责(P3)。拆后 `providers.py` 只回答**provider 是什么**(`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`/`register_provider`),`thinking.py` 承载**推理这件事的全部决策**(`ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`/`register_capability`、`resolve_thinking`、`observe_thinking`、`reconcile_thinking`、`ThinkingUnsupportedError`);纯值类型 `ThinkingObservation` 归最内层 `types.py`(§5.1)。六个公共符号同批提升到包根导出——此前只能深路径 import,而深路径引用正是模块重组会打断下游的原因。
|
||||
|
||||
### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
|
||||
|
||||
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
|
||||
@@ -248,6 +250,22 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
||||
|
||||
**影响**: §5.2 `structured` 参数三档语义、§7.9 重写为阶梯、§6.1 ResultInvalid 行注 D14;缓存写入发生在阶梯通过之后(§7.5 "不固化坏结果"的执行点);反馈模板与策略升级细则留 M1 设计文档。
|
||||
|
||||
### D15 库对下游数据库只做 SELECT/INSERT + 可选 CREATE;改结构与删数据归下游(2026-08-19,issue #13 立,issue #12 补删数据一面)
|
||||
|
||||
**决策**: 遥测表 `llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类——catalog 探测(PG `to_regclass` + `pg_attribute`,SQLite `PRAGMA table_info`)、显式列名的 `INSERT`、以及表不存在时的 `CREATE TABLE IF NOT EXISTS`;**改结构(`ALTER`)与删数据(`UPDATE`/`DELETE`/`TRUNCATE`/`DROP`)一律归下游**。`ALTER` 保留唯一一个受控出口:`PGW_TELEMETRY_SCHEMA_MODE=auto` 时给已存在的旧表补列,而该档在 PG 侧**不是缺省**(缺省按后端派生: sqlite→auto、postgres→manual)。配套五条 Expand/Contract 承诺:新列只增不删不改名且追加在既有列之后、新列必可空或带非易失常量默认值、`INSERT` 永远显式列名、库从不 `SELECT *` 也从不读回该表数据、写入的冲突处理不绑定具体约束。
|
||||
|
||||
**背景与讨论**: 补列此前没有任何开关,库一升级、下次调用即在下游生产库上发 DDL。issue #13 的三条指控成立: ① 与最小权限原则冲突;② 多进程/多版本共存时谁先补列是竞态;③ DDL 不进任何迁移记录,DBA 事后无从审计。量级判据是 `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 `await`。调研的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。
|
||||
|
||||
两条边界是讨论出来的、不是照抄先例: **① 缺省按后端不对称**(D-a,人类拍板)——issue 引用的全部先例语境都是共享的生产 PG,而本库的 SQLite 侧是下游自己的本地文件(没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作),两侧统一 manual 会给零运维场景强加运维步骤;两侧有意不对称在本库已有先例(§7.8 的建表探测,issue #9)。**② manual 档不连 `CREATE TABLE` 一起停**——新建表没有既有数据与并发访问者,不存在锁队列与数据风险,停掉它会让"零配置起步"断掉(Celery 的先例同样是"自动建表 + 永不 ALTER")。**③ 关掉 `ALTER` 必须配套按现有列裁剪 `INSERT`**,否则旧表缺列时每行写入都被拒,是把自动补列换成静默全失能,比原问题更严重地违反「遥测必录」。
|
||||
|
||||
五条承诺本身是既有实现的**成文化**(零代码变更),但成文后才可被下游依赖——它同时是遥测保留期方案(issue #12)能成立的前提: 下游拿这份 schema 自己加 `PARTITION BY RANGE (created_at)` 建成分区表后,库的 `to_regclass` 探测、列探测与 `INSERT` 路由都照常工作。第五条(冲突处理不绑定约束)是审查带出的**新增**承诺,并伴随一处真实修复,见 §7.8。
|
||||
|
||||
**删数据这一半(2026-08-19,issue #12)**: D15 里 `DELETE`/`TRUNCATE`/`DROP` 归下游,不只是"库不去做",是库连**手段**都不该持有——保留期与访问控制因此以 README 的 DDL 模板加 `tools/telemetry_retention.py` 独立脚本交付,库本体不 import 该脚本,连接串上也不需要任何删权限。这是 (b) 保留期与 (c) 不可变性两条诉求的**权限张力**逼出来的唯一解: 模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`——分区在这里**不可替代**,不是性能偏好(`DROP PARTITION` 是 DDL,同样不触发行级的不可变性触发器,且 O(1)、不留膨胀)。脚本只是存量普通表的兜底: 默认 dry-run,探测到分区表即以退出码 3 让路。库本体在 #12 里唯一的代码面是**预防性**的正文截断(§7.8)——没写进去的数据不需要删,这也是三个子问题里唯一能靠库解决的那个。
|
||||
|
||||
**被否决的备选**: 两侧统一缺省 manual(语义最一致,但现有 SQLite 下游升级即需人工干预,而这些场景根本没有承接手工 SQL 的角色);保持 auto 缺省只加关闭档(默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足);Celery 式"自动建表但永不 ALTER、无开关"(SQLite 场景纯净损失,且真想要自动补列的下游没有出路);APScheduler 4.x 式"schema 不认识就拒绝启动"(与「遥测初始化失败必须静默降级」的库铁律正面冲突,不可选)。
|
||||
|
||||
**影响**: §7.8 补列一节按档位重写;新增配置键 `PGW_TELEMETRY_SCHEMA_MODE`(§9)与公共函数 `telemetry_schema_sql`;两个 recorder 新增 keyword-only 必填参数 `auto_migrate`、`GatewaySettings` 新增必填字段 `telemetry_auto_migrate`(缺省规则只写在 config 一处,不与类签名漂移);五条承诺进 README(随包分发)。issue #12 实现同一条边界的"删数据"一面: 新增可选键 `PGW_TELEMETRY_TEXT_CAP` 与遥测正文截断(§7.8、§9),保留期与访问控制走文档模板 + `tools/` 脚本,库的权限面不扩大。
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体架构
|
||||
@@ -302,13 +320,39 @@ flowchart TB
|
||||
### 4.4 一次调用的生命周期(walkthrough)
|
||||
|
||||
1. **缓存命中**: TelemetryMW 记录(cache_hit=True, latency_ms=0)→ CacheMW 返回,不触达任何更内层。
|
||||
2. **正常路径**: RetryMW 开始第一次尝试 → selector 选源(跳过冷却中的源)→ 该源熔断门(闭路)→ 限流 acquire permit(全局+该源,并发/RPM/TPM 三闸,token 按 `est_tokens` 预扣)→ transport 发请求、流式解析(看门狗包裹)、收 usage 帧 → permit 按实际 usage settle(多退少补)→ 回程写缓存 → 遥测记成功(含 ttft/max_inter_token/成本)。
|
||||
2. **正常路径**: RetryMW 开始第一次尝试 → selector 选源(跳过冷却中的源)→ 该源熔断门(闭路)→ 限流 acquire permit(全局+该源,并发/RPM/TPM 三闸,token 按**有效预扣量** `SourceConfig.effective_est_tokens()` 预扣,取值规则见 §7.7)→ transport 发请求、流式解析(看门狗包裹)、收 usage 帧 → permit 按实际 usage settle(多退少补)→ 回程写缓存 → 遥测记成功(含 ttft/max_inter_token/成本)。
|
||||
3. **瞬时错误**(超时/5xx/429/SSE 异常): transport 翻译为 `TransientError` → RetryMW 指数退避+jitter(取 Retry-After 提示与退避的较大值)后换源重试;每次尝试独立 call_id、独立过限流闸、失败即报熔断计数与遥测。
|
||||
4. **源死亡**(401/403/欠费): `SourceDeadError` → 该源熔断 force_open + 本地冷却备忘 → 立即换下一源,不退避等待。
|
||||
5. **请求被拒**(400/坏输入): `RequestRejectedError` → 不重试不换源,直接上抛;遥测记录。
|
||||
6. **开路/全源耗尽**: `CircuitOpenError` / `AllSourcesExhausted` → 按配置 wait(等待恢复,含 stall 判定)或 fail-fast 上抛。
|
||||
7. **任意时刻取消**: `CancelledError` 穿透所有层;in-flight permit 与连接在 finally 释放。
|
||||
|
||||
### 4.5 资源所有权纪律: 谁建的谁关,注入的一律不碰(2026-08-24,issue #15)
|
||||
|
||||
这是**跨子系统的通用纪律**,不是遥测的局部约定。它被写下来的直接原因是: 库对"谁建的、谁负责关"从来没有统一说法,于是同一个根因在三个地方长出三种形态——
|
||||
|
||||
| 形态 | 位置(修复前) | 性质 |
|
||||
|---|---|---|
|
||||
| `GatewayClient.aclose()` 无条件关掉**注入的** telemetry,共享 recorder 被第一个关闭的 client 弄死(`embedding.py`/`ocr.py` 各有一份逐字复制) | `client.py:271-273` | 越权 |
|
||||
| `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 |
|
||||
| `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不持有 limiter/breaker 的引用) | `client.py:263-280` | 泄漏 |
|
||||
| 对照组: `RedisLimiter._owns_client` 的纪律**一直是对的** | `limiter.py:185-191, 318-322` | 正确先例 |
|
||||
|
||||
纪律把已有的那个正确先例推广为全库唯一说法,分两层落地:
|
||||
|
||||
| 层 | 所有权归属 | 落法 |
|
||||
|---|---|---|
|
||||
| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 |
|
||||
| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性,`aclose` 只关自建的;三处复制的 `getattr(..., "aclose")` 鸭子探测收敛为一个内部 helper(同时探测 `aclose`/`close`,SQLite recorder 只有同步 `close()`) |
|
||||
|
||||
三条实现细则各自都是"少写一条就等于纪律不成立":
|
||||
|
||||
1. **默认必须是"不拥有"**。`__init__` 是全量注入路径,经它传入的一切组件一律 `_owns_* = False`,只有三个工厂在真正自建时置 True。默认若反过来,直接构造路径下共享 transport 仍会被第一个 client 关掉。
|
||||
2. **判定一律用 `is None` / `is not None`,不用 `or`**。工厂里 `limiter or _build_limiter(...)` 这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这是所有权判定能成立的**必要条件**,不是风格偏好。
|
||||
3. **三个 client(chat/embedding/ocr)必须逐一持有 limiter/breaker 引用并各自被测试钉一次**。收敛成 helper 之后仍要三处各钉一次,否则下次有人把逻辑复制回去无人发现;`GatewayClient` 此前把 limiter/breaker 交给 `RetryMW` 后自己不留引用,`aclose` 因此触达不到自建的 redis 客户端,泄漏就是这么来的。
|
||||
|
||||
公共 API 面零变化(`_owns_*` 是私有属性)。**对下游的可见后果**只有一条,且必须显式声明: `aclose()` 不再关闭注入进来的组件,若有下游依赖了"注入后由 client 代关",升级后需自己关。
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心类型
|
||||
@@ -322,13 +366,52 @@ flowchart TB
|
||||
| `content` | str | 正式输出文本 |
|
||||
| `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) |
|
||||
| `model` / `provider` | str | 溯源 |
|
||||
| `prompt_tokens` / `completion_tokens` | int | usage 帧读取;缺失时按估算标注 |
|
||||
| `prompt_tokens` / `completion_tokens` | int | usage 帧读取;帧缺失时记 `0/0` 并由 `usage_source` 标注不可得(不编造估算值,见下) |
|
||||
| `latency_ms` | int | 总延迟 |
|
||||
| `ttft_ms` / `max_inter_token_ms` | float? | 流式活性测量 |
|
||||
| `cache_hit` | bool | 是否缓存命中 |
|
||||
| `call_id` | str | UUID,每次**尝试**独立 |
|
||||
|
||||
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(measured/estimated)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)。
|
||||
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens` 与 `model_reported`(2026-07-31,issue #3,见下)、`thinking_observation`(2026-08-25,issue #16/#17,见下)、`applied_effort`(2026-09-05,issue #20,见 §7.5/§7.8 与下文推理档位段:本次**实际**跑在哪一档,`nearest` 映射后与请求档分叉,`None` = 调用方不表态或该路径无推理语义)。
|
||||
|
||||
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
|
||||
|
||||
| 字段 | 含义 | 生产者 |
|
||||
|---|---|---|
|
||||
| `cached_prompt_tokens` | **供应商侧** prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`)。`None` = 该源未上报;`0` = 上报了一次真实零命中——两者对下游处置不同(前者不可做缓存成本校正),故不可混同 | `openai_compat` 两条路径解析后经 `TransportResult` 上浮 |
|
||||
| `model_reported` | API 响应体里的 `model` 字段;`None` = 未上报。与 `model`(`.env` 配置别名)可能分叉——供应商把别名指向新权重时,实验复现必须认这个串 | 流式取首个含 `model` 的 chunk(首次写入即固定),非流式取 body 顶层 |
|
||||
|
||||
`cache_hit` 指的始终是 **PolyGateway 自身响应缓存**,与供应商 prompt cache 无关;两者语义不同但名字相近,docstring 已消歧(改名会破坏迁移兼容,故只注释)。
|
||||
|
||||
**推理观测三态 `thinking_observation`(2026-08-25,issue #16/#17)**: 类型 `ThinkingObservation`(`StrEnum`),缺省 `UNKNOWN`。回答的问题是「这次调用到底推理没推理」,由多信号裁定:
|
||||
|
||||
| 值 | 含义 | 判据(按证据硬度排序) |
|
||||
|---|---|---|
|
||||
| `observed` | 确证本次推理发生 | 推理正文 `thinking.strip()` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
|
||||
| `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) |
|
||||
| `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 |
|
||||
|
||||
三态**不可折叠为布尔**: `unknown`(判不出)与 `absent`(确证没有)语义不同,把前者读作后者正是 `reasoning_tokens=None` 制造的那个歧义——MiniMax-M3 非流式开启推理时,推理内容已计费却不回传正文(2026-08-25 实测 completion 53 vs 关闭档 3),该档只能判 `unknown`,宣称「没推理」即撒谎。缺省取 `UNKNOWN` 使任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
|
||||
|
||||
判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。
|
||||
|
||||
**声明 × 观测对账(同批;判据 2026-09-05 由布尔改档位,issue #20)**: `reconcile_thinking` 把本次**实际发出去的档位**(`effort: Effort | None`,即 `TransportResult.applied_effort`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。判据必须写成 `effort is Effort.NONE` 的**身份比较**才落「要求关闭」一支,其余任何档(含 `auto`)落「要求开启」一支——`Effort.NONE` 的取值是非空串 `"none"`,任何靠真值性的写法(`if not effort`)恒为假,会把每个强度档送进关闭分支、告警方向整个颠倒。`none × unknown` 与 `None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(source, model, 生效档位)` 集合(第三段 2026-09-05 由 `enable_thinking` 改为**实际发出的档**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音;而档位根本不经过 `enable_thinking` 那个字段,不改就是同一模型的所有档共用一个键),与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同——一个记「未登记能力已告警过」,一个记「某源某方向的矛盾已告警过」,共用会让两种告警的生命周期纠缠;键空间本就不相交,故不是碰撞问题)。键含源名是因为多源多账号是本库的核心场景: 同一 model 跨 N 个源常态,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警定位不到该查哪个网关(源名在调用点拼进文案,不进纯判定函数的签名)。
|
||||
|
||||
这条对账的价值在于把「能力表过期」从**静默错觉**变成日志里的显式告警——能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),成本是一次枚举比较。但**保障只覆盖可观测路径**: M3 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。
|
||||
|
||||
**缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。
|
||||
|
||||
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
|
||||
|
||||
| 值 | 含义 | 生产者 | cost |
|
||||
|---|---|---|---|
|
||||
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
|
||||
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
|
||||
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
|
||||
|
||||
值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。
|
||||
|
||||
**cost 口径的不变式**: **产生了真实网关调用、但用量不可得的行 → `cost` 为 NULL**(不再算出一个假的 `0.0` 把"免费"与"未知"混为一谈)。**缓存命中行不在此列**——`cache_hit=True` 时 cost 仍为 `0.0`,因为未产生新调用,`0.0` 是事实而非未知;`TelemetryEmitter` 里 `unavailable → None` 的短路**插在 `cache_hit` 分支之后**正是为此。故账目缺口的度量口径必须写成 `WHERE usage_source = 'unavailable' AND cache_hit = false`,漏掉后半个条件会把本无缺口的缓存命中行灌进来,度量偏高。
|
||||
|
||||
**API 稳定性约定(2026-07-20,迁移文档反向约束)**: ① 公共类型新增字段必须带默认值——三项目测试中逐字段传参的 fake 构造才能零改动;② 错误四分类从 `polygateway` 顶层命名空间导出——业务侧步级重试要引用它们(GovDoc/Video-Tree 现有 `(TimeoutError, OSError)` 异常元组迁移后会**静默失效**,必须显式替换为库异常);③ `GatewayClient` 提供显式 `aclose()` 与 async context manager 生命周期 API;④ 被取消的调用尽力而为记遥测(error="cancelled",finally 中记录,绝不因遥测延迟取消传播,写失败静默)。
|
||||
|
||||
@@ -338,6 +421,18 @@ flowchart TB
|
||||
|
||||
**`chat()` 公共签名定稿(2026-07-20,GovDoc 迁移缺口 G1/G2)**: `chat(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True)`。要点: ① `session_id`/`parent_call_id` 与三项目现有 `LLMProvider.chat` Protocol 逐字兼容——这是"调用点零改动"承诺的前提;② **per-call `cache_namespace`**: GovDoc 是单 client 服务多租户、tenant 每请求变化,装配级 namespace 只是默认值,per-call 传入时覆盖并进入缓存 key(§7.5);③ `cache_salt` per-call 可传(Video-Tree 跨 epoch 重采样);④ `structured` 三档语义(D14),类型定稿 `type[BaseModel] | Literal["json"] | None`(M1 设计): 不传 = 原始文本,`"json"` = 仅修复,pydantic 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。
|
||||
|
||||
**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
|
||||
|
||||
**调用方维度追加(2026-08-17,issue #11)**: 四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)签名末尾各增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点:
|
||||
|
||||
① **校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}` 且 `pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。
|
||||
|
||||
② **两者都不进缓存 key**。租户级缓存隔离由既有 `cache_namespace` 负责(§7.5);重复进 key 只会让全部存量缓存冷启动,且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应 miss。
|
||||
|
||||
③ **维度的读取点恒为 `request`**,包括缓存命中行——那一行回答的是"本次调用由谁发起",不是缓存里历史那次。读历史会把本次记到上一个租户头上,两边的账同时错且无任何报错。
|
||||
|
||||
④ **库只交付列,不执行 RLS DDL、不建索引**(理由与模板见 `designs/2026-08-17-issue11-caller-dimensions-design.md` §4.5;下游可达的那份在 README「多租户与自定义维度」一节——`research-wiki/` 不在 sdist 内)。首要理由是 default-deny:启用 RLS 而无匹配 policy = 零行可写且静默不报错,三个下游只有一个是多租户,库若自动启用,其余部署升级后遥测全量写失败,叠加遥测静默降级铁律 = 无声全局丢数据。
|
||||
|
||||
---
|
||||
|
||||
## 6. 错误模型
|
||||
@@ -351,8 +446,14 @@ flowchart TB
|
||||
| `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ |
|
||||
| `ResultInvalidError` | 调用成功但内容不可解析(JSON 修不好、ZIP 缺关键文件) | ❌(仅 D14 结构化阶梯的有界带反馈重问,不入 transport 重试计数) | ❌ | ❌(熔断记**成功**) |
|
||||
| `CircuitOpenError` / `AllSourcesExhausted` | 开路 / 全源耗尽 | 调用方决定: wait / fail-fast 可配 | — | — |
|
||||
| `GovernanceBackendError` | 限流/熔断**状态后端自身**故障(Redis 挂等);降级方向 fail-closed,故一个请求都发不出去 | 调用方决定(同 scope 级: 延期重投) | — | — |
|
||||
| `SourceNotConfiguredError` | 源名不在限流后端配置字典中——**装配缺陷**,非调用失败,正常不可达 | ❌ | ❌ | ❌ |
|
||||
|
||||
**scope 级不可用的结构化语义(2026-07-20,CHS 迁移缺口 G1;2026-07-20 M1 设计勘误修订)**: `AllSourcesExhausted`/`CircuitOpenError` 必须携带结构化字段——`retry_after_s: float`(**非可选**,承 CHS `ProviderUnavailableError` 同款,0 表示可立即重试;取各源冷却与 Retry-After 的最小值)、`reason` 枚举、`per_source_reasons: dict[str, str]`。reason 两层值域(M1 设计 §3 勘误: 本节初版所列 7 值与 CHS `errors.py:143-153` 实际值域不符,重组如下)——scope 级 `reason`: circuit_open / retry_exhausted / stalled / quota_exhausted / no_sources;`per_source_reasons` 值: network_error / timeout / rate_limited / source_dead / circuit_open / cooldown。CHS 的"scope 级不可用 → arq 延期重投、不消耗业务失败预算"(`workers/tracking.py:406-428`)依赖 `retry_after_s` 复现。
|
||||
**scope 级不可用的结构化语义(2026-07-20,CHS 迁移缺口 G1;2026-07-20 M1 设计勘误修订)**: `AllSourcesExhausted`/`CircuitOpenError` 必须携带结构化字段——`retry_after_s: float`(**非可选**,承 CHS `ProviderUnavailableError` 同款,0 表示可立即重试;取各源冷却与 Retry-After 的最小值)、`reason` 枚举、`per_source_reasons: dict[str, str]`。reason 两层值域(M1 设计 §3 勘误: 本节初版所列 7 值与 CHS `errors.py:143-153` 实际值域不符,重组如下)——scope 级 `reason`: circuit_open / retry_exhausted / stalled / quota_exhausted / no_sources / **governance_backend_down**(2026-08-06 增,见下);`per_source_reasons` 值: network_error / timeout / rate_limited / source_dead / circuit_open / cooldown。CHS 的"scope 级不可用 → arq 延期重投、不消耗业务失败预算"(`workers/tracking.py:406-428`)依赖 `retry_after_s` 复现。
|
||||
|
||||
**治理后端故障归位(2026-08-06,Gitea issue #7;设计 `designs/2026-08-06-governance-backend-error-design.md`)**: `GovernanceBackendError` 自 M2 引入分布式后端时新增,但**当时未回补本表**,于是它在"调用方视角的分类学"里一直没有位置——本次归位同时补上这个遗漏。它此前是 `PolyGatewayError` 的直接子类,而语义上 fail-closed 意味着整个 scope 发不出任何请求,正是 scope 级不可用;下游只写 `except GatewayUnavailableError` 会把它落进兜底分支,导致"Redis 抖一下 → 积压任务消耗业务失败预算 → 进死信",而那是运维重启即可恢复的故障。现改为继承 `GatewayUnavailableError`,`reason` 恒为 `governance_backend_down`,`retry_after_s` 默认取常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`——**不取 0**,因为后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),而 0 会让积压任务零延迟同时冲击已挂掉的后端。
|
||||
|
||||
同批拆出 `SourceNotConfiguredError`: 限流后端 `_cfg()` 遇到源名不在配置字典中时原先也抛 `GovernanceBackendError`,但那是装配缺陷而非后端故障。若随整类归入"可延期重投",配置写错的任务会**永远重投、永不进死信、无人告警**——恰是本次要修的 bug 的镜像。故它有意留在 `GatewayUnavailableError` 之外,让缺陷消耗失败预算并浮出水面。它与四分类的关系见 §6.3 之外的第三论域说明: 四分类的论域是 transport 层翻译的**调用失败**(§6.2),scope 级不可用回答"整个 scope 还能不能用",而装配缺陷根本不该进入治理循环被"决定"。
|
||||
|
||||
### 6.2 翻译规则(transport 层职责)
|
||||
|
||||
@@ -365,6 +466,10 @@ flowchart TB
|
||||
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) |
|
||||
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
|
||||
|
||||
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
|
||||
|
||||
**400 在中转拓扑下的语义提醒**(同上): 第三方 API 中转服务自身抖动时也会回 400,从状态码上与供应商的"输入非法"无法区分(下游实测: 同一份字节重发 15 次全成功,失败那次 `prompt_tokens=0`、耗时远低于任何成功调用,即请求在推理开始前被挡)。本表**不改** 400 → `RequestRejectedError` 的映射——直连供应商时重试只会白烧配额,且改默认语义等于让所有直连用户为一种部署形态买单;库改为把判据(`body_text`)交给下游自行区分。
|
||||
|
||||
### 6.3 "坏结果 ≠ 坏服务"(ResultInvalidError 语义,继承 CHSAnalyzer)
|
||||
|
||||
由输入内容决定的**确定性失败**(这张图就是解析不出表格、这段输出就是修不成 JSON):服务是健康的,换源重试只会白烧配额。因此熔断器记成功、不换源、异常上抛消耗业务侧的失败预算。出处:`CHSAnalyzer governance.py:237-239`。
|
||||
@@ -381,7 +486,7 @@ flowchart TB
|
||||
|
||||
**职责**: 一次原始调用的全部协议细节——请求体组装(含 provider 注册表注入的 thinking 参数)、发送、流式 SSE 解析(增量 content/reasoning_content、usage 帧、[DONE] 检测)、HTTP/线路错误按 §6.2 翻译。**不含**重试/限流/缓存(那是中间件的事)。
|
||||
|
||||
- `OpenAICompatTransport`(默认): httpx.AsyncClient(每源一个,预配 Authorization 与分段超时),SSE 解析移植三项目的模块级纯函数;强制 `stream_options.include_usage`。**SSE 缺 [DONE] 语义(2026-07-20 M1 设计)**: per-source `missing_done: "retry" | "salvage"`,默认 retry(防截断响应进缓存被固化);零内容提前断流(early_eof)恒 retry 不可配;打捞路径强制 `usage_source="estimated"`。CHS 迁移配 salvage 保留其现状行为。看门狗活性口径: 任何增量(content 或 reasoning_content)都算 token——ttft = 首个任意 token,思考流刷新 inter_token 计时(CHS 迁移约束 R1)。**非流式快路径**: 短请求可配 `stream=False`(三项目都写死 stream=True 强迫短请求走 SSE+看门狗,库放开)。
|
||||
- `OpenAICompatTransport`(默认): httpx.AsyncClient(每源一个,预配 Authorization 与分段超时),SSE 解析移植三项目的模块级纯函数;强制 `stream_options.include_usage`。**SSE 缺 [DONE] 语义(2026-07-20 M1 设计)**: per-source `missing_done: "retry" | "salvage"`,默认 retry(防截断响应进缓存被固化);零内容提前断流(early_eof)恒 retry 不可配;打捞路径**仅在收到 usage 帧时**把 `measured` 降级为 `estimated`(**勘误 2026-07-30**: 原文"强制 estimated" 已改为有条件——没收到 usage 帧时用量本就是 `unavailable`,强制标 `estimated` 会让 `0/0` 被当作实测数字换算出一个假的 `0.0` 成本,§5.1)。CHS 迁移配 salvage 保留其现状行为。看门狗活性口径: 任何增量(content 或 reasoning_content)都算 token——ttft = 首个任意 token,思考流刷新 inter_token 计时(CHS 迁移约束 R1)。**非流式快路径**: 短请求可配 `stream=False`(三项目都写死 stream=True 强迫短请求走 SSE+看门狗,库放开)。
|
||||
- `OpenAISDKTransport`(可选 extra): 薄封装,`max_retries=0` 关掉 SDK 自带重试(治理归中间件),`extra_body`/`model_extra` 通道非标字段。
|
||||
- `MonkeyOcrTransport`: 见 §7.10。
|
||||
|
||||
@@ -396,8 +501,9 @@ flowchart TB
|
||||
- `RedisLimiter`: 移植 CHSAnalyzer 六道闸——单条 Lua 原子检查全局并发/单源并发(ZSET 租约)/全局 RPM/单源 RPM/全局 TPM/单源 TPM;窗口 id 用 **Redis 服务器时钟**(TIME 命令)统一多进程口径。随实现移植契约测试。
|
||||
- `InMemoryLimiter`: 同一契约的进程内实现(semaphore + 滑动窗口计数);单进程场景下语义等价。
|
||||
- **配额满行为可配**: `wait`(等待,配 stall 判定——本地等待超窗 + 全局无进展超窗双条件才判卡死)或 `fail-fast`(立即抛)。
|
||||
- **stall 计时口径(2026-08-06 修正,issue #8,设计 `designs/2026-08-06-issue8-stall-budget-design.md`)**: 双条件的**条件 A 只累计非生产性等待**(429 退避、配额 wait 轮询、熔断冷却),真实尝试的耗时由 `StallClock.attempting()` 从 stall 账中扣除。原实现用墙钟总耗时,使真实尝试同时向重试预算与 stall 预算计费;而 stall 预算(默认 300s)小于重试预算(`max_attempts × timeout_s`),必然先耗尽——`timeout_s ≥ stall_window_s` 时一次超时即判 scope 死,`max_attempts` **静默失效**。修正后两个预算正交,**划分依据是"谁消耗重试预算"而非"是否发出请求"**: 烧 `max_attempts` 的时间不烧 `stall_window_s`,不烧 `max_attempts` 的时间归 `stall_window_s`。**429 尝试因此也计入 stall 账**——它免重试预算,若其耗时又算生产性就两个预算都不烧,排队型网关(持满 timeout 才回 429)下调用可挂 25 小时(实施期独立验证实测,见设计 §3.6)。生产性边界即 `_attempt` 边界(含该次记账与遥测收尾),故遥测抖动不参与判死。`stall_window_s` 与 `timeout_s` 自此**无耦合**,无需按 `timeout × retries` 放大。三条治理循环(chat/embedding/ocr)共用 `middleware/retry.py` 的 `StallClock`。条件 B 的 `inf` 语义未动——新口径下"非生产性排队耗满窗口且 scope 从未出餐"判死本就正当。**残余性质(非本次引入,由条件 B 单独门控)**: 判死是双条件合取,故当同 scope 其他调用仍在正常出餐时本调用不判死(设计意图: 别人还活着就不该宣告 scope 死亡),代价是**该情形下调用级无硬上限**——持续遭遇慢 429 的调用可以等很久;需要硬上限的调用方应自行 `asyncio.wait_for`。
|
||||
- 全局活性信号: `mark_progress()`/`progress_age_s()`("最近一次出餐"时刻)供背压 stall 判定,移植 `CHSAnalyzer limiter.py:193`。
|
||||
- **契约补强(2026-07-20,CHS 迁移缺口 G6)**: `settle()`/`release()` 幂等(重复调用无副作用);装配期守卫——`timeout_s ≤ permit 租约 TTL`(防租约先于请求过期)、`stall_window ≥ 最慢源 TTFT 上限`(防误判卡死),违反直接报错拒绝装配。降级方向细化(2026-07-20 M1): "报错不放行"适用于**准入侧**(try_acquire/try_enter 及选源路径消费的 source_stats/retry_after_s);已成功调用后的 settle/release 释放侧失败降级 warning——释放失败不构成放行,且不得掩盖主异常与取消。**勘误(2026-07-20 M2 设计,人类批准)**: 记账侧的 `record_success`/`record_failure`/`mark_progress` 同归此类——调用已真实完成,后端失败若冒泡会丢弃真实成功响应或掩盖原始尝试异常,故降级 warning(CHS 原版一律报错,此为有意反转;丢一次熔断记账最多延迟状态迁移且方向偏保守,epoch fencing 防污染)。
|
||||
- **契约补强(2026-07-20,CHS 迁移缺口 G6)**: `settle()`/`release()` 幂等(重复调用无副作用);装配期守卫——`timeout_s ≤ permit 租约 TTL`(防租约先于请求过期)、`stall_window ≥ 最慢源 TTFT 上限`(防误判卡死;**issue #8 后为保守冗余**——TTFT 等待属生产性时间已不计入 stall,该误判在机制上不再可能,校验保留因其无害且不误拒合理配置),违反直接报错拒绝装配。降级方向细化(2026-07-20 M1): "报错不放行"适用于**准入侧**(try_acquire/try_enter 及选源路径消费的 source_stats/retry_after_s);已成功调用后的 settle/release 释放侧失败降级 warning——释放失败不构成放行,且不得掩盖主异常与取消。**勘误(2026-07-20 M2 设计,人类批准)**: 记账侧的 `record_success`/`record_failure`/`mark_progress` 同归此类——调用已真实完成,后端失败若冒泡会丢弃真实成功响应或掩盖原始尝试异常,故降级 warning(CHS 原版一律报错,此为有意反转;丢一次熔断记账最多延迟状态迁移且方向偏保守,epoch fencing 防污染)。
|
||||
|
||||
### 7.4 熔断
|
||||
|
||||
@@ -410,13 +516,23 @@ flowchart TB
|
||||
|
||||
**M2.5 双通道开路(2026-07-21,设计 designs/2026-07-21-m25-resilience-design.md;对 CHS 连续失败语义的有意扩展)**: P6 压测实证纯连续失败语义对"高失败率但偶尔成功"的半死源失明(10% 成功率源永不开路,吃掉 76% 尝试)。判据改为满足任一即开路——① 连续失败 ≥ 阈值(CHS 兼容,保留);② 窗口(双 30s 桶,服务器钟)样本 ≥ `min_calls`(缺省 10)且失败率 ≥ `fail_rate`(缺省 0.6)。**429 不入两通道**(限速是背压不是源故障,Envoy outlier detection 同款;交健康选源软处理);ResultInvalid/网关健康拒绝不计窗口样本(坏结果 ≠ 坏服务)。开路时长指数递增 `cooldown × 2^(streak-1)` 封顶 `max_cooldown_s`(缺省 max(300, cooldown)),仅率通道开路与探针失败重开递增 streak(连续通道误熔健康源的代价封顶单次 cooldown);CLOSED 稳定满 2×cooldown_eff 后首次成功衰减归零。探针撞 429 按无果归还语义放下家接管。原则沉淀: **治理状态的粒度必须等于配额的粒度**(限流/账号退避按配额主体建 key;缓存 key 含租户同理)。
|
||||
|
||||
**熔断拒绝补齐等待档(2026-08-19,issue #14,设计 `designs/2026-08-19-issue14-admission-wait-policy-design.md`;人类确认缺省与实施边界)**: 准入侧此前有一格是空的——限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 wait),熔断门拒时**只有 fail-fast 一档且不可配**。两者在准入语义上同构(都不发请求、都带 `retry_after` 提示),处置却分叉。补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 **fail_fast**,不跟随 quota_full——把最坏墙钟从毫秒抬到 stall 窗口是"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游)。`wait` 档下熔断的保护作用完整保留(等待期一个请求都不发),改变的只是调用方当场死还是排队等。**这一格的缺失与源数量无关**: 多源全部同时开路(共同上游挂掉、全网抖动)行为一模一样,单源只是把"全部开路"的概率从罕见变成必然;故实现上**严禁按池大小分叉**(`if len(sources) == 1` 会让行为随配置突变且无法组合测试)。等待时长按 `retry_after_s` 睡到冷却截止(而非 `poll_interval` 空转——60 秒冷却用 10ms 轮询是 6000 次往返 × 每个在途调用),抖动**上**加不缩放(对确定的截止时刻提前醒必然白醒),并夹到剩余 stall 预算,故单次调用最坏墙钟 = `stall_window_s` + 一个 poll 间隔,不随 `max_cooldown_s` 漂移。控制流必须**按拒绝原因分派**而非串行: 串行写法下 `circuit_open=wait` 不抛之后会掉进配额分支,`quota_full=fail_fast` 的调用方会收到 `reason=quota_exhausted` 而配额其实是满的。
|
||||
|
||||
**`retry_after_s` 的契约定死(同批,issue #14)**: 语义 = "距离**确定**可再试的时刻还有多久"。CLOSED/准入允许 → `0.0`(现在就能试);OPEN → 剩余冷却(确定时刻);**HALF_OPEN → `0.0`**——探针随时可能出结果,不存在确定时刻,而 `0 = 可立即重试` 本就是库既有约定。此前 HALF_OPEN 返回**探针租约剩余**,那是死锁保护参数(派生自 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)`),与"源多久能恢复"无因果关系: 现场 `TIMEOUT_S=300` 时它是 600s 而冷却只有 60s。**更重的后果不在对外报数而在库内**: 该值被喂进源冷却备忘(`SourceCooldownMemo.set_until` 取更晚者、不可回退),于是探针成功、门已恢复 CLOSED 之后,本进程仍跳过该源整整一个租约——单源下每次调用照旧判死,多源下则是"池子里少一个源"且被其他源接住流量所掩盖(issue 提交方未发现这一条)。修正后备忘写入的是已过期时刻,自动回归"只记 OPEN 的确定冷却期"。契约在**六个出口**上统一(memory 三处 + redis 六个 Lua 返回格),其中后四处是**既有的双后端分叉**(redis 在授予探针时返回 probe TTL、在 fencing 未命中时返回租约剩余,而 memory 一直是 0),由契约测试盲区掩护至今——旧用例只钉"第二个进入者被拒",从没钉它拿到什么数。
|
||||
|
||||
**准入逻辑三处收敛(同批)**: `_pick_runnable`/`_on_no_runnable` 此前在 `middleware/retry.py`、`embedding.py`、`ocr.py` 各存一份逐字复制(后两份是第一份的子集)。准入语义一直在演进(issue #8 的 stall 口径、M2.5 的 pacer、本次的等待档),每次都要三处同步。收敛为 `middleware/admission.py::SourceAdmission`,差异用注入表达而非分支: 调用内降权传空 `attempt_fails` 时恒等、AIMD pacer 为 `None` 时跳过。`QuotaGate`/`BreakerGate`/`AdaptivePacer` 由三条循环持有并与 admission **共享同一实例**(三处 `_attempt` 仍要用它们做记账写回与 `pacer.leave()`;pacer 有在途计数,分裂成两个计数器会让 admit/enter 与 leave 记到不同账上),`SourceCooldownMemo` 归 admission 独占。
|
||||
|
||||
### 7.5 响应缓存
|
||||
|
||||
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt}))`,前缀 `pgw:cache:`。
|
||||
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:`。
|
||||
|
||||
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
|
||||
- `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
|
||||
- `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。
|
||||
- `sampling`(2026-07-31,issue #4): 调用级采样参数,**仅非空时参与**(注意与 `salt` 的"仅非 None"不同——空串是有意义的 salt,而空采样参数与不传无差别),故空 overlay 时旧键逐字不变、存量缓存不冷启动。读 `request.sampling` 而非 `request.overlay`,不依赖"CacheMW 恰在 StructuredMW 外侧"的层序巧合。**不进 key 的后果**: 同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错——受控实验静默作废。源级 `extra_body` 同理并入 `model_fingerprint`(全源皆空时字面量不变,否则追加 `|sha256(...)`,摘要对象是各源 `(model, extra_body)` 的 canonical JSON 排序去重——按模型而非源名,改源名不误触冷启动)。
|
||||
- `reasoning_effort`(2026-09-05,issue #20): **请求级**档位,仅 `is not None` 时参与(判据不能用真值性——`Effort.NONE` 是「明确要求不推理」,与 `None`「不表态」拿到的是两种响应,合并即毒化)。它不能靠 `model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,同一个 client 上跑 low 与 max 在它眼里毫无分别,不进 key 就是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。**源级** `reasoning_effort` 则与 `enable_thinking` 同规则并入 `_fingerprint_mark`(仅表态时追加,故全源不表态时字面量逐字不变、存量缓存不冷启动;两者取值域不相交,`"none"`/`"low"`… vs `true`/`false`,追进同一个列表不会摘要成同一身份)。
|
||||
- **key 记的是请求档,不是 `nearest` 映射后的生效档**: `CacheMW` 在洋葱里比 transport 更外一层,查缓存时 `resolve_thinking` 尚未执行,生效档根本拿不到。副作用是被映射到同一档的两个请求各占一个缓存槽(存两份相同响应,浪费但不毒化)。**由此的已知边界**: 能力表更新导致映射结果变化时(如某模型新增 `minimal` 档),请求档算出的 key 不变而实际发出的字节变了,会命中按旧映射存下的响应——能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace` 或 `cache_salt`。
|
||||
- **两条已知副作用**: ① 逐 rollout 变化的 `seed` 进 key 后该路径天然全部 miss(正确语义,但缓存对它不再省钱);② `model_fingerprint` 是**集合级**指纹而非本次选中源的指纹,同 scope 各源 `extra_body` 不同时仍可能返回另一源的响应(既有取舍的延续,与 `model` 同),要求逐源可复现应让每源独享 scope 或 namespace。
|
||||
- value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。
|
||||
|
||||
### 7.6 流式活性看门狗
|
||||
@@ -425,17 +541,74 @@ flowchart TB
|
||||
|
||||
### 7.7 多源与选源
|
||||
|
||||
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣常量,亦作 usage 缺失时的保守兜底,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补)/enable_thinking。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。`SourceSelector` 端口: `health_aware`(M2.5 新缺省: 成功率 EWMA / (1+在途) 的 P2C,0.05 探索地板,进程本地健康态,可选 `OutcomeAwareSelector` 扩展喂数)/ `round_robin` / `least_inflight`。**逻辑角色**: Video-Tree 式 SEARCH/JUDGE/VL/EVOLVE 多角色 = 命名的 client 配置组,`from_env()` 支持按角色前缀装配多个 client;禁止两个角色静默共享同一实例却在配置上看似独立(Video-Tree `evolve_llm = llm` 别名的教训——共享必须显式)。
|
||||
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`reasoning_effort` 与 `effort_fallback`(2026-09-05 issue #20: 前者是本源默认推理档位,`None` = 不表态、`Effort.NONE` = 要求不推理,构造期与 `enable_thinking` 语义矛盾即 `ValueError`;后者取 `error`(缺省)或 `nearest`,决定请求档打空时报错还是映射到最近档)/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。
|
||||
|
||||
**TPM 有效预扣量(2026-07-30,est_tokens 解耦设计,G2 闭环)**: `try_acquire`(§7.3)传入的 est 来自 `SourceConfig.effective_est_tokens()` 这一份纯方法,五个调用点(`QuotaGate` 入场 + chat/embedding 各自的成功侧与失败侧结算)共用,保证预扣与结算恒取同一值(`delta == 0`,否则押金会被整笔退回、TPM 闸退化成进门即放行)。规则:显式 `est_tokens > 0` 则原样用;否则 `tpm > 0` 时派生 `max(1, tpm // 60)`;`tpm == 0`(该闸不启用)时为 0。
|
||||
|
||||
派生取 `tpm // 60` 的理由是**尺度无关**:任何配额规模都给出同一行为上限——"一次调用约占一秒钟的配额份额",故 `tpm=6000` 与 `tpm=600000` 都收敛到约 60 个在途。固定常量(如 1000)则与配额规模无关,在途上限随配额乱飘且取值无从解释。`est_tokens` **不再兼任 usage 缺失时的用量兜底**:那两份差事对"保守"的定义方向相反——限流语境下押多了只是慢(安全),计费语境下按上界记账只会系统性虚高(库把遥测拆成 prompt/completion 两列后又整块塞进 completion,而输出单价通常是输入的数倍,实测双重高估约 26 倍)。用量不可得现在如实记 `unavailable` + cost NULL(§5.1)。
|
||||
|
||||
**已知限制(既有行为,本次未修)**: 单源 `tpm == 0` 而 `{SCOPE}__GLOBAL__TPM > 0` 时,派生值为 0,全局 TPM 闸拿 0 预扣、入场保护形同虚设。修它需要把 `GlobalLimits` 注入 `QuotaGate`(改三处装配),属独立议题。`SourceSelector` 端口: `health_aware`(M2.5 新缺省: 成功率 EWMA / (1+在途) 的 P2C,0.05 探索地板,进程本地健康态,可选 `OutcomeAwareSelector` 扩展喂数)/ `round_robin` / `least_inflight`。**逻辑角色**: Video-Tree 式 SEARCH/JUDGE/VL/EVOLVE 多角色 = 命名的 client 配置组,`from_env()` 支持按角色前缀装配多个 client;禁止两个角色静默共享同一实例却在配置上看似独立(Video-Tree `evolve_llm = llm` 别名的教训——共享必须显式)。
|
||||
|
||||
**多 client 共享状态后端(2026-07-20,VT 迁移缺口 R5)**: 限流/熔断状态的 key 以 scope+source 为单位,与 client 实例解耦;多个逻辑角色的 client **显式注入同一个状态后端实例**时即共享全局并发/RPM/TPM 闸(Video-Tree `TREE_BUILD_API_CONCURRENCY` 跨 SEARCH+VL 共享 semaphore 的语义由此承接)。共享必须显式注入,禁止隐式全局。
|
||||
|
||||
### 7.8 遥测与成本
|
||||
|
||||
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
|
||||
**必录字段**(继承三项目 15 字段规范;当前 26 个 INSERT 字段,物理表列 27 = 26 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**。
|
||||
|
||||
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
|
||||
|
||||
**`reasoning_tokens` 列(2026-08-11,issue #6,端口 21 → 22)**: 推理 token 已计入 `completion_tokens`,故成本总额一直是对的——这不是计费缺口而是**归因**缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分,也就无从判断某个 scope 该不该关推理。供应商不报时记 NULL 而非 0(不可得 ≠ 为零,与 `usage_source='unavailable'` 同一纪律)。
|
||||
|
||||
**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。
|
||||
|
||||
**`reasoning_effort` 列(2026-09-05,issue #20,端口 25 → 26)**: 记本次调用**生效的推理档位**,`TEXT` 可空——`NULL`(不表态,或档位取值不在本版词汇内而降级)与 `'none'`(明确要求不推理)是两回事,折叠成任一档等于替上游声称一件它没说过的事。加这一列的理由是分组能力: 此前 25 列里没有任何一列能回答「这一行跑在哪档」,「不同档位是不是真有用」的压测在数据侧无从下手。**三个 emit 入口的口径必须各自定死**(与 `sampling` 列同一先例): `emit_attempt` 成功行读 `response.applied_effort`(即 `nearest` 映射后**真正发出去**的那一档)且**绝不重算**——重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出过的分组下,而这两个值在没开映射的源上恒等,该错误在本地跑不出来;失败尝试没有响应,退回请求档(`effective_effort` 三层优先级,不是裸读字段——`enable_thinking` 也是一次表态)。故**开了 `nearest` 的源上,成功行与失败行不是同一把尺子**,`GROUP BY reasoning_effort` 须带 `error IS NULL`。`emit_cache_hit` / `emit_terminal_failure` 手上没有选中源,只记请求档。embedding / OCR 路径由 `reasoning_applies=False` 显式声明「本路径无推理语义」,该列恒 NULL——这个布尔**不设默认值也不由 emitter 推断**: 三条路径共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源会让回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的档。
|
||||
|
||||
**`thinking_observation` 列(2026-08-25,issue #16/#17,端口 24 → 25)**: 落 `LLMResponse.thinking_observation` 的裸取值(`observed` / `absent` / `unknown`,两端均为可空 `TEXT`),语义见 §5.1。它补的是 `reasoning_tokens` 补不上的那一格: 后者为 NULL 时「没推理」与「没上报」不可区分,而供应商停报 `completion_tokens_details` 是会真实发生的事(MiniMax 这一路 2026-08-25 实测已停报,qwen 与 deepseek 在同一网关同一 key 上照常返回),届时按 `reasoning_tokens IS NULL OR = 0` 统计「未推理」会把推理了的调用一并算进去。有了本列,口径改为按本列取值分组,`unknown` 独立成一档而不再被并进「未推理」。
|
||||
|
||||
**recorder 收到的必须是裸 `str` 而非枚举实例**: `TelemetryEmitter` 的 `_AttemptUsage` 内部持 `ThinkingObservation` 类型,`_record` 下沉时取 `.value`。`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只降级为一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一分工(recorder 只落库,不做语义判断)。列序纪律同上: 新列排在最末,两端 DDL 与两份 backfill 同步。
|
||||
|
||||
(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
|
||||
|
||||
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
|
||||
|
||||
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"` 的 `text`、`response`、`thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。
|
||||
|
||||
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
||||
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
|
||||
- 成本: `pricing.py` 维护 model → (input 单价, output 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。
|
||||
- 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
|
||||
|
||||
**遥测池的资源语义(2026-08-24,issue #15)**: `PostgresRecorder` 此前 `create_pool(dsn, timeout=10)` 继承 asyncpg 默认的 `min_size=max_size=10`,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限"(`pool.py:457` 的 `if self._minsize:`)——建池是一次全有全无的重资源动作: 拿不到 10 条就抛异常。这让遥测成为全库唯一预占资源的组件(httpx transport 与三个 redis 后端全是按需建连),也就成了共享实例余量紧张时**必然第一个倒下**的一环,而它承担的恰恰是最不该悄悄失败的职责。改为 `create_pool(dsn, min_size=0, max_size=<PGW_TELEMETRY_PG_POOL_MAX>, timeout=<预算>, command_timeout=<预算>)`,三条随之确立:
|
||||
|
||||
| 语义 | 内容 |
|
||||
|---|---|
|
||||
| 建池零成本 | `min_size=0` 时 `_initialize` 只造 holder 对象、**一条连接都不连**(实测 0.000s,指向不可达端口也照样成功)。稳态占用由"每 client 常驻 10 条"变为"实际并发,闲时 0";真实 PG 实测: 建 recorder 后 0 → 一次写入后 1 → 20 行并发后 4(= `pool_max`)→ `aclose` 后 0 |
|
||||
| 只暴露 `max_size` | `min_size` **有意不给配置项**: 它唯一的作用是把上面那个脆点装回来,换取的只是首次写入省下 ≈390ms 建连。库没有理由提供一个只会伤人的旋钮(P1+P5)。`max_size` 则必须暴露——继承第三方默认值等于库对自己的资源占用不表态(P4) |
|
||||
| 写入有硬预算 | 整次写入(准备 + acquire + execute)由 `asyncio.timeout(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S)` 包一层,超时按行级丢弃。把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证 |
|
||||
|
||||
两处实现纪律,都是"看起来完成了、其实资源还挂着"的形态,必须写下来否则会被改回去: ① **不得用 `async with pool.acquire(...)`**——`Pool.release()` 是 `await asyncio.shield(ch.release(timeout))` 且默认复用 acquire 记录的 `ch._timeout`(asyncpg `pool.py:886-889, 930-937`),外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行的那个 shielded release **会正常等到完成**,业务路径真实上界变成 ≈ 2 × 预算;故改为显式 `acquire(timeout=<完整写入预算>)` + `finally: release(con, timeout=1s)`(内层传完整预算而非剩余量: 真正的上界是外层那一层 `asyncio.timeout`),释放超时即 `con.terminate()`,承诺精确化为"主写入尝试 ≤ 预算,释放路径独立有界"。② **`aclose()` 必须有界且终局**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时无限等、60 秒只发一条 warning(`pool.py:939-948, 961-972`),故走 `asyncio.wait_for` + 超时 `terminate()`;同时置 `_closed`,此后写入短路且**不复活**——原实现关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入方以为自己管着全部连接、实际早已不是(issue #15 实施期发现,是下面所有权根因的又一处表现)。
|
||||
|
||||
**遥测失败的三分判据(2026-08-24,issue #15)**: 判死判据此前挂在"**哪一步**失败"(`_open_pool` 失败即永久判死),而那一步里同时藏着两类性质完全不同的失败——DSN 非法(进程内不可能改变)与 `too many clients` / 网络抖动(外部状态,随时可能好)。判据改挂"失败是**什么性质**",两句话说完:
|
||||
|
||||
1. **致命 = 失败原因完全在进程内部且不可变**;其余一切失败都可能被外部修好,故一律带冷却重试。
|
||||
2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。
|
||||
|
||||
| 档 | 覆盖(按 SQLSTATE 分类而非异常类白名单——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移) | 处置 |
|
||||
|---|---|---|
|
||||
| 配置级致命 | `ClientConfigurationError`(DSN 不可解析);`create_pool` 抛的 `ValueError`/`TypeError` | 永久 no-op + 一条 **error**(人配错了,不是 warning) |
|
||||
| 环境级不可用 | SQLSTATE 类 `08`/`53`(含 53300 too many connections)/`57`/`28`/`3D`,具体码 `42501`(无权限)/`42P01`(表不存在);`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅在准备期路径可达**: 它是 `OSError` 子类,但写入期的超时先被 `record_llm_call` 的 `except TimeoutError` 接住并按行级丢弃,压根到不了本分类函数——见下方第 ④ 点);表确定不存在且建不出来 | **冷却降级**(内部常量 60s,不给配置项——无部署差异理由),到期放行**一次**重新准备,成功即恢复 |
|
||||
| 行级拒绝 | 其余 `PostgresError`(`22`/`23` 等数据与约束类),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级,接入节流复述 |
|
||||
|
||||
四点必须一起记住,否则后来人会把判据改回去: ① **致命档窄到只剩 DSN 一类是有意的**——认证失败、库不存在、表建不出来一律归环境级,因为 DBA 改完密码/建完表就该自动恢复,而永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形;②**`42703` 是唯一具名例外**,按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了优先级更高的承诺——manual 档缺列时按现有列裁剪 `INSERT` 继续写、缺列以逐行 warning 暴露,即"部分列写进去了"这件事本身有价值,不该被冷却掉;新增例外必须同款论证。③ **认不出的失败一律归最轻档(行级)**,这个保守缺省在建池路径上是安全的,理由是 `min_size=0` 让建池不触库(实测 0.000s),"下次调用重试建池"本身**零成本**——原实现注释担心的"每次重试内联吞一次 connect 超时"在新语义下不再成立;④ **表里那条 `TimeoutError` 规则只在准备期路径可达,写入期不可达**(2026-08-24 合并前审查发现,**本轮只记录不改行为**): `record_llm_call` 的 `except TimeoutError` 排在 `except Exception` 之前,写入本体抛出的任何超时都在那里被按行级丢弃,不会走到分类函数。真实后果是"后端 TCP 通但不回应(假死)且 schema 已就绪"时,每次业务调用内联付满一个写入预算(缺省 5s)、丢一行、`degraded` 保持 False、**不进 60s 冷却**——即"冷却把最坏成本压成每 60s 一次、上界一个预算"这句承诺只在准备期路径上成立。不改的理由: 相对改前的"无限期挂"仍是净改善,且"超预算丢行走行级、不置 degraded"本就是明确记下的有意取舍(见下一段中"`degraded` 与 `dropped_rows` 覆盖的不是同一件事"那一条)。是否给"连续超预算丢行"升档,留作后续议题。
|
||||
|
||||
**降级的可见性与可编程性(2026-08-24,issue #15)**: 铁律里"遥测后端挂 → 静默降级"的"静默"指的是**不向调用方冒泡**,不是"没有日志、没有状态"。此前它被实现成了后者——全程只有一条 warning,长跑进程里等同于消失(issue 是人工比对"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的,期间 19 次调用一行未落);SQLite 侧更糟,初始化失败后写入直接 `return`,连 warning 都没有。"遥测必录"铁律的实质要求是: **库做不到必录时,必须持续、可编程地让下游知道**。落法是 `telemetry/status.py` 的 `TelemetryStatusTracker`——两个 recorder 共用、不含任何后端知识(只接受"降级了/恢复了/丢了一行"三个事实),进入与恢复各一条日志(**进入那条的级别由 `fatal` 决定,且只在 tracker 这一处决定**: 致命档 error——人配错了、本进程内不会自愈,其余 warning——外部状态、会自愈;recorder 侧不得再复制一条,否则同一事实两条日志、级别两个源头),降级期间按行数(100 行)与时间(300s)双阈值节流复述,`snapshot()` 给只读 `TelemetryStatus`(`degraded`/`fatal`/`reason`/`degraded_for_s`/`dropped_rows`/`retry_after_s`),经三个 client 的 `telemetry_status` 属性出口。三条设计约束:
|
||||
|
||||
- **不叫 `health`**: 该词在 `ports.py` 已被 `OcrTransport.check_health`(源探活)与 `SourceSelector.health(source_name) -> float`(成功率 EWMA)占用两次,库内 `health` 一律指"源的健康度";这里描述的是"这个 recorder 现在能不能写、为什么不能、丢了多少",是状态不是评分(P2)。
|
||||
- **不并入 `TelemetryRecorder` 主 Protocol**,新起**独立**端口 `TelemetryStatusProvider`: 前者是 `@runtime_checkable`,而 runtime 检查按属性存在性做——加一个成员会让所有只实现 `record_llm_call` 的对象**当场不再是** `TelemetryRecorder`,库内与下游的同款 `isinstance` 断言升级即断。client 侧取值经**一处** `isinstance` 判定,不重演 `aclose` 那种三处复制的鸭子类型。
|
||||
- **`TelemetryStatus` 进顶层 `__all__`**(与 `SourceStats` 不同): 后者是端口内部快照、下游不消费,而本类型是 `client.telemetry_status` 的返回类型,下游要拿它做类型标注与对账——"顶层导出即公共 API 面"的约定要求它出现在那里。端口 `TelemetryStatusProvider` 则不导出(库外无实现者,导出即多一份永久承诺)。
|
||||
- **`degraded` 与 `dropped_rows` 覆盖的不是同一件事,下游对账必须两个都看**: `degraded` 只在**环境级/致命级**失败(服务端真的说了"不可用",如 53300)时置位;而写入因**本地池饱和**超出写入预算被丢时走的是行级丢弃——`degraded` 保持 False,只有 `dropped_rows` 增长。这是有意的(池满是本进程并发过高,不是后端挂了,冷却 60s 只会白丢更多行),但只按 `degraded` 配告警的下游会**完全看不见**这一类丢行,而它恰恰是 `pool_max` 配小了的唯一信号。
|
||||
- **SQLite 侧只做可见性**,不做 lazy 化与冷却重连: 它的失败模式(本地目录不可写、文件损坏)在装配期就暴露给下游,不是"跑到一半悄悄断",永久降级在那里语义基本正确。这个不对称是已知且有理由的;tracker 与快照两侧共用,将来要对称时接口已就位。
|
||||
|
||||
**资源所有权在遥测侧的落点**: 通用纪律见 §4.5。对遥测的直接后果是 §7.7 R5 那条"共享必须显式注入"第一次真正可用——`PostgresRecorder(dsn, pool=<外部池>)` 与"多个 client 注入同一个 recorder"都不再被第一个 `aclose()` 弄死,issue #15 提的"共享池"方向由此以显式注入形态自然成立,不需要任何隐式全局注册表(那会违反"纯 asyncio 中立: 无全局状态、无模块级单例")。
|
||||
|
||||
### 7.9 结构化输出阶梯(D14)
|
||||
|
||||
@@ -475,7 +648,8 @@ src/polygateway/
|
||||
├── config.py # GatewaySettings: 多源/韧性/装配键族聚合与装配守卫(M1 增补)
|
||||
├── middleware/ # retry.py / ratelimit.py / breaker.py / cache.py / telemetry.py / structured.py
|
||||
├── transports/ # openai_compat.py / openai_sdk.py / monkey_ocr.py
|
||||
├── providers.py # D11 provider 注册表
|
||||
├── providers.py # D11 provider 注册表(只回答 provider 是什么)
|
||||
├── thinking.py # 推理这件事的全部决策: 能力表 + 请求侧注入 + 响应侧裁定 + 对账
|
||||
├── sources.py # SourceConfig + 选源策略
|
||||
├── backends/ # memory/ 与 redis/(limiter、breaker、cache 状态实现)
|
||||
├── telemetry/ # sqlite.py / postgres.py / pricing.py
|
||||
@@ -483,7 +657,7 @@ src/polygateway/
|
||||
└── streaming.py # 三层活性看门狗(纯函数)
|
||||
```
|
||||
|
||||
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/`、`backends/`、`telemetry/`、`structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
|
||||
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/`、`backends/`、`telemetry/`、`structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。`thinking.py`(2026-08-25)夹在**实现层与 `providers` 之间**: 它 import `providers.py` 的 `ProviderProfile`(故在其上),被 `transports/` 与 `client.py` import(故在其下);契约里写作独立一层 `polygateway.thinking`,插在 `transports | backends | telemetry | structured` 与 `providers : sources` 中间。**枚举 `ThinkingObservation` 因此必须留在 `types.py`**——它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让最内层反向依赖决策层,契约当场判红。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
|
||||
|
||||
---
|
||||
|
||||
@@ -491,10 +665,15 @@ src/polygateway/
|
||||
|
||||
- **载体**: `.env` + 环境变量(工程配置);缺失关键配置直接报错,严禁硬编码默认值兜底(三项目共同铁律)。**实现勘误(2026-07-20 M1,人类确认)**: 多源 `{SCOPE}__{PROVIDER}__{N}__{FIELD}` 是动态键族,pydantic-settings 的静态字段模型无法表达,故 `GatewaySettings` 为 frozen dataclass + python-dotenv(显式核心依赖)读取,fail-loud 校验语义与 pydantic-settings 一致。
|
||||
- **多源命名**: `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(如 `LLM__QWEN__1__API_KEY`、`OCR__MONKEY__1__BASE_URL`),聚合为 `list[SourceConfig]`;SCOPE 支持逻辑角色前缀(§7.7)。
|
||||
- **`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`(2026-07-31,issue #4)**: 值为 JSON **对象**串(数组/标量报错),解析为源级恒定采样参数。`_SOURCE_FIELDS` 是跨 scope 共用的一张表,故该键在 `OCR__`/`EMBED__` 下也语法合法,但那两条路径不消费它(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart)——处置为**构造期剥离 + warning 放行**而非报错(2026-07-31 人类拍板: 这两条路径本无采样语义,配错后果远轻于 chat,不值得让下游装配起不来)。剥离本身是承重的: 不剥离则遥测 `sampling` 列会记录一个从未发出的参数(§7.8),那是数据造假而非参数失效。
|
||||
- **韧性参数键名**沿用三项目习惯(`LLM_TIMEOUT` / `LLM_MAX_RETRIES` / `LLM_RETRY_BASE_DELAY` / `LLM_RETRY_MAX_DELAY` / `LLM_CIRCUIT_BREAKER_THRESHOLD` / `LLM_CIRCUIT_BREAKER_COOLDOWN` / `LLM_TTFT_TIMEOUT` / `LLM_INTER_TOKEN_TIMEOUT`),降低三项目迁移改名成本。
|
||||
- **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。
|
||||
- **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。
|
||||
- 后端选择即配置: 如 `PGW_LIMITER_BACKEND=memory|redis`、`PGW_TELEMETRY_BACKEND=sqlite|postgres`、`PGW_QUOTA_FULL=wait|fail_fast`(命名待 M1 设计文档定稿)。
|
||||
- **`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(2026-08-19,issue #14)**: 熔断全拒时的处置,与 `{SCOPE}__QUOTA_FULL` 同形同族(上一条"后端选择即配置"里记的 `PGW_QUOTA_FULL` 是 M1 定稿前的暂拟名,实际落地为 scope 键 `{SCOPE}__QUOTA_FULL`)。缺省 **fail_fast** = 存量下游的控制流逐字不变;**单源 scope 应显式配 `wait`**。两键值域相同但语义不同故分列: 配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),调用方可能想要"配额满就等、源坏了就立刻失败"。落到 `GatewaySettings.circuit_open`(无默认值,与既有全部字段一致),校验收敛在唯一消费者 `SourceAdmission` 一处——三个客户端构造函数此前各带一份 `quota_full` 校验,再加一键就是八处复制。
|
||||
- **`PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(2026-08-19,issue #13,D15)**: 可选键、**三态**——不设 = 按后端派生(sqlite→auto、postgres→manual),显式设置则两侧都可覆盖。派生只发生在 config 层一处,落到 `GatewaySettings.telemetry_auto_migrate`(无默认值,与既有全部字段一致;`telemetry_backend=none` 时无人消费,归一为 `False`),recorder 的 `auto_migrate` 是 keyword-only **必填**参数——关键行为参数不给默认值(P4),缺省规则也就不会与类签名漂移。
|
||||
- **`PGW_TELEMETRY_TEXT_CAP`(2026-08-19,issue #12)**: 可选正整数键、**二态**——不设 = 不截断(缺省)。与相邻的 `SCHEMA_MODE` 不同,这里"未设"本身就是最终答案,没有需要按后端派生的第二种缺省。落到 `GatewaySettings.telemetry_text_cap: int | None`(同样无默认值),`TelemetryEmitter.text_cap` 是 keyword-only 必填参数。值域(`> 0`)在 settings 与 emitter **两处**校验: 前者只管 env 一条路,而"构造函数全量注入"是库承诺的另一条公共装配路,`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
|
||||
- **`PGW_TELEMETRY_PG_POOL_MAX` / `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(2026-08-24,issue #15)**: 两个可选键,**env 装配路缺省 4 与 5.0**。库必须对"自己该占多少资源"有一个可陈述的表态(不表态就等于继承第三方默认值,那正是 issue 的病根,见 §7.8),但**表态的落点是 `_load_pool_max`/`_load_write_timeout` 这条 env 装配路,不是字段默认值**: `GatewaySettings.telemetry_pg_pool_max` / `telemetry_pg_write_timeout_s` 与相邻三个遥测键**一样是无默认值的必填字段**,直接构造 `GatewaySettings` 的调用点需补两个参数(dataclass 语义上也只能如此——这两个字段后面跟着四个无默认值字段,就地加默认值即 `TypeError: non-default argument follows default argument`)。缺省写在 config 一处,`PostgresRecorder` 的 `pool_max`/`write_timeout_s` 是 keyword-only **必填**参数(与 `auto_migrate` 同一纪律: 缺省规则不与类签名漂移)。值域校验(`pool_max >= 1`、`write_timeout_s > 0`)落 `GatewaySettings._validate_telemetry`,与 `telemetry_text_cap` 同一先例覆盖**三条装配路**(直接构造 / `dataclasses.replace` / env),报错文本同时点字段名与 env 键名。两键都带 `PG` 前缀与 `PGW_TELEMETRY_PG_DSN` 对齐: SQLite 侧的等价物(`busy_timeout=5000`)本次不动,这个不对称是已知且有理由的(§7.8 末)。**冷却期 60s 有意不给键**——无部署差异理由(P1 YAGNI)。`pool_max` 的调参口径必须按实测折算而非按 `pool_max / RTT` 估算: 跨内网 RTT ≈ 123ms 的实验室 PG 上 `pool_max=4` 实测约 **15.6 行/秒**(50 行并发批 3.2s),一次 `INSERT` 的实际往返比一次 `SELECT 1` 重一倍。
|
||||
|
||||
---
|
||||
|
||||
@@ -567,7 +746,7 @@ src/polygateway/
|
||||
| # | 问题 | 建议 |
|
||||
|---|---|---|
|
||||
| Q1 | 打包与分发 | **已拍板(2026-07-22 用户)**: Gitea PyPI 包注册(gitea.iomgaa.online,内置 registry;twine 上传、项目侧 `pip install --index-url .../api/packages/iomgaa/pypi/simple/`);git+https 留作退路 |
|
||||
| Q2 | Python 最低版本 | 3.11(覆盖三项目: 3.11×2 + 3.13×1) |
|
||||
| Q2 | Python 最低版本 | **3.12(已拍板,2026-08-24 人类确认)**: "我们现在的项目至少都是 3.12 的了,3.11 都有点老"——原记载的依据"覆盖三项目: 3.11×2 + 3.13×1"**已过时**,三个迁移目标均已 ≥3.12,故抬版本不再让任何迁移目标装不上。落点: `requires-python = ">=3.12"`、ruff `target-version = "py312"`、CLAUDE.md 与 README 同步。收益是 `asyncio.timeout` 可直接用于遥测写入预算(3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,省掉一整块 `wait_for` 绕行补丁)与 PEP 695 泛型语法;代价是仍在 3.11 的部署 `pip install` 会被 pip 直接拒绝(issue #15,见 CHANGELOG"请先读这一条(一)") |
|
||||
| Q3 | Embedding 客户端是否纳入。**勘误(2026-07-20,VT 迁移文档 R11)**: 初版称"各有一套独立重试实现"不实——GovDoc 的 `OpenAICompatEmbedding` 有自研退避,但 Video-Tree 的 `RemoteEmbeddingProvider` 是**同步 SDK 裸调、无任何重试**;纳入库还需异步化其端口 | **已拍板(2026-07-20 人类)**: 纳入 M2(消灭无治理的裸调 + 统一重试),含端口异步化;Embedding 端口为公共 API,随 M2 设计文档过人类门 |
|
||||
| Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) |
|
||||
| Q4 | conda 环境名 | `PolyGateway` |
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|---|---|---|
|
||||
| 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 |
|
||||
| 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 |
|
||||
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(thinking 注入/思考流字段声明) |
|
||||
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(思考流字段声明;thinking 注入的**决策**已于 issue #16/#17 搬到 `thinking.py`,这里只留形态声明) |
|
||||
| 3 | `transports/openai_compat.py`(SSE 解析、非流式快路径、错误翻译 §6.2) | 依赖 1/2a/2b;错误翻译是中间件的语义地基 |
|
||||
| 4a | `middleware/retry.py`(D13 自研,单层原则)+ `sources.py`(SourceConfig、round_robin/least_inflight 选源、源冷却备忘) | 依赖错误分类;先于限流接入便于独立测试。**多源完整行为(换源/冷却/多源行为测试)2026-07-20 人类拍板自 M2 提前进 M1**——重试循环每次尝试都要选源,签名与行为一并钉死 |
|
||||
| 4b | `backends/memory/`(limiter + breaker)+ 对应中间件 | 语义契约(permit/settle、状态机)在内存版上钉死,契约测试同步交付 |
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# GatewaySettings 跨字段不变量守卫的生效范围
|
||||
|
||||
- **日期**: 2026-07-29;**状态**: **已批准并实施**(2026-07-29 人类门通过;§8 结论见文末)
|
||||
- **范围拍板**(用户 2026-07-29): 功能对齐社区 PR#1,但按本库规范重写;顺带销掉 PR#1 遗留的两个缺陷
|
||||
- **上游依据**: ARCHITECTURE §7.3 契约补强 G6(装配期守卫,"违反直接报错拒绝装配")、§9 配置聚合、CLAUDE.md §4.5(装配只有两条路)、`types.py` 同族 frozen dataclass 的既有校验笔迹
|
||||
|
||||
## 1. 缺陷取证(全部本地实测,worktree @ f76a89b 与 main 对照)
|
||||
|
||||
`GatewaySettings` 有三条**跨字段**不变量——单个字段合法、组合起来才非法,因此 `types.py` 各子配置的 `__post_init__` 管不到,只能在聚合层管:
|
||||
|
||||
| 不变量 | 现居位置 | 违反后的运行时后果 |
|
||||
|---|---|---|
|
||||
| 源 `timeout_s` ≤ `lease_ttl_s` | `_guard_lease`,仅 `from_env` 调用 | 租约先于请求过期,名额被放给他人 → 实际并发超配额,击穿网关 |
|
||||
| `backpressure.stall_window_s` ≥ 最大源 `ttft_timeout_s` | `_guard_stall`,仅 `from_env` 调用 | 正常慢首包被误判卡死掐断 |
|
||||
| `breaker.probe_ttl_s` ≥ 最慢源 `timeout_s` + 5 | `_load_breaker` 内联,仅 `from_env` 路径 | 半开探针在途即被接管(M2 设计 §3 原文) |
|
||||
|
||||
三条守卫都只挂在 `from_env` 上,而 CLAUDE.md §4.5 规定装配有**两条**官方路。走 `from_settings()` 能装出违反上述任一条的配置且不报错——类可以合法地存在于它自己 docstring 声称不可能的状态。
|
||||
|
||||
实测(在 PR#1 分支上,即已修前两条之后):
|
||||
|
||||
| 构造方式 | 结果 |
|
||||
|---|---|
|
||||
| `replace(base, breaker=replace(base.breaker, probe_ttl_s=1.0))`(最慢 timeout 120s) | **未拦截**,装配成功 |
|
||||
| `replace(base, sources=())` | `ValueError: max() arg is an empty sequence` —— 内置异常泄漏,既不点字段也不说原因 |
|
||||
|
||||
第一条说明 PR#1 的搬迁不完整:它的全部论证同等适用于 `probe_ttl_s`,却只搬了两条。第二条是 PR#1 **新引入**的失败模式——`max()` 此前只在 `_load_sources` 保证非空之后才执行,守卫上移到构造期后失去了这个前提。
|
||||
|
||||
另有一条隐性不变量此前从未表达:**`sources` 不得为空**。`from_env` 路径由 `_load_sources` 显式拦截,直接构造路径无人把关,零源的 client 装出来后选源必然失败。
|
||||
|
||||
## 2. 备选方案对比
|
||||
|
||||
| 方案 | 做法 | 权衡 |
|
||||
|---|---|---|
|
||||
| **A. `__post_init__` 集中校验(推荐)** | 三条跨字段守卫 + 空源检查全部收进 `GatewaySettings.__post_init__`,拆为 `_validate_sources/_validate_lease/_validate_stall/_validate_probe` 私有方法 | 与 `types.py` 同族五个 frozen dataclass 的既有笔迹完全一致;一处覆盖全部构造路径(六个工厂 + 直接构造 + `dataclasses.replace`);代价是收紧了构造承诺(见 §4) |
|
||||
| B. 各工厂入口显式调用 `settings.validate()` | 三个 client × 两个工厂,六处各加一行 | 不改构造承诺,零 breaking;但六处要永久保持同步,新增第四个 client 时必漏——正是"每个调用方各维护一份副本"的毛病挪进库里。且 `dataclasses.replace` 仍能绕过。**否决** |
|
||||
| C. 公共 `settings.validate()`,由调用方自愿调 | 提供校验入口,不强制 | 把类不变量降级成"建议";违反 P5 防御性(外部输入校验后使用)与 ARCHITECTURE §7.3"违反直接报错拒绝装配"。**否决** |
|
||||
|
||||
方案 A 与 `SourceConfig.__post_init__` 同构。选它的核心理由不是"少写五行",是**不变量的归属**:这三条约束是 `GatewaySettings` 这个类的定义的一部分,不是 `from_env` 这个函数的输入检查。放在函数里,类就失去了自我描述能力。
|
||||
|
||||
### 2.1 子决策:守卫的代码形态
|
||||
|
||||
`config.py` 现有 `_guard_lease(settings)` / `_guard_stall(settings)` 两个模块级函数,把自身实例传回给模块级函数是绕路。`types.py` 的既有做法是私有方法(`SourceConfig` 拆三个 `_validate_*`)。**改为私有方法**,与同族一致;模块级 `_guard_*` 一并删除(无其他调用点)。
|
||||
|
||||
### 2.2 子决策:`probe_ttl_s` 的派生逻辑留在哪
|
||||
|
||||
`_load_breaker` 对该字段做了两件事:未配置时**派生**(`max(2*slowest, cooldown_s, probe_floor)`,派生规则本身保证守卫恒成立)、显式配置时**校验**。派生需要读 env,必须留在 `_load_breaker`;校验上移到 `__post_init__` 后,`_load_breaker` 内联的那份校验删除(避免同一约束两处维护)。派生分支上移后仍恒过,无行为变化。
|
||||
|
||||
### 2.3 子决策:错误消息里是否列 env 键名
|
||||
|
||||
**不列。** 三条理由:(1) `types.py` 全部校验消息只点字段名,是既有笔迹;(2) 守卫现在服务两类调用方,env 键对手工拼 settings 的那类是不可执行的建议;(3) 键名的单一事实源是 `.env.example` 与 wiki `参考-配置键`,消息里复制一份即双处维护。消息格式沿用既有句式:`字段名(值)须 …;调大 X 或调小 Y`。
|
||||
|
||||
> 与 PR#1 的差异:PR#1 选择"点字段名 + 括号附 env 键",单行超 100 字符且把 `{SCOPE}__{PROVIDER}__{N}__TIMEOUT_S` 模板塞进运行时消息。本方案只留字段名。
|
||||
|
||||
## 3. 行为审计(逐条标注)
|
||||
|
||||
不是从 `reference/` 迁移,是既有模块的行为收紧,故审计对象为现有 `from_env` 路径的全部可观测行为:
|
||||
|
||||
| 现有行为 | 处置 |
|
||||
|---|---|
|
||||
| `from_env` 装配非法 lease/stall 组合 → `ValueError` | **保留**(改由 `__post_init__` 抛,时机提前到 `cls(...)` 那一行,对调用方不可见) |
|
||||
| `from_env` 配了过小 `PROBE_TTL_S` → `ValueError` | **保留**(同上,消息中不再含 env 键名 —— 有意变更,§2.3) |
|
||||
| `from_env` 未配 `PROBE_TTL_S` → 派生值 | **保留**,派生规则一字不改 |
|
||||
| `from_env` 未配任何源 → `ValueError: scope X 未配置任何源` | **保留**,`_load_sources` 的检查不动(它能给出键名模板,信息量高于构造期检查) |
|
||||
| 直接构造/`replace` 出非法组合 → 静默成功 | **有意替换**为构造期 `ValueError`(本设计的目的) |
|
||||
| 直接构造空 `sources` → 静默成功 | **有意替换**为构造期 `ValueError`,消息点明"至少一个源" |
|
||||
| 三条守卫的异常类型 `ValueError` | **保留**。装配期错误不入 `errors.py` 四分类(四分类描述的是一次调用的失败),与 `_load_sources`/`types.py` 既有装配错误一致 |
|
||||
| `GatewaySettings` 字段名与类型 | **不动**。迁移兼容约束(CLAUDE.md §4.3 例外条款)只增不删不改名,本次零字段变更 |
|
||||
|
||||
**有意放弃**:不提供 `strict=False` 之类的逃生开关。装出必然故障的配置没有正当用例。
|
||||
|
||||
## 4. 对下游的承诺变化(人类门要审的就是这条)
|
||||
|
||||
| 调用方式 | 影响 |
|
||||
|---|---|
|
||||
| `GatewayClient.from_env()` / `OcrClient.from_env()` / `EmbeddingClient.from_env()` | **零影响**,该路径本就跑这些守卫 |
|
||||
| `*.from_settings(settings)`,settings 来自 `from_env` | **零影响** |
|
||||
| 手工构造 `GatewaySettings(...)` 或 `dataclasses.replace(...)`,组合合法 | **零影响** |
|
||||
| 手工构造/`replace`,组合非法 | **行为变更**:构造期抛 `ValueError`,不再留到运行时表现为超配额/误判卡死/探针被接管 |
|
||||
|
||||
已知受影响的下游:CHSAnalyzer 重建中的 YAML → 直接构造 → `from_settings()` 路径(PR#1 提交者正是在此撞上的)。该路径若配置合法则不受影响,若非法则从"静默故障"变为"启动即报错"——方向是收益。
|
||||
|
||||
版本:**1.0.1**(patch,用户 2026-07-29 拍板)。设计初稿曾建议 minor(构造期新抛 `ValueError` 是可观测的收紧),用户判定受影响面仅限"手工拼出非法配置"这一本就故障的路径,按修复发 patch。CHANGELOG 必须把行为收紧单列小节,不能只混在"修复"里——patch 号不会给下游预警,changelog 是唯一的告知渠道。
|
||||
|
||||
发版时按 `docs-convention` §2 末行过发布清单;wiki `参考-配置键` 页现有表述("须 ≤ `PGW_LEASE_TTL_S`""须 ≥ 最大源 TTFT")与新行为一致,**无需改动内容**。
|
||||
|
||||
## 5. 非功能维度
|
||||
|
||||
| 维度 | 回答 |
|
||||
|---|---|
|
||||
| 并发与取消 | **不适用但需写明**:`__post_init__` 是同步纯计算(只读自身字段做比较),无 I/O、无 await、无锁,不存在取消穿透点。不引入任何全局状态,纯 asyncio 中立铁律不受影响 |
|
||||
| 降级方向 | 装配期校验属**准入侧**,按库铁律"报错而非放行"。无后端依赖,无降级分支 |
|
||||
| 幂等与重复 | `__post_init__` 不修改任何字段(frozen 也不允许),重复构造同一配置得同一结果;校验本身无副作用 |
|
||||
| 持久化与原子性 | 不适用,配置对象不落盘 |
|
||||
| 性能 | 每次构造增加三次 `max()` 遍历 sources(典型 1-4 个源)。`GatewaySettings` 只在装配期构造,不在请求路径上,可忽略 |
|
||||
|
||||
## 6. 测试策略
|
||||
|
||||
`tests/unit/test_config.py` 新增一个测试类,覆盖矩阵为 **4 条不变量 × 2 条构造路径**:
|
||||
|
||||
| 用例 | 断言 |
|
||||
|---|---|
|
||||
| 三条守卫各自:`dataclasses.replace` 构造出违反组合 | 抛 `ValueError`,消息含对应字段名 |
|
||||
| 三条守卫各自:边界值恰好相等(`timeout_s == lease_ttl_s` 等) | **构造成功**——守卫收紧的是错的那些,不是所有直接构造 |
|
||||
| `sources=()` | 抛 `ValueError`,消息点明"至少一个源",**且不是 `max() arg is an empty sequence`** |
|
||||
| `GatewayClient.from_settings(非法 settings)` | 抛 `ValueError`。**注意抛点**:方案 A 之下非法实例根本无法存在,异常发生在实参求值(构造 settings)那一刻,不在工厂内部——这正是构造期把关换来的性质,测试 docstring 须写明,以免后人误读为工厂自带校验 |
|
||||
| `OcrSettings` / `EmbeddingSettings` 直接构造包着非法 gateway | 抛 `ValueError`(证明三条 client 线一并覆盖) |
|
||||
| 既有 447 passed / 34 skipped | 全绿,零回归 |
|
||||
|
||||
TDD 顺序:先写测试跑出预期失败(预计 3 条守卫 + 空源 + from_settings 端到端 共失败 6 条以上),再实现,再全绿。测试不新增 mock,全部用既有 `_env()` helper 构造真实 settings 再派生。
|
||||
|
||||
## 7. 与 PR#1 的关系
|
||||
|
||||
功能对齐,不是推翻。PR#1 的问题诊断完全正确,本设计沿用其核心结论(守卫属于类不变量,应在构造期生效),差异集中在:
|
||||
|
||||
| 维度 | PR#1 | 本设计 |
|
||||
|---|---|---|
|
||||
| 覆盖的不变量 | 2 条 | 4 条(补 `probe_ttl_s`、空 sources) |
|
||||
| 代码形态 | 保留模块级 `_guard_*(settings)` | 改为 `_validate_*` 私有方法,同 `SourceConfig` |
|
||||
| docstring | 引用 `CLAUDE.md §4.5`(下游读者看不到该文件)、带论证口吻 | 只引 ARCHITECTURE §7.3 与自身概念,解释"为什么"不复述辩论 |
|
||||
| 错误消息 | 字段名 + 附 env 键模板 | 只点字段名(§2.3) |
|
||||
| 测试 | 4 条,全走 `replace`,其中 1 条同义反复 | 覆盖 4 不变量 × 2 路径 + 边界值 + 三条 client 线 |
|
||||
| 导入位置 | 两处函数内 `import dataclasses` | 文件顶部 |
|
||||
|
||||
合并后应关闭 PR#1 并在其中说明:诊断被采纳,实现按库内规范重写并扩展了覆盖范围。
|
||||
|
||||
## 8. 人类拍板结论(2026-07-29)
|
||||
|
||||
| 问题 | 结论 |
|
||||
|---|---|
|
||||
| 主决策 | **接受方案 A**,构造期强制,承诺收紧 |
|
||||
| 范围 | **全量**:`probe_ttl_s` 与空 sources 一并纳入 |
|
||||
| 消息文案 | **去掉 env 键名**,只点字段名(§2.3) |
|
||||
| 版本 | **1.0.1**(patch);初稿建议的 minor 被否,理由与代偿见 §4 |
|
||||
| PR#1 处置 | 重写合并后关闭并说明,诊断归功于提交者 |
|
||||
|
||||
## 9. 实施与验证留痕
|
||||
|
||||
实施于 `fix/settings-invariant-guards`(5 commits)。TDD 证据:新测试类先 **6 failed / 3 passed**(3 条为边界护栏,本就应过),实现后全绿。
|
||||
|
||||
独立 verifier(全新上下文)核验结论 **可以合并,无阻塞**,其中两项证据值得留档:
|
||||
|
||||
- **变异测试 12/12 全杀**:逐个破坏实现(删各 `_validate_*` 调用、`>`↔`>=`、`<`↔`<=`、删空源检查、`_PROBE_GRACE_S` 归零)均有测试失败,无一存活。边界侧用例(恰好相等必过)对每条守卫都真实有效,差一错误可捕获。
|
||||
- **无热路径回归**:库内**没有任何地方**构造或 `replace` `GatewaySettings`(`src/` 中 4 处 `dataclasses.replace` 全在 `middleware/structured.py`,作用于 `ChatRequest`/`LLMResponse`)。单次构造实测 1.45 µs,装配期一次性成本。`pickle`/`deepcopy` 不触发 `__post_init__`,只有 `replace` 触发——序列化往返既无额外开销也不构成二次守卫点。
|
||||
|
||||
### 9.1 verifier 发现的同族遗漏(范围外,另起任务)
|
||||
|
||||
`GatewaySettings` 仍有 **14 条校验只挂在 `from_env`**,直接构造/`replace` 全部放行,与本设计所修的是同一个 bug 类:`limiter/breaker/cache_backend=redis` 但 `redis_url=None`、`telemetry_backend=sqlite/postgres` 但 path/dsn 为 None、`selector`/`quota_full`/各 backend 的枚举合法性、`structured_max_retries` 负值、`scope` 空串等。
|
||||
|
||||
严重性高于本次所修的三条,因为 `client.py:262/282/302/312/316` 有 5 处 `assert ... # 内部不变量: config 已校验` **明文依赖这个前提**,而该前提在 `from_settings` 路上为假:断言开启时抛裸 `AssertionError`(不点字段不说原因),`python -O` 下断言消失、错误退化为 redis 库抛出的天书。后者同时违反 CLAUDE.md §4.3"禁止 assert 承担生产校验"。
|
||||
|
||||
**有意不纳入本次交付**(避免任务外扩张),另起任务处理。
|
||||
@@ -0,0 +1,152 @@
|
||||
# est_tokens 解耦设计(issue #2)
|
||||
|
||||
- **日期**: 2026-07-30
|
||||
- **触发**: Gitea issue #2《est_tokens 应由库按实测自估,而不是让调用方填一个没有正确取值的常量》
|
||||
- **档位**: 强制档(改公共 API 语义 + `usage_source` 公共值域 + 推翻一条已声明保留的迁移行为)→ 需人类审批门
|
||||
- **修订的权威文档**(经独立审查补全):
|
||||
- `ARCHITECTURE.md` §7.7 行 428(`SourceConfig.est_tokens` 描述)、§5.1 行 331(`usage_source` 值域)、**§4.4 行 305**("token 按 `est_tokens` 预扣")、**§7.1 行 384**("打捞路径强制 `usage_source="estimated"`",因 §3.2 #4 变为有条件)
|
||||
- `migrations/chsanalyzer.md` 行 151 与 G2(行 185)
|
||||
- **`.env.example` 行 11**("TPM > 0 时 EST_TOKENS 必填 > 0",约束已废除)
|
||||
|
||||
## 1. 问题:一个常量被派了两份互相矛盾的差事
|
||||
|
||||
`SourceConfig.est_tokens` 同时承担两个职责,而两者对"保守"的定义方向相反:
|
||||
|
||||
| 职责 | 语境 | "保守"意味着 | 填大的后果 |
|
||||
|---|---|---|---|
|
||||
| TPM 入场预扣 | 限流 | 多押金,宁可压吞吐也不击穿网关 | 安全(只是慢) |
|
||||
| usage 缺失时的用量兜底 | 计费 | **不存在保守方向** | 账单虚高 |
|
||||
|
||||
CHS 原版 `config.py:55` 把它定义为"须 ≥ 最坏情形 token"——按定义是**上界**。拿上界当实测值记账,必然系统性高估。库把遥测拆成 `prompt_tokens`/`completion_tokens` 两列后又把整个估值塞进 `completion`(`openai_compat.py:146`),而 `pricing.py:70-72` 按 `prompt×input价 + completion×output价` 换算,输出单价通常是输入的数倍——**双重高估**。
|
||||
|
||||
实测算例:`est_tokens=4000`,单价输入 1 元/百万、输出 8 元/百万,真实消耗 400+100:
|
||||
|
||||
| | 记账 token | cost |
|
||||
|---|---|---|
|
||||
| 真实 | 400 / 100 | 0.0012 元 |
|
||||
| 现状 | 0 / 4000 | 0.032 元(**26 倍**) |
|
||||
|
||||
第二个症状是装配约束:`types.py:125` 的 `tpm > 0 ⇒ est_tokens > 0` 把供应商配额(运维可从配额页抄到)与库的实现细节(预扣量,无人能正确取值)绑死。下游 CHSAnalyzer 删掉 `est_tokens` 配置项后,`tpm` 就再也不能填非 0,只能在自己的配置模型里把 `tpm` 限死为 0 绕开——库把内部细节泄漏进了配置面。
|
||||
|
||||
## 2. 备选方案对比
|
||||
|
||||
### 2.1 决策点一:usage 不可得时遥测记什么
|
||||
|
||||
| 方案 | 做法 | 权衡 |
|
||||
|---|---|---|
|
||||
| **A(选定)** | 记 `0/0`,`usage_source` 扩一个 `unavailable`,cost 记 NULL | 缺数据可被统计:`SUM(cost)` 跳过 NULL,`COUNT(*) WHERE usage_source='unavailable' AND cache_hit = false` 能量化账的缺口(**必须带 `cache_hit` 限定**:按 §3.2 #5 的裁决,缓存命中行可以既是 `unavailable` 又有 `cost=0.0`,它们本无账目缺口,不加限定就会灌水——与 §3.3 剔出 OCR 用的是同一把尺子)。代价:公共值域变更,需进 CHANGELOG,且该查询口径要一并写进 wiki(§8) |
|
||||
| B | 记 `0/0`,沿用 `estimated` | 改动最小(等于把 `est_tokens>0` 路径统一到 `est_tokens=0` 的现状行为)。**否决**:cost 算出 `0.0`,"免费"与"未知"在数据上不可区分,缺口不可量化 |
|
||||
| C | 保留 est 兜底,只修 `prompt`/`completion` 分配比例 | 保住 CHS"保守计量"意图。**否决**:比例是又一个没有正确取值的魔数,且未触及"拿上界当实测"这个根因,仍高估约 9 倍 |
|
||||
|
||||
### 2.2 决策点二:`est_tokens` 未填时的默认预扣量
|
||||
|
||||
先排除"不预扣":`try_acquire` 传 0 会让 TPM 窗口在请求飞出到 settle 回来的整段时间形同虚设,大批请求可同时入场,正是"防击穿网关"要防的场景,与 CLAUDE.md 降级方向铁律相悖。
|
||||
|
||||
| 方案 | 源甲 `tpm=6000` | 源乙 `tpm=600000` | 权衡 |
|
||||
|---|---|---|---|
|
||||
| **派生 `tpm//60`(选定)** | 押 100 → 60 个在途 | 押 10000 → 60 个在途 | 尺度无关:任何配额规模都给出同一行为上限,语义可写进 docstring("一次调用约占一秒钟的配额份额") |
|
||||
| 固定常量 1000 | 押金占配额 1/6 → 仅 6 个在途,小请求场景白慢数倍 | 押金占 1/600 → 600 个在途,大请求场景照样撞 429 | **否决**:常量与配额规模无关,在途上限随配额乱飘,无法解释取值 |
|
||||
|
||||
### 2.3 派生逻辑的落点
|
||||
|
||||
| 方案 | 权衡 |
|
||||
|---|---|
|
||||
| **`SourceConfig.effective_est_tokens()`(选定)** | 纯方法只读自身字段,落 `types.py` 内核不违反依赖铁律;零装配变更、零端口变更;5 个调用点(`QuotaGate` 入场 + retry/embedding 各自的成功侧与失败侧结算)共用一份 |
|
||||
| 注入 `GlobalLimits` 到 `QuotaGate`,派生取全局与单源 tpm 的较紧者 | 能覆盖"单源 `tpm=0` 而全局 `tpm>0`"的场景。**否决**:需改三处装配(`retry.py:186`/`embedding.py:116`/`ocr.py:119`),且它修的是一个**既有**缺口(见 §7),超出本任务范围 |
|
||||
| 派生下沉到两个 limiter 后端 | **否决**:`try_acquire(source_key, est_tokens)` 的入参会变成谎言(后端忽略它),且逻辑要写两遍,违反 D3"语义契约只有一份"与 P7"决策与存储分离" |
|
||||
|
||||
## 3. 选定方案
|
||||
|
||||
### 3.1 `usage_source` 三态值域
|
||||
|
||||
| 值 | 含义 | 生产者 | cost |
|
||||
|---|---|---|---|
|
||||
| `measured` | usage 帧完整可信 | 正常路径 | 按 token 换算 |
|
||||
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
|
||||
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL**(缓存命中行例外,见 §3.2 #5) |
|
||||
|
||||
`estimated` 保留且有真实生产者(打捞),同时保证历史库里既有的 `estimated` 行读兼容。
|
||||
|
||||
**不变式的准确表述**: 产生了真实网关调用、但用量不可得的行 → cost 为 NULL。缓存命中行不在此列(见 §3.2 #5)。
|
||||
|
||||
**值域的强制落点**: `types.py` 模块级 frozenset 常量,仅约束**库内生产侧**——所有写入 `usage_source` 的位置从该常量取值,测试断言库内产出恒在三态内。**不在 `LLMResponse`/`Usage`/`TransportResult` 等 frozen dataclass 上加 `__post_init__` 值域校验**,两条理由:① 它们是运行时构造点(如 `retry.py:418`),裸 `ValueError` 不属 `errors.py` 四分类,`RetryMW` 不捕它,会直接逃出 `chat()`,违反错误分类驱动铁律;② `LLMResponse` 是三项目已消费的公共类型,新增运行时校验是下游可见行为变更,超出本任务。故 §6 的值域测试断言"库内所有生产点的产出值落在三态内",而非"越界字符串被拒"。
|
||||
|
||||
### 3.2 逐处改动
|
||||
|
||||
| # | 位置 | 改动 |
|
||||
|---|---|---|
|
||||
| 1 | `types.py:125` | 删除 `tpm > 0 ⇒ est_tokens > 0`;`est_tokens` 保留字段、语义降为"可选调优覆盖" |
|
||||
| 2 | `types.py` `SourceConfig` | 新增 `effective_est_tokens()`:显式值 > 0 则原样返回;否则 `tpm > 0` 时返回 `max(1, tpm // 60)`,`tpm == 0` 时返回 0 |
|
||||
| 3 | `openai_compat.py:146,176` | 两处兜底改为 `(0, 0, "unavailable")` / `(0, "unavailable")`,不再读 `source.est_tokens` |
|
||||
| 4 | `openai_compat.py:336` | 打捞覆盖加条件:仅当 `usage_source == "measured"` 时降级为 `estimated`,否则保持 `unavailable`(否则 `0/0` 会被标 `estimated` 而算出假的 `0.0`) |
|
||||
| 5 | `middleware/telemetry.py:130-135` | cost 分支增加短路:`usage_source == "unavailable"` → `None`。**插在 `cache_hit` 分支之后**:缓存命中未产生新调用,`0.0` 是事实而非未知,既有"缓存命中 0.0"语义保持不动。故 `cache_hit=True` 且 `usage_source="unavailable"` 的行 cost 仍是 `0.0`,与 §3.1 不变式不冲突(那条只管产生了真实调用的行) |
|
||||
| 6 | `middleware/telemetry.py:58,100` | 失败尝试与终态失败的 `usage_source` 由 `estimated` 改 `unavailable`(用量确实不可得;这两行 cost 本已是 None,语义对齐不改金额) |
|
||||
| 7 | `middleware/ratelimit.py:26` | `source.est_tokens` → `source.effective_est_tokens()` |
|
||||
| 8 | `retry.py:370`、`embedding.py:294` | **失败侧**保守结算改用 `effective_est_tokens()`。必须同改:预扣派生值而结算退 `est_tokens=0` 会让 `delta` 为负、退掉全部押金,丢掉"失败可能已被计费"的保守意图 |
|
||||
| 9 | `retry.py:338`、`embedding.py:271` | **成功侧**结算:`usage_source == "unavailable"` 时按 `effective_est_tokens()` 结算,而非 `prompt+completion`(此时恒为 0)。**这条是保持既有行为、不是新增保守**:改前 `_resolve_usage` 恰好返回 `est_tokens`,使 `actual == 预扣量`、`delta == 0`、押金留存;#3 把它改成 `(0, 0)` 后若不同改,成功调用的押金会被整笔退回,对"从不返回 usage 帧的网关源"构成系统性 TPM 计量失效——闸门退化成进门即放行、出门即清账,正是降级方向铁律要防的击穿 |
|
||||
| 10 | `embedding.py:383,390` | 二值合并扩为三态:任一批 `unavailable` → 整体 `unavailable`;否则任一 `estimated` → `estimated`;否则 `measured`。同步更新 `types.py:273` 的行内注释 `# measured | estimated`,内核里不留与三态矛盾的注释 |
|
||||
| 11 | `embedding.py:397` `_total_cost` | 存在 `unavailable` 批时整体 cost 记 NULL(逐批求和会给出一个偏低却看似有效的金额) |
|
||||
|
||||
### 3.3 明确不改的
|
||||
|
||||
**非 dead 的瞬时失败路径**(`retry.py:369` 的 `if not dead` 分支)按预扣量做**限流**结算的行为保留——那是限流语境,保守方向正确(失败请求可能已被网关计费),且该值只流向 `_settle_and_release`,不进遥测。其余三条失败分支(`RequestRejectedError`/`ResultInvalidError`/`SourceDeadError`)的 `actual` 停在初值 0(`retry.py:329`),属既有行为,本次**不动**——#8 已把行号钉死,实现时不要顺手把这三条也改成保守结算。`SourceConfig.est_tokens` 字段与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键**保留不删不改名**(迁移兼容硬约束,ARCHITECTURE §5.1)。`RateLimiter` 端口签名不变。
|
||||
|
||||
**`ocr.py:411` 的 `usage_source="measured"` 保留不改**(初稿曾列为改动项,独立审查后剔出)。库既有立场是 OCR 的 0 token 属**事实**而非未知——`types.py:51` "token 用量;OCR 等无计费调用填 0"、`ocr.py:9` "settle 恒为 0(OCR 无 token 计费)"——故 `measured` 是准确陈述。改成 `unavailable` 还会反噬 §2.1 的核心度量:`COUNT(*) WHERE usage_source='unavailable'` 本用于量化账目缺口,灌进本无缺口的 OCR 行就失去意义。
|
||||
|
||||
## 4. 旧版行为审计(迁移保留项的推翻声明)
|
||||
|
||||
| 旧版行为 | 出处 | 本次处置 |
|
||||
|---|---|---|
|
||||
| usage 缺失按 `est_tokens` 估算并标 `estimated`,不静默用 0 | CHS `invokers.py:241-254`;`migrations/chsanalyzer.md:151` 标记为**保留** | **有意放弃**。理由:CHS 只记单个 `total_tokens`,不存在 prompt/completion 分配问题;库拆两列后无法忠实分配,且 `est_tokens` 按 CHS 自身定义是最坏情形上界。"保守"在限流语境安全、在计费语境只有错误一个方向 |
|
||||
| 缺失时不静默用 0(拒绝 VT 的"填 0 且不标注") | 同上;`m1-core-design.md:222` 行 10 | **保留**。本方案记 0 但带 `unavailable` 显式标记且 cost 为 NULL,反静默的原始意图完整保留——被放弃的只是"编一个数字"这个手段 |
|
||||
| `est_tokens` 作 TPM 入场预扣常量 | CHS `config.py:55` | **保留**,仅由必填降为可选覆盖 |
|
||||
| `tpm > 0 ⇒ est_tokens > 0` 装配校验 | `m1-core-plan.md:93` | **替换**为库内派生,校验删除 |
|
||||
| 打捞路径强制 `estimated` | `m1-core-design.md` §6 | **保留**,补一个前置条件(§3.2 #4) |
|
||||
| 遥测 `INSERT OR IGNORE` 幂等、写失败降级不冒泡、列只增 | `m1-core-design.md:218` | **保留**,本次无 DDL 变更 |
|
||||
|
||||
## 5. 非功能维度
|
||||
|
||||
**并发与取消**: `effective_est_tokens()` 是无状态纯方法(只读 frozen dataclass 字段),并发安全、无锁、可重复调用。本次改动不新增 `await` 点、不改变任何 `try/finally` 结构,取消穿透路径与 in-flight 释放语义原样不动。#8 与 #9 合起来保证**成功侧与非 dead 瞬时失败侧**的预扣与结算恒取同一派生值(`delta == 0`)——这是本设计里最容易漏的一致性约束(初稿只写了失败侧,独立审查发现成功侧缺口)。**取消 / RequestRejected / ResultInvalid / SourceDead 四侧不在此列**:它们的 `actual` 停在 `retry.py:329` 的初值 0、全额退回,属 §3.3 声明不动的既有行为。
|
||||
|
||||
**降级方向**: 不改变任何后端的降级方向。遥测侧仍是静默降级(`telemetry.py:161` 的 warning 不冒泡);限流侧仍是 `GovernanceBackendError` 上抛而非放行;TPM 计量不因 usage 帧缺失而静默失效(#9)。
|
||||
|
||||
否决 issue 建议的 p90 自估,主论据是 **`TelemetryRecorder` 目前是纯只写端口,自估需要新增读接口并强制所有后端(含 `none`)实现**,公共 API 扩张远大于它要省掉的一个可选字段,且尚无实测证据表明派生默认值不够用(§8)。初稿曾论证"那会把两条方向相反的降级铁律焊在一起",此论据经审查后**撤回**:p90 方案完全可以在遥测读失败时回退到纯派生值,限流侧仍能保持 fail-closed,故并非必然冲突。结论不变,理由收窄。
|
||||
|
||||
**幂等与重复**: `Permit.settle()`/`release()` 的幂等 flag 语义不变。#8 与 #9 使预扣与结算取自同一派生函数,同一请求重复结算仍是 no-op。
|
||||
|
||||
**持久化与原子性**: 无 DDL 变更(两 schema 的 `cost` 列已可空);无新增落盘点;Redis Lua 脚本不改(仍接收调用方算好的 est)。历史数据不迁移:旧行的 `estimated` 语义在新值域中依然合法可读。
|
||||
|
||||
## 6. 错误处理与测试策略
|
||||
|
||||
值域校验失败属配置/内部不变量违反 → `ValueError`(装配期 fail-loud),不进四分类运行时错误。本次不改变任何调用失败的分类归属。
|
||||
|
||||
| 测试 | 断言要点 | 文件 |
|
||||
|---|---|---|
|
||||
| 约束解绑 | `tpm=6000, est_tokens=0` 构造成功(改前抛 ValueError) | `tests/unit/test_types.py` |
|
||||
| 派生尺度无关 | `tpm=6000→100`、`tpm=600000→10000`、`tpm=0→0`、显式值优先、`tpm=30→max(1,·)` 不为 0 | 同上 |
|
||||
| cost 不再造假 | `est_tokens=4000` + usage 缺失 → `0/0/unavailable` 且 `record_llm_call` 收到 `cost=None`(改前 `0.032`) | `tests/unit/test_openai_compat.py`、`test_telemetry.py` |
|
||||
| 缓存命中不受牵连 | `cache_hit=True` 且 `unavailable` → cost 仍为 `0.0`(锁定 §3.2 #5 的分支次序) | `test_telemetry.py` |
|
||||
| 打捞前置条件 | 打捞 + usage 帧存在 → `estimated` 且 cost 非 None;打捞 + usage 缺失 → `unavailable` 且 cost 为 None(回归 §3.2 #4) | `test_openai_compat.py` |
|
||||
| **失败侧**结算不退多 | 未填 `est_tokens` 且 `tpm>0` 时失败请求,TPM 窗口残留量等于派生预扣量而非 0(回归 §3.2 #8) | `tests/contracts/test_limiter_contract.py` |
|
||||
| **成功侧**结算不退多 | usage 缺失的**成功**调用后,TPM 窗口残留量等于派生预扣量而非 0(回归 §3.2 #9,本设计最易漏的一条)。现有锚点 `test_retry.py:149` 的 `_src("a", tpm=1000, est_tokens=400)` 旁加一个 `est_tokens=0` + usage 缺失的用例 | `tests/unit/test_retry.py`、`test_limiter_contract.py` |
|
||||
| 三态合并 | 混合批 `measured+unavailable` → 整体 `unavailable` 且 cost 为 NULL | `tests/unit/test_embedding.py` |
|
||||
| OCR 不变 | OCR 成功行仍为 `measured` 且 settle 恒 0(防回归,锁定 §3.3 的剔出决定) | `tests/unit/test_ocr_client.py` |
|
||||
| 值域封闭 | 库内所有生产点的产出恒落在三态内;公共 dataclass 不因越界值抛异常(锁定 §3.1 的落点决定) | `test_types.py` |
|
||||
|
||||
限流侧断言随 `tests/contracts/test_limiter_contract.py` 同时覆盖内存与 Redis 两后端(Redis 走真实实例,遵守共享后端不并跑纪律)。
|
||||
|
||||
## 7. 已知限制(本次不修,显式声明)
|
||||
|
||||
单源 `tpm == 0` 而全局 `tpm > 0` 时,`effective_est_tokens()` 返回 0,全局 TPM 闸拿 0 预扣、入场保护形同虚设。**这是既有行为**(现状约束只管 `cfg.tpm > 0`,该场景下 `est_tokens=0` 本就合法),本方案不引入也不修复它。修它需要把 `GlobalLimits` 注入 `QuotaGate`(§2.3 备选二),属独立议题,建议另开 issue。
|
||||
|
||||
## 8. 下游影响与发布
|
||||
|
||||
`est_tokens` 从必填降为可选后,CHSAnalyzer 可删掉"`tpm` 必须为 0"的绕行校验并填真实 TPM。`usage_source` 出现第三个值、且不可得行的 cost 由数值变 NULL,是下游可见的行为变更:成本汇总若此前依赖"cost 非空"隐含假设需复核。按 `docs-convention.md` §2,发版须同步 CHANGELOG 与 wiki 的 usage/成本口径说明,并在 issue #2 回帖结论。
|
||||
|
||||
遥测驱动的自适应预估(issue 原建议)不在本次范围,待默认派生值在真实负载下出现实测问题后再评估。
|
||||
|
||||
## 9. 规模判定
|
||||
|
||||
改动面(独立审查后重算):**6 个源文件**(`types.py`、`transports/openai_compat.py`、`middleware/telemetry.py`、`middleware/ratelimit.py`、`middleware/retry.py`、`embedding.py`;`ocr.py` 已剔出)、**7 个测试文件**、**3 份权威文档**(ARCHITECTURE.md、`migrations/chsanalyzer.md`、`.env.example`),外加按 `docs-convention.md` §2 必须同步的 CHANGELOG 与用户文档站 wiki(版本 bump 不得裸发)。
|
||||
|
||||
属跨多文件功能 → 本设计经人类审批后须走 `writing-plans` 出实施计划,不得直接进实现。
|
||||
@@ -0,0 +1,164 @@
|
||||
# GatewaySettings 装配校验补齐(第二轮)
|
||||
|
||||
- **日期**: 2026-07-30;**状态**: **已批准并实施**(2026-07-30 人类门通过;§9 结论、§10 实施留痕)
|
||||
- **缘起**: [2026-07-29-settings-invariant-guards-design.md](2026-07-29-settings-invariant-guards-design.md) §9.1 —— 独立 verifier 在第一轮交付后发现,`from_env` 上还留着一批同族校验;本设计是那一轮的续作,**同一个 bug 类的剩余部分**
|
||||
- **上游依据**: 第一轮设计 §2 已批准的方案 A(不变量归属于类,不归属于某个工厂);CLAUDE.md §4.3(assert 仅用于内部不变量)、§4.5(装配只有两条路)
|
||||
|
||||
## 1. 待收拢的校验清单(逐条实测确认只在 `from_env` 生效)
|
||||
|
||||
### A. 枚举合法域(6 条)
|
||||
|
||||
| 字段 | 合法域 | 现居 |
|
||||
|---|---|---|
|
||||
| `limiter_backend` / `breaker_backend` | `{memory, redis}` | `_load_pgw`(经 `_load_choice`) |
|
||||
| `cache_backend` | `{redis, memory, none}` | `_load_pgw` 内联 |
|
||||
| `telemetry_backend` | `{sqlite, postgres, none}` | `_load_pgw` 内联 |
|
||||
| `selector` | `_SELECTORS` | `from_env` 调 `_load_choice` |
|
||||
| `quota_full` | `_QUOTA_FULL` | `from_env` 调 `_load_choice` |
|
||||
|
||||
直接构造传 `selector="random"` 或 `cache_backend="rediss"` 一律放行,后果是装配时落进 `_build_*` 的 else 分支或静默不建后端。
|
||||
|
||||
### B. 条件必填(7 条,跨字段)
|
||||
|
||||
| 条件 | 要求 | 违反后果 |
|
||||
|---|---|---|
|
||||
| `limiter_backend`/`breaker_backend`/`cache_backend` 取 `redis` | `redis_url` 非空 | **见 §2**,最严重 |
|
||||
| `cache_backend != "none"` | `cache_namespace` 非空 | 缓存 key 失去租户隔离——踩"无缓存毒化"铁律 |
|
||||
| `cache_backend != "none"` | `cache_ttl_s > 0` | `from_env` 明令禁止的"永不过期"从另一条路进来 |
|
||||
| `telemetry_backend == "sqlite"` | `telemetry_sqlite_path` 非空 | 断言炸或写空路径 |
|
||||
| `telemetry_backend == "postgres"` | `telemetry_pg_dsn` 非空 | 同上 |
|
||||
|
||||
### C. 标量域(2 条)
|
||||
|
||||
`structured_max_retries ≥ 0`;`scope` 非空(空 scope 会污染遥测与缓存命名空间)。
|
||||
|
||||
## 2. 为什么这批比第一轮更严重:`client.py` 的断言前提为假
|
||||
|
||||
`client.py` 有 5 处断言**明文声称这个前提已经成立**:
|
||||
|
||||
```python
|
||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||
```
|
||||
|
||||
位置:`client.py:262/282/302`(redis_url)、`:312`(pg_dsn)、`:316`(sqlite_path)。走 `from_settings` 时该注释是假的,verifier 实测:
|
||||
|
||||
| 运行方式 | 结果 |
|
||||
|---|---|
|
||||
| 断言开启 | `AssertionError()` —— 裸断言,不点字段、不说原因 |
|
||||
| `python -O` | 断言消失,退化为 redis 库的 `ValueError: Redis URL must specify one of the following schemes...` |
|
||||
|
||||
后者正是 CLAUDE.md §4.3 禁止的"assert 承担生产校验"。
|
||||
|
||||
**但注意结论的方向**:这 5 处 assert 本身不是要修的东西——它们要的前提是对的,错的是没人保证这个前提。§4 给出处置。
|
||||
|
||||
## 3. 方案
|
||||
|
||||
沿用第一轮已批准的方案 A,不重新论证:全部收进 `GatewaySettings.__post_init__`,新增三个私有方法与既有四个并列。
|
||||
|
||||
| 方法 | 覆盖 |
|
||||
|---|---|
|
||||
| `_validate_backends` | A 类 6 条枚举 + B 类 redis_url 三条件 |
|
||||
| `_validate_cache` | `cache_namespace` 非空、`cache_ttl_s > 0`(仅 `cache_backend != "none"` 时) |
|
||||
| `_validate_telemetry` | sqlite path / postgres dsn 条件必填 + §5 的 DSN 形态 |
|
||||
|
||||
标量两条(`structured_max_retries`、`scope`)并入 `_validate_sources` 改名后的 `_validate_identity`,与 `SourceConfig._validate_identity` 同名同职。
|
||||
|
||||
枚举合法域上提为模块级 frozenset 常量(`_LIMITER_BACKENDS` 等),`_load_pgw` 与 `__post_init__` 共用一份,消除现有的内联字面量重复。
|
||||
|
||||
**否决的替代**:在 `_build_limiter`/`_build_cache` 等工厂函数里逐个补显式检查。理由同第一轮 §2 方案 B——校验散落在消费点,每加一个后端就多一处要同步,且 `dataclasses.replace` 仍绕过。
|
||||
|
||||
## 4. 5 处 assert 的处置:**保留,不改**
|
||||
|
||||
修好构造期校验后,`settings.redis_url is not None` 就真的成了内部不变量——CLAUDE.md §4.3 原文"assert 仅用于内部不变量"说的正是这种用法,同时它给类型检查器收窄了 `str | None`。此时删掉 assert 反而丢失类型信息,改成 `raise` 则是在防御一个已被构造期排除的情况(死代码)。
|
||||
|
||||
**要改的是注释**:`# 内部不变量: config 已校验` 应点明由谁保证,例如 `# 内部不变量: GatewaySettings._validate_backends 已保证`。前一轮的教训就是这类注释会随时间变成谎言。
|
||||
|
||||
## 5. Postgres DSN:校验而非规范化(本轮唯一的新决策)
|
||||
|
||||
`_load_pg_dsn` 对 `from_env` 读到的 DSN 做了**规范化**:剥掉 SQLAlchemy 风格的 `+asyncpg` 驱动后缀(asyncpg 不认)。直接构造那条路不会剥,`postgresql+asyncpg://...` 会原样送进 asyncpg 然后在首次写遥测时才炸。
|
||||
|
||||
| 选项 | 权衡 |
|
||||
|---|---|
|
||||
| A. 构造期校验,含 `+driver` 即报错 | 显式,库不碰用户给的值;但两条装配路对同一输入接受度不同 |
|
||||
| B. 构造期静默剥后缀 | 两条路完全对齐;但 frozen 类在构造期悄悄改字段,调用方不知情 |
|
||||
| **C. 构造期剥后缀 + `logger.warning`(用户 2026-07-30 拍板)** | 两条路行为对齐,同时不静默——调用方在日志里看得见库动了他的值,想根治就自己改 DSN |
|
||||
|
||||
选 C。实现要点:`object.__setattr__` 改 frozen 字段(`SourceConfig` 无此先例,但 frozen 的约束是对**外部**不可变,构造期规范化是既有 dataclass 惯用法);warning 走 loguru(核心依赖,库内 `ocr.py:183`/`embedding.py:318` 同款用法)。
|
||||
|
||||
**warning 不会打扰 env 用户**:`_load_pg_dsn` 保留现有的剥离逻辑,`from_env` 传给构造函数时 DSN 已经干净,`__post_init__` 无事可做。只有手工构造传了带后缀的 DSN 才会触发。三项目 `.env` 里那些 SQLAlchemy 写法不会每次装配刷一条 warning。
|
||||
|
||||
代价是同一件事有两处剥离逻辑。用同一个模块级 helper `_strip_dsn_driver(dsn)` 供两处调用,避免实现分叉。
|
||||
|
||||
## 6. 行为审计
|
||||
|
||||
| 现有行为 | 处置 |
|
||||
|---|---|
|
||||
| `from_env` 对上述 15 条的校验与报错 | **全部保留**,时机提前到 `cls(...)`;`_load_*` 内联检查删除,避免同一约束两处维护 |
|
||||
| `_load_pg_dsn` 剥 `+driver` | **保留**,继续只在 env 路径生效(§5) |
|
||||
| `_load_choice` 的 `default` 语义(键缺失时取默认) | **保留**,那是 env 解析职责,不是不变量 |
|
||||
| `_load_breaker` 的有效阈值派生 `max(配置值, 源级并发×2)` | **有意保留在 env 层**(verifier 二次核验点名,记此备案免成"第五批")。它是**派生**不是校验/规范化:两路产出确实不同(env 装配 threshold=5/并发=100 得 200,直接构造得 5),但派生依赖的是"用户没显式表态时库替他选一个合理值"的 env 语义;代码构造那条路,调用方给什么就是什么表态。其跨字段下限风险由 `_validate_probe` 在构造期兜底 |
|
||||
| 直接构造出上述任一非法组合 → 静默成功 | **有意替换**为构造期 `ValueError` |
|
||||
| `client.py` 5 处 assert | **保留**,仅改注释(§4) |
|
||||
| 异常类型 | 一律 `ValueError`,与第一轮及既有装配错误一致 |
|
||||
|
||||
**有意放弃**:不校验 `pricing_path` 指向的文件是否存在(I/O 不属于配置校验,`PricingTable.from_file` 自会报错);不强制 `cache_backend == "none"` 时 namespace/ttl 必须为 None(多余字段无害)。
|
||||
|
||||
## 7. 非功能维度
|
||||
|
||||
与第一轮同构,不重复论证:`__post_init__` 纯同步计算无 I/O(不适用并发/取消/持久化);装配期属准入侧,报错不放行;`__post_init__` 不改字段故幂等。**性能**:新增约 10 次字符串比较,第一轮实测单次构造 1.45 µs 且库内无热路径构造 `GatewaySettings`,可忽略。
|
||||
|
||||
## 8. 测试策略
|
||||
|
||||
`tests/unit/test_config.py::TestCrossFieldInvariants` 扩充(不新建类,同族不变量归一处):
|
||||
|
||||
| 用例组 | 断言 |
|
||||
|---|---|
|
||||
| 6 条枚举各一条非法值 | 抛 `ValueError`,消息含字段名与合法域 |
|
||||
| redis_url 三条件(limiter/breaker/cache 各一) | 抛 `ValueError`,消息点明需要 `redis_url` |
|
||||
| cache namespace 缺失 / ttl ≤ 0 | 抛 `ValueError` |
|
||||
| telemetry sqlite path / pg dsn 缺失 | 抛 `ValueError` |
|
||||
| `structured_max_retries=-1`、`scope=""` | 抛 `ValueError` |
|
||||
| pg dsn 含 `+asyncpg`(直接构造) | 后缀被剥,字段值为干净 DSN,且发出一条 warning(用 `caplog`/loguru sink 断言) |
|
||||
| pg dsn 干净(直接构造)、或经 `from_env` 传入 | **不发** warning——env 路已在 `_load_pg_dsn` 剥过,不该刷噪音 |
|
||||
| 合法组合(每种 backend 组合各一) | 构造成功——收紧的是错的那些 |
|
||||
| **回归护栏**:`GatewayClient.from_settings` 走 redis 三后端的合法配置 | 装配成功,证明 assert 前提真的被保证了 |
|
||||
|
||||
TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现改动都要有对应测试能杀死它。
|
||||
|
||||
版本:**1.0.2**(patch),CHANGELOG 同样单列"行为收紧"小节。
|
||||
|
||||
## 9. 人类拍板结论(2026-07-30)
|
||||
|
||||
| 问题 | 结论 |
|
||||
|---|---|
|
||||
| §5 DSN 处置 | **选 C**:构造期剥后缀 + `logger.warning`。不静默改用户的值,也不让两条装配路产出不一致 |
|
||||
| §4 assert 处置 | **保留,只改注释**,点明由哪个方法保证前提 |
|
||||
| 方案主体 | 沿用第一轮已批准的方案 A,无需重新论证 |
|
||||
| 版本 | 1.0.2(patch) |
|
||||
| **范围追加**(实施中经 verifier 发现后拍板) | G1-G4 四条同族遗漏一并纳入本轮;G1 的 scope 规范化取**静默**小写+strip(不告警——`from_env` 一直静默小写,scope 大小写不承载语义) |
|
||||
|
||||
## 10. 实施留痕
|
||||
|
||||
分支 `fix/settings-invariants-round-2`。TDD 两段:主体 15 条先 **16 failed**、G1-G4 追加 **9 failed**,实现后全绿(547 passed / 14 skipped,1.0.1 基线 516)。
|
||||
|
||||
### 10.1 独立 verifier 的关键发现
|
||||
|
||||
第一次核验判**有阻塞**,已修:
|
||||
|
||||
- **阻塞(本轮新引入)**:DSN 剥离的 warning 打印了完整连接串,**含明文密码**,而库内此前从无任何地方打印连接串——违反 P5。已改为只报 scheme 段变化,并补回归测试断言密码与 host/path 不进日志。
|
||||
- **变异测试 27/28 被杀**,唯一存活的是 `_load_pgw` 里 `PGW_CACHE_BACKEND` 域检查删掉后仍全绿(该 env 层 raise 零覆盖)。已补 `test_cache_backend_whitelist`,与既有 `test_telemetry_backend_whitelist` 对称。
|
||||
- **assert 处置经独立核验成立**:遍历所有可达构造路径均无法制造 assert 失败,`python -O` 下同样在构造期被拦(旧病症消失);唯一能触发的是 `object.__new__` 绕过 `__post_init__` 的人造路径,非公共 API。
|
||||
- **frozen 语义无副作用**:`object.__setattr__` 后 `hash`/相等性/集合去重正常,`replace` 幂等不重复告警,`pickle`/`deepcopy` 不触发 `__post_init__` 故不重复告警,对外仍抛 `FrozenInstanceError`。
|
||||
|
||||
### 10.2 G1-G4:第三批遗漏(已纳入本轮)
|
||||
|
||||
verifier 通读 `_load_*` 后发现,除设计 §1 的 15 条外还有四条**规范化**只在 env 路生效——与本轮所修的 DSN 是同一类:
|
||||
|
||||
| | 内容 | 危害 |
|
||||
|---|---|---|
|
||||
| G1 | `scope` 小写化 | **最严重**:scope 进 Redis key,大小写不一致使限流/熔断状态分裂到两套命名空间,分布式治理静默失效 |
|
||||
| G2 | `redis_url` 空串归 None | 空串骗过 `is None`,退化为 redis 客户端的连接串天书报错——正是本轮 CHANGELOG 声称已消除的那种 |
|
||||
| G3 | `pricing_path` 空串归 None | 退化为 `Is a directory: '.'` |
|
||||
| G4 | `EmbeddingSettings.batch_size`/`expected_dim` 域 | 该类无 `__post_init__`;晚一步到 client 构造才 fail-loud |
|
||||
|
||||
统一收进新增的 `GatewaySettings._normalize()`(在全部 `_validate_*` 之前跑)与 `EmbeddingSettings.__post_init__`。DSN 后缀因需看 backend 且需告警,规范化留在 `_validate_telemetry`。
|
||||
@@ -0,0 +1,164 @@
|
||||
# 响应可观测字段扩展设计(Issue #3)
|
||||
|
||||
- **日期**: 2026-07-31
|
||||
- **来源**: Gitea Issue #3(下游 dissect 审计需求)
|
||||
- **状态**: 已批准(2026-07-31,人类逐条确认 A2 / B1 / C1 / D1)
|
||||
- **触发档位**: 强制(变更 `types.py` 公共类型 + `ports.py` 端口签名 + 遥测持久化 schema)
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 1 | `LLMResponse` 暴露供应商侧 prompt cache 命中的输入 token 数 |
|
||||
| 目标 2 | `LLMResponse` 暴露 API 响应体实际返回的模型版本串 |
|
||||
| 目标 3 | 两字段同步落 `llm_calls` 遥测表(端口 18 → 20 字段) |
|
||||
| 目标 4 | `PricingTable` 支持可选的缓存读取单价,消除 cost 高估 |
|
||||
| 非目标 1 | 不改 `EmbeddingResponse` / OCR 响应——embedding 与 OCR 无 prompt cache 语义,且 issue 未提;`cost()` 新增参数带默认值,embedding 调用点(`embedding.py:419`)零改动 |
|
||||
| 非目标 2 | 不改 `cache_hit` 字段名/类型(破兼容),只在 docstring 消歧 |
|
||||
| 非目标 3 | 不为 `reasoning_tokens` 等其他 usage 细项开口(YAGNI,无下游需求) |
|
||||
|
||||
### 1.1 Issue 前提的一处修正
|
||||
|
||||
Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核查结果:
|
||||
|
||||
| 数据 | 实际所在 | 结论 |
|
||||
|---|---|---|
|
||||
| `usage.prompt_tokens_details.cached_tokens` | `raw={"usage": ...}`(流式 `openai_compat.py:362`、非流式 `:444`) | ✅ 已在 raw 内 |
|
||||
| 响应体顶层 `model` | **不在**。非流式 raw 只放 `body["usage"]`;流式 sink 只吸收 `usage` 与 `done` 两键(`_sse_delta`,`:44-47`),chunk 的 `model` 从未收集 | ❌ 需改 transport 采集 |
|
||||
|
||||
故本变更**不是纯字段暴露**,必须同时改 `transports/`。这决定了下面决策 A 的必要性。
|
||||
|
||||
## 2. 决策 A:字段的采集与传递路径
|
||||
|
||||
| 方案 | 做法 | 权衡 |
|
||||
|---|---|---|
|
||||
| A1 raw 约定键 | transport 往 `raw` 里塞 `{"model": ...}`;RetryMW 读 `raw.get("model")` 与 `raw["usage"]["prompt_tokens_details"]["cached_tokens"]` | 改动最小;但 `raw: dict[str, Any]` 变成隐式契约,键名靠约定;且 middleware 要懂 OpenAI 报文嵌套结构 |
|
||||
| A2 TransportResult 强类型字段(**推荐**) | `TransportResult` 追加 `cached_prompt_tokens: int \| None = None`、`model_reported: str \| None = None`;解析逻辑留在 `openai_compat.py`;RetryMW 直接搬运 | 报文格式知识不出 `transports/`,middleware 只做搬运,符合 P7(middleware 只依赖端口、不懂具体报文);两字段带默认值,`monkey_ocr` 的 OCR 结果类型不受影响 |
|
||||
| A3 middleware 解析 raw | RetryMW 内写 OpenAI 嵌套路径解析 | 把 provider 报文格式知识放进 middleware 层,新增非 OpenAI 兼容 transport 时会分叉;违反分层,否决 |
|
||||
|
||||
**选 A2**。`TransportResult` 是库内部流转类型(非三项目消费面),但仍按「新增必带默认值」处理,使 `openai_compat` 之外的构造点零改动;全库该类型仅 2 处构造(`openai_compat.py:354/436`)。
|
||||
|
||||
解析纪律(P5 一切外部输入校验后使用):`cached_tokens` 与 `model` 均来自网关响应,类型不可信。取值走防御 helper,不抛异常(可观测字段缺失绝不能打断主路径):
|
||||
|
||||
| 输入 | 结果 |
|
||||
|---|---|
|
||||
| `cached_tokens` 为非负 `int`(**含 `0`**) | 如实保留——`0` 是「该源上报了一次真实零命中」,与「未上报」的 `None` 语义不同,这正是本 issue 的核心诉求 |
|
||||
| `cached_tokens` 为负数 / 非 `int` / `bool` | `None`(`bool` 必须显式排除:`isinstance(True, int)` 在 Python 里为真) |
|
||||
| `usage` 或 `prompt_tokens_details` 非 dict | `None` |
|
||||
| `model` 为非空 `str` | 保留 |
|
||||
| `model` 为非 `str` / 空白串 | `None` |
|
||||
|
||||
## 3. 决策 B:缓存命中回放时两字段取什么值
|
||||
|
||||
| 方案 | LLMResponse 层 | 遥测层 | 权衡 |
|
||||
|---|---|---|---|
|
||||
| B1 原样回放(**推荐**) | 随缓存 JSON 回放原值 | 照记回放值 | 与既有口径一致——`CacheMW._rehydrate`(`cache.py:113-119`)只覆写与本次调用相关的时序字段(`latency_ms`/`ttft_ms`/`max_inter_token_ms`/`call_id`/`cache_hit`),`model`/`provider`/`prompt_tokens` 全部回放。新字段与它们同类(溯源 + 用量),按同一规则处理 |
|
||||
| B2 命中时置 None | 覆写为 None | NULL | 语义上「本次未打供应商,无供应商侧事实」也成立,但与同层的 `prompt_tokens` 回放行为不一致,下游要记两套规则 |
|
||||
| B3 混合 | `model_reported` 回放、`cached_prompt_tokens` 置 None | 同左 | 最难解释,否决 |
|
||||
|
||||
**选 B1**,并写入文档一条度量口径约束(与 `cost` 缺口口径同款教训,ARCHITECTURE §5.1):
|
||||
|
||||
> 统计供应商缓存命中率必须写 `WHERE cache_hit = false`——缓存命中行的 `cached_prompt_tokens` 是历史回放值,计入会重复计数。
|
||||
|
||||
`cost` 不受影响:遥测层 `cache_hit=True` 分支仍短路为 `0.0`,早于任何单价换算。
|
||||
|
||||
## 4. 决策 C:缓存读取单价(人类已选「增加可选档」)
|
||||
|
||||
| 方案 | 做法 | 权衡 |
|
||||
|---|---|---|
|
||||
| C1 ModelPrice 可选第三档(**推荐**) | `cached_input_per_1m: float \| None = None`;`cost()` 增可选参 `cached_prompt_tokens: int \| None = None` | 价格表旧文件零改动仍可加载;`embedding.py:419` 的三参调用零改动 |
|
||||
| C2 cost() 收 LLMResponse | 换算函数直接吃响应对象 | `pricing.py` 会反向依赖 `types.py` 且难以单测纯函数,否决 |
|
||||
|
||||
换算规则与退化路径:
|
||||
|
||||
| 条件 | 计价方式 |
|
||||
|---|---|
|
||||
| 配了 `cached_input_per_1m` 且本次 `cached_prompt_tokens` 为正 | `(prompt - cached) × input + cached × cached_input` |
|
||||
| 未配该档,或本次 `cached_prompt_tokens` 为 None/0 | 全额按 `input` 计(现状行为,不变) |
|
||||
| `cached > prompt`(网关口径异常) | 按 `cached = prompt` 夹取并记一次 warning;不抛异常、不产生负成本 |
|
||||
|
||||
**不猜折扣率**:未配置缓存档时绝不按「五分之一」之类经验值折算(P5 严禁默认值掩盖)。`from_file` 的 fail-loud 校验对新档同样适用:出现该键但非数或为负 → `ValueError`。
|
||||
|
||||
## 5. 决策 D:遥测表扩列的落地方式
|
||||
|
||||
人类确认「现在不存在必须保留的生产库」。但两个后端的 DDL 都是 `CREATE TABLE IF NOT EXISTS`,**已存在的开发库/下游库不会自动获得新列**,INSERT 会失败。两侧的失败形态都是**逐行 warning 丢弃**(SQLite `sqlite.py:93`;PG `postgres.py:127`——`_failed` 结构性标志只在 `_ensure_ready` 建池/建表失败时置位,与写入路径无关),即每一次调用的遥测行都丢,却不会有任何一次硬失败提示,与「遥测必录」相悖。
|
||||
|
||||
| 方案 | 做法 | 权衡 |
|
||||
|---|---|---|
|
||||
| D1 初始化期幂等补列(**推荐**) | DDL 加新列;初始化时按需 `ALTER TABLE ADD COLUMN`——PG 用原生 `IF NOT EXISTS`,SQLite 先查 `PRAGMA table_info` 再按需 ALTER | 旧库自动升列,新库无副作用;两处各约 5 行;补列失败沿用现有降级策略(warning,不冒泡) |
|
||||
| D2 只改 DDL,文档写「删表重建」 | 零代码 | 已建表的开发机/下游踩坑后只看到降级 warning,排查成本高;违反防御性 |
|
||||
| D3 引入迁移框架(alembic) | 正规版本化迁移 | 新增依赖,与「依赖极简」铁律冲突,规模严重不匹配,否决 |
|
||||
|
||||
**选 D1**。列类型:SQLite `cached_prompt_tokens INTEGER` / `model_reported TEXT`;PG `INTEGER` / `TEXT`。两列均可空(NULL = 该源未上报),不设 NOT NULL 与默认值——0 与 NULL 的区分正是本 issue 的核心诉求。
|
||||
|
||||
**D1 的实现纪律(必须钉进计划,否则补列会把降级放大成永久失能)**:
|
||||
|
||||
| 约束 | 原因 |
|
||||
|---|---|
|
||||
| SQLite 的 ALTER 必须用**独立 try**,且置于 `self._conn = conn` **之后** | `__init__` 现有 try 的最后一句才是 `self._conn = conn`(`sqlite.py:74-84`);ALTER 抛异常会让 `_conn` 停在 `None`,`record_llm_call` 首行即 return —— 整个 recorder 永久 no-op,比逐行丢弃严重得多 |
|
||||
| `duplicate column name` 视为成功吞掉 | `PRAGMA table_info` 探测 + ALTER 是 TOCTOU:多 worker 共用同一 db 文件时后到者必然撞上 |
|
||||
| 不得为补列加宽 `except` | `sqlite.py:93` 只捕 `(OSError, sqlite3.Error)`,取消是天然穿透的;PG 侧的 `except asyncio.CancelledError: raise` 必须留在最前 |
|
||||
| PG 用原生 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` | 无 TOCTOU;落在既有 `_init_lock` 保护的 `_ensure_ready` 内 |
|
||||
|
||||
端口 `TelemetryRecorder.record_llm_call` 由 18 字段扩为 20 字段(关键字参数),`ports.py:248` 的「18 字段冻结」注释与 ARCHITECTURE 相应表述同步更新。新增参数在 Protocol 上**不设默认值**——依据不是「漏改会报错」(本仓无 mypy,`make lint` 只有 ruff + import-linter,8 个测试 fake 全是 `**fields`,漏改根本不会自动红),而是**库外不存在第三方实现者**:三项目迁移文档明确删除各自的 TelemetryRecorder Protocol 与实现(`migrations/govdoc-saas.md:36`、`video-tree-trm5.md:36/51`),端口的唯一实现者就是库内两个后端,完整签名的成本为零。漏改的兜底靠 §8 的键集合断言测试,不靠类型检查。
|
||||
|
||||
## 6. 行为审计(既有行为逐条标注)
|
||||
|
||||
| 既有行为 | 处置 |
|
||||
|---|---|
|
||||
| `LLMResponse` 前 11 字段顺序即公共承诺 | **保留**,新字段追加到尾部(`structured_data` 之后) |
|
||||
| 缓存序列化 `_serialize` 用 `asdict` 后 pop 掉 `structured_data`、`_rehydrate` 按 `_RESPONSE_FIELDS` 过滤 | **保留**。新字段自动进出;旧缓存条目缺这两键时,`LLMResponse(**fields)` 靠默认值构造成功(向后兼容已验证) |
|
||||
| `cache_hit` 语义 = PolyGateway 自身响应缓存 | **保留**,仅补 docstring 消歧 |
|
||||
| `TelemetryEmitter` 单一 `_record` helper(遥测必录铁律:禁止复制参数列表) | **保留**,新字段只在 `_record` 增两个参数,三个 `emit_*` 入口各传一次 |
|
||||
| 失败尝试 / 终态失败行记 `usage_source="unavailable"` | **保留**,两个新字段在这些路径记 `None` |
|
||||
| `pricing.cost()` 是唯一换算点(注释语)| **修正**:实际有 `TelemetryEmitter` 与 `embedding.py:419` 两个调用点,顺带订正该 docstring(限于一行注释,不做结构重构) |
|
||||
| OCR / embedding 各自构造 `LLMResponse` | **保留**,两字段取默认 `None`(该路径无供应商 cache 概念) |
|
||||
|
||||
## 7. 非功能维度
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 并发与取消 | 纯数据字段,无新增 await 点、无共享状态。PG 补列在既有 `_init_lock` 保护的 `_ensure_ready` 内,并发首调用不会重复 ALTER;SQLite 的 `__init__` **不持** `_lock`(它只保护 `_write`/`close`),跨进程共库靠上面 D1 纪律里的「duplicate column 视为成功」兜底。取消穿透不变:PG 两处 `except asyncio.CancelledError: raise` 保持在最前,SQLite 侧只捕 `(OSError, sqlite3.Error)` 故天然穿透 |
|
||||
| 降级方向 | 遥测属「静默降级」侧:补列失败 → warning 并沿用既有逐行丢弃,绝不冒泡到调用方,也绝不让 recorder 整体失能(见 D1 纪律)。解析失败 → 字段记 `None`,不影响响应返回 |
|
||||
| 幂等与重复 | 补列幂等(PG `IF NOT EXISTS`;SQLite 先探测)。写入幂等性不变(`INSERT OR IGNORE` / `ON CONFLICT DO NOTHING` 按 `call_id`) |
|
||||
| 持久化与原子性 | 单行 INSERT 原子性不变;新增两列不参与主键与冲突判定。缓存 JSON 是整值覆写,无部分写入 |
|
||||
| 向后兼容 | 下游三项目 + dissect:纯增字段带默认值,逐字段传参的 fake 构造零改动;旧价格表文件、旧缓存条目、旧遥测表均可继续工作 |
|
||||
|
||||
## 8. 错误处理与测试策略
|
||||
|
||||
错误分类:本变更**不新增任何错误路径**。网关报文里这两项缺失或类型异常 → 记 `None`,不归入四分类(它们不是失败,是「该源没给」)。价格表配置错误仍走装配期 `ValueError`(fail-loud,不属运行时四分类)。
|
||||
|
||||
| 层 | 测试(先失败后通过) |
|
||||
|---|---|
|
||||
| types(unit) | 新字段默认值为 `None`;字段顺序不变(前 11 位置构造仍成立) |
|
||||
| transports(unit) | 用真实网关响应二次构造样本:① 流式含 `prompt_tokens_details.cached_tokens` → 解析出正整数;② 非流式同上;③ 无该键 → `None`;④ 值为 `"abc"`/负数 → `None` 不抛;⑤ 流式 chunk 的 `model` 被 sink 采集;⑥ 顶层无 `model` → `None` |
|
||||
| retry(unit) | `_build_response` 透传两字段;失败尝试路径不受影响 |
|
||||
| cache(unit) | ① 新字段随序列化往返;② **旧格式**缓存条目(缺这两键)仍能 rehydrate;③ 命中回放值符合 B1 |
|
||||
| pricing(unit) | ① 配缓存档 + 命中 → 成本低于全额;② 未配该档 → 与现状逐位相等;③ `cached > prompt` → 夹取且不为负;④ 三参旧调用签名仍可用(embedding 调用形态);⑤ 价格表含负缓存单价 → `ValueError` |
|
||||
| telemetry(integration) | ① 20 字段写入 SQLite/PG 成功并可读回;② **旧表**(18 列)在初始化后自动补列并写入成功;③ 补列失败时降级为 warning 且 recorder 仍能工作(SQLite `_conn` 不得因此为 None) |
|
||||
| 契约(**新增,不可省**) | 断言 `TelemetryEmitter` 传给 recorder 的实参键集合 == 两个后端的 `_COLUMNS`。理由:`row = tuple(fields[col] for col in _COLUMNS)` 位于两个后端的 try **之外**(`sqlite.py:90` / `postgres.py:121`),emitter 漏传新字段会抛 `KeyError`,被 `_record` 的 `except Exception` 吞成 warning → **静默丢遥测**。这是本变更最危险的失败形态,而现有 8 个 `**fields` 形态的 fake 一个都拦不住 |
|
||||
|
||||
> integration 层的 Redis/PG 测试遵守既有纪律:共享后端严禁并跑,`conda run -n PolyGateway --no-capture-output`。
|
||||
|
||||
## 9. 影响面清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/polygateway/types.py` | `LLMResponse` +2 字段;`TransportResult` +2 字段;`cache_hit` docstring 消歧 |
|
||||
| `src/polygateway/transports/openai_compat.py` | sink 采集 `model`;两处 `TransportResult` 构造填新字段;新增防御解析 helper |
|
||||
| `src/polygateway/middleware/retry.py` | `_build_response` 透传 2 字段 |
|
||||
| `src/polygateway/middleware/telemetry.py` | `_record` + 三个 `emit_*` 各透传 2 字段;cost 换算传入 `cached_prompt_tokens` |
|
||||
| `src/polygateway/pricing.py` | `ModelPrice` +可选档;`cost()` +可选参;`from_file` 校验;订正唯一换算点注释 |
|
||||
| `src/polygateway/ports.py` | `TelemetryRecorder` 18 → 20 字段 |
|
||||
| `src/polygateway/telemetry/{sqlite,postgres}.py` | DDL +2 列;`_COLUMNS` +2;初始化期幂等补列 |
|
||||
| `tests/` | 四处天然拦截点必须同步(漏改即红): 两个 `_record_minimal` 手写 18 键 dict(`unit/test_telemetry.py:76` 起、`integration/test_postgres_telemetry.py:81-105`)与两个 `_EXPECTED_COLUMNS` 列序断言(`unit/test_telemetry.py:18-40`、`integration/test_postgres_telemetry.py:22-41`);`unit/test_ports.py:96` 的全签名 fake 同步(它**不会**红,Protocol 的 isinstance 不校验签名);新增契约测试 |
|
||||
| `research-wiki/ARCHITECTURE.md` | §5.1 字段表 + 遥测表定义 + 「18 字段冻结」表述 |
|
||||
| 「18 字段冻结」的其余措辞点 | `ports.py:248`、`pricing.py:6`(币种说明里引用了该数字)、`telemetry/sqlite.py:87`、`tests/unit/test_telemetry.py:1` |
|
||||
| Wiki 站 + `CHANGELOG.md` | 按 `docs-convention.md` §2 清单同步(公共行为变更,版本 bump 不得裸发) |
|
||||
| `.env.example:56` | 该行内联注释是仓内**唯一**的价格表格式说明(无独立模板文件,`config/prices.json` 是未入库的本地文件),补 `cached_input_per_1m` 可选档 |
|
||||
|
||||
## 10. 审批记录
|
||||
|
||||
2026-07-31 人类逐条确认: **A2**(TransportResult 强类型字段)、**B1**(缓存命中原样回放 + 度量口径带 `cache_hit = false`)、**C1**(ModelPrice 可选缓存单价档)、**D1**(DDL 加列 + 初始化期幂等补列)。设计获批,进入 `writing-plans`。
|
||||
|
||||
版本号按 `1.1.0` 推进(纯增字段不破坏下游,但触及端口签名与表结构,minor 位比 patch 位更能提示下游);发版前若人类另有指示以指示为准。
|
||||
@@ -0,0 +1,234 @@
|
||||
# 采样参数透传设计(issue #4)
|
||||
|
||||
- **日期**: 2026-07-31
|
||||
- **状态**: 待人类审批
|
||||
- **触发**: issue #4 —— `chat()` 无法设置 `temperature`/`seed`/`max_tokens`,下游受控实验无法固定解码
|
||||
- **影响面**: `chat()` 公共签名、`SourceConfig` 公共类型、缓存 key 公式(ARCH §7.5)、遥测端口(20 → 21 字段)
|
||||
|
||||
---
|
||||
|
||||
## 1. 诉求与现状审计
|
||||
|
||||
下游 dissect 是一组受控实验:解码固定 `temperature=0`,每格配置跑 5 个 seed 报标准差。标准差必须只反映被研究的变量,不能混进解码随机性。
|
||||
|
||||
代码事实(本会话核实):
|
||||
|
||||
| 事实 | 位置 | 后果 |
|
||||
|---|---|---|
|
||||
| 全库 `temperature` 零命中 | `grep -rn temperature src/` | 解码跑在供应商默认值上,不可复现 |
|
||||
| `chat()` 签名无 overlay 入口 | `client.py:143-153` | 调用方够不着 `ChatRequest.overlay` |
|
||||
| `overlay` 唯一写入点是结构化中间件 | `middleware/structured.py:98` | 字段存在但只服务库内 |
|
||||
| `payload.update(overlay)` 是最后一步 | `transports/openai_compat.py:297` | overlay 可覆盖 `model`/`messages`/`stream`/`stream_options` |
|
||||
| 缓存 key 公式不含 overlay | `middleware/cache.py:52-64` | **见 §2 决策 C** |
|
||||
| `model_fingerprint` 只由源 `model` 名算 | `client.py:117` | 配置级采样参数变更不改 key |
|
||||
| minimax / openai profile 均 `thinking_off={}` | `providers.py:49,56` | `enable_thinking=False` 对两源均无效果 |
|
||||
|
||||
**issue 未提及但必须一并处理的**: 缓存与遥测的交互。不处理的话,failure mode 恰是 issue 自己最担心的那种——数字悄悄不可比,且不报错。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
### 决策 A: 两层入口,合并优先级由现有层序天然给出
|
||||
|
||||
| 层 | 载体 | 用途 | 生效点 |
|
||||
|---|---|---|---|
|
||||
| 调用级 | `chat(..., overlay: Mapping[str, Any] \| None = None)` | 逐次变化(每 rollout 不同的 `seed`) | 填入 `ChatRequest` |
|
||||
| 配置级 | `SourceConfig.extra_body: Mapping[str, Any]` | 全局恒定(`temperature=0`) | transport `_build_payload` |
|
||||
|
||||
优先级 **结构化注入 > 调用级 > 配置级**,无需任何新机制:
|
||||
|
||||
```text
|
||||
_build_payload: payload{model,messages,stream} → thinking_profile
|
||||
→ source.extra_body ← 配置级(新增一行)
|
||||
→ overlay ← 调用级 ⊎ 结构化注入
|
||||
StructuredMW: {**request.overlay, **strategy_overlay} ← 结构化已在最右,天然最高
|
||||
```
|
||||
|
||||
配置级放在 transport 而非装配层合并,是因为 `extra_body` 是 per-source 的,选源在 RetryMW 之后才确定;放 transport 无需改动任何端口签名。
|
||||
|
||||
**`ChatRequest` 增第二个字段 `sampling: Mapping[str, Any] = field(default_factory=dict)`**(调用方原始采样意图的快照,库内中间件**永不修改**),与 `overlay`(请求体覆盖层,会被结构化注入)分开。`chat()` 同时填两者。理由是 `overlay` 在洋葱不同深度取值不同——`StructuredMW` 内侧含 `response_format`、外侧不含——缓存 key 与遥测若各自依赖"在哪一层读"就会口径分叉(见决策 C/D)。`sampling` 提供一个跨层恒定的读取点。
|
||||
|
||||
类型定死为 `Mapping` 而非 `dict[str, Any] | None`:空 dict 与 `None` 在此无语义差别(都是"没传采样参数"),多一种表示只会让 key 公式与 `merge()` 签名各选各的。因此决策 C 的 key 公式一律按**仅非空**参与(注意与同处的 `salt` 不同——`salt` 是"仅非 None",空串是有意义的 salt)。
|
||||
|
||||
### 决策 B: 保护键黑名单,构造期显式报错
|
||||
|
||||
`{model, messages, stream, stream_options}` 禁止出现在 overlay/extra_body 中。理由逐条:
|
||||
|
||||
| 键 | 被覆盖的后果 |
|
||||
|---|---|
|
||||
| `model` | 遥测记录的 model 与实际请求分叉 → 成本按错单价算 |
|
||||
| `messages` | 缓存 key 与遥测口径同时失真 |
|
||||
| `stream` | 绕过流式看门狗(TTFT/inter-token 三层超时全失效) |
|
||||
| `stream_options` | 丢 usage 帧 → 成本遥测归零、TPM 闸按预扣量结算失准 |
|
||||
|
||||
同一校验函数还必须验**值可 JSON 序列化**。理由:`CacheMW.__call__` 第 95 行的 `build_cache_key` 内部 `json.dumps`,**不在 `_safe_get`/`_safe_set` 的降级 try 内**;`TelemetryMW` 只捕 `GatewayUnavailableError`/`GovernanceBackendError`/`CancelledError`。调用方传 `{"temperature": np.float32(0)}`(温度扫描用 numpy 生成极自然)会抛裸 `TypeError`:不属四分类、一行遥测都没有、RetryMW 从未执行。构造期一次校验即可保住"overlay 错误全部发生在进洋葱之前"这条不变式。
|
||||
|
||||
校验函数落在 `types.py`(最内层,无依赖),两个入口各调一次:`chat()` 参数在进洋葱**之前**校验(与既有 `structured` 的 ImportError 同款先例),`SourceConfig.__post_init__` 在装配期校验(符合 §4.5「缺失/非法关键配置直接报错」)。抛裸 `ValueError`——这是调用方编程错误,不属 §6 四分类,不应被 RetryMW 当作可重试失败。
|
||||
|
||||
transport 不重复校验:三个 overlay 来源(chat 参数、SourceConfig 字段、库内策略)已全部在构造期收口,库内策略只注入 `response_format`(`json_repair.py:41` 恒空,`native_schema.py:21-29` 只产该键)。
|
||||
|
||||
### 决策 C: 调用级 overlay 进缓存 key —— 本设计的关键点
|
||||
|
||||
不做的话:同 messages 跑 5 个 seed,后 4 次命中第一次的缓存,返回同一 response,**标准差恒为 0**,实验静默作废。这正是「无缓存毒化」铁律的场景。
|
||||
|
||||
key 公式扩展(ARCH §7.5 需同步修订),读 `request.sampling` 而非 `request.overlay`——语义明确、不依赖"CacheMW 恰在 StructuredMW 外侧"这一层序巧合:
|
||||
|
||||
```text
|
||||
key_obj = {model, messages_digest, namespace, [salt], [sampling]}
|
||||
仅非 None 仅非空
|
||||
```
|
||||
|
||||
沿用 `salt` 的「仅非空时参与」写法,保证**空采样参数时旧键逐字不变**,不触发存量缓存全量冷启动。
|
||||
|
||||
配置级同理:`model_fingerprint` 从 `",".join(sorted(models))` 扩展为——所有源 `extra_body` 皆空时字面不变;否则追加 `"|" + sha256(...)`,摘要对象是「每个源的 `(model, extra_body)` 先各自 canonical-JSON 化成字符串,再排序去重」(dict 本身既不可排序也不可哈希,必须先序列化;`extra_body` 若存为 `MappingProxyType` 需 `dict(...)` 后再 `json.dumps`)。取 `(model, extra_body)` 而非 `(name, ...)`,语义是「本 scope 会用哪些(模型,解码参数)组合」,改源名不会误触冷启动。该计算在 `client.py:117` 且不在任何降级 try 内,写错即装配期崩——实施时须有直接单测。
|
||||
|
||||
**两条已知副作用(须写进 wiki)**:
|
||||
|
||||
1. 逐 rollout 变化的 `seed` 进 key 后,该路径**天然全部 miss**。这是正确语义而非缺陷,但下游要知道缓存对这条路径不再省钱。
|
||||
2. `model_fingerprint` 是**集合级**指纹,不是本次实际选中源的指纹。同 scope 下各源 `extra_body` 不同时,缓存仍可能返回另一源、另一组解码参数下产生的响应。这是既有取舍的延续(`cache.py:68-72` 对 `model` 已如此),不是本设计引入的新缺口,但"配置级采样参数进 key"容易被读成更强的保证,须写明边界。受控实验若要求逐源可复现,应让每个源独享 scope 或 namespace。
|
||||
|
||||
### 决策 D: 采样参数入遥测(端口 20 → 21 字段)
|
||||
|
||||
「实验可复现」的另一半是参数落库。不记的话,同 messages 不同输出在审计表里无法解释。与 issue #3 新增 `model_reported` 同类动机(供应商把别名指向新权重时,复现必须认真实串)。
|
||||
|
||||
**列语义定死**:`sampling: str | None` = 「调用方采样意图 ⊎ 生效源的 `extra_body`」的 canonical JSON,**不含库内结构化注入的 `response_format`**。两个理由:该列名叫采样参数,`response_format` 不是;schema 可达数 KB,逐行记会让审计表无谓膨胀。
|
||||
|
||||
`TelemetryEmitter` 有三个入口且都汇入同一个 `_record`(显式关键字参数,加列必须三处都传),必须逐个定死,否则同一列在不同行口径分叉——这正是 1.0.4 里 `cached_prompt_tokens` 不得不写"下游请读"警告的同类坑:
|
||||
|
||||
| 入口 | 调用者 | 有 `source`? | `sampling` 记什么 |
|
||||
|---|---|---|---|
|
||||
| `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` |
|
||||
| `emit_cache_hit` | TelemetryMW(最外) | **无** | 仅 `request.sampling` |
|
||||
| `emit_terminal_failure` | TelemetryMW | **无** | 仅 `request.sampling` |
|
||||
|
||||
后两行缺 `extra_body` 是**客观事实而非口径瑕疵**:它们没有"生效源"可言——与 `model`/`provider`/`source_name` 在终态行置空是同一先例。缓存命中行尤其无损:`sampling` 已进缓存 key,能命中就意味着历史那次的调用级采样参数与本次逐字相同;`extra_body` 亦已进 `model_fingerprint`,命中意味着源集合的配置指纹相同。
|
||||
|
||||
三个入口统一读 `request.sampling`(决策 A 的新字段)而非 `request.overlay`,是因为后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处则未被污染,直接用会让三行天然分叉。
|
||||
|
||||
**共用范围写清楚**:`types.py` 提供「2 参 dict 合并 + canonical 序列化」这一个原语,transport 与 emitter 共用它。**不追求统一到两者之上**——transport 是往更大的 payload 上依次 `update(thinking_profile) → update(extra_body) → update(overlay)`,emitter 算的是 `merge(extra_body, sampling)`,参与方与顺序本就不同,强行统一是错的。这不影响正确性:该列语义已定义为「调用方意图 ⊎ 生效源 `extra_body`」,而非 payload 的逐字回显。共用原语的目的只是让"合并语义与序列化口径"这一件事不出现两份实现。
|
||||
|
||||
两个后端按 issue #3 已建立的套路幂等补列:**先探测缺列再 ALTER**、失败只逐行降级不置结构性失能标志、新列排在 `created_at` 之后。
|
||||
|
||||
一次做完而非分两步:「能传参数但没记」的中间状态最危险——数据已产生且事后无法追溯,且分步要做两遍 DDL 迁移。
|
||||
|
||||
**OCR/Embedding 的 emit 调用点零改动**:`ocr.py:418` 与 `embedding.py:372` 也调 `emit_attempt` 且都传 `source`,只要 `sampling` 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两处调用不动一行——反之立刻 TypeError,实施时必须走推导路线。两个文件本身仍有改动,即决策 G 的构造期剥离(它正是让这里的推导对 OCR/embedding 恒得 NULL 的前提)。
|
||||
|
||||
### 决策 E: 入参拷贝语义与两条只读约束
|
||||
|
||||
`chat()` 对传入 overlay 做**一次** `dict(overlay)` 浅拷贝,同一份快照对象同时填 `overlay` 与 `sampling` 两个字段(不做两份独立拷贝——它们在进入 `StructuredMW` 之前本就应当逐字相同,两份拷贝反而给"两者可以分叉"留了口子)。
|
||||
|
||||
issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值是极可能的模式,不拷贝会出现「请求已发出、key 用了新 seed」的竞态。`ChatRequest` 虽 frozen 但 dict 是浅冻结,拦不住。`SourceConfig.extra_body` 在 `__post_init__` 转 `MappingProxyType` 同理(成本近零)。
|
||||
|
||||
拷贝之外的第二条约束:**任何中间件不得就地修改这两个 dict**,只能经 `dataclasses.replace` 派生新请求。现状已满足(`StructuredMW` 用 `{**a, **b}` 生成新 dict,`_build_payload` 只往 payload 上 `update`,全库无就地改写),本设计只是把它写成明文约束——决策 C 与 D 都建立在 `sampling` 跨层恒定之上,这条被破坏则两者同时失效(测试 #14 为此加机械执法)。
|
||||
|
||||
### 决策 F: 空 thinking profile 的诚实性缺口(issue 附带项)
|
||||
|
||||
`minimax` 与 `openai` 的 `thinking_on/thinking_off` 均为空字典。`providers.py:52` 那条「OpenAI 兼容基线,无已知注入差异」的注释在词法上属于紧随其后的 **minimax** 条目,`openai` 条目没有任何注释。所以现状是:已有的注释解释了"为何为空",但两个 provider 都没点明**后果**——`enable_thinking=False` 对它们不产生任何效果,调用方以为关掉了实际没关。
|
||||
|
||||
补的是这一句后果说明(覆盖两个 provider),不是重复已有的"为何为空"。不改行为:真需要关时经 `extra_body` 绕过。
|
||||
|
||||
### 决策 G: 非 chat 路径的 `extra_body` —— 剥离并 warning,不中断装配
|
||||
|
||||
`_SOURCE_FIELDS`(`config.py:33-47`)是**跨 scope 共用**的一张表,加了 `EXTRA_BODY` 之后 `OCR__MONKEY__1__EXTRA_BODY` / `EMBED__QWEN__1__EXTRA_BODY` 会被合法接受、进 `SourceConfig`、进遥测 `sampling` 列,但两条路径都不消费它:`monkey_ocr.py:225,247` 只发 multipart `files=`(**根本没有 JSON body**),`OpenAICompatTransport.embed`(`openai_compat.py:343`)payload 硬编码 `{"model", "input"}`。放任即**静默无效**,正是 §4.5 要禁的形态。
|
||||
|
||||
**处置(2026-07-31 人类拍板改此档)**:`EmbeddingClient` / `OcrClient` 构造期发现源带非空 `extra_body` → 记 warning 并 `dataclasses.replace(source, extra_body={})` **剥离后放行**,不抛异常。
|
||||
|
||||
剥离是这一档的**必要组成部分,不是顺手清理**。`ocr.py:390` 与 `embedding.py:350` 构造 `ChatRequest` 时不带 `sampling`,但传给 `emit_attempt` 的 `source` 是真实配置对象;若不剥离,决策 D 的 `merge(source.extra_body, request.sampling)` 会让遥测**记录一个从未发出的参数**——审计表显示该次 OCR 调用带了 `temperature=0`,实际请求体里没有。那不是"参数不生效",是遥测造假,污染的恰是事后复现的唯一依据。替代方案是在 emitter 里特判调用方身份,直接违背「遥测调用点收敛为单一 helper」铁律,否决。
|
||||
|
||||
剥离后该列在 OCR/embedding 行恒为 NULL,语义干净,emitter 零特判。
|
||||
|
||||
**被否决的原方案**: 装配期 `ValueError` 直接拒绝。理由是这两条路径本无采样语义,配错的后果远轻于 chat 路径,不值得让下游整个装配起不来。**残余风险须写进 wiki**: loguru warning 在生产中容易被淹没,运维可能仍以为参数生效——这是"不中断装配"换来的代价,故 warning 文案必须**指路**:`dimensions` 是 OpenAI embeddings 的正式参数,下游想调向量维度时会第一个撞上,文案应写明"embedding 路径暂不支持 `extra_body`,该配置已被忽略;需要 `dimensions` 等参数请提 issue"。
|
||||
|
||||
不顺手给 embed 加透传:embedding 没有采样一说,issue 也未提出诉求(YAGNI);真有需求时单独设计。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键岔路与否决记录
|
||||
|
||||
| 岔路 | 否决方 | 理由 |
|
||||
|---|---|---|
|
||||
| `chat()` 展开为 `temperature=`/`seed=`/`max_tokens=` 具名参数 | 否决 | 供应商私有参数无穷尽(`top_k`/`repetition_penalty`/`thinking_budget`),具名等于永久追加签名;且违背「深模块窄接口」(ARCH §132) |
|
||||
| 配置级放装配层全局字典而非 `SourceConfig` | 否决 | 采样参数与源强相关(不同供应商键名不同),全局字典会把无效键发给不认识它的源 |
|
||||
| overlay 不进缓存 key,靠调用方传 `cache_salt` 区分 | 否决 | 把毒化防护的责任推给调用方,漏传不报错——正是 issue 抱怨的失败形态 |
|
||||
| 采样参数不入遥测,由下游 run 快照自记 | 否决 | 见决策 D |
|
||||
| 缓存 key 与遥测都直接读 `request.overlay`,不加 `sampling` 字段 | 否决 | `overlay` 在洋葱不同深度取值不同(结构化注入),三个 emit 入口与 CacheMW 会各记各的,同一列口径分叉 |
|
||||
| `sampling` 列记「实际发出的完整合并结果」(含 `response_format`) | 否决 | 该列名为采样参数,schema 不是;且数 KB schema 逐行落库无谓膨胀 |
|
||||
| 给 embedding 路径也加 `extra_body` 透传 | 否决 | embedding 无采样一说,issue 未提诉求(决策 G) |
|
||||
| 非 chat 路径带 `extra_body` 时装配期 `ValueError` | 否决(人类拍板) | 这两条路径无采样语义,配错后果远轻于 chat,不值得让下游装配起不来;改为剥离 + warning |
|
||||
| 允许放行但**不剥离** `extra_body` | 否决 | 遥测会记录一个从未发出的参数(决策 D 的 merge 读 `source.extra_body`),是数据造假而非参数失效 |
|
||||
| 放行不剥离,改在 emitter 内特判 OCR/embedding 不记 | 否决 | emitter 是「遥测调用点收敛单一 helper」的产物,让它识别调用方身份是开倒车 |
|
||||
| transport 层再兜一次保护键校验 | 否决 | 三个入口已构造期收口,重复校验属 gold-plating |
|
||||
|
||||
---
|
||||
|
||||
## 4. 非功能维度
|
||||
|
||||
| 维度 | 回答 |
|
||||
|---|---|
|
||||
| **并发** | 无新增共享状态。`extra_body` 装配后只读(MappingProxyType);调用级 overlay 每调用独立拷贝,并发调用互不可见 |
|
||||
| **取消** | 无新增 await 点与等待循环,`CancelledError` 穿透路径完全不变 |
|
||||
| **降级方向** | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向。决策 G 的剥离 + warning 是**配置面**降级(装配期一次性、可复现、部署即暴露),与铁律里"限流/熔断后端不可用须报错"的**运行时**降级方向是两回事,不冲突 |
|
||||
| **幂等与重复** | 保护键校验是纯函数,重复调用安全;遥测补列先探测后 ALTER,重启幂等 |
|
||||
| **持久化与原子性** | 遥测单行写入,无部分写入风险。缓存 value 结构不变(`sampling` 只进遥测不进 `LLMResponse`,避免动已被三项目消费的公共类型) |
|
||||
| **重试交互** | overlay 在 RetryMW 循环外确定,换源重试时同一 overlay 应用到新源的 `extra_body` 之上——语义正确(调用级意图跨源保持) |
|
||||
| **限流交互** | overlay 里的 `max_tokens` 不影响入场预扣(取 `effective_est_tokens()`)。调用方把 `max_tokens` 抬到远超预扣量时 TPM 入场保护会短暂失真,结算侧(`retry.py:338-343`)按实测用量回填自愈。已知且可接受,不为此加机制 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 错误处理与测试策略
|
||||
|
||||
**错误分类**: 保护键违规与 `EXTRA_BODY` JSON 解析失败均为裸 `ValueError`,发生在进入洋葱之前/装配期,不入四分类、不触发重试或熔断。运行时若供应商拒绝某个采样参数(如不支持 `seed`),网关返回 4xx,由既有 `RequestRejectedError` 路径处置——无需新增分类。
|
||||
|
||||
**测试清单**(每条须先失败后通过):
|
||||
|
||||
| # | 用例 | 层 |
|
||||
|---|---|---|
|
||||
| 1 | 同 messages 不同 `seed` → 两次 miss、两个不同 key(issue 场景直接回归) | unit |
|
||||
| 2 | 空 overlay 时 key 与旧实现逐字相同(防存量冷启动) | unit |
|
||||
| 3 | 全源 `extra_body` 为空时 fingerprint 与旧实现逐字相同 | unit |
|
||||
| 4 | 保护键:`chat(overlay={"stream": False})`、`SourceConfig(extra_body={"model": "x"})` 均 `ValueError` | unit |
|
||||
| 5 | 优先级:配置 `temperature=0` + 调用级 `temperature=1` → payload 为 1;结构化 `response_format` 覆盖调用级同名键 | unit |
|
||||
| 6 | 调用方在 `chat()` 返回前修改自己的 dict,不影响已发请求与已算 key(拷贝语义) | unit |
|
||||
| 7 | env 解析:`EXTRA_BODY` 合法 JSON 对象 → dict;非法 JSON / 非对象 → `ValueError` | unit |
|
||||
| 8 | 不可 JSON 序列化的值(如 `np.float32`)在 `chat()` 入口即 `ValueError`,不进洋葱 | unit |
|
||||
| 9 | 三个 emit 入口的 `sampling` 口径:attempt 含 `extra_body`、cache_hit 与 terminal 只含调用级、结构化注入的 `response_format` **三行都不出现** | unit |
|
||||
| 10 | `EmbeddingClient`/`OcrClient` 装配时源带 `extra_body` → 记 warning、装配成功、源上 `extra_body` 已被剥空,且该路径遥测 `sampling` 为 NULL(决策 G;后半段是防遥测造假的真正断言) | unit |
|
||||
| 11 | `_EXPECTED_COLUMNS` 断言更新后仍逐字匹配实际列序(见 §6,两处会直接红) | unit + integration |
|
||||
| 12 | 遥测 `sampling` 落库正确;两后端对既有旧表幂等补列 | integration |
|
||||
| 13 | 采样参数经全链路(chat → 选源 → transport payload)到达请求体 | integration |
|
||||
| 14 | **地基不变式**:走结构化重问阶梯(至少重问一次)后,RetryMW 每次尝试看到的 `request.sampling` 与 `chat()` 传入值逐字相同,且同一时刻 `request.overlay` 含 `response_format` | unit |
|
||||
|
||||
第 14 条是决策 C/D 共同的承重前提。它现在只靠"`dataclasses.replace` 恰好保留未提及字段"这一约定成立,无任何机械执法;缺这条测试则决策 E 的只读约束被破坏时不会有人发现。
|
||||
|
||||
第 2 条(空采样参数时旧键逐字不变)需自行先固化旧 key 值再比对——现有 `tests/unit/test_cache.py:39-54` 只有相等/不等与前缀断言,没有 golden hash 可依。
|
||||
|
||||
---
|
||||
|
||||
## 6. 配置与文档同步
|
||||
|
||||
env 键名沿用既有约定:`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,值为 JSON 对象串;`_SOURCE_FIELDS` 增一项、`_cast` 增 `json` 分支(解析失败与非 dict 均报错)。
|
||||
|
||||
同步清单(docs-convention §2):
|
||||
|
||||
| 目标 | 改什么 |
|
||||
|---|---|
|
||||
| ARCH §5.2 | `chat()` 签名定稿段追加 `overlay` 要点 |
|
||||
| ARCH §7.5 | key 公式补 `sampling` 项 + 两条已知副作用 |
|
||||
| ARCH §7.7 | 该节逐字段枚举 `SourceConfig` 构成(`ARCHITECTURE.md:452`),补 `extra_body` |
|
||||
| ARCH §7.8 | 必录字段 20 → 21 |
|
||||
| ARCH §9 | 配置面键族事实源(`:519-527`),登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY` |
|
||||
| `.env.example` | `client.py:247` docstring 声明它是键名清单的事实源,新键不写进去等于无处可查 |
|
||||
| `README.md:83` | 该行逐一列举 `chat()` 关键字参数,补 `overlay` |
|
||||
| wiki how-to | 增「固定解码参数」条目,写明 seed 进 key 导致缓存必 miss、以及 OCR/embedding 路径的 `extra_body` 会被忽略(仅 warning) |
|
||||
| CHANGELOG | 公共 API 新增 + 遥测端口扩列 |
|
||||
|
||||
## 7. 实施范围
|
||||
|
||||
`types.py`(保护键与 JSON 可序列化校验、合并纯函数、`ChatRequest.sampling`、`SourceConfig.extra_body`)、`client.py`(`chat()` 参数 + fingerprint)、`middleware/cache.py`(key 公式)、`transports/openai_compat.py`(`_build_payload` 一行)、`config.py`(env 解析)、`ports.py` + `middleware/telemetry.py` + `telemetry/{sqlite,postgres}.py`(第 21 字段与补列)、`ocr.py` + `embedding.py`(仅决策 G 的构造期剥离 + warning)、`providers.py`(注释)。
|
||||
|
||||
**测试侧必改**(否则直接红):`tests/unit/test_telemetry.py:18,113` 与 `tests/integration/test_postgres_telemetry.py:22,210,231` 的 `_EXPECTED_COLUMNS` 断言完整列表与列序。
|
||||
|
||||
无需改动:import-linter 契约(校验函数落最内层 `types.py`,分层关系不变)。
|
||||
|
||||
不做:给 embedding/OCR 加采样参数透传(决策 G)、任何任务外重构。
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:2026-08-02-thinking-capability-design
|
||||
title: "推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)"
|
||||
date: 2026-08-02
|
||||
---
|
||||
|
||||
# 推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)
|
||||
|
||||
> 类型:design|日期:2026-08-02|状态:待人类确认
|
||||
> 事实基础见 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`(本文所有实测引用均出自该文)。
|
||||
> 本设计经 2026-08-02 充分讨论后直接给出单一方案,不列备选。
|
||||
|
||||
## 1. 问题
|
||||
|
||||
**issue #5——静默失效。** `SourceConfig.enable_thinking` 是给上层的统一推理开关,靠 `providers.py` 的 `ProviderProfile.thinking_on/thinking_off` 落地。`minimax` 与 `openai` 两格皆为空 dict,`_build_payload` 的 `payload.update({})` 是空操作:`enable_thinking=False` 对这两类源**完全不产生效果**,而配置方以为关掉了。
|
||||
|
||||
这不是理论缺陷。`dissect/.env:84,99` 两个 scope 均写 `ENABLE_THINKING=false`,并在 `:67-70` 记为明确阻塞项——Phase-0 要求关闭思维链以隔离变量。
|
||||
|
||||
**issue #6——归因缺口。** `usage.completion_tokens_details.reasoning_tokens` 未被采集。成本总额正确(推理 token 已含在 `completion_tokens` 内),但"本次调用有多少钱花在推理上"无法区分,而这正是 dissect 要测的因子的主要成本通道。
|
||||
|
||||
**两者的耦合。** #6 是 #5 的验收仪器:修完 #5 后判断"这次是否真的没推理",靠正文长度不可靠,靠 `reasoning_content` 也不行(MiniMax 非流式恒为空、正文无 `<think>` 标签)。因此 **#6 先落地,#5 的测试断言它**。
|
||||
|
||||
## 2. 根因
|
||||
|
||||
空 dict 同时承载了两种语义:「本 provider 无需注入任何参数」与「我们不知道本 provider 怎么表达」。二者混同,就只能靠"表里没有 = 不发"兜底,静默失效随之产生。
|
||||
|
||||
更深一层:`ProviderProfile` 的注册单位是 **provider**,而"能否关闭推理"是 **model** 的属性。实测证明同一 provider 内部代际差异是决定性的——MiniMax-M3 可关,M2.7 / M2.5 **固有不可关**(三种参数形态实测全部无效,OpenRouter 与 models.dev 独立登记为 mandatory)。provider 级的表在物理上表达不了这件事。
|
||||
|
||||
业界佐证:注册单位下沉到 model 级的(LiteLLM、models.dev、LangChain、OpenRouter、Helicone)都有显式失败通道;仍停在 provider 级的(Portkey、LlamaIndex)恰是失败语义最差的两家,均静默丢弃。**注册粒度与失败语义是同一个问题的两面。**
|
||||
|
||||
## 3. 决策摘要
|
||||
|
||||
| # | 决策 |
|
||||
|---|---|
|
||||
| D1 | **形态留 provider 级,能力下沉 model 级**。形态 = 参数长什么样(数年不变);能力 = 能否关闭(每代都变) |
|
||||
| D2 | **「未知 / 不支持 / 不干预」必须是三个不同的值**,落在三个不同层次 |
|
||||
| D3 | **遇到"关不掉"的模型报错,不静默放行**;报错在装配期,请求期兜底 |
|
||||
| D4 | **「开」的默认档定 `medium`,允许 per-source 覆盖**(经已有 `extra_body`,不新增字段) |
|
||||
| D5 | `enable_thinking` **纳入缓存指纹**(配套,必做) |
|
||||
| D6 | `reasoning_tokens` 的文档措辞为「**本次调用**未上报」,非「该源未上报」(配套,必做) |
|
||||
|
||||
D4 的依据:业界对「开」映射到哪一档**无语义共识**(LiteLLM 用 2 的幂、OpenRouter 用百分比、Helicone 一律折半),唯一的工程共识是**该映射必须是可覆盖的常量**。选 `medium` 是因为 qwen 的 `enable_thinking:true` 与 deepseek 的 `thinking:{enabled}` 都不指定预算、由模型自定,`medium` 是五档中语义最接近"厂商正常强度"的一档;选 `high` 等于库替所有下游做"加钱换质量"的业务判断,违反零业务假设。
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 形态层(provider 级)
|
||||
|
||||
`ProviderProfile` 两档由 `dict` 放宽为 `dict | None`:
|
||||
|
||||
| 值 | 含义 | 当前实例 |
|
||||
|---|---|---|
|
||||
| `{...}` | 已知的注入片段 | qwen / deepseek / minimax |
|
||||
| `{}` | 已知**无需注入**即处于该档 | 无(保留为自然零值) |
|
||||
| `None` | **未知**:库不知道该 provider 如何表达 | `openai` 两档 |
|
||||
|
||||
```python
|
||||
"minimax": ProviderProfile(
|
||||
name="minimax",
|
||||
thinking_on={"reasoning_effort": "medium"},
|
||||
thinking_off={"reasoning_effort": "none"},
|
||||
strip_think_tags=False,
|
||||
),
|
||||
"openai": ProviderProfile(
|
||||
name="openai", thinking_on=None, thinking_off=None, strip_think_tags=False,
|
||||
),
|
||||
```
|
||||
|
||||
`openai` 填 `None` 而非补 `reasoning_effort`,理由是该段名在实践中已被复用为**任意 OpenAI 兼容厂商的兜底**(`dissect/.env:116` 把 `kimi-k3` 挂在 `provider=openai` 下)。向未知厂商下发 `reasoning_effort` 会招致 400;标为未知则让误配在装配期显式暴露。真·OpenAI 推理模型的使用者走 `register_provider`——这正是 D11 承诺的"新 provider = 一个条目"。
|
||||
|
||||
qwen / deepseek 两条实测正确,**不动**。
|
||||
|
||||
### 4.2 能力层(model 级,新增)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingCapability:
|
||||
"""某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
|
||||
can_disable: bool
|
||||
evidence: str
|
||||
```
|
||||
|
||||
登记表键为模型名精确匹配,**只登记在用的模型**,未登记即"未知"并走退化路径:
|
||||
|
||||
| 模型 | `can_disable` | 证据 |
|
||||
|---|---|---|
|
||||
| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10,`reasoning_effort=none` 稳定关闭 |
|
||||
| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter `mandatory:true` |
|
||||
| `MiniMax-M2.5` | `False` | 同上 |
|
||||
| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 |
|
||||
| `deepseek-v4-pro` | `True` | 实测 `thinking:{disabled}` 关闭 |
|
||||
|
||||
注入方式沿用 D11 的纯函数注册纪律:`get_capability(model, *, table=None)` 与 `register_capability(...)` 返回新表,经 `capabilities` 参数注入,与现有 `registry` 参数同形,**不引入模块级可变状态**。
|
||||
|
||||
**不引入 models.dev / LiteLLM 的 JSON 作为运行时依赖**——违反依赖极简与纯 asyncio 中立(import 期发网络请求)。二者仅作为写表时的对照参考;本次三条 MiniMax 实测与它们的登记 100% 吻合,这本身就是表可信的旁证。
|
||||
|
||||
### 4.3 三个值的层次归属(D2)
|
||||
|
||||
| 语义 | 载体 | 层次 |
|
||||
|---|---|---|
|
||||
| **不干预**(调用方不表态) | `SourceConfig.enable_thinking is None` | 调用方意图 |
|
||||
| **未知**(库不知道怎么表达) | `ProviderProfile` 该档为 `None` | 形态层 |
|
||||
| **不支持**(模型做不到) | `ThinkingCapability.can_disable is False` | 能力层 |
|
||||
|
||||
三者不可互相替代:不干预是意图缺失,未知是知识缺失,不支持是能力缺失。当前实现把后两者塌缩成空 dict,是 issue #5 的根因。
|
||||
|
||||
## 5. 判定与失败语义(D3)
|
||||
|
||||
单一判定函数收口,形态层与能力层在此相遇:
|
||||
|
||||
```python
|
||||
def resolve_thinking(profile, capability, enable_thinking) -> Mapping[str, Any]:
|
||||
"""三态 + 两层能力 → 注入片段;不可满足时 ValueError(由调用点翻译为领域错误)。"""
|
||||
```
|
||||
|
||||
真值表:
|
||||
|
||||
| # | 条件 | 行为 |
|
||||
|---|---|---|
|
||||
| R1 | `enable_thinking is None` | 不注入。与 `False` 严格区分 |
|
||||
| R2 | 形态层该档为 `None` | **报错**,文案指路 `register_provider` 或 `extra_body` |
|
||||
| R3 | `enable_thinking is False` 且 `can_disable is False` | **报错**:调用方要的是"不推理"的语义保证,给不了必须说 |
|
||||
| R4 | 模型未登记(能力未知) | 按形态层注入 + `loguru.warning`,不阻断 |
|
||||
| R5 | 其余 | 按形态层注入 |
|
||||
|
||||
R3 与 R4 的极性相反,这是刻意的,借鉴 LiteLLM 的两极性纪律:**"关不掉"用错的后果是下游带着错误前提做实验(opt-in,从严);"未登记"多为新模型上线(opt-out,从宽)**,误拒会让库成为升级路上的绊脚石。
|
||||
|
||||
### 5.1 报错位置:两处,共用同一份判定
|
||||
|
||||
| 位置 | 异常 | 覆盖 |
|
||||
|---|---|---|
|
||||
| `client.py:from_settings`(`:248` 已在此解析 profiles) | `ValueError`(装配期) | `from_env` / `from_settings` 两条工厂路径,即 90% 场景 |
|
||||
| `OpenAICompatTransport` | `RequestRejectedError`(四分类之一,不重试不换源) | 构造函数全量注入路径 |
|
||||
|
||||
这不是重复判定:`get_provider` 现在就是同一形态(`client.py:248` + `openai_compat.py:313`)。双点校验的必要性来自 issue #1 的教训——**装配守卫必须任何构造路径都生效**。
|
||||
|
||||
**绝不在 `_build_payload` 里抛裸 `ValueError`**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`。
|
||||
|
||||
## 6. reasoning_tokens 采集(issue #6)
|
||||
|
||||
照搬 issue #3 的 `_coerce_cached_tokens` 形态:只收非负整数,显式排除 `bool`(`isinstance(True, int)` 为真,放行会把 `True` 记成 1)。
|
||||
|
||||
`LLMResponse` / `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`——字段顺序是公共承诺(`types.py:1-5`),只增不删不改名。
|
||||
|
||||
流式与非流式对称取值:`completion_tokens_details` 在最后的 usage 帧里,`missing_done="salvage"` 打捞路径拿不到时记 `None` 而非 `0`(现有代码天然满足:`sink` 无 usage 时 `_coerce_*` 返回 `None`)。
|
||||
|
||||
**`pricing.py` 一行不改**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。这是归因缺口,不是计费缺口。
|
||||
|
||||
**缓存路径无需改动**:`CacheMW._rehydrate` 按 `_RESPONSE_FIELDS` 动态过滤(`cache.py:28,133`),旧条目缺该字段自动落 `None`,语义正确。
|
||||
|
||||
### 6.1 语义澄清(D6)
|
||||
|
||||
实测三家在未推理时都是**整个 `completion_tokens_details` 对象缺失**,无一上报 `0`。且 new-api 在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage,把 ctd 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。因此:
|
||||
|
||||
- docstring 写「**本次调用**未上报」,**不可**写「该源未上报」
|
||||
- 下游判据必须是 `reasoning_tokens in (None, 0)`,写 `== 0` 的条件永远不成立
|
||||
- 这三句要同时进 docstring、CHANGELOG 与 wiki
|
||||
|
||||
## 7. 缓存指纹配套(D5)
|
||||
|
||||
`build_model_fingerprint`(`client.py:63-80`)当前只摘要 `(model, extra_body)`。#5 一旦让 thinking 真正改变请求体,就会出现"关掉推理后重启读到开着推理时的旧缓存"——issue #4 为 `temperature` 写过逐字相同的理由。
|
||||
|
||||
做法:marks 的判据由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`,摘要对象并入该值。**全源不配 `enable_thinking` 时字面量与现值逐字相同,不触发存量缓存冷启动**;dissect 会有一次性冷启动,这是正确行为(旧缓存来自推理开着的调用)。
|
||||
|
||||
## 8. 落点清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `providers.py` | 两档放宽为 `dict \| None`;填 minimax、`openai` 改 `None`;新增 `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability` / `resolve_thinking` |
|
||||
| `transports/openai_compat.py` | `_build_payload` 两分支收敛为一行 `resolve_thinking(...)`;新增 `_coerce_reasoning_tokens`;流式 `:401` 与非流式 `:485` 填值;构造函数收 `capabilities` |
|
||||
| `client.py` | `from_settings` / `from_env` 加 `capabilities`;`:248` 后加装配守卫;`build_model_fingerprint` 纳入 `enable_thinking` |
|
||||
| `types.py` | `LLMResponse` / `TransportResult` 尾部加 `reasoning_tokens` |
|
||||
| `middleware/retry.py` | `_build_response` 透传 |
|
||||
| `ports.py` | `record_llm_call` 21 → 22 字段 |
|
||||
| `telemetry/{sqlite,postgres}.py` | 建表列 + `_BACKFILL_COLUMNS` 迁移 + `_COLUMNS`,**新列排末尾**(两处注释均有明文要求) |
|
||||
| `middleware/telemetry.py` | `_record` + 三个 `emit_*` 入口 |
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
本次改动的正确性**与具体模型强相关**,mock 只能验证代码路径、无法验证"这个参数在这个模型上是否真的关掉了推理"。因此核心行为**必须由真实 API 多轮调用验证**。
|
||||
|
||||
### 9.1 三层分工
|
||||
|
||||
| 层 | 内容 | 是否门控合并 |
|
||||
|---|---|---|
|
||||
| unit | `resolve_thinking` 真值表(R1–R5)、`_coerce_reasoning_tokens` 形态防御、注入优先级、装配守卫报错、缓存指纹变化与不变性 | **是**(CI 可跑) |
|
||||
| integration | 遥测两后端新列写入与 ALTER 迁移 | **是** |
|
||||
| **e2e(真实 API)** | 见 9.2 | 打 `slow` 标记被默认排除;**合并前必须 `-m slow` 真跑并存档报告** |
|
||||
|
||||
不让本组阻断 CI 的理由是外部不可用会误伤:实测中 kimi 渠道在 429 后被中转下线并返回 404,另有一次 `network_error` 连续三次耗尽源导致 L2 假红。让外部波动阻断合并,会把测试变成噪声源。
|
||||
|
||||
**实现机制**:给本组打项目既有的 `slow` 标记。`pyproject.toml` 的 `addopts = "-m 'not slow'"` 默认排除它(该配置的注释原文:「慢速测试,CI 按需跑」),合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。实测效果:`make ci` 由 7 分钟降至 91 秒。
|
||||
|
||||
**一处必须澄清的事实**:`make test` 跑的是 `pytest tests/`,**包含 `tests/e2e/`**——只要 `.env` 有凭据,既有的轻量 e2e 冒烟就会真跑。所以「e2e 不进 CI」这句对本项目**并不成立**,只有打了 `slow` 的才被排除;本节初稿写成前者,是错的。「不自动门控」也不等于「可跳过」——沿用既有口径(`tests/e2e/test_smoke_gateway.py:22` 的 reason 写着「验收前必须真跑」)。
|
||||
|
||||
### 9.2 e2e 覆盖矩阵
|
||||
|
||||
沿用既有 e2e 约定:`dotenv_values(".env")` + `pytestmark = pytest.mark.skipif(not _HAS_SOURCE, ...)`,结构化报告输出至 `tests/outputs/e2e/`。
|
||||
|
||||
| # | 场景 | 源 | 轮数 | 判据 |
|
||||
|---|---|---|---|---|
|
||||
| L1 | `enable_thinking=False` | MiniMax-M3 | ≥10 | 每轮 `completion_tokens < 30` 且 `reasoning_tokens` 恒 `None` |
|
||||
| L2 | `enable_thinking=True` | MiniMax-M3 | ≥10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` |
|
||||
| L3 | `enable_thinking=None` | MiniMax-M3 | ≥10 | 不注入任何 thinking 参数(基线) |
|
||||
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | ≥5 | 实发 `high`,profile 的 `medium` 被覆盖 |
|
||||
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 ≥10 | 同 L1 / L2(库默认 `stream=True`,这是主路径) |
|
||||
| L6 | `enable_thinking=False` | qwen | ≥10 | 关闭 |
|
||||
| L7 | `enable_thinking=False` | deepseek | ≥10 | 关闭 |
|
||||
| L8 | **能力表漂移哨兵** | 全部登记模型 | 各 ≥5 | 实测行为与 `can_disable` 声明一致 |
|
||||
| L9 | `enable_thinking=False` + M2.7 → 装配期报错 | — | — | 纯本地,无需真实调用 |
|
||||
|
||||
轮数由环境变量可调高,默认 ≥10。总量约 100–150 次调用。
|
||||
|
||||
### 9.3 三条必须遵守的测试纪律
|
||||
|
||||
**(a)判别量只能是 `reasoning_tokens`。**(2026-08-02 e2e 实测修正:本节初稿写的是"主判据用 `completion_tokens`",被数据推翻。)两档的输出长度分布**重叠**——关闭档实测最高 46(模型偶尔把解题过程写进正文),开启档最低 13(medium 档想得少的轮次),按长度阈值判两个方向都会误判;而 `reasoning_tokens` 在同一批 30 轮里干净分开。`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路。另配一个不含魔数的确定性锚点:关闭档 `prompt_tokens` 严格小于开启档(实测 194 < 207)。
|
||||
|
||||
**(b)多轮 + 计数判定,不用单轮判定。** 关闭方向要求**每轮**都满足(关掉后 `completion_tokens` 极稳定,实测 4–10);开启方向只要求**多数轮**满足(推理量方差大)。
|
||||
|
||||
**(c)源不可用必须跳过并显式记录为"未覆盖",不得静默计入通过。** 报告里要能一眼看出哪些矩阵行没跑到。
|
||||
|
||||
### 9.4 漂移哨兵(L8)的定位
|
||||
|
||||
能力表过期是必然事件(LiteLLM 有过 `gpt-5.1-mini` 漏登记导致误拒的真实事故)。L8 用真实调用反向校验每条登记,是这张表的**过期告警**——模型升级后若 `can_disable` 声明失真,这里会先炸。建议纳入发版前清单定期执行。
|
||||
|
||||
## 10. 明确不做
|
||||
|
||||
不为中转的观测漂移在库内加任何机制(多轮取众数、渠道探测、重试到拿到 `reasoning_tokens`)——中转路由不受请求参数影响,探测结果不可迁移,属 YAGNI 违规;该问题在运维侧解决,写入 wiki 前提。
|
||||
|
||||
不改 `SourceConfig` 的公开字段形态:`enable_thinking` 保持 `bool | None`。分档需求走已有的 `extra_body` / `overlay`,两条路径已进缓存 key 与 `sampling` 遥测列,新增字段则要额外接这两处,是隐藏成本。
|
||||
|
||||
不动 qwen / deepseek 的 profile;不碰 `pricing.py`;不引入任何新依赖。
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
1. `ENABLE_THINKING=false` + MiniMax-M3 → 请求体含 `reasoning_effort: none`,响应 `reasoning_tokens is None`,真实 API 多轮验证
|
||||
2. `ENABLE_THINKING=false` + MiniMax-M2.7 → **装配期报错**,文案说明该模型无法关闭推理
|
||||
3. `ENABLE_THINKING` 任意非 `None` + `provider=openai` → **装配期报错**,指路 `register_provider` / `extra_body`
|
||||
4. 未登记模型 + 任意 `enable_thinking` → 正常注入 + 一条 warning
|
||||
5. `extra_body={"reasoning_effort":"high"}` 仍覆盖 profile 注入
|
||||
6. 流式与非流式均能采到 `reasoning_tokens`;打捞路径记 `None` 而非 `0`
|
||||
7. 改 `enable_thinking` → 缓存 key 变化;不配该项的存量 scope key 逐字不变
|
||||
8. 遥测两后端新列可写、旧库经 ALTER 迁移后可写
|
||||
9. e2e 报告存档于 `tests/outputs/e2e/`,矩阵覆盖情况可核
|
||||
|
||||
每条均需"先失败后通过"的证据(测试结果门)。
|
||||
|
||||
## 12. 影响与风险
|
||||
|
||||
**这是行为变更,不是纯修复。** MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效",CHANGELOG 须醒目标注;dissect 会有一次性缓存冷启动。
|
||||
|
||||
**dissect 的 Phase-0 实验设计需调整。** M2.7 上做不了"开思考 vs 关思考"的对照——这是模型固有属性,任何库层改动都无法改变。可行替代是只在 M3 上做该对照,或将因子改为"高档 vs 低档"。此结论须同步给 dissect。
|
||||
|
||||
**能力表的正确性依赖实测,且经中转。** 三条 MiniMax 结论均在自建 new-api 中转下取得,直连官方端点未验证;表中每条 `evidence` 须写明这一点。若下游改为直连,L8 漂移哨兵是发现失真的第一道防线。
|
||||
|
||||
**新增两处失败面。**(本段两次修正:初稿只列了 `openai` 那一处、遗漏 M2.x;二稿又把 M2.x 那处写成「合并即打挂 dissect」,同样不准确——见下。)
|
||||
|
||||
其一是 `provider=openai` + 配了 `ENABLE_THINKING`,经全仓与 dissect 检索当前无此用法(dissect 的 K3 scope 用 `provider=openai` 但未配该项)。
|
||||
|
||||
其二是**关不掉推理的模型 + `ENABLE_THINKING=false`**,而 `dissect/.env:80,85` 正是 `MiniMax-M2.7` + `false`。准确的影响是:**dissect 升到 1.0.6 之后**,该 scope 装配会抛 `ValueError`;它当前跑着的版本不受本次发布影响。但 `dissect/requirements.txt:7` 声明的是 `polygateway>=1.0.1,<1.1` —— 一个**范围**而非精确 pin,`1.0.6` 落在范围内,所以任何一次 `pip install -U`、重建环境或 CI 重装依赖都会**自动**装上它,无需谁刻意升级。换言之不是「突然挂」,而是「下次装依赖时挂」。
|
||||
|
||||
这是本设计的**预期行为**(给不了「不推理」的语义保证就必须说),dissect 侧的处置是改配置:该对照只能在 M3 上做,或把因子改为「高档 vs 低档」。
|
||||
|
||||
**三个参考下游零破坏**:VT / CHS / GovDoc 的 thinking 用法均为二元,本方案不改公开字段形态。
|
||||
|
||||
## 13. 另立 issue(不在本次范围)
|
||||
|
||||
`kimi-k3` 拒绝 `temperature=0`(400),而 400 归 `RequestRejectedError` 不重试不换源,下游统一下发 `temperature=0` 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。
|
||||
|
||||
`qwen` 的 `strip_think_tags=True` 已过时(实测走 `reasoning_content`,正文无 `<think>` 标签),无害死代码,可顺带清理或另记。
|
||||
@@ -0,0 +1,181 @@
|
||||
# 治理后端故障归位为 scope 级不可用设计(Issue #7)
|
||||
|
||||
- **日期**: 2026-08-06
|
||||
- **来源**: Gitea Issue #7(下游 CHSAnalyzer3 按异常类型分流失败,基于 1.0.1 源码核查)
|
||||
- **状态**: **已批准(2026-08-06)**,待 `writing-plans`
|
||||
- **触发档位**: 强制(变更 `errors.py` 公共错误类型树 = 库对下游的承诺)
|
||||
- **方案范围**: 人类已选定方向 A′ 并明确要求单一方案,故本文不列平行备选,仅在 §4 记录被否决路线及否决理由
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
| | 内容 |
|
||||
|---|---|
|
||||
| **G1** | `GovernanceBackendError` 归入 `GatewayUnavailableError` 之下,使"该延期重投的失败"在类型上闭合——调用方一条 `except GatewayUnavailableError` 覆盖完整,漏接在物理上不可能 |
|
||||
| **G2** | 把混在同一类里的**装配期缺陷**("未知源")拆出去,使其**不**被误判为可重投 |
|
||||
| **G3** | `retry_after_s` 取非零值,避免后端故障期间下游零延迟批量重投形成忙循环 |
|
||||
| **G4** | 公开错误面文档化:README 增"会到达调用方 / 库内吸收"两列表,`ARCHITECTURE.md` §6.1 回补缺失的 `GovernanceBackendError` 行 |
|
||||
| **非目标** | 不改 fail-closed 降级方向(限流/熔断后端不可用 → 报错而非放行,库铁律不动);不改后端重连/健康探测;不新增配置项;不改 `TransientError`/`SourceDeadError` 的库内吸收行为 |
|
||||
|
||||
### 1.1 Issue 前提的四处修正(按 1.0.6 源码核实)
|
||||
|
||||
| Issue 原文 | 实际情况 |
|
||||
|---|---|
|
||||
| 泄漏路径为 `try_enter` / `try_acquire` 两条 | **五条**(设计初稿写"三条",2026-08-06 独立验证时核出遗漏两条并订正): `QuotaGate` 的 `try_acquire` / `stats`(`retry.py:249`)/ `progress_age_s`(`retry.py:216`、`:305`),`BreakerGate` 的 `try_enter` / `retry_after_s`(`retry.py:292`、`:310`)。判据是该调用点是否被 `_record_quietly` 包裹——未包裹即直达调用方;OCR 与 Embedding 两个治理循环有同构的对应点 |
|
||||
| (未提及构造点数量) | 全库 **22 处** `raise GovernanceBackendError`,分布于 4 个文件 |
|
||||
| 方向 A 只需改类型树 | 其中 **2 处语义完全不同**(见 §3.4),整类归入"可重投"会制造镜像 bug |
|
||||
| `retry_after_s` 取 0,「docstring 已写 0 = 可立即重试,语义上是通的」 | 语义通,**工程上不通**。见 §3.2 |
|
||||
|
||||
另需记录一处根因:`ARCHITECTURE.md:372-378` §6.1 的错误分类表里 `GovernanceBackendError` **一次都没出现**。它是 M2 引入分布式后端时新增的,当时未回补架构表,于是它在"调用方视角的分类学"中从来就没有位置——README 的遗漏是这个遗漏的下游后果。
|
||||
|
||||
## 2. 影响面的决定性前提(改动安全性的依据)
|
||||
|
||||
| 事实 | 证据 | 含义 |
|
||||
|---|---|---|
|
||||
| 库内仅一处 `except GatewayUnavailableError` | `middleware/telemetry.py:250`,写法为 `except (GatewayUnavailableError, GovernanceBackendError)` | 变成父子关系后该处由"并列捕获"退化为"父类捕获",**行为逐字不变**,库内零回归 |
|
||||
| 加父类是纯扩大 | 下游既有 `except GovernanceBackendError` 全部照旧命中 | 不违反 CLAUDE.md §4.3「已被下游消费的公共类型只增不删不改名」 |
|
||||
| `QuotaGate`/`BreakerGate` 是后端异常的唯一入口 | 两类 docstring 自述,三处装配 `retry.py:186` / `ocr.py:122` / `embedding.py:123` | scope 注入点收敛为 2 个类、3 处装配 |
|
||||
| 三个装配点都持有 `self._scope` | `retry.py:183`、`ocr.py:116`、`embedding.py:118` | 注入无需新增上游参数传递链 |
|
||||
|
||||
## 3. 选定方案
|
||||
|
||||
### 3.1 类型树变更
|
||||
|
||||
`SCOPE_REASONS` 增枚举值 `governance_backend_down`;`GovernanceBackendError` 改继承 `GatewayUnavailableError`,`reason` 恒为该值(与 `CircuitOpenError` 恒为 `circuit_open` 同构,是本库已有的表达手法)。
|
||||
|
||||
构造签名保持"首参为 message"的位置参数形态,以免 22 处构造点与既有测试全部改写:
|
||||
|
||||
```python
|
||||
class GovernanceBackendError(GatewayUnavailableError):
|
||||
def __init__(self, message, *, scope, retry_after_s=GOVERNANCE_BACKEND_RETRY_AFTER_S,
|
||||
source_name=None):
|
||||
super().__init__(scope=scope, reason="governance_backend_down",
|
||||
retry_after_s=retry_after_s, source_name=source_name)
|
||||
self.args = (message,) # 见 §3.5
|
||||
```
|
||||
|
||||
`scope` 为必填 keyword(P4 显式优于隐式:它在三层调用点全部可得,给默认值只会掩盖装配疏漏)。
|
||||
|
||||
### 3.2 `retry_after_s` 的取值(本设计的核心权衡)
|
||||
|
||||
Issue 建议取 0。**否决**:下游 `schedule_retry(after_s=0)` 会立刻重投,Redis 挂掉期间队列里积压的任务将以零延迟批量重投,对着一个已经挂掉的后端打忙循环——把一次故障放大成一场风暴。这与本 issue 想修的问题同源:都是"分类正确但处置参数错误"。
|
||||
|
||||
已考虑并否决的两个替代取值:
|
||||
|
||||
| 取值 | 否决理由 |
|
||||
|---|---|
|
||||
| 复用 `BackpressureConfig.poll_interval_s`(与 `quota_exhausted` 同源,`retry.py:299` 有先例) | 该值只有三个装配点持有,后端层 11 处构造点拿不到;为此给 `RedisLimiter`/`RedisBreaker` 增构造参数,是让后端层去持有"重投策略"——违反 P7(决策逻辑与状态存储分离),后端只该知道"我坏了",不该知道这在治理上意味着什么 |
|
||||
| 新增配置项 `PGW_GOVERNANCE_BACKEND_RETRY_AFTER_S` | YAGNI。目前无任何下游表达过需要调它;真需要时下游可完全忽略 `exc.retry_after_s` 用自有退避 |
|
||||
|
||||
**选定**:`errors.py` 模块级常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`,作为构造默认值,docstring 写明理由——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),取一个保守固定值;下游若有自己的退避策略可忽略此值。本库对 scope 级异常硬编码语义值已有先例(`retry.py:206` 的 `no_sources` 取 `0.0`)。
|
||||
|
||||
它**不是环境配置项**,故不落 CLAUDE.md §4.5「严禁硬编码默认值」的论域——§4.5 约束的是 `pydantic-settings` + `.env` 管辖的工程配置(超时、并发、限额),而本常量是异常自身携带的语义默认值,与 `no_sources` 取 `0.0` 同性质。docstring 需显式写明这一点,避免后来者误加环境键。
|
||||
|
||||
### 3.3 `scope` 的三层来源
|
||||
|
||||
| 层 | 构造点数 | scope 来源 | 改动 |
|
||||
|---|---|---|---|
|
||||
| `backends/redis/limiter.py` | 6 | `self._scope`(`:170`) | 补 `scope=self._scope` |
|
||||
| `backends/redis/breaker.py` | 5 | `self._scope`(`:291`) | 补 `scope=self._scope` |
|
||||
| `middleware/breaker.py` `BreakerGate` | 5 | **需注入** | 构造函数增 `scope: str`,三处装配传 `self._scope` |
|
||||
| `middleware/ratelimit.py` `QuotaGate` | 4 | **需注入** | 同上 |
|
||||
|
||||
包装器对后端自抛异常的 `except GovernanceBackendError: raise` 原样放行**保持不变**——后端层已填好 scope,重建实例只会制造"同一异常构造两次"的怪味且覆盖值相同。
|
||||
|
||||
### 3.4 "未知源"拆分为独立错误类
|
||||
|
||||
`backends/memory/limiter.py:92` 与 `backends/redis/limiter.py:198` 的 `_cfg()` 在源名不在配置字典中时抛 `GovernanceBackendError`。**这不是后端故障**,是限流后端拿到的源列表与治理循环的对不上——装配期缺陷,正常不可达。
|
||||
|
||||
若随整类归入"延期重投、不扣失败预算",配置写错的任务将**永远重投、永远不进死信**,运维永远收不到告警——正是本 issue 要修的 bug 的镜像。
|
||||
|
||||
新增 `SourceNotConfiguredError(PolyGatewayError)`,**有意不放在** `GatewayUnavailableError` 之下:下游默认按"任务的错"处置 → 扣失败预算 → 进死信 → 人能看见。这是缺陷该有的可见性。该类进 `__init__.py` 公共导出(下游可选择性识别,但不识别也能得到正确处置)。
|
||||
|
||||
### 3.5 message 保全
|
||||
|
||||
`GatewayUnavailableError.__init__` 会把 message 覆盖为 `f"{scope} 网关暂时不可用: {reason}"`,而 22 处构造点携带的诊断串(如 `限流后端 try_acquire 失败: {exc}`)是排障的主要线索,不可丢。方案是 `super().__init__()` 后覆写 `self.args = (message,)`,使 `str(exc)` 仍为原诊断串,而 `scope`/`reason`/`retry_after_s` 作为结构化字段并存。父类不动——它的 message 生成逻辑对 `CircuitOpenError`/`AllSourcesExhausted` 仍然正确。
|
||||
|
||||
## 4. 被否决的路线
|
||||
|
||||
| 路线 | 否决理由 |
|
||||
|---|---|
|
||||
| **B: 只补文档,类型树不动** | 正确性依赖每个下游都读到那句话。本库下游不止一个,且本 issue 本身就是"文档读不出来"引发的——同一个失效模式不能用同一种药治 |
|
||||
| **C: 类型树不动,在 RetryMW 边界包成 `AllSourcesExhausted`** | 比 A′ 更具破坏性:下游现有 `except GovernanceBackendError` 会直接失效。加父类是扩大,换类型是破坏 |
|
||||
| **D: 后端层不再构造该异常,原始异常穿透由包装器统一翻译**(初评时倾向,已否决) | `backends/redis/limiter.py:133,151` 的 `RedisPermit.release/settle` 依赖 `except GovernanceBackendError` 实现**释放侧降级**(失败只 warning 不冒泡)。原始 redis 异常穿透后该处接不住,会破坏这条既有降级行为;改为 `except Exception` 则违反 P5 |
|
||||
|
||||
## 5. 行为审计(既有行为逐条标注)
|
||||
|
||||
| 既有行为 | 出处 | 处置 |
|
||||
|---|---|---|
|
||||
| 限流/熔断后端不可用 → 报错而非放行(fail-closed) | 库铁律 | **保留**,一字不改 |
|
||||
| 记账路径后端故障降级为 warning | `middleware/retry.py:404` `_record_quietly` | **保留**。仅闸门路径需要到达调用方 |
|
||||
| permit `release`/`settle` 失败降级 warning | `redis/limiter.py:133,151` | **保留**(§4 路线 D 因此被否决) |
|
||||
| 遥测对后端故障发 `emit_terminal_failure` | `middleware/telemetry.py:250` | **保留**,父子关系后由父类分支承接,行为不变 |
|
||||
| `except GovernanceBackendError: raise` 原样放行 | 包装器 9 处 | **保留** |
|
||||
| "未知源"抛 `GovernanceBackendError` | `memory/limiter.py:92`、`redis/limiter.py:198` | **替换**为 `SourceNotConfiguredError`(§3.4) |
|
||||
| `str(exc)` 为诊断串 | 22 处 | **保留**(§3.5 显式保全) |
|
||||
|
||||
## 6. 非功能维度
|
||||
|
||||
| 维度 | 回答 |
|
||||
|---|---|
|
||||
| **并发与取消** | 不适用于新增并发路径。异常构造是纯同步无状态操作,不引入共享状态。`CancelledError` 穿透路径完全不受影响——本设计不新增任何 `except` 子句,`_record_quietly` 中 `except asyncio.CancelledError`(`:402`)先于 `except GovernanceBackendError`(`:404`)的顺序不动 |
|
||||
| **降级方向** | 不变。fail-closed 是本类存在的理由,本设计只改"它被归入哪一类",不改"它是否被抛出" |
|
||||
| **幂等与重复** | 异常类型变更不涉及幂等性。需注意的是下游行为改变:同一次后端故障从"扣失败预算"变为"延期重投",重投次数由下游队列策略决定——这正是期望的变更,已在 CHANGELOG 行为变更段声明 |
|
||||
| **持久化与原子性** | 无持久化改动。遥测落库路径(`emit_terminal_failure`)的字段与调用时机均不变 |
|
||||
|
||||
## 7. 错误处理与测试策略
|
||||
|
||||
新失败面只有一个:`SourceNotConfiguredError`,它落在四分类之外。这**不违反** CLAUDE.md §4.2「一切失败必须落入四分类」——该铁律的论域是 **transport 层翻译的调用失败**(`ARCHITECTURE.md` §6.2 的翻译规则表逐条对应 HTTP 状态码与解析失败),而本库已有一整族异常合法地处在四分类之外:`GatewayUnavailableError` / `CircuitOpenError` / `AllSourcesExhausted` 都不是四分类之一,`ARCHITECTURE.md` §6.1 把它们单列一行,因为它们回答的是另一个问题——"整个 scope 还能不能用",而非"这一次调用怎么失败的"。
|
||||
|
||||
`SourceNotConfiguredError` 属于第三个论域:**装配缺陷**(配置与治理循环不一致,正常不可达)。四分类决定重试/换源/熔断,而装配缺陷根本不该进入治理循环去被"决定",它应当立刻失败并让人看见。将其塞进四分类中的任何一类都会赋予它一份不该有的治理语义(如 `RequestRejectedError` 会让下游以为请求本身有问题、去修请求)。§9 Q1 保留了"复用 `RequestRejectedError`"作为备选供人类权衡。
|
||||
|
||||
| 测试 | 位置 | 先失败后通过的证据 |
|
||||
|---|---|---|
|
||||
| `GovernanceBackendError` 可被 `except GatewayUnavailableError` 接住 | `tests/unit/test_errors.py` | 改前 `pytest.raises(GatewayUnavailableError)` 必失败 |
|
||||
| 闸门泄漏路径(五条,§1.1)抛出的异常携带正确 `scope` 与非零 `retry_after_s`;钉住 `try_acquire`/`try_enter`/`progress_age_s` 三条代表路径,余两条由同一注入机制覆盖 | `tests/unit/test_backpressure.py` — **三条桩都需新增**(Codex 审计划时核出: `:176-186` 是记账侧 `record_success`/`record_failure`/`mark_progress` 的降级桩,不是闸门路径;`progress_age_s` 仅 `:243-257` 覆盖包装行为、不验 scope) | 改前无 `scope` 属性,`AttributeError` |
|
||||
| `str(exc)` 仍为原诊断串 | `tests/unit/test_errors.py` | 防 §3.5 回归 |
|
||||
| 未知源抛 `SourceNotConfiguredError` 且**不是** `GatewayUnavailableError` | 改 `tests/unit/test_redis_key_layout.py:70-74`;内存版**当前无覆盖,需新增** | 改前抛 `GovernanceBackendError`,断言"不是 scope 级"必失败 |
|
||||
| Redis 真实掉线时准入侧行为 | `tests/integration/test_redis_cross_connection.py:228-245`(真实 Redis,不 mock) | 断言由 `GovernanceBackendError` 收紧为"是 `GatewayUnavailableError` 且 `reason == governance_backend_down`" |
|
||||
|
||||
## 8. 影响面清单
|
||||
|
||||
| 类别 | 内容 |
|
||||
|---|---|
|
||||
| **源码** | `errors.py`(新常量+新类+继承变更)、`backends/redis/limiter.py`(7)、`backends/redis/breaker.py`(5)、`backends/memory/limiter.py`(1)、`middleware/breaker.py`(6:构造函数+5 处)、`middleware/ratelimit.py`(5)、`middleware/retry.py`/`ocr.py`/`embedding.py`(各 1 行装配)、`__init__.py`(导出新类) |
|
||||
| **测试** | `tests/unit/test_errors.py`、`test_backpressure.py`、`test_redis_key_layout.py`、`tests/integration/test_redis_cross_connection.py` |
|
||||
| **文档** | `README.md` §"错误模型"增两列表 + `GovernanceBackendError` 行;`ARCHITECTURE.md` §6.1 回补该类并记录本次归位;`migrations/chsanalyzer.md` G1 条目补注;`CHANGELOG.md` 1.1.0;按 `docs-convention.md` §2 同步 Gitea Wiki |
|
||||
| **版本** | **1.1.0**。有行为变更(下游对后端故障的处置路线改变)但无 API 破坏(加父类是扩大),按语义化版本走 minor |
|
||||
| **下游** | CHSAnalyzer3 当前在 1.0.1。升级后 `except GatewayUnavailableError` 即覆盖后端故障,其现有 `except GovernanceBackendError`(若有)继续有效,无需改代码即可获得修复 |
|
||||
|
||||
### 8.1 执行顺序(单一事实源纪律)
|
||||
|
||||
`ARCHITECTURE.md` 是架构单一事实源,`SCOPE_REASONS` 新增值域与 `GovernanceBackendError` 的归位都与其 §6.1 现状冲突。因此 **§6.1 的修订必须先于或同批于代码实现落地**,不得"先改代码、事后补文档"。具体为:人类批准本设计后,`writing-plans` 的第一项任务即为修订 `ARCHITECTURE.md` §6.1(补 `GovernanceBackendError` 与 `SourceNotConfiguredError` 行、scope 级 reason 值域增 `governance_backend_down`、记录本次归位的理由与日期),与实现同一分支、同批提交。
|
||||
|
||||
## 9. 待人类确认的决策点
|
||||
|
||||
(编号用 Q 前缀,避免与 `ARCHITECTURE.md` 的架构决策 D1–D14 混淆)
|
||||
|
||||
**三点均已由人类拍板(2026-08-06),全部采纳本文的选择:**
|
||||
|
||||
| # | 决策 | 裁定 | 被否决的备选及理由 |
|
||||
|---|---|---|---|
|
||||
| Q1 | "未知源"归到哪 | ✅ **拆为 `SourceNotConfiguredError`**,不在 `GatewayUnavailableError` 之下(§3.4) | ① 沿用 `GovernanceBackendError`——配置写错的任务将无限重投、永不进死信、无人发现;② 复用 `RequestRejectedError`——治理行为与选定方案**完全等价**,但名称误导:下游会去查 prompt 而非配置文件 |
|
||||
| Q2 | `retry_after_s` 取值 | ✅ **常量 `5.0`**(§3.2) | 取 0 会让积压任务零延迟同时冲击已挂掉的后端,把一次故障放大成风暴 |
|
||||
| Q3 | 新类是否公共导出 | ✅ **导出**(进 `__init__.py`) | 不导出则下游无法给"配置写错"单独接告警,而导出无成本 |
|
||||
|
||||
## 10. 审批记录
|
||||
|
||||
| 阶段 | 状态 |
|
||||
|---|---|
|
||||
| Claude 自审 | 已完成(全部结论对应本会话内 grep/read 输出;§3.5 的 `self.args` 保全机制经 conda 环境实跑验证) |
|
||||
| Codex 独立审 | 已完成(2026-08-06),4 条意见逐条核验见下 |
|
||||
| 人类审批 | ✅ **已批准(2026-08-06)**。方向 A′ 于设计前即由人类选定;Q1–Q3 三个决策点逐条拍板,全部采纳本文选择(见 §9)。可进入 `writing-plans` |
|
||||
|
||||
### 10.1 Codex 意见的核验结果
|
||||
|
||||
| 意见 | 判定 | 处置 |
|
||||
|---|---|---|
|
||||
| ARCHITECTURE §6.1 未同步前实施违反单一事实源(判为阻塞) | **实质成立**,但性质是执行顺序而非设计缺陷——§8 本已把 §6.1 回补列入影响面 | 新增 §8.1 明确"架构文档修订先于/同批于实现" |
|
||||
| §6.1 错误分类表未承认 `GovernanceBackendError`(判为阻塞) | **与上条同源**,且 §1.1 已自陈此为根因 | 同上,由 §8.1 覆盖 |
|
||||
| `SourceNotConfiguredError` 落在四分类外违反 §4.2 铁律(判为阻塞) | **部分成立**:铁律论域被误读——`GatewayUnavailableError` 族本就合法处在四分类之外(§6.1 单列一行)。但原文表述确会引起该疑虑 | §7 补写三个论域的划分论证;§9 Q1 增列"复用 `RequestRejectedError`"备选交人类权衡 |
|
||||
| 硬编码常量与 §4.5 存在张力(建议性) | **成立** | §3.2 补写"非环境配置项"及 docstring 要求 |
|
||||
| Q 编号与架构 D1–D14 混淆(建议性) | **成立** | §9 决策点编号由 `D` 改为 `Q` |
|
||||
@@ -0,0 +1,254 @@
|
||||
# stall 判定改为非生产性等待口径设计(Issue #8)
|
||||
|
||||
- **日期**: 2026-08-06
|
||||
- **来源**: Gitea Issue #8(本机全套件跑 391.67s,1 failed;失败源于单次 300s 超时耗尽 stall 窗口,基于 1.1.0 源码核查)
|
||||
- **状态**: **已批准(2026-08-06)**,待 `writing-plans`
|
||||
- **触发档位**: 强制(变更治理行为——判死条件的度量口径,是库对下游的承诺)
|
||||
- **方案范围**: 人类明确要求单一方案,故本文不列平行备选,仅在 §5 记录被否决路线及否决理由(体例沿用 Issue #7 设计)
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
| | 内容 |
|
||||
|---|---|
|
||||
| **G1** | 消除"单次超时即判 scope 级死亡"——`timeout_s` 与 `stall_window_s` 的隐式耦合彻底解除,重试预算在超时场景下真实可用 |
|
||||
| **G2** | 使 stall 判定的度量对象与它的职责一致:**它治理的是无人治理的非生产性循环,不是已被重试预算治理的真实尝试** |
|
||||
| **G3** | 三条治理循环(chat / embedding / ocr)口径一致,计时逻辑收敛为单一共享单元,杜绝第四次复制 |
|
||||
| **G4** | 配置方不再需要心算 `stall_window > timeout × max_attempts`;`.env.example` 注释与实际语义对齐 |
|
||||
| **非目标** | 不改 `progress_age_s()` 的 `inf` 语义(见 §3.4);不新增装配期校验(见 §5.2);不新增配置项;不改 `AllSourcesExhausted` 的字段与 `reason` 取值;不给 embedding/ocr 新增主循环判死路径(见 §5.4);不改 429 免预算、AIMD、选源、熔断任何既有行为 |
|
||||
|
||||
### 1.1 Issue 前提的三处修正(按 1.1.0 源码核实)
|
||||
|
||||
| Issue 原文 | 实际情况 |
|
||||
|---|---|
|
||||
| 失效点为 `retry.py:216` 一处 | **三处同构**:`retry.py:216`(主循环)、`retry.py:305` / `embedding.py:247` / `ocr.py:272`(`_on_no_runnable`)。四个判定点共用同一个墙钟 `entered_at`,故 embedding/ocr 在"先超时一次、再遇到无可用源"时同样误判——issue 只覆盖了 chat |
|
||||
| 建议方向 1:装配期校验 `stall_window_s > max(timeout_s)` | **不采纳**。它把耦合固化成契约而非消除耦合,且约束值须为 `timeout × max_attempts`(本机即 900s),会让 stall 兜底迟钝到近乎失效。详见 §5.1 |
|
||||
| 建议方向 2:`inf` 不参与判死 | **不采纳**。在新口径下 `inf` 从"有害恒真"变回"正确的保守默认";且它会反转已被测试钉住的既有行为。详见 §3.4 与 §5.3 |
|
||||
|
||||
## 2. 根因:两个预算重叠计费
|
||||
|
||||
`retry.py:214-215` 的注释自述这处判定是「429 免预算后的兜底,防饱和期无限循环」——它治理的对象是**非生产性循环**。但条件 A `now - entered_at > stall` 度量的是**墙钟总耗时**,无法区分两类性质相反的时间:
|
||||
|
||||
| 时间性质 | 构成 | 应由谁治理 | 耗尽后 |
|
||||
|---|---|---|---|
|
||||
| **生产性** | 一次尝试的完整生命周期(发请求、等响应含耗满 `timeout_s` 的超时/TTFT/流式读取,以及该次尝试的记账与遥测收尾) | `max_attempts`(重试预算) | `retry_exhausted` |
|
||||
| **非生产性** | 429 退避、配额 wait 轮询、熔断冷却轮询、AIMD 排队 | **无人治理**(429 不计 `fails`)→ 正是 stall 的职责 | `stalled` |
|
||||
|
||||
**缺陷即:生产性时间同时向两个预算计费。** 而 stall 预算(默认 300s)远小于重试预算(`3 × 300s`),必然先耗尽,于是重试预算在超时场景下**永远用不上**——issue 观察到的"静默失效"就是这个重叠计费的直接后果。
|
||||
|
||||
`.env` 里 `TIMEOUT_S=300` 与 `_DEFAULT_STALL_WINDOW_S=300.0`(`config.py:60`)相等只是把它暴露得最快;只要 `timeout_s ≥ stall_window_s / 1`,一次超时就够。
|
||||
|
||||
### 2.1 两条佐证:`inf` 恒真是遗漏而非设计
|
||||
|
||||
| 证据 | 出处 | 含义 |
|
||||
|---|---|---|
|
||||
| `_PROGRESS_TTL_S = 3600 # 远大于任何 stall_window,防进度键过期造成假停滞` | `backends/redis/limiter.py:32` | 「无 progress 记录 ≠ 停滞」早已是设计共识,作者用超长 TTL 规避了"键过期"这一路径,但 TTL 再长也救不了"**从来没写过**"——冷启动是同类情形的漏网之鱼 |
|
||||
| `test_global_stale_but_local_fresh_keeps_waiting` docstring 写「仅全局超窗(从未出餐 age=inf)」 | `tests/unit/test_backpressure.py:120-121` | 现有测试把 `inf` 当作"全局超窗成立"钉住了;`test_both_windows_exceeded_raises_stalled`(:89)更是**全靠 `inf` 恒真**才能触发判死 |
|
||||
|
||||
## 3. 选定方案:双预算正交模型
|
||||
|
||||
### 3.1 一句话
|
||||
|
||||
**stall 计时器只累计非生产性等待时间**:`stalled_s = (now − entered_at) − 真实尝试累计耗时`。
|
||||
|
||||
两个预算自此正交,各管一段,无缝覆盖调用的全部时间:
|
||||
|
||||
| 花在哪 | 烧哪个预算 |
|
||||
|---|---|
|
||||
| 真实尝试(`_attempt` 内),**429 除外** | 重试预算 `max_attempts` |
|
||||
| 其余一切等待,**含 429 尝试本身** | stall 预算 `stall_window_s` |
|
||||
|
||||
> **划分依据是"谁消耗重试预算",不是"是否发出了请求"**(2026-08-06 实施期订正,见 §3.6)。初稿按后者划分,使 429 尝试两个预算都不烧。
|
||||
|
||||
**"生产性"的边界即 `_attempt` 的边界**——包含该次尝试的记账(`record_success`/`mark_progress`)与遥测收尾,而不止于"等响应"。这是有意的:这些收尾是"尝试已有结论"之后的动作,不是"在等待重试机会"的停滞;把它们计入 stall 会让遥测抖动参与判死,与「遥测写失败降级不冒泡」所守的"遥测不得影响主路径判决"同精神。其耗时本也在毫秒量级。
|
||||
|
||||
这与库内既有原则**同构**:429 不烧重试预算,所以 429 等待烧 stall 预算;真实尝试烧重试预算,所以它不烧 stall 预算。
|
||||
|
||||
### 3.2 为什么取补集,而不是逐处标记 sleep
|
||||
|
||||
两种实现都能达到 §3.1 的语义,选**取补集**(总时间减去 `_attempt` 耗时):
|
||||
|
||||
| 维度 | 取补集(选定) | 逐处标记 sleep(否决) |
|
||||
|---|---|---|
|
||||
| 埋点数量 | 每条循环 **1 处**(`_attempt` 调用点) | chat 3 处、embedding/ocr 各 2 处,共 7 处 |
|
||||
| 演进安全性 | **默认安全**:将来新增任何等待路径自动计入 stall,兜底不会漏 | 默认危险:新增等待路径若忘记标记,即成新的 stall 盲区 |
|
||||
| 语义可读性 | 「stall 时间 = 总时间 − 花在真实尝试上的时间」,一句话说清 | 需读者遍历全部标记点才能确认覆盖完整 |
|
||||
|
||||
`_attempt` 是纯生产性的:permit 获取、熔断准入、AIMD 判定全部在 `_pick_runnable` 内完成,`_attempt` 进入时已持 permit,内部只做"发请求 + 记账"。故补集口径不会把非生产性时间误算为生产性。
|
||||
|
||||
### 3.3 共享单元:`StallClock`
|
||||
|
||||
计时逻辑提取为 `middleware/retry.py` 的模块级小类,embedding/ocr 复用——沿用 `backoff_delay` 已被两者复用的既有手法(`tests/unit/test_backpressure.py:258` 记录该先例),不新建模块、不动依赖层次。
|
||||
|
||||
```python
|
||||
class StallClock:
|
||||
"""调用级 stall 计时器: 只累计非生产性等待(设计 §3.1)。
|
||||
|
||||
实例per调用创建, 严禁提升为实例属性——并发调用共享会互相污染。
|
||||
"""
|
||||
|
||||
def __init__(self, now: Callable[[], float]) -> None:
|
||||
self._now = now
|
||||
self._entered_at = now()
|
||||
self._productive_s = 0.0
|
||||
|
||||
def stalled_s(self) -> float:
|
||||
return self._now() - self._entered_at - self._productive_s
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def attempting(self):
|
||||
started = self._now()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# 只做算术, 不吞任何异常——CancelledError 照常穿透(库铁律)
|
||||
self._productive_s += self._now() - started
|
||||
```
|
||||
|
||||
调用点改动(三处循环同款):
|
||||
|
||||
```python
|
||||
clock = StallClock(self._now) # 替换 entered_at = self._now()
|
||||
...
|
||||
if clock.stalled_s() > stall and await self._quota.progress_age_s() > stall:
|
||||
raise AllSourcesExhausted(..., reason="stalled", ...)
|
||||
...
|
||||
async with clock.attempting(): # 包裹真实尝试
|
||||
outcome = await self._attempt(request, *picked, reasons, attempt_fails)
|
||||
```
|
||||
|
||||
`_on_no_runnable` 的形参由 `entered_at: float` 改为 `clock: StallClock`(三处同改)。
|
||||
|
||||
### 3.4 `inf` 语义为何不动(本设计的核心权衡)
|
||||
|
||||
新口径下第一象限的含义变为:「**非生产性排队已耗满 `stall_window_s`,且整个 scope 从未出餐**」。此时判死是正当的——真的没有任何证据表明这个 scope 还活着,而调用方已经白等了一整个窗口。`inf` 由此从"有害的恒真"回归为"正确的保守默认"。
|
||||
|
||||
反过来,若同时改 `inf` 语义:
|
||||
|
||||
- 冷启动窗口内 stall 判定**完全失效**,429 饱和场景下 chat 主循环重新暴露无限循环风险(429 不计 `fails`,无其他兜底);
|
||||
- 会反转 `test_both_windows_exceeded_raises_stalled` 钉住的行为,并与 CHS 保真蓝本分叉。
|
||||
|
||||
**一次改动解决问题,优于两次改动互相牵制。** 这是本设计只动条件 A 的理由。
|
||||
|
||||
### 3.5 429 饱和场景下兜底仍然有效(正确性验证)
|
||||
|
||||
修改后必须确认 stall 兜底没有被削弱。429 免预算使 `fails` 恒为 0,`retry.py` 的 `max(fails, 1)` 令退避恒定在 `backoff_base_s` 档(或取 `Retry-After` 提示的较大值),不随轮次增长。每轮构成为「一次 429 往返」+「一段恒定退避 sleep」,后者非生产性且每轮累加,`stalled_s` 单调逼近 `stall_window_s`,兜底有效。
|
||||
|
||||
**但这个论证在初稿里依赖一个未加保护的假设**:「429 往返是快速失败,毫秒至秒级」。§3.6 处理它不成立的情形。
|
||||
|
||||
### 3.6 订正:429 尝试必须退还给 stall 账(2026-08-06 实施期,独立验证发现)
|
||||
|
||||
**缺陷**:初稿按"是否发出请求"划分两个预算,于是 429 尝试的耗时算生产性。但 429 **不消耗重试预算**——它于是**两个预算都不烧**,掉进缝隙。§3.1 初稿声称的"无缝覆盖调用的全部时间"因此不成立。
|
||||
|
||||
**后果实测**(排队型网关:持满 `timeout_s` 才回 429,`timeout=300 / stall=300 / backoff_base=2 / rng=0`):
|
||||
|
||||
| | 尝试次数 | 墙钟 |
|
||||
|---|---|---|
|
||||
| 修复前(main) | 1 | 301s |
|
||||
| 初稿口径 | **301** | **90,601s ≈ 25.2 小时** |
|
||||
| 订正后 | 1 | 301s |
|
||||
|
||||
即初稿把一个 bug 换成了一个更严重的 bug——25 小时的挂起。
|
||||
|
||||
**订正**:划分依据改为**"谁消耗重试预算"**。429 免重试预算 → 429 尝试的耗时归 stall 治理,由 `StallClock.attempting()` yield 的句柄 `refund()` 退还。缝隙就此闭合,且这条规则比初稿更本质:两个预算按"由谁治理"划分,而非按"是否发出请求"这个表象。
|
||||
|
||||
**影响范围仅 chat**:embedding/ocr 无 429 免预算(无条件 `fails += 1`),429 照常烧重试预算,不存在缝隙,无需改动(与 §5.4 的分析一致)。
|
||||
|
||||
## 4. 旧版行为审计(stall 子系统逐条)
|
||||
|
||||
| 既有行为 | 处置 | 说明 |
|
||||
|---|---|---|
|
||||
| 双条件判死(本地超窗 ∧ 全局无进展超窗) | **保留** | 结构不变,只改条件 A 的度量口径 |
|
||||
| 条件 A = 调用级累计、循环内不重置(CHS `governance.py:207`) | **保留** | `StallClock` 同样每调用一个实例、循环内不重置 |
|
||||
| 条件 A 计入真实尝试耗时 | **替换** | 本设计的唯一行为变更 |
|
||||
| 条件 B `progress_age_s()`,`inf` = 从未进展 | **保留** | 见 §3.4 |
|
||||
| 本地 monotonic 与后端时钟刻意不混用 | **保留** | `StallClock` 只用注入的 `self._now`,不读后端时钟 |
|
||||
| poll jitter ∈ [0.5p, 1.0p] 防惊群 | **保留** | 不触碰 |
|
||||
| `fail_fast` 不进入 stall 判定 | **保留** | 不触碰 |
|
||||
| 429 免预算(chat 独有) | **保留** | 不触碰;embedding/ocr 无此逻辑,故无对应缺口(§5.4) |
|
||||
| `AllSourcesExhausted(reason="stalled")` 及其 `retry_after_s` 取值 | **保留** | 错误面零变更,下游 `except` 写法不受影响 |
|
||||
|
||||
**有意放弃**: 无。本设计不删除任何既有行为。
|
||||
|
||||
## 5. 被否决的路线
|
||||
|
||||
### 5.1 装配期校验 `stall_window_s > max(timeout_s)`(Issue 建议方向 1)
|
||||
|
||||
否决理由三条:
|
||||
|
||||
1. **治标**。它把"两个预算重叠计费"这个缺陷固化成一条配置契约,要求配置方绕开它,而不是消除它。
|
||||
2. **约束值不可接受**。要让重试预算真正可用,须 `stall_window > timeout × max_attempts`(本机 900s)。stall 兜底随之迟钝到 900s 才触发,饱和期无限循环的防护近乎失效——**修好一个洞,挖开另一个**。
|
||||
3. **挡不住残余情形**。即便配到 1200s,一次调用若在 429 轮询与超时上累计超过 1200s,条件 B 的 `inf` 仍恒真,双条件仍退化为单条件。坑只是被推远。
|
||||
|
||||
新口径下 `stall_window_s` 与 `timeout_s` 不再有任何耦合,**这条校验没有存在的理由**——不加校验、而是消除掉需要校验的耦合。
|
||||
|
||||
### 5.2 既有校验 `stall_window_s ≥ max(ttft_timeout_s)` 的处置
|
||||
|
||||
`config.py:240-247` 的 `_validate_stall` 被 `ARCHITECTURE.md` §7.3 记为契约补强 G6。新口径下 TTFT 等待属生产性时间,其 docstring 的理由「防把正常慢首包误判为卡死」**已不成立**。
|
||||
|
||||
**人类已定夺:保留校验,改写 docstring 说明新口径**。校验本身无害(不会误拒任何合理配置),保留可避免改动 ARCHITECTURE.md 既有契约、把本次改动的影响面控制在最小。docstring 改为说明"该校验在新口径下为保守冗余,TTFT 已不计入 stall"。
|
||||
|
||||
### 5.3 `inf` 不参与判死(Issue 建议方向 2)
|
||||
|
||||
见 §3.4:新口径下 `inf` 已无害,单独改它会制造冷启动兜底真空并反转既有测试。
|
||||
|
||||
### 5.4 给 embedding/ocr 补主循环 stall 判定
|
||||
|
||||
设计过程中一度提出(前提是"429 饱和时它们没有防无限循环兜底"),**核实后前提不成立,故否决**:
|
||||
|
||||
| 循环路径 | embedding/ocr 的兜底 |
|
||||
|---|---|
|
||||
| `picked is None` → `_on_no_runnable` 轮询(不烧 `fails`) | `_on_no_runnable` 内已有 stall 判定(`embedding.py:247` / `ocr.py:272`)✓ |
|
||||
| 尝试失败 → `fails += 1` | `max_attempts` ✓ |
|
||||
|
||||
`retry.py:233-234` 的 429 免预算分支是 chat **独有**的(`embedding.py:191`、`ocr.py:216` 均为无条件 `fails += 1`,两文件亦无 `pacer`),主循环判定正是为它打的补丁。embedding/ocr 两条路径均已封闭,补齐等于凭空新增一条判死路径,使其比 chat 更易判死——纯 gold-plating。
|
||||
|
||||
## 6. 非功能维度
|
||||
|
||||
| 维度 | 回答 |
|
||||
|---|---|
|
||||
| **并发** | `StallClock` **每次调用创建一个实例**,是调用级局部状态,与被替换的 `entered_at` 局部变量同性质。严禁提升为实例属性(并发调用会互相污染计时)——docstring 已写明,单测钉住并发两路调用互不干扰 |
|
||||
| **取消** | `attempting()` 的 `finally` 只做浮点加法,不含 `await`、不捕获任何异常,`CancelledError` 逐字穿透。既有 `test_cancellation_pierces_wait_loop` 继续有效,并新增一条"取消发生在 `_attempt` 内"的用例 |
|
||||
| **降级方向** | 不变。stall 判定读取的 `progress_age_s()` 属准入侧,后端故障仍 fail-closed 抛 `GovernanceBackendError`(scope 级),不放行 |
|
||||
| **幂等与重复** | `stalled_s()` 是纯读,可任意次调用;`attempting()` 可重入多次(每次尝试一次),累加语义天然幂等于"总生产性时间" |
|
||||
| **持久化与原子性** | 不适用。纯进程内计时,无落盘、无后端写入,不新增任何 Redis 往返 |
|
||||
| **性能** | 每次尝试新增两次 `self._now()` 调用与一次浮点加法,可忽略 |
|
||||
|
||||
## 7. 错误处理与测试策略
|
||||
|
||||
**错误分类**: 无变更。判死仍抛 `AllSourcesExhausted(reason="stalled")`,属 scope 级不可用(`GatewayUnavailableError` 家族),下游延期重投语义不变。
|
||||
|
||||
### 7.1 回归证据(先失败后通过)
|
||||
|
||||
核心用例 `test_single_timeout_does_not_exhaust_stall_budget`:`stall_window_s == timeout_s == 300`,第一次尝试推进 `FakeClock` 超过 300s 后抛 `TransientError`,第二次返回成功。
|
||||
|
||||
- **改前**:第二次尝试发出前即被判死,抛 `AllSourcesExhausted(reason="stalled")` → **失败**
|
||||
- **改后**:重试预算正常生效,返回成功响应 → **通过**
|
||||
|
||||
embedding / ocr 各一条同构用例(经"先超时一次、再遇到无可用源"触发 `_on_no_runnable`)。
|
||||
|
||||
### 7.2 其余用例
|
||||
|
||||
| 用例 | 钉住什么 |
|
||||
|---|---|
|
||||
| 四象限现有四条(`TestStallQuadrants`) | 非生产性路径行为逐字不变;`test_both_windows_exceeded` 全程无真实尝试,`stalled_s` 等价于旧墙钟,**应原样通过** |
|
||||
| `test_productive_time_excluded_from_stall` | 直接断言:仅靠真实尝试耗时无论多久都不触发判死 |
|
||||
| `test_nonproductive_wait_still_triggers_stall` | 反向:纯轮询等待累满窗口仍正常判死(兜底未被削弱) |
|
||||
| `test_saturation_429_still_stalls` | §3.5 的正确性验证:429 连续拒绝 + 退避,最终仍判死而非无限循环 |
|
||||
| `test_cancel_inside_attempt_pierces` | 取消穿透 `attempting()` 的 `finally` |
|
||||
| `test_concurrent_calls_do_not_share_clock` | 两路并发调用,一路长尝试不影响另一路的 stall 账 |
|
||||
|
||||
Redis 后端无需新增用例:本设计不改后端接口与 `progress_age_s()` 语义。
|
||||
|
||||
## 8. 交付清单(供 `writing-plans` 展开)
|
||||
|
||||
| # | 内容 |
|
||||
|---|---|
|
||||
| T1 | `middleware/retry.py` 新增 `StallClock`;主循环与 `_on_no_runnable` 改用之 |
|
||||
| T2 | `embedding.py` / `ocr.py` 复用 `StallClock`,`_on_no_runnable` 形参改签名 |
|
||||
| T3 | `config.py:240-247` `_validate_stall` docstring 改写(§5.2) |
|
||||
| T4 | 测试:§7.1 回归三条 + §7.2 五条;`test_backpressure.py:121` docstring 订正 |
|
||||
| T5 | `.env.example:41` 注释改写(删除误导性的"须 ≥ 最大源 TTFT",说明新口径);本机 `.env:37` 的临时缓解 `STALL_WINDOW_S=1200` 可回退默认(不入库,仅记录) |
|
||||
| T6 | `ARCHITECTURE.md` §7.3 背压条目补记新口径与本设计指针;`CHANGELOG.md` 记治理行为变更 |
|
||||
| T7 | Wiki 同步(`docs-convention.md` §2「治理行为变更」行):`解释-治理行为` + `指南-限流与熔断` |
|
||||
|
||||
**副作用提醒**: 修复后单次调用最坏耗时由 `stall_window_s` 抬升至 `max_attempts × timeout_s`(本机 900s)——这是重试预算恢复生效的**正确表现**,但 e2e 冒烟测试的最坏耗时随之变长,`tests/e2e` 的源 `timeout_s` 配置可能需要相应调小。
|
||||
@@ -0,0 +1,238 @@
|
||||
# HTTP 错误响应体留存设计(Issue #10)
|
||||
|
||||
- **日期**: 2026-08-16
|
||||
- **来源**: Gitea Issue #10(下游 1050 张医学影像批处理,1 张收到 400 被判确定性失败;事后无从查证原因。基于 1.1.2 源码核查)
|
||||
- **状态**: **已批准(2026-08-16)**,待 `writing-plans`
|
||||
- **触发档位**: 强制(`errors.py` 属最内层内核,新增公共字段即变更库对下游的承诺)
|
||||
- **方案范围**: 人类明确要求单一方案(2026-08-16),故本文不列平行备选,仅在 §6 记录被否决路线及否决理由(体例沿用 Issue #7/#8 设计)
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
| | 内容 |
|
||||
|---|---|
|
||||
| **G1** | 网关拒绝一次调用时,**它说了什么必须可事后查证**——库自己的遥测表里就能查到,不依赖下游额外埋点 |
|
||||
| **G2** | 留存口径覆盖 transport 层**全部**非 2xx 分支与**全部** transport(chat / embedding / stream / OCR),杜绝"只修 400 → 下次 401 复发" |
|
||||
| **G3** | 摘要文本单点规范化(折叠空白 + 截断 + 截断标记),message 与结构化字段**取同一份串**,两处永不打架 |
|
||||
| **G4** | 不改变任何状态码 → 错误分类的映射(ARCHITECTURE §6.2 表原封不动),下游 `except` 写法零影响 |
|
||||
| **非目标** | 不改 400 的治理语义(不重试不换源,见 §5.2);不新增遥测列(见 §6.1);不新增配置项;不做错误分类可插拔(见 §6.4);不顺手修 `_status_to_error` 的 `operation` 硬编码缺陷(见 §5.4) |
|
||||
|
||||
### 1.1 Issue 前提的两处修正(按 1.1.2 源码核实)
|
||||
|
||||
| Issue 原文 | 实际情况 |
|
||||
|---|---|
|
||||
| 建议方向一「让异常带上截断后的响应体……就能让下游把它记进日志和遥测」 | **只做这一半解决不了 Issue 自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`middleware/retry.py:558` → `middleware/telemetry.py:89` → `telemetry/sqlite.py:43` 的 `error TEXT` 列),即**异常 message**。新增字段不会进库的遥测表;下游说的"写进遥测表"是他们自己的埋点。故本设计**两件都做,且以 message 为主**(§3.3) |
|
||||
| 缺陷范围 = 400 分支 + 4xx 兜底 | 实为 **6 处同构**:`openai_compat._status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 的两支(读了 body 判 `insufficient_quota`,但 message 仍不带),以及 `monkey_ocr._classify_status:74-88` 的**全部**分支(message 只有 `HTTP {status}`)。Issue 场景是"读表格",极可能正落在 OCR 路径 |
|
||||
|
||||
## 2. 根因:诊断信息在翻译层被丢弃,而遥测只看 message
|
||||
|
||||
`_status_to_error`(`transports/openai_compat.py:131-143`)手上握着 `body_text`,却只把它用于 429 的类型细分,翻出的异常与 message 都不携带它。响应体在这一层之后**不再存在于进程任何位置**:该模块无 logger(grep `logger|loguru` 零命中),异常类无字段,遥测只写 message。
|
||||
|
||||
三条留存通道同时为空,是"永久查不到"的完整解释:
|
||||
|
||||
| 通道 | 现状 | 本设计后 |
|
||||
|---|---|---|
|
||||
| 日志 | 模块无 logger | 仍无(§6.2:不加日志) |
|
||||
| 异常字段 | 无承载处 | `body_text`(§3.1) |
|
||||
| 库遥测 `error` 列 | 只有 `"{源名} 请求被拒: 400"` | message 携带摘要(§3.3) |
|
||||
|
||||
## 3. 选定方案
|
||||
|
||||
### 3.1 内核:`PolyGatewayError` 基类新增 `body_text`
|
||||
|
||||
```python
|
||||
class PolyGatewayError(Exception):
|
||||
def __init__(self, message, *, source_name=None, status_code=None,
|
||||
operation=None, body_text: str = "") -> None:
|
||||
```
|
||||
|
||||
**加在基类而非 `RequestRejectedError`**:这些错误全部由同一个 HTTP 响应翻译而来,"对方说了什么"与"它属于哪一类"正交。只给一个子类加,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 一次人类门。
|
||||
|
||||
与既有 `ResultInvalidError.raw_text`(`errors.py:77`)的界限必须在 docstring 钉死,否则两个"原文字段"必然被混用:
|
||||
|
||||
| 字段 | 语义 | 来源 |
|
||||
|---|---|---|
|
||||
| `body_text` | **非 2xx** 的 HTTP 错误响应体摘要——对方**拒绝**你的理由 | transport 翻译层 |
|
||||
| `raw_text` | **2xx** 但内容不可解析时的模型输出原文 | 结构化解析层 |
|
||||
|
||||
`GatewayUnavailableError` 一族继承到一个恒空的 `body_text` 不是噪音:scope 级失败本就"没有单一响应体可言",空串是对这件事的如实表达。
|
||||
|
||||
### 3.2 共享单元:`transports/_http_errors.py`(新建,~40 行)
|
||||
|
||||
两个 transport 各有自己的状态码分类逻辑(OCR 无 429 细分,有意保留,见 `monkey_ocr.py:53-54`),但**摘要口径必须同一份**,否则就是下一个"只修一半"。两函数:
|
||||
|
||||
| 函数 | 职责 | 关键防御 |
|
||||
|---|---|---|
|
||||
| `summarize_body(text) -> str` | 折叠空白 → 按 §3.4 的机械规则截断 | 空/空白入参返回 `""` |
|
||||
| `response_body(response) -> str` | 从 `httpx.Response` 取已缓冲文本 | `ResponseNotRead` → 返回 `""`,**绝不触发网络读** |
|
||||
|
||||
- **折叠空白不是洁癖**:错误体常是缩进 JSON,直接拼进 message 会让一行日志炸成多行、遥测列不可读。
|
||||
- **截断必须留标记**:不标记,读的人分不清"网关只说了这么多"和"库切的"。
|
||||
- **`response_body` 的防御是硬要求**:`monkey_ocr._classify_status` 只拿得到 `httpx.HTTPStatusError`,若某天 OCR 走 stream 请求,`.text` 会抛 `ResponseNotRead`,把一次可分类的 4xx 变成泄漏的 httpx 异常——**违反"一切失败必须落入四分类"铁律**。诊断信息缺失绝不能升级为崩溃(降级方向,§4.2)。
|
||||
|
||||
放在 `transports/` 私有模块而非 `errors.py`:职责是"HTTP 响应 → 领域错误"的工具,放内核会稀释 `errors.py` 的单一职责(P3)。两个 transport 同 import 一个私有模块,不构成 transport 之间的互相依赖,import-linter 的 layers 契约(同层 `|` 独立性)不受影响。
|
||||
|
||||
### 3.3 翻译层:表驱动收口,message 与字段共用一份摘要
|
||||
|
||||
`_status_to_error` 现在是五个分支各拼各的 message,新增摘要意味着五处重复。改为**分类表 + 单点拼装**,代码反而变短:
|
||||
|
||||
```
|
||||
summary = summarize_body(body_text) # 全函数只算一次
|
||||
ctx = {..., "body_text": summary} # 字段
|
||||
429 → _translate_429(source, body_text, headers, ctx) # 需原文判 type,单列
|
||||
其余 → cls, label = _STATUS_MAP 查表 → cls(_compose(source, label, status, summary), **ctx)
|
||||
```
|
||||
|
||||
message 形态:`"{源名} {标签}: {状态码} | {摘要}"`;**摘要为空时不拼后缀**,避免出现悬空的 ` | `。分隔符取 ` | ` 而非既有的 `: `,让"库的话"与"网关的话"一眼可分。
|
||||
|
||||
**429 也拼,不设例外**:例外就是下一个复发点。`insufficient_quota` 那支尤其需要(配额细节全在 body 里);普通限速 body 通常很短。代价是高频限速场景遥测 `error` 列变长,由 `_ERROR_BODY_CAP` 兜住。
|
||||
|
||||
`monkey_ocr._classify_status` 同款处理:`summary = summarize_body(response_body(exc.response))`,message 追加同一后缀,`ctx` 带上字段。
|
||||
|
||||
### 3.4 常量取值
|
||||
|
||||
**机械规则(实现与测试逐字照此)**:
|
||||
|
||||
```
|
||||
_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限
|
||||
_HEAD_CHARS = 1400
|
||||
_TAIL_CHARS = 600
|
||||
|
||||
折叠空白后 len ≤ 2048 → 原样返回
|
||||
否则 → s[:1400] + f"…(略 {len(s) - 2000} 字)…" + s[-600:]
|
||||
```
|
||||
|
||||
> 规则必须写成算术而非叙述:"截断至 cap 并补标记"能同时被读成总长 2048 与 2049,两者会让测试断言与遥测长度承诺对不上(Codex 审查 2026-08-16 提出)。
|
||||
|
||||
**头尾保留而非头部硬切**(2026-08-16 调研决策)。截断的对象是**结构化 JSON 错误体**,信息分布头重尾也重:人话(`message`)在前,机器可判的 `type` / `code` / `param` / `request_id` 在后。Issue 给出的真实样本即 `"code":"invalid_parameter_error"` 收尾——头部硬切正好切掉向网关方追查时唯一有用的那部分。省略标记记下**被省略的字符数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。
|
||||
|
||||
按**字符**而非字节切:多字节字符不会被切成半个(Sentry 曾为按字节切开 issue #1691),且 `error TEXT` 列无定长约束,无需字节口径。
|
||||
|
||||
### 3.4.1 取值依据:同场景开源实践
|
||||
|
||||
| 项目 | 场景 | 上限 | 保留策略 |
|
||||
|---|---|---|---|
|
||||
| **Kubernetes client-go** `rest/request.go` | **读 HTTP 错误体生成错误信息**(与本设计同构) | `maxUnstructuredResponseTextBytes = 2048` | 头部硬切 |
|
||||
| OpenAI Python SDK `_exceptions.py` | 异常对象持有 body | **不截断**(内存对象,不落库) | — |
|
||||
| Sentry Python `strip_string` | 事件写入前 trim | `max_value_length`,2.34.0 前默认 1024 | 头部 + `...`,另用 metadata 记原长 |
|
||||
| Elastic APM | 长字段 | keyword 1024 / long field 10000 | 截断带省略号 |
|
||||
| Python 标准库 `reprlib` | 给人读的长字符串 | `maxstring` | **头 + 尾,中间省略** |
|
||||
|
||||
**2048 对齐 k8s client-go**——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。初稿的 500 仅以 issue 的单个样本(约 160 字符)为据,是拿一个样本定上限,已废弃。头部硬切在 k8s/Sentry 成立是因为它们截的是任意文本;本设计截的是结构化 JSON,故取 `reprlib` 的头尾策略。
|
||||
|
||||
遥测代价:纯 ASCII 约 2KB/条,纯中文最多约 6KB/条;5xx 重试 3 次即一次调用最多约 18KB。批处理场景(1050 次调用、5% 失败)约 300KB,`TEXT` 列可忽略。
|
||||
|
||||
message 与 `body_text` **共用同一变量**,不设两个长度:两份不同长度会让"遥测里看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
|
||||
|
||||
### 3.5 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `errors.py` | 基类新增 `body_text` 字段 + 与 `raw_text` 的界限 docstring |
|
||||
| `transports/_http_errors.py` | **新建**:`summarize_body` / `response_body` / `_ERROR_BODY_CAP` |
|
||||
| `transports/openai_compat.py` | `_status_to_error` 表驱动重写;`_translate_429` 收 `ctx` |
|
||||
| `transports/monkey_ocr.py` | `_classify_status` 带摘要 |
|
||||
| `errors.py` docstring + `ARCHITECTURE.md` §6.2 | 中转拓扑下 400 的提醒(§5.2) |
|
||||
| `README.md:34` | 安装 pin `==1.1.*` → `>=1.2,<2`(§5.3,发布前置,漏改则下游拿不到本修复) |
|
||||
|
||||
三个调用点(`openai_compat.py:402` embed、`:417` stream、`:509` 非流式)**签名不变**,无需改动。
|
||||
|
||||
## 4. 非功能维度
|
||||
|
||||
### 4.1 并发与取消
|
||||
|
||||
新增全部是纯函数与数据字段,无状态、无锁、无 IO、不引入 `await`。`response_body` 只读已缓冲字节,`ResponseNotRead` 时直接返回空串而**不发起网络读**——否则会在错误路径上凭空插入一次可能挂住的 IO。`CancelledError` 路径逐字不变。
|
||||
|
||||
### 4.2 降级方向
|
||||
|
||||
响应体不可得(未读缓冲 / 解码失败 / 空体)→ `body_text=""`,**静默降级,绝不报错**。诊断信息属可观测性,按库铁律与缓存/遥测同档:缺了降级,不得把一次本可正确分类的失败变成不可分类的崩溃。流式路径的 `(await resp.aread()).decode("utf-8", errors="replace")`(`:416`)已是这个口径,保持。
|
||||
|
||||
### 4.3 幂等与重复
|
||||
|
||||
纯函数,同输入同输出。`summarize_body` 对自身输出再调用一次是幂等的:输出总长恒为 `2000 + len(标记) ≤ 2048`(标记形如 `…(略 N 字)…`,8 + N 的位数,现实中远不足 48),且不含需折叠的空白,故第二次调用走"原样返回"分支,不会出现标记被反复嵌套。
|
||||
|
||||
### 4.4 持久化与原子性
|
||||
|
||||
不新增表、不改 DDL、不动遥测端口的 22 字段与列序。摘要经既有 `error TEXT` 列落盘,原子性由既有单行写入保证。
|
||||
|
||||
### 4.5 安全与体积
|
||||
|
||||
- **响应体可能回显请求内容**(部分网关的 `error.param` 会带违规字段值)。截断 + 空白折叠是主要止血手段;字段 docstring 须写明"可能包含请求回显,已截断"。库不做内容脱敏——库不知道下游哪些字段敏感,猜测式脱敏只会同时丢掉诊断价值与安全性。
|
||||
- **本设计不放大既有的读取风险**:`_complete_stream:416` 的 `aread()` 对错误响应体无大小上限(超大错误体可打爆内存),该风险今天已经存在(读完即丢),留存后只是更显眼。**不夹带修复**,见 §5.4。
|
||||
|
||||
## 5. 错误处理、语义与边界
|
||||
|
||||
### 5.1 错误分类
|
||||
|
||||
不改任何映射。`body_text` 是**旁路数据**,不参与任何治理判定——不影响重试、换源、熔断计数、AIMD、限流结算。这是本设计能与 ARCHITECTURE §6.1/§6.2 零冲突的根本原因。
|
||||
|
||||
### 5.2 400 语义:不改行为,补文档
|
||||
|
||||
Issue 报告了一个有说服力的观察:同字节 15 次重发全部成功、`prompt_tokens=0`、耗时 2996ms 远低于同批 631 次成功调用的最快值 7366ms——说明那次 400 来自中转服务自身抖动,而非"你的输入有问题"。
|
||||
|
||||
**仍不改分类**:400 重试对直连供应商是纯浪费(确定性坏输入,重试只烧配额并拖延失败);"中转也回 400"是**部署拓扑**引入的信息损失,库从状态码无从分辨。默认改为可重试 = 让所有直连用户为一种部署形态买单,且推翻已冻结的公共契约。
|
||||
|
||||
**但本设计本身就是对这个观察最好的答复**:body 留存后,下游能自己区分——中转抖动的 400 体与供应商 `invalid_request_error` 体形态不同。库不替下游做判断,而是把判断所需的信息交出去。配套文档动作:`RequestRejectedError` docstring 与 ARCHITECTURE §6.2 各加一句"经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类"。
|
||||
|
||||
### 5.3 兼容性
|
||||
|
||||
`LLMResponse` 一族的"字段只增不删不改名"约束(ARCHITECTURE §5.1)同样适用于异常。本次是**纯新增关键字参数且带默认值**:既有构造点、既有 `except` 写法、既有 `str(exc)` 消费方全部不受影响。message 文本变化不构成破坏——现有测试对这些 message 无格式依赖(仅 `test_openai_compat.py:558` match 源名)。
|
||||
|
||||
版本 **1.2.0**(公共类型新增字段属 minor;2026-08-16 人类定夺)。
|
||||
|
||||
**发布时必须同步改 README 的安装 pin**:`README.md:34` 现为 `"polygateway[redis,postgres,structured]==1.1.*"`,发 1.2.0 后照此命令安装的下游会**静默停在 1.1.2**——无报错、无警告,与 CLAUDE.md §4.4.1 点名的"极易漏改"完全同款(registry 长期停在 1.0.5 即此类事故)。本次改为 **`>=1.2,<2`**,把"每发一个 minor 就要通知三个下游改 pin"这一反复出现的麻烦一次性消除。此项列入实现计划的发布前置步骤,不是发布日的临时动作。
|
||||
|
||||
### 5.4 有意不夹带的两项(建议单开 issue)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| `_status_to_error` 的 `operation` 硬编码 `"chat"`(`:134`),而 `embed()` 也调它(`:402`) | embedding 的 HTTP 错误在遥测里被标成 `operation="chat"`,是既有数据正确性缺陷,与本 issue 无关 |
|
||||
| `_complete_stream:416` 的 `aread()` 无大小上限 | 恶意/故障网关的超大错误体可打爆内存,属独立的健壮性问题 |
|
||||
|
||||
两项都在本次重构触及的函数附近,但修它们既不服务 G1-G4,也各自需要独立的行为讨论——按反 gold-plating 铁律留给独立 issue。
|
||||
|
||||
## 6. 被否决的路线
|
||||
|
||||
### 6.1 给遥测端口加一列(22 → 23 字段)
|
||||
|
||||
最"正统"的结构化留存,但成本极不相称:端口 Protocol 签名变更 + SQLite/Postgres 双后端 DDL 迁移 + 下游已有表的 ALTER + 列序契约测试全线改动——为一个诊断串付出一次跨三项目的迁移。而复用既有 `error TEXT` 列可达成同样的可查证性。
|
||||
|
||||
### 6.2 只在 `_status_to_error` 打一条 WARNING 日志(Issue 方向二)
|
||||
|
||||
不采纳为**主**手段:日志与遥测是两套留存,日志轮转后仍然查不到,而 Issue 的痛点恰是"事后"。且库铁律要求库不擅自向下游日志流写入高频内容(4xx/5xx 在批处理下可能极高频)。message 携带摘要已让 loguru 侧的下游在捕获点自然拿到同一份信息,再加一条独立日志属重复留存。
|
||||
|
||||
### 6.3 截断放在异常构造器内
|
||||
|
||||
构造器自动规范化更"防遗漏",但会让下游自建异常时传入的文本被悄悄改写,违反 P4;且 message 里的摘要仍需在翻译层单独算一次,反而出现两条规范化路径。选定方案在翻译层算一次、两处共用,更简且更显式。
|
||||
|
||||
### 6.4 错误分类映射可插拔(provider profile 注入 classifier)
|
||||
|
||||
Issue 的中转 400 场景确实指向这个方向,但当前只有一个使用方且他们已用自己的兜底分类解决。`ProviderProfile`(`providers.py:17-45`)目前也没有这个扩展点,加它是新子系统级的设计。YAGNI:等第二个使用方提出。
|
||||
|
||||
## 7. 测试策略(先失败后通过)
|
||||
|
||||
**验收主张**:一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要。这条端到端断言直接对应 Issue 的痛点,是本设计成立与否的唯一硬判据;其余为覆盖性用例。
|
||||
|
||||
| # | 用例 | 覆盖 |
|
||||
|---|---|---|
|
||||
| 1 | **端到端遥测**:mock transport 返回 400 + 真实样本体 → 断言 recorder 收到的 `error` 含摘要 | G1 |
|
||||
| 2 | 参数化状态码(400 / 401 / 404 兜底 / 429 普通 / 429 `insufficient_quota` / 500)→ 断言 message 含摘要且 `exc.body_text` 非空,**分类与既有断言逐一不变** | G2, G4 |
|
||||
| 3 | 超长体 → 前 1400 字符与原文头部逐字相同、**末 600 字符与原文尾部逐字相同**、中段为 `…(略 N 字)…` 且 N 等于实际省略数;`body_text` 与 message 中的摘要逐字相同 | G3 |
|
||||
| 3b | 长度恰为 2048 / 2049 的体 → 前者原样无标记,后者走头尾保留(边界) | §3.4 |
|
||||
| 3c | **尾部关键字段可见**:以 issue 的真实样本尾部 `"code":"invalid_parameter_error"}}` 构造超长体 → 断言该串出现在摘要中 | §3.4 头尾决策的验收 |
|
||||
| 3d | 摘要对自身幂等(再摘要一次不嵌套标记) | §4.3 |
|
||||
| 4 | 多行缩进 JSON → 折叠为单行 | G3 |
|
||||
| 5 | 空体 / 纯空白体 → 不拼悬空分隔符,`body_text == ""` | §3.3 |
|
||||
| 6 | 非 JSON 体、非 UTF-8 字节 → 不抛异常,分类不变 | §4.2 |
|
||||
| 7 | 流式错误路径(`_complete_stream` 415-417)同样带摘要 | G2 |
|
||||
| 8 | `monkey_ocr._classify_status` 同款(含 `ResponseNotRead` 时降级为空串而非抛出) | G2, §4.2 |
|
||||
| 9 | embedding 路径(`:402`)HTTP 错误带摘要 | G2 |
|
||||
|
||||
`tests/unit/test_errors.py:29` 现有的"四类构造形态"参数化用例需扩展 `body_text` 默认值断言(默认 `""`、可传入、`GatewayUnavailableError` 一族恒空)。
|
||||
|
||||
## 8. 人类定夺记录(2026-08-16)
|
||||
|
||||
| 议题 | 定夺 |
|
||||
|---|---|
|
||||
| 摘要上限与保留策略 | 初稿 500 + 头部硬切被否:上限提至 **2048**(对齐 k8s client-go 同场景先例),策略改为**头 1400 + 尾 600 + 省略字数标记**——人类指出"有用的信息可能只在后半部分",经调研证实 JSON 错误体的 `code`/`request_id` 确实收尾(§3.4、§3.4.1) |
|
||||
| 429 是否设例外 | **不设**,一律拼摘要(§3.3) |
|
||||
| 版本 | **1.2.0**,并同步把 README pin 由 `==1.1.*` 改为 `>=1.2,<2`(§5.3) |
|
||||
@@ -0,0 +1,230 @@
|
||||
# 调用方自定义维度设计(issue #11)
|
||||
|
||||
- **状态**: **已人类审批(2026-08-17)**;Codex 审查 4 项已全部采纳并修订
|
||||
- **触发**: issue #11「遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离」
|
||||
- **范围**: 公共 API(`chat`/`embed` 签名)、`ports.TelemetryRecorder` 端口、`types.ChatRequest`、两个遥测后端的 schema。属 CLAUDE.md §3 强制设计档 + 人类门。
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求与边界
|
||||
|
||||
### 1.1 issue 原始诉求
|
||||
|
||||
GovDoc-SaaS 是多租户法律文书 SaaS,准备启用 `PGW_TELEMETRY_BACKEND=postgres`。落库是审计链的一半证据(另一半业务事实在其自有库,靠 `call_id` 缝合)。阻塞点: `llm_calls` 22 列**没有任何租户维度**,能区分来源的只有 `session_id`/`parent_call_id` 两个调用方自填、库内不校验的自由字符串。而这张表存**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),即多个租户的完整合同与标书全文混在同一张表里,表结构本身不提供按租户过滤的能力。
|
||||
|
||||
诉求四条: ①真实租户列(不是藏在 `session_id` 里);②`chat()` 与 `embed()` 两条路径都能传;③该列能挂 RLS 或至少能做复合索引与查询条件;④保留期与访问控制(**issue 明说可另开,本设计不含**)。
|
||||
|
||||
**不可逆性是本 issue 的核心论点,且成立**: 先启用后加列,补列之前写进去的每一行都没有租户归属,事后无法还原哪行属于谁——而那些行里是客户合同全文。
|
||||
|
||||
### 1.2 本次放大的范围(2026-08-17 人类决策)
|
||||
|
||||
issue 只要租户维度。人类决定放大为**调用方自定义维度**的通用能力,但明确收窄了两处:
|
||||
|
||||
- **只做调用方自定义的维度**。请求自带信息(模型名、供应商、源名)继续走现有 `model`/`provider`/`source_name`/`model_reported` 列,**库不往新容器写任何自采信息**。
|
||||
- 保留期与访问控制不在本次范围(issue 第 4 条)。**已另开 issue #12**(2026-08-17)。
|
||||
|
||||
**范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖**三条**遥测链路而非两条。issue 与本设计初稿都只说了 `chat()`/`embed()`,但 `OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 同构。**OCR 行与 chat 行落在同一张表**——只覆盖两条会让同一张表里一部分行有租户归属、一部分永远空白,且「先启用后加列则归属无法还原」这条不可逆性论证对 OCR 行同样成立。与 issue #10 同一判断(那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修)。
|
||||
|
||||
### 1.3 明确不做
|
||||
|
||||
不做可配置的"提升列白名单"(见 §3 方案 C 的否决理由);不自动 `ENABLE ROW LEVEL SECURITY`;不自动建索引;不改动 `_BACKFILL` 的自动 ALTER 策略(调研提出的独立议题,属任务外重构,**已另开 issue #13**)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键既有事实(设计必须服从的约束)
|
||||
|
||||
| # | 事实 | 出处 | 对本设计的约束 |
|
||||
|---|---|---|---|
|
||||
| F1 | `cache_namespace` **已是必填的租户/项目隔离维度**,per-call 可传并进缓存 key,正是为修正 GovDoc「单 client 服务多租户」的缓存毒化 | ARCH §7.5 | 缓存层租户隔离**已完成**,缺口只在遥测层。新维度**不得**再进缓存 key |
|
||||
| F2 | `chat()` 签名「冻结」,但带默认值的 keyword-only 参数不破坏该承诺 | ARCH §5.2 + issue #4 先例 | 新参数只能是 keyword-only + 默认值 |
|
||||
| F3 | 公共类型新增字段必须带默认值(三项目 fake 构造零改动) | ARCH §5.1 约定① | `ChatRequest` 新字段必须有默认 |
|
||||
| F4 | `TelemetryRecorder` 端口 22 字段冻结,且**新增参数不设默认值**(库外无第三方实现者) | `ports.py:247` | 端口扩到 24 字段,不给默认值 |
|
||||
| F5 | 遥测调用点收敛为单一 helper,禁止复制参数列表 | 库铁律 | 只改 `TelemetryEmitter._record` 一处 |
|
||||
| F6 | 遥测写失败降级 warning,不冒泡 | 库铁律 | 校验失败必须在**进洋葱之前**报错,否则被降级吞掉 |
|
||||
| F7 | SQLite 补列探测用 `PRAGMA table_info`;新列必须排在 `created_at` 之后(列序不得分叉) | `sqlite.py:53-60,123` | 新列追加到现有 22 列末尾 |
|
||||
| F8 | PG 侧建表/补列**先探测后 DDL**(权限检查早于 `IF NOT EXISTS`) | `postgres.py:174-210`, issue #3/#9 | 复用现有机制,不新增 DDL 路径 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 备选方案对比
|
||||
|
||||
### 方案 A: `tenant_id` 提列 + `meta` JSON 容器(推荐)
|
||||
|
||||
`llm_calls` 增两列: `tenant_id`(真实列,可挂 RLS、可建复合索引)与 `meta`(JSON 容器,承载任意调用方自定义 KV,**默认不建索引**)。API 增两个 keyword-only 参数。
|
||||
|
||||
**支持证据**: LiteLLM(同为 LLM 网关、同为每调用一行进 Postgres)的 `LiteLLM_SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` 两个 Json 列**没有任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野的更严格版本。六家 LLM 可观测平台(Langfuse/LangSmith/Helicone/Braintrust/Phoenix/OpenLLMetry)无一例外都是"少数物化列 + 一个 KV blob"。
|
||||
|
||||
**代价**: 下游若想再提一个高频维度(如 `business_id`)要等库发新版。这是有意接受的——见方案 C。
|
||||
|
||||
### 方案 B: 纯 `meta` JSON,不提任何列
|
||||
|
||||
最小改动、最通用。**否决**,两条独立的实证:
|
||||
|
||||
① **RLS 会静默退化**。策略挂 `meta->>'tenant_id'` 语法合法,但 PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**来规划,而 `->>`(`jsonb_object_field_text`)未标记 leakproof。pgsql-general 有实证案例(日志直接打印 `not using statistics because function ... is not leak-proof`),Tom Lane 确认根因,报告者**最终解法就是把索引列改成非 JSONB**;Tom Lane 同时警告手工标 leakproof "possibly a security problem"。
|
||||
|
||||
② **planner 对 JSONB 本就没有可用统计**(与 RLS 无关的独立问题)。`@>` 走硬编码 0.1% 选择率;Heap 的复现里真实 50% 选择率被估成 0.1%,行数低估 12 万倍,nested loop join 从 300ms 变 **584 秒**。
|
||||
|
||||
对一个「bug 会同时击穿所有下游」的库,一个在真实数据量下不可预测退化、且退化点极难诊断的方案不可选。
|
||||
|
||||
### 方案 C: 可配置提升列白名单(下游声明 `promoted_keys=[...]`,库据此建列)
|
||||
|
||||
最通用,下游不必等库发版。**否决**,理由分三层:
|
||||
|
||||
① **业界一致禁止**。dbt(`on_schema_change` 默认 `ignore`,新列静默丢弃)、Airbyte("不建议改动最终表,你的改动可能在同步中丢失")、Fivetran(用户自加的列,后续 MERGE **把值置 NULL**,官方唯一方案是建视图)——三家数据集成工具立场完全一致。
|
||||
|
||||
② **本库场景的失败模式更糟**。下游 A 配 `["tenant"]`、B 配 `["dataset"]` 共用一张表: 各自向对方的列写 NULL(尚可忍);但若两方对**同名 key 推断出不同类型**(A 认为 `run_id` 是 TEXT、B 是 BIGINT),第二个到达者的 `ADD COLUMN` 会被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、数据持续污染,是最坏的失败形态。
|
||||
|
||||
③ **与端口契约冲突**。`_COLUMNS`/`_INSERT` 从常量变成运行时拼接,标识符来自配置,SQL 注入面从零变成需要严格校验;`TelemetryRecorder` 的「22 字段冻结」与列序断言测试全部失效。
|
||||
|
||||
**旁证**: 没有任何成熟系统允许"任意 key 自动获得真实列/索引待遇"。唯一的"自动推断"派是 Elasticsearch 的 dynamic mapping,也是唯一有公开事故名的(mapping explosion: 默认 `total_fields.limit=1000`,超限整个写入请求报错;不治理则 master 节点 heap 飙升、`put-mapping` 队列堵塞)。其补救开关 `ignore_dynamic_beyond_limit` 默认仍为 `false`——官方宁可拒写也不默默膨胀。
|
||||
|
||||
### 推荐
|
||||
|
||||
**方案 A**。它同时满足 issue 的 RLS 硬需求(真实列)与人类要求的通用性(JSON 容器),且与同类系统的实际做法逐字吻合。下游需要给 `meta` 里某个 key 加速时,路径是**表达式索引**(`CREATE INDEX CONCURRENTLY ON llm_calls ((meta->>'k'))`,无 schema 变更、无 ACCESS EXCLUSIVE、写入开销远低于 GIN)或库外建视图,由下游自行决定——这正是 Fivetran 给出的官方答案。
|
||||
|
||||
---
|
||||
|
||||
## 4. 设计细节
|
||||
|
||||
### 4.1 公共 API
|
||||
|
||||
四个公共方法对称新增两个 keyword-only 参数(F2/F3):
|
||||
|
||||
`chat(messages, *, ..., tenant_id: str | None = None, meta: Mapping[str, Any] | None = None)`;`embed(texts, *, ..., 同两参数)`;`recognize_text(image, *, ..., 同两参数)` 与 `parse_layout(image, *, ..., 同两参数)`。
|
||||
|
||||
**OCR 两个方法都要改**——只改一个即漏,而漏掉的那半会静默写出无归属的行。
|
||||
|
||||
**为什么 `tenant_id` 独立成参而不是 `meta` 里的一个约定 key**: 它是唯一享有真实列待遇的维度,独立成参让"这个 key 特殊"在签名上自明(P4 显式优于隐式);混在 `meta` 里则需要库偷偷抽取一个魔法 key,调用方拼错 `tenant_id`/`tenantId` 不会报错、只会静默降级成普通维度——正是本 issue 抱怨的失败形态。
|
||||
|
||||
**为什么不复用 `cache_namespace`**: 语义不同。namespace 是**缓存隔离单位**(可以是项目名),租户是**数据归属**;二者在 GovDoc 恰好同值不代表概念相同。复用会让下游无法表达"同租户下多个缓存命名空间",且把缓存决策与审计归属绑死。
|
||||
|
||||
### 4.2 校验规则(全部在进洋葱之前报 `ValueError`)
|
||||
|
||||
| 项 | 规则 | 依据 |
|
||||
|---|---|---|
|
||||
| `tenant_id` | 非空字符串;长度 ≤ 128;首尾空白报错 | 空串是哨兵值的地盘(§4.4),调用方传空串多为 bug |
|
||||
| `meta` key | 非空;`[a-z0-9_.]` 且长度 ≤ 64 | 照搬 OTel semconv 字符集;Langfuse 限「仅字母数字」偏严 |
|
||||
| `meta` key 数量 | ≤ 16 | 量级参考: Salesforce 自定义索引 25、Loki labels 15、OTel 属性 128(对库偏宽) |
|
||||
| `meta` value | 仅 `str`/`int`/`float`/`bool`;嵌套需调用方自行序列化 | OTel AnyValue 的可移植子集;后端普遍只可靠支持标量 |
|
||||
| `meta` value: float | **必须 `math.isfinite`**;`nan`/`inf`/`-inf` 报错 | 见下 |
|
||||
| `meta` value 长度 | `str` ≤ 256 字符 | 对齐 Sentry tag 的 200、Langfuse 的 200 量级 |
|
||||
| 保留前缀 | key 以 `pg_` 开头 → 报错 | LangSmith `ls_`、Traceloop `traceloop.`、Helicone `Helicone-` 同款。**库本次不写入任何 `pg_` key**,纯预留防未来撞名 |
|
||||
|
||||
**非有限 float 必须在入口拒绝(Codex 审查发现)**。`json.dumps({'k': float('nan')})` 产出 `{"k": NaN}`——这是 Python 的扩展语法,**不是合法 JSON**,PG 的 JSONB 会拒收。若放行,一个调用方的输入错误会变成遥测写入失败,再被 F6 的降级吞成 warning,即**把调用方的 bug 转化为静默丢数据**,恰好违反 P5。故两处同时收口: 入口用 `math.isfinite` 校验,序列化用 `json.dumps(..., allow_nan=False)`(实测该参数会对非有限值抛 `ValueError`),后者是入口失守时的第二道闸而非主防线。
|
||||
|
||||
**超限必须报错,不得静默丢弃**。Langfuse 的做法是 value 超 200 字符直接丢弃——这条**不抄**,违反 P5「严禁默认值掩盖错误」。报错点选在**两个公共入口**(`chat()` 与 `embed()`)而非遥测写入点,理由是 F6: 遥测层的一切失败都被降级成 warning,校验放那里等于没有校验。这与 `overlay` 保护键的现有先例同构(`client.py:223` 的 `validate_request_overlay`),校验函数同样共用一份,不在两个入口各写一遍。
|
||||
|
||||
### 4.3 内部流转: 三条独立链路
|
||||
|
||||
`ChatRequest` 增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] = field(default_factory=dict)`(F3)。二者是**只读快照**,库内中间件永不修改——与 `sampling` 字段同一纪律(issue #4 决策 A)。
|
||||
|
||||
`TelemetryEmitter._record` 是唯一的 recorder 调用点(F5),向 recorder 多传两个参数;三个 emit 入口(`emit_attempt`/`emit_cache_hit`/`emit_terminal_failure`)统一从 `request` 读取,不各自组装。
|
||||
|
||||
**embedding 走的是另一条链,必须单独贯穿(Codex 审查发现)**。`EmbeddingClient` 不经过 chat 洋葱: `embed()` → `_embed_batch()` → `_attempt()` → `_emit()`,而 `_emit()` 在 `embedding.py:360` **现场构造** `ChatRequest` 仅为复用同一个 Emitter,当前只填了 `session_id`/`parent_call_id`。若只改 `chat()`,结果是 chat 行有维度而 embed 行恒为空——**恰好落空 issue 第 2 条诉求**(两条路径都要能传)。故新维度须沿这四层逐层透传,并在 `_emit()` 构造 `ChatRequest` 时填入。
|
||||
|
||||
**OCR 是第三条链,同构同办**(2026-08-17 范围补正)。`recognize_text()`/`parse_layout()` → `_call()` → `_attempt()` → `_emit()`,同样在 `_emit()`(`ocr.py:398`)现场构造 `ChatRequest`。两个公共方法都是入口,都要校验并透传。
|
||||
|
||||
**不顺手重构这两条链的参数列表**: 它们已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数,把它们收成一个值对象在美学上更优,但那会改动 embedding 与 OCR 现有的全部内部签名,属任务外重构(反 gold-plating)。本次只做加法;若日后参数继续增长,再单独立项。
|
||||
|
||||
**缓存命中行与终态失败行同样带维度**: 前者读 `request` 而非缓存中的历史响应(维度是"本次调用由谁发起",不是历史那次);后者虽无具体源,但租户归属是已知的——这两行恰恰是审计最需要的(缓存命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
|
||||
|
||||
**不进缓存 key**(F1)。三条理由: ①`cache_namespace` 已负责隔离,重复; ②进 key 会让全部存量缓存冷启动; ③`meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
|
||||
|
||||
### 4.4 存储层
|
||||
|
||||
两端各追加两列到现有 22 列**末尾**(F7),经现有 `_BACKFILL` 机制补列(F8):
|
||||
|
||||
- Postgres: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta JSONB NOT NULL DEFAULT '{}'::jsonb`
|
||||
- SQLite: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta TEXT NOT NULL DEFAULT '{}'`
|
||||
|
||||
**为什么 `NOT NULL DEFAULT ''` 而不是可空**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见,且静默跳过不报错**。NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**。用哨兵空串则老行归属显式可查(`COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出还有多少行未归属)。同时 PG 11+ 加带非易失默认值的列**不重写全表**(值存进 `pg_attribute.attmissingval`),SQLite 加列是元数据操作,且 SQLite 硬性要求 `NOT NULL` 列必须有非 NULL 常量默认值——三条约束在这个写法上同时满足。
|
||||
|
||||
**`meta` 序列化**: `json.dumps(ensure_ascii=False)`,与既有 `messages` 列同口径。空 dict 落 `'{}'` 而非 NULL,保持"缺省即空容器"的单一语义。
|
||||
|
||||
**库不自动建索引**。`CREATE INDEX` 是 DDL,非 `CONCURRENTLY` 会锁写,而 `CONCURRENTLY` 不能在事务里跑。与不自动 ENABLE RLS 同源(§4.5),交下游执行。文档给出模板: `(tenant_id, created_at)` 复合索引——列序判据是**启用 RLS 后 policy 会给每一条查询隐式追加 `tenant_id = ...` 等值谓词**,它出现在 100% 的谓词里,必然是前导列(Supabase 实测: policy 引用列加索引 171ms → <0.1ms)。
|
||||
|
||||
### 4.5 RLS: 库止步于列 + 模板
|
||||
|
||||
**库绝不执行 `ENABLE`/`FORCE ROW LEVEL SECURITY` 与 `CREATE POLICY`**,只在文档提供可复制的 DDL 模板。四条理由:
|
||||
|
||||
① **default-deny 会击穿非多租户下游**。启用 RLS 而无匹配 policy → 零行可见/可写,静默不报错。三个下游里只有 GovDoc 是多租户,Video-Tree 与 CHSAnalyzer 都不是;库若自动启用,这两家升级后遥测**全量写失败**,再叠加 F6 的静默降级 = **无声全局丢数据**。这才是本 issue「不可逆」担忧的真正落点。
|
||||
|
||||
② **库无权知道角色拓扑**。policy 必须绑定角色,且 AWS 官方要求应用角色**非属主且无 `BYPASSRLS`**;库拿到的只是一条连接串。
|
||||
|
||||
③ **权限不对等**。`CREATE POLICY`/`ALTER TABLE` 要求表属主;按最佳实践部署时库的运行时角色恰好不是属主。
|
||||
|
||||
④ **SQLite 无 RLS**,承诺 RLS 会让两个后端语义不对等;只承诺"列"则两端一致。
|
||||
|
||||
**同类先例一致**: graphile-worker、Ent+Atlas、Citus 官方的 django-multitenant 都把 policy 授权留给使用方;未找到任何"库自动为下游表启用 RLS"的正面先例。
|
||||
|
||||
文档必须同时告知三个陷阱: 表属主默认**豁免** RLS(需 `FORCE`);租户上下文只能用 `set_config(..., true)` 且**必须在显式事务内**(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG **只发 warning 不报错**,表现为策略永远拿不到租户 → fail-closed 到零行);policy 必须同时写 `USING` 与 `WITH CHECK`,只写前者则租户 A 能插入标着 B 的行。
|
||||
|
||||
模板正文(交付物是 wiki 用户文档的一节,此处定稿口径):
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
|
||||
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
|
||||
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
|
||||
ON llm_calls (tenant_id, created_at);
|
||||
```
|
||||
|
||||
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——两者合起来使**未设租户 = 零行**(fail-closed),而不是全部行。
|
||||
|
||||
---
|
||||
|
||||
## 5. 旧版行为审计
|
||||
|
||||
本次不是重写/迁移类任务(无 `reference/` 旧模块被替换),但触及三项既有契约,逐条声明:
|
||||
|
||||
| 契约 | 处置 |
|
||||
|---|---|
|
||||
| `TelemetryRecorder` 22 字段冻结 | **替换为 24 字段**。新参数不设默认值(F4)。库外无第三方实现者,两个内建 recorder 同步改 |
|
||||
| `llm_calls` 22 列 / 列序 | **保留**列序纪律,新列追加末尾;旧表经 `_BACKFILL` 补列,补列失败仍只降级为逐行丢弃(不置 `_failed`) |
|
||||
| `chat()`/`embed()` 签名 | **保留**"冻结"承诺——新参数是带默认值的 keyword-only,既有调用点零改动 |
|
||||
|
||||
**有意放弃**: 无。**未声明的隐式丢弃**: 无。
|
||||
|
||||
---
|
||||
|
||||
## 6. 非功能维度
|
||||
|
||||
**并发与取消**: 新增字段是不可变快照,随请求在各自链路内流转,无共享可变状态,并发调用互不干扰。取消路径不变——`TelemetryMW` 捕获 `CancelledError` 时的 `emit_terminal_failure` 同样带上维度后立即重抛,遥测写入不延迟取消传播(ARCH §5.1 约定④)。校验在两个公共入口的同步代码里完成(chat 侧在进洋葱之前,embed 侧在切批之前),不涉及 await,无取消窗口。
|
||||
|
||||
**降级方向**: 分两段,方向相反且都符合铁律。**校验失败 → 报错**(`ValueError`,在 `chat()`/`embed()` 入口,调用方可见);**遥测写失败 → 静默降级 warning**(缓存/遥测后端不可用属"静默降级"档,不是限流/熔断的"报错而非放行"档)。补列失败 → 逐行降级丢弃,不判死。
|
||||
|
||||
**幂等与重复**: 不变。`call_id` 仍是主键,`ON CONFLICT DO NOTHING`/`INSERT OR IGNORE` 语义不受影响。同一 `call_id` 重复写入仍被忽略,新增两列不引入新的重复语义。
|
||||
|
||||
**持久化与原子性**: 不变。每行单条 INSERT,两个新列与既有 22 列在同一条语句里落盘,不存在部分写入。
|
||||
|
||||
`meta` 的 JSON 序列化在 emitter 内完成。**初稿曾断言"序列化失败不可达",此论断已被 Codex 审查推翻并修正**: 非有限 float 能通过"值是 `float`"这类朴素类型检查,却产出 PG 拒收的 `NaN`/`Infinity` 字面量,于是失败会落到 emitter 的降级 try 里被吞成 warning——调用方的输入错误变成静默丢遥测。修正后是真正的双层收口: 入口 `math.isfinite` 拒绝(主防线,调用方可见),序列化 `allow_nan=False`(第二道闸)。这里记下推翻过程,是因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式本身容易复发。
|
||||
|
||||
---
|
||||
|
||||
## 7. 错误分类与测试策略
|
||||
|
||||
**错误分类**: 校验失败抛 `ValueError`,**不属**四分类——与 `overlay` 保护键的现有先例一致(构造期错误,发生在洋葱之外,`RetryMW` 不参与)。这是有意的: 它不是"一次调用失败",而是"这次调用根本没资格发出"。四分类不新增成员。
|
||||
|
||||
**测试策略**(合并前需先失败后通过的证据):
|
||||
|
||||
单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/`pg_` 前缀拒绝/**非有限 float**),每条断言**报错而非静默丢弃**(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);`ChatRequest` 快照不可变;三个 emit 入口都带上维度(尤其**缓存命中行与终态失败行**——这两条最容易被漏,而它们恰是审计刚需)。
|
||||
|
||||
**三条链路各测一遍**——`chat()`、`embed()`、OCR 两方法都必须有"传入维度 → 遥测行带该维度"的用例。embed 与 OCR 尤其不能省: 它们各经四层透传,任一层漏传都不会报错、只会让维度恒为空。批量切批时**每一批的行都应带同一份维度**(维度属于本次 `embed()` 调用,不随批次变化);OCR 的 `recognize_text` 与 `parse_layout` **各测一个**,只测一个会漏掉另一个的透传缺口。
|
||||
|
||||
非有限 float 单独一条: 断言 `chat(meta={"x": float("nan")})` 抛 `ValueError` 而**不是**写入时降级成 warning——这是 §6 记录的那个被推翻论断的机械化守卫。
|
||||
|
||||
集成层——真实 SQLite 与真实 Postgres 各跑一遍: 新建库列齐全;**旧表(22 列)经 `_BACKFILL` 补列后能写入**,且老行 `tenant_id` 读出为哨兵空串而非 NULL(这是 §4.4 不可逆性论证的机械化验收);补列权限不足时逐行降级而非判死(沿用 issue #9 的既有测试形态)。
|
||||
|
||||
契约层——`TelemetryRecorder` 端口 24 字段与两个 recorder 的 `_COLUMNS` 逐字对齐(现有列序断言测试扩展);`meta` 空 dict 落 `'{}'` 而非 NULL。
|
||||
|
||||
**不测**: RLS 行为本身(库不执行 RLS DDL,那是下游部署的验收项);索引效果(库不建索引)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 开放问题(留待人类审批时确认)
|
||||
|
||||
1. `meta` key 数量上限取 16 是量级推断(Loki 15 / Salesforce 25 / OTel 128),无本项目实测依据。若下游有明确诉求可调,但**必须有一个有限上限**。
|
||||
2. `tenant_id` 长度上限 128 同为推断值。
|
||||
3. 调研另外提出「`_BACKFILL` 自动 ALTER 应降级为默认关闭」(Hangfire `EnableHeavyMigrations` 先例: 防止不受控升级造成长停机或死锁;APScheduler 4.x 则是读到不认识的 schema 版本直接 `RuntimeError` 拒绝启动)。此议题与本 issue 同源(都源于库自管下游 schema)但**属独立架构变更**,按反 gold-plating 不纳入本次。**已另开 issue #13**(2026-08-17)。
|
||||
@@ -0,0 +1,145 @@
|
||||
# issue #12 设计: 遥测表的正文体量、保留期与访问控制
|
||||
|
||||
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #12、#11(维度落地)、#10(截断先例)
|
||||
> 同批交付: [issue #13 遥测 schema 档位](2026-08-19-issue13-schema-mode-design.md)
|
||||
|
||||
## 1. 问题
|
||||
|
||||
`llm_calls` 存的是**完整正文**: `messages` 落库前只过 `digest_messages`,而它只对多模态 part 里的 `image_url` 做 sha256,纯文本原样透传;`response` 同理。Embedding 路径有 200 字符上限,LLM 路径没有。issue #11 之后 `tenant_id` 已是真实列、RLS 模板已进 README,但另外两件事仍是空白:
|
||||
|
||||
1. **保留期**: 没有任何 TTL、归档或清理机制,写进去的行永久留存。删除请求(数据主体权利)无处执行。
|
||||
2. **访问控制的默认状态**: 库不执行任何 GRANT/REVOKE,也不建议下游怎么分角色。默认是"任何能连库的账号都能读全部租户的全文"。RLS 只挡住"用错租户上下文查询",挡不住"用一个有全表权限的账号连上来"。
|
||||
|
||||
这与 #11 的不可逆性论证同类: 数据一旦以当前形态写进去,事后再补保留期,已经超期的那部分**已经存在了**。
|
||||
|
||||
## 2. 已定决策(人类,2026-08-19)
|
||||
|
||||
| # | 决策 | 选择 |
|
||||
|---|---|---|
|
||||
| E-a | 正文截断 | 新增**可配置**上限,**缺省不截断**(保持现状全文) |
|
||||
| E-b | 保留期 | 文档模板 **+** `tools/` 独立脚本;库本体不持有 DELETE/DROP 权限 |
|
||||
| E-c | 交付节奏 | 独立分支实现,与 issue #13 合并发 1.2.3 |
|
||||
|
||||
E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无法用于复现与重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。
|
||||
|
||||
## 3. 三个子问题的边界
|
||||
|
||||
| 子问题 | 库能做什么 | 性质 |
|
||||
|---|---|---|
|
||||
| (a) 正文体量 | 遥测路径可配置截断 | **唯一改库本体代码的**,也是唯一**预防性**手段: 没写进去的数据不需要删 |
|
||||
| (b) 保留期 | 分区 + retention 模板;`tools/` 清理脚本 | 文档 + 可选工具,库不执行 DELETE/DROP |
|
||||
| (c) 访问控制与不可变性 | 角色划分模板、`REVOKE UPDATE, DELETE`、分区 | 纯文档 |
|
||||
|
||||
(b)(c) 不进库本体,与 issue #11 对 RLS 的结论、issue #13 对 DDL 的收缩同一条边界: **库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。** 建议将其写进 ARCHITECTURE 作为一条独立决策(D15),两条 issue 各实现它的一面。
|
||||
|
||||
## 4. 备选方案对比
|
||||
|
||||
| 方案 | 内容 | 权衡 | 结论 |
|
||||
|---|---|---|---|
|
||||
| **A(采纳)** | 可配置截断(缺省 None) + 文档模板 + tools 脚本 | 三个子问题都有落点;库权限面不扩大;下游按需取用 | ✅ |
|
||||
| B | 缺省即截断(如对齐 embedding 的 200 或更宽松的 4096) | 合规面默认安全 | ❌ 破坏性: 所有现有下游升级后遥测正文被静默削短,而它们的分析/复现正建立在全文之上 |
|
||||
| C | 库内建 TTL/清理(定时任务或写入时顺带删) | 下游零运维 | ❌ 库需要 DELETE 权限,与 (c) 的 `REVOKE UPDATE, DELETE` 建议直接冲突;且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务 |
|
||||
| D | 给 `TelemetryRecorder` 端口加 `purge_before(ts)` | 语义清晰、下游自己调度 | ❌ 冻结签名的端口扩展 + 库仍需 DELETE 权限,同 C 的冲突 |
|
||||
| E | 什么都不做,只在文档写"本表存全文,请自行评估合规" | 零代码零风险 | ❌ 下游唯一的手段是不用遥测 |
|
||||
|
||||
## 5. 设计: (a) 正文截断
|
||||
|
||||
### 5.1 配置与装配
|
||||
|
||||
| 层 | 形态 |
|
||||
|---|---|
|
||||
| 环境 | `PGW_TELEMETRY_TEXT_CAP`(可选键,正整数;未设 = 不截断) |
|
||||
| `GatewaySettings` | 新增字段 `telemetry_text_cap: int \| None`(无默认值,与既有字段一致);`<= 0` 报 `ValueError` |
|
||||
| `TelemetryEmitter` | 新增 keyword-only **必填**参数 `text_cap: int \| None`(与 issue #13 的 D-c 同一纪律: 关键行为参数不给默认值);库内三个构造点 `client.py:149` / `embedding.py:131` / `ocr.py:130` 必须同步传参,否则 `TypeError`(测试内另有十余处) |
|
||||
|
||||
### 5.2 作用面与切法
|
||||
|
||||
截断发生在 `TelemetryEmitter._record` ——全库**唯一**的遥测调用点(铁律),在 `digest_messages` 之后、`json.dumps` 之前。作用于 `messages` 的每条文本 `content`(含多模态 part 中 `type == "text"` 的 `text` 字段)、`response`、`thinking`。
|
||||
|
||||
**按每条文本切,而不是切整串 JSON**: 后者会产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据会静默存进去)。
|
||||
|
||||
**头部硬切 + 标记省略字数**(形如 `…(略 12345 字)`),**不复用** `_http_errors.summarize_body`: 那个函数折叠空白并保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,而保头保尾服务的是"诊断时要看清 type/code/request_id",与"我不想存全文"这个用途无关。视觉标记口径保持一致,实现各自独立。
|
||||
|
||||
非字符串 `content`(外部输入,可能是任意 JSON 值)原样放行,不做类型强转(P5: 校验后使用,但遥测路径不得因输入形状抛错)。
|
||||
|
||||
**覆盖面的诚实声明**: 截断作用于 `content` 文本,与 `digest_messages` 的处理面一致。调用方放进 `tool_calls.function.arguments` 等其他字段的内容不在覆盖范围内,文档须写明。
|
||||
|
||||
**三条链路全覆盖,不只 chat**(Codex 审查提出后核实定稿): `_record` 是 chat / embed / OCR 共同的出口,cap 自然作用于全部三条。这与 issue #11 的判断同款——三条链路的行落**同一张表**,只覆盖一条会让同表内一部分行受控、一部分不受控。核实后的实际影响远小于直觉: `embedding.py:73` 与 `ocr.py:73` 各已有 200 字符的自有上限(embed 截 `texts`、OCR 的 `messages` 本就是 `<ocr:kind image_bytes=N>` 占位、`response` 走 `_summarize` 截 200),两者**保留不动**,与新 cap 是"取更严者"的关系。issue #12 那句"LLM 路径没有上限"因此是准确的——真正没有上限的只有 chat 路径。
|
||||
|
||||
### 5.3 红线
|
||||
|
||||
**`digest_messages` 一个字节都不能碰。** 它是缓存 key 与遥测共用的函数(`middleware/cache.py:31`),动它 = 全量缓存 miss + 缓存 key 口径分叉。截断只发生在遥测分支,缓存路径不经过它。此红线有机械化验收(见 §8)。
|
||||
|
||||
## 6. 设计: (b) 保留期
|
||||
|
||||
**README 模板**: PG 侧给 `created_at` 的 RANGE 月分区 + `pg_partman` retention(过期靠 DETACH/DROP 分区实现 O(1) 清理,而非 `DELETE`——审计表通行做法);SQLite 侧给文件轮转建议(按天/按实验一个库文件,是三个现有下游天然的形态)。
|
||||
|
||||
### 6.1 分区与幂等写入的冲突(Codex 审查发现,阻断级)
|
||||
|
||||
PostgreSQL 要求分区表上的唯一约束(含主键)**必须包含分区键**。按 `created_at` 做 RANGE 分区后,`call_id TEXT PRIMARY KEY` 不再合法,主键须改为 `(call_id, created_at)`;而库今天的写入语句是 `ON CONFLICT (call_id) DO NOTHING`,它需要一个恰好匹配 `(call_id)` 的唯一约束——分区表上不存在,写入会**直接报错**。原设计"INSERT 路由对分区表透明"只对普通 INSERT 成立,对冲突目标不成立。
|
||||
|
||||
修法: 库的写入改为**无冲突目标**的 `ON CONFLICT DO NOTHING`。它在两种表形态上都合法,且在普通表上与今天逐字等价(表上只有主键一个唯一约束)。**该改动归入 issue #13 实现**——#13 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。
|
||||
|
||||
**分区部署的语义差异须写进文档**: 分区表上幂等键实际是 `(call_id, created_at)`,而 `created_at` 由数据库 `DEFAULT now()` 生成,故同一 `call_id` 重复写入不再被拦。这对逐次尝试行无影响(每次尝试一个新 `call_id`),但会改变**缓存命中行**的表现——`emit_cache_hit` 复用的是响应里的历史 `call_id`,在普通表上第二次及以后的命中会被 `DO NOTHING` 吞掉,在分区表上则每次都落一行。这是既有行为在两种部署形态下的差异,不是本次引入的变更,库不做二次判定,但下游按 `cache_hit` 统计时必须知道。
|
||||
|
||||
### 6.2 模板与工具
|
||||
|
||||
分区表**必须由下游先手工建**,库的 `CREATE TABLE` 只会建普通表。这正是 issue #13 的 `telemetry_schema_sql()` 的用途: 下游取到库要求的最小 schema,自己加上 `PARTITION BY RANGE (created_at)` 再建。库的 `to_regclass` 探测与 INSERT 路由对分区表透明,列探测同样有效(#13 的 Expand/Contract 承诺保证这一点)。
|
||||
|
||||
**`tools/telemetry_retention.py`**(独立脚本,不被 import,符合 `tools/` 规则):
|
||||
|
||||
| 项 | 设计 |
|
||||
|---|---|
|
||||
| 参数 | `--backend sqlite\|postgres`、`--path/--dsn`、`--older-than-days N`、`--apply`(**默认 dry-run**)、`--batch-size`、`--vacuum`(仅 SQLite,显式) |
|
||||
| 输出 | 将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布 |
|
||||
| PG | 分批 DELETE(避免长事务与锁膨胀);检出目标是分区表时**改为提示用 DROP PARTITION** 并拒绝 DELETE |
|
||||
| 权限 | 文档写明: 用维护角色跑,不要用应用账号(应用账号已被 `REVOKE DELETE`) |
|
||||
| 依赖 | SQLite 走标准库;PG 需 `asyncpg`,缺失时明确报错退出(不静默降级——这是运维工具不是库路径) |
|
||||
|
||||
## 7. 设计: (c) 访问控制与不可变性(纯文档)
|
||||
|
||||
README 现有的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节。文档落点必须是 **README**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),放进 wiki 的模板下游 `pip install` 后读不到——这正是 56f3805 的教训。Wiki 同步一份并互链。
|
||||
|
||||
| 内容 | 要点 |
|
||||
|---|---|
|
||||
| 三角色 | `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束) |
|
||||
| 不可变性 | `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底只防误操作**不防恶意**(属主可 disable),须写明 |
|
||||
| 分区 | 与 §6 的 retention 模板同一段落 |
|
||||
| 库需要的权限 | 明确列出: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。下游据此最小授权 |
|
||||
|
||||
**权限张力必须写明**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
|
||||
|
||||
## 8. 非功能维度
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 并发与取消 | 截断是纯计算,不新增 await 点、不新增锁;`_record` 既有的 `except asyncio.CancelledError: raise` 保持在最外层,取消穿透路径不变 |
|
||||
| 降级方向 | 不变(遥测静默降级): 截断逻辑若抛错,仍被 `_record` 的降级 `try` 接住 → warning + 丢一行,不冒泡给调用方 |
|
||||
| 幂等与重复 | 截断是纯函数,同输入同输出;`call_id` 幂等键与写入语义不变 |
|
||||
| 持久化与原子性 | 库本体不变;`tools/` 脚本的 PG 分批删除每批一个事务,中断只影响未删批次,不产生半行数据 |
|
||||
|
||||
## 9. 错误处理与测试策略
|
||||
|
||||
| 层 | 用例 |
|
||||
|---|---|
|
||||
| unit | `cap=None` → 正文原样;`cap=N` → 每条 content 被截且整串 JSON 仍可解析;多模态 part 的 `text` 被截而 `image_url` 的 sha256 不动;`response`/`thinking` 被截;标记含省略字数;非字符串 content 不抛错 |
|
||||
| unit(**红线验收**) | 同一组 messages 在 `cap` 开与关两态下 `build_cache_key` 输出**逐字节相同**——机械化钉死"截断不得污染缓存 key" |
|
||||
| unit | config: 未设 → `None`;`<= 0` → `ValueError`;合法值透传到 emitter |
|
||||
| unit | `tools/` 脚本: 真实临时 SQLite 上 dry-run 不删任何行、`--apply` 删除且仅删除超期行、`--older-than-days 0` 的边界 |
|
||||
| unit | OCR 与 embed 两条链路的遥测行同样受 cap 约束(与既有 200 上限取更严者),三个 emitter 构造点全部传参 |
|
||||
| integration(真实 PG) | 无冲突目标的 `ON CONFLICT DO NOTHING` 在**普通表与分区表上都能幂等写入**(分区表主键为 `(call_id, created_at)`);此条与 issue #13 的实现同批验收 |
|
||||
| integration(真实 PG) | **README 的模板 SQL 逐条执行**: 三角色 + REVOKE + 分区 + RLS 建起来后,app 角色能 INSERT 不能 DELETE、report 角色只读、跨租户查询为零行。README 里的 SQL 若有错,下游照抄就中招,故文档模板必须有机械化验收 |
|
||||
|
||||
遥测路径的一切失败仍不落四分类;配置校验抛裸 `ValueError`(公共入口先例)。
|
||||
|
||||
## 10. 兼容性、文档与发布
|
||||
|
||||
**非破坏性**(除 §5.1 两处必填参数带来的直接构造路改动,与 issue #13 同批): 缺省 `text_cap=None` 时行为与今天逐字节相同。
|
||||
|
||||
文档同步: README(截断配置 + 生产部署 DDL 模板 + 库所需最小权限)、`.env.example`、ARCHITECTURE(D15 边界 + §7.8 遥测字段说明)、Wiki `指南-遥测与成本` / `参考-配置键` / `参考-公共API`、CHANGELOG。
|
||||
|
||||
## 11. 开放问题
|
||||
|
||||
1. `tools/telemetry_retention.py` 是否需要覆盖"按 `tenant_id` 定向删除"(数据主体删除请求的实际形态)。本设计只做按时间清理;定向删除涉及"删哪些行由业务判断",偏向下游职责,暂不纳入。
|
||||
2. 触发器兜底模板是否纳入 README(本设计: 纳入,但明确标注它只防误操作)。
|
||||
3. Codex 提出"缺省不截断只解决了 issue 一半的默认安全诉求"——这是人类已定的 E-a 决策,不是疏漏,设计 §2 已显式记录取舍。作为补偿,README 须给出**合规下游的推荐配置**(cap + 分区 retention + 三角色)作为一段可直接照抄的组合,而不是把三件事散在各处让下游自己拼。
|
||||
@@ -0,0 +1,146 @@
|
||||
# issue #13 设计: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
|
||||
|
||||
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #13、#11(同源)、#9(探测纪律)、#3(补列由来)
|
||||
> 同批交付: [issue #12 遥测保留期与访问控制](2026-08-19-issue12-telemetry-retention-design.md)
|
||||
|
||||
## 1. 问题
|
||||
|
||||
两个遥测后端在构造期(SQLite)/首次写入前(PG)会对下游数据库发 DDL: 表不存在则 `CREATE TABLE`,表存在但缺列则逐列 `ALTER TABLE ... ADD COLUMN`。**补列没有任何开关**,库升级后首次调用即自动执行,而 issue #11 刚给这张表加了两列,这条路径的使用频率正在上升。
|
||||
|
||||
issue #13 的三条指控成立: ① 库在下游**生产**表上发不受控 DDL,与最小权限原则冲突; ② 多进程/多版本共存时谁先补列是竞态; ③ DDL 不进任何迁移记录,下游 DBA 事后无从审计表何时被谁改过。调研的 11 个同类先例(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中,**没有一个支持"库在下游库里自动 ALTER 出列"作为默认行为**。
|
||||
|
||||
### 1.1 issue 未区分、但决定方案形状的两点
|
||||
|
||||
**① SQLite 与 Postgres 的风险完全不对称。** issue 引用的全部先例(Hangfire 的锁队列雪崩、Prefect 的多实例竞态、Alembic 的 DBA 审计链)语境都是**共享的生产 PG**: `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联 await。本库的 SQLite 侧则是下游自己的本地文件(VT / CHSAnalyzer / dissect 的 `runs/*.db` 全是这个形态): 没有 DBA、没有迁移工具、没有第二个系统碰它,ALTER 是毫秒级元数据操作。让 SQLite 也要求"升级后手工跑一条 SQL",是给零运维场景强加运维步骤。两侧有意不对称在本库已有先例——`sqlite.py` 文件头写着"别为了代码对称把建表探测加回来"(issue #9)。
|
||||
|
||||
**② 关掉 ALTER 必须配套"按现有列裁剪 INSERT",否则是把自动补列换成静默全失能。** 今天 `_INSERT` 是 24 列的固定语句。旧表缺 `tenant_id` 时若不 ALTER,INSERT 会因未知列**全部失败** → 逐行 warning → 遥测彻底丢失。这比自动 ALTER 更严重地违反"遥测必录"。故降级写入不是可选增强,是本变更成立的前提。
|
||||
|
||||
## 2. 已定决策(人类,2026-08-19)
|
||||
|
||||
| # | 决策 | 选择 |
|
||||
|---|---|---|
|
||||
| D-a | 默认档 | **不对称**: PG 默认 manual(不 ALTER),SQLite 默认 auto(保持自动);同一配置项两侧均可覆盖 |
|
||||
| D-b | SQL 投放渠道 | warning 打印完整语句 **+** 新增公共函数供下游主动索取 |
|
||||
| D-c | 缺省规则落点 | **config 层派生**,recorder 的开关参数为 keyword-only **必填** |
|
||||
| D-d | 交付节奏 | 独立分支实现,与 issue #12 合并发 **1.2.3** |
|
||||
|
||||
## 3. 备选方案对比
|
||||
|
||||
| 方案 | 内容 | 权衡 | 结论 |
|
||||
|---|---|---|---|
|
||||
| **A(采纳)** | 按后端不对称默认 + 三态配置 + 裁剪写入 + schema SQL 公共函数 | PG 侧满足 issue 全部诉求;SQLite 侧零运维负担不变;代价是同一配置键在两后端缺省值不同,须文档讲清 | ✅ |
|
||||
| B | 两侧统一默认 manual | 语义最一致、最贴 issue 原文 | ❌ 现有 SQLite 下游(VT/CHS/dissect)升级即需人工干预,否则新维度静默缺失,而这些场景根本没有承接手工 SQL 的角色 |
|
||||
| C | 保持 auto 默认,只加关闭档 | 非破坏性 | ❌ 默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足,只是提供了绕法 |
|
||||
| D | Celery 式: 自动建表但**永不** ALTER,无开关 | 最简、无配置面 | ❌ SQLite 场景纯净损失;且下游若确实想要自动补列,库不给任何出路 |
|
||||
| E | APScheduler 4.x 式: schema 不认识就 `RuntimeError` 拒绝启动 | 最安全的一致性保证 | ❌ 与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选 |
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 配置与装配
|
||||
|
||||
新增环境键 `PGW_TELEMETRY_SCHEMA_MODE`,值域 `auto | manual`,**三态**: 未设 = 按后端派生,显式设置 = 两侧都可覆盖。
|
||||
|
||||
| 层 | 形态 | 理由 |
|
||||
|---|---|---|
|
||||
| 环境 | `PGW_TELEMETRY_SCHEMA_MODE`(可选键),经既有 `_load_choice` 校验值域 | 与 `PGW_LIMITER_BACKEND` 等同族 |
|
||||
| `GatewaySettings` | 新增字段 `telemetry_auto_migrate: bool`,**无默认值**(与既有全部字段一致) | settings 承载的是装配事实而非环境文本;派生只发生一次 |
|
||||
| recorder | `SQLiteRecorder(db_path, *, auto_migrate: bool)`、`PostgresRecorder(dsn, *, pool=None, auto_migrate: bool)`,keyword-only **必填** | D-c: 关键行为参数不给默认值(P4);缺省规则只写在 config 一处,不会与类签名漂移 |
|
||||
|
||||
`telemetry_backend=none` 时无 recorder 消费该字段,派生为 `False`。
|
||||
|
||||
### 4.2 行为矩阵
|
||||
|
||||
| 场景 | auto(今天的行为) | manual(新增) |
|
||||
|---|---|---|
|
||||
| 表不存在 | 建表 | **仍然建表** |
|
||||
| 表存在、列齐 | 不发任何 DDL | 不发任何 DDL |
|
||||
| 表存在、缺列 | 逐列 ALTER;失败只 warning,不判死 | **不发 DDL**;warning 逐列点名 + 打印可执行 SQL(仅一次);按现有列裁剪 INSERT 继续写入 |
|
||||
| 列探测失败 | warning,沿用全量 24 列 | warning,沿用全量 24 列 |
|
||||
|
||||
**manual 档为什么不连 `CREATE TABLE` 一起停**: issue 把建表列为现状描述而非指控(它已在 #3/#9 收口为"先探测后建")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。Celery 的先例同样是"自动建表 + 永不 ALTER"。
|
||||
|
||||
### 4.3 裁剪写入
|
||||
|
||||
`effective_columns = [c for c in COLUMNS if c in existing]`(保序),据此实例级构造 INSERT 语句,`record_llm_call` 按 `self._columns` 取值。SQLite 在 `__init__` 末尾定型,PG 在 `_prepare_schema` 成功后与 `_schema_ready` **一起**赋值(两者必须同时生效,否则会出现"已就绪但语句还是旧的"的窗口)。
|
||||
|
||||
缺列 warning 必须**逐列点名**并写明后果("以下维度不会被记录: tenant_id, meta"),不能只说"缺列"——静默丢维度的后果是多租户账目全归空串且无任何报错。warning 只在准备期发一次,不逐行。
|
||||
|
||||
`call_id` 若不在现有列内,说明该表不是本库的 `llm_calls`(下游魔改或撞名),warning 升级措辞并照常尝试写入(由数据库自己拒绝),库不做二次判定。
|
||||
|
||||
### 4.4 新公共函数(D-b)
|
||||
|
||||
```python
|
||||
polygateway.telemetry_schema_sql(backend: str) -> str
|
||||
```
|
||||
|
||||
返回可直接粘进迁移文件的完整脚本: 注释头 + `CREATE TABLE IF NOT EXISTS`(全量列) + 分隔注释 + 各补列语句(PG 用 `ADD COLUMN IF NOT EXISTS`;SQLite 无该语法,以注释标明"仅当列不存在时执行")。非法 `backend` 抛 `ValueError`(公共入口显式校验,先例同 issue #11 的维度校验)。
|
||||
|
||||
**这不是锦上添花而是正确性要求**: 打印的 SQL 必须与库真正执行的 DDL 同源。今天 `_DDL` / `_BACKFILL` / `_COLUMNS` 在 `sqlite.py` 与 `postgres.py` 各存一份,公共函数若再写一份,三份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。故新增 `telemetry/schema.py` 收敛为单一事实源,两个 recorder 与公共函数共用;顶层 `__init__` re-export 进 `__all__`。依赖方向不变(schema.py 在 telemetry 层内部,不 import 任何其他层),import-linter 契约无需改动。
|
||||
|
||||
### 4.5 Expand/Contract 成文化(零代码)
|
||||
|
||||
库已满足前三条,但从未文档化为承诺。本次写进 README 与 ARCHITECTURE §7.8: **新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`(库只写不读)、写入的冲突处理不绑定具体约束**。最后一条是 Codex 审查带出的**新增承诺**,见 §4.6。
|
||||
|
||||
它同时是 issue #12 分区方案能成立的前提——下游把 `llm_calls` 建成分区表后,库的 `to_regclass` 探测、列探测与 INSERT 路由都照常工作。
|
||||
|
||||
### 4.6 冲突目标改为无绑定(Codex 审查发现,阻断级)
|
||||
|
||||
PG 侧今天的写入是 `ON CONFLICT (call_id) DO NOTHING`,它要求一个恰好匹配 `(call_id)` 的唯一约束。而 PostgreSQL 要求分区表的唯一约束**必须包含分区键**——issue #12 的按 `created_at` 分区方案会把主键逼成 `(call_id, created_at)`,届时该语句**直接报错**,遥测在分区部署下全线写不进去。
|
||||
|
||||
改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 两种表形态都合法,普通表上与今天逐字等价(表上只有主键这一个唯一约束),SQLite 侧的 `INSERT OR IGNORE` 本就无目标、无需改动。
|
||||
|
||||
改动归属本 issue 而非 #12: 本 issue 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。分区部署下幂等语义的差异(缓存命中行复用历史 `call_id`)由 #12 的文档承接。
|
||||
|
||||
## 5. 旧版行为审计
|
||||
|
||||
| 既有行为 | 处置 |
|
||||
|---|---|
|
||||
| SQLite 构造期 `PRAGMA table_info` 探测 | 保留 |
|
||||
| SQLite 逐列独立 try、`duplicate column` 视为成功(多进程共库竞态) | 保留(auto 档) |
|
||||
| SQLite 补列失败只 warning、绝不清空 `_conn` | 保留 |
|
||||
| SQLite 不做建表前探测(issue #9 的有意不对称) | 保留 |
|
||||
| PG `to_regclass` 建表前探测(权限检查早于 IF NOT EXISTS) | 保留 |
|
||||
| PG `pg_attribute` 列探测(避开 `ADD COLUMN IF NOT EXISTS` 的排他锁) | 保留 |
|
||||
| PG 补列失败不置 `_failed`、探测失败只跳过本次下次重试 | 保留 |
|
||||
| 24 列模块级固定 INSERT 常量 | **替换**为按探测结果裁剪的实例语句 |
|
||||
| `_DDL`/`_BACKFILL`/`_COLUMNS` 两文件各一份 | **替换**为 `telemetry/schema.py` 单一事实源 |
|
||||
| 补列无开关、库升级即自动执行 | **替换**为 `schema_mode` 三态配置 |
|
||||
| PG `ON CONFLICT (call_id) DO NOTHING` | **替换**为无冲突目标的 `ON CONFLICT DO NOTHING`(§4.6);普通表上语义逐字等价 |
|
||||
| SQLite `INSERT OR IGNORE` | 保留(本就无冲突目标) |
|
||||
| 列序纪律(新列追加末尾) | 保留,并升格为文档化承诺 |
|
||||
|
||||
无有意放弃项。
|
||||
|
||||
## 6. 非功能维度
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 并发与取消 | DDL 与探测仍只发生在构造期(SQLite)/首次准备期(PG,由既有 `_init_lock` 串行);manual 档不发 DDL,多进程竞态面积**缩小**;裁剪是纯计算,不新增 await 点;PG 既有 `except asyncio.CancelledError: raise` 全部保留 |
|
||||
| 降级方向 | 遥测属静默降级档: 缺列 → 降级写入 + warning,**绝不判死、绝不报错**;与"限流/熔断后端不可用须报错"的方向差异不变 |
|
||||
| 幂等与重复 | 探测与裁剪是纯读,重复执行安全;auto 档 ALTER 经探测 + duplicate 容错幂等;`ON CONFLICT (call_id) DO NOTHING` / `INSERT OR IGNORE` 不受影响 |
|
||||
| 持久化与原子性 | 无跨行事务;单条 INSERT 原子;裁剪不触及主键 `call_id`,幂等键语义不变;部分写入不可能发生 |
|
||||
|
||||
## 7. 错误处理与测试策略
|
||||
|
||||
遥测路径的一切失败仍不落四分类、不冒泡;`telemetry_schema_sql` 的非法参数是公共入口校验,抛裸 `ValueError`。
|
||||
|
||||
| 层 | 用例 |
|
||||
|---|---|
|
||||
| unit(真实临时 SQLite) | manual + 22 列旧表 → `PRAGMA` 列数不变(证明未 ALTER)、INSERT 成功且能读回、warning 同时含缺列名与 ALTER 语句;auto + 22 列旧表 → 补列(现状回归) |
|
||||
| unit | `telemetry_schema_sql` 与 `COLUMNS` 同源(输出含全部列名且顺序一致)、非法 backend 报 `ValueError` |
|
||||
| unit | config 派生: 未设键 → sqlite `True` / postgres `False`;显式设置覆盖两侧;非法值报错;`backend=none` → `False` |
|
||||
| integration(真实 PG) | 无目标 `ON CONFLICT DO NOTHING` 在普通表上幂等(重复 `call_id` 只落一行)、在主键为 `(call_id, created_at)` 的分区表上写入成功 |
|
||||
| integration(真实 PG) | manual + 22 列旧表 → `information_schema` 断言无新列、写入成功、缺列不写;仅授 `SELECT, INSERT` 的角色在 manual 下不再产生 ALTER 失败 warning |
|
||||
|
||||
每条行为变更须有先失败后通过的证据(测试结果门)。
|
||||
|
||||
## 8. 兼容性、文档与发布
|
||||
|
||||
**破坏性**(CHANGELOG 须给"请先读这一条"待遇): ① PG 下游升级后不再自动补列,新列需手工执行(库会打印语句); ② 两个 recorder 新增 keyword-only 必填参数,直接构造的调用点需改(全库 35 处,除 `client.py` 的两处装配点外均在测试内); ③ `GatewaySettings` 新增必填字段,影响"构造函数全量注入"这条装配路。
|
||||
|
||||
文档同步: README(配置键、Expand/Contract 承诺、schema SQL 用法)、`.env.example`、ARCHITECTURE §7.8、Wiki `参考-配置键` / `参考-公共API` / `指南-遥测与成本`。
|
||||
|
||||
## 9. 开放问题
|
||||
|
||||
1. 目标版本 1.2.3 与 SemVer 的张力: 破坏性行为变更 + 新公共 API 通常走 minor。人类已定 1.2.3,发布时可再定。
|
||||
2. manual 档是否也该停 `CREATE TABLE`(本设计: 否,理由见 §4.2)。
|
||||
@@ -0,0 +1,227 @@
|
||||
# 熔断拒绝补齐等待档: 把"源不健康"与"调用判死"解耦
|
||||
|
||||
- **issue**: #14(dissect,单源第三方中转部署)
|
||||
- **核查基准**: HEAD 1.2.3;issue 按 1.2.1 提交,逐条复核后**全部仍然成立**(`backends/memory/breaker.py` md5 `630ed36ddeb87e08a9bac58260056046`,1.0.6→1.2.3 逐字节未变)
|
||||
- **状态**: 人类已确认(2026-08-19);经 Codex 审查修正(2026-08-19,修正点见 §3.1/§3.4/§3.5/§6 标注),待实施
|
||||
|
||||
## 1. 问题的真实形状
|
||||
|
||||
issue 把问题命名为"单源 scope 下熔断等于整体停服"。这个命名会把方案引向错误的方向——**单源不是病因,是让病灶 100% 复现的放大器**。三条独立缺陷叠加成了现场那 30 次瞬死,必须分开命名才修得干净。
|
||||
|
||||
### 1.1 缺陷一: 准入策略矩阵缺了一格
|
||||
|
||||
`_pick_runnable` 有四种"拒绝",库对它们的处置并不对称:
|
||||
|
||||
| 拒绝原因 | 计入 `gate_rejections` | 全被拒时的处置 | 可配? |
|
||||
|---|---|---|---|
|
||||
| `rate_limited`(permit 拿不到) | 否 | 走 `quota_full` 分支 | **是**(`wait`/`fail_fast`) |
|
||||
| `adaptive_paced`(AIMD 超限) | 否 | 走 `quota_full` 分支 | **是**(同上) |
|
||||
| `circuit_open`(熔断门拒) | 是 | 当场抛 `CircuitOpenError` | **否** |
|
||||
| `cooldown`(源冷却备忘) | 是 | 同上 | **否** |
|
||||
|
||||
限流闸满时库不判死、允许排队(`quota_full=wait`,缺省);熔断门拒时库**只有 fail-fast 一档且不可配**。两者在准入语义上完全同构(都不发请求、都带 `retry_after` 提示),处置却分叉。
|
||||
|
||||
**这一格的缺失与源数量无关**:多源全部同时开路(共同上游的中转挂了、一次全网抖动)时行为一模一样。单源只是把"全部开路"的概率从"罕见"变成"必然"。因此**任何形态的单源特判(`if len(sources) == 1`)都是错的**——它会让行为随池大小突变、无法组合测试,是比现状更重的债。
|
||||
|
||||
### 1.2 缺陷二: `retry_after_s` 在 HALF_OPEN 下返回了一个物理上无意义的数
|
||||
|
||||
`try_enter` 在 HALF_OPEN 拒绝时返回 `probe_expires - now`,即**探针租约的剩余时长**。而 `probe_ttl_s` 派生自 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)`(`config.py:400-407`),现场 `TIMEOUT_S=300` ⇒ **600 秒**,而冷却期只有 60 秒。
|
||||
|
||||
探针租约的长度回答的是"探针最长可以占用这个名额多久"(死锁保护参数),与"这个源多久能恢复"没有任何因果关系。两个后端同款(`backends/redis/breaker.py` 的 `TRY_ENTER`/`RETRY_AFTER` 两个 Lua 均返回 `probe_until - now`)。
|
||||
|
||||
### 1.3 缺陷三(issue 未发现,伤害最重): 恢复了的源被本进程屏蔽整个探针租约
|
||||
|
||||
缺陷二的值被喂进了源冷却备忘:
|
||||
|
||||
```text
|
||||
retry.py:354 self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
sources.py:139 self._until[name] = max(已有, until) # 取更晚者,不可回退
|
||||
```
|
||||
|
||||
于是:源 A 冷却到期 → 调用 1 拿到探针 → 并发的调用 2 被拒、拿到 600 → **给 A 记 600 秒本地冷却** → 调用 1 的探针成功、门恢复 CLOSED → **本进程此后 600 秒仍然跳过 A**,且 `reasons[A]="cooldown"` 计入 `gate_rejections`,单源下每次调用照旧抛 `CircuitOpenError`。
|
||||
|
||||
实测复现(`InMemoryGate` + 注入时钟,`cooldown_s=60`、`probe_ttl_s=600`):
|
||||
|
||||
```text
|
||||
B 决定: allowed=False state=half_open retry_after_s=600.0 <- 冷却只有 60s
|
||||
B 给 s1 记的本地冷却剩余: 600.0 秒
|
||||
探针成功后门 state: closed
|
||||
门已 CLOSED,memo.active('s1') = True
|
||||
再过 120 秒(远超 60s 冷却)memo.active = True 剩余 480.0 秒
|
||||
```
|
||||
|
||||
**这条与源数量、与是否单源都无关**:多源部署里,一个源每开路一次就会被本进程从池中除名 `probe_ttl_s`(可达 2 × timeout),池子越大越难被观测到,因为别的源接住了流量。现场那"30 次瞬死横跨 20 秒"里有多少来自这一条无法反推,但机制确凿。
|
||||
|
||||
## 2. 备选方案与否决理由
|
||||
|
||||
issue 给了 A/B/C/D 四条。逐条判:
|
||||
|
||||
| 方案 | 判定 | 理由 |
|
||||
|---|---|---|
|
||||
| A `PGW_BREAKER_BACKEND=noop` | **否决** | 关掉的是"保护"(401/403/配额耗尽的一击即熔一并失效,坏密钥持续撞墙),而诉求是"别当场判死"。且开了"治理组件可整个关掉"的先例,限流迟早跟进。三条缺陷一条都不解决 |
|
||||
| B `{SCOPE}__CIRCUIT_OPEN=wait\|fail_fast` | **采纳为主干** | 与 `quota_full` 严格同构,补的正是 §1.1 那一格。但 issue 版的 B 未答"wait 档等多久",而这个答案依赖 C |
|
||||
| C 修 HALF_OPEN 的 `retry_after_s` | **采纳,且不是"治标"** | issue 把它列为"可并行的小修"。实际上它是 B 的**前提**:wait 档要按 `retry_after` 睡,睡一个 600 秒的假数就是新事故。它还是 §1.3 的病根 |
|
||||
| D 只写文档 | **否决** | 把配置项的副作用固化成公开契约,将来动阈值逻辑即破坏;且解决不了 `force_open` |
|
||||
|
||||
**方案 = B + C,合并为一件事**:B 依赖 C 的正确性,C 修完 §1.3 自动消失。
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 `retry_after_s` 的契约定死为"确定的最早可尝试时刻"
|
||||
|
||||
| 门状态 | 返回值 | 依据 |
|
||||
|---|---|---|
|
||||
| CLOSED | `0.0` | 现状,不变 |
|
||||
| OPEN | `open_until - now` | 现状,不变。冷却截止是确定时刻 |
|
||||
| HALF_OPEN(被拒) | **`0.0`** | 探针随时可能出结果,**不存在**确定的等待时刻 |
|
||||
|
||||
`0.0` 不是新约定:`errors.py` 早已定义 `retry_after_s` 的 `0 = 可立即重试`,契约测试 `test_retry_after_semantics` 也以"健康 → 0、冷却到期 → 0"钉着这个语义。HALF_OPEN 归入"无确定等待"是同一语义的自然延伸,而非发明。
|
||||
|
||||
信息不丢失:`GateDecision.state` 已经携带 `HALF_OPEN`,调用方要区分"门闭着"与"探针在途"照样能区分。
|
||||
|
||||
**惊群由既有机制承担,不由这个数承担**:门自身的单探针租约保证第二个 caller 拿不到名额;wait 档的复查间隔由 middleware 的 `poll_interval_s` 抖动睡眠承担(§3.3)。
|
||||
|
||||
**§1.3 随之闭合**:`set_until(now + 0.0)` 写入一个已过期的截止时刻,`active()` 恒 False——HALF_OPEN 拒绝自此不再污染备忘,无需在 `retry.py` 加任何状态分支。备忘回归它唯一正当的用途:**记 OPEN 的确定冷却期**。
|
||||
|
||||
**准入被允许时恒 `0.0`**:`allowed=True` 意味着现在就能试,这个字段没有别的合理取值。
|
||||
|
||||
**改动面是五个出口,不是两个(Codex 审查修正)**。原稿只点了 `try_enter` 与 `retry_after_s()`,漏了 `GateUpdate` 那一侧;逐一核实后发现**两个后端在这两处本就已经分叉**——本 issue 的病根正是"`retry_after_s` 语义从未被定死,于是各后端各自发挥",不一并收口就是定了新契约却留两个后端不遵守:
|
||||
|
||||
| 出口 | memory 现状 | redis 现状 | 统一为 |
|
||||
|---|---|---|---|
|
||||
| `try_enter` 拒绝(OPEN) | `open_until - now` | 同 | 不变 |
|
||||
| `try_enter` 拒绝(HALF_OPEN) | `probe_expires - now` | `probe_until - now` | **`0.0`** |
|
||||
| `try_enter` **授予探针** | `0.0`(`memory:114`) | **`probe_ttl_ms`**(`redis:53`) | **`0.0`**(redis 侧改) |
|
||||
| `GateUpdate`(fencing 未命中,HALF_OPEN) | `0.0`(`memory:175-177` 非 OPEN 一律 0) | **`probe_until - now`**(`redis:127/158/258`) | **`0.0`**(redis 侧三处改) |
|
||||
| `retry_after_s()` 跨源取 min | HALF_OPEN 记 `probe_expires - now` | 同 | **HALF_OPEN 记 `0.0`** |
|
||||
|
||||
后两行是**既有缺陷**,与本 issue 同源、由契约测试盲区掩护至今(现有用例只钉"第二个进入者被拒",没钉它拿到什么数)。同源缺陷一并修,不作为独立议题。
|
||||
|
||||
memory 侧抽 `_remaining(g)` 私有纯方法供三处共用;redis 侧四个 Lua(`TRY_ENTER`/`RECORD_SUCCESS`/`RECORD_FAILURE`/`RELEASE_PROBE`)与 `RETRY_AFTER` 各改一处(Lua 无法共享函数,这是既有约束,`_WINDOW_HELPERS` 已是同款处理),由同一批双后端参数化契约用例锁死。
|
||||
|
||||
### 3.2 新配置键 `{SCOPE}__CIRCUIT_OPEN`
|
||||
|
||||
与 `quota_full` 逐项对齐,不发明新形状:
|
||||
|
||||
| 维度 | `quota_full`(既有) | `circuit_open`(新增) |
|
||||
|---|---|---|
|
||||
| 合法域 | `_QUOTA_FULL = {"wait","fail_fast"}` | `_CIRCUIT_OPEN = {"wait","fail_fast"}` |
|
||||
| 缺省 | `wait` | **`fail_fast`**(见 §3.5) |
|
||||
| env 键 | `{SCOPE}__QUOTA_FULL` | `{SCOPE}__CIRCUIT_OPEN` |
|
||||
| 装配 | settings → `GatewayClient` → 三条循环 | 同 |
|
||||
| 校验 | `_validate_backends` 表驱动 + 构造期 | 同(各加一行) |
|
||||
|
||||
改动面: `config.py`(常量 / 字段 / 校验元组 / `from_env` 各一行)、`client.py`(签名 + 透传各一处)、`SourceAdmission`(§3.4)一处。
|
||||
|
||||
### 3.3 `_on_no_runnable` 的控制流
|
||||
|
||||
现状两个分支是**串行**的。今天走不到那个坑(没有 wait 档,第一分支必抛),但**只要把第一分支改成"wait 时不抛"就会立刻踩中**:控制流会往下掉进 `quota_full` 分支,`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`。必须改成按拒绝原因分派:
|
||||
|
||||
```text
|
||||
if gate_rejections == len(sources): # 全部因熔断类原因被拒
|
||||
if circuit_open == "fail_fast": raise CircuitOpenError(retry_after=gate.retry_after_s(names))
|
||||
hint = await gate.retry_after_s(names) # OPEN 有确定值;全 HALF_OPEN 得 0
|
||||
else: # 至少一源是被配额/AIMD 挡的
|
||||
if quota_full == "fail_fast": raise AllSourcesExhausted("quota_exhausted")
|
||||
hint = 0.0
|
||||
if await self._stalled(clock): raise AllSourcesExhausted("stalled", ...)
|
||||
await self._sleep(self._nap(hint, clock))
|
||||
```
|
||||
|
||||
睡眠时长 `_nap(hint, clock)`,三条约束同时满足:
|
||||
|
||||
| 约束 | 实现 | 理由 |
|
||||
|---|---|---|
|
||||
| 不空转 | `hint > 0` 时睡到冷却结束再加抖动,而非 50ms 轮询 | 60 秒冷却下,`poll_interval=0.05` 会产生 1200 次无谓复查;memory 后端只是字典查询,**redis 后端是 1200 次往返 × 每个在途调用** |
|
||||
| 不白醒 | 抖动**上**加(`hint + poll_interval × (0.5+0.5×rng)`),不缩放 | 对一个确定的截止时刻提前醒必然被再拒一次 |
|
||||
| 等待有可解释上界 | 夹到剩余 stall 预算:`min(睡眠, stall_window - clock.stalled_s())`,下界 `poll_interval` | 最迟在 stall 窗口耗尽那一刻醒来判死,单次调用最坏墙钟 = `stall_window_s`(缺省 300s),不随 `max_cooldown_s` 漂移 |
|
||||
|
||||
`hint = 0` 时该式退化为现有的 `poll_interval × (0.5+0.5×rng)`,配额等待路径逐字不变。
|
||||
|
||||
**计时归属无需改动**:这段睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3 "熔断冷却属非生产性等待"的既定口径一致。
|
||||
|
||||
### 3.4 前置收敛: 准入逻辑三处复制归一
|
||||
|
||||
`_pick_runnable` / `_on_no_runnable` 目前在 `middleware/retry.py`、`embedding.py`、`ocr.py` **各有一份**,后两份是第一份的逐字子集(少 AIMD pacer 与调用内降权)。若只改 chat 一处,embedding/ocr 就成了行为分叉的角落——**那才是本次真正会留下的技术债**(CLAUDE.md 铁律痛斥的"三项目 4 处复制"的库内同款)。
|
||||
|
||||
抽 `middleware/admission.py::SourceAdmission`,持有 sources/selector/QuotaGate/BreakerGate/memo/backpressure/两个策略键/时钟三件套,暴露 `pick()` 与 `on_no_runnable()`。三条循环的差异用注入表达,不留分支:
|
||||
|
||||
| 差异 | 处理 | 行为等价性 |
|
||||
|---|---|---|
|
||||
| 调用内降权(仅 chat) | `attempt_fails` 作 `pick()` 入参 | embedding/ocr 传空 dict 时 `_demote_call_failures` 恒等返回原序(`demoted` 为空即 `return ordered`) |
|
||||
| AIMD pacer(仅 chat) | `pacer: AdaptivePacer \| None = None` | None 时跳过 `admit`/`enter`,无副作用 |
|
||||
| `_settle_and_release` 三份复制 | 提为 `middleware/` 模块级 async 函数 | chat/embedding 签名为 `(permit, actual)`,**OCR 为 `(permit)` 且体内恒 `settle(0)`**(`ocr.py:438`,Codex 审查补)。OCR 侧改为传 `0`,逐字等价;唯一可见变化是 warning 文案由"OCR permit 结算/释放失败"归一 |
|
||||
|
||||
已逐字 diff 核实(`embedding` 与 `ocr` 两份**完全相同**;chat 多出的只有上表三类)。另有两处**不在抽取边界内**、须原样保留:chat 主循环顶部额外的一次 `_stalled` 预判(`retry.py:286`),以及 OCR 的健康喂数——它们属于各自的主循环与 `_attempt`,本次一行不动。
|
||||
|
||||
**这不是任务外重构**:修复本来就必须落在这三处,"改三遍"与"抽一份改一遍"工作量相当而后者才符合 P7;且这是既有方向的延续——`StallClock` 与 `backoff_delay` 已按同一原则收敛为共享单元(ARCH §7.3)。边界严格限定在准入与无源可跑的处置,**`_attempt` 一行不动**(三者差异大: 流式 / 批 / 图)。
|
||||
|
||||
执行分两个提交:①纯重构,验收标准是全套件逐字绿、无行为变更;②在单一位置加语义。①先行以保回滚点。
|
||||
|
||||
### 3.5 缺省值取 `fail_fast`
|
||||
|
||||
`quota_full` 缺省 `wait`,但 `circuit_open` **不跟随**,理由是变更方向的危险性不对称:
|
||||
|
||||
| 取值 | 对存量下游的影响 |
|
||||
|---|---|
|
||||
| `fail_fast`(采纳) | **控制流**逐字不变(全源被熔断拒仍当场抛 `CircuitOpenError`) |
|
||||
| `wait` | 把所有人的最坏墙钟从毫秒抬到 `stall_window_s`,且是"快速失败 → 长时间挂起"这个最危险的方向 |
|
||||
|
||||
issue 的诉求本身也不是改默认值,而是**表达能力**——其 §2.3 的原话是"库对这两种情形用的是同一套默认值、且**不允许调用方表达自己属于哪一种**"。多源下 fail-fast 确实是对的(换源比等待快),单源下调用方显式配 `wait` 即可。README 与 wiki 需明写"单源 scope 建议配 `wait`"。
|
||||
|
||||
### 3.6 `errors.py` 的职责边界补写
|
||||
|
||||
issue 要求修订 `GatewayUnavailableError` 那句"业务侧 catch 本类做延期重投"——它读起来像在鼓励每个下游各写一份重试逻辑。改为明确边界:调用级的重试/退避/换源/等待**全部在库内**,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是**任务级重试**,语义与调用级重试不同。
|
||||
|
||||
这不是新决策,是把 ARCH §7.2 已经写明的"单层重试原则"补进 docstring。零代码风险。
|
||||
|
||||
**"缺省档零感知"须诚实收窄(Codex 审查修正)**: 缺省档保证的是**控制流**不变,不是零可见变更。`retry_after_s` 的语义修正在缺省档下同样生效——全源 HALF_OPEN 时 `CircuitOpenError.retry_after_s` 由"探针租约剩余"变为 `0.0`,而它是公开字段(`errors.py:118`)。这正是本次记 **1.3.0** 而非补丁号、且 CHANGELOG 需"请先读这一条"待遇的原因。另需注意 `GatewaySettings` 全部字段均无默认值(既有风格),新增 `circuit_open` 沿用之,直接构造该类的调用方须补一个参数。
|
||||
|
||||
## 4. 行为矩阵
|
||||
|
||||
| 场景 | `fail_fast`(缺省,= 现状) | `wait` |
|
||||
|---|---|---|
|
||||
| 单源 OPEN,冷却 60s | 立即 `CircuitOpenError(retry_after=剩余冷却)` | 睡到冷却结束(夹在 stall 预算内)→ 探针 → 成功即返回 |
|
||||
| 单源 `force_open`(401/403) | 立即失败 | 等 60 → 探针又 401(**烧掉一格 `max_attempts`**)→ 等 120 → …… 以**先耗尽的那个预算**的 reason 失败: `max_attempts` 先尽则 `retry_exhausted`,冷却累计超过 stall 预算则 `stalled`。**代价须进文档** |
|
||||
| 多源部分开路 | 不变(有源可跑就不进这个分支) | 不变 |
|
||||
| 多源全部开路 | 立即失败 | 等最早恢复的那个源(`retry_after_s` 取 min) |
|
||||
| 全部 HALF_OPEN(探针在途) | `CircuitOpenError(retry_after=0)`,语义准确(随时可能好) | `poll_interval` 抖动复查,秒级拿到探针结果 |
|
||||
| 配额满 / AIMD 超限 | 归 `quota_full` 管,逐字不变 | 逐字不变 |
|
||||
|
||||
## 5. 测试策略
|
||||
|
||||
行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。分三层:
|
||||
|
||||
**契约层**(`tests/contracts/test_breaker_contract.py`,双后端参数化自动覆盖 memory + redis):
|
||||
按 §3.1 那张表**逐个出口**钉——HALF_OPEN 被拒、授予探针、`GateUpdate` fencing 未命中、`retry_after_s()` 探针在途,四处均须 `== 0.0`;OPEN 语义不变(现有 `test_retry_after_semantics` 保持绿)。现有用例只钉了"第二个进入者被拒",没钉它拿到什么数,正是这个盲区放过了两处双后端分叉。Redis 侧依赖时间快进的变体在契约层会 skip,须同步补 `tests/integration/test_redis_governance_time.py` 的真实等待变体(既有约定,不缩放时长)。
|
||||
|
||||
**单元层**(`tests/unit/test_backpressure.py` 邻域,注入时钟/睡眠/rng):
|
||||
§1.3 的回归钉子——探针成功后备忘不再屏蔽该源(直接由 §3.1 的复现脚本转化);`circuit_open=wait` 下全源开路不抛 `CircuitOpenError` 而按 `retry_after` 睡;`wait` + `quota_full=fail_fast` 组合下熔断等待**不**被误报成 `quota_exhausted`(§3.3 那个坑的钉子);`wait` 档最坏墙钟 ≤ `stall_window_s` 且判死 reason 为 `stalled`、`per_source_reasons` 含 `circuit_open`;`fail_fast` 缺省下全部现有用例逐字绿。
|
||||
|
||||
**收敛层**: §3.4 的重构提交以"三条循环现有测试全绿、零新增用例"为验收——有新增用例即说明行为被动了。
|
||||
|
||||
## 6. 非功能与已知取舍
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 取消穿透 | `_nap` 的长睡眠是 `await self._sleep(...)`,`CancelledError` 逐字穿透;无新增 finally 资源 |
|
||||
| 后端往返 | wait 档每个冷却周期约 1 次 gate 查询(vs. `poll_interval` 轮询的 1200 次),Redis 压力低于按现状实现的朴素 wait |
|
||||
| 遥测 | **不加列**。wait 等待期不发请求,无 attempt 行可记;调用级总等待下游可自测。进入/退出等待各打一条 `logger.info`(scope、per-source reasons、预计等待),使"等了多久"可从日志还原 |
|
||||
| 等待上界的精确值 | `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`,Codex 审查补)。睡眠恰好夹到剩余预算时,醒来 `stalled_s()` 等于窗口而不大于,不判死。故 `_nap` 夹到 `剩余预算 + poll_interval_s`,一次到位;最坏墙钟精确表述为 `stall_window_s + 一个 poll 间隔`,不是"恰好 stall_window_s" |
|
||||
| 备忘的跨进程滞后 | 本进程记了 OPEN 冷却后,即便别的进程的探针已把共享门关回 CLOSED,本进程仍会跳到本地备忘自然过期(`_pick_runnable` 先查备忘再问门)。这是备忘"以本地记录换 Redis 往返"的固有代价,误差有界(≤ 一个 cooldown),**既有性质、本次不改**;备忘是进程内存,无持久化,故不存在滚动升级残留 |
|
||||
| 无限等待 | `_stalled` 是双条件合取,同 scope 其他调用仍在出餐时本调用不判死(ARCH §7.3 已承认的残余性质)。单源全开路时无人出餐,条件 B 必然成立,会判死;多源部分开路则走不到这个分支。文档沿用既有措辞:需要硬上限的调用方自行 `asyncio.wait_for` |
|
||||
| 未解决 | `force_open` 在 wait 档下把坏密钥的失败从毫秒拖长(上限 stall 窗口)。**有意不特判**——库无法区分"密钥坏了"与"中转抖了",选 `wait` 即声明"宁可等也不当场死" |
|
||||
| 两个预算并行(整分支审查发现,2026-08-20) | `wait` **不豁免重试预算**: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `max_attempts`(issue #8 的划分依据是"谁消耗重试预算",探针发出了真实请求,理应记在重试预算上)。故 force_open 的源常以 `retry_exhausted` 而非 `stalled` 结束。原稿 §4 只写了 stall 一种结局,已更正;由 `test_wait_does_not_exempt_probes_from_the_retry_budget` 钉住 |
|
||||
|
||||
## 7. 文档与发布
|
||||
|
||||
ARCH §7.4 增补本次决策与三条缺陷的成因;§9 配置面登记新键;README 能力表与配置表;Gitea wiki 按 `docs-convention.md` §2 同步;CHANGELOG 记为 **1.3.0**(新增配置键 + `retry_after_s` 语义变更,后者对下游可见,需"请先读这一条"待遇)。
|
||||
|
||||
`GateDecision` 的字段与 `ProviderGate` 端口签名**均不变**,故不触碰迁移兼容约束(ARCH §5.1)。
|
||||
|
||||
## 8. 已定决策(人类,2026-08-19)
|
||||
|
||||
| # | 决策 | 随之固定的实施边界 |
|
||||
|---|---|---|
|
||||
| 1 | 缺省取 **`fail_fast`**(§3.5) | 存量下游零感知;issue 提交方需自行加 `{SCOPE}__CIRCUIT_OPEN=wait`。README/wiki 必须明写"单源 scope 建议配 wait",否则这个开关等于不存在 |
|
||||
| 2 | §3.4 的三处收敛**本次一并做** | 拆为独立前置提交,验收标准是"全套件绿 + 零新增用例";该提交即回滚点 |
|
||||
@@ -0,0 +1,282 @@
|
||||
# 遥测连接池的资源语义与生命周期: 从"预占 10 条"到"按需 0 条"
|
||||
|
||||
- **issue**: #15(共享 PostgreSQL 实例,`max_connections=100`,多 worker × 多 scope 部署)
|
||||
- **核查基准**: HEAD 1.2.4。issue 按 1.1.2 运行环境提交并已自行复核 1.2.4,本文逐条重核**全部成立**: `postgres.py:100`(建池不传 min/max)、`postgres.py:106`(建池失败即永久判死)、`postgres.py:246-251`(`aclose` 不清 `_failed`)、`client.py:405-420`(每个 client 各 new 一个 recorder)。`min_size`/`max_size` 在整个包内**一次都没出现过**。
|
||||
- **状态**: **已实施**(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`,T0–T7 见实现计划末尾的提交表)。人类已确认方案与全部四组改动 + 缺省值;**Codex 已审,7 条全部处置完毕(§9)**;实施期的三处修订以 §10 标注
|
||||
- **实测环境**: asyncpg 0.31.0;真实实验室 PG(`polygateway` 专用库,跨内网 RTT ≈ 123ms)
|
||||
|
||||
## 1. 问题的真实形状
|
||||
|
||||
issue 把问题命名为"asyncpg 默认 `min_size=10` 太大"。这个命名会把方案引向"改个默认值"。实际是**四层缺陷叠加**,只改默认值会留下三层,且下一次换个瞬时错误(PG 重启、DNS 抖动)照样全量失遥测。必须分开命名。
|
||||
|
||||
### 1.1 前提实测: `min_size` 的语义是"预连接",不是"下限"
|
||||
|
||||
asyncpg `pool.py:457` 是 `if self._minsize:` ——为 0 时 `_initialize` 只创建 holder 对象,**一条连接都不连**。由此实测得到本设计的全部地基:
|
||||
|
||||
| 实测项 | `min_size=0, max_size=2` | 默认 `10/10`(现状) |
|
||||
|---|---|---|
|
||||
| 建池指向**不可达**端口 | **立即成功**,0.000s,`size=0` | 立即抛 `ConnectionRefusedError` ← **issue 的失败点** |
|
||||
| 建池连真实库 | 0.000s,`size=0` | 0.72s,**10 条常驻** |
|
||||
| 首次写入 / 稳态写入 | 513ms(含建连 ≈390ms)/ **123ms**(一次 RTT) | 同(稳态无差异) |
|
||||
| `acquire` 失败后再 `acquire` | 照常重试,池不进坏状态 | — |
|
||||
| 空闲超 `max_inactive_connection_lifetime` | 连接归 0,下次写入重连 | 同 |
|
||||
|
||||
**关键推论**: `min_size=0` 不只是"调小",它把建池从一次全有全无的重资源动作变成**零成本、不触库**的动作。这一步走出去,后面三层的性质全变。
|
||||
|
||||
**实施后在同一台真实实验室 PG 上的复测(T6,按唯一 `application_name` 过滤 `pg_stat_activity`)**,是全套证据里最直观的一条: 修复前建完 recorder 即 **10** 条连接;修复后 **0**(建 recorder)→ **1**(一次写入)→ **4**(20 行并发,恰为 `pool_max`)→ **0**(`aclose` 后)。四个数字逐一对应上表的四行推论。
|
||||
|
||||
### 1.2 缺陷一: 库对自己的资源占用从未表态——而这是全库唯一一处
|
||||
|
||||
`create_pool(self._dsn, timeout=10)` 继承第三方默认值(P4/P5: 默认参数掩盖关键逻辑)。横向扫过库内每一处外部资源:
|
||||
|
||||
| 组件 | 建连方式 | 上限 | 预占? |
|
||||
|---|---|---|---|
|
||||
| httpx transport(`openai_compat.py:301`) | 按需 | 100(httpx 缺省) | 否 |
|
||||
| RedisLimiter / RedisGate / RedisCache | 按需 | 无上限(redis-py 缺省) | 否 |
|
||||
| **PostgresRecorder** | **预占 10 条,否则建池失败** | 10 | **是** |
|
||||
|
||||
**库内每一处外部资源都是按需建立,唯独遥测池预占**。issue 那句"业务侧一条一条按需要,这个池要么一次拿到 10 条、要么建池失败,所以余量紧张时先倒下的必然是它"完全正确——它是链路上最脆的一环,承担的却是最不该悄悄失败的职责。issue 现场规模: 4 client × 10 = **40 条常驻专用于写遥测**,而实际写入并发是个位数。
|
||||
|
||||
### 1.3 缺陷二: 判死判据挂在"哪一步失败",而非"失败是什么性质"
|
||||
|
||||
issue #9 已把判死收窄为"确定写不进去",但漏了一格: `_open_pool` 这一步里**同时藏着两类失败**——DSN 本身非法(进程内不可能改变)与 `too many clients` / 网络抖动(外部状态,随时可能好)。因为 `min_size=10` 让瞬时错误**发生在建池这一步**,它就被 `postgres.py:106` 一刀切成了永久判死。
|
||||
|
||||
判据错位的证据: `postgres.py:104-105` 的注释"池建不出来 = 确定写不进去"——这句话在 `min_size=10` 下是**假的**(连接耗尽不是确定写不进去,是这一秒写不进去);在 `min_size=0` 下才为真。**注释描述的是设计意图,代码实现的是另一件事**,中间的差额就是这次事故。
|
||||
|
||||
### 1.4 缺陷三: 降级不可恢复,且不可见
|
||||
|
||||
| 性质 | 现状 | 后果 |
|
||||
|---|---|---|
|
||||
| 不可恢复 | `_failed` 置位后无任何恢复路径;`aclose()`(`postgres.py:246-251`)只清 `_schema_ready` **不清 `_failed`** | 只有进程重启能恢复 |
|
||||
| 不可见 | 全程只有**一条** warning(`postgres.py:107`) | 长跑进程里等同于静默 |
|
||||
|
||||
issue 是**手工对账**(日志里的完成里程碑条数 vs `llm_calls` 行数)才发现的,期间 19 次调用一行未落、成本少记约 $5。这就是"遥测必录"铁律的实质破口: 库做不到必录时,必须**持续、可编程地**让下游知道。SQLite 侧更糟——`sqlite.py:138-139` 初始化失败后写入直接 `return`,**连 warning 都没有**。
|
||||
|
||||
### 1.5 缺陷四: 共享路径是坏的,所以每个 client 只能各占一份
|
||||
|
||||
issue 建议"让指向同一 DSN 的多个 recorder 共享一个池"。这条路今天走不通,而且不通的原因是一个**跨组件的所有权纪律缺口**:
|
||||
|
||||
| 现象 | 位置 | 性质 |
|
||||
|---|---|---|
|
||||
| `GatewayClient.aclose()` 无条件关掉**注入的** telemetry → 共享 recorder 被第一个关闭的 client 弄死 | `client.py:271-273`(`embedding.py:455-461`、`ocr.py:465-467` 各有一份复制) | 越权 |
|
||||
| `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 |
|
||||
| `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不碰 limiter/breaker) | `client.py:263-280` | **泄漏** |
|
||||
| 对照组: `RedisLimiter._owns_client` 纪律**是对的** | `limiter.py:185-191, 318-322` | 正确先例 |
|
||||
|
||||
**实施期挖出的第四个现象(T4,本设计原稿未预见)**: 注入外部池时,`aclose()` 之后的下一次写入会拿 DSN **偷偷自建一个池**——注入方以为自己管着全部连接,实际早已不是。它与上表三条同一根因(库不区分"这个资源是谁的"),只是表现在**关闭之后**而非关闭当时,故原稿按"谁关谁的"扫一遍时没看见。修法归入 §3.2 第 4 点的"关了就是关了": 置 `_closed` 后写入短路且不复活。
|
||||
|
||||
三个现象一个根因: **库对"谁建的、谁负责关"没有统一纪律**。ARCH §7.7 R5 规定"共享必须显式注入",但显式注入这条正道今天是坏的,下游只能退回"每 client 各占一份"——缺陷一的放大器由此长在架构里,而不是长在某个默认值里。
|
||||
|
||||
## 2. 备选方案与否决理由
|
||||
|
||||
| 备选 | 否决理由 |
|
||||
|---|---|
|
||||
| 只把默认值调小(issue 方向 1 单独做) | 脆点消失,但 §1.3 的判据错位仍在: 下次 PG 重启/DNS 抖动落在准备期,照样永久失能。治标 |
|
||||
| 只加建池退避重试(issue 方向 3 单独做) | 在错的地方加复杂度。`min_size=0` 之后建池已不触库,**没有可重试的失败**;真正需要重试的是 acquire,而那里本来就有正确行为 |
|
||||
| 隐式全局池注册表(DSN → 共享池) | 违反"纯 asyncio 中立: 无全局状态、无模块级单例"铁律,且解决的是 `min_size=0` 之后已不存在的问题(闲时占 0) |
|
||||
| 暴露 `min_size` 配置项 | 它唯一的作用是把脆点装回来,换取首次 390ms。库没有理由提供一个只会伤人的旋钮(P1+P5) |
|
||||
| 遥测改异步队列 + 后台 flush | 真正彻底消除"遥测拖慢业务",但引入进程崩溃时的丢数据窗口——与遥测被下游当**审计证据**用(§7.8/issue #12 决策 E-a)正面冲突;还要背负后台任务生命周期与背压策略。重大架构变更,不在本 issue 换取的收益内 |
|
||||
| 把遥测失败塞进 `errors.py` 四分类 | 四分类的语义是"决定重试/换源/熔断"(ARCH §5.1)。遥测失败既不冒泡也不参与那套决策,塞进去会污染分类语义。改为在遥测子系统内定义自己的三分,收敛在一处(§3.2) |
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 A 组 · 池语义: 显式声明,按需建连
|
||||
|
||||
`create_pool(dsn, min_size=0, max_size=<配置>, timeout=<写入预算>, command_timeout=<写入预算>)`。
|
||||
|
||||
- **只暴露 `max_size`**(理由见 §2)。稳态占用从"40 条常驻"变成"实际并发,闲时 0"。
|
||||
- 整次写入(`_ensure_ready` + `acquire` + `execute`)由 `asyncio.timeout` 包一层**硬预算**,超时按行级丢弃。这把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证。
|
||||
- `acquire` 必须显式传 timeout。今天 `postgres.py:238` 的 `pool.acquire()` **无超时**(asyncpg 缺省 `timeout=None` = 无限等待),池满时会无限期挂在业务路径上——现状因 `max_size=10` 而未暴露,`max_size=4` 后必须补齐。
|
||||
- **不得用 `async with pool.acquire(...)`(Codex 审查,2026-08-24,已核实)**。`Pool.release()` 是 `await asyncio.shield(ch.release(timeout))`,且该 timeout **默认取 acquire 时记录的 `ch._timeout`**(asyncpg `pool.py:886-889, 930-937`)。外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行 `async with` 的 `__aexit__`,此时**没有新的 cancel 投递**,那个 shielded release 会正常等到完成——于是业务路径的真实上界是 **≈ 2 × 预算**,而不是文档原先承诺的一个预算。故改为显式 `con = await pool.acquire(timeout=self._write_timeout_s)` + `finally: await pool.release(con, timeout=<小的独立上限>)`,释放超时则 `con.terminate()`。**acquire 传的是完整预算而非剩余预算**(实施期核定,T3): 真正的上界是外层那一层 `asyncio.timeout`,内层再算一次剩余量只是把同一个上界写两遍,徒增出错面;实测总耗时正好等于预算。承诺相应精确化为: **主写入尝试 ≤ 预算,释放路径独立有界**。
|
||||
- `CancelledError` 穿透由测试钉死: `asyncio.timeout` 只把自己触发的 cancel 转成 `TimeoutError`,外部取消照常以 `CancelledError` 冒出(实测确认,Codex 独立复现)。**实现纪律**: 降级路径(节流日志、tracker 更新、release 收尾)一律不得 `except CancelledError` 而不 re-raise;`except TimeoutError` 必须排在 `except Exception` 之前;严禁裸 `except BaseException`(铁律"取消可穿透")。
|
||||
|
||||
### 3.2 B 组 · 失败三分与冷却降级
|
||||
|
||||
**判据(两句,写进 ARCH)**:
|
||||
|
||||
1. **致命 = 失败原因完全在进程内部且不可变**;其余一切失败都可能被外部修好,故一律带冷却重试。
|
||||
2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。
|
||||
|
||||
第 2 句是 Codex 审查(2026-08-24)后补的,**原稿只有第 1 句,而分类表把 SQLSTATE `42` 整类归了行级——这与第 1 句自相矛盾**: 42501(账号被收走 INSERT 权限)、42P01(表被迁走/删掉)都是"能被外部修好"的持续性状态,却要在每次 LLM 调用上内联付一次 ≈123ms 往返并刷一条 warning,永远不会自愈也永远不停。按 SQLSTATE 前两位切太粗,必须切到具体码。
|
||||
|
||||
归档(asyncpg 0.31 异常层次 + PG SQLSTATE,**按 SQLSTATE 分类而非异常类白名单**——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移):
|
||||
|
||||
| 档 | 判据 | 处置 |
|
||||
|---|---|---|
|
||||
| **配置级致命** | `ClientConfigurationError`(DSN 本身不可解析,`InterfaceError`/`ValueError` 子类);`create_pool` 抛的 `ValueError`/`TypeError`(参数非法) | 永久 no-op + 一条 **error**(人配错了,不是 warning) |
|
||||
| **环境级不可用** | SQLSTATE `08`(连接)/`53`(资源不足,含 **53300 too many connections**)/`57`(管理干预)/`28`(认证)/`3D`(库不存在)/**`42501`(无权限)**/**`42P01`(表不存在)**;`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅准备期路径可达**——写入期的超时被 `record_llm_call` 的 `except TimeoutError` 先接住并按行级丢弃,见第 3 点);**表确定不存在且建不出来** | **冷却降级**(内部常量 60s),到期允许**一次**重新准备 |
|
||||
| **行级拒绝** | 其余 `PostgresError`: 数据与约束类(`22`/`23` 等),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级(`postgres.py:242-244`),但**接入节流复述** |
|
||||
|
||||
四点必须说清:
|
||||
|
||||
1. **致命档收到极窄是有意的**。认证失败、库不存在、表建不出来一律归环境级——它们都是外部状态,DBA 改完密码/建完表就该自动恢复。永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形,而 DSN 是构造期固定的字符串,是唯一满足这条的东西。
|
||||
2. **`42703` 是判据的唯一具名例外,且必须写明理由**。按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了一条更高优先级的承诺: manual 档缺列时**按现有列裁剪 INSERT 继续写**,缺列以逐行 warning 暴露,好让下游发现 schema 漂移——即"部分列写进去了"这件事本身有价值,不该被冷却掉。代价(无限逐行 warning)由接入节流复述抵消。**例外只此一条,新增例外必须同款论证**。
|
||||
3. **带冷却正面回答了 `postgres.py:104-105` 的顾虑**。那条注释担心的是"每次调用都内联吞一次 connect 超时";冷却 + §3.1 的硬预算把最坏成本变成"每 60s 一次、上界一个预算",有界且可解释。**进程不再需要重启**。
|
||||
**这句承诺的适用范围是准备期路径**(2026-08-24 合并前审查校正,**只改文档不改行为**): `TimeoutError` 是 `OSError` 子类、本表据此归环境级,但 `record_llm_call` 的 `except TimeoutError` 排在 `except Exception` 之前,写入本体抛出的超时一律在那里按行级丢弃,`_handle_failure` 根本不会被调用——写入路径上这条分类规则是死代码。于是"后端 TCP 通但不回应(假死)且 schema 已就绪"时,每次业务调用仍内联付满一个预算(缺省 5s)、丢一行、`degraded` 保持 False、不进冷却。不改的理由: 相对改前的"无限期挂"仍是净改善,且"超预算丢行不置 degraded"是 §6"突发排队"与 ARCH §7.8"`degraded` 与 `dropped_rows` 覆盖的不是同一件事"那一条明确记下的有意取舍;升档议题见 §6 的"连续超预算丢行是否该升档"一格。
|
||||
4. **`aclose()` 的语义钉死为"关了就是关了"**: 置 `_closed`,此后写入短路且**不复活**。今天"关完还能自己重建池"的灰色状态取消。issue 提的"`aclose` 不清 `_failed`"由冷却机制解决,不由 `aclose` 解决——恢复是运行时行为,不是关闭动作的副作用。
|
||||
**关闭动作本身也必须有界(Codex 审查,已核实)**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时**无限等**,60 秒只发一条 warning(`pool.py:939-948, 961-972`);asyncpg 自己的 docstring 就写着"advisable to use `asyncio.wait_for` to set a timeout"。故 `aclose()` 走 `asyncio.wait_for(pool.close(), ...)`,超时后 `pool.terminate()`,外部取消照常穿透——否则"遥测不得拖垮业务"在收尾路径上开了个口子。
|
||||
|
||||
分类函数是全库唯一一处 PG 失败分类,作 `postgres.py` 模块级私有函数(与 recorder 同文件、只服务 PG;不新起文件避免碎片化)。**认不出的失败归最轻档(行级)**是它的保守缺省,而这个缺省在**建池路径**上安全的理由比"最轻档代价最小"更强(实施期核实,T5): `min_size=0` 让建池不触库(实测 0.000s),所以"归行级 = 下次调用再重试一次建池"本身**零成本**——`postgres.py:104-105` 那条注释担心的"每次重试内联吞一次 connect 超时"是 `min_size=10` 语义下的顾虑,在新语义下**不成立**。这是 §1.1 那个关键推论的又一处红利: 地基一换,原本需要小心处理的保守缺省变成了白拿。
|
||||
|
||||
### 3.3 C 组 · 降级可见 + 可编程
|
||||
|
||||
新增 `telemetry/status.py` 的 `TelemetryStatusTracker`(两个 recorder **共用**,消除两侧不对称):
|
||||
|
||||
| 能力 | 行为 |
|
||||
|---|---|
|
||||
| 进入降级 | 一条日志,含原因分档与恢复条件(冷却剩余 / "需重启");**级别由 `fatal` 决定且只在这一处决定**——致命档 error(人配错了,不会自愈)、其余 warning。recorder 侧不得再复制一条(实施期更正 #4) |
|
||||
| 降级期间 | 按丢弃行数与时间**节流复述**(不刷屏,也不静默)——这一条是 §1.4 的直接钉子 |
|
||||
| 恢复 | info 一条,报告"期间丢弃 N 行" |
|
||||
| 快照 | `TelemetryStatus` frozen dataclass(放 `types.py`,与 `SourceStats` 同一先例): `degraded` / `fatal` / `reason` / `degraded_for_s` / `dropped_rows` / `retry_after_s` |
|
||||
|
||||
**不叫 `health`,是因为这个词在 `ports.py` 里已经被占用两次**(本轮自查发现,Codex 未提): `OcrTransport.check_health`(`ports.py:90`,源探活)与 `SourceSelector` 侧的 `health(source_name) -> float`(`ports.py:234`,成功率 EWMA)。库内 `health` 一律指**源的健康度**,而这里描述的是"这个 recorder 现在能不能写、为什么不能、丢了多少",是状态不是评分。同一文件里一词两义会直接违反 P2(领域术语命名)。
|
||||
|
||||
**不并入 `TelemetryRecorder` 主 Protocol(Codex 审查,已核实)**: 该 Protocol 是 `@runtime_checkable`(`ports.py:246`),而 runtime 检查按属性存在性做——加一个 `status` 属性,会让所有只实现 `record_llm_call` 的实现**当场不再是** `TelemetryRecorder`。库内 `tests/unit/test_ports.py:137,141` 就有 `isinstance(_DummyRecorder(), TelemetryRecorder)` 断言,下游若用同款断言,升级即断。原稿"库外无第三方实现者故加属性零成本"的判断**只覆盖了静态类型,漏了运行时结构契约**。改为:
|
||||
|
||||
- 独立可选端口 `TelemetryStatusProvider`(单方法/单属性,`@runtime_checkable`),两个内置 recorder 实现它;`TelemetryRecorder` 逐字不动。
|
||||
- 出口 `GatewayClient.telemetry_status -> TelemetryStatus | None`(None = 未启用遥测,或注入的 recorder 不提供)。取值经**一处** `isinstance(..., TelemetryStatusProvider)` 判定,不重演 `aclose` 那种三处复制的鸭子类型。
|
||||
- `types.py` 与 `ports.py` 同层且允许互 import(import-linter `ports : types : errors` 契约),分层不破。
|
||||
- 时钟经构造参数注入(`now: Callable[[], float] = time.monotonic`,与 `GatewayClient(now=...)` 同款),冷却与节流均可测。快照对外给 `degraded_for_s` **相对时长**而非绝对时间戳,避免 monotonic 与 wall clock 两个时钟并存的二义。
|
||||
- **SQLite 侧本次只做可见性**(补上缺失的 warning + 接入 tracker + 快照),**不做** lazy 化与冷却重连。理由: SQLite 的失败模式(本地目录不可写、文件损坏)在装配期就会暴露给下游,不是"跑到一半悄悄断",永久降级在那里语义基本正确;lazy 化是独立重构。tracker 与快照两侧共用,将来若要对称,接口已就位。
|
||||
|
||||
### 3.4 D 组 · 资源所有权纪律统一
|
||||
|
||||
把 `RedisLimiter._owns_client` 这个**库内已有的正确先例**推广为全库唯一纪律: **谁建的谁关,注入的一律不碰**。区分两类:
|
||||
|
||||
| 类 | 所有权归属 | 落法 |
|
||||
|---|---|---|
|
||||
| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 → **`RedisCache` 补齐这条纪律** |
|
||||
| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性(与 `RedisLimiter.from_url:190` 逐字同款模式),`aclose` 只关自建的 |
|
||||
|
||||
- **默认必须是"不拥有"**: `__init__` 是全量注入路径(`client.py:126-149`),经它传入的一切组件一律视为**外部所有**(`_owns_* = False`),只有三个工厂在 `or _build_*` / `if telemetry is not None else _build_telemetry` 真正自建时才置 True。原稿只写了"工厂置位"没写死这条默认,Codex 据此指出直接构造路径下共享 transport 仍会被第一个 client 关掉——那是实现走偏的后果,但默认值本就该在设计里定死,故补。
|
||||
- 三处复制的 `getattr(..., "aclose")` 收敛为一个内部 helper;所有权修正必须三处一致,复制就是下一个 bug 的种子。
|
||||
- `aclose` 补关 limiter/breaker——修掉现存泄漏。这需要**三个 client 都新持引用**: 今天 `GatewayClient.__init__` 把 limiter/breaker 交给 `RetryMW` 后自己不留引用(`client.py:133-134`),embedding/ocr 同样(`embedding.py:497-498`、`ocr.py:510-511` 自建、`embedding.py:452-461`、`ocr.py:462-467` 的 `aclose` 触达不到)。内存后端无 `aclose`,helper 探测后跳过。
|
||||
- **判定一律用 `is None` / `is not None`,不得用 `or`**(实施期补,T1): 工厂里 `limiter or _build_limiter(...)` 这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这是所有权判定能成立的**必要条件**,不是风格偏好,故写进设计而非留在代码里。
|
||||
- **零公共 API 面变化**: `_owns_*` 是私有属性,由工厂置位。
|
||||
- 有了 D 组,issue 的"共享池"方向以**显式注入**形态自然成立(`PostgresRecorder(dsn, pool=...)` 已支持且不关外部池),无需任何隐式全局。
|
||||
|
||||
### 3.5 新配置键与缺省值(人类已定)
|
||||
|
||||
| 键 | 字段 | 缺省 | 依据 |
|
||||
|---|---|---|---|
|
||||
| `PGW_TELEMETRY_PG_POOL_MAX` | `telemetry_pg_pool_max: int` | **4** | 稳态吞吐**实测约 15.6 行/秒**(见 §6 的口径更正;原稿按 `max_size / RTT` 估的 32 行/秒偏乐观一倍),覆盖单 client 十余并发;闲时占 0,不构成常驻负担。issue 现场 4 client × 4 = 峰值 16、稳态趋近 0(今天是 40 条常驻) |
|
||||
| `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | `telemetry_pg_write_timeout_s: float` | **5.0** | 实测稳态 123ms、首次含建连 513ms;5s 宽松且**有界**。同时用作 connect / acquire / 整次写入硬上界 |
|
||||
| (无键) | 冷却期 | 60s,**内部常量** | 无部署差异理由(P1 YAGNI) |
|
||||
|
||||
- 两键都带 `PG` 前缀,与 `PGW_TELEMETRY_PG_DSN` 一致,语义无歧义: SQLite 侧的等价物(`busy_timeout=5000`,`sqlite.py:58`)本次不动,这个不对称是**已知且有理由**的(见 §3.3 末)。
|
||||
- 校验落 `GatewaySettings._validate_telemetry`(与 `telemetry_text_cap` 同一先例,覆盖直接构造 / `dataclasses.replace` / env 三条路): `pool_max >= 1`、`write_timeout_s > 0`,报错文本同时点字段名与 env 键名。
|
||||
- 加字段的代价可控: `GatewaySettings(` 全库**只有 1 处**构造(`config.py` 的 `_load_pgw`),测试全走 `from_env`(74 处)+ `replace`(53 处),不重演 issue #13 那 35 处直接构造点的代价。三条链路(chat/embedding/ocr)因共用 `GatewaySettings` + `_build_telemetry` 自动覆盖。
|
||||
|
||||
## 4. 行为矩阵
|
||||
|
||||
| 场景 | 现状(1.2.4) | 本设计 |
|
||||
|---|---|---|
|
||||
| 建 client,共享实例余量 3 条 | 建池失败 → **整进程永久失遥测** | 建池成功(不触库),写入按需拿 1 条 → **正常落库** |
|
||||
| 稳态写入 | 10 条常驻 | 闲时 0 条,忙时 ≤ `pool_max` |
|
||||
| `too many clients` 落在首次准备期 | 永久判死 | 冷却降级 60s → 到期重试 → **自动恢复** |
|
||||
| `too many clients` 落在稳态写入 | 丢一行,池自恢复(已正确) | 同,且进入降级态使其**可见** |
|
||||
| PG 重启 / 网络抖动 | 视落点: 准备期 → 永久判死 | 一律冷却降级 → 自动恢复 |
|
||||
| DSN 写错 | 永久 no-op + warning | 永久 no-op + **error**(措辞点明"配置错,需改 DSN 并重启") |
|
||||
| 旧表缺列(42703) | 逐行 warning 丢弃 | 逐字不变(行级档) |
|
||||
| 表不存在且建不出来 | 永久判死 | 冷却降级,DBA 建表后**自动恢复** |
|
||||
| 遥测后端慢/挂 | `acquire` 无超时,可无限期挂在业务路径 | 硬预算封顶(5s),超时丢一行 |
|
||||
| 降级期间下游想知道 | 只能人肉对账 | `client.telemetry_status` + 节流复述日志 |
|
||||
| 多 client 注入同一 recorder | 第一个 `aclose` 把它弄死 | 各关自己的,共享 recorder 存活 |
|
||||
| 自建 redis limiter | `aclose` 后**泄漏** | 被关 |
|
||||
| 注入 redis 客户端给 RedisCache | 被 `aclose` 误关 | 不动 |
|
||||
|
||||
## 5. 测试策略
|
||||
|
||||
行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。
|
||||
|
||||
**单元层**(`tests/unit/test_telemetry.py` 邻域,沿用既有假 asyncpg 模块):
|
||||
建池参数断言 `min_size == 0` 且 `max_size == 配置值`(钉住"库对资源占用的表态",防回归到继承第三方默认值,这是本 issue 的**主回归钉子**);`too many clients`(53300)落在准备期 → 进冷却降级、**不** fatal → 假时钟推进 60s → 自动恢复;`ClientConfigurationError` → fatal + 一条 error + 此后零成本短路(断言不再调 `acquire`);**`42501`/`42P01` → 进冷却降级**、`42703` → 行级丢弃且**不**进降级(§3.2 的分档边界,两侧各钉一次);假 pool 的 acquire 挂住 → 硬预算生效、丢一行、耗时 ≤ 预算;外部 `CancelledError` 在 `asyncio.timeout` 内**不**被吞成 `TimeoutError`;状态快照六字段的状态机;节流复述(N 条丢弃只出 M 条 warning,loguru sink 断言);`aclose` 后写入不复活。
|
||||
|
||||
**收尾路径层**(Codex 审查新增,两条都是"看起来完成了、其实资源还在"的形态):
|
||||
① `execute` 被硬预算取消后**连接不泄漏**——假 pool 记录 acquire/release 配对次数,断言超时路径上 release 照样发生且总耗时 ≤ 预算 + release 上限(钉 §3.1 那条 shielded release 的坑);② 持有连接不释放时 `aclose()` **不无限挂**——假 holder 永不 release,断言 `aclose` 在超时后走 `terminate()` 返回。
|
||||
|
||||
**所有权层**(`tests/unit/test_client.py` 邻域,假 recorder/transport 记 close 次数):
|
||||
注入的 recorder/transport/limiter/breaker/cache 不被 `aclose` 关;自建的被关;自建 redis limiter/breaker 被关(泄漏钉子);注入给 `RedisCache` 的客户端不被关;三个 client(chat/embedding/ocr)**逐一**覆盖——收敛成 helper 后仍须三处各钉一次,否则下次复制回来无人发现。
|
||||
|
||||
**契约层**: `isinstance(只实现 record_llm_call 的对象, TelemetryRecorder)` 必须**仍为 True**(`tests/unit/test_ports.py:137,141` 现有断言保持绿即可,不需新增)——它是"没把 `status` 并进主 Protocol"这条决策的机械化执法点。
|
||||
|
||||
**集成层**(`tests/integration/test_postgres_telemetry.py`,真实 PG,沿用 run 级前缀隔离与"严禁 DROP/TRUNCATE"纪律,缺 DSN 则 skip、不标 slow):
|
||||
`pg_stat_activity` 计数——建 recorder 后本池连接 **0** 条,一次写入后 **≤1** 条(issue 的直接回归钉子);稳态连接数 ≤ `pool_max`。**计数必须按唯一 `application_name` 过滤**(经 `server_settings` 设一个 run 级值): 该实例被多项目共用,按库名或用户名计数会被别人的连接污染,那样的用例是设计上就会间歇红的信号污染源(CLAUDE.md §4.6)。降级与恢复走**不可达 DSN** 的 recorder 验证,不去动共享实例的 `max_connections`。
|
||||
|
||||
## 6. 非功能与已知取舍
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 首次写入延迟 | `min_size=0` 把 ≈390ms 建连从"装配期"挪到"首次写入"。稳态无差异(实测 123ms);空闲超 `max_inactive_connection_lifetime`(asyncpg 缺省 300s,不暴露)后再付一次。相对一次秒级 LLM 调用可忽略 |
|
||||
| 突发排队(**热池稳态**) | 业务并发 > `pool_max` 时遥测写入排队。按下一格更正后的实测口径(15.6 行/秒): 50 行同时到达 → 实测 3.2s,在 5s 预算内但**余量只剩约 1.5 倍**(原稿按 32 行/秒估算时以为余量有 3 倍);超出即丢行(铁律"丢一条 < 拖垮调用") |
|
||||
| 突发排队(**冷启动/空闲后**) | 上一格的算术只在"schema 已就绪且连接已热"时成立。空闲超回收期后连接归 0,第一波要重新建连(实测 ≈390ms),且首次准备被 `_init_lock`(`postgres.py:75, 83-91`)串行保护——冷启动的最坏延迟不是 `64 / 32 ≈ 2s`。Codex 审查指出原稿这段易被读成两种情形通用,故拆开写。冷启动上界仍由硬预算封顶,超出即丢行 |
|
||||
| Python 版本 | **本条取舍已消解**(人类决策,2026-08-24): 最低版本提到 **3.12**(`requires-python = ">=3.12"`、ruff `target-version = "py312"`),3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,`asyncio.timeout` 可直接用,不必退回 `wait_for`。代价见 §7 |
|
||||
| `pool_max` 的调参口径(**实施期更正,T3 实测**) | 原稿的 `期望吞吐 ≈ pool_max / RTT`(4/0.123 ≈ 32 行/秒)**偏乐观一倍**: T3 实测 50 行并发批耗时 **3.2s**,即约 **15.6 行/秒**、每条连接约 4 行/秒——一次 `INSERT` 的实际往返比一次 `SELECT 1`(RTT 的测法)重。取舍方向不变(超预算丢行 < 拖垮业务),但 `.env.example` 与 README 的调参口径**必须写实测数字**,否则下游按错公式放大,以为 `pool_max=8` 能到 64 行/秒(实为约 31)。共享一个 recorder 给多 client 时并发在此汇聚,应按 client 数相应放大 |
|
||||
| 冷却期的丢数 | 降级 60s 期间的行**确实丢了**,只是可见、可计数、且到期自动恢复。这是"遥测降级不得拖垮业务"的既有方向(ARCH 降级方向铁律),本设计不改方向,只改**可恢复性与可见性** |
|
||||
| `42703` 缺列的持续逐行重试 | 缺列时每次调用付一次 acquire+execute(≈123ms 内联)且逐行 warning,不进冷却。**这是判据的唯一具名例外**(§3.2 第 2 点),由 issue #13 的"缺列须逐行暴露"承诺定死;代价由节流复述抵消。`42501`/`42P01` 原稿同归此格,经 Codex 审查已改判环境级 |
|
||||
| 快照计数的线程安全 | `dropped_rows` 是单事件循环内的 int 自增。库不承诺跨线程共享同一 recorder("纯 asyncio 中立"),最坏是计数不准,不会崩 |
|
||||
| SQLite 侧不对称 | 只做可见性,不做 lazy 化/冷却(理由见 §3.3)。tracker 与快照两侧共用,不产生第二套概念 |
|
||||
| redis / httpx 的资源上限 | 两者均无上限或偏大(§1.2),但**按需建连、无预占脆点**,不是本 issue 的病灶。列为观察项,**本次不动**(反 gold-plating) |
|
||||
| 连续超预算丢行是否该升档(**留作后续议题**) | 后端假死(TCP 通但不回应)且 schema 已就绪时,每次业务调用都内联付满一个预算并丢一行,`degraded` 恒 False、永不进冷却(成因见 §3.2 第 3 点)。本次不改行为——相对改前的"无限期挂"已是净改善,而升档需要新判据("连续 N 次超预算 = 后端不可用"),那是个有代价的猜测: 判错会把本地并发过高误判成后端挂了,冷却 60s 只会白丢更多行。要动就得先有实测依据,不在本 issue 范围内 |
|
||||
| 端口签名 | `TelemetryRecorder` **逐字不变**(24 字段签名与 Protocol 成员集合都不动),不触碰迁移兼容约束(ARCH §5.1)、也不破坏 `runtime_checkable` 的既有 `isinstance` 语义;新增的是**独立**端口 `TelemetryStatusProvider` |
|
||||
|
||||
## 7. 文档与发布
|
||||
|
||||
ARCH §7.8 增补三条: 遥测池的资源语义(为何 `min_size=0`、为何不暴露 `min_size`)、失败三分判据(§3.2 那句判据是主要交付物之一)、**资源所有权纪律**(§3.4,应作为跨子系统的通用纪律成文,而非遥测局部约定)。§9 配置面登记两个新键。`.env.example`、README 能力表与配置表、Gitea wiki 按 `docs-convention.md` §2 同步。
|
||||
|
||||
**版号由人类在发布时定**,本文不预设: 按 semver 应是 **1.3.0**(端口新增只读属性 + 两处对下游可见的行为变更),但项目既有口径明显偏 patch——issue #11 扩遥测列(端口 22→24)落 1.2.1、issue #14 新增配置键 + `retry_after_s` 语义变更**设计文档写的是 1.3.0、实际发成了 1.2.4**。不核对这一条就照抄"1.3.0"会重演同一次不一致。
|
||||
|
||||
CHANGELOG 有四处需"请先读这一条"待遇(第 4 条是合并前审查补的):
|
||||
|
||||
1. **最低 Python 提到 3.12**(人类决策,2026-08-24;`requires-python`、ruff `target-version`、README、CLAUDE.md 四处已同步)。这是四处里**唯一会让下游装不上**的变更: 仍在 3.11 的部署 `pip install` 直接被 pip 拒绝。这一条本身就足以把版号推到 **1.3.0**——它不是"新增能力",是缩小了支持面。
|
||||
2. 遥测常驻连接从 `10 × client 数` 变为按需(纯改善,但监控上会看到连接数曲线突变)。
|
||||
3. `aclose` 不再关闭注入的组件。这是修正越权,但若有下游**依赖**了"注入后由 client 代关",升级后会漏关——必须显式声明。
|
||||
4. **直接构造 `GatewaySettings` 需补两个参数**(合并前审查补,2026-08-24)。原稿漏了这一条,还把两个新字段写成"带缺省"——它们与相邻三个遥测键一样**无默认值**,缺省只在 env 装配路;直接构造的调用点升级即 `TypeError`,是货真价实的破坏性变更。
|
||||
|
||||
**版本提升的两项前置——已于 2026-08-24 执行完毕**(顺序不可颠倒,先改语法会当场把 import 全炸掉):
|
||||
|
||||
| # | 前置 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | 重建 conda 环境(原 3.11.15 不满足新的 `requires-python`,`make install` 会被 pip 拒绝) | `PolyGateway` 重建为 **3.12.13**;`make install` 通过。比对新旧 `pip freeze` 发现重建**只**缺发布工具链(`build`/`twine` 及依赖,不在 `make install` 的 extras 里),已补装(twine 7.0.0) |
|
||||
| 2 | `target-version = "py312"` 启用 UP047,3 处须改 PEP 695 语法(该语法在 3.11 是 **SyntaxError**) | `gather_bounded`(`client.py:443`)、`_anext_within`(`streaming.py:39`)、`stream_with_liveness_timeouts`(`streaming.py:60`)改为 `def f[T](...)`;两文件的模块级 `_T = TypeVar("_T")` 与 `TypeVar` import 随之删除 |
|
||||
|
||||
验证: `make check` 全绿(ruff format + lint + import-linter 契约 KEPT),全套件 **973 passed / 23 skipped / 45 deselected(slow),覆盖率 94%**。这三处改动**不是本 issue 的重构**,是版本提升的直接后果,归入版本提升那个前置提交。
|
||||
|
||||
## 8. 已定决策(人类,2026-08-24)
|
||||
|
||||
| # | 决策 | 随之固定的实施边界 |
|
||||
|---|---|---|
|
||||
| 1 | C 组只读状态快照**要做** | 新增**独立**端口 `TelemetryStatusProvider`(`TelemetryRecorder` 不动,理由见 §3.3);`types.py` 加 `TelemetryStatus`;client 侧一处 `isinstance` 判定。命名避开 `health`(该词在 `ports.py` 已两处占用) |
|
||||
| 2 | D 组(所有权纪律)**一并做** | 改动面从 telemetry 扩到 client/embedding/ocr/backends。拆为**独立前置提交**(纪律统一 + 泄漏修复),验收标准"全套件绿 + 新增用例只在所有权层",该提交即回滚点 |
|
||||
| 3 | 缺省 `POOL_MAX=4` / `WRITE_TIMEOUT=5.0` | 按 §3.5 落 config 校验;README 须给出调参口径,否则这两个旋钮等于不存在。**人类当时定的公式 `pool_max ≈ 期望吞吐 × RTT` 已被 §10 修订 #1 作废**(偏乐观一倍),文档一律写实测值 15.6 行/秒 |
|
||||
| 4 | **最低 Python 提到 3.12**,版号定 **1.3.0** | 消解 §6 的 `asyncio.timeout` 版本取舍(可直接用,不退回 `wait_for`)。两项前置(重建环境、UP047 三处改 PEP 695)**已执行完毕并验证**,详见 §7。版号 1.3.0 的依据是缩小支持面,不是新增能力 |
|
||||
|
||||
## 9. 审查留痕(Codex,2026-08-24)
|
||||
|
||||
报 3 阻断 + 3 应改 + 1 可选,**逐条独立核实后 6 条采纳、1 条改判**。采纳的都不是措辞问题,而是"承诺比实现能给的更强"这同一类错误的不同实例。
|
||||
|
||||
| # | 档 | 结论 | 落点 |
|
||||
|---|---|---|---|
|
||||
| 1 | 阻断 | **采纳**。`async with pool.acquire()` 的释放路径是 shielded 且复用 acquire 的 timeout,业务路径真实上界 ≈ 2 × 预算。核实于 `pool.py:886-889, 930-937` | §3.1 第 3 条;§5 收尾路径层① |
|
||||
| 2 | 阻断 | **采纳,并回头改了判据本身**。SQLSTATE `42` 整类归行级与"能被外部修好的一律冷却重试"自相矛盾。补出第 2 句判据(行级 vs 环境级看"与本行数据有没有关系"),`42501`/`42P01` 改判环境级,`42703` 降为唯一具名例外 | §3.2 判据 2 与第 2 点;§5 单元层;§6 |
|
||||
| 3 | 阻断 | **改判为实现约束**(非设计缺陷)。原稿"工厂置 `_owns_*`"已隐含"注入即不拥有",但确实没写死默认值。补为显式条款 | §3.4 第 1 条 |
|
||||
| 4 | 应改 | **采纳,且原稿的理由本身是错的**。原稿称"库外无第三方实现者故加属性零成本"——这只覆盖静态类型,漏了 `TelemetryRecorder` 是 `@runtime_checkable`(`ports.py:246`),加属性会让 `tests/unit/test_ports.py:137,141` 的 `isinstance` 当场变 False。改为独立端口 | §3.3;§5 契约层;§6 |
|
||||
| 5 | 应改 | **采纳**。`Pool.close()` 等 in-flight 释放会无限挂,60s 只 warning(`pool.py:939-948, 961-972`) | §3.2 第 4 点;§5 收尾路径层② |
|
||||
| 6 | 应改 | **采纳**。limiter/breaker 引用要传穿三个 client,原稿只写了 chat | §3.4 第 3 条 |
|
||||
| 7 | 可选 | **采纳**。吞吐算术只对热池稳态成立,冷启动另有口径 | §6 |
|
||||
|
||||
**本轮自查另补两条 Codex 未发现的**: ① `health` 一词在 `ports.py` 已被 `check_health`(`:90`)与 `health(source_name) -> float`(`:234`)占用两次,故快照改名 `TelemetryStatus`(§3.3);② `asyncio.timeout` 是 3.11 新增而 `requires-python = ">=3.11"`,3.11.0/3.11.1 的 `uncancel` 有已知缺陷,实施时须在"抬最低版本"与"改用 `wait_for`"之间选一(§6)。
|
||||
|
||||
Codex 的取消穿透实测与本会话结论一致(外部 `task.cancel()` 在 `asyncio.timeout` 内冒出的是 `CancelledError` 而非 `TimeoutError`),两处独立验证互为佐证。
|
||||
|
||||
## 10. 实施期修订(2026-08-24,T0–T7 执行中发现)
|
||||
|
||||
设计经人类审后实施,过程中三处需要回改设计本身——都不是措辞问题,而是"原稿的事实基础不够"。逐条落回正文而非只记在这里,以免后来人读正文时踩同一个坑。
|
||||
|
||||
| # | 修订 | 落点 |
|
||||
|---|---|---|
|
||||
| 1 | **吞吐算术偏乐观一倍**。原稿按 `pool_max / RTT` 估 32 行/秒,T3 实测 50 行并发批 3.2s(≈15.6 行/秒)——`INSERT` 的实际往返比测 RTT 用的 `SELECT 1` 重。方向不变,但下游调参必须拿实测数字 | §3.5 表、§6 两格 |
|
||||
| 2 | **原稿未预见的一处真 bug**: 注入外部池时 `aclose()` 之后的下一次写入会拿 DSN 偷偷自建一个池。与 §1.5 三条同根因,只是表现在关闭之后,T4 修掉 | §1.5 |
|
||||
| 3 | **两条论证被补强**: ①"认不出的失败归行级"这个保守缺省在建池路径上安全,理由是 `min_size=0` 让重试建池零成本(T5);②所有权判定必须用 `is not None` 而非 `or`,否则注入 falsy 后端时自建分支与所有权标志漂移(T1) | §3.2 末、§3.4 |
|
||||
| 4 | **日志级别的决策点收敛到 tracker**(独立验证发现)。原实现在 recorder 的 fatal 分支另发一条 `logger.error`,而 tracker 同时发一条语义重复的 warning——同一个事实两条日志,"级别"这个决策两个源头。改为 `enter_degraded` 按 `fatal` 选级别(error / warning),recorder 不再另发;SQLite 侧的致命档同步升为 error。**这条决策此前没有执法点**: 测试 fixture 挂 `level="WARNING"`,ERROR 与 WARNING 同池,删掉那条 error 用例照样绿。补 `captured_logs` fixture(连级别一起捕获)后三处补上级别断言 | §3.2 表、§3.3 表、§5 单元层 |
|
||||
| 5 | **`acquire` 传的是完整预算,不是剩余预算**(独立验证发现,改文档不改代码): 真正的上界是外层那一层 `asyncio.timeout`,内层再算一次剩余量只是把同一个上界写两遍。行为无害,实测总耗时正好等于预算 | §3.1 |
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:2026-08-25-thinking-observability-design
|
||||
title: "推理可观测性一等化(issue #16 + #17)"
|
||||
date: 2026-08-25
|
||||
---
|
||||
|
||||
# 推理可观测性一等化(issue #16 + #17)
|
||||
|
||||
> 类型:design|日期:2026-08-25|状态:待人类确认
|
||||
> 事实基础见 `findings/2026-08-25-thinking-observability-regression.md`(本文所有实测引用均出自该文)。
|
||||
> 沿用 `2026-08-02-thinking-capability-design.md` 的先例:经充分实测后直接给出单一方案,不列备选;被否决的路见 §9。
|
||||
|
||||
## 1. 问题不是 issue 说的那个
|
||||
|
||||
issue #16/#17(Gitea `iomgaa/PolyGateway`,原文经 `tea issues 16` / `17` 读取;本仓库 remote 非 GitHub,`gh` 读不到)判定"MiniMax-M3 开启推理静默失效,模型不推理"。**实测推翻了这个诊断**:M3 的推理完全正常——流式路径下 `reasoning_content` 有 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
|
||||
|
||||
真正发生的是:**MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**(qwen 与 deepseek 在同一网关同一 key 上照常返回),于是 `reasoning_tokens` 恒为 NULL;而 e2e 的四条用例把 `reasoning_tokens` 当作唯一判据,于是集体判红。
|
||||
|
||||
**库自己握着决定性证据却没用它**:`LLMResponse.thinking` 在同一次调用里是 185 字符的实打实推理正文,从未参与任何"推理是否发生"的判定。
|
||||
|
||||
所以这是一次**可观测性缺口**,不是功能故障。而缺口的形态——库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`——正是 P5 要消灭的静默掩盖。
|
||||
|
||||
## 2. 根因三层
|
||||
|
||||
| # | 缺陷 | 只修外层会留下什么 |
|
||||
|---|---|---|
|
||||
| ① | `reasoning_tokens=None` 同时承载"没推理"与"没上报"两个语义,不可区分。`types.py` 的 docstring **已经写明这个歧义,但只是描述它,没有解决它** | 换个供应商停报 ctd,同样的红再来一次 |
|
||||
| ② | 解析出的 `thinking` 文本从未接入任何判定:e2e、遥测、下游看的都只有 `reasoning_tokens` | 库继续把手里的硬证据丢在地上 |
|
||||
| ③ | 能力表是**静态单向**声明(只有 `can_disable`),且没有任何机制把声明与运行时观测对账 | **下一个同构故障已在等着** |
|
||||
|
||||
第 ③ 层最要紧。设想某天 M3 变成不能关推理:库照常注入 `reasoning_effort=none`,模型照常推理,下游拿到推理内容却以为关了,而库全程不吭声——与本次同构,且更隐蔽(本次至少有测试变红,那次连测试都是绿的,因为 L1 的判据同样只看 `reasoning_tokens`)。能力表过期是**必然事件**(M3 的 evidence 停在 8-02 整整 23 天),设计必须把它当常态处理,而不是靠人记得去复测。
|
||||
|
||||
## 3. 设计主张
|
||||
|
||||
一句话:**把"这次推理到底发生没发生"从下游的猜测变成库的一等返回值,由多信号裁定;单次响应判不出来时如实说"未知",绝不伪装成"没有";并用它与能力表持续对账,让声明过期成为可报警事件。**
|
||||
|
||||
三条纪律贯穿全文:
|
||||
|
||||
- **能从数据可靠推断的,绝不进静态表。** 静态表必然过期,这次就是。
|
||||
- **判不出来就叫"未知",不许折叠进"没有"。** 折叠是 ① 的病根。
|
||||
- **最硬的证据优先。** 推理正文是事实本身,token 计数是对事实的转述;转述缺失时事实仍然作数。
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 `ThinkingObservation` 三态(新增,响应侧)
|
||||
|
||||
```python
|
||||
class ThinkingObservation(StrEnum):
|
||||
OBSERVED = "observed" # 确证推理发生
|
||||
ABSENT = "absent" # 确证未推理(正面证据)
|
||||
UNKNOWN = "unknown" # 无任何信号,判不出来
|
||||
```
|
||||
|
||||
**枚举定义在 `types.py`,裁定逻辑在 `thinking.py`——两者必须分开。** 它是 `LLMResponse`/`TransportResult` 的字段类型,而 `types.py` 是最内层、不得 import 任何具体实现(P7,import-linter 契约执法)。把枚举放进 `thinking.py` 会让最内层反向依赖决策模块,契约当场判红。纯值类型归最内层、决策逻辑归上层,是本设计的分层落法。
|
||||
|
||||
取 `StrEnum` 而非裸 `str` 常量:取值域显式、可类型检查,且它是 `str` 子类,`dataclasses.asdict` + `json.dumps` 天然可序列化(缓存回放路径见 §6)。
|
||||
|
||||
裁定纯函数 `observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation`,四条分支按顺序:
|
||||
|
||||
| 条件 | 结果 | 理由 |
|
||||
|---|---|---|
|
||||
| `thinking.strip()` 非空 | OBSERVED | 推理正文是事实本身,压倒一切 |
|
||||
| `reasoning_tokens > 0` | OBSERVED | 上游明确上报了推理用量 |
|
||||
| `reasoning_tokens == 0` | ABSENT | 上报了且为零 = "未推理"的正面证据 |
|
||||
| 其余(`None`) | UNKNOWN | 无信号,不猜 |
|
||||
|
||||
**判据取 `bool(thinking.strip())` 而非 `bool(thinking)`**:transport 收集 `reasoning_content` 时只判 truthy(`openai_compat.py`),上游返回纯空白串就会被计成"观测到推理"。网关响应是外部输入,校验后使用(P5)。
|
||||
|
||||
映射到实测:
|
||||
|
||||
| 场景 | observation | 是否诚实 |
|
||||
|---|---|---|
|
||||
| M3 开启,流式 | OBSERVED | ✅ 有 185 字符正文 |
|
||||
| M3 开启,非流式 | UNKNOWN | ✅ 确实观测不到(正文与 ctd 双缺) |
|
||||
| M3 关闭 | UNKNOWN | ✅ 判不出——**且必须承认判不出**,见下 |
|
||||
| qwen 开启 | OBSERVED | ✅ 两个信号都在 |
|
||||
|
||||
**`UNKNOWN` 不具证伪力,不得声称它能保障关闭方向。** M3 关闭档落在 `UNKNOWN`,这意味着库无法证明推理真的关掉了。对账(§5)能提供的保障只有一个方向:**若模型真的推理了,可观测路径会把结果翻成 `OBSERVED`,告警随之触发**——M3 流式正属此列(关闭档若失效,正文会冒出来)。而不可观测路径(M3 非流式)没有任何保障,这一点必须写在文档里而不是假装有。**告警覆盖的是可观测路径,不是全部路径**。
|
||||
|
||||
`ABSENT` 这一支在当前三家供应商上**实测永不触发**(未推理时都是整个容器缺失,无人报 `0`)。仍然保留:协议允许上报 `0`,而一旦有供应商这么做,它就是唯一能把"没推理"与"没上报"分开的信号——为一个已知会出现的未来留一个空槽,不是 YAGNI 违例。
|
||||
|
||||
### 4.2 明确不做:不把"可观测性"写进能力表
|
||||
|
||||
诱惑很大:给 `ThinkingCapability` 加一个 `reports_reasoning_usage: bool` 或 `observable_in_non_stream: bool`。**否决**。理由是本次故障的教训本身——静态声明会过期,而过期表现为静默错觉。可观测性每次响应都能直接看出来,把它冻进静态表等于再造一个 8-02 版本的定时炸弹。
|
||||
|
||||
同理否决"看 `completion_tokens_details` 容器在不在"这一判据:实测三家在未推理时都是容器整体缺失,该信号与真实信号高度混淆,用它裁定等于把噪声当信号。
|
||||
|
||||
## 5. 对账:声明 × 观测
|
||||
|
||||
在 transport 拿到结果处做一次比较,矛盾即 warning:
|
||||
|
||||
| 请求方向 | 观测 | 能力表 | 处置 |
|
||||
|---|---|---|---|
|
||||
| `enable_thinking=False` | OBSERVED | 已登记 `can_disable=True` | **warning**:能力表漂移——声明说可关闭,实测推理了。附 model 与 `evidence` 日期,指路 `register_capability` |
|
||||
| `enable_thinking=False` | OBSERVED | 未登记 | **warning**:关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
|
||||
| `enable_thinking=True` | ABSENT | 任意 | **warning**:注入了开启参数,上游明确上报未推理 |
|
||||
| `enable_thinking=True` | UNKNOWN | 任意 | **warning 一次**:推理参数已注入但本路径观测不到,无法确认是否生效;**若为非流式路径,推理内容可能已计费却不回传**(M3 实测 completion 53 vs 关闭档 3) |
|
||||
| `False` | UNKNOWN | 任意 | 不表态——不能证伪(§4.1) |
|
||||
| `None`(不干预) | 任意 | 任意 | 不表态——调用方没提要求,无从谈"违背" |
|
||||
|
||||
前两行必须分开:`resolve_thinking` 的 Phase 3 允许未登记模型按 provider 形态尽力注入并预先 warning,那是**事前猜测**;这里的对账是**事后实证**,两者文案不能混。对未登记模型说"能力表声称可关闭"是错的——它根本没登记。
|
||||
|
||||
第四行是 issue #17 关切的"静默失效"的诚实版本:库不再默不作声,而是明说"我注入了,但我看不见结果"。M3 非流式每次都落这一档,故节流不可少。
|
||||
|
||||
**不抛错**,三条理由:一次观测不足以否决一次成功的调用;P5 的降级方向铁律只对限流/熔断要求"报错而非放行",可观测性属遥测方向,降级即 warning;矛盾结果已随 `LLMResponse` 与遥测落地,处置权归下游。
|
||||
|
||||
**节流**:per transport 实例的 `set[(source, model, direction)]`,同一组合只喊一次,与既有 `_warned_models` 同款形态与同款理由(逐次调用刷屏会把告警变成噪声,噪声等于没有告警)。键含**源名**是因为多源多账号是本库的核心场景:同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警文案定位不到该查哪个网关(源名在调用点拼进文案,不进 `reconcile_thinking` 的签名——那是纯判定函数,源名是定位信息而非判据)。两个 set 分开维护的理由是**语义不同**(一个记"未登记能力已告警过",一个记"某源某方向的矛盾已告警过"),共用会让两种告警的生命周期纠缠在一起;不是键会碰撞——两者键空间本就不相交。
|
||||
|
||||
这一条是本设计的灵魂:它把"能力表过期"从**静默错觉**变成**日志里的显式告警**,成本是一次枚举比较。
|
||||
|
||||
## 6. 落点清单
|
||||
|
||||
**源码**
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `types.py` | 新增 `ThinkingObservation`(枚举归最内层,§4.1);`LLMResponse` 增 `thinking_observation: ThinkingObservation = UNKNOWN`(只增不删,迁移兼容);`TransportResult` 同增 |
|
||||
| `thinking.py`(**新建**) | 推理这件事的全部**决策**,见 §7 |
|
||||
| `providers.py` | 收缩为纯注册表:`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`/`register_provider` |
|
||||
| **`ports.py`** | `TelemetryRecorder.record_llm_call` 24 参 → 25 参。该 docstring 明定"新增参数不设默认值"(库外无第三方实现者),故两个 recorder 与全部测试替身必须同步。**这是端口 Protocol 签名变更**,属 CLAUDE.md 强制人类确认档 |
|
||||
| `transports/openai_compat.py` | 组装 `TransportResult` 时调 `observe_thinking`;对账告警落此处(唯一同时握有请求方向与响应结果的地方) |
|
||||
| `middleware/retry.py` | 透传新字段 |
|
||||
| `middleware/telemetry.py` | `_AttemptUsage` 增一字段;三个 `emit_*` 各传一行;`_record` 签名增一参——**全部经既有单一出口 `_record` 抵达 recorder**,不新开调用点(§12) |
|
||||
| **`middleware/cache.py`** | `_rehydrate` 走 `LLMResponse(**fields)`,JSON 复活的是**裸字符串**而非枚举实例:须显式转 `ThinkingObservation(...)`。域外取值(多版本共用同一 Redis 时,更新版本写入的新态)降级为 `UNKNOWN` 并单独告警,内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存响应;"整条作废"只留给真正破坏内容完整性的失败(JSON 坏了、结构化重建不过) |
|
||||
| `telemetry/schema.py` | 新列 `thinking_observation TEXT`,两端 DDL + 两份 backfill + `COLUMNS`;INSERT 字段 24→25,物理列 25→26 |
|
||||
| `telemetry/sqlite.py`、`telemetry/postgres.py` | 实现新参 |
|
||||
| `client.py` | import 路径改指 `thinking.py` |
|
||||
| `__init__.py` | 新增包根导出,见 §7 |
|
||||
|
||||
**测试**
|
||||
|
||||
`tests/unit/` 下 `test_types.py`(默认值为 UNKNOWN、位置构造兼容、枚举归属模块)、`test_ports.py`(端口签名冻结测试与 recorder 替身)、`test_openai_compat.py`(裁定四分支、优先级、对账三类告警、节流只喊一次)、`test_retry.py`(透传)、`test_telemetry.py`(列数/列序/组装)、`test_cache.py`(回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中、内容坏了才回源)、`test_package.py`(包根导出面,比照 `TelemetryStatus` 先例)、`test_providers.py`(拆分后的注册表);`tests/integration/test_postgres_telemetry.py`(新列 backfill 与 round-trip);`tests/e2e/test_thinking_live.py`(判据重建,§8)。
|
||||
|
||||
**文档**(发布清单第 1 步要求构建前改完)
|
||||
|
||||
`README.md` 的"必录 24 字段"→ 25,**须用 `inspect.signature` 实测而非凭记忆**;`research-wiki/ARCHITECTURE.md` 的 D11、§5.1 响应字段、§7.8 遥测字段、§8 模块结构(补 `thinking.py`);`research-wiki/schemas/llm-calls.md`(标题仍写"22 字段",已过期两轮,本次一并订正为 25);`research-wiki/index.md`(登记本 design 与 finding);`CHANGELOG.md`(断裂项置顶,§13)。
|
||||
|
||||
`thinking_observation` **不进缓存 key**:它是结果不是请求。缓存回放的历史响应带回历史 observation,与 `reasoning_tokens`/`cached_prompt_tokens` 的既有回放口径一致。
|
||||
|
||||
默认值取 `UNKNOWN` 使得任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
|
||||
|
||||
## 7. 模块边界:为什么新建 `thinking.py`
|
||||
|
||||
现状 `providers.py` 装着两件事:provider 注册表(形态)与推理决策(`resolve_thinking` + 能力表)。加入响应侧裁定与对账后它会变成"推理这件事的一切",一句话说不清职责(P3)。
|
||||
|
||||
| 模块 | 职责 | 内容 |
|
||||
|---|---|---|
|
||||
| `providers.py` | **provider 是什么** | `ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`、`register_provider` |
|
||||
| `thinking.py` | **推理这件事的全部决策** | `ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`(请求侧注入)、`ThinkingUnsupportedError`、`observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义**——纯值类型归 `types.py`(§4.1) |
|
||||
|
||||
符合 P7"决策逻辑与状态存储分离":注册表存声明,`thinking.py` 做决策。未来任何推理相关能力都有唯一归属,不必再挑"放哪个文件"。
|
||||
|
||||
**同时把公共符号提升到包根导出**:`ThinkingCapability`、`ThinkingObservation`、`register_capability`、`get_capability`、`resolve_thinking`、`ThinkingUnsupportedError`。`__init__.py` 的 docstring 早已写明"顶层导出即公共 API 面",而这些符号此前只能深路径 import——**给下游一个稳定引用点,才是模块重组不再破坏下游的前提**。这是本次一并消除的第四项债务。
|
||||
|
||||
破坏面:`from polygateway.providers import ThinkingCapability / resolve_thinking / get_capability / DEFAULT_CAPABILITIES` 会断。这些符号不在包根 `__all__` 内,且三个参考项目尚未迁移接入(M4 未完成),实际下游为零。CHANGELOG 显式列出并给出改法。
|
||||
|
||||
## 8. e2e 判据重建
|
||||
|
||||
四条红用例的病根是判据盲区,不是被测行为。逐条重建:
|
||||
|
||||
| 用例 | 旧判据 | 新判据 |
|
||||
|---|---|---|
|
||||
| L1 关闭 | 每轮 `reasoning_tokens in (None,0)` | 每轮**不是 OBSERVED**。证伪力不减反增:模型若偷偷推理,流式必带出正文 → OBSERVED → 红 |
|
||||
| L2 开启 | 多数轮 `reasoning_tokens>0`,退路 `completion>100` | 多数轮 **OBSERVED**;**删除 `_ON_MIN_COMPLETION` 魔数退路** |
|
||||
| L2b 锚点 | `prompt_tokens` 两档分开 | 不变——它一直是对的,也是本次开启方向唯一没红的证据 |
|
||||
| L3b 非法值反证 | 非法值多数轮推理 | 同 L2 判据;补注 provider 不可移植性(minimax 返 200 照常推理,qwen 返 400) |
|
||||
| L4 extra_body 覆盖 | 多数轮推理 | 同 L2 判据 |
|
||||
| L5 非流式 | 非流式重跑 L1/L2,要求开启档观测到推理 | **重新定义**,见下 |
|
||||
|
||||
删掉 `_ON_MIN_COMPLETION` 是有意的。它是"`reasoning_tokens` 被中转吃掉时的退路",而实测两档的 completion 分布重叠(关闭档最高 46、开启档最低 13),这个退路从一开始就不成立——它让判据看起来有兜底,实则在噪声里画了条线。有了 `thinking` 正文这个真信号,魔数退路失去存在理由。
|
||||
|
||||
**L5 是本次改动里最重要的一条。** M3 非流式下推理正文与 ctd 双双缺失(实测),旧断言"非流式开启档应观测到推理"**永远不可能成立**——它断言的是一件事实上不发生的事。新断言改为两条:其一 `prompt_tokens` 锚点在非流式下仍然分开(证明参数确实到达了模型),其二 observation 为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。
|
||||
|
||||
**从"断言一件不成立的事"变成"断言库对这件事的诚实"**——这正是本设计要立的规矩。
|
||||
|
||||
同时在 e2e 报告与 `DEFAULT_CAPABILITIES` 的 evidence 里登记:M3 非流式路径推理不可观测,下游用非流式开推理会**付费买看不见的推理**(completion 53 vs 关闭档 3)。库修不了上游,但必须让它可见。
|
||||
|
||||
## 9. 被否决的路
|
||||
|
||||
| 备选 | 否决原因 |
|
||||
|---|---|
|
||||
| 只把 e2e 判据从 `reasoning_tokens` 改成"看 `thinking` 非空" | 能让四条转绿,但 ① ③ 两层一个不动:下游拿到的仍是歧义的 `None`,能力表过期仍然静默。修的是测试不是库 |
|
||||
| 给 `ThinkingCapability` 加可观测性字段 | 静态声明必然过期,等于再造一个 8-02 版定时炸弹(§4.2) |
|
||||
| 用"`completion_tokens_details` 容器在不在"区分 ABSENT/UNKNOWN | 实测三家未推理时都是容器整体缺失,该信号与真实信号混淆(§4.2) |
|
||||
| transport 内维护"该源历史上是否上报过推理信号"的学习态 | 行为依赖历史 → 不可复现、难测试;与"纯 asyncio 中立、无隐式状态"相抵 |
|
||||
| 观测与声明矛盾时抛错 | 一次观测不足以否决一次成功调用;且与降级方向铁律的分工不符(§5) |
|
||||
| 顺手把遥测四处复制的参数列表收敛为单一 helper | 见 §12 |
|
||||
|
||||
## 10. 非功能维度
|
||||
|
||||
**并发与取消**:裁定是纯函数,无 I/O、无状态;对账节流集合是 per-transport-instance 的 set,无跨实例共享、无模块级单例。`CancelledError` 路径完全不变(新增代码不在任何 await 之间持有资源)。
|
||||
|
||||
**降级方向**:可观测性属遥测方向 → 静默降级(warning),不报错、不阻断调用。遥测新列走既有 backfill;旧表缺列时既有的"缺列告警 + 降级写入"逻辑原样覆盖。
|
||||
|
||||
**幂等与重复**:纯函数,重复调用同结果。遥测 INSERT 仍走 `ON CONFLICT DO NOTHING` / `INSERT OR IGNORE`。
|
||||
|
||||
**持久化与原子性**:仅增一列,无写入路径变化。新列排在 `created_at` 之后(旧表只能 ALTER 追加到末尾,新建库若插在前面则两条路径的物理列序分叉——既有列序纪律,不可违)。PG 侧 `TEXT` 可空、无默认值,补列只改 catalog 不重写全表。
|
||||
|
||||
**零业务假设**:新增词汇全部是模型调用领域术语(thinking/reasoning/observation),无业务领域词。
|
||||
|
||||
## 11. 错误处理与测试策略
|
||||
|
||||
新增裁定不产生新的失败模式,**不进四分类**。`ThinkingUnsupportedError`(装配期配置错误,`ValueError` 子类)的语义与抛出位置不变,只换模块归属。
|
||||
|
||||
| 层 | 覆盖 |
|
||||
|---|---|
|
||||
| 单元 | `observe_thinking` 四条分支 + 空白串不算 OBSERVED;对账四类告警(False×OBSERVED 已登记 / False×OBSERVED 未登记 / True×ABSENT / True×UNKNOWN)与两类不表态;节流只喊一次;`LLMResponse`/`TransportResult` 默认值为 UNKNOWN 且位置构造不破;端口签名冻结(25 参);缓存回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中;遥测归一化对裸 str 与域外值都不丢整行;schema 列数与列序断言(既有测试自动抓);包根导出面 |
|
||||
| 集成 | SQLite/PG 新列 backfill 与 round-trip(既有测试模式) |
|
||||
| e2e | §8 判据重建,合并前 `pytest -m slow` 真跑并存档报告 |
|
||||
|
||||
**先失败后通过的证据**:`observe_thinking` 与对账的单测在字段落地前必然红;e2e 的 L2/L4 在判据改完、字段落地后应从当前 main 的 FAIL 转绿(库本来就拿到了 `thinking`,只是没人看)。L5 的新断言在旧代码上无法表达(`thinking_observation` 不存在),是纯新增覆盖。
|
||||
|
||||
## 12. 明确不做
|
||||
|
||||
**不重构遥测组装路径。** 铁律"遥测调用点收敛为单一 helper"**当前已经满足**:`TelemetryEmitter._record` 是全库唯一调用 `record_llm_call` 的地方(`middleware/telemetry.py` 文件头即如此声明)。三个 `emit_*` 是三个语义不同的入口(逐次尝试 / 缓存命中 / 终态失败),各自组装参数是职责所在,不是复制粘贴债务——本次新增字段照样只经 `_record` 一个出口下沉。
|
||||
|
||||
**不改 M3 的 `can_disable`**:2026-08-25 复测 `reasoning_effort=none` → prompt 194(= 基线)、completion 3、无正文,声明依然成立。只刷新 evidence 日期并补记两条新限制(非流式不可观测、仅 `reasoning_effort` 有效)。
|
||||
|
||||
**不追 MiniMax 为何停报 ctd**:那是上游的事,库无从干预,也不该把自己的正确性押在它身上——本设计的全部要点正是让库在它停报时依然说得清话。
|
||||
|
||||
## 13. 版本号
|
||||
|
||||
本次含:`LLMResponse` 新增公共字段、新增模块 `thinking.py`、新增包根导出、遥测新增一列、`providers.py` 深路径 import 断裂。按语义化版本这是 **minor**。1.3.0 仅新增一个 `TelemetryStatus` 导出即定为 minor,本次变更面更大。
|
||||
|
||||
曾建议 1.4.0,理由是把"深路径 import 断裂"藏在 patch 版号里等于留债——下游看 1.3.0→1.3.1 不会去读 CHANGELOG。
|
||||
|
||||
**人类 2026-08-25 决定:发 1.3.1。** 决定已记录,实施按此执行。既然版号不再承担预警职责,预警必须由 CHANGELOG 独立扛起:断裂项与改法置于本版条目**最前**,沿用 1.3.0"请先读这一条"的体例,不得只在中段一笔带过。
|
||||
|
||||
## 14. 验收标准
|
||||
|
||||
- `observe_thinking` 四条分支与对账三种组合有单测,节流经测试确认只喊一次
|
||||
- `LLMResponse.thinking_observation` 在 M3 开启流式档实测为 `OBSERVED`、非流式档为 `UNKNOWN`、qwen 开启档为 `OBSERVED`
|
||||
- 遥测 SQLite/PG 两端新列均可写可读,旧表 backfill 通过,列序断言绿
|
||||
- `tests/e2e/test_thinking_live.py` 全类绿(`pytest -m slow` 真跑,报告存档 `tests/outputs/e2e/`)
|
||||
- 端口 `record_llm_call` 25 参,两个 recorder 与全部测试替身同步,签名冻结测试绿
|
||||
- 缓存回放的 `thinking_observation` 是 `ThinkingObservation` 实例而非裸字符串
|
||||
- `make lint`(含 import-linter 契约,须确认 `types.py` 未 import `thinking.py`)与全套件绿
|
||||
- README 的遥测字段数经 `inspect.signature` 实测更新为 25;ARCHITECTURE §8 模块结构含 `thinking.py`;`schemas/llm-calls.md` 由过期的"22 字段"订正为 25;本 design 与 finding 进 `research-wiki/index.md`
|
||||
- CHANGELOG 本版条目**最前**列出深路径 import 断裂与改法、端口签名变更、M3 非流式付费不可见推理这一事实(§13)
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:2026-08-26-issue18-pg-test-isolation
|
||||
title: "issue #18: 隔离靠权限强制,目标靠显式声明"
|
||||
date: 2026-08-26
|
||||
---
|
||||
|
||||
# issue #18:隔离靠**权限强制**,目标靠**显式声明**
|
||||
|
||||
> 类型:design|日期:2026-08-26|状态:待 Codex 审 → 人类审
|
||||
> 事实基础见 `findings/2026-08-26-issue18-shared-pg-test-isolation.md`(本文所有实测引用均出自该文)。
|
||||
> 两处需人类拍板的取舍已于 2026-08-26 会话中确认:`--table` **纳入**;7 条写真表的用例**全迁**;`public.llm_calls` 里那 11 行历史孤儿行**不清理**。
|
||||
|
||||
## 1. issue #18 的诊断只对了一半
|
||||
|
||||
issue 判定"行数断言依赖共享实例的当下状态",方向对;它推荐的首选处置(标 `slow`,交发布清单统一跑)**不解决问题**——标 `slow` 只是把假红挪出日常关卡,而这条断言还有另一半失效:
|
||||
|
||||
| 失效方向 | 表现 | 标 `slow` 之后 |
|
||||
|---|---|---|
|
||||
| 假红 | 外部进程写/删共享表 → 断言红,脚本无辜 | 挪到发布关卡,**照样红**,只是红得更少人看见 |
|
||||
| **假阴** | 外部插入与脚本误删互相抵消 → 行数相等 → 静默放行 | **原样保留** |
|
||||
|
||||
这条断言守的是"脚本静默删了共享的真表"。假阴才是它真正的代价,而 `slow` 对假阴毫无作用。
|
||||
|
||||
## 2. 根因三层
|
||||
|
||||
| 层 | 事实 | 后果 |
|
||||
|---|---|---|
|
||||
| L1 | `_public_count` 是全套件唯一一处**全表口径**断言,而同文件的 `_RUN_PREFIX` 机制从设计上就假定"多个进程并行写同一张表" | 两套前提互斥,偶发红是必然而非意外 |
|
||||
| L2 | 一个**安全属性**(脚本不越界)被编码成对**全局可变量**(真表行数)的观测 | 假红 + 假阴,结论既不可靠也不可否证 |
|
||||
| L3 | 之所以只能这么写:`telemetry_retention.py` 的目标表由连接的 `search_path` 隐式决定(`to_regclass('llm_calls')`),**调用点无法声明"我要删哪张表"** | 测试没有别的手段表达"只许动这张表",只好退回事后观测 |
|
||||
|
||||
L3 不是测试的问题,是脚本契约的问题——它同时是生产风险:`search_path` 默认首项是 `"$user"`,换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。脚本现有的应对是把解析结果打印出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
|
||||
|
||||
## 3. 设计主张
|
||||
|
||||
1. **安全属性由数据库权限强制,不由断言观测**——测试跑脚本用的角色对 `public.llm_calls` 无任何权限,越界不是"会被发现",而是"做不到"。
|
||||
2. **目标表由调用方声明**——`--table SCHEMA.NAME` 给出后,目标不再经 `search_path` 推断。
|
||||
3. **测试与真实共享表完全脱钩**——`public.llm_calls` 从此零测试触碰,隔离手法收敛为"临时 schema"一种,并由 lint 门机械化守住。
|
||||
|
||||
## 4. 变更 A:`telemetry_retention.py` 新增 `--table SCHEMA.NAME`
|
||||
|
||||
### 4.1 语义:声明即目标,不是"声明后比对"
|
||||
|
||||
两种可能的实现要先分清:
|
||||
|
||||
| | 做法 | 结果 |
|
||||
|---|---|---|
|
||||
| 否决 | 仍按 `search_path` 解析,再与声明比对,不符则退出 | 目标**仍然**由环境决定,`--table` 只是一道确认;且要为"不符"发明第四个退出码语义 |
|
||||
| **选定** | 给了 `--table` 就用 `to_regclass('"schema"."name"')` **精确解析**,绕开 `search_path` | 目标真正由参数决定;不存在则落入既有的"目标表不可用"语义 |
|
||||
|
||||
选定做法的实现落点只有一处——`_purge_postgres` 里 `to_regclass($1)` 的入参从裸 `TABLE` 换成引号限定名,分区探测、统计、分批 DELETE 全部不变(它们本就用解析结果拼 `qualified`)。
|
||||
|
||||
三条支撑它的 PG 语义已实测(PostgreSQL 16.14,见 finding §7):`to_regclass('"schema"."llm_calls"')` 正常解析;**schema 不存在时返回 NULL 而不抛错**;引号限定名**区分大小写**(`"PGWPROBE_S_X"."llm_calls"` → NULL)。前两条决定了"找不到"能落进既有的退出码 2 而不需要新分支,第三条决定了 §4.2 的"逐字比较"是可实现的。
|
||||
|
||||
### 4.2 参数与校验
|
||||
|
||||
| 规则 | 行为 | 理由 |
|
||||
|---|---|---|
|
||||
| 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款;SQLite 库文件即目标,无 schema 概念,无歧义可消 |
|
||||
| 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**,提示写成 `schema.表名` | 单段等于没声明,隐式性原样保留 |
|
||||
| **表名段必须逐字等于 `llm_calls`** | `--table audit.events` → 退出 **1**,消息点明本脚本只清理 `llm_calls` | 见 §4.4:不加这条,`--table` 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具" |
|
||||
| 两段均非空;**schema 段须为普通标识符**(`[A-Za-z_][A-Za-z0-9_$]*`) | 不合法 → 退出 **1** | 复杂标识符(含引号的表名)不支持,此时退回不给 `--table` 的路径;写进 `--help`。**本行原写作"均不含 `.` 与 `\"`",实现阶段核出"段内含 `.`"是不可达分支**——按 `.` 切分后恰好两段是前置条件,`a.b.c` 走的是"不是恰好两段"那条消息,故删去该半句 |
|
||||
| **逐字比较,不做大小写折叠** | 传 `_quote()` 包裹的限定名给 `to_regclass` | catalog 里存的是真实标识符;未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 |
|
||||
| 解析不到 | 退出 **2**,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**:诊断方向不同。**退出码维持 2 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 |
|
||||
| 无权限 | 后续 `COUNT` 抛 `PostgresError` → 既有 except → 退出 **2** | 无需新增分支 |
|
||||
|
||||
退出码不新增。`1` 留给"参数写错了,重试也没用",`2` 留给"环境不对,值得告警"——这条分界是脚本已有的对调度器契约(见 `_Parser.error` 的注释),本变更沿用。
|
||||
|
||||
### 4.3 目标白名单:为什么表名段不可变
|
||||
|
||||
`--table` 若只校验"两段、非空、无点无引号",一次手误 `--table audit.events` 就会让脚本对一张**恰好也有 `created_at` 与 `tenant_id` 列**的业务表执行同一套 COUNT + 分批 DELETE。脚本的名字、`--help`、退出码 3 的分区提示、README 的定位全都是围绕遥测表 `llm_calls` 写的,它从未声称自己是通用清理器;让参数悄悄扩大作用域,是在一个**默认 dry-run、拿 DELETE 权限跑**的脚本上开一个静默的口子。
|
||||
|
||||
故 `--table` 的可变部分只有 schema 一段。**为什么不干脆改叫 `--schema`**:cron 配置里的那一行必须自解释——运维读 crontab 时看到 `--table public.llm_calls` 就知道全部目标,看到 `--schema public` 还得回去查脚本常量才知道表名。多出的那条校验不是冗余,它本身就是"本脚本的作用域到此为止"的显式声明,且错误消息可以当场把边界告诉用户。
|
||||
|
||||
### 4.4 未声明时的提示
|
||||
|
||||
`--apply` 且未给 `--table` 时,在"目标表: x.y"之后补一行:
|
||||
|
||||
```
|
||||
注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。
|
||||
```
|
||||
|
||||
只在 `--apply` 时打:dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途,多一行提示是噪音。
|
||||
|
||||
## 5. 变更 B:测试角色化——把安全网换成权限边界
|
||||
|
||||
### 5.1 模型
|
||||
|
||||
**凡是启动 `telemetry_retention.py` 子进程的用例,一律用临时登录角色跑,无一例外**——包括正向的 apply/dry-run/分区让路用例。只给"最坏情况"那一条用低权限角色是自欺:正向用例才是带 `--apply` 真删数据的那些,它们若仍用 `.env` 的 superuser DSN 跑,一旦 `search_path` 或 `--table` 出问题,删的就是真表,而新设计里已经没有行数快照会发现它。
|
||||
|
||||
每个这样的用例临时建一个**登录角色** `tmp`,并 `CREATE SCHEMA s AUTHORIZATION tmp`,表由 `tmp` 自己建。于是:
|
||||
|
||||
- `tmp` 是那张表的**属主**——与脚本文档要求的"用维护角色跑"形态一致,测的不是一个失真的现场
|
||||
- `tmp` 对 `public.llm_calls` 一无所有:实测 ACL 为 `{app=arwdDxt/app, chs3_test=ar/app}`,无 PUBLIC 授权
|
||||
|
||||
**必须换角色的原因**:`.env` 里的 `app` 实测 `rolsuper = true`,superuser 无视一切权限检查,用它跑则这条防线不存在。无 `CREATEROLE` 权限的环境 `skip`(项目既有惯例,见 `least_privilege_dsn`)。
|
||||
|
||||
防线已实测:临时角色裸连(`search_path = "$user", public`)对真表执行 `COUNT` 与 `DELETE`,两者均 `InsufficientPrivilegeError: permission denied for table llm_calls`。
|
||||
|
||||
**约束:角色名与 schema 名必须错开。** 实测 `CREATE SCHEMA X AUTHORIZATION X` 时,`"$user"` 会命中自有 schema 并**遮蔽 public**——今天 `least_privilege_dsn` 正是同名形态。同名虽多一层巧合式防护,却让 §5.3 的最坏情况用例根本走不到 public,等于测了个假现场。故 `pg_sandbox` 一律用 `pgw_s_<uuid>` / `pgw_r_<uuid>` 两套名字。
|
||||
|
||||
### 5.2 最坏情况从"事后观测"变成"确定性红灯"
|
||||
|
||||
| 情形 | 旧 | 新 |
|
||||
|---|---|---|
|
||||
| `search_path` 失效,脚本落到 `public` | 事后数行数,可能被并发抵消 | 数据库拒绝 → 退出 2 → 测试红,**且一行都删不掉** |
|
||||
| 外部进程并发读写 `public` | 直接假红 | 与测试无关(不再读 `public`) |
|
||||
|
||||
`_public_count` / `before_public` / 那条 `assert` 整体删除。
|
||||
|
||||
### 5.3 新增一条"最坏情况"用例,替代被删掉的安全网
|
||||
|
||||
用属主角色的 DSN **不挂 search_path** 跑脚本(于是解析走 `"$user", public`,角色同名 schema 不存在 → 落到 `public.llm_calls`),不给 `--table`:
|
||||
|
||||
- 断言退出码 **2**、stderr 非空且点名 `llm_calls`、临时表内容一行未变
|
||||
- **不断言 PG 的英文错误原文**(服务端 `lc_messages` 不由测试掌握),也**不出现 `public.llm_calls` 字面量**(见 §7 的 lint 门)
|
||||
- 库里没有 `public.llm_calls` 的环境上,脚本报"找不到表"同样退出 2 —— 两条路都绿,用例不因环境而摇摆
|
||||
|
||||
这条用例把"最坏情况"钉成确定性的红/绿,且完全不观测共享状态。
|
||||
|
||||
## 6. 变更 C:7 条用例迁出 `public`
|
||||
|
||||
| 用例 | 迁移后验的东西 |
|
||||
|---|---|
|
||||
| `TestSchema::test_schema_has_frozen_columns_in_order` | **变强**:现在验的是本机那张被历史 `_BACKFILL` 补过列的老表,迁到 fresh schema 后验的是**库当前 DDL 建出来的表** |
|
||||
| `TestObservabilityColumns::test_values_round_trip` | 不变(只要求表存在) |
|
||||
| `TestSchema::test_call_id_idempotent` / `test_concurrent_writes_all_land` | 不变(与表在哪无关) |
|
||||
| `TestDegradation::test_row_failure_does_not_poison_later_rows` / `test_aclose_idempotent` | 不变 |
|
||||
| `TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max` | 不变(验的是连接数),但**必须保留唯一 `application_name`**,见下 |
|
||||
|
||||
### 6.1 `_RUN_PREFIX` 有两个职责,只能删掉其中一个
|
||||
|
||||
| 职责 | 落点 | 处置 |
|
||||
|---|---|---|
|
||||
| call_id **行隔离** | `_cid()` 的 63 处调用、5 处 `LIKE '<前缀>%'` 过滤、`dsn` fixture teardown 的 `DELETE` | 删除——schema 隔离已完全取代它 |
|
||||
| **`application_name` 唯一** | `test_pool_does_not_preconnect_and_stays_within_pool_max` 用它标记本池连接,再查 `pg_stat_activity` 数连接数 | **保留**(就地生成 uuid)——连接是**实例级**共享资源,schema 隔离对它无效;改成固定名字会把并行进程的连接数进来,等于把偶发红从表层搬到连接层 |
|
||||
|
||||
删除行隔离用途时调用点做**机械替换**(`_cid("c1")` → `"c1"`),不改任何断言语义;5 处 `LIKE` 过滤逐条在计划里列出并单独验证。
|
||||
|
||||
### 6.2 顺带封掉一个仓库自己已记载的隐患
|
||||
|
||||
`test_schema_has_frozen_columns_in_order` 今天查的是 `information_schema.columns WHERE table_name='llm_calls'`,**不带 schema 过滤**——库里任何一个残留的临时 schema 里的同名表都会污染结果。这不是推测:`production_template` 的 `except BaseException` 分支注释里已经写明了这个坑("会被残留物在下一次运行里以列数不符的形态误伤"),当时的处置是让另一处 fixture 清理得更干净。迁移时补上 `table_schema = $1`,把它从"靠别人不留残留"改成"自己只看自己"。
|
||||
|
||||
**用函数级而非 module 级 sandbox**:建/删一个 schema 是毫秒级,7 条用例的开销可忽略;module 级共享会把"用例之间互不影响"这条重新变成需要论证的事。
|
||||
|
||||
## 7. 变更 D:`conftest.py` 收敛 + lint 门
|
||||
|
||||
### 7.1 一个沙箱工厂取代七处样板
|
||||
|
||||
`tests/integration/conftest.py` 新增:
|
||||
|
||||
| fixture | 职责 |
|
||||
|---|---|
|
||||
| `pg_admin_dsn`(session) | 读 `.env`、缺失 `skip`、库名守卫(只许 `polygateway`)。**命名下划线语义上属内部**,用例不该直接用 |
|
||||
| `pg_sandbox`(function,工厂) | `await pg_sandbox(ddl=..., extra=(), owner_role=False)` → 返回 frozen dataclass(`schema` / `dsn` / `role`);teardown 按 LIFO 统一 `DROP SCHEMA CASCADE` + `DROP OWNED BY` + `DROP ROLE` |
|
||||
|
||||
三条硬约束(缺一条工厂就会自己变成污染源):
|
||||
|
||||
1. **资源逐步登记,`except BaseException` 清理**:建角色成功、建 schema 失败时不会走到 `yield`,普通 teardown 不执行,角色就永久留在实例上(角色是**全局**对象,不随库消失)。`production_template` 已有同款先例,工厂必须继承它而不是简化掉。
|
||||
2. **uuid 后缀取 12 位十六进制**:8 位在并行会话下碰撞概率虽低却非零,而碰撞的后果是 `CREATE ROLE` 失败或误清理别人的残留。加长的成本为零。
|
||||
3. **admin DSN 不做成 fixture**:改为模块私有函数,只被工厂内部调用。做成 fixture 就等于把一个能 `DELETE FROM public.llm_calls` 的连接摆在所有用例面前,"用例不该直接用"只是纪律不是机制。
|
||||
|
||||
今天这套样板在两个文件里重复**七处**(`legacy_schema`、`pre_tenant_schema`、`fresh_schema`、`partitioned_schema`、`least_privilege_dsn`、`least_privilege_pre_tenant_dsn`、`production_template`,加 retention 侧两处)。收敛后清理逻辑只有一份——今天任何一处 teardown 写漏,残留都落在共享库里。
|
||||
|
||||
### 7.2 机械化执法
|
||||
|
||||
`make lint` / `make check` 各加一步:
|
||||
|
||||
```
|
||||
tests/ 下不得出现字面量 public.llm_calls —— 命中即 exit 1
|
||||
```
|
||||
|
||||
§5.3 的用例已按"不出现该字面量"设计,故门无需豁免名单——**注释与 docstring 同样不例外**,现有多处"共享的 public.llm_calls"措辞改写为"共享表 `llm_calls`"。豁免名单一旦开口,门就退化成建议。
|
||||
|
||||
**这道门是烟雾报警器,不是隔离证明。** 它拦不住 `f"{schema}.{table}"` 拼接、`to_regclass($1)` 参数化、或不带限定名的 `DELETE FROM llm_calls` 配上 admin 的默认 `search_path`。真正的隔离来自两处:工厂 API 不把 admin DSN 交出去(§7.1 约束 3),以及脚本以无权角色运行(§5.1)。文档里必须这样写,否则下一个人会拿这道门当"tests 零触碰 public"的证明。
|
||||
|
||||
## 8. 明确不做
|
||||
|
||||
| 不做 | 理由 |
|
||||
|---|---|
|
||||
| 标 `slow` | §1:对假阴无效;改完之后这条用例的成败不再取决于外部服务状态,它**应该**留在日常关卡里 |
|
||||
| 建临时数据库(而非 schema) | PG 的 schema 对 DML/DDL 已是完备隔离;建库只换来"孤儿库更难清、需 CREATEDB、断连才能 DROP"三项成本 |
|
||||
| 清理 `public.llm_calls` 里那 11 行孤儿行 | 人类决策:那是与迁移项目共用的表,本次不动 |
|
||||
| 给 SQLite 分支加 `--table` | 库文件即目标,无歧义(§4.2) |
|
||||
| 动 Redis 集成测试 | 实测已是每用例 uuid 命名空间/scope,无全表口径断言,不属同类 |
|
||||
| 把 `--table` 做成必填 | 会打断下游既有 cron,属破坏性契约变更 |
|
||||
|
||||
## 9. 残余风险(本设计**不**覆盖,需明写而非默认解决)
|
||||
|
||||
| 风险 | 为什么不在本设计覆盖范围 | 缓解 |
|
||||
|---|---|---|
|
||||
| fixture / teardown 里用 admin 连接手滑写真表 | admin 连接必须存在(建 schema/角色本身就需要它),权限边界对它无效 | 工厂不把 admin DSN 交给用例;§7.2 的门能拦住字面量形态 |
|
||||
| 进程被 `SIGKILL` 时 pytest finalizer 不执行,残留 schema/角色 | 任何进程内机制都做不到 | 命名固定前缀 `pgw_s_` / `pgw_r_`,残留可一条 SQL 查出(`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'`);**不做自动 TTL 清理**——并行会话下"清理别人的残留"会误删正在跑的 schema,比残留本身更危险 |
|
||||
| 共享实例上其他项目往真表写/删 | 不归本库管 | 改完之后本仓库测试对它完全不敏感,这正是本设计的目的 |
|
||||
| `production_template` 仍以管理身份执行不带限定名的 `DELETE` / `DROP TABLE` | 它有意不收敛进工厂(§7.1 末段),三角色与分区语义是它自己的 | 独立验证实测:它的连接 `search_path` **只有**自己那个 schema(`public` 不在路径里),故 `to_regclass('llm_calls')` 返回 `None`——search_path 一旦失手,报的是"关系不存在"而不是静默打到共享表 |
|
||||
| `pg_catalog_probe` 持管理连接 | 工厂自测需要查 catalog 核对残留,这个能力删不掉 | 探针只接受 `SELECT` 开头的语句(有用例钉住);它不交出 DSN,故越界能力止于只读查询 |
|
||||
|
||||
## 10. 版本号与发布
|
||||
|
||||
**1.3.2**(patch)。需在 CHANGELOG 里如实写明:`tools/` 与 `tests/` **都不在 pip 包内**(README 已声明脚本随仓库分发),故 1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同,本版的对外内容是**运维脚本的契约扩展**与测试确定性,不是库能力更新。不得包装成库更新。
|
||||
|
||||
发布按 CLAUDE.md §4.4.1 九步全走,其中与本变更直接相关的:README 需补 `--table` 用法与安装版本约束核对;`make wiki-check` 需在合并前跑过;合并后在 main 上补跑 `pytest -m slow`。
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
| # | 判据 | 验证方式 |
|
||||
|---|---|---|
|
||||
| 1a | `--table` 的**参数分类**:sqlite 互斥、非两段、空段、含点/引号、表名段非 `llm_calls` —— 各自退出 1 | 单测(`tests/unit/test_retention_tool.py`,无需 PG) |
|
||||
| 1b | `--table` 的**真实解析行为**:显式指向 sandbox 表成功删除;指向不存在的 schema → 2;指向无权表 → 2;指向分区表 → 仍 3 | **集成用例(必须真连 PG)**——单测只能验参数分类与拼出的目标字符串,验不了 `to_regclass` 的真实语义 |
|
||||
| 2 | 未给 `--table` 且 `--apply` 时打印推断提示 | **集成用例**断言 stdout —— 该提示行只在 PG 分支打印,不连库的单测触发不到它(本行原写作"单测断言 stdout",计划阶段核出该判据不可执行,就地更正) |
|
||||
| 3 | 最坏情况(search_path 落到 public)**删不掉任何行**且退出 2 | §5.3 新用例 |
|
||||
| 4 | 整套 `tests/integration` 连跑三次全绿,其间 `public.llm_calls` 行数由外部任意变动 | 连跑 + 期间手工改动共享表行数 |
|
||||
| 5 | `tests/` 下 `public.llm_calls` 零命中 | `make lint` |
|
||||
| 6 | 迁移未削弱任何用例:7 条用例的断言逐条对照迁移前后 | 计划阶段逐条列表,verifier 复核 |
|
||||
| 6b | `test_pool_does_not_preconnect...` 仍持有唯一 `application_name` | 代码复核 + 两进程并发跑该用例 |
|
||||
| 6c | `test_schema_has_frozen_columns_in_order` 带 `table_schema` 过滤 | 故意在库里留一个残留同名表,用例仍绿 |
|
||||
| 6d | 沙箱工厂 setup 中途失败不留角色/schema | 注入一个会失败的 DDL,跑完查 `pg_namespace` / `pg_roles` 无 `pgw_%` 残留 |
|
||||
| 7 | 全套件 + `-m slow` 全绿 | 合并前 |
|
||||
|
||||
## 12. 审查留痕(Codex,2026-08-26)
|
||||
|
||||
报 6 项实质问题,**全部采纳**,其中两项为阻断级:
|
||||
|
||||
| # | 意见 | 处置 |
|
||||
|---|---|---|
|
||||
| 1 | **阻断**:`--table` 未限定表名段,会把脚本扩成"任意同形表删除工具"(`--table audit.events` 且该表恰有 `created_at`/`tenant_id` 时真删数据) | 采纳,见 §4.2 新增规则与 §4.3 |
|
||||
| 2 | **阻断**:只给"最坏情况"用例换低权限角色,正向 apply 用例仍用 superuser 跑,则新安全网对最危险的那条路径不生效 | 采纳,§5.1 改为"凡启动脚本的用例一律用临时角色,无一例外" |
|
||||
| 3 | `_RUN_PREFIX` 有第二个职责(`application_name` 唯一),机械删除会让连接池用例失去并发隔离 | 采纳,§6.1;本会话的独立清点也得出同一结论 |
|
||||
| 4 | `test_schema_has_frozen_columns_in_order` 的 `information_schema` 查询不带 schema 过滤 | 采纳,§6.2;核实属实,且仓库注释已记载该坑 |
|
||||
| 5 | 沙箱工厂 setup 中途失败不清理、uuid 后缀偏短、admin DSN 做成 fixture 等于把越界能力摆在所有用例面前 | 采纳,§7.1 三条硬约束 |
|
||||
| 6 | lint 门只防字面量,不能当"零触碰"的证明;`--table` 的验收不能只靠 unit | 采纳,§7.2 定位改写 + §11 拆出 1a/1b |
|
||||
|
||||
**一处处置与建议不同**:Codex 认为 `Public.llm_calls` 这类大小写手误落到退出 2 属"告警误分类",建议归 1。本设计维持 2,理由写在 §4.2——该参数格式合法,能否解析到是环境事实;归 1 会让"schema 真的不存在"这类该重试告警的情形被调度器当成不必重试的参数错误。手误由错误消息文本消化。
|
||||
@@ -0,0 +1,355 @@
|
||||
# 推理档位一等化设计(issue #20 及其一般形式)
|
||||
|
||||
- **日期**: 2026-09-04
|
||||
- **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门)
|
||||
- **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过
|
||||
- **影响面**: `SourceConfig`/`ChatRequest` 公共类型、`ProviderProfile`/`ThinkingCapability` 公共类型、`resolve_thinking`/`reconcile_thinking` 公共函数、缓存 key 公式(ARCH §7.5)、遥测端口(25 → 26 字段)、`.env` 键
|
||||
- **人类拍板(2026-09-04)**: 作用域取「源级默认 + 请求级覆盖」;档位不支持时「默认报错、可显式开映射」;不可关闭时「报错并给可执行替代」
|
||||
- **人类复核(2026-09-04,针对 Codex 异议)**: `effort_fallback` 的最近档映射**要实现**,不因当前无已知消费者而推迟;方案选择标准 = 架构可维护性/清晰度 > 代码简洁 > 鲁棒性
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题不是 issue #20 说的那个
|
||||
|
||||
issue #20 的字面诉求是补一条 `zhipu` profile。补上它**不能**解决它自己描述的失败,因为二态 bool 在新一代模型上无档可填。
|
||||
|
||||
2026-09-04 调研,四份独立注册表(cherry-studio 客户端注册表、OpenRouter `/models` 的 `reasoning` 字段、LiteLLM 模型元数据、我们自己的网关 new-api `relaykit/relayconvert/reasoning/`)与六家官方文档,三条结论直接推翻 issue #20 的建议:
|
||||
|
||||
| # | 结论 | 证据 |
|
||||
|---|---|---|
|
||||
| 1 | **GLM-5.3 官方强制推理**,`thinking.type` 只接受 `enabled`;官方档位 `low/high/max`,`none` **不是**它的档位 | 智谱官方文档;cherry `toggle:false`;OpenRouter `mandatory:true` 三源一致 |
|
||||
| 2 | **`medium` 只在 GPT-5.x / Claude 5 / Gemini 3 三家存在** | 见 §8 档位表 |
|
||||
| 3 | 不可关闭不是孤例: GLM-5.3 系、Gemini 3 Pro / 3.1 Pro 为 mandatory;MiniMax M2.x **接受 `disabled` 但不生效** | 官方文档;与本库 2026-08-02 实测一致 |
|
||||
|
||||
第 1 条意味着 issue #20 建议的 `can_disable=True` 不能登记:我们发出去的 `reasoning_effort:"none"` 是个**未定义值**,智谱按自己的方式处理(多半当最低档)。这正好解释 issue #20 自己观测到的「短提示词 rt≈1.2,5552 token 长上下文跳到 0/54/167」——低档本来就要想,只是短提示词下想得少。
|
||||
|
||||
第 2 条意味着现有 `minimax` profile 那条「`thinking_on` 统一取 medium」的约定,推广到 GLM/kimi/deepseek 上全部是空档。
|
||||
|
||||
**真实缺口**: `enable_thinking: bool | None` 这个类型表达不了现实。补数据不能修复类型。
|
||||
|
||||
## 2. 现状审计(旧行为逐条处置)
|
||||
|
||||
替换 `thinking.py` 的请求侧决策,响应侧与对账基本保留。逐条声明:
|
||||
|
||||
| # | 现有行为 | 处置 |
|
||||
|---|---|---|
|
||||
| 1 | `enable_thinking` 三态: None 不注入 / True 注入 on / False 注入 off | **保留**语义,降为 `reasoning_effort` 的语法糖(§4.2) |
|
||||
| 2 | `ProviderProfile.thinking_on/off` 两个固定片段,`None`=形态未知 | **替换**为 `ThinkingWire`(§3.3);`None`=未知的语义**保留**。**判据须按请求档位取相关字段**(旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此)——初稿 §4.1 Phase 2 写成「只看 `on_base`」是错的: 那会让「关闭形态已知、开启形态未知」的自定义 provider 在请求 `none` 时被误拒,且指路指向它已经做过的 `register_provider`,比不指更糟(2026-09-05 独立验证查出) |
|
||||
| 3 | `ThinkingCapability.can_disable: bool` | **替换**为 `supported_efforts`;`can_disable` 成为 `'none' in supported_efforts` 的派生(§3.2) |
|
||||
| 4 | `evidence: str` 强制附实测出处 | **保留**,且强化: 初始表全部标注「文档推定,待实测」 |
|
||||
| 5 | `resolve_thinking` 四道关卡(不表态/形态未知/能力未登记/不可关闭) | **保留四关的顺序与语义**,判据从 bool 换成档位(§4.1) |
|
||||
| 6 | 能力未登记 → warning 后尽力注入 | **保留**(新模型不该被库挡住,ARCH §5 R4) |
|
||||
| 7 | `observe_thinking` 多信号裁定三态 | **保留**,不改一行 |
|
||||
| 8 | `reconcile_thinking` 声明 × 观测对账,矛盾返回文案、不抛错 | **保留**,判据扩展到档位(§4.3) |
|
||||
| 9 | transport 按 `(source, model, direction)` 节流告警 | **替换**: 节流键的 `direction` 换成生效档位——同一模型 low 与 max 是两个独立的矛盾 |
|
||||
| 10 | `ThinkingUnsupportedError(ValueError)`,由 transport 翻译为 `RequestRejectedError` | **保留**,新增的档位错误走同一条路 |
|
||||
| 11 | `_build_payload` 中 `resolve_thinking` 结果先于 `extra_body`/`overlay` | **保留**(顺序即优先级,issue #4 决策 A) |
|
||||
| 12 | 缓存 key 不含任何推理参数 | **修复**(§5,现存缺口) |
|
||||
| 13 | 遥测无档位列 | **新增**一列(§6) |
|
||||
|
||||
**有意放弃**: 无。第 3 条的 `can_disable` 是唯一的破坏性变更,迁移见 §12。
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 档位词汇
|
||||
|
||||
八档封闭枚举,取四家参考实现共同收敛的词汇(cherry / OpenRouter / LiteLLM / new-api 用的是同一套):
|
||||
|
||||
```python
|
||||
class Effort(StrEnum):
|
||||
NONE = "none"; AUTO = "auto" # 不推理 / 推理但档位由模型自定
|
||||
MINIMAL = "minimal"; LOW = "low"; MEDIUM = "medium"
|
||||
HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
|
||||
```
|
||||
|
||||
`none` 即「不推理」,与强度档同处一个词汇表——这是关键的表达力来源: 「能不能关」不再是独立的布尔,而是 `none` 在不在该模型的支持列表里。
|
||||
|
||||
`auto` 不可省(自审补): newapi 上 26 个模型里有 9 个是**纯开关型**(qwen 五个、MiniMax-M3、glm-5/5.1/4.6v),它们能开推理但没有档位名可填。没有 `auto` 就只能拿某个强度档冒充「开」,而那正是现有 `thinking_on` 硬编码 `medium` 的病根。`auto` 的 wire = `on_base` 不附 `effort_key`,恰好等于旧的 `thinking_on` 行为。四家参考实现都有这一档(cherry 的 canonical selection `'default'|'none'|'auto'|Effort`;new-api 的 `ModeAdaptive`)。
|
||||
|
||||
`Effort` 归 `types.py`(最内层纯值类型),与 `ThinkingObservation` 同处一处,理由相同: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import。
|
||||
|
||||
### 3.2 `ThinkingCapability`: 能力(按 model)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingCapability:
|
||||
supported_efforts: tuple[Effort, ...] # 顺序 = 由弱到强
|
||||
evidence: str
|
||||
```
|
||||
|
||||
**不设 `default_effort` 字段**(Codex 审查采纳): 初稿有此字段,唯一消费者是「`enable_thinking=True` 等价于哪档」;自审把该语法糖改成 `Effort.AUTO` 后它就没有消费方了——§4 不用它决策,§6 遥测在不表态时记 `NULL`(库并不观测模型内部默认档,记推定值等于把「没看见」说成「发生了」,违既有纪律)。厂商默认档是**文档知识**,写进 `evidence` 文本即可,不必升格为必须逐模型维护的 API 字段(P1 YAGNI)。
|
||||
|
||||
三个派生量,不单独存字段(存了就会漂移):
|
||||
|
||||
| 派生 | 定义 | 用途 |
|
||||
|---|---|---|
|
||||
| `can_disable` | `Effort.NONE in supported_efforts` | 兼容旧语义 |
|
||||
| `cheapest_effort` | 除 `none` 外的第一档 | 不可关闭时的可执行替代(§4.1 Phase 5) |
|
||||
| 是否档位型 | 除 `none`/`auto` 外仍有 ≥1 档 | 决定告警文案(纯开关型不该说「可选档位」) |
|
||||
|
||||
OpenRouter 与 LiteLLM 两家**独立收敛到了同一形状**(`supported_efforts`+`default_effort` / `reasoning_effort_levels`+`default_reasoning_effort`),这是「档位清单即能力」这一形状可靠的旁证。我们只取其前半——两家都是**面向展示**的目录(要在 UI 上显示默认档),本库是**执行**路径,默认档不参与任何判定,故不设该字段。
|
||||
|
||||
### 3.3 `ProviderProfile`: 形态(按 provider)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingWire:
|
||||
off: Mapping[str, Any] | None # 关闭档的片段;None = 该 provider 无关闭形态
|
||||
on_base: Mapping[str, Any] | None # 开启档的固定部分;None = 形态未知
|
||||
effort_key: str | None # 档位写进哪个键;None = 该 provider 无档位概念
|
||||
```
|
||||
|
||||
`ProviderProfile.thinking_on/thinking_off` 由 `thinking: ThinkingWire` 取代。`None` 表示「未知」这一语义原样保留(issue #5 的核心成果,不可退回)。
|
||||
|
||||
四个形态样例(经 new-api 中转的口径):
|
||||
|
||||
| provider | off | on_base | effort_key |
|
||||
|---|---|---|---|
|
||||
| zhipu | `{"thinking":{"type":"disabled"}}` | `{"thinking":{"type":"enabled"}}` | `reasoning_effort` |
|
||||
| qwen | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None`(无档位,只有 toggle) |
|
||||
| openai / anthropic / google | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` |
|
||||
| minimax | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` |
|
||||
|
||||
### 3.4 为什么不需要 cherry 的 endpoint contract 与 wireDialect
|
||||
|
||||
cherry 有两层我们**明确不做**:
|
||||
|
||||
1. **endpoint-keyed 的 per-model wire 覆盖**。它需要这层,是因为同一模型在 `openai-chat` / `openai-responses` / `anthropic-messages` / `google-generate-content` 四种协议下形态不同。**本库只有一个 chat transport(`openai_compat.py`)**,所有请求都是 OpenAI 兼容形态,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent,见 `relaykit/relayconvert/reasoning/intent.go`)。一个协议 = 一层形态。
|
||||
2. **`wireDialect` 代际方言**(Claude 4.6+ `adaptive` vs ≤4.5 `budget_tokens`;Gemini 3 `thinkingLevel` vs 2.x `thinkingBudget`)。这是**原生协议**才有的问题;我们发 OpenAI 形态的 `reasoning_effort`,代际差异由网关吸收。
|
||||
|
||||
同理,`glm-5.2`(有 `none` 档)与 `glm-5.3`(无 `none` 档)**共用同一份 wire**——差别落在 capability 的 `supported_efforts` 上。本库既有的「形态按 provider、能力按 model」分层,恰好容纳档位而无需新增一层。
|
||||
|
||||
## 4. 解析
|
||||
|
||||
### 4.1 `resolve_thinking`: 五道关卡
|
||||
|
||||
判定顺序即语义。前三关是既有的,判据从 bool 换成档位;**Phase 4「可执行替代」是新增的**,Phase 5 是既有第 4 关的档位化推广。
|
||||
|
||||
| Phase | 条件 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | 生效档位为 `None`(调用方不表态) | 返回 `{}`,不注入 |
|
||||
| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`;`none` 方向须 `off` 与 `on_base` **皆为 `None`** 才算「整体形态未知」——单 `off is None` 是「该 provider 关不掉」,归 `_inject` 说清缺的是哪半边,2026-09-05 实现时补正) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
|
||||
| 3 | 能力未登记 | warning 后按 wire 尽力注入,**不校验档位** |
|
||||
| 4 | 请求 `none` 而该模型无 `none` 档 | `ThinkingUnsupportedError`,**给出 `cheapest_effort` 作为替代** |
|
||||
| 5 | 其余档位不在 `supported_efforts` 且未开映射 | `ThinkingUnsupportedError`,列出该模型可选档 |
|
||||
|
||||
**4 必须先于 5**(自审补): `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就退化成「不支持 none,可选 low/high/max」——丢掉了「这个模型根本关不掉」这个关键信息与可执行替代。
|
||||
|
||||
Phase 4 的文案是本设计的一个交付物,而非装饰:
|
||||
|
||||
> 模型 'glm-5.3' 无法关闭推理(官方 `thinking.type` 只接受 enabled);最省的档是 'low',请配 `LLM__ZHIPU__1__REASONING_EFFORT=low` 或调用时传 `reasoning_effort=Effort.LOW`。evidence: ...
|
||||
|
||||
理由: 该分支若只报错不给出路,下游会去找 `extra_body` 那条绕过的路——**那正是 issue #20 的成因**。报错必须带可执行替代,否则等于把用户推回起点。
|
||||
|
||||
映射(Phase 5 的逃生口)默认关闭,由 `SourceConfig.effort_fallback="nearest"` 显式开启,按 `supported_efforts` 的顺序取最近档并 warning。
|
||||
|
||||
> **Codex 审查异议与人类复核**: Codex 指出本项当前无可复验的消费者,引入它要带来配置项、映射算法、warning 口径与测试面。人类 2026-09-04 复核后**确认实现**——理由是这条逃生口的价值不取决于今天有没有人用它:换模型是常态,而「换完就跑不起来」与「换完静默涨价」之间需要一个下游可以显式选择的中间档。故本项**随本期一并实现**,含映射方向、warning 口径与测试。默认关闭的理由是钱: 一次静默的 `medium→max` 在 GLM-5.3 上是数倍账单,「严禁默认值掩盖错误」(P5)在此有真金白银的含义。
|
||||
|
||||
### 4.2 生效档位的优先级
|
||||
|
||||
```
|
||||
request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语法糖) > None
|
||||
```
|
||||
|
||||
`enable_thinking` **保留不删**(它已被三项目消费,迁移兼容约束见 ARCH §5.1),降级为语法糖:
|
||||
|
||||
| 旧写法 | 等价于 |
|
||||
|---|---|
|
||||
| `enable_thinking=False` | `reasoning_effort=Effort.NONE` |
|
||||
| `enable_thinking=True` | `reasoning_effort=Effort.AUTO`(注入 `on_base`,不附档位),不依赖能力表 |
|
||||
|
||||
**`True` 的等价性分两种**(2026-09-04 实现时发现,更正初稿「与旧行为逐字节等价」的说法):
|
||||
|
||||
| provider 类型 | 旧 `thinking_on` | 新 `AUTO` 注入 | 是否等价 |
|
||||
|---|---|---|---|
|
||||
| `on_base` 完整表达「开」(qwen/deepseek/zhipu/moonshot) | `{"enable_thinking": True}` 等 | 同左 | **逐字节等价** |
|
||||
| 靠档位表达「开」(openai/anthropic/google) | `{"reasoning_effort": "medium"}` | `{}`(不注入) | **行为变更** |
|
||||
| 同上但**默认不推理**(minimax) | `{"reasoning_effort": "medium"}` | 同左(2026-09-05 回退) | **逐字节等价** |
|
||||
|
||||
第一行是**有意的**: 旧版那个 `medium` 是库替下游做的档位判断(profile 注释自己承认「取 medium 是因为它是五档里语义最接近厂商正常强度的一档」),而 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本设计要消灭的东西。语义仍是「开」(这三家的模型经 OpenRouter 登记默认即推理),只是不再强制一个档;要指定强度请显式配 `REASONING_EFFORT`。须进 CHANGELOG 的行为变更条目。
|
||||
|
||||
**第三行是 2026-09-05 的回退(issue #21,人类拍板的最小修复)**: 上述「语义仍是开」依赖「模型默认就推理」这个前提,T10 真实网关实测证明 MiniMax-M3 不满足它——不发任何推理参数时 5/5 轮不推理。故 `minimax` 段的 `on_base` 改回 `{"reasoning_effort": "medium"}`,存量 `ENABLE_THINKING=true` 的行为逐字恢复。这是权宜之计: 正解是让 `auto` 受能力表约束(模型不支持「由模型自定」时报错并指路显式档位),属公共行为变更,下一版处理。
|
||||
|
||||
**同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾**(如 `True` + `none`)→ **构造期 `ValueError`**。不做「后者赢」的静默兜底: 两个字段表达同一件事时,矛盾是配置错误,不是优先级问题。
|
||||
|
||||
### 4.3 `reconcile_thinking`: 对账扩展
|
||||
|
||||
现有对账只判「要求关闭却观测到推理」与「要求开启却未推理」。档位化后新增一类可判定的矛盾:
|
||||
|
||||
- 请求 `none`、模型登记 `can_disable=True`、却观测到 `OBSERVED` → 既有文案,**保留**(这正是 issue #20 第 3 条要恢复的机制)。
|
||||
- 请求非 `none` 档、观测到 `ABSENT` → 既有文案,保留。
|
||||
- **不做**「档位高低与 `reasoning_tokens` 多少的对账」: 档位与 token 数没有可判定的函数关系(issue #20 自己的数据里 glm-5.3-flash 的 medium 档 rt 在 8~56 之间跳),拿它报警必然是噪声。这条留给 §11 的压测,不进库。
|
||||
|
||||
### 4.4 归一化不变式(实现期补,2026-09-05)
|
||||
|
||||
库内一切档位判定都是 `is Effort.X` 的身份比较,故**每条能让档位进入库内的入口都必须先归一**
|
||||
(`types.coerce_effort`)。裸字符串不归一的后果不是报错而是**静默判否**——`("none" is Effort.NONE)`
|
||||
恒假,于是「已关闭」被当成「没表态」。
|
||||
|
||||
已知三条入口,缺一即漏:
|
||||
|
||||
| # | 入口 | 归一点 |
|
||||
|---|---|---|
|
||||
| 1 | `.env` / `from_env()` / `from_settings()` | `config._cast` 委托 `coerce_effort` |
|
||||
| 2 | 构造函数全量注入 `SourceConfig(...)` 与 `chat(reasoning_effort=...)` | `SourceConfig.__post_init__` / `chat()` 入口 |
|
||||
| 3 | **缓存命中回放** `LLMResponse.applied_effort` | `CacheMW._coerce_applied_effort` |
|
||||
| 4 | **公共函数 `resolve_thinking()` 直调** | 函数入口自行 `coerce_effort`(2026-09-05 独立验证查出) |
|
||||
|
||||
第 3 条是 T8 加 `applied_effort` 字段时才浮现的: 响应进 Redis 走 JSON,`StrEnum` 存成裸串,
|
||||
命中回放时类型已丢。与 `thinking_observation` 当年的坑**逐字相同**(见 issue #16/#17),故按同一
|
||||
先例处置: 域外取值降级为 `None` 且**不作废整条缓存**——多项目共用 Redis 时互相打缓存是老问题,
|
||||
为一个可观测字段丢掉整条响应不划算。
|
||||
|
||||
第 4 条是本次换代**自己造出来的**: 该函数在 `__all__` 里,第三参数由 `bool` 换成 `Effort` 后,下游最自然的写法就是从 JSON/配置读出来的裸串 `"low"`。不归一则 `_inject` 撞 `.value` 抛 `AttributeError`——一个未文档化、不属四分类的异常。
|
||||
|
||||
新增第五条入口时(新工厂、新 transport 参数、新的反序列化路径)必须同样过 `coerce_effort`。
|
||||
|
||||
## 5. 缓存 key
|
||||
|
||||
**更正一个误判(Codex 审查指出)**: 源级 `extra_body` 与 `enable_thinking` **早已进 key**——经 `build_model_fingerprint` 的 `_fingerprint_mark`(`client.py`),由 issue #4/#5 落地,ARCH §7.5 有明文。本设计**不存在**先前稿本断言的「现存毒化缺口」,那是把 `CacheMW` 只读 `request.sampling` 误当成了全部 key 来源。
|
||||
|
||||
真正需要处置的是两处,均因请求级档位而新增:
|
||||
|
||||
| 层 | 处置 | 理由 |
|
||||
|---|---|---|
|
||||
| 源级 `reasoning_effort` | 并入 `_fingerprint_mark`,与 `enable_thinking` 同规则(**仅表态时**追加) | 与既有一致;全源不表态时指纹字面量不变,存量缓存不冷启动 |
|
||||
| 请求级 `reasoning_effort` | 进 `build_cache_key`,仅非 `None` 时参与 | `model_fingerprint` 是**装配期**算的集合级指纹,覆盖不到逐调用变化的值。不进 key 则同 messages 跑 low 与 max 会互相命中——issue #4「5 个 seed 全命中同一响应」的逐字翻版 |
|
||||
|
||||
**已知取舍原样延续**: ARCH §7.5 已记载 `model_fingerprint` 是**集合级**而非本次选中源的指纹,同 scope 各源配置不同时仍可能返回另一源的响应;要求逐源可复现应让每源独享 scope 或 namespace。加入 `reasoning_effort` 后该取舍不变,本设计不扩大战线去改它。
|
||||
|
||||
**冷启动代价**: 只有新配 `REASONING_EFFORT` 的源冷启动一次;存量只配 `ENABLE_THINKING` 的源字面量逐字不变。
|
||||
|
||||
**key 用请求档,不用 `nearest` 映射后的生效档**(T6 实现时定,理由在此补正): 决定性的原因是
|
||||
`CacheMW` 位于洋葱中比 transport 更外的一层,查缓存时 `resolve_thinking` 尚未执行,生效档
|
||||
**根本拿不到**。副作用是被映射到同一档的两个请求(`minimal` 与 `low` 都映射到 `low`)各占一个
|
||||
缓存槽,存两份相同响应——浪费但不毒化,可接受。
|
||||
|
||||
**由此带来一条已知边界**(与 ARCH §7.5 既有两条并列,不在本设计处理): 能力表更新导致映射结果
|
||||
变化时(如某模型新增 `minimal` 档),请求档 `minimal` 算出的 key 不变而实际发出的字节变了,
|
||||
会命中按旧映射存下的响应。能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表
|
||||
与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace` 或 `cache_salt`。
|
||||
|
||||
## 6. 遥测
|
||||
|
||||
`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27;两套口径的区分见 `telemetry/schema.py` 模块 docstring)。
|
||||
|
||||
记的是**本次调用生效的档位**,不是配置值——`None`(不表态)与 `'low'` 必须能区分,故可空。
|
||||
|
||||
不加此列则你要做的压测「不同档位是不是真有用」在数据侧无法分组: 现在 25 列里没有任何一列能回答「这一行用的是哪档」。补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE` 机制,两端 DDL 与 `COLUMNS` 同源(schema.py 是单一事实源)。
|
||||
|
||||
## 7. 备选方案对比
|
||||
|
||||
| | 方案 | 改动面 | 权衡 |
|
||||
|---|---|---|---|
|
||||
| **A** | **最小补丁**: 只补 `zhipu` profile,`thinking_on` 填一个档,维持 bool | `providers.py` 一条 + `thinking.py` 两条 | issue #20 字面满足。但 §1 三条结论全部无解: GLM-5.3 填什么档都是错(`medium` 是空档、`none` 是未定义值);`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」之间二选一。**治标** |
|
||||
| **B** | **能力表档位化 + 源级/请求级双入口**(本设计) | `types.py` 加 `Effort`、两个公共类型重构、`resolve_thinking` 加两关、缓存 key、遥测加列、`.env` 加键 | 表达力对齐现实;下游不必再走 `extra_body`;压测可按档位分组。代价是公共类型破坏性变更 + 一次缓存冷启动 |
|
||||
| **C** | **照抄 cherry 的完整 wire DSL**: closed operation 集合、`effortMap`、`budgetWire`、endpoint-keyed contract | B 的全部 + 一套 wire 解释器 + per-model wire 覆盖表 | 能表达 budget 型(qwen `thinking_budget`)与原生协议代际差异。但本库只有一个 OpenAI 兼容 transport(§3.4),这层复杂度当前无消费者——**违 P1 YAGNI** |
|
||||
|
||||
**推荐 B**。A 治不了 issue #20 描述的病;C 的两项额外能力(多协议 wire、token 预算)在本库当前没有消费者,等真出现 budget 型需求时,`ThinkingWire` 增一个 `budget_key` 字段即可增量抵达,不必现在就上解释器。
|
||||
|
||||
## 8. 初始能力表(全部标注「文档推定,待实测」)
|
||||
|
||||
来源: 官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉。**这是待验证的假设,不是结论**——LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下是三档、在 `perplexity/` 下是六档,中转会改档位有第三方证据。人类已定:能力表数据以后统一经 new-api 实测。
|
||||
|
||||
**落库规则**(Codex 审查补): 本表是**调研素材**,不是可直接转代码的表。只有 `supported_efforts` 能写成合法 `Effort` 元组的条目才进 `DEFAULT_CAPABILITIES`。分三档处置:
|
||||
|
||||
| 情形 | 处置 |
|
||||
|---|---|
|
||||
| 档位清单与「能否关闭」皆无冲突 | 直接登记 |
|
||||
| **档位清单三源一致,仅「能否关闭」存疑**(如 kimi-k3: 官方档位无 `none`,OpenRouter 却标 `mandatory:false`) | 按**保守方向**登记(不含 `none`),evidence 注明存疑点。理由: 不登记会退回 Phase 3 的「尽力注入」,下游配 `none` 时静默失效——**那正是 issue #20 的病**;保守登记则报错并给出最低档,明确且有出路 |
|
||||
| 档位清单本身无该型号直接证据(`未查到`,或仅由**同系**推定如 `推定同上`) | 不登记,走 Phase 3 |
|
||||
|
||||
第二档与第三档的分界是**有没有该型号自己的档位证据**,不是「关不关得掉存不存疑」: `kimi-k3` 进第二档,因为月之暗面官方文档直接写明它的三档是 `low/high/max`,只有「能否关」两源分歧;而 `gemini-3-flash`、`claude-haiku-5` 的档位清单是从同系型号(3.1-pro / opus-5)推来的,**没有该型号自己的文档**,故进第三档。Phase 3 并非静默——它会 warning 指路「实测后用 `register_capability` 登记」,且未登记模型的运行期对账文案也专门写了这一句;登记一个纯推定值反而会让下游以为库确认过。T10 实测时这三个型号优先补。`default` 列只是调研记录,按 §3.2 并入 `evidence` 文本,不进字段。
|
||||
|
||||
| 模型 | supported_efforts(推定) | 厂商默认(入 evidence) | 关? |
|
||||
|---|---|---|---|
|
||||
| glm-5.3, glm-5.3-flash | low, high, max | max | ✗ |
|
||||
| glm-5.2 | none, high, max | max | ✓ |
|
||||
| kimi-k3 | low, high, max | max | ?(OR 标可关,但官方档位无 `none`——**待实测**) |
|
||||
| kimi-for-coding | 未查到 | — | ? |
|
||||
| deepseek-v4-pro / -flash / -flash-vision-exp | none, high, max | high | ✓ |
|
||||
| gpt-5.4, gpt-5.5 | none, low, medium, high, xhigh | medium | ✓ |
|
||||
| claude-opus-5, claude-sonnet-5 | low, medium, high, xhigh, max(+`none` 经网关转 `thinking` 关闭) | high | ✓ |
|
||||
| claude-haiku-5 | 推定同上 | — | ? |
|
||||
| gemini-3.1-pro | low, medium, high | 官说 high / OR 说 medium(**打架**) | ✗ |
|
||||
| gemini-3-flash | low, medium, high | — | ? |
|
||||
| MiniMax-M3 | none, auto | auto | ✓ |
|
||||
| MiniMax-M2.5, M2.7 | auto(**仅此一档**) | auto | ✗ |
|
||||
| glm-5, glm-5.1, glm-4.6v | none, auto | auto | ✓ |
|
||||
| qwen-plus-latest, qwen3.5-flash, qwen3.6-plus, qwen3.7-max, qwen3.7-plus | none, auto | auto | ✓ |
|
||||
|
||||
三个 embedding 模型(text-embedding-v2/v4、qwen3-vl-embedding)无推理语义,不入表。
|
||||
|
||||
## 9. 非功能维度
|
||||
|
||||
| 维度 | 回答 |
|
||||
|---|---|
|
||||
| **并发** | 两张表仍是 `MappingProxyType` + 纯函数查找,无共享可变状态。transport 的 `_warned_models`/`_warned_mismatches` 是实例级 `set`,读写之间无 `await`,单事件循环内原子。节流键加入生效档位后基数上升(源×模型×档位),仍为有界小集合 |
|
||||
| **取消** | 档位解析全部是同步纯函数,不含 `await`,不改变 `CancelledError` 的穿透路径。既有保证不受影响 |
|
||||
| **降级方向** | 推理档位属**请求正确性**而非资源闸,故一律**报错不放行**(Phase 2/4/5(下同)),与「限流/熔断后端不可用须报错」同向。能力**未登记**是唯一例外——warning 后尽力注入,理由是新模型上线不该被库挡住(既有决策,保留) |
|
||||
| **幂等** | 纯函数,无副作用,同输入恒同输出。重复调用安全 |
|
||||
| **持久化** | 两处一次性影响: ① 缓存 key 变化 → 已配推理参数的 namespace 冷启动一次;② 遥测补列 → 走既有 `PGW_TELEMETRY_SCHEMA_MODE`,补列语句与 DDL 同源。均无部分写入风险(补列是 DDL 原子操作,缓存 miss 不损坏数据) |
|
||||
|
||||
## 10. 错误处理与测试策略
|
||||
|
||||
**错误分类**: 全部落 `RequestRejectedError`(不重试、不换源、不计熔断)。理由: 档位不支持是确定性的配置/参数问题,重试与换源都不会让它变对。路径与既有一致——`thinking.py` 抛 `ThinkingUnsupportedError(ValueError)`,transport 在请求期翻译。
|
||||
|
||||
装配期 vs 运行期: 源级配置(`SourceConfig.reasoning_effort`)在**构造期**校验并报错;请求级(`ChatRequest.reasoning_effort`)只能在**运行期**校验,落 `RequestRejectedError` 上抛。
|
||||
|
||||
**测试策略**(先失败后通过,每条对应一个行为):
|
||||
|
||||
| 层 | 用例 |
|
||||
|---|---|
|
||||
| unit | 五道关卡各自的触发与不触发;`enable_thinking` 语法糖的三种等价;矛盾配置构造期报错;`nearest` 映射的取档方向;派生量(`can_disable`/`cheapest_effort`)与 `supported_efforts` 一致 |
|
||||
| unit | Phase 4 文案**含** `cheapest_effort` 与 env 键名(这是交付物,要断言内容而非只断言抛错) |
|
||||
| unit | 缓存 key: 同 messages 不同档位 → key 不同;不表态时 key 与存量形状一致(回归) |
|
||||
| integration | 遥测 `reasoning_effort` 列在两端(sqlite/pg)落值正确,不表态时为 NULL |
|
||||
| e2e(`slow`) | 经 new-api 对 §8 表逐模型实测,校正 `supported_efforts`;标 `slow`(成败取决于外部服务当下状态) |
|
||||
|
||||
## 11. 明确不做
|
||||
|
||||
1. **档位与 `reasoning_tokens` 的运行期对账**(§4.3): 无可判定的函数关系,拿它报警是噪声。
|
||||
2. **token 预算型控制**(`thinking_budget`/`budget_tokens`): qwen 系支持,但当前无下游需求;`ThinkingWire` 可增量加 `budget_key` 抵达。
|
||||
3. **原生协议 wire 与代际方言**(§3.4): 本库只有一个 OpenAI 兼容 transport。
|
||||
4. **档位对采样参数的联动**: DeepSeek 思考模式不支持 `temperature`/`top_p`,Moonshot kimi-k2.5+ 固定采样参数,传别的值 400。**本设计不代下游做参数裁剪**——这是模型的约束,应由 evidence 记录并让 400 如实抛出,库替下游删参数是「默认值掩盖错误」。记入能力表 evidence,不写进代码逻辑。
|
||||
5. **压测本身**: 「不同档位是不是真有用」是 `harness-eval` 范畴,依赖本设计的遥测列,不属于本设计。
|
||||
|
||||
## 12. 迁移与兼容
|
||||
|
||||
**破坏性变更五处**(初稿只列了第 1 条,其余四条为 2026-09-05 独立验证实测补全——照初稿写 CHANGELOG 会让下游撞上没有预告的 `TypeError`):
|
||||
|
||||
| # | 位置 | 变更 | 谁会断 |
|
||||
|---|---|---|---|
|
||||
| 1 | `ThinkingCapability` | 构造签名 `can_disable` → `supported_efforts` | 自建能力表的调用方 |
|
||||
| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 |
|
||||
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort` | 任何自建 recorder 实现 |
|
||||
| 4 | `thinking.resolve_thinking()` | 第三参数换语义(`bool` → `Effort`),**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 读侧代码一律断 |
|
||||
| 5 | `providers.ProviderProfile` | `thinking_on`/`thinking_off` → `thinking: ThinkingWire` | 自建 profile 的调用方 |
|
||||
|
||||
五者都在包根导出面或端口面上。
|
||||
|
||||
**先更正**(Codex 审查指出): 初稿称「已核实 `reference/` 三项目无调用点,实际影响面为零」——**该结论不成立**。`reference/` 下当前**没有** GovDoc-SaaS / Video-Tree-TRM5 / CHSAnalyzer 三个目录(工作区实际只有本次调研克隆的四个开源项目),此前的 `grep` 因目录不存在而输出空,被误读成「无匹配」。
|
||||
|
||||
真实的库内调用点(可复验):
|
||||
|
||||
| 位置 | 用法 | 处置 |
|
||||
|---|---|---|
|
||||
| `thinking.py:169` | 读 `capability.can_disable` | 改读派生属性,行为不变 |
|
||||
| `tests/unit/test_thinking.py:128` | `ThinkingCapability(True, "实测")` **位置参数构造** | 随实现同步改——这是不可兼容的部分 |
|
||||
| `tests/e2e/test_thinking_live.py:455` | 读 `can_disable` | 派生属性覆盖 |
|
||||
| `__init__.py` | 包根导出 `ThinkingCapability`/`register_capability` | 符号名不变,构造形态变 |
|
||||
|
||||
**兼容策略**: 保留 `can_disable` 为只读派生属性(`Effort.NONE in supported_efforts`),**读侧代码一律不改**;位置参数构造无法兼容,库内三处随实现同步修改。
|
||||
|
||||
**下游影响面: 推断而非核实**。三项目尚未迁移接入本库(M4 才做),`ThinkingCapability` 是 2026-08-02 才加入的库内表,下游调用它的可能性低——但工作区读不到三项目源码,这条只能是推断。**须人类在审批时确认**,或在实现计划里加一步「三项目可读时复验调用点」。版本号取 **1.3.3**(2026-09-05 人类指令,不走 minor)。
|
||||
|
||||
**非破坏**: `SourceConfig.enable_thinking` 保留,行为等价(§4.2);`.env` 的 `ENABLE_THINKING` 键保留;新增键 `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT`。三项目不改配置即可继续跑,除非它们配的是「关闭一个官方不可关的模型」——那种情况**本来就是静默失效**,现在会明确报错并给出替代档。
|
||||
|
||||
## 13. 验收标准
|
||||
|
||||
1. 五道关卡各有先失败后通过的测试证据;Phase 5 文案内容被断言。
|
||||
2. 同 messages 不同档位不再互相命中缓存。
|
||||
3. 遥测能按档位分组(压测的前置条件)。
|
||||
4. `.env` 只配 `ENABLE_THINKING` 的存量下游行为不变(回归测试)。
|
||||
5. 进 `DEFAULT_CAPABILITIES` 的条目**仅限** §8 中无 `?`/无冲突者,每条 `evidence` 标注「文档推定,待实测」并附出处;其余条目留在设计文档里等实测,不登记。
|
||||
6. import-linter 契约不破(`Effort` 落 `types.py`,不产生反向依赖)。
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:est-tokens-decoupling
|
||||
title: "est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)"
|
||||
date: 2026-07-30
|
||||
---
|
||||
|
||||
# est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)
|
||||
|
||||
全文见 [2026-07-30-est-tokens-decoupling-design.md](2026-07-30-est-tokens-decoupling-design.md)。缘起是 Gitea issue #2(下游 CHSAnalyzer 提出)。
|
||||
|
||||
- **缘起**: `SourceConfig.est_tokens` 被派了两份对"保守"定义相反的差事——TPM 入场预扣(多押金 = 安全)与 usage 帧缺失时的遥测用量兜底(计费没有安全方向)。CHS `config.py:55` 自己就把它定义为"须 ≥ 最坏情形 token"的**上界**,而库把遥测拆 `prompt`/`completion` 两列后又将整个估值塞进单价更贵的 `completion`(`openai_compat.py:146`),形成双重系统性高估:实测算例 26 倍。
|
||||
- **第二个症状**: `types.py:125` 的 `tpm > 0 ⇒ est_tokens > 0` 把供应商配额(可从配额页抄)与库的实现细节(无人能正确取值)绑死,下游删掉猜测项后 `tpm` 只能填 0,被迫在自己配置模型里加校验绕开。
|
||||
- **方案(两个决策点,人类审批)**: ① usage 不可得时记 `0/0` + `usage_source` 新增 `unavailable` + cost 记 NULL;② `est_tokens` 未填时由库派生 `tpm // 60`,字段降为可选调优覆盖(不删不改名,迁移兼容)。
|
||||
- **值域三态各有生产者**: `measured`(正常)、`estimated`(打捞路径——收到 usage 帧但流被截断,数字真实而可信度降级)、`unavailable`(用量不可得)。故 `estimated` 不是空值域,历史行亦读兼容。
|
||||
- **被否决 · usage 兜底记 0 但沿用 `estimated`**: cost 会算出 `0.0`,"免费"与"未知"在数据上不可区分,账目缺口无法量化。
|
||||
- **被否决 · 保留 est 兜底只修 prompt/completion 分配比例**: 比例是又一个没有正确取值的魔数,且未触及"拿上界当实测"的根因,仍高估约 9 倍。
|
||||
- **被否决 · 固定默认常量(如 1000)**: 与配额规模无关,在途上限随规模乱飘(`tpm=6000` 只剩 6 个在途、`tpm=600000` 放行 600 个)。派生值尺度无关且语义可文档化("一次调用约占一秒钟的配额份额")。
|
||||
- **被否决 · issue 原建议的遥测 p90 自估**: `TelemetryRecorder` 是纯只写端口,自估需新增读接口并强制所有后端(含 `none`)实现,公共 API 扩张远大于它要省掉的一个可选字段,且无实测证据表明派生默认值不够用。**注**: 初稿曾以"把两条方向相反的降级铁律焊在一起"为主论据,经独立审查撤回——p90 可在遥测读失败时回退纯派生值,限流侧仍能 fail-closed。
|
||||
- **被否决 · 派生逻辑取全局与单源 tpm 的较紧者**: 需改三处 `QuotaGate` 装配,且它修的是一个**既有**缺口(单源 `tpm=0` 而全局 `tpm>0` 时预扣为 0),属任务外,建议另开 issue。
|
||||
- **有意放弃的迁移保留项**: `migrations/chsanalyzer.md:151` 曾把"usage 缺失按 est 估算"列为保留(理由"保守计量")。本设计推翻:CHS 只记单个 `total_tokens` 不存在分配问题,而"保守"在计费语境只有错误一个方向。反静默的原始意图仍保留——被放弃的只是"编一个数字"这个手段。
|
||||
- **独立审查(两轮)抓出的两处实质缺陷**: ① 只改失败侧结算不够,`retry.py:338`/`embedding.py:271` 的**成功侧**取自同一返回值,改前 `actual` 恰等于预扣量使 `delta==0`,不同改则成功调用押金被整笔退回,对"从不回 usage 帧的网关源"构成系统性 TPM 失效;② OCR 行原判为"假陈述"是错的——`types.py:51` 与 `ocr.py:9` 明示 OCR 的 0 token 属**事实**,且改标 `unavailable` 会灌水本方案赖以成立的缺口度量,已剔出。
|
||||
- **待办**: 经 `writing-plans` 出实施计划;发版须同步 CHANGELOG 与 wiki(缺口查询口径须带 `AND cache_hit = false`),并回帖 issue #2。
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:governance-backend-error
|
||||
title: "治理后端故障归位为 scope 级不可用(Issue #7)"
|
||||
date: 2026-08-06
|
||||
---
|
||||
|
||||
# 治理后端故障归位为 scope 级不可用(Issue #7)
|
||||
|
||||
全文见 `2026-08-06-governance-backend-error-design.md`。来源: Gitea Issue #7(下游 CHSAnalyzer3 按异常类型分流失败)。**状态: 已批准(2026-08-06,人类逐条拍板 Q1/Q2/Q3),待 `writing-plans`。**
|
||||
|
||||
问题: 限流/熔断状态后端故障时库 fail-closed,一个请求都发不出去——语义上就是 scope 级不可用,但 `GovernanceBackendError` 是 `PolyGatewayError` 的**直接子类**,只写 `except GatewayUnavailableError` 的调用方接不住,于是 Redis 抖一下,积压任务一批批消耗业务失败预算进死信,而那是运维重启就好的故障。
|
||||
|
||||
## 选定方案
|
||||
|
||||
| 决策 | 选定 | 关键理由 |
|
||||
|---|---|---|
|
||||
| A 类型树 | `GovernanceBackendError` 改继承 `GatewayUnavailableError`,`SCOPE_REASONS` 增 `governance_backend_down`,`reason` 恒为该值 | 加父类是**扩大**不是破坏(既有 `except GovernanceBackendError` 照旧命中);库内仅 `telemetry.py:250` 一处捕父类且已并列写两者,**零回归** |
|
||||
| B `retry_after_s` | 模块常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`,非环境配置项 | 后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻);取 0 会让积压任务零延迟批量重投,把一次故障放大成风暴 |
|
||||
| C scope 来源 | 后端层用 `self._scope`;`QuotaGate`/`BreakerGate` 构造函数注入,三处装配(`retry.py`/`ocr.py`/`embedding.py`)各传一行 | 两个包装器是后端异常的唯一入口,注入点收敛;三处装配本就持有 `self._scope` |
|
||||
| D 未知源拆分 | `_cfg()` 的 2 处改抛新增的 `SourceNotConfiguredError`,**有意不放在** `GatewayUnavailableError` 之下 | 那是装配缺陷不是后端故障;随整类归入"可重投"会让配置写错的任务永远重投、永不进死信——本 issue 要修的 bug 的镜像 |
|
||||
| E message 保全 | `super().__init__()` 后覆写 `self.args = (message,)` | 父类会把 message 覆盖为 `f"{scope} 网关暂时不可用: {reason}"`,而 22 处构造点的诊断串是排障主线索。机制已实跑验证 |
|
||||
|
||||
## 被否决的备选
|
||||
|
||||
| 备选 | 否决原因 |
|
||||
|---|---|
|
||||
| B(issue 原议): 只补文档,类型树不动 | 正确性依赖每个下游都读到那句话;本 issue 本身就是"文档读不出来"引发的,同一失效模式不能用同一种药治 |
|
||||
| C: 在 RetryMW 边界包成 `AllSourcesExhausted` | 比选定方案更具破坏性——下游现有 `except GovernanceBackendError` 直接失效 |
|
||||
| D: 后端层不再构造该异常,原始异常穿透由包装器统一翻译 | 初评时倾向。`redis/limiter.py:133,151` 的 `RedisPermit.release/settle` 依赖 `except GovernanceBackendError` 实现**释放侧降级**,穿透后接不住会破坏该既有行为;改 `except Exception` 则违反 P5 |
|
||||
| `retry_after_s` 复用 `BackpressureConfig.poll_interval_s` | 该值只有三个装配点持有,为此给后端加构造参数等于让状态存储层持有重投策略,违反 P7 |
|
||||
| 新增配置项 `PGW_GOVERNANCE_BACKEND_RETRY_AFTER_S` | YAGNI;无下游表达过需要,真需要时下游可忽略该字段用自有退避 |
|
||||
|
||||
## 对 issue 前提的四处修正
|
||||
|
||||
泄漏路径是**五条**不是两条(判据: 该 gate 调用点是否被 `_record_quietly` 包裹——`QuotaGate` 的 try_acquire / stats / progress_age_s 与 `BreakerGate` 的 try_enter / retry_after_s 均未包裹,直达调用方);构造点 **22 处**;其中 2 处语义完全不同(未知源);`retry_after_s=0` 语义通但工程不通。
|
||||
|
||||
根因记录: `ARCHITECTURE.md` §6.1 错误分类表里 `GovernanceBackendError` **一次都没出现**——它是 M2 引入分布式后端时新增的,当时未回补架构表,于是它在"调用方视角的分类学"中从来没有位置,README 的遗漏是这个遗漏的下游后果。
|
||||
|
||||
## 独立审查修正(2026-08-06, Codex)
|
||||
|
||||
4 条意见逐条核验: 两条"架构文档未同步"实质成立但性质是执行顺序 → 新增 §8.1 钉死"`ARCHITECTURE.md` §6.1 修订先于/同批于实现";"新错误类违反四分类铁律"**部分成立**——铁律论域被误读(`GatewayUnavailableError` 族本就合法处在四分类之外),但原表述确会引起疑虑 → §7 补写三论域划分论证,并把"复用 `RequestRejectedError`"增列为待人类权衡的备选;两条建议性意见(常量非配置项的说明、决策编号 `D`→`Q` 防与架构 D1–D14 混淆)已采纳。
|
||||
|
||||
相关: [[m2-distributed]]、[[m1-core-design]]、[[m25-resilience]]
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue10-error-body-retention
|
||||
title: "HTTP 错误响应体留存(Issue #10)"
|
||||
date: 2026-08-16
|
||||
---
|
||||
|
||||
# HTTP 错误响应体留存(Issue #10)
|
||||
|
||||
**来源**: Gitea issue #10(CHSAnalyzer3 现场,1050 张影像批处理中 1 张 400 被判确定性失败、事后无从查证)|**范围**: `errors.py` + 两个 transport|**全文**: `designs/2026-08-16-issue10-error-body-retention-design.md`|**相关**: [[design:issue8-stall-budget]](同为下游实测反馈驱动的治理修正)
|
||||
|
||||
## 问题
|
||||
|
||||
网关拒绝一次调用时,它说的话在 transport 翻译层被丢弃,进程中不再有任何副本:该模块无 logger、异常类无承载字段、库遥测只写 message。三条留存通道同时为空,故"永久查不到"。
|
||||
|
||||
## 根因(Issue 前提的关键修正)
|
||||
|
||||
Issue 建议"给异常加 `body_text` 字段,下游就能记进遥测"——**只做这一半解决不了它自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`retry.py:552` → `telemetry.py` → `sqlite.py` 的 `error TEXT` 列),即**异常 message**;新增字段不进库的遥测表。下游说的"写进遥测表"是他们自己的埋点。
|
||||
|
||||
缺陷范围也大于 issue 所述:实为 6 处同构——`_status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 两支(读了 body 判类型却不带),以及 `monkey_ocr._classify_status` 全部分支(message 只有 `HTTP {status}`)。Issue 场景"读表格"极可能正落在 OCR 路径。
|
||||
|
||||
## 选定方案
|
||||
|
||||
**摘要在翻译层算一次,同一份串同时进 message 与新增的基类字段**——前者解决"事后可查"(走既有遥测列,零 DDL),后者解决下游结构化留存。
|
||||
|
||||
| 决策 | 理由 |
|
||||
|---|---|
|
||||
| 字段加在 `PolyGatewayError` 基类,非 `RequestRejectedError` | 这些错误全由同一个 HTTP 响应翻译而来,"对方说了什么"与"属于哪一类"正交;只加子类,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 人类门 |
|
||||
| 与 `ResultInvalidError.raw_text` 的界限写进 docstring | `body_text` = 非 2xx 的拒绝理由;`raw_text` = 2xx 但不可解析的模型输出。两个"原文字段"不钉死必被混用 |
|
||||
| 新建 `transports/_http_errors.py` 共用摘要口径 | 两 transport 各有分类逻辑(OCR 无 429 细分,有意保留),但摘要必须同一份,否则就是下一个"只修一半" |
|
||||
| `_status_to_error` 改表驱动 | 五分支各拼各的 message,加摘要即五处重复;查表 + 单点拼装后代码更短 |
|
||||
| 摘要 = 折叠空白 + 总长 ≤ 2048,超出则**保留头 1400 + 尾 600**,中段记省略字数 | 折叠是因错误体常是缩进 JSON,拼进 message 会炸成多行。2048 对齐 k8s client-go 的 `maxUnstructuredResponseTextBytes`(唯一同场景先例);**头尾保留取自 `reprlib`**——JSON 错误体的 `code`/`request_id` 收尾,头部硬切正好切掉向网关追查唯一有用的部分(人类质疑 + 2026-08-16 调研,初稿的 500 + 头部硬切已废) |
|
||||
| 429 不设例外 | 例外就是下一个复发点;`insufficient_quota` 那支的配额细节全在 body 里 |
|
||||
| 400 治理语义不动,只补文档 | 见下 |
|
||||
|
||||
## 400 语义:不改行为,本次修复本身就是答复
|
||||
|
||||
Issue 给出有力证据(同字节 15 次重发全成功、`prompt_tokens=0`、2996ms 远低于同批成功最快的 7366ms),说明那次 400 来自中转服务抖动而非坏输入。**仍不改分类**:400 重试对直连供应商是纯浪费,而"中转也回 400"是部署拓扑引入的信息损失,库从状态码无从分辨;默认改可重试 = 让所有直连用户为一种部署形态买单。
|
||||
|
||||
但 body 留存后**下游能自己区分**——中转抖动体与供应商 `invalid_request_error` 体形态不同。库不替下游判断,把判断所需的信息交出去。配套在 docstring 与 ARCHITECTURE §6.2 加一句中转拓扑提醒。
|
||||
|
||||
## 被否决的备选
|
||||
|
||||
| 备选 | 否决理由 |
|
||||
|---|---|
|
||||
| 遥测端口加一列(22 → 23 字段) | 端口签名变更 + 双后端 DDL + 下游 ALTER + 列序契约全线改动,为一个诊断串付出跨三项目迁移;复用既有 `error` 列可达成同样可查证性 |
|
||||
| 只打一条 WARNING 日志(issue 方向二) | 日志轮转后仍查不到,而痛点恰是"事后";且 4xx/5xx 在批处理下可能极高频 |
|
||||
| 截断放进异常构造器 | 下游自建异常的文本被悄悄改写(违反 P4),且 message 侧仍需单独算一次,反出现两条规范化路径 |
|
||||
| 错误分类映射可插拔 | Issue 场景确实指向它,但当前只有一个使用方且已用自己的兜底分类解决;`ProviderProfile` 无此扩展点,加它是子系统级设计。YAGNI |
|
||||
|
||||
## 有意不夹带(留独立 issue)
|
||||
|
||||
- `_status_to_error` 的 `operation` 硬编码 `"chat"`,而 `embed()` 也调它 → embedding 的 HTTP 错误在遥测里被标成 chat。
|
||||
- `_complete_stream` 的 `aread()` 对错误响应体无大小上限,超大错误体可打爆内存(既有风险,留存后更显眼)。
|
||||
|
||||
两项都在本次触及的函数附近,但均不服务本 issue 目标,且各需独立行为讨论。
|
||||
|
||||
## 验收主张
|
||||
|
||||
一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要——这条端到端断言是本设计成立与否的唯一硬判据,其余用例为覆盖性(状态码参数化、截断边界 2048/2049、**尾部关键字段可见**、空白折叠、空体不拼悬空分隔符、非 UTF-8 不炸、流式路径、OCR 路径含 `ResponseNotRead` 降级)。
|
||||
|
||||
**发布约束**:版本 1.2.0,且 README 安装 pin 必须由 `==1.1.*` 改为 `>=1.2,<2`——否则照 README 安装的下游静默停在 1.1.2,拿不到本修复。
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue11-caller-dimensions
|
||||
title: "调用方自定义维度设计(issue #11)"
|
||||
date: 2026-08-17
|
||||
---
|
||||
|
||||
# 调用方自定义维度设计(issue #11)
|
||||
|
||||
正文: `2026-08-17-issue11-caller-dimensions-design.md`。状态: **已人类审批(2026-08-17)**,进入 writing-plans。
|
||||
|
||||
审批时三个待定项按设计原值定稿,人类未提出改动: `meta` key 数量上限 16 / `str` value 上限 256 / `tenant_id` 上限 128(量级推断,无本项目实测依据);库不自动建索引、不自动启用 RLS(GovDoc 需 DBA 执行模板 SQL 才拿到数据库层隔离);`_BACKFILL` 自动 ALTER 降级议题**不纳入本次**。
|
||||
|
||||
- **选定方案**: `tenant_id` 提真实列(RLS 硬需求)+ `meta` JSON 容器承载任意调用方自定义 KV(**默认不建索引**)。四个公共方法(`chat`/`embed`/`recognize_text`/`parse_layout`)各增两个带默认值的 keyword-only 参数,签名冻结承诺不破。端口 22 → 24 字段。
|
||||
- **范围(2026-08-17 人类决策)**: 只做**调用方自定义**的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 `meta` 写任何自采信息。issue 第 4 条(保留期与访问控制)另开,**已建 issue #12**。
|
||||
- **范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖 **chat / embed / OCR 三条**遥测链路。issue 与设计初稿都只说了前两条,但 `OcrClient` 经同一 emitter 写遥测(`ocr.py:426`)且行落**同一张表**,漏掉会让同表内一部分行有归属、一部分永远空白,不可逆性论证对其同样成立(同 issue #10 判断)。
|
||||
- **为什么必须提列而不能纯 JSON**: 两条独立实证。① RLS 挂 `meta->>'tenant_id'` 语法合法但会静默退化——PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**规划,而 `->>` 未标 leakproof;pgsql-general 实证案例的最终解法就是"索引列改成非 JSONB",Tom Lane 警告手工标 leakproof 是安全问题。② 与 RLS 无关的独立问题: planner 对 JSONB 本就无可用统计,`@>` 走硬编码 0.1% 选择率,Heap 复现里行数低估 12 万倍、join 从 300ms 变 584 秒。
|
||||
- **为什么不做"可配置提升列白名单"**: dbt/Airbyte/Fivetran 三家一致禁止用户自定义列(Fivetran 的后续 MERGE 直接把用户列置 NULL,官方方案是建视图)。本库场景更糟: 两个下游对同名 key 推断出不同类型时,第二个到达者的 `ADD COLUMN` 被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、持续污染。且 `_COLUMNS`/`_INSERT` 从常量变运行时拼接,SQL 注入面从零出现,端口"22 字段冻结"与列序断言全部失效。
|
||||
- **同类系统佐证**: LiteLLM(同为 LLM 网关、同为每调用一行进 PG)的 `SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` **无任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野。六家 LLM 可观测平台无一例外都是"少数物化列 + 一个 KV blob"。没有任何成熟系统允许任意 key 自动获得列/索引待遇;唯一的自动推断派 ES dynamic mapping 也是唯一有公开事故名的(mapping explosion)。
|
||||
- **库止步于列 + policy 模板,绝不自动 ENABLE RLS**: 启用 RLS 而无匹配 policy 是 **default-deny**(零行可写,静默不报错)。三个下游里只有 GovDoc 多租户,库若自动启用,另两家升级后遥测全量写失败,叠加"遥测写失败静默降级"铁律 = **无声全局丢数据**——这才是 issue「不可逆」担忧的真正落点。另三条理由: 库无权知道角色拓扑;按最佳实践部署时库的运行时角色恰好不是表属主、无权 `CREATE POLICY`;SQLite 无 RLS,承诺它会让两后端语义不对等。先例(graphile-worker/Ent+Atlas/django-multitenant)一致把 policy 授权留给使用方。
|
||||
- **哨兵值而非 NULL**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见且静默跳过**,故 NULL 的 `tenant_id` 不是"未归属"而是**对所有人永久不可见的黑洞**。用 `NOT NULL DEFAULT ''` 则老行可一条 SQL 审计;同时满足 PG 11+ 加非易失默认值列不重写全表、SQLite 要求 NOT NULL 列必须有非 NULL 常量默认值。
|
||||
- **超限报错而非静默丢弃**: Langfuse 的"value 超 200 字符直接丢弃"**不抄**,违反 P5。报错点在 `chat()` 入口而非遥测写入点——遥测层一切失败都被降级成 warning,校验放那里等于没有校验(同 `overlay` 保护键先例)。
|
||||
- **不进缓存 key**: `cache_namespace` 已是必填的租户隔离维度并已进 key(ARCH §7.5),重复;且进 key 会让存量缓存全量冷启动。
|
||||
- **被否决备选**: 纯 `meta` JSON 不提列(RLS 静默退化);可配置提升列白名单(多下游共表静默写错类型);复用 `cache_namespace` 传租户(缓存隔离单位 ≠ 数据归属,会让下游无法表达"同租户多命名空间");`tenant_id` 混在 `meta` 里当约定 key(拼错不报错,静默降级成普通维度)。
|
||||
- **审查留痕(Codex,2026-08-17)**: 报 4 项,逐条核实后**全部采纳**。① `embed()` 路径覆盖不足——`EmbeddingClient` 不走 chat 洋葱,`_emit()` 在 `embedding.py:360` 现场构造 `ChatRequest`,只改 chat 会导致 embed 行维度恒空,恰好落空 issue 第 2 条诉求;② **非有限 float 会击穿"序列化不可达"论断**——`json.dumps` 把 `nan` 写成 `NaN` 字面量(非合法 JSON,PG JSONB 拒收),失败会被降级吞成 warning,即调用方输入错误转化为静默丢遥测;实测确认后改为入口 `math.isfinite` + 序列化 `allow_nan=False` 双层收口;③ 校验入口表述只写 `chat()`,与双路径 API 不一致;④ §4.5 承诺"提供 RLS 模板"却只给了索引模板,已补上含 `FORCE`/`USING`+`WITH CHECK`/`NULLIF(current_setting(...))` 的完整定稿。第 ② 条的推翻过程已写进正文 §6,因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式容易复发。
|
||||
- **另开议题(已建 issue #13)**: `_BACKFILL` 自动 ALTER 是否应降级为默认关闭(Hangfire `EnableHeavyMigrations` 先例、APScheduler 4.x 版本不认识即拒绝启动)——与本 issue 同源但属独立架构变更,按反 gold-plating 不纳入本次。
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue12-telemetry-retention
|
||||
title: "issue #12: 遥测表的正文体量、保留期与访问控制"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# issue #12: 遥测表的正文体量、保留期与访问控制
|
||||
|
||||
|
||||
正文: `2026-08-19-issue12-telemetry-retention-design.md`。状态: **待人类审批**。同批交付 [[design:issue13-schema-mode]]。
|
||||
|
||||
- **选定方案**: 三个子问题分层落点——(a) 正文体量: 新增 `PGW_TELEMETRY_TEXT_CAP`,**缺省 None 即不截断**,截断只发生在 `TelemetryEmitter._record`; (b) 保留期: README 分区 + `pg_partman` retention 模板 + `tools/telemetry_retention.py` 独立脚本(默认 dry-run),库本体不持有 DELETE/DROP 权限; (c) 访问控制: 纯文档,三角色划分 + `REVOKE UPDATE, DELETE` + 不可变性说明。
|
||||
- **只有 (a) 改库本体代码**,且它是唯一**预防性**手段: 没写进去的数据不需要删。
|
||||
- **缺省不截断的理由**(人类决策): 截断后遥测不再是审计证据、也无法复现重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只解决一半——默认仍是全文,但下游第一次有了不写全文的手段。
|
||||
- **按每条文本切而不是切整串 JSON**: 后者产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据静默存进去)。
|
||||
- **不复用 `_http_errors.summarize_body`**: 它折叠空白 + 保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,保头保尾服务的是诊断而非"不想存全文"。视觉标记口径一致,实现各自独立。
|
||||
- **红线**: `digest_messages` 一个字节都不能碰(缓存 key 与遥测共用,`middleware/cache.py:31`),动它 = 全量缓存 miss + key 口径分叉。已设机械化验收: 同一组 messages 在 cap 开关两态下 `build_cache_key` 输出逐字节相同。
|
||||
- **权限张力**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
|
||||
- **文档必须进 README 而非 wiki**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),wiki 里的模板下游 `pip install` 后读不到——56f3805 的教训。README 的模板 SQL 另设真实 PG 集成测试逐条执行,因为下游照抄错 SQL 就中招。
|
||||
- **被否决备选**: 缺省即截断(所有现有下游遥测正文被静默削短);库内建 TTL/清理(库需 DELETE 权限,与 (c) 的 REVOKE 建议直接冲突,且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务);给 `TelemetryRecorder` 加 `purge_before(ts)`(冻结签名的端口扩展 + 同样的权限冲突);只写文档不改代码(下游唯一手段是不用遥测)。
|
||||
- **共同边界(建议入 ARCHITECTURE D15)**: 库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。本设计与 [[design:issue13-schema-mode]] 各实现它的一面。
|
||||
- **审查留痕(Codex,2026-08-19)**: 报 3 项,**采纳 1 项、部分采纳 1 项、不采纳 1 项**。① 阻断级的分区表与幂等冲突已采纳,修法归 [[design:issue13-schema-mode]] §4.6,本设计 §6.1 承接分区部署下的语义差异(缓存命中行复用历史 `call_id`,分区表上不再被幂等吞掉)。② `text_cap` 漏列 emitter 构造点——缺口成立(`client.py:149`/`embedding.py:131`/`ocr.py:130` 三处不改即 `TypeError`),已补;但其"覆盖 embed/OCR 属语义扩散"的价值判断**不采纳**: 三条链路的行落同一张表,只覆盖一条会让同表内一半受控一半不受控(issue #11 同款判断),且核实后 embed 与 OCR 各已有 200 字符自有上限,新 cap 与之是"取更严者",实际影响远小于顾虑。③ "缺省不截断只解决一半"是人类已定的 E-a 决策而非疏漏,不改;作为补偿,README 须给一段可直接照抄的**合规下游推荐配置**(cap + 分区 retention + 三角色),不把三件事散着让下游自己拼。
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue13-schema-mode
|
||||
title: "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
|
||||
|
||||
|
||||
正文: `2026-08-19-issue13-schema-mode-design.md`。状态: **待人类审批**。同批交付 [[design:issue12-telemetry-retention]]。
|
||||
|
||||
- **选定方案**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设时**按后端派生**: SQLite→auto、Postgres→manual)。manual 档探测真实列集合后**不发 DDL**,改为 warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT 继续写入。新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取建表/补列脚本。
|
||||
- **为什么两侧不对称**: issue 引用的全部先例(Hangfire 锁队列雪崩、Prefect 多实例竞态、Alembic 审计链)语境都是**共享的生产 PG**——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,排在长事务后会阻塞该表其后所有查询,而遥测是业务路径上的内联 await。SQLite 侧则是下游自己的本地文件(VT/CHSAnalyzer/dissect 的 `runs/*.db` 全是这个形态): 无 DBA、无迁移工具、无第二个系统碰它。强加手工 SQL 是净损失。两侧有意不对称在本库已有先例(issue #9 的建表探测)。
|
||||
- **关掉 ALTER 必须配套裁剪写入**: 今天 `_INSERT` 是 24 列固定语句,旧表缺列时若不 ALTER 则 INSERT **全部失败** → 逐行 warning → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。降级写入不是增强,是本变更成立的前提。
|
||||
- **打印的 SQL 必须与执行的 DDL 同源**: `_DDL`/`_BACKFILL`/`_COLUMNS` 今天在两个 recorder 各存一份,公共函数再写一份则三份必然漂移,表现为"下游照打印的 SQL 建完表,库仍报缺列"。故收敛进新的 `telemetry/schema.py` 作单一事实源——这是正确性要求,不是顺手重构。
|
||||
- **manual 档不停 `CREATE TABLE`**: issue 把建表列为现状描述而非指控(已在 #3/#9 收口为先探测后建);新建表无既有数据、无并发访问者,不存在锁与数据风险,停掉它会断掉零配置起步。Celery 先例同样是"自动建表 + 永不 ALTER"。
|
||||
- **缺省规则落 config 层**(人类决策): recorder 的 `auto_migrate` 为 keyword-only **必填**,派生只写在 config 一处,不与类签名漂移。代价是 35 处直接构造点需改。
|
||||
- **被否决备选**: 两侧统一默认 manual(现有 SQLite 下游升级即需人工干预,而这些场景没有承接手工 SQL 的角色);保持 auto 默认只加开关(默认状态仍是库在下游生产表发不受控 DDL,核心诉求未满足);Celery 式无开关永不 ALTER(SQLite 净损失且下游无出路);**APScheduler 4.x 式"schema 不认识就拒绝启动"**——与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选。
|
||||
- **附带成文化**: Expand/Contract 纪律(新列只增不删不改名、必可空或带非易失默认、INSERT 显式列名、库从不 `SELECT *`)升格为文档化承诺。它是 [[design:issue12-telemetry-retention]] 分区方案能成立的前提——下游把表建成分区表后,库的 `to_regclass` 探测与 INSERT 路由才对分区透明。
|
||||
- **审查留痕(Codex,2026-08-19)**: 报 3 项。**采纳 1 项(阻断级)**——PG 的 `ON CONFLICT (call_id) DO NOTHING` 与 issue #12 的分区方案不兼容: PostgreSQL 要求分区表的唯一约束必须包含分区键,按 `created_at` 分区后主键被逼成 `(call_id, created_at)`,该语句再也匹配不到约束,遥测在分区部署下全线写不进去。改为无冲突目标的 `ON CONFLICT DO NOTHING`(两种表形态都合法,普通表上逐字等价),改动归本 issue(它已在重写 INSERT 构造逻辑),见正文 §4.6。原设计"INSERT 路由对分区表透明"的判断只对普通 INSERT 成立,对冲突目标不成立——这是"透明"二字被推得过宽的典型。
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue15-telemetry-pool-lifecycle
|
||||
title: "issue #15: 遥测连接池的资源语义与生命周期"
|
||||
date: 2026-08-24
|
||||
---
|
||||
|
||||
# issue #15: 遥测连接池的资源语义与生命周期
|
||||
|
||||
正文: `2026-08-24-issue15-telemetry-pool-lifecycle-design.md`。状态: **已实施(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`)**——人类已确认方案、Codex 已审并逐条处置(正文 §9),T0–T7 全部完成;独立验证发现的 5 个问题已处置,实施期修订见正文 §10。前序: [[design:issue9-telemetry-ddl-probe]](判死判据的上一次收窄)、[[design:issue13-schema-mode]](schema 单一事实源)。
|
||||
|
||||
- **现象**: 共享 PG 实例余量紧张时,遥测**建池**失败 → `_failed` 永久置位 → 该 client 此后一行遥测都不落库,只有一条 warning,靠人肉对账才发现(19 次调用、成本少记约 $5)。
|
||||
- **选定方案(四组一次做完)**: A 池语义(`min_size=0` + `max_size` 可配,缺省 4 + 整次写入硬预算 5s);B 失败三分(配置级致命 / 环境级不可用 / 行级拒绝)+ 60s 冷却降级取代永久判死;C 降级可见(共用 `TelemetryStatusTracker` + 节流复述 + 只读快照 `TelemetryStatus`,走**独立**端口 `TelemetryStatusProvider`);D 资源所有权纪律统一(谁建的谁关)。
|
||||
- **地基是一条实测**: asyncpg `pool.py:457` 的 `if self._minsize:` ——`min_size=0` 时建池**零成本、不触库**(实测 0.000s、指向不可达端口照样成功)。这一步把"建池失败"从"混着瞬时错误的一刀切判死"变回真正的确定性失败,于是 issue 提的三个方向里,**方向 3(退避重试)大部分不必新建机制**(连接失败自动落到 `acquire`,那里本来就是"丢一行、池自恢复"的正确行为),**方向 2(共享池)从刚需降级为可选的显式能力**(闲时占 0)。
|
||||
- **判据是主要交付物(两句,经审查补全)**: ①**致命 = 失败原因完全在进程内部且不可变**,其余一切失败都可能被外部修好,故一律带冷却重试;②**行级 vs 环境级看失败与这一行的数据有没有关系**——只与本行数据有关(换一行可能成功)= 行级,与数据无关、每行都会同样失败 = 环境级。按此,致命档窄到只剩"DSN 本身不可解析";认证失败、库不存在、表建不出来、权限被收、表被迁走一律归环境级(修好即自动恢复)。分类按 **PG SQLSTATE**(切到具体码,非前两位整类)而非 asyncpg 异常类白名单,不随驱动版本漂移。
|
||||
- **`postgres.py:104-105` 的注释与代码不一致才是病灶**: 注释写"池建不出来 = 确定写不进去",这在 `min_size=10` 下是假的(连接耗尽只是这一秒写不进去)。冷却重试正面回应了该注释真正的顾虑("每次调用都内联吞一次 connect 超时"): 最坏成本变成"每 60s 一次、上界 5s"。
|
||||
- **D 组是范围扩展,理由是同一根因的另外三个表现**: `GatewayClient.aclose` 关掉**注入的** telemetry(共享 recorder 被第一个关闭的 client 弄死,三处复制)、`RedisCache.aclose` 关掉注入的 redis 客户端、自建的 limiter/breaker redis 客户端**从来没人关**(泄漏)。不修它,ARCH §7.7 R5 的"共享必须显式注入"这条正道就一直是坏的——缺陷的放大器长在架构里,不在某个默认值里。纪律推广自库内已有的正确先例 `RedisLimiter._owns_client`。
|
||||
- **被否决备选**: 只调默认值(判据错位仍在,下次 PG 重启照样永久失能);只加建池退避(`min_size=0` 后建池已无可重试的失败);隐式全局池注册表(违反"无全局状态、无模块级单例"铁律);暴露 `min_size`(唯一作用是把脆点装回来);**遥测改异步队列 + 后台 flush**(真正彻底消除"遥测拖慢业务",但引入进程崩溃丢数窗口,与遥测作为**审计证据**的定位正面冲突,见 [[design:issue12-telemetry-retention]] 决策 E-a);把遥测失败塞进 `errors.py` 四分类(那套语义是"决定重试/换源/熔断",遥测不冒泡也不参与,塞进去污染分类)。
|
||||
- **SQLite 侧有意只做一半**: 补可见性(今天初始化失败后写入连 warning 都没有),**不做** lazy 化与冷却。它的失败模式(本地目录不可写)在装配期就暴露,不是"跑到一半悄悄断",永久降级语义基本正确;tracker 与快照两侧共用,不产生第二套概念。与 [[design:issue9-telemetry-ddl-probe]] 的"两侧有意不对称"同一先例。
|
||||
- **发布**: 版号发布时由人类定(semver 指向 1.3.0,但项目既有口径偏 patch: issue #11 扩端口列落 1.2.1、issue #14 设计写 1.3.0 实际发成 1.2.4)。两处需"请先读这一条"待遇: 遥测常驻连接从 `10 × client 数` 变按需(监控曲线会突变);`aclose` 不再关闭注入的组件(修正越权,但依赖过"注入后由 client 代关"的下游会漏关)。
|
||||
|
||||
- **审查留痕(Codex,2026-08-24)**: 报 3 阻断 + 3 应改 + 1 可选,核实后 6 条采纳、1 条改判为实现约束。三条最重的都是同一类错误——**承诺比实现能给的更强**: ① "写入墙钟上界 = 一个预算"不成立,`async with pool.acquire()` 的释放路径是 shielded 且复用 acquire 的 timeout(`pool.py:886-889, 930-937`),真实上界 ≈ 2 × 预算;② `Pool.close()` 等 in-flight 释放会**无限挂**,60s 只 warning(`pool.py:939-948, 961-972`),"关了就是关了"必须自己限时 + `terminate()`;③ 原稿"`TelemetryRecorder` 加 `health` 属性零成本"只覆盖静态类型,漏了它是 `@runtime_checkable`(`ports.py:246`)——加属性会让只实现 `record_llm_call` 的对象**当场不再满足协议**,库内 `tests/unit/test_ports.py:137,141` 的 isinstance 断言会红。
|
||||
- **审查还逼出判据本身的自相矛盾**: 原稿只有"致命 = 进程内不可变"一句,却把 SQLSTATE `42` 整类归了行级——而 42501(权限被收)、42P01(表被迁走)恰恰是"能被外部修好"的。补出第二句判据(**行级 vs 环境级看失败与这一行的数据有没有关系**),两者改判环境级,`42703` 缺列成为唯一具名例外(它由 [[design:issue13-schema-mode]] 的"缺列须逐行暴露"承诺定死)。
|
||||
- **自查另补两条 Codex 未发现的**: `health` 一词在 `ports.py` 已被占用两次(`check_health` 源探活、`health(source_name) -> float` 成功率 EWMA),故快照改名 `TelemetryStatus`(P2 领域术语);`asyncio.timeout` 是 3.11 新增而 `requires-python = ">=3.11"`,3.11.0/3.11.1 的 `uncancel` 有已知缺陷,实施时须在"抬最低版本"与"改用 `wait_for`"之间选一。
|
||||
- **最低 Python 提到 3.12(人类决策,2026-08-24)**: 顺带消解了原 §6 那条取舍(`asyncio.timeout` 是 3.11 新增、3.11.0/3.11.1 的 `uncancel` 有缺陷),现在可直接用、不必退回 `wait_for`。代价有两项且**顺序不可颠倒**: conda 环境 `PolyGateway` 当前是 3.11.15,须先重建;ruff `target-version = "py312"` 立刻启用 UP047,`gather_bounded`/`_anext_within`/`stream_with_liveness_timeouts` 三处要改 PEP 695 语法,而该语法在 3.11 是 **SyntaxError**——只能在 3.12 环境就位之后改。版号因此确定 **1.3.0 起步**: 缩小支持面(3.11 下游 `pip install` 会被 pip 直接拒绝)比新增能力更该进 minor。
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue8-stall-budget
|
||||
title: "stall 判定改为非生产性等待口径"
|
||||
date: 2026-08-06
|
||||
---
|
||||
|
||||
# stall 判定改为非生产性等待口径
|
||||
|
||||
**全文**: `designs/2026-08-06-issue8-stall-budget-design.md`(已批准 2026-08-06)|**来源**: Gitea issue #8 |**实施**: [[plan:issue8-stall-budget-plan]]
|
||||
|
||||
## 问题
|
||||
|
||||
`timeout_s ≥ stall_window_s` 时,一次耗满超时的请求即判 scope 死,`max_attempts` **静默失效**(无报错无 warning)。`stall_window_s` 默认 300 极易被 `TIMEOUT_S` 追平,"只配 timeout 不配 stall"这种最常见写法正好踩中。
|
||||
|
||||
## 根因
|
||||
|
||||
**两个预算重叠计费**:真实尝试的耗时同时向重试预算(`max_attempts`)与 stall 预算(`stall_window_s`)计费,而后者更小,必然先耗尽。
|
||||
|
||||
## 选定方案
|
||||
|
||||
`StallClock` 让 stall 只累计非生产性等待。**划分依据是"谁消耗重试预算"**,不是"是否发出请求"——烧 `max_attempts` 的时间不烧 `stall_window_s`,不烧 `max_attempts` 的时间(含 429 尝试本身)归 stall 治理。
|
||||
|
||||
关键理由:
|
||||
|
||||
- **消除耦合而非守护耦合**。`stall_window_s` 与 `timeout_s` 自此无关系,配置方不必心算 `stall > timeout × retries`。
|
||||
- **`inf` 语义因此不必改**。新口径下"非生产性排队耗满窗口且 scope 从未出餐"判死本就正当,`inf` 从"有害恒真"回归为"正确的保守默认"。一次改动解决问题,优于两次改动互相牵制。
|
||||
- **取补集实现**(总时间减 `_attempt` 耗时)而非逐处标记 sleep:埋点 7 处降到 3 处,且将来新增等待路径自动计入 stall,默认安全。
|
||||
|
||||
## 被否决的备选
|
||||
|
||||
| 备选 | 否决理由 |
|
||||
|---|---|
|
||||
| **装配期校验 `stall_window_s > max(timeout_s)`**(issue 建议方向 1) | 治标:把缺陷固化成配置契约。且约束值须为 `timeout × max_attempts`(本机 900s),使 stall 兜底迟钝到近乎失效——修好一个洞挖开另一个。仍挡不住残余情形 |
|
||||
| **`inf` 不参与判死**(issue 建议方向 2) | 新口径下 `inf` 已无害。单独改它会制造冷启动兜底真空(429 免预算无其他兜底),并反转 `test_both_windows_exceeded_raises_stalled` 钉住的行为、与 CHS 蓝本分叉 |
|
||||
| **逐处标记 sleep** | 埋点 7 处且默认危险:新增等待路径忘记标记即成 stall 盲区 |
|
||||
| **给 embedding/ocr 补主循环 stall 判定** | 前提不成立。429 免预算是 chat 独有,embedding/ocr 无条件 `fails += 1`,两条循环路径均已封闭,补齐等于凭空新增判死路径 |
|
||||
| **删除既有 ttft 装配校验** | 其理由虽已消失(TTFT 属生产性时间),但校验无害且不误拒合理配置;删除需动 ARCHITECTURE §7.3 契约 G6,超出本 issue 范围(人类定夺:保留并改注释) |
|
||||
|
||||
## 实施期订正(§3.6)
|
||||
|
||||
初稿按"是否发出请求"划分,使 429 尝试**两个预算都不烧**(429 免重试预算,其耗时又算生产性)。排队型网关持满 timeout 才回 429 时实测挂 **25.2 小时**(301 次尝试),而改前只有 301s——**把一个 bug 换成了更严重的 bug**。由独立验证发现。订正为按"谁消耗重试预算"划分,429 尝试耗时退还 stall 账,实测回到 301s。
|
||||
|
||||
## 不变量
|
||||
|
||||
双条件结构、`progress_age_s()` 的 `inf` 语义、429 免预算、退避与 jitter 公式、`fail_fast` 分支、`AllSourcesExhausted` 字段与 `reason` 取值全部未动——**错误面零变更**。
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue9-telemetry-ddl-probe
|
||||
title: "建表前先探测,判死只认「确定写不进去」"
|
||||
date: 2026-08-07
|
||||
---
|
||||
|
||||
# 建表前先探测,判死只认「确定写不进去」
|
||||
|
||||
**来源**: Gitea issue #9(CHSAnalyzer3 现场)|**范围**: `telemetry/postgres.py` 单模块,无独立 plan(小改动自判)|**相关**: [[design:response-observability-fields]](issue #3 修的是同一个坑的另一半)
|
||||
|
||||
## 问题
|
||||
|
||||
应用账号有表级 `INSERT`、表也已存在,但没有 schema 的 `CREATE` 权限时,`_ensure_ready()` 的 `CREATE TABLE IF NOT EXISTS` 被拒 → `_failed = True` → **整个进程遥测永久 no-op**。业务调用一切正常,只留一行 warning,从外部完全看不出异常;下游 CHSAnalyzer3 首次端到端跑的 150+ 次调用数据因此全丢且无法补回。
|
||||
|
||||
## 根因
|
||||
|
||||
**PostgreSQL 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断**(`RangeVarGetAndCheckCreationNamespace()` 先 aclcheck 后查 relid)。这与 issue #3 里 `ALTER TABLE` 的 ownership 检查早于 `IF NOT EXISTS` 是同一类问题——当时只修了补列那一半,建表这一半原样留着,于是同一账号形态下"补列失败只丢一行日志接着干活,建表失败却把整个 recorder 判死"。
|
||||
|
||||
**实测(PostgreSQL 16.14,临时角色只授 `SELECT, INSERT ON llm_calls`)**:
|
||||
|
||||
| 语句 | 结果 |
|
||||
|---|---|
|
||||
| `SELECT to_regclass('llm_calls')` | 非 NULL(表就在那儿) |
|
||||
| `CREATE TABLE IF NOT EXISTS llm_calls (...)` | **被拒 InsufficientPrivilegeError: permission denied for schema** |
|
||||
| `INSERT INTO llm_calls ...` | 通过 |
|
||||
| `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` | 被拒 must be owner(即 issue #3 那条) |
|
||||
|
||||
## 选定方案
|
||||
|
||||
两条,第二条才是治本的那条:
|
||||
|
||||
1. **表存在就绝不发 DDL**。探测走 `to_regclass`(不需要任何权限,且与 `INSERT` 走同一套 search_path 解析——裸 `CREATE TABLE` 落在首个**可建**的 schema,可能与写入命中的不是同一张表,故探测优先反而更准)。表不存在才建;新建表列已齐全,顺带跳过补列。
|
||||
2. **"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」**:
|
||||
|
||||
| 情形 | 处置 | 理由 |
|
||||
|---|---|---|
|
||||
| 建池失败 | 永久 no-op | 重试要在业务调用路径上内联吞掉 connect 超时 |
|
||||
| 表存在 | 不发 DDL,只补列(失败仅 warning) | 本 issue 的直接修复 |
|
||||
| 表不存在 → 建表成功 | 就绪,跳过补列 | 新建表列已齐 |
|
||||
| 表不存在 → 建表失败 | 永久 no-op | 后续 INSERT 必然全败,重试无意义、日志纯噪音 |
|
||||
| 探测/取连接失败 | 只跳过本条,下次调用重试 | 瞬时抖动,判死代价远大于多一次往返 |
|
||||
|
||||
**SQLite 侧有意不对称**:实测其对已存在的表在**解析期**就把 `CREATE TABLE IF NOT EXISTS` 短路掉——另一连接持 `BEGIN EXCLUSIVE`、或文件 `chmod 444` 时该语句均通过(同条件下 `INSERT` 与新表名建表分别报 database is locked / readonly database),既不抢写锁也不检查可写性。故 PG 侧的坑在此不存在,加探测零收益。**需要对称的是保证(表存在就不该因建表失败而失能),不是代码**;结论已钉进 `sqlite.py` 模块 docstring,防止后人为"对称"加回来。
|
||||
|
||||
## 被否决的备选
|
||||
|
||||
| 备选 | 否决理由 |
|
||||
|---|---|
|
||||
| 只加探测,`_failed` 语义不动(issue 原方案) | 治标。初始化瞬间的 DB 抖动、一次 `pool.acquire` 失败、search_path 配错仍会让整个进程永久失遥测——同一个开关,换个触发口 |
|
||||
| 除建池外一律不判死 | 方向最统一,但表真的不存在时每次调用都发一条注定失败的 INSERT + 一条 warning(150 次调用 = 150 行噪音),而这种情形是**可确定判定**的,没必要留活路 |
|
||||
| 捕获 `InsufficientPrivilegeError` 特判放行 | 按异常类型打补丁,漏一种错误码就复发;探测是把"该不该发这条 DDL"判断在前,与错误面无关 |
|
||||
| SQLite 侧同步加探测 | 实测证明零收益,属为对称而对称的 gold-plating |
|
||||
|
||||
## 遗留
|
||||
|
||||
**SQLite 的窄缝**:表不存在 + 构造瞬间库被排他锁(多进程共库)→ `__init__` 里的建表失败 → recorder 永久失能。修它要把 SQLite 也改成 lazy 重试结构,超出本 issue 范围,记此备查。
|
||||
|
||||
## 测试证据
|
||||
|
||||
- 单测 `TestPostgresTableProbe`(5 例,fake conn):表存在不发 DDL / DDL 被拒仍照常 INSERT 且 `_failed` 不置位 / 表缺失则建表且不补列 / 表缺失且建不出来才判死 / 探测失败下次重试。
|
||||
- 集成 `TestLeastPrivilegeDeployment`(真实 PG,临时 schema + 临时角色,teardown 删净):先钉死"该角色确实建不了表"这条库外事实,再验两行记录照常落库。**修复前该用例复现 issue 原文那行 warning 并失败**。
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:reasoning-effort
|
||||
title: "推理档位一等化设计(issue #20 及其一般形式)"
|
||||
date: 2026-09-05
|
||||
---
|
||||
|
||||
# 推理档位一等化设计(issue #20 及其一般形式)
|
||||
|
||||
正文: `2026-09-04-reasoning-effort-design.md`。状态: **2026-09-04 人类已批准**。
|
||||
|
||||
- **选定方案**: 方案 B「能力表档位化 + 源级/请求级双入口」。`Effort` 八档封闭枚举(含 `auto`)入 `types.py`;`ThinkingCapability` 由 `can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`(「能不能关」= `none` 在不在列表里);`ProviderProfile` 的两个固定片段改为 `ThinkingWire(off / on_base / effort_key)`;生效档位 = 请求级 > 源级 > `enable_thinking` 语法糖。
|
||||
- **触发与真实缺口**: issue #20 字面要一条 zhipu profile,但补它不能解决它自己描述的失败——GLM-5.3 官方强制推理(智谱文档、cherry、OpenRouter 三源一致),`none` 是我们发出去的**未定义值**;而 `medium`(现 minimax profile 硬编码的档)在 GLM/kimi/deepseek 上根本不存在。缺口是**类型**表达不了现实,不是表里少一行。
|
||||
- **人类三项拍板(2026-09-04)**: 作用域「源级默认 + 请求级覆盖」;档位打空时「默认报错 + 可显式开 nearest 映射」;不可关闭时「报错并给出该模型最低档作为可执行替代」。复核 Codex 异议后追加确认: `effort_fallback` 随本期实现,不因当前无消费者而推迟。
|
||||
- **被否决备选及理由**: **方案 A 最小补丁**(只补 zhipu profile、维持 bool)——`thinking_on` 填什么档都是错的,`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」间二选一,治标;**方案 C 照抄 cherry 完整 wire DSL**(closed operations、`effortMap`、`budgetWire`、endpoint-keyed contract)——它需要那层是因为要支持四种端点协议,而本库只有一个 OpenAI 兼容 transport,跨协议转换由 new-api 服务端完成,该复杂度当前无消费者(P1 YAGNI);**`default_effort` 字段**——自审把 `enable_thinking=True` 的语法糖改成 `Effort.AUTO` 后失去唯一消费方,厂商默认档降为 `evidence` 文本;**档位与 `reasoning_tokens` 的运行期对账**——无可判定的函数关系(实测同档 rt 在 8~56 间跳),报警必成噪声;**代下游裁剪采样参数**(DeepSeek 思考模式不支持 `temperature`)——那是「默认值掩盖错误」,记入 evidence 而不写进逻辑。
|
||||
- **审查留痕**: Claude 自审揪出两处实质缺陷(词汇缺 `auto`,导致 9 个纯开关型模型无档可填、等于把要修的 bug 重新实现一遍;五关顺序错置,使「关不掉」的特殊文案被通用分支吞掉)。Codex 独立审推翻两条**错误断言**: ① 源级 `extra_body`/`enable_thinking` **早已**经 `build_model_fingerprint` 进缓存 key(ARCH §7.5 有明文),不存在先前稿本断言的「现存毒化缺口」;② 「三项目无调用点」是对**不存在的目录**做 grep 得到的空结果,`reference/` 下当前并无三项目,迁移安全性只能是推断。另采纳其三条: 删 `default_effort`、补能力表落库规则、列出库内真实会断的调用点(`tests/unit/test_thinking.py:128` 的位置参数构造)。
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:response-observability-fields
|
||||
title: "响应可观测字段扩展(Issue #3)"
|
||||
date: 2026-07-31
|
||||
---
|
||||
|
||||
# 响应可观测字段扩展(Issue #3)
|
||||
|
||||
全文见 `2026-07-31-response-observability-fields-design.md`。来源: Gitea Issue #3(下游 dissect 的调用审计需求)。
|
||||
|
||||
## 选定方案
|
||||
|
||||
| 决策 | 选定 | 关键理由 |
|
||||
|---|---|---|
|
||||
| A 采集路径 | `TransportResult` 追加 `cached_prompt_tokens` / `model_reported` 强类型字段,解析留在 `openai_compat.py` | OpenAI 报文格式知识不出 `transports/`,middleware 只做搬运(P7) |
|
||||
| B 缓存命中语义 | 原样回放;度量口径必须带 `cache_hit = false` | 与 `_rehydrate` 既有口径一致——它只覆写时序字段,`model`/`prompt_tokens` 全回放 |
|
||||
| C 缓存单价 | `ModelPrice` 加可选 `cached_input_per_1m`,`cost()` 加可选参 | 旧价格表与 `embedding.py:419` 三参调用零改动;未配置该档时**不猜折扣率**,退化为全额计价 |
|
||||
| D 遥测扩列 | 端口 18 → 20 字段;DDL 加列 + 初始化期幂等补列 | `CREATE TABLE IF NOT EXISTS` 不会给旧库补列,INSERT 会**逐行 warning 丢弃**——遥测全失却无硬失败提示 |
|
||||
|
||||
## 被否决的备选
|
||||
|
||||
| 备选 | 否决原因 |
|
||||
|---|---|
|
||||
| A1 往 `raw` 里塞约定键 | `dict[str, Any]` 沦为隐式契约,且 middleware 要懂 OpenAI 嵌套结构 |
|
||||
| A3 middleware 内解析 raw | 报文格式知识进 middleware,新增非 OpenAI 兼容 transport 时会分叉,违反分层 |
|
||||
| B2 命中时置 None / B3 混合 | 与同层 `prompt_tokens` 的回放行为不一致,下游要记两套规则 |
|
||||
| C2 `cost()` 直接收 `LLMResponse` | `pricing.py` 会反向依赖 `types.py`,且纯函数难单测 |
|
||||
| D2 只改 DDL、文档写「删表重建」 | 已建表的开发机/下游只会看到降级 warning,排查成本高 |
|
||||
| D3 引入 alembic 迁移框架 | 新增依赖违反「依赖极简」铁律,规模严重不匹配 |
|
||||
|
||||
## 独立审查修正(2026-07-31)
|
||||
|
||||
Codex CLI 安装损坏(vendor 二进制缺失),改由全新上下文的 Claude subagent 审。三条问题全部核实属实并已折回设计:
|
||||
|
||||
1. PG 缺列时**不是**结构性短路,而是逐行 warning(`_failed` 仅在 `_ensure_ready` 置位)。
|
||||
2. SQLite 补列若塞进 `__init__` 现有 try,异常会让 `_conn` 停在 `None` → recorder 永久 no-op。已定纪律: 独立 try、置于 `self._conn = conn` 之后、duplicate column 视为成功。
|
||||
3. 「端口无默认值 → 漏改即报错」不成立(无 mypy,8 个 fake 全是 `**fields`)。改为新增「emitter 实参键集合 == `_COLUMNS`」契约测试兜底——否则 `KeyError` 会被 `_record` 的 `except Exception` 吞成 warning,静默丢遥测。
|
||||
|
||||
相关: [[m1-core-design]]、[[est-tokens-decoupling]]
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:sampling-params
|
||||
title: "采样参数透传设计(issue #4)"
|
||||
date: 2026-07-31
|
||||
---
|
||||
|
||||
# 采样参数透传设计(issue #4)
|
||||
|
||||
正文: `2026-07-31-sampling-params-design.md`。状态: 待人类审批。
|
||||
|
||||
- **选定方案**: 两层入口——调用级 `chat(..., overlay=)` 供逐 rollout 变化的 `seed`,配置级 `SourceConfig.extra_body`(env 键 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,JSON 串)供恒定的 `temperature=0`。优先级 **结构化注入 > 调用级 > 配置级** 由现有层序天然给出,不加新机制。
|
||||
- **issue 未提但必须一并处理的四件事**: ① 采样参数进缓存 key(否则 5 个 seed 全命中同一缓存、标准差恒为 0,实验静默作废——「无缓存毒化」铁律);② 保护键黑名单 `{model, messages, stream, stream_options}` 与值可 JSON 序列化,均在构造期报错(覆盖它们会击穿流式看门狗、成本遥测与 TPM 结算;不可序列化的值会在 `CacheMW` 降级 try 之外抛裸 `TypeError`,一行遥测都没有);③ 采样参数入遥测(端口 20 → 21 字段,列名 `sampling`);④ 入参拷贝语义。
|
||||
- **关键结构决策**: `ChatRequest` 增 `sampling` 快照字段作为**跨洋葱层恒定的读取点**。`request.overlay` 在 `StructuredMW` 内侧含 `response_format`、外侧不含,缓存 key 与三个遥测 emit 入口若各读各的层就会口径分叉。`sampling` 列语义定死为「调用方意图 ⊎ 生效源 `extra_body`」,**不含**结构化注入。
|
||||
- **被否决备选及理由**: `chat()` 展开为 `temperature=`/`seed=` 具名参数(供应商私有参数无穷尽,等于永久追加签名,违「深模块窄接口」);配置级放装配层全局字典(采样参数与源强相关,会把无效键发给不认识它的源);overlay 不进 key 靠调用方传 `cache_salt`(把毒化防护责任推给调用方,漏传不报错——正是 issue 抱怨的失败形态);采样参数不入遥测由下游 run 快照自记(中间态数据不可追溯,且分两步要做两遍 DDL 迁移);缓存与遥测直接读 `request.overlay` 不加 `sampling` 字段(口径必分叉);`sampling` 记含 `response_format` 的完整合并结果(列名为采样参数,且数 KB schema 逐行落库无谓膨胀);给 embedding 加 `extra_body` 透传(embedding 无采样一说,装配期报错比静默无效更能指路);transport 层重复校验保护键(三入口已构造期收口,属 gold-plating)。
|
||||
- **附带修正**: `providers.py` 的 `minimax`/`openai` 空 thinking profile 补后果说明(`enable_thinking=False` 对两者不产生效果,调用方以为关掉了实际没关);`_SOURCE_FIELDS` 跨 scope 共用导致 `EXTRA_BODY` 在 OCR/EMBED scope 静默无效,改为构造期**剥离 + warning**(2026-07-31 人类拍板由原「装配期 `ValueError`」改此档: 这两条路径无采样语义,不值得让下游装配起不来)。**剥离不可省**——不剥离则遥测会记录一个从未发出的参数(决策 D 的 merge 读 `source.extra_body`,而 `monkey_ocr` 只发 multipart、`embed` payload 硬编码),那是数据造假而非参数失效;在 emitter 内特判调用方身份则违「遥测调用点收敛单一 helper」铁律。
|
||||
- **审查留痕**: Codex CLI 不可用(vendor 二进制缺失),改派全新上下文 subagent 两轮只读审查。首轮报 5 项必修(三个 emit 入口口径分叉、OCR/embedding 耦合、JSON 序列化缺口、注释归属写反、同步清单漏 4 处),逐条核实后全部采纳;次轮结论通过,其 5 条建议(承重不变式测试、`sampling` 类型定死、拷贝语义跟进、共用范围收窄、报错文案指路)亦已就地收进。
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:settings-invariant-guards
|
||||
title: "GatewaySettings 跨字段不变量守卫的生效范围"
|
||||
date: 2026-07-29
|
||||
---
|
||||
|
||||
# GatewaySettings 跨字段不变量守卫的生效范围
|
||||
|
||||
全文见 [2026-07-29-settings-invariant-guards-design.md](2026-07-29-settings-invariant-guards-design.md)。
|
||||
|
||||
- **缘起**: 社区 PR#1 指出装配守卫只挂在 `from_env`,走 CLAUDE.md §4.5 的另一条官方路 `from_settings()` 能装出违反类不变量的配置且不报错。诊断采纳,实现按库内规范重写并扩大覆盖。
|
||||
- **选定方案**: A——四条跨字段不变量(lease/stall/probe_ttl/sources 非空)全部收进 `GatewaySettings.__post_init__`,拆 `_validate_*` 私有方法,与 `types.py` 同族五个 frozen dataclass 的既有笔迹一致;模块级 `_guard_lease/_guard_stall` 删除。
|
||||
- **关键理由**: 这三条约束是**类的定义**的一部分,不是 `from_env` 的输入检查;放在函数里类就失去自我描述能力。构造期一处覆盖六个工厂 + 直接构造 + `dataclasses.replace`。
|
||||
- **被否决备选**: B 六个工厂各调 `validate()`(六处永久同步,新增 client 必漏,`replace` 仍绕过);C 公共 `validate()` 自愿调用(把不变量降级为建议,违反 P5 与 ARCH §7.3"拒绝装配")。
|
||||
- **补 PR#1 的两个缺口**(实测):`probe_ttl_s ≥ 最慢 timeout + 5` 仍只在 `from_env`(直接构造未拦截);守卫上移后 `sources=()` 泄漏内置异常 `max() arg is an empty sequence`。
|
||||
- **承诺变化**: 经 `from_env` 装配的调用方零影响;手工构造/`replace` 出非法组合者由静默故障改为构造期 `ValueError`。发版走 1.0.1(patch,用户拍板;CHANGELOG 单列"行为收紧"小节代替版本号预警),wiki `参考-配置键` 表述与新行为一致无需改。
|
||||
- **子决策**: 错误消息只点字段名不列 env 键(`types.py` 既有笔迹 + 键名单一事实源在 `.env.example`/wiki)。
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:settings-invariants-round-2
|
||||
title: "GatewaySettings 装配校验补齐(第二轮)"
|
||||
date: 2026-07-30
|
||||
---
|
||||
|
||||
# GatewaySettings 装配校验补齐(第二轮)
|
||||
|
||||
全文见 [2026-07-30-settings-invariants-round-2-design.md](2026-07-30-settings-invariants-round-2-design.md)。第一轮见 [settings-invariant-guards](settings-invariant-guards.md)。
|
||||
|
||||
- **缘起**: 第一轮交付后独立 verifier 发现 `from_env` 上还留着 15 条同族校验(枚举合法域 6、条件必填 7、标量域 2),`from_settings` 与直接构造全部放行。
|
||||
- **严重性高于第一轮**: `client.py:262/282/302/312/316` 有 5 处 `assert ... # 内部不变量: config 已校验` 明文依赖这个前提;实测断言开启抛裸 `AssertionError`,`python -O` 下退化为 redis 库天书。
|
||||
- **方案**: 沿用第一轮已批准的方案 A,不重新论证;新增 `_validate_backends/_validate_cache/_validate_telemetry`,枚举合法域上提为模块级常量供 `_load_pgw` 与构造期共用。
|
||||
- **assert 处置**: **保留不改**——前提一旦由构造期保证,它就是 CLAUDE.md §4.3 认可的内部不变量用法且给类型检查器收窄 `str | None`;只改那句会变成谎言的注释,点明由哪个方法保证。
|
||||
- **本轮唯一新决策**: `_load_pg_dsn` 剥 `+asyncpg` 驱动后缀是**规范化**不是校验,直接构造那条路不会剥。选校验拒绝(显式)而非构造期 `object.__setattr__` 剥后缀(在用户背后改 frozen 字段)。两条路接受度不同是有意的:env 路要吃三项目历史遗留的 SQLAlchemy DSN 写法,代码构造路没有历史包袱。
|
||||
- **版本**: 1.0.2(patch),CHANGELOG 单列"行为收紧"小节。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 文档组织与维护约定(Gitea Wiki)
|
||||
|
||||
> **定位**: 用户文档站 = Gitea Wiki(`https://gitea.iomgaa.online/iomgaa/PolyGateway/wiki`);本文规定它的结构、更新时机与写作纪律。研发知识(设计/决策/验收)仍归 `research-wiki/`,两者职责不重叠。
|
||||
|
||||
> [!CRITICAL]
|
||||
> **现状(2026-08-02 起):文档站已全量下线,当前只剩 `Home` 一页占位。** 八轮审查累计确认 93 处与源码不一致,近半落在参考区(手工镜像源码里已有的事实,必然漂移),且修正本身在引入次生偏差,逐轮修补不收敛——过期文档比没有文档更危险,它看起来权威。
|
||||
> Home 页现在做的唯一一件事是**把下游指向真实事实源**:签名/字段/参数语义 → 源码 docstring;全量环境变量键 → `.env.example`;版本变更与下游注意事项 → `CHANGELOG.md`;架构决策与行为论证 → `research-wiki/ARCHITECTURE.md`;快速上手 → `README.md`。
|
||||
> 历史内容未丢失,全在 wiki 仓库的 git 历史里(`git checkout e78bfb9 -- .`)。**本文以下各节描述的是重建时的目标结构与纪律,不是当前站点的现状**;在文档站重建之前,下面凡指向具体 wiki 页面的条目一律**不可执行**。
|
||||
|
||||
## 1. 结构:Diátaxis 四区(重建目标;2026-07-23 建站 17 页,2026-08-02 全量下线)
|
||||
|
||||
| 区 | 页面 | 职责(读者此刻要干什么) | 禁止 |
|
||||
|---|---|---|---|
|
||||
| 教程 | `教程-十分钟接入` | 新手被领着走通一遍 | 塞选项枚举与原理论述 |
|
||||
| 指南(How-to) | `指南-{多源与选源,限流与熔断,响应缓存,遥测与成本,结构化输出,OCR,Embedding,迁移既有项目}` | 一页一任务:配置片段+行为+坑 | 重复参考区的全量表 |
|
||||
| 参考 | `参考-{公共API,配置键,异常}` | 查表:签名/字段/键,**以源码实测为准** | 叙述与劝导 |
|
||||
| 解释 | `解释-{架构,错误四分类,治理行为,降级与取消}` | 讲为什么;机制挂回压测病灶 | 写成使用说明 |
|
||||
|
||||
导航:`Home.md`(按意图分流表)+ `_Sidebar.md`(全页目录);页间互链用 Gitea `[[双括号]]` 语法。
|
||||
|
||||
## 2. 更新时机(与代码变更绑定,发版检查清单)
|
||||
|
||||
| 变更类型 | 必须同步的页 |
|
||||
|---|---|
|
||||
| 新公共 API / 新能力 | 对应指南页(新增或扩写)+ `参考-公共API` + 侧边栏 + CHANGELOG |
|
||||
| 新增/改名配置键 | `参考-配置键` + 相关指南页的配置片段 + 主仓库 `.env.example` |
|
||||
| 治理行为变更(重试/熔断/选源语义) | `解释-治理行为` + 受影响指南页;若改公共承诺另走 brainstorming 流程 |
|
||||
| 新异常/分类语义调整 | `参考-异常` + `解释-错误四分类` |
|
||||
| **发版(任何版本号)** | `Home.md` 版本号与安装命令 + 主仓库 `CHANGELOG.md` + `README.md` 版本相关处;过一遍上面各行 |
|
||||
|
||||
**门**: 版本 bump 的提交不允许单独存在——同一次交付里必须包含对应的 wiki/CHANGELOG 同步(发布检查清单第一项)。
|
||||
|
||||
**站点下线期间(2026-08-02 至文档站重建)本表如何执行**: 上表左列的判据照旧,右列中指向具体 wiki 页面的项**全部落空,不必也无法执行**;仍然必须做的是 `CHANGELOG.md`、`README.md`、`.env.example` 与 `research-wiki/ARCHITECTURE.md` 四处。这道门因此**没有放松**——只是承接方从 wiki 换成了这四个文件,漏改它们与从前漏改 wiki 是同一性质的失败。
|
||||
|
||||
## 3. 写作纪律
|
||||
|
||||
- 中文;表格优先;单个代码块 ≤ 15 行;每个配置片段可直接复制运行。
|
||||
- **事实以源码为准**:参考区改动前先对照 `__init__.py` 导出面、`client.py`/`ocr.py`/`embedding.py` 签名与 `.env.example`;不确定就实测,不凭记忆写。
|
||||
- 深度内容(决策论证、迁移全文、验收数字)**只放指针**指向主仓库 `research-wiki/`,不复制——避免双处维护同一事实。
|
||||
- API 参考坚持**手写精选**(公共面小 + 只增不删承诺,手写比自动生成可读且低维护);若公共面显著膨胀再评估 mkdocstrings。
|
||||
|
||||
## 4. 更新操作
|
||||
|
||||
Wiki 是独立 git 仓库,两种改法:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.iomgaa.online/iomgaa/PolyGateway.wiki.git # 批量改: clone→编辑→push
|
||||
# 或在 Gitea 网页 Wiki 页面上直接编辑(单页小改)
|
||||
```
|
||||
|
||||
文件名即页名(中文文件名);`Home.md` 是落地页,`_Sidebar.md` 是导航,新增页必须同步进侧边栏与 Home 分流表。凭据在本机 osxkeychain(git)与 `~/.pypirc`(twine)。
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
type: finding
|
||||
node_id: finding:2026-08-02-thinking-switch-and-reasoning-tokens
|
||||
title: "推理开关与 reasoning_tokens: 供应商实测与业界做法"
|
||||
date: 2026-08-02
|
||||
---
|
||||
|
||||
# 推理开关与 reasoning_tokens:供应商实测与业界做法
|
||||
|
||||
> 类型:findings(事实基础)|日期:2026-08-02|来源:issue #5 / #6 调研
|
||||
> 本文只记录**已验证的事实与其证据**,设计取舍见 `designs/2026-08-02-thinking-capability-design.md`。
|
||||
> 本文的价值不限于这两条 issue——「同一语义、形态因模型而异」是本库长期要面对的一类问题,此处的结论与方法可复用。
|
||||
|
||||
## 1. 实验环境与方法
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 端点 | 自建 new-api 中转(`newapi.iomgaa.online/v1`,OpenAI 兼容) |
|
||||
| 参数 | `temperature=0`、`max_tokens=800`、非流式为主,流式单独验证 |
|
||||
| 题目 | 固定一道鸡兔同笼题,要求"只输出两个数字" |
|
||||
| 判据 | `usage.completion_tokens_details.reasoning_tokens`(**唯一可靠的判别量**,见 §2.5) |
|
||||
| 旁证 | `prompt_tokens` 变化——注入生效的参数会改变模型侧模板,输入侧 token 数随之变化 |
|
||||
|
||||
**方法论要点(可复用)**:判断一个参数"是否被上游真正消费",`prompt_tokens` 比输出长度可靠得多。输出长度受采样影响、方差大;而输入侧 token 数在同一请求体下是确定的,一旦变化就说明服务端换了模板,即参数确实到达了模型。本次三条关键结论全部由这个旁证锁定。
|
||||
|
||||
## 2. MiniMax:真开关是 `reasoning_effort`
|
||||
|
||||
### 2.1 M3 参数矩阵(非流式)
|
||||
|
||||
| 注入参数 | prompt | completion | reasoning_tokens | 判定 |
|
||||
|---|---|---|---|---|
|
||||
| 默认(不传) | 194 | 4 | 无 ctd | 不推理 |
|
||||
| `reasoning_effort=none` | 194 | 10 | 无 ctd | 不推理 |
|
||||
| `reasoning_effort=minimal` | **207** | 129 | 123 | 推理 |
|
||||
| `reasoning_effort=low` | **207** | 98 | 93 | 推理 |
|
||||
| `reasoning_effort=medium` | **207** | 183 | 177 | 推理 |
|
||||
| `reasoning_effort=high` | **207** | 158 | 142 | 推理 |
|
||||
| `thinking={"type":"enabled"}` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||
| `thinking={"type":"disabled"}` | 194 | 4 | 无 ctd | **被静默丢弃** |
|
||||
| `enable_thinking=true` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||
| `enable_thinking=false` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||
|
||||
`prompt_tokens` 194→207 的 13 token 差是硬证据:`reasoning_effort` 被消费时模型注入了推理指令;另四种写法 prompt 恒为 194,参数根本没到达模型。
|
||||
|
||||
### 2.2 `none` 是被识别的真值,不是被当非法值丢弃
|
||||
|
||||
这是一个必须排除的伪解释——若中转把不认识的值直接丢掉,`none` 的表现会与"不传"无异,我们就会误以为它生效。
|
||||
|
||||
反证实验:传乱码值 `reasoning_effort="xyzzy"` → 返回 200、prompt=207、reasoning_tokens=180。**未知值不但没被丢弃,反而开启了推理。** 既然无效值的行为是"开推理",而 `none` 的行为是"不推理",两者不同,`none` 就必然是被识别的枚举值。
|
||||
|
||||
对照组:完全未知的**键** `zzz_bogus_param=1` → prompt=194、无 ctd、无报错,确认未知**键**才会被静默吞掉。
|
||||
|
||||
### 2.3 M2.7 / M2.5 的推理关不掉
|
||||
|
||||
三种参数形态各 3 次,`completion_tokens` 全部落在推理区间:
|
||||
|
||||
| 模型 | 默认(基线) | `reasoning_effort=none` | `thinking:{disabled}` | `thinking:{adaptive}` |
|
||||
|---|---|---|---|---|
|
||||
| MiniMax-M2.7 | 372/283/285 | 275/301/248 | 310/190/219 | 299/269/246 |
|
||||
| MiniMax-M2.5 | 273/–/256 | 363/353/264 | 286/278/320 | 278/228/259 |
|
||||
|
||||
真关闭应为 5–10("23 12" 两个数字),实测无一接近。
|
||||
|
||||
**三个独立外部来源与实测完全吻合**:
|
||||
|
||||
| 来源 | M3 | M2.7 / M2.5 |
|
||||
|---|---|---|
|
||||
| OpenRouter `/api/v1/models` 的 `reasoning` 描述符 | `mandatory: false` | **`mandatory: true`** |
|
||||
| models.dev 的 `reasoning_options` | `[{"type":"toggle"}]`(二元可控) | `[]`(有推理但无控制手段) |
|
||||
| MiniMax 官方仓库 issue #121 | — | "M2.7 不允许关闭思考",无官方回复 |
|
||||
|
||||
**结论:M2.x 的推理是模型固有属性,不是参数没找对。** 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。
|
||||
|
||||
### 2.4 M3 的稳定性
|
||||
|
||||
同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。
|
||||
|
||||
### 2.5 输出长度不是有效判别量(2026-08-02 e2e 补测,各 15 轮)
|
||||
|
||||
初版判据用 `completion_tokens` 阈值区分推理开关,被自己的数据证伪:
|
||||
|
||||
| 档位 | `completion_tokens` 观测范围 | `reasoning_tokens` |
|
||||
|---|---|---|
|
||||
| 关闭(`reasoning_effort=none`) | 4 – **46** | 15/15 轮为 `None` |
|
||||
| 开启(`medium`) | **13** – 186 | 15/15 轮 > 0 |
|
||||
|
||||
**两档的输出长度分布重叠**:关闭档偶尔到 46(模型没照做「只输出两个数字」,把解题过程写进了正文——那是正文不是推理);开启档最低到 13(medium 档想得少的轮次)。按长度阈值判,两个方向都会误判。
|
||||
|
||||
而 `reasoning_tokens` 在同一批 30 轮里干净分开。**这条对下游同样成立**:想判断某次调用是否发生了推理,只能看 `reasoning_tokens`,不能看输出长度。
|
||||
|
||||
另有一个不含魔数的确定性锚点:同一模型上关闭档的 `prompt_tokens` 严格小于开启档(实测 194 < 207),因为供应商在开启时向模板注入了推理指令。这是相对比较,供应商改模板也不会失效。
|
||||
|
||||
## 3. qwen / deepseek:现有 profile 正确
|
||||
|
||||
| 模型 | `enable_thinking=false` | `thinking:{disabled}` | `reasoning_effort=none` | 现有 profile |
|
||||
|---|---|---|---|---|
|
||||
| qwen3.7-plus | ✅ 关闭(compl 5) | ✅ 关闭 | ✅ 关闭 | `enable_thinking` — **正确** |
|
||||
| deepseek-v4-pro | ❌ 无效(仍推理 198) | ✅ 关闭(compl 3) | ✅ 关闭 | `thinking:{type}` — **正确** |
|
||||
|
||||
两点附带事实:
|
||||
|
||||
- **`reasoning_effort=none` 在三家都有效**,但这很可能是中转做了参数归一化。**不可据此认为可以统一发一个参数**——下游若直连供应商官方端点,该假设大概率不成立。翻译表必须一家一行。
|
||||
- **qwen 的 `strip_think_tags=True` 已过时**:实测 qwen 走 `reasoning_content` 字段,正文中无 `<think>` 标签。无害,但属于死代码。
|
||||
- **非流式没有 400**:DashScope 系"`enable_thinking` 仅支持流式"的限制经中转不存在。直连时是否仍存在未验证。
|
||||
|
||||
## 4. new-api 中转的三个行为(会污染观测)
|
||||
|
||||
这一节对任何经中转做实测的场景都适用,值得单独记住。
|
||||
|
||||
**(a)不校验参数值。** `reasoning_effort="xyzzy"` 返回 200 并当作"开推理"处理。**意味着"靠上游报错兜底"的设计模式在此失效**——Bedrock 式的"最小交集 + 裸逃生口"在这里等于零保护。
|
||||
|
||||
**(b)静默丢弃未知键。** 默认路径是 struct round-trip(`ConvertRequest` 返回 struct 再 `json.Marshal`),未知键在第一次序列化就消失。new-api 有 per-channel 的 `pass_through_body_enabled` 开关可改变此行为。
|
||||
|
||||
**(c)上游不返回 usage 时用本地 tokenizer 补算并整体替换。** 补算出的 usage 只有三个标量,`completion_tokens_details` 为零值。这直接解释了实测中的双峰现象:
|
||||
|
||||
| 现象 | 解释 |
|
||||
|---|---|
|
||||
| 同一请求 10 次:`prompt=74` 者 6 次不上报 `reasoning_tokens`,`prompt=72` 者 4 次上报,从不交叉 | `74` = 本地估算值,`72` = 上游真值;补算路径吃掉了 ctd |
|
||||
|
||||
**这不是多渠道路由**(MiniMax 侧为单渠道单密钥),也不是配置错误,而是上游偶发不返回 usage 时的兜底逻辑。中转日志中的 `local_count_tokens` 标志可现场确认。
|
||||
|
||||
**对库的直接影响**:`reasoning_tokens` 缺失**不能**解释为"该源不上报这个字段",只能解释为"**本次调用未上报**"。下游若按前者建立统计口径会算错。
|
||||
|
||||
## 5. 业界如何建模"同一语义、形态因模型而异"
|
||||
|
||||
调研覆盖 LiteLLM、OpenRouter、models.dev、LangChain、Vercel AI SDK、AWS Bedrock Converse、Portkey、Helicone、LlamaIndex、new-api/one-api。
|
||||
|
||||
### 5.1 核心共识:形态按 provider,能力按 model
|
||||
|
||||
| 概念 | 变化频率 | 应归属层次 |
|
||||
|---|---|---|
|
||||
| **形态**:参数长什么样(`enable_thinking` / `thinking.type` / `reasoning_effort`) | 协议方言,一个供应商数年不变 | provider 级 |
|
||||
| **能力**:能否关闭、有几档、默认开不开 | 模型属性,同一供应商每代都变 | **model 级** |
|
||||
|
||||
注册单位的分布很能说明问题:LiteLLM(2986 条目)、models.dev(5949 条)、LangChain、OpenRouter(细到 endpoint)、Helicone 全部下沉到 model 级;**仍停在 provider 级的只有 Portkey 与 LlamaIndex,而这两家恰是失败语义最差的两家(均静默丢弃)**。二者相关不是偶然:注册单位不够细,就只能靠"表里没有 = 不发"来兜底,而这正是静默失效的成因。
|
||||
|
||||
### 5.2 失败语义的四种谱系
|
||||
|
||||
| 语义 | 代表 | 适用前提 |
|
||||
|---|---|---|
|
||||
| 默认报错 + 可配置降级开关 | LiteLLM(`UnsupportedParamsError` + `drop_params`) | 有 model 级能力表可依据 |
|
||||
| 软降级 + 显式 warning 通道 | Vercel AI SDK(丢弃参数并 push `warnings[]`) | 调用方愿意读 warning |
|
||||
| 静默忽略 + 可选路由过滤 | OpenRouter(默认忽略;`require_parameters:true` 改为排除不支持的上游) | 网关自己拥有路由权 |
|
||||
| 硬失败(透传给上游报错) | Bedrock(`inferenceConfig` 4 字段交集 + `additionalModelRequestFields` 裸透传) | **上游会诚实报错** |
|
||||
|
||||
**选型时先问"我的上游会不会诚实报错"**。若不会(如本项目的中转),最后一种直接出局,静默类也不能选。
|
||||
|
||||
### 5.3 表会过期,这是公理
|
||||
|
||||
LiteLLM 有过真实事故(issue #27351:`gpt-5.1-mini` 漏登记导致 `temperature` 被误拒)。它的应对是**两种相反极性**,值得直接借鉴:
|
||||
|
||||
- **opt-in 能力**(用错会 400 或悄悄花钱):未登记 → 视作不支持 → 拒绝
|
||||
- **opt-out 能力**(多半支持,误拒代价大):未登记 → 放行 → 只有表里显式写 `false` 才拒
|
||||
|
||||
维护方式上,LiteLLM/models.dev 靠社区 PR + CI 校验,LangChain 靠"上游拉取 + 本地增补 + 代码生成"。**对内部库而言唯一现实的答案是:谁实测出来谁登记,登记必须附实测证据与日期。**
|
||||
|
||||
### 5.4 「布尔开关 → 多档旋钮」无语义共识
|
||||
|
||||
| 系统 | effort → 预算的换算 |
|
||||
|---|---|
|
||||
| LiteLLM | 一组 2 的幂(1024/2048/4096/8192/16384),全部可用环境变量覆盖;gemini 各型号还另有分叉 |
|
||||
| OpenRouter | `max_tokens` 的百分比(≈80%/50%/20%) |
|
||||
| Helicone | 一律 `max_tokens/2`,完全不看档位 |
|
||||
| LangChain | 明确不保证跨 provider 可比 |
|
||||
|
||||
**唯一对齐的是"关"**:`none` / `disabled` / `thinking:{type:"disabled"}` / OpenRouter `effort:"none"` 语义一致。"开"那一端没有任何标准。
|
||||
|
||||
**工程共识只有一条:这个映射必须是可覆盖的常量,不是可推导的公式。** 业界所有人都在拍脑袋,区别只在拍完让不让调用方改。
|
||||
|
||||
### 5.5 Vercel AI SDK 的一处设计值得单记
|
||||
|
||||
它的推理档位枚举里有一个 `'provider-default'`,与 `'none'`(明确关闭)严格区分。这与本库 `enable_thinking` 的三态(`None` 不干预 / `True` / `False`)是同一思想——**"调用方不表态"必须是一个独立的值,不能与任何具体档位混同**。本库这一点原本就做对了,应保持。
|
||||
|
||||
## 6. 附带发现(不属本次范围,建议另立 issue)
|
||||
|
||||
**kimi-k3 拒绝 `temperature=0`**:返回 `400 invalid temperature: only 1 is supported`(另有渠道回 `only 0.6`)。本库把 400 归入 `RequestRejectedError`——不重试、不换源。若下游统一下发 `temperature=0`,此类源会 100% 硬失败。这与本次两条 issue 同源:**供应商能力差异未被建模**。
|
||||
|
||||
**中转渠道可用性会波动**:kimi 渠道在 429 后被中转下线,随后返回 `404 Model not supported by any channel`。任何依赖真实 API 的测试都必须容忍源不可用(跳过并给出明确原因),而不是失败。
|
||||
|
||||
## 7. 未能证实
|
||||
|
||||
1. **MiniMax 官方文档对 `reasoning_effort` 的一手定义**:官方文档站三次抓取均失败。M2.x 关不掉有三处佐证,但官方原文未取得。另有二手来源称 MiniMax 原生开关是 `thinking:{type:"adaptive"/"disabled"}`——**该说法已被本次实测证伪**(M2.7/M2.5 上两种写法均无效),但"中转是否对 `reasoning_effort` 做了改写"仍未排除。直连官方端点复测可彻底澄清。
|
||||
2. **qwen 直连 DashScope 时非流式 `enable_thinking` 是否仍报 400**:仅验证了经中转的行为。
|
||||
3. **new-api 走本地补算的确切触发条件**:读到了补算分支与 `local_count_tokens` 标记,未逐条比对所有渠道类型。双峰现象与该解释高度吻合,但未在日志中直接验证。
|
||||
4. **能力表条目对非本次实测模型的正确性**:qwen / deepseek 只测了各一个型号,同系其他型号未验证。
|
||||
|
||||
## 8. 对后续开发的指导
|
||||
|
||||
1. **判定参数是否生效,优先看 `prompt_tokens` 而非输出长度**(§1)。
|
||||
2. **排除"无效值被静默丢弃"必须做反证实验**:传一个乱码值,看它的行为是否与目标值不同(§2.2)。
|
||||
3. **经中转做的任何实测都要标注"经中转,直连未验证"**,并写进注释(§3、§7)。
|
||||
4. **新增供应商或模型前,先查 OpenRouter `/api/v1/models` 与 models.dev**——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。
|
||||
5. **能力表条目必须附实测证据与日期**;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。
|
||||
6. **`reasoning_tokens` 缺失只能记 `None`,绝不可记 `0`**(§4c)——"观测不到"与"没发生"是两件事。
|
||||
7. **判断"是否发生了推理"只能看 `reasoning_tokens`,不能看输出长度**(§2.5)——两档的 `completion_tokens` 分布是重叠的,长度阈值两个方向都会误判。
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: finding
|
||||
node_id: finding:2026-08-25-thinking-observability-regression
|
||||
title: "issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号"
|
||||
date: 2026-08-25
|
||||
---
|
||||
|
||||
# issue #16/#17 实测:M3 推理正常,失效的是推理的**可观测信号**
|
||||
|
||||
> 类型:finding|日期:2026-08-25|网关 `newapi.iomgaa.online`
|
||||
> 本文推翻 issue #16/#17 的原始诊断("模型不再推理"),是 `designs/2026-08-25-thinking-observability-design.md` 的事实基础。
|
||||
|
||||
## 1. 为什么要重测
|
||||
|
||||
issue #16/#17 判定 MiniMax-M3 的开启推理"静默失效:模型没有推理",依据是 `tests/e2e/test_thinking_live.py` 的 L2/L3b/L4/L5 四条全红,四条的共同判据是 `reasoning_tokens > 0`。issue 自己留了一个未区分的岔路:网关侧模型行为变了,还是库的注入失效了。区分方法写得很清楚——抓一次真实请求体与原始响应。本文就是那次抓取。
|
||||
|
||||
## 2. 方法
|
||||
|
||||
两层探针,都不走 slow 套件:
|
||||
|
||||
其一**绕开库**,用裸 `httpx` 直接 POST `/chat/completions`,矩阵化七种参数形态 × 流式/非流式,记录完整 `usage` 与 `message` 的键集合。绕开库是必要的——要证的命题之一正是"库有没有把参数弄丢",用库测这一条是循环论证。
|
||||
|
||||
其二**用库本身**跑 `GatewayClient.chat`,记录 `LLMResponse` 的 `reasoning_tokens` 与 `thinking` 两个字段。两层对照才能定位缺口落在哪一层。
|
||||
|
||||
对照组取 `qwen3.7-plus` 与 `deepseek-v4-pro`——同一网关、同一 key,用来区分"MiniMax 这一路变了"与"网关全局变了"。
|
||||
|
||||
## 3. 原始观测
|
||||
|
||||
### 3.1 MiniMax-M3,裸 httpx,非流式
|
||||
|
||||
| 变体 | prompt | completion | `completion_tokens_details` | `reasoning_content` |
|
||||
|---|---|---|---|---|
|
||||
| 不注入(基线) | 194 | 3 | **整个容器缺失** | 无 |
|
||||
| `reasoning_effort=medium` | **216** | **48** | 整个容器缺失 | 无 |
|
||||
| `reasoning_effort=high` | **216** | **65** | 整个容器缺失 | 无 |
|
||||
| `reasoning_effort=none` | 194 | 3 | 整个容器缺失 | 无 |
|
||||
| `thinking={"type":"enabled"}` | 194 | 3 | 整个容器缺失 | 无 |
|
||||
| `enable_thinking=true` | 194 | 3 | 整个容器缺失 | 无 |
|
||||
| 非法值 `definitely-not-a-real-level` | 207 | 87 | 整个容器缺失 | 无 |
|
||||
|
||||
### 3.2 MiniMax-M3,裸 httpx,流式
|
||||
|
||||
| 变体 | delta 的键集合 | `reasoning_content` 累计 | usage |
|
||||
|---|---|---|---|
|
||||
| 不注入 | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
|
||||
| `reasoning_effort=medium` | `content`,**`reasoning_content`**,`role` | **124 字符,完整推理过程** | prompt 216 / completion 60,无 ctd |
|
||||
| `reasoning_effort=none` | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
|
||||
|
||||
流式 medium 档抓到的推理正文(前 120 字符):`We need answer Chinese, only two digits. Chickens x rabbits y. x+y=35,2x+4y=94 => x+y*? 2*35+2y=94 y=12, x=23. Output 23`
|
||||
|
||||
### 3.3 对照组(流式)
|
||||
|
||||
| 模型 | 变体 | `reasoning_content` | `completion_tokens_details.reasoning_tokens` |
|
||||
|---|---|---|---|
|
||||
| deepseek-v4-pro | 不注入 | 135 字符 | **88** |
|
||||
| deepseek-v4-pro | `effort=medium` | 134 字符 | **89** |
|
||||
| deepseek-v4-pro | `effort=none` | 0 | 容器缺失 |
|
||||
| qwen3.7-plus | 不注入 | 350 字符 | **158** |
|
||||
| qwen3.7-plus | `effort=medium` | 606 字符 | **229** |
|
||||
| qwen3.7-plus | `effort=none` | 0 | 容器缺失 |
|
||||
| qwen3.7-plus | 非法值 | — | **HTTP 400** |
|
||||
|
||||
### 3.4 用库跑(`LLMResponse` 字段)
|
||||
|
||||
| 场景 | `reasoning_tokens` | `thinking` 字符数 | completion |
|
||||
|---|---|---|---|
|
||||
| M3 开启,流式 | None | **185** | 69 |
|
||||
| M3 开启,非流式 | None | **0** | 53 |
|
||||
| M3 关闭,流式/非流式 | None | 0 | 3 |
|
||||
| M3 不干预 | None | 0 | 3 |
|
||||
| qwen 开启,流式 | **205** | 484 | 213 |
|
||||
| qwen 关闭,流式 | None | 0 | 5 |
|
||||
|
||||
## 4. 五条结论
|
||||
|
||||
**① M3 的推理完全正常,issue 的诊断是错的。** 流式 medium 档抓到 124 字符完整推理过程;`prompt_tokens` 194→216(供应商注入推理指令)、`completion_tokens` 3→60(推理段被计费)。三个独立信号一致。
|
||||
|
||||
**② 真正变的是 MiniMax 这一路不再返回 `usage.completion_tokens_details`。** 而 qwen 与 deepseek 在同一网关同一 key 上照常返回。所以这不是网关全局改了 usage 处理,是 MiniMax 这一路上游的 usage 形态变了。`reasoning_tokens` 恒 NULL 由此而来。
|
||||
|
||||
**③ 库自己已经握有决定性证据,却没有用。** `LLMResponse.thinking` 在 M3 开启档流式路径下是 185 字符的实打实推理正文。e2e 的 `_reasoning_on` 只看 `reasoning_tokens` 与 `completion_tokens` 长度,从不看 `thinking`——四条红是判据的盲区,不是功能的失效。
|
||||
|
||||
**④ M3 非流式路径下推理内容整体丢失,且下游在付费。** `completion_tokens` 53 vs 关闭档 3,说明推理段确实产生并计费;而 `message` 的键集合只有 `content`/`role`,`reasoning_content` 不存在。下游用非流式调 M3 开推理 = 付钱买看不见的东西,且当前库不告诉它。这不是库能修的(上游不返回),但库必须让它可见。
|
||||
|
||||
**⑤ 三家供应商在"未推理"时都是整个 `completion_tokens_details` 缺失,无人上报 `0`。** 与 2026-08-02 findings §4c 的记录一致。推论:**"容器在不在"不能当作"有没有推理"的判据**——它与真实信号高度混淆,拿它做裁定等于把噪声当信号。
|
||||
|
||||
## 5. 顺带纠正的两处既有认识
|
||||
|
||||
**`enable_thinking` / `thinking:{type:enabled}` 对 M3 无效这一条仍然成立**(prompt 恒 194 = 基线),只有 `reasoning_effort` 是真开关。`providers.py` 的 minimax profile 用的正是 `reasoning_effort`,选型至今正确。
|
||||
|
||||
**L3b 的"非法值反证"手法只对不校验值的 provider 成立。** minimax 对非法 `reasoning_effort` 返回 200 且照常推理(prompt 207,介于基线 194 与 medium 216 之间,说明走了第三条模板路径);qwen 对同样的非法值直接 **HTTP 400**。这条手法写进测试时只在 minimax 上验过,它不可移植——若哪天把 L3b 套到别的 provider 上会得到假红。
|
||||
|
||||
## 6. `can_disable` 复测
|
||||
|
||||
M3 的 `ThinkingCapability(can_disable=True)` 的 evidence 停在 2026-08-02。2026-08-25 复测:`reasoning_effort=none` → prompt 194(= 基线)、completion 3、无 `reasoning_content`。**声明依然成立**,只需刷新 evidence 日期并补记本文新发现的两条限制(非流式不可观测、仅 `reasoning_effort` 有效)。
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
type: finding
|
||||
node_id: finding:2026-08-26-issue18-shared-pg-test-isolation
|
||||
title: "issue #18 实测: 偶发红的是安全网本身,不是被测脚本"
|
||||
date: 2026-08-26
|
||||
---
|
||||
|
||||
# issue #18 实测:偶发红的是**安全网本身**,不是被测脚本
|
||||
|
||||
> 类型:finding|日期:2026-08-26|实例 `polygateway` 库(PostgreSQL 16.14,共享)
|
||||
> 本文是 `designs/2026-08-26-issue18-pg-test-isolation-design.md` 的事实基础。
|
||||
> 实测与推断在 §5 明确分界——推断部分未做复现实验,不当作既定事实使用。
|
||||
|
||||
## 1. 失败断言的唯一归属
|
||||
|
||||
`assert 12 == 61` 只能对应 `test_retention_tool_pg.py::TestPlainTableBatches::test_apply_deletes_only_expired_rows_in_batches` 的最后一行:
|
||||
|
||||
| 断言 | 形态 |
|
||||
|---|---|
|
||||
| `_call_ids(schema_dsn) == ["fresh-1", "fresh-2"]` | 列表比较,失败会打印列表 |
|
||||
| `"将删除行数: 5" in result.stdout` 等五条 | 子串判定,失败不打印数字对 |
|
||||
| `await _public_count(dsn) == before_public` | **整型比较,唯一能报出 `12 == 61`** |
|
||||
|
||||
`before_public` 在 seed 之前取,`12` 是脚本跑完后的复测值。
|
||||
|
||||
## 2. 被测脚本没有越界
|
||||
|
||||
失败发生在最后一条,意味着它前面全部通过:`_call_ids(schema_dsn)` 恰为 `["fresh-1","fresh-2"]`(临时 schema 里 5 行过期行被删、2 行新鲜行留下)、stdout 里出现 `<临时schema>.llm_calls`、`将删除行数: 5`、三条批次行齐全。
|
||||
|
||||
若 `search_path` 曾失效、脚本打到了 `public.llm_calls`,那么临时表 7 行一行不少,第二条断言就会先红。**故本次失败与 `telemetry_retention.py` 的行为无关**。
|
||||
|
||||
## 3. 共享表的实测现状
|
||||
|
||||
以 `.env` 的 `PGW_TELEMETRY_PG_DSN` 直连查得(2026-08-26):
|
||||
|
||||
| 项 | 实测值 |
|
||||
|---|---|
|
||||
| `public.llm_calls` 行数 | **11**,非分区普通表 |
|
||||
| 这 11 行的 `created_at` | 全部落在 `2026-07-22 14:00 ~ 14:26` |
|
||||
| 这 11 行的 `call_id` 形态 | 裸 hex 前缀(`3c915c04`、`c8071b6a` …)与一个 `c1`,**不是** `pgwtest-` 前缀 |
|
||||
| 表属主 / ACL | `app` / `{app=arwdDxt/app, chs3_test=ar/app}`(无 PUBLIC 授权) |
|
||||
| `.env` 里那个角色 | `app`,`rolsuper = true`、`rolcreatedb = true`、`rolcreaterole = true` |
|
||||
| 服务端版本 / 连接 | PostgreSQL 16.14;`max_connections = 100`,查时 54 个连接在用 |
|
||||
| 残留临时 schema / 角色 | 无(`pgw%` 命名下均为空) |
|
||||
|
||||
失败时的 `12` 与这个 `11` 行基线同量级;`61` 意味着取快照那一刻库里另有约 49 行,随后消失。那 11 行是一个多月前留下的**孤儿行**:它们早于 7 天截止线,任何一次带 `--apply` 的存量清理都会删掉它们——这本身说明真实共享表上确实存在"测试/工具写完没清干净"的历史。
|
||||
|
||||
## 4. 本仓库自己就是共享表的写入方
|
||||
|
||||
`tests/integration/test_postgres_telemetry.py` 存在两套并行的隔离手法:
|
||||
|
||||
| 手法 | 用在哪 | 是否触碰 `public.llm_calls` |
|
||||
|---|---|---|
|
||||
| 临时 schema(`legacy_schema`、`fresh_schema`、`pre_tenant_schema`、`partitioned_schema`、`least_privilege_dsn`、`least_privilege_pre_tenant_dsn`、`production_template`) | 需要特定表形态的用例 | 否,teardown 走 `DROP SCHEMA CASCADE` |
|
||||
| `_RUN_PREFIX` 前缀(模块级 `pgwtest-<uuid8>`) | `TestObservabilityColumns::test_values_round_trip`、`TestSchema` 三条、`TestDegradation` 两条、`TestPoolFootprint` 一条,**共 7 条** | **是**,写入真表,`dsn` fixture teardown 执行 `DELETE ... WHERE call_id LIKE '<前缀>-%'` |
|
||||
|
||||
前缀隔离对**读**是完备的(每个进程只看自己的行),对**全表口径的观测**不设防——而 `_public_count` 正是全套件里唯一一处全表口径。
|
||||
|
||||
## 5. 实测与推断的分界
|
||||
|
||||
**实测(本会话工具输出)**:§1 的断言归属、§2 的失败顺序推理、§3 的全部数字、§4 的用例清单。
|
||||
|
||||
**推断(未做复现实验)**:那 49 行的来源。同一 pytest 进程内 `test_postgres_telemetry.py` 排在 `test_retention_tool_pg.py` 之前(文件名序),且其 `dsn` fixture 是函数级、每条用例后立即清理,故同进程解释不成立;最合理的解释是**另一个进程**在同一秒窗口内完成了一轮"写 7 条 → teardown 删掉"的循环——并行的另一个开发会话,或 `~/Projects/m4-worktrees/` 下迁移项目的批跑(三个迁移项目正是用本库往这张表写遥测)。
|
||||
|
||||
这条推断不影响结论:无论那 49 行由谁写删,`public.llm_calls` 的行数都是**不归本测试控制的全局可变量**,把它当断言基线在设计上就不成立。
|
||||
|
||||
## 6. 与 `_public_count` 的设计意图的落差
|
||||
|
||||
该断言的注释写明它要防的是"`search_path` 没生效导致静默删库"。行数快照防不住这件事:
|
||||
|
||||
- **假红**:任何外部写/删都让它红(本次即是),而脚本完全正常
|
||||
- **假阴**:外部并发的增减可以与脚本的误删互相抵消,行数相等则静默放行——它守的是删库,这一半失效才是真正的代价
|
||||
|
||||
一个安全属性被编码成对全局可变量的观测,两个方向都不成立。
|
||||
|
||||
## 7. 方案可行性的实测(2026-08-26,同一实例)
|
||||
|
||||
用一次性角色/schema 做的证伪实验(建 `pgwprobe_r_*` 角色 + `pgwprobe_s_*` schema,跑完全部 `DROP`,实例上无残留):
|
||||
|
||||
| # | 探针 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | 角色以自己身份建表 | 属主为该角色(与"用维护角色跑"的现场一致) |
|
||||
| 2 | `to_regclass('"<schema>"."llm_calls"')` | 正常解析到该表 |
|
||||
| 3 | `to_regclass('"nosuch_schema_xyz"."llm_calls"')` | **返回 NULL,不抛错** |
|
||||
| 4 | `to_regclass('"<SCHEMA 大写>"."llm_calls"')` | **返回 NULL** —— 引号限定名区分大小写 |
|
||||
| 5 | 临时角色**裸连**(不挂 search_path) | `SHOW search_path` = `"$user", public`,`to_regclass('llm_calls')` 命中真表 |
|
||||
| 6 | 裸连对真表 `SELECT COUNT(*)` | `InsufficientPrivilegeError: permission denied for table llm_calls` |
|
||||
| 7 | 裸连对真表 `DELETE ... WHERE created_at < now()` | `InsufficientPrivilegeError: permission denied for table llm_calls` |
|
||||
| 8 | 角色名与 schema **同名**时裸连 | `"$user"` 命中自有 schema,**遮蔽 public** |
|
||||
|
||||
第 6、7 条是新方案的核心防线:最坏情况下脚本连数都数不出来,更谈不上删。第 8 条是一条必须写进设计的约束——今天 `least_privilege_dsn` 的角色与 schema 恰好同名,若沿用该形态,"search_path 落到 public"的最坏情况用例会走到自有 schema 上,测出来的是个假现场。
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
{
|
||||
"id": "schema:llm-calls",
|
||||
"label": "表结构: llm_calls(遥测 18 字段)",
|
||||
"label": "表结构: llm_calls(遥测 25 字段)",
|
||||
"type": "schema"
|
||||
},
|
||||
{
|
||||
@@ -90,6 +90,126 @@
|
||||
"id": "finding:m4-acceptance",
|
||||
"label": "M4 迁移验收(GovDoc+CHS)",
|
||||
"type": "finding"
|
||||
},
|
||||
{
|
||||
"id": "design:settings-invariant-guards",
|
||||
"label": "GatewaySettings 跨字段不变量守卫的生效范围",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "design:settings-invariants-round-2",
|
||||
"label": "GatewaySettings 装配校验补齐(第二轮)",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "design:est-tokens-decoupling",
|
||||
"label": "est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:est-tokens-decoupling",
|
||||
"label": "est_tokens 解耦实施计划",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:response-observability-fields",
|
||||
"label": "响应可观测字段扩展(Issue #3)",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:response-observability-fields",
|
||||
"label": "响应可观测字段扩展实现计划",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:sampling-params",
|
||||
"label": "采样参数透传设计(issue #4)",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:sampling-params-plan",
|
||||
"label": "采样参数透传实现计划(issue #4)",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:governance-backend-error",
|
||||
"label": "治理后端故障归位为 scope 级不可用(Issue #7)",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:governance-backend-error",
|
||||
"label": "实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:issue8-stall-budget",
|
||||
"label": "stall 判定改为非生产性等待口径",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:issue8-stall-budget-plan",
|
||||
"label": "issue #8 实施计划: stall 非生产性等待口径",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:issue10-error-body-retention",
|
||||
"label": "HTTP 错误响应体留存(Issue #10)",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:issue10-error-body-retention-plan",
|
||||
"label": "实现计划: HTTP 错误响应体留存(Issue #10)",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "plan:issue11-caller-dimensions",
|
||||
"label": "调用方自定义维度实现计划(issue #11)",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:issue13-schema-mode",
|
||||
"label": "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "design:issue12-telemetry-retention",
|
||||
"label": "issue #12: 遥测表的正文体量、保留期与访问控制",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:plan-issue13-schema-mode",
|
||||
"label": "实现计划: issue13-schema-mode",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "plan:plan-issue12-telemetry-retention",
|
||||
"label": "实现计划: issue12-telemetry-retention",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "review:issue14-branch-review",
|
||||
"label": "整分支审查: issue #14 熔断等待档",
|
||||
"type": "review"
|
||||
},
|
||||
{
|
||||
"id": "design:issue15-telemetry-pool-lifecycle",
|
||||
"label": "issue #15: 遥测连接池的资源语义与生命周期",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:plan-issue15-telemetry-pool-lifecycle",
|
||||
"label": "实现计划: 遥测连接池的资源语义与生命周期(issue #15)",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:reasoning-effort",
|
||||
"label": "推理档位一等化设计(issue #20 及其一般形式)",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:reasoning-effort",
|
||||
"label": "实现计划: 推理档位一等化",
|
||||
"type": "plan"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
@@ -155,6 +275,160 @@
|
||||
"relation": "implements",
|
||||
"evidence": "T0-T14 逐节实现设计 §4-§10/§15",
|
||||
"added": "2026-07-22T09:33:05.357964+00:00"
|
||||
},
|
||||
{
|
||||
"source": "design:est-tokens-decoupling",
|
||||
"target": "design:m1-core-design",
|
||||
"relation": "refines",
|
||||
"evidence": "精化 M1 冻结的 est_tokens 双职责语义: 保留 TPM 预扣、推翻 usage 缺失按 est 兜底(m1-core-design.md:59,222),改记 0/0 + unavailable + cost NULL",
|
||||
"added": "2026-07-30T09:33:55.383401+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:est-tokens-decoupling",
|
||||
"target": "design:est-tokens-decoupling",
|
||||
"relation": "implements",
|
||||
"evidence": "5 任务实现设计 §3.2 的 11 条改动项;任务排序经中间态破窗分析(先加能力→切调用点→三态生效→解绑约束)",
|
||||
"added": "2026-07-30T09:39:26.442986+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:response-observability-fields",
|
||||
"target": "design:response-observability-fields",
|
||||
"relation": "implements",
|
||||
"evidence": "计划 T1-T7 逐条实现设计的 A2/B1/C1/D1 四个决策",
|
||||
"added": "2026-07-31T11:10:03.872049+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:sampling-params-plan",
|
||||
"target": "design:sampling-params",
|
||||
"relation": "implements",
|
||||
"evidence": "11 个任务逐条覆盖设计的决策 A-G 与 §5 的 14 条测试清单",
|
||||
"added": "2026-07-31T16:59:35.657367+00:00"
|
||||
},
|
||||
{
|
||||
"source": "finding:2026-08-02-thinking-switch-and-reasoning-tokens",
|
||||
"target": "design:2026-08-02-thinking-capability-design",
|
||||
"relation": "supports",
|
||||
"evidence": "供应商实测与业界调研为该设计的形态/能力分层与失败语义提供事实依据",
|
||||
"added": "2026-08-02T09:38:57.033054+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:2026-08-02-thinking-capability",
|
||||
"target": "design:2026-08-02-thinking-capability-design",
|
||||
"relation": "implements",
|
||||
"evidence": "T1-T10 逐条实现设计的 D1-D6 六个决策与 §11 九条验收标准",
|
||||
"added": "2026-08-02T09:49:48.126539+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:governance-backend-error",
|
||||
"target": "design:governance-backend-error",
|
||||
"relation": "implements",
|
||||
"evidence": "T1-T5 逐任务实现设计 §3 的五项决策与 §8 影响面清单",
|
||||
"added": "2026-08-06T08:08:51.865565+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:issue8-stall-budget-plan",
|
||||
"target": "design:issue8-stall-budget",
|
||||
"relation": "implements",
|
||||
"evidence": "T1-T6 实施该设计,含 §3.6 订正",
|
||||
"added": "2026-08-06T14:58:01.673693+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:issue10-error-body-retention-plan",
|
||||
"target": "design:issue10-error-body-retention",
|
||||
"relation": "implements",
|
||||
"evidence": "7 任务覆盖设计 G1-G4 与 §7 全部验收用例",
|
||||
"added": "2026-08-16T09:50:57.830855+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:issue11-caller-dimensions",
|
||||
"target": "design:issue11-caller-dimensions",
|
||||
"relation": "implements",
|
||||
"evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路",
|
||||
"added": "2026-08-17T10:09:08.967997+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:plan-issue13-schema-mode",
|
||||
"target": "design:issue13-schema-mode",
|
||||
"relation": "implements",
|
||||
"evidence": "research-wiki/plans/2026-08-19-issue13-schema-mode.md",
|
||||
"added": "2026-08-19T13:10:55.616264+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:plan-issue12-telemetry-retention",
|
||||
"target": "design:issue12-telemetry-retention",
|
||||
"relation": "implements",
|
||||
"evidence": "research-wiki/plans/2026-08-19-issue12-telemetry-retention.md",
|
||||
"added": "2026-08-19T13:10:57.986963+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:plan-issue14-admission-wait-policy",
|
||||
"target": "design:2026-08-19-issue14-admission-wait-policy-design",
|
||||
"relation": "implements",
|
||||
"evidence": "research-wiki/plans/plan-issue14-admission-wait-policy.md;T0-T8 逐节映射设计 §3.1-§3.6",
|
||||
"added": "2026-08-20T03:30:06.280582+00:00"
|
||||
},
|
||||
{
|
||||
"source": "review:issue14-branch-review",
|
||||
"target": "plan:plan-issue14-admission-wait-policy",
|
||||
"relation": "informs",
|
||||
"evidence": "Important 项促使修正 CHANGELOG/README/设计 §4/计划 T5 对 wait 档失败 reason 的描述",
|
||||
"added": "2026-08-20T05:01:16.206639+00:00"
|
||||
},
|
||||
{
|
||||
"source": "design:issue15-telemetry-pool-lifecycle",
|
||||
"target": "design:issue9-telemetry-ddl-probe",
|
||||
"relation": "refines",
|
||||
"evidence": "把 issue #9 的'确定写不进去'判据从'哪一步失败'改为'失败是什么性质': 建池失败不再一律判死",
|
||||
"added": "2026-08-24T05:50:56.787791+00:00"
|
||||
},
|
||||
{
|
||||
"source": "design:issue15-telemetry-pool-lifecycle",
|
||||
"target": "design:issue12-telemetry-retention",
|
||||
"relation": "depends_on",
|
||||
"evidence": "遥测作为审计证据的定位(决策 E-a)是否决'异步队列 + 后台 flush'备选的依据",
|
||||
"added": "2026-08-24T05:50:57.954717+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:plan-issue15-telemetry-pool-lifecycle",
|
||||
"target": "design:issue15-telemetry-pool-lifecycle",
|
||||
"relation": "implements",
|
||||
"evidence": "八任务实现四组改动(池语义/失败三分/状态可见/所有权纪律)",
|
||||
"added": "2026-08-24T12:05:46.300738+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:2026-08-25-thinking-observability-plan",
|
||||
"target": "design:2026-08-25-thinking-observability-design",
|
||||
"relation": "implements",
|
||||
"evidence": "本计划 Task 1-10 实现该设计的全部落点与 §14 验收标准",
|
||||
"added": "2026-08-26T04:49:16.312785+00:00"
|
||||
},
|
||||
{
|
||||
"source": "finding:2026-08-25-thinking-observability-regression",
|
||||
"target": "design:2026-08-25-thinking-observability-design",
|
||||
"relation": "supports",
|
||||
"evidence": "裸 httpx 与库两层实测(M3 推理正常、MiniMax 停报 completion_tokens_details)是该设计三层根因与三态裁定的事实基础",
|
||||
"added": "2026-08-26T04:49:17.481308+00:00"
|
||||
},
|
||||
{
|
||||
"source": "finding:2026-08-25-thinking-observability-regression",
|
||||
"target": "design:2026-08-02-thinking-capability-design",
|
||||
"relation": "refines",
|
||||
"evidence": "复测确认 M3 can_disable 仍成立,并补记非流式不可观测、仅 reasoning_effort 有效两条限制",
|
||||
"added": "2026-08-26T04:49:18.648857+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:2026-08-26-issue18-pg-test-isolation",
|
||||
"target": "design:2026-08-26-issue18-pg-test-isolation",
|
||||
"relation": "implements",
|
||||
"evidence": "9 个任务逐条实现设计 §4-§11",
|
||||
"added": "2026-08-26T11:28:33.469681+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:reasoning-effort",
|
||||
"target": "design:reasoning-effort",
|
||||
"relation": "implements",
|
||||
"evidence": "10 个任务逐条覆盖设计 §3-§8;T10 兑现人类「能力表统一经 new-api 实测」的决定",
|
||||
"added": "2026-09-05T04:07:17.723586+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
+68
-5
@@ -1,46 +1,109 @@
|
||||
# Research Wiki 索引
|
||||
|
||||
> 自动生成,更新时间:2026-07-22 14:36 UTC
|
||||
> 自动生成,更新时间:2026-09-05 04:07 UTC
|
||||
|
||||
## design (10)
|
||||
## design (41)
|
||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||
- [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design`
|
||||
- [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-design`
|
||||
- [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design`
|
||||
- [2026-07-30-est-tokens-decoupling-design](designs/2026-07-30-est-tokens-decoupling-design.md) `design:2026-07-30-est-tokens-decoupling-design`
|
||||
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
||||
- [2026-07-31-response-observability-fields-design](designs/2026-07-31-response-observability-fields-design.md) `design:2026-07-31-response-observability-fields-design`
|
||||
- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design`
|
||||
- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design`
|
||||
- [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design`
|
||||
- [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design`
|
||||
- [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design`
|
||||
- [2026-08-19-issue12-telemetry-retention-design](designs/2026-08-19-issue12-telemetry-retention-design.md) `design:2026-08-19-issue12-telemetry-retention-design`
|
||||
- [2026-08-19-issue13-schema-mode-design](designs/2026-08-19-issue13-schema-mode-design.md) `design:2026-08-19-issue13-schema-mode-design`
|
||||
- [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design`
|
||||
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
|
||||
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
|
||||
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
||||
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||
- [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention`
|
||||
- [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention`
|
||||
- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode`
|
||||
- [issue #15: 遥测连接池的资源语义与生命周期](designs/issue15-telemetry-pool-lifecycle.md) `design:issue15-telemetry-pool-lifecycle`
|
||||
- [issue #18: 隔离靠权限强制,目标靠显式声明](designs/2026-08-26-issue18-pg-test-isolation-design.md) `design:2026-08-26-issue18-pg-test-isolation`
|
||||
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
||||
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
||||
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
||||
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
|
||||
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
||||
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
||||
- [推理可观测性一等化(issue #16 + #17)](designs/2026-08-25-thinking-observability-design.md) `design:2026-08-25-thinking-observability-design`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
||||
- [推理档位一等化设计(issue #20 及其一般形式)](designs/reasoning-effort.md) `design:reasoning-effort`
|
||||
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
|
||||
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||
|
||||
## finding (11)
|
||||
## finding (14)
|
||||
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
||||
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
||||
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
||||
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
|
||||
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
|
||||
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
|
||||
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
|
||||
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
|
||||
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
|
||||
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
|
||||
- [M4 迁移验收(GovDoc+CHS)](findings/m4-acceptance.md) `finding:m4-acceptance`
|
||||
- [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline`
|
||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||
|
||||
## plan (10)
|
||||
## plan (36)
|
||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||
- [2026-07-21-m3-ocr-plan](plans/2026-07-21-m3-ocr-plan.md) `plan:2026-07-21-m3-ocr-plan`
|
||||
- [2026-07-22-m4-migration-plan](plans/2026-07-22-m4-migration-plan.md) `plan:2026-07-22-m4-migration-plan`
|
||||
- [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan`
|
||||
- [2026-07-31-response-observability-fields](plans/2026-07-31-response-observability-fields.md) `plan:2026-07-31-response-observability-fields`
|
||||
- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params`
|
||||
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
|
||||
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
|
||||
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
|
||||
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
|
||||
- [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention`
|
||||
- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode`
|
||||
- [2026-08-24-issue15-telemetry-pool-lifecycle](plans/2026-08-24-issue15-telemetry-pool-lifecycle.md) `plan:2026-08-24-issue15-telemetry-pool-lifecycle`
|
||||
- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort`
|
||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
|
||||
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
||||
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
||||
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||
- [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy`
|
||||
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
||||
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
||||
- [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention`
|
||||
- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode`
|
||||
- [实现计划: 推理档位一等化](plans/reasoning-effort.md) `plan:reasoning-effort`
|
||||
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
||||
- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle`
|
||||
- [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
||||
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
|
||||
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||
|
||||
## review (1)
|
||||
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
|
||||
|
||||
## schema (1)
|
||||
- [表结构: llm_calls(遥测 18 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
||||
- [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
||||
|
||||
## metric (2)
|
||||
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
||||
|
||||
@@ -46,3 +46,107 @@
|
||||
- [2026-07-22 09:33 UTC] 重建索引: 32 篇页面
|
||||
- [2026-07-22 14:36 UTC] 新增 finding: M4 迁移验收(GovDoc+CHS) (finding:m4-acceptance)
|
||||
- [2026-07-22 14:36 UTC] 重建索引: 34 篇页面
|
||||
- [2026-07-30 03:44 UTC] 新增 design: GatewaySettings 跨字段不变量守卫的生效范围 (design:settings-invariant-guards)
|
||||
- [2026-07-30 03:44 UTC] 重建索引: 36 篇页面
|
||||
- [2026-07-30 04:44 UTC] 新增 design: GatewaySettings 装配校验补齐(第二轮) (design:settings-invariants-round-2)
|
||||
- [2026-07-30 04:44 UTC] 重建索引: 38 篇页面
|
||||
- [2026-07-30 09:32 UTC] 新增 design: est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2) (design:est-tokens-decoupling)
|
||||
- [2026-07-30 09:33 UTC] 重建索引: 40 篇页面
|
||||
- [2026-07-30 09:33 UTC] 新增边: design:est-tokens-decoupling --refines--> design:m1-core-design
|
||||
- [2026-07-30 09:33 UTC] 重建索引: 40 篇页面
|
||||
- [2026-07-30 09:39 UTC] 新增 plan: est_tokens 解耦实施计划 (plan:est-tokens-decoupling)
|
||||
- [2026-07-30 09:39 UTC] 新增边: plan:est-tokens-decoupling --implements--> design:est-tokens-decoupling
|
||||
- [2026-07-30 09:39 UTC] 重建索引: 42 篇页面
|
||||
- [2026-07-31 08:35 UTC] 新增 design: 响应可观测字段扩展(Issue #3) (design:response-observability-fields)
|
||||
- [2026-07-31 08:37 UTC] 重建索引: 44 篇页面
|
||||
- [2026-07-31 11:10 UTC] 新增 plan: 响应可观测字段扩展实现计划 (plan:response-observability-fields)
|
||||
- [2026-07-31 11:10 UTC] 新增边: plan:response-observability-fields --implements--> design:response-observability-fields
|
||||
- [2026-07-31 11:10 UTC] 重建索引: 46 篇页面
|
||||
- [2026-07-31 11:11 UTC] 重建索引: 46 篇页面
|
||||
- [2026-07-31 12:25 UTC] 重建索引: 46 篇页面
|
||||
- [2026-07-31 15:57 UTC] 新增 design: 采样参数透传设计(issue #4) (design:sampling-params)
|
||||
- [2026-07-31 15:57 UTC] 重建索引: 48 篇页面
|
||||
- [2026-07-31 16:00 UTC] 重建索引: 48 篇页面
|
||||
- [2026-07-31 16:31 UTC] 重建索引: 48 篇页面
|
||||
- [2026-07-31 16:59 UTC] 新增 plan: 采样参数透传实现计划(issue #4) (plan:sampling-params-plan)
|
||||
- [2026-07-31 16:59 UTC] 新增边: plan:sampling-params-plan --implements--> design:sampling-params
|
||||
- [2026-07-31 16:59 UTC] 重建索引: 50 篇页面
|
||||
- [2026-07-31 17:01 UTC] 重建索引: 50 篇页面
|
||||
- [2026-08-01 01:58 UTC] 重建索引: 50 篇页面
|
||||
- [2026-08-02 09:38 UTC] 重建索引: 52 篇页面
|
||||
- [2026-08-02 09:38 UTC] 新增边: finding:2026-08-02-thinking-switch-and-reasoning-tokens --supports--> design:2026-08-02-thinking-capability-design
|
||||
- [2026-08-02 09:38 UTC] 新增 finding: 推理开关与 reasoning_tokens 供应商实测与业界做法 (finding:2026-08-02-thinking-switch-and-reasoning-tokens)
|
||||
- [2026-08-02 09:38 UTC] 新增 design: 推理开关能力建模与 reasoning_tokens 采集 issue #5+#6 (design:2026-08-02-thinking-capability-design)
|
||||
- [2026-08-02 09:39 UTC] 重建 Query Pack: 29 字符
|
||||
- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面
|
||||
- [2026-08-02 09:49 UTC] 新增边: plan:2026-08-02-thinking-capability --implements--> design:2026-08-02-thinking-capability-design
|
||||
- [2026-08-02 09:49 UTC] 新增 plan: 推理开关能力建模与 reasoning_tokens 采集实施计划 (plan:2026-08-02-thinking-capability)
|
||||
- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面
|
||||
- [2026-08-02 10:55 UTC] 重建索引: 53 篇页面
|
||||
- [2026-08-02 10:55 UTC] 更新 finding: 补 §2.5 输出长度不是有效判别量(e2e 各 15 轮实测)
|
||||
- [2026-08-06 06:37 UTC] 新增 design: 治理后端故障归位为 scope 级不可用(Issue #7) (design:governance-backend-error)
|
||||
- [2026-08-06 06:38 UTC] 重建索引: 55 篇页面
|
||||
- [2026-08-06 08:08 UTC] 新增 plan: 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7) (plan:governance-backend-error)
|
||||
- [2026-08-06 08:08 UTC] 新增边: plan:governance-backend-error --implements--> design:governance-backend-error
|
||||
- [2026-08-06 08:08 UTC] 重建索引: 57 篇页面
|
||||
- [2026-08-06 08:11 UTC] 重建索引: 57 篇页面
|
||||
- [2026-08-06 14:57 UTC] 新增 design: stall 判定改为非生产性等待口径 (design:issue8-stall-budget)
|
||||
- [2026-08-06 14:58 UTC] 新增 plan: issue #8 实施计划: stall 非生产性等待口径 (plan:issue8-stall-budget-plan)
|
||||
- [2026-08-06 14:58 UTC] 新增边: plan:issue8-stall-budget-plan --implements--> design:issue8-stall-budget
|
||||
- [2026-08-06 14:58 UTC] 重建索引: 61 篇页面
|
||||
- [2026-08-07 15:20 UTC] 新增 design: 建表前先探测,判死只认「确定写不进去」 (design:issue9-telemetry-ddl-probe)
|
||||
- [2026-08-07 15:20 UTC] 重建索引: 62 篇页面
|
||||
- [2026-08-07 15:11 UTC] 重建索引: 62 篇页面
|
||||
- [2026-08-16 09:09 UTC] 新增 design: HTTP 错误响应体留存(Issue #10) (design:issue10-error-body-retention)
|
||||
- [2026-08-16 09:12 UTC] 重建索引: 64 篇页面
|
||||
- [2026-08-16 09:50 UTC] 新增 plan: 实现计划: HTTP 错误响应体留存(Issue #10) (plan:issue10-error-body-retention-plan)
|
||||
- [2026-08-16 09:50 UTC] 新增边: plan:issue10-error-body-retention-plan --implements--> design:issue10-error-body-retention
|
||||
- [2026-08-16 09:50 UTC] 重建索引: 66 篇页面
|
||||
- [2026-08-17 09:53 UTC] 重建索引: 68 篇页面
|
||||
- [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions)
|
||||
- [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions
|
||||
- [2026-08-17 10:09 UTC] 重建索引: 70 篇页面
|
||||
- [2026-08-19 12:44 UTC] 新增 design: issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 (design:issue13-schema-mode)
|
||||
- [2026-08-19 12:44 UTC] 新增 design: issue #12: 遥测表的正文体量、保留期与访问控制 (design:issue12-telemetry-retention)
|
||||
- [2026-08-19 12:45 UTC] 重建索引: 74 篇页面
|
||||
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue13-schema-mode (plan:plan-issue13-schema-mode)
|
||||
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue13-schema-mode --implements--> design:issue13-schema-mode
|
||||
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue12-telemetry-retention (plan:plan-issue12-telemetry-retention)
|
||||
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue12-telemetry-retention --implements--> design:issue12-telemetry-retention
|
||||
- [2026-08-19 13:10 UTC] 重建索引: 78 篇页面
|
||||
- [2026-08-20 03:29 UTC] 新增 design: 熔断拒绝补齐等待档(issue #14) (design:issue14-admission-wait-policy)
|
||||
- [2026-08-20 03:30 UTC] 新增 plan: 实现计划: 熔断拒绝补齐等待档(issue #14) (plan:issue14-admission-wait-policy)
|
||||
- [2026-08-20 03:30 UTC] 新增边: plan:issue14-admission-wait-policy --implements--> design:issue14-admission-wait-policy
|
||||
- [2026-08-20 03:30 UTC] 重建索引: 82 篇页面
|
||||
- [2026-08-20 03:30 UTC] 重建索引: 80 篇页面
|
||||
- [2026-08-20 05:01 UTC] 新增边: review:issue14-branch-review --informs--> plan:plan-issue14-admission-wait-policy
|
||||
- [2026-08-20 05:01 UTC] 重建索引: 80 篇页面
|
||||
- [2026-08-20 05:01 UTC] 新增 review: 整分支审查: issue #14 熔断等待档 (review:issue14-branch-review)
|
||||
- [2026-08-20 05:01 UTC] 重建索引: 81 篇页面
|
||||
- [2026-08-24 05:49 UTC] 新增 design: issue #15: 遥测连接池的资源语义与生命周期 (design:issue15-telemetry-pool-lifecycle)
|
||||
- [2026-08-24 05:50 UTC] 重建索引: 83 篇页面
|
||||
- [2026-08-24 05:50 UTC] 新增边: design:issue15-telemetry-pool-lifecycle --refines--> design:issue9-telemetry-ddl-probe
|
||||
- [2026-08-24 05:50 UTC] 新增边: design:issue15-telemetry-pool-lifecycle --depends_on--> design:issue12-telemetry-retention
|
||||
- [2026-08-24 05:50 UTC] 重建索引: 83 篇页面
|
||||
- [2026-08-24 10:14 UTC] 重建索引: 83 篇页面
|
||||
- [2026-08-24 10:15 UTC] design:issue15-telemetry-pool-lifecycle 经 Codex 审查: 3 阻断+3 应改+1 可选,核实后 6 采纳 1 改判,正文补 §9 审查留痕
|
||||
- [2026-08-24 10:20 UTC] 最低 Python 提到 3.12(pyproject/ruff/README/CLAUDE.md 四处);design:issue15 §6 版本取舍消解,§7 补两项实施前置
|
||||
- [2026-08-24 10:32 UTC] Python 3.12 迁移执行完毕: 环境重建 3.12.13、补装 build/twine、UP047 三处改 PEP 695;make check 绿、973 passed 覆盖率 94%
|
||||
- [2026-08-24 12:05 UTC] 新增 plan: 实现计划: 遥测连接池的资源语义与生命周期(issue #15) (plan:plan-issue15-telemetry-pool-lifecycle)
|
||||
- [2026-08-24 12:05 UTC] 新增边: plan:plan-issue15-telemetry-pool-lifecycle --implements--> design:issue15-telemetry-pool-lifecycle
|
||||
- [2026-08-24 12:05 UTC] 重建索引: 85 篇页面
|
||||
- [2026-08-24 12:10 UTC] plan:issue15 经 Codex 审: 2 阻断已修(20 处构造点须同批改、application_name 改走 DSN 查询参数)、_failed 计数修正
|
||||
- [2026-08-24 15:48 UTC] issue15 T1-T7 实施完成: 池按需建连(min_size=0)+失败三分与 60s 冷却+TelemetryStatus 快照+所有权纪律统一,1038 passed
|
||||
- [2026-08-24 15:48 UTC] issue15 独立验证 5 问题处置: 日志级别决策收敛到 tracker(fatal=error)并补执法用例、is not None 所有权纪律补 falsy 用例、更正两处过时吞吐数字、acquire 预算措辞对齐代码、登记页状态与行数校正
|
||||
- [2026-08-24 15:49 UTC] 重建索引: 85 篇页面
|
||||
- [2026-08-24 15:51 UTC] 重建索引: 85 篇页面
|
||||
- [2026-08-26 04:49 UTC] 新增边: plan:2026-08-25-thinking-observability-plan --implements--> design:2026-08-25-thinking-observability-design
|
||||
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --supports--> design:2026-08-25-thinking-observability-design
|
||||
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --refines--> design:2026-08-02-thinking-capability-design
|
||||
- [2026-08-26 04:49 UTC] 重建索引: 88 篇页面
|
||||
- [2026-08-26 11:28 UTC] 新增边: plan:2026-08-26-issue18-pg-test-isolation --implements--> design:2026-08-26-issue18-pg-test-isolation
|
||||
- [2026-08-26 11:28 UTC] 重建索引: 91 篇页面
|
||||
- [2026-09-05 04:06 UTC] 新增 design: 推理档位一等化设计(issue #20 及其一般形式) (design:reasoning-effort)
|
||||
- [2026-09-05 04:07 UTC] 新增 plan: 实现计划: 推理档位一等化 (plan:reasoning-effort)
|
||||
- [2026-09-05 04:07 UTC] 新增边: plan:reasoning-effort --implements--> design:reasoning-effort
|
||||
- [2026-09-05 04:07 UTC] 重建索引: 95 篇页面
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
| 项目键(.env.example 实测) | 库对应 | 差异 |
|
||||
|---|---|---|
|
||||
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 同名继承 | 无;`EST_TOKENS` 见 ⚠️ G2 |
|
||||
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 同名继承 | 无;`EST_TOKENS` 键保留不改名,但 2026-07-30 起由必填降为可选(G2 已闭,详见该行) |
|
||||
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY/RPM/TPM`(config.py:249-271) | 全局闸限额 | ARCH §9 未定义 GLOBAL 段命名,M2 设计须定(建议原样继承) |
|
||||
| `{SCOPE}__SELECTOR`(round_robin/least_inflight) | `SourceSelector` 策略选择 | 命名待 M1/M2 定稿,建议继承 |
|
||||
| `{SCOPE}__RETRY__MAX_ATTEMPTS/BACKOFF_BASE_S/BACKOFF_MAX_S` | RetryPolicy | ⚠️ G4:ARCH §9 只列平铺 `LLM_MAX_RETRIES` 等键,无 per-scope 形态 |
|
||||
@@ -148,7 +148,7 @@ stack = ExtractionProviderStack(
|
||||
| Retry-After 仅支持秒数形态(invokers.py:127-141) | HTTP-date 返回 None | **有意放弃** date 形态(ARCH §6.2 同款) |
|
||||
| 429 body 细分 insufficient_quota → SourceDead(invokers.py:144-166) | 欠费≠限流 | **保留**(ARCH §6.1 已承诺) |
|
||||
| 零 content 提前结束 → Transient "early_eof";有 content 缺 [DONE] → 打捞并埋点 "missing_done"(invokers.py:306-313) | 线路级异常定性(D2 的核心价值) | **保留** |
|
||||
| usage 缺失按 est_tokens 估算并标 `estimated`,不静默用 0(invokers.py:241-254) | 保守计量 | **保留**(`usage_source` 已进 ARCH §5.1;依赖 G2) |
|
||||
| usage 缺失按 est_tokens 估算并标 `estimated`,不静默用 0(invokers.py:241-254) | 保守计量 | **有意放弃**(2026-07-30 推翻原"保留"判定,est_tokens 解耦设计 §4)。理由:CHS 只记单个 `total_tokens`,不存在 prompt/completion 分配问题;库拆成两列后无法忠实分配,而 `est_tokens` 按 CHS 自身定义(config.py:55)是**最坏情形上界**——"保守"在限流语境安全(押多了只是慢),在计费语境只有虚高一个方向。库改为如实记 `0/0` + `usage_source="unavailable"` + cost NULL(ARCH §5.1)。**"不静默用 0"的原始意图完整保留**:被放弃的只是"编一个数字"这个手段,缺失依然有显式标注且可被 `WHERE usage_source='unavailable' AND cache_hit = false` 量化 |
|
||||
| reasoning_content 刷新活性但不计入结果;ttft=首个任意 token(invokers.py:55-79, 336-364) | 防 thinking 模型被看门狗误杀 | **替换+增强**:库把 thinking 收进 `LLMResponse.thinking`(不再丢弃);活性语义必须保留(反向约束 M1) |
|
||||
| enable_thinking=True 不注入参数、False 注入关闭参数(invokers.py:230-238) | 与 D11 注册表"声明注入方式"方向相反 | **替换**(provider 注册表须支持"注入关闭参数"形态) |
|
||||
| 图片 magic bytes 探测,非 PNG/JPEG 抛 RequestRejected(invokers.py:116-123) | 本地快速拒绝 | **保留**(移入库 transport) |
|
||||
@@ -181,8 +181,8 @@ stack = ExtractionProviderStack(
|
||||
| R4 | 六道闸+契约 5 条、服务器时钟窗口、settle 落 acquire 窗口、transient 按 est 保守结算 | M2 | §7.3 大体覆盖 |
|
||||
| R5 | RequestRejected 二分(真实响应记成功/本地拒绝释放探针);换源重试跨源计数口径 | M2 | 须进 M2 设计 |
|
||||
| R6 | OCR ZIP 协议 + bbox 数值防御下沉;OCR Usage=0;glm 白名单预留 | M3 | §7.10 已覆盖 |
|
||||
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常 | M2 | 已闭 |
|
||||
| **G2** | ⚠️ `est_tokens`(TPM 预扣常量 + usage 缺失兜底,config.py:55)不在 ARCH §7.7 SourceConfig 字段清单;§7.3 `try_acquire(source, est_tokens)` 的 est 来源未定义 | M2 | **架构缺口**,修订 §7.7 |
|
||||
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常。**2026-08-06 补(issue #7,库 1.1.0)**: 治理后端故障(`GovernanceBackendError`,Redis 挂等 fail-closed 情形)此前**不在**该族内,`except GatewayUnavailableError` 接不住,会落进 `_TERMINAL` 兜底而消耗业务失败预算;现已归入该族(`reason=governance_backend_down`,`retry_after_s` 默认 5.0),tracking.py 一条 except 即覆盖完整,**无需为它单列分支**。同批新增的 `SourceNotConfiguredError`(源名与配置不匹配的装配缺陷)**有意在族外**,应当落进 `_TERMINAL` 让配置错误浮出水面 | M2 | 已闭 |
|
||||
| **G2** | ✅ 已闭(2026-07-30 核实): `est_tokens` 已进 ARCH §7.7 SourceConfig 字段清单,且 §7.3 `try_acquire` 的 est 来源已定义为 `SourceConfig.effective_est_tokens()`(显式值优先,否则按 `tpm // 60` 派生)。双职责一并拆开:该字段只剩 TPM 预扣的可选调优覆盖,usage 缺失不再由它兜底(见 §7 行 151 的推翻判定) | M2 | 已闭 |
|
||||
| **G3** | ⚠️ §4.3 层序图文矛盾:图示 熔断→限流→重试(重试最内),但理由要求"每次重试重新过限流闸"且熔断/限流是 per-source 的、选源在重试循环内(governance.py:120-167 实践为每次尝试执行 选源→冷却备忘→permit→熔断门)。洋葱不澄清"逐次准入"机制则多源语义无法成立 | M2 | **架构缺口**,澄清 §4.3/§4.4 |
|
||||
| **G4** | ⚠️ per-scope 韧性配置命名(`{SCOPE}__RETRY__*`/`BREAKER__*`/`BACKPRESSURE__*`/`SELECTOR`/`GLOBAL__*`)未进 ARCH §9,现文只有平铺 `LLM_*` 键;CHSAnalyzer 的 VLM/OCR 两 scope 参数各异,平铺键无法表达 | M2 | **架构缺口**,修订 §9 |
|
||||
| **G5** | ⚠️ 半开探针租约 TTL(探针持有者死亡后 TTL 过期自动可再探,scripts.py:96-105)与 `release_probe` 操作未见于 ARCH §7.4(只写单探针/epoch fencing);缺失则探针死锁 | M2 | **架构缺口**,修订 §7.4 |
|
||||
|
||||
@@ -130,7 +130,7 @@ loop = AgentLoop(client, max_steps=...,
|
||||
| B10 | 缓存命中也记遥测(cache_hit=True, latency_ms=0)(client.py:309-331);每次 attempt 独立 call_id(client.py:337);thinking 帧 content 优先于 reasoning_content(client.py:79-91) | **保留**(库同款语义) |
|
||||
| B11 | SSE 流提前断开且未见 `[DONE]` 时正常返回:`usage_sink["done"]` 写入后无人检查(client.py:115-117),截断响应被当成功**并写入缓存** | **修复**: 库把"断流无 [DONE]"定性 TransientError(§6.1),且坏结果不进缓存 |
|
||||
| B12 | provider 差异靠字符串猜: `"deepseek" in provider`/`"qwen" in provider` 注入 thinking 参数(client.py:139-144)、`<think>` 剥离(client.py:348) | **替换**: provider 注册表(D11) |
|
||||
| B13 | usage 帧缺失时 prompt/completion_tokens 落 0(client.py:354-355),无标注 | **升级**: 库 `usage_source=measured/estimated` |
|
||||
| B13 | usage 帧缺失时 prompt/completion_tokens 落 0(client.py:354-355),无标注 | **升级**: 库 `usage_source` 三态 `measured/estimated/unavailable`(2026-07-30 est_tokens 解耦后由两态扩为三态)。GovDoc 原行为"落 0 且无标注"中的落 0 反而与库一致,被升级的是**标注**:缺失行记 `unavailable` 且 cost 为 NULL,缺口可被 `WHERE usage_source='unavailable' AND cache_hit = false` 量化 |
|
||||
| B14 | 遥测 schema 无 source_name/cost/usage_source(telemetry_sqlite.py:29-48) | **升级**: 库超集 schema;GovDoc 骨架期无生产遥测数据,直接换新库文件,不做数据迁移 |
|
||||
| B15 | 双层重试: 治理层 max_retries + AgentLoop 步级 step_retries(loop.py:308-356,默认延迟 (20,40)s) | **有意保留**(业务侧任务级重试,ARCHITECTURE §7.2 允许留在库外),但须按 §4 改 retryable_exceptions,否则静默失效 |
|
||||
| B16 | `CancelledError` 穿透重试循环(client.py:396 `except Exception` 天然放行),取消的调用**不记遥测** | **保留**穿透;取消是否记遥测库未定义,见 §9-R6 |
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# est_tokens 解耦实施计划
|
||||
|
||||
- **目标**: 把 `SourceConfig.est_tokens` 的两个职责(TPM 入场预扣 / usage 缺失时的遥测用量兜底)拆开,并解绑 `tpm > 0 ⇒ est_tokens > 0` 装配约束。
|
||||
- **方案概述**: usage 不可得时遥测记 `0/0` 并标新值 `unavailable`、cost 落 NULL(不再拿限流押金编计费数字);`est_tokens` 未填时由库按 `tpm // 60` 派生预扣量,字段降为可选调优覆盖(保留不删不改名)。全部依据已批准设计 `research-wiki/designs/2026-07-30-est-tokens-decoupling-design.md`,其 §3.2 的 11 条改动项已钉死行号。
|
||||
- **涉及技术**: Python 3.11 frozen dataclass、pytest(含 `tests/contracts` 双后端契约测试)、真实 Redis(integration/contracts)、pydantic-settings 不涉及改动。
|
||||
- **溯源**: Gitea issue #2;wiki 实体 `design:est-tokens-decoupling`。
|
||||
|
||||
## 1. 任务排序的硬约束(先读这节再动手)
|
||||
|
||||
三处改动互相牵制,**顺序错了会引入押金泄漏或计费造假**,且中间态不报错、只静默偏差:
|
||||
|
||||
| 若单独先做 | 后果 |
|
||||
|---|---|
|
||||
| 先改 usage 兜底为 `(0, 0)`,结算点还没切派生值 | 成功调用 `actual = 0` 而入场押了 `est_tokens`,`delta` 为负 → **押金整笔退回**,TPM 闸退化成进门即放行 |
|
||||
| 先切入场(`ratelimit.py:26`)+ 解绑约束,而结算点还没切 | `est_tokens=0` 的源入场押派生值、结算退 0 → 同样泄漏。**注意机制**:若**只**解绑约束而 `ratelimit.py` 一行未动,后果不是泄漏而是入场**完全不预扣**(仍传 `est_tokens=0`)——那是设计 §2.2 已否决的"不预扣"退化形态。两者都要避免,故 T4 必须在 T2 之后 |
|
||||
| 先改 `openai_compat.py:176` 而 `_merge` 还是二值逻辑 | embedding 的 `unavailable` 批被 `any(== "estimated")` 判 False 从而**误标 `measured`**,cost 照算 |
|
||||
|
||||
因此排序为:**先加能力(零行为变更)→ 再把所有调用点切到新能力(此时等价,因显式值优先)→ 再让三态生效 → 最后解绑约束**。Task 1-2 完成后行为逐字不变,Task 3 才是行为变更主体,Task 4 才让派生值真正启用。**不要合并或调换 Task 2 / 3 / 4 的顺序。**
|
||||
|
||||
## 2. 文件结构
|
||||
|
||||
| 文件 | 职责 | 涉及任务 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/types.py` | 新增 `SourceConfig.effective_est_tokens()` 派生方法与 `usage_source` 三态值域常量;删除 `_validate_gates` 里的条件必填;修 `EmbeddingTransportResult` 行内注释 | T1、T3、T4 |
|
||||
| `src/polygateway/middleware/ratelimit.py` | `QuotaGate.try_acquire` 入场预扣改用派生值 | T2 |
|
||||
| `src/polygateway/middleware/retry.py` | 成功侧(`:338`)与失败侧(`:370`)结算改用派生值 | T2 |
|
||||
| `src/polygateway/embedding.py` | 同上(`:271`/`:294`);`_merge` 三态合并;`_total_cost` 存在不可得批时整体 NULL | T2、T3 |
|
||||
| `src/polygateway/transports/openai_compat.py` | 两处 usage 兜底改 `unavailable`;打捞覆盖加前置条件 | T3 |
|
||||
| `src/polygateway/middleware/telemetry.py` | cost 短路(插在 `cache_hit` 之后);失败尝试与终态失败改标 `unavailable` | T3 |
|
||||
| `src/polygateway/ocr.py` | **不改**(设计 §3.3 已剔出),仅加防回归测试 | T3 |
|
||||
| `research-wiki/ARCHITECTURE.md` | §7.7 行 428、§5.1 行 331、§4.4 行 305、§7.1 行 384 | T5 |
|
||||
| `research-wiki/migrations/chsanalyzer.md` | 行 151 保留项改判为有意放弃;G2(行 185)标注已解决 | T5 |
|
||||
| `.env.example` | 行 11 删除"TPM > 0 时 EST_TOKENS 必填 > 0" | T5 |
|
||||
| `CHANGELOG.md` | 行为变更小节(值域新增 + cost 口径 + 约束解绑) | T5 |
|
||||
|
||||
测试文件:`tests/unit/test_types.py`、`test_retry.py`、`test_openai_compat.py`、`test_telemetry.py`、`test_embedding.py`、`test_ocr_client.py`、`tests/contracts/test_limiter_contract.py`。
|
||||
|
||||
## 3. 保真校验
|
||||
|
||||
本计划**不新增**任何 `reference/` 移植代码,但触及 ARCHITECTURE §1.4 关键资产索引中的"遥测口径"与"限流结算",且**有意推翻**一条已声明保留的迁移行为(CHS `invokers.py:241-254` 的"usage 缺失按 est 估算",见设计 §4)。故设以下检查点,每个任务完成时逐条确认:
|
||||
|
||||
1. `Permit.settle()` 的"多退少补 + 幂等 flag"语义不得改变;`release()` 的 finally 必然执行不得改变。
|
||||
2. **不得触碰** `backends/redis/limiter.py` 的 Lua 脚本与 `backends/memory/limiter.py` 的窗口/租约算法——本计划只改传入 `try_acquire` 的**数值来源**,不改闸门算法。
|
||||
3. 不得改变 `errors.py` 四分类归属,不得新增运行时异常类型;值域违反不走异常路径(设计 §3.1 已裁决)。
|
||||
4. `ocr.py` 的 `settle` 恒 0 与 `usage_source="measured"` 保持不变。
|
||||
5. 遥测 18 字段冻结不变、无 DDL 变更(两 schema 的 `cost` 列已可空)。
|
||||
|
||||
## 4. 任务清单
|
||||
|
||||
### T1 — 加派生能力与值域常量(零行为变更)
|
||||
|
||||
- [x] **改** `src/polygateway/types.py`
|
||||
|
||||
新增模块级值域常量与 `SourceConfig` 方法。派生按**源自身 tpm**,全局 tpm 不参与(设计 §7 已声明为既有限制、本次不修):
|
||||
|
||||
```python
|
||||
USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
|
||||
"""usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。"""
|
||||
|
||||
_EST_TOKENS_QUOTA_DIVISOR = 60
|
||||
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
|
||||
```
|
||||
|
||||
```python
|
||||
def effective_est_tokens(self) -> int:
|
||||
"""TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。"""
|
||||
if self.est_tokens > 0:
|
||||
return self.est_tokens
|
||||
if self.tpm > 0:
|
||||
return max(1, self.tpm // _EST_TOKENS_QUOTA_DIVISOR)
|
||||
return 0
|
||||
```
|
||||
|
||||
**验收标准**: 方法存在且为纯函数(不读全局、不 await);此任务**不修改任何调用点**,全库行为逐字不变。
|
||||
|
||||
**测试要求**(先失败后通过:方法不存在时 `AttributeError`)——`tests/unit/test_types.py`:
|
||||
- `tpm=6000, est_tokens=0` → `100`;`tpm=600000, est_tokens=0` → `10000`(尺度无关:两者在途上限同为 60)
|
||||
- `tpm=30, est_tokens=0` → `1`(下界不塌到 0)
|
||||
- `tpm=0, est_tokens=0` → `0`(TPM 闸未启用,不预扣)
|
||||
- `tpm=6000, est_tokens=4000` → `4000`(显式值优先于派生)
|
||||
- `USAGE_SOURCES` 恰为三元集合
|
||||
|
||||
**值域封闭的两条实质断言**(设计 §6 要求;缺了它们 `USAGE_SOURCES` 会沦为零消费者的死常量,且 §3.1 的落点裁决无回归保护):
|
||||
- **生产侧封闭**: 参数化覆盖库内全部 `usage_source` 生产点(`_resolve_usage`、`_resolve_embedding_usage`、`_merge`、`TelemetryEmitter.emit_*`),断言产出恒 ∈ `USAGE_SOURCES`。此断言在 T1 阶段即可写(此时产出仅 `measured`/`estimated`),T3 完成后自动覆盖 `unavailable`
|
||||
- **不做运行时校验**: `LLMResponse(usage_source="garbage")` 构造**不抛异常**——锁定设计 §3.1 的裁决(公共 frozen dataclass 不加 `__post_init__` 值域校验,否则裸 `ValueError` 不属四分类、会逃出 `chat()`)。没有这条,后人很容易顺手补上校验而击穿 `chat()`
|
||||
|
||||
**验证**: `conda run --no-capture-output -n PolyGateway pytest tests/unit/test_types.py -v` → 全 PASS
|
||||
|
||||
### T2 — 五个调用点切到派生值(零行为变更)
|
||||
|
||||
此时 `est_tokens > 0` 仍是必填(约束未解绑),故 `effective_est_tokens()` 恒返回显式值,**行为与改前逐字相同**。这一步只是把数值来源换掉,为 T3 铺路。
|
||||
|
||||
- [x] **改** `src/polygateway/middleware/ratelimit.py:26` — `source.est_tokens` → `source.effective_est_tokens()`
|
||||
- [x] **改** `src/polygateway/middleware/retry.py:370`(失败侧,`if not dead` 分支内)→ `source.effective_est_tokens()`
|
||||
- [x] **改** `src/polygateway/embedding.py:294`(失败侧)→ 同上
|
||||
- [x] **改** `src/polygateway/middleware/retry.py:338`(成功侧)— 加不可得分支:
|
||||
|
||||
```python
|
||||
if result.usage_source == "unavailable":
|
||||
actual = source.effective_est_tokens()
|
||||
else:
|
||||
actual = result.prompt_tokens + result.completion_tokens
|
||||
```
|
||||
|
||||
- [x] **改** `src/polygateway/embedding.py:271`(成功侧)— 同构,`actual = result.prompt_tokens` 落在 else 分支。
|
||||
|
||||
`TransportResult.usage_source`(`types.py:75`)与 `EmbeddingTransportResult.usage_source`(`types.py:273`)均为必填字段,在这两处的 `result` 局部变量上直接可读。
|
||||
|
||||
**验收标准**: 五处均不再直接读 `source.est_tokens`;新分支在本任务中**永不触发**(尚无 `unavailable` 生产者),现有测试全绿即证明零行为变更。**不要**在本任务修改 `retry.py:329` 的 `actual = 0` 初值,也不要动 `RequestRejectedError`/`ResultInvalidError`/`SourceDeadError` 三条失败分支(设计 §3.3:它们的 `actual` 停在 0 属既有行为)。
|
||||
|
||||
**测试要求**(回归保护,先失败后通过不适用于零行为变更任务,故以"现有测试不得回归 + 新增等价性断言"为门):
|
||||
- 新增 `tests/unit/test_retry.py`:`tpm=1000, est_tokens=400` 的源在 usage 正常返回时,settle 收到的 `actual` 等于实测 token 之和(锁定 else 分支);现有 `test_settle_uses_actual_usage` 必须继续通过
|
||||
- `tests/contracts/test_limiter_contract.py` 全绿(双后端)
|
||||
|
||||
**验证**:
|
||||
```
|
||||
conda run --no-capture-output -n PolyGateway pytest tests/unit tests/contracts -v
|
||||
```
|
||||
→ 全 PASS。**Redis 契约测试用真实 Redis db3,不得与其他 Redis 测试并跑**(时序隔离)。
|
||||
|
||||
### T3 — 值域三态生效(行为变更主体)
|
||||
|
||||
- [x] **改** `src/polygateway/transports/openai_compat.py:146` — 兜底不再读 `est_tokens`:
|
||||
|
||||
```python
|
||||
return 0, 0, "unavailable"
|
||||
```
|
||||
|
||||
- [x] **改** `src/polygateway/transports/openai_compat.py:176` — embedding 兜底同理 `return 0, "unavailable"`
|
||||
- [x] **改** `src/polygateway/transports/openai_compat.py:336` — 打捞覆盖加前置条件(否则 `0/0` 会被标 `estimated` 而算出假的 `0.0`):
|
||||
|
||||
```python
|
||||
if salvaged and usage_source == "measured":
|
||||
usage_source = "estimated" # 收到 usage 帧但流被截断: 数字真实、可信度降级
|
||||
```
|
||||
|
||||
- [x] **改** `src/polygateway/middleware/telemetry.py:130-135` — cost 短路,**插在 `cache_hit` 分支之后**(缓存命中未产生新调用,`0.0` 是事实):
|
||||
|
||||
```python
|
||||
if cache_hit:
|
||||
cost: float | None = 0.0
|
||||
elif usage_source == "unavailable":
|
||||
cost = None # 用量不可得: 宁可算不出成本,也不算错成本
|
||||
elif error is None and model and self._pricing is not None:
|
||||
cost = self._pricing.cost(model, prompt_tokens, completion_tokens)
|
||||
else:
|
||||
cost = None
|
||||
```
|
||||
|
||||
- [x] **改** `src/polygateway/middleware/telemetry.py:58` 与 `:100` — 失败尝试与终态失败的 `usage_source` 由 `"estimated"` 改 `"unavailable"`(cost 本已是 None,不改金额)
|
||||
- [x] **改** `src/polygateway/embedding.py:383,390` — 二值合并扩三态(优先级:任一不可得 → 整体不可得):
|
||||
|
||||
```python
|
||||
sources = {o.result.usage_source for o in outcomes}
|
||||
if "unavailable" in sources:
|
||||
merged_source = "unavailable"
|
||||
elif "estimated" in sources:
|
||||
merged_source = "estimated"
|
||||
else:
|
||||
merged_source = "measured"
|
||||
```
|
||||
|
||||
- [x] **改** `src/polygateway/embedding.py:397` `_total_cost` — 存在 `unavailable` 批时整体返回 `None`(逐批求和会给出偏低却看似有效的金额)
|
||||
- [x] **改** `src/polygateway/types.py:273` — 行内注释 `# measured | estimated` → 三态(内核里不留矛盾注释)
|
||||
- [x] **改** `src/polygateway/transports/openai_compat.py:142` 与 `:172` — 两个函数的中文 docstring 仍写着"缺失/非法按 `est_tokens` 保守兜底并标 `estimated`",改完不改就留下两句主动陈述旧行为的文档(与 `types.py:273` 同一把尺子)
|
||||
|
||||
**验收标准**: 全库不再有任何位置把 `est_tokens` 写进遥测用量;`ocr.py` 一字未动。
|
||||
|
||||
**测试要求**(先失败后通过):
|
||||
- `test_openai_compat.py`:`est_tokens=4000` + usage 帧缺失 → `(0, 0, "unavailable")`(**改前返回 `(0, 4000, "estimated")`,故先失败**;现有用例 `test_usage_missing_falls_back_to_est` 需改写)
|
||||
- **改写** `tests/unit/test_embedding.py:105 test_missing_usage_falls_back_estimated` — 现断言 `prompt_tokens == 7 and usage_source == "estimated"`(夹具 `est_tokens=7`),改 `openai_compat.py:176` 后必然变红,须改为 `(0, "unavailable")`。这是一条**位于 `test_embedding.py` 里的 transport 级用例**,容易在只盯 `test_openai_compat.py` 时漏掉
|
||||
- `test_openai_compat.py`:打捞 + usage 帧存在 → `estimated` **且 cost 非 None**;打捞 + usage 缺失 → `unavailable` **且 cost 为 None**。cost 配套断言不可省——#4 的真正目的就是防 `0/0` 被算成假的 `0.0`,只断言 `usage_source` 钉不住它
|
||||
- `test_telemetry.py`:成功行 `usage_source="unavailable"` → `record_llm_call` 收到 `cost=None`(改前按 4000×输出单价算出 `0.032`)
|
||||
- `test_telemetry.py`:`cache_hit=True` 且 `unavailable` → cost 仍为 `0.0`(锁定分支次序)
|
||||
- `test_telemetry.py`:失败尝试与终态失败行 `usage_source == "unavailable"`
|
||||
- `test_embedding.py`:混合批 `measured + unavailable` → 整体 `unavailable` 且 `cost is None`(改前误标 `measured`)
|
||||
- `test_ocr_client.py`:OCR 成功行仍为 `measured` 且 settle 恒 0(**防回归**,锁定设计 §3.3 的剔出决定)
|
||||
|
||||
**验证**: `conda run --no-capture-output -n PolyGateway pytest tests/unit -v` → 全 PASS;`conda run --no-capture-output -n PolyGateway pytest tests/contracts -v` → 全 PASS(T2 的结算分支此时首次被激活,契约测试须复跑)
|
||||
|
||||
### T4 — 解绑装配约束(派生值真正启用)
|
||||
|
||||
- [x] **改** `src/polygateway/types.py:125-126` — 删除:
|
||||
|
||||
```python
|
||||
if self.tpm > 0 and self.est_tokens <= 0:
|
||||
raise ValueError("启用 TPM 闸时 est_tokens 必须 > 0(入场预扣依据)")
|
||||
```
|
||||
|
||||
`_validate_gates` 的其余部分(`timeout_s > 0`、四个限额非负)**保留不动**。`est_tokens` 字段本身与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键保留不删不改名(迁移兼容硬约束);`config.py:40` 的键映射无需改动。
|
||||
|
||||
**验收标准**: `tpm=6000, est_tokens=0` 可构造;该源入场预扣 100,**成功侧与非 dead 瞬时失败侧**按 100 结算(delta=0)。**取消 / RequestRejected / ResultInvalid / SourceDead 四侧维持既有的 `actual=0` 全额退回**——`retry.py:355-359` 的取消分支不给 `actual` 赋值、停在 `:329` 初值,这是设计 §3.3 声明不动的既有行为,**不要**为了凑"三侧一致"去改它。
|
||||
|
||||
**测试要求**(先失败后通过:改前构造即抛 `ValueError`):
|
||||
- **改写** `tests/unit/test_types.py:94 test_tpm_requires_est_tokens` — 它现在断言 `_make_source(tpm=10000, est_tokens=0)` 抛 `ValueError`,删约束后必然变红。保留后半条正向断言(`est_tokens=800` 仍原样返回),把前半条改为"构造成功且 `effective_est_tokens()` 返回派生值"
|
||||
- `test_retry.py`:**成功侧**——未填 `est_tokens`、`tpm>0`、usage 帧缺失的成功调用后,TPM 窗口残留量等于派生预扣量而非 0(**这是设计中最易漏的一条**,回归 §3.2 #9;在 `test_retry.py:149` 的 `_src("a", tpm=1000, est_tokens=400)` 旁加 `est_tokens=0` 用例)
|
||||
- `test_retry.py`:**失败侧**——同配置的非 dead 瞬时失败调用后,窗口残留量同为派生预扣量(回归 §3.2 #8)
|
||||
- `tests/contracts/test_limiter_contract.py`:**只加后端级断言**——传入派生值时双后端的结算口径一致。**不要**在契约文件里写端到端用例:该文件直接驱动 limiter(形如 `limiter.try_acquire("s1", 0)`),不经 `QuotaGate`/`RetryMW`,照字面写会产出 `try_acquire(src.effective_est_tokens())` + `settle(同值)` 的退化用例——只测了后端算术,没测调用点是否真的切了派生值。上面两条端到端断言的载体是 `retry.py`,放 `test_retry.py`(内存后端)
|
||||
|
||||
**验证**: `make ci`(即 check + test,含 import-linter 契约)→ 全 PASS。**不要**在外层再套 `conda run`:`Makefile` 的 `check`/`test` 目标内部已各自 `conda run -n $(ENV)`,嵌套后外层的 `--no-capture-output` 也管不到内层缓冲
|
||||
|
||||
### T5 — 权威文档与发布物同步
|
||||
|
||||
- [x] **改** `research-wiki/ARCHITECTURE.md` 四处:§7.7 行 428(`est_tokens` 描述:可选调优覆盖 + 派生规则,删去"亦作 usage 缺失时的保守兜底")、§5.1 行 331(`usage_source` 三态 + cost NULL 口径)、§4.4 行 305("token 按 `est_tokens` 预扣" → 按有效预扣量)、§7.1 行 384(打捞路径强制 `estimated` → 仅在收到 usage 帧时降级)
|
||||
- [x] **改** `research-wiki/migrations/chsanalyzer.md`:行 151 由"保留"改判"**有意放弃**"并写入设计 §4 的理由(CHS 只记单个 `total_tokens` 不存在分配问题;保守在计费语境无安全方向);G2(行 185)标注已由本设计解决
|
||||
- [x] **改** `.env.example` 行 11:删除"TPM > 0 时 EST_TOKENS 必填 > 0",改注为"可选;未填则库按 tpm 派生"
|
||||
- [x] **改** `CHANGELOG.md`:新增"行为收紧/变更"小节三条——`usage_source` 新增 `unavailable`、用量不可得行 cost 由数值变 NULL、`est_tokens` 降为可选
|
||||
- [x] **改** wiki 用户文档站(按 `docs-convention.md` §2):usage/成本口径说明须写明缺口查询为 `WHERE usage_source='unavailable' AND cache_hit = false`(**必须带 `cache_hit` 限定**:缓存命中行按裁决 cost 为 `0.0` 且标 `unavailable`,本无账目缺口,不加限定则度量偏高)
|
||||
- [x] **回帖** Gitea issue #2:结论与下游可删绕行校验的时点
|
||||
|
||||
**验收标准**: 全库 grep `EST_TOKENS 必填`、`est_tokens` 兜底相关表述无残留;ARCHITECTURE.md 无自相矛盾表述。
|
||||
|
||||
**测试要求**: 纯文档,无行为测试。以 `grep` 输出为验收证据。
|
||||
|
||||
**验证**: `make ci` → PASS;`grep -rn "EST_TOKENS 必填" . --exclude-dir=.git` → 无输出
|
||||
|
||||
## 5. 完成判定
|
||||
|
||||
- [x] T1-T5 全部 checkbox 勾选,每个任务一次语义化提交(`commit` skill)
|
||||
- [x] `make ci` 全绿(含 ruff、import-linter 洋葱契约、pytest 覆盖率)
|
||||
- [x] 设计 §6 测试表的 10 行断言全部有对应测试且可出示"先失败后通过"证据(T2 的零行为变更任务以"现有测试不回归 + 等价性断言"替代)
|
||||
- [x] 派新上下文 verifier subagent 独立验证(`verification-before-completion`,里程碑级/跨多文件硬门)
|
||||
- [x] 版本 bump 与 CHANGELOG 同步发布(不得裸发)
|
||||
|
||||
## 6. 明确不做
|
||||
|
||||
派生值取全局与单源 tpm 较紧者(需改三处 `QuotaGate` 装配,修的是既有缺口,设计 §7 已声明另开 issue);遥测驱动的 p90 自适应预估(设计 §5 已否决,待实测证据);`ocr.py` 的 usage 标记(设计 §3.3 已剔出);`retry.py` 另外三条失败分支的 `actual` 初值。
|
||||
@@ -0,0 +1,224 @@
|
||||
# 实现计划: 响应可观测字段扩展(Issue #3)
|
||||
|
||||
- **目标**: 让 `LLMResponse` 与遥测表如实暴露「供应商 prompt cache 命中的输入 token 数」与「API 实际返回的模型版本串」。
|
||||
- **方案概述**: 报文解析留在 `transports/`(新增两个强类型字段随 `TransportResult` 上浮),`RetryMW` 只搬运;遥测端口由 18 字段扩到 20 并给两个后端加幂等补列;`PricingTable` 增加可选缓存单价档消除 cost 高估。缓存命中行按既有口径原样回放。
|
||||
- **依据设计**: `research-wiki/designs/2026-07-31-response-observability-fields-design.md`(2026-07-31 已获人类批准,决策 A2/B1/C1/D1)。
|
||||
- **涉及技术**: Python 3.11 frozen dataclass、httpx SSE 解析、sqlite3、asyncpg、pytest。
|
||||
- **保真校验**: 本计划**不涉及** `reference/` 参考实现迁移,保真校验不适用。但遥测后端属 ARCHITECTURE §1.4 资产,T5 明确约束「不得改变既有降级语义」。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 职责 | 本次改动 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/types.py` | 冻结公共类型 | `LLMResponse` / `TransportResult` 各 +2 字段;`cache_hit` docstring 消歧 |
|
||||
| `src/polygateway/transports/openai_compat.py` | OpenAI 兼容报文解析 | 防御解析 helper;SSE sink 采集 `model`;两处 `TransportResult` 构造填新字段 |
|
||||
| `src/polygateway/middleware/retry.py` | 尝试循环 | `_build_response` 搬运两字段 |
|
||||
| `src/polygateway/middleware/cache.py` | 响应缓存 | **零代码改动**(自动透传),仅补测试固化行为 |
|
||||
| `src/polygateway/pricing.py` | 单价换算 | `ModelPrice` +可选档;`cost()` +可选参;`from_file` 校验 |
|
||||
| `src/polygateway/ports.py` | 端口契约 | `TelemetryRecorder` 18 → 20 字段 |
|
||||
| `src/polygateway/telemetry/{sqlite,postgres}.py` | 遥测后端 | DDL +2 列;`_COLUMNS` +2;初始化期幂等补列 |
|
||||
| `src/polygateway/middleware/telemetry.py` | 遥测唯一调用点 | `_record` 与三个 `emit_*` 搬运两字段;cost 换算传入缓存 token |
|
||||
|
||||
字段定义(全库唯一权威,后续任务一律引用此处):
|
||||
|
||||
```python
|
||||
# LLMResponse 与 TransportResult 尾部,同名同类型同默认值
|
||||
cached_prompt_tokens: int | None = None # 供应商 prompt cache 命中的输入 token;None = 该源未上报
|
||||
model_reported: str | None = None # API 响应体的 model 字段;None = 未上报
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## T1. 类型层加字段
|
||||
|
||||
- [ ] **改**: `src/polygateway/types.py`
|
||||
|
||||
**行为**: 在 `LLMResponse` 尾部(`structured_data` 之后)与 `TransportResult` 尾部(`raw` 之后)各追加上面两个字段。`cache_hit` 的语义在 `LLMResponse` docstring 中写明是「**PolyGateway 自身响应缓存**命中,与供应商 prompt cache 无关,后者见 `cached_prompt_tokens`」。
|
||||
|
||||
**验收**: 前 11 个字段的顺序与名字一字不动;新字段有默认值,`LLMResponse(...)` 按前 11 位置参数构造仍成立;`TransportResult` 现有两处构造(`openai_compat.py:354/436`)不传新字段也能构造。
|
||||
|
||||
**测试**(`tests/unit/test_types.py`): ① 不传新字段时两个类型的新字段均为 `None`;② 按位置构造 `LLMResponse` 的前 11 字段仍可用(迁移兼容承诺)。
|
||||
|
||||
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_types.py -v` → PASS。
|
||||
|
||||
**提交**: `feat: add cached prompt tokens and reported model to response types`
|
||||
|
||||
## T2. transport 采集与防御解析
|
||||
|
||||
- [ ] **改**: `src/polygateway/transports/openai_compat.py`
|
||||
|
||||
**行为**分三处:
|
||||
|
||||
1. 新增两个模块级防御 helper(网关返回一律不可信,解析失败**返回 None,不抛异常**):
|
||||
|
||||
```python
|
||||
def _coerce_cached_tokens(usage: Any) -> int | None:
|
||||
"""从 usage.prompt_tokens_details.cached_tokens 取非负整数;任何形态异常 → None。"""
|
||||
|
||||
def _coerce_model_reported(value: Any) -> str | None:
|
||||
"""响应体 model 字段: 非空 str 才收,其余(含空串/非 str)→ None。"""
|
||||
```
|
||||
|
||||
`_coerce_cached_tokens` 需容忍:`usage` 为 None、`prompt_tokens_details` 缺失或非 dict、`cached_tokens` 为 `bool`/`str`/负数/浮点。`bool` 必须排除(Python 中 `isinstance(True, int)` 为真)。**`0` 必须如实保留而非归 None**——真实零命中与未上报是两回事,这是 issue 的核心诉求。
|
||||
|
||||
2. 流式路径:`_sse_delta`(`:44-47`)当前只把 `usage` 旁路进 sink。补一条——chunk 里出现 `model` 时写 `usage_sink["model"]`(**首次写入即固定**,后续 chunk 不覆盖,避免末帧异常值污染)。`_stream_once` 的 `TransportResult` 构造(`:354`)填 `cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage"))`、`model_reported=_coerce_model_reported(sink.get("model"))`。
|
||||
|
||||
3. 非流式路径:`TransportResult` 构造(`:436`)填 `_coerce_cached_tokens(body.get("usage"))` 与 `_coerce_model_reported(body.get("model"))`。
|
||||
|
||||
**验收**: `raw` 的内容保持原样不动(新字段是独立格子,不是杂物袋的扩充);OCR 与 embedding 的解析路径一行不改。
|
||||
|
||||
**测试**(`tests/unit/test_openai_compat.py`,用现有 fake 响应二次构造):
|
||||
|
||||
| 用例 | 期望 |
|
||||
|---|---|
|
||||
| 非流式 usage 含 `prompt_tokens_details.cached_tokens: 128` | `cached_prompt_tokens == 128` |
|
||||
| 流式 usage 帧同上 | 同上 |
|
||||
| 无 `prompt_tokens_details` / usage 帧缺失 | `None` |
|
||||
| `cached_tokens` 为 `"abc"` / `-1` / `True` / `1.5` / `[]` / dict | `None`,且**不抛异常** |
|
||||
| `cached_tokens` 为 `0` | `0`(真实零命中,**不得**归 None) |
|
||||
| `prompt_tokens_details` 非 dict | `None` |
|
||||
| 非流式 body 含 `model: "MiniMax-Text-01-250321"` | `model_reported` 为该串 |
|
||||
| 流式首个含 model 的 chunk 后又出现不同 model | 取**首个** |
|
||||
| body 无 `model` / `model` 为 `""` | `None` |
|
||||
|
||||
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_openai_compat.py -v` → PASS(新增用例先失败后通过)。
|
||||
|
||||
**提交**: `feat: collect provider cache tokens and reported model in transport`
|
||||
|
||||
## T3. RetryMW 搬运与缓存回放固化
|
||||
|
||||
- [ ] **改**: `src/polygateway/middleware/retry.py`
|
||||
|
||||
**行为**: `_build_response`(`:419-438`)追加 `cached_prompt_tokens=result.cached_prompt_tokens`、`model_reported=result.model_reported`。`model=source.model` **保持不变**——别名仍是主字段,真实版本是旁证(设计非目标 2)。
|
||||
|
||||
`middleware/cache.py` **不改一行**:`_RESPONSE_FIELDS` 由 `dataclasses.fields(LLMResponse)` 动态生成(`:26`)、`_serialize` 用 `asdict`(`:139`),新字段自动进出;决策 B1 要求命中时原样回放,而 `_rehydrate` 的覆写清单(`:113-119`)本就不含新字段,零改动即是正确行为。本任务用测试把它钉死。
|
||||
|
||||
**测试**:
|
||||
|
||||
- `tests/unit/test_retry.py`: transport 返回带两字段的 `TransportResult` → `chat()` 返回的 `LLMResponse` 上两字段一致;transport 未上报时为 `None`。
|
||||
- `tests/unit/test_cache.py`: ① 带两字段的响应写入缓存再命中,回放值与原值相等且 `cache_hit=True`;② **旧格式兼容**——手工构造缺这两个键的缓存 JSON 塞进后端,命中后能正常 rehydrate 且两字段为 `None`(不得抛异常回源)。
|
||||
|
||||
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_retry.py tests/unit/test_cache.py -v` → PASS。
|
||||
|
||||
**提交**: `feat: carry the new observability fields through retry and cache`
|
||||
|
||||
## T4. 缓存读取单价
|
||||
|
||||
- [ ] **改**: `src/polygateway/pricing.py`
|
||||
|
||||
**行为**:
|
||||
|
||||
```python
|
||||
class ModelPrice: # 追加第三档,可选
|
||||
cached_input_per_1m: float | None = None
|
||||
|
||||
def cost(self, model: str, prompt_tokens: int, completion_tokens: int,
|
||||
cached_prompt_tokens: int | None = None) -> float | None:
|
||||
```
|
||||
|
||||
换算规则(设计 §4):配了缓存档**且** `cached_prompt_tokens` 为正 → `(prompt - cached) × input + cached × cached_input`;否则全额按 `input`(现状,逐位不变)。`cached > prompt` 时按 `prompt` 夹取并 `logger.warning` 一次(沿用 `_warned` 的去重思路,按 model 去重,防日志风暴),**绝不产生负成本**。
|
||||
|
||||
`from_file` 的 fail-loud 扩展:条目出现 `cached_input_per_1m` 键时必须可转 float 且非负,否则 `ValueError`;不出现该键 = 合法(旧价格表零改动)。`__post_init__` 同步校验非负。
|
||||
|
||||
顺带订正 `PricingTable` docstring(`pricing.py:36`)那句「cost() 是全库唯一换算点(经 TelemetryEmitter)」——实际有 `TelemetryEmitter`(`middleware/telemetry.py:137`)与 `embedding.py:419` 两个调用点(设计 §6 行为审计已声明)。**只改这一行注释,不做任何结构重构**。
|
||||
|
||||
**验收**: `embedding.py:419` 的三参调用形态**一行不改**仍可用;未配缓存档时,任意输入下 `cost()` 结果与改前逐位相等。
|
||||
|
||||
**测试**(`tests/unit/test_pricing.py`): ① 配缓存档 + 命中 → 成本严格低于全额且等于手算值;② 未配缓存档 + 命中 → 与不传该参数结果相等;③ `cached > prompt` → 结果等于全部按缓存价、非负、有 warning;④ `cached_prompt_tokens=None/0` → 全额;⑤ 三参旧调用签名可用;⑥ 价格表含 `cached_input_per_1m: -1` 或 `"x"` → `from_file` 抛 `ValueError`;⑦ 无该键的旧价格表照常加载。
|
||||
|
||||
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_pricing.py -v` → PASS。
|
||||
|
||||
**提交**: `feat: support a cached input price tier in the pricing table`
|
||||
|
||||
## T5. 端口扩字段与后端补列
|
||||
|
||||
- [ ] **改**: `src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`
|
||||
|
||||
**行为**:
|
||||
|
||||
1. `TelemetryRecorder.record_llm_call`(`ports.py:250-271`)在 `cost` 之后追加 `cached_prompt_tokens: int | None` 与 `model_reported: str | None`,**不设默认值**(设计 §5:库外无第三方实现者)。同步该 Protocol 的「18 字段冻结」docstring。
|
||||
|
||||
2. 两个后端的 `_DDL` 加列(SQLite `INTEGER`/`TEXT`;PG `INTEGER`/`TEXT`,均可空、无默认值)。**新列在 DDL 里必须放在 `created_at` 之后(即表的最末尾),不得插在 `cost` 之后**——旧表走 `ALTER TABLE ADD COLUMN` 只能追加到末尾,若新建库把新列插在 `created_at` 前面,两条路径的物理列序就会分叉,而 `tests/integration/test_postgres_telemetry.py:117-128` 的 `test_schema_has_frozen_columns_in_order` 按 `ordinal_position` 逐位断言,且该表是与真实批跑共享的表、**严禁 DROP/TRUNCATE**(文件头隔离纪律),分叉后没有合规修法。
|
||||
|
||||
`_COLUMNS` 在 `"cost"` 之后追加同名两项即可——`_INSERT` 是显式列名拼装(`sqlite.py:62-65`/`postgres.py:67-71`),`_COLUMNS` 只需与自身的 `row = tuple(...)` 自洽,**与 DDL 物理列序无关**。
|
||||
|
||||
3. **幂等补列**,按设计 D1 纪律执行:
|
||||
|
||||
- **SQLite**(`sqlite.py:74-84`):补列代码必须放在 `self._conn = conn` **之后**、用**独立 try**,且**首行必须守卫 `if self._conn is None: return`**——初始化 try 吞掉失败时 `self._conn` 仍是 `None`(局部 `conn` 甚至未绑定),无守卫的补列块会抛 `AttributeError`/`NameError`,这两者不被 `sqlite3.Error` 捕获,会直接逃出 `__init__`,打破「初始化失败静默降级」的对外契约(既有测试 `tests/unit/test_telemetry.py:132-135` `test_unwritable_path_degrades_silently` 会红)。守卫之后:`PRAGMA table_info(llm_calls)` 取现有列名集合,缺哪列补哪列;捕获 `sqlite3.Error` 时消息含 `duplicate column` 视为成功(多进程共库的 TOCTOU),其余记 warning。**绝不允许**因补列失败把 `self._conn` 置回 `None`——那会让整个 recorder 永久 no-op。
|
||||
- **Postgres**(`postgres.py:_ensure_ready` 内、`_DDL` 执行之后):两条 `ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS ...`,共享既有 `_init_lock` 与 `except asyncio.CancelledError: raise` 结构。
|
||||
|
||||
**验收(降级语义不得改变)**: SQLite 侧 `except` 不得加宽(取消天然穿透);PG 侧 `CancelledError` 分支保持在最前;写入失败仍是逐行 warning 丢弃,不冒泡。
|
||||
|
||||
**必须同步改的测试(共 5 处)**:
|
||||
|
||||
| 位置 | 内容 | 漏改会怎样 |
|
||||
|---|---|---|
|
||||
| `tests/unit/test_telemetry.py:76` 起 `_record_minimal` | 手写 18 键 dict | **红**(`KeyError`) |
|
||||
| `tests/integration/test_postgres_telemetry.py:81-105` `_record_minimal` | 同上 | **红** |
|
||||
| `tests/unit/test_telemetry.py:18-40` `_EXPECTED_COLUMNS` | 19 项列序断言(含 `created_at`) | **红**;新列追加到 `created_at` **之后** |
|
||||
| `tests/integration/test_postgres_telemetry.py:22-41` `_EXPECTED_COLUMNS` | 同上 | **红**;同上 |
|
||||
| `tests/unit/test_ports.py:96` `_DummyRecorder` | 唯一写全签名的 fake | **不会红**(它只被 `:131` 的 `isinstance` 使用,`runtime_checkable` Protocol 只校验方法名不校验签名),但仍应同步以免误导后来者 |
|
||||
|
||||
前四处是本次仅有的天然拦截点;端口加参数**不会**带来编译期保护(本仓无 mypy,其余 8 个 fake 全是 `**fields`)。
|
||||
|
||||
**测试**:
|
||||
|
||||
- `tests/unit/test_telemetry.py`(SQLite):① 20 字段写入后可读回两个新列的值(含 `None`);② **旧表升级**——先用 18 列 DDL 手工建表,再实例化 `SQLiteRecorder`,写入成功且新列有值;③ **补列失败路径**(设计 §8 第 ③ 条,最危险的分支,不可用成功路径顶替)——构造一个 ALTER 必然失败的场景(把 `llm_calls` 建成同名 view,或注入在 ALTER 上抛 `sqlite3.OperationalError` 的连接),断言构造**不抛异常**、`recorder._conn` 仍非 `None`、后续 `record_llm_call` 不抛(降级为逐行 warning);④ 初始化路径不可写时仍静默降级(`test_unwritable_path_degrades_silently` 保持绿)。
|
||||
- `tests/integration/test_postgres_telemetry.py`:① 20 字段写入 PG 并 `SELECT` 回读;② 18 列旧表经初始化后自动补列并写入成功。
|
||||
|
||||
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_telemetry.py tests/unit/test_ports.py -v` → PASS;PG 部分 `conda run -n PolyGateway --no-capture-output pytest tests/integration/test_postgres_telemetry.py -v` → PASS。**PG/Redis 属共享后端,严禁与其他会话或钩子测试并跑**,起跑前确认无并发占用。
|
||||
|
||||
**提交**: **与 T6 合并为一次提交**,不得单独落地。理由:T5 落地后 emitter 仍只传 18 键,后端的 `row = tuple(fields[col] for col in _COLUMNS)` 会抛 `KeyError`,被 `_record` 的 `except Exception`(`middleware/telemetry.py:164-165`)吞成 warning → **该 commit 处于全量遥测静默丢失的状态**,且现有测试无一能捕获。提交信息见 T6。
|
||||
|
||||
## T6. Emitter 搬运与契约测试(与 T5 同一次提交)
|
||||
|
||||
- [ ] **改**: `src/polygateway/middleware/telemetry.py`
|
||||
|
||||
**行为**: `_record`(`:108-125`)新增两个参数并透传给 `record_llm_call`;三个入口各自提供取值——
|
||||
|
||||
| 入口 | `cached_prompt_tokens` | `model_reported` |
|
||||
|---|---|---|
|
||||
| `emit_attempt` | `response.cached_prompt_tokens if response else None` | 同左 |
|
||||
| `emit_cache_hit` | `response.cached_prompt_tokens`(B1 原样回放) | 同左 |
|
||||
| `emit_terminal_failure` | `None` | `None` |
|
||||
|
||||
cost 换算(`:137`)改为把 `cached_prompt_tokens` 传进 `self._pricing.cost(...)`。`cache_hit → 0.0` 与 `usage_source == "unavailable" → None` 两条短路的**先后顺序一字不动**(ARCHITECTURE §5.1 cost 口径不变式)。
|
||||
|
||||
**测试**(`tests/unit/test_telemetry.py`):
|
||||
|
||||
- **契约测试(不可省)**: 用记录 kwargs 的 fake recorder 跑一次 `emit_attempt`,断言 `set(kwargs) == set(sqlite._COLUMNS) == set(postgres._COLUMNS)`。理由:`row = tuple(fields[col] for col in _COLUMNS)` 位于两个后端 try 之外(`sqlite.py:90`/`postgres.py:121`),emitter 漏传字段会抛 `KeyError` 并被 `_record` 的 `except Exception` 吞成 warning → 静默丢遥测;现有 8 个 `**fields` 形态的 fake 一个都拦不住。
|
||||
- 三个入口各记一行,断言新字段取值符合上表。
|
||||
- cost 回归:配了缓存档且响应带 `cached_prompt_tokens` → 落库 cost 低于全额;缓存命中行 cost 仍为 `0.0`;`unavailable` 行仍为 `None`。
|
||||
|
||||
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_telemetry.py -v` → PASS;随后 `make ci` 全绿(含 ruff 与 import-linter)。
|
||||
|
||||
**提交**(含 T5 全部改动): `feat: record the observability fields end to end through telemetry`
|
||||
|
||||
## T7. 文档同步与发版
|
||||
|
||||
- [ ] **改**: `research-wiki/ARCHITECTURE.md`、`CHANGELOG.md`、`.env.example`、`pyproject.toml`、`src/polygateway/__init__.py`、四处「18 字段冻结」措辞点、Gitea Wiki 站
|
||||
|
||||
**行为**:
|
||||
|
||||
1. `ARCHITECTURE.md`:§5.1 新增字段表补两行;**§7.8「必录字段」的行内清单**(`:452`)补两项——该文件不含字面「18 字段冻结」,§7.8 与 D8(`:202`)才是遥测字段的落点;§7.8 末条「`pricing.py` 维护 model →(input 单价, output 单价)表」同步第三档。补一条度量口径警示(与 cost 缺口同款):**统计供应商缓存命中率必须带 `WHERE cache_hit = false`**,否则缓存回放行会被重复计入。
|
||||
2. 代码里的「18 字段/18 列」措辞共 **6 处**,全部订正(`grep -rn "18 字段\|18 列" src/ tests/` 可复核):`ports.py:248`、`middleware/telemetry.py:31`、`pricing.py:6`、`telemetry/sqlite.py:87`、`telemetry/postgres.py:9`(「18 列 schema 与 SQLite 版同名同序」)、`tests/unit/test_telemetry.py:1`。
|
||||
3. `.env.example:56` 是仓内**唯一**的价格表格式说明(无独立模板文件),补 `cached_input_per_1m` 可选档与「不填即全额计价、库不猜折扣率」的说明。
|
||||
4. 版本 bump `1.0.3` → `1.1.0`,**两处必须同步**(`pyproject.toml:7` 与 `src/polygateway/__init__.py:34`;`tests/unit/test_package.py:11` 会断言二者相等)。
|
||||
5. `CHANGELOG.md` 顶部新增 `## 1.1.0` 段,沿用既有写法(先讲问题、再讲变更、点明下游要读什么):两个新字段的语义与 `None`/`0` 之别、`cache_hit` 与供应商 prompt cache 的区分、遥测表新增两列与自动补列、价格表可选缓存档、度量口径的 `cache_hit = false` 约束。
|
||||
6. Gitea Wiki 站(需单独 `git clone https://gitea.iomgaa.online/iomgaa/PolyGateway.wiki.git`)按 `docs-convention.md` §2 清单同步:`参考-公共API`(LLMResponse 字段表)、`参考-配置键`(价格表格式)、`指南-遥测与成本`(新列与成本校正口径)、`Home.md` 版本号与安装命令、`_Sidebar.md` 如有结构变化。
|
||||
|
||||
**验收**: 版本 bump 的提交**不允许单独存在**(docs-convention §2 门),必须与 wiki/CHANGELOG 同步在同一次交付内。
|
||||
|
||||
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_package.py -v` → PASS;`make ci` 全绿。
|
||||
|
||||
**提交**: `chore: release 1.1.0 with the response observability fields`
|
||||
|
||||
---
|
||||
|
||||
## 合并前门(逐条对应 CLAUDE.md §3)
|
||||
|
||||
- [ ] 每个行为变更都有「先失败后通过」的测试证据(T1-T6 各自的新增用例)。
|
||||
- [ ] `make ci` 全绿(ruff + import-linter + pytest + 覆盖率)。
|
||||
- [ ] 派**全新上下文**的 verifier subagent 独立验证(跨多文件,`verification-before-completion` 强制档)。
|
||||
- [ ] 合并前整分支代码审查(`requesting-code-review`)。
|
||||
- [ ] Gitea Issue #3 的关闭说明:两个字段的最终名字与语义、缓存命中行的回放口径、遥测新列与补列行为。
|
||||
@@ -0,0 +1,396 @@
|
||||
# 实现计划: 采样参数透传(issue #4)
|
||||
|
||||
- **设计**: `research-wiki/designs/2026-07-31-sampling-params-design.md`(2026-07-31 人类批准)
|
||||
- **分支**: `feat/issue-4-sampling-params`
|
||||
- **目标**: 让下游能固定解码参数(`temperature`/`seed`/`max_tokens`),且不破坏缓存隔离与遥测诚实性。
|
||||
- **方案概述**: `chat()` 增 keyword-only `overlay` 参数(调用级),`SourceConfig` 增 `extra_body` 字段(配置级)。`ChatRequest` 增 `sampling` 快照字段作为跨洋葱层恒定读取点,供缓存 key 与遥测消费。遥测端口 20 → 21 字段。
|
||||
- **技术**: Python 3.11+,frozen dataclass,`MappingProxyType`,sqlite3 / asyncpg DDL 幂等补列。
|
||||
|
||||
**保真校验**: 本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文件结构
|
||||
|
||||
| 文件 | 职责变更 |
|
||||
|---|---|
|
||||
| `src/polygateway/types.py` | 新增 `validate_request_overlay()` 与 `merge_sampling()` 两个纯函数;`ChatRequest.sampling` 字段;`SourceConfig.extra_body` 字段与构造期校验 |
|
||||
| `src/polygateway/client.py` | `chat()` 增 `overlay` 参数;`model_fingerprint` 计算纳入 `extra_body` |
|
||||
| `src/polygateway/middleware/cache.py` | `build_cache_key()` 增 `sampling` 入参并纳入 key |
|
||||
| `src/polygateway/transports/openai_compat.py` | `_build_payload` 在 thinking profile 之后、overlay 之前应用 `source.extra_body` |
|
||||
| `src/polygateway/config.py` | `_SOURCE_FIELDS` 增 `EXTRA_BODY`;`_cast` 增 `json` 分支 |
|
||||
| `src/polygateway/ports.py` | `TelemetryRecorder.record_llm_call` 增第 21 参 `sampling` |
|
||||
| `src/polygateway/middleware/telemetry.py` | 三个 emit 入口按设计表格产出 `sampling`;`_record` 透传 |
|
||||
| `src/polygateway/telemetry/sqlite.py` | DDL / `_BACKFILL_COLUMNS` / `_COLUMNS` 增 `sampling` |
|
||||
| `src/polygateway/telemetry/postgres.py` | DDL / `_BACKFILL` / `_COLUMNS` 增 `sampling` |
|
||||
| `src/polygateway/ocr.py` / `embedding.py` | 构造期剥离 `extra_body` + warning(决策 G) |
|
||||
| `src/polygateway/providers.py` | minimax/openai 空 thinking profile 补后果注释(决策 F) |
|
||||
| `.env.example` / `README.md` / `CHANGELOG.md` / `research-wiki/ARCHITECTURE.md` | 文档同步(设计 §6) |
|
||||
|
||||
**各任务需新增的 import**(现状核实,不加即 NameError):
|
||||
|
||||
| 文件 | 需新增 |
|
||||
|---|---|
|
||||
| `types.py` | `from collections.abc import Mapping`、`from types import MappingProxyType`、`import json`。**该文件无 `from __future__ import annotations`**,注解在类体求值,`Mapping` 必须真导入 |
|
||||
| `client.py` | `import json`、`import hashlib` |
|
||||
| `config.py` | `import json` |
|
||||
| `ocr.py` / `embedding.py` | `import dataclasses`(现只有 `from dataclasses import dataclass`)、`from loguru import logger`(若未导入) |
|
||||
| `middleware/telemetry.py` | `merge_sampling`/`canonical_sampling_json` 需**运行时**导入(现对 `polygateway.types` 只在 `TYPE_CHECKING` 下导入) |
|
||||
|
||||
**关键接口**(跨任务消费,此处定死):
|
||||
|
||||
```python
|
||||
# types.py —— 两个纯函数 + 两个字段
|
||||
_PROTECTED_OVERLAY_KEYS = frozenset({"model", "messages", "stream", "stream_options"})
|
||||
|
||||
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
|
||||
"""校验采样参数覆盖层并返回浅拷贝;origin 用于错误信息定位来源。
|
||||
|
||||
保护键会击穿治理(model→成本算错、messages→缓存与遥测口径失真、
|
||||
stream/stream_options→绕过看门狗与 usage 帧);值必须 JSON 可序列化,
|
||||
否则会在 CacheMW 的降级 try 之外抛裸 TypeError(设计 §决策 B)。
|
||||
"""
|
||||
|
||||
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""合并配置级与调用级采样参数(调用级优先);两者皆空返回空 dict。"""
|
||||
|
||||
def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
|
||||
"""遥测列与缓存 key 共用的序列化口径;空 mapping → None。"""
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatRequest:
|
||||
...
|
||||
overlay: dict[str, Any] = field(default_factory=dict)
|
||||
sampling: Mapping[str, Any] = field(default_factory=dict) # 新增
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceConfig:
|
||||
...
|
||||
extra_body: Mapping[str, Any] = field(default_factory=dict) # 新增,__post_init__ 转 MappingProxyType
|
||||
```
|
||||
|
||||
```python
|
||||
# middleware/cache.py —— 签名扩展(sampling 为 keyword-only)
|
||||
# 默认值用 None 而非 {}: dict 字面量作默认参数会被 ruff B006 拦下
|
||||
def build_cache_key(
|
||||
model_fingerprint: str,
|
||||
messages: list[dict[str, Any]],
|
||||
namespace: str,
|
||||
salt: str | None,
|
||||
*,
|
||||
sampling: Mapping[str, Any] | None = None,
|
||||
) -> str: ...
|
||||
```
|
||||
|
||||
```python
|
||||
# client.py —— chat() 新签名
|
||||
async def chat(
|
||||
self, messages: list[dict[str, Any]], *,
|
||||
session_id: str | None = None, parent_call_id: str | None = None,
|
||||
cache_salt: str | None = None, cache_namespace: str | None = None,
|
||||
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||
stream: bool = True,
|
||||
overlay: Mapping[str, Any] | None = None, # 新增
|
||||
) -> LLMResponse: ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 任务清单
|
||||
|
||||
任务按依赖排序;每个任务一次提交、独立可验证。每个任务合并前必须出示**先失败后通过**的测试证据(先写测试跑红,再实现跑绿)。
|
||||
|
||||
统一验证命令前缀:`conda run -n PolyGateway --no-capture-output pytest`。
|
||||
|
||||
> **共享后端纪律**: 涉及 Redis/Postgres 的 integration 测试严禁与其他会话并跑(含 git 钩子触发的测试)。Task 7、Task 11 受此约束。
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 1: `types.py` 内核 —— 校验与合并纯函数 + 两个新字段
|
||||
|
||||
**文件**: 改 `src/polygateway/types.py`;测试 `tests/unit/test_types.py`
|
||||
|
||||
**实现行为**:
|
||||
|
||||
1. `validate_request_overlay(overlay, *, origin)`,**校验顺序即下列顺序**:
|
||||
- 键必须是 `str`,否则 `ValueError`(canonical JSON 要求)。**必须排在序列化试探之前**——`{1: "a", "b": 2}` 在 `sort_keys=True` 下抛的是 `TypeError: '<' not supported between 'str' and 'int'`,若先试序列化会被误报成"值不可 JSON 序列化",指错方向;
|
||||
- 命中 `_PROTECTED_OVERLAY_KEYS` 任一键 → `ValueError`,信息含 origin、违规键名、以及**为什么**(如 `stream` 会绕过流式看门狗);
|
||||
- 对整个 mapping 做 `json.dumps(..., sort_keys=True)` 试序列化,`TypeError` → 转 `ValueError` 并指出该值不可 JSON 序列化(信息提示改用 `float(x)` 等原生类型);
|
||||
- 返回 `dict(overlay)` 浅拷贝。
|
||||
2. `merge_sampling(extra_body, sampling)` → `{**extra_body, **sampling}`(调用级优先)。
|
||||
3. `canonical_sampling_json(merged)` → 空则 `None`,否则 `json.dumps(merged, sort_keys=True, ensure_ascii=False)`。
|
||||
4. `ChatRequest` 增 `sampling` 字段(见 §1 关键接口)。
|
||||
5. `SourceConfig` 增 `extra_body` 字段;`__post_init__` 新增 `_validate_extra_body()`:调 `validate_request_overlay(self.extra_body, origin=f"SourceConfig({self.name}).extra_body")`,再 `object.__setattr__(self, "extra_body", MappingProxyType(dict(...)))`(frozen dataclass 需用 `object.__setattr__`)。
|
||||
|
||||
**已知后果(必须显式接受,不是疏漏)**: `SourceConfig` 加 mapping 字段后**不再 hashable**(`hash()` → `TypeError`),且因 `MappingProxyType` 不可 pickle,`dataclasses.asdict()` / `copy.deepcopy()` 也会失败。
|
||||
|
||||
- 不可 hash 是**加任何 mapping 字段的固有代价**,与是否用 `MappingProxyType` 无关(裸 `dict` 同样不可 hash),无法规避;
|
||||
- 库内当前无调用点会踩:`asdict` 只用于 `LLMResponse`/`EmbeddingResponse`(`cache.py:140`),全库无 `set(sources)` 或以源作 dict key 的写法;
|
||||
- 保留 `MappingProxyType` 而非裸 dict,是因为决策 E 的只读约束值得这个代价;下游要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace(source, ...)`(已验证可行,会重跑 `__post_init__` 重新包 proxy,不递归)。
|
||||
|
||||
**验收标准**: 四个保护键各自触发 `ValueError` 且信息含原因;非 str 键报的是"键必须是 str"而非"不可序列化";`{"temperature": object()}` 类不可序列化值报 `ValueError` 而非 `TypeError`;合法 `{"temperature": 0, "seed": 42}` 通过并返回独立副本(改原 dict 不影响返回值);`SourceConfig.extra_body` 构造后为 `MappingProxyType` 且不可改。
|
||||
|
||||
**测试要求**: 新增 `tests/unit/test_types.py::TestSamplingValidation`,覆盖上述每条。不可序列化值用 `object()` 实例即可,不引入 numpy 依赖。**另加一条锁定测试**:`pytest.raises(TypeError): hash(source_config)`,把"不再 hashable"钉成有意行为——否则将来有人踩到时会以为是 bug 并"修"回去。
|
||||
|
||||
**验证**: `pytest tests/unit/test_types.py -v` → 全 PASS
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 2: `config.py` —— `EXTRA_BODY` env 解析
|
||||
|
||||
**文件**: 改 `src/polygateway/config.py`;测试 `tests/unit/test_config.py`
|
||||
|
||||
**实现行为**:
|
||||
- `_SOURCE_FIELDS` 增 `"EXTRA_BODY": ("extra_body", "json")`;
|
||||
- `_cast` 增 `json` 分支:`json.loads` 失败 → `ValueError`(沿用既有 `配置 {key} 解析失败: {exc}` 包装);解析结果**非 dict** → `ValueError`,信息说明必须是 JSON 对象(而非数组/标量)。
|
||||
|
||||
**验收标准**: `LLM__QWEN__1__EXTRA_BODY={"temperature":0}` → `SourceConfig.extra_body == {"temperature": 0}`;`{invalid` → `ValueError`;`[1,2]` → `ValueError`;`{"model":"x"}` → `ValueError`(经 Task 1 的 `SourceConfig.__post_init__` 保护键校验)。
|
||||
|
||||
**测试要求**: 新增 4 个 case 覆盖上述。**注意**: 这里同时验证了 Task 1 的校验确实挂在装配路径上。
|
||||
|
||||
**验证**: `pytest tests/unit/test_config.py -v` → 全 PASS
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 3: `chat()` 入口 + transport 应用 + fingerprint
|
||||
|
||||
**文件**: 改 `src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_client.py`、`tests/unit/test_openai_compat.py`
|
||||
|
||||
**实现行为**:
|
||||
|
||||
1. `chat()` 增 `overlay` 参数(见 §1 签名)。进洋葱**之前**:
|
||||
```python
|
||||
validated = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
|
||||
```
|
||||
同一份 `validated` 对象同时填 `ChatRequest.overlay` 与 `.sampling`(设计决策 E:一次拷贝、两个字段指向同一快照,不做两份独立拷贝)。
|
||||
2. `_build_payload`:在 thinking profile 之后、`payload.update(overlay)` 之前插入 `payload.update(source.extra_body)`。**顺序即优先级,不可调换**。
|
||||
3. `model_fingerprint`(`client.py:117`)改为:
|
||||
```python
|
||||
fingerprint = ",".join(sorted({s.model for s in sources}))
|
||||
marks = sorted({json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False)
|
||||
for s in sources if s.extra_body})
|
||||
if marks:
|
||||
fingerprint += "|" + hashlib.sha256("".join(marks).encode()).hexdigest()
|
||||
```
|
||||
全源 `extra_body` 皆空时字面量与旧实现**逐字相同**。`dict(...)` 是因为 `MappingProxyType` 不能直接进 `json.dumps`。
|
||||
|
||||
**验收标准**: 配置 `temperature=0` + 调用级 `temperature=1` → payload 中为 1;结构化注入的 `response_format` 覆盖调用级同名键;保护键在 `chat()` 入口即 `ValueError`(未进洋葱,可用 mock handler 断言未被调用);全源无 `extra_body` 时 fingerprint 与旧值逐字相同;有 `extra_body` 时不同;改源 `name` 不改变 fingerprint。
|
||||
|
||||
**测试要求**: 覆盖设计 §5 测试 #3、#4(chat 侧)、#5、#6(拷贝语义:调用方在 `chat()` 返回后修改自己的 dict,不影响已构造的 request)、#8(不可 JSON 序列化的值在 `chat()` 入口即 `ValueError`,断言洋葱 handler 未被调用)。
|
||||
|
||||
**验证**: `pytest tests/unit/test_client.py tests/unit/test_openai_compat.py -v` → 全 PASS
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 4: 缓存 key 纳入 `sampling`
|
||||
|
||||
**文件**: 改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py`
|
||||
|
||||
**实现行为**:
|
||||
- `build_cache_key` 增 keyword-only `sampling` 参数(见 §1 签名),非空时以 `"sampling"` 键并入 `key_obj`(**仅非空参与**,与 `salt` 的"仅非 None"不同——见设计决策 A 末段);
|
||||
- `CacheMW.__call__` 传 `sampling=request.sampling`(**不是 `request.overlay`**——后者在此层虽尚未被结构化注入污染,但读 `sampling` 才是语义正确且不依赖层序巧合的写法)。
|
||||
|
||||
**验收标准**:
|
||||
- 同 messages、不同 `seed` → 两个不同 key,第二次 miss(**issue 场景的直接回归**);
|
||||
- 空 `sampling` 时 key 与旧实现**逐字相同**——测试须先把旧实现的 key 值固化为常量再比对(现有 `tests/unit/test_cache.py:39-54` 只有相等/不等断言,无 golden hash 可依);
|
||||
- 同 `sampling` 不同键序 → 同一 key(canonical 序列化)。
|
||||
|
||||
**测试要求**: 覆盖设计 §5 测试 #1、#2。golden hash 的取法:在改动前先运行一次现有 `build_cache_key` 打印结果,写死进测试。
|
||||
|
||||
**验证**: `pytest tests/unit/test_cache.py -v` → 全 PASS
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 5: 地基不变式回归(承重)
|
||||
|
||||
**文件**: 测试 `tests/unit/test_structured.py`(或就近的洋葱集成测试文件)
|
||||
|
||||
**实现行为**: 纯测试任务,不改产品代码。
|
||||
|
||||
落点:`tests/unit/test_structured.py` 里既有的 `ScriptedTerminal` 恰好站在 RetryMW 的位置(`client.py:91` 的 `terminal = RetryMW(...)`,StructuredMW 是最内中间件),扩写它即可,**无需搭全洋葱**。
|
||||
|
||||
断言:走结构化重问阶梯(强制至少重问一次,用先返回坏 JSON 再返回好 JSON 的 scripted terminal)后——
|
||||
1. terminal 每次收到的 `request.sampling` 与**构造 `ChatRequest` 时传入的 `sampling`** 逐字相同;
|
||||
2. 同一时刻 `request.overlay` **含** `response_format`(证明两者确实分叉,`sampling` 不是冗余字段)。
|
||||
|
||||
**为什么单列一个任务**: 决策 C 与 D 都建立在"`sampling` 跨层恒定"之上,而这条目前只靠"`dataclasses.replace` 恰好保留未提及字段"的约定成立,无任何机械执法。这条测试同时钉死决策 A 的"库内中间件永不修改"与决策 E 的只读约束。缺它则约束被破坏时无人发现。
|
||||
|
||||
**验收标准**: 该测试在故意把 `structured.py` 的 `replace` 改成重建 `ChatRequest`(丢掉 `sampling`)时**必须变红**——实施时须实际验证这一点,否则测试是空的。
|
||||
|
||||
**验证**: `pytest tests/unit/test_structured.py -v` → 全 PASS,且上述"故意破坏"实验红过一次
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 6: 遥测端口扩至 21 字段 + 三入口口径
|
||||
|
||||
**文件**: 改 `src/polygateway/ports.py`、`src/polygateway/middleware/telemetry.py`;测试 `tests/unit/test_telemetry.py`
|
||||
|
||||
**实现行为**:
|
||||
|
||||
1. `ports.TelemetryRecorder.record_llm_call` 增第 21 参 `sampling: str | None`(排在 `model_reported` 之后)。
|
||||
2. `TelemetryEmitter._record` 增同名参数并透传给 recorder。
|
||||
3. 三个入口按设计决策 D 的表格产出(**不含**结构化注入的 `response_format`):
|
||||
|
||||
| 入口 | `sampling` 取值 |
|
||||
|---|---|
|
||||
| `emit_attempt` | `canonical_sampling_json(merge_sampling(source.extra_body, request.sampling))` |
|
||||
| `emit_cache_hit` | `canonical_sampling_json(request.sampling)` |
|
||||
| `emit_terminal_failure` | `canonical_sampling_json(request.sampling)` |
|
||||
|
||||
后两者无 `source` 可言(由最外层 TelemetryMW 调用),与 `model`/`provider`/`source_name` 在终态行置空是同一先例。
|
||||
|
||||
**关键约束**: `sampling` 必须由 emitter **内部推导**,**不得**作为新必填参数由调用者传入——否则 `ocr.py:418` 与 `embedding.py:372` 立刻 TypeError。
|
||||
|
||||
**验收标准**: 三个入口各自的 `sampling` 值符合上表;`response_format` **三行都不出现**;`request.sampling` 与 `source.extra_body` 皆空时为 `None`。
|
||||
|
||||
**测试要求**: 覆盖设计 §5 测试 #9。用 fake recorder 捕获 kwargs 断言。
|
||||
|
||||
**验证**: `pytest tests/unit/test_telemetry.py -v` → 全 PASS
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 7: 两个遥测后端落列 + 幂等补列
|
||||
|
||||
**文件**: 改 `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py`
|
||||
|
||||
**实现行为**(逐字沿用 issue #3 建立的套路):
|
||||
|
||||
- **sqlite.py**: DDL 在 `model_reported` **之后**加 `sampling TEXT`;`_BACKFILL_COLUMNS` 追加 `("sampling", "TEXT")`;`_COLUMNS` 末尾追加 `"sampling"`。
|
||||
- **postgres.py**: DDL 同位置加 `sampling TEXT`;`_BACKFILL` 追加 `("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT")`;`_COLUMNS` 末尾追加。
|
||||
- 两处 `record_llm_call(**fields)` 按 `_COLUMNS` 取值,**无需改动**。
|
||||
|
||||
**硬约束**: 新列必须排在 `created_at` **之后**(两文件既有注释已说明理由:旧表只能 ALTER 追加到末尾,新建库若插在前面,两条路径物理列序分叉)。补列一律**先探测缺列再 ALTER**;失败只逐行降级,**绝不置结构性失能标志**(postgres 的 `_failed`)。
|
||||
|
||||
**连带必改**(不改则直接红):
|
||||
|
||||
| 位置 | 改什么 | 不改的后果 |
|
||||
|---|---|---|
|
||||
| `tests/unit/test_telemetry.py:78-102` 的 `_record_minimal()` | `fields` dict 加 `"sampling": None` | **两侧所有落库测试全红**:`sqlite.py:126` / `postgres.py:161` 的 `row = tuple(fields[col] for col in _COLUMNS)` **在 try 之外**,`_COLUMNS` 加列后抛裸 `KeyError: 'sampling'` 冒泡出 `record_llm_call` |
|
||||
| `tests/integration/test_postgres_telemetry.py:88-109` 的 `_record_minimal()` | 同上 | 同上 |
|
||||
| `tests/unit/test_telemetry.py:18` 的 `_EXPECTED_COLUMNS` | 追加 `"sampling"` | 列序断言红 |
|
||||
| `tests/integration/test_postgres_telemetry.py:22` 的 `_EXPECTED_COLUMNS` | 追加(另见 `:210,231` 引用点) | 列序断言红 |
|
||||
| `tests/unit/test_ports.py:95-119` 的 `_DummyRecorder.record_llm_call` | 显式 20 参签名同步为 21 | **不会红**(`runtime_checkable` 的 isinstance 只查方法存在不查签名),但会与端口脱节,顺带同步 |
|
||||
| `sqlite.py:123` docstring、`test_telemetry.py:1` 文案 | "20 字段" → "21 字段" | 无功能影响,文案与事实脱节 |
|
||||
|
||||
**验收标准**: 新建库列序正确;对**已存在的 20 列旧表**能幂等补列且补后列序与新建库一致;重复初始化不报错;补列失败(模拟只有 INSERT 权限)时仅 warning、后续写入不被禁用。
|
||||
|
||||
**测试要求**: 覆盖设计 §5 测试 #11、#12。Postgres 部分是 integration,**须独占 PG `polygateway` 库时序,严禁并跑**。
|
||||
|
||||
**验证**:
|
||||
- `pytest tests/unit/test_telemetry.py -v` → 全 PASS
|
||||
- `pytest tests/integration/test_postgres_telemetry.py -v` → 全 PASS(确认无其他会话在用 PG)
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 8: 决策 G —— OCR/embedding 构造期剥离 + warning
|
||||
|
||||
**文件**: 改 `src/polygateway/ocr.py`、`src/polygateway/embedding.py`;测试 `tests/unit/test_ocr_client.py`、`tests/unit/test_embedding.py`
|
||||
|
||||
**实现行为**: 两个 `__init__` 在既有校验块(`quota_full` 域校验附近)之后、`self._sources = list(sources)` 之前:
|
||||
|
||||
```python
|
||||
stripped = []
|
||||
for src in sources:
|
||||
if src.extra_body:
|
||||
logger.warning(
|
||||
"{} 路径暂不支持 extra_body,源 {} 的该配置已被忽略"
|
||||
"(需要 dimensions 等参数请提 issue): {}",
|
||||
<"embedding"|"OCR">, src.name, dict(src.extra_body),
|
||||
)
|
||||
src = dataclasses.replace(src, extra_body={})
|
||||
stripped.append(src)
|
||||
self._sources = stripped
|
||||
```
|
||||
|
||||
**剥离不是顺手清理,是承重的**: 不剥离则 Task 6 的 `merge_sampling(source.extra_body, ...)` 会让遥测**记录一个从未发出的参数**——`monkey_ocr.py:225,247` 只发 multipart `files=`(根本没有 JSON body),`openai_compat.py:343` 的 embed payload 硬编码 `{"model","input"}`。那是数据造假而非参数失效。替代方案(emitter 内特判调用方身份)违「遥测调用点收敛单一 helper」铁律,已否决。
|
||||
|
||||
**验收标准**: 带 `extra_body` 的源 → 装配**成功**(不抛异常)、记一条 warning、`client._sources` 上 `extra_body` 为空;该路径遥测 `sampling` 列为 `None`;不带 `extra_body` 时无 warning。
|
||||
|
||||
**测试要求**: 覆盖设计 §5 测试 #10。**后半段(遥测 `sampling` 为 None)是防遥测造假的真正断言,不可省**——只断言"装配成功 + 有 warning"是不够的。用 `caplog`/loguru 捕获断言 warning 存在。
|
||||
|
||||
**验证**: `pytest tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → 全 PASS
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 9: 决策 F —— 空 thinking profile 的后果注释
|
||||
|
||||
**文件**: 改 `src/polygateway/providers.py`
|
||||
|
||||
**实现行为**: 给 `openai`(`:46-51`)与 `minimax`(`:53-58`)两个 profile 各补一句**后果**说明:`enable_thinking=False` 对本 provider 不产生任何效果,需要关闭推理请用 `SourceConfig.extra_body`。
|
||||
|
||||
**注意**: `:52` 那条既有注释(「OpenAI 兼容基线,无已知注入差异」)在词法上属于紧随其后的 **minimax** 条目,`openai` 条目**没有**任何注释。补的是"后果"而非重复"为何为空"——不要写出与既有注释重复或矛盾的内容。
|
||||
|
||||
**验收标准**: 两个 profile 都能让读者明白 `enable_thinking=False` 对它们无效。纯注释变更,无行为变化。
|
||||
|
||||
**测试要求**: 无(纯注释)。此任务不单独提交,与 Task 10 合并提交。
|
||||
|
||||
**验证**: `make lint` → PASS
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 10: 文档同步(设计 §6 清单)
|
||||
|
||||
**文件**: 改 `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`
|
||||
|
||||
| 目标 | 具体改动 |
|
||||
|---|---|
|
||||
| `.env.example` | 在 `LLM__QWEN__1__TRUST_ENV` 注释行(`:20`)后加 `# LLM__QWEN__1__EXTRA_BODY={"temperature":0}` 及说明(JSON 对象串;保护键会报错;OCR/EMBED scope 会被忽略并 warning)。`client.py:251` docstring 声明本文件是键名清单事实源,漏写等于新键无处可查 |
|
||||
| `README.md:83` | 该行逐一列举 `chat()` 关键字参数,补 `overlay` 及一句用途 |
|
||||
| ARCH §5.2 | `chat()` 签名定稿段追加 `overlay` 要点(带默认值的 keyword-only,不破坏"调用点零改动"承诺) |
|
||||
| ARCH §7.5 | key 公式补 `sampling` 项 + 两条已知副作用(seed 进 key 导致该路径必 miss;`model_fingerprint` 是集合级指纹,同 scope 各源 `extra_body` 不同时仍可能跨源命中) |
|
||||
| ARCH §7.7(`:451`) | 该节逐字段枚举 `SourceConfig` 构成(`name/provider/.../enable_thinking`),补 `extra_body` |
|
||||
| ARCH §7.8(`:463`) | 必录字段 20 → 21,补 `sampling` 及其列语义(不含 `response_format`) |
|
||||
| ARCH §9(`:519-527`) | 配置面键族事实源,登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY` |
|
||||
| `CHANGELOG.md` | 公共 API 新增(`chat(overlay=)`、`SourceConfig.extra_body`)+ 遥测端口扩列 |
|
||||
|
||||
**Gitea Wiki 同步**(`docs-convention.md` §2,CLAUDE.md §6 标为硬门)。本次同时命中该表两行:
|
||||
|
||||
| 命中行 | 必同步页 |
|
||||
|---|---|
|
||||
| 新公共 API / 新能力 | 对应指南页(新增「固定解码参数」内容,落在 `指南-遥测与成本` 或新页)+ `参考-公共API`(`chat()` 签名、`SourceConfig.extra_body`)+ `_Sidebar.md` + CHANGELOG |
|
||||
| 新增配置键 | `参考-配置键`(登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`)+ 相关指南页的配置片段 + `.env.example` |
|
||||
|
||||
指南页必须写明三条坑:① `seed` 逐次变化时该路径缓存**必 miss**;② `model_fingerprint` 是集合级指纹,同 scope 各源 `extra_body` 不同时仍可能跨源命中(要逐源可复现需每源独享 scope 或 namespace);③ OCR/EMBED scope 的 `EXTRA_BODY` 会被忽略并 warning。
|
||||
|
||||
**验收标准**: 每条都能在文件中指到具体位置;ARCH 的改动与设计文档不矛盾;wiki 两行清单逐页落实。
|
||||
|
||||
**测试要求**: 无(纯文档)。与 Task 9 合并提交。
|
||||
|
||||
**验证**: `make lint` → PASS
|
||||
|
||||
---
|
||||
|
||||
### - [ ] Task 11: 全链路集成验证与合并前检查
|
||||
|
||||
**文件**: 测试 `tests/integration/`(就近文件或新增)
|
||||
|
||||
**实现行为**: 端到端断言采样参数经 `chat()` → 选源 → transport payload 到达请求体(设计 §5 测试 #13),用 fake HTTP 层捕获实际 payload。
|
||||
|
||||
**合并前门(逐条出示证据)**:
|
||||
1. `make ci` → 全绿(`make lint` + `make test` + 覆盖率)
|
||||
2. import-linter 契约无新违规(校验函数落最内层 `types.py`,分层关系不变)
|
||||
3. 设计 §5 的 14 条测试全部有对应实现,逐条对应到具体测试函数名
|
||||
4. 派**全新上下文** verifier subagent 独立验证(CLAUDE.md §3.2 里程碑级/跨多文件硬门)
|
||||
|
||||
**验证**:
|
||||
- `make ci` → 全 PASS(**不要**在外面套 `conda run`:`Makefile` 每条 target 内部已是 `conda run -n PolyGateway ...`,嵌套会让内层输出被缓冲)
|
||||
- verifier 报告无 blocking 问题
|
||||
|
||||
---
|
||||
|
||||
## 3. 提交节奏
|
||||
|
||||
| 提交 | 内容 |
|
||||
|---|---|
|
||||
| 1 | Task 1(types 内核) |
|
||||
| 2 | Task 2(env 解析) |
|
||||
| 3 | Task 3(chat 入口 + transport + fingerprint) |
|
||||
| 4 | Task 4(缓存 key) |
|
||||
| 5 | Task 5(地基不变式测试) |
|
||||
| 6 | Task 6(遥测三入口) |
|
||||
| 7 | Task 7(两后端落列) |
|
||||
| 8 | Task 8(决策 G) |
|
||||
| 9 | Task 9 + 10(注释与文档) |
|
||||
| 10 | Task 11(集成验证,如有修补) |
|
||||
|
||||
每次提交调 `commit` skill。Task 1-4 是 issue 诉求的最小闭环;Task 5-8 是设计中"issue 未提但必须处理"的部分,**不可跳过**。
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:2026-08-02-thinking-capability
|
||||
title: "推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)"
|
||||
date: 2026-08-02
|
||||
---
|
||||
|
||||
# 推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)
|
||||
|
||||
**目标**:让 `enable_thinking` 对每个源要么真实生效、要么显式报错,并采集 `reasoning_tokens` 以区分推理开销与生成开销。
|
||||
|
||||
**方案概述**:`ProviderProfile` 保留为「形态层」(参数长什么样,按 provider),新增 model 级「能力层」声明该模型能否关闭推理;两层在单一判定函数 `resolve_thinking` 相遇,装配期与请求期共用。同时照搬 issue #3 的 `_coerce_cached_tokens` 采集 `reasoning_tokens`,并把 `enable_thinking` 纳入缓存指纹。
|
||||
|
||||
**涉及技术**:Python 3.11 frozen dataclass、`MappingProxyType` 只读注册表、httpx、SQLite/PostgreSQL DDL 迁移、pytest。
|
||||
|
||||
**依据文档**:设计 `designs/2026-08-02-thinking-capability-design.md`;事实基础 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`。
|
||||
|
||||
**保真校验**:本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/types.py` | 修改 | `LLMResponse` / `TransportResult` 尾部各加 `reasoning_tokens` |
|
||||
| `src/polygateway/providers.py` | 修改 | 形态层放宽为 `dict \| None`;新增能力层与 `resolve_thinking` |
|
||||
| `src/polygateway/transports/openai_compat.py` | 修改 | 采集 `reasoning_tokens`;`_build_payload` 接入 `resolve_thinking`;收 `capabilities` |
|
||||
| `src/polygateway/middleware/retry.py` | 修改 | `_build_response` 透传 `reasoning_tokens` |
|
||||
| `src/polygateway/ports.py` | 修改 | `record_llm_call` 21 → 22 字段 |
|
||||
| `src/polygateway/telemetry/sqlite.py` | 修改 | 建表列 + `_BACKFILL_COLUMNS` + `_COLUMNS`(新列排末尾) |
|
||||
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上 |
|
||||
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` + 三个 `emit_*` 入口 |
|
||||
| `src/polygateway/client.py` | 修改 | `capabilities` 参数贯通;装配守卫;缓存指纹纳入 `enable_thinking` |
|
||||
| `tests/e2e/test_thinking_live.py` | 新建 | 真实 API 矩阵 L1–L9 |
|
||||
| `CHANGELOG.md` / `research-wiki/schemas/llm-calls.md` | 修改 | 行为变更说明与字段表 21 → 22 |
|
||||
|
||||
**任务顺序不可调换**:T1–T3 先把 `reasoning_tokens` 打通(#6 是 #5 的验收仪器),T4–T7 再改推理开关,T8 用真实 API 验证,T9 收尾文档。
|
||||
|
||||
## 关键接口(跨任务消费,此处给出实际代码)
|
||||
|
||||
`providers.py` 新增:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingCapability:
|
||||
"""某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
|
||||
|
||||
can_disable: bool
|
||||
evidence: str
|
||||
|
||||
|
||||
def get_capability(
|
||||
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
||||
) -> ThinkingCapability | None:
|
||||
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定退化)。"""
|
||||
```
|
||||
|
||||
```python
|
||||
def register_capability(
|
||||
model: str,
|
||||
capability: ThinkingCapability,
|
||||
*,
|
||||
base: Mapping[str, ThinkingCapability] | None = None,
|
||||
) -> dict[str, ThinkingCapability]:
|
||||
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
||||
|
||||
|
||||
def resolve_thinking(
|
||||
profile: ProviderProfile,
|
||||
capability: ThinkingCapability | None,
|
||||
enable_thinking: bool | None,
|
||||
*,
|
||||
model: str,
|
||||
) -> Mapping[str, Any]:
|
||||
"""三态 + 两层能力 → 注入片段;不可满足时 ValueError(调用点翻译为领域错误)。
|
||||
|
||||
model 只用于错误与告警文案: 报错必须能定位到具体模型才有可操作性,
|
||||
而 capability 为 None(未登记)时无从从别处取得模型名。
|
||||
"""
|
||||
```
|
||||
|
||||
`resolve_thinking` 的判定顺序(**顺序即语义,不可调换**):
|
||||
|
||||
| 步 | 条件 | 行为 |
|
||||
|---|---|---|
|
||||
| 1 | `enable_thinking is None` | 返回 `{}`(不干预) |
|
||||
| 2 | 对应档 `slot is None` | `ValueError`:形态未知,指路 `register_provider` / `extra_body` |
|
||||
| 3 | `capability is None` | `loguru.warning` 后返回 `slot`(能力未登记,从宽放行) |
|
||||
| 4 | `enable_thinking is False` 且 `capability.can_disable is False` | `ValueError`:该模型无法关闭推理 |
|
||||
| 5 | 其余 | 返回 `slot` |
|
||||
|
||||
第 2 步必须先于第 4 步:形态未知时无从注入,能力如何无关紧要。第 3 步先于第 4 步:未登记模型无 `can_disable` 可读。
|
||||
|
||||
`transports/openai_compat.py` 新增:
|
||||
|
||||
```python
|
||||
def _coerce_reasoning_tokens(usage: Any) -> int | None:
|
||||
"""取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。"""
|
||||
```
|
||||
|
||||
## 任务清单
|
||||
|
||||
### T1 — `reasoning_tokens` 进入类型与采集路径
|
||||
|
||||
- [ ] **文件**:改 `src/polygateway/types.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/middleware/retry.py`;改测 `tests/unit/test_types.py`、`tests/unit/test_openai_compat.py`、`tests/unit/test_retry.py`
|
||||
|
||||
**行为**:`LLMResponse` 与 `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`(字段顺序是公共承诺,见 `types.py:1-5`,只增不删不改名)。新增 `_coerce_reasoning_tokens`,语义与 `_coerce_cached_tokens`(`openai_compat.py:161-177`)逐条对齐:非 `dict` 返回 `None`;`completion_tokens_details` 非 `dict` 返回 `None`;`bool` 显式排除(`isinstance(True, int)` 为真,放行会把 `True` 记成 1);负数返回 `None`;`0` 如实保留。流式(`:401` 附近)取 `sink.get("usage")`、非流式(`:485` 附近)取 `body.get("usage")`,与 `cached_prompt_tokens` 同处填值。`retry.py:_build_response` 透传。
|
||||
|
||||
**docstring 措辞**(必须逐字,理由见 findings §4c):`None` = **本次调用**未上报,**不可**写「该源未上报」——中转在上游不返回 usage 时会本地补算并吃掉该字段。
|
||||
|
||||
**验收**:非流式与流式响应含 `completion_tokens_details.reasoning_tokens: 7` → `reasoning_tokens == 7`;该键为 `0` → `0`(不与 `None` 混同);`completion_tokens_details` 缺失 / 非 dict / 值为 `True` / 值为 `-1` → 均为 `None`;`missing_done="salvage"` 打捞路径(无 usage 帧)→ `None` 而非 `0`。
|
||||
|
||||
**测试证据**:先加断言 → 失败(字段不存在)→ 实现 → 通过。
|
||||
|
||||
**验证**:`conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v` → 全部 PASS。
|
||||
|
||||
### T2 — 遥测端口 21 → 22 字段与两后端落库
|
||||
|
||||
- [ ] **文件**:改 `src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`;改测 `tests/unit/test_ports.py`、`tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py`
|
||||
|
||||
**行为**:`record_llm_call` 在 `sampling` 之后追加 `reasoning_tokens: int | None`(不设默认值——库外无第三方实现者,见 `ports.py:250` 注释)。两个后端在建表 DDL、`_BACKFILL_COLUMNS`(sqlite)/ 迁移语句列表(postgres)、`_COLUMNS` 三处各加一项,**新列必须排在末尾**(两文件均有明文注释:旧表只能 ALTER 追加,新建库若插在前面会与迁移路径的物理列序分叉)。`middleware/telemetry.py` 的 `_record` 加参数,三个 `emit_*` 入口按 `cached_prompt_tokens` 的既有形态填值:`emit_attempt` 用 `response.reasoning_tokens if response else None`,`emit_cache_hit` 原样回放,`emit_terminal_failure` 填 `None`。
|
||||
|
||||
**不改 `pricing.py`**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。
|
||||
|
||||
**验收**:新建库与经 ALTER 迁移的旧库物理列序一致;`reasoning_tokens=7` / `0` / `None` 三种值各自如实落库(`0` 与 `NULL` 可区分);遥测写失败仍降级为 warning 不冒泡。
|
||||
|
||||
**测试证据**:先扩字段清单断言 → 失败 → 实现 → 通过。
|
||||
|
||||
**验证**:`conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS;`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS 或按既有约定 SKIP(无 PG 凭据时)。
|
||||
|
||||
### T3 — 提交点:issue #6 完整可用
|
||||
|
||||
- [ ] 运行 `conda run -n PolyGateway make ci`,确认全绿后提交。提交信息类型 `feat`,正文说明 `LLMResponse` 新增字段与遥测端口 21 → 22。此处独立成一个提交,便于 #6 单独回滚。
|
||||
|
||||
**测试证据**:本任务不引入新行为,证据即 T1 与 T2 各自的「先失败后通过」记录;提交前需确认这两组记录都已产生,不得以 `make ci` 全绿代替。
|
||||
|
||||
### T4 — 形态层放宽与 profile 修正
|
||||
|
||||
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
|
||||
|
||||
**行为**:`ProviderProfile.thinking_on` / `thinking_off` 类型由 `dict[str, Any]` 改为 `Mapping[str, Any] | None`。三值语义写进类 docstring:`{...}` = 已知注入片段;`{}` = 已知无需注入即处于该档;`None` = **未知**(库不知道该 provider 如何表达)。删除现有 docstring 里「两档皆空 ⇒ 不产生任何效果」那段(`providers.py:23-25` 与 `:50-51`)——它正是把「不支持」与「未知」编码成同一个值的根因。
|
||||
|
||||
`minimax` 填 `thinking_on={"reasoning_effort": "medium"}`、`thinking_off={"reasoning_effort": "none"}`;`openai` 两档改 `None`。qwen / deepseek **不动**(实测正确)。两处均加注释写明:取值依据 2026-08-02 经自建 new-api 中转的实测,直连官方端点未验证。
|
||||
|
||||
**验收**:`get_provider("minimax").thinking_off == {"reasoning_effort": "none"}`;`get_provider("openai").thinking_on is None`;qwen / deepseek 两档与改动前逐字相同。
|
||||
|
||||
**测试证据**:`tests/unit/test_providers.py:29,34` 现有断言锁的是空字典,先改成新期望 → 失败 → 实现 → 通过。
|
||||
|
||||
**验证**:`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
|
||||
|
||||
### T5 — 能力层与 `resolve_thinking`
|
||||
|
||||
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
|
||||
|
||||
**行为**:按「关键接口」一节的签名实现 `ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`。注册表用 `MappingProxyType` 只读,注册走纯函数返回新表(不修改共享状态,纯 asyncio 中立铁律),与既有 `register_provider`(`providers.py:84-90`)同形。
|
||||
|
||||
`DEFAULT_CAPABILITIES` 首发五条,`evidence` 逐条写明实测日期与样本量:
|
||||
|
||||
| 键 | `can_disable` | `evidence` 要点 |
|
||||
|---|---|---|
|
||||
| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10,`reasoning_effort=none` 稳定关闭 |
|
||||
| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter 登记 `mandatory:true` |
|
||||
| `MiniMax-M2.5` | `False` | 同上 |
|
||||
| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 |
|
||||
| `deepseek-v4-pro` | `True` | 实测 `thinking:{"type":"disabled"}` 关闭 |
|
||||
|
||||
**验收**:`resolve_thinking` 五条判定各有一例;未登记模型返回 `slot` 并产生一条 warning(**loguru 不经标准 logging,pytest 的 `caplog` 抓不到**——必须复用项目既有写法 `logger.add(messages.append, level="WARNING")`,见 `tests/unit/test_config.py:29-31`);`enable_thinking=False` + `MiniMax-M2.7` 抛 `ValueError` 且消息含模型名与"无法关闭"字样;`enable_thinking` 任意非 `None` + `openai` profile 抛 `ValueError` 且消息含 `register_provider` 与 `extra_body` 两个指路词;`register_capability` 不修改 `DEFAULT_CAPABILITIES`。
|
||||
|
||||
**测试证据**:先写五条判定的参数化测试 → 失败(函数不存在)→ 实现 → 通过。
|
||||
|
||||
**验证**:`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
|
||||
|
||||
### T6 — transport 接入与请求期兜底
|
||||
|
||||
- [ ] **文件**:改 `src/polygateway/transports/openai_compat.py`;改测 `tests/unit/test_openai_compat.py`
|
||||
|
||||
**行为**:`OpenAICompatTransport.__init__` 增加 `capabilities: Mapping[str, ThinkingCapability] | None = None`,与既有 `registry` 参数同形并存于 `self._capabilities`。`_build_payload` 的两个 `if` 分支(`:293-296`)收敛为两行——先 `capability = get_capability(source.model, table=self._capabilities)`,再 `payload.update(resolve_thinking(profile, capability, source.enable_thinking, model=source.model))`;其后 `payload.update(source.extra_body)` 与 `payload.update(overlay)` 两行**顺序不变**(顺序即优先级,issue #4 决策 A)。`complete()` 中把构造 payload 的 `ValueError` 翻译为 `RequestRejectedError`(四分类之一,不重试不换源)。
|
||||
|
||||
**绝不在 `_build_payload` 内抛裸 `ValueError` 让它冒泡**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`。
|
||||
|
||||
**验收**:`enable_thinking=True` + minimax 源 → 请求体含 `reasoning_effort: "medium"`;`False` → `"none"`;`None` → 请求体无 `reasoning_effort` 键;`extra_body={"reasoning_effort":"high"}` 时实发 `high`(覆盖 profile);`enable_thinking=False` + M2.7 源经 transport 调用 → `RequestRejectedError` 而非裸 `ValueError`。
|
||||
|
||||
**测试证据**:扩 `tests/unit/test_openai_compat.py:418-431` 的三态参数化,加 minimax 用例 → 失败 → 实现 → 通过。
|
||||
|
||||
**验证**:`conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → PASS。
|
||||
|
||||
### T7 — 装配守卫、参数贯通与缓存指纹
|
||||
|
||||
- [ ] **文件**:改 `src/polygateway/client.py`;改测 `tests/unit/test_cache.py`(指纹相关)、新增装配守卫测试至 `tests/unit/test_config.py`
|
||||
|
||||
**行为**(三件事,同一文件):
|
||||
|
||||
其一,`from_settings` 与 `from_env` 各增加 `capabilities` 参数并透传给 `OpenAICompatTransport`;`from_settings` 在已解析 `profiles` 之后(`client.py:248`)加装配守卫:对 `zip(sources, profiles, strict=True)` 的每一对,先 `get_capability(src.model, table=capabilities)` 取能力,再调用一次 `resolve_thinking(prof, cap, src.enable_thinking, model=src.model)` 并丢弃返回值——只为让配置错误在装配期即抛 `ValueError`。守卫与 transport 内的判定共用同一函数,不复制逻辑——这与 `get_provider` 在 `client.py:248` 与 `openai_compat.py:313` 双点调用的既有形态一致。
|
||||
|
||||
其二,`build_model_fingerprint`(`client.py:63-80`)把 `enable_thinking` 纳入摘要。实现必须保持既有不变量——**全源不配 `enable_thinking` 时指纹字面量与改动前逐字相同**:
|
||||
|
||||
```python
|
||||
def _fingerprint_mark(s: SourceConfig) -> str:
|
||||
parts: list[Any] = [s.model, dict(s.extra_body)]
|
||||
if s.enable_thinking is not None: # 仅在表态时追加,保证存量指纹字面量不变
|
||||
parts.append(s.enable_thinking)
|
||||
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
|
||||
```
|
||||
|
||||
筛选条件由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`。
|
||||
|
||||
其三,为守卫补测:`enable_thinking=False` + `provider=minimax` + `model=MiniMax-M2.7` 的 `GatewaySettings` 经 `from_settings` → `ValueError`;`provider=openai` + 任意非 `None` 的 `enable_thinking` → `ValueError`。
|
||||
|
||||
**验收**:装配期报错两例;改 `enable_thinking` → 指纹变化;只配 `extra_body`、不配 `enable_thinking` 的源 → 指纹与改动前逐字相同(用硬编码的历史字面量断言,防回归)。
|
||||
|
||||
**测试证据**:先写三条断言 → 失败 → 实现 → 通过。
|
||||
|
||||
**验证**:`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_config.py -v` → PASS;随后 `conda run -n PolyGateway make ci` → 全绿。
|
||||
|
||||
### T8 — 真实 API e2e 矩阵
|
||||
|
||||
- [ ] **文件**:新建 `tests/e2e/test_thinking_live.py`
|
||||
|
||||
**行为**:沿用既有 e2e 约定(`tests/e2e/test_smoke_gateway.py:19-26`)——`dotenv_values(".env")` 读凭据、`skipif(not _HAS_SOURCE, ...)`、结构化报告写入 `tests/outputs/e2e/`。**不新造开关机制**:另加项目既有的 `slow` 标记,靠 `pyproject.toml` 的 `addopts = "-m 'not slow'"` 把本组挡在 `make ci` 之外(137 次真实调用、约 7 分钟,且判据是统计性的,网络抖动会造成假红——执行期实测撞到过一次 `network_error` 耗尽源)。合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。
|
||||
|
||||
**源映射**:L1–L5、L8 的 MiniMax 行用现有的 `LLM__MINIMAX__1__*`(`MODEL=MiniMax-M3`);M2.7 / M2.5 行经 `dataclasses.replace(source, model=...)` 派生,不新增 `.env` 键。**L6 / L7 目前无对应源**——`.env` 里只有 MINIMAX 与 MONKEY 两类;需新增 `{SCOPE}__QWEN__1__*` 与 `{SCOPE}__DEEPSEEK__1__*`(同一中转 `BASE_URL` 与密钥,仅 `MODEL` 不同)。未配置时按既有 `skipif` 约定跳过,并在报告中记为「未覆盖」,**不得静默计入通过**。
|
||||
|
||||
覆盖矩阵(轮数经环境变量可调,默认值如下):
|
||||
|
||||
| # | 场景 | 源 | 轮数 | 判据 |
|
||||
|---|---|---|---|---|
|
||||
| L1 | `enable_thinking=False` | MiniMax-M3 | 10 | **每轮** `completion_tokens < 30`(主判据)且 `reasoning_tokens in (None, 0)`(辅判据,与下游口径一致) |
|
||||
| L2 | `enable_thinking=True` | MiniMax-M3 | 10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` |
|
||||
| L3 | `enable_thinking=None` | MiniMax-M3 | 10 | 请求体无 `reasoning_effort` 键 |
|
||||
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | 5 | 实发 `high` |
|
||||
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 10 | 同 L1 / L2(`stream=True` 是库的默认主路径) |
|
||||
| L6 | `enable_thinking=False` | qwen | 10 | 每轮 `completion_tokens < 30` |
|
||||
| L7 | `enable_thinking=False` | deepseek | 10 | 每轮 `completion_tokens < 30` |
|
||||
| L8 | 能力表漂移哨兵 | 全部登记模型 | 各 5 | 实测行为与 `can_disable` 声明一致 |
|
||||
| L9 | M2.7 + `enable_thinking=False` → 装配期报错 | — | — | 纯本地,无需真实调用 |
|
||||
|
||||
**三条必须遵守的测试纪律**:
|
||||
|
||||
其一,**判别量只能是 `reasoning_tokens`**。(执行时按 e2e 实测修正:本条初稿写的是「主判据用 `completion_tokens`」,被数据推翻——两档的输出长度分布**重叠**,关闭档实测最高 46、开启档最低 13,按长度阈值判两个方向都会误判。)`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路(findings §4c、§2.5)。
|
||||
|
||||
其二,**关闭方向要求每轮满足,开启方向只要求多数轮满足**。中转吃掉 ctd 时开启方向可能偶尔观测不到,关闭方向不受影响。
|
||||
|
||||
其四,**必须有不依赖输出侧噪声的锚点**:L2b 比较两档的 `prompt_tokens`(相对比较,无魔数),L3b 用非法值反证 `none` 是被识别而非被静默丢弃——后者正是 issue #5 的原始故障形态,不排除它,关闭方向的证据就只到「未回归」,够不到「已生效」。
|
||||
|
||||
其三,**源不可用必须跳过并在报告中显式记为「未覆盖」**,不得静默计入通过(实测中 kimi 渠道 429 后被中转下线并返回 404)。报告要能一眼看出哪些矩阵行没跑到。
|
||||
|
||||
**报告内容**(`tests/outputs/e2e/test_thinking_live_<ts>.md`):逐轮记录实际注入的 thinking 片段、`prompt_tokens` / `completion_tokens` / `reasoning_tokens`、单轮判定结果;逐行记录矩阵编号、通过或跳过及其原因;文末给出总调用次数与时间戳。原始数字必须落盘——结论可以复核,才算证据。
|
||||
|
||||
**验收**:矩阵九行全部有结论(通过 / 明确跳过),报告落盘 `tests/outputs/e2e/`。
|
||||
|
||||
**验证**:`conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -v -s` → PASS,人工核对报告。
|
||||
|
||||
### T9 — 文档同步与收尾
|
||||
|
||||
- [ ] **文件**:改 `CHANGELOG.md`、`research-wiki/schemas/llm-calls.md`
|
||||
|
||||
**行为**:CHANGELOG 必须醒目标注这是**行为变更而非纯修复**——MiniMax 源的 `ENABLE_THINKING` 从「无效」变为「生效」,且配了该项的 scope 会有一次性缓存冷启动。同时写明 `reasoning_tokens` 的语义:`None` = 本次调用未上报,下游判据须为 `in (None, 0)`,写 `== 0` 永远不成立。`schemas/llm-calls.md` 的字段表由 21 改 22,新增行说明该列。
|
||||
|
||||
Gitea Wiki(独立仓库)**本任务内必须同步**:按 `docs-convention.md` §2「新公共 API / 新能力」一行,需改 `参考-公共API`(`LLMResponse` 新字段)与相关指南页;该表把同步绑定在**变更**上而非发版上,不可推迟。`Home.md` 的版本号与安装命令等发版项不在本计划范围。
|
||||
|
||||
**验收**:CHANGELOG 含行为变更与冷启动两处提示;schema 文档字段数与 `_COLUMNS` 长度一致。
|
||||
|
||||
**验证**:人工核对;`conda run -n PolyGateway make ci` → 全绿。
|
||||
|
||||
## 完成后的独立验证
|
||||
|
||||
按 `verification-before-completion` 的强制档,本计划跨多文件,合并前须派**全新上下文**的 verifier subagent 逐条核对设计 §11 的九条验收标准与本计划各任务的测试证据,不得自审代替。
|
||||
|
||||
### T10 — 同步结论给 dissect
|
||||
|
||||
- [ ] **动作**:在本分支合并时,向 dissect 提一条 issue 或在其 `ROADMAP` 风险表中记录下述结论,并确认对方已读。
|
||||
|
||||
**验收**:dissect 侧存在可追溯的记录(issue 编号或文档行号),不以口头告知为准。
|
||||
|
||||
## 需要同步给下游的结论
|
||||
|
||||
`MiniMax-M2.7` / `M2.5` 的推理**关不掉**是模型固有属性,任何库层改动都无法改变。dissect 的 Phase-0 若要做「开思考 vs 关思考」对照,只能在 M3 上做,或把因子改为「高档 vs 低档」。此结论须在本分支合并时同步给 dissect。
|
||||
@@ -0,0 +1,290 @@
|
||||
# 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)
|
||||
|
||||
- **设计**: `research-wiki/designs/2026-08-06-governance-backend-error-design.md`(已批准 2026-08-06,Q1/Q2/Q3 逐条拍板)
|
||||
- **分支**: `feat/issue-7-governance-backend-error`
|
||||
- **目标**: 让"限流/熔断后端故障"在类型上落入 `GatewayUnavailableError`,使调用方一条 `except` 覆盖完整;同时把混在同一类里的装配缺陷拆出去,避免配置写错的任务永远重投。
|
||||
- **方案概述**: `GovernanceBackendError` 改继承 `GatewayUnavailableError`(新 reason `governance_backend_down`,`retry_after_s` 默认 5.0);两处"未知源"改抛新增的 `SourceNotConfiguredError`(**不**在 scope 级家族内);`scope` 由后端层 `self._scope` 与两个 gate 包装器注入。
|
||||
- **涉及技术**: Python 3.11+,pytest(含真实 Redis 的 integration),radon/ruff 门禁。
|
||||
|
||||
## 保真校验(适用)
|
||||
|
||||
本计划触及 ARCHITECTURE.md §1.4 索引的移植蓝本:错误分类(`reference/CHSAnalyzer/app/domain/errors.py`)与限流/熔断(`reference/CHSAnalyzer/app/coordination/`)。
|
||||
|
||||
本次**有意变更**的语义只有一条,已在设计 §3.1 声明:`GovernanceBackendError` 的类型归属(CHS 的 `LimiterError` 是独立异常,本库将其提升为 scope 级不可用的一员)。除此之外,下列承自 CHS 的语义**不得被顺带改动**,每个任务完成前逐条自查:
|
||||
|
||||
| 不得改动 | 出处 |
|
||||
|---|---|
|
||||
| `retry_after_s` 非可选、`0 = 可立即重试` | `errors.py:74-78` |
|
||||
| `SCOPE_REASONS` 既有 5 值与 `SOURCE_REASONS` 既有 7 值 | `errors.py:7-20` |
|
||||
| fail-closed 降级方向(限流/熔断后端挂 → 报错而非放行) | 库铁律 |
|
||||
| 记账路径降级为 warning、闸门路径上抛的分工 | `middleware/retry.py:404` |
|
||||
| `RedisPermit.release/settle` 的释放侧降级 | `backends/redis/limiter.py:133,151` |
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 职责 | 动作 |
|
||||
|---|---|---|
|
||||
| `research-wiki/ARCHITECTURE.md` | 架构单一事实源 §6.1 错误分类表 | 修改(**必须先行**,见设计 §8.1) |
|
||||
| `src/polygateway/errors.py` | 错误类型树内核 | 修改: 新常量、新 reason、新类、继承变更 |
|
||||
| `src/polygateway/__init__.py` | 公共 API 面 | 修改: 导出新类 |
|
||||
| `src/polygateway/backends/redis/limiter.py` | Redis 限流后端 | 修改: 6 处补 scope、1 处换新类 |
|
||||
| `src/polygateway/backends/redis/breaker.py` | Redis 熔断后端 | 修改: 5 处补 scope |
|
||||
| `src/polygateway/backends/memory/limiter.py` | 内存限流后端 | 修改: 1 处换新类 |
|
||||
| `src/polygateway/middleware/ratelimit.py` | `QuotaGate` 包装器 | 修改: 构造增 scope、4 处补 scope |
|
||||
| `src/polygateway/middleware/breaker.py` | `BreakerGate` 包装器 | 修改: 构造增 scope、5 处补 scope |
|
||||
| `src/polygateway/middleware/retry.py` / `ocr.py` / `embedding.py` | 三处 gate 装配 | 修改: 各 2 行传 scope |
|
||||
| `tests/unit/test_errors.py` | 错误类型契约 | 修改 |
|
||||
| `tests/unit/test_backpressure.py` | 后端故障传播 | 修改 |
|
||||
| `tests/unit/test_redis_key_layout.py` | 未知源行为 | 修改 |
|
||||
| `tests/integration/test_redis_cross_connection.py` | 真实 Redis 掉线 | 修改 |
|
||||
| `README.md` / `research-wiki/migrations/chsanalyzer.md` / `CHANGELOG.md` / `pyproject.toml` | 文档与版本 | 修改 |
|
||||
|
||||
## 关键接口(跨任务消费,此处写死)
|
||||
|
||||
`errors.py` 新增与变更部分:
|
||||
|
||||
```python
|
||||
GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0
|
||||
"""治理后端故障的建议重投间隔(秒)。
|
||||
|
||||
**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期
|
||||
时刻),故取一个保守固定值;下游有自己的退避策略时可忽略本字段。取 0 会让
|
||||
积压任务零延迟同时冲击已挂掉的后端(issue #7 §3.2)。
|
||||
"""
|
||||
|
||||
|
||||
class SourceNotConfiguredError(PolyGatewayError):
|
||||
"""源名不在限流后端的配置字典中: 装配缺陷,正常不可达。
|
||||
|
||||
**有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是
|
||||
"配置写错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置
|
||||
错误的任务永远重投、永不告警(issue #7 §3.4)。
|
||||
"""
|
||||
|
||||
|
||||
class GovernanceBackendError(GatewayUnavailableError):
|
||||
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。
|
||||
|
||||
继承 `GatewayUnavailableError`: fail-closed 时一个请求都发不出去,语义
|
||||
上即 scope 级不可用,调用方一条 except 即可覆盖(issue #7)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
scope: str,
|
||||
retry_after_s: float = GOVERNANCE_BACKEND_RETRY_AFTER_S,
|
||||
source_name: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
scope=scope,
|
||||
reason="governance_backend_down",
|
||||
retry_after_s=retry_after_s,
|
||||
source_name=source_name,
|
||||
)
|
||||
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
|
||||
# 携带的诊断串是排障主线索,必须保住(设计 §3.5,机制已实跑验证)
|
||||
self.args = (message,)
|
||||
```
|
||||
|
||||
两个 gate 包装器的构造签名(`scope` 为 keyword-only 必填):
|
||||
|
||||
```python
|
||||
class QuotaGate:
|
||||
def __init__(self, limiter: RateLimiter, *, scope: str) -> None:
|
||||
self._limiter = limiter
|
||||
self._scope = scope
|
||||
|
||||
|
||||
class BreakerGate:
|
||||
def __init__(self, gate: ProviderGate, *, scope: str) -> None:
|
||||
self._gate = gate
|
||||
self._scope = scope
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### - [x] T1: ARCHITECTURE §6.1 回补(必须先行)
|
||||
|
||||
**文件**: `research-wiki/ARCHITECTURE.md`(§6.1,约 372-380 行)
|
||||
|
||||
**行为**: 在错误分类表补两行——`GovernanceBackendError`(scope 级不可用,reason 恒为 `governance_backend_down`)与 `SourceNotConfiguredError`(装配缺陷,不重试不换源,消耗失败预算);scope 级 `reason` 值域由 5 值扩为 6 值,增 `governance_backend_down`。同时记录本次归位的理由与日期,并说明根因(该类是 M2 引入分布式后端时新增,当时未回补本表)。
|
||||
|
||||
**为什么先行**: `ARCHITECTURE.md` 是单一事实源,新 reason 值域与其现状冲突;先改代码后补文档等于让实现与事实源脱节(设计 §8.1)。
|
||||
|
||||
**验收**: §6.1 表格含上述两行;reason 值域文字与 `errors.py` 将要写入的 `SCOPE_REASONS` 逐字一致。
|
||||
|
||||
**测试要求**: 纯文档,无测试证据要求。
|
||||
|
||||
**验证**: `grep -n "governance_backend_down\|SourceNotConfiguredError" research-wiki/ARCHITECTURE.md` → 至少各 1 处命中。
|
||||
|
||||
**提交**: `docs: admit governance backend failures into the scope-level error model`
|
||||
|
||||
---
|
||||
|
||||
### - [x] T2: errors.py 纯增量(新常量、新 reason、新类)+ 导出
|
||||
|
||||
**文件**: 改 `src/polygateway/errors.py`、`src/polygateway/__init__.py`;改 `tests/unit/test_errors.py`
|
||||
|
||||
**行为**:
|
||||
1. 加模块级常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`(docstring 逐字见上文"关键接口");
|
||||
2. `SCOPE_REASONS` 增 `"governance_backend_down"`;
|
||||
3. 新增 `SourceNotConfiguredError(PolyGatewayError)`(定义逐字见上文);
|
||||
4. `__init__.py` 的 import 块与 `__all__` 各增 `SourceNotConfiguredError`(`__all__` 保持字母序: `SourceDeadError` → **`SourceNotConfiguredError`** → `TransientError`,即插在 `SourceDeadError` **之后**)。
|
||||
|
||||
**本任务不动 `GovernanceBackendError`**——它是纯增量,不破坏任何既有调用点,可独立提交且全套件保持通过。
|
||||
|
||||
**测试要求(先失败后通过)**:
|
||||
- 新增用例断言 `SourceNotConfiguredError` **不是** `GatewayUnavailableError` 的子类,且是 `PolyGatewayError` 的子类。改前该类不存在 → `ImportError`;改后 PASS。
|
||||
- 新增用例断言 `"governance_backend_down" in SCOPE_REASONS`,且 `GatewayUnavailableError(scope="llm", reason="governance_backend_down", retry_after_s=0.0)` 可构造。改前 `reason` 校验抛 `ValueError` → 用例失败;改后 PASS。
|
||||
- 新增用例断言 `from polygateway import SourceNotConfiguredError` 可用。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_errors.py -v` → 全 PASS;`conda run -n PolyGateway pytest tests/ -q` → 与改动前同样全绿(纯增量不应影响任何既有用例)。
|
||||
|
||||
**提交**: `feat: add SourceNotConfiguredError and the governance backend reason`
|
||||
|
||||
---
|
||||
|
||||
### - [x] T3: `GovernanceBackendError` 归位 + 22 处构造点 + scope 注入(原子)
|
||||
|
||||
**文件**: 改 `src/polygateway/errors.py`、`backends/redis/limiter.py`、`backends/redis/breaker.py`、`backends/memory/limiter.py`、`middleware/ratelimit.py`、`middleware/breaker.py`、`middleware/retry.py`、`ocr.py`、`embedding.py`;改 `tests/unit/test_errors.py`、`tests/unit/test_backpressure.py`、`tests/unit/test_redis_key_layout.py`、`tests/integration/test_redis_cross_connection.py`
|
||||
|
||||
**为什么必须原子**: `scope` 是必填 keyword,继承变更与全部构造点若分批提交,中间状态会 `TypeError`,门禁跑不过。
|
||||
|
||||
**行为**:
|
||||
|
||||
1. `errors.py`: `GovernanceBackendError` 改继承 `GatewayUnavailableError` 并覆写 `__init__`(逐字见上文"关键接口")。
|
||||
|
||||
2. **两处未知源改抛新类**(设计 §3.4,Q1 已拍板):
|
||||
|
||||
| 位置 | 改为 |
|
||||
|---|---|
|
||||
| `backends/redis/limiter.py:198` | `raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")` |
|
||||
| `backends/memory/limiter.py:92` | 同上 |
|
||||
|
||||
3. **后端层 11 处补 `scope=self._scope`**(该属性已存在: redis limiter `:170`、redis breaker `:291`、memory limiter 同名字段):
|
||||
- `backends/redis/limiter.py` 的 `:250 / :268 / :275 / :286 / :298 / :305`(6 处)
|
||||
- `backends/redis/breaker.py` 的 `:370 / :388 / :410 / :422 / :432`(5 处)
|
||||
|
||||
4. **两个 gate 包装器**: 构造函数改为上文"关键接口"的签名;`QuotaGate` 4 处(`ratelimit.py:30/38/46/54`)与 `BreakerGate` 5 处(`breaker.py:26/36/46/54/62`)的 `raise` 补 `scope=self._scope`。
|
||||
- ~~各方法开头的 `except GovernanceBackendError: raise` **保持不变**~~ **← 这条是错的,2026-08-06 独立验证时炸出(见 §T6)**。正确做法: 该放行必须扩为 `except (GovernanceBackendError, SourceNotConfiguredError): raise`,否则新增的兄弟类型会落进下一行的 `except Exception` 被**重新包成** `GovernanceBackendError`,使 Q1 的拆分在唯一的生产路径上完全失效。
|
||||
|
||||
5. **三处装配各传 scope**(三处的 `self._scope` 均已在装配前赋值,无需调整顺序):
|
||||
|
||||
| 文件 | 行 | 改为 |
|
||||
|---|---|---|
|
||||
| `middleware/retry.py` | 186-187 | `QuotaGate(limiter, scope=self._scope)` / `BreakerGate(gate, scope=self._scope)` |
|
||||
| `ocr.py` | 122-123 | 同款 |
|
||||
| `embedding.py` | 123-124 | 同款 |
|
||||
|
||||
**测试要求(先失败后通过,逐条对应)**:
|
||||
|
||||
| 用例 | 文件 | 改前为何失败 |
|
||||
|---|---|---|
|
||||
| `GovernanceBackendError` 可被 `except GatewayUnavailableError` 接住,且 `reason == "governance_backend_down"`、`retry_after_s == 5.0` | `tests/unit/test_errors.py` | 改前非其子类,`pytest.raises(GatewayUnavailableError)` 不匹配 |
|
||||
| `str(exc)` 仍为构造时的诊断串(防 §3.5 回归) | `tests/unit/test_errors.py` | 改前无该风险但改后若漏写 `self.args` 即失败,是回归护栏 |
|
||||
| 闸门泄漏路径(共五条,见设计 §1.1)抛出的异常带正确 `scope`、且可被 `except GatewayUnavailableError` 接住;钉住 `try_acquire` / `try_enter` / `progress_age_s` 三条代表路径 | `tests/unit/test_backpressure.py` — **三条都要新增桩**。现状: `progress_age_s` 只有 `TestQuotaGateProgressAge`(`:243-257`)覆盖包装行为、不验 scope;`try_acquire`(`QuotaGate`)与 `try_enter`(`BreakerGate`)**完全无桩** | 改前异常无 `scope` 属性 → `AttributeError`;两条新路径改前无覆盖 |
|
||||
| 未知源抛 `SourceNotConfiguredError`,且断言它**不是** `GatewayUnavailableError` | 改 `tests/unit/test_redis_key_layout.py:70-74`(`test_unknown_source_rejected`,现断言 `GovernanceBackendError`);内存版**当前无对应用例,需新增**一条同款(`backends/memory/limiter.py:92` 的 `_cfg("nope")`) | 改前 redis 版类型断言失败;内存版改前无覆盖(该分支从未被测过) |
|
||||
| Redis 真实掉线时准入侧抛 scope 级异常且 `reason == "governance_backend_down"` | `tests/integration/test_redis_cross_connection.py:228-245`(真实 Redis,不 mock) | 改前无 `reason` 属性 |
|
||||
|
||||
**必须同批更新的既有测试构造点**(新签名为 keyword-only 必填,漏改即 `TypeError: missing required keyword-only argument`,门禁直接红):
|
||||
|
||||
| 位置 | 现状 | 改为 |
|
||||
|---|---|---|
|
||||
| `tests/unit/test_backpressure.py:176 / :181 / :186` | `raise GovernanceBackendError("redis 抖动")` | 补 `scope=`(任意测试 scope,如 `"llm"`) |
|
||||
| `tests/unit/test_errors.py:89` | `exc = GovernanceBackendError("redis down")` | 同上;该用例现断言它**不属于**可重试分类,须一并改为断言它**是** `GatewayUnavailableError` |
|
||||
| `tests/unit/test_backpressure.py:255 / :257` | `QuotaGate(_L())` / `QuotaGate(_Broken())` | `QuotaGate(_L(), scope="llm")` 等 |
|
||||
|
||||
**保真校验检查点**: 提交前对照上文"保真校验"五条逐条自查,确认无一被顺带改动。特别核对 `RedisPermit.release/settle`(`redis/limiter.py:133,151`)的 `except GovernanceBackendError` 仍能接住释放侧失败——该处是设计 §4 否决"让原始异常穿透"路线的直接原因。
|
||||
|
||||
**验证**:
|
||||
- `conda run -n PolyGateway pytest tests/unit tests/contracts -v` → 全 PASS
|
||||
- `conda run -n PolyGateway pytest tests/integration -v` → 全 PASS(需真实 Redis)
|
||||
- `conda run -n PolyGateway pytest tests/ -q` → `0 failed`
|
||||
- `conda run -n PolyGateway radon cc src -n C -s` → 无输出
|
||||
- `make lint` → import-linter 契约全绿(本次不新增跨层依赖,应无变化)
|
||||
|
||||
**提交**: `fix: reparent governance backend failures under GatewayUnavailableError (issue #7)`
|
||||
|
||||
---
|
||||
|
||||
### - [x] T4: 公开错误面文档(issue #7 第二诉求)
|
||||
|
||||
**文件**: 改 `README.md`(§"错误模型(四分类)",约 114-125 行)、`research-wiki/migrations/chsanalyzer.md`
|
||||
|
||||
**行为**:
|
||||
1. README 增一张两列表,明确区分**会到达调用方**与**库内吸收**:
|
||||
|
||||
| 会到达调用方 | 库内吸收 |
|
||||
|---|---|
|
||||
| `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError`) | `TransientError` |
|
||||
| `RequestRejectedError` | `SourceDeadError` |
|
||||
| `ResultInvalidError` | |
|
||||
| `SourceNotConfiguredError` | |
|
||||
|
||||
2. 在该表下补一句说明: `TransientError` / `SourceDeadError` 的 docstring 描述的是**库内治理行为**,它们被 `middleware/retry.py:365` 接住并在预算耗尽时包成 `AllSourcesExhausted`,**不会**到达调用方——issue #7 记载下游曾据此写错整段设计文档。
|
||||
3. `migrations/chsanalyzer.md` 的 G1 条目补注:后端故障现已并入 `GatewayUnavailableError`,项目侧 `except GatewayUnavailableError` 一条即覆盖完整,无需为 `GovernanceBackendError` 单列分支。
|
||||
|
||||
**验收**: 调用方仅读 README 即可判断该 catch 什么,无需读 `middleware/retry.py`。
|
||||
|
||||
**测试要求**: 纯文档,无测试证据要求。
|
||||
|
||||
**验证**: `grep -n "库内吸收" README.md` → 命中。
|
||||
|
||||
**提交**: `docs: publish which errors reach callers and which the library absorbs`
|
||||
|
||||
---
|
||||
|
||||
### - [x] T5: 版本 1.1.0 + CHANGELOG + Wiki 同步
|
||||
|
||||
**文件**: 改 `pyproject.toml`(version)、`src/polygateway/__init__.py`(`__version__`)、`CHANGELOG.md`;按 `research-wiki/docs-convention.md` §2 同步 Gitea Wiki
|
||||
|
||||
**行为**: 版本 `1.0.6` → `1.1.0`(有行为变更但无 API 破坏:加父类是扩大)。CHANGELOG 需写明:
|
||||
|
||||
- **行为变更**: 后端故障从"落入调用方兜底分支"变为"被 `except GatewayUnavailableError` 捕获";下游据此把它按"延期重投、不消耗失败预算"处置,这正是修复目标,但**处置路线确实变了**,升级前须确认下游的兜底分支没有依赖它。
|
||||
- **新增**: `SourceNotConfiguredError`(公共导出)、`GOVERNANCE_BACKEND_RETRY_AFTER_S`、scope 级 reason `governance_backend_down`。
|
||||
- **下游请读**: `GovernanceBackendError` 现携带 `scope` / `reason` / `retry_after_s`(默认 5.0)/`per_source_reasons`;`str(exc)` 仍是原诊断串,结构化字段并存。配置写错(源名不匹配)现在抛 `SourceNotConfiguredError` 而非 `GovernanceBackendError`,它**不**属于可重投家族——这是有意的,目的是让装配缺陷进死信而不是永远重投。
|
||||
|
||||
**验收**: 版本三处一致(`pyproject.toml` / `__init__.py` / CHANGELOG 标题);CLAUDE.md §6 要求"版本 bump 提交不得裸发",故本任务必须与 wiki 同步同批。
|
||||
|
||||
**测试要求**: 无行为变更,`pytest tests/ -q` 保持全绿即可。
|
||||
|
||||
**验证**: `grep -n "1.1.0" pyproject.toml src/polygateway/__init__.py CHANGELOG.md` → 三处命中。
|
||||
|
||||
**提交**: `chore: release 1.1.0`
|
||||
|
||||
---
|
||||
|
||||
### - [x] T6: 修复独立验证炸出的阻塞缺陷(计划外,2026-08-06)
|
||||
|
||||
T1–T5 全绿、全部门禁通过之后,全新上下文的 verifier 用一个**走 `QuotaGate` 的**端到端用例炸出:装配缺陷在唯一的生产路径上根本没有拆出去。
|
||||
|
||||
**缺陷**: `QuotaGate`/`BreakerGate` 的 `except GovernanceBackendError: raise` 只放行了旧类型,新增的 `SourceNotConfiguredError` 落进下一行 `except Exception` 被重新包成 `GovernanceBackendError`(`reason=governance_backend_down`、`retry_after_s=5.0`)。实证:
|
||||
|
||||
```
|
||||
RAISED: GovernanceBackendError | isGatewayUnavailable=True | isSourceNotConfigured=False
|
||||
| 限流后端故障(source_stats): 未知源 's1'(scope=llm)
|
||||
```
|
||||
|
||||
即配置写错的任务照样落进"可延期重投"家族,**永远重投、永不进死信、无人告警**——正是 Q1 要防的镜像 bug,G2 等于没做。
|
||||
|
||||
**为什么原有测试测不出来**: T3 写的两条用例(`test_backpressure.py`、`test_redis_key_layout.py`)都直接打私有 `_cfg()`,绕过了包装器;而治理循环只经包装器访问后端。**盲区在于测试打的层次比生产路径低一层。**
|
||||
|
||||
**修复**(三处):
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `middleware/ratelimit.py` | 4 个方法的放行扩为 `except (GovernanceBackendError, SourceNotConfiguredError): raise` |
|
||||
| `middleware/breaker.py` | 同上,5 个方法 |
|
||||
| `middleware/telemetry.py:254` | 终态捕获元组加 `SourceNotConfiguredError`。**连带坑**: 放行生效后该异常不再是 `GovernanceBackendError`,而它在任何 attempt 之前抛出,若不显式捕获则 `emit_terminal_failure` 不触发、该路径**遥测归零**,违反"遥测必录"铁律 |
|
||||
|
||||
**回归测试**: `test_backpressure.py::TestUnknownSourceIsAssemblyDefect::test_survives_the_quota_gate_wrapper`(参数化覆盖 `try_acquire` / `stats`),**走包装器而非私有方法**。修前 2 failed,修后 PASS。
|
||||
|
||||
**同批文档订正**: 泄漏路径由"三条"改为**五条**(遗漏了 `QuotaGate.stats` 与 `BreakerGate.retry_after_s`,判据是该调用点是否被 `_record_quietly` 包裹);CHANGELOG 的 `per_source_reasons` 表述改为"属性存在但恒为 `{}`"。
|
||||
|
||||
## 完成后
|
||||
|
||||
按 CLAUDE.md §3 Phase 2,合并前须派**全新上下文**的 verifier subagent 做独立验证(`verification-before-completion`),并按新规则**前台运行**。随后走 `finishing-a-development-branch` 决定合并方式,并在 Gitea 关闭 issue #7。
|
||||
@@ -0,0 +1,276 @@
|
||||
# 实施计划: stall 判定改为非生产性等待口径(Issue #8)
|
||||
|
||||
- **依据设计**: `research-wiki/designs/2026-08-06-issue8-stall-budget-design.md`(**已批准 2026-08-06**)
|
||||
- **分支**: `feat/issue-8-stall-budget`(已建,已含设计提交 `bfe423d` + `ce2dda7`)
|
||||
- **目标**: 让 stall 计时器只累计非生产性等待,解除 `timeout_s` 与 `stall_window_s` 的隐式耦合,使重试预算在超时场景下真实可用。
|
||||
- **方案概述**: 新增调用级 `StallClock`(总时间减去 `_attempt` 耗时),替换三条治理循环里的墙钟 `entered_at`。判死双条件的结构、`inf` 语义、错误面、429 免预算全部不动。
|
||||
- **涉及技术**: Python 3.11 asyncio、`contextlib.asynccontextmanager`、pytest + `FakeClock`。
|
||||
|
||||
## 保真校验适用性
|
||||
|
||||
**适用**。三条治理循环均为 `reference/CHSAnalyzer app/providers/governance.py:200-285` 的移植物(ARCHITECTURE.md §1.4 关键资产)。但 **`reference/` 当前不在工作区**,无法逐段比对源码,故保真基准改为两处已入库的等价证据:
|
||||
|
||||
1. 设计文档 §4「旧版行为审计」表——9 条既有行为逐条标注保留/替换,实施时逐条核对;
|
||||
2. 代码内既有的 CHS 行号注释(`retry.py:212`「调用级累计计时,循环内不重置(CHS governance.py:207)」、`:303-304`「双条件 stall 判死(CHS governance.py:270-281)」、`:315`「jitter 防惊群(CHS governance.py:283-285)」)与 `tests/unit/test_backpressure.py:1-6` 的蓝本 docstring。
|
||||
|
||||
**唯一允许的语义变更是条件 A 的度量口径**(设计 §4 中标"替换"的那一行)。其余任何条件分支、退避公式、jitter 区间、状态迁移若发生行为改变,即为违规,必须回退。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/middleware/retry.py` | 修改 | 新增模块级 `StallClock`(共享单元);主循环与 `_on_no_runnable` 改用之 |
|
||||
| `src/polygateway/embedding.py` | 修改 | 复用 `StallClock`;`_on_no_runnable` 改签名 |
|
||||
| `src/polygateway/ocr.py` | 修改 | 同上 |
|
||||
| `src/polygateway/config.py` | 修改 | `_validate_stall` docstring 改写(仅注释,不改逻辑) |
|
||||
| `tests/unit/test_backpressure.py` | 修改 | 新增 `TestStallBudget` 类;订正 `:121` docstring |
|
||||
| `tests/unit/test_embedding.py` | 修改 | 新增 embedding 回归用例 |
|
||||
| `tests/unit/test_ocr_client.py` | 修改 | 新增 ocr 回归用例 |
|
||||
| `.env.example` | 修改 | 第 41 行注释改写 |
|
||||
| `research-wiki/ARCHITECTURE.md` | 修改 | §7.3 背压条目补记新口径 |
|
||||
| `CHANGELOG.md` | 修改 | 记治理行为变更 |
|
||||
|
||||
**不创建任何新模块**。`StallClock` 放在 `retry.py`,沿用 `backoff_delay` 已被 embedding/ocr 复用的既有手法(依赖方向不变:`embedding.py:42`、`ocr.py:38` 已在 import 该模块)。
|
||||
|
||||
## 关键接口(跨任务消费,此处给出实际代码)
|
||||
|
||||
`StallClock` 由 T1 落地,T2/T3 直接消费,签名以此为准:
|
||||
|
||||
```python
|
||||
class StallClock:
|
||||
"""调用级 stall 计时器: 只累计非生产性等待(设计 §3.1)。
|
||||
|
||||
stall 预算治理的是"无人治理的等待"(429 退避、配额轮询、熔断冷却),
|
||||
真实尝试已由重试预算 max_attempts 治理,故须从 stall 账里扣除——
|
||||
两者重叠计费正是 issue #8 的根因。
|
||||
|
||||
每次调用创建一个实例。严禁提升为实例属性: 并发调用共享会互相污染计时。
|
||||
"""
|
||||
|
||||
__slots__ = ("_now", "_entered_at", "_productive_s")
|
||||
|
||||
def __init__(self, now: Callable[[], float]) -> None:
|
||||
self._now = now
|
||||
self._entered_at = now()
|
||||
self._productive_s = 0.0
|
||||
|
||||
def stalled_s(self) -> float:
|
||||
"""非生产性等待累计秒数 = 总耗时 - 真实尝试耗时。"""
|
||||
return self._now() - self._entered_at - self._productive_s
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def attempting(self) -> AsyncIterator[None]:
|
||||
"""包裹一次真实尝试, 其耗时记为生产性(边界即 _attempt 的边界)。"""
|
||||
started = self._now()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# 只做算术, 不吞任何异常——CancelledError 逐字穿透(库铁律)
|
||||
self._productive_s += self._now() - started
|
||||
```
|
||||
|
||||
需在 `retry.py` 新增 `import contextlib`;`AsyncIterator` 从 `collections.abc` 引入(该文件已有 `from __future__ import annotations`,类型注解延迟求值,若 `TYPE_CHECKING` 块中已有 `Callable` 则复用)。
|
||||
|
||||
三条循环的改造模式一致:
|
||||
|
||||
```python
|
||||
clock = StallClock(self._now) # 替换 entered_at = self._now()
|
||||
...
|
||||
await self._on_no_runnable(gate_rejections, reasons, clock) # 形参改类型
|
||||
...
|
||||
async with clock.attempting():
|
||||
outcome = await self._attempt(...) # 原调用不变, 仅被包裹
|
||||
```
|
||||
|
||||
判定式由 `self._now() - entered_at > stall` 改为 `clock.stalled_s() > stall`,**条件 B 与 `and` 结构逐字不动**。
|
||||
|
||||
---
|
||||
|
||||
## T1 — `StallClock` 落地与 chat 路径改造
|
||||
|
||||
- [ ] **文件**: `src/polygateway/middleware/retry.py`(修改)、`tests/unit/test_backpressure.py`(修改)
|
||||
|
||||
### 行为与验收标准
|
||||
|
||||
1. 按上文「关键接口」实现 `StallClock`,置于模块级(建议紧邻既有 `backoff_delay` 纯函数,便于 embedding/ocr 一并 import)。
|
||||
2. `RetryMW.__call__`:`entered_at = self._now()`(`retry.py:212`)改为 `clock = StallClock(self._now)`;主循环判定(`:217`)改为 `clock.stalled_s() > stall`;`_attempt` 调用(`:228`)用 `async with clock.attempting():` 包裹。
|
||||
3. `_on_no_runnable`(`:286-288`)形参 `entered_at: float` 改为 `clock: StallClock`,其内判定(`:306`)同步改为 `clock.stalled_s() > stall`。
|
||||
4. **不得改动**:429 免预算分支(`:233-234`)、`max(fails, 1)` 退避(`:243`)、jitter 公式(`:315`)、`fail_fast` 分支、条件 B `await self._quota.progress_age_s() > stall`、`AllSourcesExhausted` 的任何字段。
|
||||
5. 保留 `retry.py:212` 的 CHS 行号注释并补记新口径(说明"调用级累计、循环内不重置"仍然成立,变的只是不再计入真实尝试)。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
在 `tests/unit/test_backpressure.py` 新增 `class TestStallBudget`:
|
||||
|
||||
| 用例 | 构造 | 断言 |
|
||||
|---|---|---|
|
||||
| `test_single_timeout_does_not_exhaust_stall_budget` | `_STALL=300`,源 `timeout_s` 等价;脚本 `[TransientError(耗时 350s), _ok()]`——用 `FakeTransport` 配合在尝试中推进 `FakeClock` 350s | 返回成功响应。**改前**:抛 `AllSourcesExhausted(reason="stalled")` |
|
||||
| `test_productive_time_excluded_from_stall` | 连续两次尝试各推进时钟 `_STALL+100`,第三次成功 | 返回成功;全程不触发 `stalled` |
|
||||
| `test_nonproductive_wait_still_triggers_stall` | 沿用 `_blocked_limiter`,轮询中推进时钟超窗且不 `mark_progress` | 抛 `stalled`(兜底未被削弱) |
|
||||
| `test_saturation_429_still_stalls` | 源持续抛 429(`TransientError(status_code=429)`),退避 sleep 中推进时钟 | 抛 `stalled` 而非无限循环(`BoundedSleep` 上限内)。钉住设计 §3.5 |
|
||||
| `test_cancel_inside_attempt_pierces` | 在 `_attempt` 内挂起后 `task.cancel()` | 抛 `CancelledError`(`attempting()` 的 finally 不吞) |
|
||||
| `test_concurrent_calls_do_not_share_clock` | 两路并发调用,一路长尝试、一路正常 | 两路互不影响;钉住 `StallClock` 不得为实例属性 |
|
||||
| `test_telemetry_time_counts_as_productive` | 注入一个在 `emit_attempt` 中推进 `FakeClock` 超过 `_STALL` 的慢 emitter,transport 正常成功 | 返回成功响应,不触发 `stalled`。**钉住设计 §3.1 的边界声明**:遥测收尾属生产性,遥测抖动不得参与判死。若将来有人把 `attempting()` 的包裹范围收窄到只包 transport 调用,该不变式会被悄悄破坏而其余用例抓不到 |
|
||||
|
||||
**既有四象限用例(`TestStallQuadrants` 四条)必须原样通过,不得修改断言**——它们全程无真实尝试或真实尝试耗时为 0,`stalled_s()` 与旧墙钟等价。若其中任何一条需要改断言才能通过,说明实现越界,停下来复核。
|
||||
|
||||
同时订正 `tests/unit/test_backpressure.py:121` 的 docstring:「仅全局超窗(从未出餐 age=inf)」保持不变(该语义确实不变),但补一句说明本地口径已是非生产性等待。
|
||||
|
||||
### 验证命令
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_backpressure.py -v
|
||||
conda run -n PolyGateway pytest tests/unit/test_retry.py -v
|
||||
```
|
||||
|
||||
预期:全部 PASS。先在实现前跑新增用例,记录 `test_single_timeout_does_not_exhaust_stall_budget` 的 FAILED 输出作为红证据。
|
||||
|
||||
- [ ] **提交点**: `fix: bill only non-productive waiting against the chat stall budget`
|
||||
|
||||
---
|
||||
|
||||
## T2 — embedding 路径改造
|
||||
|
||||
- [ ] **文件**: `src/polygateway/embedding.py`(修改)、`tests/unit/test_embedding.py`(修改)
|
||||
|
||||
### 行为与验收标准
|
||||
|
||||
1. `embedding.py:42` 的 import 增加 `StallClock`(该行已 import `_failure_reason, backoff_delay`)。
|
||||
2. `_embed_batch`(`:182`):`entered_at = self._now()` 改为 `clock = StallClock(self._now)`;`_attempt` 调用(`:188`)用 `async with clock.attempting():` 包裹;`_on_no_runnable` 传参(`:186`)改为 `clock`。
|
||||
3. `_on_no_runnable`(`:230-232`)形参改 `clock: StallClock`,判定(`:248`)改 `clock.stalled_s() > stall`。
|
||||
4. **不得新增主循环 stall 判定**(设计 §5.4:embedding 无 429 免预算,`fails += 1` 无条件,缺口不存在;新增等于凭空多一条判死路径)。
|
||||
5. **不得改动**:`fails += 1` 的无条件性(`:191`)、`max_attempts` 判定、退避调用(`:200`)。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
在 `tests/unit/test_embedding.py` 新增 `test_single_timeout_does_not_exhaust_stall_budget`。
|
||||
|
||||
**构造方式(已核实可行,不必绕过既有 helper)**:`_embed_client`(`:219`)的 `**overrides` 直通 `EmbeddingClient.__init__`,而后者接受 `now`/`sleep`/`rng`(`embedding.py:109-111`),故可写 `_embed_client([src], script, now=clock, sleep=<推进时钟的 fake>)`。制造一轮 `_on_no_runnable` 沿用 `test_backpressure.py:75-85` `_blocked_limiter` 的手法:源 `max_concurrency=1`,测试先 `try_acquire` 占满 permit,在 fake sleep 回调里释放。helper 内的 `InMemoryLimiter` 未注入 `now` 不影响本用例——判定要的是 `progress_age_s()` 返回 `inf`(从未 `mark_progress`),与 limiter 时钟无关。
|
||||
|
||||
**断言**:第一次尝试推进 `FakeClock` 超过 `stall_window_s` 后抛 `TransientError`,随后经一轮 `_on_no_runnable` 再恢复,最终返回成功的 `EmbeddingResponse`。改前应抛 `AllSourcesExhausted(reason="stalled")`。
|
||||
|
||||
**取消穿透验收点**:既有 `test_cancel_releases_permit`(`test_embedding.py:331-339`)的取消路径**将被新的 `async with clock.attempting()` 包住**,故它是本任务的必过回归项,不得因改动而修改其断言。若它转红,说明 `attempting()` 的 `finally` 吞了 `CancelledError` 或泄漏了 permit,停下来复核而非改测试。
|
||||
|
||||
### 验证命令
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_embedding.py -v
|
||||
```
|
||||
|
||||
预期:全部 PASS(含既有取消与遥测用例)。
|
||||
|
||||
- [ ] **提交点**: `fix: apply the non-productive stall budget to the embedding loop`
|
||||
|
||||
---
|
||||
|
||||
## T3 — ocr 路径改造
|
||||
|
||||
- [ ] **文件**: `src/polygateway/ocr.py`(修改)、`tests/unit/test_ocr_client.py`(修改)
|
||||
|
||||
### 行为与验收标准
|
||||
|
||||
与 T2 同构,对应行号:import(`:38`)、`_call` 的 `entered_at`(`:207`)、`_on_no_runnable` 传参(`:211`)、`_attempt` 调用(`:213`)、`_on_no_runnable` 签名(`:255-257`)与判定(`:273`)。同样**不得新增主循环 stall 判定**,不得改动 `fails += 1`(`:216`)与退避(`:225`)。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_ocr_client.py` 新增与 T2 同构的 `test_single_timeout_does_not_exhaust_stall_budget`,覆盖 `recognize_text` 或 `parse_layout` 任一端点即可(两者共用 `_call`)。
|
||||
|
||||
**构造方式**:同 T2——经该文件既有的 `_client(...)` helper 传 `now=clock` 与推进时钟的 fake `sleep`;`_on_no_runnable` 一轮用"源 `max_concurrency=1` + 测试预先占满 permit + 在 fake sleep 回调里释放"制造。
|
||||
|
||||
**取消穿透验收点**:既有 `test_cancel_during_transport_releases_permit`(`test_ocr_client.py:339-348`)与其上方的退避期取消用例同样会被新包裹覆盖,均为必过回归项,不得修改断言。
|
||||
|
||||
### 验证命令
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_ocr_client.py tests/unit/test_monkey_ocr.py -v
|
||||
```
|
||||
|
||||
预期:全部 PASS。
|
||||
|
||||
- [ ] **提交点**: `fix: apply the non-productive stall budget to the ocr loop`
|
||||
|
||||
---
|
||||
|
||||
## T4 — 配置侧注释对齐(无逻辑变更)
|
||||
|
||||
- [ ] **文件**: `src/polygateway/config.py`(修改)、`.env.example`(修改)
|
||||
|
||||
### 行为与验收标准
|
||||
|
||||
1. `config.py:240-241` `_validate_stall` 的 docstring 由「stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死」改写为:说明该校验在新口径下**属保守冗余**——TTFT 等待是生产性时间,已不计入 stall;保留校验是为不改动 ARCHITECTURE.md §7.3 契约 G6(人类 2026-08-06 定夺)。**校验逻辑本身一字不改**。
|
||||
2. `.env.example:41` 注释由「stall 双条件判死窗口;须 ≥ 最大源 TTFT」改写为说明它度量的是**非生产性等待**(429 退避/配额轮询/熔断冷却)累计,与 `TIMEOUT_S` 无耦合、无需按 `timeout × retries` 放大。
|
||||
3. 不新增、不改名任何配置键(`_DEFAULT_STALL_WINDOW_S = 300.0` 保持不变)。
|
||||
|
||||
### 验证命令
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_config.py -v
|
||||
conda run -n PolyGateway make lint
|
||||
```
|
||||
|
||||
预期:全部 PASS(本任务不改逻辑,`test_config.py` 应零变化通过)。
|
||||
|
||||
- [ ] **提交点**: `docs: align the stall window comments with the new metering`
|
||||
|
||||
---
|
||||
|
||||
## T5 — 全套件回归与文档同步
|
||||
|
||||
- [ ] **文件**: `research-wiki/ARCHITECTURE.md`(修改)、`CHANGELOG.md`(修改)
|
||||
|
||||
### 行为与验收标准
|
||||
|
||||
1. 跑全套件确认零回归。**命令末尾不得接管道**(CLAUDE.md 执行模式:管道会掩盖真实退出码),需要后台跑时用 `wait`/轮询 PID 判完成。
|
||||
2. `ARCHITECTURE.md` §7.3 背压条目(第 429-431 行区域)补记:stall 双条件的条件 A 现为**非生产性等待累计**,并给出本设计文档指针。既有 G6 契约行保留,补注其在新口径下为保守冗余。
|
||||
3. `CHANGELOG.md` 记治理行为变更(属公共行为变更,须显式列出:单次调用最坏耗时由 `stall_window_s` 抬升至 `max_attempts × timeout_s`)。
|
||||
4. 核对设计 §4 行为审计表 9 条,逐条确认实现与标注一致(保真校验检查点)。
|
||||
|
||||
### 副作用处置
|
||||
|
||||
修复后单次调用最坏耗时变为 `max_attempts × timeout_s`(本机 900s)。跑 e2e 前先评估 `tests/e2e` 的源 `timeout_s` 是否需调小,以免冒烟耗时失控。本机 `.env:37` 的临时缓解 `STALL_WINDOW_S=1200` 可回退默认值(`.env` 不入库,仅在本任务记录该动作)。
|
||||
|
||||
### 验证命令
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway make lint
|
||||
conda run -n PolyGateway pytest tests/unit tests/integration -v
|
||||
conda run -n PolyGateway make test
|
||||
```
|
||||
|
||||
预期:lint 通过(含 import-linter 依赖契约);单元与集成全绿;覆盖率不低于既有水平。
|
||||
|
||||
- [ ] **提交点**: `docs: record the stall metering change in architecture and changelog`
|
||||
|
||||
---
|
||||
|
||||
## T6 — 独立验证与 Wiki 同步
|
||||
|
||||
- [ ] **文件**: Gitea Wiki(独立仓库)
|
||||
|
||||
### 行为与验收标准
|
||||
|
||||
1. **派全新上下文 verifier subagent**(`verification-before-completion`,MANDATORY:跨 3 模块属里程碑级),**前台运行**(`run_in_background: false`,CLAUDE.md 执行模式)。核验对象:设计 §1 的 G1-G4 是否逐条兑现、§4 行为审计表 9 条是否与实现一致、是否出现设计未声明的语义变更、测试是否真的覆盖"先失败后通过"。
|
||||
2. 按 `docs-convention.md` §2「治理行为变更」行同步 Wiki:`解释-治理行为`(stall 判定口径)、`指南-限流与熔断`(配置说明中删除"须按 timeout×retries 放大 stall"一类误导)。
|
||||
3. 在 Gitea issue #8 下回帖:根因、方案、被否决的两个原建议方向及理由、影响面。
|
||||
4. Wiki 注册:
|
||||
```bash
|
||||
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id issue8-stall-budget --title "stall 判定改为非生产性等待口径"
|
||||
.claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:issue8-stall-budget" --to "design:issue8-stall-budget" --type implements --evidence "本计划实施该设计的 T1-T6"
|
||||
.claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||
```
|
||||
|
||||
### 验证命令
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway make ci
|
||||
```
|
||||
|
||||
预期:只读验证全绿。verifier 报告须逐条对应本会话内的工具输出(证据化声明,禁止虚报)。
|
||||
|
||||
- [ ] **提交点**: `chore: register the issue #8 plan and sync the wiki`
|
||||
|
||||
---
|
||||
|
||||
## 任务依赖
|
||||
|
||||
T1 → (T2 ‖ T3) → T4 → T5 → T6。T2 与 T3 相互独立,但都依赖 T1 落地的 `StallClock`。
|
||||
@@ -0,0 +1,259 @@
|
||||
# 实现计划: HTTP 错误响应体留存(Issue #10)
|
||||
|
||||
- **设计**: `research-wiki/designs/2026-08-16-issue10-error-body-retention-design.md`(**已批准 2026-08-16**)
|
||||
- **分支**: `feat/issue-10-error-body-retention`
|
||||
- **目标**: 网关拒绝一次调用时,它说的话必须能在库自己的遥测表里被事后查到。
|
||||
- **方案概述**: transport 翻译层把 HTTP 错误响应体折叠空白并按头尾策略摘要,**同一份串**同时拼进异常 message(经既有 `error` 列落遥测)与新增的基类字段 `body_text`(供下游结构化留存)。覆盖两个 transport 的全部非 2xx 分支。不改任何状态码→分类的映射。
|
||||
- **涉及技术**: Python 3.11 / httpx / pytest。无新增依赖。
|
||||
- **保真校验**: 本计划**不涉及** `reference/` 参考实现迁移——错误分类映射逐条不变,保真体现为"既有分类断言全部保留、无一条被改写"(Task 3/4 验收项)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/errors.py` | 改 | 基类 `PolyGatewayError` 新增 `body_text` 字段;与 `raw_text` 的界限 docstring;`RequestRejectedError` 补中转拓扑提醒 |
|
||||
| `src/polygateway/transports/_http_errors.py` | **新建** | 摘要口径单一实现:`summarize_body` / `compose_message` / `response_body` + 三个常量 |
|
||||
| `src/polygateway/transports/openai_compat.py` | 改 | `_status_to_error` 表驱动重写;`_translate_429` 收 `ctx` |
|
||||
| `src/polygateway/transports/monkey_ocr.py` | 改 | `_classify_status` 带摘要 |
|
||||
| `tests/unit/test_http_error_body.py` | **新建** | 摘要单元的纯函数用例(截断边界、头尾保留、折叠、幂等) |
|
||||
| `tests/unit/test_openai_compat.py` | 改 | 状态码参数化断言 message + 字段;超长 `insufficient_quota` 回归 |
|
||||
| `tests/unit/test_monkey_ocr.py` | 改 | OCR 分支同款 + `ResponseNotRead` 降级 |
|
||||
| `tests/unit/test_errors.py` | 改 | `body_text` 默认值与可传性 |
|
||||
| `tests/integration/test_governance_stack.py` | 改 | **端到端验收**:400 调用后 SQLite `error` 列含摘要 |
|
||||
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` / `research-wiki/ARCHITECTURE.md` | 改 | 文档与 1.2.0 版本号 |
|
||||
|
||||
## 关键接口(跨任务消费,必须逐字一致)
|
||||
|
||||
```python
|
||||
# src/polygateway/transports/_http_errors.py
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx # response_body 的类型与 ResponseNotRead 都来自它
|
||||
|
||||
_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限
|
||||
_HEAD_CHARS = 1400
|
||||
_TAIL_CHARS = 600
|
||||
|
||||
|
||||
def summarize_body(text: str) -> str:
|
||||
"""折叠空白后按头尾策略摘要;空/空白入参返回空串。"""
|
||||
collapsed = " ".join(text.split())
|
||||
if len(collapsed) <= _ERROR_BODY_CAP:
|
||||
return collapsed
|
||||
omitted = len(collapsed) - _HEAD_CHARS - _TAIL_CHARS
|
||||
return f"{collapsed[:_HEAD_CHARS]}…(略 {omitted} 字)…{collapsed[-_TAIL_CHARS:]}"
|
||||
|
||||
|
||||
def compose_message(message: str, summary: str) -> str:
|
||||
"""摘要非空才拼后缀,避免悬空分隔符。"""
|
||||
return f"{message} | {summary}" if summary else message
|
||||
|
||||
|
||||
def response_body(response: httpx.Response) -> str:
|
||||
"""取已缓冲的响应文本;未读缓冲一律降级空串,绝不触发网络读。"""
|
||||
try:
|
||||
return response.text
|
||||
except httpx.ResponseNotRead:
|
||||
return ""
|
||||
```
|
||||
|
||||
```python
|
||||
# src/polygateway/errors.py
|
||||
class PolyGatewayError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
source_name: str | None = None,
|
||||
status_code: int | None = None,
|
||||
operation: str | None = None,
|
||||
body_text: str = "",
|
||||
) -> None:
|
||||
```
|
||||
|
||||
```python
|
||||
# src/polygateway/transports/openai_compat.py
|
||||
# 既有 errors 导入(:18-23)须补入 PolyGatewayError —— 当前只导了四个子类,
|
||||
# 直接写 _classify 的返回注解会让 ruff 报 F821 未定义名。
|
||||
from polygateway.errors import (
|
||||
PolyGatewayError, # ← 新增
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||
|
||||
|
||||
def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
|
||||
"""状态码 → (错误类, message 标签);映射与 1.1.2 逐条相同。"""
|
||||
if status in (401, 403):
|
||||
return SourceDeadError, "凭据失效/欠费"
|
||||
if status == 400:
|
||||
return RequestRejectedError, "请求被拒"
|
||||
if status >= 500:
|
||||
return TransientError, "瞬时错误"
|
||||
return RequestRejectedError, "客户端错误"
|
||||
|
||||
|
||||
def _status_to_error(
|
||||
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
|
||||
) -> Exception:
|
||||
summary = summarize_body(body_text) # 全函数只算一次
|
||||
ctx: dict[str, Any] = {
|
||||
"source_name": source.name,
|
||||
"status_code": status,
|
||||
"operation": "chat",
|
||||
"body_text": summary,
|
||||
}
|
||||
if status == 429:
|
||||
return _translate_429(source, body_text, headers, ctx) # 传**原文**,见下
|
||||
cls, label = _classify(status)
|
||||
return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
|
||||
```
|
||||
|
||||
> **实现红线**:`_translate_429` 判 `insufficient_quota` 必须解析**未截断的原文** `body_text`,不得改用 `summary`。摘要会破坏 JSON 结构,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`——那是把一个诊断改进变成治理 bug。Task 3 有专门的回归用例钉死这条。
|
||||
|
||||
## 任务清单
|
||||
|
||||
### - [ ] Task 1: 内核新增 `body_text` 字段
|
||||
|
||||
**改**: `src/polygateway/errors.py`
|
||||
|
||||
- `PolyGatewayError.__init__` 按上文签名新增 `body_text: str = ""`,存为实例属性。
|
||||
- 类 docstring 增补与 `ResultInvalidError.raw_text` 的界限:`body_text` = 非 2xx 的 HTTP 错误响应体摘要(对方拒绝的理由);`raw_text` = 2xx 但内容不可解析时的模型输出。并写明"可能包含请求回显,已截断"。
|
||||
- **不动** `TransientError` / `SourceDeadError` / `RequestRejectedError` / `ResultInvalidError` / `GatewayUnavailableError` 的任何既有签名与行为。
|
||||
|
||||
**测试**(`tests/unit/test_errors.py`,扩展 `:29` 的四类构造形态参数化):
|
||||
- 四个 transport 错误类默认 `body_text == ""`;显式传入后可读回。
|
||||
- `GatewayUnavailableError` / `CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` 的 `body_text` 恒为 `""`(它们不经 HTTP 响应翻译)。
|
||||
|
||||
**验收**: 新增字段不改变任何既有异常的 `str()` 输出。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_errors.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 2: 共享摘要单元
|
||||
|
||||
**新建**: `src/polygateway/transports/_http_errors.py`(按上文"关键接口"逐字实现,**含其中的 `import httpx`**,加中文模块/函数 docstring 解释**为什么**折叠空白、为什么头尾保留、为什么 `response_body` 必须降级)
|
||||
|
||||
**新建测试**: `tests/unit/test_http_error_body.py`
|
||||
|
||||
| 用例 | 断言 |
|
||||
|---|---|
|
||||
| 短体原样 | `summarize_body('{"a":1}') == '{"a":1}'` |
|
||||
| 空白折叠 | 多行缩进 JSON → 单行,无连续空格 |
|
||||
| 空 / 纯空白入参 | 返回 `""` |
|
||||
| 长度恰 2048 | 原样返回,无标记 |
|
||||
| 长度 2049 | 走头尾策略 |
|
||||
| 超长体头尾 | 前 1400 字符 == 原文前 1400;**末 600 字符 == 原文末 600**;中段标记内 N == `len(原文) - 2000` |
|
||||
| **尾部关键字段可见**(设计 §7 用例 3c) | 以 issue 真实样本尾部 `"code":"invalid_parameter_error"}}` 收尾构造超长体 → 断言该串出现在摘要中 |
|
||||
| 幂等 | `summarize_body(summarize_body(x)) == summarize_body(x)`(标记不嵌套) |
|
||||
| `compose_message` | 摘要为空时返回原 message 不变;非空时以竖线分隔符拼接 |
|
||||
| `response_body` 降级 | `httpx.Response(400, stream=<未读 SyncByteStream>)` → 返回 `""` 且不抛(构造法见下) |
|
||||
|
||||
未读响应的构造(已实测可用):
|
||||
|
||||
```python
|
||||
class _Unread(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"body"
|
||||
|
||||
resp = httpx.Response(400, stream=_Unread()) # 未 read → .text 抛 ResponseNotRead
|
||||
```
|
||||
|
||||
**验收**: 摘要总长恒 ≤ `2000 + len(标记)`;头尾各自与原文逐字对应。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_http_error_body.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 3: openai_compat 翻译层收口
|
||||
|
||||
**改**: `src/polygateway/transports/openai_compat.py`
|
||||
|
||||
- 新增 `_classify`,`_status_to_error` 按上文骨架重写(五分支各拼各的 message → 查表 + 单点拼装)。
|
||||
- `_translate_429` 签名改为 `(source, body_text, headers, ctx)`,两支 message 各自追加 `compose_message` 后缀,构造改用 `**ctx`;**`json.loads` 仍读原文 `body_text`**。
|
||||
- message 主体逐字保持 1.1.2 原样(`凭据失效/欠费: {status}` / `请求被拒: 400` / `瞬时错误: {status}` / `客户端错误: {status}` / `配额耗尽(insufficient_quota)` / `限速: 429`),只在末尾追加 ` | {摘要}`。
|
||||
- 三个调用点(`:402` embed、`:417` stream、`:509` 非流式)签名不变,**不改动**。
|
||||
- **补 import**:`PolyGatewayError`(errors)与 `compose_message` / `summarize_body`(`._http_errors`),见上文关键接口——漏补则 `make lint` 报 F821(Codex 审查 2026-08-16 提出)。
|
||||
- **不改** `operation` 硬编码 `"chat"`(设计 §5.4 有意留给独立 issue)。
|
||||
|
||||
**测试**(`tests/unit/test_openai_compat.py`,沿用既有 `_transport_for(handler)` + `httpx.MockTransport`):
|
||||
|
||||
| # | 用例 | 断言 |
|
||||
|---|---|---|
|
||||
| 3.1 | 状态码参数化 400 / 401 / 403 / 404 / 500 / 503,handler 返回带真实样本体 | 异常类型与 1.1.2 **逐条相同**;message 含摘要;`exc.body_text` == 摘要 |
|
||||
| 3.2 | 429 普通限速(body 无 `insufficient_quota`) | `TransientError`,message 含摘要,`retry_after_s` 解析不受影响 |
|
||||
| 3.3 | 429 + `insufficient_quota` | `SourceDeadError`,message 含摘要 |
|
||||
| 3.4 | **回归红线**:429 + `insufficient_quota` 且 body 长度 > 2048(前置大量填充字段) | 仍判 `SourceDeadError`——证明类型判定读的是原文而非摘要 |
|
||||
| 3.5 | 空 body 的 400 | message 无悬空分隔符,`body_text == ""` |
|
||||
| 3.6 | 非 JSON body、非 UTF-8 字节 body | 不抛额外异常,分类不变 |
|
||||
| 3.7 | 流式路径(handler 对 stream 请求返回 400 + body) | 经 `_complete_stream:415-417` 抛出的异常同样带摘要 |
|
||||
| 3.8 | embedding 路径(`transport.embed(...)` 遇 400) | 同样带摘要 |
|
||||
|
||||
**验收**: 既有测试零修改通过(除 3.x 新增外);`test_openai_compat.py:558`(match 源名)仍 PASS。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 4: monkey_ocr 同款收口
|
||||
|
||||
**改**: `src/polygateway/transports/monkey_ocr.py`
|
||||
|
||||
- `_classify_status`:`summary = summarize_body(response_body(exc.response))`,三支 message 统一经 `compose_message` 追加后缀,`ctx` 带 `body_text=summary`。
|
||||
- message 主体保持 `f"{source_name} OCR {operation} HTTP {status}"` 不变。
|
||||
- 429/5xx → `TransientError`、401/403 → `SourceDeadError`、其余 → `RequestRejectedError` 的映射**逐条不变**(OCR 无 429 细分是设计有意保留,见模块 docstring `:53-54`)。
|
||||
|
||||
**测试**(`tests/unit/test_monkey_ocr.py`):
|
||||
- 扩展 `:300` 的状态码参数化:各分支 message 含摘要且 `body_text` 非空,分类不变。
|
||||
- `ResponseNotRead` 降级:`exc.response` 为未读流 → `body_text == ""`,message 无悬空分隔符,**分类仍正确**(不得因取 body 失败而改变错误类型或抛出 httpx 异常)。
|
||||
|
||||
**验收**: `:195` 与 `:300` 既有断言不被改写。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_monkey_ocr.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 5: 端到端遥测验收(**本计划的硬判据**)
|
||||
|
||||
**改**: `tests/integration/test_governance_stack.py`
|
||||
|
||||
新增用例,沿用既有 `_full_client(handler, telemetry=SQLiteRecorder(...))` 与 `:135` 的 `SELECT error FROM llm_calls` 断言模式:
|
||||
|
||||
- handler 对 chat 请求返回 `httpx.Response(400, content=<issue #10 真实样本体>)`。
|
||||
- `client.chat(...)` 抛 `RequestRejectedError`(400 不重试不换源,行为不变)。
|
||||
- `recorder.close()` 后查 `SELECT error FROM llm_calls`:该行 `error` 串**含样本体里的 `InvalidParameter` 与结尾的 `invalid_parameter_error`**。
|
||||
|
||||
真实样本体(取自 issue #10 原文,一字不改):
|
||||
|
||||
```json
|
||||
{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal and cannot be opened","type":"invalid_request_error","param":"","code":"invalid_parameter_error"}}
|
||||
```
|
||||
|
||||
**验收**: 这条断言在 Task 1-4 之前**必然失败**(1.1.2 的 `error` 列只有 `"qwen_1 请求被拒: 400"`),之后通过——这就是本 issue 的"先失败后通过"证据主体,执行时须保留失败输出截图/文本进提交说明。
|
||||
**验证**: `conda run -n PolyGateway pytest tests/integration/test_governance_stack.py -v` → 全 PASS。
|
||||
|
||||
### - [ ] Task 6: 文档与版本
|
||||
|
||||
**改**:
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `src/polygateway/errors.py` | `RequestRejectedError` docstring 加一句:经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类(设计 §5.2) |
|
||||
| `research-wiki/ARCHITECTURE.md` §6.2 | 同一提醒 + 注明四分类错误自 1.2.0 起携带 `body_text` |
|
||||
| `CHANGELOG.md` | 新增 `## 1.2.0(2026-08-16)` 段:行为变更(message 追加摘要 → 遥测 `error` 列变长)、新增字段、不变项(分类映射零变更、错误面零变更) |
|
||||
| `README.md:34` | 安装 pin `==1.1.*` → **`>=1.2,<2`**(2026-08-16 人类定夺;漏改则下游静默停在 1.1.2) |
|
||||
| `pyproject.toml` + `src/polygateway/__init__.py` | 版本号 `1.1.2` → `1.2.0`,**两处必须一致** |
|
||||
|
||||
**验收**: `grep -rn "1\.1\.\*" README.md` 零命中;两处版本号一致。
|
||||
**验证**: `conda run -n PolyGateway python -c "import polygateway; print(polygateway.__version__)"` → `1.2.0`。
|
||||
|
||||
### - [ ] Task 7: 合并前全量门
|
||||
|
||||
1. `make lint`(ruff + import-linter)→ 零违规,**重点确认新建 `transports/_http_errors.py` 未触发洋葱分层契约**。
|
||||
2. `make test` 全套件 → 全 PASS,覆盖率不低于既有水平。
|
||||
3. 派**全新上下文** verifier subagent 独立验证(CLAUDE.md §3 Phase 2 硬门):逐条核对 Task 1-6 验收项与本会话工具输出。
|
||||
4. `finishing-a-development-branch` 合并回 main(`--no-ff`),合并后在 main 上重跑 `make lint` 与全套件。
|
||||
|
||||
**发布**(合并后)严格按 CLAUDE.md §4.4.1 九步执行,不在本计划展开;其中步骤 1(更新 README)已在 Task 6 前置完成,**构建前须再次确认 pin 已是 `>=1.2,<2`**。
|
||||
|
||||
## 执行顺序与提交点
|
||||
|
||||
```
|
||||
Task 1 ──┐
|
||||
├── Task 3 ──┐
|
||||
Task 2 ──┴── Task 4 ──┴── Task 5 ── Task 6 ── Task 7
|
||||
```
|
||||
|
||||
Task 1 与 2 可并行(互不依赖);Task 3、4 都依赖 1+2;Task 5 依赖 3;Task 6 独立于代码但须在 Task 7 之前。每个 Task 一次语义化提交(`commit` skill),Task 5 的提交说明须附"修复前失败、修复后通过"的实际输出。
|
||||
@@ -0,0 +1,269 @@
|
||||
# 实现计划: 调用方自定义维度(issue #11)
|
||||
|
||||
- **目标**: 让调用方能在每次调用上附带租户标识与任意自定义维度,并落进遥测表——`tenant_id` 为真实列(可挂 RLS),其余进 `meta` JSON 容器。
|
||||
- **方案概述**: `llm_calls` 增两列(`tenant_id TEXT NOT NULL DEFAULT ''`、`meta` JSON);三个公共入口(`chat`/`embed`/OCR 两方法)各增两个带默认值的 keyword-only 参数;校验在入口收口并抛裸 `ValueError`;`TelemetryRecorder` 端口 22 → 24 字段。库不建索引、不启用 RLS,只交付 policy 模板。
|
||||
- **依据设计**: `research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md`(已人类审批 2026-08-17)。
|
||||
- **涉及技术**: Python 3.11+、frozen dataclass、asyncpg、sqlite3、pytest。
|
||||
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**。
|
||||
|
||||
## 范围: 三条遥测链路(2026-08-17 人类追认)
|
||||
|
||||
设计初稿只覆盖 `chat()` 与 `embed()`(issue 只诉求这两条)。写计划时核实代码发现**第三条链路**: `OcrClient` 同样经 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 完全同构。OCR 行与 chat 行落在**同一张表**,不处理则同表内一部分行有租户归属、一部分永远空白,且同样不可逆。
|
||||
|
||||
**人类已追认纳入正式范围**(2026-08-17),设计文档 §1.2 已同步补正。Task 6 是必做项,**不是可跳过的分支**——与 issue #10 的先例一致(那次 issue 只报告 chat 的 400,OCR 侧被认定为同一缺陷的其余分支而一并修)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/types.py` | 修改 | 新增 `validate_caller_dimensions()`;`ChatRequest` 增两字段 |
|
||||
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 22 → 24 字段 |
|
||||
| `src/polygateway/telemetry/sqlite.py` | 修改 | DDL 增两列、`_BACKFILL_COLUMNS` 增两项、`_COLUMNS` 增两项 |
|
||||
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上(`_DDL`/`_BACKFILL`/`_COLUMNS`) |
|
||||
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 归一化并透传;三个 emit 入口从 request 读取 |
|
||||
| `src/polygateway/client.py` | 修改 | `chat()` 增两参数并校验 |
|
||||
| `src/polygateway/embedding.py` | 修改 | `embed()` 增两参数;沿 `_embed_batch`/`_attempt`/`_emit` 透传 |
|
||||
| `src/polygateway/ocr.py` | 修改 | `recognize_text`/`parse_layout` 增两参数;沿 `_call`/`_attempt`/`_emit` 透传 |
|
||||
| `tests/unit/test_types.py` | 修改 | 校验函数红线 |
|
||||
| `tests/unit/test_telemetry.py` | 修改 | 三个 emit 入口带维度 |
|
||||
| `tests/unit/test_client.py`、`test_embedding.py`、`test_ocr_client.py` | 修改 | 三条链路各自的端到端透传 |
|
||||
| `tests/unit/test_ports.py` | 修改 | 端口 24 字段契约 |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG 补列与写入 |
|
||||
| `README.md`、`CHANGELOG.md`、Gitea wiki | 修改 | 能力表、RLS 模板、版本说明 |
|
||||
|
||||
**依赖顺序**: Task 1 → Task 2 → Task 3 → (Task 4 / 5 / 6 可并行) → Task 7 → Task 8。
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
校验函数(`types.py`,紧邻 `validate_request_overlay` 放置,同款风格):
|
||||
|
||||
```python
|
||||
def validate_caller_dimensions(
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any] | None,
|
||||
*,
|
||||
origin: str,
|
||||
) -> tuple[str | None, dict[str, Any]]:
|
||||
"""校验调用方维度并返回浅拷贝;origin 用于把错误指回调用点。"""
|
||||
```
|
||||
|
||||
`ChatRequest` 新字段(必须带默认值,ARCH §5.1 约定①):
|
||||
|
||||
```python
|
||||
tenant_id: str | None = None
|
||||
meta: Mapping[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
`TelemetryRecorder.record_llm_call` 新增两个**无默认值**参数(`ports.py` 现有纪律),排在 `reasoning_tokens` 之后:
|
||||
|
||||
```python
|
||||
tenant_id: str, # 已归一化: None → ''
|
||||
meta: str, # 已序列化: 空 dict → '{}'
|
||||
```
|
||||
|
||||
**归一化在 emitter 完成,不在 recorder**——与 `sampling` 列由 `canonical_sampling_json()` 在 emitter 侧定型是同一先例。recorder 只负责落库,不做语义判断。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 校验函数与请求字段
|
||||
|
||||
**文件**: `src/polygateway/types.py`(修改)、`tests/unit/test_types.py`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
在 `validate_request_overlay` 之后新增 `validate_caller_dimensions()`,按 Phase 组织(照搬既有风格):
|
||||
- Phase 1 `tenant_id`: `None` 直接放行;非 `str` 报错;**`tenant_id != tenant_id.strip()` 报错**(首尾空白一律拒绝,不是"strip 后为空才拒绝"——`" t1"` 与 `"t1"` 会在 RLS policy 的等值比较下变成两个不同租户,静默漏数据);`strip()` 后为空亦报错(空串是哨兵值的地盘);长度 > 128 报错。
|
||||
- Phase 2 `meta` 键形态: 非 `str` 报错;不匹配 `^[a-z0-9_.]{1,64}$` 报错;以 `pg_` 开头报错(保留前缀)。
|
||||
- Phase 3 `meta` 键数量: > 16 报错。
|
||||
- Phase 4 `meta` 值: 类型不属 `(str, int, float, bool)` 报错(注意 `bool` 是 `int` 子类,先判 `bool` 无妨,两者都合法);`float` 且 `not math.isfinite(v)` 报错;`str` 且长度 > 256 报错。
|
||||
- 返回 `(tenant_id, dict(meta or {}))` —— 拷贝,防调用方复用同一 dict 逐次改值造成竞态(同 `overlay` 先例)。
|
||||
|
||||
`ChatRequest` 增两字段(见"关键接口")。字段 docstring 说明: 只读快照,库内中间件永不修改;`meta` 不进缓存 key(`cache_namespace` 已负责租户隔离,ARCH §7.5)。
|
||||
|
||||
**`ChatRequest.meta` 必须保存校验函数返回的那个浅拷贝**,不是调用方传入的原 dict——否则调用方复用同一 dict 逐次改值会让已在洋葱中流转的请求跟着变(同 `overlay` 拷贝语义的理由)。
|
||||
|
||||
**验收标准**: 每条红线抛 `ValueError` 且消息含 `origin`;合法输入返回浅拷贝且与入参不是同一对象。
|
||||
|
||||
**测试要求**(先失败后通过):
|
||||
逐条红线各一个用例——`tenant_id` 空串/纯空白/**`" t1"`/`"t1 "`(首尾空白)**/超长/非 str;`meta` 键非 str/含大写/含连字符/超 64 字符/`pg_` 前缀/17 个键;值为 `list`/`dict`/`None`/`nan`/`inf`/`-inf`/超 256 字符的 str。另加合法路径用例: `tenant_id=None` + `meta={}` 放行、`meta` 值为 `bool`/`int`/`float` 有限值放行、返回值是拷贝(改返回值不影响入参)。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_types.py -v` → 全 PASS
|
||||
|
||||
**另需一条缓存隔离测试**(放 `tests/unit/test_cache.py` 或 `test_client.py`): 相同 `messages` + 相同 `cache_namespace`、**仅 `meta` 不同**的两次调用,第二次**仍应命中缓存**。设计明确 `meta` 不进缓存 key(`cache_namespace` 已负责租户隔离);没有这条测试,实现者顺手把 `meta` 并进 key 不会被任何断言拦住,后果是存量缓存全量冷启动且此后命中率持续偏低——这类退化不报错、只表现为变慢。
|
||||
|
||||
- [ ] 提交点: `feat: validate the dimensions a caller may attach to a call`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 端口与两个遥测后端 schema
|
||||
|
||||
**文件**: `src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`tests/unit/test_ports.py`(均修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
`ports.py`: `record_llm_call` 增 `tenant_id: str` 与 `meta: str`(无默认值),docstring 的"22 字段冻结"改为 24 并说明新字段已归一化。
|
||||
|
||||
`sqlite.py` 三处同步改(顺序必须一致):
|
||||
- `_DDL` 在 `reasoning_tokens` 之后追加 `tenant_id TEXT NOT NULL DEFAULT ''` 与 `meta TEXT NOT NULL DEFAULT '{}'`;
|
||||
- `_BACKFILL_COLUMNS` 追加 `("tenant_id", "TEXT NOT NULL DEFAULT ''")` 与 `("meta", "TEXT NOT NULL DEFAULT '{}'")` —— SQLite 硬性要求 `NOT NULL` 列必须带非 NULL 常量默认值,缺默认值会报 `Cannot add a NOT NULL column with default value NULL`;
|
||||
- `_COLUMNS` 追加两项。
|
||||
|
||||
`postgres.py` 同三处:
|
||||
- `_DDL` 追加 `tenant_id TEXT NOT NULL DEFAULT ''` 与 `meta JSONB NOT NULL DEFAULT '{}'::jsonb`;
|
||||
- `_BACKFILL` 追加两条 `ALTER TABLE llm_calls ADD COLUMN ...`(默认值均为非易失常量,PG 11+ 不重写全表);
|
||||
- `_COLUMNS` 追加两项。
|
||||
|
||||
**新列必须排在末尾**(`created_at` 与既有补列之后)——旧表只能 ALTER 追加,新建库若插在前面两条路径的物理列序会分叉(`sqlite.py:53` 既有注释)。
|
||||
|
||||
**验收标准**: 两个后端的 `_COLUMNS` 逐字同名同序;新建库与旧表补列后列集合一致。
|
||||
|
||||
**测试要求**(先失败后通过): 扩展现有列序断言测试,断言两后端 `_COLUMNS` 相等且末两项为 `("tenant_id", "meta")`;`test_ports.py` 断言 `record_llm_call` 的参数集合含新两项且**无默认值**(用 `inspect.signature` 实测,不凭记忆)。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: give the telemetry table a tenant column and a meta container`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Emitter 透传与归一化
|
||||
|
||||
**文件**: `src/polygateway/middleware/telemetry.py`(修改)、`tests/unit/test_telemetry.py`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
`_record` 增两个形参,**位置排在现有末参 `reasoning_tokens` 之后**(它是 keyword-only,顺序不影响调用,但与 `_COLUMNS`/端口的追加位置保持一致便于逐行比对):
|
||||
|
||||
```python
|
||||
async def _record(
|
||||
self, *, ...,
|
||||
reasoning_tokens: int | None,
|
||||
tenant_id: str | None, # 新增: 未归一化,None 合法
|
||||
meta: Mapping[str, Any], # 新增: 未序列化,空 dict 合法
|
||||
) -> None:
|
||||
```
|
||||
|
||||
在传给 recorder 前归一化: `tenant_id or ''`;`json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)`,空 dict 直接用字面量 `'{}'`。
|
||||
|
||||
`allow_nan=False` 是第二道闸(主防线是 Task 1 的入口校验)——`json.dumps` 默认把 `nan` 写成 `NaN` 字面量,那不是合法 JSON,PG 的 JSONB 会拒收,失败会被 emitter 的降级 try 吞成 warning,即把调用方的输入错误变成静默丢遥测。
|
||||
|
||||
三个 emit 入口统一从 `request.tenant_id` / `request.meta` 读取,不各自组装(遥测调用点收敛铁律):
|
||||
- `emit_attempt`、`emit_terminal_failure`: 直接读 `request`;
|
||||
- `emit_cache_hit`: **同样读 `request` 而非缓存中的历史响应**——维度是"本次调用由谁发起",不是历史那次。
|
||||
|
||||
**验收标准**: 三条路径写出的行都带维度;`tenant_id=None` 落 `''`;`meta={}` 落 `'{}'` 而非 NULL。
|
||||
|
||||
**测试要求**(先失败后通过): 用 fake recorder 断言三个入口各自收到的 `tenant_id`/`meta` 值;`emit_cache_hit` 单独一个用例——构造"请求带租户 A、缓存中的历史响应属于租户 B"的场景,断言落库的是 **A**(这是最容易实现反的一处);`meta` 序列化后键有序(`sort_keys=True`,便于跨行比对)。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: carry caller dimensions through the single telemetry helper`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `chat()` 公共入口
|
||||
|
||||
**文件**: `src/polygateway/client.py`(修改)、`tests/unit/test_client.py`(修改)
|
||||
|
||||
**行为**: `chat()` 签名末尾增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`(带默认值的 keyword-only,签名冻结承诺不破)。在既有 `validate_request_overlay` 调用旁调 `validate_caller_dimensions(..., origin="chat(tenant_id=..., meta=...)")`,把返回值填进 `ChatRequest`。校验必须在**进洋葱之前**——洋葱内的一切失败都会被遥测层降级成 warning,校验放里面等于没有校验。
|
||||
|
||||
**验收标准**: 不传两参数时行为与改动前逐字一致(既有调用点零改动);传入非法值时 `chat()` 抛 `ValueError` 且**未产生任何遥测行**。
|
||||
|
||||
**测试要求**(先失败后通过): 端到端——`chat(..., tenant_id="t1", meta={"batch": "b-42"})` 后 fake recorder 收到的行带这两个值;非法 `meta` 抛 `ValueError` 且 recorder **零调用**(断言"校验早于遥测",这是 §4.2 的核心承诺);不传参数时 recorder 收到 `''` 与 `'{}'`。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: let chat() take a tenant and caller-defined dimensions`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `embed()` 链路透传
|
||||
|
||||
**文件**: `src/polygateway/embedding.py`(修改)、`tests/unit/test_embedding.py`(修改)
|
||||
|
||||
**行为**: `embed()` 增两个 keyword-only 参数并在入口校验(`origin="embed(tenant_id=..., meta=...)"`),沿 `_embed_batch()` → `_attempt()` → `_emit()` **逐层透传**,在 `_emit()`(`embedding.py:360`)构造 `ChatRequest` 时填入。
|
||||
|
||||
该链路已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数。**不顺手把它们收成值对象**——那会改动 embedding 全部内部签名,属任务外重构。本次只做加法。
|
||||
|
||||
**验收标准**: 多批(`texts` 长度 > `batch_size`)时**每一批的行都带同一份维度**——维度属于本次 `embed()` 调用,不随批次变化。
|
||||
|
||||
**测试要求**(先失败后通过): 单批与多批各一个用例,断言 fake recorder 收到的**每一行**都带维度(多批用例要断言行数 > 1 且全部一致,否则"只有第一批带维度"的实现会漏网);非法值抛 `ValueError` 且零遥测。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_embedding.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: carry caller dimensions down the embedding chain`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: OCR 链路透传
|
||||
|
||||
**文件**: `src/polygateway/ocr.py`(修改)、`tests/unit/test_ocr_client.py`(修改)
|
||||
|
||||
**行为**: `recognize_text()` 与 `parse_layout()` 各增两个 keyword-only 参数并在入口校验(`origin` 分别标明方法名),沿 `_call()` → `_attempt()` → `_emit()` 透传,在 `_emit()`(`ocr.py:398`)构造 `ChatRequest` 时填入。结构与 Task 5 同构。
|
||||
|
||||
**验收标准**: 两个公共方法都覆盖(只改一个即漏)。
|
||||
|
||||
**测试要求**(先失败后通过): 两个方法各一个用例,断言遥测行带维度;非法值抛 `ValueError` 且零遥测。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ocr_client.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: carry caller dimensions through the OCR chain`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 真实后端集成验收
|
||||
|
||||
**文件**: `tests/integration/test_postgres_telemetry.py`(修改)、`tests/unit/test_telemetry.py`(补 SQLite 真实文件用例)
|
||||
|
||||
**行为**: 覆盖三件事,每件两个后端各测一遍。
|
||||
|
||||
1. **新建库**: 表列齐全,写入后读回维度一致。
|
||||
2. **旧表补列(不可逆性的机械化验收)**: 手工建一张 **22 列的旧表**并插入一行,再用当前 recorder 打开它 → 补列成功、新行写入成功、**老行的 `tenant_id` 读出为空串而非 NULL**。这条直接对应 issue 的核心论点(先启用后加列,老行归属无法还原);断言"是空串"而非"是 NULL",因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞。
|
||||
3. **补列失败的降级方向**: 补列失败时逐行降级丢弃而非判死(沿用 issue #9 既有测试形态,不新造机制)。**两端的失败构造方式不同,不可笼统写"各测一遍"**:
|
||||
- **Postgres**: 用只有 `SELECT, INSERT ON llm_calls` 权限的角色连接——`ALTER TABLE` 的 ownership 检查早于 `IF NOT EXISTS` 的存在性判断,故必然失败。断言: 记 warning、`_failed` **未**置位、后续 INSERT 仍尝试。
|
||||
- **SQLite**: 无角色权限模型,等价构造是**文件只读**(`chmod 444` 或以 `file:...?mode=ro` 打开)。但只读库连 INSERT 也做不了,故此处只断言"补列失败不清空 `self._conn`、不抛出 `__init__`"(即 `sqlite.py:112` 那条既有纪律),**不断言"写入仍成功"**——那在只读库上本就不可能。
|
||||
|
||||
**验收标准**: PG 与 SQLite 行为对称;补列走既有 `_BACKFILL`,不新增 DDL 路径。
|
||||
|
||||
**测试要求**(先失败后通过): 上述三项即测试本体,**同样适用红绿证据门**——先写出断言看它因缺列/缺维度而失败,再实现至通过,保留失败输出。Postgres 用真实实例(CLAUDE.md §4.6: Redis/PG 相关测试不 mock)。
|
||||
|
||||
**验证命令**:
|
||||
`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS
|
||||
`conda run -n PolyGateway pytest tests/ -q` → 全套件 PASS(**命令末尾不接管道**,否则退出码失真)
|
||||
|
||||
- [ ] 提交点: `test: prove old telemetry tables gain the tenant column safely`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 文档同步
|
||||
|
||||
**文件**: `README.md`、`CHANGELOG.md`、Gitea wiki 的 `指南-遥测与成本` 与 `参考-公共API` 两页
|
||||
|
||||
按 `docs-convention.md` §2「新公共 API / 新能力」行,须同步「对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG」。本次**扩写** `指南-遥测与成本`(新增"多租户与自定义维度"一节)而非新建页,故**侧边栏与 `Home.md` 不动**——新增页才需要同步导航。若执行时判断内容多到该独立成页(如 `指南-多租户`),则必须一并改 `_Sidebar.md` 与 `Home.md` 分流表。
|
||||
|
||||
**行为**:
|
||||
|
||||
- **CHANGELOG**: 新增"未发布"段,写清新增两列、两个新参数(三条链路)、校验规则与上限数值、**以及库不建索引/不启用 RLS 的边界**。
|
||||
- **README**: 能力表补调用方维度;数字型断言若涉及遥测字段数,用 `inspect.signature` **实测**后再写(发布流程 §4.4.1 第 1 步的教训)。
|
||||
- **`参考-公共API`**: 更新 `chat()`、`embed()`(以及 Task 6 若执行则含 OCR 两方法)的签名——该页纪律是"以源码实测为准",改前先对照实际签名,不凭计划文本写。
|
||||
- **`指南-遥测与成本`**: 新增一节"多租户与自定义维度",含设计 §4.5 定稿的 RLS 模板(`ENABLE` + `FORCE` + `USING`/`WITH CHECK` 双写 + `NULLIF(current_setting(..., true), '')`)与复合索引 `(tenant_id, created_at)`,并写明三个陷阱: 表属主默认豁免 RLS;租户上下文必须在**显式事务内**用 `set_config(..., true)`(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG 只发 warning 不报错,表现为 fail-closed 到零行);只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行。
|
||||
- wiki 必须明确: **执行这些 DDL 是下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个普通列,没有数据库层强制。
|
||||
|
||||
**验收标准**: 三处文档对"库做什么、下游做什么"的表述一致,不出现"库自动启用 RLS"之类的措辞。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway make lint` → PASS;人工核对 wiki 页面渲染。
|
||||
|
||||
- [ ] 提交点: `docs: document caller dimensions and the RLS template`
|
||||
|
||||
---
|
||||
|
||||
## 全局验收
|
||||
|
||||
- [ ] `conda run -n PolyGateway make lint` → PASS(含 import-linter 依赖契约)
|
||||
- [ ] `conda run -n PolyGateway make test` → PASS,覆盖率不低于改动前
|
||||
- [ ] 派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 Phase 2 合并前硬门)
|
||||
- [ ] 三条链路各自的"传入维度 → 落库"证据齐全(chat / embed / OCR)
|
||||
- [ ] 旧表补列后老行读出空串的证据(issue 核心论点的验收)
|
||||
@@ -0,0 +1,180 @@
|
||||
# 实现计划: 遥测正文体量、保留期与访问控制(issue #12)
|
||||
|
||||
- **目标**: 让下游第一次有手段控制遥测表里存什么、留多久、谁能读——正文可配置截断,保留期与访问控制以可执行模板 + 独立脚本交付,库本体不持有 DELETE/DROP 权限。
|
||||
- **方案概述**: 新增 `PGW_TELEMETRY_TEXT_CAP`(缺省 `None` 即不截断),截断只发生在 `TelemetryEmitter._record` 这个唯一遥测调用点,按**每条文本**切而非切整串 JSON;保留期走 README 的 RANGE 分区 + `pg_partman` 模板与 `tools/telemetry_retention.py`(默认 dry-run);访问控制是纯文档的三角色模板 + `REVOKE UPDATE, DELETE`。README 的模板 SQL 有真实 PG 集成测试逐条执行。
|
||||
- **依据设计**: `research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md`(已人类审批 2026-08-19)。
|
||||
- **涉及技术**: Python 3.11+、argparse、sqlite3、asyncpg、pytest、PostgreSQL 分区与 RLS。
|
||||
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**。
|
||||
- **前置依赖**: **issue #13 的计划须先合并,本分支必须从合并后的 main 开出**(不可两条分支并行改再靠自动合并)。两者都动 `config.py:118-137` 的字段列表、`config.py:423-451` 的 `_load_pgw` 返回键与 `client.py:396-407` 的装配,字段顺序与返回键极易冲突且冲突后是静默的。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/middleware/telemetry.py` | 修改 | `_cap_text`/`_cap_messages`;`TelemetryEmitter` 增 `text_cap` 必填 |
|
||||
| `src/polygateway/config.py` | 修改 | `PGW_TELEMETRY_TEXT_CAP` 解析与校验;`GatewaySettings` 增 `telemetry_text_cap` |
|
||||
| `src/polygateway/client.py` | 修改 | `client.py:149` 的 emitter 构造点传参 |
|
||||
| `src/polygateway/embedding.py` | 修改 | `embedding.py:131` 同上(既有 200 上限保留不动) |
|
||||
| `src/polygateway/ocr.py` | 修改 | `ocr.py:130` 同上(既有 200 上限保留不动) |
|
||||
| `tools/telemetry_retention.py` | **创建** | 独立清理脚本,不被库 import |
|
||||
| `tests/unit/test_telemetry.py` | 修改 | 截断行为、三链路覆盖 |
|
||||
| `tests/unit/test_cache.py` | 修改 | **红线**: 缓存 key 不受 cap 影响 |
|
||||
| `tests/unit/test_config.py` | 修改 | 配置校验 |
|
||||
| `tests/unit/test_retention_tool.py` | **创建** | 脚本 dry-run/apply(经 subprocess) |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | README 模板 SQL 逐条执行 |
|
||||
| `README.md`、`CHANGELOG.md`、`.env.example` | 修改 | 生产部署模板、推荐配置组合、配置键 |
|
||||
|
||||
**依赖顺序**: Task 1 → Task 2 → (Task 3 ‖ Task 4) → Task 5。
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
截断函数(`middleware/telemetry.py` 模块级私有,紧邻 `_canonical_meta_json`):
|
||||
|
||||
```python
|
||||
def _cap_text(text: str, cap: int | None) -> str:
|
||||
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
||||
|
||||
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
|
||||
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
|
||||
|
||||
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
|
||||
"""
|
||||
```
|
||||
|
||||
`TelemetryEmitter` 构造签名(`text_cap` **keyword-only 必填**,无默认值):
|
||||
|
||||
```python
|
||||
class TelemetryEmitter:
|
||||
def __init__(
|
||||
self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None,
|
||||
text_cap: int | None,
|
||||
) -> None: ...
|
||||
```
|
||||
|
||||
三个公共 Client 的 `__init__` 各增 keyword-only `text_cap`,**带默认值 `None`**(与既有全部可选参数同款,非破坏性):
|
||||
|
||||
```python
|
||||
class GatewayClient: # client.py:130 起的构造签名
|
||||
def __init__(self, *, ..., text_cap: int | None = None) -> None: ...
|
||||
# EmbeddingClient / OcrClient 同款
|
||||
```
|
||||
|
||||
**为什么 emitter 必填而 Client 带默认**: `TelemetryEmitter` 是库内部类,唯一构造者是这三个 Client,必填能保证没有一处漏传;而三个 Client 是**公共装配路**(下游可直接构造并注入自己的 recorder),给它们加必填参数会破坏既有调用点,且默认 `None` 恰好等于全局缺省行为(不截断)。少了这一层,直接构造的下游要么撞 `TypeError`,要么永远没法启用 cap。
|
||||
|
||||
`GatewaySettings` 新字段(无默认值),排在 `telemetry_auto_migrate` 之后:
|
||||
|
||||
```python
|
||||
telemetry_text_cap: int | None
|
||||
```
|
||||
|
||||
`tools/telemetry_retention.py` 的 CLI 契约:
|
||||
|
||||
```text
|
||||
--backend sqlite|postgres 必填
|
||||
--path PATH | --dsn DSN 按 backend 二选一,必填
|
||||
--older-than-days N 必填,N >= 0
|
||||
--apply 缺省不带即 dry-run(只统计不删)
|
||||
--batch-size N 仅 postgres,缺省 1000
|
||||
--vacuum 仅 sqlite,须与 --apply 同时给
|
||||
退出码: 0 正常;1 参数错误;2 连接/权限失败;3 目标是分区表(PG,提示改用 DROP PARTITION)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 正文截断与 emitter 参数
|
||||
|
||||
- [ ] **文件**: `src/polygateway/middleware/telemetry.py`、`src/polygateway/client.py`、`src/polygateway/embedding.py`、`src/polygateway/ocr.py`;`tests/unit/test_telemetry.py`、`tests/unit/test_cache.py`。
|
||||
- **行为**:
|
||||
- 按上文签名实现两个截断函数;`_record` 内在 `digest_messages(...)` 之后、`json.dumps(...)` 之前调用 `_cap_messages`,并对 `response_text`、`thinking` 调用 `_cap_text`。
|
||||
- `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149`、`embedding.py:131`、`ocr.py:130`)同步传参;**三个 Client 的 `__init__` 各增带默认值的 `text_cap` 参数**(见上,否则直接构造路要么 `TypeError` 要么永远用不上 cap);测试内十余处 emitter 构造点一并补齐。
|
||||
- **`digest_messages` 一个字节都不改**(它是缓存 key 与遥测共用的函数,`middleware/cache.py:31`)。
|
||||
- **`_cap_messages` 必须产出新对象,严禁就地修改**。这是本任务最容易踩的坑: `digest_messages` 对 content 不是 list 的消息是**原样 append 同一个 dict 对象**(`cache.py:43`),即遥测拿到的 dict 与调用方传入的、以及缓存 key 计算用的是**同一份**。就地改它会同时污染调用方的 `messages`、后续重试尝试的请求体与缓存写入的 key,且全程无任何报错。多模态 part 同理(`_digest_part` 对非 image_url 的 part 也是原样返回)。
|
||||
- `embedding.py:73` 与 `ocr.py:73` 各自的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系。
|
||||
- **验收**:
|
||||
- `cap=None` → 落库正文与今天逐字节相同。
|
||||
- `cap=N` → 每条 content 被切且整串 `messages` JSON 仍可 `json.loads`;标记含省略字数。
|
||||
- 多模态消息: `type == "text"` 的 part 被切,`image_url` 的 sha256 摘要原样不动。
|
||||
- 非字符串 content(如 `123`、`None`、嵌套 dict)不抛异常。
|
||||
- `response`/`thinking` 同样受 cap。
|
||||
- OCR 与 embed 两条链路的行同样受 cap(它们共用 `_record`)。
|
||||
- **测试**:
|
||||
- 上述六条各一例(`tests/unit/test_telemetry.py`)。
|
||||
- **红线用例之一**(`tests/unit/test_cache.py`): 取一组含长文本的 messages,先算一次 `build_cache_key(...)`,再经 `cap=8` 的 emitter 走一遍遥测,然后**用同一个 messages 对象**再算一次 key —— 两次输出必须逐字节相同。这测的是"截断没有就地改掉调用方的对象",而不只是"截断函数是纯的"。
|
||||
- **红线用例之二**(`tests/unit/test_telemetry.py`): `cap=8` 走一遍遥测后,断言传入的 `messages` 结构与内容**完全未变**(含嵌套的多模态 part),落库的那份则已被截断。
|
||||
- 先失败证据: 参数不存在时 `TypeError`;截断未实现时 `cap=8` 的用例读回全文;就地修改的实现会让两条红线用例直接失败(先写一版就地改的实现跑一遍,把失败输出留档,证明红线用例真的能抓住它)。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_cache.py tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → PASS。
|
||||
- **提交**: `feat: cap telemetry bodies at a configurable length`
|
||||
|
||||
## Task 2: 配置与装配
|
||||
|
||||
- [ ] **文件**: `src/polygateway/config.py`、`.env.example`;`tests/unit/test_config.py`。
|
||||
- **行为**: `_load_pgw` 解析 `PGW_TELEMETRY_TEXT_CAP`(未设 → `None`;设了则转 `int`);`GatewaySettings` 增 `telemetry_text_cap: int | None`,`_validate_telemetry` 内校验 `<= 0` 报 `ValueError`(错误信息含键名);`client.py` 把它传给 emitter;`.env.example` 加注释行,写明缺省不截断及其取舍(截断后遥测不再是审计证据、无法复现重放)。
|
||||
- **验收**: 未设 → `None`;`"0"` 与 `"-1"` 报 `ValueError`;非整数字符串报 `ValueError`;合法值透传到 emitter 并生效(端到端一例)。
|
||||
- **测试**: 上述四条各一例。先失败证据: 字段不存在时 `AttributeError`。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
|
||||
- **提交**: `feat: wire the telemetry text cap through settings`
|
||||
|
||||
## Task 3: 保留期脚本
|
||||
|
||||
- [ ] **文件**: 创建 `tools/telemetry_retention.py`;创建 `tests/unit/test_retention_tool.py`。
|
||||
- **行为**: 按上文 CLI 契约实现。
|
||||
- **缺省 dry-run**: 不带 `--apply` 时只统计并打印将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布,一行不删。
|
||||
- SQLite: `DELETE FROM llm_calls WHERE created_at < ?`;`--vacuum` 才执行 `VACUUM`(它重写整库,不得默认)。
|
||||
- PG: 分批 DELETE(每批一个事务,`--batch-size` 控制),避免长事务与锁膨胀;**先探测目标是否为分区表**(`pg_partitioned_table`),是则打印"改用 DETACH/DROP PARTITION"并以退出码 3 结束,不执行 DELETE。
|
||||
- 脚本不被库 import(`tools/` 规则);缺 `asyncpg` 时明确报错退出码 2,**不静默降级**(这是运维工具不是库路径)。
|
||||
- 文档串: 帮助文本写明"用维护角色跑,不要用应用账号(应用账号已被 REVOKE DELETE)"。
|
||||
- **验收**: 见测试。
|
||||
- **测试**(经 `subprocess.run([sys.executable, "tools/telemetry_retention.py", ...])`,真实临时 SQLite):
|
||||
- dry-run 后行数不变,stdout 含将删行数与时间范围。
|
||||
- `--apply` 后仅超期行被删,未超期行完好。
|
||||
- `--older-than-days 0` 的边界(删到"此刻之前")行为明确且与文档一致。
|
||||
- 参数缺失/冲突(如 backend=sqlite 却给 `--dsn`)退出码 1。
|
||||
- `--vacuum` 不带 `--apply` 时退出码 1。
|
||||
- **PG 分支必须自带证据**(集成,真实 PG,临时 schema 隔离): ① 临时 schema 内建**分区表**,脚本探测到后打印改用 DETACH/DROP PARTITION 的提示并以退出码 **3** 结束、**一行都没删**; ② 临时 schema 内建普通表灌入跨日期的行,`--apply --batch-size 2` 后仅超期行被删且分多批提交; ③ 缺 `asyncpg` 时退出码 **2**——用一个只含 `raise ImportError` 的临时 `asyncpg.py` 目录挂进 `PYTHONPATH` 跑 subprocess 来构造该场景,不要靠 monkeypatch(脚本走的是子进程)。
|
||||
- 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`;PG 三例在脚本只实现 SQLite 分支时分别以"未知 backend"或退出码 1 失败。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v` → PASS(PG 三例须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算通过)。
|
||||
- **提交**: `feat: add a retention script downstreams can schedule`
|
||||
|
||||
## Task 4: 生产部署模板与其机械化验收
|
||||
|
||||
- [ ] **文件**: `README.md`;`tests/integration/test_postgres_telemetry.py`。
|
||||
- **行为**: README 现有多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节,包含:
|
||||
- **三角色**: `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束)。
|
||||
- **不可变性**: `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底明确标注"只防误操作,不防恶意(属主可 disable)"。
|
||||
- **分区**: `PARTITION BY RANGE (created_at)`、主键 `(call_id, created_at)`、`pg_partman` retention;并写明**分区部署下幂等键实际是 `(call_id, created_at)`**,`emit_cache_hit` 复用历史 `call_id`,故缓存命中行在普通表上第二次起会被吞掉、在分区表上每次都落一行——按 `cache_hit` 统计的下游必须知道。
|
||||
- **库需要的最小权限**: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。
|
||||
- **合规下游推荐配置**: 一段可直接照抄的组合(`PGW_TELEMETRY_TEXT_CAP` + 分区 retention + 三角色),不把三件事散着让下游自己拼。
|
||||
- **截断覆盖面的诚实声明**(设计 §5.2,不得省): cap 作用于消息的 `content` 文本与多模态 part 中 `type == "text"` 的 `text`,与 `digest_messages` 的处理面一致;调用方放进 `tool_calls.function.arguments` 等其他字段的内容**不在覆盖范围内**。漏写这条,下游会以为开了 cap 就没有全文残留,合规判断直接出错。
|
||||
- **SQLite 侧的保留期**(设计 §6,不得省): 给按天/按实验轮转库文件的建议——这是 VT / CHSAnalyzer / dissect 三家现成的形态,比对本地文件跑 DELETE + VACUUM 更省事也更安全;`tools/telemetry_retention.py` 的 SQLite 分支是给"已经攒成一个大库"的存量场景兜底,不是推荐路径。
|
||||
- 每个代码块 ≤15 行(输出规范),超长的拆成相邻多块。
|
||||
- **验收**: 模板 SQL 在真实 PG 上逐条可执行;README 里的行为描述与实测一致。
|
||||
- **测试**(集成,真实 PG,**新建自己的 fixture**,手法照搬 `least_privilege_dsn` 的临时 schema + 临时角色 + teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言:
|
||||
- `app` 角色能 INSERT、**不能** DELETE(报权限错)。
|
||||
- `report` 角色能读、不能写。
|
||||
- 未设 `app.tenant_id` 时查询为**零行**(fail-closed),设了则只看到本租户的行。
|
||||
- 分区表上写入成功且落进当月分区。
|
||||
- 先失败证据: 模板尚未写进 README 时该测试无 SQL 可读、直接失败。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 且账号有 `CREATEROLE` 的环境实跑;无权限时 skip,**skip 不算通过**)。
|
||||
- **提交**: `docs: ship a production deployment template with its own test`
|
||||
|
||||
## Task 5: CHANGELOG 与 wiki
|
||||
|
||||
- [ ] **文件**: `CHANGELOG.md`、Gitea wiki(`指南-遥测与成本`/`参考-配置键`/`参考-公共API`)、`research-wiki/ARCHITECTURE.md`。
|
||||
- **行为**: CHANGELOG 写明新配置键、缺省不截断的取舍、保留期脚本与部署模板的位置;ARCHITECTURE 的 D15(库对下游库的权限边界)若 issue #13 已建,此处只补 #12 的一面;wiki 三页按 docs-convention §2 同步。
|
||||
- **验收**: 版本条目里能一眼看出"默认行为未变,新增的是手段";wiki 与 README 不重复叙述(深度内容只放指针)。
|
||||
- **测试**: 无自动化测试。
|
||||
- **验证**: `conda run -n PolyGateway make ci` → 全绿。
|
||||
- **提交**: `docs: record the retention boundary and its knobs`
|
||||
|
||||
---
|
||||
|
||||
## 完成判据
|
||||
|
||||
1. 五个任务的提交点全部落地,`make ci` 全绿。
|
||||
2. 每条行为变更能出示先失败后通过的测试证据;Task 1 的缓存 key 红线用例与 Task 4 的模板 SQL 用例必须在本会话内实跑并留下输出。
|
||||
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
|
||||
4. 与 issue #13 合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步——**README 必须在构建之前定稿**(sdist 会把当时那份固化进包)。
|
||||
@@ -0,0 +1,177 @@
|
||||
# 实现计划: 遥测 schema 档位与裁剪写入(issue #13)
|
||||
|
||||
- **目标**: 让库不再默认在下游 Postgres 生产表上发不受控 DDL——探测到缺列时打印 SQL 并按现有列降级写入,而不是自己 ALTER。
|
||||
- **方案概述**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设按后端派生: SQLite→auto、PG→manual)。manual 档探测真实列集合后不发 DDL,warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT。DDL/列序/补列语句收敛进新的 `telemetry/schema.py` 单一事实源,新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取。PG 写入的冲突目标同时去绑定,为 issue #12 的分区方案让路。
|
||||
- **依据设计**: `research-wiki/designs/2026-08-19-issue13-schema-mode-design.md`(已人类审批 2026-08-19)。
|
||||
- **涉及技术**: Python 3.11+、sqlite3、asyncpg、pytest、frozen dataclass。
|
||||
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**(改的是本库自有的 issue #3/#9 收口逻辑)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/telemetry/schema.py` | **创建** | 24 列列序、两端 DDL 与补列语句、`insert_sql()`、公共 `telemetry_schema_sql()` |
|
||||
| `src/polygateway/telemetry/sqlite.py` | 修改 | 常量改从 schema.py 取;`auto_migrate` 必填;manual 档裁剪写入 |
|
||||
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上;`ON CONFLICT` 去冲突目标 |
|
||||
| `src/polygateway/config.py` | 修改 | 解析 `PGW_TELEMETRY_SCHEMA_MODE` 并派生;`GatewaySettings` 增 `telemetry_auto_migrate` |
|
||||
| `src/polygateway/client.py` | 修改 | `_build_telemetry` 透传 `auto_migrate` |
|
||||
| `src/polygateway/__init__.py` | 修改 | 导出 `telemetry_schema_sql` |
|
||||
| `tests/unit/test_telemetry.py` | 修改 | 两档行为、裁剪写入、warning 内容 |
|
||||
| `tests/unit/test_config.py` | 修改 | 派生规则与值域校验 |
|
||||
| `tests/unit/test_package.py` | 修改 | 公共导出面 |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG: manual 旧表、最小权限、无目标幂等、分区表 |
|
||||
| `.env.example`、`README.md`、`CHANGELOG.md` | 修改 | 配置键、Expand/Contract 承诺、破坏性说明 |
|
||||
|
||||
**依赖顺序**: Task 1 → (Task 2 ‖ Task 3) → Task 4 → Task 5 → Task 6 → Task 7。
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
`schema.py` 的模块级常量(名称固定,两个 recorder 与公共函数共用):
|
||||
|
||||
```python
|
||||
COLUMNS: tuple[str, ...] # 24 个 INSERT 字段(call_id 起、meta 止)
|
||||
SQLITE_DDL: str # CREATE TABLE IF NOT EXISTS(全量列)
|
||||
PG_DDL: str
|
||||
SQLITE_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, "TEXT NOT NULL DEFAULT ''")
|
||||
PG_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, 不带 IF NOT EXISTS 的 ALTER)
|
||||
```
|
||||
|
||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它有 `DEFAULT now()`/`datetime('now')`,库从不显式写它)。**物理表列 = 24 + `created_at` = 25**;issue #11 之前的旧表则是 22 + `created_at` = 23。所有列数断言必须按物理列数写,混用两套口径是本计划最容易写错的地方(现有集成测试的 `_EXPECTED_COLUMNS` 含 `created_at`,可作对照)。
|
||||
|
||||
**库内执行的补列语句与打印给下游的语句是两份,不是一份**: 库内**不用** `ADD COLUMN IF NOT EXISTS`——PG 对它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故库侧一律"先探测后 ALTER"(`postgres.py` 现有注释已记这条实测)。而 `telemetry_schema_sql` 打印给人执行的脚本**必须**带 `IF NOT EXISTS`,否则重复执行即失败,称不上"可直接粘进迁移文件";那条语句由 DBA 在自己选的时机执行,锁风险是他的职责。
|
||||
|
||||
两个语句构造函数:
|
||||
|
||||
```python
|
||||
def insert_sql(backend: str, columns: Sequence[str]) -> str:
|
||||
"""按给定列构造 INSERT;列必须是 COLUMNS 的子集,否则 ValueError。
|
||||
|
||||
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,
|
||||
不校验就等于把外部字符串拼进 SQL。sqlite 用 `?`、postgres 用 `$n`。
|
||||
"""
|
||||
|
||||
def telemetry_schema_sql(backend: str) -> str:
|
||||
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。"""
|
||||
```
|
||||
|
||||
recorder 构造签名(`auto_migrate` **keyword-only 必填**,无默认值):
|
||||
|
||||
```python
|
||||
class SQLiteRecorder:
|
||||
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None: ...
|
||||
|
||||
class PostgresRecorder:
|
||||
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None: ...
|
||||
```
|
||||
|
||||
`GatewaySettings` 新字段(无默认值,与既有全部字段一致),排在 `telemetry_pg_dsn` 之后:
|
||||
|
||||
```python
|
||||
telemetry_auto_migrate: bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 建 `telemetry/schema.py` 单一事实源
|
||||
|
||||
- [ ] **文件**: 创建 `src/polygateway/telemetry/schema.py`;修改 `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;修改 `tests/integration/test_postgres_telemetry.py`(它 `from polygateway.telemetry.postgres import _DDL`,改为从 schema.py 取)。
|
||||
- **行为**: 把 `sqlite.py` 的 `_DDL`/`_BACKFILL_COLUMNS`/`_COLUMNS` 与 `postgres.py` 的 `_DDL`/`_BACKFILL`/`_COLUMNS` 原样搬进 schema.py,按上文命名导出;两个 recorder 改为 import 使用,`_INSERT` 改为在模块加载时调用 `insert_sql(backend, COLUMNS)` 得到(本任务不改变任何行为)。新增 `insert_sql()` 与 `telemetry_schema_sql()`。
|
||||
- **验收**:
|
||||
- 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。
|
||||
- `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。
|
||||
- `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`。
|
||||
- `telemetry_schema_sql` 输出包含全部 24 个列名 + `created_at`,列名出现顺序与建表 DDL 一致;PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`(与库内执行的那份不同,见上);未知 backend 抛 `ValueError`。
|
||||
- **测试**(`tests/unit/test_telemetry.py` 新增 `TestSchemaModule`): 上述四条各一例。先失败证据: schema.py 不存在时 import 失败。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS;`make check` → 通过(**不要用 `make lint`,它带 `ruff --fix` 会改文件、掩盖问题并污染待审 diff**;import-linter 契约不得报新违规: schema.py 只依赖标准库)。
|
||||
- **提交**: `refactor: make the telemetry schema a single source of truth`
|
||||
|
||||
## Task 2: PG 写入去掉冲突目标
|
||||
|
||||
- [ ] **文件**: `src/polygateway/telemetry/schema.py`(PG 分支的 INSERT 尾巴)、`tests/integration/test_postgres_telemetry.py`。
|
||||
- **行为**: PG 的 `ON CONFLICT (call_id) DO NOTHING` 改为 `ON CONFLICT DO NOTHING`。SQLite 的 `INSERT OR IGNORE` 不动(本就无目标)。
|
||||
- **为什么**(设计 §4.6): PostgreSQL 要求分区表的唯一约束必须包含分区键,issue #12 按 `created_at` 分区后主键变成 `(call_id, created_at)`,带目标的语句再也匹配不到约束,遥测在分区部署下全线写不进去。无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
|
||||
- **验收**: 普通表上重复 `call_id` 仍只落一行;主键为 `(call_id, created_at)` 的分区表上写入成功不报错。
|
||||
- **测试**(集成,真实 PG,沿用 `legacy_schema` 同款临时 schema 隔离——**严禁碰共享的 `public.llm_calls`**): 新增两例,① 临时 schema 内建普通表,同 `call_id` 写两次,`COUNT(*) == 1`; ② 临时 schema 内建 `PARTITION BY RANGE (created_at)` 的表 + 一个覆盖当前月的分区 + 主键 `(call_id, created_at)`,写入成功且能读回。先失败证据: 例 ② 在改动前必然抛 `there is no unique or exclusion constraint matching the ON CONFLICT specification`,把该错误信息记进提交说明。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(无 `PGW_TELEMETRY_PG_DSN` 时 skip,**skip 不算通过**,必须在有 DSN 的环境跑一次并留下输出)。
|
||||
- **提交**: `fix: drop the conflict target so partitioned tables can accept writes`
|
||||
|
||||
## Task 3: 两个 recorder 加 `auto_migrate` 与裁剪写入(含 settings 字段与装配透传)
|
||||
|
||||
- [ ] **文件**: `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、**`src/polygateway/config.py`**(只加 `telemetry_auto_migrate` 字段与派生)、**`src/polygateway/client.py`**(`_build_telemetry` 透传);`tests/unit/test_telemetry.py`。
|
||||
- **为什么装配透传必须并进本任务**: `_build_telemetry` 现在调用 `PostgresRecorder(dsn)` / `SQLiteRecorder(path)`,参数一旦必填,不同步改这里整条装配路当场 `TypeError`。签名变更与其唯一调用点必须落在同一次提交,否则该提交点跑不通全套件——每个提交点都必须独立可验证。env 键解析与 `.env.example` 仍留给 Task 4。
|
||||
- **行为**:
|
||||
- 两个 recorder 的 `__init__` 增 keyword-only **必填** `auto_migrate: bool`。
|
||||
- 列探测后计算 `effective = [c for c in COLUMNS if c in existing]`(保序),据此 `self._columns` 与 `self._insert = insert_sql(backend, effective)`;`record_llm_call` 按 `self._columns` 取值。
|
||||
- `auto_migrate=True`: 行为与今天完全一致(先探测后 ALTER、`duplicate column` 视为成功、失败只 warning 不判死),补列成功后 `effective` 为全量。
|
||||
- `auto_migrate=False`: **不发任何 ALTER**;缺列时 warning **一次**,内容须同时包含 ① 逐列点名的缺失列; ② 一句"以下维度不会被记录"; ③ 可直接执行的补列 SQL。
|
||||
- 探测失败: 两档都保守回落到全量 `COLUMNS`(今天的行为),warning。
|
||||
- `call_id` 不在 `effective` 内时 warning 升级措辞(该表不是本库的 `llm_calls`),仍照常尝试写入,库不做二次判定。
|
||||
- PG 侧 `self._columns`/`self._insert` 必须与 `_schema_ready` **在同一处一起赋值**,不得出现"已就绪但语句还是旧的"的窗口。
|
||||
- 建表(`CREATE TABLE`)两档都保留,manual 只管 ALTER(设计 §4.2)。
|
||||
- **验收**: 见测试。
|
||||
- **测试**(单元,真实临时 SQLite 文件,`tmp_path`):
|
||||
- manual + 手工建的旧表(22 个 INSERT 字段 + `created_at` = **23 个物理列**) → 写入成功且能读回、`PRAGMA table_info` 行数**保持 23**(证明未 ALTER)、捕获到的 warning 恰有一条且同时含 `tenant_id`、`meta` 与 `ALTER TABLE`。
|
||||
- auto + 同款旧表 → 物理列数变 **25**(24 个 INSERT 字段 + `created_at`,现状回归)。
|
||||
- manual + 全新库 → 建表且 25 个物理列齐全(建表未被停掉)。
|
||||
- **warning 捕获不能用 `caplog`**: 库用 loguru,它不经标准 logging,`caplog` 一条也抓不到(那条断言会静默永远绿)。照搬 `tests/integration/test_postgres_telemetry.py:436` 的 `captured_warnings` fixture 形态(`logger.add(messages.append, level="WARNING")` + teardown `logger.remove`),在 `tests/unit/test_telemetry.py` 内新建同款 fixture;别命名为 `warnings`,那会遮蔽标准库模块名。
|
||||
- 缺 `call_id` 的畸形表 → warning 升级措辞,不抛异常。
|
||||
- 先失败证据: 新参数不存在时 `TypeError`;裁剪未实现时 manual 旧表用例因 `no column named tenant_id` 全行丢弃而读不回。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS。
|
||||
- **提交**: `feat: gate the automatic ALTER behind an explicit mode`
|
||||
|
||||
## Task 4: 配置派生与装配
|
||||
|
||||
- [ ] **文件**: `src/polygateway/config.py`、`.env.example`;`tests/unit/test_config.py`。(`GatewaySettings` 字段与 `client.py` 透传已在 Task 3 落地;本任务只补 env 键解析、派生规则与模板注释。)
|
||||
- **行为**:
|
||||
- `config.py` 增 `_SCHEMA_MODES = frozenset({"auto", "manual"})`;`_load_pgw` 内: 键未设 → `auto_migrate = telemetry_backend == "sqlite"`;键已设 → 经 `_load_choice` 校验后 `== "auto"`。**派生只写在这一处**。
|
||||
- `GatewaySettings` 增 `telemetry_auto_migrate: bool`(无默认值),`telemetry_backend == "none"` 时恒 `False`。
|
||||
- `.env.example` 在 `PGW_TELEMETRY_BACKEND` 附近加注释行,写明三态与两端缺省的不对称及理由。
|
||||
- **验收**: 未设键 → sqlite `True` / postgres `False` / none `False`;显式 `manual` 让 sqlite 也变 `False`,显式 `auto` 让 postgres 也变 `True`;非法值报 `ValueError` 且错误信息含键名。
|
||||
- **测试**(`tests/unit/test_config.py`): 上述五条各一例。先失败证据: 字段不存在时 `AttributeError`。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
|
||||
- **提交**: `feat: derive the schema mode from the telemetry backend`
|
||||
|
||||
## Task 5: 公共导出
|
||||
|
||||
- [ ] **文件**: `src/polygateway/__init__.py`、`tests/unit/test_package.py`。
|
||||
- **行为**: `telemetry_schema_sql` 加入顶层导出与 `__all__`(按字母序插入)。
|
||||
- **验收**: `from polygateway import telemetry_schema_sql` 可用;`__all__` 排序未乱;导入顶层包不产生循环导入。
|
||||
- **测试**: 导出面测试加断言(该名在 `__all__` 内且可调用)。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_package.py -v` → PASS。
|
||||
- **提交**: `feat: expose the telemetry schema SQL to downstreams`
|
||||
|
||||
## Task 6: 真实 Postgres 集成验收
|
||||
|
||||
- [ ] **文件**: `tests/integration/test_postgres_telemetry.py`。
|
||||
- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_pre_tenant_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。
|
||||
- **验收**:
|
||||
- manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。
|
||||
- **`least_privilege_pre_tenant_dsn`**(`tests/integration/test_postgres_telemetry.py:496`——缺列旧表 + 只授 `SELECT, INSERT` 的角色)+ manual → 不再出现补列失败的 warning,写入照常且缺的两列不写。**不要用 `least_privilege_dsn`**: 它用完整 DDL 建的是列齐全的表,压根触发不到缺列路径,那条测试会假绿。
|
||||
- **测试**: 即上述两例。先失败证据: 改动前 manual 档不存在,构造 recorder 即 `TypeError`。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算数)。
|
||||
- **提交**: `test: prove manual mode leaves a stale table untouched`
|
||||
|
||||
## Task 7: 文档与承诺
|
||||
|
||||
- [ ] **文件**: `README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`(§7.8)、Gitea wiki(`参考-配置键`/`参考-公共API`/`指南-遥测与成本`)。
|
||||
- **行为**:
|
||||
- README: 新配置键与两端不对称缺省及理由;`telemetry_schema_sql` 用法(≤15 行代码块);**Expand/Contract 承诺**成文——新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`、写入的冲突处理不绑定具体约束。
|
||||
- CHANGELOG: 破坏性三条给"请先读这一条"待遇——① PG 不再自动补列; ② 两个 recorder 新增必填参数; ③ `GatewaySettings` 新增必填字段(影响全量注入装配路)。
|
||||
- ARCHITECTURE §7.8 补一句 schema 单一事实源与冲突目标的变化;并按设计建议新增 **D15**(库对下游库只做 SELECT/INSERT + 可选 CREATE,改结构与删数据交给下游)。
|
||||
- **验收**: README 的 SQL 片段可直接复制执行;CHANGELOG 的破坏性段落在版本条目最前;wiki 三页同步(docs-convention §2 的发版清单)。
|
||||
- **测试**(集成,真实 PG,临时 schema 隔离): README 叫下游执行的就是 `telemetry_schema_sql("postgres")` 的输出,故该输出本身必须有机械化验收——在空的临时 schema 里执行一遍,断言建出的表物理列集合 == `COLUMNS` ∪ `{created_at}`;**再执行一遍,不报错**(这同时验证补列语句带 `IF NOT EXISTS` 的幂等性)。人工核对不构成可重复的回归保护,后续改 README 就会失去它。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS;`make ci` → 全绿。
|
||||
- **提交**: `docs: document the schema mode and the expand-contract promise`
|
||||
|
||||
---
|
||||
|
||||
## 完成判据
|
||||
|
||||
1. 七个任务的提交点全部落地,`make ci` 全绿。
|
||||
2. 每条行为变更能出示先失败后通过的测试证据(Task 2 的 PG 报错原文必须留档)。
|
||||
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
|
||||
4. 本计划与 issue #12 的计划合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步。
|
||||
@@ -0,0 +1,380 @@
|
||||
# 实现计划: 遥测连接池的资源语义与生命周期(issue #15)
|
||||
|
||||
- **设计**: `research-wiki/designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md`(已过 Codex 审 + 人类审)
|
||||
- **涉及技术**: Python 3.12(PEP 695 已就位)、asyncpg 0.31 连接池、`asyncio.timeout`、PG SQLSTATE、frozen dataclass、`@runtime_checkable` Protocol、pytest(含真实 PG 的 integration)
|
||||
- **版号**: 1.3.0(人类已定;**本计划不 bump 版本号**,那是发布清单第 3 步的事)
|
||||
- **状态**: **已实施**(2026-08-24)。T0–T7 全部提交完成,提交表见文末;合并前的三道门(`pytest -m slow`、独立 verifier、整分支审查)见「完成判据」)
|
||||
|
||||
## 目标
|
||||
|
||||
让遥测池的资源占用与真实负载挂钩,把"建池失败 → 整进程永久失遥测"这条路彻底拆掉,并让任何降级都可恢复、可见、可编程。
|
||||
|
||||
## 方案概述
|
||||
|
||||
`min_size=0` 让建池变成零成本动作(实测不触库),连接失败自动落到 `acquire` 那条本来就正确的"丢一行、池自恢复"路径;判死判据从"哪一步失败"改为"失败是什么性质",永久档窄到只剩"DSN 不可解析",其余一律 60s 冷却重试;降级状态升格为共用的一等对象(节流日志 + 只读快照);顺带把"谁建的谁关"统一为全库纪律,让 ARCH §7.7 R5 的显式共享真正可用。
|
||||
|
||||
## 保真校验适用性
|
||||
|
||||
**不适用**。遥测后端无参考实现蓝本(ARCHITECTURE.md §7.8 明记"参考仓无先例: 三项目遥测全 SQLite"),本计划不涉及 `reference/` 迁移。但有两条**同等强度的既有承诺**不得被本次改动破坏,各任务已挂检查点:
|
||||
|
||||
1. issue #13 的"manual 档缺列时裁剪 INSERT 继续写、逐行 warning 暴露"(T5 的 `42703` 例外);
|
||||
2. issue #9 的"表存在就绝不发 DDL"(`to_regclass` 先探测,T4/T5 不得碰这段控制流)。
|
||||
|
||||
## 起点状态(执行前必读)
|
||||
|
||||
- **工作区有未提交改动且在 `main` 上**: Python 3.12 迁移已执行完毕(`pyproject.toml` `requires-python`/`target-version`、`README.md` 两处、`CLAUDE.md` 技术栈、`client.py` 与 `streaming.py` 的 UP047 三处改 PEP 695),conda 环境已重建为 3.12.13 并补装 `build`/`twine`。**T0 的第一件事就是把它们落到分支上**。
|
||||
- **建池路径今天零测试覆盖**: 全 `tests/` 目录对 `create_pool` 与 `_open_pool` 的引用数为 **0**(执行前可自行复核)。现有 PG 用例一律经 `pool=_FakePgPool(...)` 注入,走的是 `_external_pool=True` 分支,**从不经过建池**。这正是 `min_size=10` 潜伏至今的原因,也意味着 T3 要新建这一路的第一个用例。
|
||||
|
||||
## 提交门(每个提交点都受此约束)
|
||||
|
||||
`.claude/scripts/hooks/pre-commit-guard.sh` 在检测到 `git commit` 时**阻塞式**执行: `ruff check src/`(任何问题即阻塞)、`radon cc src -n C`(圈复杂度 ≥ C 即阻塞)、`pytest tests/ --tb=line -q`(任一红即阻塞)。文件 > 200 行只是 warning,不阻塞。
|
||||
|
||||
两条由此而来的硬约束:
|
||||
|
||||
- **不得留红态跨提交**——任务边界必须切在"全绿"处,不能把一个行为拆成"改实现"和"改测试"两次提交。
|
||||
- **圈复杂度是真实风险**: `record_llm_call` 本次要同时接入硬预算、失败分类与 tracker。一旦逼近 C 就必须抽私有方法,**这不算计划外重构**,是提交门的硬要求。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/types.py` | 改 | 新增 `TelemetryStatus` frozen dataclass(与 `SourceStats` 同一先例) |
|
||||
| `src/polygateway/ports.py` | 改 | 新增**独立** `TelemetryStatusProvider` Protocol;`TelemetryRecorder` **一字不动** |
|
||||
| `src/polygateway/telemetry/status.py` | **新建** | `TelemetryStatusTracker`: 降级状态机 + 节流日志 + 快照。两个 recorder 共用,不含任何后端知识 |
|
||||
| `src/polygateway/telemetry/postgres.py` | 改 | 池语义、硬预算、失败三分、冷却降级、有界 `aclose`、接入 tracker |
|
||||
| `src/polygateway/telemetry/sqlite.py` | 改 | **仅**接入 tracker(补上今天缺失的降级 warning);不做 lazy 化与冷却 |
|
||||
| `src/polygateway/config.py` | 改 | 两个新键的加载与校验 |
|
||||
| `src/polygateway/client.py` | 改 | 所有权纪律 + `aclose` helper + `telemetry_status` 出口 |
|
||||
| `src/polygateway/embedding.py`、`ocr.py` | 改 | 同款所有权与出口(三处必须一致) |
|
||||
| `src/polygateway/backends/redis_cache.py` | 改 | 补 `_owns_client` 纪律 |
|
||||
| `tests/unit/test_telemetry.py` | 改 | `_FakePgPool` 改造 + 池语义/预算/分类/冷却/tracker 用例 |
|
||||
| `tests/unit/test_client.py` | 改 | 所有权层用例(三个 client 各钉一次) |
|
||||
| `tests/unit/test_config.py` | 改 | 两个新键的三条装配路 |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 改 | 真实 PG: 连接数计数、降级恢复 |
|
||||
| `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md` | 改 | 配置面、能力表、发布说明、架构决策成文 |
|
||||
|
||||
## 关键接口(跨任务消费,此处定死)
|
||||
|
||||
`types.py` 新增(T2 建立,T4/T5/T6 消费):
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class TelemetryStatus:
|
||||
"""遥测后端的可写状态快照;degraded 期间下游可据此对账(issue #15)。"""
|
||||
degraded: bool
|
||||
fatal: bool # True = 本进程内不可恢复(仅 DSN 不可解析一类)
|
||||
reason: str | None # 降级原因;未降级为 None
|
||||
degraded_for_s: float | None # 已降级时长;未降级为 None
|
||||
dropped_rows: int # 累计丢弃行数(进程生命周期内单调不减)
|
||||
retry_after_s: float | None # 距下次重新准备;fatal 或未降级为 None
|
||||
```
|
||||
|
||||
`ports.py` 新增(T2 建立)——**独立于 `TelemetryRecorder`**,理由见设计 §3.3:
|
||||
|
||||
```python
|
||||
@runtime_checkable
|
||||
class TelemetryStatusProvider(Protocol):
|
||||
"""可自述可写状态的遥测后端;与 TelemetryRecorder 分开是为了不破坏后者的
|
||||
runtime_checkable 语义(加成员会让只实现 record_llm_call 的对象当场不满足协议)。"""
|
||||
|
||||
@property
|
||||
def telemetry_status(self) -> TelemetryStatus: ...
|
||||
```
|
||||
|
||||
`telemetry/status.py` 新增(T2 建立,T4/T5 消费)。`now` 注入以便测试推进假时钟:
|
||||
|
||||
```python
|
||||
class TelemetryStatusTracker:
|
||||
def __init__(self, *, backend: str, now: Callable[[], float] = time.monotonic) -> None: ...
|
||||
def enter_degraded(self, reason: str, *, fatal: bool, cooldown_s: float | None) -> None: ...
|
||||
def recover(self) -> None: ...
|
||||
def record_drop(self, reason: str) -> None: ...
|
||||
def should_retry(self) -> bool: ... # fatal→False;冷却未到→False;到期→True
|
||||
def snapshot(self) -> TelemetryStatus: ...
|
||||
```
|
||||
|
||||
`PostgresRecorder.__init__` 新签名(T3 落地;`pool_max`/`write_timeout_s` keyword-only **必填**,与 `auto_migrate` 同一纪律——缺省只写在 config 一处):
|
||||
|
||||
```python
|
||||
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool,
|
||||
pool_max: int, write_timeout_s: float,
|
||||
now: Callable[[], float] = time.monotonic) -> None: ...
|
||||
```
|
||||
|
||||
`GatewaySettings` 新字段与 env 键(T3 落地):
|
||||
|
||||
| 字段 | env 键 | 缺省 | 校验(落 `_validate_telemetry`) |
|
||||
|---|---|---|---|
|
||||
| `telemetry_pg_pool_max: int` | `PGW_TELEMETRY_PG_POOL_MAX` | 4 | `>= 1`,否则 ValueError 点出字段名与键名 |
|
||||
| `telemetry_pg_write_timeout_s: float` | `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | 5.0 | `> 0`,同上 |
|
||||
|
||||
失败三分(T5 落地,`postgres.py` 模块级私有函数,全库唯一一处 PG 失败分类):
|
||||
|
||||
```python
|
||||
_FATAL = "fatal" # 配置级致命 → 永久 no-op + 一条 error
|
||||
_UNAVAILABLE = "unavailable" # 环境级 → 60s 冷却降级
|
||||
_ROW = "row" # 行级 → 逐条 warning 丢弃
|
||||
|
||||
def _classify_failure(exc: BaseException) -> str: ...
|
||||
```
|
||||
|
||||
判据(设计 §3.2,两句): ①致命 = 原因完全在进程内部且不可变;②行级 vs 环境级看失败与**这一行的数据**有没有关系。落到具体码:
|
||||
|
||||
| 归档 | 覆盖 |
|
||||
|---|---|
|
||||
| `_FATAL` | `asyncpg.ClientConfigurationError`;`create_pool` 抛的 `ValueError`/`TypeError` |
|
||||
| `_UNAVAILABLE` | SQLSTATE 前两位 ∈ {`08`,`53`,`57`,`28`,`3D`} + 具体码 `42501`、`42P01`;`OSError`/`ConnectionError`/`TimeoutError`/其余 `InterfaceError` |
|
||||
| `_ROW` | 其余 `PostgresError`(`22`/`23` 等)+ **具名例外 `42703`**(缺列,由 issue #13 承诺定死) |
|
||||
|
||||
冷却期为模块级常量 `_DEGRADE_COOLDOWN_S = 60.0`(不暴露配置,设计 §3.5)。
|
||||
|
||||
## 任务清单
|
||||
|
||||
### T0 — 分支与基线(把已完成的 3.12 迁移落盘)
|
||||
|
||||
- [x] 从 `main` 建分支 `feat/issue-15-telemetry-pool-lifecycle`
|
||||
- [x] 把工作区现有改动分两次提交: ① `chore: 最低 Python 提到 3.12 并改用 PEP 695 泛型语法`(`pyproject.toml`/`README.md`/`CLAUDE.md`/`client.py`/`streaming.py`);② `docs: issue #15 设计文档与 wiki 登记`(`research-wiki/`)
|
||||
- [x] 记录基线用例计数(执行时实测;2026-08-24 本机为 **973 passed / 23 skipped / 45 deselected**,覆盖率 94%)。该数只作**同环境**参照,不作硬验收——`addopts = "-m 'not slow'"` 与 Redis/PG 可达性都会改变它
|
||||
|
||||
**验证**: `make check` 全绿;`/home/iomgaa/miniconda3/envs/PolyGateway/bin/python -m pytest tests/ -q` → 全 PASS;`git rev-parse --abbrev-ref HEAD` → 分支名正确。
|
||||
|
||||
> **不要用 `make lint` 做验证**——它带 `--fix` 会自动改文件(`Makefile:11`),只读验证用 `make check`。
|
||||
> **不要用 `conda run ... pytest` 取统计数字**——实测其输出缓冲会把结尾的 `N passed` 与覆盖率整段吞掉,只剩 exit code(2026-08-24 踩过)。用环境解释器绝对路径直跑。
|
||||
|
||||
---
|
||||
|
||||
### T1 — D 组: 资源所有权纪律统一(独立回滚点)
|
||||
|
||||
**动**: `src/polygateway/client.py`、`embedding.py`、`ocr.py`、`backends/redis_cache.py`;测试 `tests/unit/test_client.py`。
|
||||
|
||||
**要实现的行为**: 全库唯一纪律 —— **谁建的谁关,注入的一律不碰**。分两层落:
|
||||
|
||||
1. **组件内部自建的连接**归组件自己: `RedisCache` 补 `_owns_client`(构造注入 → False;`from_url` → True),`aclose` 自查后再关。这是照抄 `backends/redis/limiter.py:185-191, 318-322` 的既有正确先例,`backends/redis/breaker.py:437-441` 同款。
|
||||
2. **client 自建的整个组件**归 client: 三个 client 各持 `_owns_transport/_owns_telemetry/_owns_cache/_owns_limiter/_owns_breaker`,**默认全 False**(`__init__` 是全量注入路径,经它传入的一切都是外部的),只有三个工厂在真正自建时置 True。工厂里 `transport` 恒自建(三处工厂都没有 transport 注入参数),`limiter`/`breaker`/`cache`/`telemetry` 按 `xxx is None` 判定。
|
||||
|
||||
**执行留痕(T1)**: 工厂里既有的 `limiter or _build_limiter(...)` 一律改成了 `is not None` 判定。理由是注入一个 **falsy** 后端时 `or` 会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这不是风格偏好,是所有权判定能成立的**必要条件**,已回写设计 §3.4。
|
||||
|
||||
**同时修掉的现存泄漏**: `GatewayClient.__init__` 今天把 limiter/breaker 交给 `RetryMW` 构造(`client.py:156-176`)后自己不留引用(`self._transport`/`_telemetry`/`_cache` 都存了,唯独这两个没存,见 `client.py:203-206`),`aclose` 因此**触达不到**自建的 redis 客户端。三个 client 都要新持 `self._limiter`/`self._breaker` 引用(仅为关闭)。embedding/ocr 的自建点在 `embedding.py:497-498`、`ocr.py:510-511`。
|
||||
|
||||
**收敛**: 三处复制的 `getattr(..., "aclose")` 探测(`client.py:268-280`、`embedding.py:452-461`、`ocr.py:462-467`)收敛为**一个**内部 helper。SQLite recorder 只有同步 `close()`,helper 须同时探测 `aclose`/`close`(今天 `client.py:274-277` 已有这个分支,embedding/ocr 也有,收敛后行为不变)。内存后端无 `aclose`,探测后跳过。
|
||||
|
||||
**测试要求**(先失败后通过): 假 recorder/transport/limiter/breaker/cache 各记 close 次数。
|
||||
- 注入的组件 `aclose` 后 close 次数 **0**;自建的为 **1**(工厂路径);
|
||||
- 自建 redis limiter/breaker 被关(**泄漏钉子**,今天必红);
|
||||
- 注入给 `RedisCache` 的客户端不被关;
|
||||
- **三个 client 逐一覆盖**——收敛成 helper 之后仍须三处各钉一次,否则下次有人把逻辑复制回去无人发现;
|
||||
- `aclose` 幂等(连调两次不重复关)。
|
||||
|
||||
**验证**: `pytest tests/unit/test_client.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py -q` → PASS;`make check` 绿;全套件绿。
|
||||
|
||||
- [x] 提交: `fix: 统一资源所有权纪律(谁建的谁关),修 aclose 越权与 redis 客户端泄漏`
|
||||
|
||||
---
|
||||
|
||||
### T2 — C 组基础设施: 状态快照 + tracker + 出口
|
||||
|
||||
**动**: `src/polygateway/types.py`、`ports.py`、**新建** `telemetry/status.py`、`telemetry/postgres.py`、`telemetry/sqlite.py`、`client.py`、`embedding.py`、`ocr.py`;测试 `tests/unit/test_telemetry.py`、`test_ports.py`、`test_client.py`。
|
||||
|
||||
**为什么排在 A/B 组之前**: T3/T5 的所有降级点都要向 tracker 报告。先建 tracker 则那两步直接写成最终形态,反之要返工一遍日志代码。
|
||||
|
||||
**要实现的行为**:
|
||||
1. `TelemetryStatus` 与 `TelemetryStatusProvider` 按上文"关键接口"定死。**`TelemetryRecorder` 一字不动**。
|
||||
2. `TelemetryStatusTracker` 状态机: `enter_degraded` 打一条 warning(含原因与恢复条件: 冷却剩余秒数,或 fatal 时写明"需改配置并重启");降级期间 `record_drop` **节流复述**(按丢弃行数与时间双阈值,阈值为模块常量);`recover` 打一条 info 并报告"期间丢弃 N 行";`should_retry` 是纯查询(fatal → False,冷却未到 → False)。
|
||||
3. 两个 recorder 各持一个 tracker,把**今天已有的**降级点接上去: PG 的建池失败与判死、SQLite 的初始化失败。**SQLite 侧同时补上今天缺失的那条 warning**——`sqlite.py:138-139` 初始化失败后写入直接 `return`,连一条日志都没有。
|
||||
4. 出口 `telemetry_status` 属性加到三个 client,取值经**一处** `isinstance(self._telemetry, TelemetryStatusProvider)` 判定,不满足或无遥测则返回 `None`。
|
||||
|
||||
**本任务不改任何失败判据**: PG 侧仍是"建池失败即永久判死",只是这次判死会经 tracker 变得可见。判据在 T5 改。这样本任务的行为变更面收敛为"日志更可见 + 多一个只读出口"。
|
||||
|
||||
**过渡期状态并存(有意,且必须在 T5 收掉)**: 本任务结束时 PG 侧的 `_failed` 布尔与 tracker 的 fatal 状态**并存**——判死点两边都写。这是为了让 T2 能独立全绿提交,不是最终形态;T5 删除 `_failed`,状态收归 tracker 一处。两份状态只允许存活这一个任务的跨度,拖久了必然漂移。
|
||||
|
||||
**契约检查点**: `tests/unit/test_ports.py:137,141` 的 `isinstance(_DummyRecorder(), TelemetryRecorder)` 断言必须**保持绿**——它是"没把状态并进主 Protocol"这条决策的机械化执法点,新增用例不得替代它。
|
||||
|
||||
**测试要求**(先失败后通过):
|
||||
- tracker 状态机六字段逐个钉: 未降级 → `degraded=False` 且三个可空字段为 None;进入降级 → `reason`/`retry_after_s` 正确;假时钟推进 → `degraded_for_s` 增长、`retry_after_s` 递减到 0;`recover` → 回到未降级且 `dropped_rows` **不清零**(进程生命周期内单调不减);
|
||||
- 节流复述: 连续 N 次 `record_drop` 只产生 M 条 warning(loguru sink 捕获断言),且 N 与 M 的关系由常量决定而非硬编码数字;
|
||||
- fatal 档: `should_retry()` 恒 False,`retry_after_s` 为 None;
|
||||
- SQLite 初始化失败(指向不可写目录)→ 有 warning **且** `telemetry_status.degraded is True`(今天必红,连 warning 都没有);
|
||||
- 三个 client 的 `telemetry_status`: 无遥测 → None;注入不实现该 Protocol 的假 recorder → None(不得抛 AttributeError);内置 recorder → 返回快照。
|
||||
|
||||
**验证**: `pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_client.py -q` → PASS;`lint-imports` 绿(新文件 `telemetry/status.py` 在实现层,只许依赖 `types`/`ports`/标准库,**不得**被 `transports`/`backends` import);全套件绿。
|
||||
|
||||
- [x] 提交: `feat: 遥测降级升格为一等状态(共用 tracker + 只读快照 + 节流日志)`
|
||||
|
||||
---
|
||||
|
||||
### T3 — A 组: 池语义与两个新配置键
|
||||
|
||||
**动**: `src/polygateway/config.py`、`client.py`(`_build_telemetry`)、`telemetry/postgres.py`;测试 `tests/unit/test_config.py`、`test_telemetry.py`、**`tests/integration/test_postgres_telemetry.py`**。
|
||||
|
||||
> **本任务必须一次改完全部 20 处 `PostgresRecorder(` 构造点**(Codex 审查,已实测复核): `src/polygateway/client.py` 1 处 + `tests/unit/test_telemetry.py` 3 处 + **`tests/integration/test_postgres_telemetry.py` 16 处**。新签名的 `pool_max`/`write_timeout_s` 是 keyword-only **必填**,漏一处就 `TypeError`,而提交门跑的是**全套件**——集成测试那 16 处不能拖到 T6,否则 T3 根本提交不了。这是 `auto_migrate` 当初(issue #13)踩过的同一形态: 必填 keyword-only 的代价就是所有构造点同批改。
|
||||
|
||||
**要实现的行为**:
|
||||
1. 两个新配置键按"关键接口"那张表落地: `_load_pgw` 里读取(模板照 `config.py:524-543` 的 `_load_text_cap`),值域校验落 `_validate_telemetry`(与 `telemetry_text_cap` 同一先例,**一次覆盖直接构造 / `dataclasses.replace` / env 三条路**),报错文本同时点字段名与 env 键名。`_build_telemetry`(`client.py:405-420`)把两个值透传给 recorder。
|
||||
2. 建池改为 `create_pool(dsn, min_size=0, max_size=pool_max, timeout=write_timeout_s, command_timeout=write_timeout_s)`。
|
||||
3. **两处** `acquire` 都改为**显式** acquire/release,**不得**用 `async with pool.acquire(...)`——`_prepare_schema`(`postgres.py:114`)与 `record_llm_call`(`postgres.py:238`)。准备期同样在预算内、同样吃 shielded release 那一刀,只改一处等于留了半个坑:
|
||||
- `con = await pool.acquire(timeout=write_timeout_s)`(传**完整**预算: 真正的上界是外层 `asyncio.timeout`,内层再算一次剩余量等于把同一个上界写两遍);
|
||||
- `finally: await pool.release(con, timeout=<小的独立上限>)`,释放超时则 `con.terminate()`;
|
||||
- 整次写入(准备 + acquire + execute)由 `asyncio.timeout(write_timeout_s)` 包一层。
|
||||
|
||||
**理由(设计 §3.1,已核实)**: `Pool.release()` 是 `await asyncio.shield(ch.release(timeout))` 且默认复用 acquire 记录的 `ch._timeout`(asyncpg `pool.py:886-889, 930-937`)。外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行 `__aexit__`,此时没有新的 cancel 投递,那个 shielded release 会**正常等到完成**——用 `async with` 的真实上界是 ≈ 2 × 预算。
|
||||
|
||||
**必须同步改造 `_FakePgPool`**(`tests/unit/test_telemetry.py:751`): 它今天的 `acquire()` **无参**且只返回一个 `_Ctx` 异步上下文管理器,没有 `release`。改造为接受 `timeout=` 并提供 `release(con, timeout=)`,同时记录 acquire/release 的配对次数(T3 与 T5 的用例都要用)。不改造则全部 PG 用例当场红。
|
||||
|
||||
**取消穿透的实现纪律**(铁律): 降级路径(节流日志、tracker 更新、release 收尾)一律不得 `except CancelledError` 而不 re-raise;`except TimeoutError` 必须排在 `except Exception` 之前;严禁裸 `except BaseException`。既有 `postgres.py:101-102` 的 `except asyncio.CancelledError: raise` 写法是对的,延续它。
|
||||
|
||||
**测试要求**(先失败后通过。注意: 建池路径**今天零覆盖**,这里要建立第一个用例):
|
||||
- **主回归钉子**: monkeypatch `asyncpg.create_pool`,断言实参 `min_size == 0` 且 `max_size == 配置值`。这一条防的是回归到继承第三方默认值,是本 issue 的核心;
|
||||
- 配置键三条装配路: env 路读取正确、缺省为 4 / 5.0、直接构造与 `replace` 同样被校验拦住(`pool_max=0`、`write_timeout_s=0` 各一条,断言报错文本含字段名与键名);
|
||||
- 硬预算: 假 pool 的 acquire 挂住 → 丢一行且耗时 ≤ 预算(用假时钟或极小预算,**不要**在用例里真睡 5 秒);
|
||||
- **release 不泄漏**(Codex 审查钉子): `execute` 被预算取消后,断言 `_FakePgPool` 记录的 acquire/release 次数**配对**;
|
||||
- 外部 `CancelledError` 在预算内**不**被吞成 `TimeoutError`(直接钉铁律)。
|
||||
|
||||
**验证**: `pytest tests/unit/test_config.py tests/unit/test_telemetry.py -q` → PASS;`make check` 绿;全套件绿。
|
||||
|
||||
- [x] 提交: `feat: 遥测池显式声明资源占用(min_size=0/max_size 可配)并给写入硬预算`
|
||||
|
||||
---
|
||||
|
||||
### T4 — B 组之一: 有界关闭
|
||||
|
||||
**动**: `src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`。
|
||||
|
||||
**为什么单列一个任务**: 它与 T5 的失败判据无关,但同属"收尾路径的隐性无界等待",且能独立验证。合进 T5 会让那次提交同时动判据与关闭两件事,回滚粒度变粗。
|
||||
|
||||
**要实现的行为**: `aclose()` 语义钉死为"关了就是关了"——置 `_closed`,此后写入短路且**不复活**(取消今天"关完还能自己重建池"的灰色状态);关闭动作本身走 `asyncio.wait_for(pool.close(), timeout=...)`,超时后 `pool.terminate()`,外部取消照常穿透。
|
||||
|
||||
**理由(已核实)**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时**无限等**,60 秒只发一条 warning(asyncpg `pool.py:939-948, 961-972`);asyncpg 自己的 docstring 就写着 "advisable to use `asyncio.wait_for` to set a timeout"。
|
||||
|
||||
**测试要求**(先失败后通过):
|
||||
- 假 holder 永不 release → `aclose()` 在超时后走 `terminate()` 返回,**不无限挂**(今天必红/挂死,用例须自带超时保护);
|
||||
- `aclose` 后再 `record_llm_call` → 直接短路,**不重建池**(断言 `create_pool` 未被再次调用);
|
||||
- `aclose` 幂等;注入的外部池仍**不**被关(`_external_pool` 既有纪律不得破)。
|
||||
|
||||
**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;全套件绿。
|
||||
|
||||
- [x] 提交: `fix: 遥测池关闭有界化(wait_for + terminate),关闭后不再复活`
|
||||
|
||||
---
|
||||
|
||||
### T5 — B 组之二: 失败三分与冷却降级(本 issue 的核心)
|
||||
|
||||
**动**: `src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`。
|
||||
|
||||
**要实现的行为**:
|
||||
1. 新增模块级 `_classify_failure`(按"关键接口"的三档表),全库唯一一处 PG 失败分类。
|
||||
2. 三个降级点改为按分类处置: `_open_pool`、`_prepare_schema`/`_prepare_table`、`record_llm_call`。
|
||||
- `_FATAL` → 永久 no-op + 一条 **error**(不是 warning: 这是人配错了),经 tracker 置 `fatal=True`;
|
||||
- `_UNAVAILABLE` → `tracker.enter_degraded(cooldown_s=_DEGRADE_COOLDOWN_S)`,此后 `_ensure_ready` 开头零成本短路(只比较时间戳,不触库),到期 `should_retry()` 放行**一次**重新准备,成功即 `tracker.recover()`;
|
||||
- `_ROW` → 逐条 warning 丢弃 + `tracker.record_drop()`,不降级。
|
||||
3. **删除 `_failed` 这个布尔**,状态收归 tracker 一处(否则两份状态必然漂移)。实测引用分布(执行时可自行复核): `src/polygateway/telemetry/postgres.py` **7 处**(74/79/84/106/124 是代码,209/211 在 `_backfill_columns` 的 docstring 里——**文档也要改**,否则留下指向已删字段的说明)、`tests/unit/test_telemetry.py` **6 处**、`tests/integration/test_postgres_telemetry.py` **6 处**,测试侧一并改为读 `telemetry_status` 快照。
|
||||
4. 判据的两条既有承诺不得破:
|
||||
- **`42703` 仍走 `_ROW`**(issue #13: manual 档缺列时裁剪 INSERT 继续写、逐行暴露)。这是判据的**唯一具名例外**,代码里必须有注释写明它是例外及理由;
|
||||
- **`_prepare_table` 的 `to_regclass` 先探测、表在就不发 DDL** 这段控制流(`postgres.py:147-157`)一行不动(issue #9)。
|
||||
|
||||
**圈复杂度检查点**: 本任务是三个降级点同时改,`record_llm_call` 与 `_ensure_ready` 最容易触到 radon 的 C 档而被提交门阻塞。逼近就抽私有方法(如 `_handle_failure(exc, *, stage)` 收敛三处处置)——这是提交门的硬要求,不算计划外重构。
|
||||
|
||||
**测试要求**(先失败后通过,分档逐个钉):
|
||||
- **issue 场景直接回归**: 建池阶段抛 `TooManyConnectionsError`(53300)→ **不** fatal、进冷却降级 → 假时钟推进 60s → 下次调用自动恢复并成功写入。今天这一条必红(现状是永久判死);
|
||||
- `ClientConfigurationError` → fatal + 一条 error + 此后零成本短路(断言不再调 `acquire`);
|
||||
- **分档边界两侧各钉一次**: `42501`/`42P01` → 进冷却降级;`42703` → 行级丢弃且**不**进降级;
|
||||
- `_prepare_table` 建表失败(表确定不存在)→ 冷却降级(不再是永久判死),DBA 建表后自动恢复;
|
||||
- 探测失败(既有 `probe_errors` 路径)仍只跳过本次、下次重试,**不**降级(issue #9 既有行为不得回归);
|
||||
- 全部现有 PG 用例保持绿(它们钉的是 issue #3/#9/#13 的承诺)。
|
||||
|
||||
**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;`radon cc src/polygateway/telemetry/postgres.py -n C -s` → 无输出;全套件绿。
|
||||
|
||||
- [x] 提交: `fix: 遥测失败按性质三分,永久判死收窄到 DSN 不可解析,其余带冷却自愈`
|
||||
|
||||
---
|
||||
|
||||
### T6 — 真实 PG 集成验证
|
||||
|
||||
**动**: `tests/integration/test_postgres_telemetry.py`。
|
||||
|
||||
**纪律(该文件既有,不得破)**: `llm_calls` 是与真实批跑共享的表,**严禁 DROP/TRUNCATE**;以 run 级 `call_id` 前缀隔离,teardown 只删自己的行;DSN 缺失则 skip;不标 `slow`(与该文件既有用例一致)。
|
||||
|
||||
**要实现的行为(用例)**:
|
||||
1. **issue 的直接回归钉子**: 建 recorder 后本池连接数为 **0**,一次写入后 **≤1**,稳态 ≤ `pool_max`。
|
||||
2. 降级与恢复走**不可达 DSN** 的 recorder 验证(连接被拒 → 降级 → 假时钟/短冷却后重试),**不去动共享实例的 `max_connections`**。
|
||||
|
||||
**计数必须按唯一 `application_name` 过滤**,该实例被多项目共用,按库名或用户名计数会被别人的连接污染——那样的用例是**设计上就会间歇红**的信号污染源(CLAUDE.md §4.6)。
|
||||
|
||||
**怎么设这个 tag(Codex 指出原稿这里无法执行,已实测给出解法)**: recorder 的构造签名**没有** `server_settings`/`connect_kwargs` 入口,原稿那句"经 `server_settings=` 建池"落不了地。解法是走 **DSN 查询参数**——给 recorder 一个 `f"{dsn}?application_name={run级唯一值}"`,其余一切不变。
|
||||
|
||||
- 已实测(2026-08-24,真实实验室 PG): `create_pool(dsn + "?application_name=pgwtest-abc123", min_size=0, ...)` 后 `SHOW application_name` 返回该值,`pg_stat_activity` 按它过滤得连接数 1,`pool.close()` 后归零。
|
||||
- **不要**改用"测试自建池后以 `pool=` 注入": 那会走 `_external_pool=True` 分支、**完全绕过被测的建池路径**,而本任务要验的恰恰是自建池不预连接。
|
||||
- **不要**为此给 recorder 加 `server_settings` 入口: 纯测试便利不值得扩公共 API(P1)。
|
||||
- 注意 `config.py` 的 `_strip_dsn_driver` 只动 scheme 的 `+driver` 后缀,不碰查询参数;且集成测试直接构造 recorder、不经 config,两条路都不受影响。
|
||||
|
||||
**验证**: `pytest tests/integration/test_postgres_telemetry.py -q` → PASS(或无 DSN 时全 skip);全套件绿。
|
||||
|
||||
- [x] 提交: `test: 真实 PG 验证遥测池不预连接与降级自愈`
|
||||
|
||||
---
|
||||
|
||||
### T7 — 文档、配置面与发布说明
|
||||
|
||||
**动**: `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`。
|
||||
|
||||
**要实现的行为**:
|
||||
1. `.env.example`: 两个新键写在 `PGW_TELEMETRY_PG_DSN` 之后,沿用该文件既有的"键 + 缩进注释块讲清为什么"风格。`pool_max` 必须给**调参口径**: 写**实测值**而非 `pool_max / RTT`(T3 实测该公式乐观一倍,见设计 §10 修订 #1)——跨内网 RTT ≈ 123ms 上 `pool_max=4` 约 **15.6 行/秒**(50 行并发批 3.2s),并写明"共享一个 recorder 给多 client 时并发汇聚,应相应放大"。
|
||||
2. `README.md`: 配置表加两键;能力表反映"遥测降级可恢复 + 可查询状态";**核对安装命令里的版本约束**(发布清单第 1 步的老账: `==1.2.*` 这类极易漏改)。
|
||||
3. `ARCHITECTURE.md` §7.8 增补三条: 遥测池的资源语义(为何 `min_size=0`、为何不暴露 `min_size`)、失败三分的**两句判据**、**资源所有权纪律**(后者应作为跨子系统的通用纪律成文,而非遥测局部约定);§9 登记两个新键。
|
||||
4. `CHANGELOG.md`: 记在"未发布"下,三处"请先读这一条": ①最低 Python 提到 3.12(**唯一会让下游装不上**的变更);②遥测常驻连接从 `10 × client 数` 变按需(监控曲线会突变);③`aclose` 不再关闭注入的组件。
|
||||
|
||||
**验证**: `make check` 绿;人工通读 `.env.example` 两键注释,确认调参口径可执行。
|
||||
|
||||
- [x] 提交: `docs: 遥测池资源语义、失败判据与所有权纪律成文`
|
||||
|
||||
---
|
||||
|
||||
## 完成判据(合并前)
|
||||
|
||||
- [x] T0-T7 全部提交完成,每次提交都过了提交门(ruff + radon + 全套件)
|
||||
- [ ] `pytest -m slow` 单独跑过一次(发布清单第 4 步;本次改动触及遥测写入路径,e2e 与 Redis 时间语义变体必须实测)
|
||||
- [ ] 派**全新上下文**的 verifier subagent 独立验证(`verification-before-completion`,里程碑级/合并前 MANDATORY)
|
||||
- [ ] 整分支审查(`requesting-code-review`,合并前 MANDATORY)
|
||||
- [ ] 设计文档 §5 的每一条测试要求都能指到一个具体用例(逐条对照,不是"大致覆盖")
|
||||
|
||||
## 审查留痕(Codex,2026-08-24)
|
||||
|
||||
**Status: Issues Found → 2 条阻断级均已修订,2 条 Recommendation 采纳 1 条。**
|
||||
|
||||
| # | 结论 | 落点 |
|
||||
|---|---|---|
|
||||
| 1 | **采纳(阻断)**。新签名的 `pool_max`/`write_timeout_s` 是必填 keyword-only,而 `PostgresRecorder(` 共 **20 处**构造点,其中 **16 处在集成测试**。原稿 T3 只列了两个单元测试文件,漏掉的那 16 处会让 T3 的提交门(跑全套件)当场红 | T3 "动"一节 |
|
||||
| 2 | **采纳(阻断),并给出比建议更好的解法**。原稿 T6 写"经 `server_settings=` 建池"设唯一 `application_name`,但 recorder 签名根本没有这个入口,零上下文执行者会卡死。Codex 给的两条出路(注入外部池 / 加 recorder 入口)都有代价——前者绕过被测的建池路径,后者为测试便利扩公共 API。**实测发现第三条**: `?application_name=<tag>` 走 DSN 查询参数,asyncpg 认、PG 侧生效、关池后计数归零,**零 API 改动且真实覆盖建池路径** | T6 计数一节 |
|
||||
| 3 | **采纳(建议)**。`_failed` 计数原稿写"测试 13 处"不准。实测: 源码 7 处(**含 2 处在 docstring 里**,文档也要改)、unit 6 处、integration 6 处 | T5 第 3 点 |
|
||||
| 4 | 无需动作。Codex 复核确认了计划的两条硬断言: 建池路径零覆盖(`rg create_pool\|_open_pool tests` 无匹配)、`_FakePgPool` 定义于 `:751-765` 且只经三个 helper 注入(故改造类本身即可覆盖既有假池用例) | — |
|
||||
|
||||
Codex 给的 `_failed` 分布数字(源码 5 处 / 测试断言 8 处)与本地实测(源码 7 / unit 6 / integration 6)不一致,以实测为准——它漏了 docstring 里那两处,而那两处恰恰是**必须改**的(留着就是指向已删字段的说明)。
|
||||
|
||||
## 实际提交(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`)
|
||||
|
||||
| 任务 | hash | message 首行 |
|
||||
|---|---|---|
|
||||
| T0 ① | `157a27f` | `chore: require python 3.12 and adopt PEP 695 type parameters` |
|
||||
| T0 ② | `e7caa50` | `docs: plan the telemetry pool lifecycle rework for issue 15` |
|
||||
| T1 | `e69ca4c` | `fix: make every client close what it built and nothing else` |
|
||||
| T2 | `f958138` | `feat: make telemetry degradation a first-class state` |
|
||||
| T3 | `84c2cc1` | `feat: make the telemetry pool declare what it costs` |
|
||||
| T4 | `bc071c6` | `fix: make closing the telemetry pool bounded and final` |
|
||||
| T5 | `eef2fdc` | `fix: judge telemetry failures by nature, not by step` |
|
||||
| T6 | `bfeda5b` | `test: prove on real PG that the pool never preconnects` |
|
||||
| T7 ⓪ | `69a5b5f` | `test: pin the cooldown assertion to a fake clock`(T5 留下的一处间歇红: 快照里的 `retry_after_s` 是时间差,却用真实时钟断言 60.0) |
|
||||
| T7 ① | `7834d75` | `feat: export TelemetryStatus from the package root` |
|
||||
| T7 ② | `4e1f09d` | `docs: record the telemetry pool semantics and ownership rule`(本表的 hash 由紧随其后的一次 bookkeeping 提交补齐) |
|
||||
| T8 ① | `f90f7b0` | `test: give the log level and ownership rules real enforcement` |
|
||||
| T8 ② | `6d6b3cf` | `docs: correct the stale throughput numbers and wiki state`(本行 hash 由紧随其后的 bookkeeping 提交补齐) |
|
||||
|
||||
**T8 不在原计划内**: 它是合并前独立验证(全新上下文 verifier)报出的 5 个问题的处置——2 条"确证的假绿"(日志级别与所有权判定各自没有执法点)+ 2 处过时数字/措辞 + 1 处 wiki 状态漂移。详见设计 §10 修订 #4/#5。
|
||||
|
||||
T7 分两次提交是因为它含一处**公共 API 面**改动(`TelemetryStatus` 进顶层 `__all__`,决策见下),与纯文档的回滚粒度不同。
|
||||
|
||||
**T7 执行期追加的决策与发现**(计划原稿只列了四项文档任务):
|
||||
|
||||
| # | 内容 | 落点 |
|
||||
|---|---|---|
|
||||
| 1 | `TelemetryStatus` 进 `polygateway.__all__`。issue #15 的核心诉求之一是下游能**编程对账**,而 `client.telemetry_status` 的返回类型若不能从顶层 import,下游做类型标注就得深入 `polygateway.types`——与"顶层导出即公共 API 面"的约定冲突。T2 参照的 `SourceStats` 先例**不适用**: 那是端口内部快照、下游不消费。端口 `TelemetryStatusProvider` 仍不导出 | `__init__.py`、`tests/unit/test_package.py`、ARCH §7.8 |
|
||||
| 2 | 吞吐算术更正为实测值(15.6 行/秒),`.env.example` / README 的调参口径按实测写 | 设计 §3.5/§6/§10 |
|
||||
| 3 | "重试建池已零成本"这条红利与"关闭后偷偷复活"这个 bug 分别补进设计 §3.2 / §1.5 | 设计 §10 |
|
||||
@@ -0,0 +1,529 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:2026-08-25-thinking-observability-plan
|
||||
title: "推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)"
|
||||
date: 2026-08-25
|
||||
---
|
||||
|
||||
# 推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)
|
||||
|
||||
> 类型:plan|日期:2026-08-25|实现设计:`designs/2026-08-25-thinking-observability-design.md`(已经人类批准)
|
||||
> 事实基础:`findings/2026-08-25-thinking-observability-regression.md`
|
||||
> **保真校验不适用**:本计划不涉及 `reference/` 三项目的迁移,推理开关是库自有子系统,不在 ARCHITECTURE.md §1.4 关键资产索引的移植蓝本内。
|
||||
|
||||
## 目标
|
||||
|
||||
让"这次推理到底发生没发生"成为库的一等返回值,由多信号裁定,判不出来时如实说 UNKNOWN,并与能力表持续对账。
|
||||
|
||||
## 方案概述
|
||||
|
||||
新增 `ThinkingObservation` 三态枚举(定义在最内层 `types.py`)与裁定纯函数 `observe_thinking`(决策层 `thinking.py`),由 transport 在组装结果时裁定并与请求方向对账,结果随 `LLMResponse` 返回、随遥测落库。同时把推理决策从 `providers.py` 拆进新模块 `thinking.py`,并把公共符号提升到包根导出。
|
||||
|
||||
涉及技术:Python 3.12 `StrEnum`、frozen dataclass、`inspect.signature` 冻结测试、import-linter 分层契约、SQLite/PG schema backfill。
|
||||
|
||||
## 文件结构
|
||||
|
||||
**新建**
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `src/polygateway/thinking.py` | 推理这件事的全部**决策**:能力表、`resolve_thinking`(请求侧注入)、`observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义** |
|
||||
| `tests/unit/test_thinking.py` | 裁定与对账的单元测试 |
|
||||
|
||||
**修改**
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `src/polygateway/types.py` | 新增 `ThinkingObservation`;`LLMResponse` / `TransportResult` 各增一字段 |
|
||||
| `src/polygateway/providers.py` | 收缩为纯注册表 |
|
||||
| `src/polygateway/ports.py` | `record_llm_call` 24 参 → 25 参 |
|
||||
| `src/polygateway/transports/openai_compat.py` | 裁定 + 对账 |
|
||||
| `src/polygateway/middleware/retry.py` | 透传 |
|
||||
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage` + 三个 `emit_*` + `_record` |
|
||||
| `src/polygateway/middleware/cache.py` | `_rehydrate` 枚举复活 |
|
||||
| `src/polygateway/telemetry/schema.py` | 新列 + 两端 DDL + 两份 backfill |
|
||||
| `src/polygateway/telemetry/sqlite.py`、`postgres.py` | 实现新参 |
|
||||
| `src/polygateway/client.py` | import 路径 |
|
||||
| `src/polygateway/__init__.py` | 包根导出 + 版本号 |
|
||||
| `pyproject.toml` | import-linter 契约加层 + 版本号 |
|
||||
| 测试 9 个、文档 5 个 | 见各任务 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1:`ThinkingObservation` 与裁定纯函数
|
||||
|
||||
**文件**:创建 `src/polygateway/thinking.py`、`tests/unit/test_thinking.py`;修改 `src/polygateway/types.py`、`pyproject.toml`
|
||||
|
||||
### 行为
|
||||
|
||||
在 `types.py` 新增(放在 `LLMResponse` 定义**之前**,因为它是其字段类型):
|
||||
|
||||
```python
|
||||
class ThinkingObservation(StrEnum):
|
||||
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
|
||||
|
||||
三态不可折叠为布尔: `UNKNOWN` 是"本次无任何信号,判不出来",与
|
||||
`ABSENT`("上游明确上报未推理")语义不同。把前者折叠进后者,正是
|
||||
`reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",
|
||||
而实际可能推理了且已计费(MiniMax-M3 非流式实测)。
|
||||
"""
|
||||
|
||||
OBSERVED = "observed"
|
||||
ABSENT = "absent"
|
||||
UNKNOWN = "unknown"
|
||||
```
|
||||
|
||||
在新建的 `thinking.py` 实现(本任务只放这一个函数,搬迁留给 Task 2):
|
||||
|
||||
```python
|
||||
def observe_thinking(
|
||||
*, thinking: str, reasoning_tokens: int | None
|
||||
) -> ThinkingObservation:
|
||||
"""由多信号裁定推理是否发生;判据按证据硬度排序。
|
||||
|
||||
推理正文是事实本身,token 计数是对事实的转述——转述缺失时事实仍然作数。
|
||||
"""
|
||||
if thinking.strip():
|
||||
return ThinkingObservation.OBSERVED
|
||||
if reasoning_tokens is None:
|
||||
return ThinkingObservation.UNKNOWN
|
||||
return (
|
||||
ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
|
||||
)
|
||||
```
|
||||
|
||||
`pyproject.toml` 的 import-linter 契约 `layers` 插入一层,位置在实现层与 `providers` 之间:
|
||||
|
||||
```toml
|
||||
layers = [
|
||||
"polygateway.client",
|
||||
"polygateway.config",
|
||||
"polygateway.middleware",
|
||||
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
|
||||
"polygateway.thinking",
|
||||
"polygateway.providers : polygateway.sources",
|
||||
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
|
||||
]
|
||||
```
|
||||
|
||||
层序理由:`thinking.py` 要 import `providers.py` 的 `ProviderProfile`(故在其上),被 `transports/` 与 `client.py` import(故在其下)。**枚举放 `types.py` 而非 `thinking.py`,正是为了让最内层不反向依赖决策层**——这是本任务最容易做错的一步,写反了 import-linter 会判红。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_thinking.py` 覆盖裁定五种输入:正文非空 → OBSERVED;**纯空白正文 + `reasoning_tokens=None` → UNKNOWN**(不得因 truthy 判成 OBSERVED);`reasoning_tokens=5` → OBSERVED;`reasoning_tokens=0` → ABSENT;`reasoning_tokens=None` 且正文空 → UNKNOWN。再加一条优先级用例:正文非空且 `reasoning_tokens=0` → OBSERVED(正文压倒转述)。
|
||||
|
||||
`tests/unit/test_types.py` 加一条:`ThinkingObservation` 定义在 `polygateway.types` 模块内(`ThinkingObservation.__module__ == "polygateway.types"`),防止后续任务把它挪回决策层。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_types.py -v
|
||||
conda run -n PolyGateway lint-imports
|
||||
```
|
||||
|
||||
预期:新测试全 PASS;`lint-imports` 全部契约 KEPT。
|
||||
|
||||
- [ ] Task 1 提交:`feat: judge whether reasoning actually happened from multiple signals`
|
||||
|
||||
---
|
||||
|
||||
## Task 2:把推理决策从 `providers.py` 搬进 `thinking.py`
|
||||
|
||||
**文件**:修改 `src/polygateway/thinking.py`、`src/polygateway/providers.py`、`src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/__init__.py`、`tests/unit/test_providers.py`、`tests/unit/test_package.py`
|
||||
|
||||
### 行为
|
||||
|
||||
从 `providers.py` **原样移入** `thinking.py`(纯移动,不改逻辑):`ThinkingUnsupportedError`、`ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`、`_warn_unregistered`。
|
||||
|
||||
`providers.py` 保留:`ProviderProfile`、`DEFAULT_PROFILES`、`get_provider`、`register_provider`。其模块 docstring 改为只讲注册表职责;`thinking.py` 的模块 docstring 说明它承载推理的全部决策而枚举归 `types.py`。
|
||||
|
||||
更新 import:`client.py`(`from polygateway.providers import get_capability, get_provider, resolve_thinking` 拆成两行)、`transports/openai_compat.py`、`client.py` 的 `TYPE_CHECKING` 块里 `ThinkingCapability` 的来源。
|
||||
|
||||
`__init__.py` 新增包根导出并加进 `__all__`(该列表**不是严格字母序**——`DEFAULT_PROFILES` 现在就排在 `AllSourcesExhausted` 前面;沿用文件既有排列,把新符号插到同类符号附近即可):`ThinkingCapability`、`ThinkingObservation`、`ThinkingUnsupportedError`、`get_capability`、`register_capability`、`resolve_thinking`。
|
||||
|
||||
`tests/unit/test_providers.py` 里针对被搬走符号的测试,整体移入 `tests/unit/test_thinking.py`。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_package.py` 比照既有 `TelemetryStatus` 用例,加一条断言六个新符号可从包根 import 且在 `__all__` 内——该测试在导出落地前必然红。
|
||||
|
||||
搬迁本身的回归证据:搬迁前后 `pytest tests/unit -q` 通过数不减(搬迁是纯移动,任何行为差异都是 bug)。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit -q
|
||||
conda run -n PolyGateway lint-imports
|
||||
conda run -n PolyGateway python -c "from polygateway import ThinkingObservation, ThinkingCapability, resolve_thinking; print('ok')"
|
||||
```
|
||||
|
||||
预期:全 PASS;契约 KEPT;import 成功。
|
||||
|
||||
- [ ] Task 2 提交:`refactor: give reasoning decisions their own module`
|
||||
|
||||
---
|
||||
|
||||
## Task 3:字段落到响应类型并贯通调用链
|
||||
|
||||
**文件**:修改 `src/polygateway/types.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/middleware/retry.py`;测试 `tests/unit/test_types.py`、`tests/unit/test_openai_compat.py`、`tests/unit/test_retry.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`TransportResult` 与 `LLMResponse` 各新增字段,**必须加在各自字段列表末尾且带默认值**(`LLMResponse` 是被三项目消费的公共类型,只增不删且不得改变既有位置参数顺序):
|
||||
|
||||
```python
|
||||
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||
```
|
||||
|
||||
`LLMResponse` 侧补 docstring:`UNKNOWN` = 本次无信号判不出,**不是**"没推理";非流式路径下部分模型推理已计费却不回传正文(M3 实测 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。
|
||||
|
||||
`transports/openai_compat.py` 的两条组装路径(流式 `_complete_stream` 的 463-475 行、非流式 `_complete_once` 的 548-560 行)在构造 `TransportResult` 时调 `observe_thinking(thinking=thinking, reasoning_tokens=...)` 填入。两条路径都要填——**只填一条正是 L5 要抓的那类分叉**。
|
||||
|
||||
`middleware/retry.py` 的 `_build_response`(372-393 行)透传 `thinking_observation=result.thinking_observation`。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_types.py`:两个类型的默认值均为 `ThinkingObservation.UNKNOWN`;`LLMResponse` 既有位置构造方式不破(沿用文件内既有的构造用例形态)。
|
||||
|
||||
`tests/unit/test_openai_compat.py`:用既有的 SSE / JSON 响应装置,构造三种响应各断言一次——含 `reasoning_content` 增量 → `OBSERVED`;无推理信号 → `UNKNOWN`;`usage.completion_tokens_details.reasoning_tokens=0` → `ABSENT`。流式与非流式各一组。
|
||||
|
||||
`tests/unit/test_retry.py`:比照既有透传测试,断言 transport 返回的 `thinking_observation` 原样出现在 `LLMResponse` 上。
|
||||
|
||||
以上在字段落地前全部红(属性不存在)。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v
|
||||
```
|
||||
|
||||
预期:全 PASS。
|
||||
|
||||
- [ ] Task 3 提交:`feat: carry the reasoning verdict through to LLMResponse`
|
||||
|
||||
---
|
||||
|
||||
## Task 4:对账告警(声明 × 观测)
|
||||
|
||||
**文件**:修改 `src/polygateway/thinking.py`、`src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_thinking.py`、`tests/unit/test_openai_compat.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`thinking.py` 新增对账纯函数,返回告警文案或 `None`(**判定与日志分离**,这样告警内容可被单测直接断言,不必去解析日志):
|
||||
|
||||
```python
|
||||
def reconcile_thinking(
|
||||
*,
|
||||
enable_thinking: bool | None,
|
||||
observation: ThinkingObservation,
|
||||
capability: ThinkingCapability | None,
|
||||
model: str,
|
||||
) -> str | None:
|
||||
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
|
||||
|
||||
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
|
||||
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
|
||||
"""
|
||||
```
|
||||
|
||||
判定矩阵(设计 §5):
|
||||
|
||||
| `enable_thinking` | observation | capability | 返回 |
|
||||
|---|---|---|---|
|
||||
| `False` | OBSERVED | 已登记 | 能力表漂移:声明可关闭,实测推理了。附 `capability.evidence` 与 `register_capability` 指路 |
|
||||
| `False` | OBSERVED | `None` | 关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
|
||||
| `True` | ABSENT | 任意 | 注入了开启参数,上游明确上报未推理 |
|
||||
| `True` | UNKNOWN | 任意 | 推理参数已注入但本路径观测不到,无法确认是否生效;若为非流式路径,推理内容可能已计费却不回传 |
|
||||
| 其余组合(含 `False`×UNKNOWN、`None`×任意) | | | `None` |
|
||||
|
||||
`False`×UNKNOWN 返回 `None` 是刻意的:`UNKNOWN` 没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。
|
||||
|
||||
`transports/openai_compat.py` 在组装完 `TransportResult` 后调用它,非 `None` 则 `logger.warning`,并按 `(model, enable_thinking)` 节流——新增实例级 `set`,与既有 `_warned_models` 同款形态,**不可复用同一个 set**(那个 set 语义是"未登记能力已告警过",混用会互相压制)。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_thinking.py`:矩阵四行各断言返回非 `None` 且文案含模型名;三种不表态组合(`False`×UNKNOWN、`None`×OBSERVED、`True`×OBSERVED)断言返回 `None`;已登记 vs 未登记两行的文案**必须不同**(不得对未登记模型说"能力表声称可关闭")。
|
||||
|
||||
`tests/unit/test_openai_compat.py`:断言同一 `(model, direction)` 连调两次只出现一条 warning;换 direction 后再出一条。**不能用 `caplog`**——本项目日志走 loguru,不经标准 `logging`,`caplog` 抓不到;复用 `tests/unit/test_thinking.py` 的 `_warnings()`(`logger.add` 收集)。
|
||||
|
||||
> `reconcile_thinking` 必须定义在 `ThinkingCapability` **之后**:本模块没有 `from __future__ import annotations`,注解在 `def` 时求值,放在文件上部会 `NameError`。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_openai_compat.py -v
|
||||
```
|
||||
|
||||
预期:全 PASS。
|
||||
|
||||
- [ ] Task 4 提交:`feat: warn when the capability table and reality disagree`
|
||||
|
||||
---
|
||||
|
||||
## Task 5:缓存回放复活枚举
|
||||
|
||||
**文件**:修改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`_rehydrate` 走 `LLMResponse(**fields)`,JSON 里的 `"observed"` 会复活成**裸 `str`** 而非枚举实例,类型与注解分叉。在 `fields.update(...)` 之前显式转换:
|
||||
|
||||
```python
|
||||
if "thinking_observation" in fields:
|
||||
fields["thinking_observation"] = ThinkingObservation(
|
||||
fields["thinking_observation"]
|
||||
)
|
||||
```
|
||||
|
||||
非法值(旧版本缓存、人为污染)会抛 `ValueError`,由既有的 `except Exception` 吞成"按未命中回源"并 warning——降级方向正确,不需额外处理。
|
||||
|
||||
`_serialize` 无需改动:`StrEnum` 是 `str` 子类,`dataclasses.asdict` + `json.dumps` 直接可序列化。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_cache.py`:写入一条 `thinking_observation=OBSERVED` 的响应后命中回放,断言 `isinstance(resp.thinking_observation, ThinkingObservation)`(改动前必然红——回放出来的是 `str`);再造一条 `thinking_observation` 为 `"bogus"` 的缓存值,断言按未命中回源。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_cache.py -v
|
||||
```
|
||||
|
||||
预期:全 PASS。
|
||||
|
||||
- [ ] Task 5 提交:`fix: revive the reasoning verdict as an enum, not a bare string`
|
||||
|
||||
---
|
||||
|
||||
## Task 6:遥测新增一列(端口 → schema → recorder → emitter)
|
||||
|
||||
**文件**:修改 `src/polygateway/ports.py`、`src/polygateway/telemetry/schema.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`;测试 `tests/unit/test_ports.py`、`tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py`
|
||||
|
||||
### 行为
|
||||
|
||||
**端口**:`TelemetryRecorder.record_llm_call` 增 `thinking_observation: str`,**不设默认值**(该 Protocol 的既有纪律,docstring 已写明理由:库外无第三方实现者,带默认值会让 emitter 漏传时静默落默认)。参数加在 `meta` 之后。docstring 的"24 字段冻结"改为 25。
|
||||
|
||||
**schema**:`SQLITE_DDL` / `PG_DDL` 末尾加 `thinking_observation TEXT`;`SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` 各加 `("thinking_observation", "TEXT")`;`COLUMNS` 末尾加同名项。**新列必须排在最末**——旧表只能 ALTER 追加到末尾,插在中间会让新建库与补列库的物理列序分叉(该纪律的注释就在这两个常量上方)。
|
||||
|
||||
**recorder**:两个 recorder 的 `record_llm_call` 都是 `(self, **fields: object)` 形态(**不是**显式参数列表),按 `COLUMNS` / `self._columns` 从 `fields` 取值——新列因此**不需要改签名**,只要 `COLUMNS` 里有、emitter 传了,取值就自动到位。要做的是核对两处:取值是否严格按列序、manual 档列裁剪路径是否覆盖新列。`sqlite.py:146` docstring 的"24 字段冻结签名"改 25。
|
||||
|
||||
> 端口 `ports.py` 的 Protocol 是**显式 25 参**,而实现是 `**fields`——这不矛盾:Protocol 声明的是调用契约(emitter 必须按名传全),实现选择用 kwargs 收。改端口签名仍然必要,它是 emitter 侧的编译期约束与冻结测试的锚点。
|
||||
|
||||
**emitter**:`_AttemptUsage` 增 `thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN`(**内部字段用枚举类型**,裸 `str` 归一化只发生在下沉 recorder 那一步),`of()` 从 response 取;三个 `emit_*` 各传一行(`emit_terminal_failure` 传 `ThinkingObservation.UNKNOWN`——无响应可言,默认值本身不撒谎);`_record` 签名增一参并下沉给 recorder。**所有新增字段只经 `_record` 这一个出口抵达 recorder,不新开调用点**(铁律:遥测调用点收敛为单一 helper,该出口已存在)。`middleware/telemetry.py:135` 的"组装 24 字段"改 25。
|
||||
|
||||
**recorder 收到的必须是裸 `str`,不是枚举实例**:`_AttemptUsage.thinking_observation` 内部用 `ThinkingObservation` 类型,但 `_record` 下沉给 recorder 时取 `.value`。`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只会被降级成一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一先例(`ports.py` docstring 明载该分工:recorder 只落库,不做语义判断)。
|
||||
|
||||
### 数字断言逐处更新(漏一处即红)
|
||||
|
||||
| 位置 | 现值 → 新值 |
|
||||
|---|---|
|
||||
| `tests/unit/test_telemetry.py:37` `_EXPECTED_COLUMNS` | 末尾加 `thinking_observation` |
|
||||
| `tests/unit/test_telemetry.py:184` INSERT 占位符串 | 补到 `$25` |
|
||||
| `tests/unit/test_telemetry.py:210` | `len(COLUMNS) == 24` → `25` |
|
||||
| `tests/unit/test_telemetry.py:633` docstring | 物理列 `23 → 25` 改为 `24 → 26` |
|
||||
| `tests/unit/test_telemetry.py:642` | `== 25` → `== 26` |
|
||||
| `tests/unit/test_telemetry.py:645` docstring | `25 个物理列` → `26 个` |
|
||||
| `tests/integration/test_postgres_telemetry.py:764` 注释 | `22 → 24 个 recorder 字段(加 created_at 共 25 个物理列)` 改为 `24 → 25 个(共 26 个物理列)` |
|
||||
|
||||
> 上表**不完整**——实施时实测另有 6 处漏改会当场把测试跑红:`_FROZEN_SQLITE_INSERT`(计划只点了 PG 那条)、`:586` 的 `_EXPECTED_COLUMNS[:-2]` → `[:-3]`、`TestBackendColumnParity` 的 `COLUMNS[-2:]` 断言、两处 `_CURRENT` 假列表(稳态不发 ALTER 的断言)、`PG_BACKFILL[-1]` 末位断言,以及 integration 侧 `:608` 的 `_PRE_TENANT_COLUMNS` 派生式。另有四处注释/docstring 的字段数会过期。**结论: 不要照表逐条打勾就收工,以"全套件绿"为准**。
|
||||
|
||||
> **不要改 `tests/unit/test_telemetry.py:1787`**:那里的"共 24 字"是 OCR 占位串 `<ocr:text image_bytes=3>` 的**字符数**,与遥测列数无关。全局替换"24"会误伤它。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
`tests/unit/test_ports.py`:现有 `TestTelemetryRecorderSignature` **并不冻结完整参数列表**——它只 parametrize 了 `["tenant_id", "meta"]` 两项,断言其无默认值且为 KEYWORD_ONLY。把 `thinking_observation` 加进该 parametrize 列表,断言同样三条——改端口前必然红。
|
||||
|
||||
`tests/unit/test_telemetry.py`:列数与列序断言(上表);新增一条 round-trip——记录一条 `thinking_observation=OBSERVED` 的调用后从 SQLite 读回该列等于 `"observed"`。
|
||||
|
||||
`tests/integration/test_postgres_telemetry.py`:既有 backfill 用例覆盖旧表补列后新列存在且可写读。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v
|
||||
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
|
||||
conda run -n PolyGateway python -c "
|
||||
import inspect
|
||||
from polygateway.ports import TelemetryRecorder
|
||||
p = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
||||
print('recorder 参数数(不含 self):', len(p) - 1)"
|
||||
```
|
||||
|
||||
预期:全 PASS;最后一条打印 `25`(README 的字段数断言按此实测值填,见 Task 9)。
|
||||
|
||||
- [ ] Task 6 提交:`feat: record the reasoning verdict in telemetry`
|
||||
|
||||
---
|
||||
|
||||
## Task 7:e2e 判据重建
|
||||
|
||||
**文件**:修改 `tests/e2e/test_thinking_live.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`_run_rounds` 的逐轮观测字典增加两个键:`"thinking_observation": resp.thinking_observation` 与 `"thinking_chars": len(resp.thinking)`(报告里要能看见证据本身,而不只是结论)。
|
||||
|
||||
判据函数改写:
|
||||
|
||||
```python
|
||||
def _reasoning_on(obs: dict) -> bool:
|
||||
"""开启方向: 观测到推理即为真。
|
||||
|
||||
判据从 `reasoning_tokens` 换成三态裁定,因为 MiniMax 这一路已不再上报
|
||||
`completion_tokens_details`(2026-08-25 findings),而库在同一次调用里
|
||||
拿得到 185 字符推理正文——旧判据看不见它,四条用例因此假红。
|
||||
"""
|
||||
return obs["thinking_observation"] == ThinkingObservation.OBSERVED
|
||||
|
||||
|
||||
def _reasoning_off(obs: dict) -> bool:
|
||||
"""关闭方向: 只要没观测到推理即算满足。
|
||||
|
||||
`UNKNOWN` 计入满足是有意的: 它没有证伪力(设计 §4.1),不能拿它判红。
|
||||
本判据真正的证伪力在于——模型若偷偷推理了,可观测路径会翻成 OBSERVED。
|
||||
"""
|
||||
return obs["thinking_observation"] != ThinkingObservation.OBSERVED
|
||||
```
|
||||
|
||||
**删除 `_ON_MIN_COMPLETION` 常量及其全部引用**:两档 completion 分布实测重叠(关闭档最高 46、开启档最低 13),这个魔数退路从一开始就不成立。
|
||||
|
||||
**L5 重新定义**(当前实现断言"非流式开启档多数轮观测到推理",而 M3 非流式推理正文与 ctd 双缺,该断言永远不可能成立):改为断言两件真实成立的事——其一非流式下关闭档与开启档的 `prompt_tokens` 锚点仍然分开(证明参数确实到达模型,判据形态照抄 L2b);其二开启档观测为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。用例 docstring 写明:M3 非流式推理已计费却不回传正文,这是上游行为,库修不了但必须让它可见。
|
||||
|
||||
L3b 的 docstring 补一句不可移植性:minimax 对非法 `reasoning_effort` 返回 200 且照常推理,qwen 对同样的值返回 **HTTP 400**——该反证手法只对不校验值的 provider 成立。
|
||||
|
||||
模块顶部的判据纪律段与 `_write_report` 的报告表头同步改写为三态口径。
|
||||
|
||||
### 测试要求(先失败后通过)
|
||||
|
||||
本任务的证据是真跑:改前 `TestMiniMaxM3` 4 failed / 3 passed,改后全类 PASS。L5 的新断言在 Task 3 之前无法表达(字段不存在),是纯新增覆盖。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v
|
||||
```
|
||||
|
||||
预期:`TestMiniMaxM3` 7 passed;报告落 `tests/outputs/e2e/`。耗时约 7 分钟、约 137 次真实调用。
|
||||
|
||||
- [ ] Task 7 提交:`test: judge reasoning by what the library actually observed`
|
||||
|
||||
---
|
||||
|
||||
## Task 8:能力表 evidence 刷新
|
||||
|
||||
**文件**:修改 `src/polygateway/thinking.py`
|
||||
|
||||
### 行为
|
||||
|
||||
`DEFAULT_CAPABILITIES` 中 `MiniMax-M3` 的 `can_disable` **保持 `True`**(2026-08-25 复测:`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立)。`evidence` 追加复测日期与两条新限制:推理信号在非流式路径不可观测;`enable_thinking` / `thinking:{type:enabled}` 对该模型无效,仅 `reasoning_effort` 是真开关。
|
||||
|
||||
`minimax` profile 上方的注入形态注释同步补记复测日期。
|
||||
|
||||
### 测试要求
|
||||
|
||||
**先失败后通过不适用于本任务,理由须写进提交信息**:本任务只改 `evidence` 字符串与注释,`can_disable` 取值不变,**没有行为变更**,因而没有可先失败的行为断言(`test-driven-development` 的结果门约束的是行为变更)。声明依然成立这一事实,其证据是 2026-08-25 的复测与 Task 7 的 e2e 真跑,不是本任务能自造的单测。
|
||||
|
||||
`tests/unit/test_thinking.py` 既有的能力表用例(`evidence` 非空、`can_disable` 取值)须保持绿,作为回归证据。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway pytest tests/unit/test_thinking.py -q
|
||||
```
|
||||
|
||||
- [ ] Task 8 提交:`docs: refresh the M3 capability evidence with the 08-25 retest`
|
||||
|
||||
---
|
||||
|
||||
## Task 9:文档同步(构建前必须改完)
|
||||
|
||||
**文件**:修改 `README.md`、`research-wiki/ARCHITECTURE.md`、`research-wiki/schemas/llm-calls.md`、`research-wiki/index.md`、`CHANGELOG.md`
|
||||
|
||||
### 行为
|
||||
|
||||
**`README.md:21`**:`必录 24 字段` → `25 字段`。数字取 Task 6 验证步骤里 `inspect.signature` 的实测输出,**不凭记忆**(发布清单第 1 步点名的失败模式)。同时核对安装命令的版本约束是否需要跟进,以及能力表是否要提及推理裁定这一新行为。
|
||||
|
||||
**`README.md` 的 `<!-- pg-template:table -->` 生产部署 DDL 模板**——**本条计划原文是错的,已订正**。
|
||||
|
||||
原文断言该模板是"独立于 `schema.py` 手写的另一份 SQL",要求补上 `thinking_observation TEXT`。**事实相反**:该模板不含任何列定义,它是 `CREATE TABLE llm_calls (LIKE llm_calls_seed INCLUDING DEFAULTS, PRIMARY KEY (call_id, created_at)) PARTITION BY RANGE (created_at)`,列全部从上一步 `telemetry_schema_sql('postgres')` 建出的 seed 表派生,README 正文原本就写着"列不在这里重抄一份——抄了就会漂移"。照原文补列会让 PG 报列重复、`TestProductionTemplate` 全红、下游部署直接失败。
|
||||
|
||||
(这条错误的来路值得记下来: 它出自另一个任务的实施报告,写进计划时**没有自己打开 README 核实**。跨任务转述的"发现"必须当作待验证的线索,不是事实。)
|
||||
|
||||
正确的做法是加一条**形态断言**: 模板必须靠 `LIKE` 派生,且不得内联任何 `COLUMNS` 里的列名。它钉住的是"日后有人把列抄进模板"这个真实风险——比原计划想堵的缺口更贴合实际。断言落在 `tests/integration/test_postgres_telemetry.py` 的 `TestProductionTemplate`(**不在** `tests/unit/test_telemetry.py`,计划原文也指错了文件)。
|
||||
|
||||
**`research-wiki/ARCHITECTURE.md`**:§8 模块结构树补 `thinking.py` 一行并说明职责;§8 依赖纪律段补 `thinking.py` 的层位;D11 段说明推理决策已从 `providers.py` 拆出;§5.1 响应字段表补 `thinking_observation`;§7.8 遥测字段补新列。
|
||||
|
||||
**`research-wiki/schemas/llm-calls.md`**:标题与正文的"遥测 22 字段"已过期两轮,订正为 25;补 `thinking_observation` 的列定义与查询口径(示例:按模型统计各观测态占比,用于发现某模型何时开始观测不到推理)。
|
||||
|
||||
**`research-wiki/index.md`**:登记本 plan、design 与 finding。
|
||||
|
||||
**先失败后通过不适用于本任务**:纯文档同步,无行为变更。其验收是下方 grep 的可见输出——数字与模块名对不上就是没改完。
|
||||
|
||||
**`CHANGELOG.md`**:新增 1.3.1 条目。**断裂项置于条目最前**,沿用 1.3.0"请先读这一条"体例(设计 §13:版号既然不承担预警职责,预警由 CHANGELOG 独立扛)。三条必须显式列出——① `polygateway.providers` 的深路径 import 断裂(`ThinkingCapability` / `resolve_thinking` / `get_capability` / `register_capability` / `DEFAULT_CAPABILITIES` / `ThinkingUnsupportedError` 移入 `polygateway.thinking`,同时提升到包根,**推荐改用包根 import**);② `TelemetryRecorder.record_llm_call` 端口签名 24 参 → 25 参,自定义 recorder 实现须同步;③ M3 非流式开启推理时推理内容已计费却不回传,该档观测为 `UNKNOWN`,库现在会告警一次。
|
||||
|
||||
### Wiki 注册
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id 2026-08-25-thinking-observability-plan --title "推理可观测性一等化实现计划"
|
||||
.claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:2026-08-25-thinking-observability-plan" --to "design:2026-08-25-thinking-observability-design" --type implements --evidence "本计划实现该设计的全部落点"
|
||||
.claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||
```
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
grep -n '25 字段' README.md
|
||||
grep -n 'thinking.py' research-wiki/ARCHITECTURE.md
|
||||
grep -rn '22 字段' research-wiki/schemas/llm-calls.md # 预期无输出
|
||||
```
|
||||
|
||||
- [ ] Task 9 提交:`docs: sync the field counts and module map to 1.3.1`
|
||||
|
||||
---
|
||||
|
||||
## Task 10:合并前独立验证与发布 1.3.1
|
||||
|
||||
**文件**:修改 `pyproject.toml`、`src/polygateway/__init__.py`
|
||||
|
||||
### 行为
|
||||
|
||||
版本号两处改 `1.3.1`(`pyproject.toml` 与 `__init__.py.__version__` 必须一致);`CHANGELOG.md` 的"未发布"定版为 `## 1.3.1(2026-08-25)`。
|
||||
|
||||
本任务分两段,**中间是一道人类确认门**。
|
||||
|
||||
**第一段:分支内可自主完成的验证**——CHANGELOG 定版为 `## 1.3.1(2026-08-25)`;版本号两处改 `1.3.1`;`verification-before-completion` 派**全新上下文** verifier subagent 独立验证(跨 20+ 文件,属强制档);`requesting-code-review` 整分支审查;在分支上跑 `make ci` 与 `pytest -m slow`(约 20-40 分钟——四个 e2e 文件与 Redis 时间语义变体默认被 `-m 'not slow'` 排除,不显式跑等于没跑)。
|
||||
|
||||
**Gitea Wiki 文档站同步**(计划原本漏了,Task 9 实施时发现):`research-wiki/docs-convention.md` §2 明写"新公共 API / 新能力 → 对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG"、"发版(任何版本号) → `Home.md` 版本号与安装命令",且该文件第 26 行是一道门——**版本 bump 的提交不允许单独存在**。本版有 6 个新包根导出、1 个新公共字段、1 个端口签名变更,wiki 必须同步。wiki 是**独立 git 仓库**(需 clone),故拆成两半:**内容在第一段写好待推**,`git push` 归第二段(外发动作)。
|
||||
|
||||
**人类确认门**:以上全绿后停下,把验证结果交给人类,**取得明确同意后**才执行第二段。
|
||||
|
||||
**第二段:外发且难以撤销的动作,一律等确认**——合并 main(`--no-ff`)+ push → 打 tag 并 push → 构建 → 上传 registry → `pip download` 验证并解包确认新代码在内 → 建 Release + 挂仓库 + 核对包页面 → 关闭 issue #16 / #17 并附修复说明(诊断纠正 + 三层根因 + 落地形态)。顺序按 CLAUDE.md §4.4.1**不得跳步**:包上传与 tag 一旦推出去就收不回,registry 里的版本号也不能复用。
|
||||
|
||||
合并到 main 后须在 main 上**重跑** `make lint` 与全套件外加 `pytest -m slow`——分支上跑过不算,合并本身可能引入差异。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
conda run -n PolyGateway make ci
|
||||
conda run -n PolyGateway pytest -m slow
|
||||
python -c "import tomllib,pathlib,re
|
||||
v=tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']
|
||||
i=re.search(r'__version__ = \"(.+?)\"', pathlib.Path('src/polygateway/__init__.py').read_text()).group(1)
|
||||
assert v == i == '1.3.1', (v, i); print('版本号一致:', v)"
|
||||
```
|
||||
|
||||
预期:`make ci` 绿;slow 全绿;版本号一致性检查通过。
|
||||
|
||||
- [ ] Task 10 提交:`chore: cut 1.3.1`
|
||||
|
||||
---
|
||||
|
||||
## 任务依赖
|
||||
|
||||
Task 1 → 2 → 3 是硬序(枚举 → 模块就位 → 字段贯通)。Task 4、5、6 都依赖 3,彼此独立可并行。Task 7 依赖 3(需要字段)。Task 8 依赖 2(能力表已搬)。Task 9 依赖 6(字段数实测值)。Task 10 最后。
|
||||
|
||||
## 全局纪律
|
||||
|
||||
不做计划外的重构与抽象——尤其**不重构遥测组装路径**:`TelemetryEmitter._record` 已经是铁律要求的单一出口,三个 `emit_*` 是三个语义不同的入口,各自组装参数是职责所在(设计 §12)。
|
||||
|
||||
每个任务独立提交,提交前跑该任务的验证命令。任何一步的完成声明必须对应本会话内的工具输出。
|
||||
@@ -0,0 +1,297 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:2026-08-26-issue18-pg-test-isolation
|
||||
title: "issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标"
|
||||
date: 2026-08-26
|
||||
---
|
||||
|
||||
# issue #18 实现计划
|
||||
|
||||
> 类型:plan|日期:2026-08-26|分支 `fix/issue-18-pg-test-isolation`
|
||||
> 实现设计 `designs/2026-08-26-issue18-pg-test-isolation-design.md`(已过人类门)。设计的节号在下文直接引用;本计划只负责"动哪些文件、按什么顺序、怎么拿到证据"。
|
||||
> **本计划不涉及参考实现迁移,保真校验不适用。**
|
||||
|
||||
> [!CAUTION]
|
||||
> **执行期唯一的不可逆风险,写在最前面。** 设计 §5.3 的"最坏情况"用例故意让脚本以裸 `search_path` 跑到共享表上。它**只有在沙箱角色就位之后才可以跑**——若在角色化之前用 `.env` 的 `app`(实测 superuser)跑它,`--older-than-days 7 --apply` 会真的删掉共享表里的过期行(实测那 11 行 2026-07-22 的数据全部早于任何截止线)。
|
||||
> 这条风险决定了下面的任务顺序:**沙箱工厂(Task 1)→ retention 全面角色化(Task 2)→ 才写这条用例**。它没有常规意义上的"先红"路径,见 Task 2 的说明。
|
||||
|
||||
## 目标
|
||||
|
||||
让 `tests/integration` 不再依赖也不再污染共享表 `llm_calls`,并把"清理脚本删错表"从事后可观测改成物理上做不到,随后发布 1.3.2。
|
||||
|
||||
## 方案概述
|
||||
|
||||
先建 `tests/integration/conftest.py` 的一次性沙箱工厂(独立 schema + 可选独占登录角色),把 retention 测试全面切到对真表无任何权限的角色上并删除行数快照;再给 `telemetry_retention.py` 加 `--table SCHEMA.llm_calls`(目标由参数精确解析、绕开 `search_path`,表名段锁死);随后把 `test_postgres_telemetry.py` 的 7 条用例迁出真表、拆分 `_RUN_PREFIX` 的两个职责;最后加一道 lint 门防字面量回归,发布 1.3.2。
|
||||
|
||||
## 涉及技术
|
||||
|
||||
Python 3.12 / pytest + pytest-asyncio(auto) / asyncpg / PostgreSQL 16 权限与 `search_path` 语义 / argparse。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `tests/integration/conftest.py` | **新建** | `PgSandbox` 与 `pg_sandbox` 工厂;admin DSN 私有化 |
|
||||
| `tests/integration/test_pg_sandbox.py` | **新建** | 工厂自身的行为测试(含 setup 中途失败不留残留) |
|
||||
| `tests/integration/test_retention_tool_pg.py` | 修改 | 全部用例角色化;删行数快照;补 `--table` 与最坏情况用例 |
|
||||
| `tools/telemetry_retention.py` | 修改 | 新增 `--table`;PG 分支目标解析改为"显式限定名优先" |
|
||||
| `tests/unit/test_retention_tool.py` | 修改 | `--table` 的参数分类用例(不连库) |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | 7 条用例迁出真表;`_RUN_PREFIX` 双职责拆分;其余 fixture 收敛到工厂 |
|
||||
| `Makefile` | 修改 | `lint` / `check` 各加一道字面量门 |
|
||||
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` | 修改 | `--table` 用法与 1.3.2 定版 |
|
||||
|
||||
---
|
||||
|
||||
## 跨任务共享接口(Task 1 产出,Task 2/4/5 消费)
|
||||
|
||||
`tests/integration/conftest.py` 对外只有一个 fixture 与一个返回类型:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class PgSandbox:
|
||||
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
|
||||
|
||||
schema: str
|
||||
role: str | None
|
||||
dsn: str # 已挂 options=-csearch_path=<schema>
|
||||
bare_dsn: str | None # 同角色但不挂 search_path;role is None 时为 None
|
||||
```
|
||||
|
||||
```python
|
||||
async def pg_sandbox(
|
||||
*,
|
||||
ddl: str | None = None,
|
||||
extra: Sequence[str] = (),
|
||||
role: Literal["none", "owner", "grantee"] = "none",
|
||||
grants: Sequence[str] = ("SELECT", "INSERT"),
|
||||
) -> PgSandbox: ...
|
||||
```
|
||||
|
||||
### 三种 `role` 的语义
|
||||
|
||||
覆盖现有全部六个 fixture 的需求,**不得再加第四种**:
|
||||
|
||||
| `role` | schema 属主 | `ddl`/`extra` 由谁执行 | 返回 DSN 的身份 | 对应今天的 fixture |
|
||||
|---|---|---|---|---|
|
||||
| `"none"` | admin | admin | admin | `fresh_schema` / `legacy_schema` / `pre_tenant_schema` / `partitioned_schema` |
|
||||
| `"owner"` | 临时角色 | **临时角色自己**(故表属主 = 该角色) | 临时角色 | 无(本次新增,retention 全部用例用) |
|
||||
| `"grantee"` | admin | **admin**(故表属主 = admin,与最小权限现场一致) | 临时角色(只被 `GRANT USAGE ON SCHEMA` + 表级 `grants`,**绝不 GRANT CREATE**) | `least_privilege_dsn` / `least_privilege_pre_tenant_dsn` |
|
||||
|
||||
### `ddl` / `extra` 的执行契约
|
||||
|
||||
1. **调用方传的 DDL 一律不带 schema 限定**(`CREATE TABLE llm_calls (...)`,不是 `CREATE TABLE {schema}.llm_calls`)。工厂在执行前对该连接 `SET search_path = <schema>`,由 search_path 定位。这条统一了两种今天并存的写法——`PG_DDL` 本就是裸表名,而 `_LEGACY_DDL` / `_PRE_TENANT_DDL` 今天带 `{schema}` 占位,**Task 5 要把这两个常量的 `{schema}.` 前缀去掉**。
|
||||
2. `extra` 在**同一连接、同一 search_path** 下按给定顺序逐条执行,不包事务(分区子表这类 DDL 各自提交即可)。
|
||||
3. `ddl is None` 时只建空 schema,不执行任何建表语句。
|
||||
|
||||
### 临时角色的 DSN 构造
|
||||
|
||||
- 密码:模块级常量(测试专用,非机密),沿用今天 `_PROBE_PASSWORD` 的做法。
|
||||
- `bare_dsn`:把 admin DSN 里的 `//user:pass@` 段整体替换为 `//<role>:<密码>@`(`re.sub(r"//[^@/]+@", ...)`,`count=1`),**不追加任何 `options` 参数**——它的用途就是让 `search_path` 回落到 `"$user", public`。
|
||||
- `dsn`:在 `bare_dsn` 基础上追加 `options=-csearch_path%3D<schema>`,分隔符按 DSN 里是否已有 `?` 选 `?` 或 `&`。
|
||||
- `role="none"` 时 `dsn` 用 admin 身份加同样的 options,`bare_dsn` 为 `None`——admin 的裸 DSN 不对用例开放(设计 §7.1 约束 3)。
|
||||
|
||||
### 三条硬约束(设计 §5.1、§7.1,逐条都是验收点)
|
||||
|
||||
1. schema 名 `pgw_s_<12 位 hex>`、角色名 `pgw_r_<12 位 hex>`,**两者前缀有意不同**——同名会让 `"$user"` 遮蔽真表,最坏情况用例就测不到真现场。
|
||||
2. 资源逐步登记:每建成一个对象就把它的清理动作入栈,`except BaseException` 时**逆序**执行并 re-raise;`yield` 之后的 teardown 走同一条清理路径。单个沙箱的清理顺序固定为 `DROP SCHEMA IF EXISTS <s> CASCADE` → `DROP OWNED BY <r>` → `DROP ROLE IF EXISTS <r>`(`DROP OWNED BY` 必须在 `DROP ROLE` 之前,否则角色仍持有对象无法删除)。一次用例内建多个沙箱时,沙箱之间也按 LIFO 清理。
|
||||
3. `role != "none"` 时先查 `rolcreaterole OR rolsuper`,**在建任何对象之前** `pytest.skip`(`production_template` 的教训:`pytest.skip` 抛的是 `BaseException`,若在清理块内触发会去 DROP 从未建过的对象,把 skip 盖掉)。
|
||||
|
||||
---
|
||||
|
||||
## Task 1:沙箱工厂
|
||||
|
||||
- [ ] **文件**:`tests/integration/conftest.py`(新建)、`tests/integration/test_pg_sandbox.py`(新建)
|
||||
|
||||
**行为**:实现上文《跨任务共享接口》全部内容。DSN 读取沿用今天两个文件里的做法(`dotenv_values(".env")` 合并 `os.environ`,剥掉 `+driver`,缺则 `skip`,库名不以 `/polygateway` 结尾则 `pytest.fail`)——这段逻辑今天重复两份,本任务收敛为一份私有函数。
|
||||
|
||||
**测试要求(先红后绿的路径明确)**:先写 `test_pg_sandbox.py` 再写 `conftest.py`——此时 `pg_sandbox` fixture 不存在,pytest 报 `fixture 'pg_sandbox' not found`,六条用例全红,这就是本任务的先失败证据。随后实现工厂使其转绿。
|
||||
|
||||
| 用例 | 断言 |
|
||||
|---|---|
|
||||
| `role="none"` 建表 | 表落在 `sandbox.schema` 下;`sandbox.bare_dsn is None` |
|
||||
| `role="owner"` 建表 | 表属主 = `sandbox.role`;`sandbox.role != sandbox.schema` 且两者前缀不同 |
|
||||
| `role="owner"` 的 `bare_dsn` | `SHOW search_path` 为 `"$user", public`;用它解析 `llm_calls` 得到的**不是**沙箱里那张表 |
|
||||
| `role="grantee"` | 该角色 `CREATE TABLE` 被拒(`asyncpg.exceptions.InsufficientPrivilegeError`),`INSERT` 正常 |
|
||||
| **setup 中途失败** | 传一段必然报错的 `ddl`(如 `CREATE TABLE llm_calls (bad_type NOT_A_TYPE)`),捕获异常后查 `pg_namespace` / `pg_roles`:本次 uuid 对应的 schema 与角色**都不存在** |
|
||||
| teardown 后无残留 | 在用例内部记下 `sandbox.schema` / `sandbox.role`,用一个**更外层**的 fixture(在 `pg_sandbox` 之后销毁)回查两者均已消失 |
|
||||
|
||||
**验证**:
|
||||
```
|
||||
conda run -n PolyGateway pytest tests/integration/test_pg_sandbox.py -v
|
||||
```
|
||||
预期全绿;随后手工查实例:`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'` 与 `pg_roles` 同款查询均为空。
|
||||
|
||||
---
|
||||
|
||||
## Task 2:retention 测试角色化,删除行数快照
|
||||
|
||||
- [ ] **文件**:`tests/integration/test_retention_tool_pg.py`(修改)
|
||||
|
||||
**必须在 Task 3 之前完成**——见文首 CAUTION。
|
||||
|
||||
**行为**:
|
||||
|
||||
1. 删除 `_public_count`、`before_public` 与那条行数断言;删除本地的 `_make_schema` / `_drop_schema` / `_search_path_dsn` / `dsn` fixture,全部改用 `pg_sandbox`。
|
||||
2. **凡启动脚本的用例一律 `role="owner"`**(设计 §5.1,无一例外,含 dry-run 与分区让路两条)。
|
||||
3. 现有三条用例的其余断言逐条保留:`将删除行数: 5`、`'acme': 3`、批次 1/3 存在而批次 4 不存在、`已删除 5 行`、剩余 `fresh-1`/`fresh-2`、分区表退出 3 且含 `DROP PARTITION`/`DETACH`、缺 asyncpg 退出 2。
|
||||
4. 新增设计 §5.3 的**最坏情况**用例:用 `sandbox.bare_dsn`、不给 `--table`、`--older-than-days 7 --apply`。断言退出 **2**、stderr 非空且含 `llm_calls`、沙箱表一行不少。**不断言 PG 的英文错误原文**(`lc_messages` 不由测试掌握),**测试代码里不得出现 `public.llm_calls` 字面量**。
|
||||
|
||||
**测试证据(这条用例没有常规先红路径,如实记录)**:让它变红的唯一方式是把角色换回 admin superuser——那会真删共享表的行,绝不执行。它的证伪由 `findings/2026-08-26-issue18-shared-pg-test-isolation.md` §7 的探针 6/7 提供:同款临时角色对真表的 `COUNT` 与 `DELETE` 均返回 `InsufficientPrivilegeError`。**提交说明里必须写明这一点**,不得含糊成"已验证"。
|
||||
|
||||
其余改动的先红路径正常:删掉 `_public_count` 之前,先把三条既有用例切到沙箱并跑通(此时它们仍带旧断言),再删断言——若沙箱切换有问题,旧断言会先报出来。
|
||||
|
||||
**验证**:
|
||||
```
|
||||
conda run -n PolyGateway pytest tests/integration/test_retention_tool_pg.py -v
|
||||
```
|
||||
预期全绿;连跑三次结果一致。
|
||||
|
||||
---
|
||||
|
||||
## Task 3:`--table` 参数与精确解析
|
||||
|
||||
- [ ] **文件**:`tools/telemetry_retention.py`(修改)、`tests/unit/test_retention_tool.py`(修改)、`tests/integration/test_retention_tool_pg.py`(追加用例)
|
||||
|
||||
**顺序**:**先写测试再改脚本**——四条集成用例与五条单测在脚本未改时全部先红(`--table` 未定义,argparse 直接以退出码 1 拒绝,而用例期望的是别的码/别的 stdout),实现后转绿。这就是本任务的先失败证据;Task 2 已先行完成,故这些用例从第一次运行起就跑在沙箱角色之下。
|
||||
|
||||
**脚本行为**(设计 §4):
|
||||
|
||||
| 项 | 要求 |
|
||||
|---|---|
|
||||
| 参数 | `--table SCHEMA.NAME`,仅 `--backend postgres` 接受 |
|
||||
| 校验(全部退出 **1**) | sqlite 给了它;不是恰好两段;任一段为空;任一段含 `.` 或 `"`;**表名段不等于 `llm_calls`** |
|
||||
| 解析 | 给了 `--table` 时用 `to_regclass($1)` 传 `"<schema>"."llm_calls"`(`_quote` 包裹),绕开 `search_path`;未给时维持今天的裸 `TABLE` 解析 |
|
||||
| 解析不到 | 退出 **2**,消息点名显式指定的表,并附一句"PG 中未加引号建的标识符在 catalog 里是小写" |
|
||||
| 无权限 | 后续 `COUNT` 抛 `PostgresError`,走既有 except → 退出 **2**(不新增分支) |
|
||||
| 分区表 | 仍退出 **3**,逻辑不动 |
|
||||
| 提示行 | `--apply` 且**未**给 `--table` 时,在"目标表: x.y"之后打印一行,指出目标由 `search_path` 推断、可用 `--table` 钉死;dry-run 不打 |
|
||||
|
||||
`--help` 的 epilog 补两句:本脚本只清理 `llm_calls`;含点或引号的复杂标识符不支持,此时退回不给 `--table` 的路径。
|
||||
|
||||
**单测**(`tests/unit/test_retention_tool.py`,不连库):`TestUsageErrors` 加五条,对应上表五种退出 1 的情形,逐条断言 stderr 含 `--table`;`TestHelp` 加一条断言 epilog 点明表名固定为 `llm_calls`。
|
||||
|
||||
**集成用例**(`test_retention_tool_pg.py`,全部 `role="owner"`):
|
||||
|
||||
| 用例 | 构造 | 预期 |
|
||||
|---|---|---|
|
||||
| 显式指定成功 | `--table <sandbox.schema>.llm_calls` + `--apply` | 退出 0,删除结果与不给 `--table` 时逐条一致 |
|
||||
| 指向不存在的 schema | `--table pgw_s_nosuchxxxxxxxx.llm_calls` | 退出 **2**,stderr 点名该表;沙箱表一行不少 |
|
||||
| 指向无权的表 | 建两个 `role="owner"` 沙箱,用 A 的 DSN 指 B 的表 | 退出 **2**;A、B 两张表都不变 |
|
||||
| 指向分区表 | 分区沙箱 + `--table` | 仍退出 **3**,含 `DROP PARTITION` / `DETACH` 字样 |
|
||||
| 提示行(设计验收 #2) | 沙箱 DSN + `--apply`,**不给** `--table` | stdout 含推断提示。设计原写"单测断言 stdout",但该行只在 PG 分支打印、不连库触发不到,故落在集成层;设计 §11 判据 2 已同步更正 |
|
||||
|
||||
**验证**:
|
||||
```
|
||||
conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4:7 条用例迁出真表,`_RUN_PREFIX` 拆职责
|
||||
|
||||
- [ ] **文件**:`tests/integration/test_postgres_telemetry.py`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
1. 七条用例改用 `pg_sandbox(role="none")`:`TestObservabilityColumns::test_values_round_trip`、`TestSchema` 三条、`TestDegradation::test_row_failure_does_not_poison_later_rows` 与 `test_aclose_idempotent`、`TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max`。
|
||||
2. `test_schema_has_frozen_columns_in_order` 的 `information_schema` 查询补 `table_schema = $1`(设计 §6.2;仓库注释已记载该隐患)。
|
||||
3. `TestPoolFootprint` **保留唯一 `application_name`**,就地生成 uuid(设计 §6.1)——这是实例级资源,schema 隔离对它无效。
|
||||
4. 删除 `_RUN_PREFIX` 的行隔离用途:`_cid()` 的 63 处调用机械替换为字面量(`_cid("c1")` → `"c1"`);5 处 `LIKE` 逐条处置——`dsn` fixture teardown 的 `DELETE` 整条删除,`test_concurrent_writes_all_land` 的计数改 `COUNT(*)`,其余三处(legacy / least_privilege / manual-lp)改为不带前缀的精确条件。
|
||||
5. 删除已无引用的本地 `dsn` fixture 与其 teardown。
|
||||
|
||||
**测试证据**:判据 6c 有明确先红路径——先在库里手工留一个残留同名表(`CREATE SCHEMA pgw_s_leftover; CREATE TABLE pgw_s_leftover.llm_calls (call_id TEXT)`),此时 `test_schema_has_frozen_columns_in_order` 因少了 `table_schema` 过滤而红;补上过滤后转绿;用完删掉该残留 schema。其余六条属迁移,证据形式是迁移前后断言逐条对照(设计 §11 判据 6),差异只允许出现在"表在哪"与"查询是否带 schema 过滤"两处——**这是回归门不是先红门,提交说明里如实这么写**。
|
||||
|
||||
**验证**:
|
||||
```
|
||||
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
|
||||
```
|
||||
判据 6b 另做:两个 shell 同时跑 `TestPoolFootprint` 那一条,两边都绿。
|
||||
|
||||
---
|
||||
|
||||
## Task 5:其余 fixture 收敛到工厂
|
||||
|
||||
- [ ] **文件**:`tests/integration/test_postgres_telemetry.py`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
1. `legacy_schema`、`pre_tenant_schema`、`fresh_schema`、`partitioned_schema` 改为 `role="none"`;`least_privilege_dsn`、`least_privilege_pre_tenant_dsn` 改为 `role="grantee"`。
|
||||
2. 按接口契约,`_LEGACY_DDL` 与 `_PRE_TENANT_DDL` 两个常量去掉 `{schema}.` 前缀与 `.format(schema=...)` 调用,改为裸表名由工厂的 search_path 定位。
|
||||
3. `production_template` **不收敛**:它要建三个角色、跑 README 解析出的整套模板 SQL、按月建分区,权限语义与失败期清理都是它自己的(设计 §7.1 末段与 Codex 意见 3)。工厂强行接管会把这些语义压扁。本任务只把它内部的 `_cid()` 调用一并处理掉。
|
||||
|
||||
**测试证据**:这些 fixture 的既有用例断言**一行不改**——它们是这次收敛的验收器,改了就失去验收意义。这是回归门。
|
||||
|
||||
**验证**:同 Task 4 的命令,预期全绿;在无 CREATEROLE 的账号下 `least_privilege` 系列仍能正确 skip。
|
||||
|
||||
---
|
||||
|
||||
## Task 6:lint 门与字面量清理
|
||||
|
||||
- [ ] **文件**:`Makefile`(修改)、`tests/integration/*.py`(注释措辞)
|
||||
|
||||
**行为**:`lint` 与 `check` 各加一步——`tests/` 下命中字面量 `public.llm_calls` 即 `exit 1` 并打印命中行。注释与 docstring **同样不豁免**,现有"共享的 public.llm_calls"改写为"共享表 `llm_calls`"。
|
||||
|
||||
Makefile 里这道门的注释必须写明它的定位(设计 §7.2):**烟雾报警器,不是隔离证明**——它拦不住 `f"{schema}.{table}"` 拼接与参数化查询,真正的隔离来自工厂不交出 admin DSN、脚本以无权角色运行。
|
||||
|
||||
**测试证据**:故意加一行含该字面量的注释 → `make lint` 失败并打印该行;移除后 → 通过。
|
||||
|
||||
**验证**:
|
||||
```
|
||||
make lint && make check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7:独立验证(合并前硬门)
|
||||
|
||||
- [ ] 派**全新上下文**的 verifier subagent(`verification-before-completion`),交给它设计 §11 的判据表逐条核对,重点:
|
||||
- 判据 3(最坏情况删不掉任何行)是否真由权限拒绝达成,而非碰巧——它没有先红证据,须由 verifier 独立复核 findings §7 的探针与用例断言是否真的对应同一条防线
|
||||
- 判据 4:整套 `tests/integration` 连跑三次,**其间由 verifier 手工改动真表行数**(插入若干行再删掉),全程应无任何用例受影响
|
||||
- 判据 6:7 条用例迁移前后断言逐条对照
|
||||
- `--table` 的五种退出 1 与三种退出 2/3 是否都有用例覆盖
|
||||
- `tests/` 与实例上是否留下任何 `pgw_%` 残留
|
||||
|
||||
---
|
||||
|
||||
## Task 8:文档与版本号
|
||||
|
||||
- [ ] **文件**:`README.md`、`CHANGELOG.md`、`pyproject.toml`、`src/polygateway/__init__.py`
|
||||
|
||||
- README:`--table` 用法落在两处——"存量兜底"表格行与 SQLite 侧段落之后的脚本说明段;写明表名固定为 `llm_calls`。安装约束是 `>=1.3.0,<2` 范围式,**本版无需改**(已核)。
|
||||
- CHANGELOG:按设计 §10 如实写明 `tools/` 与 `tests/` 都不在 pip 包内,**1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同**,本版内容是运维脚本的契约扩展与测试确定性,不得包装成库能力更新。
|
||||
- 版本号两处一致改 `1.3.2`。
|
||||
- `make wiki-check WIKI=<路径>` 跑过(公共行为变更须同步用户文档站,`docs-convention.md` §2)。
|
||||
|
||||
---
|
||||
|
||||
## Task 9:发布 1.3.2
|
||||
|
||||
- [ ] 按 CLAUDE.md §4.4.1 九步执行,一步不跳:合并 main(`--no-ff`)→ 在 main 上重跑 `make lint` 与全套件 → **显式跑 `pytest -m slow`** → 打 tag 并 push → `rm -rf dist && python -m build && twine check` → 上传 registry(token 走 `TWINE_PASSWORD`,不进命令行)→ `pip download` 验证并解包确认 → 建 Release + 挂仓库 → 以下游视角打开包页面与 Releases 页核对。
|
||||
- [ ] 关闭 issue #18,正文指向本计划与设计。
|
||||
|
||||
---
|
||||
|
||||
## 审查留痕(Codex,2026-08-26)
|
||||
|
||||
报 5 项,**全部采纳**:
|
||||
|
||||
| # | 意见 | 处置 |
|
||||
|---|---|---|
|
||||
| 1 | `ddl`/`extra` 的执行身份、search_path、顺序、schema 占位、失败清理顺序都没写成契约 | 新增《`ddl`/`extra` 的执行契约》一节;并据此在 Task 5 追加"去掉两个 DDL 常量的 `{schema}` 占位"这一步 |
|
||||
| 2 | 临时角色的密码来源与 DSN 构造规则缺失 | 新增《临时角色的 DSN 构造》一节 |
|
||||
| 3 | **Task 1 先实现 `--table`、Task 3 才写集成用例,先红路径不可能成立** | 采纳,任务重排:沙箱工厂 → retention 角色化 → `--table`(测试先写)。重排同时让 `--table` 的集成用例从第一次运行起就在沙箱角色之下,与文首 CAUTION 一致 |
|
||||
| 4 | 工厂测试缺"先写失败测试"的明确步骤 | Task 1 写明:先写 `test_pg_sandbox.py`,此时 `fixture 'pg_sandbox' not found` 全红 |
|
||||
| 5 | 设计验收 #2 说"单测断言 stdout",计划却放在集成层 | 核实后确认是**设计写错了**——该提示行只在 PG 分支打印,不连库的单测触发不到。已就地更正设计 §11 判据 2,并在 Task 3 注明 |
|
||||
|
||||
另外据 Codex 对 Task 4/5 的观察,两处证据形式(回归门而非先红门)已在任务里如实标注,不含糊成"已验证"。
|
||||
|
||||
## Wiki 注册
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan \
|
||||
--id 2026-08-26-issue18-pg-test-isolation --title "issue #18 实现计划"
|
||||
.claude/tools/research_wiki.py add_edge research-wiki/ \
|
||||
--from "plan:2026-08-26-issue18-pg-test-isolation" \
|
||||
--to "design:2026-08-26-issue18-pg-test-isolation" --type implements
|
||||
.claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||
```
|
||||
@@ -0,0 +1,418 @@
|
||||
# 实现计划: 推理档位一等化
|
||||
|
||||
- **设计**: `research-wiki/designs/2026-09-04-reasoning-effort-design.md`(2026-09-04 人类已批准)
|
||||
- **目标**: 把 `enable_thinking: bool | None` 升级为可表达厂商档位的 `Effort` 词汇,让「关不掉的模型」「打空的档位」从静默失效变成带出路的报错。
|
||||
- **方案概述**: 新增八档封闭枚举 `Effort`(含 `auto`);能力表从 `can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`;provider 的两个固定片段改为 `ThinkingWire`(off / on_base / effort_key);档位入口取「源级默认 + 请求级覆盖」,进缓存 key 与遥测各一列。
|
||||
- **涉及技术**: Python 3.12 `StrEnum`、frozen dataclass、pydantic-settings env 解析、SQLite/Postgres DDL 补列、pytest。
|
||||
- **保真校验**: **不适用**。本计划实现的是库自研的推理决策(`thinking.py` 系 2026-08-25 新建),不属 ARCHITECTURE §1.4 的移植蓝本;且 `reference/` 三项目当前不在工作区(见设计 §12),无可比对源。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/types.py` | 修改 | 新增 `Effort` 枚举;`SourceConfig`/`ChatRequest` 各加档位字段 |
|
||||
| `src/polygateway/thinking.py` | 修改 | `ThinkingCapability` 重构、`resolve_thinking` 五关、`reconcile_thinking` 判据、默认能力表重写 |
|
||||
| `src/polygateway/providers.py` | 修改 | `ThinkingWire` 新类型替换两个片段;`DEFAULT_PROFILES` 扩到 8 段 |
|
||||
| `src/polygateway/config.py` | 修改 | 两个新 env 键的解析与矛盾校验 |
|
||||
| `src/polygateway/client.py` | 修改 | `chat()` 签名加档位;`_fingerprint_mark` 纳入源级档位 |
|
||||
| `src/polygateway/middleware/cache.py` | 修改 | `build_cache_key` 纳入请求级档位 |
|
||||
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 与三个 emit 入口传递生效档位 |
|
||||
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 加一参(25 → 26 字段) |
|
||||
| `src/polygateway/telemetry/schema.py` | 修改 | `COLUMNS`、两端 DDL、补列声明 |
|
||||
| `src/polygateway/telemetry/{sqlite,postgres}.py` | 修改 | 落库新列 |
|
||||
| `src/polygateway/transports/openai_compat.py` | 修改 | 生效档位解析接线、告警节流键 |
|
||||
| `src/polygateway/__init__.py` | 修改 | 导出 `Effort`、`ThinkingWire` |
|
||||
| `.env.example` | 修改 | 两个新键的模板与注释 |
|
||||
| `tests/unit/test_thinking.py` | 修改 | 位置参数构造迁移 + 五关用例 |
|
||||
| `tests/unit/test_providers.py` | 修改 | `ThinkingWire` 用例 |
|
||||
| `tests/unit/test_cache.py` | 修改 | 档位进 key 的用例 |
|
||||
| `tests/unit/test_openai_compat.py` | 修改 | transport 接线与节流用例 |
|
||||
| `tests/unit/test_telemetry.py`、`tests/integration/test_redis_cache.py` | 修改 | 列数断言与缓存 key 回归 |
|
||||
| `tests/e2e/test_thinking_live.py` | 修改 | `can_disable` 读法迁移;新增逐模型档位实测(标 `slow`) |
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
```python
|
||||
# types.py
|
||||
class Effort(StrEnum):
|
||||
NONE = "none"; AUTO = "auto"; MINIMAL = "minimal"; LOW = "low"
|
||||
MEDIUM = "medium"; HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
|
||||
|
||||
_ORDER = (Effort.NONE, Effort.MINIMAL, Effort.LOW, Effort.MEDIUM,
|
||||
Effort.HIGH, Effort.XHIGH, Effort.MAX) # auto 不参与强弱序
|
||||
```
|
||||
|
||||
```python
|
||||
# providers.py
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingWire:
|
||||
off: Mapping[str, Any] | None
|
||||
on_base: Mapping[str, Any] | None
|
||||
effort_key: str | None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderProfile:
|
||||
name: str
|
||||
thinking: ThinkingWire
|
||||
strip_think_tags: bool
|
||||
supports_native_schema: bool = False
|
||||
```
|
||||
|
||||
```python
|
||||
# thinking.py
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingCapability:
|
||||
supported_efforts: tuple[Effort, ...]
|
||||
evidence: str
|
||||
|
||||
@property
|
||||
def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts
|
||||
@property
|
||||
def cheapest_effort(self) -> Effort | None: ... # 除 NONE 外按 _ORDER 最弱的一档
|
||||
@property
|
||||
def is_tiered(self) -> bool: ... # 除 NONE/AUTO 外仍有 ≥1 档
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingResolution:
|
||||
"""注入片段 + **实际**生效档。
|
||||
|
||||
返回 dataclass 而非裸 Mapping(CLAUDE.md 4.3「返回类型用 frozen dataclass」):
|
||||
`nearest` 映射后请求档与实际档不同,遥测必须记后者,否则 T10 的压测按档位
|
||||
分组时,被映射过的行会挂在一个从未真正发出的档下(Codex 审查指出)。
|
||||
"""
|
||||
payload: Mapping[str, Any]
|
||||
applied_effort: Effort | None # Phase 1(不表态)为 None
|
||||
|
||||
def resolve_thinking(
|
||||
profile: ProviderProfile,
|
||||
capability: ThinkingCapability | None,
|
||||
effort: Effort | None,
|
||||
*,
|
||||
model: str,
|
||||
fallback: str = "error", # "error" | "nearest"
|
||||
warn_unregistered: bool = True,
|
||||
) -> ThinkingResolution: ...
|
||||
|
||||
def reconcile_thinking(
|
||||
*,
|
||||
effort: Effort | None,
|
||||
observation: ThinkingObservation,
|
||||
capability: ThinkingCapability | None,
|
||||
model: str,
|
||||
) -> str | None: ...
|
||||
```
|
||||
|
||||
```python
|
||||
# types.py 字段追加(均追加在末尾,不扰动既有位置构造)
|
||||
# SourceConfig: reasoning_effort: Effort | None = None
|
||||
# effort_fallback: str = "error"
|
||||
# ChatRequest: reasoning_effort: Effort | None = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — `Effort` 词汇与能力表重构
|
||||
|
||||
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_thinking.py`(改)、`tests/e2e/test_thinking_live.py`(改)
|
||||
|
||||
**行为**:
|
||||
1. `types.py` 新增 `Effort` 与 `_ORDER`(见上)。放 `types.py` 而非 `thinking.py`: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import(依赖铁律)。
|
||||
2. `ThinkingCapability` 改为 `supported_efforts` + `evidence`,加两个 `@property` 派生量。构造期校验: `supported_efforts` 非空、元素唯一、全部属 `Effort`,违反即 `ValueError`。
|
||||
3. `DEFAULT_CAPABILITIES` 按设计 §8 落库规则重写(见下表)。
|
||||
4. 迁移三处既有读点: `thinking.py` 内部读 `capability.can_disable` 改为读派生属性(行为不变);`tests/unit/test_thinking.py` 的 `ThinkingCapability(True, "实测")` 位置参数构造改为关键字构造;`tests/e2e/test_thinking_live.py` 读 `can_disable` 处确认派生属性可用。
|
||||
5. `__init__.py` 导出 `Effort`(包根导出是既有纪律: 深路径 import 正是模块重组会打断下游的原因,见 ARCH D11)。
|
||||
|
||||
**初始 `DEFAULT_CAPABILITIES`**(evidence 一律以 `2026-09-04 文档推定(来源),待经 new-api 实测` 开头):
|
||||
|
||||
| model | supported_efforts |
|
||||
|---|---|
|
||||
| `glm-5.3`, `glm-5.3-flash` | `(LOW, HIGH, MAX)` |
|
||||
| `glm-5.2` | `(NONE, HIGH, MAX)` |
|
||||
| `glm-5`, `glm-5.1`, `glm-4.6v` | `(NONE, AUTO)` |
|
||||
| `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | `(NONE, HIGH, MAX)` |
|
||||
| `gpt-5.4`, `gpt-5.5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH)` |
|
||||
| `claude-opus-5`, `claude-sonnet-5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH, MAX)` |
|
||||
| `gemini-3.1-pro` | `(LOW, MEDIUM, HIGH)` |
|
||||
| `kimi-k3` | `(LOW, HIGH, MAX)` —— 保守登记,evidence 注明 OpenRouter 标可关但官方档位无 `none` |
|
||||
| `MiniMax-M3` | `(NONE, AUTO)` |
|
||||
| `MiniMax-M2.7`, `MiniMax-M2.5` | `(AUTO,)` |
|
||||
| `qwen-plus-latest`, `qwen3.5-flash`, `qwen3.6-plus`, `qwen3.7-max`, `qwen3.7-plus` | `(NONE, AUTO)` |
|
||||
|
||||
`claude-haiku-5`、`gemini-3-flash`、`kimi-for-coding` **不登记**(档位清单未知,走 Phase 3)。现有三条 MiniMax 条目的 evidence 原文保留并追加新形状说明——它们是实测得来的,比文档推定更硬,不得覆盖。
|
||||
|
||||
**验收**: `can_disable` 对 11 类模型的返回与上表一致;`cheapest_effort` 对 `(LOW, HIGH, MAX)` 返回 `LOW`、对 `(NONE, AUTO)` 返回 `AUTO`、对 `(AUTO,)` 返回 `AUTO`;`is_tiered` 对 `(LOW, HIGH, MAX)` 为真、对 `(NONE, AUTO)` 与 `(AUTO,)` 为假;空元组构造报 `ValueError`。
|
||||
|
||||
**测试**(先失败后通过): `tests/unit/test_thinking.py::test_capability_derives_can_disable`、`::test_cheapest_effort_skips_none`、`::test_is_tiered_excludes_none_and_auto`、`::test_empty_efforts_rejected`。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_package.py -v` → PASS;`conda run -n PolyGateway make lint` → PASS(含 import-linter: `Effort` 落 `types.py` 不得产生反向依赖,设计 §13 第 6 条)
|
||||
|
||||
- [ ] 提交: `refactor: make capability a tier list, since "can it be off" is one entry in it`
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — `ThinkingWire` 与 8 段 provider 表
|
||||
|
||||
**文件**: `src/polygateway/providers.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_providers.py`(改)
|
||||
|
||||
**行为**:
|
||||
1. 新增 `ThinkingWire`(见关键接口)。`None` 的语义严格沿用 issue #5: `on_base is None` = **开启形态未知**(请求开启档时报错),`off is None` = 该 provider 无关闭形态,`effort_key is None` = 该 provider 无档位概念。三者语义互不重叠,docstring 必须写明。
|
||||
2. `ProviderProfile.thinking_on`/`thinking_off` 两字段替换为 `thinking: ThinkingWire`。
|
||||
3. `DEFAULT_PROFILES` 由 4 段扩到 8 段:
|
||||
|
||||
| provider | off | on_base | effort_key |
|
||||
|---|---|---|---|
|
||||
| `qwen` | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None` |
|
||||
| `deepseek` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
|
||||
| `zhipu` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
|
||||
| `moonshot` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
|
||||
| `minimax` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
||||
| `openai` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
||||
| `anthropic` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
||||
| `google` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
||||
|
||||
`__init__.py` 同步导出 `ThinkingWire`。`openai` 段的两档由 `None`(未知)改为 OpenAI 标准形态,是本任务唯一的语义变更,理由写进注释: gpt-5.x 的 `reasoning_effort` 是 OpenAI 官方字段而非厂商方言,兜底段发它不会打到不认识它的厂商;真正未知形态的 provider 仍应走 `register_provider`。
|
||||
|
||||
**验收**: `get_provider("zhipu").thinking.effort_key == "reasoning_effort"`;未注册名仍报错且错误文案列出全部 8 段;`register_provider` 仍返回新表不改共享状态。
|
||||
|
||||
**测试**(先失败后通过): `tests/unit/test_providers.py::test_all_eight_profiles_registered`、`::test_wire_none_semantics_distinct`(三种 `None` 各自的含义不混淆)。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS
|
||||
|
||||
- [ ] 提交: `feat: give zhipu, moonshot, anthropic and google a wire of their own`
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — `resolve_thinking` 五道关卡与 nearest 映射
|
||||
|
||||
**文件**: `src/polygateway/thinking.py`(改)、`tests/unit/test_thinking.py`(改)
|
||||
|
||||
**行为**: 按下表实现,**顺序不可调换**,每关的理由写进 docstring。
|
||||
|
||||
| Phase | 条件 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | `effort is None` | 返回 `{}` |
|
||||
| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
|
||||
| 3 | `capability is None` | `warn_unregistered` 为真时 warning,随后按 wire 注入,**不校验档位** |
|
||||
| 4 | `effort is NONE` 且 `not capability.can_disable` | `ThinkingUnsupportedError`,文案含 `cheapest_effort` 与 env 键名 |
|
||||
| 5 | `effort not in supported_efforts` 且 `fallback == "error"` | `ThinkingUnsupportedError`;文案按 `capability.is_tiered` 分叉——档位型列出可选档,纯开关型说明「该模型只有开关没有档位,可用 `auto`/`none`」(设计 §3.2 第三个派生量的用途) |
|
||||
|
||||
Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。
|
||||
|
||||
**注入形态**:
|
||||
- `effort is NONE` → `wire.off`;`wire.off is None` 时报错(该 provider 无关闭形态)。
|
||||
- `effort is AUTO` → `wire.on_base`(不附档位)。这与旧 `thinking_on` 逐字节等价。
|
||||
- 其余档 → `{**wire.on_base, wire.effort_key: effort.value}`;`effort_key is None` 时报错并说明该 provider 只有开关没有档位。
|
||||
|
||||
**nearest 映射**(`fallback == "nearest"`,人类 2026-09-04 复核确认实现): 按 `_ORDER` 在 `supported_efforts` 中取距请求档**位序最近**者,等距时**取弱侧**(省钱优先,不替下游涨价);`AUTO` 不参与距离计算,仅当它是唯一候选时才被选中;映射发生时 warning 记明「请求档 → 实际档 → 模型」。`effort is NONE` 且不可关时**不走映射**——那是 Phase 4 的领域,必须报错给出路,否则又变成静默降级。
|
||||
|
||||
**验收**: 五关各自触发与不触发;`medium` 在 `(LOW, HIGH, MAX)` 上 `nearest` 映射到 `LOW`(等距取弱);`minimal` 映射到 `LOW`;`xhigh` 映射到 **`HIGH`**(与 `MAX` 等距,按「等距取弱」规则走——初稿此处写 `MAX` 是笔误,规则优先于例子)。**候选剔除 `none`**: 否则 `(none, auto)` 模型上请求 `high` 会被映射成 `none`,把「想浅一点」变成「别想了」,方向反转即 issue #20 那类静默失效。**`auto` 不受 Phase 5 清单约束**: 它在请求体里是「不写 `effort_key`」而非某个取值,可满足性只取决于 `on_base` 在不在;否则 `enable_thinking=True → AUTO` 会让存量源当场报错(能力表里档位型模型都不含 `auto`)。
|
||||
|
||||
**测试**(先失败后通过,**五关各一条**,兑现设计 §13 第 1 条): `::test_phase1_absent_effort_injects_nothing`、`::test_phase2_unknown_wire_points_to_register`、`::test_phase3_unregistered_warns_then_injects`(并断言 `warn_unregistered=False` 时不喊)、`::test_phase4_before_phase5`(请求 `none` 打到 glm-5.3,断言文案**含** `cheapest_effort` 值与 `REASONING_EFFORT` 键名)、`::test_phase5_lists_tiers_for_tiered_model`、`::test_phase5_says_toggle_only_for_switch_model`。
|
||||
另: `::test_nearest_ties_go_cheaper`、`::test_none_never_maps`、`::test_auto_injects_on_base_only`、`::test_effort_key_none_rejects_tier`、`::test_resolution_reports_applied_effort_after_mapping`(请求 `medium` → 断言 `applied_effort is Effort.LOW`)。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py -v` → PASS
|
||||
|
||||
- [ ] 提交: `feat: refuse an impossible tier with the cheapest one that model does have`
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — 源级配置入口
|
||||
|
||||
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/config.py`(改)、`.env.example`(改)、`tests/unit/test_config.py`(改)
|
||||
|
||||
**行为**:
|
||||
1. `SourceConfig` 末尾追加 `reasoning_effort: Effort | None = None` 与 `effort_fallback: str = "error"`。
|
||||
2. `config.py` 的 `_SOURCE_FIELDS` 增两行: `"REASONING_EFFORT": ("reasoning_effort", "effort")`、`"EFFORT_FALLBACK": ("effort_fallback", "str")`。新增 `"effort"` 解析类型: 值必须属 `Effort` 取值域,否则报错并列出八档。
|
||||
3. `effort_fallback` 值域 `{"error", "nearest"}`,越界即报错(与 `_SELECTORS`/`_QUOTA_FULL` 同款 frozenset 校验)。
|
||||
4. **矛盾校验**(构造期): 同源同时给出 `enable_thinking` 与 `reasoning_effort` 且语义冲突时 `ValueError`。冲突定义: `enable_thinking is True` 且 `reasoning_effort is NONE`;或 `enable_thinking is False` 且 `reasoning_effort not in (None, Effort.NONE)`。二者一致(如 `False` + `none`)则放行。
|
||||
5. `.env.example` 加两键模板,注释写明八档取值、与 `ENABLE_THINKING` 的等价关系及矛盾会报错。
|
||||
|
||||
**验收**: `LLM__ZHIPU__1__REASONING_EFFORT=low` 解析为 `Effort.LOW`;写 `lowest` 报错且文案列出八档;`ENABLE_THINKING=true` + `REASONING_EFFORT=none` 构造期报错。
|
||||
|
||||
**测试**(先失败后通过): `tests/unit/test_config.py::test_effort_key_parsed`、`::test_invalid_effort_lists_vocabulary`、`::test_contradictory_thinking_flags_rejected`、`::test_consistent_flags_allowed`。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py -v` → PASS
|
||||
|
||||
- [ ] 提交: `feat: let a source name its reasoning tier, and say so when it contradicts itself`
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — 请求级入口与优先级
|
||||
|
||||
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改,`effective_effort` 定义处)、`src/polygateway/client.py`(改)、`tests/unit/test_client.py`(改)
|
||||
|
||||
**行为**:
|
||||
1. `ChatRequest` 末尾追加 `reasoning_effort: Effort | None = None`。
|
||||
2. `GatewayClient.chat()` 增关键字参数 `reasoning_effort: Effort | None = None`,存入 `ChatRequest`。
|
||||
3. 新增纯函数(放 `thinking.py`,与其余推理决策同处):
|
||||
|
||||
```python
|
||||
def effective_effort(
|
||||
*, request_effort: Effort | None, source_effort: Effort | None,
|
||||
enable_thinking: bool | None,
|
||||
) -> Effort | None:
|
||||
"""生效档位: 请求级 > 源级 > enable_thinking 语法糖 > None。"""
|
||||
```
|
||||
|
||||
语法糖映射: `True` → `Effort.AUTO`(注入 `on_base`,与旧行为逐字节等价,且不依赖能力表);`False` → `Effort.NONE`;`None` → 不表态。
|
||||
|
||||
**验收**: 三层优先级各自生效;请求级 `None` 不会覆盖源级已配的档;只配 `enable_thinking=True` 的存量源解析为 `AUTO` 且最终 payload 与升级前逐字节相同。
|
||||
|
||||
**测试**(先失败后通过): `::test_request_effort_wins_over_source`、`::test_none_request_does_not_clear_source`、`::test_enable_thinking_true_is_auto`、`::test_legacy_on_tier_matches_old_fragment`(回归门: **仅**对 `on_base` 完整表达「开」的 provider——qwen/deepseek/zhipu/moonshot——断言逐字节不变;minimax/openai/anthropic/google 的开档旧版硬编码 `medium`、新版不注入,是设计 §4.2 声明过的有意变更)。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
|
||||
|
||||
- [ ] 提交: `feat: let one call ask for a different tier than its source defaults to`
|
||||
|
||||
---
|
||||
|
||||
## Task 5b — 让 transport 拿得到请求级档位(端口签名扩展)
|
||||
|
||||
**文件**: `src/polygateway/ports.py`(改)、`src/polygateway/middleware/retry.py`(改)、`src/polygateway/transports/openai_compat.py`(改)、`tests/unit/test_retry.py`(改)、`tests/unit/test_backpressure.py`(改)、`tests/integration/test_redis_cross_connection.py`(改)、`tests/unit/test_ports.py`(改)
|
||||
|
||||
**为什么单列一步**(Codex 审查查出的阻断问题): T5 只把 `reasoning_effort` 放进 `ChatRequest`,但 `Transport` 协议收的是**拆开的**参数(`messages/source/stream/overlay/call_id`,`ports.py:39-49`),`RetryMW._attempt` 也只传这五个(`retry.py:282-288`)。不扩展协议,请求级档位根本到不了 `_build_payload`,设计 §4.2 的优先级落不了地。
|
||||
|
||||
**行为**:
|
||||
1. `Transport.complete` 协议增关键字参数 `reasoning_effort: Effort | None`。**不设默认值**——与 `TelemetryRecorder` 同一既有约定: 库外无第三方实现者,完整签名成本为零,而给默认值会让漏传变成静默的「不表态」。
|
||||
2. `RetryMW._attempt` 调用处传 `request.reasoning_effort`。该中间件此前只读 `request` 的五个字段,新增第六个,不改其他语义。
|
||||
3. `OpenAICompatTransport.complete` 接收并透传给 `_build_payload`。
|
||||
4. 三个测试 fake 同步扩签名(`tests/unit/test_retry.py:72`、`tests/unit/test_backpressure.py:213`、`tests/integration/test_redis_cross_connection.py:76`)——`@runtime_checkable` 只查方法名不查签名,漏改会在调用时 `TypeError`,且错误现场离根因很远。
|
||||
|
||||
**不动**: `EmbeddingTransport`、`OcrTransport` 两个协议——它们无推理语义(与 issue #4 给 embedding 加 `extra_body` 被否决同理: 装配期报错比静默无效更能指路)。
|
||||
|
||||
**验收**: 请求级档位能一路到达 `_build_payload`;三个 fake 与协议签名一致;`tests/unit/test_ports.py` 的 Protocol 断言更新。
|
||||
|
||||
**测试**(先失败后通过): `tests/unit/test_retry.py::test_request_tier_reaches_transport`(断言 fake 收到的 `reasoning_effort` 与 `ChatRequest` 一致)、`::test_embedding_transport_signature_unchanged`(回归: 未误改另两个协议)。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_backpressure.py tests/unit/test_ports.py -v` → PASS
|
||||
|
||||
- [ ] 提交: `feat: carry the per-call tier down to the transport that must send it`
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — 缓存 key
|
||||
|
||||
**文件**: `src/polygateway/client.py`(改)、`src/polygateway/middleware/cache.py`(改)、`tests/unit/test_cache.py`(改)、`tests/integration/test_redis_cache.py`(改,该文件亦断言 key 形状)
|
||||
|
||||
**行为**:
|
||||
1. `_fingerprint_mark`: 源级 `reasoning_effort` **仅在非 `None` 时**追加,规则与 `enable_thinking` 完全一致——全源不表态时指纹字面量逐字不变,存量缓存不冷启动。
|
||||
2. `build_cache_key` 增关键字参数 `reasoning_effort: Effort | None = None`,**仅非 `None` 时**写入 `key_obj["reasoning_effort"]`。
|
||||
3. `CacheMW.__call__` 传 `request.reasoning_effort`。
|
||||
|
||||
**为什么两处都要**(写进注释): `model_fingerprint` 是装配期算的**集合级**指纹,覆盖不到逐次调用变化的请求级档位;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。ARCH §7.5 记载的「集合级指纹仍可能返回另一源响应」这一既有取舍原样延续,本任务不扩大。
|
||||
|
||||
**验收**: 同 messages 不同请求级档位 → key 不同;两者皆不表态 → key 与升级前逐字相同(回归);源级档位变化 → fingerprint 变化。
|
||||
|
||||
**测试**(先失败后通过): `::test_request_tier_changes_key`、`::test_absent_tier_keeps_legacy_key`(断言具体 key 字符串不变)、`::test_source_tier_enters_fingerprint`。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit -k "cache or fingerprint" -v` → PASS
|
||||
|
||||
- [ ] 提交: `fix: keep a low-tier answer out of the cache slot a max-tier one filled`
|
||||
|
||||
---
|
||||
|
||||
## Task 7 — 遥测新增 `reasoning_effort` 列
|
||||
|
||||
**文件**: `src/polygateway/telemetry/schema.py`、`src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`(均改)、`tests/unit/test_telemetry.py`(改,含列数断言)、`tests/unit/test_ports.py`(改,Protocol 签名断言)、`tests/integration/test_postgres_telemetry.py`(改——该文件有 `_EXPECTED_COLUMNS` 完整**列序**断言与 pre-tenant 历史 DDL 的列子集推导,共 5 处,漏改则 PG 集成测试必红)、`src/polygateway/middleware/retry.py`(改,`emit_attempt` 调用点传新参)
|
||||
|
||||
**行为**:
|
||||
1. `schema.py`: `COLUMNS` 末尾加 `"reasoning_effort"`(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加 `reasoning_effort TEXT`(位置与 ALTER 追加一致);补列声明同步。**列数断言按物理列写**——两套口径混用是本模块最易错处(见其 docstring)。
|
||||
2. `ports.py`: `record_llm_call` 加 `reasoning_effort: str | None`(**不设默认值**,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。
|
||||
3. 两个 recorder 落库新列。
|
||||
4. `middleware/telemetry.py`: `_record` 加参并传给 recorder(**唯一** `record_llm_call` 调用点,不复制参数列表);`emit_attempt` 增 `applied_effort` 关键字参数,由其三个调用方传值——`retry.py:411` 传实际档,`embedding.py:407` 与 `ocr.py:451` 传 `None`(无推理语义)。三个 emit 入口取值口径分列:
|
||||
|
||||
| 入口 | 取值 | 理由 |
|
||||
|---|---|---|
|
||||
| `emit_attempt` | 成功时 `response.applied_effort`(T8 送上来的实际档);**失败时**回落到 `effective_effort(...)` 的请求档 | **不是**请求档: `nearest` 映射后二者不同(请求 `medium` → 实际 `LOW`),记请求档会让 T10 的压测把行挂在从未发出的档下。失败尝试没有 response,实际档不可知,记请求档并接受这一含义差别——总好过 issue #19 抱怨的「失败行无归因」 |
|
||||
| `emit_cache_hit` | `request.reasoning_effort` | 缓存命中没有选中源,源级档位无从谈起 |
|
||||
| `emit_terminal_failure` | `request.reasoning_effort` | 同上(可能根本没选出源) |
|
||||
|
||||
与 `sampling` 列的现有做法同构(`emit_attempt` 合并源级,另两处只取请求级)。
|
||||
5. 值为 `Effort` 时取 `.value` 落库,`None` 落 `NULL`——与 `thinking_observation` 同一先例(`StrEnum` 是 `str` 子类,asyncpg 对子类编码不保证接受,遥测写失败只降级 warning,PG 那一路会静默少列)。
|
||||
|
||||
**验收**: 两端建表列数断言更新且通过;三个入口各自落值正确;不表态时为 `NULL`;`telemetry_schema_sql` 打印的 SQL 与库实际执行的 DDL 同源。
|
||||
|
||||
**测试**(先失败后通过): 既有遥测列数断言用例更新;`::test_effort_column_records_effective_tier`、`::test_cache_hit_records_request_tier_only`、`::test_absent_tier_is_null`。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit tests/integration -k telemetry -v` → PASS
|
||||
|
||||
- [ ] 提交: `feat: record which tier a call actually ran at`
|
||||
|
||||
---
|
||||
|
||||
## Task 8 — transport 接线与对账
|
||||
|
||||
**文件**: `src/polygateway/transports/openai_compat.py`(改)、`src/polygateway/thinking.py`(改)、`tests/unit/test_openai_compat.py`(改)
|
||||
|
||||
**行为**:
|
||||
1. `_build_payload`: 用 `effective_effort(...)` 求生效档位后调 `resolve_thinking(..., fallback=source.effort_fallback)`。注入结果仍**先于** `source.extra_body` 与 `overlay`(顺序即优先级,issue #4 决策 A,两行不可调换)。
|
||||
2. `_warn_on_thinking_mismatch` 的节流键由 `(source.name, source.model, source.enable_thinking)` 改为 `(source.name, source.model, effective_effort)`——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。
|
||||
3. `reconcile_thinking` 签名的 `enable_thinking: bool | None` 改为 `effort: Effort | None`,判据: `effort is NONE` 对应原「要求关闭」分支,`effort` 为其余档对应原「要求开启」分支,`None` 仍返回 `None`。**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。
|
||||
4. `ThinkingUnsupportedError` 的捕获与翻译路径不变(→ `RequestRejectedError`,不重试不换源不计熔断)。
|
||||
5. **把实际档送出 transport**(否则遥测记不到 `nearest` 映射后的真实档):
|
||||
- `TransportResult` 末尾追加 `applied_effort: Effort | None = None`——带默认值,非 OpenAI 兼容的 transport(OCR/embedding)可不填,与 `thinking_observation` 同一先例;
|
||||
- `LLMResponse` 末尾追加 `applied_effort: Effort | None = None`——**字段只增不删不改名**,符合 ARCH §5.1 迁移兼容约束;对下游也有价值(它终于能知道这次实际跑在哪档);
|
||||
- `RetryMW` 在 `retry.py:375` 的 `TransportResult → LLMResponse` 转换处带上该字段。
|
||||
|
||||
**验收**: 档位不支持时抛 `RequestRejectedError` 且不触发重试与熔断计数;同源同模型不同档各喊一次告警;`reconcile` 三类文案与既有逐字一致(除方向描述由 bool 改档位);`nearest` 映射后 `LLMResponse.applied_effort` 是**映射后**的档。
|
||||
|
||||
**测试**(先失败后通过): `::test_unsupported_tier_is_request_rejected`、`::test_no_retry_on_tier_error`、`::test_throttle_key_separates_tiers`、`::test_reconcile_none_vs_observed`、`::test_response_carries_mapped_tier`(请求 `medium`、能力 `(LOW,HIGH,MAX)` → 断言 `response.applied_effort is Effort.LOW`)。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/unit -k "transport or openai_compat" -v` → PASS
|
||||
|
||||
- [ ] 提交: `feat: wire the tier through the transport and keep each tier's warning distinct`
|
||||
|
||||
---
|
||||
|
||||
## Task 9 — 全套件、文档与 wiki
|
||||
|
||||
**文件**: `CHANGELOG.md`、`.env.example`(复核)、Gitea Wiki(按 `research-wiki/docs-convention.md` §2)、`src/polygateway/__init__.py`(版本号)、`pyproject.toml`(版本号)
|
||||
|
||||
**行为**:
|
||||
1. `make lint` + `make test` 全绿;`make format`。
|
||||
2. CHANGELOG 加「未发布」段: 破坏性变更(`ThinkingCapability` 构造签名)、新增(八档 `Effort`、两个 env 键、遥测新列、四个 provider 段)、行为变更(`openai` 段两档由未知改为 OpenAI 标准形态)。
|
||||
3. 按 docs-convention §2 同步 wiki(公共行为变更必须同步,版本 bump 不得裸发)。**CHANGELOG 必须覆盖三条行为变更**,漏第三条是独立验证点名的风险: ① `ThinkingCapability` 构造签名(破坏性);② minimax/openai/anthropic/google 开档不再注 `medium`;③ `openai` 兜底段由「形态未知即报错」放宽为标准形态——把别家模型挂在该段下并配 `ENABLE_THINKING=true` 的下游,旧版装配期报错,新版静默不注入任何字节(对这四段涉及的模型无害,它们默认即推理;但语义变了,须明写)。
|
||||
4. 版本号 **`1.3.3`**(2026-09-05 人类指令;不因破坏性变更走 minor),`pyproject.toml` 与 `src/polygateway/__init__.py` 两处一致。**本任务只 bump 不发布**——发布走 CLAUDE.md §4.4.1 全清单。
|
||||
|
||||
**验收**: `make ci` 通过;CHANGELOG 与 wiki 均含破坏性变更条目。
|
||||
|
||||
**验证**: `conda run -n PolyGateway make ci` → PASS
|
||||
|
||||
- [ ] 提交: `docs: cut 1.3.3 notes for the tier work`
|
||||
|
||||
---
|
||||
|
||||
## Task 10 — e2e 实测校正初始能力表(标 `slow`)
|
||||
|
||||
**文件**: `tests/e2e/test_thinking_live.py`(改)、`src/polygateway/thinking.py`(改——`DEFAULT_CAPABILITIES` 与 evidence 就在此处,实测结论要写回它,否则本任务只跑不改,设计 §8/§13 第 5 条落不了地)
|
||||
|
||||
**行为**: 对 §Task 1 表中每个已登记模型,经 new-api 实测其 `supported_efforts`,方法论沿用 issue #20: 固定短提示词,逐档 N≥5,判据取 `usage.completion_tokens_details.reasoning_tokens`;对声明不可关的模型额外验证「请求 `none` 是否真被拒或真未关」。测试标 `slow`(成败取决于外部服务当下状态,默认不进日常套件)。实测结论逐条替换 `evidence` 中的「文档推定」。
|
||||
|
||||
**为什么必须单列一个任务**: 人类 2026-09-04 定「能力表数据统一自己经 new-api 实测」;Task 1 落的是文档推定值,不实测则整张表都是假设。
|
||||
|
||||
**验收**: 每个已登记模型有一条实测记录;与文档推定不符者更新 `supported_efforts` 并在 evidence 记明分歧(尤其 `kimi-k3` 的保守登记、`gemini-3.1-pro` 的默认档两源打架)。
|
||||
|
||||
**验证**: `conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v` → PASS(约 20-40 分钟,取决于网关)
|
||||
|
||||
- [ ] 提交: `test: replace the guessed tier table with what the gateway actually does`
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序与依赖
|
||||
|
||||
```
|
||||
T1(词汇+能力表) ──┬─→ T3(五关) ─────────────→ T8(transport)
|
||||
T2(wire) ─────────┘ ↑
|
||||
T4(源级) ─→ T5(请求级字段) ─→ T5b(端口签名) ──┤
|
||||
│ │
|
||||
└─→ T6(缓存 key) ↓
|
||||
T7(遥测) ─→ T9(文档) ─→ T10(实测,回写能力表)
|
||||
```
|
||||
|
||||
T1/T2 可并行;T3 依赖两者;T5 依赖 T4(语法糖等价关系);**T5b 依赖 T5**(要有 `ChatRequest.reasoning_effort` 才有得传);T6 依赖 T5;T8 依赖 T3 + T5b(没有 T5b 就拿不到请求级档位);**T7 依赖 T8**(自审纠正: 遥测要记的实际档由 T8 在 transport 内算出并经 `TransportResult`/`LLMResponse` 送上来,先做 T7 只能记到请求档);T9 在功能任务全绿后;T10 最后,且它会**改回 `thinking.py`**——与 T1 同一文件,故必须排在最后而非与其并行。
|
||||
|
||||
执行方式: 10 个任务耦合度中等(共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型),**直接按计划实现**,不派 `subagent-driven-development`——跨任务共享类型多,独立上下文的 subagent 容易在签名上分叉。
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:est-tokens-decoupling
|
||||
title: "est_tokens 解耦实施计划"
|
||||
date: 2026-07-30
|
||||
---
|
||||
|
||||
# est_tokens 解耦实施计划
|
||||
|
||||
全文见 [2026-07-30-est-tokens-decoupling-plan.md](2026-07-30-est-tokens-decoupling-plan.md)。实现设计 [est-tokens-decoupling](../designs/est-tokens-decoupling.md)。
|
||||
|
||||
- **5 个任务**: T1 加派生能力与三态值域常量(零行为变更)→ T2 五个入场/结算点切到派生值(零行为变更,因显式值优先)→ T3 值域三态生效(行为变更主体)→ T4 解绑 `tpm > 0 ⇒ est_tokens > 0`(派生值真正启用)→ T5 权威文档、CHANGELOG、wiki 与 issue 回帖。
|
||||
- **排序是硬约束,不可调换**: 三处改动互相牵制且中间态**静默偏差、不报错**。先改 usage 兜底为 `(0,0)` 而结算点未切派生值 → 成功调用押金整笔退回(TPM 闸退化成进门即放行);先解绑约束而结算点未切 → 同样泄漏;先改 `openai_compat.py:176` 而 `_merge` 仍是二值 `any(=="estimated")` → `unavailable` 批被误标 `measured` 且 cost 照算。
|
||||
- **T2 的等价性是安全阀**: 约束未解绑时 `effective_est_tokens()` 恒返回显式值,故 T1/T2 后行为逐字不变,现有测试全绿即为证明;T3 才是唯一的行为变更点。
|
||||
- **保真校验(不新增移植,但触及关键资产)**: 不得改 Redis Lua 与内存后端的窗口/租约算法(只改传入 `try_acquire` 的数值来源)、不得改 `settle` 多退少补与幂等语义、不得改错误四分类归属、`ocr.py` 一字不动、遥测 18 字段冻结且无 DDL。
|
||||
- **最易漏的测试**: T4 的"成功侧结算不退多"——未填 `est_tokens` 且 usage 帧缺失的**成功**调用后,TPM 窗口残留须等于派生预扣量而非 0。这正是独立审查在设计阶段抓出的缺陷,实施阶段必须有回归钉死。
|
||||
- **发布口径**: 缺口度量必须写成 `WHERE usage_source='unavailable' AND cache_hit = false`——缓存命中行按裁决 cost 为 `0.0` 且标 `unavailable`,本无账目缺口,不加限定则度量偏高。
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:governance-backend-error
|
||||
title: "实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)"
|
||||
date: 2026-08-06
|
||||
---
|
||||
|
||||
# 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)
|
||||
|
||||
全文见 `2026-08-06-governance-backend-error-plan.md`。实现设计 [[governance-backend-error]](已批准 2026-08-06)。
|
||||
|
||||
## 五个任务
|
||||
|
||||
| # | 任务 | 关键约束 |
|
||||
|---|---|---|
|
||||
| T1 | `ARCHITECTURE.md` §6.1 回补两行 + reason 值域扩为 6 值 | **必须先行**——单一事实源纪律,先改代码后补文档等于让实现与事实源脱节(设计 §8.1) |
|
||||
| T2 | `errors.py` 纯增量: 新常量、新 reason、`SourceNotConfiguredError` + 顶层导出 | 刻意不动 `GovernanceBackendError`,故全套件保持通过,可独立提交 |
|
||||
| T3 | `GovernanceBackendError` 归位 + 22 处构造点 + gate scope 注入 + 全部受影响测试 | **必须原子**: `scope` 是必填 keyword,分批提交的中间状态会 `TypeError` |
|
||||
| T4 | README 公开错误面两列表 + 迁移文档补注 | issue #7 的第二诉求,作者认为比第一条更值得改 |
|
||||
| T5 | 版本 1.1.0 + CHANGELOG + Wiki 同步 | 加父类是扩大不是破坏,故 minor 而非 major |
|
||||
|
||||
## 保真校验(适用)
|
||||
|
||||
触及 ARCHITECTURE §1.4 的移植蓝本(CHS `app/domain/errors.py` 与 `app/coordination/`)。本次**有意变更**的语义仅一条(`GovernanceBackendError` 的类型归属);`retry_after_s` 非可选语义、两个 reason 值域、fail-closed 方向、记账/闸门分工、`RedisPermit` 释放侧降级五条**不得被顺带改动**,每任务完成前逐条自查。
|
||||
|
||||
## 独立审查修正(2026-08-06, Codex)
|
||||
|
||||
4 条意见全部核实属实并已折回:
|
||||
|
||||
1. **T3 测试证据定位错误**(重要)——原写"复用 `test_backpressure.py:176-186` 的注入桩"覆盖三条泄漏路径,实测那三个桩是 `record_success`/`record_failure`/`mark_progress` 的**记账侧降级**,与闸门路径无关;`try_acquire`/`try_enter` 全无覆盖。已改为"三条桩都要新增"并写明现状。
|
||||
2. **T3 漏了既有测试构造点**(重要)——新签名 keyword-only 必填,`test_backpressure.py:176/181/186`、`test_errors.py:89` 的裸 `GovernanceBackendError("...")` 与 `:255/:257` 的 `QuotaGate(_L())` 漏改即 `TypeError`。已补一张同批更新清单。
|
||||
3. **`__all__` 插入位置写反**(次要)——按字母序应在 `SourceDeadError` **之后**而非之前。已改。
|
||||
4. **设计中 telemetry 行号过时**(次要)——`:210` → `:250`,系本分支加 `_AttemptUsage` 造成的漂移。设计与摘要页已同步更新。
|
||||
|
||||
Codex 同时独立核实了计划的可执行性锚点: 22 处构造点、三处 gate 装配、后端层 `self._scope` 位置、README/ARCH 章节行号,均与 `src/` 现状相符。
|
||||
|
||||
## 独立验证炸出的阻塞缺陷(2026-08-06,全新上下文 verifier)
|
||||
|
||||
T1–T5 全绿、四道门禁全过之后,verifier 用一个**走 `QuotaGate` 的**端到端用例证明: 装配缺陷在唯一的生产路径上根本没拆出去——包装器的 `except GovernanceBackendError: raise` 只放行旧类型,`SourceNotConfiguredError` 落进下一行 `except Exception` 被重新包回去,配置写错照样永远重投。**盲区在于 T3 写的两条用例都直接打私有 `_cfg()`,比生产路径低一层。**
|
||||
|
||||
修复见正文 §T6(9 处放行 + 遥测终态捕获 + 走包装器的回归测试)。复核时 verifier 又指出一颗雷: 新放行让该异常能穿透 `_record_quietly`,而那层降级的存在理由是"调用已真实完成,写回失败不该丢弃成功响应"——同批把三处 `_record_quietly` 一并放宽并加了回归断言。
|
||||
|
||||
两轮都订正了同一处事实错误: 闸门泄漏路径是**五条**不是三条(`QuotaGate.stats` 与 `BreakerGate.retry_after_s` 同样未被 `_record_quietly` 包裹)。
|
||||
|
||||
相关: [[governance-backend-error]](design)、[[m2-distributed]]
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:issue10-error-body-retention-plan
|
||||
title: "实现计划: HTTP 错误响应体留存(Issue #10)"
|
||||
date: 2026-08-16
|
||||
---
|
||||
|
||||
# 实现计划: HTTP 错误响应体留存(Issue #10)
|
||||
|
||||
**全文**: `plans/2026-08-16-issue10-error-body-retention.md`|**实现**: [[design:issue10-error-body-retention]]|**分支**: `feat/issue-10-error-body-retention`
|
||||
|
||||
## 任务分解
|
||||
|
||||
| # | 任务 | 产出 |
|
||||
|---|---|---|
|
||||
| 1 | 内核字段 | `PolyGatewayError.body_text`(默认空串)+ 与 `raw_text` 的界限 docstring |
|
||||
| 2 | 共享摘要单元 | 新建 `transports/_http_errors.py`:`summarize_body` / `compose_message` / `response_body` |
|
||||
| 3 | openai_compat 收口 | `_status_to_error` 表驱动;429 判类型仍读原文 |
|
||||
| 4 | monkey_ocr 收口 | `_classify_status` 同款,含 `ResponseNotRead` 降级 |
|
||||
| 5 | **端到端验收** | 400 调用后 SQLite `error` 列含摘要——本计划的硬判据 |
|
||||
| 6 | 文档与版本 | 1.2.0、README pin `>=1.2,<2`、CHANGELOG、ARCHITECTURE §6.2 |
|
||||
| 7 | 合并前门 | lint + 全套件 + 全新上下文 verifier |
|
||||
|
||||
依赖:1‖2 → 3‖4 → 5 → 6 → 7。
|
||||
|
||||
## 计划期钉死的两条实现红线
|
||||
|
||||
1. **429 判 `insufficient_quota` 必须解析未截断原文**,不得改用摘要——摘要会破坏 JSON,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`,把诊断改进变成治理 bug。Task 3.4 有专门回归用例。
|
||||
2. **message 主体逐字保持 1.1.2 原样**,只在末尾追加摘要后缀;状态码→分类映射逐条不变,验收要求既有分类断言零改写。
|
||||
|
||||
## 保真校验
|
||||
|
||||
不涉及 `reference/` 迁移。错误分类映射不变,保真体现为"既有分类断言全部保留"。
|
||||
|
||||
## 执行期观察
|
||||
|
||||
Task 计划提交时 pre-commit 钩子的全套件跑出现一次 `tests/e2e/test_compat_projects.py::TestGovDocOnboarding::test_call_site_shape_runs_governed` 失败,单独跑与 e2e 全目录跑(7 passed / 23.30s,每例 2-3.5s)均通过,重跑全套件亦通过 → 判为真实网关抖动,非回归。该用例正是 [[design:issue8-stall-budget]] 当年记录的三个漂移用例之一,e2e 打真实网关的固有 flaky 面仍在。
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:issue11-caller-dimensions
|
||||
title: "调用方自定义维度实现计划(issue #11)"
|
||||
date: 2026-08-17
|
||||
---
|
||||
|
||||
# 调用方自定义维度实现计划(issue #11)
|
||||
|
||||
正文: `2026-08-17-issue11-caller-dimensions.md`(269 行,8 个任务)。实现 `design:issue11-caller-dimensions`。
|
||||
|
||||
- **任务顺序**: 端口与两个遥测后端(Task 2)先于三条调用链(Task 4/5/6)落地——后者写入的字段必须已有列可落。Task 1(校验函数 + `ChatRequest` 字段)是全部任务的前置。
|
||||
- **计划阶段的新发现**: 设计只覆盖 `chat()`/`embed()`,核实代码发现**第三条遥测链路**——`OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),`_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,与 embedding 同构。OCR 行与 chat 行落**同一张表**,漏掉则多租户审计链缺一块且同样不可逆。列为 Task 6,**人类已追认纳入正式范围**(设计 §1.2 同步补正)(与 issue #10 先例一致: 那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修),是必做项。
|
||||
- **把 issue 的核心论点钉成测试**: Task 7 要求手工建 22 列旧表 → 用当前 recorder 打开 → 断言老行 `tenant_id` 读回**空串而非 NULL**。这直接验收 issue「先启用后加列则归属无法还原」的论点,且断言方向选空串是因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞,而非"未归属"。
|
||||
- **几处易实现反的地方已写进验收**: `emit_cache_hit` 必须读**本次请求**的维度而非缓存中历史响应的(构造"请求属租户 A、缓存历史属租户 B"的用例断言落 A);`embed()` 多批时**每一批**的行都要带同一份维度(只断言首行会漏掉"只有第一批带维度"的实现);非法输入必须 `ValueError` 且 recorder **零调用**(证明校验早于遥测)。
|
||||
- **不做的事**: 不把 embedding/OCR 链上已有的四个同类参数收成值对象(任务外重构);不建索引、不启用 RLS(库只交付模板,执行是下游 DBA 职责)。
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:issue8-stall-budget-plan
|
||||
title: "issue #8 实施计划: stall 非生产性等待口径"
|
||||
date: 2026-08-06
|
||||
---
|
||||
|
||||
# issue #8 实施计划: stall 非生产性等待口径
|
||||
|
||||
**全文**: `plans/2026-08-06-issue8-stall-budget.md` |**实现**: [[design:issue8-stall-budget]] |**分支**: `feat/issue-8-stall-budget`
|
||||
|
||||
## 交付
|
||||
|
||||
| 任务 | 内容 | 提交 |
|
||||
|---|---|---|
|
||||
| T1 | `StallClock` 落地 + chat 路径改造 + 8 条测试 | `02c3d06` |
|
||||
| T2 | embedding 路径复用 | `6d0f3c9` |
|
||||
| T3 | ocr 路径复用 | `0477d95` |
|
||||
| T4 | `config.py` docstring 与 `.env.example` 注释对齐(无逻辑变更) | `d05114e` |
|
||||
| T5 | 全套件回归 + `ARCHITECTURE.md` §7.3 与 `CHANGELOG` 同步 | `3645e57` |
|
||||
| T6 | 独立验证(全新上下文 verifier)+ 三个问题的修复 | `bc4683d`、`a0a5cf7` |
|
||||
|
||||
## 测试证据(先失败后通过)
|
||||
|
||||
三条路径的失效链条各有一条回归用例,改前均转红于 `reason="stalled"`:`retry.py:218`、`embedding.py:250`、`ocr.py:275`。issue 只记录了 chat 路径,embedding/ocr 两条为本次核出。
|
||||
|
||||
## 独立验证发现的三个问题(均已修)
|
||||
|
||||
1. **429 缝隙(中)**: 初稿使 429 尝试两个预算都不烧,慢 429 场景实测挂 25.2 小时——**修复引入的回归**。见设计 §3.6。
|
||||
2. **测试假证据(中)**: 并发用例用了两个 `RetryMW` 实例,实例级共享被对象隔离掩盖,clock 提升为实例属性时 7 条用例全部逃逸。改为复用同一 `mw` 并补"两次调用间空转超窗"用例,变异测试确认可抓。
|
||||
3. **文档遗漏(轻)**: 计划要求的 `test_backpressure.py` docstring 订正漏做。
|
||||
|
||||
## 保真校验
|
||||
|
||||
治理主循环为 CHS `governance.py:200-285` 移植物,但 `reference/` 不在工作区,故以设计 §4 行为审计表 9 条 + 代码内 CHS 行号注释为基准。核对结果:标"保留"的 8 条在 `git diff` 中零出现,唯一"替换"项为条件 A 度量口径。
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:plan-issue12-telemetry-retention
|
||||
title: "实现计划: issue12-telemetry-retention"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# 实现计划: issue12-telemetry-retention
|
||||
|
||||
|
||||
正文: `2026-08-19-issue12-telemetry-retention.md`。实现 [[design:issue12-telemetry-retention]]。
|
||||
|
||||
五个任务: ① 截断函数 + emitter `text_cap` 必填 + 三构造点; ② 配置与装配; ③ `tools/telemetry_retention.py`(默认 dry-run); ④ README 生产部署模板 + 其真实 PG 机械化验收; ⑤ CHANGELOG 与 wiki。
|
||||
|
||||
**前置**: issue #13 须先合并(两条分支都改 `config.py`/`client.py`,且分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标写入)。
|
||||
|
||||
写计划时挖出的实现陷阱: `digest_messages` 对 content 非 list 的消息**原样 append 同一个 dict**,遥测拿到的与调用方传入的、缓存 key 用的是同一份对象——`_cap_messages` 若就地改,会同时污染调用方 messages、后续重试请求体与缓存写入 key,且全程无报错。计划已为此设两条红线用例,并要求先写一版就地改的实现证明红线能抓住它。
|
||||
- **审查留痕(Codex 计划审,2026-08-19)**: 报 5 项与本计划相关,**全部采纳**。最实质的一条是**三个公共 Client 的直接构造路**: `TelemetryEmitter` 的 `text_cap` 必填,而 `GatewayClient`/`EmbeddingClient`/`OcrClient` 的 `__init__` 都在内部构造 emitter,只改 `from_settings` 那条路会让直接构造的下游要么撞 `TypeError`、要么永远启用不了 cap。定稿: emitter 保持必填(库内部类,唯一构造者就是这三个 Client,必填保证无一处漏传),三个 Client 各加**带默认值 `None`** 的 `text_cap`(公共装配路,而默认值恰好等于全局缺省的不截断)。其余四条: `tools` 脚本的 PG 分支(分批删除、分区探测退出码 3、缺 asyncpg 退出码 2)原本一条测试证据都没有,已补三例集成用例(缺依赖那例用只含 `raise ImportError` 的临时 `asyncpg.py` 挂 `PYTHONPATH` 构造);设计要求的**截断覆盖面声明**(`tool_calls.function.arguments` 不在覆盖内)与 **SQLite 文件轮转建议**都漏了文档落点,已补进 Task 4;与 #13 的合并冲突面(`config.py` 的字段列表与 `_load_pgw` 返回键、`client.py` 的装配)措辞已强化为必须从 #13 合并后的 main 开分支。
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:plan-issue13-schema-mode
|
||||
title: "实现计划: issue13-schema-mode"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# 实现计划: issue13-schema-mode
|
||||
|
||||
|
||||
正文: `2026-08-19-issue13-schema-mode.md`。实现 [[design:issue13-schema-mode]]。
|
||||
|
||||
七个任务: ① 建 `telemetry/schema.py` 单一事实源(纯搬迁,行为不变)+ `insert_sql()`/`telemetry_schema_sql()`; ② PG 写入去掉冲突目标(为分区让路); ③ 两个 recorder 加必填 `auto_migrate` 与裁剪写入; ④ config 派生 + 装配 + `.env.example`; ⑤ 顶层导出; ⑥ 真实 PG 集成验收(临时 schema 隔离,严禁碰共享表); ⑦ 文档与 Expand/Contract 承诺。
|
||||
|
||||
`insert_sql` 的列名来自数据库探测结果而非常量,故**子集校验是注入面的闸**,不是形式主义。
|
||||
- **审查留痕(Codex 计划审,2026-08-19)**: 报 8 项与本计划相关,**全部采纳**。最有价值的三条都会让计划照着写就红在测试本身而非实现: ① 列数断言写成 22/24 是错的——`COLUMNS` 是 **INSERT 字段序**,不含数据库自填的 `created_at`,物理列是 23/25,两套口径混用会写出永远对不上的断言; ② 用 `caplog` 抓 warning 一条也抓不到(库用 loguru,不经标准 logging),那条断言会**静默永远绿**,须照搬 `captured_warnings` 的 loguru sink 形态; ③ 缺列旧表的最小权限现场是 `least_privilege_pre_tenant_dsn` 而非 `least_privilege_dsn`(后者用完整 DDL 建的是列齐全的表,触发不到缺列路径)。另外三条: `make lint` 带 `--fix` 会改文件,验证命令须用 `make check`;Task 3 让 recorder 参数必填而 Task 4 才改 `_build_telemetry`,中间那个提交点会 `TypeError`,两者已合并为同一任务;库内执行的补列语句(不带 `IF NOT EXISTS`,先探测以避 ACCESS EXCLUSIVE 锁)与打印给下游的脚本(必须带 `IF NOT EXISTS` 才幂等)**是两份不是一份**,原计划那句「原样搬迁」会产出不可重复执行的迁移 SQL。Task 7 的 README 验收也从人工核对升级为机械化: `telemetry_schema_sql` 的输出在临时 schema 执行两遍,断言列集合正确且第二遍不报错。
|
||||
@@ -0,0 +1,312 @@
|
||||
# 实现计划: 熔断拒绝补齐等待档(issue #14)
|
||||
|
||||
- **设计**: `research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md`(人类已确认 + Codex 已审)
|
||||
- **分支**: `feat/issue-14-circuit-open-policy`
|
||||
- **版本**: 1.3.0(新增配置键 + `retry_after_s` 语义变更)
|
||||
|
||||
## 目标
|
||||
|
||||
让"源不健康"不再等同于"这次调用当场判死"——补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 这一格准入策略,并把 `retry_after_s` 的语义在两个后端的五个出口上定死。
|
||||
|
||||
## 方案概述
|
||||
|
||||
三件事环环相扣: ①把 `retry_after_s` 定义为"距离**确定**可再试的时刻还有多久",HALF_OPEN 与准入允许一律 `0.0`(顺带修掉源冷却备忘被探针租约污染的 bug);②新增 `circuit_open` 策略键,`wait` 档下不抛 `CircuitOpenError` 而按 `retry_after` 睡、由 stall 预算兜底;③前置把三条治理循环里逐字复制的准入逻辑收敛成一份,否则本次修复会在 embedding/ocr 留下两个行为分叉的角落。
|
||||
|
||||
涉及技术: Python 3.11 asyncio、Redis Lua(EVALSHA)、pytest 双后端参数化契约测试。
|
||||
|
||||
## 保真校验适用性
|
||||
|
||||
**适用**。熔断状态机是 ARCHITECTURE.md §1.4 关键资产(蓝本 `reference/Video-Tree-TRM5/adapters/breaker.py` 与 `reference/CHSAnalyzer/app/coordination/provider_gate.py`),准入循环蓝本为 `reference/CHSAnalyzer/app/providers/governance.py:107-285`。T1 与 T2/T3 各带保真校验检查点。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/middleware/admission.py` | **新建** | `SourceAdmission`(准入与无源可跑的处置,三条循环共用)+ 模块级 `settle_and_release` |
|
||||
| `src/polygateway/middleware/retry.py` | 修改 | 删除本地 `_pick_runnable`/`_on_no_runnable`/`_settle_and_release`,改用 `SourceAdmission`;主循环与 `_attempt` 不动 |
|
||||
| `src/polygateway/embedding.py` | 修改 | 同上 |
|
||||
| `src/polygateway/ocr.py` | 修改 | 同上(注意 `_settle_and_release` 原签名只有 `permit`) |
|
||||
| `src/polygateway/backends/memory/breaker.py` | 修改 | 抽 `_remaining(g)`,三处出口共用;HALF_OPEN 与授予探针恒 `0.0` |
|
||||
| `src/polygateway/backends/redis/breaker.py` | 修改 | 五个 Lua 出口同步(`TRY_ENTER` 两处、`RECORD_SUCCESS`/`RECORD_FAILURE`/`RELEASE_PROBE` 各一处、`RETRY_AFTER` 一处) |
|
||||
| `src/polygateway/config.py` | 修改 | `_CIRCUIT_OPEN` 常量、`GatewaySettings.circuit_open` 字段、`_validate_backends` 元组、`from_env` 装载 |
|
||||
| `src/polygateway/client.py` | 修改 | 构造签名 + 透传 |
|
||||
| `src/polygateway/errors.py` | 修改 | `GatewayUnavailableError` docstring 职责边界 |
|
||||
| `tests/contracts/test_breaker_contract.py` | 修改 | 按五个出口逐个钉 `retry_after_s` |
|
||||
| `tests/integration/test_redis_governance_time.py` | 修改 | Redis 真实等待变体补 HALF_OPEN 出口 |
|
||||
| `tests/unit/test_backpressure.py` | 修改 | `circuit_open` 行为矩阵、备忘污染回归、`_nap` 上界 |
|
||||
| `tests/unit/test_config.py` | 修改 | 新键的合法域、缺省、两条装配路一致 |
|
||||
|
||||
## 关键接口(跨任务消费,此处定死)
|
||||
|
||||
`SourceAdmission` 构造与两个方法:
|
||||
|
||||
```python
|
||||
class SourceAdmission:
|
||||
def __init__(self, *, scope: str, sources: list[SourceConfig],
|
||||
selector: SourceSelector, quota: QuotaGate, breaker: BreakerGate,
|
||||
memo: SourceCooldownMemo, backpressure: BackpressurePolicy,
|
||||
quota_full: str, circuit_open: str,
|
||||
pacer: AdaptivePacer | None = None,
|
||||
health_view: Callable[[str], float] | None = None,
|
||||
now=time.monotonic, sleep=asyncio.sleep, rng=random.random) -> None: ...
|
||||
|
||||
async def pick(self, reasons: dict[str, str], attempt_fails: dict[str, int]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]: ...
|
||||
|
||||
async def on_no_runnable(self, gate_rejections: int, reasons: dict[str, str],
|
||||
clock: StallClock) -> None: ...
|
||||
|
||||
async def stalled(self, clock: StallClock) -> bool: ...
|
||||
```
|
||||
|
||||
`quota`/`breaker`/`pacer`/`selector`/`sources` 均为**调用方传入的同一实例**(不在 admission 内新建),因为三处 `_attempt` 仍需引用它们;`memo` 则由 admission 独占。`health_view` 对应 chat 的 `self._health_view`(由 `isinstance(selector, OutcomeAwareSelector)` 在 RetryMW 构造期判定一次),embedding/ocr 传 `None`。
|
||||
|
||||
模块级结算函数(三处 `_attempt` 的 finally 与 admission 共用):
|
||||
|
||||
```python
|
||||
async def settle_and_release(permit: Permit, actual: int) -> None:
|
||||
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
|
||||
```
|
||||
|
||||
睡眠时长(T5 实现,写死在 `SourceAdmission._nap`):
|
||||
|
||||
```python
|
||||
def _nap(self, hint: float, clock: StallClock) -> float:
|
||||
jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
|
||||
budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
|
||||
wait = hint + jitter if hint > 0 else jitter
|
||||
return max(jitter, min(wait, budget))
|
||||
```
|
||||
|
||||
`hint == 0` 时该式退化为 `jitter`,即现有 quota-wait 行为逐字不变(`tests/unit/test_backpressure.py` 已钉 `[0.5p, 1.0p]`)。**下界取 `jitter` 而非 `poll_interval_s`(实施期修正)**: 后者会把 `rng → 0` 那半边从 `0.5p` 抬到 `1.0p`,既有的 `test_poll_jitter_bounds` 当场变红;`jitter` 同样能在预算为负时兜住不返回负数、不忙循环。`budget` 加一个 `poll_interval_s` 是因为 `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`),恰好夹到窗口不会判死。
|
||||
|
||||
**调用约束**: `_nap` 必须在 `stalled()` 判定**之后**调用。若已 stall 超窗才进来,`budget` 为负,外层 `max(poll_interval_s, ...)` 会兜成一个 poll 间隔(不会返回负数),但那意味着本该判死却又睡了一轮——顺序由 `on_no_runnable` 保证(两条路汇合后统一判 `stalled()` 再 sleep)。验算示例: `hint=60, stall_window=300, 已 stall 290, poll=0.05` → `jitter∈[0.025,0.05]`、`budget=10.05` → 返回 `10.05`,醒来累计约 `300.05` > 300,下一轮判死。
|
||||
|
||||
## 任务清单
|
||||
|
||||
### T0 — 分支与基线
|
||||
|
||||
- [ ] 建分支 `feat/issue-14-circuit-open-policy`(从 main)
|
||||
- [ ] 记录基线: `conda run -n PolyGateway python -m pytest tests/ -q` 与 `make check` + `lint-imports` 全绿,记下**本机本环境**的用例计数(执行时实测,2026-08-19 为 988 passed / 32 deselected)。该数只作同环境参照——`addopts = "-m 'not slow'"` 与 Redis 可达性都会改变它,不作硬验收
|
||||
|
||||
**验证**: `conda run -n PolyGateway python -m pytest tests/ -q` → 全 PASS;`git rev-parse --abbrev-ref HEAD` → 分支名正确
|
||||
|
||||
---
|
||||
|
||||
### T1 — 纯重构: 准入逻辑三处收敛(回滚点)
|
||||
|
||||
**动**: 新建 `src/polygateway/middleware/admission.py`;改 `middleware/retry.py`、`embedding.py`、`ocr.py`。
|
||||
|
||||
**要实现的行为**: 把 `_pick_runnable`/`_on_no_runnable`/`_stalled`/`_settle_and_release` 从三处搬进 `SourceAdmission` 与模块级 `settle_and_release`,三条循环改为持有 `SourceAdmission` 实例并调用其方法。**本任务不引入 `circuit_open` 参数**(构造签名先只收 `quota_full`,T4 再加),控制流一字不改。
|
||||
|
||||
三条循环的差异只用注入表达,不留 `if` 分支:
|
||||
|
||||
| 差异 | 处理 | 等价性依据 |
|
||||
|---|---|---|
|
||||
| 调用内降权(仅 chat) | `attempt_fails` 作 `pick()` 入参,内部无条件调 `_demote_call_failures` | 传空 dict 时 `demoted` 为空 → `return ordered` 原对象返回,恒等(`retry.py:148-150`) |
|
||||
| AIMD pacer(仅 chat) | `pacer: AdaptivePacer \| None = None` | None 时跳过 `admit()` 与 `enter()` 两个调用点,无副作用 |
|
||||
| `_settle_and_release` 签名 | OCR 原为 `(permit)`、体内恒 `settle(0)`;改为调 `settle_and_release(permit, 0)` | 逐字等价 |
|
||||
| warning 文案**三处都不同** | 归一为 "permit 结算/释放失败(不掩盖主异常)" | chat `retry.py:536` 已是该文案;embedding `embedding.py:411` 为 "embedding permit …"、OCR `ocr.py:448` 为 "OCR permit …" 将被归一(Codex 审查补,原稿只承认了 OCR)。这是本任务**唯一**的可见行为变化,须在提交信息里点名 |
|
||||
| `_stalled` 形态 | chat 已抽成方法,embedding/ocr 为内联表达式 | 两者语义逐字相同(已 diff 核实),统一用 `SourceAdmission.stalled()` |
|
||||
|
||||
**搬走 vs 共享(自审修正,这一条决定 T1 能否成立)**: 三处 `_attempt` 仍在引用 `self._breaker`(记账写回)、`self._quota`(mark_progress)、`self._pacer`(leave)、OCR 还有 `self._selector`(健康喂数,`ocr.py:426`)。因此这些字段**不搬走,而是共享同一实例**——循环保留自己的引用,构造 `SourceAdmission` 时把同一对象传进去(`AdaptivePacer` 有在途计数状态,必须是同一实例而非新建,否则 `admit`/`enter` 与 `leave` 分裂到两个计数器上)。真正搬走的只有 `_pick_runnable`/`_on_no_runnable`/`_stalled` 三个方法与 `self._memo`(仅被 `pick` 消费)。
|
||||
|
||||
**`_attempt` 的唯一改动**: `self._settle_and_release(permit, actual)` → 模块级 `settle_and_release(permit, actual)`,OCR 侧由 `(permit)` 变为 `(permit, 0)`。除此之外 `_attempt` 一行不动。原稿"三处 `_attempt` 本体不在边界内"的说法与"搬走 `_settle_and_release`"自相矛盾,此处更正。
|
||||
|
||||
**不在边界内、须原样保留**: chat 主循环顶部那次额外的 `_stalled` 预判(`retry.py:286`)、OCR 的 `_gate_on_terminal`(`ocr.py:412`)与健康喂数。
|
||||
|
||||
**保真校验检查点**: 对照 `reference/CHSAnalyzer/app/providers/governance.py:107-285`,确认搬运后 `_pick_runnable` 的候选跳过顺序(备忘 → pacer → 配额 → 熔断门)、`gate_rejections` 的计入规则(备忘与熔断门计入,pacer 与配额不计入)、`_on_no_runnable` 的三段判定顺序逐段未变。
|
||||
|
||||
**测试要求(本任务特殊)**: **不新增行为用例**。全套件绿是必要条件而非充分条件——它证明不了"逐字不变",故本任务额外要求一次**机械差异审查**: 把搬迁前后的 `pick`/`on_no_runnable` 逐语句对照,确认候选跳过顺序、`gate_rejections` 计入规则、`reasons` 的 `[]=` 与 `setdefault` 用法(两者语义不同,不可互换)一字未变。
|
||||
|
||||
**已知会碰到的既有测试**: `tests/unit/test_health_selector.py:146` 断言 `client._terminal._pacer._ceiling`,`tests/unit/test_client.py:380` 断言 `._terminal._emitter._text_cap`——这两个字段必须留在 `RetryMW` 上(与上面"共享而非搬走"一致),否则这些用例会红。
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
conda run -n PolyGateway python -m pytest tests/ -q # 期望: 全 PASS,计数与 T0 同环境基线一致
|
||||
conda run -n PolyGateway make check # 只读: ruff format --check + ruff check
|
||||
conda run -n PolyGateway lint-imports # 依赖铁律
|
||||
```
|
||||
**不要用 `make lint` 做验证**——它带 `--fix` 会自动改文件(`Makefile:11`),只读验证用 `make check` + `lint-imports`。用例计数只作**同环境**参照,不作硬验收: `pytest` 默认 `-m 'not slow'`(`pyproject.toml:51`),且无 `REDIS_URL` 时 Redis 用例 skip,计数随环境浮动。
|
||||
|
||||
import-linter 层级(`pyproject.toml:76`)允许 `middleware/admission.py` 依赖 `ports`/`types`/`errors`/`sources`(更内层),但不得 import 任何 `backends/`、`transports/`、`telemetry/`。搬迁后须清理三个原文件中失去引用的 import(`CircuitOpenError`、`QuotaGate`、`BreakerGate`、`SourceCooldownMemo` 等),否则 ruff 报未使用导入。
|
||||
|
||||
- [ ] 提交: `refactor: 把三条治理循环的准入逻辑收敛为 SourceAdmission`
|
||||
|
||||
---
|
||||
|
||||
### T2 — `retry_after_s` 语义统一(两个后端一次到位)
|
||||
|
||||
**动**: `src/polygateway/backends/memory/breaker.py`、`src/polygateway/backends/redis/breaker.py`、`tests/contracts/test_breaker_contract.py`、`tests/integration/test_redis_governance_time.py`。
|
||||
|
||||
**为什么两个后端必须同一个提交(Codex 审查修正)**: 原稿把 memory 与 redis 拆成 T2/T3 两次提交,中间 redis 侧契约用例会处于 red。但 `.claude/settings.json` 注册的 `pre-commit-guard.sh` 在检测到 `git commit` 时会跑 `pytest tests/ --tb=line -q`(`pre-commit-guard.sh:61`),红态直接卡住提交。且两者本就是**同一个契约的两个实现**,分开提交没有独立意义。
|
||||
|
||||
**要实现的行为**: `retry_after_s` = "距离**确定**可再试的时刻还有多久"。HALF_OPEN 下探针随时可能出结果,不存在确定时刻,故 `0.0`;准入被允许时同样恒 `0.0`。`0 = 可立即重试` 是库既有约定(`errors.py` 与现有契约用例"健康 → 0、冷却到期 → 0")。
|
||||
|
||||
memory 侧: 抽私有纯方法 `_remaining(g: _SourceGate) -> float`(OPEN 返回 `max(0.0, g.open_until - now)`,其余状态含 HALF_OPEN 返回 `0.0`),`try_enter` 的 HALF_OPEN 拒绝分支(`memory:148`)与 `retry_after_s()`(`memory:267`)改用它。`_snapshot`(`memory:169`)与授予探针(`memory:114`)已符合新契约,保持不变。
|
||||
|
||||
redis 侧共**六个返回格**,逐处点名(改前先确认行号仍对得上):
|
||||
|
||||
| 脚本 | 位置 | 现状 | 改为 |
|
||||
|---|---|---|---|
|
||||
| `TRY_ENTER` HALF_OPEN 拒绝 | `redis:44` | `probe_until - now` | `0` |
|
||||
| `TRY_ENTER` 授予探针 | `redis:53` | `tonumber(ARGV[2])`(= probe TTL) | `0` |
|
||||
| `RECORD_SUCCESS` fencing 未命中 | `redis:124` | half_open 取 `probe_until` | half_open 记 `0`(只 OPEN 取 `open_until - now`) |
|
||||
| `RECORD_FAILURE` fencing 未命中 | `redis:155` | 同上 | 同上 |
|
||||
| `RELEASE_PROBE` fencing 未命中 | `redis:255` | 同上 | 同上 |
|
||||
| `RETRY_AFTER` | `redis:275` | half_open 取 `probe_until` | half_open 记 `0` |
|
||||
|
||||
后四行修的是**既有的双后端语义分叉**(memory `_snapshot` 对非 OPEN 一律 `0.0`),与本 issue 同源,由契约测试盲区掩护至今——现有用例只钉"第二个进入者被拒",没钉它拿到什么数。
|
||||
|
||||
**保真校验检查点**: 状态机转换、双通道开路判据、`_cooldown_eff` 指数退避、epoch fencing 匹配条件、Lua 的原子性结构与 `redis.call('TIME')` 服务器时钟口径**一律不动**——本任务只改"对外报几"这一件事,即 return 元组里 `retry_after_ms` 那一格。改完逐脚本与 memory 实现对照走一遍状态机。
|
||||
|
||||
**测试要求**(先失败后通过,`tests/contracts/` 双后端参数化,一次覆盖 memory + redis):
|
||||
- HALF_OPEN 被拒: `decision.retry_after_s == 0.0` 且 `decision.state is GateState.HALF_OPEN`
|
||||
- 授予探针的决定: `retry_after_s == 0.0`
|
||||
- `record_*` 在 fencing 未命中且门处于 HALF_OPEN: `GateUpdate.retry_after_s == 0.0`(须同时断言 `applied is False`、`state is HALF_OPEN`,否则用例可能在别的分支上误绿)
|
||||
- `gate.retry_after_s(("s1",))` 探针在途时返回 `0.0`
|
||||
- 现有 `test_retry_after_semantics` / `test_retry_after_takes_min_across_sources` 保持绿(OPEN 语义未变)
|
||||
|
||||
**Redis 时间语义变体**: 契约层用 `clock.advance()` 的用例在 redis 参数下会 skip(`conftest.py:39` 的 `SkipClock` 哨兵),故须在 `tests/integration/test_redis_governance_time.py` 补 1:1 真实等待变体(既有约定: 不缩放时长)。该文件的 `test_meta_variants_cover_all_time_cases`(`:56`)会**机械拦截**漏配,漏了就红。
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
conda run -n PolyGateway python -m pytest tests/contracts/test_breaker_contract.py -q # 双后端全 PASS
|
||||
conda run -n PolyGateway python -m pytest tests/integration/test_redis_governance_time.py -m slow -q
|
||||
```
|
||||
第二条**必须带 `-m slow`**: `pyproject.toml:51` 的 `addopts = "-m 'not slow'"` 默认排除真实等待变体,不加就是空跑(该文件单跑 12-15 分钟)。需真实 Redis(db3),不 mock Lua 行为。
|
||||
|
||||
- [ ] 提交: `fix: 把 retry_after_s 定义为确定可再试时刻,HALF_OPEN 归零(双后端)`
|
||||
|
||||
---
|
||||
|
||||
### T3 — (已并入 T2)
|
||||
|
||||
原计划把 redis 侧拆为独立任务,因 pre-commit hook 会拦截中间红态而合并进 T2。此编号保留以免后续引用错位。
|
||||
|
||||
---
|
||||
|
||||
### T4 — 新配置键 `{SCOPE}__CIRCUIT_OPEN`
|
||||
|
||||
**动**: `src/polygateway/config.py`、`src/polygateway/client.py`、`src/polygateway/middleware/admission.py`、`embedding.py`、`ocr.py`、`tests/unit/test_config.py`。
|
||||
|
||||
**要实现的行为**: 与 `quota_full` 逐项同构,不发明新形状。
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| `config.py` 常量区 | `_CIRCUIT_OPEN = frozenset({"wait", "fail_fast"})`,紧邻 `_QUOTA_FULL` |
|
||||
| `GatewaySettings` | 新增字段 `circuit_open: str`,**无默认值**(与该类全部既有字段一致),位置紧随 `quota_full` |
|
||||
| `_validate_backends` | 校验元组加一行 `("circuit_open", _CIRCUIT_OPEN)` |
|
||||
| `from_env` | `circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast")` |
|
||||
| `client.py` | `GatewayClient.__init__` 加 `circuit_open: str = "fail_fast"`;`from_settings` 透传 `settings.circuit_open` |
|
||||
| `admission.py` | 构造收 `circuit_open`,同 `quota_full` 做构造期域校验并抛 `ValueError` |
|
||||
| `embedding.py` / `ocr.py` | 两个客户端的构造签名与"从 GatewayClient 派生"路径(`embedding.py:561`、`ocr.py:574` 邻域)各透传一处 |
|
||||
|
||||
**缺省取 `fail_fast`**(人类 2026-08-19 决策): 保证控制流对存量下游不变。
|
||||
|
||||
**测试要求**(先失败后通过):
|
||||
- 缺省档: 不设该键时 `settings.circuit_open == "fail_fast"`
|
||||
- 合法域: 设为 `"nope"` 时 `from_env` 与直接构造**两条路**都抛 `ValueError` 且消息点出键名/字段名
|
||||
- 两条装配路一致: `from_env` 与直接构造同一取值产出同一行为
|
||||
- `dataclasses.replace(settings, circuit_open="wait")` 仍通过全部装配守卫
|
||||
- 透传链: 从 `GatewaySettings` 一路到三条循环的 `SourceAdmission` 实例上取值正确
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
conda run -n PolyGateway python -m pytest tests/unit/test_config.py tests/unit/test_client.py -q
|
||||
```
|
||||
|
||||
- [ ] 提交: `feat: 新增 {SCOPE}__CIRCUIT_OPEN 策略键(缺省 fail_fast)`
|
||||
|
||||
---
|
||||
|
||||
### T5 — `on_no_runnable` 按原因分派 + `_nap`
|
||||
|
||||
**动**: `src/polygateway/middleware/admission.py`、`tests/unit/test_backpressure.py`。
|
||||
|
||||
**要实现的行为**: 把现状串行的两个分支改为按拒绝原因分派(伪码见设计 §3.3)。要点:
|
||||
|
||||
1. `gate_rejections == len(sources)`(全部因熔断类原因被拒)时,`fail_fast` 抛 `CircuitOpenError`(现行为),`wait` 取 `hint = await breaker.retry_after_s(names)` 后**不抛**;
|
||||
2. 否则(至少一源是被配额/AIMD 挡的)走 `quota_full` 分支,`hint = 0.0`;
|
||||
3. 两条路汇合后统一判 `stalled()`,再 `await sleep(self._nap(hint, clock))`。
|
||||
|
||||
**必须避免的坑**: 若只把第一分支改成"wait 时不抛"而不做分派,控制流会掉进 `quota_full` 分支——`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`。
|
||||
|
||||
**可观测性**: `wait` 档每轮进入等待时 `logger.info` 一条(scope、`per_source_reasons`、本次睡眠秒数)。**只此一条,不打"醒来"那条**(实施期决定): 每一轮等待各自留痕,时间线已可完整还原,而醒来后若仍被拒会立刻打下一条——补一条"醒来"只会让日志量翻倍且信息重复。**不新增遥测列**(等待期不发请求,无 attempt 行可记;调用级总耗时下游可自测)。
|
||||
|
||||
**计时归属**: 睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3"熔断冷却属非生产性等待"一致——**无需改 `StallClock`**。
|
||||
|
||||
**取消穿透**: `_nap` 只做算术,睡眠是裸 `await self._sleep(...)`,不得包 `try/except`。
|
||||
|
||||
**测试要求**(先失败后通过,注入时钟/睡眠/rng 保持确定性):
|
||||
- `circuit_open=wait` + 全源开路 → **不**抛 `CircuitOpenError`,而是按 `retry_after` 睡;冷却结束后拿到探针并成功返回
|
||||
- `circuit_open=wait` + `quota_full=fail_fast` + 全源开路 → **不**抛 `quota_exhausted`(这是上面那个坑的钉子)
|
||||
- `circuit_open=wait` + 冷却比 stall 预算还长 → 抛 `AllSourcesExhausted(reason="stalled")`,`per_source_reasons` 含 `circuit_open`,累计墙钟 ≤ `stall_window_s + poll_interval_s`
|
||||
- `circuit_open=wait` + 源持续 `force_open` → **`retry_exhausted` 而非 `stalled`**(整分支审查发现,原稿写错): 冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`,故两个预算里先耗尽的那个决定 reason
|
||||
- 混合原因(部分 `circuit_open` + 部分 `rate_limited`)→ 走 quota 分支,`per_source_reasons` 如实混合
|
||||
- `hint == 0` 时睡眠落在 `[0.5p, 1.0p]`(现有 quota-wait 行为逐字不变)
|
||||
- `wait` 档等待中收到 `CancelledError` → 逐字穿透,in-flight permit 已释放
|
||||
- `circuit_open=fail_fast`(缺省)下,全部现有用例逐字绿
|
||||
- **备忘污染回归**(issue #14 §1.3): 探针成功后 `memo.active(源名)` 为 False,该源立即重新可选——此用例由 `/tmp/.../probe_repro.py` 的复现脚本转化而来,在 T2 之前必然 red
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
conda run -n PolyGateway python -m pytest tests/unit/test_backpressure.py tests/unit/test_retry.py -q
|
||||
conda run -n PolyGateway python -m pytest tests/ -q # 全套件
|
||||
```
|
||||
|
||||
- [ ] 提交: `feat: circuit_open=wait 下熔断拒绝改为等待而非当场判死`
|
||||
|
||||
---
|
||||
|
||||
### T6 — `errors.py` 职责边界补写
|
||||
|
||||
**动**: `src/polygateway/errors.py`。
|
||||
|
||||
**要实现的行为**: 改写 `GatewayUnavailableError` 的 docstring。现文"业务侧 catch 本类做延期重投(CHS arq 模式)"读起来像鼓励每个下游各写一份重试逻辑;改为明确边界——调用级的重试/退避/换源/等待全部在库内,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是**任务级重试**,语义与调用级重试不同(ARCH §7.2 单层重试原则)。
|
||||
|
||||
`retry_after_s` 那句保留并补一句: 它是"距离确定可再试的时刻",`0` 表示无确定等待(可立即重试)。
|
||||
|
||||
**测试要求**: 纯 docstring,无行为变更。验收为 `tests/unit/test_errors.py` 保持绿。
|
||||
|
||||
**验证**: `conda run -n PolyGateway python -m pytest tests/unit/test_errors.py -q`
|
||||
|
||||
- [ ] 提交: `docs: 收回 GatewayUnavailableError 的重试职责边界`
|
||||
|
||||
---
|
||||
|
||||
### T7 — 文档同步
|
||||
|
||||
**动**: `research-wiki/ARCHITECTURE.md`、`README.md`、`CHANGELOG.md`、Gitea wiki。
|
||||
|
||||
| 目标 | 内容 |
|
||||
|---|---|
|
||||
| ARCH §7.4 | 增补本次决策: 三条缺陷的成因、`retry_after_s` 的契约定义(五个出口)、`circuit_open` 策略键与缺省理由 |
|
||||
| ARCH §9 配置面 | 登记 `{SCOPE}__CIRCUIT_OPEN` |
|
||||
| README | 配置表新增该键;**明写"单源 scope 建议配 `wait`"**——缺了这句,这个开关等于不存在;核对安装命令的版本约束是否需要跟着改 |
|
||||
| CHANGELOG | 记 1.3.0,`retry_after_s` 语义变更给"请先读这一条"待遇(缺省档下 `CircuitOpenError.retry_after_s` 在全源 HALF_OPEN 时由探针租约剩余变为 0) |
|
||||
| Gitea wiki | 按 `research-wiki/docs-convention.md` §2 清单同步 |
|
||||
|
||||
**验证**: 人工逐项核对上表;`grep -n "CIRCUIT_OPEN" README.md research-wiki/ARCHITECTURE.md` 各有命中。
|
||||
|
||||
- [ ] 提交: `docs: 记录熔断等待档与 retry_after_s 契约`
|
||||
|
||||
---
|
||||
|
||||
### T8 — 合并前独立验证
|
||||
|
||||
- [ ] 派**全新上下文** verifier subagent(`verification-before-completion`),逐条核对: 设计每一节是否有对应实现、五个 `retry_after_s` 出口是否都改到、三条循环行为是否一致、测试证据是否都是"先失败后通过"
|
||||
- [ ] `conda run -n PolyGateway make check` + `conda run -n PolyGateway lint-imports` 全绿(**不用 `make lint`**,它带 `--fix` 会改文件)
|
||||
- [ ] `conda run -n PolyGateway make test` 全套件绿 + 覆盖率 ≥ 80%
|
||||
- [ ] Redis integration 套件在真实 Redis 上绿,含 `-m slow` 的时间语义变体(默认 addopts 会排除它)
|
||||
- [ ] `requesting-code-review` 走一次整分支审查
|
||||
- [ ] `finishing-a-development-branch`: `--no-ff` 合并 main,合并后在 main 上重跑 lint 与全套件
|
||||
|
||||
**注**: 发布(tag/构建/上传 registry/建 Release)按 CLAUDE.md §4.4.1 九步走,**不在本计划范围**,需人类确认后单独执行。
|
||||
|
||||
## 自审记录
|
||||
|
||||
- 设计每一节到任务的映射: §3.1→T2+T3、§3.2→T4、§3.3→T5、§3.4→T1、§3.5→T4(缺省值)+T7(文档)、§3.6→T6、§4 行为矩阵→T5 测试、§5 测试策略→T2/T3/T5、§6 非功能→T5(取消/计时/上界)
|
||||
- 无 TBD/TODO/"适当的错误处理"类占位
|
||||
- 跨任务消费的 `SourceAdmission` 签名、`settle_and_release`、`_nap` 公式已在"关键接口"写出实际代码
|
||||
- 任务顺序有硬依赖: T1(收敛)必须先于 T5(在单一位置加语义)。原 T2/T3 拆分已合并——pre-commit hook 跑全套件,任何跨提交的红态都会被拦
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:plan-issue15-telemetry-pool-lifecycle
|
||||
title: "实现计划: 遥测连接池的资源语义与生命周期(issue #15)"
|
||||
date: 2026-08-24
|
||||
---
|
||||
|
||||
# 实现计划: 遥测连接池的资源语义与生命周期(issue #15)
|
||||
|
||||
正文: `2026-08-24-issue15-telemetry-pool-lifecycle.md`(380 行)。实现 [[design:issue15-telemetry-pool-lifecycle]]。状态: **T0–T7 全部完成 + T8 处置独立验证发现的 5 个问题(2026-08-24)**,提交表见正文末尾。
|
||||
|
||||
- **八个任务**: T0 分支与基线(把已完成的 Python 3.12 迁移落盘)→ T1 D 组所有权纪律(独立回滚点)→ T2 C 组 tracker 与状态快照 → T3 A 组池语义与两个新配置键 → T4 有界关闭 → T5 B 组失败三分与冷却降级(核心)→ T6 真实 PG 集成验证 → T7 文档与发布说明。
|
||||
- **顺序的关键理由**: tracker(T2)排在池语义(T3)与失败判据(T5)**之前**——后两步的每个降级点都要向 tracker 报告,反过来做要把日志代码返工一遍。代价是 T2 结束时 `_failed` 与 tracker 状态**临时并存**(为了让 T2 能独立全绿提交),T5 必须收掉,两份状态只允许存活一个任务的跨度。
|
||||
- **执行前必读的两条事实**: ① Python 3.12 迁移的改动**还在 main 的工作区未提交**(T0 第一件事就是落到分支);② **建池路径今天零测试覆盖**——全 `tests/` 对 `create_pool`/`_open_pool` 的引用数为 0,现有 PG 用例一律经 `pool=_FakePgPool(...)` 注入、走 `_external_pool=True` 分支从不建池。这正是 `min_size=10` 潜伏至今的原因,也意味着 T3 要建这一路的**第一个**用例。
|
||||
- **提交门是任务边界的实际约束**: `.claude/scripts/hooks/pre-commit-guard.sh` 对每次 `git commit` 阻塞式跑 ruff + radon(圈复杂度 ≥C 即拦)+ 全套件。由此两条硬约束: 不得留红态跨提交(不能把一个行为拆成"改实现"和"改测试"两次);T5 同时改三个降级点,`record_llm_call` 逼近 C 时必须抽私有方法——这不算计划外重构,是提交门的硬要求。
|
||||
- **两条既有承诺挂了检查点,不得被本次改动破坏**: [[design:issue13-schema-mode]] 的"manual 档缺列时裁剪 INSERT 继续写、逐行暴露"(故 `42703` 是失败分类的唯一具名例外)、[[design:issue9-telemetry-ddl-probe]] 的"表存在就绝不发 DDL"(`to_regclass` 探测那段控制流一行不动)。
|
||||
- **保真校验不适用**: 遥测后端无 `reference/` 蓝本(ARCH §7.8 明记"参考仓无先例: 三项目遥测全 SQLite")。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:reasoning-effort
|
||||
title: "实现计划: 推理档位一等化"
|
||||
date: 2026-09-05
|
||||
---
|
||||
|
||||
# 实现计划: 推理档位一等化
|
||||
|
||||
正文: `2026-09-04-reasoning-effort.md`(378 行,10 任务)。实现 `design:reasoning-effort`。
|
||||
|
||||
- **拆分逻辑**: T1(`Effort` 词汇 + 能力表)与 T2(`ThinkingWire` + 8 段 provider 表)可并行 → T3(五道关卡 + nearest 映射)→ T4(源级 env 入口)→ T5(请求级入口与优先级)→ T6(缓存 key 两处)/T7(遥测第 26 列)→ T8(transport 接线与告警节流)→ T9(CHANGELOG/wiki/1.4.0)→ T10(经 new-api 逐模型实测,标 `slow`)。
|
||||
- **T10 单列的理由**: 人类定「能力表数据统一自己经 new-api 实测」。T1 落的是文档推定值(四方交叉: 官方文档/OpenRouter/cherry-studio/LiteLLM),不实测则整张表都是假设——LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下三档、`perplexity/` 下六档,中转改档位有第三方证据。
|
||||
- **执行方式**: 直接按计划实现,**不派** `subagent-driven-development`——10 个任务共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型,独立上下文的 subagent 容易在签名上分叉。
|
||||
- **保真校验**: 不适用(`thinking.py` 系库自研,非 `reference/` 移植蓝本;且三项目当前不在工作区)。
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:response-observability-fields
|
||||
title: 响应可观测字段扩展实现计划
|
||||
date: 2026-07-31
|
||||
---
|
||||
|
||||
# 响应可观测字段扩展实现计划
|
||||
|
||||
全文见 `2026-07-31-response-observability-fields.md`。实现 [[response-observability-fields]] 设计(A2/B1/C1/D1)。
|
||||
|
||||
## 任务序列
|
||||
|
||||
| 任务 | 内容 | 提交 |
|
||||
|---|---|---|
|
||||
| T1 | `types.py` 两个类型各 +2 字段;`cache_hit` docstring 消歧 | 独立 |
|
||||
| T2 | `openai_compat.py` 防御解析 + SSE sink 采集 `model` + 两处构造填值 | 独立 |
|
||||
| T3 | `retry.py` 搬运;`cache.py` 零改动但用测试固化 B1 回放语义 | 独立 |
|
||||
| T4 | `pricing.py` 可选缓存单价档 + 夹取防负 | 独立 |
|
||||
| T5+T6 | 端口 18→20、两后端 DDL 加列与幂等补列、emitter 搬运、契约测试 | **必须合一次提交** |
|
||||
| T7 | ARCHITECTURE §7.8 / CHANGELOG / `.env.example:56` / 6 处「18 字段」措辞 / 版本 1.1.0 / Gitea Wiki 站 | 独立 |
|
||||
|
||||
## 独立审查抓出的四个坑(已折回计划)
|
||||
|
||||
1. **DDL 新列必须放在 `created_at` 之后**(表末尾)。旧表走 `ALTER ADD COLUMN` 只能追加到末尾,若新建库把新列插在 `created_at` 前,两条路径列序分叉 —— 而 `test_schema_has_frozen_columns_in_order` 按 `ordinal_position` 逐位断言,且该 PG 表与真实批跑共享、严禁 DROP,分叉后无合规修法。
|
||||
2. **SQLite 补列块首行必须守卫 `if self._conn is None: return`**。否则初始化失败时补列块抛 `AttributeError`/`NameError`(不被 `sqlite3.Error` 捕获)逃出 `__init__`,打破「初始化失败静默降级」契约。
|
||||
3. **T5 与 T6 不得分开提交**。中间状态下 emitter 只传 18 键,后端抛 `KeyError` 被吞成 warning,该 commit 全量遥测静默丢失。
|
||||
4. **天然拦截点是四处而非三处**:两个 `_record_minimal` + 两个 `_EXPECTED_COLUMNS`;`test_ports.py:96` 的全签名 fake **不会**红(Protocol 的 isinstance 不校验签名),不能当作覆盖保证。
|
||||
|
||||
相关: [[response-observability-fields]]、[[est-tokens-decoupling]]
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:sampling-params-plan
|
||||
title: "采样参数透传实现计划(issue #4)"
|
||||
date: 2026-07-31
|
||||
---
|
||||
|
||||
# 采样参数透传实现计划(issue #4)
|
||||
|
||||
正文: `2026-07-31-sampling-params.md`。实现 `design:sampling-params`。
|
||||
|
||||
- **任务数**: 11 个,每个一次提交、独立可验证。Task 1-4 是 issue 诉求的最小闭环;Task 5-8 是设计中「issue 未提但必须处理」的部分(地基不变式、遥测三入口、两后端落列、决策 G 剥离),不可跳过。
|
||||
- **关键接口已在计划 §1 定死**: `validate_request_overlay()` / `merge_sampling()` / `canonical_sampling_json()` 三个纯函数落 `types.py`(最内层),`ChatRequest.sampling`、`SourceConfig.extra_body` 两个新字段,`build_cache_key()` 与 `chat()` 的新签名。
|
||||
- **审查暴露的执行陷阱(已写进计划)**: ① 两个 `_record_minimal()` 的硬编码 20 键 fields dict 必须同步,否则 `row = tuple(fields[col] for col in _COLUMNS)`(在 try 之外)抛裸 `KeyError` 让两侧落库测试全红;② `SourceConfig` 加 mapping 字段后不再 hashable、`asdict`/`deepcopy` 失效——已核实库内无调用点会踩,作为已知后果显式接受并加锁定测试;③ 各文件需新增的 import 逐一列出(`types.py` 无 `from __future__ import annotations`,注解在类体求值);④ `validate_request_overlay` 的校验顺序必须先查 str 键再试序列化,否则非 str 键会被误报成"值不可序列化"。
|
||||
- **测试证据门**: 每个任务合并前须出示先失败后通过的证据。Task 5 单列一条地基不变式回归——决策 C/D 都建立在「`sampling` 跨层恒定」之上,而这条目前只靠 `dataclasses.replace` 的约定,无机械执法;该测试须在故意破坏 `structured.py` 时验证过确实变红。
|
||||
- **共享后端纪律**: Task 7、Task 11 涉及 PG `polygateway` 库,严禁与其他会话并跑(含 git 钩子触发的测试)。
|
||||
- **审查留痕**: Codex CLI 不可用(vendor 二进制缺失),派全新上下文 subagent 只读审查。报 5 项必修(两处测试文件路径不存在、`_record_minimal` 漏项、Gitea Wiki 同步漏整块、`SourceConfig` 可哈希性后果未声明),逐条核实后全部采纳;5 条建议(import 清单、校验顺序、Task 5 落点表述、`make ci` 勿嵌套 `conda run`、`test_ports.py` 的 `_DummyRecorder` 同步)亦已收进。
|
||||
@@ -1,3 +1,3 @@
|
||||
# Query Pack
|
||||
|
||||
> 尚无数据。运行 research-lit 或 idea-creator 后自动生成。
|
||||
> 自动生成,请勿手动编辑。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: review
|
||||
node_id: review:issue14-branch-review
|
||||
title: "整分支审查: issue #14 熔断等待档"
|
||||
date: 2026-08-20
|
||||
---
|
||||
|
||||
# 整分支审查: issue #14 熔断等待档
|
||||
|
||||
|
||||
- **范围**: `feat/issue-14-circuit-open-policy`,296c765..5a025b6(8 提交,src 6 文件 + tests 5 文件)
|
||||
- **审查方**: Codex 全新上下文只读审查(两轮: 独立验收 + 整分支审查)
|
||||
- **结论**: **needs_changes → 修正后 approved**;Critical 0 项
|
||||
|
||||
## 发现与处置
|
||||
|
||||
| 级别 | 发现 | 核实 | 处置 |
|
||||
|---|---|---|---|
|
||||
| Important | `circuit_open=wait` + 持续 `force_open` 实际抛 `retry_exhausted` 而非文档声称的 `stalled` | **成立**。冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`;审查方以单源 + 连续 `SourceDeadError("401")` 复现,本地补测试复现一致 | **改文档不改代码**——该行为符合 issue #8 确立的"划分依据是谁消耗重试预算"。修正 CHANGELOG / README / 设计 §4 行为矩阵 / 计划 T5,并补 `test_wait_does_not_exempt_probes_from_the_retry_budget` 钉死 |
|
||||
| Minor | 计划要求进入/退出等待各一条日志,实现只有进入那条 | 成立 | **保持一条**,修计划措辞: 每轮等待各自留痕已可还原时间线,醒来后若仍被拒会立刻打下一条,补"醒来"只会让日志量翻倍 |
|
||||
| — | 上一轮独立验收挑出计划 `_nap` 伪码下界与实现不一致(`poll_interval_s` vs `jitter`) | 成立 | 实现是对的(用 `poll_interval_s` 会把既有 quota 轮询的 `rng→0` 半边从 `0.5p` 抬到 `1.0p`),已回填计划 |
|
||||
|
||||
审查方两轮均确认: T1 收敛行为等价、六个 `retry_after_s` 出口齐备、备忘污染闭合、取消穿透与 permit/pacer 配对无泄漏、缺省档控制流不变。
|
||||
|
||||
## 验证证据(本会话工具输出)
|
||||
|
||||
- 全套件 `pytest tests/ -q`: **980 passed, 25 skipped, 36 deselected**(基线 967 passed;+13 为新增用例)
|
||||
- 覆盖率 `make test`: 总 **94%**(`admission.py` 93%、`config.py` 99%、`memory/breaker.py` 96%)
|
||||
- Redis 时间语义全变体 `-m slow`: **18 passed in 1151s**(19 分 11 秒,真实等待不缩放),含本次新增 4 个
|
||||
- `make check` 与 `lint-imports`: 全绿,**Contracts: 1 kept, 0 broken**
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
type: schema
|
||||
node_id: schema:llm-calls
|
||||
title: "表结构: llm_calls(遥测 18 字段)"
|
||||
title: "表结构: llm_calls(遥测 26 字段)"
|
||||
date: 2026-07-20
|
||||
---
|
||||
|
||||
# 表结构: llm_calls(遥测 18 字段)
|
||||
# 表结构: llm_calls(遥测 26 字段)
|
||||
|
||||
|
||||
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
||||
@@ -16,14 +16,122 @@ date: 2026-07-20
|
||||
| parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) |
|
||||
| model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) |
|
||||
| messages / response / thinking | TEXT NOT NULL | messages 落库前多模态 part 摘要(与缓存 key 共用 digest_messages) |
|
||||
| prompt_tokens / completion_tokens | INTEGER NOT NULL | usage 帧;缺失按 est 兜底 |
|
||||
| usage_source | TEXT NOT NULL | measured / estimated |
|
||||
| prompt_tokens / completion_tokens | INTEGER NOT NULL | usage 帧;帧缺失记 0/0(不编造估值,由 usage_source 标注) |
|
||||
| usage_source | TEXT NOT NULL | measured / estimated / unavailable(2026-07-30 起三态,见下) |
|
||||
| latency_ms | INTEGER NOT NULL | 尝试耗时;缓存命中 0 |
|
||||
| ttft_ms / max_inter_token_ms | REAL | 流式活性测量 |
|
||||
| cache_hit | INTEGER NOT NULL DEFAULT 0 | 命中标记 |
|
||||
| error | TEXT | 异常信息;取消记 "cancelled" |
|
||||
| cost | REAL | M1 恒 NULL,M2 pricing 换算 |
|
||||
| cost | REAL | M2 起 pricing 换算;`usage_source='unavailable'` 的真实调用行为 NULL(缓存命中行例外,仍为 0.0) |
|
||||
| created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 |
|
||||
| cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 |
|
||||
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
|
||||
| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 |
|
||||
| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 |
|
||||
| tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 |
|
||||
| meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB |
|
||||
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
|
||||
| reasoning_effort | TEXT | 本次调用**实际发出**的推理档位(2026-09-04,issue #20);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
|
||||
|
||||
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
||||
|
||||
| usage_source | 含义 | 生产者 | cost |
|
||||
|---|---|---|---|
|
||||
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是事实) | 按 token 换算 |
|
||||
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
|
||||
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | NULL |
|
||||
|
||||
`SUM(cost)` 天然跳过 NULL,故账单汇总不再被虚构的估值污染;账目缺口的度量口径固定为 `WHERE usage_source = 'unavailable' AND cache_hit = false`。**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 是事实上的 `0.0` 而非未知,本无账目缺口,漏掉该条件会让缺口度量偏高。
|
||||
|
||||
## 供应商 prompt cache 口径(2026-07-31,issue #3)
|
||||
|
||||
新增两列排在 `created_at` **之后**——旧表只能经 `ALTER TABLE ADD COLUMN` 追加到末尾,DDL 里若插在前面,新建库与升级库的物理列序会分叉(列序断言无合规修法)。两个后端在初始化期幂等补列:`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入被逐行 warning 丢弃、遥测静默全失。两侧都**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,遥测是内联 await,锁共享审计表会拖垮业务调用),且**补列失败只降级为逐行丢弃,绝不让 recorder 整体失能**——两侧纪律必须对称。
|
||||
|
||||
`cache_hit` 指 **PolyGateway 自身响应缓存**,与供应商 prompt cache 是两回事。缓存命中行的这两列是**原样回放**的历史值(与 `model`/`prompt_tokens` 同一口径),故命中率度量口径固定为:
|
||||
|
||||
```sql
|
||||
SELECT SUM(cached_prompt_tokens)::float / NULLIF(SUM(prompt_tokens), 0)
|
||||
FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
|
||||
```
|
||||
|
||||
`WHERE cache_hit = false` 不可省,理由与上面 cost 缺口口径同源:回放行计入即重复计数。
|
||||
|
||||
## 采样参数口径(2026-07-31,issue #4)
|
||||
|
||||
`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故当时的统计口径是 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。
|
||||
|
||||
> **该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报 `completion_tokens_details`(MiniMax 这一路实测已停),此时 NULL 只意味着「没上报」而非「没推理」——同一次调用里库拿得到 185 字符推理正文。统计一律改按新列 `thinking_observation` 分组,见下方「推理观测口径」。
|
||||
|
||||
`sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。
|
||||
|
||||
三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同:
|
||||
|
||||
| 入口 | 调用者 | 有生效源? | 记什么 |
|
||||
|---|---|---|---|
|
||||
| `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` |
|
||||
| `emit_cache_hit` | TelemetryMW(最外) | 无 | 仅 `request.sampling` |
|
||||
| `emit_terminal_failure` | TelemetryMW | 无 | 仅 `request.sampling` |
|
||||
|
||||
后两行缺 `extra_body` 是客观事实而非口径瑕疵——它们没有"生效源"可言,与 `model`/`source_name` 在终态行置空是同一先例;缓存命中行亦无损:`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。
|
||||
|
||||
OCR / embedding 路径的该列**恒为 NULL**:两条路径的 transport 不发 `extra_body`(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart),故其源在构造期就被剥离——不剥离则该列会记录一个从未发出的参数,那是数据造假而非参数失效。
|
||||
|
||||
复现某批实验的解码条件:
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT sampling FROM llm_calls
|
||||
WHERE session_id = $1 AND cache_hit = false AND error IS NULL;
|
||||
```
|
||||
|
||||
## 推理观测口径(2026-08-25,issue #16/#17)
|
||||
|
||||
`thinking_observation` 是**响应侧的裁定结果**,不是请求侧的声明: 推理正文(`thinking`)非空即 `observed`(正文是事实本身,压倒 usage 明细这一转述);正文空而 `reasoning_tokens > 0` 亦 `observed`;`reasoning_tokens == 0` 为 `absent`(上游明确上报未推理);两个信号双缺为 `unknown`。
|
||||
|
||||
**`unknown` 不得并进「未推理」**。它是本列存在的全部理由: MiniMax 这一路上游 2026-08-25 起不再返回 `completion_tokens_details`,`reasoning_tokens` 因此恒 NULL,而同一次调用里库拿得到 185 字符推理正文——旧口径 `reasoning_tokens IS NULL OR = 0` 会把这类调用统计成「没推理」。**该旧口径自本版起作废**,统计一律按本列分组。M3 非流式档更极端: 推理已计费(completion 53 vs 关闭档 3)却不回传正文,该档只能是 `unknown`,任何把它读成「没推理」的报表都在撒谎。
|
||||
|
||||
按模型看各观测态占比,用于发现某模型从哪天起观测不到推理:
|
||||
|
||||
```sql
|
||||
SELECT model,
|
||||
thinking_observation,
|
||||
count(*) AS calls,
|
||||
round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY model), 1) AS pct
|
||||
FROM llm_calls
|
||||
WHERE cache_hit = false AND error IS NULL
|
||||
AND created_at >= now() - interval '7 days'
|
||||
GROUP BY model, thinking_observation
|
||||
ORDER BY model, calls DESC;
|
||||
```
|
||||
|
||||
三条限定各有理由: `cache_hit = false` 与 `cost`/`cached_prompt_tokens` 同源——缓存命中行原样回放历史观测值,计入即重复计数;`error IS NULL` 排除失败尝试与终态失败行,那些行的本列恒为 `unknown`(无响应可裁定,默认值本身不撒谎),混进来会把「观测不到」的占比整体抬高;时间窗是为了让**变化**可见——某模型的 `unknown` 占比从 0 跳到 100%,正是它停报推理信号的那一天。补列之前写入的历史行本列为 NULL,与 `unknown` 是两回事(前者是那时还没有这一列),跨版本对比须显式区分。
|
||||
|
||||
## 推理档位口径(2026-09-04,issue #20)
|
||||
|
||||
`reasoning_effort` 回答的是「这一行跑在哪一档」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。
|
||||
|
||||
三个 emit 入口的取值同样各自定死,与 `sampling` 同构:
|
||||
|
||||
| 入口 | 有生效源? | 记什么 |
|
||||
|---|---|---|
|
||||
| `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** |
|
||||
| `emit_attempt`(失败) | 有 | `effective_effort(请求级 > 源级 > enable_thinking)` 的**请求档** |
|
||||
| `emit_cache_hit` / `emit_terminal_failure` | 无 | 仅 `request.reasoning_effort` |
|
||||
|
||||
成功行必须读实发档而非重算: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。
|
||||
|
||||
OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。
|
||||
|
||||
按档位看推理产出,即压测「高档是不是真的多想」的基本查询:
|
||||
|
||||
```sql
|
||||
SELECT model, reasoning_effort,
|
||||
count(*) AS calls,
|
||||
round(avg(reasoning_tokens)) AS avg_reasoning_tokens
|
||||
FROM llm_calls
|
||||
WHERE cache_hit = false AND error IS NULL AND reasoning_effort IS NOT NULL
|
||||
GROUP BY model, reasoning_effort
|
||||
ORDER BY model, calls DESC;
|
||||
```
|
||||
|
||||
## 埋点位置(单一 helper 铁律)
|
||||
|
||||
|
||||
@@ -17,24 +17,45 @@ from polygateway.errors import (
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
SourceNotConfiguredError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.ocr import OcrClient
|
||||
from polygateway.pricing import ModelPrice, PricingTable
|
||||
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
|
||||
from polygateway.providers import (
|
||||
DEFAULT_PROFILES,
|
||||
ProviderProfile,
|
||||
ThinkingWire,
|
||||
register_provider,
|
||||
)
|
||||
from polygateway.telemetry.schema import telemetry_schema_sql
|
||||
from polygateway.thinking import (
|
||||
ThinkingCapability,
|
||||
ThinkingResolution,
|
||||
ThinkingUnsupportedError,
|
||||
get_capability,
|
||||
register_capability,
|
||||
resolve_thinking,
|
||||
)
|
||||
from polygateway.types import (
|
||||
EFFORT_ORDER,
|
||||
Effort,
|
||||
EmbeddingResponse,
|
||||
LLMResponse,
|
||||
OcrLayoutElement,
|
||||
OcrLayoutResult,
|
||||
OcrTextResult,
|
||||
SourceConfig,
|
||||
TelemetryStatus,
|
||||
ThinkingObservation,
|
||||
)
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.3.3"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROFILES",
|
||||
"EFFORT_ORDER",
|
||||
"Effort",
|
||||
"AllSourcesExhausted",
|
||||
"CircuitOpenError",
|
||||
"EmbeddingClient",
|
||||
@@ -58,8 +79,19 @@ __all__ = [
|
||||
"ResultInvalidError",
|
||||
"SourceConfig",
|
||||
"SourceDeadError",
|
||||
"SourceNotConfiguredError",
|
||||
"TelemetryStatus",
|
||||
"ThinkingCapability",
|
||||
"ThinkingObservation",
|
||||
"ThinkingResolution",
|
||||
"ThinkingUnsupportedError",
|
||||
"ThinkingWire",
|
||||
"TransientError",
|
||||
"__version__",
|
||||
"gather_bounded",
|
||||
"get_capability",
|
||||
"register_capability",
|
||||
"register_provider",
|
||||
"resolve_thinking",
|
||||
"telemetry_schema_sql",
|
||||
]
|
||||
|
||||
@@ -100,6 +100,22 @@ class InMemoryGate:
|
||||
streak = max(1, g.reopen_streak)
|
||||
return min(self._cfg.cooldown_s * (2 ** (streak - 1)), self._cfg.max_cooldown_s)
|
||||
|
||||
def _remaining(self, g: _SourceGate) -> float:
|
||||
"""距离**确定**可再试的时刻还有多久(issue #14 的契约定义)。
|
||||
|
||||
OPEN 的冷却截止是确定时刻;HALF_OPEN 下探针随时可能出结果,**不存在**
|
||||
确定时刻,故 `0.0`——`0 = 可立即重试` 是库既有约定。此前这里返回探针
|
||||
租约剩余,而租约长度是死锁保护参数(派生自 `2 × 最慢源 timeout`),与
|
||||
"源多久能恢复"无因果关系;它还被喂进源冷却备忘,而备忘 `set_until`
|
||||
取更晚者不可回退,于是门恢复 CLOSED 后本进程仍跳过该源整整一个租约。
|
||||
|
||||
三个出口(`try_enter` 拒绝、`_snapshot`、`retry_after_s`)共用本方法,
|
||||
避免同一语义在三处各算一遍而漂移。
|
||||
"""
|
||||
if g.state is GateState.OPEN:
|
||||
return max(0.0, g.open_until - self._now())
|
||||
return 0.0
|
||||
|
||||
def _grant_probe(self, g: _SourceGate, source_name: str, owner: str) -> GateDecision:
|
||||
g.state = GateState.HALF_OPEN
|
||||
g.probe_owner = owner
|
||||
@@ -140,7 +156,7 @@ class InMemoryGate:
|
||||
epoch=g.epoch,
|
||||
is_probe=False,
|
||||
probe_owner=None,
|
||||
retry_after_s=g.open_until - now,
|
||||
retry_after_s=self._remaining(g),
|
||||
)
|
||||
# HALF_OPEN: 探针在途;租约过期则接管,否则拒绝(防惊群)
|
||||
if now >= g.probe_expires:
|
||||
@@ -152,7 +168,7 @@ class InMemoryGate:
|
||||
epoch=g.epoch,
|
||||
is_probe=False,
|
||||
probe_owner=None,
|
||||
retry_after_s=g.probe_expires - now,
|
||||
retry_after_s=self._remaining(g),
|
||||
)
|
||||
|
||||
def _fenced(self, g: _SourceGate, entry: GateDecision) -> bool:
|
||||
@@ -172,9 +188,7 @@ class InMemoryGate:
|
||||
state=g.state,
|
||||
epoch=g.epoch,
|
||||
failure_count=g.fails,
|
||||
retry_after_s=max(0.0, g.open_until - self._now())
|
||||
if g.state is GateState.OPEN
|
||||
else 0.0,
|
||||
retry_after_s=self._remaining(g),
|
||||
)
|
||||
|
||||
def _open(self, g: _SourceGate, reason: str, *, bump_streak: bool) -> None:
|
||||
@@ -258,14 +272,4 @@ class InMemoryGate:
|
||||
"""集合中最早可尝试时间;健康/到期返回 0。"""
|
||||
if not sources:
|
||||
raise ValueError("sources 不能为空")
|
||||
now = self._now()
|
||||
waits = []
|
||||
for name in sources:
|
||||
g = self._gate(name)
|
||||
if g.state is GateState.OPEN:
|
||||
waits.append(max(0.0, g.open_until - now))
|
||||
elif g.state is GateState.HALF_OPEN:
|
||||
waits.append(max(0.0, g.probe_expires - now))
|
||||
else:
|
||||
waits.append(0.0)
|
||||
return min(waits)
|
||||
return min(self._remaining(self._gate(name)) for name in sources)
|
||||
|
||||
@@ -15,7 +15,7 @@ import time
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from polygateway.errors import GovernanceBackendError
|
||||
from polygateway.errors import SourceNotConfiguredError
|
||||
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -89,7 +89,7 @@ class InMemoryLimiter:
|
||||
def _cfg(self, source_key: str) -> SourceConfig:
|
||||
cfg = self._sources.get(source_key)
|
||||
if cfg is None:
|
||||
raise GovernanceBackendError(f"未知源 {source_key!r}(scope={self._scope})")
|
||||
raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")
|
||||
return cfg
|
||||
|
||||
def _window(self) -> int:
|
||||
|
||||
@@ -42,7 +42,8 @@ if state == 'open' and now < open_until then
|
||||
return {0, state, epoch, 0, '', open_until - now}
|
||||
end
|
||||
if state == 'half_open' and now < probe_until then
|
||||
return {0, state, epoch, 0, '', probe_until - now}
|
||||
-- 探针在途: 无确定的可再试时刻 → 0(issue #14,与 memory `_remaining` 同口径)
|
||||
return {0, state, epoch, 0, '', 0}
|
||||
end
|
||||
|
||||
local next_probe_until = now + tonumber(ARGV[2])
|
||||
@@ -50,7 +51,7 @@ redis.call('HSET', KEYS[1],
|
||||
'state', 'half_open',
|
||||
'probe_owner', ARGV[1],
|
||||
'probe_until', next_probe_until)
|
||||
return {1, 'half_open', epoch, 1, ARGV[1], tonumber(ARGV[2])}
|
||||
return {1, 'half_open', epoch, 1, ARGV[1], 0}
|
||||
"""
|
||||
|
||||
# M2.5 窗口/退避公共片段(拼接进 success/failure 脚本;Lua 脚本间无法共享函数)
|
||||
@@ -124,8 +125,6 @@ end
|
||||
local deadline = 0
|
||||
if state == 'open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
|
||||
elseif state == 'half_open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
|
||||
end
|
||||
return {0, state, epoch, failures, math.max(deadline - now, 0)}
|
||||
"""
|
||||
@@ -156,8 +155,6 @@ if not matches then
|
||||
local deadline = 0
|
||||
if state == 'open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
|
||||
elseif state == 'half_open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
|
||||
end
|
||||
return {0, state, epoch, failures, math.max(deadline - now, 0)}
|
||||
end
|
||||
@@ -255,8 +252,6 @@ end
|
||||
local deadline = 0
|
||||
if state == 'open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
|
||||
elseif state == 'half_open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
|
||||
end
|
||||
return {0, state, epoch, failures, math.max(deadline - now, 0)}
|
||||
"""
|
||||
@@ -272,9 +267,6 @@ for _, key in ipairs(KEYS) do
|
||||
if state == 'open' then
|
||||
local deadline = tonumber(redis.call('HGET', key, 'open_until') or '0')
|
||||
remaining = math.max(deadline - now, 0)
|
||||
elseif state == 'half_open' then
|
||||
local deadline = tonumber(redis.call('HGET', key, 'probe_until') or '0')
|
||||
remaining = math.max(deadline - now, 0)
|
||||
end
|
||||
if minimum == nil or remaining < minimum then minimum = remaining end
|
||||
end
|
||||
@@ -367,7 +359,9 @@ class RedisGate:
|
||||
keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms]
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 try_enter 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._decision(source_name, result)
|
||||
|
||||
async def record_success(
|
||||
@@ -385,7 +379,9 @@ class RedisGate:
|
||||
try:
|
||||
result = await self._success_lua(keys=[self._key(entry.source_name)], args=args)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 record_success 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._update(result)
|
||||
|
||||
async def record_failure(
|
||||
@@ -407,7 +403,9 @@ class RedisGate:
|
||||
try:
|
||||
result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 record_failure 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._update(result)
|
||||
|
||||
async def release_probe(self, entry: GateDecision) -> GateUpdate:
|
||||
@@ -419,7 +417,9 @@ class RedisGate:
|
||||
keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner]
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 release_probe 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._update(result)
|
||||
|
||||
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
|
||||
@@ -429,7 +429,9 @@ class RedisGate:
|
||||
try:
|
||||
result = await self._retry_after_lua(keys=[self._key(s) for s in sources])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return int(result) / 1000.0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING
|
||||
from loguru import logger
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from polygateway.errors import GovernanceBackendError
|
||||
from polygateway.errors import GovernanceBackendError, SourceNotConfiguredError
|
||||
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -195,7 +195,7 @@ class RedisLimiter:
|
||||
def _cfg(self, source_key: str) -> SourceConfig:
|
||||
cfg = self._sources.get(source_key)
|
||||
if cfg is None:
|
||||
raise GovernanceBackendError(f"未知源 {source_key!r}(scope={self._scope})")
|
||||
raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")
|
||||
return cfg
|
||||
|
||||
def _lease_keys(self, source_key: str) -> tuple[str, str]:
|
||||
@@ -247,7 +247,9 @@ class RedisLimiter:
|
||||
],
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 try_acquire 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
if ok != 1:
|
||||
return None
|
||||
return _RedisPermit(self, source_key, lease_id, est_tokens, window)
|
||||
@@ -265,14 +267,16 @@ class RedisLimiter:
|
||||
try:
|
||||
await self._release_lua(keys=[gl, sl], args=[lease_id])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 release 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 release 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None:
|
||||
wk = self._window_keys(source_key, window)
|
||||
try:
|
||||
await self._settle_lua(keys=[wk["g_tpm"], wk["s_tpm"]], args=[delta, _WINDOW_TTL_S])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 settle 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(f"限流后端 settle 失败: {exc}", scope=self._scope) from exc
|
||||
|
||||
async def source_stats(self, source_key: str) -> SourceStats:
|
||||
"""当前窗口快照;读侧 clamp ≥0(展示口径,存储保留负值)。"""
|
||||
@@ -283,7 +287,9 @@ class RedisLimiter:
|
||||
wk = self._window_keys(source_key, window)
|
||||
res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 source_stats 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return SourceStats(
|
||||
inflight=int(res[0]),
|
||||
rpm_used=max(0, int(res[1])),
|
||||
@@ -295,14 +301,18 @@ class RedisLimiter:
|
||||
try:
|
||||
await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 mark_progress 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def progress_age_s(self) -> float:
|
||||
"""距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。"""
|
||||
try:
|
||||
res = await self._progress_age_lua(keys=[self._progress_key()])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 progress_age_s 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return float("inf") if int(res) == -1 else int(res) / 1000.0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
@@ -25,14 +25,20 @@ class RedisCache:
|
||||
"Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'"
|
||||
) from _IMPORT_ERROR
|
||||
self._client = client
|
||||
# 注入的客户端归注入方管理: 关掉它会弄死共享同一连接的其他组件
|
||||
# (与 RedisLimiter/RedisGate 同一纪律)
|
||||
self._owns_client = False
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, url: str) -> RedisCache:
|
||||
"""自建并持有 Redis 客户端(aclose 时代关);共享后端请直接注入 client。"""
|
||||
if aioredis is None:
|
||||
raise ImportError(
|
||||
"Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'"
|
||||
) from _IMPORT_ERROR
|
||||
return cls(aioredis.from_url(url, decode_responses=True))
|
||||
cache = cls(aioredis.from_url(url, decode_responses=True))
|
||||
cache._owns_client = True
|
||||
return cache
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
return await self._client.get(key)
|
||||
@@ -41,4 +47,7 @@ class RedisCache:
|
||||
await self._client.set(key, value, ex=ttl_s)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
"""幂等释放自建客户端;注入的客户端归注入方管理。"""
|
||||
if self._owns_client:
|
||||
self._owns_client = False
|
||||
await self._client.aclose()
|
||||
|
||||
+259
-35
@@ -9,9 +9,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from polygateway.backends.memory.breaker import InMemoryGate
|
||||
from polygateway.backends.memory.cache import InMemoryCache
|
||||
@@ -22,6 +24,7 @@ from polygateway.middleware.cache import CacheMW
|
||||
from polygateway.middleware.retry import RetryMW
|
||||
from polygateway.middleware.structured import StructuredMW
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
||||
from polygateway.ports import TelemetryStatusProvider
|
||||
from polygateway.pricing import PricingTable
|
||||
from polygateway.providers import get_provider
|
||||
from polygateway.sources import (
|
||||
@@ -31,8 +34,17 @@ from polygateway.sources import (
|
||||
RoundRobinSelector,
|
||||
SourceCooldownMemo,
|
||||
)
|
||||
from polygateway.thinking import effective_effort, get_capability, resolve_thinking
|
||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||
from polygateway.types import ChatRequest, LLMResponse
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
Effort,
|
||||
LLMResponse,
|
||||
TelemetryStatus,
|
||||
coerce_effort,
|
||||
validate_caller_dimensions,
|
||||
validate_request_overlay,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Iterable, Mapping
|
||||
@@ -50,13 +62,139 @@ if TYPE_CHECKING:
|
||||
Transport,
|
||||
)
|
||||
from polygateway.providers import ProviderProfile
|
||||
from polygateway.thinking import ThinkingCapability
|
||||
from polygateway.types import (
|
||||
BackpressurePolicy,
|
||||
RetryPolicy,
|
||||
SourceConfig,
|
||||
)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
def _guard_thinking(
|
||||
sources: list[SourceConfig],
|
||||
profiles: list[ProviderProfile],
|
||||
capabilities: Mapping[str, ThinkingCapability] | None,
|
||||
) -> None:
|
||||
"""装配期把不可满足的推理开关炸掉,而不是留到运行时(issue #5)。
|
||||
|
||||
与 transport 内的同一次判定不是重复: 那里兜的是"构造函数全量注入"这条路
|
||||
(CLAUDE.md §4.5 的第二条装配路),而工厂路占 90% 场景,配置错误应当在装配期
|
||||
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
|
||||
"""
|
||||
for source, profile in zip(sources, profiles, strict=True):
|
||||
resolve_thinking(
|
||||
profile,
|
||||
get_capability(source.model, table=capabilities),
|
||||
# 装配期看不见请求级档位(它逐次调用才产生),故只解源级两层;请求级
|
||||
# 只能在运行期由 transport 校验(设计 §10 的装配期/运行期分工)
|
||||
effective_effort(
|
||||
request_effort=None,
|
||||
source_effort=source.reasoning_effort,
|
||||
enable_thinking=source.enable_thinking,
|
||||
),
|
||||
model=source.model,
|
||||
# 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被
|
||||
# 判死,而它在运行期本来是能映射到最近档跑起来的
|
||||
fallback=source.effort_fallback,
|
||||
)
|
||||
|
||||
|
||||
def _fingerprint_mark(source: SourceConfig) -> str:
|
||||
"""单源的指纹标记;`enable_thinking` 与 `reasoning_effort` 仅在**表态时**追加。
|
||||
|
||||
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
|
||||
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。
|
||||
|
||||
`reasoning_effort`(issue #20)与 `enable_thinking` 同规则、同理由: 它一旦真正
|
||||
改变请求体,"把源级档位从 low 改成 max 后重启"就会读到 low 档时缓存的旧响应。
|
||||
两者取值域不相交(`"none"`/`"low"`… vs `true`/`false`),故追加进同一个列表也
|
||||
不会把两种写法摘要成同一身份。
|
||||
"""
|
||||
parts: list[Any] = [source.model, dict(source.extra_body)]
|
||||
if source.enable_thinking is not None:
|
||||
parts.append(source.enable_thinking)
|
||||
if source.reasoning_effort is not None:
|
||||
parts.append(source.reasoning_effort)
|
||||
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
|
||||
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
|
||||
|
||||
配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1
|
||||
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking`(issue #5)与
|
||||
源级 `reasoning_effort`(issue #20)同理: 它们一旦真正改变请求体,"关掉推理后
|
||||
重启"就会读到开着推理时缓存的旧响应。全源三者皆未表态时字面量与历史实现逐字
|
||||
相同,不触发存量缓存冷启动。
|
||||
|
||||
注意本指纹是**装配期**算出的**集合级**身份,覆盖不到逐次调用变化的请求级档位
|
||||
——后者由 `build_cache_key` 的 `reasoning_effort` 参数单独承担(ARCH §7.5)。
|
||||
"""
|
||||
fingerprint = ",".join(sorted({s.model for s in sources}))
|
||||
# 按 (model, extra_body[, enable_thinking][, reasoning_effort]) 而非源名摘要:
|
||||
# 语义是"本 scope 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动。
|
||||
# 过滤条件必须与 `_fingerprint_mark` 追加的字段逐项对齐: 漏掉一项,只配了该项
|
||||
# 的源根本进不了 marks,`_fingerprint_mark` 改了也白改
|
||||
marks = sorted(
|
||||
{
|
||||
_fingerprint_mark(s)
|
||||
for s in sources
|
||||
if s.extra_body or s.enable_thinking is not None or s.reasoning_effort is not None
|
||||
}
|
||||
)
|
||||
if marks:
|
||||
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
|
||||
fingerprint = f"{fingerprint}|{digest}"
|
||||
return fingerprint
|
||||
|
||||
|
||||
async def _aclose_component(component: object | None) -> None:
|
||||
"""关闭一个**自建**组件: 优先 `aclose`,退到同步 `close`,两者皆无则跳过。
|
||||
|
||||
退到 `close` 是给 SQLiteRecorder 的(它只有同步收尾);内存后端两者皆无,
|
||||
探测后静默跳过。三个 client 曾各持一份逐字复制的探测代码,收敛为一处是
|
||||
所有权纪律能被维持的前提——复制即是下一个 bug 的种子(设计 §3.4)。
|
||||
"""
|
||||
if component is None:
|
||||
return
|
||||
aclose = getattr(component, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
return
|
||||
close = getattr(component, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
|
||||
|
||||
def _telemetry_status_of(telemetry: TelemetryRecorder | None) -> TelemetryStatus | None:
|
||||
"""三个 client 共用的状态取值点: 不提供状态的 recorder 一律返回 None。
|
||||
|
||||
判定写成 `isinstance(可选端口)` 而不是裸 `getattr`: 两者运行时都是结构检查
|
||||
(`@runtime_checkable` 按属性存在性判定),差别在**契约有没有名字**——端口是
|
||||
写进 `ports.py` 的公开承诺,下游可以照着实现;散落的 `getattr` 不是,而
|
||||
`aclose` 当年正是被复制成三份鸭子类型探测才漂移出越权关闭(设计 §3.3/§3.4)。
|
||||
"""
|
||||
if isinstance(telemetry, TelemetryStatusProvider):
|
||||
return telemetry.telemetry_status
|
||||
return None
|
||||
|
||||
|
||||
def _mark_owned_components(
|
||||
client: Any,
|
||||
*,
|
||||
limiter: RateLimiter | None,
|
||||
breaker: ProviderGate | None,
|
||||
telemetry: TelemetryRecorder | None,
|
||||
) -> None:
|
||||
"""工厂置位所有权(三个 client 共用): 传进来的是 None,就说明这一件是工厂自建的。
|
||||
|
||||
与 `RedisLimiter.from_url` 逐字同款——私有属性由工厂标记,公共 API 面不变。
|
||||
transport 单列: 三处工厂都没有 transport 注入入口,它恒是自建的。
|
||||
"""
|
||||
client._owns_transport = True
|
||||
client._owns_limiter = limiter is None
|
||||
client._owns_breaker = breaker is None
|
||||
client._owns_telemetry = telemetry is None
|
||||
|
||||
|
||||
class GatewayClient:
|
||||
@@ -74,8 +212,10 @@ class GatewayClient:
|
||||
retry: RetryPolicy,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
circuit_open: str = "fail_fast",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
pricing: PricingTable | None = None,
|
||||
text_cap: int | None = None,
|
||||
cache: CacheBackend | None = None,
|
||||
cache_namespace: str | None = None,
|
||||
cache_ttl_s: int | None = None,
|
||||
@@ -86,7 +226,11 @@ class GatewayClient:
|
||||
sleep: Any = asyncio.sleep,
|
||||
rng: Any = random.random,
|
||||
) -> None:
|
||||
emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry is not None else None
|
||||
emitter = (
|
||||
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap)
|
||||
if telemetry is not None
|
||||
else None
|
||||
)
|
||||
terminal = RetryMW(
|
||||
scope=scope,
|
||||
sources=sources,
|
||||
@@ -97,6 +241,7 @@ class GatewayClient:
|
||||
retry=retry,
|
||||
backpressure=backpressure,
|
||||
quota_full=quota_full,
|
||||
circuit_open=circuit_open,
|
||||
cooldown_memo=SourceCooldownMemo(now=now),
|
||||
# AIMD ceiling 尊重源级静态并发上限(独立核验 I1: 不得静默钳制大于 64 的配置)
|
||||
pacer=AdaptivePacer(
|
||||
@@ -113,12 +258,11 @@ class GatewayClient:
|
||||
if cache is not None:
|
||||
if cache_namespace is None or cache_ttl_s is None:
|
||||
raise ValueError("启用缓存必须提供 cache_namespace 与 cache_ttl_s")
|
||||
# 多源 scope 的 key 身份 = 排序去重的 model 合集;源集合变化 → 一次性冷启动
|
||||
fingerprint = ",".join(sorted({s.model for s in sources}))
|
||||
# 多源 scope 的 key 身份;源集合或其 extra_body 变化 → 一次性冷启动
|
||||
middlewares.append(
|
||||
CacheMW(
|
||||
backend=cache,
|
||||
model_fingerprint=fingerprint,
|
||||
model_fingerprint=build_model_fingerprint(sources),
|
||||
default_namespace=cache_namespace,
|
||||
ttl_s=cache_ttl_s,
|
||||
strategy=structured_strategy,
|
||||
@@ -138,8 +282,29 @@ class GatewayClient:
|
||||
self._transport = transport
|
||||
self._telemetry = telemetry
|
||||
self._cache = cache
|
||||
# limiter/breaker 交给 RetryMW 之后仍须自持引用,否则 aclose 触达不到
|
||||
# 自建的 redis 客户端(设计 §3.4 记录的现存泄漏)
|
||||
self._limiter_backend = limiter
|
||||
self._breaker_backend = breaker
|
||||
# 所有权默认"不拥有": `__init__` 是全量注入路径,经它传入的一切都是
|
||||
# 外部资源,关掉别人的连接会弄死共享同一后端的其他 client(ARCH §7.7 R5)。
|
||||
# 只有工厂在真正自建时才置 True
|
||||
self._owns_transport = False
|
||||
self._owns_telemetry = False
|
||||
self._owns_cache = False
|
||||
self._owns_limiter = False
|
||||
self._owns_breaker = False
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def telemetry_status(self) -> TelemetryStatus | None:
|
||||
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
|
||||
|
||||
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
|
||||
的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。
|
||||
"""
|
||||
return _telemetry_status_of(self._telemetry)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -150,12 +315,50 @@ class GatewayClient:
|
||||
cache_namespace: str | None = None,
|
||||
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||
stream: bool = True,
|
||||
overlay: Mapping[str, Any] | None = None,
|
||||
reasoning_effort: Effort | str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> LLMResponse:
|
||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。"""
|
||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
||||
|
||||
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
||||
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||
参数不影响既有调用点(issue #4)。
|
||||
|
||||
`reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT`
|
||||
与 `ENABLE_THINKING`(设计 §4.2)。`None` 是**不表态**(随源级配置),与
|
||||
`Effort.NONE`("要求不推理")严格区分。裸字符串(`"low"`)也收,在此归一成
|
||||
`Effort`,非法值当场 `ValueError`——与 `SourceConfig` 那条装配路同口径。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
||||
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
||||
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
||||
"""
|
||||
if structured is not None and not self._structured_available:
|
||||
raise ImportError(
|
||||
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
|
||||
)
|
||||
# 进洋葱之前校验并拷贝: 保护键/不可序列化值在此收口(否则会在 CacheMW
|
||||
# 的降级 try 之外抛裸 TypeError);拷贝防调用方复用同一 dict 逐次改 seed
|
||||
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
|
||||
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
|
||||
sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
|
||||
# 同理必须在洋葱之外: 洋葱内的一切失败都被遥测层降级成 warning(库铁律
|
||||
# 「遥测写失败降级不冒泡」),校验放里面等于没有校验——非法维度会变成
|
||||
# 静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
|
||||
)
|
||||
# 同样必须在洋葱之外归一: 档位一路要被 `is Effort.NONE` 身份比较,裸字符串
|
||||
# 进去会在 transport 的错误路径上抛 `AttributeError`——那不属错误四分类,
|
||||
# 会穿透 `except ThinkingUnsupportedError` 与 RetryMW 的分类捕获(库铁律
|
||||
# 「错误分类驱动」)。归一失败是调用方编程错误,抛裸 ValueError 不进洋葱
|
||||
effort = (
|
||||
None
|
||||
if reasoning_effort is None
|
||||
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
|
||||
)
|
||||
request = ChatRequest(
|
||||
messages=messages,
|
||||
session_id=session_id,
|
||||
@@ -164,27 +367,32 @@ class GatewayClient:
|
||||
cache_namespace=cache_namespace,
|
||||
structured=structured,
|
||||
stream=stream,
|
||||
overlay=sampling,
|
||||
sampling=sampling,
|
||||
reasoning_effort=effort,
|
||||
tenant_id=dimension_tenant_id,
|
||||
meta=dimensions,
|
||||
)
|
||||
return await self._handler(request)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""幂等释放: transport 连接池、遥测连接、缓存客户端。"""
|
||||
"""幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。
|
||||
|
||||
注入的组件一律不碰——它们可能被别的 client 共享,关掉即越权。
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
transport_aclose = getattr(self._transport, "aclose", None)
|
||||
if transport_aclose is not None:
|
||||
await transport_aclose()
|
||||
telemetry_aclose = getattr(self._telemetry, "aclose", None)
|
||||
if telemetry_aclose is not None:
|
||||
await telemetry_aclose() # Postgres 等异步后端
|
||||
else:
|
||||
telemetry_close = getattr(self._telemetry, "close", None)
|
||||
if telemetry_close is not None:
|
||||
telemetry_close()
|
||||
cache_aclose = getattr(self._cache, "aclose", None)
|
||||
if cache_aclose is not None:
|
||||
await cache_aclose()
|
||||
if self._owns_transport:
|
||||
await _aclose_component(self._transport)
|
||||
if self._owns_telemetry:
|
||||
await _aclose_component(self._telemetry)
|
||||
if self._owns_cache:
|
||||
await _aclose_component(self._cache)
|
||||
if self._owns_limiter:
|
||||
await _aclose_component(self._limiter_backend)
|
||||
if self._owns_breaker:
|
||||
await _aclose_component(self._breaker_backend)
|
||||
|
||||
async def __aenter__(self) -> GatewayClient:
|
||||
return self
|
||||
@@ -204,26 +412,30 @@ class GatewayClient:
|
||||
cache: CacheBackend | None = None,
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
registry: Mapping[str, ProviderProfile] | None = None,
|
||||
capabilities: Mapping[str, ThinkingCapability] | None = None,
|
||||
rng: Any = random.random,
|
||||
) -> GatewayClient:
|
||||
"""按配置装配;显式传入的后端实例即共享(None 项按配置自建私有实例)。"""
|
||||
sources = list(settings.sources)
|
||||
profiles = [get_provider(s.provider, registry=registry) for s in sources]
|
||||
_guard_thinking(sources, profiles, capabilities)
|
||||
strategy, escalation = _build_structured(profiles)
|
||||
return cls(
|
||||
client = cls(
|
||||
scope=settings.scope,
|
||||
sources=sources,
|
||||
selector=_build_selector(settings.selector, rng=rng),
|
||||
limiter=limiter or _build_limiter(settings, sources),
|
||||
breaker=breaker or _build_breaker(settings),
|
||||
transport=OpenAICompatTransport(registry=registry),
|
||||
limiter=limiter if limiter is not None else _build_limiter(settings, sources),
|
||||
breaker=breaker if breaker is not None else _build_breaker(settings),
|
||||
transport=OpenAICompatTransport(registry=registry, capabilities=capabilities),
|
||||
retry=settings.retry,
|
||||
backpressure=settings.backpressure,
|
||||
quota_full=settings.quota_full,
|
||||
circuit_open=settings.circuit_open,
|
||||
telemetry=telemetry if telemetry is not None else _build_telemetry(settings),
|
||||
pricing=PricingTable.from_file(settings.pricing_path)
|
||||
if settings.pricing_path is not None
|
||||
else None,
|
||||
text_cap=settings.telemetry_text_cap,
|
||||
cache=cache if cache is not None else _build_cache(settings),
|
||||
cache_namespace=settings.cache_namespace,
|
||||
cache_ttl_s=settings.cache_ttl_s,
|
||||
@@ -231,6 +443,9 @@ class GatewayClient:
|
||||
structured_escalation=escalation,
|
||||
structured_max_retries=settings.structured_max_retries,
|
||||
)
|
||||
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
@@ -242,6 +457,7 @@ class GatewayClient:
|
||||
cache: CacheBackend | None = None,
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
registry: Mapping[str, ProviderProfile] | None = None,
|
||||
capabilities: Mapping[str, ThinkingCapability] | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> GatewayClient:
|
||||
"""从 .env/环境变量装配一个 scope 的 client(键名清单见 .env.example)。"""
|
||||
@@ -252,6 +468,7 @@ class GatewayClient:
|
||||
cache=cache,
|
||||
telemetry=telemetry,
|
||||
registry=registry,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
|
||||
@@ -259,7 +476,7 @@ def _build_limiter(settings: GatewaySettings, sources: list[SourceConfig]) -> Ra
|
||||
if settings.limiter_backend == "redis":
|
||||
from polygateway.backends.redis.limiter import RedisLimiter
|
||||
|
||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||
return RedisLimiter.from_url(
|
||||
settings.redis_url,
|
||||
scope=settings.scope,
|
||||
@@ -279,7 +496,7 @@ def _build_breaker(settings: GatewaySettings) -> ProviderGate:
|
||||
if settings.breaker_backend == "redis":
|
||||
from polygateway.backends.redis.breaker import RedisGate
|
||||
|
||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||
return RedisGate.from_url(settings.redis_url, config=settings.breaker, scope=settings.scope)
|
||||
return InMemoryGate(config=settings.breaker)
|
||||
|
||||
@@ -299,7 +516,7 @@ def _build_cache(settings: GatewaySettings) -> CacheBackend | None:
|
||||
return InMemoryCache()
|
||||
from polygateway.backends.redis_cache import RedisCache
|
||||
|
||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||
return RedisCache.from_url(settings.redis_url)
|
||||
|
||||
|
||||
@@ -309,12 +526,19 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
|
||||
if settings.telemetry_backend == "postgres":
|
||||
from polygateway.telemetry.postgres import PostgresRecorder
|
||||
|
||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: config 已校验
|
||||
return PostgresRecorder(settings.telemetry_pg_dsn)
|
||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
||||
return PostgresRecorder(
|
||||
settings.telemetry_pg_dsn,
|
||||
auto_migrate=settings.telemetry_auto_migrate,
|
||||
pool_max=settings.telemetry_pg_pool_max,
|
||||
write_timeout_s=settings.telemetry_pg_write_timeout_s,
|
||||
)
|
||||
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||
|
||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: config 已校验
|
||||
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
|
||||
return SQLiteRecorder(
|
||||
settings.telemetry_sqlite_path, auto_migrate=settings.telemetry_auto_migrate
|
||||
)
|
||||
|
||||
|
||||
def _build_structured(
|
||||
@@ -339,7 +563,7 @@ def _build_structured(
|
||||
return None, None
|
||||
|
||||
|
||||
async def gather_bounded(aws: Iterable[Awaitable[_T]], *, concurrency: int) -> list[_T]:
|
||||
async def gather_bounded[T](aws: Iterable[Awaitable[T]], *, concurrency: int) -> list[T]:
|
||||
"""有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。
|
||||
|
||||
语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。
|
||||
@@ -348,7 +572,7 @@ async def gather_bounded(aws: Iterable[Awaitable[_T]], *, concurrency: int) -> l
|
||||
raise ValueError("concurrency 必须 ≥ 1")
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _run(aw: Awaitable[_T]) -> _T:
|
||||
async def _run(aw: Awaitable[T]) -> T:
|
||||
async with sem:
|
||||
return await aw
|
||||
|
||||
|
||||
+358
-44
@@ -11,11 +11,13 @@ fail-loud 校验语义与 pydantic-settings 一致。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dotenv import dotenv_values
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.types import (
|
||||
BackpressurePolicy,
|
||||
@@ -23,6 +25,7 @@ from polygateway.types import (
|
||||
GlobalLimits,
|
||||
RetryPolicy,
|
||||
SourceConfig,
|
||||
coerce_effort,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -41,16 +44,46 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
||||
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
|
||||
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
|
||||
"ENABLE_THINKING": ("enable_thinking", "bool"),
|
||||
# 档位两键(issue #20);值域校验分工: 档位在此(解析即校验,报错点得出 env 键名),
|
||||
# fallback 交给 SourceConfig 构造期(那道同时覆盖构造函数注入与 dataclasses.replace)
|
||||
"REASONING_EFFORT": ("reasoning_effort", "effort"),
|
||||
# 归一化(strip+lower)在 SourceConfig 构造期,与值域校验同处一点,故这里是裸 "str"
|
||||
"EFFORT_FALLBACK": ("effort_fallback", "str"),
|
||||
"MISSING_DONE": ("missing_done", "str"),
|
||||
"TRUST_ENV": ("trust_env", "bool"),
|
||||
"EXTRA_BODY": ("extra_body", "json"),
|
||||
}
|
||||
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
|
||||
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
|
||||
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
|
||||
# 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是
|
||||
# "排队等自己的份额"(必然轮到),熔断开路是"等源恢复"(未必恢复),故分列两键
|
||||
_CIRCUIT_OPEN = frozenset({"wait", "fail_fast"})
|
||||
# 后端合法域: env 解析与构造期校验共用一份定义,避免两处分叉
|
||||
_LIMITER_BACKENDS = frozenset({"memory", "redis"})
|
||||
_BREAKER_BACKENDS = frozenset({"memory", "redis"})
|
||||
_CACHE_BACKENDS = frozenset({"redis", "memory", "none"})
|
||||
_TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"})
|
||||
# 遥测 schema 档位(issue #13): auto 允许 recorder 给旧表 ALTER 补列,manual 不发 DDL
|
||||
_SCHEMA_MODES = frozenset({"auto", "manual"})
|
||||
_SCHEMA_MODE_KEY = "PGW_TELEMETRY_SCHEMA_MODE"
|
||||
# 遥测正文字符上限(issue #12);二态键,未设 = 不截断
|
||||
_TEXT_CAP_KEY = "PGW_TELEMETRY_TEXT_CAP"
|
||||
# 遥测池的资源占用与写入预算(issue #15);缺省只写在这里,recorder 侧是必填参数
|
||||
_POOL_MAX_KEY = "PGW_TELEMETRY_PG_POOL_MAX"
|
||||
_WRITE_TIMEOUT_KEY = "PGW_TELEMETRY_PG_WRITE_TIMEOUT_S"
|
||||
# 4 条实测约 15.6 行/秒(跨内网 RTT ≈ 123ms 的实验室 PG,50 行并发批耗时 3.2s)。
|
||||
# **不要按 `pool_max / RTT` 折算**——那会乐观一倍(一次 INSERT 的往返比一次
|
||||
# SELECT 1 重)。够单 client 十余并发;闲时占 0 条
|
||||
_DEFAULT_PG_POOL_MAX = 4
|
||||
# 实测稳态写入 123ms、首次含建连 513ms;5s 宽松且**有界**
|
||||
_DEFAULT_PG_WRITE_TIMEOUT_S = 5.0
|
||||
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
|
||||
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
|
||||
_DEFAULT_STALL_WINDOW_S = 300.0
|
||||
_DEFAULT_POLL_INTERVAL_S = 0.05
|
||||
_DEFAULT_LEASE_TTL_S = 1500.0 # CHS _DEFAULT_LEASE_TTL_MS 同源
|
||||
_PROBE_GRACE_S = 5.0 # 半开探针租约相对最慢调用的清理宽限(CHS container.py:274-275)
|
||||
|
||||
|
||||
def _cast(raw: str, kind: str, key: str) -> object:
|
||||
@@ -66,6 +99,16 @@ def _cast(raw: str, kind: str, key: str) -> object:
|
||||
if lowered in ("0", "false", "no", "off"):
|
||||
return False
|
||||
raise ValueError(f"非法布尔值: {raw!r}")
|
||||
if kind == "effort":
|
||||
# 归一化只有一份实现(`types.coerce_effort`),env 路与两条装配路同口径;
|
||||
# origin 传空串是因为 env 键名由下面统一的"配置 X 解析失败"补上
|
||||
return coerce_effort(raw, origin="")
|
||||
if kind == "json":
|
||||
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(f"必须是 JSON 对象(而非数组/标量): {raw!r}")
|
||||
return parsed
|
||||
return raw
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"配置 {key} 解析失败: {exc}") from exc
|
||||
@@ -88,7 +131,16 @@ def _require(env: Mapping[str, str], *keys: str) -> tuple[str, str]:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewaySettings:
|
||||
"""一个 scope 的完整装配配置;构造经 from_env 聚合并通过全部守卫。"""
|
||||
"""一个 scope 的完整装配配置;**任何**构造路径都通过全部装配守卫(ARCH §7.3)。
|
||||
|
||||
守卫校验的是**跨字段**不变量: 单看一个字段都合法,组合起来才会在运行时
|
||||
咬人(租约先于请求过期、正常慢首包被误判卡死、半开探针在途被接管)。
|
||||
types.py 各子配置的 `__post_init__` 只看得见自己的字段,故由本类把关。
|
||||
|
||||
放在 `__post_init__` 而非某个工厂里: 这些约束是本类定义的一部分,不是
|
||||
某个入口的输入检查。挂在构造期,直接构造、`dataclasses.replace` 与全部
|
||||
装配工厂一并覆盖;挂在工厂里则每加一个工厂就多一处要同步。
|
||||
"""
|
||||
|
||||
scope: str
|
||||
sources: tuple[SourceConfig, ...]
|
||||
@@ -98,6 +150,9 @@ class GatewaySettings:
|
||||
backpressure: BackpressurePolicy
|
||||
selector: str
|
||||
quota_full: str
|
||||
# 熔断全拒时是当场判死还是等冷却过去(issue #14);缺省 fail_fast 保持
|
||||
# 存量下游的控制流不变,单源 scope 应显式配 wait
|
||||
circuit_open: str
|
||||
limiter_backend: str
|
||||
breaker_backend: str
|
||||
cache_backend: str
|
||||
@@ -106,11 +161,190 @@ class GatewaySettings:
|
||||
telemetry_backend: str
|
||||
telemetry_sqlite_path: str | None
|
||||
telemetry_pg_dsn: str | None
|
||||
# 是否允许 recorder 给已存在的旧表自动 ALTER 补列(issue #13);env 的三态
|
||||
# 派生只写在 `_load_schema_mode` 一处,不与 recorder 的类签名漂移。
|
||||
# backend=none 时恒 False 这条跨字段不变量则由 `_validate_telemetry`
|
||||
# 把关,对直接构造与 `dataclasses.replace` 同样生效
|
||||
telemetry_auto_migrate: bool
|
||||
# 遥测落库正文的字符上限(issue #12);None = 不截断,与本字段出现之前逐字节相同。
|
||||
# 缺省不截断是人类决策: 截断后的遥测不再是审计证据、也无法用于复现与重放,而
|
||||
# 既有下游正依赖这一行为。值域(> 0)由 `_validate_telemetry` 把关,直接构造、
|
||||
# `dataclasses.replace` 与 env 三条路一并覆盖
|
||||
telemetry_text_cap: int | None
|
||||
# 遥测池对外声明的资源占用上限与整次写入的硬预算(issue #15)。库内每一处外部
|
||||
# 资源都按需建连,唯独遥测池此前预占 10 条(asyncpg 默认 `min_size`),共享实例
|
||||
# 余量紧张时先倒下的必然是它。这两个字段是库对自己占用的**显式表态**:
|
||||
# 稳态并发上限 = `pool_max`,闲时 0 条;单次写入(准备+取连接+执行)≤ 预算。
|
||||
# 值域由 `_validate_telemetry` 把关,直接构造、`dataclasses.replace` 与 env 三条路一致
|
||||
telemetry_pg_pool_max: int
|
||||
telemetry_pg_write_timeout_s: float
|
||||
redis_url: str | None
|
||||
pricing_path: str | None
|
||||
structured_max_retries: int
|
||||
lease_ttl_s: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._normalize()
|
||||
self._validate_identity()
|
||||
self._validate_backends()
|
||||
self._validate_cache()
|
||||
self._validate_telemetry()
|
||||
self._validate_lease()
|
||||
self._validate_stall()
|
||||
self._validate_probe()
|
||||
|
||||
def _normalize(self) -> None:
|
||||
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
|
||||
|
||||
`scope` 的 strip 才是要紧的那一半: 它进 Redis key(`pgw:limit:{scope}:…`
|
||||
/`pgw:gate:{scope}:…`),而两个 Redis 后端在构造函数里只 `.lower()` **不 strip**
|
||||
——`"llm "` 会产出 `pgw:limit:llm :…`,与 `from_env` 路的进程分裂成两套命名空间。
|
||||
大小写则不会: 后端自 v1.0.0 起各自 lower,`from_settings` 传 "LLM" 也落在同一
|
||||
套 key 上(此处 lower 只为让 `GatewaySettings.scope` 属性两路取值一致)。
|
||||
|
||||
空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端
|
||||
抛连接串解析异常。`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警,
|
||||
规范化留在 `_validate_telemetry`。
|
||||
"""
|
||||
normalized_scope = self.scope.strip().lower()
|
||||
if normalized_scope != self.scope:
|
||||
object.__setattr__(self, "scope", normalized_scope)
|
||||
for field in ("redis_url", "pricing_path"):
|
||||
if getattr(self, field) == "":
|
||||
object.__setattr__(self, field, None)
|
||||
|
||||
def _validate_identity(self) -> None:
|
||||
"""本类自身字段的基本域: 空 scope 会污染遥测与缓存命名空间;零源必然选源失败。"""
|
||||
if not self.scope.strip():
|
||||
raise ValueError("GatewaySettings.scope 不能为空")
|
||||
if not self.sources:
|
||||
raise ValueError("GatewaySettings.sources 不能为空: 至少一个源")
|
||||
if self.structured_max_retries < 0:
|
||||
raise ValueError(f"structured_max_retries 不能为负: {self.structured_max_retries}")
|
||||
|
||||
def _validate_backends(self) -> None:
|
||||
"""后端选择必须落在合法域内,取 redis 的还必须有连接串。
|
||||
|
||||
域外取值此前只有 `from_env` 拦得住,直接构造会一路走到 `client.py` 的
|
||||
`_build_*`,落进 else 分支静默不建后端,或撞上那里的断言。
|
||||
"""
|
||||
for field, allowed in (
|
||||
("limiter_backend", _LIMITER_BACKENDS),
|
||||
("breaker_backend", _BREAKER_BACKENDS),
|
||||
("cache_backend", _CACHE_BACKENDS),
|
||||
("telemetry_backend", _TELEMETRY_BACKENDS),
|
||||
("selector", _SELECTORS),
|
||||
("quota_full", _QUOTA_FULL),
|
||||
("circuit_open", _CIRCUIT_OPEN),
|
||||
):
|
||||
value = getattr(self, field)
|
||||
if value not in allowed:
|
||||
raise ValueError(f"{field} 非法值 {value!r};允许: {sorted(allowed)}")
|
||||
on_redis = [f for f in _REDIS_DEPENDENT_BACKENDS if getattr(self, f) == "redis"]
|
||||
if on_redis and self.redis_url is None:
|
||||
raise ValueError(f"{'、'.join(on_redis)} 取 redis 时必须提供 redis_url")
|
||||
|
||||
def _validate_cache(self) -> None:
|
||||
"""启用缓存必须有命名空间与正 TTL(缺命名空间即失去租户隔离,会毒化缓存)。"""
|
||||
if self.cache_backend == "none":
|
||||
return
|
||||
if not self.cache_namespace:
|
||||
raise ValueError("启用缓存时 cache_namespace 不能为空: 缓存 key 靠它做租户隔离")
|
||||
if self.cache_ttl_s is None or self.cache_ttl_s <= 0:
|
||||
raise ValueError(f"cache_ttl_s 必须 > 0(禁止永不过期): {self.cache_ttl_s}")
|
||||
|
||||
def _validate_telemetry(self) -> None:
|
||||
"""遥测后端各自的落点必填;顺带剥掉 asyncpg 不认的 SQLAlchemy 驱动后缀。
|
||||
|
||||
剥而不是拒: 两条装配路对同一 DSN 应产出同一结果。但不静默——`from_env`
|
||||
那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方,
|
||||
他有权知道库动了他给的值。
|
||||
|
||||
`telemetry_auto_migrate` 同理归一化而非报错: backend=none 时根本没有
|
||||
recorder 消费它,True 是个自相矛盾却无害的状态。`from_env` 那条路的派生
|
||||
已经给出 False,归一化是为了直接构造与 `dataclasses.replace` 也一致——
|
||||
不变量挂在构造期,才不用每加一个装配工厂就多一处要同步。
|
||||
|
||||
`telemetry_text_cap` 的值域则是**报错**而非归一化: 0 与负数都不是"不截断"
|
||||
的写法(不截断写 None),把它们悄悄改成 None 等于用默认值掩盖调用方的错误。
|
||||
报错文本同时点出字段名与 env 键名,两条装配路的调用方各看得懂自己那套。
|
||||
"""
|
||||
if self.telemetry_text_cap is not None and self.telemetry_text_cap <= 0:
|
||||
raise ValueError(
|
||||
f"telemetry_text_cap({_TEXT_CAP_KEY})必须 > 0: {self.telemetry_text_cap};"
|
||||
"不截断请不设该键(None),0 只会让每条正文退化成一个省略标记"
|
||||
)
|
||||
self._validate_telemetry_pool()
|
||||
if self.telemetry_backend == "none" and self.telemetry_auto_migrate:
|
||||
object.__setattr__(self, "telemetry_auto_migrate", False)
|
||||
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
|
||||
raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path")
|
||||
if self.telemetry_backend != "postgres":
|
||||
return
|
||||
if not self.telemetry_pg_dsn:
|
||||
raise ValueError("telemetry_backend=postgres 时必须提供 telemetry_pg_dsn")
|
||||
stripped = _strip_dsn_driver(self.telemetry_pg_dsn)
|
||||
if stripped != self.telemetry_pg_dsn:
|
||||
# 只报 scheme 段: DSN 带密码,整串不得进日志(P5 敏感信息只走 .env)
|
||||
logger.warning(
|
||||
"telemetry_pg_dsn 的 scheme 含 asyncpg 不认的驱动后缀,已由 {} 剥为 {}",
|
||||
self.telemetry_pg_dsn.partition("://")[0],
|
||||
stripped.partition("://")[0],
|
||||
)
|
||||
object.__setattr__(self, "telemetry_pg_dsn", stripped)
|
||||
|
||||
def _validate_telemetry_pool(self) -> None:
|
||||
"""遥测池两个标量的值域(issue #15);与 backend 无关,三条装配路一并覆盖。
|
||||
|
||||
不按 `telemetry_backend == "postgres"` 才校验: 值域错就是错,提前拦住
|
||||
比等到有人把 backend 切成 postgres 时才炸更接近"缺失关键配置直接报错"。
|
||||
报错文本同时点字段名与 env 键名(两类调用方各看得懂自己那套)。
|
||||
"""
|
||||
if self.telemetry_pg_pool_max < 1:
|
||||
raise ValueError(
|
||||
f"telemetry_pg_pool_max({_POOL_MAX_KEY})必须 >= 1: "
|
||||
f"{self.telemetry_pg_pool_max};0 条上限等于永远取不到连接,遥测会全灭"
|
||||
)
|
||||
if self.telemetry_pg_write_timeout_s <= 0:
|
||||
raise ValueError(
|
||||
f"telemetry_pg_write_timeout_s({_WRITE_TIMEOUT_KEY})必须 > 0: "
|
||||
f"{self.telemetry_pg_write_timeout_s};预算 0 会让每一行当场超预算被丢弃"
|
||||
)
|
||||
|
||||
def _validate_lease(self) -> None:
|
||||
"""调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。"""
|
||||
slowest = max(s.timeout_s for s in self.sources)
|
||||
if slowest > self.lease_ttl_s:
|
||||
raise ValueError(
|
||||
f"源最大 timeout_s({slowest})超过 permit 租约 lease_ttl_s"
|
||||
f"({self.lease_ttl_s});调大 lease_ttl_s 或调小源的 timeout_s"
|
||||
)
|
||||
|
||||
def _validate_stall(self) -> None:
|
||||
"""stall 窗口须 ≥ 最慢源 TTFT 上限(保守冗余,见下)。
|
||||
|
||||
原理由是"防把正常慢首包误判为卡死"。issue #8 起 stall 只累计**非
|
||||
生产性等待**(429 退避、配额轮询、熔断冷却),TTFT 等待属生产性时间、
|
||||
已不计入 stall 账,该误判在机制上不再可能。校验本身无害且不会误拒
|
||||
任何合理配置,故保留——删除它需同步改动 ARCHITECTURE.md §7.3 的契约
|
||||
补强 G6,超出 issue #8 的范围(2026-08-06 人类定夺)。
|
||||
"""
|
||||
ttfts = [s.ttft_timeout_s for s in self.sources if s.ttft_timeout_s is not None]
|
||||
if ttfts and self.backpressure.stall_window_s < max(ttfts):
|
||||
raise ValueError(
|
||||
f"backpressure.stall_window_s({self.backpressure.stall_window_s})须 ≥ "
|
||||
f"最大源 ttft_timeout_s({max(ttfts)});调大 stall_window_s 或调小 ttft_timeout_s"
|
||||
)
|
||||
|
||||
def _validate_probe(self) -> None:
|
||||
"""半开探针租约须撑过一次最慢调用,否则探针在途即被接管(M2 设计 §3)。"""
|
||||
floor = max(s.timeout_s for s in self.sources) + _PROBE_GRACE_S
|
||||
if self.breaker.probe_ttl_s < floor:
|
||||
raise ValueError(
|
||||
f"breaker.probe_ttl_s({self.breaker.probe_ttl_s})须 ≥ 最慢源 "
|
||||
f"timeout_s + {_PROBE_GRACE_S}({floor});调大 probe_ttl_s 或调小源的 timeout_s"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls,
|
||||
@@ -119,7 +353,7 @@ class GatewaySettings:
|
||||
*,
|
||||
env_file: str = ".env",
|
||||
) -> GatewaySettings:
|
||||
"""聚合 env(缺省 .env + os.environ,后者优先)并执行装配守卫。"""
|
||||
"""聚合 env(缺省 .env + os.environ,后者优先);守卫由 `__post_init__` 执行。"""
|
||||
if env is None:
|
||||
env = {
|
||||
k: v for k, v in {**dotenv_values(env_file), **os.environ}.items() if v is not None
|
||||
@@ -129,7 +363,7 @@ class GatewaySettings:
|
||||
global_limits = _load_global_limits(scope_u, env)
|
||||
retry = _load_retry(scope_u, env)
|
||||
breaker = _load_breaker(scope_u, env, sources, global_limits)
|
||||
settings = cls(
|
||||
return cls(
|
||||
scope=scope_u.lower(),
|
||||
sources=tuple(sources),
|
||||
global_limits=global_limits,
|
||||
@@ -138,11 +372,9 @@ class GatewaySettings:
|
||||
backpressure=_load_backpressure(scope_u, env),
|
||||
selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"),
|
||||
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
|
||||
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
|
||||
**_load_pgw(env),
|
||||
)
|
||||
_guard_lease(settings)
|
||||
_guard_stall(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def _load_sources(scope: str, env: Mapping[str, str]) -> list[SourceConfig]:
|
||||
@@ -217,16 +449,12 @@ def _load_breaker(
|
||||
if concurrency > 0:
|
||||
threshold = max(threshold, concurrency * 2)
|
||||
slowest = max(s.timeout_s for s in sources)
|
||||
probe_floor = slowest + 5.0 # CHS container.py:274-275: 最慢调用 + 清理宽限
|
||||
probe_floor = slowest + _PROBE_GRACE_S
|
||||
probe = _first(env, f"{scope}__BREAKER__PROBE_TTL_S")
|
||||
if probe is not None:
|
||||
# 配置值不在此校验: 探针租约下限是跨字段不变量,由 GatewaySettings._validate_probe
|
||||
# 统一把关(否则直接构造那条装配路会绕过)
|
||||
probe_ttl_s = float(_cast(probe[1], "float", probe[0]))
|
||||
# 装配守卫(M2 设计 §3): 探针租约必须撑过一次最慢调用,否则半开探针在途即被接管
|
||||
if probe_ttl_s < probe_floor:
|
||||
raise ValueError(
|
||||
f"probe_ttl_s({probe_ttl_s})须 ≥ 最大源 timeout_s + 5({probe_floor});"
|
||||
f"调大 {probe[0]} 或调小源超时"
|
||||
)
|
||||
else:
|
||||
# 派生规则: 探针租约须撑过一次最慢调用,且不短于冷却期(第三项保证守卫恒成立)
|
||||
probe_ttl_s = max(2 * slowest, cooldown_s, probe_floor)
|
||||
@@ -277,21 +505,20 @@ def _load_choice(env: Mapping[str, str], key: str, allowed: frozenset[str], defa
|
||||
|
||||
|
||||
def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
limiter_backend = _load_choice(
|
||||
env, "PGW_LIMITER_BACKEND", frozenset({"memory", "redis"}), "memory"
|
||||
)
|
||||
breaker_backend = _load_choice(
|
||||
env, "PGW_BREAKER_BACKEND", frozenset({"memory", "redis"}), "memory"
|
||||
)
|
||||
# 合法域与构造期守卫共用常量;此处的检查保留是为了报错能点出 env 键名,
|
||||
# 构造期那道点的是字段名(两类调用方各看得懂自己那套)
|
||||
limiter_backend = _load_choice(env, "PGW_LIMITER_BACKEND", _LIMITER_BACKENDS, "memory")
|
||||
breaker_backend = _load_choice(env, "PGW_BREAKER_BACKEND", _BREAKER_BACKENDS, "memory")
|
||||
_, cache_backend = _require(env, "PGW_CACHE_BACKEND")
|
||||
_, telemetry_backend = _require(env, "PGW_TELEMETRY_BACKEND")
|
||||
if cache_backend not in ("redis", "memory", "none"):
|
||||
if cache_backend not in _CACHE_BACKENDS:
|
||||
raise ValueError(f"PGW_CACHE_BACKEND 非法值 {cache_backend!r}")
|
||||
if telemetry_backend not in ("sqlite", "postgres", "none"):
|
||||
if telemetry_backend not in _TELEMETRY_BACKENDS:
|
||||
raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}")
|
||||
redis_url = env.get("REDIS_URL") or None
|
||||
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
|
||||
raise ValueError("缺关键配置: 限流/熔断后端取 redis 需设置 REDIS_URL")
|
||||
auto_migrate = _load_schema_mode(env, telemetry_backend)
|
||||
return {
|
||||
"limiter_backend": limiter_backend,
|
||||
"breaker_backend": breaker_backend,
|
||||
@@ -302,6 +529,10 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
if telemetry_backend == "sqlite"
|
||||
else None,
|
||||
"telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None,
|
||||
"telemetry_auto_migrate": auto_migrate,
|
||||
"telemetry_text_cap": _load_text_cap(env),
|
||||
"telemetry_pg_pool_max": _load_pool_max(env),
|
||||
"telemetry_pg_write_timeout_s": _load_write_timeout(env),
|
||||
"redis_url": redis_url,
|
||||
"pricing_path": env.get("PGW_PRICING_PATH") or None,
|
||||
"structured_max_retries": _load_structured_retries(env),
|
||||
@@ -309,13 +540,109 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _load_pg_dsn(env: Mapping[str, str]) -> str:
|
||||
"""读取 Postgres DSN 并剥 SQLAlchemy 风格驱动后缀(asyncpg 不认 `+driver`)。"""
|
||||
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
|
||||
def _load_schema_mode(env: Mapping[str, str], telemetry_backend: str) -> bool:
|
||||
"""把 `PGW_TELEMETRY_SCHEMA_MODE` 的三态解成 `telemetry_auto_migrate`(issue #13)。
|
||||
|
||||
三态: 键未设 → 按后端**不对称**派生;显式 auto/manual → 两侧都可覆盖。
|
||||
不对称的理由是两个后端的风险量级不同: SQLite 是下游自己的本地文件(没有
|
||||
DBA、没有迁移工具、没有第二个系统碰它),ALTER 是毫秒级元数据操作,要求
|
||||
手工跑 SQL 是给零运维场景强加运维步骤;PG 是共享的生产表,ALTER 取
|
||||
ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询,而遥测是业务
|
||||
路径上的内联 await。
|
||||
|
||||
`_load_choice` 带 default,不能直接用来读这个键——default 会把"未设"和
|
||||
"设成默认值"抹平成同一种,三态就塌回两态,后端派生也就再没机会生效。故
|
||||
先用 `_first` 探"设没设",确认设了才交给 `_load_choice` 做值域校验(错误
|
||||
信息点出 env 键名这件事仍由它负责)。
|
||||
|
||||
Args:
|
||||
env: 已合并的环境映射。
|
||||
telemetry_backend: 已校验过值域的遥测后端名。
|
||||
|
||||
Returns:
|
||||
recorder 是否获准给旧表自动 ALTER 补列;backend=none 时无人消费,
|
||||
构造期守卫会再把它归一化为 False。
|
||||
"""
|
||||
if _first(env, _SCHEMA_MODE_KEY) is None:
|
||||
return telemetry_backend == "sqlite"
|
||||
return _load_choice(env, _SCHEMA_MODE_KEY, _SCHEMA_MODES, "auto") == "auto"
|
||||
|
||||
|
||||
def _load_text_cap(env: Mapping[str, str]) -> int | None:
|
||||
"""读 `PGW_TELEMETRY_TEXT_CAP`(issue #12);键未设即 None = 不截断。
|
||||
|
||||
与相邻的 `PGW_TELEMETRY_SCHEMA_MODE` 不同,这个键是**二态**而非三态:
|
||||
"未设"本身就是最终答案(不截断),没有需要按后端派生的第二种缺省,故不必像
|
||||
那边一样先探"设没设"再分两条路取值,读到什么解什么即可。
|
||||
|
||||
值域(> 0)刻意不在此处判: 构造期守卫那道同时覆盖直接构造与
|
||||
`dataclasses.replace`,而报错文本已点出本键名,env 路的调用方不会看丢。
|
||||
|
||||
Args:
|
||||
env: 已合并的环境映射。
|
||||
|
||||
Returns:
|
||||
遥测正文的字符上限;键未设或为空串时返回 None(不截断)。
|
||||
"""
|
||||
found = _first(env, _TEXT_CAP_KEY)
|
||||
if found is None:
|
||||
return None
|
||||
return int(_cast(found[1], "int", found[0]))
|
||||
|
||||
|
||||
def _load_pool_max(env: Mapping[str, str]) -> int:
|
||||
"""读 `PGW_TELEMETRY_PG_POOL_MAX`(issue #15);未设即缺省 4。
|
||||
|
||||
与 `_load_text_cap` 同为二态键,只是"未设"落到一个具体缺省而非 None:
|
||||
池上限没有"不设上限"这一档——不表态就是继承第三方默认值,而那正是本 issue
|
||||
的病灶。值域(>= 1)留给构造期守卫,它同时覆盖直接构造与 `dataclasses.replace`。
|
||||
|
||||
Args:
|
||||
env: 已合并的环境映射。
|
||||
|
||||
Returns:
|
||||
遥测池允许的最大连接数。
|
||||
"""
|
||||
found = _first(env, _POOL_MAX_KEY)
|
||||
if found is None:
|
||||
return _DEFAULT_PG_POOL_MAX
|
||||
return int(_cast(found[1], "int", found[0]))
|
||||
|
||||
|
||||
def _load_write_timeout(env: Mapping[str, str]) -> float:
|
||||
"""读 `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(issue #15);未设即缺省 5.0 秒。
|
||||
|
||||
这个值同时是 connect、acquire 与整次写入的上界: 遥测是业务路径上的内联
|
||||
await,"不设预算"不是一个允许存在的档位(铁律"丢一条 < 拖垮调用")。
|
||||
|
||||
Args:
|
||||
env: 已合并的环境映射。
|
||||
|
||||
Returns:
|
||||
单次遥测写入的硬预算(秒)。
|
||||
"""
|
||||
found = _first(env, _WRITE_TIMEOUT_KEY)
|
||||
if found is None:
|
||||
return _DEFAULT_PG_WRITE_TIMEOUT_S
|
||||
return float(_cast(found[1], "float", found[0]))
|
||||
|
||||
|
||||
def _strip_dsn_driver(dsn: str) -> str:
|
||||
"""剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。"""
|
||||
scheme, sep, rest = dsn.partition("://")
|
||||
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
||||
|
||||
|
||||
def _load_pg_dsn(env: Mapping[str, str]) -> str:
|
||||
"""读取 Postgres DSN 并剥驱动后缀。
|
||||
|
||||
env 路在此剥干净,构造期那道就无事可做——三项目 `.env` 里的 SQLAlchemy
|
||||
写法不会每次装配都刷一条 warning。
|
||||
"""
|
||||
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
|
||||
return _strip_dsn_driver(dsn)
|
||||
|
||||
|
||||
def _load_cache_keys(
|
||||
env: Mapping[str, str], cache_backend: str, redis_url: str | None
|
||||
) -> dict[str, object]:
|
||||
@@ -339,26 +666,6 @@ def _load_structured_retries(env: Mapping[str, str]) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _guard_lease(settings: GatewaySettings) -> None:
|
||||
"""装配守卫: 调用超时须 ≤ permit 租约 TTL,防租约先于请求过期(ARCH §7.3)。"""
|
||||
slowest = max(s.timeout_s for s in settings.sources)
|
||||
if slowest > settings.lease_ttl_s:
|
||||
raise ValueError(
|
||||
f"源最大 timeout_s({slowest})超过 permit 租约 TTL({settings.lease_ttl_s});"
|
||||
f"调大 PGW_LEASE_TTL_S 或调小超时"
|
||||
)
|
||||
|
||||
|
||||
def _guard_stall(settings: GatewaySettings) -> None:
|
||||
"""装配守卫: stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死(ARCH §7.3)。"""
|
||||
ttfts = [s.ttft_timeout_s for s in settings.sources if s.ttft_timeout_s is not None]
|
||||
if ttfts and settings.backpressure.stall_window_s < max(ttfts):
|
||||
raise ValueError(
|
||||
f"stall_window_s({settings.backpressure.stall_window_s})须 ≥ 最大源 "
|
||||
f"ttft_timeout_s({max(ttfts)});调大 BACKPRESSURE__STALL_WINDOW_S 或调小 TTFT"
|
||||
)
|
||||
|
||||
|
||||
def _load_lease_ttl(env: Mapping[str, str]) -> float:
|
||||
found = _first(env, "PGW_LEASE_TTL_S")
|
||||
return float(_cast(found[1], "float", found[0])) if found else _DEFAULT_LEASE_TTL_S
|
||||
@@ -378,6 +685,13 @@ class EmbeddingSettings:
|
||||
normalize: bool = False
|
||||
expected_dim: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""自身字段的域校验;内嵌的 gateway 由 `GatewaySettings.__post_init__` 自己把关。"""
|
||||
if self.batch_size < 1:
|
||||
raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}")
|
||||
if self.expected_dim is not None and self.expected_dim < 1:
|
||||
raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}")
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls,
|
||||
|
||||
+184
-113
@@ -21,27 +21,35 @@ import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.client import _aclose_component, _telemetry_status_of
|
||||
from polygateway.config import EmbeddingSettings
|
||||
from polygateway.errors import (
|
||||
AllSourcesExhausted,
|
||||
CircuitOpenError,
|
||||
GovernanceBackendError,
|
||||
PolyGatewayError,
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
SourceNotConfiguredError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.middleware.retry import _failure_reason, backoff_delay
|
||||
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.sources import SourceCooldownMemo
|
||||
from polygateway.types import ChatRequest, EmbeddingResponse, LLMResponse
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
EmbeddingResponse,
|
||||
LLMResponse,
|
||||
TelemetryStatus,
|
||||
strip_unsupported_extra_body,
|
||||
validate_caller_dimensions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
@@ -95,8 +103,10 @@ class EmbeddingClient:
|
||||
retry: RetryPolicy,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
circuit_open: str = "fail_fast",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
pricing: PricingTable | None = None,
|
||||
text_cap: int | None = None,
|
||||
batch_size: int,
|
||||
normalize: bool = False,
|
||||
expected_dim: int | None = None,
|
||||
@@ -106,29 +116,50 @@ class EmbeddingClient:
|
||||
) -> None:
|
||||
if batch_size < 1:
|
||||
raise ValueError("batch_size 必须 ≥ 1")
|
||||
if quota_full not in ("wait", "fail_fast"):
|
||||
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
|
||||
if expected_dim is not None and expected_dim < 1:
|
||||
raise ValueError("expected_dim 必须 ≥ 1")
|
||||
self._scope = scope
|
||||
self._sources = list(sources)
|
||||
self._selector = selector
|
||||
self._quota = QuotaGate(limiter)
|
||||
self._breaker = BreakerGate(breaker)
|
||||
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
|
||||
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||
self._sources = strip_unsupported_extra_body(list(sources), path="embedding")
|
||||
self._quota = QuotaGate(limiter, scope=self._scope)
|
||||
self._breaker = BreakerGate(breaker, scope=self._scope)
|
||||
self._transport = transport
|
||||
self._retry = retry
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry else None
|
||||
self._emitter = (
|
||||
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None
|
||||
)
|
||||
self._telemetry = telemetry
|
||||
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
|
||||
# 引用才关得到自建的 redis 客户端(设计 §3.4)
|
||||
self._limiter_backend = limiter
|
||||
self._breaker_backend = breaker
|
||||
# 所有权默认"不拥有": `__init__` 是全量注入路径,只有工厂自建时才置 True
|
||||
self._owns_transport = False
|
||||
self._owns_telemetry = False
|
||||
self._owns_limiter = False
|
||||
self._owns_breaker = False
|
||||
self._pricing = pricing
|
||||
self._batch_size = batch_size
|
||||
self._normalize = normalize
|
||||
self._expected_dim = expected_dim
|
||||
self._memo = SourceCooldownMemo(now=now)
|
||||
self._now = now
|
||||
self._sleep = sleep
|
||||
self._rng = rng
|
||||
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
|
||||
self._admission = SourceAdmission(
|
||||
scope=self._scope,
|
||||
sources=self._sources,
|
||||
selector=selector,
|
||||
quota=self._quota,
|
||||
breaker=self._breaker,
|
||||
backpressure=backpressure,
|
||||
quota_full=quota_full,
|
||||
circuit_open=circuit_open,
|
||||
now=now,
|
||||
sleep=sleep,
|
||||
rng=rng,
|
||||
)
|
||||
self._closed = False
|
||||
|
||||
async def embed(
|
||||
@@ -137,10 +168,22 @@ class EmbeddingClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> EmbeddingResponse:
|
||||
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。"""
|
||||
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
|
||||
本次调用而非某一批,故每批的遥测行都带同一份维度。
|
||||
"""
|
||||
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
|
||||
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
|
||||
# 必须在切批之前校验: 洋葱/链路内的一切失败都被遥测层降级成 warning
|
||||
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验——非法维度
|
||||
# 会变成静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
|
||||
)
|
||||
if not texts:
|
||||
return EmbeddingResponse(
|
||||
vectors=[],
|
||||
@@ -159,7 +202,11 @@ class EmbeddingClient:
|
||||
for start in range(0, len(texts), self._batch_size):
|
||||
outcomes.append(
|
||||
await self._embed_batch(
|
||||
texts[start : start + self._batch_size], session_id, parent_call_id
|
||||
texts[start : start + self._batch_size],
|
||||
session_id,
|
||||
parent_call_id,
|
||||
dimension_tenant_id,
|
||||
dimensions,
|
||||
)
|
||||
)
|
||||
return self._merge(outcomes)
|
||||
@@ -167,17 +214,26 @@ class EmbeddingClient:
|
||||
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
|
||||
|
||||
async def _embed_batch(
|
||||
self, batch: list[str], session_id: str | None, parent_call_id: str | None
|
||||
self,
|
||||
batch: list[str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _BatchOutcome:
|
||||
fails = 0
|
||||
reasons: dict[str, str] = {}
|
||||
entered_at = self._now()
|
||||
# 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算
|
||||
clock = StallClock(self._now)
|
||||
while True:
|
||||
picked, gate_rejections = await self._pick_runnable(reasons)
|
||||
picked, gate_rejections = await self._admission.pick(reasons, {})
|
||||
if picked is None:
|
||||
await self._on_no_runnable(gate_rejections, reasons, entered_at)
|
||||
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
|
||||
continue
|
||||
outcome = await self._attempt(batch, *picked, reasons, session_id, parent_call_id)
|
||||
async with clock.attempting():
|
||||
outcome = await self._attempt(
|
||||
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta
|
||||
)
|
||||
if isinstance(outcome, _BatchOutcome):
|
||||
return outcome
|
||||
fails += 1
|
||||
@@ -191,62 +247,6 @@ class EmbeddingClient:
|
||||
if not outcome.immediate:
|
||||
await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng))
|
||||
|
||||
async def _pick_runnable(
|
||||
self, reasons: dict[str, str]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
||||
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
||||
gate_rejections = 0
|
||||
for cand in self._selector.order(self._sources, stats):
|
||||
if self._memo.active(cand.name):
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "cooldown"
|
||||
continue
|
||||
permit = await self._quota.try_acquire(cand)
|
||||
if permit is None:
|
||||
reasons.setdefault(cand.name, "rate_limited")
|
||||
continue
|
||||
entry = None
|
||||
try:
|
||||
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
|
||||
finally:
|
||||
if entry is None:
|
||||
await self._settle_and_release(permit, 0)
|
||||
if entry.allowed:
|
||||
return (cand, permit, entry), gate_rejections
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "circuit_open"
|
||||
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
await self._settle_and_release(permit, 0)
|
||||
return None, gate_rejections
|
||||
|
||||
async def _on_no_runnable(
|
||||
self, gate_rejections: int, reasons: dict[str, str], entered_at: float
|
||||
) -> None:
|
||||
if gate_rejections == len(self._sources):
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise CircuitOpenError(
|
||||
scope=self._scope,
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
if self._quota_full == "fail_fast":
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="quota_exhausted",
|
||||
retry_after_s=self._bp.poll_interval_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
stall = self._bp.stall_window_s
|
||||
if self._now() - entered_at > stall and await self._quota.progress_age_s() > stall:
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
|
||||
|
||||
async def _attempt(
|
||||
self,
|
||||
batch: list[str],
|
||||
@@ -256,6 +256,8 @@ class EmbeddingClient:
|
||||
reasons: dict[str, str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _BatchOutcome | _FailedBatch:
|
||||
call_id = str(uuid.uuid4())
|
||||
started = self._now()
|
||||
@@ -268,21 +270,53 @@ class EmbeddingClient:
|
||||
source_name=source.name,
|
||||
operation="embedding",
|
||||
)
|
||||
actual = result.prompt_tokens
|
||||
if result.usage_source == "unavailable":
|
||||
# 与 RetryMW 同口径: 用量不可得时按入场预扣量结算(设计 §3.2 #9)
|
||||
actual = source.effective_est_tokens()
|
||||
else:
|
||||
actual = result.prompt_tokens
|
||||
await self._record_quietly(self._breaker.record_success(entry))
|
||||
await self._record_quietly(self._quota.mark_progress())
|
||||
latency_ms = int((self._now() - started) * 1000)
|
||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, result)
|
||||
await self._emit(
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
result,
|
||||
)
|
||||
return _BatchOutcome(result, source, call_id, latency_ms)
|
||||
except (RequestRejectedError, ResultInvalidError) as exc:
|
||||
await self._gate_on_terminal(exc, entry)
|
||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
|
||||
await self._emit(
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
if entry.is_probe:
|
||||
await self._record_quietly(self._breaker.release_probe(entry))
|
||||
await self._emit(
|
||||
batch, source, call_id, started, session_id, parent_call_id, error="cancelled"
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error="cancelled",
|
||||
)
|
||||
raise
|
||||
except (SourceDeadError, TransientError) as exc:
|
||||
@@ -291,11 +325,22 @@ class EmbeddingClient:
|
||||
reasons[source.name] = reason
|
||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||
if not dead:
|
||||
actual = source.est_tokens # 保守: 失败请求可能已被网关计费(CHS 同款)
|
||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
|
||||
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
|
||||
actual = source.effective_est_tokens()
|
||||
await self._emit(
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
return _FailedBatch(exc, immediate=dead)
|
||||
finally:
|
||||
await self._settle_and_release(permit, actual)
|
||||
await settle_and_release(permit, actual)
|
||||
|
||||
# —— 辅助 ——
|
||||
|
||||
@@ -314,20 +359,9 @@ class EmbeddingClient:
|
||||
await write_back
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except GovernanceBackendError as exc:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
|
||||
logger.warning("embedding 治理记账写回降级(不冒泡): {}", exc)
|
||||
|
||||
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
||||
try:
|
||||
try:
|
||||
await permit.settle(actual)
|
||||
finally:
|
||||
await permit.release()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("embedding permit 结算/释放失败(不掩盖主异常): {}", exc)
|
||||
|
||||
async def _emit(
|
||||
self,
|
||||
batch: list[str],
|
||||
@@ -336,16 +370,22 @@ class EmbeddingClient:
|
||||
started: float,
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
result: EmbeddingTransportResult | None = None,
|
||||
error: object | None = None,
|
||||
) -> None:
|
||||
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
|
||||
if self._emitter is None:
|
||||
return
|
||||
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(embedding 不走 chat
|
||||
# 洋葱),故调用方维度必须在这里显式填回,否则 embed 行的维度恒为空
|
||||
request = ChatRequest(
|
||||
messages=[{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in batch],
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
tenant_id=tenant_id,
|
||||
meta=meta,
|
||||
)
|
||||
response = None
|
||||
if result is not None:
|
||||
@@ -371,6 +411,9 @@ class EmbeddingClient:
|
||||
latency_ms=int((self._now() - started) * 1000),
|
||||
response=response,
|
||||
error=None if error is None else str(error),
|
||||
# embedding payload 硬编码 {model, input},从不带推理参数;源上即便
|
||||
# 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事
|
||||
reasoning_applies=False,
|
||||
)
|
||||
|
||||
def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
|
||||
@@ -380,14 +423,21 @@ class EmbeddingClient:
|
||||
vectors = [_l2_normalize(v) for v in vectors]
|
||||
first = outcomes[0]
|
||||
prompt_tokens = sum(o.result.prompt_tokens for o in outcomes)
|
||||
estimated = any(o.result.usage_source == "estimated" for o in outcomes)
|
||||
# 三态合并优先级(解耦设计 §3.2 #10): 任一批不可得 → 整体不可得
|
||||
sources = {o.result.usage_source for o in outcomes}
|
||||
if "unavailable" in sources:
|
||||
merged_source = "unavailable"
|
||||
elif "estimated" in sources:
|
||||
merged_source = "estimated"
|
||||
else:
|
||||
merged_source = "measured"
|
||||
return EmbeddingResponse(
|
||||
vectors=vectors,
|
||||
dim=first.result.dim,
|
||||
model=first.source.model,
|
||||
provider=first.source.provider,
|
||||
prompt_tokens=prompt_tokens,
|
||||
usage_source="estimated" if estimated else "measured",
|
||||
usage_source=merged_source,
|
||||
latency_ms=sum(o.latency_ms for o in outcomes),
|
||||
call_id=first.call_id,
|
||||
source_name=first.source.name,
|
||||
@@ -395,27 +445,41 @@ class EmbeddingClient:
|
||||
)
|
||||
|
||||
def _total_cost(self, outcomes: list[_BatchOutcome]) -> float | None:
|
||||
"""全批成本;任一批用量不可得则整体记 NULL(解耦设计 §3.2 #11)。
|
||||
|
||||
逐批求和会把不可得的批当 0 计入,给出一个偏低却看似有效的金额——
|
||||
与"宁可算不出成本,也不算错成本"的不变式相悖。
|
||||
"""
|
||||
if self._pricing is None:
|
||||
return None
|
||||
if any(o.result.usage_source == "unavailable" for o in outcomes):
|
||||
return None
|
||||
costs = [self._pricing.cost(o.source.model, o.result.prompt_tokens, 0) for o in outcomes]
|
||||
known = [c for c in costs if c is not None]
|
||||
return sum(known) if known else None
|
||||
|
||||
@property
|
||||
def telemetry_status(self) -> TelemetryStatus | None:
|
||||
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
|
||||
|
||||
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
|
||||
的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。
|
||||
"""
|
||||
return _telemetry_status_of(self._telemetry)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""幂等释放 transport 连接池与遥测连接(与 GatewayClient 对称)。"""
|
||||
"""幂等释放**自建**资源(与 GatewayClient 对称);注入的组件一律不碰。"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
transport_aclose = getattr(self._transport, "aclose", None)
|
||||
if transport_aclose is not None:
|
||||
await transport_aclose()
|
||||
telemetry_aclose = getattr(self._telemetry, "aclose", None)
|
||||
if telemetry_aclose is not None:
|
||||
await telemetry_aclose()
|
||||
else:
|
||||
telemetry_close = getattr(self._telemetry, "close", None)
|
||||
if telemetry_close is not None:
|
||||
telemetry_close()
|
||||
if self._owns_transport:
|
||||
await _aclose_component(self._transport)
|
||||
if self._owns_telemetry:
|
||||
await _aclose_component(self._telemetry)
|
||||
if self._owns_limiter:
|
||||
await _aclose_component(self._limiter_backend)
|
||||
if self._owns_breaker:
|
||||
await _aclose_component(self._breaker_backend)
|
||||
|
||||
async def __aenter__(self) -> EmbeddingClient:
|
||||
return self
|
||||
@@ -441,30 +505,37 @@ class EmbeddingClient:
|
||||
_build_limiter,
|
||||
_build_selector,
|
||||
_build_telemetry,
|
||||
_mark_owned_components,
|
||||
)
|
||||
from polygateway.pricing import PricingTable
|
||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||
|
||||
gw = settings.gateway
|
||||
sources = list(gw.sources)
|
||||
return cls(
|
||||
client = cls(
|
||||
scope=gw.scope,
|
||||
sources=sources,
|
||||
selector=_build_selector(gw.selector),
|
||||
limiter=limiter or _build_limiter(gw, sources),
|
||||
breaker=breaker or _build_breaker(gw),
|
||||
limiter=limiter if limiter is not None else _build_limiter(gw, sources),
|
||||
breaker=breaker if breaker is not None else _build_breaker(gw),
|
||||
transport=OpenAICompatTransport(registry=registry),
|
||||
retry=gw.retry,
|
||||
backpressure=gw.backpressure,
|
||||
quota_full=gw.quota_full,
|
||||
circuit_open=gw.circuit_open,
|
||||
telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
|
||||
pricing=PricingTable.from_file(gw.pricing_path)
|
||||
if gw.pricing_path is not None
|
||||
else None,
|
||||
# embed 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
||||
# 一半不受控(issue #12)
|
||||
text_cap=gw.telemetry_text_cap,
|
||||
batch_size=settings.batch_size,
|
||||
normalize=settings.normalize,
|
||||
expected_dim=settings.expected_dim,
|
||||
)
|
||||
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
|
||||
@@ -5,8 +5,22 @@
|
||||
"""
|
||||
|
||||
SCOPE_REASONS = frozenset(
|
||||
{"circuit_open", "retry_exhausted", "stalled", "quota_exhausted", "no_sources"}
|
||||
{
|
||||
"circuit_open",
|
||||
"retry_exhausted",
|
||||
"stalled",
|
||||
"quota_exhausted",
|
||||
"no_sources",
|
||||
"governance_backend_down", # issue #7: 限流/熔断后端故障(fail-closed → 整个 scope 发不出请求)
|
||||
}
|
||||
)
|
||||
GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0
|
||||
"""治理后端故障的建议重投间隔(秒)。
|
||||
|
||||
**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),
|
||||
故取一个保守固定值;下游有自己的退避策略时可忽略本字段。取 0 会让积压任务零延迟
|
||||
同时冲击已挂掉的后端,把一次故障放大成一场风暴(issue #7 §3.2)。
|
||||
"""
|
||||
SOURCE_REASONS = frozenset(
|
||||
{
|
||||
"network_error",
|
||||
@@ -21,7 +35,26 @@ SOURCE_REASONS = frozenset(
|
||||
|
||||
|
||||
class PolyGatewayError(Exception):
|
||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。"""
|
||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
|
||||
|
||||
`body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。
|
||||
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
|
||||
|
||||
============== ==================================================
|
||||
``body_text`` **非 2xx** 的 HTTP 错误响应体: 对方**拒绝**的理由
|
||||
``raw_text`` **2xx** 但内容不可解析时的模型输出原文
|
||||
============== ==================================================
|
||||
|
||||
加在基类而非某个子类,是因为这些错误全部由同一个 HTTP 响应翻译而来——
|
||||
"对方说了什么"与"它属于哪一类"正交。scope 级错误(`GatewayUnavailableError`
|
||||
一族)继承到的恒空值不是噪音,而是"没有单一响应体可言"的如实表达。
|
||||
|
||||
**内容已由 transport 层截断**(`transports/_http_errors.summarize_body`),
|
||||
且可能包含网关对请求的回显——库不做脱敏: 它不知道下游哪些字段敏感,
|
||||
猜测式脱敏只会同时丢掉诊断价值与安全性。
|
||||
|
||||
本字段是**旁路数据**,不参与任何治理判定(重试/换源/熔断计数/限流结算)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -30,11 +63,13 @@ class PolyGatewayError(Exception):
|
||||
source_name: str | None = None,
|
||||
status_code: int | None = None,
|
||||
operation: str | None = None,
|
||||
body_text: str = "",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.source_name = source_name
|
||||
self.status_code = status_code
|
||||
self.operation = operation
|
||||
self.body_text = body_text
|
||||
|
||||
|
||||
class TransientError(PolyGatewayError):
|
||||
@@ -50,7 +85,16 @@ class SourceDeadError(PolyGatewayError):
|
||||
|
||||
|
||||
class RequestRejectedError(PolyGatewayError):
|
||||
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。"""
|
||||
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。
|
||||
|
||||
**经中转部署时请注意**(issue #10 下游实测): 第三方 API 中转服务自身抖动
|
||||
时也会回 400,从状态码上与供应商说"你的输入有问题"无法区分。下游曾观测到
|
||||
同一份字节(sha256 一致)重发 15 次全部成功,且失败那次 `prompt_tokens=0`、
|
||||
耗时远低于任何成功调用——请求在推理开始前就被挡了。本库仍按确定性失败处理
|
||||
(对直连供应商而言重试只会白烧配额),批处理场景的下游宜自备兜底分类;
|
||||
`body_text` 即为此提供判据: 中转抖动的响应体与供应商的 `invalid_request_error`
|
||||
形态不同。
|
||||
"""
|
||||
|
||||
|
||||
class ResultInvalidError(PolyGatewayError):
|
||||
@@ -72,9 +116,20 @@ class ResultInvalidError(PolyGatewayError):
|
||||
|
||||
|
||||
class GatewayUnavailableError(PolyGatewayError):
|
||||
"""scope 级暂时不可用;业务侧 catch 本类做延期重投(CHS arq 模式)。
|
||||
"""scope 级暂时不可用: 库的**调用级**预算已经耗尽。
|
||||
|
||||
`retry_after_s` 非可选(0 = 可立即重试),承 CHS ProviderUnavailableError。
|
||||
**职责边界(issue #14)**: 调用级的重试、退避、换源、等待冷却全部在库内,
|
||||
不需要下游再写一层——两边各写一份必然漂移(库调了退避曲线而下游不知道,
|
||||
下游改了等待上限而库的遥测算不进去),漂移之后"这次调用到底等了多久、
|
||||
试了几次"就没有单一事实源答得出来。本异常表示那份预算(重试预算或 stall
|
||||
预算)已经用完。下游据此再投是**任务级重试**,与调用级重试语义不同,由
|
||||
业务自行在库外包(ARCH §7.2 单层重试原则)。
|
||||
|
||||
熔断开路时是当场抛本类还是先等冷却过去,由 `{SCOPE}__CIRCUIT_OPEN`
|
||||
决定(缺省 fail_fast;单源 scope 建议配 wait)。
|
||||
|
||||
`retry_after_s` 非可选,语义是"距离**确定**可再试的时刻还有多久";
|
||||
`0` 表示不存在确定的等待时刻(可立即重试),承 CHS ProviderUnavailableError。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -127,5 +182,38 @@ class AllSourcesExhausted(GatewayUnavailableError): # noqa: N818 — ARCH §6.1
|
||||
"""重试预算耗尽 / 无可用源 / 配额 fail-fast 等 scope 级失败。"""
|
||||
|
||||
|
||||
class GovernanceBackendError(PolyGatewayError):
|
||||
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。"""
|
||||
class SourceNotConfiguredError(PolyGatewayError):
|
||||
"""源名不在限流后端的配置字典中: 装配缺陷,正常不可达。
|
||||
|
||||
**有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是"配置写
|
||||
错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置错误的任务永远
|
||||
重投、永不告警——正是 issue #7 要修的那个 bug 的镜像(§3.4)。
|
||||
"""
|
||||
|
||||
|
||||
class GovernanceBackendError(GatewayUnavailableError):
|
||||
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。
|
||||
|
||||
继承 `GatewayUnavailableError`(issue #7): fail-closed 意味着整个 scope 一个
|
||||
请求都发不出去,语义上即 scope 级不可用。此前它是 `PolyGatewayError` 的直接
|
||||
子类,只写 `except GatewayUnavailableError` 的调用方接不住,后果是"Redis 抖
|
||||
一下 → 积压任务消耗业务失败预算 → 进死信",而那是运维重启即可恢复的故障。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
scope: str,
|
||||
retry_after_s: float = GOVERNANCE_BACKEND_RETRY_AFTER_S,
|
||||
source_name: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
scope=scope,
|
||||
reason="governance_backend_down",
|
||||
retry_after_s=retry_after_s,
|
||||
source_name=source_name,
|
||||
)
|
||||
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
|
||||
# 携带的诊断串(如"限流后端 try_acquire 失败: ...")是排障主线索,必须保住
|
||||
self.args = (message,)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""SourceAdmission: 一次尝试的准入编排,三条治理循环(chat/embedding/ocr)共用一份。
|
||||
|
||||
**收敛缘由(issue #14)**: 本模块的两个方法此前在 `middleware/retry.py`、
|
||||
`embedding.py`、`ocr.py` 各存一份逐字复制(后两份是第一份的子集)。准入语义
|
||||
一直在演进——issue #8 改过 stall 口径、M2.5 加过 AIMD pacer、issue #14 要加
|
||||
熔断等待档——每演进一次就要三处同步,漏一处即行为分叉。三份复制正是库铁律
|
||||
痛斥的那种模式(遥测"三项目 4 处复制"的教训),只不过这次发生在库内部。
|
||||
|
||||
**职责边界**: 只管"挑出一个可跑的源"与"一个都挑不出来时怎么办";一次尝试
|
||||
本身(transport 调用、记账写回、逐次遥测)仍归各循环的 `_attempt`。
|
||||
|
||||
**共享而非持有**: `QuotaGate`/`BreakerGate`/`AdaptivePacer`/`SourceSelector` 由
|
||||
调用方构造后传入**同一实例**——三处 `_attempt` 仍要用它们做记账写回与
|
||||
`pacer.leave()`。pacer 尤其不能各建一个: 它有在途计数,分裂成两个计数器会让
|
||||
`admit`/`enter` 与 `leave` 记到不同账上。`SourceCooldownMemo` 只被准入消费,
|
||||
由本类独占。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.errors import AllSourcesExhausted, CircuitOpenError
|
||||
from polygateway.sources import SourceCooldownMemo
|
||||
|
||||
# 两个准入策略键共用的值域;校验只此一处,不在各客户端重复
|
||||
_POLICIES = frozenset({"wait", "fail_fast"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.middleware.retry import StallClock
|
||||
from polygateway.ports import GateDecision, Permit, SourceSelector
|
||||
from polygateway.sources import AdaptivePacer
|
||||
from polygateway.types import BackpressurePolicy, SourceConfig
|
||||
|
||||
|
||||
async def settle_and_release(permit: Permit, actual: int) -> None:
|
||||
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。
|
||||
|
||||
三条循环的 `_attempt` 与本模块的准入拒绝路径共用这一份(此前三处逐字复制,
|
||||
仅 warning 文案不同)。
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
await permit.settle(actual)
|
||||
finally:
|
||||
await permit.release()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("permit 结算/释放失败(不掩盖主异常): {}", exc)
|
||||
|
||||
|
||||
def _demote_call_failures(
|
||||
ordered: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float] | None,
|
||||
) -> list[SourceConfig]:
|
||||
"""调用内降权(设计 §3.3/§3.36): 失败 ≥2 次且存在可信替代才让位。
|
||||
|
||||
可信替代 = 某未失败候选 health ≥ 0.5 × 失败源 health——异构池里健康源
|
||||
偶发失败不该被推向已知坏源(第三轮教训: 期望成功率 83% vs 10%)。
|
||||
无健康视图(round_robin 等)保持无条件降权(冷启动保护)。
|
||||
|
||||
`attempt_fails` 为空时恒等返回原列表对象——embedding/ocr 不维护调用内
|
||||
失败计数,故对它们这一步是零成本的空操作,无需在调用侧加分支。
|
||||
"""
|
||||
demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2]
|
||||
if not demoted or len(demoted) == len(ordered):
|
||||
return ordered
|
||||
if health is None:
|
||||
return _move_to_tail(ordered, demoted)
|
||||
return _health_gated_reorder(ordered, demoted, attempt_fails, health)
|
||||
|
||||
|
||||
def _move_to_tail(ordered: list[SourceConfig], demoted: list[SourceConfig]) -> list[SourceConfig]:
|
||||
"""无健康视图: 无条件移尾(冷启动保护原语义)。"""
|
||||
names = {d.name for d in demoted}
|
||||
return [s for s in ordered if s.name not in names] + demoted
|
||||
|
||||
|
||||
def _health_gated_reorder(
|
||||
ordered: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""健康门槛降权: 无可信替代则原地重试;有则插到可信替代之后。"""
|
||||
demoted = _credible_demotions(ordered, demoted, attempt_fails, health)
|
||||
if not demoted:
|
||||
return ordered
|
||||
names = {d.name for d in demoted}
|
||||
rest = [s for s in ordered if s.name not in names]
|
||||
return _insert_after_credible(rest, demoted, health)
|
||||
|
||||
|
||||
def _insert_after_credible(
|
||||
rest: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""插入位置(第四轮教训): 被降权源排在可信替代之后、不可信源之前——
|
||||
可信替代被限流闸/熔断跳过时,下一候选是失败源本身而非垃圾源。"""
|
||||
bar = 0.5 * max(health(d.name) for d in demoted)
|
||||
credible = [s for s in rest if health(s.name) >= bar]
|
||||
junk = [s for s in rest if health(s.name) < bar]
|
||||
return credible + demoted + junk
|
||||
|
||||
|
||||
def _credible_demotions(
|
||||
ordered: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""健康门槛过滤: 仅当存在"健康分 ≥ 失败源一半"的未失败候选,让位才有意义。"""
|
||||
alts = [o for o in ordered if attempt_fails.get(o.name, 0) < 2]
|
||||
return [s for s in demoted if any(health(o.name) >= 0.5 * health(s.name) for o in alts)]
|
||||
|
||||
|
||||
class SourceAdmission:
|
||||
"""准入编排器(CHS `governance.py:107-285` 同款);时钟/睡眠/随机全部注入。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
sources: list[SourceConfig],
|
||||
selector: SourceSelector,
|
||||
quota: QuotaGate,
|
||||
breaker: BreakerGate,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str,
|
||||
circuit_open: str,
|
||||
memo: SourceCooldownMemo | None = None,
|
||||
pacer: AdaptivePacer | None = None,
|
||||
health_view: Callable[[str], float] | None = None,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], object] = asyncio.sleep,
|
||||
rng: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
for name, value in (("quota_full", quota_full), ("circuit_open", circuit_open)):
|
||||
if value not in _POLICIES:
|
||||
raise ValueError(f"{name} 必须是 wait|fail_fast: {value!r}")
|
||||
self._scope = scope
|
||||
self._sources = sources
|
||||
self._selector = selector
|
||||
self._quota = quota
|
||||
self._breaker = breaker
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._circuit_open = circuit_open
|
||||
self._memo = memo or SourceCooldownMemo(now=now)
|
||||
self._pacer = pacer
|
||||
self._health_view = health_view
|
||||
self._now = now
|
||||
self._sleep = sleep
|
||||
self._rng = rng
|
||||
|
||||
# —— 选源与准入(CHS _pick_runnable 120-167)——
|
||||
|
||||
async def pick(
|
||||
self, reasons: dict[str, str], attempt_fails: dict[str, int]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
||||
"""挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。"""
|
||||
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
||||
gate_rejections = 0
|
||||
ordered = _demote_call_failures(
|
||||
self._selector.order(self._sources, stats), attempt_fails, self._health_view
|
||||
)
|
||||
for cand in ordered:
|
||||
if self._memo.active(cand.name):
|
||||
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "cooldown"
|
||||
continue
|
||||
if self._pacer is not None and not self._pacer.admit(cand.name):
|
||||
# AIMD 超限: 不计 gate_rejections → 走 quota-wait 排队,不误判熔断
|
||||
reasons.setdefault(cand.name, "adaptive_paced")
|
||||
continue
|
||||
permit = await self._quota.try_acquire(cand)
|
||||
if permit is None:
|
||||
reasons.setdefault(cand.name, "rate_limited")
|
||||
continue
|
||||
entry = None
|
||||
try:
|
||||
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
|
||||
finally:
|
||||
# try_enter 未归还 entry(异常/取消)→ 释放已占 permit,不吞任何异常
|
||||
if entry is None:
|
||||
await settle_and_release(permit, 0)
|
||||
if entry.allowed:
|
||||
if self._pacer is not None:
|
||||
self._pacer.enter(cand.name)
|
||||
return (cand, permit, entry), gate_rejections
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "circuit_open"
|
||||
# 开路源本地记冷却,避免每轮白烧 RPM 探测(CHS governance.py:107)
|
||||
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
await settle_and_release(permit, 0)
|
||||
return None, gate_rejections
|
||||
|
||||
# —— 背压与 stall 判死(CHS governance.py:270-285)——
|
||||
|
||||
async def stalled(self, clock: StallClock) -> bool:
|
||||
"""双条件 stall 判死(CHS governance.py:270-281): 本地累计等待与全局
|
||||
无进展**同时**超窗才判死——本地 monotonic 与后端时钟刻意不混用。
|
||||
|
||||
本地一侧只计非生产性等待(issue #8,见 `StallClock`)。短路顺序有意为之:
|
||||
本地未超窗就不问后端,省一次 Redis 往返。
|
||||
"""
|
||||
stall = self._bp.stall_window_s
|
||||
return clock.stalled_s() > stall and await self._quota.progress_age_s() > stall
|
||||
|
||||
async def on_no_runnable(
|
||||
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
|
||||
) -> None:
|
||||
"""一个源都挑不出来时的处置: **按拒绝原因分派**到各自的策略。
|
||||
|
||||
分派而非串行是硬要求(issue #14): 串行写法下 `circuit_open=wait` 不抛
|
||||
之后会径直掉进配额分支,`quota_full=fail_fast` 的调用方于是收到一个
|
||||
`reason=quota_exhausted` 的异常——而配额其实是满的,坏的是熔断门。
|
||||
"""
|
||||
names = tuple(s.name for s in self._sources)
|
||||
if gate_rejections == len(self._sources):
|
||||
# 全部因熔断类原因(门开路 / 本地冷却备忘)被拒
|
||||
if self._circuit_open == "fail_fast":
|
||||
raise CircuitOpenError(
|
||||
scope=self._scope,
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
# wait: 保护作用完整保留(这一轮照样一个请求都不发),改变的只是
|
||||
# 调用方当场死还是排队等——多源可换源故 fail-fast 对,单源无源可换
|
||||
hint = await self._breaker.retry_after_s(names)
|
||||
else:
|
||||
# 至少一个源是被配额/AIMD 挡的,归 quota_full 管
|
||||
if self._quota_full == "fail_fast":
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="quota_exhausted",
|
||||
retry_after_s=self._bp.poll_interval_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
hint = 0.0
|
||||
if await self.stalled(clock):
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
nap = self._nap(hint, clock)
|
||||
if hint > 0:
|
||||
logger.info("熔断开路等待 {:.1f}s 后重试(scope={}, 原因={})", nap, self._scope, reasons)
|
||||
await self._sleep(nap)
|
||||
|
||||
def _nap(self, hint: float, clock: StallClock) -> float:
|
||||
"""本轮等待多久。**必须在 `stalled()` 判定之后调用**(预算可能已耗尽)。
|
||||
|
||||
`hint > 0`(熔断开路有确定的冷却截止)时睡到那个时刻,而不是按
|
||||
`poll_interval` 空转——60 秒冷却用 10ms 轮询是 6000 次空转,内存后端
|
||||
只是查字典,Redis 后端则是 6000 次往返 × 每个在途调用。抖动**上**加
|
||||
而非缩放(既有 quota 路径是 `[0.5p, 1.0p]`): 对一个确定的截止时刻提前
|
||||
醒来必然被再拒一次,白跑一趟。
|
||||
|
||||
两档都夹到剩余 stall 预算,故单次调用的最坏墙钟是 `stall_window_s`
|
||||
加一个 poll 间隔,不随 `max_cooldown_s` 漂移。多加的那一格是因为
|
||||
`stalled()` 判据是 `>` 而非 `>=`——恰好睡到窗口边界不判死,留这一格
|
||||
让下一轮必定判死。`hint == 0` 时整个式子退化为既有的 jitter 轮询。
|
||||
"""
|
||||
jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
|
||||
budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
|
||||
wait = hint + jitter if hint > 0 else jitter
|
||||
# 下界取 jitter 而非 poll_interval: 既有 quota 轮询是 [0.5p, 1.0p],用
|
||||
# poll_interval 兜底会把 rng→0 那半边抬上去。预算为负时(本地已超窗但
|
||||
# 全局仍在出餐,故 stalled() 不判死)靠它退回正常轮询节奏,不忙循环。
|
||||
return max(jitter, min(wait, budget))
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from polygateway.errors import GovernanceBackendError
|
||||
from polygateway.errors import GovernanceBackendError, SourceNotConfiguredError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from polygateway.ports import GateDecision, GateUpdate, ProviderGate
|
||||
@@ -14,49 +14,61 @@ if TYPE_CHECKING:
|
||||
class BreakerGate:
|
||||
"""RetryMW 面向熔断后端的唯一入口;包装一切后端异常。"""
|
||||
|
||||
def __init__(self, gate: ProviderGate) -> None:
|
||||
def __init__(self, gate: ProviderGate, *, scope: str) -> None:
|
||||
self._gate = gate
|
||||
# 后端故障即 scope 级不可用,异常须携 scope 供调用方定位(issue #7 §3.3)
|
||||
self._scope = scope
|
||||
|
||||
async def try_enter(self, source: SourceConfig, owner: str) -> GateDecision:
|
||||
try:
|
||||
return await self._gate.try_enter(source.name, owner)
|
||||
except GovernanceBackendError:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GovernanceBackendError(f"熔断后端故障(try_enter): {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端故障(try_enter): {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def record_success(
|
||||
self, entry: GateDecision, *, count_attempt: bool = True
|
||||
) -> GateUpdate:
|
||||
try:
|
||||
return await self._gate.record_success(entry, count_attempt=count_attempt)
|
||||
except GovernanceBackendError:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GovernanceBackendError(f"熔断后端故障(record_success): {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端故障(record_success): {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def record_failure(
|
||||
self, entry: GateDecision, reason: str, force_open: bool
|
||||
) -> GateUpdate:
|
||||
try:
|
||||
return await self._gate.record_failure(entry, reason, force_open)
|
||||
except GovernanceBackendError:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GovernanceBackendError(f"熔断后端故障(record_failure): {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端故障(record_failure): {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def release_probe(self, entry: GateDecision) -> GateUpdate:
|
||||
try:
|
||||
return await self._gate.release_probe(entry)
|
||||
except GovernanceBackendError:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GovernanceBackendError(f"熔断后端故障(release_probe): {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端故障(release_probe): {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
|
||||
try:
|
||||
return await self._gate.retry_after_s(sources)
|
||||
except GovernanceBackendError:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GovernanceBackendError(f"熔断后端故障(retry_after_s): {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端故障(retry_after_s): {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
@@ -17,15 +17,65 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.types import ChatRequest, LLMResponse
|
||||
from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from polygateway.ports import CacheBackend, CallNext, StructuredOutputStrategy
|
||||
|
||||
_KEY_PREFIX = "pgw:cache:"
|
||||
_RESPONSE_FIELDS = {f.name for f in dataclasses.fields(LLMResponse)}
|
||||
|
||||
|
||||
def _coerce_observation(raw: Any) -> ThinkingObservation:
|
||||
"""缓存里的三态取值 → 枚举;域外取值降级为 `UNKNOWN`,**不作废整条缓存**。
|
||||
|
||||
方向选择的理由: `_rehydrate` 对 JSON 里的**新字段**已经是宽容的(先按
|
||||
`_RESPONSE_FIELDS` 过滤),对同一字段的**新取值**却不该是致命的。真实场景是
|
||||
多个项目共用一个 Redis,先升级的那个写入了本版没有的取值,未升级的项目若把
|
||||
这些条目判成未命中,就会每次真打网关、随后覆写回旧值,两个版本互相打对方的
|
||||
缓存(表现是命中率莫名腰斩,而通用的"重建失败"文案给不出任何线索)。一个纯
|
||||
可观测性字段不该有能力废掉内容完好的缓存响应——"整条作废"留给真正破坏内容
|
||||
完整性的失败(JSON 坏了、结构化重建不过)。
|
||||
|
||||
降级到 `UNKNOWN` 而不是别的态: 它的语义恰好就是"本次判不出来",对一个本库
|
||||
读不懂的取值,这是唯一诚实的说法。
|
||||
"""
|
||||
try:
|
||||
return ThinkingObservation(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"缓存条目的 thinking_observation 取值 {!r} 不在本版取值域内(多半由更新版本的"
|
||||
"进程写入),已降级为 UNKNOWN;响应内容照常复活——可观测性字段不作废缓存",
|
||||
raw,
|
||||
)
|
||||
return ThinkingObservation.UNKNOWN
|
||||
|
||||
|
||||
def _coerce_applied_effort(raw: Any) -> Effort | None:
|
||||
"""缓存里的档位字符串 → 枚举;域外取值降级为 `None`,**不作废整条缓存**。
|
||||
|
||||
与 `_coerce_observation` 同源同向,理由逐条相同: 多项目共用一个 Redis 时,
|
||||
先升级的进程可能写入本版没有的档位名,未升级的进程若把这些条目判成未命中,
|
||||
两个版本就会互相打对方的缓存。归因字段不该有能力废掉内容完好的响应。
|
||||
|
||||
降级到 `None` 而不是别的档: 它的语义是"库不知道这次跑在哪档",对一个读不懂
|
||||
的取值这是唯一诚实的说法——随便挑一档等于替上游声称了一件它没说过的事。
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return Effort(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"缓存条目的 applied_effort 取值 {!r} 不在本版档位词汇内(多半由更新版本的"
|
||||
"进程写入),已降级为 None;响应内容照常复活——归因字段不作废缓存",
|
||||
raw,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
|
||||
|
||||
@@ -50,9 +100,28 @@ def _digest_part(part: Any) -> Any:
|
||||
|
||||
|
||||
def build_cache_key(
|
||||
model_fingerprint: str, messages: list[dict[str, Any]], namespace: str, salt: str | None
|
||||
model_fingerprint: str,
|
||||
messages: list[dict[str, Any]],
|
||||
namespace: str,
|
||||
salt: str | None,
|
||||
*,
|
||||
sampling: Mapping[str, Any] | None = None,
|
||||
reasoning_effort: Effort | None = None,
|
||||
) -> str:
|
||||
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。"""
|
||||
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
|
||||
|
||||
`sampling` 仅**非空**时参与(与 salt 的"仅非 None"不同——空串是有意义的
|
||||
salt,而空采样参数与不传无语义差别)。它必须进 key: 否则同 messages 跑 5 个
|
||||
seed 会全部命中第一次的响应,标准差恒为 0 且不报错(issue #4 决策 C)。
|
||||
|
||||
`reasoning_effort` 是**请求级**档位(issue #20),仅非 `None` 时参与。它不能靠
|
||||
`model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,一次调用改档位不会
|
||||
让它变一个字节;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4
|
||||
「5 个 seed 全命中同一响应」的逐字翻版。
|
||||
|
||||
判据用 `is not None` 而非真值: `Effort.NONE`(明确要求不推理)与 `None`
|
||||
(不表态)语义不同——前者拿到的是没有推理过程的响应,合并即毒化。
|
||||
"""
|
||||
key_obj: dict[str, Any] = {
|
||||
"model": model_fingerprint,
|
||||
"messages": digest_messages(messages),
|
||||
@@ -60,6 +129,10 @@ def build_cache_key(
|
||||
}
|
||||
if salt is not None:
|
||||
key_obj["salt"] = salt
|
||||
if sampling:
|
||||
key_obj["sampling"] = dict(sampling)
|
||||
if reasoning_effort is not None:
|
||||
key_obj["reasoning_effort"] = str(reasoning_effort)
|
||||
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
|
||||
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -92,7 +165,18 @@ class CacheMW:
|
||||
|
||||
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
|
||||
namespace = request.cache_namespace or self._namespace
|
||||
key = build_cache_key(self._fingerprint, request.messages, namespace, request.cache_salt)
|
||||
# 读 sampling 而非 overlay: 语义明确,且不依赖"CacheMW 恰在 StructuredMW
|
||||
# 外侧"这一层序巧合——结构化注入不该改变缓存身份(设计决策 C)
|
||||
key = build_cache_key(
|
||||
self._fingerprint,
|
||||
request.messages,
|
||||
namespace,
|
||||
request.cache_salt,
|
||||
sampling=request.sampling,
|
||||
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
|
||||
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
|
||||
reasoning_effort=request.reasoning_effort,
|
||||
)
|
||||
cached = await self._safe_get(key)
|
||||
if cached is not None:
|
||||
hit = self._rehydrate(cached, request)
|
||||
@@ -110,6 +194,13 @@ class CacheMW:
|
||||
data = json.loads(raw)
|
||||
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
|
||||
structured_data = self._rebuild_structured(fields.get("content", ""), request)
|
||||
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
|
||||
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
|
||||
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
|
||||
if "thinking_observation" in fields:
|
||||
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
|
||||
if "applied_effort" in fields:
|
||||
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
|
||||
fields.update(
|
||||
cache_hit=True,
|
||||
latency_ms=0,
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from polygateway.errors import GovernanceBackendError
|
||||
from polygateway.errors import GovernanceBackendError, SourceNotConfiguredError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from polygateway.ports import Permit, RateLimiter
|
||||
@@ -18,37 +18,47 @@ if TYPE_CHECKING:
|
||||
class QuotaGate:
|
||||
"""RetryMW 面向限流后端的唯一入口;包装一切后端异常。"""
|
||||
|
||||
def __init__(self, limiter: RateLimiter) -> None:
|
||||
def __init__(self, limiter: RateLimiter, *, scope: str) -> None:
|
||||
self._limiter = limiter
|
||||
# 后端故障即 scope 级不可用,异常须携 scope 供调用方定位(issue #7 §3.3)
|
||||
self._scope = scope
|
||||
|
||||
async def try_acquire(self, source: SourceConfig) -> Permit | None:
|
||||
try:
|
||||
return await self._limiter.try_acquire(source.name, source.est_tokens)
|
||||
except GovernanceBackendError:
|
||||
return await self._limiter.try_acquire(source.name, source.effective_est_tokens())
|
||||
except (GovernanceBackendError, SourceNotConfiguredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GovernanceBackendError(f"限流后端故障(try_acquire): {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端故障(try_acquire): {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def stats(self, source: SourceConfig) -> SourceStats:
|
||||
try:
|
||||
return await self._limiter.source_stats(source.name)
|
||||
except GovernanceBackendError:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GovernanceBackendError(f"限流后端故障(source_stats): {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端故障(source_stats): {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def mark_progress(self) -> None:
|
||||
try:
|
||||
await self._limiter.mark_progress()
|
||||
except GovernanceBackendError:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GovernanceBackendError(f"限流后端故障(mark_progress): {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端故障(mark_progress): {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def progress_age_s(self) -> float:
|
||||
try:
|
||||
return await self._limiter.progress_age_s()
|
||||
except GovernanceBackendError:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GovernanceBackendError(f"限流后端故障(progress_age_s): {exc}") from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端故障(progress_age_s): {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
+125
-167
@@ -11,6 +11,7 @@ httpx 是库的核心依赖而非实现层内部件,不违反"middleware 只依
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
@@ -22,23 +23,24 @@ from loguru import logger
|
||||
|
||||
from polygateway.errors import (
|
||||
AllSourcesExhausted,
|
||||
CircuitOpenError,
|
||||
GovernanceBackendError,
|
||||
PolyGatewayError,
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
SourceNotConfiguredError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.ports import OutcomeAwareSelector
|
||||
from polygateway.sources import AdaptivePacer, SourceCooldownMemo
|
||||
from polygateway.sources import AdaptivePacer
|
||||
from polygateway.streaming import StreamLivenessTimeout
|
||||
from polygateway.types import LLMResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
|
||||
from polygateway.ports import (
|
||||
GateDecision,
|
||||
@@ -48,6 +50,7 @@ if TYPE_CHECKING:
|
||||
SourceSelector,
|
||||
Transport,
|
||||
)
|
||||
from polygateway.sources import SourceCooldownMemo
|
||||
from polygateway.types import (
|
||||
BackpressurePolicy,
|
||||
ChatRequest,
|
||||
@@ -73,68 +76,63 @@ def backoff_delay(
|
||||
return max(delay, retry_after)
|
||||
|
||||
|
||||
def _demote_call_failures(
|
||||
ordered: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float] | None,
|
||||
) -> list[SourceConfig]:
|
||||
"""调用内降权(设计 §3.3/§3.36): 失败 ≥2 次且存在可信替代才让位。
|
||||
class _Attempt:
|
||||
"""一次尝试的计时句柄;`refund()` 把它退还给 stall 账(见 `StallClock`)。"""
|
||||
|
||||
可信替代 = 某未失败候选 health ≥ 0.5 × 失败源 health——异构池里健康源
|
||||
偶发失败不该被推向已知坏源(第三轮教训: 期望成功率 83% vs 10%)。
|
||||
无健康视图(round_robin 等)保持无条件降权(冷启动保护)。
|
||||
__slots__ = ("productive",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.productive = True
|
||||
|
||||
def refund(self) -> None:
|
||||
"""该次尝试不消耗重试预算(429),故其耗时归 stall 治理而非重试治理。"""
|
||||
self.productive = False
|
||||
|
||||
|
||||
class StallClock:
|
||||
"""调用级 stall 计时器: 只累计非生产性等待(issue #8 设计 §3.1)。
|
||||
|
||||
**划分依据是"谁消耗重试预算"**,不是"是否发出了请求"。消耗 `max_attempts`
|
||||
的时间已被重试预算治理,从 stall 账扣除;不消耗它的时间无人治理,归 stall。
|
||||
两者重叠计费正是 issue #8 的根因: stall 预算(默认 300s)小于重试预算
|
||||
(3 × timeout_s),必然先耗尽,于是重试预算在超时场景下永远用不上。
|
||||
|
||||
"生产性"的边界即 `_attempt` 的边界,含该次尝试的记账与遥测收尾——它们是
|
||||
"尝试已有结论"之后的动作,不是在等待重试机会;把它们计入 stall 会让遥测
|
||||
抖动参与判死。
|
||||
|
||||
**例外: 429 尝试须 `refund()`**。429 免重试预算(饱和期等待而非死亡),若其
|
||||
耗时又算生产性,就掉进两个预算的缝隙——排队型网关持满 timeout 才回 429 时,
|
||||
每轮只有退避那一两秒进 stall 账,调用可挂满 `stall_window/backoff_base` 轮
|
||||
(实测 timeout=300/base=2 时达 25 小时)。退还后缝隙闭合。
|
||||
|
||||
每次调用创建一个实例。严禁提升为实例属性: `_entered_at` 会固定在进程启动
|
||||
时刻,使 `stalled_s()` 随进程运行时长单调增长,最终所有调用被误判 stalled。
|
||||
模块级共享单元, EmbeddingClient 与 OcrClient 复用(同 `backoff_delay`)。
|
||||
"""
|
||||
demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2]
|
||||
if not demoted or len(demoted) == len(ordered):
|
||||
return ordered
|
||||
if health is None:
|
||||
return _move_to_tail(ordered, demoted)
|
||||
return _health_gated_reorder(ordered, demoted, attempt_fails, health)
|
||||
|
||||
__slots__ = ("_now", "_entered_at", "_productive_s")
|
||||
|
||||
def _move_to_tail(ordered: list[SourceConfig], demoted: list[SourceConfig]) -> list[SourceConfig]:
|
||||
"""无健康视图: 无条件移尾(冷启动保护原语义)。"""
|
||||
names = {d.name for d in demoted}
|
||||
return [s for s in ordered if s.name not in names] + demoted
|
||||
def __init__(self, now: Callable[[], float]) -> None:
|
||||
self._now = now
|
||||
self._entered_at = now()
|
||||
self._productive_s = 0.0
|
||||
|
||||
def stalled_s(self) -> float:
|
||||
"""非生产性等待累计秒数 = 调用总耗时 - 消耗重试预算的时间。"""
|
||||
return self._now() - self._entered_at - self._productive_s
|
||||
|
||||
def _health_gated_reorder(
|
||||
ordered: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""健康门槛降权: 无可信替代则原地重试;有则插到可信替代之后。"""
|
||||
demoted = _credible_demotions(ordered, demoted, attempt_fails, health)
|
||||
if not demoted:
|
||||
return ordered
|
||||
names = {d.name for d in demoted}
|
||||
rest = [s for s in ordered if s.name not in names]
|
||||
return _insert_after_credible(rest, demoted, health)
|
||||
|
||||
|
||||
def _insert_after_credible(
|
||||
rest: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""插入位置(第四轮教训): 被降权源排在可信替代之后、不可信源之前——
|
||||
可信替代被限流闸/熔断跳过时,下一候选是失败源本身而非垃圾源。"""
|
||||
bar = 0.5 * max(health(d.name) for d in demoted)
|
||||
credible = [s for s in rest if health(s.name) >= bar]
|
||||
junk = [s for s in rest if health(s.name) < bar]
|
||||
return credible + demoted + junk
|
||||
|
||||
|
||||
def _credible_demotions(
|
||||
ordered: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""健康门槛过滤: 仅当存在"健康分 ≥ 失败源一半"的未失败候选,让位才有意义。"""
|
||||
alts = [o for o in ordered if attempt_fails.get(o.name, 0) < 2]
|
||||
return [s for s in demoted if any(health(o.name) >= 0.5 * health(s.name) for o in alts)]
|
||||
@contextlib.asynccontextmanager
|
||||
async def attempting(self) -> AsyncIterator[_Attempt]:
|
||||
"""包裹一次真实尝试,其耗时默认记为生产性(除非被 `refund()`)。"""
|
||||
handle = _Attempt()
|
||||
started = self._now()
|
||||
try:
|
||||
yield handle
|
||||
finally:
|
||||
# 只做算术与取值, 不吞任何异常——CancelledError 逐字穿透(库铁律)
|
||||
if handle.productive:
|
||||
self._productive_s += self._now() - started
|
||||
|
||||
|
||||
def _failure_reason(exc: PolyGatewayError) -> str:
|
||||
@@ -156,6 +154,15 @@ class _Failed:
|
||||
immediate: bool
|
||||
|
||||
|
||||
def _is_rate_limited(outcome: LLMResponse | _Failed) -> bool:
|
||||
"""429 = 服务端调度指令(gRPC pushback 语义,迭代 5): 按 Retry-After 退避但
|
||||
**不消耗重试预算**——饱和窗口里等待而非死亡;其余失败照常计数。
|
||||
|
||||
因其免重试预算,该次尝试的耗时必须归 stall 治理(`StallClock` 的 refund)。
|
||||
"""
|
||||
return isinstance(outcome, _Failed) and _failure_reason(outcome.exc) == "rate_limited"
|
||||
|
||||
|
||||
class RetryMW:
|
||||
"""尝试编排器;时钟/睡眠/随机全部注入,纯确定性可测(P6)。"""
|
||||
|
||||
@@ -171,6 +178,7 @@ class RetryMW:
|
||||
retry: RetryPolicy,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
circuit_open: str = "fail_fast",
|
||||
cooldown_memo: SourceCooldownMemo | None = None,
|
||||
pacer: AdaptivePacer | None = None,
|
||||
emitter: object | None = None,
|
||||
@@ -178,27 +186,39 @@ class RetryMW:
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
rng: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
if quota_full not in ("wait", "fail_fast"):
|
||||
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
|
||||
self._scope = scope
|
||||
self._sources = list(sources)
|
||||
self._selector = selector
|
||||
self._quota = QuotaGate(limiter)
|
||||
self._breaker = BreakerGate(gate)
|
||||
# 记账写回与 pacer 结算仍在 `_attempt` 内,故这三者由本类持有并与
|
||||
# `SourceAdmission` **共享同一实例**(pacer 有在途计数,不可分裂)
|
||||
self._quota = QuotaGate(limiter, scope=self._scope)
|
||||
self._breaker = BreakerGate(gate, scope=self._scope)
|
||||
self._transport = transport
|
||||
self._retry = retry
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._memo = cooldown_memo or SourceCooldownMemo(now=now)
|
||||
# M2.5: 选源器可选健康喂数端口,构造期 isinstance 判定一次(设计 §3.2)
|
||||
self._outcome_sink = selector if isinstance(selector, OutcomeAwareSelector) else None
|
||||
self._health_view = self._outcome_sink.health if self._outcome_sink else None
|
||||
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
|
||||
self._pacer = pacer or AdaptivePacer(ceiling=64.0)
|
||||
self._emitter = emitter
|
||||
self._now = now
|
||||
self._sleep = sleep
|
||||
self._rng = rng
|
||||
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
|
||||
self._admission = SourceAdmission(
|
||||
scope=self._scope,
|
||||
sources=self._sources,
|
||||
selector=selector,
|
||||
quota=self._quota,
|
||||
breaker=self._breaker,
|
||||
backpressure=backpressure,
|
||||
quota_full=quota_full,
|
||||
circuit_open=circuit_open,
|
||||
memo=cooldown_memo,
|
||||
pacer=self._pacer,
|
||||
health_view=self._outcome_sink.health if self._outcome_sink else None,
|
||||
now=now,
|
||||
sleep=sleep,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
async def __call__(self, request: ChatRequest) -> LLMResponse:
|
||||
"""执行治理调用;scope 级失败按 §6.1 携结构化字段上抛。"""
|
||||
@@ -208,28 +228,31 @@ class RetryMW:
|
||||
reasons: dict[str, str] = {}
|
||||
# 调用内失败计数(设计 §3.3): 局部状态,调用结束即弃;严禁实例属性(并发共享)
|
||||
attempt_fails: dict[str, int] = {}
|
||||
entered_at = self._now() # 调用级累计计时,循环内不重置(CHS governance.py:207)
|
||||
# 调用级累计计时,循环内不重置(CHS governance.py:207);issue #8 起只计
|
||||
# 非生产性等待——真实尝试由重试预算治理,不再重复烧 stall 预算
|
||||
clock = StallClock(self._now)
|
||||
while True:
|
||||
# 调用级时间上限(迭代 5): 429 免预算后的兜底,防饱和期无限循环。
|
||||
# 与 _on_no_runnable 同款双条件(CHS 口径): 本地超窗且全局无进展才判死
|
||||
stall = self._bp.stall_window_s
|
||||
if self._now() - entered_at > stall and await self._quota.progress_age_s() > stall:
|
||||
# 调用级时间上限(迭代 5): 429 免预算后的兜底,防饱和期无限循环
|
||||
if await self._admission.stalled(clock):
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=self._retry.backoff_base_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
picked, gate_rejections = await self._pick_runnable(reasons, attempt_fails)
|
||||
picked, gate_rejections = await self._admission.pick(reasons, attempt_fails)
|
||||
if picked is None:
|
||||
await self._on_no_runnable(gate_rejections, reasons, entered_at)
|
||||
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
|
||||
continue
|
||||
outcome = await self._attempt(request, *picked, reasons, attempt_fails)
|
||||
async with clock.attempting() as attempt:
|
||||
outcome = await self._attempt(request, *picked, reasons, attempt_fails)
|
||||
rate_limited = _is_rate_limited(outcome)
|
||||
if rate_limited:
|
||||
# 免了重试预算就得进 stall 账,否则这段耗时无人治理(见 StallClock)
|
||||
attempt.refund()
|
||||
if isinstance(outcome, LLMResponse):
|
||||
return outcome
|
||||
# 429 = 服务端调度指令(gRPC pushback 语义,迭代 5): 按 Retry-After
|
||||
# 退避但不消耗重试预算——饱和窗口里等待而非死亡;其余失败照常计数
|
||||
if _failure_reason(outcome.exc) != "rate_limited":
|
||||
if not rate_limited:
|
||||
fails += 1
|
||||
if fails >= self._retry.max_attempts:
|
||||
raise AllSourcesExhausted(
|
||||
@@ -241,78 +264,6 @@ class RetryMW:
|
||||
if not outcome.immediate:
|
||||
await self._sleep(self._backoff_delay(max(fails, 1), outcome.exc))
|
||||
|
||||
# —— 选源与准入(CHS _pick_runnable 120-167)——
|
||||
|
||||
async def _pick_runnable(
|
||||
self, reasons: dict[str, str], attempt_fails: dict[str, int]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
||||
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
||||
gate_rejections = 0
|
||||
ordered = _demote_call_failures(
|
||||
self._selector.order(self._sources, stats), attempt_fails, self._health_view
|
||||
)
|
||||
for cand in ordered:
|
||||
if self._memo.active(cand.name):
|
||||
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "cooldown"
|
||||
continue
|
||||
if not self._pacer.admit(cand.name):
|
||||
# AIMD 超限: 不计 gate_rejections → 走 quota-wait 排队,不误判熔断
|
||||
reasons.setdefault(cand.name, "adaptive_paced")
|
||||
continue
|
||||
permit = await self._quota.try_acquire(cand)
|
||||
if permit is None:
|
||||
reasons.setdefault(cand.name, "rate_limited")
|
||||
continue
|
||||
entry = None
|
||||
try:
|
||||
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
|
||||
finally:
|
||||
# try_enter 未归还 entry(异常/取消)→ 释放已占 permit,不吞任何异常
|
||||
if entry is None:
|
||||
await self._settle_and_release(permit, 0)
|
||||
if entry.allowed:
|
||||
self._pacer.enter(cand.name)
|
||||
return (cand, permit, entry), gate_rejections
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "circuit_open"
|
||||
# 开路源本地记冷却,避免每轮白烧 RPM 探测(CHS governance.py:107)
|
||||
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
await self._settle_and_release(permit, 0)
|
||||
return None, gate_rejections
|
||||
|
||||
async def _on_no_runnable(
|
||||
self, gate_rejections: int, reasons: dict[str, str], entered_at: float
|
||||
) -> None:
|
||||
if gate_rejections == len(self._sources):
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise CircuitOpenError(
|
||||
scope=self._scope,
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
if self._quota_full == "fail_fast":
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="quota_exhausted",
|
||||
retry_after_s=self._bp.poll_interval_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
# 双条件 stall 判死(CHS governance.py:270-281): 本地累计等待与全局
|
||||
# 无进展**同时**超窗才判死——本地 monotonic 与后端时钟刻意不混用。
|
||||
stall = self._bp.stall_window_s
|
||||
if self._now() - entered_at > stall and await self._quota.progress_age_s() > stall:
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
# jitter ∈ [0.5p, 1.0p] 防惊群(CHS governance.py:283-285)
|
||||
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
|
||||
|
||||
# —— 单次尝试(CHS run 200-268)——
|
||||
|
||||
async def _attempt(
|
||||
@@ -334,8 +285,16 @@ class RetryMW:
|
||||
stream=request.stream,
|
||||
overlay=request.overlay,
|
||||
call_id=call_id,
|
||||
# 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport
|
||||
# 自己按选中的源解析,两者在 effective_effort 里汇合)
|
||||
reasoning_effort=request.reasoning_effort,
|
||||
)
|
||||
actual = result.prompt_tokens + result.completion_tokens
|
||||
if result.usage_source == "unavailable":
|
||||
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
|
||||
# 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9)
|
||||
actual = source.effective_est_tokens()
|
||||
else:
|
||||
actual = result.prompt_tokens + result.completion_tokens
|
||||
await self._record_quietly(self._breaker.record_success(entry))
|
||||
await self._record_quietly(self._quota.mark_progress())
|
||||
self._feed_outcome(source.name, ok=True)
|
||||
@@ -367,12 +326,13 @@ class RetryMW:
|
||||
self._pacer.on_backpressure(source.name)
|
||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||
if not dead:
|
||||
actual = source.est_tokens # 保守: 失败请求可能已被网关计费(CHS 同款)
|
||||
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
|
||||
actual = source.effective_est_tokens()
|
||||
await self._emit(request, source, call_id, started, error=exc)
|
||||
return _Failed(exc, immediate=dead)
|
||||
finally:
|
||||
self._pacer.leave(source.name)
|
||||
await self._settle_and_release(permit, actual)
|
||||
await settle_and_release(permit, actual)
|
||||
|
||||
async def _on_rejected(
|
||||
self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision
|
||||
@@ -395,7 +355,7 @@ class RetryMW:
|
||||
await write_back
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except GovernanceBackendError as exc:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
|
||||
logger.warning("治理记账写回降级(不冒泡): {}", exc)
|
||||
|
||||
def _feed_outcome(self, source_name: str, ok: bool) -> None:
|
||||
@@ -430,20 +390,16 @@ class RetryMW:
|
||||
source_name=source.name,
|
||||
cost=None,
|
||||
usage_source=result.usage_source,
|
||||
cached_prompt_tokens=result.cached_prompt_tokens,
|
||||
model_reported=result.model_reported,
|
||||
reasoning_tokens=result.reasoning_tokens,
|
||||
# 裁定归 transport(它才见得到原始信号),本层只搬运不改判
|
||||
thinking_observation=result.thinking_observation,
|
||||
# 同理: 实际档由做注入的那一层裁定(`nearest` 映射后与请求档分叉),
|
||||
# 本层若"顺手"改读 request.reasoning_effort,记的就是从未发出过的档
|
||||
applied_effort=result.applied_effort,
|
||||
)
|
||||
|
||||
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
||||
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
|
||||
try:
|
||||
try:
|
||||
await permit.settle(actual)
|
||||
finally:
|
||||
await permit.release()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("permit 结算/释放失败(不掩盖主异常): {}", exc)
|
||||
|
||||
async def _emit(
|
||||
self,
|
||||
request: ChatRequest,
|
||||
@@ -465,6 +421,8 @@ class RetryMW:
|
||||
latency_ms=int((self._now() - started) * 1000),
|
||||
response=response,
|
||||
error=None if error is None else str(error),
|
||||
# chat 路径是唯一带推理参数的路径,故实发档由这里的响应说了算
|
||||
reasoning_applies=True,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
@@ -12,27 +12,241 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.errors import GatewayUnavailableError, GovernanceBackendError
|
||||
from polygateway.errors import (
|
||||
GatewayUnavailableError,
|
||||
GovernanceBackendError,
|
||||
SourceNotConfiguredError,
|
||||
)
|
||||
from polygateway.middleware.cache import digest_messages
|
||||
from polygateway.thinking import effective_effort
|
||||
from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from polygateway.ports import CallNext, TelemetryRecorder
|
||||
from polygateway.pricing import PricingTable
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||
|
||||
|
||||
class TelemetryEmitter:
|
||||
"""从请求与结果组装 18 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
|
||||
"""把调用方自定义维度定型为 JSON 文本(issue #11);空 dict 落字面量 `'{}'`。
|
||||
|
||||
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
|
||||
`sort_keys=True` 让同一份维度在任意两行里字节一致,可直接等值比对与去重;
|
||||
`ensure_ascii=False` 保留中文原文,避免落库成 `\\uXXXX` 串而无法肉眼审计。
|
||||
|
||||
`allow_nan=False` 是**第二道闸**(主防线是 `types.validate_caller_dimensions`
|
||||
在公共入口的校验): `json.dumps` 默认把 `nan` 写成裸 `NaN` 字面量,那不是合法
|
||||
JSON。这道闸真正的价值在 **SQLite 侧**——PG 的 JSONB 本来就会拒收 `NaN`,而
|
||||
SQLite 的 `meta` 是 TEXT 列**不做任何 JSON 校验**,没有这道闸就会把 `NaN`
|
||||
这种非法 JSON 静默存进去,污染后续一切按 JSON 解析 meta 的分析。
|
||||
|
||||
注意它抛出的 `ValueError` **不会外泄给调用方**: 本函数在 `_record` 的降级
|
||||
`try` 内被求值,异常会被那里的 `except Exception` 接住 → 落 warning、整行
|
||||
遥测丢弃。即入口失守时的真实结果是"警告 + 丢一行",不是"报错给调用方"。
|
||||
"""
|
||||
if not meta:
|
||||
return "{}"
|
||||
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
|
||||
|
||||
|
||||
def _normalize_observation(raw: object) -> str:
|
||||
"""三态裁定 → 落库用的裸 str;不是枚举也不在取值域时降级为 `unknown` 并告警。
|
||||
|
||||
**不写 `raw.value`**: `LLMResponse` 是无运行时校验的 frozen dataclass,下游
|
||||
(尤其迁移期的测试替身)写 `LLMResponse(..., thinking_observation="observed")`
|
||||
完全自然、`==` 比较照常成立,而 `.value` 会当场抛 `AttributeError`,被 `_record`
|
||||
的 `except Exception` 吞成一条泛化 warning —— 丢的不是这一列,是**整行**,而
|
||||
"遥测必录"是铁律。
|
||||
|
||||
域外取值同样只降级不抛: 直接 `ThinkingObservation(raw)` 会抛 `ValueError`,
|
||||
落到同一个 `except` 上、同样丢整行,那只修好了裸 str 一半(口误值对测试替身
|
||||
一样自然)。降级到 `unknown` 是诚实的——库确实判不出这个取值的含义,而单独
|
||||
一条点名取值的 warning 保证它不被掩盖(P5 不许默认值掩盖错误)。
|
||||
"""
|
||||
try:
|
||||
return ThinkingObservation(raw).value
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"thinking_observation 取值 {!r} 不在取值域内,本行降级记为 unknown"
|
||||
"(其余列照常落库);调用方应传 ThinkingObservation 成员",
|
||||
raw,
|
||||
)
|
||||
return ThinkingObservation.UNKNOWN.value
|
||||
|
||||
|
||||
def _normalize_effort(raw: object) -> str | None:
|
||||
"""实际档位 → 落库用的裸 str;不表态与域外取值都落 `NULL`。
|
||||
|
||||
**不写 `raw.value`**,理由与 `_normalize_observation` 逐字相同: `LLMResponse`
|
||||
是无运行时校验的 frozen dataclass,测试替身写 `applied_effort="low"` 完全自然,
|
||||
而 `.value` 会当场抛 `AttributeError`,被 `_record` 的 `except Exception` 吞成
|
||||
一条泛化 warning —— 丢的不是这一列,是**整行**。
|
||||
|
||||
域外取值降级为 `None` 而不抛,方向与 `CacheMW._coerce_applied_effort` 一致
|
||||
(设计 §4.4): 多项目共用一套后端时,更新版本的进程可能带来本版没有的档位名,
|
||||
归因字段不该有能力废掉一整行遥测。降级到 `None` 也是唯一诚实的说法——库确实
|
||||
不知道这次跑在哪档,随便挑一档等于替上游声称了一件它没说过的事。
|
||||
|
||||
注意 `None` 在本列有**两个**来源(不表态 / 读不懂),二者都不可折叠进 `'none'`:
|
||||
`'none'` 是"明确要求不推理",是一次表态。
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return Effort(raw).value
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"推理档位取值 {!r} 不在本版档位词汇内,本行 reasoning_effort 降级记为 NULL"
|
||||
"(其余列照常落库)",
|
||||
raw,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _attempt_effort(
|
||||
*,
|
||||
request: ChatRequest,
|
||||
source: SourceConfig,
|
||||
response: LLMResponse | None,
|
||||
applies: bool,
|
||||
) -> str | None:
|
||||
"""一次尝试该记哪一档: 成功读**实发档**,失败退回**请求档**(设计 §6)。
|
||||
|
||||
成功行一律读 `response.applied_effort` 而**绝不重算**: 源上开了
|
||||
`EFFORT_FALLBACK=nearest` 时,请求 `medium` 而模型只有 low/high/max,实发的是
|
||||
`low`;此处重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出
|
||||
过的分组下——而两个值在没开映射的源上恒等,这个错在本地跑不出来。
|
||||
|
||||
失败尝试没有响应,实发档无从得知,故退回请求档并**接受这层含义差别**: 开了映射
|
||||
的源上,成功行是映射后的档、失败行是请求档,两种行不是同一把尺子。仍然记而不是
|
||||
留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,
|
||||
这类行记的正是**被拒绝的那一档**——"哪一档配错了"是压测与排障要的信号。
|
||||
|
||||
回落走 `effective_effort` 而非裸读两个字段: `enable_thinking` 也是一次表态
|
||||
(语法糖),漏掉它就会把一次明确要求推理的调用记成"没表态"。
|
||||
"""
|
||||
if not applies:
|
||||
return None
|
||||
if response is not None:
|
||||
return _normalize_effort(response.applied_effort)
|
||||
return _normalize_effort(
|
||||
effective_effort(
|
||||
request_effort=request.reasoning_effort,
|
||||
source_effort=source.reasoning_effort,
|
||||
enable_thinking=source.enable_thinking,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cap_text(text: str, cap: int | None) -> str:
|
||||
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
||||
if cap is None or len(text) <= cap:
|
||||
return text
|
||||
return f"{text[:cap]}…(略 {len(text) - cap} 字)"
|
||||
|
||||
|
||||
def _cap_part(part: Any, cap: int) -> Any:
|
||||
"""多模态 part 的文本截断;非 `type == "text"` 的 part 原样返回同一对象。"""
|
||||
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||
return {**part, "text": _cap_text(part["text"], cap)}
|
||||
return part
|
||||
|
||||
|
||||
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
|
||||
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
|
||||
|
||||
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
|
||||
|
||||
**只产出新对象,严禁就地修改**: `digest_messages` 对 content 非 list 的消息是
|
||||
原样透传**同一个 dict 对象**(`cache.py:43`),多模态里非 image_url 的 part 同理。
|
||||
就地改它会一并污染调用方持有的 messages、后续重试尝试的请求体与缓存写入的 key,
|
||||
且全程无任何报错。
|
||||
"""
|
||||
if cap is None:
|
||||
return messages
|
||||
capped: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
capped.append({**msg, "content": _cap_text(content, cap)})
|
||||
elif isinstance(content, list):
|
||||
capped.append({**msg, "content": [_cap_part(part, cap) for part in content]})
|
||||
else:
|
||||
capped.append(msg)
|
||||
return capped
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AttemptUsage:
|
||||
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
|
||||
|
||||
存在的理由是把 `emit_attempt` 里逐字段重复的 `X if response else Y` 收敛为
|
||||
一处判定——十处三元把该方法推到圈复杂度 C,而它们表达的是同一件事。
|
||||
"""
|
||||
|
||||
response_text: str = ""
|
||||
thinking: str = ""
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
usage_source: str = "unavailable"
|
||||
ttft_ms: float | None = None
|
||||
max_inter_token_ms: float | None = None
|
||||
cached_prompt_tokens: int | None = None
|
||||
model_reported: str | None = None
|
||||
reasoning_tokens: int | None = None
|
||||
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
|
||||
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
|
||||
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||
|
||||
@classmethod
|
||||
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
||||
"""从响应取用量;`None`(失败尝试)返回全默认视图。"""
|
||||
if response is None:
|
||||
return cls()
|
||||
return cls(
|
||||
response_text=response.content,
|
||||
thinking=response.thinking,
|
||||
prompt_tokens=response.prompt_tokens,
|
||||
completion_tokens=response.completion_tokens,
|
||||
usage_source=response.usage_source,
|
||||
ttft_ms=response.ttft_ms,
|
||||
max_inter_token_ms=response.max_inter_token_ms,
|
||||
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||
model_reported=response.model_reported,
|
||||
reasoning_tokens=response.reasoning_tokens,
|
||||
thinking_observation=response.thinking_observation,
|
||||
)
|
||||
|
||||
|
||||
class TelemetryEmitter:
|
||||
"""从请求与结果组装 26 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
recorder: TelemetryRecorder,
|
||||
*,
|
||||
pricing: PricingTable | None = None,
|
||||
text_cap: int | None,
|
||||
) -> None:
|
||||
"""`text_cap` 无默认值是有意的: 它是关键行为参数,漏传即静默改变落库正文。
|
||||
|
||||
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。
|
||||
同理,值域校验也放在这一处: 三个 Client 的 `text_cap` 全部汇流到这里,
|
||||
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
|
||||
条公共装配路——`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
|
||||
"""
|
||||
if text_cap is not None and text_cap <= 0:
|
||||
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
|
||||
self._recorder = recorder
|
||||
self._pricing = pricing
|
||||
self._text_cap = text_cap
|
||||
|
||||
async def emit_attempt(
|
||||
self,
|
||||
@@ -43,24 +257,45 @@ class TelemetryEmitter:
|
||||
latency_ms: int,
|
||||
response: LLMResponse | None,
|
||||
error: str | None,
|
||||
reasoning_applies: bool,
|
||||
) -> None:
|
||||
"""逐次尝试记录(RetryMW 调用);失败尝试 usage 按 estimated 记 0。"""
|
||||
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
|
||||
|
||||
`reasoning_applies` 声明**这条调用路径有没有推理语义**: chat 路径为
|
||||
`True`,embedding / OCR 路径为 `False`。它不能由 emitter 自己推断——三条路径
|
||||
共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源
|
||||
会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的
|
||||
档。**不设默认值**: 与 `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传
|
||||
当场 TypeError,好过被静默当成"没表态"。
|
||||
"""
|
||||
usage = _AttemptUsage.of(response)
|
||||
await self._record(
|
||||
request=request,
|
||||
call_id=call_id,
|
||||
model=source.model,
|
||||
provider=source.provider,
|
||||
source_name=source.name,
|
||||
response_text=response.content if response else "",
|
||||
thinking=response.thinking if response else "",
|
||||
prompt_tokens=response.prompt_tokens if response else 0,
|
||||
completion_tokens=response.completion_tokens if response else 0,
|
||||
usage_source=response.usage_source if response else "estimated",
|
||||
response_text=usage.response_text,
|
||||
thinking=usage.thinking,
|
||||
prompt_tokens=usage.prompt_tokens,
|
||||
completion_tokens=usage.completion_tokens,
|
||||
usage_source=usage.usage_source,
|
||||
latency_ms=latency_ms,
|
||||
ttft_ms=response.ttft_ms if response else None,
|
||||
max_inter_token_ms=response.max_inter_token_ms if response else None,
|
||||
ttft_ms=usage.ttft_ms,
|
||||
max_inter_token_ms=usage.max_inter_token_ms,
|
||||
cache_hit=False,
|
||||
error=error,
|
||||
cached_prompt_tokens=usage.cached_prompt_tokens,
|
||||
model_reported=usage.model_reported,
|
||||
reasoning_tokens=usage.reasoning_tokens,
|
||||
thinking_observation=usage.thinking_observation,
|
||||
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
|
||||
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
|
||||
tenant_id=request.tenant_id,
|
||||
meta=request.meta,
|
||||
reasoning_effort=_attempt_effort(
|
||||
request=request, source=source, response=response, applies=reasoning_applies
|
||||
),
|
||||
)
|
||||
|
||||
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
|
||||
@@ -81,6 +316,24 @@ class TelemetryEmitter:
|
||||
max_inter_token_ms=None,
|
||||
cache_hit=True,
|
||||
error=None,
|
||||
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
|
||||
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
|
||||
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||
model_reported=response.model_reported,
|
||||
reasoning_tokens=response.reasoning_tokens,
|
||||
# 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果
|
||||
thinking_observation=response.thinking_observation,
|
||||
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
||||
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
# 与上面的 model/prompt_tokens 相反,维度读 request 而非 response:
|
||||
# 维度回答的是"本次调用由谁发起",不是历史那次。读历史会把本次调用
|
||||
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
|
||||
tenant_id=request.tenant_id,
|
||||
meta=request.meta,
|
||||
# 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射
|
||||
# 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的)
|
||||
reasoning_effort=_normalize_effort(request.reasoning_effort),
|
||||
)
|
||||
|
||||
async def emit_terminal_failure(
|
||||
@@ -97,12 +350,24 @@ class TelemetryEmitter:
|
||||
thinking="",
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
usage_source="estimated",
|
||||
usage_source="unavailable",
|
||||
latency_ms=latency_ms,
|
||||
ttft_ms=None,
|
||||
max_inter_token_ms=None,
|
||||
cache_hit=False,
|
||||
error=error,
|
||||
cached_prompt_tokens=None,
|
||||
model_reported=None,
|
||||
reasoning_tokens=None,
|
||||
# 无响应可言,故裁不出结果;UNKNOWN 正是"观测不到"本身,不是伪装的"没推理"
|
||||
thinking_observation=ThinkingObservation.UNKNOWN,
|
||||
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
|
||||
tenant_id=request.tenant_id,
|
||||
meta=request.meta,
|
||||
# 可能根本没选出源,故与 sampling 同样只取请求档
|
||||
reasoning_effort=_normalize_effort(request.reasoning_effort),
|
||||
)
|
||||
|
||||
async def _record(
|
||||
@@ -123,18 +388,43 @@ class TelemetryEmitter:
|
||||
max_inter_token_ms: float | None,
|
||||
cache_hit: bool,
|
||||
error: str | None,
|
||||
cached_prompt_tokens: int | None,
|
||||
model_reported: str | None,
|
||||
sampling: str | None,
|
||||
reasoning_tokens: int | None,
|
||||
# issue #16: 枚举形态进来,归一化成裸 str 后才下沉(收口在 `_record` 内)。
|
||||
# 注解是契约,但 `LLMResponse` 无运行时校验,故 `_normalize_observation`
|
||||
# 仍按外部输入防御——违约的代价不该是丢掉整行遥测
|
||||
thinking_observation: ThinkingObservation,
|
||||
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any],
|
||||
# issue #20: 已由各入口按自己的口径定型成裸 str/None(口径差别见三个入口的
|
||||
# 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把
|
||||
# "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制
|
||||
reasoning_effort: str | None,
|
||||
) -> None:
|
||||
try:
|
||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||
# 失败/终态行 None;未注入价格表 = 恒 None(M1 现状)
|
||||
if cache_hit:
|
||||
cost: float | None = 0.0
|
||||
elif usage_source == "unavailable":
|
||||
# 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。
|
||||
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
||||
cost = None
|
||||
elif error is None and model and self._pricing is not None:
|
||||
cost = self._pricing.cost(model, prompt_tokens, completion_tokens)
|
||||
cost = self._pricing.cost(
|
||||
model, prompt_tokens, completion_tokens, cached_prompt_tokens
|
||||
)
|
||||
else:
|
||||
cost = None
|
||||
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
|
||||
messages_json = json.dumps(digest_messages(request.messages), ensure_ascii=False)
|
||||
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12);
|
||||
# 截断只发生在摘要之后、序列化之前的遥测分支,缓存路径不经过它(issue #12)
|
||||
messages_json = json.dumps(
|
||||
_cap_messages(digest_messages(request.messages), self._text_cap),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
await self._recorder.record_llm_call(
|
||||
call_id=call_id,
|
||||
parent_call_id=request.parent_call_id,
|
||||
@@ -143,8 +433,8 @@ class TelemetryEmitter:
|
||||
provider=provider,
|
||||
source_name=source_name,
|
||||
messages=messages_json,
|
||||
response=response_text,
|
||||
thinking=thinking,
|
||||
response=_cap_text(response_text, self._text_cap),
|
||||
thinking=_cap_text(thinking, self._text_cap),
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
usage_source=usage_source,
|
||||
@@ -154,6 +444,19 @@ class TelemetryEmitter:
|
||||
cache_hit=cache_hit,
|
||||
error=error,
|
||||
cost=cost,
|
||||
cached_prompt_tokens=cached_prompt_tokens,
|
||||
model_reported=model_reported,
|
||||
sampling=sampling,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
# 空串是哨兵而非 NULL: NULL 的 tenant_id 在 PG 的 RLS policy 下
|
||||
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
|
||||
tenant_id=tenant_id or "",
|
||||
meta=_canonical_meta_json(meta),
|
||||
# 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对子类不
|
||||
# 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让
|
||||
# Postgres 那一路悄悄少一列数据
|
||||
thinking_observation=_normalize_observation(thinking_observation),
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
@@ -174,7 +477,7 @@ class TelemetryMW:
|
||||
started = self._now()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except (GatewayUnavailableError, GovernanceBackendError) as exc:
|
||||
except (GatewayUnavailableError, GovernanceBackendError, SourceNotConfiguredError) as exc:
|
||||
await self._emitter.emit_terminal_failure(
|
||||
request=request,
|
||||
call_id=str(uuid.uuid4()),
|
||||
|
||||
+158
-109
@@ -18,32 +18,36 @@ import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.client import _aclose_component, _telemetry_status_of
|
||||
from polygateway.errors import (
|
||||
AllSourcesExhausted,
|
||||
CircuitOpenError,
|
||||
GovernanceBackendError,
|
||||
PolyGatewayError,
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
SourceNotConfiguredError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.middleware.retry import _failure_reason, backoff_delay
|
||||
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.ports import OutcomeAwareSelector
|
||||
from polygateway.sources import SourceCooldownMemo
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
LLMResponse,
|
||||
OcrLayoutResult,
|
||||
OcrTextResult,
|
||||
TelemetryStatus,
|
||||
Usage,
|
||||
strip_unsupported_extra_body,
|
||||
validate_caller_dimensions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -105,29 +109,51 @@ class OcrClient:
|
||||
retry: RetryPolicy,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
circuit_open: str = "fail_fast",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
text_cap: int | None = None,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
rng: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
if quota_full not in ("wait", "fail_fast"):
|
||||
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
|
||||
self._scope = scope
|
||||
self._sources = list(sources)
|
||||
# MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则
|
||||
# 遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||
self._sources = strip_unsupported_extra_body(list(sources), path="OCR")
|
||||
self._selector = selector
|
||||
self._feed_health = isinstance(selector, OutcomeAwareSelector)
|
||||
self._quota = QuotaGate(limiter)
|
||||
self._breaker = BreakerGate(breaker)
|
||||
self._quota = QuotaGate(limiter, scope=self._scope)
|
||||
self._breaker = BreakerGate(breaker, scope=self._scope)
|
||||
self._transport = transport
|
||||
self._retry = retry
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._emitter = TelemetryEmitter(telemetry) if telemetry else None
|
||||
self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None
|
||||
self._telemetry = telemetry
|
||||
self._memo = SourceCooldownMemo(now=now)
|
||||
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
|
||||
# 引用才关得到自建的 redis 客户端(设计 §3.4)
|
||||
self._limiter_backend = limiter
|
||||
self._breaker_backend = breaker
|
||||
# 所有权默认"不拥有": `__init__` 是全量注入路径,只有工厂自建时才置 True
|
||||
self._owns_transport = False
|
||||
self._owns_telemetry = False
|
||||
self._owns_limiter = False
|
||||
self._owns_breaker = False
|
||||
self._now = now
|
||||
self._sleep = sleep
|
||||
self._rng = rng
|
||||
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
|
||||
self._admission = SourceAdmission(
|
||||
scope=self._scope,
|
||||
sources=self._sources,
|
||||
selector=selector,
|
||||
quota=self._quota,
|
||||
breaker=self._breaker,
|
||||
backpressure=backpressure,
|
||||
quota_full=quota_full,
|
||||
circuit_open=circuit_open,
|
||||
now=now,
|
||||
sleep=sleep,
|
||||
rng=rng,
|
||||
)
|
||||
self._closed = False
|
||||
|
||||
# —— 公共端口(OcrTextPort / OcrLayoutPort)——
|
||||
@@ -138,9 +164,21 @@ class OcrClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> OcrTextResult:
|
||||
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。"""
|
||||
outcome = await self._call("text", image, session_id, parent_call_id)
|
||||
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||
"""
|
||||
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
|
||||
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
|
||||
)
|
||||
outcome = await self._call(
|
||||
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
|
||||
)
|
||||
result = outcome.result
|
||||
return OcrTextResult(
|
||||
text=result.text,
|
||||
@@ -157,9 +195,20 @@ class OcrClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> OcrLayoutResult:
|
||||
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。"""
|
||||
outcome = await self._call("layout", image, session_id, parent_call_id)
|
||||
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||
"""
|
||||
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
|
||||
)
|
||||
outcome = await self._call(
|
||||
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
|
||||
)
|
||||
result = outcome.result
|
||||
return OcrLayoutResult(
|
||||
elements=result.elements,
|
||||
@@ -191,6 +240,8 @@ class OcrClient:
|
||||
image: bytes,
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _AttemptOutcome:
|
||||
if not isinstance(image, bytes):
|
||||
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
||||
@@ -200,13 +251,17 @@ class OcrClient:
|
||||
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
|
||||
fails = 0
|
||||
reasons: dict[str, str] = {}
|
||||
entered_at = self._now()
|
||||
# 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算
|
||||
clock = StallClock(self._now)
|
||||
while True:
|
||||
picked, gate_rejections = await self._pick_runnable(reasons)
|
||||
picked, gate_rejections = await self._admission.pick(reasons, {})
|
||||
if picked is None:
|
||||
await self._on_no_runnable(gate_rejections, reasons, entered_at)
|
||||
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
|
||||
continue
|
||||
outcome = await self._attempt(kind, image, *picked, reasons, session_id, parent_call_id)
|
||||
async with clock.attempting():
|
||||
outcome = await self._attempt(
|
||||
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
|
||||
)
|
||||
if isinstance(outcome, _AttemptOutcome):
|
||||
return outcome
|
||||
fails += 1
|
||||
@@ -220,62 +275,6 @@ class OcrClient:
|
||||
if not outcome.immediate:
|
||||
await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng))
|
||||
|
||||
async def _pick_runnable(
|
||||
self, reasons: dict[str, str]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
||||
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
||||
gate_rejections = 0
|
||||
for cand in self._selector.order(self._sources, stats):
|
||||
if self._memo.active(cand.name):
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "cooldown"
|
||||
continue
|
||||
permit = await self._quota.try_acquire(cand)
|
||||
if permit is None:
|
||||
reasons.setdefault(cand.name, "rate_limited")
|
||||
continue
|
||||
entry = None
|
||||
try:
|
||||
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
|
||||
finally:
|
||||
if entry is None:
|
||||
await self._settle_and_release(permit)
|
||||
if entry.allowed:
|
||||
return (cand, permit, entry), gate_rejections
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "circuit_open"
|
||||
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
await self._settle_and_release(permit)
|
||||
return None, gate_rejections
|
||||
|
||||
async def _on_no_runnable(
|
||||
self, gate_rejections: int, reasons: dict[str, str], entered_at: float
|
||||
) -> None:
|
||||
if gate_rejections == len(self._sources):
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise CircuitOpenError(
|
||||
scope=self._scope,
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
if self._quota_full == "fail_fast":
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="quota_exhausted",
|
||||
retry_after_s=self._bp.poll_interval_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
stall = self._bp.stall_window_s
|
||||
if self._now() - entered_at > stall and await self._quota.progress_age_s() > stall:
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
|
||||
|
||||
async def _attempt(
|
||||
self,
|
||||
kind: _OcrKind,
|
||||
@@ -286,9 +285,13 @@ class OcrClient:
|
||||
reasons: dict[str, str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _AttemptOutcome | _FailedAttempt:
|
||||
call_id = str(uuid.uuid4())
|
||||
started = self._now()
|
||||
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
|
||||
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
|
||||
try:
|
||||
result = await self._invoke(kind, image, source, call_id)
|
||||
await self._record_quietly(self._breaker.record_success(entry))
|
||||
@@ -296,20 +299,47 @@ class OcrClient:
|
||||
self._feed_outcome(source.name, ok=True)
|
||||
latency_ms = int((self._now() - started) * 1000)
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, result
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
result,
|
||||
)
|
||||
return _AttemptOutcome(result, source, call_id, latency_ms)
|
||||
except (RequestRejectedError, ResultInvalidError) as exc:
|
||||
await self._gate_on_terminal(exc, entry)
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
if entry.is_probe:
|
||||
await self._record_quietly(self._breaker.release_probe(entry))
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, error="cancelled"
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error="cancelled",
|
||||
)
|
||||
raise
|
||||
except (SourceDeadError, TransientError) as exc:
|
||||
@@ -319,11 +349,20 @@ class OcrClient:
|
||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||
self._feed_outcome(source.name, ok=False)
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
return _FailedAttempt(exc, immediate=dead)
|
||||
finally:
|
||||
await self._settle_and_release(permit)
|
||||
await settle_and_release(permit, 0)
|
||||
|
||||
async def _invoke(
|
||||
self, kind: _OcrKind, image: bytes, source: SourceConfig, call_id: str
|
||||
@@ -357,21 +396,9 @@ class OcrClient:
|
||||
await write_back
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except GovernanceBackendError as exc:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
|
||||
logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc)
|
||||
|
||||
async def _settle_and_release(self, permit: Permit) -> None:
|
||||
"""settle 恒 0: OCR 无 token 计费(设计 §5 差异①)。"""
|
||||
try:
|
||||
try:
|
||||
await permit.settle(0)
|
||||
finally:
|
||||
await permit.release()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("OCR permit 结算/释放失败(不掩盖主异常): {}", exc)
|
||||
|
||||
async def _emit(
|
||||
self,
|
||||
kind: _OcrKind,
|
||||
@@ -381,16 +408,22 @@ class OcrClient:
|
||||
started: float,
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
|
||||
error: object | None = None,
|
||||
) -> None:
|
||||
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
|
||||
if self._emitter is None:
|
||||
return
|
||||
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱),
|
||||
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空
|
||||
request = ChatRequest(
|
||||
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
tenant_id=tenant_id,
|
||||
meta=meta,
|
||||
)
|
||||
latency_ms = int((self._now() - started) * 1000)
|
||||
response = None
|
||||
@@ -422,6 +455,8 @@ class OcrClient:
|
||||
latency_ms=latency_ms,
|
||||
response=response,
|
||||
error=error_text,
|
||||
# OCR 走 MonkeyOCR 自有端点,没有推理参数可言(理由同 embedding)
|
||||
reasoning_applies=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -432,21 +467,28 @@ class OcrClient:
|
||||
|
||||
# —— 生命周期 ——
|
||||
|
||||
@property
|
||||
def telemetry_status(self) -> TelemetryStatus | None:
|
||||
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
|
||||
|
||||
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
|
||||
的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。
|
||||
"""
|
||||
return _telemetry_status_of(self._telemetry)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""幂等释放 transport 连接池与遥测连接(与 EmbeddingClient 对称)。"""
|
||||
"""幂等释放**自建**资源(与 EmbeddingClient 对称);注入的组件一律不碰。"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
transport_aclose = getattr(self._transport, "aclose", None)
|
||||
if transport_aclose is not None:
|
||||
await transport_aclose()
|
||||
telemetry_aclose = getattr(self._telemetry, "aclose", None)
|
||||
if telemetry_aclose is not None:
|
||||
await telemetry_aclose()
|
||||
else:
|
||||
telemetry_close = getattr(self._telemetry, "close", None)
|
||||
if telemetry_close is not None:
|
||||
telemetry_close()
|
||||
if self._owns_transport:
|
||||
await _aclose_component(self._transport)
|
||||
if self._owns_telemetry:
|
||||
await _aclose_component(self._telemetry)
|
||||
if self._owns_limiter:
|
||||
await _aclose_component(self._limiter_backend)
|
||||
if self._owns_breaker:
|
||||
await _aclose_component(self._breaker_backend)
|
||||
|
||||
async def __aenter__(self) -> OcrClient:
|
||||
return self
|
||||
@@ -471,6 +513,7 @@ class OcrClient:
|
||||
_build_limiter,
|
||||
_build_selector,
|
||||
_build_telemetry,
|
||||
_mark_owned_components,
|
||||
)
|
||||
from polygateway.transports.monkey_ocr import MonkeyOcrTransport
|
||||
|
||||
@@ -481,18 +524,24 @@ class OcrClient:
|
||||
alien = sorted({s.provider for s in sources if s.provider != "monkey"})
|
||||
if alien:
|
||||
raise ValueError(f"OCR 装配仅支持 provider=monkey(D9 其余后端预留未实现): 发现 {alien}")
|
||||
return cls(
|
||||
client = cls(
|
||||
scope=gw.scope,
|
||||
sources=sources,
|
||||
selector=_build_selector(gw.selector),
|
||||
limiter=limiter or _build_limiter(gw, sources),
|
||||
breaker=breaker or _build_breaker(gw),
|
||||
limiter=limiter if limiter is not None else _build_limiter(gw, sources),
|
||||
breaker=breaker if breaker is not None else _build_breaker(gw),
|
||||
transport=MonkeyOcrTransport(),
|
||||
retry=gw.retry,
|
||||
backpressure=gw.backpressure,
|
||||
quota_full=gw.quota_full,
|
||||
circuit_open=gw.circuit_open,
|
||||
telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
|
||||
# OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
||||
# 一半不受控(issue #12)
|
||||
text_cap=gw.telemetry_text_cap,
|
||||
)
|
||||
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from .types import (
|
||||
ChatRequest,
|
||||
Effort,
|
||||
EmbeddingTransportResult,
|
||||
LLMResponse,
|
||||
OcrLayoutResult,
|
||||
@@ -20,6 +21,7 @@ from .types import (
|
||||
OcrTextTransportResult,
|
||||
SourceConfig,
|
||||
SourceStats,
|
||||
TelemetryStatus,
|
||||
TransportResult,
|
||||
)
|
||||
|
||||
@@ -35,7 +37,16 @@ class Middleware(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class Transport(Protocol):
|
||||
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。"""
|
||||
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
|
||||
|
||||
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。
|
||||
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
|
||||
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。
|
||||
|
||||
该参数**不设默认值**,与 `TelemetryRecorder.record_llm_call` 同一既有约定:
|
||||
库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的
|
||||
"调用方没表态"——一次本该报错的漏配就此变成一次悄悄涨价的调用。
|
||||
"""
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
@@ -45,6 +56,7 @@ class Transport(Protocol):
|
||||
stream: bool,
|
||||
overlay: dict[str, Any],
|
||||
call_id: str,
|
||||
reasoning_effort: Effort | None,
|
||||
) -> TransportResult: ...
|
||||
|
||||
|
||||
@@ -243,9 +255,38 @@ class StructuredOutputStrategy(Protocol):
|
||||
def parse(self, text: str) -> Any: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TelemetryStatusProvider(Protocol):
|
||||
"""可自述可写状态的遥测后端;`TelemetryRecorder` 的**可选**伴生端口(issue #15)。
|
||||
|
||||
与 `TelemetryRecorder` 分开而不是给它加成员,是因为后者是 `@runtime_checkable`
|
||||
而运行时检查按属性存在性做: 加一个属性会让所有只实现 `record_llm_call` 的
|
||||
实现**当场不再是** `TelemetryRecorder`,下游若有同款 isinstance 断言,升级即断
|
||||
(设计 §3.3)。消费方一律先 isinstance 再取值,取不到就当没有状态可报。
|
||||
"""
|
||||
|
||||
@property
|
||||
def telemetry_status(self) -> TelemetryStatus: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TelemetryRecorder(Protocol):
|
||||
"""遥测后端;18 字段冻结(M1 设计 §4.4),唯一调用点是 TelemetryEmitter。"""
|
||||
"""遥测后端;26 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20),唯一调用点是 TelemetryEmitter。
|
||||
|
||||
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||
|
||||
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
|
||||
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
|
||||
`thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value`
|
||||
的裸 `str`(`StrEnum` 是 `str` 子类,而 asyncpg 的参数编码对子类不保证接受,
|
||||
遥测写失败又只降级成 warning——PG 那一路会静默少一列数据)。
|
||||
`reasoning_effort` 同一先例(issue #20): emitter 已把 `Effort` 取成 `.value`
|
||||
的裸 `str`,`None` 表示调用方没表态——它与 `'none'`(明确要求不推理)不可折叠。
|
||||
|
||||
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
|
||||
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。
|
||||
"""
|
||||
|
||||
async def record_llm_call(
|
||||
self,
|
||||
@@ -268,4 +309,12 @@ class TelemetryRecorder(Protocol):
|
||||
cache_hit: bool,
|
||||
error: str | None,
|
||||
cost: float | None,
|
||||
cached_prompt_tokens: int | None,
|
||||
model_reported: str | None,
|
||||
sampling: str | None,
|
||||
reasoning_tokens: int | None,
|
||||
tenant_id: str,
|
||||
meta: str,
|
||||
thinking_observation: str,
|
||||
reasoning_effort: str | None,
|
||||
) -> None: ...
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**零内置单价**: 实验室走中转网关,计费非官方牌价;库内硬编码单价表
|
||||
必然过时并掩盖真实成本(P5 严禁默认值掩盖错误)。价格一律由使用方
|
||||
提供——JSON 文件(`PGW_PRICING_PATH`)或 dict 注入;币种由使用方全表
|
||||
统一口径,库不设币种字段(18 字段冻结)。查不到的 model → cost=None
|
||||
统一口径,库不设币种字段(20 字段冻结)。查不到的 model → cost=None
|
||||
且每 model 仅首次 warning(防日志风暴),不阻塞调用。
|
||||
"""
|
||||
|
||||
@@ -22,22 +22,33 @@ if TYPE_CHECKING:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelPrice:
|
||||
"""每百万 token 的输入/输出单价(币种由使用方口径统一)。"""
|
||||
"""每百万 token 的输入/输出单价(币种由使用方口径统一)。
|
||||
|
||||
`cached_input_per_1m` 是可选的**缓存读取单价**(issue #3): 供应商 prompt
|
||||
cache 命中的那部分输入按更低单价计费。不填即不启用——库绝不按经验折扣率
|
||||
猜一个数(P5 严禁默认值掩盖),未填时全额按 `input_per_1m` 计。
|
||||
"""
|
||||
|
||||
input_per_1m: float
|
||||
output_per_1m: float
|
||||
cached_input_per_1m: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.input_per_1m < 0 or self.output_per_1m < 0:
|
||||
raise ValueError("单价不能为负")
|
||||
if self.cached_input_per_1m is not None and self.cached_input_per_1m < 0:
|
||||
raise ValueError("缓存读取单价不能为负")
|
||||
|
||||
|
||||
class PricingTable:
|
||||
"""model → 单价 的只读表;cost() 是全库唯一换算点(经 TelemetryEmitter)。"""
|
||||
"""model → 单价 的只读表;cost() 有两个调用点: `TelemetryEmitter`(chat 主路径)
|
||||
与 `embedding.py` 的批量换算。"""
|
||||
|
||||
def __init__(self, prices: Mapping[str, ModelPrice]) -> None:
|
||||
self._prices = dict(prices)
|
||||
self._warned: set[str] = set()
|
||||
# 独立集合: 与"未知 model"的告警去重键分开,避免 model 名恰好撞上时互相抑制
|
||||
self._warned_clamp: set[str] = set()
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path | str) -> PricingTable:
|
||||
@@ -53,21 +64,61 @@ class PricingTable:
|
||||
for model, entry in data.items():
|
||||
if not isinstance(entry, dict) or not {"input_per_1m", "output_per_1m"} <= set(entry):
|
||||
raise ValueError(f"价格表 {p} 条目 {model!r} 须含 input_per_1m 与 output_per_1m")
|
||||
cached_raw = entry.get("cached_input_per_1m")
|
||||
try:
|
||||
cached = None if cached_raw is None else float(cached_raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"价格表 {p} 条目 {model!r} 的 cached_input_per_1m 必须是数字: {cached_raw!r}"
|
||||
) from exc
|
||||
if cached is not None and cached < 0:
|
||||
raise ValueError(f"价格表 {p} 条目 {model!r} 的 cached_input_per_1m 不能为负")
|
||||
prices[model] = ModelPrice(
|
||||
input_per_1m=float(entry["input_per_1m"]),
|
||||
output_per_1m=float(entry["output_per_1m"]),
|
||||
cached_input_per_1m=cached,
|
||||
)
|
||||
return cls(prices)
|
||||
|
||||
def cost(self, model: str, prompt_tokens: int, completion_tokens: int) -> float | None:
|
||||
"""换算一次调用成本;未知 model 记 None 并仅首次 warning。"""
|
||||
def cost(
|
||||
self,
|
||||
model: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
cached_prompt_tokens: int | None = None,
|
||||
) -> float | None:
|
||||
"""换算一次调用成本;未知 model 记 None 并仅首次 warning。
|
||||
|
||||
`cached_prompt_tokens` 是供应商 prompt cache 命中的输入 token 数
|
||||
(issue #3);仅当该 model 配了 `cached_input_per_1m` 时才分段计价,
|
||||
否则全额按输入价——不猜折扣率。参数带默认值: embedding 侧的三参调用
|
||||
形态不受影响。
|
||||
"""
|
||||
price = self._prices.get(model)
|
||||
if price is None:
|
||||
if model not in self._warned:
|
||||
self._warned.add(model)
|
||||
logger.warning("pricing 表无 model {!r} 的单价,cost 记 None", model)
|
||||
return None
|
||||
return (
|
||||
prompt_tokens / 1_000_000 * price.input_per_1m
|
||||
+ completion_tokens / 1_000_000 * price.output_per_1m
|
||||
)
|
||||
billed_input = prompt_tokens / 1_000_000 * price.input_per_1m
|
||||
# 负数按"无命中"处理: cost() 是公共方法,不能假定调用方已过 transport 的校验
|
||||
if price.cached_input_per_1m is not None and (cached_prompt_tokens or 0) > 0:
|
||||
cached = self._clamp_cached(model, prompt_tokens, cached_prompt_tokens)
|
||||
billed_input = (prompt_tokens - cached) / 1_000_000 * price.input_per_1m + (
|
||||
cached / 1_000_000 * price.cached_input_per_1m
|
||||
)
|
||||
return billed_input + completion_tokens / 1_000_000 * price.output_per_1m
|
||||
|
||||
def _clamp_cached(self, model: str, prompt_tokens: int, cached: int) -> int:
|
||||
"""命中数按输入总数夹取: 网关口径异常不得算出负成本(每 model 只警告一次)。"""
|
||||
if cached <= prompt_tokens:
|
||||
return cached
|
||||
if model not in self._warned_clamp:
|
||||
self._warned_clamp.add(model)
|
||||
logger.warning(
|
||||
"model {!r} 上报的缓存命中 {} 超过输入总数 {},按总数夹取计价",
|
||||
model,
|
||||
cached,
|
||||
prompt_tokens,
|
||||
)
|
||||
return prompt_tokens
|
||||
|
||||
+127
-17
@@ -3,6 +3,9 @@
|
||||
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
|
||||
**精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享
|
||||
状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。
|
||||
|
||||
**本模块只存放声明,不做判断**: 拿这些声明去决定注入什么、响应算不算推理,
|
||||
全部在 `thinking.py`(P7 决策逻辑与状态存储分离)。
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
@@ -11,49 +14,156 @@ from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThinkingWire:
|
||||
"""一个 provider 表达"开/关/多深"的请求体形态(设计 §3.3)。
|
||||
|
||||
三个字段各自的 `None` **语义互不重叠**,混淆任意两个都会退回 issue #5 修掉的
|
||||
那种静默失效:
|
||||
|
||||
============== ==========================================================
|
||||
``on_base=None`` **形态未知**: 本库不知道该 provider 如何表达"开",配了开关
|
||||
即装配期报错并指路 `register_provider`/`extra_body`
|
||||
``off=None`` 已知开启形态,但**没有关闭形态**(该 provider 关不掉)
|
||||
``effort_key`` ``None`` = 该 provider 只有开关、没有档位(qwen 系靠
|
||||
``=None`` ``thinking_budget`` 调深度,不是档位)
|
||||
============== ==========================================================
|
||||
|
||||
`on_base={}` 与 `on_base=None` 同样不可混: 前者是"已知无需注入任何参数即处于
|
||||
开启档"(经网关的 OpenAI 兼容路径正是如此——档位由 `effort_key` 单独附加),
|
||||
后者是"不知道怎么表达"。
|
||||
|
||||
**为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat /
|
||||
openai-responses / anthropic-messages / google-generate-content 四种端点协议,
|
||||
故需要 closed operation 集合与 `budgetWire` 代际变体。本库只有一个 OpenAI 兼容
|
||||
transport,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent),
|
||||
一个协议一层形态即够(P1 YAGNI)。
|
||||
"""
|
||||
|
||||
off: Mapping[str, Any] | None
|
||||
on_base: Mapping[str, Any] | None
|
||||
effort_key: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderProfile:
|
||||
"""单个 provider 的能力与差异声明。
|
||||
|
||||
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为
|
||||
True/False 时并入请求体的参数片段(None 时二者都不注入,用模型默认);
|
||||
strip_think_tags 声明响应 content 需剥离 ``<think>`` 标签(qwen 系);
|
||||
supports_native_schema 供 D14 阶梯选择原生 response_format 策略。
|
||||
`thinking` 声明推理参数的**形态**(按 provider 变,数年不变一次);
|
||||
`strip_think_tags` 声明响应 content 需剥离 ``<think>`` 标签(qwen 系);
|
||||
`supports_native_schema` 供 D14 阶梯选择原生 response_format。
|
||||
|
||||
注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型支持哪些
|
||||
档位属**能力**(按 model 变),见 `thinking.ThinkingCapability`。二者合一在
|
||||
provider 级表达不了代际差异——glm-5.2 能关而 glm-5.3 不能,形态却完全相同。
|
||||
"""
|
||||
|
||||
name: str
|
||||
thinking_on: dict[str, Any]
|
||||
thinking_off: dict[str, Any]
|
||||
thinking: ThinkingWire
|
||||
strip_think_tags: bool
|
||||
supports_native_schema: bool = False
|
||||
|
||||
|
||||
# 经 new-api 中转的口径。四家参考实现(cherry-studio / OpenRouter / LiteLLM /
|
||||
# new-api 自身)一致的结论: OpenAI 兼容端点上,档位一律走标准的 `reasoning_effort`,
|
||||
# 跨协议转换(→ Claude 的 thinking、Gemini 的 thinkingConfig)由网关服务端完成。
|
||||
DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
|
||||
{
|
||||
# 注入片段出处: VT llm.py:130-144(开启形态)与 CHS invokers.py:230-238(关闭形态)
|
||||
"qwen": ProviderProfile(
|
||||
name="qwen",
|
||||
thinking_on={"enable_thinking": True},
|
||||
thinking_off={"enable_thinking": False},
|
||||
thinking=ThinkingWire(
|
||||
off={"enable_thinking": False},
|
||||
on_base={"enable_thinking": True},
|
||||
# 百炼的深度控制是 `thinking_budget`(token 预算)而非档位;
|
||||
# 预算型控制本库当前不支持(设计 §11 明确不做)
|
||||
effort_key=None,
|
||||
),
|
||||
strip_think_tags=True,
|
||||
),
|
||||
# 官方 thinking_mode 文档: thinking:{type} 是开关,reasoning_effort 是深度,
|
||||
# V4 一代两者并用(deepseek-v4-* 的档位见能力表)
|
||||
"deepseek": ProviderProfile(
|
||||
name="deepseek",
|
||||
thinking_on={"thinking": {"type": "enabled"}},
|
||||
thinking_off={"thinking": {"type": "disabled"}},
|
||||
thinking=ThinkingWire(
|
||||
off={"thinking": {"type": "disabled"}},
|
||||
on_base={"thinking": {"type": "enabled"}},
|
||||
effort_key="reasoning_effort",
|
||||
),
|
||||
strip_think_tags=False,
|
||||
),
|
||||
"openai": ProviderProfile(
|
||||
name="openai",
|
||||
thinking_on={},
|
||||
thinking_off={},
|
||||
# issue #20。智谱官方迁移建议原文: 原先用 {"type":"disabled"} 的应改为
|
||||
# {"type":"enabled"} + reasoning_effort="low"——GLM-5.3 起 thinking.type
|
||||
# 不再接受 disabled,故"关"这一档由能力表按型号裁定(5.2 能关,5.3 不能)
|
||||
"zhipu": ProviderProfile(
|
||||
name="zhipu",
|
||||
thinking=ThinkingWire(
|
||||
off={"thinking": {"type": "disabled"}},
|
||||
on_base={"thinking": {"type": "enabled"}},
|
||||
effort_key="reasoning_effort",
|
||||
),
|
||||
strip_think_tags=False,
|
||||
),
|
||||
# OpenAI 兼容基线,无已知注入差异;reasoning_content 由 transport 通用处理
|
||||
# kimi-k3 的档位是 low/high/max;thinking.type 为月之暗面的开关形态
|
||||
"moonshot": ProviderProfile(
|
||||
name="moonshot",
|
||||
thinking=ThinkingWire(
|
||||
off={"thinking": {"type": "disabled"}},
|
||||
on_base={"thinking": {"type": "enabled"}},
|
||||
effort_key="reasoning_effort",
|
||||
),
|
||||
strip_think_tags=False,
|
||||
),
|
||||
# 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。
|
||||
# enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线
|
||||
# 194),reasoning_effort 才是真开关——本段形态据此成立。
|
||||
# `on_base={"reasoning_effort": "medium"}` 是**权宜之计**(issue #21),不是本段
|
||||
# 的理想形态: 它退回了"库替下游选一个档"这件本次工作原本要消灭的事。
|
||||
# 之所以接受: 本次一度改成 `on_base={}`("开"不需要任何参数),该形态依赖
|
||||
# "模型默认就推理"这个前提,而 T10 真实网关实测推翻了它——MiniMax-M3 不发任何
|
||||
# 推理参数时 5/5 轮不推理(六个强度值 minimal..max 则全部生效且彼此等价)。
|
||||
# 于是存量配 ENABLE_THINKING=true 的下游会从"真开推理"静默变成"不推理"。
|
||||
# 取 medium 是为逐字恢复旧版的 thinking_on,与存量行为一致;M3 六档等价,
|
||||
# 故选哪档对效果无差别。
|
||||
# 正解是让 `auto` 受能力表约束(模型不支持"由模型自定"时报错并指路显式档位),
|
||||
# 属公共行为变更,已记入 gitea issue #21 待下一版处理。
|
||||
"minimax": ProviderProfile(
|
||||
name="minimax",
|
||||
thinking_on={},
|
||||
thinking_off={},
|
||||
thinking=ThinkingWire(
|
||||
off={"reasoning_effort": "none"},
|
||||
on_base={"reasoning_effort": "medium"},
|
||||
effort_key="reasoning_effort",
|
||||
),
|
||||
strip_think_tags=False,
|
||||
),
|
||||
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底。两档此前标
|
||||
# None(未知),因为当时无法区分"厂商方言"与"标准字段";`reasoning_effort`
|
||||
# 是 OpenAI **官方**字段而非方言,发给经网关的兼容端点不会打到不认识它的
|
||||
# 厂商,故 2026-09-04 起给出标准形态。真正形态未知的 provider 仍走
|
||||
# register_provider 注册,而不是挂在本段下
|
||||
"openai": ProviderProfile(
|
||||
name="openai",
|
||||
thinking=ThinkingWire(
|
||||
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
|
||||
),
|
||||
strip_think_tags=False,
|
||||
),
|
||||
# Claude 5 系原生是 thinking.type=adaptive + output_config.effort,Gemini 3 系
|
||||
# 原生是 thinkingConfig.thinkingLevel;两者的代际方言(Claude ≤4.5 的
|
||||
# budget_tokens、Gemini 2.x 的 thinkingBudget)由 new-api 的 canonical intent
|
||||
# 层吸收,本库只发 OpenAI 形态(设计 §3.4)
|
||||
"anthropic": ProviderProfile(
|
||||
name="anthropic",
|
||||
thinking=ThinkingWire(
|
||||
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
|
||||
),
|
||||
strip_think_tags=False,
|
||||
),
|
||||
"google": ProviderProfile(
|
||||
name="google",
|
||||
thinking=ThinkingWire(
|
||||
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
|
||||
),
|
||||
strip_think_tags=False,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -14,13 +14,11 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公共名
|
||||
"""流活性超时异常。
|
||||
@@ -38,14 +36,14 @@ class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公
|
||||
super().__init__(f"流活性超时({kind}, elapsed={elapsed_s:.1f}s)")
|
||||
|
||||
|
||||
async def _anext_within(
|
||||
it: AsyncIterator[_T],
|
||||
async def _anext_within[T](
|
||||
it: AsyncIterator[T],
|
||||
timeout_s: float,
|
||||
*,
|
||||
kind: str,
|
||||
start: float,
|
||||
first: bool,
|
||||
) -> _T:
|
||||
) -> T:
|
||||
"""限时取下一项;本层 deadline 触发抛 StreamLivenessTimeout(kind)。
|
||||
|
||||
上游自抛的 TimeoutError 用 cm.expired() 区分,原样上抛不误吞。
|
||||
@@ -59,13 +57,13 @@ async def _anext_within(
|
||||
raise StreamLivenessTimeout(kind, time.monotonic() - start, not first) from None
|
||||
|
||||
|
||||
async def stream_with_liveness_timeouts(
|
||||
source: AsyncIterator[_T],
|
||||
async def stream_with_liveness_timeouts[T](
|
||||
source: AsyncIterator[T],
|
||||
*,
|
||||
ttft_s: float,
|
||||
inter_token_s: float,
|
||||
total_s: float,
|
||||
) -> AsyncIterator[_T]:
|
||||
) -> AsyncIterator[T]:
|
||||
"""逐项产出 source,并施加三层活性超时。
|
||||
|
||||
关键实现: 超时**只包裹单次 __anext__**,绝不包裹 yield——否则总时长
|
||||
|
||||
@@ -1,80 +1,165 @@
|
||||
"""Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 两级降级。
|
||||
"""Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 按失败性质三分的降级。
|
||||
|
||||
参考仓无先例(三项目遥测全 SQLite);asyncpg 工程写法取 GovDoc
|
||||
`taskrun/postgres_store.py`($n 占位、`CREATE TABLE IF NOT EXISTS`、
|
||||
`ON CONFLICT DO NOTHING`),但其"失败冒泡"方向按遥测铁律**有意反转**:
|
||||
① 结构性失败(建池/建表)→ warning 一次后永久降级(池置 None 短路);
|
||||
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
|
||||
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
|
||||
构造不连库(lazy),18 列 schema 与 SQLite 版同名同序。
|
||||
`taskrun/postgres_store.py`($n 占位、`ON CONFLICT DO NOTHING`),但其
|
||||
"失败冒泡"方向按遥测铁律**有意反转**: 遥测失败一律不冒泡,只降级。
|
||||
构造不连库(lazy),24 列 schema 与 SQLite 版同名同序。
|
||||
|
||||
**降级档位挂在"失败是什么性质",不挂"哪一步失败"**(issue #15)。挂步骤是
|
||||
issue 的病灶: `min_size=10` 把"连接耗尽"这种瞬时错误逼到建池那一步,于是它被
|
||||
一刀切成了永久判死,整进程从此一条遥测都不落,只有重启能恢复。判据两句:
|
||||
|
||||
1. **致命 = 失败原因完全在进程内部且不可变**。DSN 是构造期定死的字符串,是唯一
|
||||
满足这条的东西;认证失败、库不存在、表建不出来一律不算——DBA 改完就该好。
|
||||
2. **行级 vs 环境级看失败与"这一行的数据"有没有关系**: 只与本行数据有关(换一行
|
||||
可能成功)= 行级,逐条丢弃;与数据无关、每一行都会同样失败 = 环境级,进冷却。
|
||||
|
||||
见 `_classify_failure`(全库唯一一处 PG 失败分类)与 `_handle_failure`(三个降级点
|
||||
唯一一处处置)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.telemetry.schema import (
|
||||
COLUMNS,
|
||||
PG_BACKFILL,
|
||||
PG_DDL,
|
||||
insert_sql,
|
||||
missing_columns_warning,
|
||||
)
|
||||
from polygateway.telemetry.status import TelemetryStatusTracker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
import asyncpg
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms DOUBLE PRECISION,
|
||||
max_inter_token_ms DOUBLE PRECISION,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
"""
|
||||
from polygateway.types import TelemetryStatus
|
||||
|
||||
_COLUMNS = (
|
||||
"call_id",
|
||||
"parent_call_id",
|
||||
"session_id",
|
||||
"model",
|
||||
"provider",
|
||||
"source_name",
|
||||
"messages",
|
||||
"response",
|
||||
"thinking",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"usage_source",
|
||||
"latency_ms",
|
||||
"ttft_ms",
|
||||
"max_inter_token_ms",
|
||||
"cache_hit",
|
||||
"error",
|
||||
"cost",
|
||||
# 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析
|
||||
_TABLE_EXISTS = "SELECT to_regclass('llm_calls')"
|
||||
|
||||
# 归还连接的独立上限(issue #15)。**不**复用写入预算: 写入预算已经花在
|
||||
# acquire+execute 上,归还再给它一个同样大的额度,等于允许业务路径上的一次遥测
|
||||
# 写入吃掉 2 倍预算。归还是本地动作(reset 一次往返),1 秒足够;超时即断开,
|
||||
# asyncpg 会在下次 acquire 时补一条新连接
|
||||
_RELEASE_TIMEOUT_S = 1.0
|
||||
|
||||
# 关闭池的独立上限(issue #15)。**不**复用写入预算: 关闭跑在收尾路径而非业务
|
||||
# 路径上,给它一个略宽的固定额度即可,但必须**有界**——asyncpg 的
|
||||
# `Pool.close()` 会 await 每个 holder 的 `wait_until_released()`,in-flight
|
||||
# 连接不归还就无限等(`pool.py:939-948, 961-972`,60s 只发一条 warning),
|
||||
# 其 docstring 自己写着 "advisable to use asyncio.wait_for to set a timeout"
|
||||
_CLOSE_TIMEOUT_S = 5.0
|
||||
|
||||
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
|
||||
_EXISTING_COLUMNS = (
|
||||
"SELECT attname FROM pg_attribute "
|
||||
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
f"INSERT INTO llm_calls ({', '.join(_COLUMNS)}) "
|
||||
f"VALUES ({', '.join(f'${i + 1}' for i in range(len(_COLUMNS)))}) "
|
||||
"ON CONFLICT (call_id) DO NOTHING"
|
||||
)
|
||||
# 环境级降级的冷却期(issue #15)。**不暴露配置**: 它的取值只影响"多久重试一次"
|
||||
# 这个内部节奏,任何取值都不改变对外承诺(降级可见、可自愈、有界成本),给出旋钮
|
||||
# 只会多一个下游要理解却调不对的东西(设计 §3.5)
|
||||
_DEGRADE_COOLDOWN_S = 60.0
|
||||
|
||||
_FATAL = "fatal"
|
||||
"""配置级致命: 原因完全在进程内部且不可变 → 永久 no-op + 一条 error。"""
|
||||
|
||||
_UNAVAILABLE = "unavailable"
|
||||
"""环境级不可用: 与本行数据无关、每行都会同样失败 → 冷却降级,到期重试一次。"""
|
||||
|
||||
_ROW = "row"
|
||||
"""行级拒绝: 只与本行数据有关 → 逐条 warning 丢弃,不降级。"""
|
||||
|
||||
# 环境级的 SQLSTATE 类(前两位): 08 连接、53 资源不足(含 53300 too many
|
||||
# connections)、57 管理干预、28 认证、3D 库不存在。共同点是"与这一行的数据无关,
|
||||
# 换一行照样失败",且都能被外部修好
|
||||
_UNAVAILABLE_SQLSTATE_CLASSES = frozenset({"08", "53", "57", "28", "3D"})
|
||||
|
||||
# 类 42 整体归行级(见 `_classify_failure` 的默认档),但这两个码与本行数据无关:
|
||||
# 42501 = 账号被收走 INSERT 权限,42P01 = 表被迁走/删掉。它们是持续性的环境状态,
|
||||
# 按类归行级会让每次 LLM 调用都内联付一次往返、刷一条 warning,且永不自愈
|
||||
_UNAVAILABLE_SQLSTATES = frozenset({"42501", "42P01"})
|
||||
|
||||
# **判据的唯一具名例外**(issue #13 的更高优先级承诺): 42703 = 缺列。按判据第 2 句
|
||||
# 它本该是环境级(缺列时每一行都失败),归行级是因为 manual 档会按现有列裁剪 INSERT
|
||||
# 继续写——"部分列写进去了 + 缺列逐行 warning 暴露"本身有价值,是下游发现 schema
|
||||
# 漂移的唯一信号,不该被冷却掉。**新增例外必须同款论证**: 说清它为什么值得违反判据
|
||||
_ROW_SQLSTATES = frozenset({"42703"})
|
||||
|
||||
|
||||
def _classify_failure(exc: BaseException) -> str:
|
||||
"""按**失败的性质**分档(全库唯一一处 PG 失败分类);判据见模块 docstring。
|
||||
|
||||
分类只认 SQLSTATE 与异常类型,不认"在哪一步失败"——后者正是 issue #15 的病灶。
|
||||
SQLSTATE 而非 asyncpg 异常类白名单: 前者是 PG 标准,不随驱动版本漂移。
|
||||
|
||||
**认不出来的失败一律给最轻的一档**(`_ROW`): 升档(冷却 60s)要有依据,没依据就
|
||||
宁可每次调用多付一次内联往返,也不拿 60 秒的遥测去赌一个猜测。issue #9 定下的
|
||||
"探测抖动只跳过本次、下次重试"正是靠这条默认保住的。
|
||||
"""
|
||||
if isinstance(exc, ValueError | TypeError):
|
||||
# DSN 不可解析(实测: 端口写成非数字 → 裸 ValueError;scheme 不对 →
|
||||
# ClientConfigurationError,它本身就是 ValueError 子类)与建池参数非法。
|
||||
# 这些是构造期就定死的进程内部事实,重试在任何时刻都不可能成功
|
||||
return _FATAL
|
||||
sqlstate = getattr(exc, "sqlstate", None)
|
||||
if isinstance(sqlstate, str):
|
||||
if sqlstate in _ROW_SQLSTATES:
|
||||
return _ROW
|
||||
if sqlstate[:2] in _UNAVAILABLE_SQLSTATE_CLASSES or sqlstate in _UNAVAILABLE_SQLSTATES:
|
||||
return _UNAVAILABLE
|
||||
# 其余 PostgresError(22 数据异常、23 约束冲突等)都是这一行的数据问题
|
||||
return _ROW
|
||||
# 没有 SQLSTATE = 话还没说到 PG 就断了: OSError(含 ConnectionError 与
|
||||
# TimeoutError)与 asyncpg 自己的 InterfaceError,都与本行数据无关
|
||||
return _UNAVAILABLE if isinstance(exc, OSError | _interface_error()) else _ROW
|
||||
|
||||
|
||||
def _interface_error() -> type[BaseException]:
|
||||
"""asyncpg 的 `InterfaceError` 类型;延迟取用以免模块导入期硬依赖 extra。"""
|
||||
import asyncpg
|
||||
|
||||
return asyncpg.InterfaceError
|
||||
|
||||
|
||||
class PostgresRecorder:
|
||||
"""TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。"""
|
||||
|
||||
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
dsn: str,
|
||||
*,
|
||||
pool: asyncpg.Pool | None = None,
|
||||
auto_migrate: bool,
|
||||
pool_max: int,
|
||||
write_timeout_s: float,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
"""记下装配参数(不连库);列与 INSERT 语句在首次准备期定型。
|
||||
|
||||
Args:
|
||||
dsn: asyncpg 连接串(已剥驱动后缀)。
|
||||
pool: 外部注入的池;注入方自己负责关闭。
|
||||
auto_migrate: True 则给已存在的旧表自动补列;False(PG 侧的缺省档)
|
||||
则一条 ALTER 都不发——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE
|
||||
锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联
|
||||
await。keyword-only **必填**: 缺省规则只写在 config 一处,不与本类
|
||||
签名漂移(设计 D-c)。
|
||||
pool_max: 自建池的连接数上限(issue #15)。稳态吞吐**按实测折算,不要按
|
||||
`pool_max / RTT` 估**(那会乐观一倍): RTT ≈ 123ms 上 `pool_max=4`
|
||||
实测约 15.6 行/秒(50 行并发批 3.2s)。与 `auto_migrate` 同一纪律:
|
||||
必填,缺省只写在 config 一处。
|
||||
write_timeout_s: 单次写入的硬预算,同时用作 connect 与 acquire 的上限。
|
||||
now: 单调时钟,注入给降级 tracker(测试可推进冷却与节流窗口)。
|
||||
"""
|
||||
try:
|
||||
import asyncpg # noqa: F401 - 仅探测 extra 是否安装
|
||||
except ImportError as exc:
|
||||
@@ -84,53 +169,350 @@ class PostgresRecorder:
|
||||
self._dsn = dsn
|
||||
self._pool: asyncpg.Pool | None = pool
|
||||
self._external_pool = pool is not None
|
||||
self._auto_migrate = auto_migrate
|
||||
self._pool_max = pool_max
|
||||
self._write_timeout_s = write_timeout_s
|
||||
# 先按全量列定型: 准备期探测失败时保守沿用全量(今天的行为)
|
||||
self._columns: tuple[str, ...] = COLUMNS
|
||||
self._insert = insert_sql("postgres", COLUMNS)
|
||||
self._schema_ready = False
|
||||
self._failed = False # 结构性降级标志: 置位后所有写入短路
|
||||
self._closed = False # 关了就是关了: 置位后写入短路且**不重建池**
|
||||
# 降级状态**只此一份**: 是否短路写入、多久重试一次、下游查到什么,
|
||||
# 全由 tracker 回答。两份状态(曾经的 `_failed` 布尔 + tracker)必然漂移
|
||||
self._status = TelemetryStatusTracker(backend="postgres", now=now)
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def telemetry_status(self) -> TelemetryStatus:
|
||||
"""当前可写状态快照(ports.TelemetryStatusProvider)。"""
|
||||
return self._status.snapshot()
|
||||
|
||||
async def _ensure_ready(self) -> asyncpg.Pool | None:
|
||||
"""lazy 建池+建表;结构性失败 warning 一次后永久降级(设计 §5 两级之一)。"""
|
||||
if self._failed:
|
||||
"""lazy 建池+备表;降级期间**零成本短路**,冷却到期放行一次重新准备。
|
||||
|
||||
`should_retry()` 是纯时间比较,不触库: 降级期间的调用因此既不内联吞
|
||||
connect 超时(`postgres.py` 老注释担心的正是这个),也不需要重启进程——
|
||||
成本变成"每 60s 一次、上界一个写入预算",有界且可解释。
|
||||
|
||||
`_closed` 在锁内**必须复查**: 等锁期间发生的 `aclose` 否则会被这次
|
||||
等待"绕过",等到锁时照旧建出一个没人负责关的池(注入档更隐蔽——
|
||||
注入方以为自己管着全部连接,实际早已不是)。
|
||||
"""
|
||||
if self._closed or not self._status.should_retry():
|
||||
return None
|
||||
if self._schema_ready:
|
||||
return self._pool
|
||||
async with self._init_lock:
|
||||
if self._failed or self._schema_ready:
|
||||
return None if self._failed else self._pool
|
||||
try:
|
||||
if self._pool is None:
|
||||
import asyncpg
|
||||
|
||||
self._pool = await asyncpg.create_pool(self._dsn, timeout=10)
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(_DDL)
|
||||
self._schema_ready = True
|
||||
return self._pool
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._failed = True
|
||||
logger.warning("Postgres 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||
if self._closed or not self._status.should_retry():
|
||||
return None
|
||||
if self._schema_ready:
|
||||
return self._pool
|
||||
pool = await self._open_pool()
|
||||
if pool is None:
|
||||
return None
|
||||
return await self._prepare_schema(pool)
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。"""
|
||||
pool = await self._ensure_ready()
|
||||
if pool is None:
|
||||
return
|
||||
row = tuple(fields[col] for col in _COLUMNS)
|
||||
async def _open_pool(self) -> asyncpg.Pool | None:
|
||||
"""建池;失败按性质分档处置(见 `_handle_failure`),不再一律判死。
|
||||
|
||||
**池的资源占用由本库显式声明**(issue #15): `min_size=0` 的语义是"不预
|
||||
连接"(asyncpg `pool.py:457` 为 0 时只造 holder 对象,一条连接都不连),
|
||||
建池因此从"要么拿到 10 条、要么失败"的重资源动作变成零成本、不触库的
|
||||
动作;连接失败自然落到 acquire 那条本来就正确的"丢一行、池自恢复"路径。
|
||||
`max_size` 是库对自己占用的表态——继承第三方默认值等于不表态(P4),而
|
||||
那正是共享实例余量紧张时先倒下的原因。
|
||||
"""
|
||||
if self._pool is not None:
|
||||
return self._pool
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(_INSERT, *row)
|
||||
import asyncpg
|
||||
|
||||
self._pool = await asyncpg.create_pool(
|
||||
self._dsn,
|
||||
min_size=0,
|
||||
max_size=self._pool_max,
|
||||
timeout=self._write_timeout_s,
|
||||
command_timeout=self._write_timeout_s,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 遥测铁律: 丢一条 < 拖垮调用;仅记 warning(非 pass),池自恢复
|
||||
logger.warning("Postgres 遥测写入失败(丢弃该行): {}", exc)
|
||||
self._handle_failure(exc, stage="建池")
|
||||
return None
|
||||
return self._pool
|
||||
|
||||
async def _prepare_schema(self, pool: asyncpg.Pool) -> asyncpg.Pool | None:
|
||||
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。
|
||||
|
||||
取连接走显式 acquire/release(理由见 `_release`): 准备期同样跑在调用方的
|
||||
写入预算里,`async with` 那条路的归还会把真实上界撑到 ≈2× 预算。
|
||||
"""
|
||||
try:
|
||||
conn = await pool.acquire(timeout=self._write_timeout_s)
|
||||
try:
|
||||
columns = await self._prepare_table(conn)
|
||||
finally:
|
||||
await self._release(pool, conn)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._handle_failure(exc, stage="建表探测")
|
||||
return None
|
||||
if columns is None:
|
||||
# 表确定不存在且建不出来: 与本行数据无关(每行都会同样失败)且能被
|
||||
# 外部修好(DBA 建了表就该自愈)—— 判据第 2 句下的环境级
|
||||
self._status.enter_degraded(
|
||||
"表 llm_calls 不存在且建不出来(记录无处可落)",
|
||||
fatal=False,
|
||||
cooldown_s=_DEGRADE_COOLDOWN_S,
|
||||
)
|
||||
return None
|
||||
# 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready`
|
||||
# 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口
|
||||
self._columns = columns
|
||||
self._insert = insert_sql("postgres", columns)
|
||||
self._schema_ready = True
|
||||
return pool
|
||||
|
||||
async def _prepare_table(self, conn: object) -> tuple[str, ...] | None:
|
||||
"""备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。
|
||||
|
||||
返回 None 仅表示表确定不存在且建不出来(调用方据此进环境级冷却降级)。
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
|
||||
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
|
||||
只授 `SELECT, INSERT ON llm_calls` 的角色,表明明在、也写得进去,这一句
|
||||
照样被拒 `permission denied for schema`)。这与 `_backfill_columns` 撞的
|
||||
是同一类问题(issue #3/#9),故守卫也必须同款: 先探测,后 DDL。
|
||||
探测走 `to_regclass`,不需要任何权限,且与 INSERT 的 search_path 解析
|
||||
口径一致——比裸 DDL 更准(裸 `CREATE TABLE` 落在首个**可建**的 schema,
|
||||
可能与 INSERT 命中的不是同一张表)。
|
||||
"""
|
||||
exists = await conn.fetchval(_TABLE_EXISTS) is not None # type: ignore[attr-defined]
|
||||
if exists:
|
||||
return await self._resolve_columns(conn) # 旧表可能缺列
|
||||
try:
|
||||
await conn.execute(PG_DDL) # type: ignore[attr-defined]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc)
|
||||
return None
|
||||
return COLUMNS # 新建表列已齐全,无需再走补列
|
||||
|
||||
async def _resolve_columns(self, conn: object) -> tuple[str, ...]:
|
||||
"""探测旧表现有列并定型写入列: auto 档先补齐,manual 档改为裁剪(issue #13)。
|
||||
|
||||
**先探测**的理由(两档共用): `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会
|
||||
**先取 ACCESS EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是
|
||||
内联 await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
||||
|
||||
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
|
||||
"""
|
||||
try:
|
||||
existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Postgres 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
|
||||
return COLUMNS
|
||||
if self._auto_migrate:
|
||||
await self._backfill_columns(conn, existing)
|
||||
return COLUMNS
|
||||
return self._trim_columns(existing)
|
||||
|
||||
def _trim_columns(self, existing: set[str]) -> tuple[str, ...]:
|
||||
"""manual 档: 按现有列裁剪写入列,并把缺列一次讲清楚。
|
||||
|
||||
裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
|
||||
都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。
|
||||
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法
|
||||
INSERT,`insert_sql` 会 ValueError,而 `_prepare_schema` 里那次调用在 try
|
||||
**之外**,异常会顺着 `record_llm_call` 一路冒给业务调用方(遥测绝不冒泡)
|
||||
——回落必须发生在把空列集交给它之前。
|
||||
"""
|
||||
effective = tuple(column for column in COLUMNS if column in existing)
|
||||
if not effective:
|
||||
logger.warning(
|
||||
"Postgres 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
|
||||
"现有列: {}",
|
||||
sorted(existing),
|
||||
)
|
||||
return COLUMNS
|
||||
missing = [column for column in COLUMNS if column not in existing]
|
||||
if missing:
|
||||
# 单参数传入: 补列 SQL 里带 `'{}'::jsonb` 字面量,拼进 format 模板会被当占位符
|
||||
logger.warning(
|
||||
"{}",
|
||||
missing_columns_warning("postgres", missing, alien_table="call_id" not in existing),
|
||||
)
|
||||
return effective
|
||||
|
||||
async def _backfill_columns(self, conn: object, existing: set[str]) -> None:
|
||||
"""auto 档: 给已存在的旧表补新列(issue #3);**失败绝不让 recorder 降级**。
|
||||
|
||||
不降级的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的
|
||||
ownership 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。降级会让
|
||||
整个 recorder 停写(环境级还要停满一个冷却期),与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||
(SQLite 侧同款守卫,两侧必须对称)。补列失败后写入沿用全量列(今天的行为):
|
||||
auto 档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入
|
||||
请显式选 manual。
|
||||
"""
|
||||
try:
|
||||
for column, statement in PG_BACKFILL:
|
||||
if column not in existing:
|
||||
await conn.execute(statement) # type: ignore[attr-defined]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
def _handle_failure(self, exc: BaseException, *, stage: str) -> None:
|
||||
"""按分档处置一次遥测失败;三个降级点(建池/建表探测/写入)共用这一处。
|
||||
|
||||
收敛成一处不只是去重: 三处各写一遍处置,就是三处各自漂移一次判据的机会,
|
||||
而判据漂移正是 issue #15 的病灶(注释写着"确定写不进去",代码做的是别的事)。
|
||||
|
||||
`stage` 只进日志文案,**不参与分档**——挂步骤分档正是要被拆掉的错法。
|
||||
"""
|
||||
verdict = _classify_failure(exc)
|
||||
if verdict == _FATAL:
|
||||
# 这里**不再**另发一条 error: 级别由 tracker 按 `fatal` 决定(致命档发
|
||||
# error——人配错了,本进程内不会自愈)。此处复制一条只会让同一个事实出
|
||||
# 两条语义重复的日志,并给"级别"这个决策造出第二个源头
|
||||
self._status.enter_degraded(
|
||||
f"{stage}失败(配置有误): {exc}", fatal=True, cooldown_s=None
|
||||
)
|
||||
elif verdict == _UNAVAILABLE:
|
||||
# 每一行都会同样失败 → 冷却期内不再内联重试;`_schema_ready` 一并作废,
|
||||
# 到期那次要重新走准备(表被删/权限被收回都得靠重新准备才能发现已修好)
|
||||
self._schema_ready = False
|
||||
self._status.enter_degraded(
|
||||
f"{stage}失败: {exc}", fatal=False, cooldown_s=_DEGRADE_COOLDOWN_S
|
||||
)
|
||||
else:
|
||||
# 行级不进降级: 换一行可能就成了。逐条出声是 issue #13 的承诺
|
||||
# (缺列靠这条 warning 暴露 schema 漂移),不因刷屏而节流掉
|
||||
logger.warning("Postgres 遥测{}失败(丢弃该行,下次调用照常重试): {}", stage, exc)
|
||||
|
||||
def _drop_reason(self) -> str:
|
||||
"""写不进去时说清是**哪一种**写不进去: 关了 / 降级中 / 本次没准备好。
|
||||
|
||||
三者的处置完全不同(一个是调用方自己关了却还在写、一个等自愈、一个下次
|
||||
就会重试),混成一句话会让对账的人分不清该等还是该修。
|
||||
"""
|
||||
if self._closed:
|
||||
return "遥测已关闭"
|
||||
if self._status.snapshot().degraded:
|
||||
return "遥测降级中"
|
||||
return "后端本次未准备好(下次调用重试)"
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;整次写入受硬预算约束,失败逐条丢弃(两级降级之二),绝不冒泡。
|
||||
|
||||
**硬预算**(issue #15): 准备 + 取连接 + 执行合计不得超过 `write_timeout_s`。
|
||||
这把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"变成一条可陈述、可测试的
|
||||
保证——此前 `pool.acquire()` 无超时(asyncpg 缺省 `timeout=None` = 无限等),
|
||||
池满时会无限期挂在业务路径上。
|
||||
|
||||
外部取消照常穿透: `asyncio.timeout` 只把**自己**触发的 cancel 转成
|
||||
TimeoutError,故 `CancelledError` 分支必须排在最前且原样 re-raise(铁律)。
|
||||
"""
|
||||
try:
|
||||
async with asyncio.timeout(self._write_timeout_s):
|
||||
await self._write_row(fields)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Postgres 遥测写入超预算 {}s(丢弃该行);后端慢不得拖垮业务调用",
|
||||
self._write_timeout_s,
|
||||
)
|
||||
self._status.record_drop("写入超预算")
|
||||
except Exception as exc:
|
||||
# 遥测铁律: 丢一条 < 拖垮调用。这一行无论如何都没了,区别只在于
|
||||
# **下一行还试不试**——那由失败的性质决定,不由这里决定
|
||||
self._handle_failure(exc, stage="写入")
|
||||
self._status.record_drop("写入失败")
|
||||
|
||||
async def _write_row(self, fields: dict[str, object]) -> None:
|
||||
"""预算内的写入本体: 准备 → 取连接 → 执行 → 归还。
|
||||
|
||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||
"""
|
||||
pool = await self._ensure_ready()
|
||||
if pool is None:
|
||||
# 降级期间静默 return 就是 issue #15 的破口: 丢行必须计数且节流出声
|
||||
self._status.record_drop(self._drop_reason())
|
||||
return
|
||||
row = tuple(fields[col] for col in self._columns)
|
||||
conn = await pool.acquire(timeout=self._write_timeout_s)
|
||||
try:
|
||||
await conn.execute(self._insert, *row)
|
||||
finally:
|
||||
await self._release(pool, conn)
|
||||
# **恢复的唯一权威证据是一次真正写成功**(未降级时是廉价 no-op)。放在这里
|
||||
# 而不是准备期: 准备通过不代表写得进去(权限只到 SELECT 时正是如此)
|
||||
self._status.recover()
|
||||
|
||||
async def _release(self, pool: asyncpg.Pool, conn: object) -> None:
|
||||
"""归还连接;归还路径独立有界,失败即断开(下次 acquire 会补一条新的)。
|
||||
|
||||
**不用 `async with pool.acquire()`**(设计 §3.1,已核实): asyncpg 的
|
||||
`Pool.release` 是 `await asyncio.shield(ch.release(timeout))`,且那个
|
||||
timeout 默认复用 acquire 时记录的 `ch._timeout`(`pool.py:886-889,
|
||||
930-937`)。写入预算到期时 cancel 在 execute 处抛出,异常传播中执行
|
||||
`__aexit__`,此时没有新的 cancel 投递——那次 shielded release 会**正常
|
||||
等到完成**,业务路径的真实上界因此变成 ≈2 × 预算。显式归还才能给它一个
|
||||
独立的小上限,承诺才精确成立: 主写入尝试 ≤ 预算,归还路径独立有界。
|
||||
"""
|
||||
try:
|
||||
await pool.release(conn, timeout=_RELEASE_TIMEOUT_S)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 含 TimeoutError: 归还超时与归还出错的处置相同——断开而不是留一条
|
||||
# 状态不明的连接在池里(asyncpg 的 reset 失败路径也是这么做的)
|
||||
logger.warning("Postgres 遥测连接归还失败(强制断开): {}", exc)
|
||||
self._terminate(conn, label="连接")
|
||||
|
||||
@staticmethod
|
||||
def _terminate(target: object, *, label: str) -> None:
|
||||
"""强制断开一条连接或整个池;断开本身再失败也只记 warning(遥测绝不冒泡)。
|
||||
|
||||
`label` 必填(不给默认值): 两个调用点的诊断价值全在"拆的是哪一层",
|
||||
默认值只会让其中一处悄悄报错成另一处。
|
||||
"""
|
||||
try:
|
||||
target.terminate() # type: ignore[attr-defined]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Postgres 遥测{}断开失败(交给上层自行回收): {}", label, exc)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""幂等关闭自建池;注入的池归注入方管理。"""
|
||||
"""幂等关闭自建池;**关了就是关了**,此后写入短路且不复活。注入的池归注入方管理。
|
||||
|
||||
取消"关完还能自己重建池"的灰色状态(设计 §3.2 第 4 点): 关闭是所有权的
|
||||
终结,而恢复是运行时行为(冷却重试),不该是关闭动作的副作用。
|
||||
|
||||
**关闭动作本身也有界**: `Pool.close()` 会 await 每个 holder 的
|
||||
`wait_until_released()`,in-flight 连接不归还就无限等——收尾路径上照样是
|
||||
"遥测拖垮业务"。超时即 `terminate()` 强拆: 关闭已在进行,留着一个关不掉的池
|
||||
既不会自愈也没人再来收。外部取消照常穿透(铁律),不当成一次关闭超时。
|
||||
"""
|
||||
self._closed = True
|
||||
pool, self._pool = self._pool, None
|
||||
self._schema_ready = False
|
||||
if pool is not None and not self._external_pool:
|
||||
await pool.close()
|
||||
if pool is None or self._external_pool:
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(pool.close(), timeout=_CLOSE_TIMEOUT_S)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 含 TimeoutError: 关不掉与关出错的处置相同——强拆
|
||||
logger.warning("Postgres 遥测池关闭失败(强制断开): {}", exc)
|
||||
self._terminate(pool, label="池")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user