
这段时间我一边用 Claude 写代码一边用 ChatGPT 整理资料偶尔还要切到本地跑着的 Ollama 模型上做验证。凡是这么折腾过的人应该都有同感每个 AI 客户端都是个失忆的黑盒子你在 A 里面交代清楚的术语表、代码风格、项目背景到了 B 里面全得重新讲一遍。这不是某一款产品做得不好而是架构上根本不存在“跨客户端记忆”这类东西。所以我自己做了一个开源工具名字叫 MemTether。它的定位很简单把“记忆”从单个 AI 客户端里抽出来放到一个独立的记忆服务上让多个 AI 客户端通过统一的协议读写同一份记忆。这样你在任何一端产生的事实、偏好、决策、项目状态在另一端都能被检索到、被引用到不需要重复开会。这篇文章把它的设计思路、核心实现、实操接入和踩坑记录都摊开讲适合那些同时用多个 AI 工具、本地大模型或者正在做多智能体协作但苦于上下文不互通的人。1. 先想清楚多客户端共享记忆到底难在哪1.1 每个 AI 客户端都是一座孤岛先说个朋友常问的问题为什么不能直接把客户端里的聊天记录导出来再导进去答案是可以但没用。因为“记忆”这件事在现有产品里是藏在会话状态里的不是一份能独立存取的数据。你导出 Markdown 之后模型不会自动认为自己“记得”那份内容还得靠你重新粘贴进上下文。真正的问题是首轮请求的隔离。无论 ChatGPT、Claude、Kimi 还是本地 Ollama客户端在发起一次对话时都只携带当前面板里的上下文。它们不携带其他客户端的会话状态也不共享任何应用层以下的存储。这意味着同一个用户在同一台电脑上面对不同客户端时体验相当于面对一个完全陌生的人。项目背景要重讲、代码规范要重讲、写作口吻要重讲而且每次切模型前面的一次性沟通都归零。我把这个现象叫“孤岛效应”。孤岛效应的代价不只是烦而是不可靠。你在客户端 A 里得出的一个重要结论如果没被客户端 B 知道它就可能基于一个已经被否决的前提继续往下生成内容。对普通聊天这最多算低效对正经工作和多智能体协作来说这就是事实错误。1.2 记忆不只是聊天历史很多人的第一反应是搞一个数据库把聊天记录存起来不就行了。这是最常见的误解。需要在多个客户端之间共享的“记忆”至少有三层用户画像层用户偏好、禁忌、行业背景、常用术语。任务状态层当前在做什么、做到哪一步、哪些方案已经被否决。语义事实层项目地址、关键参数、结论、待办、时间约束、依赖关系。这三层的共同点是它们都可以脱离具体某次会话而存在但每家客户端都只把它们保存在“这次会话的历史上下文”里。所以共享记忆的关键不是存储聊天记录而是把用户真正需要长期复用的知识原子化再按语义检索出来。我把这种思路叫“把记忆变成服务而不是把历史变成文件”。MemTether 从一开始就按这个原则设计每个记忆条目是一段自然语言文本加元信息可以被寻回、更新、过期、合并。界面层百分之百与存储解耦。1.3 一个典型的使用失血场景给你一个我真实遇到的场景你就能明白为什么非要这么做。我有一次负责一个文档站点的重构。在客户端 A 里我花了一个小时交代清楚这个站用的技术栈是 VuePress、需要保持中英双语、所有组件代码必须带类型注释、不要碰重构前的路由结构。客户端 A 很配合后面生成的方案全对。但中途我因为网络问题切到客户端 B结果 B 输出了一份完全不一样的技术方案甚至建议我重写整个路由体系。浪费的时间不多但吵起来的挫败感很强。更麻烦的是多智能体协作如果你让 agent1 负责调研、agent2 负责写方案它们若不共享记忆agent2 只能拿到一份调研报告的摘要而拿不到调研过程中的判断依据。慢慢你会发现大家真正缺少的不是更强的模型而是一个稳定的、可以被任意客户端复用的公共记忆区。2. MemTether 的设计拆解把记忆抽到会话外面2.1 核心设计原则记忆服务化MemTether 的整体结构可以概括为一句话记忆跑在独立的服务进程里客户端只负责消费。它不直接修改 ChatGPT 或 Claude 的源码而是在中间加了一层“记忆协议层”。整个系统由三个组件组成第一个是记忆存储服务负责持久化用户写入的记忆条目。第二个是语义检索器负责在收到查询时把最相关的记忆找回来。第三个是客户端适配层既提供 REST API 给脚本调用也提供一个 OpenAI 兼容的网关让支持自定义接口地址的 AI 客户端可以直接接入。为什么用服务化而不是做成某个客户端插件因为插件的边界太窄。你想让五个不同客户端共享就得写五个插件而且每个客户端的插件机制还不一样。服务化之后记忆是独立的客户端是流动的今天用 ChatGPT、明天换成 Claude、后天接一个自研 Agent接入成本都不会变化。2.2 技术选型SQLite 加轻量向量检索有人一看“语义检索”就觉得得上一套专业向量数据库。实际做下来我的结论是大多数人用不上。MemTether 的默认存储是 SQLite嵌入向量用本地小型嵌入模型生成检索则通过构造临时内存索引完成。为什么不直接用专业的向量数据库两个原因第一单用户的记忆量级在几万条上下SQLite 完全撑得住部署成本却低得多第二本地优先是目前 AI 记忆类工具里最稳妥的定位用户记忆是隐私敏感数据默认能让人把数据放在自己手里比什么都重要。我把嵌入式检索做了一个可替换的接口默认用余弦相似度做 top-K 召回。记忆条目多到单机膨胀之后可以换成支持向量索引的数据库插件但普通场景没必要也先别折腾。另外网关转发请求时用的是标准 OpenAI 兼容格式也就是说底层模型是 OpenAI、Anthropic、Ollama 还是本地 vLLM对记忆层而言并无差别。记忆层只认“文本进、文本出”不绑模型厂商。这避免了当前 AI 圈最常见也是最愚蠢的锁死。2.3 三种接入方式分别解决什么问题MemTether 提供三种接入方式原因很实际不同客户端能开放的程度差太多。第一种是 REST API最适合你写脚本或做定时任务时用。第二种是 OpenAI 兼容网关适合支持自定义 API 地址的桌面客户端和 Web 客户端把它们指向一个本地地址就能替它们注入记忆。第三种是 MCP 工具适合那些已经支持模型上下文协议 Model Context Protocol 的客户端把记忆的读写能力作为工具暴露给模型。三种方式覆盖了绝大多数场景。需要说明的是网关方式我做的默认行为是在每轮对话前用用户当前最近的输入做一次语义检索把相似度最高的记忆条目自动注入到系统提示里。这不是魔法就是一次基于自然语言的数据库查询只不过查的是“用户此刻最需要想起”什么。3. 记忆机制的实现写入、检索、更新和遗忘3.1 统一的记忆条目模型在设计数据库表结构前我先确定了记忆条目长什么样。一个有效可用的记忆条目不能只是一段话必须带足够的元信息否则后续检索、去重、过期都无从谈起。我把每条记忆定义为如下的结构id全局唯一的记忆 ID。user_id所属用户单机部署时通常是本机用户。scope作用域可以是user、project、global等。content实际记忆内容通常是自然语言短文本。importance重要度分数用于控制后面的召回排序。expire_at过期时间可选。source_client写入这条记忆的客户端标识。created_at和updated_at时间戳。为什么必须要有scope因为记忆如果没有边界就会互相污染。比如「不要用这个库」这句话在项目 A 里成立在项目 B 里可能是错误结论。我把作用域作为检索过滤条件之一明确要求调用方每轮查询都携带当前项目标识避免记忆串味。importance也不是摆设。有些记忆是随手记录的临时想法有些记忆是已经被验证过的严肃结论。检索召回时我会按“相关度×重要性”来排序避免临时想法把关键结论挤下去。3.2 检索不是什么黑魔法检索的核心逻辑不复杂。每一条记忆写入时先用本地嵌入模型生成一个 384 维的向量存进系统表查询时把查询文本也转成向量然后计算余弦相似度取 top-K。这里有个容易被忽略的细节只做向量检索会遇到一个典型问题就是精确术语匹配不到。比如你把记忆写成「电路板图纸存放在服务器 /data/pcb/ 下」查询时说的是「板子的源文件路径」向量相似度可能不理想但关键词检索能精准命中。所以我把召回设计成了双路的向量召回和关键词召回互相补充再做一次合并排序。在实现里这部分的处理顺序是先关键词过滤从候选集里筛出可能相关的记忆再对候选集做向量相似度排序最后用importance加权完成 top-K。这里我给个挺实用的经验不要只依赖嵌入模型尤其中文场景下先做关键词粗筛能明显减少丢召回。3.3 记忆的更新与遗忘机制记忆最怕的不是没有而是有过时的。如果机器人永远把旧方案当成最新决策那它等于在给你帮倒忙。为此我做了一套很轻的失效机制。更新规则有三种处理方式。第一种是直接覆盖当写入的记忆在作用域和主题上与已有高相似度条目匹配时默认把新文本作为最新版本。第二种是追加适用于“补充信息”类记忆。第三种是过期给每条可选设定 TTL时间一到就不参与检索比如机票什么时候出行这种临时信息。遗忘机制也有两层。一层是人工删除通过 API 或可视化面板直接删掉某条记忆。另一层是自动清理启动时扫描过期条目从索引中移除。自动清理不能覆盖人工删除这一点我留了一条硬边界涉及用户主动标记的记忆除非显式再确认否则不自动处理。3.4 上下文注入的边界控制网关自动注入记忆时最容易踩的坑是注入太多把模型上下文撑爆。我默认只注入系统提示之外的 1500 个 token也就是大概几百字。检索到十条相关记忆只选择排序最高的几条注入而不是全塞进去。同时注入的记忆必须带日期和来源标签比如“来自客户端 A三天前记录”。这样模型能清楚知道这不是当前用户刚说的内容而是历史记忆避免产生“用户上一句就是这个意思”的错误判断。这一条对防止模型误用记忆很关键。4. 实操演示两个客户端共享同一份记忆的全流程4.1 部署记忆服务我先说明一下条件建议 Python 3.11 及以上最好有虚拟环境习惯。MemTether 默认不需要 Docker 就能跑数据库直接落在本地文件里配置起来非常轻。部署步骤是这样先拉代码并安装依赖git clone https://github.com/yourname/memtether.git cd memtether python -m venv .venv source .venv/bin/activate pip install -r requirements.txt然后初始化数据库并启动服务python -m memtether init python -m memtether serve --host 127.0.0.1 --port 8001启动后记忆服务的地址是http://127.0.0.1:8001。我特意选了 8001 而不是 8000是为了避免和本地常用服务冲突。初始化时系统会创建 SQLite 文件、加载本地嵌入模型并自动建好三张核心表记忆条目表、向量索引表、客户端注册表。输入init后没有任何多余依赖整个启动过程大约两三秒。4.2 用 REST API 写入和检索记忆服务启动起来先用最直观的方式验证一下 API。写入一条记忆curl -X POST http://127.0.0.1:8001/v1/memory \ -H Content-Type: application/json \ -d { user_id: local-user, scope: project:website-refactor, content: 网站重构项目里所有组件必须带类型注释保留原有路由结构不要引入新的状态管理库。, source_client: manual, importance: 0.9 }返回一个带id的 JSON 对象后说明写入成功。接着模拟另一个客户端来查记忆curl -X POST http://127.0.0.1:8001/v1/remember \ -H Content-Type: application/json \ -d { user_id: local-user, scope: project:website-refactor, query: 这个项目做组件时有什么要求 }你会看到返回值里带上刚才那条记忆并且会有相似度分数。到这一步底层机制已经通了接下来把它接到真实 AI 客户端上。4.3 通过 OpenAI 兼容网关接入客户端我用 Chatbox 和一个自研脚本做了完整验证。操作上只需要把客户端的 API 地址改成 MemTether 的网关地址同时把 API Key 改成任意占位字符串因为真正做鉴权的是后面的模型后端。先看一眼网关配置用环境变量控制export MEMTETHER_BACKEND_BASEhttp://localhost:11434/v1 export MEMTETHER_BACKEND_KEYollama这个配置表示所有请求转给本地 Ollama。启动参数加上--gateway选项python -m memtether serve --gateway --port 8001然后把客户端 A 的 Base URL 填成http://127.0.0.1:8001/v1模型名填成你本地已经拉好的模型名。客户端 B 同样操作。两个客户端的模型后端是同一个但默认情况下它们依然互不相通——因为客户端的对话上下文并没有共享真正共享的是外面那层记忆服务。现在做一个验证。在客户端 A 里正常对话并触发记忆写入“记住我们统一用双引号不用单引号代码注释必须写中文。”这条消息会走网关MemTether 会从消息文本里识别记住这类写入指令把后续内容存为一条记忆。然后切到客户端 B直接问“这个项目代码风格有什么要求”客户端 B 的请求同样走网关MemTether 检索到刚才那条记忆自动注入系统上下文中客户端 B 就能回答出正确的风格规范。4.4 实测效果和性能开销我实测过的最典型案例是客户端 A 写入 50 条项目背景客户端 B 问一个需要其中三条背景才能回答的问题。在没有记忆服务的情况下B 的回答完全跑偏接入之后B 的答案准确率显著提升而且本地嵌入模型的处理时间只有几十毫秒对体验几乎没有影响。整个服务运行时内存占用大约 180MB其中大部分是本地嵌入模型。如果你换用更小的嵌入模型还能把内存压在 100MB 以内。对一台开发电脑来说这个开销可以忽略但对部署在服务器上同时服务多用户的情况还是建议做一次模型裁剪别一上来就跑最大的那个。5. 常见问题排查与避坑经验5.1 检索引回不准确怎么办这是所有记忆系统最核心的问题。我首先会怀疑的是查询文本和记忆文本的表达方式差太远。解决办法是给记忆条目增加别名和关键词标签。比如记忆内容里写的是“Cherry Studio”标签里可以补一个“Cherry 客户端”“CherryStudio”作为别名召回成功率立刻不一样。另一个常见原因是作用域过滤太严。我把 scope 定位成一个必须精确匹配的字段但如果你没想到项目代号叫做project:website-refactor查询时带成了project:doc-site那当然一条也查不到。所以建议在实现时给 scope 加一个模糊提示机制或者干脆默认允许跨作用域召回再在排序时给精确匹配加一个较大权重。5.2 记忆重复和互相覆盖记忆写多了之后最大的问题是重复。第一次写“偏好双引号”第二天又写“代码里统一用双引号”两条看起来不完全一样意思却一样检索时两条都会命中过一段时间数据就脏了。我在系统里加了相似度合并的触发开关写入前先和现有记忆做一次相似度计算超过阈值的默认执行更新逻辑而不是新增。至于阈值选多少我建议文本层面 0.86 以上都算同一主题。太低了容易合并掉关键差异太高了又起不到去重的作用这个数是我自己试了一轮之后相对稳的参考值。5.3 并发写入导致 SQLite 锁异常最开始我偷懒所有读写都直接落库结果多客户端同时写记忆时偶发database is locked。这个问题在 SQLite 里很典型解决办法不算难但容易忽略。我最终的方案是三层处理写操作统一走一个队列由单线程 worker 顺序执行读操作走连接池并用 WAL 模式开启并发读所有嵌入向量的计算都在队列外先完成不让耗时的向量化过程占用数据库连接。改完以后连续压测几百条并发写入也没有再出过锁问题。5.4 隐私边界在哪里最后说个容易劝退用户的点记忆服务既然保存了这么多自然语言内容就得把它们当成密码本一样看待。我的原则是默认本地优先所有数据存在本机不做任何默认云端备份日志里不记录记忆内容只记录操作行为。有人问过为什么不做云同步。这其实是一个取舍。做成云同步确实更方便但代价是用户必须信任第三方来保存最私密的对话数据。我做的最多是一个“导出全部记忆”的接口让用户可以随时把记忆带走而不是被工具绑住。我觉得这种“最低信任等级”的设计才是这类工具真正的护城河。6. 继续折腾的一些方向和个人偏好当前 MemTether 已经支持了记忆的写入、检索、过期、去重和自动注入但离我理想中的形态还有距离。下一步值得做的是两个方向一是把检索出来的记忆组织成一张依赖关系图让模型不只看到单条记忆还能看到记忆之间的前后因果关系。二是加入“记忆复盘”机制定期把高频出现的零散片段归纳成一份更稳定的结构化知识让记忆从量变走向质变。另外我现在的默认方式是把记忆注入系统提示词。这个方案胜在通用缺点是对超长上下文模型的利用率不够。如果你的客户端支持更细粒度的工具调用我更推荐走 MCP 工具的方式由模型自主决定什么时候去查记忆而不是每轮都替它决定。最后多分享一条实战经验别一开始就急着设计几十种记忆类型。我最早把记忆分成用户偏好、项目背景、决策日志、情绪状态、对话摘要、代码规范等十几种结果发现分类边界根本划不清楚反而影响召回。后来简化成“事实型记忆”和“偏好型记忆”两种内部标签其余信息全部交给自然语言本身承载系统一下子清爽很多。工具越往外扩展越要先守住简单这一条。如果你的场景也卡在多客户端不互通这件事上建议先跑通一个最小闭环感受一下“记忆真的能被复用”是什么体验再决定要不要在这个方向上加更多想象力。