1. 从每次都要重新自我介绍说起本地记忆为什么成了 AI 工作流的瓶颈用 AI 写代码、写文档、做分析的人大概都经历过这种别扭昨天刚跟它讲清楚项目用的是哪套目录结构、命名规范是什么、数据库字段怎么命名今天开个新会话它又一脸茫然地问你请问你的项目是做什么的。你只能把昨天说过的话再复制粘贴一遍像每天上班都要重新面试一次。这不是模型不够聪明而是会话级上下文这个机制本身的天花板。绝大多数 AI 工作流工具的记忆是跟着对话走的对话结束记忆清零换个客户端记忆清零换个模型记忆还是清零。对于一次性问答这没问题但对于我要长期维护一个项目我要让 AI 持续理解我的写作风格我要让 Agent 记住我踩过的坑这类需求会话级记忆就是致命的。MCPModel Context Protocol之所以被很多人称为天花板核心就在于它把记忆这件事从对话的附属品变成了独立的基础设施。它定义了一套标准协议让 AI 客户端Host可以通过统一的接口去访问外部的能力提供方Server而 Server 可以暴露三类东西Tools工具、Resources资源、Prompts提示模板。其中Resource这一类就是本地记忆接入 AI 工作流的关键抓手。我先把结论摆在这里本地记忆接入 AI 工作流本质上是把记忆抽象成一个 MCP Resource让任何支持 MCP 的客户端都能按需读取而不是把记忆硬编码进某一次对话里。这句话听起来简单但它带来的变化是结构性的——你的记忆不再属于某个 App而是属于你自己。这篇文章适合三类人看一是已经在用 AI 写代码、写内容但被重复交代背景折磨的从业者二是正在搭 Agent、想让 Agent 有长期记忆的开发者三是听说过 MCP 但一直没搞明白 Resource 到底怎么用的人。我会从协议原理讲到落地实操把本地记忆怎么接进 AI 工作流这条链路完整拆开。2. MCP 里 Resource 和 Tool 的分工为什么记忆应该走 Resource 而不是 Tool很多人第一次接触 MCP注意力全在 Tool 上——毕竟让 AI 调用工具听起来最酷。但如果你要接的是记忆选错类别会让整个设计变得别扭。我见过不少项目把读取记忆做成一个 Tool结果用起来处处别扭。这里必须把 Resource 和 Tool 的边界讲清楚。2.1 Resource 是可读的上下文Tool 是可执行的动作用生活化的类比Resource 像是你办公桌上的一本笔记本AI 可以翻开来看Tool 像是你桌上的一个按钮AI 按下去会触发某个动作。笔记本是被动可读的按钮是主动触发的。MCP 协议里Resource 的设计目标就是为模型提供上下文数据。它有几个鲜明特征有 URI 标识每个 Resource 都有一个唯一地址比如memory://project/notes、file:///Users/me/notes.md客户端可以按地址精确读取。支持订阅变更Resource 内容变了Server 可以主动通知客户端这个资源更新了客户端再决定要不要重新拉取。以内容为中心Resource 返回的是数据本身文本、JSON、二进制不是操作结果。而 Tool 的特征是有输入参数 schema调用时要传参比如search_memory(query数据库命名)。有副作用或计算调用会执行逻辑返回的是执行结果。面向动作适合搜索写入删除这类操作。2.2 把记忆拆成读用 Resource、写用 Tool才是正解理解了上面的分工记忆系统的设计就清晰了记忆操作应该用什么原因读取全部记忆 / 读取某个记忆文件Resource纯读取内容即上下文客户端可直接注入按关键词搜索记忆Tool需要计算和参数属于动作新增一条记忆Tool有写入副作用删除 / 修改记忆Tool有副作用记忆变更通知Resource 订阅协议原生支持变更推送这个拆分的好处是读取路径极短。客户端在组装上下文时可以直接把 Resource 的内容塞进去不需要先调 Tool 再等返回再拼装这一圈。对于每次对话都要带上项目背景这种高频场景省下的这一圈就是体验差距。提示如果你的客户端支持在会话启动时自动加载指定 Resource那本地记忆的接入成本几乎为零——配置一次之后每次对话都自带背景。2.3 一个容易踩的坑把大文件整个塞进 Resource我早期犯过一个错把整个项目的所有笔记几万字做成一个 Resource结果每次对话上下文都被撑爆模型反而抓不住重点。后来改成分层 Resource一个索引 Resource只放目录和摘要具体内容按需通过 Tool 检索。这个思路和 RAG 里的先召回再精读是一致的。所以记住一句话Resource 负责给什么Tool 负责找什么。两者配合记忆系统才既轻又快。3. 本地记忆的存储选型文件、SQLite 还是向量库确定了走 MCP 这条路下一个问题就是记忆到底存在哪这一步选型直接决定了后面 Server 的实现复杂度和检索效果。我把常见的三种方案摊开讲都是我在实际项目里用过的。3.1 纯文件方案Markdown 目录结构最朴素也最可控的方案。每条记忆是一个 Markdown 文件用目录做分类memory/ projects/ my-app/ architecture.md conventions.md pitfalls.md personal/ writing-style.md preferences.md优点非常明显人可读、可版本控制、可用 Git 管理、迁移零成本。你甚至可以直接用编辑器改记忆AI 读到的就是最新的。对于个人使用、记忆量在几百条以内的场景这是我最推荐的起点。缺点也明显检索能力弱。文件多了以后找某条相关记忆只能靠文件名和目录或者全文 grep。这时候就需要配合一个搜索 Tool。3.2 SQLite 方案结构化 全文检索当记忆条目上千或者需要按时间、标签、来源做过滤时SQLite 是性价比极高的选择。它单文件、零依赖、支持 FTS5 全文索引。一张典型的表结构CREATE TABLE memories ( id INTEGER PRIMARY KEY, content TEXT NOT NULL, tags TEXT, source TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE VIRTUAL TABLE memories_fts USING fts5(content, tags, contentmemories, content_rowidid);FTS5 让按关键词搜记忆变成一条 SQL 就能搞定的事响应在毫秒级。相比文件方案它多了结构化查询能力但牺牲了直接用编辑器改的便利。3.3 向量库方案语义检索当记忆量大、且用户提问和记忆原文用词不一致时比如用户问数据库字段怎么起名记忆里写的是命名规范snake_case关键词检索会漏。这时候需要向量检索。常见选择有 Chroma、LanceDB、Qdrant 本地模式等。核心流程是写入时把记忆文本 embedding 后存向量检索时把 query embedding 后做相似度搜索。但我要泼一盆冷水向量库不是银弹。它引入了 embedding 模型依赖、维度管理、增量更新等复杂度而且纯语义检索有时会召回语义相近但实际无关的内容。我的实际经验是混合检索关键词 向量效果最稳先用 FTS 粗筛再用向量精排。3.4 我的选型建议场景推荐方案理由个人使用记忆 500 条Markdown 文件简单、可读、可 Git记忆 500~10000 条需过滤SQLite FTS5结构化 全文检索零依赖记忆 10000 条语义检索刚需SQLite 向量库混合兼顾精确与语义团队共享记忆SQLite 服务化需要并发和权限控制注意不要一上来就上向量库。我见过太多项目为了显得高级直接上向量结果维护成本远超收益。从文件开始痛了再升级这是最务实的路径。4. 手把手实现一个本地记忆 MCP Server理论讲完进入实操。我用 Python 写一个最小可用的记忆 MCP Server暴露一个 Resource读取记忆索引和两个 Tool搜索、写入。选 Python 是因为 MCP 官方 SDK 对 Python 支持成熟且依赖少。4.1 环境准备与依赖安装先建一个独立目录用虚拟环境隔离mkdir memory-mcp-server cd memory-mcp-server python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install mcp这里只装官方mcp包。存储我用 SQLite标准库自带不引入额外依赖。依赖越少Server 越稳——这是我在生产环境反复验证过的原则。4.2 定义记忆存储层先写存储逻辑和 MCP 解耦方便单独测试import sqlite3 from datetime import datetime from pathlib import Path DB_PATH Path.home() / .memory-mcp / memories.db def init_db(): DB_PATH.parent.mkdir(parentsTrue, exist_okTrue) conn sqlite3.connect(DB_PATH) conn.execute( CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY, content TEXT NOT NULL, tags TEXT DEFAULT , created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ) conn.execute( CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(content, tags, contentmemories, content_rowidid) ) conn.commit() return conn def add_memory(content: str, tags: str ) - int: conn init_db() cur conn.execute( INSERT INTO memories (content, tags) VALUES (?, ?), (content, tags) ) conn.execute( INSERT INTO memories_fts (rowid, content, tags) VALUES (?, ?, ?), (cur.lastrowid, content, tags), ) conn.commit() conn.close() return cur.lastrowid def search_memory(query: str, limit: int 5): conn init_db() rows conn.execute( SELECT m.id, m.content, m.tags, m.created_at FROM memories_fts f JOIN memories m ON f.rowid m.id WHERE memories_fts MATCH ? ORDER BY rank LIMIT ?, (query, limit), ).fetchall() conn.close() return rows这段代码有两个关键点值得说。第一FTS5 用外部内容表模式contentmemories避免内容存两份节省空间。第二写入时同步更新 FTS 索引保证搜索实时性。如果你追求极致性能可以改成触发器自动同步但手动同步更直观、更易调试。4.3 暴露 Resource让客户端直接读到记忆索引Resource 的核心是给一个 URI返回内容。我设计一个memory://index资源返回所有记忆的摘要列表from mcp.server import Server from mcp.types import Resource, TextContent import mcp.server.stdio app Server(memory-server) app.list_resources() async def list_resources(): return [ Resource( urimemory://index, name记忆索引, description所有本地记忆的摘要列表, mimeTypetext/plain, ) ] app.read_resource() async def read_resource(uri: str): if str(uri) memory://index: conn init_db() rows conn.execute( SELECT id, substr(content, 1, 80), tags FROM memories ORDER BY id DESC LIMIT 50 ).fetchall() conn.close() lines [f[{r[0]}] ({r[2]}) {r[1]}... for r in rows] return TextContent(typetext, text\n.join(lines) or 暂无记忆) raise ValueError(f未知资源: {uri})注意substr(content, 1, 80)这个细节——索引只返回摘要不返回全文。这正是前面说的分层 Resource思路。客户端拿到索引后如果对某条感兴趣再通过 Tool 精确读取。4.4 暴露 Tool搜索与写入Tool 部分定义两个动作注意每个 Tool 都要有清晰的 description因为模型是靠 description 决定调不调的from mcp.types import Tool, TextContent app.list_tools() async def list_tools(): return [ Tool( namesearch_memory, description按关键词搜索本地记忆返回最相关的若干条, inputSchema{ type: object, properties: { query: {type: string, description: 搜索关键词}, limit: {type: integer, default: 5}, }, required: [query], }, ), Tool( nameadd_memory, description新增一条本地记忆用于长期保存重要信息, inputSchema{ type: object, properties: { content: {type: string}, tags: {type: string}, }, required: [content], }, ), ] app.call_tool() async def call_tool(name: str, arguments: dict): if name search_memory: rows search_memory(arguments[query], arguments.get(limit, 5)) text \n.join(f[{r[0]}] {r[1]} for r in rows) or 未找到相关记忆 return [TextContent(typetext, texttext)] if name add_memory: mid add_memory(arguments[content], arguments.get(tags, )) return [TextContent(typetext, textf已保存ID{mid})] raise ValueError(f未知工具: {name})4.5 启动入口最后加上 stdio 启动逻辑这是 MCP Server 最常见的通信方式async def main(): async with mcp.server.stdio.stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options()) if __name__ __main__: import asyncio asyncio.run(main())跑起来之后在支持 MCP 的客户端里配置这个 Server 的命令通常是python /path/to/server.py就能看到memory://index这个资源和两个工具了。提示stdio 模式下Server 的日志千万不要打到 stdout否则会污染协议通信。要打日志就打到 stderr 或文件。这个坑我踩过排查了半天才发现是 print 惹的祸。5. 接进 AI 工作流之后记忆怎么用才不添乱Server 跑通只是第一步。真正决定体验的是记忆怎么被用起来。我见过不少项目记忆接是接上了但用起来反而更乱——要么每次塞太多要么塞了不相关的。这一节讲几个实战中的关键设计。5.1 会话启动时注入什么索引而非全文最自然的接入点是会话启动时自动加载memory://index。客户端把索引作为系统上下文的一部分注入模型一上来就知道我有哪些记忆可用。但这里有个度索引不能太长。我的经验是控制在50 条以内、每条 80 字以内总长度不超过 4000 字。超过这个量模型注意力会被稀释反而记不住重点。如果记忆确实多就按最近使用或重要性排序只注入 Top N。5.2 什么时候触发搜索让模型自己决定不要每次对话都全量检索。正确做法是把search_memory作为工具交给模型让它按需调用。当用户问我们项目数据库字段怎么命名的模型会自己判断这可能需要查记忆然后调search_memory(query数据库命名)。这个让模型自己决定的设计比每次都预检索要优雅得多。因为预检索会引入无关内容而按需检索是精准的。前提是你的 Tool description 写得够清楚——description 就是给模型的使用说明书。5.3 什么时候写入显式指令 自动捕获写入记忆有两种触发方式显式指令用户说记住这个以后都按这个来模型调add_memory。自动捕获在系统提示里约定当用户明确表达偏好、约定、重要决策时主动保存。我倾向于以显式为主、自动为辅。全自动捕获容易把噪音也存进去时间一长记忆库就脏了。可以在系统提示里加一句约束仅在信息具有长期价值且用户明确表达时保存。5.4 记忆的保鲜去重、更新与淘汰记忆库用久了必然膨胀。三个必须做的维护动作去重写入前先search_memory查一下有没有相似内容有就更新而不是新增。更新给记忆加updated_at支持覆盖旧版本。淘汰定期清理长期未命中、且内容过时的记忆。我在实际项目里加了一个记忆命中计数每次被检索到就 1。半年后回头看命中 0 次的记忆基本可以清理。这个机制让记忆库始终保持活水状态。注意淘汰要谨慎。有些记忆命中率低但极其关键比如生产环境禁止直接改数据库这种红线。建议给记忆加重要标签重要记忆永不自动淘汰。6. 实测中暴露的问题与排查链路上面讲的都是顺利路径。但真实落地时问题一个接一个。这一节我把踩过的坑按排查链路完整还原方便你复现排查思路。6.1 客户端连不上 Server从进程到协议逐层查第一次配置时客户端显示Server 未响应。我的排查顺序是手动跑一遍 Server 命令在终端直接执行配置里的命令看有没有报错。结果发现是 Python 路径写错了虚拟环境没激活。检查 stdout 污染手动跑时看到有 print 输出立刻意识到这会破坏 stdio 协议。把 print 改成 stderr。检查协议版本客户端和服务端的 MCP 协议版本不匹配也会连不上。确认 SDK 版本后统一升级。这三步走下来90% 的连不上都能定位。6.2 Resource 读到了但模型不用description 的问题Server 连上了memory://index也能读到但模型从来不主动用。排查发现是Resource 的 description 写得太模糊——只写了记忆索引模型不知道这玩意儿什么时候该用。改成包含用户历史偏好、项目约定、踩坑记录等长期记忆的摘要列表回答涉及项目背景或用户偏好时优先参考之后模型的使用率明显上升。description 不是给人看的是给模型看的要写清楚这是什么、什么时候用。6.3 搜索召回不准FTS 分词与查询构造中文搜索时FTS5 默认分词对中文不友好经常搜不到。解决方案有两个用unicode61分词器配合手动分词把中文按字切分。或者引入 jieba 等分词库写入前先分词查询时也分词。我选了后者因为中文语义单元是词不是字。改造后数据库命名规范能准确命中命名规范snake_case这条记忆。6.4 记忆越用越慢索引与查询优化记忆到几千条后搜索开始变慢。排查发现两个问题一是 FTS 索引没建好二是每次查询都重新init_db()建连接。优化后连接改为长连接复用避免频繁开关。给tags加普通索引支持按标签过滤。定期INSERT INTO memories_fts(memories_fts) VALUES(optimize)优化 FTS 索引。优化后几千条记忆的搜索稳定在 10ms 以内。6.5 多客户端并发写冲突同时开两个客户端都往记忆库写偶尔出现database is locked。SQLite 的写锁是库级的并发写会冲突。解决方案开启 WAL 模式PRAGMA journal_modeWAL;读写可以并发。写入加重试逻辑捕获 locked 异常后短暂等待重试。WAL 模式是 SQLite 并发场景的标配加上之后基本没再遇到锁问题。7. 从单机记忆到 Agent 长期记忆这套架构能走多远把本地记忆接进 MCP 工作流表面上是解决重复交代背景往深了看它其实是Agent 长期记忆的雏形。这一节聊聊这套架构的延展方向以及我个人的一些判断。7.1 记忆分层工作记忆、情景记忆、语义记忆认知科学里把记忆分几层Agent 记忆设计完全可以借鉴工作记忆当前会话的上下文随对话结束消失。情景记忆具体发生过的事比如上周三我们决定用 PostgreSQL。语义记忆抽象出的规律比如这个项目偏好 snake_case 命名。MCP Resource 适合承载语义记忆稳定、可读Tool 适合检索情景记忆按需、精准。分层之后注入策略也能差异化语义记忆常驻情景记忆按需拉取。7.2 多 Agent 共享记忆从个人到团队单机记忆是个人资产。当多个 Agent 协作时记忆需要共享。这时候可以把 MCP Server 从 stdio 改成 HTTP/SSE 模式部署成一个服务多个 Agent 通过 URL 接入。但共享带来新问题权限和隔离。A 项目的记忆不该被 B 项目读到。解决方案是给记忆加namespace字段Server 按 namespace 过滤。这其实就是多租户设计的简化版。7.3 记忆的遗忘机制主动设计而非被动堆积人脑会遗忘Agent 也该会。我越来越觉得一个不会遗忘的记忆系统是危险的——它会积累过时信息、矛盾信息最终拖垮判断。主动遗忘的设计思路时间衰减越老的记忆权重越低除非被反复命中。矛盾检测新记忆和旧记忆冲突时提示用户确认而不是默默覆盖。重要性分级核心约定永久保留临时信息定期清理。这套机制我在自己的项目里跑了大半年记忆库始终保持在几百条的精炼状态检索准确率反而比堆到几千条时更高。7.4 我对这套架构的判断MCP 把记忆标准化这件事价值不在于多了一个功能而在于它把记忆的所有权还给了用户。你的记忆存在你自己的机器上用标准协议暴露任何支持 MCP 的客户端都能读。这意味着你换客户端、换模型记忆都跟着你走。这是我认为它配得上天花板这个说法的真正原因——不是技术多炫而是它解决了一个结构性问题记忆不该被锁在某个 App 里。如果你现在还在用每次复制粘贴背景的方式工作我建议你花一个下午把这套东西搭起来。从 Markdown 文件方案起步跑通 Resource 读取再逐步加 Tool 检索。搭完之后你会发现AI 终于开始记得你了而不是每天重新认识你一次。最后分享一个我自己的小习惯我会定期打开记忆库像翻日记一样看看 AI 都记住了什么。有时候会发现一些自己都忘了的约定也会发现一些该清理的过时信息。这个过程本身就是在维护你和 AI 之间的共同记忆。