这个标题,是我最近一周的真实经历。我给线上一个 AI Agent 服务加上了超时和重试机制,本以为这活儿半天就能干完:超时无非设几个 timeout,重试无非包一层 retry decorator。结果上线后问题反而更多了——同一笔订单在账单里出现了两次,模型偶尔把上一轮的过期结论当作这轮的新事实,甚至有个任务明明已经重试成功了,后台却还在悄悄执行原来的旧逻辑。追了一整圈才发现,超时和重试本身的代码并没有写错,真正的坑在状态清理。
这篇文章我想聊的,就是这个“状态清理”。我会从超时和重试的设计说起,讲清楚为什么普通的接口超时经验在 Agent 场景里不适用,再重点拆解状态清理这个真正的坑:它到底包含哪些状态、有哪些清理策略、什么时候该回滚、什么时候该补偿,以及一套可以直接抄作业的实现骨架。适合正在用 LangGraph、CrewAI、Spring AI Agent 或者自研 Agent 编排的开发者参考,也适合那些准备给自己 Agent 加超时重试但还没想清楚“重试之后世界应该长什么样”的同学。
1. 为什么 AI Agent 比普通接口更需要超时和重试
1.1 长链路决定:超时不能只当“一层拦截器”用
一个典型的 Agent 调用链路长这样:用户请求进来之后,先进入规划阶段,LLM 把请求拆成步骤,然后 Agent 逐个执行工具调用,把每个工具的结果回填到上下文,可能再来一轮 LLM 推理,最后汇总成回答。这条链路里的任何一环都可能卡住:模型服务本身慢、工具 API 慢、下游数据库慢、内部队列积压、网络抖动。
我见过很多服务把这个链路当成一条普通 HTTP 接口来保护,在最外层设一个 timeout,整个调用只受这一个时间限制。结果就是,总超时一旦触发,Agent 内部还在跑的线程并不会立刻被停掉,尤其是一些异步实现的工具调用,主流程已经返回超时错误了,后台任务照旧把数据库字段改了、把消息发出去。这种“超时了但副作用还在”的情况,才是 Agent 场景里最危险的部分——你看到的错误是超时,实际的事故是状态被乱改。
1.2 重试的对象是“整段流程”而不是“一次请求”
普通接口失败后重试,重建连接再发一次请求就行,因为请求是无状态的。Agent 不一样:Agent 的每一次执行都对应着上下文的变化,重试一个 Agent 步骤,本质上是重放“思考—调用—回填”的整段流程。
这里有一个很多人容易踩进去的误区:代码里看到“LLM 调用失败就再调用一次 LLM”,听起来很自然,但第一次调用可能已经产生过一次工具调用和工具返回。重试的时候,LLM 看到的历史消息里还留着上一次的中间结果,此时模型很容易把旧的工具结论当成当前状态,或者干脆因为消息里同时出现两个相互矛盾的 tool_result 而开始胡说八道。我自己的经验是,如果只以函数为粒度做重试,不加状态清理,Agent 的错误率往往不会下降,反而会上升。
1.3 真正的目标不是“让请求成功”,而是“让成功的结果正确”
想清楚这个问题再动手,能帮你少走很多弯路。超时和重试的本质,是在一个不确定的环境里追求确定性。但要确定的是结果,而不是动作。如果一次 Agent 任务超时后你简单重试,结果工具被调用了两次,用户被扣了两次钱,那么从业务视角看,这次重试是失败的,甚至在创造事故。
所以我现在的习惯是:先定义“任务成功”的标准——上下文一致、所有工具副作用要么完成要么补偿、最终回复正确——再定义超时和重试策略。尽量把问题管到“结果正确”这一层,而不是只管“调用成功”。
2. 超时参数怎么设才科学
2.1 别再只设一个 timeout 了:分四层来管
写普通接口超时,通常只关心 connect、read、write 这几个;写 Agent 超时,我建议按链路的粒度拆成四层:连接超时、响应超时、单步超时、总超时。
连接超时管的是 TCP 握手和 TLS 握手,一般 3 到 5 秒就够了,再长说明网络层面已经出问题,等下去没有意义。响应超时管的是“发出去之后多久算失败”,模型推理接口可以放得宽一些,工具类 API 要看对应服务的 p99。单步超时管的是 Agent 完成一次“观察—思考—行动”循环所允许的时间,这一层必须比单次 LLM 调用更长,因为一次循环里可能包含多轮工具交互。总超时则是兜底,管的是整个任务从接入到返回的最长时间。
每一层失败的原因不同,处理方式也不同:连接超时多半是网络或 DNS 问题,重试可能有效;单步超时可能意味着 LLM 输出失控或者工具死锁,需要做取消而不是盲目重试;总超时一触发,哪怕内部逻辑还没跑完,也必须要能停住并且进入收尾。四层超时缺一层,线上就会出现“看起来成功了但实际上没跑完”的假象。
2.2 超时值从哪来:先用 p99,再乘 1.5 到 2
很多同学上来就拍脑袋写 timeout=10,然后线上天天误杀。我推荐的做法是:先接好日志和耗时统计,跑至少一周,算出每个环节的 p99,再以 p99 的 1.5 到 2 倍作为超时阈值。为什么不是 p99 或者 p999?因为网络和模型服务的延迟天然有抖动,阈值卡在 p99 会让 1% 的正常请求被打断,误杀率太高;卡到 p999 又起不到保护作用。乘 1.5 到 2 是安全缓冲,既留出抖动空间,又能挡住真正卡死的请求。
下面是我在某个项目里用的参考值:
| 环节 | p99 | 建议超时 | 说明 |
|---|---|---|---|
| LLM 非流式首 token | 5s | 10s | 模型推理常见波动 |
| 工具 API 调用 | 3s | 6s | 按下游服务实际统计 |
| Agent 单步循环 | 25s | 45s | 含多轮工具交互 |
| 整个任务 | 90s | 150s | 兜底,含重试总时长 |
注意,这里只是参考,每个人业务不一样,一定要基于自己的线上数据来定。表里的 150s 是指单次尝试的总时长,如果配了重试,整体耗时还要按“单次超时 × 最大重试次数”来估算,别让用户等太久。
2.3 超时触发之后:停掉流程,再做收尾
超时不是一抛异常就完事了。我第一次写超时,就在 Agent 循环外面包了一层 asyncio.wait_for,时间到大函数直接抛 TimeoutError,看起来挺干净。但问题来了,被包住的子任务在 asyncio 里并不会因为超时而自动停止,它还在后台跑;如果里面正好是一次真实的工具调用,这个调用最终会成功执行,但主流程已经认为它超时了。结果是“两头都算账”:任务超时重试了一次,后台旧调用又成功执行了一次,副作用翻倍。
所以超时处理要明确拆成两步:第一步,取消整个任务上下文,传一个取消令牌给内部的工具调用,能主动中断的中断,不能中断的至少记录状态;第二步,进入收尾流程,包括释放锁、回滚未提交的数据库事务、把任务标记成 canceled。这两步做完,超时才算真正处理完。这也是后面状态清理的第一层要求。
3. 重试策略:先算风险账,再写代码
3.1 什么错该重试,什么错重试就是雪上加霜
我见过一份重试配置,内容就四个字:所有异常。这看起来省事,实际上是把事故放大。重试的第一原则是区分“可重试错误”和“不可重试错误”。可重试错误包括连接超时、读取超时、HTTP 5xx、限流 429(配合退避)、数据库死锁、偶发的网络抖动;不可重试错误包括 4xx 业务错误、参数校验错误、权限认证失败、模型输出被安全策略拦截、下游业务规则明确拒绝的操作。
判断标准很简单:这个错误重试后有没有可能成功?如果是因为请求本身写错了,重试一万次也还是错。比这个更重要的第二条原则是:重试之前必须确认这个操作是否幂等。LLM 生成本身不幂等,同样的 prompt 两次结果可能不同;工具调用更不幂等,尤其是下单、扣费、发消息这类有副作用的操作。如果你只需要记住一条,那就是:给工具调用带上幂等键,否则重试就是在赌博。
3.2 退避别用固定间隔,用“指数退避 + 抖动”
固定间隔重试是最糟糕的写法之一。失败后立刻重试,大概率还是失败,因为下游服务可能正处于过载或者雪崩状态;固定间隔又会让所有失败任务在同一时间点集中重试,形成“重试风暴”,把本来还有救的服务直接打挂。
我常用的公式是:delay = min(base * 2^attempt, cap) + random(0, jitter_max)。base 取 1 秒,cap 取 30 秒,jitter_max 取 2 秒,attempt 从 0 开始数。第一次重试等 1 秒左右,第二次 2 秒,第三次 4 秒,最多 30 秒后封顶。抖动的作用是让一批同时失败的任务在重试时间上错开,不要像上课铃一样整齐冲到下游。
这里有个细节:很多人以为“最多重试 3 次”就稳了,实际上在 Agent 场景里,一次 Agent 任务的耗时可能本来就长,三次重试加退避可能让整体时长超过用户容忍极限。我一般把最大重试次数压到 2 次,然后靠更前置的超时和更合理的工具调用来降低需要重试的概率,而不是靠无限重试来兜底。
3.3 用“快照思维”看待重试:从干净状态重新开始
重试的正确姿势,不是从失败的那个点继续走,而是回到一个干净的快照点,重新走整段流程。每次任务开始前,把上下文、工具调用列表、临时状态做一份快照;重试时,先把任务恢复到快照时的状态,再重新执行。这个“快照—恢复”的动作,远比重试本身更重要。
把它想清楚之后,你会发现状态清理的必要性已经浮出水面:如果你不恢复快照,那么下一次执行一定是带着上一次执行的残留状态开始的。而那些残留状态,恰恰是 Agent 最容易出错的地方。快照也不能只包含内存里的消息列表,还要考虑外部系统的状态:已经发出的 HTTP 请求怎么处理?已经写进数据库的半成品记录怎么回滚?已经加上的分布式锁什么时候释放?这些问题,放到下一章一起解决。
4. 真正的坑:状态清理
4.1 先盘点:Agent 场景里到底有哪些状态
一说状态清理,很多人第一反应是“清一下 Redis 缓存”。但 Agent 场景下的状态比想象中宽得多。我的习惯是画一张状态清单,把每一项标记清楚:会话上下文(messages 列表、token 计数、截断策略)、工具调用历史(调过哪些工具、参数是什么、结果是什么)、执行环境(临时文件、子进程、环境变量)、持久化数据(数据库记录、Redis 缓存、消息队列消息)、外部副作用(已经发出去的邮件、已创建工单、已扣款)、分布式资源(锁、事务、幂等键)。
每一类状态的生命周期还不一样:内存态随任务结束就没了,持久化状态要显式清理,外部副作用可能要靠补偿操作。如果不先做这份清单,后面写的清理代码大概率是东补一块西补一块。
4.2 三种清理策略:快照回滚、补偿、隔离标记
针对不同的状态,我整理了三种策略。第一种是快照回滚,适用于可以覆盖的内存态和临时状态:重试时直接把状态恢复到任务开始时的快照,简单粗暴但有效。第二种是补偿,适用于外部副作用:如果一笔扣款已经发生,就发起退款;如果一封邮件已经发出,就发送更正通知;如果工单已创建,就关闭工单。补偿不追求把世界恢复原样,只追求业务上可接受的一致。第三种是隔离标记,适用于无法回滚也不适合补偿的场景:给这次重试打上新的 request_id、task_version,让所有日志、数据库写入、下游调用都带这个版本号,以后排查和审计都有据可查。
三种策略不是互斥的,一个正常的重试过程往往是“回滚内存态 + 补偿外部副作用 + 用版本号隔离新尝试”。
4.3 上下文污染:Agent 特有的脏状态
这里要单独把“LLM 上下文”拿出来说,因为它太容易出问题,而且普通后端开发没有这个概念。普通服务重试时,之前请求的数据不会再影响新请求;但对 Agent 来说,消息列表本身就是最重要的状态,它会参与模型的每一次推理。
最典型的错误是:重试时不清除上一轮的工具调用结果,直接把新的查询结果追加到旧结果后面。假设第一轮查询库存返回 10 件,第二轮下单接口超时,重试开始时 LLM 看到的消息里还挂着“库存 10 件”和一条“下单未确认”的工具返回。这时候模型很可能被误导,认为下单已经完成了,直接给用户回一句“已下单”。更隐蔽的情况是,旧的 tool_result 和新的 tool_result 同时存在,两者互相矛盾,模型开始出现幻觉,说一些根本不在数据里的结论。
所以重试前一定要清理 messages 列表里的工具调用历史,或者把整份消息恢复到上一个成功里程碑。这一步做不好,超时重试就是给 Agent 喂毒。
4.4 三个特别隐蔽的坑:孤儿任务、锁过期、幂等记录过期
光有上下文清理还不够,线上还有三个坑几乎每个人都会踩。第一个是孤儿任务。主流程超时返回后,内部异步任务还在跑,过了几十秒把数据库状态改了,然后又过几分钟,重试任务的旧版本写入反而覆盖了新数据。解决办法是体系化的任务取消:注册表登记所有子任务,超时时逐个 cancel,数据库写入强制带 task_version 做乐观锁。
第二个是分布式锁过期。锁一般会设过期时间防止死锁,但 Agent 的单步执行可能比锁的过期时间长,锁先过期,另一个任务拿到锁开始操作,原来的任务还继续写,两个任务同时改同一份数据。我的习惯是给锁做续期,而不是把过期时间设到天荒地老。
第三个是幂等记录过期。如果幂等键的保留时间比对账重试窗口还短,第一个请求处理完之后记录过期了,第二个重试请求来了查不到记录,于是又执行了一遍。幂等记录的保留时间应该覆盖“最慢一次 Agent 任务 + 最大重试窗口”,宁长勿短。
5. 一个可落地的实现参考:带超时重试与状态清整的 Agent 编排
5.1 设计思路:让状态管理成为任务生命周期的一部分
直接给代码之前,先讲设计。我给 Agent 加超时重试的最终版本,和最初版本最大的区别是:不再把“超时重试”当作函数外层的装饰器,而是把它放进“任务生命周期”里。也就是说,每个 Agent 任务有明确的开始、尝试、成功/失败/取消这几个阶段,超时和重试只是触发阶段迁移的事件,状态清理在每次阶段迁移时被显式调用。这样做的好处是,你永远知道当前状态处于哪个版本,重试后该往哪走。
为了表述清楚,我用 Python 写一个简化版本,核心是三个组件:TaskContext 保存任务状态;RetryPolicy 决定是否重试;状态管理负责快照、恢复、清理。实际工程里可以换成 LangGraph、CrewAI 或者自研框架,但状态生命周期这个思想是通用的。
5.2 核心代码骨架:带快照与恢复的 Agent 循环
我写一个可直接参考的骨架:
import copy import random import time class AgentTask: def __init__(self, task_id, request): self.task_id = task_id self.request = request self.messages = [] self.tool_calls = [] self.status = "pending" self.version = 0 def snapshot(self): return copy.deepcopy({ "messages": self.messages, "tool_calls": self.tool_calls, "status": self.status, }) def restore(self, snap): self.messages = snap["messages"] self.tool_calls = snap["tool_calls"] self.status = snap["status"] self.version += 1 class RetryPolicy: def __init__(self, max_attempts=2, base=1, cap=30, jitter=2): self.max_attempts = max_attempts self.base = base self.cap = cap self.jitter = jitter def backoff(self, attempt): delay = min(self.base * (2 ** attempt), self.cap) return delay + random.uniform(0, self.jitter) def is_retryable(self, error): return isinstance(error, (ConnectionTimeout, ReadTimeout, Upstream5xx, Throttled)) def run_agent(task, policy, step_timeout=45): snap = task.snapshot() for attempt in range(policy.max_attempts): try: result = execute_agent_loop(task, timeout=step_timeout) task.status = "success" return result except RetryableError as e: if attempt >= policy.max_attempts - 1: task.status = "failed" compensate(task) raise task.restore(snap) # 状态清理:回到起点 release_temporary_resources(task) # 状态清理:释放临时资源 time.sleep(policy.backoff(attempt)) except FatalError: task.status = "failed" compensate(task) raiseexecute_agent_loop 内部就是常规的 LLM 调用和工具调用循环,按你自己用的框架实现即可。注意,代码里的 restore 只还原内存态,tool_calls 里的外部副作用不会凭空消失,所以对非幂等操作,还要在补偿逻辑里调工具的撤销接口,或者在下游用幂等键识别重试。实际项目里 compensate 和 release_temporary_resources 需要根据你的业务清单逐一补全。
5.3 状态清理的接入时机:重试前、结束前、取消时
最后总结一下状态清理应该在哪些时机接入。重试前:恢复快照、释放上一轮临时文件与子进程、给锁续期、更新 task.version。任务成功结束时:把幂等记录落库、释放所有锁、关闭连接、写审计日志。任务失败时:执行补偿、把所有未完成工具调用标记为 failed、释放锁。任务被取消或超时时:先发取消信号,等子任务停住,再做上面失败分支的处理。
还有一个容易被忽略的地方:消息队列场景里,任务可能被多台机器消费,状态清理不能只在本机内存里做,分布式锁和幂等记录必须放在 Redis 或者数据库里。版本号则是贯穿始终的那条线,所有下游调用和数据写入都带上它,这样即使出了幺蛾子,你也能在日志里一查到底。
6. 线上踩坑实录与排查手册
6.1 案例一:重试导致同一用户收到两笔订单
前阵子排查过一个线上问题,用户反馈“下单两次被扣两次钱”。看日志,第一次调下单接口时发生了读超时——请求已经到达服务端,服务端也处理完了,只是响应回来时客户端连接断了。客户端这边捕获超时后直接重试,又没有携带幂等键,于是服务端又创建了第二笔订单。
这个案例其实和状态清理有两层关系:一层是幂等键没有贯穿整个重试生命周期,另一层是第一次请求的残留副作用没有被补偿或标记。修复方法是给所有非查询工具接入幂等键,重试时复用同一个幂等键;同时在任务上下文里记录“该工具是否已成功执行”,如果已执行则不再调第二次,而是把之前的返回结果直接拿来用。
6.2 案例二:模型回复里出现自相矛盾的数据
另一个高频问题:用户问“还剩多少库存”,第一轮查询返回 10 件,第二轮因为更新接口超时触发重试,重试后查询却返回 0 件。按理说 0 件是新结果,但消息列表里旧结果没有清掉,模型看到两个 tool_result,最后回复“系统显示库存 10 件,但实际可能已经缺货”。
表面看是模型能力问题,实际上是我们把脏上下文喂给了模型。清理方法就是前面说的快照恢复,重试时把 messages 列表恢复到上一轮工具调用之前的状态,绝不能让旧工具结果留在里面。
6.3 案例三:孤儿任务把重试结果覆盖了
还有个特别隐蔽的场景:总超时设置了 60 秒,主流程超时后返回“系统繁忙”并触发重试,重试任务 10 秒后就成功了;但第一次任务里的一个异步子任务直到 75 秒才执行完,把数据库里的状态字段覆盖回了旧值。检查时发现第一次任务的线程还活着,因为它没被真正取消。
修复分三步:引入任务注册表,超时时主动 cancel;所有数据库写入带 task_version,用乐观锁防止旧版本覆盖新版本;异步子任务完成前检查当前任务版本,版本不一致就主动放弃结果。
6.4 状态问题排查清单:五分钟定位脏状态
最后分享一套排查清单,遇到 Agent 行为异常时按顺序检查:消息列表里是否存在多条互相矛盾的 tool_result;数据库里是否有同一 request_id 的多条业务记录;分布式锁的持有者还是不是当前任务;日志里同一个 task_id 是否出现多个不同 version;临时目录是否残留本次任务相关文件;幂等表里是否有重复键。六个问题对应六种脏状态,大多数线上事故都能在这个清单里找到答案。
| 症状 | 可能原因 | 检查方法 | 解决方向 |
|---|---|---|---|
| 模型回复矛盾 | 旧 tool_result 混入上下文 | 查看 messages 中工具结果 | 重试前快照恢复 |
| 重复扣费/重复下单 | 缺少幂等键 | 查业务表 request_id | 工具调用统一幂等键 |
| 任务失败后数据被改 | 孤儿子任务仍在执行 | 查线程/进程调用链 | 任务注册表 + 版本号 |
| 两个任务同时改数据 | 分布式锁过期 | 查锁过期配置 | 加锁续期机制 |
| 重试后行为不一致 | 幂等记录过期 | 查幂等表保留时间 | 延长保留时间 |
写到这里,其实想说的核心观点就一句:给 AI Agent 加超时和重试,难点不是技术,而是对“状态生命周期”的认识。你在设计重试策略之前,应该先问自己:重试之后,这份任务的状态应该长什么样?哪些状态要保留,哪些要回滚,哪些要补偿,哪些要靠版本号隔离。我个人是把状态清单、快照恢复、补偿、隔离标记这套机制当成 Agent 基建的一部分来维护的。如果你正准备给现有 Agent 加超时重试,我的建议是先别急着写装饰器,先花半小时把状态清单列出来。列完你就会发现,真正的活都在状态清理这一侧。