ARTICLE DETAIL

资讯详情

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

MemTether:为AI客户端打造共享记忆层的开源实践

MemTether:为AI客户端打造共享记忆层的开源实践 如果你和我一样电脑上装着好几个AI客户端本地还跑着一两个开源模型那你大概率经历过这种崩溃上午在客户端A里把项目背景、技术约束、目标用户从头到尾梳理了一遍下午切到客户端B想让它接着写代码结果它完全不知道你在说什么你又得把上午说过的话原封不动重复一遍。每次切换都像失忆时间和耐心就是这么被一点点磨没的。后来我实在忍不了动手做了一个开源工具——MemTether让多个AI客户端共享同一份记忆。这篇文章就是我从想法到落地的完整复盘包括架构思路、数据结构、核心代码和踩坑记录希望能给同样被“AI失忆”困扰的人一点参考。1. 这个工具到底解决什么问题1.1 多客户端切换的“失忆”困境先说说我自己的真实工作流。我平时处理一个项目时习惯让不同AI干不同的事客户端A擅长长文本分析和产品梳理我把需求丢给它它能产出结构清晰的PRD客户端B写代码更顺手我让它实现接口和修bug本地部署的模型则用来处理一些不方便提交给外部服务的敏感内容。听起来很合理对吧但实际操作起来有个巨大的问题这三个“AI”之间没有任何共享信息。举个例子。某个周三我用客户端A把项目的数据库选型讨论清楚了结论是用PostgreSQL加Flyway做迁移。到了下午我打开客户端B让它写这个项目的建表脚本它压根不知道前面那个结论反而根据自己“更熟悉”的MySQL模板给出了完全偏离需求的代码。我当时的表情大概就是我们不是在聊同一个项目吗这种问题不是偶尔发生而是每次切换必现。我也试过把背景说明写在一个文件里需要时复制粘贴给AI。但问题是你不知道什么信息该给、给多少而且随项目推进背景文件越来越长改起来也麻烦粘贴进去又占上下文窗口。说白了这条路只适合一次性小任务根本撑不起一个信息会持续增长的真实项目。1.2 MemTether到底是个什么东西MemTether这个名字拆开看就是memory记忆加tether拴绳、连接。含义很直白把多个AI客户端的记忆用一根绳子拴到同一个锚点上。锚点就是我维护的一份结构化记忆数据任何客户端都能读写它。一句话定义MemTether是一个面向AI客户端的开源“记忆层”。它对外提供一组干净的记忆读写接口让客户端A写入的信息客户端B能通过检索获取到。它本身不强依赖任何特定AI也不替代任何AI只负责做一件事——让“记忆”独立于“客户端”存在。你可以把它想象成档案系统。你看病时病历不是跟着医生走的而是存在医院档案科。你换科室、换医生新医生调一下档就知道你的病史。MemTether就是AI世界的档案科。每个客户端都是“科室”它们可以调阅同一份“病历”而不是各自记一套互不相通的小本本。1.3 适合谁用能解决什么级别的痛点从实际使用场景看有三类用户会比较需要它。第一类像我这样在多个商业AI客户端和本地模型之间来回切换的个人用户。痛点是上下文断裂好处是终于不用每次重新做自我介绍。第二类小型团队做项目协作。每个成员习惯用的AI不一样有人用ChatGPT有人用Claude有人用本地Ollama。以前大家各自和AI聊聊出来的项目结论零散地躺在不同的对话记录里。接上MemTether之后团队的项目背景、已做决策、代码约束可以沉淀成一份共享记忆任何客户端调出来都是同一版本的事实。第三类在做AI Agent或自动化工作流的开发者。Agent跑起来经常需要多步决策每一步可能调用不同的模型。把中间状态写进记忆层整个流程就有了“短期工作记忆”而不是每步都是无状态的调用。当然它也不是银弹。如果你只是偶尔用AI查点资料、追个热点那根本用不上这玩意。它是给工作流“重”的人准备的属于锦上添花还是雪中送炭取决于你被重复背景说明折磨的程度。2. 整体设计思路为什么采用“记忆层”架构2.1 记忆和客户端为什么要解耦刚开始做这个工具时我脑子里其实闪过一个更直接的方案给每个客户端写插件直接扩展它们的记忆能力。调研了一圈之后我放弃了原因有三条。第一大多数商业AI客户端是黑盒。它们不开放自定义内存存储你只能在系统提示词里塞东西或者在会话里手动引导。这种“记忆”既不持久也不结构化更像临时便签。第二就算某些客户端自带记忆功能那也是各自独立的。我在ChatGPT里记住的东西Claude绝对读不到。记忆被锁在应用的会话体系里没法迁移。第三聊天记录本身是脏数据。里面夹杂大量语气词、错误尝试、重复讨论直接把整段聊天记录当记忆喂给另一个AI效果比你想象的差得多。它需要被提炼、压缩、结构化才能变成真正可复用的记忆。所以结论很清晰不要在客户端层面做记忆要做就做一个独立的中间层。这就像不要在每个App里单独存一份用户画像而是做一个统一的用户中心所有App都来调接口。解耦的好处是以后来了新客户端只要它能调用HTTP接口或支持工具协议我就能让它接入同一份记忆不用为每个客户端单独造轮子。2.2 三种可行方案的对比我梳理过市面上可能走得通的三条路线这里直接做成表格给大家看。方案实现方式侵入性通用性维护成本A. 客户端插件在客户端内写插件或自定义指令高受限于客户端开放程度低每接一个客户端要单独适配高客户端一升级就可能失效B. 聊天记录迁移解析导出文件转换后导入另一个客户端中只解决一次性迁移很低无法持续同步高格式识别是噩梦C. 中间记忆层独立服务各客户端通过标准接口读写低不改客户端本身高任何能调HTTP/支持工具协议的客户端都能接低只需维护一个服务端方案A是最符合直觉的但落地困难方案B听上去美好实际上聊天导出的格式五花八门而且只能导出一次性的无法做双向持续同步。方案C初看要多搭一个服务但它把复杂度和扩展性问题一次性解决了。我做MemTether选择的就是方案C。2.3 架构拆解Adapter、Memory Service、StorageMemTether整体的架构可以分成三层各层职责很清晰。第一层是适配层Adapter。它负责把不同客户端“拉齐”。具体做法是MemTether对外提供统一的HTTP REST接口同时也可以封装成MCP Server。MCP是现在很多客户端都在支持的模型上下文协议像Claude Desktop、Cursor这类支持MCP的工具可以直接把MemTether当作一个外部工具来调用。适配层做的事情就是自定义GPT也好、MCP server也罢最终都翻译成对记忆服务的统一调用。第二层是记忆服务Memory Service这是整个工具的核心。它负责记忆条目的写入、检索、更新、去重和遗忘判断。比如写到过的东西要不要合并检索时该用关键词还是语义向量多个客户端同时更新同一条记忆该怎么处理这些业务逻辑都收敛在这一层。第三层是存储引擎Storage。我第一版用的是SQLite后面为了做语义检索又加了向量索引。选SQLite的原因很简单个人工具部署要轻单文件存储没有运维负担数据主权也完全在自己手里。存储层对上层屏蔽了实现细节哪天记忆量真到了几十万条我可以把向量部分换成独立数据库上层接口不需要变。数据在客户端和MemTether之间的流动大概是这样的用户在Claude里问“我们之前定的数据库选型是什么”如果Claude通过MCP接入了MemTether它会调用记忆检索工具带上“数据库选型”这个查询MemTether在记忆库里检索把结构化的记忆条目返回给ClaudeClaude根据返回内容组织回答。整个流程里Claude不需要在系统提示里看见整段背景它需要的时候自己“想起”就行。3. 记忆的数据结构与读写机制3.1 一条记忆长什么样做过信息管理的人都知道没有结构的数据就是垃圾堆。MemTether的每一条记忆我设计成下面这样的JSON结构{ id: 1024, type: fact, content: 项目数据库选型为 PostgreSQL迁移工具使用 Flyway, tags: [project:alpha, database, architecture], source: chatgpt, created_at: 2025-03-18T10:20:00Z, updated_at: 2025-03-19T14:05:00Z, version: 3 }每个字段都有自己的用途。type表示记忆类型我目前定了四种fact是事实陈述preference是用户偏好summary是对话摘要progress是任务进度。为什么要做类型区分因为检索时可以按类型过滤而且不同类型的清理策略也应该不同。比如fact要长期保留临时性的progress可能过几天就没用了。content是记忆本身的内容要求把它写成一句独立可读的话而不是口语碎片。“用户喜欢简洁的回复”是一条好记忆“用户感觉好像那个就是比较喜欢短一点的吧”就不是。tags是标签我建议用统一的标签体系后面会讲它带来的收益。source记录这条记忆来自哪个客户端这个字段对调试和溯源特别有用。version是版本号用来处理并发写冲突。3.2 检索从关键词到语义向量的三层演进MemTether的检索能力不是一步到位的我走了一个渐进的过程这里分享出来可能对你自己实现类似功能有参考价值。第一版用的是SQLite的LIKE模糊匹配。说实话凑合能用但体验很差。比如记忆里存的是“PostgreSQL”你搜“数据库选型”根本搜不到因为两者没有共同的字面片段。LIKE匹配适合小数据量、查询词和内容字面高度重合的场景但对真实语言的多样性无能为力。第二版升级到SQLite内置的FTS5全文检索。FTS5提供了倒排索引和BM25排序算法能很好解决“单词匹配”问题速度也快。但对中文场景FTS5默认分词器并不好用。我一开始用的是unicode61它会按Unicode字符简单切分中文句子切出来的效果一言难尽检索准确率飘忽不定。后来我用自定义分词逻辑做补偿才把情况稳住。这段坑在第5章会详细说。第三版引入了向量检索这是MemTether真正“智能”起来的关键一步。我用嵌入模型把每条记忆的content转成一个几百维的浮点向量查询时把用户的问题也转成向量然后算余弦相似度取最接近的Top-K条返回。这就是所谓的“语义检索”。生活化理解全文检索像查字典必须字面匹配语义检索像看同义词词典你说“数据库选型”它能联想到“我们最后决定了PostgreSQL”。实际生产的时候我推荐用混合检索先向量召回一批候选记忆再用关键词或标签做一次精确过滤最后按一个加权分排序。MemTether默认就是这么做的单独用一个检索方式要么漏、要么杂。3.3 写入、更新与版本控制写入记忆看起来是简单的INSERT但实际上要小心两件事去重和更新策略。先说去重。经常出现的情况是客户端A写了一条“系统使用JWT做身份认证”客户端B过两天又写了一条几乎一样的。如果不做去重记忆库会满是重复条目检索结果长得都一样质量很差。我的处理方式是在写入前做一次相似度检查如果新条目和已有条目的相似度超过阈值比如0.85就不是新增而是更新原条目的updated_at、source等字段。如果相似度不高但明显有关联我会选择把新条目作为补充写进去保留下关联信息。再说更新策略。MemTether默认做得比较保守更新记忆时不允许裸UPDATE覆盖而是要带上版本号。写入时如果发现当前内存里的版本号和数据库里的版本号不一致说明有别的客户端改过这条记忆这时就触发冲突处理。冲突处理有三种策略策略行为适用场景last-write-wins后写入的覆盖先写的单机自用不纠结历史source-priority指定某个source优先团队有明确信息源优先级merge-mark保留旧版本并标记冲突审计要求高的场景对大多数普通用户last-write-wins已经够用。我自己使用时会设置source优先级比如把ChatGPT产生的项目决策类记忆当成高优先级来源避免本地模型偶尔编造的碎片把它覆盖掉。3.4 遗忘机制同样重要一提记忆大家都希望AI“记得越久越好”但真实使用中发现记忆库如果只增不减最后会变得一团糟。你存了几千条细节检索时每次都命中一堆过期的临时信息反而干扰判断。所以MemTether设计了一套轻量的遗忘机制。第一时间衰减超过一定时间没有被检索命中的记忆参考评分会降低被返回的排序会被推后。第二固定记忆用户可以主动给重要记忆打pinned标记被pinned的条目不参与衰减和清理。第三定期合并有些记忆是零散的对话摘要系统可以定期把这些低层记忆合并成一条更高层的摘要删掉细节。比如十条“讨论了数据库兼容性”的碎片合并成一条“Alpha项目数据库需兼容PostgreSQL和MySQL”。遗忘不是功能缺失而是记忆系统保持健康的必要手段。4. 从零实现第一版技术选型与核心代码4.1 技术栈选择及理由我实现MemTether第一版时技术选型其实没有太多纠结。后端选了Python和FastAPI原因有三一是Python的AI生态最成熟后续接嵌入模型顺手二是FastAPI写这种小型API服务非常快自带交互式文档调试体验好三是个人开源项目要保证别人能快速跑起来Python几乎不需要构建步骤。存储先用SQLite这是故意的。有人跟我建议直接用PostgreSQL但我觉得第一版能单文件跑起来最重要用户下载代码、装依赖、启动服务三步就能用上这种“零运维”体验对开源项目的传播帮助巨大。等到记忆量真大了存储层本来就被接口隔离换掉也容易。嵌入模型这块我初期直接调用sentence-transformers里的小模型本地跑离线也能用。虽然精度不如商用大模型API但胜在完全本地、没有调用成本。向量存储我一开始没有上独立数据库写在SQLite旁边的一个表里。原因是数据量到几万条以内纯暴力余弦计算完全能扛住没必要多引入一个服务依赖。4.2 项目结构第一版的项目结构很朴素就几个文件memtether/ ├── main.py # FastAPI入口注册路由 ├── models.py # Pydantic数据模型 ├── memory_store.py # 记忆写入、查询、版本控制 ├── retriever.py # 检索逻辑FTS5 向量 ├── embedder.py # 嵌入模型封装 ├── config.yaml # 配置存储路径、检索参数 └── requirements.txt不搞复杂的框架分层是因为核心逻辑本身不复杂摊开来反而好维护。等社区用户多了、功能需求多了再重构不迟。4.3 核心代码实现先说数据模型。用Pydantic定义MemoryItem在API入口做数据校验。# models.py from datetime import datetime from typing import List, Optional from pydantic import BaseModel class MemoryItem(BaseModel): type: str fact # fact / preference / summary / progress content: str tags: List[str] [] source: str # 来源客户端标识 created_at: datetime datetime.now() updated_at: datetime datetime.now() version: int 1然后是存储层这里放写入和FTS5索引的简化实现。注意content和tags会同步写入一张FTS5虚拟表供搜索引擎使用。# memory_store.py import sqlite3 from models import MemoryItem class MemoryStore: def __init__(self, db_pathmemtether.db): self.conn sqlite3.connect(db_path) self.conn.row_factory sqlite3.Row self._init_db() def _init_db(self): self.conn.execute( CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, content TEXT, tags TEXT, source TEXT, created_at TEXT, updated_at TEXT, version INTEGER DEFAULT 1 ) ) self.conn.execute( CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(content, tags, tokenizeunicode61) ) def write(self, item: MemoryItem) - dict: tags ,.join(item.tags) cur self.conn.execute( INSERT INTO memories (type, content, tags, source, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?) , (item.type, item.content, tags, item.source, item.created_at.isoformat(), item.updated_at.isoformat(), item.version)) self.conn.execute( INSERT INTO memories_fts (content, tags) VALUES (?, ?) , (item.content, tags)) self.conn.commit() return {id: cur.lastrowid, **item.model_dump()}检索层这里给出FTS5关键词检索部分。向量检索的实现会额外引用embedder核心就是先算向量再排序这里为了篇幅就不展开了。# retriever.py class Retriever: def __init__(self, store: MemoryStore): self.store store def keyword_search(self, query: str, limit: int 5) - list: # 这里做了简化的查询词转义实际使用需要更完整的处理 q query.replace(, ) rows self.store.conn.execute( SELECT m.id, m.type, m.content, m.tags, m.source, m.updated_at, bm25(memories_fts) AS score FROM memories_fts JOIN memories m ON m.id memories_fts.rowid WHERE memories_fts MATCH ? ORDER BY score LIMIT ? , (q, limit)).fetchall() return [dict(row) for row in rows]最后是FastAPI入口对外暴露两个核心接口写入和检索。# main.py from fastapi import FastAPI from models import MemoryItem from memory_store import MemoryStore from retriever import Retriever app FastAPI(titleMemTether) store MemoryStore() retriever Retriever(store) app.post(/write) def write_memory(item: MemoryItem): return store.write(item) app.get(/search) def search_memories(q: str, limit: int 5): return retriever.keyword_search(q, limit)跑起来之后接口调用方式非常直观。写入一条记忆curl -X POST http://localhost:8000/write \ -H Content-Type: application/json \ -d {type:fact,content:Alpha项目数据库选型为PostgreSQL,tags:[alpha,database],source:chatgpt}检索记忆curl http://localhost:8000/search?q数据库选型limit5这套代码已经能构成一个最小可用的记忆服务。真正接入客户端时再把向量检索和MCP Server封装加上体验会再上一个台阶。4.4 客户端接入的几种真实路径客户端接入MemTether我实际操作下来主要走三条路。第一条是走MCP协议。Claude Desktop、Cursor这类客户端都有MCP配置入口我可以把MemTether封装成一个MCP Server里面暴露两个工具mem_write和mem_search。配置好之后AI在对话中需要回忆时会自动调mem_search用户也可以让它把当前讨论的结论写入记忆。这种接入方式对用户最隐形AI会“自主”使用记忆能力。第二条是走HTTP API加提示词注入。对于不支持MCP但支持自定义API的客户端比如我通过OpenAI Action接入ChatGPT或给本地Ollama套一层代理做法是在系统提示词里加一段模板“你可以调用MemTether接口查询背景记忆。查询接口是GET /search?q关键词写入接口是POST /write。回答用户问题前先用查询接口检索相关背景。”这种方案虽然比MCP笨一点但兼容面广几乎不需要客户端原生支持什么新协议。第三条是本地脚本直接调用。我在一些自动化任务里写一个Python脚本在调用模型之前先向MemTether查询相关记忆拼进prompt再把模型返回的结论写回MemTether。这个过程和客户端无关完全是程序化的。对做Agent的人来说这条路径最灵活记忆系统变成一个随时可存取的数据源。5. 常见问题速查与避坑心得5.1 典型问题速查表用了一段时间又被早期使用者反馈了不少问题我把高频的整理成一张表方便大家对照排查。现象原因解决方案检索完全搜不到某条记忆中文分词不合适或查询词和内容字面无关换自定义分词减少对纯关键词的依赖启用向量检索多个客户端同时写后一个覆盖前一个没有版本控制盲目UPDATE写入时对比version冲突时按source优先级取记忆越来越多但检索结果反而越来越差缺少去重和遗忘机制垃圾条目堆积写入前做相似度去重定期跑合并脚本同一个问题在不同客户端上得到不同答案各客户端只检索到不同片段调整Top-K数量建议5~10统一标签体系向量检索变慢全量暴力算余弦相似度对向量做主成分压缩后续可引入独立的向量库5.2 踩过的几个大坑第一个坑是FTS5和中文的“兼容性”。我一开始天真地以为FTS5开箱即用结果用unicode61分词后像“数据库选型”这种词被切得稀碎存进去和查出来的对齐方式完全对不上导致很多关键词明明存在却检索不到。后来我改用jieba先分词把分词后的词序列存进FTS5索引检索时也走同样的分词流程问题才解决。如果你也在做中文检索这块建议提前留出调试时间别等上线了才发现在分词上栽跟头。第二个坑是并发写入导致记忆静默丢失。有一次我用两个客户端同时让它们更新同一份技术方案结果后写的覆盖了先写的里面有几条重要约定消失了。排查之后就是因为我当初直接UPDATE没有版本控制。后来我把version字段加上更新时带上IF条件只有版本号匹配才更新。自那以后就再没发生静默覆盖的问题。第三个坑是“什么都存”。刚开始用MemTether时我抱着多多益善的心态什么对话都往里面写结果三天后检索返回的前几条全是废话和过期状态。后来我下决心做了两件事一是给type定义清楚只有有价值的事实和决策才用fact二是写了一个每周清理脚本把零散的summary合并成更高层的条目。核心原则是记忆系统的价值不在于存得多而在于记得准。5.3 关于开源和实际使用的小建议最后聊几个和项目本身无关、但很实用的心得。第一标签体系一定要提前建。我在项目初始阶段没太在意标签全部靠关键词检索效果只能说一般。后来给项目定了统一的标签规则比如project:alpha代表项目、tech:database代表技术栈检索时先拿标签过滤一轮准确率直接上了个大台阶。tags这个字段是我最开始设计时觉得最可有可无的现在反而是最高频的过滤条件。第二README就是产品。这个项目在GitHub上开源、用MIT协议发布但我发现用户愿不愿意尝试往往取决于README写得够不够直观。我后来狠狠花了一晚上把“它解决什么问题”“怎么快速跑起来”“怎么接入常见客户端”写清楚star和issue的质量立刻不一样了。开源工具的第一批用户不是因为功能多来的是因为“我能不能在5分钟内跑起来”来的。第三别急着加功能。我的第一版只有写入和关键词搜索两个接口照样解决了我自己的问题。真正的痛点在于稳定好用而不是功能炫技。有不少人问我为什么不直接做成浏览器插件我解释说插件的自由度太小一个独立记忆层以后能对接的客户端是无限的这个定位在初期已经验证是对的。如果你也在多个AI客户端之间切来切去体会过那种每次都要重新讲一遍背景的感觉我建议你试一下这个思路哪怕不直接用MemTether也可以自己搭一套记忆服务。核心就一句话让记忆属于你自己而不是属于某个AI客户端。这个方向我后续还会继续迭代目前最想做的是把记忆的遗忘和合并策略做得更聪明让工具真正像人一样“记重点”。
返回列表