跳到主要内容

LLM 流水线中的背压:排队论在基于 Token 的服务中的应用

· 阅读需 13 分钟
Tian Pan
Software Engineer

凌晨 3 点的重试风暴通常以同样的方式开始:提供商的一次短暂抖动导致少数请求超过了速率限制,你的客户端库对此进行了重试,而这些重试落在了尚未恢复的端点上,导致更多请求失败;在 90 秒内,你的队列深度迅速飙升,而你的提供商仪表板显示你已经用满了 100% 的每分钟 Token 配额(TPM),由此产生的积压工作甚至可以用五位数的美元来衡量。事后分析会将其归结为“惊群效应(thundering herd)”。但诚实的回答是,你在一个容量多变的下游服务之上构建了一个固定吞吐量的重试策略,却忘记了排队论对此早有定论。

大多数知名的服务韧性模式是为那些吞吐量像一堵墙一样固定的下游服务设计的:例如带有连接池的数据库,或者具有已知并发限制的微服务。但 LLM 提供商并非如此。你的有效吞吐量是一个动态目标,受到你的服务层级、所选模型、Prompt 大小、响应大小、一天中的时间,以及同一提供商的其他用户是否正在微调前沿模型的影响。将它视为一根固定的管道,是我今年看到的多数 LLM 故障的根本原因。

本文将探讨如何将标准的排队论工具——利特尔法则(Little's Law)、隔板隔离(bulkheads)、准入控制和令牌桶背压——专门应用于 LLM 的工作负载,以及为什么大多数 SDK 示例中自带的简单重试逻辑与其说是韧性模式,不如说更像是拒绝服务(DoS)攻击工具。

你的提供商是一个容量变动的下游服务

首先看提供商实际暴露了什么。OpenAI、Anthropic 和主要的托管提供商都至少并行执行两个限制:每分钟请求数 (RPM) 和每分钟 Token 数 (TPM)。Anthropic 增加了第三个维度,将输入和输出 Token 分开,因此 Claude 的集成可能会根据它是 Prompt 密集型、生成密集型还是聊天平衡型,而触达三种不同的上限。这些限制都不是保证的;它们是随着容量、你的账户状态和提供商的整体负载而波动的滑动窗口软限制。

这与数据库连接池在两个重要方面有所不同。首先,约束条件在请求之间不断变化——一个长上下文的摘要请求可能会触发 TPM 限制,而高频的分类工作负载可能会触发 RPM 限制,即使它们的成本完全相同。其次,实际容量是部分可观测的:你只有在提供商返回 429 错误后,才知道自己超过了限制,这意味着你对“管道有多满”的本地视图总是滞后且被动的。你无法像查询连接池那样轮询“当前配额使用情况”的指标。

再加上 Token 长度变化的问题。最近关于使用 M/G/1 模型进行 LLM 推理的排队论研究表明,在重尾输出分布下,极少数异常长的响应可能会主导所有人的排队延迟。在你客户请求后的一个 8K Token 生成任务会阻塞其后任意数量的小请求。你的 p99 并不是由你的平均负载决定的,而是由你的流量在那个窗口期内恰好产生的最长输出来决定的。

这意味着“将下游视为固定管道并在失败时重试”的标准心理模型在现实面前无法维系。你需要针对一个动态目标来调整规模、进行调度和处理削峰填谷。

基于 Token 的服务的利特尔法则

利特尔法则 (Little's Law) 是分布式系统中最廉价的数学工具,它在这里同样适用。系统中处理中(in flight)请求的长期平均数量等于到达率乘以每个请求在系统中花费的平均时间:L = λW

对于 LLM 调用链路,带入实际数值:如果你的 Agent 端点每秒接收 50 个请求,且平均端到端延迟(排队 + 提供商调用 + 后处理)为 4 秒,那么平均有 200 个处理中的请求。这就是你调整工作线程池、连接池、内存缓冲区和提供商并发限制时需要参考的数字。如果你的工作线程池只能容纳 100 个并发的处理中请求,那么在稳态下你已经处于排队状态了——而稳态排队并不是稳定的,它是一种缓慢的累积,一旦延迟发生波动,情况就会急剧恶化。

更有用的应用是在另一个方向:利特尔法则告诉你延迟退化时会发生什么。如果提供商的 p50 从 2 秒翻倍至 4 秒(这在模型发布或区域负载偏移期间很常见),在到达率不变的情况下,你的处理中请求数也会翻倍。你系统中任何具有固定并发上限的部分(HTTP 服务器线程数、异步运行时任务槽、提供商客户端池大小)现在的运行负载系数都与一小时前完全不同,你以为拥有的缓冲已经悄然蒸发。

由此可以得出两条实用的规则:

  • 将每个并发限制设置为稳态利特尔法则数值的 2 倍,而不是 1.1 倍。LLM 延迟的方差足够大,以至于运行在临界线上意味着只要提供商出现一分钟的异常,你就会崩溃。
  • 将处理中请求数(in-flight number)作为一等指标进行追踪。大多数团队分别测量到达率和延迟,却从未计算过它们的乘积。将 λ × W 整合到单个仪表板中,并对其二阶导数设置告警——这是你的隔板隔离(bulkheads)即将失效的先行指标。

准入控制层

一旦你接受了下游服务是多变的,且处理中的请求数(in-flight count)随之波动的现实,接下来的问题就是如何在“大门口”进行处理。幼稚的系统会接收每一个请求,将其在内部排队,并让压力以延迟的形式传播。这种做法在出问题之前一直有效,而“出问题”往往来得很突然,因为面向用户的延迟会掩盖队列的增长,直到队列已经达到了病态的程度。

准入控制则反其道而行之。在请求进入处理流水线之前,你先决定系统是否能够吸收它。如果不能,你就明确且立即地拒绝——给出清晰的错误提示,理想情况下还带有 Retry-After 提示。被拒绝的请求变成了调用者的问题,这听起来很残酷,但实际上是唯一的稳定平衡。一个接收无法处理的请求的系统最终会导致所有请求失败;而一个拒绝无法处理的请求的系统则会牺牲部分请求以维持整体运行。

准入决策应该由你对本地处理中任务(in-flight work)的观察驱动,而不是由供应商的响应驱动。当你看到 429 错误时,你已经支付了往返延迟的代价,消耗了一个请求槽位,并在供应商的视角中为你的账户增加了一次失败调用。你的准入层应该在请求被允许尝试调用之前就将其拒绝。

一个合理的 LLM 端点准入流水线如下所示:

  • 根据 Prompt 长度加上最大输出假设,计算请求的预估 Token 成本。
  • 将预估值与令牌桶(token-bucket)预算进行比对,预算大小通常设定为供应商 TPM(每分钟 Token 数)的 80% 左右。
  • 如果桶内有空间,取走 Token 并允许请求通过。
  • 如果没有空间,返回一个结构化的拒绝响应,告知调用者需要等待多长时间。

令牌桶部分至关重要。供应商的速率限制是以 Token 为单位的,因此基于请求数的桶会默默地放行几个巨大的 Prompt,从而冲破你的 TPM 上限。桶的单位必须与你所保护的限制单位保持一致。

隔板模式与喧闹邻居问题

即使有了行之有效的准入层,你通常仍需要将容量切分成独立的池,而不是让所有任务共享同一个全局预算。隔板模式(Bulkhead pattern)借鉴自船舶设计,旨在防止一个工作负载击沉整个系统。

考虑一个典型的多功能应用:你有一个处于热路径上的聊天助手,一个后台摘要处理程序,以及一个运行计划内数据增强任务的内部 Agent。这三者都使用同一个 API 密钥调用同一个供应商。如果没有隔板,一个失控的批处理作业(例如某个内部用户启动了 50,000 份文档的重新摘要任务)就会耗尽整个账户的 TPM,导致你面向客户的聊天助手开始返回 429 错误。对延迟要求最严苛的、用户可见的功能往往会最先崩溃。

解决方法是给每个工作负载分配全局预算的一个专用切片。比如,聊天路径获得 60% 的 TPM 硬性预留;后台程序获得 30%,且仅在聊天路径未充分利用时才能突发(burst)使用到 50%;定时任务获得 10%,并运行在漏桶(leaky-bucket)调度上以平滑其负载。供应商并不会强制执行这些切片;你需要通过独立的令牌桶、独立的工作线程池和独立的优先级队列在本地进行强制执行。

这要求的纪律是将供应商配额视为内部的多租户资源,而非一个可以自由取用的池子。一旦你具有多个产品界面使用 LLM 调用,每个新功能都必须明确其容量需求,并获得分配给它的预算切片。这种平台化工作在 MVP(最小可行产品)阶段没人会规划,但到了第三季度,每个人都会被迫不得不做。

为什么幼稚的重试逻辑会破坏背压

大多数重试逻辑作为客户端 SDK 或 HTTP 封装库的功能提供,但对于 LLM 工作负载来说,这些逻辑几乎全都是错误的。默认策略——带抖动(jitter)的指数退避、三次尝试、每个请求固定预算——是为无状态的幂等请求设计的,这类请求的目标服务在调用方退避时能快速恢复吞吐量。但在 LLM 场景下,这两个假设都不成立。

第一种失效模式是重试放大(retry amplification)。如果你的 Agent 进行了一系列 5 次 LLM 调用,且每次调用都有 3 次尝试的重试策略,那么调用链中一次短暂的供应商波动,就会为一个用户可见的操作产生高达 3⁵ = 243 次请求尝试。其中的每一次尝试都要消耗 Token,占用一个 RPM 槽位,并延迟后续步骤。供应商 1% 的瞬时错误率就是这样演变成应用层 50% 的延迟退化和 5 倍成本激增的。

第二种失效模式是重试与背压冲突。当你的准入层正常工作,并以 Retry-After 拒绝请求时,客户端幼稚的重试逻辑会立即再次尝试,忽略提示或只是敷衍地遵守。这些重试行为本身就是你试图卸掉的负载。一个设计正确的重试策略必须服从于准入决策:如果系统说“我们满了,请 30 秒后再来”,客户端的任务就是等待至少那么长的时间(带上抖动),并将这段等待时间计入总请求预算中。

第三种失效模式是恢复期间的重试风暴。当供应商的服务降级结束时,每一个一直在退避的客户端都会在几乎相同的时间点回来。如果你的重试策略是指数级的且没有适当的抖动,你会看到一波同步的重试浪潮冲击刚恢复的供应商,导致恢复无法维持。真正的抖动——在整个退避窗口内均匀分布的随机延迟,而不是一个微小的加性扰动——是系统能否恢复还是遭遇二次停机的关键区别。

真正有效的重试策略结合了以下四点:

  • 单次调用的尝试次数上限,其设定应确保链式调用的最坏情况仍处于用户的延迟预算内。
  • 工作流中跨所有调用的单次会话(per-session)重试预算,防止 Agent 在重试上烧光所有 Token 配额。
  • 严格遵守 Retry-After,将服务器提供的值视为底线,而非建议。
  • 退避过程中的全窗口抖动,特别是在系统恢复场景下。

首先应该构建什么

如果你从一个完全没有这些机制的系统开始——即直接通过 SDK 默认的重试策略调用供应商,且没有准入控制(admission control)——那么构建顺序就至关重要。首先构建在途请求(in-flight)指标;没有它,你无法对其他任何东西进行规模估算。然后在供应商调用之前添加一个令牌桶(token-bucket)准入层,其大小设定在你 TPM 的 70-80%。接着,根据工作负载对桶进行分区。最后再修复重试策略,因为一个分区合理且带有准入层的系统在结构上就能消除大多数重试带来的病态问题。

值得带入这一切的心智模型是:你的 LLM 供应商不是一个服务,而是一个你正与陌生人共享的资源,而你唯一能完全控制的数字,就是你允许自己的应用程序尝试使用多少资源。其他的一切都属于排队论,以及对方差保持敬畏。

熬过第一次重大供应商事故的团队是通过惨痛教训学到这一点的。而熬过第二次事故的团队,则会在第三次发生之前构建好准入控制。

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