大模型 API 重试与退避完全指南:从一次"上游抖一下、重试风暴把服务和上游一起打垮"看懂指数退避与抖动

2024 年我做一个接大模型 API 的服务要把用户的请求转发给上游的大模型。调用失败这件事我压根没多想。第一版我做得很省事调用失败了就重试套个循环失败就再调一次直到成功为止。本地开发时真不错本地网络稳上游也稳几乎不失败偶尔失败重试一次立刻就好了。我心里很踏实重试嘛不就是失败了再试一次多试几次总能成。可等这个服务真正上线扛起真实流量一串问题冒了出来。第一种最先把我打懵有一次上游大模型服务抖了几秒我的服务里成百上千个请求同时失败又同时一起重试瞬间把请求量翻了好几倍本来只是抖一下的上游被我这波重试彻底打垮了我自己也跟着雪崩。第二种最浪费我对所有失败一视同仁地重试包括一个请求参数写错了返回的 400 API key 失效返回的 401 这些错误重试一万次也不会成功我却傻傻地重试满了次数白白浪费了时间烧掉了配额。第三种最火上浇油我重试时没有任何等待失败立刻就重试可上游返回失败往往正是因为它过载了我不给它一点喘息反而立刻又怼一个请求过去等于往着火的地方浇油。第四种最隐蔽我所有的客户端所有的重试都用固定的 1 秒间隔结果一次大面积失败后成千上万个重试像约好了一样在同一个时刻一起涌向上游。我盯着这一连串问题想了很久才彻底想明白第一版错在我以为调用失败了就重试试到成功为止。这句话把重试当成了一个无脑的越多越好的动作。可它不是。重试是一把双刃剑它能帮你扛过偶发的瞬时的抖动但它同样能在一次真正的故障里把你和上游一起拖进深渊。一个失败首先要问的不是重试几次而是这个失败到底该不该重试参数错了重试也没用一个该重试的失败重试之前要先等而且越往后等得越久给上游留出恢复的时间多个客户端的重试还要错开时刻不能一起涌上去整个重试更要有一个总的上限不能无限试下去。本文从头梳理为什么失败了就反复重试是错的错误怎么分类指数退避怎么算抖动 jitter 为什么不能省重试预算和超时怎么设以及幂等性 Retry-After 熔断这些把重试真正做扎实要避开的坑。

2024 年我做一个接大模型 API 的服务,要把用户的请求转发给上游的大模型。调用失败这件事,我压根没多想。第一版我做得很省事:调用失败了?那就重试——套个循环,失败就再调一次,直到成功为止。本地开发时——真不错:本地网络稳、上游也稳,几乎不失败,偶尔失败,重试一次立刻就好了。我心里很踏实:"重试嘛,不就是失败了再试一次?多试几次,总能成。"可等这个服务真正上线、扛起真实流量,一串问题冒了出来。第一种最先把我打懵:有一次上游大模型服务抖了几秒,我的服务里成百上千个请求同时失败、又同时一起重试,瞬间把请求量翻了好几倍——本来只是抖一下的上游,被我这波重试彻底打垮了,我自己也跟着雪崩。第二种最浪费:我对所有失败一视同仁地重试,包括一个请求参数写错了返回的 400、API key 失效返回的 401——这些错误重试一万次也不会成功,我却傻傻地重试满了次数,白白浪费了时间、拖慢了响应、烧掉了配额。第三种最火上浇油:我重试时没有任何等待,失败立刻就重试——可上游返回失败,往往正是因为它过载了,我不给它一点喘息,反而立刻又怼一个请求过去,等于往着火的地方浇油。第四种最隐蔽:我所有的客户端、所有的重试,都用固定的 1 秒间隔——结果一次大面积失败后,成千上万个重试像约好了一样,在同一个时刻一起涌向上游,一波刚被打趴、下一波又准时拍上来。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"调用失败了,就重试,试到成功为止"。这句话把重试当成了一个无脑的、越多越好的动作。可它不是重试是一把双刃剑:它能帮你扛过偶发的、瞬时的抖动;但它同样能在一次真正的故障里,把你和上游一起拖进深渊。一个失败,首先要问的不是"重试几次",而是"这个失败,到底该不该重试"——参数错了,重试也没用;一个该重试的失败,重试之前要先等,而且越往后等得越久,给上游留出恢复的时间;多个客户端的重试,还要错开时刻,不能一起涌上去;整个重试,更要有一个总的上限,不能无限试下去。把这些合起来,才是一套能用的重试,它有个名字,叫"带退避和抖动的重试"。真正做好大模型 API 的重试,核心不是"失败了多试几次",而是理解重试是有代价的、先区分错误可不可重试、重试之间用指数退避、给退避加上抖动、并用重试预算和超时兜底。这篇文章就把大模型 API 的重试梳理一遍:为什么"失败了就反复重试"是错的、错误怎么分类、指数退避怎么算、抖动 jitter 为什么不能省、重试预算和超时怎么设,以及幂等性、Retry-After、熔断这些把重试真正做扎实要避开的坑。

问题背景

先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:一套"失败了就无脑重试"的大模型转发服务,上线后冒出一串问题:上游抖一下,满服务的请求一起重试,把上游和自己一起打垮;400401 这类重试也没用的错,也被重试满了次数,白烧配额;失败立刻重试,对已经过载的上游火上浇油;所有重试用固定间隔,大面积失败后像约好了一样同时涌上去

我当时的错误认知:"调用失败了,就重试,试到成功为止。"

真相:这个认知错在它只看见了重试的"好处",没看见重试的"代价"。重试的好处很直白:网络偶尔丢个包、上游偶尔抖一下,这种瞬时的、转眼就恢复的故障,重试一下确实能扛过去。但重试的代价同样真实:每一次重试,都是一个额外的请求。当上游只是偶发抖动时,这点额外请求无关紧要;可当上游真的出了故障、本来就在过载边缘时,你的服务里所有失败的请求一起重试,就等于在它最虚弱的时候,把打到它身上的流量瞬间放大了好几倍——这个现象有个名字,叫"重试风暴"。它会让一次本可以几秒就自愈的小抖动,变成一场谁也起不来的雪崩。所以,重试这件事,从来不是"试得越多越好",而是要在"帮自己扛过抖动"和"不要拖垮上游"之间,小心地走钢丝。一旦你接受"重试是有代价的、要克制地用"这个定位,那串问题的答案就全有了:不该重试的错,一次都别试(省下代价);该重试的,重试之间要等、越等越久(给上游恢复的时间);多个客户端的重试要错开(别叠加成风暴);整个重试要有总上限(代价不能无限累加)。

要把大模型 API 的重试做对,需要几块认知:

  • 为什么"失败了就反复重试"是错的——重试有代价,无脑重试会酿成重试风暴;
  • 区分错误——哪些是瞬时故障值得重试,哪些重试一万次也没用;
  • 指数退避——重试之间要等待,而且等待时间随次数指数增长;
  • 抖动 jitter——给退避加随机,打散"所有客户端同时重试"的惊群;
  • 重试预算、超时、幂等、熔断这些工程坑怎么处理。

一、为什么"失败了就反复重试"是错的

先把这件最根本的事钉死:重试,是用"多发一个请求"去换"扛过一次故障"。这笔交换划不划算,完全取决于故障的性质。如果故障是瞬时的——一个丢包、一次零点几秒的网络抖动——那它在你重试时早已恢复,你多发的那个请求顺利成功,这笔交换非常划算。但如果故障是持续的——上游过载了、它正需要流量降下来才能缓过气——那你的重试就是在反向帮倒忙:它非但救不了这个请求,还往一个正在挣扎的系统上又压了一份负载。更可怕的是规模效应:你的服务不是只有一个请求在失败,而是成百上千个一起失败、一起重试,这股叠加起来的额外流量,足以把一次小故障直接顶成大雪崩。所以"失败了就重试"这句话最大的问题,是它默认了"重试总是有益无害的"——而事实是,重试在最需要它克制的那个时刻,恰恰最容易失控。

下面这段代码,就是我那个"一抖动就雪崩"的第一版:

# 反面教材:失败就无脑重试,直到成功
def call_llm(prompt):
    while True:                       # 一直试,直到成功为止
        try:
            return llm_api.create(prompt=prompt)
        except Exception:
            continue                  # 失败了?立刻原地再试一次
    # 破绽一:大面积失败时,所有请求一起重试,把上游和自己一起打垮。
    # 破绽二:400 参数错误这种重试也没用的错,它也会无限试下去。
    # 破绽三:失败立刻重试,不给已经过载的上游任何喘息。

这段代码在本地开发时表现不错,因为本地只有你一个人在用:网络稳、上游稳,几乎不触发重试;就算偶尔失败,也只是孤零零一个请求重试一下,根本形成不了"风暴"——重试的代价,被"本地没有规模、没有真实故障"这件事彻底掩盖了。它的问题不在某一行代码上——try/except、循环本身都没错——而在一个被忽略的前提:它默认"重试是免费的、无害的,所以越多越好"。可线上恰恰相反:重试有代价,而且这个代价会随着并发规模、故障持续时间一起放大。于是那串问题就有了解释:一抖动就雪崩,是因为无脑重试在故障时把流量放大成了重试风暴;白烧配额,是因为它连"这个错重试也没用"都不判断,见错就试;火上浇油,是因为它失败后立刻重试,不留任何退避。问题的根子清楚了:做好重试的工程量,全在"承认重试是有代价的、要克制地用"之后——你把重试当成免费的,它就会在最关键的时刻,把代价连本带利地还给你。而克制的第一步,是先搞清楚:这个失败,到底值不值得重试。

二、区分错误:哪些该重试,哪些重试也没用

重试要克制,第一刀就砍在"区分错误"上。不是所有失败都值得重试。把失败粗分成两类。第一类是"瞬时故障":网络超时、连接被重置、上游返回 503(暂时不可用)、429(被限流)、500/502/504(服务端临时出错)——这些错误的共同点是"现在不行,过会儿可能就行",它们值得重试。第二类是"确定性失败":400(请求参数错了)、401(鉴权失败)、403(没权限)、404(资源不存在)、422(参数不合法)——这些错误的共同点是"问题出在请求本身,你不改请求,试一万次结果都一样",它们重试纯属浪费。把这个判断写成一个函数:

# 区分错误:哪些值得重试,哪些重试也是白费
RETRYABLE_STATUS = {429, 500, 502, 503, 504}   # 限流 + 服务端临时故障
FATAL_STATUS = {400, 401, 403, 404, 422}        # 客户端错误:重试也不会变好


def is_retryable(error) -> bool:
    """判断一个错误该不该重试。"""
    status = getattr(error, "status_code", None)
    if status in RETRYABLE_STATUS:
        return True
    if status in FATAL_STATUS:
        return False                  # 参数/鉴权错了,重试一万次也没用
    # 没有 HTTP 状态码的:网络超时、连接重置 —— 属于瞬时故障,可重试
    if isinstance(error, (TimeoutError, ConnectionError)):
        return True
    return False                      # 拿不准的,默认不重试,避免帮倒忙

注意最后那一行 return False——对拿不准的错误,默认不重试。这是故意的:重试既然有代价,那么"要不要付出这个代价"的举证责任,应该落在"重试"这一边——只有当一个错误明确属于瞬时故障,才重试;否则宁可不重试、把错误如实抛出去,也别糊里糊涂地多发一个可能帮倒忙的请求。这里的认知要点是:区分错误,是整个重试体系里"性价比最高"的一步,因为它做的是减法。后面要讲的退避、抖动,都是在优化"怎么重试得更得体";而错误分类,是在源头掐掉"根本不该发生的重试"。一个 400 错误,无论你退避得多优雅、抖动加得多均匀,重试它都是 100% 的浪费——浪费配额、浪费用户的等待时间、还可能在大面积参数错误时凑成一场毫无意义的风暴。所以重试的第一个动作,永远不是"开始重试",而是"判断这个错该不该进入重试流程"。把不可重试的错第一时间抛出去,既快、又省、又安全。错误分类解决了"该不该重试",接下来是"重试该怎么试"——而第一条规矩,是重试之间必须等。

三、指数退避:重试之间要等,而且越等越久

开头第三个问题——"失败立刻重试、火上浇油"——根子在重试之间没有等待。上游返回 503429,潜台词往往是"我现在忙不过来";你立刻又怼一个请求过去,等于完全没听懂它的求救。正确的做法是:重试之前先等一会儿,给上游留出恢复的时间。而且这个等待时间不该是固定的,而该随重试次数指数增长——这就是指数退避(exponential backoff):第一次重试前等 1 秒,还不行就等 2 秒,再不行 4 秒、8 秒……每多失败一次,就多给上游一倍的喘息时间:

# 指数退避:第 n 次重试前,等待时间按 2 的幂次增长
def backoff_delay(attempt, base=1.0, cap=60.0):
    """attempt 从 0 开始:0 -> 1s, 1 -> 2s, 2 -> 4s, 3 -> 8s ...,但不超过 cap。"""
    delay = base * (2 ** attempt)
    return min(delay, cap)            # 封顶,别让等待时间无限膨胀


for attempt in range(5):
    print(f"第 {attempt + 1} 次重试前等待 {backoff_delay(attempt)} 秒")
# 输出: 1s, 2s, 4s, 8s, 16s —— 越往后等得越久,给上游留出恢复时间

为什么是指数增长,而不是固定间隔、或者线性增长?因为指数增长暗含一个判断:重试失败的次数越多,说明这次故障越可能不是"一闪而过的抖动",而是"需要更久才能恢复的真故障"。既然如此,你就该越来越保守——失败几次之后,还在 1 秒 1 秒地猛试,只会持续给上游添乱;而指数退避会让你很快退到几十秒一次的低频试探,既不放弃,又几乎不给上游压力。注意代码里的 cap:退避时间要封顶,否则 2 ** attempt 涨下去,等待时间会膨胀到几小时、几天那么离谱。这里的认知要点是:指数退避的本质,是让你的重试"听得懂上游的反馈"。固定间隔的重试是"聋子"——上游崩了 5 分钟,它就用同一个节奏猛敲 5 分钟,完全不顾对方的死活。指数退避则是"会察言观色"的:它从密集的快速试探开始,一旦发现"试了几次还不行",就主动把节奏慢下来,把频率指数级地降低。这样,在故障的前几秒——也就是抖动最可能自愈的窗口里——它反应灵敏;而在故障持续下去之后,它又能识相地退到一边,不再添乱。一句话:退避不是"消极地等",而是"主动地把试探频率,和故障的严重程度匹配起来"。但指数退避只解决了"一个客户端等多久",还有一个更隐蔽的问题——所有客户端会算出同一个等待时间。

四、抖动 jitter:别让所有客户端一起重试

开头第四个问题——"成千上万个重试在同一时刻一起涌上去"——藏得最深。你想想:一次大面积失败,意味着成百上千个请求几乎同时失败;它们用同一套指数退避公式,算出来的等待时间一模一样(都是 1 秒、都是 2 秒);于是它们会在同一个时刻,精确地一起发起重试——上游刚要缓过气,又被这一波整整齐齐的重试拍了回去。这个现象叫"惊群效应"(thundering herd)。解法是给退避时间加上随机抖动(jitter),让每个客户端的重试时刻各自散开:

import random

# 抖动:在退避时间的基础上加随机,打散"所有客户端同时重试"
def backoff_with_jitter(attempt, base=1.0, cap=60.0):
    """full jitter:在 [0, 指数退避值] 之间随机取一个等待时间。"""
    delay = min(cap, base * (2 ** attempt))
    return random.uniform(0, delay)   # 关键:随机化,让重试时刻散开


# 对比:不加 jitter,1000 个客户端会在同一秒一起重试(惊群)
#       加了 jitter,这 1000 个重试被均匀地摊到一段时间里

这里用的是 "full jitter":不是在固定退避值上下小幅抖动,而是干脆在 [0, 退避值] 这整个区间里随机取一个值。它的效果最彻底——把原本挤在一个点上的重试,均匀地摊平到一整段时间里。把错误分类、指数退避、抖动合起来,就是一个能用的完整重试循环:

import time

# 把"错误分类 + 指数退避 + 抖动"合成一个完整的重试调用
def call_with_retry(func, max_attempts=5):
    """带退避和抖动的重试:只重试该重试的错,且越等越久。"""
    last_error = None
    for attempt in range(max_attempts):
        try:
            return func()
        except Exception as e:
            last_error = e
            if not is_retryable(e):
                raise                 # 不可重试的错:立刻抛出,别浪费
            if attempt == max_attempts - 1:
                break                 # 已是最后一次:不再等待,直接收尾
            delay = backoff_with_jitter(attempt)
            time.sleep(delay)         # 等一会儿,再试
    raise last_error                  # 重试次数耗尽,抛出最后一次的错误

这里的认知要点是:抖动解决的,是一个"单看一个客户端完全正常、放到一群客户端里就出事"的问题。指数退避如果不加抖动,有一个致命的隐藏假设:它默认每个客户端是孤立的。可现实里,你的服务有成百上千个并发请求,一次故障会让它们"同时"失败——同时失败,加上同一套确定性的退避公式,就会推导出同一个重试时刻。于是无数个本想错开的重试,反而被退避公式精确地"对齐"到了一起,惊群就此发生。抖动做的事,就是往这个确定性的公式里注入随机:让每个客户端各自掷一次骰子,决定自己到底等多久。一个不起眼的 random,把"步调一致的冲锋"打散成了"细水长流的试探"。记住:在分布式系统里,任何"大家会同时做同一件事"的设计,都要警惕惊群——而抖动,就是打散惊群最廉价、最有效的手段。重试该不该试、试之前等多久、怎么错开,都解决了,但还差一个总的刹车——整个重试不能无限拖下去。

五、重试预算与超时:给重试设一个总上限

到这里,重试已经"得体"了,但还不够"安全"——它缺一个总的上限。设想:指数退避会越等越久,1 秒、2 秒、4 秒、8 秒、16 秒……如果重试次数设得很大、上游又恰好长时间不恢复,那一个用户请求可能在你的服务里卡上好几分钟——用户早就关掉页面走了,你还在替一个没人要的请求,固执地重试。所以重试必须有"预算":不光限次数,还要限总时间——只要"重试次数到顶"或"总耗时超预算"任意一个先到,就立刻停手:

import time

# 重试预算:除了次数上限,还要有一个"总时间"上限
def call_with_budget(func, max_attempts=5, total_deadline=30.0):
    """重试不能无限拖:次数到顶、或总耗时超预算,都要停。"""
    start = time.monotonic()
    for attempt in range(max_attempts):
        try:
            return func()
        except Exception as e:
            if not is_retryable(e):
                raise
            elapsed = time.monotonic() - start
            delay = backoff_with_jitter(attempt)
            # 关键:如果"等完这次退避"就会超总预算,干脆别等了,直接放弃
            if elapsed + delay >= total_deadline or attempt == max_attempts - 1:
                raise
            time.sleep(delay)

注意那行关键判断:它不是"等完了再发现超预算",而是"在打算睡之前,先算一笔账"——如果这一觉睡下去就会超总预算,那这一觉根本不值得睡,直接放弃。下面这张图,把一次调用从失败到"重试还是放弃"的完整决策画出来:

这里的认知要点是:重试预算,是把"重试"这件事关进一个笼子里。前面几节让重试变得聪明——只试该试的、试之前会等、会错开;但"聪明"不等于"有边界",一个聪明的重试,如果没有总上限,照样能把一个用户请求拖死在你的服务里。预算给的就是这个边界,而且它有两道:一道是"次数",一道是"总时间"——两者缺一不可。只限次数不行,因为指数退避下,次数不多、总时间却可能很长;只限时间也不够直观。两道一起卡,任意一道先到就收手,重试才真正"有始有终"。还要记住:这个总预算,必须和"用户/上游愿意等的时间"对齐——用户的请求可能本身就有 30 秒超时,你的重试预算就绝不能超过它,否则你还在勤勤恳恳地重试,用户那头早已超时离场,你做的全是无用功。主干的五步都齐了,最后是几个真正接上游接久了才会撞见的工程坑。

六、工程坑:Retry-After、熔断与幂等性

五步设计之外,还有几个工程坑,不处理就会让你的重试要么不够聪明、要么不够安全坑 1:上游用 Retry-After 头明说了"多久后再来",一定要听它的。很多大模型 API 在返回 429 限流时,会带一个 Retry-After 响应头,明确告诉你"等 N 秒后再试"。这是上游给你的精确指示,比你自己瞎猜的退避值权威得多——有它就优先用它:

# 上游用 Retry-After 头明确告诉你"多久后再来",一定要听它的
def resolve_delay(error, attempt):
    """有 Retry-After 就用它,没有才用自己算的退避。"""
    retry_after = getattr(error, "headers", {}).get("Retry-After")
    if retry_after is not None:
        try:
            return float(retry_after)     # 服务端说的,优先级最高
        except ValueError:
            pass
    return backoff_with_jitter(attempt)   # 没给,才用本地退避兜底

坑 2:连续失败到一定程度,要熔断,而不是继续重试。重试是为了对付"偶发抖动";可如果上游已经连续失败了几十次,那它显然不是抖动,而是真的挂了。这时候再逐个请求地重试,纯属浪费——正确的做法是熔断:发现上游连续失败到阈值,就直接"开闸断流",在接下来一段时间里对新请求快速失败,连重试都不做,等冷静期过了再放一个请求去试探:

import time

# 熔断器:上游连续失败到一定程度,就先停一段时间,别再试了
class CircuitBreaker:
    def __init__(self, fail_threshold=5, recovery_time=30.0):
        self.fail_threshold = fail_threshold
        self.recovery_time = recovery_time
        self.fail_count = 0
        self.opened_at = None

    def allow(self) -> bool:
        """熔断打开期间,直接拒绝请求,连重试都不做。"""
        if self.opened_at is None:
            return True
        if time.monotonic() - self.opened_at >= self.recovery_time:
            self.opened_at = None         # 冷静期已过,放一个请求去试探
            self.fail_count = 0
            return True
        return False                      # 还在冷静期:快速失败

    def record(self, success):
        if success:
            self.fail_count = 0
        else:
            self.fail_count += 1
            if self.fail_count >= self.fail_threshold:
                self.opened_at = time.monotonic()   # 失败太多,打开熔断

坑 3:重试一个"可能已经成功"的请求,要小心幂等性。有一种最阴险的失败:上游其实已经处理成功了,只是返回响应的路上网络断了,你没收到。你以为失败了,一重试,这个请求就被执行了两次——对大模型 API,这意味着同一份内容生成两次、计费两次。解法是幂等键:给一个请求生成一个唯一的 ID,重试时一直带着同一个 ID,上游就能识别"这是同一个请求的重试",从而不重复执行:

import uuid

# 幂等键:重试同一个请求时,带上同一个 idempotency key
def call_llm_idempotent(prompt, idem_key=None):
    """重试时复用同一个幂等键,上游能识别这是同一个请求的重试。"""
    if idem_key is None:
        idem_key = str(uuid.uuid4())      # 一个请求只生成一次
    return llm_api.create(
        prompt=prompt,
        headers={"Idempotency-Key": idem_key},
    )
    # 关键:idem_key 在"一个请求的所有重试"间保持不变 ——
    #       即便上游其实已成功、只是响应丢了,重试也不会重复计费/重复执行。

熔断、重试、幂等串成一条完整的调用链,就是真正上线能用的样子:

# 把熔断 + 重试 + 幂等串成一条完整的调用链
breaker = CircuitBreaker()


def robust_call_llm(prompt):
    if not breaker.allow():               # 1. 熔断打开:快速失败,不浪费
        raise RuntimeError("上游熔断中,请稍后再试")
    idem_key = str(uuid.uuid4())          # 2. 一个请求一个幂等键
    try:
        result = call_with_budget(
            lambda: call_llm_idempotent(prompt, idem_key)
        )
        breaker.record(success=True)      # 3. 成功:重置熔断计数
        return result
    except Exception:
        breaker.record(success=False)     # 4. 失败:累计,可能触发熔断
        raise

坑 4:流式请求的重试,要看"流到哪一步断的"。大模型的流式输出(SSE),如果连接还没建立就失败,和普通请求一样重试即可;但如果已经吐出了一部分 token 才断,直接整个重试会让用户看到前半段内容重复一遍。这种情况要么整段重来并明确丢弃已收到的部分,要么干脆不重试、把已有内容返回给用户——别无脑套用普通重试。坑 5:重试一定要配监控。重试会悄悄掩盖问题:上游成功率已经跌到 80%,但因为重试兜底,你的服务对外看起来一切正常——直到重试也兜不住才爆。所以重试次数、重试成功率、熔断开关状态都要打点上报,让"悄悄发生的重试"变成"看得见的指标"。

关键概念速查

概念 / 手段 说明
重试风暴 大面积失败时所有请求一起重试,把上游流量瞬间放大成雪崩
瞬时故障 超时、429、503 等过会儿可能自愈的错误,值得重试
确定性失败 400、401 等问题在请求本身的错误,重试也不会成功
指数退避 重试间隔随次数按 2 的幂次增长,并设上限封顶
抖动 jitter 给退避时间加随机,打散多客户端同时重试的惊群
惊群效应 大量客户端用同一退避公式,算出同一时刻一起重试
重试预算 给重试设次数与总时间双上限,任一到顶就停手
Retry-After 上游响应头明确给出的重试间隔,优先级高于本地退避
熔断器 上游连续失败到阈值就开闸断流,快速失败不再重试
幂等键 重试复用同一 ID,避免响应丢失导致的重复执行与计费

避坑清单

  1. 别无脑重试,重试有代价,大面积失败时会酿成重试风暴。
  2. 重试前先分类错误,400、401 这类确定性失败一次都别试。
  3. 拿不准的错误默认不重试,把举证责任交给"该重试"那一边。
  4. 重试之间必须等待,失败立刻重试是对过载上游火上浇油。
  5. 退避时间随重试次数指数增长,并设一个上限封顶。
  6. 给退避加随机抖动,打散多客户端同一时刻重试的惊群。
  7. 重试要有预算:次数与总时间双上限,任一先到就停。
  8. 重试总预算要小于用户和上游的超时,别做无用功。
  9. 上游给了 Retry-After 就听它的,优先级高于本地退避。
  10. 连续失败到阈值要熔断,流式请求重试和幂等性都要单独处理。

总结

回头看那串"一抖动就雪崩、白烧配额、火上浇油、惊群拍上去"的问题,以及我后来在重试上接连踩的坑,最该记住的不是某一个退避公式的写法,而是我动手前那个想当然的判断——"调用失败了,就重试,试到成功为止"。这句话错在它只看见了重试的好处,没看见重试的代价。我以为重试是免费的、无害的,所以多多益善。可我忽略了一件事:每一次重试,都是一个实实在在的额外请求当上游只是偶发抖动,这点代价微不足道;可当上游真的出了故障、本就在过载边缘,你的服务里成百上千个请求一起重试,这股被放大了好几倍的流量,会把一次本可几秒自愈的小抖动,直接顶成一场谁也起不来的雪崩。重试从来不是"试得越多越好",它是一把双刃剑——用得克制,它替你扛过抖动;用得无脑,它把你和上游一起拖下水。

所以做好重试,真正的工程量不在"套一个重试循环"那几行代码上。那几行,谁都会写。真正的工程量,在于你要承认"重试是有代价的、必须克制地用",并据此给每一次重试都装上刹车:不该重试的错,你就靠错误分类一刀砍掉;该重试的,你就用指数退避让它越等越久,听懂上游的求救;多个客户端的重试,你就用抖动把它们错开,别凑成惊群;整个重试,你就用次数和时间的双重预算把它关进笼子;上游真的挂了,你就用熔断直接断流,别再做无谓的试探。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"失败了就反复重试"为什么错,再讲错误怎么分类、指数退避怎么算、抖动为什么不能省、预算和超时怎么兜底,最后是 Retry-After、熔断、幂等这几个把重试守扎实的工程细节。

你会发现,大模型 API 的重试,和现实里"给一个总占线的电话回拨"完全相通。你打一个重要的电话,没打通——一个急躁的人会怎么做?他挂掉立刻重拨,不通再立刻重拨,一秒一次,把对方的线死死占住(这就是无脑的、不带退避的重试)。如果对面是个呼叫中心,而此刻很多人都没打通、都在这样猛拨,那这个呼叫中心永远别想腾出一条空线(这就是重试风暴和惊群)。而一个懂分寸的人会怎么做?他先分清情况:如果是"您拨打的号码是空号",那这个号码再拨一万遍也是空号,他立刻就不拨了(这就是区分确定性失败);如果只是占线,他会等一会儿再拨,还占线就等更久——头一两次隔一分钟,后面隔五分钟、十分钟(这就是指数退避);他还不会卡着整点拨,而是随性地错开几分钟,免得和别人撞在一起(这就是抖动);要是对方语音明确说了"请 20 分钟后再拨",他就老老实实等 20 分钟(这就是 Retry-After);而要是试了一下午都打不通,他也不会没完没了,而是先放下、改天再说(这就是重试预算和熔断)。同样是"没打通就再打",可急躁的人占死了线、谁也别想通,懂分寸的人却总能在合适的时机把电话接上——差别不在"重不重拨"这件事本身,只在他有没有把"重拨"当成一件需要分寸和节奏的事,而不是"挂了就拨"那一下

最后想说,重试做没做对,差距永远不会在"本地开发、自己一个人调,几乎不失败"时暴露——本地你只有一个请求,网络稳、上游稳,偶尔失败重试一次就过,根本凑不成"风暴",也撞不上"惊群",重试的代价被"没有规模、没有真实故障"彻底掩盖了,你会觉得"失败了就重试"已经够用。它只在真实的、成百上千个请求并发、上游会在高峰期真实地抖动和过载的线上环境里才显形。那时候它会用最剧烈的方式给你结账:做不好,你会在上游抖一下时,用一场重试风暴把它和自己一起打垮,会为一堆 400 错误白烧掉配额和用户的耐心,甚至因为重试了一个其实已成功的请求而重复计费;而做了,上游偶发的抖动,被你的退避重试悄无声息地兜住,用户根本无感,上游真的故障时,你的熔断会及时断流、不去添乱,等它恢复,你的服务又能平稳地接上。所以别等"一次上游抖动把整个服务带崩"那一刻找上门,在你写下每一处"调用上游 API"的代码时就该想清楚:这次调用失败了,它知道怎么体面地重试吗——它会分清错误吗、它会退避吗、它加抖动了吗、它有预算和熔断吗,这一道道工序,我是不是都替它设计过了?这些问题有了答案,你写下的才不只是一个"本地能跑"的调用,而是一套扛得住上游抖动、经得起真实流量和故障反复考验的可靠重试。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

服务优雅停机完全指南:从一次"一发版就冒 500、用户请求被拦腰斩断"看懂 SIGTERM 与连接排空

2026-5-22 11:29:20

技术教程

API 版本管理完全指南:从一次"改个字段名、第三方集成方全线崩溃"看懂契约思维与版本演进

2026-5-22 11:44:14

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索