Files
PolyGateway/research-wiki/ARCHITECTURE.md
T

56 KiB
Raw Blame History

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.pyGovernedLLMClient 实现五层治理(熔断→缓存→重试+流式→写缓存→遥测)。
  • 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 providermodel.split("-")[0] 推断 provider,接新 provider 要改核心类。
  5. 同样的坑各踩各的:遥测调用逐字复制 4 次(Video-Tree 与 GovDoc 都有,record_llm_call 十几个参数的调用散布在缓存命中/成功/致命/瞬时/非重试五个分支);CHSAnalyzer 的 judge 同步裸调无治理。

1.4 各项目关键资产索引(移植蓝本)

资产 来源 移植去向(§7)
治理网关主循环(参考结构,需重构掉遥测复制) Video-Tree/adapters/llm.pyGovDoc/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.pyapp/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.pyGovDoc/.../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.pyCHSAnalyzer/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–D14,含讨论过程与备选方案)

每条决策记录格式:决策 / 背景与讨论 / 被否决的备选 / 影响。这些决策已与人类逐条确认;推翻任何一条需要人类批准并修订本节。

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 同一套中间件治理栈(CHSAnalyzer 已有 VLM/OCR 共用泛型治理核心的先例)。

背景(调研结论): 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 providermodel.split("-")[0] 式字符串猜测。显式 provider 注册表,每个 provider 声明:thinking 参数注入方式(deepseek {"thinking":{"type":"enabled"}} / qwen {"enable_thinking": True})、思考流字段(reasoning_content / <think> 标签剥离)、原生 schema 能力(供 D7 策略选择)、默认错误翻译细则。新 provider = 注册一个条目,不改核心类。

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=<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 设计文档。


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)

  1. 缓存命中: TelemetryMW 记录(cache_hit=True, latency_ms=0)→ CacheMW 返回,不触达任何更内层。
  2. 正常路径: RetryMW 开始第一次尝试 → selector 选源(跳过冷却中的源)→ 该源熔断门(闭路)→ 限流 acquire permit(全局+该源,并发/RPM/TPM 三闸,token 按 est_tokens 预扣)→ transport 发请求、流式解析(看门狗包裹)、收 usage 帧 → permit 按实际 usage settle(多退少补)→ 回程写缓存 → 遥测记成功(含 ttft/max_inter_token/成本)。
  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 释放。

5. 核心类型

5.1 LLMResponse(frozen dataclass,与三项目超集兼容)

兼容约束(硬): 以下字段为三项目现有消费面,只增不删不改名:

字段 类型 说明
content str 正式输出文本
thinking str 思考流内容(reasoning_content / think 标签,按 provider 注册表提取)
model / provider str 溯源
prompt_tokens / completion_tokens int usage 帧读取;缺失时按估算标注
latency_ms int 总延迟
ttft_ms / max_inter_token_ms float? 流式活性测量
cache_hit bool 是否缓存命中
call_id str UUID,每次尝试独立

新增字段(库扩展,全部带默认值): source_name(多源溯源)、cost(pricing 换算,可为 None)、usage_source(measured/estimated)、structured_data(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)。

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/raw)、OcrLayoutResult(elements 含 bbox/type + page_size + 溯源)。全部 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 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。


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 可配

scope 级不可用的结构化语义(2026-07-20,CHS 迁移缺口 G1;2026-07-20 M1 设计勘误修订): AllSourcesExhausted/CircuitOpenError 必须携带结构化字段——retry_after_s: float(非可选,承 CHS ProviderUnavailableError 同款,0 表示可立即重试;取各源冷却与 Retry-After 的最小值)、reason 枚举、per_source_reasons: dict[str, str]。reason 两层值域(M1 设计 §3 勘误: 本节初版所列 7 值与 CHS errors.py:143-153 实际值域不符,重组如下)——scope 级 reason: circuit_open / retry_exhausted / stalled / quota_exhausted / no_sources;per_source_reasons 值: network_error / timeout / rate_limited / source_dead / circuit_open / cooldown。CHS 的"scope 级不可用 → arq 延期重投、不消耗业务失败预算"(workers/tracking.py:406-428)依赖 retry_after_s 复现。

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

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_usageSSE 缺 [DONE] 语义(2026-07-20 M1 设计): per-source missing_done: "retry" | "salvage",默认 retry(防截断响应进缓存被固化);零内容提前断流(early_eof)恒 retry 不可配;打捞路径强制 usage_source="estimated"。CHS 迁移配 salvage 保留其现状行为。看门狗活性口径: 任何增量(content 或 reasoning_content)都算 token——ttft = 首个任意 token,思考流刷新 inter_token 计时(CHS 迁移约束 R1)。非流式快路径: 短请求可配 stream=False(三项目都写死 stream=True 强迫短请求走 SSE+看门狗,库放开)。
  • 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(立即抛)。
  • 全局活性信号: mark_progress()/progress_age_s()("最近一次出餐"时刻)供背压 stall 判定,移植 CHSAnalyzer limiter.py:193
  • 契约补强(2026-07-20,CHS 迁移缺口 G6): settle()/release() 幂等(重复调用无副作用);装配期守卫——timeout_s ≤ permit 租约 TTL(防租约先于请求过期)、stall_window ≥ 最慢源 TTFT 上限(防误判卡死),违反直接报错拒绝装配。降级方向细化(2026-07-20 M1): "报错不放行"适用于准入侧(try_acquire/try_enter 及选源路径消费的 source_stats/retry_after_s);已成功调用后的 settle/release 释放侧失败降级 warning——释放失败不构成放行,且不得掩盖主异常与取消。勘误(2026-07-20 M2 设计,人类批准): 记账侧的 record_success/record_failure/mark_progress 同归此类——调用已真实完成,后端失败若冒泡会丢弃真实成功响应或掩盖原始尝试异常,故降级 warning(CHS 原版一律报错,此为有意反转;丢一次熔断记账最多延迟状态迁移且方向偏保守,epoch fencing 防污染)。

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, concurrency * 2)(三项目 .env 注释中的手动约定,库内自动计算)。
  • 源冷却备忘: 开路源在进程本地记冷却截止时刻,选源时跳过,避免白烧 RPM 去探测(移植 governance.py:107)。

7.5 响应缓存

key 公式: sha256(canonical_json({model, messages_digest, namespace, salt})),前缀 pgw:cache:

  • messages_digest: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
  • namespace: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
  • salt: 可选,跨 epoch 强制重采样(Video-Tree 需求)。
  • 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.timeoutexpired() 区分本层 deadline 与上游超时,避免取消泄漏。三项目同款,近乎原样移植。约束 0 < inter_token < ttft < timeout_s(继承 CHSAnalyzer SourceConfig 不变式校验)。

7.7 多源与选源

SourceConfig: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/est_tokens(TPM 预扣常量,亦作 usage 缺失时的保守兜底,移植 CHS config.py:55;2026-07-20 缺口 G2 补)/enable_thinking。聚合自环境变量 {SCOPE}__{PROVIDER}__{N}__{FIELD}(§9)。SourceSelector 端口: round_robin / least_inflight 首发。逻辑角色: Video-Tree 式 SEARCH/JUDGE/VL/EVOLVE 多角色 = 命名的 client 配置组,from_env() 支持按角色前缀装配多个 client;禁止两个角色静默共享同一实例却在配置上看似独立(Video-Tree evolve_llm = llm 别名的教训——共享必须显式)。

多 client 共享状态后端(2026-07-20,VT 迁移缺口 R5): 限流/熔断状态的 key 以 scope+source 为单位,与 client 实例解耦;多个逻辑角色的 client 显式注入同一个状态后端实例时即共享全局并发/RPM/TPM 闸(Video-Tree TREE_BUILD_API_CONCURRENCY 跨 SEARCH+VL 共享 semaphore 的语义由此承接)。共享必须显式注入,禁止隐式全局。

7.8 遥测与成本

必录字段(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、cost。链路: session_id/parent_call_id 由调用方传入贯穿(agent step → LLM call)。messages 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(llm.py:330),库内修复(2026-07-20,VT 迁移缺口 R12)。

  • 后端: SQLiteRecorder(默认;WAL + busy_timeout、INSERT OR IGNORE 幂等、asyncio.to_thread 桥接、初始化/写入失败全降级不冒泡)与 PostgresRecorder
  • 单一 helper 铁律: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 record_llm_call(15 个参数) 是本条的直接教训。
  • 成本: pricing.py 维护 model → (input 单价, output 单价) 表,遥测时换算 cost 字段;查不到价格记 None 并 warning,不阻塞调用

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 走同一中间件栈(无 token 计费,Usage 填 0,elapsed 照记);多后端经 provider 注册表扩展(GLM 已在 CHSAnalyzer 白名单,输入形态为 URL,届时封装在其 invoker 内部,端口签名不变)。数值防御(bbox 有限性/顺序/退化校验)随协议解析下沉进库。OCR transport 支持 per-source trust_env 开关(LAN 直连绕过本地代理,Video-Tree ocr.py:46 教训;2026-07-20 缺口 R9);端口族含 check_health() -> bool 逐源健康预检(Video-Tree A/B 评测的启动门;缺口 R10)。

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 注册表
├── 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 是唯一的组装层。核心依赖仅 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_KEYOCR__MONKEY__1__BASE_URL),聚合为 list[SourceConfig];SCOPE 支持逻辑角色前缀(§7.7)。
  • 韧性参数键名沿用三项目习惯(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|redisPGW_TELEMETRY_BACKEND=sqlite|postgresPGW_QUOTA_FULL=wait|fail_fast(命名待 M1 设计文档定稿)。

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.pybreaker.pyredis_cache.pystreaming.pytelemetry_sqlite.py 删除,由库继任
protocols.pyLLMProvider.chat(messages, *, session_id, parent_call_id) 签名 库保持兼容(或一行 shim)
倒推的库需求 from_env 工厂(GovDoc 装配层本就缺失,库直接补上)、Postgres 遥测、缓存 key namespace 含租户

11.2 Video-Tree-TRM5(难度中)

项目侧 处置
adapters/llm.pybreaker.pystreaming.pyredis_cache.pytelemetry.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.pyapp/coordination/limiter.py + scripts.pyprovider_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 打包与分发: 内网 pip index / git+ssh 依赖 / submodule? git+ssh 起步,稳定后内网 index
Q2 Python 最低版本 3.11(覆盖三项目: 3.11×2 + 3.13×1)
Q3 Embedding 客户端是否纳入。勘误(2026-07-20,VT 迁移文档 R11): 初版称"各有一套独立重试实现"不实——GovDoc 的 OpenAICompatEmbedding 有自研退避,但 Video-Tree 的 RemoteEmbeddingProvider同步 SDK 裸调、无任何重试;纳入库还需异步化其端口 已拍板(2026-07-20 人类): 纳入 M2(消灭无治理的裸调 + 统一重试),含端口异步化;Embedding 端口为公共 API,随 M2 设计文档过人类门
Q6 CHSAnalyzer 的 judge(core/eval/judge.py,同步裸调 anthropic SDK)迁移路径: 走 OpenAI 兼容中转网关(零库改动)还是库提供 Anthropic 原生 transport(D2 有端口预留,未排里程碑) 待人类拍板(CHS 迁移文档 G7)
Q4 conda 环境名 PolyGateway
Q5 本仓库工程脚手架(git init、.claude/ skills、Makefile、pyproject、import-linter)何时落地 本文档终审通过后、M1 编码前一次落地