1. 从“hindsight”说起为什么我们需要给Agent装上记忆“hindsight”这个词本身很有意思字面意思是“事后的洞察力”也就是我们常说的“后见之明”。放在LLM Agent的语境里它指向一个非常具体且棘手的问题Agent如何记住过去发生过的事情并在后续决策中真正用上这些经验。我接触过不少做Agent项目的团队大家一开始都信心满满觉得只要把LLM接上工具调用再套一个ReAct循环就能做出一个能自主完成复杂任务的智能体。但实际跑起来之后几乎所有人都会撞上同一堵墙——Agent没有记忆。它每次对话都像失忆一样用户上周告诉它的偏好、上次任务失败的原因、某个工具在特定场景下会返回异常这些信息全部丢失。结果就是Agent永远在“重新开始”永远在犯同样的错误。这就是hindsight要解决的核心问题。它不是一个具体的开源项目名称而是一类能力的统称让Agent具备对历史交互的回顾、提炼和复用能力。你可以把它理解成Agent的“长期记忆系统”但它比简单的对话历史存储要复杂得多。它需要决定什么值得记、怎么存、什么时候取出来用、用完怎么更新。这几个环节每一个都有坑。这篇文章适合谁看如果你正在做LLM Agent相关的开发或者你已经在用MCP协议搭建工具链又或者你只是对“Agent记忆”这个概念感兴趣想了解落地细节那接下来的内容应该能给你不少可参考的东西。我会从整体设计思路讲到具体实现再把我自己踩过的坑和排查经验一并分享出来。2. Agent记忆系统的整体设计与核心思路拆解2.1 为什么简单的向量数据库不够用很多人一提到Agent记忆第一反应就是“上个向量数据库不就行了”。我一开始也是这么想的拿Chroma或者Milvus把对话历史embedding一下存进去需要的时候做相似度检索。但实际用下来发现这套方案在Agent场景下有几个致命问题。第一个问题是检索粒度失控。向量检索返回的是一段一段的文本块但Agent需要的是结构化的信息。比如用户说“我不喜欢在周五下午安排会议”向量检索可能返回整段对话但Agent真正需要的是“用户偏好周五下午不排会”这个结构化事实。你让LLM每次从一堆文本块里自己提取不仅浪费token还容易提取错。第二个问题是没有优先级和时效性。所有记忆平等地存在向量库里但实际上一周前的临时任务细节和用户的核心偏好重要性完全不一样。向量相似度只能衡量语义相关性衡量不了重要性。第三个问题是写入策略缺失。什么信息值得存每轮对话都存吗那记忆库很快就爆炸了。只存用户明确说“记住这个”的内容那又漏掉太多隐含信息。所以hindsight这类记忆系统的设计核心不是“存什么”而是“怎么组织”和“怎么决策”。它更像是一个信息管理系统而不是一个存储系统。2.2 三层记忆架构的设计逻辑我目前采用的方案是三层记忆架构这个设计参考了认知科学里人类记忆的分类方式但在工程上做了简化。三层分别是工作记忆Working Memory、情景记忆Episodic Memory、语义记忆Semantic Memory。工作记忆就是当前对话轮次内的上下文存在内存里对话结束就丢弃。这部分不需要持久化就是LLM的context window里直接维护的内容。情景记忆是具体的事件记录比如“2024年3月15日用户要求查询北京天气Agent调用了weather API返回晴天25度”。语义记忆是从情景记忆中提炼出来的抽象知识比如“用户经常查询北京天气可能居住在北京”或者“weather API在查询中国城市时需要用中文城市名”。为什么要分三层因为不同层的读写频率、存储介质、检索方式完全不一样。工作记忆追求速度情景记忆追求完整语义记忆追求精炼。混在一起存检索效率会急剧下降。注意三层架构不是必须的。如果你的Agent场景很简单比如只是客服问答可能两层就够了。架构复杂度要匹配业务复杂度不要为了架构而架构。2.3 记忆的写入决策什么值得记这是整个系统里最容易被忽视但最关键的一环。我的做法是引入一个记忆评估器它本身也是用LLM实现的但prompt经过精心设计。每轮对话结束后评估器会判断这轮交互是否包含值得写入长期记忆的信息。评估器主要看几个维度新颖性这个信息之前是否已经存在、重要性对后续任务是否有影响、稳定性这个信息是临时的还是长期的。比如用户说“帮我查一下今天的天气”这是临时需求不需要写入长期记忆。但用户说“我以后查天气都默认用摄氏度”这就是一个稳定的偏好必须记下来。这里有个实操技巧评估器的prompt里一定要给正反例。我一开始只写了判断标准结果LLM把什么都往里存记忆库很快就臃肿了。后来加了几个few-shot例子比如“用户说‘你好’→不存”、“用户说‘我对花生过敏’→存”准确率立刻上来了。2.4 记忆的检索策略什么时候取出来用检索策略的核心是在正确的时间把正确的记忆注入到Agent的context里。我的做法是在每次Agent准备调用工具或者生成回复之前先做一次记忆检索。检索的query不是简单的用户输入而是结合了当前任务状态的复合query。具体来说检索query由三部分组成当前用户输入当前任务类型最近几轮对话摘要。这样检索出来的记忆既相关又不会太发散。比如用户说“帮我订个餐厅”当前任务类型是“预订”最近对话摘要是“用户在讨论周末聚餐”那检索出来的记忆可能包括“用户偏好川菜”、“用户之前提过周六晚上有空”等。检索数量也要控制。我一般取top-5最多不超过8条。太多记忆注入会挤占context window而且LLM在处理大量记忆时反而容易忽略关键信息。这跟人开会一样给你一堆参考资料你反而抓不住重点。3. 核心细节解析与实操要点3.1 记忆的数据结构设计记忆存什么格式直接决定了后续检索和更新的效率。我试过几种方案最后稳定下来的结构是这样的{ memory_id: uuid, type: episodic | semantic, content: 用户偏好周五下午不安排会议, embedding: [0.123, 0.456, ...], metadata: { created_at: 2024-03-15T10:30:00Z, last_accessed: 2024-03-20T14:00:00Z, access_count: 5, source: conversation_123, confidence: 0.92, tags: [preference, schedule] } }这个结构里content是给LLM看的自然语言描述embedding是给检索用的向量metadata里的字段各有用途。access_count和last_accessed用来做记忆的衰减和淘汰confidence表示这条记忆的可信度有些信息是用户随口说的可信度就低一些tags用来做分类过滤。提示content字段的写法很讲究。不要存原始对话要存提炼后的事实。原始对话“用户我周五下午一般都有会别给我排东西”应该转成“用户偏好周五下午不安排会议”。前者检索出来LLM还要再理解一遍后者直接就能用。3.2 记忆的更新与冲突处理记忆不是只写不更新的。用户上周说“我喜欢川菜”这周说“我最近吃不了辣”这两条记忆就冲突了。怎么处理我的方案是新记忆覆盖旧记忆但保留历史版本。具体操作是写入新记忆时先检索是否有语义相似的旧记忆。如果有比较时间戳和confidence新记忆时间更新且confidence不低于旧记忆时将旧记忆标记为superseded新记忆正常写入。检索时默认只返回非superseded的记忆。但这里有个坑有些冲突不是真正的冲突而是补充。比如“用户喜欢川菜”和“用户最近吃不了辣”并不矛盾只是时效性不同。所以我在metadata里加了一个valid_until字段对于临时性信息设置过期时间。过期后自动降权但不删除。3.3 记忆衰减与淘汰机制记忆库不能无限增长。我的淘汰策略是基于访问频率和时间的加权评分。每条记忆有一个分数计算公式大致是score access_count * 0.4 recency_score * 0.3 confidence * 0.3其中recency_score是最近一次访问距今时间的衰减函数我用的是指数衰减半衰期设为7天。分数低于阈值的记忆会被归档到冷存储不再参与常规检索但保留以备审计。这个机制的效果是常用的记忆会一直保留偶尔用到的会慢慢降权从来不用的最终被归档。实测下来一个中等复杂度的Agent运行一个月活跃记忆数量稳定在200-500条左右不会无限膨胀。3.4 与MCP协议的集成方式MCPModel Context Protocol是现在Agent工具调用的事实标准之一。把记忆系统做成MCP Server是一个很自然的选择。这样做的好处是任何支持MCP的Agent框架都可以直接接入不需要改Agent本身的代码。我实现的记忆MCP Server暴露了三个toolmemory_write、memory_search、memory_update。Agent在需要的时候调用这些tool就像调用其他MCP工具一样。Docker部署也很简单一个容器跑Server一个容器跑向量数据库通过Docker network互联。version: 3.8 services: memory-server: build: ./memory-server ports: - 8080:8080 environment: - VECTOR_DB_URLhttp://vector-db:6333 depends_on: - vector-db vector-db: image: qdrant/qdrant:latest volumes: - ./data:/qdrant/storage这个compose文件是我实际在用的Qdrant做向量存储memory-server做业务逻辑。两个容器通过内部网络通信对外只暴露memory-server的端口。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先说环境。我是在Ubuntu 22.04上做的开发和部署Windows用户建议用WSL2Mac用户直接跑就行。Docker和Docker Compose是必须的版本不要太老Docker 24以上、Compose v2以上。安装Docker的步骤我就不赘述了网上教程很多。重点说一下Docker Desktop在Windows上常见的启动问题。如果你遇到“Virtualization support not detected”这个报错大概率是BIOS里的虚拟化支持没开。重启进BIOS找到Intel VT-x或者AMD-V设为Enabled。如果还不行检查一下Hyper-V和WSL2是否冲突有时候两个都开着会打架。Python环境我用的3.11主要依赖这几个包pip install fastapi uvicorn qdrant-client openai tiktokenqdrant-client是Qdrant的Python SDKtiktoken用来算token数方便控制记忆注入的量。OpenAI的SDK用来调LLM做记忆评估和提炼如果你用其他模型换成对应的SDK就行。4.2 记忆写入的完整流程实现写入流程分四步接收对话 → 评估是否值得记 → 提炼结构化记忆 → 写入存储。第一步接收对话很简单MCP tool被调用时传入对话内容就行。关键是第二步的评估。我的评估器prompt大致是这样的你是一个记忆评估器。判断以下对话内容是否包含值得长期记忆的信息。 值得记忆的信息包括用户偏好、重要事实、任务关键上下文、工具使用经验。 不值得记忆的信息包括寒暄、临时查询、已经过时的信息。 对话内容{conversation} 请输出JSON格式{should_remember: true/false, reason: ...}第三步提炼结构化记忆我用另一个prompt来做将以下对话内容提炼为一条简洁的记忆事实。 要求用第三人称陈述不超过50字包含关键实体和关系。 对话内容{conversation} 输出格式{content: ..., type: episodic/semantic, tags: [...]}第四步写入Qdrant同时把embedding也算好存进去。embedding我用的是OpenAI的text-embedding-3-small1536维性价比不错。实操心得评估和提炼可以合并成一次LLM调用省token也省时间。我一开始分开做后来发现合并后效果差不多但速度快了一倍。prompt里让LLM同时输出should_remember和content就行。4.3 记忆检索的注入时机与格式检索的触发时机很关键。我的做法是在Agent的system prompt里加一段动态内容每次Agent准备生成回复前先调用memory_search把返回的记忆格式化后插入system prompt。格式大概是这样## 相关历史记忆 1. [偏好] 用户偏好周五下午不安排会议置信度0.92 2. [事实] 用户对花生过敏置信度0.98 3. [经验] weather API查询中国城市需用中文城市名置信度0.85这个格式的好处是LLM一眼就能看懂每条记忆的类型和可信度在做决策时会自然考虑这些因素。我试过用JSON格式注入效果反而不好LLM对JSON里的信息敏感度不如自然语言列表。检索的query构造也有讲究。我一开始直接用用户输入做query发现检索出来的记忆经常不相关。后来改成用户输入 最近三轮对话的摘要 当前任务类型相关性明显提升。摘要用LLM生成任务类型从Agent的状态机里取。4.4 记忆更新的触发与执行更新有两个触发点定时任务和事件驱动。定时任务每天跑一次做记忆的衰减计算和归档。事件驱动是在写入新记忆时触发检查是否有冲突需要处理。冲突检测的逻辑是新记忆写入前先用新记忆的embedding做一次检索取top-3。如果某条旧记忆的相似度超过0.85就认为可能冲突。然后让LLM判断这两条记忆是否真的矛盾。如果矛盾按前面说的覆盖策略处理。def check_conflict(new_memory, existing_memories): for old in existing_memories: similarity cosine_sim(new_memory.embedding, old.embedding) if similarity 0.85: prompt f判断以下两条记忆是否矛盾\n新{new_memory.content}\n旧{old.content}\n输出矛盾/不矛盾 result llm.invoke(prompt) if 矛盾 in result: return old return None这个逻辑跑下来误判率大概在5%左右主要是LLM有时候会把补充信息误判为矛盾。后来我在prompt里加了“如果新记忆是旧记忆的补充或细化不算矛盾”这个说明误判率降到了2%以下。5. 常见问题与排查技巧实录5.1 记忆检索不相关怎么办这是最常见的问题。你明明存了相关记忆但检索的时候就是出不来。排查思路按优先级来先看embedding模型是否合适。如果你用的是通用embedding模型在特定领域比如医疗、法律可能效果不好。可以考虑用领域数据微调一个embedding模型或者换一个在该领域表现更好的模型。再看检索query的构造。前面说了直接用用户输入做query效果不好。试试加上任务类型和对话摘要。如果还不行可以试试多query检索就是用不同的query分别检索然后合并去重。最后看相似度阈值。Qdrant默认返回top-k但有些结果相似度很低注入了反而干扰LLM。我一般设一个0.7的阈值低于这个值的不返回。5.2 记忆库膨胀太快怎么控制如果你的记忆库一周就涨了几千条说明写入评估太宽松了。检查评估器的prompt是不是把太多临时信息判为值得记忆。另外可以加一个写入频率限制比如同一个session最多写入10条记忆超过的排队或者丢弃。还有一个技巧是记忆合并。如果多条记忆讲的是同一件事可以合并成一条。比如“用户喜欢川菜”、“用户喜欢火锅”、“用户喜欢麻辣烫”可以合并成“用户喜欢川菜和火锅类食物”。合并用LLM做定期跑一次。5.3 Docker网络不通导致记忆服务不可用这个坑我踩过好几次。表现是memory-server容器启动正常但Agent调用时报连接超时。排查步骤先docker exec进memory-server容器curl一下vector-db的地址看能不能通。如果不通检查两个容器是否在同一个Docker network里。docker network inspect看一下。如果网络通但服务不可用检查端口映射。Qdrant默认端口是6333memory-server连的时候要用容器名端口不是localhost。这个新手很容易搞错。还有一个隐蔽的问题Docker Desktop在Mac上有时会有网络延迟容器间通信偶尔超时。解决办法是给memory-server加一个重试机制或者把两个服务放到同一个容器里用supervisor管理。5.4 LLM评估器返回格式不稳定用LLM做评估器最头疼的就是它有时候不按格式返回。你让它输出JSON它给你输出一段解释文字。解决办法有几个一是用function calling。OpenAI的function calling可以强制LLM按schema输出稳定性高很多。二是加格式校验和重试。解析失败就重试重试三次还失败就跳过这条记忆。三是用更小的模型做评估。大模型虽然聪明但有时候“话多”小模型反而更听话。我试过用GPT-3.5-turbo做评估格式稳定性比GPT-4还好。5.5 常见问题速查表问题现象可能原因排查方法解决方案检索不到相关记忆embedding模型不匹配手动测试几条已知相关记忆的相似度换模型或微调记忆库增长过快写入评估太宽松检查评估器prompt和few-shot例子收紧标准加频率限制容器间连接超时Docker网络配置错误docker network inspect确保同网络用容器名通信LLM返回格式错误prompt不够明确打印原始返回内容用function calling或加重试记忆冲突误判冲突检测prompt不完善人工审核误判案例补充说明和反例检索结果太多top-k设置过大检查检索参数降低top-k加相似度阈值最后分享一个小技巧记忆系统的调试一定要有可视化界面。我一开始全靠日志排查问题很痛苦。后来用Streamlit搭了一个简单的管理页面可以浏览、搜索、手动编辑记忆效率提升巨大。这个页面不需要多好看能看能改就行。这个记忆系统我跑了大概三个月中间迭代了七八个版本从最开始简单的向量存储到现在三层架构加冲突处理踩的坑基本都在这了。后续还可以扩展的方向包括记忆的跨Agent共享、基于记忆的主动学习、记忆的可解释性分析等。不过那是另一个话题了先把基础打牢再说。