Files
PolyGateway/research-wiki/designs/2026-07-21-m25-resilience-design.md
iomgaa a06761917e fix: address M2.5 verifier findings before merge
AIMD ceiling now respects per-source max_concurrency and the pacer is
assembled explicitly in the client; MIN_CALLS parses as strict int;
acceptance doc corrects source-5 attempt count to 549; design and
migration notes aligned with implemented 429/stall/suppression
semantics and AIMD constants documented.
2026-07-21 21:10:02 -04:00

153 lines
19 KiB
Markdown
Raw Permalink 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.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)" | 同步改为源级并发口径(交付清单项) |