Files
PolyGateway/research-wiki/designs/2026-07-21-m25-resilience-design.md
T

124 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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) |
| 成功清零连续失败计数 | **保留**(仅作用于通道 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)" | 同步改为源级并发口径(交付清单项) |