
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权重我实测下来w10.5, w20.2, w30.15, w40.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, confidence0.9, session_idNone): mem { id: fmem_{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, encodingutf-8) as f: f.write(json.dumps(mem, ensure_asciiFalse) \n) return mem[id]追加写入用a模式天然支持并发操作系统层面保证单行写入的原子性。ensure_asciiFalse必须加否则中文会被转义成\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, encodingutf-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_n10, min_confidence0.6): conn sqlite3.connect(data/index.db) rows conn.execute( SELECT * FROM memories WHERE layerL1 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(reverseTrue) 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 天过期省得手动删。这个字段我加得晚但加完之后记忆库的整洁度提升了一大截。