熔断 + fallback 路由实战
昨天(Day 83)把韧性三件套(backoff+jitter / 熔断 / fallback)作为单独原语写进了 resilience.ts 并测绿。今天把其中两件——CircuitBreaker 和 withFallback——组合成一条主→备路由,并用故障注入验证它们在一条真实链路上协同工作。
阶段: B9 · 部署 + 韧性 + 可观测 + 独立红队(Day 81-90) 标签: #circuit-breaker #fallback-routing #half-open #fault-injection
今日导引(由浅入深)
昨天(Day 83)把韧性三件套(backoff+jitter / 熔断 / fallback)作为单独原语写进了 resilience.ts 并测绿。今天把其中两件——CircuitBreaker 和 withFallback——组合成一条主→备路由,并用故障注入验证它们在一条真实链路上协同工作。
这是「原语 → 组合」的进阶:单独的熔断器会拒绝请求,但拒绝之后呢?真实系统需要的是「熔断保护下游 + fallback 保证服务不中断」的组合拳。今天还要补上一个昨天没展开的关键细节——half-open 探测窗口,以及降级到底损耗多少质量(用既有 A/B 数字量化)。
在能力曲线上,今天是 B9「韧性」支线的收口:Day 83 把原语写出来,Day 84 把它们装成一条能扛真实故障的路由,并把「降级的代价」量化成可记录的数字。这一步之后,韧性不再是「测试里能过」的孤立函数,而是「主模型挂了用户仍有服务、且系统知道自己在用弱模型」的完整链路。
最小可判定产出:复用 CircuitBreaker + withFallback 组合成主→备路由,故障注入断言「熔断触发 1 次 + fallback 触发 1 次」(确定性单测,已覆盖)。质量损耗引用既有 A/B:Pro 89.7% / Flash 79.3%(judge=pass)。
1. 机理精读
half-open 探测窗口是熔断恢复的关键。 昨天讲了三态(closed→open→half-open),今天放大 half-open 这个最微妙的状态。熔断 open 后,下游可能已经恢复、也可能还半死。如果 cooldown 一到就直接全量放行回 closed,万一下游还没好,会瞬间被全量流量再次打垮、再次 open——抖动(flapping)。half-open 的设计正是为防这个:
- cooldown 后只转 half-open,只放少量探测请求试水。
- 探测成功 → 确认下游真恢复了 → close。
- 探测失败 → 下游还没好 → 重新 open,再等一个 cooldown。
所以 half-open 是「用少量探测流量小心试探,而不是用全量流量赌一把」。资源:AWS Builders' Library — circuit breaker 章节。
熔断器三种「失败认定」的口径差异。 open 的触发条件可以是不同口径,本仓 CircuitBreaker 用的是最简单的连续失败计数(failures 达 threshold 即 open,一旦成功就清零)。生产级实现还有两种更稳的口径:
- 滑动窗口失败率:统计最近 N 次调用里失败占比超过 X% 才 open——比「连续计数」更抗偶发抖动(一次成功不会让计数清零到从头再来)。
- 慢调用比例:把「虽然成功但耗时超阈值」也计入失败——因为持续变慢往往是下游崩溃的前兆。
本仓选连续计数是因为它纯、易测、足够演示机理;真上量时换成滑动窗口口径更稳。这条边界要讲清,避免把「教学实现」误当「生产最优」。
fallback 的质量损耗必须被量化和记录。 降级不是免费的——它换来可用性,但牺牲质量。本仓的具体降级语义是:主 deepseek-v4-pro → 备 deepseek-v4-flash(或 Qwen3)。质量差距用既有 A/B 实测量化:
| 模型 | judge=pass 完成率 |
|---|---|
| deepseek-v4-pro(主) | 89.7% |
| deepseek-v4-flash(备) | 79.3% |
也就是说,降级换来可用性,但掉约 10pp 完成率(89.7 − 79.3 ≈ 10.4pp,承接前面 B 批次的 A/B:Δ+10.3pp,95% CI[0,20.7],N=29 时不显著)。这条降级语义必须写进日志/trace——否则你会在不知情的情况下用一个明显更弱的模型在跑生产,且无人察觉。
「记录降级语义」的工程含义。 withFallback 返回的 usedFallback 标志就是为此而生:每次降级都要能在 trace 里被看见(呼应 Day 82 的 OTel span),这样运维才知道「这段时间的低质量是降级导致的,不是模型退化」。可用性与质量是一对 trade-off,可观测性让这个 trade-off 变得可审计。
为什么「掉约 10pp」不能被当成「不显著就忽略」。 前面 B3 的 A/B 给出 Δ+10.3pp、95% CI[0,20.7]、N=29 时不显著——CI 下界压到 0 意味着「在 29 个 task 上还不能断言 Pro 一定强于 Flash」。但这不等于降级没代价:点估计 +10.3pp 是当前最佳猜测,CI 宽只是样本太小(N=29)导致不确定性大。工程决策上,fallback 时仍应假设「降级会掉约 10pp 完成率」并记录,而不是因为「统计不显著」就当无损替换——这两件事是不同的判断层级(统计显著性 vs 工程保守假设)。扩到 ~70 task 才能把这个 CI 收窄到可下结论。
熔断与 fallback 的协同顺序很关键。 正确的嵌套是「fallback 包在熔断之外」:withFallback(primary = breaker.exec(callPro), fallback = callFlash)。这样当 Pro 连续失败、熔断器 open 后,breaker.exec 会立即 throw(不再傻等 Pro 超时),withFallback 立刻接住走 Flash——降级是「快」的。若反过来把熔断包在 fallback 外,逻辑就拧了:熔断器会把「主备整体」当一个下游统计失败,失去「只熔断主、保留备」的语义。
2. 代码走读
复用 src/agent/runtime/resilience.ts 的两个符号(已建并测):
-
CircuitBreaker.exec(fn)(第 69 行):open态直接throw new Error('circuit open')——这是「快速失败」,不打下游。连续失败达threshold(默认5)转open并记openedAt。getState()(第 62 行)在 cooldown(默认 5000ms)过后把open转half-open。注入now即可在单测里精确控制冷却边界,无需真时钟。 -
withFallback(primary, fallback)(第 88 行):try { primary() },catch 后跑fallback(),返回{ value, usedFallback }。组合时,primary包成「经熔断器的主模型调用」,fallback是备模型调用——于是「主路由被熔断器拒绝(throw circuit open)」会被withFallback接住、自动走备模型。 -
组合形态:把
() => breaker.exec(callPro)当作withFallback的primary,() => callFlash()当作fallback。当callPro连续失败到熔断器 open,下一次breaker.exec直接 throw →withFallbackcatch → 走callFlash并usedFallback=true。两个原语的接口(都基于 Promise reject/resolve)天然可组合,这正是 Day 83 把它们写成纯小函数的回报。 -
故障注入的可测性来自昨天:
now注入控时钟、primary 注入抛错——所以「熔断触发 1 次 + fallback 触发 1 次」是一条确定性断言,不需要真 key、不需要真 timer。
走一遍状态机(threshold=2, cooldown=5000ms,注入 now):
| 步 | 动作 | breaker.state | usedFallback | 说明 |
|---|---|---|---|---|
| 1 | callPro 失败 | closed(failures=1) | — | 未达阈值,正常 throw |
| 2 | callPro 失败 | open(failures=2≥threshold,记 openedAt) | — | 熔断触发(第 1 次) |
| 3 | 再发主调用 | open → exec 直接 throw 'circuit open' | true | withFallback 接住走 Flash(fallback 触发第 1 次) |
| 4 | now 前进 ≥5000ms 后调用 | getState() 转 half-open | 视探测结果 | 探测成功→closed,失败→重新 open |
这条轨迹完全由注入的 now + 注入抛错驱动,断言点就是步 2 的「熔断触发 1 次」和步 3 的「fallback 触发 1 次」。
3. 今日实战
按 seed,今天的实战是组合 + 故障注入:
- 用
CircuitBreaker+withFallback组合出主(deepseek-v4-pro经熔断器)→ 备(deepseek-v4-flash)路由。 - 故障注入第一步:让主调用连续失败,注入到达
threshold触发熔断器open(断言熔断触发 1 次)。 - 故障注入第二步:熔断 open 后再发一次主调用 →
breaker.exec抛circuit open→withFallback接住走备模型(断言 fallback 触发 1 次,usedFallback === true)。 - 断言「熔断各触发 1 次、fallback 触发 1 次」,全程注入时钟/错误,确定性绿。
- 在记录里标出降级语义,引用质量损耗:Pro 89.7% → Flash 79.3%。
4. 今日实测 / 产出
CircuitBreaker+withFallback:已建并测试(✅)。- 「熔断触发 1 次 + fallback 触发 1 次」测试:已覆盖(确定性单测)。
- 真实质量损耗数字引用既有 A/B:Pro 89.7% / Flash 79.3%(降级掉约 10pp 完成率)。
诚实状态:组合逻辑和测试是真已完成的;质量损耗数字是引用既有 A/B 实测,不是今天新跑的——没有臆造新数字。
降级语义应落成的一条结构化日志/span(示意):
event "model.fallback"
from = "deepseek-v4-pro"
to = "deepseek-v4-flash"
reason = "circuit-open" # 或 "primary-error"
usedFallback= true
quality.note= "expect ~10pp lower completion (pro 89.7% vs flash 79.3%)"
把它挂到 Day 82 的 OTel trace 上,运维就能在面板里一眼看到「这段时间在降级、预期质量更低」——这正是「可用性 vs 质量 trade-off 可审计」的落地形态。
5. 常见误区 / 陷阱
- half-open 放全量流量:cooldown 一到就全量放行,下游若没好会立刻被打回 open,形成 flapping。只放少量探测流量。
- 降级不记录语义:悄悄降级到弱模型却不在 trace 里标出,运维会把「降级导致的低质量」误判成「模型退化」。
usedFallback必须落到可观测层。 - fallback 目标写成 legacy model id:降级目标若写成已退役的
deepseek-chat/deepseek-reasoner,降级时直接调一个不存在的模型,雪上加霜。 - 以为降级是免费的:约 10pp 完成率损耗是真金白银的质量代价;fallback 是「保命」不是「等价替换」。
- 熔断阈值与 cooldown 不匹配真实故障时长:cooldown 远短于下游真实恢复时间 → half-open 探测必败、反复 flapping;远长于恢复时间 → 下游早好了还在拒绝、白白损失可用性。两者要按真实故障画像调。
- fallback 也失败时没有兜底:备模型也可能挂。
withFallback只兜一层,备也失败时应有明确的最终错误语义(返回降级提示而非裸异常),不要让用户吃到未处理的 throw。
6. 学习资源(每条带 YYYY-MM)
- AWS Builders' Library — circuit breaker 章节(half-open 探测窗口,seed 引用),常青文档,2025 复访。
- Release It!(Nygard)— Circuit Breaker 模式(含 half-open 状态机),2018 第二版,机理常青。
- AICAP 既有 A/B 实测笔记(Pro 89.7% / Flash 79.3%,Δ+10.3pp 95% CI[0,20.7],N=29),本仓 B3 批次,2026-06。
- DeepSeek API docs — model ids(
deepseek-v4-pro/deepseek-v4-flash,legacy 退役表),执行当周复查,2026-06。
SOTA检查 (2026-06 更新)
- 当前主流:熔断 + half-open + fallback 路由模式稳定,仍是 SOTA,无机理过时风险。
- 模型 id 需复查:
deepseek-v4-pro/deepseek-v4-flash为当前;legacydeepseek-chat/deepseek-reasoner于 2026-07-24 退役——fallback 目标勿写 legacy id。 - 过时黑名单:fallback 指向已退役模型 id;half-open 放全量流量。
- 下次复查点:2026-07-24 DeepSeek legacy 退役日,复查 fallback 目标 model id 是否仍有效。
衔接
- 昨天:Day 83 — 韧性模式:backoff+jitter / circuit breaker / fallback(把三件套写成单独原语并测绿)。
- 今天:把
CircuitBreaker+withFallback组合成主→备路由做故障注入,量化降级质量损耗(Pro 89.7% → Flash 79.3%)。 - 明天:Day 85 — 把 eval harness 接入 OTel(把 29-task suite 批量埋点,让评测变成可观测的流水线)。