ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent沙箱生产落地:选型、持久化与执行协议全复盘

Agent沙箱生产落地:选型、持久化与执行协议全复盘

把 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)用户态内核拦截秒级中高中系统调用被拦截,安全性高
微 VM(Firecracker)独立虚拟化百毫秒~秒级中高中高强隔离,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;
  • 运行时支持切换:默认使用runsc(gVisor),低风险任务可切回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 秒以上;就算镜像在节点上,调度加启动也要 2~3 秒。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 一删就释放;
  • 需要跨步骤保留的工作目录挂载到 PVC(PersistentVolumeClaim),每个 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 关键步骤执行前后,把最新状态写入 Redis;Pod 重启后先从 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:进程启动,附带 pid;
  • output:有新的 stdout/stderr 文本;
  • exit:进程结束,附带 exit_code;
  • error:沙箱内部错误(如超时、资源超限);
  • 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 整体 kill

5.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 都在跑失败任务。具体做法分三步:

  1. 先在一个内部测试环境,用 10 个固定会话跑回归用例,覆盖run_code、run_bash、write_file、cancel等核心方法;
  2. 发布到预发环境,并放量到 5% 的用户流量,对比启动失败率、执行成功率、执行耗时的 p50/p95、取消响应时延这几个核心指标;
  3. 如果 24 小时内没有明显的异常指标,再逐步放量到 30%、100%,同时保留一个“一键回滚”的开关,能快速切回上一版协议实现。

灰度期间一定要看一个容易被忽视的指标——“协议错误码分布”。因为有的错误是客户端老版本不兼容新协议导致的,比如没传新增的seq字段导致服务端解析失败。我们把协议版本号放到消息头里,不支持旧版本的场景直接报unsupported version,避免双方默默地“鸡同鸭讲”。

最后的一点个人体会

做完整个项目,我最大的感觉是:别把 Agent 沙箱当成一个“临时容器”,把它当成一个带记忆、有边界、能审计的微型执行操作系统。前期的选型决定了你能防住多大的风险,持久化设计决定了 Agent 能走多远,而执行协议则决定了整个系统的灵活性。当初我们纠结了很久要不要用重隔离方案,后来发现“轻重隔离组合 + 按风险动态切换”才是最贴近生产的方式。最后再分享一个小技巧:在执行协议里保留一个debug字段,只在内部调试环境开放,线上出了诡异问题时,你把某个会话的 debug 标志打开,就能看到沙箱内部完整的事件流细节,很多时候比日志查半天高效得多。希望这篇复盘能帮你把 Agent 沙箱在生产环境真正落地。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进