# 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% 用户只需要这三行): ```python 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 每次重新开发的到底是什么(五类重复) 1. **治理栈本体**被复制至少三次,每次复制产生变异(缓存 salt、租户隔离、错误分类粒度各不相同),修一个 bug 修不到另外两份。 2. **"配置 → 装配 client"** 每个项目手写一遍;GovDoc 甚至 `.env` 参数定义齐全但装配层缺失。 3. **三套互不一致的 JSON 结构化输出解析**:json_repair / 手写正则 / `find('{')`+`rfind('}')` 并存,甚至同一项目内并存(Video-Tree)。 4. **provider 差异靠字符串猜**:`"qwen" in provider`、`model.split("-")[0]` 推断 provider,接新 provider 要改核心类。 5. **同样的坑各踩各的**:遥测调用逐字复制 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 定位:治理单位是"一次模型调用" 三个项目呈现两种使用形态,曾担心无法统一;结论是**在调用层天然同构,在编排层不应统一**: ```text 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` / `` 标签剥离)、原生 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 可注入。**若需求只是"按指数退避重试一个幂等函数",应直接用它**。但对本库是净负担,理由: 1. **控制反转与逐次编排冲突(决定性)**: 我们每次尝试要改变下一次尝试做什么——换源、重新过限流闸、新 call_id、逐次遥测与熔断计数。查证确认 tenacity 的回调只能旁观 retry_state,**无任何 per-attempt argument mutation 机制**;唯一绕法是把全部编排塞进被重试的 callable,此时 tenacity 只剩循环骨架,而 Retry-After 取大者、按错误类型分支等待仍要写在自定义 wait callable 里(官方无按异常类型路由 wait 的组合子)。它能省下的只有约 15 行已被三项目验证过的退避公式。 2. **两个已证实的坑打在要害**: ① statistics 用 thread-local 实现,不隔离同一事件循环内的并发协程——`AsyncRetrying` 实例不可跨并发协程共享(pydantic-ai issue #2661,2025-08,框架层被迫每次调用新建实例),而"共享 GatewayClient 被数百协程并发调用"正是本库标准形态;② CancelledError 默认不被吞,但谓词配成 BaseException 即复现 issue #186"被取消的协程在后台继续重试"——对"取消可穿透"铁律(§6.4)是靠约定而非结构维持的风险;自研循环中该保证是结构性的。 3. **供应链与调试透明度**: 8.4.0(2024-06)发版事故一天击穿 langchain/llama-index/plotly 全生态;裸 `@retry` 默认无限次零间隔重试。基础库为省 15 行引入此类外部风险不划算;重试 bug 排查走自己 ~100 行循环远快于穿框架内部栈。 4. **依赖纪律**(§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=` = 完整阶梯。库只校验**形态**(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 洋葱结构 ```mermaid flowchart TB subgraph 业务侧["业务侧(三项目各自保留)"] A1["arq worker / FastAPI
(CHSAnalyzer, GovDoc)"] --- A2["批处理脚本 asyncio.gather
(Video-Tree)"] A3["抽帧 / 图像预处理 / prompt 组装 / Agent Loop / 公平调度"] end subgraph PolyGateway["PolyGateway: GatewayClient"] M1[TelemetryMW 遥测+成本] --> M2[CacheMW 响应缓存] M2 --> M4["StructuredMW 结构化阶梯(D14)"] M4 --> M5["RetryMW 重试循环
每次尝试: 选源 → 熔断门(源) → 限流 permit(全局+源)"] M5 --> T["Transport 端口
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) 1. **缓存命中**: TelemetryMW 记录(cache_hit=True, latency_ms=0)→ CacheMW 返回,不触达任何更内层。 2. **正常路径**: RetryMW 开始第一次尝试 → selector 选源(跳过冷却中的源)→ 该源熔断门(闭路)→ 限流 acquire permit(全局+该源,并发/RPM/TPM 三闸,token 按**有效预扣量** `SourceConfig.effective_est_tokens()` 预扣,取值规则见 §7.7)→ transport 发请求、流式解析(看门狗包裹)、收 usage 帧 → permit 按实际 usage settle(多退少补)→ 回程写缓存 → 遥测记成功(含 ttft/max_inter_token/成本)。 3. **瞬时错误**(超时/5xx/429/SSE 异常): transport 翻译为 `TransientError` → RetryMW 指数退避+jitter(取 Retry-After 提示与退避的较大值)后换源重试;每次尝试独立 call_id、独立过限流闸、失败即报熔断计数与遥测。 4. **源死亡**(401/403/欠费): `SourceDeadError` → 该源熔断 force_open + 本地冷却备忘 → 立即换下一源,不退避等待。 5. **请求被拒**(400/坏输入): `RequestRejectedError` → 不重试不换源,直接上抛;遥测记录。 6. **开路/全源耗尽**: `CircuitOpenError` / `AllSourcesExhausted` → 按配置 wait(等待恢复,含 stall 判定)或 fail-fast 上抛。 7. **任意时刻取消**: `CancelledError` 穿透所有层;in-flight permit 与连接在 finally 释放。 ### 4.5 资源所有权纪律: 谁建的谁关,注入的一律不碰(2026-08-24,issue #15) 这是**跨子系统的通用纪律**,不是遥测的局部约定。它被写下来的直接原因是: 库对"谁建的、谁负责关"从来没有统一说法,于是同一个根因在三个地方长出三种形态—— | 形态 | 位置(修复前) | 性质 | |---|---|---| | `GatewayClient.aclose()` 无条件关掉**注入的** telemetry,共享 recorder 被第一个关闭的 client 弄死(`embedding.py`/`ocr.py` 各有一份逐字复制) | `client.py:271-273` | 越权 | | `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 | | `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不持有 limiter/breaker 的引用) | `client.py:263-280` | 泄漏 | | 对照组: `RedisLimiter._owns_client` 的纪律**一直是对的** | `limiter.py:185-191, 318-322` | 正确先例 | 纪律把已有的那个正确先例推广为全库唯一说法,分两层落地: | 层 | 所有权归属 | 落法 | |---|---|---| | 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 | | client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性,`aclose` 只关自建的;三处复制的 `getattr(..., "aclose")` 鸭子探测收敛为一个内部 helper(同时探测 `aclose`/`close`,SQLite recorder 只有同步 `close()`) | 三条实现细则各自都是"少写一条就等于纪律不成立": 1. **默认必须是"不拥有"**。`__init__` 是全量注入路径,经它传入的一切组件一律 `_owns_* = False`,只有三个工厂在真正自建时置 True。默认若反过来,直接构造路径下共享 transport 仍会被第一个 client 关掉。 2. **判定一律用 `is None` / `is not None`,不用 `or`**。工厂里 `limiter or _build_limiter(...)` 这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这是所有权判定能成立的**必要条件**,不是风格偏好。 3. **三个 client(chat/embedding/ocr)必须逐一持有 limiter/breaker 引用并各自被测试钉一次**。收敛成 helper 之后仍要三处各钉一次,否则下次有人把逻辑复制回去无人发现;`GatewayClient` 此前把 limiter/breaker 交给 `RetryMW` 后自己不留引用,`aclose` 因此触达不到自建的 redis 客户端,泄漏就是这么来的。 公共 API 面零变化(`_owns_*` 是私有属性)。**对下游的可见后果**只有一条,且必须显式声明: `aclose()` 不再关闭注入进来的组件,若有下游依赖了"注入后由 client 代关",升级后需自己关。 --- ## 5. 核心类型 ### 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-source `missing_done: "retry" | "salvage"`,默认 retry(防截断响应进缓存被固化);零内容提前断流(early_eof)恒 retry 不可配;打捞路径**仅在收到 usage 帧时**把 `measured` 降级为 `estimated`(**勘误 2026-07-30**: 原文"强制 estimated" 已改为有条件——没收到 usage 帧时用量本就是 `unavailable`,强制标 `estimated` 会让 `0/0` 被当作实测数字换算出一个假的 `0.0` 成本,§5.1)。CHS 迁移配 salvage 保留其现状行为。看门狗活性口径: 任何增量(content 或 reasoning_content)都算 token——ttft = 首个任意 token,思考流刷新 inter_token 计时(CHS 迁移约束 R1)。**非流式快路径**: 短请求可配 `stream=False`(三项目都写死 stream=True 强迫短请求走 SSE+看门狗,库放开)。 - `OpenAISDKTransport`(可选 extra): 薄封装,`max_retries=0` 关掉 SDK 自带重试(治理归中间件),`extra_body`/`model_extra` 通道非标字段。 - `MonkeyOcrTransport`: 见 §7.10。 ### 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-Tree `breaker.py`(时钟由调用方注入,纯确定性可测)。 - `RedisBreakerState`: 移植 CHSAnalyzer `provider_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=, timeout=<预算>, command_timeout=<预算>)`,三条随之确立: | 语义 | 内容 | |---|---| | 建池零成本 | `min_size=0` 时 `_initialize` 只造 holder 对象、**一条连接都不连**(实测 0.000s,指向不可达端口也照样成功)。稳态占用由"每 client 常驻 10 条"变为"实际并发,闲时 0";真实 PG 实测: 建 recorder 后 0 → 一次写入后 1 → 20 行并发后 4(= `pool_max`)→ `aclose` 后 0 | | 只暴露 `max_size` | `min_size` **有意不给配置项**: 它唯一的作用是把上面那个脆点装回来,换取的只是首次写入省下 ≈390ms 建连。库没有理由提供一个只会伤人的旋钮(P1+P5)。`max_size` 则必须暴露——继承第三方默认值等于库对自己的资源占用不表态(P4) | | 写入有硬预算 | 整次写入(准备 + acquire + execute)由 `asyncio.timeout(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S)` 包一层,超时按行级丢弃。把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证 | 两处实现纪律,都是"看起来完成了、其实资源还挂着"的形态,必须写下来否则会被改回去: ① **不得用 `async with pool.acquire(...)`**——`Pool.release()` 是 `await asyncio.shield(ch.release(timeout))` 且默认复用 acquire 记录的 `ch._timeout`(asyncpg `pool.py:886-889, 930-937`),外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行的那个 shielded release **会正常等到完成**,业务路径真实上界变成 ≈ 2 × 预算;故改为显式 `acquire(timeout=<完整写入预算>)` + `finally: release(con, timeout=1s)`(内层传完整预算而非剩余量: 真正的上界是外层那一层 `asyncio.timeout`),释放超时即 `con.terminate()`,承诺精确化为"主写入尝试 ≤ 预算,释放路径独立有界"。② **`aclose()` 必须有界且终局**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时无限等、60 秒只发一条 warning(`pool.py:939-948, 961-972`),故走 `asyncio.wait_for` + 超时 `terminate()`;同时置 `_closed`,此后写入短路且**不复活**——原实现关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入方以为自己管着全部连接、实际早已不是(issue #15 实施期发现,是下面所有权根因的又一处表现)。 **遥测失败的三分判据(2026-08-24,issue #15)**: 判死判据此前挂在"**哪一步**失败"(`_open_pool` 失败即永久判死),而那一步里同时藏着两类性质完全不同的失败——DSN 非法(进程内不可能改变)与 `too many clients` / 网络抖动(外部状态,随时可能好)。判据改挂"失败是**什么性质**",两句话说完: 1. **致命 = 失败原因完全在进程内部且不可变**;其余一切失败都可能被外部修好,故一律带冷却重试。 2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。 | 档 | 覆盖(按 SQLSTATE 分类而非异常类白名单——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移) | 处置 | |---|---|---| | 配置级致命 | `ClientConfigurationError`(DSN 不可解析);`create_pool` 抛的 `ValueError`/`TypeError` | 永久 no-op + 一条 **error**(人配错了,不是 warning) | | 环境级不可用 | SQLSTATE 类 `08`/`53`(含 53300 too many connections)/`57`/`28`/`3D`,具体码 `42501`(无权限)/`42P01`(表不存在);`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅在准备期路径可达**: 它是 `OSError` 子类,但写入期的超时先被 `record_llm_call` 的 `except TimeoutError` 接住并按行级丢弃,压根到不了本分类函数——见下方第 ④ 点);表确定不存在且建不出来 | **冷却降级**(内部常量 60s,不给配置项——无部署差异理由),到期放行**一次**重新准备,成功即恢复 | | 行级拒绝 | 其余 `PostgresError`(`22`/`23` 等数据与约束类),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级,接入节流复述 | 四点必须一起记住,否则后来人会把判据改回去: ① **致命档窄到只剩 DSN 一类是有意的**——认证失败、库不存在、表建不出来一律归环境级,因为 DBA 改完密码/建完表就该自动恢复,而永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形;②**`42703` 是唯一具名例外**,按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了优先级更高的承诺——manual 档缺列时按现有列裁剪 `INSERT` 继续写、缺列以逐行 warning 暴露,即"部分列写进去了"这件事本身有价值,不该被冷却掉;新增例外必须同款论证。③ **认不出的失败一律归最轻档(行级)**,这个保守缺省在建池路径上是安全的,理由是 `min_size=0` 让建池不触库(实测 0.000s),"下次调用重试建池"本身**零成本**——原实现注释担心的"每次重试内联吞一次 connect 超时"在新语义下不再成立;④ **表里那条 `TimeoutError` 规则只在准备期路径可达,写入期不可达**(2026-08-24 合并前审查发现,**本轮只记录不改行为**): `record_llm_call` 的 `except TimeoutError` 排在 `except Exception` 之前,写入本体抛出的任何超时都在那里被按行级丢弃,不会走到分类函数。真实后果是"后端 TCP 通但不回应(假死)且 schema 已就绪"时,每次业务调用内联付满一个写入预算(缺省 5s)、丢一行、`degraded` 保持 False、**不进 60s 冷却**——即"冷却把最坏成本压成每 60s 一次、上界一个预算"这句承诺只在准备期路径上成立。不改的理由: 相对改前的"无限期挂"仍是净改善,且"超预算丢行走行级、不置 degraded"本就是明确记下的有意取舍(见下一段中"`degraded` 与 `dropped_rows` 覆盖的不是同一件事"那一条)。是否给"连续超预算丢行"升档,留作后续议题。 **降级的可见性与可编程性(2026-08-24,issue #15)**: 铁律里"遥测后端挂 → 静默降级"的"静默"指的是**不向调用方冒泡**,不是"没有日志、没有状态"。此前它被实现成了后者——全程只有一条 warning,长跑进程里等同于消失(issue 是人工比对"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的,期间 19 次调用一行未落);SQLite 侧更糟,初始化失败后写入直接 `return`,连 warning 都没有。"遥测必录"铁律的实质要求是: **库做不到必录时,必须持续、可编程地让下游知道**。落法是 `telemetry/status.py` 的 `TelemetryStatusTracker`——两个 recorder 共用、不含任何后端知识(只接受"降级了/恢复了/丢了一行"三个事实),进入与恢复各一条日志(**进入那条的级别由 `fatal` 决定,且只在 tracker 这一处决定**: 致命档 error——人配错了、本进程内不会自愈,其余 warning——外部状态、会自愈;recorder 侧不得再复制一条,否则同一事实两条日志、级别两个源头),降级期间按行数(100 行)与时间(300s)双阈值节流复述,`snapshot()` 给只读 `TelemetryStatus`(`degraded`/`fatal`/`reason`/`degraded_for_s`/`dropped_rows`/`retry_after_s`),经三个 client 的 `telemetry_status` 属性出口。三条设计约束: - **不叫 `health`**: 该词在 `ports.py` 已被 `OcrTransport.check_health`(源探活)与 `SourceSelector.health(source_name) -> float`(成功率 EWMA)占用两次,库内 `health` 一律指"源的健康度";这里描述的是"这个 recorder 现在能不能写、为什么不能、丢了多少",是状态不是评分(P2)。 - **不并入 `TelemetryRecorder` 主 Protocol**,新起**独立**端口 `TelemetryStatusProvider`: 前者是 `@runtime_checkable`,而 runtime 检查按属性存在性做——加一个成员会让所有只实现 `record_llm_call` 的对象**当场不再是** `TelemetryRecorder`,库内与下游的同款 `isinstance` 断言升级即断。client 侧取值经**一处** `isinstance` 判定,不重演 `aclose` 那种三处复制的鸭子类型。 - **`TelemetryStatus` 进顶层 `__all__`**(与 `SourceStats` 不同): 后者是端口内部快照、下游不消费,而本类型是 `client.telemetry_status` 的返回类型,下游要拿它做类型标注与对账——"顶层导出即公共 API 面"的约定要求它出现在那里。端口 `TelemetryStatusProvider` 则不导出(库外无实现者,导出即多一份永久承诺)。 - **`degraded` 与 `dropped_rows` 覆盖的不是同一件事,下游对账必须两个都看**: `degraded` 只在**环境级/致命级**失败(服务端真的说了"不可用",如 53300)时置位;而写入因**本地池饱和**超出写入预算被丢时走的是行级丢弃——`degraded` 保持 False,只有 `dropped_rows` 增长。这是有意的(池满是本进程并发过高,不是后端挂了,冷却 60s 只会白丢更多行),但只按 `degraded` 配告警的下游会**完全看不见**这一类丢行,而它恰恰是 `pool_max` 配小了的唯一信号。 - **SQLite 侧只做可见性**,不做 lazy 化与冷却重连: 它的失败模式(本地目录不可写、文件损坏)在装配期就暴露给下游,不是"跑到一半悄悄断",永久降级在那里语义基本正确。这个不对称是已知且有理由的;tracker 与快照两侧共用,将来要对称时接口已就位。 **资源所有权在遥测侧的落点**: 通用纪律见 §4.5。对遥测的直接后果是 §7.7 R5 那条"共享必须显式注入"第一次真正可用——`PostgresRecorder(dsn, pool=<外部池>)` 与"多个 client 注入同一个 recorder"都不再被第一个 `aclose()` 弄死,issue #15 提的"共享池"方向由此以显式注入形态自然成立,不需要任何隐式全局注册表(那会违反"纯 asyncio 中立: 无全局状态、无模块级单例")。 ### 7.9 结构化输出阶梯(D14) | 级 | 内容 | 成本 | |---|---|---| | ① 预防 | 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. 模块结构与依赖纪律 ```text 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/.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 编码前一次落地 |