补进「请先读这一条(四)」之后,CHANGELOG:20 与设计 §7 仍写「三条/三处」, 同一份文档里出现自相矛盾的计数——正是本轮在消灭的那类失真。一并给设计 §7 补上第 4 条的正文,免得清单与 CHANGELOG 再次漂移。
66 KiB
Changelog
未发布
遥测后端从此按需占用连接、失败可自愈、降级可查询(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 上:
-- 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 的 RLSUSING表达式返回 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的构造签名增加必填 keywordscope。 库内 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.mdG1 已补注)。
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://…的+asyncpgasyncpg 不认)。这一条剥的时候会发一条 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)。
- M1 核心: types/errors/ports 内核、OpenAI 兼容 httpx transport(SSE + 非流式)、三层活性看门狗、自研重试(错误四分类驱动,换源/退避/Retry-After)、多源多账号 + 选源 + 源冷却、内存限流/熔断、Redis/内存响应缓存(key 含 namespace/salt/多模态摘要)、SQLite 遥测(18 字段必录)、结构化输出阶梯(json_repair/原生 schema + 有界重问)、provider 注册表、
from_env装配。 - M2 分布式: Redis 六道闸限流(Lua,契约测试双后端共用)、跨进程熔断(单探针租约 + epoch fencing)、背压 stall 双条件判定、Postgres 遥测、pricing 成本、EmbeddingClient(分批/维度校验)。
- M2.5 治理韧性: 双通道熔断(失败率窗 + 连败 + 健康证据抑制)、健康感知选源(EWMA×在途 P2C 缺省)、AIMD 自适应并发、429 免重试预算、健康门槛降权;故障混编 soak 同场景 58.1%→98.96%。
- M3 OCR: OcrTextPort/OcrLayoutPort 端口族 + MonkeyOCR 双端点 transport(数值防御下沉)、OcrClient 独立治理循环、
check_health()逐源预检;OCR soak 1500 调用 99.73%。 - M4 迁移验证: GovDoc 与 CHS 全量迁移(合计约 −6800 行项目治理代码由库继任),原测试全绿 + 真实冒烟 + 50 样本回归;Gitea PyPI 分发。
安装(实验室 Gitea PyPI):
pip install --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
--extra-index-url https://pypi.org/simple/ "polygateway[redis,postgres,structured]==1.0.*"