ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多AI客户端共享记忆层:MemTether的设计与实践复盘

多AI客户端共享记忆层:MemTether的设计与实践复盘 如果你和我一样日常干活要在四五个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用PipelineClaude 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_embeddingsTrue).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, dtypenp.float32) score float(q_vec emb) # 已经归一化所以点积就是余弦相似度 results.append((score * (0.5 importance), row_id, content, tags)) results.sort(reverseTrue) 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 / CursorMCP方式MCPModel 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_asciiFalse) 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_URLhttp://embedding-service:8000 embedding-service: image: ghcr.io/yourname/memtether-embedding:latest ports: - 8000:8000 environment: - MODEL_NAMEBAAI/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客户端越来越多、越来越碎的未来只会更强烈。我自己的体会是做这类基础设施型开源工具别一上来就想做大而全先把写一条记忆、读一条记忆、搜到一条记忆这三件事做到极致比什么都有说服力。
返回列表