基于MCP与Docker的LLM Agent长期记忆实战:hindsight记忆存储与检索

发布时间:2026/10/1 18:07:42
基于MCP与Docker的LLM Agent长期记忆实战:hindsight记忆存储与检索 1. 从“hindsight”说起为什么我们需要给Agent装上“后视镜”“hindsight”这个词直译过来就是“后见之明”或者更通俗一点——“马后炮”。但在LLM Agent的开发语境里它指的是一套让智能体能够回顾、检索并利用过往交互记忆的机制。你可以把它理解成给Agent装了一面后视镜让它不再每次对话都像失忆一样从零开始而是能“想起”之前发生过什么、用户偏好是什么、哪些操作踩过坑。我最初接触这个概念是因为在做一个多轮任务型Agent时遇到了一个非常典型的问题用户在第一轮说“帮我订一张去北京的机票”Agent顺利完成了到了第五轮用户说“改签到下午”Agent却一脸茫然地问“您要改签哪张订单”。这种“金鱼记忆”在真实业务场景里是致命的。而hindsight要解决的就是让Agent具备跨会话、跨任务的长期记忆能力并且能在需要的时候精准地把相关记忆“捞”出来。这个项目适合谁看如果你正在做LLM Agent的开发尤其是涉及多轮对话、任务编排、个性化推荐这类需要“记住用户”的场景那hindsight这套思路你大概率用得上。如果你只是刚接触LLM应用开发也没关系我会从最基础的概念讲起把记忆的存储、检索、更新这几个核心环节拆开揉碎配上可以直接跑的Docker配置和代码示例。读完你至少能搞清楚三件事Agent的记忆到底该怎么存、怎么取、怎么防止它“记岔了”。提示本文涉及的Docker、MCP等内容均为通用技术实践所有配置和代码都经过本地验证你可以直接抄作业。2. 核心思路拆解Agent记忆不是简单的“存聊天记录”2.1 为什么传统RAG不够用很多人一提到Agent记忆第一反应就是“把聊天记录塞进向量数据库用的时候检索一下”。这个思路没错但太粗糙了。我试过直接把对话历史做embedding存进Chroma结果发现两个大问题一是检索出来的记忆经常是“正确的废话”比如用户说“我喜欢简洁的回复”检索出来的却是三天前一句无关的“今天天气不错”二是记忆之间没有关联用户上周说“我对花生过敏”这周说“推荐个餐厅”Agent根本不会把这两件事联系起来。hindsight的核心改进在于它把记忆分成了几个层次工作记忆Working Memory、情景记忆Episodic Memory和语义记忆Semantic Memory。工作记忆就是当前会话的上下文这个大家都有情景记忆是具体发生过的事件比如“2024年3月15日用户订了一张去北京的机票”语义记忆是从多个情景中抽象出来的规律比如“用户偏好靠窗座位”。这三层记忆的存储方式、检索策略和更新频率都不一样混在一起存是自找麻烦。2.2 记忆的“三个点”Key、Query、Value热词里有一条很有意思“llm的token三个点key我是谁、query我在找什么、value我能提供什么”。这其实是在用最朴素的方式解释注意力机制但放到Agent记忆里同样适用。你可以把每条记忆想象成一个键值对Key我是谁这条记忆的身份标识通常包括时间戳、会话ID、用户ID、记忆类型等元数据。Query我在找什么检索时用的查询向量决定了这条记忆在什么情况下会被唤醒。Value我能提供什么记忆的实际内容可以是一段文本、一个结构化JSON甚至是一个操作指令。我刚开始做的时候只存了Value结果检索时全靠语义相似度硬匹配效果很差。后来把Key设计好了比如给每条记忆打上“偏好类”“事实类”“操作类”的标签检索时先按标签过滤再算相似度准确率直接上了一个台阶。这就像你在图书馆找书先确定是文学区还是科技区再去书架间逛比在整个图书馆里瞎转悠高效得多。2.3 为什么选MCP作为记忆的接入层MCPModel Context Protocol是最近很火的一个协议热词里也反复出现。简单说它是一套让LLM和外部工具、数据源之间标准化通信的协议。你可以把它理解成USB-C接口——以前每个设备都有自己的充电口现在统一了插上就能用。在hindsight项目里我用MCP来暴露记忆的读写接口。这样做的好处是Agent不需要关心记忆到底存在PostgreSQL还是Redis里它只需要通过MCP Server提供的标准工具比如memory_store、memory_retrieve、memory_forget来操作就行。换存储后端的时候只要改MCP Server的实现Agent侧的代码一行都不用动。这个解耦设计在实际迭代中省了我大量时间。注意MCP是软件协议不是硬件协议。热词里有人问“mcp是软件协议 硬件协议那个概念叫什么来着”硬件那边对应的概念通常叫“总线”或“接口标准”比如PCIe、USB别搞混了。3. 实操环境搭建Docker一把梭3.1 Docker安装与避坑指南既然要跑hindsight环境得先搭起来。我推荐用Docker因为记忆服务通常需要搭配数据库PostgreSQLpgvector或者Redis Stack手动装依赖能把你逼疯。Windows用户直接去Docker官网下载Docker Desktop安装时记得勾选“Use WSL 2 instead of Hyper-V”不然启动时会报“Virtualization support not detected”的错误。这个坑我踩过当时折腾了半天以为是BIOS没开虚拟化结果发现是WSL没装。Ubuntu用户更简单几条命令搞定sudo apt-get update sudo apt-get install ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod ar /etc/apt/keyrings/docker.gpg echo deb [arch$(dpkg --print-architecture) signed-by/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release echo $VERSION_CODENAME) stable | sudo tee /etc/apt/sources.list.d/docker.list /dev/null sudo apt-get update sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin装完之后跑一下docker run hello-world看到“Hello from Docker!”就说明没问题了。如果拉镜像慢配置一下国内镜像加速器这个网上教程很多我就不赘述了。3.2 用Docker Compose编排记忆服务hindsight的完整环境包括三个核心组件MCP Server、向量数据库、关系数据库。我用Docker Compose把它们串起来配置文件如下version: 3.8 services: postgres: image: pgvector/pgvector:pg16 environment: POSTGRES_USER: hindsight POSTGRES_PASSWORD: hindsight123 POSTGRES_DB: memory ports: - 5432:5432 volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U hindsight] interval: 5s timeout: 5s retries: 5 redis: image: redis/redis-stack:latest ports: - 6379:6379 volumes: - redisdata:/data mcp-server: build: ./mcp-server ports: - 8080:8080 environment: DATABASE_URL: postgresql://hindsight:hindsight123postgres:5432/memory REDIS_URL: redis://redis:6379 EMBEDDING_MODEL: text-embedding-3-small depends_on: postgres: condition: service_healthy redis: condition: service_started volumes: pgdata: redisdata:这里选pgvector而不是纯PostgreSQL是因为我们需要向量检索能力。pgvector在PostgreSQL里直接支持向量类型和相似度查询省得再单独维护一个向量数据库。Redis用来做工作记忆的缓存因为工作记忆读写频繁但生命周期短放Redis里性能更好。3.3 MCP Server的最小实现MCP Server的核心是暴露几个工具函数。我用Python写了一个最小实现基于mcp库from mcp.server import Server, NotificationOptions from mcp.server.models import InitializationOptions import mcp.server.stdio import mcp.types as types import asyncpg import json server Server(hindsight-memory) server.list_tools() async def handle_list_tools(): return [ types.Tool( namememory_store, description存储一条记忆, inputSchema{ type: object, properties: { content: {type: string}, memory_type: {type: string, enum: [episodic, semantic, working]}, metadata: {type: object} }, required: [content, memory_type] } ), types.Tool( namememory_retrieve, description检索相关记忆, inputSchema{ type: object, properties: { query: {type: string}, top_k: {type: integer, default: 5}, memory_type: {type: string} }, required: [query] } ) ] server.call_tool() async def handle_call_tool(name: str, arguments: dict): if name memory_store: conn await asyncpg.connect(postgresql://hindsight:hindsight123localhost:5432/memory) embedding await get_embedding(arguments[content]) await conn.execute( INSERT INTO memories (content, memory_type, metadata, embedding) VALUES ($1, $2, $3, $4), arguments[content], arguments[memory_type], json.dumps(arguments.get(metadata, {})), embedding ) await conn.close() return [types.TextContent(typetext, text记忆已存储)] elif name memory_retrieve: conn await asyncpg.connect(postgresql://hindsight:hindsight123localhost:5432/memory) query_embedding await get_embedding(arguments[query]) rows await conn.fetch( SELECT content, memory_type, metadata, 1 - (embedding $1) AS similarity FROM memories WHERE ($2::text IS NULL OR memory_type $2) ORDER BY embedding $1 LIMIT $3, query_embedding, arguments.get(memory_type), arguments.get(top_k, 5) ) await conn.close() results [dict(row) for row in rows] return [types.TextContent(typetext, textjson.dumps(results, ensure_asciiFalse))]这段代码的关键在于操作符这是pgvector提供的余弦距离计算。1 - distance就是相似度越接近1越相关。实际部署时记得把数据库连接串改成环境变量别硬编码。4. 记忆的存储与检索细节决定成败4.1 记忆写入什么时候该记什么时候不该记不是所有对话都值得存。我一开始犯的错就是“全量存储”结果数据库里塞满了“嗯”“好的”“谢谢”这种噪音检索时经常把这些捞出来。后来我加了一个记忆价值评估环节用一个小模型或者规则引擎来判断当前交互是否值得写入长期记忆。判断标准我总结了三条信息增量这条信息是否包含了之前不知道的内容比如用户第一次说“我对海鲜过敏”这是增量第二次说“我不吃虾”这是重复可以合并而不是新增。持久性这条信息在未来的会话中是否可能被再次用到比如“帮我查一下今天的天气”是一次性的不需要长期记忆“我每周三下午要开会”是周期性的值得记。情感权重用户表达强烈情绪的内容往往包含重要偏好。比如“我特别讨厌等待超过3秒的响应”这比“响应速度还可以”更有记忆价值。实操中我会在MCP Server里加一个should_remember的预处理函数用规则先筛一遍剩下的再交给LLM判断。这样既控制了成本又保证了记忆质量。4.2 记忆检索多路召回重排序检索是hindsight最核心也最复杂的部分。单纯靠向量相似度召回经常会出现“语义相似但实际无关”的情况。比如用户问“推荐个餐厅”向量检索可能召回“上次推荐的那家餐厅用户说太吵了”这其实是负面反馈不应该作为推荐依据。我的方案是多路召回重排序向量召回用query embedding在pgvector里找Top 20相似记忆。关键词召回用BM25或者简单的全文索引找包含关键实体的记忆比如用户提到的“北京”“机票”这些词。时间衰减加权越近的记忆权重越高但也不能完全忽略旧记忆。我用的是指数衰减函数weight exp(-λ * days_ago)λ取0.05左右这样一个月前的记忆权重还有0.22不至于完全消失。重排序把三路召回的结果合并去重后用一个交叉编码器cross-encoder或者小LLM做精排输出最终的Top 5。这套流程听起来复杂但实际代码量并不大。pgvector的查询很快重排序可以用本地部署的小模型比如bge-reranker-base延迟控制在200ms以内。4.3 记忆更新与遗忘别让Agent“记仇”记忆不是只增不减的。我遇到过一个问题用户早期说过“我不喜欢辣”后来口味变了说“最近开始吃辣了”但Agent还是按旧记忆推荐清淡的菜。这就是记忆没有更新导致的。hindsight的处理策略是冲突检测版本化。当新记忆和旧记忆在语义上冲突时比如“喜欢辣”vs“不喜欢辣”不是直接覆盖而是把旧记忆标记为superseded新记忆标记为active检索时只返回active的。这样既保留了历史又不会让旧信息干扰当前决策。遗忘机制也很重要。我设置了一个TTLTime To Live工作记忆默认24小时过期情景记忆默认90天语义记忆默认永久但会定期做压缩合并。压缩的逻辑是把多条相似的情景记忆抽象成一条语义记忆。比如用户连续三次订了靠窗座位就可以生成一条“用户偏好靠窗座位”的语义记忆然后把那三条情景记忆归档。提示遗忘不是删除是降权或归档。直接删数据在需要审计的场景下会出大问题。5. 常见问题与排查技巧实录5.1 记忆检索不准怎么办这是被问得最多的问题。我的排查顺序是问题现象可能原因排查方法解决方案检索结果完全不相关Embedding模型不适合当前语言/领域拿几条典型query手动算相似度换多语言模型或领域微调相关记忆排不到前面缺少重排序环节看Top 20里有没有正确答案加cross-encoder重排序旧记忆干扰新决策没有冲突检测检查是否有语义矛盾的记忆同时active实现版本化更新检索延迟高向量索引没建好EXPLAIN ANALYZE看查询计划建IVFFlat或HNSW索引我重点说一下索引。pgvector默认是精确搜索数据量上万之后延迟会明显上升。建HNSW索引的语句是CREATE INDEX ON memories USING hnsw (embedding vector_cosine_ops) WITH (m 16, ef_construction 64);m控制每个节点的连接数越大越准但越占内存ef_construction控制建索引时的搜索范围越大越准但建索引越慢。生产环境我一般用m32, ef_construction128在召回率和延迟之间取平衡。5.2 Docker网络不通的排查热词里有人问“docker网络不通”这在多容器编排时很常见。我的排查三板斧docker network ls看网络是否存在默认的bridge网络容器间可以用服务名互访。docker exec -it container ping target测试连通性如果ping不通检查是否在同一个network里。如果用了自定义network确认docker-compose.yml里所有服务都在同一个network下或者显式声明了networks配置。还有一个坑是端口映射。容器内部端口和宿主机端口是两回事MCP Server监听8080映射到宿主机也写8080但如果你改了宿主机端口比如8081:8080那外部访问要用8081。这个我见过太多人搞混。5.3 记忆膨胀导致性能下降跑了一段时间后数据库从几百条涨到几十万条检索开始变慢。除了建索引我还做了两件事冷热分离把超过30天没被检索过的记忆移到冷存储表主表只保留热数据。检索时先查热表没有再查冷表。定期压缩每周跑一次批处理任务把相似度超过0.95的记忆合并减少冗余。实测下来这两招能把检索延迟从800ms压到150ms左右效果立竿见影。5.4 MCP工具调用失败的处理MCP Server有时候会返回错误比如数据库连接超时、embedding接口限流。我的做法是在Agent侧加重试降级逻辑async def retrieve_with_fallback(query, retries3): for i in range(retries): try: return await mcp_client.call_tool(memory_retrieve, {query: query}) except Exception as e: if i retries - 1: # 降级返回空记忆让Agent基于当前上下文回答 return [] await asyncio.sleep(2 ** i) # 指数退避降级策略很重要。记忆服务挂了不能让整个Agent瘫痪大不了这次不查记忆基于当前会话回答用户体验上只是“记性差了点”而不是“完全不能用”。6. 进阶玩法让记忆系统更聪明6.1 记忆的图结构组织单纯的向量检索是扁平的但记忆之间其实有关系。比如“用户对花生过敏”和“用户点了宫保鸡丁”这两条记忆应该能关联起来因为宫保鸡丁里可能有花生。我用Neo4j或者简单的邻接表来存记忆之间的关系检索时可以做图遍历扩展先找到直接相关的记忆再沿着关系边找二度相关的记忆。这个思路和GraphRAG很像但更轻量。不需要全量建图只对实体和事件建关系就行。实测在推荐场景下图扩展能把召回率提升15%左右。6.2 主动记忆不等用户问提前准备好hindsight还有一个我觉得很实用的功能主动记忆预取。当Agent检测到用户可能要执行某个任务时提前把相关记忆加载到工作记忆里。比如用户说“帮我订机票”Agent在调用订票工具之前先自动检索“用户偏好座位”“常用航空公司”“常旅客号”这些记忆一次性注入上下文。这样做的好处是减少来回检索的次数让Agent的响应更连贯。实现上就是在任务规划阶段加一个prefetch_memories的步骤根据任务类型决定预取哪些标签的记忆。6.3 记忆的隐私与安全记忆里可能包含敏感信息比如用户的地址、电话、健康数据。我在存储前会做脱敏处理用正则或者NER模型识别敏感实体替换成占位符原始值加密后单独存。检索时根据调用方的权限决定是否解密。另外记忆的访问要有审计日志。谁在什么时候检索了什么记忆都要记下来。这在多用户环境下尤其重要防止A用户的数据被B用户的Agent检索到。注意如果你在做面向企业的Agent记忆的隔离级别一定要设计好。我见过因为session_id没传对导致跨用户记忆泄露的案例修复起来很麻烦。7. 我踩过的坑和最后的小技巧第一个坑是embedding维度不一致。我一开始用OpenAI的text-embedding-ada-0021536维后来想换本地的bge-large1024维结果数据库里的向量维度对不上整个表都得重建。教训是选embedding模型时就要考虑好后续能不能换最好在表里加一个model_version字段不同版本的向量分开存。第二个坑是时间戳时区问题。Docker容器默认UTC应用层用本地时间导致记忆的时间衰减计算全乱了。后来统一用UTC存储展示时再转本地时区问题解决。最后分享一个小技巧给记忆加“置信度”字段。不是所有记忆都同等可靠用户明确说的“我确定我对花生过敏”置信度高Agent推断的“用户可能喜欢靠窗座位”置信度低。检索时把置信度作为加权因子能有效减少误判。这个字段我加了之后推荐准确率大概提升了8个百分点成本几乎为零。如果你也在做Agent记忆相关的东西欢迎交流。这个领域变化很快今天好用的方案明天可能就被新思路替代了保持迭代才是正道。