给Claude装上长期记忆:设计并实现本地记忆层
如果你和我一样,习惯把 Claude 当成长期知识库来用——项目思路、会议纪要、代码方案全都在同一个对话框里来回聊,那你大概率遇到过这个场景:上周明明让它帮忙整理过一套迁移方案,这周再问,它一脸茫然地重新给你分析了一遍。别怪模型变笨,这是对话机制决定的:每次会话结束,上下文就会被清空,模型对你的印象几乎归零。为了治这个毛病,我写了一个本地小工具,代号就叫 claude-mem。
claude-mem 的思路并不复杂:把聊天里值得记住的信息抽取、结构化、落库,再在下一轮对话开始时按需回灌给模型,让模型能“想起”几周甚至几个月前的决策。这篇文章不打算介绍什么现成插件,而是把我在设计、实现、然后连续使用三个月沉淀下来的完整经验写出来,包括记忆单元怎么设计、数据库怎么建、回灌怎么拼、踩了哪些坑,给正在折腾同类方案的朋友做个参考。
1. 记忆清零的尴尬:每一次对话都是“新同事报到”
1.1 先还原一个让我决定做 claude-mem 的场景
我当时在推进一个内部模拟项目 X,目标是做一套跨平台的数据迁移工具。前期和模型聊了很多轮,确定了迁移顺序、异常处理策略、甚至把表结构映射规则都敲定了。两周后我打开新会话,想让它基于之前的结论补充一段执行脚本,结果它完全不记得我们已经选过方案,甚至在回复里重新提出了一个和原方案冲突的设计。
那一刻我意识到,模型本身没有任何“跨会话保留状态”的能力。它就像一个只上一次班就离职的新同事,每次上岗都拿着相同的说明书,却不知道你上个星期刚拍板过什么。上下文窗口再大,关掉对话框也就不存在了。要让模型具备连续工作的能力,必须在模型之外补一个记忆层。
1.2 把“记不住”拆成三个需要分别解决的问题
如果仔细看,“记不住”其实是三个问题的叠加。
第一个是状态问题:模型没有跨会话持久化状态。这没法靠模型自身解决,只能在外部保存信息,然后在合适的时候重新喂给它。
第二个是容量问题:哪怕你把所有历史聊天全文都塞回上下文,几十轮、上百轮的对话文本很快就会撑爆上下文窗口,而且按 token 计费的话成本会快速失控。所以不能无脑回灌,必须做筛选。
第三个是鉴别问题:聊天记录里不是每条都有长期价值。很多寒暄、过程性讨论、临时的试探性选项,根本不值得记住。如果全都留下来,噪声会淹没真正重要的决策痕迹。所以我们需要一套抽取和分级机制。
明白了这三点,思路就清晰了:做一个中间层,负责在会话结束前后提取记忆、管理记忆、按需投递记忆。这就是 claude-mem 的定位。
1.3 我给 claude-mem 划定的功能边界
很多第一次接触这类工具的人会想:那我直接做全文检索不就行了?把所有历史聊天塞进向量库,每次把相似片段拉回来。我一开始也试过这个路线,后来放弃了。
全文检索的问题是:把聊天片段原文拉回来,经常会带回一些没有最终结论的碎碎念,模型要用这些片段去“猜”你到底定了什么,效果非常不稳定。所以我给 claude-mem 定的核心原则是:不存原文流水账,只存结论、约束、偏好、任务状态。它做的是“记忆管理”,不是“聊天记录备份”。
这样一来,存储量大幅下降,回灌内容也干净很多。代价是需要花一些精力设计抽取逻辑。这个代价我认为非常值得。
2. claude-mem 到底存什么:记忆单元、分级与更新时机
2.1 记忆单元长什么样
既然要做记忆管理,第一步就是定义记忆的存储格式。我给它起了个名字叫“记忆单元”,一条记忆对应一个结构化记录。最基本字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 记忆主键 |
| content | TEXT | 记忆正文,必须是完整、独立的陈述句 |
| kind | TEXT | 记忆类型:decision / preference / progress / context |
| importance | INTEGER | 重要性,1 到 3,3 为最高 |
| source_session | INTEGER | 来源会话 ID,方便回溯 |
| created_at | TEXT | 创建时间 |
| invalid | INTEGER | 是否失效,0 正常,1 失效 |
content 字段我要求非常严格:只能写成“已完成……的选型,采用方案 A”“用户明确表示优先保稳定性”这样不需要上下文也能独立理解的句子。那种“这个方案就像上次说的那样”的表述一律重写,否则下次回灌的时候模型根本看不懂。
2.2 三级记忆分级
我一开始只用一个布尔“是否重要”来区分,后来发现不够用。有些记忆每次对话都必须带上,有些只是在相关话题出现时才需要,还有些基本不需要主动回灌。于是我把记忆分成了三级:
| 级别 | 代表内容 | 回灌策略 |
|---|---|---|
| 核心记忆 | 项目目标、已确认方案、硬性约束 | 每次会话都注入 |
| 参考记忆 | 用户偏好、背景事实、阶段性结论 | 按相关度检索后注入 |
| 临时记忆 | 一次性的细节、过程性信息 | 不主动注入,仅手动查询用 |
核心记忆通常很少,我限制在 20 条以内。参考记忆可以多一些,但回灌时会通过相似度筛选,只放最相关的那批。临时记忆说白了就是给人工排查用的,不参与自动投递逻辑。
2.3 什么时候触发记忆更新
把聊天记录变成记忆,时机很重要。我现在主要靠四条触发规则。
第一条是用户主动要求,比如我直接说“记住:以后所有 SQL 迁移脚本都放到 migration 目录下”。这种最可靠,必须无条件存。
第二条是对话中出现明确结论。比如模型说“那就确定用双写方案吧”,或者用户说“就这么定”。此时要抽取一条决策记忆。
第三条是任务状态发生变化。例如“第一阶段已完成,接下来进入数据校验”,这种进度信息很有价值,值得单独记一条。
第四条是会话结束后的自动摘要。我会在每次会话收尾时让模型对整轮对话做一次压缩,挑出有长期价值的内容生成候选记忆,进入待确认列表,而不是直接写库。这样可以避免很多无效记录。
3. 落库与检索:用 SQLite 加向量搜索搭起记忆存储
3.1 为什么选 SQLite 而不是 JSON 文件
在存储选型上,我对比过 JSON 文件、SQLite 和 Redis。Redis 更偏向缓存场景,还要额外维护一个服务端进程,对单机工具来说有点重。JSON 文件读写简单,但更新一条记录就得全量解析、全量写回,数据量稍微上去一点就开始别扭。
最后选了 SQLite。单文件、零服务、支持 SQL 条件更新,备份也简单,直接复制文件就行。以 claude-mem 的定位来看,数据量撑死几千条记忆,SQLite 完全够用,而且交付给别人时也不需要装任何额外组件。
3.2 三张表的结构说明
我实际的库表结构比记忆单元本身多两张表,一共三张。第一张是会话表,记录每次会话的起止时间和会话主题,用于追溯记忆来源。第二张是记忆表,就是前面定义的核心字段。第三张是可选向量表,用来存每条记忆对应的向量向量,用于语义检索。
建表 SQL 大致长这样:
CREATE TABLE session ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, started_at TEXT, ended_at TEXT ); CREATE TABLE memory ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER, content TEXT NOT NULL, kind TEXT NOT NULL, importance INTEGER DEFAULT 1, created_at TEXT NOT NULL, invalid INTEGER DEFAULT 0 ); CREATE TABLE memory_vector ( memory_id INTEGER PRIMARY KEY, embedding BLOB, model_tag TEXT );向量单独放一张表,而不是直接在 memory 表里加字段,主要是考虑到向量模型的版本会升级。不同时期生成的向量不一定可比,当 embedding 模型换版本时,我可以只重建这张表,保留原始记忆文本不动。
3.3 写入与检索的核心逻辑
记忆写入逻辑的核心如下。我简化成一个最小可运行的示例:
import sqlite3 from datetime import datetime class MemoryStore: def __init__(self, db_path): self.conn = sqlite3.connect(db_path) self.conn.execute(""" CREATE TABLE IF NOT EXISTS memory ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, kind TEXT NOT NULL, importance INTEGER DEFAULT 1, source_session INTEGER, created_at TEXT NOT NULL, invalid INTEGER DEFAULT 0 ) """) self.conn.commit() def add(self, content, kind="context", importance=1, source_session=None): cur = self.conn.execute( "INSERT INTO memory(content, kind, importance, source_session, created_at) VALUES (?, ?, ?, ?, ?)", (content, kind, importance, source_session, datetime.utcnow().isoformat()), ) self.conn.commit() return cur.lastrowid def invalidate(self, memory_id): self.conn.execute( "UPDATE memory SET invalid = 1 WHERE id = ?", (memory_id,) ) self.conn.commit()检索流程我一般分四步:先对当前用户问题做向量化,再到向量表里做相似度 top-k 召回,然后过滤掉 invalid=1 的旧记忆,最后按重要性和时间做一个排序。向量检索部分不一定要自己写索引库,现成的向量数据库可以处理,但在数据量不大的前提下,直接读全表算余弦相似度也够快。真正决定效果好坏的,不在检索速度,而在记忆本身质量。
3.4 向量检索以外的取舍
有人会问:如果不想接向量库,能不能用关键词匹配?可以用,但效果差距明显。记忆和当前问题的表述往往不完全一致。比如你之前存的是“迁移采用灰度发布”,下次你问的是“上线时怎么控制风险”,关键词重叠度很低,但语义上高度相关,向量检索才能把它找出来。
我还试过直接让模型做语义判断再决定是否召回,那样成本太高,而且每轮都调模型,延迟也很明显。向量检索是最平衡的方案。
4. 回灌实战:把记忆塞回上下文的正确姿势
4.1 注入位置与格式
记忆抽取完,最终要配合对话模型使用。模型本身是状态无关的,所以我们要在构造对话时,把记忆作为前缀放进上下文。我采用的做法是:在 system 提示之后、正式对话内容之前,拼入一段“已知记忆”区域。
常见的格式:
[已知记忆] - 2025-04-02 [决策] 数据库迁移方案已确定采用双写,先跑一周观察数据一致性 - 2025-04-05 [约束] 所有迁移脚本需放到 migration 目录下 - 2025-04-12 [进度] 第一阶段完成,已进入数据校验阶段位置为什么放前面?因为模型对上下文开头的注意力权重通常更高。如果放在很靠后的位置,可能被更长的当前问答稀释。我尝试过放在末尾,效果不如开头稳定。所以现在统一放开头。
4.2 按重要性和时间筛选
回灌不是把所有记忆一股脑塞进去。我定了一个很保守的预算:核心记忆最多 5 条,参考记忆最多 10 条,加起来也就 15 条,长度控制在 800 token 以内。筛选逻辑简单说就是:先按相似度召回候选,再按重要性和时间衰减综合排序,取前 N 条。
这里有个细节:旧记忆如果不带时间信息,回灌多了模型容易把几周前的状态当成当前的。所以我在每条记忆前都加了创建时间,格式就是上面那样的 ISO 日期。模型看到日期后,至少能区分“这是旧信息”,不会把已经过期的进度当作现状。
4.3 实测效果观察
改造完成之后,我拿模拟项目 X 连续试了三周。最大的变化是:跨会话的一致性明显改善。不用再花半轮对话去重申“我们上周选的是方案 A,别再说 B 了”,新会话直接基于记忆回灌,开头就能接住之前的结论。
另外一个意外收获是,我发现自己重复说明的次数变少了。过去每次开新会话都要重新描述项目背景,现在背景作为核心记忆自动带上,对话可以更早进入正题。当然,也出现了几次翻车,比如无中生有的错误记忆被注入,或者过期的旧进度被当成新进度,这些问题我放到下一节详细说。
5. 连跑三个月后踩到的坑:膨胀、污染与失效
5.1 上下文膨胀:记忆不是越多越好
第一周我比较贪心,把核心记忆上限调到了 20 条,参考记忆调到了 20 条。结果模型的回复开始出现一种诡异的现象:它太“重视”记忆了。明明当前问题是简单的格式转换,它却会主动扯出记忆里的迁移方案,回答变得冗长而且偏题。
原因不难理解:当上下文中记忆占比过高,当前问题占比被压缩,模型的注意力就会被历史信息拉走。后来我把核心记忆压到 5 条,参考记忆压到 10 条,效果立刻恢复正常。这个阈值不一定适合所有人,但如果你的模型回答开始“过度引用历史”,第一件事就是检查是不是注入过量了。
5.2 错误记忆固化:最危险的一个坑
比上下文膨胀更麻烦的是错误记忆固化。有次和模型讨论一个错误处理策略,它给了一个并不正确的重试方案,我当时没细看,顺手把“已确定采用重试方案”存成了核心记忆。结果接下来整整两周,每次对话模型都默认这个错误方案是对的,直到我核对日志才发现问题。
从那以后我加了一道缓冲机制:自动抽取的记忆不会直接进入核心库,而是先进一个“待确认”列表。只有我自己确认过,或者对话中用户明确认可,才标为核心记忆。这个改动牺牲了一点自动化便利,但换来了可靠性的巨大提升。
5.3 过期记忆的失效标记
第三个坑是记忆会过期。项目进行到第四周,我们的方向变了,原定的迁移顺序彻底作废,但记忆库里的旧进度还没删除。新会话里模型还在依据旧进度回答,我纠正了好几次才反应过来是记忆库的问题。
现在我会在两类情况下主动做失效标记:一是当新记忆和旧记忆针对同一主题且结论冲突时,自动把旧记忆置为 invalid;二是每次周检时,把超过一定时限、且没有被近期对话引用过的参考记忆降为临时记忆。这么说可能有点抽象,但核心其实就一句话:记忆不是写进库就完事,还要有生命周期管理。
5.4 数据安全与成本
最后说两个大家容易忽略的问题。第一个是敏感信息。Claude 这类对话模型的 API 调用会把输入发送到远端服务,如果你的记忆库里有密码、密钥、完整的内部文档,回灌时等于主动把敏感内容发给第三方。我在实践中会把所有记忆文本过一道脱敏规则,只保留结论性的描述,不在记忆正文里写具体密钥。
第二个是成本。回灌的记忆最终都要转成 token 计入每次请求。哪怕记忆写得再精炼,只要每次都注入,长期成本就会累积。所以我把记忆回灌设计成“按需装配”:普通会话只带核心记忆;当检测到问题涉及某个具体项目时,才临时检索并注入相关参考记忆。
6. 记忆工具的边界与下一张拼图
6.1 本地优先与数据自主
用 claude-mem 三个月,我最大的感受是:记忆数据必须本地优先,不能放到某个封闭的云端服务里。因为记忆是所有对话价值沉淀下来的资产,一旦服务不可用或账号被封,你积累的知识资产就什么都没了。
我的做法是:SQLite 文件放在本地目录,记忆的写入和检索全部本地完成,只有最终拼好的上下文才会随对话请求发出。这样即使哪天模型本身不能用了,记忆库仍然是我自己可读、可迁移的财富。
6.2 多会话、多角色的记忆隔离
还有一个必须处理的问题是隔离。同一个对话模型可能同时在服务多个项目、多种角色。如果所有项目共用一套记忆,A 项目的结论会在 B 项目的对话里被当成背景,直接造成串味。
我给记忆表增加一个 project_id 字段,回灌时按 project_id 过滤,不同项目之间完全隔离。多角色的场景也类似,相当于每条记忆都挂在一个命名空间里。这个设计看起来简单,但很多人做记忆工具时会漏掉,等串味了才回头补。
6.3 我现在的使用习惯和后续计划
目前 claude-mem 已经成了我日常和对话模型协作的固定中间层。我养成了两个习惯:每天结束后扫一眼新增记忆,把不需要的立刻标失效;周末用一条空会话让模型帮我对记忆库做一次“体检”,找出阶段性旧信息,再决定降级还是删除。
如果你也想做类似的记忆工具,我的建议是不要一开始就把功能做得很大。先解决“容易忘掉已确定结论”这一个痛点,跑顺之后再加自动摘要、向量检索、多项目隔离。记忆管理的本质不是堆功能,而是对抗混乱。在这一点上,少即是多。