韧性模式 backoff+jitter / circuit breaker / fallback
B9 前两天解决了「在哪跑」(Day 81 Cloud Run)和「看见它在干什么」(Day 82 OTel trace)。但一个上线服务还缺最关键的一环:它依赖的下游会失败——模型 API 会 429(限流)、会超时、会 5xx。今天补韧性模式:失败了怎么重试、下游半死时怎么自保、主模型挂了怎么降级。
阶段: B9 · 部署 + 韧性 + 可观测 + 独立红队(Day 81-90) 标签: #resilience #backoff-jitter #circuit-breaker #fallback
今日导引(由浅入深)
B9 前两天解决了「在哪跑」(Day 81 Cloud Run)和「看见它在干什么」(Day 82 OTel trace)。但一个上线服务还缺最关键的一环:它依赖的下游会失败——模型 API 会 429(限流)、会超时、会 5xx。今天补韧性模式:失败了怎么重试、下游半死时怎么自保、主模型挂了怎么降级。
这是 B9 里少数纯代码、纯确定性、可直接测绿的一天(不像 Day 81/82 那样卡在「待云」)。原因是:退避/熔断/降级的逻辑全是纯函数,时钟/sleep/rng 都可注入,所以无需真 key、无需真 timer 就能单测验证。今天把机理吃透 + 代码走读 + 确认测试绿;明天(Day 84)再把熔断 + fallback 组合成主→备路由做故障注入实战。
最小可判定产出:src/agent/runtime/resilience.ts 已建且测试通过——backoffDelay/retry/CircuitBreaker/withFallback 纯函数确定性绿,5 次 429 的退避序列断言已覆盖。
1. 机理精读
① 指数退避 + jitter。 重试不能立刻重试、也不能等距重试,否则会把下游打得更惨。公式:
delay = min(base · factor^attempt, max) # 指数增长,封顶
delay = delay ± (delay · jitter · random) # 加随机扰动
- 指数(
factor^attempt):每次失败等待翻倍,给下游恢复时间。 - 封顶(
min(…, max)):避免无限增长到几分钟后才重试。 - jitter(随机扰动):这是最反直觉但最关键的一步。若一批客户端同时被 429、又用同样的纯指数退避,它们会同步地在同一时刻一起重试——这就是惊群(thundering herd),等于自制 DDoS。加 ±fraction 随机扰动把重试时刻打散,削平这个尖峰。资源:AWS Builders' Library — Timeouts, retries and backoff with jitter。
② 熔断器(circuit breaker)三态。 退避解决「单次调用失败重试」,但若下游持续挂掉,每个请求都重试只会雪上加霜。熔断器是更高一层的保护:
- closed(正常):放行所有请求,正常计失败数。
- 连续失败超阈值 → open(熔断):直接拒绝请求、不再打下游,给它喘息空间。这是「快速失败」而非「慢慢拖死」。
- cooldown 后 → half-open(半开):放少量探测请求试水。成功 → 回 closed;失败 → 重新 open。
熔断的本质是用「主动拒绝一部分请求」换「保护下游不被彻底打垮 + 自己不被拖死」。
③ fallback(降级路由)。 主模型不可用时,降级到备用模型继续服务。它牺牲的是质量/一致性,换来的是可用性。fallback 与重试/熔断正交:重试是「同一个下游再试」、fallback 是「换一个下游」。三者组合成完整的韧性栈。
与「超时(timeout)」的边界。 超时是这套模式的前置——没有超时,一个 hang 住的调用会永远占着不触发任何重试/熔断逻辑。超时定义「多久算失败」,退避/熔断/降级定义「失败后怎么办」。
三层韧性的责任分工(一图记牢)。
| 层 | 触发粒度 | 解决的问题 | 代价 |
|---|---|---|---|
| 超时 | 单次调用 | hang 住的调用永不返回 | 可能误杀慢但会成功的调用 |
| 退避+jitter | 单次调用失败后 | 瞬时故障 + 惊群 | 增加延迟 |
| 熔断器 | 一段时间的失败累积 | 下游持续故障被打垮 | 主动拒绝一部分请求 |
| fallback | 主下游不可用 | 服务中断 | 质量/一致性损耗 |
四层从「单次」到「持续」到「换备」,逐级兜底。今天 resilience.ts 实现了后三层,超时通常由 HTTP client/AbortController 在更外层管。
2. 代码走读
src/agent/runtime/resilience.ts(已建并测试,纯/确定性,clock/sleep/rng 全可注入):
-
backoffDelay(attempt, opts)(第 14 行):返回某次 0-based attempt 的延迟。默认base=100ms / factor=2 / max=10s / jitter=0.2。实现严格对应公式:raw = min(base·factor^attempt, max),再叠delta = raw·jitter·(rng()·2−1)(把[0,1)的 rng 映射到[−1,1)实现 ±jitter),最后max(0, round(...))保证非负。rng可注入 → 测试里塞固定 rng 就能断言确定的退避序列。 -
retry(fn, opts)(第 32 行):最多retries(默认 3)次重试。每次 catch 后,若attempt === retries || !shouldRetry(e)就 break,否则await sleep(backoffDelay(attempt, opts))。shouldRetry默认() => true,可注入成「只对 429 重试」;sleep可注入 → 测试无需真 timer。 -
CircuitBreaker(第 52 行):三态机。getState()在open且now() - openedAt >= cooldownMs(默认5000)时自动转half-open。exec(fn):若open直接throw 'circuit open'(快速失败);成功则failures=0且回closed;失败则failures++,达threshold(默认5)就转open并记openedAt。now可注入 → 不用真时钟就能测冷却转换。 -
withFallback(primary, fallback)(第 88 行):tryprimary,catch 后跑fallback,返回{ value, usedFallback }——usedFallback显式标出这次是否降级了(用于记录降级语义,Day 84 会用到)。注释里写的降级例子是DeepSeek-V3 -> Qwen3。 -
文件头注释明确:「Pure + deterministic — clock/sleep/rng are injectable, so it's unit-tested with NO real timers and NO API key.」——这就是它今天能直接测绿、不卡「待云」的根本原因。
手算退避序列(用本仓默认参数,jitter=0 便于看清骨架)。 base=100ms, factor=2, max=10s:
| attempt | base·factor^attempt | min(…, max) |
|---|---|---|
| 0 | 100 | 100ms |
| 1 | 200 | 200ms |
| 2 | 400 | 400ms |
| 3 | 800 | 800ms |
| 4 | 1600 | 1.6s |
| 5 | 3200 | 3.2s |
| 6 | 6400 | 6.4s |
| 7 | 12800 | 10s(封顶) |
可见第 7 次起被 max=10s 钳住,不再翻倍——这就是「封顶」的作用。叠上 jitter=0.2 后,每个值会在 ±20% 内随机抖动(如 100ms 落在 80~120ms),把多客户端的重试时刻打散。代码里 delta = raw·jitter·(rng()·2−1):当注入 rng=()=>0.5 时 rng()·2−1=0,delta=0,退避序列正好回到上表的确定值——这正是单测能精确断言序列的原理。
3. 今日实战
按 seed,今天的实战是写并跑单测验证退避序列:
- 单测里给
backoffDelay/retry注入固定 rng(如恒返回 0.5),让退避序列变确定可断言。 - 模拟下游连续 429:
shouldRetry只对 429 返回 true,断言 5 次 429 触发的退避序列符合min(100·2^attempt, 10000)叠固定 jitter 后的预期值。 CircuitBreaker注入固定now,断言「连续失败到 threshold → open」「cooldown 后 → half-open」的状态转换。withFallback注入 primary 抛错,断言走 fallback 且usedFallback === true。- 全程不需要真 key、不需要真 timer(sleep/now/rng 都注入)。
4. 今日实测 / 产出
src/agent/runtime/resilience.ts:已建且测试通过(✅ tested)——backoffDelay/retry/CircuitBreaker/withFallback纯函数确定性绿。- 退避序列断言(5 次 429):已覆盖。
这是 B9 这批里难得「已完成」而非「待跑/待云」的一天——因为逻辑纯、依赖全可注入。
5. 常见误区 / 陷阱
- 无 jitter 的纯指数退避:批量客户端同步重试 → 惊群 → 自制 DDoS。jitter 不是可选项。
- 无上限重试:不封顶的重试次数 + 不封顶的退避,会把瞬时故障放大成级联雪崩。
retries和max都要有界。 - 熔断阈值设太松/太紧:太松(threshold 很大)→ 下游已半死还在猛打;太紧 → 偶发抖动就熔断、可用性反降。要随真实失败率调。
- 把超时和重试混为一谈:没有超时的重试是空的——hang 住的调用永远不会进入重试分支。
6. 学习资源(每条带 YYYY-MM)
- AWS Builders' Library — Timeouts, retries and backoff with jitter(退避/jitter 权威来源,seed 引用),常青文档,2025 复访。
- AWS Builders' Library — Avoiding fallback in distributed systems(fallback 的代价与陷阱),2025 复访。
- Release It!(Nygard)— Circuit Breaker / Bulkhead 模式(熔断器经典出处),2018 第二版,机理常青。
- Google SRE Book — Handling Overload / Addressing Cascading Failures,2025 复访。
SOTA检查 (2026-06 更新)
- 当前主流:backoff+jitter + 熔断 + fallback 是经典稳健模式,无过时风险——这是分布式系统的物理常数级知识,AWS Builders' Library 仍权威,仍是 SOTA。
- 过时黑名单:无 jitter 的纯指数退避(惊群);无上限重试(级联放大)。
- 下次复查点:把
resilience.ts接到真 API 调用层时(含真 429/超时),复查 DeepSeek/Qwen 限流响应头是否暴露Retry-After,可据此替代盲目退避。
衔接
- 昨天:Day 82 — OTel GenAI 语义约定 + Langfuse/Phoenix(给调用打可观测 span)。
- 今天:写
resilience.ts——backoff+jitter / 熔断 / fallback,纯确定性测绿(B9 里少见的「已完成」日)。 - 明天:Day 84 — 熔断 + fallback 路由实战(把
CircuitBreaker+withFallback组合成主→备路由做故障注入)。