Round 8 forensics caught the healthy source circuit-opened by five random empty completions (~20% ambient failure rate makes a 5-streak land every ~3000 attempts), blacking out the only good source for 60s. When the window holds min_calls samples below the failure-rate threshold, a streak is noise and no longer opens the gate; cold-start and low-traffic semantics are unchanged and sudden death of a warm source is still caught by the rate channel.
19 KiB
M2.5 治理韧性设计: 半死源隔离与健康感知调度
- 日期: 2026-07-21;状态: 已批准(用户 2026-07-21 明示豁免人类门并授权全权实施:"不需要给我过目了…把成功率提升到 98% 以上")
- 验收目标(用户定): P6 压测场景一字不改(五源池含坏 key/黑洞/紧闸/紧看门狗,
.envSOAK 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:
- 连续失败通道(CHS 兼容): 连续失败数 ≥
fail_threshold(配置键不变;废除全局并发×2 自动抬升,config.py:219删除,改按源级并发抬升max(配置, source.max_concurrency×2)——防误熔的本意保留,M2 的错在用了全局并发); - 失败率通道(新): 窗口内样本 ≥
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 OutcomeAwareSelectorProtocol(仅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)。
修正: 携 Retry-After 的 429 是服务端调度指令而非失败(gRPC A6 pushback / SRE 语义): 照常退避(与 Retry-After 取大)、照常喂 AIMD/健康分,但不消耗重试预算;新增调用级时间上限(复用 stall_window_s,缺省 300s)在重试循环顶部兜底——持续饱和超窗即按既有 stalled 语义抛出,防无限循环。对下游的承诺变化: 饱和期调用最长等待 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) |
| 成功清零连续失败计数 | 保留(仅作用于通道 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)" | 同步改为源级并发口径(交付清单项) |