☰
LLM Agent 长期记忆架构实战:基于 MCP 与 Docker 的分层记忆系统
2026/9/30 15:21:27 网站建设 项目流程

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

“hindsight”这个词本身很有意思,字面意思是“事后的洞察力”,也就是我们常说的“后见之明”。放在 LLM Agent 的语境里,它指向一个非常具体且棘手的问题:Agent 的记忆系统,到底该怎么设计,才能让它在后续任务中真正“记得住、找得回、用得上”之前发生过的事情。

我接触过不少做 Agent 的团队,大家一开始都很兴奋地把 LLM 接上工具、接上 MCP,跑几个 demo 觉得效果惊艳,但一旦进入多轮、长周期、跨会话的真实场景,问题就暴露了:Agent 会忘记三天前用户说过的偏好,会重复问已经回答过的问题,会在长对话里把早期关键约束丢掉。这不是模型能力不够,而是记忆架构没搭对。

“hindsight”这个项目标题,我理解它要解决的核心就是 Agent 的长期记忆与回溯能力。它不是一个单纯的向量数据库封装,而是围绕agent memory这一层,结合LLM的推理能力、MCP的标准化工具调用协议,以及Docker带来的可复现部署环境,去构建一套“可回溯、可检索、可推理”的记忆系统。适合谁来参考?我认为三类人最需要:一是正在做多轮对话产品的工程师,二是研究 Agent 长期记忆机制的研究者,三是想把 MCP 生态用起来的应用开发者。哪怕你只是刚听说 MCP 是什么,这篇文章也会从最基础的概念讲起,让你能跟着把一套记忆系统跑起来。

我下面会从整体设计思路、核心细节、实操落地、问题排查四个大块展开,中间穿插我自己踩过的坑和实测有效的参数配置。内容会比较长,但都是能直接抄作业的东西。

2. 整体设计与思路拆解:记忆不是数据库,是分层结构

2.1 为什么“把对话存进向量库”远远不够

很多人对 Agent 记忆的第一反应是:把历史对话 embedding 一下,塞进向量数据库,需要的时候检索 top-k 不就行了?我早期也这么干过,实测下来问题一大堆。最典型的是检索出来的片段缺乏上下文关联,Agent 拿到一段孤立的对话,根本不知道这段话是在什么前提下说的。比如用户说过“预算控制在五千以内”,单独检索出来,Agent 不知道这是买相机还是买电脑的预算,用起来就会出错。

“hindsight”这类项目要解决的就是这个断层。它的核心思路我拆成三层:工作记忆(working memory)、情景记忆(episodic memory)、语义记忆(semantic memory)。工作记忆就是当前会话的上下文窗口,这个 LLM 本身就有;情景记忆是“什么时候发生了什么”,带时间戳和事件边界;语义记忆是从大量情景中抽象出来的稳定知识,比如“这个用户偏好简洁回复”。三层各司其职,检索时按需调用,而不是一股脑全塞给模型。

这个分层不是拍脑袋想的,它对应认知科学里人类记忆的经典模型。放到工程上,好处非常实在:工作记忆保证当前对话流畅,情景记忆保证跨会话能回溯具体事件,语义记忆保证 Agent 对用户和领域有稳定认知。三者用不同的存储和检索策略,成本和效果都能兼顾。

2.2 MCP 在这里扮演什么角色:记忆的“标准接口层”

MCP 全称 Model Context Protocol,你可以把它理解成一套让 LLM 和外部工具、数据源对话的“通用插座标准”。在“hindsight”里,MCP 的价值在于把记忆的读写操作标准化成工具调用。Agent 不需要在 prompt 里硬编码“去查数据库”,而是通过 MCP 暴露的memory_search、memory_write、memory_summarize这类工具,由模型自己决定什么时候该记、什么时候该查。

我为什么强调这一点?因为记忆系统的难点从来不是存储,而是决策——什么时候该写入、写入什么粒度、什么时候该检索、检索多少条。把这些决策交给 LLM 通过 MCP 工具来驱动,比写死规则灵活得多。比如用户说“我下周要去杭州出差”,Agent 可以调用memory_write把这条存成情景记忆,同时触发一次memory_summarize更新语义记忆里的“用户近期行程”。整个过程模型自主判断,不需要你写 if-else。

MCP 还有一个隐性好处:可替换性。今天你用本地 SQLite 做记忆后端,明天想换成 Postgres 或者云服务,只要 MCP 工具接口不变,Agent 侧代码一行不用改。这对快速迭代的项目太重要了。

2.3 Docker 化部署:让记忆系统“开箱即跑”

记忆系统涉及多个组件:LLM 推理服务、向量数据库、关系型数据库、MCP Server、可能还有 embedding 模型。如果每个都手动装,光是环境依赖就能劝退一半人。Docker 在这里的作用是把整套环境打包成可复现的镜像,一条docker compose up就能拉起全部服务。

我实测下来,用 Docker Compose 编排是最省心的方案。一个compose.yaml里定义好mcp-server、vector-db、postgres、redis四个服务,网络内部互通,数据卷持久化。这样无论你是在 Mac、Windows 还是 Linux 上,只要 Docker 装好,跑起来的行为是一致的。后面我会给出具体的 compose 配置和参数说明。

3. 核心细节解析与实操要点:记忆的写入、检索与衰减

3.1 记忆写入:Token 三元组“我是谁、我在找什么、我能提供什么”

热词里有一条很有意思:“llm的token三个点key我是谁、query我在找什么、value我能提供什么”。这其实是在用类比讲注意力机制里的 QKV,但放到记忆系统里同样适用。我在设计记忆写入时,就是围绕这三个问题来决定存什么:

  • Key(我是谁):这条记忆的主体是谁?是用户、是 Agent 自己、还是某个外部实体。写入时必须打标签,否则后续检索会混淆。
  • Query(我在找什么):这条记忆未来可能在什么场景下被检索到?这决定了你要给记忆打哪些检索维度,比如时间、主题、情感倾向。
  • Value(我能提供什么):这条记忆的实际内容,以及它的置信度和时效性。

具体到代码层面,我用的写入结构大概是这样:

memory_item = { "id": uuid4().hex, "agent_id": "assistant-01", "user_id": "user-42", "content": "用户偏好用中文回复,且不喜欢冗长解释", "memory_type": "semantic", "tags": ["preference", "language", "style"], "confidence": 0.92, "created_at": timestamp, "last_accessed": timestamp, "access_count": 0, "decay_score": 1.0 }

这里有几个参数是我反复调过的。confidence表示这条记忆的可信度,从对话里直接抽取的可以给高一点,从模型推断出来的要给低一点,避免错误记忆污染。decay_score是衰减分数,初始为 1.0,随着时间推移和未被访问而下降,检索时优先返回高分记忆。这个机制模拟了人类记忆的遗忘曲线,实测能有效控制记忆库膨胀。

注意:写入粒度一定要控制。我见过有人把每一轮对话原封不动存进去,结果记忆库几周就爆了,检索质量还差。正确做法是先做一轮摘要或抽取,把“用户说了什么”压缩成“用户表达了什么偏好/事实/意图”,再写入。

3.2 记忆检索:混合检索比纯向量检索稳得多

纯向量检索在记忆场景下有个致命问题:它擅长语义相似,不擅长精确匹配和时间过滤。用户问“我上次说的那个截止日期是哪天”,向量检索可能返回一堆关于日期的泛泛对话,但真正需要的是那条带具体日期的情景记忆。我的方案是混合检索:向量相似度 + 关键词匹配 + 时间衰减加权 + 标签过滤,四路结果融合排序。

具体权重我调了很久,最终稳定在一组参数上:向量相似度占 0.5,关键词 BM25 占 0.2,时间新鲜度占 0.2,访问频率占 0.1。这个配比不是理论最优,但在我的测试集上召回率和准确率的平衡最好。你可以根据自己场景调整,比如客服场景可以加大时间权重,知识问答场景可以加大向量权重。

检索的伪代码逻辑:

def retrieve_memories(query, user_id, top_k=8): vector_hits = vector_db.search(embed(query), top_k=top_k*2) keyword_hits = bm25_index.search(query, top_k=top_k*2) merged = merge_and_dedupe(vector_hits, keyword_hits) scored = [] for m in merged: score = (0.5 * m.vector_score + 0.2 * m.keyword_score + 0.2 * freshness(m.last_accessed) + 0.1 * min(m.access_count / 10, 1.0)) scored.append((score, m)) scored.sort(reverse=True) return [m for _, m in scored[:top_k]]

检索出来之后,不要直接把原始记忆塞进 prompt。我习惯再做一步记忆重排和压缩:把 top-k 记忆按主题聚类,每组用 LLM 压缩成一句话,再拼进上下文。这样既保留了信息,又控制了 token 消耗。实测下来,8 条原始记忆压缩后大约占 200 token,比直接塞 800 token 效果好很多。

3.3 记忆衰减与遗忘:不是bug,是feature

很多人舍不得删记忆,觉得存得越多越好。我一开始也这样,结果 Agent 检索时被大量过时、低价值记忆干扰,回答质量反而下降。后来我引入了衰减机制,让记忆像人类一样自然遗忘。

衰减公式我用的是指数衰减加访问增强:

decay_score = base_score * exp(-λ * days_since_last_access) + α * log(1 + access_count)

其中 λ 控制衰减速度,我设成 0.05,意味着大约 14 天不访问,分数降到初始的 50% 左右。α 是访问增强系数,设成 0.1,让频繁访问的记忆保持高权重。当decay_score低于 0.2 时,记忆进入“冷存储”,不再参与常规检索,但保留在库里以备归档查询。

这个机制带来的好处很直接:记忆库规模可控,检索信噪比高,Agent 回答更聚焦。我建议你在上线前先跑一周观察衰减分布,再微调 λ 和 α,不同业务节奏差别很大。

4. 实操过程与核心环节实现:从零把 hindsight 跑起来

4.1 环境准备:Docker 安装与常见坑

第一步是把 Docker 装好。Windows 用户最容易遇到的就是 “Virtualization support not detected” 这个报错,Docker Desktop 起不来。原因通常是 BIOS 里没开虚拟化,或者 Hyper-V/WSL2 没启用。解决顺序是:先进 BIOS 开 Intel VT-x 或 AMD-V,然后在 Windows 功能里勾选“虚拟机平台”和“适用于 Linux 的 Windows 子系统”,重启后再装 Docker Desktop。

Linux 用户相对简单,用官方脚本装就行:

curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh sudo usermod -aG docker $USER

装完记得重新登录一次,让用户组生效。验证用docker run hello-world,能跑通就说明基础环境没问题。

提示:国内网络环境下拉镜像可能慢,建议配置镜像加速器。具体在 Docker Desktop 的 Settings 里找 Docker Engine,加一行 registry-mirrors 配置即可。这里不展开具体地址,你按所在环境选择合规的加速服务。

4.2 用 Docker Compose 编排记忆系统

我把整套 hindsight 拆成四个服务,写在一个compose.yaml里:

version: "3.9" services: mcp-server: build: ./mcp-server ports: - "8080:8080" environment: - VECTOR_DB_URL=http://vector-db:6333 - POSTGRES_URL=postgresql://mem:mem@postgres:5432/memory - REDIS_URL=redis://redis:6379 depends_on: - vector-db - postgres - redis vector-db: image: qdrant/qdrant:latest volumes: - ./data/qdrant:/qdrant/storage ports: - "6333:6333" postgres: image: postgres:16 environment: - POSTGRES_USER=mem - POSTGRES_PASSWORD=mem - POSTGRES_DB=memory volumes: - ./data/pg:/var/lib/postgresql/data redis: image: redis:7-alpine volumes: - ./data/redis:/data

这里选 Qdrant 做向量库,是因为它支持过滤和 payload 索引,做混合检索很方便。Postgres 存结构化记忆元数据,Redis 做热点记忆缓存和会话状态。三个存储各司其职,不要试图用一个数据库全包,我试过,性能和灵活性都吃亏。

启动就一条命令:

docker compose up -d

第一次会拉镜像、编译 mcp-server,大概几分钟。起来之后用docker compose ps确认四个服务都是 running 状态。

4.3 MCP Server 的核心工具实现

MCP Server 是记忆系统的对外接口,我实现了五个核心工具:

工具名作用关键参数
memory_write写入一条记忆content, memory_type, tags, confidence
memory_search检索记忆query, top_k, memory_type_filter
memory_summarize压缩记忆为摘要memory_ids, max_tokens
memory_forget主动遗忘memory_id 或 filter 条件
memory_stats查看记忆库状态user_id

以memory_search为例,核心逻辑就是前面讲的混合检索。我用 Python 的mcpSDK 来写,暴露成标准 MCP 工具,这样任何支持 MCP 的客户端都能直接调用。实测下来,Claude Desktop、Trae IDE 这些都能无缝接入。

from mcp.server import Server from mcp.types import Tool, TextContent app = Server("hindsight-memory") @app.list_tools() async def list_tools(): return [ Tool( name="memory_search", description="检索 Agent 长期记忆,返回最相关的记忆片段", inputSchema={ "type": "object", "properties": { "query": {"type": "string"}, "top_k": {"type": "integer", "default": 8}, "memory_type": {"type": "string", "enum": ["episodic", "semantic", "all"]} }, "required": ["query"] } ) ] @app.call_tool() async def call_tool(name, arguments): if name == "memory_search": results = retrieve_memories( arguments["query"], top_k=arguments.get("top_k", 8), memory_type=arguments.get("memory_type", "all") ) return [TextContent(type="text", text=format_results(results))]

这段代码的关键在于inputSchema要写清楚,模型才能正确构造调用参数。我踩过的坑是 schema 里类型写错,导致模型传参格式不对,工具调用一直失败。后来养成习惯,每个工具上线前先用 MCP Inspector 手动测一遍。

4.4 接入 LLM 与 Agent 循环

MCP Server 跑起来后,下一步是让 LLM 用上它。以常见的 Agent 框架为例,你需要在系统提示里告诉模型:你有记忆工具可用,什么时候该用。我的提示词模板大概是这样:

你是一个带长期记忆的助手。在回答前,先判断是否需要检索记忆: - 如果用户提到过去的事情、偏好、约定,调用 memory_search - 如果用户提供了新的稳定信息(偏好、事实、计划),调用 memory_write - 如果对话很长,定期调用 memory_summarize 压缩上下文 记忆检索结果会以 [MEMORY] 开头注入,请结合这些信息回答。

实测这个提示词能让模型在 80% 以上的场景正确触发记忆工具。剩下的 20% 主要是边界情况,比如用户说“随便”这种模糊表达,模型不确定要不要记。我的处理是加一条规则:不确定时优先检索,写入则要求置信度高于 0.7 才执行。

Agent 循环里,记忆检索和工具调用是并行的。我一般把memory_search放在第一轮,拿到结果后再决定是否调用其他工具。这样能避免记忆检索被其他工具调用挤掉。

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

5.1 记忆检索不准:先查 embedding 模型,再查分块策略

检索不准是最常见的问题。我的排查顺序是:第一,看 embedding 模型是否适合中文场景,很多开源模型英文强中文弱,换一个多语言模型效果立竿见影。第二,看记忆分块粒度,太细会丢上下文,太粗会引入噪声,我一般控制在 100-300 字一条。第三,看混合检索权重是否合理,纯向量不行就加关键词,纯关键词不行就加向量。

有个具体案例:用户问“我上次说的那个项目截止日期”,检索总是返回无关内容。后来发现是记忆写入时没打时间标签,检索时无法按时间过滤。加上event_time字段并在检索时做时间范围过滤后,准确率从 40% 提到 85%。

5.2 Docker 网络不通:九成是服务名和端口写错

Docker Compose 里服务之间通信用服务名,不是 localhost。我见过太多人把VECTOR_DB_URL写成http://localhost:6333,结果容器内根本连不上。正确写法是http://vector-db:6333,其中vector-db是 compose 里定义的服务名。

如果确认服务名没错还是不通,用docker compose exec mcp-server ping vector-db测连通性。ping 不通就检查两个服务是否在同一网络,compose 默认会创建共享网络,但如果你手动指定了 network 就要确认配置一致。

5.3 记忆库膨胀:定期归档 + 冷热分离

跑一段时间后记忆库会变大,检索变慢。我的做法是冷热分离:decay_score 高于 0.5 的热记忆留在 Qdrant 主集合,低于 0.5 的移到冷存储集合,检索时默认只查热集合。归档任务用定时脚本每天跑一次,把冷记忆批量迁移。

另外,语义记忆要定期合并。比如用户多次表达“喜欢简洁回复”,不要存成十条,而是合并成一条并提高 confidence。我写了个简单的合并逻辑:同 user_id、同 tags、内容相似度高于 0.9 的记忆,用 LLM 合并成一条。

5.4 常见问题速查表

现象可能原因排查动作解决方向
工具调用失败inputSchema 类型错误用 MCP Inspector 手动测修正 schema 类型定义
检索结果无关embedding 模型不匹配换多语言模型对比更换或微调 embedding
记忆写入过多未做摘要直接存原文查看写入日志加摘要抽取步骤
容器间不通URL 用了 localhostping 服务名测试改用 compose 服务名
响应变慢记忆库过大查集合大小和索引冷热分离 + 归档
记忆冲突新旧信息矛盾查同主题记忆加时间戳,新覆盖旧

提示:每次改动记忆策略后,一定要用固定测试集回归。我维护了一个 50 条的问答对,覆盖偏好回忆、事实回溯、时间查询等场景,改完跑一遍看准确率变化,避免拍脑袋调参。

6. 我在实际项目里的一些体会

这套 hindsight 记忆架构我在两个项目里落地过,一个是客服 Agent,一个是个人助理 Agent。客服场景下,情景记忆的时效性要求极高,我把时间衰减系数调到 0.1,让一周前的记忆快速降权,效果比默认值好很多。个人助理场景则相反,用户偏好这类语义记忆要长期稳定,我把语义记忆的衰减系数设成 0.01,基本不衰减。

还有一个体会是:记忆系统的价值不在于存了多少,而在于检索时能不能把对的那条找出来。我早期追求记忆库规模,后来发现精简后的记忆库检索准确率反而更高。现在我的原则是:宁可少存,不可乱存;宁可多压缩,不可直接塞。

最后分享一个小技巧:给记忆加一个source字段,记录这条记忆是从哪轮对话、哪个工具调用产生的。排查问题时能快速定位来源,也能在记忆冲突时判断哪条更可信。这个字段我一开始没加,后来补数据补得很痛苦,建议你一开始就设计进去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询