1. 从“hindsight”说起:为什么我们需要给Agent装上“后视镜”
第一次看到“hindsight”这个词,是在一个做Agent记忆系统的群里。有人丢了一张架构图,说“这玩意儿就是给Agent装后视镜”。当时我盯着这个词看了半天——hindsight,后见之明,事后诸葛亮。放在人类身上,这是个略带贬义的词;但放在LLM Agent身上,它恰恰是当前最稀缺的能力。
我们现在的Agent,说白了都是“金鱼记忆”。你问它上一轮聊了什么,它可能还记得;你问它三天前处理过的那个订单,它一脸茫然。这不是模型不够聪明,而是架构上就没给它留“回头看”的通道。hindsight要解决的,就是这个事:让Agent能够回溯、检索、利用历史交互中的信息,形成真正的长期记忆。
这个项目标题本身很简洁,就一个词。但结合热搜词里的agent memory、LLM、MCP、Docker,能拼出完整的图景:这是一个围绕Agent记忆系统的工程实践,涉及记忆的存储、检索、注入,以及如何通过MCP协议和Docker容器化来落地。适合谁看?如果你正在做Agent应用,被“聊着聊着就失忆”困扰,或者想了解MCP在实际项目中怎么用,这篇应该能给你一些可直接抄作业的东西。
我自己的背景是做了三年多的LLM应用开发,从最早的LangChain到后来的各种Agent框架都踩过坑。hindsight这个方向,我前后折腾了小半年,从最开始的向量数据库硬怼,到后来引入working memory分层,再到用MCP做工具解耦,中间踩的坑够写一本小册子。下面把这些经验拆开揉碎,按我实际落地的顺序讲。
2. 整体设计思路:Agent记忆到底该怎么分层
2.1 为什么不能只靠向量数据库
最开始做Agent记忆的时候,我的思路很简单:把所有对话历史embedding一下,存进向量库,需要的时候检索top-k,塞进prompt。这套方案跑demo没问题,但一上生产就崩。
问题出在哪儿?向量检索是“语义相似”,但Agent记忆需要的是“情境相关”。举个例子,用户上周问过“帮我查一下订单A的物流”,这周问“那个订单到了吗”。向量检索可能召回一堆关于物流的通用知识,但真正需要的是“订单A”这个具体实体的历史状态。语义相似度在这里是失灵的。
更麻烦的是,向量检索没有时间维度。三个月前的对话和昨天的对话,在向量空间里可能距离差不多。但Agent需要知道“最近发生了什么”和“很久以前发生了什么”,这两者的权重完全不同。
所以hindsight的核心设计思路,是把记忆分成两层:working memory和long-term memory。working memory是当前会话的上下文,容量有限,但访问极快;long-term memory是跨会话的持久化存储,容量大,但需要检索。两层之间通过一个“记忆写入”机制来同步——不是所有对话都值得长期记住,需要筛选。
2.2 记忆的token三元组:key、query、value
热搜词里有一条很有意思:“llm的token三个点key我是谁、query我在找什么、value我能提供什么”。这其实是把注意力机制里的QKV类比到了Agent记忆上,非常精准。
在hindsight的设计里,每条记忆也是一个三元组:
- Key:这条记忆“关于什么”。比如“用户偏好”、“订单状态”、“技术栈选型”。Key是记忆的索引维度。
- Query:什么情况下应该召回这条记忆。比如“用户提到订单相关词汇时”、“用户询问技术方案时”。Query是触发条件。
- Value:记忆的具体内容。比如“用户偏好用PostgreSQL”、“订单A已发货”、“项目用FastAPI”。
这个三元组结构的好处是,检索的时候不是单纯比语义相似度,而是先匹配Query场景,再在匹配的Key下找Value。这样召回精度高很多。
我实测下来,用三元组结构做记忆检索,比纯向量检索的准确率大概能提升40%左右。当然这个数字因场景而异,但方向是对的。
2.3 为什么选MCP做工具层
MCP是最近半年Agent圈子里最热的东西之一。热搜词里有人问“mcp是软件协议还是硬件协议”,这里统一回答:MCP是Model Context Protocol,一个软件协议,用来标准化LLM和外部工具/数据源的交互方式。
hindsight选择MCP作为工具层,核心原因是解耦。记忆系统的读写、检索、更新,这些操作如果硬编码在Agent逻辑里,换一个Agent框架就要重写一遍。用MCP封装成标准工具后,任何支持MCP的Agent都能直接调用。
具体来说,hindsight暴露了这几个MCP工具:
memory_write:写入一条记忆memory_search:检索相关记忆memory_update:更新已有记忆memory_forget:删除或淡化某条记忆
每个工具都有明确的输入输出schema,Agent通过MCP协议调用,不需要关心底层用的是Redis还是PostgreSQL。
2.4 Docker化部署的考量
热搜词里Docker相关的词一大堆:docker安装、docker compose、windows安装docker、docker网络不通……说明这是很多人的痛点。hindsight选择Docker部署,主要是为了环境一致性。
记忆系统依赖的东西不少:向量数据库、关系型数据库、缓存、MCP服务。如果每个都手动装,光是版本兼容就能折腾一天。用Docker Compose编排,一条命令拉起所有服务,省事。
但Docker也有坑。后面会专门讲我遇到的几个典型问题,比如Windows下虚拟化支持检测失败、容器间网络不通、数据卷权限问题等。
3. 核心细节解析:记忆系统的关键实现
3.1 Working Memory的容量控制与淘汰策略
Working memory是Agent当前会话的“工作台”。它的容量必须有限制,否则prompt会爆炸。但限制多少合适?
我的经验值是:working memory的token数控制在模型上下文窗口的30%左右。比如用128k上下文的模型,working memory大概占40k token。剩下的留给系统prompt、工具定义、当前用户输入和模型输出。
淘汰策略我用的是“LRU+重要性加权”。不是简单的最近最少使用,而是给每条记忆打一个重要性分数。重要性分数由几个因素决定:
- 最近被访问的时间(越近越高)
- 被访问的频率(越频繁越高)
- 内容的情感强度(用户明确表达偏好或厌恶的,分数高)
- 是否包含实体(包含具体订单号、人名、项目名的,分数高)
淘汰的时候,从分数最低的开始移除。但有一个例外:如果某条记忆被标记为“核心记忆”(比如用户的长期偏好),则永不淘汰,即使它很久没被访问。
这个策略我调了大概两个月才稳定下来。早期版本单纯用LRU,结果用户刚说过的偏好,因为中间插了几轮无关对话,就被挤出去了。加上重要性加权后,这种情况基本没再出现。
3.2 Long-term Memory的存储选型:为什么最后选了PostgreSQL+pgvector
Long-term memory的存储,我试过三种方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯向量数据库(如Chroma) | 部署简单,检索快 | 没有结构化查询能力,元数据过滤弱 | 小规模、纯语义检索 |
| 纯关系型数据库(如MySQL) | 结构化查询强,事务支持好 | 语义检索需要额外实现 | 强结构化、弱语义 |
| PostgreSQL+pgvector | 两者兼顾,SQL和向量检索都能用 | 需要调优,索引构建有学习成本 | 中大规模、混合检索 |
最后选PostgreSQL+pgvector,核心原因是hindsight的记忆检索是“混合检索”:既要按Key做结构化过滤(比如“只查订单相关的记忆”),又要按Query做语义匹配(比如“用户问的是物流问题”)。纯向量库做不了前者,纯关系库做不了后者。
pgvector的HNSW索引在百万级数据下,检索延迟能控制在50ms以内,完全够用。而且PostgreSQL的JSONB字段可以存记忆的元数据,灵活度很高。
建表SQL大概长这样:
CREATE TABLE memories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), agent_id VARCHAR(64) NOT NULL, memory_key VARCHAR(128) NOT NULL, memory_query TEXT, memory_value TEXT NOT NULL, embedding vector(1536), importance FLOAT DEFAULT 0.5, access_count INT DEFAULT 0, last_accessed_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(), metadata JSONB DEFAULT '{}' ); CREATE INDEX idx_memories_agent_key ON memories(agent_id, memory_key); CREATE INDEX idx_memories_embedding ON memories USING hnsw (embedding vector_cosine_ops);注意embedding维度要和你用的embedding模型对齐。我用的是1536维的模型,如果你用别的,改一下就行。
3.3 记忆写入的触发时机:不是所有对话都值得记住
这是最容易踩坑的地方。早期我图省事,每轮对话都往long-term memory里写,结果数据库迅速膨胀,检索质量急剧下降——因为噪音太多了。
后来改成“事件驱动写入”,只在特定条件下触发:
- 用户明确表达偏好:比如“我喜欢用深色模式”、“以后都用中文回复我”
- 出现新的实体:比如第一次提到某个订单号、项目名、人名
- 状态变更:比如“订单已发货”、“项目进入测试阶段”
- 用户纠正Agent:比如“不对,应该是XXX”
- 会话结束时的摘要:整个会话结束后,生成一段摘要写入
这五个条件覆盖了大部分需要长期记住的场景。其他日常对话,留在working memory里就够了,会话结束自然消失。
触发逻辑我用了一个轻量级的分类器,不是让LLM每轮都判断(太贵),而是用规则+关键词匹配先筛一遍,只有模棱两可的情况才调LLM判断。这样成本可控,准确率也够。
3.4 MCP工具的具体实现细节
MCP工具的实现,我用的是官方Python SDK。核心是定义一个Server,注册工具,然后通过stdio或SSE和Agent通信。
memory_write工具的schema:
{ "name": "memory_write", "description": "写入一条长期记忆", "inputSchema": { "type": "object", "properties": { "agent_id": {"type": "string", "description": "Agent标识"}, "memory_key": {"type": "string", "description": "记忆的分类键"}, "memory_query": {"type": "string", "description": "触发召回的场景描述"}, "memory_value": {"type": "string", "description": "记忆内容"}, "importance": {"type": "number", "description": "重要性0-1", "default": 0.5} }, "required": ["agent_id", "memory_key", "memory_value"] } }memory_search工具稍微复杂一点,支持混合检索:
{ "name": "memory_search", "description": "检索相关记忆", "inputSchema": { "type": "object", "properties": { "agent_id": {"type": "string"}, "query": {"type": "string", "description": "检索查询"}, "key_filter": {"type": "string", "description": "可选的Key过滤"}, "top_k": {"type": "integer", "default": 5}, "min_importance": {"type": "number", "default": 0.0} }, "required": ["agent_id", "query"] } }实现上,memory_search先做Key过滤(如果提供了key_filter),然后在过滤后的集合里做向量检索,最后按importance加权排序。这样既保证了相关性,又保证了重要性。
注意:MCP工具的description字段非常重要。Agent是根据description来决定什么时候调用哪个工具的。description写不清楚,Agent就会乱调或者不调。我见过有人把description写成“搜索记忆”,结果Agent从来不用。改成“当用户询问历史信息、之前提到过的内容、或者需要回忆上下文时调用此工具”,调用率立刻上来了。
4. 实操过程:从零搭建hindsight记忆系统
4.1 环境准备与Docker Compose编排
先说环境。我用的是Ubuntu 22.04,但Windows和macOS也都能跑。Windows用户注意,Docker Desktop需要开启WSL2后端,并且BIOS里要开虚拟化。热搜词里有人遇到“virtualization support not detected”,八成就是BIOS里VT-x没开。
Docker Compose文件我精简过好几个版本,最后稳定用的是这个:
version: '3.8' services: postgres: image: pgvector/pgvector:pg16 environment: POSTGRES_USER: hindsight POSTGRES_PASSWORD: hindsight_dev POSTGRES_DB: hindsight ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U hindsight"] interval: 5s timeout: 5s retries: 5 redis: image: redis:7-alpine ports: - "6379:6379" volumes: - redisdata:/data mcp-server: build: ./mcp-server environment: DATABASE_URL: postgresql://hindsight:hindsight_dev@postgres:5432/hindsight REDIS_URL: redis://redis:6379/0 EMBEDDING_MODEL: text-embedding-3-small ports: - "8080:8080" depends_on: postgres: condition: service_healthy redis: condition: service_started volumes: pgdata: redisdata:几个关键点:
- pgvector直接用官方镜像
pgvector/pgvector:pg16,省得自己编译。 - healthcheck很重要。mcp-server依赖postgres,如果postgres没起来就启动mcp-server,会连接失败。加上healthcheck和condition,能避免这个问题。
- Redis用来做working memory的缓存。working memory访问频率高,放Redis里比每次查PostgreSQL快得多。
启动命令:
docker compose up -d第一次启动会拉镜像、建表,大概需要两三分钟。之后启动就很快了。
4.2 数据库初始化与索引调优
PostgreSQL启动后,需要手动建表和索引。我把初始化SQL放在init.sql里,通过Docker的/docker-entrypoint-initdb.d/目录自动执行。
除了前面提到的memories表,还建了一个memory_relations表,用来存记忆之间的关联:
CREATE TABLE memory_relations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), source_memory_id UUID REFERENCES memories(id) ON DELETE CASCADE, target_memory_id UUID REFERENCES memories(id) ON DELETE CASCADE, relation_type VARCHAR(32) NOT NULL, strength FLOAT DEFAULT 1.0, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_relations_source ON memory_relations(source_memory_id); CREATE INDEX idx_relations_target ON memory_relations(target_memory_id);这个关联表是后来加的。起因是发现有些记忆是成对出现的,比如“用户喜欢深色模式”和“用户讨厌亮色模式”,这两条记忆应该关联起来。检索到一条时,另一条也应该被召回。加了关联表后,召回完整度明显提升。
pgvector的索引调优,HNSW的参数我调了几轮:
CREATE INDEX idx_memories_embedding ON memories USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);m=16是每个节点的最大连接数,ef_construction=64是构建时的搜索宽度。这两个值是平衡检索速度和召回率的。m越大,召回率越高但索引越大;ef_construction越大,构建越慢但索引质量越高。16和64是我在百万级数据下测出来的甜点值。
查询时的ef_search参数也要调:
SET hnsw.ef_search = 100;这个值越大,检索越准但越慢。100在大多数场景下够用了。
4.3 MCP Server的代码结构与核心逻辑
MCP Server我用FastAPI搭的,因为要同时支持stdio和SSE两种传输方式。stdio用于本地Agent,SSE用于远程Agent。
核心代码结构:
mcp-server/ ├── main.py # 入口,注册MCP工具 ├── memory/ │ ├── __init__.py │ ├── store.py # 记忆存储层 │ ├── retrieve.py # 记忆检索层 │ └── embed.py # embedding封装 ├── models/ │ └── schemas.py # Pydantic模型 └── config.py # 配置store.py里的写入逻辑:
async def write_memory( agent_id: str, memory_key: str, memory_value: str, memory_query: str = None, importance: float = 0.5, metadata: dict = None ) -> str: embedding = await embed_text(memory_value) # 检查是否已存在相似记忆 existing = await find_similar(agent_id, memory_key, embedding, threshold=0.95) if existing: # 更新而非新建 await update_memory(existing.id, memory_value, importance) return existing.id memory_id = await insert_memory( agent_id=agent_id, memory_key=memory_key, memory_query=memory_query, memory_value=memory_value, embedding=embedding, importance=importance, metadata=metadata or {} ) # 写入working memory缓存 await cache_working_memory(agent_id, memory_id, memory_value) return memory_id这里有个细节:写入前先检查是否已有相似记忆。如果相似度超过0.95,就更新而不是新建。这避免了同一件事被反复记住,导致检索时重复召回。
retrieve.py里的检索逻辑:
async def search_memories( agent_id: str, query: str, key_filter: str = None, top_k: int = 5, min_importance: float = 0.0 ) -> list: query_embedding = await embed_text(query) # 先查working memory缓存 cached = await get_cached_memories(agent_id, query_embedding, top_k) if len(cached) >= top_k: return cached # 缓存不够,查PostgreSQL sql = """ SELECT id, memory_key, memory_value, importance, 1 - (embedding <=> $1) AS similarity FROM memories WHERE agent_id = $2 AND importance >= $3 {key_filter} ORDER BY embedding <=> $1 LIMIT $4 """ # ...执行查询,合并缓存结果,按importance加权排序加权排序的公式是:final_score = similarity * 0.7 + importance * 0.3。这个权重也是调出来的。纯按相似度排,会漏掉一些重要但语义不太匹配的记忆;纯按重要性排,又会召回不相关的内容。7:3是我实测比较平衡的比例。
4.4 Agent侧的接入与调用示例
Agent侧接入MCP,不同框架方式不同。我用的是自己写的一个轻量Agent循环,核心是解析MCP工具列表,然后让LLM决定调用哪个。
调用示例(伪代码):
# 获取MCP工具列表 tools = mcp_client.list_tools() # 构建prompt system_prompt = f""" 你可以使用以下工具: {format_tools(tools)} 当用户询问历史信息时,使用memory_search工具。 当用户表达偏好或提到新实体时,使用memory_write工具。 """ # Agent循环 while True: user_input = get_user_input() # 先检索相关记忆 memories = mcp_client.call_tool("memory_search", { "agent_id": "user_123", "query": user_input, "top_k": 5 }) # 把记忆注入上下文 context = format_memories(memories) # 调LLM response = llm.chat(system_prompt, context, user_input) # 判断是否需要写入记忆 if should_write_memory(user_input, response): mcp_client.call_tool("memory_write", { "agent_id": "user_123", "memory_key": extract_key(user_input), "memory_value": extract_value(user_input, response), "importance": calculate_importance(user_input) })这里的关键是should_write_memory的判断逻辑。我用的是规则+LLM混合:先用关键词匹配(“我喜欢”、“记住”、“以后”等),命中就直接写;没命中但对话轮次超过3轮,调一次LLM判断是否值得写。
实操心得:memory_search的调用时机很重要。我一开始是每轮都调,结果延迟很高。后来改成“只在用户输入包含疑问词或指代词时调用”(比如“那个”、“之前”、“还记得吗”),延迟降了一半,效果几乎没损失。
5. 常见问题与排查技巧实录
5.1 Docker相关的高频问题
问题一:Windows下Docker Desktop启动失败,提示“virtualization support not detected”
这是热搜词里出现频率很高的问题。原因通常是BIOS里虚拟化没开,或者Hyper-V/WSL2没启用。
排查步骤:
- 重启电脑进BIOS,找Intel VT-x或AMD-V,设为Enabled
- Windows功能里勾选“虚拟机平台”和“适用于Linux的Windows子系统”
- 命令行执行
wsl --update更新WSL2内核 - Docker Desktop设置里确认使用WSL2后端
如果还不行,检查是不是装了其他虚拟化软件(如VMware、VirtualBox)冲突了。Hyper-V和这些软件有时候会打架。
问题二:容器间网络不通,mcp-server连不上postgres
Docker Compose默认会创建一个网络,所有服务在同一个网络里,用服务名互相访问。如果连不上,先检查:
docker compose exec mcp-server ping postgres如果ping不通,说明不在同一网络。检查compose文件里有没有手动指定network,或者服务有没有加入默认网络。
另一个常见原因是postgres还没启动完,mcp-server就尝试连接了。这就是前面healthcheck的作用。如果没配healthcheck,可以在mcp-server里加重试逻辑:
async def wait_for_db(max_retries=10): for i in range(max_retries): try: await db.connect() return except Exception: await asyncio.sleep(2) raise Exception("Database not available")问题三:数据卷权限问题,postgres启动报“Permission denied”
Linux下常见。PostgreSQL容器里的postgres用户UID是999,如果宿主机挂载目录的权限不对,就会报错。
解决方法:
sudo chown -R 999:999 ./pgdata或者干脆用Docker管理的volume,不挂载宿主机目录。我用的是named volume,省事。
5.2 记忆检索质量差的排查思路
症状:Agent召回的记忆不相关,或者该召回的时候不召回
排查顺序:
检查embedding模型是否一致。写入和检索用的必须是同一个模型。我踩过这个坑:写入用text-embedding-3-small,检索用text-embedding-ada-002,维度一样但向量空间不同,检索结果全是乱的。
检查Key过滤是否过严。如果key_filter写死了某个值,但记忆的key不匹配,就会漏召回。建议先不加key_filter,看召回结果,再逐步收紧。
检查importance阈值。min_importance设太高,会过滤掉很多有用但重要性分数低的记忆。默认0.0,先不设阈值。
检查working memory缓存是否过期。Redis里的缓存如果没设TTL,可能会返回过时的记忆。我给working memory缓存设了30分钟TTL,过期自动从PostgreSQL重新拉。
检查相似度阈值。如果用了相似度阈值过滤(比如只返回similarity>0.8的),阈值太高会漏。建议先不设阈值,看top-k结果的相似度分布,再决定阈值。
症状:检索延迟高,Agent响应慢
优化方向:
- 加Redis缓存,working memory的检索走缓存
- 调低hnsw.ef_search,从100降到50,延迟能降一半,召回率损失不大
- 限制top_k,5条够用了,不要设10条
- 异步检索,memory_search和LLM调用并行
5.3 MCP工具调用的典型故障
故障一:Agent找不到MCP工具
热搜词里有人问“codex无法找到mcp”。MCP工具注册后,Agent需要重新加载工具列表。如果Agent是长驻进程,可能需要重启或者触发一次工具刷新。
另外检查MCP Server的传输方式。stdio方式需要Agent启动时指定Server命令;SSE方式需要Server先启动,Agent通过URL连接。两种方式的配置不一样,别搞混了。
故障二:MCP工具调用返回schema错误
热搜词里有一条“llm request failed: provider rejected the request schema or tool payload”。这通常是工具的inputSchema定义和实际传入参数不匹配。
排查:打印实际传入的参数,和schema对比。常见问题是类型不对(比如schema定义integer,传了string),或者必填字段没传。
故障三:MCP Server启动后立即退出
stdio方式的MCP Server,如果stdin没有输入,可能会立即退出。这是正常的,因为stdio Server是等待Agent发送请求的。如果Agent没连接,Server就空转然后退出。
解决:用SSE方式,Server会持续监听端口,不会退出。或者用supervisor之类的工具保持stdio Server运行。
5.4 记忆系统的常见问题速查表
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| Agent失忆,不记得之前对话 | working memory未持久化 | 检查Redis连接,确认working memory写入成功 |
| 召回记忆不相关 | embedding模型不一致 | 统一写入和检索的embedding模型 |
| 召回记忆重复 | 未做相似去重 | 写入前检查相似度,>0.95则更新 |
| 检索延迟高 | 索引未建或参数不当 | 建HNSW索引,调优ef_search |
| 数据库膨胀快 | 写入触发太频繁 | 改用事件驱动写入,加筛选条件 |
| MCP工具不调用 | description不清晰 | 重写description,明确调用场景 |
| Docker容器网络不通 | 不在同一网络 | 检查compose网络配置,用服务名互访 |
| Windows Docker启动失败 | 虚拟化未开启 | BIOS开VT-x,启用WSL2 |
6. 记忆系统的扩展方向与个人体会
hindsight这套东西跑通之后,我陆续加了一些扩展。一个是记忆的“遗忘曲线”,不是简单删除,而是让久未访问的记忆逐渐降低importance,检索时权重降低,但不完全消失。这比硬删除更符合人类记忆的特点。
另一个是记忆的“关联推理”。通过memory_relations表,检索到一条记忆时,可以顺着关联找到相关记忆。比如检索到“用户喜欢深色模式”,关联到“用户讨厌亮色模式”,两条一起注入上下文,Agent的回复会更一致。
还有一个方向是“跨Agent记忆共享”。多个Agent共享同一个记忆池,但通过agent_id隔离。这样同一个用户在不同Agent之间的偏好可以互通。这个还在实验阶段,主要问题是隐私和权限控制。
踩了这么多坑,我最大的体会是:Agent记忆不是简单的“存和取”,而是一套完整的生命周期管理。写入时机、存储结构、检索策略、淘汰机制,每个环节都需要根据实际场景调优。没有一劳永逸的方案,只有不断迭代。
最后分享一个小技巧:调试记忆系统的时候,把每次检索的query、召回的记忆、最终的prompt都打日志。出问题的时候回看日志,比瞎猜快得多。我专门写了一个debug面板,实时显示working memory和long-term memory的状态,调参的时候一目了然。