The reasoning_tokens docstring was still teaching downstream to treat None or 0 as no reasoning. The changelog and the schema page had both been corrected; the docstring had not, and it is the copy that ships in the wheel and shows up on hover. Someone writing a report from it would have counted every real MiniMax reasoning call as not reasoning, which is issue #16 all over again with the tests green. The original wording stays, since reading pre-1.3.1 rows still needs it. What follows it now says when it expired and what to read instead. Two more places had drifted the same way: the changelog and the architecture doc described the throttle and the cache fallback as they were before this review, which is to say as the opposite of what the code now does. The claim that the two throttle sets would suppress each other does not survive checking, as the mutation testing showed: their key spaces do not overlap. Keeping them apart is still right, but for the honest reason, which is that the two warnings have unrelated lifetimes.
115 KiB
PolyGateway 架构文档
文档定位: 本文档是 PolyGateway 的架构单一事实源,记录项目边界、全部架构决策(含讨论过程与被否决的备选方案)、各子系统设计与三项目迁移验收标准。
- 与
research-wiki/designs/的关系:designs/存放每次实现具体功能时的设计文档(受 ≤400 行约束);本文档不受行数限制,以"让后来的 AI 或人类无需还原原始讨论就能准确理解全部决策及其理由"为准绳。功能设计文档与本文档冲突时,以本文档为准,或先修订本文档。- 状态: 边界与决策已与人类逐条讨论确认(2026-07-19),待人类终审。
- 前置输入: 对
reference/下三个项目(Video-Tree-TRM5 / CHSAnalyzer / GovDoc-SaaS)LLM 层与 OCR 层的完整代码调研,关键证据以文件:行号形式散布于本文各节。
0. 一页速览
PolyGateway 是实验室内部统一的大语言模型(LLM/VLM/OCR,音频预留端口)调度与中转库。它治理的单位是一次模型调用:业务侧把就绪的 messages/图像字节交给它,它负责多源选择、限流、发请求、流式解析、错误分类、重试换源、熔断、缓存、遥测记账,返回统一的响应类型。
期望的使用形态(90% 用户只需要这三行):
client = GatewayClient.from_env()
resp = await client.chat(messages) # resp: LLMResponse
全部治理能力组织为中间件洋葱,全部易变点(状态存哪、协议怎么发、源怎么选、输出怎么解析、遥测记到哪)定义为端口(Protocol),按部署配置插拔。库对下游零业务假设,验收标准是三个参考项目删掉各自的治理代码、换成本库后原测试全过(§11)。
1. 背景:为什么需要这个库
1.1 三个参考项目与它们的同源性
实验室三个项目各自维护一套 LLM/VLM 调用基础设施。调研证实它们本质是同一套治理栈的三次复制与变异:
- Video-Tree-TRM5(长视频理解科研,批处理形态):治理栈的源头之一。
adapters/llm.py的GovernedLLMClient实现五层治理(熔断→缓存→重试+流式→写缓存→遥测)。 - GovDoc-SaaS(多租户文书 SaaS,服务形态):第一次抽库尝试。
packages/docagent-core/src/docagent_core/llm/已经是独立包,代码注释多处标注"移植自 CHSAnalyzer2 / Video-Tree-TRM5"。但装配层(配置→client)始终没写完,且并发限流完全缺失。 - CHSAnalyzer(超声影像分析,服务形态):独立演化出三者中最强的分布式治理——Redis+Lua 原子限流、跨进程熔断、多源多账号,但完全没有响应缓存,且同一项目里还并存着一个无任何治理的裸 SDK 调用(
core/eval/judge.py,反面教材)。
三者共同的技术底色高度一致,这是统一库可行的基础:全部用 httpx 手写 OpenAI 兼容 SSE 协议(不用官方 SDK 发聊天请求)、自研重试(不用 tenacity)、pydantic-settings + .env、loguru、@runtime_checkable Protocol 依赖注入、组装点(Composition Root)统一构造。
1.2 能力对比矩阵
| 能力 | Video-Tree-TRM5 | GovDoc-SaaS | CHSAnalyzer |
|---|---|---|---|
| 治理网关(重试/退避/超时) | ✅ GovernedLLMClient |
✅ 同款移植 | ✅ Invoker/Governance 分层(结构最好) |
| 错误分类 | ⚠️ 二分类(瞬时/致命) | ⚠️ 同款 | ✅ 三分类 + Retry-After 解析 + 工件级失败 |
| 限流 | ❌ 仅 asyncio.Semaphore |
❌ 完全没有 | ✅ Redis+Lua 六道闸(并发/RPM/TPM × 全局/单源) |
| 熔断 | ⚠️ 进程内存态 | ⚠️ 同款 | ✅ Redis 跨进程 + epoch fencing |
| Redis 响应缓存 | ✅ sha256 内容寻址 | ✅ 同款(缺租户隔离) | ❌ 完全没有 |
| 多源多账号切换 | ❌ 单源 | ❌ 单源 | ✅ 多源配置 + 选源策略端口 |
| 流式活性看门狗 | ✅ TTFT/token间/总时长三层 | ✅ 同款 | ✅ 同款 |
| 多模态(图像) | ✅ base64 注入 content 数组 | ❌ 明确不做 | ✅(端口限单图,靠拼图绕过) |
| OCR | ⚠️ 裸调 /ocr/text |
❌(明确剥离) | ✅ 全治理 /parse |
| 音频/ASR | ❌ 死配置(配了 Groq whisper 无实现) | ❌ | ❌ |
| 遥测 | ✅ SQLite 每调用必录 | ✅ 同款(设计可注入 Postgres) | ⚠️ 仅 JSON 结构化日志 |
| 装配工厂(配置→client) | ⚠️ 每项目手写 _build_adapters() |
❌ 缺失(env 定义了但没人读) | ⚠️ 手写 container |
没有任何一个项目是完整的;每个项目各有一块别人没有的能力,同时各缺一块。
1.3 每次重新开发的到底是什么(五类重复)
- 治理栈本体被复制至少三次,每次复制产生变异(缓存 salt、租户隔离、错误分类粒度各不相同),修一个 bug 修不到另外两份。
- "配置 → 装配 client" 每个项目手写一遍;GovDoc 甚至
.env参数定义齐全但装配层缺失。 - 三套互不一致的 JSON 结构化输出解析:json_repair / 手写正则 /
find('{')+rfind('}')并存,甚至同一项目内并存(Video-Tree)。 - provider 差异靠字符串猜:
"qwen" in provider、model.split("-")[0]推断 provider,接新 provider 要改核心类。 - 同样的坑各踩各的:遥测调用逐字复制 4 次(Video-Tree 与 GovDoc 都有,
record_llm_call十几个参数的调用散布在缓存命中/成功/致命/瞬时/非重试五个分支);CHSAnalyzer 的 judge 同步裸调无治理。
1.4 各项目关键资产索引(移植蓝本)
| 资产 | 来源 | 移植去向(§7) |
|---|---|---|
| 治理网关主循环(参考结构,需重构掉遥测复制) | Video-Tree/adapters/llm.py、GovDoc/packages/docagent-core/src/docagent_core/llm/client.py |
client + middleware |
| 三层流式活性看门狗(纯函数,近乎原样复用) | 三项目同款 streaming.py |
streaming.py |
| 进程内熔断器(时钟注入、单探针) | Video-Tree/adapters/breaker.py |
backends/memory/ |
| Redis 跨进程熔断(epoch fencing) | CHSAnalyzer/app/coordination/provider_gate.py |
backends/redis/ |
| Redis+Lua 六道闸限流(含 permit/settle) | CHSAnalyzer/app/coordination/limiter.py + scripts.py(约 245 行 Lua)及契约测试 tests/contracts_limiter.py |
backends/redis/ |
| Redis 响应缓存(sha256 key、TTL、salt、静默降级) | Video-Tree/adapters/redis_cache.py |
backends/redis/ |
| 错误三分类 + HTTP→领域翻译 | CHSAnalyzer/app/domain/errors.py、app/providers/invokers.py:127-227 |
errors.py + transports |
| 治理编排泛型核心(VLM/OCR 共用一个 core 的先例) | CHSAnalyzer/app/providers/governance.py:200 |
middleware 组合 |
| SQLite 遥测(WAL、幂等、to_thread 桥接) | Video-Tree/adapters/telemetry.py、GovDoc/.../telemetry_sqlite.py |
telemetry/sqlite.py |
统一响应类型 LLMResponse(frozen dataclass) |
三项目同款 types.py |
types.py(超集兼容) |
多源配置解析(SCOPE__PROVIDER__N__FIELD) |
CHSAnalyzer/app/config.py:223 |
配置层 |
| JSON 围栏剥离 + json_repair + 变体归一化 | Video-Tree/core/agent/loop.py:341-399 |
structured/json_repair.py |
| MonkeyOCR 两端点 invoker | Video-Tree/adapters/ocr.py、CHSAnalyzer/app/providers/invokers.py:408-552 |
transports/monkey_ocr.py |
2. 定位、目标与非目标
2.1 定位:治理单位是"一次模型调用"
三个项目呈现两种使用形态,曾担心无法统一;结论是在调用层天然同构,在编排层不应统一:
CHSAnalyzer / GovDoc(服务形态) Video-Tree(批处理形态)
────────────────────────────── ─────────────────────────
HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协程
└────── await client.chat(...) ──────┘ ← 库站在这一层
(多源/限流/重试/熔断/缓存/遥测)
- 编排层(上):单位是"一个业务任务"(解析一份文书、建一棵视频树),带任务持久化、阶段重试、租户配额等业务概念——留在业务侧。
- 调用层(下):单位是"一次模型调用"。对库而言,上方是 arq worker 里的协程还是 gather 出来的协程毫无区别,都只是并发调用者。
为使两种形态同构接入,库承诺三条硬约束(详见 §4.2 库铁律与 §6.4):纯 asyncio 无全局状态、CancelledError 全栈穿透、配额满时的行为(等待 vs 快速失败)可配置。
2.2 目标能力
请求封装(LLM/VLM 聊天 + OCR)、多源多账号与选源、限流(并发/RPM/TPM)、错误分类与重试退避、熔断、Redis 响应缓存、流式活性看门狗、遥测(含成本统计)、结构化输出策略、装配工厂。全组件端口化可插拔,配置驱动装配。
2.3 非目标(已确认,含理由)
| 不做 | 理由(讨论结论) | 归属 |
|---|---|---|
| 任务队列(arq)/任务编排 | 队列单位是业务任务,库单位是单次调用,高度不同;强行进库会迫使批处理项目部署队列、并把"任务"业务概念污染进零业务假设的库。Video-Tree 声明了 arq 依赖却从未使用(死依赖)是现实佐证 | 业务侧 |
| 视频抽帧(ffmpeg)、图像裁剪/拼接/增强等预处理 | 纯业务先验(超声图表格在左上角、每 5 帧一批等),且会拖入 ffmpeg/PIL/numpy 重依赖;库只收就绪的 content 数组/图像字节 | 业务侧 |
| OCR 结果的几何映射(坐标换算/归一化/marker 推算) | 同上,业务先验;库只返回 OCR 服务的原生 bbox + page_size | 业务侧 |
| Prompt 模板管理 | 三项目组织方式差异过大(md 文件加载 / 模块常量 / 版本化进化资产),无公共形态可抽 | 业务侧 |
| Agent Loop / 工具调用编排 | 属 agent 运行时,不属调用治理;但其中的 JSON 解析部分下沉为库的结构化输出策略(D7) | 业务侧 |
| 音频模型实现 | 实际用量少,收益不及维护成本;只预留端口,需求到来时增量实现 | 库(仅端口) |
| 公平调度(CHSAnalyzer 的按 Session 轮转 position_scheduler) | 调用级公平队列是该项目的业务需求,不是通用治理关注点 | 业务侧 |
3. 架构决策记录(D1–D15,含讨论过程与备选方案)
每条决策记录格式:决策 / 背景与讨论 / 被否决的备选 / 影响。这些决策已与人类逐条确认;推翻任何一条需要人类批准并修订本节。
D1 架构风格 = 端口适配器 + 中间件洋葱模型
决策: 借鉴 Clean Architecture 的三条原则——依赖规则(核心不依赖具体技术)、端口与适配器(Protocol 定义接缝)、组装点(所有构造集中注入);不照搬其面向应用的四层分层(Entities/Use Cases/Interface Adapters/Frameworks)。库内部的组织模式采用中间件洋葱(同 ASGI middleware / gRPC interceptor / Rust tower):重试、限流、熔断、缓存、遥测各为一层,层与层正交,顺序与取舍是配置。
背景与讨论: 人类提问"是否借鉴《Clean Architecture》,是否有更好的指导思想"。结论:那本书为应用程序而写,库没有"用例层",硬套四层会造出空转抽象。对库更适配的思想来源:
- Hexagonal / Ports & Adapters(Cockburn):三项目已在实践的本质。
- 《A Philosophy of Software Design》(Ousterhout)的"深模块、窄接口":接口复杂度是用户付的成本。落地为——90% 用户三行起步(
from_env()→chat()),全部可配置性经构造函数暴露给需要的人,但绝不强迫简单用户理解。 - 中间件洋葱:与治理栈天然同构。反面证据:三项目的
GovernedLLMClient.chat()是约 500 行的方法,五层治理手工内联在一个重试循环里,横切关注点没有被切开,遥测调用因此被迫复制 4 次。洋葱模型下遥测就是一层,只写一次。
被否决的备选: 照搬应用四层(过度抽象);继续单体 God-method(现状,已证明产生复制)。
影响: Middleware 协议为 (request, call_next) -> response;§4 给出默认层序及理由;§8 的依赖纪律由 import-linter 机械执法。
D2 Transport 默认手写 httpx OpenAI 兼容协议;Transport 是端口,SDK 适配器可选
决策: 默认 transport 复用三项目久经实战的手写 httpx + SSE 实现;同时把 transport 定义为端口,提供可选的 OpenAISDKTransport(薄封装),未来接 Anthropic 原生/Gemini 时增量加对应 transport,治理栈零改动。
背景与讨论: 人类要求完整阐述官方 SDK 与手写的差异优劣。核心对比:
| 维度 | 手写 httpx | 官方 SDK(openai) |
|---|---|---|
| SSE 协议解析(帧格式、畸形帧、usage 帧、[DONE]) | 自己写自己修(约 200 行),但全可控 | SDK 维护,跟随协议演进 |
| 错误分类 | 状态码 + body 字符串匹配,自己写 | 类型化异常层级(RateLimitError 等),映射干净 |
非标字段(qwen enable_thinking、deepseek reasoning_content) |
天然支持 | extra_body 写入 + model_extra 读出,够用 |
| 流式活性看门狗 | 天然支持 | 同样支持(看门狗包的是 __anext__,对 SDK 的 chunk 迭代器同样生效) |
| 线路级异常定性(如"未收到 [DONE] 即断流") | 能精确检测并作为熔断输入 | 做不到(SDK 内迭代器正常结束)——这是换 SDK 的真实损失 |
| 依赖 | 零新增 | openai 包,有大版本破坏史 |
| 私有网关不合规帧 | 实测调,全可控 | SDK 行为不受控 |
| 非 OpenAI 协议 | 每协议再手写一套 | 每协议换官方 SDK,成本低 |
讨论中澄清了两个常见误解:(a)"用 SDK 就失去 thinking/自定义参数"——不成立,extra_body/model_extra 是官方逃生口;(b)"用 SDK 就没法做活性看门狗"——不成立,看门狗包装异步迭代器,与实现无关。真正拿不回来的只有线路级异常的精确定性,而该信号目前是熔断器输入之一。
默认选手写的理由:实验室主要打 OpenAI 兼容私有中转网关(newapi 等),该场景下 SDK 增益最小(类型化错误)、风险最大(网关不合规帧);且现有代码已实战验证。
被否决的备选: 全库押注 SDK(丢线路级信号,依赖风险);全库永远手写(接非 OpenAI 协议成本高)。端口化让"SDK vs 手写"从全局站队降级为 per-provider 选择。
影响: Transport 端口职责 = 请求体组装 + 流式解析 + HTTP/线路错误 → 领域错误翻译(§6.2);provider 差异不写在 transport 里,写在 provider 注册表(D11)。
D3 限流/熔断双后端:决策逻辑一份,状态存储端口化
决策: 限流与熔断的算法(退避公式、窗口计算、开路/半开/探针状态机)只实现一次;状态存储(计数器、租约、失败数)定义为端口,发货 InMemory* 与 Redis* 两组实现,按部署配置选择。
背景与讨论: 人类初期"限流/熔断状态放哪一层没想好"。分析:状态放进程内存——单进程批处理(Video-Tree)完全正确、零依赖零开销;但多 worker 时每进程各持一份计数器,配置 RPM=60 会实际打出 60×N,熔断信息也不共享。状态放 Redis(CHSAnalyzer 现状)——全局限额真实生效,但强依赖 Redis + Lua,对单进程脚本过重。结论:这不是架构二选一,而是不同部署形态各有正确答案;把状态存储做成端口后,该问题从"现在必须决定、以后改要动核心"降级为"每个项目接入时的一行配置"。人类确认接受此方案。
被否决的备选: 只做 Redis(强迫脚本用户起 Redis);只做进程内(CHSAnalyzer 无法迁移);在库外让各项目自己解决(重复即回归)。
影响: §7.3/§7.4 分别定义两种后端必须满足的同一套语义契约;后端不可用时的降级方向见 §4.2 库铁律(限流/熔断后端不可用必须报错而非放行)。
D4 Redis 响应缓存为第一优先交付
决策: 继承 Video-Tree/GovDoc 已验证的方案(sha256 内容寻址 key、TTL、Redis 不可用静默降级),并修正三个已知缺陷:key 增加命名空间/租户字段(GovDoc 多租户铁律,现实现缺失,存在跨租户缓存命中风险)、多模态 content 先摘要再 hash(Video-Tree 现把整段 base64 图片喂进 sha256,开销巨大)、保留 cache salt(跨 epoch 强制重采样)。
背景与讨论: 人类明确"Redis 缓存对我们很重要,应该实现"。澄清过一个误会:D3 的"双后端"只关于限流/熔断状态,与响应缓存无关;缓存后端本身也是端口(CacheBackend),但 Redis 实现是默认且第一优先。CHSAnalyzer 完全没有响应缓存(相同图像+指令重复付费),迁移后免费获得。
影响: key 组成公式见 §7.5;缓存命中也必须写遥测(cache_hit=True,latency_ms=0),且不消耗限流配额(§4.3 层序理由)。
D5 arq/任务队列不进库
决策与理由: 见 §2.1 与 §2.3 第一行。人类确认。
影响: 库承诺三条约束(纯 asyncio 中立、取消穿透、配额满行为可配)保证两种形态同构接入;附带交付 gather_bounded(calls, concurrency) 十行级便利函数,替代 Video-Tree 手搓的 semaphore+gather 样板——它是便利函数,不是队列。
D6 多源多账号是硬需求
决策: 多源配置(每源独立 base_url/api_key/model/限额)+ SourceSelector 端口(首发 round_robin 与 least_inflight 两策略)+ 错误分类驱动换源(§6)。人类确认"必须满足"。
影响: 单源项目(GovDoc/Video-Tree 现状)= 源列表长度为 1 的特例,不感知复杂度;源冷却备忘(熔断源在本地记冷却截止,避免白烧配额去探测)一并移植(CHSAnalyzer governance.py:107)。
D7 结构化输出为可选策略,不二选一
决策: StructuredOutputStrategy 端口,两个首发实现:JsonRepairStrategy(prompt 约定 + 围栏剥离 + json_repair 事后修复 + provider 变体归一化,即三项目现状的收敛统一)与 NativeSchemaStrategy(response_format / function calling,网关支持时使用)。按 provider 能力声明(D11)与调用参数逐调用选择。人类明确要求做成可选项。
影响: 消灭三项目三套互不一致的 JSON 解析(§1.3 第 3 条);解析失败抛 ResultInvalidError(坏结果≠坏服务,§6.3)。
D8 遥测必录、后端可插拔、成本并入遥测
决策: 每次调用(含缓存命中与失败)必经 TelemetryRecorder 记录;字段继承三项目 15 字段规范(§7.8)并新增成本字段;后端首发 SQLite(默认)与 Postgres;pricing 表(model → 单价)把 token 用量换算为金额。人类确认遥测方式需可配置、成本统计加入遥测。
影响: 遥测调用点收敛为单一 helper(针对三项目 4 处复制的教训,列为库铁律);遥测写失败降级不冒泡(记录基础设施不得拖垮业务调用)。
D9 OCR 提前纳入;端口族而非单接口
决策: OCR 优先级提前(高于音频)。因两个项目调用同一套 MonkeyOCR 服务的两个不同端点、两种输出语义,统一为单接口会强行合并不同类型的能力,故做成端口族:OcrTextPort.recognize_text(bytes)(对应 /ocr/text,纯文本转录)与 OcrLayoutPort.parse_layout(bytes)(对应 /parse,ZIP→结构化元素+bbox)。OCR 调用复用与 LLM 同一套治理算法件与错误分类(限流/熔断门、退避公式、冷却备忘、遥测 helper、健康选源),循环形态取 EmbeddingClient 先例的独立精简循环(M2 §7.1 有限重复裁决;M3 设计 §2 方案 A——chat 循环已长出 AIMD/429 免预算/流式看门狗等 OCR 不适用机制,CHSAnalyzer 泛型核心的同构前提不再成立)。
背景(调研结论): Video-Tree 用 /ocr/text 把帧文字作为"硬证据"并置进 VLM 提示词,裸调(无重试/限流/熔断,单帧失败跳过,手写双端点轮询)——迁移后免费升级为全治理。CHSAnalyzer 用 /parse 定位表格 bbox,已全治理。GovDoc 无 OCR 且是明确架构否决(旧系统被"OCR 挂死"拖垮,教训反哺 §6.4 取消语义)。多后端预留有直接证据:CHSAnalyzer provider 白名单已含 glm(延后),设计文档明确三种后端图片输入形态各异(VLM=base64、Monkey=multipart、GLM=URL)——因此端口收 bytes,编码差异封装在 invoker 内部。
影响: 协议细节与后处理分界见 §7.10;TableLocator 这类业务化端口留在业务侧,由库端口结果组装。
D10 音频只留端口
决策: AudioPort 占位 Protocol,不实现。人类确认"留出接口即可"。Video-Tree 的 ASR 死配置(.env 配了 Groq whisper 但零实现)不作为需求证据。协议形态(OpenAI /audio/transcriptions vs 聊天式多模态)留待真实需求出现时定,届时写功能设计文档。
D11 provider 差异显式化(注册表)
决策: 消灭 "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])。
D13 重试自研,不引入 tenacity
决策: RetryMW 的重试循环自研(即移植三项目已实战验证的循环并收敛为单层),不引入 tenacity;下游业务项目中"重试一个幂等调用"的简单场景可自行使用 stamina,但不属于本库。
背景与讨论: 三个参考项目当年因不知道 tenacity 而自研。人类要求带着完整信息重新评估(2026-07-20 网络调研,含源码级查证)。tenacity 的客观优点:9.1.x 仍在维护、零传递依赖、wheel <30KB、月下载亿级、自定义 wait callable 可读取异常对象、sleep 可注入。若需求只是"按指数退避重试一个幂等函数",应直接用它。但对本库是净负担,理由:
- 控制反转与逐次编排冲突(决定性): 我们每次尝试要改变下一次尝试做什么——换源、重新过限流闸、新 call_id、逐次遥测与熔断计数。查证确认 tenacity 的回调只能旁观 retry_state,无任何 per-attempt argument mutation 机制;唯一绕法是把全部编排塞进被重试的 callable,此时 tenacity 只剩循环骨架,而 Retry-After 取大者、按错误类型分支等待仍要写在自定义 wait callable 里(官方无按异常类型路由 wait 的组合子)。它能省下的只有约 15 行已被三项目验证过的退避公式。
- 两个已证实的坑打在要害: ① statistics 用 thread-local 实现,不隔离同一事件循环内的并发协程——
AsyncRetrying实例不可跨并发协程共享(pydantic-ai issue #2661,2025-08,框架层被迫每次调用新建实例),而"共享 GatewayClient 被数百协程并发调用"正是本库标准形态;② CancelledError 默认不被吞,但谓词配成 BaseException 即复现 issue #186"被取消的协程在后台继续重试"——对"取消可穿透"铁律(§6.4)是靠约定而非结构维持的风险;自研循环中该保证是结构性的。 - 供应链与调试透明度: 8.4.0(2024-06)发版事故一天击穿 langchain/llama-index/plotly 全生态;裸
@retry默认无限次零间隔重试。基础库为省 15 行引入此类外部风险不划算;重试 bug 排查走自己 ~100 行循环远快于穿框架内部栈。 - 依赖纪律(§8): 核心依赖极简,本案例恰是该铁律要拦的典型——收益小、面积大。
被否决的备选: tenacity(上述);stamina(hynek 封装,2026-04 仍活跃,安全默认值+仪表,适合业务侧简单重试,不适合需逐次编排的网关核心);backoff(仓库 2025-08 已 archive,不再考虑,实验室他处若在用应提醒迁移)。
影响: §7.2 的单层重试原则不变;RetryMW 循环保持结构性禁止 except BaseException;此结论基于 2026-07 的库现状,若 tenacity 未来提供逐次编排能力可重评。
D14 结构化输出阶梯:修复 → 校验 → 有界带反馈重问(2026-07-20)
决策: 结构化输出处理为五级阶梯——①预防(provider 声明支持时用原生 schema)→ ②修复(围栏剥离→json_repair→provider 变体归一化,零网络)→ ③形态校验(调用方传 pydantic 模型时库内校验,零网络)→ ④受约束重问(有界 max_structured_retries,默认 1-2;校验错误作为反馈追加重问;可升级到原生 schema 策略;照过限流但不计熔断;逐次遥测)→ ⑤耗尽抛 ResultInvalidError(携原始文本+修复错误+校验错误)。校验入库但可选,三档: 不传 structured = 原始文本零负担;structured="json" = 仅修复;structured=<pydantic 模型> = 完整阶梯。库只校验形态(schema),语义校验(业务规则,如 bbox 是否在图内)留用户层(D12 零业务假设)。
背景与讨论: 人类提出"先修复、修不好再重试、重试要有约束",并质疑校验层归属("放用户层是否增加调用方负担")。现状盘点: D7 只定义了两个策略,"修复失败之后"仅 §6.1 一句"策略层可选二次尝试"未设计;三项目对解析失败的处理互相不同——VT/GovDoc 靠业务步级重试无脑重来(不带反馈),CHS 刻意不重试转人工复核——库须同时支持,CHS 策略即 max_structured_retries=0。校验入库的结构性理由: 校验必须位于重问循环内侧,循环才能由校验失败触发;放用户层则三项目各自重搭循环,恰是要消灭的重复;且 NativeSchemaStrategy 发 response_format 本就需要 schema。带反馈重问与 transport 重试(§7.2)是两种重试: 独立计数、医不同的病;反馈改变 messages,天然不命中原坏答案的缓存 key;不计熔断(服务健康,§6.3);照过限流(真实请求)。CHS classifiers.py 跨模块 import 私有 _extract_json_object 证明用户对库侧解析有真实需求。
被否决的备选: 校验全放用户层(破坏循环闭环、重复×3);无界重问(违背"重试有约束");解析失败并入 transport 重试计数(混淆坏结果与坏服务)。
影响: §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. 总体架构
4.1 洋葱结构
flowchart TB
subgraph 业务侧["业务侧(三项目各自保留)"]
A1["arq worker / FastAPI<br/>(CHSAnalyzer, GovDoc)"] --- A2["批处理脚本 asyncio.gather<br/>(Video-Tree)"]
A3["抽帧 / 图像预处理 / prompt 组装 / Agent Loop / 公平调度"]
end
subgraph PolyGateway["PolyGateway: GatewayClient"]
M1[TelemetryMW 遥测+成本] --> M2[CacheMW 响应缓存]
M2 --> M4["StructuredMW 结构化阶梯(D14)"]
M4 --> M5["RetryMW 重试循环<br/>每次尝试: 选源 → 熔断门(源) → 限流 permit(全局+源)"]
M5 --> T["Transport 端口<br/>OpenAICompat(httpx,默认) / OpenAISDK(可选) / MonkeyOCR"]
end
subgraph 状态后端["可插拔状态后端"]
B1["InMemory*(单进程)"]
B2["Redis*(跨进程)"]
B3["SQLite / Postgres 遥测"]
end
业务侧 -->|"await client.chat(...) / ocr.recognize_text(...) / ocr.parse_layout(...)"| PolyGateway
M1 -.-> B3
M2 & M5 -.-> B1 & B2
分层要义:决策逻辑(中间件算法)只有一份;易变处全部是端口——状态存哪(后端)、协议怎么发(transport)、源怎么选(selector)、输出怎么解析(structured strategy)、账记到哪(telemetry)。
4.2 库铁律(与 CLAUDE.md §4.2 同步维护,冲突以 CLAUDE.md 为准)
零业务假设 / 纯 asyncio 中立(无全局状态、无框架假设、无模块级单例)/ 取消可穿透 / 错误分类驱动(禁止 ad-hoc 判断)/ 遥测必录且调用点收敛单一 helper / 降级方向(缓存与遥测后端挂 → 静默降级;限流与熔断后端挂 → 报错而非放行,防击穿网关)/ 依赖极简(核心仅 httpx+pydantic,其余 optional extras)/ 缓存 key 防毒化。
4.3 默认中间件层序及理由(外→内)
遥测 → 缓存 → 结构化(StructuredMW,D14)→ 重试循环(每次尝试: 选源 → 熔断门(源) → 限流 permit(全局+源) → transport)
2026-07-20 修订(CHS 迁移文档缺口 G3): 初版把熔断/限流画在重试循环外,与"每次重试重新过限流闸"的理由自相矛盾,且熔断/限流是 per-source 的——源在循环内才被选出,准入只能发生在循环内。修订后与 CHSAnalyzer 实践(
governance.py:120-167逐次尝试执行选源→熔断→permit)一致。
| 相对顺序 | 理由 |
|---|---|
| 遥测最外 | 观测一切,包括缓存命中与各类失败;任何路径都留痕 |
| 缓存在重试循环外 | 缓存命中不打网关:不消耗限流配额、不受熔断状态影响 |
| 结构化在缓存内、重试外(2026-07-20 M1 设计) | 带反馈重问 = 再次调用内层,天然照过限流/熔断门、逐次遥测;缓存只固化阶梯通过的最终结果 |
| 重试循环拥有"尝试"的全部编排 | 每次尝试 = 选源(跳过冷却源)→ 该源熔断门(开路视为该源不可用,换源)→ 全局+该源限流 permit → transport;换源、逐次遥测、熔断计数都在循环内(与 D13 自研理由同构) |
| 熔断门先于限流 | 开路源直接跳过,不占限流租约、不排队等配额 |
| 每次尝试独立过限流闸 | 重试是真实网络请求,必须重新准入,否则重试风暴击穿配额;限流后端看到的是"含重试的真实请求数" |
层序与取舍最终是配置——项目可增删层(如无 Redis 环境去掉 CacheMW),但改默认顺序需理解上表理由。
4.4 一次调用的生命周期(walkthrough)
- 缓存命中: TelemetryMW 记录(cache_hit=True, latency_ms=0)→ CacheMW 返回,不触达任何更内层。
- 正常路径: RetryMW 开始第一次尝试 → selector 选源(跳过冷却中的源)→ 该源熔断门(闭路)→ 限流 acquire permit(全局+该源,并发/RPM/TPM 三闸,token 按有效预扣量
SourceConfig.effective_est_tokens()预扣,取值规则见 §7.7)→ transport 发请求、流式解析(看门狗包裹)、收 usage 帧 → permit 按实际 usage settle(多退少补)→ 回程写缓存 → 遥测记成功(含 ttft/max_inter_token/成本)。 - 瞬时错误(超时/5xx/429/SSE 异常): transport 翻译为
TransientError→ RetryMW 指数退避+jitter(取 Retry-After 提示与退避的较大值)后换源重试;每次尝试独立 call_id、独立过限流闸、失败即报熔断计数与遥测。 - 源死亡(401/403/欠费):
SourceDeadError→ 该源熔断 force_open + 本地冷却备忘 → 立即换下一源,不退避等待。 - 请求被拒(400/坏输入):
RequestRejectedError→ 不重试不换源,直接上抛;遥测记录。 - 开路/全源耗尽:
CircuitOpenError/AllSourcesExhausted→ 按配置 wait(等待恢复,含 stall 判定)或 fail-fast 上抛。 - 任意时刻取消:
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()) |
三条实现细则各自都是"少写一条就等于纪律不成立":
- 默认必须是"不拥有"。
__init__是全量注入路径,经它传入的一切组件一律_owns_* = False,只有三个工厂在真正自建时置 True。默认若反过来,直接构造路径下共享 transport 仍会被第一个 client 关掉。 - 判定一律用
is None/is not None,不用or。工厂里limiter or _build_limiter(...)这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按is None判成 False——两者一漂移就等于又造了一个aclose越权。这是所有权判定能成立的必要条件,不是风格偏好。 - 三个 client(chat/embedding/ocr)必须逐一持有 limiter/breaker 引用并各自被测试钉一次。收敛成 helper 之后仍要三处各钉一次,否则下次有人把逻辑复制回去无人发现;
GatewayClient此前把 limiter/breaker 交给RetryMW后自己不留引用,aclose因此触达不到自建的 redis 客户端,泄漏就是这么来的。
公共 API 面零变化(_owns_* 是私有属性)。对下游的可见后果只有一条,且必须显式声明: aclose() 不再关闭注入进来的组件,若有下游依赖了"注入后由 client 代关",升级后需自己关。
5. 核心类型
5.1 LLMResponse(frozen dataclass,与三项目超集兼容)
兼容约束(硬): 以下字段为三项目现有消费面,只增不删不改名:
| 字段 | 类型 | 说明 |
|---|---|---|
content |
str | 正式输出文本 |
thinking |
str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) |
model / provider |
str | 溯源 |
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(三态,见下)、structured_data(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、cached_prompt_tokens 与 model_reported(2026-07-31,issue #3,见下)、thinking_observation(2026-08-25,issue #16/#17,见下)。
可观测字段(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——它是结果不是请求。
声明 × 观测对账(同批): reconcile_thinking 把请求方向(enable_thinking)与实测观测比对,矛盾即 warning、不抛错(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。False × unknown 与 None × 任意 不表态: unknown 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 (source, model, direction) 集合,与既有 _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 中记录,绝不因遥测延迟取消传播,写失败静默)。
5.2 其他类型与 chat() 公共签名
ChatRequest(model/messages/结构化输出参数/per-call 覆盖项)、Usage(tokens + elapsed,OCR 无计费填 0)、OcrTextResult(text + 溯源五件 source_name/usage/latency_ms/call_id/raw)、OcrLayoutResult(elements 含 bbox/type/page_index + page_sizes + 同套溯源五件;元素自 _middle.json para_blocks 全量提取,M3 设计 §1.2 取证)。全部 frozen dataclass。空结果语义:合法"无内容"用空值/None 表达,调用失败必须走异常——二者严格区分。
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. 错误模型
6.1 统一四分类 + 熔断信号(融合 CHSAnalyzer 三分类与 GovDoc 二分类)
| 错误类 | 触发 | 重试 | 换源 | 熔断计数 |
|---|---|---|---|---|
TransientError |
超时/5xx/429/网络抖动/SSE 异常(畸形帧、断流无 [DONE])/看门狗超时 | ✅ 退避后 | ✅ | ✅ |
SourceDeadError |
401/403/欠费/insufficient_quota(429 body 细分) | ❌ | ✅ 立即 | ✅ force_open |
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 / 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 层职责)
| 输入 | 翻译为 |
|---|---|
httpx Timeout/Transport 错误、StreamLivenessTimeout、SSE 异常 |
TransientError |
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | TransientError(携 Retry-After 解析值,仅支持秒数形态) |
| HTTP 429 + body 含 insufficient_quota、401、403 | SourceDeadError |
| HTTP 400 | RequestRejectedError |
| 空补全: 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。
6.4 取消语义
asyncio.CancelledError 永不捕获吞没(它继承 BaseException,防御性 except Exception 天然放行,但库内严禁 except BaseException 与裸 except:)。重试循环、限流等待、退避 sleep、流式读取全部可被取消;in-flight permit、httpx 连接在 finally 释放。教训出处:GovDoc 旧系统"OCR 挂死、前端无取消能力"(GovDoc research-wiki/designs/2026-07-09-docagent-core-monorepo-design.md:16)。
7. 子系统设计
7.1 Transport
职责: 一次原始调用的全部协议细节——请求体组装(含 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-sourcemissing_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。
7.2 重试与退避
指数退避 + jitter: delay = min(base * 2^attempt, max_delay) * uniform(0.5, 1.5),与 Retry-After 提示取较大值。单层重试原则: 库内只有 RetryMW 一层重试;Video-Tree/GovDoc 现存的"治理层重试 + Agent 步级二次重试"双层结构(异常集合不同、职责重叠)不复制——业务侧如需任务级重试,自行在库外包,且语义是"任务重试"不是"调用重试"。
7.3 限流
语义契约(两后端同一套): try_acquire(source, est_tokens) -> Permit | 拒绝(含等待提示);Permit.settle(actual_usage) 按实际 usage 结算(预扣保守入场,多退少补);Permit.release() 在 finally 必然执行;permit 带 TTL 租约防进程死亡泄漏。
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 上限(防误判卡死;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 熔断
状态机(算法一份): 闭路 --连续失败达阈值--> 开路(冷却)--冷却到期--> 半开(只放一个探针,防惊群)--成功--> 闭路 / --失败--> 开路。force_open 支持 SourceDeadError 一击即熔。按 source_name 分别计数。半开探针名额是带 TTL 的租约(移植 CHS scripts.py:96-105): 探针持有者死亡后租约自动过期释放,防"探针永远在路上"死锁;release_probe 幂等(2026-07-20,CHS 迁移缺口 G5)。
InMemoryBreakerState: 移植 Video-Treebreaker.py(时钟由调用方注入,纯确定性可测)。RedisBreakerState: 移植 CHSAnalyzerprovider_gate.py,含 epoch fencing(防旧世代进程污染新状态)。- 阈值指导: 有效阈值 =
max(configured_threshold, 源级并发 * 2)(M2.5 勘误: M2 曾误用全局并发,SOAK 并发 100 把阈值抬到 200 使熔断失灵——P6 病灶 2;.env 注释约定的本意是"防单源并发误熔",只应看源级)。 - 源冷却备忘: 开路源在进程本地记冷却截止时刻,选源时跳过,避免白烧 RPM 去探测(移植
governance.py:107)。
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, sampling})),前缀 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 排序去重——按模型而非源名,改源名不误触冷启动)。- 两条已知副作用: ① 逐 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 流式活性看门狗
三层超时——TTFT(首 token)/ inter_token(token 间隔)/ total(总时长),分别抛出携超时类别的 StreamLivenessTimeout(归 TransientError)。实现为领域无关纯函数,只包裹迭代器的单次 __anext__,用 asyncio.timeout 的 expired() 区分本层 deadline 与上游超时,避免取消泄漏。三项目同款,近乎原样移植。约束 0 < inter_token < ttft < timeout_s(继承 CHSAnalyzer SourceConfig 不变式校验)。
7.7 多源与选源
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/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、cached_prompt_tokens、model_reported、sampling、reasoning_tokens、tenant_id、meta、thinking_observation。
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 路径,失败仍只逐行降级、不判死。
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 单价, 可选 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 / 网络抖动(外部状态,随时可能好)。判据改挂"失败是什么性质",两句话说完:
- 致命 = 失败原因完全在进程内部且不可变;其余一切失败都可能被外部修好,故一律带冷却重试。
- 行级 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)
| 级 | 内容 | 成本 |
|---|---|---|
| ① 预防 | provider 注册表声明支持时,用 response_format / function calling 直接约束(NativeSchemaStrategy) |
无额外 |
| ② 修复 | 围栏剥离 → json_repair → provider 变体归一化(DeepSeek 参数平铺等)(JsonRepairStrategy) |
零网络 |
| ③ 校验 | 调用方传 pydantic 模型时库内做形态校验;语义校验留业务层 | 零网络 |
| ④ 受约束重问 | max_structured_retries(默认 1;0 = CHS"不重试转人工"策略): 校验错误作为反馈追加重问(messages 已变,不命中原坏答案缓存);重问一律叠加 ① 策略(若 provider 支持)——修复失败说明 prompt 约定不够,升级到协议约束;照过限流闸,不计熔断,逐次遥测 |
真实调用 |
| ⑤ 耗尽 | 抛 ResultInvalidError,携原始文本 + 修复错误 + 校验错误,业务决定兜底(人工复核/降级) |
— |
structured 参数三档: 不传 = 原始文本(零负担);"json" = 仅②;pydantic 模型 = ①-⑤ 完整阶梯。缓存写入发生在阶梯通过之后——这是 §7.5 "ResultInvalid 的原始响应不缓存"的执行点。细则(2026-07-20 M1 设计): 反馈模板为库内英文常量(原 messages + assistant 坏输出 + user 纠错指令,错误取前 3 条、每条截断 200 字符);provider 变体归一化(如 DeepSeek 参数平铺收拢)面向特定业务 schema,不入库——JsonRepairStrategy(normalize: Callable | None) 提供注入钩子,业务侧自带(零业务假设铁律)。
7.10 OCR 端口族
| 端口 | 对应 MonkeyOCR 端点 | 协议 | 输出 |
|---|---|---|---|
OcrTextPort.recognize_text(image: bytes) |
POST /ocr/text |
multipart 上传 → JSON {content} |
OcrTextResult(多行纯文本) |
OcrLayoutPort.parse_layout(image: bytes) |
POST /parse |
multipart → JSON(download_url) → GET ZIP → 解包 *_middle.json |
OcrLayoutResult(elements 含 bbox + page_size) |
设计要点:输入统一 bytes(路径读取/多帧批量拼接留业务侧);bbox 返回 OCR 原生页面坐标,几何映射(裁剪偏移/归一化/marker 推算)留业务侧;None/空表达"合法无内容",异常表达"调用失败";ZIP 内容不可解析抛 ResultInvalidError(坏图≠坏服务);OCR 复用同一套治理算法件、循环形态同 EmbeddingClient 先例(见 D9 修订;无 token 计费,Usage 填 0,latency_ms 照记);多后端经 provider 注册表扩展(GLM 已在 CHSAnalyzer 白名单,输入形态为 URL,届时封装在其 invoker 内部,端口签名不变)。数值防御(bbox 有限性/顺序/退化校验)随协议解析下沉进库。OCR transport 支持 per-source trust_env 开关(LAN 直连绕过本地代理,Video-Tree ocr.py:46 教训;2026-07-20 缺口 R9);客户端暴露 check_health() -> dict[str, bool] 逐源健康预检(M3 设计 §3.3 细化:bool 无法承载逐源结果,业务 all() 即得启动门布尔;Video-Tree A/B 评测消费;缺口 R10 已闭)。协议事实(2026-07-21 实测): /parse 的 download_url 为相对路径(/static/*.zip),transport 相对 base_url 解析并兼容绝对 URL。
7.11 音频占位
AudioPort Protocol 占位,无实现、无 transport。真实需求出现时走 designs/ 功能设计文档流程定协议形态。
8. 模块结构与依赖纪律
src/polygateway/
├── types.py # §5 核心类型(frozen dataclass)
├── errors.py # §6 错误四分类
├── ports.py # 全部 Protocol(§4 各端口)
├── client.py # GatewayClient + from_env()/from_settings() 装配工厂 + gather_bounded
├── 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 注册表(只回答 provider 是什么)
├── thinking.py # 推理这件事的全部决策: 能力表 + 请求侧注入 + 响应侧裁定 + 对账
├── sources.py # SourceConfig + 选源策略
├── backends/ # memory/ 与 redis/(limiter、breaker、cache 状态实现)
├── telemetry/ # sqlite.py / postgres.py / pricing.py
├── structured/ # json_repair.py / native_schema.py
└── streaming.py # 三层活性看门狗(纯函数)
依赖纪律(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"错误。
9. 配置面
- 载体:
.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重一倍。
10. 非功能性需求(强制覆盖,继承 Video-Tree CLAUDE.md §4.2.1 条款)
| 维度 | 回答 |
|---|---|
| 持久化策略 | 遥测逐调用追加写(WAL);缓存写在响应成功后;崩溃最多丢当次调用的遥测记录 |
| 幂等性 | 遥测 INSERT OR IGNORE(call_id 主键);缓存写幂等(同 key 同值);限流 permit 带 TTL 租约,进程死亡后自动过期回收 |
| 断点续跑 | 库无长任务状态,天然无断点问题;响应缓存本身即业务侧重跑的加速器 |
| 原子性 | Redis 限流/熔断操作全部单条 Lua 原子执行;窗口 id 用 Redis 服务器时钟统一多进程口径 |
| 降级 | 缓存/遥测后端不可用 → 静默降级(warning);限流/熔断后端不可用 → 报错而非放行(防击穿网关) |
| 取消 | CancelledError 全栈穿透;in-flight permit 与连接在 finally 释放(§6.4) |
11. 三项目迁移路径(库的验收标准)
验收定义: 每个项目删除自己的治理实现文件,换成
from polygateway import ...+ 配置,原测试全部通过。凡替换不掉的能力,就是库的边界缺口,回补后重验。这条标准同时是防"造没人用的空中楼阁"的机制:每个里程碑都有真实接入方。每个项目的详细迁移文档(删除清单、组件映射、调用点清单、配置迁移、分步回滚、旧版行为审计、反向约束)见
research-wiki/migrations/<project>.md;本节保持概要。迁移文档暴露的架构缺口已于 2026-07-20 修订进本文各节(检索"迁移缺口"可定位全部修订点)。
11.1 GovDoc-SaaS(难度低,首个迁移)
| 项目侧 | 处置 |
|---|---|
docagent-core/llm/client.py、breaker.py、redis_cache.py、streaming.py、telemetry_sqlite.py |
删除,由库继任 |
protocols.py 的 LLMProvider.chat(messages, *, session_id, parent_call_id) 签名 |
库保持兼容(或一行 shim) |
| 倒推的库需求 | from_env 工厂(GovDoc 装配层本就缺失,库直接补上)、Postgres 遥测、缓存 key namespace 含租户 |
11.2 Video-Tree-TRM5(难度中)——已放弃迁移(2026-07-22 用户拍板: 项目本体已放弃)
下表保留作历史记录与能力倒推依据(VT 倒推的库能力——多逻辑角色、cache salt、多模态摘要进 hash、OcrTextPort 等——均已交付且被其他消费方使用,不回收);v1.0 验收标准相应改为 §11.1 + §11.3 两项目。
| 项目侧 | 处置 |
|---|---|
adapters/llm.py、breaker.py、streaming.py、redis_cache.py、telemetry.py |
删除,由库继任 |
main.py:_build_adapters() |
改为按角色调用 from_env(SEARCH/JUDGE/VL/EVOLVE;共享实例显式声明) |
adapters/vlm.py(base64 编码与注入)、抽帧、OCR 文本拼接与注入前缀 |
留在项目(业务侧),组装好 content 数组后调库 |
adapters/ocr.py 裸调轮询 |
删除,换 OcrTextPort(免费升级为多源+重试+熔断全治理) |
| 倒推的库需求 | 多逻辑角色、cache salt、多模态 content 摘要进 hash、gather_bounded、非流式快路径 |
11.3 CHSAnalyzer(难度高,能力对标项)
| 项目侧 | 处置 |
|---|---|
app/providers/governance.py、app/coordination/limiter.py + scripts.py、provider_gate.py |
删除,由库继任(库必须先达到能力对等,这是 M2 的验收内容) |
app/providers/invokers.py 的 VLM invoker / MonkeyOcrParseInvoker |
由库 transport / OcrLayoutPort 继任 |
app/providers/table_locator.py(几何映射)、marker_imaging.py(拼图/增强)、position_scheduler.py(公平调度) |
留在项目(业务侧) |
core/eval/judge.py(同步裸 SDK,反面教材) |
迁移到库,消灭无治理调用 |
| 倒推的库需求 | 多源多账号、Redis 六道闸+契约测试、错误四分类、OCR 端口族、背压 stall、ResultInvalidError 语义 |
12. 里程碑
| 阶段 | 交付 | 可接入 |
|---|---|---|
| M1 核心 | types/errors/ports、OpenAICompat transport(含非流式)、看门狗、RetryMW、多源多账号+选源+源冷却备忘(2026-07-20 人类拍板,自 M2 提前——理由: RetryMW 循环与端口签名 M1 冻结,多源行为一并钉死避免 M2 返工)、内存版限流/熔断、缓存(Redis+内存)、SQLite 遥测、结构化输出双策略、provider 注册表、from_env | GovDoc、Video-Tree |
| M2 分布式 | Redis 限流(六道闸+契约测试)/熔断后端、多源 × Redis 后端联合验证(全局限额跨 worker)、背压 stall、Postgres 遥测、pricing 成本 | CHSAnalyzer(治理部分) |
| M3 OCR | OcrText/OcrLayout 端口 + MonkeyOCR transport,走同一治理栈 | CHSAnalyzer(全量)、Video-Tree(OCR 升级) |
| M4 迁移验证 | 三项目逐一按 §11 验收,缺口回补 | 全部 |
每个里程碑进入实现前,按 SOP 在 research-wiki/designs/ 写该里程碑的功能设计文档(受 400 行约束),与本文档冲突时先修订本文档。
13. 开放问题(待人类拍板)
| # | 问题 | 建议 |
|---|---|---|
| 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.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 |
| Q5 | 本仓库工程脚手架(git init、.claude/ skills、Makefile、pyproject、import-linter)何时落地 |
本文档终审通过后、M1 编码前一次落地 |