三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Agent记忆系统设计:短期上下文与长期外部记忆的协同实践

Agent记忆系统设计:短期上下文与长期外部记忆的协同实践

1. 记忆系统:Agent的“大脑”与“笔记本”

最近在社区里看到不少朋友在讨论Agent开发,尤其是关于记忆模块的设计,经常能听到这样的困惑:“我的Agent怎么聊着聊着就把之前说过的话给忘了?”或者“为什么Function Call的结果Agent处理不了,对话直接卡住了?”这背后,其实都指向了同一个核心问题:Agent的记忆系统设计

如果把Agent比作一个智能体,那么它的“大脑”就是处理当前对话的短期上下文,而它的“笔记本”就是用来记录重要信息、供未来查阅的长期外部记忆。一个只会用“大脑”思考,从不做笔记的Agent,就像我们人类一样,只能处理眼前有限的几件事,一旦信息量稍大或者对话轮次一多,就会“失忆”。而一个设计良好的记忆系统,能让Agent拥有持续学习和进化的能力,真正实现多轮、复杂、有状态的交互。

在实战中,我发现很多初涉Agent开发的团队,最容易踩的坑就是混淆了“短期上下文”和“长期外部记忆”的边界,或者干脆只用了前者。结果就是Agent的表现极不稳定,时灵时不灵。今天,我们就来彻底拆解Agent的记忆系统,从原理到实践,讲清楚短期上下文和长期外部记忆到底该怎么设计、怎么用,以及如何避免那些让你对话“当场死机”的经典陷阱。

2. 短期上下文:Agent的“工作记忆区”

短期上下文,在技术实现上通常指的就是我们传给大语言模型(LLM)的那段Prompt,里面包含了系统指令、用户当前的问题、以及最近几轮的对话历史。你可以把它想象成Agent的“工作记忆区”或“大脑的缓存”,所有即时的思考、推理和决策都发生在这里。

2.1 短期上下文的核心作用与工作原理

它的核心作用有两个:一是提供决策依据,让LLM基于当前的对话历史和指令来生成回复;二是传递执行状态,特别是Function Call(函数调用)的结果必须及时回填到这里。

这里就引出了一个至关重要的原则,也是很多新手会忽略的致命点:Function Call的“执行结果”必须放进短期上下文,否则本轮对话会当场死机。

为什么?我们来模拟一下流程:

  1. LLM分析用户问题,发现需要调用一个外部函数(比如查询天气)。
  2. LLM生成一个结构化的Function Call请求(例如{“name”: “get_weather”, “arguments”: {“city”: “北京”}})。
  3. 你的程序执行这个函数,拿到了结果(例如{“city”: “北京”, “weather”: “晴”, “temperature”: “25°C”})。
  4. 关键步骤:你必须把这个执行结果,作为一条新的消息(通常是role: “function”)追加到当前的对话上下文中。
  5. 然后,将包含了这条function结果消息的完整上下文,再次发送给LLM。
  6. LLM看到了函数执行的结果,才能基于这个结果组织最终的自然语言回复给用户。

如果在第4步遗漏了,LLM在下一轮生成时,根本不知道函数调用已经发生以及结果是什么,它可能会:

  • 完全忽略之前要调用函数这回事,给出一个无关的回复。
  • 或者,因为它“记得”自己发出了调用指令但没看到结果,陷入逻辑混乱,生成无意义的输出。
  • 更常见的是,在需要依赖函数结果才能继续的对话流中,对话直接无法推进,看起来就像“死机”了。

所以,短期上下文是一个有状态的、滚动的窗口。它的大小受限于LLM的上下文长度(如4K、8K、16K、128K tokens)。当对话轮次增加,超出这个窗口时,最早的历史就会被“遗忘”(从技术上讲是被截断)。

2.2 短期上下文的管理策略与常见陷阱

管理短期上下文,不仅仅是简单地把所有历史对话堆进去。你需要策略:

1. 摘要压缩策略:当对话历史很长时,盲目截断会丢失关键信息。更好的做法是进行摘要压缩。例如,每经过N轮对话,或者当上下文长度接近阈值时,调用LLM本身对之前的对话历史生成一个简洁的摘要。然后,用这个摘要替换掉大段的原始历史,再继续新的对话。这样,既保留了核心信息,又节省了宝贵的token。

2. 关键信息提取策略:另一种思路是,在对话过程中,主动识别并提取关键实体和信息(如用户提到的项目名、日期、特定需求等),将这些结构化信息单独存储(这其实已经过渡到长期记忆了),而在上下文中只保留最近几轮最“鲜活”的对话。

3. 一个实战中的大坑:角色(Role)混淆在构建上下文消息列表时,消息的角色(role)至关重要。通常有system,user,assistant,function。一个常见的错误是把所有非用户输入都塞进assistant角色。请务必遵守:

  • system: 定义Agent的初始指令和身份,通常只在对话开始时出现一次。
  • user: 用户说的话。
  • assistant: Agent的话(即LLM生成的回复文本)。
  • function:Function Call执行后返回的结果

把function结果错放在assistant里,可能会干扰LLM对对话结构的理解。正确的消息流看起来是这样的:

[ {"role": "system", "content": "你是一个天气助手..."}, {"role": "user", "content": "北京今天天气怎么样?"}, {"role": "assistant", "content": null, "function_call": {"name": "get_weather", "arguments": {"city": "北京"}}}, {"role": "function", "name": "get_weather", "content": "{\"city\": \"北京\", \"weather\": \"晴\", \"temperature\": \"25°C\"}"}, {"role": "assistant", "content": "北京今天天气晴朗,气温25摄氏度,非常适合外出。"} ]

短期上下文是Agent实时思考的舞台,但它容量有限且不持久。要构建真正有“记忆力”的Agent,我们必须引入长期外部记忆。

3. 长期外部记忆:Agent的“知识库”与“经验簿”

如果说短期上下文是大脑的缓存,那么长期外部记忆就是大脑皮层里的长期记忆,外加一个随身携带的笔记本和资料库。它的核心目的是跨越对话会话(Session)持久化存储信息,并在未来的交互中快速、准确地检索出来。

3.1 为什么需要长期记忆?

  1. 突破上下文长度限制:LLM的上下文再长(比如128K),也有上限,且全部放在上下文里推理成本极高(更贵、更慢)。长期记忆可以将海量信息存储在外部,按需取用。
  2. 实现个性化与持续学习:记住用户的偏好(“我不喜欢咖啡”)、历史任务细节(“上周你帮我订的酒店是XX”)、以及Agent自身积累的经验(“上次用方法A解决这个问题失败了”)。
  3. 支持复杂、多步骤任务:对于一个需要多天、多次交互才能完成的项目,Agent必须能把中间状态、已收集的信息可靠地保存下来,下次接着干。

3.2 长期记忆系统的核心组件

一个完整的长期记忆系统通常包含三个部分:

1. 记忆存储器:这是物理存储的地方。选择很多:

  • 向量数据库(主流选择):如 Pinecone、Chroma、Weaviate、Qdrant,以及国内的一些云服务。它们擅长存储文本的向量嵌入(Embedding),并做相似性搜索。适合存储“非结构化”的经验、知识片段、对话摘要。
  • 传统数据库:如 SQLite、PostgreSQL、MySQL。适合存储“结构化”的信息,比如用户配置、订单号、任务状态等。
  • 简单文件存储:如 JSON 文件。适用于原型验证或极其简单的场景,不推荐生产环境。

2. 记忆读写器:负责决定什么该记以什么格式记,以及什么时候去读

  • 写策略:不是用户说的每句话都要记。常见的触发策略包括:检测到用户表达了明确偏好(“我住在上海”);完成了一个重要任务节点;对话中产生了值得总结的结论。写入的内容通常需要经过清洗和结构化,比如生成一个包含“时间戳”、“实体”、“摘要”、“类型”等字段的记忆对象。
  • 读策略(检索):当新对话开始时,或对话中提及某些关键词时,需要从长期记忆中召回相关信息。最常用的方法是向量相似性检索:将当前用户的问题或对话上下文编码成向量,去向量数据库中搜索最相关的N条记忆。也可以结合关键词过滤(元数据过滤)来提升精度。

3. 记忆处理器:在检索到相关记忆后,直接把这些记忆文本塞进上下文可能不够高效或准确。处理器的作用是对记忆进行加工,例如:

  • 相关性排序与过滤:剔除相关性分数过低的记忆。
  • 摘要与融合:如果检索出多条相似记忆,可以合并或摘要成一条。
  • 格式化:将记忆转换成适合插入LLM上下文的自然语言描述,例如:“根据我们之前的对话,我记得您更喜欢窗口的座位,并且对花生过敏。”

3.3 搭建长期记忆的实战步骤

假设我们使用 Chroma(轻量级,易于本地部署)作为向量数据库,为Agent添加一个“记住用户喜好”的长期记忆功能。

步骤1:设计记忆结构首先定义一条记忆长什么样。这没有固定标准,但一个好的结构包含:

{ “id”: “unique_id”, “content”: “用户明确表示他最喜欢的颜色是蓝色。”, # 记忆的文本内容 “embedding”: [0.12, -0.45, …], # 由文本编码成的向量,由数据库管理 “metadata”: { “user_id”: “user_123”, “type”: “preference”, # 记忆类型:preference/fact/task_context等 “entity”: “color”, # 关联的实体 “timestamp”: “2023-10-27T10:00:00Z”, “source_session”: “session_abc” } }

步骤2:实现记忆写入在对话过程中,监听或分析消息,触发写入。

import chromadb from sentence_transformers import SentenceTransformer # 初始化 client = chromadb.PersistentClient(path=“./memory_db”) collection = client.get_or_create_collection(name=“user_memories”) embed_model = SentenceTransformer(‘all-MiniLM-L6-v2’) # 一个轻量级的嵌入模型 def save_memory(user_id, content, memory_type, entity): # 生成嵌入向量 embedding = embed_model.encode(content).tolist() # 准备元数据 metadata = { “user_id”: user_id, “type”: memory_type, “entity”: entity, “timestamp”: datetime.now().isoformat() } # 存入Chroma collection.add( documents=[content], embeddings=[embedding], metadatas=[metadata], ids=[f”{user_id}_{int(time.time())}”] # 简单生成ID )

步骤3:实现记忆检索当新对话开始或进行中,检索相关记忆。

def retrieve_memories(user_id, query, n_results=3): # 将查询文本转换为向量 query_embedding = embed_model.encode(query).tolist() # 从该用户的记忆中检索,增加元数据过滤提高精度 results = collection.query( query_embeddings=[query_embedding], n_results=n_results, where={“user_id”: user_id} # 只检索当前用户的记忆 ) # 处理结果 retrieved_memories = [] if results[‘documents’]: for doc, meta in zip(results[‘documents’][0], results[‘metadatas’][0]): retrieved_memories.append({ “content”: doc, “metadata”: meta, “distance”: results[‘distances’][0][results[‘documents’][0].index(doc)] }) # 可以在这里根据distance(距离,越小越相关)进行过滤 return retrieved_memories

步骤4:将记忆整合进上下文检索到的记忆需要被巧妙地插入到发给LLM的上下文中。通常放在system指令之后,或者最近的历史对话之前。

# 在构建LLM上下文时 context_messages = [] context_messages.append({“role”: “system”, “content”: f”你是助手。以下是你之前了解到的关于用户的信息:\n{formatted_memories}”}) # … 追加历史对话和当前问题

这里的formatted_memories就是将检索到的多条记忆内容,用自然语言串联起来,例如:“用户曾说过他喜欢蓝色。用户还提到他对花生过敏。”

3.4 长期记忆的挑战与优化

  • 记忆冲突与更新:如果用户说“我喜欢蓝色”,后来又说“我现在更喜欢绿色了”。如何处理?简单的方案是用新记忆覆盖旧记忆(通过元数据关联同一entity并删除旧的)。更复杂的方案可以引入记忆的“强度”或“新鲜度”衰减模型。
  • 检索精度与召回率:向量检索并不总是精准。优化方法包括:使用更好的嵌入模型;在元数据中添加更丰富的标签;采用混合检索(向量+关键词);对检索结果进行LLM二次重排序。
  • 记忆的抽象与泛化:记住“用户喜欢蓝色”是具体的,能否抽象出“用户对颜色有明确偏好”这条更高阶的记忆?这需要更高级的认知架构,目前通常通过定期用LLM总结对话来实现。

4. 短期与长期的协同:构建完整的记忆流

短期上下文和长期外部记忆不是孤立的,它们必须协同工作,形成一个闭环的记忆流。一个典型的工作流程如下:

  1. 会话开始:用户发起新对话。Agent首先从长期记忆中检索与该用户相关的记忆(如偏好、未完成任务),并将其作为背景信息插入短期上下文的开头。
  2. 对话进行:Agent基于短期上下文(包含系统指令、长期记忆背景、近期对话)与用户交互。期间可能触发Function Call,其结果必须立刻回填到短期上下文。
  3. 记忆触发:在对话过程中,监控是否产生了值得长期存储的信息。这可以通过规则(检测到“记住”、“我喜欢”等关键词),或通过一个小型LLM分类器来判断。
  4. 记忆固化:当触发记忆点时,将当前短期上下文中相关的片段(可能是经过LLM摘要的)进行结构化处理,生成记忆对象,存入长期记忆库(向量数据库)。
  5. 上下文管理:同时,监控短期上下文的长度。如果接近模型限制,可以对较早的、不那么重要的对话历史进行摘要压缩,或将确定已固化的信息从上下文中移除,只保留一个引用指针,以节省空间。
  6. 会话结束/暂停:在对话结束时,可以启动一个更全面的总结过程,将整个会话的要点和结论写入长期记忆。

这个流程确保了Agent既能关注当下,又能积累过去,并为未来做好准备。

5. 避坑指南:记忆系统开发中的典型问题

在开发Agent记忆系统时,我踩过不少坑,这里分享几个最典型的:

坑1:混淆记忆与对话历史把整个对话历史日志直接当长期记忆存进向量库。这会导致检索出大量无关的闲聊片段,噪音极大。长期记忆应该是提炼后的、高价值的信息结晶,而不是原始日志。

坑2:忽视记忆的时效性与冲突没有设计记忆更新机制。比如用户地址变了,但Agent检索到的还是旧地址。解决方案是为记忆添加版本管理或“最后更新时间”字段,并在检索时优先返回最新的,或者在写入新记忆时主动清理旧的、冲突的记忆。

坑3:检索到的记忆直接“硬塞”进上下文如果检索到5条记忆,不分青红皂白全部以原始文本形式塞进系统提示,可能会占用大量token,甚至干扰主要任务。需要对记忆进行优先级排序、摘要和格式化。例如:“关于您的颜色偏好(相关度85%):您喜欢蓝色。关于您的饮食禁忌(相关度90%):您对花生过敏。”

坑4:向量嵌入模型选型不当使用一个在通用语料上训练的嵌入模型来编码非常垂直领域的对话(比如医疗、法律),可能导致相似性搜索效果很差。如果条件允许,可以考虑用领域数据对嵌入模型进行微调,或者选择在相关领域表现更好的模型。

坑5:忘记为记忆添加访问控制在Multi-Agent(多智能体)系统中,不同Agent的记忆可能需要隔离。或者在单用户场景下,要确保用户A的记忆绝不会被用户B检索到。这需要在存储和检索时严格加入user_idagent_id等元数据过滤条件。

记忆系统是Agent从“玩具”走向“工具”,从“单次对话”走向“持续服务”的关键桥梁。理解短期上下文与长期外部记忆的分工与协作,是设计出强大、实用Agent的必修课。它没有一成不变的方案,需要你根据具体的应用场景、性能要求和资源约束,不断地进行权衡、设计和迭代。

← 返回列表