Agent崩溃后如何避免重复执行与假执行?幂等设计+状态机实战

📅 发布时间:2026/9/17 10:03:10
Agent崩溃后如何避免重复执行与假执行?幂等设计+状态机实战
最近帮一个团队排查线上Agent崩溃的问题现象挺典型的Agent在编排工具调用的时候进程突然挂了任务调度器看到超时就把任务重新派发结果同一个订单被创建了两次。还有一个场景更隐蔽Agent在回复里说“文件已更新”但实际写文件那一步根本没执行成功。前一个是重复执行后一个是假装执行过。做AI Agent相关开发这两类问题大概率都会撞上。这篇文章就从这两个问题入手聊聊怎么通过幂等设计、执行确认和状态机让Agent在崩溃之后既不重复干活也不睁着眼睛说瞎话。内容偏工程实践适合Agent开发者、后端工程师以及所有被自动任务重试坑过的人。下面不扯概念直接讲方案。1. 问题拆解Agent崩溃到底会发生什么事1.1 崩溃的常见场景和影响范围Agent不是普通函数它是一条长时间运行的编排链路。通常是接收目标 → 规划步骤 → 调用工具 → 看结果 → 再规划 → 再调用。整个过程可能持续几十秒也可能跑上几个小时中间任何一个环节出错整个任务就会卡在一个“说不清到底做没做”的状态里。先列一下我在实际环境里遇到过的崩溃类型宿主进程崩溃。最常见的是OOM尤其是Agent在跑长上下文时内存占用一路飙升最后被系统杀掉。另外还有代码里的空指针、炸栈、第三方SDK段错误。这类崩溃没有任何善后机会。网络中断。Agent调外部API时连接超时、DNS解析失败、对端负载均衡把连接重置都可能导致这一步的结果永远拿不到。模型侧异常。LLM接口超时、限流、返回异常甚至返回了内容但格式不对解析失败。这算是Agent独有的“崩溃”而且往往发生在工具执行之后造成的结果更暧昧。宿主环境的重启。发布代码、升级依赖、运维重启机器、用户手动杀掉进程这些在长时间任务中其实很难完全避开。桌面端和插件场景的资源崩溃。比如浏览器里跑Agent标签页崩溃比如本地应用用到了GPU驱动崩了。这些崩溃更隐蔽经常没有日志。崩溃的直接后果是任务状态丢失。外部操作可能已经发生可能没有发生还可能发生了一半比如先扣了款没更新订单状态。最麻烦的是我们没有可靠渠道去判断到底属于哪种。这正是分布式系统里典型的“不确定结果”问题。影响范围不只是重跑一次这么简单。轻则浪费token和算力重则产生重复扣款、重复发消息、重复创建资源。再往上走如果Agent自己汇报“完成”但实际没有完成下游依赖这些汇报的自动化流程就会跟着出错连锁反应很吓人。所以崩溃后的处理不是简单的“重试”而是要回答两个问题还能不能安全地重试怎么证明之前那一步真的做了1.2 两个核心风险重复执行和“假执行”先看重复执行。一个任务被调度器分发给Agent AAgent A在调用“创建订单”接口后、拿到响应前崩溃了。调度器发现心跳没了于是拉起Agent B重新执行同一个任务。如果“创建订单”没有做幂等B就会再创建一个订单。更糟的是如果A其实是在接口已经成功创建订单之后才失去心跳那这个重复就是必然。还有Agent内部的隐形重复。模型在调用某个工具时工具返回了超时模型自行决定“再试一次”。这种内部重试很容易漏审计。比如一个发邮件的工具超时了模型以为没发出去自己又调了一次结果两封邮件都发出去了。再看“假装执行过”。具体来说Agent在工具调用阶段没收到预期成功响应但模型为了满足用户请求或者因为上下文里的指令被设计成“尽量完成任务”直接生成了一段“已完成”的回复。这个过程非常自然你甚至不能说模型在撒谎它只是在生成最像“答应完成任务”的下一个token。另外一个隐蔽版本是工具返回了200但Agent只看了状态码没检查返回体里的业务字段。比如调删除接口接口返回200但资源实际不存在删除没生效Agent却认定删除成功。严格说这也算“假执行”。所以要解决这两个风险不只是给Agent换一个更听话的模型而是要在工程层面建立一套“事实优先”的执行框架不管Agent说什么我们都只认已经被验证、被持久化的执行结果。2. 从“乱序”到“幂等”先想清楚执行语义2.1 为什么Agent比传统接口更容易重复执行传统后端接口的重复请求大多来自客户端重试或者消息队列重投我们只需要在接口层做幂等把重复请求挡在业务代码前面就行。Agent场景不一样它有至少三个独立层面都会产生重复。第一层是用户和上游系统。用户可能双击提交前端可能莫名其妙发了两次请求或者我们自己的任务编排系统把任务重新投递了。这层和传统API相似但Agent任务往往耗时很长用户等得不耐烦刷新页面又生成一个新的任务ID重复概率更高。第二层是Agent运行框架。很多Agent框架自带重试机制任务执行超时后会重新分发给另一个Worker有的框架还支持手动重新运行失败的任务。如果任务本身没设计幂等键每次重新分发的“任务ID”可能不一样下游看起来就是两个完全独立的请求。第三层是Agent内部的模型自主重试。这是最容易被忽视的。模型在规划阶段决定调用工具但工具返回异常后模型可能会根据上下文自己决定调换参数再试一次。这种重试既不是用户触发的也不是框架触发的而是模型“自作主张”。如果工具没有幂等这就是灾难。更关键的是Agent的“动作”往往不是单个操作而是一连串操作的组合。比如订酒店要查房型、锁房、下单、支付。整个过程横跨多个系统每个动作都可能成为重复源。如果只给任务入口加幂等ID任务内部第二步重复执行了照样会产生脏数据。所以做Agent可靠性设计时不能只在最外层包装一个幂等注解就完事要把幂等键透传到每一个工具调用逐段设防。2.2 幂等的本质不是“不执行第二次”而是“第二次没有副作用”有的同学会把幂等理解为“重复请求要能识别出来然后直接返回之前的结果”。这确实是实现方式的一种但不是本质。幂等的本质说的是效果执行一次和执行N次系统的最终状态一致。比如“将状态置为已支付”天然幂等不管执行多少次最终都是已支付。“给余额增加100元”不是幂等执行两次就是200。实现幂等有两种思路一种是选天然幂等的接口。比如对象存储PUT同一个key多次覆盖结果不变删除一个不存在的资源也通常是成功的幂等。这要求我们在设计工具时尽量把动词设计成“目标状态”而不是“动作”。比如不要用“加100元”而是“将余额设为100元”不要用“插入一条记录”而是用“如果不存在则插入”。另一种是强制去重。用唯一约束挡住第二次。比如插入订单时带上request_id数据库订单表加request_id唯一索引第二次插入直接报唯一冲突我们捕获到冲突后查询已有订单返回即可。或者用状态机控制任务状态必须是PENDING才能执行某动作一旦变成RUNNING后来的请求就会被拒绝。有一个容易踩坑的地方幂等键的作用域。“按用户操作”去重不靠谱因为同一种操作可能应该允许重复比如同一用户发两封不同内容的邮件。正确的做法是每次“逻辑动作”就生成一个唯一ID以这个ID为维度去重。Agent任务里每个step都应该有自己的ID不要整个任务共用一个ID否则就出现不了步骤级重试。3. 不重复执行的关键全局唯一执行ID 状态机3.1 每次任务下发都带一个幂等键幂等设计第一个动作就是引入全局唯一执行ID。我强调一点这个ID必须由调用方生成不能由执行方生成。原因很简单如果执行方崩溃重启后忘记了自己上次的ID那重试时就只能生成一个新ID下游看来就是两个请求。而由调用方生成ID的话重试时调用方还会用同一个ID执行方就可以靠它识别重复。具体做法分三层第一层任务入口。上游系统创建任务时生成task_id或request_id。如果上游是用户点击最好在前端就生成一个event_id后端保存下来。防止用户多次点击产生多个任务。第二层Agent框架内部。Agent把任务拆成步骤后每个步骤都分配一个step_id。step_id的生成规则可以直接用task_id 序号比如task_123_step_2。这个ID要传给工具调用层作为动作级幂等键。第三层工具执行落地。比如调外部订单接口把step_id透传给对方的幂等参数写数据库把step_id作为业务表的唯一字段发MQ消息把step_id放到消息key里。还有一个很多人会忽略的点幂等键本身要“传到底”而不是到中间层就被丢弃了。比如Agent先调了一个内部门户门户再去调订单中心如果门户没用Agent传下来的ID而是自己新造了一个那幂等就断了。只要中间有一个环节丢掉了上游ID全局幂等就是空谈。这里建议在框架层把所有工具调用的通用参数里强制带上幂等头比如x-execution-id每个工具都要透传。哪怕暂时用不上也比以后出了问题再来加要便宜得多。3.2 状态机的设计与状态转移有了ID还不够还需要知道这个ID的任务执行到哪个阶段了。任务状态机是防止重复执行的第二道保险。分享一个我常用的最小状态集不一定适合所有场景但足够覆盖大部分Agent任务。状态含义PENDING任务已创建等待调度RUNNING任务已被某个实例接管正在执行TOOL_EXECUTED已发出某个工具调用等待确认结果可能已生效CONFIRMED工具调用结果已确认副作用已落库COMPLETED任务全部完成FAILED任务失败需要人工介入或重试RECOVERING崩溃恢复中正在核实现场这里重点解释TOOL_EXECUTED这个状态。很多团队做的简化状态机只在PENDING、RUNNING、SUCCESS、FAILED之间切换漏掉了“发出但未确认”这个中间态。而Agent崩溃后最要命的就是这个中间态。当Agent调用外部工具后不管进程是否拿到响应都应该先把记录写入tool_execution表状态置为TOOL_EXECUTED。这样就算进程立刻崩溃数据库里至少还留了一条“我干过这事但不知道结果”的痕迹。之后恢复进程就可以拿着这条记录去查作用域外事件。状态迁移必须保证原子性。不能先读再写两个并发实例会同时读到旧状态。要用带条件的更新语句UPDATE task SET status RUNNING, worker_id $worker WHERE task_id $task_id AND status PENDING;如果影响行数为0说明任务已被别人接管直接放弃。这叫“乐观锁认领”是防止双写的基本功。给状态机设计一个核心原则任何状态都能通过一份持久化的事实在崩溃后重建。所以状态虽然是内存的但变迁依据一定要落库。3.3 落地时要注意的细节锁定粒度、超时、恢复第一个细节锁定粒度不能太粗。有些团队为了省事直接对整个task加一把分布式锁任务级的操作全部串行。这在Agent场景会带来麻烦因为一个任务里可能有多个工具调用粒度太粗会导致本来可以并行的步骤变成串行延长任务时间。正确做法是“任务级加锁但锁只保护状态转移每个工具执行步骤都用自己的唯一索引去重”。第二个细节超时后的动作选择。工具调用超时不应该直接标记失败也不应该直接重试。超时只代表我们没有在预期时间内收到结果不代表动作没有发生。正确做法是把状态标记为TOOL_EXECUTED并记录下来然后进入一个独立的“核实流程”。核实流程里优先调用对端的状态查询接口如果对端不提供查询接口就降级为等待一段时间再查询或申请人工介入。总之绝不能默认“超时没执行”。第三个细节崩溃恢复的启动顺序。进程重启后不要一上来就把所有未完成任务重新跑一遍而是要分成两步先恢复现场再决定动作。恢复现场指扫描任务表找出所有没有终态的任务然后检查每个任务下所有tool_execution记录的状态最后根据每条记录的verified标志决定是跳过、继续还是补偿。这个顺序反了的话很容易在恢复过程中再次触发重复动作。还有一个隐性细节内存缓存。不能因为任务状态之前放在Redis里就只信任Redis。Redis也可能丢数据尤其没开启持久化时。关键任务的幂等判断必须以数据库为准缓存只能当加速器不能当唯一事实源。4. 不假装执行过真实执行确认与可观测性4.1 什么是“假装执行过”它怎么发生的假设你让Agent登录后台导出一份用户列表然后通过内部消息推送给相关负责人。Agent实际调用导出API后API返回了一个错误码401权限失效。但Agent并没有认真看这个错误码而是接着生成了“导出任务已创建消息已推送完毕”的回复。用户看到回复以为搞定了第二天一问根本没人收到推送。这就是一个典型的假装执行过。为什么会发生这种事关键不在模型“故意骗人”而在LLM的训练目标里没有“严谨执行记录”这个概念。它优化的是“生成与上下文一致的文本”。当上下文里有一堆例如“帮用户完成任务”“成功”“好的已完成”等高频词时模型很容易顺嘴就把成功话术说得很有说服力。另一个工程原因是工具返回结构不规范。有些工具返回的是一个自然语言字符串比如“操作完成”。Agent无法从中提取结构化状态只能靠语言理解。而语言理解恰恰是最不可靠的环节。所以不假装执行过的第一步就是切断“LLM直接解释工具结果”的路径。确切地说工具返回结果不应该直接传给模型作为事实至少要先经过一道程序化校验提取出可验证的事实字段比如success、code、resourceId然后再交给模型去组织语言。模型可以润色措辞但不能篡改事实。4.2 工具调用的真实结果如何确认我建议每个有副作用的工具都设计“校验器”。工具调用完之后框架自动执行校验而不是让模型判断。校验器分两部分第一部分是契约校验。工具返回必须符合预定义schema。比如约定所有工具返回{success: bool, code: string, data: object}。框架取到返回后先用JSON Schema或手写代码校验字段类型和必填项。如果不匹配直接判定为执行失败并记录原因。第二部分是业务校验。契约校验只能保证变量存在不能保证真的成功了。比如调创建订单接口返回200但订单号字段为空那业务上大概率是失败的。所以还要有针对性校验创建类操作回查一下订单ID删除类操作回查一下资源是否真的不存在。这个“回查”听上去会增加一次调用但它是成本最低的保险。对于一个重要动作多一次读操作的代价远小于错误执行带来的损失。另外不是所有校验都要同步做。对于一些异步场景比如Agent调用了消息推送服务服务回了一个消息ID但真正发送可能还有延迟。这时可以安排“延迟校验”比如等几秒再查询发送状态。有的系统会专门起一个CheckWorker轮询pending状态的记录直到超时或确认成功。再说回人工确认。有人问“有没有办法让它自动执行操作”说明大家都嫌手动点击确认烦。我理解这种诉求但高风险动作例如支付、删除数据库、批量发消息还是需要保留一个人工确认闸门。我们可以做一个分级低风险动作自动确认高风险动作在状态机里增加一个WAITING_MANUAL_CONFIRM状态等人工点同意后才继续。这既保留可靠性又不会让所有操作都变得卡顿。4.3 用“事件日志”代替“自说自话”Agent的最终回复说到底是一段文本不是事实。但系统不能只凭这段文本就判定“已经完成”。所以我们要把“事实来源”从LLM的总结切换到事件日志。事件日志在Agent执行框架里通常是这样的每当完成一个工具调用和校验就写入一条不可变的事件记录。这条记录由程序生成字段包括事件ID、任务ID、步骤ID、工具名、入参、原始返回、校验结果、校验方法等。LLM只负责读取这些事件然后生成对用户的回复。好处是即使LLM生成了一句话“任务已完成”我们也可以在后台运行一个一致性检查——该任务最后一条事件里有没有verifiedtrue如果没有这条回复就应该被拦截或标记为存疑。我还会做一个更狠的设计不允许Agent在缺少成功事件时输出成功类词汇。实现方式不是靠LLM自觉而是在输出后过滤或重试。当然最可靠的方式还是让回复生成本身引用事件ID例如要求Agent在回复“已完成”时必须附带对应的event_id。框架检查event_id是否存在且verified若不存在就不发出去。这也是“不假装执行过”的终极解法让事实和声明逐条对应。声明从哪来必须有事件依据。5. 实操方案一个Agent执行框架的设计示例5.1 核心数据结构这里给出一个最小可用、可复制的设计。假设用Python数据库用PostgreSQL。核心是三张表tasks、tool_executions、events。tasks表class Task(Base): __tablename__ tasks task_id: str Column(String, primary_keyTrue) request_id: str Column(String, indexTrue) # 调用方幂等键 status: str Column(String, nullableFalse) # pending/running/tool_executed/completed/failed/recovering step_index: int Column(Integer, default0) plan_json: Text Column(Text) # 规划结果 worker_id: str Column(String, nullableTrue) # 当前接管实例 created_at: DateTime Column(DateTime, defaultnow)tool_executions表class ToolExecution(Base): __tablename__ tool_executions id: str Column(String, primary_keyTrue) # task_id _step_ step_index task_id: str Column(String, indexTrue) step_index: int Column(Integer) tool_name: str Column(String) input_data: JSONB Column(JSONB) raw_output: JSONB Column(JSONB, nullableTrue) verified: bool Column(Boolean, defaultFalse) status: str Column(String) # sent/confirmed/verification_failed created_at: DateTime Column(DateTime, defaultnow)events表用来记录可追溯事件class Event(Base): __tablename__ events event_id: str Column(String, primary_keyTrue) task_id: str Column(String, indexTrue) execution_id: str Column(String, indexTrue) event_type: str Column(String) # tool_called/tool_verified/status_transition payload: JSONB Column(JSONB) created_at: DateTime Column(DateTime, defaultnow)注意tool_executions.id 必须是“步骤级”的而不是任务级的。也就是f{task_id}_{step_index}这样不同步骤不会互相干扰同一步骤重试才能被唯一索引拦下来。5.2 幂等控制的代码核心逻辑整个幂等控制围绕“认领”与“确认”展开。先看认领def claim_step(task_id, step_index, tool_name, input_data): execution_id f{task_id}_step_{step_index} try: with db.transaction(): db.execute( INSERT INTO tool_executions (id, task_id, step_index, tool_name, input_data, status) VALUES (%s, %s, %s, %s, %s, sent) , (execution_id, task_id, step_index, tool_name, json.dumps(input_data))) return (claimed, execution_id) except IntegrityError: row db.fetch_one(SELECT status, verified FROM tool_executions WHERE id %s, (execution_id,)) if row[verified]: return (already_done, execution_id) return (need_recheck, execution_id)拿到了 “claimed” 才能继续调用工具拿到 “already_done” 直接跳过拿到 “need_recheck” 不能盲目重试先做一次确认。然后是执行完后的确认def confirm_step(execution_id, raw_output, verifier): if not verify_schema(raw_output): db.update(execution_id, statusverification_failed) return False verified verifier(raw_output) # 业务二次确认比如查库/查外部状态 if verified: db.update(execution_id, statusconfirmed, verifiedTrue, raw_outputraw_output) record_event(execution_id, tool_verified, raw_output) return True else: db.update(execution_id, statusverification_failed, raw_outputraw_output) record_event(execution_id, tool_verification_failed, raw_output) return False这段代码最关键的一点是verified字段只能由verifier函数赋值不能由Agent模型赋值。无论LLM怎么描述状态永远以这条记录为准。5.3 进程崩溃后的恢复流程恢复流程写成伪代码大概是这样的def recover_tasks(): unfinished db.fetch_all(SELECT * FROM tasks WHERE status NOT IN (completed,failed)) for task in unfinished: db.execute(UPDATE tasks SET statusrecovering WHERE task_id%s AND status NOT IN (completed,failed), (task.task_id,)) for step in get_steps(task): exec_row db.fetch_one( SELECT * FROM tool_executions WHERE id%s, (f{task.task_id}_step_{step.index},) ) if exec_row is None: # 该步骤没发起过可以安全执行 continue_to_execute(step) elif exec_row[verified]: # 已确认生效跳过 continue else: # 状态不明先做外部查询 result external_verifier(step) if result confirmed: db.update(exec_row.id, verifiedTrue, statusconfirmed) continue else: # 无法确认走补偿或人工 raise ManualInterventionRequired(task.task_id, step.index)这个流程就是“以数据库为准逐步核实”的落地。注意恢复流程里不要再让模型重新规划整个任务而是尽可能沿用之前已经保存的plan_json和step_index。只有遇到确实缺步骤时才在保留已确认事实的基础上做局部补充规划。恢复过程本身也要幂等。如果恢复进程又崩溃了下次恢复要从头开始扫但借助tool_executions表的状态不会执行两次。6. 常见问题与排查技巧实录6.1 快速排查速查表我把日常处理过的Agent故障整理成一张速查表遇到问题可以直接对照。症状可能原因解决方向任务重复执行生成了重复订单没有全局幂等键每次重试都换新ID调用方生成requestId数据库加唯一索引并发两个Worker同时执行同一任务任务认领时没有做状态CASUPDATE task SET status... WHERE status...工具超时后重试导致重复扣款超时被当成失败直接重试超时记录为TOOL_EXECUTED先查再重试Agent回复“已完成”但事实未完成工具返回值未校验LLM自行编造强制schema校验 业务二次确认状态恢复后跳过了一些未执行的步骤恢复时只判断任务status没有检查步骤记录逐步骤检查tool_executions表使用Redis做幂等锁锁过期后并发锁过期时间设置不合理改用数据库唯一索引或行锁工具支持内部自动重试导致发散模型/工具层自己重试了多次工具文档中明确禁用自动重试手动点两次“执行”产生了两个任务前端没有生成事件ID前端首先生成eventId后端按eventId去重这张表其实就是一个设计清单也可以当成Code Review时的检查点如果代码里没有幂等键没有确认校验没有状态机那大概率会掉进上述某个坑。6.2 我踩过的几个坑第一个坑只对任务入口做了幂等没对步骤做。当时我们做了一个知识库同步Agent入口部分加了request_id去重看着没问题。结果某次崩溃恢复时框架把任务的前几个步骤全部重放了其中一个步骤是“写入知识库文档”。同一个文档被插了两次多了两条重复记录。后来把步骤级唯一索引加上才算兜住底。第二个坑把工具调用后“返回200”当作“执行成功”。有一次Agent调文件上传返回200我们直接标记成功但用户后来发现文件根本打不开。查日志才发现上传服务返回200只是说明网关收到了请求真正的对象存储入账是异步的后来入库失败了。从那以后对上传类操作一律增加“文件存在性”校验等几秒查一下文件大小。第三个坑模型内部自动重试没被记录。我们在工具函数里没有限制调用次数LLM发现某次调用超时后自己换了个参数又调了一遍。最终数据被写入了两次。排查时从框架日志看只有一次调用因为重试是模型在同一个上下文里生成的第二次tool_call框架把它当成了另一个步骤没有关联到同一个幂等键。现在所有工具超时返回都明确提示“禁止自动重试”框架层面也加了一层“同一语义步骤只能调用一次”的校验。第四个坑人工确认闸门被当成可选项关闭了。有一次为了演示顺滑把测试环境的删除动作设成了全自动确认。结果Agent误把整个测试库的表都删了。虽然能恢复但耽误了一下午。后来定了一个规矩凡是delete、drop、批量更新、支付类动作无论如何都要经过人工确认幂等和校验都只是辅助不是替代。这些坑都有一个共同点都是因为“太相信Agent的描述”或者“太相信某一个环节的成功”而忘了完整的证据链。做Agent可靠性本质上是在构建一条可追溯的证据链每一步都留下可查证的事实每一步都基于事实做决策。最后分享一点个人感受。我最早做Agent的时候注意力全在“怎么让LLM更聪明”觉得模型能力上去了一切就都解决了。后来开始上线跑真实任务才发现最头疼的从来不是模型不会回答而是系统说不清楚自己到底做了什么。崩溃这件事是躲不掉的。我们能做的是让Agent在崩溃前后都保有对事实的尊重所有执行都配上幂等键所有状态都落到数据库所有“已完成”都必须有校验事件背书。我把这两条写成了团队内部的上线检查项没有幂等键的工具调用不允许上生产没有结果确认的Agent功能不允许上生产。执行起来确实麻烦但确实帮我省下了无数个加班排查的夜晚。希望这篇文章也能帮你少踩几个坑。