最近大半年我一直在折腾一个很个人向的事:让 Claude 记得我和它聊过的内容。起因挺朴素,我用 Claude 处理各种项目记录、选题策划、代码片段归档,但每次新开对话它就对之前的事一无所知,哪怕是前一天刚讨论过的方案,第二天也得从头讲一遍。于是我自己写了一个叫 claude-mem 的小工具,把跨会话记忆这个能力补上。这篇文章就是关于这个东西的完整拆解——它解决什么问题、怎么设计、怎么搭起来、实际用下来有哪些坑,尽量把话说透,让也想给 Claude 加记忆的开发者少走弯路。
claude-mem 本质上是一个给 Claude 加长期记忆的轻量方案:每次对话结束后自动提炼摘要、把关键信息结构化存储,在新会话开始时按需检索并注入到系统提示词里。它不依赖某个特定前端或客户端,只要你用的是 Claude 的 API 或任何允许自定义 system prompt 的接入方式,都能套上去。
1. 会话失忆的根源:为什么 Claude 一关对话就什么都不记得
先说清楚原理,不然你可能不理解为什么这事非得自己动手。
Claude 这类大语言模型的“记忆”完全局限在上下文窗口内。你在同一个会话里发的每一条消息、它回的每一条内容,都会留在上下文里,它因此能“记住”前面说过的话。但一旦会话关闭、上下文被清空,下一次调用就是全新的状态。API 设计上就是这样无状态的:你传 messages 数组它就回答,你不传它就不知道你是谁、之前聊过什么。
这里有个很关键的认知:不是模型没有记忆能力,而是我们根本没把“记忆”喂给它。系统提示词、历史消息、用户输入,这些都是它唯一能接触到的信息源。所以想让 Claude 拥有跨会话记忆,你能做的就是:在每次对话开始前,把和当前主题相关的历史信息写成文字,塞进 system prompt 里。
最简单的做法是手动复制粘贴,但完全不现实。对话一多,你根本记不清哪段内容在哪个会话里,而且每次塞多少、塞什么也需要判断。这也是我最初动手写 claude-mem 的直接动机:我需要一个自动化的中间层,替我做三件事——提炼、存储、检索注入。
顺带说一句,市面上很多带“记忆”能力的聊天前端,原理也大同小异,无非是把历史对话摘要存到数据库,新对话时再取回来。claude-mem 没做什么黑科技,它只是把这条链路做成了可复用的库和命令行工具,并且针对 Claude 的使用习惯做了一些专门设计。
1.1 有多少“记忆”是真正需要的
这是被多数人忽略的问题:Claude 的上下文窗口虽然越来越大,但 token 还是钱,而且塞入大量无关信息反而会稀释注意力,让模型对真正重要的指令变得迟钝。所以 claude-mem 的存储策略不是“全部存下来”,而是区分两层:完整对话流水和精简记忆条目。
完整对话流水是原始数据,用于回溯和调试;记忆条目才是真正会被注入回会话的内容。每次新对话,系统只注入与当前主题最相关的若干条记忆,而非历史全文。这样做的好处有两个:一是控制 token 开销,二是减少噪音干扰。
我在设计 claude-mem 时把这两层分开存储,流水用 JSONL 按对话 ID 归档,记忆条目则放进 SQLite 并通过语义检索按需召回。下面详细说。
2. claude-mem 的整体设计:摘要、向量化与自动注入三层结构
整个工具的架构可以拆成三个模块,对应三个职责:从对话中提炼记忆、把记忆存成可检索的形式、在正确的时间把记忆注入给 Claude。
第一层是摘要提取。每一轮对话结束后,claude-mem 会调用一次 Claude,让它把当前的完整对话压缩成一组结构化记忆条目。每个条目包括:主题标签、一句话要点、涉及的文件或项目(如果有)、时间戳。为了控制摘要本身的开销,我这里做了个很实用的设计:不是每轮都调用摘要接口,而是检测到对话长度超过某个阈值(比如 3000 token)或明确出现“这个要记下来”的意图时才触发。
意图检测也是走 Claude 判断,我在 system prompt 里写清楚规则:当用户表达出“记住”“下次用”“其实我习惯/偏好…”这类语义时,把对应内容标记为 long-term,其余内容标记为 short-term。short-term 条目保留 7 天自动过期,long-term 条目除非显式删除,否则一直保留。
第二层是存储和检索。SQLite 存一段时间后发现,纯关键词匹配根本满足不了“找相似记忆”的需求。因为用户关心的内容是语义层面的相关性,比如这次聊“用户增长策略”,历史里存的是“客户留存方案”,关键词没有重合,但它们高度相关。所以我在 SQLite 之上加了向量检索:每条记忆用 embedding 模型编码成向量,检索时把当前 query 也编码成向量,用余弦相似度召回 top-k。
embedding 模型我用了 text-embedding-3-small,维数低,速度和费用都友好。向量检索没有专门引入向量数据库,因为数据量级在个人使用场景下就是几千到几万条,SQLite+Python 里直接算余弦距离完全扛得住,没必要为了这个上 Milvus 或 Qdrant。选型的原则就一句话:规模没到十万级,不要给自己增加运维负担。
第三层是自动注入。它发生在每次调用 Claude API 之前。claude-mem 会根据当前任务主题(通常取自第一条用户消息或系统指定的 project tag),去检索最相关的记忆条目,格式化后拼接到 system prompt 后面。拼接格式有一定的讲究,不是简单罗列,而是用明确的指令告诉模型“以下是用户的历史信息,可信但可能有时间差,使用时注意优先级”。
| 功能模块 | 技术选型 | 选型理由 |
|---|---|---|
| 摘要提取 | Claude API | 摘要质量高,能理解隐式意图 |
| 结构化存储 | SQLite + JSONL | 零运维、单文件、个人场景够用 |
| 语义检索 | text-embedding-3-small + 余弦相似度 | 兼顾效果与成本,万条以内无需专用向量库 |
| 注入层 | system prompt 模板 | 兼容任何 Claude API 客户端 |
2.1 记忆条目的数据格式
一个记忆条目长这样:
{ "id": "mem_8f3a2c", "type": "long-term", "topic": "项目-阅读笔记方案", "content": "用户倾向于以行动导向做读书笔记,每本书产出一条可执行的清单而非摘要", "source_session": "20250112_books", "tags": ["阅读", "方法论", "偏好"], "created_at": "2025-01-12T21:30:00Z", "expires_at": null }字段不复杂,但每一项都有具体用途。type控制生命周期,topic是注入时的粗筛依据,tags配合向量检索一起用,source_session用于追溯这条记忆来自哪次对话。expires_at为空表示长期保留,short-term 记忆会在第七天自动被打上过期标记,检索时直接过滤掉。
这里我踩过一个设计上的坑:一开始没做 topic 粗筛,只靠向量检索,结果新会话的主题和某条记忆在语义上有点沾边但实际不相干时,就会注入一堆没用的历史信息。后来加了 topic 标签作为第一道闸门,语义相似度作为第二道闸门,注入准确率明显提升。
3. 从零搭建 claude-mem:环境准备与核心代码实现
工程上我选了 Python,原因很简单:Python 生态里调用 Claude API、做 embedding、操作 SQLite 都是顺手的事。整个项目没有引入重量级框架,依赖就三个:anthropic 官方 SDK、openai 的 embedding 库(或者其他 embedding 接口)、以及 Python 自带的 sqlite3。
3.1 目录结构与基础依赖
claude-mem/ ├── claude_mem/ │ ├── __init__.py │ ├── store.py # SQLite + JSONL 存储层 │ ├── summarize.py # 调用 Claude 生成记忆条目 │ ├── retrieve.py # 语义检索 + 过滤 │ ├── inject.py # 构建 system prompt 注入模板 │ └── cli.py # 命令行入口 ├── data/ │ ├── memory.db │ └── sessions/ ├── .env # 存放 API Key └── requirements.txt安装依赖就三行:
pip install anthropic openai python-dotenv我把 embedding 接口统一封装了一下,避免以后换模型时改动过大。.env里只需要配两个变量:ANTHROPIC_API_KEY 和 EMBEDDING_API_KEY,后者如果也用同一家供应商,填同一个 Key 就行。
3.2 存储层:SQLite 写起来最顺手
我建了两张表,一张存记忆条目,一张存会话索引。还记得前面的两层设计吗,原始对话流水写在 JSONL 文件里,不进数据库。
# claude_mem/store.py import sqlite3 import json import uuid from datetime import datetime, timedelta class MemoryStore: def __init__(self, db_path="data/memory.db"): self.conn = sqlite3.connect(db_path) self._init_tables() def _init_tables(self): self.conn.execute(""" CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, type TEXT, topic TEXT, content TEXT, tags TEXT, source_session TEXT, created_at TEXT, expires_at TEXT, embedding BLOB ) """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, started_at TEXT, message_count INTEGER, summary TEXT ) """) self.conn.commit() def add_memory(self, entry: dict, embedding: list[float]): import pickle blob = pickle.dumps(embedding) self.conn.execute( "INSERT INTO memories VALUES (?,?,?,?,?,?,?,?,?)", ( entry["id"], entry["type"], entry["topic"], entry["content"], json.dumps(entry["tags"]), entry["source_session"], entry["created_at"], entry["expires_at"], blob, ), ) self.conn.commit()这里注意created_at我统一存 ISO 格式的 UTC 时间字符串,而不是本地时间。因为之后做过期判断、跨时区检索时,统一时区能省掉大量 bug。embedding 直接 pickle 成 BLOB 存在同一行里,取出时一起还原,这样数据库里查一条记录就直接拿到了向量,不用再做二次查询。
3.3 摘要提取:让 Claude 自己整理自己的记忆
摘要模块是整个工具里最需要调 prompt 的地方。我试过很多版本,核心要义是:对 Claude 说清楚“你是在替未来的自己整理资料”,这比笼统地说“总结对话”效果好得多。
# claude_mem/summarize.py from anthropic import Anthropic client = Anthropic() def digest_conversation(messages, session_id): sys = ( "你是一个记忆管理助手。请阅读以下对话,提取值得长期记住的信息。" "需要记住的内容包括:用户的偏好和习惯、明确的决定和结论、" "关键的项目事实与参数、待办事项与下一步计划。" "不需要记住的是:寒暄、重复解释、临时的思考过程。" "请用 JSON 数组输出,每条包含 topic/content/type 三个字段," "type 取 long-term 或 short-term。" ) resp = client.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=800, system=sys, messages=messages, ) # 解析 JSON,写入 MemoryStore ...max_tokens 设 800 是因为大多数对话顶多产出 5-10 条记忆,800 token 足够又不浪费。如果对话很短,摘要接口可以不调用,这就回到前面说的阈值控制。
3.4 检索与注入:最影响体感的部分
检索不能只靠向量。我实际的调用方式是混合式:先用 SQL 按 topic 精确匹配候选,再用 embedding 向量对候选取 top-k。这个组合方式在召回率和准确率上最平衡。
# claude_mem/retrieve.py import numpy as np def cosine_sim(a, b): a, b = np.array(a), np.array(b) return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def search(store, query_embedding, topic=None, k=5): rows = store.conn.execute( "SELECT * FROM memories WHERE expires_at IS NULL OR expires_at > ?", (datetime.utcnow().isoformat(),), ).fetchall() if topic: rows = [r for r in rows if topic in json.loads(r[6])] scored = [] for r in rows: emb = pickle.loads(r[8]) score = cosine_sim(query_embedding, emb) scored.append((score, r)) scored.sort(key=lambda x: -x[0]) return scored[:k]注入时模板是这样:
system_prompt = f""" 你是 Claude,请基于用户提供的信息和以下历史记忆进行回答。 历史记忆来自用户过去的对话,仅供参考,如果与本次对话内容冲突,以本次对话为准。 <memory> {formatted_memories} </memory> """把这个 system_prompt 传给客户端,就完成了“带记忆的对话”。从使用者的角度看,Claude 就像一直记得你之前说过什么。
4. 实测中的五个坑:记忆污染、Token 开销与并发冲突
功能跑通之后,真正耗费时间的是打磨细节。下面这几个问题是我实际用了两个月、存了几千条记忆之后才逐渐暴露出来的,每一个都值得单独提醒。
4.1 记忆污染:Claude 会把“临时想法”当成长期偏好吗
最典型的情况:对话里你随口说了一句“要是这功能能导出 PDF 就好了”,摘要接口容易把它提取成 long-term 偏好,结果之后每次会话注入记忆时,Claude 都以为你是坚定的 PDF 爱好者,反复主动提这事儿,非常烦人。
解决办法是在摘要 prompt 里加了一条铁律:凡是不带明确“我觉得这很重要”“以后都用这种方式”这样表态的内容,一律归为 short-term。宁可漏记也不要乱记,因为错误的记忆比没有记忆更有害。清理阶段我还加了一个每日定时任务,把 7 天前的 short-term 记忆直接删除,从根上防止短期噪音沉淀下来。
4.2 摘要粒度失控:一条记忆塞了太多内容
早期版本里,一条记忆条目可能包含几个互不相关的信息点,检索时只要命中其中一个,整条记忆都会被注入,有效信息密度很低。后来我强制摘要接口遵守更细的分割要求:每个记忆条目只能包含一个独立事实,如果某条超过 40 个字,就必须拆成多条。这个约束让检索精度提升了一大截。
4.3 Token 开销:检索出的记忆不是越多越好
我一开始把 top-k 设成了 10,觉得多给点信息总没错。后来看用量统计发现,每次对话光记忆注入就吃掉 1500-2000 token,长期用下来成本并不低。而且记忆多了之后模型对历史的关注度反而下降,有些关键记忆被稀释。
最终我把 top-k 降到了 3-5 条,并且加了相关度阈值,低于 0.72 的一律不注入。这个阈值是在实际使用中调的,不同使用场景可能不一样,但做法都是同一套:根据日志里的注入效果不断调整,而不是拍脑袋定一个数。
4.4 并发会话冲突:同一主题的旧记忆覆盖新记忆
有一次我同时开三个会话聊同一个项目的方案,因为项目标签相同,检索出来的记忆来自三个会话,内容还互相矛盾,Claude 直接精神分裂了,一会说 A 方案已经确定,一会又说 B 更合适。
后来我在记忆条目里加了source_session和created_at,注入时如果同一 topic 下有来自多个会话的记忆,就按时间排序,只保留最近几条。更本质的解决方案是:同一时间段内,同一 project tag 只允许一个活跃会话写入记忆,避免交叉污染。
4.5 隐私边界:记忆文件里存了什么,自己得心里有数
这个坑不发生在技术上,但很重要。因为 claude-mem 会把对话摘要和偏好存成明文 SQLite 文件,如果里面涉及密钥、密码、内部客户信息,暴露风险就等同于暴露本地文件。我的做法是两层保险:一是给需要长期保存的敏感内容配置一个加密字段,用 Fernet 加密后存储;二是提供--forget命令,支持按 topic、时间范围批量删除记忆。记忆系统做得越久,越要给自己留好退出通道。
5. 进阶玩法:把 claude-mem 变成个人知识库与自动化中枢
跑通基础功能后,我又以 claude-mem 为底座做了两件事,实际收益非常明显。
第一件事是把历史会话自动沉淀成知识库。以前我存的对话流水,到用时才想起来翻,现在 claude-mem 每晚会定时把当天新增的记忆条目按 topic 聚合成一份 Markdown 周报,每周一自动写入 Obsidian 库。这等于让 Claude 自己把自己的产出整理成了可长期管理的笔记,我只需要偶尔校对。专注写东西的人应该能体会,这一步把“记录”和“整理”两个动作彻底解耦了。
第二件事是和自动化任务联动。我写了一个调度脚本,每天给 Claude 发一条类似“基于我的历史记忆,今天应该优先处理哪三件事”的消息。它会自动检索本周相关的记忆,结合待办标签给出建议。本质上这还是检索注入的那套机制,但它让记忆不只是在“对话时被动调用”,而是变成主动驱动决策的输入。
我会把 claude-mem 做成一个可以在多个设备之间同步记忆的版本,目前我的实现是把 SQLite 数据目录放到本地同步盘里,但多设备同时写入可能会有锁的问题。如果你的使用场景也涉及多设备,可以考虑后端换成 PostgreSQL 或干脆上 LiteFS,个人项目完全不需要为了这一点提前上分布式存储。
5.1 把 claude-mem 接进任何 Claude 客户端
如果你的 Claude 客户端不允许自定义 system prompt,比如手机 App 那种纯官方产品,那 claude-mem 就无用武之地了。我只能把它接到允许自定义 API 接入的客户端或自己的脚本中。所以接入之前先确认你的使用方式支持这一层,否则再好的记忆设计也落不了地。
接 API 时有个小技巧:不要直接把记忆塞进第一条 user message,而是放进 system prompt。因为 system prompt 每轮对话都自动存在,不会因为多轮上下文往后滚动而被挤出去。Claude 的 system prompt 并不占用对话首条的可见位置,这正好符合记忆需要“全程在场”的特性。
如果你常用的是官方命令行工作台之类的工具,也可以把 claude-mem 的检索逻辑做成一个前置命令:对话开始前先手动搜一次记忆,把结果贴给模型,相当于半自动模式。虽然不如全自动舒服,但这种方式对已有工作流的侵入最小,几乎是零成本接入。
最后说几句实在话
这个项目从头到尾没有用任何惊艳的技术,SQLite、embedding、API 调用,每个环节都是大家熟悉的东西。真正让 claude-mem 变得好用的,是对使用细节的反复打磨:记忆要分段、注入要设阈值、临时的和长期的不能混为一谈、检索前要先过滤过期内容。这些判断没有任何模型帮你完成,只能靠自己在真实对话里一点点试出来。
我的建议是:如果你也在做依赖 Claude 的多轮任务,别等官方出记忆功能,也不必迷信某个“AI 记忆框架”,先把你最常用的一个场景拆出来,用 200 行代码让它在每次对话前自动拿到历史相关信息。这一步跑通之后,你会对“记忆到底是什么”有完全不同的理解。