我的 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 秒。单个客户端没问题,但我的 Agent 会并发开十几个工具调用。它们几乎在同一瞬间失败,然后几乎在同一瞬间醒来,再一起把服务端打回 429。重试变成了一波一波的脉冲。
- 什么错都重试。400、401、404 也被拉进去重试三次。这些错重试一万次结果都一样,纯浪费三倍请求量。
- 睡下去了就叫不醒。没有预算、没有取消,一个抽风的接口能把整批任务拖在那儿。
我懒得等线上慢慢出问题,直接在本机造了个会随机抽风的服务端来压它。
我造的抽风服务端
零依赖,一个 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)