1. 从“hindsight”说起:为什么Agent的记忆问题值得单独拎出来做
“hindsight”这个词本身很有意思,字面意思是“事后的洞察力”,也就是我们常说的“后见之明”。放在LLM Agent的语境里,它指向一个非常具体且长期被低估的问题:Agent在完成任务之后,能不能回过头来,从自己的历史交互中提炼出有价值的经验,并在下一次遇到类似场景时真正用上。
我接触过不少Agent项目,从简单的工具调用到复杂的多步推理,绝大多数团队在早期都会把精力砸在“当前这一步能不能做对”上——prompt怎么写、工具怎么调、MCP协议怎么接。但跑了一段时间之后,一个共性问题就会浮出水面:Agent没有记忆,或者说,它的记忆是死的。每次对话都是重新开始,昨天踩过的坑今天照踩不误,上周用户纠正过的偏好这周完全想不起来。这不是模型能力的问题,是架构设计里压根没给“记忆”留位置。
“hindsight”这个项目标题,结合agent memory、LLM、MCP、Docker这几个关键词,我判断它要解决的核心问题是:给LLM-based Agent构建一套可持久化、可检索、可演进的记忆系统,让Agent具备“回头看”的能力。具体来说,它需要做到几件事:把Agent与环境的交互历史存下来,从历史中提取结构化的经验或知识,在新任务到来时检索相关记忆并注入上下文,同时通过MCP协议与外部工具链打通,用Docker保证部署的一致性和可移植性。
这套东西适合谁看?如果你正在做Agent应用开发,不管是客服机器人、代码助手还是自动化工作流,只要你发现Agent“记不住事”“重复犯错”“每次都要重新教”,那这套思路就值得你花时间研究。如果你只是刚接触LLM应用,还没到需要记忆系统的阶段,那可以先了解概念,等遇到瓶颈了再回来深入。
我下面会从设计思路、核心细节、实操落地、问题排查几个维度,把“hindsight”这类Agent记忆系统的构建过程拆开来讲。所有内容基于我对Agent memory领域的实践认知和常见工程方案来展开,不会停留在概念层面,而是尽量给出可复现的路径。
2. 整体设计思路:Agent记忆系统到底该怎么搭
2.1 为什么不能直接把对话历史塞进上下文
很多人第一反应是:记忆嘛,把历史对话全部拼到prompt里不就行了?这个方案在Demo阶段能用,一旦上生产就会撞墙。原因有三层。
第一层是token成本。假设你的Agent每天处理1000次交互,每次交互平均2000 token,一个月下来就是6000万token的历史数据。你不可能每次都把全部历史塞进去,成本扛不住,延迟也扛不住。
第二层是信息密度。原始对话历史里大量内容是冗余的、重复的、无意义的。用户说“你好”“谢谢”“帮我查一下”,这些内容对后续决策几乎没有价值。真正有价值的是那些纠正性反馈、偏好声明、任务成功/失败的关键节点。把原始历史直接塞进去,等于让模型在噪音里找信号。
第三层是检索效率。当记忆量大了之后,你需要的是“根据当前任务找到最相关的几条记忆”,而不是“把所有记忆都过一遍”。这本质上是一个检索问题,不是存储问题。
所以“hindsight”这类系统的核心设计思路,一定是分层处理:原始交互日志存一份(用于审计和回溯),结构化记忆存一份(用于检索和注入),经验/规则存一份(用于长期演进)。三层各司其职,不能混在一起。
2.2 记忆的分层模型:working memory、episodic memory、semantic memory
借鉴认知科学的分类,Agent记忆通常分三层:
Working memory(工作记忆)是当前任务上下文里正在用的信息,生命周期最短,通常就是当前对话窗口内的内容。它的作用是保证Agent在单次任务中的连贯性。
Episodic memory(情景记忆)是具体的事件记录,比如“2024年3月15日,用户要求查询订单状态,Agent调用了订单查询工具,返回了结果,用户表示满意”。它记录的是“发生了什么”,带有时间戳和上下文。
Semantic memory(语义记忆)是从多个情景中抽象出来的规律或知识,比如“这个用户偏好用中文回复”“查询订单时需要先验证用户身份”“每周五下午系统会维护,查询会超时”。它记录的是“什么是对的/有效的”。
“hindsight”的价值在于,它不只是存,而是从episodic向semantic的转化。这个转化过程就是“hindsight”的字面意思——事后从具体经历中提炼出可复用的洞察。
2.3 为什么选MCP和Docker作为基础设施
MCP(Model Context Protocol)在这里的角色是标准化Agent与外部资源的连接方式。记忆系统不是一个孤立的数据库,它需要和工具调用、知识库、外部API打通。MCP提供了一套协议,让Agent可以用统一的方式访问这些资源,而不需要为每个工具写一套适配代码。
Docker的角色是环境一致性。Agent记忆系统涉及多个组件:向量数据库、关系型数据库、缓存、消息队列、MCP server。这些东西在开发机上跑通不难,难的是在测试环境、生产环境、不同同事的机器上都能一致运行。Docker Compose可以把整套依赖打包,一条命令启动,避免“在我机器上是好的”这类问题。
这两个选择背后的逻辑是一致的:降低集成成本和运维成本。Agent记忆系统本身已经够复杂了,基础设施层面能标准化就标准化,不要把精力浪费在环境配置上。
3. 核心细节解析:记忆的写入、检索与演进
3.1 记忆写入:什么该记,什么不该记
写入策略直接决定了记忆系统的质量。我的经验是,不要试图记录一切。全量记录的结果是检索时噪音太大,反而降低效果。
一个可操作的写入触发条件清单:
- 用户显式纠正:用户说“不对”“不是这个意思”“你应该先做X再做Y”,这类信号必须记录,而且优先级最高。
- 任务成功/失败节点:一个多步任务完成或失败时,记录关键步骤和结果,用于后续类似任务的参考。
- 偏好声明:用户说“我喜欢用表格展示”“以后回复简短一点”,这类偏好需要写入semantic memory。
- 工具调用异常:某个工具连续失败、超时、返回异常格式,这类信息对后续决策很有价值。
- 高频模式:如果某个操作在短时间内重复出现,可能意味着需要抽象成规则。
写入的时候要注意结构化。不要存原始文本,而是存成带字段的JSON:{type, timestamp, context, action, outcome, lesson}。这样后续检索和聚合都方便。
注意:写入操作最好是异步的,不要阻塞主流程。Agent回复用户之后,后台再慢慢处理记忆写入。否则每次交互都要等记忆系统响应,延迟会很难看。
3.2 记忆检索:怎么找到“对的那几条”
检索的核心是相关性排序。常见方案是向量检索+关键词检索的混合模式。
向量检索负责语义相似度:把当前任务描述embedding之后,去记忆库里找余弦相似度最高的几条。关键词检索负责精确匹配:比如用户提到了“订单号12345”,那就直接找包含这个订单号的记忆。
混合检索的权重需要调。我的经验是,在Agent记忆场景下,关键词匹配的权重要比通用搜索场景更高。因为Agent记忆里很多内容是具体的工具名、参数名、错误码,这些词的语义embedding区分度不高,但精确匹配非常有效。
检索出来之后,还需要一层重排序。可以用一个小模型或者规则来打分,考虑的因素包括:时间衰减(越近的记忆越重要)、类型权重(纠正性反馈>普通事件)、使用频率(被检索过多次的记忆可能更通用)。
最终注入上下文的时候,不要把所有检索结果都塞进去。控制在3-5条,每条压缩成一句话或一个短段落。太多会稀释注意力,反而干扰模型判断。
3.3 记忆演进:从情景到语义的抽象
这是“hindsight”最核心也最难的部分。系统需要定期(比如每天凌晨)跑一个批处理任务,把episodic memory聚类、抽象、归纳成semantic memory。
具体做法可以是:把最近一段时间的情景记忆按任务类型分组,每组用LLM做一次总结,提取出“在这个场景下,什么做法有效,什么做法无效”。总结结果写入semantic memory,同时保留原始情景记忆的引用。
这个过程的难点在于避免过度泛化。比如从“用户A在查询订单时喜欢先看物流状态”抽象成“所有用户查询订单时都应该先看物流状态”,这就错了。所以抽象的时候要保留条件限定,semantic memory的条目应该是“在X条件下,Y做法通常有效”的形式。
另一个难点是冲突处理。新的semantic memory和旧的冲突怎么办?我的做法是保留版本号和时间戳,检索时优先用新的,但旧的也不删,用于追溯。如果冲突频繁出现,说明这个场景本身就不稳定,不应该抽象成规则。
4. 实操落地:用Docker和MCP把系统跑起来
4.1 环境准备与Docker Compose编排
先明确组件清单。一个最小可用的Agent记忆系统需要:
| 组件 | 用途 | 推荐选型 |
|---|---|---|
| 向量数据库 | 存储记忆embedding,支持相似度检索 | Qdrant或Milvus |
| 关系型数据库 | 存储结构化记忆、元数据、版本信息 | PostgreSQL |
| 缓存 | 加速高频检索,存储会话状态 | Redis |
| MCP Server | 对外暴露记忆读写接口 | 自研或基于开源框架 |
| 应用服务 | 记忆写入、检索、演进的业务逻辑 | Python FastAPI |
Docker Compose的编排思路是:每个组件一个service,通过内部网络通信,数据卷持久化。下面是一个可参考的compose文件结构:
version: "3.9" services: qdrant: image: qdrant/qdrant:latest ports: - "6333:6333" volumes: - qdrant_data:/qdrant/storage postgres: image: postgres:16 environment: POSTGRES_DB: agent_memory POSTGRES_USER: memory_user POSTGRES_PASSWORD: memory_pass volumes: - pg_data:/var/lib/postgresql/data ports: - "5432:5432" redis: image: redis:7-alpine ports: - "6379:6379" memory-service: build: ./memory-service depends_on: - qdrant - postgres - redis environment: QDRANT_HOST: qdrant PG_HOST: postgres REDIS_HOST: redis ports: - "8000:8000" volumes: qdrant_data: pg_data:启动命令就一句:docker compose up -d。等所有服务healthy之后,记忆系统的底座就搭好了。
注意:Windows环境下如果Docker Desktop启动报“virtualization support not detected”,需要进BIOS开启虚拟化支持(Intel VT-x或AMD-V),然后在Windows功能里确认WSL2或Hyper-V已启用。这个问题很常见,但排查起来不复杂。
4.2 MCP Server的实现要点
MCP Server是Agent访问记忆系统的入口。它需要暴露几个核心工具:
write_memory:写入一条记忆,参数包括类型、内容、上下文、时间戳。search_memory:根据查询文本检索相关记忆,返回排序后的结果。get_preferences:获取用户的偏好类记忆。summarize_episodes:触发情景记忆到语义记忆的抽象。
实现的时候,参数schema要设计得足够明确。比如write_memory的type字段应该是枚举值(correction/preference/event/rule),而不是自由文本。这样后续检索和聚合的时候才能做精确过滤。
MCP Server和记忆服务之间通过内部HTTP或gRPC通信。MCP Server本身不直接连数据库,它只做协议转换和参数校验,业务逻辑下沉到memory-service。这样分层的好处是,MCP Server可以独立部署和扩缩容,不会因为记忆服务的负载影响协议层的稳定性。
4.3 记忆写入与检索的代码骨架
写入逻辑的核心是先判断是否值得记,再决定记成什么类型。下面是一个简化的Python示例:
def should_write(interaction: dict) -> bool: if interaction.get("user_correction"): return True if interaction.get("task_status") in ("success", "failure"): return True if interaction.get("preference_declared"): return True return False def classify_memory(interaction: dict) -> str: if interaction.get("user_correction"): return "correction" if interaction.get("preference_declared"): return "preference" if interaction.get("task_status"): return "event" return "event" def write_memory(interaction: dict): if not should_write(interaction): return mem_type = classify_memory(interaction) record = { "type": mem_type, "timestamp": interaction["timestamp"], "context": interaction.get("context", ""), "content": interaction.get("summary", ""), "metadata": interaction.get("metadata", {}), } # 写入PostgreSQL pg.insert("memories", record) # 生成embedding写入Qdrant vector = embed(record["content"]) qdrant.upsert(collection="memories", vector=vector, payload=record)检索逻辑的核心是混合检索+重排序:
def search_memory(query: str, top_k: int = 5): # 向量检索 query_vec = embed(query) vector_results = qdrant.search( collection="memories", query_vector=query_vec, limit=top_k * 3 ) # 关键词检索 keyword_results = pg.search_fulltext("memories", query, limit=top_k * 3) # 合并去重 merged = merge_results(vector_results, keyword_results) # 重排序 ranked = rerank(merged, query) return ranked[:top_k]重排序的规则可以很简单:时间越近加分,类型是correction或preference加分,被检索次数多加分。不需要上模型,规则引擎就够用。
4.4 记忆注入的上下文组装
检索到记忆之后,怎么注入到Agent的prompt里也有讲究。我的做法是按类型分组,用不同的模板:
- 偏好类记忆放在system prompt的末尾,用“用户偏好”小节列出。
- 纠正类记忆放在当前任务描述之前,用“历史反馈”小节提醒。
- 事件类记忆放在工具调用结果之后,用“相关历史”小节参考。
每条记忆压缩成一句话,不要超过50字。如果检索到5条,总共注入的token控制在300以内。这个量级对模型来说刚好能注意到,又不会喧宾夺主。
5. 常见问题与排查技巧实录
5.1 记忆检索不相关怎么办
这是最常见的问题。排查顺序如下:
先看embedding模型是否合适。如果你用的是通用文本embedding模型,在Agent记忆这种短文本、专业术语多的场景下,效果可能不好。可以试试针对检索任务优化的模型,或者在embedding之前先做一次关键词提取,把关键实体拼到文本前面。
再看检索参数。top_k设太小会漏,设太大引入噪音。我的经验是向量检索取top 15,关键词检索取top 15,合并后重排序取top 5。这个比例在多数场景下比较平衡。
最后看记忆本身的质量。如果写入的记忆内容太笼统,比如“用户查询了订单”,那检索什么都匹配不上。写入的时候要尽量具体:“用户查询订单12345的物流状态,期望看到预计送达时间”。
5.2 记忆写入过多导致检索变慢
写入策略太宽松,什么垃圾都往里塞,检索时自然慢。解决办法是加一层写入过滤,只有满足触发条件的才写。另外,定期做记忆压缩:把超过30天的、从未被检索过的、类型是event的记忆归档到冷存储,不参与在线检索。
Qdrant和PostgreSQL都支持按时间分区,可以把冷数据放到单独的分区,检索时只查热分区。这个优化能把检索延迟从几百毫秒降到几十毫秒。
5.3 Docker网络不通导致服务间调用失败
Docker Compose默认会创建一个内部网络,所有service在同一个网络里可以用service名互相访问。但有几个坑:
- 端口映射和内部访问是两回事。你在compose里写了
ports: "5432:5432",这是把容器端口映射到宿主机。容器之间互相访问用的是service名+容器端口,不是宿主机端口。 - depends_on不保证服务就绪。
depends_on只保证启动顺序,不保证服务已经可以接受请求。需要在应用层做重试,或者用healthcheck+condition。 - 环境变量里的host要写service名。比如memory-service连PostgreSQL,
PG_HOST应该写postgres,不是localhost。
排查的时候,进容器里ping一下目标service名,或者用nc -zv postgres 5432测端口连通性。多数问题都是host写错了。
5.4 记忆演进任务跑不动或结果质量差
记忆演进是批处理任务,跑不动通常是数据量太大或者LLM调用超时。解决办法是分批处理,每次只处理一个时间窗口的数据,比如按天分组。每组内部再按任务类型聚类,减少LLM调用次数。
结果质量差通常是抽象过度或抽象不足。抽象过度就是前面说的,把特例当规律。抽象不足就是总结出来的东西和原始情景差不多,没有提炼价值。调prompt的时候,明确要求“保留条件限定”“用‘在X情况下,Y做法有效’的句式”,效果会好很多。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 检索结果不相关 | embedding模型不适配 | 人工检查top 10结果 | 换模型或加关键词提取 |
| 检索延迟高 | 记忆量过大或索引未优化 | 看Qdrant/PostgreSQL慢查询 | 加分区、归档冷数据 |
| 服务间调用失败 | Docker网络配置错误 | 容器内ping/nc测试 | 检查host和端口配置 |
| 记忆演进质量差 | prompt过于笼统 | 人工抽检总结结果 | 加条件限定和句式约束 |
| 写入阻塞主流程 | 同步写入 | 看接口响应时间 | 改异步写入 |
| 记忆冲突 | 新旧规则矛盾 | 查版本和时间戳 | 保留版本,检索优先新的 |
6. 一些实操心得和后续扩展方向
我在搭这类系统的时候,最大的体会是:不要一开始就追求完美。先跑通“写入-检索-注入”这个最小闭环,哪怕检索效果一般,先让Agent能用上记忆。然后根据实际bad case去调写入策略和检索参数。记忆系统的质量是迭代出来的,不是设计出来的。
另一个心得是日志要打全。每次检索返回了什么、注入了什么、Agent用了哪条记忆、结果如何,这些都要记下来。没有这些日志,你根本不知道记忆系统到底有没有起作用。我习惯在memory-service里加一个retrieval_log表,记录每次检索的query、返回的记忆ID、最终注入的记忆ID。后面分析的时候一目了然。
后续扩展的话,有几个方向可以考虑。一是多Agent共享记忆,让不同Agent之间能互相学习,但这需要解决权限和冲突问题。二是记忆的可解释性,让Agent能说出“我之所以这么做,是因为之前遇到过类似情况”,这对调试和用户信任都有帮助。三是记忆的主动遗忘,有些记忆过时了、错误了,需要能被标记和淘汰,而不是永远留在库里。
这些方向目前都还在探索阶段,没有特别成熟的方案。但“hindsight”这个思路本身是对的:Agent不能只活在当下,它需要回头看,才能往前走得更稳。