1. 本章目标 #
前三章一直在说「文件系统是 harness 的核心能力」,但一直没说清三件事:
- 第 40 章 §5.3 写出的
report.md,为什么不在你的硬盘上? - 第 39 章和第 41 章都发现
execute被装了却不给模型,这个「条件工具」的机制是什么? - 官方反复讲的「上下文卸载」,到底长什么样?
这一章把这三个问题一起解决。
学完你应能:
- 说清七个文件工具各自的行为边界,尤其是
edit_file那个最容易踩的唯一性要求 - 看懂一次真实的上下文卸载:38 万字符的工具结果去了哪、模型收到了什么
- 按场景选对四种后端,并说清「线程内 / 跨线程 / 落磁盘」的区别
- 用
CompositeBackend把内部数据和项目文件分开——这是官方对几乎所有落盘场景的建议 - 知道
virtual_mode是唯一的路径安全开关,以及它在 0.7.12 的默认值和文档不一致 - 让模型读图片和 PDF(
read_file原生支持,返回多模态块) - 绕开一个会直接崩掉的 Windows 中文坑(
grep的编码问题,§6)
前置依赖: 第 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 大致对应两三万汉字。
什么时候该调? 两种情况:
- 调低:模型的上下文窗口小,或者你的工具经常返回几万字的结果。降到 5000 左右能更早开始卸载。
- 调高:结果虽然大但模型每次都要看全文(比如需要通篇比对),卸载反而增加往返次数。
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 |
三条通用建议:
- 默认后端始终用
StateBackend,需要持久化的路径用routes挂出去(§4.4)。 - 用
FilesystemBackend就一定显式写virtual_mode=True,别信默认值(§4.2)。 - 用
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) | 把旧消息压成纯文本摘要,图片、音频、视频、文件块不会被带过去——模型只能看到摘要器写的文字描述 |
这意味着长对话里的图片会「消失」:摘要一触发,旧轮次的图片就从活跃上下文里掉出去了。官方给了四条应对:
- 把图片、截图、图表存在文件系统或对象存储里,消息里传路径或 URL 而不是 base64
- 长对话里优先用引用而不是内联 base64
- 图片密集的检查工作交给子智能体做,主智能体只收一份紧凑的文字结果(第 40 章 §6 那个模式)
- 调整摘要阈值,或者为图片计价高的供应商提供自定义 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): cp936cp936(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. 练习 #
基础(跑通即可)
- 验证
write_file会覆盖:用 §3.1 的run()驱动模型对同一路径写两次不同内容,确认第一次的内容没了。 - 看清一次卸载:把 §3.1 的
dump_logs结果调成不同大小(4000 行、8000 行、20000 行),找出你这个环境下开始触发卸载的临界点。 - 换后端不改提示词:拿第 40 章那个研究助手(
search_docs+ 写报告),只把backend=换成FilesystemBackend,验证报告真的出现在磁盘上、且提示词一个字没改。
进阶(要动手设计)
- 搭一个干净的落盘方案:用
CompositeBackend,把/workspace/挂到真实项目目录、默认走StateBackend。跑一个会产生大工具结果的任务,确认项目目录里没有large_tool_results/。 - 做跨会话记忆:用
CompositeBackend把/memories/挂StoreBackend,让 agent 第一轮记住用户偏好、第二轮换thread_id后仍能读到。再和第 16 章的语义检索记忆对比:什么时候该用文件、什么时候该用向量检索? - 测
edit_file的唯一性:让模型改一个含重复字符串的文件,观察它收到报错后怎么自我纠正(会加replace_all还是会补上下文?)。 - 读一份真 PDF:找一份中文 PDF 放进后端,用支持视觉的模型
read_file它,看返回的内容块结构,以及模型能不能读懂内容。 - (扩展)写一个自定义后端:按 §4 提到的
BackendProtocol,实现一个把文件存到 SQLite 的后端(六个必需方法:ls/read/write/edit/glob/grep)。注意协议要求「总是返回带error字段的结构化结果,不要抛异常」。
9. 本章小结 #
- 文件工具是两层结构:工具层(七个工具)固定不变,后端可插拔。换后端不用改任何提示词。
- 「后端不支持 → 工具自动对模型隐藏」就是条件工具机制。
delete和execute都是这样——这解释了第 39 章「注册 10 个、模型看到 9 个」的悬案。 write_file是覆盖不是新建,不报错也不警告。协议文档写的「Create-only」在 0.7.12 里不成立。要局部改用edit_file。read_file两层默认值不同:工具层 100 行,后端层 2000 行。offset从 0 起算。- 行号是工具层加的,后端返回的内容里没有。它的用处是配合
edit_file定位和分页读时知道位置。 edit_file要求 old_string 唯一,出现多次直接拒绝并在报错里给出两条出路(replace_all或补上下文)。这个强制是为了防止模型静默改错地方。glob("*.md")也会递归匹配子目录,和标准 glob 语义不同。要精确控制用path参数。- 上下文卸载是本章最重要的机制:38 万字符的工具结果被写进
/large_tool_results/c0,发给模型的只有 1151 字符(压缩约 334 倍),而且那 1151 字符是一张「取件单」——告诉它存在哪、怎么取、并叮嘱分页读。 - 卸载是 harness 自动做的,不是模型主动写文件。阈值是
tool_token_limit_before_evict(默认 20000 token)和human_message_token_limit_before_evict(默认 50000)。 - harness 的内部数据也走后端(
/large_tool_results/、/conversation_history/),所以直接用FilesystemBackend会把智能体产物和项目文件混在一起。 StateBackend线程隔离:同一个后端实例,B 线程ls不到 A 线程写的文件。但它在主智能体和子智能体之间是共享的——这就是第 40 章那三个子智能体写的文件事后还能读到的原因。FilesystemBackend真落盘(state['files']为空、磁盘上有文件),这是第 40 章「文件不在硬盘上」的答案。root_dir必须绝对路径。virtual_mode=True是唯一的路径安全开关,能拦住..、~和越界绝对路径。官方说默认False,但 0.7.12 实际默认True——还是要显式写。- 越界时后端抛
ValueError,工具层兜底成status="error"的 ToolMessage。所以模型能自我纠正,但你直接调后端要try/except。 StoreBackend是唯一跨线程的选项,实测 B 线程能读到 A 线程写的文件。必须显式设namespace,否则同一助手的所有用户共享存储。CompositeBackend按路径前缀分流,更长的前缀优先,ls会聚合结果并保留原始前缀(但路由前缀不进后端内部的 key)。- 「默认后端用
StateBackend+ 需要持久化的路径挂 routes」几乎总是对的,这是官方对落盘场景的标准建议。 virtual_mode管不了execute——只要能跑任意命令,路径限制就绕得过去。这和第 43 章「权限规则对沙箱不生效」是同一类问题。read_file原生支持多模态:读 PNG 返回{'type': 'image', 'base64': ..., 'mime_type': 'image/png'}内容块,读.txt返回带行号的字符串。- 多模态有个硬限制:卸载只按文本 token 计量,摘要会把媒体块整个丢掉。长对话里应该存文件传引用,而不是内联 base64。
- Windows 中文环境下
grep会直接崩(UnicodeDecodeError: 'gbk' codec),根因是subprocess.Popen(text=True)没指定 encoding,用 cp936 解码 ripgrep 的 UTF-8 输出。解法是设PYTHONUTF8=1,实测立刻正常。
下一章讲权限、沙箱与解释器:用 permissions= 给文件操作加声明式的读写规则(三要素 operations / paths / mode,按声明顺序首条命中),护住 .env 和凭据;再看沙箱后端怎么安全地提供 execute——以及本章两次提到的那条坑「路径级限制管不住能跑命令的后端」,在那里会有完整的解释和对策。