拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LLM Agent记忆系统实战:分层架构、MCP集成与Docker部署

LLM Agent记忆系统实战:分层架构、MCP集成与Docker部署

1. 从“hindsight”这个词说起:为什么记忆是Agent最被低估的能力

第一次看到“hindsight”这个标题,我脑子里蹦出来的不是技术,而是一句老话——事后诸葛亮。但恰恰是这个略带调侃的词,点中了当前LLM Agent领域最要命的一个短板:大多数Agent只有“当下”,没有“过去”。

你让它处理一个任务,它做得挺好;你换个会话再问它,它完全不记得你上次说过什么。这不是模型不够聪明,而是它的记忆机制压根没设计好。hindsight这个词本身就有“后见之明”的意思,放在Agent语境里,我理解它想解决的核心问题是:如何让Agent在任务执行之后,把有价值的经验沉淀下来,并在后续决策中真正用上。

结合热搜词里反复出现的agent memory、working memory、MCP、Docker这些关键词,可以判断这个项目大概率是一个围绕Agent记忆系统的工程实践,而且很可能涉及MCP协议做工具调用、Docker做环境隔离。它适合谁看?如果你正在做Agent应用,被“上下文窗口不够用”“多轮对话记不住”“跨会话状态丢失”这些问题折磨过,那这篇内容就是写给你的。

我先把话说在前面:记忆系统不是简单地把历史对话塞进prompt就完事了。那样做的结果只有一个——token爆炸,成本飙升,而且模型还会被无关信息干扰。真正要做的是分层、有策略、可检索、能遗忘。下面我按自己踩过的坑和实际落地的思路,把这个事情拆开讲。

2. Agent记忆的三个层次:working memory、episodic memory和semantic memory

2.1 为什么不能只有一份“对话历史”

很多人做Agent的第一反应是维护一个messages数组,每次调用把整个数组传进去。这个做法在demo阶段没问题,一旦上生产就崩。原因很简单:上下文窗口是有限资源,而对话历史是无限增长的。

我实测过一个客服场景,连续对话20轮之后,prompt长度轻松突破8000 token,其中至少60%是重复的寒暄和已经解决的问题。模型不仅响应变慢,还开始“幻觉”——把之前用户的错误信息当成事实继续用。

所以记忆必须分层。参考认知科学的分类,我在工程上把它拆成三层:

  • Working Memory(工作记忆):当前任务正在用的信息,生命周期最短,通常就是最近几轮对话加上当前任务的中间状态。它必须始终在上下文里,因为模型每一步都要用。
  • Episodic Memory(情景记忆):具体发生过的事件,比如“用户上周三反馈过登录失败”“这个任务上次执行到第三步超时了”。它按时间或任务维度存储,需要时检索出来。
  • Semantic Memory(语义记忆):从多次交互中提炼出的稳定知识,比如“这个用户偏好中文回复”“这个API的限流阈值是100 QPS”。它是去时间化的,是Agent的“常识”。

这三层的读写策略完全不同。Working memory是高频读写、容量小;episodic memory是写多读少、按需检索;semantic memory是低频写、高频读、需要定期合并更新。

2.2 三层记忆的存储选型与数据结构

选型上我走过弯路。一开始想用向量数据库一把梭,把所有记忆都embedding存进去,检索的时候按相似度捞。结果发现working memory根本不需要向量检索——它就是最近N条,直接放list里就行,用向量库反而增加延迟。

我现在的做法是这样的:

记忆层存储方案数据结构读写频率
Working Memory内存(进程内)环形缓冲区 + 任务状态字典每步读写
Episodic MemorySQLite / PostgreSQL表:id, session_id, timestamp, content, embedding, metadata写多读少
Semantic Memory向量库 + KV向量索引 + 键值对(用于精确匹配)读多写少

这里有个细节:episodic memory我坚持用关系型数据库打底,而不是纯向量库。因为情景记忆经常需要按时间范围、按session_id过滤,这些是关系型数据库的强项。向量检索只作为其中一种查询方式,不是唯一方式。

Semantic memory则相反,它主要是“相似语义召回”,向量库更合适。但我也保留了一个KV结构,用于存储那些需要精确匹配的条目,比如用户ID对应的偏好设置。

2.3 记忆的写入时机:不是每句话都值得记

这是我最想强调的一点。很多Agent项目失败不是因为记不住,而是因为记得太多太杂。

我见过一个实现,把用户每一句话都embedding存进向量库。结果检索的时候,捞出来的全是“好的”“谢谢”“嗯嗯”这种废话,真正有用的信息被淹没了。

我的策略是:写入前先做价值判断。具体做法是让模型在每轮对话结束后输出一个结构化结果,包含三个字段:

{ "should_remember": true, "memory_type": "episodic", "content": "用户反馈登录接口在弱网环境下超时,错误码504", "importance": 0.8 }

只有should_remember为true且importance超过阈值(我一般设0.6)的才写入长期记忆。这个判断本身也消耗token,但相比存一堆垃圾再花更多token去检索和过滤,这笔账是划算的。

提示:importance阈值不要设太低。我试过0.3,结果记忆库迅速膨胀,检索质量断崖式下降。0.6到0.7是比较舒服的区间。

3. 用MCP把记忆能力做成可插拔的服务

3.1 MCP到底解决了记忆系统的什么问题

MCP在这套架构里的角色,说白了就是让记忆系统从Agent主体里解耦出来。没有MCP的时候,记忆的读写逻辑是硬编码在Agent代码里的,换个模型、换个框架就得重写。有了MCP,记忆变成一个独立的server,Agent通过标准协议去调用。

热搜词里出现了“mcp是什么”“agent mcp”“playwright mcp”“blender mcp”这些,说明MCP正在成为Agent工具调用的事实标准。它的核心价值是协议统一:不管你的Agent是用什么语言写的、跑在什么环境里,只要实现了MCP client,就能调用任何MCP server提供的能力。

对于记忆系统来说,这意味着我可以把memory server单独部署、单独扩容、单独做持久化,Agent本身不需要关心记忆存在哪、怎么检索。

3.2 记忆MCP Server的接口设计

我设计的memory MCP server暴露了四个核心tool:

  • memory_write:写入一条记忆。参数包括content、memory_type、importance、metadata。返回记忆ID。
  • memory_search:检索记忆。参数包括query、memory_type(可选)、top_k、time_range(可选)。返回按相关度排序的记忆列表。
  • memory_forget:删除或降权某条记忆。用于处理错误信息或过期内容。
  • memory_consolidate:触发记忆 consolidation,把多条episodic memory合并成一条semantic memory。

这里重点说consolidate。它的逻辑是:当某个主题下的episodic memory积累到一定数量(比如5条),就调用模型把它们总结成一条semantic memory,然后把这5条标记为已合并。这样做的好处是记忆库不会无限膨胀,而且高层知识会越来越精炼。

# memory_consolidate 的核心逻辑示意 def consolidate(topic, memories): prompt = f"将以下关于'{topic}'的多条记录合并为一条简洁的知识:\n" for m in memories: prompt += f"- {m.content}\n" summary = llm.invoke(prompt) semantic_memory = SemanticMemory( content=summary, source_ids=[m.id for m in memories], topic=topic ) db.save(semantic_memory) db.mark_consolidated([m.id for m in memories])

3.3 MCP连接的实际配置与踩坑

配置MCP连接的时候,我遇到的最大的坑是超时和重试。记忆检索有时候会慢(尤其是向量库数据量大的时候),如果Agent那边设了很短的超时,就会频繁失败。

我的配置是这样的:

{ "mcpServers": { "memory": { "command": "python", "args": ["-m", "memory_server"], "env": { "MEMORY_DB_PATH": "/data/memory.db", "VECTOR_STORE": "qdrant", "QDRANT_URL": "http://localhost:6333" }, "timeout": 30000, "retry": { "max_attempts": 3, "backoff_ms": 500 } } } }

timeout我设了30秒,比默认值大很多。因为记忆检索偶尔会触发向量库的冷启动,第一次查询可能要几秒。如果按默认的5秒,大概率失败。

注意:MCP server的启动命令里不要放太多初始化逻辑。我一开始把向量库的索引构建放在启动时做,结果每次重启都要等好几分钟。后来改成懒加载,第一次查询时才构建索引,启动瞬间完成。

4. Docker化部署:让记忆服务跑得稳、搬得走

4.1 为什么记忆服务必须容器化

记忆服务有两个特点决定了它适合Docker:有状态和依赖复杂。它要连数据库、连向量库、可能还要连模型API,环境依赖一堆。如果直接跑在宿主机上,换台机器就得重新配一遍,而且很容易出现“在我这能跑”的问题。

Docker化之后,整个记忆服务加上它的依赖(SQLite、Qdrant、Redis)可以用docker-compose一键拉起。我在开发机上跑通的配置,直接搬到服务器上就能用,不用改任何东西。

热搜词里“docker安装”“docker desktop安装教程”“windows安装docker”出现频率很高,说明很多读者可能刚接触Docker。我建议记忆服务这种有状态的东西,优先用docker-compose而不是单容器docker run,因为compose能管理多容器之间的依赖和网络。

4.2 docker-compose配置详解

下面是我实际在用的compose文件,做了简化但核心都在:

version: "3.8" services: memory-server: build: ./memory_server ports: - "8080:8080" environment: - DB_URL=postgresql://user:pass@postgres:5432/memory - VECTOR_URL=http://qdrant:6333 - REDIS_URL=redis://redis:6379 depends_on: postgres: condition: service_healthy qdrant: condition: service_started volumes: - ./data/memory:/app/data restart: unless-stopped postgres: image: postgres:16 environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=pass - POSTGRES_DB=memory volumes: - ./data/postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user"] interval: 5s timeout: 5s retries: 5 qdrant: image: qdrant/qdrant:latest ports: - "6333:6333" volumes: - ./data/qdrant:/qdrant/storage redis: image: redis:7-alpine volumes: - ./data/redis:/data

几个关键点:

  • depends_on配healthcheck:postgres必须等健康检查通过再启动memory-server,否则连不上数据库。我一开始没配healthcheck,memory-server启动时报连接拒绝,排查了半天。
  • 数据卷映射到宿主机:所有有状态服务的数据都映射出来,这样容器删了数据还在。我吃过亏,有一次docker-compose down -v把向量库数据全清了,重建索引花了一下午。
  • restart策略:用unless-stopped,容器挂了自动重启,但手动停的不重启。

4.3 资源限制与性能调优

记忆服务对内存比较敏感,尤其是向量检索。我在compose里加了资源限制:

deploy: resources: limits: memory: 2G reservations: memory: 512M

Qdrant默认会尽量用内存做索引缓存,如果不限制,它可能把宿主机内存吃满。2G对于百万级向量是够用的,再大就得考虑分片了。

另外,Postgres的连接池要配好。memory-server如果用同步方式连数据库,并发一高就连接耗尽。我用的asyncpg配连接池,max_size设20,基本够用。

提示:如果你在Windows上用Docker Desktop,记得在设置里把WSL2的内存限制调大。默认可能只有2G,跑Qdrant加Postgres会OOM。我调到8G之后稳定多了。

5. 记忆检索的质量控制:从“能查到”到“查得准”

5.1 纯向量检索为什么不够用

向量检索有个致命问题:它只认语义相似,不认时效和重要性。一条三年前的、importance只有0.2的记忆,可能因为语义上和当前query很像,被排到最前面。而一条昨天的、importance 0.9的记忆,因为用词不同,反而排后面。

我实测过一个case:用户问“上次那个接口的问题解决了吗”,纯向量检索返回的是“接口文档在哪里”这种语义相近但完全没用的记忆。真正该返回的“上周三登录接口504超时”反而没排上来。

5.2 混合排序策略:语义分+时效分+重要性分

我的解决方案是混合排序。最终得分由三部分组成:

final_score = α * semantic_score + β * recency_score + γ * importance_score

其中:

  • semantic_score是向量相似度,归一化到0-1
  • recency_score按时间衰减,我用的是指数衰减:exp(-λ * days_ago),λ取0.1,意味着一周前的记忆得分衰减到约0.5
  • importance_score就是写入时模型打的importance分

α、β、γ三个权重我调过很多次,最终定的是0.5、0.3、0.2。语义相关性还是最重要的,但时效和重要性各占一定比例,能把明显过时或次要的记忆压下去。

这个混合排序在检索层做,不依赖向量库本身。具体实现是先从向量库捞top 50,然后在应用层重新算分排序,取top 5返回给Agent。

5.3 检索结果的去重与压缩

还有一个坑:检索出来的记忆可能高度重复。比如用户三次提到同一个问题,episodic memory里就有三条几乎一样的记录。直接塞给模型,浪费token还干扰判断。

我的做法是在返回前做一次去重。用简单的文本相似度(比如Jaccard相似度)判断,超过0.85的只保留importance最高的那条。更进一步,如果检索出多条同一主题的记忆,可以现场做一次小合并,把多条压缩成一条摘要再返回。

def deduplicate(memories, threshold=0.85): kept = [] for m in sorted(memories, key=lambda x: x.importance, reverse=True): if not any(jaccard(m.content, k.content) > threshold for k in kept): kept.append(m) return kept

这个去重逻辑看起来简单,但效果立竿见影。我测过一个场景,去重前平均返回4.2条记忆,去重后2.1条,token消耗直接减半,而且模型回答的准确率还略有提升——因为干扰信息少了。

6. 记忆的遗忘机制:会忘才会记

6.1 为什么必须主动遗忘

这是最反直觉的一点。大多数人做记忆系统只想着怎么存、怎么查,从来没想过怎么删。但不遗忘的记忆系统一定会崩溃。

原因有三:第一,存储成本无限增长;第二,检索噪声越来越大,有用信息被淹没;第三,过时或错误的记忆会持续误导模型。我见过一个Agent,因为早期存了一条错误的用户偏好,后面所有推荐都是错的,而且它自己意识不到。

6.2 三种遗忘策略

我目前用的是组合策略:

  • 时间衰减遗忘:episodic memory超过90天且importance低于0.5的,自动归档(不是删除,移到冷存储)。semantic memory不设时间限制,因为它本身就是提炼过的。
  • 容量触发遗忘:当某个memory_type的记忆总数超过阈值(我设的是10000条),触发一次清理,按final_score排序,淘汰末尾10%。
  • 冲突替换遗忘:当新写入的记忆和已有记忆矛盾时(比如用户改了偏好),把旧的标记为superseded,检索时默认不返回。

冲突检测我用的是模型判断。写入新记忆时,先检索最相似的3条,让模型判断是否冲突。这个判断也消耗token,但比存着错误信息导致后续全错要划算。

6.3 遗忘不等于删除:归档与可恢复

我坚持一个原则:遗忘是逻辑上的,不是物理上的。所有被“遗忘”的记忆都移到归档表,只是默认检索不返回。万一需要追溯(比如排查为什么Agent做了某个决策),还能捞出来。

归档表的结构和主表一样,只是加了一个archived_at字段。检索时默认加WHERE archived_at IS NULL,需要时去掉这个条件即可。

注意:归档数据也要定期清理。我设的是归档超过一年的直接物理删除。不然归档表也会无限膨胀,只是慢一点而已。

7. 实测中的几个意外发现

7.1 记忆写入的延迟比想象中重要

我一开始是同步写入记忆——每轮对话结束,等记忆写完再返回给用户。结果用户明显感觉响应变慢,因为写入要调模型做价值判断,还要写数据库和向量库,加起来一两秒。

后来改成异步写入:对话结束立即返回,记忆写入放到后台队列。用户无感知,记忆稍微延迟几秒入库,完全不影响体验。这个改动让P95响应时间从3.2秒降到1.8秒。

7.2 记忆检索的top_k不是越大越好

我试过top_k=10,想着多给点信息总没错。结果模型反而更容易跑偏,因为它会抓住某条不相关的记忆过度发挥。top_k=3到5是比较好的区间,既能提供足够上下文,又不会引入太多噪声。

7.3 不同模型对记忆的利用能力差异很大

同一个记忆系统,换不同的LLM,效果差很多。我实测下来,参数量大的模型更能“正确使用”检索回来的记忆,而小模型经常忽略记忆或者错误引用。所以如果你的Agent用的是小模型,记忆系统的检索精度要调得更高,宁可少返回,不要返回错的。

8. 后续可以继续深挖的方向

这套记忆系统跑了一段时间,基本稳定了。但有几个点我还在琢磨。

一个是记忆的跨Agent共享。现在每个Agent有自己的记忆库,但其实很多知识是通用的。如果能做一个共享的semantic memory层,多个Agent都能读写,整体效率会高很多。难点在于权限控制和冲突解决。

另一个是记忆的可解释性。现在模型用了哪条记忆、为什么用,是黑盒。我在尝试让模型在回答时标注引用了哪条记忆ID,这样排查问题会方便很多。初步试下来可行,但会增加输出token。

还有就是记忆的自动评估。怎么知道记忆系统好不好?我现在是靠人工抽查,效率低。理想情况是有一套自动指标,比如检索命中率、记忆利用率、冲突率,能持续监控。这个还在设计中。

如果你也在做Agent记忆相关的东西,欢迎交流。这个领域没有标准答案,很多坑得自己踩过才知道。我上面写的都是实际跑过的配置和代码思路,拿去改改就能用。但记住一点:记忆系统的核心不是技术,是策略——什么该记、什么该忘、什么时候该查,这些判断比用什么数据库重要得多。

返回列表