跳到主要内容

速率限制是设计约束,不是错误代码

· 阅读需 10 分钟
Tian Pan
Software Engineer

我认识的一个团队构建了一个带有智能体循环的金融助手。第一周,API 费用是 127 美元。第十一周,费用飙升至 47,000 美元——同样的系统,同样的功能,范围上没有任何有意的变化。智能体触及了速率限制,重试逻辑忠实地进行了重试,循环没有熔断器,成本在悄无声息中不断累积,直到有人注意到他们设置得太高的计费告警。

这不是一个 bug 的故事,而是一个架构的故事。团队的思维模型将速率限制视为需要被动处理的错误。他们构建的系统完全反映了这种模型。那 47,000 美元的那一周,正是系统按设计运行的结果。

"我的系统处理速率限制事件"与"我的系统被设计为在持续配额压力下正常运行"之间的差异不是语义上的。这是在 API 调用周围添加 try/except 与在设计时就决定当配额成为约束时系统该做什么之间的差异——因为在生产规模下,配额往往就是约束所在。

配额的多维本质

大多数工程师将速率限制视为单一维度:每分钟请求数。而实际上,每个主要的 LLM 提供商都同时在至少三个独立维度上执行限制:每分钟请求数(RPM)、每分钟 token 数(TPM)以及每天 token 数(TPD)。部分提供商——尤其是 Anthropic——将每分钟输入 token 数(ITPM)与每分钟输出 token 数(OTPM)分开,这很重要,因为对 100K token 上下文的缓存命中不会消耗任何 ITPM,但如果没有命中缓存,则会占用你大部分的每分钟预算。

实际影响是:优化一个维度可能会将你推入一个意想不到的不同限制。一个为保持在 RPM 限制以下而高效批处理请求的系统,在高流量时期可能会突破 TPM 限制。一个精心保持在 TPM 以下的系统,在持续负载下仍可能触及 RPD 限制。你需要同时追踪这三者,而不仅仅是上次触碰到的那一个。

速率限制还因层级不同而差异巨大。Anthropic 的 Tier 1(5 美元充值后)提供 50 RPM 和 12,500 ITPM——比 Tier 4 的 4,000 RPM 和 2,000,000 ITPM 低了一个数量级。OpenAI 的 Tier 5——需要 500,000 美元的累计消费——在其旗舰模型上允许每分钟 4000 万 token。初创公司能获得的访问权限与规模化公司能获得的访问权限之间的差距是巨大的。在开发阶段看起来没问题的架构选择,在生产环境中当层级上限降到组织实际达到的水平时可能会崩溃。

这对容量规划至关重要。在不知道你的实际配额上限的情况下,你无法设计队列深度、背压阈值或重试预算——而这个上限会随着你的消费层级变化而改变。配额不是固定参数;它是一个动态约束,你的架构需要将其作为一等变量来追踪。

在需要之前计算可持续吞吐量

"我的系统能维持多少负载?"这个问题需要在规划会议中给出答案,而不是在流量高峰期间。这个计算并不复杂,但需要在需要之前就完成。

对于基于 token 的限制,计算从每个请求的平均 token 成本开始。如果你平均每个请求使用 2,500 个输入 token 和 800 个输出 token,每次调用消耗 3,300 个 token。对于 100,000 TPM 的限制,理论最大值约为每分钟 30 个请求——大约 0.5 RPS。对于 1,000 的 RPM 限制,token 限制是绝对约束,差距很大。如果你的 RPM 限制是 50,token 限制可能根本不会起作用。

可持续吞吐量是在给定请求配置下所有限制维度中的最小值。对于请求大小变化的工作负载——不同用户发送差异很大的提示——你需要的是分布,而不是平均值。尾部很重要:如果你的 p99 请求是 10,000 个 token,单个大型请求可以在一次调用中消耗 100,000 TPM 预算的 10%,突然使你的有效可持续吞吐量远低于平均情况下的计算结果。

一个有用的经验法则:将额定限制的 70-80% 作为持续运营上限进行预算。留出剩余余量用于流量高峰、运行时间超出预期的请求,以及在追踪多秒滚动窗口时累积的测量误差。在额定配额 100% 下运行的系统,没有任何空间来应对轻微的意外。

优先级队列:决定什么先获得配额

一旦确定了吞吐量上限,就需要制定当接近上限时哪些工作优先运行的策略。

最朴素的方法是 FIFO:先到先得。对于异构工作负载,这几乎总是错误的。当用户在等待交互式响应而后台批处理任务正在运行时,它们不应该共享一个队列。批处理任务愿意等待是一种资源,你应该加以利用。

一个可行的生产模型至少使用三个层级。交互式请求——面向用户的聊天、实时补全——享有最高优先级,应该立即提供服务,以延迟作为主要 SLO。后台请求——内容生成、代码审查、文档摘要——可以忍受秒到分钟的排队,应该在交互式工作完成后安排。维护工作——评估运行、索引更新、监控探针——只有在队列为空或配额充裕时才运行。

目标不仅仅是公平性;而是负载卸载。当配额压力上升时,后台和维护工作应该是系统首先停止做的事情。如果你接近 TPM 上限,你不希望你的评估套件消耗等待用户所需的 token。优先级队列是明确而非意外地做出这个决定的机制。

截止时间提示是一个有用的补充。如果客户端请求有 5 秒的超时,而请求已经排队 4.8 秒,正确的行为是将其从队列中丢弃,而不是派发它——输出到达时将被客户端丢弃。这很容易实现(在每个队列条目中包含截止时间戳,在派发时检查),并防止你的推理容量被不产生任何价值的工作消耗。

突发-平滑权衡及其作为设计决策的本质

当你的工作量超过可持续配额允许的范围时,有两个选项:突破舒适的运营上限并接受由此产生的延迟,或者通过排队工作来平滑负载,以增加端到端延迟换取可预测性。

突破提供商的限制上限可以在短时间内实现最大吞吐量,但随着请求堆积并开始被拒绝或延迟,会产生不可预测的延迟。平滑——使用令牌桶或漏桶算法以稳定速率清空队列——以吞吐量换取稳定性。通过连续批处理,将 32 个请求分组在一起作为一个单元派发而不是单独处理,对于非交互式工作负载,你可以在延迟仅增加 20% 的情况下将每 token 成本降低约 85%。对于没有严格延迟 SLO 的批处理管道,这几乎总是正确的选择。

错误在于将其视为单一的全局决策。交互式和批处理工作负载有不同的权衡特性,应该分别管理。一个设计良好的系统以低并发、激进的延迟目标运行交互式工作,并以平滑队列运行批处理工作,以吞吐量作为优化目标。这是两个不同的控制循环,而不是一个。

在重试逻辑中添加抖动属于同一类别:这不是可选的。当 500 个客户端同时遇到速率限制并以相同的指数退避间隔重试时,它们全部在同一时刻重试,重新创造了导致速率限制的完全相同的流量高峰。没有抖动的固定间隔重试会将流量高峰放大 60-80%,而不是吸收它们。在每个重试实现中无一例外地添加随机化——完全抖动或去相关抖动。

熔断器和多提供商故障转移

速率限制不纯粹是配额管理问题,也是可靠性信号。持续收到 429 响应的系统不仅仅是超出配额;它可能处于部分降级状态,其中一些请求成功而另一些失败,结果不可预测。标准的熔断器逻辑——计数错误、跳闸到打开状态、在关闭之前用单个请求探测——直接适用于此。

LLM API 需要针对标准实现所忽略的两种故障模式进行调整的熔断器。第一种是持续的延迟升高:即使最终成功,耗时 90 秒完成的请求也消耗了你的上下文窗口和线程 90 秒。熔断器应该在 p95 延迟超过你的 SLO 时跳闸,而不仅仅是在错误率上。第二种是提供商容量耗尽,API 返回 200 但没有通常标志剩余配额的速率限制头——这种微妙的故障模式看起来像成功,却悄悄耗尽你的预算。

单一提供商架构存在仅靠速率限制处理无法解决的结构性可靠性问题。主要 LLM 提供商的 API 正常运行时间逐年下降,在过去 18 个月中,每个主要提供商都发生了多小时的中断。多提供商路由——为跨提供商的等效模型维护备用端点——将单一提供商中断从完全服务故障转变为短暂的路由决策。维护两个提供商集成的运营成本远低于在 4 小时提供商中断期间解释完全服务不可用的成本。

为配额而设计,而不是对抗它

改变架构决策的重新框架是:配额不是你的系统所抗争的障碍;而是你的系统围绕其设计的约束。

这意味着配额限制应该与延迟 SLO 和吞吐量要求一起出现在系统设计文档中,而不是出现在错误处理代码中。这意味着你的容量规划过程应该在上线之前计算最大可持续吞吐量,而不是在你第一次触及 429 之后。这意味着你的优先级队列和背压机制应该在你有用户之前就已就位,而不是在高峰期揭示你的 FIFO 队列没有区分交互式和批处理工作后被动添加。

做得好的团队将配额类比于数据库连接池大小或缓存内存:一种有上限的资源,他们的系统被明确设计为在其中运行。他们知道自己的上限,持续监控消耗,并对消耗曲线从 0% 到 100% 的每个点都有定义好的行为。

做得差的团队在流量高峰期发现他们真正的瓶颈,没有任何负载卸载行为可以依靠,然后遭遇那 47,000 美元的一周。两种结果都可以从数月前做出的架构选择中预测到。区别在于速率限制是被视为设计约束还是错误代码。

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