你把一个任务交给Agent,它老老实实跑了十几分钟,眼看就要出报告了,结果一次网络抖动,进程直接崩掉。你咬咬牙重新执行,又是十几分钟,结果这次模型抽风,在第二步走了另一条路,生成的结论还跟前一次不一样。这种“Agent跑一半就夭折”的体验,做过AI应用的人应该都懂。
这其实不是偶发问题,而是Agent类系统绕不开的必修课。Agent是“有状态的长任务”,一次多步骤执行里有模型推理、工具调用、外部API请求,任何一个环节都可能出岔子。而大模型接口本身是黑盒,你不知道它下次调用会给出什么结果,所以单纯靠“最后再跑一次”根本解决不了稳定性问题。正确的思路是:给Agent设计一套断点续跑机制,让它在失败后从最近的有效状态继续,而不是从头再来。
这篇博文,我会从Agent失败的本质出发,拆解状态持久化、事件溯源、幂等设计这几个底层技术点,然后给出一套可以直接落地的检查点与恢复流程。适合正在做Agent应用、或者准备把Agent接入生产环境的人参考,看完至少能少踩一半的坑。
1. 先想清楚:Agent 失败的本质与断点续跑的价值
1.1 一个真实项目里的失败现场:跑了一半的任务
我之前帮某团队调过一个数据采集类的Agent,任务流程拆得很清楚:主题拆解、资料搜索、网页抓取、数据清洗、分析报告。前四步跑得顺顺当当,网页抓回来几十个页面,清洗出来的表格也存好了,结果第五步调用模型接口生成报告时,供应商那边突然开始超时,连续重试三次全失败,Agent进程直接抛异常退出。
问题在于,这个Agent的所有中间状态都存在内存里。进程一挂,清洗好的数据、抓回来的页面、搜索到的链接全部丢光。重新跑一遍不是不行,但整个过程又得从头开始:搜索重新做、网页重新抓、模型重新生成。更要命的是,第二次跑的时候模型给的搜索词和抓取范围跟第一次不完全一样,最后生成的报告跟第一次没执行完的半成品对不上,团队成员想对比都没法对比。
这个例子反映了一个很普遍的现象:很多人开发Agent时,默认把“执行逻辑”当成一次性函数来写,压根没考虑“执行到一半挂了怎么办”。但Agent本质上是一个多步骤、长耗时、依赖外部服务的流程,每一环都有失败的可能。你不在设计阶段把恢复机制放进去,上线后就会被动地处理各种“跑一半挂了”的工单。
1.2 失败带来的三种典型代价:时间、Token、稳定性
先说时间。Agent的每一步模型推理通常要几秒到几十秒,工具调用虽然快一点,但网络往返也需要时间。一个五步任务跑下来,顺利情况可能三分钟,如果中间有大量数据要处理,十分钟也很正常。失败一次,这些时间全部回滚,用户等待的体验会非常差。
再说成本。现在大模型的收费基本按token来,Agent每轮调用都要把历史消息、工具返回结果全部拼进上下文,上下文越长token消耗越贵。一次长任务执行到中段时,消息列表可能已经积累了上万token,这时候挂掉,等于把前半段的prompt成本全部浪费掉。如果任务涉及大量代码文件或网页内容,一次中途失败烧掉的token能抵得上平时十次短任务。
最后是稳定性。LLM本身是非确定性的,哪怕你给完全相同的输入,两次输出也可能不同。所以“重新跑一遍”并不是一个稳定的兜底方案——重跑后可能遇到新的错误,也可能输出不同结果,导致前后行为无法对齐。这对企业级应用来说非常致命,因为你没法跟用户解释“本次失败后重跑,结果跟上次不一样”。
1.3 为什么“重新跑一遍”不是好答案
有人可能会说,失败就失败,我直接整个重跑不就行了吗?这事放到下载文件上没问题:下载到一半断网,重新下载就是重复拉取相同数据。但Agent不一样,它的执行路径中包含了大量非确定性因素。
最直接的问题,是重复执行外部副作用。假设Agent在第三步已经调用了“发送会议邀请”这个工具,只是后面生成确认消息时崩了。整个重跑的话,第三步会被再次调用,于是同一场会议收到了两份邀请,这种事故比任务失败本身更严重。外部写操作一旦发生,重放就有重复风险。
另一个问题是上下文不一致。Agent重跑一次后,模型可能会抱着“换个策略”的心态,搜索意图和工具参数都变了。用户等了大半天,拿到的不是“重试后的最终结果”,而是“另一次执行的新结果”。从产品视角看,这就是不可复现的系统。所以靠蛮力重跑解决不了问题,真正应该做的是:让失败后的重跑继续沿用之前的状态,从断点无缝接上。
2. 断点续跑的底层设计:状态、检查点与幂等
2.1 把“工作记忆”落盘:状态持久化怎么做
做断点续跑的第一件事,是搞清楚Agent运行过程中到底有哪些状态需要保存。很多人只想到“把对话消息存下来”,但Agent的状态远不止这些。
拆开看,Agent的“工作记忆”至少包含四类:
- 对话上下文:完整的历史消息,包括用户输入、Assistant回复、工具返回的文本,这是恢复后继续调用模型的基础。
- 执行进度:当前执行到了第几步,哪些已经完成,哪些还没开始。这个信息主要用于恢复时跳过已完成环节。
- 中间产物:比如抓取到的页面内容、清洗后的表格、中间计算结果。它们不一定全部塞进prompt,但可能被后续步骤引用。
- 运行元信息:任务ID、环境变量、已调用的工具列表、调用参数和时间戳等,用于审计和排障。
这四类信息缺一不可,都得放到持久化存储里。最简单的方式是存成JSON,落到数据库的特定表或者对象存储里。每次Agent完成一个关键节点(比如一次工具调用结束、一次模型回复生成),就把最新状态整体刷新一次。
我见过一些团队图省事,只把对话上下文存了,执行进度和中间产物一律不存。结果恢复以后,Agent确实能接上对话,但从哪一步继续、哪些工具调用结果还能复用,系统一无所知,相当于从半截又重新摸索,恢复和重跑没有本质区别。
2.2 用事件日志而非快照来恢复现场
保存状态有两种典型思路:快照和事件日志,很多人会把它们混用。
快照就是“在某一时刻,把Agent的全部状态复制一份存起来”。优点是恢复快,直接把快照读回来就能接着跑;缺点是快照只保存了结果,没保存“是怎么变成这个结果的”。一旦后续步骤出错,你想知道某个中间变量是被谁改脏的,快照里根本看不出来。
事件日志则不同,它记录的是“每一件发生了什么”:“第3步调用了搜索工具,返回了50条链接”“第4步清洗完成,生成了30行数据”。恢复的时候,把事件从第一个开始逐条重放,就能重建任意时刻的状态。看起来麻烦,但好处非常明显:可以做精确回放、可以审计每一步操作、还可以在中间插入补偿事件。
实际生产中,比较理性的做法是两者结合:平时以事件日志作为主要记录手段,同时定期打一个快照压缩事件长度。比如每执行10步存一个快照,这样恢复时先取最近快照,再重放最近10步的事件,既不会太慢,又能保留详尽的日志。下面这张对比表可以帮助你快速做选择:
| 方案 | 恢复速度 | 可审计性 | 存储开销 | 适用场景 |
|---|---|---|---|---|
| 全量快照 | 快 | 低 | 高 | 简单短任务 |
| 事件日志 | 随重放量变慢 | 高 | 中 | 长任务、需求审计 |
| 快照+事件 | 中 | 中 | 中 | 生产环境推荐 |
对绝大多数Agent应用,我的建议是别只选一种。两个都做既不复杂也不贵,但恢复体验和排查效率会好一个量级。
2.3 幂等设计:防止重放操作变成双倍副作用
断点续跑意味着Agent恢复后,某些已经执行过的步骤可能会被再次执行,所以你必须具备幂等能力。幂等这个词听着学术,大白话解释就是:同一个操作执行一次和执行一百次,对外部世界的影响是一样的。
最典型的做法是加幂等键。以调用某个外部接口发消息为例,Agent调用时带上一个唯一请求ID,比如“run_20250324_step3_001”。服务端收到请求后先查这个ID有没有处理过,处理过就直接返回上次结果,不再重复执行。这样即使Agent因为超时重试、断点恢复重复调用了同一个接口,下游也只会收到一次真正的影响。
需要注意的是,幂等能力不光是下游接口的事。Agent自己也要维护一份“已完成操作清单”,保存每个工具调用的请求ID和返回结果。恢复时先查这一步是否已经执行过,执行过就直接用历史结果,跳过调用。否则你会发现,断点续跑是续上了,重复扣费、重复发邮件、重复下单这些事故也跟着“续”上了。
3. 实操:给 Agent 加上可恢复的检查点
3.1 检查点应该存哪些字段(附样例)
先给一个可以直接参考的检查点结构。核心字段如下表所示:
| 字段 | 类型 | 说明 |
|---|---|---|
| run_id | string | 唯一任务ID,所有状态共享 |
| step_index | int | 当前执行到第几步,用于恢复后定位 |
| status | string | running / interrupted / done |
| messages | array | LLM完整消息历史 |
| tool_results | map | 最近工具调用的结果,避免重放时重复请求 |
| context | map | 业务自定义上下文,如目标、约束、中间产物引用 |
| version | int | 状态版本号,防止并发覆盖 |
对应的JSON样例大致长这样:
{ "run_id": "run_20250324_153001", "step_index": 4, "status": "interrupted", "messages": [ {"role": "system", "content": "你是数据分析助手"}, {"role": "user", "content": "分析本周销售数据"}, {"role": "assistant", "content": "我将先拉取数据表"}, {"role": "tool", "content": "get_sales_data 返回 120 行"} ], "tool_results": { "get_sales_data": {"rows": 120, "status": "success"} }, "context": {"target_metric": "周增长率", "source_table": "sales_2024"}, "version": 7 }字段里最容易被忽略的是version。Agent是异步任务,万一两个进程同时在跑同一个run_id,后写入的检查点会把先写入的覆盖掉,状态就乱了。加一个版本号做乐观锁,每次写入前比较版本,写入时自增,能有效避免这类并发问题。
3.2 恢复决策树:重试、回退、重新规划怎么选
检查点存好以后,真正难的是恢复策略。我把常见情况分成三类:
第一类是重试。适用于瞬时故障,比如网络超时、接口限流、连接中断。这类错误不用回退,也不用重新规划,把同一操作再做一次大概率就能成功。重试时注意加退避,具体做法下一章会讲。
第二类是回退。适用于“当前步骤还没有成功,但已经调过几个工具,可能把上下文搞乱了”的场景。做法是从检查点恢复到上一个稳定位置,把中间涉及的工具调用结果作废,重新生成接下来的步骤。比如一个Agent尝试调用代码执行工具,但代码报错,这时代码已经把环境弄脏了,你直接重试同样代码没有意义,不如回退到写代码之前,让模型重新生成一段fix版本。
第三类是重新规划。适用于更严重的情况:计划本身就是错的,或者前面的执行链路已经彻底不可信了。这时候把当前状态整理成摘要,连同原始目标一起交给LLM,让它重新生成一个计划,然后从当前进度继续执行。注意“重新规划”不等于“从头再来”,它依然要保留已完成的有效中间产物,只是换一条路径通向终点。
这三类策略并不互斥。实际恢复流程往往是先重试两三次,不行就回退一步再试,仍然不行才触发重新规划。判断依据很简单:错误越接近基础设施,越倾向重试;错误越接近逻辑决策,越倾向回退或重新规划。
3.3 一个稳定可用的恢复循环示例
下面给一个最小可用的恢复循环,用Python描述核心逻辑。它包含三个部分:负责保存加载检查点的管理类、负责执行Agent步骤的处理函数、以及恢复时用的决策逻辑。生产环境可以把存储换成Redis或数据库,这里以内存字典简化演示。
import time import json class CheckpointManager: def __init__(self, storage): self.storage = storage # 这里可以是数据库或对象存储的客户端 def save(self, run_id, checkpoint): checkpoint["version"] = checkpoint.get("version", 0) + 1 checkpoint["updated_at"] = time.time() self.storage.put(run_id, checkpoint) def load(self, run_id): return self.storage.get(run_id) def resume(self, run_id, target_status=("interrupted", "failed")): cp = self.load(run_id) if cp and cp.get("status") in target_status: return cp return None def execute_step(step_fn, messages, step_name, max_retries=3): for attempt in range(max_retries): try: result = step_fn(messages) return {"status": "success", "output": result} except Exception as e: print(f"[{step_name}] attempt {attempt + 1} failed: {e}") if attempt < max_retries - 1: time.sleep(min(2 ** attempt, 8)) # 指数退避 else: raise raise RuntimeError(f"step {step_name} failed after {max_retries} retries") def run_agent(run_id, steps, checkpoint_mgr): cp = checkpoint_mgr.resume(run_id) start_step = 0 messages = [] if cp: start_step = cp.get("step_index", 0) + 1 messages = cp.get("messages", []) print(f"恢复任务 {run_id},从第 {start_step} 步继续") for i in range(start_step, len(steps)): step_name, step_fn = steps[i] checkpoint = { "run_id": run_id, "step_index": i, "status": "running", "messages": messages, "tool_results": {}, "context": {}, } checkpoint_mgr.save(run_id, checkpoint) try: output = execute_step(step_fn, messages, step_name) messages.append({"role": "assistant", "content": f"步骤{step_name}完成:{output}"}) checkpoint_mgr.save(run_id, { "run_id": run_id, "step_index": i, "status": "running", "messages": messages, "tool_results": {step_name: output}, "context": {}, }) except Exception as e: checkpoint_mgr.save(run_id, { "run_id": run_id, "step_index": i, "status": "interrupted", "messages": messages, "tool_results": {}, "context": {"last_error": str(e)}, }) print(f"任务 {run_id} 在第 {i} 步中断,错误:{e}") return "interrupted" checkpoint_mgr.save(run_id, { "run_id": run_id, "step_index": len(steps) - 1, "status": "done", "messages": messages, "tool_results": {}, "context": {}, }) return "done"这个循环的关键设计点是:每步入向时先存一次检查点,步骤执行成功后再存一次。这样即使进程在第4步执行到一半时挂了,恢复后也能找到一条“第4步running”的检查点,从第4步重新开始,而不是丢状态。
执行时调用方式如下:
steps = [ ("search_material", elastic_search), ("fetch_pages", fetch_webpage), ("clean_data", clean_dataframe), ("generate_report", call_llm), ] cp_mgr = CheckpointManager(RedisStorage()) status = run_agent("run_20250324_153001", steps, cp_mgr) if status == "interrupted": # 下次进程启动后,直接再调一次 run_agent 就会自动续跑 run_agent("run_20250324_153001", steps, cp_mgr)我把这个循环跑过很长的任务,实测下来稳定复现“中途手动杀掉进程,再启动自动续跑”的效果。你可以把它当成一个骨架,往里面加自己的工具调用逻辑和更复杂的恢复策略。
3.4 主流框架里的断点集成思路
如果你在用市面上常见的Agent框架,它们通常已经内置了一些回调能力,只是很多人没用起来。在框架层面做断点续跑,基本思路是同一条:把检查点保存挂到每一个关键生命周期节点上。
典型节点包括:LLM调用之前、LLM调用之后、工具调用之前、工具调用之后、任务终止时。这些节点一般会暴露成钩子函数或中间件形式。你只需要在这些钩子里做两件事:一是持久化当前状态,二是把之前保存的状态注入到新的执行上下文中。
工具调用之前存状态尤其重要。很多坑都出在“Agent调用了一个外部工具,工具执行成功但返回值丢失”,如果调用前状态是完整的,恢复后我们能知道“第3步要调用某工具”,并选择幂等重放。如果调用前没存,工具到底执行没执行变成了一笔糊涂账。
如果你用的框架没有提供钩子机制,也有一个笨办法:把Agent的每一步执行包装成函数,在每个函数入口和出口调用统一的检查点保存。虽然侵入性强一点,但胜在逻辑直白,容易调试。
4. 常见故障与排查技巧实录
4.1 重试反而放大了故障:指数退避与抖动
断点续跑最容易犯的错,就是恢复逻辑里出现“无脑重试”。有些同学会在失败后立刻重试,间隔1秒、再隔1秒,连续重试十几次。这么做在目标服务已经完全过载的情况下,等于给故障雪上加霜——你每一次重试都在消耗对方的资源,最后把对方的故障时间拉长。
正确的做法是用指数退避加抖动。指数退避是指重试间隔随尝试次数指数增长,比如第一次失败后等1秒,第二次等2秒,第三次等4秒,最大不超过一个上限;抖动是在这个间隔上加入随机值,避免多个并发任务同时重试形成“惊群效应”。
一段简单的计算方式如下:
import random def backoff_delay(attempt, base_seconds=1, cap_seconds=30): delay = min(cap_seconds, base_seconds * (2 ** attempt)) return delay + random.uniform(0, delay * 0.2)这个逻辑也是一种“重试”,但它不是自暴自弃的循环,而是在给外界服务留出恢复时间。我见过几次线上事故,最后定位原因都是“重试太快把对方接口彻底打挂”,从那以后我所有重试逻辑都会强制加上退避。
4.2 无法回滚的外部副作用如何处理
断点续跑最头疼的问题,不是怎么存状态,而是怎么处理那些已经发生且无法回滚的外部操作。比如Agent执行到第5步,已经调用了“创建云服务器”接口,但后面的步骤失败了。续跑时如果再从第5步开始,就会又创建一台服务器;如果跳过第5步,状态又对不上。
处理这个问题的原则是:副作用一旦发生,永远不要尝试“完美回滚”,而是记录事实并补偿。先把“已调用创建接口、返回实例ID”这个事实存进检查点,然后在后续逻辑里把它标记为“已执行,但下游条件不满足”。想要撤销时,调用“删除服务器”这个补偿操作,而不是让Agent重新跑一遍“创建服务器”再假装没发生过。
操作越是带副作用的,越要提前做分类。我习惯给所有工具打个标记:只读、可安全重试、不可安全重试。只读工具在恢复时可以随意重放;可安全重试的加幂等键重放;不可安全重试的永远优先检查历史结果,绝不重放。没有这个分类表,遇到问题只能靠人工盯着,Agent跑得越快翻车概率越高。
4.3 恢复后模型上下文错乱
断点续跑还有一个特别隐蔽的问题:状态恢复了,但模型上下文“脏了”。举个实际例子,Agent执行第3步时调用了一个搜索工具,但因为超时返回了一次空结果,模型基于空结果生成了一个残缺的中间结论。这个残缺结论被存进messages,恢复后模型会把“搜索没结果”当成事实继续展开,整个后续逻辑就越走越偏。
回头看,问题出在错误状态被持久化了。所以检查点保存不能笼统地说“把messages存下来”,而要判断当前messages里有没有包含了错误结论,如果有,恢复时要把对应轮次的消息丢弃或重写。
一个实用的技巧是:在保存检查点之前先做“消息清洗”。比如工具调用失败时,不要把报错信息直接原样塞进messages,而是先清理掉这次失败的调用记录,等恢复后重新生成新的调用。再比如模型某次回复明显偏离了目标,在进入下一轮之前就可以把这条assistant消息删掉,而不是等恢复后再处理。
如果你用的是本地大模型或者接口支持摘要,恢复时也可以用一条“最近进展摘要”代替完整的历史对话,既能让模型更快恢复上下文,也能省token。但摘要有个前提:摘要本身要足够中性和准确,不能带上偏见,否则模型会拿着摘要里的结论继续跑偏。
4.4 断点续跑问题排查速查表
我在项目中总结过一张排查表,遇到断点续跑相关故障时先过一遍,能省不少时间。
| 症状 | 可能原因 | 排查动作 |
|---|---|---|
| 恢复后步骤重复执行 | 检查点里的step_index没更新成功 | 核对每步执行后是否调用了save;尝试在每一步执行前先save |
| 工具被重复调用 | 缺少幂等键或已完成操作清单 | 给外部调用加request_id;检查tool_results是否完整 |
| 恢复后上下文错乱 | 失败的模型回复被存进messages | 增加消息清洗逻辑,删除失败轮次后恢复 |
| 恢复后无法续跑反而死循环 | 重试策略没有退避,无限循环 | 检查重试次数上限;加上指数退避与抖动 |
| 多个进程竞争写检查点 | 缺少版本号或乐观锁 | 给检查点加version字段,写入前比较版本 |
| 任务报已完成但实际没完成 | 恢复后跳过了未执行的步骤 | 检查恢复逻辑的步进判断,确认是否从step_index+1开始 |
这张表看着简单,但每一条背后都是我实打实踩过的坑。特别是第一和第三条,我前前后后折腾了好几周才想明白根因。所以建议你在上线前就对着这张表做一轮测试:人为杀掉进程、人为让工具失败、人为让模型输出错误答案,把每种故障模式都过一遍。
5. 几个让人少踩坑的实操习惯
5.1 检查点不是越多越好
做断点续跑,不是保存越频繁越安全。保存本身有IO成本,而且保存的内容过多会造成性能下降。我见过一个团队每调用一次模型就全量存一次上下文,一个短任务跑下来数据库里多了一百多条记录,最后状态混乱到无法定位。
比较合理的节奏是:有副作用的工具调用前后必须存;模型调用的每轮结束可以存,但可以合并成一批。如果你的Agent十步以内就能跑完,完全可以把“完成一个章节”作为保存节点,不需要每一步都存。关键是要想清楚哪些状态丢了必须恢复、哪些丢了可以重新生成,而不是一刀切地全量保存。
5.2 把“恢复”当功能测试,而不是事后补救
很多人把断点续跑当成了上线后的事故兜底,开发时根本不测。我的建议是:在开发阶段就把它当核心功能来测,而且要用“破坏性测试”来逼出问题。
具体做法很简单:跑一个正常的Agent任务,中途随机杀掉进程,然后重新启动,观察它能不能从断点接上,接上以后结果是否正常。多试几次,特别要在第1步、中间步骤、最后一步分别杀进程,覆盖不同阶段的恢复路径。还可以用mock数据故意让某个工具抛异常,看看恢复后是否会重复执行该工具。
这套测试做下来,你会发现很多预想之外的问题:状态存了一半、恢复后步骤错位、某次工具调用结果没有落盘。问题越早暴露越好。上线以后再发现断点续跑是坏的,那它就不是“救命的机制”,而是“制造更多故障的机制”。
5.3 最后再分享一个小技巧
如果你正在为Agent接入外部大模型平台的API,记得在调用参数里打开“流式输出”的时候,也把“流式结果缓存”考虑进去。有些长报告生成过程本身要几十秒,这期间网络断掉的话,已经生成的文字全会被丢弃。可以每隔一小段就把流式输出追加到存储里的一个临时文件,恢复时从当前长度继续请求,这个技巧能显著降低长报告任务的失败率。
说到底,给Agent加断点续跑不是一项“要不要做”的加分功能,而是Agent进入生产环境的入场券。多步骤长任务天然会失败,与其每天盯着错误日志做救火队员,不如花点时间把检查点、幂等、恢复策略设计到位,让系统自己在失败之后稳稳站住,接着往前走。