为Claude构建持久记忆层:架构设计与工程实践

发布时间:2026/10/25 7:56:27
为Claude构建持久记忆层:架构设计与工程实践

1. 项目缘起与核心定位

第一次看到claude-mem这个名字,我下意识地把它拆成了两半:claude和mem。前者指向 Anthropic 家的对话模型,后者显然是 memory 的缩写。合在一起,直觉告诉我这是一个围绕 Claude 做“记忆层”的项目。事实也确实如此——它要解决的是一个所有长期使用大模型的人都会撞上的痛点:模型本身没有跨会话的持久记忆。

你每次开一个新对话,模型对你是完全陌生的。上一轮你告诉它“我在做一个 Python 数据处理项目,用的是 pandas 2.x,环境是 conda”,关掉窗口再开,它忘得一干二净。对于一次性问答这没什么,但对于需要连续几周甚至几个月推进的项目、需要长期维护的知识库、需要反复迭代的写作任务,这种“失忆”就是灾难。你得反复粘贴背景、反复解释上下文、反复纠正它已经犯过的错。

claude-mem的核心价值就在这里:它在 Claude 和你的工作流之间插了一层可持久化的记忆管理机制,把对话中产生的关键信息抽取、存储、检索,并在后续会话中按需注入。说白了,就是给一个天生没有长期记忆的助手,配了一个外挂的笔记本和检索系统。

这个项目适合谁?我梳理了三类人。第一类是重度 Claude 用户,每天要开几十个会话,跨会话上下文丢失严重拖慢效率。第二类是做 AI 应用开发的工程师,需要在产品里集成“带记忆的对话能力”,claude-mem提供了一套可参考的架构和实现。第三类是对 RAG、向量检索、上下文工程感兴趣的技术爱好者,这个项目麻雀虽小,但把记忆抽取、存储、召回、注入的完整链路都跑通了,是很好的学习样本。

需要提前说明的是,claude-mem这类项目在社区里有多种实现形态,有的偏 CLI 工具,有的偏库,有的直接做成 MCP 服务。下面我讲的架构和实操,是基于这类“Claude 记忆层”项目最常见的工程实践来展开的,具体到某个仓库的目录结构可能略有差异,但核心原理和踩坑点是一致的。

2. 记忆层的整体架构设计思路

2.1 为什么不能简单粗暴地“全量存对话”

很多人第一反应是:记忆嘛,把历史对话全存下来,下次全塞进 prompt 不就行了?我一开始也这么想过,实测下来这条路走不通,原因有三个。

第一是 token 成本。Claude 的上下文窗口虽然大,但你把过去几十次会话的原文全塞进去,动辄几万 token,每次请求都在烧钱,而且大部分内容是冗余的。第二是信噪比。历史对话里充斥着“好的”“明白了”“让我想想”这类无信息量的内容,全量注入会稀释真正重要的信息,模型反而抓不住重点。第三是检索效率。全量注入等于没有检索,模型要在噪音里自己找相关记忆,效果远不如你主动把最相关的几条喂给它。

所以claude-mem的设计核心不是“存储”,而是抽取 + 检索。存储只是手段,把对话压缩成高密度的记忆条目、在需要时精准召回,才是目的。

2.2 四层架构拆解

我把这类项目的架构归纳成四层,从下往上依次是:

层级职责常见技术选型
存储层持久化记忆条目与向量SQLite / PostgreSQL + pgvector / 本地向量库
抽取层从对话中提炼记忆LLM 抽取 + 规则过滤
检索层按相关性召回记忆向量相似度 + 关键词混合检索
注入层把记忆拼进 prompt模板拼接 + 优先级排序

存储层我倾向于用 SQLite 起步,原因很简单:单文件、零运维、方便迁移和备份。记忆数据量在个人使用场景下通常不会超过几十万条,SQLite 完全扛得住。向量部分可以用sqlite-vec这类扩展,或者干脆把向量序列化后存 BLOB,检索时在内存里算余弦相似度——几千条记忆的暴力检索延迟在毫秒级,根本不需要上专业向量数据库。

抽取层是整个项目最考验设计的地方。你不能把每句话都存成记忆,那样等于没抽取。我的做法是让 LLM 扮演“记忆管理员”,在每轮对话结束后,把这一轮的内容喂给它,让它输出结构化的记忆条目。抽取的粒度很关键,太细会碎片化,太粗会丢失细节。

检索层决定了记忆能不能被用上。纯向量检索有个经典问题:它对语义相似但关键词不同的情况很擅长,但对精确匹配(比如某个具体的变量名、文件路径)反而不如关键词检索。所以实践中我推荐混合检索:向量召回一批,BM25 或关键词召回一批,然后做融合排序。

注入层是最容易被忽视的一环。召回了一堆记忆,怎么塞进 prompt 也有讲究。塞太多会挤占上下文,塞太少又不够用。我的经验是控制在总上下文的 10% 到 20% 之间,并且按相关性从高到低排列,让模型优先看到最相关的。

2.3 方案选型背后的取舍

这里重点说两个取舍,都是我在实际搭建时纠结过的。

取舍一:抽取用 LLM 还是规则?规则抽取(比如正则匹配“我的项目是 XXX”)成本低、速度快,但覆盖面窄,稍微换个说法就抓不到。LLM 抽取灵活、准确,但每次都要调 API,有成本和延迟。我的结论是以 LLM 抽取为主,规则做兜底和过滤。比如用规则先过滤掉明显无意义的短句,再交给 LLM 做精细抽取,这样能省下不少 token。

取舍二:记忆是实时写入还是批量写入?实时写入(每轮对话结束就抽)的好处是记忆新鲜,坏处是延迟叠加,用户能感觉到卡顿。批量写入(比如会话结束时统一抽)延迟低,但如果会话中途崩溃,记忆就丢了。我最终选了异步实时写入:对话正常进行,抽取任务丢到后台队列里跑,不阻塞主流程。这样既保证了记忆的及时性,又不影响交互体验。

3. 记忆抽取的核心细节与实操要点

3.1 什么样的内容才值得存成记忆

这是抽取环节最核心的问题。我踩过的坑是:一开始什么都存,结果记忆库迅速膨胀到几千条,检索出来的全是垃圾。后来我总结了一套筛选标准,只有满足以下条件之一的内容才值得存:

  • 事实性信息:用户的偏好、项目的技术栈、环境配置、命名约定。比如“用户偏好用 tabs 而不是 spaces”“项目用 FastAPI + PostgreSQL”。
  • 决策与结论:讨论后达成的方案、排除的选项及原因。比如“最终选了方案 B,因为方案 A 的延迟太高”。
  • 待办与承诺:还没做完的事、下次要跟进的点。比如“下次要补上单元测试”。
  • 纠错记录:模型犯过的错、用户纠正过的点。这类记忆能防止模型重复犯错,价值极高。

反过来,以下内容坚决不存:寒暄、确认性回复、模型自己的长篇解释(除非用户明确表示要记住)、临时性的中间推理过程。

3.2 抽取 prompt 的设计

抽取质量高度依赖 prompt。我反复迭代过好几版,最终稳定下来的结构是这样的:

你是一个记忆抽取器。请从下面这轮对话中,提取值得长期记住的信息。 只提取以下类型: 1. 用户的事实性偏好或项目背景 2. 达成的决策及其理由 3. 未完成的待办事项 4. 用户对助手的纠正 对每条记忆,输出 JSON: { "type": "fact | decision | todo | correction", "content": "一句话描述,不超过50字", "confidence": 0.0-1.0, "tags": ["标签1", "标签2"] } 如果这轮对话没有值得记住的内容,返回空数组 []。 不要提取寒暄、确认、临时推理。 对话内容: {conversation}

这个 prompt 有几个关键设计点。一是明确排除项,直接告诉模型什么不要抽,比只说什么要抽效果好得多。二是要求 confidence 字段,低于 0.6 的记忆我会在写入前再过滤一遍,减少噪音。三是 tags 字段,为后续的关键词检索提供抓手。四是限制长度,50 字以内强制模型做压缩,避免记忆条目本身变成一大段话。

3.3 去重与冲突处理

记忆库用久了必然出现重复和冲突。重复好办,写入前做一次相似度检查,余弦相似度超过 0.95 的就认为是同一条,直接跳过或更新。冲突就麻烦一些,比如用户上周说“用 MySQL”,这周说“改用 PostgreSQL 了”。

我的处理策略是时间戳优先 + 显式覆盖。每条记忆带创建时间和更新时间,检索时如果发现两条冲突的记忆,优先返回更新的那条。同时,在抽取 prompt 里加一条指令:如果这轮对话明确推翻了之前的某个决策,输出时把 type 标成correction,content 里写清楚“从 X 改为 Y”。这样检索时 correction 类型的记忆会被赋予更高的权重。

提示:去重不要只靠向量相似度,短文本的向量相似度经常不准。我建议向量相似度和编辑距离结合判断,两个都超过阈值才认为是重复。

3.4 实操心得:抽取频率的控制

这里分享一个我踩坑后才明白的点:不是每轮对话都需要抽取。如果用户和模型来回聊了二十轮才形成一个决策,你每轮都抽一次,前十九轮抽出来的全是废话,第二十轮才抽到有用的。

我的做法是按“信息增量”触发抽取,而不是按轮次。具体来说,维护一个滑动窗口,当窗口内的对话累积到一定长度(比如 800 token),或者检测到明显的决策信号词(“就这么定了”“改用”“记住”),才触发一次抽取。这样既减少了 API 调用,又提高了单次抽取的质量。

4. 存储与检索的完整实现

4.1 数据表设计

存储层我用的 SQLite,表结构设计如下。这个结构在个人项目和中小型应用里都够用:

CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, -- fact / decision / todo / correction content TEXT NOT NULL, -- 记忆正文 tags TEXT, -- 逗号分隔的标签 confidence REAL DEFAULT 1.0, embedding BLOB, -- 向量序列化存储 created_at INTEGER NOT NULL, -- Unix 时间戳 updated_at INTEGER NOT NULL, source_session TEXT, -- 来源会话 ID access_count INTEGER DEFAULT 0, last_access_at INTEGER ); CREATE INDEX idx_type ON memories(type); CREATE INDEX idx_created ON memories(created_at);

几个字段值得说明。access_count和last_access_at用来做热度衰减——一条记忆如果很久没被访问,说明它可能过时了,检索时可以降权。source_session方便你追溯某条记忆是从哪次对话来的,排查问题时很有用。embedding存 BLOB,读取时反序列化成 float 数组。

4.2 向量化的选型

向量化模型的选择直接影响检索效果。我的建议是:

  • 英文为主:用all-MiniLM-L6-v2,384 维,速度快,本地跑无压力。
  • 中英混合:用bge-small-zh或bge-m3,对中文支持好很多。
  • 追求效果不差钱:调云端 embedding API,维度更高,语义捕捉更准。

我实测下来,对于记忆这种短文本(通常 20 到 50 字),小模型的检索效果和大模型差距没有想象中大,本地小模型完全够用,还省了 API 调用。关键是向量化用的模型要和检索时一致,不能写入用 A 模型、查询用 B 模型,那样向量空间对不上,检索结果全是乱的。

4.3 混合检索的实现

纯向量检索的短板在精确匹配上。我举个真实例子:记忆里有一条“项目路径是/home/user/proj-alpha”,用户查询“proj-alpha 的配置在哪”。向量检索可能召回一堆语义相近但路径不对的记忆,而关键词检索能精准命中。所以混合检索是必须的。

实现思路是双路召回 + 融合排序:

def hybrid_search(query, top_k=5): # 路径一:向量检索 query_vec = embed(query) vec_results = vector_search(query_vec, top_k=20) # 路径二:关键词检索(BM25 或简单的 LIKE 匹配) kw_results = keyword_search(query, top_k=20) # 融合:用 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) # 按融合分数排序,取 top_k ranked = sorted(scores.items(), key=lambda x: -x[1])[:top_k] return [get_memory(id) for id, _ in ranked]

RRF 里的常数 60 是经验值,来自信息检索领域的经典做法,不用纠结具体数字,60 到 100 之间效果都差不多。这个融合方式的好处是不需要归一化两路分数,直接按排名融合,简单又稳。

4.4 热度衰减与时间加权

记忆是有时效性的。三个月前的一条“待办”可能早就做完了,如果检索时还把它排在前面,就是误导。我的做法是在最终排序分数里加一个时间衰减因子:

import math def time_decay(created_at, half_life_days=30): age_days = (now() - created_at) / 86400 return math.exp(-age_days / half_life_days) final_score = relevance_score * (0.7 + 0.3 * time_decay(created_at))

半衰期设 30 天,意味着一条记忆一个月后权重降到约 0.37。但注意我用了0.7 + 0.3 * decay而不是直接乘 decay,这是为了保底——再老的记忆也不会被完全压到零,避免重要但久远的信息被彻底埋没。这个 0.7/0.3 的比例是我调了几次后觉得比较平衡的,你可以根据自己的场景调整。

注意:todo类型的记忆不要做时间衰减,反而应该随时间加权,因为拖得越久的待办越需要提醒。这个细节很多实现都忽略了。

5. 记忆注入与上下文工程

5.1 注入模板的设计

召回记忆后,怎么拼进 prompt 是个技术活。我见过最粗暴的做法是直接把记忆列表贴上去,模型经常分不清哪些是记忆、哪些是当前指令。我的模板是这样的:

[相关记忆] 以下是你之前记住的、可能与当前对话相关的信息。请参考但不要盲从, 如果与当前对话冲突,以当前对话为准。 - [决策] 项目使用 FastAPI + PostgreSQL(2024-01-15) - [事实] 用户偏好用 tabs 缩进(2024-01-10) - [待办] 补上用户模块的单元测试(2024-01-12) [当前对话] {user_input}

这个模板有三个关键点。一是明确边界,用[相关记忆]和[当前对话]把两部分隔开,模型不会混淆。二是加免责声明,“参考但不要盲从”,防止过时记忆误导模型。三是带类型和时间戳,让模型自己判断记忆的时效性和可信度。

5.2 注入数量的控制

注入几条记忆合适?我的经验是3 到 7 条。少于 3 条,可能漏掉关键信息;多于 7 条,边际收益递减,还挤占上下文。具体数量可以根据当前对话的复杂度动态调整:简单问答注入 3 条,复杂任务注入 5 到 7 条。

还有一个技巧是按类型分配配额。比如决策类最多 3 条,事实类最多 2 条,待办类最多 2 条。这样能保证各类信息都有覆盖,不会出现全是事实类、决策类一条没有的情况。

5.3 实操心得:注入位置的影响

这个点很少有人提,但实测影响不小。记忆注入的位置会影响模型的注意力分配。放在 prompt 最前面,模型容易忽略(尤其是长上下文时);放在最后面,又可能盖过当前指令。

我的做法是放在系统提示之后、用户输入之前。这个位置模型既能看到,又不会和当前指令抢注意力。如果你用的是 Claude 的 system prompt 机制,可以把记忆拼进 system prompt 的末尾,效果也不错。

5.4 一个完整的注入流程

把前面的环节串起来,一次完整的记忆注入流程是这样的:

  1. 用户发来新消息
  2. 用这条消息做 query,走混合检索,召回 20 条候选
  3. 对候选做时间衰减和热度加权,重新排序
  4. 按类型配额筛选,取最终 3 到 7 条
  5. 套进注入模板,拼到 system prompt 末尾
  6. 连同用户消息一起发给 Claude
  7. 对话结束后,异步触发记忆抽取,写入新记忆

这个流程里,第 2 到 4 步是检索,第 5 步是注入,第 7 步是抽取。整个链路跑通后,你会明显感觉到 Claude 变得“记得住事”了。

6. 常见问题与排查技巧实录

6.1 记忆检索不准怎么办

这是最高频的问题。表现是:明明存了相关记忆,但检索时就是召不回来。排查思路按顺序来:

第一步,检查向量化模型是否一致。写入和查询用了不同模型,向量空间对不上,检索必然失效。这是最常见的低级错误。

第二步,看记忆条目的质量。如果记忆本身写得含糊(比如“用户说了个项目”),向量化后语义信息就弱,检索自然不准。回到抽取环节,确保每条记忆都是具体、自包含的。

第三步,调检索参数。top_k 太小会漏,太大引入噪音。先放大到 50 看看能不能召回,能召回说明是 top_k 的问题,再逐步收窄。

第四步,加关键词兜底。如果向量检索始终不行,说明你的场景里精确匹配更重要,加大关键词检索的权重。

6.2 记忆库膨胀太快

用了一个月发现记忆库上万条,检索变慢、噪音变多。这个问题我遇到过,解决方案是定期清理 + 归档。

清理策略:access_count为 0 且超过 90 天的记忆,直接归档到冷存储(另一张表或另一个文件),不参与日常检索。confidence低于 0.5 的记忆,直接删除。重复记忆合并。

我写了个定时任务,每周跑一次清理,把活跃记忆控制在 2000 条以内。这个量级下检索延迟稳定在 50ms 以内,体验很好。

6.3 记忆冲突导致模型行为异常

典型表现:模型一会儿用 MySQL 一会儿用 PostgreSQL,因为它同时召回了新旧两条冲突记忆。排查时先看检索结果里有没有冲突条目。

解决分两层。写入层:抽取时检测冲突,新记忆覆盖旧记忆,旧记忆标记为superseded。检索层:如果召回结果里有冲突,只保留最新的那条,并在注入时明确标注“此决策已更新”。

6.4 常见问题速查表

问题现象可能原因排查方向解决方案
检索召回为空向量模型不一致对比写入/查询模型统一模型
召回不相关记忆条目太含糊抽查记忆内容优化抽取 prompt
检索变慢记忆库过大统计条目数清理归档
模型行为矛盾冲突记忆共存检查召回结果冲突检测+覆盖
注入后模型忽略注入位置不当调整拼接位置放 system prompt 末尾
抽取成本高抽取频率过高统计调用次数按信息增量触发

6.5 独家避坑技巧

最后分享几个文档里不会写、但实际很管用的技巧。

技巧一:给记忆加“来源可信度”。用户明确说的(“记住,我用的是 X”)可信度最高,模型推断出来的(“看起来你用的是 X”)可信度低。检索时按可信度加权,能有效减少误记带来的干扰。

技巧二:定期人工抽查。我每周会随机抽 20 条记忆看看质量,发现抽取 prompt 的问题就及时调。自动化再好,也需要人工兜底。

技巧三:会话 ID 要稳定。如果你用会话 ID 做记忆隔离,确保同一个项目的会话用同一个 ID。我一开始用随机 UUID,结果同一项目的记忆散落在几十个会话里,检索时互相看不见,白存了。

技巧四:给检索加个“最近使用”通道。除了语义检索,额外维护一个“最近 N 条记忆”的列表,每次注入时带上最近 2 条。这样即使语义检索没召回,模型也能感知到最近的上下文,体验更连贯。

技巧五:备份要包含向量。很多人备份只导 SQL 数据,忘了向量是 BLOB 存的。恢复时如果向量丢了,所有记忆都得重新向量化,费时费力。备份时把整张表连同 BLOB 一起导出。

这套记忆层搭下来,Claude 从一个“每次都要重新自我介绍”的陌生人,变成了一个“记得你项目细节、记得你偏好、记得你踩过的坑”的老搭档。前期投入大概两三天,后面省下的重复沟通时间,一周就赚回来了。如果你也在做长期项目,强烈建议把这套机制跑起来。