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

19 KiB
Raw Permalink Blame History

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 来自现有 stats0.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)" 同步改为源级并发口径(交付清单项)