跳到主要内容

LLM 速率限制是一个分布式系统问题

· 阅读需 14 分钟
Tian Pan
Software Engineer

你的 AI 产品有两个功能面:一个面向用户的聊天功能和一个后台报告生成任务。两者在同一个 Key 下调用同一个 LLM API。一个下午,你收到了一张工单:“聊天回复在中途被截断了。”没有触发任何警报。日志中也没有 429 错误。API 在整个过程中一直返回 HTTP 200。

发生了什么:报告生成任务逐渐消耗了你大部分的共享 Token 配额。聊天请求虽然能完成,但仅达到了你的 max_tokens 限制——在语义上被截断,在语法上有效,却在无声无息中出错了。你的标准监控从未察觉到这一点,因为在 HTTP 层面上没有任何异常。

这并不是一种边缘情况。当工程师将 LLM 速率限制视为简单的节流问题,而不是意识到它们实际上属于分布式系统失效类别时,就会发生这种情况。

速率限制的表现类似于分布式锁

大多数团队持有的心理模型是:“我们达到了速率限制,请求收到 429 错误,我们退避并重试。”这个模型对于单租户、单工作负载的场景是准确的。一旦你有多个工作负载竞争共享配额,失效模式就会完全改变。

LLM 速率限制对所有使用同一个 Key 的调用者施加了共享容量限制。这种共享限制在功能上等同于对有限资源池的分布式锁。困扰分布式锁设计的那些失效模式在这里同样会出现:

饥饿 (Starvation) 发生在某个工作负载持续持有或消耗配额,导致其他工作负载无法取得进展时。一个针对 100 RPM 限制运行 50 个并行请求的批处理任务仅留下 50 个槽位——并且它会在每个请求完成后立即重新填满这些槽位。在那个窗口期到达的面向用户的聊天请求将无限期地等待一个永远不会空出的槽位。

队头阻塞 (Head-of-line blocking) 发生在队列排序方式过于简单时。如果你的请求队列在所有工作负载类型中都是先进先出的,那么排在队列前面的大型批处理任务将阻塞其后所有对延迟敏感的小型交互式请求。这些交互式请求并没有在处理中,它们正在等待批处理任务排空。

优先级反转 (Priority inversion) 是更微妙的失效模式。它发生在低优先级工作负载持有了高优先级工作负载所需的资源时。在 LLM 语境下:报告生成任务(低优先级、后台运行、无用户监督)正在使用交互式聊天(高优先级、用户正在主动等待)所需的配额。批处理任务通过时间占位获得了资源的“优先级”,而非通过任何显式策略。

使这种情况特别危险的是,这三种失效模式都可以在没有任何 429 错误的情况下显现。只有当你的整个共享池耗尽时,你才会看到 429 错误。饥饿、队头阻塞和优先级反转可能会降低你高优先级流程的质量,而你的低优先级流程却运行良好——并且你的错误率仪表盘保持洁净。

排队论可以预测系统何时崩溃

理解这些动态的数学框架是排队论。一个简化的模型——M/M/1 队列(到达时间和处理时间均呈指数分布,且只有一个服务器)——清晰地展示了利用率与延迟之间的关系。

关键变量是利用率:ρ = λ/μ,其中 λ 是你的请求到达率,μ 是你的服务速率(API 处理请求的速度)。稳定性要求是 ρ < 1。数学表明,随着利用率接近 1,延迟并不是线性增加的,而是呈非线性爆炸式增长:

  • 在 50% 的利用率下,排队带来的延迟开销极小。
  • 在 70% 时,延迟开始明显爬升。
  • 在 85% 时,你已进入严重的性能退化区域。
  • 在 95% 时,系统对于延迟敏感型工作负载来说实际上已不可用。

LLM 基础设施的行业标准目标是 60–70% 的利用率。这种保守并非浪费——它是吸收流量峰值、重试风暴和故障转移事件的缓冲,防止它们级联成指数级的延迟增长。

利特尔法则 (Little's Law) 强化了这一点。基本关系是 L = λW:平均处理中的请求数等于吞吐量乘以平均响应时间。实际含义是,延迟和并发是耦合的。如果供应商的响应速度减慢一倍,导致你的平均响应时间增加一倍,那么你的并发请求数也会增加一倍,即使你的到达率没有变化。你可能会因为延迟退化而触及并发限制 (RPM),而不是并发限制导致了延迟退化——在亲眼目睹这种级联反应之前,这往往是不符合直觉的。

LLM 请求使标准的 M/M/1 分析变得复杂,因为服务时间不具备无记忆性。生成 20 个 Token 的补全所花费的时间远少于生成 2000 个 Token。你的配额消耗在 Token 数和延迟上都是可变的,这使得系统更接近 M/G/1 队列(通用服务分布)。实际的经验总结:请使用经验百分位分布(而非平均值)来建模你的 LLM 调用,并针对 P95 或 P99 服务时间来规划你的冗余空间。

你未在监控的隐性降级

大多数团队遗漏的失败模式不是 429 错误 —— 而是带有截断响应的 200 状态码。

当 Token 配额压力增大时,单个请求并不会直接失败。它们会成功返回,但在模型达到自然停止点之前,max_tokens 约束就已生效。响应在语法上是有效的。它能通过 JSON schema 验证。它返回 HTTP 200。但内容在句中、列表项中或代码块中途被任意截断了。

标准监控无法捕获这一点,因为在 HTTP 层没有任何异常可供测量。你需要不同的信号:

  • 响应完成率 (Response completion rate):追踪命中 max_tokens 边界的响应百分比(通过检查响应元数据中的 finish_reason == "length")。该指标的激增意味着配额压力正在迫使响应提前中断。
  • 按工作负载的 Token 计数分布:如果你的聊天请求通常产生 200–400 个输出 Token,但突然出现大量刚好处于 max_tokens 上限的请求,那是配额压力所致,而非用户行为改变。
  • 分层级分别计算 P99 延迟:不要将交互式和批处理的延迟平均在一起。聚合后的 P99 可能看起来正常,但交互式 P99 可能已经翻倍,因为批处理作业速度快且数量庞大。
  • 语义验证探测:对于关键路径,定期验证响应在语义上是否完整,而不仅仅是语法正确。

最后一点的系统性实现成本很高,这就是为什么前三个指标是实际的基准线。

公平调度需要显式架构

解决资源饥饿(starvation)和优先级反转(priority inversion)的方法不是监控 —— 而是配额分区(quota partitioning)结合显式优先级调度。降级的响应式检测对于交互式工作负载来说太慢了;当你注意到截断率爬升时,你的用户早已察觉到了。

配额分区意味着将你的 TPM/RPM 总预算划分为每个工作负载层级的独立预留,而不是让所有工作负载共用一个资源池:

总预算:1,000,000 TPM
├── P0 — 面向用户的交互式:400,000 TPM(保证)
├── P1 — 异步产品功能:300,000 TPM(保证)
└── P2 — 批处理作业:300,000 TPM(机会主义,不能消耗 P0/P1)

至关重要的一点是:P2 应该是机会主义的 —— 它可以利用 P0 和 P1 的闲置容量,但绝不能消耗它们的预留配额。这可以防止批处理作业在接近容量极限时导致交互式请求发生饥饿。

优先级队列在请求到达提供商之前,在你自己的请求队列中处理调度。当你跨层级积压请求时,先入先出 (FIFO) 队列会保证排在前面的大型批处理作业会延迟其后的所有交互式请求。你需要一个多级队列,优先排空高优先级层级。

加权公平队列 (WFQ) 是处理此问题的经典算法。在同一个优先级层级内,WFQ 根据权重按比例分配容量 —— 防止单个高权重租户或功能导致同一优先级的其他租户饥饿。OpenAI 的 Priority Processing 功能在提供商端应用了这一机制的变体,但关键限制在于优先级层级和标准层级共享相同的配额。优先级访问并不增加容量;它只是重新分配现有容量。你仍然需要配额分区来防止跨工作负载层级的饥饿。

真正有效的背压模式

一旦建立了配额分区和优先级队列,你就需要重试和背压 (backpressure) 逻辑来处理配额确实超限的情况。

带有抖动 (jitter) 的指数退避是处理 429 错误的强制性模式。单纯的指数退避会产生惊群效应:所有同时遇到频率限制的客户端都会在同一时间唤醒并同时发送请求,立即重现频率限制峰值。抖动将各客户端的重试时间随机化:

  • 完全抖动 (Full jitter)sleep = random(0, min(cap, base * 2^attempt)) —— 激进的随机化,平均等待时间最短,最适合高竞争场景。
  • 去相关抖动 (Decorrelated jitter)sleep = random(base, last_sleep * 3) —— 增加了相对于前一次休眠的可变性,是一个很好的折中方案。

AWS 的基准测试表明,与同步指数退避相比,带有抖动的退避可减少 50–80% 的系统总重试量。原因很简单:去同步化的重试将负载分散在恢复窗口内,而不是集中在窗口开始。

重试预算 (Retry budgets) 防止重试风暴演变为永久性的负载放大。将重试次数限制在总请求量的 10% 左右。如果你重试的比例超过这个值,说明你处理的不是瞬时峰值,而是持续的资源短缺,此时正确的反应是舍弃负载 (load shedding),而不是加大重试力度。绝不要对非 429 的 4xx 错误进行重试,并且当提供商提供 Retry-After 标头时,始终遵守该指令。

熔断器 (Circuit breakers) 在 LLM 客户端层防止提供商性能降级时发生级联故障。熔断器模式是调用外部服务的标准做法:在达到失败阈值后,打开电路并在本地快速失败,而不是让请求在一个不会响应的端点前排队。恢复探测 —— 定期发送单个请求以检查提供商是否已恢复 —— 应与你的 Retry-After 窗口保持一致。

对于 AI 特有的熔断,应将标准的失败条件扩展到 HTTP 错误之外。追踪异常的 Token 成本增长(失控的 Agent 循环在表现为延迟峰值之前,会先表现为成本峰值)以及语义验证失败(模型返回结构正确但语义错误的输出不是 HTTP 错误 —— 它是质量熔断触发点)。

你可能正在推迟的架构决策

大多数团队直到出问题时才会处理这件事。常见的序列是:在所有工作负载(workloads)中共享一个 API 密钥启动,观察到难以复现的间歇性性能下降,将其归因于“API 不稳定”,最终发生一次严重事故,即批处理作业搞垮了交互式体验,然后才开始事后补救。

事后补救总是比预先构建更痛苦,因为你现在必须从假设共享访问的代码中解开配额分配。架构决策其实很直接:

  1. 按工作负载层级分离 API 密钥。 提供商的速率限制(rate limits)通常是按密钥计算的。分离密钥能以零工程成本为你提供硬性的配额隔离 —— 提供商会为你强制执行分区。折衷之处在于,你失去了在不同层级之间借用闲置容量的能力。

  2. 具有按层级配额跟踪功能的共享网关。 在 LLM 调用前放置一个反向代理,可以让你在一个地方实现优先级队列、配额分区和按工作负载的可观测性。像 Portkey 或 TrueFoundry 这样的工具实现了这一点;一个相对简单的自定义服务也可以。当你需要动态配额借用和详细的成本分摊时,这是正确的选择。

  3. 带溢出池的分离 API 密钥。 混合方法:使用独立密钥为每个层级预留基准配额,并将溢出流量路由到具有严格优先级门控的共享密钥。操作上较复杂,但能最大化利用率。

这三种方法中常见的错误是将配额分配视为一劳永逸的决策。随着产品的演进 —— 新功能、新模型、更大的上下文窗口、增加的批处理作业频率 —— Token 消耗模式也会发生变化。配额分配需要像任何其他基础设施资源的容量规划一样,拥有相同的评审节奏。

在需要之前应该构建什么

生产级 AI 产品的最小可行实现:

  • 为交互式(面向用户)和批处理工作负载使用独立的 API 密钥。这是杠杆率最高的变化 —— 它通过结构设计让资源饥饿(starvation)变得不可能。
  • finish_reason == "length" 作为一个生产指标进行跟踪。设置告警阈值。这是你在用户注意到内容截断之前,感知配额压力的早期预警。
  • 为每个 LLM 客户端添加带有全抖动(full jitter)的指数退避。这是基本功;如果你没有它,你就会把每一次速率限制事件放大成一场重试风暴。
  • 为交互式与批处理工作负载设置独立的 P99 延迟 SLI。永远不要在告警或仪表板中将它们聚合在一起。

更完整的实现会增加一个具有显式优先级队列和按工作负载配额计费的网关层。当你有多个团队或功能共享同一个配额池、需要按功能进行成本分摊,或者你的工作负载组合足够多样化以至于投机性配额借用变得重要时,这就是正确的投资。

LLM 提供商正在快速构建容量,但配额压力是你构建使用方式的产物,而不仅仅是你拥有多少配额。更多的配额无法修复批处理作业可能导致交互流饥饿的系统 —— 它只是推迟了崩溃点。

好消息是:这在分布式系统中是一个已解决的问题。这些模式 —— 公平队列、优先级调度、背压传播、熔断 —— 已经存在了几十年。工作重点在于意识到你面临的是一个分布式系统问题,而不是 API 配额问题。

References:Let's stay in touch and Follow me for more thoughts and updates