说实话,刚接到这个需求时,我觉得“给 AI Agent 加超时和重试”是个挺常规的活儿,无非就是外部调用包一层timeout,出错就retry。真正动手做完一轮才发现,这事的难度根本不在“怎么加超时重试”,而在“加了之后,怎么保证 Agent 内部那坨状态不出乱子”。我花了大半个周末排查线上 Agent 的诡异行为,最后定位到的问题,全部集中在状态清理上——有些是脏状态残留,有些是上下文污染,有些是并发下的状态串线。这篇文章把我整个排查过程和最终沉淀的方案完整梳理出来,希望能帮后来人少走几个弯路。
1. 为什么 AI Agent 比普通服务更需要超时与重试
1.1 AI Agent 的不确定性决定了它必须“容错”
传统的后端服务,比如一个订单接口,它有明确的输入输出,超时和重试策略可以精确地按接口维度去设置。但 AI Agent 不一样。它的执行路径本身是模型“自由发挥”出来的,同一个用户问题,上次可能 3 步就完成了,这次可能要 8 步,中间还可能调用工具、读取文件、检索知识库。任何一个环节卡住,整体就悬在那儿了。
而且 LLM 接口的延迟波动非常大。我测过同一个模型的响应时间,P50 是 2 秒,P99 能飙到 30 秒,高峰期甚至直接超时。再加上 Agent 经常要串行调用多次模型,假设每次调用 P99 是 20 秒,一个 5 步的 Agent 任务,理论上限就是 100 秒。用户根本等不了这么久,所以从产品体验角度,超时是必须的。
但加了超时和重试之后,问题就来了。普通服务超时后可以直接返回错误,但 Agent 超时后,它可能已经走到了第 3 步,内部状态已经变了。如果你直接重试,是重跑整个流程,还是从第 3 步继续?这两种选择背后,是两套完全不同的状态管理策略。
1.2 重试策略的本质争论:全量重跑 vs 断点续跑
我最初想的很简单:超时就全量重跑,最干净。但很快发现不对。有些 Agent 步骤是有副作用的,比如已经调用了一个“发送邮件”的工具,邮件都发出去了,你全量重跑,会再发一遍。这种场景下,重试的目标应该是“让 Agent 最终完成目标”,而不是“让 Agent 重新执行一遍过程”。
所以设计重试策略前,你得先想清楚:你的 Agent 适合哪种恢复模式?我后来的做法是把 Agent 的执行路径拆成“只读阶段”和“写操作阶段”,只读阶段超时可以做无缝重试,写操作阶段超时则必须把状态快照下来,人工介入或者从上一个安全检查点续跑。这个区分是后面所有状态清理方案的地基。
1.3 超时重试是手段,状态一致性才是核心
如果你只是简单地在外面套了个tenacity的装饰器,遇到超时就重试,那大概率会踩中状态污染的坑。因为 Agent 的状态不是孤立的,它运行时的每一步都会修改内部的上下文。这里的上下文包括:对话历史、已收集的中间结果、工具返回的数据、临时的变量缓存,甚至外部依赖里持久化的数据。
这些状态的生存周期如果不受超时和重试逻辑的约束,就会出现三种典型的脏状态问题:
- 残留状态:上一次运行到一半留下的临时变量,被下一次重试读到了。
- 污染状态:上一次运行写坏了外部存储里的数据,导致重试时会读到错误内容。
- 串线状态:同一进程中有多个 Agent 实例在跑,A 实例的中间数据被 B 实例读取了。
这些问题在普通服务里不太突出,但在 AI Agent 里会被放大,因为 Agent 的执行路径是动态的,你没法像传统服务那样通过参数组装来隔离上下文。后面我会逐个展开,先说清楚超时重试为什么要做,接下来进入实操环节。
2. 给 Agent 加超时:从 timeout 参数到线程硬杀
2.1 你以为的 timeout 参数其实只覆盖了“网络等待”
一开始我用了最直白的方案:在调用 LLM 的客户端里设置timeout参数。比如 OpenAI 的 Python SDK 支持timeout=30,这确实能限制单次 HTTP 请求的等待时间。但问题在于,Agent 的一个“步骤”不只是一次 LLM 调用,而是“思考 + 调用工具 + 处理结果”的完整循环。
举个例子,一个 Agent 要回答“帮我查一下上季度销售数据并做个分析”,它的执行过程可能是:
- 调用 LLM 决定要查什么数据
- 调用数据库工具执行 SQL
- 拿到 SQL 结果后再调用 LLM 进行分析
这 3 步里任何一步都不慢,但加起来就是很慢。如果用户端只设置了 60 秒超时,你只给每一步设置了 20 秒的 timeout,反而会触发错误——因为用户等不到整体完成。所以,Agent 级别的超时需要的是“整体执行时间预算”,而不是单次 API 调用的超时时间。
我经过几次痛苦的现场排障后,给团队定的原则是:在入口处给整个 Agent 运行逻辑包一个统一的超时控制,内部每次外部调用的 timeout 必须小于整体预算,留出余量。
2.2 硬杀线程不是好方案,但有时是真没办法
先说结论:Python 里给 Agent 包一个threading.Thread,然后thread.join(timeout=60),超时后不管线程强制结束,这是最粗暴也最容易留坑的做法。
我之前真这么干过。现象是:线程确实在 60 秒时被“抛下”了,主流程继续走,但那个线程实际上并没有死,它还在后台继续执行——因为 Python 的线程无法被强制杀掉,它在跑 LLM 请求的阻塞调用时,你调用join超时只是放弃了等待,线程本身还活着。它后面还会继续往里写状态,造成的结果是:主流程以为任务失败了,开始清理状态并重试,但旧线程还在后台修改同一个状态对象。
这就导致了我在开头提到的“诡异行为”:有时候重试第一次就成功了,但结果却是旧的;有时候明明任务显示失败了,但数据库里却出现了写入记录。
如果你一定要用线程硬杀的方式,至少在状态保护上要做到这几个最低要求:
- 给每个线程分配独立的上下文对象,不能共享同一个 dict。
- 线程结束后不要立即销毁上下文,要留一个“泄气期”,让后台线程有机会写完报错信息。
- 主流程只在“线程安全退出标志”被置位时才读取上下文,否则一律丢弃。
但这只是保住面子,治标不治本。真正可靠的方案还是在 Agent 执行循环内部处理超时状态。
2.3 正确姿势:在 Agent 执行循环内部做超时检查
我把方案改成了“协作式超时 + 外部兜底”。核心逻辑是:Agent 的主循环在每次迭代开始前检查已经消耗的时间,如果超过预算,就主动中断,并标记一个“超时终止”的内部状态。这样做的关键是——状态是在 Agent 自己掌控下被清理的,而不是从外部硬掰的。
class AgentExecutor: def __init__(self, max_duration=60): self.max_duration = max_duration self.state = AgentState() def run(self, task): start = time.time() while True: if time.time() - start > self.max_duration: self.state.status = "timeout" self.state.cleanup() return self.state step_result = self.execute_next_step(task) if step_result.is_final: return step_result def execute_next_step(self, task): # 调用模型、工具、更新状态 ...这里最重要的细节是最后一次循环可能正好卡在一个耗时的工具调用上,所以我还是会保留外部兜底,但兜底的职责从“杀掉线程”变成“记录异常并触发一段安全清理逻辑”。简单说就是:外部兜底负责通知,内部循环负责善后,两边配合才不会留脏状态。
3. 重试机制:为什么重试后效果反而更差
3.1 重试次数越多,幻觉越离谱
有一种直觉是:Agent 一次没跑成功,重试几次总能成功吧?实际经验告诉我,重试的成功率不是线性上升的,甚至可能下降。原因很简单:LLM 是有记忆的,如果你把上一次失败的历史也放进了 prompt,它会被这些“失败痕迹”带偏。
我做过一个实验:让一个 Agent 解析 PDF 里的表格,第一次跑的时候表格解析工具报错了,第二次重试时上下文里带着“表格解析工具报错”的日志,模型就开始反复尝试用各种方式重新解析,反而把自己绕进死胡同了,回答的幻觉明显增多。
所以重试不是简单地“再跑一次”,而是“清掉污染,重新决策”。这也是为什么状态清理在重试场景里如此核心——清理的目标就是让每次重试都拥有和第一次一样干净的起始状态。
3.2 幂等设计:让Agent重试像事务提交一样安全
要让重试安全,先要让 Agent 的执行具备幂等性。这里的幂等不是指“同样的输入一定产生同样的输出”这种数学幂等,而是指“重复执行不会产生多余的外部副作用”。
对于有外部副作用的步骤(发送消息、写文件、调接口),我采取的办法是给每个步骤生成一个step_id,外部系统通过这个 ID 做去重。比如发送邮件的工具会先查一下step_id是否已经成功执行过,如果执行过就直接返回成功,避免重试时重复发送。
这一步做完之后,重试才从“有风险的赌博”变成“可控的恢复操作”。但仅仅是外部副作用幂等还不够,内部的上下文污染同样要处理。
3.3 重试时的状态基线:从零开始还是从检查点开始?
这两种策略我都试过,分别适用于不同场景。如果你做的是“对话型 Agent”,用户在聊天框里问一个问题,Agent 跑挂了你重试,那最好从用户的原始对话开始,重跑一遍完整流程,这种场景下“从零开始”是稳妥的。但如果你做的是“任务型 Agent”,比如一个自动化的数据报表流程,中间可能已经跑了几十步,再从头跑会被外部依赖反复摩擦,这时候最好用“检查点”策略。
检查点策略的实现细节是:每一步完成后把必要的状态(上下文摘要、已收集的数据、当前步骤号)序列化到某个存储里。重试时读取最近的检查点,从那里恢复执行。这里又引出了一个新的状态清理问题:检查点存储里的旧数据要不要清?
当然要清。我有一次就是因为没清旧检查点,导致 Agent 重试时读取到了三个小时前的旧数据,那时候用户的输入已经完全变了。所以检查点的选取和清理规则一定要和业务语义绑定,不能只按“时间最近”来决定。
4. 真正的大坑:状态清理的全局性与层次性
4.1 你清理的只是变量,漏掉的却是“隐式状态”
我最开始做状态清理时,把注意力放在显式定义的变量上,比如self.agent_state["messages"]、self.agent_state["collected_data"]。清理起来也简单,重试前重置成空字典。但真正出问题的,是那些没有显式定义的隐式状态。
典型的隐式状态包括:
- LangGraph 的 StateGraph 节点内部缓存:有些节点函数内部有
lru_cache装饰器,跑过一遍后第二次重试直接命中缓存,返回的却是上一次的旧结果。 - 模型响应里的隐藏上下文:比如你用了某些支持“记忆”的 Embedding 模型,它们可能在服务端做了会话关联。
- 全局单例里残留的数据:比如一个全局的
tool_registry,Agent 执行过程中往里面注册了临时工具,重试时这些临时工具还挂在上面。
这些东西不会出现在你眼前,但会影响重试结果。排查方法也很笨:我在每次重试前后把整个 Agent 进程的堆快照拉出来对比,看到底哪些对象的数量异常增长,才发现很多状态其实藏在各种缓存和全局对象里。
4.2 层次一:进程内的上下文状态
进程内状态清理是最基础的,也是最容易被想到的。我给 Agent 定义了一个显式的AgentContext类,所有中间数据都挂在它下面。清理时重置这个上下文对象,但有一个细节容易忽略:清理不等于“新建一个对象”,因为如果外部还持有旧对象的引用,你新建对象也白搭。
正确做法是“重建 + 弃旧 + 广播”。重建一个干净对象,旧对象标记为废弃,同时给所有可能引用它的组件发送一个重置事件。我之前漏了“广播”,结果状态对象虽然换了,但某些组件里还持有旧的上下文引用,写入了已经“废弃”的状态,导致重试的数据一直对不上。
4.3 层次二:跨 Agent 共享的全局状态
如果你的服务里同时跑多个 Agent 实例,状态清理就升级成了“状态隔离”问题。我踩过一次很经典的坑:有一个 Agent 在运行时会往全局字典里写当前用户的会话信息,另一个 Agent 在另外一个请求里读这个全局字典,结果读到了别人的数据。
解决思路很直接:所有跨请求共享的全局状态,一律改成 key 维度隔离。每个 Agent 实例有一个唯一的execution_id,全局字典的 key 都带上它。同一个 Agent 重试时使用同一个execution_id,清理时按这个 ID 删除对应数据。
这里有一个额外的坑:如果你用了进程内缓存框架(比如 Redis + 本地缓存双写),清理时要同时清两处。我见过只清了 Redis 没清本地缓存的情况,重试时读到的还是本地缓存的旧数据。
4.4 层次三:外部持久化状态
最容易被忽略的状态其实是外部存储里的。比如 Agent 在执行过程中往数据库里插入了一些中间记录,超时后这些记录还留在库表里。重试时,如果查询逻辑不带执行ID过滤,这些残留记录就会混入结果。
我当时的处理方案分两步。第一步,所有 Agent 产生的外部写入都必须携带execution_id字段,这是硬性约束。第二步,重试启动前先执行一个“状态回收”动作,把上个execution_id残留的中间数据删除或标记为失效。注意不能直接 delete,因为有时候你可能还要靠这些残留日志排查问题,所以标记失效更安全。
这个方案实施之后,重试的准确率明显上来了,但代价是代码里多了很多和业务无关的“状态清理”逻辑。后来我把清理逻辑收敛进了一个统一的装饰器,Agent 开发者只需要声明哪些资源是“该状态下创建的”,清理逻辑就自动完成。
5. 完整落地方案:我的状态清理分层模型
5.1 状态清理触发时机的三个“关隘”
实操下来,我发现状态清理不能只挂在“重试前”一个时机,要至少覆盖三个关隘:超时中断时、重试启动时、整个执行结束后。
超时中断时清理是为了防止“僵尸执行”继续污染外部资源;重试启动时清理是为了保证“干净起点”;执行结束后清理则是为了防止状态对象堆积导致内存泄漏。这三个时机我分别设计了不同的清理动作,核心代码大致长这样:
def safe_execute(self, task): execution_id = uuid4().hex self.current_execution = execution_id try: result = self._run_internal(task, execution_id) except AgentTimeoutError: self.cleanup_on_timeout(execution_id) # 关隘1 raise except AgentExecutionError: self.cleanup_before_retry(execution_id) # 关隘2 return self._run_with_retries(task, execution_id) finally: self.cleanup_final(execution_id) # 关隘3 return result三个关隘的清理动作有重叠但侧重点不同,落地时不要合并成一个函数,否则你会被“清理逻辑本身不清干净”的问题反噬。
5.2 需要清理的资源清单
我整理了一张清单,每次做 Agent 服务评审时都会对着过一遍。这张清单的价值在于它不是抽象的“状态”,而是非常具体到位的资源类型。清理时按这张表逐个核对,能避免漏网之鱼:
| 资源类型 | 清什么 | 怎么清 |
|---|---|---|
| 内存变量 | AgentContext、对话历史、中间结果 | 重置上下文对象并广播废弃事件 |
| 缓存数据 | lru_cache、本地缓存、Redis 中间数据 | 按 execution_id 批量删除 |
| 外部存储 | 数据库临时表、消息队列待处理消息 | 标记失效而非物理删除 |
| 工具副作用 | 已发送的调用记录、文件句柄 | 通过 step_id 去重,标记重复调用 |
| 线程局部变量 | 线程内的用户映射、日志上下文 | 在线程结束前手动解绑 |
| 全局单例 | 临时注册的工具、钩子函数 | 恢复至执行前快照 |
这张表看起来简单,实际操作时每一行都有讲究。比如清缓存时,如果你用了@lru_cache且没有预留清理入口,就得在重试逻辑里放弃函数复用的思路,改成每次生成新的函数对象。为了迁移这部分,我花了不少时间改造代码。
5.3 自动状态隔离:给每个Agent实例一张“状态独立空间”
从根子上解决状态污染,我最后做了一套“状态隔离容器”。每个 Agent 任务在执行时绑定一个独立的StateSpace对象,里面包含它用的所有 key-value 存储、缓存命名空间、日志打点槽位。执行结束后把整个StateSpace释放掉,相当于一次性回收了所有资源,不用再担心漏掉某个具体状态。
这套设计参考了测试框架里“隔离事务”的思路。具体来说,我对 Redis 的 key 做了前缀命名空间,对本地缓存做了实例级封装,每个 Agent 拿到的缓存都是独立对象,工具调用时用的临时目录也挂在这个空间下。这样清理逻辑从“枚举所有可能的状态”变成了“释放一个容器”——前者容易漏,后者不会漏。
当然,隔离容器的实现复杂度和系统规模成正比。如果你的 Agent 只是单个脚本里的一个小任务,不需要上这么重的方案,做到第四节的“三个层次”就足够了。但如果你的 Agent 已经在生产环境被多用户并发访问了,建议尽早往隔离容器方向演进。
6. 常见问题与排查技巧实录
6.1 问题速查表:那些年我踩过的坑
| 现象 | 根因 | 解决方案 |
|---|---|---|
| Agent 重试后返回上一次的旧结果 | 进程内缓存命中 | 清理 lru_cache / 更换函数对象 |
| 重试第一次失败第二次成功但数据对不上 | 外部表中残留旧记录 | 查询条件带 execution_id |
| 多个用户互相看到对方的数据 | 全局字典被错误共享 | 换成按 execution_id 隔离的命名空间 |
| 超时后线程还在后台执行 | 线程无法被强制杀死 | 协作式超时 + 外部兜底通知 |
| 重试时 prompt 里带着失败日志导致幻觉变多 | 上下文污染 | 重试时重置对话历史,只保留用户原始输入 |
| Agent 执行结束后内存居高不下 | 状态对象未被释放 | 执行结束后统一释放 StateSpace |
这张表只是高频问题的索引,不要生搬硬套。实际排查时最好的工具是“对比法”——把一个正常完成的 Agent 和一个超时重试过的 Agent 的全链路日志拉出来做 diff,基本一眼就能看到状态哪里不一样了。
6.2 从现象追踪到根因:一次真实的排障过程
有一次线上反馈:Agent 每次第一次执行总是报错,第二次执行就正常,但结果偶尔不对。我用 trace_id 把两次执行的完整链路拉了出来,发现第一次执行调用了工具后拿到了数据,但还没来得及写入上下文就超时了。第二次重试时,因为上下文被重置成“初始状态”,Agent 重新调用了一次工具,但这次工具返回的数据和第一次不同(因为底层数据刷新了),前后两次数据不一致,模型把它合并成了看似正常但实际是错误的答案。
这个问题的根子在于:重试时虽然清除了上下文,但工具返回的数据已经落到了外部存储里,第二次查询时拿到了“新旧混合”的结果。解决方式是把查询逻辑改成“只查交付时间在本次执行之后的数据”,或者干脆用execution_id过滤掉上一次的残留写入。
这类问题特别容易误导人,因为现象看起来是“结果不稳定”,实际上就是状态清理漏了外部维度。排查时一定要把排查视野扩展到进程外部,不能只盯着 Agent 自身的上下文看。
6.3 三个不用写代码也能用的排查技巧
分享三个排查技巧,不依赖复杂工具,都是我在生产环境里实际用过的。
第一个技巧是“埋点看状态”。在 Agent 的入口、退出、重试点分别打日志,输出当前上下文的 hash 值或者关键变量摘要。这样做最大的好处是,线上出问题时能快速判断“重试时上下文是否真是干净状态”。我在关键节点打上了“状态指纹”,一比对就知道哪一步悄悄改了状态。
第二个技巧是“录播回放”。把 Agent 和外部工具的完整交互录下来(请求参数 + 响应内容),排障时按时间线回放。这个比看日志直观得多,能快速发现“第二次调用的参数里带了不该带的数据”这类隐蔽问题。实现上也不复杂,就是给工具调用加一层代理,把输入输出序列化成 JSON 存下来。成本不高,但价值极大。
第三个技巧是“强制重启法”。如果你判断某次重试仍可能带有污染状态,可以给 Agent 的任务加一个配置开关:重试时是“复用已有上下文”还是“完全新开一个上下文”。生产环境里遇到棘手的脏状态问题,直接切成“完全新开”,虽然可能丢失部分中间步骤,但至少能保证结果正确。这个开关在关键时刻非常有用。
6.4 经验沉淀:什么样的Agent需要做重试状态清理
并非所有 Agent 场景都需要同等程度的状态清理。我的判断标准很简单:看 Agent 的执行是否会对状态产生写操作。纯对话型 Agent,每一步只根据用户输入和固定系统提示生成回答,不需要清理倒也能跑;但只要是会调用工具、读写外部存储、收集中间数据的 Agent,状态清理就是不可绕过的环节。
我遇到过最简单但最魔幻的坑:一个只做“把用户问题改写得更清晰”的 Agent,因为我复用了上一轮对话的上下文对象,第二个人来问的时候,系统提示里还带着第一个人的名字。这种事故虽然不严重,但足够让人社死。
所以,无论你的 Agent 看起来多简单,只要它在一个长期存活的服务进程里被反复调用,状态隔离和清理就是必须认真做的事。纯粹的函数式 Agent 在现在这个 AI 应用大环境下其实很少见了,多数业务 Agent 都需要维护多种状态,这也决定了状态清理会成为 AI Agent 工程化里一个长期存在的话题。
7. 方案升级:当重试机制遇到长流程与多智能体
7.1 从“短任务重试”到“长流程断点续跑”
前面的方案处理几分钟内能完成的任务绰绰有余,但有些 Agent 任务是要跑几十分钟甚至几个小时的,比如“对全公司销售数据做季度分析”这类偏批处理的场景。这种任务遇到超时,靠全量重跑的代价太大了。
长流程场景下,我更依赖检查点机制。每个步骤完成后,把关键状态序列化到本地文件或对象存储里。这里有一个非常关键的设计细节:检查点要按“步骤语义”来划分,而不是按“时间间隔”来划分。比如“刚跑完数据清洗”是一个有语义的检查点,而“运行了 5 分钟”则没有意义。重试时,直接从最近一个有语义的检查点恢复,而不是从崩溃点硬续。
不过检查点机制也引入了另一个清理问题:检查点本身的过期清理。我设定了一条规则,同一execution_id的检查点文件只保留最近 3 个,旧的会在写入新检查点时自动删除。同时,整个执行结束成功后,该execution_id的检查点目录直接清空。这样既保证崩溃恢复能力,又不至于让存储被垃圾文件拖垮。
7.2 多个 Agent 协同时的状态清理差异化
在 LangGraph 这类编排框架里,你通常会有多个子 Agent。子 Agent 的状态清理要有差异化:叶子 Agent(执行具体工具调用的)清理频率要高,因为它们最容易产生残留;编排 Agent(决定下一步走哪个分支的)则要保留足够的上下文摘要,否则上游的决策信息就丢了。
我做过一个多 Agent 协同的项目,开始对所有子 Agent 一刀切地做“重试前清空上下文”,结果编排 Agent 重试时因为没有之前的决策依据,完全不知道往后该怎么走,反而陷入死循环。后来我调整策略:叶子 Agent 重试时清空细粒度状态,但编排 Agent 只清理临时变量,保留高层决策摘要。这个平衡点需要根据你的实际业务去摸索,没有统一标准。
7.3 上下文压缩:解决“不可能清理干净”的另一种路径
有一种状态是永远清不干净的,就是模型自身的“已经读到的历史信息”。比如用户上传了一个 20 页的 PDF,Agent 已经把其中 8 页的内容读进上下文了,这时候重试,你怎么清理?你不可能把模型的记忆擦掉,只能选择“折叠它”。
我的做法是引入“上下文压缩层”:重试时不保留原始细粒度内容,而是保留一份经过摘要化处理的“项目进展概述”。模型看到的不再是上一轮跑到的具体第几行代码,而是“已完成数据解析,正在生成分析结论”这样的一句话。这个方法显著降低了重试时的上下文污染程度。
上下文压缩本质上是一种“有损状态清理”——用丢失细节的代价换取模型的重新决策能力。在多数业务场景下,这种取舍是值得的。因为模型本来也不需要你告诉它所有历史细节,只需要让它知道“当前进行到了哪里,目标是什么”就够了。
8. 探索风格与提示词控制在状态清理中的辅助作用
8.1 通过系统提示词降低状态残留的影响
有些状态问题不是代码层面的,而是模型层面的。重试时哪怕代码已经把上下文清干净了,但模型在生成回答时,还是会受到此前失败尝试的影响——因为它在同一个会话里已经“看过”失败的经过了。但这种影响可以通过系统提示词弱化。
我试过在重试时把系统提示词动态调整为“你正在进行一次全新的尝试,请忽略此前所有的失败记录”,实际效果不错,模型的重新决策意愿明显提高。这种做法的原理其实是把“状态清理”延伸到“模型心理状态”层面,让模型把当前执行当作第一次来对待,而不是在失败的经历上不断叠加焦虑。
8.2 工具调用结果的重放与去重
重试时还有一个隐蔽问题:工具调用结果的重放。Agent 第一次执行时已经拿到了工具 A 的返回结果,但因为超时中断了,第二次重试时你清掉了上下文,工具 A 的结果被清掉,Agent 只能重新调用工具 A。如果这个工具有副作用,或者结果是不稳定的,就会导致重试结果偏差。
我的处理方式是在工具调用层做“结果缓存 + 幂等标识”。同一个execution_id下,相同参数的调用直接返回第一次的结果,不再真正执行。这个设计既保证了重试效率(不用反复调用外部工具),又保证了状态一致性(重试结果和第一次尝试基于同样的外部数据)。这一步是“状态清理”的反向操作——不是清掉状态,而是把该保留的稳定状态沉淀下来。
8.3 保留“最后一次成功”的状态快照
最后分享一个很实用的技巧。Agent 每次成功完成一个任务时,我会把它当时的上下文状态序列化保存为“最后一个成功快照”。后续如果这个 Agent 的任务需要增量化推进(比如每周自动跑一次销售分析),就直接从快照恢复,而不是从零开始。
这个快照的清理策略和检查点不同:它不属于某个具体执行,而是属于业务实体(比如“某用户的分析任务”)。只要用户没有主动重置任务,快照就一直保留;用户重置时,快照才会被清除。这种“业务级快照”和“执行级检查点”分离的设计,帮我理清了两个一直混淆的概念,也让重试时的状态恢复有了更清晰的依据。
把超时、重试和状态管理的版本迭代到这版之后,我在内部复盘会上说了一句话:AI Agent 的工程化难点,通常不在“让模型跑通”,而在“让跑了半截又中断的模型安全落地”。超时和重试只是给这个“安全落地”提供了手段,真正的核心是状态清理这层地基。地基没打好,任何看似可靠的超时重试策略都会在某个深夜线上事故中露出马脚。倒是建议把状态清理的预算和代码评审提到和模型效果一样高的优先级。至于这个方案能撑到哪一步,还得看后面的业务复杂度,不过目前半年多的线上表现,至少让我不用再为那些玄学一般的脏状态从凌晨三点爬起来查日志了。