DeepAgents(四):虚拟文件系统:管的是自己的上下文

2026-10-01 16:570 条评论7 次阅读

Agent 跑长任务时会撞上一堵墙:上下文窗口。DeepAgents 的虚拟文件系统就是为这堵墙准备的,它是这套 harness 的上下文引擎:窗口装不下的内容被自动写成文件,模型那一轮只看到一条路径和一小段预览。

这篇把它的 3 道机制拆开算账:什么情况触发、内容写到哪、模型实际看到多少、要付多少 token。

准备 2 样:装好 deepagents,愿意读几行源码。全程不用 API key,读完约 6 分钟。

一、先把 3 个结论摆出来

问题 结论
它管的是什么 管 Agent 自己的上下文,不负责检索外部知识
怎么管 装不下的写成文件,历史里只留路径与预览
代价是什么 找回内容要靠字面匹配,措辞得对得上

后面按这 3 条逐条给证据。

二、它管的是自己的上下文

先看默认装了什么。create_deep_agent 的文档里列了基础栈的 6 个中间件:

中间件 干什么
SkillsMiddleware 把技能说明加进提示词
FilesystemMiddleware 文件系统,卸载与取回的底座
SubAgentMiddleware 把重活派给独立窗口的子 Agent
SummarizationMiddleware 上下文快满时把旧消息压成摘要
PatchToolCallsMiddleware 修补被截断拆散的配对调用
AsyncSubAgentMiddleware 异步子 Agent

6 个里有 4 个直接为上下文服务。实装一个 agent 抓下来是 5 个,其中 4 个是这几个。

包里面向上下文的模块也有 5 个,summarization.py、_overflow_clip.py、_message_eviction.py 这些名字都在说同一件事。

课程里对这个定位写得很直接:

虚拟文件系统最大的价值不在于「存文件」本身,而在于它与 Deep Agents 的上下文自动管理机制紧密配合。

所以把它理解成一个存东西的地方是不够的。它是一套让 Agent 自己管住窗口的装置。

上下文窗口容量有限

装不下的内容写成文件

历史里只留路径与预览

需要时按需取回

三、机制一:装不下的写成文件

先看那条线画在哪。源码里的默认值是 tool_token_limit_before_evict = 20000,配合 NUM_CHARS_PER_TOKEN = 4,也就是 8 万字符。

我造了一个返回固定长度文本的工具,真跑 3 轮看历史里剩下什么:

工具返回 历史里 被换成引用
79000 字符 79000 字符 没有
81000 字符 1231 字符 有
240000 字符 1274 字符 有

线卡得很准。79000 原样进来,81000 就被压下去了,只留 1231 字符,压掉 98.5%。

那 24 万字符哪去了?完整写进了文件,一个字没丢。落盘路径是 /large_tool_results/call_1,240000 字符全在里面。

历史里剩下的那 1274 字符,结构是这样:

部分 内容
开头 一句话说明结果太大,附上落盘路径
中间 提示用 read_file 取回,并给出 offset 与 limit 的用法
结尾 头 5 行加尾 5 行的预览,中间用折叠标记隔开

预览那部分读起来是这样:头 5 行是原始文本的行号与内容,接着一行写着 ... [3876 lines truncated] ...,再跳到 3882 到 3886 行收尾。

路径、取回方法、预览,3 样都在。模型拿到这 1274 字符就知道该去哪取、怎么翻页。

这条线不只管工具。人递进去的一大段文字也会被卸载,阈值是单独的 human_message_token_limit_before_evict = 50000,也就是 20 万字符。我递进去 200058 字符试了一次:

位置 内容长度
递进去 200058 字符
存进文件 200058 字符
state 里那条消息 200058 字符
模型这一轮收到的 1293 字符

前 3 行都是原样。存下来的历史一个字没少,只有模型这一轮看到的换成了路径加预览。这个区分很重要:卸载不是删掉,是让模型这一轮别看见。

四、机制二:到 85% 就压成摘要

卸载是按单条算的,摘要按总量算。源码里的默认参数是:

trigger = ("fraction", 0.85)
keep = ("fraction", 0.10)

上下文涨到模型窗口的 85% 时动手,最近 10% 不动。被摘掉的旧消息写到哪,文档里写着:

Offloaded messages are stored as markdown at /conversation_history/{session_id}.md

每次摘要往同一个文件追加一节,最后攒成一份被逐出消息的运行日志。写完之后,模型收到的内容换成摘要加最近那几条。

这里有个前提:只有模型 profile 带 max_input_tokens 时才用分数。拿不到窗口大小,就退回固定阈值。

摘要之外还有一层兜底。上下文真的溢出时,尾部那批工具结果会被裁:read_file 的结果头切 4000 字符再补一句回原路径的提示,其他工具的结果整个写到 /large_tool_results/ 去。

三层加起来是一道完整的防线,从单条结果、到总窗口、到真溢出。

五、机制三:读的时候也是分片的

前面 2 道都在往外搬,这一道从源头就掐住了。read_file 的默认参数是:

参数 默认值
offset 0
limit 100

一次最多读 100 行。想读后面就自己翻页,模型拿到的是分片,不是整份文件。

这个默认值还能按模型改。harness profile 里有一个把 _DEFAULT_READ_LIMIT 改成 500 的,同一个机制在不同模型上给的窗口不一样。

取回通道自己也有上限,grep 一次最多回 1000 条匹配。

3 道机制的分工可以这么看:

单条结果太大
20000 token

卸载
写成文件

总窗口快满
85%

摘要
旧消息压成一段

每次读文件
100 行

分片
只取要用的那段

六、账:省下来的是上下文预算

机制讲清了,算一笔账。手上有一份 31971 字符、600 行的笔记,约 7997 token。

如果整份写进系统提示词,第一轮就付 7997 token,之后每轮都得带着。跑 12 轮是 95964 token。

如果放成文件,每轮固定在语料上的成本是 0。用到时取:grep 命中 1 行回给模型 52 字符,再 read_file 读 30 行约 395 token,一次任务的增量合计 412 token。

12 轮任务 token
整份写进提示词 95964
放成文件,取 1 处加读 1 段 412

差 233 倍。但别急着高兴,这个倍数只在「只用了其中一小部分」时成立。把整份读一遍是 7997 token,正好等于写进提示词的一轮。

所以这条路改的是付法,不是价格。写进提示词是每轮都付、语料越长越贵;放成文件是读一次付一次、不读不付,而且跟语料总长无关。

工具面本身还有一笔固定的钱,7 个文件工具 2230 token,加 task 是 2729。这笔钱不随语料变化,2000 字和 200 万字都一样。

七、取回通道:为什么字面匹配够用

东西搬出去了,取回来靠 grep。它的底层实现只有一行:

# deepagents/backends/utils.py
if pattern in line:

注释写的是 # Simple substring search for literal matching。不看正则、不分大小写、不切词。工具描述里也用了全力讲清这一点:

Search for a LITERAL text pattern across files (NOT regex).

我拿 4 个文件、415 字符的语料试了 6 组问法,全落空:

你怎么问 命中行数
上下文快满了怎么办 0
空间不足 0
压缩|卸载 0
TOKEN 预算 0
工具结果 会落到 0
输出太多了 0

换成原文里的字,一问就有:压缩历史消息、20000 token、call_abc123 各命中 1 行。能收窄的只有 path 和 glob 2 个参数。

这条边界放在别处是缺陷,放在这里刚好。搬出去的都是 Agent 自己写的东西,措辞它自己定的,想取的时候自然也按自己的写法想。对一份措辞未知的外部语料,这个前提就不成立了。

顺带一句旁证:这套机制不依赖向量检索。11 条依赖里含向量库关键字的 0 条,53 个源文件里 15 个相关关键字零命中。同一批文件里 literal 出现 127 次。

命中

未命中

一个 pattern

逐行做
子串匹配

返回文件与行号

No matches found

能收窄的只有
path 与 glob

八、所以它和向量库各管什么

把上面几节收一下,两条路各管一段:

你的知识是 放哪 为什么
Agent 自己写下的笔记、中间产物 文件系统 措辞自己定的,字面能找到
卸载掉的大段工具结果 文件系统 卸载机制会自动放进去
外部来的大量语料 向量库 字面匹配接不住措辞未知的情况

框架也没拦着你接向量库。StoreBackend 的 search 签名里有一个 query 参数,文档写着「natural language search」。但实测不配 embedding 时它完全不参与排序:3 条记录,拿「今天天气」和「上下文快满了怎么办」去查,返回的都是同一批 3 条。

要语义检索,得自己把索引传进去:

InMemoryStore(index={
    "dims": 1536,
    "embed": embed_fn,
})

这就是整套设计的取向:它只保证自己记的东西找得回来,外部知识怎么检索,接口留着但不替你决定。

本篇所有数字都来自实跑脚本,从阈值对照到那 6 组问法,源码位置也都标在了正文里。

选型上其实只有一条判据:这份知识是不是 Agent 自己写下去的。 自己写的,交给文件系统就够;外面来的,才轮到向量库。