你有没有过这种体验:昨天刚和某个AI助手聊完旅行计划,今天再打开它,对方一脸无辜地反问“你想去哪儿玩来着”。如果你只是个普通用户,顶多吐槽一句“人工智障”;但如果你正在做AI Agent开发,这种“金鱼记忆”问题几乎躲不掉。这个系列走到第三篇,前两篇我们处理了Agent的骨架搭法和任务执行,这一篇集中解决一个让Agent真正有“人味”的能力——记忆。这篇文章适合三类人:正在用LangChain这类框架搭Agent的开发者、想给个人知识库助手加长期记忆的爱好者、以及刚入行但对“记忆机制”这个概念还一头雾水的初学者。先给个结论:Agent的记忆不是一个功能,而是一套系统工程,只靠“把历史消息塞进prompt”这种歪招,做出来的东西离“记住你”差着十万八千里。
1. 记忆对Agent意味着什么:先想清楚再动手
1.1 没有记忆的Agent为什么显得“笨”
很多人把Agent的“笨”归咎于模型能力不够,其实有一半的锅要记在“失忆”头上。一个没有持久化记忆的Agent,每次对话都是从零开始,它不知道你是程序员还是牙医,不知道你上次聊到一半的项目叫什么,也不知道你对答案的偏好是简洁还是详尽。这意味着每一次交互,用户都要重新自我介绍、重新描述背景、重新解释上下文。这种体验放在PC时代,叫作“重启即空”;放在AI应用里,叫作“留不住用户”。
从技术角度看,大模型本身有上下文窗口,几千到几万token不等。窗口内的内容模型“看得见”,窗口外的东西一律等于不存在。短期会话还好,一旦跨天、跨设备、跨会话,模型就是彻底失忆。所以我们要做的记忆系统,本质上是一个“上下文的外置硬盘”:把模型窗口里放不下的历史信息,压缩、提炼、存储,在需要的时候捞回来,再塞回窗口里,让模型表现得像什么都记得。
1.2 从人脑借鉴的四类记忆
在做记忆系统设计时,我建议先别急着写代码,而是把人脑的记忆机制过一遍。认知科学里通常把记忆分成工作记忆、情景记忆、语义记忆、程序性记忆,这套分类放在Agent上非常贴切。
工作记忆对应Agent当前会话的上下文,也就是窗口里的对话历史,它是易失的;情景记忆对应“某个时间点发生了什么”的具体事件,比如“上周二用户说他的项目下周上线”;语义记忆是从事件里提炼出来的事实和偏好,比如“用户是后端工程师,喜欢用Go语言”;程序性记忆则对应“怎么做某件事”的技能和流程,比如用户教过Agent如何格式化周报、如何整理报销单,Agent学会了,下次就照着做。
市面上大多数Agent只做到了工作记忆,少数做到了情景记忆和语义记忆,程序性记忆几乎没有产品做好。但你要知道,完整的产品化记忆系统,这四个层面都得有,只是实现的优先级和成本不同。
1.3 开工前必须回答的三个问题
别一上来就研究Milvus、Chroma、向量索引,先想清楚三个业务问题:
第一,你的Agent需要记住多久?只记住当前会话,还是跨会话记住一周、一个月、永久?记住多久决定了存储方案和数据生命周期管理策略。
第二,Agent需要记住什么?用户的基本信息、偏好、历史决策记录、曾经输入过的文档内容,还是它自己执行过的任务轨迹?记错比不记更可怕,记太多又会导致检索时抓不回重点。
第三,记忆用在哪里?是每次对话前自动调用来润色回答,还是只在用户主动询问“你还记得吗”时调取?这两者对检索时效和召回精度的要求完全不同。
这三个问题来自我实际的踩坑教训。我做第一个带记忆的Agent时,根本没想清楚,一股脑把所有对话都向量化存进向量库,结果对话一长,检索结果全是噪音,Agent经常张冠李戴,把A用户的事安到B用户头上。后来才明白,记忆系统的设计决策,80%在动手前就定完了,不是靠调参调出来的。
2. 一张记忆系统架构图:分层、存储、技术栈怎么选
2.1 四层记忆模型:从热数据到冷数据
根据前面三个问题的结论,我把Agent记忆分成四层来设计:
第一层是模型上下文层,也就是当前会话窗口。这一层不需要我们存储,但需要“整理”。对话长了要总结、压缩,保证窗口里始终是最关键的信息。
第二层是会话缓存层,存的是原始对话记录,通常放Redis或内存。它有两个用途:一是做对话中的快速回溯,用户问“我刚才说那个事”,Agent能翻出来;二是作为提炼记忆的原料,每隔一段时间或者会话结束时,从缓存里抽取结构化记忆。
第三层是关键记忆层,存放提炼后的用户画像、偏好、事实、重要事件。这层数据量不大但价值最高,适合用SQLite或PostgreSQL这类关系型数据库存储,查询快、结构清晰,也方便做增删改查。
第四层是语义记忆层,存放需要模糊检索的文本片段、文档、历史对话摘要。这一层数据量大,用途是“语义召回”,用向量数据库实现,比如Chroma、FAISS、Milvus、pgvector。很多Agent教程只讲这层,好像把对话丢进向量库就完事了,实际上向量层只是记忆系统的一个组件,不是全部。
2.2 存储引擎怎么选:用表格说话
当初我自己选型时,看了不少资料,也踩过不少坑。不同层级的记忆数据,冷热属性、结构化程度、查询方式都不一样,不可能用一套存储通吃。我整理了一个选型对照表,直接照着选就行。
| 记忆层级 | 典型数据 | 存储选型 | 原因 |
|---|---|---|---|
| 会话缓存层 | 原始对话、未提炼的上下文 | Redis / Memcached / 内存 | 读写快、支持过期时间,适合高吞吐 |
| 关键记忆层 | 用户画像、偏好、事实 | SQLite / PostgreSQL | 结构化强、事务可靠、查询灵活 |
| 语义记忆层 | 文档、对话摘要、经验文本 | Chroma / FAISS / Milvus / pgvector | 适合向量检索,支持相似度召回 |
| 元数据索引 | 记忆来源、时间、置信度、用户ID | 数据库字段或标签系统 | 用于过滤、排序、权限控制 |
如果你的项目还处于原型阶段,SQLite加一个Chroma(文件模式)就完全够用了,不用为了所谓的高并发去上重服务。等到用户量起来、数据量到了千万级别,再考虑把SQLite换成PostgreSQL、把Chroma换成Milvus或者使用云厂商的向量数据库。一上来就堆组件,只会让排查问题变得痛苦。
2.3 一个可以照抄的技术栈组合
我现在的个人项目采用的是一个非常朴素的组合:FastAPI作为Agent后端服务,Redis存会话缓存,SQLite存用户画像和结构化记忆,Chroma做向量库存语义记忆,embedding模型用bge-m3或者OpenAI的text-embedding-3-small。模型调用层用的是开源的LangChain生态,但记忆模块我建议你尽量自己写,少用框架自带的高层Memory类。
为什么建议自己写?我遇到过太多次框架升级导致记忆接口行为变化的情况,而且框架封装好用的Memory类虽然方便,但底层逻辑是个黑盒,出了问题你都不知道是存丢了还是查错了。自己写一套记忆模块,代码量不大,大约几百行,但完全可控,出了问题能顺着代码查。后面的示例我也都按自定义实现来讲,不依赖某个特定框架。
3. 第一步:让Agent“听进去”——记录层实现细节
3.1 会话身份与上下文管理的坑
讲存储之前,先解决一个最基础也最容易翻车的问题:怎么区分不同用户的记忆。很多初学者在开发测试时只用本机,user_id写死,结果产品上线后用户A看到了用户B的记忆,这是数据安全事故级别的bug。
我的做法是,所有记忆表都带两个字段:user_id和session_id。user_id代表用户身份,跨会话不变;session_id代表一次会话,每次对话开始都会生成。检索记忆时,所有查询都必须强制带user_id过滤,这一步绝对不能省。在设计数据库表时,user_id和session_id都要建索引,否则数据量上来后查询会非常慢。
另外一个容易被忽略的点是session_id的生成时机。不要在用户第一次发消息时才创建,而要在应用加载时就生成。不然用户刷新页面就会产生新会话,看起来只是丢了一点点上下文,实际上会导致会话缓存层频繁失效,后续的记忆抽取也会把本来连续的对话切成碎片。
3.2 记忆抽取:不写流水账,只提炼关键信息
记录层最核心的工作不是“存原文”,而是“提取值得记的东西”。如果你把整个对话原文都塞进向量库,那等于没有设计,检索时必然噪声爆炸。我一般用LLM执行抽取,每次对话结束或者每经过一定轮数,跑一次抽取任务。
抽取的内容分成三类:用户画像相关的事实(姓名、职业、城市、技术栈)、明确的偏好(喜欢详细回答、不要emoji、习惯用Python)、重要事件(某项目立项、某日期要交报告)。注意,抽取时不能让模型自由发挥,必须用JSON结构约束输出,方便后续解析入库。
这是我常用的抽取代码结构,用pydantic定义结构,再让模型填充:
from pydantic import BaseModel class ExtractedMemory(BaseModel): user_name: str | None = None preferences: list[str] = [] facts: list[str] = [] def extract_memories(conversation: list[dict]) -> ExtractedMemory: prompt = f""" 你是用户的私人记忆助手。请仔细阅读以下对话, 只抽取值得长期记住的用户信息,包括: 1. 用户的称呼或身份信息 2. 用户的明确偏好 3. 与用户相关的重要事实或事件 要求: - 不抽取临时性情绪,不抽取与用户无关的信息 - 不编造任何对话中不存在的内容 - 每个字段尽量精炼,一句话一条 对话内容: {conversation} 请严格按 JSON 格式返回: {{"user_name": "", "preferences": [], "facts": []}} """ resp = llm.chat( messages=[{"role": "user", "content": prompt}], response_format={"type": "json_object"} ) return ExtractedMemory.model_validate_json(resp)实际运行下来,有几个细节值得注意。其一,抽取频率不要太高,每轮都抽会很费token,而且会存大量重复信息。我一般是每5轮对话或者用户主动说“记住这个”的时候才触发一次。其二,对话内容在传给抽取模型前要做截断,取最近N轮就可以,早期的信息要么已经被抽过,要么早就不重要了。其三,抽取结果入库前要做一次归一化,比如用户这次说“我喜欢吃川菜”,下次说“我超爱火锅”,系统得能把这两条合并成“偏好:辣味食物”,而不是存两条互相矛盾的记录。
3.3 落地示例:SQLite存用户事实
关键记忆层用SQLite就够,建表和执行插入都非常轻量。我习惯把记忆内容分成mem_type字段,方便后续按类型查,比如profile表示画像、preference表示偏好、event表示事件。
import sqlite3 DB_PATH = "agent_memory.db" def init_db(): with sqlite3.connect(DB_PATH) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, session_id TEXT NOT NULL, mem_type TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_user ON memories (user_id, mem_type)") def upsert_memory(user_id: str, session_id: str, mem_type: str, content: str): with sqlite3.connect(DB_PATH) as conn: conn.execute( "INSERT INTO memories (user_id, session_id, mem_type, content) VALUES (?, ?, ?, ?)", (user_id, session_id, mem_type, content) )这套表结构看起来非常简单,但应付个人项目和中小型产品足够了。当数据量涨到几十万条时,你可能会发现SQLite的写入锁是一个瓶颈,但那是后话,真到了那个规模,迁移PostgreSQL也不难,因为schema基本不用变。
4. 第二步:让Agent“想起来”——检索层实现细节
4.1 向量化不是万能的:先搞懂相似度查询的边界
记录存进库里只是第一步,真正难的是“在正确的时候把正确的记忆捞出来”。很多人一上来就用向量检索,把用户当前问题embedding之后去向量库里查TopK。这个方法对付“语义相似”的场景没问题,但它的边界很明显:向量相似不等于事实正确,语义相近的句子可能内容完全不同。比如用户今天说“我想离婚咨询”,明天说“我想离婚后财产怎么分”,语义相似度很高,但后者对前者并没有参考价值。类似的例子在实际对话里比比皆是。
所以我不建议把向量检索当作唯一的召回手段。更稳的做法是混合检索:向量检索负责语义召回,关键词检索负责精确匹配,两者取交集或按权重融合。这样既不会漏掉表达方式不同的句子,也不会被“只说了一句话但包含关键人名、项目名”的情况漏掉。
4.2 混合检索:关键词+向量,别再二选一
关键词检索可以用经典BM25算法,也可以用简单的数据库LIKE查询。对于小规模数据,SQLite的LIKE配合fts全文搜索就够。对于大规模数据,可以考虑用Elasticsearch或者OpenSearch。不过我个人建议,在数据量没有大到MySQL扛不住之前,先用SQLite FTS5或PostgreSQL自带全文检索,不要为了一个关键词检索就引入一套Elasticsearch集群。
混合检索的常用融合方法是Reciprocal Rank Fusion,原理很简单:把两种检索结果分别排序,每个结果按排名的倒数算分,然后相加排序。这种方法实现简单、效果稳定,是RAG领域常用的baseline。
def hybrid_search(user_id: str, query: str, top_k: int = 5): vec_results = vector_recall(user_id, query, top_k * 3) bm25_results = bm25_recall(user_id, query, top_k * 3) combined = {} for rank, doc_id in enumerate(vec_results): combined[doc_id] = combined.get(doc_id, 0) + 1 / (60 + rank) for rank, doc_id in enumerate(bm25_results): combined[doc_id] = combined.get(doc_id, 0) + 1 / (60 + rank) ranked = sorted(combined.items(), key=lambda x: -x[1]) return [doc_id for doc_id, _ in ranked[:top_k]]这个代码里的60是经验常数,用来平滑排名差异,你也可以根据数据情况调整。混合检索不是银弹,但它能明显减少“向量召回了一堆相关但不正确内容”的情况。我实测下来,在用户画像和事实类记忆的召回准确率上,混合检索比纯向量检索提高了大约20到30个百分点,数据量越大,提升越明显。
4.3 排序与相关性门槛:宁可少召回,不要错召回
召回之后还要过一道“相关性门槛”。很多人忽略了这一点,把TopK结果一股脑塞给模型。问题是,如果Top5里有2条是无关的,那模型就会基于错误的上下文回答,生成胡说八道的内容。更糟的是,这种错召回还会污染对话主题,让用户觉得Agent“记性真的好差”。
我习惯在召回之后加一个reranking步骤,用rerank模型对候选记忆重新打分。轻量做法是直接用LLM做二分类判断:给定用户问题和候选记忆,判断是否相关。更高效的做法是用cross-encoder模型,比如bge-reranker-base,它对相关性判断的准确率明显高于纯向量相似度。
def rerank(query: str, candidates: list[str]) -> list[str]: pairs = [[query, c] for c in candidates] scores = reranker.predict(pairs) return [c for _, c in sorted(zip(scores, candidates), key=lambda x: -x[0])]除了排序,我还会设置一个相关性分数的下限阈值。低于阈值的记忆直接丢弃,宁可让Agent说“我记不太清了”,也不要让它把一个错误记忆当作事实说出来。这个阈值怎么定?我一般拿一组人工标注的正负样本跑一遍,画出精确率和召回率曲线,取曲线拐点对应的分数。听起来复杂,其实用几十条样本就够了。
4.4 完整示例:从query到prompt注入
把上面几步串起来,完整流程如下:用户发来问题,先判断是否需要唤起记忆;如果需要,就取用户ID和问题文本,做向量召回和关键词召回,融合排序,再过rerank和阈值过滤,最终把命中的记忆拼进system prompt。
def build_system_prompt(user_id: str, query: str) -> str: profile = load_user_profile(user_id) # 查SQLite关键记忆层 memory_hits = recall_relevant_memories(user_id, query) # 混合检索+重排 system = "你是我的AI助手。关于我的背景信息:\n" if profile: system += json.dumps(profile, ensure_ascii=False) + "\n" if memory_hits: system += "我过去提过相关内容:\n" system += "\n".join(f"- {m}" for m in memory_hits) return system这里有一个细节:唤起记忆的时机不是每轮都做。如果用户只是在闲聊,你频繁唤起记忆反而显得机械。我现在的策略是,先用一个轻量分类器判断当前问题是否需要历史信息。比如“我上次说的那个bug解决了吗”这类含“上次、之前、当时”的句子,直接判定为需要唤起;比如单纯问“今天天气”就不需要。这个判断可以用规则,也可以用LLM,数据量大了以后再用模型也不迟。
5. 记忆该写什么、又该忘什么:写入策略与遗忘机制
5.1 写少不写多,写稳不写快
记忆的写入策略,我总结成六个字:写少、写稳、写准。宁可少记几条,也不要乱记一堆。原因很简单,记忆检索是有噪音的,你存进去100条垃圾信息,最终被召回的概率就越高,影响回答质量的风险就越大。我见过很多团队把Agent记忆做成“对话录音机”,什么都存,结果系统变成一个巨大的垃圾场。
一个合理的最小集是:用户明确表达过的偏好、用户主动要求记住的事情、跨会话持续出现的高频主题、与任务强相关的关键事实。这四类信息价值密度最高,值得写入。其他的,比如今天中午吃了什么、随口吐槽了一句什么,统统不进长期记忆。你可以把“用户主动要求记住”当作最高优先级,把“持续出现3次以上”当作次高优先级,其他的都只留在会话缓存层。
5.2 冲突与合并:旧记忆和新信息打架了怎么办
用户的信息是会变化的。用户上个月说“我在北京工作”,这个月说“我搬到上海了”。如果系统不做处理,两条记忆同时存在,Agent检索时会很困惑。处理办法是:写入新记忆时,先查一下有没有同类型、同主体的旧记忆,如果有,就标记为“待确认”状态,让LLM判断是更新还是新增。
这一步需要建立一个实体归一化的映射。最简单的做法是给每条记忆打上实体标签,比如user、project、location,当新记忆和旧记忆实体相同且内容冲突时,触发更新流程。如果实体的技术判断做不好,退一步也可以用时间戳覆盖:同一实体、同一类型的记忆,新写入的覆盖旧的。这个策略虽然粗暴,但在大多数场景下够用,因为它保证Agent永远优先相信最近的信息。
5.3 遗忘机制:不能只增不删
很多记忆系统设计者把注意力全放在“怎么记住更多”,却忽略了“怎么遗忘”。这是一个大坑。记忆只增不删,最终会导致两个问题:一是检索时干扰项越来越多,二是存储成本不断上升。更关键的是,用户对Agent的信任建立在“它能忘记不该记的东西”上,一个什么都记得、什么都不忘的助手,反而会让用户觉得不安。
遗忘策略可以从三个维度设计。时间维度:超过一定时限未访问的记忆,自动降低权重,最终进入归档或删除;重要性维度:低重要度记忆定期清理,比如每周清理一次“闲聊类”记忆;用户控制维度:提供一个接口,让用户能查看Agent记住了什么、删除某条记忆、甚至一键清空。最后一个维度往往最容易被开发团队忽略,但对产品口碑来说非常重要。
我在自己的项目里实现了一个“记忆废纸篓”:删除的记忆不会立刻物理删除,而是先软删除30天,避免用户误操作后无法恢复。这套机制不复杂,多一个deleted_at字段就能实现,但它带来的用户体验提升非常明显。
6. 必须避开的坑:我在Agent记忆实战里踩过的雷
6.1 记忆串号:最严重的数据安全事故
我在文章开头提过,user_id没隔离,用户A看到用户B的记忆,这种问题一旦发生,轻则产品口碑崩盘,重则涉及隐私合规风险。排查这类问题,关键是要在存储和检索两个环节都加上强制过滤,并且在写代码时约定:任何查记忆的SQL或向量查询,都必须显式带上user_id条件,禁止任何形式的全表扫描。
更隐蔽的风险出现在向量库where过滤条件缺失时。Chroma这类向量库,如果查询时不加metadata过滤,就会检索到所有用户的数据,这个bug在开发环境很难发现,因为数据量小,结果看起来都对。我建议在测试阶段专门写一条“跨用户检索测试用例”,明确验证user_a查询时不会返回user_b的内容。
6.2 重复写入:同一事实被存了几十条
重复写入是最常见的记忆质量问题。我一开始没有做去重,结果用户说了一次“我喜欢喝美式”,后面对话里又提了几次,系统每5轮抽取一次,一个月下来存了十几条“喜欢美式”。检索时全被召回,浪费上下文空间。
解决办法有两个层面。写入前先查重:同用户、同内容、同类型,如果已存在就直接跳过。定期合并:用LLM对同一主题的多条记忆做一次归并,提炼出一条更精炼的表述。前者适合在线写入,后者适合离线任务。两个都做,效果最好。
6.3 上下文膨胀:记忆太多把窗口塞满了
很多人给Agent加记忆之后,发现对话轮数一长,提示词越来越大,模型响应越来越慢,费用也越来越高。这就是上下文膨胀。解决思路是分层使用记忆:检索到的原始记忆只保留当轮需要的最核心部分,其他细节转成摘要放到会话缓存层,而不是一股脑全部注入system prompt。
我现在会控制注入的token预算。比如设定每次最多注入600字记忆,超出部分截断或只放摘要。这样既保证核心信息不丢,又不会撑爆上下文窗口。
6.4 和知识库类应用的结构差异
搜索热词里有不少人在关注“obsidian + ai agent 知识库”这类应用。这里要提醒一句:知识库检索和记忆检索是两套逻辑。知识库是“用户问什么,文档里有没有对应内容”,它重语义匹配、重来源引用;记忆是“用户是谁、之前发生过什么”,它重时间线、重用户视角。你可以把知识库当作Agent的外部工具,但不要把它塞进记忆模块里混着存,否则检索时的目标函数会互相干扰。我见过有人把用户笔记直接向量化存进记忆库,结果Agent回答问题时经常引用用户自己的旧笔记,看起来像在自言自语,体验非常差。
7. 从记住一个人到记住一群人:多Agent与共享记忆的扩展方向
7.1 用户画像中心:让多个Agent共享同一份用户档案
如果你在做一个平台型产品,不同场景下可能有多个Agent:客服Agent、推荐Agent、写作助手Agent。如果每个Agent各存一套记忆,用户就会觉得这些Agent“不熟”。正确做法是建立独立的用户画像服务,所有Agent都通过API读写同一份用户画像,再按场景附加各自的专用记忆。
这个画像服务本质上就是把我前面讲的关键记忆层抽出来,做成一个内部微服务。写接口要保证幂等,读接口要带缓存,底层直接用PostgreSQL就能扛住大部分场景。
7.2 团队记忆:一群Agent共享项目上下文
再往后扩一步,就是多个Agent协作完成复杂任务。这时候记忆的粒度不只有用户,还有项目、团队、任务。比如一个项目Agent团队,其中一个Agent负责需求分析,另一个负责写代码,第三个负责测试,它们之间得共享项目背景、技术方案、当前进度。这个共享的知识库不是用户画像,而是“项目长期记忆”。
这里要处理的核心问题是写入冲突。不同Agent可能在同一时间往共享记忆里写内容,必须做好乐观锁或版本控制。实际操作中,我给每条共享记忆加一个version字段,写入时带上期望版本号,版本不一致就拒绝写入并返回冲突,由上层逻辑决定是覆盖还是合并。
7.3 记忆可视化:让用户看到Agent记住了什么
最后分享一个我觉得极其重要的扩展方向:记忆可视化。给用户一个面板,展示Agent当前记住了哪些关于他的信息,并且提供“删除”“修改”“补充”的操作入口。这件事技术难度不高,但对用户信任度的提升非常明显。很多人担心AI记住自己的隐私,一旦你给了用户“查看和控制”的权利,这个顾虑就会大幅缓解,主动使用记忆功能的频率也会更高。
我自己的项目里做了一个“记忆卡片”界面,按用户画像、偏好、事件三类展示记忆,每条旁边都有编辑和删除按钮。上线后大概有三分之一的用户会主动去清理或修改记忆,这比任何宣传语都更能说明问题。
真要动手做的话,我建议从最小可行版本开始:SQLite存关键记忆、向量库存语义记忆、单Agent接入。这套东西不用框架自带记忆模块,几百行代码就能跑通。跑通之后再逐步加混合检索、重排、遗忘机制、记忆可视化。很多团队一上来就设计一个超级复杂的记忆系统,结果迟迟落不了地。先从“能记住”开始,再往“记得准”“记得巧”迭代,这条路我走过,稳。