1. 为什么我要自己动手做一个 claude-mem
先说清楚这个项目是干什么的。claude-mem是一个给 Claude 这类大语言模型做对话记忆持久化的轻量工具。它解决的问题很具体:每次开新会话,模型对之前聊过什么一无所知,你得反复交代背景、重复贴代码、重新解释项目结构。我受够了这种"每次都要重新自我介绍"的体验,于是花了一个周末把记忆层单独抽出来做成了claude-mem。
它适合谁?三类人。第一类是把 Claude 当日常编程搭子的人,每天要开十几个会话,上下文反复丢失;第二类是做 AI 应用开发的工程师,需要在产品里嵌入"记住用户偏好"的能力;第三类是喜欢折腾本地工具链的技术爱好者,想搞清楚记忆系统到底怎么落地。不管你是哪一类,只要你有"让模型记住我"的需求,这个项目就有参考价值。
核心关键词就一个:claude-mem。围绕它,我会把设计思路、存储结构、检索逻辑、实操步骤、踩过的坑全部摊开讲。这不是一篇 API 文档翻译,而是一个真实做过这件事的人的经验复盘。
2. 整体设计思路与方案选型
2.1 记忆系统到底该存什么
很多人一上来就想做"全量对话存档",我一开始也这么干,结果三天就放弃了。原因很简单:存得越多,检索越慢,噪声越大。真正有用的记忆不是逐字记录,而是结构化的事实片段。
我把记忆分成四层:
| 层级 | 内容类型 | 示例 | 生命周期 |
|---|---|---|---|
| L1 身份层 | 用户偏好、技术栈 | "主力语言 Python,讨厌 tab 缩进" | 长期 |
| L2 项目层 | 项目背景、架构决策 | "这个服务用 FastAPI + PostgreSQL" | 中期 |
| L3 会话层 | 当前任务上下文 | "正在重构 auth 模块" | 短期 |
| L4 瞬时层 | 临时变量、草稿 | "刚才那个函数名叫 foo" | 单次 |
分层的好处是检索时可以按需加载。L1 永远注入,L2 按项目匹配,L3 按会话 ID 关联,L4 用完即弃。这样既保证连贯性,又不会把上下文窗口撑爆。
2.2 为什么选本地文件而不是向量数据库
这是被问得最多的一个问题。市面上的方案清一色推荐向量库,我偏不用,理由有三条。
第一,规模不匹配。个人使用的记忆条目通常几百到几千条,这个量级用 SQLite 加全文索引完全够用,上向量库属于杀鸡用牛刀。第二,可解释性。向量检索是黑盒,你很难说清为什么这条记忆被召回。而基于关键词和标签的检索,每一条命中都能追溯。第三,部署成本。本地文件零依赖,拷贝一个目录就能迁移,向量库还要考虑服务进程、索引重建、版本兼容。
提示:如果你的记忆条目预期超过十万条,或者需要跨语言语义检索,那还是老老实实上向量方案。工具选型永远看场景,没有银弹。
2.3 存储格式的取舍
我最终选了JSONL + SQLite 索引的组合。JSONL 负责原始存储,一行一条记忆,追加写入极快,人类可读,出问题直接打开看。SQLite 负责索引和检索,把关键词、标签、时间戳、层级这些字段建索引,查询走 B-tree。
为什么不直接全用 SQLite?因为记忆内容经常需要人工审阅和批量编辑,JSONL 的纯文本形态对这类操作友好得多。为什么不直接全用 JSONL?因为几千条以上做条件查询时,全量扫描的性能会肉眼可见地变差。
这个组合的本质是读写分离:写入走 JSONL 保证吞吐和可读,读取走 SQLite 保证速度。两者通过一个同步脚本保持一致,写入后异步更新索引。
3. 核心数据结构与检索逻辑拆解
3.1 一条记忆的字段设计
每条记忆的 schema 我改了七八版才稳定下来,最终长这样:
{ "id": "mem_20250115_a3f2", "layer": "L2", "content": "项目使用 FastAPI 作为 Web 框架,数据库是 PostgreSQL 15", "tags": ["project:myapp", "stack:backend", "db:postgres"], "keywords": ["FastAPI", "PostgreSQL", "Web框架"], "source_session": "sess_20250115_001", "created_at": "2025-01-15T10:23:00Z", "updated_at": "2025-01-15T10:23:00Z", "confidence": 0.9, "hit_count": 0 }几个字段值得单独说。layer决定加载优先级,前面讲过。tags用冒号分隔的命名空间,方便前缀匹配,比如project:myapp能一次捞出某个项目的所有记忆。confidence是我加的,因为有些记忆是从对话里推断出来的,不一定准,给个置信度,检索时可以设阈值过滤。hit_count记录被召回次数,用于后续做热度排序。
3.2 检索是怎么工作的
检索流程分三步:过滤、打分、截断。
过滤阶段先按layer和tags做硬筛选。比如当前会话属于project:myapp,那就只保留 L1 全局记忆和project:myapp的项目记忆,其他项目的一律不看。
打分阶段对候选集算一个综合分:
score = w1 * keyword_match + w2 * recency + w3 * hit_frequency + w4 * confidence权重我实测下来w1=0.5, w2=0.2, w3=0.15, w4=0.15比较均衡。keyword_match是查询词和记忆关键词的重合度,recency按时间衰减,越新越高,hit_frequency是归一化后的命中次数,confidence直接用字段值。
截断阶段按分数排序,取前 N 条,N 由当前上下文窗口的剩余空间决定。我一般留 20% 的窗口给记忆,剩下的给对话本身。
3.3 记忆的写入时机
什么时候该写记忆?我的策略是显式触发 + 隐式抽取双轨。
显式触发就是用户主动说"记住这个",或者调用一个remember()接口。这种方式准确率高,但依赖用户习惯。
隐式抽取是在每轮对话结束后,用一个轻量 prompt 让模型判断"这轮对话里有没有值得长期记住的事实"。有就抽出来,没有就跳过。这里的关键是抽取 prompt 要足够克制,宁可漏抽也不要乱抽。我最初的 prompt 太激进,结果把"用户说了句你好"都存进去了,噪声爆炸。
注意:隐式抽取一定要加去重。同一件事反复被抽出来是常态,我用的方案是对
content做归一化后算相似度,超过 0.85 就合并,更新updated_at和hit_count,而不是新增一条。
4. 完整实操流程与关键环节实现
4.1 环境准备与目录结构
项目本身零外部依赖,Python 3.9+ 即可。目录结构我建议这样组织:
claude-mem/ ├── data/ │ ├── memories.jsonl # 原始记忆存储 │ └── index.db # SQLite 索引 ├── src/ │ ├── store.py # 读写层 │ ├── retrieve.py # 检索层 │ ├── extract.py # 隐式抽取 │ └── sync.py # 索引同步 ├── config.yaml # 权重、阈值配置 └── cli.py # 命令行入口data/目录建议加进.gitignore,记忆是私密数据,不该进版本库。如果你要备份,单独同步这个目录就行。
4.2 写入一条记忆的完整代码
先看写入逻辑,这是整个系统的基础:
import json import uuid from datetime import datetime, timezone def add_memory(layer, content, tags, keywords, confidence=0.9, session_id=None): mem = { "id": f"mem_{datetime.now().strftime('%Y%m%d')}_{uuid.uuid4().hex[:4]}", "layer": layer, "content": content, "tags": tags, "keywords": keywords, "source_session": session_id, "created_at": datetime.now(timezone.utc).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(), "confidence": confidence, "hit_count": 0 } with open("data/memories.jsonl", "a", encoding="utf-8") as f: f.write(json.dumps(mem, ensure_ascii=False) + "\n") return mem["id"]追加写入用a模式,天然支持并发(操作系统层面保证单行写入的原子性)。ensure_ascii=False必须加,否则中文会被转义成\uXXXX,可读性全毁。
写完 JSONL 后要触发索引同步。同步逻辑我放在单独的函数里,可以手动调也可以定时跑:
import sqlite3 def sync_index(): conn = sqlite3.connect("data/index.db") conn.execute(""" CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, layer TEXT, content TEXT, tags TEXT, keywords TEXT, created_at TEXT, confidence REAL, hit_count INTEGER ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_layer ON memories(layer)") conn.execute("CREATE INDEX IF NOT EXISTS idx_tags ON memories(tags)") with open("data/memories.jsonl", encoding="utf-8") as f: for line in f: m = json.loads(line) conn.execute( "INSERT OR REPLACE INTO memories VALUES (?,?,?,?,?,?,?,?)", (m["id"], m["layer"], m["content"], ",".join(m["tags"]), ",".join(m["keywords"]), m["created_at"], m["confidence"], m["hit_count"]) ) conn.commit() conn.close()INSERT OR REPLACE保证幂等,重复跑同步不会产生脏数据。索引建在layer和tags上,因为这两个字段是过滤阶段的主力。
4.3 检索函数的实现细节
检索是核心,我把打分逻辑完整写出来:
import math from datetime import datetime, timezone W_KEYWORD, W_RECENCY, W_HIT, W_CONF = 0.5, 0.2, 0.15, 0.15 def retrieve(query_keywords, project_tag, top_n=10, min_confidence=0.6): conn = sqlite3.connect("data/index.db") rows = conn.execute( "SELECT * FROM memories WHERE layer='L1' OR tags LIKE ?", (f"%{project_tag}%",) ).fetchall() conn.close() now = datetime.now(timezone.utc) scored = [] for r in rows: mem_id, layer, content, tags, keywords, created, conf, hits = r if conf < min_confidence: continue kw_list = keywords.split(",") match = len(set(query_keywords) & set(kw_list)) / max(len(query_keywords), 1) age_days = (now - datetime.fromisoformat(created)).days recency = math.exp(-age_days / 30) hit_score = min(hits / 10, 1.0) score = (W_KEYWORD * match + W_RECENCY * recency + W_HIT * hit_score + W_CONF * conf) scored.append((score, mem_id, content)) scored.sort(reverse=True) return scored[:top_n]recency用指数衰减,半衰期设 30 天,意思是 30 天前的记忆权重降到约 0.37。这个参数可以按你的使用频率调,天天用的话半衰期可以短一点,比如 14 天。
hit_score用min(hits/10, 1.0)做饱和处理,避免高频记忆无限膨胀压过其他维度。
4.4 把记忆注入对话的实操
检索出来的记忆怎么用?我的做法是拼成一段结构化前缀,放在 system prompt 里:
[长期记忆] - 用户主力语言是 Python,偏好类型注解 - 当前项目 myapp 使用 FastAPI + PostgreSQL 15 - 用户不喜欢过度注释,代码要简洁 [当前会话上下文] - 正在重构 auth 模块的 token 刷新逻辑这段前缀控制在 500 token 以内,超了就按分数砍。实测下来,有了这段前缀,模型第一次回复的准确率提升非常明显,尤其是涉及项目约定的问题,基本不用再解释第二遍。
提示:注入的记忆要标注来源层级,方便模型判断可信度。L1 的偏好可以直接采信,L3 的会话上下文如果和当前对话冲突,以当前对话为准。
5. 常见问题与排查技巧实录
5.1 记忆污染:模型记错了怎么办
这是最头疼的问题。表现是模型信誓旦旦地说"你之前说过 X",但 X 根本是它自己编的。根因通常是隐式抽取时把模型的推测当成了事实。
我的解法是给抽取加一道确认门槛:只有用户明确陈述的事实才允许写入 L1/L2,模型推断出来的内容一律标confidence <= 0.5,检索时默认过滤掉。另外加一个claude-mem review命令,定期人工过一遍低置信度记忆,该删的删,该改的改。
5.2 检索召回不准的排查路径
召回不准分两种:该召回的没召回,不该召回的召回了。
前者先查tags是否匹配。我踩过一次坑,项目 tag 写成了project:MyApp,检索时用的是project:myapp,大小写不一致导致全部漏掉。后来统一规定 tag 全小写。
后者多半是关键词太泛。比如把"代码"当关键词,那几乎所有记忆都会命中。解决办法是维护一个停用词表,把这类高频泛词过滤掉,只保留有区分度的词。
5.3 性能问题的速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 检索变慢 | 索引未更新 | 查 index.db 行数 vs jsonl 行数 | 跑 sync_index |
| 写入卡顿 | jsonl 文件过大 | 看文件大小 | 按月分片 |
| 内存占用高 | 全量加载 | 看进程内存 | 改流式读取 |
| 召回为空 | tag 不匹配 | 打印实际 tag | 统一大小写 |
jsonl 按月分片是个实用技巧。文件名用memories_202501.jsonl,检索时按时间范围只加载相关月份的文件,老数据归档不参与日常检索。
5.4 几个我踩过的坑
第一个坑是时间戳时区混乱。早期我混用了本地时间和 UTC,导致 recency 计算出现负数。后来强制全部用 UTC,存储和计算统一,显示时再转本地。
第二个坑是并发写入丢数据。多进程同时追加 jsonl 时,如果单行超过操作系统的原子写上限(通常 4KB),会出现行交错。解决办法是限制单条记忆长度,超过就拆分,或者加文件锁。
第三个坑是记忆膨胀。跑了两个月,jsonl 涨到几万条,检索明显变慢。后来加了归档策略:hit_count为 0 且超过 90 天的 L3/L4 记忆自动归档到冷存储,主库只留活跃记忆。
6. 记忆系统的扩展方向
claude-mem目前是个单机工具,但它有几个自然的扩展点。一是多设备同步,把 data 目录放到同步盘或者自建一个简单的同步服务,让记忆跟着人走。二是记忆可视化,做一个简单的 Web 界面,把记忆按层级和标签展示成图谱,方便审阅和清理。三是跨模型复用,记忆层本身和具体模型解耦,理论上换个模型只要改注入格式就行。
我个人在实际使用中的体会是,记忆系统的价值不在于技术多复杂,而在于克制。存得少、存得准、检索得精,比堆一堆花哨功能有用得多。我见过太多人一上来就搞向量库、搞知识图谱,结果维护成本高到自己都不想用。先用最简单的方案跑起来,让记忆真正融入日常,再考虑优化,这个顺序不能反。
最后分享一个小技巧:给记忆加一个expire_at字段,对临时性的事实设过期时间,到期自动清理。比如"这周在调试支付模块"这种,设个 7 天过期,省得手动删。这个字段我加得晚,但加完之后记忆库的整洁度提升了一大截。