1. 从 "hindsight" 说起:为什么 Agent Memory 值得单独拎出来做
第一次看到 "hindsight" 这个词,是在做 LLM Agent 长会话调试的时候。当时遇到一个很典型的问题:Agent 在前 20 轮对话里表现得很聪明,能记住用户提过的偏好、约束、上下文,但一旦会话拉长到 50 轮以上,它就开始"失忆"——要么把之前说过的结论推翻,要么重复问已经回答过的问题。我一开始以为是上下文窗口不够,换了更大的模型,结果发现不是窗口的问题,而是记忆的组织方式出了问题。
hindsight 这个词本身的意思是"事后之明",也就是回头看的时候才明白当时应该怎么做。放到 Agent Memory 这个语境里,它其实指向一个非常核心的命题:Agent 能不能在事后回看自己的历史交互,从中提取出真正有用的信息,而不是把所有对话一股脑塞进上下文。这跟当前热词里频繁出现的 agent memory、working memory、LLM wiki 知识库、MCP 协议这些概念是高度耦合的。
我理解这个项目标题想做的事情,是围绕 LLM Agent 的记忆机制,构建一套"可回看、可检索、可复用"的记忆层。它要解决的问题很具体:Agent 在多轮、多任务、跨会话场景下,如何不丢失关键信息,同时又不被冗余信息淹没。适合谁来参考?我觉得三类人最需要:一是正在做 Agent 产品的工程师,二是研究 RAG 和知识库方向的技术人,三是想把 MCP 协议真正落地到实际项目里的开发者。哪怕你只是刚接触 Docker 和 LLM 框架,这篇文章里的思路和操作步骤也能直接抄作业。
下面我会从整体设计思路、核心细节、实操过程、问题排查四个维度,把 hindsight 这套 Agent Memory 方案拆开讲清楚。中间会穿插 MCP 协议、Docker 部署、working memory 存储、token 三元组这些关键点,尽量做到你读完就能自己搭一套。
2. 整体设计与思路拆解:Agent Memory 到底该怎么分层
2.1 为什么不能只靠上下文窗口硬扛
很多人做 Agent 的第一反应是:上下文窗口不是已经到 128K、200K 了吗,直接把历史全塞进去不就行了?我实测下来,这条路在短会话里能跑,但一旦上生产就会崩。原因有三个。
第一是成本。token 是按量计费的,你把 50 轮对话全塞进去,每轮请求都在重复付费,成本会线性甚至指数级上升。第二是注意力稀释。模型在超长上下文里对中间部分的关注度会下降,这是有大量实验支撑的现象,关键信息放在中间反而容易被忽略。第三是噪声污染。历史对话里大量内容是寒暄、确认、重复,这些对当前任务毫无价值,却占用了宝贵的上下文空间。
所以 hindsight 的核心思路不是"记得更多",而是"记得更准"。它要做的是一套分层记忆架构,把不同时效性、不同重要度的信息放到不同的存储层里,按需检索、按需注入。
2.2 三层记忆结构的设计逻辑
我参考当前主流的 Agent Memory 实践,把记忆分成三层,这个分层也是 hindsight 这类方案最常见的骨架。
| 记忆层 | 存储内容 | 时效性 | 存储介质 | 检索方式 |
|---|---|---|---|---|
| Working Memory | 当前任务上下文、临时变量 | 秒级到分钟级 | 内存 / Redis | 直接读取 |
| Episodic Memory | 历史会话片段、事件记录 | 小时级到天级 | 向量库 / 文档库 | 语义检索 |
| Semantic Memory | 提炼后的知识、规则、偏好 | 长期 | 知识库 / LLM wiki | 关键词 + 语义 |
Working Memory 就是热词里说的 "agent 存储 working memory",它对应的是 Agent 当前正在处理的任务状态。比如用户说"帮我订明天下午三点的会议室",那"明天下午三点""会议室"这些就是 working memory 里的内容,任务结束就可以释放。
Episodic Memory 是情景记忆,记录的是"什么时候发生了什么"。比如用户上周问过一次报销流程,这周又问了一次,Agent 应该能通过情景记忆知道"这个问题之前回答过",而不是从零开始。
Semantic Memory 是语义记忆,是从大量交互中提炼出来的稳定知识。比如"这个用户偏好简洁回答""这个项目用的是 Python 而不是 Java",这些是跨会话长期有效的。
2.3 为什么引入 MCP 协议做记忆层解耦
MCP 是当前热词里出现频率极高的一个词,全称是 Model Context Protocol。它的核心价值在于把工具调用和上下文管理标准化。在 hindsight 这套方案里,我把记忆层做成一个独立的 MCP Server,Agent 通过 MCP 协议去读写记忆,而不是把记忆逻辑硬编码在 Agent 里。
这样做的好处很直接。第一,记忆层可以独立升级,换向量库、换存储引擎都不影响 Agent 主体。第二,多个 Agent 可以共享同一套记忆服务,比如一个做客服的 Agent 和一个做数据分析的 Agent,可以共用用户偏好这类语义记忆。第三,调试方便,记忆的读写都有标准协议,出问题容易定位。
MCP 在这里扮演的角色,类似于数据库驱动在传统应用里的角色——它不关心上层业务逻辑,只负责把"存"和"取"这两件事做标准、做可靠。
2.4 Docker 化部署的取舍
热词里 docker、docker desktop、docker 安装教程这些词反复出现,说明部署是很多人的痛点。hindsight 这套方案我建议用 Docker 来跑,原因有三个。
一是依赖隔离。向量库、Redis、MCP Server、LLM 网关这些组件版本要求各不相同,裸机装容易打架。二是可复现。Docker Compose 一份配置文件,换台机器就能跑起来,团队协作时省去大量"在我机器上是好的"的扯皮。三是资源可控。记忆层对内存和磁盘的消耗是可预估的,用 Docker 限制资源上限,避免它把宿主机拖垮。
当然 Docker 也有代价,比如网络配置、卷挂载、虚拟化支持这些问题,后面实操部分我会详细讲怎么处理。
3. 核心细节解析与实操要点:token 三元组与记忆写入策略
3.1 用 key-query-value 三元组组织记忆
热词里有一条很关键:"llm 的 token 三个点 key 我是谁、query 我在找什么、value 我能提供什么"。这其实是在描述一种记忆条目的结构化方式。我把它落到实操里,每条记忆都按这个结构存:
- key(我是谁):这条记忆的主体标识,比如用户 ID、会话 ID、任务 ID。
- query(我在找什么):这条记忆适用的检索场景,也就是"什么情况下应该被召回"。
- value(我能提供什么):记忆的实际内容,也就是真正要注入上下文的信息。
举个例子,用户说"我下周三要去上海出差"。这条记忆可以这样组织:
{ "key": "user_1024", "query": "行程安排、出差、地点、时间", "value": "用户下周三前往上海出差", "timestamp": "2025-01-15T10:30:00Z", "importance": 0.8 }这样组织的好处是,检索的时候不是拿整句话去做语义匹配,而是拿当前 query 去匹配记忆的 query 字段,命中率更高,也更容易做权重排序。importance 字段用来标记重要度,高重要度的记忆在上下文预算紧张时优先保留。
3.2 记忆写入的触发时机
不是所有对话都值得写入记忆。我踩过的坑是:早期版本把所有对话都往向量库里塞,结果检索出来的全是噪声,Agent 反而更糊涂了。后来我定了几个写入触发条件。
第一,显式事实。用户明确说出的偏好、约束、事实,比如"我不吃辣""我们公司用的是飞书",这类必须写。
第二,任务结论。一个任务完成后,把结论和关键参数写进去,比如"报销单号 XXX 已提交,金额 2000 元"。
第三,纠错信息。用户纠正 Agent 的地方,比如"不对,我说的是下周三不是这周三",这类要覆盖旧记忆。
第四,高频重复。同一个问题被问过三次以上,说明这是个稳定需求,值得提炼成语义记忆。
反过来,寒暄、确认、过渡性对话,一律不写。这条规则看起来简单,但能过滤掉 70% 以上的噪声。
3.3 记忆检索的排序策略
检索出来一堆记忆,怎么决定哪些注入上下文?我用的是加权打分:
score = w1 * 语义相似度 + w2 * 时间衰减 + w3 * 重要度 + w4 * 命中频次时间衰减用指数衰减函数,越久远的记忆权重越低,但重要度高的记忆衰减慢一些。命中频次是指这条记忆被召回后确实被用到的次数,用到的越多说明越有价值。
参数上我一般设 w1=0.5,w2=0.2,w3=0.2,w4=0.1。这个配比不是拍脑袋,是调了几轮之后的结果:语义相似度是主信号,但不能让它一家独大,否则会漏掉那些语义不太像但确实相关的记忆。
提示:排序参数没有万能值,跟你的业务场景强相关。客服场景可以加大时间衰减权重,知识问答场景可以加大语义相似度权重。建议先跑一批真实数据,看召回结果再调。
3.4 上下文预算的分配
即使检索出 20 条相关记忆,也不能全塞进去。我一般给记忆预留上下文窗口的 20% 到 30%,剩下的留给系统提示、当前对话和工具返回。
假设模型上下文是 32K token,那记忆预算大概 6K 到 9K。按每条记忆平均 100 token 算,能放 60 到 90 条。但实际不会放这么多,因为还要留余量给模型输出。我通常控制在 30 条以内,按 score 从高到低取。
如果高重要度记忆太多放不下,就做二次压缩:把多条相关记忆合并成一条摘要。这一步可以调 LLM 来做,但要注意压缩本身也消耗 token,别为了省 token 反而花更多。
4. 实操过程与核心环节实现:从 Docker 到 MCP Server 跑通
4.1 环境准备与 Docker 安装要点
先说环境。我用的是一台 16G 内存的开发机,系统是 Linux,Windows 和 macOS 也能跑,但要注意几个坑。
Docker 安装这块,Linux 上直接用包管理器装最省事:
# Ubuntu/Debian 系 sudo apt-get update sudo apt-get install -y docker.io docker-compose-plugin # 启动并设置开机自启 sudo systemctl enable --now docker # 把当前用户加入 docker 组,避免每次 sudo sudo usermod -aG docker $USERWindows 上装 Docker Desktop 是主流选择,但热词里 "virtualization support not detected docker desktop failed to start" 这个问题非常常见。原因通常是 BIOS 里没开虚拟化,或者开了但被 Hyper-V、WSL2 的配置冲突挡住了。解决办法分两步:先进 BIOS 打开 Intel VT-x 或 AMD-V,然后在 Windows 功能里确认"虚拟机平台"和"适用于 Linux 的 Windows 子系统"都勾上,重启后再装 Docker Desktop。
注意:Windows 家庭版默认没有 Hyper-V,需要走 WSL2 后端。装完 Docker Desktop 后在设置里确认 Use WSL 2 based engine 是勾选状态,否则启动会报虚拟化相关的错。
macOS 相对省心,装 Docker Desktop 就行,但 Apple Silicon 芯片要注意镜像架构,很多老镜像只有 amd64 版本,跑起来会慢或者直接报 exec format error。解决办法是在 docker run 时加--platform linux/amd64,或者找 arm64 版本的镜像。
4.2 用 Docker Compose 编排记忆层组件
hindsight 的记忆层我拆成四个容器:Redis 做 working memory,Qdrant 做向量存储,MCP Server 做协议层,再加一个 LLM 网关做模型调用代理。Compose 文件大概长这样:
version: "3.9" services: redis: image: redis:7-alpine ports: - "6379:6379" volumes: - redis_data:/data command: redis-server --appendonly yes qdrant: image: qdrant/qdrant:latest ports: - "6333:6333" - "6334:6334" volumes: - qdrant_data:/qdrant/storage memory-mcp: build: ./memory-mcp ports: - "8080:8080" environment: - REDIS_URL=redis://redis:6379 - QDRANT_URL=http://qdrant:6333 - LLM_GATEWAY=http://llm-gateway:9000 depends_on: - redis - qdrant llm-gateway: build: ./llm-gateway ports: - "9000:9000" environment: - MODEL_ENDPOINT=${MODEL_ENDPOINT} - API_KEY=${API_KEY} volumes: redis_data: qdrant_data:这里有几个设计取舍值得说。Redis 开了 appendonly,是为了防止容器重启丢 working memory,虽然 working memory 理论上可以丢,但调试阶段保留历史很有用。Qdrant 单独暴露 6333 和 6334 两个端口,前者是 HTTP API,后者是 gRPC,MCP Server 用 gRPC 性能更好。memory-mcp 用 build 而不是现成镜像,是因为记忆逻辑需要按业务定制,用 Dockerfile 打包进去更干净。
4.3 MCP Server 的核心接口实现
MCP Server 要暴露给 Agent 的接口不多,核心就四个:写入记忆、检索记忆、更新记忆、删除记忆。我用 Python 写,骨架大概是这样:
from mcp.server import Server from mcp.types import Tool, TextContent import redis, json from qdrant_client import QdrantClient app = Server("hindsight-memory") r = redis.from_url("redis://redis:6379") qdrant = QdrantClient(url="http://qdrant:6333") @app.tool() async def write_memory(key: str, query: str, value: str, importance: float = 0.5): """写入一条记忆,按 key-query-value 三元组组织""" # 短期记忆进 Redis,带 TTL if importance < 0.6: r.setex(f"wm:{key}:{query}", 3600, value) return TextContent(type="text", text="written to working memory") # 长期记忆进向量库 embedding = await embed(query + " " + value) qdrant.upsert( collection_name="episodic", points=[{ "id": gen_id(), "vector": embedding, "payload": {"key": key, "query": query, "value": value, "importance": importance, "ts": now()} }] ) return TextContent(type="text", text="written to episodic memory") @app.tool() async def recall_memory(query: str, top_k: int = 10): """按 query 检索记忆,返回加权排序后的结果""" embedding = await embed(query) hits = qdrant.search( collection_name="episodic", query_vector=embedding, limit=top_k * 2 ) scored = [rerank(h, query) for h in hits] scored.sort(key=lambda x: x["score"], reverse=True) return TextContent(type="text", text=json.dumps(scored[:top_k]))这里的关键点是双写策略:重要度低的记忆只进 Redis 带 TTL,重要度高的才进向量库。这样既保证了 working memory 的轻量,又保证了长期记忆的可检索。embed 函数调的是 LLM 网关的 embedding 接口,不直接调模型,这样换模型时只改网关配置。
4.4 与 Agent 的对接方式
Agent 这边通过 MCP 客户端连接 memory-mcp。以常见的 LLM 框架为例,配置大概是:
{ "mcpServers": { "hindsight-memory": { "url": "http://localhost:8080/sse", "transport": "sse" } } }Agent 在每轮对话开始前调一次 recall_memory,把当前用户输入作为 query,拿回相关记忆注入系统提示。对话结束后,根据前面说的触发条件决定是否调 write_memory。
这里有个实操细节:recall 的 query 不要直接用用户原话,最好做一次改写。比如用户说"那个事办了吗",直接拿这句去检索基本啥也搜不到。我会先用 LLM 把用户输入改写成"检索意图",比如"查询用户之前提到的待办事项状态",再拿这个去检索,命中率能提升一大截。
4.5 参数计算:记忆容量与成本估算
假设你的 Agent 每天处理 1000 次对话,每次对话平均产生 3 条值得写入的记忆,那每天新增 3000 条。每条记忆的 embedding 是 768 维 float32,占 3KB 左右,加上 payload 大概 4KB。一天 12MB,一年 4.4GB。这个量级用单机 Qdrant 完全扛得住。
检索成本方面,每次 recall 做一次 embedding 调用,按当前主流 embedding 模型的价格,1000 次对话大概几毛钱。真正贵的是 LLM 调用本身,记忆层带来的额外开销占比很小。
上下文注入成本要算清楚:假设每次注入 20 条记忆,每条 100 token,就是 2000 token。按 1000 次对话算,每天多消耗 200 万 token。这个数字看着大,但相比把全部历史塞进去(可能每次 2 万 token),已经省了 90%。
5. 常见问题与排查技巧实录
5.1 记忆检索不准的排查路径
这是最高频的问题。我整理了一个排查顺序,按这个走基本能定位。
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 检索结果完全不相关 | embedding 模型不匹配 | 检查写入和检索是否用同一模型 | 统一 embedding 模型 |
| 相关记忆排不到前面 | 排序权重不合理 | 打印 score 各分项 | 调 w1-w4 权重 |
| 该有的记忆搜不到 | 写入时被过滤 | 查写入日志 | 放宽写入触发条件 |
| 检索结果重复 | 去重逻辑缺失 | 看 payload 是否重复 | 加 key+query 去重 |
| 时间久的记忆干扰 | 衰减系数太小 | 检查时间衰减函数 | 加大衰减系数 |
我遇到过一次很典型的:检索出来的记忆全是三个月前的,最近的反而搜不到。查了半天发现是时间衰减函数写反了,越新的记忆权重越低。这种 bug 不看代码根本发现不了,所以排序逻辑一定要打日志,把每条记忆的各分项分数都打出来。
5.2 Docker 网络不通的常见原因
热词里 "docker 网络不通" 也是高频问题。容器之间通信失败,八成是这几个原因。
第一,用了 localhost。容器里的 localhost 指的是容器自己,不是宿主机。要连宿主机上的服务,得用host.docker.internal(Docker Desktop)或者宿主机的实际 IP。要连其他容器,直接用 Compose 里的服务名,比如redis://redis:6379。
第二,端口没暴露。容器间通信不需要 ports 映射,但宿主机访问容器必须映射。我见过有人容器间通信也去配 ports,结果配错了反而出问题。
第三,网络模式不对。默认 bridge 模式下容器间可以互通,但如果用了 host 模式,端口会直接占用宿主机端口,容易冲突。除非有特殊需求,否则别用 host 模式。
排查命令很简单:
# 进容器看能不能解析服务名 docker exec -it memory-mcp ping redis # 看容器网络配置 docker network inspect hindsight_default # 看容器日志 docker logs -f memory-mcp5.3 记忆膨胀导致性能下降
跑了一段时间后,向量库越来越大,检索变慢。这个问题我踩过,解决办法是分层归档。
超过 90 天且重要度低于 0.5 的记忆,从主集合移到归档集合,检索时默认不查归档,只有显式指定才查。归档集合可以用更低的索引精度,省内存。再久一点的,直接做摘要合并,多条相关记忆压成一条。
另外,Qdrant 的 collection 要定期做 optimize,把删除的向量真正回收。我一般每周跑一次:
curl -X POST http://localhost:6333/collections/episodic/index \ -H 'Content-Type: application/json' \ -d '{"field_name": "importance", "field_schema": "float"}'给 importance 建索引后,按重要度过滤的查询会快很多。
5.4 LLM 请求被拒的排查
热词里 "llm request failed: provider rejected the request schema or tool payload" 这个报错,在 MCP 场景下很常见。原因通常是工具调用的参数 schema 和模型期望的不一致。
比如你定义了一个参数是 float,但模型传了个字符串 "0.8",有些 provider 会直接拒。解决办法是在 MCP Server 的参数校验层做类型转换,别指望模型每次都传对类型。另外,工具描述要写清楚,参数含义、取值范围、是否必填都标明白,模型理解得越清楚,传错的概率越低。
还有一种情况是 payload 太大。MCP 工具返回的内容如果超过 provider 的限制,也会被拒。记忆检索返回 top_k 条,如果每条都很长,加起来可能超限。我的做法是返回前先做截断,单条记忆超过 500 token 就压缩,总返回控制在 4000 token 以内。
5.5 独家避坑技巧汇总
几个文档里不会写但实际很坑的点。
embedding 要缓存。同一条 query 可能被反复检索,每次都调 embedding 接口既慢又贵。我在 MCP Server 里加了一层 LRU 缓存,key 是 query 的 hash,命中率能到 40% 以上。
写入要异步。write_memory 如果同步等向量库写入完成,会拖慢 Agent 响应。改成写消息队列,后台消费,Agent 这边立即返回。代价是可能丢少量记忆,但用户体验提升明显。
记忆要能手动干预。上线后一定会遇到"这条记忆是错的"的情况,得有个管理接口能手动删改。我留了一个 admin 端点,支持按 key 批量删除,调试时非常有用。
版本要能回滚。记忆结构改过一次,加了 importance 字段,结果旧记忆没有这个字段,检索时报错。后来所有结构变更都做兼容处理,新字段给默认值,别假设所有记忆都是最新结构。
监控要跟上。记忆层的核心指标就几个:写入量、检索量、检索命中率、平均检索延迟、上下文注入 token 数。这几个指标画成曲线,出问题一眼就能看出来。我用 Prometheus 加 Grafana,配置不复杂,但省了大量排查时间。
6. 记忆层的扩展方向与个人实践体会
hindsight 这套方案跑通之后,我陆续做了几个扩展,效果还不错,分享出来供参考。
第一个扩展是跨 Agent 共享语义记忆。前面提到多个 Agent 可以共用一套记忆服务,实际做的时候要注意权限隔离。用户偏好这类记忆可以共享,但任务相关的情景记忆要按 Agent 隔离,否则会串味。我在 payload 里加了 agent_id 字段,检索时按 agent_id 过滤。
第二个扩展是记忆的主动遗忘。不是所有记忆都值得永久保留,有些信息有时效性,过期了反而误导。我给记忆加了 expire_at 字段,到期自动归档。比如"用户明天要开会"这种,过了明天就没意义了。
第三个扩展是记忆冲突检测。同一个 key 下如果出现矛盾的值,比如用户先说"我住北京"后说"我住上海",系统要能检测到冲突并提示。我的做法是写入时做一次相似度检查,如果新记忆和旧记忆语义相近但值不同,标记为冲突,让上层决定是覆盖还是并存。
第四个扩展是结合 LLM wiki 做知识沉淀。热词里 llm wiki 知识库、llm wiki 项目这些词出现多次,我理解它指的是把零散信息结构化成可检索的知识库。hindsight 的语义记忆层其实可以往这个方向走,把高频记忆提炼成 wiki 条目,形成更稳定的知识底座。这块我还在摸索,目前的做法是每周跑一次批处理,把命中频次高的记忆聚类,人工确认后转成 wiki 条目。
我个人在实际操作中的体会是,Agent Memory 这件事,难点不在技术选型,而在信息取舍。什么该记、什么该忘、什么该合并,这些判断没有标准答案,得结合业务场景反复调。我见过太多项目一上来就追求"记住一切",结果被记忆噪声拖垮。反而是那些克制地只记关键信息的方案,跑得又稳又准。
最后再分享一个小技巧:调试记忆层的时候,把每次检索的 query、召回的记忆、最终注入上下文的内容都打到一个日志文件里,按会话 ID 串起来。出问题时回看这个日志,比看代码快十倍。这个日志我保留最近 7 天,占不了多少磁盘,但排查效率提升非常明显。