ARTICLE DETAIL

资讯详情

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

Agent Memory实战:基于MCP与Docker构建LLM长期记忆系统

Agent Memory实战:基于MCP与Docker构建LLM长期记忆系统 1. 从“hindsight”说起为什么我们需要给Agent装上“后视镜”第一次看到“hindsight”这个词我脑子里蹦出来的不是词典里的“事后聪明”而是开车时那面后视镜。你往前开眼睛盯着前方路况但真正让你敢变道、敢超车的是后视镜里那几秒前的画面。Agent Memory这件事本质上就是在给LLM驱动的智能体装一面足够清晰、足够可靠的后视镜。过去大半年我一直在折腾各种Agent框架从最朴素的ReAct循环到带工具调用的复杂工作流。踩过最大的坑不是模型不够聪明而是它“记不住”。你上一轮告诉它“用户对花生过敏”下一轮它推荐菜谱时照样给你整出个宫保鸡丁。你让它查了三次数据库每次都要重新描述表结构。这种体验就像跟一个每五分钟失忆一次的人合作效率低到让人抓狂。“hindsight”这个项目标题结合热搜词里的agent memory、LLM、MCP、Docker我判断它要解决的核心问题就是如何让Agent拥有持久化、可检索、可推理的长期记忆能力。不是简单的对话历史堆砌而是结构化的、带语义索引的、能在需要时被精准唤醒的记忆系统。它适合谁适合那些已经跑通了基础Agent流程但被“金鱼记忆”折磨得死去活来的开发者也适合想从零搭建一套可落地记忆架构的技术负责人。我打算从架构设计、核心组件、实操部署、问题排查四个维度把这件事拆开揉碎讲清楚。文章里会涉及Docker部署、MCP协议对接、向量存储选型这些硬核内容也会分享我在实际调试中总结的避坑经验。不管你是刚接触Agent Memory的新手还是已经在用RAG但效果不理想的进阶玩家应该都能找到能直接抄作业的部分。2. 整体架构设计Agent Memory不是简单的“存聊天记录”2.1 为什么传统RAG方案在Agent场景下会“水土不服”很多人第一次做Agent记忆直觉反应是上RAG把历史对话切块、向量化、存进向量数据库需要时检索Top-K。我一开始也这么干结果发现三个致命问题。第一时间维度丢失。RAG检索只看语义相似度不看时间顺序。用户三天前说“我下周要去北京出差”今天问“帮我推荐个餐厅”RAG可能把三天前那条记录检索出来但Agent不知道“下周”已经变成了“这周”推荐逻辑完全错乱。第二记忆粒度混乱。对话历史里既有“用户叫张三”这种永久事实也有“今天天气不错”这种瞬时噪声。全部一视同仁地向量化检索时噪声会淹没信号。我实测过在一个200轮对话的测试集里纯RAG方案的记忆召回准确率只有43%左右超过一半的检索结果是无用信息。第三缺乏主动遗忘机制。人脑会遗忘Agent也需要。过期的、矛盾的、低价值的记忆如果不清理向量库会越来越臃肿检索质量断崖式下跌。我见过一个跑了三个月的Agent向量库里堆了十几万条记录检索延迟从200ms涨到2s效果还越来越差。“hindsight”这个命名很有意思它暗示的是一种回溯性、反思性的记忆处理。不是简单地把所有东西塞进去而是在需要的时候能够“回头看”并理解哪些记忆真正相关。2.2 分层记忆架构Working Memory、Episodic Memory、Semantic Memory基于上面这些教训我设计了一套三层记忆架构这也是我认为“hindsight”类项目最合理的落地方案。Working Memory工作记忆对应Agent当前会话的上下文窗口。这部分不持久化就是标准的LLM context。但关键在于它不应该塞满原始对话而应该是一个经过压缩和摘要的滚动窗口。我的做法是保留最近5轮完整对话更早的内容用LLM生成结构化摘要摘要里必须包含用户意图、关键实体、未完成任务、情绪状态。这样即使窗口只有4K token也能承载几十轮对话的核心信息。Episodic Memory情景记忆存储具体的交互事件。每条记录包含时间戳、参与者、动作、结果、情感标签。这部分用关系型数据库存原始数据同时把关键字段向量化后存入向量库做语义索引。注意不是整条记录向量化而是把“用户说了什么”“Agent做了什么”“结果如何”分开向量化检索时可以按需匹配。Semantic Memory语义记忆存储从情景记忆中提炼出的抽象知识。比如从“用户三次提到对花生过敏”提炼出“用户有花生过敏史”这个事实。这部分需要定期跑一个反思任务用LLM对近期情景记忆做归纳生成或更新语义记忆条目。语义记忆的优先级最高检索时应该被优先召回。三层之间的流转关系是这样的Working Memory满了压缩后写入Episodic MemoryEpisodic Memory积累到一定量触发反思任务提炼出Semantic MemorySemantic Memory反过来影响Working Memory的构建比如在系统提示词里注入用户偏好。2.3 MCP协议在记忆系统中的角色定位热搜词里MCP出现了很多次我理解很多人对它的定位还比较模糊。MCPModel Context Protocol本质上是一个标准化的工具调用协议它让LLM能够以统一的方式访问外部资源。在Agent Memory场景下MCP的价值在于把记忆系统封装成一个标准化的Server任何支持MCP的Agent框架都能即插即用。我目前的实现方式是用Python写一个MCP Server暴露三个核心工具——memory_store、memory_retrieve、memory_reflect。Agent通过MCP协议调用这些工具不需要关心底层用的是Redis还是PostgreSQL是FAISS还是Milvus。这种解耦带来的好处是我可以在不修改Agent代码的情况下把底层存储从本地SQLite切换到云端PostgreSQL把向量索引从暴力搜索换成HNSW。MCP的另一个好处是跨Agent共享记忆。我同时跑着三个不同的Agent一个负责日程管理一个负责邮件处理一个负责代码审查它们都连接到同一个MCP Memory Server。日程Agent记录的用户偏好邮件Agent也能检索到。这种共享能力在没有MCP之前需要写大量胶水代码才能实现。2.4 Docker化部署为什么我坚持用容器跑记忆服务热搜词里Docker出现频率极高这很合理。Agent Memory服务涉及多个组件向量数据库、关系型数据库、缓存、MCP Server、反思任务调度器。如果全部裸装在宿主机上依赖冲突能把你逼疯。我试过在一台Ubuntu机器上同时装Milvus和PostgreSQL光是glibc版本冲突就折腾了一下午。Docker Compose是我目前最推荐的方案。一个docker-compose.yml文件定义所有服务网络互通数据卷持久化环境变量集中管理。迁移的时候把文件拷到新机器docker compose up -d五分钟搞定。而且Docker的隔离性让每个组件可以独立升级不会牵一发而动全身。注意Windows环境下跑Docker Desktop一定要在BIOS里开启虚拟化支持。我见过太多人卡在“Virtualization support not detected”这个报错上以为是Docker的问题其实是主板设置没开。任务管理器→性能→CPU看“虚拟化”那一栏是不是“已启用”。3. 核心组件拆解与选型逻辑3.1 向量存储选型FAISS、Chroma、Milvus到底怎么选向量存储是记忆系统的基石选错了后面全是坑。我按数据量级和部署复杂度给一个实操建议。方案适用数据量部署复杂度持久化我的评价FAISS10万条极低pip install需手动序列化适合原型验证生产环境慎用Chroma50万条低支持Docker内置SQLite小团队首选API友好Milvus100万条高需etcdMinIO完善大规模场景唯一选择pgvector100万条中PostgreSQL扩展完善已有PG基础设施时的最优解我目前的生产环境用的是pgvector。原因很简单我的情景记忆本来就存在PostgreSQL里加一个vector扩展不用额外维护一套向量数据库。检索时可以用SQL做混合查询比如“找最近7天内、情感标签为负面、且语义相似度0.8的记忆”一条SQL搞定。Milvus虽然性能更强但为了那点性能提升多维护三个组件我觉得不划算。如果你是从零开始数据量预期在10万条以内我建议直接上Chroma。它的collection.query()接口设计得很直觉而且支持元数据过滤基本能满足90%的Agent记忆场景。3.2 嵌入模型选择不是越大越好嵌入模型决定了记忆检索的语义理解能力。我试过OpenAI的text-embedding-3-large、Cohere的embed-multilingual-v3、还有开源的BGE-M3。实测下来对于Agent记忆这种短文本、多语言、带专有名词的场景BGE-M3的性价比最高。text-embedding-3-large效果确实好但成本摆在那里。我算过一笔账一个中等活跃度的Agent每天产生约2000条记忆每条平均50个token一年下来嵌入成本接近200美元。BGE-M3本地部署一次性投入GPU资源后续零边际成本。而且BGE-M3支持8192 token的上下文长度对于长记忆条目更友好。实操心得嵌入模型不要频繁更换。我吃过这个亏中途从text-embedding-ada-002换到3-small结果新旧向量空间不兼容检索结果乱七八糟。如果非要换必须全量重新嵌入没有捷径。3.3 反思任务的设计让Agent学会“温故知新”反思任务是Semantic Memory的核心来源。我的实现方案是每天凌晨2点触发一次取过去24小时内新增的Episodic Memory按用户ID分组每组喂给LLM做归纳。Prompt的设计很关键。我试过几种模板最终稳定下来的是这个结构REFLECTION_PROMPT 你是一个记忆整理助手。以下是用户{user_id}在过去24小时内的交互记录 {episodic_memories} 请完成以下任务 1. 提取用户明确表达的偏好、事实、约束条件如过敏、禁忌、习惯 2. 识别用户未完成的任务或待跟进事项 3. 发现用户情绪变化的模式 4. 对每条提取的信息标注置信度高/中/低 输出格式为JSON每个条目包含content, category, confidence, source_memory_ids 这里有个细节必须要求LLM输出source_memory_ids。这样当语义记忆出现错误时可以追溯到原始情景记忆方便调试和修正。我一开始没加这个字段后来发现语义记忆里有一条“用户喜欢川菜”但怎么都想不起来是从哪次对话提炼的排查了半天。反思任务的频率也需要调优。太频繁LLM调用成本高而且短期内的记忆可能还没形成模式太稀疏语义记忆更新滞后。我实测下来每天一次对大多数场景够用。如果是高频交易类Agent可以缩短到每4小时一次。3.4 MCP Server的实现细节工具定义与错误处理MCP Server的实现看起来简单但有几个坑我踩过之后觉得值得单独拎出来说。首先是工具描述的质量。MCP协议要求每个工具提供description这个description会直接进入LLM的上下文。我一开始写得很随意比如“存储记忆”结果LLM经常在不该调用的时候调用。后来改成“将当前对话中的关键信息持久化存储适用于用户明确表达偏好、事实或约束条件的场景。不要用于存储临时性问候或闲聊内容。”调用准确率明显提升。其次是错误处理。MCP工具调用失败时返回的错误信息也会进入LLM上下文。如果直接抛Python异常堆栈LLM会懵掉。我的做法是捕获所有异常返回结构化的错误信息try: result memory_store(...) return {status: success, memory_id: result.id} except VectorDBConnectionError: return {status: error, message: 记忆存储服务暂时不可用请稍后重试或继续当前对话} except DuplicateMemoryError: return {status: skipped, message: 该记忆已存在无需重复存储}这样LLM能理解发生了什么并做出合理决策而不是直接崩溃。还有一个细节是超时设置。MCP工具调用默认超时是30秒但向量检索在数据量大时可能超过这个时间。我建议在Server端做分页每次最多返回20条结果并且设置5秒的检索超时超时后返回部分结果加一个has_more: true标记。4. 从零搭建Docker Compose一键部署实操4.1 环境准备与目录结构我假设你用的是Ubuntu 22.04或Windows 11 WSL2。先确认Docker和Docker Compose已安装docker --version docker compose version如果没装Ubuntu下用官方脚本curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USERWindows下直接下载Docker Desktop安装包安装时勾选“Use WSL 2 instead of Hyper-V”。目录结构我习惯这样组织hindsight/ ├── docker-compose.yml ├── .env ├── mcp-server/ │ ├── Dockerfile │ ├── requirements.txt │ └── src/ │ ├── main.py │ ├── memory_store.py │ ├── memory_retrieve.py │ └── reflection.py ├── init-scripts/ │ └── init.sql └── data/ ├── postgres/ └── redis/4.2 docker-compose.yml核心配置解析下面是我生产环境在用的配置做了脱敏处理version: 3.8 services: postgres: image: pgvector/pgvector:pg16 environment: POSTGRES_DB: hindsight POSTGRES_USER: agent POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - ./data/postgres:/var/lib/postgresql/data - ./init-scripts:/docker-entrypoint-initdb.d ports: - 5432:5432 healthcheck: test: [CMD-SHELL, pg_isready -U agent -d hindsight] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru volumes: - ./data/redis:/data ports: - 6379:6379 mcp-server: build: ./mcp-server environment: DATABASE_URL: postgresql://agent:${DB_PASSWORD}postgres:5432/hindsight REDIS_URL: redis://redis:6379/0 EMBEDDING_MODEL: BAAI/bge-m3 REFLECTION_CRON: 0 2 * * * depends_on: postgres: condition: service_healthy redis: condition: service_started ports: - 8080:8080 volumes: - ./mcp-server/src:/app/src几个关键点解释一下。pgvector/pgvector:pg16这个镜像已经预装了vector扩展省得自己编译。Redis的maxmemory-policy allkeys-lru确保缓存满了之后自动淘汰最久未使用的键避免OOM。depends_on配合healthcheck保证PostgreSQL完全就绪后才启动MCP Server否则初始化脚本会失败。4.3 数据库初始化脚本init.sql负责建表和创建索引CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE episodic_memories ( id BIGSERIAL PRIMARY KEY, user_id VARCHAR(64) NOT NULL, session_id VARCHAR(64) NOT NULL, content TEXT NOT NULL, embedding vector(1024), category VARCHAR(32), emotion VARCHAR(16), created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ ); CREATE INDEX idx_episodic_user_time ON episodic_memories(user_id, created_at DESC); CREATE INDEX idx_episodic_embedding ON episodic_memories USING hnsw (embedding vector_cosine_ops); CREATE TABLE semantic_memories ( id BIGSERIAL PRIMARY KEY, user_id VARCHAR(64) NOT NULL, content TEXT NOT NULL, embedding vector(1024), category VARCHAR(32), confidence VARCHAR(8), source_ids BIGINT[], created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_semantic_user ON semantic_memories(user_id); CREATE INDEX idx_semantic_embedding ON semantic_memories USING hnsw (embedding vector_cosine_ops);HNSW索引的构建参数我用的默认值m16, ef_construction64对于百万级以下的数据量足够。如果检索延迟敏感可以把ef_search调到100召回率会更高代价是查询稍慢。4.4 MCP Server核心代码实现memory_store.py的核心逻辑import asyncpg import redis.asyncio as redis from sentence_transformers import SentenceTransformer class MemoryStore: def __init__(self, db_url, redis_url): self.db_pool None self.redis redis.from_url(redis_url) self.encoder SentenceTransformer(BAAI/bge-m3) async def store_episodic(self, user_id, session_id, content, categoryNone, emotionNone): # 去重检查最近1小时内相同内容不重复存储 cache_key fmem:{user_id}:{hash(content)} if await self.redis.exists(cache_key): return {status: skipped, reason: duplicate} embedding self.encoder.encode(content).tolist() async with self.db_pool.acquire() as conn: row await conn.fetchrow( INSERT INTO episodic_memories (user_id, session_id, content, embedding, category, emotion) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, user_id, session_id, content, embedding, category, emotion ) await self.redis.setex(cache_key, 3600, 1) return {status: success, memory_id: row[id]}这里有个设计决策去重窗口设为1小时。太短了用户重复说同一件事会被反复存储太长了用户改口说“我现在喜欢上海了”可能被误判为重复。1小时是我实测下来比较平衡的值。memory_retrieve.py的混合检索逻辑async def retrieve(self, user_id, query, top_k10, time_decayTrue): query_embedding self.encoder.encode(query).tolist() # 语义检索 async with self.db_pool.acquire() as conn: semantic_results await conn.fetch( SELECT id, content, category, confidence, 1 - (embedding $1) AS similarity FROM semantic_memories WHERE user_id $2 ORDER BY embedding $1 LIMIT $3, query_embedding, user_id, top_k ) episodic_results await conn.fetch( SELECT id, content, category, emotion, created_at, 1 - (embedding $1) AS similarity FROM episodic_memories WHERE user_id $2 AND (expires_at IS NULL OR expires_at NOW()) ORDER BY embedding $1 LIMIT $3, query_embedding, user_id, top_k ) # 时间衰减越久远的记忆权重越低 if time_decay: for r in episodic_results: days_old (datetime.now(timezone.utc) - r[created_at]).days r[score] r[similarity] * (0.95 ** days_old) # 语义记忆优先情景记忆补充 combined sorted( list(semantic_results) list(episodic_results), keylambda x: x.get(score, x[similarity]), reverseTrue )[:top_k] return combined时间衰减系数0.95是我调出来的。意味着一条记忆每过一天权重打95折。30天后权重降到约21%基本可以忽略。这个系数可以根据业务调整如果是长期偏好类Agent可以调到0.99。4.5 启动与验证cd hindsight docker compose up -d docker compose logs -f mcp-server看到MCP Server listening on 0.0.0.0:8080就说明启动成功了。验证一下curl -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -d { jsonrpc: 2.0, method: tools/call, params: { name: memory_store, arguments: { user_id: test_user, session_id: test_session, content: 用户对花生过敏, category: constraint } }, id: 1 }返回{status: success, memory_id: 1}就说明存储成功了。再查一下curl -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -d { jsonrpc: 2.0, method: tools/call, params: { name: memory_retrieve, arguments: { user_id: test_user, query: 饮食禁忌 } }, id: 2 }应该能检索到刚才存的那条记忆。5. 常见问题与排查技巧实录5.1 Docker网络不通容器间无法互相访问这是最高频的问题。症状是MCP Server日志里报Connection refused连不上PostgreSQL或Redis。排查步骤确认所有容器在同一个网络里。docker compose默认会创建一个以项目名命名的网络所有服务自动加入。如果你手动指定了network_mode: host就会脱离这个网络。在MCP Server容器里执行ping postgres看能否解析到IP。如果不行检查docker-compose.yml里的服务名是否拼写正确。Docker Compose内置DNS服务名就是主机名。检查端口映射。容器间通信走的是容器内部端口不是宿主机映射端口。比如PostgreSQL容器内部是5432你映射到宿主机是5433那么MCP Server连接时应该用postgres:5432而不是postgres:5433。避坑技巧我习惯在docker-compose.yml里显式定义网络而不是依赖默认网络。这样即使项目名变了网络名也是固定的方便调试。5.2 向量检索结果不相关嵌入模型与查询不匹配有时候存进去的记忆明明相关检索时却排不到前面。我遇到过几次原因各不相同。情况一嵌入模型对中文支持不好。早期我用all-MiniLM-L6-v2英文效果不错中文一塌糊涂。换成BGE-M3后解决。情况二查询和记忆的表述差异太大。用户存的是“我不吃辣”查询是“饮食偏好”语义相似度可能只有0.6。解决方案是在存储时让LLM生成多个表述变体一起向量化。比如“我不吃辣”生成“用户忌辛辣”“用户偏好清淡口味”检索时命中率会高很多。情况三向量维度不匹配。如果你中途换了嵌入模型旧向量的维度可能和新查询向量不一致PostgreSQL会直接报错。必须全量重新嵌入。5.3 反思任务生成错误语义记忆LLM归纳时偶尔会“过度推理”。比如用户说“今天不想吃川菜”LLM可能归纳成“用户不喜欢川菜”。这是过度泛化。我的解决方案是在反思Prompt里加一条约束“只提取用户明确表达的事实不要做任何推断。如果用户说‘今天不想吃川菜’只能记录‘用户今天不想吃川菜’不能记录‘用户不喜欢川菜’。”同时把置信度标为“低”并在检索时对低置信度记忆降权。另外我加了一个人工审核队列。所有新生成的语义记忆先进入pending状态在管理后台展示我每天花五分钟扫一眼确认无误后手动批准。虽然麻烦但避免了错误记忆污染整个系统。5.4 记忆膨胀导致检索变慢跑了三个月后我的测试环境里积累了约8万条情景记忆检索延迟从150ms涨到1.2s。解决方案有三个第一设置过期时间。对于明确时效性的记忆比如“用户明天要开会”存储时设置expires_at为后天。过期后自动不参与检索。第二定期归档。超过90天的情景记忆如果从未被检索命中过转移到冷存储表。热表只保留最近90天或高频访问的记忆。第三优化索引。HNSW索引的ef_search参数默认是40我调到80后召回率提升明显但延迟也增加了。最终我用了分区索引按用户ID哈希分区每个分区独立建HNSW索引。这样单次检索只扫描一个分区延迟降回200ms以内。5.5 常见问题速查表现象可能原因排查命令解决方案MCP Server启动即退出数据库连接失败docker compose logs mcp-server检查DATABASE_URL环境变量检索返回空列表向量维度不匹配SELECT vector_dims(embedding) FROM episodic_memories LIMIT 1确认嵌入模型输出维度与表定义一致存储报唯一约束冲突去重逻辑失效检查Redis连接确认Redis服务正常cache_key生成逻辑正确反思任务不执行Cron表达式错误docker compose exec mcp-server crontab -l确认REFLECTION_CRON格式为分 时 日 月 周检索延迟突然飙升向量表数据量过大SELECT COUNT(*) FROM episodic_memories启用分区或归档旧数据6. 记忆系统的扩展方向与个人实践体会这套架构跑了大半年支撑了三个内部Agent的日常运行累计处理了约50万条记忆。过程中最大的体会是Agent Memory不是一个纯技术问题而是一个产品设计问题。存什么、存多久、怎么用这些决策比选什么向量数据库重要得多。我目前正在尝试的扩展方向有两个。一是跨Agent记忆共享的权限控制。现在所有Agent共享一个记忆池但日程Agent不应该看到代码审查Agent的技术细节。我在MCP Server层加了一个scope字段每个Agent只能检索自己scope内的记忆以及标记为global的公共记忆。二是记忆的主动遗忘。除了时间衰减我还在实验基于访问频率的遗忘曲线。一条记忆如果连续30天没有被任何检索命中自动降低其权重60天后移入冷存储。这模仿了人脑的突触修剪机制让系统保持“轻盈”。最后分享一个调试技巧我写了一个memory_explorer的简单Web界面用Flask搭的可以按用户ID、时间范围、类别筛选记忆还能手动触发反思任务。每次Agent行为异常时我第一件事就是打开这个界面看看它到底“记得”什么。十次有八次问题都出在记忆层而不是模型本身。这个项目后续还可以往多模态记忆方向走。现在只能存文本但Agent在实际场景中会看到图片、听到语音。把图像嵌入和文本嵌入对齐到同一向量空间就能实现“用户上次发的那张红色裙子图片”这种跨模态检索。我试过用CLIP做原型效果还行但工程化还有不少坑要填。
返回列表