☰
给大模型装上“记忆外挂”:claude-mem构建持久记忆实战
2026/10/12 10:37:56 网站建设 项目流程

你是不是也有这种经历:跟 AI 助手聊了一大通,项目背景、技术选型、踩坑记录全交代清楚了,结果第二天新开一个对话,它又像第一次见面一样两眼一抹黑。我这个项目的起点,就是这段让人抓狂的重复劳动。claude-mem 是我自己动手做的对话记忆增强层,目标很直接——让每次新对话都能继承之前的关键信息,而不是每次都从自我介绍开始。

这篇文章不是什么通用方案,而是我基于 Claude 这类大模型接口,从零搭建一套"记忆外挂"的完整记录。适合正在做 LLM 应用开发、想给聊天机器人加持久记忆、或者单纯被"金鱼脑"式对话折磨过的朋友。里面会讲清楚记忆系统该怎么分层、数据怎么写进去、检索时怎么避免把垃圾也捞出来,以及我实际用下来踩过的几个印象深刻的坑。

1. 从"金鱼脑"到"长效记忆":这个项目诞生的直接原因

1.1 一次对话的"失忆",你和我都经历过

很多人第一次感受到大模型的"失忆"是从一个很小的事情开始的。某次我调试一段脚本,花了一整个下午确认某个配置参数必须用字符串而不是整数,还在对话里明确说了一句"这个坑记一下,以后别再犯了"。当时模型答得挺好,表示理解了。结果第二天新开会话,它又开始推荐整数写法,我差点以为自己在跟一个患了短期失忆症的实习生沟通。

问题的根源在于:大模型在标准 API 调用模式下是无状态的。每次请求发送过去,服务端只基于你这次给的内容做推理,推理完就结束。所谓的上下文窗口本质上是"临时便签本",窗口内的内容用完即丢,一旦会话关闭或者上下文被截断,那些"讨论结论""偏好设定""冲突警告"就全蒸发了。

生活里类比一下:想象你每次见同一个朋友,对方都有兴趣帮你,但不记得上次你们说过什么。你说"还是之前那个问题",它只能回一句"不好意思,什么问题?"这种体验一次两次还能忍,天天如此就非常消耗耐心。

1.2 为什么不能靠堆历史对话解决

最简单粗暴的思路是:既然它不记得,那我每次都把所有历史记录贴进去不就行了?我第一次遇到这个需求时也是这么干的,把整个聊天记录导出,拼接成一大段扔进提示词里。结果三个问题接踵而至。

上下文长度有限。对话一旦变长,总会超出窗口限制,系统只能从中间截断,反而可能把最关键的结论丢掉。就算不超限,塞进去几千行历史后,模型的注意力被大量无关内容稀释,回答质量肉眼可见地下降——它开始纠结于某次闲谈里的无关细节,而不是你当前真正要解决的问题。还有 token 成本,所有输入都要计费,早先几次密集测试下来,光"搬运历史"这件事的消耗就占了总消耗的两三成。

更隐蔽的问题是,对话历史里其实充满了"过程性噪音"。比如我一开始考虑用方案 A,聊着聊着发现不行,改成了方案 B。如果整个历史都贴进去,模型可能还会被方案 A 的讨论影响,时不时冒出"要不要试试 A"这种已经废弃的想法。所以真正的需求不是记住所有,而是记住重要的、忘记该忘的。

1.3 记忆系统要解决的真问题

经过那段时间的折腾,我把需求拆成了三条:

  • 跨会话连续:上上次聊定的结论,下次新开会话能直接引用,不用再说一遍。
  • 语义关联召回:用户后续不会用一模一样的词提问,需要系统能根据语义找到相关记忆。
  • 噪声可控:不能因为想"记得多"就把不相关的内容也带进来,污染当前对话。

这三点单独实现都不难,难的是放进一个系统里同时满足。很多现成方案我试用过,有的只做全量历史堆叠,有的只擅长关键词匹配,有的部署起来太重。说到底,别人的工具是为别人的场景设计的,我自己需要的记忆系统并不复杂,但必须贴着我自己的使用习惯来。于是 claude-mem 就开工了。

2. 记忆层的整体设计:三层存储与数据流动方向

2.1 事实、事件、偏好:三层记忆分类

动手写代码之前,我先花了一点时间想清楚一个问题:什么信息值得记?如果什么都记,那和堆历史没有区别。我把值得长期记住的信息分成了三类,这个分类在后面实现检索时帮了大忙。

第一类是事实型记忆。比如"项目使用 Python 3.11""后端框架是 FastAPI""部署在私有服务器"。这些信息是稳定的、明确表述过的,用结构化存储最合适,更新时直接覆盖旧值。

第二类是事件型记忆。比如"上个月排查出一个超时问题,根因是数据库连接池过小,调大之后恢复"。这类信息的特点是过程性、有因果,适合用一句话摘要保存。后面检索时主要靠语义匹配来找它们。

第三类是偏好型记忆。比如"用户喜欢简洁的代码风格""回答时要先给结论再解释细节"。这类信息往往不是用户直接说出来的,而是从多次交互里推断出来的。能推断,但要小心,不能乱猜。

三种记忆在存储和处理方式上天然不同。我最早把它们混在一起存,后来发现在同一张表里又做关键词匹配又做向量检索,逻辑非常混乱,拆开之后清爽得多。

2.2 存储选型:结构化数据库与向量索引的分工

存储选型是另一个需要解释清楚的决策点。事实型信息我用SQLite,原因很朴素:个人使用场景,单机跑完全够,零部署成本,一个文件就能带走全部数据。我见过有人在这种规模上直接上重型关系数据库,属实没必要。

事件型和偏好型信息则走了向量检索路线。为什么不能只靠关键词匹配?举个例子:库里记着"之前确认过采用离线优先架构",用户某天随口问"那网络不好的时候怎么办"。这两句话字面上几乎没有一个词重合,但语义高度相关。关键词搜索完全抓瞎,向量检索却能把这条老记忆捞出来。这个需求决定了必须引入 embedding 化文本。

向量库的选择上,我一开始考虑过部署一个独立服务,但是个人项目图省事,最后还是选了轻量嵌入式方案,本地加载模型做向量化,不依赖外部接口。这样即使网络环境不好,记忆检索也照常工作。检索延迟方面,本地向量化一次大约几十毫秒,完全在可接受范围内。

2.3 数据流设计:从对话到记忆再到提示词

整个系统的数据流,我用文字描述一下,方便有个整体感:

用户对话流 -> 记忆提取器(异步触发) ├── 事实提取 -> SQLite(结构化覆盖/更新) └── 事件摘要 -> 向量库(追加写入) 新会话/新问题 -> 候选记忆检索 ├── 向量相似度 ├── 时间衰减 └── 冲突消解 -> 注入系统提示词中的"历史记忆"区块 -> 模型正常回答

这个设计里有一个关键原则:记忆系统是助手手里的笔记本,不是大脑本身。它不应该替代模型的推理能力,只是给模型提供参考。所以我在提示词里特意把记忆区块设计成"仅供参考的上下文",而不是"必须遵守的指令"。这个细节一开始没注意,后面踩了好几个坑,会在第 4 部分详细说。

3. 核心链路落地:写入、检索、注入的完整实现

3.1 记忆提取:什么时候触发、用什么逻辑抽

记忆写入的第一步是"提取"。如果对每个用户的每条消息都做一次提取,成本会高到不可接受。我测试了几轮之后就放弃了这种暴力方案。

最终我设定了三个触发条件,只要命中其一才执行提取:

  • 用户明显切换了话题。可以通过对话片段的嵌入向量突变来检测,简单点也可以看用户是否开始问一个与前面完全无关的新问题。
  • 出现了明确的结论性表述。像"决定了""就用""不要再考虑""记住"这类词,说明这是一个值得落库的信息锚点。
  • 会话自然结束或到达一个较长的停顿。比如用户一段时间没有新消息,系统就把刚才这一段对话做一次批量提取。

提取本身由模型来完成。我用了一个专门的提示词模板,让模型从对话片段中抽取值得长期记住的条目:

MEMORY_EXTRACT_PROMPT = """从下面的对话片段中,提取值得长期记住的信息。 只输出条目,每行一条,不要解释。 要求: 1. 只提取对后续对话有价值的信息,过滤寒暄和过程性废话 2. 事实用陈述句,事件用一句话概括结果和关键原因 3. 不确定的信息标注为“可能”,不要强行下结论 对话片段: {segment} """

为什么用模型而不是正则规则?早期版本我试过纯规则提取,靠关键词命中句子,结果碰到"不要选方案 B""暂时不考虑用 X"这种否定信息就完全抓瞎。换成模型抽取之后准确率明显提升,代价是每次提取要额外发一次子请求。所以控制触发频率就成了降本的关键,只在真正有价值的时间点执行。

3.2 事实层的 Schema 与写入逻辑

提取出来的信息会写入存储。事实层的 SQLite 表结构如下:

CREATE TABLE facts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_key TEXT NOT NULL, content TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'fact', source_session TEXT, confidence REAL DEFAULT 1.0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), last_access_at TEXT, access_count INTEGER DEFAULT 0 ); CREATE INDEX idx_facts_user ON facts(user_key);

这个 Schema 里有两个字段后来被证明非常关键。一个是user_key,用来区分不同用户或不同项目主题的隔离;另一个是updated_at,用于处理信息更新和时间衰减。这两个都是使用过程中踩坑之后补的,不是一开始就想到的,后面会展开讲。

写入逻辑里我也做了一个轻量级的去重与更新合并。思路是:如果同一用户下已经存在高度相似的记录,就不新增条目,而是更新它的updated_at和confidence,避免同一事实被反复记录成多条。这里用的是前缀匹配做粗筛,再结合模型判断做精过滤。

3.3 检索层的向量召回与阈值

检索层我封装了一个MemoryStore类,底层是嵌入向量库。写入时同时保留 SQLite 里的id,这样检索到向量之后可以回查原文和元数据。

class MemoryStore: def __init__(self, embed_fn, top_k=5, min_score=0.45): self.embed_fn = embed_fn self.top_k = top_k self.min_score = min_score self.index = [] def add(self, text: str, metadata: dict): vec = self.embed_fn(text) self.index.append({"text": text, "vec": vec, "meta": metadata}) def search(self, query: str): qvec = self.embed_fn(query) scored = [] for item in self.index: score = cosine_similarity(qvec, item["vec"]) scored.append((score, item)) scored.sort(reverse=True) return [(s, item) for s, item in scored[:self.top_k] if s >= self.min_score]

这个类里那个min_score参数是踩坑后才加的,最开始根本没有。没有阈值的结果就是:什么阿猫阿狗的记忆都被召回了,上下文被塞进一堆不相关的东西,回答质量反而断崖式下降。具体踩坑过程会在第 4 部分展开。

本地 embedding 模型的选择也有讲究。我用了一个中等尺寸的文本向量模型,速度尚可,语义匹配精度对"记忆召回"这个任务足够用。如果完全依赖云端 embedding 接口,每次检索都有网络开销,交互体验会差不少,而且离线环境下整个系统就瘫痪了。

3.4 注入格式:如何让模型正确使用记忆

检索完成后,把记忆注入提示词。这一步的格式设计决定了模型是真正把记忆当参考,还是被记忆带偏。

def build_system_prompt(user_key, current_query, memories): memory_block = "\n".join(f"- {m['content']}" for m in memories) return f"""你是助手。下面提供一些关于当前用户的“历史记忆”,全部来自用户过去的对话,仅供参考。 如果与当前对话冲突,以当前对话为准。 <history_memory> {memory_block} </history_memory> 现在请回答用户的问题。"""

这个提示词有两个刻意设计,值得说一下。

第一个是"仅供参考"这个措辞。早期版本我写的是"用户要求如下",结果模型把历史记忆当成了硬性指令,遇到接口已经升级的情况还死抱着旧参数不放。改成"仅供参考"之后,模型明显更懂得灵活处理。

第二个是<history_memory>标签。把记忆区块用特定的标签包裹起来,模型就能清晰地区分"哪些是历史事实参考"和"哪些是当前指令"。这个技巧看似微小,但对稳定性的提升是肉眼可见的。

3.5 冲突消解的保守与激进

当新旧记忆内容矛盾时怎么处理?比如用户三个月前说"数据库用 PostgreSQL",前几天说"换 MySQL 了"。我的策略介于保守和激进之间。

轻量做法是:按更新时间排序,新的优先。实现上就是按记忆条目的updated_at字段比较,保留较新的版本,删除或忽略旧的。对绝大多数事实型记忆,这个策略够用了。

真正的语义级冲突检测(比如"项目采用离线优先架构"和"项目放弃离线优先、全面转在线"这种需要理解语义才能发现的对立)则要复杂得多。我试过用模型来逐对判断,效果确实好,但成本太高,无法在每次检索线上都跑。目前的做法是:只在写入时做一次粗粒度的相似检查,检索出来之后按时间裁剪。这个方案在个人场景下已经足够稳,如果要处理高并发多用户场景,肯定还需要更完备的方案,目前不在我考虑范围内。

4. 真实使用中的翻车现场与针对性修复

4.1 检索噪声污染上下文的修复

第一次把完整链路跑通的时候,我满怀期待地上线了。结果实测下来,加了记忆之后回答质量还不如不加。排查了很久才发现问题出在检索阈值上。

有个典型例子:用户问"今天有什么待处理事项",系统把两个月前一次闲聊里提到的某首歌名也召回了,模型居然还顺着这首歌展开了几句,用户直接懵了。根因就是min_score不存在,系统把"语义沾边但完全不重要"的内容也当成有价值的记忆。

修复策略是组合拳:

  • min_score调到 0.45,具体数值跟 embedding 模型有关,需要自己校准,调太低会带进噪声,调太高会导致该召回的召回不到。
  • 记忆大类分开处理:事实型走关键词/结构化匹配,事件型才走向量检索,避免向量检索把不相关事实也带进来。
  • 注入前做一次排序截断,最多取 5 条,多余的宁可不给。

这套调整下来,回答质量才真正超过了"不加记忆"的基线。

4.2 旧接口信息被反复引用的坑

第二个坑跟时效性有关。某次用户改了某个配置接口的参数名,老版本已经废弃了。但记忆库里还留着旧接口的写法,结果之后每次询问,模型都按旧格式回答,折腾了很长时间才发现是记忆在捣鬼。

根因是事实型记忆没有"时效性"概念。一条三个月前的接口信息,和一条今天的变更记录,在检索里权重是一样的。修复方案分两部分。

一是给事实记录加updated_at字段,注入时按时间加权,越新的记忆得分越高。二是对接口、版本号、配置项这类高时效信息,单独建立一个"当前值"索引,命中后直接用新值覆盖旧值。

我还总结了一条经验规则:凡是对话中出现了"修改""改用""换成""不要再"这类强硬变更词,就强制提高该条记忆的权重,并标记为对旧信息的覆盖。这个规则虽然简单,但处理配置类变更特别有效。

4.3 token 成本翻倍与降耗方案

最早实现是每条用户消息后都触发一次记忆提取。结果每个交互后面都跟着一次子请求,不仅延迟大,token 消耗直接翻倍。尤其是密集测试阶段,账面数字非常难看。

后来改成三个触发点(话题切换、结论性信号、会话暂停)之后,提取频率降了一大截。具体幅度跟对话类型有关,技术讨论类的对话提取频率高一些,闲聊类的频次就很低。

检索侧也有一个隐蔽的消耗:每次请求都先做一次 embedding 查询。单次便宜,但高频交互下积累起来也不少。我给检索加了一层"会话内缓存"——同一个会话内,前一次已经注入过的记忆,只要当前查询语义没有显著变化,就不重复检索。大概缓存命中率能有 60%,整体 token 消耗降下来三分之一。

4.4 多会话互相串台的隔离设计

这个坑印象最深。当时我同时开着两个会话,一个聊个人博客改造,一个聊工作项目重构,两个都在往同一个记忆库写入。结果在个人博客会话里问项目思路时,系统把工作项目里的技术栈也带了出来,上下文变成了四不像。

修复就是前面提过的user_key隔离。更进一步,我用"会话主题标签"做了二级分区:

def topic_key(user_key, topic): return f"{user_key}::{topic}"

检索时优先查当前主题分区,没命中再放宽到用户全量记忆。这个策略既保证了跨会话的连续性,又不至于让不同项目互相干扰。相当于同一个人的记忆库里又按"笔记本分册"管理,检索时先翻对应分册,找不到再翻全部。

4.5 记忆引起的人格漂移

还有一次比较"乌龙"的翻车。历史记忆里有一条旧对话中用户随口说的"我喜欢极简风格",注入后模型回答突然变得极端简洁,什么都不解释。实际上用户那次语境下明显需要详细解释,这条旧记忆反而帮了倒忙。

这说明偏好型记忆的注入需要格外谨慎。修复方法有两个关键点:

  • 在注入格式里明确"历史记忆仅供参考,当前对话的指令优先",防止偏好型记忆喧宾夺主。
  • 只注入与当前问题语义相关的偏好记忆,而不是把用户所有历史偏好一股脑全塞进去。比如当前在讨论性能优化,就别把"喜欢极简风格"这种无关偏好调出来。

这条经验的本质是:记忆系统的价值密度比记忆总量更重要。宁可少给,不能给错。

5. 更进一步的优化:过载、衰减与可解释性

5.1 记忆过载的清理策略

用了一段时间,库里的条目慢慢积累到几百条。检索性能倒没成为瓶颈,但出现了两个新问题。

第一个是语义重叠。同一件事被不同会话记录了好几次,表述不同但意思一样,检索时全部命中,白白占用上下文。第二个是过时信息残留,早期的一些试验性结论已经没有参考价值,却在检索里频繁出现。

我的做法是加一条离线清理任务:定期跑一遍,按last_access_at和access_count排序,把长期没被命中的低价值条目标记为"休眠",再跑一轮模型判断确认后删除。这个过程就像定期整理笔记本,防止记录变成一堆堆灰的杂物。毕竟记忆系统的价值不在数量,而在"调出来的时候真的有用"。

5.2 时间衰减与得分合成

检索的最终排序得分由三部分合成:

score = semantic_score * 0.7 + recency_score * 0.2 + access_count_score * 0.1

semantic_score是向量相似度。recency_score用指数衰减计算:exp(-age_days / half_life_days)。access_count_score则根据条目被成功引用的次数归一化,等于是"用进废退"机制——经常被用到的记忆权重更高。

半衰期的取值需要根据记忆类型区分。接口、版本号这类高时效信息,半衰期我设成 7 天,两周没提到基本就该淡出视野了;用户偏好、项目背景这类稳定信息,半衰期设成 90 天,就算很久没提也不能轻易丢掉。这个参数直接决定了"多久没提过的事就该被淡忘",我是用两组不同的对话测试标定出来的,不建议照抄,最好自己跑几轮对话观察一下。

5.3 记忆可视化与调试定位

记忆系统的另一个痛点是黑盒。模型回答引用了某条记忆,但你不知道它引用了哪条、为什么引用。为了定位问题,我在注入格式里给每条记忆加了编号:

<history_memory> [1] 项目使用Python 3.11(更新时间:2025-05-02) [2] 数据库连接池曾因过小造成超时,已调大(更新时间:2025-04-18) </history_memory>

然后提示模型:在回答中如果使用了历史记忆,用括号标出编号。这样如果模型答错了,我能直接看到是哪条记忆带偏的,而不需要靠猜。这是一个典型的"调试友好"设计,在记忆系统这种天然隐蔽的模块里尤其重要。

5.4 后续想做的事

目前 claude-mem 已经稳定跑了一段时间,满足了我的日常使用,但还有几个方向想继续做。

  • 跨会话摘要升级:当某个主题下记忆条目超过一定数量时,自动生成一篇"主题页",把零散记忆浓缩成结构化摘要,减少检索时的重复命中。
  • 主动遗忘:不只是时间衰减,还要支持用户显式反馈"这条记错了/删掉",让记忆系统可纠错。
  • 多后端适配:当前实现是面向某一类模型协议写的,后面想抽出一层接口,方便接不同的模型服务,同时不影响记忆层。

这些都是锦上添花,核心的记忆链路已经足够稳固。对我个人而言,真正改变使用体验的是"它记得住我是谁、我做过什么决定"这件事本身。

我在实现 claude-mem 前后的最大感受是:真正让对话工具好用的,往往不是模型本身有多强,而是它能不能记住你是什么样的人、做过什么决定。如果你也被大模型的"金鱼记忆"折磨过,不妨从最小的版本做起——一个 SQLite 表、一个向量索引、一段注入逻辑,加起来不过几百行代码,但体验的提升立竿见影。记忆系统的精髓不在算法多花哨,而在于对"什么该记、什么该忘、什么时候该引用"的判断。这组判断,才是 claude-mem 这个项目里最值钱的部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询