01 篇做接入底座时我们给LlmClient配了RetryPolicy超时或 5xx 就重试一次退避再试。这在「网络偶尔抖一下」的场景够用。但真机上你会遇到三种性质完全不同的失败偶尔抖动重试就好、下游持续不可用重试只会雪上加霜、以及彻底连不上必须给用户一个交代。把它们混在一套「无条件重试」里轻则白烧 token重则把一个已经不健康的下游彻底打死。10 这一篇在 01 的RetryPolicy之上补齐完整的韧性三层指数退避 抖动 → 熔断器 → 兜底文案。三者各管一段互不越界。三层防御各自解决什么问题失败不是一种状态而是三种。先分清你要对付的是哪一种再选工具失败类型典型表现应对手段不该做的事瞬时可恢复偶发超时、502、连接被重置指数退避 抖动重试立刻无间隔重试把下游打满持续性故障网关维护、配额耗尽、地域故障熔断器开路快速失败继续重试浪费每一次往返彻底不可用长时间无响应、熔断已开返回兜底文案让用户盯着转圈的空白页这三层不是「选一个」而是串联起来请求进来先过熔断器通过后再走重试重试耗尽才落兜底。顺序不能颠倒——先重试再判熔断等于每次都要先浪费一轮网络往返才知道下游是坏的。第一层退避为什么必须带抖动01 的RetryPolicy.calcBackoff做的是固定指数退避。它有个隐藏问题如果一百台设备在同一秒失败它们的退避时刻也完全一致于是一秒后、两秒后、四秒后各来一次整齐的流量尖峰——这就是「惊群」。解法是给退避值叠一个随机抖动jitter把整齐的尖峰抹平// 指数退避计算器纯函数可直接单测不依赖网络/设备。// 公式base * factor^(attempt-1) * jitterjitter ∈ [0.5, 1.5)。// rng 注入是为了测试可确定性生产里可传 () Math.random()。exportclassBackoffCalculator{staticnextDelay(attempt:number,base:number,factor:number,rng:()number):number{constexp:numberbase*Math.pow(factor,attempt-1);constjitter:number0.5rng()*1.0;// rng∈[0,1) → jitter∈[0.5,1.5)constdelay:numberexp*jitter;returnMath.floor(delay);}}三个设计细节值得注意attempt从 1 算起所以第一次退避是base * factor^0 base符合直觉。抖动是乘性的而不是加性的。乘性抖动让退避时长与退避基数成比例长退避的抖动也更长抹平效果更好。rng从外部注入。这是为了单测可确定性测试里传入一个返回固定值的函数断言就稳定了生产里传Math.random。把随机源做成参数是让「随机逻辑」变得可测的标准手法。第二层熔断器的三态机熔断器保护的是「已经明显不健康」的下游。它有三个状态CLOSED正常放行累计连续失败次数达标默认 3 次就打开OPEN直接拒绝请求不浪费网络往返冷却期默认 5 秒结束后转HALF_OPENHALF_OPEN只放一个探针请求。探针成功 → 回到CLOSED探针失败 → 重新OPEN。exportenumBreakerState{CLOSED0,OPEN1,HALF_OPEN2}exportclassCircuitBreaker{privatestate:BreakerStateBreakerState.CLOSED;privateconsecutiveFailures:number0;privateopenTime:number0;// now 注入是为了测试可确定性默认取真实时钟。constructor(privatereadonlyfailureThreshold:number3,privatereadonlycooldownMs:number5000,privatereadonlynow:()number():numberDate.now()){}// 成功CLOSED 下清零连续失败HALF_OPEN 下探针成功 → 彻底闭合。recordSuccess():void{if(this.stateBreakerState.HALF_OPEN){this.stateBreakerState.CLOSED;this.consecutiveFailures0;}elseif(this.stateBreakerState.CLOSED){this.consecutiveFailures0;}}// 失败CLOSED 下累计达标即打开HALF_OPEN 下探针失败 → 重新打开。recordFailure():void{if(this.stateBreakerState.CLOSED){this.consecutiveFailures;if(this.consecutiveFailuresthis.failureThreshold){this.stateBreakerState.OPEN;this.openTimethis.now();}}elseif(this.stateBreakerState.HALF_OPEN){this.stateBreakerState.OPEN;this.openTimethis.now();this.consecutiveFailuresthis.failureThreshold;}// OPEN 下再失败保持打开。}// 是否放行本次请求。OPEN 且冷却未到 → 拒绝冷却已到 → 转 HALF_OPEN 并只放一个探针。allowRequest():boolean{if(this.stateBreakerState.CLOSED){returntrue;}if(this.stateBreakerState.OPEN){if(this.now()-this.openTimethis.cooldownMs){this.stateBreakerState.HALF_OPEN;returntrue;// 这一个探针名额在转 HALF_OPEN的瞬间发出}returnfalse;}// HALF_OPEN探针名额已在上一步发出不再放行。returnfalse;}}HALF_OPEN只放一个探针这点容易被写错如果在HALF_OPEN下一路放行冷却期刚过就会瞬间涌入一批请求熔断形同虚设。上面代码的做法是——在从OPEN转为HALF_OPEN的那一次调用里发放唯一的探针名额之后allowRequest()在HALF_OPEN下一律返回false直到探针结果回来把它推回CLOSED或再次OPEN。now同样是从外部注入的。测试里喂一个可控的时钟就能确定性地断言「冷却期未到不放行、冷却期已到转半开」完全不依赖真实 sleep。第三层把三层串起来的 ResilientLlmClientResilientLlmClient不关心底层是盘古还是 OpenAI 兼容接口它只负责三件事的顺序熔断挡住 → 重试扛抖动 → 实在不行给兜底。// 韧性客户端在 01 的 LlmClient 之外再套一层熔断 重试 兜底。exportclassResilientLlmClient{// 最近一次调用的可观测信息供界面/测试观察lastAttempts:number0;usedFallback:booleanfalse;constructor(privatereadonlyinner:LlmClient,cfg:ResilienceConfig,privatereadonlybreaker:CircuitBreaker,privatereadonlyfallback:string,privatereadonlyonRetry?:(e:RetryEvent)void){/* ... */}// 返回模型文本若被熔断拦截或重试耗尽则返回兜底文案。asyncchat(req:ChatRequest):Promisestring{this.usedFallbackfalse;// 第一道闸熔断。OPEN 阶段直接返回兜底不再浪费一次网络往返。if(!this.breaker.allowRequest()){this.usedFallbacktrue;returnthis.fallback;}letattempt:number0;while(attemptthis.maxRetries){try{constresp:ChatResponseawaitthis.inner.chat(req);this.breaker.recordSuccess();this.lastAttemptsattempt1;returnresp.text;}catch(e){this.breaker.recordFailure();if(attemptthis.maxRetries){// 重试次数已用尽给兜底文案。this.usedFallbacktrue;returnthis.fallback;}// 计算指数退避含抖动。测试里不真实等待仅通过 onRetry 暴露数值供观察。constdelay:numberBackoffCalculator.nextDelay(attempt1,this.base,this.factor,Math.random);if(this.onRetry!undefined){this.onRetry({attempt:attempt1,delayMs:delay});}attempt;}}this.usedFallbacktrue;returnthis.fallback;}}三个实现要点usedFallback每次调用开头重置。它是「本次调用」的状态而不是累计状态否则界面上第一次兜底之后就永远显示「已兜底」。重试前先recordFailure()。熔断器的失败计数必须包含每一次重试失败否则失败 3 次、每次都重试 2 回熔断器却只记到 1 次failureThreshold永远达不到。退避数值通过onRetry回调暴露。测试里不真的 sleep否则单测要跑好几秒而是把算出来的delayMs交给调用方观察。真机上把这个回调换成真实的setTimeout等待即可。lastAttempts和usedFallback这两个字段让「韧性行为」变得可观测界面上可以显示「本次重试了 3 次」或「已启用兜底」而不是让用户猜。演示页两个按钮看懂韧性entry/src/main/ets/pages/Index.ets用MockLlmClient模拟失败离线即可验证「调用模拟前 2 次失败」配一个前两次抛错、第三次成功的 mock观察onRetry回调打印出的退避时长递增最终拿到正常回复usedFallback false。「调用持续失败 → 熔断」连续触发失败直到达到failureThreshold熔断器转OPEN后续请求被直接拦截返回兜底文案usedFallback true且mock 的调用计数不再增长——证明熔断确实省下了网络往返。页面主色用#0D9488背景#F7FAFD与配图保持一致。测试状态机必须逐条断言熔断器的状态流转是本文最容易写错的地方所以测试要逐条覆盖BackoffCalculator固定rng时结果确定随attempt单调递增抖动落在[0.5, 1.5) × 基数区间内。CircuitBreaker连续失败达到阈值转OPENOPEN且冷却未到时allowRequest()返回false冷却已到转HALF_OPEN并放出一个探针探针成功回CLOSED、失败回OPEN。ResilientLlmClient重试后成功返回正常文本重试耗尽返回兜底熔断已开时直接返回兜底且不调用底层客户端用 mock 的调用计数断言。verify-node.cjs用纯 JS 镜像了同样的逻辑命令行 20 条断言全绿。加上entry/src/ohosTest/ets/test/LlmClient.test.ets里的 Hypium 断言含 01 底座的复用部分不装 DevEco 也能先验纯逻辑。避坑六个常见错误什么都重试。400 参数错误、鉴权失败属于「重试一万次也还是失败」必须直接抛出。对策用classifyError区分可重试与不可重试只重试后者。退避不带抖动。大量设备整齐重试会形成流量尖峰。对策务必叠加乘性 jitter。重试里再套重试。01 的LlmClient.chat内部已经有RetryPolicy外层ResilientLlmClient再重试一次实际请求数会变成乘积关系。对策二选一——要么只留外层要么把内层的maxRetries设为 0。忘记把重试失败计入熔断器。只在首次失败时recordFailure()会导致阈值永远达不到、熔断永不触发。半开状态一路放行。冷却期刚过就涌入一批请求熔断形同虚设。对策半开只放一个探针。兜底文案太敷衍。一句「出错了」等于没兜底。对策兜底要说清「暂时不可用」并给出下一步稍后重试 / 联系人工让用户知道发生了什么。与系列其它章节的关系本篇是「HarmonyOS 7 Agent 开发实战」第 10 篇是对 01 篇RetryPolicy的深化01 解决「单次请求怎么发得稳」本篇解决「下游整体不健康时怎么自保」。源码在code/10-降级兜底重试/开箱即跑。参考与出处HarmonyOS ArkTS 语言ohos.net.http 数据请求Hypium 自动化测试框架使用指南Hvigor 编译构建概述最后一句重试是给偶然的抖动留机会熔断是给持续的故障留体面兜底是给用户留交代——三层缺一层线上都会露怯。