124 lines
14 KiB
Markdown
124 lines
14 KiB
Markdown
# 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)" | 同步改为源级并发口径(交付清单项) |
|