ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

为LLM Agent构建持久记忆:基于MCP与Docker的分层架构实战

为LLM Agent构建持久记忆:基于MCP与Docker的分层架构实战 1. 从“hindsight”说起为什么我们需要给Agent装上记忆“hindsight”这个词本身很有意思字面意思是“事后的洞察力”也就是我们常说的“后见之明”。放在LLM Agent的语境里它指向一个非常具体且棘手的问题Agent如何记住过去发生过的事情并在后续决策中真正用上这些经验。我接触过不少做Agent项目的团队大家一开始都兴致勃勃地接上大模型、写好工具调用觉得智能体马上就能跑起来。但真正上线跑几天就会发现一个尴尬的现实——Agent像个失忆症患者每次对话都从零开始用户昨天纠正过的错误今天照犯不误上周已经确认过的偏好这周完全不知道。这不是模型能力不行而是记忆层缺失导致的系统性问题。“hindsight”这个项目标题我理解它的核心诉求就是为LLM Agent构建一套可持久化、可检索、可演进的记忆系统让Agent具备“回头看”的能力。结合热搜词里的agent memory、MCP、Docker这些关键词可以判断这是一个偏工程落地的方向——不是纯理论研究而是要能跑起来、能部署、能接入现有Agent框架的实战项目。这篇文章适合三类人看一是正在做Agent产品但被记忆问题卡住的工程师二是想理解Agent记忆架构设计思路的技术负责人三是对MCP协议和Docker部署有一定了解、想动手搭一套记忆系统的开发者。我会从设计思路、核心细节、实操部署、问题排查四个维度展开尽量把每个决策背后的“为什么”讲清楚。2. 记忆系统的整体设计与思路拆解2.1 为什么Agent记忆不能简单等同于“聊天历史”很多人第一反应是记忆不就是把对话历史存下来下次拼到prompt里吗这个方案在小规模场景下能跑但很快就会撞墙。第一个问题是上下文窗口的物理限制。就算模型支持128K甚至更长的上下文把几百轮对话全塞进去token成本会爆炸而且模型对长上下文中段信息的注意力会明显衰减——这是Transformer架构本身的特性不是换个模型就能解决的。第二个问题是信息密度。聊天历史里大量内容是寒暄、确认、重复表述真正有价值的决策依据、用户偏好、事实性知识可能只占5%。全量存储等于把噪声和信号混在一起检索效率极低。第三个问题是跨会话的持久性。用户今天聊完关掉窗口明天再打开如果记忆只存在内存里一切归零。这就需要持久化存储层。所以“hindsight”这类项目的核心设计思路一定是分层记忆架构把记忆拆成工作记忆working memory、短期记忆、长期记忆不同层用不同的存储和检索策略。热搜词里出现的“agent 存储 working memory”正好印证了这个方向。2.2 三层记忆架构的设计逻辑我倾向于把Agent记忆分成三层来设计这个划分方式在工程上最好落地工作记忆层负责当前会话内的即时上下文生命周期就是一次会话。它存储的是最近几轮对话、当前任务状态、临时变量。实现上可以用内存队列或者Redis读写要快不需要复杂检索。短期记忆层负责跨会话但时间窗口较近的记忆比如最近7天或30天的交互摘要。这一层需要做摘要压缩——把原始对话用LLM提炼成结构化的事实和偏好。存储可以用关系型数据库检索以时间范围查询为主。长期记忆层负责持久化的知识沉淀包括用户画像、领域知识、历史决策模式。这一层需要向量化存储支持语义检索。通常用向量数据库如Milvus、Qdrant、Chroma配合元数据过滤。三层之间的流转关系是工作记忆在会话结束时经过摘要和抽取写入短期记忆短期记忆定期归档、聚类、抽象沉淀到长期记忆检索时从长期记忆召回相关条目注入工作记忆作为上下文。注意不要一上来就搞三层很多项目其实两层就够了。过度设计会让调试成本急剧上升。我的建议是先用“工作记忆长期记忆”两层跑通闭环等数据量上来再拆出短期层。2.3 MCP协议在记忆系统中的角色定位热搜词里MCP出现频率很高这里需要说清楚它在架构中的位置。MCPModel Context Protocol本质上是一套标准化的工具调用协议它让LLM能够以统一的方式访问外部资源和服务。在“hindsight”这类记忆系统里MCP的价值在于解耦。记忆的存储、检索、更新这些操作如果直接写死在Agent代码里换一个Agent框架就要重写一遍。但如果把记忆能力封装成MCP Server那么任何支持MCP的客户端不管是Claude Desktop、还是自研Agent都能通过标准协议调用记忆服务。具体来说记忆MCP Server会暴露几个核心工具store_memory用于写入记忆retrieve_memory用于语义检索update_memory用于修正过时信息forget_memory用于删除敏感数据。Agent在对话过程中根据需要调用这些工具就像调用其他任何MCP工具一样自然。这个设计的好处是可替换性。今天用向量数据库做后端明天想换成图数据库只要MCP接口不变Agent侧完全无感。这也是为什么热搜词里会出现“ruoyi-vue-pro合并mcp功能”这类内容——大家都在往标准化协议上靠。2.4 Docker化部署的考量热搜词里Docker相关内容非常多从“docker安装教程”到“docker网络不通”都有。这说明目标读者大概率是要自己部署这套系统的。记忆系统涉及多个组件向量数据库、关系型数据库、MCP Server、可能还有Redis做缓存。如果每个都手动装环境依赖能把人逼疯。Docker Compose编排是唯一合理的选择——一个docker-compose.yml把依赖关系、网络配置、数据卷全部定义清楚换台机器docker compose up就能跑起来。而且记忆系统对数据持久化要求很高Docker的volume机制正好解决这个问题。容器可以随便重建数据卷挂载在宿主机上记忆不会丢。这一点在实操部分我会详细展开。3. 核心细节解析与实操要点3.1 记忆条目的数据结构设计记忆系统好不好用很大程度上取决于存什么和怎么存。我见过太多项目把原始对话直接扔进向量库结果检索出来的全是无关的寒暄内容。一个设计良好的记忆条目至少应该包含以下字段字段名类型说明idstring唯一标识建议用UUIDcontentstring记忆的文本内容经过摘要提炼embeddingvector内容的向量表示用于语义检索memory_typeenum类型fact/preference/decision/contextsource_sessionstring来源会话ID便于追溯created_attimestamp创建时间updated_attimestamp最后更新时间importancefloat重要性评分0-1之间access_countint被检索次数用于热度衰减metadatajson扩展字段存领域特定信息这里重点说几个设计决策的理由。memory_type分类是为了检索时能做类型过滤。用户问“我之前说过喜欢什么颜色”这是preference类型用户问“上次那个方案最后怎么定的”这是decision类型。不同类型走不同的检索策略精度会高很多。importance评分解决的是记忆淘汰问题。记忆不能无限增长需要有个机制决定哪些该保留、哪些该归档。重要性可以综合几个因素计算内容长度、是否包含明确的事实陈述、用户是否显式强调、被检索频率等。我通常用一个简单的加权公式importance 0.3 * recency_score 0.4 * access_frequency 0.3 * content_richness其中recency_score按时间衰减access_frequency是归一化的访问次数content_richness可以用内容长度和实体密度来估算。access_count配合时间衰减可以实现类似“遗忘曲线”的效果。很久没被访问的记忆检索权重自动降低但不会删除——万一哪天又需要呢。3.2 记忆写入的触发时机什么时候往记忆系统里写数据这个决策比数据结构更关键。写太频繁噪声多写太少关键信息丢失。我的实践经验是设置多触发点第一个触发点是会话结束。当用户关闭对话或超时无交互触发一次全量摘要把本次会话的关键信息抽取出来写入短期记忆。这个操作可以异步做不阻塞用户。第二个触发点是显式指令。用户在对话中说了“记住这个”、“以后都按这个来”Agent应该立即调用store_memory工具。这类记忆importance直接给高分。第三个触发点是关键事件检测。比如用户纠正了Agent的错误、确认了一个重要决策、提供了个人偏好信息这些都应该实时写入。实现上可以用一个轻量的分类模型或者规则引擎来检测。实操心得我一开始只在会话结束时写入结果发现如果会话中途崩溃整段记忆就丢了。后来改成“关键事件实时写会话结束全量补”可靠性提升明显。另外写入操作一定要做幂等同一个事件重复触发不能产生重复记忆。3.3 语义检索的召回策略检索是记忆系统的“出口”召回质量直接决定Agent表现。单纯用向量相似度检索有几个坑坑一语义相似但事实无关。用户问“我上次说的那个餐厅”向量检索可能召回一堆提到“餐厅”的记忆但未必是用户真正指的那次。解决办法是混合检索——向量相似度关键词匹配时间范围过滤三者加权。坑二多跳推理需求。用户问“我之前推荐给你的那本书的作者还写过什么”这需要先检索到“书”再检索“作者”再检索“其他作品”。单次向量检索搞不定。解决办法是迭代检索Agent根据第一次检索结果构造第二次查询。坑三时效性冲突。用户三个月前说喜欢A上周说喜欢B检索时应该优先返回B。解决办法是在检索评分里加入时间衰减因子同时用updated_at字段做冲突检测。我常用的检索评分公式final_score 0.5 * vector_similarity 0.2 * keyword_match 0.2 * time_decay 0.1 * importance这个权重不是固定的可以根据场景调。比如做客服Agent时效性权重应该更高做知识助手importance权重可以加大。3.4 MCP Server的工具定义把记忆能力封装成MCP Server核心是定义好工具接口。以下是我建议的工具集{ tools: [ { name: store_memory, description: 存储一条新记忆, parameters: { content: string, 记忆内容, memory_type: string, 类型: fact/preference/decision/context, importance: float, 重要性 0-1, metadata: object, 扩展信息 } }, { name: retrieve_memory, description: 语义检索相关记忆, parameters: { query: string, 检索查询, top_k: int, 返回条数, 默认5, memory_type: string, 可选类型过滤, time_range: string, 可选时间范围 } }, { name: update_memory, description: 更新已有记忆, parameters: { memory_id: string, 记忆ID, content: string, 新内容, reason: string, 更新原因 } }, { name: forget_memory, description: 删除记忆, parameters: { memory_id: string, 记忆ID, reason: string, 删除原因 } } ] }工具描述要写得足够清晰因为LLM是根据description来决定何时调用的。store_memory的描述里最好加上“当用户提供个人信息、确认决策、纠正错误时调用”给模型明确的触发信号。4. 实操过程与核心环节实现4.1 环境准备与Docker Compose编排假设你在Ubuntu 22.04或者Windows with WSL2上操作先确认Docker和Docker Compose已安装。Windows用户如果遇到“virtualization support not detected”的报错需要进BIOS开启虚拟化支持然后在Windows功能里启用WSL2和虚拟机平台。整个记忆系统的组件清单Qdrant向量数据库存embedding和元数据PostgreSQL关系型存储存记忆条目结构化字段Redis缓存层存工作记忆和热点数据memory-mcp-server自研的MCP Server连接上述组件embedding-service文本向量化服务可以用本地模型或APIdocker-compose.yml的核心配置version: 3.8 services: qdrant: image: qdrant/qdrant:latest ports: - 6333:6333 volumes: - qdrant_data:/qdrant/storage restart: unless-stopped postgres: image: postgres:16 environment: POSTGRES_USER: memory POSTGRES_PASSWORD: memory_pass_2024 POSTGRES_DB: agent_memory ports: - 5432:5432 volumes: - pg_data:/var/lib/postgresql/data restart: unless-stopped redis: image: redis:7-alpine ports: - 6379:6379 volumes: - redis_data:/data restart: unless-stopped memory-mcp: build: ./mcp-server ports: - 8080:8080 environment: QDRANT_URL: http://qdrant:6333 POSTGRES_URL: postgresql://memory:memory_pass_2024postgres:5432/agent_memory REDIS_URL: redis://redis:6379 depends_on: - qdrant - postgres - redis restart: unless-stopped volumes: qdrant_data: pg_data: redis_data:这里有几个关键决策要解释。为什么用Qdrant而不是ChromaChroma适合原型验证但生产环境下的并发性能和过滤能力不如Qdrant。Qdrant支持payload索引做元数据过滤时效率高很多。而且Qdrant的Docker镜像很干净没有乱七八糟的依赖。为什么还要PostgreSQL向量库不擅长做结构化查询和事务。记忆的元数据、访问计数、更新历史这些用关系型数据库更合适。而且PostgreSQL的JSONB字段可以灵活存扩展信息。网络配置Compose默认创建一个内部网络服务之间用服务名互相访问。memory-mcp里配置的QDRANT_URL用的是http://qdrant:6333而不是localhost这是Docker网络的基本规则。很多人第一次用Docker会在这里踩坑容器里的localhost指向容器自己不是宿主机。4.2 记忆写入的完整流程实现写入流程从Agent调用store_memory工具开始到数据落库结束。完整链路如下第一步MCP Server收到写入请求先做参数校验。content不能为空memory_type必须在枚举范围内importance做clamp到0-1。第二步调用embedding服务把content转成向量。这里有个细节embedding模型的选择要和检索时保持一致否则向量空间不对齐检索效果会很差。我一般用text-embedding-3-small或者本地的bge-m3。第三步做去重检测。在Qdrant里用新向量做一次相似度搜索如果存在相似度超过0.95的已有记忆就不新增而是走更新逻辑。这一步能有效防止重复记忆堆积。第四步写入PostgreSQL拿到自增ID或者UUID。然后把这个ID作为Qdrant的point ID把向量和payload写入Qdrant。两个存储的ID要一致方便关联查询。第五步更新Redis里的工作记忆缓存。如果是当前会话相关的记忆直接push到会话的working memory列表里。async def store_memory(content, memory_type, importance, metadata): # 1. 参数校验 if not content or len(content.strip()) 0: raise ValueError(content cannot be empty) # 2. 向量化 embedding await embedding_service.encode(content) # 3. 去重检测 similar await qdrant.search( collection_namememories, query_vectorembedding, limit1, score_threshold0.95 ) if similar: return await update_memory(similar[0].id, content, auto-dedup) # 4. 写入PostgreSQL memory_id str(uuid.uuid4()) await pg.execute( INSERT INTO memories (id, content, memory_type, importance, metadata, created_at) VALUES ($1, $2, $3, $4, $5, NOW()), memory_id, content, memory_type, importance, json.dumps(metadata) ) # 5. 写入Qdrant await qdrant.upsert( collection_namememories, points[{ id: memory_id, vector: embedding, payload: { content: content, memory_type: memory_type, importance: importance, created_at: time.time() } }] ) # 6. 更新缓存 await redis.lpush(fworking_memory:{metadata.get(session_id)}, memory_id) return {memory_id: memory_id, status: stored}注意去重阈值0.95是个经验值。设太高会漏掉重复设太低会误合并不同记忆。如果你的记忆内容普遍较短可以降到0.9如果内容较长且细节丰富可以提到0.97。4.3 检索流程与上下文注入检索流程比写入复杂因为要处理查询改写、多路召回、重排序。Agent发起检索时通常带着一个自然语言query。第一步是查询改写——把口语化的query转成更适合检索的形式。比如用户问“我上次说的那个事儿”改写后可能是“用户之前提到的待办事项或决策”。这一步可以用小模型做也可以用规则模板。第二步是多路召回。同时走三条路向量检索Qdrant、关键词检索PostgreSQL的全文索引、时间范围检索最近N条。三路各取top 10合并去重。第三步是重排序。用一个cross-encoder模型对候选记忆做精排或者用前面提到的加权公式算分。重排序后取top 5注入上下文。第四步是上下文组装。把检索到的记忆按类型分组fact类放在“已知事实”区块preference类放在“用户偏好”区块decision类放在“历史决策”区块。这样LLM读起来结构清晰利用率更高。async def retrieve_memory(query, top_k5, memory_typeNone, time_rangeNone): # 1. 查询改写 rewritten await rewrite_query(query) # 2. 多路召回 query_vector await embedding_service.encode(rewritten) vector_results await qdrant.search( collection_namememories, query_vectorquery_vector, limit10, query_filterbuild_filter(memory_type, time_range) ) keyword_results await pg.fetch( SELECT id, content, memory_type, importance, created_at FROM memories WHERE content ILIKE $1 ORDER BY created_at DESC LIMIT 10, f%{extract_keywords(rewritten)}% ) # 3. 合并去重 candidates merge_and_dedup(vector_results, keyword_results) # 4. 重排序 scored [] for c in candidates: score (0.5 * c.vector_score 0.2 * c.keyword_score 0.2 * time_decay(c.created_at) 0.1 * c.importance) scored.append((score, c)) scored.sort(reverseTrue, keylambda x: x[0]) # 5. 更新访问计数 top_results [c for _, c in scored[:top_k]] for r in top_results: await pg.execute( UPDATE memories SET access_count access_count 1 WHERE id $1, r.id ) return top_results4.4 与Agent框架的对接MCP Server跑起来后需要在Agent侧配置连接。以Claude Desktop为例配置文件里加一段{ mcpServers: { hindsight-memory: { url: http://localhost:8080/sse, transport: sse } } }如果是自研Agent用MCP客户端库连接即可。关键是在System Prompt里告诉模型有这个能力。我通常会在prompt里加一段你可以使用hindsight-memory工具来记住重要信息和检索历史记忆。当用户提供个人信息、确认决策、纠正你的错误时主动调用store_memory。在回答需要历史上下文的问题前先调用retrieve_memory。这段提示词看起来简单但效果差异很大。不写的话模型经常忘记调用工具写得太啰嗦又会占用过多上下文。5. 常见问题与排查技巧实录5.1 Docker部署阶段的典型报错问题一容器间网络不通。表现是memory-mcp容器日志里报“connection refused”连不上qdrant。排查步骤先docker compose ps确认所有容器都是Up状态然后docker compose exec memory-mcp ping qdrant测试网络连通性如果ping不通检查compose文件里是否在同一个network下。最常见的原因是手动指定了network_mode: host导致服务名解析失效。问题二数据卷权限问题。PostgreSQL容器启动失败日志报“permission denied”。这是因为宿主机挂载目录的属主和容器内postgres用户的UID不匹配。解决办法是在宿主机上chown -R 999:999 ./pg_data999是postgres镜像里postgres用户的UID。问题三Windows下Docker Desktop启动失败。报“virtualization support not detected”。需要进BIOS开启Intel VT-x或AMD-V然后在Windows“启用或关闭Windows功能”里勾选“虚拟机平台”和“适用于Linux的Windows子系统”。重启后wsl --update更新内核。问题四端口冲突。5432端口被本地已安装的PostgreSQL占用。改compose文件里的端口映射为5433:5432同时把连接字符串里的端口改成5433。5.2 记忆检索质量差的排查思路检索效果不好通常不是单一原因要按链路逐段排查。先看embedding质量。把query和一条已知相关的记忆分别向量化算余弦相似度。如果相似度低于0.7说明embedding模型不适合你的领域数据。可以考虑换模型或者做领域微调。再看召回数量。如果top 10里没有一条相关说明召回阶段就出了问题。检查Qdrant里的数据量、索引是否建好、过滤条件是否过严。然后看重排序。如果召回里有相关条目但没排到前面说明评分公式的权重需要调。可以打印出每条候选的各分项得分看是哪一项拖了后腿。最后看上下文组装。检索对了但LLM没用上可能是注入位置不对或者格式不清晰。试试把记忆放在System Prompt之后、用户消息之前用明确的分隔符标记。5.3 记忆冲突与更新策略用户偏好变了怎么办比如三个月前说“我喜欢简洁的回复”现在说“能不能详细一点”。如果两条记忆都存着检索时可能同时召回LLM会困惑。我的处理策略是版本化软删除。每条记忆有个superseded_by字段新记忆写入时如果检测到和旧记忆冲突同类型、同主题、内容矛盾就把旧记忆标记为被取代检索时默认过滤掉。但旧记忆不物理删除保留在归档表里万一需要追溯还能查到。冲突检测可以用LLM做让模型判断两条记忆是否矛盾。虽然增加了一次调用成本但比规则匹配准确得多。5.4 性能优化与容量规划记忆量到十万条级别时检索延迟会明显上升。几个优化手段Qdrant索引调优。默认的HNSW参数m16, ef_construct100适合大多数场景。如果召回率不够提高ef参数如果延迟太高降低m。量化可以开启scalar quantization内存占用降4倍精度损失很小。PostgreSQL分区。按created_at做月度分区老数据查询走分区裁剪速度快很多。Redis缓存热点。把access_count最高的前1000条记忆缓存在Redis里检索时先查缓存命中直接返回。异步写入。非关键记忆的写入走消息队列异步处理不阻塞Agent主流程。容量规划上一条记忆平均占向量存储约2KB1536维float32十万条约200MBQdrant完全扛得住。PostgreSQL那边每条记录约1KB十万条100MB。整体资源需求不大一台4核8G的机器足够跑。5.5 常见问题速查表现象可能原因排查方法解决措施容器启动即退出配置错误/端口冲突docker compose logs service检查环境变量和端口映射检索返回空集合未创建/数据未写入查Qdrant collection列表初始化时创建collection检索结果不相关embedding模型不匹配算query和记忆的相似度统一embedding模型记忆重复堆积去重阈值过高查相似度分布降低阈值到0.9写入延迟高同步调用embedding看日志耗时改异步写入LLM不调用工具prompt未说明查对话日志在System Prompt里明确工具用途记忆冲突无版本管理查同主题记忆实现superseded_by机制实操心得我踩过最大的坑是embedding模型换了但没重新索引历史数据导致新旧向量空间不一致检索结果乱七八糟。换模型一定要全量重建索引没有捷径。6. 记忆系统的演进方向与个人体会这套系统跑通之后有几个方向可以继续深挖。记忆的图结构化。现在记忆是扁平的条目但很多知识之间有因果关系、时序关系。用图数据库存记忆支持多跳推理能回答更复杂的问题。热搜词里的“rag graphrag llm wiki 本体rag”就是这个方向。主动记忆。现在的记忆写入是被动的等Agent调用工具。更高级的形态是Agent在后台持续分析对话流主动识别值得记住的内容甚至主动提醒用户“你上次说的那个事现在有进展了”。记忆的隐私与安全。记忆里可能包含敏感信息需要加密存储、访问控制、定期审计。热搜词里“a-memguard”提到的主动防御框架就是解决这个问题的思路。跨Agent记忆共享。多个Agent共享一套记忆系统A Agent学到的知识B Agent也能用。这需要解决记忆的权限隔离和冲突合并问题。我个人在实际操作中的体会是记忆系统的难点不在技术实现而在产品判断——什么该记、什么该忘、什么时候该用。这些决策没有标准答案需要根据具体场景反复调优。我建议先用最小可行方案跑起来收集真实使用数据再逐步迭代。一上来就追求完美架构大概率会过度设计最后发现大部分功能根本用不上。最后分享一个小技巧在记忆条目里加一个source_quote字段存原始对话的片段。当检索到某条记忆时把原始片段也带上LLM能更准确地理解记忆的语境。这个字段会让存储成本增加约30%但对检索准确率的提升非常明显尤其是在处理模糊指代的时候。
返回列表