
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 memory2.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记忆变成一个独立的serverAgent通过标准协议去调用。热搜词里出现了“mcp是什么”“agent mcp”“playwright mcp”“blender mcp”这些说明MCP正在成为Agent工具调用的事实标准。它的核心价值是协议统一不管你的Agent是用什么语言写的、跑在什么环境里只要实现了MCP client就能调用任何MCP server提供的能力。对于记忆系统来说这意味着我可以把memory server单独部署、单独扩容、单独做持久化Agent本身不需要关心记忆存在哪、怎么检索。3.2 记忆MCP Server的接口设计我设计的memory MCP server暴露了四个核心toolmemory_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( contentsummary, source_ids[m.id for m in memories], topictopic ) 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_URLpostgresql://user:passpostgres:5432/memory - VECTOR_URLhttp://qdrant:6333 - REDIS_URLredis://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_USERuser - POSTGRES_PASSWORDpass - POSTGRES_DBmemory 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配healthcheckpostgres必须等健康检查通过再启动memory-server否则连不上数据库。我一开始没配healthcheckmemory-server启动时报连接拒绝排查了半天。数据卷映射到宿主机所有有状态服务的数据都映射出来这样容器删了数据还在。我吃过亏有一次docker-compose down -v把向量库数据全清了重建索引花了一下午。restart策略用unless-stopped容器挂了自动重启但手动停的不重启。4.3 资源限制与性能调优记忆服务对内存比较敏感尤其是向量检索。我在compose里加了资源限制deploy: resources: limits: memory: 2G reservations: memory: 512MQdrant默认会尽量用内存做索引缓存如果不限制它可能把宿主机内存吃满。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-1recency_score按时间衰减我用的是指数衰减exp(-λ * days_ago)λ取0.1意味着一周前的记忆得分衰减到约0.5importance_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, threshold0.85): kept [] for m in sorted(memories, keylambda x: x.importance, reverseTrue): 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_k10想着多给点信息总没错。结果模型反而更容易跑偏因为它会抓住某条不相关的记忆过度发挥。top_k3到5是比较好的区间既能提供足够上下文又不会引入太多噪声。7.3 不同模型对记忆的利用能力差异很大同一个记忆系统换不同的LLM效果差很多。我实测下来参数量大的模型更能“正确使用”检索回来的记忆而小模型经常忽略记忆或者错误引用。所以如果你的Agent用的是小模型记忆系统的检索精度要调得更高宁可少返回不要返回错的。8. 后续可以继续深挖的方向这套记忆系统跑了一段时间基本稳定了。但有几个点我还在琢磨。一个是记忆的跨Agent共享。现在每个Agent有自己的记忆库但其实很多知识是通用的。如果能做一个共享的semantic memory层多个Agent都能读写整体效率会高很多。难点在于权限控制和冲突解决。另一个是记忆的可解释性。现在模型用了哪条记忆、为什么用是黑盒。我在尝试让模型在回答时标注引用了哪条记忆ID这样排查问题会方便很多。初步试下来可行但会增加输出token。还有就是记忆的自动评估。怎么知道记忆系统好不好我现在是靠人工抽查效率低。理想情况是有一套自动指标比如检索命中率、记忆利用率、冲突率能持续监控。这个还在设计中。如果你也在做Agent记忆相关的东西欢迎交流。这个领域没有标准答案很多坑得自己踩过才知道。我上面写的都是实际跑过的配置和代码思路拿去改改就能用。但记住一点记忆系统的核心不是技术是策略——什么该记、什么该忘、什么时候该查这些判断比用什么数据库重要得多。