你有没有过这种体验:和AI助手聊得正深入,它忽然完全不记得你十分钟前说的话;换个新对话,又要从头开始自我介绍一遍。我搞这个名叫ai-memory的项目,起因就是受不了这种"金鱼式"对话体验。当时我正好在给一个客服机器人做长期迭代,发现用户反复问同样的问题、业务方反复抱怨"它怎么一点都不懂我",于是动了念头:与其每次把上下文硬塞进窗口里,不如给AI单独建一套记忆系统。这个项目做下来,核心就三件事:把对话里值得记的东西抽出来、存起来、在合适的时候塞回去。
这篇文章适合两类人。一类是正在做Agent、客服机器人、AI助手的开发者,想给产品加上长期记忆却不知道从哪下手;另一类是单纯对"AI为什么记不住事"好奇的技术爱好者,想搞明白市面上那些记忆方案内部到底怎么转。我会把整个项目的设计思路、技术选型、写入链路、检索注入、踩坑实录全部摊开讲,目标是你看完就能照着搭一套自己的AI记忆服务。
1. 项目概述与设计思路拆解
1.1 为什么AI天生"记不住事"
先说一个很多人忽略的底层事实:大语言模型本身没有记忆,它的"记忆"只在上下文窗口存活。窗口内的Token就像黑板上的粉笔字,对话一结束、进程一关,黑板就被擦得干干净净。更麻烦的是,即便在单次对话内,一旦超过上下文长度,早期信息也会被截断或者相对权重变低,模型对"十几轮以前你提到过你养了只猫"这种事,经常是两眼一抹黑。
所以业界普遍的做法是"外挂记忆",也就是把模型本身当成一个纯粹的大脑,真正的记忆放在外面。这也是我在项目立项时做的第一个判断:ai-memory 不该绑定任何具体模型,它应该是一个独立于LLM之外的服务。不管底层接的是GPT、Claude还是国产开源模型,记忆服务都提供统一的读写接口。这样推理引擎可以随时换、版本可以随时升,记忆数据却不受影响。这个"存储与推理分离"的思路,后来被证明是正确的——我至少换了三次模型,记忆库一次都没动过。
1.2 记忆分层的总体设计
做记忆系统最忌讳的是把所有信息一股脑塞进一个池子。我的做法是参考认知科学里的记忆分类,做了三层划分:
第一层叫短期工作记忆,对应最近几轮对话的完整原文,保留在会话上下文中。第二层叫情景记忆,记录"用户在某个时间点做过什么事"——比如"2025年3月12日用户反馈登录页面加载慢"。第三层叫语义记忆,是用户稳定的偏好和画像——"用户偏好简洁回复""用户公司使用的是微服务架构"。这三层的更新频率、重要程度、查询方式完全不同,混在一起会导致系统臃肿且检索混乱。
对应到代码实现上,我用了两张表来支撑这三层:主表conversations存短期原文,memories表存提炼后的事实和偏好。每次对话结束后,系统后台异步执行一次"记忆提取 + 写入",就像人睡一觉后大脑在整理白天的经历一样。这个异步设计很关键,因为它保证了对话主链路的低延迟,提取过程再慢也不影响用户交互。
1.3 与市面上主流记忆方案的差异
做之前我也调研过Mem0、Zep、Letta这些现成方案。坦白说它们各有亮点,但我最终选择自己写,原因有三。第一,自托管和可控性:很多开源方案部署包里有额外的服务依赖,运维成本不低,我希望整个项目用一个Docker Compose就能拉起来。第二,定制检索策略:我需要针对客服场景做"实体+语义+时间衰减"的复合检索,通用方案在这块的灵活性不够。第三,也是最重要的,想彻底搞懂原理。直接调别人的库,出了问题你只能干瞪眼;自己从零搭一遍,向量检索、重排序、记忆冲突解决这些细节才能门儿清。
当然,如果只是想快速验证效果,直接集成Mem0没毛病。但如果你的产品有特殊的领域术语或行业属性,我还是建议自研。AI记忆本质上是很业务化的事情,通用的"记住用户喜欢猫"容易,但要记住"用户是三级经销商,下单要过财务审批流"这种带强逻辑关系的信息,没有一套自己的抽取规则很难做好。
2. 核心架构与关键技术选型
2.1 两条链路一根主线
整个ai-memory的架构可以用两句话讲完:写链路负责把对话变成记忆,读链路负责把记忆变成上下文。写链路是离线的、异步的,对实时性几乎没要求;读链路是在线的,需要保证极低延迟,否则会影响对话响应速度。两条链路中间靠一个统一的存储层连接。
读链路里还有个容易被忽略的优化点:记忆检索通常发生在LLM调用之前。也就是说,当用户输入一条新消息,系统先把当前提问转成向量,去向量库里捞相关记忆,再把记忆拼成系统提示词的一部分,最后才把完整的Prompt交给模型。整个过程增加的网络开销如果控制在30~50毫秒内,体感上是无感的。我实际压测下来,最耗时的不是向量检索本身,而是消息队列里堆积的写入任务,所以在写链路上我做了单独的worker进程,和在线服务隔离部署,避免互相干扰。
2.2 向量数据库选型:Qdrant还是Chroma还是pgvector
向量数据库是这个项目的核心存储组件。我先后试过三种,最终固定在了Qdrant上,先看对比:
| 维度 | Chroma | pgvector | Qdrant |
|---|---|---|---|
| 部署复杂度 | 极简,嵌入式 | 依赖PostgreSQL | 单容器,独立服务 |
| 百万级向量性能 | 一般 | 中等 | 较好,自带过滤索引 |
| 向量过滤 | 支持有限 | 依赖SQL | 支持Payload过滤 |
| 内置混合检索 | 不支持 | 不支持 | 支持稀疏向量 |
| 运维难度 | 低 | 中 | 中低 |
选Qdrant的关键原因有三个:一是它的Payload过滤能力很强,可以给每段记忆打上user_id、memory_type、created_at等元数据标签,查询时先过滤再检索,精度和速度都能兼顾;二是它支持稀疏向量(Sparse Vector),这意味着我可以直接在数据库层面做关键词与语义的混合检索,而不用额外引入Elasticsearch;三是对Docker部署非常友好,一个qdrant/qdrant镜像就完事,本地调试也不折腾。
如果你只是想在内网小规模试用,pgvector其实也够用了,毕竟可以直接复用已有的PostgreSQL,少维护一个组件。但从项目后续扩展的角度看,Qdrant的Payload过滤机制能省掉大量应用层代码,而且它独立于业务数据库,写坏了大不了清空重建,不会把业务数据一起拖下水。
2.3 数据模型与记忆类型设计
记忆的存储结构我设计了五类字段:user_id(归属哪个用户)、type(记忆类型)、content(记忆内容)、metadata(业务附加信息)、vector(内容向量)。其中type字段尤其重要,它决定了系统如何对待这条记忆。
我把type分为四类:fact(客观事实,如"公司位于上海")、preference(用户偏好,如"喜欢表格汇总,不喜欢大段文字")、episode(事件记录,如"上周三处理过退款订单")、warning(禁忌事项,如"不要说'亲'这种称呼,用户明确表示反感")。你可能会问,直接用自然语言描述不就行了,为什么要分这么细?因为在检索时,不同类型会被赋予不同的权重。比如用户说"我要处理退款",warning类型的记忆必须高亮展示,这是绝对不能踩的坑;而episode类型的记忆可以低权重出现,作为背景参考。
实践下来,这种类型标签带来的调试收益远大于我最初的预期。你可以肉眼检查一条记忆结果,直观判断"这条答案是不是被错误的记忆带偏了"。如果所有记忆长得都一样,出问题时你只能一脸懵。
3. 实操:记忆写入链路完整实现
3.1 对话接入与预处理
写链路的第一步是拿到原始对话。这里有个设计细节:不是所有对话都要进记忆系统。我当时定了一条规则——核心业务会话全量记录,闲聊会话只做摘要。实现方式是给SDK传一个session_type参数,值为core或chat。之所以这么设计,是因为客服场景里用户可能在对话里穿插"今天天气不错""你们公司几点下班"这类闲聊,全量存入会导致记忆库膨胀,且大概率污染后续检索。
预处理阶段还需要干一件事:把对话拆分成"用户轮次"和"助手轮次",并标注消息时间戳。注意,提取记忆时不仅看用户说了什么,也要看助手回复了什么。举个例子,用户问"能不能优惠",助手回复"目前对新客有5%折扣"。如果只记用户的话,你丢失了"该用户可享受新客折扣"这个关键信息。所以我的预处理逻辑是成对提取,User消息负责触发,Assistant消息负责补充可记忆的业务回执。
3.2 LLM异步提取记忆:提示词设计
这是写链路的大脑环节。我设计了一套专门的Prompt,让LLM从"用户-助手"消息对中抽出结构化记忆。Prompt模板大致长这样:
你是一名记忆提取引擎。请从对话中提取需要长期保存的关于用户或业务的关键信息。 提取原则: 1. 只提取确定性的、未来可能用到的信息,不提取猜测性内容。 2. 如果用户提到的内容与已有记忆冲突,以最新对话为准,并标注"conflict"。 3. 每条记忆控制在50字以内,用陈述句描述,不要留口头语。 4. 如果没有值得提取的信息,返回空数组,不要硬编。 输出JSON格式: {"memories": [{"type": "fact|preference|episode|warning", "content": "记忆内容", "confidence": 0-1}]} 对话内容(用户与助手轮流): {{conversation}}这个Prompt里有三个容易被忽视的点。一是**"冲突检测"指令,如果没有它,当用户说"我搬到北京了"而你库里有"用户在上海"时,系统会同时检索出两条互相矛盾的记忆,模型就困惑了。二是置信度字段**confidence,低于0.6的记忆会被直接丢弃或者存到草稿区等待人工审核,这能有效过滤LLM的幻觉。三是字数限制,让LLM把"好的好的,这个问题我帮我问问财务再说哈"这种噪音过滤掉,只留下"需要财务确认才能答复"的实质信息。
3.3 向量化与存储实现
提取出来的记忆是纯文本,要进向量数据库必须转成向量。我用的Embedding模型是BAAI/bge-m3,中文效果好、支持8192长度、并且能输出稀疏向量,正好配合Qdrant的混合检索。部署上我用FastAPI包了一个向量化服务,接口就一个/embed,输入文本输出768维稠密向量加稀疏向量。
存储的核心代码如下:
import qdrant_client from qdrant_client.models import PointStruct, VectorParams, Distance client = qdrant_client.QdrantClient(host="localhost", port=6333) # 创建集合,配置稠密+稀疏双向量 client.recreate_collection( collection_name="ai_memory", vectors_config={ "dense": VectorParams(size=768, distance=Distance.COSINE), }, sparse_vectors_config={ "sparse": {} } ) # 写入一条记忆 point = PointStruct( id=memory_id, vector={ "dense": dense_vector, "sparse": sparse_vector }, payload={ "user_id": user_id, "type": memory_type, "content": content, "created_at": timestamp, "confidence": 0.92 } ) client.upsert(collection_name="ai_memory", points=[point])一个容易踩的坑是recreate_collection在正式环境千万别乱调。我早期调试时图省事,每次改完配置就重建集合,结果线上库被清了一次,几百条真实记忆全没了,只能从备份恢复。正确做法是先get_collection检查是否存在,不存在才创建,修改配置时用update_collection。
3.4 记忆去重与冲突解决
单纯提取会带来另一个问题:同一件事用户在不同时间说了好几遍,库里存了好几份相似甚至相同的记忆,检索时它们同时命中,白白消耗上下文空间。我加了两个机制解决这个问题。
第一是语义去重。新提取的记忆先跟库里最近30天的高频记忆做一次向量相似度检索,如果余弦相似度大于0.92,就认为两者是同一件事,新记忆不写入,只更新旧记忆的时间戳和置信度。这个阈值是调出来的,一开始用的0.85,结果把"用户喜欢用微信沟通"和"用户喜欢用企业微信沟通"这种有明显差异的记忆也合并了,后来改成0.92才稳定。
第二是冲突标注。当新记忆与旧记忆的向量距离很近但内容有矛盾时(比如一个说"上海",一个说"北京"),系统不直接删除旧记忆,而是把新记忆标上conflict=true,并附上resolve_at时间戳。检索时,冲突记忆默认给低权重,只有最新一条会进入上下文。为什么不全删?因为我发现用户经常说反话或者中途改主意,万一新记忆本身是错的,旧记忆还能作为兜底证据,方便人工介入排查。
4. 实操:记忆检索与上下文注入
4.1 检索触发策略:全局加局部
读链路要解决的核心问题是"什么时候把记忆注入Prompt"。最粗暴的做法是把该用户所有记忆一次性注入,对于重度用户来说,记忆可能几千条,Prompt直接爆炸。我采用的是全局注入 + 局部检索的双层策略。
全局注入的是一条经过压缩的"用户画像速览"。每当用户进行新会话时,系统会拉取该用户最近的Top 3条preference和warning记忆,投喂给LLM作为长期人格参考。这部分是固定的,每次对话都有,但内容极简,占不了多少Token。
局部检索则是根据当前用户消息,实时去向量库捞Top K条相关记忆。这里需要精细设计的是过滤条件:必须带user_id过滤,否则会检索到其他用户的记忆——这个错误我还真犯过,上线第一天就出现了"A用户问产品价格,模型却回答出B用户的专属折扣",排查了一下午才发现是检索接口漏传了user_id。
4.2 混合检索、评分与重排序
只靠向量检索有个明显短板:对专有名词和实体匹配不够精准。比如客服场景里用户报出一串订单号"ORD-20250312-001",向量检索可能因为语义上没学过这种格式而给出低分。所以我做了向量检索 + 关键词检索的混合模式,评分公式如下:
final_score = 0.65 * cosine_similarity(dense_query, dense_mem) + 0.35 * bm25_score(sparse_query, sparse_mem)这条公式里的两个权重不是拍脑袋定的,我做了对照组实验。把权重调成0.5/0.5时,长文本语义匹配的记忆(比如"用户提到希望售后响应时间不超过2小时")的排序明显变差;调成0.8/0.2时,订单号、产品型号这类精确匹配的记忆经常漏掉。最后停在0.65/0.35,算是语义和字面匹配的均衡点。
重排序环节我用了一个轻量策略:在拿到Top 30候选后,先做一层硬性过滤,把conflict=true且created_at不是最新的结果踢掉;然后按以下规则微调排名:warning类型记忆排名+5分,preference+3分,fact不加分,episode-2分,同时按时间衰减系数exp(-0.05 * days_since)进行调整。做完这些处理后只取前8条作为最终注入记忆。
4.3 上下文注入与Prompt组装
记忆检索完了,如何优雅地放进Prompt是个技术活。直接平铺记忆列表会加重模型的阅读负担,我采用的是分类分区块的组裝方式:
【用户固定偏好】 - 偏好数据导出的格式是Excel,不接受PDF。 - 沟通时请勿使用"亲"等过度亲昵称呼。 【历史事实】 - 用户公司当前使用的是Spring Cloud微服务架构。 - 用户在本平台开通过企业版套餐。 【近期事件】 - 2025-03-10:用户反馈过一次登录超时问题。 - 2025-03-14:用户咨询过发票抬头变更流程。 【当前对话】 {{current_user_message}}这种结构的优势在于,给模型提供了明确的"信息来源"参照系。模型在生成回复时如果用到记忆里的内容,会自然基于对应分区的事实去回答,而不是把记忆和当前对话混成一锅粥。我实际对比过,没有分区结构时,模型偶尔会把"用户以前的诉求"和"用户现在的诉求"混为一谈;加上分区之后,这个现象几乎消失了。
4.4 遗忘机制与隐私保护
记忆系统建起来容易,收尾难。如果不做遗忘机制,记忆库会堆积大量过期信息,并且带来隐私合规风险。我在项目里做了三层遗忘策略。
第一层是主动遗忘:每条记忆都带expire_at字段,episode类型的默认生命周期是90天,fact类型是180天,preference和warning不自动过期。过期记忆由一个定时任务清除,每天凌晨跑一次。第二层是用户侧遗忘:提供DELETE /memories/{user_id}接口,用户如果要求"忘记我",系统立即删除该用户全部记忆,并且在日志级别也做脱敏处理。第三层是敏感信息过滤:提取Prompt里明确加了指令"如果对话中出现身份证号、银行卡号、完整手机号,不要提取,直接返回'敏感信息已忽略'",同时在写入前还会过一次正则匹配,匹配到敏感格式就直接丢弃。
这个部分虽然看起来不性,但绝对是上线前必须做扎实的功课。记忆系统最大的风险不是记不住,而是记了不该记的东西。
5. 常见问题与排查技巧实录
5.1 记忆注入后模型变"傻"了
有段时间我接到反馈,说加了记忆系统后,模型的回答质量反而不如不带记忆的时候。查了一圈发现是注入的上下文太杂乱。比如用户问"你们的API调用限制是多少",系统却把"用户昨天吐槽过文档不清晰"这种低相关度的记忆也注入了,模型开始东拉西扯说些有的没的。
解决办法是给检索环节加了一个相关性硬门槛:最终分数低于0.62的记忆,一个字都不注入。同时把注入条数从10条降到8条。别小看这个阈值,它是个极其有效的手段。宁可让模型不知道,也不能让它被错误信息干扰。测试下来,模型回答的准确率反而提升了约15%。
5.2 记忆库膨胀严重
运行一个月后,我的记忆库涨到了几十万条向量,检索延迟从30毫秒涨到200多毫秒。排查后发现是重复记忆太多,用户每次进客服说"我要查订单"都会被抽取成一条新的episode记忆。去重机制当时只针对fact和preference,漏掉了episode类型。
修复方案是在去重检索时不再限定记忆类型,对所有类型统一做相似度比对。同时给episode类型加了合并策略:如果用户一周内重复提出相同诉求,只保留最近两次的记录,更早的自动合并成"用户本周曾多次提交某类请求"这条概括性记录。这波优化后,库的增速降了大约60%。
5.3 检索结果张冠李戴
一个经典的Bug:用户A问"我该升级固态硬盘还是机械硬盘",系统给出的记忆提示却是用户B喜欢"雷蛇机械键盘"。原因就是Payload过滤条件在某个查询分支里漏掉了user_id。这类问题的排查思路很简单,在Qdrant的Query结果里打印payload,一眼就能看出user_id是否匹配。我建议在做任何检索逻辑调整时,日志里都要输出检索命中的payload信息,方便快速定位这种"串号"问题。
5.4 可视化管理与测试技巧
记忆系统是黑盒,你很难靠肉眼判断"这些记忆存得对不对"。我后来做了一个简易的记忆管理后台,按用户维度展示所有记忆列表,支持人工编辑和删除。虽然丑,但调试效率提高了很多。
测试方面我的经验是,做一套固定的"记忆回归用例集"。里面包含几十个典型的用户提问场景,每个场景都标注了期望注入的记忆类型和TOP1检索结果。每次改动检索逻辑,跑一遍用例集,对比输出差异。没有这套自动化校验,你改完算法永远不知道是变好了还是变坏了。
常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 模型引用其他用户的信息 | 检索接口漏传user_id | 检查Payload过滤条件 |
| 记忆冗余、库膨胀过快 | episode类型缺少去重 | 统一所有类型去重逻辑 |
| 注入后回答变差 | 低相关度记忆太多 | 添加0.62相关性硬阈值 |
| 新旧记忆冲突 | 缺少冲突检测 | 冲突标注+保留旧记忆兜底 |
| 敏感数据被存入库 | Prompt缺少过滤指令 | 增加正则匹配+丢弃机制 |
做了这个项目后我自己最深的体会是,AI记忆系统真正的分水岭不在于"能记多少",而在于"该记的准确记,不该记的坚决不记,需要时能捞得上来,过期的能删得掉"。这四个环节环环相扣,任何一个出了毛病,整套系统都会变成负资产。
最后再分享一个实用技巧:给记忆库写一个简单的记忆审计日志,把每次写入、检索、删除操作都记下来。遇到用户投诉"AI怎么知道我的……"这种问题时,你能直接翻日志看是哪条记忆在被什么时候注入的,快速判断是Bug还是正常业务逻辑。这比上线后对着代码猜,省太多时间了。