☰
Agent Memory 实战:基于 MCP 与 Docker 构建智能体记忆系统
2026/9/30 8:16:35 网站建设 项目流程

1. 从“hindsight”说起:为什么记忆是 Agent 落地的最后一公里

“hindsight”这个词本身很有意思,字面意思是“事后的洞察力”,也就是我们常说的“后见之明”。把这个词放到 LLM Agent 的语境里,它指向的其实是一个非常具体、也非常痛的问题:Agent 怎么记住过去发生过的事,并且在需要的时候把这段记忆调出来用。

我接触过不少做 Agent 的团队,大家一开始都把精力砸在工具调用、提示词工程、工作流编排上,等到真正跑起来才发现,一个没有记忆的 Agent 就像金鱼——每一轮对话都是全新的开始,用户上一句说的偏好、上一轮任务踩过的坑、三天前确认过的方案,它统统不记得。用户会疯,开发者也会疯。

所以“hindsight”这个项目标题,我理解它要解决的核心就是Agent Memory(智能体记忆)这件事。它不是一个单纯的“存聊天记录”功能,而是一整套围绕记忆的写入、组织、检索、遗忘、更新的机制。配合热搜词里出现的agent memory、LLM、MCP、Docker,基本可以判断这是一个偏工程化、可本地部署、通过 MCP 协议对外暴露能力的记忆系统。

这篇文章我打算按一个真实项目的推进节奏来写:先讲清楚记忆系统到底要设计哪些东西,再拆核心机制,然后给一套基于 Docker + MCP 的可复现部署方案,最后把我踩过的坑和排查经验整理出来。适合正在做 Agent 应用、被记忆问题折磨过的开发者,也适合刚接触 MCP 想找个真实场景练手的朋友。哪怕你只是好奇“Agent 记忆到底难在哪”,看完应该也能有个清晰的判断。

2. 记忆系统的整体设计与思路拆解

2.1 为什么不能直接用向量库“一把梭”

很多人对 Agent 记忆的第一反应是:搞个向量数据库,把对话历史 embedding 一下存进去,需要的时候相似度检索 top-k 不就完了?我一开始也是这么想的,直到实际跑起来发现一堆问题。

第一个问题是记忆没有结构。对话历史里混杂着事实、偏好、临时状态、任务进度、闲聊,全部塞进一个向量空间,检索出来的东西经常是“语义相似但毫无用处”。比如用户问“我上次说的那个方案改了吗”,向量检索可能召回一堆提到“方案”的闲聊,而不是真正的那条任务状态。

第二个问题是没有时间维度。向量相似度天然是“无时间”的,但记忆恰恰高度依赖时间。三天前的偏好可能已经过期,上一轮刚确认的结论优先级最高。纯向量检索会把新旧信息平等对待,导致 Agent 用过期信息做决策。

第三个问题是写入和更新没有策略。什么该记、什么不该记、记了之后怎么合并、冲突了怎么办,这些在“一把梭”方案里全是空白。结果就是记忆库越来越臃肿,检索质量越来越差。

所以一个像样的 Agent Memory 系统,本质上要解决的是记忆的生命周期管理,而不是简单的存储检索。这也是 hindsight 这类项目存在的意义。

2.2 记忆的分层:working memory 与 long-term memory

热搜词里有个很关键的词叫agent 存储 working memory,这其实点出了记忆系统的第一层设计:分层。

我习惯把 Agent 记忆分成两层:

  • Working Memory(工作记忆):当前会话或当前任务上下文里的短期状态。它容量小、更新频繁、生命周期短,通常就是上下文窗口里那部分内容,或者一个结构化的 session state。它的作用是让 Agent 在“这一轮任务”里保持连贯。
  • Long-term Memory(长期记忆):跨会话、跨任务持久化的信息。它容量大、更新相对慢、需要检索。它存的是用户偏好、历史结论、领域知识、踩过的坑等等。

这两层的读写策略完全不同。Working memory 追求的是“快”和“准”,基本是直接读;Long-term memory 追求的是“召回率”和“相关性”,需要检索和排序。很多项目失败就在于把这两层混在一起,用同一套逻辑处理,结果两头不讨好。

hindsight 这个命名暗示的“事后洞察”,我理解更多是作用在长期记忆层——把过去发生的事沉淀下来,在未来某个时刻被重新调用,形成“后见之明”。

2.3 记忆的 token 三元组:key、query、value

热搜词里有一条特别值得展开:llm的token三个点key我是谁、query我在找什么、value我能提供什么。这其实是一个非常精炼的记忆建模思路,我把它称为记忆三元组。

  • Key(我是谁):这条记忆的“身份标识”。它回答的是“这条记忆是关于什么的”。可以是一个实体(用户、项目、文件)、一个主题、一个时间点。Key 决定了记忆怎么被组织和索引。
  • Query(我在找什么):检索时的意图表达。它回答的是“当前我需要什么信息”。Query 和 Key 的匹配方式,决定了检索的精度。
  • Value(我能提供什么):记忆的实际内容。它回答的是“这条记忆能给出什么”。Value 是最终被注入到上下文里的东西。

这个三元组的好处是把“存储”和“检索”解耦了。存储时关注 Key 和 Value 怎么组织,检索时关注 Query 怎么匹配 Key。比起纯向量相似度,这种结构化建模能大幅提升检索的准确性。

我在实际项目里会把这个三元组进一步落地成一张表或者一个 JSON 结构,每条记忆都有明确的 key 字段、value 字段,以及用于检索的索引字段。这样检索时可以先按 key 过滤,再做语义匹配,效率和精度都能兼顾。

2.4 为什么选 MCP 作为对外接口

热搜词里MCP出现频率极高,还有mcp协议、mcp是什么、agent mcp这些。MCP(Model Context Protocol)本质上是一套让 LLM 应用和外部能力对接的协议标准。把记忆系统做成 MCP Server,好处非常直接:

  • 解耦:记忆系统独立部署,任何支持 MCP 的客户端都能接进来,不绑定特定框架。
  • 标准化:工具定义、参数 schema、调用方式都有统一规范,不用每个 Agent 框架都写一遍适配。
  • 可组合:记忆 MCP 可以和文件系统 MCP、浏览器 MCP、数据库 MCP 一起挂载,Agent 按需调用。

我实测下来,用 MCP 暴露记忆能力,最大的收益是复用性。同一套记忆服务,今天接在 A 框架上,明天换 B 框架,几乎零改动。这对快速迭代的 Agent 项目来说太重要了。

2.5 Docker 化部署的取舍

热搜词里Docker、docker安装、docker desktop、docker网络不通一大堆,说明部署环节是很多人的痛点。记忆系统涉及数据库、向量索引、服务进程,本地裸装很容易把环境搞乱。Docker 化的价值在于:

  • 环境隔离:向量库、关系库、服务各自独立容器,互不污染。
  • 一键复现:docker-compose 一把起,换台机器也能跑。
  • 数据持久化:volume 挂载,容器删了数据还在。

但 Docker 也有坑,尤其是网络和资源限制。后面我会专门讲排查。

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

3.1 记忆写入:什么该记,什么不该记

这是记忆系统里最容易被忽视、却最影响效果的一环。我的经验是,写入策略比检索策略更重要。垃圾进,垃圾出,检索算法再好也救不了。

我一般会设几道过滤:

  • 事实性过滤:只记客观事实和明确结论,不记闲聊和情绪表达。“用户喜欢深色主题”要记,“用户今天心情不错”不用记。
  • 稳定性过滤:只记相对稳定的信息。临时状态(比如“当前正在加载”)不进入长期记忆,留在 working memory 即可。
  • 去重与合并:新记忆写入前先查是否已有同类记忆,有则更新而非新增。比如用户改了偏好,应该覆盖旧值,而不是两条并存。
  • 重要性打分:给每条记忆一个重要性权重,检索时可以加权。重要性可以来自显式标记(用户说“记住这个”),也可以来自隐式信号(被反复引用)。

提示:写入策略一定要可配置。不同应用场景对“什么值得记”的判断差异很大,硬编码会很快失效。

3.2 记忆检索:Query 怎么匹配 Key

检索环节我踩过最大的坑是只做语义相似度。后来改成“结构化过滤 + 语义排序”的混合策略,效果提升非常明显。

具体做法是:

  1. 先按 Key 过滤:根据当前 Query 推断出可能的 Key 范围(比如当前用户 ID、当前项目 ID),先把候选集缩小。
  2. 再做语义匹配:在候选集内做向量相似度或关键词匹配,排序。
  3. 最后做时间衰减:给较新的记忆更高权重,避免过期信息干扰。
  4. Top-k 截断:只取最相关的几条注入上下文,避免上下文被记忆撑爆。

这个流程里,Key 的设计是关键。Key 太粗,过滤没效果;Key 太细,召回率下降。我的经验是 Key 用“实体 + 主题”的组合,比如user:123:preference、project:abc:decision,既能过滤又能保持一定召回。

3.3 记忆更新与遗忘:别让记忆库变成垃圾场

记忆系统跑久了,一定会遇到“记忆膨胀”问题。我的处理原则是:

  • 冲突检测:新记忆和旧记忆矛盾时,以新为准,旧记忆标记为失效而非直接删除(保留审计能力)。
  • TTL 机制:给部分记忆设过期时间,到期自动降权或归档。
  • 定期压缩:把多条相关记忆合并成一条摘要,减少条目数。
  • 冷热分离:高频访问的记忆放热存储,低频的归档到冷存储。

这套机制听起来复杂,但落地时可以先用最简单的版本:每条记忆带created_at、updated_at、importance、status四个字段,检索时按这几个字段加权。等数据量上来了再逐步加复杂度。

3.4 MCP Server 的工具设计

把记忆系统做成 MCP Server,核心是设计好暴露给 Agent 的工具。我一般会暴露这几个:

工具名作用关键参数
memory_write写入一条记忆key, value, importance, ttl
memory_search检索记忆query, key_filter, top_k
memory_update更新已有记忆memory_id, value
memory_forget标记记忆失效memory_id
memory_list列出某 key 下所有记忆key_filter

工具设计的原则是参数尽量少、语义尽量清晰。Agent 调用工具时不像人那么灵活,参数一多就容易传错。我见过有人把十几个参数塞进一个工具,结果 Agent 调用成功率惨不忍睹。

注意:MCP 工具的 description 字段非常重要,它是 Agent 判断“什么时候该调用这个工具”的唯一依据。description 要写清楚使用场景,而不是简单描述功能。

3.5 与 LLM 的对接:记忆怎么进上下文

记忆检索出来后,怎么注入到 LLM 上下文里,也有讲究。我的做法是:

  • 格式化:把检索到的记忆整理成结构化文本,比如“已知信息:1. ... 2. ...”,而不是直接拼接原始记录。
  • 标注来源:每条记忆标注来源和时间,方便 LLM 判断可信度。
  • 控制长度:记忆部分不超过上下文窗口的 20%,留足空间给当前任务。
  • 优先级排序:最重要的记忆放最前面,利用 LLM 的“首因效应”。

这些细节看起来小,但对最终效果影响很大。我做过对比,同样的记忆内容,格式化注入比原始拼接,任务完成率能差 15% 以上。

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

4.1 环境准备:Docker 与依赖

先说环境。我推荐用 Docker Compose 来编排,因为记忆系统通常涉及多个组件:记忆服务本身、向量库、关系库。

基础环境要求:

  • Docker Engine 20.10+ 或 Docker Desktop(Windows/Mac)
  • Docker Compose v2
  • 至少 4GB 可用内存(向量库比较吃内存)
  • 磁盘预留 10GB 以上

Windows 用户如果遇到virtualization support not detected或docker desktop failed to start,基本是两个原因:一是 BIOS 里没开虚拟化,二是 WSL2 没装好。前者进 BIOS 开 VT-x/AMD-V,后者用wsl --install装一下,重启即可。这个坑我踩过不止一次,尤其是新机器。

Linux 用户装 Docker 用官方脚本最省事:

curl -fsSL https://get.docker.com | sh sudo systemctl enable --now docker sudo usermod -aG docker $USER

最后一行是把当前用户加入 docker 组,避免每次都要 sudo。加完要重新登录才生效。

4.2 目录结构与 compose 编排

我习惯的目录结构是这样的:

hindsight/ ├── docker-compose.yml ├── .env ├── data/ │ ├── postgres/ │ └── qdrant/ └── config/ └── memory.yaml

docker-compose.yml大致长这样:

version: "3.9" services: postgres: image: postgres:16 environment: POSTGRES_USER: hindsight POSTGRES_PASSWORD: ${PG_PASSWORD} POSTGRES_DB: hindsight volumes: - ./data/postgres:/var/lib/postgresql/data ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U hindsight"] interval: 10s retries: 5 qdrant: image: qdrant/qdrant:latest volumes: - ./data/qdrant:/qdrant/storage ports: - "6333:6333" memory-server: build: . depends_on: postgres: condition: service_healthy qdrant: condition: service_started environment: PG_DSN: postgresql://hindsight:${PG_PASSWORD}@postgres:5432/hindsight QDRANT_URL: http://qdrant:6333 ports: - "8080:8080"

这里有几个设计取舍值得说:

  • Postgres 存结构化记忆:key、value、时间戳、重要性这些字段,关系库处理起来最顺手,查询也灵活。
  • Qdrant 存向量:专门做语义检索,性能比在 Postgres 里硬扛向量好得多。
  • memory-server 依赖健康检查:用condition: service_healthy确保 Postgres 真的起来了再启动服务,避免启动顺序问题导致的连接失败。

4.3 记忆服务的核心逻辑

记忆服务的核心就三件事:写入、检索、更新。我用伪代码把关键逻辑说清楚。

写入逻辑:

def write_memory(key, value, importance=0.5, ttl=None): # 1. 去重检查 existing = db.query("SELECT id FROM memories WHERE key=%s AND status='active'", key) if existing and is_duplicate(existing, value): return update_memory(existing.id, value) # 2. 冲突检测 if existing and is_conflict(existing, value): db.execute("UPDATE memories SET status='superseded' WHERE id=%s", existing.id) # 3. 写入结构化存储 mem_id = db.insert("memories", { "key": key, "value": value, "importance": importance, "created_at": now(), "status": "active", "expires_at": now() + ttl if ttl else None }) # 4. 写入向量索引 vector = embed(value) qdrant.upsert(mem_id, vector, payload={"key": key, "importance": importance}) return mem_id

检索逻辑:

def search_memory(query, key_filter=None, top_k=5): # 1. 结构化过滤 candidates = db.query( "SELECT id FROM memories WHERE status='active' " "AND (%s IS NULL OR key LIKE %s)", key_filter, f"{key_filter}%" ) # 2. 向量检索 query_vec = embed(query) hits = qdrant.search(query_vec, limit=top_k * 3, filter={"id": [c.id for c in candidates]}) # 3. 时间衰减 + 重要性加权 scored = [] for hit in hits: age_days = (now() - hit.created_at).days time_decay = 0.99 ** age_days score = hit.similarity * time_decay * (0.5 + hit.importance) scored.append((hit, score)) # 4. 排序截断 scored.sort(key=lambda x: -x[1]) return [s[0] for s in scored[:top_k]]

这两个函数是整个系统的骨架。实际项目里还要加缓存、批量写入、异步 embedding 等优化,但核心逻辑就是这些。

4.4 MCP Server 的接入

把记忆服务包装成 MCP Server,我用的是官方 SDK。核心是定义工具和 handler:

from mcp.server import Server from mcp.types import Tool, TextContent server = Server("hindsight-memory") @server.list_tools() async def list_tools(): return [ Tool( name="memory_write", description="写入一条长期记忆。当用户表达了偏好、确认了结论、或明确要求记住某事时调用。", inputSchema={ "type": "object", "properties": { "key": {"type": "string", "description": "记忆的分类键,如 user:123:preference"}, "value": {"type": "string", "description": "记忆内容"}, "importance": {"type": "number", "default": 0.5} }, "required": ["key", "value"] } ), Tool( name="memory_search", description="检索长期记忆。在回答需要历史信息的问题前调用。", inputSchema={ "type": "object", "properties": { "query": {"type": "string"}, "key_filter": {"type": "string"}, "top_k": {"type": "integer", "default": 5} }, "required": ["query"] } ) ] @server.call_tool() async def call_tool(name, arguments): if name == "memory_write": mem_id = write_memory(**arguments) return [TextContent(type="text", text=f"已写入记忆 {mem_id}")] elif name == "memory_search": results = search_memory(**arguments) return [TextContent(type="text", text=format_results(results))]

启动后,在支持 MCP 的客户端里配置这个 server 的地址,Agent 就能调用记忆能力了。我实测下来,工具 description 写得越具体,Agent 调用时机判断得越准。比如“当用户表达了偏好时调用”就比“写入记忆”有效得多。

4.5 验证与压测

部署完一定要验证。我一般分三步:

  1. 单元验证:直接调 API 写入几条记忆,再检索,确认能召回。
  2. 集成验证:通过 MCP 客户端让 Agent 调用,观察调用时机和参数是否正确。
  3. 压测:写入 1 万条记忆,测检索延迟。我的经验是 Qdrant 在 1 万条量级下,检索延迟能控制在 50ms 以内,完全够用。

压测脚本用 locust 或者简单的 asyncio 并发就行。重点看 P99 延迟和内存占用。

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

5.1 Docker 网络不通

这是最高频的问题。表现是 memory-server 连不上 postgres 或 qdrant。排查顺序:

  • 确认在同一网络:compose 默认会创建一个 bridge 网络,所有服务都在里面。如果手动docker run的容器,要--network指定同一个网络。
  • 用服务名而非 localhost:容器内localhost指的是容器自己,连别的容器要用服务名(如postgres、qdrant)。
  • 检查端口映射:容器间通信走的是容器端口(5432、6333),不是宿主机映射端口。
  • DNS 解析:docker exec进容器,ping postgres看能不能解析。

我遇到过一次诡异的情况:服务名能 ping 通但连不上,最后发现是 Postgres 还没初始化完,服务就启动了。加了 healthcheck 后解决。

5.2 记忆检索召回率低

表现是明明存了记忆,检索时却召回不到。常见原因:

现象可能原因解决
完全召回不到key_filter 太严放宽过滤条件或去掉
召回不相关向量模型不匹配确认写入和检索用同一 embedding 模型
新记忆召回不到索引延迟检查是否异步写入,加同步等待
旧记忆干扰无时间衰减加时间衰减权重

我的经验是,embedding 模型一定要统一。写入用 A 模型,检索用 B 模型,向量空间不一致,召回率会惨不忍睹。这个坑我踩过,排查了半天才发现是模型版本不一致。

5.3 记忆膨胀导致性能下降

跑一段时间后,检索变慢、内存飙升。处理办法:

  • 加 TTL,过期记忆自动归档。
  • 定期跑压缩任务,合并相似记忆。
  • 冷热分离,热数据留内存,冷数据落盘。
  • 给检索加 limit,别一次拉太多。

我一般会设一个定时任务,每天凌晨跑一次压缩,把 importance 低于阈值且 30 天未访问的记忆归档。

5.4 MCP 工具调用失败

热搜词里有个llm request failed: provider rejected the request schema or tool payload,这是典型的 schema 问题。排查:

  • 检查 inputSchema 是否符合 JSON Schema 规范:类型、必填项、默认值都要写对。
  • 参数名不要用保留字:有些客户端对参数名有要求。
  • description 不要过长:部分客户端对 description 长度有限制。
  • 返回格式要规范:必须返回TextContent列表,不能返回裸字符串。

我遇到过一次,工具定义里default写成了字符串"0.5"而不是数字0.5,导致 schema 校验失败。这种细节很容易忽略。

5.5 记忆冲突处理

用户改了偏好,旧记忆还在,导致 Agent 用旧值。解决办法:

  • 写入时做冲突检测,新值覆盖旧值,旧值标记superseded。
  • 检索时只查status='active'的记忆。
  • 保留旧值用于审计,但默认不参与检索。

这个机制一定要有,否则记忆系统会越来越“精神分裂”。

6. 一些实操心得与后续扩展方向

做记忆系统这段时间,我最大的体会是:记忆不是存得越多越好,而是用得越准越好。很多团队一上来就想做“全量记忆”,结果检索质量一塌糊涂。我的建议是先从最核心的场景做起,比如只记用户偏好和任务结论,跑通了再扩展。

另一个心得是可观测性非常重要。记忆系统是个黑盒,你不知道它记了什么、检索了什么、为什么召回这条而不是那条。我一般会加日志,记录每次写入和检索的详情,出问题时能快速定位。有条件的话做个简单的 dashboard,看记忆增长趋势和检索命中率。

后续扩展方向,我觉得有几个值得做:

  • 记忆的图结构化:把记忆组织成知识图谱,支持多跳推理。热搜词里的rag graphrag llm wiki 本体rag就是这个方向。
  • 记忆的主动遗忘:不只是 TTL,而是根据访问频率和重要性动态调整。
  • 多 Agent 共享记忆:多个 Agent 共享一个记忆池,需要处理并发和权限。
  • 记忆的可解释性:让 Agent 能解释“我为什么记得这个”,提升可信度。

最后分享一个小技巧:调试记忆系统时,我会写一个简单的 CLI 工具,能直接查、写、删记忆,不经过 Agent。这样排查问题时能快速定位是记忆系统的问题还是 Agent 调用的问题。这个工具花不了多少时间,但能省下大量调试时间。

记忆这件事,说到底是在给 Agent 装一个“过去”。有了过去,Agent 才谈得上“成长”。hindsight 这个方向,我觉得才刚刚开始。

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

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

立即咨询