chore: bootstrap project scaffolding

Add architecture doc (research-wiki/ARCHITECTURE.md), CLAUDE.md with
tiered SOP for Fable 5, adapted .claude skills/hooks/settings, package
skeleton (src/polygateway), pyproject with import-linter contracts,
Makefile, .env.example and smoke test.
This commit is contained in:
2026-07-20 00:49:10 -04:00
commit 3058f4c744
63 changed files with 7631 additions and 0 deletions
+525
View File
@@ -0,0 +1,525 @@
# 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–D12,含讨论过程与备选方案)
> 每条决策记录格式:**决策 / 背景与讨论 / 被否决的备选 / 影响**。这些决策已与人类逐条确认;推翻任何一条需要人类批准并修订本节。
### 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 provider``model.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]`)。
---
## 4. 总体架构
### 4.1 洋葱结构
```mermaid
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 --> M3[BreakerMW 熔断]
M3 --> M4[RateLimitMW 限流]
M4 --> M5[RetryMW 重试+退避+换源]
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 & M3 & M4 -.-> 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 默认中间件层序及理由(外→内)
**遥测 → 缓存 → 熔断 → 限流 → 重试 → transport**
| 相对顺序 | 理由 |
|---|---|
| 遥测最外 | 观测一切,包括缓存命中与各类失败;任何路径都留痕 |
| 缓存在熔断/限流外 | 缓存命中不应消耗限流配额,也不应被开路的熔断挡住(命中不打网关) |
| 熔断在限流外 | 开路时直接拒绝,不占用限流租约、不排队等配额 |
| 重试在限流内 | 每次重试是一次真实网络请求,必须重新过限流闸;否则重试风暴击穿配额。此顺序意味着限流后端看到的是"含重试的真实请求数" |
| 换源在重试循环内 | `TransientError`/`SourceDeadError` 触发选下一源(§6),同一逻辑调用的多次尝试可落在不同源上 |
层序与取舍最终是配置——项目可增删层(如无 Redis 环境去掉 CacheMW),但改默认顺序需理解上表理由。
### 4.4 一次调用的生命周期(walkthrough)
1. **缓存命中**: TelemetryMW 记录(cache_hit=True, latency_ms=0)→ CacheMW 返回,不触达任何更内层。
2. **正常路径**: 穿过熔断(闭路)→ 限流 acquire permit(并发/RPM/TPM 三闸,token 预扣)→ RetryMW 首次尝试 → selector 选源 → 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)。
### 5.2 其他类型
`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 表达,调用失败必须走异常——二者严格区分。
---
## 6. 错误模型
### 6.1 统一四分类 + 熔断信号(融合 CHSAnalyzer 三分类与 GovDoc 二分类)
| 错误类 | 触发 | 重试 | 换源 | 熔断计数 |
|---|---|---|---|---|
| `TransientError` | 超时/5xx/429/网络抖动/SSE 异常(畸形帧、断流无 [DONE])/看门狗超时 | ✅ 退避后 | ✅ | ✅ |
| `SourceDeadError` | 401/403/欠费/insufficient_quota(429 body 细分) | ❌ | ✅ 立即 | ✅ force_open |
| `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ |
| `ResultInvalidError` | 调用成功但内容不可解析(JSON 修不好、ZIP 缺关键文件) | ❌(策略层可选二次尝试) | ❌ | ❌(熔断记**成功**) |
| `CircuitOpenError` / `AllSourcesExhausted` | 开路 / 全源耗尽 | 调用方决定: wait / fail-fast 可配 | — | — |
### 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` |
| 解析层失败(结构化输出/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_usage`。**非流式快路径**: 短请求可配 `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`
### 7.4 熔断
**状态机**(算法一份): 闭路 --连续失败达阈值--> 开路(冷却)--冷却到期--> 半开(只放**一个**探针,防惊群)--成功--> 闭路 / --失败--> 开路。`force_open` 支持 SourceDeadError 一击即熔。按 source_name 分别计数。
- `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.timeout``expired()` 区分本层 deadline 与上游超时,避免取消泄漏。三项目同款,近乎原样移植。约束 `0 < inter_token < ttft < timeout_s`(继承 CHSAnalyzer SourceConfig 不变式校验)。
### 7.7 多源与选源
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/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` 别名的教训——共享必须显式)。
### 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)。
- 后端: `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 结构化输出策略
| 策略 | 机制 | 适用 |
|---|---|---|
| `JsonRepairStrategy` | prompt 约定 + ```json 围栏剥离 + json_repair + provider 变体归一化(如 DeepSeek 参数平铺) | 任意网关;三项目现状的收敛 |
| `NativeSchemaStrategy` | response_format json_schema / function calling | provider 注册表声明支持时 |
逐调用可选;解析失败统一抛 `ResultInvalidError`(§6.3)。
### 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 有限性/顺序/退化校验)随协议解析下沉进库。
### 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
├── middleware/ # retry.py / ratelimit.py / breaker.py / cache.py / telemetry.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. 配置面
- **载体**: `pydantic-settings` + `.env`(工程配置);缺失关键配置直接报错,严禁硬编码默认值兜底(三项目共同铁律)。
- **多源命名**: `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(如 `LLM__QWEN__1__API_KEY`、`OCR__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`),降低三项目迁移改名成本。
- **装配只有两条路**: `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 设计文档定稿)。
---
## 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 ...` + 配置,原测试全部通过。**凡替换不掉的能力,就是库的边界缺口**,回补后重验。这条标准同时是防"造没人用的空中楼阁"的机制:每个里程碑都有真实接入方。
### 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(难度中)
| 项目侧 | 处置 |
|---|---|
| `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、内存版限流/熔断、缓存(Redis+内存)、SQLite 遥测、结构化输出双策略、provider 注册表、from_env | GovDoc、Video-Tree |
| M2 分布式 | Redis 限流(六道闸+契约测试)/熔断后端、多源多账号+选源、背压 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 客户端是否纳入(GovDoc `retrieval/embedding.py` 与 Video-Tree `adapters/embedding.py` 各有一套独立重试实现,是第三处重复) | 建议 M2 纳入,复用同一治理栈 |
| Q4 | conda 环境名 | `PolyGateway` |
| Q5 | 本仓库工程脚手架(git init、`.claude/` skills、Makefile、pyproject、import-linter)何时落地 | 本文档终审通过后、M1 编码前一次落地 |