1. 项目缘起与核心定位
第一次看到claude-mem这个名字,我的直觉是:这大概率是一个围绕 Claude 生态做“记忆层”的项目。事实也确实如此。它要解决的是一个所有长期使用大模型的人都会遇到的痛点——模型没有持久记忆。每次开新会话,之前聊过的偏好、项目背景、技术栈约定、命名习惯,全部归零,你得重新交代一遍。短对话无所谓,但当你把 Claude 当成日常协作伙伴,天天用它写代码、改文档、做方案时,这种“失忆”带来的重复劳动非常消耗人。
claude-mem的核心价值,就是给 Claude 这类对话式模型外挂一套可检索、可累积、可管理的长期记忆系统。它把你在不同会话里产生的关键信息——比如“这个项目用 TypeScript 严格模式”“接口返回统一用{ code, data, msg }结构”“我偏好简洁的注释风格”——抽取出来,存进本地或远程的存储里,下次对话时按需召回,拼进上下文。这样模型就能“记得”你之前说过什么,而不是每次都从白纸开始。
适合谁来参考?三类人最值得看:一是重度 Claude 用户,每天要开十几个会话那种;二是想给自己产品加记忆能力的开发者,可以借鉴它的架构思路;三是对RAG(检索增强生成)落地感兴趣的人,claude-mem本质上就是一个面向个人场景的轻量 RAG 实践。它不追求企业级复杂度,而是把“记忆”这件事做得足够简单、足够可用。
我先把话说在前面:这类项目的价值不在于代码多复杂,而在于它把哪些信息定义为“值得记住的”,以及用什么策略在合适的时机把记忆塞回上下文。这两点才是真正决定体验好坏的地方,也是我下面要重点拆解的内容。
2. 记忆系统的整体设计与选型考量
2.1 为什么不做“全量对话历史”而是做“记忆抽取”
很多人第一反应是:要记忆简单啊,把历史对话全存下来,每次全塞进上下文不就行了?这个方案在小规模下能跑,但很快会撞墙。原因有三个。
第一是上下文窗口有限。Claude 的上下文再大也有上限,你把几十次会话的原始记录全塞进去,token 消耗爆炸,而且真正有用的信息被大量寒暄、试错、废弃方案稀释掉了。第二是信噪比问题。对话里 80% 的内容是一次性的,比如“帮我改下这个变量名”“这里报错了看看”,这些没有长期价值。第三是成本。每次请求都带上巨量历史,API 费用会高得离谱。
所以claude-mem走的是抽取式记忆路线:不是存原始对话,而是从对话里提炼出结构化的“记忆条目”。这个思路和人类记忆很像——你不会记住每次对话的逐字内容,但你会记住“这个人喜欢用 tabs 而不是 spaces”这种结论。
2.2 存储选型:本地优先还是云端优先
记忆存哪里,是个关键决策。我实测下来,claude-mem这类项目通常提供两种模式,各有取舍。
| 存储方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 本地文件(JSON/SQLite) | 隐私好、零成本、离线可用 | 多设备不同步、需自己备份 | 个人单机使用 |
| 云端数据库 | 多设备同步、可共享 | 有隐私顾虑、需维护 | 团队协作、多端 |
我的建议是本地优先。原因很直接:记忆里往往包含你的项目细节、代码片段、甚至一些敏感的业务逻辑,这些东西放在自己机器上最踏实。而且本地 SQLite 的检索性能对个人使用量来说完全够用,几万条记忆的查询延迟都在毫秒级。如果你确实需要多设备同步,可以后续再加一层同步机制,但不要一上来就上云。
2.3 检索策略:向量、关键词还是混合
记忆存下来之后,怎么在需要的时候找回来?这是整个系统的技术核心。常见方案有三种:
- 纯关键词检索:用 BM25 或简单的字符串匹配。优点是快、可解释、无需额外模型;缺点是无法处理语义相近但用词不同的情况,比如你搜“代码风格”,但记忆里写的是“命名规范”。
- 纯向量检索:把记忆和查询都转成 embedding,算余弦相似度。优点是语义理解强;缺点是需要 embedding 模型,有额外成本,且对精确匹配(比如某个具体函数名)反而不如关键词。
- 混合检索:两者结合,先各自召回再融合排序。这是目前公认效果最好的方案。
claude-mem这类项目我建议直接用混合检索。具体做法是:关键词检索负责精确命中(函数名、变量名、专有名词),向量检索负责语义召回(偏好、习惯、抽象概念),最后用 RRF(Reciprocal Rank Fusion)把两路结果合并。RRF 的好处是不需要调权重,公式简单:
score = Σ 1 / (k + rank_i)其中 k 通常取 60,rank_i 是某条记忆在第 i 路检索中的排名。这个公式我在多个项目里用过,稳定且省心。
2.4 记忆的生命周期管理
记忆不是存进去就完事了,它需要生命周期管理。我踩过的坑是:早期不做清理,记忆库越来越臃肿,检索出来的东西越来越杂,反而干扰模型判断。合理的做法是给每条记忆加上元数据:
- 创建时间:老记忆可能过时
- 最后访问时间:长期不用的可以降权
- 置信度:从明确陈述里抽取的置信度高,从模糊对话里猜的置信度低
- 类型标签:偏好、事实、约定、待办等
然后定期做衰减和归档。比如超过 90 天没被访问、且置信度低于阈值的记忆,自动归档到冷存储,不参与默认检索。这个机制听起来简单,但对长期体验的提升非常明显。
3. 核心细节解析与实操要点
3.1 记忆抽取:什么该记,什么不该记
这是整个项目里最考验判断力的环节。抽取规则定得好,记忆库干净有用;定得差,就是一堆垃圾。我总结了一套实用的判断标准。
值得记住的:
- 用户的明确偏好:“我喜欢用单引号”“注释用中文”
- 项目级约定:“这个仓库用 pnpm 不用 npm”
- 稳定的技术事实:“我们的 API 网关地址是 xxx”
- 反复出现的模式:同一个问题问过三次以上
不值得记住的:
- 一次性的调试信息:“这行报错了帮我看看”
- 临时状态:“我现在在改登录页”
- 已经被推翻的方案:“先试试用 Redis 缓存”——后来改成别的了
- 寒暄和确认:“好的”“谢谢”“明白了”
实操上,我建议用规则 + 模型双层过滤。先用规则快速排除明显无价值的(比如长度小于 10 个字符、纯语气词),再用一次轻量的模型调用做精判。这里可以用 Claude 自己来做抽取,prompt 大致是:
从以下对话片段中抽取值得长期记住的信息。 只抽取:用户偏好、项目约定、稳定事实、重复模式。 不要抽取:临时状态、一次性调试、寒暄、已废弃方案。 输出 JSON 数组,每项包含 content、type、confidence(0-1)。 对话片段: {conversation}注意:抽取这一步一定要做去重。同一个偏好可能在十次对话里被提到,如果不去重,记忆库里会有十条一样的记录,检索时全被召回,白白占上下文。去重可以用 embedding 相似度,超过 0.9 就合并,保留最新的一条并累加访问计数。
3.2 上下文注入:怎么塞才不干扰模型
记忆召回之后,怎么放进 prompt 也有讲究。我见过两种错误做法:一是把记忆直接拼在用户消息前面,导致模型分不清哪是记忆哪是当前指令;二是塞太多,把上下文占满。
正确做法是用清晰的分隔和标签。比如:
<memory> 以下是与当前对话相关的历史记忆,供参考: - [偏好] 用户偏好 TypeScript 严格模式 - [约定] 项目使用 pnpm 作为包管理器 - [事实] API 基础路径为 /api/v2 </memory> <user_query> 帮我写一个获取用户列表的函数 </user_query>这样模型能明确知道哪些是背景记忆、哪些是当前任务。另外,记忆条数要控制。我的经验是默认召回 top 5 到 top 8 条就够了,太多反而会让模型过度关注历史而忽略当前指令。如果记忆确实很多,可以在 prompt 里加一句“优先遵循 user_query 中的明确要求”。
3.3 记忆的写入时机
什么时候触发记忆写入?常见的有三种时机:
- 会话结束时批量抽取:优点是省调用次数,缺点是要等会话结束,如果中途崩溃就丢了。
- 每轮对话后实时抽取:优点是及时,缺点是调用频繁、成本高。
- 手动触发:用户显式说“记住这个”,才写入。
我推荐混合策略:默认在会话空闲一段时间后(比如 5 分钟无新消息)触发批量抽取,同时保留手动触发入口。这样既不会太频繁,也不会丢数据。手动触发特别适合那些“这句话很重要,必须记住”的场景,给用户一个明确的控制感。
3.4 隐私与安全边界
记忆系统天然涉及隐私,这点必须严肃对待。几个硬性要求:
- 本地存储默认加密:SQLite 可以用 SQLCipher,或者至少对敏感字段做加密。
- 提供一键清空:用户要能随时删掉所有记忆,且删除要彻底。
- 敏感信息过滤:抽取时自动识别并跳过密码、密钥、token 这类内容。可以用正则先扫一遍,命中就丢弃。
- 可审计:用户能查看记忆库里到底存了什么,最好还能逐条删除。
提示:千万不要把记忆库默认上传到任何第三方服务。哪怕项目提供了云同步选项,也要默认关闭,让用户主动开启并明确知晓数据流向。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
假设我们用 Python 来实现一个最小可用的claude-mem。先准备环境。我习惯用uv管理依赖,比 pip 快很多。
uv init claude-mem cd claude-mem uv add anthropic sqlite-vec sentence-transformers rank-bm25这里几个依赖的作用:anthropic是官方 SDK,sqlite-vec给 SQLite 加向量检索能力,sentence-transformers提供本地 embedding 模型(省得调 API),rank-bm25做关键词检索。用本地 embedding 模型的好处是零成本、无网络依赖,缺点是首次要下载模型(几百 MB)。
4.2 数据库表结构设计
记忆库的表结构不用复杂,但字段要设计到位。我用两张表:一张存记忆主体,一张存向量。
CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, type TEXT NOT NULL, -- preference / convention / fact / pattern confidence REAL DEFAULT 0.8, created_at INTEGER NOT NULL, last_accessed INTEGER, access_count INTEGER DEFAULT 0, archived INTEGER DEFAULT 0 ); CREATE VIRTUAL TABLE memory_vectors USING vec0( memory_id INTEGER PRIMARY KEY, embedding FLOAT[384] );384是all-MiniLM-L6-v2这个模型的向量维度,选它是因为体积小、速度快,个人场景够用。如果你追求更好的语义效果,可以换成bge-small-zh,维度是 512,中文表现更好。
4.3 记忆抽取的完整实现
抽取函数是整个系统的入口。我把它写成接收一段对话、返回记忆列表的形式。
import json from anthropic import Anthropic client = Anthropic() EXTRACT_PROMPT = """从以下对话中抽取值得长期记住的信息。 只抽取:用户偏好、项目约定、稳定事实、重复模式。 不要抽取:临时状态、一次性调试、寒暄、已废弃方案。 输出 JSON 数组,每项含 content、type、confidence(0-1)。 对话: {conversation}""" def extract_memories(conversation: str) -> list[dict]: resp = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=1024, messages=[{ "role": "user", "content": EXTRACT_PROMPT.format(conversation=conversation) }] ) text = resp.content[0].text # 容错:模型有时会包 ```json text = text.strip().removeprefix("```json").removesuffix("```").strip() try: return json.loads(text) except json.JSONDecodeError: return []这里有个实操细节:一定要做 JSON 解析容错。模型输出偶尔会带 markdown 代码块标记,或者末尾多个逗号。我踩过好几次坑,后来统一加了清洗逻辑,稳定多了。
4.4 去重与入库
抽取出来的记忆不能直接入库,要先和已有记忆比对去重。
import numpy as np def dedupe_and_store(new_memories, conn, embedder): for mem in new_memories: vec = embedder.encode(mem["content"]) # 查最相似的已有记忆 rows = conn.execute( "SELECT memory_id, distance FROM memory_vectors " "WHERE embedding MATCH ? ORDER BY distance LIMIT 1", (vec.tobytes(),) ).fetchall() if rows and rows[0][1] < 0.15: # 距离足够小,视为重复 conn.execute( "UPDATE memories SET last_accessed=?, access_count=access_count+1 " "WHERE id=?", (now(), rows[0][0]) ) else: cur = conn.execute( "INSERT INTO memories(content,type,confidence,created_at) " "VALUES(?,?,?,?)", (mem["content"], mem["type"], mem["confidence"], now()) ) mid = cur.lastrowid conn.execute( "INSERT INTO memory_vectors(memory_id,embedding) VALUES(?,?)", (mid, vec.tobytes()) ) conn.commit()那个0.15的阈值是我实测调出来的。太小会漏判重复,太大会误合并不同记忆。不同 embedding 模型这个值不一样,建议你先跑一批样本手动校准。
4.5 混合检索的实现
检索是体验的关键。我实现的是关键词和向量双路召回,再用 RRF 融合。
from rank_bm25 import BM25Okapi def hybrid_search(query, conn, embedder, top_k=8): # 路一:向量检索 qvec = embedder.encode(query) vec_rows = conn.execute( "SELECT memory_id FROM memory_vectors " "WHERE embedding MATCH ? ORDER BY distance LIMIT 20", (qvec.tobytes(),) ).fetchall() vec_ids = [r[0] for r in vec_rows] # 路二:关键词检索 all_mems = conn.execute( "SELECT id, content FROM memories WHERE archived=0" ).fetchall() corpus = [m[1].split() for m in all_mems] bm25 = BM25Okapi(corpus) scores = bm25.get_scores(query.split()) kw_ids = [all_mems[i][0] for i in np.argsort(scores)[::-1][:20]] # RRF 融合 rrf = {} for rank, mid in enumerate(vec_ids): rrf[mid] = rrf.get(mid, 0) + 1 / (60 + rank) for rank, mid in enumerate(kw_ids): rrf[mid] = rrf.get(mid, 0) + 1 / (60 + rank) top_ids = sorted(rrf, key=rrf.get, reverse=True)[:top_k] return fetch_memories(conn, top_ids)这套逻辑跑下来,实测召回质量比单路好不少。尤其是当你的查询里既有具体名词(走关键词)又有抽象意图(走向量)时,融合效果最明显。
4.6 接入对话流程
最后把记忆系统接到实际对话里。整体流程是:用户发消息 → 检索相关记忆 → 拼进 prompt → 调 Claude → 返回结果 → 空闲后抽取新记忆。
def chat(user_input, conn, embedder): memories = hybrid_search(user_input, conn, embedder) memory_block = "\n".join(f"- [{m['type']}] {m['content']}" for m in memories) prompt = f"""<memory> {memory_block} </memory> <user_query> {user_input} </user_query>""" resp = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=2048, messages=[{"role": "user", "content": prompt}] ) return resp.content[0].text抽取那一步我建议放到后台任务里,别阻塞用户等待。可以用一个简单的定时器,会话空闲 5 分钟后触发。
5. 常见问题与排查技巧实录
5.1 记忆召回不准怎么办
这是最高频的问题。表现是:明明存过相关记忆,但检索时没召回,或者召回了不相关的。排查思路按顺序来:
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 完全召不回 | 向量没入库 | 查 memory_vectors 行数 | 检查入库逻辑 |
| 召回了无关的 | 阈值太松 | 看距离分布 | 收紧 top_k 或加置信度过滤 |
| 语义相近召不回 | embedding 模型太弱 | 换模型对比 | 换 bge 系列 |
| 关键词命中差 | 分词问题 | 看 BM25 分数 | 中文要专门分词 |
我遇到过一次特别隐蔽的:向量存进去时用了tobytes(),但查询时忘了同样处理,导致距离计算全乱。这种低级错误排查了半天,后来加了单元测试才避免。
5.2 记忆库膨胀导致变慢
用久了记忆库会越来越大,检索变慢。我的处理是分层:热记忆(近 30 天访问过)放主表参与检索,冷记忆归档到单独的表,只在明确需要时查。归档逻辑很简单:
def archive_cold_memories(conn, days=90): cutoff = now() - days * 86400 conn.execute( "UPDATE memories SET archived=1 " "WHERE last_accessed < ? AND access_count < 3", (cutoff,) ) conn.commit()注意条件里加了access_count < 3,因为有些记忆虽然很久没访问,但访问次数多,说明它重要,不该归档。
5.3 记忆冲突怎么处理
同一个话题,用户前后说法不一致,比如先说“用 tabs”,后来说“还是用 spaces 吧”。这时候不能两条都留着,否则模型会困惑。我的做法是新记忆覆盖旧记忆:入库时如果发现同类型、高相似度的旧记忆,就把旧的标记为superseded,新的正常入库。检索时只返回未被取代的。
5.4 抽取成本太高
如果每轮对话都调模型抽取,成本会上去。几个降本技巧:一是批量抽取,攒几轮一起抽;二是用小模型,抽取这种任务不需要最强模型,用便宜的快模型就行;三是规则前置,先用正则和长度过滤掉明显无价值的,减少模型调用次数。我实测下来,规则前置能砍掉 60% 以上的无效调用。
5.5 几个我踩过的坑
第一个坑是过度抽取。早期我把阈值设得太松,结果连“好的”“收到”都被抽成记忆,记忆库全是噪音。后来加了最小长度和类型白名单才好转。
第二个坑是上下文污染。有次我把记忆块放在了 system prompt 里,结果模型把记忆当成了系统指令,行为变得很奇怪。后来改成放在 user 消息里、用标签隔开,就正常了。
第三个坑是忘记处理时区。created_at用了本地时间,换台机器就乱了。统一用 UTC 时间戳,显示时再转本地,这个习惯一定要养成。
提示:建议给记忆系统加一个简单的“记忆查看”命令,让用户能随时
list出所有记忆、delete掉某条。透明可控是这类系统能长期用下去的前提,黑盒记忆会让人不安。
6. 记忆系统的扩展方向
基础版本跑通之后,有几个方向值得继续做。一是记忆的自动摘要,把同一主题的多条记忆合并成一条更精炼的,减少冗余。二是跨项目隔离,不同项目的记忆分开存,检索时按当前项目过滤,避免串味。三是记忆的时效性标注,给每条记忆加个“有效期”概念,比如“这个季度用这个方案”,过期自动降权。
我个人最看好的扩展是记忆的主动确认。当系统不确定某条记忆是否还有效时,可以在合适的时机主动问用户一句“你之前提到用 pnpm,现在还是吗”,让用户确认或更新。这样记忆库能保持鲜活,而不是越用越陈旧。这个机制实现起来不难,但体验提升很大,值得一试。