1. 为什么我坚持给所有 Agent 业务套一层沙箱
先说个真事。之前给某客户做智能客服升级,计划让 Agent 自动调用工单系统、CRM、排班 API 去完成"改地址""查积分""转人工"这类操作。模型本身表现很不错,工具调用准确率能到 93% 上下。结果在联调阶段,一个测试任务里 Agent 意外调用了内部运维接口,幸好那个接口当时处于 mock 状态,不然一次真实的误操作就会把生产环境某个表的数据给清了。
那之后我立了个规矩:凡是跑 Agent 的环境,必须有一层明确的沙箱边界,没有例外。
这里要解释一下,AI Agent 和普通程序本质上有很大不同。普通程序行为是确定的,输入什么逻辑就执行什么逻辑,出问题可以靠 code review 和单测兜底。但大模型驱动的 Agent 是个概率系统,同一个 prompt 给两次,执行路径可能完全不一样,而且它会自己拆解目标、选择工具、构造参数。它自己都不知道自己下一步要干嘛,代码审查根本无从审起。所以只能用运行时隔离来兜底——也就是沙箱。
沙箱对 Agent 的价值主要体现在四块:
- 环境隔离:Agent 跑在一个独立、可销毁的环境里,宿主和平台核心服务不受影响。
- 权限收敛:Agent 进程只有完成任务所需的最小权限,拿不到宿主主机的权限。
- 网络管控:外网访问、内网访问都走白名单,禁止默认放开。
- 审计留痕:Agent 的所有操作记录可还原、可回放、可归档。
我现在的做法是:所有 Agent 任务,无论大小,统一跑在受控沙箱里。评测任务放沙箱,生产任务也放沙箱,只是安全策略等级不同。这个习惯帮我拦下了几次风险事件,后面你会看到具体案例。
2. 沙箱粒度怎么定:容器、微虚拟机还是纯进程限制
选沙箱技术方案时,我对比了市场上主流的三种路线:容器沙箱、微虚拟机(microVM)、纯进程级限制。它们不是互斥关系,很多线上系统其实是混用的。
2.1 三条技术路线的核心差异
为了讲清楚,我直接列了一张对比表,方便你按场景选:
| 维度 | 容器(Docker/containerd) | 微虚拟机(gVisor / Kata / Firecracker) | 纯进程限制(seccomp / namespace) |
|---|---|---|---|
| 启动速度 | 毫秒级,非常快 | 百毫秒到秒级 | 微秒级,接近零开销 |
| 隔离强度 | 内核共享,存在逃逸风险 | 独立内核或系统调用拦截,强度最高 | 仅隔离系统调用,不隔离进程本身 |
| 资源开销 | 低 | 中等到偏高 | 极低 |
| 适用场景 | 大量快速任务、评测、批处理 | 高安全要求、运行不可信代码 | 对性能极其敏感的内核态场景 |
| 运维复杂度 | 低,生态成熟 | 中高,需要额外组件 | 高,需要精细配置 |
我见过不少团队一上来就追求最强隔离,直接上 Firecracker 微虚拟机。但实际跑起来发现一个问题:Agent 任务大多是短平快的,一次工具调用可能三秒就完成了,微虚拟机的启动开销反而拖慢了整体吞吐。所以我的建议是:隔离强度要跟任务的信任级别匹配,别一刀切。
2.2 我最终采用的方案:容器为主、gVisor 兜底
我的默认方案是 Docker 容器,每个 Agent 任务一个容器,用完即销毁。对于信任级别低的任务(比如让 Agent 执行自然语言描述里带出来的 shell 命令),我会在容器里再套一层 gVisor 运行时作为加固。
为什么不用纯 Docker?因为 Docker 容器和宿主机是共享内核的,一旦出现容器逃逸漏洞,宿主就没了。gVisor 的价值在于它在用户态实现了系统调用拦截,即使 Agent 在容器里执行了恶意代码,也只能触达 gVisor 这个"假内核",碰不到宿主机真实内核。
一个更省事的做法是直接用runsc作为 Docker 的 runtime:
docker run --runtime=runsc \ -it --rm \ --network=none \ -v /tmp/agent-workspace:/workspace:rw \ agent-runtime:latest这样每个 Agent 都跑在一个受限运行时里,系统调用被拦截,网络默认关闭,工作目录被挂载成一个临时目录。任务结束以后容器直接删掉,什么都不会留下。
2.3 什么场景下我才会上微虚拟机
经验是:当 Agent 需要处理客户提供的不受信任文件(比如 PDF、压缩包、Office 文档)时,我会上 Kata 容器或 Firecracker。因为解析文档类操作最容易踩到内核漏洞或解析器漏洞,恶意构造的文件可能在解析阶段就试图逃逸。这种情况我会把 Agent 连同文档解析进程一起放进微虚拟机,彻底隔离。
当然,微虚拟机也有代价:启动慢、资源占用高、调优复杂。所以它只用于"高风险文件处理"这一类任务,不会每个对话都开。
3. 网络出口管控:默认禁网,白名单放行,审计每一字节流量
Agent 沙箱最容易忽略的就是网络隔离。很多人只做了文件系统隔离,却忘了给 Agent 一个完全受控的网络环境。事实上Agent 最大的风险面恰恰是网络出口。
3.1 为什么网络要跟沙箱同级对待
你想想一个没有网络限制的 Agent 能做什么:它可以调用任意公网 API、可以访问内网管理系统、可以把数据 POST 到某个第三方服务。模型确实有对齐训练,但提示注入、恶意指令、上下文攻击都能让 Agent 变成攻击工具。
所以我的网络策略很简单,分三层:
- 默认禁止一切网络连接。容器创建时
--network=none,Agent 进程根本拿不到网络设备。 - 按需开通白名单域名/IP。通过一个本地代理统一放行,只有 Agent 实际需要调用的外部服务才进白名单。
- 内网访问一律走网关鉴权。即使开了内网权限,请求也要经过 API 网关,能精确到工具级别和控制级别。
3.2 一个可落地的网络放行配置
我在生产环境里用一个 sidecar 代理统一收口 Agent 的所有 HTTP 请求。Agent 自身不直接访问外部,而是把请求发给本机代理,由代理根据策略决定放行还是拦截。大致配置长这样:
# agent-net-proxy.yaml rules: - name: public-internet domain: ["api.external-service.com", "www.company-cdn.com"] action: allow rate-limit: 10/s - name: internal-apis domain: ["api.internal-tools.example.corp"] paths: ["/v1/ticket", "/v1/customer"] methods: ["GET", "POST"] action: allow-http-gateway - name: catch-all action: deny log: trueAgent 调用工具的 URL 必须匹配规则才能出去。比如客户服务 Agent 需要查工单,它就只能访问工单 API,其他一律拦截。通过 API 网关做内网访问鉴权时,还会带上调用方标识和链路 ID,一旦某个 Agent 行为异常,能精确定位到是哪个任务发的请求。
3.3 DNS 层面也要管住
很多团队只做了 HTTP 层白名单,但 Agent 完全可以绕过 HTTP 走 DNS 隧道,或者直接发 UDP 包。所以在沙箱内部我把 DNS 解析也收编了:容器内的/etc/resolv.conf指向本地 DNS 代理,只有白名单域名才能被解析,其他域名直接 NXDOMAIN。这样即使 Agent 想外连,也找不到可用的出口路。
4. 资源限额与熔断降级:别让一个失控 Agent 拖垮整个平台
沙箱不是只管"能不能跑",还得管"能跑多久、能用多少资源"。Agent 是自主循环系统,一个模型如果陷入死循环,或者在某个工具上反复重试,它就会无限消耗 CPU、内存、网络流量。每个请求都计费,每次重试都延时,这种失控会直接拖垮整个服务。
4.1 硬性配额怎么设
我一般给 Agent 容器设四个维度的配额:CPU 会限、内存要够、磁盘给大、超时必须短。下面是 docker run 里的参数:
docker run \ --cpus=1.0 \ --memory=1g \ --memory-swap=1g \ --pids-limit=128 \ --read-only \ --tmpfs=/tmp:rw,size=256m,mode=1777 \ --stop-timeout=10 \ agent-runtime几个容易被忽略的细节:
--memory-swap要和--memory相同,否则容器可能通过 swap 把自己卡死。--pids-limit限制了进程数,防止 Agent fork 炸弹或者启动一堆子进程。--read-only挂载根文件系统,Agent 只能写 tmpfs 和工作目录,其他地方改不了。--stop-timeout=10控制 SIGTERM 之后的清理时间,防止容器永远不退。
除了容器层配额,平台层面还会对单次 Agent 运行的总时长设上限。我这边默认是 5 分钟(看任务复杂度也可以放宽),超过就发 SIGTERM,再一分钟没反应就 SIGKILL。这样即使模型陷入死循环,也最多占用 6 分钟的资源。
4.2 工具调用层的熔断
容器层的配额只能兜底,真正防失控还需要工具调用层的策略。我设计了三个熔断条件,任一触发就终止当前 Agent 任务:
- 连续失败熔断:同一个工具连续调用超过 5 次失败,立刻停止任务。
- 结果漂移熔断:Agent 的输出偏离任务目标超过阈值(用 embedding 相似度衡量),立即取消。
- 评价分骤降熔断:如果平台有自动评价机制,分数从高位跌到最低分且无法恢复,则终止。
标签上这仨有个场景:有个 Agent 在不停调用定时任务 API,每次传的时间格式都错,失败了就换一种格式重试。如果没有连续失败熔断,它会烧十分钟。加了熔断后第五次失败就停了,非常果断。
4.3 退避与降级策略
除了熔断,还要有降级策略。比如 Agent 请求某个上游接口时,若上游返回 429 或 5xx,不要立即重试,而是按指数退避:第一次等 1 秒,第二次 2 秒,第三次 4 秒,最多到 30 秒。同时可以设置一个"允许总等待时间",超过就直接跳过该工具,避免一次慢查询把整个任务拖垮。
这类降级逻辑写进 Agent 框架里,比在模型 prompt 里加约束可靠得多。纯粹靠提示词告诉模型"别重试"是没用的,模型该重试还是重试。
5. 可观测性与事后复盘:把 Agent 每一步操作完整还原
沙箱的最后一个核心功能是留痕。AI Agent 出问题的时候,你不能只靠看模型输出判断哪里错了。必须能回答:它调了哪些工具、传了什么参数、请求了哪些 URL、读了哪些文件、用了多长时间、每一步的中间结果长什么样。这些信息要全部记录,且要能快速回放。
5.1 日志体系记录哪些内容
我设计的 Agent 审计日志至少包含这几个维度:
| 类别 | 内容 | 示例 |
|---|---|---|
| 任务元信息 | 任务 ID、模型版本、提示词版本、超时时间 | task_abc123,gpt-4o-2024-08 |
| 工具调用 | 工具名、参数、返回值摘要、耗时 | 工具名=ticket_api, 参数={id: 123}, 耗时=350ms |
| 网络访问 | 目标域名、请求路径、方法、状态码 | POST api.internal-tools/customer, 200 |
| 文件操作 | 读取/写入的文件路径、大小 | 读 /workspace/upload/xx.pdf, 2MB |
| 系统行为 | 进程执行记录、异常退出 | sh -c "curl ..." |
| 关键决策 | Agent 选择了哪条分支、打分评价 | 选择了方案B, 因为 ... |
每条日志都带上任务链路 ID,后面就可以一键追溯整个任务的生命周期。
5.2 一次恶意文件事件的完整复盘链路
举个真实复盘例子。某次评测中,一个 Agent 在处理一个恶意构造的 PDF 时触发了异常行为。我在日志里看到的时间线是这样的:
09:01:02 任务启动 09:01:13 读取 upload/evil.pdf (2MB) 09:01:28 调用 pdf_parser 工具 09:01:41 进程异常退出 (exit code = 137) 09:01:42 容器被 gVisor 拦截一次 syscall 09:01:43 网络代理请求被 deny 09:05:00 任务超时,被平台终止整个过程中文件解析进程确实尝试执行了一次系统调用,被 gVisor 拦了;随后它又尝试外连一个陌生域名,被网络代理 deny。虽然我们没有成功拦下文件自身的恶意行为(因为 pdf_parser 本身被构造的文件坑了),但沙箱防线阻止了后续所有危害动作。最后容器销毁,恶意文件没有落地到宿主。
这种复盘能力,没有沙箱日志是绝对做不到的。普通程序最多留个"报错"信息,Agent 的问题往往是一个长链条里的多个环节叠加,你必须能一条一条捞出来看。
6. 落地过程中的五个坑:快照、Docker in Docker、清理时机与更多
沙箱落地过程中踩过不少坑,选几个典型的分享一下。你要是打算在自己的平台里搞同类系统,这些大概率你也躲不开。
6.1 坑一:容器根文件系统--read-only和依赖库之间的冲突
Agent 跑起来要装 Python 包、要写临时缓存,这些都需要写/usr或/root/.cache。我一开始直接挂--read-only,Agent 任务全在初始化阶段就崩了。后来解决方案是提前把依赖烘焙进镜像,并把缓存目录挂到 tmpfs。
另一个有效技巧是分两层挂载:根文件系统只读,但单独挂一个可写的agent-cache目录。这样既保护了镜像文件,又给了 Agent 足够的缓存空间。
6.2 坑二:Docker in Docker 的权限陷阱
有段时间我想让 Agent 在沙箱里再管理别的容器,于是用了 docker-in-docker 方案。结果发现 mounted docker.sock 等于把宿主机 Docker 的 root 权限直接交给了 Agent——这比不沙箱还危险。
后来改成两个方案:需要嵌套调用容器的场景,直接通过外部编排 API 让 Agent 请求一个"容器管理工具",工具在宿主侧执行,Agent 拿不到任何 Docker socket 权限。利用这个思路,Agent 的权限始终是最小化的,它只能请求"创建容器"这类操作,而真正执行的是宿主上的受控服务。
6.3 坑三:容器退出后的文件残留与磁盘泄漏
最开始我是容器跑完直接docker rm,结果发现宿主机磁盘越用越少。查了半天,发现容器产生的大量日志、缓存、临时文件都写到了挂载卷里,容器删了但卷没删。后来我在编排层统一做了三件事:挂载卷全部使用临时卷(不写持久化路径)、容器销毁时一并删掉关联卷、定时任务扫描超过 24 小时的孤儿卷并清理。
6.4 坑四:超时后容器不退出
容器配了--stop-timeout=10,但 Agent 任务本身就是多线程的,主进程收到 SIGTERM 后子进程还会继续跑。更糟的是有些 Agent 框架捕捉到 SIGTERM 后会自己"收拾"半天才退出,10 秒根本不够。
我的处理是在任务级别增加一个强制终止通道:超时先让 Agent 框架执行善后(保存状态、补日志),2 秒后下发 SIGKILL,同时用nsenter或进程组 id 把所有子孙进程一并杀掉。这样容器一定是干净退出的。
6.5 坑五:快照时机不对,导致评测结果失真
做 Agent 评测时,我们会在任务开始前给工作目录打一个快照,用来对比 Agent 改动了哪些文件。最开始我是在容器启动那一刻打快照,结果任务一完发现快照不完整——因为 Agent 可能把一些预置数据也删了或改了,对比完全失真。后来我把快照时机调整为"基础镜像 + 本次任务专属数据"两层:基础镜像永不改,任务数据单独挂载并记录 hash,评测时只比对任务数据的变化,非常干净。
7. 一点个人体会
说回最初那个客户项目的教训。回想起来,我们当时没把沙箱当"安全衬底"来设计,而是当成"上线前再补的加固",这个顺序本身就是错的。AI Agent 的行为天然不可预测,只有在不可预测性发生之前就把它装进笼子里,才能保证后面的评测、上线、灰度都是安全的。
如果你现在刚开始搭自己的 Agent 平台,我的建议是:先不要纠结哪个沙箱技术最先进,先想清楚你的 Agent 会接触什么、能做什么、可能会搞坏什么,然后从最小权限出发补全隔离策略。容器、gVisor、网络代理、资源配额、熔断、审计日志,这六块缺哪块都不完整。等这些基础打牢,再谈 Agent 的能力上限才有底气。