多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Agent记忆系统实战:从失忆到跨会话长期记忆的工程化落地

Agent记忆系统实战:从失忆到跨会话长期记忆的工程化落地 1. 为什么“记忆”才是Agent落地的真正门槛1.1 从一次翻车现场说起去年冬天我接了个私活帮一家做跨境电商的朋友搭一套客服Agent。需求听起来简单得不行接上他们的订单系统客户问“我上周买的那个蓝色杯子到哪了”Agent能查出来并回复。我用了当时最顺手的LLM框架工具调用配好提示词打磨了两天测试环境里跑得飞起。上线第一天前二十条对话完美第二十一条开始出问题——客户问“那它什么时候能到”Agent一脸茫然地回了一句“请问您指的是什么商品”。它把上一轮的“蓝色杯子”忘得一干二净。那一刻我才真正理解为什么圈子里有句话叫“Agent的瓶颈从来不是模型智商而是记忆”。LLM本身是无状态的每次调用都是一张白纸你给它多少上下文它就知道多少事。所谓“多轮对话”本质上是把历史消息一遍遍塞回prompt里。短对话还行一旦轮次上去、工具调用穿插进来、用户还时不时切换话题上下文窗口就开始爆炸要么超token被截断要么成本高到肉疼要么模型被一堆无关历史干扰得抓不住重点。这就是“hindsight”这个项目标题吸引我的地方。Hindsight事后之明、后见之明。放在Agent语境里它指向一个非常具体的能力让Agent在事情发生之后能够回看、检索、利用过去发生过的一切——不只是当前会话而是跨会话、跨任务、跨时间的长期记忆。这不是简单的“把聊天记录存数据库”而是一整套关于记忆如何写入、如何组织、如何检索、如何遗忘的工程体系。1.2 这篇东西适合谁看如果你正在做Agent产品被“聊几句就失忆”折磨过这篇值得往下读。如果你只是好奇LLM应用怎么落地想搞明白“agent memory”到底是个什么玩意儿也能看懂我会尽量少堆术语。如果你已经在用MCP、Docker这些工具搭环境那更好后面有大量可直接抄的配置。我打算把hindsight拆成四块讲先讲整体设计思路为什么记忆系统不能简单等同于向量数据库再讲核心细节包括记忆的分层、写入时机、检索策略然后是实操用Docker把环境跑起来接上MCP让一个Agent真正拥有跨会话记忆最后是我踩过的坑和排查技巧。全程按我自己的实践来不保证是唯一解但保证是跑通过的。2. 记忆系统的整体设计与选型思路2.1 别把记忆等同于“向量库相似度搜索”刚接触agent memory的人第一反应往往是“上个向量数据库不就行了”。我一开始也这么想拿embedding把每轮对话存进去用户提问时做相似度检索把top-k塞回上下文。跑了两天就发现不对劲。问题出在“相似”和“相关”是两码事。用户说“帮我改一下昨天那个方案”向量检索会去找和这句话语义相近的历史片段但真正需要的是“昨天那个方案”具体指哪份文档、改了什么。语义相似度抓不住这种指代关系和时间锚点。更麻烦的是向量检索没有“重要性”概念用户随口说的“今天天气不错”和“我的收货地址是XXX”在embedding空间里可能离得很近但后者才是必须记住的。所以hindsight这类系统的第一层设计是把记忆拆成不同类型分别用不同机制处理。我实践下来至少得分三层工作记忆working memory当前会话的短期上下文就是那几十轮对话直接放prompt里追求低延迟。情景记忆episodic memory发生过的事件带时间戳、参与者、结果。比如“2024年3月5日用户A询问订单12345的物流”。这类记忆靠结构化存储加时间索引检索时优先按实体和时间过滤。语义记忆semantic memory从多次交互中提炼出的稳定事实。比如“用户A的默认收货地址是杭州”“用户A偏好顺丰”。这类才是向量库的主场但写入前必须经过提炼和去重。这个分层不是拍脑袋来的它对应认知科学里人类记忆的基本分类。你想想自己是怎么记事的刚说的话在脑子里转工作记忆上周干了啥能回忆起来情景记忆你妈叫什么名字这种事实张口就来语义记忆。Agent要像人一样“记得住”就得有类似的结构。2.2 为什么选MCP做记忆的接入层确定了分层下一个问题是记忆系统怎么和Agent对接传统做法是在Agent代码里硬编码一堆数据库调用写记忆、读记忆、更新记忆全耦合在业务逻辑里。这样做的后果是换个Agent框架就得重写一遍记忆逻辑和业务逻辑搅在一起调试起来想死。MCPModel Context Protocol在这里的价值就体现出来了。它本质上是给LLM应用定义了一套标准化的“工具/资源”接口。你可以把记忆系统封装成一个MCP Server对外暴露几个工具write_memory、search_memory、update_memory、forget_memory。Agent那边只要支持MCP就能通过标准协议调用这些工具完全不用关心底层是Postgres还是Redis还是向量库。我选MCP还有两个实际原因。一是解耦记忆服务可以独立部署、独立扩容、独立升级Agent挂了不影响记忆记忆库迁移不影响Agent。二是复用同一个记忆服务可以同时给多个Agent用比如客服Agent和推荐Agent共享用户画像这在硬编码方案里几乎做不到。提示MCP是协议不是框架别指望它帮你解决记忆策略问题。它只负责“怎么调”不负责“什么时候调、调什么”。策略层还是得自己设计。2.3 Docker在这套体系里的角色有人会问搭个记忆系统为什么非要提Docker。我的答案是因为记忆系统天然是多组件的。你要一个关系库存结构化情景记忆要一个向量库存语义记忆要一个缓存层做工作记忆的快速读写可能还要一个消息队列处理异步的记忆写入。这么多组件手工装环境能装到你怀疑人生尤其是Windows上装MySQL、Redis那一套版本冲突、端口占用、依赖缺失随便一个都能耗掉半天。Docker Compose把这些组件编排成一个文件一条命令全起来。更重要的是环境一致性——你本地跑通的配置原样丢到服务器上就能跑不会出现“我这儿好好的怎么你那儿报错”。对于记忆系统这种需要长期稳定运行的服务这点太重要了。我后面给的实操方案就是用Docker Compose把Postgres带pgvector扩展、Redis、记忆服务本体一起编排起来。你只要有Docker Desktop复制粘贴就能跑。3. 核心细节记忆的写入、检索与遗忘3.1 写入时机比写入内容更关键很多人做记忆系统第一反应是“每轮对话都存”。我试过结果是记忆库迅速膨胀检索质量断崖式下跌。原因很简单大部分对话内容是噪音。“好的”“嗯嗯”“谢谢”这种存进去只会稀释真正有用的记忆。我的做法是设置写入触发器只在特定条件下才写记忆显式指令用户说“记住”“帮我记一下”“以后都这样”直接触发写入优先级最高。实体出现对话中出现了新的实体人名、地址、订单号、偏好且该实体在已有记忆中没有记录触发写入。任务完成一个多步任务结束时把任务的目标、过程、结果打包成一条情景记忆写入。会话结束会话关闭时对整段对话做一次摘要提炼把值得长期保留的语义记忆抽出来写入。这套触发器机制我实测下来记忆库的增长速度比“全量写入”慢了大概一个数量级但检索命中率反而更高因为库里全是干货。写入的时候还有个细节一定要带元数据。时间戳、会话ID、用户ID、来源是用户说的还是Agent推断的、置信度。这些元数据在检索时是过滤的第一道闸门。没有元数据的记忆检索时只能靠语义相似度硬扛效果差很多。3.2 检索策略先过滤再排序最后重排检索是记忆系统里最容易做砸的环节。我见过太多方案就是“embedding cosine similarity top-k”然后抱怨Agent记不住事。问题在于纯语义检索忽略了太多信号。我现在用的检索流程分三步第一步硬过滤。用元数据把候选集缩小。比如当前用户是A就只检索user_idA的记忆当前话题涉及订单就优先过滤出带订单实体的记忆如果对话里提到了具体时间就按时间范围过滤。这一步能把候选集从几万条砍到几百条而且砍掉的都是明显不相关的。第二步多路召回。不要只靠向量检索。我同时跑三路向量相似度召回、关键词BM25召回、实体图谱召回如果记忆里建了实体关系。三路结果合并去重保证既不漏掉语义相近的也不漏掉字面匹配的。第三步重排。用一个轻量级的重排模型或者干脆用LLM做judge对合并后的候选做精排。重排时考虑的因素包括时间衰减越近的记忆权重越高、重要性分数、与当前query的语义相关度、是否被多次引用过。这一步是提升精度的关键实测能把top-5的命中率从60%多拉到85%以上。注意重排会引入额外延迟。如果对响应速度要求极高可以只在候选集较大时才启用重排候选集小的时候直接按规则排序。3.3 遗忘机制不删记忆的系统迟早会崩这是最容易被忽略、但最重要的一环。人的记忆会自然遗忘Agent的记忆如果只增不减迟早会变成一个巨大的垃圾场。检索质量下降、存储成本上升、隐私风险累积全是问题。我的遗忘策略分三种TTL过期工作记忆和低置信度的情景记忆设置生存时间比如7天没被访问过就自动清理。重要性淘汰给每条记忆打重要性分数分数低的在容量达到阈值时优先淘汰。重要性怎么算我用的公式是重要性 访问频次 × 0.4 用户显式标记 × 0.4 时效性 × 0.2。访问频次高说明这条记忆有用用户显式标记比如“这个很重要”权重最高时效性保证新记忆有优势。合并压缩多条相似的情景记忆可以合并成一条语义记忆。比如用户三次提到喜欢某个品牌就合并成“用户偏好该品牌”这一条语义记忆原始情景记忆可以归档或删除。遗忘不是bug是feature。一个会遗忘的Agent反而更像人也更可靠。4. 实操用Docker把hindsight记忆服务跑起来4.1 环境准备与Docker Compose编排先说环境。我用的是Windows 11 Docker DesktopMac和Linux同理。装Docker Desktop的时候如果报“virtualization support not detected”去BIOS里把虚拟化打开Intel的叫VT-xAMD的叫SVM不同主板位置不一样自己搜一下型号。装完之后在终端敲docker --version和docker compose version都有输出就说明OK。接下来是编排文件。我把它命名为docker-compose.yml放在项目根目录version: 3.8 services: postgres: image: pgvector/pgvector:pg16 container_name: hindsight-postgres environment: POSTGRES_USER: hindsight POSTGRES_PASSWORD: hindsight123 POSTGRES_DB: memory ports: - 5432:5432 volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U hindsight] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine container_name: hindsight-redis ports: - 6379:6379 volumes: - redisdata:/data command: redis-server --appendonly yes memory-service: build: ./memory-service container_name: hindsight-memory depends_on: postgres: condition: service_healthy redis: condition: service_started environment: DATABASE_URL: postgresql://hindsight:hindsight123postgres:5432/memory REDIS_URL: redis://redis:6379/0 EMBEDDING_MODEL: text-embedding-3-small ports: - 8080:8080 volumes: pgdata: redisdata:这里选pgvector/pgvector:pg16而不是官方postgres镜像是因为它预装了pgvector扩展省得自己编译。Redis用alpine版体积小启动快开了AOF持久化保证重启不丢数据。memory-service是我自己写的记忆服务下面会给核心代码。4.2 记忆服务的核心实现记忆服务我用Python写FastAPI做HTTP接口同时通过MCP协议暴露工具。核心是三个模块写入、检索、遗忘。先看数据库表结构这是整个系统的地基CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE memories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id VARCHAR(64) NOT NULL, session_id VARCHAR(64), memory_type VARCHAR(20) NOT NULL, -- working/episodic/semantic content TEXT NOT NULL, embedding vector(1536), metadata JSONB DEFAULT {}, importance FLOAT DEFAULT 0.5, access_count INT DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), last_accessed_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP ); CREATE INDEX idx_memories_user ON memories(user_id); CREATE INDEX idx_memories_type ON memories(memory_type); CREATE INDEX idx_memories_embedding ON memories USING ivfflat (embedding vector_cosine_ops) WITH (lists 100);embedding字段维度1536对应text-embedding-3-small。ivfflat索引的lists参数经验值是数据量的平方根初期数据少设100够用数据上百万了再调。写入逻辑的关键是触发器判断def should_write(content, context): # 显式指令 if any(kw in content for kw in [记住, 帮我记, 以后都]): return True, explicit # 实体检测 entities extract_entities(content) if entities and not all(entity_exists(e) for e in entities): return True, entity # 任务完成标记 if context.get(task_completed): return True, task return False, None检索逻辑就是前面说的三步走async def search_memory(user_id, query, top_k5): # 第一步硬过滤 candidates await db.fetch( SELECT * FROM memories WHERE user_id $1 AND (expires_at IS NULL OR expires_at NOW()), user_id ) # 第二步多路召回 query_embedding await embed(query) vector_hits cosine_search(candidates, query_embedding, top_k20) keyword_hits bm25_search(candidates, query, top_k20) merged dedupe(vector_hits keyword_hits) # 第三步重排 reranked await rerank(query, merged, top_ktop_k) # 更新访问计数 await update_access_stats([m.id for m in reranked]) return reranked遗忘用定时任务跑每天凌晨执行一次async def forget_cycle(): # TTL清理 await db.execute( DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at NOW() ) # 重要性淘汰 await db.execute( DELETE FROM memories WHERE id IN ( SELECT id FROM memories WHERE importance 0.2 AND access_count 0 AND created_at NOW() - INTERVAL 30 days ORDER BY importance ASC LIMIT 1000 ) )4.3 接入MCP让Agent真正用起来记忆服务跑起来之后通过MCP暴露给Agent。我用的是官方Python SDK核心是定义工具from mcp.server import Server from mcp.types import Tool, TextContent server Server(hindsight-memory) server.list_tools() async def list_tools(): return [ Tool( namewrite_memory, description写入一条记忆, inputSchema{ type: object, properties: { content: {type: string}, memory_type: {type: string, enum: [episodic, semantic]}, importance: {type: number, default: 0.5} }, required: [content, memory_type] } ), Tool( namesearch_memory, description检索相关记忆, inputSchema{ type: object, properties: { query: {type: string}, top_k: {type: integer, default: 5} }, required: [query] } ) ]Agent那边配置MCP Server的地址就能在对话中自动调用这些工具。我实测下来接上记忆服务之后同一个Agent在跨会话场景下的表现提升非常明显。之前用户第二天再来问“上次那个订单”Agent完全懵现在它能检索到昨天的情景记忆直接接上话。提示MCP工具的描述description写得好不好直接影响Agent会不会在正确的时机调用。别写“搜索记忆”这种模糊描述写“当用户提到过去发生的事、之前讨论过的内容、或需要回忆历史信息时调用此工具”。5. 常见问题与排查技巧实录5.1 记忆检索不准怎么办这是最高频的问题。排查顺序我总结成一张表现象可能原因排查方法解决检索结果完全不相关embedding模型不匹配检查写入和检索用的模型是否一致统一模型重建索引相关记忆排不到前面缺少重排看top-20里有没有目标加重排模型该记的没记住写入触发器没触发打日志看should_write返回值调整触发条件记住了但检索不到元数据过滤太严检查user_id等过滤条件放宽过滤或补元数据结果重复冗余没做去重看返回内容是否高度相似加去重逻辑我踩过最坑的一次是embedding模型换了但没重建索引导致新旧向量混在一起检索结果乱七八糟。换模型一定要重建全部向量没有捷径。5.2 Docker环境下的典型故障Docker虽然省事但坑也不少。我列几个遇到过的端口冲突。5432被本地MySQL占了容器起不来。改compose里的端口映射比如5433:5432然后连接串里的端口也要改。容器间网络不通。memory-service连不上postgres报connection refused。检查两点一是depends_on有没有配healthcheck条件二是连接串里用的是服务名postgres而不是localhost。Docker Compose内部用服务名做DNS解析用localhost会指向容器自己。数据卷权限问题。Linux上跑的时候pgdata卷权限不对postgres起不来。解决办法是sudo chown -R 999:999 ./pgdata999是postgres容器里的用户ID。Docker Desktop启动失败。Windows上最常见的就是虚拟化没开或者WSL2没装。去“启用或关闭Windows功能”里把“虚拟机平台”和“适用于Linux的Windows子系统”都勾上重启。5.3 记忆膨胀与性能下降跑了一段时间之后如果发现检索越来越慢八成是记忆库太大了。先查数据量SELECT memory_type, COUNT(*) FROM memories GROUP BY memory_type;如果episodic记忆占了绝大多数说明情景记忆没有及时压缩。我的做法是每周跑一次压缩任务把同一用户、同一主题的多条情景记忆用LLM总结成一条语义记忆原始记录归档到冷存储。另一个性能杀手是向量索引没建好。ivfflat索引的lists参数如果设得太小检索会退化成全表扫描。数据量到十万级的时候lists至少设300。可以用EXPLAIN ANALYZE看查询计划确认走了索引。5.4 几个我踩过的坑坑一把工作记忆也存数据库。工作记忆是当前会话的上下文读写频率极高存Postgres纯属找罪受。后来改成存Redis设置会话结束自动过期性能好了不止一个档次。坑二importance分数拍脑袋定。一开始所有记忆importance都是0.5结果淘汰机制完全失效。后来改成动态计算根据访问频次和用户反馈调整才有意义。坑三忘了处理并发写入。多个会话同时写同一个用户的记忆出现过覆盖。加了个基于user_id的乐观锁写入前检查版本号冲突就重试。坑四MCP工具调用超时。记忆检索如果超过3秒Agent那边就超时了。后来把重排改成异步先返回粗排结果重排结果通过回调更新。用户体验上几乎无感但稳定性大幅提升。6. 记忆系统还能怎么扩展hindsight这套东西跑通之后我发现它的扩展空间比想象中大。比如把记忆和知识图谱结合实体之间的关系也存进去检索时就能做多跳推理——用户问“我上次和谁一起买的那个东西”Agent能通过“用户-订单-同行人”这条链路找到答案。再比如做记忆的版本管理同一条事实在不同时间可能有不同值用户换了地址保留历史版本Agent就能回答“你之前留的地址是XXX现在改成了YYY”。还有个方向是记忆的主动召回。现在的检索都是被动等query触发其实可以让Agent在对话开始前就预加载相关记忆或者在对话过程中定期检查是否有值得提起的历史信息。这需要更精细的相关性判断但效果会很自然就像老朋友聊天时会突然想起“对了你上次说的那个事”。我个人的体会是agent memory这个领域现在还没有标准答案各家做法差异很大。但核心思路是共通的分层存储、精准写入、多路检索、主动遗忘。把这四件事做好Agent的“记性”就不会差。至于具体用什么数据库、什么模型、什么协议都是可以替换的实现细节。
返回列表