浅谈 Agent 系统设计之失败类型与处理
从任务完成、没完成和停不下来三个角度,讨论 Agent 的常见失败、处理办法,以及 sandbox 和容器的作用。
Contents目录
前两篇介绍了如何把 Agent 接入系统,以及可以使用哪些中间件。这一篇讨论 Agent 出错以后怎么办。
还是以自动修复代码为例:用户提交一个 Bug,Agent 分析代码、修改文件、运行测试,最后返回结果。这里可能出现三种情况:它说做完了,但结果有问题;它做到一半失败了;它一直在做,却怎么也不结束。
这三种情况需要分别处理。统一加上“失败后重试三次”,往往解决不了问题,还可能把事情变得更麻烦。
任务完成了
先检查它到底做了什么
Agent 回复“已经修复”,或者进程返回码为 0,都不代表 Bug 真的修好了。它可能只写了一份修复建议,没有修改文件;也可能运行了测试,但测试命令根本没找到用例。
因此,系统需要自己检查结果。补丁有没有保存,能不能应用到原来的代码版本,测试是否实际执行过,这些都可以由程序检查。第一篇提到的 submit 工具就适合做这件事:Agent 提交结果,工具检查,不符合要求就返回具体错误,让它修改。修改次数也要有限制,避免一直卡在提交和退回之间。
更难发现的是“做了,但做错了”。例如,用户要求修复并发问题,Agent 却只处理了空值;或者它修好了主要流程,却漏掉了用户要求保留的旧接口。
处理这类问题,需要在执行前明确怎样才算完成。代码任务可以先准备一个能复现 Bug 的测试,再检查修改后是否通过,以及原有功能是否正常。对于难以自动判断的任务,可以让人或另一个模型审核。模型审核也可能犯同样的错,重要结论还是要对照原始材料。
还要防止 Agent 为了通过测试,把失败用例删掉、跳过,或者直接修改预期结果。测试变绿了,问题却没有解决。用于验收的关键测试应由系统保管,Agent 可以补充测试,但不能随意取消原来的要求。
容器可以帮助我们单独验证结果:把补丁应用到一份干净的代码副本,在新容器里重新运行测试,减少缓存、临时文件和依赖变化的影响。最后提交的补丁应该与验证过的版本一致,测试记录也由系统收集。
做对了也可能惹出别的问题
有时 Bug 修好了,但 Agent 顺手升级了整个项目的依赖、改了其他任务的文件,或者把带有密钥的日志传到了外部网站。还有一种情况是提示注入:它把网页或仓库文件里的文字当成指令,执行了用户没有要求的操作。
这就需要 sandbox 限制它能做什么。比如,只允许修改本次任务的目录,只允许访问必要的服务,把发布代码、创建 PR 等操作交给专门的工具检查权限。Anthropic 的 sandbox 介绍也强调了文件和网络限制需要配合使用。
不过,sandbox 里允许修改的文件仍然可能被改坏。隔离环境能缩小影响范围,结果对不对还得另外检查。
任务没有完成
先判断值不值得重试
模型限流、缺少依赖、内存不足和缺少用户输入,都会让任务停下来,但处理办法不同。
| 原因 | 处理办法 |
|---|---|
| 限流、短暂断网、服务不可用 | 等待后重试,逐步拉长间隔,并设置次数上限 |
| 依赖缺失、版本不兼容、路径错误 | 先修好环境,再执行失败的步骤 |
| 内存不足、磁盘写满 | 检查资源消耗,调整任务或资源配置后再试 |
| 一直理解错问题、遗漏关键条件 | 补充信息,调整计划,必要时整理已有结果,换个会话继续 |
| 缺少输入或授权 | 保存进度,等待用户处理,释放 Worker |
| 要求冲突,确实无法继续 | 说明原因,返回已有结果,结束任务 |
依赖不兼容时,把同一条命令再跑十次通常也没有用。使用容器并固定镜像和依赖版本,可以减少环境差异;环境被改乱了,也方便重新创建。
等待用户输入也不必算作失败。系统可以保存任务,等输入补齐以后再继续,没有必要让 Agent 一直运行着等待。
接着做需要保留哪些东西
有些框架可以通过 session id 恢复对话,但对话恢复了,文件不一定还在。Agent 记得自己改过三个文件,临时容器却已经被销毁,下次执行就会从错误的假设开始。
因此,需要一起保存对话、代码修改、环境版本,以及已经完成的外部操作。这些信息要对应同一阶段,避免对话记着第十步,文件却只保存到第五步。这样的阶段性记录就是检查点。
恢复时创建新容器,取回代码和修改,再让 Agent 接着做。检查点和重要文件要保存在容器外面,否则容器一删,进度也没了。LangGraph等框架能保存会话和执行状态,文件与运行环境仍然需要应用自己处理。
超时不代表操作没有成功
假设 Agent 调用接口创建 PR,PR 已经建好了,但响应在网络中丢失。系统看到超时后直接重试,就可能重复创建。
对于这类操作,应该优先使用接口提供的幂等机制:同一次操作始终使用同一个标识,让接口识别重复请求,避免再次创建或修改。重试时不能换一个新标识,具体有效期和限制也要看接口约定。AWS 的幂等 API 文章详细解释了这种情况。
如果接口不支持,就需要查询已有结果再决定怎么处理;查不到也不能完全排除原请求仍在执行。无法确认时,应把这一步标记为待确认,避免盲目重试。
删除容器也撤销不了已经创建的 PR、发出的邮件或数据库里的修改。这些操作必须单独记录,出错后再决定如何补救。
任务停不下来
一直在调用工具不代表有进展
Agent 可能反复读取同一个文件、重复执行同一条失败命令,或者在两个方案之间来回修改。还有时测试已经通过,它仍然继续重构,最后又把代码改坏。多 Agent 系统里,也可能出现相互等待、不断拆分任务的情况。
所以,除了心跳,还需要观察它到底有没有往前推进。例如,是否找到了新信息,失败测试是否减少,某一步是否已经完成。相同的调用和错误反复出现时,可以提醒它重新检查思路;调整后仍然没有进展,就应停止或交给人处理。
这里也要考虑工具的特点。轮询本来就会重复调用,编译可能很久没有输出,不能只凭“重复”或“安静”就判断任务卡住了。
给整次任务设置上限
每条命令都有超时,整个任务仍然可能无限运行。例如每条命令一分钟内就返回,但 Agent 一直发起新命令。因此,除了单次调用超时,还需要限制任务总时长、重试次数、调用次数,以及 Token 或费用。
这些限制应由执行框架负责,也就是启动和管理 Agent 的程序。重试和子 Agent 的消耗都要计入同一个任务,不能重新启动一次就把预算清零。并行调用要先分配额度,避免每个 Agent 都以为自己还有完整预算。
还要注意重试可能叠了很多层:SDK 重试、Agent 重试、队列重新分发任务,再加上容器重启。各层都认为自己只试了几次,实际请求量却可能远超预期。它们需要共同遵守任务的次数和时间限制,任务取消后也不能再把它启动起来。
如果使用 Kubernetes,Job 的 activeDeadlineSeconds 可以限制运行时长,backoffLimit 控制失败重试。ttlSecondsAfterFinished 则用于结束后的清理,不能拿来限制运行时间。跨多个 Job 的总时长仍要由执行框架管理。具体行为见 Kubernetes Job 文档。
真正把它停下来
给 Agent 发一句“请停止”不够。如果它卡在工具调用里,可能根本读不到这条消息。即使 Agent 进程结束了,它启动的浏览器、测试服务或远程任务也可能还在运行。
因此,执行框架需要记录这些进程和任务,取消时一起处理:先停止发起新调用,通知正在运行的任务退出,留一小段时间保存进度;超时后强制终止,再收集日志并检查有没有残留任务。
容器让这件事更容易管理。Docker 的 stop默认先发送 SIGTERM,等待一段时间后再发送 SIGKILL。不过,容器外的远程任务仍然需要另外取消。
重试以后,还可能出现旧 Worker 恢复连接、回来提交结果的情况。可以给每次执行分配不同的编号,让接收结果和工具请求的服务拒绝已经失效的执行。这个编号用于区分新旧 Worker,与前面用来防止重复操作的幂等标识是两回事。
Sandbox 和容器的作用
Sandbox 的目的,是限制程序能够访问的文件、网络和系统能力;容器可以用来实现这些限制,同时提供容易重建的运行环境。但启动一个 Docker 容器,并不代表限制已经配置好了。
对于代码 Agent,我会优先做好这几件事:
- 每次任务使用独立目录和容器,只挂载需要的文件,参考资料尽量只读。
- 限制 CPU、内存和进程数,另外控制磁盘与日志大小,避免一个任务拖垮整台机器。
- 使用非 root 用户,只开放必要的权限和网络访问,不把宿主机主目录或 Docker socket 直接交给 Agent。
- 把检查点、日志和重要文件保存在容器外,失败后能够查原因、接着做。
Docker 的资源限制和安全文档介绍了相应机制。CPU 和内存限制不会让一个不断调用模型的 Agent 自动停止,任务时长和费用仍然要单独控制。
普通 Linux 容器与宿主机共享内核。需要运行不同用户提供的不可信代码时,还可以考虑 gVisor或基于 Firecracker 的 microVM,加强隔离,同时评估兼容性和运行成本。
对 Agent 系统来说,结果检查负责发现“做错了”,检查点帮助处理“没做完”,执行框架负责结束“停不下来”的任务。Sandbox 和容器则限制这些错误能影响到哪里,并让清理和重试更容易。