先交代背景。我一直在做 AI 辅助日常工作的落地,桌面端、Web 端、手机端、编辑器插件轮着用。用了半年多,最烦的一个问题就是:同一个 AI 服务,在这个客户端里聊过的上下文,换到另一个客户端就全断了。比如我在电脑上让 AI 梳理了一份项目的技术方案,转头在手机上问“那个方案里的数据库选型定了没有”,它一脸茫然。这种“记忆断裂”在 AI Agent 场景下尤其致命,因为 Agent 的连续推理、多步任务执行都依赖上下文,而上下文一旦散落在多个客户端里,等于没有上下文。
后来我去调研了 mem0,业内很火的开源记忆层方案,理念很吸引人:把每次对话提取成结构化记忆,用向量和图混合存储,查询时做智能重排。但我把它接入到真实的多客户端工作流里跑了两周之后,还是决定自己写一套跨客户端 AI 记忆共享系统。这篇文章把我当时的对比过程、踩过的坑、最终的自研设计和关键参数完整记录下来,给同样在折腾 AI Agent 记忆层的朋友做个参考。
1. 我为什么放弃了 mem0:不是它不够好,而是场景不匹配
先说结论:mem0 本身是个好项目,但它的默认设计是为“单客户端、单 Agent、以查询为核心”的场景服务的。而我实际面对的是“多个客户端、多个 Agent、以同步为核心”的场景,这两者对架构的要求完全不一样。
1.1 先说我的实际场景:多客户端共享的真正困境
我的日常使用方式是这样的:电脑上的浏览器插件负责长文阅读和资料整理,手机上的 AI 助手负责碎片化记录和语音问答,IDE 里的 AI 插件负责代码生成和项目理解,还有一个跑批任务的脚本会定期和 AI 交互,生成日报。这些客户端理论上都在服务同一个“我”,但它们各自的对话历史、用户画像、项目知识是完全割裂的。
这意味着什么?我在 Web 端告诉 AI“我项目 A 的技术栈是 Python + FastAPI + PostgreSQL”,换到手机端去问“项目 A 的部署脚本在哪”,它回答不了。它连项目 A 是什么都不知道。更麻烦的是,如果两个客户端同时问我同一个问题,它们的回答会基于完全不同的上下文,给出两个互相矛盾的结论。
所以跨客户端记忆共享的第一步,不是把记忆“存起来”,而是把记忆“从单机私有状态变成多端一致的公共状态”。这个从“私有”到“公共”的转变,是架构层面的大改动,而不是在现有框架上打个补丁。mem0 在我的场景里吃亏就吃亏在这一点。
1.2 mem0 的设计优势,以及它在我这里的三个硬伤
必须客观说,mem0 的理念和模块划分是漂亮的。它把 memory 抽象成三类:用户记忆、会话记忆、Agent 记忆,底层用向量库做语义召回,用图数据库存实体关系,查询时会做一种“来自不同来源的智能重排”,把最相关的记忆优先拿出来。这种设计在“单 Agent 连续对话”的场景下表现很好,我单独测试时也确实觉得它有灵气。
但放到多客户端共享场景里,三个硬伤立刻暴露:
第一,mem0 本质上是“库”而不是“服务”。默认用法是每个客户端进程内初始化一个 Memory 实例,各自独立工作。虽然官方也提供了服务化和自托管方案,但客户端要共享记忆,必须自己解决登录态、用户映射、会话归属等一系列问题。这部分官方给的引导偏少,集成时基本靠猜。
第二,记忆提取和查询重排都重度依赖 LLM。每轮对话要调用多次模型接口做提取、打分、重排。在我每天几十条消息的体量下,账单不至于吓人,但一旦有多个客户端同时在线,又都往同一个记忆服务上写,成本翻倍波动很明显。而且 LLM 调用是有延迟的,提取一次记忆平均 300-600 毫秒,这个延迟会直接叠加在用户可感知的响应路径上。
第三,数据模型偏“单用户单 AI”。它设计了一套以 person_id 和 memory 为核心的简单结构,但我的场景里需要给不同项目、不同 Agent、不同客户端做隔离和权限控制。比如公司项目的记忆不应该出现在个人闲聊的上下文里,这个需求用 mem0 的默认数据模型得自行扩展很多字段和过滤逻辑,等于在别人设计的骨架上做二次重构。
1.3 成本、延迟、集成度:我跑的一组对比数据
为了让“放弃 mem0”这个决定不是凭感觉,我专门做了一组对照测试。测试环境是同一台 8 核 16G 的服务器,记忆条目数控制在 3000 条,客户端接了 3 个,模拟连续对话 100 轮。
| 对比维度 | mem0(自托管 + 云端 LLM) | 我的自研方案(本地模型 + 混合检索) |
|---|---|---|
| 单次查询平均延迟 | 620ms(包含 LLM 重排) | 96ms(词法 + 向量融合) |
| 单次记忆提取成本 | 约 0.01-0.02 元(API 调用) | 几乎为 0(本地 embedding + 本地小模型) |
| 多客户端同步 | 需要自行搭同步层 | 服务端原生支持,客户端接 API 即同步 |
| 客户端接入耗时 | 约 1-2 天(登录态 + 同步逻辑) | 约 2 小时(一个 SDK 搞定) |
| 数据隔离粒度 | 粗(需要自行扩展) | 细(namespace + client 双维度) |
这组数据说明了一个朴素的问题:在单机、单客户端、数据量几千条的场景里,mem0 完全够用。但我的核心诉求是“多端一致”和“可控成本”,这两点它给不了。所以我决定自己写一个,哪怕牺牲掉一些花哨的重排能力,也要先把跨客户端记忆同步这个地基打牢。
2. 动手前先想清楚:记忆系统的边界条件和设计取舍
说实话,一开始我也想“上一个完整的记忆系统”,做了几天之后发现方向偏了。记忆系统的目标不是“记住所有东西”,而是“在需要的时候把恰好相关的信息准确找出来”。想明白这一点,很多功能都可以砍掉,架构也会简单很多。
2.1 跨客户端记忆到底要解决哪三个问题
我把需求压缩成三个问题,后续所有设计都是围绕它们展开的。
第一是写入一致性。客户端 A 写入一条记忆,客户端 B 必须能立刻看到。这里的关键不是“最终一致”,而是“低延迟一致”。因为 AI 对话是交互式的,如果手机端问了问题,却拿到的是桌面端 1 小时前的记忆快照,用户立刻会感觉到不对。
第二是检索准确性。记忆库里可能存了用户几个月以来的对话摘要、项目信息、偏好设置。用户问“上次说好的接口返回格式是什么”,系统要能准确找到那一条,而不是把所有含“接口”两个字的记忆都倒出来。这要求检索不能只靠语义相似度,还要有词法匹配、时间衰减、重要性加权等多重信号。
第三是隔离与安全。多个客户端共用一个记忆库,不代表所有客户端可以看所有记忆。我明确要求:公司项目的记忆只对工作客户端可见,个人偏好只对个人助手可见。这个隔离必须在系统层面做好,不能靠每个客户端自觉。
2.2 记忆分层:短期、长期、全局、局部的设计思路
我参考了认知科学里工作记忆和长期记忆的区分,把记忆分成四个池子。
短期记忆池保存的是最近若干轮对话的摘要,TTL 很短,可能是 2 小时或者一个会话的生命周期。它解决的是“同一会话内的连续性”,比如你刚才让 AI 写了一段代码,现在问它“这个代码里为什么用了异步”,它得记得刚才的上下文。
长期记忆池保存的是跨会话的稳定信息,比如用户的偏好、项目背景、技术选型、做事习惯。这类记忆的 TTL 很长,重要性高,是检索时的重点对象。
全局记忆池保存的是关于用户身份的基础信息,比如“这个用户是一名后端开发者”“他倾向于先写测试再写实现”。全局记忆会被所有客户端共享,所有 Agent 在首次交互时都会先读取它。
局部记忆池则带 namespace 隔离,比如某个具体项目的记忆归到 project:xxx 命名空间下,只有处理这个项目的 Agent 才能访问。
这四个池子不是物理上分开存储的,而是同一份数据带上不同标签,在写入时通过标签分类,在检索时通过标签过滤。这样存储层保持简洁,逻辑层的灵活性也够。
2.3 存储选型:为什么我选了词法 + 向量混合,而不是纯向量
很多人一想到“AI 记忆”就默认得用向量数据库。我一开始也这么想,但做了实验之后改变了主意。纯向量检索有个隐蔽的缺陷:语义相近不代表因果相关。用户问“明天早上提醒我开会”,向量检索很可能召回“他每天早上有跑步习惯”这种语义上挨得着、实际上没用的记忆,因为两者的向量距离确实不远。
所以我的存储层没有走“单一向量库”路线,而是做了混合检索。对每一次写入,既生成 embedding 向量存入向量索引,也把原文做分词后存入全文索引。查询的时候,两路检索并行执行,再通过一个融合算法把结果合并排序。这样既保留了语义召回对“同义不同词”的泛化能力,也保住了词法匹配对准确关键词的精确命中。
生产环境我用了 PostgreSQL 加 pgvector,单机开发环境直接用 SQLite 加 FTS5 和内置向量扩展。SQLite 版本在我测试 5000 条记忆时,混合检索的耗时大概在 50-80 毫秒,完全够用,不用一上来就想着上分布式。
3. 自研跨客户端 AI 记忆共享系统的整体架构
这一章讲清楚系统长什么样、数据怎么流动、各模块之间怎么配合。
3.1 核心组件与数据流
我的系统分成四个核心组件:记忆服务端、客户端 SDK、维护脚本、LLM 提取模块。记忆服务端是中心,所有读写请求都经过它。客户端 SDK 是一个轻量 HTTP 封装,负责把各端的对话上下文快照发送到服务端,并拉取相关记忆。维护脚本负责定时做记忆衰减、归档、一致性校验。LLM 提取模块是从对话中抽取结构化记忆的关键环节,但它被设计成独立服务,可以随时降级或替换。
完整的数据流是这样的:用户在某个客户端里说了一句话,客户端先把这句话作为查询条件,调用记忆服务的检索接口,拿到与当前语境最相关的历史记忆,拼接到 Prompt 里再发给大模型。模型返回回答后,客户端把这一轮对话发送到记忆服务的写入接口。写入接口先做一轮隐私过滤,把明显的身份证号、手机号、密钥打码或剔除,然后交给 LLM 提取模块抽取出偏好、事实、决策等结构化记忆,再生成 embedding,最后落库。落库成功后会通过消息队列广播一个“记忆更新”事件,其他在线客户端收到事件后自动刷新本地记忆缓存。
这个流程的核心原则是“读优先、写异步”。用户发出的查询必须尽快返回,所以检索路径一定要短;写入可以放到异步队列里慢慢处理,不阻塞用户的对话响应。
3.2 记忆条目的数据结构设计
数据结构是在传统键值对基础上扩展出来的,核心字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 全局唯一记忆 ID |
| namespace | string | 隔离域,如 project:alpha / personal:general |
| client | string | 写入客户端标识,如 web / mobile / ide |
| type | string | 记忆类型:preference / fact / decision / entity |
| content | string | 记忆正文,通常是一句完整的话 |
| embedding | vector | 向量化的内容表示 |
| importance | float | 重要性分数 0-1,影响检索排序 |
| ttl | int | 过期时间,默认 -1 表示永久 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
| version | int | 版本号,用于冲突合并 |
| meta | json | 扩展元信息,如来源对话 ID、关联实体列表 |
这个结构里最有用的是 namespace 和 type 两个字段。namespace 解决隔离问题,type 解决记忆多样化问题。比如 type=decision 的记忆在排序时权重会高一些,因为“用户拍板过的决定”比“随便说过的一句话”更值得被记住。
3.3 API 设计与客户端接入方式
客户端只需要对接两个核心接口,一个是检索,一个是写入。
检索接口接收 query、namespace、client、top_k 等参数。服务端把 query 做词法检索和向量检索,混合排序后返回命中的记忆列表。写入接口接收 session_id、client、messages 数组,服务端自行完成提取和落库。还有一个可选的订阅接口,客户端通过 WebSocket 订阅某个 namespace 的记忆变更事件,用于实时刷新本地缓存。
这样的接口设计让客户端接入成本降到很低。我现在的做法是每个客户端集成一个 200 行左右的 SDK,封装好这三个接口,其他什么都不用管。实测下来,接入一个新客户端从开发到联调,半天能完事。对比之前用 mem0 时自己搭同步层的 1-2 天,效率提升非常明显。
4. 核心模块的实操实现与关键参数
接下来是重点,我会把每个模块的具体实现方式、关键参数、以及我当时怎么调优的细节都写出来。
4.1 记忆写入管线:从对话到结构化记忆
写入管线是整个系统里最复杂的一环,也是直接决定记忆质量的一环。它的核心工作是把一段自由对话压缩成几条结构化的记忆条目,同时过滤掉噪音和隐私信息。
我用的 LLM 提取 Prompt 模板大概是这样的:
你是记忆提取助手。从下面的对话中提取值得长期记住的信息。 只提取以下四类: 1. preference:用户的偏好、习惯、禁忌 2. fact:客观事实、项目背景、技术选型 3. decision:用户做出的决策、拍板过的结论 4. entity:重要的人、项目、工具、时间节点 输出 JSON 数组,每个元素包含 type, content, importance(0到1), expires_in(小时,-1表示永久)。 如果没有可提取的内容,输出空数组。这个模板看似简单,但它起到的作用非常关键。它强制 LLM 用固定格式输出,方便程序解析;分类别提取又方便后续按类型做权重排序。我在实际使用中给 importance 做了一个启发式修正:如果对话里出现了“我总是”“我从不”“一定不要”这类强偏好词,importance 就自动加 0.2;如果记忆内容涉及用户明确给出的项目代号或时间节点,importance 也会上调。
embedding 生成我一开始用的是云端接口,后来为了降延迟和成本,换成了本地部署的 embedding 模型,单条文本的向量化时间约 10-20 毫秒。提取用的 LLM 则用了一个量化到 4bit 的 7B 开源模型,跑在 GPU 上,单次提取延迟约 400 毫秒。因为是异步处理,这个延迟不会暴露给用户客户端。
写入有一个重要细节:不是每一轮对话都需要提取记忆。我把消息按“是否触发新信息”做了过滤,高频的寒暄、重复提问、简单确认语都不会进入提取流程。这个过滤规则让 LLM 的调用量减少了约 70%,成本下降非常明显。
4.2 记忆检索管线:混合检索和重排的权衡
检索管线是用户感知最强的部分,我把目标定在 150 毫秒内返回结果。
第一步是词法检索。用全文索引的 BM25 算法把 query 分词后匹配,命中的就带上一路候选集。第二步是向量检索。用 embedding 模型把 query 向量化,在向量索引里按余弦相似度取 top 50。第三步是融合排序。我用的是经典 RRF(Reciprocal Rank Fusion)公式:
score = sum(1 / (k + rank_i))
其中 k 设成 60,rank_i 是该条记忆在某一检索路中的排名。融合后取 top 20,再做过滤和重排。
过滤规则按顺序执行:先过滤掉 namespace 不匹配的记忆,再过滤超过 TTL 的过期记忆,最后过滤掉带隐私标签的记忆。重排规则用线性加权:最终分 = 0.5 x 融合分 + 0.3 x importance + 0.2 x 时间衰减权重。时间衰减权重的公式是 exp(-age_days / 180),即 180 天半衰期。这样设计的结果是:近期的重要决定排在前面,陈旧且不重要的记忆自然沉底,语义相关但实际无用的噪音也有机会被压下去。
4.3 跨客户端同步与冲突合并:我在这里踩过一个深坑
跨客户端同步是整个系统的招牌功能,也是踩坑最多的部分。我最初的方案很简单:每次写入直接改数据库,客户端查询时实时读库。结果发现一个问题——客户端为了降低延迟,会在本地做缓存,而缓存更新的触发条件如果设计得不好,就会出现“桌面端已经更新了记忆,手机端还在用旧数据”的同步延迟,甚至因为两边同时写同一条记忆,出现版本互相覆盖的冲突。
后来我把同步机制改成“服务端推送 + 本地缓存失效”。服务端每次写入成功后,通过消息队列向订阅了该 namespace 的在线客户端推送一条变更通知。客户端收到通知后,把本地缓存里的对应记忆标记为过期,下次查询时强制回源。本地缓存用 LRU 策略,热点记忆 TTL 设为 15 分钟,普通记忆 2 小时。
冲突合并策略则用“版本号 + 时间戳”双管齐下。每条记忆带 version 字段,客户端读取一下版本再写入。服务端比较版本号,只接受高于当前版本的写入。如果两个客户端同时基于同一版本修改了同一条记忆,则取 updated_at 更新的一条为准。为了唯一性,每次写入都配一个全局唯一的 request_id,服务端用这个 ID 做幂等,避免网络重试导致重复写入。这个方案在内存条款里牺牲了一些精细合并能力,但它简单可靠,尤其适合记忆这种“取最新有效版本即可”的数据类型。
4.4 记忆衰减、过期和冷热分层
记忆不是越多越好,存得太多反而会拉低检索准确性。我专门加了衰减和归档机制。
维护脚本每隔 6 小时跑一次扫描,对 TTL 到期且 importance 低于 0.3 的记忆,直接标记为“已归档”,从主索引里移除,但保留在冷存储里可追溯。对 TTL 到期但 importance 较高或 type=decision 的记忆,则延长 TTL,例如再续 180 天。对超过 90 天没有命中的记忆,即便没有过期,也会降权处理,避免陈旧记忆持续影响检索排序。
冷热分层不是一开始就做的。我最初把所有记忆都放在同一个索引里,结果数据量到了 8 万条时,检索耗时明显上升,约 400 毫秒。后来把“最近 30 天活跃记忆”放入热索引,其余放到冷索引,查询时先查热索引,未命中再降级查冷索引。这样一个简单的改动,让 95% 的查询都停在热索引阶段,耗时回落到了 80 毫秒以内。
5. 性能、成本与稳定性的一线实测
技术方案不能只停留在理念上,我把上线以来的实测数据整理出来,这些数字基本可以复现。
5.1 性能数据:常规量级和多租户情况
| 场景 | 记忆总量 | 单次查询耗时 | 单次写入耗时(异步摊分) |
|---|---|---|---|
| 个人日常 | 5000 条 | 60-90ms | 约 300ms |
| 项目知识库 | 2 万条 | 100-120ms | 约 350ms |
| 多 Agent 共享 | 8 万条 | 180-250ms | 约 400ms |
这里关键的一条优化是批量写入。原来我一条一条提取、一条一条落库,效率低。后来把同一会话中连续的 5-10 轮对话合并成一个大请求,批量提取、批量写入,写入吞吐提升了大概 3 倍,LLM 调用次数也显著下降。
5.2 成本对比:自研和 mem0 的账单差异
成本是我决定自研的最现实原因之一。我按每月 30 万条消息的规模粗略算过一笔账。
用 mem0 加云端 LLM 方案,假设 30% 的消息触发记忆提取,每次提取消耗约 500 token,大约要花掉 15 万次 LLM 调用,按当前市场价算一个月光提取费用就在 100-200 元。如果查询时开启 LLM 重排,这个数字还要再涨 30%。
自研方案里,LLM 提取用的是本地开源模型,embedding 也走本地,电费和 GPU 折旧摊下来每个月大概 30 元。两个方案差了一个数量级。这还是不谈数据隐私的代价。云端 LLM 要把对话原文传出去做提取,这一条在我们处理项目文档时是不能接受的。
5.3 稳定性设计和容灾方案
我最担心的是本地 LLM 提取服务挂了之后,整个系统会不会跟着挂。后来做了降级设计:提取服务不可用时,写入接口自动降级为“不提取结构化记忆,只保留原始对话摘要”,检索时靠词法匹配和向量检索兜底,系统仍然可用。也就是说,AI 提取是增强项,不是必需项。
消息队列也做了持久化。即使服务端在写入后、广播同步通知前崩溃,客户端下次主动查询时也能从数据库拿到最新数据,只是同步延迟从毫秒级变成秒级。我的容灾目标不是“零丢失”,而是“关键记忆不丢、服务不整体不可用”。
6. 我从这套系统上线前后踩过的坑
这篇内容如果只说设计不说坑,价值少一半。下面这几个问题都是我真实遇到过、花时间排查过的,按典型程度排列。
6.1 语义搜索并不万能,召回偏差的典型案例
有一次用户(我自己)在手机端问“明天开会材料准备了吗”,系统召回的三条记忆里有一条是“用户每天早上有晨跑的习惯”,理由是“明天早上”和“晨跑”语义相近。这属于召回偏差。单纯靠向量距离无法区分“明天早上开会”和“平时早上跑步”的关系。后来我加了两个修正:一是对包含明确时间词的查询加时间过滤,二是把词法检索结果在融合中的权重调高,确保精确匹配不会输给语义泛化。
6.2 同步风暴:多个客户端同时写同一条记忆
多客户端同时在线的场景里,最恐怖的问题就是同步风暴。桌面端和手机端同时编辑同一条项目记忆,两个客户端各自基于旧版本生成新版本,造成持续互相覆盖,日志里反复出现 version conflict。我最后靠“读取时带上版本号、写入时校验版本号”解决,另外在客户端 SDK 里加了 200 毫秒的写入去抖,同一客户端在 200 毫秒内对同一条记忆的多次修改只提交最后一次。这个去抖大大减少了冲突发生频率。
6.3 隐私与安全在跨客户端场景下的具体要求
跨客户端意味着数据会从多个入口进来,权限边界必须清晰。我的处理是:每个客户端启动时向服务端申请一个 client_token,token 绑定 namespace 列表。Web 端可以读写 project:xxx 和 personal:general,手机端默认只能读写 personal:general,IDE 插件额外可读写 project:codebase。服务端在每条读写请求里校验 token 与 namespace 的对应关系,不匹配直接拒绝。这种做法的好处是:即使某个客户端的数据泄露了,被波及的记忆也限定在它被授权的范围内,不会把整个记忆库拖下水。
6.4 一点关于 token 开销的教训
刚上线时我把每一轮对话都交给 LLM 提取记忆,成本飙升到让人心疼。后来加了一个“信息增量”判断:如果当前消息和上一轮提取过的记忆语义重复度过高,就跳过提取,只更新原记忆的时间戳和权重。这个判断用向量相似度实现,超过 0.9 就跳过。效果是提取次数下降了约 70%,几乎感觉不到对比度差异。所以对于记忆系统,真正省钱的不是选更便宜的模型,而是减少无效提取。
7. 这套系统的工程化扩展方向
写完自研系统之后,我并没有停下来。有几个方向是我已经在做或准备做的,对同场景的人可能有参考价值。
第一个是支持多 Agent 协作。现在多个客户端共享记忆,本质上还是一个用户和一个 AI 服务之间的记忆。下一步我想把这个系统扩展成多个 Agent 之间的共享黑板,让不同的 Agent 能够读取彼此的中间状态、任务进度、决策记录,真正实现多体协作。
第二个是更精细的记忆权限。现在的 namespace 隔离是粗粒度的。未来想做成类似“记忆级 ACL”,每条记忆单独标注可见的 Agent 列表或用户组列表。
第三个是记忆闭环反馈。系统目前只做存取,没有做“记忆是否真的帮助了后续回答”的效果回传。我准备在检索接口里加入一个 feedback 字段,客户端在回答结束之后回传哪些记忆被用到,系统据此调整记忆的重要性权重,让高价值记忆越用越靠前。
还有一个现实问题需要提一下:如果你也想自研记忆系统,不必从零开始造所有轮子。我在实现中发现,大部分存储和检索能力用现成的 SQLite、PostgreSQL 加开源 embedding 模型就能搞定,真正需要自己写的只有三个点:读取和写入的结构化提取、跨客户端的同步冲突逻辑、以及贴合自己业务场景的重排规则。把这三块想清楚,系统就成功了一大半。
我在这套系统的开发过程中最大的体会是:不要被“AI 记忆”这个概念吓住,本质上它就是一个带有语义检索能力的数据库,难点不在存储,而在“知道什么该被记住、什么该被忘掉”。mem0 在很多场景下确实值得一试,尤其是单客户端、数据量不大、对延迟不敏感的项目;但如果你像我一样需要多客户端共享、数据可控、成本敏感,自己写一套轻量级的记忆服务,反而是一条更踏实、更可控的路。