从零构建AI对话记忆系统:存储、检索与遗忘的工程实践
1. 从claude-mem这个名字说起它到底想解决什么第一次看到claude-mem这个命名我的直觉是这是一个围绕对话记忆做文章的项目。拆开来看claude 指向的是对话式 AI 的交互场景mem 显然是 memory 的缩写。合在一起它要处理的核心问题就浮出水面了——如何让 AI 在多次对话之间记住东西。这件事听起来简单做起来极其麻烦。用过对话式 AI 的人都有体会你昨天跟它聊了一个项目的架构设计今天再开一个新会话它完全不记得你是谁、之前聊过什么。每次都要重新交代背景效率低得让人抓狂。这不是模型能力不行而是会话本身是无状态的——每次请求对模型来说都是第一次见面。claude-mem这类项目要做的就是在无状态的对话接口之上搭一层有状态的记忆系统。它要回答几个很实际的问题记忆存在哪里什么时候写入什么时候读取读多少怎么保证不把无关信息塞进去污染上下文这些问题每一个都有坑而且坑与坑之间还相互牵连。这篇文章适合三类人看一是正在做 AI 应用、被上下文丢失折磨过的开发者二是想给自己的对话工具加一层长期记忆的产品同学三是对记忆系统这个抽象概念好奇、想知道它落地时长什么样的技术爱好者。我会从记忆的本质讲起一路拆到存储结构、检索策略、写入时机最后给出可以直接抄的实操方案和我在实践中踩过的坑。先说一个反直觉的结论记忆系统的难点从来不是存而是取和忘。存东西谁都会写个文件、塞个数据库就完事了。但当你存了几千条记忆之后怎么在正确的时候取出正确的那几条怎么让过时的信息自动退场这才是决定一个记忆系统好不好用的关键。claude-mem这个名字背后藏着的正是这套取舍逻辑。2. 记忆系统的三层结构短期、长期与工作记忆在动手写代码之前必须先把记忆这个概念拆清楚。很多人一上来就想做一个万能记忆库结果做出来的东西又慢又乱。问题出在没有分层。人的记忆本身就是分层的AI 的记忆系统也应该照这个思路来。2.1 短期记忆当前会话的上下文窗口短期记忆就是当前这次对话的上下文。它存在于模型的上下文窗口里特点是容量有限、访问极快、会话结束即消失。你可以把它理解成电脑的内存——读写飞快但一断电就没了。在claude-mem这类系统里短期记忆的管理核心是上下文预算。假设模型的上下文窗口是 200K token你不能把 200K 全塞满因为还要留空间给模型的回复。我的经验是把短期记忆控制在窗口的 60% 到 70% 比较稳妥剩下的留给系统提示、当前问题和模型输出。短期记忆里应该放什么当前会话的完整对话历史、最近几轮的工具调用结果、以及从长期记忆里检索出来的相关片段。注意最后这一项——长期记忆不是全量加载的而是按需检索后注入短期记忆。这个设计是整个系统的关键后面会详细讲。2.2 长期记忆跨会话持久化的知识长期记忆是跨会话存在的它存在磁盘或数据库里特点是容量几乎无限、访问较慢、需要主动检索。它对应的是电脑的硬盘。长期记忆里存什么我把它分成四类这个分类直接影响后面的存储结构设计记忆类型内容举例更新频率检索优先级事实型记忆用户偏好、项目背景、技术栈低高事件型记忆某次讨论的结论、某个决策中中过程型记忆解决某类问题的步骤低中临时型记忆当前任务的中间状态高低事实型记忆最稳定比如这个用户偏好用 Python、这个项目用的是 PostgreSQL这类信息一旦写入很久都不用改。事件型记忆是某次对话产生的结论比如上周决定把缓存层从 Redis 换成内存缓存。过程型记忆是方法论比如部署这个服务要先跑迁移脚本再重启。临时型记忆生命周期最短任务做完就该清理。2.3 工作记忆正在处理的任务状态工作记忆是介于短期和长期之间的一层它记录的是当前正在进行的任务的状态。比如用户说帮我重构这个模块工作记忆里就会记下任务目标、已完成步骤、待办事项、遇到的阻塞。这一层最容易被忽略但它对多轮复杂任务至关重要。没有工作记忆AI 会在长任务里迷路——做到一半忘了目标是什么或者重复做已经做过的事。工作记忆的实现通常是一个结构化的状态对象随着任务推进不断更新。三层记忆的关系可以这样理解工作记忆是当前在做什么短期记忆是刚才聊了什么长期记忆是一直以来知道什么。三者各司其职缺一不可。3. 存储选型为什么我没有一上来就上向量数据库聊到记忆存储很多人的第一反应是上向量数据库。这个思路没错但如果你一上来就搞一套向量检索很可能会过度工程化。我在实际项目里的做法是分层存储、渐进增强先用简单方案跑通再按需升级。3.1 从纯文本加索引开始最初级的方案是把记忆存成结构化的 JSON 或 Markdown 文件配一个简单的关键词索引。别小看这个方案对于记忆条数在几百到几千级别的场景它完全够用而且有几个巨大优势可读、可调试、可手动编辑。我试过用纯文本方案跑了两个月记忆条数涨到三千多检索延迟依然在可接受范围内。真正让我决定升级的不是性能而是语义检索的需求——用户问上次聊的那个缓存方案关键词匹配找不到因为记忆里写的是内存缓存策略字面对不上但语义相关。3.2 向量检索的引入时机与代价向量检索解决的就是语义匹配问题。把每条记忆转成向量存起来查询时把问题也转成向量算余弦相似度取最相近的几条。听起来很美但代价不小嵌入成本每条记忆都要调用嵌入模型这是真金白银的 API 调用量大时成本可观。维度灾难向量检索对短文本效果一般记忆条目往往很短语义信号弱。更新麻烦记忆内容改了向量要重新算索引要重建。不可解释向量检索出来的结果你很难跟用户解释为什么取出了这条。所以我的建议是混合检索。关键词检索保底向量检索增强。先用关键词快速筛出一批候选再用向量在这批候选里做语义排序。这样既控制了嵌入成本只对候选做向量计算又保留了可解释性。3.3 一个务实的存储结构下面是我实际用的存储结构用 SQLite 加一个向量扩展就能跑起来轻量且够用CREATE TABLE memories ( id INTEGER PRIMARY KEY, content TEXT NOT NULL, memory_type TEXT NOT NULL, -- fact / event / process / temp keywords TEXT, -- 逗号分隔的关键词用于快速筛选 embedding BLOB, -- 向量可选 created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, access_count INTEGER DEFAULT 0, -- 被检索次数用于热度排序 last_access_at INTEGER, -- 最后访问时间用于衰减 importance REAL DEFAULT 0.5, -- 重要度0到1 source_session TEXT -- 来源会话便于溯源 );这个结构里access_count、last_access_at、importance三个字段是精髓它们共同支撑了后面要讲的记忆衰减机制。source_session用于溯源当用户质疑你怎么知道这个时你能告诉他这条记忆来自哪次对话。提示不要一开始就给embedding字段填数据。先让系统跑起来积累真实记忆等语义检索的需求明确出现后再批量补算向量。这样能省下大量无谓的嵌入成本。4. 检索策略在正确的时候取出正确的记忆存储是基础检索才是灵魂。一个记忆系统好不好用90% 取决于检索策略。我见过太多项目记忆存了一大堆但检索时要么取出一堆无关的要么该取的没取到最后用户觉得这 AI 记性还不如没有。4.1 检索触发的时机判断不是每一轮对话都需要检索长期记忆。如果用户只是说你好或者继续你检索一堆记忆塞进去纯属浪费上下文。我的做法是按需触发具体判断逻辑如下用户消息里出现指代词那个、上次、之前说的时触发检索。用户消息涉及具体实体项目名、技术名、人名时触发检索。当前会话已经进行了多轮且话题发生切换时触发检索。用户明确要求回忆你还记得吗时强制检索。反过来纯粹的寒暄、确认、简单指令不触发检索。这个判断可以用一个轻量的小模型来做也可以用规则加关键词匹配后者更快更可控。4.2 多路召回与重排序单一路径的检索很容易漏。我的方案是多路召回关键词召回一路向量召回一路最近访问召回一路然后把三路结果合并去重再做重排序。重排序的打分公式我实际用的是这个加权组合score 0.4 * 语义相似度 0.2 * 关键词匹配度 0.2 * 重要度 0.1 * 时间新鲜度 0.1 * 访问热度这几个权重不是拍脑袋定的是调出来的。语义相似度权重最高因为它最能反映相关性。重要度次之保证关键记忆不会被淹没。时间新鲜度和访问热度权重较低起微调作用。你可以根据自己的场景调整比如做客服场景重要度权重可以调高做闲聊场景新鲜度权重可以调高。4.3 记忆衰减让过时的信息自动退场这是最容易被忽略、但最重要的一环。记忆不是越多越好过时的记忆会污染检索结果。比如用户三个月前说我在用 Vue 2现在早就迁到 Vue 3 了如果这条旧记忆还在检索时被取出来AI 就会给出错误建议。我的做法是给每条记忆算一个衰减分随时间推移和访问减少而降低decay importance * exp(-λ * days_since_last_access) * (1 log(1 access_count))其中 λ 是衰减系数我一般取 0.01 到 0.05 之间。衰减分低于阈值的记忆不参与检索但也不立即删除而是标记为冷记忆。定期跑一个清理任务把长期处于冷记忆状态的条目归档或删除。这里有个坑事实型记忆不能简单按时间衰减。用户的技术栈偏好可能半年不变你不能因为它半年没被访问就把它衰减掉。所以衰减系数要按记忆类型区分事实型 λ 取小值衰减慢临时型 λ 取大值衰减快。4.4 冲突检测与记忆更新当新记忆和旧记忆冲突时怎么办比如旧记忆说用户用 MySQL新记忆说用户迁到了 PostgreSQL。直接都存着检索时会打架。我的处理是写入时做冲突检测新记忆写入前先检索同类型、同主题的已有记忆。如果发现语义冲突把旧记忆标记为已废弃并记录被哪条新记忆取代。检索时只返回未废弃的记忆。这样既保留了历史可溯源又保证了检索结果的时效性。冲突检测可以用简单的规则同主题关键词 语义相似度也可以用模型判断看你对准确率的要求。5. 写入时机什么时候该把对话变成记忆检索讲完了回到写入。写入时机的选择直接决定了记忆库的质量。写太勤噪音多写太懒重要信息丢失。我踩过的坑基本都集中在这一块。5.1 不要每轮对话都写最初我的方案是每轮对话结束就把整段对话存进去。结果一周下来记忆库膨胀到上万条检索出来的全是好的、明白了这种废话。对话不等于记忆记忆是从对话里提炼出来的、值得长期保留的信息。正确的做法是提炼后写入。每轮对话结束后用一个提炼步骤判断这轮对话里有没有值得记住的信息如果有提炼成一条简洁的记忆如果没有直接丢弃。提炼可以用小模型做也可以用规则加模板。5.2 提炼的粒度控制提炼粒度是个技术活。太粗一条记忆里塞了太多信息检索时不好匹配太细一条信息拆成好几条检索时又容易漏。我的经验是一条记忆只表达一个事实或一个结论。比如用户偏好 Python项目用 PostgreSQL部署在容器里应该拆成三条记忆而不是合成一条。这样检索时用户问我用什么数据库能精准命中第二条不会把无关的 Python 偏好也带出来。但也不能拆得太碎。像用户偏好 Python这种就别再拆成用户和偏好 Python了那样反而丢失了语义完整性。判断标准是拆出来的每条记忆单独拿出来读是不是一个完整、可理解的信息单元。5.3 显式记忆与隐式记忆用户明确说记住这个的是显式记忆优先级最高必须写。用户没明说、但从对话里能推断出来的是隐式记忆要谨慎写。隐式记忆的风险在于推断可能出错。用户随口提了一句我最近在看 Rust你推断成用户是 Rust 开发者这就错了。我的做法是隐式记忆写入时标记为待确认重要度给低值等后续对话里再次出现相关信号时再提升为确认状态。注意涉及用户个人偏好、习惯、背景的隐式记忆写入前最好在对话里自然确认一下。比如我记下你偏好用 Python对吧这样既确认了信息又让用户知道系统在记东西体验更好。5.4 写入的批处理与去重高频对话场景下逐条写入数据库会有性能问题。我的做法是批量写入加去重把待写入的记忆先攒在一个队列里每隔一段时间比如 30 秒或攒够一定数量比如 20 条再批量落库。落库前做一次去重语义高度相似的记忆合并只保留信息量最大的那条。去重的阈值要调。太松重复记忆多太严该保留的差异信息被合并掉了。我一般用 0.9 的相似度阈值超过就认为是重复。6. 实操从零搭一个最小可用的记忆系统前面讲的都是原理和策略这一节给一套可以直接跑的方案。我用 Python 写依赖尽量少SQLite 加一个嵌入接口就能跑起来。6.1 环境准备与依赖pip install sqlite-utils numpy嵌入模型我建议先用一个轻量的本地模型避免一开始就依赖外部 API。如果本地跑不动再用 API 方案。核心依赖就这两个别引入一堆框架那样调试起来很痛苦。6.2 核心代码骨架import sqlite3 import json import time import numpy as np class MemoryStore: def __init__(self, db_pathmemory.db): self.conn sqlite3.connect(db_path) self._init_schema() def _init_schema(self): self.conn.execute( CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY, content TEXT NOT NULL, memory_type TEXT NOT NULL, keywords TEXT, embedding BLOB, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, access_count INTEGER DEFAULT 0, last_access_at INTEGER, importance REAL DEFAULT 0.5, status TEXT DEFAULT active ) ) self.conn.commit() def add(self, content, memory_type, keywords, importance0.5): now int(time.time()) self.conn.execute( INSERT INTO memories (content, memory_type, keywords, created_at, updated_at, importance) VALUES (?, ?, ?, ?, ?, ?), (content, memory_type, ,.join(keywords), now, now, importance) ) self.conn.commit() def search(self, query, top_k5): # 关键词召回 cursor self.conn.execute( SELECT * FROM memories WHERE statusactive ) candidates cursor.fetchall() scored [] for row in candidates: score self._score(row, query) scored.append((score, row)) scored.sort(keylambda x: x[0], reverseTrue) return [r for _, r in scored[:top_k]] def _score(self, row, query): # 简化版打分实际项目里替换成完整公式 content row[1] keywords row[3] or importance row[9] last_access row[8] or row[5] days (time.time() - last_access) / 86400 decay np.exp(-0.02 * days) kw_match 1.0 if any(k in query for k in keywords.split(,)) else 0.0 return 0.5 * kw_match 0.3 * importance 0.2 * decay这段代码是骨架_score里的打分逻辑是简化版实际用的时候把前面讲的完整公式填进去。重点是让你看到整个结构存储、检索、打分三个部分清晰分离方便后续替换和优化。6.3 接入对话流程记忆系统要嵌入到对话流程里才有意义。典型的接入点有两个对话开始前和对话结束后。对话开始前拿用户的第一句话去检索记忆把相关记忆注入系统提示。对话结束后把这一轮对话送去提炼提炼出的记忆写入库。中间的多轮对话按需触发检索。def build_context(user_input, memory_store): memories memory_store.search(user_input, top_k5) memory_text \n.join([f- {m[1]} for m in memories]) system_prompt f你是一个有记忆的助手。以下是关于用户的已知信息 {memory_text} 请自然地运用这些信息不要生硬地复述。 return system_prompt注意最后那句不要生硬地复述。这是实战经验如果不加这句模型会把记忆原封不动地念出来用户体验很差。加了之后模型会自然地运用记忆比如用户问我用什么数据库它直接答PostgreSQL而不是根据我的记忆你用的是 PostgreSQL。6.4 验证与调试系统跑起来后怎么验证它工作正常我的做法是准备一组测试用例给系统喂一段对话然后问几个需要记忆才能回答的问题看它答得对不对。调试时最有用的是把检索到的记忆打印出来。很多时候 AI 答错不是模型的问题而是检索取错了记忆。把检索结果和最终回答对照着看问题一目了然。7. 踩过的坑与实战心得这一节是我最想写的部分。前面讲的都是应该怎么做这里讲实际做的时候会怎么翻车。7.1 记忆污染最隐蔽的杀手记忆污染是指错误或过时的记忆被反复检索、反复强化最后变成事实。我遇到过一次用户在某次对话里开玩笑说我是 Java 高手系统当成事实记了下来。之后每次聊技术AI 都默认用户精通 Java给出的建议越来越深用户一脸懵。这个坑的根源是没有区分信息的可信度。玩笑、假设、反问这些都不该被当成事实记忆。我的修复方案是给记忆加一个置信度字段显式陈述置信度高推断和玩笑置信度低检索时低置信度的记忆要打折。7.2 上下文挤占记忆把正事挤没了早期我贪心每次检索都取 top 10 记忆注入上下文。结果有一次用户问一个很简单的问题AI 却答非所问因为上下文里塞满了无关记忆把当前问题淹没了。教训是记忆注入要克制。top_k 不要超过 5而且要做相关性过滤相似度低于阈值的直接不注入。宁可少注入也不要污染上下文。上下文是稀缺资源每一 token 都要花在刀刃上。7.3 冷启动新用户没有记忆怎么办新用户第一次用记忆库是空的检索不到任何东西。这时候如果系统提示里写以下是关于用户的已知信息后面跟一片空白模型会困惑。我的处理是空记忆时用不同的提示模板不显示记忆区块或者显示这是一个新用户你还不了解他可以在对话中自然地了解。这样模型的行为会更自然。7.4 隐私边界什么该记什么不该记这是必须严肃对待的问题。用户对话里可能包含敏感信息不是所有东西都适合长期存储。我的原则是与任务相关的技术信息可以记个人隐私信息不记。具体来说技术栈、项目背景、工作习惯可以记联系方式、住址、财务信息一律不记即使用户主动说了也不记。实现上写入前加一道过滤用规则加模型双重判断。规则负责拦截明显的敏感模式模型负责判断语义上的敏感性。这道过滤宁可误杀不可放过。7.5 记忆的可解释性用户有时候会问你怎么知道这个。如果记忆系统不可解释你没法回答。所以每条记忆都要能溯源到具体的会话。我在存储结构里留了source_session字段就是为了这个。当用户质疑时能告诉他这是你在某次对话里提到的。这个功能看似小众但对建立用户信任极其重要。用户知道系统记得什么、为什么记得才会放心地用。8. 记忆系统的演进方向把最小可用版本跑通之后可以往几个方向演进。这些不是必须做的而是根据实际需求选择性推进。第一个方向是记忆的主动整理。就像人睡觉时大脑会整理记忆一样系统可以定期跑一个整理任务把碎片化的记忆合并成更抽象的结论把矛盾的记忆清理掉。这个任务放在低峰期跑不影响正常使用。第二个方向是记忆的共享与隔离。多个用户之间哪些记忆可以共享比如团队共同的项目背景哪些必须隔离个人偏好需要一套权限机制。这个在多用户场景下会变得很重要。第三个方向是记忆的版本管理。记忆会更新更新历史本身也是有价值的信息。比如用户的技术栈从 Vue 2 迁到了 Vue 3这个迁移过程如果能记录下来对理解用户的技术演进很有帮助。第四个方向是记忆的主动遗忘。除了被动衰减还应该支持用户主动删除某条记忆。用户说忘掉我刚才说的系统要能真的忘掉而不是标记个状态了事。这涉及物理删除和索引重建实现上要小心。我在实际项目里的体会是记忆系统的复杂度要匹配应用场景。个人助手场景几百条记忆加简单检索就够了企业级场景才需要上向量检索、权限管理、版本控制这一整套。别为了技术而技术够用就好。很多团队一上来就搞最复杂的方案结果维护成本高得吓人实际效果还不如简单方案。最后分享一个小技巧给记忆系统加一个记忆面板让用户能看到系统记住了什么并且能手动编辑和删除。这个功能实现起来不难但对用户信任的建立帮助巨大。用户看到系统记得的都是对的才会放心让它继续记。反过来如果用户发现系统记错了却没法改很快就会失去信任再好的检索策略也白搭。