基于MCP与Docker的Agent Memory实战:构建具备反思能力的LLM记忆系统
发布时间:2026/9/30 4:20:07来源:尧图网络
1. 从“hindsight”说起为什么我们需要给Agent装上“后视镜”“hindsight”这个词直译过来就是“后见之明”或者更通俗一点——“马后炮”。但在Agent Memory和LLM工程化的语境里它指向的是一个非常具体且要命的问题当你的Agent在完成一轮对话、一次任务、一个工作流之后它到底记住了什么它能不能在后续的交互中像人一样“回想”起之前发生过的事情并且用这些记忆来指导当下的决策我接触过不少做Agent落地的团队大家一开始都兴致勃勃地接入了LLM搭好了MCP协议用Docker把服务跑起来觉得万事大吉。结果上线没几天就发现用户问“上次你帮我查的那个订单到哪了”Agent一脸茫然用户说“还是按我昨天说的那个方案来”Agent又开始重新问一遍需求。这不是模型不够聪明而是记忆系统没有设计好。“hindsight”这个项目标题本质上就是在解决这个问题。它不是一个简单的“把对话历史塞进上下文”的粗暴方案而是一套围绕Agent Memory构建的、带有反思和回溯能力的记忆管理机制。结合热搜词里出现的agent memory、LLM、MCP、Docker这些关键词可以很清楚地看到这个项目要处理的是在LLM驱动的Agent系统中如何通过MCP协议和容器化部署实现一套可持久化、可检索、可反思的记忆层让Agent具备“回头看”的能力。这篇文章适合谁看如果你正在做Agent开发或者你已经在用MCP协议搭建工具链又或者你只是单纯好奇“为什么我的Agent总是记不住事”那接下来的内容应该能给你一些可以直接抄作业的思路和实操方案。我会从整体设计、核心细节、实操过程、问题排查几个维度把“hindsight”这个项目背后的技术逻辑和落地经验拆开来讲。2. 整体设计与思路拆解Agent Memory到底该怎么分层2.1 为什么“把历史对话全塞进Prompt”是条死路很多人第一次做Agent记忆想都不用想直接把最近N轮对话拼接到System Prompt后面。这个方案在Demo阶段没问题一旦进入真实场景就会撞上三堵墙。第一堵墙是Token成本。LLM的上下文窗口再大也是有上限的而且大部分API是按Token计费的。你把几十轮对话全塞进去每次请求都在烧钱而且响应延迟会明显上升。第二堵墙是信息稀释。上下文里塞的东西越多模型对关键信息的注意力就越容易被分散。你明明在第三轮对话里说了“我对花生过敏”结果到第二十轮的时候模型已经把这个信息淹没在大量无关对话里了。第三堵墙是跨会话失忆。用户今天关掉页面明天再回来你的Agent完全不知道昨天发生过什么因为对话历史根本没有持久化。“hindsight”的设计思路本质上就是要解决这三个问题。它把记忆拆成了不同的层次每一层有不同的存储介质、检索策略和生命周期。这不是过度设计而是被真实场景逼出来的。2.2 三层记忆架构Working Memory、Episodic Memory、Semantic Memory参考认知科学里对人类记忆的分类结合热搜词里提到的“agent 存储 working memory”我把“hindsight”的记忆体系拆成三层来理解。第一层是Working Memory工作记忆。这一层对应的是当前会话的上下文窗口生命周期最短通常只保留最近几轮对话或者当前任务相关的信息。它的作用是让Agent在单次交互中保持连贯性。实现上你可以用一个滑动窗口来管理窗口大小根据模型上下文长度和任务复杂度来定。比如你用的是128K上下文的模型那Working Memory可以控制在8K到16K Token之间留出足够的空间给System Prompt和工具调用结果。第二层是Episodic Memory情景记忆。这一层记录的是“什么时候发生了什么”。比如用户在某次会话中提到了自己的偏好、完成了一个订单、反馈了一个问题。这些信息需要被持久化存储并且在后续会话中能够被检索出来。热搜词里提到的“llm的token三个点key我是谁、query我在找什么、value我能提供什么”其实就是在描述这种记忆的索引结构Key是身份标识Query是检索意图Value是具体内容。Episodic Memory通常用向量数据库来存因为你需要做语义检索而不是精确匹配。第三层是Semantic Memory语义记忆。这一层存储的是从多次交互中抽象出来的、去除了时间属性的知识。比如“这个用户偏好简洁的回答风格”、“这个项目的代码规范要求用TypeScript”。Semantic Memory的更新频率更低但一旦形成就比较稳定。它可以通过对Episodic Memory的定期总结和归纳来生成也可以由人工显式注入。这三层记忆不是孤立的它们之间有一个写入和读取的流转机制。Working Memory里的信息在会话结束后经过筛选和压缩写入Episodic MemoryEpisodic Memory里的多条记录经过归纳沉淀为Semantic Memory。反过来当Agent需要做决策时会先从Semantic Memory里拉取长期偏好再从Episodic Memory里检索相关历史最后和Working Memory里的当前上下文合并形成最终的Prompt。2.3 为什么选MCP和Docker作为基础设施热搜词里MCP和Docker的出现频率非常高这不是偶然的。MCP协议解决的是工具调用标准化的问题而Docker解决的是环境一致性和部署效率的问题。先说MCP。在没有MCP之前你要让Agent去查数据库、调API、读文件每个工具都要写一套适配代码而且不同LLM框架之间的工具定义格式还不一样。MCP把这件事标准化了你只需要按照MCP协议定义一个Server暴露Tools和Resources任何支持MCP的Client都可以直接调用。对于“hindsight”这样的记忆系统来说这意味着你可以把记忆的读写操作封装成MCP Tools比如memory_write、memory_search、memory_summarize然后让Agent在需要的时候自主调用。这样记忆系统就和Agent的核心逻辑解耦了换模型、换框架都不影响。再说Docker。Agent Memory系统通常需要依赖向量数据库、关系型数据库、缓存服务等多个组件。如果你在本地开发环境跑通了部署到服务器上发现各种依赖版本冲突那就很头疼。Docker把每个组件打包成独立的容器用docker-compose或者Kubernetes编排环境一致性有保障。而且热搜词里提到了“docker安装mysql8.0并使用”、“docker安装redis主从”这些具体操作说明很多开发者已经在用Docker来搭建Agent的基础设施了。注意如果你在Windows上安装Docker Desktop时遇到“virtualization support not detected”的报错大概率是BIOS里的虚拟化支持没有开启。重启进入BIOS找到Intel VT-x或者AMD-V选项设为Enabled即可。这个问题在“docker desktop安装教程”相关的搜索里出现频率极高但很多人会误以为是软件问题。3. 核心细节解析与实操要点记忆的写入、检索与反思3.1 记忆写入不是所有对话都值得记住Working Memory里的内容在会话结束时不能一股脑全写进Episodic Memory。你需要一个筛选和压缩的过程。我的做法是在会话结束前让LLM对当前对话做一次总结提取出关键信息点然后按照固定的Schema写入。这个Schema通常包含以下字段字段名类型说明memory_idstring唯一标识通常用UUIDuser_idstring用户标识用于隔离不同用户的记忆session_idstring会话标识用于追溯来源timestampdatetime记忆产生的时间contentstring记忆的具体内容经过压缩和总结embeddingvector内容的向量表示用于语义检索tagslist标签用于分类和过滤importancefloat重要性评分0到1之间ttlint过期时间单位秒0表示永不过期这里面的importance评分很关键。不是所有记忆都同等重要。用户说“今天天气不错”和用户说“我对青霉素过敏”这两条记忆的价值天差地别。我的做法是让LLM在总结时给每条记忆打一个重要性分数低于阈值的直接丢弃高于阈值的才写入Episodic Memory。阈值设多少根据我的经验0.6是一个比较合理的起点你可以根据实际效果微调。Tags的设计也很有讲究。你可以用标签来标记记忆的类型比如preference、fact、event、instruction。这样在检索的时候可以先按标签过滤再做向量相似度搜索效率和准确率都会更高。3.2 记忆检索Key-Query-Value的三元组逻辑热搜词里有一句很精辟的总结“llm的token三个点key我是谁、query我在找什么、value我能提供什么”。这其实就是在描述记忆检索的核心逻辑。当Agent需要检索记忆时它首先要明确三个问题我是谁Key身份标识、我在找什么Query检索意图、我能提供什么Value返回内容。映射到技术实现上Key对应的是user_id和session_id用来限定检索范围。你不能让用户A的记忆被用户B检索到这是基本的安全隔离。Query对应的是当前对话的上下文或者用户的最新输入用来生成检索向量。Value对应的是Episodic Memory里存储的content字段也就是最终返回给Agent的记忆内容。检索的流程通常是这样的先用user_id过滤出该用户的所有记忆然后用Query生成embedding在向量数据库里做相似度搜索返回Top-K条结果。K值设多少我一般设5到10太多了会稀释上下文太少了可能漏掉关键信息。但纯向量检索有一个问题它只能找到语义相似的记忆找不到时间相关的记忆。比如用户问“我上周让你帮我查的那个东西”向量检索可能找不到因为“上周”是一个时间概念不是语义概念。所以你需要结合时间过滤。在检索时先根据Query里的时间信息如果有的话缩小时间范围再做向量搜索。3.3 记忆反思让Agent学会“回头看”“hindsight”这个名字本身就暗示了反思的能力。记忆系统不能只是被动地存储和检索它还需要定期做反思和归纳。具体来说你可以设置一个定时任务每天或者每周跑一次把Episodic Memory里最近一段时间的记忆拉出来让LLM做一次总结提取出稳定的偏好和模式写入Semantic Memory。比如Agent发现用户在过去两周里三次提到了“不要用Markdown格式回复”那就可以在Semantic Memory里生成一条“该用户偏好纯文本回复”的长期记忆。这个反思过程还可以用来清理过期记忆。有些记忆是有时效性的比如“用户明天要开会”过了明天这条记忆就没用了。你可以在写入时设置TTL到期自动删除。或者让LLM在反思时判断哪些记忆已经过时主动标记删除。提示反思任务的频率不要太高否则会浪费Token而且可能引入噪声。我的经验是对于个人助手类的Agent每天一次足够了对于企业级应用可以每周一次。另外反思生成的Semantic Memory最好保留一个“置信度”字段因为LLM的归纳不一定总是准确的。3.4 MCP Tools的设计把记忆操作暴露给Agent把记忆系统封装成MCP Server之后你需要定义一组Tools让Agent调用。以下是我在实际项目中常用的几个{ tools: [ { name: memory_write, description: 写入一条新的记忆, parameters: { content: string, 记忆内容, tags: list, 标签, importance: float, 重要性评分 } }, { name: memory_search, description: 检索相关记忆, parameters: { query: string, 检索意图, top_k: int, 返回条数, tags_filter: list, 标签过滤 } }, { name: memory_summarize, description: 对近期记忆进行总结归纳, parameters: { time_range: string, 时间范围, target: string, 总结目标 } } ] }Agent在对话过程中可以根据需要自主决定什么时候写入记忆、什么时候检索记忆。比如用户说“记住我对花生过敏”Agent就应该调用memory_write用户问“我之前说过什么偏好”Agent就应该调用memory_search。这种设计的好处是解耦。记忆系统的实现细节对Agent透明Agent只需要知道有哪些Tools可用就行了。你后面想换向量数据库、想调整检索策略都不需要改Agent的核心逻辑。4. 实操过程与核心环节实现从零搭建一套可用的记忆系统4.1 环境准备Docker Compose一键拉起依赖服务假设你现在要从零开始搭建这套系统第一步是把基础设施跑起来。你需要的东西不多一个向量数据库我用Qdrant轻量且API友好、一个关系型数据库PostgreSQL用来存结构化数据、一个缓存Redis用来做Working Memory的快速读写。创建一个docker-compose.yml文件version: 3.8 services: qdrant: image: qdrant/qdrant:latest ports: - 6333:6333 - 6334:6334 volumes: - qdrant_data:/qdrant/storage postgres: image: postgres:16 environment: POSTGRES_USER: memory POSTGRES_PASSWORD: memory_pass POSTGRES_DB: agent_memory ports: - 5432:5432 volumes: - pg_data:/var/lib/postgresql/data redis: image: redis:7-alpine ports: - 6379:6379 volumes: - redis_data:/data volumes: qdrant_data: pg_data: redis_data:然后在终端里执行docker compose up -d三个服务就都跑起来了。这里有几个细节需要注意Qdrant的6333端口是HTTP API6334是gRPC。如果你用Python客户端默认走6333就行。PostgreSQL的密码不要用默认的生产环境一定要改。我这里写memory_pass只是为了演示。Redis我用了alpine版本体积小启动快。如果你需要持久化记得配置appendonly yes。注意如果你在Windows上跑Docker Desktop确保WSL2后端已经启用。另外如果之前装过Docker Toolbox可能会有端口冲突建议先清理干净再装Docker Desktop。4.2 记忆写入的完整代码实现基础设施跑起来之后接下来写记忆写入的逻辑。我用Python来演示因为生态最成熟。import uuid import datetime from qdrant_client import QdrantClient from qdrant_client.models import PointStruct, VectorParams, Distance from openai import OpenAI client QdrantClient(hostlocalhost, port6333) openai_client OpenAI() COLLECTION_NAME episodic_memory # 初始化Collection client.recreate_collection( collection_nameCOLLECTION_NAME, vectors_configVectorParams(size1536, distanceDistance.COSINE) ) def get_embedding(text: str) - list: response openai_client.embeddings.create( modeltext-embedding-3-small, inputtext ) return response.data[0].embedding def write_memory(user_id: str, session_id: str, content: str, tags: list, importance: float, ttl: int 0): if importance 0.6: return None memory_id str(uuid.uuid4()) embedding get_embedding(content) timestamp datetime.datetime.now().isoformat() payload { user_id: user_id, session_id: session_id, content: content, tags: tags, importance: importance, timestamp: timestamp, ttl: ttl } client.upsert( collection_nameCOLLECTION_NAME, points[ PointStruct( idmemory_id, vectorembedding, payloadpayload ) ] ) return memory_id这段代码的核心逻辑是先判断重要性低于阈值的直接丢弃然后生成embedding把内容和元数据一起写入Qdrant。user_id和session_id放在payload里检索时可以用来过滤。4.3 记忆检索的实现与参数调优检索的逻辑比写入稍微复杂一点因为要处理过滤条件和相似度排序。def search_memory(user_id: str, query: str, top_k: int 5, tags_filter: list None, time_range: tuple None): query_embedding get_embedding(query) # 构建过滤条件 must_conditions [ {key: user_id, match: {value: user_id}} ] if tags_filter: must_conditions.append({ key: tags, match: {any: tags_filter} }) if time_range: must_conditions.append({ key: timestamp, range: { gte: time_range[0], lte: time_range[1] } }) results client.search( collection_nameCOLLECTION_NAME, query_vectorquery_embedding, query_filter{must: must_conditions}, limittop_k, with_payloadTrue ) return [ { content: hit.payload[content], score: hit.score, timestamp: hit.payload[timestamp], tags: hit.payload[tags] } for hit in results ]这里有几个调优的点值得展开说。Top-K的选择。我默认设5但这不是固定的。如果Agent的任务比较复杂需要更多背景信息可以调到10。但不要超过15否则上下文里塞太多记忆反而会干扰模型判断。相似度阈值。Qdrant返回的score是余弦相似度范围在-1到1之间。我一般会设一个最低阈值比如0.7低于这个值的直接丢弃。不然你可能会检索出一堆不相关的记忆反而误导Agent。时间衰减。越久远的记忆相关性可能越低。你可以在排序时引入一个时间衰减因子让新记忆的权重更高。具体做法是在score上乘以一个衰减系数比如score * exp(-lambda * days_ago)lambda取0.01到0.05之间。4.4 反思任务的定时调度反思任务可以用APScheduler或者Celery来调度。我一般用APScheduler轻量且够用。from apscheduler.schedulers.background import BackgroundScheduler def reflect_memory(user_id: str): # 拉取最近7天的Episodic Memory recent_memories search_memory( user_iduser_id, query, top_k50, time_range( (datetime.datetime.now() - datetime.timedelta(days7)).isoformat(), datetime.datetime.now().isoformat() ) ) if len(recent_memories) 5: return # 让LLM做总结 memory_text \n.join([m[content] for m in recent_memories]) prompt f请对以下用户记忆进行总结提取出稳定的偏好和模式 {memory_text} 请以JSON格式返回包含以下字段 - preferences: 用户偏好列表 - facts: 关于用户的事实列表 - patterns: 行为模式列表 response openai_client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], response_format{type: json_object} ) # 将总结结果写入Semantic Memory semantic_content response.choices[0].message.content write_memory( user_iduser_id, session_idreflection, contentsemantic_content, tags[semantic, reflection], importance0.9 ) scheduler BackgroundScheduler() scheduler.add_job(reflect_memory, cron, hour3, minute0, args[user_123]) scheduler.start()这个反思任务每天凌晨3点跑一次对指定用户最近7天的记忆做总结。总结结果写入Semantic Memory标签设为semantic和reflection重要性设为0.9确保不会被轻易丢弃。提示反思任务的Prompt设计很关键。我试过让LLM自由发挥结果它经常生成一些模棱两可的总结。后来改成强制JSON格式并且明确要求提取preferences、facts、patterns三类信息效果稳定了很多。5. 常见问题与排查技巧实录5.1 记忆检索不准确怎么办这是最常见的问题。你明明存了一条记忆但检索的时候就是找不到。排查思路按以下顺序来第一步检查embedding模型是否一致。写入时用的模型和检索时用的模型必须是同一个。如果你写入用的是text-embedding-3-small检索时换成了text-embedding-ada-002那向量空间都不一样肯定搜不到。第二步检查过滤条件是否过严。有时候user_id或者tags_filter写错了导致过滤掉了正确的记忆。你可以先把过滤条件去掉只做纯向量检索看看能不能搜到。如果能搜到那就是过滤条件的问题。第三步检查相似度阈值。如果你设了0.7的阈值但实际相似度只有0.65那就会被丢弃。可以先把阈值降到0.5看看结果如何。第四步检查记忆内容的质量。如果写入的记忆本身就是一堆废话那embedding的质量也不会高。确保写入前做了充分的压缩和总结。问题现象可能原因解决方法完全搜不到embedding模型不一致统一写入和检索的模型搜到但不相关相似度阈值过低提高阈值到0.7以上搜到但排序不对缺少时间衰减引入时间衰减因子搜到但内容过时TTL未生效检查TTL字段和清理任务5.2 Docker环境下的网络问题热搜词里出现了“docker网络不通”这在Agent Memory系统里很常见。因为你的应用容器需要访问Qdrant、PostgreSQL、Redis如果网络配置不对就会连不上。最常见的坑是容器间通信用了localhost。在Docker Compose里每个服务的主机名就是服务名。比如你的应用容器要连Qdrant应该用qdrant:6333而不是localhost:6333。因为localhost在容器内部指向的是容器自己不是宿主机。另一个坑是端口映射和容器内部端口混淆。比如Qdrant在容器内部监听6333你映射到宿主机的6333。如果你的应用跑在宿主机上用localhost:6333没问题但如果应用也跑在容器里就要用qdrant:6333。注意如果你在Windows上跑Docker Desktop有时候WSL2的网络会有问题。可以尝试重启WSLwsl --shutdown或者重启Docker Desktop。如果还是不行检查一下防火墙设置。5.3 MCP连接失败的排查热搜词里提到了“谷歌浏览器扩展设置中启用mcp连接”和“llm request failed: provider rejected the request schema or tool payload”。MCP连接失败通常有几个原因Schema不匹配。MCP协议对Tools的定义有严格的Schema要求。如果你的参数类型写错了比如该用string的地方用了integerServer端会拒绝请求。建议用MCP官方提供的Schema验证工具先校验一遍。Token过期。如果你用的是带认证的MCP ServerToken过期后所有请求都会被拒绝。检查Token的有效期必要时实现自动刷新。网络隔离。如果MCP Server跑在Docker容器里Client跑在宿主机上确保端口映射正确并且防火墙没有拦截。5.4 记忆膨胀导致成本失控这是很多团队上线一段时间后才会发现的问题。记忆越存越多每次检索都要扫描大量向量Token成本和时间成本都在上升。我的做法是分级存储。重要性高的记忆存在Qdrant里重要性低的转移到冷存储比如S3或者本地文件只在需要时才加载。另外定期做记忆合并把多条相似的记忆合并成一条减少总量。还有一个技巧是设置记忆上限。每个用户的Episodic Memory最多保留1000条超过之后按重要性和时间排序淘汰最差的。这样既能控制成本又不会丢失关键信息。6. 一些踩坑之后的个人体会这套记忆系统我在几个项目里跑过有做个人助手的也有做企业知识管理的。最大的体会是记忆系统的核心不是技术而是策略。你用什么向量数据库、用什么embedding模型这些都可以换。但“什么该记、什么该忘、什么时候该回想”这些策略层面的决策才是决定系统好不好用的关键。另外不要一开始就追求完美。我见过有人花了两周时间设计了一套极其复杂的记忆架构结果上线后发现用户根本不买账。不如先用最简单的方案跑起来收集真实数据再根据反馈迭代。记忆系统是一个需要持续调优的东西没有一劳永逸的方案。最后分享一个小技巧在写入记忆时让LLM同时生成一个**“反事实”版本**也就是“如果这条记忆不成立会是什么情况”。这个反事实版本可以帮助Agent在检索时做更精准的判断避免误用记忆。这个技巧是我在一次调试中偶然发现的后来在多个项目里验证过效果确实不错。
网站建设高端定制企业官网