说明:本文讨论的是 Agent 让模型写代码并实际执行时的执行环境隔离,属于工程架构话题,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。
一、隔离的前提:默认不信任每一个执行
到 2026 年,代码执行几乎成了 Agent 的标配能力。模型不再只输出一段文字或一次工具调用的参数,而是直接写一段脚本,由运行时执行、拿回结果、再决定下一步。这件事把 Agent 的能力边界往上抬了一截,同时把一个原本属于系统安全的题目,塞进了每个应用工程师的待办里。
一种常见的自我安慰是:模型写的不过是普通脚本,能出什么事。这个假设站不住,因为它把「模型大概率写对」当成了「每次都对」。代码执行的失控来源至少有四类,性质完全不同。
其一,模型写的代码本身就有破坏性。不是它想搞破坏,而是它按指令写字面意思。让它「清理一下临时文件」,它可能写出对某个目录递归删除的语句;让它「把结果写回配置」,它可能覆盖掉你没备份的原件。这类风险的特点是意图正确、执行范围错误。损害大小取决于运行环境把什么暴露给了它,而不取决于它是否怀有恶意。
其二,依赖供应链。一句 pip install 或 npm install 拉下来的不只是声明里的包,还有包的安装脚本、传递依赖、以及构建阶段执行的代码。这个环节里任何一环被投毒,运行时就等于把第三方代码请进了进程,而你并没有读完那棵依赖树。
其三,无限循环与资源耗尽。一个循环条件写反了,就能把 CPU 吃满;一次性读入超大文件、递归展开,能把内存打爆;高频写日志能把磁盘写满。这类问题在模型侧根本看不出来——它写的语法完全正确,只是运行时没有停下来。
其四,被执行代码读到了不该读的东西。这是最容易被忽略、后果又最重的一类。执行进程继承的环境变量里往往躺着访问密钥、数据库连接串、云账号凭据;挂载进来的目录可能是宿主机的家目录;它还能访问内网的元数据服务与内部接口。模型也许只是「顺手打印一下环境」,密钥就离开了边界。
把这四类放在一起看,结论就清楚了:隔离不是用来防「模型变坏」,而是保证在模型偶尔写错、或被带偏时,损害被限制在一个可接受的范围内。换句话说,隔离是一个工程假设:默认不信任每一次执行,而不是假定每一次都对。
二、三层边界:文件系统、网络、进程与凭据
边界要在创建执行环境时就定死,而不是在执行过程中靠检测去拦。检测是兜底,配置才是边界。三层里任何一层漏了,另外两层都会被绕过去。
文件系统。第一件事是决定挂什么。可写的位置通常只有一个临时工作目录,宿主目录一律不挂,需要输入的大文件走对象存储或只读挂载。这里有两个容易踩的细节:一是临时目录的生命周期必须绑定到这次执行,执行结束就销毁,不能跨任务复用,否则上一轮留下的文件会变成下一轮的输入;二是共享的临时目录在多租户下等价于一条隐蔽的通信通道,要不要共享要想清楚。只读挂载也要留意,挂进来的不只是一份数据,可能还有它的元信息(权限、属主、软链),软链指向宿主路径时,只读边界会被绕开。
网络。默认拒绝还是默认放行,是这一层唯一但关键的决定。默认放行意味着任何一次执行都能连出去、把数据带走,白名单就形同虚设;默认拒绝则要求你把「这次执行允许访问哪些域名或网段」写成显式声明,由环境去强制。白名单要跟着任务走,不能做成一份全局大名单——全局名单会随着项目变多而失控,最后退化成默认放行。出站的域名解析也要一起管,否则解析本身就能泄露意图,也能被当作隧道。
进程与凭据。执行进程不要用 root,也不要用能访问宿主资源的高权限用户,能力集按需裁剪,能不给就不给。这一层最常被忽视的是环境变量:很多团队把密钥放在应用的配置里,启动执行环境时又顺手把整个环境继承过去,于是沙箱里随手打印一下环境变量就能拿到密钥。环境变量里不能有长期密钥,这是硬约束,不是建议。
| 边界层 | 要定的事 | 典型错误 | 安全侧默认 |
|---|---|---|---|
| 文件系统 | 挂载白名单、可写目录、临时目录生命周期 | 挂宿主机家目录、临时目录跨任务复用 | 只挂临时工作目录 |
| 网络 | 默认策略、出站白名单、域名解析 | 默认放行、白名单做成全局大表 | 默认拒绝、按任务声明 |
| 进程与凭据 | 运行用户、能力集、环境变量 | 用 root 跑、继承全量环境变量 | 非 root、能力裁剪、无长期密钥 |
把这三层写成一份显式的执行规格,比在代码各处写零散判断要可靠得多。规格是数据,能被审阅、能被固化、能被强制执行:
⚠️ 代码待验证
fromdataclassesimportdataclass,field@dataclassclassExecSpec:image:strworkdir:str="/workspace"mounts:list=field(default_factory=list)# 只挂白名单路径,默认空writable:list=field(default_factory=lambda:["/workspace"])# 仅此处可写net:str="deny"# deny | allowlistegress_allow:list=field(default_factory=list)# 仅当 net=allowlist 生效env:dict=field(default_factory=dict)# 显式注入,不继承宿主环境user:str="sandbox"# 非 rootttl_seconds:int=300# 环境存活上限,结束即销毁defdefault_spec(image:str)->ExecSpec:# 安全侧默认:无可写额外路径、无网络、无密钥、短生命周期returnExecSpec(image=image)这段的重点不在字段多少,而在默认值全部取安全侧:不传参数得到的,是能力最小的一份规格。要放开哪一项,就显式写哪一项,而放开这个动作本身应该留下记录。
三、四道资源限:为什么限时长必须同时限输出
隔离解决的是「能碰什么」,限额解决的是「能消耗多少」。两者独立:一个文件系统边界收得很紧的沙箱,照样能用一段死循环把宿主机的 CPU 占满。
要限的有四样:CPU、内存、磁盘、执行时长。它们各自的失控方式不同,超限后的处置也不该一样。
CPU。限的是核数与总时长,不是瞬时占用。给足核数同时设总时长上限,是更常见的做法——不少任务本身就是短时高并发的。这里要区分 CPU 时间与墙钟时间:一个等待型的任务,墙钟耗时长但 CPU 时间几乎为零,用 CPU 时间做限会把它误杀,用墙钟做限才符合「这次执行不能占着位置太久」的诉求。
内存。内存超限最常见的表现不是报错,是被操作系统直接杀掉,进程收到信号就没了,日志可能都来不及刷。所以内存上限要配一个接近上限的预警阈值,在真正被杀之前先返回一个可读的错误,否则你拿到的只有一句「进程退出了」。
磁盘。既要限工作目录的写入总量,也要限单文件大小。日志是最容易写爆磁盘的一类——模型写了个循环,每轮都往日志里追加,磁盘在几分钟内就满了,而满盘影响的不只是这一个沙箱。
执行时长。这一道最容易被单独使用,也最容易出问题。只限时长、不限输出体积,会留下一个窗口:任务在超时前的一瞬间,把一份几十兆的结果写到标准输出或写入文件。你以为限住了它的运行时间,其实放它带出了一大坨东西,接入方还要为这坨东西付解析与存储成本。所以限制执行时长的同时必须限制输出体积,两者是一对。
超限之后怎么处置,也不止「杀掉」一种。把处置分清楚,后续的排障和指标才有意义。
| 资源 | 限什么 | 超限处置 | 备注 |
|---|---|---|---|
| CPU | 核数 + 总时长(墙钟) | 杀进程,返回可读错误 | 用 CPU 时间会误杀等待型任务 |
| 内存 | 上限 + 预警阈值 | 先预警,超限再杀 | 直接被杀往往来不及留日志 |
| 磁盘 | 写入总量 + 单文件大小 | 拒绝写入,返回错误 | 日志循环是最常见的写爆来源 |
| 时长 | 墙钟上限 + 输出体积上限 | 终止并截断输出 | 不限输出体会抵消时长限制 |
| 外部调用 | 次数上限 | 拒绝调用,任务可降级 | 防止沙箱内高频探测外网 |
⚠️ 代码待验证
sandbox_limits:cpu:cores:2wall_clock_seconds:300# 用墙钟而非 CPU 时间,避免误杀等待型任务memory:limit_mb:2048warn_mb:1536# 接近上限先告警,争取返回可读错误disk:workspace_mb:512single_file_mb:64output:stdout_bytes:262144# 输出体积必须与时长一起限artifact_total_mb:128on_exceed:kill_process:truereturn_readable_error:truecount_as_task_failure:false# 超时可降级重试,不必一律记失败最后一项值得单独说一句:把超限一律计为任务失败,会让「这个任务本身就跑不完」和「这次分配的资源偏小」两类情况混在一起,指标上分不开,也就没法据此调参。可重试的超限和不可重试的失败要分开记。
四、凭据不下发:要联网取数据时怎么办
上一章把出站默认设成了拒绝。但很多任务天然需要联网:查一份文档、拉一批数据、调用一个内部接口。一放开网络,凭据问题就跟着来了——沙箱里跑的是一段模型写的代码,你不希望它拿到能长期使用的密钥。
这里有三条路线,代价各不相同。
编排层代跑受控请求。沙箱执行模型代码时,只允许它声明「我要请求哪个资源」,真正的请求由编排层带着凭据去发,结果再回给沙箱。这样沙箱里从头到尾没有凭据,暴露面最小。代价是灵活度下降:模型不能自己拼请求、不能处理分页与重定向这类逻辑,这些都得在编排层预设。适合接口固定、调用方式可枚举的场景。
临时短时凭据。给这次执行一枚作用域受限、有效期很短的凭据,执行结束即失效。灵活度比上一条高,模型可以自己写请求逻辑。代价是凭据仍然进了沙箱,暴露窗口虽然小,但存在;而且短时凭据的签发本身要有一个可信入口,这个入口不能又变成长期密钥的新去处。
出站代理白名单。所有出站流量走一个受控代理,代理持有凭据、按域名与路径做校验,沙箱侧只看到代理地址。它把「凭据」和「网络策略」合在一起管,运维上最集中。代价是代理成了单点,要处理它的容量与可用性,而且代理规则一旦写松,「白名单」就名不副实。
| 做法 | 凭据是否进沙箱 | 灵活度 | 主要代价 | 适用 |
|---|---|---|---|---|
| 编排层代跑 | 否 | 低 | 请求逻辑要预先枚举 | 接口固定、调用可预测 |
| 临时短时凭据 | 是(短窗) | 中 | 需可信签发入口 | 调用方式多变、可容忍短窗 |
| 出站代理白名单 | 否 | 中 | 代理是单点,规则易写松 | 出站需要集中治理 |
三种做法不互斥,常见组合是「代理管通用出站,编排层代跑高价值接口,短时凭据留给确实需要自建请求链路的场景」。无论选哪种,有一条底线不变:长期密钥不进沙箱。
把「代跑」落到代码上,大致是这样的分工——沙箱只提交请求描述,编排层校验后执行:
⚠️ 代码待验证
ALLOWED={"docs.read":("https://docs.internal.example",["GET"]),"metrics.read":("https://metrics.internal.example",["GET"]),}defproxy_request(handle:str,path:str,method:str,body=None):ifhandlenotinALLOWED:return{"ok":False,"error":"handle_not_allowed"}base,methods=ALLOWED[handle]ifmethod.upper()notinmethods:return{"ok":False,"error":"method_not_allowed"}# 凭据只在这一层出现,沙箱侧永远拿不到它resp=http_call(base+path,method,body=body,auth=load_secret(handle))return{"ok":True,"status":resp.status,"body":truncate(resp.body)}这里的 handle 是编排层预先登记的别名,不是由沙箱自由拼接的完整地址。让沙箱能自由指定地址,等于把白名单交还给被隔离的一方,白名单也就不再是边界。
五、产物怎么回传:三条通道,别把大文件塞回上下文
执行完,结果怎么带出沙箱,是一个经常被欠考虑的问题。最容易犯的错误,是让沙箱把一切结果都打到标准输出,再由编排层原样塞进模型上下文。这条路在结果小的时候没问题,一大就崩。
产物分三类,通道不同,处理方式也不同。
文件产物。报告、图表、生成的数据文件、改动后的代码。这类不该走上下文,而应该从沙箱的临时工作目录直接搬到对象存储或制品目录,编排层只拿一个可访问的引用(地址加大小加校验值)。往上下文里塞一份几兆的文本,除了消耗 token,还会把真正重要的结论挤到不显眼的位置。搬运时要校验大小与摘要,防止「沙箱说文件写好了、其实没写完」。
结构化产物。让模型输出的关键结论、状态、下一步建议,应当要求它写成一个约定字段名的 JSON,而不是一段自然语言。约定字段名的好处是编排层能直接读、能校验、能落库;自然语言则要再解析一次,又多一层不确定性。结构化产物的体积要有上限,超过就截断并标记,不要把截断当成无事发生。
日志。执行日志分两类用途:给模型看的少量诊断信息,和给人看的完整运行记录。前者要精简,只留出错位置与关键参数;后者走另外的通道直连日志系统,不进上下文。把完整的运行日志塞回模型,是最常见的 token 浪费来源之一。
| 产物类型 | 回传通道 | 编排层拿到什么 | 常见错误 |
|---|---|---|---|
| 文件 | 工作目录 → 对象存储 | 引用(地址/大小/摘要) | 把大文件内容塞进上下文 |
| 结构化 | 约定字段的 JSON | 可直接读与校验的对象 | 用自然语言代替结构化字段 |
| 诊断日志 | 精简后进上下文 | 出错位置与关键参数 | 把全量日志喂给模型 |
| 完整日志 | 直连日志系统 | 供人排查的链路 | 与诊断日志混在同一条通道 |
回传这一步要顺手把体积管住。下面这段把「结构化产物 + 文件搬运 + 输出截断」放在一起:
⚠️ 代码待验证
MAX_STDOUT=32*1024# 超出即截断并标记,不静默丢弃MAX_ARTIFACT=128*1024*1024defcollect(result):out={}structured=result.get("report")# 约定字段,直接可用ifstructuredisnotNone:out["report"]=structured artifacts=[]forfinresult.get("files",[]):iff.size>MAX_ARTIFACT:artifacts.append({"name":f.name,"skipped":"too_large","size":f.size})continueuri=upload_to_store(f)# 文件不进上下文,只回引用artifacts.append({"name":f.name,"uri":uri,"size":f.size,"sha256":f.sha})out["artifacts"]=artifacts raw=result.get("stdout","")iflen(raw.encode())>MAX_STDOUT:out["stdout"]=raw.encode()[:MAX_STDOUT].decode(errors="ignore")out["stdout_truncated"]=True# 截断必须显式标记else:out["stdout"]=rawreturnout标记截断这件事不能省。一段被悄悄截断的输出,模型会当成完整结果继续往下推理,错误会一路传下去,而且中途没有任何地方会报错。
完整版资料清单:本文用到的沙箱边界配置与限额模板都整理在里面了,扫码即可获取:
六、自建容器还是用托管沙箱
边界和限额想清楚之后,会落到一个采购题:自己用容器技术搭一套执行环境,还是用托管沙箱。这不是立场问题,是五个维度的取舍。
隔离强度。自建若只做命名空间隔离、共用宿主内核,逃逸面要比基于独立内核或虚拟化的方案大。托管沙箱通常在这一层做得更重,但你要接受它的具体实现不透明。判断标准不是「谁更安全」,而是「你的执行内容有多不可信」——如果跑的是模型随机生成的代码,就该更偏向强隔离。
冷启动。每次执行都新建环境最干净,但冷启动会占掉可观的时间。自建可以通过预热池缓解,代价是池子里的环境可能被复用,隔离性下降。托管方案一般帮你处理了这层,但冷启动表现要按自己的负载实测,不能只看文档。
并发成本。隔离越强,单实例成本越高。并发量大的场景要把「峰值并发乘单实例成本」和「平均并发乘单实例成本」分开算,用平均值得出的结论会低估峰值时的开销。
可观测性。自建的好处是你能把它接进自己的 trace 与日志体系,出问题能定位到具体的环境实例;托管方案通常只暴露有限的接口,超限、被杀这类事件要确认它是否回传、回传什么字段。
运维责任。自建要自己养一套「创建、隔离、限额、回收、巡检」的流程,还要跟内核补丁;托管把这部分转成了服务方的责任,但引入了对服务方可用性与策略变更的依赖。
| 维度 | 自建容器 | 托管沙箱 |
|---|---|---|
| 隔离强度 | 取决于内核隔离方案,共享内核时较弱 | 通常更强,但实现不透明 |
| 冷启动 | 可控,靠预热池缓解,复用会降隔离性 | 由服务方处理,需按自身负载实测 |
| 并发成本 | 峰值成本需单独核算 | 通常按用量计费,弹性较好 |
| 可观测性 | 可接入自有体系,定位到实例 | 字段有限,需确认超限事件回传 |
| 运维责任 | 补丁、巡检、回收全自担 | 转由服务方承担,依赖其可用性 |
有两种场景两边都不合适。一是执行内容几乎完全不可信、又不接受任何外部依赖——自建难在隔离强度,托管难在不能自控;这时候只能缩小能力面(不联网、只给临时目录、只跑纯计算),把风险压到能力层面,而不是指望隔离方案兜住一切。二是执行延迟要求在毫秒级——任何「新建环境再执行」的模型都太慢,可行做法是把执行范围压到一组预先审定过的表达式或纯函数,而不是通用代码执行。不要为了保住「能跑任意代码」这个能力,去牺牲隔离强度。
⚠️ 代码待验证
需要一个执行环境 ├─ 执行内容是否高度不可信? │ ├─ 是 → 优先强隔离;若同时要求不依赖外部服务 → 缩小能力面,别硬上通用执行 │ └─ 否 → 进下一步 ├─ 并发是否大且峰谷差异明显? │ ├─ 是 → 倾向托管,按用量走 │ └─ 否 → 自建预热池即可 └─ 是否必须接入自有 trace 与审计? ├─ 是 → 自建的收益更大 └─ 否 → 按成本与团队运维能力定七、落地顺序与观测
这套东西不适合一次全开。放开的能力越多,出问题的面越大,所以顺序本身也是一种安全措施。
第一阶段,只读环境跑通。沙箱不给网络、不给可写目录,只让模型写的代码能读输入、算出结果、把结论以结构化形式带出。这一步验证的是「代码能不能跑通、产物通道通不通」,与能力放开无关。很多团队卡在这里才发现,问题往往不是沙箱,而是产物回传的字段没对齐。
第二阶段,放开写临时盘。允许在临时工作目录里写文件、生成中间产物,仍然不给网络。这一步要同时把前文的四道限额配上,观察超限终止的比例,据此调参。临时目录的生命周期在这一阶段最容易出问题,要确认它确实随执行销毁。
第三阶段,才考虑联网。先只对少数已登记的资源放开,走出站代理或编排层代跑,不要一上来就开全量白名单。联网是风险最高的一步,也是收益最直接的一步,把它放在最后,是为了让前两阶段建立的观测能力先就位。
四个指标要一起看。单看任何一个都会误导。
- 沙箱创建耗时:按分位看,均值会被大量小任务拉平。它直接决定交互式体验,也是判断该不该上预热池的依据。
- 超限终止率:按资源类型拆分。CPU 超限多说明模型写的逻辑容易失控,内存超限多往往指向输入数据过大,要分开处理。
- 出站拦截次数:这个数字突然上升,通常意味着有任务在尝试访问未登记的资源。它是要看的信号,不是要压下去的错误。
- 产物回传成功率:它掉下去,多半是文件没写完就搬走,或大小校验没过。这一项低,前面跑得再顺,结果也传不出来。
⚠️ 代码待验证
sandbox_metrics:-name:sandbox_create_durationunit:secondsslice:[image,pool_warm]note:看分位不看均值;决定是否上预热池-name:limit_terminated_rateunit:ratioslice:[cpu,memory,disk,wall_clock]note:按资源拆分,不同资源指向不同根因-name:egress_blocked_countunit:countnote:上升是信号,说明有任务在访问未登记资源-name:artifact_delivery_rateunit:rationote:掉下去多为搬运时文件未写完或校验未过把「只读跑通、放开临时盘、最后联网」这个顺序和这四个指标固定下来,代码执行就从一个看起来很酷的能力,变成了一个可以被限、被观察、被回退的工程组件。至于读到不可信的内容之后该怎么约束动作,那是另一条线上的问题,不在这里展开。
完整版资料清单:本文用到的沙箱边界配置与限额模板都整理在里面了,扫码即可获取:
附表 A:关键取舍一览
本文涉及的所有工程判断集中在这里,方便按需回看。
| 取舍 | 本文结论 | 判断依据 | 位置 |
|---|---|---|---|
| 隔离的前提 | 默认不信任每一次执行 | 模型偶尔写错的损害要靠边界兜住 | 第一章 |
| 依赖安装 | 视为引入第三方代码 | 安装脚本与传递依赖都会执行 | 第一章 |
| 边界何时定 | 创建环境时定死 | 检测是兜底,配置才是边界 | 第二章 |
| 文件系统 | 只挂临时工作目录 | 临时目录跨任务复用会污染下一轮 | 第二章 |
| 网络默认 | 默认拒绝、按任务声明 | 全局大名单会退化成默认放行 | 第二章 |
| 环境变量 | 不放长期密钥 | 继承宿主环境等于把密钥送进沙箱 | 第二章 |
| CPU 限额 | 用墙钟而非 CPU 时间 | CPU 时间会误杀等待型任务 | 第三章 |
| 内存限额 | 配预警阈值 | 直接被系统杀掉往往不留日志 | 第三章 |
| 输出体积 | 与执行时长一起限 | 不限输出会抵消时长限制 | 第三章 |
| 超限记账 | 可重试的超限不记任务失败 | 混记会让指标分不开根因 | 第三章 |
| 凭据路线 | 长期密钥一律不进沙箱 | 三条路线的底线一致 | 第四章 |
| 代跑请求 | 沙箱只提交 handle | 自由拼接地址等于交回白名单 | 第四章 |
| 大文件产物 | 只回引用,不回内容 | 塞进上下文既费 token 又淹没结论 | 第五章 |
| 截断处理 | 必须显式标记 | 静默截断会被模型当成完整结果 | 第五章 |
| 托管与自建 | 按执行内容的不可信程度定 | 隔离强度与冷启动互相牵制 | 第六章 |
| 不合适的场景 | 毫秒级延迟不做通用执行 | 新建环境再执行的模型太慢 | 第六章 |
| 放开顺序 | 只读、临时盘、再联网 | 顺序本身也是一种安全措施 | 第七章 |
附表 B:术语速查表
| 术语 | 含义 |
|---|---|
| 沙箱 | 把不可信的代码限制在受限资源与权限内运行的执行环境 |
| 执行规格 | 描述一次执行挂载、网络、用户、限额的显式配置数据 |
| 安全侧默认 | 不传参数时得到能力最小的一份配置,放开项需显式声明 |
| 出站白名单 | 仅允许访问显式登记域名或网段的网络策略 |
| 能力裁剪 | 削减进程可用的系统能力,只保留任务必需的部分 |
| 临时短时凭据 | 作用域受限、有效期很短,执行结束即失效的访问凭据 |
| 编排层代跑 | 由编排层持凭据发起受控请求,沙箱侧不接触凭据 |
| 出站代理 | 统一承载出站流量、集中持有凭据与校验规则的中间层 |
| 墙钟时间 | 从开始到结束的实际流逝时间,区别于 CPU 占用时间 |
| 预警阈值 | 接近资源上限时先触发告警,争取返回可读错误 |
| 结构化产物 | 以约定字段名输出的 JSON,可直接读取与校验 |
| 产物引用 | 指向对象存储中产物的地址与摘要,替代把内容塞回上下文 |
| 预热池 | 预先创建好的执行环境池,用于降低冷启动耗时 |
写在最后:这篇用到的资料
写这篇文章时,我把几个代码执行方案的隔离配置、限额参数和实测记录都对了一遍,顺手整理成几份配套的东西:
- 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
- 大模型全套教程:按主题分好的视频与文档清单
- 大模型实战好书:24 本,附每本适合的阶段
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「大模型」,优先通过。
拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。