☰
大模型生成代码的安全执行:OpenSandbox沙箱方案与实践
2026/10/14 22:00:47 网站建设 项目流程

1. 大模型生成代码之后,"敢不敢跑"才是真正的分水岭

1.1 从代码补全到自主执行的信任跃迁

大概半年前,我把一个微调过的代码生成模型接进内部工具链时,第一次被这个问题击中。前端的同事很兴奋,说只要告诉模型"把这个 CSV 按日期分组汇总",它就能自动写出一段能跑的 Python 脚本。脚本确实写得又快又好,但问题紧跟着就来了——脚本谁来执行?在哪里执行?

一开始的方案是在开发机上直接跑。结果没撑过一周就出了状况:有一段模型生成的代码调用了subprocess,在后台起了个循环,把磁盘写到满;还有一次它"自作聪明"地遍历起/etc目录。那一刻我意识到,大模型写代码这件事已经相当成熟,但让大模型安全地执行代码,完全是另一个层次的问题。

传统编程工具链里,代码生成工具交付的是"代码文本",开发者 review 之后才手动运行,信任决策的主动权始终在人手上。但进入 Agent 模式后——模型根据自然语言指令生成代码、自动执行、读取结果、继续迭代——执行权从人转移到了机器。这个翻转带来的核心矛盾是:模型对代码的"理解"不等于对代码"后果"的理解,而用户真正想要的是结果,代码本身只是中间产物。

这篇文章要讲的 OpenSandbox,就是我为这个场景专门设计的一套沙箱方案。它解决的核心问题是:大模型生成的代码,如何在一个可控、可审计、可回收的环境里安全执行,同时把结果高效地反馈给模型继续迭代。我会把隔离边界的设计逻辑、关键实现细节、以及我实际运行大半年踩过的坑,全部摊开讲。

1.2 大模型场景下的风险清单:比你想的更细

做沙箱之前,我先列了一个针对大模型生成代码的风险清单。这很重要,因为"风险意识"直接决定你要在沙箱里投入多厚的防御,也决定后续每一层设计的技术选型。

风险类别具体表现实际危害
资源滥用死循环、超大数组分配、fork 炸弹拖垮宿主节点,影响同机其他服务
文件系统访问读取/etc/passwd、遍历磁盘目录内部信息泄露,暴露服务器结构
进程逃逸代码内嵌subprocess,启动反弹进程完全脱离代码上下文控制
网络外联将数据打包后外传,或下载恶意依赖数据泄露,供应链被污染
持久化破坏写 cron、改系统配置清理困难,留下长期后门

这里有个很多人没意识到的地方:大模型生成代码的安全风险,不只是"模型被恶意 prompt 注入"。其实模型完全可能在无恶意的情况下,因为对环境的无知而生成危险代码。举个例子,它为了"查看 CPU 信息",会理所当然地调用os.system('cat /proc/cpuinfo')。这在本地复现环境没问题,但一旦放到生产环境,就成了一个不受控的系统调用入口。

还有个容易被忽略的点:大模型写代码时,对"某个库到底存不存在、某个函数具体参数是什么"是概然性判断,所以经常凭空捏造 API。这类代码的错误往往不是安全问题,而是执行层面的频繁崩溃——但这同样需要沙箱来承接,因为每一次崩溃都是一次进程级别的执行尝试,不能让它在宿主机上直接发生。

1.3 为什么不能指望"约束 Prompt"代替沙箱

有人问过我:既然模型是自己生成的代码,能不能在 Prompt 里加一条"不许使用危险操作",从源头杜绝?

这个问题我在项目初期专门验证过。结论是:Prompt 约束只能降低概率,不能消除风险。大模型是概率生成器,你无法在生成阶段严格保证"不生成某类代码"。你告诉它不要用subprocess,它可能在某个分支逻辑里还是写出来了;你告诉它不要访问网络,它在解析 URL 时依旧会调requests.get。RAG 约束、规则过滤、系统提示词,在人机协同的宽松场景里有用,但在"模型自主执行"的场景里,任何基于"模型自觉"的防线都是脆弱的。

真正靠得住的防线在执行层——让所有代码都在一个默认拒绝的环境里跑。这正是 OpenSandbox 的核心指导思想:不在源头做过度的限制,而在执行边界上做彻底的隔离。就像给访客安排房间,你不需要叮嘱他"不要碰你的保险柜",而是直接把保险柜放在另一栋楼里。

2. 隔离边界怎么选:进程、容器,还是微型虚拟机

2.1 隔离方案的投入产出账

动手设计前,我先过了一遍主流的隔离方案。这个选型很关键,因为它直接决定了后续所有设计的复杂度。我把当时对比的几个方案摆出来:

隔离方式隔离强度启动速度资源开销适合场景
裸进程 + seccomp弱(共享内核)极快极低轻量脚本快速执行
容器(Docker)中(内核命名空间隔离)快低通用代码执行
gVisor / 用户态内核强(隔离 syscall 层)快中不可信代码批量执行
微型虚拟机(Firecracker)最强(硬件虚拟化)中高极高安全等级

对于大模型代码执行这个场景,我最终的选择是容器为主、seccomp 兜底、gVisor 作为高安全等级的候选切换。原因有三条,每一条都是实际经验得出的:

第一,大模型场景的代码执行频率很高。在一个多轮对话里,模型可能反复生成、执行、修改代码十几个来回,如果每次执行都要启动一台微型虚拟机,交互延迟完全不可接受。容器秒级启动这个优势,在体验上几乎是决定性的。

第二,模型生成的 Python 代码往往依赖第三方库,比如 pandas、numpy、requests。容器天然适合把这些依赖打包成镜像,按任务类型选择不同的运行环境。如果换成裸进程方案,环境准备和切换的成本会把你拖垮。

第三,从运维的成熟度来看,容器有最丰富的生态。资源限制、日志采集、网络策略、容器泄漏回收,这些都有现成工具。我宁可把精力花在业务逻辑上,也不想在基础设施层面重复造轮子。

2.2 OpenSandbox 的四层防御模型

确定容器为主之后,我搭了一套四层防御模型。设计原则很简单——分层防御,每层只管一件事,互不依赖。

第一层是文件系统隔离。容器内部只挂载一个临时工作目录,其余路径全部只读。这样即使代码执行了文件删除或写入语句,影响范围也被锁死在一个临时目录里。

第二层是资源限制。用 cgroups 限定 CPU 配额、内存上限、进程数上限。这一层防的是资源耗尽攻击——最典型的死循环、内存膨胀、fork 炸弹。

第三层是系统调用过滤。通过 seccomp 禁掉危险系统调用,比如mount、reboot、ptrace。这是纵深防御里最容易被忽略但非常关键的一层,因为即使代码获得了某种程度的执行权限,它也没有办法动内核级别的底层操作。

第四层是网络限制。默认禁止容器访问外网,白名单式开放特定域名。对我而言,绝大多数大模型生成的代码压根不需要联网,默认断网能直接掐掉数据外传风险,也避免模型"顺手"执行pip install安装一个来历不明的依赖包。

这套模型里有几个防御细节是实践中才确定的,我在后面会展开讲。

2.3 容器镜像的用料清单

镜像本身也是一道防线。我给 OpenSandbox 定制了基础镜像,基于python:3.11-slim,按用途分类打了两类镜像:

第一类是通用执行镜像,预装 pandas、numpy、matplotlib、scipy、requests、openpyxl 这些高频库。装的时候必须注意体积控制,python:3.11-slim本身只有一百多兆,装完这些库大概到 800MB 左右,已经比较臃肿了,所以我会用--no-cache-dir关掉 pip 缓存,并且清理 apt 残留。

第二类是零依赖的精简镜像,只带 Python 标准库,用于执行简单的纯算法代码。这类镜像启动更快,攻击面也最小。

镜像构建时有一个关键操作:用非 root 用户运行代码。Dockerfile 里加一个普通用户,容器的 entrypoint 切换过去,这样即使代码通过某种方式拿到了容器的 shell,它也没有 root 权限去做容器内部的高危操作。

FROM python:3.11-slim RUN groupadd -r sandbox && useradd -r -g sandbox sandbox \ && pip install --no-cache-dir numpy pandas requests USER sandbox WORKDIR /workspace

这个设计看似简单,实际上把很多潜在的安全问题消解在了初始状态里。

3. 调度器实现:从代码注入到容器回收的完整链路

3.1 沙箱执行的六个阶段

OpenSandbox 的执行调度器,我用 Python + Docker SDK 实现,完整流程分六个阶段:

  1. 接收大模型生成的代码字符串
  2. 创建临时工作目录,把代码写入main.py
  3. 构建容器参数,挂载工作目录到/workspace
  4. 在容器内执行python /workspace/main.py
  5. 捕获 stdout、stderr、exit code,同时监控超时与资源超限
  6. 回收容器和临时目录

这个流程看起来简单,但每个阶段都有值得细扣的细节。我们一个个过。

3.2 核心创建逻辑与关键参数说明

先看容器创建的核心代码。这段代码是我实际在跑的版本,每一步注释都标了原因:

import docker client = docker.from_env() def create_sandbox_container(work_dir: str, memory_limit: str = "512m", cpu_limit: float = 1.0, network: bool = False): container = client.containers.run( image="python:3.11-slim", command=["sh", "-c", "cd /workspace && python main.py"], working_dir="/workspace", volumes={work_dir: {"bind": "/workspace", "mode": "rw"}}, network_disabled=not network, mem_limit=memory_limit, nano_cpus=int(cpu_limit * 1e9), pids_limit=64, read_only=True, tmpfs={"/tmp": "size=64m"}, cap_drop=["ALL"], security_opt=["no-new-privileges:true"], detach=True, ) return container

这几个参数每个都有讲究,我挑四个最关键的展开。

network_disabled=True是我最看重的一个。模型生成的代码绝大多数不需要访问公网,默认断网等于直接砍掉了数据外传这条路。就算代码里有恶意的上传逻辑,它也找不到可以连接的端点。

read_only=True配合tmpfs的组合,是我调试了很久才敲定的。纯只读的根文件系统会导致很多 Python 库在运行时报错——有些库会在运行时悄悄写缓存文件,比如导入某个大型库时生成__pycache__。完全不只读又控制不住风险。最终方案是根文件系统只读,只给/tmp放一个 64MB 的可写临时空间,既满足运行时临时文件的正常需求,又确保真正重要的数据无法被落盘。

cap_drop=["ALL"]和security_opt=["no-new-privileges:true"]是配套使用的。前者丢掉容器内所有 Linux capabilities,后者防止进程在运行过程中提升权限。这两条配合非 root 用户,等于给容器内部加了三道权限紧箍咒。

pids_limit=64是防 fork 炸弹的一个务实手段。它限制容器内最多只能同时存在 64 个进程,一旦超限,新进程会被拒绝创建,死循环也起不了作用。

3.3 执行、超时与清理的完整闭环

创建容器只是第一步,真正麻烦的是执行和回收。我来看这段代码:

import shutil import tempfile import os import requests def run_code_in_sandbox(code: str, timeout: int = 30): work_dir = tempfile.mkdtemp(prefix="sandbox_") container = None try: # 写入代码 with open(os.path.join(work_dir, "main.py"), "w", encoding="utf-8") as f: f.write(code) container = create_sandbox_container(work_dir) # 等待执行完成,超时强制终止 try: result = container.wait(timeout=timeout) logs = container.logs(stdout=True, stderr=True).decode("utf-8", errors="replace") exit_code = result["StatusCode"] except requests.exceptions.ReadTimeout: container.kill() logs = container.logs(stdout=True, stderr=True).decode("utf-8", errors="replace") exit_code = -1 # 自定义超时标志 return { "exit_code": exit_code, "logs": logs, "timeout": exit_code == -1, } finally: if container: container.remove(force=True) shutil.rmtree(work_dir, ignore_errors=True)

这里有几个实操中验证过的关键点。

第一,超时用container.wait(timeout=...)而不是在容器外面起一个time.sleep线程轮询,原因是前者直接依赖 Docker 守护进程的事件机制,响应准确且不额外消耗调用端的 CPU。

第二,容器强制终止必须用container.kill()。如果你用了stop(),它会走一个优雅停机的流程,给进程留一段缓冲时间——但对可疑代码来说,这个缓冲期恰恰是它完成"最后一步坏事"的机会。宁可数据可能在正常执行完成前被掐断,也不能给潜在恶意行为留空间。

第三,container.remove(force=True)放在 finally 里,确保无论执行成功、超时、还是抛异常,容器都会被清理。容器泄漏是这类调度器最常见的运维事故,一旦泄漏,宿主机上的容器数量会慢慢涨到让你怀疑人生。

3.4 结果反馈的三段式封装

容器执行完成后,拿到的是原始的 stdout 和 stderr 混在一起的日志。我第一次就这么直接返给上层模型,结果发现效果很差——模型把第三方库的 warning 当成错误来处理,在"修复"一个根本不存在的 bug,越改越离谱。

后来我把返回结果改成了三段式结构:

{ "exit_code": 0, "stdout": "执行结果的标准输出", "stderr": "异常 traceback、运行时错误", "timed_out": false, "duration_ms": 320.5 }

模型收到这个结构后,判断能力完全不一样了。它能把"正常输出"和"异常堆栈"分开看,定位问题快了很多。我统计过一段时间的运行记录,改成三段式反馈后,模型在第二次迭代中修复代码的成功率提升了大约 20% 到 30%。

4. 实际跑起来之后,那些比预想更麻烦的坑

4.1 大模型生成代码的"风格不稳定"问题

我最初以为沙箱里预装好 Python 3.11 环境就够了,结果实测发现,同一个模型生成代码,十次请求的写法可能千奇百怪。

有些代码喜欢把整个计算结果用print输出,结果一执行就刷出好几 MB 的日志,不仅拖慢返回速度,还白白浪费内存。有些代码在模块顶层就写循环,还没执行到main函数就卡在死循环里。还有相当比例的代码引用了模型自己"凭空捏造"的第三方库名,一导入就报ImportError。

针对这些现象,我做了几个针对性的调整:

第一,日志管道加截断上限。我设置容器日志超过 1MB 就不再返回完整内容,只返回最后 512KB。这个数字是实测出来的——绝大部分正常输出不超过 100KB,1MB 算一个安全阈值,既能容纳异常膨胀的输出,又不至于让数据库被炸掉。

第二,预装库清单要做成可配置的,而不是写死。因为模型生成的代码,依赖的库会随着任务类型不同而变化。我用一个共享的requirements.txt管理,每次模型在某个任务里反复用到某个库,就把这个库加进清单,下次构建镜像时带上。

第三,针对模型"捏造库"的问题,除了预装常用库,我还需要在反馈里明确告诉模型当前环境有哪些库。这一步看起来简单,但很有效——模型知道环境边界之后,写出来的代码明显更克制了。

4.2 超时时间要和任务类型挂钩

超时时间的确定,我经历了一个从一拍脑袋到精细化的过程。一开始统一设 60 秒,后来发现两个问题:

一个问题是,在 Agent 的多轮对话场景里,60 秒的等待时间已经接近用户的忍耐极限。如果单次代码执行卡在这个阈值,整个对话的节奏都会变得迟钝。

另一个问题是,任务类型之间的天然差异没有反映到超时参数上。简单的字符串处理脚本几秒就出结果,但一个 pandas 处理几百万行 DataFrame 的任务可能需要几分钟。统一用 60 秒,前者浪费等待时间,后者直接误杀。

任务类型建议超时理由
简单计算、文本处理10-15秒大部分简单脚本几秒内完成
数据分析、中等计算30-60秒pandas 加载和处理数据需要时间
长时文件处理2-5分钟需要单独申请,谨慎开放

所以我让请求方在调用沙箱时显式声明任务类型,调度器再根据任务类型动态调整超时。这个设计上线后,因为超时导致的"误杀"投诉基本消失。

4.3 僵尸进程与容器回收的幽灵问题

这是我踩过最深的一个坑。早期版本里,我发现宿主机上的进程数无缘无故地涨,而且 kill 掉容器之后,进程数也不会降下来。

排查后发现问题出在一个隐蔽的机制上:容器内的某些任务会通过subprocess启动子进程,当 Python 主进程退出后,子进程变成孤儿进程。Docker 容器被 kill 时,它的 PID 1 进程会被终止,但那些孤儿进程可能暂时残留在宿主机的进程表里,形成一段时间的僵尸状态。

在每小时执行上百次沙箱调用的场景下,这种残留进程不断累积,宿主机进程数一度涨到几千个,最终触发了系统级别的进程数限制。

解决方式有两个,见效果明显的组合拳:

第一,容器启动命令里加清场逻辑。在执行完主脚本后,显式杀掉所有残余子进程:

sh -c "python main.py; pkill -P 1; exit 0"

第二,宿主机配一个定时任务,定期扫描异常状态的容器,超过生命周期上限的统一强制清理。这个兜底方案保证即使调度器本身出 bug 漏掉了某个容器,宿主机也不会被拖垮。

4.4 工作目录的读写分离设计

最初我的方案里,/workspace是带写权限的,为了方便模型把生成的结果文件直接保存在工作目录里。这个设计的隐患在一次事故中彻底暴露了。

那次模型生成的代码在结尾处做了一步"自我检查",逻辑是读取并修改自己的main.py,添加一段调试日志。因为/workspace可写,这个操作居然成功了。更糟糕的是,这个修改后的代码被缓存在了临时目录里,下一次执行同一个任务时,实际跑的已经是被改动过的版本。

从那天起,我把工作目录拆成了三块:

  • /workspace/in:只读,存放代码文件和输入数据
  • /workspace/out:可写,专门保存结果文件
  • /tmp:可写,运行时临时文件

这样就形成了一个有趣的局面:代码可以正常读取输入数据、写出结果,但永远无法修改自己的源文件。即使它有意为之,也只会在只读文件系统上报一个清晰的PermissionError,而这个错误会很快被反馈给模型,让它意识到"不能改自己的代码"。

5. 让沙箱反过来"教"大模型写出更稳的代码

5.1 错误信息的语义化翻译

沙箱除了控制风险,还有一个对提升模型实用价值极大但经常被忽略的作用——给大模型提供高质量的反馈。如果你直接丢一段PermissionError: [Errno 13]给模型,它虽然能看懂,但不一定能正确归类问题。

我维护了一个错误映射表,把高频错误翻译成模型更容易理解的自然语言提示:

原始错误语义化提示
ImportError: No module named 'xxx'当前环境没有该库,请改用已安装的库,或先请求安装依赖
MemoryError内存超限,建议减小输入规模或改用分批处理
Timeout超时执行超时,建议简化算法或减少循环迭代次数
PermissionError试图写入只读目录,请将输出文件写到/workspace/out

这个映射表上线后,第二轮代码质量明显提升,模型不再在同一个错误上反复跌倒。

5.2 静态度预检:在代码进入容器之前拦一道

在代码真正进入容器执行之前,我额外加了一层快速静态预检。这里的逻辑不是拒绝执行,而是给模型一个即时的安全提示。

我用一个简单的规则扫描器,检查代码里是否包含高危模式:

import re HIGH_RISK_PATTERNS = [ r"os\.system\s*\(", r"subprocess\.(call|run|Popen|check_output)\s*\(", r"\beval\s*\(", r"\bexec\s*\(", r"socket\.socket\s*\(", ] def static_precheck(code: str) -> list: warnings = [] for pattern in HIGH_RISK_PATTERNS: if re.search(pattern, code): warnings.append(f"检测到 {pattern} 调用,注意确认行为安全性") return warnings

命中高危模式后,我不会直接拒绝执行——毕竟有些场景确实要调系统命令,这是正常需求。我会把警告附加在反馈里:"你生成的代码包含 system 调用,可能有风险,如非必要请用标准库替代。"实测这个功能把沙箱被 kill 的频率降低了大概一半,很多问题在代码执行之前就被前置处理了。

5.3 积累行为画像,持续优化执行策略

最后分享一个方向性的经验:把每次执行的行为记录下来,形成画像数据。不要只记录"执行成功或失败",要把下面这些信息都落库:

  • 代码调用了哪些 syscall
  • 是否尝试访问网络(即使被阻断)
  • 是否有异常的资源申请(比如申请了超过 300MB 内存)
  • 触发了哪些高危规则

这些画像数据沉淀下来之后,你可以做很多有意思的事情。比如按任务类型统计某些模型的行为模式,识别出"这类任务里模型经常试图访问网络,可以针对性优化生成策略";或者按模型版本对比代码的规范程度,看某个微调版本是否更倾向于生成危险代码。

这个方向目前还在迭代中,但我个人认为它比单纯优化沙箱的隔离强度更有长期价值——因为随着大模型能力的提升,我们真正需要的不是把沙箱越做越厚,而是让模型越来越清楚"什么代码能写、什么代码不该写"。

6. 沙箱的边界:它不是一个魔法盒子

最后聊一点偏理念的东西,也算是我做完整个项目后最真实的体会。

很多人以为给大模型代码执行套上沙箱就万事大吉了,其实沙箱解决的只是"不可信代码执行"这一个环节。大模型代码执行的整体安全链路,还包括三个部分:

第一,Prompt 层面的注入防护。防的是用户通过对话内容诱导模型生成恶意代码。沙箱管不到这个环节,因为代码都还没生成。

第二,代码生成后的安全审查。虽然不能完全杜绝危险代码,但可以通过预检和规则扫描降低概率。

第三,执行结果的敏感信息过滤。模型把结果返回给用户时,要防止它把宿主机环境信息、内部路径、环境变量等不该出现的内容带出去。

OpenSandbox 目前聚焦在第二和第三个环节的前半部分。第一个环节主要依赖上游模型的 prompt 安全机制,这是另一个领域的问题。

另外我要强调一个在团队里反复讲过的观念:沙箱不是绝对安全的魔法盒子。哪怕你用了容器、seccomp、只读文件系统,只要容器和宿主机共享同一个内核,理论上的逃逸风险就依然存在。高敏感场景,比如执行完全不可信、且有被定向攻击可能性的代码,应该直接上微型虚拟机或者独立的物理隔离环境。安全等级必须和代码的可信度、场景的敏感度匹配——这个匹配的决策,必须在架构设计的最开始就想清楚,而不是上线后出了问题再补。

根据我个人经验,做这类系统的正确姿势是:先接受沙箱不是万能的,然后把每一层防御做到自己能力范围内的最优,最后通过持续的行为画像去不断逼近真正的安全问题。大模型还在快速进化,它们生成代码的能力会越来越强,而安全这件事,永远不能靠运气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询