alt

01 篇做接入底座时,我们给 LlmClient 配了 RetryPolicy:超时或 5xx 就重试一次,退避再试。这在「网络偶尔抖一下」的场景够用。但真机上你会遇到三种性质完全不同的失败:偶尔抖动(重试就好)、下游持续不可用(重试只会雪上加霜)、以及彻底连不上(必须给用户一个交代)。把它们混在一套「无条件重试」里,轻则白烧 token,重则把一个已经不健康的下游彻底打死。

10 这一篇在 01 的 RetryPolicy 之上补齐完整的韧性三层:指数退避 + 抖动 → 熔断器 → 兜底文案。三者各管一段,互不越界。

三层防御各自解决什么问题

失败不是一种状态,而是三种。先分清你要对付的是哪一种,再选工具:

失败类型典型表现应对手段不该做的事
瞬时可恢复偶发超时、502、连接被重置指数退避 + 抖动重试立刻无间隔重试,把下游打满
持续性故障网关维护、配额耗尽、地域故障熔断器开路,快速失败继续重试,浪费每一次往返
彻底不可用长时间无响应、熔断已开返回兜底文案让用户盯着转圈的空白页

这三层不是「选一个」,而是串联起来:请求进来先过熔断器,通过后再走重试,重试耗尽才落兜底。顺序不能颠倒——先重试再判熔断,等于每次都要先浪费一轮网络往返才知道下游是坏的。

alt

第一层:退避为什么必须带抖动

01 的 RetryPolicy.calcBackoff 做的是固定指数退避。它有个隐藏问题:如果一百台设备在同一秒失败,它们的退避时刻也完全一致,于是一秒后、两秒后、四秒后各来一次整齐的流量尖峰——这就是「惊群」。

解法是给退避值叠一个随机抖动(jitter),把整齐的尖峰抹平:

// 指数退避计算器:纯函数,可直接单测(不依赖网络/设备)。
// 公式:base * factor^(attempt-1) * jitter,jitter ∈ [0.5, 1.5)。
// rng 注入是为了测试可确定性;生产里可传 () => Math.random()。
export class BackoffCalculator {
  static nextDelay(attempt: number, base: number, factor: number, rng: () => number): number {
    const exp: number = base * Math.pow(factor, attempt - 1);
    const jitter: number = 0.5 + rng() * 1.0; // rng∈[0,1) → jitter∈[0.5,1.5)
    const delay: number = exp * jitter;
    return Math.floor(delay);
  }
}

三个设计细节值得注意:

  1. attempt 从 1 算起,所以第一次退避是 base * factor^0 = base,符合直觉。
  2. 抖动是乘性的而不是加性的。乘性抖动让退避时长与退避基数成比例,长退避的抖动也更长,抹平效果更好。
  3. rng 从外部注入。这是为了单测可确定性:测试里传入一个返回固定值的函数,断言就稳定了;生产里传 Math.random。把随机源做成参数,是让「随机逻辑」变得可测的标准手法。

第二层:熔断器的三态机

熔断器保护的是「已经明显不健康」的下游。它有三个状态:

  • CLOSED:正常放行,累计连续失败次数,达标(默认 3 次)就打开;
  • OPEN:直接拒绝请求,不浪费网络往返,冷却期(默认 5 秒)结束后转 HALF_OPEN;
  • HALF_OPEN:只放一个探针请求。探针成功 → 回到 CLOSED;探针失败 → 重新 OPEN。
export enum BreakerState {
  CLOSED = 0,
  OPEN = 1,
  HALF_OPEN = 2
}

export class CircuitBreaker {
  private state: BreakerState = BreakerState.CLOSED;
  private consecutiveFailures: number = 0;
  private openTime: number = 0;

  // now 注入是为了测试可确定性(默认取真实时钟)。
  constructor(
    private readonly failureThreshold: number = 3,
    private readonly cooldownMs: number = 5000,
    private readonly now: () => number = (): number => Date.now()
  ) {}

  // 成功:CLOSED 下清零连续失败;HALF_OPEN 下探针成功 → 彻底闭合。
  recordSuccess(): void {
    if (this.state === BreakerState.HALF_OPEN) {
      this.state = BreakerState.CLOSED;
      this.consecutiveFailures = 0;
    } else if (this.state === BreakerState.CLOSED) {
      this.consecutiveFailures = 0;
    }
  }

  // 失败:CLOSED 下累计,达标即打开;HALF_OPEN 下探针失败 → 重新打开。
  recordFailure(): void {
    if (this.state === BreakerState.CLOSED) {
      this.consecutiveFailures++;
      if (this.consecutiveFailures >= this.failureThreshold) {
        this.state = BreakerState.OPEN;
        this.openTime = this.now();
      }
    } else if (this.state === BreakerState.HALF_OPEN) {
      this.state = BreakerState.OPEN;
      this.openTime = this.now();
      this.consecutiveFailures = this.failureThreshold;
    }
    // OPEN 下再失败:保持打开。
  }

  // 是否放行本次请求。OPEN 且冷却未到 → 拒绝;冷却已到 → 转 HALF_OPEN 并只放一个探针。
  allowRequest(): boolean {
    if (this.state === BreakerState.CLOSED) {
      return true;
    }
    if (this.state === BreakerState.OPEN) {
      if (this.now() - this.openTime >= this.cooldownMs) {
        this.state = BreakerState.HALF_OPEN;
        return true; // 这一个探针名额在"转 HALF_OPEN"的瞬间发出
      }
      return false;
    }
    // HALF_OPEN:探针名额已在上一步发出,不再放行。
    return false;
  }
}

HALF_OPEN 只放一个探针这点容易被写错:如果在 HALF_OPEN 下一路放行,冷却期刚过就会瞬间涌入一批请求,熔断形同虚设。上面代码的做法是——在从 OPEN 转为 HALF_OPEN 的那一次调用里发放唯一的探针名额,之后 allowRequest() 在 HALF_OPEN 下一律返回 false,直到探针结果回来把它推回 CLOSED 或再次 OPEN。

now 同样是从外部注入的。测试里喂一个可控的时钟,就能确定性地断言「冷却期未到不放行、冷却期已到转半开」,完全不依赖真实 sleep。

第三层:把三层串起来的 ResilientLlmClient

ResilientLlmClient 不关心底层是盘古还是 OpenAI 兼容接口,它只负责三件事的顺序:熔断挡住 → 重试扛抖动 → 实在不行给兜底。

// 韧性客户端:在 01 的 LlmClient 之外再套一层"熔断 + 重试 + 兜底"。
export class ResilientLlmClient {
  // 最近一次调用的可观测信息(供界面/测试观察)
  lastAttempts: number = 0;
  usedFallback: boolean = false;

  constructor(
    private readonly inner: LlmClient,
    cfg: ResilienceConfig,
    private readonly breaker: CircuitBreaker,
    private readonly fallback: string,
    private readonly onRetry?: (e: RetryEvent) => void
  ) { /* ... */ }

  // 返回模型文本;若被熔断拦截或重试耗尽,则返回兜底文案。
  async chat(req: ChatRequest): Promise<string> {
    this.usedFallback = false;

    // 第一道闸:熔断。OPEN 阶段直接返回兜底,不再浪费一次网络往返。
    if (!this.breaker.allowRequest()) {
      this.usedFallback = true;
      return this.fallback;
    }

    let attempt: number = 0;
    while (attempt <= this.maxRetries) {
      try {
        const resp: ChatResponse = await this.inner.chat(req);
        this.breaker.recordSuccess();
        this.lastAttempts = attempt + 1;
        return resp.text;
      } catch (e) {
        this.breaker.recordFailure();
        if (attempt >= this.maxRetries) {
          // 重试次数已用尽,给兜底文案。
          this.usedFallback = true;
          return this.fallback;
        }
        // 计算指数退避(含抖动)。测试里不真实等待,仅通过 onRetry 暴露数值供观察。
        const delay: number = BackoffCalculator.nextDelay(attempt + 1, this.base, this.factor, Math.random);
        if (this.onRetry !== undefined) {
          this.onRetry({ attempt: attempt + 1, delayMs: delay });
        }
        attempt++;
      }
    }
    this.usedFallback = true;
    return this.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:连续失败达到阈值转 OPEN;OPEN 且冷却未到时 allowRequest() 返回 false;冷却已到转 HALF_OPEN 并放出一个探针;探针成功回 CLOSED、失败回 OPEN。
  • ResilientLlmClient:重试后成功返回正常文本;重试耗尽返回兜底;熔断已开时直接返回兜底且不调用底层客户端(用 mock 的调用计数断言)。

verify-node.cjs 用纯 JS 镜像了同样的逻辑,命令行 20 条断言全绿。加上 entry/src/ohosTest/ets/test/LlmClient.test.ets 里的 Hypium 断言(含 01 底座的复用部分),不装 DevEco 也能先验纯逻辑。

避坑:六个常见错误

alt

  1. 什么都重试。400 参数错误、鉴权失败属于「重试一万次也还是失败」,必须直接抛出。对策:用 classifyError 区分可重试与不可重试,只重试后者。
  2. 退避不带抖动。大量设备整齐重试会形成流量尖峰。对策:务必叠加乘性 jitter。
  3. 重试里再套重试。01 的 LlmClient.chat 内部已经有 RetryPolicy,外层 ResilientLlmClient 再重试一次,实际请求数会变成乘积关系。对策:二选一——要么只留外层,要么把内层的 maxRetries 设为 0。
  4. 忘记把重试失败计入熔断器。只在首次失败时 recordFailure(),会导致阈值永远达不到、熔断永不触发。
  5. 半开状态一路放行。冷却期刚过就涌入一批请求,熔断形同虚设。对策:半开只放一个探针。
  6. 兜底文案太敷衍。一句「出错了」等于没兜底。对策:兜底要说清「暂时不可用」并给出下一步(稍后重试 / 联系人工),让用户知道发生了什么。

与系列其它章节的关系

本篇是「HarmonyOS 7 Agent 开发实战」第 10 篇,是对 01 篇 RetryPolicy 的深化:01 解决「单次请求怎么发得稳」,本篇解决「下游整体不健康时怎么自保」。源码在 code/10-降级兜底重试/,开箱即跑。


参考与出处


最后一句:重试是给偶然的抖动留机会,熔断是给持续的故障留体面,兜底是给用户留交代——三层缺一层,线上都会露怯。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐