给Agent接代码沙箱:六个容易踩的坑
原文:GitHub - 《SPACE:给智能体跑代码的沙箱平台(仓库与架构文档)》(https://github.com/perplexityai/space)
前言
让 Agent 写代码不难,难的是让它把代码跑起来。直接在宿主上执行,等于把 shell 交给了模型;随便用个容器,又挡不住恶意代码往外连。真正要在生产里跑,绕不开沙箱这一层。
Perplexity 开源了支撑 Perplexity Computer 全部沙箱能力的平台 SPACE,全称是 Sandboxed Platform for Agentic Code Execution,把 Agent 和用户的代码放进 Firecracker microVM 里隔离执行,对外只暴露 HTTP API。仓库里有一份写得相当克制的文档,把对象模型、生命周期、输出流都讲清楚了。
正是这份文档让我发现,接沙箱时的坑几乎都不在"能不能跑",而在"你以为的状态"和"它真实的状态"之间。下面六个,是我读完架构文档后认为最容易写错的地方。
一、先记住五个对象
文档把整个平台拆成五个概念,后面所有坑都跟它们的边界有关:
| 对象 | 含义 |
|---|---|
| template | 起始文件系统与运行配置,多个沙箱可以共用同一个模板 |
| sandbox | 一个独立的 microVM,有自己的可写文件系统和生命周期 |
| process | 沙箱内运行的程序,同沙箱内的进程共享环境与文件系统 |
| command session | 一条命令的连接信息与授权,创建时分配进程 ID,attach 之后进程才启动 |
| operation | 异步请求的记录,跟踪 create / pause / suspend / wake / delete |
请求里的 template 既可以写seed:latest这种发布引用,也可以写 digest。沙箱的响应会回报解析后的 template digest,方便你记录这次到底跑的是什么内容。
二、坑一:接口返回成功,不等于沙箱能跑
这是最容易写错的一处。文档说得很直:API 只是把期望状态写进 PostgreSQL,控制器和 worker 去把它对齐,所以请求被接受不是就绪信号。
正确的等待顺序是三段,缺一不可:
- 轮询 operation,直到 state 表示成功;
- 查沙箱,直到 state 是 running;
- 确认进程端点可达,然后才创建 command session。
文档还特别提醒:operation 的 state 描述的是这次生命周期请求的状态,沙箱的 state 描述的是环境状态,两者都不是命令的退出码。把这三个状态混成一个"成功"来判断,是后面一连串诡异问题的源头。
三、坑二:pause 不是快照
暂停有两种,语义完全不同:
pause:执行暂停,但 VM 内存仍然驻留,文件系统还在 worker 上,文档明确写了这不是持久快照;suspend:把 VM 内存、运行时状态和根文件系统做成快照,释放活跃的 VM 资源;wake:把暂停或挂起的沙箱恢复到 running,恢复后要重新等就绪再发命令。
如果你的代码把 pause 当成"存档",指望节点重启后还能捞回来,就会丢状态。
顺带一个容易踩的语义错位:仓库里的 E2B 兼容层(ECW)用的是另一套名字,它的 pause 映射到 SPACE 的 suspend,resume 映射到 restore。同一个词在两套 API 里指的不是一件事,迁移时务必按语义而不是按名字对齐。
四、坑三:命令输出是流,不是日志
文档里有一句话很值得抄下来贴在屏幕上:命令输出是实时流,不是持久日志存储。
具体表现是:客户端用Last-Event-ID重连,得到的是resync_required,而不是补发漏掉的输出。也就是说断了就是断了,没有回放。所以应用侧必须自己把事件流落成 transcript,流失败后保留记录;重跑命令只在"重复执行副作用是安全的"前提下才允许。
下面是一段读取流的骨架,按文档描述的事件语义写成,不是官方 SDK:
# 读取命令输出(自拟示意,基于 SPACE 文档的事件语义)importbase64importjsondefdrain(stream,process_id,timeout=45):out,err,exit_code=[],[],Noneforlineinstream.iter_lines():# SSE:每行形如 "data: {...}"ifnotline.startswith("data: "):continueev=json.loads(line[6:])ifev["type"]=="stdout":# 输出是 base64,必须解码,否则中文和二进制都会乱out.append(base64.b64decode(ev["data"]).decode("utf-8","replace"))elifev["type"]=="stderr":err.append(base64.b64decode(ev["data"]).decode("utf-8","replace"))elifev["type"]=="process_exited":exit_code=ev["exit_code"]break# 只有收到 process_exited 且退出码为 0,才算这条命令成功return"".join(out),"".join(err),exit_code几个要点:输出内容是 base64,不解码会把中文和二进制一起搞坏;process_exited是唯一可靠的结束标志,流关闭本身不代表命令成功;示例里给流设了 45 秒上限,所以命令自身的 timeout 要设得比它小,否则你会先被自己掐断。
五、坑四:命令是参数数组,不是 shell 字符串
请求里的命令是一个可执行文件加参数数组,不是隐式交给 shell 解释的字符串。想要管道、重定向、变量展开,得自己显式写sh -c。
# 想用管道就必须显式走 sh -cCOMMAND={"command":["sh","-c","printf '%s\n' \"$GREETING\"; pwd"],"cwd":"/tmp","env":{"GREETING":"hello"},"timeout":"30s",}还有一个常被忽略的前提:可执行文件和工作目录必须存在于沙箱内,而不是宿主上。你在本机 ls 出来的路径,在 guest 里可能根本不存在。
六、坑五:两套 token 不能混用
管理面用 user session 或 service bearer token,命令流用创建 session 时返回的、作用域限定在该 session 的 attachment token。文档写明两者不可互换。
示例代码还额外做了两件事,值得照做:一是只接受配置好的 data plane origin 加上精确的 session path 作为 attachment 地址,防止 token 被引到别处;二是除回环地址外一律要求 HTTPS。
如果这两条你不做,等于把一次性的命令执行凭据交给了任何一个能改 URL 的地方。
七、坑六:超时不等于失败,别自动重试
分布式系统里这条老规矩在沙箱上格外要命。文档的建议是:每次生命周期变更都带Idempotency-Key,超时后先 inspect,按保存下来的原始请求和幂等键复用,而不是重新发起一个新请求。
还有两个相关的默认行为值得记住:
- 命令执行完成不会自动删除沙箱,删不删是你自己的决定;
- 进程查询接口只返回运行中的进程,命令退出后就查不到了,别指望用它拿历史结果。
顺带几个工程默认值
- 通过 API 创建的沙箱,默认在连续六小时无访问、无命令活动后自动 suspend。这是闲置上限,不是最长生命周期,两者别搞混;
- 根文件系统用 btrfs 的写时复制来创建,模板可以复用而互不影响;
- 仓库提供 warm pool,在 Kubernetes 上预热已启动的沙箱,让创建跳过冷启动;
- Firecracker 隔离的是 guest 执行,宿主侧管理 VM、存储、网络的服务仍然需要特权,安全边界不在这一层;
- 项目使用 Apache License 2.0。
小结
接代码沙箱这件事,真正的成本不在"调通一个执行接口",而在把异步生命周期、流式输出、凭据隔离这三件事的边界认清楚。上面六个坑,归根到底是一句话:把"请求成功"和"执行成功"分开,把"暂停"和"存档"分开,把"实时流"和"日志"分开。
文中对象模型、接口路径与默认值均来自该仓库文档,具体字段约束以其 OpenAPI 描述与官方文档为准;沙箱平台仍在演进,版本细节请以仓库最新说明为准。