AI Agent记忆系统实战:从短期上下文到长期向量存储
2026/9/12 8:15:43 网站建设 项目流程

做AI Agent开发,最容易被低估的环节就是记忆。我见过不少团队把Agent的规划、推理、工具调用做得风生水起,结果一测多轮对话就露馅:用户昨天刚说“我不吃香菜”,今天问“帮我推荐几家餐厅”,Agent一本正经推了一堆含香菜的菜馆。问题不在模型不够聪明,而是它根本不记得你。

这一篇我打算把Agent记忆这件事讲透。从记忆的本质开始,到分层架构、存取机制,再到可以照抄的代码实现和踩坑经验。你可能是刚开始接触Agent开发,也可能已经做了一些Demo但对记忆模块总觉得别扭,这篇文章都值得看完。我会尽量用大白话讲原理,同时给出足够具体的方案,保证你读完能直接动手改自己的项目。

1. 先搞清楚:Agent为什么需要记忆,记不住到底卡在哪

1.1 没有记忆的Agent,本质上是“金鱼脑”

大模型本身是没有状态的。你把一段话发给GPT接口,它回你一段话,这件事就结束了。下一次调用,它完全不记得上一轮聊了什么。这种机制在单次问答里没有毛病,但放在Agent这种需要连续交互、逐步完成任务的产品里,就成了致命短板。

很多人误以为“多轮对话”就是有记忆。你把最近几轮对话拼在一起作为上下文再发给模型,确实能让它“看起来”记得刚才聊了什么,但这只是短期记忆。用户隔天再来,历史清零,一切从头开始。更麻烦的是,即便在同一轮任务里,如果上下文窗口被长文档或工具返回结果塞满,最早的关键信息也会被挤出去,Agent照样会“失忆”。

我把这个现象叫“金鱼脑综合症”:每条消息7秒后就忘干净。用户昨天告诉你他养了一只叫“豆包”的柯基,今天问“帮我给豆包安排一顿健康餐”,Agent完全不知道豆包是谁。这不是模型能力问题,是根本没有记忆存储的架构。

1.2 记忆和RAG、上下文工程不是一回事

讨论Agent记忆时,我经常看到有人把记忆跟RAG(检索增强生成)或者上下文工程混为一谈。这三者确实都用相似的技术手段,但目标完全不同,先把概念理清楚,后面设计才不会跑偏。

RAG解决的是“外部知识注入”问题。用户问的问题可能涉及到你本地文档、数据库、网页里的知识,模型训练时没见过,所以你需要先检索再生成。这里的信息来源是公共或半公共的知识池,跟“某个具体用户说过什么”没有直接关系。

上下文工程解决的是“当前任务的状态管理”问题。把工具调用结果、历史对话、系统约束拼成一个完整的上下文窗口,让模型理解当下正在干什么。它本质上是短期、临时的。

记忆解决的是“跨会话的用户信息沉淀”问题。用户上次说过的偏好、做过的选择、更正过的事实,需要在未来某次会话中被重新唤起。它的特点是个性化、长期化,并且有更新和遗忘的需求。

一句话总结:RAG管“知识”,上下文管“状态”,记忆管“用户”。记住这个区别,你设计系统时就不会把用户私有数据扔进公共知识库里。

1.3 记忆缺失的典型症状

我整理了一份对照清单,可以帮助你快速判断你的Agent是不是“记忆缺失”。如果你在测试中发现下述任意几条,基本可以断定记忆模块需要重做:

症状具体表现根源
重复提问用户已经回答过城市、预算、口味,Agent下一轮又问一遍短期上下文丢失或未持久化
偏好失效用户说过“不要辣”,推荐结果还是川菜偏好信息没有从对话中抽取并回填
画像模糊无法回答“我上次是什么时候来访问的”“我最常用哪个功能”没有结构化沉淀用户事实
任务断点中断一个长任务再回来,Agent完全不接茬缺少任务级记忆或状态恢复机制
越聊越陌生刚开始几句还挺准,越往后越跑偏上下文被其他内容污染,关键记忆被挤出窗口

这些症状背后,对应的其实是同一个根因:你没把“该记的信息”从对话流里捞出来,放进一个能跨时间读取的地方。下一节我会说清楚,这个地方应该长什么样。

2. Agent记忆的分层:短期、长期,以及什么东西该记

2.1 短期记忆:对话窗口与上下文管理

短期记忆最直接的实现方式,就是把历史消息拼进Prompt。难点在于窗口大小有限,不可能无限拼接。实际上你需要对历史做取舍。

我目前常用的做法是“滑动窗口 + 摘要压缩”的组合。滑动窗口保留最近几轮完整对话,保证当前任务的连续性;更早的历史如果还有价值,就用LLM生成一段摘要,把要点浓缩成几十个Token。这样既不丢失关键背景,也不会被海量历史撑爆上下文。

具体来说,窗口大小要根据你的任务类型调整。简单问答保留5-8轮足够;复杂任务比如写代码、做分析,可能需要保留15轮以上。摘要的操作可以放在每次对话达到窗口上限时触发,用一条Prompt让模型输出“这段对话里最重要的约束、决策和结论”,再把摘要塞进系统消息里。要注意摘要本身也会累积,摘要太长时还要做摘要的摘要,形成多层压缩。

这里有个容易被忽略的点:不是所有历史消息价值都一样。用户的明确偏好、关键决策,重要性远高于插科打诨和中间追问。所以我现在更倾向于“关键信息抽取 + 摘要兜底”的双轨短期记忆:抽取模块负责把高价值信息单独存下来,摘要负责保存完整的叙事脉络。

2.2 长期记忆:向量库加结构化存储的组合

长期记忆才是“让Agent记住你”的关键。它要解决的是跨天、跨周、甚至跨月的用户信息沉淀。纯靠拼接历史无法满足,你需要真正的存储和检索机制。

我建议把长期记忆拆成两层。一层是“语义记忆”,以自然语言片段为单位,存进向量数据库,通过语义相似度召回。比如“用户偏好日料,人均预算200元以内”“用户的工作单位在望京SOHO”,这类描述具体、需要模糊匹配的信息。另一层是“结构化记忆”,以键值或关系型记录为单位,存进SQLite或MySQL,适合存确定性强、需要精确读取的事实,比如用户的生日、所在城市、会员等级。

为什么要拆两层?因为向量检索擅长“按意思找”,但不擅长精确过滤。你想查“用户的会员等级是不是VIP”,向量库里搜半天可能出来一堆不相干的“用户对VIP服务的感想”,但在关系表里一查字段就有了。反过来,想找“用户对吃饭有什么偏好”,你没法提前把所有偏好字段定义好,必须用向量做语义匹配。两层配合,才是完整的长期记忆。

这里还要提一下“情景记忆”的概念。情景记忆记录的是用户与你交互过程中的具体事件,比如“上周二用户咨询过一次社保补缴流程”。它跟语义记忆的区别在于带时间属性和场景属性。情景记忆适合存成带时间戳的向量记录,召回时可以通过时间范围过滤。

2.3 筛选机制:什么值得记,什么不值得记

记忆不是越多越好。把每句话都存进向量库,结果就是检索时噪音爆炸,真正重要的信息反而召不回来。你需要一套筛选机制,只让高价值信息进入记忆系统。

我总结了几条筛选原则,实际操作中用LLM做抽取时我会写进Prompt里:

  • 值得记的:用户主动声明的偏好(“我不吃辣”“我喜欢靠窗的座位”)、可复用的客观事实(“我在北京工作”“我的项目下个月上线”)、明确的目标和计划(“我今年要考下CFA”)、用户在互动中更正过的信息(“不是上海,是杭州”)。
  • 不值得记的:一次性寒暄(“今天天气不错”)、临时指令(“帮我查一下这个词的意思”)、情绪宣泄(“这功能太难用了”)、过于私密的身份信息(身份证号、银行卡号,这类通常应该直接过滤)。

实际操作中,我会让LLM边对话边抽取记忆,但给它的判断标准要写得非常具体。比如“只有当你认为这句话在未来调用时会改变我的回答方式时,才把它记为记忆”。宁可漏掉一两句,也不要灌进去一堆噪音。漏掉的最多导致某次回答不够个性化,灌错的却会反复污染后续所有对话,危害大得多。

3. 记忆的存取与更新:写进去只是开始,用起来才是关键

3.1 写入:从对话流里“打捞”高价值信息

记忆系统第一步是把高价值信息提取出来。常见的做法是每轮对话后,把当前的对话摘要发给LLM,让它判断有没有值得写入记忆的信息,有就输出结构化的JSON,没有就返回空。

我在生产项目里用的是OpenAI的Function Calling来做抽取,因为它能保证输出符合Schema,不容易出现奇怪的格式。没有Function Calling条件时,用Prompt加JSON解析也能跑,只是要多做一层容错。核心逻辑是:让模型读懂用户的话,输出一组“记忆单元”,每个单元包含类型、正文内容、重要度、时间等字段。

下面是一段示范Schema:

{ "memories": [ { "type": "preference", "content": "用户不喜欢吃香菜", "importance": 0.9, "category": "饮食偏好" }, { "type": "fact", "content": "用户在北京工作", "importance": 0.7, "category": "个人情况" } ] }

写入动作本身要注意“幂等性”。同一句话可能被抽取出多次,如果不做去重,记忆库会迅速膨胀。我的做法是:在写入前先检索一遍已有记忆,如果相似度超过0.9且语义一致,就不重复写入,而是更新原记录的时间戳和权重。这就像给记忆续了一次命,但不会产生重复项。

3.2 召回:不是所有记忆都要灌给模型

很多初学者的误区是:把所有记忆一股脑塞进Prompt里让模型自己挑。这会造成两个问题:一是Token爆炸,二是无关记忆干扰判断。正确做法是把记忆当作“被检索的知识”,在用户提问时动态召回最相关的几条。

召回流程一般是这样:取用户最新的输入(和一些额外上下文),做Embedding,然后在向量库里做相似度搜索,取Top-K条。K的值我通常设在5到10之间,具体看任务类型。同时还可以按时间衰减做加权:同样相关度下,最近产生的记忆优先级更高。如果你做了分类,还可以在召回时过滤类别,比如用户问餐厅就只查偏好和情景记忆。

召回结果需要格式化后拼进System Prompt,让模型知道这些是“关于用户的事实”而不是当前对话内容。我习惯用类似下面的模板:

【关于用户的记忆】 - 用户平时偏爱日料,人均预算200元以内 - 用户不能吃香菜 - 7月20日用户咨询过公司附近的日料店 请基于以上记忆,结合当前问题回答。

有个细节:召回结果要标注来源和可信度。如果记忆是用户明确说过的,就说“用户说过”;如果是Agent从行为中推断的,就写成“推测用户可能”。让模型对记忆保留一丝怀疑,能有效避免错误记忆被当成真理传播。

3.3 更新与遗忘:记忆不能只增不改

用户是会变的。上个月喜欢重口味,这个月开始健身吃轻食。如果你的记忆系统只会写入不会更新,很快就会被过时的信息带偏。我见过最坑的案例是:用户明确说“我已经搬家到成都了”,Agent还因为一条几个月前的记忆持续推荐北京餐厅,怎么纠正都不管用。

解决这个问题,需要给每条记忆加上状态管理。基本动作有三个:

  1. 覆盖更新:用户说出与新记忆矛盾的信息时,旧记忆要标记为失效,而不是继续参与召回。我在结构化存储里会为每条记忆保留status字段,失效的用inactive标记,召回时直接过滤。
  2. 加权衰减:每条记忆有weight,每次被成功召回并确认有用时weight提升;长期不被命中的记忆weight逐步下降,低过阈值的进入冷数据区或直接删除。这相当于模拟了人脑的遗忘曲线。
  3. 主动遗忘:用户可能要求“忘了这件事”。你必须提供删除接口,从向量库和结构化库里同时清除相关记录,这不仅是体验问题,还是合规问题。

3.4 存储选型:向量库和关系库怎么搭配

这一节我直接给结论,附上选型理由。

向量库负责存“语义块”,适合内容不确定、需要模糊检索的记忆。我用过的几个:

方案定位适用场景优缺点
Chroma轻量级内嵌向量库本地开发、单机Demo部署简单,功能偏弱;适合起步
FAISS高性能向量检索库海量向量、高并发不是完整数据库,需要自己管理持久化
Milvus分布式向量数据库生产级、大规模场景功能全、性能强,运维成本高
pgvectorPostgreSQL插件已经有PG的场景与业务数据同库,少一个组件

结构化库负责存“确定事实”和记忆状态。小项目用SQLite就行,一个文件搞定;大项目上PostgreSQL或MySQL,看团队基础设施。

我目前的推荐组合是:Chroma或pgvector做语义检索 + SQLite或PostgreSQL做结构化存储。先别急着上Milvus,大部分项目到后期瓶颈不在向量库,而在记忆抽取和召回策略的质量上。数据库只是载体,真正拉开差距的是前两节讲的写入和召回逻辑。

4. 实战:30分钟给Agent装上可用的记忆系统

4.1 场景设定与技术栈

为了让你看得明白,我用一个具体场景来讲:做一个“美食推荐助手”,它能记住用户的饮食偏好和忌口,下次推荐时会主动避开用户讨厌的食物。

技术栈如下,都是比较常见且容易上手的:

  • Python 3.10+
  • OpenAI兼容接口(本文用openai库的ChatCompletion格式,你可以平移到任意兼容API)
  • Chroma作为向量库
  • SQLite作为结构化记忆存储
  • 内置的sqlite3模块,不需要额外安装

整个系统做的核心事情只有三件:从对话里抽取记忆、把记忆存下来、在新对话里召回记忆并影响回答。

4.2 第一步:定义记忆结构和工具函数

先定义一个记忆数据类,并初始化Chroma和SQLite。

import json import sqlite3 from datetime import datetime import chromadb from chromadb.utils import embedding_functions # 使用OpenAI embedding模型 openai_ef = embedding_functions.OpenAIEmbeddingFunction( api_key="your-api-key", model_name="text-embedding-3-small" ) chroma_client = chromadb.PersistentClient(path="./agent_memory") memory_collection = chroma_client.get_or_create_collection( name="user_memories", embedding_function=openai_ef ) # 初始化SQLite conn = sqlite3.connect("./agent_memory.db") conn.execute(""" CREATE TABLE IF NOT EXISTS structured_memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, key TEXT, value TEXT, status TEXT DEFAULT 'active', updated_at TEXT ) """) conn.commit()

这里有两个细节要说明。一是PersistentClient会让Chroma把向量数据落盘到本地目录,重启不丢。二是SQLite里key字段类似“city”“spicy_level”,适合存确定字段;value存具体值;status用来标记失效记录。

4.3 第二步:用大模型抽取记忆

抽取逻辑放在extract_memories函数里。核心是给LLM一个详细Prompt,让它判断对话里有没有值得存入的信息,并输出JSON。

def extract_memories(dialogue): prompt = f""" 你是用户记忆管理系统。请阅读下面的对话,抽取值得长期记住的用户信息。 判断标准: 1. 用户主动表达的偏好或忌口(如“不吃辣”“喜欢日料”) 2. 可用于未来个性化的客观事实(如“在北京工作”“养了一只柯基”) 3. 用户纠正过的信息(如“不是A,是B”) 只抽取以上三类,其余不要。请以JSON数组返回,格式: [{{"type": "preference|fact", "content": "记忆内容", "importance": 0.0-1.0}}] 如果没有值得记忆的内容,返回空数组 []。 对话: {dialogue} """ resp = openai_client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.2, ) content = resp.choices[0].message.content try: memories = json.loads(content) return memories if isinstance(memories, list) else [] except json.JSONDecodeError: return []

这个Prompt里我特别强调了“判断标准”,而且把“纠正过的信息”单独列了一条。根据实际测试,这样抽取出来的记忆质量明显高于泛泛地让模型“提取所有重要信息”。如果你用的是支持Function Calling的模型,建议改成结构化输出,稳定性会更好。

4.4 第三步:写入记忆

写入分两路:语义型记忆进Chroma,结构化事实进SQLite。

def store_memories(user_id, memories): for m in memories: content = m["content"] mtype = m["type"] importance = m.get("importance", 0.6) now = datetime.utcnow().isoformat() # 去重:检查是否已有高度相似的记忆 results = memory_collection.query( query_texts=[content], n_results=1, where={"user_id": user_id} ) if results["distances"] and results["distances"][0]: if results["distances"][0][0] < 0.1: continue # 已存在,跳过避免重复 # 写入向量库 memory_collection.add( ids=[f"{user_id}-{now}-{len(memories)}"], documents=[content], metadatas=[{ "user_id": user_id, "type": mtype, "importance": importance, "created_at": now }] ) # 结构化偏好单独存SQLite for m in memories: if m["type"] == "fact" and m.get("structured_key"): key = m["structured_key"] value = m["content"] now = datetime.utcnow().isoformat() conn.execute( "INSERT INTO structured_memories (user_id, key, value, updated_at) VALUES (?, ?, ?, ?)", (user_id, key, value, now) ) conn.commit()

写入前先查一次相似度,距离很接近就跳过。这里阈值0.1是我调试出来的经验值,换算成相似度就是0.9以上视为重复。不同Embedding模型的默认距离度量不同,你需要根据自己的模型调这个值,可以用几组真实数据跑一下看看重复检测的效果。

4.5 第四步:记忆召回与Prompt融合

召回的关键是“结合用户当前问题检索”,而不是把所有记忆倒出来。

def recall_memories(user_id, query_text, top_k=5): # 先从向量库召回语义记忆 results = memory_collection.query( query_texts=[query_text], n_results=top_k, where={"user_id": user_id} ) semantic_memories = results["documents"][0] if results["documents"] else [] # 再从SQLite读取结构化事实 cursor = conn.execute( "SELECT key, value FROM structured_memories WHERE user_id = ? AND status = 'active'", (user_id,) ) structured_memories = [f"{key}: {value}" for key, value in cursor.fetchall()] combined = structured_memories + semantic_memories return list(dict.fromkeys(combined)) # 简单去重 def build_system_prompt(user_id, query): memories = recall_memories(user_id, query) memory_text = "\n".join(f"- {m}" for m in memories) system_prompt = f""" 你是一个贴心的美食推荐助手。请结合用户的偏好回答问题。 【关于用户的记忆】 {memory_text} 如果记忆信息不足,请主动询问用户偏好。 """ return system_prompt

召回策略中有一个小技巧:把SQLite里的结构化记忆全部取出来,是因为这类字段量通常不大,全量读取成本低,而且保证“城市”“口味”这类关键数值不会因为语义检索不到而缺失。向量记忆只取Top-K,避免噪音。

4.6 第五步:组装成完整对话流程

最后把上面这些模块串起来。

def chat_with_memory(user_id, user_input): # 1. 先抽取新记忆 history = current_history.get(user_id, []) dialogue_text = "\n".join(history[-6:]) + f"\n用户: {user_input}" new_memories = extract_memories(dialogue_text) if new_memories: store_memories(user_id, new_memories) # 2. 召回记忆,构建系统提示 system_prompt = build_system_prompt(user_id, user_input) # 3. 调用LLM回答 resp = openai_client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_input} ] ) answer = resp.choices[0].message.content # 4. 更新历史 current_history.setdefault(user_id, []).append(f"用户: {user_input}") current_history.append(f"助手: {answer}") return answer

到这里,一个带记忆的Agent闭环已经跑通了。用户第一次说“我喜欢日料,千万别放香菜”,系统会抽取并存储这两条记忆;下一次说“帮我推荐餐厅”,召回模块会命中这两条,系统提示里就会出现“用户喜欢日料、不吃香菜”,Agent自然会在推荐时避开雷区。

我建议你按这个流程先跑通一遍,再去改细节。记忆系统最忌讳的是一上来就搞复杂架构,先有主干再优化分支,比什么都强。

5. 踩坑实录:记忆功能的常见问题与排查思路

5.1 检索不到该有的记忆

最典型的场景是用户说了自己的偏好,第二天再问相关问题时,Agent仿佛失忆。排查顺序我一般是这样的:

第一步,检查抽取环节有没有成功。很多情况下不是召回的问题,是压根没写入。把抽取模块单独跑一遍,看看LLM有没有输出JSON,有没有因为格式解析失败被丢弃。第二步,检查写入时去重逻辑是不是误判了重复。有几次我的相似度阈值设得太宽松,导致新信息被当成旧信息跳过了。第三步,检查召回时where条件里的user_id是不是传错了。多用户系统特别容易在传递链路里把ID弄丢。

如果上面都没问题,那就是Embedding本身召回效果不好。你可以把记忆内容直接打印出来看一眼,如果内容太泛、信息密度低,召回效果一定差。解决办法是优化抽取Prompt,让记忆写得更具体、更可检索。

5.2 记忆久了Prompt塞不下

记忆系统跑久了,SQLite里的结构化字段越来越多,向量库里的Top-K虽然固定但每条很长,拼接起来还是会让System Prompt膨胀。

我的应对策略是分级处理。高优记忆(比如用户的忌口)无条件进Prompt,低优记忆(比如三个月前的一次闲聊)只有在任务相关时才召回。另外给记忆加上“有效性窗口”,例如一次性的临时任务相关记忆,设置7天有效期,过期自动降权或删除。对于用户主动设置的“常驻偏好”,才允许长期保留。

5.3 模型把错误记忆当真理

这是记忆系统最隐蔽的坑。用户的记忆是被LLM“提炼”过的,提炼就有机会出错。比如用户说“我最近在减肥”,模型可能抽取成“用户不吃甜食”,这一字之差就会导致后续推荐完全偏离。

规避方法有这么几个:一是抽取Prompt里明确要求“不要做推断,只记录用户明确表达的信息”;二是对模型推断出来的内容打上“推测”标签,让回答模型明白这不是铁板钉钉的事实;三是定期让模型做“记忆一致性检查”,把冲突的记忆挑出来人工确认。你可以每周跑一次扫描,把矛盾记录列个清单交给运营人员处理。

5.4 用户隐私与记忆管理

只要你开始存用户数据,隐私问题就绕不开。最基本的功能够三件:查询、导出、删除。用户问“你记了我什么”,你得能展示;用户要求删除,你得能从向量库和SQLite里同时清掉。忘了任何一处,你都会在合规上栽跟头。

另外一个容易被忽视的点是:向量库里删除需要按ID精确删。Chroma的delete方法需要传入id列表,所以你在写入时就要设计好ID生成规则,确保能根据user_id反查出所有关联ID。我用的是{user_id}-{timestamp}-{seq}这种格式,删除时按前缀匹配明显不靠谱,所以我在meterdata里也存了user_id,配合where条件可以批量查出来再删。

5.5 记忆问题排查速查表

现象可能原因排查手段
明明说过却记不住抽取失败/去重误判/召回过滤太严先单独跑抽取,再看写入日志,最后看召回结果
推荐的还是用户讨厌的结构化记忆缺失或状态失效查SQLite的status字段
记忆里冲突信息太多没有做覆盖更新增加“新信息覆盖旧信息”逻辑
Prompt太长低价值记忆太多做分级召回和有效期管理
删除用户失败只删了结构化,没删向量两个库都要删,vector按元数据删

排查记忆问题,本质上是理清一条链路:抽取 → 写入 → 召回 → 生成。哪一环断了,症状都会不同。只要把每一环的日志打清楚,问题定位就快得多。

这套记忆系统我还在持续迭代。目前比较看好的方向是给记忆加一个“认知图谱”:不再只是存一条条孤立的记忆,而是把这些记忆连成网络,比如“用户A → 喜欢 → 日料”,“日料 → 属于 → 美食”,这样Agent推理时能做的关联就远不止字符串匹配了。不过在把基础记忆能力做扎实之前,先别急着上图谱,底子不牢,图再漂亮也白搭。

最后分享一个我自己的体会:真正好的记忆系统,不是什么都记住,而是知道什么该记、什么该忘。每次调记忆模块,我都会想起《攻壳机动队》那句台词——记忆本身就是一件很主观的事情。你替用户记住的每一条信息,都在悄悄定义你理解他的方式。设计记忆的时候,保持克制,远比一味堆功能重要。

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

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

立即咨询