# CHSAnalyzer 迁移文档(迁移即验收)

> [!NOTE]
> **迁移已完成并验收(2026-07-22,M4;回归 50/50、verifier 1I3M 全清)**——worktree 分支 feat/polygateway-migration,证据见 `findings/2026-07-22-m4-acceptance.md` §2,生产切换手册在项目侧 `research-wiki/findings/2026-07-22-polygateway-cutover-runbook.md`。实施对本文的勘误/裁决:
> ① **上游漂移(基点 7eb8482)**: positioning 已重构为纯图像模板分类,`PositionCallScheduler`/`load_single_vlm_capacity`/`vascular_positioner.py` 上游已删——§2"load_single_vlm_capacity 保留"、§6-S3"灰度先切 position worker"、§4 的 vascular_positioner 调用点三条作废;VLM 消费点收敛为 extractors/classifiers(pipeline worker 单段切换)。
> ② **judge(§4/G7)**: 实测 core/eval/ 零调用方、从未接线,且实验室网关无 claude 系——用户拍板豁免不动(Q6 落定,见 ARCHITECTURE §13)。
> ③ **G1 消费形态**: tracking.py 直接 `except GatewayUnavailableError`(无翻译 shim——Provider 错误族随 §2 清场删除,翻译层是死代码);`_TERMINAL` 同步改捕库 `RequestRejectedError`。
> ④ **magic bytes 归属**: 审计表"移入库 transport"修订为"移入业务 shim(`pgw_shims._data_url` 逐字保真)"——库 chat 的多模态组装归业务侧(ARCH §11.2 同款边界),transport 不拆 data URL。
> ⑤ 装配期守卫(G6 lease/stall)归库 from_env,container 本地守卫与其自测删除;限流契约文件(tests/contracts_limiter.py)已由库契约套件继任后删除。

> **定位**: 本文是 `ARCHITECTURE.md §11.3` 的展开——库建成后如何合并进 CHSAnalyzer、替换哪些内部组件。与 ARCHITECTURE.md 冲突时**以 ARCHITECTURE.md 为准**。CHSAnalyzer 是三项目中迁移难度最高、能力对标要求最高的一个:库必须先达到其治理能力**逐项对等**(§1 对标清单),迁移才有动机。全部结论基于 2026-07-20 对 `reference/CHSAnalyzer/` 的代码实测(file:line 为证)。

## 1. 迁移目标与验收定义

**验收公式**: 删除 §2 标注"删除"的文件 → 业务侧 import 换成 `polygateway` + shim → 原测试全绿(被删组件的自测如 `tests/unit/test_governance.py`、`tests/integration/test_redis_limiter.py` 随组件迁入库侧,不计入"原测试")。

| 阶段 | 可替换范围 | 前置里程碑 |
|---|---|---|
| M2 后 | VLM scope 治理核心(governance/limiter/scripts/provider_gate/selector/streaming + VLM invoker) | Redis 六道闸+契约测试、多源选源、跨进程熔断、背压 stall |
| M3 后 | OCR scope(MonkeyOcrParseInvoker → `OcrLayoutPort`)、`core/eval/judge.py` 收编,**全量迁移** | OCR 端口族 + MonkeyOCR transport |

**能力对标清单**(库必须逐项对等,任一缺失即库的边界缺口;✅ = M2 验收打钩 2026-07-21,证据为库测试):

| # | 能力 | 项目侧证据 | M2 打钩 |
|---|---|---|---|
| 1 | 六道闸原子限流(全局/单源 × 并发/RPM/TPM),拒绝零副作用 | `app/coordination/scripts.py:6-34` | ✅ `backends/redis/limiter.py` Lua 逐字移植;契约 redis 参数全绿 |
| 2 | TPM 预扣入场、settle 按实际 usage 落回 acquire 窗口多退少补 | `limiter.py:55-62`、`scripts.py:44-48` | ✅ 契约 `test_prededuct_and_settle_refund[redis]` |
| 3 | 并发 lease 带 TTL(防进程死亡泄漏)+ release/settle 幂等 | `limiter.py:46-63`、`tests/contracts_limiter.py:66-73` | ✅ 真实等待变体 `test_variant_lease_expiry_reclaims_slot` + 幂等契约 |
| 4 | 窗口 id 用 Redis 服务器时钟(多进程口径统一) | `limiter.py:95-98` | ✅ `test_rpm_window_rollover_resets_quota` + 跨连接 RPM 用例 |
| 5 | 跨进程熔断:单探针半开、探针租约 TTL、epoch fencing、force_open | `scripts.py:82-225`、`provider_gate.py` | ✅ `backends/redis/breaker.py`;熔断契约 + 7 个真实等待变体 |
| 6 | 背压 stall 双条件判卡死(本地等够 + 全局无进展)+ mark_progress 全局活性 | `governance.py:270-281`、`limiter.py:193-209` | ✅ `middleware/retry.py` + `test_backpressure` 四象限 + 跨连接进度可见 |
| 7 | 换源重试:失败跨源累计、退避取 Retry-After 较大值、源冷却备忘 | `governance.py:105-118, 244-261` | ✅ M1 已交付(`test_retry`);M2 联合验证下重验 |
| 8 | 错误分类:429 body 细分 insufficient_quota、工件级失败不熔断 | `invokers.py:144-166`、`governance.py:237-239` | ✅ M1 已交付(`test_openai_compat`/ResultInvalid 不熔断) |
| 9 | scope 级不可用结构化错误(reason + retry_after_s),供 worker 延期重投 | `errors.py:140-180`、`workers/tracking.py:406-428` | ✅ M1 错误模型(5 值 reason 勘误后)+ M2 增 stalled 真实触发路径 |
| 10 | 流式三层活性看门狗;thinking token 刷活性不计结果 | `streaming.py`、`invokers.py:55-79` | ✅ M1 已交付(`test_streaming`) |

> 注: OCR invoker 能力(MonkeyOCR 两端点)不在本清单——归 M3。

## 2. 现状盘点(行数 wc -l 实测)

| 文件 | 行数 | 职责一句话 | 迁移后命运 |
|---|---|---|---|
| `app/providers/governance.py` | 350 | 多源治理编排(选源+限流+熔断+重试+背压) | **删除**(库 middleware 继任;Governed 外壳变 shim) |
| `app/providers/invokers.py` | 552 | VLM SSE invoker + MonkeyOCR `/parse` invoker + 错误翻译 | **删除**(库 transport 继任) |
| `app/providers/selector.py` | 42 | round_robin / least_inflight 选源 | **删除**(库 `SourceSelector` 同款) |
| `app/providers/streaming.py` | 128 | 三层活性看门狗纯函数 | **删除**(库 `streaming.py` 同款移植) |
| `app/coordination/limiter.py` | 209 | Redis 六道闸限流 + Permit | **删除**(库 `backends/redis`) |
| `app/coordination/scripts.py` | 245 | 限流/熔断全部 Lua 脚本 | **删除**(随限流/熔断迁库) |
| `app/coordination/provider_gate.py` | 179 | Redis 跨进程熔断门 | **删除**(库 `RedisBreakerState`) |
| `tests/contracts_limiter.py` | 79 | 限流器契约(5 条,任一实现须过) | **迁入库**(M2 随实现交付) |
| `core/eval/judge.py` | 201 | LLM 语义裁判,**同步裸 SDK 无治理**(反面教材) | **改写**(走库,见 §4) |
| `app/domain/errors.py` | 226 | 领域异常;其中 82-180 行为 Provider 错误族 | **改写**(Provider 族删除换库 `errors.py`;业务异常保留) |
| `app/config.py` | 574 | Settings + 多源/全局限额/retry/breaker/背压解析 | **改写**(治理配置解析约 43-410 行删除,由库 `from_env` 继任;Settings/S3/Zip/Stage 保留) |
| `app/container.py` | 511 | 组装根;225-320 行手工装配治理栈 | **改写**(治理装配换库工厂,其余保留) |
| `app/ports.py` | 704 | 全部端口;其中 396-529 行为限流/熔断端口 | **改写**(限流/熔断端口删除;`VlmProvider`/`TableLocator`/`ProviderOutcome`/`Usage` 保留为业务端口,由 shim 实现) |

**明确留在业务侧(零改动或仅换上游类型)**: `position_scheduler.py`(347 行,按 Session 轮转的公平调度,ARCHITECTURE §2.3 明确不进库)、`table_locator.py`(111 行,裁剪/坐标映射/marker 推算等几何映射)、`marker_imaging.py`(96 行,拼图增强)、`preprocessing.py`(64 行)、`extractors.py`(108 行)/`classifiers.py`(47 行)/`vascular_positioner.py`(378 行)的业务解析与编排、`worker_liveness.py`(238 行,任务级活性,非调用治理)、`redis_pool.py`(22 行,arq/调度器仍需)。

## 3. 组件替换映射表

| CHSAnalyzer | PolyGateway 对应物 | 签名兼容性(实测) | shim 形态 |
|---|---|---|---|
| `GovernanceCore.run(call)`(governance.py:200) | 中间件洋葱整体 | 泛型闭包 → `client.chat(request)`,范式不同 | 无需 shim,整体替换 |
| `VlmProvider.complete(image, instruction)`(ports.py:552-555) | `client.chat(messages)` | **不兼容**:bytes+str vs messages 数组 | `PgwVlmProvider`:组 content 数组(text+image_url base64)→ chat → 映射回 `ProviderOutcome` |
| `ProviderOutcome{text,source_name,model,usage,raw}`(ports.py:541-548) | `LLMResponse`(ARCH §5.1) | 字段可全映射:text←content、usage.total_tokens←prompt+completion、elapsed_s←latency_ms/1000 | shim 内一次转换 |
| `Permit{release, settle(actual_tokens:int)}`(ports.py:495-504) | 库 `Permit.settle(actual_usage)`(ARCH §7.3) | 语义同;settle 参数类型 int vs usage 需 M2 定稿 | 库内部端口,业务不触及 |
| `ConcurrencyLimiter`(ports.py:508-529,含 `mark_progress`/`progress_age_s`/`source_stats`) | 库限流端口(ARCH §7.3 同款契约) | 对等(§7.3 已列 mark_progress/progress_age) | 无 |
| `ProviderGate` + `ProviderGateDecision`(epoch/is_probe/probe_owner)(ports.py:406-491) | 库熔断端口 + epoch fencing(ARCH §7.4) | 状态机对等;probe TTL 见 §9-G5 | 无 |
| `TableLocator.locate(image)`(ports.py:577-580) | 业务端口保留,内部改调 `OcrLayoutPort.parse_layout` | 库返回 elements 全量(para_blocks 提取,与 tables 列表 bbox 逐一相等——M3 设计 §1.2 35 样本取证);项目 shim 约 5 行: 取首个 type=="table" 元素 + `int()` 四元组 | `table_locator.py` 改写取数逻辑 |
| 错误三分类 + `OcrResultInvalidError` + `ExtractionParseError` | 库四分类(ARCH §6.1) | Transient/SourceDead/RequestRejected 一一对应;`OcrResultInvalidError` 与 `ExtractionParseError` 合并进 `ResultInvalidError` | worker `_TERMINAL`/重试分支改 except 库异常 |

**ProviderUnavailableError 的专门论述**(errors.py:140-180): 这是项目独有的 **scope 级不可用**语义——不是"某次调用失败",而是"整个 VLM/OCR 作用域暂时无源可用,任务应延期且**不消耗业务失败预算**"。它携带 `scope`、7 种受控 `reason`(network_error/timeout/rate_limited/source_dead/circuit_open/retry_exhausted/stalled)、`retry_after_s`(读共享熔断门取全 scope 最早恢复时刻,governance.py:182-198)、`reasons`(per-source 失败原因字典)。消费点 `workers/tracking.py:406-428`:捕获后 `finish_deferred_attempt`(FAILED 但不计失败预算)并 `raise Retry(defer=retry_after_s 派生)`——**arq 级延期重投**。ARCHITECTURE §6.1 的承接:`CircuitOpenError`/`AllSourcesExhausted` 覆盖了"发生了什么",但**未定义结构化字段**(retry_after_s/reason/per-source reasons),而 tracking.py 的延期时长直接依赖 `retry_after_s`。承接方案:库两异常须携带 `retry_after_s`(熔断后端最早恢复时刻)与逐源原因;项目侧留 10 行翻译 shim 把库异常包成 `ProviderUnavailableError`(或 tracking.py 直接改 except 库异常)。字段缺失则该行为不可复现 → **⚠️ 架构缺口 G1**。

## 4. 调用点清单(grep 实测)

| 调用点 | 现状 | 迁移后形态 |
|---|---|---|
| `app/providers/extractors.py:101` | `await self._vlm.complete(cropped, instruction)` | 不变(经 `PgwVlmProvider` shim) |
| `app/providers/classifiers.py:33` | 同上 | 不变(同上) |
| `app/providers/vascular_positioner.py:329-330` | `scheduler.acquire(session_id)` 内 `vlm.complete` | 不变;公平调度留业务侧,包在库调用外 |
| `app/pipeline/extract_table.py:141` | `await self._locator.locate(data)` | 不变(`table_locator` 内部改调 `OcrLayoutPort`) |
| `app/workers/startup.py:123` | `container.build_extraction_pipeline(...)` | 装配体内部换库工厂(§5) |
| `app/workers/startup.py:196` | `container.build_vlm_provider(environ)` | 同上 |
| `app/workers/tracking.py:22,406-428` | `except ProviderUnavailableError` → 延期重投 | except 库异常(或经翻译 shim),**实质改动点之一** |
| `core/eval/judge.py:34-52` | 同步裸调 anthropic/openai SDK,零治理;JSON 解析手写 `find('{')`+`rfind('}')`(:112-123) | `_call_llm` 改 `asyncio.run(client.chat(...))`(Judge 端口是同步的、runner 无事件循环,桥接安全);解析换库 `JsonRepairStrategy`,**实质改动点之二** |

**"judge 是唯一需要业务代码实质改动的点?"——否。** 实质改动共两处:judge(新增治理能力,且默认 provider=anthropic,须经实验室 OpenAI 兼容中转网关接入,或等库增 Anthropic transport——D2 已预留端口但无里程碑)与 `workers/tracking.py` 的异常分支(§3 G1)。其余调用点全部躲在 `VlmProvider`/`TableLocator` 业务端口后,shim 保签名即零改动。**arq worker 与治理层的边界**清晰不变:worker(编排层,任务重试/死信/liveness)只透过这两个端口消费治理层,库恰好站在端口之下,边界无移动。

## 5. 配置迁移

**多源键名**: 库的 `{SCOPE}__{PROVIDER}__{N}__{FIELD}` 约定即源自本项目(config.py:107-125),`VLM__QWEN__1__*`、`OCR__MONKEY__1__*` 及字段名(BASE_URL/API_KEY/MODEL/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TIMEOUT_S/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING)**原样继承,零改名**。

| 项目键(.env.example 实测) | 库对应 | 差异 |
|---|---|---|
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 同名继承 | 无;`EST_TOKENS` 键保留不改名,但 2026-07-30 起由必填降为可选(G2 已闭,详见该行) |
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY/RPM/TPM`(config.py:249-271) | 全局闸限额 | ARCH §9 未定义 GLOBAL 段命名,M2 设计须定(建议原样继承) |
| `{SCOPE}__SELECTOR`(round_robin/least_inflight) | `SourceSelector` 策略选择 | 命名待 M1/M2 定稿,建议继承 |
| `{SCOPE}__RETRY__MAX_ATTEMPTS/BACKOFF_BASE_S/BACKOFF_MAX_S` | RetryPolicy | ⚠️ G4:ARCH §9 只列平铺 `LLM_MAX_RETRIES` 等键,无 per-scope 形态 |
| `{SCOPE}__BREAKER__FAIL_THRESHOLD/COOLDOWN_S` | BreakerConfig | 同 G4 |
| `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S/POLL_INTERVAL_S` | 配额满 wait+stall 判定参数 | 同 G4;项目还有废弃键守卫(config.py:374-378)可放弃 |
| (代码常量)`_LEASE_TTL_MS=1_500_000`(container.py:99) | permit 租约 TTL | 项目是常量+装配守卫,库应配置化并保留守卫(G6) |
| 新增 | `PGW_LIMITER_BACKEND=redis`、缓存 namespace/TTL、遥测后端等 | 净新增能力的配置 |

**装配对比**: container.py:225-320 手工装配(约 96 行:load_sources → 守卫 → Redis 池 → limiter → selector → retry/backpressure/breaker → gate → 每源 httpx client + invoker → GovernanceCore,失败逐层清理)。迁移后:

```python
# after(示意;实际 API 以 M1 设计文档为准)
vlm_client = GatewayClient.from_env(role="VLM")   # 限流/熔断/重试/缓存/遥测/资源生命周期全内置
ocr_client = GatewayClient.from_env(role="OCR")
stack = ExtractionProviderStack(
    vlm=PgwVlmProvider(vlm_client),                        # shim: 实现 app.ports.VlmProvider
    table_locator=BusinessTableLocator(PgwOcrLayout(ocr_client)),  # 几何映射留业务
)
```

`load_single_vlm_capacity`(config.py:274-299,position 调度器容量与治理并发口径一致性校验)保留,改读库的 SourceConfig 聚合结果。

## 6. 迁移步骤(生产服务,按 scope 灰度)

可灰度性依据:治理栈按 scope 独立装配(container.py:303-320),VLM 与 OCR 的 Redis key 前缀互不相交(`cclimit:{scope}:*`/`provider_gate:{scope}:*` vs 库前缀),且 worker 按角色分进程部署(startup.py:116/189)——**可以一个 scope 一个 scope 迁**。同一 scope 内禁止新旧栈并跑(限额会被双份计数),须整 scope 原子切换。

| 步骤 | 内容 | 验证 | 回滚点 |
|---|---|---|---|
| S0 | 基线:记录当前测试全绿证据;`chs` 环境安装 `polygateway[redis,...]`;git 提交回滚点 | `make test` 全绿 | git tag |
| S1(M2) | 库侧先行:`contracts_limiter.py` 5 条契约对库 RedisLimiter 跑通(真实 Redis);项目零改动 | 契约测试全过 | 无风险 |
| S2(M2) | 引入装配开关(env:`GOVERNANCE_IMPL=legacy\|pgw`,按 scope);实现 `PgwVlmProvider` shim + G1 异常翻译 | 单测 shim 映射;integration 双实现对拍 | 开关切回 legacy |
| S3(M2) | **VLM scope 灰度**:先 position worker(消费面最小,仅 vascular_positioner)观察,再 pipeline worker(extract) | e2e 提取/定位测试;遥测比对错误率与延迟 | 开关 + 旧代码未删 |
| S4(M3) | **OCR scope**:`table_locator.py` 改写消费 `OcrLayoutResult`,切 OCR scope | extract_table integration 全绿 | 同上 |
| S5(M3) | judge 收编:`_call_llm` 走库(异步桥接),解析换 `JsonRepairStrategy` | `tests/unit/test_eval_judge.py` | judge 独立文件,单独回滚 |
| S6 | 清场:执行 §2 删除清单、errors.py Provider 族删除、tracking.py 定稿、拆开关 | **原测试全绿(验收门)** + 独立 verifier | S5 前的 git 提交 |

## 7. 旧版行为审计(逐条:保留/替换/修复/有意放弃)

| 现有行为(证据) | 语义 | 处置 |
|---|---|---|
| 六道闸全有或全无,拒绝零副作用(scripts.py:6-33) | 任一闸不过 return 0,不加计数 | **保留**(契约测试守护) |
| token 预扣:成功按实际 settle、transient 按 est 保守结算、400/SourceDead 全额退款(governance.py:219, 252-254, 262-266) | 差异化结算防少计 | **保留** |
| settle 落回 acquire 时刻窗口(limiter.py:55-62) | 跨窗口退款不污染新窗口 | **保留** |
| Redis 服务器时钟生成窗口 id(limiter.py:95-98) | 多进程口径统一 | **保留**(ARCH §7.3 已承诺) |
| lease TTL 1500s + `timeout_s ≤ TTL` 装配守卫(container.py:99-112) | 防慢请求并发槽被提前回收 | **保留**(库配置化,守卫进 from_env,G6) |
| 单探针半开 + 探针租约 probe_ttl=max(timeout)+5s(scripts.py:96-105, container.py:274-276) | 防惊群 + 探针持有者死亡自动恢复 | **保留**(probe TTL 见 G5) |
| epoch fencing:迟到结果 applied=False(scripts.py:110-143) | 防旧世代污染新状态 | **保留**(ARCH §7.4 已承诺) |
| 取消/本地拒绝时 release_probe 不判健康(governance.py:230-243) | 探针让位但源保持 OPEN | **保留**(G5) |
| mark_progress + 双条件 stall(本地等够 且 全局无进展,双时钟各自比较)(governance.py:270-281) | 防冷启动/多进程误判 | **保留**(ARCH §7.3 已承诺) |
| 源冷却备忘:gate 拒绝后本地记冷却截止,期内不取 permit(governance.py:105-107, 129-131) | 治标:先 permit 后 gate 导致熔断期白烧 RPM | **修复**:库层序熔断在限流外(ARCH §4.3),开路源不再消耗限流;备忘作为选源优化保留(ARCH §7.4 已列) |
| `OcrResultInvalid` 熔断记成功、不换源、消耗任务失败预算(governance.py:237-239) | 坏结果≠坏服务 | **保留**(库 `ResultInvalidError`,ARCH §6.3) |
| RequestRejected 细分:provider 真实响应→gate 记成功;本地拒绝(如坏图格式)→仅释放探针(governance.py:228-236) | HTTP 响应证明服务活着 | **保留**(反向约束 M2) |
| Retry-After 仅支持秒数形态(invokers.py:127-141) | HTTP-date 返回 None | **有意放弃** date 形态(ARCH §6.2 同款) |
| 429 body 细分 insufficient_quota → SourceDead(invokers.py:144-166) | 欠费≠限流 | **保留**(ARCH §6.1 已承诺) |
| 零 content 提前结束 → Transient "early_eof";有 content 缺 [DONE] → 打捞并埋点 "missing_done"(invokers.py:306-313) | 线路级异常定性(D2 的核心价值) | **保留** |
| usage 缺失按 est_tokens 估算并标 `estimated`,不静默用 0(invokers.py:241-254) | 保守计量 | **有意放弃**(2026-07-30 推翻原"保留"判定,est_tokens 解耦设计 §4)。理由:CHS 只记单个 `total_tokens`,不存在 prompt/completion 分配问题;库拆成两列后无法忠实分配,而 `est_tokens` 按 CHS 自身定义(config.py:55)是**最坏情形上界**——"保守"在限流语境安全(押多了只是慢),在计费语境只有虚高一个方向。库改为如实记 `0/0` + `usage_source="unavailable"` + cost NULL(ARCH §5.1)。**"不静默用 0"的原始意图完整保留**:被放弃的只是"编一个数字"这个手段,缺失依然有显式标注且可被 `WHERE usage_source='unavailable' AND cache_hit = false` 量化 |
| reasoning_content 刷新活性但不计入结果;ttft=首个任意 token(invokers.py:55-79, 336-364) | 防 thinking 模型被看门狗误杀 | **替换+增强**:库把 thinking 收进 `LLMResponse.thinking`(不再丢弃);活性语义必须保留(反向约束 M1) |
| enable_thinking=True 不注入参数、False 注入关闭参数(invokers.py:230-238) | 与 D11 注册表"声明注入方式"方向相反 | **替换**(provider 注册表须支持"注入关闭参数"形态) |
| 图片 magic bytes 探测,非 PNG/JPEG 抛 RequestRejected(invokers.py:116-123) | 本地快速拒绝 | **保留**(移入库 transport) |
| 换源重试失败跨源累计,`fails > max_attempts` → retry_exhausted(governance.py:255-260) | 重试预算是全局的不是 per-source | **保留**(M2 设计明确计数口径) |
| 整圈 gate 全拒 → circuit_open 快失败;配额满则 poll+jitter 重探(governance.py:210-214, 283-285) | 区分"全熔断"与"配额满" | **保留**(映射 CircuitOpenError vs wait,G1) |
| MonkeyOCR 两段协议(POST /parse → GET ZIP)+ bbox 有限性/顺序/退化校验(invokers.py:489-552, 427-479) | 数值防御 | **保留**(下沉库,ARCH §7.10 已承诺;首表选取留业务) |
| `success!=true` 拒绝不带 status_code,gate 走"本地拒绝"分支不记成功(invokers.py:514-519 + governance.py:228-236) | 熔断口径 | **有意修复**(M3): 库附 status_code=200,按"响应即健康"记成功——200 响应确证服务活着 |
| OCR 路径共用 `_translate_429`(insufficient_quota→SourceDead、Retry-After 解析) | 429 细分 | **有意放弃**(M3): MonkeyOCR 无鉴权无计费,429 语义不存在,防御性归 Transient |
| 无响应缓存 | 相同图+指令重复付费 | **替换**:迁移后净增缓存(风险见 §8) |
| judge 同步裸 SDK 零治理(judge.py:34-52) | 反面教材 | **修复**(§4) |

## 8. 行为差异与风险

| 差异/风险 | 论述与缓解 |
|---|---|
| **净增响应缓存**(最大行为变化) | 同图+同指令缓存命中对本项目语义:提取/分类是**确定性期望**任务(信度评估恰恰希望同输入同输出),命中可接受且省费;但科研上做"提取可靠性(信度)"实验时,重复采样**必须**绕过缓存——用 cache salt 按实验 epoch 强制重采样(ARCH §7.5)。医疗合规:缓存值含影像衍生文本,Redis 为项目私有实例,与现状(Redis 已存任务/调度数据)风险面一致。配置:`namespace="chsanalyzer:{scope}"`(单租户,无租户维度),TTL 必填;**灰度期建议 VLM scope 先关 CacheMW**,行为与旧版完全一致,稳定后再开 |
| 熔断/限流检查顺序变化 | 旧:先 permit 后 gate(有 RPM 白烧缺陷);新:熔断在限流外。行为上开路期间 RPM 消耗下降,属改善;但整体准入顺序依赖 G3 澄清 |
| thinking 内容保留 | 旧版丢弃 reasoning 文本,新版进 `LLMResponse.thinking` 与遥测——遥测库体积增大,属预期(遥测必录) |
| Lua 契约漂移 | 库重写 Lua 时语义漂移风险 → `contracts_limiter.py` 5 条契约(并发占用型/RPM/TPM 预扣结算退款/release 幂等/progress 新鲜度)先于项目迁移在库侧对真实 Redis 跑通(S1),并随库永久交付 |
| Redis key 迁移 | 新旧前缀不同,切换瞬间限流/熔断状态清零(短暂过放行)→ 选低峰切换;旧 key 有 TTL 自然过期,无需清理 |
| 双栈并跑超限 | 同 scope 新旧并跑限额双计 → 整 scope 原子切换 + 部署脚本禁止混版 worker(§6) |

## 9. 对库的反向约束清单(⚠️ = 架构缺口)

| # | 约束 | 里程碑 | 状态 |
|---|---|---|---|
| R1 | thinking token 刷新看门狗活性、ttft=首个任意 token(invokers.py:55-79) | M1 | ARCH §7.6 未明说,须写进 M1 设计 |
| R2 | SSE 异常细分(malformed_json/early_eof/missing_done)分类+埋点;missing_done 打捞不失败 | M1 | ARCH §6.2 部分覆盖,细目须进 M1 设计 |
| R3 | provider 注册表支持"注入关闭参数"形态(enable_thinking=False) | M1 | D11 方向相反,小修 |
| R4 | 六道闸+契约 5 条、服务器时钟窗口、settle 落 acquire 窗口、transient 按 est 保守结算 | M2 | §7.3 大体覆盖 |
| R5 | RequestRejected 二分(真实响应记成功/本地拒绝释放探针);换源重试跨源计数口径 | M2 | 须进 M2 设计 |
| R6 | OCR ZIP 协议 + bbox 数值防御下沉;OCR Usage=0;glm 白名单预留 | M3 | §7.10 已覆盖 |
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常。**2026-08-06 补(issue #7,库 1.1.0)**: 治理后端故障(`GovernanceBackendError`,Redis 挂等 fail-closed 情形)此前**不在**该族内,`except GatewayUnavailableError` 接不住,会落进 `_TERMINAL` 兜底而消耗业务失败预算;现已归入该族(`reason=governance_backend_down`,`retry_after_s` 默认 5.0),tracking.py 一条 except 即覆盖完整,**无需为它单列分支**。同批新增的 `SourceNotConfiguredError`(源名与配置不匹配的装配缺陷)**有意在族外**,应当落进 `_TERMINAL` 让配置错误浮出水面 | M2 | 已闭 |
| **G2** | ✅ 已闭(2026-07-30 核实): `est_tokens` 已进 ARCH §7.7 SourceConfig 字段清单,且 §7.3 `try_acquire` 的 est 来源已定义为 `SourceConfig.effective_est_tokens()`(显式值优先,否则按 `tpm // 60` 派生)。双职责一并拆开:该字段只剩 TPM 预扣的可选调优覆盖,usage 缺失不再由它兜底(见 §7 行 151 的推翻判定) | M2 | 已闭 |
| **G3** | ⚠️ §4.3 层序图文矛盾:图示 熔断→限流→重试(重试最内),但理由要求"每次重试重新过限流闸"且熔断/限流是 per-source 的、选源在重试循环内(governance.py:120-167 实践为每次尝试执行 选源→冷却备忘→permit→熔断门)。洋葱不澄清"逐次准入"机制则多源语义无法成立 | M2 | **架构缺口**,澄清 §4.3/§4.4 |
| **G4** | ⚠️ per-scope 韧性配置命名(`{SCOPE}__RETRY__*`/`BREAKER__*`/`BACKPRESSURE__*`/`SELECTOR`/`GLOBAL__*`)未进 ARCH §9,现文只有平铺 `LLM_*` 键;CHSAnalyzer 的 VLM/OCR 两 scope 参数各异,平铺键无法表达 | M2 | **架构缺口**,修订 §9 |
| **G5** | ⚠️ 半开探针租约 TTL(探针持有者死亡后 TTL 过期自动可再探,scripts.py:96-105)与 `release_probe` 操作未见于 ARCH §7.4(只写单探针/epoch fencing);缺失则探针死锁 | M2 | **架构缺口**,修订 §7.4 |
| G6 | 装配期不变式守卫:`timeout_s*1000 ≤ lease TTL`(container.py:105-112)、`stall_window ≥ 最慢源 ttft/timeout`(container.py:115-124)须进库 from_env;契约中 settle/release **幂等性**须写进 §7.3 契约文字 | M2 | 缺口(轻),M2 设计补 |
| G7 | judge 默认 provider=anthropic:走实验室 OpenAI 兼容中转即可接入,若须直连 Anthropic 官方 API 则需新 transport(D2 有端口无里程碑)——迁移前与人类确认网关路径 | M3 | 决策项,非缺口 |


## M2.5 追记(2026-07-21,治理韧性设计的行为偏离)

| 偏离 | 对 CHS 迁移的影响 |
|---|---|
| 熔断增设失败率通道(窗口样本 ≥ min_calls 且失败率 ≥ fail_rate 即开路;429 不入两通道) | CHS 纯连续失败语义保留为通道 1;高失败率"半死源"将比 CHS 更早被隔离——P6 实证 CHS 语义对 10% 成功率源永不开路(病灶 1) |
| 开路时长指数递增,`retry_after_s` 上限由 cooldown_s(60s)变为 max_cooldown_s(缺省 300s) | **G1 消费点注意**: tracking.py arq defer 延期时长最多放大 5 倍;属期望行为(反复坏的源就该等更久) |
| 阈值自动抬升由全局并发改为源级并发 | CHS .env 注释约定的本意(防并发误熔)保留;全局并发抬升是 M2 错误移植,已废除 |
| 缺省选源 round_robin → health_aware(EWMA×在途 P2C) | 显式配置 `LLM__SELECTOR=round_robin` 者行为不变;迁移时建议直接吃新缺省 |
| 429 不消耗重试预算(pushback 语义),调用级时间兜底 = 双条件 stall(本地超窗且全局无进展) | 饱和期调用延迟大幅拉长(全局有进展时可超 stall_window);CHS 快失败偏好者可调小 `LLM__BACKPRESSURE__STALL_WINDOW_S` |
| **AIMD 自适应并发**(库常量: 每源初始 8、429 削减 ×0.5、成功 +1/limit、下限 1、ceiling=max(64, 源级 max_concurrency)) | 冷启动每源并发从"无上限"变为 8 起步爬升——高吞吐下游首分钟吞吐低于旧行为;无 env 开关(韧性常量,同 jitter 系数先例),需要禁用者构造函数注入自定义 pacer |
| 连败通道受健康证据抑制(窗口样本 ≥ min_calls 且失败率 < fail_rate 时 5 连败不开路) | CHS "连败必开"在高流量健康源上不再成立(防随机噪声误熔唯一好源);冷启动/低流量语义不变 |
| 结构化重问缺省 1→2 | 解析失败时最多多一跳成本;`PGW_STRUCTURED_MAX_RETRIES=1` 可显式还原 |
