《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存储位置生命周期最适合
StateBackendLangGraph 代理状态同一线程内跨轮次持久,不跨线程草稿本、卸载的大工具结果
FilesystemBackend本地磁盘 root_dir永久本地 CLI、CI、挂载卷
StoreBackendLangGraph 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 最核心的协作机制。


参考资料: