1. 本章目标 #

前三章一直在说「文件系统是 harness 的核心能力」,但一直没说清三件事:

这一章把这三个问题一起解决。

学完你应能:

前置依赖: 第 40 章(跑得起来)、第 41 章(工具面这个概念)、第 11 章(checkpointer 与 thread_id)、第 16 章(store 与长期记忆,§4.3 会对照)。

参考文档:

建议阅读顺序: §2 是工具行为手册,可以先扫一遍、之后当参考查。§3 是本章最重要的一节——上下文卸载是文件系统存在的根本理由,那个实测数据值得细看。§4 是本章的产出(后端选型表)。§6 如果你在 Windows 中文环境下开发,一定要看,否则 grep 会直接抛异常。

本章验证环境:deepagents 0.7.12,Windows 11 中文环境。本章实验全部零 token——文件操作不需要模型的智能,我用一个「按脚本发出工具调用」的假模型来精确驱动,一分钱不花。

2. 七个文件工具各管什么 #

2.1. 先看清两层结构 #

第 41 章讲工具面时,把这七个工具当成一个整体。实际上它们是两层:

模型 → ls / read_file / write_file / edit_file / delete / glob / grep(工具层)
           ↓
       后端(StateBackend / FilesystemBackend / StoreBackend / ...)
           ↓
    LangGraph 状态 / 真实磁盘 / LangGraph store / 沙箱 ...

工具层是固定的,后端是可换的。 官方原话是:这些工具「通过一个可插拔的后端来操作」。

这个分层有个直接好处:换后端不用改任何提示词。模型看到的工具、说明书、参数都一模一样,只是底下存到哪变了。

它还解释了第 39 章那个悬案。官方在后端协议里写着:

delete——可选。删除文件,或递归删除目录。如果后端不支持删除,这个工具会在请求时自动对模型隐藏。

「后端不支持 → 工具自动隐藏」就是那个条件工具机制。 execute 也是同一套逻辑:它要求后端实现 SandboxBackendProtocol,而默认的 StateBackend 没实现,所以工具装着但不给模型看。第 39 章 §3.3 数出的「注册 10 个、模型看到 9 个」,根源就在这里。

下面逐个看工具的真实行为。本节的实验直接调后端方法,这样能绕开模型、精确观察:

import os
import shutil

# FilesystemBackend 直接操作磁盘,可以脱离图运行,最适合做行为观察
from deepagents.backends import FilesystemBackend

ROOT = os.path.abspath("./tmp_fs_demo")
shutil.rmtree(ROOT, ignore_errors=True)
os.makedirs(ROOT, exist_ok=True)

# virtual_mode=True 把所有路径限制在 root_dir 内,详见 §4.2
be = FilesystemBackend(root_dir=ROOT, virtual_mode=True)
print("后端支持的方法:", [m for m in ("ls", "read", "write", "edit", "glob", "grep", "delete", "execute") if hasattr(be, m)])

运行输出:

后端支持的方法: ['ls', 'read', 'write', 'edit', 'glob', 'grep', 'delete']

没有 execute——所以 FilesystemBackend 下模型也看不到那个工具。想要它得用沙箱后端或 LocalShellBackend(§4.5)。

注意 StateBackend 不能这样测。 官方说明:它「被设计为在图内部使用,在图运行之外调用它的方法不会生效」。所以 §2 用 FilesystemBackend 观察行为,§4 才用真实 agent 对比各后端。

2.2. write_file:是覆盖,不是新建 #

官方工具表里写的是「新建一个文件,或覆盖已有的」。但后端协议文档里写的是「仅新建(Create-only),冲突时返回错误」。两处说法矛盾,实测一下:

# 接 §2.1 的 be
r = be.write("/a.md", "第一版内容\n")
print("首次写入:", r)

r = be.write("/a.md", "第二版内容\n")
print("再写同一个路径:", r)

print("磁盘上的实际内容:", repr(open(os.path.join(ROOT, "a.md"), encoding="utf-8").read()))

运行输出:

首次写入: WriteResult(error=None, path='/a.md')
再写同一个路径: WriteResult(error=None, path='/a.md')
磁盘上的实际内容: '第二版内容\n'

实际行为是覆盖,没有任何报错或警告。 工具表的说法是对的,协议文档那句「Create-only」在 0.7.12 的 FilesystemBackend 上不成立。

这个行为要当心:模型如果对同一路径写两次,第一次的内容就没了。第 40 章 §7.2 那个实验里,模型「又查又写」时就重写了 vec.md——如果它第一次写的内容更好,那就丢了。要防这种情况,靠的是 edit_file(局部改)而不是 write_file(整体覆盖)。

2.3. read_file:默认读多少、行号从哪来 #

先看签名:

import inspect

print(inspect.signature(be.read))

运行输出:

(file_path: str, offset: int = 0, limit: int = 2000) -> deepagents.backends.protocol.ReadResult

后端层默认一次读 2000 行。 注意这和第 39 章 §3.4 那份工具说明书里写的「默认最多 100 行」不一样——那是工具层给模型的默认值,这是后端层的默认值,两层各有一套。

实测分页:

# 造一个 250 行的文件
be.write("/big.md", "".join(f"第 {i} 行\n" for i in range(1, 251)))

r = be.read("/big.md")
fd = r.file_data or {}
body = fd.get("content", "")
print(f"不带参数读:{len(body)} 字符,{body.count(chr(10))} 行")
print("file_data 的键:", list(fd))

# 从第 101 行开始读 5 行
r2 = be.read("/big.md", offset=100, limit=5)
print("\noffset=100 limit=5 的结果:")
print((r2.file_data or {}).get("content", ""))

运行输出:

不带参数读:1892 字符,250 行
file_data 的键: ['content', 'encoding']

offset=100 limit=5 的结果:
第 101 行
第 102 行
第 103 行
第 104 行
第 105 行

offset 是 0 起算的行号,所以 offset=100 从第 101 行开始。

注意 file_data 的键只有 ['content', 'encoding']。而第 40 章 §5.3 实测 StateBackend 时是四个键(content / encoding / created_at / modified_at)。不同后端返回的元数据不一样,所以第 40 章那个兼容取值的 read_vfs() 写法是有必要的。

那模型看到的行号是哪来的? 后端返回的是纯内容(上面的输出里没有行号)。行号是工具层加的——§5 那个通过真实 agent 读 .txt 的实验会看到,工具层返回给模型的是 '1 这是纯文本\n',内容前面多了行号。 这个设计是为了配合 edit_file——模型能准确说出「第几行那段」,也能在分页读时知道自己读到哪了。第 40 章 §6.2 那次运行里,主智能体分页读文件时输出 1 #...、101 ---、201 ...,就是这个行号。

2.4. edit_file:唯一性要求(最容易踩的一个) #

edit_file 做的是精确字符串替换,但它有个硬要求:要替换的字符串必须在文件里唯一,否则拒绝执行。

be.write("/e.md", "价格 100 元\n数量 100 个\n")

# "100" 在文件里出现了两次
r = be.edit("/e.md", "100", "200")
print("替换出现两次的字符串:")
print("  ", r.error)

# 加上 replace_all=True
r = be.edit("/e.md", "100", "200", replace_all=True)
print("\n加 replace_all=True:", r)
print("结果:", repr((be.read("/e.md").file_data or {}).get("content")))

运行输出:

替换出现两次的字符串:
   Error: String '100' appears 2 times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context.

加 replace_all=True: EditResult(error=None, path='/e.md', occurrences=2)
结果: '价格 200 元\n数量 200 个\n'

报错信息本身就给出了两条出路:要么 replace_all=True 全替换,要么「提供一个带更多上下文的、更具体的字符串」。这条错误消息会回灌给模型,所以模型通常能自己纠正——第 8 章讲的「错误回灌」在这里又见到一次。

唯一匹配和找不到的情况:

be.write("/e2.md", "价格 100 元\n数量 5 个\n")

# 带上下文,唯一匹配
r = be.edit("/e2.md", "价格 100 元", "价格 288 元")
print("替换唯一字符串:", r)

r = be.edit("/e2.md", "不存在的内容", "x")
print("替换不存在的内容:", r.error)

运行输出:

替换唯一字符串: EditResult(error=None, path='/e2.md', occurrences=1)
替换不存在的内容: Error: String not found in file: '不存在的内容'

成功时返回 occurrences(替换了几处),这是个有用的确认信号。

为什么要有唯一性要求? 因为模糊替换会造成难以发现的破坏。如果 edit_file("100", "200") 默认就替换第一个匹配,模型很可能改错地方而毫不知情。强制唯一 = 强制模型说清楚要改哪一处,这和 write_file 前先 read_file 的纪律是同一个设计思路:宁可多一次往返,也不要静默改错。

2.5. ls / glob / grep #

be.write("/docs/guide.md", "# 指南\n重排能提升命中率\n")
be.write("/docs/api.md", "# API\n重排接口说明\n")
be.write("/notes/todo.txt", "记得测重排\n")

r = be.ls("/")
print("ls('/'):")
for e in (r.entries or []):
    print("   ", e)

运行输出:

ls('/'):
    {'path': '/a.md', 'is_dir': False, 'size': 16, 'modified_at': '2026-09-03T02:10:20.278521'}
    {'path': '/big.md', 'is_dir': False, 'size': 2892, 'modified_at': '2026-09-03T02:10:20.287039'}
    {'path': '/docs/', 'is_dir': True, 'size': 0, 'modified_at': '2026-09-03T02:10:20.323373'}
    {'path': '/e.md', 'is_dir': False, 'size': 30, 'modified_at': '2026-09-03T02:10:20.301442'}
    {'path': '/e2.md', 'is_dir': False, 'size': 28, 'modified_at': '2026-09-03T02:10:20.316375'}
    {'path': '/notes/', 'is_dir': True, 'size': 0, 'modified_at': '2026-09-03T02:10:20.324877'}

ls 只列当前一层(/docs/ 里的文件没展开),目录项的 path 以 / 结尾且 is_dir=True。

glob 有个反直觉的地方:

print("glob('**/*.md'):", [e["path"] for e in (be.glob("**/*.md").matches or [])])
print("glob('*.md')  :", [e["path"] for e in (be.glob("*.md").matches or [])])

运行输出:

glob('**/*.md'): ['/a.md', '/big.md', '/docs/api.md', '/docs/guide.md', '/e.md', '/e2.md']
glob('*.md')  : ['/a.md', '/big.md', '/docs/api.md', '/docs/guide.md', '/e.md', '/e2.md']

两个模式返回的结果完全一样——*.md 也匹配到了子目录里的文件。 按标准 glob 语义,*.md 只该匹配当前层,**/*.md 才递归。这里 *.md 也递归了。

实践上影响不大(多半正是你想要的),但如果你依赖「只匹配当前层」的语义,会拿到超出预期的结果。要精确控制就用 path 参数限定搜索起点。

grep 做的是字面量搜索(不是正则):

r = be.grep("重排")
print("grep('重排') 命中:")
for m in (r.matches or []):
    print("   ", m)

r = be.grep("重排", glob="*.md")
print("\ngrep + glob 过滤后命中数:", len(r.matches or []))

运行输出:

grep('重排') 命中:
    {'path': '/notes/todo.txt', 'line': 1, 'text': '记得测重排'}
    {'path': '/docs/guide.md', 'line': 2, 'text': '重排能提升命中率'}
    {'path': '/docs/api.md', 'line': 2, 'text': '重排接口说明'}

grep + glob 过滤后命中数: 2

返回结构是 {path, line, text},可以直接定位。glob= 参数用来限定只搜某类文件。

重要:上面这段 grep 中文搜索在 Windows 中文环境下默认会直接抛异常。 我这里能跑出结果是因为开了一个开关——详见 §6,那一节专门讲这个坑。

2.6. delete #

print("删单个文件:", be.delete("/notes/todo.txt"))
print("删整个目录:", be.delete("/docs"))
print("删不存在的:", be.delete("/nope.md"))

运行输出:

删单个文件: DeleteResult(error=None, path='/notes/todo.txt')
删整个目录: DeleteResult(error=None, path='/docs')
删不存在的: DeleteResult(error="Error: '/nope.md' not found", path=None)

删目录是递归的(/docs 下两个文件一起没了)。删不存在的路径返回错误而不是抛异常。

delete 有两个要记住的点:

一、它需要 deepagents>=0.7(第 41 章 §2.1 那张表里标注过)。 二、配合权限规则时,删目录是「全有或全无」的。官方说明:delete 会检查目标及其所有子路径的写权限,只要有一个被拒就拒绝整个操作,而不会删掉一半。这是第 43 章的内容,这里先记下这个保守设计。

2.7. 七个工具速查表 #

工具 后端方法 关键行为 最容易踩的点
ls ls(path) 只列当前一层,目录带 / 后缀 不递归,别指望一次看全
read_file read(path, offset, limit) 后端默认 2000 行;offset 从 0 起算 工具层默认 100 行,两层默认值不同
write_file write(path, content) 覆盖已有文件,不报错 写两次会丢掉第一次的内容
edit_file edit(path, old, new, replace_all) 精确替换,要求 old 唯一 出现多次时直接拒绝执行
delete delete(path) 删目录是递归的 需要 deepagents>=0.7
glob glob(pattern, path) 按通配符找文件 *.md 也会递归匹配子目录
grep grep(pattern, path, glob) 字面量搜索,返回 {path,line,text} 不是正则;Windows 中文有坑(§6)

3. 上下文卸载:文件系统到底解决什么问题 #

工具行为讲完了,但还有个更根本的问题:为什么 harness 要内置一整套文件系统?

官方给 StateBackend 列的两条「最适合的用途」直接回答了这个问题:

  • 给智能体当草稿纸,写中间结果。
  • 自动驱逐(eviction)过大的工具输出,之后智能体可以一片一片读回来。

第二条就是「上下文卸载」。它不是让模型手动去写文件,而是harness 自动发生的。实测一次。

3.1. 实测:38 万字符的工具结果去哪了 #

造一个返回超大结果的工具,用假模型驱动它调用一次:

from typing import List

from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from deepagents import create_deep_agent
from deepagents.backends import StateBackend


class ScriptedModel(BaseChatModel):
    """按脚本依次发出 tool_calls 的假模型:用来精确驱动文件工具,零 token。"""

    # 每一项形如 {"name": "write_file", "args": {...}}
    script: List[dict] = []
    step: int = 0

    @property
    def _llm_type(self) -> str:
        return "scripted"

    def bind_tools(self, tools, **kw):
        # 假模型不需要真的绑定工具,返回自己就行
        return self

    def _generate(self, messages, stop=None, run_manager=None, **kw) -> ChatResult:
        i = self.step
        self.step = i + 1
        if i < len(self.script):
            s = self.script[i]
            # 构造一条带 tool_calls 的 AIMessage,图会去执行对应工具
            msg = AIMessage(content="", tool_calls=[{
                "name": s["name"], "args": s["args"], "id": f"c{i}", "type": "tool_call",
            }])
        else:
            # 脚本跑完就正常收尾,结束循环
            msg = AIMessage(content="done")
        return ChatResult(generations=[ChatGeneration(message=msg)])


def run(backend, script, tools=None, store=None):
    """建一个 deep agent 并按脚本跑一遍,返回最终状态。"""
    kw = dict(model=ScriptedModel(script=script), backend=backend,
              checkpointer=InMemorySaver())
    if tools:
        kw["tools"] = tools
    if store:
        kw["store"] = store
    agent = create_deep_agent(**kw)
    return agent.invoke({"messages": [{"role": "user", "content": "go"}]},
                        {"configurable": {"thread_id": "x"}})


@tool
def dump_logs(day: str) -> str:
    """导出某天的全部日志(会返回很长的内容)。"""
    # 造一个远超默认阈值的巨大结果:约 38 万字符
    return ("2026-09-01 12:00:00 INFO 订单创建成功 order_id=A00001\n") * 8000


result = run(StateBackend(),
             [{"name": "dump_logs", "args": {"day": "2026-09-01"}}],
             tools=[dump_logs])

# 模型实际收到了什么
for m in result["messages"]:
    if type(m).__name__ == "ToolMessage":
        body = m.content if isinstance(m.content, str) else str(m.content)
        print(f"模型收到 {len(body)} 字符:")
        print(body[:300])

# 状态里多出了什么文件
files = result.get("files") or {}
print("\nstate['files'] 里出现了:", list(files))
for p, v in files.items():
    body = v.get("content", "") if isinstance(v, dict) else str(v)
    print(f"  {p}: {len(body)} 字符")

运行输出:

模型收到 1151 字符:
Tool result too large, the result of this tool call c0 was saved in the filesystem at this path: /large_tool_results/c0

You can read the result from the filesystem by using the read_file tool, but make sure to only read part of the result at a time.

You can do this by specifying an offset and limit...

state['files'] 里出现了: ['/large_tool_results/c0']
  /large_tool_results/c0: 384000 字符

这就是上下文卸载的真面目:

大小
工具真实返回 384000 字符
写进文件系统 384000 字符(/large_tool_results/c0)
发给模型的 1151 字符

压缩比约 334 倍,而且没有丢失任何信息——完整内容就在文件里,模型想看随时能 read_file 读回来。

发给模型的那 1151 字符也不是简单的「结果太大」,而是一张取件单:告诉它结果存在哪、用什么工具取、并且明确叮嘱「一次只读一部分,用 offset 和 limit 分页」。

这个机制解释了第 40 章 §6.2 那个观察:当时主智能体读子智能体写的报告时,是按 100 行一页翻的(输出里能看到 1 #...、101 ---、201 ...)。它不是随便这么干的,是被这类提示反复教出来的纪律。

3.2. 阈值在哪调 #

触发卸载的阈值是 FilesystemMiddleware 的构造参数(第 41 章 §6.2 打印过它的签名):

参数 默认值 管什么
tool_token_limit_before_evict 20000 工具结果超过这么多 token 就卸载
human_message_token_limit_before_evict 50000 用户消息超过这么多 token 就卸载
from deepagents import FilesystemMiddleware, create_deep_agent

agent = create_deep_agent(
    model="deepseek:deepseek-v4-flash",
    middleware=[
        FilesystemMiddleware(
            # 调低阈值:5000 token 以上的工具结果就卸载
            tool_token_limit_before_evict=5000,
        )
    ],
)
print("已调低卸载阈值")

注意单位是 token 不是字符,中文的字符/token 比例大约在 1:1 到 1.5:1 之间,所以 20000 token 大致对应两三万汉字。

什么时候该调? 两种情况:

3.3. 顺带看清一件事:内部文件也走后端 #

上面那个 /large_tool_results/c0 是 harness 自己写的,不是模型写的。官方明确说明:

Deep Agents 会自动把内部数据写到后端,包括卸载的大工具结果(在 /large_tool_results/ 下)和对话历史(在 /conversation_history/ 下)。

这件事有个直接后果,也是官方对落盘场景的核心建议:

当你单独使用 FilesystemBackend 时,这些内部文件会被写到 root_dir 下的真实磁盘上,把智能体的产物和你的项目文件混在一起。

也就是说,如果你直接 FilesystemBackend(root_dir="./my_project"),那么跑一段时间后你的项目目录里会多出 large_tool_results/、conversation_history/ 这些目录。解决办法是 CompositeBackend,§4.4 会给出官方推荐的写法。

4. 四种后端 #

现在正式看后端。全部用同一段脚本驱动,只换 backend=,这样差别一目了然。

4.1. StateBackend:默认,线程内 #

不传 backend= 时用的就是它。文件存在 LangGraph 状态里。

# 复用 §3.1 的 ScriptedModel 和 run()
from deepagents.backends import StateBackend

WRITE = [{"name": "write_file",
          "args": {"file_path": "/report.md", "content": "# 报告\n重排上线复盘\n"}}]

r = run(StateBackend(), WRITE)
print("state['files']:", list(r.get("files") or {}))

val = (r.get("files") or {})["/report.md"]
print("值的类型:", type(val).__name__)
print("键:", list(val) if isinstance(val, dict) else "-")

运行输出:

state['files']: ['/report.md']
值的类型: dict
键: ['content', 'encoding', 'created_at', 'modified_at']

这四个键和第 40 章 §5.3 实测的完全一致,也和官方协议文档里 FileData 的定义对得上。

关键特性是线程隔离。第 40 章 §7 用真实模型验证过一次,这里用假模型验证得更干净——同一个后端实例,换个 thread_id:

from langgraph.checkpoint.memory import InMemorySaver
from deepagents import create_deep_agent
from deepagents.backends import StateBackend

be = StateBackend()

# thread A:写文件
agent_a = create_deep_agent(model=ScriptedModel(script=WRITE), backend=be,
                            checkpointer=InMemorySaver())
r1 = agent_a.invoke({"messages": [{"role": "user", "content": "go"}]},
                    {"configurable": {"thread_id": "A"}})
print("thread A 写入后:", list(r1.get("files") or {}))

# thread B:列目录
agent_b = create_deep_agent(
    model=ScriptedModel(script=[{"name": "ls", "args": {"path": "/"}}]),
    backend=be, checkpointer=InMemorySaver())
r2 = agent_b.invoke({"messages": [{"role": "user", "content": "go"}]},
                    {"configurable": {"thread_id": "B"}})
for m in r2["messages"]:
    if type(m).__name__ == "ToolMessage":
        print("thread B 里 ls 的结果:", m.content)

运行输出:

thread A 写入后: ['/report.md']
thread B 里 ls 的结果: No files found

同一个后端实例,B 线程看不见 A 线程的文件。 因为「文件」本质上就是状态的一部分,状态是按线程存的。

还有一条容易忽略但很重要的特性:

这个后端在主管智能体和子智能体之间是共享的,子智能体写的文件在它执行结束后仍然留在 LangGraph 状态里,会继续对主管智能体和其他子智能体可用。

这解释了第 40 章 §6.2 的现象:三个子智能体各自写了文件,结束后主智能体还能 ls 出来、read_file 读到。子智能体的上下文是隔离的(只回一份摘要),但文件系统是共享的——所以文件是它们之间唯一的高效传递通道。

4.2. FilesystemBackend:真的落磁盘 #

import os
import shutil

from deepagents.backends import FilesystemBackend

ROOT = os.path.abspath("./tmp_disk")
shutil.rmtree(ROOT, ignore_errors=True)
os.makedirs(ROOT, exist_ok=True)

r = run(FilesystemBackend(root_dir=ROOT, virtual_mode=True), WRITE)
print("state['files']:", list(r.get("files") or {}))
print("磁盘上有:", os.listdir(ROOT))

p = os.path.join(ROOT, "report.md")
print("磁盘文件内容:", repr(open(p, encoding="utf-8").read()))

运行输出:

state['files']: []
磁盘上有: ['report.md']
磁盘文件内容: '# 报告\n重排上线复盘\n'

完美的对照:state['files'] 是空的,文件真的在磁盘上。

这就是第 40 章那个疑问的答案:当时 report.md 找不到,是因为默认 StateBackend 把它存在状态里;换成 FilesystemBackend 就能在硬盘上看到了。

root_dir 必须是绝对路径。 官方明确要求这一点,上面用 os.path.abspath() 就是为此。

安全开关 virtual_mode。 这是本节最需要注意的东西。官方对它的说明很重:

始终配合 root_dir 使用 virtual_mode=True,以启用基于路径的访问限制(阻止 ..、~ 以及 root_dir 之外的绝对路径)。

注意:默认值(virtual_mode=False)即使设置了 root_dir 也不提供任何安全性。

实测越界拦截:

be = FilesystemBackend(root_dir=ROOT, virtual_mode=True)

for p in ["/../escape.md", "/~/escape.md", "C:/Windows/Temp/escape.md"]:
    try:
        r = be.write(p, "x")
        print(f"  {p!r:32s} -> {r.error!r}")
    except ValueError as e:
        print(f"  {p!r:32s} -> 抛出 ValueError: {e}")

运行输出:

  '/../escape.md'                  -> 抛出 ValueError: Path traversal not allowed
  '/~/escape.md'                   -> 抛出 ValueError: Path traversal not allowed
  'C:/Windows/Temp/escape.md'      -> 抛出 ValueError: Path traversal not allowed

三种越界写法全被拦住。 注意它是抛异常而不是返回 error 字段——这和协议文档要求的「总是返回结构化结果、不要抛异常」不一致。

不过通过工具层调用时,异常被捕获成了正常的错误消息:

r = run(FilesystemBackend(root_dir=ROOT, virtual_mode=True),
        [{"name": "write_file", "args": {"file_path": "/../escape.md", "content": "x"}}])
for m in r["messages"]:
    if type(m).__name__ == "ToolMessage":
        print("ToolMessage:", repr(m.content))
        print("status:", m.status)

运行输出:

ToolMessage: 'Error: Path traversal not allowed: /../escape.md'
status: error

工具层做了兜底:转成 status="error" 的 ToolMessage 回灌给模型,模型能看懂并改用合法路径。所以这个不一致不影响实际使用,但你自己直接调后端方法时要记得 try/except。

一个和文档不符的地方: 官方文档说 virtual_mode 默认是 False,但在 deepagents 0.7.12 里实际默认是 True:

root_dir               默认=None
virtual_mode           默认=True
max_file_size_mb       默认=10

这是好事(默认安全),但别依赖默认值——显式写 virtual_mode=True,既保证行为确定,也让读代码的人看得见这个开关。

另外注意 max_file_size_mb=10:单文件超过 10MB 会被拒绝。处理大文件时记得调这个参数。

什么时候用这个后端? 官方给了很明确的界线:

适合 不适合
本地开发用的 CLI(编码助手、开发工具) Web 服务器或 HTTP API
CI/CD 流水线(做好密钥管理) 处理不受信任的用户输入

不适合的原因也说得很直白:智能体能读到任何可访问的文件,包括 .env 和各种凭据;如果同时给了联网工具,密钥可能被外传;而且文件修改是永久且不可逆的。生产环境要文件操作,应该用沙箱后端(第 43 章)。

4.3. StoreBackend:跨线程持久 #

要跨会话、跨线程共享文件,用它。需要两样东西:一个 store 和一个 namespace。

from langgraph.store.memory import InMemoryStore
from deepagents.backends import StoreBackend

store = InMemoryStore()
# namespace 决定数据存在哪个隔离区,多用户场景必须按用户区分
sb = StoreBackend(namespace=lambda rt: ("demo",))

READ = [{"name": "read_file", "args": {"file_path": "/report.md"}}]

# thread A 写入。注意 store 传给 create_deep_agent,不是传给 backend
print("thread A 写入:")
r1 = run(sb, WRITE, store=store)
print("  A 的 state['files']:", list(r1.get("files") or {}))

# thread B 读取(不同 thread、不同 checkpointer)
print("thread B 读取:")
r2 = run(sb, READ, store=store)
for m in r2["messages"]:
    if type(m).__name__ == "ToolMessage":
        print("  读到:", repr(m.content))

print("\nstore 里实际存了什么:")
for item in store.search(("demo",)):
    print("  namespace=", item.namespace, "key=", item.key)
    print("  value=", str(item.value)[:70])

运行输出:

thread A 写入:
  A 的 state['files']: []
thread B 读取:
  读到: '1  # 报告\n2  重排上线复盘\n'
print
store 里实际存了什么:
  namespace= ('demo',) key= /report.md
  value= {'content': '# 报告\n重排上线复盘\n', 'encoding': 'utf-8', 'created_

B 线程读到了 A 线程写的文件——这是 StateBackend 做不到的(§4.1 那个实验返回 No files found)。同时 state['files'] 是空的,数据在 store 里。

namespace 是多用户隔离的关键。 官方警告:不提供 namespace 工厂时,遗留的默认行为是用 assistant_id,意味着同一个助手的所有用户共享同一份存储。所以生产环境必须显式设置:

from deepagents.backends import StoreBackend

# 按用户隔离:每个用户一份独立存储(最常用)
per_user = StoreBackend(namespace=lambda rt: (rt.server_info.user.identity,))

# 按线程隔离:每个会话一份
per_thread = StoreBackend(namespace=lambda rt: (rt.execution_info.thread_id,))

# 组合:某用户的某个会话
per_both = StoreBackend(
    namespace=lambda rt: (rt.server_info.user.identity, rt.execution_info.thread_id)
)

rt 是 LangGraph 的 Runtime,能拿到三组信息:rt.context(你传的上下文,比如 user_id)、rt.server_info(LangGraph Server 上的助手 ID、认证用户)、rt.execution_info(thread / run / checkpoint ID)。

和第 16 章长期记忆的关系: 第 16 章用的是 store + 语义检索(store.search() 找相似记忆)。这里的 StoreBackend 也用 store,但把它当文件系统用——按路径读写,不做向量检索。两者可以共存:需要「按名字取」用文件,需要「按意思找」用语义检索。第 45 章讲 Memory 时会详细对比。

本地开发用 InMemoryStore 就够,进程结束就没了。要真持久化换 Postgres / Redis 版(第 16 章配过)。部署到 LangSmith 时要省略 store 参数,平台会自动供给。

4.4. CompositeBackend:按路径分流 #

前三种后端各有取舍,而真实项目往往同时需要多种。CompositeBackend 按路径前缀把请求分发给不同后端。

from langgraph.store.memory import InMemoryStore
from deepagents.backends import CompositeBackend, StateBackend, StoreBackend

store = InMemoryStore()
comp = CompositeBackend(
    # 没匹配上任何路由的走这个
    default=StateBackend(),
    routes={
        # /memories/ 下的走 store,跨线程持久
        "/memories/": StoreBackend(namespace=lambda rt: ("mem",)),
    },
)

script = [
    {"name": "write_file", "args": {"file_path": "/scratch.md", "content": "临时草稿\n"}},
    {"name": "write_file", "args": {"file_path": "/memories/user.md", "content": "用户偏好:中文回答\n"}},
    {"name": "ls", "args": {"path": "/"}},
]

r = run(comp, script, store=store)
print("state['files'] 里有:", list(r.get("files") or {}))
print("store 里有:")
for item in store.search(("mem",)):
    print("  key=", item.key, "|", str(item.value)[:40])

运行输出:

state['files'] 里有: ['/scratch.md']
store 里有:
  key= /user.md | {'content': '用户偏好:中文回答\n', 'encoding'

ls 的结果(从 ToolMessage 里看)是:

['/memories/', '/scratch.md']

三处细节值得注意:

一、分流真的生效了:/scratch.md 进了状态,/memories/user.md 进了 store。 二、ls 聚合了两个后端的结果,而且保留原始前缀(显示 /memories/ 而不是 store 内部的 /user.md)。官方说明是「在列表和搜索结果里保留原始路径前缀」。 三、store 里的 key 是 /user.md,前缀被剥掉了。路由前缀只用于选后端,不进入后端内部的路径。

更长的前缀优先。 官方说明:路由 "/memories/projects/" 可以覆盖 "/memories/"。所以可以做多层分流。

最重要的用法:把内部数据和项目文件分开。 这就是 §3.3 提到的问题的解法,也是官方对落盘场景的标准建议:

from deepagents import create_deep_agent
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend

agent = create_deep_agent(
    model="deepseek:deepseek-v4-flash",
    backend=CompositeBackend(
        # 默认走状态:卸载的大工具结果、对话历史都留在内存里,用完即弃
        default=StateBackend(),
        routes={
            # 只有 /workspace/ 下的才落到真实磁盘
            "/workspace/": FilesystemBackend(
                root_dir="/absolute/path/to/project",
                virtual_mode=True,
            ),
        },
    ),
)
print("内部数据留在状态里,项目文件落磁盘")

这样 /workspace/plan.md 写到真实磁盘,而 /large_tool_results/c0 留在状态里——你的项目目录保持干净。

官方特意强调了这一点:

Deep Agents 会把内部数据(卸载的工具结果、对话历史)写到默认后端。用 StateBackend 作为默认后端,能让这些产物保持临时性,避免写到磁盘或持久存储里。

所以「默认后端用 StateBackend」几乎总是对的,需要持久化的路径通过 routes 单独挂出去。

4.5. 另外三种后端 #

官方还提供三种,本章不深入,先知道存在和用途:

后端 用途 备注
LocalShellBackend FilesystemBackend + execute,直接在宿主机跑 shell 没有任何隔离,官方要求「极其谨慎」,只在可信的本地开发环境用
ContextHubBackend 文件存在 LangSmith Context Hub 仓库里,带提交历史 需要 LANGSMITH_API_KEY 和已建好的 Hub repo
沙箱后端 隔离环境里跑代码,提供 execute LangSmith / AgentCore / Daytona 等,第 43 章专讲

LocalShellBackend 值得多提一句,因为它是唯一能在本机提供 execute 的选项:

from deepagents import create_deep_agent
from deepagents.backends import LocalShellBackend

# 危险:命令直接在你的机器上跑,用你的用户权限
agent = create_deep_agent(
    model="deepseek:deepseek-v4-flash",
    backend=LocalShellBackend(
        root_dir=".",
        virtual_mode=True,
        # 收窄环境变量,减少泄露面
        env={"PATH": "/usr/bin:/bin"},
    ),
)

官方对它的警告比 FilesystemBackend 更重:智能体能用你的权限执行任意 shell 命令、能读任何文件包括密钥、命令执行不可逆、还能无限占用 CPU 内存磁盘。而且有一句关键提醒:

注意:开启 shell 执行后,virtual_mode=True 不提供任何安全性,因为命令可以访问系统上的任何路径。

换句话说:virtual_mode 只管文件工具,管不了 execute。 这条和第 43 章「权限规则对沙箱不生效」是同一类问题——只要能跑任意命令,路径级的限制就都绕得过去。

4.6. 后端选型表 #

这是本章的产出。按「你的场景」查:

你的场景 选什么 理由
刚开始学、做原型 StateBackend(默认,什么都不用传) 零配置,用完即弃
Web 服务 / HTTP API StateBackend 或 StoreBackend 官方明确禁止在此类场景用 FilesystemBackend
要跨会话记住东西 StoreBackend + 显式 namespace 唯一跨线程的选项
本地编码助手,要改真实项目 CompositeBackend(默认 State + /workspace/ 挂 FilesystemBackend) 项目文件落盘,内部产物不污染目录
需要跑 shell / 执行代码 沙箱后端(第 43 章) 生产环境唯一安全的选项
本地开发想让它跑命令 LocalShellBackend + HITL 审批 无隔离,必须配人工审批(第 48 章)
多用户 SaaS CompositeBackend,/memories/ 挂按用户隔离的 StoreBackend 数据隔离靠 namespace
部署在 LangSmith StoreBackend,省略 store 参数 平台自动供给 store

三条通用建议:

  1. 默认后端始终用 StateBackend,需要持久化的路径用 routes 挂出去(§4.4)。
  2. 用 FilesystemBackend 就一定显式写 virtual_mode=True,别信默认值(§4.2)。
  3. 用 StoreBackend 就一定显式写 namespace,否则多用户会共享存储(§4.3)。

5. 多模态:让它读图片和 PDF #

read_file 不只能读文本。官方说明:

harness 的 read_file 工具对支持的多模态文件返回标准内容块,而不是纯文本。

支持的扩展名:

类型 扩展名
图片 .png、.jpg、.jpeg、.gif、.webp、.heic、.heif
视频 .mp4、.mpeg、.mov、.avi、.flv、.mpg、.webm、.wmv、.3gpp
音频 .wav、.mp3、.aiff、.aac、.ogg、.flac
文档 .pdf、.ppt、.pptx

实测读一张 PNG:

import os
import shutil

# Pillow 用来造一张测试图片
from PIL import Image
from deepagents.backends import FilesystemBackend

ROOT = os.path.abspath("./tmp_media")
shutil.rmtree(ROOT, ignore_errors=True)
os.makedirs(ROOT, exist_ok=True)

# 造一张 60x30 的纯色图
Image.new("RGB", (60, 30), (200, 40, 40)).save(os.path.join(ROOT, "chart.png"))

# 复用 §3.1 的 run()
r = run(FilesystemBackend(root_dir=ROOT, virtual_mode=True),
        [{"name": "read_file", "args": {"file_path": "/chart.png"}}])

for m in r["messages"]:
    if type(m).__name__ == "ToolMessage":
        print("content 的 python 类型:", type(m.content).__name__)
        if isinstance(m.content, list):
            for blk in m.content:
                print("  块 type =", blk.get("type"))
                print("  键 =", list(blk))
                print("  mime_type =", blk.get("mime_type"))
                print("  base64 前 40 字符 =", (blk.get("base64") or "")[:40])

运行输出:

content 的 python 类型: list
  块 type = image
  键 = ['type', 'base64', 'mime_type']
  mime_type = image/png
  base64 前 40 字符 = iVBORw0KGgoAAAANSUhEUgAAADwAAAAeCAIAAAD/

返回的是 list,里面是一个 {'type': 'image', 'base64': ..., 'mime_type': 'image/png'} 块——标准的多模态内容块,模型(只要支持视觉)能直接看图。

对照读一个 .txt:

be = FilesystemBackend(root_dir=ROOT, virtual_mode=True)
be.write("/plain.txt", "这是纯文本\n")

r = run(be, [{"name": "read_file", "args": {"file_path": "/plain.txt"}}])
for m in r["messages"]:
    if type(m).__name__ == "ToolMessage":
        print("content 类型:", type(m.content).__name__, "|", repr(m.content))

运行输出:

content 类型: str | '1  这是纯文本\n'

纯文本返回 str 且带行号,图片返回 list 的内容块。 同一个工具、两种返回形态,靠扩展名判断。

顺便,这也印证了 §2.3 的说法:行号是工具层加的(后端返回的内容里没有)。

用户消息里也能带图(第 4 章讲过的标准内容块):

result = agent.invoke({
    "messages": [{
        "role": "user",
        "content": [
            {"type": "text", "text": "这张截图里有什么问题?"},
            {"type": "image", "url": "https://example.com/screenshot.png"},
        ],
    }],
})

但多模态有一个必须知道的限制,官方在开头就警告了:

内置的上下文压缩主要面向文本。请相应地规划多模态负载:把大的媒体文件存在后端里,尽量传引用。

具体有两条:

机制 对多模态的影响
卸载(offloading) 只按文本 token 计量。非文本块(含图片)会被原样保留而不压缩——一条只有图片的消息,不会因为图片大就被卸载
摘要(summarization) 把旧消息压成纯文本摘要,图片、音频、视频、文件块不会被带过去——模型只能看到摘要器写的文字描述

这意味着长对话里的图片会「消失」:摘要一触发,旧轮次的图片就从活跃上下文里掉出去了。官方给了四条应对:

  1. 把图片、截图、图表存在文件系统或对象存储里,消息里传路径或 URL 而不是 base64
  2. 长对话里优先用引用而不是内联 base64
  3. 图片密集的检查工作交给子智能体做,主智能体只收一份紧凑的文字结果(第 40 章 §6 那个模式)
  4. 调整摘要阈值,或者为图片计价高的供应商提供自定义 token 计数器

第 46 章讲上下文工程时会展开这些。

6. Windows 中文用户必看:grep 会直接崩 #

这一节是踩坑记录,如果你在 Windows 中文环境下开发,不看会被卡住。

§2.5 那个 grep("重排") 的实验,在默认环境下会直接抛异常:

from deepagents.backends import FilesystemBackend

be = FilesystemBackend(root_dir=ROOT, virtual_mode=True)
be.write("/docs/guide.md", "# 指南\n重排能提升命中率\n")

# 搜英文没问题
print("搜英文:", be.grep("rerank").matches)

# 搜中文直接崩
try:
    r = be.grep("重排")
    print("搜中文:", r.matches)
except UnicodeDecodeError as e:
    print(f"搜中文崩了: UnicodeDecodeError: {e}")

运行输出:

搜英文: [{'path': '/en.md', 'line': 1, 'text': 'rerank improves hit rate'}]
搜中文崩了: UnicodeDecodeError: 'gbk' codec can't decode byte 0xad in position 667: illegal multibyte sequence

搜英文正常,搜中文崩溃。 注意崩的是内容含中文的文件,不只是搜索词含中文——只要 ripgrep 输出里带中文就会触发。

6.1. 根因 #

FilesystemBackend 的 grep 优先调用外部的 ripgrep(rg)来加速。问题出在启动子进程那行:

proc = subprocess.Popen(
    cmd,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,          # 关键:只说了要文本模式,没说用什么编码
    cwd=rg_cwd,
)

text=True 但没有指定 encoding=,于是 Python 用系统默认编码去解码。而 ripgrep 输出的是 UTF-8:

import locale

print("locale.getpreferredencoding(False):", locale.getpreferredencoding(False))

在 Windows 中文环境下输出:

locale.getpreferredencoding(False): cp936

cp936(GBK)解码 UTF-8 的中文字节,必然失败。

更麻烦的是:grep 的代码里有 Python 版的兜底实现(ripgrep 不可用时会用),但 UnicodeDecodeError 是在遍历 proc.stdout 时抛出的未捕获异常,直接冒到上层,走不到兜底那一步。

6.2. 解法:打开 Python 的 UTF-8 模式 #

最干净的办法是让 locale.getpreferredencoding(False) 返回 utf-8,这样 Popen 就会用 UTF-8 解码。设一个环境变量即可:

# PowerShell:当前会话生效
$env:PYTHONUTF8=1
uv run python your_script.py
# 想永久生效(对当前用户)
[Environment]::SetEnvironmentVariable("PYTHONUTF8", "1", "User")

也可以在命令行加参数,效果一样:

uv run python -X utf8 your_script.py

开启后重跑同一段代码:

Python UTF-8 模式: 1
locale.getpreferredencoding(False): utf-8

搜中文: 
    {'path': '/notes/todo.txt', 'line': 1, 'text': '记得测重排'}
    {'path': '/docs/guide.md', 'line': 2, 'text': '重排能提升命中率'}
    {'path': '/docs/api.md', 'line': 2, 'text': '重排接口说明'}

中文搜索完全正常,三处全部命中。 §2.5 那个实验就是在这个模式下跑的。

6.3. 三条替代方案 #

如果不方便改环境变量:

方案 做法 代价
改用 StateBackend 它的 grep 是纯 Python 实现,不调 ripgrep 文件不落盘
让 ripgrep 不可用 把 rg 从 PATH 里去掉,强制走 Python 兜底 大目录搜索变慢
不用 grep 第 41 章 §6.2 的白名单里不给它:FilesystemMiddleware(tools=["ls", "read_file", "glob"]) 失去内容搜索能力

推荐第一条路(PYTHONUTF8=1),因为它还能顺手解决 Windows 上其他一堆中文编码问题——比如控制台输出乱码。本教程后续章节都假设你开了这个开关。

顺带说明本教程里另一个相关习惯。 前面几章的实验脚本开头都有这么两行:

import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

那是解决打印中文到 PowerShell 时的乱码,管的是 Python 自己的标准输出。而这一节的 PYTHONUTF8=1 管的是子进程输出的解码,两者是不同的问题。开了 PYTHONUTF8=1 之后,那两行也可以省掉了。

7. 实用约定与坑 #

约定 说明
默认后端始终用 StateBackend 让内部产物保持临时性(§4.4)
需要落盘的路径用 CompositeBackend 的 routes 挂出去 别让 large_tool_results/ 污染项目目录(§3.3)
用 FilesystemBackend 必须显式写 virtual_mode=True 唯一的路径安全开关,且别信默认值(§4.2)
root_dir 必须是绝对路径 官方硬要求(§4.2)
用 StoreBackend 必须显式写 namespace 否则多用户共享存储(§4.3)
Windows 中文环境设 PYTHONUTF8=1 否则 grep 直接崩(§6)
让模型改文件优先用 edit_file write_file 是整体覆盖,会丢内容(§2.2)
直接调后端方法时记得 try/except 越界路径抛异常而不是返回 error(§4.2)
多模态大文件存后端、消息里传引用 摘要会丢掉媒体块(§5)
部署到 LangSmith 时省略 store= 平台自动供给(§4.3)

一、静默失效(不报错,但结果不对)

现象 原因 处理
项目目录里冒出 large_tool_results/、conversation_history/ 直接用了 FilesystemBackend,内部数据也落盘了 用 CompositeBackend,默认走 State(§4.4)
换 thread_id 后文件不见了 StateBackend 是线程内的 换 StoreBackend(§4.3)
多个用户看到彼此的文件 StoreBackend 没设 namespace 按用户设 namespace(§4.3)
模型写了两次,第一次的内容没了 write_file 是覆盖 改用 edit_file(§2.2)
glob("*.md") 返回了子目录里的文件 这里的 * 也递归 用 path 参数限定范围(§2.5)
长对话里图片「消失」了 摘要只保留文本,媒体块被丢掉 存文件传引用(§5)
.env 被智能体读到 FilesystemBackend 能读 root_dir 下任何文件 用权限规则(第 43 章)或换沙箱
模型看不到 execute / delete 后端不支持时工具自动隐藏 换支持的后端(§2.1)

二、会明确报错

现象 原因 处理
UnicodeDecodeError: 'gbk' codec can't decode Windows 中文环境下 grep 调 ripgrep 的解码问题 设 PYTHONUTF8=1(§6.2)
Error: String 'x' appears N times in file edit_file 要求 old_string 唯一 加 replace_all=True 或补上下文(§2.4)
Error: String not found in file 要替换的内容不存在(可能是模型记错了) 先 read_file 确认原文(§2.4)
ValueError: Path traversal not allowed virtual_mode=True 拦住了越界路径 用 root_dir 内的合法路径(§4.2)
root_dir 相关报错 传了相对路径 用 os.path.abspath()(§4.2)
文件超过大小限制 max_file_size_mb 默认 10 调大这个参数(§4.2)
StoreBackend 报找不到 store 忘了给 create_deep_agent 传 store= 传上,注意是传给 agent 不是 backend(§4.3)

三、概念性误解

误解 纠正
「智能体写的文件在我硬盘上」 默认在 LangGraph 状态里,要落盘得换后端(§4.2)
「上下文卸载是模型自己写文件」 是 harness 自动做的,模型只收到一张取件单(§3.1)
「write_file 遇到已有文件会报错」 直接覆盖,不报错(§2.2)
「read_file 默认读 100 行」 工具层默认 100,后端层默认 2000,两层不同(§2.3)
「行号是文件内容的一部分」 是工具层加的,后端返回的内容里没有(§2.3、§5)
「virtual_mode 能防住 execute」 只管文件工具,shell 命令绕得过去(§4.5)
「子智能体的文件用完就没了」 留在状态里,主智能体和其他子智能体还能读(§4.1)
「CompositeBackend 的路由前缀会进后端」 前缀只用于选后端,store 里的 key 不含它(§4.4)

8. 练习 #

基础(跑通即可)

  1. 验证 write_file 会覆盖:用 §3.1 的 run() 驱动模型对同一路径写两次不同内容,确认第一次的内容没了。
  2. 看清一次卸载:把 §3.1 的 dump_logs 结果调成不同大小(4000 行、8000 行、20000 行),找出你这个环境下开始触发卸载的临界点。
  3. 换后端不改提示词:拿第 40 章那个研究助手(search_docs + 写报告),只把 backend= 换成 FilesystemBackend,验证报告真的出现在磁盘上、且提示词一个字没改。

进阶(要动手设计)

  1. 搭一个干净的落盘方案:用 CompositeBackend,把 /workspace/ 挂到真实项目目录、默认走 StateBackend。跑一个会产生大工具结果的任务,确认项目目录里没有 large_tool_results/。
  2. 做跨会话记忆:用 CompositeBackend 把 /memories/ 挂 StoreBackend,让 agent 第一轮记住用户偏好、第二轮换 thread_id 后仍能读到。再和第 16 章的语义检索记忆对比:什么时候该用文件、什么时候该用向量检索?
  3. 测 edit_file 的唯一性:让模型改一个含重复字符串的文件,观察它收到报错后怎么自我纠正(会加 replace_all 还是会补上下文?)。
  4. 读一份真 PDF:找一份中文 PDF 放进后端,用支持视觉的模型 read_file 它,看返回的内容块结构,以及模型能不能读懂内容。
  5. (扩展)写一个自定义后端:按 §4 提到的 BackendProtocol,实现一个把文件存到 SQLite 的后端(六个必需方法:ls / read / write / edit / glob / grep)。注意协议要求「总是返回带 error 字段的结构化结果,不要抛异常」。

9. 本章小结 #

  1. 文件工具是两层结构:工具层(七个工具)固定不变,后端可插拔。换后端不用改任何提示词。
  2. 「后端不支持 → 工具自动对模型隐藏」就是条件工具机制。delete 和 execute 都是这样——这解释了第 39 章「注册 10 个、模型看到 9 个」的悬案。
  3. write_file 是覆盖不是新建,不报错也不警告。协议文档写的「Create-only」在 0.7.12 里不成立。要局部改用 edit_file。
  4. read_file 两层默认值不同:工具层 100 行,后端层 2000 行。offset 从 0 起算。
  5. 行号是工具层加的,后端返回的内容里没有。它的用处是配合 edit_file 定位和分页读时知道位置。
  6. edit_file 要求 old_string 唯一,出现多次直接拒绝并在报错里给出两条出路(replace_all 或补上下文)。这个强制是为了防止模型静默改错地方。
  7. glob("*.md") 也会递归匹配子目录,和标准 glob 语义不同。要精确控制用 path 参数。
  8. 上下文卸载是本章最重要的机制:38 万字符的工具结果被写进 /large_tool_results/c0,发给模型的只有 1151 字符(压缩约 334 倍),而且那 1151 字符是一张「取件单」——告诉它存在哪、怎么取、并叮嘱分页读。
  9. 卸载是 harness 自动做的,不是模型主动写文件。阈值是 tool_token_limit_before_evict(默认 20000 token)和 human_message_token_limit_before_evict(默认 50000)。
  10. harness 的内部数据也走后端(/large_tool_results/、/conversation_history/),所以直接用 FilesystemBackend 会把智能体产物和项目文件混在一起。
  11. StateBackend 线程隔离:同一个后端实例,B 线程 ls 不到 A 线程写的文件。但它在主智能体和子智能体之间是共享的——这就是第 40 章那三个子智能体写的文件事后还能读到的原因。
  12. FilesystemBackend 真落盘(state['files'] 为空、磁盘上有文件),这是第 40 章「文件不在硬盘上」的答案。root_dir 必须绝对路径。
  13. virtual_mode=True 是唯一的路径安全开关,能拦住 ..、~ 和越界绝对路径。官方说默认 False,但 0.7.12 实际默认 True——还是要显式写。
  14. 越界时后端抛 ValueError,工具层兜底成 status="error" 的 ToolMessage。所以模型能自我纠正,但你直接调后端要 try/except。
  15. StoreBackend 是唯一跨线程的选项,实测 B 线程能读到 A 线程写的文件。必须显式设 namespace,否则同一助手的所有用户共享存储。
  16. CompositeBackend 按路径前缀分流,更长的前缀优先,ls 会聚合结果并保留原始前缀(但路由前缀不进后端内部的 key)。
  17. 「默认后端用 StateBackend + 需要持久化的路径挂 routes」几乎总是对的,这是官方对落盘场景的标准建议。
  18. virtual_mode 管不了 execute——只要能跑任意命令,路径限制就绕得过去。这和第 43 章「权限规则对沙箱不生效」是同一类问题。
  19. read_file 原生支持多模态:读 PNG 返回 {'type': 'image', 'base64': ..., 'mime_type': 'image/png'} 内容块,读 .txt 返回带行号的字符串。
  20. 多模态有个硬限制:卸载只按文本 token 计量,摘要会把媒体块整个丢掉。长对话里应该存文件传引用,而不是内联 base64。
  21. Windows 中文环境下 grep 会直接崩(UnicodeDecodeError: 'gbk' codec),根因是 subprocess.Popen(text=True) 没指定 encoding,用 cp936 解码 ripgrep 的 UTF-8 输出。解法是设 PYTHONUTF8=1,实测立刻正常。

下一章讲权限、沙箱与解释器:用 permissions= 给文件操作加声明式的读写规则(三要素 operations / paths / mode,按声明顺序首条命中),护住 .env 和凭据;再看沙箱后端怎么安全地提供 execute——以及本章两次提到的那条坑「路径级限制管不住能跑命令的后端」,在那里会有完整的解释和对策。