title: 我的 Agent 一次泼 30 个工具调用被限流打爆


前两天跑一个批量任务,我的 Agent 一轮甩出 30 个工具调用,全打在同一个外部 API 上。结果 30 个里 25 个直接吃到 429。

我之前给工具调用写过一层退避重试,那篇就靠它兜底了——重试 78 次,30 个调用最终全部成功,耗时 16.32 秒。看着日志我越想越不对:退避是为「我不知道什么时候能恢复」设计的,可这里我明明知道,下一秒额度就回来了。每次被拒还要从 0.5 秒起跳翻倍等,等于把明摆着的等待时间浪费掉一大半。

于是我换了个思路:别等被拒了再退,先在客户端把速度压住。

说明一下实验方式:我没真去把某个 API 的额度打满做实验,那既费钱又不可复现。我写了个虚拟时钟的离散事件模拟,把服务端建模成一个令牌桶(容量 5,每秒补 5 个),客户端用不同策略往上打。脚本是零依赖 Node,每次跑出来的数字完全一样。下面所有表格都是这份脚本的真实输出。

先划清界限:这事儿和退避重试不是一回事

站内那篇《一撞上 429 就整批崩》讲的是「已经被拒了怎么退」。这篇讲的是「怎么别被拒」。两者要一起用,但不能互相替代。

查资料时我翻到一个之前没注意的点:OpenAI 官方文档(核对日期 2026-10-12)里,429 不光是额度打满,还有一种是 error.code = slow_down,含义是你的请求速率上升得太快,哪怕 RPM 和 TPM 都没超也会给你。文档还给了条经验值:流量到 100 万 TPM 之后,每 15 分钟涨幅不要超过 50%。

这条正好戳中我:一次性泼一批请求本身就是错的做法,不是「额度够就能泼」。429 的响应头里带 Retry-After(秒)和 x-ratelimit-remaining-requests / -reset-requests(形如 1s、6m0s),Anthropic 那边对等的头是 retry-after 和 anthropic-ratelimit-requests-remaining。这些头我拿来做客户端的自适应校准很有用,但那是后话。

我的第一反应是限并发,跑完发现没用

我的第一反应跟大家一样:加个并发上限,p-limit(4) 那种。跑出来是这样:

策略                                       429   重试  失败  总耗时s  关键路径完成s
A 无控并发,不重试                             25     0    25     0.12        -
B 无控并发 + 退避重试                          78    78     0    16.32      16.17
C 并发上限 4 + 退避重试                        77    77     0    16.52       4.07
D 并发上限 2 + 退避重试                        68    68     0    17.38       9.01

C 只把 429 从 78 次降到 77 次,等于没做。原因想明白之后其实很直白:并发上限管的是「同时在飞几个」,管不了「一秒发几个」。4 个并发、每个 120ms 就返回,一秒照样能发 33 个。服务端是按秒计数的,当然照拒不误。D 把并发压到 2 才稍微有点效果,代价是总耗时反而涨到 17.38 秒——比不管还慢。

这条我觉得是整篇最值钱的坑:限并发看起来在限流,其实它限的是另一件事。

换成客户端令牌桶,429 直接归零

服务端自己是令牌桶,那客户端就照抄一个同样的桶,出门前先问桶里有没有货:

function makeBucket(cap, rate) {
  let tokens = cap, last = 0;
  return {
    take(t) {
      const tk = Math.min(cap, tokens + (t - last) * rate);
      if (tk >= 1) { tokens = tk - 1; last = t; return true; }
      return false;
    },
    waitFor(t) {                       // 还要等多久才有下一个令牌
      const tk = Math.min(cap, tokens + (t - last) * rate);
      return tk >= 1 ? 0 : (1 - tk) / rate;
    },
  };
}

客户端拿不到令牌时不发,也不睡死循环——直接 waitFor() 算出还差多少秒,把虚拟时钟推进到那个点。加上之后的对比:

E 令牌桶 4/s burst=2,FIFO                   0     0     0     7.12       6.12
F 令牌桶 4/s burst=2,优先级                   0     0     0     7.12       0.37
G 令牌桶 5/s burst=2,FIFO                   0     0     0     5.72       4.92
H 令牌桶 5/s burst=2,优先级                   0     0     0     5.72       0.32
I 令牌桶 6/s burst=2,优先级                   2     2     0     5.12       0.29

429 从 78 次直接归零。而且这里有个我一开始设错的坑:我把客户端速率拍了个 4/s,比服务端的 5/s 「保守一点」,结果 E 要 7.12 秒;改成和服务端对齐的 5/s 后只要 5.72 秒,省了 20% 而 429 依然是 0。

再往上加到 6/s(I 档),出现 2 次 429,但那 2 次交给退避兜掉就行,总耗时 5.12 秒——这正好是 30 个请求在 5/s 服务端下的理论下界((30-5)/5 + 0.12)。

所以我的结论跟直觉相反:客户端速率宁可略微超配,让少量 429 交给退避兜底,也不要大幅欠配。欠配省下的那点 429,是用实打实的吞吐换的。

关键路径不该排在队尾:这是白拿的 16 倍

30 个调用里,有 3 个是用户在等的结果,剩下 27 个是后台补全。我把那 3 个标成 prio: 0 散在队列中间(idx 10/18/25),而不是放在最前面——放最前面是作弊,说明不了问题。

看 E 和 F:总耗时一模一样,都是 7.12 秒,唯一的区别是关键路径从 6.12 秒降到 0.37 秒。

这一点让我挺兴奋:优先级调度在这批任务里是纯赚的。它没有牺牲任何总吞吐,只是把用户等着的那几个往前挪了。因为总耗时由服务端速率决定,跟谁先谁后无关。

优先级会把后台任务饿死,我试了三种补救

但优先级有个老问题:关键路径要是源源不断地来,后台任务就永远排不上。我造了个更狠的场景——8 个普通任务在 t=0 到达,关键路径每 0.25 秒来一个(正好等于客户端 4/s 的带宽,把通道吃满),共 32 个:

策略                                       总耗时s  关键路径完成s  普通最大等待s
J 令牌桶 4/s,纯优先级(不老化)                   9.62      8.12        9.62
K 线性 aging 0.5/s                         9.62      9.62        3.37
M 阈值老化:等够 2s 提级                       9.62      9.62        3.37
P 加权公平 3:1(每 3 个关键让 1 个)              9.62      9.62        6.12
Q 加权公平 2:1                             9.62      9.62        4.62
R 加权公平 5:1                             9.62      9.62        9.12
S 纯 FIFO(不区分优先级)                       9.62      9.62        1.62

我原本以为加权公平(P/Q/R)比老化优雅——它是「预留带宽」而不是「等久了就造反」。跑出来被打脸了:3:1 只把普通任务的等待从 9.62 秒降到 6.12 秒,不如阈值老化的 3.37 秒。因为加权公平是均匀让位,而老化是等到点后整批插队,对「攒了一堆老任务」的场景更直接。

但请注意 K/M 那一列:所有补救措施都把关键路径从 8.12 秒推到了 9.62 秒。没有免费午餐。老化本质是拿关键路径的延迟去换后台任务的延迟。

我的判断

这套东西最后落到我代码里的就三样:一个客户端令牌桶、一个优先级标记、一个默认关掉的老化开关。

令牌桶是必开的,它是唯一能把 429 打到 0 的手段,而且代码只有十几行。优先级也必开,因为它是白拿的——总耗时不变,用户在等的那几个快 16 倍。

老化我默认关着。只有当我明确给后台任务设了 SLA(比如「30 秒内必须落库」)时才打开,并且阈值就设成那个 SLA,不拍系数。原因上面那张表写得很清楚:它会拖慢关键路径,而我大部分场景里后台任务晚几秒完成根本无所谓。如果哪天真的出现后台任务长期排不上,我宁愿把它从这一轮里摘掉、下轮再补,也不愿意给关键路径加延迟。

最后说个我这回踩的、跟限流无关的坑。第一版脚本跑出来 C/D/E 三档全是「卡住」,我查了半天才发现是时钟推进的逻辑错了:我把「已就绪但被策略挡住」的任务时间也算进了候选时间点,于是 cand 一直等于当前时刻,时钟原地打转。修法是候选时间只取严格晚于当前时刻的那些。第二版又发现我的排序器不管什么策略都按优先级取任务,导致「FIFO 对照组」也被偷偷优先化了,对照完全失效。这两个 bug 都是那种「跑得起来、结果不对」的类型,比直接报错难查得多。

所以如果你也要自己搭这套:先把对照组跑出一个你已经知道答案的结果,确认对照真的在对照,再去信那些看起来很漂亮的提升幅度。

添加新评论

文章目录