大模型 API 报 429 和超时,表面看是网络问题,实际上最考验的是调用层的限流、重试与降级设计。夜里两点监控飘红,几百个线程同时卡在等待和重试上,最后整个服务一起雪崩,这种场面我见过不止一次。这篇文章不聊大道理,直接讲我在项目里怎么定义可重试范围、怎么实现令牌桶、怎么加抖动的指数退避,以及熔断器与降级链路的完整写法。道理讲透,代码可直接抄,参数给模板。适合所有正在把大模型接入生产环境的后端开发者。
1. 先搞明白:429 和超时到底是怎么发生的
1.1 429 状态码的真实含义,以及比“限流”更隐蔽的坑
429 的完整名字是 Too Many Requests,但它背后的限制维度远不止“请求次数”这一个。常见的大模型服务商配额体系通常包含 RPM、TPM 和并发数。RPM 最好理解,一分钟最多请求多少次;TPM 则是一分钟之内所有请求消耗的 token 总量。很多人的误区是只看 RPM,觉得我每分钟才打二十次,怎么会 429?实际上如果 prompt 很长,一次请求就吃掉一万多 token,TPM 配额可能瞬间就打满了。所以排查 429 时,先翻一下服务端返回的 quota 信息和响应头,而不是盲目调低 QPS。
还有一个隐蔽的地方:部分网关在高负载时并不会按文档返回 429,而是返回 503 或 504。这意味着你的重试逻辑如果不覆盖 5xx,只盯着 429,那真正过载时反而不会重试,用户直接拿到错误。反过来,所有 5xx 都重试也会有问题,比如 501 这种没实现的方法,重试没意义。我更倾向于把状态码分成“可重试”和“不可重试”两类,而不是把所有失败一视同仁。这也就是后面 3.1 节的分类表。
1.2 超时不只是时间长短问题:连接超时、读超时与请求生命周期
超时参数是大模型 API 客户端最容易配置错的地方。很多 HTTP 库只允许你传一个 timeout,比如常见请求库的默认单值超时,但实际上完整超时应该拆成连接超时和读超时。连接超时管的是 TCP 建连阶段,设置过长会让用户在服务不可达时傻等;通常 5 秒就够。读超时管的是发完请求到收到响应之间的时间,这是大模型和老接口差异最大的地方——一个生成任务可能排队几秒、推理几十秒,你按普通接口习惯设 3 秒,那几乎每个正常请求都会被打断。我一般把读超时设为 90 秒左右,流式响应单独处理首字节超时。
超时还有一个容易被忽略的维度:请求体是否已经写出去。如果是连接超时,请求大概率没到服务端,重试是安全的;但如果是读超时,数据已经写完了,服务端可能已经完成了生成,只是响应没回到客户端。这时候无脑重发,轻则产生重复结果,重则重复计费。所以读超时不能和连接超时使用同一套重试策略。后面 3.1 的分类表也专门区分了这两类异常。
1.3 你以为的“请求失败”可能并没有失败:幂等与重复计费
说到重复计费,就绕不开幂等。HTTP 语义里的幂等是指同一个请求执行一次和执行多次结果一致。对大模型生成任务来说,天然不幂等,每次调用都可能生成不同内容。超时后你再次发送一模一样的 prompt,服务端很可能又生成一遍,又扣一次钱。所以客户端设计时,我建议每个业务任务都生成唯一 request_id,并带在请求头里。如果服务商支持查询,超时后先查这条请求最终状态;如果不支持,就把超时请求标记为“待确认”,后续通过对账任务处理,而不是立刻重发。
另外一个细节是,重试时不要重新构造 payload。有些同学写代码时把超时后的重试封装成一个新函数,每次都会拿当前时间拼 prompt 或重新选一个采样温度,导致“重试”实际上变成了“换了参数的另一次请求”。如果业务允许重试,应该把原始请求的完整 JSON 固定下来,重放同一个对象;要调整参数,请通过独立的业务参数传递,不要太依赖重试路径去改变生成内容。
2. 限流:与其被动挨打,不如主动限速
2.1 令牌桶与漏桶怎么选:从“管流量”到“管预算”
限流算法选型,我更看重“是否符合大模型调用的资源模型”。漏桶的特点是输出速率恒定,适合需要绝对平滑的场景,但它不允许突发,哪怕你配额有富余,也只能按固定速率慢慢放;令牌桶则允许消费过去积攒的令牌,天然支持突发。大模型请求往往是“少量大请求”,单次调用消耗的 token 很高,偶发突发需求也比较常见,用令牌桶能让平均速率贴着配额走,又能让突发流量短时间内打出去。
实际操作里,我通常会在令牌桶外面再加一层并发信号量。令牌桶管的是每秒/每分钟能启动多少个请求,信号量管的是同一时刻最多有多少个请求在等待或执行。如果你用到了线程池,线程数本身就是一种并发控制,但如果你直接用全局连接池,还要注意连接池默认上限。一句话:频率和并发是两个维度,限流不只调速率。
2.2 用量化:RPM、TPM 与并发数,参数到底怎么定
量化参数是整个调用层最容易拍脑袋的地方。我先给一个我认为最实用的估算流程。第一步,去服务商控制台查自己的配额,重点看 RPM 和 TPM。第二步,估算平均每个请求消耗多少 token——用一段有代表性的 prompt 和期望输出长度来实测,不要拍脑袋。第三步,算等效请求速率:等效 RPM = TPM / 平均单请求 token。取 min(厂商 RPM, 等效 RPM) 作为速率基准。
举例:某厂商给你 RPM=60,TPM=200000。你实测平均每个请求消耗 prompt 8k + 输出 6k,共 14k token,那么等效 RPM ≈ 200000 / 14000 ≈ 14.3。也就是说,即使允许每分钟发 60 次,token 配额也只够 14 次。这时如果你把客户端限速调到 60,早晚把 TPM 打爆。我一般取计算结果的 80% 作为安全速率,也就是约 11.4 次/分钟,换算成每秒就是 0.19。容量方面,如果允许 15 秒突发,桶容量就是 0.19 × 15 ≈ 3。别嫌 3 太小,这本来就是“请求重、消耗大”的画像;如果你的请求平均只有 2k token,容量自然可以放宽。
并发数我同样给一个公式:并发上限 = min(厂商并发上限, 单请求平均耗时 ÷ 业务愿意等待的最长时间)。平均耗时 20 秒、业务最多等 5 秒,并发上限就是 4。超过这个数,意味着新进来的请求排队时间会超过你能接受的 SLA。并发控制可以用线程安全的信号量,也可以用线程池和连接池配合实现。注意这里的平均耗时要把排队和生成时间都算进去,最好加一点余量。
2.3 一个线程安全的令牌桶实现(附代码)
下面这个实现严格保证线程安全,rate 单位是每秒令牌数,capacity 是桶容量。acquire 支持阻塞和非阻塞,超时返回 False。注意 refill 不需要加锁之外的定时器,每次取令牌时按时间差补令牌。在等待时释放了锁,用 sleep 让出 CPU,避免某个线程一直占着锁不放。
import threading import time class TokenBucket: def __init__(self, rate_per_sec: float, capacity: int): self.rate = rate_per_sec self.capacity = capacity self.tokens = float(capacity) self.updated_at = time.monotonic() self.lock = threading.Lock() def _refill(self): now = time.monotonic() delta = now - self.updated_at self.tokens = min(self.capacity, self.tokens + delta * self.rate) self.updated_at = now def acquire(self, block: bool = True, timeout: float = None) -> bool: deadline = time.monotonic() + timeout if timeout is not None else None while True: with self.lock: self._refill() if self.tokens >= 1: self.tokens -= 1 return True if not block: return False if deadline and time.monotonic() >= deadline: return False time.sleep(0.01)这里为什么用time.monotonic而不是time.time?因为单调时钟不受系统时间跳变影响,适合测量间隔。这个区别在定时任务和限流场景里很关键,否则 NTP 校时一旦发生,限流速率可能瞬间失真。使用方式很简单:每次真正发请求前调用acquire(block=True, timeout=5),拿不到令牌就走降级逻辑。注意令牌桶锁只保护桶状态本身,不要在做网络请求时持有这把锁。
3. 重试:退避不是简单睡一觉就起来
3.1 什么能重试,什么不能重试:状态码与异常分类
凡是重试逻辑,先做分类。我把可重试性做成一张表:429 根据 Retry-After 头重试;500/502/503/504 可重试;400/401/403 不重试;连接超时和连接错误可重试;读超时默认不重试,除非业务侧有幂等保障。很多人把“请求失败”一刀切全部重试,这是最典型的坑。
| 类型 | 示例 | 是否重试 | 原因 |
|---|---|---|---|
| 429 | Too Many Requests | 按 Retry-After 或退避重试 | 服务端明确让你等待 |
| 5xx | 500 / 502 / 503 / 504 | 可重试 | 服务端临时故障,大概率恢复 |
| 4xx 非 429 | 400 / 401 / 403 | 不重试 | 参数或鉴权错误,再多次也一样 |
| 连接超时 | ConnectTimeout | 可重试 | 请求大概率没到服务端 |
| 读超时 | ReadTimeout | 谨慎重试 | 服务端可能已处理完成,盲目重发会重复计费 |
| 其他网络异常 | ConnectionError / DNS 错误 | 可重试 | 短暂网络抖动 |
如果厂商响应里有Retry-After头,那就是服务端给出的“建议等待时间”,优先级最高,应该直接采用;如果没有,再按退避算法自己算。另外提醒一个容易翻车的地方:Retry-After 可能是秒数,也可能是 HTTP 日期格式。写代码时如果直接float(header)遇到日期字符串会抛异常,建议用email.utils或第三方日期库解析,兼容两种格式。
3.2 指数退避 + 抖动:防止重试风暴
指数退避是经典的退避算法:第 n 次重试前等待base * 2^n。不加抖动时,同一批同时失败的请求会在相同时间点同时重试,形成周期性尖峰,这也是“重试风暴”的源头。解决方法是给退避时间加上随机量,让重试点分散开。一般公式是delay = min(max_delay, base * 2**attempt) + random.uniform(0, jitter)。jitter 可以是固定值,也可以是延迟的一部分。业内还有更激进的 Full Jitter:sleep = random.uniform(0, min(max_delay, base * 2**attempt)),上限和随机兼顾。
用生活类比来说:一大群人同时摔倒,如果没有抖动,大家喊口号“123 起”一起爬起来,会互相踩踏;给每个人一个随机等待时间,爬起来的时间分散,队伍就稳了。这个比喻放到调用层完全成立。我在实际项目中就把固定 2 秒重试改成了指数退避加抖动,同样是厂商故障恢复的瞬间,客户端不再扎堆涌入。
3.3 重试的具体实现:带预算和 Backoff 的调用循环
给一个可以放进生产环境的request_with_retry函数。它接收 URL、headers、JSON 体,也接收重试次数上限、基础延迟、最大延迟。内部根据状态码和异常类型决定是否重试。注意预算:重试次数不是越大越好,我一般控制在 3~4 次,总等待时长不超过一两分钟;否则一次故障会让整个调用线程卡住很久,后面的请求全部堵在队列里。
import random import time import requests from requests.exceptions import RequestException def should_retry(exc, status_code): if status_code is not None: if status_code == 429 or status_code >= 500: return True return False if isinstance(exc, (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError)): return True if isinstance(exc, requests.exceptions.ReadTimeout): # 读超时:可能已经生成成功,默认不重发,除非外部保证幂等 return False return False def request_with_retry(url, headers, json_body, *, retries=3, base_delay=1.0, max_delay=30.0): for attempt in range(retries + 1): try: resp = requests.post( url, headers=headers, json=json_body, timeout=(5, 90) ) if should_retry(None, resp.status_code): retry_after = _parse_retry_after(resp.headers.get("Retry-After")) if retry_after is None: retry_after = min(max_delay, base_delay * (2 ** attempt)) delay = retry_after + random.uniform(0, min(1, retry_after * 0.1)) time.sleep(delay) continue resp.raise_for_status() return resp.json() except RequestException as exc: status_code = getattr(exc.response, "status_code", None) if not should_retry(exc, status_code): raise delay = min(max_delay, base_delay * (2 ** attempt)) delay += random.uniform(0, min(1, delay * 0.1)) time.sleep(delay) raise RuntimeError("重试次数已耗尽") def _parse_retry_after(value): if value is None: return None try: return float(value) except ValueError: # 这里可以用 email.utils.parsedate_to_datetime 解析日期格式 return None这个函数里我们区分了异常类型和状态码。对于读超时默认不重试,因为前面说了可能已经成功;如果业务上确实需要重试,请先把请求 ID / 幂等键设计好。同时,这段逻辑与后面的熔断器是配合使用的,request_with_retry负责单次请求的重试,而熔断器承担更宏观的“这段时间还值不值得发请求”的决策。
4. 熔断与降级:最后一公里的兜底设计
4.1 熔断器实现:从“失败率阈值”到“半开探测”
只靠限流和重试还不够,因为当厂商服务持续故障,客户端会在每次请求上都重复等待和重试,线程池被占满,调用端整体雪崩。熔断器解决的是“快速失败”问题:一段时间内失败率达到阈值,就把断路器打开,后续请求在几毫秒内直接返回失败或走降级,不再发真实请求;等冷却时间过了,放一个半开探测请求,成功则关断,失败则保持打开。
下面是一个基于滑动窗口的熔断器实现。window_seconds是统计窗口,failure_rate是失败比例阈值,min_requests防止样本太少就熔断,cooldown是打开后的冷却时间。HALF_OPEN状态只放一个探测请求,成功关闭,失败重新打开。整个类线程安全。
import threading import time from collections import deque class CircuitBreaker: def __init__(self, failure_rate=0.5, min_requests=20, cooldown=60, window_seconds=60): self.failure_rate = failure_rate self.min_requests = min_requests self.cooldown = cooldown self.window_seconds = window_seconds self.events = deque() # (success, timestamp) self.state = "CLOSED" self.opened_at = 0 self.half_open_taken = False self.lock = threading.Lock() def _prune(self, now): while self.events and now - self.events[0][1] > self.window_seconds: self.events.popleft() def allow_request(self): with self.lock: now = time.monotonic() self._prune(now) if self.state == "OPEN": if now - self.opened_at >= self.cooldown: self.state = "HALF_OPEN" self.half_open_taken = False return True return False if self.state == "HALF_OPEN": if self.half_open_taken: return False self.half_open_taken = True return True return True def record(self, success): with self.lock: now = time.monotonic() self._prune(now) self.events.append((success, now)) if self.state == "HALF_OPEN": if success: self.state = "CLOSED" self.events.clear() else: self.state = "OPEN" self.opened_at = now self.events.clear() return if len(self.events) >= self.min_requests: failures = sum(1 for ok, _ in self.events if not ok) if failures / len(self.events) >= self.failure_rate: self.state = "OPEN" self.opened_at = now self.events.clear()这个实现的关键是:窗口内的失败事件会被_prune清理,时间一旦滑过去,旧失败不再影响当前判断。半开状态只放一个探测请求,避免了恢复初期的并发冲击。
4.2 降级策略分层:缓存、备用模型与阈值内降质
熔断器打开或限流等不到令牌时,不能干等,要降级。降级不是简单地 “return None”,而是按业务可接受的成本分层。第一层是缓存:如果 prompt 是模板化或可哈希的,相同请求直接返回历史结果,成本和延迟都最低。第二层是备用模型:一个厂商挂掉,切换到另一个支持相似接口的大模型,需要把 prompt 和响应格式做一层适配,避免业务层写死厂商字段。
第三层是降质:保留主模型但减少输入上下文,降低max_tokens,或压缩候选数量,用更便宜、更快的模式换可用性。第四层是把请求转异步队列,等厂商恢复后离线补跑,适合报表、批处理等不要求实时的场景。最后一层才是返回用户可见的“服务繁忙”,但也要保证接口是成功的 HTTP 200,里面带一个业务状态码,否则前端可能把错误当成系统崩溃。按优先级排序,越靠前越优先,逐层穿透。
4.3 完整的调用链整合:限流 + 重试 + 熔断 + 降级一个都不能少
把前面的组件串起来。调用链核心流程是:先看熔断器是否放行,不放行走降级;放行后在令牌桶里取令牌,取不到也降级;接着执行带重试的请求;成功后记录成功,失败记录失败并走降级。降级函数本身也要有兜底,不能递归降级把自己玩死。
# 假设全局已经有: # breaker = CircuitBreaker(...) # bucket = TokenBucket(...) # cache = {} def call_model(prompt): if not breaker.allow_request(): return do_fallback(prompt, reason="circuit_open") if not bucket.acquire(block=True, timeout=5): return do_fallback(prompt, reason="rate_limited") try: data = request_with_retry( primary_cfg["url"], primary_cfg["headers"], primary_cfg["build_payload"](prompt), ) except Exception: breaker.record(False) return do_fallback(prompt, reason="primary_failed") breaker.record(True) return data def do_fallback(prompt, reason): cached = cache.get(prompt) if cached is not None: return {"source": "cache", "reply": cached} if fallback_cfg.get("enabled") and fallback_breaker.allow_request(): try: reply = fallback_cfg["request_fn"](prompt) fallback_breaker.record(True) return {"source": "fallback_model", "reply": reply} except Exception: fallback_breaker.record(False) return {"source": "service_busy", "reply": "服务繁忙,请稍后再试"}注意:备用模型一定要用独立的令牌桶和熔断器,否则主模型故障时,大量降级流量瞬间打到备用模型,备用模型同样会被 429 打爆。这就是为什么图里永远会有两个桶、两个断路器。另外,并发场景下有一个关键纪律:不要在持有锁的情况下发起远程调用。上面的allow_request和acquire方法返回后锁已经释放,远程请求是在无锁状态下执行的,这是正确做法。
5. 实测中踩过的坑与排查速查表
5.1 重试风暴和请求堆积的真实案例
讲一个模拟项目 X 里真实发生过的事故。某晚定时任务启动,500 个并发线程同时请求大模型 API。厂商网络闪断约 30 秒,大量请求读超时。当时重试逻辑只做了“固定 2 秒重试”,没有抖动。第一波线程 2 秒后同时重试,打到刚恢复的厂商网关;厂商限流开始返回 429,客户端看到 429 又固定 2 秒重试,相当于把限流当成了普通失败不断放大。整个服务从请求层到数据库连接池全部打满,恢复花了快两个小时。
这个案例说明三个问题:一、读超时默认不重试,或者至少要确认幂等;二、重试必须有抖动和预算,不能固定间隔;三、限流、重试和熔断必须作为一个整体设计,单加任何一环都不够。后来我把读超时改成“超时后只记录请求 ID,通过查询接口确认”,把重试改成指数退避加抖动,再补上熔断器,同样场景下系统稳住了。
5.2 排查速查:429、超时、熔断误伤的出现时该怎么看日志
日志是定位这类问题的眼睛。每一条调用日志至少要有:请求 ID、目标模型或接口、状态码或异常类型、耗时、重试次数、是否触发限流等待、是否走了降级、token 用量(如果能拿到)。把这几列打到结构化日志里,下次排查会省很多时间。下面是我常用的速查表。
| 症状 | 常见原因 | 解决思路 |
|---|---|---|
| 大量 429 且没有 Retry-After | 客户端限速过高或 token 配额打满 | 按 TPM 重新估算速率并下调 |
| 偶发 429,重试后成功 | 正常配额竞争 | 可接受;重试次数控制在 3 次内 |
| 大量读超时 | 厂商过载或读超时设置太短 | 区分首字节延迟,调大 read timeout,或切流式 |
| 错误码 400/401/403 | 参数或鉴权问题 | 打印响应体并修请求,重试无意义 |
| 熔断频繁打开 | min_requests 或 failure_rate 太敏感 | 观察正常失败率,调大窗口和阈值 |
| 降级太频繁 | 备用模型也被打爆,或缓存命中率低 | 单独给备用模型配限流和熔断,提高缓存复用 |
这些场景我都实际遇到过。最容易被忽视的是最后一行:主模型挂了之后,所有流量灌给备用模型,备用模型如果没做独立限流,就会像多米诺骨牌一样连续倒下。
5.3 一套快速落地的配置模板(供直接抄)
最后给一份可以当作起点的配置模板。它不是最优解,但比从零开始靠谱。所有数值都要按业务重新调:请求量大、单次 token 小的场景,速率可以放大;读超时长一点的场景,重试次数就要相应减少,否则一次故障能拖住线程好几分钟。
| 配置项 | 推荐起点 | 说明 |
|---|---|---|
| 速率基准 | 厂商 RPM 的 80%,且不超过 TPM 折算值 | 留出 20% 余量 |
| 并发上限 | min(厂商并发, 平均耗时 ÷ 最大排队容忍时间) | 用信号量或线程池控制 |
| 连接超时 | 5 秒 | 服务不可达时快速失败 |
| 读超时 | 60~120 秒 | 流式接口单独处理首字节超时 |
| 最大重试次数 | 3~4 次 | 超过预算直接走降级 |
| 退避基准 | 1 秒 | 指数增长 |
| 退避上限 | 30 秒 | 防止重试等待过长 |
| 抖动方式 | Full Jitter 或 10% 抖动 | 防止重试风暴 |
| 熔断窗口 | 60 秒 | 滑动窗口 |
| 熔断失败率 | 50% | 窗口内失败比例 |
| 最小请求数 | 20 | 样本太少不轻易熔断 |
| 熔断冷却 | 60 秒 | 之后进入半开探测 |
| 降级顺序 | 缓存 → 备用模型 → 降质 → 排队 → 友好提示 | 按成本从低到高 |
上线前最好做一次故障演练:模拟厂商延迟升高、持续 429、断网三种故障,观察调用链是否会在预期时间内降级并恢复。没有经过故障演练的限流重试配置,都只是一堆看起来合理的数字。
我自己写过一版没有熔断器、只有重试和限流的调用层,当时觉得够用,直到被事故狠狠教育了一遍,才意识到这套机制是一个整体。如果你时间紧,可以先从“超时分类 + 指数退避抖动 + 令牌桶”起步,这三样能解决大部分 429 和超时问题;等系统规模上来,再加上熔断和分级降级。每加一个组件,都要有日志能证明它在关键时刻生效,否则就是给自己堆安慰。希望这些在坑里爬出来的经验,能让你少走一点弯路。