为AI助手打造长期记忆:向量召回与上下文注入的工程实践

📅 发布时间:2026/10/10 10:38:54
为AI助手打造长期记忆:向量召回与上下文注入的工程实践
作为长期折腾聊天机器人项目的开发者我一直在苦恼一个问题每次和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代码长出来的。最后说个实战小技巧如果你在某个对话里发现某条记忆明确被用上了给它加一笔“命中次数”。这个数据能反过来帮你判断哪些记忆类别真正有用哪些只是站位置的噪音。等模型越来越会用记忆之后再把那些一直没被命中的记忆交给模型让它自己决定要不要删这就是“记忆整理”的方向了。