Agent 沙箱运行时的生命周期可靠性
先说结论。
docker run看着是一条命令,其实是两步:先建出容器,再把里面的进程拉起来。中间哪一步挂了,都会剩一个没跑起来的空壳。这种残留没法完全避免,所以系统稳不稳,不看它会不会出故障,而看出故障后能不能自己恢复。这套沙箱恰恰没有任何恢复手段,于是一次几毫秒的挂载权限抖动,就把整组文件工具卡成了永久不可用。更冤的是那个固定容器名。它本想换来"重启后接着用同一个容器",可代码根本没做这件事,复用的好处一点没拿到,撞名的风险却全担了。
下面用一个真实事故(自建的 per-tenant Docker 沙箱)把这条线讲透。本文是文章骨架,可以再扩写成工作文档或博客。
序幕 · 钻到核心
一张截图加五个为什么,先把核心挖出来。
0. 现象:一次"工具执行失败"如何让 agent 开始说谎
AI 聊天里 5 个文件工具(Read/Write/Edit/Grep/Glob)突然全报"工具执行失败",普通业务工具却正常。用户说"你再试试,系统已更新",模型却一本正经地回:"这组文件工具在当前环境被限制了,无法使用",还贴心地编出"复制粘贴 / 改建工单"的替代方案。
先把这张"模型自己编替代方案"的截图摆最前面。这里最吓人的不是工具坏了,而是工具坏了模型却不知道,还一本正经替它找补。截图背后是两层问题:工具为什么坏(infra),和模型为什么说谎(agent 可观测性)。
(截图、抛栈与逐行根因来自源项目私有问题清单 issues #18/#19,本文只保留可公开的技术拆解。)
1. 五个为什么:60 秒钻到核心
别急着分类、别急着上方案,先纵向钻到底。一个"工具执行失败",问五层就见底:
- 5 个文件工具为什么全 fail? → 它们都跑在同一台沙箱容器里,容器没起来。
- 容器为什么没起来? →
docker run --name agent-sandbox-<tenant>撞名,exit 125: name already in use。 - 名字为什么被占? → 上一次留下一个卡在
Created的残骸容器,一直占着这个名字。 - 残骸为什么没人清、重试也没用? → 沙箱管理器没有任何恢复手段:
create抛异常后既不缓存、也不删旧重建,一次失败就永久卡死,直到人工docker rm。 - 为什么会同时有"固定名 + 不自清"这对组合? → 当初赌固定名
…-<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) |
| 进程崩溃 / SIGKILL | OOM、kill -9、重启脚本 | Running/Exited 孤儿 | 重启遗留 |
| 固定命名 + 残留 | 名字被占 | 永久 exit 125 撞名 | ✅ 根因②(真凶) |
| 不自清 / 不对账 | 上面任一 + 没有恢复手段 | 永久卡死 | ✅ "再试也没用" |
前三种是"故障",第四种"没有恢复手段"才是"事故"。故障迟早会来,躲不掉;能不能自己恢复,才是分水岭。只要第四行不存在,前三行都只是一次性的小抖动,是它把这些抖动焊成了永久不可用。
第Ⅱ幕 · 最该改的一处设计
这套系统为一个根本不存在的好处,担了一个真实的风险。
5. 命名即身份:固定名 vs 唯一名
固定名 agent-sandbox-<tenant> 看着天经地义,它其实暗含两个承诺:一是好认,docker ps 一眼看出是谁的,这个做到了;二是重启后能接着用同一个容器,这个是空的。
翻一下复用逻辑就明白:所谓复用只靠进程内一个 map,进程一重启 map 就空了,代码根本不会去 docker start 那个还在的旧容器,而是直接重新 docker run。所以这个名字白担了撞名风险,复用的好处一点没拿到。
既然复用根本不存在,把名字改成带进程纪元的 agent-sandbox-<tenant>-<jvmEpoch> 就是白捡的可靠:每次进程都是新名字,永远撞不上,而且不丢任何功能(反正本来也不复用)。
起名之前不妨先问一句:这个名字让人以为能复用、能对上号,这些我真做到了吗?没做到的,就是负担,不是功能。
第Ⅲ幕 · 怎么修
思路就一句:能在设计上排除的问题,别留到运行时再补救。
6. 五条可靠性原则(搬到任何沙箱 / 资源编排都成立)
- 幂等:
create多调几次,要么收敛到同一个能用的资源,要么干脆利落地失败,别留半成品。 - 失败自清:谁建的,失败时谁负责把自己的残留清掉(配唯一名,清起来不会误伤)。
- 启动回收:进程起来先扫一遍,把上一回漏掉的孤儿收走。
- 多留几道:唯一名防撞、失败自清即时清、启动扫兜底,别指望哪一道百分百管用。
- 老实降级:能退就退(比如本地沙箱),不能退就明确报错,别闷头卡死不吭声。
7. 方案权衡:为什么选"唯一名"而非"对账接管"
| 方案 | 怎么做 | 优点 | 致命问题 |
|---|---|---|---|
| 唯一名 ✅ | 名字带 <jvmEpoch>,每进程全新 | 撞名直接消失;零回退;改动最小 | 旧容器靠启动扫回收(可接受) |
| Reconcile-adopt | inspect 旧容器,running 就接管 | 看着聪明、能复用 | 跨进程误接管:两进程进同一容器,进程内锁管不住,导致数据损坏;还要比镜像、处理 uid/工作区漂移、inspect↔run 的时间窗 |
| A+B(撞了再清再重试) | catch exit 125 → rm → retry | 改动局部 | 反应式、靠错误文案字符串匹配、分不清死活可能误杀活容器 |
看着最聪明的 adopt 其实最危险:它把"撞名"这种吵闹好查的错,换成了"两个进程共用一个容器"这种安静的数据损坏。所以宁可在设计上把问题排掉,也别靠运行时耍机灵。
8. 横向对照:业界怎么解
| 方案 | 机制 | 对应本文哪条 | 适用 / 局限 |
|---|---|---|---|
| Testcontainers Ryuk | sidecar 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 的感官比喻。骨架已经搭好,扩写时把这个事故落到每一幕里就行。