数据截至 (上游 commit 25aa2735dabb)
可插拔后端:文件与执行到底落在哪
30 秒导读: Deep Agents 让 agent 用
read_file/write_file这样的工具操作"文件"。 但这些文件可以根本不在磁盘上——可能在 LangGraph 的 state 里、在跨线程的 store 里、 在一台远程沙箱机器上。这一层抽象就叫 backend。本章只讲存储与执行这条轴: 契约长什么样、六种内置实现各自的取舍、路由怎么拼、沙箱怎么把文件操作翻译成 shell。 调用它的工具层(ls工具、权限闸门、可见性)是 第 03 章 的事。
引用约定: 本章正文里的
path:line一律相对包目录libs/deepagents/deepagents/—— 例如backends/protocol.py:378的完整路径是libs/deepagents/deepagents/backends/protocol.py:378。 以libs/开头的(§8 的 partners 包、§9 的代码地图)是相对克隆根的完整路径。 同一张表或同一段里重复引用同一个文件时,省写成:行号。
术语约定: 上游对"把大内容搬出上下文"这件事有 offload 和 eviction 两个叫法 (
execute_with_offload与_message_eviction.py说的是同一件事)。 本书统一:offload 译「卸载」、eviction 译「驱逐」,指的都是"内容写进文件系统, 上下文里只留预览 + 路径"。本章讲沙箱侧的卸载,中间件侧的驱逐见 第 05 章。
1. 为什么需要这层抽象
1.1 一个具体的困境
假设你写了一个 agent,它会 write_file("/notes/plan.md", ...)。这句话该落到哪?
答案取决于你在做什么产品:
| 你在做什么 | 文件应该落到哪 | 为什么 |
|---|---|---|
| Demo / 多租户 Web 服务 | 对话的 state 里 | 不碰宿主机,随 checkpoint 走,会话结束即散 |
| 本地编码 CLI | 真实磁盘 | 用户就是要你改他仓库里的代码 |
| 跨会话的长期记忆 | 数据库(store) | 下一个 thread 也要读得到 |
| 跑不可信代码 | 远程沙箱 | 崩了、被投毒了都不影响宿主机 |
四类落点的物理性质完全不同:state 是内存字典 + reducer, 磁盘是 POSIX,store 是键值表,
沙箱只给你一根 execute(command) 管子。
1.2 收敛点:七个动词
Deep Agents 的做法是:承认它们不同,但强制它们对外暴露同一套七个动词——
ls / read / write / edit / glob / grep / delete(外加批量的
upload_files / download_files)。
工具层只认这七个动词,一行都不用知道文件真实躺在哪。
工具层(第 03 章:read_file / write_file / ls / grep ...)
│
│ 只调这七个动词
▼
┌──────────────────────────┐
│ BackendProtocol │ ← 本章主角
└────────────┬─────────────┘
│
┌──────────┬─────────────┼─────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
StateBackend Filesystem StoreBackend ContextHub BaseSandbox
(graph state) (真实磁盘) (跨线程持久) (Hub 仓库) (远程,靠 shell)
│
┌─────────────┴──────────┐
▼ ▼
LangSmithSandbox partners 三方包
(Daytona/Modal/…)
上面这些实现之上还有一层 CompositeBackend,它本身也是一个 backend,
按路径前缀把请求分派给上面任意几个(见 §4)。
别把「动词」和「工具」的数字混起来。 全书出现过 6 / 7 / 8 三个数字,数的不是同一样东西:
数字 数的是什么 依据 7 后端动词,也是 _FS_TOOL_ORDER里的内置文件工具名middleware/filesystem.py:13398 模型可能看到的文件工具全集 = 七件套 + execute_ALL_FS_TOOL_NAMES,middleware/filesystem.py:13406 create_deep_agentdocstring 里列的文件操作(漏了delete)graph.py:291-299
execute不是后端动词而是另一条协议上的方法(§2.6);delete与execute都要后端支持才会 出现在模型面前(§2.4),所以那份 docstring 只列了"必然存在"的六个。
2. BackendProtocol:契约长什么样
这节讲契约本身:它有哪些方法、返回什么、为什么不抛异常。
2.1 一个"故意不严格"的抽象基类
BackendProtocol 是 abc.ABC,但一个 @abstractmethod 都没有
(backends/protocol.py:378,类定义那行还挂了 # noqa: B024 承认这点)。
所有方法的默认实现都是 raise NotImplementedError。
这是刻意的:子类只实现自己支持的子集就行,不会因为漏了 delete 就实例化失败。
代价是"能力"变成了运行时才知道的东西——所以框架另配了探测函数,见 §2.4。
2.2 结构化返回类型族
每个动词都有一个专属的结果 dataclass,共同点是都带一个 error: str | None 字段
(下面两张表的"位置"列均相对 backends/protocol.py):
| 动词 | 返回类型 | 成功时带什么 | 位置 |
|---|---|---|---|
ls | LsResult | entries: list[FileInfo] | :322 |
read | ReadResult | file_data: FileData | :195 |
write | WriteResult | path | :269 |
edit | EditResult | path、occurrences | :286 |
delete | DeleteResult | path | :305 |
grep | GrepResult | matches: list[GrepMatch]、truncated | :335 |
glob | GlobResult | matches: list[FileInfo]、truncated | :360 |
被这些结果包着的三个数据结构都是 TypedDict:
| 类型 | 字段 | 说明 | 位置 |
|---|---|---|---|
FileInfo | path 必填;is_dir / size / modified_at 可选 | 目录项。只有 path 是硬要求,其余"尽力而为" | :120 |
GrepMatch | path、line(1-indexed)、text | 一条命中行 | :150 |
FileData | content、encoding;created_at / modified_at 可选 | 文件本体。encoding 为 "utf-8" 或 "base64" | :178 |
ReadResult 的分页元数据值得注意:total_lines / start_line / end_line / next_offset
四个字段把"读的窗口在哪、还有多少"一并带回(backends/protocol.py:204-214),构造时还有
一道 __post_init__ 校验窗口字段成对出现(backends/protocol.py:225-230)。这是 0.7.x 给
分页读加的契约,各后端都要遵守。
2.3 关键设计:返回结构体,而不是抛异常
这是整层抽象最值得抄的一条。
问题: 后端的失败是给谁看的?——是给模型看的。read 一个不存在的文件,不是程序 bug,
是模型下一步该纠正的动作。异常穿透到工具层,只会变成一坨 traceback 或者被 except 吞掉。
做法: 失败塞进 result.error,让工具层原样转成 ToolMessage。
# 示意,非源码 —— 工具层拿到结果后怎么用
result = backend.read(file_path, offset, limit)
if result.error: # 失败:直接把人话错误还给模型
return ToolMessage(content=f"Error: {result.error}")
text = format_content_with_line_numbers(result.file_data["content"]) # 成功:格式化
return ToolMessage(content=text)
真实的消费点就长这样:_handle_read_result 用
if read_result.error: content=f"Error: {read_result.error}"(middleware/filesystem.py:1845-1993,嵌在 _create_read_file_tool 里),
grep 那边还先过一道 truncate_if_too_long(result.error)(在 _create_grep_tool 内部,middleware/filesystem.py:2460-2570)。
重点看:错误文本是后端写的,工具层一个字都不改 ——这样"文件不存在"在各个后端下措辞一致。
于是各后端的错误串都刻意写成给模型看的句子。最典型的是编辑冲突:
perform_string_replacement 在 old_string 出现多次时,返回的不是
ValueError,而是一句"appears N times in file. Use replace_all=True..."
(backends/utils.py:502-558)。
2.4 可选能力怎么表达
不抛异常的原则有两个例外——能力缺失仍然走 NotImplementedError,因为那不是模型该修的错。
框架用两个"看类不看实例"的探测函数把它挡在调用之前:
| 探测什么 | 函数 | 怎么判断 | 位置 |
|---|---|---|---|
后端支不支持 delete | _supports_delete | type(backend).delete is not BackendProtocol.delete,即有没有覆写 | backends/protocol.py:939-954 |
沙箱的 execute 收不收 timeout | execute_accepts_timeout | inspect.signature 里有没有 timeout 参数,@lru_cache 缓存 | backends/protocol.py:917 |
_supports_delete 的用法是动态摘掉工具:中间件在处理请求时发现后端不支持删除,就不把 delete
工具暴露给模型(_create_delete_tool 内的能力探测,middleware/filesystem.py:2192-2286)——比让模型调一个必然失败的工具干净得多。
execute_accepts_timeout 解决的是版本错配:早期的 partner 包没给 SDK 打下界,可能压根不认
timeout 这个 kwarg,所以调用前先探测,不支持就退化成不带 timeout 的调用
(backends/protocol.py:893-895)。
2.5 同步 / 异步成对,默认用线程兜底
每个动词都有 a 前缀的异步版(als / aread / agrep …),基类的默认实现统统是
asyncio.to_thread(同步版)(如 backends/protocol.py:424-426 的 als)。子类想要真异步就自己覆写。
agrep 是唯一多加了一层保护的:它用 asyncio.wait_for 包了个
ASYNC_GREP_TIMEOUT = 2 * 15 + 5 = 35 秒的上限(backends/protocol.py:23、:532-570)。
文档注释诚实地说了这层的局限——超时只约束调用方等多久,并不会真的停掉那个工作线程。
2.6 还有一条平行的协议:执行
SandboxBackendProtocol 继承 BackendProtocol,加了两样东西
(backends/protocol.py:840-898):
id属性——沙箱实例的唯一标识。execute(command, *, timeout) -> ExecuteResponse——跑一条 shell 命令。
ExecuteResponse 只有三个字段:output(stdout + stderr 合并)、exit_code、truncated
(backends/protocol.py:780-799)。文档里写明这是 "optimized for LLM consumption" ——
不给模型分 stdout/stderr,就一坨文本加一个退出码。
3. 六种内置实现:落点各自的取舍
这节讲具体实现。六个内置后端归成 §1.1 那四类落点(state / 磁盘 / store / 远程),
外加一个只做路由、不存东西的 CompositeBackend(§4)。先看全表,再逐个说各自最有意 思的那一点。
| 后端 | 数据落在哪 | 生命周期 | 支持 execute | 主要取舍 | 文件 |
|---|---|---|---|---|---|
StateBackend | LangGraph files channel | 单个 thread 内,随 checkpoint | 否 | 零外部依赖,但会把文件塞进 checkpoint | backends/state.py:37 |
FilesystemBackend | 真实磁盘 | 永久 | 否 | 最直接;virtual_mode(默认开)给路径护栏 | backends/filesystem.py:91 |
StoreBackend | LangGraph BaseStore | 跨 thread 持久 | 否 | 真正的长期记忆;要管 namespace 隔离 | backends/store.py:89 |
LocalShellBackend | 真实磁盘 + 本机 shell | 永久 | 是 | 本地 CLI 的全能形态;零隔离 | backends/local_shell.py:26 |
ContextHubBackend | LangSmith Hub agent repo | 永久,带 commit | 否 | 文件即版本化的 prompt 资产 | backends/context_hub.py:97 |
LangSmithSandbox | 远程沙箱容器 | 沙箱生命周期 | 是 | 真隔离;每次操作一次网络往返 | backends/langsmith.py:56 |
3.1 StateBackend:文件就是一个 channel
它要解决的小问题: 让"文件"随对话 checkpoint 一起存,不落任何外部介质。
早期版本让 write 返回一个 files_update 字典,由调用方负责合并进 state——耦合难受。
现在的做法是后端自己去写 channel,调用方完全不用管
(旧版的 WriteResult.files_update 已在 0.7.0 如期移除,backends/protocol.py 里已无此字段)。
关键在两个 LangGraph 内部 config key(backends/state.py:7):
┌─────────────────────────────────────┐
backend.read ───►│ CONFIG_KEY_READ("files", fresh=True)│──► 当前 files 字典
└─────────────────────────────────────┘
▲
│ fresh=True 会先把本超步内
│ 挂起的写经 reducer 应用一遍
│
┌───────────────┴─────────────────────┐
backend.write ──►│ CONFIG_KEY_SEND([("files", update)])│──► 排队一次 channel write
└─────────────────────────────────────┘
在节点边界提交进 state
两个细节值得记:
- 读己所写(read-your-writes)。
_read_files传fresh=True(backends/state.py:80-97),所以同一个超步里"先写再读"能看到自己刚写的东西—— 比如一个 code interpreter 在一次 eval 里连发好几个子工具调用。 - 删除靠
None标记。delete不是"从字典里 pop",而是把每个要删的 key 都send一个None值,由fileschannel 的 reducer 解释成删除 (backends/state.py:249-274)。删目录 = 删base这个 key 本身 + 所有base + "/"前缀的 key。
_send_files_update 只发变化的那几个文件,不用发全量——因为 files channel 用的是
dict-merge reducer(backends/state.py:98-118)。
3.2 FilesystemBackend:root_dir 到底约 不约束
这里有个非常容易误解的点,源码自己也反复强调:root_dir 本身不是安全边界。
行为由 virtual_mode 决定(backends/filesystem.py:139-144 的构造参数,语义说明在类 docstring :130-137 与 _resolve_path :182-242)。0.7.x 起默认翻转为 True——虚拟路径成了默认语义,要宿主机裸路径得显式 opt-out:
virtual_mode | 绝对路径怎么处理 | .. / ~ | 结论 |
|---|---|---|---|
True(当前默认) | 视作以 root_dir 为根的虚拟路径 | 直接 ValueError: Path traversal not allowed | 有路径护栏,但仍非沙箱 |
False(仅限可信本地开发) | 原样使用,/etc/passwd 直接穿透 | 允许,可以逃出 root_dir | root_dir 只影响相对路径解析,无任何防护 |
virtual_mode=True 的检查是三步:先拒绝含 .. 或以 ~ 开头的路径,再
(self.cwd / vpath).resolve(),最后用 full.relative_to(self.cwd) 确认没跑出根
(_resolve_path 内部,backends/filesystem.py:203-242)。
配套的还有 _display_path:虚拟模式下,报错信息里也不能泄漏真实的 root_dir,
所以先把路径转回虚拟形式再显示(backends/filesystem.py:243-264)。
源码里 virtual_mode 的