三层记忆:`memory` 目录的分层与落盘
「三层记忆」这四个字放在 README 的 Key Features 里很好听——原文把它写成「可检视记忆(L1/L2/L3)」(README.md:195-200)。但要真去读代码,光有这句话是没法下手的:三层各存什么?存到哪个目录?谁在写、谁在读?
好消息是这套东西在 DeepTutor 里落得相当实。分层边界不是靠文档约定的,是靠两条 Literal 类型钉死的;落盘位置有明确的目录常量;两个对模型开放的记忆工具,挂载规则还不一样。下面按文件与行号走一遍。
本文对应的仓库快照是 HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。行号会随版本漂,文件名和标识符是稳的。
一、先把三层的边界读出来
模块的自述在 deeptutor/services/memory/__init__.py:1-13:这是一个三层记忆子系统,trace 负责 L1,document 负责 L2 与 L3,另有 ops、paths、ids、store、consolidator 五个模块,consolidator 是把 L1 往 L2、L2 往 L3 归并的那一环。
真正把边界定死的是 deeptutor/services/memory/paths.py:47-56 里的两条类型声明:
| 层 | 定义 | 取值 |
|---|---|---|
| L2 | Surface | chat / notebook / quiz / kb / book / partner / cowriter(7 个) |
| L3 | L3Slot | recent / profile / scope / preferences(4 个) |
这两行是本文最值得你自己去看一眼的地方。它意味着 L2 不是「一堆记忆条目」,而是按来源切成七份——聊天一份、笔记本一份、测验一份、知识库一份、书一份、partner 一份、协作写作一份;L3 也不是「更久远的记忆」,而是四个固定槽位,槽位名字本身就说明了它装什么。
CLI 侧的口径与之对得上。deeptutor_cli/memory.py 只有 99 行、注册了 2 个命令,memory show 的 help 自述 L3 是「四个 global docs」、L2 是「七个 surface docs」(memory.py:25)。命令组的 help 文案是 View and manage lightweight memory.(deeptutor_cli/main.py:36-46)。
顺带一个容易忽略的默认值:memory show [target] 的 target 默认是 L3,不是 L1(memory.py:21-27)。也就是说你不带参数敲这个命令,看到的是最上面那一层归并结果,不是原始事件。
二、清理命令的作用范围是限定的
另一个命令是 memory clear [target],target 默认 all,带 --force/-f(memory.py:63-70)。
这里的 all 需要照字面读一遍:它只删 memory 根目录下的文件,以及 trace、L2、L3 三个子目录里的文件(memory.py:81-90)。范围是明确列举的,不是「把这个目录整个端掉」。这两种语义的差别在什么时候会咬人,取决于你的目录里还有什么,本文不做推断——你要做的是在敲这条命令前,先按下一节的路径把目录列一遍,自己确认删的是哪些东西。
这是本机磁盘上的用户数据,包含学习过程中沉淀下来的内容。清理动作不可逆,--force 更是把确认环节跳过了。
三、落盘落在哪:两个都叫 memory 的位置
运行期目录布局写在 deeptutor/services/setup/init.py:109-141 的 init_user_directories() docstring 里,结构是 data/user/{chat_history.db, logs/, settings/{interface.json, main.yaml, agents.yaml}, workspace/{notebook, memory, co-writer, book, chat/...}}。
记忆落在 workspace/ 下面,和 notebook、co-writer、book 平级。
而 docker-compose.dev.yml(51 行)的可写挂载列的是三项:data/user、data/memory、data/knowledge_bases(docker-compose.dev.yml:17-40)。把两处并排摆一下:docker-compose.dev.yml:17-40 的可写挂载里出现的是 data/memory,而 services/setup/init.py:109-141 的目录树里记忆位于 data/user/workspace/memory;这两个路径是否指向同一份数据,我们没有核实。
本文只负责把这两个位置指给你,谁对谁错、要不要放在一起理解,都不在我们读到的范围内。
四、反直觉的那一处:写常驻挂载,读要过门控
模型能碰到记忆,靠的是两个内置工具,都定义在 deeptutor/tools/builtin/__init__.py:read_memory(:751)和 write_memory(:782)。入参也不对称——read_memory 无参,write_memory 收四个参数 op / text / target_id / reason。
真正反直觉的是它们的挂载规则,写在 deeptutor/agents/_shared/tool_composition.py 里:
write_memory属于恒定挂载(always-on)的 5 个工具之一,另外四个是web_fetch、github、ask_user、cron(tool_composition.py:190-192)。read_memory属于条件挂载,条件是has_memory;这张条件挂载表共 10 条,同表里还有rag/kb_files←has_kb、read_source←has_sources、list_notebook/write_note←has_notebooks等(tool_composition.py:46-57)。
按直觉,读是安全操作、写才该受限,这里恰好相反:写永远在工具面上,读反而要看用户有没有记忆。
这个顺序对着冷启动场景看就顺了:一个全新用户没有任何记忆文档,read_memory 挂上去也读不到东西,而 write_memory 必须常驻,否则第一条记忆永远写不进来。至于门控本身,user_has_memory() 与 user_has_notebooks() 两个函数都是 fail closed 的,异常时返回 False(tool_composition.py:215-249)。翻译成排查语言就是:门控出错的表现不是报错,而是 read_memory 悄悄没挂上。你看到的现象会是「它明明有记忆却不去读」,而不是一条异常。
同一个文件里还有两个更强的开关:forced 绕过所有门控直接追加工具,suppressed 则不论工具怎么进来都把它移除(tool_composition.py:149-155、196-201)。partner runtime 就是用 suppressed 把 chat 的记忆工具换掉的。所以「为什么这个场景下模型没有记忆工具」这个问题,答案可能在门控,也可能在 suppressed,得两处都看。
记忆还会占上下文预算:deeptutor/agents/chat/context_budget.py:33-51 把 PromptBlock 映射成 13 个展示段,memory 是其中之一,和 system_prompt、persona_style、tool_manifest、sources、skills 等并列。这两个工具的提示词 hint 也各自有文件——hints/{en,zh}/ 下各 19 个 YAML,文件名与工具名一一对应,read_memory 与 write_memory 都在其中。
五、6332 行是怎么摆的:归并被拆成四个 mode
deeptutor/services/memory/ 共 28 个文件、6332 行 Python。放在 services 的 26 个子领域里排第四,前面是 llm(9263)、rag(7539)、session(6677)。
consolidator/ 下有四个 mode(update/audit/dedup/merge),这四个 mode 文件合计约 1645 行,另配 14 个 YAML 提示词;memory 目录内部各子目录的行数分布我们没有统计,所以这里不排「哪一块最厚」。单看文件的话,consolidator/modes/update.py 有 706 行,是整个 services 层行数 Top 15 表里的最后一名。
从目录结构本身能看出来的只有一件事:从 L1 往上归并的那一步被拆成了四个独立的 mode,去重、合并、审计各占一个。四个 mode 同目录下另有 14 个 YAML 提示词文件;这些提示词具体怎么参与归并、四个 mode 是否都要调用模型,我们没有读提示词内容,不下结论。
必须说清边界:这四个 mode 与 14 个提示词,我们只看了文件名与目录结构,没有读提示词内容,也没有核实 L1→L2→L3 的具体算法。所以本文不描述”它是怎么决定要不要合并两条记忆的”,那一层我们没有依据。
六、v1 已经删掉了:唯一入口是 get_memory_store
memory/__init__.py:11-12 有一句原样写在 docstring 里的话:v1 那个两文件版的 MemoryService 已经没了,所有调用方都走 get_memory_store。
这句话对读源码的人有直接价值:你在网上或旧文里看到的 MemoryService 用法,在这个快照里已经不成立,别照着找。
与之配套的还有一处迁移动作。API 服务的 lifespan 启动序列的最后一项,是迁移 v1 memory、并把 legacy 的 tutorbot memory surface 改名为 partner(deeptutor/api/main.py:114-170)。回头看第一节那张 Surface 表里的 partner,它的历史名字就是 tutorbot。
七、谁在用它
三个可核查的数字,摆在一起看比单看有意义:
deeptutor/api/层引用deeptutor.services.memory共 23 处,在 api 引用最多的 services 子模块里并列第二(config34、rag23、memory23)。agents+core+tools三层合计引用 memory 8 处。- REST 侧
/api/v1/memory有 27 条路由。
前端路由也给了它相当的位置:(utility)/memory 下共 8 个页面路径——/memory、/memory/graph、/memory/l1、/memory/l2、/memory/l2/[surface]、/memory/l3、/memory/l3/[slot]、/memory/resolve;另外 settings 的 26 个子页里也有一个 memory(连 /settings 自身共 27 页)。注意 /memory/l2/[surface] 与 /memory/l3/[slot] 这两个动态段,正好对应第一节那两条 Literal 的取值。
这三组数字合起来说明的是:记忆子系统主要由 API 层与前端驱动,agent 侧只通过那两个工具接触它。排查时的方向也就清楚了——「记忆没写进去」优先看工具与门控,「记忆页显示不对」优先看 API 路由那 27 条。
八、「memory」这个词在这个仓库里指三种东西
这是搜代码时最容易串味的一处,单独拎出来说:
- 记忆子系统:
deeptutor/services/memory/,就是本文讲的这个。 - 进程内存:
deeptutor/runtime/memory_probe.py(343 行)观测进程树的常驻内存供状态展示,memory_reclaim.py(73 行)在高分配任务之后做尽力而为的内存回收。这两个和 L1/L2/L3 毫无关系。 - 调用点上的内存回收:BookEngine 的后台 worker 在队列空时会注销 runtime 并调用
schedule_memory_reclaim()(book侧engine.py:833-852)——名字里有 memory,做的是第 2 类的事。
所以在这个仓库里 grep -rn memory 会同时命中三类东西。要找记忆子系统,直接按 services.memory、get_memory_store、read_memory、write_memory、L3Slot、Surface 这些标识符搜,比搜 memory 干净得多。
九、你可以照着核的六步
- 打开
deeptutor/services/memory/paths.py:47-56,把Surface的 7 个值和L3Slot的 4 个值抄下来——后面所有路径和路由的动态段都从这两条来。 - 打开
deeptutor/services/memory/__init__.py:1-13,确认七个模块的分工,以及第 11-12 行那句 v1 已移除的说明。 - 打开
deeptutor/agents/_shared/tool_composition.py,先在:190-192确认write_memory在 always-on 的 5 个里,再在:46-57确认read_memory挂在has_memory上,最后看:215-249的 fail closed。 - 分别打开两处与落盘相关的位置:
deeptutor/services/setup/init.py:109-141的目录树、docker-compose.dev.yml:17-40的 volumes,看清各自写的是哪个路径。 - 敲
deeptutor memory show之前先意识到 target 默认是L3;敲clear之前先按memory.py:81-90核清删除范围。上述命令语义均来自源码阅读,以官方文档与--help的实际输出为准。 - 想看归并逻辑,去
deeptutor/services/memory/consolidator/modes/下的四个文件与同目录的 YAML 提示词——那部分我们没读,需要你自己看。
最后交代边界:本文只读了 memory 目录的模块 docstring、paths.py 的类型声明、CLI 的命令定义与工具挂载表,store.py、ops.py、document.py、trace.py 的实现体与 consolidator 的算法都没有读;/api/v1/memory 那 27 条路由只统计了数量,没有逐条核实语义。凡是涉及”归并出来的内容质量如何""记忆对学习过程有什么效果”的部分,本文一律不下结论——那既超出我们读到的范围,也不是源码能回答的问题。文中出现的槽位划分、目录布局与挂载规则,都是这个快照里的实现选择,不是经过验证的通用结论。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。