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

发布时间:2026/10/4 14:06:35
基于MCP与pgvector构建LLM Agent长期记忆系统实战 1. 从“hindsight”说起为什么Agent的记忆问题值得单独拎出来做“hindsight”这个词本身很有意思字面意思是“事后的洞察力”也就是我们常说的“后见之明”。放在Agent Memory这个语境下它指向一个非常具体且长期被低估的问题当一个LLM Agent完成了一轮任务之后它能不能从刚才的交互中真正“学到东西”并在下一次遇到类似场景时表现得更好大多数人对Agent的记忆理解还停留在“把聊天记录塞进上下文”这个层面。但做过实际项目的人都知道上下文窗口再大也有上限而且把全部历史对话无差别地塞进去不仅浪费token还会引入大量噪声导致模型在关键决策点上被无关信息干扰。hindsight要解决的核心矛盾就在这里——它不是简单地做“存储”而是做“筛选、压缩、索引和召回”这一整套链路。我最初接触这个方向是在做一个多轮工具调用的Agent项目时。当时遇到一个非常典型的问题Agent在第一轮对话中已经通过某个API查到了用户的偏好设置但到了第五轮当用户提出一个相关联的请求时Agent完全“忘了”之前查到的信息又重新调用了一次API。这不仅浪费了调用配额还让整个交互显得非常笨拙。后来我意识到问题的根源不在于模型能力不够而在于我们没有给Agent建立一个有效的记忆机制。hindsight这个项目标题所代表的方向正是围绕Agent Memory构建一套可落地的工程方案。它涉及的核心技术点包括记忆的写入策略什么时候存、存什么、记忆的存储结构用什么数据库、怎么组织索引、记忆的召回机制怎么在需要的时候精准取出来、以及记忆的更新与淘汰策略旧信息怎么处理。这些环节环环相扣任何一个环节设计不好整个记忆系统就会变成一个“垃圾进、垃圾出”的摆设。这篇文章适合几类人看一是正在做LLM Agent开发、被多轮对话一致性问题困扰的工程师二是对MCP协议感兴趣、想把记忆能力做成标准化服务的开发者三是想用Docker快速搭建一套Agent Memory实验环境的技术爱好者。我会从整体设计思路讲到具体实操把踩过的坑和验证过的方案都摊开来说。2. 整体设计思路Agent Memory不是“聊天记录数据库”2.1 为什么传统方案不够用很多人第一反应是记忆嘛不就是把对话存到数据库里下次用的时候查出来拼到prompt里这个思路在简单场景下能跑通但一旦Agent的任务复杂度上来就会暴露三个致命问题。第一个问题是检索粒度太粗。一整段对话记录里可能只有一两句话是真正有价值的但你把整段都召回回来模型需要自己从噪声里找信号。这就好比你去图书馆找一本书管理员直接把整个书架搬到你面前让你自己翻。第二个问题是缺乏结构化索引。对话记录是线性的、时序的但Agent在实际任务中需要的是按“实体”“意图”“任务类型”等维度来检索记忆。比如用户之前提到过“我住在杭州”这条信息应该被索引到“用户位置”这个维度下而不是埋在第三轮对话的第七句话里。第三个问题是没有遗忘机制。人的记忆会自动淘汰过时信息但大多数Agent的记忆系统是只增不减的。时间一长数据库里堆满了过期信息召回时反而干扰判断。2.2 hindsight的核心设计原则基于上面这些问题hindsight的设计思路可以归纳为三条核心原则。原则一写入时做压缩而不是读取时做过滤。当一轮交互结束时系统应该立即对这次交互的内容进行摘要和结构化提取把原始对话转化成更紧凑的记忆单元。这样做的好处是存储成本降低召回时也不需要再做额外的过滤处理。具体来说可以用一个轻量级的LLM调用来完成这个压缩过程把“用户说他下周要去北京出差希望推荐几个适合商务宴请的餐厅”压缩成{实体: 用户, 意图: 出差计划, 地点: 北京, 需求: 商务宴请餐厅推荐, 时间: 下周}这样的结构化条目。原则二多维度索引按需召回。记忆单元存储时要同时建立多个索引维度。常见的有时间索引什么时候产生的、实体索引涉及哪些人/物/地点、任务索引属于哪类任务、语义索引向量相似度。召回时根据当前上下文选择最合适的索引路径而不是只用一种方式。原则三记忆有生命周期。每条记忆都应该有一个“新鲜度”评分随着时间推移和未被召回次数的增加而衰减。当评分低于阈值时记忆被归档或删除。这个机制可以防止数据库无限膨胀也能确保召回时优先返回最相关的信息。2.3 与MCP协议的关系MCPModel Context Protocol在这里扮演的角色是标准化接口层。如果没有MCP每个Agent框架都要自己定义一套记忆读写的API换一个框架就得重写一遍。有了MCP之后记忆系统可以作为一个独立的Server运行任何支持MCP协议的Client都可以通过标准化的方式调用记忆的写入和查询能力。这带来的好处是显而易见的你可以用Docker把记忆服务打包成一个容器Agent端只需要配置一个MCP Server的地址就能接入。记忆的存储后端、索引策略、压缩算法都可以在Server端独立升级不影响Agent本身的逻辑。这种解耦设计在实际项目中非常关键因为记忆系统的迭代频率往往比Agent主体更高。3. 核心细节解析记忆的写入、存储与召回3.1 记忆写入什么时候存、存什么、怎么存记忆写入的触发时机很关键。我的经验是不要在每一轮对话结束后都触发写入那样会产生大量碎片化的低价值记忆。更好的做法是设置几个触发条件当对话中出现明确的事实性信息时如用户告知了某个偏好、某个时间安排、某个约束条件当Agent完成了一个完整的任务单元时如成功调用了一系列工具完成了一个查询当用户明确表示**“记住这个”**或类似意图时当对话轮次达到一定数量如每5轮时做一次批量压缩写入写入的内容不是原始对话文本而是经过压缩的结构化记忆单元。我通常用这样一个JSON结构来组织{ memory_id: mem_20250101_001, timestamp: 2025-01-01T10:30:00Z, entities: [用户, 北京, 商务宴请], intent: 出差计划, summary: 用户下周要去北京出差需要商务宴请餐厅推荐, raw_context: 原始对话片段可选用于追溯, freshness_score: 1.0, recall_count: 0, tags: [出差, 餐饮, 北京] }这个结构里entities和tags用于建立倒排索引summary用于向量化后做语义检索freshness_score和recall_count用于生命周期管理。注意压缩过程本身会消耗一次LLM调用所以要在压缩质量和调用成本之间做权衡。我的做法是对于事实性信息用规则提取正则关键词匹配对于复杂的任务总结才调用LLM。3.2 存储选型为什么我最终选了PostgreSQLpgvector存储后端的选择上我试过几种方案。纯向量数据库如Milvus、Qdrant在语义检索上很强但缺乏结构化查询能力纯关系型数据库如MySQL结构化查询没问题但向量检索需要额外插件Redis适合做缓存但持久化能力偏弱。最终我选择的是PostgreSQL pgvector扩展的组合。原因有三第一pgvector提供了足够的向量检索能力支持IVFFlat和HNSW索引对于百万级记忆条目的场景完全够用第二PostgreSQL本身的关系型查询能力可以支撑实体索引、时间范围查询等结构化检索需求第三运维成本低一个数据库实例搞定所有需求不需要维护多套存储系统。如果你用Docker来部署一条命令就能拉起带pgvector的PostgreSQLdocker run -d \ --name agent-memory-db \ -e POSTGRES_PASSWORDyourpassword \ -e POSTGRES_DBagent_memory \ -p 5432:5432 \ -v memory_data:/var/lib/postgresql/data \ pgvector/pgvector:pg16建表语句大致如下CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE memories ( id SERIAL PRIMARY KEY, memory_id VARCHAR(64) UNIQUE NOT NULL, timestamp TIMESTAMPTZ DEFAULT NOW(), entities TEXT[], intent VARCHAR(128), summary TEXT, embedding vector(1536), freshness_score FLOAT DEFAULT 1.0, recall_count INT DEFAULT 0, tags TEXT[] ); CREATE INDEX idx_entities ON memories USING GIN(entities); CREATE INDEX idx_tags ON memories USING GIN(tags); CREATE INDEX idx_embedding ON memories USING hnsw (embedding vector_cosine_ops);3.3 召回机制多路召回重排序召回是整个记忆系统里最考验设计功力的环节。单一召回策略很难覆盖所有场景我的方案是多路召回统一重排序。多路召回包括三条路径第一条是实体匹配召回根据当前对话中出现的实体去entities字段里做精确匹配第二条是语义相似召回把当前对话的query向量化用pgvector做余弦相似度检索第三条是时间衰减召回对于近期产生的记忆给予更高的基础权重。三路召回各自返回一批候选记忆后用一个统一的重排序函数来打分final_score w1 * entity_match_score w2 * semantic_similarity w3 * freshness_score w4 * (1 / (1 recall_count))权重系数需要根据你的具体场景来调。我的经验是对于任务型Agentw1实体匹配给0.4w2语义相似给0.3w3新鲜度给0.2w4召回惩罚给0.1这个配比在多数场景下表现比较均衡。实操心得召回数量不要贪多。我一开始设置top_k20结果发现大量低分记忆反而干扰了模型判断。后来改成top_k5并且设置了一个最低分数阈值0.6效果明显更好。宁可少召回几条高相关的也不要塞一堆边缘相关的。4. 实操过程用Docker搭建一套完整的Agent Memory服务4.1 环境准备与依赖安装这套方案对环境的依赖不算复杂核心就是Docker和Python运行时。我假设你用的是Windows 11或者macOSLinux环境更简单直接跳过Docker Desktop的安装部分即可。Windows 11上安装Docker Desktop有几个坑需要注意。首先WSL2后端是必须的安装过程中如果提示“virtualization support not detected”大概率是BIOS里的虚拟化开关没打开。重启进BIOS找到Intel VT-x或AMD-V选项设为Enabled。其次安装完成后建议在Docker Desktop设置里把资源限制调一下默认的2GB内存跑PostgreSQLpgvector会有点吃力调到4GB以上比较稳妥。macOS上就简单很多下载Docker Desktop的dmg包拖进Applications启动后等鲸鱼图标稳定即可。Apple Silicon芯片的机器要注意选择arm64版本的镜像不过pgvector官方镜像已经支持多架构直接pull就行。Python端需要安装的依赖pip install psycopg2-binary pgvector openai mcp fastapi uvicorn这里mcp包是MCP协议的Python SDK用来把我们的记忆服务暴露成标准的MCP Server。openai包用于调用embedding模型如果你用的是其他厂商的模型替换成对应的SDK即可。4.2 记忆服务的核心代码实现先写记忆写入的逻辑。核心思路是接收一段对话文本调用LLM做结构化提取生成记忆单元然后写入PostgreSQL。import json from openai import OpenAI from pgvector.psycopg2 import register_vector import psycopg2 client OpenAI(api_keyyour-api-key) def extract_memory(dialogue_text): prompt f从以下对话中提取关键记忆信息以JSON格式返回 对话内容{dialogue_text} 返回格式 {{ entities: [实体1, 实体2], intent: 意图分类, summary: 一句话总结, tags: [标签1, 标签2] }} 只返回JSON不要其他内容。 response client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], temperature0 ) return json.loads(response.choices[0].message.content) def get_embedding(text): response client.embeddings.create( modeltext-embedding-3-small, inputtext ) return response.data[0].embedding def write_memory(conn, dialogue_text): memory_data extract_memory(dialogue_text) embedding get_embedding(memory_data[summary]) with conn.cursor() as cur: cur.execute( INSERT INTO memories (memory_id, entities, intent, summary, embedding, tags) VALUES (%s, %s, %s, %s, %s, %s) , ( fmem_{int(time.time()*1000)}, memory_data[entities], memory_data[intent], memory_data[summary], embedding, memory_data[tags] )) conn.commit()再写召回逻辑。三路召回分别用SQL实现然后在Python层做融合排序。def recall_memories(conn, query_text, top_k5): query_embedding get_embedding(query_text) with conn.cursor() as cur: # 语义召回 cur.execute( SELECT memory_id, summary, entities, tags, 1 - (embedding %s::vector) AS similarity, freshness_score, recall_count FROM memories ORDER BY embedding %s::vector LIMIT 20 , (query_embedding, query_embedding)) semantic_results cur.fetchall() # 实体召回简化示例实际需要先提取query中的实体 cur.execute( SELECT memory_id, summary, entities, tags, 0.8 AS similarity, freshness_score, recall_count FROM memories WHERE entities %s LIMIT 20 , (query_entities,)) entity_results cur.fetchall() # 融合排序 all_results {} for row in semantic_results entity_results: mid row[0] if mid not in all_results: all_results[mid] row scored [] for mid, row in all_results.items(): score 0.4 * row[4] 0.3 * row[4] 0.2 * row[5] 0.1 * (1/(1row[6])) scored.append((score, row)) scored.sort(keylambda x: x[0], reverseTrue) return [s[1] for s in scored[:top_k]]4.3 用MCP协议暴露记忆服务MCP Server的实现需要定义一个工具列表让Client知道有哪些能力可以调用。核心工具就两个write_memory和recall_memory。from mcp.server import Server from mcp.types import Tool, TextContent app Server(agent-memory) app.list_tools() async def list_tools(): return [ Tool( namewrite_memory, description将一段对话内容写入长期记忆, inputSchema{ type: object, properties: { dialogue: {type: string, description: 对话文本} }, required: [dialogue] } ), Tool( namerecall_memory, description根据查询文本召回相关记忆, inputSchema{ type: object, properties: { query: {type: string, description: 查询文本}, top_k: {type: integer, default: 5} }, required: [query] } ) ] app.call_tool() async def call_tool(name, arguments): if name write_memory: write_memory(conn, arguments[dialogue]) return [TextContent(typetext, text记忆已写入)] elif name recall_memory: results recall_memories(conn, arguments[query], arguments.get(top_k, 5)) return [TextContent(typetext, textjson.dumps(results, ensure_asciiFalse))]把整个服务用Docker Compose编排起来一个文件搞定数据库和记忆服务version: 3.8 services: memory-db: image: pgvector/pgvector:pg16 environment: POSTGRES_PASSWORD: yourpassword POSTGRES_DB: agent_memory ports: - 5432:5432 volumes: - memory_data:/var/lib/postgresql/data memory-server: build: . ports: - 8080:8080 depends_on: - memory-db environment: DATABASE_URL: postgresql://postgres:yourpasswordmemory-db:5432/agent_memory volumes: memory_data:启动命令就一句docker compose up -d4.4 与Agent端的对接测试服务跑起来之后用一个简单的测试脚本来验证整条链路import asyncio from mcp import ClientSession, StdioServerParameters async def test(): async with ClientSession(...) as session: # 写入记忆 await session.call_tool(write_memory, { dialogue: 用户说他下周三要去上海参加一个行业会议需要推荐会场附近的酒店 }) # 召回记忆 result await session.call_tool(recall_memory, { query: 用户出差住哪里 }) print(result) asyncio.run(test())如果一切正常你应该能看到召回的记忆里包含了“上海”“行业会议”“酒店推荐”这些关键信息。这说明整条链路——从对话压缩、向量化、存储到多路召回——都跑通了。5. 常见问题与排查技巧实录5.1 记忆召回不准确怎么办这是最常见的问题。表现是明明数据库里有相关记忆但召回时就是排不到前面。排查思路按优先级来先检查embedding模型是否一致。写入时用的text-embedding-3-small召回时也必须用同一个模型。不同模型的向量空间不兼容混用会导致相似度计算完全失效。我踩过这个坑换了模型之后忘了同步更新召回端结果召回结果乱七八糟。再检查摘要质量。如果写入时的summary提取得太笼统比如把“用户下周三去上海出差”压缩成“用户有出行计划”那语义检索时自然匹配不上“上海酒店”这样的query。解决办法是优化提取prompt要求summary必须保留具体的时间、地点、人物等关键要素。最后检查权重配置。如果实体召回的结果总是被语义召回的结果挤下去说明w1给低了。可以先把w2设为0只用实体召回测试一下确认实体索引本身没问题再逐步调权重。5.2 Docker环境下的网络问题用Docker Compose编排时memory-server容器访问memory-db容器连接地址要用服务名memory-db而不是localhost。这个坑很基础但很容易犯因为在本机直接跑Python脚本时用的是localhost:5432搬到容器里就忘了改。另一个常见问题是端口冲突。如果本机已经装了PostgreSQL占用5432端口Docker映射时会报错。解决办法是把映射端口改成5433:5432然后连接时用5433。排查技巧进入容器内部测试网络连通性docker exec -it memory-server bash然后ping memory-db或者nc -zv memory-db 5432。如果连不通说明是Docker网络配置问题检查两个服务是否在同一个network下。5.3 记忆膨胀与性能下降跑了一段时间之后如果发现召回速度变慢大概率是记忆条目太多了。这时候需要做两件事一是启用生命周期管理定期清理freshness_score低于阈值的记忆二是检查索引是否生效用EXPLAIN ANALYZE看一下查询计划确认走了HNSW索引而不是全表扫描。清理策略我一般这样设置freshness_score每天衰减5%recall_count每被召回一次就1并重置衰减计时。当freshness_score低于0.2且超过30天未被召回时移到归档表或直接删除。问题现象可能原因排查方法解决方案召回结果不相关embedding模型不一致检查写入和召回用的模型名统一使用同一embedding模型召回结果为空相似度阈值过高打印原始相似度分数降低阈值或增加top_k写入速度慢LLM调用延迟高统计每次写入耗时改用规则提取或批量写入数据库连接失败容器网络不通容器内ping数据库服务名检查Docker network配置查询变慢索引未生效EXPLAIN ANALYZE重建HNSW索引或调整参数5.4 MCP Client连接失败的排查如果你用的是某个支持MCP的Agent框架配置了Server地址但连不上先确认Server是否正常监听。docker logs memory-server看有没有报错curl http://localhost:8080/health看健康检查接口是否返回正常。还有一个容易忽略的点是MCP的传输方式。目前主流支持的是stdio和SSE两种如果你用的是SSE方式确保Server端启用了对应的endpointClient端的URL路径要匹配。stdio方式则要求Server进程由Client拉起不能独立运行。6. 记忆系统的扩展方向与个人经验这套基础方案跑通之后有几个方向可以继续深挖。一个是记忆的跨Agent共享多个Agent可以读写同一个记忆池实现团队级别的知识积累。另一个是记忆的主动遗忘不只是被动衰减而是让Agent自己判断哪些记忆已经过时主动发起删除。还有一个是记忆的可解释性当Agent做出某个决策时能追溯到是哪几条记忆影响了这个决策这在调试和审计场景下非常有价值。我在实际项目里最大的体会是记忆系统的价值不在于存了多少而在于召回得准不准。一开始我总想着把所有东西都存下来生怕漏掉什么结果反而导致召回质量下降。后来把写入策略收紧只存真正有长期价值的信息召回准确率反而上去了。这个道理跟人做笔记一样什么都记等于什么都没记关键是记下那些以后真正会用到的。另外一个小技巧是在记忆的summary里保留原始对话中的关键原话而不是完全用自己的话转述。因为用户后续的query往往会用跟原话相似的表达保留原话能提高语义匹配的命中率。比如用户说“帮我订一个安静点的餐厅”summary里就保留“安静”这个词而不是转述成“用户偏好低噪音环境”。