做 Agent 项目的人,迟早会撞上一个有点诡异的场景:你把用户的记忆存进了 AI 数据库,Agent 也确实把它“想起来”了,结果用出来的答案是错的——而且是那种让人哭笑不得的错。我之前帮一个客服机器人项目做过排查,用户上个月刚申请过退款,这个月跑来问“我的货到哪了”,机器人居然调取了一段“用户不喜欢电话沟通”的旧记忆,上来就说“我们为您转人工处理”,用户当场就毛了。类似的问题在 Agent 记忆系统里太常见了,大部分情况的根子不在大模型本身,而在记忆底座的设计方式:记忆怎么存、怎么检索、怎么过滤、怎么更新,每一步都可能把方向带偏。这篇文章我会把自己在做 AI 数据库 + Agent 记忆底座时踩过的坑、查过的原因、最后沉淀下来的方案完整写出来。适合正在做 Agent 开发、准备接记忆功能、以及被“记住了却用错”折磨得想摔键盘的朋友。
1. 先搞清楚:Agent 的“记忆”到底需要记什么
1.1 别把“记忆”等同于“历史聊天记录”
很多团队刚开始做 Agent 记忆时,顺手就把用户的聊天记录一股脑全写进数据库。这个想法本身可以理解,但实操下来问题极大。聊天记录是“原始素材”,不是“记忆”。真正有价值的记忆,是能从素材里提炼出来的用户偏好、关键事实、状态变化、任务结果这类高密度信息。就像你和一个客户合作三五年,你不会记得他说的每句话,但你一定会记得他关注成本、讨厌周报、拍板要快这些关键信息。
我会用一个三层模型来区分 Agent 的记忆层级:
- 短期记忆(上下文):当前会话内的消息,直接在 prompt 里传递,会话结束就消失。
- 工作记忆(执行状态):当前任务执行过程中的中间状态,比如规划好的步骤、已经调用过的工具、临时变量。这部分一般放在 Agent 框架的运行时里,比如 ReAct 循环中 thought、action、observation 的记录。
- 长期记忆(持久层):跨会话需要保留的信息,这才是 AI 数据库 + 记忆底座要解决的部分。
这里有个容易被忽略的点:长期记忆不是越长越好,而是越“结构化”越好。很多 Agent 项目把长期记忆做成“把上次对话全文存起来”,检索出来又是一大坨文本灌进 prompt,结果上下文被塞得乱七八糟。真正的记忆底座应该把原始对话拆解成一条条“记忆单元”,每条指向一个明确的主题、一个明确的结论,这样才能在需要的时候精准取用。
1.2 记忆在 Agent 主循环中的位置
Agent 的主循环大体上是“接收输入 → 推理规划 → 调用工具 → 观察结果 → 再推理 → 回复”。记忆底座通常插在两个位置:一是在“推理规划”之前做读取,把与当前问题相关的长期记忆注入;二是在“观察结果”之后做写入,把本轮交互中有价值的信息提炼后存回去。
这个位置的选择会直接影响 Agent 的行为。如果读取太早,连用户意图都没搞清楚就去检索,可能召回一堆不相关的旧记忆;如果读取太晚,模型在第一步推理时缺少关键背景,后面再怎么补也扭转不过来。比较稳妥的做法是双阶段读取:先用一个轻量级的意图判断决定是否触发记忆检索,再在真正进入工具调用与内容生成之前注入高置信度的记忆。这也是 Agent 框架与编排里很常被问到的一个细节。
用生活化的类比来理解:你写周报的时候,不会先翻出手机里三个月前所有聊天记录,而是先想清楚“这周我到底干了什么”,再去翻项目相关的笔记。Agent 需要的也是这种“按主题、按当前问题去翻找记忆”的能力,而不是把所有历史一股脑摆在桌面上。
2. 为什么记忆底座要落到 AI 数据库上
2.1 把历史全塞进 Prompt,是一条注定走不通的路
网上有不少 Agent 教程,偷懒的做法是“把用户历史消息追加到 system prompt 后面”,看着好像也能跑,实际上隐患很大。
第一是 token 成本问题。假设一个重度用户一天产生 200 条交互,平均每条 100 token,一个月就是 60 万 token,放到文本里都够写两本书了。就算你的模型支持百万级上下文,单次请求的延迟和成本也吃不消。第二是信息稀释。模型面对十几万字上下文时,注意力天然会被开头结尾和最近的对话带走,三个月前某条关键偏好很可能直接被淹没。第三是矛盾信息打架。用户三个月前说“爱用邮件”,今天明确说“以后发微信就行”,你把两条历史都塞进去,模型大概率不知道怎么取舍。
所以业界才会把“长期记忆”单独外置到数据库里,而不是常驻上下文。AI 数据库在这里承担的角色,类似于给 Agent 装了一个“外置硬盘加索引系统”:需要的时候按需取回几条,不需要的时候就放在库里面。这也是为什么现在一聊到 Agent 记忆,大家的第一反应都是“上向量数据库 / 混合检索数据库”。
需要说明一下,这里说的 AI 数据库不单指某个向量数据库品牌。它可以是带向量索引的 PostgreSQL(pgvector),也可以是专门的向量库,还可以是支持混合检索的托管服务。关键在于你要有“语义检索能力 + 结构化过滤能力 + 持久化能力”这三样,缺一不可。
2.2 向量检索、关键词检索、Rerank 怎么配合
记忆检索不是只靠向量就能搞定。几种检索手段的特点很不一样:
- 向量检索(Embedding):把文本映射成高维向量,擅长“语义相似”。比如用户问“怎么申请退款”,能匹配到过去“退款流程”相关的记忆,即使字面不同。
- BM25/关键词检索:擅长精确匹配专有名词,比如订单号、SKU 编码、人名、地名。
- Hybrid Search:向量和关键词打分融合,保证语义和精确匹配都能覆盖。
- Rerank(重排):先用向量或关键词召回一个较大的候选集(比如 100 条),再用更精细的排序模型挑出真正相关的 top 5。
在记忆场景里,我的习惯是:专有名词类问题靠关键词兜底,泛化表达类问题靠向量兜底,最终质量靠 Rerank 把关。很多团队只上向量检索,结果专有名词相关的记忆召回效果很差;反过来只靠关键词,跨表述语义匹配又不行。混合检索的价值就是两边都不得罪。
这里给出一个我在项目里常用的选型对照:
| 方案 | 语义检索 | 关键词精确匹配 | 持久化/并发 | 适合场景 |
|---|---|---|---|---|
| 内存 dict / JSON 文件 | 无 | 弱 | 差 | 原型验证 |
| MySQL/PostgreSQL + LIKE | 无 | 一般 | 强 | 元数据过滤、结构化属性查询 |
| 全文检索引擎(ES 等) | 弱 | 很强 | 强 | 日志、文档搜索 |
| 向量数据库 / 混合检索库 | 强 | 强(需开启 hybrid) | 强 | Agent 记忆底座、RAG 问答 |
对照下来,生产级 Agent 记忆底座基本上都会优先选最后一行,但实际部署时也要考虑团队已有的基础设施。比如项目已经用了 PostgreSQL,那用 pgvector 往往比另外引入一个独立向量库更省心,运维成本低不少。
2.3 记忆单元的结构化建模
很多人在数据库里建一张“记忆表”,字段只有 id、user_id、content、created_at,然后就没别的了。这种设计跑个 Demo 还行,一上生产就各种问题:无法过滤过期记忆、无法按类型管理、无法定位到具体业务上下文、多租户隔离只能靠 user_id 硬拼。我强烈建议把记忆单元当成“一张独立的业务表”来设计,至少包含以下字段:
- memory_id:全局唯一标识
- user_id:归属用户,必须建立强制过滤
- session_id:产生该记忆的会话,用于回溯上下文
- type:记忆类型,比如 preference(偏好)、fact(事实)、event(事件)、state(状态)、skill(技能)
- content:记忆正文,建议控制在 100~200 字以内,一条记忆只表达一个核心结论
- source:来源,比如 conversation、tool_call、manual_input
- confidence:置信度,人工录入高,对话提炼低
- importance:重要度 0~10,影响记忆的持久保留优先级
- timestamp:产生或更新时间
- expire_at:过期时间,可为空
- metadata:扩展字段,比如关联订单号、商品 ID、页面路径等
- embedding:向量字段,由 content 生成
字段不是越多越好,但上面这些属于“基本盘”。设计时记住一个原则:读取出的是“结构化记录”,而不是“一段话”。有了这些元数据,检索时才能做精准过滤,比如“只取 type=preference 的记忆”“只取三个月内的 event 记忆”“只取与当前订单相关的 state 记忆”。没有这些维度,你就只能拿整段文本去撞语义,误召回率会很高。
3. 记住了为什么还会用错:检索环节的四大坑
3.1 相似度不等于相关度
这是记忆系统里最常见、也最难排查的坑。向量检索计算的相似度,是“语义上的接近”,不是“当前问题真的需要它”。举个例子,用户之前问过“MacBook 还有货吗”,这段记忆里包含“苹果笔记本电脑”的语义。结果今天用户问“苹果怎么切好吃”,向量检索把购买意向的旧记忆召回出来,Agent 就可能把话题带到电子产品上。
这种情况在纯向量检索下逃不掉,因为“苹果”在不同语境中是同一个词、相近的语义空间。我的应对方式有三个:第一,开启混合检索,让关键词和向量互相制衡;第二,在记忆条目的 metadata 里加上主题标签,比如 tech_shopping、food_cooking,检索时用当前意图去过滤;第三,也是最重要的,不直接相信第一次召回结果,而是让一个轻量级 LLM 调用做相关性二判,把明显不对的记忆滤掉。
这里插一个常见争论:到底要不要为了“省一次模型调用”而跳过二判?我的经验是,在记忆准确性敏感的 Agent 场景里,不要省。一次二判的成本很低,大概多花几十到一百个 token,但它能显著降低“想起旧事、答非所问”的概率。等你上线以后因误召回收到的用户投诉,远比这点 token 成本贵。
3.2 记忆没有保质期:过期信息与新偏好冲突
用户是善变的。三个月前说“我习惯邮箱沟通”,今天在即时聊天里大发雷霆,系统可能还执着地认为“该用户偏好邮件”。时间衰减没有做,新老记忆互相叠加,模型自然不知道该信谁。
解决这个问题,需要两个机制同时工作。一是检索时做时间加权:给较新的记忆更高权重,对过老的记忆指数衰减。比如可以用一个简单公式:score = base_score × exp(-λ × age_days),λ 初始取 0.1(按天为单位),跑一段后根据线上效果调整。二是写入时做“记忆合并/覆盖”:检测到与已有记忆同主题的冲突信息时,不追加,而是把旧记忆标记为 superseded(已取代),再把新记忆作为当前有效版本。只有这种“档案式”管理,才能避免模型每次都被两三条互相矛盾的历史搞糊涂。
实操中我发现一个细节:覆盖不等于删除。旧记忆不要物理删除,而是保留结构化历史,方便审计和回溯。但检索时一定要过滤掉 superseded 状态,否则等于白做。
3.3 上下文污染:Top-K 无脑取
很多系统“召回什么就注入什么”,一口气塞 10 条记忆进 prompt,其中 7 条和当前问题无关。结果是无关记忆成为噪声,甚至把模型带偏——它看到一个完全不相关的旧偏好,反而生硬地往那个方向答。
针对这个坑,我总结了三道闸门:
- 第一道闸门(score 阈值):低于分数线的候选直接丢弃,不进入候选集。这个阈值和 embedding 模型强相关,需要实测定标,一般从 0.45~0.6 起调。
- 第二道闸门(条数上限):真正注入 prompt 的记忆建议控制在 3~5 条以内。宁可少,不可杂。
- 第三道闸门(LLM 相关性过滤):对最终候选再做一次“该记忆是否对当前问题有用”的判断,只保留明显有用和可能相关的。
这样下来每条进 prompt 的记忆都经过了两轮甚至三轮筛选。你会发现 Agent 的答准确率会有肉眼可见的提升。反之,如果你连“阈值过滤”都没做,就不要怪模型把记忆用错。
3.4 写入垃圾,读出来必然垃圾
前面三坑都发生在读取侧,第四个坑在写入侧:如果一开始存进去的就是垃圾,再怎么优化检索也白搭。很多团队用“整段对话原文入库”的方式存记忆,结果库里充满了“今天天气不错”“哈哈”这类无意义内容,还有一些相互矛盾的草稿性表述。检索时,这些垃圾没有足够判别度,经常和有效记忆混在一起被召回。
我推荐的写入管线是三层加工:先判断“这段对话里有没有值得长期保留的信息”,再抽取“实体、偏好、事件、状态”,最后提炼成一条简洁、中立、无歧义的记忆文本。一个典型示例如下:
- 原始对话:用户说“这个订单我不要了,赶紧帮我退了,以后别给我默认选那个笨重的物流,走顺丰就行”
- 值得保留的判断:是,涉及用户偏好
- 抽取结果:实体=订单;偏好=物流
- 提炼成两条记忆:
- 用户在处理该订单时选择退款,取消该订单原配送方式(event)
- 用户偏好物流使用顺丰,默认不要选笨重物流(preference)
你会发现,提炼后的记忆信息密度高、指向明确,检索命中率和相关性都会好很多。这也解释了为什么现在任务拆解时,“记忆抽取”会成为 Agent 开发中的一个专属环节,而不是把聊天记录原样入库。
4. 实操:搭一个能用但不过度设计的记忆底座
4.1 读写链路的最小实现
结合上面讲的原则,我给出一个非常精简的参考实现,不绑定具体框架,用 Python 风格的伪代码来说明结构。写入侧的流程是:
def write_memory(user_id: str, message: str, response: str): # 1. 用 LLM 抽取记忆:判断 + 提炼 + 分类 extracted_items = memory_extractor.invoke({ "user_id": user_id, "message": message, "response": response }) for item in extracted_items: record = { "memory_id": generate_id(), "user_id": user_id, "session_id": item.get("session_id"), "type": item["type"], # event / fact / preference / state "content": item["content"], # 已提炼的单条结论 "source": "conversation", "confidence": item.get("confidence", 0.8), "importance": item.get("importance", 5), "timestamp": now(), "expire_at": item.get("expire_at", None), "metadata": item.get("metadata", {}), } # 2. 冲突记忆合并/覆盖 if exists_conflicting(record): mark_superseded(record["topic_key"]) # 3. 写入数据库并异步生成向量 db.insert(record) embed_async(record)这里要注意三件事:先做冲突检查再插入,避免同主题新旧记忆共存;metadata 里最好带一个 topic_key,用于合并和覆盖判断;embedding 异步生成,不要阻塞对话响应。
读取侧的流程是:
def recall_memory(user_id: str, query: str, top_k: int = 5) -> list[str]: # 1. 查询改写,让检索更贴合用户当前意图 rewritten_query = query_rewriter.invoke(query) # 2. 混合检索:向量 + 关键词 + 元数据过滤 candidates = db.hybrid_search( user_id=user_id, # 强制多租户隔离 query=rewritten_query, top_k=top_k * 3, # 多召回,给 rerank 留余量 filter={"status": "active"}, score_threshold=0.5 # 第一道闸门 ) # 3. 精排 reranked = reranker.rerank(rewritten_query, candidates)[:top_k] # 4. LLM 二次相关性过滤 final_items = llm_filter(rewritten_query, reranked) return [item["content"] for item in final_items]读取链路的关键点:user_id 在检索参数里强制带上,这是隔离的第一道防线;top_k 是最终注入数的 3 倍,给后续筛选留空间;score_threshold 宁可高一点也不要混入低质量记忆;LLM 过滤后拼接成“记忆小节”注入系统的 prompt 区。
4.2 记忆条目 JSON 示例
为了保证可读性,我给出一条实际入库的记忆记录长这样:
{ "memory_id": "mem_8f3a1c", "user_id": "user_1024", "session_id": "chat_7890", "type": "preference", "content": "用户偏好物流使用顺丰,默认不要选择笨重包装", "source": "conversation", "confidence": 0.9, "importance": 7, "timestamp": "2025-02-14T09:30:00+08:00", "expire_at": "2025-05-14T09:30:00+08:00", "status": "active", "topic_key": "user:1024:logistics_preference", "metadata": { "order_id": "ord_9527", "tags": ["logistics", "preference"] }, "embedding": "[...]" }几个字段的使用说明:
- status:active / superseded。检索只过滤 active,superseded 保留用于审计。
- topic_key:同主题合并的钥匙。检测到同 key 的新记忆时,旧记录置为 superseded。
- expire_at:临时性记忆(比如“用户当前在出差”)到期后自动下沉。
- metadata.tags:用于检索时的标签过滤。
有这套结构,后续做多轮对话、多 Agent 共享记忆、用户画像洞察,都能直接在数据库层面完成大部分工作,而不是靠 prompt 硬堆。
4.3 需要记住的初始参数与踩坑提醒
给一份我常用的初始参数速查表,不同模型和库可能略有差异,但可以作为起点:
| 参数 | 推荐初值 | 说明 |
|---|---|---|
| top_k | 5(上限 10) | 最终注入记忆条数 |
| 召回候选数 | top_k × 3 | 给 rerank 留余量 |
| score_threshold | 0.5 ~ 0.6 | 根据 embedding 模型实测调整 |
| 时间衰减 λ | 0.1/天 | 以天为单位,越大越“喜新厌旧” |
| 记忆正文长度 | ≤ 200 字 | 超过要拆分 |
| LLM 过滤开关 | 开启 | 高准确性场景必开 |
踩坑提醒:第一,embedding 模型不要随便换。上线时用了 A 模型生成向量,中途换 B 模型,旧向量和新查询的相似度分布会彻底乱掉。解决方案就是记录每条记忆的 embedding_model 字段,版本升级时统一做向量回填或者重建索引。第二,数据库的 user_id 过滤要双保险。代码层过滤只是第一层,如果库本身支持 partition key 或者租户字段级隔离,一定要在存储模型层面也做一层,防止某个查询漏带 user_id 时把全量记忆捞出来。第三,慢查询多半出在 filter 字段上。如果记忆表很大,要给 user_id + status + timestamp 建组合索引,避免每次检索都全表扫描。
5. 用错记忆的排查实录与经验
5.1 三种典型症状:怎么对症下药
我把自己和客户项目里遇到过的问题归成三类,基本能覆盖绝大多数“记忆用错”的场景:
| 症状 | 常见原因 | 排查突破口 | 修复方向 |
|---|---|---|---|
| 答非所问,突然扯到很久以前的话题 | 向量召回无关记忆,上下文污染 | 打印召回候选和对应会话片段 | 加标签过滤 + LLM 二次相关性判断 |
| 张冠李戴,用了另一个用户的信息 | 多租户隔离失效 | 检查 user_id 过滤是否在代码与存储层都生效 | 强制检索条件带 user_id,存储层加分区隔离 |
| 态度前后矛盾,旧偏好一直压过新偏好 | 无覆盖机制,同主题记忆并存 | 查同 topic_key 的记忆状态 | 写覆盖:旧记忆置 superseded,新记忆作为 active |
这三种情况在真实项目里占比极高。第一种尤其隐蔽,因为系统看起来“有记忆”,模型也答得顺,但就是方向不对——典型症状是用户都蒙了:“我没问这个啊!”
5.2 调试三板斧:把召回、写入、过滤都打开
记忆系统是个黑盒子,不做好可观测性就会变成“改了参数也不知道有没有用”。我长期使用的调试方法是三板斧。
第一板斧:记录读取链路的原始召回。不要只记录最终注入 prompt 的记忆,要把召回出来的 top 10 候选、分数、被过滤掉的记忆也存一份日志。这样排查时能区分三种情况:是根本没召回、还是召回了但没用上、还是召回了也用了但用错了。
第二板斧:记录写入链路的审计日志。每条记忆的 createdAt、来源会话、由哪段话提炼而来,都保留下来。用户投诉时能倒查“这条记忆是哪里来的、当时对不对”。这里要注意日志本身也要脱敏,不要记录手机号、身份证这类敏感字段的原文。
第三板斧:做一个小型离线回归集。挑 20 个该用户最典型的 query,人工标注“期望召回的记忆 ID”。每次改动检索参数后,跑一遍回归集看命中率有没有提高。有了这个基准,优化就不再是拍脑袋,而是能度量。
5.3 多 Agent 场景下的记忆共享与隔离
现在不少项目已经走向多 Agent 架构,比如一个总控 Agent 带若干个专业 Agent。这时候记忆系统要回答的新问题是:记忆是全局共享,还是按 Agent 隔离?
我见过两种做法。一种是“全局一个记忆库”,所有 Agent 共读,好处是信息流通,坏处是互相污染——销售 Agent 记住的谈判偏好,可能被客服 Agent 当成服务偏好用。另一种是“按 Agent 维度打标”,每条记忆除了 user_id,还有 agent_scope 字段,检索时默认只取当前 Agent 域下的记忆,跨域访问需要显式授权。我的建议是:优先采用第二种。在多 Agent 场景里,记忆共享的“默认拒绝、按需开放”,远比“默认全量共享”安全。
同时多 Agent 的记忆写入要避免重复。同一个用户行为可能被多个 Agent 各自记录,导致同一偏好出现三五条近似条目。解决方案是写入前做一次 topic_key 去重,或者由总控 Agent 统一负责记忆落盘,各专业 Agent 只读不写。这一点在 Agent 框架与编排的设计阶段就要定下来,后面再改成本很高。
我做了几个 Agent 项目后,最深的一个体会是:记忆底座的价值不在“存了多少”,而在“用得准不准”。很多时候你以为问题出在模型不够聪明,其实把检索日志翻出来一看,压根是记忆体系在源头就给了模型一堆错牌。与其无限堆 prompt 技巧,不如老老实实把记忆的写入、检索、覆盖、过滤、隔离这几个环节逐一打磨。最后再分享一个小技巧:调试时记得把每次注入 prompt 的记忆格式也打出来——很多时候模型不是不想用记忆,而是多段记忆被拼接得太乱,它根本分不清哪段是背景、哪段是偏好、哪段是任务要求。把记忆区域和其他 prompt 区域用清晰的标记分开,准确率马上就能感觉到差别。