ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Code Agent容错设计:从稳定点、幂等性到崩溃恢复的工程实践

Code Agent容错设计:从稳定点、幂等性到崩溃恢复的工程实践 前几天我排查了一个坚持到第40分钟才崩溃的 Code Agent 任务。日志把异常点标得很清楚git commit 阶段网络超时。乍一看是小事可我把工作区一检查就有点坐不住自动生成的变更散落在十几个文件里有的被改了一半还有一个临时文件被带进了源码目录。那次任务跑的是文档生成没碰正式仓库但它给了我一个非常直接的教训我以前给 agent 外层套的 try/except对这种场景几乎等于没有。这个系列走到了 Harness 设计部分的第四篇。前几篇更多在讲 agent 跑起来需要哪些外壳、工具怎么编排、上下文怎么组织而一旦谈到容错与恢复讨论对象就从“让智能体通过一次考试”变成了“让智能体一边和外部世界交互一边保证就算失败也不留下无法收拾的局面”。今天这篇不想给一大段复制就能跑的代码我想把它拆成一个可以照着搭的设计骨架。适合正在自建 agent 任务编排、给 code agent 写执行框架、或者只是遇到过“任务明明崩了但状态已经被搞乱”的朋友。1. 先从一次事故说起恢复不是简单把异常接住1.1 那次任务到底是怎么崩的任务的流程并不复杂读取一批 Markdown 文档按模板补全目录结构跑一遍链接检查最后把改动提交到仓库。跑在大概第 36 分钟的时候agent 对同一个“更新文档元信息”的工具连续发起了两次调用。第一次其实已经写入了大部分字段但因为返回超时被判定为失败于是模型按“失败就应该换个方式再试”的惯性又执行了一次第二次在合并时把文件内容搞出了冲突标记还残留了一个后缀为 .tmp 的临时文件。真正的崩溃发生在后面的 commit 阶段pre-commit 钩子检测到大量本地未跟踪文件加上格式问题直接失败。但从工程视角看真正的根因早就埋下了失败被重试放大了而工作区在没有校验的情况下被重复修改。这个场景不算罕见。Code Agent 尤其是长时任务里模型经常会把“某个调用失败了”理解为“我该换个姿势再来一次”。在只有一轮对话的短任务里这个策略基本不会出事可在会连续操作文件系统、执行命令、留下产物的工作流里每重试一次环境中就会多出一份不确定性。1.2 try/except 在 agent 场景里为什么不够如果你把 agent 的某一步包进 try/except你捕获到的只是“这次调用抛出了异常”这个事实。真正的问题是异常抛出时工具可能已经执行了一半也可能完整执行完了但响应丢失有些工具不抛异常只是会挂起直到超时而超时并不告诉你它内部改了什么更麻烦的是某一步失败之后后续步骤的决策依赖的已经不是最初那套干净上下文了。所以 harness 层真正要处理的不是“让某一行代码不报错”而是“让整条 agent 轨迹相对外部世界保持一致”。一个普通函数调用链的容错可以靠栈回退实现而 agent 执行的是带副作用的动作序列栈回退根本退不掉已经写入硬盘的内容。1.3 用“稳定点”代替“永不失败”我现在的习惯是不再追求让 agent 永不失败而是追求每一步之间都存在稳定点。稳定点这个概念可以这样定义当任务停在这个位置时你能够明确说出当前环境处于什么状态并且知道从哪个动作继续不会产生重复副作用。Harness 的核心职责之一就是在一连串不确定性动作之间铺设稳定点。模型层出错可以重试工具层出错要回到上一个稳定点再决策。这篇文章后面的所有设计基本都是在回答一个问题稳定点应该怎么铺。2. 四类故障来源与它们的“反恢复”特性2.1 把失败来源分层才能决定容错策略我习惯先把故障按发生位置分成四层模型层、工具层、编排上下文层、宿主环境层。每一层的失败模式完全不同恢复手段也完全不能互相套用。故障层常见形态生命周期恢复姿势模型层超时、限流、生成中断、返回 JSON 格式非法、内容被截断瞬时为主可重试指数退避重试、格式校验、降级摘要工具层文件写入半途失败、命令执行失败但留下副作用、检索返回超限持续存在可能污染环境幂等设计、事务性写入、环境指纹比对编排上下文层历史消息过长、工具输出挤占上下文、旧摘要失真累积性恶化摘要滚动、滑动窗口、内容裁剪宿主环境层进程被杀、容器重启、磁盘写满、电源中断瞬时但破坏性大检查点落盘、恢复协议、外部持久化为什么说它们有“反恢复”特性因为这里面很多故障不是发生一次就结束的。比如工具层写坏了文件你重试只会把坏文件再改一次。又比如上下文层的历史消息膨胀短时间可能没问题但超过模型窗口后之前的摘要会被挤压失真最终导致 agent 用错误的前提做决策。这种问题不是靠单个异常捕获能解决的。2.2 瞬时故障与持久性故障要分开处置一个很实用的判断维度是这个故障在重试时会发生什么。如果重试后系统能恢复是瞬时故障。如果重试后垃圾状态被进一步放大是持久性故障。模型层超时通常属于第一种但工具层失败往往是第二种。设计 harness 时我不会把所有错误都交给同一条重试通道而是先给故障打标签再决定是自动重试、等待人工确认还是走回滚路径。给故障打标签这个动作是整个容错设计的地基。它比写具体的重试逻辑更早也更影响最终可靠性。3. 幂等语义重试技术的地基而不是兜底3.1 只要存在重试就必须先回答“这个工具调用是不是幂等的”我把这个问题放在所有容错机制的最前面因为它决定了你后面几乎所有恢复动作能否安全自动化。最典型的场景是“超时但实际成功”。一个文件写入工具已经把内容写完了但网络超时导致 agent 认为它没成功。如果自动重试文件会被写两遍如果工具本身设计成幂等的第二次写入不会产生多余副作用结果仍然正确。判断一个工具调用是否幂等比给工具加 retry 重要得多。在 code agent 里读操作天然幂等写操作则分两种增量修改类比如在文件末尾追加一段文字天然不幂等声明式更新类比如“确保某个路径下的文件内容等于以下内容”则可以设计成幂等。3.2 把写操作表达成期望状态而不是一段动作序列这是我在实际代码里踩过几次坑之后总结出的原则。举个例子早期我给 agent 提供的文件修改工具长这样“把第 10 行替换成 xxx”。第一次执行可能是对的可一旦这个任务因为某种原因重复运行第 10 行已经不是原来内容了这个工具就会把不相干的代码改掉。后来我改成了 apply_patch 风格让 agent 提交“原文上下文 新内容”harness 负责在当前文件中定位并替换。执行前检查原文片段是否仍然存在如果不存在但新内容已经存在就认为操作已完成如果二者都不匹配直接报冲突而不是硬改。这样设计后重试不会越改越乱恢复时的安全性大大提高。3.3 harness 侧要有一份可审计的动作记录除了工具本身要幂等harness 还得留痕。每次工具调用都应当带上 step_id、execution_id 和状态并且这些信息要进入持久化存储不能只存在内存里。核心结构大致长这样{ step_id: step_023, execution_id: exec_a1f3, tool: apply_patch, args: {...}, status: pending, # pending / confirmed / failed pre_hash: {...}, # 执行前相关文件指纹 post_hash: {...} # 执行后相关文件指纹 }这里的关键不是记录本身而是 status 的状态迁移。pending 表示“已经发起但没确认结果”confirmed 表示“确认完成且副作用已被验证”failed 表示“明确失败”。恢复时如果看到一个 pending 动作就要按它的幂等属性决定下一步只读工具直接重放幂等写工具可以重试无法判定的一律进入人工确认队列。一个容易被忽略的细节是执行前后的文件指纹。只记录成功失败恢复时你依然不知道环境是否被改成预期状态记录指纹以后恢复流程可以通过比对当前哈希与 post_hash 是否一致来判断“这个动作是不是真完成了”。3.4 重试策略要区分“可自动重试”和“需要收手”故障类型是否自动重试说明瞬时网络错误是指数退避 抖动最多 2-3 次模型限流是等待时间按 Retry-After 或加倍退避超时且无法确认是否完成否标记 pending交给恢复策略工具明确失败且前置条件未变视幂等性而定幂等则可重试否则人工确认工具调用报出格式类错误否应转给模型修正参数而不是重放同一调用指数退避时我会加抖动避免多个并行任务同时重试造成二次限流。连续失败后及时收手也很重要宁可让任务停在稳定点也不能让 agent 在一个坏掉的环境里反复横跳。4. 检查点设计什么该被记录什么不该被记录4.1 快照 vs 事件溯源我用的混合方案不少讨论会把检查点分成两条路线状态快照和事件溯源。状态快照是定期把整个 agent 会话信息保存下来恢复快事件溯源是保存所有动作和结果需要时重放能回答“为什么会变成这样”。实际做的时候我没选二选一。我的做法是以事件为主、以快照为辅每个动作记录事件定期或在关键步骤前后生成轻量快照。这样既有事件溯源带来的可解释性也有快照带来的快速启动能力。一份可用的 checkpoint 至少包含四部分meta 信息任务目标、创建时间、当前工作目录、当前分支编排状态已完成的最后一步 step_id、当前执行计划中的位置、待处理动作列表上下文快照系统提示、长期目标、最近 N 轮关键消息、必要的摘要环境证据相关文件路径的指纹、工作区状态、临时目录里的半成品标记。4.2 保存时机与保存原子性保存时机不能太随意。我不会在每次模型输出后都保存完整上下文那样 checkpoint 会膨胀到没法用。比较实用的时机是每个工具动作完成后、进入外部命令执行前、以及 agent 准备做重大决策比如要向仓库提交大量变更之前。保存本身要有原子性。最稳妥的方式是写临时文件再原子替换避免进程在保存中途崩溃导致 checkpoint 文件损坏。我自己还习惯在同一目录下保留上一个可用 checkpoint 的备份恢复时如果新文件校验失败可以自动退到上一份。4.3 我踩过的三个坑大输出、无边界摘要、同进程同文件第一个坑是保存完整工具输出。一次检索可能返回几十万字符全部写进 checkpoint 会让文件迅速膨胀。后来我只保存摘要和指向结果的路径真正的大输出放到外部存储恢复需要时再按需读取。第二个坑是摘要没有边界。工具输出的摘要如果无限累加到最后摘要本身比原文还难读。我的方案是分层摘要第一步只保留最近一轮的完整内容更早的历史统一合并成高层的段落摘要并设定摘要长度的上限。第三个坑是检查点文件与主任务写在同一个进程里还自锁。进程崩溃时锁文件不可靠甚至可能把 checkpoint 写坏。现在统一用先写临时文件再原子改名的模式恢复进程读取时永远只面对一个完整文件或一个不存在的文件不会遇到半截数据。5. 超时、熔断与资源配额不让一个失控步骤拖垮整个任务5.1 每个工具都要有独立超时整个任务还要有总预算在 agent harness 里最可怕的一类失败是“不报错但永远不返回”。所以每个工具调用都必须有明确的超时控制并且按工具类型区分超时量级本地文件读写、代码检索5 到 10 秒单个模型生成请求30 到 120 秒需要长时间运行的外部命令测试、构建可以放宽到分钟级但必须支持输出流和主动取消。除了单步超时长任务还需要总预算。如果整个任务只允许运行 30 分钟而 agent 已经在某个子环节消耗了 25 分钟那无论当前步骤看起来多接近完成都应该让模型先停下来总结状态而不是继续无限等下去。5.2 同一个工具连续失败时应该熔断熔断器这个概念来自微服务但 agent harness 里同样需要。模型带着自己的策略反复尝试同一个不可用工具是很常见的浪费路径。一个很简单的实现记录某个工具最近 3 次调用的结果如果连续失败达到阈值就在一段时间内不再向模型提供这个工具或者改成只允许用只读方式调用。熔断期间要停止模型继续尝试但也不能让整个 agent 卡死。正确的处理是让模型感知到“这个工具暂时不可用”要求它基于已有信息调整策略或者回到上一步询问用户。agent 的核心价值是能随机应变而不是换个参数重试十遍。5.3 并行子任务必须有资源配额不少 code agent 框架会支持并行执行多个子任务或同时操作多个文件。并行度一旦放开风险会成倍增加。需要明确的配额包括同时执行的子任务数量、单个子任务可占用的最大内存、外部进程可创建的临时文件总量。在 Linux 上我会尽量用进程组或者 cgroup 管理外部命令确保超时后能杀干净整个子进程树而不是只杀掉父进程留下孤儿任务。Windows 上也同理必须在启动任务时就给一个可关闭的句柄。很多 agent 崩溃后的脏状态不是来自业务逻辑而是来自一个没人回收的残留子进程。6. 进程崩溃后恢复重放轨迹而不是重新执行6.1 不确定是否完成过的动作一律不自动重放进程恢复和普通的 retry 有个本质区别retry 发生在同一个进程内你可以依赖内存里的状态判断进程恢复则要靠持久化记录来还原现场。这时候有一条原则非常关键——如果某个动作在崩溃前处于 pending 状态无法确认是否完成那就不能自动重放。原因是它可能已经执行完了只是没来得及写 confirmed。自动重放会造成重复副作用完全不处理又会让后续步骤建立在“某事已发生但实际没发生”的错误前提上。我的处理分三种只读动作直接重新执行百分百安全带幂等语义的写动作自动重试或者跳过无法判定且副作用较大的写动作放入待人工确认队列并阻止 agent 继续行动。这个策略会让少数任务需要人工介入但换来的是“agent 不会在没人盯着的情况下基于猜测继续开工”。权衡下来非常值得。6.2 恢复后的第一轮行动应该只允许读我见过很多恢复方案只把 checkpoint 加载回上下文然后直接对模型说“请继续”。这是把恢复当成了续写严重低估了环境变化的可能性。更稳妥的做法是恢复后先给模型一个“观察轮”只允许它运行读取类工具比如查看当前工作区状态、读取相关文件内容、检查 commit 状态。一切确认完毕、没有发现意外变更之后才够解除写操作限制。为了做到这一点检查点里要保存“任务目标”和“当前进度摘要”而不是只保存对话轮次。恢复时把新上下文整理成一个清晰的简报原任务目标是什么崩溃前已经确认完成了哪些步骤结果分别是什么仍然处于 pending 状态的动作是哪些工作区指纹与记录是否一致。这份简报要明确告诉模型如下信息来自持久化记录需要你先验证再行动。6.3 恢复流程本身要当成一个正式流程来设计和演练一次完整的恢复不是“加载 checkpoint 然后继续”而是一条独立流程加载最近一份完整 checkpoint校验工作目录、依赖和相关文件的 hash识别所有 pending 动作给它们分类生成恢复简报让模型进入只读观察模式收集观察结果后允许模型提交下一步计划确认计划后重新开启正常执行。这一步容易忽略的是第 6 步。有时候环境变化并不大模型看一眼就能继续但恢复后第一份计划仍然值得人工或规则层确认。因为模型刚从旧上下文跳出来可能把“自己正在恢复”误当成“自己正在正常推进”从而做出过于激进的决策。6.4 给失败打标签让恢复策略可以持续演进同一类崩溃模式会在不同任务里反复出现。现在我每遇到一个新的失败模式都会给它打一个标签记录触发条件、失败表现和最终采用的恢复策略。比如“apply_patch 因上下文冲突失败”是一个典型标签它的恢复策略通常是让 agent 读取文件当前内容重新生成 patch而不是反复用旧的 patch 硬试。“模型限流后 agent 立即重试”也是一个标签恢复策略是等退避时间结束再恢复。这些标签积累起来后harness 可以在崩溃发生时直接匹配历史模式优先给出验证过的恢复路径而不是每次都让模型从零开始想。这套做法不需要引入特别复杂的规则引擎用一份持久化的策略表就能跑起来。7. 配套可观测性把恢复过程变成可审计的流程7.1 日志必须把 step_id、execution_id、attempt 串起来如果日志里只有 error level 和错误信息崩溃后想回答“它到底执行过哪些操作”会非常困难。我会让每一步调用都输出结构化日志包里带上对应 ID{ ts: 2025-01-15T10:23:11.192Z, level: error, step_id: step_023, execution_id: exec_a1f3, attempt: 2, tool: file.write, error: timeout after 6000ms, file_hashes: {...} }有了这个基础日志就不只是给人排错看的恢复流程也能直接根据日志重建动作链判断哪个步骤是 pending、哪个步骤的产物已经留在环境里。7.2 恢复决策要有留痕不能只写在一个内存变量里当恢复流程决定把某个动作标记为 pending 或人工确认时要把决策和原因写入恢复报告。比如这次恢复跳过了步骤 5是因为它的 execution_id 对应的动作已在崩溃前确认执行步骤 8 被冻结是因为它可能已修改了文件但无法验证幂等性。这些留痕很重要因为用户或上层系统要在 agent 恢复后判断是否信任它的后续操作。没有客观的中断原因记录agent 给出的“我继续完成了”就缺少依据。恢复报告的格式可以很简单就是一个 markdown 文件或者一张 JSON 表但它必须存在。7.3 用 kill -9 做恢复演练才是检验 harness 的唯一标准最后分享一个我评估 harness 恢复能力的方法人工制造进程崩溃。我会准备一个需要运行 20 到 30 分钟的可复现任务在任务进行到中段直接 kill -9 杀掉进程然后启动恢复流程观察它能否正确判断已完成步骤、pending 动作和工作区变化。这个演练不能只看任务有没有“继续跑完”还要对比最终产物与不中断任务的基准结果是否一致。我通常会让任务在每个阶段分别写一个 marker 文件崩溃恢复后检查这些 marker 是否存在用来验证 agent 有没有跳过或重复执行关键步骤。这个方法虽然听起来糙但它能真实暴露检查点漏存、幂等设计不完整、恢复上下文提示不够清晰等一系列问题。凡是没做过这类演练的 harness我都不敢直接拿去跑长任务。一个能在 kill -9 之后还能正确恢复的 harness不能保证永远不出问题但至少能保证你在半夜被叫起来处理故障时可以从恢复报告里快速看清现场而不是对着工作区发呆。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进