☰
从mem0到自研:跨客户端AI记忆系统设计与踩坑实录
2026/10/6 20:11:23 网站建设 项目流程

1. 为什么我会盯上 AI 记忆系统

过去半年我一直在做自己的 AI Agent 项目,模型换了好几个,prompt 调了无数版,但最影响体验的从来不是模型有多聪明,而是它到底记住了多少东西。用户上午在网页端说过“我养了一只猫叫年糕”,下午打开手机端助手问“年糕吃什么猫粮”,助手却一脸茫然。这个问题不是模型能力问题,而是记忆系统问题。

市面上随手能用的记忆方案并不少,mem0 就是其中名声最大、GitHub star 也最亮眼的一个。我也认真读过它的文档、跑过它的 demo,刚开始确实兴奋:它很聪明地把对话拆成可检索的记忆片段,再通过向量检索插回上下文。但真的打算把它架进生产环境、并且接给多个客户端一起用时,问题就越来越多。我最后的选择是:不引入 mem0,自己写了一套只在内部使用的跨客户端 AI 记忆共享系统。

这篇文章不是来踩 mem0 的,它是个很棒的项目,只是它的设计取向和我的场景不同。我想把自己从“接入开源方案”到“决定自己造轮子”的全过程,以及新系统的设计思路、数据模型、核心代码、踩坑记录都摊开来讲。如果你也在做多端 AI 助手、AI Agent、聊天机器人这类产品,大概率会遇到和我一样的痛点。

2. mem0 到底解决什么问题,以及我为什么最终放弃

2.1 mem0 印象:一次看起来很美的初体验

mem0 的核心思路是给大模型应用加一套“外挂记忆层”。它会把用户对话里值得记住的信息提取成结构化条目,比如“用户偏好简洁回答”“用户公司叫星云科技”“用户不喜欢邮件通知”,存进向量数据库,然后在每次模型调用前把相關记忆取出来,拼进提示词。

我第一次跑通 demo 时体验是很好。它提供两段式工作流:一段负责记忆写入,一段负责记忆检索。写入时可以指定 agent_id 和 user_id,检索时也能按这些维度过滤。也就是说,同一个用户的历史信息可以被不同 session 复用。这正好击中了我“跨会话记忆”的痛点。看上去只要我把所有客户端的请求都指向同一个 mem0 服务,就能实现共享记忆。

于是我很快做了一版 POC:统一部署一个 mem0 服务,网页端、命令行工具、API 调度任务全往这里写,再从这里读。测试时能跑通,但问题也从这个“能跑通”开始一点点浮出来了。

2.2 黑盒记忆库:我想调的东西全被封死了

用 mem0 的第一个难受点是修改行为要依赖它的配置项,可我希望控制的那几个维度它反而没暴露。比如我希望能区分“长期事实”“临时偏好”“近期任务状态”三类记忆,并在检索时给它们分配不同权重。mem0 支持分类,但更多是面向通用记忆,自定义程度有限。

更麻烦的是记忆的存储结构。对学习项目来说,数据结构黑盒一点无所谓,可我要接自己的多端系统,就必须知道每条记忆的更新时间、来源客户端、有没有被合并过。这些信息在 mem0 里并不容易拿到。我需要的是记忆服务的完整控制权,而不是一块只允许我塞 Prompt 进去、拿检索结果出来的智能黑盒。

还有一个实际成本问题。mem0 默认链路依赖向量数据库和 OpenAI 兼容接口。小规模内测没事,但我要给多台客户端做长期记忆,每条消息都要抽取、入向量库、检索时再做向量比对,这个运营成本要比我想象的高不少。不是说它贵到用不起,而是“用不起”的风险一直在那儿。

2.3 跨客户端共享时的关键梗阻

真正让我放弃 mem0 的是“跨客户端”这件事。它虽然在逻辑上支持按 user 隔离,但我的场景里同一个用户有四个客户端:Web 聊天页、终端里的配置文件、定时任务、手机快捷指令。它们对记忆的读写模式完全不一样。

Web 端希望读取历史偏好后立刻生效;命令行工具希望写入新的任务状态,但不希望被不相关的记忆刷屏;定时任务只关心某个特定项目的记忆,不能混入闲聊记忆。我急需一个能按“业务域”隔离记忆的方案。而 mem0 的 agent_id 和 user_id 虽然能做基础过滤,但复杂一些的读写策略,比如“当前对话只在销售任务域内检索”“但需要继承用户通用偏好”,就得在业务层做大量二次封装。

这个二次封装其实是压死骆驼的最后一根稻草。既然无论如何都要写业务层逻辑,那不如连底层记忆模型一起设计,把整个系统做成自己能完全看懂、能随意扩展的样子。于是我开始列自研需求清单。

3. 自研前先把边界想清楚:跨客户端共享的核心需求

3.1 四个客户端,四种记忆读写模型

动手之前,我先画了一张表,把每个客户端与记忆系统的关系列清楚。这一步很关键,你得让每个客户端都知道“自己该写入什么”“自己该读取什么”。

客户端典型使用方式需要写入的记忆需要读取的记忆
Web 对话页用户直接聊天用户偏好、个人信息、临时反馈此前所有相关对话、偏好
命令行助手执行任务、查询状态任务进度、技术术语偏好当前项目上下文、任务状态
定时任务每日自动汇总执行结果、异常记录项目配置、长期规则
手机快捷指令快速记录/查询灵感、待办最近记录、个人偏好

从这张表能看出来,不同客户端对记忆的需求既有“共享”又有“隔离”。共享的是个人信息、语言风格、长期事实;隔离的是各业务域内的状态、任务和临时上下文。所以我的系统不能只有一张记忆大表,必须引入“域”的概念。

3.2 核心需求:共享、隔离、可解释、可遗忘

我把需求收敛成四条原则。

第一是真正跨端共享。同一用户在任何客户端写的记忆,在另一客户端立刻可见,不需要重新导入或者搬运。第二是业务域隔离。聊天中的闲聊记忆不能跑到任务执行器里,任务状态也不能污染用户画像。第三是信息可解释。每次模型拿到记忆时,我要知道它拿的是哪些记忆、为什么匹配上这几条,方便排查“模型为什么表现得这么奇怪”。第四是可持续遗忘。记忆不能只增不减,一段时间后没用的旧记忆应该自动降权或清理,否则系统会越来越笨。

这四条原则成了后来所有设计和实现的总纲。mem0 覆盖了第一和第二点的前半部分,但第三和第四点需要依赖它内部的实现细节,做不到我要的程度。自研路线就此定下来。

3.3 技术选型上的克制:不先碰向量库

很多朋友听说“AI 记忆系统”,第一反应就是上向量数据库、上 embedding。我做选型时故意反着来:第一步先不上向量,只做结构化存储 + 关键词/标签检索。原因很简单,我不希望在第一步就引入一个本人无法完全掌控的大依赖。

我的目标是先验证核心逻辑,等跑通之后,如果语义检索确实必要,再引入本地 embedding 模型也不迟。实际上后来测试发现,大部分用户偏好、项目事实都能通过“标签 + 关键词 + 时间”检索出来,向量检索不是银弹。

4. 系统架构与数据模型设计

4.1 全局架构:一个轻量级记忆服务加四个客户端

整体架构非常简单:一个中央记忆服务,四个客户端都通过 HTTP API 读写。中央服务我用 FastAPI + SQLite,Web 端和命令行端用同一个 Python SDK,定时任务直接调 HTTP 接口,手机端通过一个极简的 HTTP 客户端接入。

浏览器 Web 端 命令行工具 定时任务 手机快捷指令 | | HTTP v +--------------------------+ | Memory Service (FastAPI)| | /memories /search | | /sync /cleanup | | SQLite + FTS5 + JSON | +--------------------------+

这个架构没有任何奇技淫巧。为什么不引入消息队列?因为目前的客户端数量和写入频率根本用不上,引入 MQ 等于增加运维成本。为什么不直接用 Redis 存 JSON?因为记忆系统需要复杂的过滤、聚合、版本控制、后续迁移能力,SQLite 在单机场景下足够可靠,还能直接用 SQL 做调试。

4.2 核心表结构:一条记忆该怎么存

存储是我最花心思的地方。我设计了一张主表,并用另一张表保存标签关系。核心字段如下:

CREATE TABLE memory_entries ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'general', content TEXT NOT NULL, category TEXT NOT NULL, source_client TEXT NOT NULL, source_conversation TEXT, confidence REAL DEFAULT 0.6, access_count INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_access_at TEXT, is_tombstone INTEGER DEFAULT 0 ); CREATE TABLE memory_tags ( memory_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (memory_id, tag) ); CREATE INDEX idx_scope_user ON memory_entries(user_id, scope); CREATE INDEX idx_updated ON memory_entries(updated_at);

id 直接生成 UUID;user_id 是业务账号维度;scope 就是上面说的“域”;content 是记忆正文;category 区分 fact / preference / task / event;confidence 是置信度;is_tombstone 是预留的删除标记,用来解决多端同步删除冲突。这里特意把“更新时间”和“最后访问时间”分开存储,后面做遗忘机制时能直接用。

4.3 为什么给每条记忆打上 domain 和 category 标签

很多记忆系统只有“用户 ID + embedding”。我在实际试过之后发现远远不够。一个用户有“个人喜好域”“工作项目 A 域”“工作项目 B 域”“临时任务域”等,如果不按域隔离,检索时模型很容易被无关记忆干扰。

category 也很关键。记忆“喜欢简洁回答”属于 preference,“昨天任务已处理完成”属于 task,“公司位于杭州”属于 fact。它们对当前问题的相关度完全不同。task 类记忆时效性高,fact 类时效性低,preference 类则长期稳定。如果不分权重,系统就无法区分“这条记忆是过期的状态”和“这条记忆是长期属性”。

所以我在 API 设计里允许客户端写入时显式声明 scope 和 category,而不是让模型自动抽取后就随意落库。这样检索时先按 scope 过滤,再按 category 排序,效果会清晰很多。

5. 核心实现:记忆读写、检索合并与遗忘机制

5.1 写入记忆:结构比 prompt 更可靠

写入口的流程很简单:客户端把一段文本以及可选的 scope、category、source 信息 POST 给服务端,服务端负责抽取、去重、入库。我一开始偷懒,让大模型从对话里抽取 JSON 再入库,结果发现模型总会在分类上犯迷糊,且每次调用都有成本。后来改为“规则 + 模板 + 模型兜底”的抽取方式。

比如聊天内容里出现“我更喜欢”这种句式,就优先归类为 preference;出现“明天”“计划”“待办”等关键词,就归类为 task;如果规则无法判断,再调用一次模型做抽取。这样做既便宜又稳定。

以下是写入口的核心伪代码:

def create_memory(user_id, content, scope, category, source_client): memory_id = uuid4().hex now = datetime.utcnow().isoformat() # 先用相似度去重 if is_duplicate(user_id, content): return {"status": "duplicate", "merged": True} conn.execute( "INSERT INTO memory_entries " "(id, user_id, scope, content, category, source_client, " " created_at, updated_at, confidence) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (memory_id, user_id, scope, content, category, source_client, now, now, default_confidence(category)), ) # 根据内容打标签 tags = extract_tags(content) for tag in tags: conn.execute( "INSERT INTO memory_tags (memory_id, tag) VALUES (?, ?)", (memory_id, tag), ) return {"status": "created", "id": memory_id}

这里的重点是 is_duplicate。我用的是一种非常轻量级的方案:先按用户 ID 和内容前 50 个字的 Jaccard 相似度过滤,相似度超过 0.85 就判定为重复,并更新原记忆的访问时间而不是新插入一条。没有接 embedding,因为这个阶段的去重偏向“完全重复”和“轻微改写”,规则接近人类直觉,成本几乎为零。

5.2 检索记忆:多路召回再合并,而不是只靠相似度

检索是整个系统的灵魂。我的查询参数一般这样组装:

def search_memories(user_id, scope, query, k=5): results = [] # 第一路:标签 + 关键词检索 tag_results = search_by_tags_and_keywords(user_id, scope, query, k=k) # 第二路:最近访问过的记忆 recent_results = search_recent(user_id, scope, k=k) # 第三路:当前业务域内的 task 状态 task_results = search_by_category(user_id, scope, "task", time_window_hours=72) return merge_ranked_results([ (tag_results, 1.0), (recent_results, 0.7), (task_results, 1.2), ], k=k)

merge_ranked_results 里我会先按来源加权算出初始分,再叠加时间衰减因子。时间衰减公式用的是:

score = base_score * pow(0.95, hours_since_last_access / 24)

这条公式的意思是,每过一天初始分数衰减 5%,用来“惩罚”很久没被碰过的记忆。再简单解释下三路召回的设计:第一路负责“和当前问题内容相关”,第二路负责“和用户近期思路相关”,第三路负责“任务状态不要丢失”。三路不共用一套分数,能有效避免冷启动时没有关键词匹配就返回空结果的问题。

5.3 上下文注入:记忆不是越多越好

检索到记忆之后,系统会拼出一段“记忆上下文”追加到 system prompt 里。我的格式类似这样:

【用户长期记忆】 - [fact] 用户公司叫星云科技 - [preference] 用户喜欢简洁回答,不喜欢客套话 【近期任务状态】 - [task] 项目 alpha 的 API 文档还在编写中

这里有一个非常关键的约束:每次注入的记忆条目数必须限制在 5 到 8 条以内。超过 8 条,模型开始表现得“过于顺从”,会把一些模棱两可的记忆当成事实,反而不利于对话。我实际踩过的坑是,有一版测试把相关记忆全部灌进去,结果模型把 3 天前一次聊天里的随口话当成了用户硬性要求,差点做出错误判断。所以检索完一定要做“数量上限”和“按权重截断”。

5.4 遗忘机制:老记忆怎么自动降权

遗忘机制我用了三件事:访问计数、时间衰减、软删除。

第一,每次检索命中某条记忆,access_count +1,last_access_at 更新;第二,系统每天跑一次 cleanup 任务,把超过 60 天没有被访问且 confidence 低于 0.5 的 task 类记忆标记为 tombstone;第三,用户可以在任意客户端主动删除记忆,删除也是标记 tombstone,不直接物理删行。

真正物理删除只发生在执行彻底清理时。这个设计强调了“跨端同步删除”的一致性:如果我在 Web 端删了一条,手机端同步时看到 tombstone,就知道这条不能重新写回来。很多自研系统忽略删除同步,最后被误删的记忆又“复活”了。

6. 多客户端同步与冲突处理的完整方案

6.1 同步协议:客户端先本地保存,再增量上传

每个客户端内部都有自己的本地 SQLite。它们的角色不是服务中心,而是“离线缓存 + 增量日志”。客户端写的任何记忆都先进本地库,再通过/sync接口把增量数据上传到中央服务。

同步协议采用简单的“时间戳 + ID 集合”方式。客户端每次上传时携带自己的last_sync_at,服务端返回这个时间点之后的新增/更新/删除记录。客户端再把这些记录 merge 进本地库。这样即使某个客户端长时间离线,重新联网后也能补上所有记忆。

def sync_client(client_id, user_id, last_sync_at): changes = conn.execute( """ SELECT * FROM memory_entries WHERE user_id = ? AND updated_at > ? AND (source_client != ? OR is_tombstone = 1) ORDER BY updated_at ASC """, (user_id, last_sync_at, client_id), ).fetchall() return {"changes": [row_to_dict(row) for row in changes]}

6.2 冲突解决:最后写入者不是最优解

多端同时写同一条记忆时,最无脑的方案是“后写入的覆盖先写入的”。但我的场景里不同端的记忆往往关注不同维度。比如 Web 端更新了“用户偏好”,手机端同时更新了同一条记忆的 confidence,这两次修改其实可以合并。

我最后采用的策略是字段级合并:更新记忆时,客户端上传的每个字段都带一个版本号。服务端比较版本号,只把版本号更高的字段写入,而不是整行覆盖。这个策略类似“多主复制按列合并”。实现起来不复杂,却解决了我一大半认知负担。

UPDATE memory_entries SET content = CASE WHEN ? > content_version THEN ? ELSE content END, confidence = CASE WHEN ? > confidence_version THEN ? ELSE confidence END, updated_at = ? WHERE id = ?

6.3 删除冲突:墓碑(Tombstone)机制

删除在同步里是最容易翻车的地方。如果不做墓碑机制,就会发生这种局面:客户端 A 删除了记忆,客户端 B 因为离线不知道,上传时又把这条记忆写回来,导致删除失效。

我这里的做法是:删除时不直接删行,而是把 is_tombstone 置为 1,并更新 updated_at。客户端同步时会看到这个字段,然后在自己本地也标记为删除。正在运行中的旧客户端用 [id] 作为主键更新时,只会更新普通字段,不会把 tombstone 改回去。只有“手动重建记忆”才会插入一条新 id。这一环解决了多端同步中最隐蔽的“幽灵复活”问题。

6.4 版本一致性与失败重试

失败重试是我在最初版本里忽略的地方,后来吃了不少亏。网络抖动时客户端请求失败,然后就地重传,结果导致同一条记忆被重复插入。解决办法是客户端生成 UUID,服务端以 UUID 为准做幂等;如果同一个 UUID 已经存在,直接返回已存在状态,不做重复插入。这样即使同步接口被重复调用,也不会炸出多条脏数据。

7. 实际踩坑记录与调试经验

7.1 问题一:多客户端并发写入导致记忆互相覆盖

这是我遇到的第一个严重问题。当时还没做字段级合并,两个客户端同时更新一条记忆时,一条的 content 会被另一条的空值覆盖。排查时发现 updated_at 是新的,但 content 变成了空字符串。

解决思路分两步:第一步是服务端拦截空字段,发现 content 为空直接拒绝写入;第二步是引入字段级版本控制,只合并新值。这类问题如果发生在单客户端系统里基本不会出现,跨客户端场景必须从设计层面小心。

7.2 问题二:记忆注入过多导致模型“过度自信”

我一度觉得记忆越多越聪明。结果恰恰相反。有一版测试中,系统注入了 18 条相关记忆,模型回答表现出了一种“过分肯定的幻觉”:用户明明没有明确要求,它却根据旧记忆推断出结论。

解决方式是加了两道闸:一道是数量闸,最多注入 8 条;另一道是置信度闸,低于 0.4 的记忆默认不注入。另外,我后来给每条注入的记忆下面加了一行“此记忆来自历史对话,可能已过时”,模型收到提示后会更谨慎一些。这个技巧成本为零,但对避免幻觉非常有效。

7.3 问题三:embedding 模型更新导致相似度完全漂移

本来我没打算加 embedding,但在测试语义召回时引入过一版。后来换了 embedding 模型版本,发现原先相似的句子在新模型下相似度大幅下降,很多记忆召不回,导致整个系统像失忆了一样。

这就是“底层模型版本漂移”问题。最后我的策略是:固定 embedding 模型版本,所有 embedding 都存储在本地,升级模型时全量重算,而不是在查询时动态换模型。这个教训很重要,任何系统如果底层依赖 embedding,都需要把“模型版本”和“数据版本”绑定在一起。

7.4 问题四:隐私边界被打破后的补救

跨客户端共享最容易踩的是隐私问题。有一次用户开玩笑说“现在的客户快把我烦死了”,系统在 task 域里记了这句话,结果另一个客户端在回答时把这条当成潜在负面情绪提醒,造成了很尴尬的联想。

我加了一个机制:允许每条记忆设置一个privacy_level字段,标记private的记忆只能在写入它的客户端范围内读取,不能跨端共享。同步时遇到private记忆就直接跳过,不进入其他客户端。隐私过滤不是安全系统的全部,但至少要一个基础分级,否则跨端共享就是一个定时炸弹。

8. 这套系统写下来,我到底得到了什么

回到标题的问题。我为什么不用 mem0?不是因为 mem0 不好,而是它默认解决的是“给单一大模型应用加记忆”,而我要解决的问题是“多个客户端共享一套可解释、可隔离、可遗忘的记忆”。后者的关键在于数据模型、同步协议、冲突合并和隐私边界,这些细节在成熟框架里往往被封装起来,反而成了扩展的阻碍。

如果你也是一个人维护多个 AI 客户端,我建议不要急着把任何记忆方案直接架进来。先花两个晚上想清楚:我的客户端分别是谁?它们各自需要什么记忆?它们之间可以共享什么、必须隔离什么?这比选型本身重要得多。

自研这套系统的过程,也让我把记忆系统的坑一个不落踩了一遍。数据库表结构怎么设计、删除怎么同步、冲突怎么合并、上下文怎么限量注入、隐私怎么分级,这些问题没有一个能靠“堆更多模型能力”解决。它们都是从工程细节里长出来的。

要说值不值得,我的答案是对于我的场景非常值得。它现在只服务我自己和少数内部工具,但结构已经足够清楚,后续要加一个语音助手客户端,只需要按同样的同步协议接入即可。如果你也想做类似的事,希望这篇记录能帮你避开不少弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询