做 Agent 训练的人应该都有同感:训练跑不动的时候,十有八九不是 GPU 不够,而是没人给 agent 一个安全的执行环境。DeepSeek 一天创建 300 万个沙箱,这个数字第一次看到时,我不惊讶它大,而是惊讶它已经能把“执行”这个环节规模化到这种程度。代码沙箱在 Agent 训练里的角色,不是跑几个样本用的调试工具,而是每一轮轨迹采样都依赖的流水线节点。
这篇文章会把这件事完整拆开:为什么 Agent 训练会爆发式地需要沙箱;300 万/天这个体量到底意味着多大的容量压力;隔离方案怎么选;容量账怎么算;以及真正把它撑住的关键并不是创建速度,而是回收效率。内容偏工程落地,适合正在搭 Agent 训练基建、做 RL 数据回放、或者被工具调用执行环境折磨过的工程师参考。准备自建沙箱体系的同学,也能当一份避坑大纲用。
1. 为什么 Agent 训练离不开“沙箱”这个角色
很多从 SFT 转过来的同事,最初都对“沙箱”这个概念很困惑:预训练和 SFT 喂给模型的是静态文本,数据从文件里读进来,模型读完样本就结束了,整个训练过程根本不需要“环境”。Agent 训练完全是另一套逻辑:模型生成的不是最终答案,而是一系列动作,动作必须作用到真实环境上,拿回观察结果,才能继续生成下一步。最典型的就是代码类任务:模型写出一段 Python,如果没人替它执行,stdout、报错类型、退出码全是空的,那这个动作就没有任何监督信号。
拿 DeepSeek 这类大规模 RL 训练来说,每天跑出的轨迹里,每一步工具调用都可能对应一个沙箱实例。一天 300 万个,实际就是把“模型—环境—模型”这种回合制交互搬上了万台机器的规模。所以沙箱不是训练流程的附属品,它就是训练数据的生产线。没有这条生产线,Agent 在“调用工具”这件事上的能力根本没有办法被稳定评估。
1.1 传统训练与 Agent 训练的本质区别
两类训练对基础设施的要求差异极大,先看一张对比:
| 对比维度 | 传统预训练 / SFT | Agent 训练 |
|---|---|---|
| 数据来源 | 静态语料,提前准备好 | 环境交互产生的轨迹,边跑边生成 |
| 训练单位 | 输入-输出样本对 | 回合(Episode),含多步动作 |
| 失败样本 | 直接进负例 | 需要区分是策略错还是环境错 |
| 主要瓶颈 | GPU 算力、数据吞吐 | 沙箱创建/回收、工具执行延迟 |
| 安全要求 | 低,数据可控 | 高,模型可能写出任意代码 |
传统训练的样本是“已经发生的事实”,训练过程只是让模型拟合它;Agent 训练的样本则是“现场发生的事实”,环境给什么反馈,样本就是什么。这意味着环境本身必须是真实、可复现、并且能抵抗各种意外行为的。一个最简单的原因是:模型生成的代码里除了正常逻辑,还可能出现死循环、fork、写满磁盘、对外发流量这类行为。如果让每个动作都直接在训练机裸跑,一次事故就能污染整个集群。沙箱要做的事情,就是把“环境反馈”做成一等公民,同时把“环境风险”隔离在训练主流程之外。
1.2 训练 harness、Agent 框架和沙箱的关系
很多刚入坑的同事会把 Agent 框架和训练 harness 搞混。Agent 框架解决的是“模型会不会调工具”,它负责把 LLM 推理结果转成结构化工具调用、管理多轮消息;训练 harness 解决的是“这种调用能力怎么被数据固化下来”,它负责采样、执行、奖励计算、样本入库的闭环。两者都会用到沙箱,但定位完全不同:Agent 框架里的沙箱服务于在线产品,训练 harness 里的沙箱服务于离线数据生产。
在 DeepSeek 这类大规模训练体系里,harness 是调度大脑,沙箱是执行末端。一次典型交互长这样:策略模型产出一个动作,harness 把它解析成 exec 请求,沙箱执行,收集 stdout、stderr、exit code、是否超时,再交给 verifier 计算奖励,最后样本进入训练 buffer。每个动作服务完,沙箱要么销毁、要么归还池子。这个流程跑起来,一天百万级创建的流量特征就出现了。
2. 先把容量账算清楚:300 万/天的背后是什么
300 万/天听起来很唬人,但工程上第一件事不是惊呼,而是把数字拆开算。这个体量下的设计决策,几乎全部由一道乘法题决定。
2.1 创建速率、稳态并发与生命周期
300 万除以 86400 秒,约等于每秒 34.7 个创建速率,高峰按五倍算也就是每秒不到 200 个。单看这个数字,任何容器编排系统都能做到,真正难的是稳态并发。假设每个沙箱平均存活 60 秒,稳态并发等于每秒创建数乘以平均存活时间,也就是 34.7 × 60 ≈ 2083;但实际训练任务里,一个沙箱往往要服务同一 episode 里的多次环境交互,一次 exec 可能带着多次小动作,实测生命周期经常拉到 3 到 10 分钟。按 10 分钟算,稳态并发就是 34.7 × 600 ≈ 2 万个。
从容量规划看,资源池规模等于稳态并发乘以单沙箱平均资源。按每沙箱 0.5 vCPU、512MB 内存、平均生命周期 120 秒估算,稳态并发 7000 左右,就需要 3500 vCPU、3.5TB 内存,此外还要留磁盘给镜像缓存、网络带宽给 stdout 回传和日志、以及触发回报所需的队列容量。结论很反直觉:想撑住 300 万,最重要的不是压榨创建速度,而是压平均生命周期和回收速率。生命周期少一半,容量需求直接少一半,这笔账最划算。
2.2 隔离粒度怎么选:不是所有沙箱都要拉满
沙箱隔离强度从低到高大致是这几档:进程级隔离(cgroup、unshare)、runC 容器、gVisor 用户态内核、Firecracker/Kata 这类微虚拟机、以及全虚拟化。
| 方案 | 隔离强度 | 启动成本 | 单机密度 | 兼容性 | 适用定位 |
|---|---|---|---|---|---|
| 进程/cgroup | 低 | 毫秒级 | 最高 | 高 | 静态安全检查 |
| runC 容器 | 中 | 50-200ms | 高 | 高 | 常规 tool call 验证 |
| gVisor | 中高 | 100-300ms | 中 | 中,性能损耗 20%-30% | 半信任代码 |
| Firecracker/Kata | 高 | 125ms 起步 | 低 | 全系统兼容 | 任意代码、高危任务 |
选型逻辑要把 Agent 训练里的沙箱分成两类。一类是普通 tool call 验证,模型让执行什么就执行什么,恶意概率不高,用加固过的 runC 加 seccomp 加资源限制就够了;另一类是执行任意代码、甚至需要完整系统环境的高危任务,必须用微虚拟机。我见过一些团队一上来就全量上微VM,结果 200 台节点只能跑 2000 个并发,镜像这边还没跑满,资源先被虚拟化开销吃掉了;反过来全用容器,一晚上就被恶意 agent 拖垮宿主机。正确的做法是信任分级,不同等级走不同资源池。
3. 撑住一天 300 万的落地架构
体量模型算清楚了,剩下的就是工程架构。这里没有银弹,核心就三件事:别让镜像重复拉、别让沙箱赖着不走、别让短生命周期对象压垮控制面。
3.1 预热池、镜像层级与 P2P 分发
如果每个沙箱都从镜像完整拉起,平均基础镜像哪怕只有 1GB,300 万次创建就是 3PB 的重复 IO,任何一个存储都扛不住。实践里靠三层解决。第一是节点本地预热,把常用基础镜像提前发到所有节点;第二是 overlayfs 只读共享,所有沙箱共享底层镜像层,每个沙箱只增加薄薄一层可写层;第三是 P2P 镜像分发,扛住峰值拉取,避免几千个节点同时从一个 registry 拖镜像。
另一个容易被忽略的优化是预热池。沙箱不销毁而是洗干净复用,池子里常驻 500 到 1000 个空沙箱,新请求来了直接分配,创建时间从 100 毫秒降到 5 毫秒以下。但注意,池子不等于永远保持同一套环境。训练代码迭代很频繁,池子里的镜像版本会过期,必须按版本分池、定时刷新,不然线上会出现“这次训练的镜像跟上个任务混在一起”的脏状态。我见过一次奇怪的偶发失败,查了一下午,最后发现是预热池里混着两个版本的依赖包。
3.2 生命周期管理:真正的胜负手
一天创建 300 万,意味着一天也要回收 300 万。回收一慢,僵尸沙箱占着资源,后续任务排队,训练吞吐肉眼可见地下降。我经历过最严重的事故:回收线程卡在不可中断的 IO 上,残留沙箱把宿主机磁盘写满,整个集群半天产不出数据。从此之后,生命周期协议被我放在了比创建速度更优先的位置。
手工管理几百个并发可能还行,百万级必须有明确的租约模型。沙箱创建时带 TTL,训练端每次 exec 后心跳续租,超时直接回收,不商量。核心逻辑可以用一段伪代码说明:
class SandboxManager: def __init__(self, lease_ttl=60): self.leases = {} self.pool = SandboxPool() async def acquire(self, task_id, image_version): sb = self.pool.get(image_version) or await self.create(image_version) self.leases[sb.id] = Lease(owner=task_id, expires_at=now() + lease_ttl) return sb async def heartbeat(self, sandbox_id): lease = self.leases.get(sandbox_id) if lease: lease.expires_at = now() + lease_ttl async def reap_loop(self): while True: for sb_id, lease in list(self.leases.items()): if lease.expires_at < now(): await self.force_recycle(lease.owner, sb_id) await asyncio.sleep(5)这段代码里没有复杂的算法,关键是强约束。回收动作必须按顺序执行:摘掉网络、杀掉进程树、卸载挂载、清理 overlay 写层、归还节点资源。每一步都要幂等,任何一步失败都要重试并上报,绝不能静默吞掉。很多团队把回收做成一个后台低优任务,结果资源泄漏积累一两天,集群莫名变慢,定位代价远大于提前写好的重试逻辑。
3.3 节点 Agent 模式:绕开 K8s API 上限
如果 300 万个沙箱都是标准 K8s Pod,那 API Server 和 etcd 一定会先扛不住:每秒几百个 Pod 创建删除,事件流和状态更新会把控制面拖垮。实际落地时,大部分团队并不是用原生 K8s 承接短生命周期容器,而是“K8s 管长期资源、节点级 Agent 管短命沙箱”的混合模式。
中心控制器只下放策略:集群有多少节点、每个节点跑什么镜像版本、全局配额是多少。节点上驻留的 agent 负责从本地池拿沙箱、配置网络、监听租约、执行回收。这样短命对象的生命周期被收敛在节点内部,控制面只需要关心资源水位和异常审计。这个设计的收益是两个数量级的:Pod 的 Create/Update 不再频繁打到 etcd,沙箱的创建速率由节点数水平扩展,不再被中心化 API 的吞吐卡死。
4. 训练侧怎么把这个能力用好
沙箱平台建得再好,最终还是要被训练流程调用。这一层最容易出的问题不是沙箱本身,而是接口协议设计不合理,让 harness 和沙箱之间出现各种隐式约定。
4.1 harness 与沙箱的接口协议设计
训练侧其实不关心沙箱底层是 runC 还是 gVisor,它只关心一件事:把一个动作丢进去,能不能快速拿到结构化结果。所以接口协议必须稳定。给一个典型的 exec 请求示例:
{ "sandbox_id": "sb_8f3a...", "action": "exec", "command": ["python", "main.py"], "workdir": "/workspace", "env": {"HARNESS_RUN_ID": "run_001"}, "timeout_ms": 30000, "max_stdout_bytes": 65536, "network_policy": "allow_dns_only" }响应至少包含 exit_code、stdout、stderr、timed_out、oom_killed 这几个字段。工具调用有个硬约束:需要即时结果。Agent 在推理过程中调起一个工具,后面所有 token 生成都依赖这个结果,所以 exec 请求的延迟直接进入训练关键路径。任何异步化包装都会让整个采样步骤变慢,这也是沙箱要紧贴训练进程部署的原因。
这里特别容易被忽略的是 max_stdout_bytes。Agent 可能写出打印一百万个日志行的脚本,不限制会直接把训练进程内存打爆;限制后还要把“结果被截断”这个状态明确返回,不然 verifier 会拿残缺 stdout 去算奖励,得出错误结论。我自己就在 eval 阶段被坑过一次:某次任务得分突然偏离,排查到最后,是 eval 脚本打印了一个几 MB 的 JSON,截断后奖励模型把关键字段读丢了。
4.2 资源配额与安全边界配置
给一份可以直接抄的 LimitRange:
apiVersion: v1 kind: LimitRange metadata: name: sandbox-limit spec: limits: - type: Pod max: cpu: "1" memory: 1Gi default: cpu: 500m memory: 512Mi资源层只是开胃菜,更重要的是进程能力和权限裁剪。推荐的默认配置包括:Drop CAP_SYS_ADMIN、禁 mount/reboot 等危险 syscall、pids 限制 256、按需加 seccomp profile。不要指望模型只会写“好代码”,prompt 里被人塞进“先 sleep 一小时再返回”这种逻辑完全可能发生,没有 cgroup 兜底,一个沙箱就能拖垮整台训练机。安全设计的核心不是把环境封死,而是让它跑不死、跑不远:编译器、网络、文件系统都要给,判断恶意行为交给奖励模型在样本层处理,工程层只负责防止它影响邻居和执行边界。
5. 常见故障与排障实录
百万级系统跑久了,排障经验比架构设计更值钱。这里的故障往往不是“创建不出来”,而是“资源悄悄消失”或者“延迟悄悄变高”。
5.1 三个真实事故复盘
镜像拉取风暴。某次凌晨全量新池冷启动,几千个节点同时拉同一个镜像,磁盘 IO 直接打满,节点陆续变成 NotReady。根因是预热任务没覆盖新镜像版本,生产池撞上了冷启动窗口。解法是三层:镜像预热做成定时流水线、P2P 分发扛峰值、并且保证生产环境的池子在任何情况下都不从零开始。
exec 超时但进程还在跑。框架把超时错误返回给模型,看起来沙箱已经“完成”,实际上进程仍然占着 CPU 继续执行。这个问题非常隐蔽,因为从外面看沙箱的租约仍在续期。后来规定:exec 超时返回的同时必须做 cgroup freeze 加 kill,并且把该沙箱踢进强制回收队列,任何超时状态都不允许继续服务下一个工具调用。
DNS 抖动导致 exec 变慢。模型生成的网络代码频繁解析外部域名,公共 DNS 一旦变慢,exec 平均耗时从 200 毫秒涨到 3 秒,训练吞吐直接掉一个量级。解决方式是 node-local DNS 缓存加可信 hosts 预注入,同时把沙箱里 /etc/resolv.conf 统一指向本地缓存。
5.2 问题速查表
| 现象 | 可能原因 | 处置方式 |
|---|---|---|
| 节点磁盘缓慢打满 | 残留 overlay 写层、日志未截断 | 强制回收流程补清理步骤,限制 stdout 大小 |
| 沙箱创建越来越慢 | 本地池镜像版本过期,触发冷启动拉取 | 池子按版本分池,预热流水线覆盖所有活跃版本 |
| exec 偶发超时 | 公共 DNS 抖动、跨节点调度 | node-local DNS 缓存,exec 请求绑定到本节点池 |
| 训练吞吐周期性掉底 | 回收线程被不可中断 IO 卡住 | 回收线程与数据面分离,失败重试带上告警 |
| 节点 CPU 异常飙高 | 沙箱内进程未随租约终止 | 超时必须 cgroup freeze + kill,并进入强制回收 |
| 偶发脏环境导致样本错乱 | 预热池复用未彻底重置 | 每次归还做完整性校验,异常镜像直接销毁重建 |
这张表不是从文档里抄来的,是我在实际运维中一条条填进去的。很多问题单独看影响不大,但 300 万/天的频率下,任何小概率事件乘以百万都是高概率事件,所以治理思路永远是:让异常在毫秒级被识别、在秒级被强制回收,而不是靠人工巡检去补救。
我自己搭过两套沙箱平台,最大的教训是:第一天就要把回收、租约和资源记账做成一等公民。300 万/天听着吓人,摊开就是每秒 35 个;它真正杀死你的从来不是创建 API 扛不住,而是回收不干净、镜像风暴、DNS 抖动这些“低频高损”问题。给正在搭 Agent 训练底座的团队一句建议:先让沙箱活不过 60 秒,再谈优化创建速度。生命周期管住了,容量账自然就平了。