1. 为什么我要把记忆层从 Agent 里拆出来
先说结论:把本地记忆层做成一个独立的 MCP 服务,再让 Agent 通过协议去调用,是我这两年折腾 Agent 项目里性价比最高的一次重构。它解决的不是"能不能记住"的问题,而是"记忆归谁管、怎么复用、怎么换 Agent 不丢数据"的问题。
我最早做 Agent 的时候,记忆是直接塞在 Agent 进程里的——一个内存字典,或者一个本地 SQLite 文件,Agent 启动时读进来,对话结束写回去。这套做法在单 Agent、单场景的 Demo 里跑得挺欢,但只要项目稍微长大一点,问题就全冒出来了。最典型的是三个:第一,我换了 Agent 框架,记忆层要重写一遍;第二,我同时跑两个 Agent(一个负责问答、一个负责执行任务),两边记忆对不上;第三,我想给记忆加个向量检索、加个过期淘汰策略,结果发现逻辑和 Agent 主循环缠在一起,改一处崩三处。
后来 MCP(Model Context Protocol)这个概念火起来,我意识到它其实给了一个很干净的解法:把记忆层当成一个独立的"能力提供方",Agent 只是"能力消费方"。记忆的存储、检索、更新、淘汰全部封装在一个本地服务里,Agent 通过标准协议去读写。这样 Agent 换框架、换模型、换编排方式,记忆层纹丝不动;反过来记忆层升级检索算法,Agent 也完全无感。
这篇文章我会把整套接入实践拆开讲:记忆层到底该存什么、MCP 服务怎么设计、Agent 侧怎么接、并发和一致性怎么处理、以及我踩过的那些坑。适合已经写过至少一个 Agent Demo、想把它往"能长期用"方向推的开发者。如果你还在纠结 Agent 是什么、MCP 是软件协议还是硬件协议这类基础概念,建议先把基础补一补再回来看,这篇偏实战。
提示:本文讲的"本地记忆层"指的是跑在你本机或内网、数据不出域的存储服务,和云端托管记忆是两条路线。选本地还是云端取决于你的数据敏感度和部署条件,本文只讨论本地这条线。
2. 记忆层到底该存什么:三层结构的设计取舍
很多人一上来就问"用哪个向量库",我觉得这是把问题问反了。先想清楚记忆分几类,再决定用什么存。我实践下来,Agent 的记忆至少分三层,每层的生命周期、检索方式、存储介质都不一样。
2.1 会话记忆、事实记忆、技能记忆的分工
会话记忆(Session Memory):当前这轮对话的上下文,生命周期最短,通常几十分钟到几小时。它的特点是读写极频繁、要求极低延迟,而且大部分内容用完就废。这层我一般直接放内存或者带 TTL 的 Redis,不落盘。
事实记忆(Fact Memory):用户告诉过 Agent 的稳定信息,比如"我叫老张""我偏好用 Python""我们团队用 PostgreSQL"。生命周期长,可能几个月甚至永久。这层需要持久化,而且需要按语义检索——用户下次说"帮我写个脚本",Agent 得能想起"他偏好 Python"。这层是向量库的主战场。
技能记忆(Skill Memory):Agent 自己积累的"怎么做某件事"的经验,比如"处理这类 CSV 要先检测编码""调用某个内部接口要先拿 token"。这层介于前两者之间,既可以结构化存(键值对),也可以语义存。我倾向于结构化为主、语义为辅,因为技能往往有明确的触发条件。
把这三层分开之后,你会发现"用哪个向量库"这个问题自然就有了答案:只有事实记忆和部分技能记忆需要向量检索,会话记忆根本不需要。我见过太多项目上来就全量向量化,结果延迟高、成本高、召回还差。
2.2 为什么我不建议把所有记忆都向量化
向量检索有个被低估的坑:它对精确匹配是无能为力的。用户说"我的订单号是 A12345",你把这个订单号向量化存进去,下次用户问"A12345 到哪了",向量检索很可能召回一堆语义相似但订单号完全不同的记录。这种场景必须用精确匹配(键值或全文索引)。
我的做法是混合检索:结构化字段走精确匹配,自由文本走向量检索,最后做一次融合排序。具体到实现,记忆条目里我会显式标注memory_type和retrieval_hint,告诉检索层这条记忆该走哪条路。
| 记忆类型 | 存储介质 | 检索方式 | 典型 TTL |
|---|---|---|---|
| 会话记忆 | 内存 / Redis | 按 session_id 直取 | 1-24 小时 |
| 事实记忆 | SQLite + 向量索引 | 语义 + 精确混合 | 长期 |
| 技能记忆 | SQLite | 键值 + 标签匹配 | 长期 |
这张表是我项目里实际用的配置,你可以直接抄。SQLite 做本地记忆层的主存储我觉得非常合适——单文件、零运维、支持全文检索(FTS5)、还能挂向量扩展。别一上来就上 PostgreSQL,本地场景 SQLite 足够撑到几十万条记忆。
2.3 记忆条目的最小字段设计
一条记忆该有哪些字段,直接决定了后面检索和淘汰好不好做。我踩过的坑是字段设计太随意,导致后面想加"记忆重要性排序"时发现没地方存权重。现在我的最小字段集是这样的:
id:唯一标识,建议用内容哈希,天然去重content:记忆正文memory_type:session / fact / skillembedding:向量(可选,事实记忆才有)tags:标签数组,用于精确过滤importance:0-1 的权重,影响淘汰和排序created_at/updated_at:时间戳access_count:被召回次数,用于热度排序source:来源标识,方便追溯是哪次对话产生的
importance和access_count这两个字段是我强烈建议加的。前者让你能做"重要记忆优先保留",后者让你能做"常用记忆优先召回"。没有这两个字段,你的记忆层就是个只会堆积的垃圾桶。
3. MCP 服务端:把记忆能力封装成标准工具
MCP 的核心价值在于它定义了一套标准的"工具暴露"方式,Agent 不需要知道记忆层内部怎么实现,只需要知道"有这么几个工具可以调"。这一节讲服务端怎么设计。
3.1 工具粒度:为什么我最终只暴露了四个
一开始我设计得很细,add_memory、update_memory、delete_memory、search_by_vector、search_by_keyword、get_by_id……一口气暴露了十几个工具。结果 Agent 调用时经常选错工具,而且工具描述太长,占用了大量上下文。
后来我收敛到四个工具,覆盖 95% 的场景:
memory_write:写入或更新一条记忆(内部按 id 去重,有则更新无则插入)memory_search:检索记忆,参数里带mode(semantic / exact / hybrid)和filtersmemory_forget:删除记忆,支持按 id 或按条件批量删memory_summarize:把一段会话压缩成事实记忆(这个工具很关键,后面细说)
工具少了,Agent 的选择准确率明显上升。这里有个经验:MCP 工具的粒度应该对齐"用户意图",而不是对齐"数据库操作"。用户想的是"记住这件事",不是"INSERT 一行"。
3.2 memory_search 的参数设计细节
memory_search是整个记忆层最核心的工具,参数设计直接决定召回质量。我的参数结构是这样的:
{ "query": "用户偏好的编程语言", "mode": "hybrid", "memory_type": ["fact", "skill"], "tags": ["preference"], "top_k": 5, "min_importance": 0.3, "recency_weight": 0.2 }几个关键点解释一下。mode让 Agent 自己决定检索策略,简单查询用 exact,模糊查询用 semantic,不确定就用 hybrid。memory_type和tags是硬过滤,先缩小范围再检索,比纯向量检索准得多。recency_weight是时间衰减权重,让新记忆稍微占优——这个参数我调了很久,0.2 是个比较平衡的值,太高会导致老的重要记忆被淹没。
注意:
top_k不要设太大。我见过有人设 20,结果召回一堆弱相关记忆,反而干扰 Agent 判断。5 到 8 是比较舒服的区间,配合min_importance过滤效果更好。
3.3 memory_summarize:让记忆自己"沉淀"
这个工具是我觉得最有价值的设计。Agent 每轮对话都往记忆里塞原始文本,记忆层很快就会爆炸。memory_summarize的作用是:接收一段会话原文,让记忆层(内部调用一次小模型)把它压缩成结构化的事实条目。
比如一段对话:"用户说他最近在做一个 Agent 项目,用的是 Python,部署在本地,遇到了并发问题。" 压缩后变成三条事实记忆:
- 用户在做一个 Agent 项目(tag: project)
- 用户偏好 Python(tag: preference)
- 用户遇到并发问题(tag: issue)
这样做的好处是记忆密度大幅提升,检索时信噪比高很多。代价是要多一次模型调用,所以我一般只在会话结束时触发一次 summarize,而不是每轮都触发。
3.4 服务端用 stdio 还是 HTTP:本地场景的选择
MCP 服务端有两种常见传输方式:stdio 和 HTTP(SSE)。本地记忆层我强烈建议用 stdio。原因很简单:stdio 是进程内管道通信,没有网络开销,没有端口占用,没有鉴权烦恼,Agent 启动时把记忆服务作为子进程拉起来就行。
HTTP 方式适合记忆层要跨机器共享的场景,比如你团队几个人共用一个记忆服务。但本地单机场景用 HTTP 纯属给自己找麻烦——你得管端口、管进程存活、管防火墙。我早期用 HTTP,后来全换回 stdio,启动脚本都简单了一大截。
4. Agent 侧接入:从"能调"到"调得好"
服务端写好了,Agent 侧接入其实不难,难的是让 Agent"用得聪明"。这一节讲接入的实操和调优。
4.1 接入的最小闭环:先跑通再优化
最小闭环就三步:配置 MCP 服务、注册工具、在系统提示里告诉 Agent 什么时候用记忆。我建议先用最笨的方式跑通——手动触发记忆写入和检索,确认数据能存进去、能查出来,再去做自动化。
配置部分,不同 Agent 框架写法不同,但核心都是声明一个 MCP server。以常见的配置格式为例:
{ "mcpServers": { "local-memory": { "command": "python", "args": ["-m", "memory_server", "--db", "./memory.db"], "env": { "EMBEDDING_MODEL": "local-mini", "MAX_MEMORY_ITEMS": "100000" } } } }跑通之后你会看到 Agent 的工具列表里多了四个记忆工具。这时候先别急着让它自动用,手动测几轮,看看检索结果符不符合预期。
4.2 系统提示怎么写,Agent 才会主动用记忆
这是最容易被忽略的一环。很多人接完 MCP 就指望 Agent 自己会用记忆,结果 Agent 要么不用,要么乱用。系统提示里必须明确三件事:
什么时候写:告诉 Agent 遇到"用户陈述稳定信息""用户明确要求记住""任务产生可复用经验"时调用memory_write。不要让它每轮都写,那样记忆会被噪音淹没。
什么时候读:告诉 Agent 在"回答需要个性化""执行任务前""用户提到'之前''上次'"时先调memory_search。这里有个技巧——让 Agent 在不确定时先搜一下,成本很低但收益很高。
怎么写:告诉 Agent 写入时带上合适的memory_type和tags。标签体系最好在提示里给几个示例,否则 Agent 会自创一堆乱七八糟的标签。
我实测下来,把这三条写进系统提示后,Agent 主动使用记忆的比例从不到 20% 提升到 70% 以上。这个投入产出比非常高。
4.3 检索结果怎么喂回上下文
检索到的记忆不能直接一股脑塞进上下文,得做二次处理。我的做法是:按importance × recency × relevance算一个综合分,取 top 3 到 5 条,格式化成一段简短的"背景信息"再注入。
格式大概是这样:
[相关记忆] - 用户偏好 Python(重要度 0.8,3 天前) - 用户在做 Agent 项目(重要度 0.7,1 周前)这样 Agent 一眼就能看到关键信息,而不是被一堆原始文本淹没。记住,记忆注入的目标是"提供背景",不是"复述历史"。
4.4 一个反直觉的经验:少即是多
我一开始追求"记忆越全越好",结果 Agent 反而变笨了——因为上下文里塞了太多无关记忆,干扰了它对当前任务的判断。后来我把召回数量砍到 3 条,并且加了min_importance过滤,Agent 的表现明显变好。
这背后的道理其实很简单:记忆是辅助,不是主角。Agent 的核心能力还是理解和推理,记忆只是给它提供必要的背景。背景太多,反而喧宾夺主。
5. 并发、一致性与淘汰:本地记忆层的三个硬骨头
前面讲的都是"顺风顺水"的部分,这一节讲真正难的地方。本地记忆层一旦上了生产,并发、一致性、淘汰这三件事一定会找上门。
5.1 多 Agent 同时读写怎么办
如果你只有一个 Agent,SQLite 默认的串行写入就够了。但如果你像我一样同时跑多个 Agent(比如一个问答、一个执行、一个定时任务),就会遇到写冲突。
我的方案是:读操作完全并发,写操作走单写者队列。具体实现是在记忆服务里起一个写队列,所有memory_write和memory_forget请求排队处理,读请求直接走。SQLite 开 WAL 模式后,读和写可以并发,性能足够。
如果你用的是别的存储,思路一样:把写操作串行化,读操作放开。别指望数据库自己搞定并发写,本地场景下自己控制更可靠。
5.2 记忆冲突:同一个事实被写了两次不同的值
这是很常见的场景:用户先说"我用 Python",后来说"我改用 Go 了"。两条记忆都存进去,检索时召回哪条?
我的处理策略是:同 tag 同 key 的记忆,新值覆盖旧值,但旧值保留在历史里。具体做法是给记忆加一个superseded_by字段,新记忆写入时把旧记忆标记为"已被取代",检索时默认过滤掉被取代的。这样既保证了当前状态正确,又保留了变更历史,方便追溯。
这个设计我踩过坑才想明白。最早我是直接删旧值,结果用户问"我之前用什么语言"时,Agent 完全答不上来。保留历史之后,这类问题就能回答了。
5.3 淘汰策略:什么时候该忘
记忆不能无限增长,必须有淘汰。我的淘汰策略是分层的:
- 会话记忆:TTL 到期自动删,不犹豫
- 事实记忆:按
importance和access_count综合排序,低于阈值的进入"冷存储",超过一定时间没被访问就删 - 技能记忆:基本不主动删,但会定期合并相似条目
淘汰的触发时机我选在每天凌晨低峰期跑一次批处理,而不是实时淘汰。实时淘汰会引入额外的写操作和锁竞争,得不偿失。
提示:淘汰前一定要做备份。我有一次淘汰逻辑写错,把重要记忆全删了,幸好有备份。现在我的记忆服务每天自动导出一次快照,保留最近 7 天。
5.4 记忆服务的健康检查
本地服务最怕的是"悄悄挂了"。Agent 调用记忆工具失败,如果没处理好,可能直接报错中断。我的做法是给记忆服务加一个轻量健康检查:Agent 启动时 ping 一次,运行中每隔几分钟 ping 一次,失败就降级——记忆功能不可用时,Agent 继续工作,只是不带记忆。
这个降级设计很重要。记忆是增强功能,不是核心功能,不能因为它挂了就让整个 Agent 瘫痪。
6. 我踩过的坑和对应的解法
这一节全是血泪教训,每一条都是我真金白银试出来的。
6.1 坑一:向量模型换了,历史记忆全废
我最早用的嵌入模型是某个通用模型,后来想换成更小的本地模型省资源。结果一换发现,新旧向量不在同一个空间,历史记忆的向量全部失效,检索结果乱七八糟。
解法:记忆条目里存embedding_model字段,检索时只比对同模型的向量。换模型时要么全量重算,要么新旧并存一段时间。我现在固定用一个模型,换之前一定先做全量重算的预案。
6.2 坑二:记忆写入没有去重,同一件事存了十几遍
Agent 有时候会反复确认同一件事,导致同一条记忆被写入多次。检索时召回一堆重复内容,浪费上下文。
解法:用内容哈希做 id,写入前先查 id 是否存在,存在就更新updated_at和access_count,不新增。这一招直接解决了 90% 的重复问题。
6.3 坑三:检索延迟拖垮了 Agent 响应
有段时间 Agent 响应特别慢,排查发现是记忆检索平均要 800ms。原因是向量检索没建索引,全表扫描。
解法:给向量列建 ANN 索引(SQLite 可以用向量扩展,或者自己实现一个简单的 HNSW)。建完索引后检索降到 30ms 以内。这个优化是必须做的,别偷懒。
6.4 坑四:Agent 把敏感信息写进了记忆
这个坑最危险。Agent 有时候会把用户随口说的密码、token 之类的东西写进记忆。虽然是本地存储,但也是隐患。
解法:在memory_write里加一层敏感信息过滤,用正则匹配常见的敏感模式(密码、密钥、身份证号等),命中就拒绝写入或者脱敏后再写。这层过滤必须在服务端做,不能指望 Agent 自觉。
6.5 坑五:记忆服务的日志把磁盘写满了
本地服务跑久了,日志文件能涨到几个 G。我有一次磁盘满了才发现。
解法:日志按大小滚动,保留最近几个文件。记忆服务这种长期运行的东西,日志管理一定要提前做。
7. 关于复用:怎么把这套东西搬到下一个项目
最后聊聊复用。这套记忆层我后来在三个不同项目里复用,每次接入成本不到半天。能复用的关键是我做了两件事。
第一件是把记忆层做成完全独立的进程,不依赖任何特定 Agent 框架。它只认 MCP 协议,谁来调都行。这样换 Agent 框架时,记忆层零改动。
第二件是把配置全部外置。数据库路径、嵌入模型、淘汰策略、召回参数全部走配置文件或环境变量。新项目接入时,改几个配置就能跑,不用动代码。
我现在的标准做法是:记忆层作为一个独立的 Python 包发布,新项目pip install一下,配好 MCP server 声明,就能用。这套流程跑顺之后,我再也没为"记忆"这件事操过心。
如果你也在做 Agent 项目,我强烈建议尽早把记忆层拆出来。越早拆,后面越省事。等到记忆逻辑和 Agent 主循环缠成一团再拆,那个痛苦程度会翻好几倍。