ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

给LLM Agent装上后视镜:基于MCP与Docker的长期记忆架构实战

给LLM Agent装上后视镜:基于MCP与Docker的长期记忆架构实战 1. 从“hindsight”说起为什么我们需要给Agent装一个“后视镜”第一次看到“hindsight”这个词我脑子里蹦出来的不是技术概念而是开车时看后视镜的动作。后视镜这东西平时不起眼但变道、倒车、超车的时候没有它你心里就没底。做LLM Agent开发这几年我越来越觉得大多数Agent缺的就是这么一块“后视镜”——它们能推理、能调用工具、能生成看起来像模像样的回答但它们记不住自己做过什么更不会从过去的交互里吸取教训。“hindsight”这个项目标题结合agent memory、LLM、MCP、Docker这几个关键词指向的其实是一个非常具体的问题域如何让LLM驱动的Agent拥有可回溯、可检索、可复用的长期记忆能力。这不是简单的“把对话历史塞进context window”那种做法而是要让Agent在完成一次任务之后能够把这次任务的经验沉淀下来下次遇到类似场景时能主动调用。说白了就是给Agent装一个“后视镜”让它知道自己从哪儿来、走过哪些路、哪些路是死胡同。我之所以对这个方向特别感兴趣是因为在实际项目里踩过太多“Agent失忆”的坑。你花了大半天调好一个Agent的工作流它今天表现很好明天换个会话就完全忘了之前学到的偏好和约束。用户每次都要重复交代“我不喜欢表格形式的回答”“这个项目的代码风格是PEP8”“上次那个API的rate limit是每分钟60次”体验极差。而hindsight要解决的正是这个“每次从零开始”的问题。这篇文章适合谁看如果你正在做LLM Agent相关的开发或者对MCP协议、Docker部署、Agent记忆架构感兴趣那接下来的内容应该能给你不少可直接参考的东西。我会从整体设计思路讲到具体实现细节包括Docker环境搭建、MCP服务配置、记忆存储结构设计、检索策略选择以及我在实际调试中遇到的各种坑。内容会比较长但都是实打实的经验不是那种“介绍了什么什么”的泛泛之谈。2. 整体设计思路hindsight到底该怎么拆2.1 核心问题定义Agent记忆的三个层次在动手之前得先把“Agent记忆”这件事拆清楚。我自己的经验是Agent的记忆需求可以分成三个层次每个层次的技术方案和存储策略都不一样。第一个层次是会话内记忆也就是单次对话或单次任务执行过程中的上下文。这个层次用context window就能解决无非是控制token数量、做滑动窗口或者摘要压缩。大多数LLM框架自带的memory模块都是干这个的没什么门槛。第二个层次是跨会话记忆也就是同一个用户或同一个Agent在不同会话之间的记忆延续。这个层次就需要外部存储了常见方案是向量数据库加元数据过滤。用户偏好、项目约束、常用配置这些东西都应该存在这个层次。第三个层次是经验记忆也就是Agent从成功和失败的任务中提炼出来的可复用知识。这个层次最难做因为它要求Agent不仅能记住“发生了什么”还要能归纳出“下次该怎么做”。hindsight这个项目最有价值的部分恰恰是在这个层次上做了探索。我见过很多项目把这三个层次混在一起做结果就是存储结构混乱、检索效率低下、记忆污染严重。正确的做法是分层设计每层用不同的存储介质和检索策略层与层之间有明确的写入和读取规则。2.2 为什么选MCP作为记忆服务的接入层MCPModel Context Protocol这两年在Agent生态里火得很快从playwright mcp到chrome devtools mcp再到各种企业级工具的MCP server基本上主流工具都在往这个协议上靠。hindsight选择MCP作为记忆服务的接入层我认为是非常务实的选择。原因有三点。第一MCP把工具调用标准化了Agent不需要为每个记忆操作写专门的适配代码只要按照MCP协议暴露几个工具方法就行。第二MCP天然支持多Agent共享同一个记忆服务可以同时被多个Agent实例调用这对于团队协作场景很重要。第三MCP的传输层可以走stdio也可以走SSE部署灵活性高本地开发和远程部署都能覆盖。具体到hindsight的场景我会把记忆服务拆成几个MCP工具memory_write负责写入记忆memory_search负责语义检索memory_forget负责过期清理memory_summarize负责把零散记忆归纳成结构化知识。每个工具都有明确的输入输出schemaAgent通过MCP client调用这些工具完全不需要关心底层用的是向量数据库还是图数据库。这里有个细节值得注意MCP工具的粒度不能太细否则Agent调用次数太多token消耗和延迟都受不了也不能太粗否则灵活性不够。我的经验是围绕“写、查、删、归纳”这四个动作来设计工具基本能覆盖90%的记忆操作需求。2.3 Docker在整体架构中的角色Docker在这个项目里不是可选项而是必选项。原因很简单记忆服务需要长期运行需要持久化存储需要和Agent的运行环境隔离。用Docker来部署可以保证环境一致性也方便做资源限制和网络隔离。我通常会这样划分容器一个容器跑MCP记忆服务一个容器跑向量数据库比如Qdrant或Weaviate一个容器跑Agent运行时。三个容器通过Docker network互联记忆服务和向量数据库之间的通信走内部网络不暴露到宿主机。Agent运行时通过MCP client连接记忆服务走SSE或者stdio。这样做的好处是记忆服务的生命周期和Agent解耦了。Agent可以随时重启、升级、扩容记忆数据始终在向量数据库里稳稳当当存着。而且Docker的volume机制让数据持久化变得很简单不用担心容器重启丢数据。注意如果你在Windows上跑Docker Desktop一定要确认虚拟化支持已经开启。我遇到过好几次“virtualization support not detected”的报错最后发现是BIOS里的VT-x没打开。这个坑很基础但确实卡过不少人。3. 核心细节解析记忆存储与检索的关键设计3.1 记忆的数据结构该怎么设计记忆存什么、怎么存直接决定了后续检索的质量。我试过几种方案最后沉淀下来的结构是这样的每条记忆包含content原始内容、summary摘要、embedding向量、metadata元数据、timestamp时间戳、access_count访问次数、decay_score衰减分数。content是原始文本比如用户说“这个项目的API base URL是https://api.example.com/v2”。summary是Agent归纳后的短句比如“项目API base URL”。embedding是content或summary的向量表示用于语义检索。metadata里放结构化信息比如{type: preference, project: xxx, confidence: 0.9}。access_count和decay_score是我后来加上的这两个字段解决了一个很实际的问题记忆会过时。用户三个月前说“我喜欢用Python 3.8”现在可能已经升级到3.12了。如果所有记忆权重一样检索时就会把过时信息也捞出来。我的做法是每次记忆被检索命中并实际使用后access_count加一同时根据时间戳计算decay_score越久远的记忆衰减越厉害。检索排序时综合语义相似度、access_count和decay_score来打分。这个设计不是拍脑袋想出来的是我在实际项目里被“过时记忆污染”坑过之后才加上的。有一次Agent反复推荐一个已经废弃的API端点查了半天才发现是半年前的一条记忆一直在被检索命中。从那以后我就把衰减机制作为标配了。3.2 向量检索与关键词检索的混合策略纯向量检索有个问题它对精确匹配不敏感。比如用户问“上次那个rate limit是多少”向量检索可能返回一堆关于“限制”“配额”的记忆但真正精确的那条“rate limit: 60/min”反而排不到前面。纯关键词检索又缺乏语义泛化能力用户换个说法就搜不到了。我的方案是混合检索先用向量检索召回Top 20再用BM25或关键词匹配做二次排序最后用RRFReciprocal Rank Fusion融合两个排序结果。这样既能保证语义相关性又能保证精确匹配的记忆不被漏掉。具体实现上我用的Qdrant做向量存储它原生支持payload过滤可以在向量检索的同时做metadata过滤。比如只检索typepreference且projectxxx的记忆这样能大幅缩小检索范围提升精度。关键词检索这块我用的是轻量级的BM25实现不需要额外部署Elasticsearch那么重的东西。融合排序的公式我用的是标准的RRFscore sum(1 / (k rank_i))其中k取60。这个参数是经验值k太小会让头部结果权重过高k太大又会让排序趋近于平均。60这个值在大多数场景下表现比较均衡。3.3 记忆写入的触发时机与去重逻辑记忆写入不能太频繁否则存储膨胀得很快也不能太稀疏否则关键信息会丢。我的做法是设置几个触发点用户显式说“记住这个”时立即写入任务完成后由Agent归纳写入检测到重复模式时批量写入。去重逻辑是必须的。我见过太多项目因为不去重导致同一条信息存了几十遍检索时全是重复结果。去重的策略分两层第一层是精确去重用content的hash值判断是否完全重复第二层是语义去重用embedding的余弦相似度判断相似度超过0.95的就认为是重复只保留最新的那条并更新access_count。这里有个细节语义去重不能太激进。有些记忆看起来相似但实际有细微差别比如“API rate limit是60/min”和“API rate limit是100/min”相似度可能很高但内容完全不同。我的做法是语义去重时同时检查metadata里的关键字段如果关键字段不一致就不做去重而是保留两条并标记冲突让Agent在检索时自己判断哪条更可信。4. 实操过程从零搭建hindsight记忆服务4.1 Docker环境准备与容器编排先确保Docker和Docker Compose已经装好。Windows用户直接装Docker DesktopUbuntu用户用apt装docker-ce和docker-compose-plugin。装完之后跑一下docker run hello-world确认环境正常。接下来创建项目目录结构mkdir -p hindsight/{mcp-server,vector-db,agent-runtime} cd hindsight然后写docker-compose.ymlversion: 3.9 services: vector-db: image: qdrant/qdrant:latest ports: - 6333:6333 volumes: - ./vector-db/data:/qdrant/storage networks: - hindsight-net mcp-server: build: ./mcp-server ports: - 8080:8080 environment: - QDRANT_URLhttp://vector-db:6333 - EMBEDDING_MODELtext-embedding-3-small depends_on: - vector-db networks: - hindsight-net agent-runtime: build: ./agent-runtime environment: - MCP_SERVER_URLhttp://mcp-server:8080/sse depends_on: - mcp-server networks: - hindsight-net networks: hindsight-net: driver: bridge这个编排文件定义了三个服务Qdrant做向量存储mcp-server做记忆服务agent-runtime跑Agent。三个服务在同一个bridge网络里通过服务名互相访问。Qdrant的数据目录挂载到宿主机保证容器重启不丢数据。提示如果你在国内网络环境下拉Qdrant镜像比较慢可以配置镜像加速器。Docker Desktop在设置里有registry mirror选项填上可用的加速地址就行。4.2 MCP记忆服务的核心代码实现mcp-server我用Python写基于mcp官方SDK。核心是暴露四个工具memory_write、memory_search、memory_forget、memory_summarize。先看memory_write的实现from mcp.server import Server from mcp.types import Tool, TextContent import hashlib from qdrant_client import QdrantClient from qdrant_client.models import PointStruct, Distance, VectorParams app Server(hindsight-memory) qdrant QdrantClient(urlos.getenv(QDRANT_URL)) app.tool() async def memory_write(content: str, summary: str, metadata: dict) - str: # 精确去重 content_hash hashlib.sha256(content.encode()).hexdigest() existing qdrant.scroll( collection_namememories, scroll_filter{must: [{key: content_hash, match: {value: content_hash}}]}, limit1 ) if existing[0]: # 更新access_count和时间戳 point_id existing[0][0].id qdrant.set_payload( collection_namememories, payload{timestamp: time.time(), access_count: existing[0][0].payload[access_count] 1}, points[point_id] ) return f记忆已存在已更新访问记录: {point_id} # 生成embedding embedding await get_embedding(summary) # 语义去重 similar qdrant.search( collection_namememories, query_vectorembedding, limit1, score_threshold0.95 ) if similar and similar[0].payload.get(metadata, {}).get(key_fields) metadata.get(key_fields): return f语义重复已跳过: {similar[0].id} # 写入新记忆 point_id str(uuid.uuid4()) qdrant.upsert( collection_namememories, points[PointStruct( idpoint_id, vectorembedding, payload{ content: content, summary: summary, metadata: metadata, content_hash: content_hash, timestamp: time.time(), access_count: 0, decay_score: 1.0 } )] ) return f记忆已写入: {point_id}这段代码里有几个关键点。第一精确去重用hash比对速度快、准确率高。第二语义去重时同时检查key_fields避免把内容相似但关键信息不同的记忆误判为重复。第三写入时初始化access_count为0、decay_score为1.0后续检索时动态更新。memory_search的实现app.tool() async def memory_search(query: str, top_k: int 5, filters: dict None) - list: query_embedding await get_embedding(query) # 向量检索 vector_results qdrant.search( collection_namememories, query_vectorquery_embedding, limittop_k * 4, query_filterbuild_filter(filters) if filters else None ) # 关键词检索简化版BM25 keyword_results keyword_search(query, top_k * 4) # RRF融合 fused rrf_fusion(vector_results, keyword_results, k60) # 应用衰减分数 for item in fused: age_days (time.time() - item[timestamp]) / 86400 item[final_score] item[rrf_score] * (0.99 ** age_days) * (1 0.1 * item[access_count]) # 排序返回Top K fused.sort(keylambda x: x[final_score], reverseTrue) return fused[:top_k]衰减公式0.99 ** age_days的意思是每过一天记忆的基础权重乘以0.99。一年之后权重降到原来的0.03左右基本可以忽略。access_count的加成是线性的每被访问一次加0.1倍权重但不会无限增长因为最终排序还是以语义相关性为主。4.3 Agent端接入MCP记忆服务的配置Agent端我用的是Python的mcpclient通过SSE连接mcp-server。配置大概长这样from mcp.client.sse import sse_client from mcp.client.session import ClientSession async def init_memory_client(): async with sse_client(http://mcp-server:8080/sse) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() print(f可用记忆工具: {[t.name for t in tools.tools]}) return sessionAgent在每次任务开始前先调用memory_search检索相关记忆把结果注入到system prompt里。任务结束后调用memory_write把本次任务的关键信息写入记忆。如果任务过程中发现了与已有记忆冲突的信息调用memory_forget标记旧记忆为过期。这里有个实操心得记忆检索的query不要直接用用户的原始输入而是让LLM先做一次“记忆检索意图提取”把用户输入转换成更适合检索的query。比如用户说“帮我改一下上次那个脚本”直接拿这句话去检索效果很差但转换成“脚本修改 项目配置”之后检索命中率会高很多。5. 常见问题与排查技巧实录5.1 Docker网络不通导致MCP连接失败这是最常见的问题。表现是agent-runtime启动后连不上mcp-server报connection refused或者timeout。排查步骤先确认mcp-server容器是否正常运行docker ps看状态。进入agent-runtime容器用curl http://mcp-server:8080/sse测试连通性。如果curl不通检查两个容器是否在同一个network里docker network inspect hindsight-net。如果网络没问题检查mcp-server是否监听在0.0.0.0而不是127.0.0.1。我遇到过一种情况是mcp-server代码里写死了localhost容器内localhost指向容器自己当然连不上。改成0.0.0.0就好了。5.2 向量检索结果不相关有时候检索出来的记忆跟query完全不搭边。原因通常是embedding模型选得不对或者summary写得不好。我的经验是embedding用text-embedding-3-small就够用了性价比高。summary一定要让LLM认真写不要直接把content截断当summary。另一个常见原因是collection的distance metric选错了。Qdrant默认是Cosine大多数场景下没问题。但如果你用的embedding模型是归一化过的用Dot product会更快。这个要根据具体模型来定。5.3 记忆膨胀导致检索变慢跑了一段时间之后记忆条数可能上万检索延迟明显上升。解决方案有三个第一定期跑memory_summarize把零散记忆归纳成高层摘要减少总条数。第二给Qdrant配置HNSW索引参数调整m和ef_construct来平衡速度和精度。第三对超过一定时间的低access_count记忆做归档移到冷存储里。我自己的做法是每周跑一次归纳任务把过去一周的记忆按项目、按类型聚类每个簇生成一条摘要记忆原始记忆标记为archived。这样检索时只搜活跃记忆速度快很多。5.4 MCP工具调用报schema错误有时候会看到llm request failed: provider rejected the request schema or tool payload这样的报错。这通常是MCP工具的input schema定义有问题比如required字段没填、类型不匹配、或者嵌套结构太深。排查方法是把工具的schema打印出来对照MCP规范逐字段检查。我踩过的一个坑是metadata字段用了dict类型但没有指定具体结构有些LLM provider会拒绝这种模糊的schema。后来我把metadata拆成几个明确的字段比如project、type、confidence问题就解决了。5.5 常见问题速查表问题现象可能原因排查方法解决方案MCP连接超时容器网络不通docker network inspect确认同网络、监听0.0.0.0检索结果不相关embedding模型不合适检查模型和distance metric换模型或调整metric检索变慢记忆条数过多查看collection大小归纳归档、调HNSW参数schema报错工具定义不规范打印schema对照规范简化结构、明确类型记忆重复去重逻辑失效检查hash和相似度阈值调整阈值、加key_fields检查过时记忆污染缺少衰减机制检查decay_score计算加时间衰减和access_count6. 一些实操心得与扩展思路跑通hindsight这套记忆服务之后我在几个实际项目里做了验证。最明显的感受是Agent的“重复犯错率”下降了很多。以前同一个用户要反复纠正Agent的偏好现在第一次纠正之后后续会话里Agent会主动检索并遵守。这个体验提升是质变的。有一个细节我想特别提一下记忆的写入时机比写入内容更重要。我一开始让Agent在每轮对话结束后都写记忆结果存储爆炸检索质量也差。后来改成只在任务完成、用户显式要求、或者检测到新模式时写入效果好很多。记忆这东西少而精比多而杂有用。扩展方向上我最近在尝试把hindsight和GraphRAG结合起来。单纯的向量检索只能找到相似记忆但找不到记忆之间的因果关系。比如“因为API限流所以改了重试策略”这种因果链向量检索是表达不出来的。用图结构来存记忆之间的关系配合向量检索做混合查询应该是下一步值得探索的方向。另外MCP生态现在发展很快蓝湖MCP、Playwright MCP、Chrome DevTools MCP这些工具都在往标准化走。hindsight的记忆服务如果能和这些工具打通比如让Agent在操作浏览器时自动记录关键步骤到记忆里下次遇到类似页面就能直接复用操作序列那价值就更大了。这个我还在实验阶段等跑通了再单独写一篇分享。最后说一个我踩过的坑Docker Desktop在Windows上跑久了偶尔会卡死表现是容器还在跑但网络不通。重启Docker Desktop能解决但数据不会丢因为volume是挂载在宿主机的。如果你也遇到类似情况先别急着删容器重启一下Docker服务大概率就好了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进