Agent 跑完了,机器没回到原点——AI Agent 时代的沙箱基础设施

Agent 跑完了,机器没回到原点------AI Agent 时代的沙箱基础设施

本文基于 agent-infra-book(Agent 基础设施基金会)和 cloudflare/computer 两个开源项目的研究,系统讨论 AI Agent 时代沙箱面临的新难题及其技术解法。全文围绕一个全栈应用开发场景展开。


本文地图

写之前先把整篇文章的骨架摊开,让你在进入细节之前就有一个整体认知。下面的结构遵循金字塔原理:塔尖是一个核心论点,往下逐层展开为支撑论据和具体技术手段。读任何一个章节时,你都能知道它在整个金字塔中的位置。

lua 复制代码
                          ┌─────────────────────────────────┐
                          │                                 │
                          │   Agent 报告"做完了"(exit 0),  │
                          │    但机器没有回到原点------           │
                          │  沙箱需要从"隔离容器"进化为        │
                          │   "可追踪的完整工作流契约"         │
                          │                                 │
                          └───────────────┬─────────────────┘
                                          │
                    ┌─────────────────────┼─────────────────────┐
                    │                     │                     │
              ┌─────▼─────┐         ┌─────▼─────┐         ┌─────▼─────┐
              │ 沙箱不是   │         │ 三个核心  │         │  工程实现  │
              │ 一个东西,  │         │  难题需要 │         │  证明可行  │
              │ 而是一个谱系│         │  组合解法  │         │            │
              └─────┬─────┘         └─────┬─────┘         └─────┬─────┘
                    │                     │                     │
          ┌─────────┼─────────┐   ┌───────┼───────┐      ┌──────┼──────┐
          │         │         │   │       │       │      │      │      │
        ┌─▼─┐    ┌──▼──┐   ┌──▼──┐ │     │     │    ┌──▼──┐ ┌─▼─┐ ┌──▼──┐
        │权限│    │私有  │   │容器/│ │副作用│并发  │生命 │Overlay│CAS│Cloud-│
        │策略│    │工作  │   │micro│ │追踪  │隔离  │周期  │FS原理 │去重│flare │
        │    │    │目录  │   │ VM  │ │      │      │管理  │       │   │Computer│
        └────┘    └─────┘   └─────┘ └──┬───┴──┬──┴──┬──┘ └──┬───┘ └───┘ └──┬───┘
                                     │      │     │         │              │
                                   会话ID  upper-│ 6步     透明胶片       DO SQLite
                                   = join  dir   │ 清理    + lower/       + FUSE
                                   key    +cgroup│         upper分层      + 同步
                                         +publish│                        协议
                                         gate    │
                                                  │
                                          ┌───────▼────────┐
                                          │ 核心设计原则:   │
                                          │ "只有当运行时能  │
                                          │ 说明一次操作创建 │
                                          │ 的机器状态时,   │
                                          │ 这项操作才算结束"│
                                          └────────────────┘

从上往下读:

塔尖(核心论点)------Agent 说"做完了"但机器上还残留着进程、端口、内存占用。问题的根源不是隔离不够强,而是运行时缺少"工作空间会话"这个概念------一个能把所有副作用归属到同一次有边界任务上的连接键。

第二层(三个支柱)------支撑核心论点的三条线索:沙箱是一个谱系不是单个产品(第一部分),Agent 场景有三个传统沙箱解决不了的新难题(第二部分),Cloudflare 的工程实现证明这套设计可行(第四部分)。

第三层(具体展开)------谱系的四个层次(权限策略→私有工作目录→容器→microVM),三个难题各自的技术解法(副作用追踪用 session ID 做 join key、并发隔离用 OverlayFS + cgroup + publish gate、生命周期管理用 6 步结构化清理),OverlayFS 的核心原理(透明胶片分层),CAS 去重的工程价值,以及 Cloudflare Computer 的 DO SQLite + FUSE + 同步协议架构。

塔基(一句话原则)------贯穿全文的设计哲学:只有当运行时能够说明一次智能体操作所创建的机器状态时,这项操作才算结束。

全文围绕一个场景展开:三个 Agent 同时在一个全栈 TypeScript monorepo 上工作。Agent A 给商品详情页加用户评论功能(跨后端 API、数据库、前端组件),Agent B 修购物车税计算 bug,Agent C 给订单接口加 CSV 导出。项目结构如下:

bash 复制代码
shop/
├── package.json              # 公共依赖,三个 Agent 都可能改
├── docker-compose.yml        # Postgres + Redis
├── prisma/schema.prisma      # 数据库 schema
├── src/
│   ├── routes/
│   │   ├── products.ts       # Agent A 不直接改,但测试会加载
│   │   ├── cart.ts           # Agent B 的领域
│   │   ├── orders.ts         # Agent C 的领域
│   │   └── index.ts          # 路由注册,Agent A 要改
│   └── services/
│       ├── cartService.ts    # Agent B 修 bug 的目标
│       └── orderService.ts
├── app/                      # Next.js 前端
│   ├── pages/product/[id].tsx  # Agent A 要改
│   └── components/             # Agent A 新建 Reviews.tsx
├── shared/
│   └── types.ts              # 前后端共享类型,Agent A 和 C 都要改
└── tests/

引言:退出码 0 的谎言

你对 Agent 说:"给商品详情页加一个用户评论功能。"

Agent 开始工作。它先修改 prisma/schema.prisma 加了 Review 模型,执行 npx prisma migrate dev 生成数据库迁移。然后改 package.json 加了 @prisma/client 依赖,执行 npm install。接着写评论路由、评论组件、共享类型定义,执行 npm test 跑测试。测试发现两个用例失败,它修了测试代码再跑一遍,全过了。最后执行 npm run dev 启动开发服务器验证效果,页面正常显示评论区。

arduino 复制代码
npx prisma migrate dev    # exit code 0 ✓
npm install                # exit code 0 ✓
npm test                   # exit code 0 ✓(第二次)
npm run dev                # exit code 0 ✓

Agent 报告:"评论功能已完成,所有测试通过。"

看起来万事大吉。但如果你在这一刻检查机器的真实状态,会发现:npm install 派生的 3 个子进程中,有 1 个没收住还在后台跑。npm test 启动的 Postgres 和 Redis 实例还在运行,占着 5432 和 6379 端口。npm run dev 的开发服务器还活着,占着 3000 端口。4 个进程在运行,3 个端口被占用,540MB 内存没释放。

进程的退出码是 0,机器却没有回到原点。

问题在下一个 Agent 启动时爆发。Agent B 要修购物车的税计算 bug,也需要启动开发服务器。它执行 npm run dev,尝试绑定 3000 端口:

perl 复制代码
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000

Agent B 不知道端口被谁占了,换了个端口继续。但它的测试代码里写死了 BASE_URL=http://localhost:3000,测试全挂。或者更隐蔽的情况:Agent B 的测试碰巧连上了 Agent A 遗留的 Postgres 实例,跑在一个充满评论测试数据的脏数据库上------测试报告"通过",但这个通过是假的。

这不是假设。这是 AI Agent 在真实开发环境中的日常。传统沙箱(Docker 容器)解决了隔离------Agent B 看不到 Agent A 的进程。但一个更根本的问题没有被回答:一次 Agent 操作在机器上到底创建了什么状态?哪些是工作成果应该保留,哪些是临时产物应该清理?

这个问题在 agent-infra-book(Agent 基础设施基金会的项目)中被系统性讨论。另一个项目 cloudflare/computer 给出了 Cloudflare 平台上的工程实现。本文基于对这两个项目的研究,讨论 Agent 时代沙箱的三个核心难题------副作用追踪、并发隔离、状态的生命周期管理------以及解决它们的技术手段。全文围绕上图中提到的三个 Agent 并行开发场景展开。


一、沙箱不是你以为的那个东西

在深入 Agent 的难题之前,先打破一个认知:"沙箱 = Docker"。

沙箱是一个谱系,不是一个东西。从弱到强大致是这几层:

权限策略是最轻的。Claude Code 执行 bash 命令前弹窗让你确认,这就算一层沙箱------它不隔离任何东西,只加了一道审批门。对可信的 Agent 够用,对不可信代码形同虚设。

私有工作目录隔离文件写操作。git worktree 在这一层------每个 Agent 一个独立工作目录,改的文件互不干扰。但进程、网络、内核仍然共享。Agent A 启动的 dev server 占的端口,Agent B 一样看得到。agent-infra-book 里有一句精到的总结:"工作树是一张私有书桌,不是一间上锁的房间。"

容器对文件系统、进程、网络做命名空间隔离,但共享宿主机内核。Docker 是典型代表。大多数人对沙箱的理解停在这一层。但 agent-infra-book 指出,容器"对配置范围内的文件系统、进程和网络状态做了隔离,却不说明项目谱系、变更集、溯源与发布策略"。它不知道哪个文件修改属于哪个 Agent 的哪次操作,不知道哪些变更已经准备好进入共享历史,不知道两个 Agent 是否在改同一个文件。

microVM 增加客户机内核边界,隔离最强。Firecracker(AWS Lambda 底层用的技术)在毫秒级启动一个轻量虚拟机。但 agent-infra-book 同样指出:"microVM 本身也没有说明如何比较或合并两个优秀补丁。"隔离强度不等于 Agent 基础设施完整性。

层次 隔离了什么 还共享什么 缺什么
权限策略 无(仅审批) 一切 不隔离任何东西
私有工作目录 文件写入 进程、网络、内核 进程冲突、端口冲突
容器(Docker) 文件+进程+网络 内核 变更归属、发布协调
microVM(Firecracker) 文件+进程+网络+内核 宿主机硬件 同上

关键认知是:这些机制"可以组合,却不能排成一条通用的强度刻度"。对协作型编码场景,工作目录隔离可能恰好解决了最重要的冲突------两个 Agent 不会互相踩到对方的半成品代码。当代码不可信时,才需要 microVM 级别的隔离。反过来,microVM 也不自带"哪个 Agent 改了哪个文件"的追踪能力------隔离得再强,里面跑的 npm install 一样会留下遗留进程。

agent-infra-book 给出的结论是:提到"沙箱"时,必须同时说明四件事------隔离单元是什么粒度的工作(一条命令?一个任务?一个 Agent?),边界隔离了什么(文件?进程?网络?内核?),状态如何存储和恢复,Agent 的私有工作如何变成共享事实。只说"我们用了容器"是没有意义的。

本文接下来讨论的三个难题,正是这四件事在 Agent 场景下的具体展开。


二、Agent 时代的三个新难题

难题一:副作用追踪------"pid=2105 是谁起的?"

Agent A 执行评论功能的开发任务。完整的操作链是 13 步:

步骤 操作 文件副作用 进程/端口/资源
1 新建 routes/reviews.ts upperdir 写入 ---
2 routes/index.ts 注册路由 upperdir 写入 ---
3 prisma/schema.prisma 加 Review 模型 upperdir 写入 ---
4 npx prisma migrate dev migration 文件写入 2 子进程连 Postgres,端口 5432,120MB
5 package.json@prisma/client upperdir 写入 ---
6 npm install node_modules 写入,lockfile 修改 3 子进程(1 个遗留),180MB
7 shared/types.ts 加 Review 接口 upperdir 写入 ---
8 新建 app/components/Reviews.tsx upperdir 写入 ---
9 app/pages/product/[id].tsx upperdir 写入 ---
10 npm test .coverage 写入 4 worker + 1 mock server,端口 9222,320MB
11 tests/reviews.test.ts(测试失败) upperdir 写入 ---
12 npm test(再跑,全过) --- 4 worker,端口 9222,280MB
13 npm run dev(验证效果) .next/ 编译缓存写入 dev server 持续运行,端口 3000,200MB

每一步操作都在机器上留下了状态。文件系统里有源代码修改、数据库迁移文件、node_modules 新装的依赖、.next 编译缓存、.coverage 覆盖率报告。进程层面有 npm install 遗留的下载子进程、Postgres 和 Redis 实例、Next.js dev server。网络层面有 5432、6379、9222、3000 四个端口被占用。资源层面峰值内存达到 540MB。

操作系统知道 PID 2105 拥有一个套接字,Git 显示 shared/types.ts 被修改。但两者都不会告诉你"这个套接字和这个文件修改属于 Agent A 的评论功能开发任务,基于修订 R42,目前仍未发布"。PID 太窄------一个数字不代表任务归属。Git diff 太晚------它只能告诉你在工作完成之后改了什么,不能告诉你"正在跑的 pid=2105 该不该被 kill"。

agent-infra-book 的解法是引入工作空间会话(Workspace Session)作为所有副作用的连接键(join key)。不是为了文件、进程、端口、资源各建一套独立的日志,而是让它们共享同一个身份标识。

Agent A 接到任务时,运行时创建会话 S17。接下来所有操作都挂在 S17 名下:文件修改写入 S17 的 OverlayFS upperdir(后面会讲),进程记在 S17 的 cgroup 进程组里,端口绑定记录 owner=S17,资源消耗按 S17 汇总。运维者问"谁占了 3000 端口",答案直接是 S17 / agent-a / pid=1035,而不是从一堆互不相关的机器指标里猜。

这比传统容器方案高明在哪?传统 CI/CD 和 Docker 知道容器 ID,但不知道"一次工具调用"的边界。一个容器里可能跑了 N 条 Agent 命令,它们的副作用全混在一起。你想问"npm test 第一次失败时改了哪些文件"------答不了,因为第二次 npm test 覆盖了第一次的痕迹。你想问"那个遗留的 pid=2105 是哪次操作产生的"------也答不了,进程列表不区分操作来源。

工作空间会话把粒度切到了"一次有边界的任务"。任务内的每步操作都可追溯,任务结束后整个副作用集合可以原子性地清理或发布。agent-infra-book 给出了一条规则来定义"什么时候算结束":只有当运行时能够说明一次智能体操作所创建的机器状态时,这项操作才算结束------不是文本响应结束时,不是进程退出时,而是机器状态被完整说明时。

难题二:并发隔离------"A 写到一半的代码让 C 的测试崩了"

Agent A 在写评论功能的同时,Agent B 在修购物车税计算 bug(只改 src/services/cartService.ts),Agent C 在给订单接口加 CSV 导出(改 src/routes/orders.ts、新建 src/services/exportService.ts、改 shared/types.ts 加导出格式枚举、改 package.jsonjson2csv 依赖)。三个 Agent 同时基于同一个共享基线 R42 开始工作。

如果没有沙箱,三个 Agent 直接在同一个 /project 目录里操作,会发生什么?

Agent A 在第 2 步修改了 routes/index.ts,注册了评论路由。但此时 routes/reviews.ts 虽然已经创建了,里面引用的 Review 类型(步骤 7 才加到 shared/types.ts)还没写。routes/index.ts 处于一个半成品状态------导入了 reviews 路由,但类型定义还不完整。

Agent C 这时正好在跑测试验证自己的 CSV 导出功能。测试框架加载了 routes/index.ts------加载的是 A 写到一半的版本。A 的代码引用了还不存在的 Review 类型,TypeScript 编译报错:

lua 复制代码
error TS2304: Cannot find name 'Review'.
  at routes/reviews.ts:15:12

Agent C 困惑了:"我的 CSV 导出功能跟评论类型有什么关系?" 它开始调试,浪费时间,最后可能误以为自己的代码有问题去改无关的东西。这就是 agent-infra-book 第 I 部分开场场景的翻版------Agent C 的测试报告"失败",但这份失败跟 C 的工作毫无关系。

更危险的是端口层面的干扰。Agent B 跑测试时启动了 mock server 绑定 3000 端口。Agent A 也在跑 npm run dev 需要端口 3000。A 的 dev server 启动失败。A 以为是上次自己跑测试没退出干净,kill 了一个看起来像 node 的 PID------结果把 B 的 mock server 杀了。B 的测试跑到一半挂在网络超时上。

agent-infra-book 的解法用 OverlayFS 做文件层面的物理隔离。每个 Agent 有自己的 upperdir(可写私有层),共享同一个 lowerdir(只读基线 R42)。Agent A 在 S17 的 upperdir 里改了 routes/index.ts,这个修改只在 S17 的视图里可见。Agent C 读 routes/index.ts 时走 OverlayFS fall-through------upperdir 里没有,穿透到 lowerdir 返回 R42 的原始版本。A 写到一半的代码对 C 完全不可见。

三个 Agent 看到的项目视图是这样的:

bash 复制代码
Agent A 的 /workspace:               Agent B 的 /workspace:
  routes/reviews.ts    ← A 新建        routes/reviews.ts    ← 原始(R42)
  routes/index.ts      ← A 修改        routes/index.ts      ← 原始(R42)
  shared/types.ts      ← A 修改        shared/types.ts      ← 原始(R42)
  services/cartService.ts ← 原始      services/cartService.ts ← B 修改
  routes/orders.ts     ← 原始          routes/orders.ts     ← 原始

Agent C 的 /workspace:
  routes/reviews.ts    ← 原始(R42)     ← A 建了什么?不知道
  routes/index.ts      ← 原始(R42)
  shared/types.ts      ← C 修改        ← 加了 ExportFormat 枚举
  services/exportService.ts ← C 新建
  package.json         ← C 修改        ← 加了 json2csv

进程和端口层面,每个 Agent 的命令在自己的 cgroup 进程组和(可选的)network namespace 里。Agent B 的 mock server 绑了 3000 端口,端口注册表记录 port 3000 → session=S18。Agent A 也想绑 3000,运行时检测到冲突------要么拒绝并告知 A"端口被 S18 占用",要么给 A 分配独立的网络命名空间,A 在自己的 namespace 里绑 3000,跟 B 的 3000 物理隔离。

三个 Agent 各自做完了,进入发布阶段。发布采用三方合并模型:base 是会话租用的基线(R42),ours 是会话的私有变更,theirs 是当前共享历史的最新状态。冲突检测逐路径进行------如果某个文件在 base→theirs 之间被其他会话修改过,且当前会话也修改了它,则冲突。

Agent A 先发布。upperdir 里有 8 个源代码文件变更。检查冲突------从 R42 到当前 head(还是 R42,没人先发过)之间,没有其他会话改过这些文件。通过。创建 R43。

Agent B 再发。改了 cartService.tscart.test.ts。R42 到 R43 之间有人改过这两个文件吗?没有(A 改的是 reviews 相关文件)。通过。创建 R44。

Agent C 发布。改了 orders.tsexportService.tsshared/types.tspackage.json。检查:R42 到 R44 之间,shared/types.ts 在 R43 被 S17(Agent A)修改过,package.json 也在 R43 被 S17 修改过。两个文件冲突。

vbnet 复制代码
❌ S19 publish CONFLICT on: shared/types.ts, package.json
   S19 based on R42, but these files were modified in R43 (by S17)

关键设计决策是 all-or-reject:即使 4 个文件只有 2 个冲突,也拒绝整个变更集。agent-infra-book 的理由是"只发布不冲突的两个文件,会产生一份 Agent 从未作为整体验证过的结果"------orders.ts 里的导出逻辑可能依赖 shared/types.ts 里的 ExportFormat 类型,分开发布会导致类型断裂。

冲突不是异常,而是正常控制流。Agent C 的会话不会被销毁,私有工作还在。C 可以丢弃旧会话,基于 R44 创建新会话,在最新的基线上重新应用自己的变更,然后发布。这就是 rebase 的语义------不需要从日志中重建发生了什么,运行时已经保留了完整的变更集和基线信息。

这里有一个诚实的局限需要指出。文本 diff 没有冲突不代表语义兼容。假设 Agent A 在 shared/types.ts 里加了一个 Review 接口,同时重命名了已有的 Product 接口为 ProductInfo。Agent C 的 exportService.ts 还在用 Product 这个名字。git diff 会显示 A 和 C 改了 types.ts 的不同区域,没有文本冲突。但合并后的代码编译不过------Product 已经不存在了。agent-infra-book 的一句话很到位:"一次干净合并完全可以生成一个干净利落的坏程序。" 这不是文件级别的并发隔离能解决的问题,需要系统级集成验证------在发布前用合并后的候选状态跑一次编译和测试。

难题三:生命周期管理------"任务完成了,然后呢?"

回到 Agent A。13 步操作全部完成,评论功能开发好了,测试通过了。此时 S17 的完整生命快照:

ini 复制代码
S17 (agent-a, base=R42)
├── state: ACTIVE
├── 文件变更 (upperdir):
│    ├── routes/reviews.ts           [新建] 评论路由
│    ├── routes/index.ts             [修改] 注册评论路由
│    ├── prisma/schema.prisma        [修改] 加 Review 模型
│    ├── prisma/migrations/          [新建] 迁移文件
│    ├── package.json                [修改] 加 @prisma/client
│    ├── package-lock.json           [修改] 锁文件更新
│    ├── shared/types.ts             [修改] 加 Review 接口
│    ├── app/components/Reviews.tsx  [新建] 评论组件
│    ├── app/pages/product/[id].tsx  [修改] 集成评论
│    ├── tests/reviews.test.ts       [修改] 评论测试
│    ├── node_modules/@prisma/       [新建] 新装依赖,~200 个文件
│    ├── .next/                      [新建] 编译缓存
│    └── .coverage                   [新建] 覆盖率报告
│
├── 进程状态:
│    ├── pid=2105  npm install 子进程     ⚠️ RUNNING(遗留)
│    ├── pid=2110  Postgres              ⚠️ RUNNING
│    ├── pid=2112  Redis                 ⚠️ RUNNING
│    └── pid=2116  Next.js dev server    ⚠️ RUNNING
│
├── 端口占用: 5432, 6379, 9222, 3000
├── 资源消耗: 内存 540MB, CPU 12%
└── 发布状态: 未尝试

"任务完成"了吗?从 Agent A 的角度,代码改好了,测试通过了。但从机器状态看,4 个进程还在跑,4 个端口还在占,540MB 内存没释放。

如果 S17 不被清理,前面描述的灾难就会发生:Agent B 端口冲突、假阳性测试通过、OOM。agent-infra-book 的设计是三层生命周期模型:

yaml 复制代码
Sandbox(运行时生命周期)
  ├── LayerStack(持久项目生命周期)
  │     R42 → R43 → R44 → ...    永久的,不随单个任务消失
  │
  ├── Workspace S17(有界任务生命周期)
  │     ├── Command: prisma migrate    进程级,跑完就结束
  │     ├── Command: npm install       进程级,跑完就结束
  │     ├── Command: npm test (失败)   进程级,跑完就结束
  │     ├── Command: 改代码             文件操作,不是进程
  │     └── Command: npm test (通过)   进程级,跑完就结束
  │
  └── Workspace S18(另一个有界任务)

核心原则:每项操作只结束自己真正拥有的生命周期。一条命令的进程退出,不代表工作空间会话的任务完成------S17 还有后续操作。S17 发布了,不代表 LayerStack 的 R42 被删除------R42 是共享历史,可能还有别的会话在引用。S17 被清理了,S18 不受影响------它们是独立的任务。

清理 S17 时有一个关键的顺序问题。正确的步骤是:

第一步冻结。通过 cgroup freezer 冻结 S17 的所有进程。这防止进程在后续清理过程中继续产生文件写入、网络请求或派生子进程。不先冻结直接 kill,进程可能在收到信号后还有时间做最后一次 write。

第二步终止进程组。killpg 先发 SIGTERM 给优雅退出的机会,等待几百毫秒后发 SIGKILL 强制终止。cgroup v2 还提供了 cgroup.kill 文件------往这个文件写 1 会递归终止 cgroup 内所有进程,是原子操作。4 个 running 进程全部被终止。

第三步释放网络资源。释放端口绑定,关闭 socket,销毁 network namespace。

第四步卸载文件系统。umount OverlayFS 挂载点。

第五步是最关键的决策------区分代码变更和临时产物。upperdir 里的 13 个文件变更分两类:

要发布的(Agent 的工作成果):routes/reviews.tsroutes/index.tsprisma/schema.prismaprisma/migrations/package.jsonpackage-lock.jsonshared/types.tsapp/components/Reviews.tsxapp/pages/product/[id].tsxtests/reviews.test.ts

不发布的(执行产生的临时状态):node_modules/@prisma/(依赖安装产物)、.next/(编译缓存)、.coverage(覆盖率报告)。

如果把 node_modules.next 一起发布到共享历史,下一个 Agent 从 R43 开始工作时,自己的工作空间里会莫名其妙出现别人的 node_modules------一份可能跟自己平台不兼容的依赖。

第六步释放 cgroup。从 cgroup hierarchy 中移除 S17 的目录。

清理完成后,只有源代码变更通过三方合并进入 R43。upperdir 被删除,S17 状态变为 MERGED。进程清零,端口释放,内存回收。机器回到干净状态,Agent B 和 C 可以安全工作。


三、OverlayFS------不复制数据的隔离

前面反复提到 OverlayFS,这里展开讲它的原理。它不是一个 API 或一个库,而是 Linux 内核提供的一个文件系统驱动。使用方式就是一条 mount 命令:

bash 复制代码
mount -t overlay overlay \
  -o lowerdir=/base,upperdir=/sessions/S17/upper,workdir=/sessions/S17/work \
  /workspace

执行后,/workspace 就出现了,看起来跟 /base 一模一样。你在里面改文件,改的内容写到 /sessions/S17/upper/base 纹丝不动。

原理可以想象成一组透明胶片叠在一起。最底层是只读的基底内容(lowerdir),上面是可写的私有层(upperdir)。你从上往下看时,上面有的内容遮住下面;上面没有的就透过去看下面。你在上面写字时,只会写最上面那层,下面的层完全不动。

读操作先查 upperdir,找到了直接返回------这叫 first-match-wins。没找到就穿透(fall-through)到 lowerdir。写操作自动进入 upperdir,不碰 lowerdir。删除操作在 upperdir 里创建一个 whiteout 标记(一个特殊文件),表示"这个路径被遮挡了",读的时候看到 whiteout 就知道文件"被删了",不会再穿透到下层。

OverlayFS 在 Agent 场景下的核心优势不是"隔离"这么简单------Docker 也能隔离。它的优势是三个:

零成本复用。 10 个 Agent 基于 500MB 的项目工作,lowerdir 只有一份,10 个 upperdir 各自只存私有变更。总共占 500MB + 10 × 10MB = 600MB。如果是 cp -r 复制,要 10 × 500MB = 5GB。

变更集物理分离。 所有私有变更(新建、修改、删除 whiteout)全部在 upperdir 里。不需要 git diff 或文件系统快照对比来算"这个 Agent 改了什么"------直接读 upperdir 扫一遍就是变更集。agent-infra-book 的发布阶段(capture)就是这么做的:读 upperdir,拿到变更集,做三方合并。比从混合状态中算 diff 更可靠------Git 可能因为 .gitignore 规则忽略某些文件,OverlayFS 不会。

删除天然可追踪。 Whiteout 是文件系统级别的标记,不依赖任何应用层 diff 工具。Agent 删了 requirements.txt,upperdir 里有 whiteout 节点,capture 时直接读取得知删除意图。

三个 Agent 并发时的叠加结构:

ini 复制代码
共享基线 R42 (lowerdir)
  │
  ├── Agent A: lowerdir = R42 的 layers    upperdir = sessions/S17/upper
  ├── Agent B: lowerdir = R42 的 layers    upperdir = sessions/S18/upper
  └── Agent C: lowerdir = R42 的 layers    upperdir = sessions/S19/upper

三个 lowerdir 引用的是同一份物理数据。lease 机制保证 Agent A 基于的 R42 不会被垃圾回收------即使 B 和 C 已经发布了 R43、R44,A 脚下的基线仍然稳定。这就像在图书馆借了一本书的某一版,新版本可以继续入库,但不会替换你正在阅读的那一版。

OverlayFS 只是文件系统隔离这一层。完整的 Agent 沙箱还需要进程隔离(PID namespace + cgroup)、网络隔离(network namespace + 端口注册表)、资源限制(cgroup v2 的 memory/cpu/io 控制器)。OverlayFS 的价值是让这些机制拥有一个干净的文件系统基础------Agent 的所有文件变更天然可追踪、天然隔离、天然可捕获。


四、Cloudflare Computer------工程实现的真实样子

前面讲的是设计理念。cloudflare/computer 仓库是这些理念在 Cloudflare 平台上的工程实现。它不处理多 Agent 并发冲突(那是 agent-infra-book 中 Ephemeral Sandbox 的领域),但它在回答另一个问题:如何让 Agent 有一个持久的、可执行的工作空间。

核心架构用一句话概括:Durable Object 的 SQLite 是权威存储,通过 FUSE 挂载到沙箱容器里变成 /workspace 真实目录。Agent 在容器里操作文件就像在普通 Linux 目录操作,但所有变更通过同步协议回到 DO 持久化。

用我们贯穿全文的评论功能场景来解释。如果这个项目跑在 Cloudflare Computer 上,数据流是这样的:

Agent 在 Durable Object 侧调用 workspace.fs.writeFile("/workspace/src/routes/reviews.ts", code) 写评论路由文件。这个写操作进入 DO 的 SQLite VFS------文件按 512KiB 分块,每块算 SHA-256 哈希存到 vfs_blobs 表,相同内容的块只存一份。然后同步协议把变更推送到容器(push)。容器里 computerd 守护进程通过 FUSE 挂载了同一个 VFS,/workspace/src/routes/reviews.ts 立即可读。

Agent 接着执行 npm test。这条命令跑在容器里------完整的 Linux 环境,真实的 Node.js 二进制。测试框架读 /workspace/src/routes/reviews.ts,读到的就是刚才写的文件。测试通过后,prisma migrate 在容器里生成 migration 文件,写到 /workspace/prisma/migrations/。FUSE 拦截这个写操作,标记为 dirty。exec 结束后,DO 批量拉取(pull)容器的变更到 SQLite 持久化。

一条数据管道贯穿宿主(DO)和容器两侧,文件不需要手动传输。agent-infra-book 把这个设计原则总结为:"状态有持久地址,执行环境可丢弃。" SQLite 是持久的状态家,容器是可以随时销毁的执行环境。容器死了?下次连接时从 DO 做 rev-0 基线重建。WebSocket 断了?同步协议的水位线在 SQLite 里,重连后从水位线继续,apply 是幂等的,重复部分自动丢弃。

存储层的设计和 agent-infra-book 的 CAS(内容寻址存储)理念一致。@cloudflare/dofs 把文件按 512KiB 分块存到 SQLite 的 vfs_blobs 表,每块按内容哈希索引。三个 Agent 都跑了 npm install,都装了 TypeScript 作为间接依赖。三个 upperdir 里各有 node_modules/typescript/ 的副本,但底层 CAS 存储里 TypeScript 的数据块只存了一份------内容哈希相同就复用。这就是 CAS 去重的工程实现。

同步协议是增量双向的。DO→容器叫 push,每个 DO 侧的写操作都带递增的 revision 号,容器只获取它还没看到的 revision。同一路径多次修改会被合并为一条 entry------Agent A 对 routes/index.ts 写了三次,push 时只发最终状态一条。容器→DO 叫 pull,FUSE 写操作被标记为 dirty,exec 执行完后 DO 批量拉取。协议传的是最终状态而非操作日志------不需要重放操作顺序,apply 是幂等的,中途断了重连只需要从上次的水位线继续。

性能数据值得一看。在 Cloudflare Containers standard-2 实例(1 vCPU, 6GiB 内存)上的测试结果:

操作 computerd FUSE tmpfs ext4 磁盘 vs 磁盘
stat 1000 files 1972ms 1324ms 2659ms 快 9%
rm 1000 files 828ms 323ms 1282ms 快 34%
mkdir tree (10×10×10) 1598ms 1586ms 3035ms 快 26%
git init + commit 100 files 459ms 40ms 635ms 快 28%
write 64 MiB 231ms 47ms 17ms 慢 13.6 倍
copy 64 MiB 1037ms 37ms 40ms 慢 26 倍
npm install (854 包) 124.7s 34.3s 63.9s 慢 2 倍

元数据操作(stat、rm、mkdir、git init)比真实磁盘还快------inode 存在内存里,不用寻道。但大文件顺序 I/O 慢得多,因为每个 512KiB 块都要算哈希进 CAS 存储。这是内容寻址去重的工程代价:你在写入时付出哈希计算和分块的开销,换来的是后续同步时只传变更块、相同内容自动去重的收益。对 Agent 的工作负载(大量小文件读写、少量大文件操作)来说,这个 trade-off 是划算的------npm init + tiny install 的耗时甚至比磁盘还快 5%。

Cloudflare Computer 目前是 1:1 模型------一个 Durable Object 对应一个容器。它不处理多 Agent 并发冲突检测。但它的 FUSE VFS、CAS 存储、增量同步协议、生命周期管理(DO 重启后从 SQLite 重建容器状态),都是 agent-infra-book 设计理念的工程映射。两个项目恰好互补:agent-infra-book 的 Ephemeral Sandbox 解决"多个 Agent 如何并行工作",Cloudflare Computer 解决"单个 Agent 如何有持久可执行的工作空间"。


结尾:沙箱是契约,不是产品

回到开头的问题。Agent 说"做完了",退出码是 0。但机器上还有 4 个进程、4 个端口、540MB 内存。下一个 Agent 在这种环境上工作,测试通过了------但这个通过是假的。

这个问题的根源不是"隔离不够强"。给你一个 Firecracker microVM,隔离够强了,但 npm install 的遗留进程一样会在 VM 内部捣乱。问题的根源是运行时缺少一个概念:工作空间会话------一个能把文件变更、进程、端口、资源消耗全部归属到同一次有边界的任务上的连接键。

agent-infra-book 给出的方案是一组需要组合的机制:OverlayFS 做 COW 文件隔离,cgroup + namespace 做进程/网络/资源隔离,workspace session 做副作用归属的 join key,lease 做稳定基线,publish boundary + all-or-reject 做安全集成,结构化的 cleanup 做生命周期收尾。每一层解决一部分问题,组合起来才把"并发上限"从内核能跑多少进程提升到运行时能维持多少可验证的并行编码任务。Cloudflare Computer 则从工程侧证明,FUSE + SQLite VFS + CAS 存储 + 增量同步这套技术栈是可实现的,性能在 Agent 工作负载下可接受。

隔离只是契约的起点,不是终点。真正有价值的是围绕沙箱构建的"从基线到发布"的完整工作流------副作用可追踪、并发不互相踩、生命周期可管理、变更可归属和审计。

agent-infra-book 里有一句话,可能是整个领域最重要的设计原则:

只有当运行时能够说明一次智能体操作所创建的机器状态时,这项操作才算结束。

不是文本响应结束时,不是进程退出时,而是机器状态被完整说明时。Agent 时代的基础设施,围绕这句话展开。


参考项目:

  • agent-infra-book --- Agent 基础设施基金会的设计文档和 benchmark
  • cloudflare/computer --- Cloudflare 的虚拟文件系统和沙箱执行后端
  • 文中 demo 代码:agent-infra-book/demo/workspace_runtime.py --- 60 行 Python 实现的最小沙箱,可跑 11 个场景演示所有核心概念