# M2.5 治理韧性设计: 半死源隔离与健康感知调度

- **日期**: 2026-07-21;**状态**: 已批准(用户 2026-07-21 明示豁免人类门并授权全权实施:"不需要给我过目了…把成功率提升到 98% 以上")
- **验收目标(用户定)**: P6 压测场景**一字不改**(五源池含坏 key/黑洞/紧闸/紧看门狗,`.env` SOAK scope 不动、不删坏源、不调 `LLM_MAX_RETRIES` 等参数放水),最终调用成功率 **≥98%**,业务层零重试;允许多轮迭代。
- **输入**: findings/2026-07-21-p6-soak-baseline.md(P6 首跑 58.1%)+ 六病灶数据核实(本文 §1)+ 业界调研(Envoy/Resilience4j/Finagle/Google SRE/gRPC/LiteLLM,调研报告要点内嵌 §3)。

## 1. 病灶(P6 遥测逐条核实,证据链完整)

| # | 病灶 | 位置 | 定量证据 |
|---|---|---|---|
| 1 | 熔断"连续失败、成功清零"语义对高失败率源失明 | `backends/*/breaker.py` record_success→fails=0 | 源5(成功率 10%)全程未开路,吃掉 14455 次尝试中的 **11070 次(76%)**;对照黑洞源(0%)20 分钟内被压至每 10 分钟 ≤2 次 |
| 2 | 阈值自动抬升 = max(配置, **全局**并发×2) | `config.py:219` | SOAK 全局并发 100 → 阈值 200,熔断进一步失灵(M2 设计错误,本设计废除) |
| 3 | 轮询空洞漏斗: 游标落在被跳过源(开路/闸满)时流量全漏给序列中下一个活源 | `sources.py` RoundRobin + `retry.py:_pick_runnable` | 游标 2/3/4/5 位全落源5,预测 4:1,实测 11070:2892=3.83:1 |
| 4 | 失败源不进冷却(冷却备忘只服务熔断开路) | `retry.py:186` | 同一调用可背靠背重撞同一坏源 |
| 5 | 重试预算 3 次与源池结构无关 | `retry.py:148` | 3318 个调用(41.5%)耗尽预算且尝试链从未含健康源 |
| 6 | 同 key 源互相触发账号级 429 | 配置现实(源1/4/5 同 key) | 对源5 的 1.1 万次冲撞使健康源1 单次成功率被拖至 43%(996 次 429) |

反事实: 8000 调用 = 2373 缓存 + ~5600 真实(75 次/分 ≪ RPM 600),健康源足以承载 → 98%+ 天花板存在。

## 2. 备选方案对比

| 方案 | 内容 | 预估成功率 | 权衡 |
|---|---|---|---|
| A 最小侵入 | 只改熔断: 滑动窗失败率 + 开路时长指数递增 | 90-96% | 改动最小;但轮询空洞仍在——源5 每次恢复期照旧吸走 4/5 流量直到再次攒满样本开路,flap 损耗大,收敛慢且不稳,大概率够不到 98% |
| **B 三件套(推荐)** | A + 本地健康感知选源(P2C×成功率×在途)+ 调用内失败降权 | 98-99.5% | 熔断(慢反馈、硬隔离)与健康选源(快反馈、软降权)双层互补: 选源在熔断攒样本期间就把流量撤走,熔断兜底硬隔离;三改动全部进程本地、零新依赖、零端口签名变更 |
| C 全家桶 | B + 账号级 429 共享退避(LiteLLM 式)+ SRE 10% 重试预算 + AIMD 自适应并发 | ≈B | 生产最强,但三个增项在本场景无增量收益(路由修好后账号 429 风暴自然消失、重试量自然回落);复杂度/Redis 状态面扩大、审查面扩大。**列为后续演进**,本轮不做(YAGNI) |

**选 B。否决 A**: 数据表明选源空洞是独立放大器,单修熔断留下 flap 窗口期漏斗,98% 不稳。**否决 C**: 三增项各自成立但对本验收目标零边际,违反反 gold-plating;设计预留其接入点即可。

## 3. 方案 B 详设

### 3.1 B1 熔断语义升级(双通道 + 开路退避)

蓝本: Resilience4j 滑动窗(failureRate + minimumNumberOfCalls)+ Envoy 驱逐时长指数递增。**声明: 此为对 CHS 连续失败语义的有意偏离**(CHS 1-2 源场景够用,五源池失明,见 §1 病灶 1);连续失败通道保留,CHS 迁移行为向后兼容。

**判据(record_failure 原子执行)**——满足任一即 OPEN:
1. **连续失败通道(CHS 兼容)**: 连续失败数 ≥ `fail_threshold`(配置键不变;**废除全局并发×2 自动抬升**,`config.py:219` 删除,改按**源级**并发抬升 `max(配置, source.max_concurrency×2)`——防误熔的本意保留,M2 的错在用了全局并发);
2. **失败率通道(新)**: 窗口内样本 ≥ `min_calls` 且 失败率 ≥ `fail_rate`。

**429 不入熔断(独立审查 C1/I2 的化解,Envoy outlier detection 同款)**: `rate_limited`(上游 429)既不计入连续失败也不入窗口——限速是背压不是源故障,网关抖动/账号级 429 脉冲不应熔断健康源(P6 基线: 健康源风暴期失败率 57%,与 0.6 阈值仅差 3 点,若计入必误熔);429 仍计入健康 EWMA 与调用内降权(选源层软处理)。timeout/network/5xx 正常入两通道。**连续通道触发的开路不递增 `reopen_streak`**(误熔代价封顶为单次 cooldown_s;只有率通道开路与探针失败重开递增)。补对抗性契约测试: 健康源 429 脉冲不开路、timeout 脉冲开路但 60s 自愈。

**窗口实现**: 固定 30s 桶 × 2(当前桶+上一桶合并读),每桶 `attempts/failures` 计数——服务器钟窗口 id 与限流器同构;Redis 侧并入现有 record_* Lua(gate HASH **固定域轮换**: `win_id/a0/f0/a1/f1`,写前按 win_id 轮换清零,O(1) 无扫描无陈旧桶,独立审查 M1),**不新增 key、不引入环形数组**。memory 侧同构双桶。

**开路时长指数递增(Envoy)**: `cooldown_eff = cooldown_s × 2^(reopen_streak-1)`,封顶 `max_cooldown_s`;`reopen_streak` 存 gate HASH,半开探针成功转 CLOSED 时**不清零**,由"持续 CLOSED 满 `2×cooldown_eff` 后的下一次 record_success"衰减归零(防 flap 抖动重置;`closed_since` 域仅在探针转 CLOSED 时写入,普通成功不刷新)。

**窗口/EWMA 口径(独立审查 I1 定稿)**: ResultInvalid 与"网关健康拒坏请求"(RequestRejected 且 provider_responded)**既不入 attempts 也不入 failures**,且不喂 `record_outcome`(坏结果/坏请求 ≠ 坏服务);真实成功入 attempts(否则率通道退化为纯失败计数)。

**状态机域清单(双后端契约的共同规格,独立审查 I4)**:

| 域 | 写入时机 | 清除/重置时机 |
|---|---|---|
| `state/epoch/open_until/probe_*` | 既有五操作语义**逐字不动**(epoch 仅开断+1) | 不变 |
| `failures`(连续) | record_failure(429 除外)+1,在 fencing matches 分支内 | record_success 置 0(仅此域;**不得顺手清窗口/streak 域**) |
| `win_id/a0/f0/a1/f1` | record_success(真实成功)与 record_failure(429/ResultInvalid 除外),fencing matches 分支内 | 写前 win_id 轮换清零对应桶 |
| `reopen_streak` | 率通道开路/探针失败重开时 +1 | closed_since 满 2×cooldown_eff 后首次 record_success 置 0 |
| `closed_since` | 探针成功转 CLOSED 时写 now | 开路时删除 |

率通道开路时 `failure_count` 沿用既有顶格语义(置 threshold,迁移文档标注)。

**半开/探针/epoch fencing 全部不动**(单探针租约 + epoch 仅开断+1 + release_probe 置 open_until=now,M2 已验收)。SourceDead force_open、探针租约惰性重发不动。

**新配置键(全部可选,库缺省;沿用 `{SCOPE}__BREAKER__*` 族)**:

| 键 | 缺省 | 说明 |
|---|---|---|
| `BREAKER__MIN_CALLS` | 10 | 失败率通道最小样本(防低流量误判,Resilience4j 语义) |
| `BREAKER__FAIL_RATE` | 0.6 | 窗口失败率阈值(比业界 50% 宽,容忍 LLM 网关抖动) |
| `BREAKER__WINDOW_S` | 60 | 失败率窗口(双 30s 桶) |
| `BREAKER__MAX_COOLDOWN_S` | max(300, cooldown) | 开路退避封顶 |

缺省合法性: 韧性参数缺省先例已有(probe_ttl 派生、jitter 系数);关键配置(阈值/冷却)仍必填不变。

### 3.2 B2 健康感知选源(进程本地,新默认)

蓝本: Envoy least-request 权重公式 + gRPC WRR"权重×成功率"+ Envoy slow-start。健康记忆**每进程本地**(业界共识: Envoy/Finagle/gRPC 全本地;共享仅在单进程流量极低时划算——本库多 worker 各自流量充足)。

新增 `HealthAwareSelector`(实现既有 `SourceSelector` 端口,`order()` 签名不变):
- **健康分**: `score = max(ewma_success, 0.05) × 1 / (1 + inflight)`。`ewma_success` 为源级成功率 EWMA(α=0.2,初始 1.0 乐观起步);`inflight` 来自现有 `stats`。**0.05 地板 = 探索项**(独立审查 I5 定稿): 被打压源保有微量被选概率,恢复后靠真实成功让 EWMA 自然爬升(α=0.2 → ~10 次成功回到 0.9)——**EWMA 爬升本身就是 slow-start**,不再做独立的爬升机制,也消除"选源器感知不到冷却过期"的接线缺口;真坏源的探索流量在 try_enter 处被开路熔断拦下(零真实成本)。
- **排序**: P2C——随机取两源比 score,高者列首,其余按 score 降序补全(`order()` 返回全序列,`_pick_runnable` 逐个试准入的既有逻辑不变,被跳过时自然落到次优)。已知取舍(独立审查 M5): 仅头名随机化,尾序确定,多 worker 溢出会集中到同一次优源——SOAK 规模可接受,记录备查。
- **喂数**: `ports.py` 新增可选 `@runtime_checkable OutcomeAwareSelector` Protocol(仅 `record_outcome(source_name: str, ok: bool) -> None`);RetryMW 构造时 isinstance 判定一次并缓存(独立审查 I3,显式于 getattr 私约)。`_attempt` 真实成功/Transient/SourceDead/429 处喂数;ResultInvalid/健康拒绝不喂(§3.1 口径)。round_robin/least_inflight 两实现不动。
- `LLM__SELECTOR` 新增值 `health_aware` 并设为**新缺省**(生产级默认;显式配 round_robin/least_inflight 者行为不变——迁移文档标注;`config.py` `_SELECTORS` 与 `.env.example` 注释同步更新)。

### 3.3 B3 调用内失败降权(替代硬排除)

蓝本: Envoy previous_hosts 语义,但按 §1 病灶分析**改软降权**——硬排除在异构池会把"健康源偶发失败"的重试强推给已知坏源(计算: 硬排除下 1→5→1 链成功率 98.7%,软降权下 1→1→1 链 99.8%)。

实现(选源器无关,不触碰端口签名): `attempt_fails: dict[str, int]` 为 `__call__` **局部变量**逐层传参给 `_pick_runnable`(RetryMW 实例被并发调用共享,严禁实例属性,独立审查 M2);`_pick_runnable` 拿到 `order()` 结果后,把**本调用内已失败 ≥2 次**的源移到序列尾部(存在其他候选时)。效果: 健康源失败 1 次仍是首选(429/空补全属瞬态,原地退避重试最优);失败 2 次让位给次优源试一次;全部候选都失败过则按原序回退——自适应而非教条,调用结束状态即弃。病灶 5(预算与池结构无关)由此**间接闭环**: 预算数值不动,但尝试链质量由健康排序+降权保证(推演: 稳态下 3 次尝试大概率全落健康源,1−0.12³ ≈ 99.8%)。

### 3.35 迭代 1 补遗: AIMD 自适应并发(2026-07-21,P6 第二轮数据驱动)

**动因**: 三件套修复后 P6 第二轮实测——路由完全收敛(源5 尝试 11070→23),但 64 并发全压健康源后**网关账号级 429 率从 34% 恶化到 62%**,18 分钟即积累 180+ 终态失败(预算上限 160),纯退避重试救不动持续性背压。这正是 §3.4 预留的 AIMD 场景,数据证明它是达成 98% 的必要件,提前启用(用户已授权多轮迭代)。

**机制**(Netflix concurrency-limits 损失型客户端建议;进程本地,同健康记忆): 每源浮点并发上限 `limit`,初始 8、下限 1、上限 = worker 并发;**429 即乘性削减 ×0.7,真实成功即加性增长 +1/limit**。RetryMW 本地记每源在途数(选中 +1,尝试收尾 -1);`_pick_runnable` 对 `在途 ≥ limit` 的源跳过(reason=`adaptive_paced`,**不计 gate_rejections**——全被 paced 时走既有 quota-wait 轮询等待,绝不误判 CircuitOpen,也不消耗重试预算)。效果: 多余调用排队而非失败,并发自动收敛到网关可持续水位;429 同时仍喂健康 EWMA(排序信号)但不入熔断(§3.1 不变)。

**测试**: 单测削减/增长/上下限;RetryMW 集成——429 后准入收紧、被 paced 源跳过不耗预算、全 paced 走轮询非 CircuitOpen、在途数在异常/取消路径归零。

### 3.36 迭代 2 补遗: 调用内降权加健康门槛(2026-07-21,P6 第三轮数据驱动)

**动因**: AIMD 后 429 归零,但第三轮 750 次完成仍 21 败(2.8%)。根因: 无条件"失败 ≥2 让位"在**异构池**把第三次尝试推给已知坏源——健康源两次空补全(网关 17% 空补全率)后被降权,替补是 10% 成功率的看门狗源;且该源的"成功"是被 0.5s 看门狗筛出的短促退化响应(均值 128 token vs 健康 262),喂给结构化解析再炸一层。期望值算术: 第三次留在健康源成功率 ~83%,推给坏源 ~10%。

**修正**: `OutcomeAwareSelector` 协议增 `health(source_name) -> float`(EWMA 裸值);RetryMW 降权仅在存在**可信替代**(某未失败候选 health ≥ 0.5 × 失败源 health)时生效,否则原地第三试。无健康视图的选源器(round_robin 等)保持无条件降权(冷启动保护原语义)。

### 3.37 迭代 4 补遗: 结构化重问缺省 2 + AIMD 削减 0.5(2026-07-21,第五轮数据驱动)

**动因**: 第五轮(正午高峰,外部条件比首跑更严苛)剩余失败三分: 阶梯耗尽 ~1.1%(结构化档死亡率 3.2%,重问仅 1 次)、retry_exhausted ~0.9%(源1 尝试失败率 20.5% = 空补全 14.4% + **429 残漏 5.8%**,后者说明 AIMD 0.7 削减在临界点上方震荡)、400 拒绝 ~0.5%(语料固有毒负载,环境常量)。
**修正**: ① `PGW_STRUCTURED_MAX_RETRIES` 库缺省 1→2(instructor 库缺省 3 的保守版;成本只在解析失败时新增一跳);② AIMD `_CUT` 0.7→0.5(更快收敛到网关水位之下,Netflix 建议区间 0.5-0.9 内取激进端)。预期: 阶梯死亡 ~3.2%→~0.6%、429 出链后 retry_exhausted ~0.9%→~0.3%,合计残余 ≈ 1.0-1.4%(含 0.5% 环境常量)。

### 3.38 迭代 5 补遗: 429 免重试预算(gRPC pushback 语义;2026-07-21 第六轮数据驱动)

**动因**: 第六轮实测网关限速是**按账号速率**而非并发——外部负载高峰时我方仅 26 req/min 仍吃 16% 429,AIMD(并发型)压不住速率型限流;429 每次消耗 1/3 重试预算,饱和窗口里调用被"429 链"干净杀死(16 retry_exhausted/336)。
**修正**: 一切 429 均视为**服务端调度指令而非失败**(gRPC A6 pushback / SRE 语义;无 Retry-After 头时用本地指数退避代行): 照常退避(与 Retry-After 取大)、照常喂 AIMD/健康分,但**不消耗重试预算**;新增**调用级时间兜底**在重试循环顶部——与 quota-wait 同款**双条件**(本地超 `stall_window_s` **且**全局无进展)按既有 `stalled` 语义抛出,防无限循环(全局持续有进展时单调用可等待超过 stall_window,非严格上限)。对下游的承诺变化: 饱和期调用最长等待 stall_window 而非快速失败(生产语义,迁移文档标注)。

### 3.39 迭代 6 补遗: 健康证据抑制连续通道(2026-07-21,第八轮取证驱动)

**动因**: 第八轮失败链取证发现健康源1 自己进 cooldown——空补全率 ~20% 下 5 连败每 ~3000 次尝试随机发生一次,连续通道(阈值 5)把唯一好源关 60s,期间调用全灭(链样本: `{'minimax_1': 'cooldown', 其余: 'cooldown/rate_limited'}`)。
**修正**(Resilience4j 精神: 率证据优于连败直觉): 窗口样本 ≥ min_calls 且失败率 < fail_rate 时**抑制连续通道开路**——有充分健康证据的源上连败是统计噪声。连续通道本职(冷启动/低流量快杀死源)保留(窗口样本不足时照常开路);温热源猝死由率通道在失败累积后接管(检出延迟从 threshold 次升至率窗口收敛,契约测试⑩钉住);SourceDead force_open 不受影响。

### 3.4 不做与预留(方案 C 组件的接入点)

- 账号级 429 共享退避: 不做;预留 = 冷却备忘 key 从 source_name 换 account_key 即可接入(LiteLLM 先例,治理粒度=配额粒度原则记入 ARCHITECTURE)。
- SRE 10% 重试预算 / AIMD 自适应并发 / hedged requests / TTFB 慢调用计熔断: 不做,理由见 §2 方案 C;hedged 对 LLM 有 token 计费与 429 放大问题,默认永不启用。
- 熔断配额(max_ejection_percent): 不做——全部源真实坏死时 fail-fast 抛 `CircuitOpenError`(带 retry_after)正是库对下游的既有承诺,放行反而违反"熔断不可用报错"铁律的精神。

## 4. 非功能维度(brainstorming 清单)

- **并发与取消**: 全部新状态(EWMA/调用内降权/双桶计数)为 asyncio 单循环内存操作或 Lua 原子操作,无锁需求;新代码不引入 await 点于取消敏感路径,`CancelledError` 穿透路径不变。
- **降级方向**: 熔断后端不可用 → 准入侧 fail-closed 报错、记账侧 `_record_quietly` 降级——**全部不变**;健康记忆纯本地无后端,不存在降级问题;`record_outcome` 任何异常吞并 warning(选源优化失效退化为轮询序,不影响正确性)。
- **幂等与重复**: record_failure 双桶 HINCRBY 天然可重入;epoch fencing 防跨代写回不变;reopen_streak 衰减判据基于时刻比较,重复执行安全。
- **持久化与原子性**: Redis 侧窗口计数并入既有 gate HASH 与既有 Lua 脚本(单脚本原子);HASH 随既有 TTL 过期,无新清理负担。
- **纯 asyncio 中立**: 选源器健康态属 client 实例(构造注入),无全局/模块级状态;同一 GatewayClient 在 arq 与裸脚本行为一致。

## 5. 错误处理与测试策略

- 失败分类不变(四分类驱动);新逻辑只消费分类结果。
- **契约测试(双后端)新增**(全部注入**确定性失败序列**,不做概率断言,独立审查 M3): ① 高失败率序列在 min_calls 样本处开路(病灶 1 回归);② 样本 < min_calls 永不率开路;③ 连续失败通道阈值=配置值且按源级并发抬升(全局抬升废除回归);④ 开路退避 2^n 递增且封顶、CLOSED 稳定期后衰减(redis 真实等待变体用小 `MAX_COOLDOWN_S` 配置防 slow 套件超时);⑤ ResultInvalid/健康拒绝不入窗口(attempts/failures 均不入);⑥ 429 脉冲不开路、timeout 脉冲开路且不增 streak(C1 对抗测试);⑦ 率通道开路 failure_count 顶格。
- **选源单测**: EWMA 收敛、P2C 分布(注入 rng)、slow-start 爬升、`record_outcome` 缺失方法兼容、调用内降权重排。
- **RetryMW 单测**: 健康源单失败仍首选、双失败让位、reasons 语义不变。
- **端到端回归(验收门)**: 原封不动 P6 场景重跑(相同 .env、相同预算 8000、相同并发),成功率 ≥98%;八不变量全 PASS;对照首跑基线出对比报告。不达标按数据迭代(用户已授权多轮)。

## 6. 旧版行为审计(重写熔断判据部分)

| 旧行为 | 处置 |
|---|---|
| 连续失败 ≥ 阈值开路 | **保留为通道 1,但受 §3.39 健康证据抑制**(窗口样本充足且失败率低时连败不开路) |
| 成功清零连续失败计数 | **保留**(仅作用于通道 1 计数;窗口计数不清) |
| 阈值自动抬升 max(配置, 并发×2) | **有意废除**(病灶 2;M2 设计错误,迁移文档更新) |
| SourceDead force_open / 探针租约 / epoch fencing / release_probe / 半开单探针 | **保留**(逐字不动) |
| ResultInvalid 记成功 | **保留**(且不入窗口失败) |
| 开路冷却固定 cooldown_s | **替换**为指数递增(初值=cooldown_s,兼容: 首次开路时长不变) |
| RoundRobin 为缺省选源 | **替换**缺省为 health_aware;显式配置者不变 |
| retry_after_s 上限 = cooldown_s | **变更**为 max_cooldown_s(缺省 300s)——CHS arq defer 延期时长随之最多放大 5 倍,迁移文档(chsanalyzer G1)标注 |
| .env.example 注释"阈值自动取 max(此值,并发×2)" | 同步改为源级并发口径(交付清单项) |
