1. 从“hindsight”说起:为什么我们需要给 Agent 装上一双“后视之眼”
第一次看到“hindsight”这个词,我脑子里蹦出来的不是词典释义,而是自己踩过的一个坑。去年我搭了一个基于 LLM 的自动化运维助手,能连 MCP 工具、能查 Docker 容器状态、能读日志。上线头一周表现惊艳,第二周开始犯迷糊——同一个报错它反复问、同一个修复方案它反复试,明明三天前才解决过的问题,它像失忆一样从头再来。我当时以为是模型能力不够,换了更大的模型,结果只是把“失忆”换成了“更贵的失忆”。
问题根本不在模型,而在记忆。LLM 本身是无状态的,每次请求都是一张白纸。你给它挂上 MCP 工具、接上 Docker 环境,它确实能“动手”,但它记不住“手”之前干过什么。hindsight 这个项目标题,恰好戳中了这个痛点:hindsight 是“事后之明”,是回头看的能力。给 Agent 装上 hindsight,本质上是让它具备对历史交互的回顾、提炼与复用能力,而不是每次都从零开始。
这篇文章我想聊的,就是围绕 agent memory 这套东西,怎么从零搭出一个能记住事、能反思、能跨会话复用的记忆层。涉及 LLM、MCP、Docker 这几个关键词,我会把选型逻辑、存储结构、实操步骤、以及我踩过的坑都摊开讲。适合两类人看:一是已经在用 MCP 接工具、但被 Agent“健忘”折磨的开发者;二是想给自己的 LLM 应用加一层长期记忆、但不知道从哪下手的人。不需要你是分布式系统专家,但得对 Docker 和基本的 API 调用有概念。
先说清楚一个认知:agent memory 不是把聊天记录往数据库一塞就完事。那是日志,不是记忆。真正的记忆层要解决三个问题——存什么、怎么取、怎么用。hindsight 的价值就在于它回答的是第三个问题:当 Agent 面对新任务时,它能不能“回头看看”过去类似场景是怎么处理的,并把那个经验迁移过来。这跟 RAG 检索文档不是一回事,RAG 检索的是外部知识,hindsight 检索的是 Agent 自己的经历。
2. 记忆层的整体设计:存什么、放哪里、怎么连
2.1 三类记忆的划分与选型考量
动手之前先把记忆分类想清楚,否则后面存储结构会一团乱。我参考的是认知科学里比较通用的分法,落到工程上就是三类:
- 工作记忆(working memory):当前会话内的短期上下文,比如这一轮对话里用户提到的容器名、刚才调用的 MCP 工具返回了什么。生命周期就是一次会话,会话结束基本可以丢。
- 情景记忆(episodic memory):具体发生过的事件,带时间戳和上下文。比如“2024-06-11 下午,用户让我重启 nginx 容器,原因是 502,我用 docker restart 解决了”。这是 hindsight 的核心素材。
- 语义记忆(semantic memory):从多次情景里提炼出的规律。比如“这台机器上 nginx 的 502 通常是上游 php-fpm 挂了,先查 php-fpm 再重启 nginx”。这是最高价值的沉淀。
为什么这么分?因为它们的检索方式和存储成本完全不同。工作记忆放内存就行,读写要快;情景记忆要能按时间、按关键词、按语义检索,得用向量库加结构化字段;语义记忆数量少但价值高,适合单独维护一张“经验卡片”表,甚至可以让 LLM 定期从情景记忆里归纳生成。
我试过偷懒,把所有东西都塞进一个向量库,结果检索时工作记忆的噪音把情景记忆的召回挤掉了。后来分开存,工作记忆用 Redis 带 TTL,情景和语义用 Postgres 加 pgvector,检索质量立刻上来了。这个取舍的逻辑很简单:不同记忆的访问模式不一样,混在一起就是给自己找麻烦。
2.2 为什么用 Docker 把整套环境容器化
这套东西涉及 LLM 调用、向量库、关系库、MCP 服务,本地直接装能把环境搞崩。用 Docker Compose 编排是最省心的选择。我见过有人问“virtualization support not detected docker desktop failed to start”这种问题,多半是 BIOS 里虚拟化没开,或者 Windows 上 Hyper-V 和 WSL2 冲突。这类环境问题在容器化方案里是绕不开的第一道坎,后面排查章节我会细说。
容器化的另一个好处是 MCP 服务的隔离。MCP 协议本质上是让 LLM 通过标准化接口调用外部工具,这些工具可能是 playwright 做浏览器自动化、可能是 burpsuite 做安全测试、也可能是 blender 做三维操作。每个工具依赖不同,塞在一个环境里迟早打架。一个 MCP 服务一个容器,通过 Docker 网络互通,干净利落。
提示:MCP 是“Model Context Protocol”的缩写,可以理解成 LLM 和外部工具之间的通用插座。它跟硬件协议不是一回事,是软件层面的接口约定。你不需要自己实现协议细节,用现成的 MCP server 就行。
2.3 整体架构与数据流
架构上分四层,从下往上:
- 存储层:Postgres + pgvector 存情景和语义记忆,Redis 存工作记忆。
- 记忆服务层:一个独立的服务,对外暴露“写入记忆”“检索记忆”“归纳记忆”三个接口。
- Agent 编排层:负责调 LLM、调 MCP 工具,在关键节点调用记忆服务。
- 工具层:各个 MCP server,跑在独立容器里。
数据流是这样的:用户发起请求 → Agent 先从记忆服务检索相关情景和语义 → 把检索结果拼进 prompt → LLM 决策调用哪个 MCP 工具 → 工具执行返回结果 → Agent 把这次交互写入情景记忆 → 会话结束后触发一次归纳,把值得沉淀的写成语义记忆。
这个流程里最关键的是“检索时机”和“写入时机”。检索不能每轮都做,太费 token;写入不能等会话结束,万一崩了就丢了。我的做法是:检索在任务开始时做一次,任务中途如果换了子目标再补一次;写入在每次工具调用返回后立即做,用异步队列,不阻塞主流程。
3. 核心细节拆解:记忆的存储结构与检索策略
3.1 情景记忆的表结构设计
情景记忆是 hindsight 的原料,表结构设计直接决定检索质量。我用的字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | uuid | 主键 |
| session_id | text | 会话标识,用于按会话聚合 |
| timestamp | timestamptz | 事件发生时间 |
| task_goal | text | 当时要完成的目标 |
| action | text | 执行的动作,如调用了哪个 MCP 工具 |
| params | jsonb | 动作参数 |
| result | text | 执行结果摘要 |
| outcome | text | 成功/失败/部分成功 |
| embedding | vector(1536) | 对 goal+action+result 拼接后的向量 |
为什么把 task_goal 单独拎出来?因为检索时用户的新请求往往只跟“目标”相关,跟具体动作无关。比如用户说“帮我看看服务为什么慢”,检索时应该匹配历史上“排查服务慢”的情景,而不是匹配“执行了 docker stats”这个动作。把 goal 单独向量化,召回准确率明显更高。
embedding 维度 1536 是 OpenAI text-embedding-3-small 的默认输出。如果你用本地模型,维度可能不同,建表时记得改。pgvector 支持多种距离度量,我选的是余弦距离,因为文本语义相似度用余弦更稳。
3.2 语义记忆的“经验卡片”设计
语义记忆不存原始事件,存提炼后的规律。我把它设计成“经验卡片”,每张卡片包含:
- 触发条件:什么情况下适用,比如“nginx 返回 502”。
- 建议动作:应该怎么做,比如“先检查 php-fpm 进程状态”。
- 置信度:这条经验被验证过多少次,初始 0.5,每次成功应用加 0.1,失败减 0.2。
- 来源:从哪些情景记忆归纳来的,存 id 列表,方便追溯。
置信度这个设计很关键。Agent 自己归纳的经验不一定对,如果无脑信任,错误会累积。有了置信度,检索时可以设阈值,低于 0.3 的卡片不返回。我实测下来,这个机制能挡掉大部分“幻觉经验”。
3.3 检索策略:三路召回加重排
单靠向量检索不够。我遇到过用户问“上次那个容器问题怎么解决的”,向量检索把“容器”相关的全召回了,但“上次”这个时间限定没体现。所以检索要做三路:
- 语义召回:用 query 的 embedding 去情景和语义记忆里找相似的,取 top 20。
- 时间召回:如果 query 里有时间词(“上次”“昨天”“刚才”),按 timestamp 倒序取最近 10 条。
- 关键词召回:用 BM25 或简单的全文索引,匹配容器名、错误码这类精确词,取 top 10。
三路结果合并去重后,用一个轻量重排模型(我用的 bge-reranker-base)打分,取 top 5 塞进 prompt。这个流程听起来复杂,但每步都是现成组件,拼起来不到 200 行代码。
注意:重排模型别用太大的,base 级别足够,推理延迟能控制在 100ms 内。用 large 级别延迟翻倍,收益不明显。
3.4 工作记忆的 TTL 与淘汰策略
工作记忆放 Redis,key 是 session_id,value 是一个 list,存最近的 N 轮交互。TTL 设 2 小时,够一次完整任务用。淘汰策略用 LRU,但有个细节:工具调用的结果比闲聊重要,不能一视同仁。我的做法是给每条记录加个权重,工具调用权重 2,普通对话权重 1,淘汰时按权重加权 LRU。这样即使会话很长,关键的工具调用记录也不会被挤掉。
4. 实操过程:从零搭一套带 hindsight 的 Agent 记忆层
4.1 环境准备与 Docker Compose 编排
先装 Docker。Windows 用户装 Docker Desktop,Linux 用户装 docker-ce。装完跑docker run hello-world验证。如果报 “virtualization support not detected”,去 BIOS 开虚拟化;如果 Windows 上启动失败,检查 WSL2 是否装好,wsl --update跑一下。
然后写 docker-compose.yml,把 Postgres、Redis、记忆服务、MCP 工具都编排进去:
version: "3.9" services: postgres: image: pgvector/pgvector:pg16 environment: POSTGRES_PASSWORD: mempass POSTGRES_DB: agentmem ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data redis: image: redis:7-alpine ports: - "6379:6379" memory-service: build: ./memory-service depends_on: - postgres - redis environment: PG_DSN: postgresql://postgres:mempass@postgres:5432/agentmem REDIS_URL: redis://redis:6379/0 ports: - "8000:8000" mcp-playwright: image: mcp/playwright:latest ports: - "8931:8931" volumes: pgdata:pgvector 官方镜像直接带扩展,省得自己编译。MCP 工具我拿 playwright 举例,其他工具照葫芦画瓢加 service 就行。
4.2 初始化数据库与向量扩展
容器起来后,进 Postgres 建表和扩展:
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE episodic_memory ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), session_id text NOT NULL, timestamp timestamptz DEFAULT now(), task_goal text, action text, params jsonb, result text, outcome text, embedding vector(1536) ); CREATE INDEX ON episodic_memory USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); CREATE INDEX ON episodic_memory (session_id, timestamp DESC); CREATE TABLE semantic_memory ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), trigger_condition text, suggested_action text, confidence float DEFAULT 0.5, source_ids uuid[], embedding vector(1536) );ivfflat 索引的 lists 参数,经验值是行数的平方根。数据量小的时候(几千条)设 100 够用,数据量大了再调。建索引前先插点数据,空表建索引没意义。
4.3 记忆写入的实现
写入分同步和异步。工具调用返回后,立即往 Redis 写工作记忆,同时往一个内存队列塞一条待持久化的情景记忆。后台协程消费队列,做 embedding 后写 Postgres。这样主流程不被 embedding 的延迟拖慢。
embedding 调用要加缓存。同一个 result 文本可能被多次写入(比如重试),用文本 hash 做 key 缓存 embedding 结果,能省不少 API 费用。我实测缓存命中率在 30% 左右,一个月省下的费用够喝好几杯咖啡。
写入情景记忆时,task_goal 从当前会话的上下文里取。如果 Agent 是多轮对话,goal 可能变化,我的做法是每轮让 LLM 用一句话总结当前目标,存进去。多花一次 LLM 调用,但检索质量提升明显。
4.4 记忆检索的接入点
检索接口接收 query 文本和可选的 session_id,返回拼好的记忆文本块。Agent 在构造 prompt 时,把这个文本块放在 system prompt 之后、用户输入之前。
prompt 模板大概长这样:
你是一个有记忆的助手。以下是过去相关经验: {retrieved_memories} 如果经验与当前任务无关,忽略它。如果相关,优先参考高置信度的经验。 当前任务:{user_input}这里有个坑:检索回来的记忆可能互相矛盾。比如一条说“先重启 nginx”,另一条说“先查 php-fpm”。我的处理是在 prompt 里明确让 LLM 按置信度排序参考,同时在检索阶段就把置信度低于阈值的过滤掉。双保险。
4.5 语义记忆的归纳触发
归纳不能太频繁,也不能太少。我的策略是:每积累 20 条同一 session 的情景记忆,或者会话结束时情景记忆超过 10 条,触发一次归纳。归纳用 LLM 做,prompt 大意是“从以下事件中提炼可复用的规律,输出触发条件和建议动作”。
归纳出来的卡片先存着,置信度 0.5。后续每次被检索并成功应用,置信度上调。如果某张卡片连续 5 次被检索但应用后任务失败,置信度降到 0.1 以下,自动归档不再返回。这套机制让记忆层能自我净化,不用人工干预。
5. 常见问题与排查技巧实录
5.1 Docker 环境类问题速查
| 现象 | 原因 | 解决 |
|---|---|---|
| virtualization support not detected | BIOS 虚拟化未开 | 进 BIOS 开 VT-x/AMD-V |
| Docker Desktop 启动失败 | WSL2 未装或版本旧 | wsl --update后重启 |
| 容器间网络不通 | 不在同一 network | compose 里显式定义 network |
| pgvector 扩展建不了 | 用了普通 postgres 镜像 | 换 pgvector/pgvector 镜像 |
| 端口冲突 | 5432 被本地 PG 占用 | 改映射端口或停本地 PG |
Docker 网络不通这个问题我遇到最多。默认 compose 会建一个 network,所有 service 都在里面,用 service 名当 hostname 就能互通。但如果你手动docker run起的容器,不在这个 network 里,就连不上。排查方法:docker network ls看有哪些网络,docker inspect 容器名看它在哪个网络。
5.2 记忆检索质量问题
问题一:检索回来的记忆不相关。先看 embedding 模型是不是多语言的。如果你用英文模型处理中文 query,效果会很差。换 bge-m3 或 text-embedding-3-large 这类多语言模型。再看 chunk 粒度,情景记忆如果一条太长(比如把整个工具输出塞进去),向量会被稀释。我的做法是 result 字段只存摘要,超过 500 字截断,完整输出另存。
问题二:明明有相关记忆但没召回。检查索引类型。ivfflat 是近似索引,有召回损失。数据量小的时候可以先用精确检索(不建索引,直接算距离),确认是索引问题再调 lists 参数。另外检查 embedding 是不是写入时和检索时用了不同模型,这个错误很隐蔽。
问题三:记忆太多把 prompt 撑爆。控制返回条数,top 5 是甜点。如果确实需要更多,做分层摘要:先返回语义记忆(短),再返回情景记忆的摘要(中),完整情景按需加载。
5.3 MCP 工具调用的典型故障
MCP 协议本身不复杂,但工具实现质量参差不齐。我遇到过 playwright MCP 在容器里跑不起来,因为缺字体库;也遇到过工具返回的 schema 和 LLM 期望的不一致,报 “provider rejected the request schema or tool payload”。
排查思路:先用 curl 直接调 MCP server 的接口,确认工具本身能跑通,再让 LLM 调。如果 curl 通但 LLM 不通,多半是 schema 描述问题。MCP 工具的 input schema 要写得足够清晰,参数类型、必填项、示例值都写上。LLM 对 schema 的遵循程度跟描述质量强相关。
提示:MCP server 的日志一定要挂出来。
docker logs -f mcp-playwright能看到每次调用的入参和返回,排查问题全靠它。
5.4 记忆写入的性能瓶颈
embedding 调用是同步阻塞的,如果每写一条记忆都等 embedding 返回,高并发下会堆积。我的解法是批量写入:后台协程每 500ms 或每积累 10 条,批量调 embedding 接口,一次拿回所有向量再批量插入。OpenAI 的 embedding 接口支持批量,一次最多 2048 条,够用。
另一个瓶颈是 Postgres 的写入。ivfflat 索引在插入时会更新,数据量大时插入变慢。如果写入吞吐要求高,可以先插到无索引的表,定时重建索引。或者用 HNSW 索引,插入性能更好,但内存占用高。这个取舍看你的场景。
5.5 记忆的隐私与安全边界
记忆层会存大量交互数据,里面可能有敏感信息。我的做法是写入前过一遍脱敏规则,把明显的密钥、密码、token 替换掉。另外记忆服务不要暴露公网,只在 Docker 内网访问。如果多租户共用一套记忆层,session_id 要带租户前缀,检索时强制过滤,防止跨租户串数据。
6. 记忆层的扩展方向与个人实践体会
这套东西跑稳定之后,我做了几个扩展,效果不错,分享出来供参考。
第一个扩展是记忆的“遗忘曲线”。不是所有记忆都值得永久保留。我给情景记忆加了个衰减因子,超过 30 天没被检索到的,权重降一半;超过 90 天的,归档到冷存储。这样热数据的检索速度不受冷数据拖累。实现上就是加个 last_accessed 字段,定时任务扫一遍更新权重。
第二个扩展是跨 Agent 的记忆共享。如果你有多个 Agent 干不同的事,它们的记忆其实可以互通。比如运维 Agent 积累的“服务慢排查经验”,对开发 Agent 也有用。做法是语义记忆表加个 domain 字段,检索时按 domain 过滤,但允许跨 domain 检索高置信度卡片。这个我还在小范围试,效果有待观察。
第三个扩展是记忆的可解释性。Agent 用了一条记忆做了决策,用户可能想知道“你为什么这么做”。我在返回结果时附带引用的记忆 id,前端可以点开看原始事件。这个对调试和建立信任很有帮助。
最后说个我踩过的最大的坑:一开始我追求“记住一切”,把所有交互都往记忆层塞,结果检索质量断崖式下跌。后来想明白了,记忆的价值不在于多,而在于准。现在我的写入策略是“宁缺毋滥”,只有包含明确目标、明确动作、明确结果的三元组才写入,闲聊和中间过程一律不存。记忆条数少了,但每条都有用,检索准确率反而上去了。
这个项目我还在持续迭代,下一步想试试用 LLM 自动做记忆的冲突检测——当新记忆和旧记忆矛盾时,自动标记出来让人确认。如果你也在搞 agent memory,欢迎交流踩坑经验。