重试策略:区分临时故障和永久故障
场景引入:agent 最常用的「笨办法」
一个自动化 agent 在执行任务时遭遇错误,最常见的反应是什么?重试。再试一次。
这个反应很自然,也确实解决过大量问题。但它有一个隐藏的陷阱:不是所有错误都值得重试。
网络抖动、超时、服务器暂时过载——这类错误有自然消退性,等一等就会好,重试确实有效。但认证失效、参数写死、资源根本不存在——这类错误无论重试多少次,结果都一样,重试只是在浪费时间,更糟糕的是,它把整个任务的执行周期卡在了一个注定失败的循环里。
对人类来说,这个问题很容易分辨。经验丰富的工程师看到「401 Unauthorized」,会直接去查 token;看到「404 Not Found」,会先去确认资源路径。但对于自动化执行的 agent 系统,这个判断必须被显式编码,否则它就会用同等的努力去处理两种截然不同的问题。
本文来聊一件事:如何在 agent 系统里设计一个合理的重试策略,核心是区分临时故障和永久故障。
两种故障的本质区别
临时故障和永久故障的区别不在于严重程度,而在于时间维度上的自愈性。
临时故障有「自然消退性」——问题会随着时间自动解决。DNS 解析偶尔失败,但 DNS 服务器恢复后请求就通了;某个微服务实例过载,但负载均衡把它摘掉或它自己恢复后,流量就能正常路由。这类问题的共同特征是:它们描述的是系统的瞬时状态,而不是系统配置的永久性错误。
永久故障没有自愈性,重试不会改变结果。认证凭证过期,不会因为多请求一次就突然有效;接口参数写错了,不会因为再发一次请求就自己修正。这类问题的根源是配置或逻辑层面的硬错误,时间在这里不起作用。
区分这两种故障,是设计重试策略的第一步。本质上,你需要回答一个问题:这次失败,是因为「现在不对」,还是因为「就不对」?
真实案例:HTTP 状态码的分类
最直观的分类依据是 HTTP 状态码,这里有相对成熟的行业共识。
5xx 系列,大多是临时故障。502 Bad Gateway、503 Service Unavailable、504 Gateway Timeout——这些描述的是服务器端的问题,通常是瞬时的,可能是某台机器过载、某个服务在重启、或网络链路短暂抖动。重试是合理的。
4xx 系列,大多是永久故障。400 Bad Request 表示请求本身不符合服务端要求,参数错了、格式错了;403 Forbidden 表示服务端明确拒绝了这次请求。这些问题的根源在请求侧,重试同样的请求不会得到不同结果。
但要注意两个容易踩坑的状态码。
HTTP 429 Too Many Requests,即 rate limit,实际上是临时的。它表示你在一个时间窗口内发起了太多请求,服务端选择暂时拒绝服务。这是动态限流,不是配置错误——等限流窗口过去再重试,大概率能成功。更重要的是,429 通常会附带一个 Retry-After 响应头,明确告诉你等多久。如果你的重试策略忽略这个信息,既不等够时间、也不做退避,就是在主动撞墙。
HTTP 401 Unauthorized,大多数人认为它是永久的,但有一个边界条件:token 有刷新机制。如果你的认证方案支持 token 刷新,那么 401 只是告诉你「当前 token 失效了」,重试之前先刷新 token,然后继续——这其实是一个可恢复的场景。大多数 OpenAI API 调用遇到 401 都要走这个路径:触发刷新、重试,而不是直接放弃。
所以更准确的说法是:4xx 不全是永久故障,5xx 也不全是临时故障。状态码是重要的参考,但不是非黑即白的判决书。
指数退避:临时故障的正确应对方式
对于确认是临时的故障,重试策略也有高下之分。
最朴素的做法是固定间隔重试:失败了,等 1 秒,再试。失败了,等 1 秒,再试。这个策略有一个致命缺陷:在服务器已经过载的场景下,所有客户端同时重试,等 1 秒后再次同时请求,相当于对服务器发动了一轮 DoS 攻击。这会让本已紧张的服务端资源更加吃紧。
指数退避是更合理的策略:每次失败后,等待时间按系数增长。第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,以此类推。这个策略的核心逻辑是:让重试请求的密度随时间自然衰减,给服务端恢复留出空间。
但指数退避有一个必须加的约束:设上限。指数增长没有尽头会变成什么?等 1 秒、2 秒、4 秒、8 秒、16 秒……到第 10 次已经等了 17 分钟,到第 15 次已经超过 24 小时。如果一个故障注定要持续超过你设定的任务周期上限,这种无限增长的重试只是在拖延失败的时间,而不是在争取成功。
上限通常有两层:第一层是最大重试次数,比如最多重试 5 次;第二层是最大等待间隔,比如单次等待不超过 60 秒。两层约束共同保护系统不要在无意义的重试里消耗过多时间和资源。
另外,还有一个常见但容易被忽视的优化:加一点随机抖动(jitter)。如果所有客户端的重试间隔都是固定的 1、2、4、8 秒,那在时间轴上会形成周期性重叠——大量请求在相同的时间点同时涌入。加入随机偏移(比如在 4 秒的基础上 ±1 秒),能把重试请求在时间轴上分散开,减少峰值压力。
Agent 场景的特殊性
传统的 API 调用,重试失败的成本是几分钟的等待。但 agent 任务不同,它的执行周期可能是几十分钟,占用着上下文窗口(按 token 计费),维护着执行状态。如果被永久故障卡在重试循环里,浪费的是整个任务的上下文窗口,同时阻塞后续任务调度。
更隐蔽的问题是:重试循环里的错误会积累。每次失败产生的错误信息都会写入上下文,被重试 20 次还没识别出永久故障,上下文里就塞满了重复的错误摘要,既浪费 token,又干扰真正有价值的错误分析。上下文窗口是有限资源,对它的使用必须有所克制。
判断框架:三个维度
综合以上分析,我建议用三个维度来判断一个故障是否值得重试。
第一,看 HTTP 状态码。这是最直接的信号。2xx 是成功,不用管;5xx 大概率是临时故障,值得重试(设上限);4xx 大概率是永久故障,不值得重试,但要注意 401(可能需要 token 刷新)和 429(带 Retry-After 的临时故障)这两个例外。
第二,看错误信息里的关键词。状态码是粗粒度判断,错误信息是细粒度补充。如果错误描述里出现这些词,通常意味着临时故障:timeout、connection refused、connection reset、temporary failure、service unavailable、too many requests。如果出现这些词,通常意味着永久故障:not found、auth failed、unauthorized、invalid parameter、bad request、permission denied、forbidden。这些关键词能帮助你在没有明确状态码的场景(比如自定义错误码、数据库异常)里做判断。
第三,看历史重试记录。这个维度很多人忽略,但非常有效。如果同一个操作在过去 10 分钟内已经重试过 3 次以上还是失败,这个故障大概率是永久的。 这个判断的逻辑是:如果是临时故障,在足够长的时间窗口里应该有机会成功;如果连续多次都失败,说明问题不随时间消退,本质上已经变成永久故障。这个历史维度特别适合作为兜底策略——在代码层面你可能无法精确判断,但通过重试历史可以间接推断。
三个维度联合使用,优先级是:关键词 > 状态码 > 历史记录。关键词最精确(错误信息直接说明了问题类型),状态码次之,历史记录是兜底。
在 agent 系统里的实现建议
基于以上分析,在 agent 系统中实现重试策略,有几个具体的工程建议。
让每个 action 声明自己的可重试性。不是所有操作都需要或者适合重试。在定义一个 action 的规格(spec)时,明确声明它是否可重试,以及重试时的最大次数和退避策略。比如一个「发送消息」的 action 是可重试的(网络抖动导致发送失败可以重试),但一个「删除资源」的 action 就不应该重试(删除失败的原因往往是幂等性问题,重试可能导致重复删除或其他副作用)。
在计划阶段就做预过滤。计划系统在编排执行计划时,如果已经知道某个 action 是不可重试的,就应该在执行前把这个约束注入到执行器里。当不可重试的 action 第一次失败时,执行器直接报告失败,而不是进入重试循环。这比在失败后再判断要高效得多,也更符合「fail fast」的原则。
不是更努力,而是更聪明
重试是系统弹性的体现,但弹性不等于蛮力。
一个好的重试策略,不是让系统在失败的泥潭里越陷越深,而是让系统具备判断能力:能区分「现在不行但过会儿可以」和「怎么试都不行」这两种情况,然后把努力放在值得的地方。
对于临时故障,重试加指数退避,给系统自我恢复的机会。对于永久故障,快速失败、精准报告,把上下文窗口留给真正重要的任务。
说到底,重试是一种选择权。聪明地使用这种选择权,比无条件地使用它,需要更多的设计思考。