☰
会话AI长期记忆实现:claude-mem外挂记忆架构与工程实践
2026/10/12 6:31:00 网站建设 项目流程

我自己一直在折腾大模型对话类的应用,前阵子搞了一个叫claude-mem的小项目,说白了就是给“会话型 AI”加长期记忆的插件。现在大部分模型 API 虽然在单次会话里能记住几万字的上下文,但只要你把窗口关掉或者新建会话,它立刻把之前聊过的东西忘得干干净净。做实际产品的时候,这个痛点极其致命——用户昨天刚交代过的偏好,今天一问就“失忆”了,体验非常割裂。claude-mem的思路就是用“外部持久化记忆 + 动态注入上下文”的方式,让模型跨会话记住用户偏好、历史关键事实和长期任务的进度。

这个项目我自己测了好几个版本,从最初挂在主流程里的一个 Python 脚本,慢慢改成带本地索引、自动摘要、过期清理的独立服务。这篇文章把我的设计思路、核心实现和踩过的坑完整梳理一遍。适合正在做私人助理类应用、客服机器人、知识库问答系统的开发者参考,尤其是那些被“模型记忆只有一扇窗”逼到想骂人的朋友。我会尽量把关键代码和取舍理由都讲清楚,方便你直接拿去改。

1. 项目整体设计与思路拆解

1.1 为什么不能直接把全部历史塞进 Prompt

业界最常见的“伪记忆”方案,就是把历史会话记录原封不动拼进 system prompt。这在早期确实糊弄过不少人,但用一阵就发现两个硬伤:第一是token 开销飞速膨胀,聊个二十轮可能就两千多字,再往后每次请求都背着越来越重的历史包袱;第二是无效信息噪音太大,寒暄、临时话题、重复确认这些话占了绝大部分,真正对后续对话有价值的可能就那么几句。模型被无效上下文干扰后,反而更容易答偏。

这也是claude-mem一开始就放弃“全量注入”路线的原因。真正的长期记忆应该做到三件事:只留关键事实、能按需检索、主动做时间衰减。不是把流水账背下来,而是像人一样,记住“用户养了一只叫年糕的猫”,忘掉“用户昨天下午三点说了一句好的”。

1.2 外挂记忆模块的典型架构

这个项目的整体结构并不复杂,本质上是在模型调用层前面加了一层“记忆代理”。我用最朴素的方式组织:

  • 记忆写入层:实时从每轮对话中抽取出“值得记下来的信息”;
  • 存储层:本地 SQLite 数据库,按 session、实体、摘要三种粒度存;
  • 检索层:根据当前对话内容,用关键词+向量混合打分召回相关记忆;
  • 注入层:把召回结果压缩成不超过一定字数的记忆片段,追加到 prompt 前部;
  • 维护层:定期对记忆做摘要合并、过期清理和去重。

从部署形态上看,它就是一个 HTTP 服务,模型服务商的那套 SDK 照常用,只不过每次发请求前先从记忆服务拉一次相关记忆。我特意没有做成需要改模型调用底层的深度集成,因为不同框架的调用链差异太大。做成独立服务的好处是,以后换模型厂商、换前端框架,记忆这块完全不用动。

1.3 设计取舍:轻量可自部署优先

做这个项目之前我考虑过直接用别人做好的现成方案,但调研了一圈发现要么太重(要上向量数据库集群),要么太“黑盒”,你想调一下抽取规则只能改源码。claude-mem的理念是能跑在树莓派上的记忆工具,所以存储直接用 SQLite,向量召回用本地的 TF-IDF 风格权重算法兜底,后续再决定要不要挂外部向量库。这样一个单文件服务就能搞定全部功能,部署成本极低。

这种取舍有一个很实际的收益:用户可以完全掌控自己的对话记忆数据,所有记录都以明文 SQLite 文件存放在本地机器上,不依赖任何云端同步。这对那些注重隐私、不希望对话内容经过额外第三方服务的场景特别友好。代价则是语义检索的聪明程度比接一个成熟向量库要弱一些,但通过混合打分和同义词扩展,日常够用。

2. 核心数据模型与记忆抽取策略

2.1 三类记忆粒度:核心记忆、会话摘要、实体画像

设计存储结构时,我把记忆分成了三层,每一层的生命周期和写入策略都不一样。最初我是把所有信息一股脑塞进一张表,结果“三小时前聊的电影”和“用户对辣椒过敏”混在一起,检索权重一塌糊涂。后来拆成了三张核心表,问题立刻清晰很多。

  • 实体记忆(entities):用户明确表达或上下文里反复出现的稳定事实,比如“用户是产品经理”“用户喜欢深色主题”“用户的项目的名字叫某内部系统”。这类记忆半衰期最长,通常不会被自动清除。
  • 会话摘要(summaries):每一次完整会话结束后,用抽取模型生成一小段结构化摘要,记录“这次对话处理了什么任务、得出什么结论、遗留什么问题”。会话摘要按时间递减权重,但不过期,用于横向定位历史上下文。
  • 临时事件(events):粒度最小、时效性最强的信息,比如“用户今天提到下周要出差”。这类记录默认 72 小时后就进入待压缩状态。

前端进行检索时,三类记忆分别打分并汇总。这样用户的长期画像不会被临时事件冲淡,而近期任务类信息也不会被埋进陈年旧事里。这个分层结构我调了很久才稳定下来,比较适合做成通用模板。

2.2 关键表结构与字段设计

SQLite 的表结构我尽量精简,但有几个字段是必须认真设计的。直接看建表语句:

CREATE TABLE IF NOT EXISTS entities ( id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT NOT NULL, -- person / preference / project / other content TEXT NOT NULL, -- 记忆内容,如 "用户对花生过敏" confidence REAL DEFAULT 0.8, -- 抽取置信度 source_session TEXT, -- 来源会话 ID first_seen_at TEXT DEFAULT (datetime('now')), last_seen_at TEXT DEFAULT (datetime('now')), seen_count INTEGER DEFAULT 1, weight REAL DEFAULT 1.0 ); CREATE TABLE IF NOT EXISTS summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, summary_text TEXT NOT NULL, task_status TEXT DEFAULT 'open', -- open / done / blocked created_at TEXT DEFAULT (datetime('now')), access_count INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, occurred_at TEXT NOT NULL, expire_at TEXT NOT NULL, processed INTEGER DEFAULT 0 );

entities表里的confidence和weight非常关键,它们直接参与检索排序。confidence表示“我们对这条记忆的判断有多确定”,如果用户在对话里明确纠正过这条信息,我会把 confidence 降到 0.3 以下,甚至直接标记删除。weight则表示“这条记忆当前对全局对话的重要性”,比如用户反复提到某个项目细节,这个项目的相关实体 weight 就会上升。

有些朋友可能觉得用 SQLite 存这种数据不够“高级”,但我实测下来,单机几万条记忆的查询速度完全无感。而且 SQLite 的备份、迁移、导出都极方便,一个文件拷走就是全量备份。

2.3 记忆抽取:从对话流里捞出“值得记的东西”

模型的输出质量很大程度上取决于记忆抽取的质量。claude-mem的抽取环节采用“规则 + 轻量模型判断”的两段式设计。

第一段用规则做粗筛,包括:包含明确偏好词(“我喜欢”“我不喜欢”“我习惯”“千万别”);包含人称指代后跟稳定属性(“我是”“我在”“我有”);对话中出现两次以上的专有名词(抓取中文人名、英文人名、项目代号、产品名)。粗筛命中的句子进入候选列表。

第二段把候选句子拼接成一批,请求一次模型接口做结构化抽取,返回 JSON:

[ { "entity_type": "preference", "content": "用户不喜欢在方案里使用红色作为主色调", "confidence": 0.9, "expire_in_hours": null }, { "entity_type": "event", "content": "用户计划下周三去北京出差", "confidence": 0.85, "expire_in_hours": 168 } ]

因为不是每轮对话都需要实时抽取(成本和延迟顶不住),我在项目里加了一个“延迟抽取”队列:当前轮对话结束、用户超过 40 秒没有输入时,后台把这个 session 最近几轮的文本拿去批量抽取。这样体验上几乎无感,又能保证记忆及时落库。实测中,这一招比每轮同步抽取少消耗大约 70% 的模型调用量。

2.4 记忆修正与用户反馈闭环

光有写入没人纠错,记忆库很容易堆积垃圾信息。我专门设计了“记忆反馈”接口:模型在对话中发现之前提取的信息与用户当前表述矛盾时,会调用工具接口去做更新。比如用户两轮前说“我下周在上海”,后来又纠正“改去杭州了”,此时如果系统把“上海”当成硬记忆注入,就会导致回答穿越。

矛盾处理策略很简单:不覆盖,而是新增一条新记忆,然后给旧记忆打上superseded标记,同时把两边的 confidence 互换。查询时只召回最新状态,但历史信息仍保留在库里,方便追溯。这个设计起初只是我拍脑袋想的,没想到后来在复盘对话质量时特别有用——它能告诉你模型是不是在频繁“误解”用户的更新意图。

3. 实操过程与核心环节实现

3.1 项目模块结构与运行流程

我把服务拆成五个模块,每个模块只负责一件事,调试起来非常省脑子:

  • mem_capture.py:对话采集与抽取调度;
  • mem_index.py:负责实体、摘要、事件的写入和更新;
  • mem_search.py:混合检索与打分排序;
  • mem_inject.py:将检索结果格式化为可注入的上下文;
  • mem_main.py:HTTP 服务入口,绑定本地端口。

运行时主流程大概是:

  1. 用户发消息,后端对话服务先把历史请求发送给记忆服务,带上当前用户 ID;
  2. 记忆服务执行检索,将相关记忆拼成一段不超过 800 字的memory_context返回;
  3. 对话服务把memory_context拼到 prompt 的 system 部分,再正常调用模型接口;
  4. 模型返回后,对话服务把“用户消息 + 模型回复”异步回传记忆服务;
  5. 记忆服务把文本放入抽取队列,在空闲时完成写入和更新。

整个流程里,对话服务和记忆服务只通过标准 HTTP 的 JSON 通信,不共享任何内部状态。我甚至可以在聊天过程中随时重启记忆服务,对话那边最多感知到一个短暂的检索超时,不影响主流程。

3.2 检索打分:关键词加向量怎么融合

mem_search是这个项目里我调试最久的部分。因为本地不想引入太大的模型,我选择用一个轻量的 TF-IDF 风格关键词打分再加可选向量相似度的混合方案。

关键词打分部分的核心,是把当前用户输入拆分成词项,然后逐条扫描记忆表,算重叠词覆盖率、词频权重和时效衰减。逻辑上很像搜索引擎的 BM25,但我做了一些简化:

def keyword_score(query_terms, memory_text, last_seen_at): overlap = len(set(query_terms) & set(memory_terms)) if overlap == 0: return 0.0 coverage = overlap / max(len(query_terms), 1) recency = max(0.0, 1.0 - (now - last_seen_at).days / 30.0) return coverage * 0.7 + recency * 0.3

向量相似度部分我保留成可插拔接口:默认直接跳过。如果你本地已经跑了一个 embedding 服务(比如用轻量模型把文本转成向量),只需要在配置里填一个 endpoint,mem_search就会自动把查出来的几条候选结果再做一轮语义排序。这样就算不提向量库,纯靠关键词也能有个七八十分的水平;接上向量后,长尾的语义匹配,比如用户说“上次聊的那个很贵的东西”,也能隐约摸到相关记忆。

3.3 注入模板:让模型读懂记忆上下文

记忆不是丢给模型就能生效的,注入格式非常考验 prompt 功底。我踩过一次很蠢的坑:刚开始直接把记忆列表逐条拼进去,结果模型把记忆当成了对话历史的一部分,开始回答“好的,我记得您喜欢深色主题”,显得又假又聒噪。

后来我把注入模板改成了下面这种“事实清单”风格,效果立刻正常了:

memory_block = """ ## 关于用户的已知记忆 请将以下事实作为背景,不要主动提及“我记得”之类的表述,需要时自然引用: - [偏好] 用户偏好深色界面,尤其是深蓝和黑灰配色 - [事实] 用户目前在做一个跨平台通知工具的开发,项目代号 M - [近期] 用户上周提到希望把通知推送延迟控制在 5 秒内 """

这个模板有两个核心设计:一是明确告诉模型“不要主动提及记忆”,二是用“自然引用”替代“背诵式回应”。这样用户感知到的是“AI 居然知道我喜欢深色”,而不是“AI 在秀它的记忆插件”。对于客服类场景,还可以调整语气为“根据您的历史需求”这类偏正式表述,但克制原则不变。

3.4 会话摘要自动生成机制

每次对话结束,后台会触发一轮摘要生成。摘要不是简单把对话压缩,而是按“任务型会话”的思路提取四要素:目标、进展、结论、遗留项。我用一条精心设计的 prompt 做抽取,抽取结果直接写进summaries表。

{ "goal": "用户希望搭建个人知识库搜索工具", "progress": "已完成数据采集模块,向量索引方案选了本地轻量方案", "conclusion": "先用手动打标签方式做 MVP,再决定是否引入重模型", "follow_up": ["需要对比至少两个候选 embedding 模型的精度"] }

摘要的意义不光是上下文记忆,还承担着“跨会话任务延续”的作用。比如用户第二天回来说“继续搞那个搜索工具”,模型检索到这条摘要后可以接着进度往下聊,而不需要用户重新描述一遍需求。这一点做私人助理类应用时收益极高。

3.5 历史记忆清理与过期策略

记忆如果没有清理机制,迟早会被垃圾塞满。我设计了三级清理:

  • 事件级:临时事件超过expire_at后进入待清理队列,若该事件在后续会话中被重新提及,则自动续期;
  • 摘要级:超过 60 天未访问的会话摘要,每次检索时权重降为原来的 80%,降到阈值以下就只保留外部索引不进注入 Context;
  • 实体级:实体绝不自动删除,但如果连续 60 天未被触发且 confidence 低于 0.6,会被标记为“休眠记忆”,默认不参与召回,除非用户主动搜索。

我在实践里体会到,记忆清理的本质是控制注入噪声。模型能力再强,给它灌一堆过期的旧信息也容易带偏。把“休眠”但保留在库里的设计,比直接物理删除更稳——用户某天突然问起一年前聊过的东西,还能靠主动搜索找回来。

4. 常见问题与排查技巧实录

4.1 高频故障速查表

这个项目在实际使用中遇到的最典型问题,我整理了一张速查表,方便你对照排查:

症状可能原因解决方案
记忆完全没有注入检索结果为空或注入模板被禁用检查mem_search日志的关键词命中数,调低召回阈值
模型总是主动提起记忆注入模板中的引导词不对把“不要主动提及”改成“仅作为背景事实使用”
记忆库膨胀很快抽取规则太敏感,无关句子都入库提高规则粗筛门槛,或调整置信度阈值到 0.85 以上
旧记忆覆盖了新记忆忘记打superseded标记检查写入逻辑,确认新事实写入时是否执行了旧记忆标记
多用户数据互相串检索时没有按用户 ID 过滤给每个用户单独建一个 SQLite 文件或加统一的 user_id 条件
抽取任务延迟太久队列积压,空闲窗口不足把批量抽取的任务放到独立线程,设置最长等待时间

最坑的一种情况是记忆服务本身正常,但对话服务在拼接 prompt 时把注入块的位置放错,导致模型把记忆当成了系统指令。我建议把注入块放在 system 角色内容的中间位置,前面保留原始系统设定,后面留一小段“结合上文已知事实做出回复”的引导,整体效果最好。

4.2 模型回复质量反而不升反降怎么办

有些朋友照着示例接了记忆服务,发现加了记忆后模型回答反而变空泛、变啰嗦。我排查过几次,原因出在“记忆条目过于碎片化”上。比如一条记了“用户喜欢蓝色”,又一条记了“用户喜欢深色”,还有一条“用户不喜欢花哨的配色”,三条拼在一起以后,模型反而不知道听哪条。

解决办法是引入“记忆合并”机制:同实体、同属性的记忆做相似度检测,相似度超过阈值就自动合并成一条综合表述。合并不是字符串拼接,而是做一次精简改写:

merged_content = "用户偏爱深色系配色(深蓝、黑灰),不太接受高饱和度的花哨配色"

同时把原来旧条目的weight减半,确保新合并条目在检索排序中优先冒出。这一招对偏好类记忆特别有效,合并后回复的确定性和一致性明显提升。这个维护动作我起初是每周手动跑一次脚本,后来写成了定时任务,每天凌晨执行。

4.3 检索召回率不足的调试技巧

如果你发现很多该想起的记忆其实没被召回,不要急着上向量库。先用几条典型的用户输入跑一遍mem_search的 debug 模式,看关键词命中情况。大部分场景下问题出在“抽取时存进来的词,和用户查询时用的词,根本不是同一套表达”。我在项目中做了一层极轻量的同义词扩展,只维护了一个小词典,比如把“喜欢”“偏爱”“偏好”“中意”映射到同一组词根。这个词典只有几十条,但覆盖了高频表达,效果比想象中的好很多。

更进一步,可以对记忆内容做分词并存储关键词倒排表。没必要追求大而全的分词组件,直接对中文按常用词表滑动匹配即可。倒排表顺手解决了模糊查询慢的问题,现在几万条记忆在本地检索耗时能压到 50 毫秒以内,对对话场景来说完全是可接受的。

4.4 本地存储进程崩溃的数据恢复

SQLite 虽然稳,但服务进程如果被强杀,偶尔会遇到database is locked或者 WAL 文件异常。我在项目中用了 WAL 模式,并且在每次写库后不立即关闭连接,而是用一个常驻连接池保证写入串行化,大幅度降低锁冲突。

如果真碰上数据库损坏,SQLite 自带的.recover命令能救回绝大多数数据。另外我做了每日凌晨的全量备份,直接复制数据库文件到backup/目录,保留最近七天版本。一个小细节:复制前要执行一次PRAGMA wal_checkpoint(TRUNCATE);,不然 WAL 文件里还有未合并的数据,冷拷贝可能会丢尾巴。

5. 如果再往下做,这几点强烈建议你先搞定

5.1 记忆可视化调试后台

写代码的人可能觉得命令行日志够了,但实际用起来,记忆数据还是“看得见”比较踏实。我后来给claude-mem加了一个极简的本地 Web 页面,就是列出当前库里的全部记忆条目、来源会话ID、最近触发时间和权重,支持搜索和手动删除。这个后台帮了大忙——用户反馈“AI 怎么老说我是做金融的”,我打开后台一看,原来是一条三个月前的错误记忆排在最前面。当场能改,不用去翻代码。

5.2 接入带时间感知的自动任务

记忆服务里保留的“事件”数据,天然适合做提醒类功能。比如用户提到“下周出差”,事件表里已经有时间字段,那就可以加一个定时任务,在出差前一天触发一条推送消息。这块还不需要动模型,纯粹是程序逻辑。很多人问记忆工具除了喂 prompt 还有什么用,实际这就是最有用的延伸:把对话里提到的待办事项和时间点结构化出来,本身就是一个轻量级的日程系统。

5.3 迁移到共享记忆层

如果你同时接入了多个模型服务(比如代码补全、聊天、语音助手都调不同的模型),可以考虑把记忆服务独立成团队共享层。这样用户在 A 场景中告诉 AI 的偏好,在 B 场景里也能生效。实现上只需要把 SQLite 换成 PostgreSQL,把检索接口改成 REST 并加上鉴权,整体架构不用大改。我这个项目最初就是个人自用,后来发现这个思路完全可以作为团队内部的一个基础服务来维护。

关于 claude-mem 项目的一些个人体会

这阵子把这个记忆工具从零写到能稳定跑,我最大的体会是:记忆系统的难点不在存储,而在判断“什么值得记”和“什么时候该忘”。模型本身已经给了很强的语义理解能力,但作为开发者,你需要通过精心的数据分层、检索排序和注入模板设计,才能让这份理解真正服务于跨会话的场景。如果你也在做类似的对话应用,我建议从小而美的方案开始,先跑通核心闭环,再逐步加复杂度。别一上来就上重型的向量库和分布式存储,很多时候一个本地文件数据库加一套聪明的权重算法,就已经能带来很明显的体验提升了。

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

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

立即咨询