长期记忆:16 个开源项目是怎么做的

Agent; 长期记忆; 向量检索; RAG; 记忆系统 3254 words 17 min read
This post is not yet available in English. Showing the original.

本文是「专题·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.mdUSER.md、Skill 索引、项目上下文用的就是这个。约束
    ,否则击穿缓存。
  • 放在易变尾部,作为工具结果
    search_memory 工具,结果作为 tool message 回来。实时、查询时、无缓存代价(结果本来就在尾部)。最适合那些依赖对话刚发现的东西的查询。
  • 会话启动时预取,注入首条用户消息
    在循环开始前查外部提供商,结果作为一个围栏块进入尾部。Hermes 的 MemoryManager.prefetch_all() 就是这个形状。它是个折中——新鲜数据但不用每轮多一次工具调用,代价是首轮付一次缓存 miss。

六、16 个项目的记忆系统横评

这是 agent开发系列横评 16 个开源项目时,差异最大的维度之一。挑几个有代表性的:

维度GooseHermesNano ClawDeerFlowEino
专门记忆系统❌ 仅历史检索✅ 完整✅ 文件✅ LLM 自动提取❌ 仅状态快照
搜索方式FTS5 全文FTS5 + 外部向量插件无(文件直读)LLM 语义提取
跨会话持久化✅ 查历史✅ 自动提炼+注入✅ CLAUDE.md 文件✅ memory.json✅ Checkpoint
注入安全机制✅ fence tag 双层防御N/A<memory> 标签
提炼方式存原始消息LLM 提炼事实用户手动 rememberLLM + 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 归档层 = 档案室。压缩存档,平时大门紧闭,只在追溯审计时打开。

关键设计有两个:

  1. 按需路由,不预加载。任务启动只注入元记忆 + L1 索引;LLM 推理时发现需要某类知识,才沿 L1→L2/L3 路由链用 file_read 加载。而且路由是通过工具调用和提示策略强制的,不是硬编码流水线——模型自主决定读不读更深层。
  2. 工作记忆不自动晋升为长期记忆。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)。观点经二次整合,术语保留英文原词。