Kimi AgentENV:大规模 Agent 训练的执行环境

2026 年 7 月,月之暗面、清华 MADSys 和 KVCache.ai 开源了 AgentENV。Kimi K3 的 Agent 后训练就跑在它上面:每条训练轨迹分到一台 Firecracker 微虚拟机,有自己的内核和磁盘;模型思考时虚拟机可以暂停,把内存还给宿主机;做到一半的状态还能当场分叉出最多 16 份,分别往下试。

仓库在 kvcache-ai/AgentENV,介绍文章是 AgentENV 开源介绍,文档在 kvcache-ai.github.io/AgentENV。Kimi 在 K3 开放日 上把它和 MoonEP、FlashKDA 一起列为训练基础设施。它对外提供兼容 E2B 的 HTTP API,现有的 E2B SDK 改个地址就能用。

读完代码会发现,AgentENV 真正的重心在存储上。虚拟机的根文件系统和内存快照,都被做成了一叠只读层加一层可写层,格式是 overlaybd;这些层再经过 Linux 的 ublk 变成块设备,交给 Firecracker。启动快、暂停便宜、分叉几乎不花钱、镜像总量可以超过单机磁盘,都是从这一套设计里来的。所以这篇文章先讲整体架构,然后把大部分篇幅留给 overlaybd 和 ublk。

Agent 训练需要什么样的环境

一次 Agent 尝试需要一台真正能改文件的电脑:模型要读代码、装依赖、起服务、跑测试,下一条命令依赖上一条留下的文件。这台电脑必须隔离得足够好,否则模型会去翻不该看的日志,或者到外网把答案取回来。

规模上的要求更苛刻。训练同时跑着几万条轨迹,环境种类成百上千,镜像总量可以超过任何一台机器的磁盘。大部分时间虚拟机都在等模型生成下一句话,CPU 是闲的,但内存和改过的磁盘还占着。树搜索和多轨迹采样还要求从同一个中间状态再开出几条路。

把这些要求翻译成系统设计,就是四件事:

后三件说的其实是同一个问题:大量虚拟机怎样共享同一份磁盘和内存数据,又各自保留自己改过的那一小部分。AgentENV 对这个问题的回答是分层块设备。

整体架构

AgentENV 分成三部分:多机部署时用的控制面,每台宿主机上的节点进程,以及节点旁边专门管块设备的 ublk 守护进程。

训练框架 / E2B SDK 创建、执行命令、读写文件、暂停、分叉 控制面(Go,多机部署时使用) Gateway :8080 反向代理 · 按沙箱 ID 路由 Scheduler :9090 轮询/随机放置 · 内存里的绑定表 gRPC 宿主机(每台一个节点,Kubernetes 上是特权 DaemonSet) HTTP 转发 心跳:资源、沙箱名单、P2P 地址 AgentENV 节点(Rust) APIAxum · E2B 兼容 Orchestrator状态机 · 回收 Firecracker 运行时网络槽 快照管理本机/对象存储/NFS 模板构建aenv pull · RUN P2Piroh · 可选 预热池网络槽 + FC 进程 Firecracker 微虚拟机 1 envd命令 · 文件 · 进程 vda 工具盘 vdb 用户镜像 客户机内存(File 后端) Firecracker 微虚拟机 2 envd从同一快照恢复 vda 工具盘 vdb 用户镜像 客户机内存(File 后端) uvm-ublk-daemon 所有 ublk 设备 · 每队列一个 io_uring /dev/ublkb1overlaybd:VM1 用户镜像 /dev/ublkb2overlaybd:VM2 用户镜像 /dev/ublkb3(只读)内存层 · 两台共用,引用计数 2 预热设备池swap_state 换镜像 远程 I/O 运行时默认 4 worker 本地块缓存有上限 · 冷数据淘汰 Unix 套接字 RPC:创建 / 快照叠层 / 释放设备 本地层文件 io_uring · .commit 缓存 OCI 镜像仓库 registryfs_v2 · HTTP Range 对象存储 快照与层文件 其他节点 P2P 按内容拉取 按需读取

黑线是控制路径,蓝线是数据路径。节点进程只管生命周期,块设备全部交给 uvm-ublk-daemon;虚拟机看到的是普通的 virtio-blk 盘和一段文件映射的内存,背后都是 overlaybd 层。两台从同一快照恢复的虚拟机共用一个只读内存设备。

控制面:Gateway 和 Scheduler

控制面是 services/ 目录下的两个 Go 进程,和节点分开编译。单机部署时可以不要它们,直接对节点发请求。

Gateway(默认端口 8080)是 HTTP 反向代理,也是所有请求的入口。创建沙箱时,它通过 gRPC 调用 Scheduler 的 Schedule 选一台节点,成功后用 RecordAssignment 记下“这个沙箱在哪台节点上”。之后的暂停、查询等控制请求,按 URL 里的沙箱 ID 查这张表转发。访问沙箱内部服务的流量则靠请求头 x-agentenv-sandbox-id(或 e2b-sandbox-id)路由,也可以用 {端口}-{沙箱 ID}.域名 这样的主机名。

Scheduler(默认端口 9090)负责放置和节点发现。放置策略很朴素,就是轮询或随机,不考虑“哪台机器上已经缓存了这份镜像”。节点名单可以静态配置,也可以监视 Kubernetes 里 headless Service 的 EndpointSlice。每台节点定期发心跳,带上资源用量、正在跑的沙箱名单和可选的 P2P 地址;Scheduler 以心跳里的名单为准,心跳里消失的沙箱,绑定也随之删除。

这张“沙箱 → 节点”的绑定表只存在 Scheduler 的内存里。Scheduler 重启后,要等新的创建请求和下一轮心跳才能把表补齐,这期间已经在跑的沙箱暂时无法路由。文档把它列为当前的已知限制。

节点:一个 Rust 进程管一台机器

每台宿主机上跑一个 Rust 写的节点进程(入口 src/bin/server.rs),需要 /dev/kvm。在 Kubernetes 上,它以特权 DaemonSet 的方式部署,每台宿主机一个 Pod,这样才能用 KVM、网络命名空间和本机缓存。进程内部按职责分成几个子系统:

子系统代码位置职责
API src/api/ Axum 实现的 E2B 兼容 HTTP 接口,以及进沙箱的反向代理
Orchestrator src/orchestrator/ 沙箱状态机、超时回收、重启后恢复暂停中的沙箱
Firecracker 运行时 src/sandbox/firecracker/ 拉起虚拟机、挂磁盘和网络、与客户机里的 envd 通信
存储集成 src/sandbox/ublk/ 通过 ublk 守护进程创建、快照、释放块设备
快照管理 src/snapshot/ 把沙箱状态提交成可再次启动的快照,存到本机、对象存储或共享文件系统
模板构建 src/template/ 在临时沙箱里执行构建步骤,把结果收成模板
P2P src/p2p/ 可选,节点之间按内容互相拉取层文件

Orchestrator 维护每个沙箱的状态:创建中、运行中、暂停中、已暂停,以及做快照和分叉时的中间状态,最后是销毁。过期的沙箱由后台大约每秒扫描一次回收。暂停中的沙箱记录在本机目录里,节点重启后会以暂停状态重新装回。如果开了自动恢复,请求打到一台已暂停的沙箱时会先把它拉起来,并把剩余存活时间至少补到配置的下限(默认 300 秒)。

为了让恢复更快,节点准备了两级预热池。[pool.firecracker] 预先准备好“网络槽 + 已经启动的 Firecracker 进程”,恢复时直接接手,省掉 fork 进程和等待 API 套接字的时间。[pool.block] 是 ublk 守护进程里的块设备池,后面讲 ublk 时再展开。

客户机里:两块盘和一个 envd

每台微虚拟机至少挂两块盘:

内核从工具盘启动,init 脚本先挂上 /dev/vdb,把工具盘 bind mount 到用户文件系统的 /agentenv 下,然后 pivot_root 切进用户文件系统。这样用户镜像里不需要预装任何 AgentENV 的东西,一个普通的 OCI 镜像就能当环境用。

在客户机里接收命令的是 envd(thirdparty/envd/),沿用 E2B 的实现:执行命令、读写文件、管理进程、报告健康状态。宿主机通过 Firecracker 的 MMDS 在 169.254.169.254 上告诉客户机它的沙箱身份和快照身份。

overlaybd:块级别的分层镜像

容器镜像是分层的:基础系统一层,装依赖一层,拷代码一层。Docker 用 overlayfs 把这些层叠成一棵目录树,这是文件级的分层,上层通过同名文件或“白化”标记遮住下层。

微虚拟机用不了这一套。Firecracker 只提供 virtio-blk 块设备,没有 virtio-fs,客户机需要的是一块“盘”,而不是一棵目录树。overlaybd 换了一个角度:在块这一层做分层。每一层不再记录“哪些文件变了”,而是记录“这块虚拟盘上哪些扇区变了,新内容是什么”。把所有层按顺序叠起来,就得到一块完整的虚拟盘,上面是一个普通的 ext4 文件系统。

这个设计来自阿里巴巴的 DADI,发表在 USENIX ATC 2020(DADI: Block-Level Image Service for Agile and Elastic Application Deployment),后来以 overlaybd 的名字开源,成为 containerd 的一种远程镜像格式。AgentENV 在 storage/overlaybd/ 里用 Rust 重新实现了它的层格式和读写路径。

块级分层有几个文件级分层没有的好处:

层文件长什么样

一个 overlaybd 层文件由三部分组成:数据区、索引和头尾。

一个层文件 (只读层封存后的样子) Header LSMT\0\1\2 段 a b 段 c d → 追加 数据区:写入的扇区按到达顺序追加(只读层可用 ZFile 压缩) 索引:16 B × N 条段映射 Trailer 索引偏移 放大其中一条段映射(128 位) offset · 50 位 虚拟盘上的起始扇区 length · 14 扇区数 ≤16383 moffset · 55 位 数据在本层数据区里的位置 z·1 全零 tag · 8 来自哪一层 例子 { offset: 2048, length: 16, moffset: 7680, zeroed: 0, tag: 2 } 虚拟盘第 2048 个扇区(1 MiB 处)起的 16 个扇区(8 KiB),数据在第 2 层数据区第 7680 个扇区处。 { offset: 4096, length: 2048, moffset: 0, zeroed: 1, tag: 0 } 从 2 MiB 处起的 1 MiB 全是零:discard 或写零只记这一条,不占数据区。

数据区只追加、不覆盖;“这段虚拟扇区现在是什么”全由索引决定。所以封存一层、叠一层、按偏移去远端取一段数据,都只是改索引或查索引。

数据区按追加顺序存放这一层写入的扇区内容。索引是一组段映射(segment mapping),每条 16 字节,记录一段连续扇区的映射关系:

头尾(HeaderTrailer)以魔数 LSMT\0\1\2 开头,记录这一层的 UUID、标志位,以及索引和数据区在文件里的偏移。文件开头是头,封存后的只读层末尾还有一个结构相同的尾;打开只读层时先读尾部,就能找到索引。

读写怎么走

叠起来的一组层,底下是若干只读层,最上面是唯一的可写层(upper)。

    读的时候,从上往下找:最上面一层有这段映射,就用它的数据;没有,就往下一层找;所有层都没有,就是从没写过的空洞,返回零。一次读请求可能跨好几段,每段由不同的层提供,overlaybd 把它们拼起来返回。

    如果每次读都逐层查找,层数一多就会变慢。所以 overlaybd 在打开镜像时,会把所有只读层的索引合并成一张索引:对每段虚拟地址只保留最上面那一层的映射,再用层标签记住它来自哪一层。这样读的时候只需要查两张表:可写层的可变索引,以及合并好的只读索引。代码里这两者组合成 ComboIndex。

    写只发生在可写层,而且只追加:新数据写到可写层数据区的末尾,再在可写层的内存索引里插入一条映射,遮住下层的旧数据。客户机发 flush 时,索引才刷到磁盘上。已经发布的只读层永远不会被改动。

    快照由 create_snapshot_and_restack 完成:把当前可写层封存,变成最新的一层只读层,再在原地打开一个空的可写层。整个过程不复制数据,只是改了层的身份。下次快照记录的,就只是相对上一次又改了哪些块。

    这正是分叉便宜的原因:从同一份模板或同一个快照出来的多台虚拟机,共享下面那一叠只读层文件,各自只有一个很薄的可写层。

    压缩:ZFile

    只读层可以压缩,格式叫 ZFile。它不是把整个文件压成一块,而是把原始数据按固定大小(默认 4 KiB)切块,每块单独用 zstd(级别 3)压缩,再附一张跳表,记录每个压缩块在文件里的位置,并为每块存 CRC32C 校验值。

    读某个偏移时,先查跳表找到对应的压缩块,只读这一块、只解这一块。这样压缩和按需读取可以同时成立:远端的层文件是压缩过的,传输量小,但读一个 4 KiB 块不需要先把整层解开。

    层文件从哪来:远程按需读取和本地缓存

    一个 overlaybd 层的字节可能在本机,也可能在远端。overlaybd 把“怎么读一个文件”抽象成 VirtualFile 接口,后端可以替换:

    远端后端前面还有一层本地缓存,以固定大小的块为单位回填。缓存有容量上限,冷数据会被淘汰,所以集群里的镜像总量可以远大于单机磁盘。节点之间还可以开 P2P(基于 iroh),一份层文件被某台节点拉过以后,其他节点可以直接从它那里取,不必都回源。

    客户机读 vdb 1 MiB 处 4 KiB 合并索引 → L1 层,层内偏移 X ZFile 跳表 → 第 k 个压缩块的字节范围 本地块缓存 按固定大小的块回填 L1 层文件在本机缓存里的块: 命中后:解压这一块 → 校验 CRC32C → 返回 4 KiB 其他节点(P2P,iroh) 按内容寻址,先问本机目录再问对等节点 镜像仓库 / 对象存储 GET blob Range: bytes=a-b 远端回填约 20 ms,本地缓存命中约 0.1 ms(仓库里 startup pack 记录器用的经验值)

      镜像进来有两条路。如果仓库里本来就是 overlaybd 格式的镜像,aenv pull 只取清单,不下载数据,沙箱启动后由 registryfs_v2 按需拉取,后台再慢慢把完整的层下载进本地缓存。如果是普通的 OCI 镜像(tar、tar+gzip、tar+zstd),AgentENV 会按顺序把每个 tar 层解进一块 64 GiB 的虚拟盘上的 ext4,每层转换的结果存成一个 .commit 文件,按 OCI 层的摘要做内容寻址缓存。下次遇到同一个层,就不必再下载和转换。私有仓库沿用 Docker 的登录配置。

      仓库里还有两种预取机制。一种是按 I/O 轨迹录制和回放:记下一次启动读了哪些块,下次启动时提前取。另一种是给内存快照用的启动包(startup pack),后面讲内存时再说。

      从层文件到 /dev/vdb:ublk

      到这里,overlaybd 已经能在用户态回答“虚拟盘第 N 个扇区是什么”。但 Firecracker 需要的是宿主机上的一个块设备或文件路径。把用户态程序变成块设备的桥,就是 ublk。

      ublk 是什么

      ublk(userspace block device)是 Linux 6.0 引入的用户态块设备框架。内核里的 ublk_drv 驱动负责对外呈现一个真正的块设备 /dev/ublkbN;所有发到这个设备上的读写,驱动不自己处理,而是转交给用户态的一个服务进程,由它决定数据从哪来、写到哪去。

      在它之前,也有把块设备逻辑放到用户态的办法,比如 NBD(经过一个 socket 转发)和 TCMU(经过 SCSI 目标框架)。ublk 的不同在于它直接建立在 io_uring 上:内核和用户态之间不走 socket,而是通过 io_uring 的 URING_CMD 命令和一块共享内存交换请求,开销小得多。

      一个 ublk 设备的生命周期是这样的:

      1. 服务进程通过 io_uring 向 /dev/ublk-control 发 ADD 命令,描述设备的队列数、队列深度和容量。
      1. 内核分配设备号,创建控制设备 /dev/ublkcN 和块设备 /dev/ublkbN。
      1. 服务进程把 /dev/ublkcN 上的一块 I/O 描述符数组 mmap 进来,每个队列起一个线程、一个自己的 io_uring,向内核提交一批“取请求”命令,然后等待。
      1. 有人读写 /dev/ublkbN 时,内核把请求的操作类型、起始扇区和长度写进描述符数组,并完成对应的那条“取请求”命令,唤醒服务进程。
      1. 服务进程处理完,提交“完成并取下一个”命令,内核据此结束原来的块请求。

      AgentENV 自己写了一个基于 tokio 的 ublk 服务端(storage/ublk/),每个设备的处理逻辑是一个 OverlaybdTarget:它把 ublk 送来的读、写、flush、discard、写零请求,分别翻译成对 overlaybd ImageFile 的读、追加写、索引落盘和零段映射。数据缓冲区可以走传统的用户态内存,也可以用 io_uring 的稀疏缓冲表做零拷贝,文档写的是内核 6.8 以上可用。

      uvm-ublk-daemon:所有设备放在一个进程里

      AgentENV 没有让节点进程直接管理 ublk 设备,而是起了一个长期运行的守护进程 uvm-ublk-daemon,一台机器上所有的 ublk 设备都归它管。节点通过 Unix 套接字向它发 RPC:创建根文件系统设备、创建额外磁盘、从预热池里取设备、快照时重新叠层、改变容量、删除设备。

      这样拆有几个理由。一是 io_uring 和设备所有权集中在一个进程里,节点进程只需要拿着一个客户端,不用碰底层细节。二是远程 I/O 可以集中调度:从对象存储或镜像仓库读数据的 HTTP 连接,统一放在一个专用的运行时上(默认 4 个 worker),而不是绑在某个设备自己的运行时上。这是为了避开一个实际踩过的坑:opendal 和 reqwest 会把连接池里的任务钉在当初轮询它们的运行时上,设备拆除时这个运行时被销毁,别的设备还在用的连接也会跟着断掉。

      三是可以做块设备预热池。ublk 设备的创建本身有开销,守护进程会预先建好一批设备;需要时通过 swap_state 把设备背后的 overlaybd 镜像原子地换掉,设备号不变,不用拆了重建。因为设备要和具体的镜像、容量对应,预热池只能在请求路径上异步补充,不能像网络槽那样提前准备一批通用的。

      交给 Firecracker

      块设备准备好之后,接到 Firecracker 上就很直接了:

      1. 节点在这个沙箱的 Firecracker 工作目录里建一个符号链接 user-rootfs,指向 /dev/ublkbN。
      1. 通过 Firecracker 的 API 调用 PUT /drives/...,把 user-rootfs 注册成第二块盘:可写,direct 打开(以 O_DIRECT 打开设备,绕过宿主机页缓存),I/O 引擎用 Async(Firecracker 内部用 io_uring 发 I/O),磁盘限速在启动前就配好。工具盘则以只读、Sync 引擎挂成第一块盘。
      1. 虚拟机启动后,客户机内核看到 /dev/vda 和 /dev/vdb 两块 virtio-blk 盘。

      宿主机的 udev 规则还会把 ublkb* 设备的 I/O 调度器设成 none:真正的调度和合并在 overlaybd 里做,内核这一层不需要再排队。

      于是客户机里的一次写,要穿过这样一条链路:

      客户机(微虚拟机内核) 进程 write() + fsync() 改 /app/main.py ext4 文件 → 若干扇区 virtio-blk 驱动 /dev/vdb 请求放进 virtqueue Firecracker 进程(宿主机用户态) virtio-blk 设备模拟 取出请求描述符 io_uring 写 user-rootfs 符号链接 → /dev/ublkb1 · O_DIRECT · Async 引擎 宿主机内核 块层 调度器 none,不再排队 ublk_drv 把请求交给用户态 I/O 描述符数组 /dev/ublkc1 上 mmap 共享 uvm-ublk-daemon(宿主机用户态) 队列线程 + 自己的 io_uring 挂着的 FETCH 命令完成了 OverlaybdTarget WRITE → ImageFile 写 overlaybd ImageFile 追加数据 · 改内存索引 本地盘(沙箱工作目录) overlaybd/upper.data · upper.index 可写层;下面的只读层是共享的层文件

        这条链路看起来很长,但每一段都是异步的、基于 io_uring 的,没有额外的数据中转进程。它换来的是:Firecracker 不需要知道 overlaybd 的存在,它看到的只是一个普通的块设备;而 overlaybd 也不需要嵌进 Firecracker,可以独立升级、独立做缓存和预取。

        内存也走同一条路

        暂停一台虚拟机,最朴素的做法是把整份客户机内存写到磁盘上,恢复时再整份读回来。8 GB 的虚拟机暂停一次就要写 8 GB,几万台空闲环境,这个代价太大了。

        AgentENV 的做法是把内存快照也做成 overlaybd 层。

        暂停时,Firecracker 先做一份只含设备状态的差异快照。节点再向 Firecracker 询问哪些内存范围是脏的、哪些页实际存在,然后用 process_vm_readv 直接从 Firecracker 进程里把这些页读出来,写成一层新的 overlaybd 内存层。之前快照留下的内存层叠在下面,这一层只覆盖这次改过的范围。

        恢复时,节点用这叠内存层创建一个只读的 ublk 设备,作为 File 类型的内存后端交给 Firecracker。Firecracker 以私有映射的方式把这个设备 mmap 成客户机内存:读的时候直接读设备,第一次写某一页时,内核把这一页复制成虚拟机私有的匿名页,底层设备始终不变。

          关键在于共享。从同一个快照恢复的多台沙箱,共用同一个内存 ublk 设备,靠引用计数管理生命周期。设备上读过的页会留在宿主机的页缓存里,所有虚拟机都能命中;只有被写过的页,才在每台虚拟机里各占一份匿名内存。和根文件系统那块盘正好相反:用户镜像以 O_DIRECT 打开,避免客户机和宿主机各缓存一份;内存设备则恰恰要靠宿主机页缓存在虚拟机之间共享。

          为了让刚恢复的虚拟机少等远端数据,仓库里还有启动包:拍完快照后,让一台一次性的虚拟机从这份快照恢复一次,记录内存设备上每一页第一次被读到的顺序,再把这些页映射到最终层文件的 256 KiB 块上,生成一份预取清单。之后真正的恢复可以按清单提前把这些块拉进缓存。

          E2B 的编排器用 userfaultfd 在缺页时从内存文件里填页,AgentENV 的仓库里也有一份这样的实现(storage/uffd-core/),但不在当前构建里,文档说保留作参考。换成块设备的好处是,内存快照和磁盘快照用的是同一套层格式、同一个守护进程、同一套缓存和 P2P。

          虚拟机跑久了,客户机里会堆积文件缓存。AgentENV 用 DAMON 找出冷页,再通过 Firecracker 的 balloon 设备把空闲内存还给宿主机。

          快照、暂停与分叉

          有了分层的磁盘和内存,几个生命周期操作就都落在了同一套机制上:

          分叉不能跨机器,因为它共享的正是这台机器上的层文件和页缓存。官方博客把分叉对应到多轨迹采样和树搜索:从同一个中间状态出发,同时试几种做法。

          模板构建也复用这套机制。aenv pull 把一个 OCI 镜像变成模板;更复杂的环境可以写类似 Dockerfile 的 RUN、ENV、WORKDIR 步骤,构建器从基础环境拉起一台临时沙箱,逐条执行,就绪检查通过后暂停,把这一刻的状态提交成新模板。

          网络:每个沙箱一个槽

          网络不是这篇文章的重点,简单说一下。每个运行中的沙箱有自己的网络命名空间,Firecracker 的 TAP 设备放在里面,命名空间再通过一对 veth 接回宿主机。客户机地址固定是 169.254.0.21,宿主机侧从 10.12.0.0/16 里为每个槽切一个 /31。内部网段在任何用户策略之前就被拒绝,所以一台沙箱碰不到另一台。网络槽也能放回预热池,下一个沙箱只需要换掉用户规则。

          出站策略写在命名空间里的 iptables 链上。需要按域名控制时,命名空间把 TCP 80 和 443 重定向到本命名空间内的一个透明代理,代理从 HTTP Host 或 TLS SNI 里取出域名,按策略决定放行还是拒绝。它不解密 TLS,也不改请求头。

          数字

          官方博客统计了高负载期间 70 台节点、大约 22.5 万个环境:申请量和宿主机实际用量之比,CPU 平均 27.9 倍(最低 14.5 倍),内存平均 9.6 倍(最低 5.7 倍)。一台 128 核、512 GB 的机器,在每个环境 2 vCPU / 4 GB 或 4 vCPU / 8 GB 的配置下,可以稳定同时运行 400 个;生产集群最多到过大约 3 万个。

          延迟方面,博客写的是:基于模板的启动或恢复最快 49 毫秒,创建快照最快 133 毫秒,并发分叉摊到每个子环境最快 122 毫秒。README 给的是另一组口径:快照启动或恢复在 50 毫秒内,暂停和增量快照都在 100 毫秒内。两组数字都是项目自己测的,测量条件不同,不宜混在一起比较。

          这些数字背后,就是前面讲的那几件事:空闲虚拟机的内存还给宿主机,同一份快照的干净页在页缓存里共享,磁盘只存改过的块。

          和其他方案的对比

          AgentENV其他方案
          隔离 只有 Firecracker 微虚拟机(x86_64 上可选 PVM) DSec 按任务在函数调用、容器、Firecracker、QEMU 之间选;OpenSandbox 可选 runc、gVisor、Kata、Firecracker
          磁盘共享 overlaybd 分层块设备,经 ublk 呈现成一块盘 CubeSandbox 用 XFS reflink;DSec 的容器用 EROFS + overlayfs,数据在 3FS
          内存共享 同一快照共用一个只读内存 ublk 设备,写时复制成匿名页 E2B 用 userfaultfd;DSec 用 virtio-pmem 共享只读镜像的页缓存
          分叉 同节点,一次最多 16 个 多数方案没有这个原语
          调度 轮询或随机 DSec 抽样后选最空的节点;OpenSandbox 的 FastSandbox 优先选已缓存镜像的节点

          值得一提的是,DeepSeek 的 DSec 论文写明,它的 microVM 可写盘用的也是 overlaybd 加 ublk,Rust 实现就在 AgentENV 仓库的 storage/overlaybd 里。两边共享的是这套块存储代码,控制面和调度各不相同。

          参考