DeepAgents(五):DeepAgents 的 backend 怎么选
DeepAgents 的 backend 怎么选:7 个类的实测差异
DeepAgents 把文件操作抽成了一层 backend,建 Agent 的时候传一个参数就能换掉。包里一共导出 7 个实现类,文档开头都在说「文件存在哪儿」。
我把这 7 个类逐个装上跑了一遍,发现差异不在那儿。
backend 决定了 3 件事:模型这一轮拿到哪些工具、同名工具背后是谁在干活、文件能活多久。
跑这些对照不需要 API key,也不用联网。我用一个假模型顶替真模型,它只在工具面上留个钩子,把送进来的工具原样记下来。下面每个数字都能在本机复现,口径都写在对照旁边。
一、先把 3 个结论摆出来
| 结论 | 硬证据 |
|---|---|
| 换 backend 就换工具面 | 默认装配 8 个工具,带 shell 的 9 个 |
| 同名工具的实现可以完全不同 | 同一个 grep,一个扫内存字典,一个起子进程 |
| 文件活多久由装配决定 | 加不加 checkpointer,同样的一次读取结果相反 |
二、7 个实现类,和一个没有抽象方法的协议
backends.__all__ 导出 10 项,拆开是这样:
| 项目 | 数量 | 名字 |
|---|---|---|
| 实现类 | 7 | StateBackend / FilesystemBackend / StoreBackend / CompositeBackend / LocalShellBackend / ContextHubBackend / LangSmithSandbox |
| 基类 | 1 | BackendProtocol |
| 常量 | 2 | DEFAULT_EXECUTE_TIMEOUT / NamespaceFactory |
BackendProtocol 是个 abc.ABC,但源码里一个 @abstractmethod 都没用上。类注释写得很直白:
@abstractmethod to avoid breaking subclasses that only implement a subset
所以这个协议不强制实现任何东西。9 个同步文件方法在基类里全是 raise NotImplementedError,另配 9 个异步版本:
ls / read / write / edit / delete
grep / glob
upload_files / download_files
7 个实现类把这 9 个都写全了,但那是自觉,不是协议逼的。漏实现的后果也不是实例化就报错,得真调用那一下才炸。
三、换 backend,工具面就变了
这是最实用的一条。我装上 4 种 backend,抓模型侧真正绑定到的工具:
| backend | 工具数 | 有 execute 吗 |
|---|---|---|
| StateBackend(默认) | 8 | 没有 |
| FilesystemBackend | 8 | 没有 |
| CompositeBackend(默认 State) | 8 | 没有 |
| LocalShellBackend | 9 | 有 |
8 个是 7 个文件工具加 task。
最值得留意的是 FilesystemBackend。它能直接读写你硬盘上的文件,工具面却和内存态一模一样,也没有 execute。能碰真实文件,不等于能跑命令。
判定逻辑在 supports_execution 里,就一句话:这个 backend 是不是 SandboxBackendProtocol 的实例。是 CompositeBackend 就看它的 default。
工具不只多一个,grep 的描述也换了版本:
| backend | grep 描述长度 |
带 execute 退路吗 |
|---|---|---|
| 默认 / Filesystem / Composite | 557 字符 | 没有 |
| LocalShellBackend | 638 字符 | 有 |
多出那 81 个字符是一句话:「如果你确实需要正则,改用 execute 工具跑 rg」。只有装了 shell 的 backend 才配这句。
工具描述的总成本也跟着涨。7 个文件工具 2230 token,加 task 499 token,一共 2729。换成 LocalShellBackend 是 3077,多 348。
prompt 也会变。默认几种 backend 注入 0 字符,但把 CompositeBackend 的 default 换成 LocalShellBackend,会多出 667 字符一段:
## Shell paths vs. virtual paths
标题下面讲的是虚拟路径和宿主路径怎么对应。没有 shell 的 backend 用不上这段,就不给。
命令通道是通的,我跑了一条:
backend.execute("echo hello")
# exit_code=0 output='hello'
把参数换成 exit 3,拿到的 exit_code 就是 3。
四、同一个 grep,两种引擎
两边都是字面匹配。我拿同一批语料、同一批问法分别喂进去:
| 问法 | StateBackend | FilesystemBackend |
|---|---|---|
卸载阈值(原文里的字) |
1 行 | 1 行 |
call_abc123(原文里的字) |
2 行 | 2 行 |
压缩|卸载(正则写法) |
0 行 | 0 行 |
20\d+ token(正则写法) |
0 行 | 0 行 |
上下文快满了怎么办(换了说法) |
0 行 | 0 行 |
TOKEN 预算(换了说法) |
0 行 | 0 行 |
行为完全一致。区别在引擎:StateBackend 的底层是一行子串判断,扫的是内存里的字典。FilesystemBackend 先起 ripgrep 子进程,取不到结果再退到 Python 搜索。
同一批语料扫 1,000 次(5 轮、每轮 200 次,取中位数),StateBackend 在 0.08 毫秒上下,FilesystemBackend 在 0.8 毫秒上下。内存里扫字典快 9 倍左右。
回退路径还有个不声不响的边界。FilesystemBackend 有个 max_file_size_mb,默认 10,只在 Python 回退里生效。我造了一个 11.8 MB 的文件,里面确实写着要找的那串字:
| 谁去搜 | 命中 |
|---|---|
| StateBackend | 1 条 |
| FilesystemBackend(默认 10 MB) | 0 条 |
| FilesystemBackend(调到 50 MB) | 1 条 |
0 条的意思是它压根没去搜这份文件。这个过程不报错,也不给警告。
还有一条:FilesystemBackend 的 grep 带一个 context_lines 参数,能多带回上下文行。协议注释写明它故意不暴露给 agent 的 grep 工具。backend 的 API 面比工具面宽。
五、文件能活多久,由装配决定
StateBackend 的文档第一句就是 "ephemeral"。我实测了两种装配:
| 装配 | 同一线程下一轮读 | 换一个线程读 |
|---|---|---|
| StateBackend | 失败 | 失败 |
| StateBackend + InMemorySaver | 成功 | 失败 |
同一段代码,加不加 checkpointer,同样的一次读取结果相反。加上之后文件在会话内留得住,跨线程还是各管各的。
要做到跨会话,得换 StoreBackend,它的定位写在文档里:"persistent, cross-conversation"。代价是构造时必须给一个 namespace 工厂。
StoreBackend(
namespace=ns_factory,
)
FilesystemBackend 走的是另一条路:写下去就是真文件。我写了一个 /probe.txt,磁盘上真的出现了。
六、前缀是路由,不是隔离
CompositeBackend 按路径前缀分发。我配了 2 条路由:
routes = {
"/memories/": mem,
"/notes/": mem,
}
comp = CompositeBackend(
default=fs,
routes=routes,
)
写 3 个路径进去:
| 写进去的 | 落到哪个 backend | 落到哪个 key |
|---|---|---|
| /top.txt | FilesystemBackend | /top.txt |
| /memories/a.txt | StoreBackend | /a.txt |
| /notes/deep/b.txt | StoreBackend | /deep/b.txt |
留意后 2 行:前缀被剥掉了再交给对应的 backend。
这里有个坑。上面 2 条路由用的是同一个 StoreBackend 实例、同一个 namespace。结果是 /memories/a.txt 和 /notes/a.txt 剥掉前缀后都是 /a.txt,在 store 里成了同一个 key。
我读 /notes/a.txt,拿到的是写进 /memories/a.txt 的那份内容。
前缀是路由规则,不是隔离边界。要真隔离,得给每个前缀配各自的 namespace。
七、一张选型表
| 你的需求 | 选哪个 | 要付什么 |
|---|---|---|
| 会话内临时记点东西 | StateBackend | 想跨轮留住,必须配 checkpointer |
| 跨会话、多用户隔离 | StoreBackend | 必填 namespace 工厂 |
| 读写真实项目文件 | FilesystemBackend | 要指定 root_dir;默认 10 MB 以上的文件搜不到 |
| 上面几种按路径混用 | CompositeBackend | 必填 default 和 routes;前缀不隔离 |
| 还要跑命令 | LocalShellBackend | 工具描述多 348 token,且有本机权限 |
| 远端沙箱 | LangSmithSandbox | 构造要传一个现成的 sandbox |
| 托管版本化存储 | ContextHubBackend | 构造要一个 identifier |
最后 2 个都得先有对应的外部环境,本机跑不起来,我没测它们的运行时行为。
还有个开关值得提一句。FilesystemBackend 的 virtual_mode 默认开着,路径穿越会被拦住:
| virtual_mode | 读 /../outside.txt |
读一个绝对路径 |
|---|---|---|
| True | 拦下,报 Path traversal not allowed | 拦下 |
| False | 找不到 | 读到了根目录外的文件 |
它是路径语义开关,不是安全边界。
LocalShellBackend 的源码里还有一段安全警告,措辞比一般文档重。它给 Agent 的是「直接文件访问和不受限的 shell 执行」,建议只在本地开发 CLI 这类场景用。生产环境应该换成 StateBackend 或 StoreBackend,或者自己扩展 BaseSandbox 做隔离。
警告里专门提了一句:有 shell 的时候,virtual_mode 和基于路径的限制不提供任何安全性,因为命令本来就能访问系统上的任意路径。上面那张对照表正好印证了这一点。
八、所以怎么选
先问自己一句话:这些文件是谁写下去的、要活多久。再看一句:要不要跑命令。
自己写、当轮用完就扔,StateBackend 最省。要跨会话,StoreBackend。要动真实文件,FilesystemBackend。
要跑命令,LocalShellBackend。几种混着用,用 CompositeBackend 按前缀接起来。
选完记得回头看一眼工具面。多一个 execute,成本和风险都跟着涨。