[控场AI]
· 11 分钟阅读· 5,600 字

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

指数退避加抖动仍失败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个调用冲击限流器

实验设置: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 的缺陷在于半开状态未限制并发试探调用数量,导致冷却结束后所有等待的调用同时涌向尚未恢复的服务,失去熔断意义。正确的半开逻辑是熔断器有效性的关键,也是分布式系统韧性工程中的常见考点。

在你的项目中这样做

  1. 使用全抖动,而非±10%。delay = random(0, min(cap, base * 2^attempt)),本次测试中免费将p95从16.1秒降到10.5秒。
  2. 读取并遵守retry-after,再加上一点随机扩散。它优于两种公式:11.8秒对比16.6和17.1秒。
  3. 无retry-after的429可能是支出上限,会持续失败直到重置。告警,而非循环。
  4. 重试408、409、429、5xx和529,绝不重试自己造成的4xx和自身异常。如果你的重试包装器里有catch (...),它正在重试bug。
  5. 知道限额就提前限速。每个API密钥一个限流器,所有worker共享。这是唯一触及8.0秒下限、零浪费请求的策略。
  6. 为每个客户端设置重试预算。Google SRE手册使用3次尝试外加10%的重试预算,将负载增长从接近3倍降到1.1倍。
  7. 半开意味着一个试探调用。用50个并发调用测试你的熔断器——如果不止一个到达提供方,它就不是熔断器。
  8. 测试突发,而非偶发。作者库自带的示例(一个调用失败两次)通过了七个月,而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调用运行共享的客户端限流器?当提供方调整限额时,你如何保持同步?

#指数退避#抖动算法#LLM API重试#限流器#Retry-After#熔断器模式#令牌桶算法#LLM调用可靠性
分享:

相关推荐