news 2026/10/4 6:56:29

Agent记忆架构实战:基于MCP与Docker的hindsight设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Agent记忆架构实战:基于MCP与Docker的hindsight设计

1. 从“hindsight”说起:为什么我们需要给Agent装上“后视镜”

“hindsight”这个词本身很有意思,字面意思是“事后的洞察力”,也就是我们常说的“后见之明”。放在AI Agent和LLM的语境里,它指向一个非常具体且要命的问题:Agent的记忆机制。你肯定遇到过这种情况——跟一个LLM驱动的Agent聊了十几轮,它突然忘了你前面说过的关键约束,开始胡言乱语;或者一个自动化工作流跑了半小时,中间某一步的上下文丢了,后面全盘崩溃。这不是模型不够聪明,而是它的“记忆”没设计好。

我最早接触Agent记忆这个概念,是在做一套基于MCP协议的多工具编排系统时。当时用Docker把几个服务隔离开,每个服务负责不同的工具调用,结果发现Agent在跨服务传递上下文时频繁丢状态。后来才意识到,LLM本身是无状态的,它的“记忆”完全依赖于你每次请求时塞进去的上下文窗口。而hindsight要解决的,就是如何让Agent在长周期任务中,既能记住该记的,又能忘掉该忘的,还能在需要的时候把“过去”捞回来。

这篇文章适合谁看?如果你正在用LLM搭Agent、正在折腾MCP协议的工具接入、或者用Docker部署过带记忆功能的对话系统,那下面的内容应该能帮你少踩几个坑。我会从记忆架构的设计思路讲起,拆到working memory和长期存储的配合方式,再落到Docker环境下的实操部署,最后把常见的问题和排查手段整理成速查表。全程按我自己的项目经验来写,不堆术语,尽量说人话。

2. Agent记忆的整体设计与核心思路拆解

2.1 为什么LLM的“记忆”不能只靠上下文窗口

很多人刚开始做Agent的时候,习惯把所有历史对话一股脑塞进prompt里。短对话没问题,一旦轮次多了,token消耗直线上升,而且模型对超长上下文的注意力会衰减——中间部分的信息基本等于没给。我实测过一个20轮的对话,把完整历史塞进去,模型对第3轮提到的约束条件的召回率不到40%。这不是模型的问题,是架构的问题。

Agent记忆的核心矛盾在于:你需要让Agent知道足够多的过去,但又不能让它被过去淹没。hindsight这个思路的本质,就是给Agent加一层“记忆管理层”,把working memory(工作记忆)和长期存储分开。工作记忆放当前任务最相关的信息,长期存储放历史沉淀,需要的时候再检索回来。这跟人脑的工作方式很像——你不会记得昨天午饭吃了什么,但如果有人问你“昨天那家餐厅怎么样”,你能很快回忆起来。

2.2 Working Memory与长期存储的分工逻辑

Working memory在Agent里通常表现为一个结构化的短期缓存,它保存的是当前会话或当前任务链中最活跃的信息。比如用户刚说的偏好、当前正在处理的文件路径、上一步工具调用的返回值。这部分内容会直接进入每次LLM请求的上下文,所以必须精简。

长期存储则是另一个极端,它可以是向量数据库、关系型数据库、甚至就是文件系统。存进去的东西不会自动出现在prompt里,而是通过检索机制按需召回。这里的关键是检索策略——什么时候触发检索、检索多少条、怎么排序。我见过不少项目在这里翻车,要么检索太频繁导致上下文被无关信息污染,要么检索太少导致Agent“失忆”。

一个比较稳的做法是:每轮对话结束后,用一个轻量级的LLM调用判断当前轮次是否产生了“值得记住”的信息,如果有,就写入长期存储;下一轮开始时,用当前query去检索长期存储,把top-k条结果注入working memory。这个判断和检索的过程,就是hindsight的核心动作。

2.3 MCP协议在记忆管理中的角色

MCP(Model Context Protocol)在这里扮演的是“工具接入标准化”的角色。你可以把记忆的读写操作封装成MCP工具,比如memory_write、memory_search、memory_forget,然后让Agent通过MCP协议来调用。这样做的好处是记忆层和Agent逻辑解耦,换一个Agent框架或者换一个LLM,记忆层不用重写。

我自己的项目里,记忆服务是独立跑在一个Docker容器里的,通过MCP协议暴露接口。Agent那边只需要配置好MCP server的地址,就能像调用普通工具一样操作记忆。这种架构在需要横向扩展的时候特别舒服——记忆服务可以单独扩容,Agent节点可以无状态部署。

2.4 Docker带来的隔离与可复现性

用Docker部署记忆服务,最大的好处是环境隔离和可复现。记忆服务通常依赖向量数据库、嵌入模型、缓存中间件,这些东西的版本和配置很容易互相打架。Docker compose一写,所有依赖锁死,换台机器照样跑起来。

另外,Docker的网络模式对MCP服务很友好。你可以把记忆服务放在一个独立的bridge网络里,只暴露MCP端口给Agent容器,数据库端口完全不对外。这样即使Agent被注入了恶意指令,也没法直接碰到底层存储。

3. 核心细节解析与实操要点

3.1 记忆写入的触发条件与内容裁剪

不是所有对话都值得写入长期记忆。我一开始的做法是每轮都写,结果向量库里塞满了“好的”“谢谢”“明白了”这种废话,检索的时候噪声极大。后来改成用一个小模型做判断,prompt大概是:“以下对话轮次是否包含用户偏好、事实性信息、任务约束或重要决策?如果是,提取关键信息;如果不是,返回空。”

这个判断步骤会增加一次LLM调用,但换来的是检索质量的显著提升。实测下来,写入量减少了约70%,但检索命中率反而提高了。内容裁剪方面,我习惯把一轮对话压缩成一条结构化记录,包含时间戳、会话ID、关键实体、摘要文本。摘要文本控制在200字以内,太长的话嵌入向量的语义会发散。

注意:写入判断的prompt里一定要明确“不要记录寒暄和确认性回复”,否则模型很容易把“好的”也当成重要信息。

3.2 检索策略:什么时候查、查多少、怎么排

检索触发时机一般有两种:每轮对话开始前固定检索,或者由Agent自己决定是否调用检索工具。前者简单但可能引入无关信息,后者灵活但对Agent的规划能力要求高。我目前用的是混合策略:每轮开始前做一次轻量检索,同时把memory_search作为工具暴露给Agent,让它可以在任务中途主动查。

检索数量上,top-3到top-5是比较舒服的区间。太少可能漏掉关键信息,太多会挤占上下文窗口。排序方面,除了向量相似度,我还会加一个时间衰减因子——越近的记忆权重越高。公式大概是:score = similarity * 0.7 + recency_score * 0.3。recency_score用指数衰减,半衰期设成7天左右,这样一个月前的记忆权重会降到很低,但不会完全消失。

3.3 记忆的遗忘机制与冲突处理

记忆不是只进不出的。用户改了偏好、任务约束变了、旧信息被证伪了,这些情况都需要更新或删除记忆。我的做法是给每条记忆加一个status字段,可以是active、superseded、deleted。当新记忆和旧记忆冲突时,把旧记忆标记为superseded,检索时默认只返回active的。

冲突检测可以用LLM来做,prompt大概是:“以下两条记忆是否矛盾?如果矛盾,哪条更新?”这个判断不需要太精确,宁可多标几个superseded,也不要让矛盾信息同时出现在上下文里。我踩过的坑是:早期没有做冲突处理,结果Agent一会儿说“用户喜欢简洁回复”,一会儿说“用户要求详细解释”,直接精神分裂。

3.4 Docker环境下的存储选型与网络配置

存储选型上,向量检索我用的是Qdrant,轻量、Docker镜像小、API简单。关系型元数据用PostgreSQL,跟Qdrant配合做混合检索。嵌入模型用的是一个本地部署的小模型,通过FastAPI封装成HTTP服务,也跑在Docker里。这样整个记忆栈就是三个容器:qdrant、postgres、embedding-service,再加一个MCP server容器做协议转换。

网络配置上,我建了一个叫memory-net的bridge网络,四个容器都接进去。MCP server暴露一个端口给外部Agent容器,其他三个容器的端口只在memory-net内部可见。Docker compose里用expose而不是ports来声明内部端口,避免意外暴露到宿主机。

services: qdrant: image: qdrant/qdrant:latest networks: - memory-net expose: - "6333" postgres: image: postgres:16 networks: - memory-net expose: - "5432" environment: POSTGRES_PASSWORD: ${PG_PASS} embedding: build: ./embedding networks: - memory-net expose: - "8000" mcp-server: build: ./mcp-server networks: - memory-net ports: - "8080:8080" depends_on: - qdrant - postgres - embedding networks: memory-net: driver: bridge

提示:PostgreSQL的密码一定要用环境变量注入,不要写在compose文件里。我见过有人直接把密码硬编码然后推到公开仓库,后果不用多说。

4. 实操过程与核心环节实现

4.1 从零搭建记忆服务的完整步骤

第一步,先把目录结构定好。我习惯这样组织:

memory-stack/ ├── docker-compose.yml ├── embedding/ │ ├── Dockerfile │ └── app.py ├── mcp-server/ │ ├── Dockerfile │ └── server.py └── .env

第二步,写embedding服务。用FastAPI包一个sentence-transformers模型,接口就两个:/embed接收文本返回向量,/health做健康检查。模型选all-MiniLM-L6-v2,384维,速度快,效果对记忆检索够用。

from fastapi import FastAPI from sentence_transformers import SentenceTransformer app = FastAPI() model = SentenceTransformer('all-MiniLM-L6-v2') @app.post("/embed") def embed(texts: list[str]): vectors = model.encode(texts, normalize_embeddings=True) return {"vectors": vectors.tolist()} @app.get("/health") def health(): return {"status": "ok"}

第三步,写MCP server。核心是三个工具:memory_write、memory_search、memory_forget。写入时先调embedding服务拿向量,然后同时写Qdrant和Postgres。检索时先embed query,然后查Qdrant拿top-k,再回Postgres补元数据,最后按时间衰减重排。

from mcp.server import Server from mcp.types import Tool, TextContent import httpx app = Server("memory-server") @app.list_tools() async def list_tools(): return [ Tool(name="memory_write", description="写入一条记忆", inputSchema={...}), Tool(name="memory_search", description="检索相关记忆", inputSchema={...}), Tool(name="memory_forget", description="标记记忆为失效", inputSchema={...}), ] @app.call_tool() async def call_tool(name, arguments): if name == "memory_write": text = arguments["text"] vec = await embed(text) await qdrant_upsert(vec, arguments) await pg_insert(arguments) return [TextContent(type="text", text="written")] elif name == "memory_search": query = arguments["query"] vec = await embed(query) hits = await qdrant_search(vec, top_k=5) enriched = await pg_enrich(hits) ranked = recency_rerank(enriched) return [TextContent(type="text", text=format_results(ranked))]

第四步,docker compose up -d启动。第一次启动时Qdrant和Postgres需要初始化,等健康检查通过后再启动mcp-server。compose里的depends_on配合healthcheck可以控制启动顺序。

4.2 记忆写入与检索的参数计算

嵌入向量的维度是384,Qdrant里用Cosine距离。检索时top_k设5,但实际注入上下文的只取前3条,留2条做缓冲。时间衰减的半衰期我设的是7天,计算公式:

recency_score = 0.5 ** (days_ago / 7)

比如3天前的记忆,recency_score ≈ 0.74;14天前的,≈ 0.25;30天前的,≈ 0.05。最终排序分数:

final_score = 0.7 * cosine_similarity + 0.3 * recency_score

这个权重是我调了几轮之后定下来的。相似度权重太高会导致旧记忆霸屏,时间权重太高会让检索变得短视。0.7/0.3在我的场景里比较平衡,你可以根据自己的数据分布微调。

4.3 Agent侧接入MCP记忆服务的配置

Agent侧我用的是一个基于LLM的编排框架,配置MCP server的地址就行。关键是要在system prompt里告诉Agent:“你可以使用memory_search工具来回忆过去的相关信息,使用memory_write来记录重要信息。”同时要在工具调用循环里处理好MCP的返回格式。

我实测下来,Agent主动调用memory_search的频率并不高,大部分时候还是靠每轮开始前的自动检索。但把工具暴露出去有个好处:当Agent遇到需要回忆细节的任务时,它知道自己有这个能力,不会瞎编。

4.4 完整链路的联调与验证

联调的时候,我写了一个简单的测试脚本:先写入几条记忆,然后模拟几轮对话,看检索结果是否符合预期。测试用例包括:同义改写查询、时间衰减验证、冲突记忆处理。比如写入“用户喜欢简洁回复”和“用户要求详细解释”两条矛盾记忆,看检索时是否只返回最新的那条。

验证通过后,把整个栈接到实际的Agent工作流里跑一周,观察记忆写入量、检索命中率、上下文token消耗。我自己的数据是:每天约200轮对话,写入约60条记忆,检索命中率(人工抽检)约85%,上下文里记忆部分平均占300-500 token,完全可以接受。

5. 常见问题与排查技巧实录

5.1 记忆检索返回无关内容的排查思路

最常见的问题是检索出来的记忆跟当前query八竿子打不着。排查顺序:先看embedding服务是否正常,用相同的文本调两次/embed,看向量是否一致;再看Qdrant里的collection配置,距离度量是不是Cosine,维度是不是384;然后检查写入时文本是否被截断或污染,比如把整个对话历史当成一条记忆写进去了。

我遇到过一次,检索结果全是“好的”“谢谢”,原因是写入判断的prompt没写好,模型把确认性回复也标成了重要信息。改prompt之后问题消失。所以写入质量直接决定检索质量,这一步不能偷懒。

5.2 Docker网络不通的典型场景

MCP server连不上Qdrant,报Connection refused。先确认两个容器是否在同一个network里,docker network inspect memory-net看一眼。然后确认Qdrant的端口是expose还是ports,如果是expose,宿主机访问不了但同网络容器可以。再检查Qdrant的启动日志,有时候是存储卷权限问题导致服务没起来。

还有一个坑:Docker Desktop在Windows上默认用WSL2后端,容器间的DNS解析有时候会抽风。解决办法是在compose里给每个服务加container_name,然后用容器名当主机名,不要用localhost。

5.3 记忆膨胀导致上下文超限的处理

跑了一段时间后,working memory里注入的记忆越来越多,prompt token超了。这时候需要做两件事:一是收紧检索的top-k,从5降到3;二是给working memory设一个token预算,比如最多500 token,超了就按分数砍掉最低的。另外,定期跑一个清理任务,把superseded和超过90天没被检索过的active记忆归档。

5.4 常见问题速查表

问题现象可能原因排查手段解决方式
检索结果无关嵌入质量差或写入噪声大检查embedding输出、抽查写入内容优化写入判断prompt、换嵌入模型
连接Qdrant失败网络不通或服务未启动docker network inspect、看容器日志确认同网络、检查healthcheck
上下文token超限检索条数过多或记忆过长统计prompt token分布降低top-k、压缩记忆文本
记忆冲突未做冲突检测抽查检索结果是否矛盾加status字段、LLM冲突判断
写入量过大触发条件太宽松统计每日写入条数收紧判断prompt、加去重

5.5 几个我踩过的坑和对应技巧

第一个坑:早期用localhost在compose里配服务地址,结果容器内解析不到。后来全部改成服务名,问题解决。第二个坑:Qdrant的collection没设on_disk,数据量大了之后内存爆了。改成on_disk: true之后稳定了。第三个坑:embedding服务没做批处理,逐条embed延迟很高。改成批量接口后,写入吞吐提升了大概5倍。

提示:如果你用的是Docker Desktop on Windows,记得在设置里把WSL2的内存限制调大,默认可能只有2GB,跑向量数据库很容易OOM。

6. 记忆服务的扩展方向与个人体会

这套hindsight架构跑了大半年,最大的体会是:Agent的记忆不是越多越好,而是越准越好。我见过太多项目把记忆当成一个“什么都往里塞”的桶,结果检索出来的全是噪声,Agent反而变得更笨。写入判断、检索排序、冲突处理,这三个环节每一个都值得花时间打磨。

扩展方向上,我最近在试的是分层记忆——把记忆分成“会话级”“任务级”“用户级”三层,检索时按层级加权。会话级的记忆权重最高但生命周期最短,用户级的记忆权重低但持久。另外,记忆的图结构化也是一个有意思的方向,把实体和关系抽出来存成图,检索时可以做多跳推理。不过这些还在实验阶段,等跑稳了再单独写一篇。

如果你也在做Agent记忆相关的东西,建议先从最简单的向量检索加时间衰减开始,跑通了再逐步加复杂度。一上来就搞太复杂的架构,调试成本会高到让你想放弃。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 6:54:29

MRAM+SPI接口实现工业数据记录:PIC32MX驱动设计与掉电保护实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 6:53:32

Vensim系统动力学仿真:从下载安装到实战建模全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 6:52:37

工业嵌入式存储:用MRAM替代EEPROM与SPI Flash解决掉电数据丢失

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 6:52:08

Angular依赖注入与模块化架构:从注入器层级到Standalone实战

开头部分:如果你写过几年 Angular,大概率会有这种感觉:依赖注入(DI)和模块化架构并不是“懂不懂”的问题,而是“用得顺不顺手”的问题。同样是注册一个服务,有的人在模块里写个 providers 就完事…

作者头像 李华