
最近我把大量时间砸在了一个叫 claude-mem 的项目上。简单说它是给 Claude 这类对话模型做的一套持久记忆层——解决的是对话模型天生没有跨会话记忆的问题。这篇文章不是官方文档的翻译而是我前后跑了两周、改了三个版本之后把设计和踩坑经验完整整理出来的一份实战记录内容包括架构思路、关键代码、真实体验和记忆治理方案适合正在自己做 Agent、想做会话状态管理的开发者也适合对如何让 AI 记得住事这件事感兴趣的技术爱好者当作参考。1. 对话模型天生失忆这个痛点比想象中严重1.1 一次完整的失忆事故复盘先讲一个真实场景。我前两天在处理一本技术书的读书笔记全程用对话模型辅助。第一天晚上我和 Claude 花了将近一个小时讨论清楚了整本书的章节逻辑梳理出每个章节的核心论点还把零散的批注整理成了结构化的笔记。当时我觉得特别顺第二天准备接着把最后一章整理完。结果第二天打开新会话我还没来得及说话对面就已经完全不记得我们昨天聊过什么了。我重新发了笔记文件补充了昨天的上下文让它继续它确实能继续但我发现它对于我昨天为什么决定采用这种笔记结构完全没有印象甚至把第一天推翻过的视角又重新提了出来。这意味着昨天那一小时里真正重要的判断过程并没有被任何系统记录下来。类似的事情其实每天都在发生。我们跟对话模型之间的协作天然是有状态的人类在和无状态的模型交流。人这边记得所有的背景、偏好、取舍理由模型那边每次会话都是白纸一张。一次两次单独看影响不大但当你需要它承担连续性工作——比如长期项目的技术方案维护、持续追踪一个调研课题、或者只是让同一个助手越来越了解你的写作习惯——无状态就成了致命的短板。1.2 现有方案为什么只能缓解不能根治市面上并不是没有应对手段我梳理下来大致是三类人工喂上下文每次打开新会话手动粘贴历史摘要效率低摘要质量全靠自己写而且长项目很容易漏掉关键细节。超大上下文窗口把整段历史一次性塞进去短期有效但成本和延迟都上去了还容易触发信息混杂导致的注意力分散模型反而抓不住重点。对话日志导出把历史记录存成文件需要时再传给模型。相比前两种好了不少但本质还是人肉检索文件多了以后你根本不知道该传哪一份。这三类思路有一个共同的缺陷它们都在用**尽力保留所有内容的方式对抗失忆而不是让模型知道什么值得记、什么不值得记**。而后者才是记忆系统该干的事。1.3 claude-mem 到底管什么不管什么我做完 claude-mem 之后的定位非常明确它不是一个对话记录工具也不是一个知识库。它只做一件事——维护一份经过提炼的、跨会话复用的长期记忆。不管什么不管每一次对话的完整文字记录那是日志系统的事不管模型内部权重那不是我们这一层能碰的。管什么管从对话中抽取出的可复述事实、用户偏好、项目状态、决策理由。它的输出最终要能作为一种软上下文注入到新会话开头让模型带着记忆工作同时又不至于被历史细节淹没。这样一来模型的失忆问题被转变成另一个问题如何低成本地判断一句对话值不值得记以及如何在新会话里最自然地使用这些记忆。下面整个架构就是围绕这个问题展开的。2. 三段式记忆架构写入、存储、检索各司其职2.1 记忆写入从对话里提炼值得记住的东西我第一版做得非常笨把对话全文存成文件需要时整段塞回去。结果跨会话之后模型依然分不清主次该记住的没记住不该记住的全在里面。后来我把思路改成了**先提炼再存储**。具体做法是把记忆写入拆成三个步摘要生成每段对话结束后对对话内容做一次结构化解构只提取四类信息——事实类用户提到的项目状态、技术选型、偏好类用户明确表达的风格喜好、操作习惯、决策类用户为什么选了 A 而放弃 B、任务类未完成的待办事项。唯一性检查新记忆要与已有记忆比对判断是新增、更新还是重复。这一步很重要不然同一个事实会被反复写入记忆库很快就膨胀得没法看。去重合并如果是更新就改写原记忆条目而不是新增一条如果是重复直接丢弃。这个机制我用一个日常例子解释。你在对话里说我倾向于用 PostgreSQL 而不是 MySQL因为需要复杂查询和 JSON 支持它就会被提炼成一条偏好类记忆数据库选型倾向 PostgreSQL理由复杂查询 JSON 支持。之后你再问它数据库设计问题它就会默认沿着 PostgreSQL 的方向给方案不会又把 MySQL 拿出来重新分析一遍。2.2 存储设计为什么选 SQLite 而非向量数据库记忆存哪里这是我在架构阶段纠结最久的问题。一开始我理所当然地觉得应该上向量数据库记忆嘛语义检索理所当然。但实际做下来发现对 claude-mem 这个需求来讲向量数据库带来的收益并不划算。我最终选的是SQLite FTS全文搜索理由有三个记忆总量不大。个人级使用一天撑死产生几十条记忆一年下来也不过万条级别这个体量 SQLite 毫无压力。记忆检索需要可解释性。记忆会被注入到模型上下文一旦错了我得能查出来为什么错。SQL 查询让我可以直接看到匹配逻辑向量相似度在黑盒里很难排查。部署零依赖。不需要单独起一个数据库服务一个.db文件搞定README 里写三行就能跑起来。但完全放弃向量化也不对我留了一个扩展位每条记忆在写入时同步计算一次摘要向量存到单独的aux_vectors表里。需要纯语义召回时可以用 Python 的轻量向量库在内存里做暴力检索万条规模下耗时也就是几十毫秒完全够用。存储层的核心表结构大致是这样的CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, category TEXT NOT NULL DEFAULT fact, source_conversation_id INTEGER, created_at TEXT NOT NULL DEFAULT (datetime(now)), updated_at TEXT NOT NULL DEFAULT (datetime(now)), last_accessed_at TEXT, access_count INTEGER DEFAULT 0, version INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1 ); CREATE TABLE memory_changelog ( id INTEGER PRIMARY KEY AUTOINCREMENT, memory_id INTEGER NOT NULL, op TEXT NOT NULL, before_raw TEXT, after_raw TEXT, changed_at TEXT NOT NULL DEFAULT (datetime(now)) );memory_changelog表是第二版加上的作用是让每次记忆的增改都有迹可循。实践下来这个表在排查模型为什么会说出某句话的时候帮了很大的忙后面讲冲突处理时还会再提到它。2.3 检索注入怎么把记忆喂回对话上下文存储做完了还差最关键的一环——如何让记忆真正参与对话。我的方案是检索-排序-注入三步检索新会话开始时从用户输入中提取关键词在记忆库里做全文搜索再配一部分高频记忆防止冷启动。比如用户问数据库怎么选检索层会把和数据库、选型相关的记忆捞出来。排序按相关度为主、最近访问时间为辅排序同时把用户明确置顶的重要记忆提到最前面。注入将 Top N 条记忆组装成一段memory context插入到系统提示词和用户问题之间。注入数量我控制在58 条超过这个数模型反而开始混淆来源这是实验出来的经验值。注入格式大概长这样[长期记忆] - 用户偏好数据库PostgreSQL原因复杂查询、JSON支持、扩展生态 - 当前项目状态xx工具已完成初版下一阶段是接入自动化测试 - 用户写作风格偏口语化喜欢先给结论再解释讨厌过度废话 [长期记忆结束]这段记忆会被模型当成已知背景来使用。注意注入的是提炼后的记忆不是原始对话这样既能承载跨会话上下文又不至于让模型只是机械地复制历史回复。3. 核心代码实现一个能跑起来的记忆层3.1 工程结构我实现 claude-mem 用的语言是 Python整套工程只有三个核心文件结构我贴一下方便你对照理解claude-mem/ ├── claude_mem/ │ ├── db.py # 数据库连接与 schema 初始化 │ ├── memory.py # 记忆写入、更新、删除、检索 │ └── cli.py # 命令行入口支持手动添加记忆 ├── memory_store.db # SQLite 数据文件 └── pyproject.toml整个设计刻意保持简单没有去搞复杂的插件体系或者事件总线。原因在于记忆管理这种基础设施越透明越好维护。写成一个函数库 命令行工具的组合既能被脚本调用也能被用户手动管理比做成一个黑盒服务要实用得多。3.2 关键实现解释add_memory 与 recall_memory我看很多开源项目一上来就是几百个文件结果核心逻辑反而被淹没了。claude-mem 里最核心的函数就两个一个管写一个管查我把它们的实现逻辑讲透。add_memory写入前先查重判断是新记忆还是更新已有记忆。查重用的不是简单的字符串匹配而是先做关键词抽取再比对语义相似度避免同一意思换个说法导致重复存储import sqlite3 import uuid import re class MemoryStore: def __init__(self, db_path: str memory_store.db): self.conn sqlite3.connect(db_path) self._init_db() def _init_db(self): self.conn.executescript( CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, category TEXT NOT NULL DEFAULT fact, source_conversation_id INTEGER, created_at TEXT NOT NULL DEFAULT (datetime(now)), updated_at TEXT NOT NULL DEFAULT (datetime(now)), last_accessed_at TEXT, access_count INTEGER DEFAULT 0, version INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1 ); CREATE TABLE IF NOT EXISTS memory_changelog ( id INTEGER PRIMARY KEY AUTOINCREMENT, memory_id INTEGER NOT NULL, op TEXT NOT NULL, before_raw TEXT, after_raw TEXT, changed_at TEXT NOT NULL DEFAULT (datetime(now)) ); ) self.conn.commit() def add_memory(self, content: str, category: str fact, source_conversation_id: int None): cursor self.conn.execute( SELECT id, content, version FROM memories WHERE is_active 1 ) existing None for row in cursor.fetchall(): if self._similarity(row[1], content) 0.85: existing row break if existing: self.conn.execute( UPDATE memories SET content ?, updated_at datetime(now), version version 1 WHERE id ?, (content, existing[0]), ) self.conn.execute( INSERT INTO memory_changelog (memory_id, op, before_raw, after_raw) VALUES (?, update, ?, ?), (existing[0], existing[1], content), ) else: cur self.conn.execute( INSERT INTO memories (content, category, source_conversation_id) VALUES (?, ?, ?), (content, category, source_conversation_id), ) self.conn.execute( INSERT INTO memory_changelog (memory_id, op, before_raw, after_raw) VALUES (?, insert, NULL, ?), (cur.lastrowid, content), ) self.conn.commit()_similarity这里我先用了一个基于词重叠的简单函数够用但是比较粗糙。如果你想做得更准可以把sentence-transformers之类的库引进来算语义相似度代价就是多一个依赖看需求取舍。recall_memory检索时可以用LIKE做关键词匹配也可以配合全文索引。我在真实使用中更喜欢先按关键词粗筛再用last_accessed_at排序这样既能保证相关度高又不会让太久远的记忆占据宝贵上下文def recall_memory(self, query: str, limit: int 6): keywords self._extract_keywords(query) conditions OR .join([content LIKE ? for _ in keywords]) params [f%{kw}% for kw in keywords] rows self.conn.execute( f SELECT id, content, category, last_accessed_at, access_count FROM memories WHERE is_active 1 AND ({conditions or 11}) ORDER BY access_count DESC, last_accessed_at DESC LIMIT ? , (*params, limit), ).fetchall() for row in rows: self.conn.execute( UPDATE memories SET last_accessed_at datetime(now), access_count access_count 1 WHERE id ?, (row[0],), ) self.conn.commit() return [{id: r[0], content: r[1], category: r[2]} for r in rows]_extract_keywords我最开始用jieba分词但后来发现对技术类缩写比如 PostgreSQL、SQLite、API处理不好干脆切回正则 预置词表的方式简单可控。已知代价是中文长句的召回率会略低但通过限制注入条数反而过滤掉了许多噪声。3.3 集成方式在每次会话开始时温和注入有了这两个核心函数接入日常对话就顺理成章了。我写了一个集成示例模拟的是在每次新会话开始时自动带上记忆from claude_mem.memory import MemoryStore def build_system_prompt(user_input: str) - str: store MemoryStore(memory_store.db) memory_items store.recall_memory(user_input, limit6) memory_block \n.join([- m[content] for m in memory_items]) system_prompt f你是我的长期协作助手。以下是关于用户、项目与偏好的长期记忆 {memory_block} 在这个基础上回答问题。如果记忆与当前对话冲突以当前对话为准。 return system_prompt注意system_prompt里那句如果记忆与当前对话冲突以当前对话为准。这行字我第一次写的时候觉得多余后来发现它在防止记忆污染这件事上起了很大作用。模型很容易把强行注入的历史记忆当成必须遵守的新指令导致用户明明在新对话里表达了相反意图它还固执地沿用旧记忆。加了这个声明之后模型会在记忆与当轮输入矛盾时优先听当轮的这个设计后来在我看来是整个项目最值得保留的细节之一。4. 连续实测一周真实的收益与意外翻车4.1 场景一跨会话项目笔记整理先说收益最明显的场景就是我最开始提到的读书笔记整理。我在周一录入了一条记忆正在整理某技术书全书 12 章已完成 8 章第 5 章的核心论点是……。周三继续时我不需要重新上传整份笔记只问了句我们上次整理到哪了模型从记忆库里捞到了任务状态直接接着第 9 章开始干活。更让我满意的是它还记得我在第一轮对话里表达过的整理偏好每章先给结论再给论证标注关键代码位置。我把这个偏好存成了偏好类记忆之后每一次整理它都自动遵循这个格式我完全不需要重复说明。这种收益本质上是把人反复交代背景的时间省了下来让对话模型从每次都要重新培训的实习生变成了越用越顺手的老同事。4.2 场景二记住用户偏好省掉重复叮嘱第二个受益场景是代码风格偏好的跨会话延续。我之前用对话模型辅助写 Python 工具类代码几乎每次都记得强调不要过度设计不要硬上类函数优先。但每次开新会话都要重新说一遍偶尔忘了说模型就会给出一个套着六个类、三层抽象的方案然后我还要花费额外轮次去纠正比直接自己写还慢。把这条偏好存进记忆库之后效果直接改观。写代码请求还没到记忆已经告诉模型用户偏好函数式、扁平的代码结构它给出的第一版方案就已经接近我的习惯了修改次数肉眼可见地减少。为了验证这个效果不是心理作用我特意做了 A/B 对比第一天不开记忆直接提问第二天开记忆问同一个问题两边各五次。有记忆时方案被接受的轮次中第一版直接被采用的比例明显高于无记忆时。数据虽然不严谨但趋势已经足够说明问题了。4.3 翻车记录记忆串味导致的事实错乱当然这套系统不是没翻过车而且翻得很有代表性。事情是这样的我在一个对话里讨论了项目 A 的技术方案明确说项目 A 暂时不考虑容器化部署环境太复杂。模型把这个决策存成了记忆。两天后我在另一个对话里讨论项目 B向它请教容器化部署方案。按理说这是另一个项目不应该受项目 A 决策的影响。但问题来了我当时的记忆条设计只是用户倾向不用容器化没有把适用边界存进去。结果检索时这条记忆被 cue 到并注入到了项目 B 的上下文中模型开始劝我你之前不是说过容器化环境太复杂要不要再考虑一下。我花了两三轮才把这个误解扭过来还差点真的被带偏了。这次翻车让我明白了一个关键原则记忆一定要带边界条件。存用户倾向不用容器化不如存用户倾向项目 A 不用容器化因为部署环境受限其他项目另当别论。从那之后我在记忆格式里强制要求提取scope字段写入时如果信息不足宁可让模型问一句补齐也不存一条可能误导未来的模糊记忆。4.4 实测小结值得做但别期望太高一周实测下来的总结claude-mem 这类记忆层解决的是连续性问题不是智力问题。它能让你和模型的协作变得顺滑减少重复沟通但它不会让模型变得更聪明。如果模型本身在当前任务上的能力不足注入再多记忆也救不回来。另外记忆系统的收益有累积效应。第一天你感觉不出什么变化第三、四天之后当记忆库里积累了几十条高质量记忆模型表现才开始有明显差别。我建议打算试的朋友至少坚持跑一周再评估效果别第一天下了个结论就把它弃了。5. 记忆治理过期、冲突、权限这三个问题躲不开5.1 过期机制记忆不是越多越好很多人在刚接触记忆系统时有个误区以为记忆越多模型越懂自己。实际恰恰相反记忆库膨胀之后检索命中率会被无关内容稀释注入的 Top 条记忆反而可能全是高访问频率但低相关度的热记忆真正该用的那条被排在后面了。我的解决方案有三层写入时精简每条记忆控制在 50 字以内只保留不可推导的结论。定期衰减运行一个定时任务对超过 90 天未访问的记忆做标记降为dormant状态默认不再参与检索但保留可查记录。手动归档CLI 提供archive命令用户可以直接把不再需要的记忆打标。访问计数要配合衰减一起用否则高频但过时的记忆会永远排在前面。我的做法是访问计数每个月衰减一次让最近被使用成为排序的主导因素。# 手动查看当前记忆库里的内容 claude-mem list --active # 删除某条记忆 claude-mem delete --id 23 # 暂停一条记忆不删除但不再注入 claude-mem pause --id 23 # 归档三个月前的旧记忆 claude-mem archive --older-than 90d5.2 冲突处理同一件事出现相互矛盾的说法怎么办记忆冲突是我在处理中最常遇到的问题典型场景是用户今天说我倾向于 PostgreSQL一周后又说我决定还是用 MySQL 算了。如果系统只是简单追加两条记忆模型就会同时看到两个相反的事实表现出的行为会非常不稳定。我在add_memory阶段已经做了查重但查重只能处理相似内容处理不了明显矛盾的内容。后来我给系统加了一个resolve_conflict规则先按scope topic分组把同一主题下的记忆归到一组。如果有新增记忆与组内旧记忆结论相反不直接覆盖而是更新旧记忆并在 changelog 里记录冲突版本。始终以最近一次更新的结论为准但保留历史记录方便回溯。这样做的好处是模型检索到的永远是最新结论而不是一堆互相打架的旧记录。代价是更新连续发生时版本号会不断增大memory_changelog表会变得很长需要定期清理。我在archive命令里顺带做了对 changelog 表的归档超过 50 条的旧版本直接压缩成一条摘要。5.3 隐私与边界哪些内容不该进记忆库最后一个问题也是我认为最该警惕的——记忆系统不该记录所有事情。我最初尝试过让系统自动把对话里所有涉及用户的个人信息都存进记忆比如用户住在某城市用户工作时间是……。听起来很方便但细想一下非常危险。记忆库一旦泄露、或者被某个错误的 prompt 注入带出来这些敏感信息就会变成模型上下文中一个无法控制的风险点。我最终定下的边界原则是不存身份类敏感信息真实姓名、住址、联系方式的替代词都不进记忆库对话里出现就直接跳过。不存不可验证的主观推断比如用户性格内向用户情绪低落这几天一是容易错二是这种标签一旦存了会长期影响模型对你的预设。可存偏好与事实但不存隐私数据细节偏好类可以写喜欢简洁回复不必写常用支付账户是 xxx。用户最好能拥有完整的控制权。我因此做了一个小功能在 CLI 里允许--forget-all一键清空所有记忆同时导出记忆库 JSON 以便备份让用户至少保留随时反悔的权利。如果你要参考 claude-mem 做自己的记忆系统我强烈建议你在设计之初就把这条边界定下来不要等其他人口罩摘下来才想起隐私问题。6. 给打算做同类工具的人几条实在建议如果你看了前面的内容也想自己搓一个对话模型记忆层下面几条是我按优先级排出来的建议每一条背后都有教训第一先做写再做读。我最初的版本一直在优化检索后来发现真正的瓶颈是记忆质量。写入阶段不做去重、不做边界提取检索做得再好也是垃圾进垃圾出。先把add_memory的质量提上来再折腾召回策略。第二注入逻辑要轻。记忆注入不是越多越好也不是越详细越好。模型在处理混杂信息时注意力会被分散与其塞十条记忆不如精挑五条最相关的。我最后设的 58 条上限是用多次对比试出来的你可以从 5 开始。第三边界条件一定写进记忆格式里。用户推荐 PostgreSQL 不如 用户推荐 PostgreSQL在需要复杂 JSON 查询的场景。带边界会降低检索命中率但能显著提高注入后的准确率。这个取舍值得。第四把记忆生命周期管理当成一等公民。过期、冲突、删除这些机制不要等记忆库膨胀到不可收拾再补。在最开始的 schema 设计里就留出is_active、version、last_accessed_at这些字段后面真的需要做清理时有据可依不然就得像我一样回去改表。整套 claude-mem 做下来我最深的体会是给模型外挂记忆这件事技术难点并不在存储或者召回而在**什么时候该记住、什么时候该忘记的判断上**。前者是工程问题有标准答案后者是经验问题只能靠反复使用、反复调整才能慢慢形成一套适合自己的规则。如果你也在做类似的工具我希望这篇记录能帮你少走一些我走过的弯路特别是第二条和第三条建议真的能省下大量调试时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。