第6章 沙盒与工作树:隔离的执行环境

本章金句

  • 隔离的代价是值得的,因为不隔离的代价是灾难。
  • 没有沙盒的 Agent 是一匹没拴绳的野马,跑得越快越危险。
  • worktree 不是 git 的一个命令,它是 Loop 工程把"分支"变成"工作台"的那次升华。
  • 失败的 Agent 不可怕,可怕的是失败的 Agent 还把别人成功的成果一起带走。
  • 沙盒不是关押 AI 的牢笼,是让 AI 敢于尝试的舞台。
  • 隔离的层级,决定并发的上限。

引子:一个事故

凌晨 2 点 14 分,值班工程师小林被电话叫醒。CI 仪表盘上一片血红,主线分支构建失败,已经被 17 个 PR 抢着推过,每一次都把上一次的失败更深地埋进合并历史。生产环境部署被卡住,运营同事在群里 @ 他:电商首页 banner 推迟 3 小时上线,每拖一分钟都是六位数的 GMV 损失。

小林打开构建日志,看到的不是常见的编译错误,而是一段诡异的代码:

def get_user_profile(uid):
    user = db.query("SELECT * FROM users WHERE id = " + str(uid))
    user = db.query("SELECT * FROM users WHERE id = " + str(uid))
    return user

同一行被复制了两遍。这不是手滑,这是合并冲突被"自动解决"留下的痕迹。仓库里有两条分支,分别在凌晨 1 点 47 分和 1 点 52 分被两个 Agent 同时启动,目标都是给 get_user_profile 加上日志埋点。Agent A 在第 23 行插入了 logger.info("query user", uid)。Agent B 在同一个函数的第 24 行插入了 logger.debug("query user", uid)。两个 Agent 各自跑测试,各自绿了,各自开了 PR。CI 的自动合并策略是 squash and merge,结果到了主线,函数体里出现了一段谁也没写过的代码——重复的 SQL 查询。

这不是 Agent 写错了,这是两个 Agent 同时改了同一个文件的同一片段,git 的三路合并算法在没有语义理解的情况下,机械地把两段文本拼在一起,然后默默丢掉了一行 logger.info,又把剩余的语句错位粘贴。

小林花了 40 分钟回滚主线,又花了 90 分钟手工合并两个 PR 的真实意图。凌晨 4 点 35 分,banner 上线,迟到了 2 小时 21 分。

事后复盘,团队得出结论:不要让两个 Agent 在同一个工作副本上同时改同一个文件。这是朴素到不像工程结论的工程结论,可它指向了一个 Loop 工程的根本命题——

当 Agent 从单次执行进入循环执行,它必须先有一个属于自己的、不被别人干扰的、可以随便折腾的"工作台"。这个工作台,在 git 里叫 worktree,在 Linux 里叫 namespace,在云原生里叫 container,在 Loop 工程里统称"沙盒"。

这一章我们专门谈沙盒。不是因为它复杂,而是因为它太基础、太容易被忽视。一个团队第一次上 Loop 系统,几乎一定是先撞"两个 Agent 改同一个文件"的撞墙,才会回头补沙盒的课。我们希望读者读完这一章后,能在第一次部署 Loop 之前就把沙盒设计好,而不是在凌晨 2 点 14 分的电话里学会。

先把事故画成一张图,让读者看清"没有隔离"到底发生了什么:

            主线 main  ────────────────────────────●
                  │
       ┌──────────┴──────────┐
       │                     │
   PR-A 分支            PR-B 分支
   (Agent A 改函数)     (Agent B 改同一函数)
       │                     │
       │ squash @01:47       │ squash @01:52
       │ ← 自动合并          │ ← 自动合并
       │                     │
       └──────────┬──────────┘
                  ▼
            主线 main  ──●── 重复 SQL,构建挂红
                  │
                  ▼
            凌晨 02:14 值班电话

事故的根因不是 Agent 不聪明,也不是 git 不强大。根因是两个 Agent 共用了同一份工作副本——它们都直接 clone 了主线仓库到本地工作目录,然后在同一片土壤上耕种。这就像两个农夫被分到同一块田,各自种各自的玉米,最后收割时玉米地已经没法分辨谁是谁的。

修复这件事的办法,不是给 Agent 加更聪明的 prompt,也不是教 git 学语义。修复的办法只有一个:给每个 Agent 一块自己的田。这块田在 git 里就是 git worktree,在操作系统层面就是 process namespace,在云原生层面就是 container。它们的核心思想是同一个——隔离

接下来这一章,我们从三层意义、三种方案、三种生命周期、三种失败模式,把"沙盒"这件事彻底讲透。

6.1 隔离的三层意义:数据安全、状态独立、并发可扩

很多团队谈"隔离"时,其实只想到了第一层——别让 Agent 把代码搞坏。这只是隔离的最低层收益。隔离真正要解决的,是三件互相牵连但层次不同的事。

6.1.1 第一层:数据安全

数据安全是隔离最直觉的意义。它回答的问题是:如果 Agent 跑飞了,最坏会发生什么?

考虑一个没有沙盒的 Agent:它在主仓库的工作目录里直接跑,能写任何文件,能改任何代码,能 rm -rf 任何路径。它有 prompt,prompt 里写了"只改 src/auth 目录",但 Agent 在第 17 次循环里为了"清理冗余测试"删掉了 tests/ 整个目录。它觉得自己做对了,因为 prompt 里没有禁止删 tests。然后 CI 红,回滚 30 分钟,团队加班。

数据安全问题的本质,是 Agent 的"意图"和"能力"必须分离。Agent 的意图可以被 prompt 引导,但 Agent 的能力必须被环境硬约束。这是安全工程的第一原则——永远不要靠意图来保证安全,要靠机制

💡 Tip:写 prompt 约束 Agent 不要删文件,等于在前门贴了一张"请勿入内"的纸条。真正能挡住人的是门锁。沙盒就是门锁。

数据安全的具体威胁清单至少包括:

威胁 无沙盒后果 沙盒限制
删除文件 rm -rf 主线代码 沙盒内删除只影响自己的 worktree
改错文件 误改 .git/node_modules/ 沙盒内只挂载必要路径
越权写入 ~/.ssh/authorized_keys 沙盒外路径不可见
网络外联 curl 任意域名泄露代码 沙盒网络白名单
资源耗尽 fork bomb 拖垮宿主机 沙盒 CPU/内存限额
凭证泄漏 .env.npmrc 沙盒内只注入最小凭证

这张表揭示了数据安全的真正含义——不是"别让 Agent 干坏事",而是"哪怕 Agent 想干坏事,它也干不了"。前者是道德规劝,后者是机制约束。

6.1.2 第二层:状态独立

第二层意义容易被忽略,但它是 Loop 工程比单次 Agent 工程更深的诉求。它回答的问题是:两个 Agent 同时跑,它们的工作内存会不会互相污染?

考虑 Agent A 在跑一个重构任务,它把 utils.js 拆成了 utils/string.jsutils/array.js,正在测试。Agent B 在跑一个 bugfix 任务,它需要 import utils.js 里的某个函数。如果两个 Agent 共用同一个工作副本,Agent B 在 Agent A 重构的中间状态里运行,会 import 一个不存在的路径,然后报错。Agent B 不知道是 Agent A 的问题,会去"修复"这个 import,把 utils.js 加回来,结果破坏了 Agent A 的重构。两个 Agent 互相拆台,循环到天荒地老。

状态独立的本质是:每个 Agent 应该看见一个稳定的、自洽的世界。它不应该看见别人的中间状态,也不应该被别人看见自己的中间状态。Agent 的中间状态往往是错误的、半截的、不可运行的——这是软件工程的常态,不是异常。隔离让"中间状态"成为私有,而不是公共灾难。

                  共用工作副本(反模式)
   ┌──────────────────────────────────────────┐
   │           shared working copy             │
   │  ┌─────────┐   ┌─────────┐   ┌────────┐ │
   │  │ Agent A  │   │ Agent B  │   │ Agent C│ │
   │  │ 重构中   │   │ bugfix中 │   │ 跑测试 │ │
   │  └────┬────┘   └────┬────┘   └───┬────┘ │
   │       │             │            │      │
   │       └───── 互相看见中间状态 ─────┘      │
   └──────────────────────────────────────────┘
                  互相干扰,全部失败

                  隔离工作副本(正确模式)
   ┌──────────┐  ┌──────────┐  ┌──────────┐
   │ worktree │  │ worktree │  │ worktree │
   │   A      │  │   B      │  │   C      │
   │ ┌──────┐ │  │ ┌──────┐ │  │ ┌──────┐ │
   │ │Agent │ │  │ │Agent │ │  │ │Agent │ │
   │ │  A   │ │  │ │  B   │ │  │ │  C   │ │
   │ └──────┘ │  │ └──────┘ │  │ └──────┘ │
   └──────────┘  └──────────┘  └──────────┘
        │             │             │
        └───────── 共享 .git/ ───────┘
                  (只读对象数据库)

注意图里那个关键的细节——三个 worktree 共享 .git/ 目录。这是 git worktree 的精髓:状态独立不等于数据冗余。三个 Agent 看到三个不同的工作副本,但它们共享同一份 git 对象数据库,分支、提交、对象不重复存储。这是 git worktree 比单纯 clone 三份仓库轻量得多的根本原因。

💡 Tipgit worktree add 的开销几乎等于 git checkout,不是 git clone。一个仓库可以挂十几个 worktree 而不爆磁盘。这是它能成为 Loop 主力隔离方案的根本前提。

6.1.3 第三层:并发可扩

第三层意义最深远,它决定了 Loop 系统能不能"放大"。它回答的问题是:我能不能同时跑 10 个、100 个、1000 个 Agent?

并发可扩的本质不是"能跑多少 Agent",而是"Agent 之间有没有共享的可变资源"。如果有,并发上限就被这个资源的争用锁死;如果没有,并发上限就只取决于算力与预算。

考虑一个共享工作副本的 Loop 系统:所有 Agent 都在一个目录里跑。任何时刻只能有一个 Agent 在写文件,其他 Agent 必须排队。这是一个隐式的全局锁,并发上限永远是 1。这个 Loop 系统"能跑",但它永远跑不出 1 的并发——它退化成了一个串行的 Agent。

把工作副本换成 worktree 之后,每个 Agent 有自己的目录,写文件不再争用。但 git 操作仍然有争用——git commit 会写 .git/refs/git gc 会重写 pack 文件。这种争用比文件写争用轻得多,但仍然存在。这就是为什么大规模 Loop 系统最终会走向"每个 Agent 一个 container + 一个 worktree"的组合——container 把进程级别也隔离了,争用点降到最低。

       并发上限的演进
   ┌──────────────┐
   │  共享工作副本  │  并发 = 1(文件锁)
   └──────────────┘
   ┌──────────────┐
   │  worktree    │  并发 ≈ 10(git refs 锁)
   └──────────────┘
   ┌──────────────┐
   │  worktree +  │
   │  container   │  并发 ≈ 100(容器调度)
   └──────────────┘
   ┌──────────────┐
   │  per-repo    │
   │  container   │  并发 ≈ 1000+(算力/预算)
   └──────────────┘

这张图揭示了 Loop 工程的一个隐藏规律:并发上限不是"我开了多少 Agent",而是"我把多少共享资源变成了私有资源"。每往下一层,都把一个共享资源私有化,并发上限就上一个数量级。这是从单机到分布式、从串行到并行的工程哲学。

💡 Tip:当你想"我能不能再多跑几个 Agent"时,先问"它们之间还有什么在共享"。每发现一个共享点,就是并发的潜在瓶颈。把瓶颈清单画出来,按"能不能私有化"排序,能私有化的优先做掉,剩下的就是真正的硬上限。

6.1.4 三层意义的相互关系

数据安全、状态独立、并发可扩,这三层不是并列的,而是递进的。数据安全是底线——没有它,其他两层都无从谈起。状态独立是中段——它把 Agent 从"会跑的脚本"升级成"能协作的工人"。并发可扩是顶端——它让 Loop 从"一台机器"扩展成"一座工厂"。

                ┌─────────────────────┐
                │   并发可扩           │  ← Loop 能不能做大
                └─────────────────────┘
              ┌───────────────────────────┐
              │      状态独立              │  ← Agent 能不能协作
              └───────────────────────────┘
            ┌───────────────────────────────────┐
            │         数据安全                   │  ← 系统能不能活下来
            └───────────────────────────────────┘

三层之间还有一个微妙的关系:下层满足后,上层自动获得一部分能力。比如,做到了状态独立,并发可扩就自然而然往前走了一步——因为没有了工作副本争用,加 Agent 不再互相干扰。但反过来不成立——做不到数据安全的系统,状态独立也保不住(Agent 跑飞了,别的 Agent 状态也跟着崩)。这就是为什么设计沙盒时,要从下往上补:先把数据安全做扎实,再谈状态独立,最后才优化并发。

很多团队犯的错误,是倒着来——先想"我要跑 100 个 Agent",于是上来就上 K8s、上分布式调度,结果 100 个 Agent 同时跑崩了主线分支,数据安全都没保证。这就像盖楼先盖第 30 层再补地基,地基还没盖好楼就塌了。

6.2 Git Worktree 详解:最轻量的隔离

在三种隔离方案里,git worktree 是最轻量的。它不是最彻底的,但它是 Loop 工程的默认选择——因为绝大多数 Agent 任务都发生在代码仓库里,而代码仓库的隔离需求 90% 可以由 git worktree 满足。

6.2.1 git worktree 的本质

很多人把 git worktree 当成 git 的一个"附加功能",这是误解。git worktree 是 git 对"分支"概念的物理化——它把"分支"从一个抽象的引用指针,变成了一个真实存在于磁盘上的工作目录。

考虑一个普通仓库:

my-repo/
├── .git/              ← git 元数据
├── src/
├── tests/
└── README.md

这个工作目录对应一个分支(比如 main)。如果你想同时看 maindev 分支的代码,传统做法是 git checkout dev,但这一刻 main 的代码就从磁盘上消失了——你只能看见一个分支。

git worktree add 解决了这个问题:

git worktree add ../my-repo-dev dev

这条命令在 ../my-repo-dev 创建了一个新的工作目录,里面是 dev 分支的代码。原工作目录继续是 main。两个目录共享同一个 .git/,但物理上分离:

my-repo/                  ← main 分支的工作目录
├── .git/                 ← 共享的 git 元数据
├── src/
├── tests/
└── README.md

my-repo-dev/              ← dev 分支的工作目录
├── .git                  ← 指向 my-repo/.git/worktrees/dev 的文件
├── src/
├── tests/
└── README.md

注意 my-repo-dev/.git 不是目录,是一个文件,里面写着 gitdir: /path/to/my-repo/.git/worktrees/dev。这就是 git worktree 的精髓——工作目录物理分离,元数据逻辑共享

💡 Tip:要清理 worktree,用 git worktree remove <path>,不要直接 rm -rf。直接删目录会让 .git/worktrees/ 留下僵尸记录,下次 git worktree list 还会显示。git worktree prune 可以清掉已经物理删除但元数据残留的 worktree。

6.2.2 Loop 中的 worktree 命名约定

Loop 工程里,worktree 不是临时手段,是常驻设施。给它起名字不能随便——名字决定了它的生命周期、归属、用途,甚至决定了回收策略。

一个被实践验证有效的命名约定是:

{repo-root}/.worktrees/{loop-id}/{branch-name}

例如:

my-repo/
└── .worktrees/
    ├── fix-auth-20260703-001/   ← Loop 实例 1
    │   └── fix-auth-bug-123
    ├── add-login-20260703-002/  ← Loop 实例 2
    │   └── add-login-oauth
    └── refactor-utils-20260703-003/
        └── refactor-utils

这个命名有几个关键设计:

  1. 统一放在 .worktrees/ 目录下:避免散落各处,便于批量管理。
  2. 以 loop-id 为一级子目录:一个 Loop 实例对应一个隔离单元,回收时整个目录一起删。
  3. branch-name 作为最后一级:方便人在 worktree 内部 pwd 就知道自己在哪个分支。

把 worktree 放在仓库内部的 .worktrees/ 目录有一个额外好处——.gitignore 加一行 .worktrees/ 就能让这些工作副本不污染主仓库的 git 状态。

# .gitignore
.worktrees/

💡 Tip:worktree 路径不要放在仓库外部。放在外部看似"干净",但会带来两个麻烦:一是路径管理变复杂,每个 worktree 都要记绝对路径;二是容易遗漏在备份/迁移之外。放在仓库内部的 .worktrees/ 是自包含的,整个仓库一打包就全带走。

6.2.3 worktree 与 branch 的对应关系

worktree 和 branch 是一一对应的——一个 worktree 同时只能 checkout 一个分支。这是 git 的硬约束,不是 Loop 工程的选择。

这个约束在 Loop 场景下其实是优势。它强制每个 Agent 都有自己专属的分支,不会出现"一个分支被两个 Agent 改"的二次冲突。Loop 工程师应当把"一个 Agent 一个 worktree 一个 branch"作为铁律:

        Agent ←── 1:1 ──→ Worktree ←── 1:1 ──→ Branch

这个三联对应关系,是 Loop 工程能用 git 跑大规模并发的基础。它让"Agent 的状态"等价于"分支的状态",可以用 git 的所有工具(log、diff、merge、reset)来观察和操作 Agent 的进度。

实践中,建议把这三者用同一个 ID 命名。比如一个修复 issue #123 的 Loop 任务,可以叫 loop-123,那么 worktree 路径就是 .worktrees/loop-123/fix-123,分支名就是 fix-123。这种命名一致性让人和工具都能从任何一个推出另外两个。

6.2.4 实战命令序列

下面是从零搭建一个 Loop worktree 的完整命令序列,可以作为脚本的基础:

# 1. 在主仓库创建工作分支(基于 main)
cd /path/to/my-repo
git checkout main
git pull origin main
git checkout -b fix-123

# 2. 创建 worktree
git worktree add .worktrees/loop-123/fix-123 fix-123

# 3. 进入 worktree 启动 Agent
cd .worktrees/loop-123/fix-123
claude-code --prompt "修复 issue #123..."

# 4. Agent 完成后,回到主仓库合并
cd /path/to/my-repo
git checkout main
git merge --no-ff fix-123

# 5. 删除 worktree 和分支
git worktree remove .worktrees/loop-123/fix-123
git branch -d fix-123

这五步构成了 worktree 的完整生命周期——创建、使用、合并、回收。每一步都有陷阱,第六步回收尤其容易出问题,我们在 6.7 节专门展开。

6.2.5 worktree 的边界

worktree 不是万能的。它解决的是"git 工作副本的物理隔离",仅此而已。它没有解决:

  • 进程级隔离:worktree 里的 Agent 仍然能 kill 宿主机上的其他进程。
  • 网络隔离:worktree 里的 Agent 仍然能 curl 任意地址。
  • 文件系统隔离:worktree 里的 Agent 仍然能 cat /etc/passwd、读 ~/.ssh/
  • 资源限额:worktree 里的 Agent 仍然能把 CPU 跑满、把内存吃光。

把 worktree 当成"完全的沙盒",是 Loop 工程的第一个常见误区。worktree 是"代码隔离",不是"系统隔离"。要系统隔离,必须叠加 Docker 或 namespace。

💡 Tip:判断"worktree 够不够"的简单标准:你的 Agent 会跑 npm installpip installapt-get 这种系统级副作用吗?如果会,worktree 不够,需要 container。如果只是改代码、跑测试,worktree 够。

6.2.6 worktree 的常见陷阱

在 Loop 系统里把 worktree 用熟的工程师,几乎都踩过这几个坑:

陷阱一:在 worktree 里改了 .gitignore 但忘了同步。每个 worktree 有自己的 .gitignore,但项目根目录的 .gitignore 才是主权威。Agent 在 worktree 里加了一行 .env 到自己的 .gitignore,commit、push、合并到 main。结果 main 上的 .gitignore 没变,下一次别的 Agent 启动时,.env 又被跟踪了。修复办法是把项目级 .gitignore 当成"共享文件"——任何 Agent 改它,必须走特殊的合并流程。

陷阱二:worktree 之间共享了不该共享的路径。Agent A 在 worktree 里写了一个临时文件到 /tmp/foo.txt,Agent B 也写了 /tmp/foo.txt,两个 Agent 互相覆盖。/tmp 是宿主机的全局共享目录,不是 worktree 隔离的。修复办法是让 Agent 写临时文件时总是用 mktemp -d 或 worktree 内的 .tmp/ 目录。

陷阱三:忘了 worktree 也有 .git 配置git config user.email 在主仓库设过,但 worktree 是独立的配置。Agent 在 worktree 里 commit 时,可能用了全局默认的 user.email,导致提交历史里的 author 不对。修复办法是在创建 worktree 后立即 git config user.email "loop-agent-{id}@example.com",把 author 标识清楚。

陷阱四:worktree 里的 git hooks 没启用。项目根目录的 .git/hooks/ 是共享的,但有些团队用 core.hooksPath 把 hooks 指向 worktree 外的路径,worktree 里就不生效了。Agent 跑 pre-commit hook 失败,但 hook 其实根本没跑。修复办法是把 hooks 路径明确设为项目级,并在 STATE.md 里记录 hook 是否执行。

💡 Tip:每个 worktree 创建后,跑一次"健康检查"——git statusgit config user.emailgit rev-parse --show-toplevells -la .git。把检查结果写进 STATE.md,作为 Agent 启动前的环境指纹。出问题时对比指纹,能快速定位是 worktree 本身坏了还是 Agent 行为异常。

6.2.7 worktree 与 git 的对象数据库

理解 worktree 必须理解 git 的对象数据库(object database)。这是 git 区别于其他版本控制系统的核心设计。

git 把所有内容——文件内容(blob)、目录结构(tree)、提交(commit)、标签(tag)——都作为对象存在 .git/objects/ 里。每个对象用 SHA-1 哈希作为唯一 ID。分支(branch)只是指向某个 commit 的指针,标签(tag)同理。

worktree 共享这个对象数据库。三个 worktree 看见三个不同的分支,但分支指向的 commit 对象都存在同一个 .git/objects/ 里。如果两个分支都包含同一个文件版本,这个文件只存一份——按哈希去重。

           .git/objects/  (共享对象数据库)
                │
   ┌────────────┼─────────────┐
   │            │             │
   ▼            ▼             ▼
 worktree A  worktree B   worktree C
 (main)      (fix-123)    (refactor)
   │            │             │
   └──── refs/heads/main      │
                │             │
                └─ refs/heads/fix-123
                              │
                              └─ refs/heads/refactor

这张图揭示了 worktree 轻量的根本原因——对象不重复存储。100 个 worktree 共享同一份 git 对象,磁盘开销几乎等于 1 个 worktree。这是 worktree 比单纯 clone 100 份仓库轻量几十倍的根本原因。

理解这一点对 Loop 工程师有实际意义。当 Loop 系统跑了很久、积累了大量分支和 worktree,.git/objects/ 会越来越大,影响 git 操作速度。这时需要定期 git gc(垃圾回收)清理不可达对象。但 git gc 会重写 pack 文件,是写操作,不能和别的 git 操作并发。Loop 系统要在低峰期定时跑 git gc,避开 Agent 活跃时段。

6.3 Docker 容器隔离:最彻底的隔离

当代码隔离不够,需要系统隔离时,Docker 容器是 Loop 工程的主力方案。它的隔离强度足以挡住绝大多数 Agent 失控场景,开销又远低于虚拟机,是 Loop 工程在大规模并发下的"安全网"。

6.3.1 为什么 worktree 不够

考虑一个跑代码重构的 Loop 任务。Agent 需要 npm install 来装新依赖。在 worktree 里跑这条命令,会修改 ~/.npm 缓存、写入 node_modules/、可能触发全局的 npm 行为。如果同时有 5 个 Agent 在跑 npm install,它们的缓存会互相干扰,npm 缓存不是为并发设计的,会出现奇怪的 lock 文件冲突。

更严重的场景:Agent 需要 apt-get install 一个系统包来跑测试。在 worktree 里直接跑会修改宿主机的系统状态,留下不可逆的痕迹。Agent 跑完被回收,但系统包还在,下次另一个 Agent 启动时看到的环境已经不是初始状态了。

这些场景的核心问题是:Agent 的副作用越过了代码仓库的边界,进入了系统层。worktree 只隔离了代码仓库,对系统层无能为力。

        ┌──────────────────────────────────┐
        │           宿主机系统              │
        │  ┌────────────────────────────┐  │
        │  │     Docker 容器             │  │
        │  │  ┌──────────────────────┐  │  │
        │  │  │   worktree           │  │  │
        │  │  │  ┌──────────────┐    │  │  │
        │  │  │  │  Agent 进程   │    │  │  │
        │  │  │  └──────────────┘    │  │  │
        │  │  └──────────────────────┘  │  │
        │  └────────────────────────────┘  │
        │  系统级副作用被容器挡住           │
        └──────────────────────────────────┘

容器在 worktree 外面又包了一层。Agent 跑 npm install,影响的是容器内的 node_modules/~/.npm,宿主机看不到。Agent 跑 apt-get install,影响的是容器内的 /usr/lib/,宿主机也看不到。容器被销毁,所有副作用一起消失,宿主机回到初始状态。

6.3.2 Docker 隔离的能力边界

Docker 隔离强度有边界。它隔离的是:

资源 隔离机制 默认强度
文件系统 OverlayFS / bind mount 强(默认容器看不见宿主机文件)
进程 PID namespace 强(容器内只看自己的进程)
网络 network namespace + bridge 中(默认能出网,需配置白名单)
用户 user namespace 中(默认 root 仍是 root,需映射)
资源 cgroups 中(默认不限,需配置限额)
IPC IPC namespace
主机名 UTS namespace

这张表的关键洞察是——Docker 默认配置的隔离,文件系统和进程是强的,网络和资源限额是弱的。一个"开箱即用"的 Docker 容器,Agent 仍然能 curl 任意地址、把 CPU 跑满。要做严肃的 Loop 工程沙盒,必须显式补上网络白名单和资源限额。

💡 Tip:永远不要在生产 Loop 系统里用 docker run --network host 或不加 --memory --cpus 限制的容器。前者让 Agent 能监听宿主机端口、扫描内网;后者让一个失控 Agent 能把宿主机拖垮。这两条是 Docker 沙盒的最低线。

6.3.3 Loop 容器镜像设计

Loop 工程的容器镜像设计,和普通应用的容器镜像设计哲学不同。普通应用镜像追求"小而美"——alpine + 一个二进制,几十兆搞定。Loop Agent 镜像追求"全而稳"——它要预装 Agent 可能用到的所有工具链,否则 Agent 跑到一半发现缺个 gcc 就尴尬了。

一个典型的 Loop Agent 镜像层次:

FROM node:22-slim

# 系统依赖层
RUN apt-get update && apt-get install -y \
    git curl wget vim jq \
    chromium \
    python3 python3-pip \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

# Agent 工具层
RUN npm install -g @anthropic-ai/claude-code @anthropic-ai/claude-agent-sdk
RUN pip install --break-system-packages ruff black mypy

# Skills 层(项目级知识)
COPY skills/ /workspace/project-skills/

# 工作目录
WORKDIR /workspace/group
USER node

镜像设计的几个原则:

  1. 基础镜像选 slim 而非 alpine:alpine 用 musl libc,很多 npm 包的预编译二进制不兼容,会触发从源码编译,build 时间爆炸。slim 是 Debian 减肥版,glibc 兼容性好。
  2. 预装工具链:python、node、build-essential、chromium 这些 Agent 可能用到的工具,镜像里就装好。容器启动再装太慢。
  3. 以非 root 用户运行USER node 让 Agent 进程没 root 权限,再加一层防护。
  4. Skills 走 volume 挂载:项目级 Skills 经常更新,放镜像里每次更新都要重建镜像。放 volume 挂载,宿主机改完容器立即生效。

6.3.4 卷挂载策略

容器隔离的核心矛盾是——既要 Agent 看不到宿主机敏感文件,又要 Agent 能读写工作区。这个矛盾通过精心设计的卷挂载策略解决。

一个典型的 Loop 容器挂载清单:

docker run \
  -v /path/to/worktree:/workspace/group \      # 工作区(读写)
  -v /path/to/.claude:/home/node/.claude \     # 会话持久化(读写)
  -v /path/to/skills:/workspace/project-skills:ro \  # Skills(只读)
  -v /path/to/env:/workspace/env-dir:ro \      # 环境变量(只读)
  -v /path/to/ipc:/workspace/ipc \             # IPC 通道(读写)
  my-loop-agent

挂载策略的几条原则:

  1. 工作区读写,其他尽量只读:能只读的就只读,把可写面降到最小。
  2. 不挂载宿主机的 home 目录~/.ssh~/.aws~/.npmrc 这些凭证不能让 Agent 看到。
  3. IPC 通道必须读写:Agent 通过 IPC 文件和主进程通信,必须可写。
  4. 挂载点路径固定:容器内路径固定(/workspace/group/home/node/.claude),让 Agent 的 prompt 可以硬编码这些路径。
   宿主机                                容器
   ┌─────────────────────────┐
   │ /path/to/worktree       │ ──────► /workspace/group   (rw)
   │ /path/to/.claude        │ ──────► /home/node/.claude  (rw)
   │ /path/to/skills         │ ──────► /workspace/project-skills (ro)
   │ /path/to/env            │ ──────► /workspace/env-dir  (ro)
   │ /path/to/ipc            │ ──────► /workspace/ipc      (rw)
   │ ~/.ssh                  │ ✗ 不挂载
   │ ~/.aws                  │ ✗ 不挂载
   │ /etc/passwd             │ ✗ 不挂载(容器内自己有)
   └─────────────────────────┘

这张图揭示了卷挂载策略的精髓——白名单制:默认什么都看不见,只挂载必要的几个路径。这是安全工程的"最小权限原则"在容器层面的体现。

6.3.5 网络与权限

Docker 默认的 bridge 网络让容器能出网,能访问宿主机所在的内网。这在 Loop 场景下是危险的——Agent 可能被诱导去访问内网的 admin 接口、扫描 redis 的 6379 端口、或者把代码 push 到一个外部仓库。

严肃的 Loop 沙盒必须显式做网络白名单。Docker 自带的网络白名单工具有两个层级:

  1. 网络层:用 --network 选自定义网络,配合 iptables 规则限制出站。
  2. DNS 层:在 /etc/hosts 或自定义 DNS 服务器里,把允许的域名解析到真实 IP,其他域名解析到 0.0.0.0

更彻底的做法是用一个代理容器,所有 Agent 容器的出网流量都走代理,代理做域名白名单:

   ┌──────────────────────────────────────────┐
   │              宿主机网络                  │
   │                                          │
   │   ┌──────────────┐    ┌──────────────┐  │
   │   │ Agent 容器 A │    │ Agent 容器 B │  │
   │   │  --net=loop  │    │  --net=loop  │  │
   │   └──────┬───────┘    └──────┬───────┘  │
   │          │                   │          │
   │          └────────┬──────────┘          │
   │                   │                     │
   │                   ▼                     │
   │          ┌────────────────┐             │
   │          │  proxy 容器    │             │
   │          │  (域名白名单)  │             │
   │          └────────┬───────┘             │
   │                   │                     │
   │                   ▼                     │
   │          ┌────────────────┐             │
   │          │   外网         │             │
   │          │  (api.anthropic.com,  │      │
   │          │   github.com, ...)   │      │
   │          └────────────────┘             │
   └──────────────────────────────────────────┘

这种代理白名单模式有一个隐藏好处——所有 Agent 的网络流量都经过代理,可以做可观测性。代理可以记录每个 Agent 访问了什么域名、传了多少字节,给后续的审计和成本分析提供数据。

💡 Tip:网络白名单的第一版建议从"必需域名"反推,而不是从"危险域名"排除。先列出 Agent 跑通任务必须访问的域名(API endpoint、代码仓库、依赖源),只放行这些,其他全拒。出问题再补——这样比"放行所有,逐个封堵"安全得多。

6.4 进程级 / 命名空间级隔离:折中方案

worktree 太轻,container 太重,中间有一档——进程级和命名空间级隔离。这一档在 Loop 工程里不是主流,但在某些场景下是最佳解。

6.4.1 进程级隔离

进程级隔离的本质是——Agent 作为宿主机的子进程跑,但通过环境变量和工作目录限制它的视野。这是 HappyClaw 的"宿主机模式"采用的方式。

进程级隔离的能力边界很窄:

  • 隔离了工作目录(cwd 不同)
  • 隔离了环境变量(不继承父进程的)
  • 隔离了退出码(子进程崩溃不影响父进程)
  • 隔离文件系统(仍能 cd / 看到全盘)
  • 隔离网络(仍能 curl 任意地址)
  • 隔离进程(仍能 kill 别的进程)

进程级隔离的优势是性能——没有容器的启动开销,启动一个 Agent 进程就是 fork + exec,毫秒级。这在某些"高频短任务"场景下是关键:比如一个 Loop 系统每 10 秒触发一次小任务,每次用容器启动要 1-2 秒,开销就太大了。

进程级隔离适合的场景:

  1. Agent 完全可信:是自家系统的 Agent,prompt 经过严格审计,工具调用经过白名单。
  2. 任务短而频繁:每次任务几秒到几十秒,容器启动开销占比太高。
  3. 宿主机本身就是专用的:整台机器就跑这一个 Loop 系统,没有别的服务,Agent 跑飞了也只影响自己。

HappyClaw 的 admin 主容器就是宿主机模式——admin 是可信用户,admin 的工作目录是项目根目录,admin 的 Agent 直接跑在宿主机进程里,跳过容器开销。这是一个经过权衡的折中。

6.4.2 命名空间级隔离

Linux 的 namespace 是 Docker 容器隔离的底层技术。Docker 把 namespace 包装成了易用的 CLI,但 namespace 本身可以单独用——这就是"无 Docker 的容器"。

直接用 namespace 跑一个隔离进程的命令:

# 用 unshare 创建一个新的 mount namespace
unshare --mount --pid --net --user \
  --map-root-user \
  --root /path/to/rootfs \
  --propagation private \
  /bin/bash

这条命令创建了一个新的进程,它有:

  • 独立的 mount namespace(看不见宿主机的文件系统挂载点)
  • 独立的 PID namespace(自己是 PID 1)
  • 独立的 network namespace(看不见宿主机网络)
  • 独立的 user namespace(root 映射到宿主机的非 root)

效果上接近 Docker 容器,但没有 Docker daemon、没有镜像、没有 OverlayFS。需要自己准备 rootfs(可以用 debootstrapbusybox)。

命名空间级隔离的应用场景:

  1. 不能装 Docker 的环境:某些受限制的服务器、某些 CI runner。
  2. 极轻量隔离需求:启动开销要 < 100ms。
  3. 定制化隔离:需要 Docker 默认不提供的特殊隔离组合。

它的代价是工程复杂度——所有 Docker 帮你做的事,你都要自己做:rootfs 管理、网络配置、cgroup 限额、日志收集、清理回收。在大多数 Loop 工程场景下,这个代价不值得。

6.4.3 三种方案的频谱

把三种隔离方案画在一张频谱图上:

   隔离强度 ──────────────────────────────────────────►
   启动开销 ──────────────────────────────────────────►
   工程复杂度 ──────────────────────────────────────────►

   ┌──────────┐    ┌──────────────┐    ┌────────────────┐
   │ worktree │    │ namespace    │    │ container      │
   │  仅代码   │    │  系统层     │    │  系统层 + 工具链 │
   │  ~0ms    │    │  ~50ms      │    │  ~1-2s         │
   │  低      │    │  高         │    │  中(Docker 帮忙)│
   └──────────┘    └──────────────┘    └────────────────┘
        │                │                    │
        ▼                ▼                    ▼
    代码隔离         系统+网络隔离        系统+网络+工具链隔离

这个频谱揭示了选择隔离方案的核心权衡——隔离强度和启动开销成正比,和工程便利度成反比。越强的隔离越慢、越复杂;越弱的隔离越快、越简单。Loop 工程师的任务,是根据任务特性选合适的档位,而不是盲目追求最强。

6.5 三种隔离方案的决策矩阵

把三种方案放在一起做决策矩阵。决策维度有八个,每个维度上三种方案各有强弱:

维度 worktree namespace container
启动开销 ~0ms ~50ms ~1-2s
隔离强度 仅代码 系统+网络 系统+网络+工具链
工程复杂度 极低 中(Docker 包装)
磁盘开销 共享 .git rootfs 镜像+层
资源限额 cgroups cgroups(默认配置)
网络隔离 强(需配置)
可观测性 高(直接看) 中(需 docker logs)
适合任务 纯代码改动 短任务+可信 长任务+不可信

从决策矩阵能看出,没有"最好"的方案,只有"最合适"的方案。下面是一个决策树,帮助读者根据任务特性选方案:

                       任务来了
                          │
                  ┌───────┴────────┐
                  ▼                ▼
            Agent 可信?       Agent 不可信?
                  │                │
              ┌───┴───┐            │
              ▼       ▼            ▼
        短任务?  长任务?      container
              │       │        (默认安全选项)
              ▼       ▼
        worktree  container
        或 namespace

决策树的几个关键分支:

  1. Agent 不可信 → container:不可信意味着 prompt 来自外部(用户输入、Issue 描述),必须用最强隔离。
  2. Agent 可信 + 短任务 → worktree 或 namespace:可信意味着可控,可以用轻隔离。短任务用 worktree,稍长用 namespace。
  3. Agent 可信 + 长任务 → container:长任务即使可信,跑飞的概率也高,建议用 container 加限额。

💡 Tip:实际工程中,"混合策略"比"单一策略"更常见——核心仓库用 worktree(性能优先),实验性任务用 container(安全优先),临时脚本用 namespace(折中)。Loop 系统应该支持多种隔离方案的配置,让用户按任务类型选。

6.5.1 三种方案的成本对比

除了能力差异,三种方案的运营成本也不同。下表是一个粗略的成本对比(以 100 个并发 Agent 跑 24 小时为基准):

成本项 worktree namespace container
CPU 开销 几乎 0 ~1% 调度开销 ~3-5% 容器运行时
内存开销 几乎 0 ~10MB/实例 ~50-200MB/实例
磁盘开销 共享 .git rootfs 共享 镜像层共享
启动延迟 <10ms ~50ms ~1-2s
运维复杂度 低(git 命令) 高(自建工具链) 中(Docker 生态)
故障恢复 git reset 重启 namespace docker restart

这张表的关键洞察是——container 的内存开销是 worktree 的几十倍。100 个并发 container Agent 可能吃掉 10-20GB 内存,而 100 个 worktree Agent 几乎不增内存。这个差距在大规模场景下决定成本。

6.6 并发 Agent 的锁与版本协调

隔离解决了"Agent 之间不互相干扰",但 Loop 工程还有更深的问题——Agent 跑完后要合并回主线,合并本身是有冲突的。这一节谈并发的锁与版本协调。

6.6.1 文件锁:最粗的锁

最朴素的并发控制是文件锁——同一时刻只有一个 Agent 能改某个文件。这能彻底避免冲突,但代价是并发降为 1。

文件锁在 Loop 场景下几乎从不合适。它的粒度太粗,把不相关的修改也串行化了。比如 Agent A 改 auth.py,Agent B 改 models.py,两个改动毫无关系,但文件锁会让 B 等 A。

文件锁只在极特殊场景下用——比如修改一个不可分的配置文件(数据库 schema 迁移脚本)。这种文件改一次影响全局,必须串行。

6.6.2 分支锁:中粒度的锁

分支锁是 Loop 工程的实际选择——同一时刻一个分支只能被一个 Agent 改。这是 worktree 的 1:1 对应关系的自然结果。

分支锁的粒度比文件锁细。Agent A 在 fix-123 分支,Agent B 在 fix-456 分支,两个分支独立推进,互不影响。冲突只在合并到主线时发生。

分支锁的隐式约束是——主线合并必须串行。两个分支同时合并到 main,可能产生合并冲突。Loop 工程的标准做法是把合并放进一个队列,串行执行:

       Agent A 完成 ─────►┐
                          │
       Agent B 完成 ─────►┼──► 合并队列 ──► main
                          │    (串行)
       Agent C 完成 ─────►┘

合并队列是 Loop 工程的标配组件。它做的事是——收到"某分支可以合并"的信号后,依次执行:fetch latest main → rebase 当前分支到 main → 跑测试 → merge to main → 通知 Agent。任何一步失败,分支退回队列,等下一个机会。

💡 Tip:合并队列不要用"先来先服务",用"测试通过优先"。一个分支如果连续 N 次合并失败,应该被踢出队列,让位给后面的分支。这避免一个有问题的分支堵塞整个合并流。

6.6.3 合并锁:细粒度的锁

更细的锁是合并锁——只在主线合并那一刻加锁,平时 Agent 各自跑。这是 git 本身的乐观策略——分支并行开发,合并时检测冲突,冲突就人工解决。

合并锁的本质是乐观并发控制(OCC)。它的假设是——冲突是少数。绝大多数分支合并到 main 不会冲突,因为它们改的是不同文件。少数冲突的,靠 git 三路合并或人工解决。

乐观并发控制在 Loop 场景下有一个隐藏陷阱——冲突的概率随并发数指数上升。10 个 Agent 并发,冲突概率还行;100 个 Agent 并发,几乎每次合并都有冲突。这是因为冲突不是"两两独立"的,它有传递性——A 和 B 冲突,B 和 C 冲突,A 和 C 即使没直接冲突,解决 B 的冲突可能引发 A 和 C 的冲突。

工程上应对这个陷阱的办法是——把高冲突风险的修改集中在少数 Agent 上。比如 schema 改动、公共接口改动,这种高影响面的修改不该让 100 个 Agent 各自尝试,应该有一个专门的 Agent 串行处理。

6.6.4 乐观锁 vs 悲观锁:四种策略

把锁策略整理成四象限:

                    隔离强度
                    强
                    │
                    │
        悲观锁  ────┼────  乐观锁
          (先锁)    │     (后检测)
                    │
                    弱
        ───────────┼───────────
         粗粒度    粒度    细粒度

四象限对应的策略:

策略 隔离 粒度 适合场景
悲观+粗粒度 文件锁 极高冲突,慢任务
悲观+细粒度 分支锁 中等冲突,常规 Loop
乐观+粗粒度 合并检测 低冲突,少 Agent
乐观+细粒度 行级 diff 检测 实验性

Loop 工程的默认选择是"悲观+细粒度"——分支锁。它在并发性和正确性之间取了平衡。少数实验性场景可以用乐观策略。

6.6.5 跨领域类比:数据库事务

并发 Agent 的锁协议和数据库事务的并发控制高度同构。这是 Loop 工程可以借鉴的成熟领域。

数据库事务的四个隔离级别——读未提交、读已提交、可重复读、串行化——对应到 Loop 场景:

DB 隔离级别 Loop 对应 副作用
读未提交 Agent 看别人的中间状态 脏读——Agent B 看到 A 的半成品
读已提交 Agent 只看已合并的状态 不可重复读——同一文件两次读结果不同
可重复读 Agent 整个生命周期看一个 snapshot 幻读——合并时发现状态变了
串行化 全局串行,一次一个 Agent 并发降为 1

这个类比揭示了——Loop 工程的默认应该是"可重复读"。每个 Agent 启动时拍一个主线 snapshot,整个生命周期看这个 snapshot。合并时再做冲突检测。这是 git 的天然模型——git checkout main 拍 snapshot,git merge 时检测冲突。

💡 Tip:让 Agent 在 worktree 启动那一刻拍 snapshot,整个生命周期不 pull 主线。这避免 Agent 在跑的过程中"世界变了",导致它的判断和最终合并时的状态不一致。合并时再处理冲突,比让 Agent 边跑边追主线简单得多。

6.6.6 合并冲突的自动解决:危险与边界

当 Loop 系统规模上去之后,合并冲突会变得频繁。一些团队尝试用 LLM 自动解决冲突——把冲突标记和两边改动喂给 Agent,让它选一个或合并。这在 80% 的简单冲突上是有效的,但有 20% 的危险场景需要警惕。

自动解决冲突的安全边界

  1. 文本非重叠冲突:两边改了不同函数,git 三路合并已经能解决,不需要 LLM 介入。
  2. 格式冲突:一边用了 tab、另一边用了 space,LLM 可以安全地统一格式。
  3. import 顺序冲突:两边都加了 import,顺序不同,LLM 可以按字母序合并。
  4. 逻辑冲突:两边对同一函数做了语义不同的修改——禁止自动解决,必须人工。
  5. 接口冲突:一边改了函数签名、另一边调了旧签名——禁止自动解决,必须人工。
   冲突类型                  自动解决风险
   
   文本非重叠   ──────►  安全(git 已能处理)
   格式         ──────►  安全
   import 顺序  ──────►  安全
   逻辑         ──────►  ✗ 高危,禁止自动
   接口签名     ──────►  ✗ 高危,禁止自动
   资源释放     ──────►  ✗ 高危(可能导致泄漏)
   并发同步     ──────►  ✗ 高危(可能导致死锁)

最后三类是"语义冲突"——文本上看似能合并,但语义上藏 bug。一个 Agent 把 lock.acquire() 改成了 with lock:,另一个 Agent 在同一个函数里加了 lock.release()。文本合并可能产生 with lock: ... lock.release()——release 在 with 块外被调,但 lock 已经被 with 释放过了,这是 double-release bug。LLM 在没有类型系统和执行模型的情况下,识别不了这种语义冲突。

💡 Tip:自动冲突解决可以放在低风险冲突上,高风险冲突必须有人工兜底。把"自动解决"和"人工审核"分开两个队列,自动队列解决了 80%,剩下 20% 进人工队列。这个分流让 Loop 在大规模并发下仍能保持正确性。

6.6.7 跨领域类比:版面编辑的"lock-free"协作

并发 Agent 的另一个跨领域类比是——在线协作文档(Google Docs、飞书文档)的实时协作。这两件事看似无关,实际同构。

在线协作文档允许多人同时编辑同一份文档,没有显式锁。它的工作机制是 OT(Operational Transformation)或 CRDT(Conflict-free Replicated Data Type)。每个编辑者看到自己的本地副本,操作通过服务器同步。同步时,OT/CRDT 算法保证——只要每个操作有因果关系,最终所有副本会收敛到同一状态。

这和 Loop 的并发 Agent 有什么关系?它启发了"无锁并发"的另一种思路。传统 git 模型是悲观的——分支隔离,合并检测冲突。OT/CRDT 模型是乐观的——操作直接合并,算法保证收敛。

在某些 Loop 场景下,可以用 CRDT 思路改造 git 工作流。比如多个 Agent 改的是文档(markdown、注释、README),而不是代码——文档没有语法冲突风险,可以走 CRDT 路径,自动合并,不进合并队列。

这是 Loop 工程的前沿实验方向。一些团队已经在尝试用 Automerge(一个 CRDT 库)做 Agent 之间的文档协作,绕开 git 的合并队列瓶颈。结果喜忧参半——文档场景效果显著,代码场景仍需要 git 的严格语义边界。

6.7 工作树生命周期:创建、回收、合并

工作树不是静态设施,是有生命周期的。这一节谈它的四阶段——创建、心跳、回收、合并。生命周期管理的核心是"不留垃圾"——一个跑完的 Loop 任务应该把它的 worktree 干净地清掉,不留下半截分支、不留下孤儿目录。

6.7.1 创建阶段

创建 worktree 看起来简单,一条 git worktree add 就完了。但在 Loop 工程里,创建要做的事比这条命令多:

  1. 选基线分支:从 main 还是 dev 拉?这决定 Agent 看到的初始世界。
  2. 拉最新代码git fetch && git pull,避免基于过时状态。
  3. 起分支名:用一个能体现任务意图的名字,不要 tmp-1fix-2
  4. 创建 worktreegit worktree add 到约定目录。
  5. 写入 STATE.md:在 worktree 里创建状态文件,记录任务、Agent、起始时间。
  6. 注册到调度器:告诉 Loop 系统"这个 worktree 存在了,归某个 Agent 管"。

第 5 步经常被忽略。STATE.md 是 Agent 跨会话记忆的关键,没有它,Agent 重启后不知道自己是谁、在干什么。一个最小的 STATE.md:

# State

- task: 修复 issue #123
- branch: fix-123
- started: 2026-07-03T10:00:00+08:00
- agent: claude-code-4.7
- iteration: 0
- last_attempt: ""

6.7.2 心跳与租约

worktree 一旦创建,就开始消耗资源——磁盘空间、git refs、可能的 container。如果 Agent 跑到一半崩溃了,worktree 会变成孤儿,永久占着资源。

工程上的对策是心跳与租约。每个 Agent 在跑的过程中,定期向调度器报告"我还活着"——这就是心跳。调度器给每个 worktree 一个租约(lease),租约期内的心跳续期,超过租约没有心跳就认为 Agent 死了,触发回收。

   时间轴 ──────────────────────────────────►
   
   Agent 启动   心跳  心跳  心跳  崩溃!
       │        │    │    │     ✗
       ▼        ▼    ▼    ▼     ▼
   ┌───┴────┬───┴────┴────┴─────┴────┬─────┐
   │   租约 1   │   租约 2   │  超时  │ 回收 │
   └──────────┴──────────┴────────┴─────┘
                              ▲
                              │
                       租约期内没收到心跳
                       触发回收流程

租约时长是个权衡——太短会误杀慢任务,太长会让孤儿 worktree 长期占资源。实践中,5-15 分钟是合理区间。Agent 心跳间隔通常是租约的 1/3,比如租约 15 分钟、心跳 5 分钟。

💡 Tip:心跳别只发"我活着",带上当前进度——“我在改第 3 个文件”、“测试跑了 50%”。这样调度器能做更智能的决策,比如发现某个 Agent 卡在同一个步骤 30 分钟就主动介入。进度心跳是可观测性的金矿。

6.7.3 回收阶段

Agent 完成(或失败、或超时)后,worktree 进入回收流程。回收是一个多步操作:

  1. 最终提交:如果有未提交改动,先 commit。
  2. 推送分支git push origin fix-123,备份到远程。
  3. 触发合并队列:通知合并队列"这个分支可以合并了"。
  4. 删除 worktreegit worktree remove
  5. 删除本地分支:合并完成后 git branch -d fix-123
  6. 清理状态:删除 STATE.md、container、IPC 文件等附属资源。

回收流程最容易出错的是第 4 步——git worktree remove。如果 worktree 里有未提交改动,git 默认拒绝删除。Agent 跑完应该已经 commit 了,但有时会有意外残留(比如 Agent 临时实验的文件)。工程上有两种处理:

  • 严格模式:拒绝删除有改动的 worktree,报警等人工处理。
  • 强制模式:git worktree remove --force,丢弃改动。

两种模式各有适用场景。生产 Loop 系统建议严格模式为主——一个有未提交改动的 worktree,往往是 Agent 异常的信号,强制删除会掩盖问题。

6.7.4 合并阶段

合并是工作树生命周期的高潮。一个分支能不能合并回主线,决定了 Agent 的工作有没有价值。

合并的标准流程:

# 1. 切回主线
git checkout main
git pull origin main

# 2. 拉最新分支
git checkout fix-123
git pull origin fix-123

# 3. rebase 到最新主线
git rebase main

# 4. 跑测试
npm test

# 5. 切回主线合并
git checkout main
git merge --no-ff fix-123

# 6. 推送
git push origin main

这个流程在 Loop 工程里几乎总是自动化的,因为它要跑很多次。自动化时几个关键点:

  1. rebase 而不是 merge:rebase 让历史线性,合并后主线没有"merge commit"杂烩。
  2. 测试必须在 rebase 之后:rebase 可能引入新冲突,必须重跑测试。
  3. --no-ff:保留 merge commit,方便回溯。
  4. 失败回滚:任何一步失败,主线回到合并前状态,分支退回合并队列。

6.7.5 僵尸 worktree

工作树生命周期管理的天敌是"僵尸 worktree"——Agent 死了,worktree 没被回收,永久占着资源。一个跑了一年的 Loop 系统,如果没有严格的回收机制,可能积累几百个僵尸 worktree,每个占几百 MB 磁盘。

防僵尸的策略:

  1. 租约 + 心跳:前面讲过,租约超时自动回收。
  2. 定期巡检:每天定时跑 git worktree list,找出超过 N 天没活动的 worktree。
  3. 启动时清理:Loop 系统每次启动,扫描所有 worktree,把不属于活跃 Agent 的全清掉。

巡检脚本示例:

#!/bin/bash
# 清理超过 7 天没活动的 worktree
WORKTREES=$(git worktree list --porcelain | grep "^worktree " | cut -d' ' -f2)
for wt in $WORKTREES; do
    last_mod=$(find "$wt" -type f -printf '%T@\n' | sort -rn | head -1)
    now=$(date +%s)
    age_days=$(( (now - ${last_mod%.*}) / 86400 ))
    if [ $age_days -gt 7 ]; then
        echo "Pruning stale worktree: $wt ($age_days days old)"
        git worktree remove --force "$wt"
    fi
done

💡 Tip:把僵尸清理脚本挂在 Loop 系统的定时任务里,每周跑一次。不要等磁盘满了才想起来清。磁盘满的时候,所有 Agent 一起挂,那是事故不是维护。

6.7.6 生命周期的状态机

把整个生命周期画成状态机:

              ┌──────────┐
              │  Pending │ ── 任务排队中
              └────┬─────┘
                   │ 创建 worktree
                   ▼
              ┌──────────┐
              │ Running  │ ── Agent 执行中
              └────┬─────┘
                   │
        ┌──────────┼──────────┐
        │          │          │
        ▼          ▼          ▼
   ┌────────┐ ┌────────┐ ┌────────┐
   │ Done   │ │ Failed │ │Timeout │
   └───┬────┘ └───┬────┘ └───┬────┘
       │          │          │
       │          │ 重试?    │
       │          ▼          │
       │     ┌────────┐      │
       │     │ Retrying│     │
       │     └────┬───┘      │
       │          │          │
       └──────────┴──────────┘
                  │
                  ▼
             ┌──────────┐
             │ Merging  │ ── 进入合并队列
             └────┬─────┘
                  │
        ┌─────────┴─────────┐
        ▼                   ▼
   ┌─────────┐         ┌─────────┐
   │ Merged  │         │Conflict │
   └────┬────┘         └────┬────┘
        │                   │ 人工介入
        ▼                   ▼
   ┌──────────┐        ┌──────────┐
   │ Cleaning │        │ Awaiting │
   └────┬─────┘        │  Human   │
        │              └──────────┘
        ▼
   ┌──────────┐
   │  Closed  │ ── 终态
   └──────────┘

这个状态机覆盖了 worktree 生命的所有阶段。状态机的设计有几个关键决策:

  1. Failed 和 Timeout 可以重试:但重试次数有上限,避免死循环。
  2. Merging 是独立状态:合并是异步的,进队列后等机会。
  3. Conflict 走人工:自动解决冲突是高风险操作,Loop 工程默认交给人工。
  4. Closed 是唯一终态:所有路径最终都要走到 Closed,否则就是僵尸。

6.7.7 生命周期的工程实现

把状态机落到代码里,是一个典型的"工作流引擎"。下面是一个最小实现的核心结构(TypeScript 伪代码):

type WorktreeState =
  | "pending"
  | "running"
  | "done"
  | "failed"
  | "timeout"
  | "retrying"
  | "merging"
  | "merged"
  | "conflict"
  | "cleaning"
  | "awaiting_human"
  | "closed";

interface WorktreeRecord {
  id: string;
  branch: string;
  path: string;
  state: WorktreeState;
  createdAt: number;
  lastHeartbeat: number;
  leaseUntil: number;
  iteration: number;
  retryCount: number;
  errorMessage?: string;
}

async function tick(record: WorktreeRecord) {
  switch (record.state) {
    case "pending":
      await createWorktree(record);
      record.state = "running";
      break;
    case "running":
      if (Date.now() > record.leaseUntil) {
        record.state = "timeout";
      }
      break;
    case "done":
      await enqueueMerge(record);
      record.state = "merging";
      break;
    case "failed":
      if (record.retryCount < MAX_RETRIES) {
        record.retryCount++;
        record.state = "retrying";
      } else {
        record.state = "awaiting_human";
      }
      break;
    case "merging":
      const result = await tryMerge(record);
      record.state = result.ok ? "merged" : "conflict";
      break;
    case "merged":
    case "conflict":
      await cleanupWorktree(record);
      record.state = "closed";
      break;
  }
}

这个实现的关键设计是——tick 是无状态的轮询。调度器每秒扫一遍所有 worktree record,按状态机决定下一步。这种设计的好处是——调度器重启后从记录里恢复,不丢状态。每个 tick 都是幂等的,重复执行不会出错。

💡 Tip:状态机要"持久化到磁盘"。每个 worktree 的状态、心跳、租约都写到数据库或文件里。调度器重启后能从断点恢复,不会出现"调度器死了,所有 worktree 都变孤儿"。这是 Loop 工程的工程纪律——状态不在内存里,状态在文件里。

6.7.8 跨领域类比:建筑工地的"施工许可证"

工作树生命周期管理的跨领域类比是——建筑工地的施工许可证制度。

一座楼要建起来,不是承包商想怎么干就怎么干。每个施工阶段都要有许可证——开工证、基坑验收、结构验收、装修验收、竣工备案。每个许可证都有:

  1. 申请时审核资质
  2. 执行时定期巡检
  3. 完工时验收备案
  4. 超期未完工吊销

这四步正好对应 worktree 生命周期的四阶段——创建(申请)、心跳(巡检)、回收(验收)、合并(备案)。

施工许可证制度的精髓是——每一步都有"许可"和"验收"两个动作,不是一个动作做到底。这避免了"承包商自己说自己做完了"的诚信问题——验收必须由独立的监理做。

Loop 工程的对应是——Agent 不能自己说"我做完了",必须经过 Sub-Agent(Checker)的独立评估。这是第 9 章的主题——Maker-Checker 分离。但它的物理基础就在 worktree 生命周期里——状态机里的 “done” 不是 Agent 自己设的,是 Checker 评估通过后才设的。

这个类比揭示了工程管理的一个共性——任何长流程的工程,都需要"申请-执行-验收-归档"四阶段。建筑施工、生产制造、软件交付、Loop Agent——四个看似无关的领域,共享同一套管理节奏。这不是巧合,是复杂系统的内在规律。

6.8 沙盒里的"安全失败"设计

隔离的目的不是"让 Agent 不失败",而是"让 Agent 失败了也安全"。这一节谈沙盒里的安全失败设计——这是 Loop 工程从航天工业借来的核心理念。

6.8.1 失败可逆

航天工业有一个铁律——任何动作必须可逆。火箭点火、级分离、姿态调整,每一个动作都要有"撤销"路径。如果某个动作不可逆,那它失败的概率必须低到 10⁻⁹ 以下。

Loop 工程继承了这条铁律。Agent 在沙盒里的每一个动作,都应该可以撤销——代码改了可以 git reset,依赖装了可以删容器,文件删了可以从 git 恢复。不可逆的动作(比如 git push --force 到 main、删数据库)必须被显式禁止。

失败可逆的实现机制:

失败类型 可逆机制 实现方式
代码改错 git reset / revert 在 worktree 内随时 reset
依赖装错 删容器重建 container 模式天然可逆
测试破坏 回滚 worktree git checkout . 丢弃改动
文件误删 git 恢复 git checkout -- <file>
配置改坏 版本控制 配置文件也进 git
部署失败 蓝绿/金丝雀 保留上一版本,快速回滚

💡 Tip:把"不可逆动作"列一个黑名单,在 Agent 调用工具时拦截。rm -rfgit push --forcedrop databasekubectl delete 这些动作,必须经过人工确认才放行。不要相信 prompt 约束——Agent 在第 N 次循环里可能"灵机一动"绕过约束。

6.8.2 资源限额

沙盒的第二道安全网是资源限额——CPU、内存、磁盘、网络、时间,每一项都要有上限。没有限额的沙盒,Agent 跑飞了能拖垮整个宿主机。

Docker 的资源限额参数:

docker run \
  --cpus="2.0" \              # 最多用 2 核
  --memory="4g" \             # 最多 4GB 内存
  --memory-swap="4g" \        # 不许用 swap
  --pids-limit="200" \        # 最多 200 个进程
  --ulimit nofile=1024:1024 \ # 最多 1024 文件描述符
  --ulimit nproc=200:200 \    # 最多 200 进程
  --network-loopback \        # 限制网络(可选)
  --read-only \               # 根文件系统只读(可选)
  --tmpfs /tmp:size=100m \    # /tmp 用 tmpfs,限 100MB
  my-loop-agent

这套限额组合能让一个 Agent 即使跑飞,也最多占 2 核 4GB,不会拖垮宿主机。

资源限额的关键不是上限值,而是有上限。具体值可以根据任务调,但每一项都必须显式设置。Docker 默认是不限额的,这意味着默认的 Agent 容器可以吃光宿主机所有资源。

6.8.3 失败隔离

沙盒的第三道安全网是失败隔离——一个 Agent 失败了,不能传染给其他 Agent。这是隔离的"传染性"维度。

失败隔离的层次:

  1. 数据隔离:Agent A 的失败不影响 Agent B 的数据。worktree 天然满足。
  2. 状态隔离:Agent A 的失败不影响 Agent B 的运行状态。container 满足。
  3. 服务隔离:Agent A 的失败不影响 Loop 系统本身的服务。需要主进程和 Agent 进程分离。
  4. 网络隔离:Agent A 的失败不影响 Agent B 的网络。需要独立的 network namespace。

层次越深,隔离越强,工程复杂度也越高。Loop 工程的实践是至少做到前两层,第三层是大规模部署才需要。

6.8.4 回滚

回滚是失败可逆的极致形式——整个 Agent 的工作可以一键撤销。git 让回滚变得简单:一个 git revertgit reset 就能把分支回到任意历史点。

但回滚在 Loop 工程里有一个陷阱——外部副作用不能回滚。Agent 跑过程中可能发了邮件、改了数据库、调了外部 API。这些副作用一旦发生,git 回滚不了。

工程上的对策是两阶段提交

  1. 第一阶段:Agent 在沙盒内完成所有内部工作,进入"待提交"状态。
  2. 第二阶段:调度器检查 Agent 的工作,确认无外部副作用或外部副作用可接受,再触发实际的外部动作。
   Agent 内部工作           外部副作用
   ┌────────────────┐      ┌────────────────┐
   │ 改代码          │      │ 发邮件          │
   │ 跑测试          │      │ 改数据库        │
   │ 写文档          │      │ 调 API          │
   └────────┬───────┘      └────────▲───────┘
            │                       │
            └─── 待提交 ────► 通过?─┘
                  状态           │
                                是 ──► 提交外部副作用
                                否 ──► 回滚内部工作

两阶段提交把"可逆"和"不可逆"分开。Agent 在沙盒里随便折腾,全是可逆的。等到要触发不可逆动作时,过一道审批。这是数据库工程的老把戏,在 Loop 工程里同样适用。

💡 Tip:把 Agent 工具分成两类——“沙盒内工具"和"外部副作用工具”。前者随便调,后者必须经过两阶段提交。这种分类让 Agent 能大胆实验,又不会一不小心发出尴尬的邮件。

6.8.5 跨领域类比:核电站的"纵深防御"

沙盒的安全失败设计,和核电站的"纵深防御"哲学高度同构。核电站安全工程的核心信念是——任何单一防护都可能失效,必须有多层独立的防护

核电站的纵深防御层次:

  1. 燃料包壳(第一道)
  2. 一回路压力边界(第二道)
  3. 反应堆安全壳(第三道)
  4. 厂房外屏蔽(第四道)
  5. 应急撤离区(第五道)

每一道都是独立的物理屏障,前一道失效了后一道还能挡。这就是"纵深"——不是一道墙修得很厚,而是多道独立的墙。

Loop 工程的沙盒防御层次:

   ┌─────────────────────────────────────┐
   │ 1. Prompt 约束(最弱,靠 Agent 自觉)│
   │   ┌─────────────────────────────┐  │
   │   │ 2. 工具白名单(强,机制约束) │  │
   │   │   ┌─────────────────────┐  │  │
   │   │   │ 3. worktree 隔离    │  │  │
   │   │   │   ┌─────────────┐  │  │  │
   │   │   │   │ 4. container│  │  │  │
   │   │   │   │   ┌─────┐  │  │  │  │
   │   │   │   │   │ 5.  │  │  │  │  │
   │   │   │   │   │资源 │  │  │  │  │
   │   │   │   │   │限额 │  │  │  │  │
   │   │   │   │   └─────┘  │  │  │  │
   │   │   │   └─────────────┘  │  │  │
   │   │   └─────────────────────┘  │  │
   │   └─────────────────────────────┘  │
   └─────────────────────────────────────┘

每一层都是独立的防护,假设上一层失效了仍能挡。Prompt 约束失效了(Agent 想干坏事),工具白名单挡住;白名单漏了(某个工具被滥用),worktree 隔离把损害限制在一个分支;worktree 也失守了(Agent 改了不该改的),container 拦住系统级副作用;container 也失守了(资源耗尽),资源限额保证宿主机不挂。

💡 Tip:设计沙盒时问自己——“如果这一层失效,下一层能挡住吗?“如果答案是不能,说明你的防御没有"纵深”,是单点失效。每一层都应该是"上一层失效的兜底”。

6.8.6 安全失败的代价模型

纵深防御不是免费的。每一层防护都有成本——prompt 约束降低 Agent 灵活性,工具白名单增加工程负担,worktree 增加磁盘管理,container 增加启动开销,资源限额可能误杀合法长任务。

工程师面对的核心权衡是——“安全失败的代价” vs “不安全失败的代价”

失败类型 直接代价 间接代价 概率
Agent 误删代码 1-2 小时回滚 业务延误
Agent 越权访问 凭证泄漏 合规/法律 低但致命
Agent 资源耗尽 宿主机挂 全系统停
Agent 网络外联 数据外泄 信任损失 低但致命
Agent 错误部署 生产事故 用户损失

这张表的关键洞察是——有些失败是"致命但低概率",有些是"中等但中概率"。纵深防御要按概率和代价双重排序,把致命低概率的放在最外层(资源限额、网络白名单),把中等中概率的放在中层(worktree、container),把高概率低代价的放在内层(prompt、工具白名单)。

工程上的"过设计"通常是——把所有层都做得很厚。这会让 Agent 启动慢、调试难、开发累。好的纵深防御是"层次分明,重点突出"——致命层必须厚,便利层可以薄。这种权衡来自对失败代价的清醒计算,不是"安全越多越好"的盲目。

💡 Tip:把"安全失败的代价"和"不安全失败的代价"画成四象限——代价 vs 概率。高代价高概率的必须严防(容器+限额+白名单全上),低代价低概率的可以放任(prompt 提醒即可),中间档按任务特性选。这种分档让安全投入有重点,避免撒胡椒面。

6.9 沙盒的可观测性

沙盒不是黑盒。一个跑在沙盒里的 Agent,它的状态、行为、资源消耗,对 Loop 工程师都应该是可见的。这是"可观测性"——不是监控(看是否在跑),而是理解(看在干什么、为什么)。

沙盒的可观测性有三个维度:

  1. 状态可观测:Agent 当前在哪个阶段、改了哪些文件、跑了哪些命令。
  2. 资源可观测:Agent 用了多少 CPU、内存、磁盘、网络。
  3. 行为可观测:Agent 调了哪些工具、访问了哪些外部地址、产生了哪些副作用。

每个维度都需要不同的采集机制。状态可观测靠 Agent 主动上报(心跳带进度);资源可观测靠 cgroup / docker stats;行为可观测靠工具调用日志和网络代理。

下面是一个最小可观测性仪表盘的布局:

┌─────────────────────────────────────────────────────────────┐
│  Loop Dashboard                                              │
├──────────────┬──────────────┬──────────────┬────────────────┤
│ Active       │ Pending      │ Failed (24h) │ Merged (24h)   │
│ 12           │ 3            │ 2            │ 18             │
├──────────────┴──────────────┴──────────────┴────────────────┤
│  Worktree                       CPU     Mem      Status      │
│  fix-123                        45%     1.2G     Running     │
│  fix-456                        0%      200M     Idle        │
│  refactor-utils                 95%     3.5G     Compiling   │
│  add-login                      10%     800M     Testing     │
├──────────────────────────────────────────────────────────────┤
│  Recent Events                                               │
│  10:23  fix-123      commit "add validation"                 │
│  10:21  refactor-utils  test failed (3 errors)               │
│  10:18  add-login    merge to main (success)                 │
│  10:15  fix-456      timeout (5min) → retrying               │
└──────────────────────────────────────────────────────────────┘

这个仪表盘让 Loop 工程师一眼看出系统的健康状况——有多少 Agent 在跑、有没有卡住的、最近的事件是什么。没有这个仪表盘,Loop 系统是个黑盒,出问题只能去翻日志。

6.10 全章小结

让我们用金句串起这一章的核心结论:

没有沙盒的 Agent 是一匹没拴绳的野马,跑得越快越危险。

引子里的两个 Agent 改同一个文件的事故,揭示了 Loop 工程的第一条铁律——Agent 必须有隔离的工作环境。这不是奢侈品,是 Loop 能跑起来的前提。

隔离的代价是值得的,因为不隔离的代价是灾难。

隔离的三层意义——数据安全、状态独立、并发可扩——递进地构成了 Loop 系统的安全网。每一层都把 Agent 的失败半径缩小一档。

worktree 不是 git 的一个命令,它是 Loop 工程把"分支"变成"工作台"的那次升华。

git worktree 是最轻量的隔离方案,它把抽象的分支指针物理化成了真实的工作目录。一个 Agent 一个 worktree 一个 branch,是 Loop 工程能用 git 跑大规模并发的基础。

失败的 Agent 不可怕,可怕的是失败的 Agent 还把别人成功的成果一起带走。

容器隔离、资源限额、失败隔离、纵深防御,构成沙盒的"安全失败"设计。每一层都是独立的物理屏障,前一道失效了后一道还能挡。这是从核电站安全工程借来的哲学。

沙盒不是关押 AI 的牢笼,是让 AI 敢于尝试的舞台。

这是这一章最终想传达的——沙盒不是限制,是解放。有了沙盒,Agent 才敢于试错、敢于迭代、敢于在错误中学习。没有沙盒的 Agent 必须谨小慎微,每一步都怕踩坏什么;有沙盒的 Agent 可以大胆实验,因为失败可逆。Loop 工程的"循环迭代"哲学,物理基础就在沙盒里。

隔离的层级,决定并发的上限。

从共享工作副本到 worktree 到 container,每往下一层都把一个共享资源私有化,并发上限就上一个数量级。Loop 工程师要做的,是按任务特性选合适的层级,而不是盲目追求最强。

这一章我们用三种隔离方案、四阶段生命周期、五层纵深防御,搭起了 Loop 工程的物理底座。下一章我们会进入 Skills——沙盒之上的"长期记忆"。沙盒让 Agent 有身体,Skills 让 Agent 有经验。

番外篇:进程隔离史——从 chroot 到 namespaces 到 WebAssembly

Loop 工程的沙盒不是凭空发明。它是半个世纪以来,计算机科学家在"如何让一段代码安全地跑在另一段代码旁边"这个问题上反复试错的的结果。这一节我们从 1979 年的 chroot 走到 2025 年的 WebAssembly,看隔离技术的演进脉络,理解为什么 Loop 工程的沙盒是今天这个样子。

一、chroot:1979 年的初代沙盒

1979 年,Bell Labs 的 Bill Joy 在开发 4.2BSD 时,引入了一个系统调用——chroot。这个调用接受一个路径参数,把当前进程的"文件系统根"改成那个路径。从此之后,进程看见的 / 不再是真实的根文件系统,而是 chroot 参数指向的子目录。

chroot 的初衷不是安全,是测试。开发者可以用它在系统里造一个"假根",比如把 /var/test-root 当成 /,在里面跑构建、装依赖,不污染真实系统。这是软件工程第一次有了"隔离环境"的概念。

chroot 作为沙盒有致命缺陷——它是文件系统隔离,不是进程隔离。chroot 里的进程仍然和外面共享 PID namespace,能 kill 别的进程;仍然共享网络栈,能监听宿主机端口;更糟糕的是,chroot 里的 root 用户就是真的 root,能 mknod 创建设备文件、能 mount 文件系统,从而逃逸出 chroot。

1980-1990 年代,最早用 chroot 做"安全沙盒"的是 FTP 服务器。ftpd 把匿名用户 chroot 到 /home/ftp,让他们只能看见那个目录。但很快就有人发现逃逸技巧——如果 chroot 里的进程是 root,可以通过 mknod 创建一个磁盘设备节点,然后 mount 它,从而访问真实文件系统。这就是著名的"chroot 逃逸"漏洞家族。

chroot 的教训是——只隔离文件系统是不够的。一个真正的沙盒必须同时隔离进程、网络、用户、IPC,否则隔离边界可被绕过。这个教训直接催生了后来的 namespace 体系。

二、namespace:2002 年开始的渐进革命

Linux 在 2.4.19 内核(2002 年)引入了第一个 namespace——mount namespace。这是 chroot 的进化版——不仅进程的根文件系统被换掉,连 mount 表都是独立的。chroot 里的进程仍能看见外部的 mount 点,namespace 里完全看不见。

此后 Linux 用了十多年时间,陆续添加了其他 namespace:

时间 namespace 隔离对象
2002 mount 文件系统挂载点
2006 UTS 主机名、域名
2008 IPC System V IPC、POSIX 消息队列
2008 PID 进程号
2009 network 网络栈(接口、路由、端口)
2013 user 用户 ID、组 ID
2016 cgroup cgroup 视图

这张表揭示了 namespace 的本质——它是把"操作系统的全局状态"切成多个独立的副本。每个 namespace 内的进程,看见的是自己那份副本,看不见别的 namespace 的。

namespace 的关键突破是 user namespace(2013 年)。它解决了 chroot 时代最头疼的问题——“chroot 里的 root 是真的 root”。user namespace 让容器内的 root(UID 0)映射到容器外的非特权用户(比如 UID 100000)。这意味着即使容器内进程"是 root",它实际能做的事仍然受限于容器外那个非特权用户的权限。

namespace 的成熟,让"无虚拟机的隔离"成为可能。一个进程可以同时拥有独立的 mount、PID、network、user namespace,效果上接近一台虚拟机,但开销只是几个进程。这就是容器技术的内核基础。

三、cgroups:资源限额的另一条线

namespace 解决了"看不见别人",但没有解决"不让别人吃光资源"。一个 namespace 内的进程仍能跑 fork bomb、占满 CPU、耗尽内存。这个问题由另一条技术线解决——cgroups(control groups)。

cgroups 是 2007 年由 Google 工程师 Paul Menage 和 Rohit Seth 引入 Linux 的。它的核心机制是——把进程分组,对每组施加资源限额。限额维度包括 CPU、内存、IO、PID 数等。

cgroups 和 namespace 是两条独立的技术线,但它们天然互补——namespace 提供"看不见",cgroups 提供"用不多"。两者结合,才构成完整的容器隔离。

Docker 在 2013 年横空出世,本质就是把 namespace + cgroups 包装成易用的 CLI。Docker 的成功不在技术深度(namespace 和 cgroups 都是 Linux 内核已有的),而在工程便利度——一个 docker run 命令就把所有隔离机制配置好了。这是工程化对基础研究的价值放大。

四、虚拟机:另一条更重的路线

在 namespace + cgroups 路线之外,还有一条更重的隔离路线——虚拟机(VM)。VM 的代表是 1999 年 VMware Workstation 引入的 x86 全虚拟化,和 2003 年 Xen 提出的 paravirtualization。

VM 的隔离机制和 namespace 完全不同。namespace 是"同一内核,不同视图"——所有 namespace 共享一个 Linux 内核,只是看不见彼此。VM 是"不同内核"——每个 VM 跑一个完整的操作系统内核,硬件通过 hypervisor 虚拟化。

VM 的隔离强度远超 namespace——一个 VM 内的进程即使拿到 root,也只能影响这个 VM,因为内核都是独立的。但代价是开销大——每个 VM 要跑一个完整内核,内存动辄几百 MB 起步,启动要几秒到几十秒。

Loop 工程基本不用 VM,因为开销太重。但 VM 在某些场景下仍是最佳选择——比如跑不可信代码(沙箱化第三方插件)、跨操作系统隔离(Linux 容器跑不了 Windows 二进制)。AWS Firecracker(2018 年开源)把 VM 启动开销降到 125ms,让 VM 在某些 Loop 场景下重新有竞争力。

五、WebAssembly:2020 年代的新路线

2020 年代,进程隔离史迎来了一个新分支——WebAssembly(Wasm)。

Wasm 最初是浏览器里的"安全的可移植字节码",2017 年成为 W3C 标准。但很快有人意识到,Wasm 的设计天然适合做沙盒——它的指令集是精简的(不能直接调系统调用),内存模型是隔离的(线性内存,不能越界访问),验证机制严格的(类型系统保证内存安全)。

Wasm 作为沙盒的优势是启动开销极低——一个 Wasm runtime 启动一个 Wasm 模块只要微秒级,比 container 快三个数量级。这让它适合超高频的隔离需求——比如每个 HTTP 请求跑一个独立 Wasm 沙盒。

Wasm 的隔离哲学和 namespace 不同。namespace 是"切分操作系统资源",Wasm 是"用一个完全无法访问操作系统的虚拟机"。Wasm 模块默认不能读写文件、不能访问网络、不能调系统调用,所有这些能力必须通过 host 显式提供。这是"白名单"哲学的极致——默认全无,按需注入。

这种哲学和 Loop 工程的"安全失败"设计高度契合。一些前沿的 Loop 系统已经在用 Wasm 跑不可信的 Skills 和插件——一个 Skill 即使恶意,也只能调 host 显式提供的几个函数,无法越界。

六、三条路线的同构与互补

把三条隔离路线画在一张图上:

   隔离强度
       ▲
       │      VM
       │       ●  (完整内核隔离)
       │
       │           container
       │              ●  (namespace + cgroup)
       │
       │
       │     Wasm
       │       ●  (字节码沙盒)
       │
       │
       │  chroot  ●  (仅文件系统)
       │
       └─────────────────────────────► 启动开销
       0       ms        s        min

这张图揭示了隔离技术的一个根本权衡——隔离强度和启动开销成正比。越强的隔离越慢,越弱的隔离越快。从 chroot(极弱极快)到 VM(极强极慢),整个频谱覆盖了所有可能的权衡点。

Loop 工程在不同场景下用不同档位:

  1. worktree(≈ chroot 的子集):纯代码隔离,启动毫秒级。
  2. container(namespace + cgroup):系统隔离,启动秒级。
  3. Wasm(字节码):插件隔离,启动微秒级。
  4. VM(完整内核):不可信代码,启动秒级。

一个成熟的 Loop 系统可能同时用四种沙盒——核心仓库用 worktree(性能)、不可信任务用 VM(安全)、高频插件用 Wasm(开销)、常规任务用 container(默认)。这是隔离技术的"纵深防御"在多个沙盒方案上的体现。

七、隔离史的启示

半个世纪的进程隔离史,给 Loop 工程留下三条经验:

第一,隔离技术的演进是被攻击驱动的。chroot 不够强,因为出了逃逸漏洞,才有了 namespace。namespace 不够强,因为没法限额资源,才有了 cgroups。container 不够细,因为整个文件系统都给容器,才有了 Wasm 的白名单哲学。每一次升级都是对前一代漏洞的回应。Loop 工程师设计沙盒时,应当假设"现在的方案也有未发现的漏洞",纵深防御是必然选择。

第二,隔离强度和便利度成反比。最强的隔离(VM)最不方便,最弱的隔离(chroot)最便利。所有隔离技术的演进,都在试图打破这个反比——Docker 让 container 像命令一样简单,Firecracker 让 VM 启动到 125ms,Wasm runtime 让字节码沙盒像函数调用一样快。但打破是有限的,反比仍然是主旋律。Loop 工程师要做的不是"找最强方案",而是"找任务特性匹配的方案"。

第三,隔离的哲学从黑名单走向白名单。chroot 是黑名单——默认全可见,只隔离文件系统。Wasm 是白名单——默认全不可见,只允许 host 提供的。这个哲学转向,反映了对安全本质的深刻理解——安全不是堵漏洞,是开白名单。Loop 工程的沙盒设计应当走白名单路线——默认 Agent 什么都不能干,逐个开放必要的能力。

这三条经验,让 Loop 工程的沙盒设计有了历史的纵深。我们今天用的 worktree、container、Wasm,不是凭空发明的,是半个世纪计算机科学家在"如何让代码安全地共存"这个问题上的智慧结晶。理解这段历史,让我们在面对新的隔离需求时,能从历史中找答案,而不是从零开始重新发明。

八、隔离史的另一种视角:安全 vs 性能的钟摆

半个世纪的隔离史,还可以从另一个角度看——安全与性能的钟摆。

1980-1990 年代,隔离研究偏重安全。VM、chroot 加固、各种 Mandatory Access Control(MAC)框架层出不穷。这些方案强度高,但开销大,工业界接受度低。

2000-2010 年代,钟摆摆向性能。namespace + cgroups 让"轻量隔离"成为可能,Docker 把它工程化。这一段时间的安全研究在性能压力下退守——大家宁可弱一点,也要快。

2010-2020 年代,钟摆又摆回安全。容器逃逸漏洞频发(runc CVE、containerd CVE),人们重新意识到"轻量隔离"的代价。gVisor、Kata Containers 等"强隔离容器"出现,把 VM 的强度和 container 的便利结合。

2020 年代,钟摆再次摆向性能。Wasm 让"极轻量沙盒"成为可能,启动开销微秒级。但 Wasm 的隔离模型和 container 不同,不是 namespace 的延续,而是白名单哲学的复兴。

   1980 ───── 1990 ───── 2000 ───── 2010 ───── 2020 ───── 2026
     │           │           │           │           │           │
     │   安全    │   性能    │   安全    │   性能    │   ?       │
     │   chroot  │   VM     │   namespace│ gVisor  │   Wasm    │
     │   加固    │   全虚拟 │   Docker  │   Kata   │           │
     │           │           │           │           │           │
     └───────────┴───────────┴───────────┴───────────┴───────────┘
                       钟 摆

这个钟摆告诉我们——没有"完美的隔离",只有"当时的权衡"。每一代隔离技术都是对前一代的回应——前一代太弱就加强,太强就放松。Loop 工程师选沙盒方案时,要意识到自己处在钟摆的哪个位置,避免在"安全回归"的周期里盲目追求性能。

九、Loop 工程的"沙盒组合"实践

回到 Loop 工程本身。一个成熟的 Loop 系统,几乎从不只用一种沙盒。下面是几个常见的组合模式:

模式一:worktree + container(最常见)。worktree 提供代码隔离,container 提供系统隔离。Agent 在 container 内的 worktree 跑,两层防护。这是 HappyClaw、OpenClaw 等系统的默认模式。

模式二:worktree + 宿主机进程(admin 场景)。可信 Agent 跳过 container,直接在宿主机进程里跑,但仍在 worktree 内。这是 HappyClaw admin 主容器的模式,性能优先。

模式三:container-only(不可信场景)。每个 Agent 一个独立 container,容器内没有 worktree,直接 clone 仓库。这种模式适合跑不可信或实验性任务,容器销毁即清。

模式四:Wasm + worktree(高频短任务)。Wasm 沙盒里跑一个 Agent 函数,函数操作的代码版本预先 clone 到 worktree。Wasm 启动开销微秒级,适合每秒触发几十次的高频任务。

这四种模式不是排他的,一个 Loop 系统可以同时支持。任务调度时根据任务特性选模式——可信+长任务用模式一,可信+短任务用模式二,不可信用模式三,超高频用模式四。这种"沙盒组合"是 Loop 工程走向成熟的标志——不是"找一个最好的沙盒",而是"为每种任务准备合适的沙盒"。

十、隔离史给 Loop 工程师的三句话

最后,把隔离史浓缩成三句话,给 Loop 工程师带走:

第一句:你的沙盒方案不是终态。今天用的 container,明天可能被 Wasm 取代;今天的 worktree,明天可能被某种新 git 工作流取代。设计沙盒时留出抽象层,让底层方案可以替换。

第二句:你的沙盒总有不周。半个世纪的隔离技术没有一种是无漏洞的。设计时假设"这一层会失效",下一层兜底。纵深防御不是过度设计,是历史教给我们的常识。

第三句:你的沙盒要服务任务。不是越强越好,是越匹配越好。一个高频短任务被关在重 VM 里,是工程师的耻辱;一个不可信任务跑在共享 worktree 里,是工程师的失职。读懂任务,再选沙盒。


本章核心结论:沙盒是 Loop 工程的物理底座。没有沙盒,Agent 跑得越快越危险。worktree 是最轻量的隔离,container 是最彻底的隔离,namespace 是中间档。三种方案的取舍,取决于任务的可信度、时长、副作用范围。并发 Agent 的锁协议从文件锁到分支锁到合并锁,粒度递减、并发递增。工作树生命周期管理的关键是"不留垃圾"——创建、心跳、回收、合并四阶段缺一不可。沙盒的"安全失败"设计借鉴核电站的纵深防御——prompt 约束、工具白名单、worktree 隔离、container 隔离、资源限额五层独立屏障。隔离史从 chroot 到 namespace 到 Wasm,给 Loop 工程留下了"纵深防御、强度便利反比、白名单哲学"三条经验。下一章,我们走进沙盒之上的 Skills——项目记忆的设计。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐