1. 从"hindsight"说起:为什么Agent的记忆问题值得单独拎出来做
"hindsight"这个词本身很有意思,字面意思是"事后的洞察力",也就是我们常说的"后见之明"。把这个词用在Agent Memory(智能体记忆)这个方向上,其实点出了一个很核心的痛点:大部分基于LLM的Agent,在任务执行完之后,是没有"回头看"的能力的。它做完一件事,对话结束,上下文清空,下一次遇到类似场景,又是从零开始。
我最早接触Agent记忆这块,是因为一个很实际的问题:我搭了一个基于LLM的自动化助手,用来处理一些重复性的信息整理工作。跑单次任务的时候效果很好,但只要涉及"上次我们讨论到哪了""之前那个方案为什么被否掉了"这类需要跨会话回忆的场景,它就完全抓瞎。后来我意识到,这不是模型能力的问题,而是记忆架构的缺失——LLM本身是无状态的,它的"记忆"完全依赖于你喂给它的上下文窗口,而上下文窗口是有限的、易失的。
hindsight这个项目标题,我理解它的定位是:给LLM-based Agent补上一套可回溯、可检索、可复用的记忆层。它要解决的不是"模型聪不聪明",而是"模型记不记得住、能不能从过去的交互里提取经验"。这个方向最近热度很高,从热搜词里能看到agent memory、MCP、Docker、a-memguard这些关键词频繁出现,说明整个社区都在往"让Agent有长期记忆"这个方向使劲。
这篇文章适合谁看?如果你正在做LLM应用开发,尤其是涉及多轮对话、任务型Agent、知识库问答这类场景,那Agent记忆是你绕不开的一环。如果你只是刚接触LLM,想搞清楚"记忆"到底是怎么一回事,这篇也会从最基础的概念讲起。我会尽量把架构思路、实操步骤、踩坑经验都摊开讲,让你看完能自己动手搭一套最小可用的记忆系统。
2. Agent Memory的核心设计思路拆解
2.1 为什么LLM需要"外挂记忆"而不是靠上下文硬撑
先说一个很多人容易混淆的点:LLM的上下文窗口(context window)和"记忆"是两回事。上下文窗口是模型单次推理能看到的token上限,比如128K、200K,它确实能塞很多内容进去,但它是临时的、线性的、无结构的。你把一堆历史对话塞进去,模型能读到,但它不会自动区分哪些是重要的、哪些是过期的、哪些是互相矛盾的。
真正的记忆系统需要具备几个特征:持久化(关掉进程还在)、可检索(能按相关性捞出来)、可更新(新信息能覆盖旧信息)、有结构(不是一坨文本堆在那)。这就是为什么大家开始做Agent Memory——把记忆从"上下文里的一段文本"变成"一个独立管理的存储层"。
hindsight这个方向,本质上就是在做这层存储和检索的抽象。它要回答的问题是:Agent在什么时候该写入记忆、写入什么格式、下次怎么找到它、找到之后怎么用。
2.2 记忆的三种类型:working memory、episodic memory、semantic memory
在动手之前,得先把记忆的分类搞清楚,不然架构会乱。参考认知科学和目前主流的Agent记忆实践,一般分三类:
Working Memory(工作记忆):当前任务正在用的信息,生命周期最短,任务结束就丢。比如你现在让Agent订机票,它需要记住"出发地北京、目的地上海、日期下周三",这些信息在任务完成后就没用了。工作记忆通常直接放在上下文里,或者放在一个临时的session存储里。
Episodic Memory(情景记忆):记录"什么时候发生了什么"。比如"上周三用户让我查了A项目的进度,我返回了三个风险点"。这类记忆带时间戳,用于回溯和审计。
Semantic Memory(语义记忆):从多次交互中提炼出的稳定知识。比如"用户偏好用表格形式看数据""这个项目的负责人是张三"。这类记忆是去时间化的,是Agent对世界的"认知"。
hindsight如果要做完整,这三层都得覆盖。但实际落地时,我建议先从working memory和semantic memory入手,因为episodic memory的写入频率高、检索需求相对低,容易变成存储垃圾场。
2.3 为什么选MCP作为记忆的接入协议
热搜词里MCP出现频率极高,这里得解释一下。MCP(Model Context Protocol)是一个让LLM应用和外部工具/数据源对接的协议标准。它的核心价值是解耦:记忆系统作为一个独立的MCP Server,任何支持MCP的客户端(比如各种IDE、Agent框架)都能接进来用,不用为每个框架单独写适配。
这就像USB接口——以前每个设备一个专用口,现在统一成USB-C,谁都能插。MCP让记忆层变成了一个"可插拔的组件",这是它比"直接在代码里写个数据库调用"更优雅的地方。
用MCP做记忆接入,大致流程是:记忆服务暴露几个工具(比如write_memory、search_memory、update_memory),Agent在需要的时候调用这些工具。好处是记忆逻辑和Agent逻辑完全分离,你可以单独升级记忆系统而不动Agent代码。
2.4 Docker在这套架构里扮演什么角色
热搜词里Docker、Docker Desktop、docker安装这些词反复出现,说明很多人卡在环境这一步。记忆系统通常需要跑几个组件:向量数据库(存embedding)、关系数据库(存结构化记忆)、可能还有一个缓存层。这些组件用Docker跑是最省事的,因为依赖隔离、版本可控、迁移方便。
我自己的习惯是:所有记忆相关的服务都用docker-compose编排,一个文件拉起向量库+关系库+记忆服务本身。这样换机器的时候,docker compose up就完事了,不用重新配环境。后面实操部分我会给一个具体的compose配置。
3. 核心细节解析与实操要点
3.1 记忆的写入策略:什么时候该记,记什么
这是最容易做错的地方。很多人一上来就把所有对话都往记忆库里塞,结果检索的时候全是噪音。我的经验是:写入要有触发条件,不能无脑写。
常见的写入触发条件有这么几种:
- 显式指令:用户说"记住这个""以后都按这个来",直接写。
- 任务完成节点:一个任务结束时,把关键结论、决策、产出写进去。
- 信息密度阈值:当一轮对话里出现了新的实体、新的偏好、新的约束条件时写。
- 定期摘要:每隔N轮对话,让LLM做一次摘要,把摘要写入semantic memory。
写入的内容格式也很关键。我推荐用结构化的三元组+自然语言描述的混合格式。热搜词里有个说法很形象:"key是我谁、query我在找什么、value我能提供什么"。这其实就是把记忆拆成"主体-关系-客体"的结构。比如:
{ "key": "user_preference_format", "query": "用户喜欢什么格式的数据展示", "value": "表格形式,带对比列", "timestamp": "2025-01-15T10:30:00Z", "source": "session_20250115_001", "confidence": 0.85 }这种结构的好处是检索时可以用key做精确匹配,也可以用value做语义匹配,两条路都通。
3.2 记忆的检索:向量检索+关键词检索的混合方案
检索是记忆系统的核心。纯向量检索的问题是:它对"精确匹配"不敏感。比如你搜"张三的电话",向量检索可能返回一堆"联系人相关"的记忆,但不一定精确命中张三那条。纯关键词检索的问题是:它无法处理语义相似但用词不同的情况。
我的做法是混合检索:先用关键词/元数据过滤缩小范围,再用向量相似度排序。具体流程:
- 用户query进来,先做一次实体抽取,提取出关键实体(人名、项目名、时间等)。
- 用实体做元数据过滤,从记忆库里捞出候选集。
- 对候选集做向量相似度计算,排序取Top-K。
- 如果候选集为空,退化为纯向量检索。
这个方案实测下来召回率和准确率都比单一方案好很多。代价是需要维护两套索引,但用现成的向量库(比如Milvus、Qdrant)都支持payload过滤,实现起来不复杂。
3.3 记忆的更新与遗忘:别让记忆库变成垃圾场
记忆系统做久了,最大的问题不是"记不住",而是"记太多"。过期的、矛盾的、低价值的记忆如果不清理,检索质量会断崖式下降。
我的更新策略是:
- 同key覆盖:如果新记忆的key和旧记忆相同,且新记忆的confidence更高,直接覆盖。
- 矛盾检测:如果新记忆和旧记忆在语义上矛盾(比如"用户喜欢简洁"vs"用户喜欢详细"),标记为冲突,让LLM做一次裁决,或者保留时间更新的那条。
- TTL机制:给每类记忆设一个生存时间。working memory可能几小时就过期,episodic memory保留30天,semantic memory长期保留但定期做摘要压缩。
- 访问频率加权:被频繁检索到的记忆权重更高,长期不被访问的记忆降权甚至归档。
这里有个坑:不要用硬删除。记忆删了就找不回来了,万一后面发现删错了很麻烦。我一般用软删除,加一个deleted_at字段,检索时过滤掉,但数据还在,需要的时候能恢复。
3.4 用MCP封装记忆服务的具体做法
把记忆系统封装成MCP Server,需要定义几个工具。我一般会暴露这几个:
| 工具名 | 功能 | 输入参数 | 输出 |
|---|---|---|---|
memory_write | 写入一条记忆 | content, key, tags, ttl | memory_id |
memory_search | 检索记忆 | query, top_k, filters | 记忆列表 |
memory_update | 更新记忆 | memory_id, new_content | 状态 |
memory_forget | 软删除记忆 | memory_id | 状态 |
memory_summarize | 对一段记忆做摘要 | memory_ids | 摘要文本 |
MCP Server的实现可以用Python的mcp库,也可以用TypeScript的SDK。核心是把上面这些操作包装成MCP的tool调用。Agent侧只需要在system prompt里告诉模型"你有这些记忆工具可用",模型就会在合适的时候调用。
注意:MCP工具的description要写清楚,因为模型是靠description来决定什么时候调用的。description写得太模糊,模型要么不调用,要么乱调用。
4. 实操过程与核心环节实现
4.1 环境准备:用Docker Compose拉起记忆系统全家桶
先把环境搭起来。我假设你已经装了Docker Desktop(Windows/Mac)或者Docker Engine(Linux)。如果没装,去官网下对应平台的安装包,一路下一步就行。Windows上如果提示"Virtualization support not detected",去BIOS里把虚拟化打开。
下面是一个docker-compose.yml,包含向量库(Qdrant)、关系库(PostgreSQL)、缓存(Redis)和记忆服务本身:
version: '3.8' services: qdrant: image: qdrant/qdrant:latest ports: - "6333:6333" volumes: - ./data/qdrant:/qdrant/storage postgres: image: postgres:16 environment: POSTGRES_USER: memory POSTGRES_PASSWORD: memory_pass POSTGRES_DB: agent_memory ports: - "5432:5432" volumes: - ./data/postgres:/var/lib/postgresql/data redis: image: redis:7-alpine ports: - "6379:6379" memory-service: build: ./memory-service ports: - "8080:8080" environment: QDRANT_URL: http://qdrant:6333 POSTGRES_URL: postgresql://memory:memory_pass@postgres:5432/agent_memory REDIS_URL: redis://redis:6379 depends_on: - qdrant - postgres - redis启动命令就一句:
docker compose up -d等几十秒,四个服务都起来之后,docker compose ps能看到状态都是running。
提示:第一次拉镜像会比较慢,尤其是Qdrant和Postgres的镜像。如果网络环境不好,可以配置国内镜像源加速。另外数据卷挂载到本地目录,方便备份和迁移。
4.2 记忆服务的核心代码实现
记忆服务我用Python写,核心是三个模块:写入、检索、更新。先看写入:
import uuid from datetime import datetime, timedelta from qdrant_client import QdrantClient from qdrant_client.models import PointStruct, VectorParams, Distance class MemoryStore: def __init__(self, qdrant_url, collection_name="agent_memory"): self.client = QdrantClient(url=qdrant_url) self.collection = collection_name self._ensure_collection() def _ensure_collection(self): collections = self.client.get_collections().collections if self.collection not in [c.name for c in collections]: self.client.create_collection( collection_name=self.collection, vectors_config=VectorParams(size=768, distance=Distance.COSINE) ) def write(self, content, key=None, tags=None, ttl_hours=None): memory_id = str(uuid.uuid4()) embedding = self._embed(content) payload = { "content": content, "key": key, "tags": tags or [], "created_at": datetime.utcnow().isoformat(), "expires_at": (datetime.utcnow() + timedelta(hours=ttl_hours)).isoformat() if ttl_hours else None, "access_count": 0, "deleted": False } self.client.upsert( collection_name=self.collection, points=[PointStruct(id=memory_id, vector=embedding, payload=payload)] ) return memory_id_embed方法负责把文本转成向量,可以用OpenAI的embedding API,也可以用本地的sentence-transformers模型。本地模型的好处是不依赖外部服务,坏处是占内存。我一般用bge-base-zh这类中文效果好的模型。
检索部分:
def search(self, query, top_k=5, filters=None): query_vector = self._embed(query) results = self.client.search( collection_name=self.collection, query_vector=query_vector, limit=top_k, query_filter=self._build_filter(filters) ) # 过滤掉已删除和已过期的 valid = [] now = datetime.utcnow().isoformat() for r in results: p = r.payload if p.get("deleted"): continue if p.get("expires_at") and p["expires_at"] < now: continue valid.append({"id": r.id, "score": r.score, **p}) return valid_build_filter负责把tags、key这些元数据条件转成Qdrant的filter对象。这样就能实现前面说的"先过滤再排序"的混合检索。
4.3 把记忆服务接成MCP Server
MCP Server的代码结构大概是这样的:
from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent app = Server("agent-memory") store = MemoryStore(qdrant_url="http://localhost:6333") @app.list_tools() async def list_tools(): return [ Tool( name="memory_write", description="写入一条长期记忆。当用户表达了偏好、约束、重要事实时调用。", inputSchema={ "type": "object", "properties": { "content": {"type": "string", "description": "记忆内容"}, "key": {"type": "string", "description": "记忆的唯一标识,用于覆盖更新"}, "tags": {"type": "array", "items": {"type": "string"}}, "ttl_hours": {"type": "integer", "description": "过期时间,不填则永久"} }, "required": ["content"] } ), Tool( name="memory_search", description="检索相关记忆。在回答用户问题前,先检索是否有相关历史记忆。", inputSchema={ "type": "object", "properties": { "query": {"type": "string"}, "top_k": {"type": "integer", "default": 5} }, "required": ["query"] } ) ] @app.call_tool() async def call_tool(name, arguments): if name == "memory_write": mid = store.write(**arguments) return [TextContent(type="text", text=f"已写入记忆 {mid}")] elif name == "memory_search": results = store.search(**arguments) return [TextContent(type="text", text=format_results(results))]启动方式:
python -m memory_service.server然后在支持MCP的客户端里配置这个Server的启动命令,就能用了。实测下来,模型在收到"记住我喜欢用表格"这类指令时,会主动调用memory_write;在回答新问题前,会先调memory_search看看有没有相关记忆。
4.4 参数选择与性能调优
几个关键参数的选择经验:
向量维度:取决于你用的embedding模型。bge-base-zh是768维,text-embedding-3-small是1536维。维度越高精度越好但存储和计算成本越高。一般768维够用。
Top-K:检索返回几条。太小可能漏掉相关记忆,太大引入噪音。我一般设5-10,然后让LLM做二次筛选。
相似度阈值:低于某个分数的直接丢弃。我一般设0.6-0.7,具体看模型。太低会召回不相关的,太高会漏掉。
TTL:working memory设1-24小时,episodic设7-30天,semantic不设或设很长。
批量写入:如果一次要写多条记忆,用Qdrant的批量upsert,比逐条写快很多。
5. 常见问题与排查技巧实录
5.1 记忆检索不准的排查思路
这是最高频的问题。检索不准通常有三个原因:
embedding模型不适合你的语言/领域。如果你做的是中文场景,用英文为主的embedding模型效果会很差。换成bge系列或者m3e这类中文模型。
记忆内容太短或太碎。一条记忆只有"好的"两个字,embedding出来没有区分度。写入时要做内容规范化,把上下文补全。
没有做元数据过滤。纯向量检索在记忆量大时噪音很多。加上tags、时间范围这些过滤条件。
排查方法:把检索结果打出来,人工看Top-10里有多少是真正相关的。如果低于50%,说明检索策略有问题。
5.2 Docker环境常见报错
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
| Virtualization support not detected | BIOS虚拟化未开启 | 进BIOS开启VT-x/AMD-V |
| port is already allocated | 端口被占用 | 改compose里的端口映射,或杀掉占用进程 |
| no space left on device | 磁盘满 | 清理docker system prune,或迁移数据目录 |
| network not found | compose网络问题 | docker compose down后重新up |
| permission denied | 挂载目录权限 | Linux下chown对应目录,或加user配置 |
5.3 记忆冲突与幻觉的处理
Agent有时候会"记错",把不存在的事写进记忆。这种情况要靠写入前的校验:让LLM在写入前先确认"这条信息是用户明确说的,还是我推断的"。推断的内容标记低confidence,检索时降权。
记忆冲突的话,我一般保留时间更新的那条,但把旧的标记为superseded,不删除。这样万一新的是错的,还能回溯。
5.4 性能瓶颈与扩展
单机跑几千条记忆没问题,上万条之后检索延迟会上升。扩展方向:
- Qdrant开分片和副本
- 加Redis缓存热点记忆
- 把embedding计算异步化,写入时先落库再算向量
- 定期做记忆压缩,把多条相关记忆合并成一条摘要
提示:不要过早优化。大部分场景几千条记忆足够了,先把功能跑通,性能问题等真遇到了再解决。
6. 关于hindsight方向的一些个人判断
我做Agent记忆这块有一段时间了,最大的体会是:记忆系统的价值不在于"记得多",而在于"记得准"。一个只记100条但每条都精准的系统,比记10000条但一半是噪音的系统有用得多。
hindsight这个方向,我觉得接下来会往几个方向走:一是记忆的自动摘要和压缩会越来越重要,因为原始记忆的增长速度远超检索能力的提升;二是记忆的权限和隔离会成为一个刚需,多用户场景下不能互相看到对方的记忆;三是记忆的可解释性,用户得能知道Agent"为什么记得这个"。
如果你现在要动手做,我的建议是:先用最简单的方案跑起来——一个向量库加一个MCP Server,能写能查就行。别一上来就搞三层记忆、冲突检测、自动摘要,那些都是后面根据实际需求加的。我见过太多人卡在架构设计上,最后一行代码没写。
最后分享一个小技巧:在system prompt里明确告诉模型"你有记忆工具,在回答前先检索",比让它自己判断要不要检索要可靠得多。模型的自驱性没你想的那么强,该给的指令要给足。