返回 AICAP-180
B9 · Day 83部署 + 韧性 + 可观测 + 独立红队

韧性模式 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 全可注入):

  1. 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 就能断言确定的退避序列。

  2. retry(fn, opts)(第 32 行):最多 retries(默认 3)次重试。每次 catch 后,若 attempt === retries || !shouldRetry(e) 就 break,否则 await sleep(backoffDelay(attempt, opts))shouldRetry 默认 () => true,可注入成「只对 429 重试」;sleep 可注入 → 测试无需真 timer。

  3. CircuitBreaker(第 52 行):三态机。getState()opennow() - openedAt >= cooldownMs(默认5000) 时自动转 half-openexec(fn):若 open 直接 throw 'circuit open'(快速失败);成功则 failures=0 且回 closed;失败则 failures++,达 threshold(默认5) 就转 open 并记 openedAtnow 可注入 → 不用真时钟就能测冷却转换。

  4. withFallback(primary, fallback)(第 88 行):try primary,catch 后跑 fallback,返回 { value, usedFallback }——usedFallback 显式标出这次是否降级了(用于记录降级语义,Day 84 会用到)。注释里写的降级例子是 DeepSeek-V3 -> Qwen3

  5. 文件头注释明确:「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

attemptbase·factor^attemptmin(…, max)
0100100ms
1200200ms
2400400ms
3800800ms
416001.6s
532003.2s
664006.4s
71280010s(封顶)

可见第 7 次起被 max=10s 钳住,不再翻倍——这就是「封顶」的作用。叠上 jitter=0.2 后,每个值会在 ±20% 内随机抖动(如 100ms 落在 80~120ms),把多客户端的重试时刻打散。代码里 delta = raw·jitter·(rng()·2−1):当注入 rng=()=>0.5rng()·2−1=0,delta=0,退避序列正好回到上表的确定值——这正是单测能精确断言序列的原理。

3. 今日实战

按 seed,今天的实战是写并跑单测验证退避序列:

  1. 单测里给 backoffDelay/retry 注入固定 rng(如恒返回 0.5),让退避序列变确定可断言。
  2. 模拟下游连续 429:shouldRetry 只对 429 返回 true,断言 5 次 429 触发的退避序列符合 min(100·2^attempt, 10000) 叠固定 jitter 后的预期值。
  3. CircuitBreaker 注入固定 now,断言「连续失败到 threshold → open」「cooldown 后 → half-open」的状态转换。
  4. withFallback 注入 primary 抛错,断言走 fallback 且 usedFallback === true
  5. 全程不需要真 key、不需要真 timer(sleep/now/rng 都注入)。

4. 今日实测 / 产出

  • src/agent/runtime/resilience.ts已建且测试通过(✅ tested)——backoffDelay/retry/CircuitBreaker/withFallback 纯函数确定性绿。
  • 退避序列断言(5 次 429):已覆盖

这是 B9 这批里难得「已完成」而非「待跑/待云」的一天——因为逻辑纯、依赖全可注入。

5. 常见误区 / 陷阱

  • 无 jitter 的纯指数退避:批量客户端同步重试 → 惊群 → 自制 DDoS。jitter 不是可选项。
  • 无上限重试:不封顶的重试次数 + 不封顶的退避,会把瞬时故障放大成级联雪崩。retriesmax 都要有界。
  • 熔断阈值设太松/太紧:太松(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 组合成主→备路由做故障注入)。