我最近有一个真实的业务需求:
- 有一份正在开发的项目,不想被 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 |
StateBackend |
LangGraph 执行状态里的一块数据 | ❌ | 只适合放很小的临时文本,重启/换 thread 就没了 |
StoreBackend |
LangGraph 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. 配置:把上面的表格变成代码
py
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. 怎么判断某个路径到底去哪(路由算法)
拿一个路径去比对,规则只有三条:
- 命中哪个前缀 :
/artifacts/chart.png以/artifacts/开头 → 用OSSBackend;对不上任何一行 → 用default(沙箱)。 - 前缀会被裁掉 :交给 OSSBackend 的路径是
/chart.png,它再拼上自己的prefix,所以 OSS 上的 key 是my-agent-output/runs/2026-10-09/chart.png------ 不含/artifacts。 - 多路由时最长前缀优先 :同时有
/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/里?
核心:映射分两层,每层只干一件事
bash
模型给的虚拟路径
│
│ 第 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 层已删掉 |
把这两层写成代码,一眼就能对上:
py
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 层要删前缀
- 同一个后端要能挂多个前缀。 假如我把同一个 OSS 后端同时挂在
"/artifacts/"和"/uploads/"上:不删前缀的话,OSS 侧的 key 会莫名其妙带上/artifacts、/uploads这些虚拟目录名 ,两个入口各成一套目录体系。删掉之后两边都以/为根,OSS 侧的结构由我完全掌控。 - 后端不该知道自己是挂在哪个虚拟前缀下的。
FilesystemBackend(root_dir=...)只管「把/x放进 root_dir」;它被挂在/input/还是/assets/,它不需要关心。正因为这样,同一个后端类才能在多个路由位置上复用。 /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/,所以在容器内建同名目录,路径就自然对齐了,什么都不用配。
反过来,如果我也给沙箱显式配一条路由:
ini
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 又是远端沙箱
小结
arduino
路径能对上 routes 的某一行吗?
├─ 对不上 → default = 沙箱 (写代码、跑代码、临时产物,用完即弃)
└─ 对上了 → 那一行指定的后端
├─ "/artifacts/" → OSS (最终产物,永久保存)
├─ "/input/" → 本机磁盘 (只读资料)
└─ "/memories/" → Store (长期记忆)