做 AI 应用最憋屈的一件事,就是模型永远不记得你是谁。明明刚才还在聊某个项目背景,换个会话它又变成第一次见面的陌生人,所有偏好、结论、上下文全部归零。Claude 的 API 本质上就是无状态的,每次请求都是一次全新的对话。我一开始靠手动复制粘贴历史记录,后来实在受不了,就直接自己写了一个叫 claude-mem 的小工具,给对话加一层长期记忆。
claude-mem 不是什么大工程,就是一个跑在 Claude 前面的记忆层。它负责把过往对话里的关键信息抽取出来、结构化存起来,下次再发起对话时自动把相关记忆拼进提示词里。这样 Claude 就能“想起”你是谁、你关心什么、之前聊到哪一步。这篇文章我会把我踩过的坑、选型的理由、核心实现细节全部摊开讲,重点面向正在做 Claude 应用但被上下文长度和无状态问题折磨的开发者。你不用懂太多底层算法,直接照着下面的思路改就能用起来。
1. 为什么给 Claude 做记忆层而不是直接堆上下文
我刚接触这件事的时候也想过一个土办法:每次请求之前,把之前全部聊天记录都塞给 Claude。短期内能用,但只要会话超过几轮,token 成本就直线上升,而且 Claude 根本抓不住重点。后来我才想明白,问题的核心不是“把历史给模型看”,而是“让模型只看到当前真正需要的那部分历史”。
1.1 Claude 的问题不是笨,是“不记事”
这里需要先理解 Claude 这类大语言模型的工作方式。你可以把它想象成一个只有短期记忆的专家,你发给它的整段提示词就是它的工作记忆。上一轮对话结束之后,这段工作记忆就会被清空,下一次请求又是从零开始。Claude 本身没有“长期记忆”这个功能模块,它就只能依赖你每次把相关历史重新喂进去。
所以很多人做聊天机器人时,第一个反应就是“把最近 N 轮对话存下来,下次跟着问题一起发”。这种做法不是完全不行,但有一个很隐蔽的问题:N 轮对话里绝大多数内容都是冗余的。比如用户说了句“嗯嗯”“好的”,或者围绕同一个问题来回确认细节,这些内容对当前决策毫无帮助,却白白占掉 token。比如上下文窗口是 20 万 token,看起来很大,但你要是把大量原始日志堆进去,聊个两小时大概就满了,而且满窗口之后模型反而容易忽略真正重要的信息。
1.2 记忆怎么分类才不会有副作用
我在设计 claude-mem 的时候,没有把所有历史一把抓,而是先把记忆分成了四类:用户事实、偏好设置、对话摘要、临时状态。用户事实是“我叫张三,在做一个电商平台”;偏好设置是“我喜欢简洁的回答,不要用敬语”;对话摘要是一段时间内聊过的话题脉络;临时状态是“当前正在配置支付接口,已经完成了签名部分”。
为什么要分得这么细?因为不同记忆的失效时间完全不一样。用户事实和偏好设置几乎不会变,可以长期保存;对话摘要是需要滚动的,上次会话结束时的总结可以作为下次会话的开头;临时状态则可能几小时后就过期了。如果所有信息都混在一个表里,检索的时候就会互相干扰。比如你问 Claude 支付接口的问题,它把三周前的“想给网站加暗黑模式”也翻出来,那就跑题了。
1.3 功能边界:claude-mem 不做什么
在设计这个项目时,我把边界划得很清楚。claude-mem 不做对话生成,不代替 Claude 思考,也不去访问 Claude 以外的任何模型。它只做三件事:抽取记忆、存储记忆、召回记忆。所有理解、推理、回答仍然交给 Claude 本身,claude-mem 更像一个贴身助理,负责提前把文件放到专家桌上,专家负责看文件并给出结论。
这个边界很重要。一旦你想让工具“更聪明”,比如让它自己判断要不要回答、让它生成摘要后自动执行下一步,复杂度就会爆炸式增长。我见过不少项目死在过度设计的路上,所以 claude-mem 从一开始就坚持单一职责,所有逻辑都围绕“记忆”两个字展开。
2. 记忆系统选型:SQLite、向量检索还是图库?
确定要做的记忆层之后,我第一个纠结的问题就是用什么存储。当时在三个方案之间摇摆:纯 SQLite、向量数据库、图数据库。最后我选了“SQLite + 关键词召回为主、向量检索为辅”的组合。这个选择不是最优的,但对一个个人项目和中小型应用来说,是目前体验和复杂度的最佳平衡点。
2.1 为什么不用纯内存缓存
最偷懒的做法是把所有记忆放在 Python 字典或者 Redis 里,程序一跑,记忆全在,重启就没了。对一个需要长期使用的工具来说,这不可接受。比如我今天跟 Claude 聊了三个小时项目规划,明天我打开电脑想继续聊,结果记忆丢了,那我为什么要用它?所以必须落盘。SQLite 是我特别熟悉的选择,单文件、零配置、事务可靠,Python 标准库直接支持,不需要额外起服务。对一个本来就是命令行小工具的项目来说,这是最务实的起点。
2.2 向量检索什么时候才值得引入
向量检索的吸引力在于它对语义理解更好。传统关键词搜索查“我的车钥匙”,如果记忆里存的是“我把钥匙放在玄关的碗里”,关键词匹配是匹配不到的,但语义检索可以关联上。听起来很完美,但我又仔细想了想,记忆场景里大多数查询本质上是名词和实体匹配,比如项目名、功能名、技术栈,这些关键词匹配已经足够。如果需要处理更复杂的语义查询,比如“我之前跟 Claude 说过对某个方案的不满吗”,那确实需要向量检索。
所以我最后的做法是:核心属性用 SQLite 字段存,正文内容用 FTS(全文搜索)做关键词索引。当关键词召回结果太少或者置信度不高时,再退回一次向量检索。向量这部分我选择了本地 embedding 模型,主要考虑到隐私和成本,具体用什么模型并不重要。这样既保留了语义检索的能力,又不会让整个项目一开始就得依赖一个重量级数据库。
2.3 显式记忆和隐式记忆的双链路
还有一点必须讲清楚,记忆并不全是从对话里自动抽出来的。我把它分成显式记忆和隐式记忆两条链路。显式记忆是用户主动告诉的信息,比如“我叫陈晨,记一下”,这种内容必须原样保存,不能交给抽取模型自由发挥。隐式记忆是模型从对话里读出来的,比如“用户不喜欢太长的解释”,这种内容要经过二次确认才能写入。
claude-mem 的抽取器会把每个候选记忆打上一个置信度标签,只有置信度超过阈值的隐式记忆才会被自动保存,低于阈值的放在待确认区。这里如果不做控制,很容易出现模型自己脑补出一个根本不存在的用户画像,然后一直按照这个错误画像去误导后续对话。我在初期没加这个机制时,Claude 有次居然认定我是做量化交易的,实际上我只是问了一下相关概念,那次对话就彻底偏了。
2.4 记忆注入策略:优先级和截断规则
有存储就有注入。每次对话请求之前,claude-mem 会从数据库里取一批记忆,按优先级排序,然后拼进 system prompt。优先级我按三个维度打分:关联度、时间新鲜度、历史引用频率。比如“用户叫张三”这种核心事实优先级永远最高;两年前的临时需求优先级很低,除非当前问题明确提到相关关键词,否则不会注入。
截断规则同样重要。Claude 的上下文窗口虽然大,但我还是把记忆部分的总长度控制在 1500 token 以内。因为记忆只是参考材料,不是对话主体。如果记忆占了 5000 token,真正用来推理和回复的空间就少了,回答质量会明显下降。我还特别加了一条:如果记忆块超过限制,优先丢弃描述性内容,保留实体和结论。比如“用户上周说想研究向量数据库的性能对比”这种带背景的描述,会被压缩成“用户关注向量数据库性能对比”。
3. 手把手把 claude-mem 跑起来:核心模块拆解
接下来进入最实际的部分。这一节我会把 claude-mem 的关键代码和数据结构讲清楚,你不需要完全照抄,只要理解每个模块的作用,就能很轻松地移植到你自己的项目里。
3.1 项目结构和核心数据结构
整个项目用 Python 写的,结构很简单,核心文件也就这么几个:
claude-mem/ cli.py # 命令行入口 memory_store.py # SQLite 存储和检索 extractor.py # 从对话流中抽取记忆 prompt_builder.py # 组装系统提示词和记忆块 engine.py # 串联对话主流程 config.toml # 配置文件 data/memory.db # SQLite 数据库文件存储层是整个项目的地基。我建了两张表:一张存记忆实体,一张存对话事件。记忆实体表记录的是抽取出来的“可复用知识”,对话事件表则记录原始会话的基本信息,用于生成摘要和事后审计。
CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, confidence REAL DEFAULT 0.8, source_time TEXT NOT NULL, last_access_time TEXT, access_count INTEGER DEFAULT 0, UNIQUE(user_id, kind, content) ); CREATE TABLE conversation_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL );你会发现memories表里加了一个UNIQUE(user_id, kind, content)约束,这是为了避免同一句话被抽出来十次。重复记忆不仅浪费 token,还会让模型误以为用户特别在意这件事,从而在回复中反复强调。这个唯一约束在实际使用中非常管用,配合updated_at字段,就可以让旧记忆被反复触发时不断刷新时间戳,而不是新增一条记录。
3.2 核心引擎:对话主流程怎么串联
engine.py是整个项目的核心流程,它做的事情说起来很简单:拿到用户输入,检索相关记忆,加上系统提示词,发给 Claude,再把 Claude 的回复保存起来,最后异步抽取新记忆。伪代码如下:
def run_turn(user_id, session_id, user_input): memories = memory_store.retrieve(user_id, user_input, top_k=8) system_prompt = prompt_builder.build_system_prompt(user_id, memories) messages = memory_store.load_recent_messages(user_id, session_id, max_turns=6) messages.append({"role": "user", "content": user_input}) reply_text = call_claude(system_prompt, messages) memory_store.save_event(user_id, session_id, "user", user_input) memory_store.save_event(user_id, session_id, "assistant", reply_text) extractor.extract_from_conversation( user_id, session_id, previous_text_lookback, user_input, reply_text ) return reply_text这里有个关键细节:load_recent_messages只加载最近 6 轮对话。因为长摘要已经存在记忆表里了,原始消息不需要全部保留。这样做确实会丢掉一些细节,但可以把每次请求的 token 控制在比较理想的范围内。如果你需要更强的细节保留,可以调大max_turns,但代价是响应变慢、成本变高,最好自己权衡。
3.3 记忆抽取器是怎么工作的
抽取器是 claude-mem 里最见功夫的部分。它本身也用 Claude 来做,但不是直接问“请抽取记忆”,而是给出一个非常明确的 JSON 输出格式。格式化输出对模型非常友好,可以极大减少解析失败的概率。我的提示词模板大概长这样:
EXTRACT_PROMPT = """ 你是记忆抽取器。根据下面的对话,找出值得长期记住的信息。 要求: 1. 只输出 JSON,不要额外解释。 2. 如果某句话是客套、寒暄、废话,忽略它。 3. 事实类信息输出 kind=fact,偏好类输出 kind=preference, 事件类输出 kind=event,项目状态类输出 kind=status。 4. 置信度低于 0.5 的信息不要输出。 对话: <user>{user_input}</user> <assistant>{reply_text}</assistant> 输出格式:{"memories": [{"kind": "fact", "content": "...", "confidence": 0.9}]} """拿到 JSON 之后,抽取器会做一层去重和冲突检查。比如旧记忆里已经有“用户喜欢 Python”,新抽出来的也是“用户喜欢 Python”,那就跳过;如果旧记忆是“用户喜欢 Python”,新记忆变成“用户现在主要写 Go”,这就属于偏好变更,需要把旧记忆标记为过期,而不是简单覆盖。这一层变更处理非常关键,我刚开始直接覆盖,结果用户回退到旧偏好时,模型已经完全不记得了。
3.4 检索层的细节:关键词、衰减和去重
检索这块,大多数场景用不上花哨的模型,一条带全文搜索的 SQL 就能解决大部分问题。我用的核心逻辑是先按 FTS 匹配关键实体,再按last_access_time做时间衰减。时间衰减的意思是,越久没被命中的记忆,下次被召回的优先级越低。这个衰减因子我设置成每 7 天降低 0.2,最低降到 0.3 就不继续降了,避免核心偏好彻底沉底。
def retrieve(user_id, query, top_k=8): rows = db.execute(""" SELECT content, kind, confidence, last_access_time FROM memories WHERE user_id = ? AND content MATCH ? ORDER BY CASE WHEN last_access_time IS NULL THEN 0 ELSE 1 END, last_access_time DESC LIMIT ? """, (user_id, build_fts_query(query), top_k * 3)) scored = [] for row in rows: score = row["confidence"] * time_decay(row["last_access_time"]) if keyword_overlap(row["content"], query): score += 1.2 scored.append((score, row)) scored.sort(key=lambda x: x[0], reverse=True) return [row for _, row in scored[:top_k]]这里有个关键词重叠度的小技巧。如果当前用户输入里直接出现了记忆内容里的名词,说明这条记忆和信息高度相关,应该直接加一个较高的加权分。这样即使它已经很旧,依然能被捞出来。但要注意,如果同一个关键词对应的记忆太多,必须优先选更新鲜、置信度更高的那一条,否则很容易拿一堆过时信息去污染模型。
3.5 提示词组装:如何让 Claude 自然使用记忆
记忆召回之后,不能一股脑塞给模型,得让模型知道这些记忆是什么、可以怎么用。我最开始只是简单地把记忆拼在 system prompt 后面,结果 Claude 表现得很奇怪,它会把记忆当成对话内容来复述,甚至说“根据你的记忆,你是张三”。这就是因为忽视了角色定位。
后来我换了种写法,明确告诉模型:下面这些是外部检索到的背景笔记,你需要结合当前对话判断哪些有用,绝不能直接复述“在我的记忆中”之类的表述。最终 system prompt 的模板大致是:
你是一个拥有持续记忆的智能助手。以下内容是从用户的历史对话中检索出来的背景资料。 它们只代表用户过去可能表达过的内容,可能已经过期,请根据当前对话判断是否适用。 <memory> {memory_block} </memory> 回答时保持自然,避免刻意引用背景资料。这段模板的改变效果非常明显。Claude 开始像是一个真正了解用户的助手,而不是一个背诵档案的机器人。建议所有做类似功能的开发者都试试这个插槽式模板,把记忆和主任务明确分开,模型会表现得自然很多。
4. 上线后最容易踩的五个坑
工具能用和工具好用是两回事。claude-mem 在本地跑通很简单,真正进入日常使用后我陆续遇到了一堆问题,这里挑五个最有代表性的讲,每个都是真实存在的坑。
4.1 记忆过期:用户偏好变了怎么办
这是最隐蔽也是最麻烦的问题。我早期用的是“相同的记忆直接合并”,后来发现用户表达偏好是一个渐变过程。比如上周说“我喜欢 Kubectl 管理集群”,这周说“我想试试新的工具”,如果直接覆盖旧记忆,新记忆可能还不太稳定,但旧记忆已经没了;如果不覆盖,两条记忆又会打架。最终的方案是给偏类型记忆加一个supersede_id字段,让新记忆指向旧记忆,并保留 30 天。30 天内如果新偏好反复出现,就把旧记忆彻底标记为废弃;如果新偏好只出现过一次,30 天后自动恢复旧偏好。这个逻辑听起来复杂,但代码实现也就 40 行,却让整个记忆系统稳定了很多。
4.2 记忆块太长挤压回答空间
刚开始我为了让 Claude 更了解用户,恨不得把几百条记忆全塞进去。结果就是模型经常在回答里引用根本不相关的东西,还显得特别啰嗦。后来我做了个统计,发现当记忆块超过 2000 token 时,回答质量下降得非常明显。于是加了严格的长度上限,并且把优先级的权重改成“和当前问题相关 > 近期使用频率 > 用户主动强调”。如果一个用户的长期记忆实在太多,我就把那些低置信度的记忆进行二次摘要,只保留结论和可执行项,去掉背景描述。
4.3 检索到无关记忆导致跑题
关键词检索的缺陷在于它只看字面重合。比如用户问“上次说的那个方案怎么样了”,数据库里所有带“方案”二字的记忆都会被捞出来,有些完全是别的话题。我试过给这些结果做一次重排筛选,调用 Claude 让它在短时间内判断每条记忆和当前问题的相关性,但每轮对话额外请求一次 Claude 太贵了,速度也慢。最后换了个轻量方案:基础召回数量扩大三倍,然后通过一个简单的规则模型过滤。规则包括:句子里的实体数量、当前问题的实体重叠度、记忆的访问次数、记忆是否和动词短语有关。实测下来已经能过滤掉一半的噪声记忆。
4.4 并发写库冲突
如果你的 claude-mem 只服务于你自己,基本遇不到这个问题。但我把它接到一个多用户应用里测试时,多个请求同时写 SQLite 频繁报database is locked。这里有个基本教训:SQLite 适合低并发场景,同一个连接不能跨线程共享。我改成了“写操作使用单线程队列,读操作走 WAL 模式”,冲突率立刻降了下来。注意 SQLite 默认会在文件所在目录生成-wal和-shm文件,部署时千万不要漏了这些文件,否则数据会丢失。
4.5 隐私和安全边界
本地单机使用时,这个问题不大,但一旦接入远程服务,记忆里可能包含用户敏感信息。我的方案非常保守:默认只把记忆保存在本地数据库中,不做云端同步;如果需要同步,必须加密后再传输。同时在抽取阶段就加了一道过滤,凡是涉及密码、API 密钥、身份证号等明显敏感字段的内容,直接丢弃,连存储都不存。很多人会忽略这个小细节,等记忆库变得庞大之后再去清理,就非常痛苦。
5. claude-mem 还能怎么扩展
工具做出来是拿来用的,但用着用着你就发现很多新需求会冒出来。这里聊聊几个我打算在后续版本里加的方向,目前还在验证阶段,但思路是可以参考的。
5.1 记忆质量打分与自动归档
现在的写入机制是“抽取出来就入库”,但抽取结果的实用性其实是有差异的。“用户喜欢蓝色按钮”这种记忆可能半年都用不上一次,写入后反而占用检索空间。我计划加一个记忆质量评分模块,综合信息密度、话题稳定度、历史命中率来打分,分数低于阈值的自动进入冷归档表。冷归档表里的记忆不会主动参与召回,只有在大搜索场景下才会被捞出来。
5.2 多用户与多身份隔离
claude-mem 目前通过user_id隔离不同用户的数据,但还缺少身份等级体系。比方说一个用户既可以是普通使用者,也可以是某个团队项目的管理员,那他的偏好可能会根据上下文有不同表现。后续打算引入“记忆空间”的概念,类似命名空间,让用户在不同角色下拥有独立记忆。这个扩展如果要做,需要把所有查询都增加一个space_id条件,数据库迁移是核心工作量。
5.3 接入本地模型做记忆抽取
调用 Claude 做抽取虽然在效果上很理想,但每轮多一次调用,成本和延迟都上去了。目前我在尝试用本地小模型替代抽取器,比如只有几百 M 的量化模型,专门做“抽取事实类记忆”这一件事。我的预期是事实类抽取准确率达到 80% 以上,偏好类仍然交给 Claude。这个混合方案如果验证成功,可以把整体运行时延降掉一半。
5.4 和其他工具链集成
最后一个是把 claude-mem 作为中间层,接入到 RAG 流程、智能客服、个人知识库助手这些场景。它的价值在于:RAG 通常只能搜索外部文档,而 claude-mem 还能搜索用户和模型之间的历史交互记录。这类交互记录往往是文档里没有的隐性知识,集成之后能让整个问答系统更有“懂我”的感觉。不过这已经超出 claude-mem 本身的范围,更像是一个把它从工具变成基础设施的过程。
现在 claude-mem 已经成为我本地工作流里离不开的小部件。每当我打开终端,它自动会给我一个带着“上一段记忆”的启动提示,让我能无缝接着聊。最后分享一个我的使用习惯:每隔两周我会手动跑一次记忆盘点脚本,把那些过于琐碎的内容清理掉,给核心事实做一次确认更新。要维护一套健康的长期记忆,功夫其实不在实现算法上,而在持续维护和克制设计上。