跳到主要内容

供应商速率限制是你从未编写过的容量计划

· 阅读需 10 分钟
Tian Pan
Software Engineer

当你的应用程序第一次从模型供应商那里收到 429 错误时,发生了一些重要的事情,但几乎没人注意到。并不是错误本身,而是接下来执行的那行代码。也许你的 HTTP 客户端会以指数退避进行重试。也许它会降级到更小的模型。也许它会将请求排队,或者直接丢弃,又或者显示一个永远无法解决的加载动画。无论它做什么,这种行为现在就是你的容量策略。它决定了当供不应求时,哪些用户能获得服务,哪些用户的体验会降级。

而且,几乎可以肯定你并没有亲自制定过这个策略。它是由编写 SDK 封装的人、重试装饰器,或者是某人从教程中复制的三行 try/except 代码决定的。在负载下,你的系统中最重要的决策——当无法兼顾所有任务时该怎么办——正由一段没人审视过的代码作为容量决策来执行。

这篇文章的观点是,你应该把这段代码视为它的真实面貌:一个负载削减策略和一个容量计划,而不是一个错误处理器。429 并不是问题所在。问题在于你已经将系统在资源竞争下的行为设计,外包给了库的默认设置。

429 是容量信号,而非错误

大多数团队将频率限制错误与超时和 500 错误归为一类:瞬时故障,重试然后继续。这种定性是最初的错误。超时意味着某些东西坏了。而 429 意味着什么都没坏——系统正在精准且有目的地告诉你,你要求的资源超过了分配给你的额度。它不是一个失败。它是一个已传达的容量边界。

供应商在两个独立变动的维度上展示这一边界。RPM(每分钟请求数)限制了你发出的调用次数。TPM(每分钟 Token 数)限制了你消耗的算力。包含许多细小提示词的工作负载会首先触及 RPM 上限;而少量巨大的提示词工作负载会首先触及 TPM。将“频率限制”视为一个单一的数字,是你还没有为自己的容量建模的第一个迹象——供应商已经完成了建模并将结果交给了你,而 429 就是那张账单。

一旦你将 429 视为信号而非故障,问题就变了。它不再是“我如何让这个错误消失”,而是“需求刚刚超过了供给——我该服务谁,又该让谁等待?”这是一个容量规划问题。每个调用模型 API 的系统都会回答这个问题。大多数系统只是无意中做出了回答。

你的重试封装是伪装的负载削减器

负载削减(Load shedding)是一种当系统超出容量时,有意识地丢弃或降级工作任务的准则,目的是确保你保留下来的任务能够保持健康。基础架构团队已经实践了几十年:当你无法服务所有请求时,你会深思熟虑地选择去服务哪些。

你的重试封装也在做负载削减,只不过它在选择时漫无目的。看看通用的退避重试机制实际上在代表你做出了哪些决定:

  • 一个重试的请求在等待时会占用工作线程、连接和内存。在持续收到 429 的情况下,你的重试队列现在正在与新鲜请求竞争相同的有限资源。
  • 一个使用指数退避重试五次的请求可能需要 30 秒或更长时间才会最终失败。它背后的用户已经被默默地划入了你的最差延迟层级。
  • 降级到更小的模型是一种质量缩减。你刚刚决定这个用户会得到一个较差的答案——而且你对所有人都是一视同仁的,无论他们是正在结账还是在运行后台摘要任务。

None of those decisions are wrong in isolation. They are wrong because nobody decided them. The wrapper treats a paying customer's interactive request and a nightly batch job as identical, because it has no concept that they differ. It sheds load — it just sheds it blind. 这些决定孤立来看都不是错误的。它们之所以错误,是因为没有人主动决定它们。封装程序将付费客户的交互式请求和每晚的批处理任务视为等同,因为它没有区分它们的概念。它削减了负载——只是这种削减是盲目的。

你默认交付的“惊群效应”

默认的重试行为还有第二种失效模式,它比盲目性更糟糕:它会主动延长它本该挺过的故障时间。

当需求激增且一波请求收到 429 时,每个使用相同固定重试间隔——甚至是相同且无抖动的指数调度——的客户端都会在同一时刻重试。原本暂时超出容量的供应商,现在收到了一个经过精确同步的脉冲式爆发,其目的恰恰是让它继续保持在容量上限之上。客户端同步退避,再次同步重试。这就是惊群效应(thundering herd),在由相同智能体(agent)实例组成的集群中,这并不是极端情况。它是预期行为,因为相同的代码会产生相同的时间节拍。

解决办法众所周知但仍常被忽略:抖动(jitter)。全抖动(Full jitter)——在零到计算出的退避上限之间随机休眠一段时间——使集群去同步化,从而让重试分布在恢复窗口内,而不是堆叠在一起。关于这种模式的案例研究始终表明,抖动能显著减少重试引发的资源竞争。此外还有两条规则:遵循供应商发送的 Retry-After 头部信息,那是供应商明确告诉你容量何时恢复,而猜测退避时间则注定会出错;并将重试次数限制在 3 到 5 次,因为 5 次重试和无限重试之间的差距,就是削减一个请求与永远助长惊群效应之间的差距。

请注意,这些其实并不是重试策略。它们是容量策略。抖动是你避免制造自身需求高峰的方法。重试次数上限是你决定一个请求是否值得占用其份额的受限资源的方法。你正在进行容量规划。你只是每次只编写三行代码。

优先级属于请求,而非封装

一个真正的容量规划始于承认并非所有请求都是平等的,然后在系统中明确这种不平等。

这种模式是在请求创建时为其标记优先级,因为此时才具备判断重要性的上下文。一个实用的默认层级如下:

  • P0 —— 交互式、关系营收。 用户正在等待,且该请求处于转化路径或工单解决的关键路径上。
  • P1 —— 交互式、非关键。 用户正在等待,但可以忍受延迟或质量下降的答案。
  • P2 —— 延迟执行。 由用户触发但不被即时等待:异步增强、通知草稿等。
  • P3 —— 后台任务。 批处理作业、预计算、评估(evals)。没有用户参与,在一分钟内没有截止日期。

有了这些标签,系统在面对 429 错误时的行为就不再是一刀切。P3 任务首先让路——按理说,它们根本不应该与交互式流量竞争有限的配额,而且非常适合使用供应商提供的折扣批处理层级(batch tier)。P2 任务进入队列。P1 任务可以采用较小模型的降级方案,因为一个略差的答案总比一个加载动画要好。P0 任务则获得重试机会、保持原有的质量并排在队列最前端。当你达到 TPM 上限的 95% 时,你并不是没有容量处理结账流程了——你只是没有容量处理每晚的摘要任务。只有能够识别这种区别的系统才能据此采取行动。

这也是概率性丢弃(probabilistic shedding)发挥作用的地方。与其采用硬性切断,不如随着负载接近上限,逐渐增加丢弃低优先级流量的比例——丢弃率根据 (current_load − threshold) / (capacity − threshold) 进行缩放。其结果是一个在压力下平滑弯曲而不是直接崩断的系统。

将配额视为带有调度器的资源

最深刻的观念转变在于:你的供应商配额是一种固定的、共享的、存在竞争的资源。你已经知道如何称呼那个在竞争者之间分配固定、共享、竞争资源的组件了,它叫调度器(scheduler)。而你现在几乎可以肯定拥有的是一个异常处理器(exception handler)。

异常处理器是被动的且局部的。它看到一个失败的请求,然后决定如何处理这一个请求,而对其他正在进行的请求一无所知。调度器是主动的且全局的。它知道你每分钟的 RPM 和 TPM 预算,知道队列中有什么以及它们的优先级,并在发送工作之前根据预算准入工作——这样供应商的 429 就变成了最后的防线,而不是你的主要控制手段。

具体来说,调度器架构的客户端可以做到异常处理器无法做到的几件事:

  • 在发送前预估 Token 成本,从而可以根据 TPM 剩余空间进行推理,而不是通过撞到上限来发现它。
  • 维护优先级队列,因此当预算紧张时,准入顺序是有意为之的,而不是先到先得。
  • 集中跟踪预算,让十个 Agent 实例共享一个剩余配额视图,而不是每个实例独立地去重新发现限制。
  • 公开其决策,使得“我们在九分钟内丢弃了 12% 的 P3 流量”成为仪表板上的折线图,而不是一个未解之谜。

你不需要在第一天就从头开始构建一个分布式调度器。Redis 中的一个共享令牌桶(token bucket)和请求队列上的一个优先级字段就能解决大部分问题。重点不在于机制的复杂性。重点在于有人(一个能够思考哪些请求更重要的人)制定了策略,而不是直接继承了库的默认设置。

在上限替你制定计划之前,先制定好计划

每个调用模型 API 的系统都有容量规划。唯一的问题是这个规划是否是你有意制定的。如果你在不阅读重试装饰器源代码的情况下,无法回答“当我们达到 TPM 上限时,P0 请求会发生什么,这与批处理作业的处理有何不同”,那么答案就是“没有”——你现有的计划只是库作者碰巧选择的任何方案。

找到你的技术栈中处理 429 错误的地方。把它当成一份容量策略来阅读,因为它本质上就是策略。问问它是否区分了客户和定时任务,是否加入了抖动(jitter),是否遵循了 Retry-After,以及事后是否能告诉你它丢弃了什么以及原因。如果它做不到这些,你并没有省去容量规划的工作。你只是把这项工作交给了那个写下 except RateLimitError: sleep(1) 的人——而他们当时并没有在考虑你的用户。

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