把 Agent 沙箱接进生产这个问题的复杂度比大多数人预想的高得多。你在本地用 Docker 跑一个 Agent 很爽代码写错大不了删容器重来可一旦上了生产要面对真实用户、真实并发、真实业务数据选型、持久化、执行协议这三件事随便一件都能让人折腾一个月。我在花椒负责 Agent 执行基础设施这段时间把沙箱从原型做到上线踩了不少坑。这篇就把我们的选型逻辑、持久化方案、执行协议设计以及生产落地时的那些「文档里写不到」的经验完整复盘出来给正在做 Agent 平台的工程师一个参考。1. 为什么 Agent 要单独拥有一套沙箱1.1 威胁模型Agent 不是普通命令行很多人一开始会觉得Agent 执行代码不就是服务器上subprocess.run()跑一下吗不是的。Agent 沙箱面对的执行对象是 LLM 生成的代码LLM 不是可预测的程序它可能因为幻觉、Prompt 注入或者单纯模型抽风生成一段对你服务器造成致命打击的命令。举个真实的场景用户让 Agent “帮我整理/tmp/a.csv里的数据并把含我的手机号的列删掉”。如果 Agent 生成的代码里出现了rm -rf /这种极端命令又或者它的工作目录判断失误跑到了生产配置目录后果不是开玩笑的。更隐蔽的威胁包括向上读/etc/passwd、向下写/root/.ssh/authorized_keys、遍历内网 IP、拉满 CPU 造成同机房其他任务卡顿等。所以 Agent 代码执行需要比普通 CI 沙箱更严格的安全边界。CI 里跑的代码是可控的Agent 里的代码默认是不可信的这个思维要转变过来。1.2 生产环境和开发调试的差距开发调试时你本地docker run --rm agent-sandbox开一个容器传一段 Python 进去看不到什么资源限制反正你的 Mac 也只有十几秒。但生产环境完全不同多租户隔离一个 Agent 会话的异常不能影响其他用户生命周期管理任务要能超时强杀、用户取消、空闲回收容器不能一直挂在那里资源配额CPU、内存、磁盘、进程数、线程数全部要限额否则一个while True就能拖垮一台机器可观测性执行日志、事件流、traceId 全链路串联出问题要能定位审计要求谁在哪一次会话里让 Agent 执行了什么命令都必须可回溯。这些需求意味着你不能简单地把所有 Agent 进程丢进一个共享 runtime 里。想象一下住集体宿舍一个室友发疯全宿舍都遭殃。生产环境需要的是每人一个带独立卫浴的小单间独立到可以随时拆掉重建。1.3 我们需要什么样的边界能力在选型之前我们内部整理了一份需求清单后来证明这份清单帮我们避开了很多花里胡哨的方案。关键需求如下强隔离至少做到文件系统、进程树、网络栈隔离内核级权限要最小化细粒度资源限制每个沙箱的 CPU、内存、磁盘、进程数、带宽都可限定文件系统“可写但可丢弃”Agent 执行过程中肯定要写临时文件但沙箱销毁后这些文件要能无痕清掉网络默认不可达默认拒绝对外访问必须显式放行才能访问指定外部服务启动快Agent 交互有延迟敏感度沙箱创建最好秒级内支持流式输出LLM 需要一边执行一边观察 stdout/stderr而不是等进程结束才拿结果持久化可选需要跨步骤保留的数据要能挂载外部存储不需要的则全部随沙箱销毁。有了这份清单接下来选型就变得清晰了——不是看哪个方案名字响而是看它能否同时满足这些“约束条件”。2. 沙箱选型从容器到微 VM花椒最终选了组合方案2.1 候选方案横向对比我们调研过几类主流方案最终用一张表把它们的关键指标摊开对比。这里的“隔离级别”指安全边界强度不是简单的 namespace 分词义。方案隔离级别启动延迟资源开销生态兼容性运维复杂度备注普通 Docker 容器Cgroups Namespace毫秒~秒级低极高低共享内核逃逸风险偏高沙箱运行时容器gVisor用户态内核拦截秒级中高中系统调用被拦截安全性高微 VMFirecracker独立虚拟化百毫秒~秒级中高中高强隔离VM 内核启动是开销K8s Job/Pod容器化 网络策略秒级含调度低高中适合已有 K8s 集群的场景WASM 沙箱编译后受限运行极快极低低低很多动态语言依赖原生 syscall跑不了商业沙箱 PaaS外部托管快低因厂商而异最低数据出域、合规和定制受限表格里的“生态兼容性”是 Agent 执行代码的关键。比如 WASM 看着很轻很安全但跑 Python 脚本需要运行时具备完整的操作系统调用能力很多 PyPI 包装不上直接淘汰。Firecracker 隔离很强但镜像内的程序要跑在精简 Linux 内核上某些依赖可能报错而且运维虚拟机镜像比管容器重太多。2.2 花椒为什么没选“全家桶式”方案我们也考虑过直接上一套商业沙箱 PaaS因为它接口简单、开箱即用。但最后放弃的原因是三点的组合一是业务数据必须经过沙箱数据出域在合规和风控上很难受二是审计和日志必须深度定制商业方案很难把执行明细无缝接入我们的安全事件平台三是 Agent 执行特性变化快我们需要快速在沙箱层加能力比如自定义网络白名单、按场景切换隔离级别托管平台做不到这么细。内部也考虑过所有任务统一用 Firecracker 微 VM。PoC 阶段发现两件事一是 VM 冷启动加镜像网络初始化平均耗时超过我们的预期二是有些 Agent 执行的代码会访问 GPU 相关设备或特殊文件系统微 VM 需要额外配置直通复杂度成倍增加。最终我们把 Firecracker 定位成“高敏感任务专用”默认路径不用。2.3 最终选型K8s 按需 Pod 可插拔运行时我们的最终方案是利用已有的 Kubernetes 集群每个 Agent 执行任务动态创建一个独立的 Pod。Pod 里跑一个sandbox-runner进程它负责接收控制端发来的执行指令操作内部进程并把结果返回。关键配置如下每个 Pod 的根文件系统挂载为只读可写目录只挂载emptyDir临时数据或指定的PersistentVolume持久数据容器内以非 root 用户启动并设置no-new-privileges: true启用 seccomp 限制系统调用使用 NetworkPolicy 默认拒绝所有入站和出站流量只放行白名单域名/IP运行时支持切换默认使用runscgVisor低风险任务可切回runc以提高性能。这里给一个简化版的 Pod 参考配置实际生产需要结合内部 Controller 动态生成apiVersion: v1 kind: Pod metadata: name: agent-sandbox-${sid} labels: app: agent-sandbox spec: runtimeClassName: gvisor restartPolicy: Never containers: - name: runner image: registry.internal/sandbox-runner:v1.2.3 securityContext: runAsNonRoot: true runAsUser: 10001 privileged: false allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: [ALL] resources: limits: cpu: 1 memory: 1Gi volumeMounts: - name: tmpfs mountPath: /workspace - name: persistent mountPath: /data volumes: - name: tmpfs emptyDir: medium: Memory sizeLimit: 512Mi - name: persistent persistentVolumeClaim: claimName: sandbox-pvc-${sessionId}为什么选这套组合而不是单一方案在于“可插拔运行时”给上层留了退路。Agent 的任务类型差异很大写个 SQL 分析、跑一段 Python 计算、执行 shell 脚本安全要求不同。我们按风险等级给任务打标高风险任务用 gVisor 全网络拒绝低风险任务用 runc 域名白名单性能和安全各得其所。2.4 选型中容易被忽略的“调度等待时间”容器创建不是瞬时的尤其在 K8s 里要经过 API Server、调度器、kubelet、镜像拉取、容器运行时启动这一整套链路。实测下来如果镜像没预热冷启动可能要 10 秒以上就算镜像在节点上调度加启动也要 23 秒。Agent 在等代码执行结果时每多一秒都会显著影响用户体验。我们的解法是维护一个“预热沙箱池”。控制面预先创建若干处于空闲状态的 Pod这些 Pod 内部跑着一个长期候命的 agentv1只等待控制面的指令。接到真实任务后控制面通过内部消息把任务参数传给已就绪的 Sandbox Runner把 Pod 状态从“空闲”切到“运行”。池子的大小根据历史并发量和任务到达速率动态调整空闲 Pod 超过保留时长就回收任务高峰则自动扩容。这个做法把“所见即所得”的启动延迟从秒级甚至十秒级降到了几百毫秒。3. 持久化别让 Agent 一断电就失忆很多人对沙箱有个误解以为沙箱就等于一次性容器所有东西用完就扔。但生产中的 Agent 是有状态的它可能需要读取上一个步骤生成的文件需要记住对话历史需要恢复上次的执行上下文。所以持久化必须分层设计。3.1 沙箱持久化的三个层次文件系统层Agent 执行过程中的临时工作目录、任务产物、依赖包这些数据通常随 Pod 销毁状态层Agent 的会话状态、对话历史、变量、任务进度这些数据必须放在沙箱之外审计层每次执行的完整输入、输出、退出码、耗时、资源使用都要持久化到日志/事件中心。这里要特别说明Reids 只是状态层的一个组件不要试图用 Redis 扛所有持久化。文件产物得有对象存储审计日志得有消息队列和冷存储各司其职。3.2 文件持久化不可变根文件系统 挂载卷 对象存储归档我们把 Pod 的根文件系统设置为只读所有运行时需要的写入都走挂载卷。具体策略是/workspace挂载内存型emptyDir作为快速临时存储Agent 在这个目录里乱写也不怕Pod 一删就释放需要跨步骤保留的工作目录挂载到 PVCPersistentVolumeClaim每个 Agent 会话独立一个 PVC避免彼此污染任务结束后控制面从 PVC 里筛选产物上传到对象存储然后删除 PVC。这个设计类似于“草稿纸 vs 文件柜”临时结果写在草稿纸上删了就删了正式产物放进文件柜归档。在代码里Agent 可以把中间文件写到/workspace/intermediate.json把最终结果声明成output路径由沙箱协议层在任务收尾时主动抓取并异步上传。注意上传前要做文件类型检测避免把 Agent 意外生成的大体积二进制日志传到对象存储拖累成本。3.3 状态持久化为什么用 Redis 而不是数据库Agent 会话状态是典型的 KV 型数据session_id - { history, memory_variables, current_context }。这种高频读写、短生命周期、允许一定过期容错的数据天然适合 Redis。花椒的选型是 Redis 为主存储 定期兜底。关于热词里大家关心的“Redis 持久化”我要多说几句。Redis 的持久化有两种RDB 快照和 AOF 日志。生产环境我们开启了 AOF 并设置everysec这种同步策略又打开了 RDB 作为冷启动的备份。但真正的重点不是开关而是预防大 Key 和内存淘汰每个 Agent 会话的 Hash Key 严格限制大小历史消息超长时做 summarization 或裁剪给 Key 设置合理的 TTL比如 24 小时无人活跃就自动过期用单独的 Redis 实例/逻辑库承载 Agent 状态不要和生产缓存混用避免一次全量清扫把 Agent 状态误杀。状态持久化的数据一致性也要考虑。如果 Agent 任务执行到一半Pod 被淘汰怎么办我们的做法是平台侧在每次 Agent 关键步骤执行前后把最新状态写入 RedisPod 重启后先从 Redis 加载状态再决定是从头继续还是从断点重试。注意 Agent 的下一步动作往往依赖上一步的输出所以状态更新要采用“执行前准备、执行后提交”的方式防止任务执行过程中状态丢失。3.4 持久化的“数据所有权”问题沙箱里的数据可能是用户的代码、用户的文件、用户的不想被别人看到的东西。所以在持久化设计中我们必须实现数据访问控制。文件上传到对象存储时路径会带上 sessionId并通过 ACL 限制只有对应用户的任务能读取。同时我们会对持久化内容做一轮敏感信息扫描脱敏比如检测疑似密钥、手机号、token命中则替换或在写入时阻止。此外还要规定数据的最大保留期——比如原始执行输入 30 天后删除只保留脱敏后的统计日志。这不仅是合规要求也是一种面向长期运营的保护。4. 执行协议Agent 与沙箱如何高效、安全地对话沙箱是一个执行引擎Agent 框架则是大脑二者之间的“语言”至关重要。我们管这个叫执行协议。它决定了调用是否顺畅、能否中断、能否流式观察、能否跨网络和数据中心工作。4.1 交互模式同步 vs 异步Agent 调用工具时大多数场景希望同步拿到结果模型传入一段代码沙箱执行返回 stdout。但同步不能一直挂在那里必须有超时。对于长时间运行的任务训练一个小模型、批量爬虫等我们必须支持异步模式。为此我们设计了两种接口通道短连接通道HTTP用于秒级以内能完成的小命令比如read_file、list_dir、simple_exec长连接通道WebSocket用于需要执行几秒到几分钟的代码流式输出结果并支持随时cancel。为什么不是全部走 HTTP 轮询因为 Agent 执行代码常常需要“边跑边看输出”比如print到一半还卡着模型需要根据部分输出来决定接下来怎么做。HTTP 轮询每次都要带 token、重新建立连接还会产生额外延迟并且在取消操作时很难实时驱动远端进程。WebSocket 是双向的控制面和沙箱之间可以随时互相推消息取消指令几乎无延迟。执行协议层我们最终选择了自定义的 JSON-RPC 2.0 风格消息机制而不是直接套现成框架比如 gRPC因为这个层次需要很强的灵活性参数的多样性同时 JSON 报文对排查问题友好。4.2 协议方法定义我们的协议以 JSON-RPC 2.0 为基础每条请求包含id、method、params响应包含id、result或error。下面是核心方法方法参数说明run_codelanguage, code, timeout, workdir, env运行一段代码返回聚合输出run_bashcommand, args, timeout, workdir运行一条 shell 命令参数必须数组化write_filepath, content, mode, append向沙箱内写入文件正常路径需在白名单内read_filepath, offset, max_bytes读取文件内容超限截断list_dirpath, recursive列出目录结构便于 Agent 观察环境ping-心跳检测同时上报资源占用canceltask_id取消当前执行任务杀掉进程树每个执行方法都支持timeout参数控制端会给一个硬超时到点后沙箱内会杀掉进程组并返回 timeout 错误。我们特别强调整体执行粒度的最大时长比如单次run_code最长 300 秒超过后必须分步。这样做一方面是降低资源风险另一方面也能防止 Agent 陷入无意义的死循环。4.3 流式输出与输出截断流式输出需要定义事件协议。我们的事件类型包括started进程启动附带 pidoutput有新的 stdout/stderr 文本exit进程结束附带 exit_codeerror沙箱内部错误如超时、资源超限heartbeat由沙箱定期发送表示进程仍存活。每条事件带一个单调递增的seq序号接收方用于去重和排序。如果 Agent 需要停止任务的输出我们会插入截断事件。输出截断有一个比较隐蔽的坑代码里的print(\x1b[31m...)这类 ANSI 转义会让模型看到乱码甚至可能触发某些模型的“越狱”行为。我们在协议层做了清洗把 ANSI 控制字符、非 UTF-8 字节序列统一替换成可见占位符。每行输出也限制长度超过部分自动折叠并注明“line ignored”。4.4 与 Agent 框架的对接实现一个标准 Tool 接口执行协议设计得再好也不能让上层模型直接对着 JSON-RPC 裸调。我们的做法是在 Agent 框架层封装一个沙箱工具符合模型 Function Calling / Tool Use 的抽象。简化后的接口长这样class SandboxTool: def __init__(self, session_id, ws_uri): self.session_id session_id self.ws_uri ws_uri async def call(self, tool_input: str) - str: # tool_input 是一段自然语言描述这里由内部 LLM 解析成 run_code 参数 code self.llm_plan_code(tool_input) result await self.run_code(code) return result async def run_code(self, code: str) - str: payload { id: uuid4().hex, method: run_code, params: {language: python, code: code, timeout: 60} } # 发送到 WebSocket 连接等待流式事件聚合后返回 return await self._send_and_aggregate(payload)这里要注意不要假设模型输出的代码一定安全所以工具层发起run_code之前还会经过一个静态预检模块检测import os; os.system(...)等危险模式虽然沙箱本身已经做了隔离但多一层检测可以提前阻断明显恶意行为。预检命中后返回一个友好错误给 Agent提示它换一种实现方式。4.5 操作安全细节即使有了强隔离沙箱协议层仍然要做安全加固命令执行参数数组化run_bash的command和args必须分离禁止用字符串拼接整条命令防止 Agent 自己注入额外的 shell 元字符路径白名单write_file和read_file只能操作挂载目录内的路径路径要做realpath解析后再比对前缀防止../穿越网络访问控制沙箱默认没有外网访问能力仅允许访问显式配置的“业务外网白名单”比如数据源 API、对象存储域名同时明确封禁云厂商元数据服务地址和内网网段取消必须杀进程树不能只杀父进程否则子进程可能变成孤儿继续偷偷跑。我们给每个执行任务建一个单独的进程组取消时对整个进程组发送 SIGKILL再配合 Pod 回收兜底。5. 生产落地那些只有上线后才知道的坑5.1 常见问题速查表上线运维阶段我们积累了下面这张问题速查表希望能帮你少走弯路现象可能原因排查思路解决办法沙箱启动很慢镜像没预热 / 节点资源不足 / 调度排队看镜像拉取耗时、Node 剩余资源、Pending 状态原因镜像定期预热、扩节点、预置池兜底Agent 执行卡死无响应代码里有死循环或等待某种 IOC抓取 Pod 内进程 CPU/内存看是用户态卡住还是 syscall 阻塞强制超时 杀进程组限 CPU写盘超限某个任务不停写大文件占了节点磁盘查 PVC 使用量、emptyDir 大小挂载卷设置 sizeLimit写满后让进程报错输出乱码二进制内容、ANSI 控制符、编码不识别抓事件流看原始字节协议层清洗不可见字符网络超时沙箱没有放行目标域名看 NetworkPolicy 日志看 DNS 解析补充白名单或让业务把目标服务的访问改为异步化Agent 恢复会话失败Redis Key 过期或状态未及时提交查 Redis TTL查状态写入时序调整 TTL状态提交改为事务性或先写后执行取消命令不生效只杀了 shell子进程未终结看进程树里残留 PID用进程组/ session id 整体 kill5.2 资源回收怎么避免沙箱泄漏资源泄漏是沙箱平台最大的隐性成本。K8s 里的 Pod 如果被外部网络请求一直挂着比如 WebSocket 连接没断开或者 Agent 任务执行完成后控制面没有通知 Pod 退出Pod 就会变成“僵尸”持续占着节点上的 CPU/内存。我们的策略是每个沙箱 Pod 都设置activeDeadlineSeconds从创建开始算最长存活不超过 1 小时控制面维护一个心跳表如果沙箱 2 分钟没有心跳或没有任何协议消息就主动标记为“失联”并调用 K8s API 删除 Pod删除 Pod 前先由回收 Worker 拉取尚未同步的执行日志和产物这个过程限定在 30 秒内超时则放弃并记录审计事件持久卷PVC同样使用 TTL 机制任务结束后异步删除删除前检查对象存储归档状态。这里还有一个细节如果你用 Deployment 管理沙箱 Pod记得把replicas设为 0或者干脆用 Job 资源。因为 Landing 到一个“任务型”Pod 不应该被 Deployment 的副本控制器拉起。5.3 审计与可观测性生产环境一旦出了安全事件没有审计就完全无法定位。我们的做法是所有发往沙箱的协议请求和所有返回事件都会在控制面打一个全局traceId并同步写两份日志一份是结构化日志落到 Elasticsearch 类似系统用于查询最近任务上下文另一份是事件流投递到消息队列由风控和审计服务消费关注run_bash里的高危命令、read_file的敏感路径、write_file的目标文件是否有异常。为了性能协议的全文日志只在“高风险任务”或者“告警事件”时才完整记录常规任务只记录摘要方法名、返回码、执行时长、流量大小。否则全量存储成本会非常高。这里也有一点小技巧在 Agent 的每次工具调用结果前加一个隐藏标记字段比如_event_query这个字段只在内部日志分析时启用可以快速定位某一次模型决策所对应的沙箱操作。5.4 灰度与发布试验新协议任何沙箱配置改动都要走灰度。我们改协议字段时不敢直接全量切换否则一旦沙箱 runner 新版本有 bug所有 Agent 都在跑失败任务。具体做法分三步先在一个内部测试环境用 10 个固定会话跑回归用例覆盖run_code、run_bash、write_file、cancel等核心方法发布到预发环境并放量到 5% 的用户流量对比启动失败率、执行成功率、执行耗时的 p50/p95、取消响应时延这几个核心指标如果 24 小时内没有明显的异常指标再逐步放量到 30%、100%同时保留一个“一键回滚”的开关能快速切回上一版协议实现。灰度期间一定要看一个容易被忽视的指标——“协议错误码分布”。因为有的错误是客户端老版本不兼容新协议导致的比如没传新增的seq字段导致服务端解析失败。我们把协议版本号放到消息头里不支持旧版本的场景直接报unsupported version避免双方默默地“鸡同鸭讲”。最后的一点个人体会做完整个项目我最大的感觉是别把 Agent 沙箱当成一个“临时容器”把它当成一个带记忆、有边界、能审计的微型执行操作系统。前期的选型决定了你能防住多大的风险持久化设计决定了 Agent 能走多远而执行协议则决定了整个系统的灵活性。当初我们纠结了很久要不要用重隔离方案后来发现“轻重隔离组合 按风险动态切换”才是最贴近生产的方式。最后再分享一个小技巧在执行协议里保留一个debug字段只在内部调试环境开放线上出了诡异问题时你把某个会话的 debug 标志打开就能看到沙箱内部完整的事件流细节很多时候比日志查半天高效得多。希望这篇复盘能帮你把 Agent 沙箱在生产环境真正落地。