长期记忆:16 个开源项目是怎么做的
本文是「专题·Agent」系列第 6 篇。上一篇讲的短期记忆是”模型此刻看到什么”,这一篇讲的是”这次会话结束后,什么东西能活下来,以及怎么把它找回来”。
一个没有长期记忆的 Agent,每次开新会话都要重新学一遍项目事实、用户偏好、上次踩过的坑。而一个装了错误记忆的 Agent 更糟——它会悄无声息地检索出错的东西
,向量检索却自信地返回一段语义相近的释义;关键词检索又漏掉了所有换过说法的内容。两种失败都是静默的,都在教模型错误的东西,而且都容易被误诊成”模型不行”。能用的检索,是失败模式可见的检索。 这一篇讲三种主要机制、它们怎么失败、以及怎么组合起来让单点失败不至于变成静默的错误答案。同时,我们会横看 16 个开源项目的真实做法——记忆系统是它们之间差异最大的维度之一。
一、“长期记忆”到底是什么
在真实系统里,“长期记忆”通常是四类东西叠在一起:
| 形态 | 适合存 | 更新方式 | 检索方式 |
|---|---|---|---|
| Markdown 文件 | 身份、用户模型、项目规则、Skill 内容 | 人工或策展 | 会话启动时整个读入 |
| 结构化表(+ 全文索引) | 审计日志、会话搜索、按 ID 查 | 只追加 | SQL 查询、全文检索 |
| 向量索引 | 语义召回、释义查询 | 追加 + 嵌入 | k 近邻 |
| 外部提供商 | 跨产品记忆、大规模知识 | API 推送 | API 查询 |
第一列同时也是一个”该不该加这一层”的自检问题:我的 Agent 需要记住的东西,上面几层是不是已经处理了? 如果答案是”没有新东西”,那就不需要这一层。
一个重要的判断:Markdown 文件单独就能撑起一个小型单用户 Agent——一个只有一个用户、一台机器、几条笔记的个人助手,用文件就够了。结构化表在你需要跨会话搜索、审计历史、或扩展到多用户时才变成刚需(这是大多数生产路径)。向量索引和外部提供商是叠在上面的,第一天你几乎都不需要。
这一点在 16 项目横评里体现得很直白
16 个项目里,只有少数几个真正做了跨会话记忆,大多数依赖上下文窗口。这个现象本身就说明——大多数场景可能并不需要复杂的跨会话记忆系统。
二、三种检索范式
你从三种主要技术里挑,然后组合它们:
- 向量检索(Vector search),检索相邻向量。赢在释义、相似的历史问题、概念性问题。输在精确标识符——工单号、错误码、commit hash、函数名。
- 全文检索(Full-text search,BM25 或 FTS5)。赢在 ID、代码、文件名、精确短语。输在释义和概念性查询。
- 策展知识(Curated knowledge) Markdown 页面。赢在知识集有界、值得手工编辑时。输在规模——撑不住几千条事实。
三种范式应该实现同一个接口,这样你可以替换实现或者把它们叠起来:
type Retriever = {
search(query: string, opts: {
tenantId: string; // 不是可选的——见下
topK?: number;
filters?: Record<string, unknown>;
}): Promise<Array<{
id: string; text: string; score: number;
metadata: Record<string, unknown>;
}>>;
};
tenantId过滤不是可选项。检索就是数据访问,一个把 A 用户的记忆召回进 B 用户会话的 Agent,是安全事故,不是 bug。要在存储层强制(拒绝没有租户的查询),而不是在调用点(那里离”忘记加”只有一个 bug 的距离)。
三、混合检索
对大多数 Agent,向量 + 全文一起用,比单独任一个都好。标准的合并方式是倒数排名融合(Reciprocal Rank Fusion,RRF):
flowchart LR
Q["用户/任务查询<br/>+ 租户上下文"] --> FTS["全文检索<br/>租户内查询"]
Q --> Vec["向量检索<br/>租户内查询"]
FTS --> Merge["倒数排名融合 RRF"]
Vec --> Merge
Merge --> Policy["策略过滤<br/>近因、归档、角色"]
Policy --> Inject["注入 prompt 或 tool result"]
// RRF:无需训练数据,奖励在多个列表里都排名靠前的记录
function rrf(rankedLists: Array<Array<{ id: string }>>, k = 60) {
const scores = new Map<string, number>();
for (const list of rankedLists) {
list.forEach((r, idx) => {
scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + idx + 1));
});
}
return [...scores.entries()]
.map(([id, score]) => ({ id, score }))
.sort((a, b) => b.score - a.score);
}
RRF 奖励那些在多个列表里都出现的记录,同时仍然允许一个足够强的信号把单个结果顶上来。它不需要训练,只要两个同构的排名列表。它是整个记忆栈里最便宜的质量提升之一。
一个关键的架构细节
查询时的谓词,不是合并后的过滤。搜索本身就要拒绝返回跨租户的行。任何在合并之后才做的过滤(近因加权、归档拒绝、角色可见性)是策略,不是访问控制。把两者混在一起就是那个经典 bug——一个坐在检索下游的租户过滤器,意味着跨租户的行已经进过内存了,在大多数威胁模型下,这和泄漏是一回事。
四、排序不只是相似度
纯相似度分数经常把错的东西顶上来。一条两年前语义完美匹配的记录,通常不如上周一条稍弱的匹配有用。生产系统在排序时靠三个额外信号:
- 近因(Recency)。相同余弦相似度下,昨天的记录赢过去年的。
- 置信度(Confidence)
user-confirmed(用户确认)的记录,在相同相似度下排在agent-inferred(Agent 推断)之上。 - 访问频率(Access frequency) 30 次的记录,大概率比没人碰过的更有用。追踪
last_accessed_at并折进排序,又便宜又有效。
RRF 之后接一个小的重排器(近因 + 置信度 + 访问频率),对感知质量的提升,往往比换底层向量模型更大。同一个重排器也是你直接拒掉候选的地方
0、置信度是”Agent 推断”、年龄超过 90 天的记录,几乎肯定是噪声——在它进 prompt 之前就丢掉,而不是让模型去判断。五、检索在循环里的位置
检索不是单一的挂载点。三种放置选择,各有取舍——这一条直接呼应上一讲缓存的铁律:
- 放在前缀,会话启动时(冻结),烤进系统提示,整个会话冻结。缓存温热,后续每一轮都便宜。
MEMORY.md、USER.md、Skill 索引、项目上下文用的就是这个。约束,否则击穿缓存。 - 放在易变尾部,作为工具结果
search_memory工具,结果作为 tool message 回来。实时、查询时、无缓存代价(结果本来就在尾部)。最适合那些依赖对话刚发现的东西的查询。 - 会话启动时预取,注入首条用户消息 在循环开始前查外部提供商,结果作为一个围栏块进入尾部。Hermes 的
MemoryManager.prefetch_all()就是这个形状。它是个折中——新鲜数据但不用每轮多一次工具调用,代价是首轮付一次缓存 miss。
六、16 个项目的记忆系统横评
这是 agent开发系列横评 16 个开源项目时,差异最大的维度之一。挑几个有代表性的:
| 维度 | Goose | Hermes | Nano Claw | DeerFlow | Eino |
|---|---|---|---|---|---|
| 专门记忆系统 | ❌ 仅历史检索 | ✅ 完整 | ✅ 文件 | ✅ LLM 自动提取 | ❌ 仅状态快照 |
| 搜索方式 | FTS5 全文 | FTS5 + 外部向量插件 | 无(文件直读) | LLM 语义提取 | 无 |
| 跨会话持久化 | ✅ 查历史 | ✅ 自动提炼+注入 | ✅ CLAUDE.md 文件 | ✅ memory.json | ✅ Checkpoint |
| 注入安全机制 | ❌ | ✅ fence tag 双层防御 | N/A | ✅ <memory> 标签 | ❌ |
| 提炼方式 | 存原始消息 | LLM 提炼事实 | 用户手动 remember | LLM + 30s 防抖批量 | 无 |
| 成本 | 零额外 | 低(FTS5 零依赖) | 零 | 高(每次提炼调 LLM) | 零 |
几个核心差异点:
- Hermes 是唯一把记忆系统当一等公民认真设计的项目 FTS5 + 可选外部语义记忆插件(MEM0/Hunter/Honcho 等 8 种)。它的一个约束很有意思——
memory_manager管理”一个内置提供商 + 至多一个外部提供商”,不允许多个外部提供商。理由是 schema 膨胀、系统提示膨胀、记忆冲突(一个记住喜欢 Python 一个记住喜欢 JS,听谁的?)、用户心智负担。 - DeerFlow 质量最高但成本最高 语义提炼 + 30 秒防抖队列批量处理。
- Nano Claw 最简洁实用 Markdown 文件,人工可读可编辑,用户手动
remember。 - Goose 严格说不是记忆系统,只是历史消息的 FTS5 全文搜索。
一个值得所有系统借鉴的设计(来自 Hermes → Goose/OpenCode):Subdirectory Hint Tracker——压缩时注入当前工作目录信息,几十个 token 的成本,却根除了 Agent 压缩后在错误位置创建文件的常见失败模式。
七、GA 的四层记忆
Datawhale 开源的 Generic Agent(GA)给了一个把记忆分层做到极致的样本,值得单独看。它把记忆比作一座图书馆:
- L1 索引层 = 目录检索台。很小(几百 token),快速路由到任何知识。
- L2 事实层 = 百科全书区。存经过验证的、长期稳定的事实。准入门槛高——必须执行验证过且跨任务可复用。
- L3 SOP 层 = 操作手册区。存可复用的流程知识。准入规则叫 “No Execution, No Memory”(没执行过就不进记忆)——不是”应该这样做”,而是”这样做了,而且成功了”。
- L4 归档层 = 档案室。压缩存档,平时大门紧闭,只在追溯审计时打开。
关键设计有两个:
- 按需路由,不预加载。任务启动只注入元记忆 + L1 索引;LLM 推理时发现需要某类知识,才沿 L1→L2/L3 路由链用
file_read加载。而且路由是通过工具调用和提示策略强制的,不是硬编码流水线——模型自主决定读不读更深层。 - 工作记忆不自动晋升为长期记忆。GA 在最低层(L4)保存原始轨迹,但不会自动把它们提升到 L2/L3。可复用的 SOP 只能通过显式的蒸馏步骤生成。这道”不自动晋升”的闸,是防止记忆污染的核心手段。
这套严格准入机制回答了一个很多人做记忆系统会忽略的问题:记什么不重要,不记什么才重要。如果任何信息都能随意写入长期记忆,错误的经验、过时的配置、一次性的调试信息都会混进去,最终让记忆变得不可信。
小结
- 长期记忆通常是四层叠加 文件 / 结构化表 / 向量索引 / 外部提供商。大多数生产 Agent 落在”Markdown + SQLite+FTS”,释义查询多了才加向量。
- 三种检索范式各有软肋,全文输在释义,策展输在规模。混合检索(RRF)是默认选择。
- 租户隔离要在存储层强制,而且是查询时谓词,不是合并后过滤。
- 排序不只是相似度——近因、置信度、访问频率一起用。
- 记忆系统最难的不是”记什么”,而是”不记什么” + 不自动晋升,是防止记忆污染的关键。
下一篇讲状态、持久化与中断恢复——记忆活到了下一次会话,但一个跑到一半崩溃的任务,怎么从断点接着跑?
本文主要参考
ch06(检索范式/混合检索/排序);agent开发系列「记忆系统」与「16 项目差异对比总览」维度 1(Hermes/DeerFlow/Nano Claw 横评);Datawhale Generic Agent 教程(四层记忆/元记忆/No Execution No Memory)。观点经二次整合,术语保留英文原词。