ARTICLE DETAIL

资讯详情

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

Agent Memory实战:基于MCP与Docker构建可检索的LLM记忆系统

Agent Memory实战:基于MCP与Docker构建可检索的LLM记忆系统 1. 从“hindsight”说起为什么我们需要给Agent装上“后视镜”第一次看到“hindsight”这个词我脑子里蹦出来的不是词典释义而是自己踩过的一个坑。去年做一套基于LLM的客服工单自动分类系统模型在测试集上表现很漂亮F1值能到0.92。上线跑了三天运营同事跑来找我说同一个用户上午投诉物流慢、下午追问退款进度系统给出的分类标签完全不一样甚至把“退款”识别成了“咨询”。我去翻日志才发现每次请求都是独立的模型根本不知道这个用户五分钟前说过什么。那会儿我就意识到LLM再强没有记忆就是金鱼——七秒之后世界重新开始。“hindsight”这个项目标题直译是“后见之明”放在Agent Memory这个语境里它要解决的就是上面那个问题让Agent拥有对历史交互的追溯能力不是简单地把聊天记录塞进上下文窗口而是构建一套可检索、可推理、可演进的记忆体系。你可以把它理解成给Agent装了一面后视镜开车的时候不用一直扭头看但需要变道、超车的时候后视镜里的信息能帮你做判断。这套东西适合谁看如果你正在做LLM应用尤其是多轮对话、任务型Agent、知识库问答这类场景并且已经发现“模型记不住事儿”成了瓶颈那这篇内容就是给你写的。我会从整体设计思路讲到具体实现细节包括记忆的存储结构、检索策略、与MCP协议的配合、Docker环境下的部署要点以及我在实际调试中遇到的那些坑。不需要你事先精通Agent架构但最好对LLM的基本调用方式、向量检索、Docker常用命令有个概念这样读起来会更顺。提示本文讨论的“记忆”指的是Agent在运行过程中产生的交互历史、任务状态、用户偏好等信息的持久化与再利用不涉及模型参数层面的微调或终身学习。2. 整体设计思路Agent Memory到底该怎么拆2.1 为什么不能只靠“把历史塞进Prompt”很多人第一反应是记忆嘛把之前的对话拼到Prompt里不就行了我一开始也这么干过。结果就是一个跑了二十轮对话的Agent每次请求的Token消耗从800涨到6000响应时间从1.2秒变成4.5秒而且模型开始“分心”——它会把三天前用户随口说的一句“我最近在搬家”当成当前任务的相关背景给出莫名其妙的建议。这里面的核心矛盾是上下文窗口是有限的而交互历史是无限增长的。你不可能把所有东西都塞进去必须做取舍。取舍的依据是什么是相关性、时效性和重要性。这就引出了Agent Memory的第一个设计原则分层存储按需检索。2.2 三层记忆结构工作记忆、情景记忆、语义记忆参考认知科学里对人类记忆的分类我在hindsight的实践中把Agent记忆拆成三层工作记忆Working Memory当前会话的短期上下文通常就是最近几轮对话。它的特点是容量小、访问快、生命周期短。实现上可以直接放在内存里或者用Redis的List结构存最近N条消息。情景记忆Episodic Memory具体的历史交互事件比如“用户A在2024年3月5日询问过退款政策”。这些信息需要持久化并且要能按时间、用户ID、会话ID等维度检索。通常用关系型数据库或文档数据库存储。语义记忆Semantic Memory从历史交互中提炼出的抽象知识比如“用户A偏好简洁回复”“这个用户对价格敏感”。这类信息更新频率低但复用价值高适合用向量数据库存储通过语义相似度检索。这三层不是孤立的而是有数据流动的。工作记忆里的内容会定期“沉淀”到情景记忆情景记忆经过聚合、摘要后可以提炼成语义记忆。反过来语义记忆在工作记忆初始化时会被加载进来作为Agent的“先验知识”。2.3 为什么选择MCP作为记忆服务的接口协议MCPModel Context Protocol是Anthropic推出的一个开放协议用来标准化LLM与外部工具、数据源的交互方式。我在hindsight项目里把记忆服务封装成一个MCP Server而不是直接写死在Agent代码里主要考虑三点第一解耦。Agent的逻辑和记忆的存储、检索逻辑分开以后换向量数据库、换存储后端不需要动Agent的核心代码。第二复用。同一个记忆服务可以被多个Agent、多个应用共享比如客服Agent和推荐Agent可以共用一套用户偏好记忆。第三标准化。MCP定义了工具发现、调用、结果返回的格式任何支持MCP的客户端都能接入不用为每个框架写适配层。注意MCP目前还在演进中不同客户端对协议版本的支持程度不一样。我在调试时遇到过某个客户端不支持tools/list的动态发现只能手动注册工具这个后面会细说。2.4 Docker在其中的角色环境一致性与快速迭代Agent Memory服务涉及多个组件向量数据库、关系型数据库、缓存、MCP Server本身。如果每个组件都手动装光是版本兼容就能耗掉一整天。Docker Compose可以把这些组件编排在一起一条命令拉起整个环境。更重要的是开发环境和生产环境用同一套镜像避免了“我本地跑得好好的”这种经典问题。我用的组合是PostgreSQL存情景记忆Redis存工作记忆Qdrant存语义记忆的向量MCP Server用Python写跑在一个独立的容器里。下面这张表是我对比过的几种存储方案供你参考存储类型候选方案最终选择选择理由工作记忆内存字典 / Redis / SQLiteRedis支持TTL自动过期读写性能好容器化部署简单情景记忆PostgreSQL / MongoDB / SQLitePostgreSQL结构化查询能力强JSONB字段灵活生态成熟语义记忆Qdrant / Milvus / Chroma / FAISSQdrant单机部署轻量REST和gRPC接口都有过滤条件丰富接口协议自定义HTTP / MCP / gRPCMCP标准化客户端兼容性好工具发现机制省心3. 核心细节解析记忆的写入、检索与遗忘3.1 记忆写入不是所有对话都值得记住一开始我犯了个错误把每一轮对话都原封不动地写进情景记忆。结果数据库一周就涨了200万条记录检索速度从50毫秒退化到800毫秒。后来我加了一个“记忆价值评估”环节只有满足以下条件之一的对话才会被持久化包含用户明确表达的偏好、事实或指令比如“以后都用中文回复我”涉及任务状态变更比如“订单已提交”“退款已到账”被标记为重要用户手动收藏或Agent判断为关键决策点距离上一次记忆写入超过一定轮次防止长时间对话丢失上下文评估逻辑可以用一个轻量级的LLM调用实现也可以用规则引擎。我为了控制成本先用规则过滤再用小模型做二次判断。具体来说规则层会检查消息中是否包含关键词、是否来自用户而非Agent、是否包含实体日期、金额、订单号等。小模型只对规则层拿不准的样本做判断这样Token消耗能降低70%左右。写入时的数据结构大致是这样的{ memory_id: mem_20240305_001, session_id: sess_abc123, user_id: user_456, timestamp: 2024-03-05T14:32:00Z, type: episodic, content: 用户询问退款政策表示对7天无理由退货的时限有疑问, entities: [退款政策, 7天无理由退货], importance: 0.75, embedding: [0.023, -0.041, ...], metadata: { source: chat, turn: 5, agent_version: v1.2.0 } }importance字段是后续检索排序的重要依据我一般用0到1的浮点数表示0.5以下的基本不会主动召回除非用户明确追问。3.2 记忆检索多路召回与重排序检索是记忆系统里最考验工程能力的部分。单一向量检索的问题在于它只能捕捉语义相似度对时间、用户ID、记忆类型这些结构化条件的支持很弱。我的做法是“多路召回重排序”第一路向量检索。把当前查询转成向量在Qdrant里找Top-K个语义最相近的记忆。K一般取20到50太少容易漏太多影响后续排序速度。第二路结构化过滤。根据当前会话的user_id、时间范围、记忆类型做过滤。比如用户问“我上次说的那个问题解决了吗”时间范围就限定在最近7天类型限定为episodic。第三路关键词匹配。用BM25或简单的全文索引补充向量检索可能漏掉的精确匹配。比如用户提到“订单号12345”向量检索可能找不回来但关键词匹配一抓一个准。三路召回的结果合并后用一个重排序模型我用的bge-reranker-base做精排取Top-5注入到Prompt里。实测下来这种组合策略比纯向量检索的召回率提升了30%以上尤其是在长尾查询上。实操心得重排序模型的推理延迟不低如果对响应时间敏感可以把Top-K从50降到20或者用更小的重排序模型。我试过用交叉编码器做精排效果确实好但单次推理要200毫秒后来换成了双编码器加轻量级排序延迟降到40毫秒效果只差3个百分点。3.3 记忆遗忘主动清理比无限堆积更聪明记忆不是越多越好。过期的、重复的、低价值的记忆不仅占用存储还会干扰检索。我设计了一套遗忘机制包含三种策略时间衰减每条记忆有一个“新鲜度”分数随时间指数衰减。检索时新鲜度低于阈值的记忆会被降权。具体公式是freshness exp(-λ * days_since_creation)λ取0.05的话大约14天后新鲜度降到0.5。容量淘汰每个用户的情景记忆上限设为500条超出后按“重要性×新鲜度”排序淘汰最低的。这个上限可以根据用户活跃度动态调整活跃用户给1000条沉默用户给200条。主动摘要对于同一主题的多个记忆定期用LLM做摘要合并。比如用户在过去一个月里问了五次退款进度可以合并成一条“用户持续关注退款进度最近一次询问是3月5日”。遗忘机制的执行频率不用太高我一般放在每天凌晨的低峰期跑一次批处理。实时性要求高的场景可以在写入时同步做一次轻量级的去重检查。3.4 与MCP协议的对接细节把记忆服务封装成MCP Server需要实现几个核心接口tools/list返回可用的工具列表比如memory_write、memory_search、memory_forget。tools/call接收客户端调用执行具体操作返回结果。resources/list如果有静态资源比如记忆统计报表可以通过这个接口暴露。我遇到的一个坑是某些MCP客户端在启动时会缓存tools/list的结果如果Server端动态增减工具客户端不会自动刷新。解决办法是在Server启动时就把所有工具注册好运行期间不动态变更。如果确实需要动态性可以在工具描述里加一个version字段客户端调用时带上版本号Server端做兼容处理。另一个坑是错误处理。MCP协议对错误的返回格式有要求如果Server端抛异常没被捕获客户端可能直接断开连接。我的做法是在每个工具函数的入口加一层try-except把异常转成标准的MCP错误响应同时记录日志。# MCP Server工具注册示例简化版 from mcp.server import Server, NotificationOptions from mcp.server.models import InitializationOptions server Server(hindsight-memory) server.list_tools() async def handle_list_tools(): return [ Tool( namememory_write, description写入一条新的记忆, inputSchema{ type: object, properties: { content: {type: string}, user_id: {type: string}, memory_type: {type: string, enum: [episodic, semantic]} }, required: [content, user_id] } ), Tool( namememory_search, description检索相关记忆, inputSchema{ type: object, properties: { query: {type: string}, user_id: {type: string}, top_k: {type: integer, default: 5} }, required: [query, user_id] } ) ]4. 实操过程从零搭建一套可运行的Agent Memory服务4.1 环境准备与Docker Compose编排我假设你用的是Linux或macOSWindows的话建议用WSL2能省掉很多路径和权限的麻烦。首先确保Docker和Docker Compose已经装好用docker --version和docker compose version检查一下。如果是在Windows上Docker Desktop的安装过程可能会遇到“Virtualization support not detected”的报错这通常是因为BIOS里的虚拟化选项没开或者Hyper-V和WSL2冲突了。我的建议是优先用WSL2后端在Docker Desktop设置里勾选“Use WSL 2 based engine”然后确保WSL2内核版本在5.10以上。接下来创建项目目录结构如下hindsight/ ├── docker-compose.yml ├── mcp-server/ │ ├── Dockerfile │ ├── requirements.txt │ └── src/ │ ├── main.py │ ├── memory_store.py │ └── retrieval.py ├── init-scripts/ │ └── postgres-init.sql └── .envdocker-compose.yml的内容version: 3.9 services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: hindsight POSTGRES_PASSWORD: hindsight_dev POSTGRES_DB: memory volumes: - pg_data:/var/lib/postgresql/data - ./init-scripts:/docker-entrypoint-initdb.d ports: - 5432:5432 healthcheck: test: [CMD-SHELL, pg_isready -U hindsight] interval: 5s timeout: 5s retries: 5 redis: image: redis:7-alpine command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru ports: - 6379:6379 volumes: - redis_data:/data qdrant: image: qdrant/qdrant:v1.7.4 ports: - 6333:6333 - 6334:6334 volumes: - qdrant_data:/qdrant/storage mcp-server: build: ./mcp-server depends_on: postgres: condition: service_healthy redis: condition: service_started qdrant: condition: service_started environment: POSTGRES_DSN: postgresql://hindsight:hindsight_devpostgres:5432/memory REDIS_URL: redis://redis:6379/0 QDRANT_URL: http://qdrant:6333 EMBEDDING_MODEL: BAAI/bge-small-zh-v1.5 ports: - 8080:8080 volumes: - ./mcp-server/src:/app/src volumes: pg_data: redis_data: qdrant_data:这里有几个参数值得说明。PostgreSQL用16-alpine体积小启动快。Redis设了maxmemory 256mb和allkeys-lru淘汰策略防止工作记忆无限增长把内存吃满。Qdrant的版本我锁在v1.7.4因为新版本偶尔有API变动生产环境建议锁版本。MCP Server的depends_on里对PostgreSQL用了condition: service_healthy确保数据库真正就绪后再启动避免连接被拒。4.2 数据库初始化与索引设计PostgreSQL的初始化脚本主要做两件事建表和建索引。-- postgres-init.sql CREATE TABLE IF NOT EXISTS episodic_memory ( memory_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, content TEXT NOT NULL, entities JSONB DEFAULT [], importance FLOAT DEFAULT 0.5, created_at TIMESTAMPTZ DEFAULT NOW(), last_accessed_at TIMESTAMPTZ DEFAULT NOW(), access_count INT DEFAULT 0 ); CREATE INDEX idx_episodic_user_time ON episodic_memory (user_id, created_at DESC); CREATE INDEX idx_episodic_session ON episodic_memory (session_id); CREATE INDEX idx_episodic_importance ON episodic_memory (importance DESC); CREATE TABLE IF NOT EXISTS semantic_memory ( memory_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id VARCHAR(64) NOT NULL, content TEXT NOT NULL, category VARCHAR(32) DEFAULT general, confidence FLOAT DEFAULT 0.8, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_semantic_user ON semantic_memory (user_id); CREATE INDEX idx_semantic_category ON semantic_memory (category);episodic_memory表里我加了last_accessed_at和access_count两个字段用来做热度衰减。检索时如果一条记忆被频繁访问它的权重会适当提升这符合“常用记忆更重要”的直觉。entities字段用JSONB存储方便后续做基于实体的过滤。Qdrant的集合创建在MCP Server启动时自动完成不需要手动初始化。集合的向量维度取决于你选的Embedding模型bge-small-zh-v1.5是512维bge-base-zh-v1.5是768维。我选small版本是因为它在中文短文本上的表现和base差距不大但推理速度快一倍内存占用也小。4.3 MCP Server核心逻辑实现memory_store.py负责写入逻辑核心是“价值评估去重持久化”三步。价值评估我用了一个简单的规则引擎加小模型兜底规则部分检查消息长度、是否包含实体、是否来自用户。去重则是计算新记忆与最近10条记忆的余弦相似度超过0.95就视为重复只更新last_accessed_at不新增记录。# memory_store.py 核心片段 import numpy as np from datetime import datetime, timedelta class MemoryStore: def __init__(self, pg_pool, redis_client, qdrant_client, embedder): self.pg pg_pool self.redis redis_client self.qdrant qdrant_client self.embedder embedder async def write_episodic(self, user_id, session_id, content, entitiesNone): # 价值评估 importance self._evaluate_importance(content, entities) if importance 0.3: return None # 低价值记忆直接丢弃 # 去重检查 recent await self._get_recent_memories(user_id, limit10) if recent: new_vec self.embedder.encode(content) for mem in recent: sim np.dot(new_vec, mem[embedding]) / ( np.linalg.norm(new_vec) * np.linalg.norm(mem[embedding]) ) if sim 0.95: await self._touch_memory(mem[memory_id]) return mem[memory_id] # 持久化到PostgreSQL memory_id await self._insert_pg(user_id, session_id, content, entities, importance) # 写入Qdrant vector self.embedder.encode(content).tolist() self.qdrant.upsert( collection_nameepisodic_memory, points[{ id: memory_id, vector: vector, payload: { user_id: user_id, session_id: session_id, importance: importance, created_at: datetime.utcnow().isoformat() } }] ) return memory_id def _evaluate_importance(self, content, entities): score 0.5 if len(content) 50: score 0.1 if entities: score 0.15 * min(len(entities), 3) if any(kw in content for kw in [记住, 以后, 偏好, 重要]): score 0.2 return min(score, 1.0)retrieval.py负责检索实现多路召回和重排序。这里的关键是控制各路召回的权重我一般设向量检索0.6、结构化过滤0.3、关键词匹配0.1具体数值可以根据业务调。# retrieval.py 核心片段 class MemoryRetriever: async def search(self, query, user_id, top_k5): # 第一路向量检索 query_vec self.embedder.encode(query).tolist() vector_hits self.qdrant.search( collection_nameepisodic_memory, query_vectorquery_vec, query_filter{must: [{key: user_id, match: {value: user_id}}]}, limit30 ) # 第二路结构化过滤最近7天高重要性记忆 time_threshold datetime.utcnow() - timedelta(days7) structured_hits await self.pg.fetch( SELECT memory_id, content, importance, created_at FROM episodic_memory WHERE user_id $1 AND created_at $2 AND importance 0.6 ORDER BY importance DESC LIMIT 20, user_id, time_threshold ) # 第三路关键词匹配简化版实际可用tsvector keyword_hits await self.pg.fetch( SELECT memory_id, content, importance, created_at FROM episodic_memory WHERE user_id $1 AND content ILIKE $2 ORDER BY created_at DESC LIMIT 10, user_id, f%{query[:20]}% ) # 合并去重 candidates self._merge_and_dedup(vector_hits, structured_hits, keyword_hits) # 重排序 reranked self.reranker.rerank(query, candidates, top_ktop_k) return reranked4.4 与Agent的集成方式MCP Server跑起来之后Agent端只需要通过MCP客户端连接即可。以Python为例可以用mcp官方库from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client server_params StdioServerParameters( commandpython, args[-m, src.main], env{POSTGRES_DSN: ..., QDRANT_URL: ...} ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() # 调用记忆检索 result await session.call_tool( memory_search, arguments{query: 用户上次提到的退款问题, user_id: user_456, top_k: 5} ) memories result.content # 把memories注入到Prompt中 prompt build_prompt(user_input, memories)这里有个细节MCP的call_tool返回的是content列表每个元素可能是文本、图片或其他类型。记忆检索返回的一般是文本直接拼接即可。如果返回的是结构化JSON需要先解析再格式化。注意MCP Server的启动方式有stdio和SSE两种。stdio适合本地开发SSE适合远程部署。如果Agent和MCP Server不在同一台机器上用SSE模式但要注意鉴权别把接口裸奔在公网上。5. 常见问题与排查技巧实录5.1 记忆检索不准确召回的内容答非所问这是最常见的问题原因通常有三个Embedding模型不适合中文、检索策略太单一、重排序模型没调好。我的排查顺序是先看Embedding模型用几个典型查询手动算一下相似度如果明显不相关的文本相似度也很高说明模型不行换bge或m3e系列。再看检索策略是不是只用了向量检索没有加结构化过滤。最后看重排序可以打印出重排序前后的顺序对比看看是不是把相关记忆排到了后面。5.2 Docker容器启动后MCP Server连不上数据库先检查docker compose logs mcp-server看报错信息。常见的是connection refused说明数据库还没就绪。虽然depends_on配了service_healthy但有时候健康检查的间隔设得太短数据库还没完全初始化完就被判定为健康。解决办法是把healthcheck的interval调到10sretries调到10。另一个可能是网络问题docker network inspect看一下容器是不是在同一个网络里。5.3 记忆写入速度慢影响Agent响应写入慢通常是因为同步做了太多事情价值评估、去重、Embedding、写PG、写Qdrant。我的优化方案是异步化价值评估和去重放在写入前同步做因为要决定是否写入Embedding和持久化放到后台任务队列里。用Redis的Stream或者简单的asyncio.create_task都可以。这样Agent的响应时间不受写入影响代价是记忆有轻微延迟但一般用户感知不到。5.4 记忆越来越多检索越来越慢这是容量问题必须做遗忘和归档。我的做法是超过30天的低重要性记忆importance 0.4归档到冷存储表主表只保留热数据。Qdrant那边也类似可以按时间分区老分区只读不查。另外定期做记忆摘要把同一用户的相似记忆合并能减少30%到50%的记录数。5.5 MCP客户端报“provider rejected the request schema”这个报错通常是因为工具调用的参数格式和inputSchema定义的不一致。比如inputSchema里定义top_k是integer但客户端传了字符串5。解决办法是在Server端做参数类型转换或者在工具描述里写清楚类型要求。我一般会在工具函数入口加一层校验类型不对就尝试转换转换失败再返回错误。问题现象可能原因排查步骤解决方案检索结果不相关Embedding模型不适配手动计算相似度换bge-small-zh或m3e-base容器间连接失败网络未互通docker network inspect确保在同一compose网络写入延迟高同步操作过多打印各步骤耗时异步化Embedding和持久化检索变慢数据量过大查表行数和索引命中归档冷数据加分区MCP调用报schema错误参数类型不匹配看Server端日志加参数校验和类型转换5.6 几个我踩过的坑和对应的技巧第一个坑Qdrant的search接口在v1.7之后改成了query_points如果你看的教程比较老代码会跑不通。解决办法是查官方文档的版本迁移指南或者锁版本。第二个坑PostgreSQL的gen_random_uuid()需要pgcrypto扩展在初始化脚本里要加CREATE EXTENSION IF NOT EXISTS pgcrypto;否则建表会报错。第三个坑Redis的maxmemory-policy如果设成noeviction内存满了之后写入会直接报错。一定要设成allkeys-lru或volatile-lru。第四个坑MCP Server如果用stdio模式日志不能直接打到stdout否则会干扰协议通信。日志要写到stderr或文件里。第五个坑Embedding模型第一次加载会下载权重如果容器没有外网访问权限会卡住。解决办法是提前把模型下载到本地挂载到容器里或者用国内镜像源。6. 记忆系统的演进方向与个人体会这套hindsight的架构我跑了大概三个月处理了将近50万条记忆记录整体稳定性还不错。但有几个地方我觉得还有优化空间。一个是记忆的“推理”能力现在只是检索和拼接未来可以加一层记忆推理比如根据用户的历史行为推断当前意图。另一个是多模态记忆现在只处理文本如果能把图片、语音也纳入记忆体系适用场景会更广。我个人在实际操作中的体会是Agent Memory这件事技术选型只占三成七成在于对业务场景的理解。你得清楚哪些信息值得记、哪些该忘、检索时怎么排序这些没有标准答案只能根据具体场景调。我建议刚开始不要追求大而全先把工作记忆和情景记忆做扎实语义记忆可以后面再加。另外监控一定要做好记忆的写入量、检索命中率、响应延迟这些指标要能实时看到不然出了问题只能靠猜。最后分享一个小技巧在开发阶段可以加一个“记忆调试面板”把每次检索的召回结果、重排序分数、最终注入Prompt的内容都展示出来。我用的是简单的Web页面加WebSocket推送调试效率提升非常明显。这个面板不用做得太精致能看数据就行但一定要有。
返回列表