让 Agent 在沙箱里写代码跑代码,产物进 OSS

2 阅读10分钟

我最近有一个真实的业务需求:

  • 有一份正在开发的项目,不想被 Agent 生成的代码污染
  • 我需要一个沙箱来运行它写出来的代码(还要能装依赖、跑测试)
  • 跑出来的产物(图表、csv、日志、报告)要传到 OSS,好下载、好分享、好长期保存

本文要回答的就是最后一句话:哪些文件进沙箱,哪些进 OSS,靠什么决定。


1. 需求映射:两种介质,各管一件事

沙箱(sandbox)OSS(对象存储)
用来干嘛写代码、装依赖、跑代码、跑测试的工作台存最终产物的仓库
生命周期会话结束就该丢掉永久
会不会污染我的项目不会(在远端容器里)不会(在云上)
谁往里放东西Agent 的 write_file + execute只有 write_file(见第 6 节:execute 永远不碰 OSS)
存什么源码、临时文件、依赖、日志、中间结果交付物:图表、csv/json、训练好的模型、报告

结论先行:在 deepagents 里,决定一个文件去哪的,只有它的「路径前缀」。 我要做的事,本质是先定一套路径规范,再把 CompositeBackend 的 routes 配成对应关系。


2. 先认清「沙箱」这个词

deepagents 里能当后端的类不少,但只有沙箱类才能跑代码(实现 SandboxBackendProtocol,有 execute())。这是选后端的第一条硬标准:

后端文件存哪能 execute 跑代码吗适合你的需求吗
BaseSandbox / LangSmithSandbox远端隔离环境内部✅✅ 这就是你要的「沙箱」
(自己包的)E2B / Docker / 云函数远端容器内部✅✅ 你的环境里已经装了 e2b 2.45.1
FilesystemBackend你本机磁盘(root_dir)❌⚠️ 只适合当「被读取的资料区」,不要拿它当代码工作台
LocalShellBackend你本机磁盘✅❌ 它是在你电脑上直接执行命令,没有隔离,等于把项目暴露给 Agent
StateBackendLangGraph 执行状态里的一块数据❌只适合放很小的临时文本,重启/换 thread 就没了
StoreBackendLangGraph Store(可挂数据库/Redis)❌想要「跨会话记住东西」时用,不是 OSS

注意一个反直觉的点:StateBackend 经常被教程叫作「沙箱」,其实它不是沙箱——它只是一个不落盘的虚拟文件系统,跑不了代码。

所以 default 必须是一个真沙箱后端,execute 才有地方执行。我这里的沙箱后端选用的是阿里云。


3. 路径规范(先定规矩,再配路由)

在项目代码里,「我去哪」这件事完全由路径前缀表达。比方说可以这么规定:

虚拟路径(Agent 看到的)实际去向放什么
/workspace/** (即 default)沙箱项目源码、requirements.txt、跑出来的中间文件
/tmp/**沙箱(或 StateBackend)一次性临时文件、日志
/artifacts/**OSS最终产物:report.md、chart.png、result.csv
/input/**本机磁盘(只读)我原本项目里的数据/文档,让 Agent 读了当输入
/memories/**StoreBackend想长期记住的笔记、偏好

要点:

  • 前缀是给模型看的约定,不是真实目录名。/artifacts/ 本身不会被写进 OSS 的 key(会被切掉,见第 5 节)。
  • /artifacts/ 下面写得越规整,OSS 上越好找(比如 /artifacts/{run_id}/result.csv)。

4. 配置:把上面的表格变成代码

from deepagents import create_deep_agent
from deepagents.backends import CompositeBackend, FilesystemBackend, StoreBackend
from my_project.sandbox_e2b import E2BSandbox      # 我自己包的沙箱后端(见第 5 节)
from my_project.backend_oss import OSSBackend      # 我自己写的 OSS 后端(见第 5 节)
​
agent = create_deep_agent(
    model=model,
    system_prompt=(
        "你只能在 /workspace 下写代码并运行,不要碰 /input。\n"
        "所有最终产物必须写到 /artifacts/ 下(用 write_file),"
        "写完再用 ls /artifacts 确认一次。"
    ),
    backend=CompositeBackend(
        default=E2BSandbox(),                      # ① 兜底 = 沙箱:写代码、跑代码都在这
        routes={
            "/artifacts/": OSSBackend(bucket="my-agent-output"),
            "/input/": FilesystemBackend(root_dir=r"D:\work\my-project\data"),
            "/memories/": StoreBackend(namespace=lambda rt: ("agent", "notes")),
        },
    ),
)

读法(这就是全文的核心):

  • default=E2BSandbox() → 凡是没有被任何 route 命中的路径,全部进沙箱。 所以 /workspace/main.py、/tmp/run.log 自动在沙箱里,你不需要为它们写路由。
  • routes 里那几行 → 只有这些前缀,才被「劫持」到 OSS / 本机磁盘 / Store。
  • 我原本的项目目录完全不在任何一行里 → Agent 根本触达不到它。

5. 怎么判断某个路径到底去哪(路由算法)

拿一个路径去比对,规则只有三条:

  1. 命中哪个前缀:/artifacts/chart.png 以 /artifacts/ 开头 → 用 OSSBackend;对不上任何一行 → 用 default(沙箱)。
  2. 前缀会被裁掉:交给 OSSBackend 的路径是 /chart.png,它再拼上自己的 prefix,所以 OSS 上的 key 是 my-agent-output/runs/2026-10-09/chart.png —— 不含 /artifacts。
  3. 多路由时最长前缀优先:同时有 /artifacts/ 和 /artifacts/raw/ 时,/artifacts/raw/x.bin 走后者。

举几个例子(对照第 4 节的配置):

Agent 操作的路径命中哪行实际落点
/workspace/train.py没命中 → default沙箱内 /workspace/train.py,你磁盘上没有
/tmp/out.log没命中 → default沙箱内,随沙箱销毁
/artifacts/report.md/artifacts/oss://my-agent-output/runs/2026-10-09/report.md
/input/sales.csv/input/你本机 D:\work\my-project\data\sales.csv(只读用)
/memories/todo.md/memories/LangGraph Store
execute("python train.py")不看路径永远在 default(沙箱)里跑

5.5 映射规则是怎么定的:从虚拟路径到真实存储位置

上面的表里,文件的输入与输出路径,可能有些人会有疑惑:

  • /artifacts/report.md 凭什么变成 my-agent-output/runs/2026-10-09/report.md?/artifacts/ 和 my-agent-output 是什么关系?
  • 沙箱那边我没有配 /workspace/ 这条路由,为什么文件却落在沙箱的 /workspace/ 里?

核心:映射分两层,每层只干一件事

模型给的虚拟路径
   │
   │ 第 1 层:CompositeBackend(路由层)—— 只做一件事:把命中的路由前缀「删掉」
   │         /artifacts/report.md ──(命中 "/artifacts/")──▶ 交给 OSSBackend 的路径 = "/report.md"
   │
   │ 第 2 层:后端自己(存储层)—— 把 "/xxx" 翻译成自己的存储位置
   │         OSSBackend         "/report.md" ──(自己拼 bucket + key 前缀)──▶ oss://my-agent-output/runs/2026-10-09/report.md
   │         FilesystemBackend  "/x.md"     ──(root_dir + 路径)──▶          D:\work\my-project\data\x.md
   │         沙箱后端           "/x.py"     ──(远端容器文件系统)──▶         容器内 /x.py
层由谁决定职责可配置的部分
第 1 层 · 路由层CompositeBackend按前缀选后端,并把该前缀从路径里去掉只能配「哪个前缀 → 哪个后端」
第 2 层 · 存储层各后端类自己的实现把收到的 /xxx 映射成真实存储位置由该后端的构造参数决定

关键结论:第 1 层做的是「减法」,不是「替换」。 /artifacts/ 不会变成 my-agent-output,它只是被删掉。my-agent-output 这个词根本没出现在路由配置里,它出现在第 2 层:

最终 key 里的部分在哪一层被拼上来自哪里
my-agent-output(bucket 名)第 2 层,由 OSSBackend 决定OSSBackend(bucket="my-agent-output")
runs/2026-10-09/(key 前缀)第 2 层,由 OSSBackend 决定OSSBackend(key_prefix="runs/2026-10-09/")
report.md(真实文件名)第 1 层剥完前缀后剩下的部分模型传进来的 /artifacts/report.md
/artifacts/(虚拟路由前缀)不会出现在 OSS 上第 1 层已删掉

把这两层写成代码,一眼就能对上:

  class OSSBackend:
      def __init__(self, bucket: str, key_prefix: str = ""):
          self.bucket = bucket            # ← "my-agent-output" 来自这里
          self.key_prefix = key_prefix    # ← "runs/2026-10-09/" 来自这里
​
      def write(self, path: str, content: str):
          # 注意:传进来的 path 已经被第 1 层处理过,不带 "/artifacts"
          key = self.key_prefix + path.lstrip("/")     # "runs/2026-10-09/" + "report.md"
          self.client.put_object(self.bucket, key, content.encode("utf-8"))

为什么非要分两层、为什么第 1 层要删前缀

  1. 同一个后端要能挂多个前缀。 假如我把同一个 OSS 后端同时挂在 "/artifacts/" 和 "/uploads/" 上:不删前缀的话,OSS 侧的 key 会莫名其妙带上 /artifacts、/uploads 这些虚拟目录名,两个入口各成一套目录体系。删掉之后两边都以 / 为根,OSS 侧的结构由我完全掌控。
  2. 后端不该知道自己是挂在哪个虚拟前缀下的。 FilesystemBackend(root_dir=...) 只管「把 /x 放进 root_dir」;它被挂在 /input/ 还是 /assets/,它不需要关心。正因为这样,同一个后端类才能在多个路由位置上复用。
  3. /artifacts/ 是给模型看的「约定」,属于提示词工程;my-agent-output/runs/... 是运维层面的存储布局。 两者关注点不同,所以被刻意分在两层,改一个不影响另一个。

由此得到一个很实用的推论:想调整 OSS 上的目录结构,不用动路由,改后端参数就行。 把 key_prefix 从 runs/2026-10-09/ 换成 runs/2026-10-10/,Agent 眼里的 /artifacts/... 一点变化都没有。

沙箱这一侧的映射:没有第 1 层

沙箱在我的配置里是 default,这里和 OSS 有个本质区别:

default 完全不经过第 1 层。 按第 5 节规则 1,路径没命中任何路由时原样交给 default,一个字都不改。所以 /workspace/train.py 到了沙箱适配器手上,仍然是 /workspace/train.py。

OSS 路由沙箱(default)
第 1 层 删前缀删掉 /artifacts/ → /report.md不删,原样 /workspace/train.py
第 2 层 映射bucket + key 前缀 + report.md交给远端容器,落到容器内同名路径
结果oss://my-agent-output/runs/.../report.md容器内 /workspace/train.py
换算次数两次一次

也就是说:沙箱这边没有「虚拟 /workspace → 真实某目录」的换算,虚拟路径和容器内路径是 1:1 同名对应的。容器里本来就允许写 /workspace/,所以在容器内建同名目录,路径就自然对齐了,什么都不用配。

反过来,如果我也给沙箱显式配一条路由:

routes={"/workspace/": E2BSandbox(), ...}     # 不推荐:和 default 指向同一个后端

那么第 1 层同样会删掉 /workspace/,适配器收到的就是 /train.py,文件会落到容器的根目录 /train.py,而不是 /workspace/train.py。要用这种写法就必须在适配器内部补回来(例如给后端加一个 workdir="/workspace" 的参数,在 execute / upload_files 里拼接)。

结论:沙箱这种「整个环境都是我的工作区」的后端,最适合当 default,天然零换算;只有需要从沙箱里划出一块地方、挂到别的介质上时,才用 routes。 这正是第 4 节那样配置的原因。


6. 两个必须自己写的后端

① 沙箱后端:把 E2B 包成 BaseSandbox

BaseSandbox 已经把 ls/read/grep/glob/write/edit/delete 全部用 execute() 实现好了,开发者只需要提供两件事:

  • execute(command, timeout=None) → 调 sandbox.commands.run(...),返回 ExecuteResponse(output=stdout+stderr, exit_code=...)
  • upload_files(files: list[tuple[str, bytes]]) → 用 sandbox.files.write(path, data) 把文件送进去(write 就靠它)

② OSS 后端:实现 BackendProtocol

必须实现的是文件读写那几个方法;建议的对应关系:

方法用 OSS SDK 怎么做
write(path, content)put_object(key=prefix+path, data=content.encode())
read(path)get_object + 按行切片,返回 ReadResult(file_data=...)
ls(path)list_objects(prefix=...),把结果截成「直接子项」
glob / grep先 list_objects 再本地过滤(OSS 没有真正的 grep,注意加数量上限)
delete(path)delete_object(目录 = 按前缀批量删)
upload_files / download_files批量 put / get(这两个 API 不会自动暴露给模型,是给中间件和你自己的工具用的)
edit(path, old, new)读-改-写

现成参考:社区包 deepagents-backends 已经实现了 S3 / Azure Blob / GCS / MongoDB 等远端后端,OSS 可以照它的形状改。


7. 最终结论:哪些文件落在沙箱,哪些落在 OSS

落在沙箱里(= 所有没被路由命中的路径)

  • /workspace/**.py、/workspace/requirements.txt —— Agent 写的代码和依赖声明
  • execute 跑出来的一切中间文件:__pycache__、.pytest_cache、模型 checkpoint、/tmp/*
  • 终端输出本身(ExecuteResponse.output)—— 它只是文本,回给模型看,不会自动变文件

特征:随沙箱销毁而消失,本机磁盘和 git 状态完全不受影响。

落在 OSS 里(= 命中 /artifacts/ 的写入)

  • /artifacts/report.md → oss://my-agent-output/.../report.md
  • /artifacts/chart.png → 图片/二进制同样走 write_file(前端上传时用 upload_files(路径, bytes))
  • /artifacts/result.csv、/artifacts/metrics.json —— 结构化的实验结果

特征:持久、可分享、沙箱销毁也不丢。

既不进沙箱也不进 OSS 的

  • /input/... → 你本机磁盘(只读输入区)
  • /memories/... → LangGraph Store(长期记忆)
  • 你原来的项目目录(比如 D:\work\my-project\src**)→ Agent 完全看不到,因为你没给任何路由,default 又是远端沙箱

小结

路径能对上 routes 的某一行吗?
 ├─ 对不上 → default = 沙箱        (写代码、跑代码、临时产物,用完即弃)
 └─ 对上了 → 那一行指定的后端
      ├─ "/artifacts/" → OSS        (最终产物,永久保存)
      ├─ "/input/"     → 本机磁盘   (只读资料)
      └─ "/memories/"  → Store      (长期记忆)