
我平时干活离不开AIChatGPT、Claude、本地Ollama、手机上的几个助手App轮着用。工具一多问题就来了每个客户端都有自己的对话记忆但它们彼此完全不互通。上午在ChatGPT里敲定的技术方案下午到Claude那边问一个接口细节它一脸茫然。这种记忆碎片化让我实在忍不了了于是自己动手写了一个开源工具MemTether核心就一件事——让多个AI客户端共享同一份记忆聊天界面还是各家自己的但“记忆”统一由外面这层服务管。之所以叫这个名字tether就是“拴住、绑定”的意思把分散在各个客户端里的上下文通过一个公共存储层绑在一起。这个工具适合谁开发者、知识工作者、重度AI使用者凡是日常要在两个以上客户端切换的人都用得上。它解决的不是单个客户端“记性好不好”的问题而是“我换了个工具、我的上下文丢了”的协作断层。接下来我把设计思路、技术选型、完整实现和踩过的坑都摊开讲你可以直接照着复现。1. 这个项目到底解决什么问题1.1 多客户端之间的记忆断层我先描述几个具体场景你大概率也遇到过。场景一我在ChatGPT里讨论了一套微服务的拆分方案明确了每个模块的边界和数据流向。下午切到Claude想让它基于这个方案写一份接口文档它完全不知道上午聊了什么我只能把整个方案重新贴一遍浪费大量token。场景二本地Ollama跑着一个私有模型我喜欢让它处理一些不便于传到云端的文本。但它上下文窗口有限超过一定轮数就开始忘前面的约束条件。每次都得把“你是一个熟悉我们项目的助手以下三条规则必须遵守”这种话重复粘贴。场景三手机上的AI助手和桌面端不互通。我在手机上问了一个问题回到电脑前想接着追问结果对话记录对不上手头又懒得去翻聊天历史。这些问题本质上是同一个每个客户端都维护一份封闭的记忆没有公共的、可被多个客户端读写的记忆层。所谓“AI有没有长期记忆”在单个客户端内部其实是模型靠上下文窗口和客户端自带的存储实现的但换成跨客户端就完全是空白。1.2 为什么不去做一个全家桶客户端可能有人会说你直接用某个全家桶产品不就行了比如在一个平台里把文档、对话、知识库全包进去。我也试过但有两个问题。第一全家桶把你锁死在它的生态里。很多客户端的对话记录、知识库格式都是私有的导出困难跨平台迁移更麻烦。我既想在桌面端用又想在手机上用还想接本地模型全家桶很难同时满足。第二工具之间本来就有分工。ChatGPT适合快速头脑风暴Claude在长文本写作上表现更好本地模型处理敏感数据更安心。我希望保留各自的长处只是让它们共用一套“记忆底子”。所以我选择做一个独立的、开源的中转记忆层——MemTether不替代任何客户端只是给所有客户端加一个外置大脑。1.3 合适的人群和不合适的场景这个工具最适合的人群是需要同时维护多个AI助手、并且希望它们之间的上下文能连贯的人。典型用户包括独立开发者、研究员、内容创作者、经常用AI做技术方案的人。但也得说清楚哪些场景不适合。如果你只是偶尔用一次AI对话之间确实没有延续需求那这个工具对你来说就是过度设计。如果你的记忆内容涉及极其敏感的商业机密且对数据隔离要求非常高那你要做的是私有化部署所有模型而不是把记忆集中到一个第三方服务上——虽然MemTether本身完全本地运行但你的实际使用环境决定了风险边界。2. 架构设计与技术选型2.1 总体结构记忆服务与客户端之间的三层关系MemTether的整体结构分为三层客户端层、协议层、存储层。客户端层就是你现在用的各种AI工具它们负责对话和生成。协议层是我定义的公共记忆接口——支持通过MCPModel Context Protocol调用也支持通过一个OpenAI兼容风格的小代理接入。存储层是一个SQLite数据库保存记忆条目。这样的分层设计有一个好处记忆服务不关心对话是怎么生成的也不关心生成模型是谁。它只负责两件事——接收来自任意客户端的“写入记忆”请求以及响应“查找记忆”请求。客户端与客户端之间不需要互相知道对方的存在它们都只与MemTether通信。我把这种模式称为“星型共享”中心是记忆服务外围是各客户端。2.2 对接协议的选型为什么主推MCP当时我可以选择的对接方式有好几种一种是直接改客户端源码适合开源客户端一种是给客户端做插件还有一种是走HTTP API。但最后我主推MCP。MCP是模型上下文协议相当于给AI应用提供的标准“目录服务”。它的核心逻辑是让客户端可以动态发现外部工具能力并调用。目前越来越多客户端原生支持MCP比如Claude Desktop、一些开源的IDE助手、知识库工具等。也就是说我不用为每个客户端写专门的插件只要把我的记忆能力封装成MCP server支持MCP的客户端直接就能用。当然现在还有大量客户端不支持MCP。所以我额外做了一个OpenAI兼容的代理层让那些只认OpenAI API格式的客户端也能通过自定义base_url接入。这个代理接收到请求后会先从记忆库捞出相关内容塞进system prompt里再转发到真正的模型接口。这样等于把“记忆注入”做在了代理层客户端无感知。2.3 存储层先从SQLite开始再谈向量检索记忆存储我第一版直接用SQLite。为什么不是MySQL、PostgreSQL或者向量数据库因为我需要的是零依赖、单文件、备份容易的存储方案。MemTether的定位是一个可以跑在树莓派或者一台老笔记本上的轻量服务SQLite完全够用。但只靠SQLite的LIKE查询是不够的——用户搜索“上周说的数据库选型”这种模糊语义关键词匹配会很糟糕。所以在存储层我留了扩展接口数据库内容会同步导出到向量索引中支持语义检索。向量检索的索引可以选sqlite-vec、Chroma或者纯本地运行的嵌入模型。默认情况不开因为会增加部署复杂度但架构上已经预留了位置。2.4 记忆模型短期摘要和长期事实分开管记忆不是简单地把聊天记录塞进数据库。我设计了两类记忆短期对话摘要和长期事实记录。短期摘要用于捕获一段对话的轮廓比如“这次对话确定了项目采用模块化架构讨论了登录模块的授权方案”。它的特点是更新频繁随着对话推进不断被覆盖和压缩。长期事实则是一些稳定的结论和用户偏好比如“用户偏好Python前端用Vue数据库用PostgreSQL”在每次新对话开始时都应该注入给模型。区分这两类的意义在于写入策略不同短期摘要走滚动更新窗口内的东西会随对话生成不断重写长期事实走去重和冲突覆盖不会因为同一个话题聊了三次就出现三条重复记录。这个设计直接避免了记忆库变成垃圾场。3. 核心实现与接入实操3.1 项目结构一览把MemTether拆开看核心文件不多memtether/ ├── config.yaml # 服务端配置 ├── store.py # SQLite存储与检索 ├── server.py # HTTP API服务 ├── mcp_server.py # MCP服务 ├── proxy.py # OpenAI兼容代理 └── requirements.txt # fastapi, uvicorn, mcp, openai整体不到一千行代码跑起来很轻。下面我挑关键部分讲实现。3.2 搭建记忆存储与检索服务先看store.py的核心逻辑。我对记忆条的字段设计是type区分short_summary/long_termcontent是正文source标记来自哪个客户端importance表示重要程度created_at和updated_at用于时间衰减。# store.py import sqlite3 import json import time class MemoryStore: def __init__(self, db_pathmemtether.db): self.conn sqlite3.connect(db_path, check_same_threadFalse) self.conn.row_factory sqlite3.Row self.conn.execute( CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, content TEXT NOT NULL, source TEXT NOT NULL, importance INTEGER DEFAULT 1, created_at REAL, updated_at REAL ) ) self.conn.commit() def write(self, type_, content, source, importance1): ts time.time() # 同一来源同一类型下完全重复的内容只更新时间 cur self.conn.execute( SELECT id FROM memories WHERE source? AND type? AND content?, (source, type_, content), ) row cur.fetchone() if row: self.conn.execute( UPDATE memories SET updated_at?, importance? WHERE id?, (ts, importance, row[id]), ) self.conn.commit() return row[id] self.conn.execute( INSERT INTO memories (type, content, source, importance, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) , (type_, content, source, importance, ts, ts), ) self.conn.commit() return self.conn.execute(SELECT last_insert_rowid()).fetchone()[0] def search(self, keyword, limit10): # 进阶版本可以换成 sqlite-vec 或外部向量检索 cur self.conn.execute( SELECT * FROM memories WHERE content LIKE ? ORDER BY updated_at DESC LIMIT ? , (f%{keyword}%, limit), ) return [dict(r) for r in cur.fetchall()]这里有个容易忽略的细节SQLite默认不支持并发写而多个客户端可能同时写入记忆。我通过check_same_threadFalse加上短暂的操作窗口来规避大部分并发问题实际操作中压力远没有想象中大因为AI客户端的请求频率本身不会特别高。如果你预期会有大量并发建议加上一个简单的内存队列写锁。HTTP API层用FastAPI实现提供两个核心端点# server.py from fastapi import FastAPI from pydantic import BaseModel from store import MemoryStore app FastAPI() store MemoryStore() class MemIn(BaseModel): type: str content: str source: str importance: int 1 class MemQuery(BaseModel): keyword: str limit: int 10 app.post(/memories/write) def write_mem(mem: MemIn): mid store.write(mem.type, mem.content, mem.source, mem.importance) return {id: mid, status: ok} app.post(/memories/search) def search_mem(query: MemQuery): results store.search(query.keyword, query.limit) return {results: results}启动服务很简单uvicorn server:app --host 127.0.0.1 --port 87653.3 通过MCP接入客户端MCP服务端我用的是FastMCP这个SDK写起来很顺# mcp_server.py import json from mcp.server.fastmcp import FastMCP from store import MemoryStore mcp FastMCP(MemTether) store MemoryStore() mcp.tool() def mem_write(type: str, content: str, source: str) - str: 写入一条记忆type可选short_summary或long_term return str(store.write(type, content, source)) mcp.tool() def mem_search(keyword: str, limit: int 10) - str: 按关键词搜索记忆 return json.dumps(store.search(keyword, limit), ensure_asciiFalse) if __name__ __main__: mcp.run(transportstdio)在Claude Desktop里接入只需要编辑客户端的MCP配置文件{ mcpServers: { memtether: { command: python, args: [mcp_server.py], cwd: /path/to/memtether } } }保存后重启客户端模型就能通过mem_write和mem_search这两个工具来读写共享记忆了。我实测的效果是在ChatGPT里讨论完的结论可以手动写进记忆切到Claude后它会自己调用mem_search把相关上下文捞出来然后基于这些内容继续回答。3.4 给不支持MCP的客户端做兼容代理考虑到还有很多客户端不支持MCP我写了一个轻量代理proxy.py逻辑不复杂接受OpenAI格式的请求先从记忆库查询相关内容注入到system prompt再转发给真正的模型服务。# proxy.py import httpx from fastapi import FastAPI from pydantic import BaseModel from store import MemoryStore app FastAPI() store MemoryStore() UPSTREAM_URL https://api.example.com/v1/chat/completions API_KEY your-api-key class ChatRequest(BaseModel): model: str messages: list app.post(/v1/chat/completions) async def chat(req: ChatRequest): # 从用户问题里提取一个查询词这里先用最后一个用户文本 user_text req.messages[-1].get(content, ) memories store.search(user_text[:30], limit8) memory_block for m in memories: memory_block f- [{m[source]}] {m[content]}\n if memory_block: sys_prompt 以下是历史记忆请在回答时优先参考\n memory_block req.messages.insert(0, {role: system, content: sys_prompt}) async with httpx.AsyncClient() as client: resp await client.post( UPSTREAM_URL, headers{Authorization: fBearer {API_KEY}}, jsonreq.model_dump(), ) return resp.json()这样任何允许你自定义base_url的客户端把地址指向http://127.0.0.1:8000/v1就能自动享受记忆注入。4. 记忆管理机制写入、检索与更新4.1 写入策略让模型只记值得记的东西刚开始我让模型把每轮对话都写入记忆结果记忆库三五天就变成了一堆废话。后来改成只有在对话中出现明确结论、偏好或者关键事实时才触发写入原始聊天记录不直接入库。实操上有两条经验。第一短期摘要要定期压缩比如每5轮对话把之前的摘要和新增内容重新合成一条新摘要旧摘要标记为过期第二长期事实写入前要做归一化比如“我用Python”“我在用python”应该合并成“用户使用Python”。归一化可以靠模型做也可以在记忆服务里做规则匹配。4.2 检索策略关键词、时间衰减和语义搜索检索直接影响记忆注入的质量。我用的是三级策略第一级是关键词匹配就是上面store.search的实现简单直接。第二级是时间衰减加权近期更新的记忆权重更高。第三级是语义检索需要把内容向量化后按余弦相似度召回。时间衰减的实现也不复杂在SQL里给updated_at加一个衰减系数即可SELECT * FROM memories WHERE content LIKE ? ORDER BY (importance * 0.5 1.0 / (strftime(%s,now) - updated_at 1) * 3600) DESC LIMIT ?这个公式的含义是重要程度和新鲜度各占一部分重要性高的记忆不会因为时间久就被甩出召回列表同时最新写入的内容依然靠前。4.3 冲突与覆盖同一事实不同说法怎么处理多个客户端反复写入同一类事实很容易产生冲突。我的做法是以sourcetypecontent三者组合判断是否重复对长期事实允许设置“主题键”user_idtopic同一主题下新写入的结论直接覆盖旧结论。举个例子用户昨天在ChatGPT里说“数据库用PostgreSQL”今天在Claude里改成“数据库计划迁移到TiDB”。这两条记忆如果不做覆盖就会同时存在模型检索时不知道该听谁的。我在写入接口里加了一个可选参数topic当topic相同时新的长期事实写入会把旧记录标记为superseded检索时默认排除。这样记忆库保持单一事实源。4.4 权限与隐私隔离记忆服务集中存放内容隐私必须考虑。我默认做了几层隔离一是监听地址默认127.0.0.1不接受外部网络访问二是接入需要token调用API时必须带Authorization头三是支持按namespace隔离不同项目、不同用户的记忆物理存在同一个库里但查询时通过namespace字段过滤。如果你需要多人共用同一个记忆服务我建议每个用户一个独立SQLite文件而不是共用一张表。这样即使出错也不会串数据。5. 实战中的坑与排查实录5.1 常见问题速查表现象原因解决办法MCP工具在客户端里不出现MCP server没启动或路径配置错误先手动执行mcp_server.py确认stdio启动无报错记忆检索返回空结果关键词过于宽泛或写入时source不一致查看库里是否存在该条记录调整检索关键词中文乱码SQLite连接默认编码问题写入时统一用UTF-8连接后执行PRAGMA encoding UTF-8记忆重复堆积缺少去重逻辑升级到支持topic覆盖的版本或手动清理库系统提示词被记忆撑爆检索结果过多把limit从10调低到5并做内容截断代理接入后模型行为异常记忆注入位置不对确认system prompt放在messages最前面5.2 三个现场排查案例第一个案例MCP工具在客户端里始终不显示。查了一会儿才发现是cwd路径写错了客户端启动子进程时找不到store.py模块直接报错。解决方法是把MCP服务的启动脚本改成绝对路径并且在配置里加上env: {PYTHONPATH: /path/to/memtether}。第二个案例记忆检索总是返回过时结果。因为我原本只用updated_at排序但有些旧记忆的重要程度很高反而被新写入的琐碎记录压下去了。后来我在排序公式里加大了importance的权重才让重要结论保持稳定可见。第三个案例同时接入三个客户端后数据库出现了偶尔的locked错误。原因是FastAPI的多线程并发写入SQLite。解决方法是给write操作加了一个threading.Lock把写入串行化。实测在个人使用场景下完全够用。5.3 一些值得单拎出来的小经验日志一定要打。我在server.py里给每次读写都加了一条结构化日志记录source、type、耗时。出了问题翻日志能快速定位是哪个客户端导致的异常写入。还有一个经验不要一股脑把所有历史对话都变成记忆。记忆的价值在于提炼不在于存储量。我大约每两三天会执行一次清理把短期摘要里已经不再重要的条目删掉长期事实里重复的内容合并。清理我用的是一个定时脚本调用模型对旧记忆做一次摘要合并效果比手动处理省心很多。6. 对这个项目的复盘与后续打算MemTether现在已经稳定跑在我一台迷你主机上所有常用客户端都指向它。和之前的体验对比最明显的变化是我在切换客户端时不用再花几分钟重新铺垫上下文直接说“你记得我们上次聊的那个方案吗”就能继续。跨客户端的连续性带来的效率提升比换一个更强的模型更直观。复盘来看这个项目的核心不在于代码量而在于把“记忆”这件事从各个客户端手里捞出来变成一个标准化的、可共享的基础设施。后续我打算加两块一块是用向量检索替换关键词检索让模糊语义的召回更准另一块是做一个简单的Web管理面板可以直接在浏览器里查看、删除、合并记忆条目。如果你也被多客户端记忆割裂的问题折磨可以直接去仓库clone一份跑起来试试跑通了记得来跟我反馈你的使用场景。我现在的日常就是靠这个小工具串起所有AI工具的它已经是我工作流里离不开的一环了。