content/02-agent/harness/agent-sandbox-lifecycle-reliability.md

Agent 沙箱运行时的生命周期可靠性

先说结论。docker run 看着是一条命令,其实是两步:先建出容器,再把里面的进程拉起来。中间哪一步挂了,都会剩一个没跑起来的空壳。这种残留没法完全避免,所以系统稳不稳,不看它会不会出故障,而看出故障后能不能自己恢复。这套沙箱恰恰没有任何恢复手段,于是一次几毫秒的挂载权限抖动,就把整组文件工具卡成了永久不可用。

更冤的是那个固定容器名。它本想换来"重启后接着用同一个容器",可代码根本没做这件事,复用的好处一点没拿到,撞名的风险却全担了。

下面用一个真实事故(自建的 per-tenant Docker 沙箱)把这条线讲透。本文是文章骨架,可以再扩写成工作文档或博客。


序幕 · 钻到核心

一张截图加五个为什么,先把核心挖出来。

0. 现象:一次"工具执行失败"如何让 agent 开始说谎

AI 聊天里 5 个文件工具(Read/Write/Edit/Grep/Glob)突然全报"工具执行失败",普通业务工具却正常。用户说"你再试试,系统已更新",模型却一本正经地回:"这组文件工具在当前环境被限制了,无法使用",还贴心地编出"复制粘贴 / 改建工单"的替代方案。

先把这张"模型自己编替代方案"的截图摆最前面。这里最吓人的不是工具坏了,而是工具坏了模型却不知道,还一本正经替它找补。截图背后是两层问题:工具为什么坏(infra),和模型为什么说谎(agent 可观测性)。

(截图、抛栈与逐行根因来自源项目私有问题清单 issues #18/#19,本文只保留可公开的技术拆解。)

1. 五个为什么:60 秒钻到核心

别急着分类、别急着上方案,先纵向钻到底。一个"工具执行失败",问五层就见底:

  1. 5 个文件工具为什么全 fail? → 它们都跑在同一台沙箱容器里,容器没起来。
  2. 容器为什么没起来?docker run --name agent-sandbox-<tenant> 撞名,exit 125: name already in use
  3. 名字为什么被占? → 上一次留下一个卡在 Created 的残骸容器,一直占着这个名字。
  4. 残骸为什么没人清、重试也没用? → 沙箱管理器没有任何恢复手段:create 抛异常后既不缓存、也不删旧重建,一次失败就永久卡死,直到人工 docker rm
  5. 为什么会同时有"固定名 + 不自清"这对组合? → 当初赌固定名 …-<tenant> 能跨重启复用容器,值得为它担撞名风险。可复用只靠进程内一个 map,代码从不 docker start 旧容器,复用的好处压根不存在。

挖到底就清楚了。真凶不是表面的现象,也不是最后那层的 chown 抖动(那只是导火索),而是第 4、5 层凑一起:为了一个根本不存在的复用好处,用了会被占的固定名,又不给它任何恢复手段。一次几毫秒的抖动,就这么拖成永久故障。

两条最底层的道理

  • (A) docker run 不是一步到位。 建容器和起进程是分开的两步,中间失败就留残留。这是改不掉的事实。
  • (B) 残留躲不掉,那"可靠"就等于"残留能被收拾"。 能收拾的办法就三种:建的时候幂等、失败了自清、启动时把漏网的扫掉。这套系统一种都没有,所以任何抖动都拖成永久故障。

下面四幕,都是顺着这两条展开。


第Ⅰ幕 · 问题的本质

先把问题归位,再看残留从哪来、哪种最致命。

2. 先定位问题域:这是什么问题,不是什么问题

排查第一步是别走错房间。这事最容易被误诊成"模型 / prompt 问题",其实不沾边:

内容
不是模型不够强 / prompt 不好 / tool-calling 不可靠——那属 prompt、解码、模型选型,是另一篇文章
Agent 工具执行运行时 / 代码执行沙箱的基础设施可靠性问题
同类的东西OpenAI Code Interpreter、Anthropic computer-use、E2B、Modal Sandboxes、Daytona、Firecracker microVM,都是"给 agent 一台能隔离的计算机"
在 agent loop 的位置reason → act → observe 的 act/observe 边界。推理没问题,是干活和环境这一侧塌了

案例就是用 per-tenant 的 Docker 容器自己搭了这层,毛病出在容器怎么建、怎么收。

3. 沙箱容器的生命周期状态机:残留从哪来

道理 (A) 落到状态机上就一目了然:

(none) ──run──▶ Created ──start──▶ Running ──exit/kill──▶ Exited/Dead
                  │                   │
            chown/mount 失败      进程没了容器还在
                  │                   │
                  ▼                   ▼
              残留·空壳          残留·孤儿
  • docker run 把"建容器"和"起进程"塞进一条命令,内部却是两步,中间一卡就停在 Created
  • 不同的失败留下不同残留:Created 空壳、Exited 退出体、进程没了容器还在的孤儿,都得有人收。
  • 这次的残留就卡在 Created(挂载 chown 瞬时失败,exit 128),说明"半成品"是真会发生的,不是纸上谈兵。

4. 失效模式四象限:哪些是"故障",哪个才是"事故"

失效模式触发残留本文示例
瞬时创建失败mount/chown/磁盘/守护进程抖动Created 空壳✅ 根因①(chown)
进程崩溃 / SIGKILLOOM、kill -9、重启脚本Running/Exited 孤儿重启遗留
固定命名 + 残留名字被占永久 exit 125 撞名✅ 根因②(真凶)
不自清 / 不对账上面任一 + 没有恢复手段永久卡死✅ "再试也没用"

前三种是"故障",第四种"没有恢复手段"才是"事故"。故障迟早会来,躲不掉;能不能自己恢复,才是分水岭。只要第四行不存在,前三行都只是一次性的小抖动,是它把这些抖动焊成了永久不可用。


第Ⅱ幕 · 最该改的一处设计

这套系统为一个根本不存在的好处,担了一个真实的风险。

5. 命名即身份:固定名 vs 唯一名

固定名 agent-sandbox-<tenant> 看着天经地义,它其实暗含两个承诺:一是好认,docker ps 一眼看出是谁的,这个做到了;二是重启后能接着用同一个容器,这个是空的。

翻一下复用逻辑就明白:所谓复用只靠进程内一个 map,进程一重启 map 就空了,代码根本不会去 docker start 那个还在的旧容器,而是直接重新 docker run。所以这个名字白担了撞名风险,复用的好处一点没拿到。

既然复用根本不存在,把名字改成带进程纪元的 agent-sandbox-<tenant>-<jvmEpoch> 就是白捡的可靠:每次进程都是新名字,永远撞不上,而且不丢任何功能(反正本来也不复用)。

起名之前不妨先问一句:这个名字让人以为能复用、能对上号,这些我真做到了吗?没做到的,就是负担,不是功能。


第Ⅲ幕 · 怎么修

思路就一句:能在设计上排除的问题,别留到运行时再补救。

6. 五条可靠性原则(搬到任何沙箱 / 资源编排都成立)

  1. 幂等create 多调几次,要么收敛到同一个能用的资源,要么干脆利落地失败,别留半成品。
  2. 失败自清:谁建的,失败时谁负责把自己的残留清掉(配唯一名,清起来不会误伤)。
  3. 启动回收:进程起来先扫一遍,把上一回漏掉的孤儿收走。
  4. 多留几道:唯一名防撞、失败自清即时清、启动扫兜底,别指望哪一道百分百管用。
  5. 老实降级:能退就退(比如本地沙箱),不能退就明确报错,别闷头卡死不吭声。

7. 方案权衡:为什么选"唯一名"而非"对账接管"

方案怎么做优点致命问题
唯一名名字带 <jvmEpoch>,每进程全新撞名直接消失;零回退;改动最小旧容器靠启动扫回收(可接受)
Reconcile-adoptinspect 旧容器,running 就接管看着聪明、能复用跨进程误接管:两进程进同一容器,进程内锁管不住,导致数据损坏;还要比镜像、处理 uid/工作区漂移、inspect↔run 的时间窗
A+B(撞了再清再重试)catch exit 125rm → retry改动局部反应式、靠错误文案字符串匹配、分不清死活可能误杀活容器

看着最聪明的 adopt 其实最危险:它把"撞名"这种吵闹好查的错,换成了"两个进程共用一个容器"这种安静的数据损坏。所以宁可在设计上把问题排掉,也别靠运行时耍机灵。

8. 横向对照:业界怎么解

方案机制对应本文哪条适用 / 局限
Testcontainers Ryuksidecar reaper,TCP 心跳感知父进程死亡后清容器启动回收,生产级长驻场景的成熟答案
Kubernetes reconcile期望态 vs 实际态对账循环对账思想的源头重,但思想可借
docker run --rm退出即清失败自清的极简版适合短命;对 sleep infinity 长驻 + Created 卡死无效
Podman --replace同名直接替换优雅地消灭撞名Docker 没有(踩坑点)
E2B / Firecracker microVM更强隔离 + 更快冷启换路线绕开问题per-call 短命,绕开长驻容器的生命周期负担

案例 MVP 就选了"唯一名 + 启动扫",复杂度最低,先拿到九成收益;reaper、对账留给以后上生产再说。


第Ⅳ幕 · 第二层问题 + 沉淀

把视线从基础设施抬回模型,再收成一张清单。

9. 第二层问题:工具错误的忠实可观测性

同一个根因还牵出另一类纯模型侧的问题:基础设施挂了,却被包成一个看不出所以然的 TOOL_FAILED 丢给模型。模型分不清是"环境坏了"还是"这工具被禁了",干脆自己编个理由,还顺手替它找补(就是开头那张截图)。

像样的做法是给错误分个类:是基础设施暂时不行、还是这个能力本就没有、还是业务错误,并带上能不能重试。基础设施类的,要么引擎自己重试,要么老实告诉模型"环境暂时不可用,待会儿再试",别让它自由发挥。

说到底,工具返回值就是模型的眼睛和耳朵。手脚坏了它会停下来,感官坏了它会信心十足地做错,后者更难发现。

10. 可复用检查清单(沉淀为团队心法)

  • 每个外部资源(容器/进程/临时文件/锁)都有明确的创建、失败清理、孤儿回收三条路径?
  • 资源命名的身份语义被真正兑现了吗?(没复用就别用固定名)
  • 失败路径会不会留半成品?谁负责清?清理有歧义吗?
  • 进程崩溃 / SIGKILL 后,下次启动能自愈吗,还是要人工 rm
  • 基础设施错误回给上游(模型/调用方)时,是不透明黑盒还是可分类 + 可重试?
  • 隔离边界(PID/FS/资源)和你声称的粒度(per-tenant? per-conv?)一致吗?

11. 参考

  • 业界:Testcontainers Ryuk、Kubernetes Reconciliation Loop、Docker bind-mount on Docker Desktop(macOS)、E2B / Firecracker、Podman --replace
  • 关联问题域:弱模型 tool-calling(约束解码 / tool_choice / FC 微调 / 解析重试),与本文不是一回事,别混
  • 案例原始证据(私有):源项目 issues #18(沙箱永久卡死)/ #19(工具错误忠实回传)+ 修复计划

接着写的话:给团队看的工作文档,重点放第Ⅰ–Ⅲ幕的取舍;个人博客,重点放开头的截图、第Ⅱ幕那处设计错误、和 §9 的感官比喻。骨架已经搭好,扩写时把这个事故落到每一幕里就行。

评论