☰
为AI助手打造长期记忆:向量召回与上下文注入的工程实践
2026/10/10 10:38:46 网站建设 项目流程

作为长期折腾聊天机器人项目的开发者,我一直在苦恼一个问题:每次和AI助手重新开一个会话,它就忘了我上个月交代过的偏好、项目背景和整理过的结论。这个痛点太普遍了,所以我动手写了一个叫claude-mem的小项目,目标是给对话程序加上“长期记忆”。它做的事很简单:把历史对话埋进本地存储,在下一次模型调用前自动把相关旧内容捞出来,拼进上下文里。这篇文章老规矩,把完整的设计思路、核心实现、踩过的坑和评估方法都摊开来讲,适合正在给AI应用做记忆模块、或者想了解“会话外记忆”该怎么落地的朋友参考。

这个项目不是某一天突然想出来的,而是从实际需求里长出来的。当时我在维护一个内部问答机器人,它频繁被问到“我之前让你记的那个配置项是什么”。机器人每次都不记得,只能让用户重新说一遍。我试过把整段历史全塞进上下文,但模型窗口很快撑爆,费用也扛不住。反复折磨之后,我确信需要一个独立于会话之外的记忆模块,它负责三件事:记住、想起来、送回去。claude-mem就是冲着这三件事去的。

1. 记忆对AI助手意味着什么:场景痛点与设计目标

先别急着看代码,得先说清楚“记忆”到底在解决什么问题。裸模型本身没有跨会话状态,每次调用都是一个重开。要让它“记得”,本质上是把记忆外置到调用流程里,让模型看到的不仅仅是一轮新问题,还包括被筛选过的旧信息。

1.1 没有记忆的对话体验有多糟糕

我在早期给某个群聊机器人做功能时,用户反复问“上次的结论是什么”。我最初的做法是把最近50轮对话原始文本存下来,在每次请求时拼到系统提示词后面。效果确实有一点,但问题也很明显:无关信息太多,模型容易抓错重点;消息一长,接口延迟肉眼可见;更重要的是,50轮之外的旧事它依然完全不记得。

另一个麻烦是,用户说的“上次”可能已经是三天前的事。对话文本里那叫长尾信息,靠穷举式拼接压根捞不回来。真实用户不会把关键信息重复说第二遍,AI助手要是总装着没听过,信任感就没了。

1.2 记忆模块的设计目标与边界

我把项目目标收敛成三条:

  • 持久化:所有值得记住的内容都要落盘,重启服务不丢。
  • 语义召回:不能只靠关键词匹配,要能根据当前问题的意思找到相关旧记忆。
  • 低侵入:接入现有调用链路时,改动要小,最好就是包一层调用。

与此同时,我也给自己划了边界:不做全文对话备份,不做知识库,不试图理解情绪。claude-mem服务的是“事实性记忆”——用户说过什么、结论是什么、偏好是什么。这类信息适合结构化提取,不该让模型每次去猜。

2. 技术选型的取舍:为什么我选的是向量库而不是数据库

entire architecture 的根基是存储。我一开始很随意,后来发现存储方式直接决定了“想起来”的效果上限。

2.1 几种存储方案的对比

方案优点缺点适合场景
纯文本文件简单、零依赖无法按语义检索;大文件低效临时脚本、小规模调试
SQLite事务可靠、支持SQL只能做精确匹配或正则结构化管理元数据
关系数据库+全文索引查询灵活语义匹配弱,中文分词麻烦混合检索
向量数据库(或向量索引)语义召回强、支持相似度检索需要额外资源;召回质量依赖embedding记忆这种非结构化内容

我最终选择了SQLite + 向量索引的混合方案。SQLite保存记忆条目本身和元信息(时间、来源、类型),向量索引负责相似度检索。为什么不是直接用专门的向量数据库?因为项目体量小,不想引一个重服务进来;SQLite到处都有,备份迁移都方便。向量部分我用了一个轻量级的本地索引库,几百MB数据量完全够用,不需要单独部署。

2.2 一个关键决策:记忆条目不能是“整段对话”

最开始我把“每轮问答”存成一条记忆。实践下来发现不行:一轮对话里既有事实信息又有废话,拿来当一条记录去匹配,维度太杂。后来改成“先生成摘要,再按语义拆分”。具体流程是:模型完成一轮回答后,由另一个提示词把该轮内容提炼成几条独立的“记忆片段”,每片段尽量只包含一个事实或结论。

这一步让召回准确率提升非常明显。数据库里的记录越干净,后续检索越精准。这也算是我踩过的第一个坑——粒度不对,后面全白费。

3. 实现claude-mem的核心模块

代码层面我分成了四个模块:提取、存储、召回、注入。下面我按实际运行顺序来讲。

3.1 记忆提取的提示词设计

记忆提取是重活,直接决定了存进去的东西值不值得留。我给提取环节设计了一个专门的prompt模板,要求它返回JSON数组。数组里每个元素包含字段:content(记忆内容)、importance(重要性0-1)、category(比如偏好、事实、结论)。

下面是提炼出一个具体的关键提示片段:

您需要从刚才的对话中提取值得长期记住的事实性内容。 要求: 1. 每条只包含一个事实或结论,不要混入多件事。 2. 去掉临时性信息,比如“今天天气不错”。 3. 如果一句话既包含事实也包含情绪,只保留事实部分。 4. 输出JSON数组,格式为[{"content": "...", "importance": 0.9, "category": "fact"}]

这一步我当时重复调了好几版。最初提取出的条目经常是“用户说今天很忙”,这种信息没有任何记忆价值。解决办法是加了一条“除非是约定或偏好,不要记录临时状态”。另外,importance字段非常关键,后面召回排序要靠它。

3.2 存库与向量化的协作逻辑

提取后的每条记忆先进SQLite,拿到一个自增ID,然后调用embedding接口生成向量,把向量写入本地索引库,和ID关联。这里有个细节:我在应用层做了幂等控制,用哈希比对判断新记忆是否和已有记忆高度相似,相似度超过阈值就直接跳过。一开始没加这个,第二天数据库里全是意思相近的重复条目,召回结果一团糟。

存储表结构也不复杂,核心字段就是id、content、category、importance、created_at、source_session。source_session用来回溯是哪场对话产生的,排除错误记忆时非常有用。

写入顺序也讲究:先存库再生成向量。如果向量生成失败,至少SQLite里还有原始内容,后面可以补算,不会丢失数据。

3.3 语义召回与重排策略

召回不是简单的Top K相似度,不然你会发现结果老带着噪声。我用的是“两级策略”:先取相似度最高的候选集(比如20条),再根据三个指标重排。

三个指标分别是:

  • 信息新鲜度:创建时间越近权重越高,但也不是绝对,要和时间衰减函数配合。
  • 重要性:importance字段越高的记忆更容易排前面。
  • 相似度:语义相似度是基础分。

最终分数可以按下面这个简单公式算:

最终分 = 0.6 * 相似度 + 0.3 * 重要性 + 0.1 * 新鲜度

这个比例我试过很多轮,最后发现0.6/0.3/0.1最稳。也提醒一句:不要迷信这个数字,不同业务场景比例不一样。比如做客服系统,“新用户偏好”比“三周前的历史结论”更该被召回;做个人助手反而相反。建议你把参数暴露成可配置项,方便按场景调。

4. 把记忆送进对话上下文:接入流程与窗口控制

模块本身单纯从库里找记忆还不够,最难的是如何把它拼进一次真实的模型调用。

4.1 最小侵入的调用封装

我提供包装函数,记住原始请求和返回结果。原流程不变,只在发送给模型之前做三件事:

  • 读当前用户输入。
  • 用当前输入构造查询,召回10条左右记忆。
  • 把记忆拼成一段“历史背景说明”,插入到系统提示词和用户消息之间。

这里的插入方式有讲究。我一开始直接塞在用户消息前面,模型容易被旧记忆带偏。后来改成单列一个区块,明确告诉模型:“以下是从长期记忆中提取的背景,仅作参考。”这样模型便不会把记忆当成当前必须执行的命令。

“仅作参考”这几个字,你听起来轻飘飘的,实际效果巨大。它既提供了上下文,又不会让模型盲目相信记忆,毕竟记忆可能是旧的、错误的。

4.2 上下文窗口不够用怎么办

再优化的召回,也会遇到窗口压力。对话长起来后,模型窗口就那么点空间。我的解决办法是:只保留“摘要 + 最近两轮完整对话”。摘要由模型针对整段历史生成,并定期更新。在摘要中,我会额外附上几条关键召回记忆。

窗口分配大致如下:

  • 系统指令与固定prompt:约占10%
  • 历史摘要:约占20%
  • 本轮召回记忆:约占30%
  • 最近两轮完整对话:约占25%
  • 当前用户消息:剩余空间

这个配比不绝对,但思路是务实的:把最可能影响回答的信息放到显眼位置,其余能省则省。真的遇到超长对话,我会把“最近两轮完整对话”进一步压缩成“上一轮的用户意图”。

提示:窗口分配不是写死的就管用,建议先跑50条真实对话,统计每次实际用了多少token,再回头调比例。

5. 踩坑实录:第一版到稳定版的血泪史

再完美的设计图,落地都会遇坑。我挺乐意给claude-mem背这些锅,一是因为它们很有代表性,二是这些坑文档里一般不写。

5.1 向量维度与性能的平衡

最早为了图省事,我选用了一个返回768维向量的embedding模型。准确性不差,但本地索引库内存和磁盘占用直线上升。后来换成256维的小模型,召回质量掉了一些,整体资源占用掉了三分之二。对个人项目来说,换取的内存收益完全值得。

做法是:先跑一个小规模数据集,同时测128维、256维、512维、768维的召回率,画个表格看看准确性拐点在哪。我自己的测试结果里,256维的准确率比768维只低2到3个百分点,资源少一半。如果你是给线上服务用,可以考虑512维作为折中。

5.2 历史消息去重与更新

这个坑出现得非常隐蔽。用户说“把服务器时间改成8点”,过一会又说“不对,改成9点”。如果没有更新策略,两条都进记忆,下次召回时会同时出现“8点”和“9点”,模型就得猜。

解决方式分两步。第一步,用高频词的embedding相似度做粗粒度碰撞:新记忆和旧记忆内容相似度超过0.85时,不新增,而是更新旧记录。第二步,引入“版本号”字段,每次更新版本号加1,而查询时只返回最高版本。

这种模式缺点也明显:如果模型提取时把两条事实拼在一块,去重就撕不开它俩。所以我刚才一直在强调“每条记忆只保留一个事实”,这个约束在去重时也会反哺你。

5.3 异步写入与数据一致性

记忆提取要在主调用之后做,不能阻塞用户拿到响应,所以必须异步。但异步就带来问题:上一轮的记忆还没落盘,下一轮用户就开始问了,结果又没召回。

我开了两层保险:

  • 在应用启动时,把待写入队列移到“处理中”状态,重启后可以续写。
  • 在调用链路上加一个最短入库等待时间:如果用户在同一会话内连续提问,直接优先读“刚提取但尚未入向量库”的临时缓存,兼顾实时性和一致性。

这种方法不算完美,但实际使用很少出现召回失败。核心思路是,查询路径和数据写入路径分离,加上一层临时缓存兜底。

6. 怎么验证记忆真的有用:离线评测与线上观察

搞了个记忆模块,光说“能用”没用,得有数据支撑。我的验证分成两阶段。

6.1 搭建离线召回测试集

我准备了一组模拟问题,提前标注好每个问题应该匹配哪些记忆。很简单,像这样:

test_cases = [ { "question": "上次说服务器迁移到哪个云平台了?", "expected_memory": "用户决定将服务器迁移到某云平台,原因是现有主机费用过高", "session_time": "2025-03-01" }, ... ]

然后写脚本自动注入记忆、调用召回、检查top5是否包含expected_memory。这个环节主要调参:阈值、重要度权重、新鲜度权重。我反复调整时,把召回率从68%拉到了82%。

离线测试有个陷阱:它可能只是记住了测试集,而不是真的泛化。所以还得去真实流量里观察。

6.2 线上观察的三个指标

线上我没法自动打标,就用几个间接指标来盯:

  • “问旧事”的复述率:用户再次提到上次内容时不说全,而说“老规矩”的比例。
  • 召回不到时的追问次数:看机器人说“你之前说的我不太确定”的频率。
  • 上下文命中率:在调用日志中标记哪些请求拼上了记忆,再人工抽样看回答是否用上了。

我从后台看了一个星期的日志,发现“用户重复解释”的频次确实下降了。比较典型的一幕是,有人问我“内存为何还得降”,我直接回引用了他三天前说过的预算上限,对方只回了一句“对,就按这个来”。这说明记忆真的在起作用。

7. claude-mem后续还能怎么玩

说实话,写完第一版阶后我最大的体会是:记忆模块的价值,一半靠实现,一半靠“召回策略”。你存得再好,捞偏了一样白搭。后面我的计划是给记忆加一个“过期机制”,比如某些临时约定在两周后自动降权;另一个方向是让用户可以显式标记“这条必须永远记住”,优先级直接拉满。

如果你也在做类似的东西,我的建议是先从最小闭环开始:先手工提取、手工召回,跑通流程再自动化。别急着上复杂架构,claude-mem这名字听起来大,但它就是从几行SQLite代码长出来的。

最后说个实战小技巧:如果你在某个对话里发现某条记忆明确被用上了,给它加一笔“命中次数”。这个数据能反过来帮你判断哪些记忆类别真正有用,哪些只是站位置的噪音。等模型越来越会用记忆之后,再把那些一直没被命中的记忆交给模型让它自己决定要不要删,这就是“记忆整理”的方向了。

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

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

立即咨询