《Deep Agents 实战笔记》第 4 篇 | 基于 deepagents (Python) 官方文档 原文:https://docs.langchain.com/oss/python/deepagents/backends
上一篇拆完五大内置能力,文件系统排第一,但我们只看了它对模型暴露的工具面:ls、read_file、write_file……这些工具背后站着谁?文件到底存在哪、活多久、谁能看到?
答案是本文的主角:Backend。这是 Deep Agents 整个设计里我认为最值得抄到自家系统里的一段抽象——同一个 Agent API 不改一行代码,文件可以存在内存里、真实磁盘上、LangGraph store 里,甚至按路径前缀路由到不同存储各管各的。
一、一个接口,七种人格
Agent 眼里只有一套文件工具,工具背后是一个可插拔的 Backend。官方预置了这么几位:
flowchart TB
T["文件系统工具<br/>ls / read_file / write_file / edit_file / glob / grep"] --> B["BackendProtocol"]
B --> SB["StateBackend<br/>默认:存进 LangGraph 代理状态<br/>线程作用域"]
B --> FB["FilesystemBackend<br/>真实磁盘<br/>root_dir 可配置"]
B --> STB["StoreBackend<br/>LangGraph store<br/>跨线程持久化"]
B --> CHB["ContextHubBackend<br/>LangSmith Hub 仓库"]
B --> LSB["LocalShellBackend<br/>文件系统 + execute 工具"]
B --> CB["CompositeBackend<br/>按路径前缀路由"]
B --> CUS["你的自定义 Backend<br/>数据库 / 对象存储 / 远程文件系统"]
CB --> R{按前缀路由}
R --> SB
R --> FB
R --> STB
style T fill:#f6ffdb,color:#2e3900,stroke:#6e8900
style B fill:#e5f4ff,color:#030710,stroke:#006ddd
style CB fill:#fdf3ff,color:#504b5f,stroke:#7e65ae
style CUS fill:#ebd0f0,color:#441e33,stroke:#885270
正在渲染图表…
指定方式就是把实例传给 backend= 参数(默认为 StateBackend()):
from deepagents import create_deep_agent
from deepagents.backends import StateBackend
# 默认情况下框架就提供了 StateBackend
agent = create_deep_agent(model="google_genai:gemini-3.6-flash")
# 其底层实现等价于
agent2 = create_deep_agent(
model="google_genai:gemini-3.6-flash",
backend=StateBackend(),
)
四个内置 Backend 各有明确分工,选型先想清楚一个问题:文件需要活多久?
| Backend | 存储位置 | 生命周期 | 最适合 |
|---|---|---|---|
StateBackend | LangGraph 代理状态 | 同一线程内跨轮次持久,不跨线程 | 草稿本、卸载的大工具结果 |
FilesystemBackend | 本地磁盘 root_dir | 永久 | 本地 CLI、CI、挂载卷 |
StoreBackend | LangGraph BaseStore | 跨线程、跨会话 | 长期记忆、多用户隔离 |
CompositeBackend | 按路由分发 | 取决于各子 Backend | 混合场景,灵活性最高 |
二、StateBackend 与 checkpointer:一对必须分清的概念
很多人第一次读文档会把 Backend 和 checkpointer 搞混,这里值得单独说清楚。
StateBackend 把文件存在当前线程的 LangGraph 代理状态里——文件和消息历史一样,就是图状态的一个字段。它"在同一线程的多个代理轮次之间持久化",靠的正是你的 checkpointer:每轮结束,checkpointer 把整个状态(含文件)快照下来;下次带着同一个 thread_id 进来,状态恢复,文件还在。但换个线程?文件就没了,线程之间不共享。
flowchart LR
subgraph T1["thread_id = A"]
R1["第 1 轮<br/>Agent 写入 /workspace/plan.md"] --> CP1["checkpointer 快照<br/>状态含文件一起落盘"]
CP1 --> R2["第 2 轮<br/>状态恢复,文件还在"]
end
subgraph T2["thread_id = B<br/>全新线程"]
R3["第 1 轮<br/>看不到线程 A 的文件"]
end
T1 -.->|"跨线程不可见"| T2
ST["StoreBackend<br/>走 LangGraph store"] -.->|"跨线程持久<br/>换个会话仍可读"| R3
style T1 fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a
style T2 fill:#fde8e8,color:#8b1a1a,stroke:#e05252
style ST fill:#f4effb,color:#4a2170,stroke:#8b52c9
正在渲染图表…
所以"会话内的草稿本"用 StateBackend 正好;而"用户偏好、项目规范"这类要跨会话活下来的东西,要么换 StoreBackend,要么用 CompositeBackend 把 /memories/ 路径单独路由出去——下一节就是干这个的。
还有两个使用注意:StateBackend 设计为在 graph 内部使用,在 graph 执行之外直接调它的方法不会生效;它默认在主代理与子代理之间共享,子代理写入的文件在子代理执行结束后仍留在代理状态里,主代理和其他子代理可以继续读。
三、按路径路由:CompositeBackend 的杀手锏
CompositeBackend 接受一个 default Backend 和一组"路径前缀 → Backend"的路由表,文件操作按前缀分发,列表和搜索结果会汇总并保留原始路径前缀:
from deepagents import create_deep_agent
from deepagents.backends import CompositeBackend, StateBackend, StoreBackend
from langgraph.store.memory import InMemoryStore
agent = create_deep_agent(
model="google_genai:gemini-3.6-flash",
backend=CompositeBackend(
default=StateBackend(),
routes={
"/memories/": StoreBackend(namespace=lambda _rt: ("memories",)),
},
),
store=InMemoryStore(), # 注意:store 传给 create_deep_agent,不是 backend
)
执行时的分发逻辑:
flowchart TB
OP["文件操作<br/>write_file /memories/agent.md"] --> M{"匹配哪个前缀?"}
M -->|"/memories/ 开头"| S["StoreBackend<br/>跨线程持久化"]
M -->|"其他路径"| D["default:StateBackend<br/>线程作用域"]
M -->|"更长前缀优先<br/>如 /memories/projects/ 可覆盖 /memories/"| L["更具体的路由规则胜出"]
style S fill:#f4effb,color:#4a2170,stroke:#8b52c9
style D fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a
正在渲染图表…
对模型来说,这一切完全透明——它始终面对"一个文件系统",根本不知道 /memories/agent.md 和 /workspace/plan.md 落在两个不同的存储里。ls、glob、grep 会跨 Backend 汇总结果,体验上是无缝的。
一条来自官方的重要建议:大多数用例下,请把 FilesystemBackend 包裹在 CompositeBackend 里。因为 Deep Agents 会自动把内部数据写入默认 Backend——卸载的大型工具结果放在 /large_tool_results/ 之下,对话历史放在 /conversation_history/ 之下。单独使用 FilesystemBackend 时,这些内部文件会被写到 root_dir 下的真实磁盘,和你的项目文件混成一锅粥。官方推荐的姿势是:
from deepagents import create_deep_agent
from deepagents.backends import CompositeBackend, StateBackend, FilesystemBackend
agent = create_deep_agent(
backend=CompositeBackend(
default=StateBackend(),
routes={
"/workspace/": FilesystemBackend(root_dir="/path/to/project", virtual_mode=True),
},
)
)
项目目录路由到真实磁盘,卸载结果和对话历史留在临时状态里,各回各家。virtual_mode=True 也是必须养成的习惯——它启用基于路径的访问限制,阻止 ..、~ 和根目录之外的绝对路径;默认值 virtual_mode=False 即使设了 root_dir 也不提供任何安全保障,这句话官方用加粗强调了,请背下来。
另外两个内置 Backend 简单带过:ContextHubBackend 把文件存进 LangSmith Context Hub 仓库,写入以 Hub commit 形式持久化、自带版本历史;LocalShellBackend 在 FilesystemBackend 之上扩展 execute 工具,通过 subprocess.run(shell=True) 直接在宿主机跑命令——没有任何隔离,只可在受控的开发环境使用。
四、多用户场景:命名空间工厂
StoreBackend 还有一个生产环境绕不开的参数:namespace。它接收一个工厂函数,接收 LangGraph Runtime,返回用作 store 命名空间的字符串元组,用来做数据隔离:
from deepagents.backends import StoreBackend
# 按用户隔离:每个用户有独立的存储空间
backend = StoreBackend(
namespace=lambda rt: (rt.server_info.user.identity,),
)
# 按助手隔离:同一助手的所有用户共享存储
backend = StoreBackend(
namespace=lambda rt: (rt.server_info.assistant_id,),
)
# 按线程隔离:存储限定在单个对话内
backend = StoreBackend(
namespace=lambda rt: (rt.execution_info.thread_id,),
)
命名空间组件可以组合出更细的粒度,比如 (user_id, thread_id) 实现按用户按对话的双重隔离。注意 Runtime 参数要求 deepagents>=0.5.2,且通配符(*、?)会被拒绝以防 glob 注入。多用户部署千万别省这个参数——不提供时旧版默认按 assistant_id 隔离,意味着同一助手的全部用户共享同一份存储,这大概是生产事故预备役第一名。
五、自定义 Backend:一套协议走天下
内置的不够用怎么办?把 Deep Agents 接到公司知识库、对象存储、Redis 上?答案是子类化 BackendProtocol,实现七个方法:
| 方法 | 签名 | 作用 |
|---|---|---|
ls | (path: str) -> LsResult | 列出路径下的文件和目录 |
read | (file_path: str, offset: int, limit: int) -> ReadResult | 返回文件内容,可选分页 |
write | (file_path: str, content: str) -> WriteResult | 创建或覆盖文件 |
edit | (file_path, old_string, new_string, replace_all) -> EditResult | 在既有文件中查找替换 |
glob | (pattern: str, path: str | None) -> GlobResult | 返回匹配 glob 模式的路径 |
grep | (pattern: str, path: str | None, glob: str | None) -> GrepResult | 在文件内容中搜索 |
delete | (file_path: str) -> DeleteResult | 可选;不实现则该工具自动对模型隐藏 |
官方给了一个 S3 风格的骨架,把文件路径映射到对象键:
from deepagents.backends.protocol import (
BackendProtocol,
EditResult,
GlobResult,
GrepResult,
LsResult,
ReadResult,
WriteResult,
)
class S3Backend(BackendProtocol):
def __init__(self, bucket: str, prefix: str = ""):
self.bucket = bucket
self.prefix = prefix.rstrip("/")
def _key(self, path: str) -> str:
return f"{self.prefix}{path}"
def ls(self, path: str) -> LsResult:
...
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
...
def grep(self, pattern: str, path: str | None = None, glob: str | None = None) -> GrepResult:
...
def glob(self, pattern: str, path: str | = ) -> GlobResult:
...
() -> WriteResult:
...
() -> EditResult:
...
协议只有一条铁律:失败用结构化结果里的 error 字段表达,永远不要抛异常。文件缺失就返回 ReadResult(error="Error: File '/x' not found"),而不是 raise FileNotFoundError——这是给 LLM 看的接口,错误信息会直接进上下文引导模型下一步动作,一个抛出的异常只会让整轮调用崩掉。
实战作业:写一个最小自定义 Backend
光看骨架不过瘾,来动手。目标:一个 100 行以内的 DictBackend,用普通字典当存储,能跑通"写入 → 列出 → 读取 → 编辑"的完整闭环——它也是你接 Redis / OSS 的脚手架,把字典操作换成对应客户端调用即可:
import time
from deepagents.backends.protocol import (
BackendProtocol, EditResult, GlobResult, GrepResult,
LsResult, ReadResult, WriteResult,
)
from deepagents.backends.protocol import FileInfo
class DictBackend(BackendProtocol):
"""最小自定义 Backend:字典即文件系统。"""
def __init__(self):
self.files: dict[str, str] = {}
def ls(self, path: str) -> LsResult:
prefix = path.rstrip("/") + "/"
entries = [
FileInfo(path=p)
for p in sorted(self.files)
if p.startswith(prefix)
]
return LsResult(entries=entries)
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
if file_path not in self.files:
return ReadResult(error=f"Error: File '{file_path}' not found")
lines = self.files[file_path].splitlines(keepends=True)
chunk = "".join(lines[offset:offset + limit])
ReadResult(file_data={
: chunk,
: time.strftime(),
: time.strftime(),
})
() -> WriteResult:
.files[file_path] = content
WriteResult(path=file_path)
() -> EditResult:
file_path .files:
EditResult(error=)
content = .files[file_path]
count = content.count(old_string)
count == :
EditResult(error=)
count > replace_all:
EditResult(error=)
.files[file_path] = content.replace(old_string, new_string)
EditResult(path=file_path, occurrences=count)
() -> GlobResult:
fnmatch
matches = [FileInfo(path=p) p (.files)
fnmatch.fnmatch(p, pattern)]
GlobResult(matches=matches)
() -> GrepResult:
fnmatch
matches = []
p, content (.files.items()):
glob fnmatch.fnmatch(p, glob):
i, line (content.splitlines(), start=):
pattern line:
matches.append({: p, : i, : line})
GrepResult(matches=matches)
deepagents create_deep_agent
agent = create_deep_agent(
model=,
backend=DictBackend(),
)
验收标准:让 Agent "写一个 plan.md,然后把它里面的第一行标题改掉,再列出目录确认"。全流程走通,说明你的 Backend 与框架的协议兼容。接 Redis 时把 self.files 换成 Redis 读写、接 OSS 时映射到对象键(参考上面的 S3 骨架),其余方法逻辑不变。
想更进一步,还可以做策略钩子——通过包裹 Backend 在写入前实施企业规则(限流、审计、敏感路径保护)。官方示例的 GuardedBackend 就是子类化 FilesystemBackend 后覆盖 write / edit,命中拒绝前缀时返回 WriteResult(error=...),其余请求透传给父类,十几行代码给文件系统加上一道闸。
六、本篇踩坑点
virtual_mode=False没有任何保护:只设root_dir不等于沙箱化,代理可以摸到根目录之外的路径——凡是接磁盘,一律virtual_mode=True;- 单独使用
FilesystemBackend会污染项目目录:卸载的工具结果和对话历史会被写到root_dir下,务必用CompositeBackend把内部数据留在StateBackend; store参数传给create_deep_agent,不是传给 Backend:路由到StoreBackend时忘了给 store,运行时才会爆,而且报错位置离配置点很远;- 二进制内容卸载的坑:该功能(
deepagents>=0.7.21)要求 blob 存在 graph 状态之外的 Backend 里——blob 路径路由到StateBackend时卸载不生效,如果默认 Backend 是StateBackend,记得把 blob 目录路由到外部存储; - 别用工厂函数写法:
backend=lambda rt: StateBackend(rt)自 0.5.0 起已弃用,现在直接传实例,Backend 会在内部自己解析运行时上下文; - 错误要进
error字段,不要抛异常:自定义 Backend 里一个未捕获的异常会打断整轮执行,而error字符串会被模型读到并自行调整策略。
下一篇预告
文件系统解决了"装得下"——再大的结果可以落盘,再多的经验可以存档。但还有半壁江山没攻下来:主代理看到子代理的 task 工具时到底发生了什么?为什么 description 写得好不好,委派质量天差地别?官方文档里那个"坏描述导致子代理永远不被调用"的排错案例有多戏剧?下一篇《子代理(上):上下文隔离与任务委派》,我们进入 Deep Agents 最核心的协作机制。
参考资料:
- Deep Agents Backends(官方文档,本文主要素材):https://docs.langchain.com/oss/python/deepagents/backends
- 社区构建的 Backend 集成:https://docs.langchain.com/oss/python/integrations/backends