早上十点,我正在看着一个批量处理客户工单的Agent跑流程,进度条走到第七单,日志突然停在一行“agent execution terminated due to error.”。再往下翻,是调用CRM接口超时的错误。整个任务队列原地冻结,前面处理过的六单状态不明,后面还有几十个等着。那一刻我意识到,Agent项目真正让人头疼的从来不是“能不能跑起来”,而是“跑到一半挂了,怎么恢复”。
这个系列专门聊Agent系统工程里那些被demo掩盖掉的硬问题。上一篇讲了整体架构的分层设计,这次聚焦一个所有线上Agent都绕不开的课题——任务失败后的状态恢复。目标读者是那些正在把Agent从“玩得转”推向“扛得住”的工程师,无论你用LangGraph、自研框架还是最朴素的多轮调用循环,这篇文章讲的思想和坑都适用。
1.1 先分清Agent挂掉是死在哪一层
处理恢复问题之前,得先给“挂掉”做个分类。我观察到的线上故障,基本逃不出四类:
- 工具调用异常:API超时、限流、返回格式变化、权限Token过期。这类最常见,报错也最直白。
- 环境断连:进程被OOM kill、机器重启、网络闪断、容器被调度器回收。特点是Agent本身没做错什么,但承载它的运行时没了。
- 模型侧问题:上下文超长被截断、单次请求Token耗尽、内容合规拦截导致回复被吞、模型服务本身返回5xx。
- 业务逻辑卡死:Agent在同一个错误结果上反复重试,直到踢到max_step上限;或者多个子任务互相等待,进入死锁状态。
不同失败类型对应不同的恢复策略。工具调用失败可以靠重试解决,环境断连需要完整的状态快照和新进程拉起,模型侧问题往往要降级或换模型,业务逻辑卡死则不能无脑重试——越重试反而越陷越深。这里有个很容易被忽略的点:传统程序的失败是确定性的,同样的输入必然走到同样的错误;Agent的失败是概率性的,第二次跑的结果可能和第一次完全不同。所以你不能像重启老服务那样简单“重跑一遍”,最终状态可能对不上。
1.2 为什么Agent恢复比传统服务恢复麻烦得多
传统后端服务的恢复,核心是“原子性”和“幂等性”。比如支付系统,扣款失败就回滚事务,状态存数据库,重启后从数据库恢复。Agent任务的执行路径却是一条非确定性的决策链:模型每一步都在做选择,选择又依赖大段的上下文和中间观察结果。
更麻烦的是,Agent的执行往往横跨多个环境。主流程在一个进程里跑,调用的工具散布在外部API、数据库、文件系统甚至另一个Agent里。这可能涉及脏数据写入、外部副作用已经发生、数据库状态已改变。如果恢复策略是把整条链路从头重跑,很可能重复提交订单、重复发送邮件、重复扣费。
我在实际项目中把Agent的执行拆成了两层去看:一层是Agent本体(模型决策、Prompt、上下文、记忆),一层是执行框架(Harness)——就是负责调度工具、管理循环、处理中间结果的运行时。这两者的恢复策略完全不同:Agent本体的恢复,本质是“重建语境”;执行框架的恢复,本质是“重建现场”。很多项目把这两层混在一起设计,恢复的时候只找到了最后一轮的Prompt,却丢了执行轨迹,结果模型醒了却不知道自己在哪一步,只能瞎编。
2. 恢复的前提是先把状态和记忆剥离开
2.1 状态和记忆是两回事
讨论“Agent状态恢复”,第一步必须厘清概念。我在面试里经常问候选人:“Agent的记忆和状态有什么区别?”大多数人答不上来。
简单讲:记忆是给模型看的,状态是给执行引擎看的。记忆是上下文窗口里的对话历史、检索回来的知识片段,它影响模型下一步的决策质量;状态是执行引擎用来决定“当前该执行什么”的事实数据,比如“第几轮”、“哪个工具调用成功”、“哪个子任务还没做”。恢复的时候,记忆丢了还可以通过重新检索拼回来,状态丢了整个任务就变成无头苍蝇。
我现在的项目里,所有Agent实例都维护一份显式的State对象,存储在Redis或数据库中。这份State只包含结构化数据,不包含大段文本历史:
@dataclass class AgentState: task_id: str plan_id: str step_index: int # 当前执行到规划的第几步 status: str # pending / running / paused / completed / failed tool_results: dict # 工具调用id -> 结果摘要 attempts: dict # 工具调用id -> 重试次数 context_summary: str # 给恢复用的上下文摘要,而不是完整历史 updated_at: datetime这里最关键的设计决策是:状态更新必须跟着执行事件同步推进,而不是事后埋点。每一步工具调用之前先记录“我要调用什么、参数是什么”,调用成功后补记“结果摘要”,失败就记“错误摘要”。这样即使进程在下一秒宕机,State里的最后一条记录也能告诉我们现场发生了什么。
2.2 用事件日志重建执行现场
状态快照给的是“当前在哪”,但要真正恢复执行,还需要知道“怎么走到这一步的”。我的做法是为每个Agent任务维护一条追加写的事件日志,每条事件记录一个原子事实:
- agent_started → 任务启动,附上目标描述
- plan_created → 生成了计划,附上计划步骤列表
- tool_invoked → 即将调用工具,附上工具名和参数
- tool_succeeded → 工具返回成功,附上结果摘要
- tool_failed → 工具返回失败,附上错误信息
- llm_decided → 模型生成了关键决策,附上决策摘要
- step_completed → 当前步骤完成,推进到下一步
- agent_finished → 任务完成
这套日志的好处是它天然支持两种恢复模式。一种是“倒带到指定检查点”:删除检查点之后的事件,从那里重新执行;另一种是“沿日志续跑”:保留所有事件,把日志摘要打包给模型,让它接续决策。前者适合确定性较强的工具密集型任务,后者适合需要保持上下文连续性的推理型任务。两者结合使用,几乎覆盖所有Agent场景。
此外,事件日志还有一个朴素而强大的功能:故障排查的完整证据链。Promise问题发生时,日志能告诉你到底哪一步出错、模型当时被喂了什么输入、工具返回了什么异常。否则你面对的只有一句“Agent execution terminated due to error”,连定位问题的入口都没有。
2.3 ReAct循环里,恢复点是“观察”
现在很多Agent框架,尤其是热门的ReAct架构,本质是“思考→行动→观察”的循环。在这个循环里,最值得做恢复点的位置是观察之后、下一次思考之前。
为什么?因为“行动”是会产生外部副作用的,而“观察”拿到的是工具返回的结果。如果断点打在“行动之前”,恢复时还没产生副作用,可以从头执行;如果断点在“观察之后”,副作用已经发生,但结果已经被记录,恢复时只需要接着思考,不用重新调用工具。
我见过不少项目在“思考”前后做检查点,结果恢复后发现模型完全忘了自己刚才的推理思路,又重新推理了一遍,反而走出了不同的决策路径。把检查点压在“观察”这一步,等于给模型一个既定的、不可更改的事实输入,外部世界的状态不会因为重跑而改变,模型的决策也锚定在确定性的观察结果上,不容易漂移。
3. 检查点设计:在哪里下桩、存什么、怎么恢复
3.1 三类检查点,按场景取舍
检查点的粒度直接决定了恢复的效率和成本。存太密,每次决策都落盘,性能和成本都受不了;存太疏,故障时只能从头重来。我一般分三类:
- 常规检查点:每个步骤完成、每轮工具调用结束后写入。粒度最细,适合任务步骤较多的场景,但开销也最高。
- 工具边界检查点:只在调用有外部副作用的工具(写库、发消息、提交订单)前写。粒度适中,主要为了保证“副作用只会发生一次”。
- 业务里程碑检查点:在用户定义的节点写入,比如“客户资料已完整收集”、“方案已评审通过”。粒度最粗,适合人工参与审批的任务,恢复后可以从最近的里程碑继续。
一个实践经验:宁可牺牲常规检查点的频率,也绝不能在工具调用这个边界上省事。因为工具调用是Agent系统中唯一可能造成真实世界影响的操作。我现在的项目对“发邮件”“写文件”“改数据库”这类操作一律“调用前先记账,调用后再核销”。记账记录表示“我准备执行这个操作”,核销记录表示“操作已完成”。恢复时,凡是只有记账没有核销的操作,一律视为未执行——因为执行过程中可能死在半路上,也可能已经完成但没来得及记录,我们选择保守处理,用幂等机制兜底。
3.2 存什么才能真正恢复现场
一个常见的误区是:检查点里只存JSON状态快照,恢复时发现模型根本不理解自己面临什么局面。解决这个问题的关键,是给状态快照配一份上下文摘要。
我的经验是,一个高质量的检查点应该包含四层信息:
- 任务元信息:任务ID、目标描述、创建时间、所属用户。这是恢复时重新构建系统Prompt的基础。
- 执行轨迹摘要:已完成步骤的编号、每步的关键输入输出概述。这相当于给模型看“事情讲到哪里了”。
- 待办与卡点:当前未完成的工作、当前遇到的障碍。这是模型恢复后需要立刻关注的信息。
- 过程上下文:不是完整历史对话,而是压缩后的关键信息,比如之前发现的几个关键事实、用户的最新指令变更。
在实际代码里,检查点恢复时的Prompt会长这样:
你正在恢复一个中断的任务。 任务目标:{task_goal} 当前进度:已完成第1步至第4步,第5步正在进行。 已完成的关键操作: - step 2: 调用了客户信息查询接口,返回客户的会员等级为普通 - step 3: 根据套餐规则计算出可升级方案2套 尚未完成的操作: - step 5: 需要生成升级报价单并发给客户确认 最后观察结果为:{latest_observation} 请从当前进度继续,不要重复已完成的步骤,不要重复调用已成功的工具。这个Prompt把模型锚定在“恢复点之后的现场”,而不是让它重新推开大门。实测下来,模型迷路率大幅下降。
3.3 幂等与补偿:工具能被重复调用而不闯祸
极简状态恢复的最后一环,是让工具调用做到“可以被重复执行”。行业里通常叫幂等设计。两个层面:
调用方层面:每次工具调用带上全局唯一的invocation_id,服务端记录这个ID对应的结果。同样ID的请求只执行一次,其余直接返回缓存结果。这能解决“客户端重试”带来的重复执行问题。
被调方层面:操作本身设计成幂等的。比如“更新用户备注”天然幂等,执行两遍结果一样;“发送邮件”不是幂等的,需要额外设计:要么先检查目标邮箱是否已有同主题邮件,要么在邮件正文里附加本次操作的任务ID供业务校验。
有些操作无法做到天然幂等,就需要补偿动作。比如一个Agent发起退款,第一次调用成功但网络中断导致确认消息没返回,Agent重试后可能重复退款。补偿方案是:先调用“查询退款状态”接口,确认前一次退款是否已经成功,再决定是继续还是跳过。我在系统里为每个非幂等操作注册了一个“前置校验器”,恢复执行时统一走一遍校验,再决定是否真的执行。这套机制搭配检查点事件日志,基本能把重复副作用降到零。
4. 三种失败恢复触发姿势的取舍
4.1 自动重试:最简单,但别无脑重试
自动重试适合处理瞬时故障:网络抖动、API限流、依赖服务短时不可用。我一般用指数退避加随机抖动:第一次等2秒,第二次4秒,第三次8秒,最多五轮。超过重试上限就把任务标记为failed,转入人工或者编排层处理。
自动重试有个极易踩的坑:重试的代价。如果失败发生在副作用已经产生之后,重试会让副作用扩大。比如“创建工单”接口超时了,你以为没创建成功,重试一次,结果创建了双份工单。所以自动重试前一定要判断当前失败点是否穿过了副作用边界,穿过了就得走“查询+补偿”的路子,不能直接重发。
自动重试还要注意不要把模型生成步骤也自动重试。模型生成结果的不确定性意味着第二次生成的内容可能和第一次完全不同,如果第一次已经部分执行了计划,重新生成很可能造成两份互不兼容的执行轨迹。
4.2 断点续跑:人工介入的恢复过程
有一种失败是系统无法自行决定的:业务规则不允许重试、需要用户确认下一步、或者错误信息不足以支撑决策。这时最合理的设计是把任务冻结,弹出“继续/放弃”的选择给用户。
我实现断点续跑的方式是:Agent状态持久化之后,把异常事件和当前状态摘要推送到前端。如果用户点击“继续”,系统从最近的检查点恢复;如果点击“放弃”,执行补偿清理。这种方式特别适合有审批节点的业务流程——Agent负责铺路,人负责做关键决策。
断点续跑还有一个细节:续跑时要注意外部依赖的时间上下文。一个检查点是上午十点存的,用户下午三点才点击继续,中间五小时外部数据可能已经变化。恢复时最好重新拉取一遍关键数据,再让模型基于最新数据决策。旧检查点里存的是“数据ID和摘要”,而不是“数据全文快照”,正是为了这个目的。
4.3 编排层管控:多Agent协作里的恢复责任
当任务由多个Agent协作完成时,恢复问题的复杂度就不是单机单Agent能解决的了。我目前的实践是引入一个Manager Agent(或称为协调器),专门负责监控子Agent的状态。
Manager Agent的工作分三步:
- 健康巡检:定期检查子Agent的心跳和事件日志,发现异常立即标记。
- 失败归因:判断失败发生在哪个环节,是哪个子Agent惹的祸。
- 恢复调度:如果失败的任务是独立的子任务,直接为该子任务重建执行现场;如果失败影响到了其他子任务依赖的共享数据,先撤销或补偿,再重排水流。
多Agent环境下最头疼的问题是重复副作用跨Agent生效。两个子Agent同时调用了同一个“写文件”工具,互相不知道对方已经写过,最终文件内容被覆盖。解决思路是状态共享中心:所有Agent的执行状态统一写入一个共享状态存储,每个工具调用ID全局唯一,任何人调用“写完文件”之前都要先查询该ID是否已执行。这一步不复杂,但必须在设计一开始就考虑,后期补全靠逆向翻日志,成本极高。
5. 现场实录:恢复机制最容易踩的四个坑
5.1 坑一:日志太“干净”,错误复现不了
早期我在日志里只记录“工具调用失败”,不记原始异常信息。结果有个任务频繁失败,我盯着日志看不出原因,只好手动复现,花了半天才定位到是某个接口返回了空数组导致递归解析出错。
现在我的日志规范是:任何失败事件必须记录完整现场——触发失败的完整参数、原始返回内容(截断到2KB)、模型此时看到的Prompt片段、时间戳、环境和版本号。有了这些才能复现问题,才能在恢复逻辑里精准判断错误的性质是“可重试”还是“不可重试”。
5.2 坑二:恢复了现场,模型却“迷路”了
有一次我实现了全套恢复机制,任务却依然卡住。查看模型日志发现,恢复后模型仍在“思考”,只是它把恢复点当成了新任务开始,重新审视了一遍全部历史才动起来——不仅慢,还可能给出和原计划冲突的新决策。
解决方法是给恢复流程加了一段**“恢复认知”前置对话**:在向模型提供检查点内容之后,加一轮强制性的“确认”环节。让模型先用自己的话复述当前任务状态、接下来要做什么。这时模型输出的“复述内容”会被当作执行引擎的下一个输入,本质上就是让模型把“我已经恢复”这个事实内化,而不是从零开始分析。
5.3 坑三:状态越滚越大,预算被吃光
刚开始做检查点时,我把每轮工具调用的完整返回结果都存进状态。一个月后,Redis里堆着几万条历史记录,每条好几KB,恢复时这些内容一股脑塞进上下文——Token超标、成本暴涨、响应变慢。
后来我做了两层压缩:第一层是摘要压缩,工具返回的大JSON只保留结构化摘要,原始数据存入可检索的对象存储,需要时按ID取;第二层是上下文淘汰,超过N轮之前的事件只保留“事件类型+关键参数摘要”,丢弃完整字段。状态恢复不是考古,不需要完整重放每一帧历史,只要把关键现场信息传给模型就够。
5.4 坑四:多Agent共享状态,恢复时撞车
有一次子任务A恢复后重新执行了“生成订单”操作,但这份订单其实已经被子任务B在恢复期间生成过了。两个订单编号不同,金额一致——客户收到了两遍合同,别提多尴尬。
这个问题的根源是:恢复机制只考虑了“单个任务的状态”,没有考虑“全局资源的互斥”。后来我在共享状态存储里引入了一把分布式锁:任何涉及外部副作用的工具调用,在执行前先尝试获取锁,锁的key是“业务对象ID+操作类型”。拿到了就执行,拿不到就等待或跳过。恢复流程也一样:先尝试拿锁,拿不到就说明另一个Agent正在处理同一份业务,放弃操作或者标记为等待。至此,这类撞车事故基本绝迹。
6. 聊点我在实际项目中的心得体会
Agent任务失败恢复这件事,做得越深,越觉得它像“飞行记录仪”和“自动油门”的组合。飞行记录仪让你知道发生了什么,自动油门让你在意外后还能保持航线。
我的习惯是:每个Agent任务设计之初,先画一张失败流程图——把所有可能的失败点在代码注释里列清楚,标注出哪些可以自动恢复,哪些需要用户确认,哪些必须人工介入。写代码之前先确认恢复策略,比写完之后再补省太多事。
另外,检查点不只是给故障恢复用的。它还可以服务调试和复现:用户报了一个“Agent说错了句话但不知道为啥”,事件日志能帮你回放它的推理链条。它甚至能服务离线分析:统计哪些工具调用失败率最高,优化Prompt和工具设计。可以说,把状态恢复做好,是整个Agent系统从“实验品”迈向“生产系统”的分水岭。
这一篇就把状态恢复的核心框架讲完整了。下一篇我想聊聊Agent的记忆体系——短期、中期、长期记忆是怎么分层的,又该怎么优雅地塞进上下文窗口。这个话题和状态恢复紧密相关,很多恢复问题本质上就是记忆检索不到位导致的。到时候见。