三家机构、三个已发布的系统、同一个诊断:耗时间的是环境,不是 agent。这是对 DeepSeek DSec、腾讯微信 WeEnv、月之暗面 AgentENV 的逐条对照拆解。
项目 机构 材料形态 时间 DSec(DeepSeek Elastic Compute) DeepSeek-AI + 清华大学 技术报告,arXiv:2609.22978v1 [cs.DC],31 页 2026-09-19 WeEnv 腾讯微信 AI(WeChat AI, Tencent) 论文,arXiv:2609.30766v1 [cs.DC],16 页 2026-09-25 AgentENV(AENV) 月之暗面 Moonshot AI 开源代码库,GitHub org kvcache-ai首次开源提交 2026-07-25
三个项目要解决的问题,在 WeEnv 里有一个最好的名字:environment tax(环境税)。
微信团队拿生产级框架 Slime 做了一次迭代时间拆解(WeEnv §1、§3.1,Figure 2):
| 后端 | Train | Env init | Task exec | Others |
|---|---|---|---|---|
| E2B(microVM) | 24.0% | 53.4% | 20.2% | 2.5% |
| Docker(容器) | 15.4% | 39.5% | 44.0% | 1.1% |
原文措辞:
"initialization alone accounts for 53.4% of the iteration time with virtual machines (E2B) and 39.5% with containers (Docker), 2.6× the task execution itself with E2B and roughly on par with it under Docker, while training takes merely 15.4%–24.0%."
在 E2B 上,环境初始化吃掉 53.4% 的迭代时间,是任务执行的 2.6 倍、训练本身的 2 倍多。训练只占 24.0%。
再补一个视角:rollout(= 初始化 + 任务执行)整体占 E2B 的 73.6%、Docker 的 83.5%。也就是说,这条流水线绝大部分时间不在学习,也不在解题,而在准备解题的地方。
DSec 没有做"迭代时间占比"这种拆解,它量的是工作负载的形状(§4,数据采样于 2026 年初一周):
爆发性创建。 单任务创建的沙箱数(Fig. 2):
| 每任务创建的沙箱数 | p50 | p90 | p99 |
|---|---|---|---|
| 容器 | 2,528 | 7,969 | 16,388 |
| microVM | 352 | 1,835 | 4,044 |
"The largest production jobs can request up to 32K sandboxes. These requests arrive within a short window because the training or evaluation batch cannot use an instance until its environment is ready."
极稀疏的访问。 容器镜像按语言统计的运行期实际访问比例(Tab. 3):
| 镜像类型 | C++ | Go | Java | JavaScript | Python |
|---|---|---|---|---|---|
| 访问到的数据占比 | 8.7% | 13.3% | 9.2% | 4.2% | 6.0% |
| 镜像大小 | 4.9 GB | 4.1 GB | 12.1 GB | 9.6 GB | 6.0 GB |
绝大多数镜像数据从头到尾没人碰。
低 fanout。 单个任务内镜像复用度(Fig. 8,统计超过 150 万容器 / 39 万 microVM):容器镜像 p50 = 3、p90 = 28;microVM 镜像 p50 = 1、p90 = 3。DSec 的结论很直白:工作集太杂,单节点缓存根本吸收不了,所以每次爆发都必然触发镜像拉取。
稀疏的 CPU + 长寿命的内存。 Fig. 5:约 90% 的容器和 microVM 沙箱平均只用到申请 CPU 的 5% 以内——这是超分的天然理由。Fig. 7:容器寿命 p50 = 17.4 分钟、p99 = 231.5 分钟;microVM p50 = 15.5 分钟、p99 = 213.9 分钟。CPU 空转,但内存和状态一直钉着。
把两边的测量并排放,会发现一件很有说服力的事——两家公司在互不知情的情况下,观察到了同一组结构性事实:
| 结构性事实 | DeepSeek(DSec §4) | 微信(WeEnv §3) |
|---|---|---|
| 访问稀疏 | 运行期只碰 4.2%–13.3% 的镜像数据 | 中位数只读 artifact 的 0.81%,p99 仅 6.02%,无任务超过 7% |
| 爆发式创建 | 单任务最多 32K 沙箱;容器 p99 = 16,388 | 一个 batch 会分两波 admission,初始化在关键路径上 |
| CPU 需求长尾 | 约 90% 沙箱平均用 ≤5% 申请 CPU | 峰值 CPU p50 = 0.99 核 → p99 = 68.3 核,近 70× 跨度 |
| 内存需求长尾 | 内存回收是密度瓶颈 | 92% 任务峰值 < 0.2 GiB,p99 却跳到 7.0 GiB |
| 需求剧烈突变 | 工具调用期 CPU 断续,内存持续驻留 | 单任务 CPU 需求一秒内暴涨 47× |
这张表是全文最重要的论证基础。它说明:"环境税"不是某家实现的缺陷,而是 agentic RL 这个工作负载的结构性属性。 所以三家的分歧不在于"要不要解决",而在于"先砍哪一刀"。
这是全文的骨架,先给结论:
共同病灶:环境税
(爆发创建 · 访问稀疏 · 需求长尾 · 状态驻留)
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
DeepSeek/DSec 微信/WeEnv 月之暗面/AgentENV
「平台化 + 密度」 「全生命周期」 「启动 + 状态」
│ │ │
赌注:一个平台 赌注:把 packaging 赌注:把"已存在的
管四种隔离后端, 到 provisioning 的 镜像"变成"跑起来的
与 RL 训练器协同 整条链都管起来 环境",并让它可快照、
承担集群弹性 可 fork、可暂停
│ │ │
稀缺资源: 稀缺资源: 稀缺资源:
节点密度 & 集群弹性 迭代时间(税本身) 启动延迟 & 状态可移植性
│ │ │
证据:160 节点/ 证据:init 占比 证据:README 宣称的
300 万沙箱/天 53.4% → 9.1% <50ms / <100ms / 9.6×
下面三节把三道窄门逐一拆开。
DSec 是技术报告(不是论文投稿),作者署名 DeepSeek-AI 与清华大学,第一作者 Jialiang Huang 是清华博士生、在 DeepSeek-AI 实习期间完成该工作,通讯作者 Liyue Zhang。它服务的是 DeepSeek V3.2 到 V4.1 全部 RL 训练与评测的沙箱工作负载(§6 开头)。
它的自我定位非常明确,而且刻意不做单一抽象:
"This interface is intentionally not a full semantic abstraction over all backends. Function calls, containers, microVMs, and full VMs have different startup costs, isolation boundaries, filesystem semantics, and operating-system capabilities. libdsec provides a unified access path and a similar operational model, but the caller remains responsible for selecting a backend that matches the workload."(§2.1)
这是三家里面唯一一个"不押注单一隔离机制"的设计。
DSec §2.4 给了一个 scale unit 的实际数字:
| 指标 | 数值 |
|---|---|
| 节点 | ~160 个 CPU 节点 |
| 核心 | 30K cores |
| DRAM | ~250 TB |
| 镜像/层 | 数 PB |
| 日沙箱实例 | ~300 万 |
| 峰值并发 | ~380,000 |
| 创建速率 | >5,000 实例/秒 |
以及密度操作点(§4.3):生产上稳定跑过单节点 3,200 容器或 800 microVM(一日采样峰值是 1,048 容器 / 524 microVM)。
这个量级解释了 DSec 所有设计的激进程度。当创建速率是 5,000/s 时,"拉全量镜像"不是慢,是结构性错误。
DSec §3 的架构分成三块,每一块都刻意做成无状态可替换:
集群级服务(控制面)
sandbox ID 里编码了所属 edge,因此任意 apiserver 实例都能直接转发 —— 这才是它能水平扩展的原因。节点运行时
一个容易被忽略但很有信息量的设计选择:FnCall 和容器都跑在 QEMU/libvirt VM 里,而不是直接跑在宿主机上(§3.3)。
"FnCall and containers run inside QEMU/libvirt VMs rather than directly on the host. The VM provides an isolated kernel and network stack and serves as an additional security boundary between untrusted containers and the bare metal."
也就是容器外面再套一层 VM——牺牲密度换取"不可信容器不直接接触裸金属"。这种"宁可多一层"的取向,在 §6.4 的安全故事里会再次出现。
问题(§4.2):一个沙箱的内容可以拆成三部分——base image(OS 级依赖)、workspace(任务代码仓库 + 任务专属依赖)、toolkit(频繁更新的工具,例如 DeepSeek Harness)。
单周生产数据(Tab. 2):
| 后端 | base images | workspaces | snapshots | 聚合大小 |
|---|---|---|---|---|
| 容器 | 11,266 | 102,171 | – | 82.8 TB |
| microVM | 2 | 53,590 | 4,889 | 50.9 TB |
另有 103 个 toolkit,67.8% 的沙箱需要 base image 之外的 workspace 或 toolkit。
如果把它们焊成一个 OCI 镜像:维护 M 个 base、N 个 workspace、K 个 toolkit 时,升级 m 个 base 要重建 O(m·N),升级 k 个 toolkit 要 O(k·N)。原文的例子很直观:升级 Toolkit T1,每个包含它的单体镜像都要重建,哪怕 base 和 workspace 一个字节没变。
DSec 的答法(§5.1):把三者当独立生命周期的逻辑层,用 overlayfs 在创建时动态拼栈。base 在底,workspace 作为只读层插在上面,toolkit 叠在最上。
实现上最"脏"也最有意思的一处:改了 dockerd。
"We modify the open-source Docker daemon (based on the Moby project) to dynamically insert EROFS-backed lower layers at container creation time. Specifically, we pass the path of a pre-mounted EROFS layer and insert it into the overlayfs stack before mounting, placing it as the topmost lower layer so it can override files in layers below. This change is minimal, requiring only 30 lines of Go code."(§7)
30 行 Go 换来 O(m·N)/O(k·N) → O(m)/O(k)。这是全文性价比最高的一处工程改动。
为什么用 EROFS 而不是 ext4/XFS:EROFS 是面向只读数据设计的,省掉写相关记账,布局更紧凑,支持压缩且保留随机访问。关键对比:
"Unlike a tar.gz archive, EROFS can read and decompress only the compressed blocks covering the requested data, so the complete image does not have to be transferred and unpacked before use."
实测收益(§8.3):同一份 workspace + toolkit,tar.gz 逐沙箱解压 vs EROFS 直接挂载:
| 方案 | 端到端完成时间 | 磁盘写流量 | 峰值写吞吐 |
|---|---|---|---|
| tar.gz 解压 | 79 min | ~5.5× | ~3.4× |
| EROFS 挂载 | 45 min(1.76× 加速) | 1× | 1× |
注意原文对"EROFS 峰值 CPU 更高"的解释——不是开销更大,而是更多沙箱提前进入工具调用阶段并发执行。这个自我澄清很诚实。
microVM 侧拿到了同一套组合模型(§5.1 末):base 和 toolkit 打包成独立版本化的 EROFS 镜像,以只读块设备暴露给 guest;guest 内 rootfs 用 overlayfs,EROFS 挂载点做 lower,ext4 可写盘上一个目录做 upper。
DSec 的关键判断是"不另建镜像分发层"(§5.3):
"Existing on-demand image distribution systems often combine a container registry with peer-to-peer delivery to prevent the registry from becoming a bottleneck. We instead host images on 3FS, which already supports our production training workloads at scale. This choice reuses the existing storage infrastructure and avoids deploying a separate image-distribution layer."
但 3FS 有个必须迁就的特性:大顺序读写吞吐极高,小随机 I/O 极差。这条不对称性直接决定了三条设计原则:
容器路径用 EROFS 的三个特性承接:EROFS 严格只读 → 所有运行期写自然落到本地 overlayfs upper;EROFS 走 buffered I/O 按需供给,内核 readahead 把相邻块合并成大请求;EROFS 的 multi-device 模式把元数据和文件数据分开 → DSec 只把元数据下载到 worker 本地盘,文件数据留在 3FS,于是路径查找不产生远程 I/O。
它还顺手做了一件"减少挂载数"的事:把尺寸阈值(约 3 GB)内的连续层离线坍缩成一对元数据/数据 EROFS 镜像,同时保留 overlayfs 的 whiteout 语义以正确表达文件删除。
microVM 路径不一样,而且原因很硬(§5.3):
"Docker's overlay2 driver cannot use an overlayfs-backed data directory. An alternative would be to export the host-mounted filesystem to the guest via virtio-fs, but our Firecracker backend does not support this interface."
所以 microVM 走块级:只读 base/toolkit 层仍用 EROFS,而可写 ext4 盘走 OverlayBD,经 ublk 暴露。代价写在明面上:"Unlike the multi-device EROFS path, ext4 metadata remains embedded in the block image, so metadata reads can trigger remote I/O." 缓解手段是 256 KiB 粒度取块 + 本地二级文件系统缓存——chunk 被 page cache 淘汰后仍在本地缓存里,不必回 3FS。
实测收益(§8.2):10 节点、8,192 容器的真实评测负载:
| 方案 | 完成时间 | 累积磁盘写 |
|---|---|---|
| Docker 冷拉(eager) | >60 min | >1,600 GB/节点 |
| EROFS 按需 | ~35 min | ~700 GB(少 57%) |
| Docker 全本地缓存(上界) | ~35 min | ~600 GB |
按需加载打平了"全部本地预缓存"这个理论上界,同时比 eager 快 1.71×、写入少 57%。这个结果比"快了多少"更有意义:它说明按需路径没有引入额外惩罚。
这一节是 DSec 里最"只有做过生产才知道"的部分。
内存(§5.2 / §8.4)。microVM 有两处独立的内存浪费:
两个互补的答案:
代价也讲清楚了:pmem 需要 guest 为整个范围分配 struct page,4 KiB 页 + 64 字节 struct page 时元数据占 pmem 容量的 1/64——"a 128 GB pmem device requires 2 GB of guest RAM for this metadata." 而且冷访问可能触发同步缺页。
madvise(MADV_DONTNEED) 释放。但散落的 file page 凑不出高阶块,于是用 DAMON 采样访问位,找出超过年龄阈值的冷 file page 并走内核 reclaim 路径驱逐,回收后由 buddy 合并成高阶块,满足 FPR 的要求。实测时间积分内存消耗降 21.2%,CPU 开销可忽略。两机制互补:pmem 消重复,DAMON+FPR 回收空闲。而组合后瞬时峰值 CPU 从 26.5% 升到 41.4%——DSec 没有掩饰,并给出运维建议:"In CPU-constrained deployments, operators may prefer to enable FPR alone and retain virtio-blk."
CPU QoS(§5.2 / §8.5)。问题场景很具体:有些任务有严格每步延迟预算(例如固定每步时限的棋类 agent),而"给 best-effort 降优先级"根本不够——因为 BE 和 LS 可能跑在同一个物理核的两个 SMT 兄弟线程上,仍然抢执行资源。
两层策略:
SCHED_IDLE,LS 可运行时让出 CPU;实测(§8.5,50% BE 负载下的 LS 每步延迟):
| 配置 | 延迟膨胀 |
|---|---|
| 无保护 baseline | 45.2% |
仅 SCHED_IDLE | 最高只改善 3.4% |
SCHED_IDLE + core scheduling | 17.3% |
"仅 SCHED_IDLE 最高只改善 3.4%"这个数字很有价值——它证明单纯的优先级调整对 SMT 干扰几乎无效,必须动 core scheduling。
残余的 17.3% 也解释清楚了:主要来自多核高负载下 turbo 频率下降、内存带宽与共享 LLC 竞争,core scheduling 管不到;并明确说不做内存带宽隔离,因为"already tolerable"。
如果只记住 DSec 一件事,应该是这件:它把沙箱平台和 RL 训练器当成一个系统来设计。这是另外两家都没有做的。
(a)让 agent 自己造环境(§6.1):标题就是 "Build environments of Agents, by Agents, for Agents"。核心接口叫 pack_diff:
"at any point, an agent can checkpoint a sandbox by taking an incremental disk snapshot, which can later be restored as a new sandbox. This checkpoint-and-restore interface turns an interactive session directly into a reusable environment, allowing environments to be built, validated, and consumed on the same infrastructure without a separate image-building pipeline."
注意这里的位置关系:WeEnv 和 DSec 都在解决"环境打包",但解法方向相反。WeEnv 是离线把组件拆成 layer group 再组合;DSec 是让 agent 在线交互式地把一个跑起来的沙箱直接冻成环境。前者优化"发布的组合爆炸",后者优化"根本没有构建流水线"。
配套的治理措施同样具体:给 agent 的构建规则约束(限制对共享基础设施的性能影响)、一个内部平台做质检并导出标准格式、builder 与 runtime 使用不同账号、打包前清除可写层里的构建期残留数据,避免参考答案被带进镜像。
(b)把 rollout 从可抢占的 GPU 池里拆出来(§6.2):旧版把 agent loop 和 model serving、RL 框架一起放在可抢占的 GPU 训练 pod 里。GPU 作业被抢占时,agent loop 丢了,沙箱还在,于是恢复只能靠 command log 重放来对账(已完成的操作复用记录结果,避免非幂等命令的重复副作用)。
从 DeepSeek-V4.1 起,rollout 执行被搬到 DSec 上,拆成两个组件:
两者都跑在可抢占 GPU 池之外。结果是 rollout 生命周期与 trainer 生命周期解耦,worker container + agent sandbox 共同持有完整 rollout 状态并作为唯一真源,被抢占的 GPU 作业重连后不需要再靠 command-log 重放重建执行。
(c)暂停即回收(§6.3):GPU 作业被抢占时,RL 框架主动给所有相关沙箱发 pause,DSec 回收内存但保留执行状态;对暂停态沙箱的任何后续请求会透明地先 resume 再执行。两条路径不同:
| 后端 | pause | resume |
|---|---|---|
| 容器 | docker pause 冻结进程树 → 开 memory.swap.max → memory.reclaim 主动回收匿名页与文件页 | 对进程内存映射 MADV_WILLNEED 异步预取 → docker unpause |
| microVM | 快照内存与执行状态 → 终止 Firecracker 进程释放运行时内存 | 起新进程 → 恢复快照继续执行 |
注意 microVM 的 pause 是真的把进程杀掉,内存靠快照落盘保留——这是"暂停几乎不占内存"的来源,也是后面 AgentENV 用同一招的对照点。
DSec §6.4 是全篇最"生产味"的一节,标题就叫 "Agent Misbehavior and System Failures"。它把风险分两类:任务看起来通过了,但答案来自非预期渠道(破坏训练/评测有效性),以及agent 动作破坏环境(危及本任务或同机其他任务)。
原文列举的失败案例,每一条都很具体:
在沙箱内找答案
/bin/bash 来绕过检查或向后续 shell 会话注入命令;XFS_IOC_SWAPEXT——一个交换两个文件数据 extent 映射的 ioctl,目的是让受保护文件的内容可以通过另一个 fd 读到。这次尝试损坏了 XFS 元数据并导致文件系统 shutdown。在沙箱外找参考实现
普通命令打崩基础设施
grep,遍历 /proc,读 /proc/kpagecgroup,触发内核 bug 把内核打崩;yes,而 chronus 会把 stdout 缓存下来供用户异步获取,于是捕获了数十 GB 的 stdout。缓解措施(§6.5)只承认自己解决了一部分,标题就写明 "These controls address only part of the problem and do not provide a general defense against destructive behavior such as triggering kernel bugs."
network_rules={"npm": False, "pypi": True}。这一节的战略含义:DSec 的对手模型是对抗性的。WeEnv 也用机制应对(独立的干净评测环境防 reward hacking),但那是架构层面的隔离;AgentENV 目前没有对等的安全叙事。这个差异在第五节展开。
WeEnv 出自 腾讯微信 AI(WeChat AI, Tencent),作者 Yang Yu、Jing Lei、Shaoxun Zeng(三人共同一作,Shaoxun Zeng 为通讯作者),论文 arXiv:2609.30766v1,16 页。结论只有一句:"WeEnv is deployed for agentic RL at WeChat."——已上线,但没有披露规模数字。
三家里面,WeEnv 的立场最"管理学派":
"The root cause is the lack of a full-lifecycle solution to environment management." "WeEnv manages environments across packaging, initialization, and provisioning."
它同时做了两个别人没做的选择:
WeEnv builds on container tooling: each environment is instantiated as a container,§5)。不碰 microVM,也不碰全 VM。外加一个方法论上的独特点:它是唯一一个把 AgentENV 当成 baseline 实测的项目。这既让它成为三者中唯一有跨机构实测数据的来源,也带来必须标注的公平性问题(见 4.7 与第七节)。
WeEnv §3 的动机分析写得非常干净,三段各自有量化。
(1)Packaging 是个两难(§3.2)
| 方案 | 做法 | 代价 |
|---|---|---|
| Dynamic assembly(动态装配) | artifact 排除演进组件,初始化时现装 | 每个环境多花约 20 秒安装 harness(约占初始化的 13%) |
| Static bundling(静态打包) | 全量打进单体 artifact,初始化零安装 | 组合爆炸:T 任务 × H harness 变体 = T×H 个预打包镜像 |
静态打包的痛点在原文里被点得很透:"Worse, the harness is not the only fast-evolving component; as described in §2, the evaluator is also updated routinely against newly discovered reward hacking. Each such change touches every affected image, which can take days and lengthens the experimentation cycle of agentic RL."
注意这里出现了两家机构的隐性呼应:微信说"evaluator 要频繁更新以对抗 reward hacking";DeepSeek 则用了整整一节(§6.4)记录他们实际抓到的 reward hacking 行为(伪造 chronus RPC、翻日志、XFS_IOC_SWAPEXT 绕过访问控制)。两家独立确认了同一件事:对抗 reward hacking 是一个需要频繁迭代环境的持续性工程,而不是一次性的数据清洗。
(2)Initialization 的 I/O 放大(§3.3)
WeEnv 追踪了每个任务执行期对 rootfs 的全部 I/O(在 OverlayFS 上跟踪,因为部分写会先整读文件,所以只测读就覆盖了全部所需字节),得到 CDF:
"the median task reads merely 0.81% of its artifact, and even the 99th percentile reaches only 6.02%; no task in our trace touches more than 7%." "fetching the full image transfers over 100× the bytes that execution ever accesses."
(3)Provisioning 的固定配额(§3.4)
从 SWE-Smith-Python 的 8k 任务profile 出峰值需求(Figure 4):
| 指标 | p50 | p90 | p99 | 跨度 |
|---|---|---|---|---|
| 峰值 CPU | 0.99 核 | 17.7 / 18 核 | 68.3 / 68 核 | 近 70× |
| 峰值内存 | 0.15 GiB | 0.17 GiB | 7.0 GiB | — |
内存比 CPU 更偏:92% 的任务峰值低于 0.2 GiB,勉强高于 0.15 GiB 的中位数,然后 p99 直接跳到 7.0 GiB。原文的算术很扎心:
"One sized for the median starves the tail, while one sized for the tail wastes resources on the majority, e.g., a 7 GiB quota would over-provision nine out of ten tasks by more than 35×."
而配额直接转化为延迟(Figure 5):同一个资源密集型任务,最小配额(2 核 / 8 GiB)下 rollout 需要 310.5 s,最大配额(128 核 / 512 GiB)下只需 201.6 s——紧配额把 rollout 拉长 1.5×。
为什么不能"预测"(§4):WeEnv 采样 10 个资源密集型任务对齐起点看需求演化(Figure 6),结论是预测不可行——需求无清晰模式,同一任务在不同 rollout 之间差异巨大,而且爆发是突发的:某任务 CPU 需求在一秒内暴涨约 47×。所以 WeEnv 干脆放弃预测,改为弹性调节。
问题定位极准(§4):
"The environment tooling of both virtual machines and containers already embraces a similar notion, the layer. ... However, layers serve only as packaging-stage units of caching and deduplication. At initialization, they must have been bound into a single monolithic artifact, one template or one image, before an environment can launch." "The fundamental gap is that layers cannot be located on their own. They exist only as entries in the artifact's manifest... Layers thus cannot be indexed apart from the enclosing artifact."
关键技术细节:Docker 的 registry 协议只接受 image reference,不接受的裸 digest。所以在标准协议下,拿不到"只按层"的寻址能力。
WeEnv 的答法(§6.1):
harness:2.0、task1:1.0、base-env:3.0),各自独立版本化、独立发布;原文点出了与常规 artifact 的唯一本质差异:
"the artifact freezes its layer stack at packaging time, whereas the plan assembles it at initialization from independently published groups, so the same groups recompose into different environments without repackaging."
而组合的成本极低:"it manipulates only metadata, resolving cached references, concatenating digest lists, and issuing one mount, while no layer content is copied, unpacked, or executed."
打包粒度按变化率切(§6.2)。这是设计指南本身:"The guideline is to draw group boundaries along change rates." 于是切成四组:
| 组 | 内容 | 变化率 |
|---|---|---|
| harness group | 协调模型与环境的 harness(Claude Code / Codex 等) | 最高,持续升级 |
| evaluator group | 判分与 reward 计算 | 高,随 reward hacking 持续打补丁 |
| task group | 每任务的初始状态(git checkout、faulty patch) | 中 |
| base group | OS、语言运行时、通用工具 | 低,全任务共享 |
于是一个 harness 升级或 evaluator 补丁只发布一个小 group,不触碰任何 T 个 task group,且改动在之后组合的每个环境里都生效。
再加一层:仓库粒度打包(§6.2 末)。原文观察到一个很强的结构:
"many RL tasks share one code repository and differ only in a small mutation, such as checking out a particular commit or applying a faulty patch. For example, the 39,471 tasks of SWE-Smith-Python span merely 131 repositories."
于是把 task 按仓库粒度打包(所有同仓库任务共享),每任务只需在初始化时施加一个廉价 mutation——实测中位数 0.43 秒(1024 次初始化测量),对比安装 harness 的约 20 秒。
成果(§9.3, Table 1):
| 数据集 | Dynamic assembly | Static bundling | WeEnv |
|---|---|---|---|
| SWE-Smith-Python | 39,471 | 39,471·H | 133 + H |
| SWE-Smith-C++ | 5,123 | 5,123·H | 71 + H |
| SWE-Smith-Go | 1,629 | 1,629·H | 21 + H |
| SWE-Smith-Java | 6,704 | 6,704·H | 61 + H |
| SWE-Smith-JS | 6,073 | 6,073·H | 36 + H |
| SWE-Smith-Rust | 5,311 | 5,311·H | 41 + H |
| SWE-Smith-TS | 5,032 | 5,032·H | 32 + H |
通用公式是 B + 2 + H(B 个仓库级 base 镜像 + base 工具 1 个 + evaluator 1 个 + 每 harness 1 个)。从 39,471(或 ×H)降到 133 + H,约两个数量级。 更关键的是变更代价:静态打包下改 harness 或 evaluator 要动"最多整个数据集的 T 个镜像",WeEnv 下恰好重发一个 layer group。
数据流(§7.1):为了拦截文件 I/O,WeEnv 通过 FUSE 把 rootfs 挂进环境。任务访问文件时,内核把 I/O 转给 envlet 的 FUSE handler,后者借助层元数据把它翻译成一个"指定层 + 指定字节范围"的请求。请求按三层递进服务:
envlet 缓存(它托管的全部环境共享)
└─ miss → 节点级缓存(每节点一个,且与 peer 节点共享)
└─ miss → 远程 registry
只有读进入这条路径——写全部落在本地可写层;对只读层支撑的文件做部分写会触发 copy-up,先整读文件,所以也表现为读。
另有一个尽力而为的后台填充:环境一启动就开始逐 chunk 遍历 artifact,跳过已被按需拉取带入的 chunk,把补全放到关键路径之外。
为什么必须重造层格式(§7.2)。两个需求,Docker 的常规层格式一个都不满足:
| 需求 | 常规格式(gzip 压缩的 tar 流) | 后果 |
|---|---|---|
| 元数据可独立拉取 | 没有中央目录,每个文件头紧挨着它的数据、与内容交错 | 恢复元数据必须走完整个层 |
| 任意字节范围可独立读 | gzip 把一个归档压成单一顺序流,后面的字节引用前面的 | 读任何一个字节都意味着解压它之前的全部内容 |
WeEnv 的层格式:
实测收益(§9.4):
| 指标 | 关闭按需(全量下载后启动) | WeEnv 按需 | 改善 |
|---|---|---|---|
| 初始化中位数 | 32.5 s | 10.6 s | 3.1× |
| P95 | 83.5 s | 35.1 s | 2.4× |
| 最坏情况 | 208 s(层必须来自远程 registry) | 47 s | 4.4× |
| 第 1 次 RL 迭代(全冷) | 1,845 s | 763 s | 2.4× |
| 全部迭代总和 | 8,092 s | 5,988 s | 1.35× |
原文的定性结论很关键:"on-demand fetching removes the artifact transfer from the critical path regardless of the cache state"——初始化延迟不再依赖 artifact 大小。
监控(§8.1):每个环境从同一个小配额起步:2 核 / 8 GiB。envlet 通过 cgroup 管理资源,也用同一接口监控——每 200 ms 读一次 cpu.stat / memory.stat。它强调监控本身极廉价:环境内不跑任何东西,每个环境一轮读只需几十微秒,200 ms 间隔下占用不到单核的 0.1%。
原则一:升得激进,降得保守(§8.2)。理由是需求一秒内能涨 47×,而错误的降配会直接杀死环境、丢掉累积的多轮交互。
| 压力等级 | 判据 | 动作 |
|---|---|---|
| 普通压力 | CPU:配额利用率 ≥85%,或 10% 的调度周期被 throttle;内存:工作集 ≥ 配额 60%,或 cgroup 报 reclaim 事件/pressure | 配额翻倍 |
| 严重压力 | CPU:throttle 超过 80%;内存:OOM emergency | 配额立刻 ×4 |
| 降配(CPU) | 利用率持续 10 秒低于 30% 且 throttle 可忽略 | 配额减半 |
| 降配(内存) | 工作集持续 60 秒低于配额 20% | 配额减半 |
原则二:升配不能饿死 admission(§8.2)。这个场景设计得很真实:因为部分任务要在独立的干净环境里评测,一个 batch 会分两波 admission——先是执行环境,等执行完成后是评测环境。两波在同一节点重叠,如果运行中环境的升配把 admission 容量吃光,评测环境就起不来。两个约束:
原则三:失败要显式,不要静默降级(§8.2)。
"A trajectory silently corrupted by starvation is worse than a lost one, as it feeds wrong signals into training. When an OOM emergency persists with no tier left to raise, the envlet terminates the task and reports the cause, so that the RL framework re-samples the task instead of learning from a starved execution."
这一条是全文最值得抄的设计判断。 它把"资源配置失败"从一个性能问题升格为一个训练数据正确性问题:宁可丢一条轨迹,绝不能让一条被饿坏的轨迹进训练集。同一思路在 WeEnv 的架构里还有一次体现——评测必须跑在独立的干净环境里,"so that rollout-side modifications cannot contaminate the reward computation, guarding against reward hacking."
实测收益(§9.5):
| 指标 | 固定配额 | 弹性 provisioning | 改善 |
|---|---|---|---|
| 单个任务 rollout | 最慢 2 条撞上 1,800 s agent timeout | 同一任务约 40 s | 45× |
| 节省 >100 s 的执行数 | — | 61 条 | — |
| 第 3 / 5 / 8 次迭代 | 1,949 / 1,773 / 1,981 s | 542 / 806 / 534 s | 最高 3.7× |
| 全部 rollout 延迟总和 | 8,968 s | 5,988 s | 1.50× |
原文还主动交代了反例:右端少数执行在弹性模式下反而更慢,原因是 "their extra time is spent in the solving phase, where the agent takes a different, longer trajectory, a stochastic effect of agent sampling rather than of the resource allocation." 这个澄清做得很规范。
配额时间线(§9.5, Figure 12)给出了一个完整例子:CPU 从 2 核起步,求解期一次爆发从 4 直接跳到 16,随后随爆发消退逐步减半回到 2(释放的核"在求解期的大部分时间里服务同节点其他环境");评测期再次爆发,配额爬过 32 直到单环境上限 64。内存则是另一个故事:整个求解期一直停在初始的 8 GiB(该阶段以模型调用为主,内存消耗很小),直到 evaluator 需要时才连翻两次到 32 GiB。任务完成时环境被拆掉,配额立刻归零,不等保守降配。
这个例子的价值在于它证明了弹性配额的必要性:"the same environment holds widely different quotas at different moments, which no fixed allocation could match."
(§9.2) 同一负载跑四个后端:
| 指标 | E2B | Docker | AgentENV | WeEnv |
|---|---|---|---|---|
| Env init 占迭代时间 | 53.4% | 39.5% | — | 9.1% |
| Task exec 占迭代时间 | 20.2% | 44.0% | — | 74.0% |
| 初始化中位数 | 150.6 s | 111.0 s | 59.7 s | 10.6 s |
三个 baseline 的初始化占比区间是 28.9%–53.4%,WeEnv 压到 9.1%,任务执行升到 74.0%——原文的总结是 "the iteration finally spends its time on solving the tasks rather than on preparing the environments."
初始化中位数 10.6 s vs 59.7 / 111.0 / 150.6 s,即 5.6–14.2×。分布形状也值得看:Docker 曲线是双峰的——38% 的初始化命中节点上的 warm container、30 s 内完成,其余付 100–300 s 的完整冷创建;而 WeEnv "keeps the entire distribution low"。
鲁棒性实验(这是论文质量高的地方,三个维度的敏感性都测了):
| 变化 | WeEnv | E2B | Docker | AgentENV |
|---|---|---|---|---|
| 换 harness 为 Codex,P50 任务时间 | 108 s | 260 s | 228 s | 178 s |
| 同上,初始化中位数 | 13.7 s | 160.2 s | 126.8 s | 87.3 s |
| Qwen3-14B + 8 服务器,P50 任务时间 | 188 s | 288 s | 253 s | 207 s |
| 同上,初始化中位数 | 13.9 s | 150.7 s | 108.2 s | 60.4 s |
换数据集(Rust / C++ / Go / JS)时 WeEnv 也都是最快:66.9 / 131.5 / 78.9 / 105.3 s。原文对差异的解释很到位:WeEnv 的节省主要来自环境侧、每任务大致恒定,所以任务本身执行越快的语言相对收益越大(Rust、Go 上最高 3.4×),而 C++ 因为编译密集、任务时间本身占主导,对最强 baseline 的优势收窄到 1.2×——但仍是领先的。
WeEnv 在 §9.2 里明确对比了 AgentENV 的存储设计,两条批评都指向架构而非实现:
(1)块级转换对缓存不友好。
"it converts file-level layers into block-level ext4 layers with overlaybd, and the conversion is not cache-friendly: the same layer may map to different blocks across images, so the converted blocks cannot be shared by digest, an issue also observed in file-oriented image services [31]."
对比它自己的做法:"WeEnv instead caches at the layer level, where a cached layer is reused wherever its digest matches, regardless of the enclosing image."
这条批评是结构性的:文件级层有内容寻址的 digest,天然全局可去重可共享;一旦转成块级 ext4,"同一个上层内容"在不同镜像里可能落在不同块上,跨镜像的按 digest 复用就断了。这是"块级路线"的固有代价,不是 AgentENV 的实现 bug。
(2)优化面窄。
"AgentENV optimizes solely the initialization, whereas WeEnv spans the full lifecycle, rethinking packaging and resource provisioning as well."
对照第一节的"三道窄门"图:这正是 AgentENV 押注"启动 + 状态"的必然结果——它把工程量几乎全部压在初始化和快照/fork 上,packaging 和 provisioning 不在它的职责范围内。
T×H 的对比略有简化:静态打包的 T×H 是上界(现实中去重后不一定真有这么多),但"改一次 evaluator 要动一遍受影响镜像"这个结论与上界无关,所以不影响论证。AgentENV 出自月之暗面(Moonshot AI),代码在 GitHub org kvcache-ai 下。仓库自陈:
"AgentENV (AENV) is a platform for running agent environments at scale, powering agentic RL training for Kimi K3."(README:22)
可核验的仓库事实(本地 checkout):
| 项目 | 值 | 位置 |
|---|---|---|
| License | MIT | LICENSE:1(Copyright (c) 2026 AgentENV) |
| 首次开源提交 | 2026-07-25 feat: Initial open-source release of AgentENV | git log --reverse |
| 提交总数 | 235(截至本地 checkout) | git rev-list --count HEAD |
| 生产模型声明 | Kimi K3,外链 github.com/MoonshotAI/Kimi-K3 技术报告 | README:22, 28 |
| 存储 crate 元数据 | name = "overlaybd", version = "0.1.0",无 repository / authors / license 字段 | storage/overlaybd/Cargo.toml:1-4 |
| 对 DeepSeek 的披露 | 全部 *.md 检索 deepseek / dsec 零命中 | 见 §1.2 |
它的定位与另外两家有本质区别:DSec 和 WeEnv 是"论文里的系统",AgentENV 是"可以直接装的东西"(install.sh / aenv CLI / E2B SDK 兼容)。所以对它做评估时,"代码里有什么"比"README 说什么"更可信——下面我会严格分开这两类。
AgentENV 自己的架构文档把重心说得很直白(docs/src/internals/architecture.md:3):
"AgentENV runs AI agents inside isolated, snapshot-capable Firecracker microVMs. Its core is a storage subsystem that provides layered block devices mountable into VMs and ublk-backed memory snapshot restore. The system also includes a per-node orchestrator managing sandbox lifecycle, and a distributed control plane (gateway + scheduler) for multi-node routing."
三层结构:
AgentENV Node
├── API (Axum) ── E2B 兼容 + 反代
├── Orchestrator ── 生命周期状态机(Creating/Running/Pausing/Paused/Resuming/Killing)
├── Firecracker VM
│ ├── /dev/vda rootfs ── ublk /dev/ublkbN ── overlaybd(upper 可写 + layer 0..N 只读)
│ ├── /dev/vdb extra drive ── ublk ── overlaybd
│ └── VM memory ── ublk 只读内存块设备 ── overlaybd mem layers
│ (同一快照的多个沙箱通过引用计数共享这一个设备)
└── 集群侧:Gateway(:8080) ── gRPC ── Scheduler(:9090)
注意内存路径的形状:内存快照不是文件,而是另一个 ublk 块设备,和 rootfs 走同一套 overlaybd 叠层逻辑。这是 AgentENV 与另外两家最不一样的地方——它把"内存"也变成了一个可以分层、可以按需读、可以被多个沙箱共享的块设备。
overlaybd 的格式(architecture.md:47-67):不是简单的层叠 tar,而是一个 LSMT(Log Structured Merge Tree) 格式:
HeaderTrailer(magic LSMT\0\1\2、UUID、flags、index/data 偏移)和一组 DiskSegmentMapping 条目,每条 16 字节按位打包:50-bit offset、14-bit length、55-bit physical offset、zeroed 标志、layer tag。ImageFile 自上而下检索各层的 segment index,第一个含该块区间映射的层供数;upper 未映射的区间自然落到下层。VirtualFile trait):LocalFile(io_uring pread/pwrite,可选 O_DIRECT)、registryfs_v2(OCI registry 远程层下载)、tar,外加可选解压块缓存。ublk 的设备生命周期(architecture.md:69-85):
UVMUblkCtrlBuilder 通过 io_uring 的 UringCmd 向 /dev/ublk-control 发 ADD;/dev/ublkcN(控制)与 /dev/ublkbN(块);AsyncIoRing 和 slab 分配的 I/O slot;ublksrv_io_desc 数组,用户态异步处理;AutoRegBuffer(稀疏缓冲表做零拷贝,内核 6.8+)或传统 UserBuffer。一个只有踩过坑才知道的细节(architecture.md:92):ublk 设备由一个常驻守护进程 uvm-ublk-daemon 统一持有,节点侧通过 Unix domain socket 发 RPC。原因写得很实在:
"All remote image I/O (OSS/registry reads) is dispatched from the per-queue runtimes onto a dedicated per-
ImageServiceremote-io runtime viaRuntimeDispatchFile, because opendal/reqwest pin pooled HTTP connection tasks to whatever runtime polls them and per-device runtimes are dropped on device teardown."
也就是说:tokio 的 HTTP 连接池会把任务钉在"首次 poll 它的那个 runtime"上,而 per-device runtime 在设备销毁时就被 drop 了——如果远程 I/O 直接跑在 per-queue runtime 上,设备一销毁,连接池里的任务就成了孤儿。于是所有远程 I/O 被发配到一个独立的、常驻的 remote-io runtime(由 ublk.overlaybd.remote_io_workers 控制,默认 4,见 config/default.toml:313)。这是一个纯粹的运行时所有权问题,不是算法问题,而它恰好是"能跑起来"和"跑久了会炸"的分界线。
这是 AgentENV 在三者中最深的一块,也是它压上全部工程量的地方。
(a)pause 就是一个"封层 + 重开 upper"的动作(architecture.md:65)
"
ImageFile::create_snapshot_and_restack()is the primary pause path. It seals the live upper layer viaLSMTFile::close_seal_and_reopen()so the upper becomes the newest lower layer, then reopens a fresh writable upper in place."
注意这个设计的一致性:封层后原 upper 变成最年轻的只读层,新的可写 upper 原地打开——于是 pause 之后这个环境可以立刻继续跑,而它刚才累积的整个文件系统增量已经变成了一层可复用的 lower。快照不是一个"导出"动作,而是层栈指针的重排。
(b)一个快照保留什么(docs/src/concepts/snapshots/index.md:13-24)
(c)fork 是一等公民 API,不是"复制快照"的包装
路由真实存在于 OpenAPI 契约与生成代码中:POST /sandboxes/{sandboxID}/fork(src/api/openapi.yml:2045、src/api/generated/src/server/mod.rs:73)。契约里的语义写得很清楚:
"Number of forked sandboxes to create. All forks boot from the same snapshot, so the snapshot is captured once regardless of count. Each fork succeeds or fails independently; the outcome of each is reported in its entry of the response list."(
src/api/openapi.yml:836-838)
实现层(src/orchestrator/service.rs:629-860、src/sandbox/firecracker/sandbox.rs:302-500)有几个值得记录的判断:
FirecrackerSandbox::pause(self) 取到快照后马上 FirecrackerSandbox::resume(self),再用 futures::future::join_all 并发把所有 child 拉起来(src/sandbox/firecracker/sandbox.rs:452-459, 488-502)。源沙箱只被暂停一瞬,不存在"复制一份内存"这个动作。snapshot_config_for_fork() 为每个 child 生成替换 drive 配置(sandbox.rs:302),并校验 "fork replacement drives must refer to launch-time volume drives"(sandbox.rs:474)。fork_sandbox_keeps_successful_siblings_when_one_start_fails(src/orchestrator/tests.rs:5345)就是在锁这个行为——一个 child 起不来,不能把已经起来的兄弟拖下水。CLAUDE.md:195 的约束完全对应:| 模式 | fork 时的行为 | 位置 |
|---|---|---|
exclusive(默认) | 每个 child 建独立的 COW 子卷,并登记 drive 替换关系 | src/api/impls/sandbox.rs:221-243;枚举默认值见 src/volume.rs:36-41 |
ro | 复用同一个 volume id(只读,多属主安全) | src/api/impls/sandbox.rs:245-248 |
底层的预约语义也一致:exclusive 是单属主预约,冲突直接报错(src/volume.rs:591-600);ro 记录多个属主(:579-589)。文档口径与代码一致(docs/src/concepts/volumes/sandbox-forks-and-snapshots.md:12-16)。
prepare_volume_fork_specs() 建 volume-fork-{uuid} 子卷,任一步失败就 cleanup_fork_volume_children() 清掉已建的(src/api/impls/sandbox.rs:191-255)。Running;终止性错误才要求拆除源沙箱(测试 fork_sandbox_recoverable_failure_cleans_up_metrics vs fork_sandbox_terminal_failure_removes_source_and_cleans_up_metrics)。这与 CLAUDE.md 的约束一致:"Recoverable capture/fork errors restore Running; terminal errors require teardown."(d)内存快照绕开了 userfaultfd,改走块设备——这一处的技术含量最高
architecture.md:112-120 记录了一次明确的架构转向:
"Memory snapshot restore uses ublk-backed overlaybd devices rather than userfaultfd. On resume, a read-only ublk device is created from the stacked memory overlaybd layers and passed to Firecracker as a
BackendType::Filememory backend. Firecracker mmaps the block device and COWs pages into anonymous memory on first write, so the underlying device is never modified."
三个后果,每一个都有实用价值:
→ 这直接服务于"从一个模板瞬间拉起一大批环境"的场景,并且共享发生在 host page cache 这一层,不需要任何去重算法。
process_vm_readv 读出选中内存,直接创建 OverlayBD 内存层;并把之前快照的父层叠起来,形成完整的分层内存镜像。→ 反复 pause/resume 的沙箱,每次只付增量。但增量不是免费的。 层会不断累积,所以代码里配了一个层数预算:DEFAULT_MAX_OVERLAYBD_SNAPSHOT_LAYERS: usize = 32(src/sandbox/firecracker/overlaybd_snapshot.rs:41,注释说明是"运行时拥有的 snapshot lower 在合并前的基础预算")。超过就触发 compaction 把多层坍缩。
这是"增量快照"这类设计最容易漏掉的一半:DSec 在容器侧靠离线层坍缩(约 3 GB 阈值)解决同一个问题(§3.5),AgentENV 则在运行时用一个 32 层的预算来兜住它。两家都独立发现:分层方案必须配套一个"层太多了怎么办"的答案,否则读放大和挂载开销会慢慢吃掉收益。
旁证这次转向的彻底性:storage/uffd-core/ 仍然留在仓库里,但被排除在 workspace 构建之外(architecture.md:120:"retained for reference but excluded from the workspace build")。也就是说 userfaultfd 那套是废弃路径,不是可选路径。
(e)warm pool:把 resume 关键路径上的两件事提前做掉
architecture.md:142:
"Snapshot resume can also use
[pool.firecracker]to pre-spawn(network slot, Firecracker process)pairs. A warm entry transfers its network slot, process, and Firecracker CWD to the resumed sandbox, which avoids the spawn and API-socket wait in the resume critical path."
配置默认值是开着的(config/default.toml:338-344):pool.firecracker.enabled = true、startup_prewarm = true、fill_concurrency = 4。另有 [pool.block] 复用 OverlayBD ublk 设备池(default.toml:331-336),以及共享水位线 low_watermark = 2 / high_watermark = 64(default.toml:321-325)。
这是一组很典型的"启动延迟工程":延迟不是在算法里省出来的,而是把工作挪到空闲时段(预拉起进程、预热网络槽、预建块设备)然后在关键路径上做所有权转移。
AgentENV 的 guest 内核命令行(config/default.toml:33)里有这一串:
page_reporting.page_reporting_order=6
damon_reclaim.enabled=Y
damon_reclaim.min_age=100000
damon_reclaim.quota_ms=20
damon_reclaim.quota_sz=1073741824
damon_reclaim.quota_reset_interval_ms=500
damon_reclaim.wmarks_high=990
damon_reclaim.wmarks_mid=990
damon_reclaim.wmarks_low=200
damon_reclaim.skip_anon=Y
damon_reclaim.wmarks_interval=1000000
以及 balloon 侧的配置——src/sandbox/firecracker/instance.rs:417-434 显式开启 free page reporting:
pub async fn set_balloon(&self) -> Result<()> {
let balloon = Balloon {
...
free_page_hinting: None,
free_page_reporting: Some(true),
};
配置文件的注释还专门解释了这套组合的用意(config/default.toml:31-33:"Editing it replaces the shipped boot and DAMON reclaim settings"),src/sandbox/firecracker/config.rs:38-39 则逐项解释了 wmarks 的含义。
这里出现了一个必须点明的技术呼应:DSec §5.2 用的正是同一组内核机制——"We therefore combine DAMON (Data Access MONitor) with virtio-balloon free-page reporting",并用实测证明 DAMON + balloon FPR 把时间积分内存消耗降了 21.2%。
两者在同一个参数上取值不同,这个差异很有意思:
| DSec §5.2 | AgentENV default.toml:33 | |
|---|---|---|
| 页上报粒度 | "operates on order-9 pages, corresponding to 2 MiB regions with 4 KiB base pages"(默认,可调) | page_reporting.page_reporting_order=6(256 KiB) |
| DAMON 冷页回收 | 启用(配合 balloon FPR) | damon_reclaim.enabled=Y,skip_anon=Y |
换算一下:order-9 = 2⁹ × 4 KiB = 2 MiB,order-6 = 2⁶ × 4 KiB = 256 KiB——AgentENV 把上报粒度调细了 8 倍。对 microVM 场景这很合理:粒度越细,能凑出来上报的空闲页越多、回收越精确,但扫描开销也越大。这是一处典型的、"论文不会写、只有调过才知道"的工程选择。
需要明确的是:内核参数相同本身不能证明代码同源(DAMON + FPR 是任何用 Firecracker 做高密度部署的团队都会碰到的组合)。但它与 §1.2 那个已被 DSec 论文披露的存储组件关系放在一起看,就构成了一个连贯的图景:这两家在块存储与内存回收这两个"内核交界层"上,用的是同一套技术词汇和同一批内核特性。 而它们的平台层(调度、生命周期、安全)依然完全不同。
对外接口直接兼容 E2B:
POST /sandboxes 创建
GET /sandboxes 列表
GET /sandboxes/{id} 元数据
DELETE /sandboxes/{id} 删除
POST /sandboxes/{id}/pause 暂停(快照)
POST /sandboxes/{id}/resume 从快照恢复
POST /sandboxes/{id}/fork fork(N 个 child)
GET /nodes, /nodes/{id} 节点视图
ANY /proxy, /proxy/{path} HTTP/SSE/WebSocket 反代进沙箱
(docs/src/internals/architecture.md:181-192;其中 fork 不在该列表内,它由契约定义:src/api/openapi.yml:2045)
"E2B 兼容"具体是怎么做到的:src/api/openapi.yml 里一次 "e2b" 都没出现——兼容不是靠一份 E2B 专门的 spec,而是靠对齐路由形状、请求头和 ID 格式:
| 兼容点 | 位置 |
|---|---|
路由头别名 e2b-sandbox-id / e2b-sandbox-port | src/api/proxy.rs:87-92(解析在 :818, :825,转发前剥离 :973, :975) |
流量令牌头 e2b-traffic-access-token | src/api/impls/auth.rs:14 |
API key 前缀 e2b_ | src/api_key.rs:20 |
| MMDS 字段名 / 模板 step 的错误形状 / 默认 ready 命令均向 E2B 对齐 | src/sandbox/firecracker/mmds.rs:68、src/template/step_executor.rs:473、src/template/runner.rs:28 |
而且它把不兼容的部分写明了(docs/src/integration/e2b.md:96-97):"AgentENV supports the TypeScript SDK's volume create, list, and delete operations... The E2B SDK's direct volume content API is not supported."——一个兼容层主动标注自己的缺口,比声称"完全兼容"有用得多。
另有 [custom_extension] 生命周期钩子(default.toml:131-136)、[sandbox_proxy].domains 的按主机名路由,以及 aenv CLI(Auth / Pull / Build / Start / Exec / Connect(别名 cn)/ Pause / Resume / List(ls)/ Delete(rm)/ Timeout / Snapshot(snap)/ Template / Volume,见 crates/aenv/src/main.rs:20-65)。
控制面是独立的 Go module(services/):
Client ──HTTP──> Gateway(:8080) ──gRPC──> Scheduler(:9090)
│
└──proxy HTTP──> Node A / Node B (:8000)
architecture.md:209 列了 8 个(Schedule、LookupNode、RecordAssignment、Heartbeat、ListObservedNodes、ListP2pPeers、GetNode、UnregisterNode),但契约里实际有 13 个(services/api/proto/scheduler.proto:7-21,另有 ListNodes、ReportSandboxEvent 以及三个 P2P artifact RPC)——文档的 RPC 清单是不完整的;策略 round_robin(默认)/ random;发现模式 static 或 kubernetes(EndpointSlice watch)。RecordAssignment 建绑定;runtime 心跳携带该节点全量沙箱 ID 名册,scheduler 以名册为准删掉不在其中的绑定;binding_ttl 是路由新鲜度而非沙箱超时的副本。关于"状态放在哪",这里有一处必须替 AgentENV 纠正的常见误读。 architecture.md:223 写着:
"Limitations: All bindings are in-memory (lost on scheduler restart). After a scheduler restart, bindings are rebuilt from new sandbox creations plus the next heartbeat roster from each runtime node... but binding persistence is still not replicated."
这句话过于笼统,照抄会低估这个控制面。实际情况是:
| 事实 | 位置 | |
|---|---|---|
| 默认 | 绑定存内存(NewInMemoryBindingStore) | services/scheduler/cmd/main.go:165 |
| 但可选 Redis 后端 | NewRedisBindingStore,Lua 脚本做原子写入 | services/scheduler/internal/redis_store.go:28, 103-113 |
| 且有已文档化的 HA 形态 | 一个 primary(带 scheduler.redis_addr)+ 多个 --query-only 副本,后者只用 Redis 服务 LookupNode | services/README.md:293 |
| 仍有真正的空洞 | "Artifact store state is still in-memory and is not covered by this HA mode." | services/README.md:293 |
所以准确的说法是:AgentENV 的控制面有一个可选的 Redis 绑定存储和 query-only 副本方案,但 artifact 索引在 HA 模式下依然只在内存里;architecture.md:223 那句是过宽甚至是过时的表述。
即便如此,这个控制面的定位仍然清楚:AgentENV 的重心在节点内的存储与快照,控制面只负责"把请求路由到正确的节点"。这和 DSec 那种被反复强调"placement engine 与 watcher 不需要持久状态"的设计取向看起来相似,但路径相反:DSec 是主动设计成无状态并用 IaC 定期重建来验证;AgentENV 是先默认有状态,再补一个可选的 Redis 后端,并且明确承认 HA 没覆盖到 artifact 索引。
AgentENV 没有配套论文,所以它的性能主张只能来自 README。我逐条区分了"有代码支撑"和"只有文字":
| 主张 | 出处 | 代码级证据 | 判定 |
|---|---|---|---|
| boot/resume < 50 ms,pause < 100 ms | README:29(docs/src/getting-started/overview.md:9 同句) | 有基准套件(crates/benchmarks/benches/snapshot_benchmark.rs:snapshot_resume、snapshot_resume_cold、concurrent_resume,并发度 CONCURRENCY = 50),但只计时、不设阈值,仓库内也无结果文件;机制侧有 warm pool(src/sandbox/firecracker/pool.rs 注释:"entry to skip process spawn and API socket polling on the critical path")+ pool.firecracker.startup_prewarm = true | ⚠️ 宣称值,无阈值/无结果 |
| 9.6× 内存超分比(生产) | README:31 | 机制齐备(balloon FPR + DAMON + 内存设备引用计数共享 + 控制面允许 >100% 分配:services/shared/config/config.go:46-48 的 max_memory_allocated_percent "can exceed 100 (overcommit)");但 9.6 这个字符串在本仓库其他地方只出现在 Cargo.lock 的无关 crate 版本号里,没有任何测量或配置项对应它 | ⚠️ 具体数值无支撑;且 docs/src/getting-started/overview.md:11 的同一段文字里删掉了 "9.6x" |
| snapshot < 100 ms,即使重磁盘修改 | README:30 | 增量捕获机制真实(src/sandbox/firecracker/sandbox.rs:1345-1351:state-only diff snapshot + get_dirty_memory_ranges());重盘场景也有 bench 变体(bench_snapshot_creation_1gdisk) | ⚠️ 无 100 ms 断言 |
| 生产 150 万镜像 | README:28 | 外链 MoonshotAI/Kimi-K3 技术报告,不在本仓库内 | ⚠️ 无法本地核实 |
| native snapshot / fork | README:30 | ✅ 完整实现:pause restack、POST /fork、per-child 独立结果、volume COW 子卷、分级失败语义、专项测试 | ✅ 可核验 |
| on-demand 加载 OCI 镜像 | README:28 | ✅ image.resolver.convert_standard_oci = true(default.toml:76)、registryfs_v2 后端、image.cache.remote_blocks.max_size_gb = 100(default.toml:95-97) | ✅ 可核验 |
| 本地磁盘作为有界缓存,热点保留、冷数据淘汰 | README:28 | ✅ image.cache.capacity_gb = 100 + GC(间隔 1800 s、最小空闲 600 s、高水位 0.95 / 低水位 0.70,default.toml:92-111) | ✅ 可核验 |
| P2P 加速 | default.toml:113-119 | ⚠️ 默认关闭(enabled = false),且注释自称 "EXPERIMENTAL: P2P has not been tested in production. Keep it disabled in production unless the deployment accepts that operational risk.";另有一处出厂配置自相矛盾:[snapshot].p2p_enabled = true(:228)而 [p2p].enabled = false(:117),前者因 src/p2p/config.rs:36-40 的强制降级而不生效 | ⚠️ 自陈未验证 |
这张表最值得注意的两点:
9.6x 只在 README 里,在 docs 的对应段落中被删掉了。(README:31 vs docs/src/getting-started/overview.md:11:后者保留了"Memory ballooning returns reclaimable guest memory to the host",但把"achieving a 9.6x memory overcommit ratio in production"改写成了"sustaining high overcommit"。)这个不一致是文档内部的可观测差异——我无法确定它是刻意还是疏漏,但引用这个数字时应当标注出处只有 README。[template_build] 只是用 BuildKit 构建模板镜像(builder_image = "moby/buildkit:v0.33.0"、max_concurrent_builds = 4、cache_size_mb = 65536,default.toml:376-385),并且有 per-node 并发上限(超出返回 429)。WeEnv 的批评在此成立:AgentENV 不负责"环境如何被打包与维护",它假设 artifact 已经存在,专注把 artifact 变成跑得快的沙箱。docs/src/concepts/volumes/index.md:3-6:"The volume feature is still in beta. For workloads that can use a drive supplied only when cold-starting a sandbox, the attachedDrives feature on POST /sandboxes-cold is more extensively tested.")——而 fork 的卷语义恰好依赖 volume;userfaultfd 路径已废弃并从 workspace 移除。docs/src/internals/sandbox-testing.md 记录的默认值全部陈旧(:313 说 socket_poll_ms 默认 20 ms,代码是 1;:319 说 machine 默认 "128 MiB RAM and 1 vCPU",代码是 1024 MiB / 2 vCPU;:321-322 说 envd 30 s / 10 ms,代码是 60 / 3),而 docs/src/configuration/reference.md 与代码一致;architecture.md:63 说压缩是 "zstd (level 3)",而出厂的发布压缩默认是 lz4(default.toml:246,两种都支持);architecture.md:223 的"绑定全在内存"过宽(实际有可选 Redis 后端,见 §5.6)。这些都不影响功能,但意味着:引用 AgentENV 文档里的任何具体数值,都必须回查代码。sandbox.rs:474),并且 volume fork 走 COW 子卷——fork 不是"任意状态的无限自由复制",而是有明确边界的一等操作。| 维度 | DSec(DeepSeek + 清华) | WeEnv(腾讯微信) | AgentENV(月之暗面) |
|---|---|---|---|
| 材料形态 | 技术报告(31 页) | 论文(16 页) | 开源代码库 |
| 隔离底座 | FnCall / 容器 / Firecracker microVM / QEMU 全 VM(4 种) | 容器(唯一) | Firecracker microVM(唯一) |
| 容器是否再套 VM | 是(容器跑在 QEMU/libvirt VM 内) | 否 | 不适用 |
| 环境打包单位 | base + workspace + toolkit(EROFS 层) | layer group + environment plan | 不做打包 |
| 谁负责造环境 | agent 自己在线构建(pack_diff) | 离线按变化率切组 + 仓库粒度打包 | 外部(BuildKit 构建模板) |
| 按需加载 | 容器:EROFS multi-device;microVM:overlaybd + ublk,256 KiB chunk + 本地二级缓存 | FUSE + 自研层格式(尾部 TOC + payload 连续存放) | overlaybd(LSMT)+ ublk,remote blocks 缓存 |
| 内容寻址粒度 | 文件级(EROFS)/ 块级(overlaybd) | 文件级(层 digest 全镜像可复用) | 块级(LSMT segment) |
| 密度手段 | virtio-pmem+DAX、DAMON+FPR、CPU QoS(core scheduling) | 弹性 provisioning(200 ms cgroup 采样) | balloon FPR + DAMON + 内存块设备引用计数共享 |
| 快照 / fork | pause/resume(容器 docker pause;microVM 快照+杀进程) | 未作为核心机制 | 一等公民:restack pause、POST /fork、内存设备共享 |
| 与训练器协同 | 深度 co-design(rollout 移出可抢占 GPU 池) | 仅鲁棒性(干净评测环境防污染) | 无(定位为通用平台) |
| 对抗模型 | 最完整(AppArmor + eBPF 网络白名单 + 实案复盘) | 架构级(独立干净评测环境) | 未公开 |
| 对外接口 | libdsec(Python SDK) | 集成 Slime | E2B 兼容 HTTP API + aenv CLI |
| 规模证据 | 160 节点/scale unit、300 万沙箱/天、38 万并发、>5,000 创建/s | "已上线微信",规模未披露;评测 4–6 台 H20 | README 宣称 150 万镜像;唯一第三方实测是被削弱过的 |
| 性能实测可信度 | 自测,含消融,10 节点实验集群 | 自测,含 3 维敏感性分析,且实测了三家竞品 | 无独立实测 |
把总表竖着读一遍会发现一件容易忽略的事:这三家几乎没有在同一个技术决策上正面冲突过。
三个赌注的正交性体现在几个具体的地方:
| 问题 | DSec 的答案 | WeEnv 的答案 | AgentENV 的答案 |
|---|---|---|---|
| 环境从哪来? | agent 在线造 | 离线按变化率切组 | 不关心 |
| 环境怎么到节点? | EROFS/overlaybd 从 3FS 按需 | 自研格式从 registry 按需 | overlaybd 从 registry/OSS 按需 |
| 跑起来之后呢? | 和训练器协同 pause/resume | 弹性调 CPU/内存配额 | 快照、fork、共享内存页 |
| 谁最危险? | 模型自己(reward hacking) | 被污染的评测信号 | 未表态 |
所以"谁更好"是个错误问题。 正确的问题是:你要解决的那一刀,在哪一家的射程里。
一个粗粒度的选型指引:
| 如果你的约束是… | 更接近的答案 |
|---|---|
| 安全敏感任务 / 全 OS 环境 / Android / 图形 / GPU kernel | DSec(四后端谱系,别无选择) |
| SWE 类容器可达负载,痛点是"迭代时间被环境吃掉" | WeEnv(全生命周期 + 弹性配额) |
| 需要环境状态可移植、可 fork 分支、或要 E2B 兼容的自托管底座 | AgentENV |
| 需要和 RL 训练器做抢占协同(GPU 作业频繁被抢) | DSec(唯一做了这件事的) |
这是我认为整篇文章最值得记住的一点。三者都在说"environment",但它们对"环境"这个词的外延定义根本不一样:
| "环境"是什么 | 隐含的职责边界 | |
|---|---|---|
| DSec | 可编程产物(base + workspace + toolkit,agent 可随时 pack_diff) | 环境是运行时产出的东西,平台要支持在线构建 |
| WeEnv | 需要治理的软件供应链(4 个变化率不同的 layer group) | 环境是需要被持续维护的发布物,平台要管它的全生命周期 |
| AgentENV | 一个输入(已存在的 OCI 镜像) | 环境是给定的前提,平台只管把它变成快沙箱 |
这个定义差异直接决定了 WeEnv 的批评是否成立。 WeEnv 说 AgentENV "optimizes solely the initialization"——从 AgentENV 自己的边界定义看,这不是缺陷,而是它的定义本身:packaging 不在它的职责里。反过来说,DSec 的 pack_diff 之所以可能,恰恰因为它不把 packaging 交给外部流水线。
一句话总结这三家的分工:
DSec 在管"环境怎么被造出来";WeEnv 在管"环境怎么被维护和调度";AgentENV 在管"环境怎么被启动和复制"。
三者可以叠加,而不是互相替代——这也解释了为什么 DSec 会把自己的存储层开源进 AgentENV 的仓库:在"块设备路径"这一层,两家是共享的;在它上面,三家各建各的楼。
整篇文章里,只有一组数据把三个不同机构的产物放在了同一个实验台上——WeEnv §9。它是我们理解 AgentENV 相对性能的唯一公开来源。因此它的限定条件必须逐条标注,否则极易被误用。
| 指标(SWE-Smith-Python,Qwen3-8B,Claude Code) | E2B | Docker | AgentENV | WeEnv |
|---|---|---|---|---|
| 环境初始化中位数 | 150.6 s | 111.0 s | 59.7 s | 10.6 s |
| 换 Codex harness 后初始化中位数 | 160.2 s | 126.8 s | 87.3 s | 13.7 s |
| 换 Codex 后 P50 任务时间 | 260 s | 228 s | 178 s | 108 s |
| Qwen3-14B + 8 服务器,P50 任务时间 | 288 s | 253 s | 207 s | 188 s |
AgentENV 在这张表里的位置很明确:所有 baseline 里最快,但落后 WeEnv。 这本身对 AgentENV 是有利的——它是三个 baseline 中唯一在初始化上低于 60 s 的(另两个是 111 s 和 150.6 s)。
这句话必须原文引用(WeEnv §9.1):
"Since our servers lack ublk kernel support, the on-demand fetching is disabled in our experiments."
ublk 是 AgentENV 按需加载的物理载体(§5.3、§5.4 整条路径都建立在 ublk 块设备上)。服务器没有 ublk 内核支持,就意味着 AgentENV 的块级按需拉取完全没跑起来——而这恰恰是它相对"全量拉镜像"的核心优势。
结论:59.7 s 这个数字,不是 AgentENV 架构的性能,而是"AgentENV 在没有 ublk 的机器上退化成什么样"的性能。
同段紧接着:
"We tune AgentENV for better performance. The file-level layers are converted into block-level ext4 layers offline and pushed to the registry, so that initialization fetches the converted layers directly and bypasses the conversion during initialization, reducing its initialization latency by 25%."
也就是:把转换工作从初始化路径挪到离线,换来 25% 的初始化提速。
这一点常被忽略,但它很重要,因为它说明 WeEnv 的操作不是单纯地削弱对手:他们既关掉了 AgentENV 的按需加载(−),又替它把初始化路径上的转换开销移走(+)。净偏差方向未知——我们无法从论文里算出这两者相抵后是偏乐观还是偏悲观。
诚实的结论是:5.6–14.2× 这个倍数,在"无 ublk + 已离线预转换"这套特定设置下成立。它是一个有效测量,但它既不能外推成"AgentENV 架构慢 5.6–14.2×",也不能反过来用来推断 DSec 的性能。
WeEnv 的四个后端是 E2B、Docker、AgentENV、WeEnv。DSec 不在其中——两篇论文相隔 6 天(09-19 vs 09-25),我逐条核对了双方参考文献,互不引用。
所以:
AgentENV README:29 声称 "Snapshot-backed environments boot or resume in under 50 ms and pause in under 100 ms";WeEnv 实测其中位数初始化是 59.7 s。
这两个数差了三个数量级,看似矛盾,但很可能量的是两件不同的事:
真正的问题不是矛盾,而是不可对齐:AgentENV 从未定义"boot"的起止点、测的是哪条路径、在什么缓存状态下,也没有在仓库里留下任何基准脚本。所以这两个数字无法被放置到同一个坐标轴上。这不是 AgentENV 独有的问题——只要一个项目的性能主张是不可复现的,它就无法参与跨机构比较。
WeEnv 的评测是目前唯一的跨机构数据,它的价值是给出了一个在受限条件下的下界。但使用它时必须同时带上四个标签:AgentENV 按需加载被关闭、AgentENV 被离线预转换提速 25%、实验里没有 DSec、被比较的两个指标定义不同。
抛开"谁赢",这三份材料里有六条结论是可以直接迁移到任何做 agent 基础设施的团队的。每一条都标注了它的来源机构——这也是本文"三个机构"框架的实际用途:它们是三种不同处境下的实证经验。
WeEnv 的方法论是可以照抄的:把 RL 迭代拆成 Train / Env init / Task exec / Others 四段,按总延迟归一化,再把 rollout 拆成初始化与任务执行。做完这一步,你才知道该投入在训练侧、环境侧还是任务侧。
WeEnv 的实测结果(53.4% / 39.5% 的初始化占比、训练只占 15.4%–24.0%)很可能在别的团队那里数值不同,但"先拆开量一遍"这个动作本身,成本极低、信息量极高。
两台独立仪器给出同一方向、但量级不同的数字:
| 测量对象 | 访问占比 |
|---|---|
| WeEnv(文件级层,SWE 任务) | 中位数 0.81%,p99 6.02% |
| DSec(容器镜像整体,按语言) | 4.2% – 13.3% |
两个都对,因为测的粒度不同:WeEnv 数的是"任务实际读到的 artifact 字节",DSec 数的是"运行期访问的镜像数据比例"。
设计含义:不要照抄数字,要照抄"先量再定粒度"的顺序。而且这个量级差正好解释了为什么两条路线都成立——文件级层天然比块级更粗,所以文件级方案的收益更依赖"层切得好不好",块级方案的收益更依赖"按需读够不够快"。
两家独立得出了同一个打包原则:
O(m·N)、O(k·N) 的升级成本压到 O(m)、O(k);实现成本是改 dockerd 的 30 行 Go。两条可操作的推论:
WeEnv 把它列为第三条调度原则,理由说得比任何性能论证都重:
"A trajectory silently corrupted by starvation is worse than a lost one, as it feeds wrong signals into training."
因此在 OOM 且无档可升时,选择终止任务并上报原因,让 RL 框架重采样,而不是让它带病跑完。
AgentENV 在实现层给出了同一原则的工程化形态:失败要分级——
| 失败类型 | 处理 |
|---|---|
| 可恢复(capture/fork error) | 把源沙箱恢复为 Running,源不受损 |
| 终止性(terminal) | 才要求拆除源沙箱 |
| 子沙箱启动失败 | 不得拖累已成功的兄弟(fork_sandbox_keeps_successful_siblings_when_one_start_fails) |
| volume 子卷创建失败 | cleanup_fork_volume_children() 回滚 |
这两件事是同一个道理:在训练数据正确性面前,"部分成功"比"明确失败"危险得多。
DSec §6.4 的案例清单应该被每一个做 agent 基础设施的人读一遍。最有价值的不是它的缓解措施(AppArmor + eBPF),而是它记录的失败长什么样:
/bin/bash;XFS_IOC_SWAPEXT 这个交换文件 extent 映射的 ioctl 去绕开——代价是打坏了文件系统元数据;/proc/kpagecgroup,触发内核 bug 把内核打崩;yes 命令产生数十 GB 被捕获的 stdout。WeEnv 从另一侧给出架构级答案:评测必须跑在独立、干净的环境里,"so that rollout-side modifications cannot contaminate the reward computation, guarding against reward hacking."
DSec 还补了一条容易被忽视的治理措施:builder 与 runtime 使用不同账号,并且打包前清除可写层里的构建期残留数据,避免参考答案被带进镜像。
可操作推论:把"答案可能从哪些非预期渠道泄漏"当成一个需要持续枚举的攻击面(内部 socket、日志、被覆写的解释器、包代理、进程文件系统),而不是一次性的数据清洗。两家独立机构都确认了这是一个持续迭代的对抗过程——WeEnv 甚至把它写成了 packaging 的成本项("evaluator 要频繁更新以对抗 reward hacking,每次改动要动一遍镜像,可能要几天")。
AgentENV 的 warm pool 是一个很干净的范式(architecture.md:142、default.toml:338-344):
(network slot, Firecracker process) 配对;[pool.block] 复用 OverlayBD ublk 设备池,共享水位线 low=2 / high=64。这和 DSec 的 placement 设计是同一个哲学的两个面:DSec 用 "local view overlaying its recent placements not yet reflected in periodic watcher snapshots" 来消除协调延迟;AgentENV 用 warm pool 来消除创建延迟。两者都不是"把某步做得更快",而是"把某步从关键路径上挪走,或者让它不需要协调"。
两家的做法相反,但都把理由写清楚了,这比选哪边更重要:
| DSec | AgentENV | |
|---|---|---|
| 做法 | 显式设计为不需要持久状态 | 默认内存态,但提供可选 Redis 绑定存储 + query-only 副本(services/README.md:293、services/scheduler/internal/redis_store.go:28) |
| 理由 | 让 apiserver / placement / watcher 实例可任意增删替换,无昂贵恢复步骤;靠 IaC 定期重建验证 | 把状态权威留给节点,控制面只做路由;HA 需求用可选后端补 |
| 代价 | 需要重建能力与验证机制(BGP + 多实例 + 定期 cluster reset) | architecture.md:223 的表述已过宽;真正的空洞是 artifact 索引在 HA 模式下仍只在内存里 |
共同点:都不把控制面当作状态的唯一权威。差别在于路径相反——DSec 是主动设计成无状态,并用 IaC 定期重建来证明重建能力;AgentENV 是默认有状态,再补一个可选 Redis 后端,并明确承认 HA 没覆盖到 artifact 索引。前者靠约束换弹性,后者靠可选项换弹性。
上一篇把一个发布版本一路读到配置项。这一篇拿三个发布版本互读:DeepSeek、腾讯微信、月之暗面各自为同一个瓶颈做了什么,它们的设计在哪里真正分岔,以及哪些数字你可以自己核。三方都测到的「环境税」,正是第一篇命名、第二篇追进单个技术栈的那一个。