如果你也遇到过这种场景:跟 Claude 聊了一个星期的项目,换一个新会话,它连我们三天前定下的技术栈都不记得了。我是在一个周五下午遇到这件事的,当时对着空白的输入框愣了几秒,然后决定不再当"人肉上下文",动手写一个给 Claude 用的持久记忆工具,代号就叫 claude-mem。
这个工具通过 MCP 协议给 Claude 挂上一个外置记忆库,自动把对话沉淀成可检索的摘要片段,等下次新会话开启时,按语义召回最相关的历史记忆,重新注入当前上下文。文章里会完整记录我在设计、实现和调优 claude-mem 时的思路、代码细节,以及三个让我折腾到半夜的坑。如果你重度使用 Claude 做技术方案、写作、编程,并且已经受够了"每次重新交代背景"的循环,这篇内容应该能直接拿来当参考。
1. 为什么 Claude 越聊越"蠢":一个让我决定动手做记忆工具的导火索
1.1 从"上下文塞满"到"重复交代背景",问题出在会话天然无状态
先说我自己的真实经历。我在维护一个内部项目,涉及前端、后端、部署三个模块,基本每个模块都会跟 Claude 聊上几个小时。前端方案聊完了,第二天开新会话聊后端,Claude 完全不记得前端已经定了什么约束。我无奈地开始粘贴背景材料,从技术栈到已决策项,粘贴完这几段背景,上下文预算已经烧掉了不少,真正的问题还没开始问。
这个现象背后的原理其实很直白:每一次会话都是独立的,模型没有跨会话记忆。上下文窗口有长度上限,对话过程中内容超了就会被截断,本质上像是一个一次性的工作台,东西放上去,关掉会话就清空了。或许有人会觉得"把上下文写长一点就完事",但窗口再长也有天花板,而且塞进来的每一个 token 都会增加处理成本和响应延迟。
1.2 那些常见的"记忆方案",为什么总差一口气
在决定自己写工具之前,我把能想到的方案都试了一遍,各有各的难受。
| 方案 | 做法 | 痛点 |
|---|---|---|
| 手动复制历史 | 新会话开头粘贴上次的关键结论 | 每开一次会话都要人工整理,费时,且靠自觉维护 |
| System Prompt 塞背景 | 把项目说明写死在提示词里 | 有长度限制,内容更新后得手动改,过期信息容易残留 |
| 外置文档喂给 Claude | 每次把整个文档丢进上下文 | 文件大了很贵,而且与当前问题无关的内容会稀释注意力 |
| 自己搭 RAG | 文档切片后做向量检索再注入 | 需要额外维护向量库和检索流程,对个人工具来说太重 |
| claude-mem | 对话自动沉淀,跨会话按语义召回 | 需要写代码自己搭,但一次搞定后就不用再管 |
这几个方案共同的问题是:你把"记忆维护者"的角色交给了人。要么靠人肉复制,要么靠人肉更新文档。我想要的是一个能自动运转的东西,对话结束不需要我再做二次整理。这正是 claude-mem 立项时最原始的动机。
1.3 claude-mem 要解决的三个核心问题
所以我给这个工具定了三条硬性要求。
第一,自动沉淀。对话过程中产生的关键结论、偏好、决策要自动进入记忆库,而不是等对话结束后我再手动整理。第二,跨会话召回。新会话里用户只要正常提问,工具就能根据语义检索到旧记忆,把它重新放回上下文。第三,低摩擦接入。我不想改变和 Claude 对话的方式,哪怕有一天换一个支持 MCP 的客户端,这套记忆库还能继续用。
带着这三个目标,我开始了 claude-mem 的设计。技术选型的时候比想象中纠结,但核心思路确定之后,实现路径其实很清晰。
2. 整体设计与技术选型:MCP、SQLite 和语义检索是怎么拧在一起的
2.1 为什么选择 MCP 当"记忆插槽"
先解释一下 MCP 是什么。MCP 的全称是模型上下文协议,它定义了一套统一规范,让 AI 应用可以调用外部工具、读取外部数据源。Claude Desktop 和 Claude Code 这类客户端原生支持 MCP,所以我只要实现一个 MCP Server,记忆功能就能像普通工具一样被 Claude 使用,而不需要依赖某个特定客户端的私有没有接口。
为什么不用"把历史记录全部塞进 System Prompt"这种笨办法?因为成本高而且会稀释注意力。上下文里堆的无关历史越多,模型对当前问题的判断就越容易被干扰。我的思路是把 MCP 当成一个"记忆插槽":Claude 在需要的时候主动调用 search_memory 工具去查,而不是每一轮都把所有历史强行喂给模型。
claude-mem 的 MCP Server 总共注册了三个工具:save_memory(写入记忆)、search_memory(检索记忆)、delete_memory(删除记忆)。工具数量刻意保持精简,因为对模型来说,工具越多,调用时的决策负担就越大。
2.2 SQLite 做记忆库:单文件、零运维、够用
确定接入方式之后,下一个问题是记忆库用什么存。我选了 SQLite,理由很简单:个人工具的数据量撑死就是几万条记录,SQLite 单文件就能搞定;备份就是复制一个文件;事务可靠,不容易出现写一半文件损坏的情况。
不用 JSON 文件的原因是并发写入容易互相覆盖,而且每次检索都得全量加载,几百条可以忍,上万条就会变慢。不上专业数据库则是因为没必要,个人工具还要装服务端和维护数据目录,属于过度设计。实际项目里我会尽量让事情简单一点,能用一个文件解决的问题,不值得引入一整套服务。
表结构设计也比较精简,核心就三张表:
CREATE TABLE conversations ( id INTEGER PRIMARY KEY, title TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE messages ( id INTEGER PRIMARY KEY, conversation_id INTEGER, role TEXT, content TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE memories ( id INTEGER PRIMARY KEY, conversation_id INTEGER, content TEXT, summary TEXT, embedding BLOB, created_at TEXT DEFAULT CURRENT_TIMESTAMP );messages 表存原始对话,memories 表存经过提炼的记忆片段。embedding 字段用 BLOB 存向量,这样暂时不需要引入独立的向量数据库。
2.3 语义检索:只用向量还不够,必须加关键词兜底
记忆检索的核心是"怎么找到相关的旧内容"。我采用了双路召回方案,第一路是向量相似度,第二路是关键词过滤。
向量相似度的原理是把文本通过嵌入模型转换成一串高维向量,语义相近的文本在向量空间里距离也近。我选择在本地跑一个轻量的中文嵌入模型,既省 API 费用,又不会把对话数据发到外部服务。向量直接存到 SQLite 的 BLOB 字段里,检索时暴力计算余弦相似度并取 top_k,在几万条数据的规模下完全能接受。
但向量检索有一个明显短板:对数字、版本号、专有名词不敏感。比如"把依赖升级到 Python 3.12"和"测试 Python 3.11 的兼容性",语义上差别很大,但向量距离可能很接近,导致召回一堆不相干的片段。关键词过滤就是干这个的,用正则和精确匹配优先命中包含"Python 3.12"这类实体的记忆,再参与排序。
2.4 一次带记忆的对话请求是怎么流转的
完整链路大概是这样的顺序:用户在 Claude 里发出提问;Claude 分析当前问题是否需要历史记忆,需要的话会调用 search_memory 工具;MCP Server 收到查询后先做查询嵌入化,再去 SQLite 里做向量召回和关键词精排,返回最相关的几个记忆片段;Claude 把这些片段组装进当前上下文,然后生成回答;回答完成后,消息异步写入 messages 表和 memories 表,如果消息数量达到阈值,再触发一次摘要生成。
这里有一个关键细节:检索是同步的,但摘要生成是异步的。用户提问时最怕的就是每个问题都要等摘要生成完,那体验会非常差。异步沉淀的好处是,记忆写入不会阻塞正常对话,Claude 该回答就回答,沉淀的事在后台悄悄完成。
3. 手把手实现核心流程:从 MCP 服务器骨架到记忆自动沉淀
3.1 项目骨架和 MCP Server 初始化
我用 Python 来写这个项目,依赖主要是 mcp 的 Python SDK、sqlite3 标准库、以及一个本地嵌入模型。项目结构比较简洁,一个server.py负责 MCP 工具注册,一个memory_store.py负责数据库读写,一个embedder.py负责文本转向量,一个summarizer.py负责调用模型生成摘要。
MCP Server 的骨架长这样:
from mcp.server.fastmcp import FastMCP mcp = FastMCP("claude-mem") @mcp.tool() def search_memory(query: str, top_k: int = 5) -> list[dict]: """根据查询文本召回最相关的历史记忆片段。""" return memory_store.search(query, top_k=top_k) @mcp.tool() def save_memory(content: str, conversation_id: str, metadata: dict | None = None) -> str: """把一段关键决策或结论写入长期记忆库。""" memory_store.save(content, conversation_id, metadata or {}) return "saved" @mcp.tool() def delete_memory(memory_id: int) -> str: """手动删除某条记忆片段。""" memory_store.delete(memory_id) return "deleted"MCP 协议默认通过 stdio 传输,所以 Claude Desktop 配置里只需要指向启动命令,比如python /path/to/server.py,不需要开放网络端口。这保证了工具只在本地被调用,不会暴露额外攻击面。
3.2 写入流程:不是存聊天记录,而是存"决策片段"
在最初版本里,我天真地想把原始 messages 直接灌进 memories 表,结果发现检索噪声巨大。原因很好理解:原始对话里大量内容属于寒暄、反复试探、废话,这些都会干扰向量检索的结果。后来我调整了策略:messages 表保存原始记录用于回溯,memories 表只保存经过提炼的"决策片段"。
save_memory 的内部逻辑会做三件事:清洗文本,去掉无意义的语气词和重复内容;提取关键信息,比如技术选型、偏好、结论、时间点;生成结构化摘要,记录会话 ID 和时间戳。这样存入记忆库的内容是压缩过的、高密度的,检索时命中率明显提升。
摘要生成是按主题压缩,而不是逐句总结。我给 summarizer 定义了一个固定输出框架:当前项目背景、确定的结论、候选方案、用户偏好、下一步待办。这样后续检索时能快速定位到用户真正需要的"结论",而不是一段普通聊天内容。
def generate_summary(messages: list[dict]) -> str: prompt = f""" 请把以下对话内容压缩成结构化摘要,包含: - 项目背景 - 确定的结论/决策 - 候选方案 - 用户偏好 - 下一步待办 --- {messages} """ return llm_chat(prompt)3.3 召回流程:查询嵌入化、向量召回、关键词精排
search_memory 的完整实现分三步。第一步,对用户查询做嵌入化,得到查询向量。第二步,遍历 memories 表,用余弦相似度计算每条记忆与查询的相似度,筛掉低于阈值的片段,按分数排序。第三步,用关键词和正则对候选片段做精排,比如查询里包含"FastAPI"时,精确包含"FastAPI"的记忆应该排在前面。
代码实现用到了一个关键技巧:把向量存储为 BLOB 二进制数组,检索时再反序列化成 numpy 数组。数据量小的时候,这种暴力扫描的方法反而比维护复杂索引更可靠。
import sqlite3, numpy as np def cosine_similarity(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def search_memory(query: str, top_k: int = 5, min_score: float = 0.6): q_vec = embedder.encode(query) rows = conn.execute("SELECT id, content, embedding FROM memories").fetchall() scored = [] for row in rows: vec = np.frombuffer(row[2], dtype=np.float32) score = cosine_similarity(q_vec, vec) if score >= min_score: scored.append((score, row[1], row[0])) scored.sort(reverse=True) return scored[:top_k]召回后返回的片段,会按照模板格式拼进上下文,例如"历史记忆(来自 3 天前):用户明确说过后端优先选择 FastAPI,原因是团队更熟悉 Python"。Claude 看到这段记忆后,回答就知道该怎么对齐用户的历史偏好。
3.4 嵌入和摘要模型怎么选才划算
嵌入模型我用了本地轻量的中文模型,几百 MB 级别,纯 CPU 就能跑。摘要模型直接用 Claude 本身,因为摘要质量直接影响记忆库的价值,这里不建议省。成本上,本地嵌入是零 API 费用,摘要按 token 计费但触发频率不高,整体开销可以接受。
| 环节 | 方案 | 成本 | 特点 |
|---|---|---|---|
| 文本嵌入 | 本地轻量模型 | 零 API 费用 | 隐私友好,中文表现需要实测 |
| 远程嵌入 | 云端模型 | 按 token 计费 | 质量高,但数据要出本机 |
| 摘要生成 | Claude 当前模型 | 按 token 计费 | 质量高,触发频率可调低 |
经验是:摘要频率不要设太高,否则 API 费用会涨得很快。我最终设定每 20 条消息或 4000 token 才触发一次摘要,既保证记忆连续性,又不会让后台任务频繁跑。
4. 实测效果与四个关键参数:让记忆"记得准"比"记得多"更重要
4.1 跨会话技术选型记忆:一个完整的实测场景
工具跑起来之后,我做了个最直接的验证。第一天,我和 Claude 聊后端技术选型,聊到中途明确说了"不想引入重型框架,倾向用 FastAPI"。第二天,我开了一个全新会话,直接问"我们后端定的是什么?"。如果没有 claude-mem,这个问题注定需要我重新讲一遍背景;但有记忆库的情况下,Claude 在回答前调用了 search_memory,召回了前一天的结论,然后直接回答"根据历史记忆,后端倾向 FastAPI,理由是团队熟悉 Python 且项目规模不大"。
这次实测的召回结果如下:
| 记忆片段 | 相似度 | 是否命中 |
|---|---|---|
| 用户明确说后端优先考虑 FastAPI,原因是团队更熟悉 Python | 0.82 | 命中 |
| 讨论过 Django 的重量级特性以及维护成本 | 0.67 | 命中 |
| 前端组件打算用某个 UI 库 | 0.31 | 未命中 |
相似度低于 0.6 的基本都是无关内容,说明 min_score 设为 0.6 在当前场景下是合理的。
4.2 四个关键参数怎么调才不翻车
我重点调了四个参数,这里直接给出我实测下来的推荐值和建议。
top_k 是每次召回的记忆片段数量,默认 5。太小容易漏掉关键信息,太大又会让上下文被无关记忆占满,实测下来 5 到 10 之间比较合适,我最终停在 5。
min_score 是相似度阈值,默认 0.6。这个参数宁高勿低,低阈值会让大量弱相关的记忆进入上下文,反而干扰 Claude 的判断,也就是所谓的"记忆污染"。0.6 到 0.7 是我在中文场景下的经验区间。
摘要触发间隔是每多少条消息生成一次摘要,我设成 20 条消息或 4000 token,两个条件先到先触发。这个值影响的是记忆库的更新频率,太频繁会白烧 token,太稀疏又可能漏掉关键转折。
max_memory_block 是每次注入上下文的记忆块最大长度,我限制在 800 token 以内。记忆不是越多越好,给 Claude 塞一整个记忆库进去,它反而不知道哪个信息对当前问题最重要。
4.3 记忆噪声和过期信息处理:怎么避免旧消息误导新决策
对话里会有大量临时信息,比如"先这样试试""暂时用这个方案",这类内容过几天就会过期。如果全部塞进记忆库,老方案会干扰新决策。我采用了两层策略。
第一层是时间衰减。检索时对记忆片段按时间做加权,超过一定时间阈值的记忆分数会打折,新鲜记忆优先。第二层是让用户能手动标记和删除。MCP 工具里提供 delete_memory,用户发现某条记忆已经过时或错误时,一句话就能让 Claude 调用工具把它删掉。这比在数据库里手动改文件方便得多。
还有一个更深的原则:记忆系统的目标是辅助决策,不是存档。我后来梳理的时候发现,真正值得进记忆库的是"确定性的结论",而不是"过程性的讨论"。所以我在摘要生成框架里刻意区分了"确定的结论"和"候选方案"两个字段,检索时优先返回结论类记忆。
4.4 性能开销实测:普通笔记本上能不能忍受
我担心过这个工具会让对话变慢,实测数据打消了顾虑。在普通笔记本的 CPU 上,本地嵌入模型编码一个查询大约耗时 30 到 50 毫秒,SQLite 暴力扫描一万条向量并计算余弦相似度大约 80 到 120 毫秒,总耗时在 150 毫秒左右。这个量级的延迟,用户基本感知不到。
| 环节 | 耗时 | 说明 |
|---|---|---|
| 查询嵌入化 | 30-50ms | CPU 推理,受文本长度影响 |
| SQLite 向量扫描 | 80-120ms | 一万条向量规模 |
| 结果组装和注入 | <10ms | 纯字符串拼接 |
如果记忆量超过五万条,暴力扫描就会开始吃力,那时可以考虑换独立向量数据库或者给向量列建立索引,但个人使用场景下其实很难走到那一步。
5. 踩坑记录:三个让我折腾到半夜的问题
5.1 MCP 工具返回体不符合客户端预期,工具调用一直报错
跑通第一版的时候,Claude Desktop 里调用 search_memory 一直报"Tool execution failed",看日志也只有一个笼统的错误描述。因为错误信息太模糊,我最初怀疑是 MCP Server 崩溃或者网络问题,排查了半小时毫无进展。
后来我写了一个本地 stdio 客户端脚本,直接绕过 Claude Desktop 去调用 MCP Server,把返回结果完整打出来,才发现问题根本不在 MCP Server 本身,而是工具返回的数据结构不符合协议要求。早期版本的 mcp SDK 里,工具函数直接返回了普通字符串,但外部客户端期望的是一个 content 对象的列表,里面每项要明确 type 为 text。
修复很简单,让工具函数返回正确的 content 结构,或者用 SDK 提供的辅助类包装一下返回值。这个坑给我的教训是:调试 MCP Server 时不要每次都用客户端测,先写一个最小的 stdio 脚本在命令行里把工具调用结果打出来,定位问题的速度快得多。
5.2 SQLite 写锁:摘要线程和主线程同时抢数据库
异步摘要生成引入之后,我很快遇到了 SQLite 的经典问题:database is locked。原因是主进程写入消息的同时,后台线程也在写入摘要,SQLite 默认的 journal 模式在并发写入时会直接拒绝第二个写入请求,而不是等待。
排查过程比较烦,因为不是每次都会触发,而是和时序强相关。最终确定是写并发问题后,解决方式是在连接初始化时执行两条 PRAGMA:PRAGMA journal_mode=WAL;和PRAGMA busy_timeout=3000;。WAL 模式允许读写并发,busy_timeout 让连接在遇到锁时等待三秒而不是立刻报错。
顺手也改掉了另一个隐患:每次操作都新建数据库连接,改成使用连接池复用连接。SQLite 的单写者限制还在,但对这个量级的个人工具来说已经足够稳定。
5.3 中文文本向量化效果差,召回结果频频跑偏
最头疼的是中文召回不准。一开始我用了英文场景下表现很好的通用嵌入模型,结果中文查询召回的内容经常驴唇不对马嘴,比如问"部署方案"却召回"训练数据准备",语义关系八竿子打不着。
根因是通用英文模型在中文语料上的分布覆盖不足。换成一个在中文语料上训练过的轻量模型之后,召回率明显改善。但中文嵌入模型的 benchmark 和真实召回表现往往不一致,所以我养成了一个习惯:任何新嵌入模型接入前,先拿实际对话数据跑一遍召回测试,人工检查命中率,而不是只看公开评测分数。
同时也在检索环节加了关键词兜底,遇到版本号、类名、专有名词这类高确定性信息时,关键词匹配比向量更可靠。双路召回之后,中文场景的可用性才真正到了一个能日常使用的水平。
最后说一点个人体会。claude-mem 做完之后,我最大的感受是记忆工具的核心难点不在技术,而在"该记什么"和"该忘什么"。向量检索、SQLite、MCP 这些技术都是成熟的,难的是摘要框架怎么设计才能让记忆高密度、低噪声,以及阈值怎么调才能保证检索精准。我现在每天还在用这个工具,目前体验比较满意。如果你也在做类似的记忆增强工具,建议先从你真实的对话数据出发,统计一下哪些信息是你反复强调的,再倒推记忆库的摘要格式,这种思路比照搬现成方案要靠谱得多。