如果你和我一样,日常干活要在四五个AI客户端之间来回切换,那你大概率遇到过这种让人抓狂的情况:上午在Claude Code里和Agent讨论数据库表设计,下午切到ChatGPT继续写代码,它却一本正经地建议我推翻上午的方案,理由是"项目背景信息不足"。不是任何一个模型变笨了,而是它们各自只拥有自己会话里的那点上下文。所以我做了个开源小工具,名字叫MemTether,专门让多个AI客户端共享同一份记忆——你在一个客户端里拍板过的结论、积累下来的偏好和项目背景,换到另一个客户端时它也能读到、能用上。
这也是我从"多AI协作"这个想法落地到实际工程的一次尝试。MemTether不是一个新客户端,也不打算替代任何聊天工具,它只是一个很薄的记忆服务:任何AI客户端,通过一套简单的HTTP接口或者适配器,都能往里面写记忆、读记忆、搜记忆。项目发布之后,我陆陆续续把它接进了本地部署的Open WebUI、Claude Code、Cursor这几个常用工具,中间踩了不少坑,也收到了不少用户的真实反馈。这篇文章想把从设计到实现、从单机部署到多客户端接入的整个过程完整复盘一遍,给那些打算给AI客户端做"共享大脑"的人一点参考。
1. 为什么我需要一个"共享记忆层":多客户端记忆碎片化的真实痛点
1.1 我的日常:一天切换五个AI客户端
先说背景。我自己用的是比较杂的一套组合:写代码主力是Claude Code,聊思路和设计用ChatGPT,跑开源模型用本地部署的Open WebUI,偶尔还会打开国产的豆包、通义做资料检索和文本校对。每个客户端都有自己的优势,但问题是:它们之间的信息是彻底隔离的。
举个真实的例子。上周我做一个爬虫项目,在Open WebUI里跟本地模型聊了半天,确定了目标站点、反爬策略、数据字段映射方案。下午我打开Claude Code想让它把那套方案落地成代码,结果它完全不知道上午讨论的结果,还在问我"这个项目的目标站点是什么""数据存到哪里"。我不得不把上午聊的内容重新说一遍,甚至更糟——它给出的实现方式跟上午定好的方案完全不同。
这种割裂感在长时间项目里非常致命。项目背景、技术选型理由、踩坑记录、用户偏好,这些东西本来应该成为AI客户端持续工作的"长期记忆",但现实是它们被分散在一个又一个封闭的会话里。每次切换客户端,都意味着重新投喂一遍上下文。
1.2 各家客户端的"记忆"到底放在哪里
很多人以为AI客户端有记忆,这个理解其实要分开看。目前主流客户端的"记忆"大致分三种形态:
- 云端账号里的聊天历史:ChatGPT、Claude这类云端产品会保存你的历史对话,你也可以在设置里让它"记住你的偏好"。但这种记忆绑定在账号上,换一个客户端、换一个平台就带不走。
- 本地会话文件:像Claude Code、Cursor这类编码Agent,会在本地项目目录生成会话记录文件,比如
.claude/sessions之类。它只能读到当前项目里自己生成过的会话,换个目录、换台机器就没了。 - 模型上下文窗口:这是最短暂的一种。你在一轮对话里贴进去的资料、讨论出的结论,只有当前这几轮提问在上下文窗口内时才有效,窗口一滚动,前面讨论的东西就被"挤出"了。
这三种形态本质上都是会话隔离(session isolation)。每个AI客户端对世界的理解都是从零开始,只看到自己眼皮底下那点内容。站在单个客户端的设计角度看这没问题,但站在使用者角度,这就是典型的"信息孤岛"。
1.3 复制粘贴、共享文档为什么治标不治本
最早我也试过土办法:把重要的聊天记录复制到Notion,或者写一个PROJECT_CONTEXT.md放到每个项目根目录,让模型每次读一遍。这些东西在单个项目、单一客户端下确实管用,但用久了你会发现三个硬伤:
- 写的时候靠人肉同步。你得记得在讨论出新结论之后去更新文档,忘了就等于没有。
- 读的时候耗token。项目背景文档越写越长,每次对话都要从头塞进上下文,几十页的背景文档还没读完,预算先烧完了。
- 检索能力为零。你希望AI客户端在需要的时候精准想起某条结论,而不是每次都通读全文。而一个静态文档没法做语义检索,模型只能从头读或者读不完整的部分。
说白了,记忆不该是一段被反复搬运的静态文本,而应该是一个可以被动态读写、检索、更新的独立服务。这也是MemTether最开始的想法:把"记忆"从AI客户端的会话流里抽出来,单独做成一层基础设施。
2. MemTether的设计:把记忆从会话流里抽出来
2.1 记忆不该是聊天记录的流水账
动手之前我花了很长时间想一个问题:到底什么才是"该被共享的记忆"?
聊天记录是一条时间线,里面有大量寒暄、重复提问、错误的中间结论。如果直接把完整聊天记录同步给所有客户端,那叫做"会话共享",不叫"记忆共享"。真正有价值的记忆是从对话里提炼出来的原子事实,比如:
- "MySQL改为PostgreSQL,原因是需要更好的JSON检索能力"
- "用户对日报格式有要求:先结论后细节,不要罗列数据"
- "部署环境有且仅有Ubuntu 22.04,不要考虑CentOS"
每条都是独立、可引用、可更新的。所以MemTether的数据模型核心是一个记忆块(Memory Block),它不是流水账,而是带着元数据的一个个知识单元。
MemTether这个名字来自memory和tether两个词的拼接,思路也很直白:把记忆像绳子一样拴在各个客户端之间,谁要用都能牵过来。
2.2 记忆块长什么样
记忆块的JSON结构大致是这样的:
{ "id": "8f3a1c2e-6b4d-4f2a-9c31-7a62e5d1f9a0", "content": "数据库从MySQL迁移到PostgreSQL,原因:项目需要全文检索和JSON字段操作能力", "source_client": "open-webui", "tags": ["db", "architecture", "decision"], "importance": 0.8, "created_at": "2025-06-01T10:23:00Z", "updated_at": "2025-06-01T10:23:00Z", "access_count": 0, "last_accessed_at": null }其中content是记忆的正文,source_client记录这条记忆来自哪个客户端,importance是重要程度(0到1之间),tags用于快速过滤,后面还会用到access_count和last_accessed_at来做记忆温标管理,这个我放到后面第五章细说。
2.3 五个接口撑起全部读写
MemTether的服务端只暴露了五个接口,少到不能再少:
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /memories | 新增一条记忆 |
| GET | /memories | 分页列出记忆,支持按tag过滤 |
| POST | /memories/search | 按语义相似度检索记忆 |
| PUT | /memories/{id} | 更新一条记忆 |
| DELETE | /memories/{id} | 删除一条记忆 |
这五个接口对应了记忆生命周期的全部操作:写、读、搜、改、删。没有比这更简的。因为我的原则是:接入方越简单越好,客户端要做的无非就是"把值得记的记下来"和"在需要时查出来"。
POST /memories/search是我花时间最多的接口。它接收一个查询文本,返回和这个文本语义最相近的记忆列表。这个接口是所有客户端"想起事情"的关键入口。
2.4 为什么做记忆服务而不是做新客户端
这是整个项目最重要的一个架构决策。市面上已经有很多AI客户端,各有各的生态和用户习惯,我再做一个客户端毫无意义。所以我选择做成中立记忆服务 + 多种适配器的结构:
- 核心服务是独立运行的,谁都能调用;
- 针对不同客户端写不同的适配器,比如Open WebUI用Pipeline,Claude Code用MCP工具,普通程序直接调HTTP;
- 适配器不负责理解语义,只负责把客户端和记忆服务之间的"翻译"工作做好。
这样做还有个额外的好处:记忆格式中立意味着不管以后新的客户端出什么生态,只要能发HTTP请求,就能接入MemTether。这也让我在维护的时候轻松很多,不用跟着某个客户端的版本更新疲于奔命。
2.5 它和AI Agent框架的关系
有朋友问过:LangChain、LlamaIndex、Dify这些框架里不是也有记忆模块吗?为什么还要自己搞一个?
我的理解是:框架里的记忆模块是给同一个框架生态里的Agent用的,它跟你的会话实现深度绑定。而MemTether的定位是跨客户端、跨框架的公共层。你可以让LangChain的Agent写记忆,然后让Claude Code读出来,反过来也行。它不是某个框架的组件,而是客户端之间的共同语言。这种"AI Native研发范式"下的基础设施层,目前看还是挺缺的。
3. 存储与检索:SQLite起步,本地嵌入模型兜底
3.1 选型复盘:为什么第一版不上向量数据库
很多人一听"语义检索",第一反应是上向量数据库,比如pgvector、Milvus、Qdrant什么的。我在第一版就故意没上,理由很实际:MemTether的定位是单用户或小团队自托管工具,数据量级通常只有几千到几万条。这个体量下,向量数据库的分布式扩展能力完全用不上,反而会带来部署复杂度——你得额外维护一个数据库服务,还要处理备份和版本升级。
我用了一个非常"抠门"的方案:SQLite存元数据 + 关键词全文索引(FTS5)+ 内存里跑余弦相似度。下面这张表是当时做的对比:
| 方案 | 部署复杂度 | 检索性能(万条级别) | 维护成本 | 适合场景 |
|---|---|---|---|---|
| SQLite + FTS5 + 内存向量计算 | 低(单文件) | 好 | 低 | 单用户/小团队自托管 |
| pgvector | 中(需PostgreSQL) | 很好 | 中 | 已有PostgreSQL基础设施的团队 |
| 专用向量数据库 | 高(独立服务) | 极好 | 高 | 大规模知识库/企业级应用 |
如果启动时就把存储层做成可插拔的,后面数据量真的上去了,替换成pgvector也不难。但第一步一定要让用户"一条命令跑起来",这是开源工具被采用的第一道门槛。
3.2 表结构与字段设计
SQLite的核心表结构长这样:
CREATE TABLE memories ( id TEXT PRIMARY KEY, content TEXT NOT NULL, source_client TEXT NOT NULL, tags TEXT DEFAULT '[]', importance REAL DEFAULT 0.5, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT, access_count INTEGER DEFAULT 0, last_accessed_at TEXT, embedding BLOB ); CREATE VIRTUAL TABLE memories_fts USING fts5(id, content);几个字段的设计考量:
embedding直接存BLOB。因为每条记忆的向量维度固定(我用的是384维),把这384个float32值序列化成一个字节串塞进BLOB完全可行,读取时一次性反序列化,省去一张关联表。tags存JSON字符串。本来想归一化成子表,但实际使用中tag查询量很轻,JSON里存数组,查询时用LIKE或JSON函数过滤就足够了。source_client字段很重要。它是做"记忆溯源"的关键,用户查出一条记忆时,能知道这条信息最初是哪个客户端提供的,方便判断可信度。
3.3 语义检索的轻量化实现
嵌入模型我选了本地的sentence-transformers,用的模型是BAAI/bge-small-zh,维度384,中文效果不错,单条查询延迟在本地CPU上只有几十毫秒。为的是彻底离线也能跑,不依赖任何云端API。
检索的核心逻辑是这样的:
import numpy as np from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-small-zh") def compute_embedding(text: str) -> list[float]: return model.encode(text, normalize_embeddings=True).tolist() def search_memories(conn, query: str, top_k: int = 5): q_vec = np.array(compute_embedding(query)) cursor = conn.execute("SELECT id, content, embedding, tags, importance FROM memories") results = [] for row_id, content, emb_blob, tags, importance in cursor: emb = np.frombuffer(emb_blob, dtype=np.float32) score = float(q_vec @ emb) # 已经归一化,所以点积就是余弦相似度 results.append((score * (0.5 + importance), row_id, content, tags)) results.sort(reverse=True) return [{"id": r[1], "score": f"{r[0]:.4f}", "content": r[2]} for r in results[:top_k]]几个实现细节值得说一下。
为什么不直接按原始相似度排序?我加了一个importance权重:score * (0.5 + importance)。目的是让"重要但相似度稍低"的记忆更容易被翻出来。比如"用户明确要求不用MySQL"这种强偏好条款,即使跟当前查询不是特别语义相近,也应该排到前面。这个设计是后加的,因为早期版本里重要决策经常被淹没在无关紧要的日常记忆里。
为什么用BLOB而不是SQLite的vec扩展?当时更看重兼容性,BLOB方案在任何SQLite版本上都能跑,不需要编扩展。等到用户规模变大再考虑换存储。
FTS5用来兜底。语义检索偶尔会莫名其妙找不回精确关键词,比如你把模型名写错了一个字母,语义相似度可能很低,但关键词搜索一下就能命中。所以我在search接口里做了两路召回:一路向量相似度,一路FTS5关键词匹配,然后把结果合并去重。实测下来召回率比单一方案高了不止一点。
4. 实测接入三个客户端:接口差异和我的心路历程
4.1 接入Open WebUI:管道方式
Open WebUI是我日常跑本地模型的前端,它支持一种叫Pipeline的扩展机制,本质上是给后端处理链上挂一段Python逻辑。MemTether的接入思路很简单:在每次用户提问之前,先拿这个问题去/memories/search搜一段相关记忆,拼到上下文里;同时监听用户说"记住xxx"这种指令,把内容写入记忆库。
一个最小可用的Pipeline骨架:
import requests from open_webui.models.pipelines import Pipeline class MemTetherPipeline(Pipeline): def __init__(self): self.mem_url = "http://localhost:5000" self.name = "MemTether Memory" async def on_startup(self): pass async def inlet(self, body, __target_uri, __user): query_text = body.get("messages", [])[-1].get("content", "") if query_text.startswith("记住"): note = query_text.replace("记住", "", 1) requests.post(f"{self.mem_url}/memories", json={ "content": note, "source_client": "open-webui", "tags": ["note"] }) return body # 原样放行 try: memories = requests.post(f"{self.mem_url}/memories/search", json={"query": query_text, "top_k": 3}).json() context_block = "\n".join( f"- [记忆] {m['content']}" for m in memories ) if context_block: body["messages"].insert(-1, { "role": "user", "content": f"以下是之前积累的相关记忆,请结合参考:\n{context_block}" }) except Exception: pass # 记忆服务不可用时不阻塞正常对话 return body接入过程中遇到的一个典型问题是:管道只能看到当前请求的文本,看不到会话的完整历史。所以"记住"的指令匹配只能基于最后一条用户消息。这样设计很干净,但也意味着用户必须用固定句式"记住xxx"来触发写入,不然模型上下文滚动后,你根本不知道他在哪句话里表达了"想记住"的意图。
4.2 接入Claude Code / Cursor:MCP方式
MCP(Model Context Protocol)现在几乎是编码类Agent的标准扩展协议了。我把MemTether包成了一个MCP server,向Agent暴露三个工具:read_memory、search_memory、write_memory。
MCP server的注册配置(~/.claude.json里添加):
{ "mcpServers": { "memtether": { "command": "python", "args": ["-m", "memtether_mcp"], "env": { "MEMTETHER_URL": "http://localhost:5000" } } } }MCP工具的核心实现逻辑:
from mcp.server import Server from mcp.server.stdio import stdio_server app = Server("memtether") @app.tool() async def search_memory(query: str, top_k: int = 5): """在共享记忆中检索与query相关的历史结论、偏好和背景资料""" resp = requests.post(f"{MEMTETHER_URL}/memories/search", json={"query": query, "top_k": top_k}) return json.dumps(resp.json(), ensure_ascii=False) @app.tool() async def write_memory(content: str, tags: str = ""): """将一条新的结论或偏好写入共享记忆库""" resp = requests.post(f"{MEMTETHER_URL}/memories", json={ "content": content, "source_client": "claude-code", "tags": tags.split(",") if tags else [] }) return str(resp.status_code)体验下来的最大感受是:MCP比Pipeline优雅太多了。Agent会在自己判断"该查记忆"的时候主动调用工具——比如上下文里出现了项目代号,它就会去搜索"项目代号"相关的历史记忆。不需要我写任何"记住xxx"的指令规则,模型天然理解"这是一个可以读写的知识库"。
但这里有个坑:MCP工具调用是否触发,完全取决于Agent自己的判断。有时候模型会过度查询,每轮对话都去搜索一遍记忆,导致token开销增加;有时候又懒得不查。我的解决办法是在server端做个简单的频率限制:同一个Agent,一分钟内最多调用10次搜索工具,超过后直接返回"最近已检索过,请勿重复查询"。
4.3 最朴素的HTTP接入
有些客户端既没有Pipeline也没有MCP,只有最基础的自定义API能力。对这种场景,最朴素的方案反而最好用:在客户端的"自定义指令"或"系统提示词"里声明MemTether的存在,让它按照约定调用HTTP接口。
我示范一下在通用自定义指令里的写法:
当用户提到"项目背景""之前的决定""我的偏好"等概念时,请先访问 http://localhost:5000/memories/search,传入当前问题文本获取相关记忆;如果用户明确让你记住某些事实或偏好,请向 http://localhost:5000/memories 提交一条新记忆。 提交格式:{"content": "需要记住的内容", "source_client": "custom-client", "tags": []}
这个方案在豆包桌面端和通义网页版都实测过。好处是零额外依赖,坏处是全凭模型"自觉",有时候它会编造一个HTTP响应而不是真的去请求。所以我更推荐在客户端支持"HTTP工具"功能的情况下,把MemTether配置成真正的工具,让模型发起真实请求,而不是靠提示词驱动。
4.4 三条路线的体验对比
| 接入方式 | 接入难度 | 触发可靠性 | 维护成本 | 适用客户端 |
|---|---|---|---|---|
| Open WebUI Pipeline | 低(写Python) | 中(靠固定句式) | 中 | Open WebUI类自托管前端 |
| MCP Server | 低(声明接口) | 高(模型自主判断) | 高(随Agent能力变化) | Claude Code、Cursor等编码Agent |
| 纯HTTP/提示词 | 极低(配一段文字) | 低(靠模型自觉) | 低 | 任意网站或客户端 |
如果让我排序,编码场景首选MCP,自托管WebUI场景首选Pipeline,临时接入先用HTTP。由于客户端本身的更新速度很快,Pipeline和MCP的实现都要跟上版本节奏,我几乎每个月都要改一次适配层。
5. 部署细节、冲突策略和记忆管理
5.1 一条docker compose命令跑起来
MemTether的部署我做成了两容器方案:一个跑记忆服务,一个跑嵌入模型服务。分开跑的原因是嵌入模型进程比较吃内存,如果和主服务挤在一起,一旦模型加载失败会影响整个记忆服务。
services: memtether-server: image: ghcr.io/yourname/memtether:latest ports: - "5000:5000" volumes: - ./data:/data environment: - STORAGE_PATH=/data/memtether.db - EMBEDDING_URL=http://embedding-service:8000 embedding-service: image: ghcr.io/yourname/memtether-embedding:latest ports: - "8000:8000" environment: - MODEL_NAME=BAAI/bge-small-zh第一次启动时嵌入模型要从模型仓库下载,大概占几百MB空间,之后就全离线了。我用docker compose up -d之后,在浏览器里打开http://localhost:5000/health看到返回ok就算部署完成。单机部署就这么简单,这也是我刻意追求的效果——让普通用户不用理解嵌入式服务、不用装Python环境也能跑起来。
5.2 多客户端同时写同一份记忆的冲突处理
多个客户端共写一份记忆库,冲突是迟早的事。最常见的场景:两个客户端在同一天各自更新了"项目技术栈"这条记忆,一个写的是Python 3.12,一个写的是Python 3.11,到底谁说了算?
我的策略简单粗暴:时间戳后写覆盖先写(last-writer-wins),并保留历史版本。
def update_memory(conn, memory_id: str, new_content: str, new_tags: list[str]): old = conn.execute("SELECT content, tags, updated_at FROM memories WHERE id = ?", (memory_id,)).fetchone() if old: conn.execute( "INSERT INTO memory_versions (memory_id, content, tags, changed_at) VALUES (?, ?, ?, ?)", (memory_id, old[0], old[1], old[2]) ) conn.execute( "UPDATE memories SET content = ?, tags = ?, updated_at = datetime('now') WHERE id = ?", (new_content, json.dumps(new_tags), memory_id) ) conn.commit()这样即使后写的客户端"覆盖"掉了前面的结论,你仍然可以通过GET /memories/{id}/versions找回历史记录。项目里重要的架构决策,我都建议客户端写入时加tags: ["decision"],这类记忆在检索时享受更高的importance加成,不容易被普通记录冲掉。
当然,这句话必须说清楚:last-writer-wins不是一种智能合并。如果两个客户端在同一天针对"日志格式"给出了完全不同的两条记忆,它们会作为两条独立记录共存,时间戳只解决"更新同一条已知记忆"的冲突。我本来想实现基于语义相似度的自动合并,但那个方向水太深,正确率没法保证,索性不做。宁可让用户看到两条冲突记忆自己判断,也不能让系统错误合并把重要信息搞没了。
5.3 记忆膨胀和遗忘机制
这是上线之后被问最多的一个问题:记忆越积越多,检索时怎么保证出来的都是有用的?
我不可能把所有记忆永远留着,也不可能让每一条都以相同权重参与检索。所以做了一套简单的"记忆温标"机制。核心思路:每条记忆的检索热度由importance、access_count和last_accessed_at共同决定。
维护进程每24小时跑一次:
- 热门记忆(
access_count > 10且最近7天访问过):提升importance,让它更容易被检索到。 - 冷门记忆(超过30天没被访问且
importance < 0.3):标记为archived,默认不再参与检索;用户明确要求时可以从存档里翻出来。 - 废弃记忆(用户手动标记,或者来自已被删除的客户端):从主表移除,保留在归档表。
这个机制的灵感来自人的记忆方式:越常被想起的事越清晰,长期不提的事逐渐模糊。实测下来,加了这个机制之后,/memories/search的返回质量提升很明显,因为"一次命中一条准确的记忆"比"一次返回十条良莠不齐的候选"重要得多。
6. 开源之后真实发生的几件事
6.1 第一个release被兼容性问题教做人
发布v0.1的时候,我以为顶多有三五十个感兴趣的人clone下来玩玩。实际确实有人clone,但第一个issue不是"怎么用",而是"macOS上跑不起来"。排查后发现是Python版本问题——我在开发机用的3.12,有一部分依赖在3.10上行为不一致。后来我把入口打包成了Docker镜像,README里明确要求三秒钟内能起服务,才把这类问题压下去。
第二个教训是关于MCP的。Claude Code更新了一版协议之后,我原来的配置文件格式完全变了,社区里好几个人反馈接入失败。这让我意识到:接外部客户端,适配层的维护成本会一直存在。所以我把MCP部分拆成了独立仓库单独发版,这样记忆服务本身稳定,适配层可以快速迭代,不至于每次客户端更新都要发整个项目的版本。
6.2 用户把记忆变成了别的东西
最意外的是用户带来的用法。有个做培训的哥们把MemTether接进了公司的AI客服知识库,让不同客服客户端共享同一套产品FAQ;还有个自由职业者把它当成"第二大脑",所有AI工具产出的灵感和笔记统一灌进去,找的时候直接用语义搜索捞。这些用法都超出了我最初"多客户端共享项目上下文"的预期,但恰恰验证了一个判断:记忆格式中立,比适配器数量重要。只要你把记忆块的抽象做对了,用户自然会找到自己的接入姿势。
这也让我现在维护MemTether的心态稳了很多——不用追着每一个AI客户端的新特性跑,把核心服务做稳定,然后把适配层留给社区。毕竟,工具会被某个客户端的版本更迭淘汰,但"共享记忆"这个需求,在AI客户端越来越多、越来越碎的未来,只会更强烈。
我自己的体会是:做这类基础设施型开源工具,别一上来就想做大而全,先把"写一条记忆、读一条记忆、搜到一条记忆"这三件事做到极致,比什么都有说服力。