
1. 从“hindsight”说起为什么我们需要给 Agent 装上“后视镜”第一次看到“hindsight”这个词我脑子里蹦出来的不是词典释义而是每次 debug 到凌晨三点、盯着日志发呆时的那种感觉——事情发生的时候你什么都看不出来等回过头复盘才发现每一步都错得明明白白。把这个词放到 LLM Agent 的语境里它指向的东西非常具体Agent 的记忆系统尤其是那种“事后可回溯、可复盘、可修正”的记忆能力。现在市面上讲 Agent Memory 的文章大多停留在“短期记忆用对话窗口、长期记忆用向量库”这种层面。但真正做过生产级 Agent 的人都知道问题远比这复杂。一个 Agent 跑了几十轮工具调用之后它到底记住了什么、忘了什么、为什么做出某个决策这些东西如果没有一套结构化的记忆机制你根本无从查起。hindsight 要解决的就是这个“事后诸葛亮”的问题——让 Agent 不仅能记住还能在需要的时候把记忆调出来像放录像一样回看整个决策链路。这篇文章适合谁看如果你正在用 LLM 框架搭 Agent或者已经在用 MCP 协议对接各种工具又或者你单纯对“Agent 怎么记住东西”这件事好奇那接下来的内容应该能给你一些可以直接抄作业的东西。我会从记忆架构的设计思路讲起一路拆到 Docker 环境搭建、MCP 协议对接、记忆存储的具体实现最后再聊聊我在实际踩坑过程中总结出来的那些“文档里不会写”的经验。核心关键词先摆出来hindsight、agent memory、LLM、MCP、Docker。这五个词基本覆盖了整条技术链路——从记忆模型的设计到大模型推理的接入再到工具协议的标准化最后到容器化部署的落地。2. Agent Memory 的核心设计思路拆解2.1 为什么传统记忆方案不够用先说一个我自己的真实经历。去年我搭了一个客服 Agent用向量数据库存历史对话检索的时候按相似度召回。上线第一周就出问题了用户问“我上次那个订单退了吗”Agent 召回了一堆语义相似的对话片段但就是找不到那个具体订单的状态。为什么因为向量检索擅长的是“语义相似”不擅长“精确回溯”。用户要的是“我上次那个订单”这个具体事件而不是“和订单相关的所有对话”。这就是传统记忆方案的根本缺陷它把记忆当成一个扁平的、无结构的文本池子。而人类记忆不是这样的。人类记忆有情景记忆Episodic Memory具体发生了什么、语义记忆Semantic Memory抽象出来的知识、程序记忆Procedural Memory怎么做事。Agent 要真正“记住”东西也需要类似的分层结构。hindsight 这个思路的核心就是给 Agent 的记忆加上时间维度和因果维度。不只是“这句话和那句话相似”而是“这件事发生在那个决策之后导致了那个结果”。这种结构化的记忆才能支撑真正的“事后复盘”。2.2 记忆分层Working Memory、Episodic Memory、Semantic Memory我在实际项目中把 Agent 记忆分成三层来设计这个分层方式参考了认知科学的模型但在工程上做了简化第一层Working Memory工作记忆。这就是当前对话窗口里的内容容量有限通常就是最近 N 轮对话或者最近 M 个 token。它的作用是维持当前任务的上下文连贯性。实现上最简单就是维护一个滑动窗口超出容量就把最旧的内容挤出去。但这里有个坑挤出去的内容不能直接扔掉得转存到下一层。第二层Episodic Memory情景记忆。这一层记录的是“发生了什么”。每一次工具调用、每一个决策节点、每一次用户反馈都作为一条独立的事件记录存下来。关键是要带上时间戳、事件类型、输入输出、以及和前后事件的关联 ID。这样你才能在后面对某个决策做回溯的时候把整条链路串起来。第三层Semantic Memory语义记忆。这一层是从情景记忆里抽象出来的“知识”。比如 Agent 发现“用户每次问退款都会先问订单号”这个模式就可以抽象成一条语义记忆下次遇到类似场景直接调用。语义记忆的更新频率低但价值密度高。这三层之间的关系是Working Memory 溢出到 Episodic MemoryEpisodic Memory 经过抽象沉淀到 Semantic Memory。检索的时候优先查 Working Memory不够再查 Episodic最后查 Semantic。这个优先级顺序很重要因为越靠近 Working Memory 的记忆越新鲜、越具体。2.3 记忆的写入、检索与遗忘机制写入这块我的经验是异步写入。Agent 在跑任务的时候不要同步等记忆写入完成否则会拖慢响应速度。具体做法是Agent 产生一条记忆事件后扔到一个消息队列里后台 worker 慢慢消费写入。这样即使记忆存储挂了也不影响主流程。检索这块纯向量检索不够我一般用混合检索向量相似度 关键词匹配 时间衰减因子。时间衰减因子很关键——同样相似度的两条记忆一条是五分钟前的一条是五天前的显然应该优先用新的。衰减函数我用的是指数衰减半衰期设成 24 小时这个值可以根据业务场景调。遗忘机制是很多人忽略的。Agent 的记忆不能无限增长否则检索效率会越来越低而且旧记忆会干扰新决策。我的做法是Episodic Memory 保留最近 30 天的详细记录超过 30 天的做摘要压缩Semantic Memory 定期做合并去重相似度超过阈值的两条语义记忆合并成一条。这个阈值我一般设在 0.92 左右太低会误合并太高又起不到去重效果。提示记忆的遗忘不是删除而是降权。被降权的记忆仍然可以被检索到只是优先级降低。这样既控制了检索成本又保留了回溯的可能性。3. MCP 协议对接让 Agent 的记忆能力标准化输出3.1 MCP 到底是什么为什么它和 Agent Memory 有关MCP 全称是 Model Context Protocol是一个让 LLM 和外部工具、数据源之间标准化交互的协议。你可以把它理解成“AI 世界的 USB 接口”——不管对面是数据库、文件系统还是某个 API只要实现了 MCP 协议LLM 就能用统一的方式去调用。那它和 Agent Memory 有什么关系关系大了。Agent 的记忆系统本质上也是一个“外部数据源”它需要被 LLM 查询、写入、更新。如果没有标准协议每换一个 LLM 框架就要重写一遍对接代码。有了 MCP记忆系统可以封装成一个 MCP Server任何支持 MCP 的客户端都能直接调用。我现在的做法是把记忆系统做成一个独立的 MCP Server暴露三个核心工具——memory_write、memory_search、memory_forget。Agent 在需要的时候通过 MCP 协议调用这三个工具完全不关心底层用的是向量库还是图数据库。3.2 把记忆系统封装成 MCP Server 的实操步骤先看目录结构我一般这么组织memory-mcp-server/ ├── src/ │ ├── server.py # MCP Server 主入口 │ ├── memory/ │ │ ├── working.py # 工作记忆实现 │ │ ├── episodic.py # 情景记忆实现 │ │ └── semantic.py # 语义记忆实现 │ ├── storage/ │ │ ├── vector.py # 向量存储适配 │ │ └── graph.py # 图存储适配 │ └── tools/ │ ├── write.py # memory_write 工具 │ ├── search.py # memory_search 工具 │ └── forget.py # memory_forget 工具 ├── Dockerfile ├── docker-compose.yml └── requirements.txt核心的 MCP Server 入口大概长这样from mcp.server import Server from mcp.types import Tool, TextContent app Server(memory-server) app.list_tools() async def list_tools(): return [ Tool( namememory_write, description写入一条记忆, inputSchema{ type: object, properties: { content: {type: string}, memory_type: {type: string, enum: [working, episodic, semantic]}, metadata: {type: object} }, required: [content, memory_type] } ), Tool( namememory_search, description检索记忆, inputSchema{ type: object, properties: { query: {type: string}, top_k: {type: integer, default: 5}, memory_type: {type: string} }, required: [query] } ) ] app.call_tool() async def call_tool(name: str, arguments: dict): if name memory_write: result await handle_write(arguments) return [TextContent(typetext, textresult)] elif name memory_search: result await handle_search(arguments) return [TextContent(typetext, textresult)]这里有个细节要注意MCP 工具的inputSchema一定要写清楚因为 LLM 是根据这个 schema 来决定怎么调用工具的。schema 写得模糊LLM 就容易传错参数。我一般会在 description 里把每个参数的用途、格式、取值范围都写明白这样 LLM 的调用准确率会高很多。3.3 工具描述怎么写才能让 LLM 正确调用这是很多人踩坑的地方。MCP 工具的 description 不是写给人看的是写给 LLM 看的。LLM 会根据 description 来判断“这个工具是干什么的、什么时候该用、参数怎么传”。我总结了几条经验第一用动词开头说清楚“做什么”。比如“写入一条记忆”就比“记忆写入功能”好因为前者直接告诉 LLM 这是一个动作。第二说清楚“什么时候用”。比如memory_search的 description 可以写成“当需要回忆之前发生的事件、查找历史信息时使用”。这样 LLM 在遇到需要回忆的场景时就会主动调用这个工具。第三参数描述要具体。top_k不要只写“返回数量”要写“返回最相关的记忆条数建议 3-10太大可能引入噪声”。LLM 看到这个描述就知道不该传 100 进去。第四给出调用示例。在 description 里加一句“例如memory_search(query用户上次提到的订单号, top_k5)”LLM 的调用准确率会明显提升。注意MCP 工具的 description 长度是有限制的不要写太长。我的经验是控制在 200 字以内把最关键的信息放前面。4. Docker 环境搭建把记忆系统跑起来4.1 为什么用 Docker 而不是直接跑在宿主机上Agent Memory 系统涉及多个组件向量数据库、图数据库、消息队列、MCP Server 本身。如果直接跑在宿主机上光是环境依赖就能折腾半天而且换一台机器又要重来一遍。Docker 的好处是环境隔离 一键复现你把 docker-compose.yml 写好换任何一台装了 Docker 的机器一条命令就能把整套系统拉起来。另一个原因是资源隔离。向量数据库吃内存消息队列吃磁盘 IOMCP Server 吃 CPU如果都跑在宿主机上互相抢资源。用 Docker 可以给每个容器单独限制资源避免一个组件把整台机器拖垮。4.2 docker-compose.yml 完整配置与参数解读下面是我实际在用的 docker-compose.yml跑的是记忆系统的核心组件version: 3.8 services: memory-mcp: build: . ports: - 8080:8080 environment: - VECTOR_DB_URLhttp://vector-db:6333 - GRAPH_DB_URLbolt://graph-db:7687 - QUEUE_URLamqp://queue:5672 - MEMORY_TTL_DAYS30 - DECAY_HALFLIFE_HOURS24 depends_on: - vector-db - graph-db - queue deploy: resources: limits: cpus: 2.0 memory: 4G restart: unless-stopped vector-db: image: qdrant/qdrant:latest ports: - 6333:6333 volumes: - vector_data:/qdrant/storage deploy: resources: limits: memory: 8G restart: unless-stopped graph-db: image: neo4j:5-community ports: - 7474:7474 - 7687:7687 environment: - NEO4J_AUTHneo4j/memory123 - NEO4J_dbms_memory_heap_max__size2G volumes: - graph_data:/data restart: unless-stopped queue: image: rabbitmq:3-management ports: - 5672:5672 - 15672:15672 volumes: - queue_data:/var/lib/rabbitmq restart: unless-stopped volumes: vector_data: graph_data: queue_data:几个关键参数解释一下MEMORY_TTL_DAYS30控制情景记忆的保留天数超过这个天数的记忆会被压缩成摘要。这个值根据你的业务场景调客服场景一般 30 天够了如果是长期陪伴类 Agent 可能要设到 90 天甚至更长。DECAY_HALFLIFE_HOURS24是时间衰减的半衰期意思是每过 24 小时记忆的检索权重减半。这个值越小记忆“过期”越快检索越偏向新记忆。deploy.resources.limits是资源限制防止某个容器把宿主机资源吃光。向量数据库给 8G 内存是因为它要把索引加载到内存里内存不够会频繁读磁盘检索速度断崖式下降。4.3 启动顺序与健康检查配置Docker Compose 的depends_on只保证启动顺序不保证服务就绪。也就是说vector-db 容器启动了但里面的服务可能还没准备好接受请求这时候 memory-mcp 去连就会失败。解决办法是加健康检查vector-db: image: qdrant/qdrant:latest healthcheck: test: [CMD, curl, -f, http://localhost:6333/healthz] interval: 10s timeout: 5s retries: 5 start_period: 30s memory-mcp: depends_on: vector-db: condition: service_healthy graph-db: condition: service_healthystart_period是给容器一个“热身时间”在这段时间内健康检查失败不算数。向量数据库启动通常需要 20-30 秒来加载索引所以start_period设成 30s 比较稳妥。启动命令就一条docker compose up -d-d是后台运行。启动后可以用docker compose ps看各个容器的状态docker compose logs -f memory-mcp看 MCP Server 的日志。提示如果你在 Windows 上跑 Docker Desktop遇到 “Virtualization support not detected” 的报错大概率是 BIOS 里的虚拟化支持没开。重启进 BIOS找到 Intel VT-x 或 AMD-V 选项设为 Enabled 就行。这个坑我踩过不止一次。5. 记忆存储的底层实现从向量到图5.1 向量存储语义检索的基础设施向量存储负责的是“语义相似”检索。每条记忆写入的时候先用 embedding 模型转成向量然后存到向量数据库里。检索的时候把 query 也转成向量算余弦相似度返回 top-k。embedding 模型的选择很关键。我试过几种方案OpenAI 的 text-embedding-3-small 效果不错但要走网络本地部署的 bge-m3 效果接近且没有网络延迟。如果你的记忆系统对延迟敏感建议用本地 embedding 模型。向量维度方面bge-m3 是 1024 维text-embedding-3-small 是 1536 维。维度越高表达能力越强但存储和计算成本也越高。我的经验是 1024 维对于大多数 Agent 记忆场景够用了。Qdrant 的 collection 配置大概这样from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams client QdrantClient(urlhttp://localhost:6333) client.create_collection( collection_nameepisodic_memory, vectors_configVectorParams( size1024, distanceDistance.COSINE ), optimizers_config{ memmap_threshold: 20000, indexing_threshold: 10000 } )memmap_threshold是超过这个数量的向量就用内存映射文件存储减少内存占用。indexing_threshold是超过这个数量就建 HNSW 索引加速检索。这两个值根据你的记忆规模调记忆条数少的时候不建索引反而更快。5.2 图存储因果关系与时间线的载体向量存储解决不了“因果关系”和“时间线”的问题。比如“用户投诉之后Agent 决定退款然后用户满意了”这是一个因果链向量检索很难把这三个事件按顺序串起来。这时候就需要图存储。我用 Neo4j 来存记忆之间的关联关系。每个记忆事件是一个节点事件之间的因果关系、时间顺序、引用关系是边。查询的时候可以用 Cypher 语句做路径查询比如“找出所有导致用户不满意的决策链路”。// 创建记忆节点 CREATE (m:Memory { id: mem_001, content: 用户投诉订单延迟, type: episodic, timestamp: datetime(2024-01-15T10:30:00) }) // 创建因果关系 MATCH (a:Memory {id: mem_001}) MATCH (b:Memory {id: mem_002}) CREATE (a)-[:CAUSED_BY {confidence: 0.85}]-(b) // 查询因果链 MATCH path (start:Memory)-[:CAUSED_BY*1..5]-(end:Memory) WHERE start.id mem_001 RETURN path图存储的写入成本比向量存储高所以不是所有记忆都往图里写。我的做法是只有涉及决策、因果、多步推理的记忆才写入图存储普通的对话片段只写向量存储。5.3 混合检索策略向量 关键词 时间衰减单独用向量检索或者单独用图检索都不够。我的混合检索策略是这样的第一步向量检索召回 top-20 候选。第二步对候选做关键词匹配打分关键词匹配用的是 BM25 算法。第三步叠加时间衰减因子。第四步综合打分排序返回 top-5。综合打分的公式final_score 0.6 * vector_score 0.3 * keyword_score 0.1 * time_decay权重可以根据场景调。如果场景对时效性要求高把 time_decay 的权重提到 0.2 甚至 0.3。如果场景对精确匹配要求高把 keyword_score 的权重提上去。时间衰减的计算import math from datetime import datetime, timedelta def time_decay(memory_time, halflife_hours24): delta datetime.now() - memory_time hours delta.total_seconds() / 3600 return math.exp(-0.693 * hours / halflife_hours)0.693是 ln(2)这是指数衰减的标准公式。半衰期设成 24 小时意思是 24 小时前的记忆权重是现在的一半48 小时前是四分之一以此类推。6. 常见问题与排查技巧实录6.1 记忆检索不准的排查思路检索不准是最常见的问题。我一般按这个顺序排查先看 embedding 模型是否合适。如果记忆内容是中文但用的是英文 embedding 模型效果肯定差。换一个多语言模型试试。再看 chunk 大小。如果一条记忆被切得太碎检索的时候召回的都是碎片拼不出完整信息。我的经验是每条记忆控制在 200-500 字太短信息不足太长检索精度下降。然后看时间衰减参数。如果半衰期设得太短旧记忆全被压下去了检索出来的全是最近的但最近的未必是最相关的。把半衰期调长试试。最后看权重分配。如果向量权重太高关键词匹配起不到作用精确匹配的场景就会失效。适当降低向量权重提高关键词权重。6.2 Docker 网络不通的典型场景Docker 容器之间网络不通最常见的原因是容器不在同一个网络里。Docker Compose 默认会创建一个网络所有服务都在这个网络里用服务名就能互相访问。但如果你手动docker run启动的容器没有指定网络它就孤零零地待着谁也连不上。解决办法是在 docker-compose.yml 里显式定义网络networks: memory-net: driver: bridge services: memory-mcp: networks: - memory-net vector-db: networks: - memory-net另一个常见原因是端口映射搞混了。容器内部端口和宿主机端口是两回事。容器之间通信用的是容器内部端口比如 vector-db 的 6333不是宿主机映射的端口。如果你在 memory-mcp 里写VECTOR_DB_URLhttp://localhost:6333那肯定连不上因为 localhost 在容器里指的是容器自己。要写成http://vector-db:6333用服务名。6.3 记忆膨胀导致性能下降的解决跑了一段时间之后记忆条数越来越多检索越来越慢。这是必然的关键是怎么控制。第一定期压缩。超过 TTL 的情景记忆用 LLM 做摘要压缩把多条相关记忆合并成一条摘要。压缩比大概 10:1十条原始记忆压成一条摘要。第二分层存储。热数据最近 7 天放内存或 SSD温数据7-30 天放普通磁盘冷数据30 天以上放对象存储。检索的时候先查热数据不够再查温数据最后查冷数据。第三索引优化。向量数据库的 HNSW 索引参数要调。m参数控制每个节点的连接数越大检索越准但内存占用越高。ef_construct控制建索引时的搜索深度越大索引质量越好但建索引越慢。我的经验值是m16、ef_construct100在精度和性能之间比较平衡。6.4 常见问题速查表问题现象可能原因排查方法解决方案检索结果不相关embedding 模型不匹配检查模型语言支持换多语言 embedding 模型检索结果全是旧的时间衰减参数过小检查 halflife 配置增大半衰期值容器间连接超时网络配置错误docker network inspect统一网络或改用服务名记忆写入延迟高同步写入阻塞检查写入日志改为异步写入检索速度越来越慢记忆膨胀统计记忆条数启用压缩和分层存储MCP 工具调用失败schema 描述不清查看 LLM 调用日志优化工具 descriptionDocker 启动失败虚拟化未开启检查 BIOS 设置开启 VT-x/AMD-V向量检索精度下降索引参数不当检查 HNSW 配置调整 m 和 ef_construct注意这张表里的解决方案都是“方向性”的具体参数值要根据你的实际场景调。没有一套参数能通吃所有场景一定要结合自己的数据做实验。7. 我在实际项目中的几点体会踩了这么多坑有几个体会特别深。第一记忆系统不是越复杂越好。我一开始设计了三层记忆 向量 图 消息队列结果光调试环境就花了一周。后来简化成两层记忆 向量 异步队列效果反而更好。复杂度应该花在刀刃上而不是为了架构好看。第二MCP 工具的描述比实现更重要。我花在写工具 description 上的时间比写工具实现的时间还多。但这是值得的因为 LLM 能不能正确调用工具90% 取决于 description 写得好不好。第三Docker 的资源限制一定要设。我有一次忘了给向量数据库设内存限制结果它把宿主机内存吃光了整个系统卡死。后来加了deploy.resources.limits再也没出过这个问题。第四时间衰减参数要跟着业务走。客服场景半衰期 24 小时合适但如果是长期陪伴类 Agent半衰期可能要设到 168 小时一周。参数没有标准答案只有适合不适合。第五日志一定要打全。记忆系统的调试难度在于“你看不到它内部在想什么”。所以每次写入、每次检索、每次衰减计算都要打日志。日志格式建议用结构化 JSON方便后面做分析。最后分享一个小技巧如果你不确定记忆检索的效果可以做一个“回放测试”。把历史对话录下来重新跑一遍看 Agent 在相同输入下能不能检索到正确的记忆。这个测试比单元测试更能反映真实效果。