
上个月朋友把一个内部演示项目丢给我看他们的智能体已经能在聊天界面里自己调接口、写查询、改配置了但最头疼的是没有人敢让它直接碰真实环境。演示时一切正常一旦放开权限谁也不知道模型会突发奇想调用什么工具、执行什么脚本。“AI Agent 沙箱”这个项目就是为解决这类问题而做的给智能体一套完全受控的试炼场让它能调用工具、操作文件、访问接口但每一动作都被拦截、记录、限制在安全边界内。这套东西把我这些年接触过的 Agent 项目痛点几乎都踩了一遍也沉淀出不少可复用的经验。本文不打算讲什么高深理论而是完整复盘这个沙箱项目从设计、搭建到排障的实践过程。适合以下几类人看一是刚接触智能体开发、想知道怎么安全验证 Agent 行为的技术人员二是做自动化流程、需要让模型执行真实操作但不敢放权的工程师三是想搭一套可观测、可回放、可审计的工具调用环境的运维或平台开发同学。1. 引言为什么需要 AI Agent 沙箱先想一个问题普通聊天机器人调用一次模型接口出错了顶多答非所问但如果一个 Agent 调用的是“执行命令”“写文件”“发请求”这类真实工具一次误操作就可能污染数据、触发线上故障。这不是危言耸听我在项目里见过模型把删除命令当玩笑输出也见过它为了“修正”一个字段值直接把整个配置文件重写了。所以 AI Agent 沙箱的核心价值就三个词隔离、限权、可观测。隔离是指让 Agent 跑在一个独立环境里天然无法直接接触宿主机和真实业务系统限权是指即便 Agent 有能力执行某项操作也必须经过策略判断白名单没允许的动作一律拒绝可观测是指每一次工具调用、参数、返回结果、耗时、上下文都要留下完整记录之后能重放、能定位。很多人会把沙箱简单理解成“开个容器跑起来就行”实际远没有这么简单。你要面对的是模型幻觉、工具协议不统一、资源泄漏、日志缺失、并发污染等一系列工程问题。我最初做这套系统时也天真地以为核心在模型推理结果真正花时间最多的反而是工具服务的稳定性、权限判断的缜密性、以及排障时的可重放能力。这个项目当前的状态是一套可以独立部署的最小闭环系统支持多个 Agent 会话同时提交任务每个会话共享同一个沙箱镜像但拥有独立工作目录工具执行强制走策略中间层。整套核心代码量不大但设计和排障过程很值得记录。2. 整体设计沙箱到底在“限”什么2.1 从“调用语言模型”到“调用真实世界”传统 LLM 应用里模型的输入输出都是文本安全风险集中在提示词注入和内容层面。但 Agent 的本质是把模型从“文本生成器”变成了“决策器”模型决定下一步调用哪个工具、传什么参数然后由执行层代它操作真实系统。这一步跨越就是风险陡增的根源。我见过最典型的例子模型收到一个工具清单其中有一项是“delete_file“根据对话上下文它判断某个临时文件“不需要了”于是果断调用。从模型视角这是合理行为但在真实业务系统里这就是事故。沙箱要做的事情不是禁止这类工具存在而是让这类操作在执行前多几道闸门路径是否在白名单内、该会话是否具备删除权限、是否需要人工审批、操作是否会被记录。2.2 沙箱不只是隔离运行环境在设计初期我梳理了三类不同层次的沙箱它们解决的问题并不一样第一层是静态沙箱也叫解析层隔离。模型输出不直接执行而是先交给解析器把它要调用的工具名、参数做严格校验。比如字段类型不对、必需参数缺失、参数超出枚举范围都直接打回并让模型重新生成。这一层成本最低挡掉了大部分低级错误。第二层是环境沙箱也就是运行层隔离。Agent 的实际操作发生在容器和受限用户态里文件系统、网络、进程都有明确边界。这里主要防止“万一模型判断是对的但工具本身有漏洞”或“操作对象超出了预期范围”。第三层是流程沙箱也就是策略层约束。它会结合任务上下文、用户身份、历史行为做动态判断。比如同一会话内频繁调用高危工具就触发熔断或者某类操作必须经过人工确认。这一层最灵活也最考验系统对业务的理解。2.3 架构分层控制、执行、资源、策略整个沙箱我按四个平面划分这样每个模块的职责非常清晰排障时也能快速定位问题。平面职责典型组成控制面任务接收、生命周期管理、审计日志调度服务、状态存储执行面模型推理、工具调用决策、上下文管理编排器、模型网关资源面文件、网络、进程、资源配额的实际载体容器运行时、隔离文件目录策略面权限判断、白名单校验、熔断审批策略引擎、拦截中间层控制面主要负责任务从提交到结束的流转它决定一个 Agent 会话何时开始、何时结束、最大允许执行多少步、日志落哪里。执行面是大脑承载模型对话和工具调用循环。资源面负责提供真实但受限的操作对象比如一个只包含示例数据的目录、一个假的内部接口。策略面则在执行面和资源面之间架一道闸所有工具调用都必须过它不允许旁路。这套分层带来的直接好处是我可以在不动模型代码的前提下单独调整策略规则比如把某类脚本从“允许执行”改成“需要审批”只要改策略配置不需要重新部署沙箱。3. 环境准备与工具选型3.1 核心运行时容器引擎与资源配额隔离运行环境是沙箱的底座。我选择的方案是通用容器运行时配合 Linux 的用户命名空间。这样 Agent 进程看到的是一个限制后的视图即便出了漏洞也不太容易直接打到宿主机。实际使用时有几个关键配置不能省内存上限、CPU 限额、网络模式、只读根文件系统。我给 Agent 容器设置了 512MB 内存上限和 0.5 个 CPU 的配额网络默认关闭只有通过代理网关出去的请求才被允许。根文件系统改为只读Agent 能写数据的只有挂载进去的独立工作目录。有人会觉得资源配额不关“沙箱”的事但实际踩过坑就明白了模型在循环里调用工具时如果某个工具出现死循环或内存泄漏配额是最后一道防失控的保险。没有配额一个异常脚本就能拖垮整个节点。3.2 开发语言与服务骨架主控服务我用的 Python理由很简单对接各类模型 SDK 最方便生态里的工具解析、Schema 校验库也齐全。整个项目分成三个服务调度服务、编排服务、工具网关。它们之间通过内部 HTTP 接口通信复杂度低也方便独立升级。工具网关本质上是一个轻量级 Web 服务暴露一组执行点比如执行命令、读写文件、请求外部接口。执行面不直接调用工具函数而是请求工具网关再由网关做参数校验、权限判断后真正落地。这种“间接调用”设计最大的好处是审计集中所有工具调用都过同一个入口日志不会缺。3.3 模型服务的接入方式沙箱里的模型客户端我封装了一层支持两类接入一类是本地部署的开源模型通过本地推理服务提供接口另一类是云端模型 API。两者在框架内部使用同一套抽象接口切换时只需要改配置里的端点地址和模型名称。配置里我保留了一个关键开关temperature。沙箱测试阶段我建议把值调低比如 0.2 甚至接近 0这样能减少模型自由发挥的空间避免同一任务不同批次给完全不同的方案。真正要评估模型多样性表现时再把这个参数稍微调高。4. 实操过程从零搭建一套 Agent 沙箱4.1 目录结构与基础配置项目根目录我按模块拆分结构如下agent-sandbox/ ├── orchestrator/ # 编排服务 │ ├── agent_loop.py # 模型调用与工具调用主循环 │ ├── context.py # 会话上下文管理 │ └── model_client.py # 模型接口封装 ├── gateway/ # 工具网关 │ ├── tools/ # 内置工具实现 │ ├── policy.py # 策略判断 │ └── server.py # 工具执行 HTTP 接口 ├── runtime/ # 容器与镜像相关 │ ├── Dockerfile.agent # Agent 运行镜像定义 │ └── start_container.sh # 启动容器的封装脚本 ├── workspace/ # Agent 工作目录挂载进容器 ├── logs/ # 运行日志与审计日志 └── config.yaml # 全局配置config.yaml里集中管理模型端点、容器资源限制、策略开关和日志路径。这里有一个经验不要把配置散落在代码里特别是白名单、超时时间这些要频繁调整的参数散落的后果就是排障时到处找还经常忘改一处。4.2 工具服务化把能力变成可被模型调用的接口工具网关是沙箱最重要的模块。每个工具我定义了三件事调用描述、参数 Schema、执行函数。调用描述给模型看参数 Schema 给模型生成参数用执行函数才是真正干活的代码。# gateway/tools/file_tools.py import os from pathlib import Path WORKSPACE_ROOT Path(/workspace) def read_file(path: str) - str: 读取工作区内文件内容 full_path (WORKSPACE_ROOT / path).resolve() # 关键校验路径必须位于工作区内 if not full_path.is_relative_to(WORKSPACE_ROOT): raise PermissionError(路径越界拒绝读取) if not full_path.exists() or not full_path.is_file(): return f文件不存在: {path} return full_path.read_text(encodingutf-8, errorsreplace) def write_file(path: str, content: str) - str: 写入工作区内文件 full_path (WORKSPACE_ROOT / path).resolve() if not full_path.is_relative_to(WORKSPACE_ROOT): raise PermissionError(路径越界拒绝写入) full_path.parent.mkdir(parentsTrue, exist_okTrue) full_path.write_text(content, encodingutf-8) return f已写入文件: {path}, 长度 {len(content)}这个示例里最关键的是resolve()加is_relative_to校验。模型生成的路径千奇百怪可能是绝对路径可能带..如果不过滤路径穿越Agent 就能借工具访问工作区之外的敏感文件。这类攻击不需要模型很聪明只要工具实现不严谨普通路径拼接就能绕过。工具注册表把描述和 Schema 组织成模型可直接理解的格式# gateway/tools/__init__.py TOOL_REGISTRY { read_file: { description: 读取工作区内的文本文件返回文件内容, parameters: { type: object, properties: { path: {type: string, description: 文件相对工作区的路径} }, required: [path] }, func: read_file, }, write_file: { description: 写入文本到工作区内的文件覆盖已有内容, parameters: { type: object, properties: { path: {type: string, description: 文件相对工作区的路径}, content: {type: string, description: 要写入的内容} }, required: [path, content] }, func: write_file, }, }每个工具的描述必须明确写清“能做什么”和“在什么范围做”。模型依赖这些描述来决定调用哪个工具描述含糊不到位模型就会乱猜。我测试时发现描述里写上“工作区”“相对路径”“覆盖已有内容”这些词模型出错的概率明显下降。4.3 编排器:Agent 主循环实现编排器的任务比较简单维护会话消息调用模型解析工具调用结果把结果回填给模型。但有几个细节需要格外注意。# orchestrator/agent_loop.py def run_agent(task: str, max_steps: int 10): messages [{role: system, content: SYSTEM_PROMPT}] messages.append({role: user, content: task}) for step in range(max_steps): response model_client.chat( messagesmessages, toolstool_schemas, temperature0.2, ) if not response.tool_calls: return response.content for call in response.tool_calls: tool_name call.function.name tool_args json.loads(call.function.arguments) # 统一走策略层不允许直接调用工具函数 result policy_check_and_execute(tool_name, tool_args, session_id) messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse)[:2000], }) return 达到最大执行步数任务终止这里的policy_check_and_execute是核心。它先查白名单再检查参数是否合规然后走工具网关执行。返回结果裁剪到 2000 字符是实践经验模型上下文窗口有限如果工具返回超长文本既不经济又容易把关键信息挤出去。对 Agent 来说大部分情况下路径、状态、摘要已经足够做下一步判断。另一个容易忽略的坑工具返回的内容格式要稳定。不要在返回里出现随机 UUID、当前时间这种字段模型下一次生成时会受这些无关信息干扰。写工具时尽量让返回内容结构化、稳定化。4.4 策略层白名单、审批与熔断策略层的设计我按“先静态、后动态”的思路来做。静态策略包括工具白名单、路径前缀、参数枚举、网络域名白名单。动态策略包括调用频率、危险操作累计次数、会话状态。# gateway/policy.py POLICY { tools: { read_file: {allow: True, audit: True}, write_file: {allow: True, audit: True}, execute_command: {allow: False, audit: True}, # 默认禁止 delete_file: {allow: False, audit: True, require_approval: True}, } } def policy_check_and_execute(tool_name, args, session_id): tool_policy POLICY[tools].get(tool_name) if not tool_policy: return {error: f工具未注册: {tool_name}} if not tool_policy[allow]: return {error: 当前策略不允许调用该工具, tool: tool_name} # 动态熔断同一会话的失败次数过多直接短路 if failure_count(session_id) 5: return {error: 会话累计失败过多已熔断} # 触发人工审批的卡点 if tool_policy.get(require_approval): approval poll_approval(tool_name, args, session_id) if not approval: return {error: 工具调用未获人工审批} return gateway.execute(tool_name, args, session_id)默认拒绝的 “execute_command” 和 “delete_file” 我用的是“显式加白才放行”的策略跟防火墙一个思路默认允许等于裸奔默认拒绝才安全。实际项目里需要执行命令的场景我会把命令限定在特定目录、特定脚本集合里而不是开放任意 shell。最开始我也偷懒过想着内部测试环境无所谓把 execute_command 直接放开。结果模型写了一个脚本里面是无限循环直接把容器 CPU 打满。虽然容器有配额不至于拖垮宿主机但那次也让我意识到沙箱的价值不是“防住所有攻击”而是“即使出了事影响也被控制在最小范围”。4.5 容器启动与挂载容器启动脚本我做了简化封装启动前先把每个会话的工作目录准备好然后以只读根文件系统方式拉起容器。# runtime/start_container.sh SESSION_ID$1 WORKSPACE_DIR${HOST_WORKSPACE_ROOT}/${SESSION_ID} container run --rm \ --network none \ -m 512m \ --cpus 0.5 \ --read-only \ -v ${WORKSPACE_DIR}:/workspace:rw \ -e SESSION_ID${SESSION_ID} \ agent-sandbox:latest容器内部进程通过工具网关访问外部网络等于网络也被代理接管了。这样模型生成的 HTTP 请求只有经过网关校验才能出去外部接口看到的永远是网关 IP而不是某个会话的真实来源。这种模式对于“Agent 胡乱请求外部站点”的场景特别好用。5. 安全加固与边界防护5.1 文件系统工作区即边界工作区是 Agent 在沙箱里唯一可写的区域我把它当作虚拟“磁盘”。所有文件类工具都强制做路径规格化和越界校验。这样做的好处是即便模型被提示词注入诱导去读“ssh 私钥”这类文件它调用的 read_file 也只能读工作区读不到别的路径。另外我建议把工作区做成会话级隔离每个会话一个独立目录。这样多个 Agent 并行跑测试时互不干扰。有人会图省事共用同一个目录结果两个任务同时写同一个文件产生脏数据排障时极难定位。5.2 网络默认关闭显式放行我给容器设置了--network none网络请求统一走工具网关的代理能力。默认情况下 Agent 不具备直接联网能力只有配置了网络访问策略的工具才允许代理请求比如“请求天气接口”“请求内部搜索服务”。域名白名单这个环节容易被忽略但非常重要。模型生成 URL 的能力很强如果不限制域名它可能去请求一个开发机上的内网地址也可能去请求某个真实生产接口。配置里我维护了一个allowed_hosts列表只有命中列表的请求才被代理放行。5.3 资源熔断超时、次数、人工干预熔断设计我总结了三个维度维度阈值示例触发行为单步耗时超过 30 秒终止当前工具调用返回超时错误会话步数超过 10 步终止整个任务提示“达到上限”连续失败连续 5 次工具报错停止执行等待人工介入人工审批接口也做了预留对于“删除文件”“发送消息到外部系统”这类高风险操作策略层可以配置为需要人工审批。审批通过才继续执行审批拒绝则返回给模型一个明确错误。这些熔断值不是拍脑袋定的。我在迭代中根据任务复杂度调整过简单问答任务 5 步基本能完成复杂数据分析要 15 步左右。设置得太小容易误杀正常任务太大又起不到保护作用。建议先按任务样例集跑一遍统计步数分布再定阈值。5.4 审计日志让每次工具调用都有迹可循日志是沙箱项目的生命线。每次工具调用我必须记录会话 ID、模型消息、工具名、参数、策略结果、耗时、返回摘要。这些日志不但用于排障也是后续优化提示词和策略的依据。我定义了一套审计日志格式落成 JSON 文件方便后续做分析{ timestamp: 2024-05-20T10:00:00.123Z, session_id: sess_001, step: 3, tool: write_file, params: {path: notes.txt, content: ...}, policy: { allowed: true, approval_required: false }, result: 已写入文件 notes.txt, duration_ms: 45 }有了这份记录当任务结果异常时我可以按时间线回放整个会话看到模型在第几步做出了哪个决定。没有这套日志面对“Agent 为什么把配置写错了”这种问题只能靠猜。6. 常见问题与排查技巧6.1 模型频繁调用不存在的工具参数一开始我的工具 Schema 写得比较粗比如没有必填字段、没有枚举值限制模型调工具时经常给出莫名其妙的参数。后来我把每个字段的 description 写得更细致并在工具网关上做了严格校验模型参数生成准确率明显提升。排查技巧遇到工具参数错误别急着改模型提示词先看审计日志里模型实际生成的 arguments 长什么样。经常是模型对字段含义理解有偏差这时候在描述里加一句“如果不知道路径就先用 list_files 查看”往往比强行调 temperature 更有效。6.2 工具返回过长导致上下文爆炸Agent 在执行数据分析类任务时如果工具返回一个几千行的 CSV 全文下一轮模型调用会非常慢甚至超出上下文限制。我做了两级处理一是工具层支持摘要模式默认只返回前 N 行和前 N 列二是编排层裁剪结果长度超过 2000 字符就省略中间内容。实践心得工具返回精炼程度比想象中重要。让模型面对一个上千行 JSON 的结果它的判断力会显著下降可能忽略关键的报错字段。工具应该尽量返回“结论式”的内容比如统计信息、路径状态、错误原因而不是原始数据堆砌。6.3 并发会话污染项目早期没有做会话级工作目录隔离多个任务共享一个 workspace结果 A 任务生成的文件被 B 任务删掉排障时两边的日志都对得上但文件就是不见了。后来把工作目录改成了按会话 ID 子目录隔离问题才彻底解决。这也是沙箱设计里“资源面隔离”的重要体现会话不仅要隔离进程还要隔离文件、变量、网络访问身份。任何跨会话的共享都是一颗地雷能不共享就不共享。6.4 模型“坚持”使用已被拒绝的工具有时候模型会对某个工具特别执着即便策略层明确返回“未授权”模型还是会继续尝试调用。这种情况通常是提示词里给了太多工具模型被误导了。我的处理办法先把不允许的工具从模型可见的工具列表里移除而不是只依靠策略层拒绝。让模型“看不见”比“看见了但拒绝”更省事。策略层仍然保留兜底检查但模型侧已经不把不允许的操作纳入决策范围了。7. 从实践里沉淀的一些体会做这套沙箱最大的感受是Agent 的安全性不是靠单一技术点解决的而是靠分层叠加出来的纵深防御。路径校验挡漏网之鱼容器隔离挡严重漏洞策略层挡业务风险审计日志让每次事故都能被复盘。如果只让我分享一条经验那就是先默认拒绝再逐个放行。无论模型多聪明、工具多完善安全边界越窄事故半径越小。沙箱的价值不在于把自己包得密不透风而在于明确告诉 Agent “你可以自由发挥但只限在这块场地里”。这套体系后续还能往两个方向扩展一是把工具调用结果做成向量存储让 Agent 跨会话积累经验二是接入更细粒度的审批策略比如结合任务上下文做动态权限。沙箱本身不是终点它是让 Agent 从“玩具”走向“工具”的必经之路。