1. 为什么“事后复盘”才是 Agent 记忆的正确打开方式
做 Agent 开发的人都有一个共同的痛:模型聊着聊着就失忆了。你上一轮告诉它“这个项目用 PostgreSQL,不用 MySQL”,下一轮它又给你生成一段 MySQL 的建表语句。你纠正它一次,它道歉,然后过两轮再犯。这不是模型笨,是它压根没有一套像样的记忆机制。
hindsight这个词本身就点破了问题的本质——后见之明。人类做决策靠的不只是当下看到的信息,还有过去踩过的坑、做过的选择、验证过的结论。Agent 也一样,它需要的不是把所有聊天记录一股脑塞进上下文窗口,而是在需要的时候,能“回想”起那些真正有用的历史经验。
我最近花了不少时间折腾 Agent 的记忆层,从最粗暴的“全量塞 context”,到基于向量库的 RAG 检索,再到用 MCP 协议把记忆能力做成独立服务,中间踩的坑足够写一篇长文。这篇就围绕hindsight这个思路,把 Agent Memory 从设计到落地讲透,包括 LLM 怎么参与记忆的写入和召回、MCP 协议在这里扮演什么角色、Docker 怎么把整套东西跑起来。不管你是刚接触 Agent 开发,还是已经在做多轮对话系统,应该都能从里面捞到点能直接抄的东西。
先说清楚这套东西解决什么问题:让 Agent 拥有跨会话、跨任务的长期记忆,并且能在正确的时机召回正确的记忆,而不是靠堆上下文长度硬撑。适合谁看?做 LLM 应用的后端、想给自己的 Agent 加记忆能力的独立开发者、以及被“模型失忆”折磨过的所有人。
2. Agent Memory 的整体设计与思路拆解
2.1 为什么不能靠“加大上下文窗口”解决记忆问题
很多人第一反应是:现在模型上下文都 128K、200K 了,我把历史全塞进去不就行了?我实测过,这条路走不通,原因有三个。
第一是成本。上下文越长,每次推理的 token 消耗越大,而且是线性甚至超线性增长。一个跑了三天的 Agent,历史记录轻松几十万 token,你每轮都全量塞进去,账单会教你做人。
第二是注意力稀释。这是更要命的。模型在超长上下文里,对中间部分的注意力会明显下降,业内叫“lost in the middle”。你把关键信息埋在 5 万 token 之前,模型很可能根本“看不见”。塞得越多,反而越容易漏掉重点。
第三是噪声污染。历史记录里大量是寒暄、试错、废弃方案。这些内容混进去,会干扰模型判断。你告诉它“先试试 MySQL”,后来改成 PostgreSQL,如果两条都塞进去,模型很可能抓错。
所以正确的思路不是“记住所有”,而是“记住该记的,忘掉该忘的”。这就是hindsight的核心:把记忆当成一个需要主动管理的系统,而不是一个被动堆积的日志。
2.2 记忆分层:working memory 与 long-term memory
我最终采用的方案是把记忆分成两层,这个划分参考了认知科学里人类记忆的模型,落地到工程上非常自然。
Working Memory(工作记忆):当前任务、当前会话的短期上下文。它容量小、更新快、随会话结束就丢弃或归档。比如用户当前在问“帮我改一下这个函数的返回值类型”,这个意图就属于工作记忆。它通常直接放在 prompt 里,不需要检索。
Long-term Memory(长期记忆):跨会话沉淀下来的、值得复用的信息。比如“这个用户偏好用 TypeScript”“这个项目的数据库是 PostgreSQL”“上次这个 bug 是因为时区没处理”。这些信息需要被结构化存储,在相关的时候被检索出来。
关键问题是:什么东西值得从工作记忆晋升到长期记忆?这就是 LLM 要介入的地方。我让模型在每轮对话结束后做一次“复盘”,判断这轮里有没有值得长期记住的事实、偏好、决策。有就抽取成结构化条目写入长期记忆,没有就跳过。这个“复盘”动作,就是 hindsight 的字面含义——事后回看,提炼经验。
2.3 为什么用 MCP 把记忆做成独立服务
一开始我把记忆逻辑直接写在 Agent 主程序里,后来发现几个问题:换一个 Agent 框架就得重写一遍;多个 Agent 想共享记忆很麻烦;调试记忆逻辑要重启整个应用。
后来我把记忆层抽出来,用MCP(Model Context Protocol)做成独立服务。MCP 是一个软件协议,你可以把它理解成“给模型用的 USB 接口”——它规定了模型和外部工具/数据源之间怎么通信。记忆服务通过 MCP 暴露几个工具:write_memory、search_memory、forget_memory,任何支持 MCP 的 Agent 都能直接调用。
这样做的好处很实在:记忆逻辑和 Agent 逻辑解耦,可以独立部署、独立升级;多个 Agent 共享同一套记忆;调试的时候直接调 MCP 工具就能验证,不用跑整个对话流程。
2.4 用 Docker 打包,解决“在我机器上能跑”
记忆服务依赖向量库、数据库、嵌入模型,环境配置很烦。我用Docker Compose把整套东西打包:记忆服务本体、PostgreSQL(存结构化记忆)、Redis(做缓存和会话状态)、以及一个轻量的向量检索组件。一条docker compose up就能起来,换台机器也一样跑。这部分后面会给出完整的 compose 配置。
3. 核心细节解析与实操要点
3.1 记忆条目的数据结构设计
记忆存成什么样,直接决定了检索效果。我试过几种结构,最后定下来的是“三元组 + 元数据”的形式,这个设计借鉴了 LLM 里 token 的 key-query-value 思路。
每条记忆包含三个核心字段:
- key(我是谁):这条记忆的主体。比如“用户”“项目A”“函数parseDate”。
- query(我在找什么):这条记忆适用的场景或问题。比如“数据库选型”“日期处理”。
- value(我能提供什么):具体的内容。比如“使用 PostgreSQL 15”“时区统一用 UTC”。
用生活化的类比:这就像图书馆的索引卡。key 是书名,query 是分类标签,value 是书的内容摘要。检索的时候,你用当前的问题去匹配 query,命中后取出 value。
除了三元组,还要存元数据:创建时间、最后访问时间、访问次数、置信度、来源会话 ID。这些字段在后面的记忆淘汰和排序里非常关键。
{ "key": "project_alpha", "query": "database_selection", "value": "PostgreSQL 15, 不用 MySQL", "confidence": 0.9, "created_at": "2025-01-15T10:30:00Z", "last_accessed": "2025-01-20T14:22:00Z", "access_count": 7, "source_session": "sess_abc123" }注意:value 一定要写成“结论式”的短句,不要写成长篇大论。记忆是给模型看的,越精炼越容易被正确使用。我见过有人把整段对话塞进 value,检索出来一堆噪声,模型反而更糊涂。
3.2 LLM 如何参与记忆的写入
写入记忆是整个系统里最需要 LLM 智能的环节。我的做法是在每轮对话结束后,触发一个“记忆抽取”流程,prompt 大致是这样设计的:
你是一个记忆管理助手。请分析以下对话,判断是否有值得长期记住的信息。 对话内容: {conversation} 请按以下规则抽取: 1. 只抽取事实、偏好、决策、结论,不要抽取寒暄和过程性内容 2. 每条记忆用 key-query-value 三元组表示 3. 如果这轮对话没有值得记住的内容,返回空数组 4. 输出 JSON 格式 输出示例: [ {"key": "user", "query": "编程语言偏好", "value": "偏好 TypeScript"} ]这里有几个实操心得。第一,一定要给“返回空数组”的选项。早期我没加这条,模型每轮都硬凑几条记忆出来,导致记忆库全是垃圾。第二,few-shot 示例很重要。给一两个正例和反例,抽取质量会明显提升。第三,抽取和写入要异步。不要让记忆写入阻塞主对话流程,否则用户会感觉响应变慢。我用一个后台队列处理,对话结束就丢进队列,慢慢抽。
3.3 记忆召回:怎么在正确时机捞出正确记忆
召回比写入更难。用户问一句话,你要从几千条记忆里找出最相关的几条,还不能引入噪声。
我的召回流程分三步:
第一步,粗筛。用当前用户输入做向量检索,从记忆库里捞出 top-20 候选。这一步用嵌入模型把 query 和记忆的 query 字段都转成向量,算余弦相似度。向量检索组件我选的是轻量的方案,跑在 Docker 里,资源占用小。
第二步,精排。对 top-20 候选做重排序,综合考虑相似度、置信度、访问频率、时间衰减。时间衰减很重要——三个月前的记忆,相关性要打个折。我用的公式大致是:
score = similarity * 0.6 + confidence * 0.2 + recency * 0.15 + frequency * 0.05其中 recency 用指数衰减:exp(-days_since_access / 30),30 天为一个半衰期。
第三步,LLM 过滤。把精排后的 top-5 交给 LLM,让它判断“这些记忆里哪些真的和当前问题相关”,把不相关的剔掉。这一步是最后一道防线,能有效防止“检索到但用错”的情况。
提示:召回的记忆不要直接拼进 system prompt,最好放在一个独立的“相关记忆”区块里,并明确标注“以下是历史记忆,供参考”。这样模型能区分哪些是当前指令,哪些是历史信息。
3.4 记忆的淘汰与遗忘机制
记忆库不能只进不出,否则迟早爆炸。我设计了三层淘汰机制。
第一层,置信度淘汰。置信度低于 0.3 的记忆直接删除。置信度怎么来的?写入时由 LLM 给一个初始值,之后每次被召回且被证明有用(比如用户没有纠正),就加一点;被用户否定,就大幅降低。
第二层,时间淘汰。超过 90 天没被访问过的记忆,标记为“冷记忆”,从主检索库里移出,归档到冷存储。需要的时候还能捞回来,但不占用主库资源。
第三层,冲突消解。当新记忆和旧记忆的 key、query 都相同但 value 不同时,不是简单覆盖,而是把旧的置信度降低,新的设为高置信度。这样保留了“曾经有过不同结论”的历史,模型在需要时能看到演变过程。
4. 实操过程与核心环节实现
4.1 用 Docker Compose 一键拉起记忆服务
整套系统的部署我用 Docker Compose 管理。先给完整的 compose 文件,再逐段解释。
version: "3.8" services: memory-service: build: ./memory-service ports: - "8080:8080" environment: - DB_HOST=postgres - DB_PORT=5432 - DB_NAME=agent_memory - DB_USER=memory - DB_PASSWORD=memory_pass - REDIS_HOST=redis - REDIS_PORT=6379 - EMBEDDING_MODEL=all-MiniLM-L6-v2 depends_on: postgres: condition: service_healthy redis: condition: service_started restart: unless-stopped postgres: image: postgres:15-alpine environment: - POSTGRES_DB=agent_memory - POSTGRES_USER=memory - POSTGRES_PASSWORD=memory_pass volumes: - pg_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U memory"] interval: 5s timeout: 5s retries: 5 restart: unless-stopped redis: image: redis:7-alpine volumes: - redis_data:/data restart: unless-stopped volumes: pg_data: redis_data:几个关键点解释一下。PostgreSQL 用 alpine 版本,镜像小、启动快,记忆这种场景不需要完整版。healthcheck 必须加,否则 memory-service 可能在数据库还没就绪时就启动,连接失败。数据卷一定要挂,不然容器一删记忆全没,这个坑我踩过。restart: unless-stopped保证服务崩溃后自动拉起。
启动命令就一行:
docker compose up -d第一次跑会拉镜像、建表,大概一两分钟。之后docker compose logs -f memory-service能看到服务日志。
4.2 记忆服务的 MCP 接口实现
记忆服务对外通过 MCP 协议暴露工具。核心是三个:写入、检索、遗忘。下面给出检索工具的核心逻辑(Python 伪代码,实际实现用 FastAPI + MCP SDK):
async def search_memory(query: str, top_k: int = 5) -> list[dict]: # 1. 向量粗筛 query_vec = embed(query) candidates = vector_store.search(query_vec, top_k=20) # 2. 精排打分 scored = [] for mem in candidates: similarity = cosine_sim(query_vec, mem.vector) recency = math.exp(-days_since(mem.last_accessed) / 30) score = (similarity * 0.6 + mem.confidence * 0.2 + recency * 0.15 + min(mem.access_count / 10, 1) * 0.05) scored.append((score, mem)) scored.sort(reverse=True, key=lambda x: x[0]) top = [mem for _, mem in scored[:top_k]] # 3. LLM 过滤 filtered = await llm_filter(query, top) # 4. 更新访问记录 for mem in filtered: mem.last_accessed = now() mem.access_count += 1 await db.update(mem) return filteredMCP 工具的定义要写清楚描述,因为模型是靠描述来决定什么时候调用哪个工具的。search_memory的描述我写的是:“当需要回忆历史信息、用户偏好、过往决策时调用。输入当前问题,返回相关记忆。”描述越具体,模型调用越准。
4.3 接入 Agent 主流程
记忆服务跑起来后,接入 Agent 就简单了。以常见的对话循环为例:
async def chat_loop(user_input: str, session_id: str): # 1. 召回相关长期记忆 memories = await mcp_client.call("search_memory", {"query": user_input}) # 2. 组装 prompt system_prompt = build_system_prompt() if memories: memory_block = format_memories(memories) system_prompt += f"\n\n## 相关历史记忆\n{memory_block}" # 3. 调用 LLM response = await llm.chat(system_prompt, user_input) # 4. 异步写入记忆 asyncio.create_task(extract_and_store(user_input, response, session_id)) return response注意第 4 步是create_task,异步的。记忆抽取不阻塞用户响应,这是体验的关键。抽取任务丢进队列,后台 worker 慢慢处理。
4.4 参数选择与容量估算
记忆库的容量规划很多人忽略,等爆了才慌。给个估算方法。
假设单条记忆平均 200 字节(含向量是 1.5KB 左右,用 384 维的嵌入模型)。一个活跃用户每天产生 20 条有效记忆,一年就是 7300 条,约 11MB。1000 个用户就是 11GB。这个量级 PostgreSQL 完全扛得住。
向量检索的性能瓶颈在候选集大小。我的经验是:单用户记忆控制在 1 万条以内,检索延迟能稳定在 50ms 以内。超过就得上分片或者更专业的向量库。对于大多数应用,1 万条足够用很久了。
嵌入模型我选的是all-MiniLM-L6-v2,384 维,模型小、速度快、效果够用。如果追求更好的语义匹配,可以换更大的模型,但推理成本会上去。这个取舍看你的场景。
5. 常见问题与排查技巧实录
5.1 记忆检索不准的排查思路
检索不准是最常见的问题,表现是“明明存了,但召回不出来”或者“召回了一堆不相关的”。排查按这个顺序来。
先看存进去没有。直接查数据库:SELECT * FROM memories WHERE key = 'xxx'。如果没存进去,问题在写入环节,检查 LLM 抽取的 prompt 是不是太严格,或者异步任务是不是失败了。
再看向量对不对。把 query 和记忆的向量都打出来,算一下余弦相似度。如果相似度很低,说明嵌入模型没理解语义,可能需要换模型或者调整 query 的表述方式。
最后看排序。有时候记忆被召回了,但排在后面被 top_k 截掉了。把 top_k 调大试试,如果调大就出来了,说明排序权重需要调整,可能是时间衰减太狠,把老记忆压下去了。
我整理了一个速查表:
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 完全召回不出 | 写入失败 | 查数据库 | 检查异步任务日志 |
| 召回但排序靠后 | 权重不合理 | 打印打分明细 | 调整 recency/confidence 权重 |
| 召回不相关 | 嵌入模型弱 | 算相似度 | 换嵌入模型或优化 query |
| 召回重复 | 去重缺失 | 查 key+query | 加唯一约束或合并逻辑 |
| 召回旧结论 | 冲突未消解 | 查同 key 记录 | 启用冲突消解机制 |
5.2 Docker 环境下的典型故障
用 Docker 跑这套东西,有几个坑几乎人人都会踩。
容器起来了但服务连不上数据库。九成是 healthcheck 没配或者 depends_on 条件写错。PostgreSQL 启动需要几秒,服务如果不等就绪就连接,必然失败。加condition: service_healthy能解决。
Windows 上 Docker Desktop 启动失败,报 virtualization support not detected。这是 BIOS 里虚拟化没开。进 BIOS 打开 VT-x 或 AMD-V。如果是 Windows 11,还要确认 WSL2 装好了。这个报错跟 Docker 本身没关系,是系统层面的。
docker 网络不通,容器之间互相 ping 不到。默认情况下,同一个 compose 文件里的服务在同一个网络里,用服务名就能互相访问。如果你手动docker run起的容器,要--network指定同一个网络。我建议所有服务都用 compose 管理,省心。
数据卷权限问题。Linux 上 PostgreSQL 容器可能因为挂载目录权限不对起不来。解决办法是给目录设对权限,或者干脆用命名卷(named volume)而不是绑定挂载(bind mount)。上面 compose 里我用的就是命名卷。
5.3 记忆污染与投毒防护
这个话题最近很热,agentpoison那类研究说的就是通过污染记忆来操控 Agent 行为。实际应用里,记忆污染主要来自两个地方:用户恶意输入和模型幻觉。
防护手段有几条。第一,写入前做校验。对抽取出的记忆做一次 LLM 审核,判断是否包含可疑指令(比如“忽略之前的所有指令”这类)。第二,来源标记。每条记忆记录来源,用户直接说的和模型推断的分开存,召回时区别对待。第三,敏感操作二次确认。如果召回的记忆要触发写文件、发请求这类操作,必须让用户确认,不能自动执行。
注意:记忆系统天然是攻击面。任何能被写入记忆的内容,都可能在未来某个时刻影响 Agent 行为。设计时就要把“记忆是不可信输入”这个前提刻进脑子里。
5.4 性能优化的几个实操技巧
记忆服务跑久了会变慢,几个优化点很有效。
给向量检索加缓存。相同或相似的 query 很常见,用 Redis 缓存检索结果,命中率能到 30% 以上。缓存 key 用 query 的哈希,过期时间设短一点,5 分钟就够。
批量写入。异步抽取任务不要一条一条写数据库,攒一批一起写。我用的是每 10 条或者每 5 秒触发一次批量写入,数据库压力小很多。
索引优化。PostgreSQL 里给key、query、last_accessed建索引。向量检索如果用的是 pgvector,记得建 IVFFlat 或 HNSW 索引,查询速度差好几倍。
冷热分离。前面提到的冷记忆归档,实际就是把 90 天没访问的记录移到另一张表。主表小了,检索自然快。
6. 记忆系统的扩展方向与个人体会
这套hindsight记忆系统跑了一段时间,稳定性不错,但我还在持续折腾几个方向。
一个是记忆的主动整理。现在是被动等 LLM 抽取,未来想让系统定期做“记忆复盘”,把零散的记忆合并成更高层的结论。比如“用户喜欢 TypeScript”“用户讨厌 Java”可以合并成“用户偏好静态类型的前端语言”。这需要更强的 LLM 推理能力,但方向是对的。
另一个是多 Agent 共享记忆。现在记忆服务是独立的,多个 Agent 理论上能共享,但实际用起来还要处理权限和隔离。不同 Agent 的记忆该不该互相可见,这是个设计问题,不是技术问题。
最后分享一个我踩过的坑:不要过早追求记忆的“智能”。我一开始想搞很复杂的记忆图谱、关系推理,结果发现基础的三元组 + 向量检索已经能解决 80% 的问题。先把简单方案跑通、跑稳,再考虑加复杂度。记忆系统最怕的不是不够聪明,而是不稳定——今天能召回,明天召回不出来,这种不确定性比没有记忆还糟糕。
如果你也在做 Agent 记忆,建议从最小可用版本开始:一个 PostgreSQL 存三元组,一个嵌入模型做检索,一个 LLM 做抽取。跑起来,用起来,再根据实际问题迭代。这套东西没有银弹,都是在真实场景里磨出来的。