AI API 报错了到底要不要重试?指数退避加抖动,手写一个能直接套上去的 retry 装饰器

先给结论:只有限流、服务端错误和连接异常值得重试

openai.RateLimitError: Error code: 429openai.InternalServerError: Error code: 502httpx.ConnectError 这三类,退避之后再打一次,大概率能拿到正常结果。400、401、403、404、422 这五种,重试一万次还是同一个返回值,你多花的只有等待时间和调用次数。把这两堆错误分开,重试策略的全部价值就在这里。分不清就无脑重试,等于给账单加了一层随机扣费。

429:被限流了,等一下再打

429 说的是打得太快,不是打错了。同一把 Key 在短时间内的并发冲上去,网关会直接拒绝,有时还带一个 Retry-After 头告诉你等多久。抓一次响应头,比自己猜数字准:

curl -i -s -o /dev/null -D - -X POST https://xyuapi.top/v1/chat/completions \
  -H "Authorization: Bearer $XYU_KEY" -H "Content-Type: application/json" \
  -d '{"model":"deepseek-v4-flash-thinking","messages":[{"role":"user","content":"ping"}]}' \
  | grep -i -E '^(HTTP/|retry-after|x-ratelimit)'

出现 Retry-After: 3 就等 3 秒再发;出现 x-ratelimit-remaining: 0,说明配额见底,这种情况该退避,也该顺手把并发调小一档。

5xx:服务端自己出错,重试通常有效

500、502、503、504 出现在上游处理请求的过程中:网关拿不到后端响应、实例正在重启、长请求被中间层掐断。这类错误跟你发的参数没关系,换个时间点再发,命中的可能是另一台健康的机器。

openai.InternalServerError: Error code: 502 密集出现,通常是上游某条线路在抖动。退避重试的同时把时间点记下来,连续十分钟都这样,该去查线路,而不是改代码。

超时与连接中断:请求可能压根没送到

httpx.ConnectTimeout 是 TCP 还没握上手,httpx.ReadTimeout 是连上了但没等到响应,httpx.ReadError: Connection reset by peer 是连接被中途掐断。这三种的共同点是结果未知,既没成功也没明确失败。重试是拿回结果的常规手段。

重试会不会产生副作用,靠接口自身的语义兜底,这一点后面单独讲。

400 / 401 / 403 / 404 / 422:重试一万次还是同一个结果

这些是确定性错误。参数没改,重试只是把同样的失败再演一遍,顺带多消耗一次额度。

错误类型典型报错原文是否重试建议动作
429 限流openai.RateLimitError: Error code: 429重试Retry-After,退避加抖动
500openai.InternalServerError: Error code: 500重试指数退避,记录时间点
502openai.InternalServerError: Error code: 502重试同上,持续异常就查线路
503openai.InternalServerError: Error code: 503重试上游过载,退避等待
504openai.InternalServerError: Error code: 504重试处理超时被切断
连接超时httpx.ConnectTimeout重试结果未知,退避重发
读超时httpx.ReadTimeout重试缩短单次超时再重试
连接重置httpx.ReadError: Connection reset by peer重试换连接重发,看总预算
400 参数错openai.BadRequestError: Error code: 400不重试直接失败,打印原文
401 Key 错openai.AuthenticationError: Error code: 401不重试检查 Key 和空格
403 无权限openai.PermissionDeniedError: Error code: 403不重试换模型或换 Key
404 模型名错openai.NotFoundError: Error code: 404不重试/v1/models 核对
422 校验不过Error code: 422不重试对照参数表核对字段

表里最该盯的是 400 和 404:出现频率高,也最容易被无脑重试盖过去。参数写错本来就该当场炸出来,被四次重试一掩盖,你可能第二天才发现整批任务全军覆没。

指数退避:等待时间一次比一次长

固定间隔重试是省事的写法:每 3 秒打一次,打满 6 次。上游在过载,你的节奏跟它恢复的节奏对不上,越打越糟。指数退避让等待时间随失败次数翻倍,给上游腾出恢复窗口,也省下无效请求。

公式与手算过程

delay = min(cap, base * 2 ** attempt)

base 是起步等待秒数,cap 是等待上限,attempt 从 0 开始计数。取 base=1cap=30:第 1 次失败等 1 秒,第 2 次等 2 秒,第 3 次等 4 秒,第 4 次等 8 秒,第 5 次等 16 秒,第 6 次本该等 32 秒,被 cap 压到 30 秒,六轮累计 61 秒。同样六轮,固定 3 秒间隔只要 18 秒——多出来的几十秒不是浪费,是留给上游回血的时间。

为什么必须加抖动

一批任务同时被 429,如果都严格等 1、2、4 秒,它们会在同一毫秒集体回来,把刚松开的配额又一次打满,然后集体再吃一轮 429。假设 1000 个任务在 09:00:00 同时被限流,不带抖动的话,09:00:01 会有 1000 个请求同时到达,09:00:03 再来 1000 个,上游看到的瞬时并发跟上一轮被限流时一模一样。这个现象叫惊群。

full jitter 的写法

random.uniform(0, delay) 就是完整抖动:等待时间取 0 到计算值之间的均匀随机数。同一批任务的等待量在首轮散成 0 到 1 秒,第二轮散成 0 到 2 秒,峰值从 1000 掉到几十,上游才真的有机会喘气。有个细节容易踩:随机数种子别在循环里反复初始化,否则整批任务算出来还是同一个值。

一个能直接套在 chat() 上的 retry 装饰器

装饰器代码:纯标准库

# retry_util.py —— 只依赖标准库,可直接复制
import functools
import logging
import random
import time

import httpx
import openai

log = logging.getLogger("xyu.retry")

# 只有这些异常值得重试,其余异常原样抛出
RETRYABLE = (
    openai.RateLimitError,        # 429
    openai.InternalServerError,   # 500 / 502 / 503 / 504
    openai.APITimeoutError,       # SDK 侧请求超时
    openai.APIConnectionError,    # 连接建立失败或被重置
    httpx.ConnectTimeout,
    httpx.ReadTimeout,
    httpx.RemoteProtocolError,    # 连接被中途掐断
)

class RetryGiveUp(Exception):
    """重试次数或总预算用尽,主动放弃。"""

def with_retry(max_attempts=5, base=1.0, cap=30.0, budget=120.0):
    if max_attempts < 1:
        raise ValueError("max_attempts 至少为 1")

    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            started = time.monotonic()
            for attempt in range(1, max_attempts + 1):
                try:
                    return fn(*args, **kwargs)
                except RETRYABLE as exc:
                    used = time.monotonic() - started
                    # 抖动:等待时间取 0 到退避值之间的随机数,打散惊群
                    raw = min(cap, base * (2 ** (attempt - 1)))
                    delay = random.uniform(0.0, raw)
                    if attempt >= max_attempts:
                        log.error("attempt=%d/%d 放弃(次数用尽) 已用=%.1fs %s: %s",
                                  attempt, max_attempts, used, type(exc).__name__, exc)
                        raise RetryGiveUp("次数用尽 attempt=%d" % attempt) from exc
                    if used + delay >= budget:
                        log.error("attempt=%d/%d 放弃(预算不足) 已用=%.1fs 需等=%.2fs",
                                  attempt, max_attempts, used, delay)
                        raise RetryGiveUp("预算用尽 已用=%.1fs" % used) from exc
                    log.warning("attempt=%d/%d 失败 %s: %s -> 等待 %.2fs",
                                attempt, max_attempts, type(exc).__name__, exc, delay)
                    time.sleep(delay)
            raise RetryGiveUp("循环意外结束")  # 逻辑兜底,正常不会走到

        return wrapper

    return deco

client = openai.OpenAI(
    api_key="你的Key",
    base_url="https://xyuapi.top/v1",
    max_retries=0,   # SDK 自带重试关掉,只保留下面这一处
    timeout=60.0,
)

@with_retry(max_attempts=5, base=1.0, cap=30.0, budget=120.0)
def chat(prompt, model="deepseek-v4-flash-thinking"):
    if not prompt or not prompt.strip():          # 边界:空提示词不发请求
        raise ValueError("prompt 不能为空")
    resp = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        stream=False,
    )
    choices = getattr(resp, "choices", None)
    if not choices:                                # 边界:上游返回空 choices
        raise RuntimeError("上游返回空 choices")
    content = choices[0].message.content
    return content or ""                           # 边界:内容为 None 时给空串

异常要按可重试清单捕获

别写 except Exception。它会把参数错误、Key 错误、模型名错误一起吞掉并重试,把你最想看的那条报错埋进日志里。把可重试异常列成一个元组,白名单式捕获,其余异常照原样往上抛。

每次 attempt 和等待秒数都要打日志

重试静默进行的话,你只能看到最终失败。日志里带上三样东西就够用:第几次尝试、异常类名、本次等待秒数。上面那段代码打出来是这样:

边界处理:空值、零值、连接重置

生产环境翻车的常常不是重试本身,是边界。空提示词要先拦下来,别浪费一次请求;上游返回空 choices 要当成异常,否则后面取值直接崩;max_attempts 传成 0 会让函数一次都不执行,开头就校验掉。连接被重置这类异常,异常元组里把 SDK 和 httpx 两边的类型都列上,能少踩这个坑。

总预算 deadline:别让一个任务卡十分钟

单次重试上限挡不住总时长失控

五次重试、单次超时 60 秒、退避上限 30 秒,最坏情况是 5 乘 60 秒等待加上退避的十几秒,接近 320 秒。一个任务占住 worker 五分钟,一批 200 条排下去,后面的全部积压,你看到的就是任务超时。重试次数管的是次数,管不了时间,所以 budget 参数要单独设。

带 deadline 的实现:用单调时钟

单个请求的预算管不了排队时间,整批任务还得有一个总时限:

import logging
import time

log = logging.getLogger("xyu.batch")

def run_batch(prompts, model="deepseek-v4-flash-thinking", total_deadline=600.0):
    """整批任务的总时限:到点就停,剩余条目直接标记失败。"""
    if not prompts:                       # 边界:空列表直接返回
        return [], []
    end_at = time.monotonic() + total_deadline   # 单调时钟,不怕系统时间被校时
    ok, failed = [], []
    for idx, prompt in enumerate(prompts):
        if time.monotonic() >= end_at:
            rest = prompts[idx:]
            log.error("批次总时限 %.0fs 用尽,剩余 %d 条标记失败", total_deadline, len(rest))
            failed.extend((p, "batch_deadline_exceeded") for p in rest)
            break
        try:
            ok.append((prompt, chat(prompt, model=model)))
        except (RetryGiveUp, ValueError, RuntimeError) as exc:
            failed.append((prompt, repr(exc)))
    log.info("批次结束 成功=%d 失败=%d 耗时=%.1fs", len(ok), len(failed),
             total_deadline - (end_at - time.monotonic()))
    return ok, failed

算耗时用 time.monotonic(),不要用 time.time()。系统对时、容器迁移都可能让后者的值往回跳,一旦往回跳,预算会算成负数,重试要么永远不触发,要么一次都不等。

SDK 自带重试会和你的重试相乘

max_retries=2 叠加 3 次重试,最坏 9 次请求

OpenAI(api_key=..., max_retries=2) 表示 SDK 自己会重试 2 次,单次调用最多发 3 个请求;外面再套一层 3 次重试,最坏是 3 乘 3 等于 9 个请求。SDK 内部重试你完全看不见,日志里只有一次调用记录,成本和耗时同时放大到九倍。

建议 max_retries=0,重试逻辑只留一处

max_retries=0 写进客户端初始化,重试统一交给自己的装饰器,attempt 次数、等待秒数、最终结果全在一个地方,出问题只查一个文件。真要用内置重试也行,那就别在外面再包一层,两处只留一处。

重试要配限流,否则会把自己刚压下去的流量再冲一遍

无脑重试会把限流后的流量顶回去

限流让流量平稳,重试让失败的请求再来一次,两个动作方向相反。你刚把并发从 50 压到 20,重试一来,瞬时并发又回到 40,上游看到的就是一次没压住的反弹。加退避能缓解,但退避只推迟了冲击时间,没减少冲击量。

重试前先排队

稳妥做法是重试前先过一次并发闸门:用信号量或线程池把同时在途的请求数卡在阈值以下,超出的重试请求在队列里等。闸门大小按你实跑出来的限流阈值定——被 429 之前那一档并发,往下留三成余量。怎么量阈值、怎么把闸门跟退避串起来,之前那篇讲并发控制的文章拆得比较细,可以对着看。

幂等与计费:重试成功不等于免费

/v1/chat/completions 是只读语义

/v1/chat/completions 是一次纯推理请求:读输入、返回文本,不改服务端状态,不生成订单,不写业务数据。结果未知时重发同一个请求,不会出现两条记录,也不会把某个字段加两次。这一点决定了它天然适合重试。用同一条链路去触发写操作时,重试前要想清楚会不会重复执行。

按次计费下每次成功返回都算一次调用

按次计费的门槛在这:每一次成功返回都算一次调用,跟输入多长没关系,固定价一次。重试成功的那一次照样计费。所以重试是可用性工具,不是省钱工具。常见单价长这样:gemini-2.5-pro 0.031 元一次,deepseek-v4-flash-thinkinggrok-4.1 0.05 元,claude-sonnet-4-5-thinking 0.09 元,claude-opus-4-5-thinking 0.12 元,claude-opus-4-6-thinking 0.25 元,gpt-5.5 0.2 元。另有按量计费与无限卡套餐可选,最低充值 7 元,支付宝和微信都能付,不需要海外信用卡。

重试要可观测:把 attempt 和状态码落成一行

每次尝试都落盘

在装饰器里加一个 CSV 落盘:一行一次尝试,字段固定成时间戳、任务 ID、模型名、第几次尝试、异常类名、状态码、等待秒数、最终结果。不用上监控系统,追加写文件就够,出了事按任务 ID 过滤。

重试率怎么算,超过 5% 就该降并发

重试率等于发生重试的请求数除以总请求数。稳定在 5% 以内属于正常抖动;超过 5%,说明你的并发已经踩在限流线附近,再加 retry 只会让重试率更高、成本更贵。这个时候的动作是把并发降一档、把批量任务错峰。加 retry 是把问题往后推,降并发才是解决它。

不想手写就用 tenacity

wait_exponential_jitter 的完整写法

# pip install tenacity
import logging

import httpx
import openai
from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    stop_after_delay,
    wait_exponential_jitter,
    before_sleep_log,
    RetryError,
)

log = logging.getLogger("xyu.tenacity")

client = openai.OpenAI(api_key="你的Key", base_url="https://xyuai.top/v1", max_retries=0)

@retry(
    retry=retry_if_exception_type((          # 只重试这些类型
        openai.RateLimitError,
        openai.InternalServerError,
        openai.APITimeoutError,
        openai.APIConnectionError,
        httpx.ReadTimeout,
        httpx.RemoteProtocolError,
    )),
    wait=wait_exponential_jitter(initial=1, max=30, jitter=1),  # 指数退避 + 抖动
    stop=stop_after_attempt(5) | stop_after_delay(120),         # 次数或时长任一触顶就停
    before_sleep=before_sleep_log(log, logging.WARNING),        # 每次等待前打日志
    reraise=True,                                              # 最后一次抛原异常
)
def chat(prompt, model="deepseek-v4-flash-thinking"):
    if not prompt or not prompt.strip():
        raise ValueError("prompt 不能为空")
    resp = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        stream=False,
    )
    if not resp.choices:
        raise RuntimeError("上游返回空 choices")
    return resp.choices[0].message.content or ""

try:
    print(chat("用一句话解释指数退避"))
except RetryError as exc:
    log.error("重试全部失败: %s", exc.last_attempt.exception())

stop 和 retry 条件怎么配

stop_after_attempt 管次数,stop_after_delay 管总时长,两个用竖线连起来表示任一满足就停,这正是手写版本里 max_attemptsbudget 的等价写法。reraise=True 别漏,默认它会抛一个包了一层的 RetryError,你真正关心的 429 原文被藏在 last_attempt 里。jitter=1 表示抖动上限 1 秒,配 initial=1max=30 够用。

重试次数、累计等待与成本账

重试次数 vs 累计等待时间

第几次失败本次等待(base=1, cap=30)累计等待不设 cap 时的累计
11 秒1 秒1 秒
22 秒3 秒3 秒
34 秒7 秒7 秒
48 秒15 秒15 秒
516 秒31 秒31 秒
630 秒61 秒63 秒

1 加 2 加 4 加 8 加 16 加 32 等于 63 秒,cap 把第 6 次压到 30 秒,累计变成 61 秒。看这张表就明白为什么超过 5 次基本没意义:第 6 次还要再等半分钟,换来的成功率提升只有零点几个百分点,任务占用的时间却翻了一倍。真需要更高成功率,方向是降并发、换线路、配备用入口。

一万次调用的成本账

重试率额外请求数额外支出
1%100 次5 元
5%500 次25 元
7.5%750 次37.5 元
15%1500 次75 元
30%3000 次150 元

deepseek-v4-flash-thinking 每次 0.05 元算,一万次等于 500 元。重试率 15% 意味着多打 1500 次请求,一千五百乘以 0.05 等于 75 元,相当于预算多出 15%;把重试率压到 7.5%,额外支出从 75 元掉到 37.5 元。

为什么退避比无脑重试划算

没有退避、直接重打的那套代码,成功率经常掉到 80% 以下。一万条任务有 2000 条要补救,整批重跑一遍等于再花 500 元。加上退避和错误分类之后,成功率能稳在 99.5% 以上:一万条里剩 50 条失败,补救成本只有 50 乘 0.05 等于 2.5 元。

同样是重试,一个有界、带抖动、带总预算的实现,和一个 try 套 try 的无脑重试,成本差着一个数量级。重试率这个数字每周看一眼,涨过 5% 就去查并发和模型选型,比事后对账单便宜得多。

相关阅读

🚀 想要立即使用?来小鱼API体验全系列AI模型

查看全部产品

支持Gemini / Claude / GPT / Grok / DeepSeek · 国内直连 · 支付宝/微信