第一次在后台看到那条告警时,我第一反应是“又有人把公共环境搞坏了”。巡检日志显示,某个 Agent 训练 worker 里跑起来的 Python 进程,正在反复尝试读取宿主机的系统敏感文件,还想把文件系统挂到自己新建的临时目录下。单看这一条记录,它和一次真实的入侵行为几乎没有区别。但这是模型自己生成的代码在沙箱里执行——而这种执行,恰恰是大规模 Agent 训练里每天都在发生的、最普通的操作。
我当时所在的团队一直在支撑大模型 Agent 相关训练任务,内部代号 DSec 的这套沙箱调度与安全模块,就是为了应对这类问题逐步搭起来的。它的核心不是做一个“更严格的容器”,而是把隔离、调度、镜像供给、状态恢复放在同一个体系里统一解决。如果你正在做 Agent 训练,需要让模型为了工具调用而执行真实代码,或者你只是想理解为什么 Agent 训练不能直接放在普通容器环境里跑,这篇文章里关于沙箱调度、镜像加载和状态恢复的经验,应该能帮你少走不少弯路。
1. 为什么 Agent 训练需要多出这一层“独立沙箱”
1.1 Agent 任务的核心特点是动态且不可信
传统的模型推理任务,我们可以把它看成一条静态流水线:输入进来,模型算一遍,输出出去,中间没有任何外部动作。但 Agent 训练不是这样。模型为了完成一个目标,会自己规划步骤,生成要执行的代码,调用工具,读取文件,甚至安装新的软件包。训练过程中我们还会让模型与环境反复交互,通过 rollout 采样出一条条轨迹,再用轨迹去更新模型参数。
这里有个很反直觉的事实:模型生成的东西,不能因为它“是我们训练出来的”就默认可信。模型可能因为指令错误、上下文误导,甚至单纯能力不足,写出完全超出预期的命令。它可能去读不该读的文件,可能循环拉取外部接口,可能把自己跑进死循环。在训练阶段,这些行为不是 bug,而是需要被采集和分析的样本。所以我们不能“禁止”它乱来,只能让它在乱来的时候也破坏不了任何东西。
这就像一个公司里,你不能因为实习生是自家招来的,就把他单独放在放满公章和账本的房间里办公。你需要给他一个独立工位,给一套标准软件环境,让他只能碰到自己该碰的东西。DSec 沙箱干的就是这件事:模型生成的代码可以在里面自由执行,但它永远碰不到宿主机和其他任务。
1.2 普通容器隔离为什么不够用
很多人会问:Docker 不是已经能做隔离了吗,为什么还要整套沙箱方案?容器确实是很好的基础,但它解决的是“常规隔离”,不是“对抗隔离”。普通容器和宿主机共享一个内核,Namespace、Cgroup 提供了进程、文件系统、网络层面的隔离,可一旦内核出现漏洞,或者容器被赋予了过多的 Capability,进程就可能逃逸出来。
在大规模训练集群里,这个风险会被放大。一个节点上可能同时跑着几十个 Agent 沙箱,同一批节点上可能还有别的训练任务、模型权重、用户代码。一旦有一个 Agent 生成的内核级破坏代码找到漏洞逃逸,影响是一整片,不是一个沙箱。而且训练集群经常是内网互通,横向移动的成本非常低。
所以 DSec 的思路是在容器之上再加一层“加固沙箱”。具体展开说,我们做了几件事:启用用户命名空间,把沙箱内 root 映射成宿主机上的普通用户;用 seccomp 做系统调用白名单,Agent 代码里大部分高风险的调用直接拒绝;Capability 按白名单给,比如 net_raw 默认不给,挂载操作默认不给;根文件系统只读,只有 /workspace 和 /tmp 可写,/tmp 用 tmpfs 挂载,避免脏数据落到磁盘上。
这一层做下来,即使模型真的生成了恶意代码,它面对的也是一个几乎没有可利用面的环境。我们不是要防住一个专业攻击者,而是要防住“模型随机犯傻”的小概率事件,这两者的目标完全不同,但手段可以共用。
1.3 调度、镜像、恢复,为什么必须放在一起
最初我们尝试过把沙箱、调度器、镜像仓库拆成三个独立系统去维护,结果发现它们之间耦合太深。调度一个新沙箱时,必须知道节点上有没有对应镜像缓存;镜像拉取慢,会拖累调度排队;沙箱崩溃后要恢复,恢复逻辑又得重新找节点、重新加载镜像、重新对齐状态。
后来我们干脆把这三件事统一成 DSec 的三大核心能力:调度器负责分配资源和排队,镜像服务负责让环境快速就位,状态恢复负责让跑了一半的任务能接上。它们共享同一份任务描述、同一套心跳协议、同一个元数据库。这样设计的根本原因是:在超大规模训练里,这三个环节任何一个出现缺口,都会直接变成训练吞吐的天花板。
2. DSec 沙箱调度的资源模型与排队策略
2.1 资源模型:Agent 任务需要哪些配额维度
调度最基础的问题是资源由哪些维度构成。Agent 训练任务和普通 Web 服务不一样,消耗的资源很不规律。一段模型生成的代码可能立刻把内存打满,另一个任务可能趴在网络层疯狂发包,第三个可能只是在磁盘上写了几个大文件。所以我们在常规的 CPU、内存、GPU 之外,给每个沙箱增加了一批“行为配额”。
下面的表是我们的默认配置,不一定适合所有场景,但可以当个参考起点:
| 资源维度 | 单沙箱默认配额 | 说明 |
|---|---|---|
| CPU | 200% (2 核) | 用 CFS 配额控制,不绑核 |
| 内存 | 4GB | cgroup v2 memory.max |
| 临时磁盘 | 8GB / 每个沙箱 | 可写目录的 tmpfs 上限 |
| 磁盘写带宽 | 100MB/s | 防止日志把宿主盘打爆 |
| 并发 TCP 连接 | 128 | 限制连接跟踪表占用 |
| 出网带宽 | 50Mb/s | 避免单个沙箱抢占出口带宽 |
| 单任务运行时 | 30 分钟 | 超时自动优雅回收 |
大多数 Agent 任务都用不到配额上限,但设了上限之后,一个失控样本不会拖累整个节点。配额的实现可以用 cgroup v2 加 systemd scope 来做,启用很方便。我贴一段我们节点侧初始化沙箱时实际用到的核心约束模版:
systemd-run --scope \ --property=CPUQuota=200% \ --property=MemoryMax=4G \ --property=TasksMax=256 \ --property=IOReadBandwidthMax="/dev/nvme0n1 100M" \ --property=IOWriteBandwidthMax="/dev/nvme0n1 100M"GPU 资源也是同理,通过显存分片或算力比例去限制。早期我们不敢给 Agent 任务配 GPU,后来发现很多 Agent 会调用本地视觉模型,就专门划分了一个小资源池,所有沙箱按需申请,用完立刻释放。
2.2 全局调度器和队列:从提交到运行的完整链路
DSec 调度器采用中心调度加节点代理的结构。中心调度器维护全局资源池、待执行队列和运行队列,每个节点上跑一个轻量节点代理,负责创建沙箱、上报状态、执行回收命令。两者的职责边界要非常清楚:中心只做决策,不做具体操作;节点只执行操作,不做全局判断。
一次任务从提交到运行,完整的链路是这样的:
- 任务提交,携带资源需求、镜像哈希、运行时长上限。
- 调度器校验配额,写入待执行队列。
- 分配节点时,优先选择“目标镜像缓存命中”的节点,次选负载最低的节点。
- 节点代理检查镜像层缓存,补齐缺失层,初始化网络命名空间,启动沙箱。
- sidecar 进程被拉起,开始上报心跳。
- 调度器确认心跳正常,把任务标记为运行中,开始计时计费。
- 任务正常结束或被回收,节点代理清理沙箱,释放配额。
排队策略上,我们按任务类型区分优先级:训练主任务最高,评测任务次之,人工调试最低。同一优先级内部做严格的 FIFO,不搞小聪明。为什么不用抢占?因为 Agent 沙箱是有状态的,强行杀掉一个正在执行工具调用的低优任务,丢的不只是那几秒计算,而是一整条轨迹样本。所以当高优任务需要资源时,我们对低优任务发起“优雅回收”:通知 sidecar 先打检查点,再结束进程,之后等资源空出来重新排队恢复。这样看起来稍微慢一点,但整体收益更高。
2.3 单机并发、端口分配和“旧数据污染”
单机到底能跑多少个沙箱?这个问题不能只看内存。每个沙箱都是独立的网络命名空间,会占端口,会建 iptables 规则,会占连接跟踪表条目。我们实测下来,一个 32 核、128GB 的节点,维持“训练稳定不抖动”的极限大约是 64 个同时存活的沙箱。超过这个数,节点负载开始剧烈波动,沙箱的启动和回收都会变慢。所以在调度器里,我们硬编码了每节点最大活动沙箱数,留出约 10% 的资源给系统进程和日志采集。
端口分配是另一个容易踩的坑。沙箱需要回连模型服务,也需要接收工具回调,因此必须分配动态端口。我们在每个节点上维护了一个端口池,范围为 30000-60000,由节点代理统一分配。沙箱启动后,会把“本沙箱对外的入口地址和端口”通过环境变量注入进去,这样 Agent 内部的回调用服务时不需要自己瞎猜地址。
还有一个很隐蔽的问题:回收沙箱后,旧连接可能残留在连接跟踪表里,新沙箱如果复用同一个端口,会收到上一次会话的残留数据。我们处理的办法很简单粗暴——每次回收强制销毁整个网络命名空间,重新建立回环和路由表。这样旧沙箱的任何状态都带不过来,彻底断绝了数据串扰的可能性。
3. 镜像加载:把最耗时的环节变成最快的一环
3.1 镜像加载为什么能成为吞吐瓶颈
训练平台上线初期的数据很难看。我们统计过一个训练任务从提交到真正开始执行的总耗时,结果发现镜像拉取占了四成左右。为什么这么慢?一个 Agent 训练镜像不只是 Python 运行时,它还包含模型依赖的 torch 全家桶、一整套工具二进制、测试数据集快照、自定义的国产包源配置,加起来轻松上 GB。
如果只是单个任务慢也就算了,麻烦的是所有任务几乎都是“一波一波”提交的。每次训练版本迭代,几百个节点同时开始拉新镜像,镜像仓库的网卡瞬间被打满,随后 registry 进程内存飙升,最后连锁导致一批任务因为拉取超时直接失败。那段时间我们挂在嘴边的口头禅就是:训练是卡在镜像里,不是卡在算力里。
3.2 分层、内容寻址和节点级缓存
解决这个问题的核心思路是借鉴镜像领域的成熟经验,但做得更彻底。我们把镜像拆成三层:基础层、应用层、运行层。
基础层放 Python 运行时、系统库、底层依赖,这一层基本不变。应用层放训练代码和固定依赖,每次代码迭代时重新构建,但基础层不失效。运行层放每个任务特定的工具和数据集,变化最频繁,体积却最小。这样的结构保证了 90% 的节点在绝大多数情况下只需要拉取很小的增量层,而不是全量重新下载。
镜像仓库是基于 OCI Registry 标准自建的,但我们在存储上用了严格的内容寻址:每一层用 sha256 哈希命名,镜像 Tag 只是一个 manifest,manifest 里记录各层哈希和大小。这样能避免一个经典问题:两个同学先后推了同名 Tag,导致某部分节点拉到的镜像漂移。层不可变之后,拉取、缓存、校验全部以哈希为基准,链路清晰。
节点级缓存是提速的关键。我们在每台节点上保留一个镜像层缓存目录,只有缓存中不存在的层才会回源 registry 拉取。拉取到新的层后,先校验哈希,再链接到本地存储目录。调度器做节点选择时,也会优先选目标镜像缓存命中的节点,进一步跳过拉取环节。
实测数据可以给你们一个体感:全新冷节点拉 1.8GB 的全量镜像,网络正常情况下要 40 秒左右;如果目标节点已有基础层和应用层缓存,只需要拉运行层那几十 MB,沙箱从开始创建到 ready 的 P95 时间大约 1.6 秒。这个差距在千级并发场景下,就是“跑不动”和“随便跑”的区别。
3.3 压缩层、共享层和启动加速
镜像存储我们都用 zstd 压缩,压缩比和和解压速度都不错。节点缓存里直接存压缩格式,创建沙箱时边解压边写入 overlay 层。后来的版本还尝试过基础层共享——基础层在节点上只保留一份只读数据,所有沙箱通过只读绑定叠加引用,不再为每个沙箱单独复制一份全量数据。效果非常直观:一百个沙箱在磁盘占用上几乎等于一个基础层加各自的小写层,省了大量存储空间,也减少了垃圾回收的压力。
这里有一个操作细节供参考:当节点磁盘空间受限时,千万不要急着清缓存。我们用的是引用计数加 LRU 的双层策略——先看某个层有没有被存活沙箱引用,如果还引用着,即使长期没被使用也不能删;确认无引用后,再按最近访问时间从旧到新回收。我曾见过同事图省事直接docker rmi -f,结果一批沙箱的基础层文件瞬间被清掉,后续任务全部重建,整体反而更慢。
3.4 镜像安全与离线回退
考虑到 Agent 训练沙箱代码的不可信特性,镜像本身也需要做安全收敛。我们每次构建镜像后都会做漏洞扫描,高危漏洞组件会直接被阻断部署。同时在镜像构建时不装任何非必需的高风险工具,减小攻击面。镜像仓库内部还有一道隔离:模型生成的代码运行在沙箱里,沙箱内无法访问镜像仓库的管理接口。
离线回退是整个镜像链路最后的保险。因为训练集群通常在内网,一旦 registry 服务挂了,所有依赖镜像的新任务都会卡死。我们为此保留了一个离线兜底镜像,平时不更新,只存在各节点本地。registry 出问题时,调度器自动切到兜底镜像,保证存量任务可以继续跑,已经跑起来的任务不受影响。虽然功能受限,但训练中断的损失远比功能受限大。
4. 状态恢复:从一次节点宕机说起
4.1 Agent 训练过程中到底要恢复哪些状态
做状态恢复之前,得先想明白“状态”到底指什么。Agent 训练任务不是无状态计算,它会产生四类状态。
环境状态是文件系统层面的,包括沙箱里安装的依赖包、下载的数据、改动过的配置文件。执行状态是进程层面的,包括当前执行到第几步、子进程 PID、正在运行的脚本路径。会话状态是逻辑层面的,包括与模型服务的完整消息序列、已执行完的工具调用及其结果、模型规划出的下一步动作。外部资源状态是容易被忽略的,比如临时令牌、文件锁、外部租约、回调地址。
恢复的本质,是在新沙箱里重建这四类状态,并且做到与崩溃前“对外可观测一致”。注意,我们追求的从来不是内部字节级一致,而是模型和外部工具感知到的结果一致。只要会话里看到的消息顺序、文件内容、工具返回值是一致的,训练就能正常继续。这个认知极其重要,否则你会钻进“全内存快照”的死胡同,成本和收益完全不成正比。
4.2 心跳、快照和回放机制
每个沙箱里我们都会放一个 sidecar 进程,它做四件事:采集执行状态、维护心跳、接收调度器指令、生成检查点。心跳是秒级的,每次携带当前步骤、CPU 内存占用、子进程列表、资源使用量。某个中控侧超过 10 秒没收到心跳,就会启动异常判断流程。
检查点不要做太频繁,否则对磁盘和执行的干扰很大。我们的节奏是:文件系统的增量快照每 5 分钟打一次,同时在“步骤切换点”——比如一次工具调用结束、模型准备生成下一步的时候——额外打一次。步骤切换点往往是相对安静的时刻,对正在执行的任务影响最小。
当恢复发生时,新沙箱先拉取最近一次快照,恢复文件系统;然后重放 sidecar 记录的增量日志,补齐文件变更;最后重放会话消息序列,已经完成的工具调用直接返回原结果。写入侧我们用本地 NVMe 加远端异步复制,本地保证低延迟,远端保证节点级故障时可恢复。
4.3 一次节点宕机后的完整恢复时间线
拿一次真实事故来说,一千个 Agent 并发训练中,一台节点因为硬件问题突然宕机,上面有 86 个活跃沙箱。恢复的完整时间线是这样的:
- T+0 秒:节点心跳中断,调度器将该节点标记为疑似失联。
- T+5 秒:另一台监控节点也确认该节点不可达,双节点同时判定后,正式触发恢复流程。
- T+10 秒:调度器从恢复队列取出任务,优先调度到镜像缓存命中的新节点。
- T+15 秒:新沙箱创建完成,sidecar 从远端存储拉取最近快照。
- T+20 秒:文件系统恢复完毕,开始重放执行状态和消息序列。
- T+30 秒:Agent 重新跑起来,从崩溃前那一步继续执行。
最终结果:86 个沙箱里有 81 个成功恢复并继续完成轨迹,剩下的 5 个是因为崩溃点正好落在非幂等工具调用之后,无法判断外部副作用是否已经发生,我们选择标记失败并重跑该轨迹。整体恢复时长控制在 30 秒内,几乎没有影响训练主流程。
这里面最关键的工程技巧是幂等。所有工具调用都带着 request id,重放时如果发现同一个 request id 已经存在,直接返回旧结果,不重新执行。比如“搜索”这类只读操作重放无所谓,但“发一条消息”“下单”“扣费”这类操作如果重放两次,后果可能很严重。在 Agent 训练平台里,外部工具的幂等网关不是加分项,是必需品。
4.4 什么情况不建议硬恢复
状态恢复不是万能的。我们后来总结出三类“不硬恢复”的情形。
第一类是环境损坏的崩溃。Agent 代码把关键文件改成不可恢复的形态,或者把 Python 运行时搞坏了,这时候硬恢复只会让新沙箱继续摔在同一处。我们的规则是同一个步骤连续恢复失败两次,直接放弃该轨迹,回到最近的安全检查点重新采样。
第二类是明确的内存超限。沙箱被 OOM 杀掉时,正在运行的进程堆栈数据全部丢失,硬恢复很可能再次触发 OOM。我们做的是给目标步骤提高配额后重跑一次,而不是直接恢复现场。如果提高配额还是 OOM,就把这个样本标记为“异常执行样本”,它本身反而是训练数据质量分析的重要素材。
第三类是恢复风暴。如果网络抖动导致大量活着的节点被误判为宕机,恢复任务会同时启动,反而把集群压垮。所以我们引入了双节点仲裁:一个节点失联时,必须有另一个独立观察者确认,才认为节点真的宕机。另外恢复任务要有并发上限,避免瞬间大量重排重放。
5. 踩过坑之后沉淀下来的几条铁律
5.1 网络限制必须在沙箱层做,不能指望代码检查
我们踩过最狠的一脚,是一个 Agent 生成了类似“端口扫描”的代码,在五分钟内向网段内其他节点发了几十万个小包。那个节点上的出口带宽瞬间打满,同节点的正常训练任务全部卡顿,模型服务侧的请求超时率飙升。
排查时最容易看到的指标是连接跟踪表溢出。但我们没有去埋怨模型,而是反思平台为什么没有拦住。后来我们把网络限制彻底下沉到沙箱层:每个沙箱创建时强制设置出网带宽上限和连接数上限。代码检查那种事后逻辑根本追不上 Agent 的随机发挥,只有在执行层做硬限制才是可靠的。现在所有沙箱默认出网带宽 50Mb/s,如果任务需要更大带宽,显示申请并写明理由。
5.2 镜像仓库被自己人打挂,不是危言耸听
有一次训练版本热更,几位同学的代码改动把基础层也带上了新哈希,相当于把全量缓存全部击穿。几百个节点几乎同时发现缓存不命中,同时回源 registry 拉层,仓库进程内存飙升,最终整站不可用。那次事故后我们立了一个规矩:基础层的变更必须过发布审批,普通训练迭代只能动应用层和运行层;所有镜像构建必须带不可变哈希 Tag,同一个 Tag 不允许覆盖。
另外,registry 前端加了基于令牌桶的并发控制,每个层同时最多允许 N 个节点拉取,超出部分在节点侧排队。这是故意制造“受控的慢”,而不是让仓库直接崩溃。事故是不优雅的,排队只是慢,但至少任务不会全挂。
5.3 恢复流程本身可能成为新的事故源
状态恢复是为了减少事故,但设计不好它自己就是事故。我们在早期也遇到过:一个网络分区导致三十多台节点同时被判离线,恢复机制随即创建了几乎同样数量的替代任务,这些替代任务同时去访问模型服务,直接把模型服务的队列打满,最后变成了全集群范围的连锁故障。
从那以后,我们坚持两条原则。第一,判定节点离线必须有第二观察者,单点判断永远不可信。第二,恢复任务必须限流,不能一把梭同时拉起所有替代沙箱。宁可让恢复慢一点,也不能让恢复本身击溃依赖的下游服务。这条经验适用于所有做调度系统的人,请务必刻在脑子里。
5.4 日常运维检查清单
最后晒一份我们内部要求的日常运维清单,算是从一次次故障里压出来的最低标准:
- 镜像层不允许覆盖,任何更新只允许新 Tag 上线。
- 每个沙箱必须有运行时上限,不允许存在“永久任务”。
- 所有外部工具调用必须走网关,网关负责幂等和重试上限。
- 对所有运行中的沙箱做随机抽样审查,记录输入输出序列用于复盘。
- 任务提交时必须声明资源需求和镜像哈希,缺一项直接拒绝排队。
- 每季度做一次恢复演练,故意杀一台节点,验证整套流程不是纸上谈兵。
清单看起来很基础,但大部分平台的问题是“基本都没做到”。把这些做成硬性校验而不是自觉,稳定性会提高一个量级。
说回最初那条告警。后来我们查清了,那个试图读取系统敏感文件的 Agent,是因为上下文里的错误指令导致模型生成了探测本地配置的代码。它被沙箱里的权限规则拦住了,没有造成任何实际影响,反而成为训练数据里一条值得分析的异常轨迹。我觉得这就是 DSec 这类系统最大的价值:它不是阻碍 Agent 探索,而是给探索划定了一个不会伤到自己的安全边界。调度、镜像、恢复这三件事,本质上都是为了让这种探索更稳、更快、更持久。
如果你也在做 Agent 训练平台,我最后想给一个建议:先想清楚你的沙箱是给“可信的自己人”用的,还是给“不可信的模型生成代码”用的。如果偏向后者的场景,请务必把隔离边界、镜像不可变、恢复幂等这三件事当成一等公民来设计,它们不值得你后续拿一个个事故来重新认识。