claude-mem 记忆系统设计:从持久化到遗忘机制的工程实践

发布时间:2026/10/28 21:31:19
claude-mem 记忆系统设计:从持久化到遗忘机制的工程实践

1. 从"聊完就忘"说起:claude-mem 到底想解决什么

如果你用 Claude 这类对话式 AI 做过稍微长一点的项目,大概率遇到过这种尴尬:昨天花了两个小时跟它把一套数据清洗逻辑捋得清清楚楚,今天开个新会话,它一脸无辜地问你"请问你想处理什么数据"。你只能把昨天的上下文重新贴一遍,贴到一半发现 token 又超了,于是开始删删减减,最后连自己原本想问什么都忘了。

这不是你用得不对,而是对话式 AI 的默认工作模式就是"无状态"。每一次会话对模型来说都是全新的开始,它不记得你上周让它写过什么函数,也不记得你偏好用 tab 还是空格、喜欢用 pandas 还是 polars。所谓"记忆",在绝大多数产品里只是把当前会话的历史消息一起塞进上下文窗口而已,会话一关,记忆归零。

claude-mem这个项目,从名字就能看出来,它瞄准的正是这个痛点——给 Claude 装上一套可持久化的记忆系统。它要做的不是简单地"保存聊天记录",而是让 AI 在跨会话、跨项目、跨时间的场景下,依然能记住你是谁、你在做什么、你之前做过什么决定。关键词里的"记忆持久化""上下文管理""会话连续性""知识沉淀"这几个词,基本勾勒出了它的核心价值边界。

这篇文章适合几类人看:一是天天跟 Claude 打交道、被上下文窗口折磨过的开发者;二是想给自己的 AI 工作流加上"长期记忆"能力的技术负责人;三是对 AI Agent 记忆机制好奇、想动手复现一套的爱好者。我会从它要解决的问题本质讲起,拆解记忆系统的核心设计思路,给出可落地的实操方案,再把我自己踩过的坑和盘托出。全文不涉及任何具体平台的部署细节,只讲通用的架构逻辑和工程实践,你可以把它当成一份"AI 记忆系统"的设计参考。

先说结论:claude-mem 这类项目的技术难点从来不在"存",而在"取"和"忘"。存谁都会存,写个 JSON 文件就完事;难的是在需要的时候精准地把相关记忆捞出来,以及在不需要的时候果断地把过时信息清理掉。后面几个章节会围绕这个核心矛盾展开。

2. 记忆系统的三层结构:为什么不能只做一个大 JSON

很多人第一次做记忆功能,思路特别朴素:把所有对话历史拼成一个大字符串,存到一个文件里,下次会话开头直接读进来塞进上下文。这个方案在数据量小的时候能跑,但只要用上一两周就会崩——上下文窗口塞不下,检索出来的全是无关噪音,模型反而被干扰得更厉害。

一个能真正用起来的记忆系统,通常要分成三层来设计,这也是 claude-mem 这类项目普遍采用的思路。

2.1 短期记忆:当前会话的工作台

短期记忆就是当前这次会话的上下文,它的生命周期最短,通常随着会话结束就丢弃或者压缩。这一层的关键不是"存多少",而是"怎么压缩"。因为上下文窗口是硬约束,你不可能无限往里塞。

常见的压缩策略有两种。一种是滑动窗口,只保留最近 N 轮对话,简单粗暴但会丢失早期的重要信息。另一种是摘要压缩,当对话轮数超过阈值时,调用模型把前面的内容总结成一段话,用摘要替换原始消息。我实测下来,摘要压缩的效果明显更好,但要注意摘要本身也会消耗 token,而且摘要质量参差不齐,最好在摘要 prompt 里明确要求"保留所有涉及文件路径、函数名、参数取值的信息"。

# 摘要压缩的简化逻辑示意 def compress_history(messages, threshold=20): if len(messages) < threshold: return messages # 保留最近 5 轮,其余压缩成摘要 recent = messages[-5:] old = messages[:-5] summary = call_model( "请把以下对话压缩成要点,务必保留文件路径、函数名、关键参数:\n" + format_messages(old) ) return [{"role": "system", "content": f"历史摘要:{summary}"}] + recent

2.2 长期记忆:跨会话的知识库

长期记忆才是 claude-mem 的核心。它要解决的是"下次会话还能记得"的问题。这一层通常落在一个持久化存储里,可以是 SQLite、向量数据库,也可以是简单的结构化文件。

关键在于存什么。我的经验是,不要存原始对话,而要存"提炼后的记忆条目"。一条好的记忆条目应该包含:事实内容、时间戳、来源会话、相关标签、以及一个可选的"重要度"评分。比如"用户偏好用 polars 而不是 pandas 处理大于 1GB 的数据"就是一条高质量记忆,而"用户说了一句好的"就是垃圾。

2.3 工作记忆:当前任务的临时状态

工作记忆介于两者之间,它服务于"当前正在进行的任务",比如你正在重构一个模块,工作记忆里就存着这个模块的文件列表、已改动的函数、待办事项。任务完成后,工作记忆里的关键结论会被提升到长期记忆,其余丢弃。

这三层的关系可以用一个类比理解:短期记忆是你桌上的草稿纸,长期记忆是你的笔记本档案柜,工作记忆是你正在写的那份文档。草稿纸用完就扔,档案柜长期保存,文档写完归档。没有哪一层可以省略。

层级生命周期存储介质核心操作
短期记忆单次会话内存/上下文压缩、滑动窗口
工作记忆单个任务内存+临时文件状态更新、任务结束归档
长期记忆永久数据库/向量库写入、检索、淘汰

3. 检索才是命门:向量、关键词还是混合

存进去容易,取出来难。这是所有记忆系统的共同困境。你存了一千条记忆,用户问了一个问题,你怎么知道该把哪几条捞出来塞进上下文?捞多了污染上下文,捞少了答非所问。

3.1 纯向量检索的局限

最直觉的方案是向量检索:把每条记忆用 embedding 模型转成向量,查询时算相似度,取 top-k。这个方案在语义匹配上确实强,比如用户问"怎么处理大文件",能召回"用户偏好 polars 处理大文件"这条记忆,即使字面不完全匹配。

但纯向量检索有几个坑。第一,它对精确匹配不敏感。用户问"上次那个 process_data 函数怎么改的",向量检索可能召回一堆语义相近但函数名不对的记忆。第二,embedding 模型本身有成本,每次写入和查询都要调用,量大之后开销不小。第三,相似度阈值很难调,调高了召回不足,调低了全是噪音。

3.2 关键词检索的补位

关键词检索(比如 BM25)恰好补上向量检索的短板。它对函数名、文件路径、专有名词这类精确 token 特别敏感。用户提到process_data,BM25 能精准命中包含这个词的记忆。

所以现在主流的做法是混合检索:向量检索负责语义召回,关键词检索负责精确召回,两路结果合并后用 RRF(Reciprocal Rank Fusion)之类的算法重排。我自己的项目里用下来,混合检索的召回质量比单路高出大概三成,尤其是技术类查询。

# 混合检索的合并逻辑示意 def hybrid_search(query, top_k=5): vec_results = vector_search(query, top_k=top_k * 2) kw_results = bm25_search(query, top_k=top_k * 2) # RRF 融合 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) ranked = sorted(scores.items(), key=lambda x: -x[1]) return [get_memory(mid) for mid, _ in ranked[:top_k]]

3.3 重排与过滤:最后一道闸

召回之后别急着塞进上下文,还要过一道重排。可以用一个小的交叉编码器(cross-encoder)对召回的候选做精排,或者干脆用一个便宜的模型做相关性打分。更重要的是过滤:把过期的、和当前项目无关的、重要度太低的记忆剔掉。

这里有个容易被忽略的点:记忆要带作用域。全局记忆(比如用户偏好)和项目记忆(比如某个仓库的架构)应该分开检索,否则你在做 A 项目时,系统把 B 项目的细节也塞进来,纯属干扰。作用域可以用标签或者命名空间来实现,检索时先按作用域过滤再排序。

提示:检索的 top-k 不要设太大。我见过有人设成 20,结果上下文里一半是无关记忆,模型反而变笨了。经验值是 3 到 5 条,配合重排足够用。

4. 遗忘机制:不会忘的记忆系统是灾难

新手做记忆系统,总想着"记得越多越好"。这是个致命误区。一个不会遗忘的系统,用上三个月就会变成一个塞满过时信息、自相矛盾、检索噪音爆炸的垃圾场。遗忘不是缺陷,是特性。

4.1 为什么必须遗忘

三个理由。第一,信息会过时。用户三个月前说"我用的是 Python 3.8",现在早升级到 3.12 了,这条记忆如果还在,就会误导模型。第二,信息会冲突。用户先说要 A 方案,后来说改 B 方案,两条都留着,模型不知道该听谁的。第三,检索成本会膨胀。记忆条目越多,检索越慢,噪音越大,信噪比直线下降。

4.2 三种遗忘策略

基于时间的衰减:给每条记忆一个"新鲜度"分数,随时间衰减。检索时把新鲜度作为排序因子之一。但纯时间衰减太粗暴,一条"用户的名字"和一条"昨天的临时决定"不该用同一个衰减曲线。

基于冲突的覆盖:当新记忆和旧记忆冲突时,标记旧记忆为"已废弃"而不是直接删除。保留废弃记录有个好处——可以追溯"用户改主意的历史",有时候这本身就是有价值的信息。判断冲突可以用语义相似度加时间戳,相似度高且新的覆盖旧的。

基于重要度的淘汰:给记忆打分,低分且长期未被检索到的记忆定期清理。重要度可以综合几个维度:写入时模型判断的重要度、被检索命中的次数、是否被用户显式确认过。

# 记忆淘汰的评分逻辑示意 def should_evict(memory, now): age_days = (now - memory.created_at).days recency_score = 1 / (1 + age_days / 30) # 30天半衰期 hit_score = min(memory.hit_count / 10, 1.0) importance = memory.importance # 0~1,写入时评估 final = 0.4 * recency_score + 0.3 * hit_score + 0.3 * importance return final < 0.2 # 低于阈值则淘汰

4.3 遗忘的实操心得

我踩过最大的坑是淘汰太激进。早期我把阈值设得很高,结果把用户的核心偏好也清掉了,模型又开始犯老毛病。后来我改成"分级淘汰":核心偏好类记忆永不自动淘汰,只允许手动更新;项目细节类记忆正常衰减;临时决定类记忆快速淘汰。分类比统一阈值靠谱得多。

还有一个技巧:淘汰前先归档。把要删的记忆移到冷存储,万一发现删错了还能捞回来。这个成本很低,但能救命。

5. 落地实操:从零搭一套最小可用记忆系统

前面讲的是设计思路,这一章给一套能跑起来的最小实现。不依赖任何特定平台,纯 Python 加 SQLite 加一个 embedding 接口就能搭起来。

5.1 数据模型设计

先定表结构。核心就一张记忆表,加一张检索日志表用于统计命中。

CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, -- 记忆正文 scope TEXT DEFAULT 'global', -- 作用域:global / project:xxx tags TEXT, -- 逗号分隔的标签 importance REAL DEFAULT 0.5, -- 重要度 0~1 embedding BLOB, -- 向量,序列化存储 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, hit_count INTEGER DEFAULT 0, status TEXT DEFAULT 'active' -- active / deprecated ); CREATE TABLE retrieval_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, memory_id INTEGER, query TEXT, retrieved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

status字段是关键,废弃的记忆不删除,只改状态,检索时过滤掉。scope字段实现作用域隔离。

5.2 写入流程:什么时候该记

不是每句话都值得记。我的做法是显式触发 + 隐式提炼结合。显式触发是用户主动说"记住这个",直接写入。隐式提炼是在会话结束时,用一个模型调用把整段对话过一遍,提炼出值得长期保留的条目。

提炼的 prompt 很关键,我用的模板大致是这样:

EXTRACT_PROMPT = """ 从以下对话中提炼值得长期记住的信息。只提炼以下类型: 1. 用户的稳定偏好(工具、风格、习惯) 2. 项目相关的关键决策和架构信息 3. 明确的待办和约定 不要提炼:临时性的闲聊、一次性的问答、已经过时的信息。 每条记忆用一句话表述,并给出 0~1 的重要度评分。 输出 JSON 数组,每项包含 content、tags、importance。 对话内容: {conversation} """

实测下来,这个 prompt 提炼出的记忆质量比让模型自由发挥高很多,因为它给了明确的边界。

5.3 读取流程:会话开始时注入什么

会话开始时,根据当前上下文构造查询,走混合检索,取 top-3 到 top-5 条记忆,格式化成一段"背景信息"注入到系统提示里。

def build_memory_context(user_query, scope="global"): memories = hybrid_search(user_query, scope=scope, top_k=5) if not memories: return "" lines = ["以下是关于用户和当前项目的已知信息,供参考:"] for m in memories: lines.append(f"- {m.content}") # 更新命中计数 bump_hit_count(m.id) return "\n".join(lines)

注意这里有个细节:注入的记忆要标明是"参考信息"而不是"指令"。否则模型可能把记忆里的内容当成必须执行的命令,反而出问题。

5.4 一个完整的会话循环

把上面串起来,一次会话的完整流程是:会话开始 → 构造查询 → 检索记忆 → 注入上下文 → 正常对话 → 会话结束 → 提炼新记忆 → 写入 → 触发淘汰检查。这个循环跑通,一套最小可用的记忆系统就成了。

阶段操作关键点
会话开始检索并注入记忆top-k 控制在 5 以内
对话中正常交互记忆作为参考而非指令
会话结束提炼新记忆prompt 限定提炼范围
写入后淘汰检查分级淘汰,先归档再删

6. 那些文档不会告诉你的坑

这一章是我最想写的部分。前面讲的是"应该怎么做",这里讲的是"实际做起来会怎么翻车"。

6.1 记忆污染:模型把自己的猜测记成了事实

这是最隐蔽的坑。会话结束时提炼记忆,模型有时候会把"它自己推测的内容"当成"用户说的事实"记下来。比如用户随口问了句"pandas 是不是比 polars 慢",模型提炼成"用户认为 pandas 比 polars 慢",下次会话就拿着这条错误记忆去指导决策。

解决办法是在提炼 prompt 里强制要求区分事实和推测,只记录用户明确表达的内容,模型自己的推断一律不记。另外可以加一道校验:写入前用另一个模型调用判断"这条记忆是否忠实于原始对话",不忠实就丢弃。多一道校验,成本增加不多,但能挡掉大部分污染。

6.2 检索的"回音壁"效应

如果你的记忆系统长期只召回某几条高频记忆,模型就会反复基于这几条记忆回答,形成回音壁。用户的新需求被旧记忆压制,体验越来越差。

对策是引入探索机制。检索时不要永远取 top-k,留一两个位置给"随机但相关"的记忆,或者给长期未被命中的记忆一点曝光机会。这听起来反直觉,但实测能显著改善长期体验。

6.3 上下文注入的位置很讲究

记忆注入到系统提示的开头还是结尾,效果差别很大。我实测下来,放在系统提示的靠后位置效果更好,因为模型对靠近用户消息的内容注意力更集中。放在最开头,模型容易"看了就忘"。

另外,注入的记忆条数不是越多越好。我做过对比,注入 3 条和注入 10 条,前者的回答准确率反而更高。原因很简单:10 条里必然有噪音,噪音会稀释有效信息。

6.4 别忽视冷启动

新用户的记忆库是空的,检索什么都返回不了。这时候系统要能优雅降级,不能因为没记忆就报错或者行为异常。我的做法是:记忆为空时,注入一段默认的通用偏好(比如"用户偏好简洁的回答"),同时在前几次会话里更积极地提炼记忆,快速把库填起来。

注意:冷启动阶段的提炼要更保守,宁可少记也别记错。因为早期记忆会长期影响后续所有会话,一条错误记忆的破坏力远超你的想象。

7. 记忆系统的边界:它不该做什么

聊了这么多"怎么做",最后说说"不该做什么"。任何技术都有边界,越界使用只会适得其反。

记忆系统不该替代明确的指令。如果用户希望 AI 每次都按某个格式输出,正确做法是写进系统提示,而不是指望记忆系统每次都召回那条偏好。记忆是"锦上添花",不是"雪中送炭"。

记忆系统不该存储敏感信息。用户的密钥、密码、个人身份信息这类内容,绝对不应该进入记忆库。写入前要做敏感信息过滤,这是底线。

记忆系统不该追求"全自动"。完全自动的记忆系统看起来很酷,但实际用起来,用户往往希望有"查看记忆""编辑记忆""删除记忆"的入口。给用户控制权,比追求全自动更重要。我自己的项目里就加了一个简单的记忆管理界面,用户能看到系统记了什么,能手动修正,这个功能的使用率出乎意料地高。

记忆系统不该无限扩张。定期做记忆库的"体检",看看有多少条记忆长期未被命中,有多少条互相冲突,有多少条已经过时。一个健康的记忆库,条目数应该稳定在一个合理区间,而不是只增不减。

说到底,claude-mem 这类项目的价值,不在于它用了多先进的向量检索或者多复杂的淘汰算法,而在于它把"让 AI 记住该记的、忘掉该忘的"这件事,做成了一套可工程化、可维护的机制。我自己的体会是,搭一套能跑的记忆系统可能只需要一个周末,但把它调到"真的好用",需要几个月的持续打磨。这个打磨过程里,最重要的不是技术,而是对"什么值得记"的判断力——这个判断力,只能从一次次实际使用和翻车中积累出来。