我的 Agent 一撞上 429 就整批崩:给它写了层带抖动的退避重试,200 次调用从 61% 救回 97%

上周我让 Agent 去批量拉一批外部数据,200 次调用跑完一看:122 条成功,78 条报错,几乎全是 429 和 503。任务没崩,但结果缺了一大块,我只能再跑一遍补齐——而重跑会把已经成功的那 122 条再拉一次。干了两回我就不想忍了。

结果加完重试我发现,第一版比不重试还糟。下面是从"土味 sleep 循环"改到最后这版的记录,所有数字都是我本地真跑出来的。

第一版:固定 sleep,越重试越挤

最开始我写的是这个:

for (let i = 0; i < 3; i++) {
  try { return await call(); }
  catch (e) { if (i === 2) throw e; await sleep(1000); }
}

三个毛病,我是一个一个撞出来的:

  1. 固定 1 秒。单个客户端没问题,但我的 Agent 会并发开十几个工具调用。它们几乎在同一瞬间失败,然后几乎在同一瞬间醒来,再一起把服务端打回 429。重试变成了一波一波的脉冲。
  2. 什么错都重试。400、401、404 也被拉进去重试三次。这些错重试一万次结果都一样,纯浪费三倍请求量。
  3. 睡下去了就叫不醒。没有预算、没有取消,一个抽风的接口能把整批任务拖在那儿。

我懒得等线上慢慢出问题,直接在本机造了个会随机抽风的服务端来压它。

我造的抽风服务端

零依赖,一个 http.createServer 搞定,可以按场景切换行为:随机失败、一直 503、一直 400、带 Retry-After 返回 429。

const server = http.createServer((req, res) => {
  hits++;
  if (mode === 'down') { res.writeHead(503); return res.end('down'); }
  if (mode === 'retryafter') {
    res.writeHead(429, { 'Retry-After': '1' });   // 单位是秒,不是毫秒
    return res.end('slow down');
  }
  if (mode === 'badreq') { res.writeHead(400); return res.end('bad request'); }
  if (rnd() < failRate) {                          // 35% 概率抽风
    const pick = rnd();
    const status = pick < 0.6 ? 429 : pick < 0.9 ? 503 : 500;
    res.writeHead(status); return res.end('oops');
  }
  res.writeHead(200, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ ok: true, hits }));
});

随机序列我用了固定种子,这样每次跑出来的数字能对得上,不是凭运气。

压完的结果:

场景结果
不重试,35% 失败率,200 次成功 122(61.0%)
退避 + 全抖动,最多 4 次成功 194(97.0%),服务端共收到 299 次请求,累计等待 1872ms,墙钟 2907ms
成功发生在第几次第1次 138 · 第2次 37 · 第3次 13 · 第4次 6
服务端一直 400服务端只收到 1 次请求,1ms 返回

第 4 次只救回 6 个,收益已经很小了——这是我把默认次数定在 3~4 之间的原因,再往上加基本是给对方加压。

我现在的重试器

零依赖,60 行,我把关键部分贴出来:

const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);

function backoffDelay(attempt, { baseMs, maxMs, factor, jitter }) {
  const raw = Math.min(maxMs, baseMs * Math.pow(factor, attempt - 1));
  if (jitter === 'none') return raw;
  if (jitter === 'equal') return raw / 2 + Math.random() * (raw / 2);
  return Math.random() * raw;                    // full jitter
}

async function retry(fn, opts = {}) {
  const {
    attempts = 4, baseMs = 200, maxMs = 5000, factor = 2,
    jitter = 'full', budgetMs = 15000, idempotent = true, onRetry = null,
  } = opts;
  const maxTry = idempotent ? attempts : 1;      // 非幂等操作只调一次,绝不重放
  let spent = 0, lastErr;
  for (let attempt = 1; attempt <= maxTry; attempt++) {
    try { return await fn(attempt); }
    catch (err) {
      lastErr = err;
      if (attempt >= maxTry || !isRetryable(err)) throw err;
      // 服务端给了 Retry-After 就听它的,但用自己的 maxMs 兜底
      let wait = retryAfterMs(err.headers);
      if (wait === null) wait = backoffDelay(attempt, { baseMs, maxMs, factor, jitter });
      wait = Math.min(wait, maxMs);
      if (spent + wait > budgetMs) throw err;     // 预算耗尽就放弃
      if (onRetry) onRetry({ attempt, wait, err, spent });
      spent += wait;
      await sleep(wait);
    }
  }
  throw lastErr;
}

四个决定,每个都有实测支撑。

只重试"有可能自己好"的错

408、425、429、500、502、503、504,加上 ECONNRESET / ETIMEDOUT / socket hang up 这类网络层错误。400、401、403、404 直接抛。上面表格里"服务端一直 400"那一行就是验证:只发了一次请求,1ms 返回,没有多余动作。

服务端给了 Retry-After,就听它的

我一开始没管这个头,后来才发现我在跟服务端打架——它明确说了"1 秒后再来",我偏要 20ms 后就冲上去,等于白送一次 429。

MDN 上对 Retry-After 的定义(我 2026-10-03 核对):用在 503、429 和重定向响应里,值有两种形式——delay-seconds(非负十进制整数,单位是秒)和 http-date,规范在 RFC 9110 §field.retry-after。MDN 还专门提醒了一句"客户端和服务端对这个头的支持都不一致"(Support for the Retry-After header on both clients and servers is still inconsistent),所以我做了两手准备:解析不出来就退回自己算的退避值。

实测:服务端回 429 + Retry-After: 1,我的重试器老老实实等了 1000ms,整个调用耗时 1008ms,而不是按自己算的 20ms 冲上去。同时我用 Math.min(wait, maxMs) 做了截断——万一对方给个 Retry-After: 3600,我不打算让 Agent 干等一小时。把 maxMs 压到 300ms 时,实测等待被截成 300ms。

注意单位:是秒。我第一版写成毫秒,白等了 1000 秒才发现不对。

抖动不是可选项

这块的道理 AWS 那篇《Exponential Backoff And Jitter》(Marc Brooker,2015-03-04 发布,2023-05 更新)讲得很清楚:光有 capped exponential backoff 是不够的,调用还是成簇出现(there are still clusters of calls),加抖动之后"在 100 个客户端竞争的场景下,调用量减少了一半以上",而且作者明确说无抖动的指数退避"是明显的输家"(the clear loser)。

我拿自己的实现复现了一遍(120 个客户端同时失败,只看第一次重试的等待值):

  • 无抖动:等待值 100~100ms,同一个 10ms 窗口里醒来 120/120 个
  • 全抖动:等待值 1~99ms,同一个 10ms 窗口里最多醒来 15/120 个

一行 Math.random() * raw 换来的差距,没有理由省。

预算比次数更重要

attempts 管的是次数,管不住时间。我加了个 budgetMs:累计等待超过预算就直接放弃。实测 attempts=20、budgetMs=1500、服务端一直 503 的情况下,它在 1526ms 处放弃,而不是傻等到 20 次跑完。Agent 里一个卡住的工具调用会把整条链路挂住,这个兜底必须有。

接进 Agent 的工具调用层,以及一个我踩过的坑

包装起来就是一层:

async function callTool(name, args) {
  return retry(() => httpCall(name, args), {
    attempts: 3, baseMs: 200, maxMs: 4000, jitter: 'full', budgetMs: 8000,
    idempotent: IDEMPOTENT_TOOLS.has(name),        // 只有白名单里的工具才允许重放
    onRetry: ({ attempt, wait, err }) => trace.log('tool.retry', { name, attempt, wait, status: err.status }),
  });
}

IDEMPOTENT_TOOLS 这个白名单是我踩出来的。有一回 Agent 调"追加写文件"和"发通知",接口超时了,重试器很勤快地又执行了一遍——结果文件里多了两行重复内容,通知发了两条。超时不等于没执行,对方可能已经干完了,只是回包丢了。

所以现在我把工具分成两类:读类(查询、搜索、读取)允许自动重试;写类(创建、追加、发送、支付)默认一次都不重放。实测 idempotent: false 时,服务端一直 503,它也只收到 1 次请求;idempotent: true 时收到 4 次。

写类工具失败了怎么办?我的做法是把错误原样返回给模型,让它自己判断是换个参数重试、还是先查一下刚才那次到底成功没有。这比我在代码里替它瞎猜安全。

我的判断

重试是个"看起来免费、其实很贵"的东西。97% 对比 61% 很漂亮,但代价是 299 次请求换 194 次成功——多花了 54% 的请求量。在对方已经限流的情况下,这个放大效应会让你变成那个把服务打垮的人。所以我的默认值一直很保守:attempts=3,budgetMs 卡在 8 秒以内。

另外两点是我的取舍:

第一,抖动和预算是零成本高收益项,必开;把 attempts 从 4 加到 8 收益微乎其微(第 4 次只救回 6/200),不值得。

第二,重试次数是监控指标,不是配置项。我让 onRetry 每次往 trace 里打一行。某个工具的重试率长期超过 5%,就该去查对面的限流策略或我的并发设置,而不是把数字调大盖住。靠加重试掩盖问题,最容易骗过的是自己。

最后交代一句:这些数据全来自我本机造的抽风服务端,不是拿线上真实 API 压的。各家限流策略差别很大,参数得对着你用的那家文档调,别直接抄我这几个数。


顺手放一个我自己常用的推理 API 平台,含我的邀请码,注册我有返利:https://cloud.siliconflow.cn/i/pyV0lE45 (邀请码 pyV0lE45)

添加新评论

文章目录