1. 项目概述与核心定位
1.1 这个工具到底解决什么问题
claude-mem这个名字第一次看到的时候,我下意识以为是某个 Claude 的周边小工具,实际用下来才发现它解决的是一个非常具体的痛点:跨会话的上下文持久化。
如果你日常用 Claude 做开发、写文档、做研究,肯定遇到过这种情况——昨天聊了三个小时的架构方案,今天开新会话,它完全不记得你是谁、在做什么项目、之前定了哪些技术选型。你得重新贴一遍背景,重新解释约束条件,甚至重新纠正它上次犯过的错。这种重复劳动在长期项目里极其消耗精力。
claude-mem的思路很直接:把每次会话中值得保留的信息抽取出来,存到一个本地可检索的记忆库里,下次开新会话时自动把相关记忆注入上下文。它不是简单的聊天记录存档,而是做了结构化提取 + 语义检索 + 按需注入这一整套流程。
适合谁来用?我梳理了三类:
- 长期维护同一项目的开发者:项目背景、技术决策、踩坑记录需要跨天甚至跨周保持连贯
- 用 Claude 做研究或写作的人:参考资料、论点脉络、已写过的段落需要持续累积
- 多项目并行的人:不同项目之间的记忆需要隔离,不能互相污染
1.2 核心架构拆解
claude-mem的架构可以拆成四层,我用一个生活化的类比来解释:把它想象成一个私人助理的笔记本系统。
| 层级 | 功能 | 类比 |
|---|---|---|
| 采集层 | 从会话中提取关键信息 | 助理在会议中做笔记 |
| 存储层 | 结构化保存记忆条目 | 笔记归档到分类文件夹 |
| 检索层 | 根据当前问题找相关记忆 | 助理翻出相关历史记录 |
| 注入层 | 把记忆拼接到新会话上下文 | 助理在开会前给你简报 |
采集层是最关键的一环。它不能把整段对话原封不动存下来,那样检索时噪音太大。常见做法是用一个轻量的提取 prompt,让模型输出结构化的记忆条目,每条包含:主题标签、核心内容、时间戳、关联项目。这个提取过程本身也消耗 token,所以需要控制频率——不是每轮对话都提取,而是在会话结束或达到一定轮次后批量处理。
存储层我建议用 SQLite 配合向量扩展(比如 sqlite-vec),原因后面会详细讲。检索层是语义检索为主、关键词检索为辅的混合策略。注入层则要控制总量,不能把所有相关记忆都塞进去,否则上下文窗口很快就被占满了。
1.3 为什么值得单独做一个工具
有人可能会问:Claude 本身不是有 Projects 功能吗?为什么还要自己搞一套?
我实际对比过,差异主要在三个地方。第一,Projects 的知识库是静态的,你手动上传什么它就用什么,而claude-mem是动态增长的,每次会话都在自动积累。第二,Projects 的检索是黑盒,你不知道它到底用了哪些内容,而自建方案可以精确控制注入哪些记忆、注入多少。第三,也是最重要的——数据完全在本地,对于涉及内部项目信息的场景,这一点是刚需。
还有一点:claude-mem这类工具通常设计成可以配合 API 调用使用,意味着你可以把它集成到自己的脚本、编辑器插件或者自动化流程里,而不是被绑死在某个网页界面上。这个灵活性是官方功能给不了的。
2. 核心细节解析与实操要点
2.1 记忆提取的粒度控制
这是整个项目里最容易做砸的地方。我踩过的坑是:一开始提取粒度太细,每轮对话都存一条,结果记忆库迅速膨胀到几千条,检索出来的内容全是碎片,拼在一起反而干扰模型判断。
后来调整成按主题聚合的方式:不是按对话轮次切分,而是按话题切换点切分。具体做法是维护一个滑动窗口,当检测到当前话题与上一个话题的语义相似度低于阈值时,就把上一个话题的完整片段作为一个记忆单元存下来。
阈值怎么定?我用的是余弦相似度 0.65 作为分界。低于这个值认为话题发生了切换。这个数字不是拍脑袋来的——我拿自己过去两周的对话记录做了标注,试了 0.5、0.6、0.65、0.7、0.75 几档,0.65 在"不过度切分"和"不遗漏切换"之间平衡最好。当然这个值跟你的对话风格有关,建议自己标注二三十个样本调一下。
每条记忆条目的结构我建议包含这些字段:
{ "id": "唯一标识", "project": "项目名", "topic": "主题标签", "summary": "一两句话的核心内容", "detail": "完整片段", "entities": ["涉及的技术名词、人名、文件名"], "timestamp": "创建时间", "access_count": "被检索次数", "last_access": "最后检索时间" }entities字段很关键,它是关键词检索的抓手。access_count和last_access用于后续做记忆衰减——长期不被访问的记忆可以降低检索权重,避免陈旧信息干扰。
注意:提取记忆时一定要带上项目标识。我见过有人把所有项目的记忆混在一个库里,结果写 A 项目的代码时检索出了 B 项目的架构决策,直接导致模型给出错误建议。
2.2 存储方案选型:为什么是 SQLite + 向量扩展
存储方案我试过三种:纯文件(JSON/Markdown)、纯向量数据库(Chroma、Qdrant)、SQLite + 向量扩展。最后选了第三种,理由如下。
纯文件方案最简单,一条记忆一个 Markdown 文件,用 grep 检索。优点是透明、可读、易备份。缺点是语义检索基本没法做,只能靠关键词匹配,召回率很低。我用了两周就放弃了。
纯向量数据库方案检索能力强,但引入了一个独立的服务进程,部署和维护成本上来了。而且向量数据库通常不擅长结构化过滤——比如"只检索 project=A 且 timestamp 在最近一周内"这种条件,在纯向量库里做起来很别扭。
SQLite + sqlite-vec 的组合刚好补上了这个缺口。SQLite 处理结构化字段的过滤和排序是强项,sqlite-vec 提供向量相似度检索,两者在同一个查询里就能完成"先按项目过滤,再按语义排序"的操作。而且整个数据库就是一个文件,备份就是复制一个文件,迁移毫无成本。
建表语句大概长这样:
CREATE TABLE memories ( id TEXT PRIMARY KEY, project TEXT NOT NULL, topic TEXT, summary TEXT, detail TEXT, entities TEXT, timestamp INTEGER, access_count INTEGER DEFAULT 0, last_access INTEGER ); CREATE VIRTUAL TABLE memory_vectors USING vec0( memory_id TEXT PRIMARY KEY, embedding FLOAT[768] );向量维度 768 对应的是常见的嵌入模型输出维度。如果你用的模型维度不同,改这个数字就行。注意 sqlite-vec 目前对维度的支持是固定的,建表时就要确定好,后期改维度需要重建表。
2.3 检索策略:混合检索的权重分配
单纯靠向量检索有个问题:对于包含具体文件名、函数名、专有名词的查询,向量检索反而不如关键词精确匹配。比如你问"上次那个parse_config函数怎么改的",向量检索可能返回一堆关于配置解析的泛泛内容,而关键词检索能精准命中包含parse_config的那条记忆。
我的做法是双路检索 + 加权融合:
- 向量检索取 Top 20,按相似度打分
- 关键词检索(对 entities 和 detail 做全文索引)取 Top 20,按 BM25 打分
- 两路结果合并去重,用加权公式算最终分数
加权公式我用的是:
final_score = 0.6 * vector_score_normalized + 0.4 * bm25_score_normalized0.6 和 0.4 这个比例也是调出来的。纯向量(1.0/0)在专有名词查询上表现差,纯关键词(0/1.0)在语义泛化查询上表现差,0.6/0.4 是我测试集上综合表现最好的。如果你的使用场景里专有名词查询特别多,可以把关键词权重提到 0.5。
检索出来之后还要做一步去重和压缩。因为同一主题可能有多条记忆,内容有重叠。我的做法是按 topic 分组,每组只保留分数最高的那条,如果组内有多条且内容互补,就做一次摘要合并。这一步能显著减少注入的 token 量。
2.4 注入策略:上下文窗口的预算管理
检索到相关记忆后,怎么注入也是有讲究的。全塞进去肯定不行,上下文窗口是稀缺资源。
我的预算分配是这样的:假设总上下文窗口是 200K token,我会给记忆预留 10% 到 15%,也就是 20K 到 30K token。在这个预算内,按检索分数从高到低填充,填满为止。
注入的格式也很重要。我试过几种,最后固定成这个结构:
[历史记忆 - 项目A] 主题:数据库选型讨论 时间:2024-01-15 内容:确定了使用 PostgreSQL 而非 MySQL,原因是需要 JSONB 支持和更丰富的索引类型... --- 主题:API 认证方案 时间:2024-01-18 内容:采用 JWT + refresh token 方案,access token 有效期 15 分钟...用分隔符明确标出每条记忆的边界,并带上时间和主题,这样模型能清楚地知道哪些是历史信息、什么时候的信息。实测下来,带时间戳的记忆比不带时间戳的,模型在判断信息时效性时准确率高不少。
提示:注入位置建议放在系统提示之后、用户当前问题之前。放在最前面容易被模型忽略,放在最后面又可能干扰当前问题的理解。
3. 实操过程与核心环节实现
3.1 环境搭建与依赖安装
先把基础环境跑起来。我用的是 Python 3.11,主要依赖就几个:
pip install sqlite-vec anthropic numpysqlite-vec是 SQLite 的向量扩展,安装后会提供一个可加载的扩展文件。anthropic是官方 SDK,用来调嵌入模型和做记忆提取。numpy处理向量运算。
加载扩展的代码:
import sqlite3 import sqlite_vec db = sqlite3.connect("memories.db") db.enable_load_extension(True) sqlite_vec.load(db) db.enable_load_extension(False)注意enable_load_extension用完要关掉,这是个安全习惯。有些环境默认禁用了扩展加载,如果报错说not authorized,需要在连接时传check_same_thread=False并确认 Python 编译时开启了扩展支持。
3.2 记忆提取的完整实现
提取逻辑我封装成一个函数,输入是一段对话文本,输出是结构化的记忆列表。核心是构造一个提取用的 prompt:
EXTRACT_PROMPT = """分析以下对话片段,提取值得长期保留的记忆条目。 要求: 1. 只提取有长期价值的信息:技术决策、项目背景、明确的偏好、踩坑记录 2. 忽略寒暄、临时性讨论、已经被推翻的方案 3. 每条记忆包含:主题(10字以内)、摘要(50字以内)、详情(200字以内)、涉及的关键实体 4. 如果整段对话没有值得保留的内容,返回空列表 对话内容: {dialogue} 以 JSON 数组格式输出,每个元素包含 topic, summary, detail, entities 四个字段。"""这里有几个细节值得说。第一,明确要求"忽略已经被推翻的方案"很重要,否则模型会把讨论过程中被否定的选项也存下来,后续检索时造成干扰。第二,"如果整段对话没有值得保留的内容,返回空列表"这个兜底条件能避免模型强行编造记忆。
调用提取:
def extract_memories(dialogue, project): response = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=2000, messages=[{"role": "user", "content": EXTRACT_PROMPT.format(dialogue=dialogue)}] ) try: memories = json.loads(response.content[0].text) except json.JSONDecodeError: return [] for m in memories: m["project"] = project m["timestamp"] = int(time.time()) return memoriesJSON 解析失败的情况一定要处理,模型偶尔会输出带 markdown 代码块包裹的 JSON,或者末尾多一个逗号。我加了一层清洗:先 strip 掉json 和,再尝试解析。
3.3 向量化与入库
每条记忆的 summary + detail 拼接后做嵌入:
def embed_text(text): response = client.embeddings.create( model="voyage-3", input=text ) return response.embeddings[0]嵌入模型的选择上,我用的是 voyage-3,768 维。选它的原因是它在中文和英文混合文本上的表现比较均衡,而且支持 32K 的输入长度,对于较长的记忆详情也能一次处理完。
入库操作要保证原子性,记忆表和向量表要么都成功要么都失败:
def save_memory(db, memory): embedding = embed_text(memory["summary"] + " " + memory["detail"]) db.execute("BEGIN") try: db.execute( "INSERT INTO memories (id, project, topic, summary, detail, entities, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)", (memory["id"], memory["project"], memory["topic"], memory["summary"], memory["detail"], json.dumps(memory["entities"]), memory["timestamp"]) ) db.execute( "INSERT INTO memory_vectors (memory_id, embedding) VALUES (?, ?)", (memory["id"], json.dumps(embedding)) ) db.execute("COMMIT") except Exception as e: db.execute("ROLLBACK") raise e3.4 检索与注入的串联
检索函数把前面说的双路策略实现出来:
def retrieve_memories(db, query, project, top_k=10): query_vec = embed_text(query) # 向量检索 vector_results = db.execute(""" SELECT memory_id, distance FROM memory_vectors WHERE embedding MATCH ? ORDER BY distance LIMIT 20 """, (json.dumps(query_vec),)).fetchall() # 关键词检索 keyword_results = db.execute(""" SELECT id, bm25(memories_fts) as score FROM memories_fts WHERE memories_fts MATCH ? AND project = ? ORDER BY score LIMIT 20 """, (query, project)).fetchall() # 融合打分 scores = {} for mid, dist in vector_results: scores[mid] = scores.get(mid, 0) + 0.6 * (1 - dist) for mid, bm25 in keyword_results: scores[mid] = scores.get(mid, 0) + 0.4 * normalize_bm25(bm25) # 按项目过滤并排序 ranked = sorted(scores.items(), key=lambda x: -x[1]) results = [] for mid, score in ranked[:top_k]: row = db.execute("SELECT * FROM memories WHERE id = ? AND project = ?", (mid, project)).fetchone() if row: results.append(row) return resultsnormalize_bm25是因为 BM25 的分数范围不固定,需要归一化到 0-1 才能和向量分数加权。我用的是 min-max 归一化,在当前结果集内做。
注入函数把检索结果拼成前面说的格式:
def build_memory_context(memories, max_tokens=20000): context_parts = [] current_tokens = 0 for m in memories: entry = f"主题:{m['topic']}\n时间:{format_time(m['timestamp'])}\n内容:{m['detail']}\n---" entry_tokens = estimate_tokens(entry) if current_tokens + entry_tokens > max_tokens: break context_parts.append(entry) current_tokens += entry_tokens return "[历史记忆]\n" + "\n".join(context_parts)estimate_tokens我用的是简单的字符数除以 2.5 的估算,中文场景下这个比例比较接近实际。要精确的话可以调 tokenizer,但每次检索都调一遍开销不小,估算够用了。
4. 常见问题与排查技巧实录
4.1 检索结果不相关怎么办
这是最高频的问题。排查思路按这个顺序走:
第一步,检查嵌入模型是否一致。入库时用的模型和检索时用的模型必须是同一个。我遇到过有人入库用 voyage-3,检索时手滑改成了别的模型,结果向量空间对不上,检索出来的全是随机结果。
第二步,检查项目过滤条件。如果检索时 project 参数传错了,或者记忆入库时 project 字段为空,过滤就会失效或过滤掉所有结果。建议在检索函数里加一行日志,打印出过滤前后的候选数量。
第三步,看查询本身是否太短。三五个字的查询,向量检索效果很差。我的做法是当查询短于 10 个字符时,自动把最近几轮对话的上下文拼进去一起做嵌入,增加语义信息量。
第四步,检查记忆质量。如果提取出来的记忆本身摘要就很模糊,检索自然不准。可以抽样看几条记忆的 summary 字段,如果都是"讨论了某个问题"这种废话,说明提取 prompt 需要调整,要求模型写得更具体。
4.2 记忆库膨胀太快怎么控制
我最初的版本两周就攒了 3000 多条记忆,检索延迟明显上升。后来加了三层控制:
去重:入库前先做一次相似度检查,如果新记忆和已有记忆的向量相似度超过 0.92,就合并而不是新增。合并策略是保留新的 detail,但把 access_count 累加。
衰减:定期(我是一周一次)跑一个清理任务,把超过 90 天未被访问且 access_count 小于 2 的记忆归档到冷存储表,不参与常规检索。
容量上限:给每个项目设一个记忆条数上限,比如 500 条。超过时按"最后访问时间 + 访问次数"的综合分数淘汰最低的。
这三层下来,我的记忆库稳定在 400 条左右,检索延迟控制在 200ms 以内。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 检索返回空 | 项目过滤条件不匹配 | 打印过滤前后数量 | 检查 project 字段 |
| 检索结果随机 | 嵌入模型不一致 | 对比入库和检索的模型名 | 统一模型 |
| 注入后模型答非所问 | 记忆内容与当前问题冲突 | 检查注入的记忆时间戳 | 加时效性权重,旧记忆降权 |
| 提取出大量无意义记忆 | 提取 prompt 太宽松 | 抽样检查 summary 质量 | 收紧 prompt,加负面示例 |
| 入库报错 | 向量维度不匹配 | 检查建表时的维度 | 重建表或换模型 |
| 检索延迟高 | 记忆条数过多 | 统计总条数 | 启用去重和衰减 |
| JSON 解析失败 | 模型输出格式不规范 | 打印原始输出 | 加清洗逻辑和重试 |
4.4 几个我踩过的坑
坑一:时间戳用了本地时间。一开始用datetime.now(),后来跨时区协作时发现记忆时间全乱了。改成统一用 Unix 时间戳存,展示时再转本地时区。
坑二:entities 字段存成了字符串。早期版本 entities 直接存 JSON 字符串,做关键词检索时匹配不到。后来改成存成空格分隔的字符串,同时对 detail 字段建全文索引,检索命中率提升明显。
坑三:忘记处理并发写入。如果你同时开多个会话,两个进程同时写 SQLite 会锁库。我的做法是加一个文件锁,写入时串行化。SQLite 本身支持 WAL 模式,开启后并发读性能好很多:
PRAGMA journal_mode=WAL;坑四:注入的记忆没有排序。早期版本按入库顺序注入,结果最新的记忆排在最后,模型容易忽略。改成按检索分数排序,最相关的放最前面,效果立竿见影。
4.5 性能优化的几个实用技巧
嵌入调用是最大的延迟来源,每次检索都要调一次嵌入 API,网络往返 200-500ms。我的优化是加一层查询缓存:对最近 100 个查询做 LRU 缓存,命中缓存直接返回结果。实际使用中查询重复率不低,缓存命中率能到 30% 左右。
批量嵌入也值得做。如果你一次要入库多条记忆,不要一条一条调嵌入,拼成一个 batch 一次调用,能省不少时间。voyage-3 支持一次最多 128 条输入。
检索时的向量计算,如果记忆条数在几千条以内,SQLite 的暴力扫描完全够用。超过一万条再考虑加 IVF 索引或者换专门的向量库。不要过早优化,我见过有人几百条记忆就上了分布式向量库,纯属折腾自己。
最后分享一个我在实际使用中觉得最有价值的经验:记忆的提取质量比检索算法重要得多。我花了很多时间调检索的权重和排序,后来发现真正影响体验的是提取出来的记忆本身是否准确、具体、有信息量。把提取 prompt 打磨好,比换任何检索算法都管用。具体来说,在提取 prompt 里加几个正例和反例,让模型明确知道什么样的内容值得存、什么样的不值得,效果提升非常明显。