ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

智能体沙箱生产落地:隔离选型、冷启动与资源治理

智能体沙箱生产落地:隔离选型、冷启动与资源治理

智能体沙箱(Agent Sandbox)这个词,我最早是在做代码解释器能力的时候被迫研究的。当时只有一个朴素的想法:让模型生成的 Python 代码能跑起来就行。结果第一版上线不到两天,就有一次执行把临时目录塞了几十 GB,还有一个死循环把整台机器的负载拉到 40 以上,最后只能硬重启。那次之后我才明白,智能体沙箱不是一个"顺手加一下"的功能模块,而是一套需要单独设计、单独压测、单独监控的基础设施。它要解决的问题很具体:在不完全信任模型输出、不完全信任外部输入的前提下,让代码和工具调用安全地跑起来,并且跑得足够快、足够便宜、足够可观测。

这篇文章面向的是准备把智能体从 Demo 推进到生产环境的团队——你可能已经在用容器跑代码,也可能还在用exec()加个超时;无论哪种,下面这套从架构到落地的拆解应该都能对上号。我会把隔离方案的选择逻辑、控制面的组件划分、冷启动的优化组合、资源回收的边界条件,以及那些半夜把我叫起来的坑,一条条摊开讲。

1. 智能体为什么必须住进盒子里

1.1 风险面是从"工具调用"那一刻开始膨胀的

只做文本生成的模型,风险主要停留在内容层面。一旦给它挂上工具——读文件、写文件、发 HTTP 请求、执行 shell、装依赖包——风险就从"说错话"变成了"做错事",而且是不可逆的那种。我观察到绝大多数事故落在三个模式里:模型在调试循环里写出了破坏性命令并且真的执行了;提示注入让智能体把上下文里的敏感数据拼进一个外部请求发走;以及最常见的,一个没有收敛条件的循环把 CPU 和磁盘吃干净。

这三个模式的共同点是:它们都不是模型的"恶意",而是模型在不确定状态下的正常输出。你没法靠提示词工程把它们消灭干净,只能在执行层兜住。所以沙箱的定位要说清楚:它不是用来防模型的,而是用来防"模型 + 不可控输入 + 有副作用的运行环境"这个组合。

1.2 沙箱真正要守的四条边界

很多团队做沙箱只想到了"别让它删库",实际上边界有四条,缺一条都会在某个时刻咬你一口。

边界类型不加控制会发生什么常用控制手段
文件系统读到宿主机上的配置、密钥、其他租户的产物只读根 + 可写临时层 + 挂载白名单
网络数据外带、内网横向探测、被当作请求源默认拒绝 + 出口代理 + 域名白名单
资源单次执行拖垮整台机器,影响同机其他会话cgroup v2 配额 + rlimit + 超时强杀
生命周期沙箱长期驻留、状态串味、产物残留会话超时 + 空闲驱逐 + 销毁即清理

这四条边界里,最容易被低估的是生命周期。我见过一个实现,沙箱用完不销毁,只是"重置"了一下工作目录,结果上一位用户的~/.config和 pip 缓存被下一位用户继承,出现了非常诡异的"为什么这次执行能 import 到一个我没装的包"。这类问题排查起来极其费时,因为现象和原因之间隔了一层。

1.3 "隔离"和"限制"是两件不同的事

这两个概念经常被混着用,但在设计时得分清楚。隔离是让对方看不见,限制是让对方看得见但用不了。chroot改变的是路径视图,属于隔离;seccomp拦截系统调用,属于限制。只做限制不做隔离,攻击面依然存在——比如你限制了写入权限,但目录结构仍然可枚举,信息泄露照样发生。

一个健康的沙箱是两层叠加:先用命名空间把 PID、网络、挂载、IPC、UTS 全部切开,让进程看到一个"假装只有自己"的世界;再用 seccomp 和 capability 白名单把系统调用面收窄。我在实际项目里会给执行进程一个明确的能力清单,默认全部丢弃,只按需加回CAP_CHOWN这类极少数必要项,其余一律拒绝。

2. 隔离强度的光谱:六种方案的真实取舍

2.1 从语言级隔离到微虚拟机,性能与安全的跷跷板

沙箱方案不是"越强越好",而是"在你能接受的兼容性和启动开销下,取最强的那个"。下面这张表是我这几年实际用过或深度评估过的六档方案,参数是量级感受而非精确基准,具体数字会随机器和负载波动。

方案隔离强度冷启动量级生态兼容性运维复杂度
子进程 + rlimit很弱毫秒级完全兼容极低
容器(namespace + cgroup)中百毫秒到秒级基本兼容低
容器 + seccomp + 只读根中偏强同上基本兼容中
用户态内核(系统调用拦截型)强百毫秒级存在 syscall 缺口中高
微虚拟机(基于 KVM 的轻量 VM)很强百毫秒级接近完全兼容高
WASM 运行时强(内存安全)毫秒级差,Python 生态弱中

关键在于理解每一档"漏"在哪里。子进程加setrlimit只防资源滥用,文件系统和网络完全敞开,稍微复杂一点的场景就不能用。标准容器把视图切开了,但共享宿主内核,内核漏洞就是共因失效点。用户态内核把系统调用拦在用户空间自己实现,安全性上一个台阶,代价是某些系统调用它压根不实现,你的程序会在一个意想不到的地方返回ENOSYS。微虚拟机给每次执行一个独立内核,隔离最彻底,代价是镜像体积、启动路径和运维工具链都要重做一遍。

2.2 为什么兼容性是最大的隐藏成本

选型时最容易犯的错,是拿一个"hello world 级别的脚本"去测所有方案,然后得出"都能跑"的结论。真实的智能体执行负载完全不是这样:它会在运行时pip install,会起子进程,会fork,会用到mmap,偶尔还会用到共享内存或者io_uring。

我在一个项目里踩过这个坑。当时选了系统调用拦截型方案,Demo 里跑pandas一切正常,上线后发现只要用户代码里用了某个多进程库,执行就会卡死——原因是它依赖的一个系统调用在被拦截后走了降级路径,行为和原生内核不一致。这类问题不会在启动时报错,只会在特定负载下静默地慢或者挂。所以我的建议是:选型阶段一定要用真实的历史执行日志做回放压测,把过去一周所有跑过的代码片段批量丢进去,看失败率和耗时分布,而不是自己编几个测试用例。

2.3 我实际用的决策路径

如果非要给一条可执行的判断线,我会这么走:

先问执行的代码是谁写的。如果是模型生成的任意代码,直接上微虚拟机或者强隔离方案,不要犹豫,因为输入完全不可控。如果是平台预置的受控脚本,参数由模型填,那容器加 seccomp 加只读根就够了,没必要为了理论上的安全余量付出几倍的运维成本。如果只是表达式求值或者简单数据变换,WASM 或者干脆在宿主进程里跑沙箱化的解释器都行。

再问一个补充问题:同机多租户吗。如果一台机器上会同时跑不同用户的执行,隔离等级要往上提一档,因为共因失效的后果被放大了——一次内核级的问题会同时影响所有租户。

3. 控制面的架构拆解

3.1 组件怎么切

一套能上生产的沙箱,控制面至少要拆出这几个角色:接入层负责鉴权和限流,调度器负责把执行请求分配到具体节点,池管理器负责维护预热实例的库存,执行代理跑在每台机器上负责真正拉起和回收沙箱,产物服务负责把执行结果和生成的文件传回来,事件流负责把生命周期事件吐给监控和计费。

拆这么细不是为了架构好看,而是因为它们的扩缩容节奏完全不同。调度器和接入层是无状态的,可以随便水平扩;池管理器是有状态的,要处理并发抢占和库存一致性;执行代理跟机器绑定,扩容等于加机器。混在一个服务里,最后一定会出现"为了扩容接入层结果把池状态搞乱"这种事。

3.2 一次执行请求的完整旅程

我把这条链路走一遍,你就能看出哪些环节是延迟大头。请求进来后,接入层做鉴权和租户配额检查,通过后交给调度器;调度器先问池管理器要一个可用实例,池管理器返回一个已经预热好的沙箱句柄,同时把它标记为占用;调度器把代码和参数通过执行代理投递到对应节点;执行代理把代码写进沙箱的挂载目录,启动执行进程,同时开启一个计时器;执行过程中,标准输出和标准错误通过流式通道回传,产物文件在执行结束后被打包上传;最后执行代理上报结束事件,池管理器把这个实例标记为待回收,走清理流程或者直接销毁重建。

这条链路上,真正影响用户体验的是"要实例"这一步。鉴权和上传都是毫秒到几十毫秒级别,而要实例如果打到冷启动路径,就是几百毫秒到几秒的差距。所以第 4 节专门讲这个。

3.3 数据面为什么必须是推的模式

早期我设计成执行代理定时上报状态,调度器来拉。跑了一段时间发现两个问题:状态有延迟,调度器看到的"空闲"实例可能已经被占用了;以及请求量大时轮询把控制面打满。后来改成事件推送——执行代理在状态变化时主动推事件,控制面只维护一个最终一致的状态视图,配合租约机制解决并发抢占。租约的意思是:池管理器把实例给出去的时候带一个过期时间,执行代理必须在这个时间内确认接管,否则实例自动回到池里。这样即使某台机器失联,实例也不会被永久占死。

# 池管理器返回的实例租约结构示意 instance_id: sbx-7f3a9c node: node-12 lease: issued_at: 1735689600 ttl_seconds: 15 # 超时未确认则回收 heartbeat_interval: 5 # 执行中每 5 秒续约一次 spec: cpu_cores: 2 memory_mb: 2048 disk_mb: 4096 network_policy: egress-proxy-only wall_clock_timeout: 120

3.4 会话粘性:什么时候必须复用同一个沙箱

有些场景必须复用,比如多轮对话里用户先说"帮我读一下这个 CSV",下一轮说"按第二列排序"。这时候如果每轮都新建沙箱,上一轮的文件就没了。解决办法是给会话分配一个粘性标识,调度器优先把同一会话的请求路由到同一个实例,实例带一个空闲计时器,超过阈值才销毁。

但粘性会带来一个新问题:状态污染。同一个沙箱被多轮使用,工作目录里的残留会互相影响,用户上一轮装的包这一轮还在,上一轮写的临时文件可能被这一轮的脚本误读。我的做法是给每一轮执行分配独立的子目录,通过环境变量告诉代码"你的工作区在这里",根目录保持只读,只有这个子目录可写。这样既保留了会话连续性,又把污染面控制住了。

4. 冷启动是生死线

4.1 把冷启动拆成四段来看

用户感知到的"卡",其实是四段延迟叠加:调度延迟(排队等实例)、镜像延迟(拉镜像或者恢复快照)、初始化延迟(启动运行时、挂载文件系统、配置网络)、依赖延迟(导入库、装包、加载模型)。这四段的优化手段完全不同,不拆开就只能瞎调。

调度延迟靠扩容策略解决,池子水位低就提前加机器。镜像延迟靠快照和镜像分层解决,这是最大的一块。初始化延迟靠精简基础镜像解决,把不需要的组件全部砍掉,启动脚本里能省的都省。依赖延迟靠预置环境和缓存解决,把常用包提前装进模板镜像。

4.2 预热池的三种水位策略

最朴素的做法是固定池:常驻 N 个空闲实例,用完就补。实现简单,但资源利用率低——闲时占着机器不用,忙时又补不及。第二种是水位池:维持一个最小值和最大值之间的动态水位,根据最近一分钟的取用速率预测需要补多少。这个效果最好,但预测逻辑要做对,我见过水位抖动导致短时间大量创建又大量销毁的情况,反而更糟。第三种是按租户分池,给大客户预留专属实例,避免被小客户的突发流量挤占。

我一般从固定池起步,把最小值设成"历史 P50 并发",再叠一个简单的滑动窗口水位算法往上加,够用且稳定。切忌一上来就上复杂的预测模型,冷启动优化里 80% 的收益来自"有池子"而不是"池子调得多聪明"。

4.3 快照恢复比重新启动快在哪里

重新启动一个沙箱,本质是把运行时从磁盘重新加载到内存。快照恢复是直接把这个进程的内存镜像灌回去,省掉了初始化的绝大部分工作。文件系统快照和内存快照可以分开用:文件系统快照让镜像层秒级就位,内存快照让运行时跳过冷启动。

代价是快照的兼容性和新鲜度。快照打下来之后,底层库升级了、配置变了,旧快照恢复出来就是不一致的状态。所以必须建立快照的版本管理和定期重建机制,我一般设成"底层镜像一变就作废所有快照",虽然浪费一点,但省掉了大量诡异的排查。

优化手段收益量级代价与风险
常驻预热池高,消掉绝大部分冷启动闲置资源成本
文件系统快照中高需要版本管理,过期快照有坑
内存快照恢复极高兼容性脆弱,恢复后需校验状态
基础镜像精简中可能砍掉某些库导致兼容问题
依赖预装缓存中镜像体积变大,拉取变慢

4.4 依赖预装:把 pip 缓存做成基础设施

智能体执行里最耗时的动作之一就是装包。完全不装不行,随便装又慢又不可控。我采用的折中方案是维护一套预置环境模板:把过去一个月执行日志里出现频率最高的那批包,分几个模板(数据分析、图表、文本处理、HTTP 请求)预先装好,模板镜像按热度排序。执行时先看请求的包在不在模板里,在就直接用,不在才走在线安装,并且给在线安装单独设置更短的超时和更严的网络策略。

线上数据也印证了这个做法:预置模板覆盖之后,需要在线安装的执行占比从三成多降到个位数,整体 P95 耗时下降非常明显。

5. 资源治理与生命周期管理

5.1 cgroup v2 里那几个真正管用的参数

资源治理不要靠应用层自己数,交给内核。cgroup v2 里我必设的是这几个:

# 沙箱资源的 cgroup v2 配置示例(仅示意关键参数) # 内存:硬上限与软限双设,软限触发回收,硬限触发 OOM echo "2G" > /sys/fs/cgroup/sbx-7f3a9c/memory.max echo "1600M" > /sys/fs/cgroup/sbx-7f3a9c/memory.high # CPU:按配额限制,100000 为 1 核的时间片基准 echo "200000 100000" > /sys/fs/cgroup/sbx-7f3a9c/cpu.max # 进程数:防 fork 炸弹的最后一道墙 echo "256" > /sys/fs/cgroup/sbx-7f3a9c/pids.max # IO:限制写入带宽,避免把共享盘打满 echo "8:0 rbps=52428800 wbps=52428800" > /sys/fs/cgroup/sbx-7f3a9c/io.max

memory.high这个参数很多人不用,但它非常关键。它给出一个软阈值,超过之后内核会开始积极回收页缓存并给进程施加压力,而不是直接 OOM。这样对于"内存用量只是短暂冲高"的场景,程序能自己撑过去,不会因为一次峰值就被杀掉。我给memory.high一般设成memory.max的 80% 左右。

5.2 回收的三道闸门

实例回收我设计了三个独立触发路径,任何一个命中都会走清理流程。第一道是执行超时,墙钟时间到点直接发信号,先SIGTERM给一个短暂的优雅退出窗口,不响应就SIGKILL。第二道是空闲驱逐,会话粘性的实例在没有请求进来超过阈值后被回收。第三道是资源异常,执行代理检测到实例的内存或进程数接近上限,提前主动终止,避免它影响到同机的其他实例。

三道闸门的阈值要分开调。超时阈值看业务,代码执行一般给 60 到 300 秒;空闲驱逐看会话模式,多轮对话可以给到 10 分钟以上,一次性执行给 30 秒就够;资源异常阈值要留足余量,别设成 95%,因为从检测到强杀有延迟。

5.3 僵尸进程:那个让沙箱"越跑越慢"的隐形元凶

容器里如果第一个进程不是真正的 init,它就不具备回收孤儿进程的能力。智能体执行又特别喜欢起子进程——subprocess.Popen、多进程库、shell 管道,随便哪个没等wait()就退出,都会留下僵尸。僵尸本身不占 CPU,但会占 PID,累积到pids.max之后,新进程就起不来了,表现是"沙箱还在,但什么都跑不了"。

解决办法有两个,选一个就行:要么在运行时里用tini或者dumb-init作为入口进程,它专门负责reap;要么在执行代理里定期扫一遍/proc找状态为Z的进程并清理。我倾向前者,因为它是结构性解决,不用靠定时任务兜底。

# 执行代理侧的轻量巡检:发现僵尸堆积就主动终止该实例 import os, signal def count_zombies(pid_root="/proc"): zombies = 0 for entry in os.listdir(pid_root): if not entry.isdigit(): continue try: with open(f"{pid_root}/{entry}/stat") as f: fields = f.read().split() if len(fields) > 2 and fields[2] == "Z": zombies += 1 except (FileNotFoundError, ProcessLookupError): continue return zombies # 超过阈值说明实例已经处于不健康状态,重建比修复更划算 if count_zombies() > 64: os.kill(os.getpid(), signal.SIGTERM)

5.4 计量与配额:没有账本就没有治理

每个租户必须有一本账。我记录三样东西:执行次数、CPU 毫秒数、峰值内存。CPU 毫秒从 cgroup 的cpu.stat里读,峰值内存从memory.peak读,这两个都是内核直接给的,比自己统计准。有了账本才能做配额,才能在大客户把池子占满时知道该限谁。

这里有个细节容易忽略:执行失败要不要计费。我的做法是区分类型,因为平台自身原因(调度失败、节点故障、超时强杀)不计费,因为用户代码原因(语法错误、业务逻辑抛异常、资源超限被终止)照常计费。不做这个区分,用户会觉得平台在坑他;全都免费,又会有恶意刷量的风险。

6. 生产环境踩过的坑与排查链路

6.1 OOM 误杀:页缓存也是内存

这是我踩得最深的一个坑。现象是某些读大文件的执行总是被 OOM 杀掉,但看进程的内存占用才几百兆,远没到上限。原因在于 cgroup 的内存统计包含页缓存——进程读文件时内核会把文件内容缓存到内存里,这部分算在这个 cgroup 头上。一个 2 GB 的 CSV 被read()一遍,页缓存就涨到 2 GB,memory.max一到,进程就被杀了。

排查链路是这样的:先看被杀的进程是否涉及大文件读写,如果是,就去看memory.current和memory.stat里的file字段,确认页缓存占比;再确认memory.high有没有设置,如果没设,内核就没有温和回收的机会,只能等到硬限触发强杀。修复方案是双管齐下:设置memory.high让内核提前回收页缓存,同时在执行模板里引导用户用流式读取而不是一次性读入。

6.2 文件描述符泄漏导致"沙箱莫名变慢"

某个时间段开始,部分实例的执行耗时从 200 毫秒涨到 3 秒以上,CPU 和内存都正常。查了很久才发现是文件描述符泄漏:每次执行都打开一个日志管道但没关,累积到RLIMIT_NOFILE附近时,后续的open()开始走失败重试路径,每次都带一点延迟。现象是"慢"而不是"错",所以特别难定位。

定位方法是进到实例里看/proc/<pid>/fd的数量,和基线对比。修复分两步:把RLIMIT_NOFILE设成合理值(我一般给 1024 到 4096,不要给无限),以及在执行代理里做资源清理的finally保障,确保管道、临时文件、网络连接在任何退出路径上都被释放。

6.3 快照恢复之后那些"不该出现的重复"

用内存快照恢复实例时,有几个状态是必须重新初始化的,否则会出现诡异的重复。最典型的是随机数:快照里保存的是当时的熵池状态,多个实例从同一快照恢复,会得到相同的随机序列。如果业务里用随机数做请求 ID 或者幂等键,就会出现碰撞。类似的问题还有网络标识重复、临时目录里的陈旧文件、单调时钟的基准偏移。

我的处理清单是:恢复后立刻重新播种随机源、重新生成实例唯一标识、清空临时目录、用单调时钟做超时判断而不是墙钟。这几条写进恢复脚本的固定流程里,不靠人记。

6.4 一个完整的排查时间线

把上面这些串起来,我给一个真实的排查顺序,你可以直接拿去用。发现"执行失败率上升"时,第一步看失败分布,是按节点集中还是全局均匀——按节点集中就是机器问题,全局均匀就是代码或配置问题。第二步看失败类型,是超时、OOM 还是异常退出,这决定了往哪个方向查。第三步如果是超时,先看是调度超时还是执行超时,调度超时说明池子不够,执行超时说明资源或依赖有问题。第四步进到具体实例复现,用同样的输入跑一次,看/proc下的进程状态、fd 数量、cgroup 统计。第五步对比基线,把这台机器的指标和历史正常值放一起看,差异点往往就是答案。

这个顺序的价值在于它保证你每一步都在缩小范围,而不是东一榔头西一棒子。我见过太多排查是"想到什么查什么",最后查了半天发现方向从一开始就错了。

有件事我到现在还在坚持做:每次线上事故之后,把排查路径写成文档,标注每个环节该看什么指标、在哪个面板看。半年下来这份文档变成了团队最常用的东西,新人接手也能自己走一遍。沙箱这类基础设施的价值不在于它多先进,而在于它出问题的时候,你能不能在一个小时内定位并恢复——这才是生产级和玩具级的真正分界线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进