DeepAgents(四):虚拟文件系统:管的是自己的上下文
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 道机制的分工可以这么看:
六、账:省下来的是上下文预算
机制讲清了,算一笔账。手上有一份 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 次。
八、所以它和向量库各管什么
把上面几节收一下,两条路各管一段:
| 你的知识是 | 放哪 | 为什么 |
|---|---|---|
| Agent 自己写下的笔记、中间产物 | 文件系统 | 措辞自己定的,字面能找到 |
| 卸载掉的大段工具结果 | 文件系统 | 卸载机制会自动放进去 |
| 外部来的大量语料 | 向量库 | 字面匹配接不住措辞未知的情况 |
框架也没拦着你接向量库。StoreBackend 的 search 签名里有一个 query 参数,文档写着「natural language search」。但实测不配 embedding 时它完全不参与排序:3 条记录,拿「今天天气」和「上下文快满了怎么办」去查,返回的都是同一批 3 条。
要语义检索,得自己把索引传进去:
InMemoryStore(index={
"dims": 1536,
"embed": embed_fn,
})
这就是整套设计的取向:它只保证自己记的东西找得回来,外部知识怎么检索,接口留着但不替你决定。
本篇所有数字都来自实跑脚本,从阈值对照到那 6 组问法,源码位置也都标在了正文里。
选型上其实只有一条判据:这份知识是不是 Agent 自己写下去的。 自己写的,交给文件系统就够;外面来的,才轮到向量库。