ARTICLE DETAIL

资讯详情

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

从mem0到自研:跨客户端AI记忆系统设计与踩坑实录

从mem0到自研:跨客户端AI记忆系统设计与踩坑实录 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 SQLiteWeb 端和命令行端用同一个 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 直接生成 UUIDuser_id 是业务账号维度scope 就是上面说的“域”content 是记忆正文category 区分 fact / preference / task / eventconfidence 是置信度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, k5): results [] # 第一路标签 关键词检索 tag_results search_by_tags_and_keywords(user_id, scope, query, kk) # 第二路最近访问过的记忆 recent_results search_recent(user_id, scope, kk) # 第三路当前业务域内的 task 状态 task_results search_by_category(user_id, scope, task, time_window_hours72) return merge_ranked_results([ (tag_results, 1.0), (recent_results, 0.7), (task_results, 1.2), ], kk)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 1last_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 客户端我建议不要急着把任何记忆方案直接架进来。先花两个晚上想清楚我的客户端分别是谁它们各自需要什么记忆它们之间可以共享什么、必须隔离什么这比选型本身重要得多。自研这套系统的过程也让我把记忆系统的坑一个不落踩了一遍。数据库表结构怎么设计、删除怎么同步、冲突怎么合并、上下文怎么限量注入、隐私怎么分级这些问题没有一个能靠“堆更多模型能力”解决。它们都是从工程细节里长出来的。要说值不值得我的答案是对于我的场景非常值得。它现在只服务我自己和少数内部工具但结构已经足够清楚后续要加一个语音助手客户端只需要按同样的同步协议接入即可。如果你也想做类似的事希望这篇记录能帮你避开不少弯路。
返回列表