跳到主要内容

三层记忆

这章讲什么: DeepTutor 怎么把「和你聊过什么」沉淀成「关于你的长期画像」。它的记忆不是向量库,而是人类可读、可审计、可手改的 markdown 文档,靠 LLM 一层层往上整理。

1. 它要解决的小问题

LLM 没有跨会话记忆。要让 AI 老师「记住你」,得有个地方存「你是谁、你偏好什么、你学到哪了」,而且这个存储最好:能被人看懂、能被审计(每条记忆从哪来)、能手动修正——而不是一坨黑盒 embedding。

2. 思路 / 直觉:把原始流一层层蒸馏

DeepTutor 把记忆分三层(deeptutor/services/memory/__init__.py 顶部 docstring):

是什么形态怎么来
L1原始事件捕获append-only JSONL,每个表面每天一个文件各表面(chat/notebook/partner…)实时 append
L2表面摘要脚注引用的 markdownLLM 从 L1 增量整理
L3长期画像脚注引用的 markdown(4 个 slot)LLM 从 L2 整理

直觉:L1 像流水账,L2 像每条线(聊天/笔记)的小结,L3 像一份关于你的稳定档案。 越往上越浓缩、越稳定。

L3 的四个 slot:recent(近期小结)、profile(用户画像)、scope(知识范围)、preferences(偏好)——其中 preferences 不自动整理,只由聊天里的 write_memory 工具写(store.py:123-142store.py:173)。

3. 核心机制

3.1 统一门面 MemoryStore

所有调用方(API 路由、LLM 工具、事件钩子)都走 MemoryStore(deeptutor/services/memory/store.py:47)。它无状态——按进程级单例用即可(get_memory_store,store.py:393);per-user 隔离paths.memory_root 懒解析 PathService(多用户部署时按用户分目录)。

写操作按文件路径加 asyncio 锁(_lock_for,store.py:277),并用 _atomic_write(写 .tmpreplace)保证原子性(store.py:414)。

3.2 L1 trace:永不拖累产生方

L1 的铁律:捕获绝不能弄崩产生它的表面——每次 append 都包在 try 里、失败只记日志吞掉(deeptutor/services/memory/trace.py:1-7 docstring)。写入按表面加锁串行,避免同进程多回合的 JSON 行交错。

3.3 记忆文档格式:脚注引用

L2/L3 是这样的 markdown(deeptutor/services/memory/document.py 顶部 docstring):

# <Title>

## <section_a>
- <一条记忆文本> [^1][^2] <!--m_xxx-->
- <另一条> [^1] <!--m_yyy-->

---

[^1]: notebook:abc
[^2]: chat:def

要点:

  • 脚注标号是整数,按首次出现顺序分配;两条记忆引用同一来源 → 共享标号,渲染时去重。
  • 每条 bullet 末尾的 HTML 注释 <!--m_xxx-->条目 id 锚点,能跨往返存活,被 audit/dedup 和 DELETE /entry/{id} 用。
  • 解析/序列化是纯函数——无 I/O、无 LLM,serialize(parse(x)) 幂等(document.py docstring)。这让记忆可被程序安全读改写。

3.4 整理(consolidation):基于 id 差集的增量

「整理」就是 L1→L2、L2→L3 的 LLM 驱动蒸馏。它有三种模式(deeptutor/services/memory/consolidator/__init__.py docstring):update(增量抽取新事实)、audit(对着原始证据逐行核对修订)、dedup(全文去重)。

update 模式的算法(deeptutor/services/memory/consolidator/modes/update.py 顶部 docstring):

  1. *.meta.json 里记的「上次见过的 id 集合」做差集,算出「自上次以来的新输入」。
  2. 按时间(旧→新)拼接新输入。
  3. chunk_with_boundary 把拼接切成 ≤ 预算的块,绝不在段落(或句子)中间切
  4. 每块:LLM 调用 → 解析出事实 → 用引用池过滤 → append 进内存里的 Document
  5. 原子刷盘 + 更新 *.meta.json
  6. 若配了 auto_after_update,顺手跑一遍 dedup。

精妙处:append 复用 ops.AddOp 的 apply 路径,让「id 分配 / 校验 / 序列化时重建脚注」这些不变量集中在一处,整理逻辑不用各自重新发明(update.py docstring)。

为什么用 id 差集而不是「整文重写」: 这样整理是增量的——只看新事件,旧结论不动;而且即使进程崩了重启,文档是按步原子写的、meta 的 id 差集仍给出「自上次以来新增了什么」的正确性(consolidator/runs.py docstring 解释了为什么用内存管理器而非 DB)。

3.5 可取消、可重连的整理「run」

一次整理是一个「run」,由一个 asyncio 任务持有;事件流经一个带缓冲的环,断线的客户端能用 since=<cursor> 重连补播漏掉的事件(runs.py docstring)。同一 (layer, key) 同时只允许一个 active run,起第二个返回 RunBusyError

4. 巧妙之处

  • 记忆即文档,可审计可手改。 每条记忆都带来源脚注(chat: / notebook: / partner: …)和 id 锚点,网页工作台能预览 ops、两步「preview → apply」后再落盘(store.py:144,apply_ops_payload)。
  • 平滑迁移历史格式。 解析器同时认新格式(<!--m_xxx--> 注释 + 整数脚注)和旧格式(bullet 末尾 [^m_xxx]),让老文档继续工作到下次保存自动迁移(document.py docstring)。
  • 启动期数据迁移。 migrate_v1_if_needed 把 v1 的两文件记忆挪进 backup;migrate_partner_surface_if_needed 把旧的 tutorbot 表面重命名为 partner,连 L2 文档里的 tutorbot: 引用前缀和散文里的词都改掉,且幂等(store.py:289store.py:318)。

5. 边界与局限

  • 整理依赖 LLM,质量随模型波动;guards.py 有 banned-phrase 过滤兜底(consolidator/__init__.py docstring 列了 _filter_banned)。
  • 整理 run 是内存态,崩溃即丢——这是有意的取舍(run 最多分钟级,文档本身已原子落盘)(runs.py docstring)。
  • 检索是「把整层文档拼起来给模型看」(read_l3_concat,store.py:72),不是向量召回——文档很大时会直接占上下文,靠分层浓缩来控规模。

6. 代码地图

主题文件符号
统一门面deeptutor/services/memory/store.pyMemoryStoreget_memory_storeread_l3_concatapply_ops_payload
L1 原始事件deeptutor/services/memory/trace.pyTraceEventappend
文档格式(纯函数)deeptutor/services/memory/document.pyparseserializeDocument
增量整理deeptutor/services/memory/consolidator/modes/update.pyupdate 模式;chunk_with_boundary
整理 run 管理deeptutor/services/memory/consolidator/runs.pyrun 注册表、RunBusyError
迁移deeptutor/services/memory/store.pymigrate_v1_if_neededmigrate_partner_surface_if_needed