docs: document reasoning ownership and explicit cache migration

This commit is contained in:
2026-09-09 02:41:25 -04:00
parent 73008ad7d5
commit d332287b28
12 changed files with 252 additions and 98 deletions
+42 -24
View File
@@ -38,7 +38,7 @@ resp = await client.chat(messages) # resp: LLMResponse
### 1.2 能力对比矩阵
| 能力 | Video-Tree-TRM5 | GovDoc-SaaS | CHSAnalyzer |
|---|---|---|---|
| --- | --- | --- | --- |
| 治理网关(重试/退避/超时) | ✅ `GovernedLLMClient` | ✅ 同款移植 | ✅ Invoker/Governance 分层(结构最好) |
| 错误分类 | ⚠️ 二分类(瞬时/致命) | ⚠️ 同款 | ✅ 三分类 + Retry-After 解析 + 工件级失败 |
| 限流 | ❌ 仅 `asyncio.Semaphore` | ❌ 完全没有 | ✅ Redis+Lua 六道闸(并发/RPM/TPM × 全局/单源) |
@@ -65,7 +65,7 @@ resp = await client.chat(messages) # resp: LLMResponse
### 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/` |
@@ -108,7 +108,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
### 2.3 非目标(已确认,含理由)
| 不做 | 理由(讨论结论) | 归属 |
|---|---|---|
| --- | --- | --- |
| 任务队列(arq)/任务编排 | 队列单位是业务任务,库单位是单次调用,高度不同;强行进库会迫使批处理项目部署队列、并把"任务"业务概念污染进零业务假设的库。Video-Tree 声明了 arq 依赖却从未使用(死依赖)是现实佐证 | 业务侧 |
| 视频抽帧(ffmpeg)、图像裁剪/拼接/增强等预处理 | 纯业务先验(超声图表格在左上角、每 5 帧一批等),且会拖入 ffmpeg/PIL/numpy 重依赖;库只收就绪的 content 数组/图像字节 | 业务侧 |
| OCR 结果的几何映射(坐标换算/归一化/marker 推算) | 同上,业务先验;库只返回 OCR 服务的原生 bbox + page_size | 业务侧 |
@@ -128,6 +128,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**决策**: 借鉴 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 次。洋葱模型下遥测就是一层,只写一次。
@@ -143,7 +144,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**背景与讨论**: 人类要求完整阐述官方 SDK 与手写的差异优劣。核心对比:
| 维度 | 手写 httpx | 官方 SDK(openai) |
|---|---|---|
| --- | --- | --- |
| SSE 协议解析(帧格式、畸形帧、usage 帧、[DONE]) | 自己写自己修(约 200 行),但全可控 | SDK 维护,跟随协议演进 |
| 错误分类 | 状态码 + body 字符串匹配,自己写 | 类型化异常层级(RateLimitError 等),映射干净 |
| 非标字段(qwen `enable_thinking`、deepseek `reasoning_content`) | 天然支持 | `extra_body` 写入 + `model_extra` 读出,**够用** |
@@ -221,6 +222,10 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**职责拆分(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,而深路径引用正是模块重组会打断下游的原因。
**1.3.4 受管推理契约(2026-09-09 已批准)**:AUTO=要求开启、不指定强度;空 on_base 仅是协议无需开启字节,不是任意模型默认推理。已登记模型必须含 AUTO 才接受 TrueAUTOnearest 不将 AUTO 代选强度;未知模型按已知 wire 尽力+warning,不保证开启。MiniMax on_base 改空,M3 仍不含 AUTOM2.5M2.7 空 wire 真实复验待完成,不新增能力条目。
有受管意图(含 NONE/糖/未知模型)时,source.extra_body 或 request.overlay 任一层出现标准控制根或当前 wire 两向控制根/effort_key 均拒绝,同值和后层遮蔽也不豁免;无意图保留 raw-only,不推断 applied。on_base 不得含自己的 effort_key 或标准 reasoning_effortoutput_config.effort,点号仍是字面顶层键,不新增私有方言解释器。工厂源级校验在装配期;chat 仅前置校验显式请求档与已知 raw;默认 transport 对选中源完整校验并在 HTTP 前 RequestRejected,可能已经准入,finally 结算不变。自定义 transport 由端口实现方履约,不新增 preflight。细则与 M1M9 见[批准设计 §45](designs/2026-09-09-134-thinking-contracts-design.md)。
### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
@@ -307,7 +312,7 @@ flowchart TB
> 2026-07-20 修订(CHS 迁移文档缺口 G3): 初版把熔断/限流画在重试循环外,与"每次重试重新过限流闸"的理由自相矛盾,且熔断/限流是 **per-source** 的——源在循环内才被选出,准入只能发生在循环内。修订后与 CHSAnalyzer 实践(`governance.py:120-167` 逐次尝试执行选源→熔断→permit)一致。
| 相对顺序 | 理由 |
|---|---|
| --- | --- |
| 遥测最外 | 观测一切,包括缓存命中与各类失败;任何路径都留痕 |
| 缓存在重试循环外 | 缓存命中不打网关:不消耗限流配额、不受熔断状态影响 |
| 结构化在缓存内、重试外(2026-07-20 M1 设计) | 带反馈重问 = 再次调用内层,天然照过限流/熔断门、逐次遥测;缓存只固化阶梯通过的最终结果 |
@@ -332,7 +337,7 @@ flowchart TB
这是**跨子系统的通用纪律**,不是遥测的局部约定。它被写下来的直接原因是: 库对"谁建的、谁负责关"从来没有统一说法,于是同一个根因在三个地方长出三种形态——
| 形态 | 位置(修复前) | 性质 |
|---|---|---|
| --- | --- | --- |
| `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` | 泄漏 |
@@ -341,7 +346,7 @@ flowchart TB
纪律把已有的那个正确先例推广为全库唯一说法,分两层落地:
| 层 | 所有权归属 | 落法 |
|---|---|---|
| --- | --- | --- |
| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 |
| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性,`aclose` 只关自建的;三处复制的 `getattr(..., "aclose")` 鸭子探测收敛为一个内部 helper(同时探测 `aclose`/`close`,SQLite recorder 只有同步 `close()`) |
@@ -362,7 +367,7 @@ flowchart TB
**兼容约束(硬)**: 以下字段为三项目现有消费面,只增不删不改名:
| 字段 | 类型 | 说明 |
|---|---|---|
| --- | --- | --- |
| `content` | str | 正式输出文本 |
| `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) |
| `model` / `provider` | str | 溯源 |
@@ -377,7 +382,7 @@ flowchart TB
**可观测字段(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 顶层 |
@@ -386,11 +391,13 @@ flowchart TB
**推理观测三态 `thinking_observation`(2026-08-25,issue #16/#17)**: 类型 `ThinkingObservation`(`StrEnum`),缺省 `UNKNOWN`。回答的问题是「这次调用到底推理没推理」,由多信号裁定:
| 值 | 含义 | 判据(按证据硬度排序) |
|---|---|---|
| --- | --- | --- |
| `observed` | 确证本次推理发生 | 推理正文 `thinking.strip()` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
| `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) |
| `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 |
**测试证据边界(1.3.4**:运行时 UNKNOWN 不告警不等于关闭测试成功。关闭须完整合格轮次全 ABSENT;不可关闭命题在完整合格轮次有 OBSERVED 可支持本条件下未关闭,全 ABSENT 证伪,无 OBSERVED 但 UNKNOWN 仅未覆盖。开启保留完整计划分母与多数 OBSERVED,不丢失败轮。身份缺失只有独立原始 JSON 证据才可归上游;公共身份丢失且无取证 FAIL,成功 SSE 不新增捕获器。默认 FAIL,仅完整请求/唯一尝试/完整无重复键 JSON404 精确 error.type=model_not_found 可自动 UNCOVERED;一般400、429、5xx、解析与治理异常不整类 skip,预期拒绝另按预声明机器字段断言。逐轮安全报告在 tests/outputs,写失败 FAIL,不增加生产数据面。
三态**不可折叠为布尔**: `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**——它是结果不是请求。
@@ -404,7 +411,7 @@ flowchart TB
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
| 值 | 含义 | 生产者 | cost |
|---|---|---|---|
| --- | --- | --- | --- |
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
@@ -440,7 +447,7 @@ flowchart TB
### 6.1 统一四分类 + 熔断信号(融合 CHSAnalyzer 三分类与 GovDoc 二分类)
| 错误类 | 触发 | 重试 | 换源 | 熔断计数 |
|---|---|---|---|---|
| --- | --- | --- | --- | --- |
| `TransientError` | 超时/5xx/429/网络抖动/SSE 异常(畸形帧、断流无 [DONE])/看门狗超时 | ✅ 退避后 | ✅ | ✅ |
| `SourceDeadError` | 401/403/欠费/insufficient_quota(429 body 细分) | ❌ | ✅ 立即 | ✅ force_open |
| `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ |
@@ -458,7 +465,7 @@ flowchart TB
### 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` |
@@ -524,6 +531,8 @@ flowchart TB
### 7.5 响应缓存
**1.3.4 显式迁移前置(D3**:key 与源指纹不新增包版本、语义 revision、fallback、能力表或 wire 版本。AUTOrawMiniMax 语义变更及同版本 fallback/能力表/自定义 wire 改变时,受影响调用集合必须在首次读写前切到从未承载旧语义的 namespace 或 salt。保留租户前缀与 epoch;覆盖工厂默认、全量注入和 per-call(只改默认对覆盖路径无效)。同一共享缓存身份只要一源受影响,整个调用集合须隔离或由下游显式拆分;不强制未受影响 chat 冷启动。新旧版本不共享新身份,回滚旧身份会重见旧值。**未迁移仍可能回放旧响应、绕过新拒绝**,库不会自动检查新可满足性;操作说明不能当自动防护。
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:`
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
@@ -561,7 +570,16 @@ flowchart TB
**`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` 路径,失败仍只逐行降级、不判死。
**`reasoning_effort`(2026-09-05,issue #20,端口 25 → 26)**: 记本次调用**生效的推理档位**,`TEXT` 可空——`NULL`(不表态,或档位取值不在本版词汇内而降级)与 `'none'`(明确要求不推理)是两回事,折叠成任一档等于替上游声称一件它没说过的事。加这一列的理由是分组能力: 此前 25 列里没有任何一列能回答「这一行跑在哪档」,「不同档位是不是真有用」的压测在数据侧无从下手。**三个 emit 入口的口径必须各自定死**(与 `sampling` 列同一先例): `emit_attempt` 成功行读 `response.applied_effort`(即 `nearest` 映射后**真正发出去**的那一档)且**绝不重算**——重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出过的分组下,而这两个值在没开映射的源上恒等,该错误在本地跑不出来;失败尝试没有响应,退回请求档(`effective_effort` 三层优先级,不是裸读字段——`enable_thinking` 也是一次表态)。故**开了 `nearest` 的源上,成功行与失败行不是同一把尺子**,`GROUP BY reasoning_effort` 须带 `error IS NULL``emit_cache_hit` / `emit_terminal_failure` 手上没有选中源,只记请求档。embedding / OCR 路径由 `reasoning_applies=False` 显式声明「本路径无推理语义」,该列恒 NULL——这个布尔**不设默认值也不由 emitter 推断**: 三条路径共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源会让回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的档
**`reasoning_effort`(1.3.4 四种行来源澄清,不改 schema)**:TEXT 可空NULL 与明确要求不推理的 `'none'` 不同。只有真实成功尝试读 transport 的 response.applied_effortnearest 后,不重算);AUTO 是编码选择,不是服务端内部强度,未知 AUTO 不构成能力验证,raw-only 为 NULL
| 行类型 | 来源/限制 |
| --- | --- |
| 真实成功尝试 | response.applied_effort;未知/raw-only 限制如上 |
| 失败尝试 | effective_effort(请求>源>糖)的意图,可能零 HTTP,不能称实际发出 |
| cache_hit | 本次请求级 reasoning_effort,不读历史 applied、不推源级;观测回放历史,不是本次实测 |
| scope 终态失败 | 本次请求级 reasoning_effort,可能尚未选源,不补逐次根因 |
实际档分析须 `cache_hit=false AND error IS NULL`。embeddingOCR textlayout 的成功与失败尝试由 reasoning_applies=False 保证 NULL,真实 client→emitter→临时 SQLite 与 chat 阳性共同守卫,不能用空行集合证明。生产 emitter 单一出口、端口字段数和 DDL 不变。
**`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` 独立成一档而不再被并进「未推理」。
@@ -580,7 +598,7 @@ flowchart TB
**遥测池的资源语义(2026-08-24,issue #15)**: `PostgresRecorder` 此前 `create_pool(dsn, timeout=10)` 继承 asyncpg 默认的 `min_size=max_size=10`,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限"(`pool.py:457``if self._minsize:`)——建池是一次全有全无的重资源动作: 拿不到 10 条就抛异常。这让遥测成为全库唯一预占资源的组件(httpx transport 与三个 redis 后端全是按需建连),也就成了共享实例余量紧张时**必然第一个倒下**的一环,而它承担的恰恰是最不该悄悄失败的职责。改为 `create_pool(dsn, min_size=0, max_size=<PGW_TELEMETRY_PG_POOL_MAX>, timeout=<预算>, command_timeout=<预算>)`,三条随之确立:
| 语义 | 内容 |
|---|---|
| --- | --- |
| 建池零成本 | `min_size=0``_initialize` 只造 holder 对象、**一条连接都不连**(实测 0.000s,指向不可达端口也照样成功)。稳态占用由"每 client 常驻 10 条"变为"实际并发,闲时 0";真实 PG 实测: 建 recorder 后 0 → 一次写入后 1 → 20 行并发后 4(= `pool_max`)→ `aclose` 后 0 |
| 只暴露 `max_size` | `min_size` **有意不给配置项**: 它唯一的作用是把上面那个脆点装回来,换取的只是首次写入省下 ≈390ms 建连。库没有理由提供一个只会伤人的旋钮(P1+P5)。`max_size` 则必须暴露——继承第三方默认值等于库对自己的资源占用不表态(P4) |
| 写入有硬预算 | 整次写入(准备 + acquire + execute)由 `asyncio.timeout(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S)` 包一层,超时按行级丢弃。把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证 |
@@ -593,7 +611,7 @@ flowchart TB
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 丢弃,不降级,接入节流复述 |
@@ -613,7 +631,7 @@ flowchart TB
### 7.9 结构化输出阶梯(D14)
| 级 | 内容 | 成本 |
|---|---|---|
| --- | --- | --- |
| ① 预防 | provider 注册表声明支持时,用 response_format / function calling 直接约束(`NativeSchemaStrategy`) | 无额外 |
| ② 修复 | 围栏剥离 → json_repair → provider 变体归一化(DeepSeek 参数平铺等)(`JsonRepairStrategy`) | 零网络 |
| ③ 校验 | 调用方传 pydantic 模型时库内做**形态**校验;语义校验留业务层 | 零网络 |
@@ -625,7 +643,7 @@ flowchart TB
### 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) |
@@ -680,7 +698,7 @@ src/polygateway/
## 10. 非功能性需求(强制覆盖,继承 Video-Tree CLAUDE.md §4.2.1 条款)
| 维度 | 回答 |
|---|---|
| --- | --- |
| 持久化策略 | 遥测逐调用追加写(WAL);缓存写在响应成功后;崩溃最多丢当次调用的遥测记录 |
| 幂等性 | 遥测 `INSERT OR IGNORE`(call_id 主键);缓存写幂等(同 key 同值);限流 permit 带 TTL 租约,进程死亡后自动过期回收 |
| 断点续跑 | 库无长任务状态,天然无断点问题;响应缓存本身即业务侧重跑的加速器 |
@@ -699,7 +717,7 @@ src/polygateway/
### 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 含租户 |
@@ -709,7 +727,7 @@ src/polygateway/
> 下表保留作历史记录与能力倒推依据(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 数组后调库 |
@@ -719,7 +737,7 @@ src/polygateway/
### 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`(公平调度) | 留在项目(业务侧) |
@@ -731,7 +749,7 @@ src/polygateway/
## 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 升级) |
@@ -744,7 +762,7 @@ src/polygateway/
## 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 设计文档过人类门 |