首页 / 资讯中心 / 文章详情

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

claude-mem 记忆分层与检索机制:从原理到工程落地实践 ★ FEATURED ARTICLE
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_k10): # 向量路 vec_results vector_search(query, top_ktop_k*2) # 关键词路 kw_results keyword_search(query, top_ktop_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(), keylambda 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 记忆写入的完整流程写入不是简单地把文本存进去就完事中间有好几步处理。完整流程是这样的对话截获从对话流里拿到新一轮的用户输入和模型回复事实抽取用 LLM 从这轮对话里抽出原子事实输出结构化 JSON重要度评分对每条事实打分向量化把事实文本转成向量去重检查和已有记忆比对重复的走更新逻辑入库写入 memories 表和 vec_memories 表关联建立抽取实体更新实体-记忆的关联表事实抽取这一步的 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_k8) # 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_k5) # 慢速路径完整处理异步 async def slow_retrieve(query): memories hybrid_retrieve(query, top_k10) 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 向量检索跑通别一上来就搞复杂的分层和衰减等基础链路稳了再逐步加机制。记忆系统这东西复杂度是慢慢长出来的不是设计出来的。
阅读完成 · 觉得有帮助?
咨询建站