1. 从零认识 claude-mem:它到底解决什么问题
第一次看到claude-mem这个名字,我脑子里蹦出来的第一反应是:这不就是给 Claude 加了个“记忆外挂”吗?事实也确实如此。claude-mem是一个围绕 Claude 生态构建的持久化记忆层,核心目标只有一个——让 Claude 在跨会话、跨项目、跨时间的协作中,不再每次都从零开始。
用过 Claude 做长期项目的人应该都有这种体验:今天跟它聊清楚了项目架构、命名规范、技术选型,明天开个新会话,它全忘了。你得重新贴一遍上下文,重新解释一遍约束条件,甚至重新纠正一遍它上次犯过的错。这种“失忆式协作”在短对话里还能忍,一旦项目周期拉长到几周甚至几个月,重复沟通的成本就会指数级上升。
claude-mem要解决的就是这个痛点。它本质上是一套记忆的写入、存储、检索、注入机制:把对话中值得留存的信息结构化地存下来,在需要的时候精准地捞出来,再以合适的形态塞回 Claude 的上下文里。听起来简单,但真正做过的人都知道,难点全在细节里——存什么、怎么存、什么时候取、取多少、怎么防止记忆污染,每一步都是坑。
这篇文章适合三类人看:一是正在用 Claude 做长期项目的开发者,二是想给自己的 AI 工作流加记忆能力的技术人,三是对“AI 记忆系统”这个方向好奇、想动手复现一套的实践派。我会从设计思路讲到实操细节,再到踩坑记录,尽量把我知道的都倒出来。
2. 整体设计思路:为什么记忆系统不能简单粗暴
2.1 记忆系统的核心矛盾:存得多 vs 取得准
做记忆系统,第一个要面对的矛盾就是存储成本和检索精度的博弈。你当然可以把每一轮对话原封不动全存下来,但这样做的后果是:检索的时候噪音极大,真正有用的信息被淹没在大量废话里;而且上下文窗口是有限的,你不可能把所有历史都塞回去。
claude-mem这类系统的设计哲学,我理解下来是分层记忆 + 按需检索。它不会把所有东西都当成同等重要的记忆,而是区分出不同层级:有的是长期稳定的“事实性记忆”(比如项目用的技术栈、代码规范),有的是中期有效的“任务性记忆”(比如当前迭代的目标、待办事项),还有的是短期临时的“会话性记忆”(比如这次对话临时提到的某个变量名)。
这种分层的好处在于,检索的时候可以按层级加权。事实性记忆优先级最高,因为它跨会话都有效;会话性记忆优先级最低,过期就该丢。我实测下来,如果不做分层,检索结果里经常混进一堆无关的临时信息,反而干扰 Claude 的判断。
2.2 为什么选择“外部存储 + 上下文注入”而不是微调
有人可能会问:既然要让 Claude 记住东西,为什么不直接微调模型?这个问题我认真想过,结论是微调不适合做记忆。
微调的本质是把知识固化进模型权重,它适合的是“能力”层面的调整,比如让模型学会某种输出风格。但记忆是动态的、频繁变化的,今天记住的东西明天可能就过时了。你不可能每更新一条记忆就重新微调一次模型,成本高到离谱,而且微调还会带来灾难性遗忘的问题。
claude-mem走的是外部存储 + 运行时注入的路线:记忆存在外部数据库里,每次对话开始时根据当前上下文检索相关记忆,拼接到 prompt 里。这样做的好处是记忆可以随时增删改查,完全不影响模型本身,灵活性和可控性都高得多。代价是每次都要消耗上下文窗口,所以检索的精准度就成了关键。
2.3 记忆的生命周期:写入、存储、检索、衰减
一套完整的记忆系统,生命周期至少包含四个阶段,每个阶段都有讲究。
写入阶段要解决“什么值得记”。我的经验是,不是每句话都值得存。值得存的大致有几类:明确的偏好和约束(“这个项目统一用 4 空格缩进”)、重要的决策和理由(“选 PostgreSQL 是因为需要 JSONB 支持”)、反复出现的实体(项目名、模块名、关键人名)、以及纠正过的错误(“上次把日期格式搞错了,应该是 ISO 8601”)。
存储阶段要解决“怎么组织”。我倾向于用结构化的方式存,每条记忆带上类型、时间戳、来源、置信度这些元数据。纯文本堆在一起,后期检索会非常痛苦。
检索阶段要解决“怎么捞得准”。常见做法是向量检索加关键词检索的混合方案,向量负责语义相似,关键词负责精确匹配。单用向量容易漏掉精确的术语匹配,单用关键词又抓不住语义相近的表述。
衰减阶段要解决“怎么忘”。记忆不是越多越好,过期的、被推翻的记忆要及时清理或降权,否则会污染检索结果。我一般给记忆加一个“最后访问时间”和“命中次数”,长期没被命中又比较旧的记忆就自动降权。
3. 核心细节解析:记忆系统的关键组件与实操要点
3.1 记忆的数据结构设计
先说数据结构,这是整个系统的地基。我踩过的最大坑就是一开始用纯文本存记忆,结果检索的时候完全没法做精细过滤。后来改成结构化 schema,体验立刻不一样。
一条记忆记录我一般包含这些字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | string | 唯一标识,建议用 UUID |
| content | text | 记忆正文,自然语言描述 |
| type | enum | fact / preference / decision / entity / correction |
| embedding | vector | 语义向量,用于相似度检索 |
| keywords | array | 关键词列表,用于精确匹配 |
| source | string | 来源,比如会话 ID 或项目名 |
| created_at | timestamp | 创建时间 |
| last_accessed | timestamp | 最后命中时间 |
| hit_count | int | 命中次数 |
| confidence | float | 置信度,0 到 1 |
| ttl | int | 过期时间,秒为单位,0 表示永不过期 |
type字段特别重要,它决定了检索时的加权策略。比如preference和decision类型的记忆,我一般给更高的权重,因为它们对后续协作的影响最大。entity类型主要用于实体消歧,比如项目里有个模块叫 “auth”,检索时能快速定位到相关记忆。
confidence字段是给记忆“打分”用的。用户明确说出来的偏好,置信度给 0.9 以上;从对话里推断出来的,给 0.6 到 0.8;不确定的,给 0.5 以下。检索时低置信度的记忆要么不返回,要么明确标注“这是推测”。
3.2 记忆写入的触发时机与去重
写入时机是个容易被忽视但很关键的细节。我的做法是双通道触发:一是显式触发,用户说“记住这个”或者“以后都这样”的时候,立刻写入;二是隐式触发,在对话结束时用一个小模型或者规则引擎扫一遍,把值得留存的信息抽出来。
隐式触发最容易出的问题是重复写入。同一件事在多次对话里被反复提到,如果每次都存一条,记忆库很快就会膨胀。解决办法是写入前先做一次相似度检查,如果已有高度相似的记忆,就更新它的last_accessed和hit_count,而不是新建一条。
去重的阈值我一般设在 0.92 左右。太高了去不掉重复,太低了会把本来不同的记忆误合并。这个值需要根据实际数据调,没有万能数字。
注意:去重的时候不要只看向量相似度,还要看
type是否一致。一条preference和一条decision即使文本很像,也可能是两回事,不该合并。
3.3 检索策略:混合检索与重排序
检索是记忆系统里技术含量最高的部分。我目前用的是三路召回 + 重排序的方案。
第一路是向量召回,用 embedding 做语义相似度检索,取 top 20。这一路负责抓语义相近但表述不同的记忆。
第二路是关键词召回,用 BM25 或者简单的倒排索引,取 top 20。这一路负责抓精确术语匹配,比如项目里特有的命名。
第三路是元数据召回,根据当前会话的source、type过滤,取最近访问的 top 10。这一路负责保证同一项目、同一类型的记忆优先返回。
三路结果合并去重后,用一个轻量的重排序模型(或者简单的加权打分)重新排序。我的加权公式大致是:
final_score = 0.5 * vector_score + 0.3 * keyword_score + 0.2 * recency_scorerecency_score是根据last_accessed算的,越近的越高。这个权重分配不是固定的,如果当前任务偏精确匹配,我会把keyword_score的权重调高。
重排序之后,取 top 5 到 top 8 条记忆注入上下文。取太多会挤占上下文窗口,取太少又可能漏掉关键信息。这个数量我一般根据记忆的平均长度动态调整,总 token 控制在 800 到 1500 之间。
3.4 上下文注入的格式与位置
记忆检索出来了,怎么塞进 prompt 也有讲究。我试过几种格式,最后稳定下来的方案是用结构化标签包裹,放在系统提示之后、用户消息之前。
格式大概长这样:
<memory> <fact confidence="0.95">项目使用 PostgreSQL 15,需要 JSONB 支持</fact> <preference confidence="0.9">代码统一 4 空格缩进,不用 tab</preference> <decision confidence="0.85">选型时优先考虑社区活跃度,其次才是性能</decision> </memory>用 XML 风格的标签有几个好处:一是 Claude 对这类结构化标签的解析很稳,二是可以带上confidence属性让模型知道这条记忆的可信程度,三是方便后续做程序化处理。
位置放在系统提示之后是有原因的。系统提示定义了角色和基本规则,记忆是对这些规则的补充和具体化,放在后面符合逻辑层次。如果放在用户消息之后,模型可能会把记忆当成用户当前输入的一部分,产生混淆。
提示:注入的记忆要控制总量,我一般不超过 1500 token。超了就按
final_score截断,宁可少给也不要挤爆上下文。
4. 实操过程:从零搭一套可用的记忆系统
4.1 环境准备与技术选型
动手之前先把技术栈定下来。我的选型思路是够用就好,别过度设计。
存储层我选的是 SQLite + 向量扩展(比如 sqlite-vec),原因是轻量、零运维、单文件方便迁移。如果数据量上到百万级,再考虑换 PostgreSQL + pgvector。对于个人和小团队项目,SQLite 完全够用。
Embedding 模型我用的是本地部署的小模型,维度 384 或 768 都行。选本地模型主要是考虑成本和隐私,调用外部 API 虽然方便,但长期用下来费用不低,而且记忆内容往往包含项目敏感信息。
检索层的关键词部分,我用 SQLite 的 FTS5 全文索引,够用且快。重排序部分一开始用规则加权,后来数据多了换成了一个小的 cross-encoder 模型,效果提升明显。
整个系统的代码结构我分成四个模块:writer(写入)、store(存储)、retriever(检索)、injector(注入)。模块之间通过明确的接口通信,方便单独测试和替换。
4.2 数据库表结构初始化
建表语句我整理了一份,可以直接抄:
CREATE TABLE memories ( id TEXT PRIMARY KEY, content TEXT NOT NULL, type TEXT NOT NULL, source TEXT, created_at INTEGER NOT NULL, last_accessed INTEGER, hit_count INTEGER DEFAULT 0, confidence REAL DEFAULT 0.8, ttl INTEGER DEFAULT 0 ); CREATE VIRTUAL TABLE memories_fts USING fts5( content, keywords, content='memories', content_rowid='rowid' ); CREATE TABLE memory_embeddings ( memory_id TEXT PRIMARY KEY, embedding BLOB NOT NULL, FOREIGN KEY (memory_id) REFERENCES memories(id) );memories存主数据,memories_fts是全文索引,memory_embeddings单独存向量。向量单独存是因为它的体积大,分开存可以避免主表膨胀影响查询性能。
初始化之后记得建触发器,让memories的增删改自动同步到 FTS 索引。这个细节不做的话,全文检索会查不到新写入的记忆。
4.3 写入流程的代码实现
写入流程我拆成三步:抽取、去重、落库。
抽取部分,如果是显式触发,直接把用户指定的内容结构化;如果是隐式触发,我用一个简单的 prompt 让 Claude 自己抽:
EXTRACT_PROMPT = """ 从以下对话中抽取值得长期记忆的信息,按 JSON 数组返回。 每条包含 content、type、confidence 三个字段。 type 可选值:fact, preference, decision, entity, correction。 只抽取跨会话仍然有效的信息,临时性的内容不要抽。 对话内容: {dialogue} """去重部分,先算新记忆和已有记忆的向量相似度,超过阈值就更新而不是新建:
def upsert_memory(new_mem, threshold=0.92): candidates = vector_search(new_mem.embedding, top_k=5) for cand in candidates: if cosine_sim(cand.embedding, new_mem.embedding) > threshold \ and cand.type == new_mem.type: update_memory(cand.id, last_accessed=now(), hit_count=cand.hit_count+1) return cand.id return insert_memory(new_mem)落库的时候记得同时写主表、FTS 索引和向量表,三处要在一个事务里,否则会出现数据不一致。
4.4 检索与注入的完整链路
检索链路的入口是一个查询函数,输入当前对话的上下文,输出要注入的记忆列表:
def retrieve_memories(query_text, source=None, top_k=8): query_emb = embed(query_text) vec_results = vector_search(query_emb, top_k=20) kw_results = fts_search(query_text, top_k=20) meta_results = metadata_search(source=source, top_k=10) merged = merge_and_dedup(vec_results, kw_results, meta_results) scored = rerank(merged, query_emb) return scored[:top_k]rerank函数里就是前面说的加权打分。注入的时候把结果格式化成 XML 标签,拼到系统提示后面。
整个链路我实测下来,从查询到注入完成,延迟在 100 到 300 毫秒之间,对交互体验基本没影响。如果延迟敏感,可以把 embedding 计算做成异步的,先返回关键词检索结果,向量结果算完了再补充。
4.5 记忆衰减与清理的定时任务
记忆库不能只进不出,我设了一个每天跑一次的清理任务,逻辑是:
- 删除
ttl不为 0 且已过期的记忆 - 对
last_accessed超过 90 天且hit_count小于 2 的记忆,把confidence乘以 0.8 confidence低于 0.3 的记忆标记为待清理,人工确认后删除
这个策略的核心思想是用进废退。经常被命中的记忆保持高权重,长期不用的逐渐降权。这样记忆库能保持在一个合理的规模,检索质量也不会随时间下降。
注意:清理任务一定要有日志和回滚机制。我有一次误删了一批重要记忆,因为没有备份,只能重新录入,教训很深刻。
5. 常见问题与排查技巧实录
5.1 记忆检索不准的排查思路
检索不准是最常见的问题,表现是注入的记忆和当前对话不相关。排查我一般按这个顺序走:
先看 embedding 模型是否合适。如果模型是在通用语料上训练的,对项目特有的术语可能不敏感。解决办法是用项目数据做一次轻量微调,或者换一个领域适配更好的模型。
再看关键词索引是否同步。FTS 索引如果没建触发器,新记忆查不到,表现就是“明明存了却检索不出来”。这个坑我踩过,排查了半天才发现是索引没同步。
最后看权重分配是否合理。如果当前任务偏精确匹配,但向量权重给太高,就会召回一堆语义相近但实际无关的记忆。这时候调一下权重公式就行。
5.2 记忆冲突与版本管理
同一个事实,不同时间存了不同版本,检索时返回了旧版本,这是很尴尬的情况。比如项目早期用 MySQL,后来迁移到 PostgreSQL,如果两条记忆都在,模型可能按旧版本回答。
我的解决办法是给记忆加版本链。新记忆写入时,如果检测到和旧记忆是同一主题但内容冲突,就把旧记忆标记为superseded,并记录superseded_by指向新记忆。检索时默认只返回未过期的版本。
判断“同一主题”用向量相似度加实体匹配,相似度高于 0.85 且实体重叠的,就认为是同一主题的不同版本。
5.3 上下文窗口被记忆挤爆怎么办
记忆注入太多导致上下文不够用,这个问题的本质是检索精度不够,只能靠数量来凑。解决办法有两个方向:一是提升检索精度,让 top 5 就能覆盖关键信息;二是压缩记忆内容,把长记忆摘要成短句。
我一般两条路一起走。检索精度靠重排序模型提升,记忆压缩靠定期做摘要。摘要的触发条件是记忆内容超过 200 字,用一个小模型压到 100 字以内,保留关键信息。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 检索结果不相关 | embedding 模型不适配 | 检查模型领域匹配度 | 换模型或微调 |
| 新记忆检索不到 | FTS 索引未同步 | 检查触发器 | 补建触发器 |
| 返回旧版本记忆 | 缺少版本管理 | 检查是否有 superseded 标记 | 加版本链 |
| 上下文被挤爆 | 检索精度低 | 看重排序效果 | 提升精度或压缩记忆 |
| 记忆库膨胀过快 | 去重阈值太低 | 检查相似度阈值 | 调高阈值 |
| 写入延迟高 | embedding 同步计算 | 看写入链路耗时 | 改异步计算 |
5.5 几个我踩过的坑和独家技巧
第一个坑是过度依赖向量检索。一开始我觉得向量检索万能,结果发现项目里特有的缩写、代号,向量模型根本抓不住。后来加了关键词召回才解决。所以混合检索不是可选项,是必选项。
第二个坑是置信度设置太随意。早期我所有记忆都给 0.8,结果检索时没法区分哪些是用户明确说的、哪些是推断的。后来严格区分,用户明说的给 0.9 以上,推断的给 0.6 到 0.8,检索质量明显提升。
第三个技巧是给记忆加来源标记。同一件事,来自用户直接输入的,和来自模型推断的,可信度完全不同。我在source字段里区分了user和inferred,检索时对user来源的加权更高。
第四个技巧是定期做记忆审计。我每个月会抽一批记忆人工检查,看看有没有错误、过时、重复的。这个习惯帮我发现了好几次去重逻辑的漏洞。
6. 记忆系统的扩展方向与个人体会
claude-mem这套思路搭起来之后,能扩展的方向其实不少。我目前在做的一个扩展是跨项目记忆共享。有些记忆是通用的,比如“用户偏好简洁的回答风格”,这种记忆不该绑定在单个项目上。我在source字段之外加了一个scope字段,区分global和project,检索时全局记忆始终参与召回。
另一个扩展方向是记忆的可视化。记忆库大了之后,光靠日志很难看清全貌。我写了个简单的 Web 界面,把记忆按类型、时间、命中次数做成图表,一眼就能看出哪些记忆是活跃的、哪些该清理了。这个工具对调试特别有用。
还有一个我还没做但想做的方向是记忆的自动归纳。现在记忆是一条条独立的,但有些记忆其实可以归纳成更高层的规则。比如存了十条关于代码风格的偏好,可以自动归纳成一条“代码风格规范”。这样能大幅压缩记忆数量,提升检索效率。
我个人在实际操作中的体会是,记忆系统的价值不在于技术多复杂,而在于持续维护。搭一套能跑的框架可能一两天就够了,但让它真正好用,需要长期的调参、清理、审计。这活儿有点像养花,不是种下去就完事,得天天浇水修剪。如果你打算长期用 Claude 做项目,花点时间把记忆系统搭起来,回报是实打实的——每次新会话省下的重复沟通时间,累积起来相当可观。
最后分享一个小技巧:记忆写入的时候,尽量用陈述句而不是疑问句或命令句。“项目使用 PostgreSQL”比“项目是不是用 PostgreSQL”更适合作为记忆,因为前者是明确的事实,后者还需要二次判断。这个细节看起来小,但对检索质量的影响不小。