速率限制 背压 可靠性 LLM API

扛得住的 LLM API 速率限制与背压

如何读取 LLM 速率限制响应头,用退避加抖动吸收 429,并通过队列与并发上限施加客户端背压,让系统在过载时优雅泄流而非崩溃。

AveMujica API 2 分钟阅读

当 LLM 提供商对你限流时,它会返回 HTTP 429。而最本能的反应——立刻重试——只会火上浇油,因为失败的请求同样会计入你每分钟的额度。真正持久的解法是两层机制协同工作:读取提供商返回的速率限制响应头,从而知道自己还剩多少预算;同时在你这一侧施加背压——有界队列、并发上限、带抖动的退避——这样当需求超过供给时,你的系统会主动放慢,而不是彻底熔毁。本文覆盖这两层,以及实现它们的代码。

速率限制不是一个需要遮掩的错误状态,它是一个容量信号。把它当作信号来处理的系统会以可预测的方式降级;忽视它的系统则会在猛捶提供商和丢弃用户请求之间来回震荡。

调优之前,先读响应头

OpenAI 会在每个响应上通过可直接检视的响应头返回速率限制状态。基于请求数的一组是:

  • x-ratelimit-limit-requests —— 窗口内请求数的上限
  • x-ratelimit-remaining-requests —— 你还剩多少
  • x-ratelimit-reset-requests —— 距离请求窗口重置的时间

还有一组与之平行、基于 token 的响应头——x-ratelimit-limit-tokens、x-ratelimit-remaining-tokens、x-ratelimit-reset-tokens——以及诸如 x-ratelimit-remaining-project-tokens 这样按项目维度划分的变体。大多数真实的限制卡在 token 上,而非请求数,因为单次长上下文调用就可能吃掉你 token 预算的一大块。在每个响应上记录这些响应头,你就能在限流演变成一堵 429 之墙之前提前看到它逼近:

resp = client.chat.completions.with_raw_response.create(
    model="gpt-5.6",
    messages=[{"role": "user", "content": prompt}],
)
h = resp.headers
print(h.get("x-ratelimit-remaining-requests"), h.get("x-ratelimit-remaining-tokens"))

记录这些响应头是运维工作的基础:只要逐个响应保存 x-ratelimit-remaining-requests 和 x-ratelimit-remaining-tokens,你就能在 429 出现之前看出哪个上游正在逼近上限。网关很适合集中记录这些数据,因为所有响应本就会经过它;但按渠道绘制剩余预算曲线,需要你基于原始响应头自行构建,并不是响应会直接给出的预计算指标。

用指数退避加抖动重试 429

当 429 真的到来时,在重试前先退避。指数退避在每次失败后成倍拉长等待时间;而抖动会将其随机化,这样一整队客户端就不会在同一时刻一起重试、再次相撞——那种惊群效应会重建你正在恢复的过载。

import random, time

def backoff_sleep(attempt, base=0.5, cap=20.0):
    # full jitter: random point in [0, min(cap, base * 2**attempt))
    ceiling = min(cap, base * (2 ** attempt))
    time.sleep(random.uniform(0, ceiling))

for attempt in range(6):
    r = call_model(prompt)
    if r.status_code != 429:
        break
    backoff_sleep(attempt)

两条规则能让它保持诚实。其一,为延迟和尝试次数都设上限——无限重试只是把故障推向下游。其二,只重试可重试的东西:429 和瞬时的 5xx/超时值得重试;而请求格式错误导致的 400 每次都会以相同方式失败。由于被重试的写操作可能重复做功,请把重试与幂等性配对——完整处理见 LLM API 重试与幂等性。

如果响应中带有 Retry-After 头,优先采用它而非你算出的退避时间——服务器正明确告诉你窗口何时重新打开,遵守这个值既能更快恢复,也能避免浪费那些注定失败的尝试。把你的指数退避方案当作服务器未给出明确提示时的兜底。

背压:放慢源头,别丢掉底线

重试处理的是单次失败的调用。背压处理的则是这样一种情形:你的进料速率干脆就超过了提供商愿意接受的量。这个核心思想来自排队论:在生产者和提供商之间放一个有界缓冲区,当缓冲区满时,让生产者等待,而不是接下你无法服务的无界工作量。

三种机制组合成一套背压策略:

机制它约束的对象它避免的失效模式
并发限制(信号量)在途请求打开数千个全都返回 429 的套接字
有界队列等待开始的工作尖峰下内存无界增长
准入控制 / 负载泄流新接受的工作接下你永远来不及服务的请求

并发限制是杠杆率最高、也最容易加上的一项改动。给同时在途的调用设上限,让你去匹配而非超过提供商的容忍度:

import asyncio

sem = asyncio.Semaphore(20)  # never more than 20 concurrent upstream calls

async def guarded_call(prompt):
    async with sem:
        return await call_model_async(prompt)

当信号量已满时,新来的调用者会等待——这份等待就是背压。它把约束沿路传回源头,而不是任其在提供商边界处炸开。

队列满时,有意识地泄流

在持续尖峰中,有界队列终会被填满。那时会发生什么,是一个产品决策,而把它显式化正是全部意义所在:

  • 快速拒绝 —— 返回带 Retry-After 的 503,让调用者稍后重试。最适合交互式流量,那里一个缓慢的答复比一句明确的"稍后再试"更糟。
  • 按优先级泄流 —— 丢弃或延后低优先级工作(批处理、后台),让交互式请求继续流动。
  • 降级 —— 把溢出的流量路由到一个更便宜、更快、更可能有余量的模型。

反模式是一个无界队列,它照单全收,悄悄拉高延迟,直到请求反正还是超时。这会把一个可见、可恢复的拒绝,变成一次不可见、全系统的停滞。给队列设界,并决定"掉出队尾"意味着什么。

网关在何处集中承载策略

在每个服务里各自实现的背压会逐渐漂移——每个服务都会选择不同的限制,而且这些限制之间往往相互干扰。网关是执行一套连贯策略的天然场所,因为所有流量本就从它经过。在本项目中,提供商连接以渠道形式注册,每个渠道都有自己的额度与优先级;当某个渠道开始限流时,网关已经会按 LLM API 故障转移策略 切换到健康渠道,并记录每个请求的用量,供你据此调优。更丰富的准入机制,则需要继续构建在同一个流量汇聚点上:

  • 持久化响应头,追踪每个渠道剩余的 token 与请求预算,
  • 施加按渠道的并发限制,让一个高负载渠道不至于饿死其他渠道,
  • 维持一份聚合重试预算,避免恢复流量放大故障。

请把这三项视为需要在网关实现的设计,而不是默认随产品提供的开关;网关的价值就在于,你只需在一个地方加入这些机制。集中化之所以重要,有一个很具体的原因:如果十个服务各自独立地对 429 重试,它们合起来的重试流量本身就可能超过你的限制,只有一份共享的重试预算才能防止这种自伤式放大。

一套分层防御,按影响力排序

按顺序施行这些措施;每一项都为下一项争取时间:

  1. 度量 —— 记录速率限制响应头,让限流尽早可见。
  2. 限制并发 —— 用一个信号量去匹配提供商的容忍度。
  3. 给队列设界 —— 一个固定缓冲区,永不无界。
  4. 带抖动退避 —— 对 429/5xx 退避,对 4xx 客户端错误则绝不退避重试。
  5. 泄流或降级 —— 为满队列制定一条显式策略。
  6. 分散负载 —— 多个渠道加故障转移,以抬高有效上限。

前两项改动通常就能消除大部分 429 之痛。其余几项则把剩下的尖峰变成优雅的减速,而非停机。

给 token 编预算,而不只是给请求

由于 token 限制通常先于请求限制触顶,请在发送之前预先估算一次调用的 token 成本,并据此预留预算。一次请求会同时消耗输入 token(你的提示词,加上任何串联的历史)和输出 token(受 max_tokens/max_output_tokens 约束)。如果你只按请求数来准入,一波长上下文调用就能在你的请求计数器看起来还很健康时冲破 token 上限——而那些调用会一个接一个地返回 429。

一条实用的准入规则是"token 感知的闸门":从 x-ratelimit-remaining-tokens 追踪剩余 token 预算,估算每个待处理调用的最坏情况 token 数,只准入其估算能塞进剩余余量的调用。这就是令牌桶思想应用到 LLM 流量上——在重置间隔补充、在准入时花费、当桶位偏低时让调用者等待。

  • 把 max_tokens/max_output_tokens 压到你实际预期的大小;过大的上限会预留你永远用不上的预算,饿死其他调用。
  • 计入串联历史:在有存储的会话状态下,先前的输入 token 仍按输入 token 计费,因此一条长线程每轮的成本比看上去要高。
  • 当单次请求会一次性预留 token 窗口的一大块时,宁可用多次小调用而非一次巨型调用。

把 token 估算调到合适的大小,往往比调优并发是更大的杠杆,因为它让那些会返回 429 的调用根本不被准入。成本与容量在这里是同一个旋钮,这也是它与降低 LLM API 成本相连的原因。

常见问题

哪个状态码意味着我被限流了,我该重试它吗?

HTTP 429。它可重试,但只能带退避和抖动,绝不能立刻重试——失败的请求仍会计入你每分钟的额度,因此紧凑的重试循环会加深限流。给尝试次数和延迟都设上限。

我实际会撞上的,是请求限制还是 token 限制?

通常是 token 限制。检查 x-ratelimit-remaining-tokens,并与 x-ratelimit-remaining-requests 对照。带长提示词或大 max_tokens 的工作负载会在几乎不触碰请求计数的情况下耗尽 token 预算,因此只按请求数编预算会误导你。

提高并发上限能提升吞吐量吗?

只在提供商容忍度之内有效。越过那个点,额外的并发会带来更多 429、更多重试,以及更低的有效吞吐量。把信号量调到 429 率仍接近零的最大值,然后就停。

更大的队列是持续过载的解药吗?

不是。更大的队列只会推迟真相时刻,并在它被填满的过程中拉高尾延迟。如果进料在一段持续时间内超过容量,没有哪个缓冲区能救你——你需要负载泄流、更高的速率限制,或更多渠道。队列吸收尖峰,而非趋势。

先从一个信号量和响应头日志开始

在模型调用外围加上一个并发信号量,并开始为每个响应记录 x-ratelimit-remaining-tokens。用一天时间把这些记录值与 429 对照成图:如果剩余 token 的下探与错误相吻合,就调低信号量,直到这种下探停止。这一条反馈回路,就能在你构建任何更复杂的机制之前消除大部分速率限制事故。

参考资料