ARTICLE DETAIL

资讯详情

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

LLM Agent长期记忆系统实战:从记忆抽取到MCP协议集成与Docker部署

LLM Agent长期记忆系统实战:从记忆抽取到MCP协议集成与Docker部署 1. 从“hindsight”说起为什么我们需要给Agent装上记忆“hindsight”这个词本身很有意思字面意思是“事后的洞察力”也就是我们常说的“后见之明”。放在LLM Agent的语境里它指向一个非常具体且棘手的问题Agent如何记住过去发生过的事并在后续决策中真正用上这些经验。这不是一个简单的“存个日志”就能解决的问题它涉及到记忆的编码、存储、检索、更新和遗忘一整条链路。我最初接触这个方向是因为在实际项目里反复遇到同一个尴尬场景用户跟Agent聊了半小时中间明确说过“我对花生过敏”结果推荐餐厅时Agent还是推了一家花生酱招牌菜的店。用户骂它“没脑子”但本质上不是模型不够聪明而是它根本没有一个可靠的记忆机制。LLM的上下文窗口再大也扛不住长对话的累积更别说跨会话的场景了。所以“hindsight”这个项目标题我理解它的核心使命就是让Agent拥有跨会话、可检索、可推理的长期记忆能力。这篇文章适合谁看如果你正在做LLM Agent相关的产品、正在选型记忆方案、或者单纯想搞清楚“Agent Memory”到底该怎么落地那接下来的内容应该能帮你省下不少试错时间。我会从整体设计思路讲到具体实现细节包括存储结构、检索策略、MCP协议集成、Docker部署以及我在实际调试中踩过的坑。不堆概念只讲能跑起来的东西。2. 整体设计思路Agent Memory到底该怎么架构2.1 为什么不能只靠上下文窗口和向量数据库很多人第一反应是记忆嘛不就是把对话历史塞进向量数据库需要的时候检索一下我一开始也这么想但实际跑下来发现三个致命问题。第一向量检索的粒度太粗。你把一整段对话embedding成一个向量检索出来的是“某次对话的模糊印象”而不是“用户在第3轮明确说过对花生过敏”这种精确事实。Agent拿到这种模糊记忆很容易产生幻觉式的补全。第二缺乏结构化推理能力。记忆不应该是孤立的碎片而应该是有关系的网络。比如“用户对花生过敏”和“用户喜欢川菜”这两条记忆在推荐餐厅时需要联合推理纯向量检索做不到这种关系推导。第三没有遗忘和更新机制。用户上周说喜欢某家店这周说那家店倒闭了记忆系统得知道旧信息该失效了。向量数据库的“相似度检索”天然不支持这种时序更新。所以“hindsight”的设计思路我倾向于把它拆成三层工作记忆Working Memory、情景记忆Episodic Memory、语义记忆Semantic Memory。工作记忆就是当前会话的上下文情景记忆是带时间戳的具体事件语义记忆是从事件中抽象出来的稳定事实。三层各司其职检索时按需调用。2.2 核心存储结构Key-Query-Value三元组的设计哲学热词里有一条我印象很深“LLM的token三个点key我是谁、query我在找什么、value我能提供什么”。这其实是在用注意力机制的隐喻来解释记忆检索。我把它落地成具体的存储结构字段含义示例key记忆的主体标识user:12345:allergyquery检索时的意图向量“推荐餐厅时需要考虑的约束”value具体记忆内容“对花生过敏严重程度中度”timestamp记忆写入时间2025-01-15T10:30:00Zconfidence置信度0.95source来源会话IDsession:abc-123ttl过期时间无永久有效这个结构的好处是检索时可以用key做精确匹配用query做语义匹配用value做内容返回三者解耦。比如用户问“今晚吃什么”Agent先用query“饮食偏好”去检索命中keyuser:12345:allergy返回value“对花生过敏”然后推理时自动排除含花生的餐厅。注意key的设计一定要有命名空间隔离否则多用户场景下会串数据。我见过有人直接用“allergy”做key结果A用户的过敏信息被B用户检索到了这是生产事故级别的bug。2.3 记忆的生命周期管理记忆不是写进去就完事了它有一个完整的生命周期写入 → 索引 → 检索 → 更新 → 衰减 → 归档/删除。写入阶段我建议用LLM做一次“记忆抽取”把原始对话转成结构化的三元组。比如用户说“我上周去了趟成都吃了火锅但第二天肚子不舒服”抽取出来应该是事件2025-01-08 去成都事件吃了火锅事实吃火锅后肠胃不适置信度0.7因为可能是其他原因索引阶段key做倒排索引query做向量索引timestamp做时序索引。检索阶段根据当前对话意图决定走哪条索引路径。更新阶段如果新记忆和旧记忆冲突按置信度和时间戳做合并。衰减阶段给每条记忆一个“热度分”长期不被检索到的记忆逐渐降低权重。归档阶段超过一定时间的低热度记忆移到冷存储。这套机制听起来复杂但用Docker把各个组件容器化之后运维成本其实可控。后面我会讲具体怎么部署。3. 核心细节解析从记忆抽取到检索排序的完整链路3.1 记忆抽取怎么让LLM输出结构化的三元组记忆抽取的质量直接决定整个系统的上限。我的做法是给LLM一个严格的输出模板用few-shot引导它按格式输出。提示词大概长这样EXTRACTION_PROMPT 你是一个记忆抽取器。从以下对话中提取结构化记忆。 对话内容 {conversation} 请按以下JSON格式输出不要输出任何其他内容 { memories: [ { key: 命名空间:主体:属性, query: 这条记忆可能被什么意图检索到, value: 具体内容, confidence: 0.0-1.0, memory_type: episodic|semantic } ] } 规则 1. 只提取确定的事实不确定的降低confidence 2. key必须包含用户ID命名空间 3. 一条对话可能提取出多条记忆 4. 如果对话中没有值得记忆的内容返回空数组 实测下来这个提示词在GPT-4级别的模型上抽取准确率能到85%左右。剩下的15%主要是两类问题一是模型过度抽取把寒暄也当成记忆二是key的命名不规范。我的解决办法是加一层后处理校验用正则检查key的格式不合规的直接丢弃。实操心得抽取温度建议设成0不要让它发挥创造力。记忆抽取要的是稳定和准确不是多样性。3.2 检索排序为什么不能只看向量相似度检索阶段最容易犯的错就是“唯向量相似度论”。我一开始也是这么做的结果发现检索出来的记忆经常是“语义相似但实际无关”的。比如用户问“推荐个电影”向量检索可能返回“用户上次说喜欢诺兰的电影”这没问题但也可能返回“用户上次说电影院停车很难”这就跑偏了。后来我改成多路召回重排序的架构精确匹配路用当前对话的实体做key前缀匹配召回强相关记忆语义匹配路用query向量做ANN检索召回语义相关记忆时序匹配路召回最近N条记忆保证时效性重排序用一个小的cross-encoder模型对三路召回结果做统一打分重排序的打分公式我设计成final_score 0.4 * semantic_similarity 0.3 * recency_score 0.2 * confidence 0.1 * access_frequencyrecency_score用指数衰减exp(-λ * hours_since_access)λ取0.01的话大约3天后权重降到一半。access_frequency是这条记忆被检索到的次数高频记忆说明它重要。这套组合拳下来检索准确率比纯向量方案提升了大概30%。代价是多了一次重排序推理延迟增加约50ms但在可接受范围内。3.3 记忆冲突处理新信息来了旧信息怎么办这是最容易被忽略的环节。用户先说“我喜欢咖啡”后来说“我戒咖啡了”记忆系统得知道后者覆盖前者。我的处理策略是同key冲突新记忆直接覆盖旧记忆但旧记忆保留在历史版本中标记为superseded矛盾但不同key比如“喜欢咖啡”和“戒咖啡了”key不同但语义矛盾用LLM做一次冲突检测生成一条新的“状态变更”记忆置信度冲突新记忆置信度低于旧记忆时不覆盖而是追加一条“存疑”标记def resolve_conflict(old_memory, new_memory): if old_memory.key new_memory.key: # 同key直接更新保留历史 archive(old_memory) return new_memory elif is_contradictory(old_memory, new_memory): # 语义矛盾生成状态变更记忆 return create_state_change_memory(old_memory, new_memory) elif new_memory.confidence old_memory.confidence: # 新记忆置信度低标记存疑 new_memory.status pending_verification return new_memory else: return new_memory注意冲突检测不要用规则硬匹配一定要用LLM做语义判断。“戒咖啡了”和“喜欢咖啡”字面上不矛盾但语义上矛盾规则匹配搞不定。4. 实操部署用Docker把整套记忆系统跑起来4.1 环境准备与Docker安装要点这套系统我建议用Docker Compose编排因为涉及多个组件记忆存储PostgreSQL pgvector、缓存Redis、记忆服务Python FastAPI、MCP网关。先确保Docker环境正常。Windows用户装Docker Desktop时最常见的坑是“Virtualization support not detected”。这个报错的意思是BIOS里没开虚拟化。重启进BIOS找到Intel VT-x或AMD-V设为Enabled。如果开了还报错检查是不是Hyper-V和WSL2冲突了在“启用或关闭Windows功能”里把Hyper-V关掉只留WSL2。Ubuntu用户相对简单# 安装Docker curl -fsSL https://get.docker.com | sh # 把当前用户加入docker组避免每次sudo sudo usermod -aG docker $USER # 重新登录后验证 docker run hello-world实操心得国内环境拉镜像慢的话配置一下镜像加速器。但注意不要用那些来路不明的加速地址用云厂商官方提供的。4.2 docker-compose编排文件详解version: 3.8 services: postgres: image: pgvector/pgvector:pg16 environment: POSTGRES_DB: agent_memory POSTGRES_USER: memory_user POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data ports: - 5432:5432 healthcheck: test: [CMD-SHELL, pg_isready -U memory_user] interval: 10s retries: 5 redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redisdata:/data ports: - 6379:6379 memory-service: build: ./memory-service environment: DATABASE_URL: postgresql://memory_user:${DB_PASSWORD}postgres:5432/agent_memory REDIS_URL: redis://redis:6379/0 LLM_API_KEY: ${LLM_API_KEY} depends_on: postgres: condition: service_healthy redis: condition: service_started ports: - 8000:8000 mcp-gateway: build: ./mcp-gateway environment: MEMORY_SERVICE_URL: http://memory-service:8000 depends_on: - memory-service ports: - 8080:8080 volumes: pgdata: redisdata:这个编排文件里postgres用pgvector镜像直接支持向量检索省得单独装向量数据库。healthcheck确保postgres完全启动后再启动memory-service避免连接失败。4.3 数据库表结构设计CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE memories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key TEXT NOT NULL, query_vector vector(1536), value TEXT NOT NULL, memory_type TEXT CHECK (memory_type IN (episodic, semantic)), confidence FLOAT DEFAULT 1.0, source_session TEXT, access_count INT DEFAULT 0, last_accessed_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(), status TEXT DEFAULT active, superseded_by UUID REFERENCES memories(id) ); CREATE INDEX idx_memories_key ON memories(key); CREATE INDEX idx_memories_vector ON memories USING ivfflat (query_vector vector_cosine_ops) WITH (lists 100); CREATE INDEX idx_memories_created ON memories(created_at DESC);ivfflat索引的lists参数经验值是数据量的平方根。10万条记忆的话lists设316左右。数据量小的时候1万不建向量索引反而更快因为索引本身有开销。4.4 MCP协议集成让Agent通过标准协议访问记忆MCPModel Context Protocol是当前Agent工具调用的事实标准。把记忆系统封装成MCP Server好处是任何支持MCP的Agent框架都能直接接入不用改代码。MCP Server的核心是暴露几个工具from mcp.server import Server, NotificationOptions from mcp.server.models import InitializationOptions server Server(hindsight-memory) server.tool() async def store_memory(key: str, value: str, query: str, memory_type: str semantic, confidence: float 1.0) - str: 存储一条记忆 memory_id await memory_service.store( keykey, valuevalue, queryquery, memory_typememory_type, confidenceconfidence ) return fMemory stored: {memory_id} server.tool() async def retrieve_memories(query: str, top_k: int 5) - str: 根据查询检索相关记忆 memories await memory_service.retrieve(query, top_k) return format_memories(memories) server.tool() async def forget_memory(key: str) - str: 删除指定记忆 await memory_service.delete(key) return fMemory deleted: {key}Agent侧只需要配置MCP Server地址就能自动发现这三个工具。我实测过Claude Desktop和几个开源Agent框架接入都很顺。注意MCP Server的token认证一定要做。热词里那个wss://api.xiaozhi.me/mcp/?token...的格式就是典型做法token放在URL参数或Header里。生产环境建议用HeaderURL参数容易在日志里泄露。5. 常见问题与排查技巧实录5.1 记忆检索不准的排查思路这是最高频的问题。排查顺序我总结成一张表现象可能原因排查方法解决检索不到任何记忆向量维度不匹配检查embedding模型输出维度与表定义是否一致统一维度重建索引检索到无关记忆query向量质量差打印query向量看是否和预期语义一致换embedding模型或加query改写检索结果排序不合理重排序权重失衡打印各路召回分数调整权重系数新记忆检索不到索引未更新检查ivfflat索引是否需要重建定期REINDEX记忆重复抽取阶段重复写入查key是否有唯一约束加唯一索引或去重逻辑我遇到最坑的一次是embedding模型换了从text-embedding-ada-002换到text-embedding-3-small维度从1536变成15363-small默认也是1536但可以调结果旧记忆的向量和新query的向量不在同一空间检索全乱。后来加了模型版本字段换模型时自动触发全量重embedding。5.2 Docker网络不通的经典场景Docker Compose里服务之间通信用服务名不是localhost。我见过有人把DATABASE_URL写成postgresql://user:passlocalhost:5432/db在容器里localhost指向容器自己当然连不上。正确写法是postgres:5432postgres是compose里的服务名。另一个坑是端口映射。ports: - 5432:5432是把容器端口映射到宿主机容器之间通信不需要这个映射直接用服务名容器端口就行。映射多了反而容易和宿主机已有服务冲突。如果确实需要从宿主机访问容器内的服务做调试用docker exec -it container_name bash进去查比映射端口更安全。5.3 记忆膨胀导致性能下降跑了一段时间后记忆表可能涨到几十万条检索延迟从50ms涨到500ms。我的处理策略冷热分离超过30天未被访问的记忆移到memories_archive表主表只保留热数据向量索引调优ivfflat的probes参数查询时设置SET ivfflat.probes 10在召回率和速度之间平衡定期归档写个定时任务每天凌晨跑一次归档压缩低频记忆多条相似的低频记忆合并成一条摘要记忆-- 归档30天未访问的记忆 INSERT INTO memories_archive SELECT * FROM memories WHERE last_accessed_at NOW() - INTERVAL 30 days AND access_count 3; DELETE FROM memories WHERE last_accessed_at NOW() - INTERVAL 30 days AND access_count 3;实操心得归档前一定要备份。我有次手抖把WHERE条件写错了差点把活跃记忆全删了。现在归档脚本里强制加LIMIT 1000分批处理每批确认后再继续。5.4 MCP工具调用超时的处理MCP协议本身有超时机制但记忆检索如果走了LLM做query改写延迟可能超过默认超时。我的做法是记忆检索的MCP工具设置独立超时比默认值长query改写做成可选的简单查询直接走向量检索加缓存相同query在5分钟内直接返回缓存结果from functools import lru_cache import hashlib lru_cache(maxsize1000) def cached_retrieve(query_hash: str): # 实际检索逻辑 pass async def retrieve(query: str, top_k: int): query_hash hashlib.md5(f{query}:{top_k}.encode()).hexdigest() return cached_retrieve(query_hash)缓存命中率在实际场景里能到40%左右因为用户的追问往往语义相近。6. 记忆系统的安全防护与未来扩展6.1 记忆投毒与防御思路Agent记忆系统有一个容易被忽视的攻击面记忆投毒。如果攻击者能往记忆里写入虚假信息比如“用户说他的密码是xxx”后续Agent就可能泄露敏感信息。热词里提到的“a-memguard”就是针对这个问题的主动防御框架。我的防御策略分三层第一层写入校验。所有记忆写入前过一遍敏感信息检测密码、身份证号、银行卡号这类直接拒绝写入。用正则LLM双重检测。第二层来源可信度。每条记忆记录来源会话的可信等级。用户直接输入的可信度高Agent从网页抓取的可信度低。检索时低可信记忆降权。第三层异常检测。监控记忆写入频率如果某个会话突然写入大量记忆触发人工审核。正常对话每分钟写入1-3条记忆超过10条就可疑。SENSITIVE_PATTERNS [ r\b\d{16,19}\b, # 银行卡号 r\b\d{17}[\dXx]\b, # 身份证号 rpassword\s*[:]\s*\S, # 密码 ] def is_sensitive(content: str) - bool: for pattern in SENSITIVE_PATTERNS: if re.search(pattern, content, re.IGNORECASE): return True return False6.2 多Agent共享记忆的隔离设计如果一个系统里有多个Agent记忆要不要共享我的建议是默认隔离按需共享。每个Agent有自己的命名空间共享记忆放在公共命名空间通过显式授权访问。命名空间设计agent:{agent_id}:private:{key} # 私有记忆 shared:{domain}:{key} # 共享记忆 user:{user_id}:{key} # 用户级记忆跨Agent共享检索时Agent默认只能查自己的私有记忆和用户级记忆共享记忆需要显式声明。这样既保证了隔离性又支持协作场景。6.3 后续可以扩展的方向这套系统跑通之后有几个方向可以继续深挖。一是记忆的可视化做一个Web界面让用户能看到Agent记住了什么还能手动编辑和删除这对建立用户信任很重要。二是记忆的跨模态扩展现在只存文本未来可以存图片、音频的embedding让Agent记住用户发过的图片。三是记忆的联邦学习多个部署实例之间在不共享原始数据的前提下共享记忆的统计规律提升整体检索质量。我个人最看好的方向是记忆可视化。现在Agent的记忆是个黑盒用户不知道它记住了什么、忘了什么出了问题也没法排查。把记忆变成用户可感知、可控制的东西才是Agent真正走向实用的关键一步。我下一个项目就打算做这个到时候再跟大家分享。
返回列表