API 重试和退避怎么写才不放大故障?

2026-07-27

数据截至 2026-07,价格与限额以各官网为准。

重试不是给你的服务加保险,而是在上游最脆弱的时刻额外给它加压。写得好,它能吃掉偶发抖动;写得不好,它会把一次十秒的小故障放大成半小时的雪崩。判断一段重试代码是否安全,只看三件事:哪些错误才允许重试、两次重试之间的间隔是不是带随机性的递增、整条链路有没有一个统一的时间预算和重试配额。

一个很常见的误解是:重试次数越多越稳。实际情况正好相反。上游返回错误往往说明它已经过载,这时候你的客户端立刻再打一次、再打一次,等于在别人快撑不住的时候排队加塞。更糟的是,所有客户端通常会在同一时刻收到同一批错误,于是它们的重试也在同一时刻发出——这就是重试风暴。你以为自己在容错,其实是在参与制造故障。

第一件事:分清哪些错误该重试

这是最容易被跳过、但收益最大的一步。很多项目的重试逻辑是 except Exception: retry,这基本等于没做判断。

按可重试性大致可以分成三类:

  • 值得重试的:连接超时、读超时、连接被重置这类网络层错误;服务端 5xx(尤其是 502/503/504 这种明显的临时不可用);以及限速类的 429。这些错误的共同点是「同样的请求过一会儿可能就成功了」。
  • 重试也没用的:请求参数错误、模型名写错、鉴权失败、内容被安全策略拒绝。这类错误你重试一百次结果都一样,只是白白消耗配额和时间。把它们直接抛出去、记录清楚,比默默重试三次再报错要好得多,至少排查时你能第一眼看到真实原因。
  • 要额外小心的:请求已经发出去、但你没收到完整响应的情况。比如流式输出到一半连接断了,或者读超时。这时候服务端很可能已经真的执行完了——你重试等于让模型跑了两次,账单是双份的,如果这次调用还带工具调用、写数据库之类的副作用,后果就不只是多花钱。

针对最后这类,最实际的做法是:给每次业务级调用生成一个自己的请求标识并记录状态,重试前先查一下「这次任务是不是已经有结果了」。不要依赖「反正模型调用是只读的所以随便重试」这个假设——一旦你的链路里挂了工具调用,它就不再是只读的了。

429 这个错误码值得单独说一句。它的含义是「你太快了」,而不是「服务坏了」。收到 429 之后立刻重试是最糟的反应。如果响应里带了 Retry-After 之类的等待提示头,优先按它给的时间等;具体哪些平台会带哪些头、字段口径是什么,以你实际调用的那家服务的官方文档为准,别按别家的经验去猜。关于国内几家常见服务的限流表现,可以看 DeepSeek API 报 429 限流怎么办

固定间隔为什么危险:退避必须带抖动

假设你写的是「失败后等 1 秒再试,最多 3 次」。上游抖动 3 秒,期间有 500 个客户端撞上错误。这 500 个请求会在 1 秒后同时再来一次,2 秒后再同时来一次——每一波的瞬时压力都和第一波一样大,甚至更大,因为新进来的正常流量也在叠加。上游本来只要几秒就能缓过来,被你们这么一顶,可能就彻底起不来了。

正确的形状是两条:间隔要指数级增长,并且要加随机抖动。

指数增长的意义是给上游让出恢复空间:第一次等 0.5 秒,第二次 1 秒,第三次 2 秒,第四次 4 秒。抖动的意义是打散同步性:在算出来的间隔上乘一个随机系数,让原本会挤在同一时刻的重试均匀铺开。少了抖动,指数退避只是把「同时打一波」变成「隔更久同时打一波」,惊群问题一点没解决。

一个可以直接抄的实现思路(这里以 OpenAI 兼容接口为例,把 base_url 指向你自己在用的服务):

import os, random, time
from openai import OpenAI

client = OpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key=os.getenv("OPENROUTER_API_KEY"),
)

RETRYABLE = (429, 500, 502, 503, 504)

def call_with_backoff(messages, model, deadline_s=25.0, max_attempts=4):
    started = time.monotonic()
    delay = 0.5
    last_err = None
    for attempt in range(max_attempts):
        # 时间预算用完就别再试了,直接降级
        if time.monotonic() - started > deadline_s:
            break
        try:
            return client.chat.completions.create(model=model, messages=messages)
        except Exception as e:
            status = getattr(e, "status_code", None)
            if status is not None and status not in RETRYABLE:
                raise           # 参数错、鉴权错,重试无意义
            last_err = e
            # 指数退避 + 全抖动:在 [0, delay] 里随机取
            sleep_s = random.uniform(0, delay)
            remain = deadline_s - (time.monotonic() - started)
            if remain <= 0:
                break
            time.sleep(min(sleep_s, remain))
            delay = min(delay * 2, 8.0)
    raise last_err

这段代码里真正重要的不是 delay * 2,而是 random.uniform(0, delay)deadline_s 这两处。前者负责打散,后者负责封顶。

上限不该是「次数」,而该是「时间预算」

只写 max_attempts=5 是不够的。假设每次调用本身的超时是 30 秒,退避间隔累计 15 秒,那么最坏情况下这个函数会阻塞两分半钟。如果它跑在一个 Web 请求里,用户早就走了,而你的连接池、协程、内存还被这次调用占着——上游没崩,你自己先被拖垮了。

所以要给整条链路一个明确的截止时间,并且把它一路传下去:入口约定 25 秒返回,那么内部调用的超时加上所有退避间隔就不能超过 25 秒,剩余时间不够再试一次时,就别试了,直接走降级路径。上面代码里 remain 那几行就是干这个的。

同时别忘了给单次请求本身设超时。很多人只在重试层做文章,底层 HTTP 客户端却用了默认的无限等待,结果第一次调用就永远挂着,重试逻辑根本没机会执行。

你的重试会和网关的 fallback 相乘

这是做模型 API 接入时最容易被忽视的一处放大。

聚合类网关通常自带故障转移。以 OpenRouter 为例,官方说明里写得很清楚:某个提供商返回错误时,它会自动切换到下一个可用提供商,这个过程对调用方是透明的;官方也提到对失败请求不计费(Zero Completion Insurance),按成功请求计费。这个机制本身是好事,但它意味着你看到的一次「失败」,在网关内部可能已经试过好几家提供商了

如果你在外层再包三次重试,实际发出的上游请求数是「你的重试次数 × 网关内部尝试的提供商数」。两层各自看起来都很克制,乘起来就不小了。更麻烦的是,网关内部的尝试也要消耗时间,你外层测出来的「一次调用耗时」会比预期长很多,前面说的时间预算就更容易被击穿。

务实的做法有两条:一是把外层重试次数压到很低(很多场景 1 到 2 次就够,剩下的交给网关),二是明确知道自己用的这层网关到底做了什么——是透明 fallback,还是原样把错误抛给你。这两种情况下你该写的重试逻辑完全不同。跨多家模型做主备切换的整体设计,可以参考 多模型 fallback 怎么设计OpenRouter API 怎么接入

限速档位决定了重试有没有意义

有一类失败,重试从原理上就救不了:你的配额本身就不够。

还是以 OpenRouter 的免费模型档为例,官方说明里给出的限制是:从未购买过 credits 的账户,每天 50 次、每分钟 20 次;账户历史充值达到 10 美元后,每天上限提升到 1000 次(每分钟 20 次的限制仍在)。官方也明确这类免费档不适合用于生产环境。

在这种额度下,如果你的应用并发稍微高一点,429 就不是偶发抖动而是常态。这时候加重试只会让情况更糟:每次失败都变成两三次请求,把本就紧张的每分钟额度消耗得更快,形成「越重试越限流、越限流越重试」的正反馈。

正确的应对不是重试,而是在客户端侧主动限流:按你实际拿到的配额算出安全的发送速率,用令牌桶之类的机制把请求排队送出去,让请求根本不会撞上 429。重试只该用来兜住偶发失败,不该用来对抗一个结构性的容量不足。容量不够就该升档或换路由,这是选型问题,不是代码问题。

顺带一提,OpenRouter 采用的是预充值 credits 模式、按底层模型价格透传(官方声明不在模型调用费上加价),所以重试带来的额外成本是实打实从余额里扣的——除非失败请求本身不计费。不同平台对「失败请求是否计费」的口径不一样,这一点务必去看你在用那家的官方计费页,别默认都一样。

给重试单独设一个配额,并且把它做成指标

即便前面几条都做了,还是建议在服务级别加一道闸:重试流量占正常流量的比例设一个上限,超过就暂时停止重试、直接快速失败。比例定多少要看你自己的压测结果和上游余量,这里不给一个假装普适的数字。这道闸的作用是在真出大故障时防止重试量失控——正常情况下重试占比很低,一旦这个比例飙升,说明上游已经大面积不可用了,此时继续重试没有任何收益。

熔断器是同一思路的另一种形态:连续失败到一定程度就直接断开一段时间,期间所有请求快速失败或走降级,而不是每个都老老实实退避着试一遍。

配套地,至少把这几个数字打到监控里:重试发生的次数、重试之后最终成功的比例、每次调用的总耗时分布(含退避)、以及因重试产生的额外 token 花费。没有这些指标,你根本不知道退避参数调得对不对,只能凭感觉改。成本这条线的做法可以看 API 成本怎么监控

诚实说局限

重试和退避能解决的只有一类问题:短暂的、随机的、会自行恢复的故障。它救不了下面这些:

  • 上游长时间宕机。这时候需要的是降级方案(换模型、返回缓存结果、给用户一个诚实的提示),不是更多重试。
  • 你的请求本身有问题。参数错、超长上下文、被安全策略拦,重试多少次都是同一个结果。
  • 配额结构性不足。前面说过了,那是选型和限流的事。
  • 数据一致性。重试天然会带来重复执行的可能,幂等性得靠你自己在业务层保证,退避算法帮不上忙。

另外,海外模型服务还有一层前提要说清楚:OpenAI、Google、Anthropic、xAI 这几家官方并未把中国大陆列为受支持地区,注册、控制台与 API 端点都在境外(其中 Anthropic 官方受支持地区列表不含中国大陆,见 anthropic.com/supported-countries;xAI 也没有面向大陆的官方开放渠道)。OpenRouter 作为境外托管的聚合平台同样如此,其官网与 API 在大陆网络环境下普遍需要额外的网络条件才能稳定访问,官方页面也未见针对中国大陆访问的正式声明,具体以各官网当前的地区政策页为准。本文不提供也不背书任何第三方中转渠道——这类链路的稳定性本身就会显著抬高你的失败率,而那种失败靠调退避参数是调不好的。

小结

重试的价值不在次数多,而在克制:只对可能自行恢复的错误重试,间隔指数递增并且必须带随机抖动,整条链路共用一个时间预算,重试量本身也要有配额上限。接聚合类网关时要额外留意,它内部可能已经替你切换过提供商,你外层再叠三次重试就是相乘放大。配额结构性不足导致的限流不该用重试去顶,那是限流和选型该解决的问题。最后,把重试次数、重试后成功率和额外成本做成可观测的指标,否则你调的每一个退避参数都只是猜。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。