claude-mem 记忆分层与检索机制:从原理到工程落地实践

发布时间:2026/10/26 15:46:31
claude-mem 记忆分层与检索机制:从原理到工程落地实践

1. 从零认识 claude-mem:它到底在解决什么痛点

第一次看到claude-mem这个名字,很多人会下意识以为它又是一个"给 Claude 加记忆"的第三方插件。这个理解方向没错,但太浅了。真正用过一段时间之后你会发现,它要解决的核心问题其实不是"让模型记住更多",而是"让模型在正确的时机想起正确的事"。

这两者差别巨大。前者是堆容量,后者是调度。做过对话系统或者长期陪伴类产品的人应该都有体会:上下文窗口从 8K 涨到 200K,看起来能塞的东西多了几十倍,但实际体验并没有线性变好。原因很简单——塞进去的内容越多,噪声越大,模型抓重点的能力反而会被稀释。你给它灌了三个月的聊天记录,它可能连你昨天说过"我不吃香菜"都记不住,因为这句话被淹没在几万字的闲聊里了。

claude-mem的思路是把记忆做成一个分层、可检索、可衰减的结构,而不是一坨平铺的文本。它把对话中产生的信息拆成不同粒度的"记忆单元",给每个单元打上时间戳、重要度、关联标签,然后在需要的时候按相关性召回。这套机制听起来像 RAG,但比通用 RAG 更聚焦——它专门为"个人长期对话"这个场景做了优化。

适合谁来研究这个东西?三类人最该看:一是做 AI 陪伴、AI 助手类产品的开发者,二是想给自己搭一个"记得住事"的私人助理的重度用户,三是单纯对"记忆机制"这个方向感兴趣、想搞明白工程上怎么落地的人。如果你只是想随便玩玩对话,那这东西对你价值不大;但如果你真的想让 AI 在三个月后还能记得你提过的那件小事,那claude-mem值得花时间。

我下面会从设计动机、核心结构、落地步骤、踩坑经验几个角度把它拆开讲,尽量把"为什么这么设计"讲透,而不是只丢一堆配置让你抄。

2. 记忆分层机制:为什么不能把所有东西都塞进上下文

2.1 上下文窗口的"边际效用递减"现象

先讲一个我实测过的现象。假设你有一个持续对话的助手,把最近 50 轮对话全部塞进上下文,和只塞最近 10 轮加上一份"历史摘要",哪个效果好?直觉上肯定是前者信息更全。但实测下来,后者在"回答是否切题"这个维度上反而更稳。

原因在于注意力机制的特性。Transformer 在处理长上下文时,注意力权重会被稀释,尤其是当上下文里存在大量语义相近但实际无关的内容时,模型很容易"抓错重点"。这不是模型不行,而是信息密度的问题。50 轮对话里可能只有 3 句话是真正重要的,剩下 47 轮都是"嗯""好的""然后呢"这种填充。

claude-mem的分层机制就是冲着这个来的。它把记忆分成几个层级,每层的存储策略和召回策略都不一样:

层级存储内容保留周期召回方式
瞬时层当前会话的原始对话会话结束即压缩直接进上下文
工作层近期关键事实、待办、偏好数天到数周按时间+关键词召回
长期层稳定画像、重要事件、关系长期语义检索+重要度加权
归档层低频但可能有用的事实永久仅在明确触发时召回

这个分层的核心逻辑是:不同时效性的信息,用不同的成本和精度去管理。瞬时层追求完整,工作层追求及时,长期层追求准确,归档层追求不丢。如果全部用一套机制处理,要么成本爆炸,要么精度崩盘。

2.2 记忆单元的粒度设计

粒度是另一个容易被忽略的点。很多人做记忆系统时,习惯以"一轮对话"为单位存储,这其实很粗糙。一轮对话里可能包含多个独立事实,也可能一个事实都没有。以轮为单位存,召回时要么召回一堆噪声,要么漏掉关键信息。

claude-mem的做法是把对话拆成原子事实。比如用户说"我下周三要去上海出差,顺便想见一下老同学,他叫张伟,在浦东做芯片设计",这一句话里至少能拆出四个原子事实:

  • 用户下周三去上海出差(时间+事件)
  • 用户想见老同学(意图)
  • 老同学叫张伟(人物)
  • 张伟在浦东做芯片设计(人物属性)

拆成原子事实的好处是召回精度高。当用户后面问"我下周那个行程安排得怎么样了",系统只需要召回"下周三去上海出差"这一条,而不是把整轮对话都拖出来。同时,原子事实之间可以建立关联,形成一个小型知识图谱,这样"张伟"这个实体就能和"上海出差"这个事件挂上钩。

拆解的粒度需要控制。太粗了召回不准,太细了会产生大量碎片,维护成本高。我的经验是:一个原子事实应该是一个可以独立成立、独立被引用的陈述。如果一句话拆出来的两个片段必须一起看才有意义,那就不该拆。

2.3 重要度评分与衰减曲线

不是所有记忆都值得长期保留。用户随口说的一句"今天天气不错"和"我对花生过敏",重要度天差地别。claude-mem用一套评分机制来区分,大致考虑这几个维度:

  • 显式标记:用户明确说"记住这个"的,直接拉满
  • 情感强度:带有强烈情绪的内容,权重更高
  • 重复出现:多次提到的信息,说明重要
  • 实体密度:包含人名、时间、地点等具体实体的,权重更高
  • 时效性:带明确时间点的,过期后自动降权

评分之后还要配一条衰减曲线。这里有个反直觉的点:衰减不应该是线性的。线性衰减会导致所有记忆在某个时间点集体失效,体验很割裂。更合理的是指数衰减加上"重要度地板"——重要度高的记忆衰减到一定程度就不再降,保持一个最低召回概率。

我自己的配置里,长期层的重要度地板设在 0.3 左右,也就是说即使一条记忆很久没被用到,只要它当初评分够高,仍然有 30% 的基础召回概率。这个值调太低会导致重要信息丢失,调太高会导致噪声堆积,0.3 是我试了几个月比较舒服的平衡点。

3. 检索与召回:让模型"想起来"的完整链路

3.1 召回触发时机的判断

记忆系统最容易翻车的地方不是存储,而是什么时候去查记忆。查得太频繁,每轮对话都检索一遍,延迟高、成本高,而且容易把无关记忆塞进上下文干扰模型;查得太少,该想起来的时候想不起来,用户会觉得"你这助手怎么这么健忘"。

claude-mem的触发策略是组合式的,大致分三类:

第一类是显式触发。用户说"你还记得吗""上次我说的那个""我之前提过的",这类信号直接触发检索,优先级最高。

第二类是实体触发。当前对话里出现了已知实体(人名、项目名、地点),自动去关联记忆里查这个实体的相关事实。比如用户提到"张伟",系统就去查所有跟张伟有关的记忆。

第三类是语义触发。当前对话的语义向量和历史记忆做相似度匹配,超过阈值就召回。这一类最容易误触发,所以阈值要设得保守一些。

实际跑下来,显式触发和实体触发的准确率最高,语义触发需要配合重要度过滤才靠谱。我一般会把语义触发的阈值设在 0.75 以上,低于这个值的候选直接丢弃,宁可漏召回也不要错召回。

3.2 向量检索与关键词检索的混合策略

纯向量检索有个老问题:对精确匹配不敏感。用户问"我那个尾号 3721 的订单",向量检索可能给你返回一堆"订单相关"的记忆,但就是找不到尾号 3721 的那条。这时候关键词检索就派上用场了。

claude-mem用的是混合检索,两路并行然后融合排序:

def hybrid_retrieve(query, top_k=10): # 向量路 vec_results = vector_search(query, top_k=top_k*2) # 关键词路 kw_results = keyword_search(query, top_k=top_k*2) # 融合:RRF (Reciprocal Rank Fusion) scores = {} for rank, item in enumerate(vec_results): scores[item.id] = scores.get(item.id, 0) + 1/(60 + rank) for rank, item in enumerate(kw_results): scores[item.id] = scores.get(item.id, 0) + 1/(60 + rank) # 叠加重要度权重 for item_id in scores: scores[item_id] *= get_importance(item_id) return sorted(scores.items(), key=lambda x: -x[1])[:top_k]

RRF 这个融合方法的好处是不需要调权重,两路结果直接按排名倒数相加,工程上很省心。后面再乘一个重要度系数,让高重要度的记忆在同等相关性下优先出线。

注意:关键词检索的分词要针对中文做处理。直接用空格分词对中文完全无效,得用 jieba 之类的分词器,或者干脆用字符级 n-gram。我一开始偷懒用了英文分词逻辑,结果中文记忆几乎检索不出来,排查了半天才发现是分词的问题。

3.3 召回结果的去重与压缩

检索出来 10 条记忆,直接全塞进上下文吗?不行。这 10 条里可能有 3 条说的是同一件事,还有 2 条是过期的。直接塞进去既浪费 token 又干扰模型。

去重这块,claude-mem用的是语义相似度加实体重叠的双重判断。两条记忆如果语义相似度超过 0.9,且涉及的实体高度重叠,就判定为重复,保留时间更新或重要度更高的那条。

压缩则是把多条相关记忆合并成一段简洁的陈述。比如召回的三条记忆分别是"用户下周三去上海""用户要见张伟""张伟在浦东做芯片",压缩后变成"用户下周三去上海出差,计划见在浦东做芯片设计的老同学张伟"。这样一段话比三条独立记忆更连贯,模型理解起来也更顺。

压缩这一步我建议用一个小模型来做,不要用主模型。因为压缩是纯文本处理任务,不需要太强的推理能力,用小模型能省不少成本,延迟也低。实测下来,7B 级别的模型做这种压缩已经够用了。

4. 落地实操:从环境准备到跑通第一条记忆

4.1 存储选型:为什么我最终选了 SQLite + 向量扩展

存储这块有很多选择,Postgres + pgvector、Milvus、Qdrant、Chroma 都能用。我一开始用的是 Chroma,图它上手快,但跑了一段时间后换成了 SQLite + sqlite-vec。

换的原因有三个。第一,claude-mem这个场景的数据量其实不大,个人使用的话,几万条记忆顶天了,SQLite 完全扛得住,没必要上分布式向量库。第二,记忆数据里既有向量又有关联关系(实体图谱),SQLite 的关系查询能力比纯向量库强很多,做实体关联查询很方便。第三,部署简单,一个文件搞定,备份就是复制文件,不用维护额外的服务。

# 安装 sqlite-vec 扩展 pip install sqlite-vec # 建表 sqlite3 memory.db <<EOF CREATE TABLE memories ( id INTEGER PRIMARY KEY, content TEXT NOT NULL, embedding BLOB, importance REAL DEFAULT 0.5, created_at INTEGER, last_accessed INTEGER, layer TEXT DEFAULT 'working' ); CREATE VIRTUAL TABLE vec_memories USING vec0( embedding float[768] ); EOF

向量维度 768 是配合常用的中文 embedding 模型选的。如果你用的是 1024 维的模型,把float[768]改成对应维度就行。维度不要随便改,改了要重新生成所有向量。

4.2 记忆写入的完整流程

写入不是简单地把文本存进去就完事,中间有好几步处理。完整流程是这样的:

  1. 对话截获:从对话流里拿到新一轮的用户输入和模型回复
  2. 事实抽取:用 LLM 从这轮对话里抽出原子事实,输出结构化 JSON
  3. 重要度评分:对每条事实打分
  4. 向量化:把事实文本转成向量
  5. 去重检查:和已有记忆比对,重复的走更新逻辑
  6. 入库:写入 memories 表和 vec_memories 表
  7. 关联建立:抽取实体,更新实体-记忆的关联表

事实抽取这一步的 prompt 很关键。我试过好几个版本,最后稳定下来的是这个结构:

EXTRACT_PROMPT = """从以下对话中抽取原子事实。每条事实必须: 1. 独立成立,不依赖上下文 2. 包含明确的实体(人、时间、地点、事物) 3. 用陈述句表达 输出 JSON 数组,每个元素包含: - content: 事实内容 - entities: 涉及的实体列表 - time_ref: 时间引用(如有) - confidence: 置信度 0-1 对话: {conversation} """

这里有个坑:模型很容易把"用户问了什么"也当成事实抽出来。比如用户问"上海明天天气怎么样",模型可能抽出一条"用户关心上海天气"。这条其实不算稳定事实,应该过滤掉。我的做法是在 prompt 里明确说"只抽取关于用户的稳定事实,不要抽取用户的提问本身"。

4.3 召回链路的接入点

召回要接在模型生成之前。具体来说,在把用户输入发给主模型之前,先跑一遍召回,把召回的记忆拼成一段"背景信息"塞进 system prompt 或者作为前置上下文。

def build_context(user_input, session_history): # 1. 召回相关记忆 memories = hybrid_retrieve(user_input, top_k=8) # 2. 去重压缩 compressed = compress_memories(memories) # 3. 拼接上下文 context = f"""以下是关于用户的已知信息,供参考: {compressed} 当前对话历史: {session_history} 用户最新输入:{user_input} """ return context

拼接的位置有讲究。放在 system prompt 里会让模型把它当成"人设"的一部分,放在对话历史前面会让模型把它当成"参考资料"。我倾向于后者,因为记忆是动态的,放 system 里容易和固定人设混淆。

提示:召回的记忆不要超过 8 条,压缩后控制在 500 字以内。超过这个量,模型反而会开始忽略部分内容。宁可少而精,不要多而杂。

5. 实测踩坑:那些文档里不会写的经验

5.1 记忆污染:错误事实一旦写入就很难清除

这是我最惨痛的一次教训。早期测试的时候,我随口跟助手说了句"我住在北京",其实我住上海,就是测试一下。结果这条错误记忆被写进长期层,后面好几周助手都以为我住北京,每次聊到天气、通勤都往北京上靠。

问题在于,记忆系统默认是"信任写入"的,它不会去验证用户说的话是真是假。而且一旦写入,后面用户再说"我住上海",系统会当成两条独立记忆并存,而不是覆盖。结果就是模型看到两条矛盾信息,随机选一条用。

解决办法有两个。一是在写入时做冲突检测:新记忆和已有记忆如果涉及同一实体同一属性但值不同,标记为冲突,走人工确认或者以最新为准。二是在召回时做时效加权:同等条件下优先召回时间更新的记忆。

我现在用的是组合方案:冲突检测标记 + 时间加权召回 + 定期人工审查。每周花十分钟过一遍冲突列表,把明显的错误记忆删掉。这个习惯救了我很多次。

5.2 召回延迟:别让记忆查询拖慢首字响应

记忆检索是同步操作,如果检索慢,用户会明显感觉到"这助手反应怎么这么慢"。我一开始没注意,向量检索加上 LLM 压缩,一轮下来多了 1.5 秒延迟,体验很差。

优化思路是分级响应。快速路径只做向量检索,不做压缩,直接返回原始记忆,延迟控制在 200ms 以内;慢速路径做完整检索加压缩,但异步执行,结果用于下一轮对话。

# 快速路径:只检索不压缩 def fast_retrieve(query): return vector_search(query, top_k=5) # 慢速路径:完整处理,异步 async def slow_retrieve(query): memories = hybrid_retrieve(query, top_k=10) return await compress_memories_async(memories)

这样首字响应不受影响,用户感知不到记忆系统的存在,但下一轮对话时记忆已经准备好了。实测下来,这个方案把感知延迟从 1.5 秒降到了几乎为零。

5.3 实体消歧:同名不同人怎么区分

"张伟"这个名字,用户可能认识三个。如果系统不做消歧,把三个张伟的信息混在一起,召回时就会张冠李戴。

claude-mem的消歧靠的是上下文指纹。每个实体除了名字,还记录它出现的上下文特征:关联的其他实体、出现的时间段、涉及的话题。当新提到"张伟"时,用当前对话的上下文去匹配已有的实体指纹,选最匹配的那个。

如果匹配度都不高,就创建一个新的实体条目,标记为"待确认"。下次用户再提到时,如果上下文能对上,就合并;对不上,就保持独立。这个机制不完美,但比粗暴地按名字合并要好得多。

我自己的经验是,对于高频出现的名字(比如家人、同事),手动确认一次实体归属,后面就稳了。对于低频名字,让它自动处理,偶尔出错也不影响大局。

5.4 记忆膨胀:三个月后数据库里全是垃圾

跑久了必然遇到这个问题。三个月下来,数据库里堆了几万条记忆,大部分是"用户说了句好的""用户表示同意"这种无意义内容。这些垃圾记忆不仅占空间,还会在召回时干扰排序。

清理策略我试过几种,最后稳定下来的是定期归档 + 重要度阈值。每周跑一次清理任务,把重要度低于 0.2 且 30 天内未被访问的记忆移到归档层。归档层的记忆不参与常规召回,只在显式查询时才会被翻出来。

def archive_stale_memories(): cutoff = now() - 30 * 24 * 3600 db.execute(""" UPDATE memories SET layer = 'archived' WHERE importance < 0.2 AND last_accessed < ? AND layer != 'archived' """, (cutoff,))

这个阈值可以根据自己的使用频率调。用得频繁的话,30 天可以缩短到 14 天;用得少的话,可以放宽到 60 天。核心原则是:不常用的低价值记忆,不该占用常规召回的带宽。

6. 进阶玩法:让记忆系统真正"活"起来

6.1 记忆的主动遗忘机制

前面讲的都是怎么记住,但一个健康的记忆系统必须会忘。人脑的遗忘不是 bug,是 feature——它帮我们过滤噪声,保留重点。AI 记忆系统也一样,什么都记的结果就是什么都记不牢。

主动遗忘有两种触发方式。一种是被动衰减,前面讲的重要度衰减曲线就是干这个的。另一种是主动删除,当用户明确说"忘掉这个"或者系统检测到某条记忆长期造成负面影响时,直接删除。

主动删除要谨慎,因为删了就真没了。我的做法是软删除——标记为 deleted,但不物理删除,保留 30 天冷静期。30 天内如果发现删错了,可以恢复;30 天后才真正物理清除。这个缓冲期救过我好几次。

6.2 记忆的跨会话迁移

如果你在多个设备或多个会话里用同一个助手,记忆需要能跨会话共享。这块的难点不是同步本身,而是冲突解决。设备 A 上更新了一条记忆,设备 B 上也更新了同一条,合并时听谁的?

claude-mem用的是版本向量 + 最后写入优先的混合策略。每条记忆带一个版本号,同步时比对版本,版本高的覆盖版本低的。如果版本相同但内容不同(说明是并发写入),走冲突解决逻辑,通常是保留两条并标记冲突,等下次用户交互时确认。

实际用下来,跨设备冲突其实很少发生,因为大部分时候用户是单设备操作。但一旦发生,处理不好会很烦。我建议如果只是个人使用,干脆用单一数据源(比如放在一台常开的机器上),其他设备通过接口访问,从根上避免同步问题。

6.3 记忆可视化:看见系统在想什么

调试记忆系统最痛苦的是"看不见"。你不知道系统到底记了什么、召回了什么、为什么这么召回。所以做一个简单的可视化面板非常有必要。

我搭了一个极简的 Web 面板,三个视图:记忆列表(按重要度和时间排序)、召回日志(每次对话召回了哪些记忆、得分多少)、实体图谱(实体之间的关联关系)。有了这个面板,排查问题效率提升好几倍。

# 召回日志记录 def log_retrieval(query, results, scores): db.execute(""" INSERT INTO retrieval_logs (query, result_ids, scores, timestamp) VALUES (?, ?, ?, ?) """, (query, json.dumps([r.id for r in results]), json.dumps(scores), now()))

这个日志表跑久了会很大,记得定期清理,只保留最近 30 天的记录就够了。老日志的价值不大,排查问题主要看近期的。

6.4 和主模型的协作边界

最后讲一个容易被忽略的点:记忆系统和主模型的职责边界。记忆系统负责"提供信息",主模型负责"使用信息"。不要让记忆系统去做推理判断,也不要让主模型去管理记忆。

具体来说,记忆系统只做三件事:存、查、排。它不判断"这条信息该不该用",只负责把最相关的信息按顺序递上去。用不用、怎么用,是主模型的事。这个边界划清楚之后,两边的逻辑都简单了很多,调试也容易定位问题。

我见过一些实现,把"这条记忆是否适合当前场景"的判断也塞进记忆系统,结果逻辑越写越复杂,最后变成一个四不像。记住:记忆系统是图书馆管理员,不是作家。它负责把书找出来,怎么读是读者的事。

这套东西我陆陆续续调了小半年,从最初的"什么都记"到现在的分层调度,中间踩的坑基本都写在上面了。如果你刚开始搭,建议先从最简单的 SQLite + 向量检索跑通,别一上来就搞复杂的分层和衰减,等基础链路稳了再逐步加机制。记忆系统这东西,复杂度是慢慢长出来的,不是设计出来的。