1. 项目缘起与核心定位
第一次看到claude-mem这个名字,我的直觉是:这大概率是一个围绕 Claude 生态做“记忆层”的项目。事实也确实如此。简单说,claude-mem要解决的是一个所有长期使用大模型的人都会撞上的痛点——模型本身没有跨会话的持久记忆。你这次跟它聊完一个项目的架构设计,关掉窗口,下次再开,它对你、对你的项目、对你们之前达成的共识一无所知,一切从零开始。
这个项目做的事情,就是给 Claude 这类对话式模型外挂一套可持久化的记忆系统。它把对话中产生的关键信息抽取、压缩、存储下来,在后续会话中按需检索、注入上下文,让模型“记得住”之前发生过什么。适合谁来参考?三类人:一是重度依赖 Claude 做长期项目协作的开发者;二是想给自己的 AI 应用加记忆能力的工程师;三是对 RAG、向量检索、上下文工程感兴趣,想找一个具体落地案例来拆解的学习者。
我之所以愿意花时间把claude-mem这套思路完整拆一遍,是因为“记忆”这件事看起来简单,真做起来坑极多——存什么、怎么存、什么时候取、取多少、怎么防止污染上下文,每一步都有取舍。下面我按自己实际搭建和调试的经验,把整套逻辑讲透。
2. 记忆系统的整体设计与思路拆解
2.1 为什么不能只靠“把历史对话全塞进去”
很多人第一反应是:记忆嘛,把之前的对话记录全部拼到 prompt 里不就行了。这个方案在小规模下能用,一旦对话轮次上去就彻底崩了。原因有三点,我实测下来每一点都很致命。
第一是上下文窗口的硬约束。即便模型支持很长的上下文,把几十轮历史全塞进去,token 消耗会线性甚至更快地膨胀,成本和延迟都不可接受。第二是注意力稀释。上下文里塞了大量无关的旧对话,模型对当前问题的注意力会被摊薄,回答质量反而下降,这就是所谓的“lost in the middle”现象。第三是噪声累积。历史对话里大量是寒暄、试错、被推翻的方案,这些内容留在上下文里会误导模型,让它把已经废弃的结论当成事实。
所以claude-mem的核心设计哲学必然是:不存原始对话,存提炼后的记忆单元;不全量注入,按相关性检索注入。这是整个项目的地基,后面所有细节都从这里推导出来。
2.2 记忆的分层模型
我在复现这套系统时,把它拆成了三层,这个分层不是拍脑袋,而是对应了不同的检索频率和生命周期。
| 层级 | 存储内容 | 生命周期 | 检索方式 |
|---|---|---|---|
| 工作记忆 | 当前会话的近期上下文 | 会话内 | 直接拼接 |
| 情节记忆 | 具体事件、决策、结论 | 长期 | 向量相似度检索 |
| 语义记忆 | 提炼出的事实、偏好、规则 | 长期 | 关键词+向量混合 |
工作记忆就是当前这轮对话的滑动窗口,不需要持久化。情节记忆记录“什么时候发生了什么”,比如“用户在 3 月决定用 PostgreSQL 而不是 MySQL”。语义记忆则是从多条情节里归纳出的稳定知识,比如“用户偏好强类型语言”。这个分层的好处是:检索时可以按需选择粒度,问具体历史就查情节,问偏好就查语义,避免一股脑全捞出来。
2.3 存储选型:为什么是向量库加结构化存储的组合
纯向量库能解决语义检索,但解决不了精确过滤。比如我想查“上周关于数据库的所有决策”,纯向量检索做不到时间范围过滤。所以claude-mem这类系统的合理选型是混合存储:向量库存 embedding 用于语义召回,结构化库(SQLite 或 Postgres)存元数据用于过滤和排序。
我自己的实现里用 SQLite 存元数据加一个轻量向量索引,理由是单机部署零依赖、迁移方便。如果要做多用户或者大规模,换成 Postgres 加 pgvector 是更稳的选择。这里的关键考量是:记忆系统的读写比通常很高,读多写少,所以检索路径的性能比写入路径更重要,选型时要优先保证查询快。
3. 核心细节解析与实操要点
3.1 记忆抽取:什么该存,什么该丢
这是整个系统里最难、最影响效果的一环。存得太多,检索出来的全是噪声;存得太少,关键信息丢失。我的经验是设定一套明确的抽取规则,而不是让模型自由发挥。
我实际用的抽取触发条件是这样的:当一轮对话中出现决策、偏好、事实性结论、待办事项这四类信号时,才触发记忆写入。具体判断可以交给一个轻量的抽取 prompt,让它输出结构化的 JSON。下面是我用的抽取 prompt 骨架:
EXTRACT_PROMPT = """ 从以下对话中抽取值得长期记忆的信息,只抽取以下类型: - decision: 明确做出的决策 - preference: 用户表达的偏好 - fact: 确认的事实 - todo: 待办事项 以 JSON 数组返回,每项包含 type, content, confidence(0-1)。 没有值得记忆的内容就返回空数组。 对话: {dialogue} """这里有个关键细节:confidence 字段非常重要。模型抽取出来的记忆有置信度高低之分,低置信度的记忆在检索时应该降权,甚至不注入。我踩过的坑是早期没做置信度过滤,结果模型把一句玩笑话当成用户偏好存了下来,后面每次推荐都往那个方向偏,非常尴尬。
注意:抽取 prompt 里一定要明确“没有就返回空数组”,否则模型倾向于硬凑内容,导致大量低质量记忆入库。
3.2 记忆压缩与去重
同一个事实可能在多轮对话里被反复提及,如果每次都存一条,检索时会返回一堆重复内容,浪费上下文。所以写入前必须做去重。我的做法是:新记忆入库前,先用向量检索找出相似度超过阈值(我设的是 0.92)的已有记忆,如果命中,就更新已有记忆的时间戳和置信度,而不是新增。
压缩则是针对长文本记忆。一条记忆如果超过一定长度(比如 200 字),就再跑一次摘要,把它压到 100 字以内。原因是检索回来的记忆最终要拼进 prompt,太长会挤占宝贵的上下文预算。这里的原则是:记忆的存储可以详细,但注入上下文时必须精简。
3.3 检索策略:相似度不是唯一指标
新手最容易犯的错是只按向量相似度排序。实际用下来,一个记忆是否该被召回,取决于三个因素的加权:语义相似度、时间新鲜度、重要程度。我用的打分公式大致是:
score = 0.6 * similarity + 0.25 * recency + 0.15 * importancerecency 用时间衰减函数算,越近的记忆分越高;importance 来自写入时的 confidence 和记忆类型(决策类权重高于事实类)。这个权重不是固定的,要按你的场景调。比如做长期个人助理,recency 权重可以低一点,因为用户的老偏好依然有效;做实时任务助手,recency 就要拉高。
3.4 上下文注入的预算控制
检索回来的记忆不能无脑全塞。我给自己定了一条硬规则:记忆注入占用的 token 不超过总上下文预算的 20%。假设模型上下文是 200k,那记忆部分最多 40k,通常我会控制在 8k 以内,留足空间给当前对话和系统提示。
注入时的排序也有讲究。把最相关的记忆放在 prompt 的开头和结尾,中间放次要的,这是利用了模型对首尾位置注意力更强的特性。每条记忆前面加一个简短的类型标签,比如[决策]、[偏好],帮助模型快速理解这条信息的性质。
4. 实操过程与核心环节实现
4.1 环境搭建与依赖安装
我用的技术栈是 Python 加 SQLite 加一个本地向量模型,整套东西单机就能跑,不需要外部服务。先建虚拟环境装依赖:
python -m venv venv source venv/bin/activate pip install sqlite-utils sentence-transformers numpy向量模型我选的是sentence-transformers里的all-MiniLM-L6-v2,理由是体积小(约 80MB)、推理快、在英文语义检索上够用。如果你的记忆以中文为主,换成paraphrase-multilingual-MiniLM-L12-v2效果会更好。这里不用调外部 API 做 embedding,是为了让整个记忆系统离线可用,隐私和延迟都更可控。
4.2 数据库表结构设计
记忆库的表结构我改过三版,最终稳定下来的是这样:
CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, -- decision/preference/fact/todo content TEXT NOT NULL, -- 记忆正文 embedding BLOB, -- 向量,序列化存储 confidence REAL DEFAULT 1.0, -- 置信度 importance REAL DEFAULT 0.5, -- 重要度 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, access_count INTEGER DEFAULT 0 -- 被检索次数 ); CREATE INDEX idx_type ON memories(type); CREATE INDEX idx_created ON memories(created_at);access_count这个字段是我后来加的,用来做热度加权——被频繁检索的记忆说明它确实有用,可以在打分时给一点加成。embedding 直接存 BLOB,SQLite 没有原生向量类型,用 numpy 的tobytes()序列化,读出来再frombuffer()还原,实测几万条记忆检索延迟在几十毫秒级别,完全够用。
4.3 写入流程的完整实现
写入是记忆系统的入口,逻辑必须严谨。我的写入函数大致长这样:
def add_memory(dialogue, embedder, conn): # 1. 抽取 raw = extract(dialogue) # 调用抽取 prompt if not raw: return for item in raw: if item['confidence'] < 0.6: # 低置信度直接丢 continue vec = embedder.encode(item['content']) # 2. 去重检查 similar = search_similar(conn, vec, top_k=1) if similar and similar[0]['similarity'] > 0.92: update_memory(conn, similar[0]['id'], item) else: insert_memory(conn, item, vec)这里有两个我踩过的坑要提醒。第一,置信度阈值 0.6 是调出来的,一开始设 0.8 太严,很多有用信息被丢;设 0.4 太松,噪声太多。0.6 是我在几十轮测试里找到的平衡点,你可以从这个值开始调。第二,去重阈值 0.92 也不能乱设,太高会漏掉语义相同但表述不同的记忆,太低会把不同的事实误判为重复。建议先用一批样本测一下相似度分布再定。
4.4 检索与注入的完整链路
检索环节是把记忆用起来的关键。完整链路是:当前用户输入 → 生成 query embedding → 混合检索 → 打分排序 → 预算裁剪 → 拼进 prompt。
def retrieve(query, embedder, conn, budget_tokens=2000): qvec = embedder.encode(query) candidates = search_similar(conn, qvec, top_k=20) scored = [] for c in candidates: score = (0.6 * c['similarity'] + 0.25 * recency_score(c['created_at']) + 0.15 * c['importance']) scored.append((score, c)) scored.sort(reverse=True) # 按 token 预算裁剪 selected, used = [], 0 for score, mem in scored: t = count_tokens(mem['content']) if used + t > budget_tokens: break selected.append(mem) used += t bump_access(conn, mem['id']) # 更新访问计数 return selectedbump_access这一步别省,它是热度加权的数据来源。检索回来的记忆按类型标签格式化后拼进系统提示,我用的格式是每行一条:[决策] 用户选择 PostgreSQL 作为主数据库。实测这种带标签的格式比纯文本拼接,模型对记忆的利用率明显更高。
4.5 参数调优的实测记录
我把几个关键参数的调优过程记录一下,方便你少走弯路。相似度去重阈值从 0.85 调到 0.92,重复记忆减少了约 70%,但漏检增加了,最后折中在 0.90。检索 top_k 从 10 加到 20,召回率提升明显,但超过 20 后收益递减,因为后面的候选基本不相关。注入预算从 4000 token 压到 2000,回答质量几乎没降,但延迟降了约 30%。这些数字不是标准答案,但能给你一个调参的起点。
5. 常见问题与排查技巧实录
5.1 记忆污染:模型把错误信息记住了
这是最头疼的问题。表现是模型反复引用一条错误或过时的记忆。排查思路是:先查这条记忆的来源和置信度,如果是低置信度误入库,直接删掉;如果是过时信息,需要一套失效机制。我的做法是给记忆加一个superseded_by字段,当新记忆与旧记忆冲突时,把旧的标记为被取代,检索时过滤掉。判断冲突可以靠抽取 prompt 里加一步“是否与已有记忆矛盾”的检查。
5.2 检索召回不准
用户明明之前说过某件事,但系统就是检索不出来。常见原因有三个:一是 embedding 模型对中文支持差,换多语言模型;二是记忆内容被压缩得太狠,语义信息丢失,这时候要放宽压缩阈值;三是 query 和记忆的表述差异太大,纯向量检索搞不定,需要加关键词检索做混合召回。我最后是上了 BM25 加向量的混合检索,召回率提升很明显。
5.3 上下文被记忆挤爆
如果发现模型开始忽略当前对话,只盯着记忆回答,多半是记忆注入太多。检查注入预算是否超标,以及是否有大量低相关度记忆被塞进去。解决方法是收紧打分阈值,只注入 score 排前几位的记忆,同时把预算硬卡死。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 模型引用错误记忆 | 低置信度入库/过时 | 查来源与置信度 | 删除或标记失效 |
| 该记的没记住 | 抽取阈值过严 | 看抽取日志 | 降 confidence 阈值 |
| 检索召回不准 | 模型/压缩/表述差异 | 测相似度分布 | 换模型+混合检索 |
| 上下文被挤爆 | 注入预算超标 | 统计注入 token | 收紧阈值+卡预算 |
| 记忆重复堆积 | 去重阈值过高 | 查相似度 | 降去重阈值 |
5.5 几条独家避坑经验
第一,记忆系统一定要有“遗忘”能力。我早期只做写入不做清理,几个月后库里全是过时信息,检索质量断崖式下跌。后来加了定期清理:超过一定时间且从未被检索过的记忆自动归档。第二,写入和检索要用同一个 embedding 模型,中途换模型会导致新旧记忆向量空间不一致,检索直接失效,换模型必须全量重算。第三,给记忆系统加日志,每次写入和检索都记下来,出问题时这是唯一的排查依据,没有日志你根本不知道模型为什么记住了某条信息。
6. 记忆系统的扩展方向
把基础版跑通之后,我试过几个扩展,效果不错,分享给你。一个是记忆的主动遗忘曲线,模仿人类记忆,新记忆衰减慢、老记忆衰减快,长期不用的自动降权。另一个是跨项目记忆隔离,不同项目用不同的命名空间,避免工作记忆和生活记忆互相污染。还有一个是记忆的可视化面板,把库里的记忆按类型和时间线展示出来,方便人工审查和手动修正,这个对调试特别有用。
claude-mem这类项目的价值不在于技术多复杂,而在于它把“让模型记住事”这个模糊需求,拆解成了一套可工程化、可调优、可排查的具体方案。我自己搭完这套东西之后,最大的体会是:记忆系统的效果,八成取决于抽取和检索这两个环节的细节打磨,而不是用了多高级的向量库。把抽取规则定清楚、把打分公式调顺、把预算卡死,一个简单的 SQLite 方案就能跑出很好的效果。