指数退避加抖动仍失败64%:100个LLM调用实测与根因

实测100并发LLM调用:默认指数退避加抖动失败率64%,客户端限流是唯一达最优解。
文章通过模拟Anthropic限流器(令牌桶,容量20,每秒10令牌)对100个并发LLM调用进行压力测试,发现llm-retry默认的3次指数退避加±10%抖动导致64个调用失败,且服务端为36个成功请求拒绝了255次请求。±10%抖动无法打散请求,造成波次到达浪费令牌。全抖动将p95从16.1秒降至10.5秒,但总请求量增9%,末次完成时间反而略高。遵守Retry-After并叠加随机扩散可将末次完成时间降至11.8秒。客户端限流器在8.0秒内完成全部100个调用,零429,触及理论下限。文章还揭露llm-retry的三个bug(不重试529/408、重试自身异常、with_failover掩盖400错误)及熔断器半开状态不限制试探调用的缺陷,并给出八条实践建议。
指数退避加抖动仍失败64%:100个LLM调用实测与根因
一次看似标准的重试策略,在100个并发LLM请求面前溃不成军。C++库llm-retry的作者用自家工具做了一次压力测试:100个调用同时冲击一个每秒补充10个令牌的限流器,默认的指数退避(3次重试、±10%抖动)导致64个调用彻底失败。更糟的是,为了服务36个成功请求,服务端被迫拒绝了255次请求——放大2.55倍。
问题出在哪?加重试次数能解决吗?作者进一步测试了全抖动(full jitter)、尊重Retry-After头、以及客户端限流器,并顺带挖出了库本身的三个bug。这份详尽的实验报告,对任何调用LLM API的开发者都有参考价值。

实验设置:100个调用,一个令牌桶
测试模拟了Anthropic API的限流机制:令牌桶算法,容量20,每秒补充10个令牌。每个请求消耗一个令牌,桶空则返回429,并附带整数秒的retry-after头(符合RFC 9110)。100个线程同时发起调用,理论最优完成时间是8.0秒——0秒时用掉20个突发令牌,之后以每秒10个的速度处理剩余80个。所有策略都以此作为性能底线。
llm-retry是一个单头文件C++库,提供with_retry、with_failover和CircuitBreaker。作者坦言,此前只用“一个调用失败两次然后成功”的用例测试过,从未验证过并发突发的场景——而这恰恰是重试机制真正发挥作用的时刻。
令牌桶算法是限流器最常用的实现之一。它维护一个容量为 B 的桶,以固定速率 r 向桶中添加令牌,桶满则丢弃新令牌。每个请求到达时必须从桶中取走一个令牌,若桶空则请求被拒绝(通常返回 429)。其关键特性是允许突发流量:当桶满时,最多可以一次性通过 B 个请求,随后速率被限制在 r。这与漏桶算法(强制恒定输出速率、不允许突发)形成对比。Anthropic、OpenAI 等 LLM API 普遍采用令牌桶或其变体来平衡突发容忍与平均吞吐。在本实验中,容量 20、补充速率 10/秒意味着理论最优完成时间 = 突发消耗时间 + 剩余请求按速率处理的时间,即 0 秒用掉 20 个,剩余 80 个以每秒 10 个处理,共需 8 秒。理解这一点是评估后续所有重试策略效率的基准。
三次重试远不足以应对突发
无重试时,20个调用获得突发令牌,80个立即失败。使用llm-retry默认配置(3次尝试),36个成功,重试多换来16个答案,却让服务端多承受155次请求。作为对比,作者移植了Anthropic官方Python SDK的重试逻辑(默认2次重试、0.5秒起步翻倍至8秒、75%-100%抖动、遵循retry-after头),结果只有40个成功。
“这不是SDK的bug,”作者强调,“两次重试是为偶发故障设计的,而所有默认重试策略都是为单次不稳定调用构建的,不是为100个争抢同一稀缺资源的调用准备的。”
将max_attempts提升到8次后,llm-retry让全部100个调用通过,但耗时16.6秒,是理论下限的两倍多,且发送了375次请求。原因藏在请求到达服务端的模式里。
为什么请求成波次涌来:±10%不是真正的抖动
问题核心在于延迟计算:每次失败后,客户端等待500ms * 2^n * random(0.9, 1.1)。所有在0秒失败的客户端都等待450-550毫秒,于是80个重试挤在同一个100毫秒窗口内到达,而桶只补充了约5个令牌。5个成功,75个再次失败,然后全部等待0.9-1.1秒……如此形成波次。
波次之间的静默期,令牌桶在补充却无人请求,容量被白白浪费。“退避本身只是把集群间隔开,并没有打散它们,”作者引用AWS架构师Marc Brooker的经典分析指出。
抖动(Jitter) 是在重试延迟中加入随机成分的技术,目的是避免多个客户端在同一时刻重试,造成“惊群”或同步波次。常见的抖动策略有几种:固定抖动(在固定延迟上加减一个小比例,如 ±10%)、全抖动(在 0 和指数上限之间均匀随机)、等抖动(在延迟的一半和完整延迟之间随机)、装饰抖动(在指数退避基础上加一个固定随机量)。AWS 架构师 Marc Brooker 在分析中推荐全抖动,因为它能最大程度打散重试时间,减少峰值负载。然而,全抖动并不减少重试总次数,只是改变时间分布。对于令牌桶这类有状态限流器,重试过早仍会撞上空桶而浪费请求。因此,抖动需要与对服务端反馈(如 Retry-After)的尊重或客户端限速结合,才能实现高效重试。
全抖动改善了尾部,但没减少总量
教科书式的修复是“全抖动”:在0到指数上限之间均匀随机取延迟。作者预期这会完胜,结果却出乎意料。全抖动将95分位延迟从16.1秒降到10.5秒,大部分调用更快完成,但最后一个调用在17.1秒落地(固定延迟策略为16.6秒),且发送了408次请求,反而多出9%。
原因在于:随机延迟让每次重试都比固定延迟更早返回(一半的重试不到一半时间就回来),撞上空桶又浪费一次请求。“面对令牌桶,更聪明的猜测仍然在浪费大部分请求去猜。”
Retry-After:服务端已经告诉了你何时重试
真实API的429响应通常包含retry-after头告诉客户端等待多久。Anthropic文档明确要求遵循该头。但llm-retry的LLMError根本没有这个字段。
在遵守retry-after的基础上叠加全抖动(避免客户端在同一整数秒返回),最后一个调用在11.8秒完成,发送313次请求,p95为9.1秒——每项指标都优于两种退避公式。但仍有213次浪费,因为retry-after是整数秒,80个客户端不可能都拿到下一个令牌。
Retry-After 是 HTTP 响应头(定义于 RFC 9110),用于在 429(Too Many Requests)或 503(Service Unavailable)等响应中告知客户端建议等待多久再重试。其值可以是秒数(如 Retry-After: 120)或HTTP 日期。LLM API 如 Anthropic 和 OpenAI 在限流响应中通常返回整数秒的 Retry-After,客户端应优先遵循此头,而不是自行猜测退避时间。但实践中存在两个陷阱:第一,Retry-After 可能精度较粗(整数秒),大量客户端仍可能在同一秒内返回,需要叠加随机扩散;第二,并非所有 429 都带 Retry-After——例如因月度支出上限触发的 429 可能没有该头,且会持续失败直到额度重置。因此,区分“带 Retry-After 的 429”(可重试)和“不带的 429”(可能需告警)至关重要。
真正的解法:在客户端提前限速
如果知道自己的限额(LLM API的限额就在控制台页面上),最便宜的429就是从未发出的那个。客户端限流器在请求离开进程前,按服务端的补充速率分配发送时隙:
struct ClientLimiter {
double rate, next = 100;
std::mutex m;
double reserve(double burst_until) {
std::lock_guard<std::mutex> lk(m);
double t = now_ms();
if (next < t) next = t;
double slot = next;
next += 1000.0 / rate;
return slot - t;
}
};
结果在三次运行中完全一致:100个全部成功、100次请求、0个429、最后一个答案在8.0秒——正好触及理论下限。重试循环仍然保留,用于处理未预测到的429(比如同一密钥上的其他进程),但在此测试中从未触发。
该重试什么(以及llm-retry搞错了什么)
重试策略一半是“等多久”,另一半是“值不值得重试”。作者用默认配置对每种错误类型运行了一次探针测试,发现三个bug:
- 529从不重试。Anthropic的
529 overloaded_error表示API因高流量暂时过载,正是退避机制存在的理由,却不在retry_on_http_codes默认列表中。408(请求超时)同样被遗漏。 - 自己的异常被重试。来自回调中JSON解析的
std::runtime_error落入catch (...),被无抖动重试3次,为一个必然重复失败的bug浪费1.5秒。 with_failover掩盖了错误请求。一个400错误(提示词过长、JSON格式错误)会静默返回备用模型的答案,bug变成了无人察觉的质量下降。
还有一个不该重试的429:Anthropic文档指出,由月度支出上限触发的429没有retry-after头,且会持续失败直到额度重置。“429无retry-after”和“429有retry-after”是不同情况,同等对待只会浪费尝试和用户时间。
熔断器的半开状态形同虚设
熔断器在连续失败后“打开”,冷却期后进入“半开”状态,本应只放一个测试调用通过。但llm-retry的实现没有这个限制。三个探针结果:
- 提供方仍然宕机,冷却结束后50个并发调用全部打到它上面,各自等待失败。
- 提供方完全恢复后,需要20次连续成功才切回关闭状态。
- 提供方30%概率抖动时,1000次调用后熔断器始终停留在半开,既不关闭也不重新打开,状态失去意义。
修复很小:半开时只允许一个试探调用,成功则关闭并清空计数器,失败则重新打开。修补后同样三个探针:50个调用中只有1个打到宕机服务(49个快速失败),1次成功即关闭,30%抖动的提供方保持关闭状态(1000次调用中284次失败,从未连续5次)。
熔断器模式(Circuit Breaker)是一种防止系统在故障时持续调用下游服务的容错机制。它有三种状态:关闭(正常调用)、打开(直接快速失败,不调用下游)、半开(允许有限数量的试探调用以探测下游是否恢复)。标准实现中,半开状态应只放行一个试探请求:若成功,则重置为关闭并清空失败计数;若失败,则重新打开并重置计时器。这可以避免在服务仍不可用时涌入大量请求。llm-retry 的缺陷在于半开状态未限制并发试探调用数量,导致冷却结束后所有等待的调用同时涌向尚未恢复的服务,失去熔断意义。正确的半开逻辑是熔断器有效性的关键,也是分布式系统韧性工程中的常见考点。
在你的项目中这样做
- 使用全抖动,而非±10%。
delay = random(0, min(cap, base * 2^attempt)),本次测试中免费将p95从16.1秒降到10.5秒。 - 读取并遵守retry-after,再加上一点随机扩散。它优于两种公式:11.8秒对比16.6和17.1秒。
- 无retry-after的429可能是支出上限,会持续失败直到重置。告警,而非循环。
- 重试408、409、429、5xx和529,绝不重试自己造成的4xx和自身异常。如果你的重试包装器里有
catch (...),它正在重试bug。 - 知道限额就提前限速。每个API密钥一个限流器,所有worker共享。这是唯一触及8.0秒下限、零浪费请求的策略。
- 为每个客户端设置重试预算。Google SRE手册使用3次尝试外加10%的重试预算,将负载增长从接近3倍降到1.1倍。
- 半开意味着一个试探调用。用50个并发调用测试你的熔断器——如果不止一个到达提供方,它就不是熔断器。
- 测试突发,而非偶发。作者库自带的示例(一个调用失败两次)通过了七个月,而100调用场景失败率高达64%。
数据与复现
全部代码是三个小型C++文件,使用MSVC 19.44编译(/std:c++17 /O2 /EHsc),在i7-13700KF上运行,时间戳为10月18日晚间。herd.cpp包含提供方、100个客户端和全部七种策略,每次运行生成 arrivals 和 results 两个CSV,共21次运行;probes.cpp和probes2.cpp分别测试未修改和修补后的熔断器。由于使用真实睡眠,整个测试约需3分钟。
一个重要的注意事项:这里的提供方是对文档算法的模拟,并非真实API,真实延迟会略微拉长所有数字。作者赌的是形态成立:固定延迟重试成波次到来,波次浪费容量,而提供方自己的提示优于你的猜测。
他留下的开放问题:有没有人在生产环境中为LLM调用运行共享的客户端限流器?当提供方调整限额时,你如何保持同步?
相关推荐

零成本搭建AI超级员工系统:智能体自动工作流全解析
深度拆解B站演示的AI超级员工系统:Agent智能体编排、数字人口播、地图与企业获客、24小时智能客服及法务模块全覆盖。分析零成本开户背后的商业逻辑与合规风险,帮助创业者判断这套AI数字员工系统的真实投产比。

Chatbot与AI Agent的本质区别:从对话到执行的跨越
Chatbot和AI Agent有什么区别?本文基于Learn Tech频道的MCP系列讲解,用订机票、笔记摘要等实例说明:Chatbot只给答案,Agent通过思考-行动-观察循环真正完成任务,并拆解模型、工具、记忆、目标四大部件及MCP的桥梁作用,同时讨论Agent的适用边界与权限风险。

免费GitHub仓库:342小时AI课程,从数学到MCP服务器
一个免费GitHub仓库提供342小时AI课程,包含523节课、20个阶段,覆盖数学基础到Agent构建和MCP服务器。支持目标驱动学习,每节课产出可留存的prompt、skill或agent,已有近10万人开始学习。