多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

给AI装上长期记忆:从上下文失忆到claude-mem落地实战

给AI装上长期记忆:从上下文失忆到claude-mem落地实战 我最早意识到 AI 缺“记忆”这件事是在一个特别尴尬的场景。头天晚上我花了大半个小时跟模型聊清楚一个数据清洗脚本的痛点第二天早上打开新会话问它“那个脚本的问题还记得吗”它回了我一句“请问你说的是哪个脚本”。那一刻我就明白上下文窗口再大也撑不起“记得用户”这件事。后来我在自己的项目里折腾 claude-mem 这个记忆层方案逐步把“给 AI 接上外挂记忆”从 demo 做成了能稳定跑的服务。很多人以为记忆就是“把历史消息都塞回上下文”真做一遍就会发现完全不是这么回事要判断什么值得记、什么该忘、怎么在用户问的时候自然想起来、怎么不让记忆反过来污染回答。这篇文章就把我整个落地的过程、踩过的坑、以及最后沉淀下来的方案完整写出来给同样在做 AI 应用但被“上下文断片”折磨的朋友参考。1. 上下文窗口不等于记忆问题到底出在哪1.1 大模型每轮对话其实都是一次“失忆重启”先说一个很多人忽略的技术事实模型本身是不保存对话历史的。你每次发消息应用层都要把之前的聊天记录手工拼在一起塞进上下文窗口模型才“看起来记得”。窗口再大也只是临时容纳不是长期记忆一旦超出长度要么截断要么被更早的消息挤掉而且每轮都重新传一遍历史Token 成本也跟着涨。我一开始也踩过这个误区以为只要把上下文窗口从 4k 换到 16k用户就不会觉得 AI 失忆了。结果窗口大了问题反而更隐蔽——模型读的文本太长中间的关键内容会被稀释用户问“我之前提到的那个项目”模型经常找不到那条信息。这跟人读书读到最后忘了开头是一样的窗口大了但注意力是有限的不可能靠堆历史解决问题。1.2 记忆不是“历史记录”它分三种真正做记忆层必须先区分三种性质完全不同的信息。第一种是工作记忆就是当前这轮对话里正在进行的状态比如用户刚说“今天的会议推迟到下周”这个临时信息只需要在当次会话内生效不必跨会话保留。第二种是情景记忆指的是过去具体发生过的对话事实比如“上周用户聊过准备换工作还在犹豫”。第三种是语义记忆是从多次交流中归纳出的稳定偏好比如“用户喜欢简洁回复讨厌长篇大论”。claude-mem 这个名字听上去像是给某个模型加记忆实际做的是后两种把跨会话的事实和偏好沉淀成可检索的长期记忆。工作记忆继续交给上下文处理长期记忆走另外一套存取体系两者不混在一起。这样每次请求带的历史可以控制在足够小的范围而真正需要“记住”的东西由记忆库承担。2. claude-mem 的核心架构短期缓冲与长期仓库的分工2.1 一条消息从进入到归档的完整流转路径整个系统的核心思想其实很简单把“对话过程”和“记忆沉淀”拆成两条线。用户发消息进来主流程照常处理把消息喂给模型回答同时在后台跑一条异步链路判断这段话里有没有值得长期记住的东西如果有就提炼成一条结构化记忆写入仓库。我最终落地的路径是这样的用户消息到达应用后端先进入会话缓冲也就是当前对话的上下文管理模块。主线程把最近的若干轮消息组装好直接请求模型并返回结果给用户这个流程和普通聊天应用没有任何区别。与此同时后端把这条用户消息丢进一个异步队列记忆提取 worker 拿到消息后做两件事第一判断是否有值得保存的信息第二如果有就生成一条短文本记忆做向量化写入长期记忆库。这套流程最关键的设计就是异步。最开始我偷懒在同一个请求里先调用模型判断“这句话值不值得记”再调用向量库写入结果用户的每条消息都要多等一两秒。后来改成异步写入之后用户完全没有感知记忆存储的成功与否也不影响主对话的响应速度。这个取舍后来被证明是整个系统能稳定上线的前提。2.2 数据模型一张关键的记忆表记忆库的表结构不需要很复杂但有几个字段特别重要。我用的核心表大概长这样CREATE TABLE memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, session_id TEXT, content TEXT NOT NULL, memory_type TEXT NOT NULL, -- user_preference / user_fact / task_state / conversation_summary importance_score REAL NOT NULL, -- 0.0 - 1.0 embedding_id TEXT, created_at TIMESTAMP NOT NULL, last_accessed_at TIMESTAMP, access_count INTEGER DEFAULT 0, status TEXT DEFAULT active, -- active / superseded / archived / deleted source_message_id TEXT UNIQUE );这里每个字段都有自己的用途。user_id 是隔离的第一道锁所有查询都必须带这个条件content 是最终注入提示词的记忆原文应该是一句完整、无歧义的自然语言不要存碎片化的原始消息memory_type 用来区分偏好、事实、任务状态不同类型在检索排序时的权重不一样importance_score 是记忆的长期价值打分决定了它会不会被优先召回、会不会在清理时被保留。还有一个容易忽略的细节source_message_id 加唯一约束。因为写入是异步的worker 可能重试不加这个字段同一条消息被处理两次就会出现重复记忆。这也是我踩过一次的坑——测试时发了同一条消息三次库里出现三条几乎一样的记忆后来加了这个唯一键才解决。2.3 为什么必须用向量检索而不是直接搜关键词记忆检索的核心矛盾是用户提问的说法和当初存档时的说法几乎不可能字面一致。用户三周前说的是“我最近在发愁周报怎么写更省时间”三周后他问的是“那个自动生成周报的方法还有没有”两句话没有任何共享关键词全文搜索完全无能为力。向量检索解决的就是这个语义鸿沟。把记忆文本和用户当前问题分别编码成向量在向量空间里找到语义最接近的若干条记忆。这个思路在技术圈已经很成熟但我建议不要一上来就上重型向量库数据量小的时候用轻量方案先跑通流程更重要。我在早期阶段就是先在本地的轻量关系库里存向量字段配合应用层算余弦相似度数据量到了几万条之后才迁移到独立的向量库做分区存储。向量检索也不是万能的它召回的是“语义相似”而不是“绝对正确”所以后面还需要一整套重排、过滤、注入的控制逻辑这部分我会在下一章展开讲。3. 检索注入实战怎么让 AI 在合适的时候“想起来”3.1 召回、重排、注入三步把记忆变成模型的“已知信息”每次用户发消息记忆模块要做三件事。第一步把当前的问题向量化在用户的记忆空间里召回 topK 候选第二步对这些候选做重排按照重要度、时间衰减、与当前问题的相关性综合打分砍掉明显不相关的第三步把最终通过的记忆拼装成一段“已知信息”插入到系统提示词里再交给模型回答。我实际使用的注入格式大概是这样[系统] 你是一个有帮助的助手。下面是你对用户的部分已知记忆仅供参考 - 已知事实用户目前居住在杭州从事数据分析相关工作 - 长期偏好用户喜欢分点回复希望答案控制在 500 字以内 - 任务状态用户正在处理一个销售报表的自动化生成脚本卡在日期格式转换 如果上述记忆与当前问题无关请忽略它们。请基于用户当前输入和对话历史回答。这里最核心的一句话是“如果与当前问题无关请忽略它们”。一开始我没有写这句结果模型遇到任何问题都会尽力往记忆上靠。用户问“今天天气怎么样”模型非要提一句“根据您的记忆您在杭州”看着贴心其实很烦人。给模型一条“记忆仅供参考”的逃生通道回答质量会明显提升。3.2 Token 预算控制一次对话最多带多少记忆记忆不是带得越多越好。每一条注入的记忆都吃 Token 配额而且会干扰模型对当前对话的注意力。我给记忆区设了一条硬规则单请求的记忆注入量不得超过整体输入预算的 30%。举个例子某次请求的输入预算总共 8k Token系统提示词和当前对话已经占掉 4k留给记忆区的只有 2.4k。按平均每条记忆 60 到 80 Token 估算最多只能带 30 到 40 条。真到了这个数量问答效果已经开始变差了。我最终把线上参数调成默认 15 到 25 条效果最稳定。重排的时候不能只看向量相似度。我自己的排序公式大概是最终分 语义相似度 × 0.5 重要度 × 0.3 时间衰减系数 × 0.2。其中时间衰减系数由上次访问时间和创建时间共同决定三个月前创建但最近频繁访问的记忆分数反而高于昨天创建但再也没碰过的记忆。这套权重可以在应用里做成可配置项不同业务调的侧重不一样。3.3 召回翻车现场撞车、漂移、记忆污染这块是我最想分享的经验因为光看文档根本学不到。第一个经典翻车是“短 query 召回噪声”。用户发一句“你觉得呢”这句的向量非常泛几乎和库里所有记忆都有一些距离但都没有特别近。结果召回回来的往往是随机散落的记忆模型被“我不知道该不该把某个记忆拿出来”的情况弄得无所适从。我的解决办法是对短 query 做改写比如检测到问题少于 5 个字就把它扩展成“用户正在询问对某件事的看法请结合已知信息辅助判断”这样向量检索的语义中心会更明确。第二个翻车是“记忆过时但没被察觉”。用户三个月前说“我在上海做项目”系统一直把这条当成当前事实。用户后来问“你建议我住浦东还是浦西”模型理所当然地帮他分析浦东浦西完全不知道他人已经搬到南京了。这件事让我意识到光有记忆没有时间意识是很危险的。后来所有记忆写入时都强制带上 created_at检索时如果记忆创建时间超过一定阈值注入文本前会加前缀“注这是三个月前的信息可能与当前情况不符”。第三个翻车是偏好记忆的误用。用户去年说过一次“我喜欢简短回复”模型在后续对话里就疯狂压缩答案连用户明确说“这次请详细解释”的时候依然只写三句话。本质原因是系统把一条临时偏好当成了永久的刚性约束。我的修复方式是区分偏好类型关于表达风格的预设只在用户没有明确指示时才作为默认值用户一旦在当前对话表达了明确指令当前指令优先级永远高于历史偏好。4. 记忆写入与遗忘比存储更难的治理问题4.1 什么值得记什么必须扔我把值得长期记忆的信息分成四类与用户身份相关的事实比如所在城市、职业、家庭状况用户明确表达的偏好比如“不要发语音”“回复里多给数据”正在推进的任务状态比如“在写某个脚本卡在权限问题”还有跨多次对话反复出现的话题。不值得记的包括一次性的寒暄、临时地点、情绪化发泄以及那些只对当前对话有用、过后毫无价值的信息。判断要不要记不能靠写死的规则因为自然语言太灵活了。我实行的方案是让提取模型做分类用一个轻量级调用完成请从下面的用户消息中提取值得长期记住的信息。只输出 JSON不要解释。 { should_remember: true/false, memory_type: user_fact|user_preference|task_state|none, content: 一句话总结需要记住的信息, importance: 0.0-1.0, sensitive_level: 1/2/3 } 如果不需要记住返回 {should_remember: false}。这个方案的提取率大概是 6% 到 9%不是每条消息都会产生记忆。我见过有人把每条对话都存进来结果是记忆库膨胀得飞快召回时全是噪声比没记忆还不如。记住一个原则少而精永远优于多而杂。4.2 去重、冲突检测与“最新表述优先”用户的信息是会变的。他第一次说“我在上海”三个月后说“我现在搬到北京了”。如果系统把这两条都视为有效记忆召回时两条同时出现模型就会犯错。我的做法是冲突检测加版本化处理。写入新记忆之前先对同 user_id 下同类型的记忆做一轮相似度比对如果发现高度相关的旧记忆就把旧记忆标记为 superseded新记忆作为最新版本生效。被替代的旧记忆不会立刻删除只是不再参与常规召回只有在用户主动追溯历史的时候系统才会把旧版本翻出来。这里面有一个细节不能因为用户说过一次“我改主意了”就永远以最后一次为准。比如用户某天心情不好说“我不想再用这个产品了”第二天又正常使用如果系统把前天的气话当成最新偏好就会一直呈现消极状态。所以我设置了“记忆冷却期”高重要性的事实类信息如果发生了冲突更新需要等待一段时间并且用户后续没有再次否定才正式替换旧版本。这个设计帮我挡住了不少“情绪化更新”。4.3 遗忘机制为什么记忆越少效果反而越好记忆系统最大的风险不是“记不住”而是“记得太多”。我观察过一个测试账号用了两周之后库里攒了一千两百多条记忆每次召回都像大海捞针真正重要的信息反而被淹没。我最终采用两级遗忘策略。被动遗忘靠衰减每条记忆维护一个综合分由重要度和访问新鲜度共同决定长时间没有被检索到的记忆会自动降权降到阈值以下就进入归档区不再参与召回。主动遗忘靠用户指令用户明确说“忘掉我之前讲的那件事”系统会立即做语义匹配把所有相关记忆标记为 deleted。每年的每月审计也是我坚持做的事导出一份低访问量记忆清单用一批量调用让模型读一遍判断哪些记忆已经过时把确认为过时的记忆批量归档。这个操作听起来很重但一个月做一次每次成本完全可控换来的效果是召回精度能持续保持在稳定水平。记忆治理不是一个一次性任务它是伴随系统生命周期持续运营的长期工作。5. 从原型到稳定运行性能、隔离与部署细节5.1 异步写入链路和失败重试前面提到过记忆提取绝对不能设计在用户请求的主链路上否则每句话都慢一两秒用户能明显感知到卡顿。我用的方案是引入一个任务队列把“消息归档”变成一个异步事件。流程文字描述就是前端回调进应用网关会话服务正常生成回复并返回这个阶段完全不需要知道记忆是否存在同时把当前消息的 ID 和 user_id 发到任务队列记忆提取 worker 从队列里拉任务调用提取模型做分类如果判定为需要记录做向量化并写入记忆库写入成功之后更新该用户的缓存时间线。这个链路有三个容易翻车的地方。第一个是幂等前面说了用 source_message_id 唯一约束这能保证队列重试不会产生重复记忆。第二个是重试策略worker 挂了不能静默失败我给写入操作设计了三次指数退避重试仍然失败就放进死信队列等人工介入。第三个是削峰遇到大量历史消息需要批量归档时队列要控制消费速率避免一次性把提取模型的额度打爆。5.2 多用户隔离记忆绝对不能“串台”记忆是用户的隐私资产多用户之间出现串台性质比功能 bug 严重得多。这里不只要在代码里过滤更要从架构上强制隔离。我给每个用户分配一个独立的向量空间命名空间所有检索和写入都限定在该命名空间内关系型数据库里则使用 user_id 作为所有查询的强制过滤条件。两个地方同时起作用哪怕某一层过滤条件写漏了另一层还能兜住。这个方法不是多余而是必要。我吃过一次亏做联调测试时一个接口临时去掉了一处 user_id 校验导致某个用户检索到了另一个用户的记忆。虽然只是测试环境但那次之后我定了一条规矩记忆模块的每个检索入口必须同时有显式的命名空间检查和 user_id 条件单靠一层隔离不做上线审核。5.3 实测开销数据一个项目的真实量级为了给你一个直观的概念我放在一个模拟项目 X 上遇到的真实数据。这个项目有 20 个测试用户连续运行了 30 天指标数值备注日均消息量约 3000 条包含群聊和单聊日均新增记忆180 - 220 条提取率约 6% - 8%记忆库总量约 9500 条经过归档和去重单次检索延迟35 - 80 ms向量库本地部署记忆注入 Token 增量平均 850 Token/请求约占输入预算 12%误召回率约 6%指与当前问题完全不相关的记忆这个量级整个系统完全撑得住延迟主要消耗在模型响应上记忆检索的那几十毫秒可以忽略不计。如果数据量继续涨到几十万条我会考虑做一层热记忆缓存把高频召回的固定事实直接放在应用内存里减少对向量库的查询压力。6. 记忆安全这是功能更是责任6.1 敏感信息分级不该进库的坚决不进做记忆系统最怕的一件事就是把用户的敏感信息原样存起来。用户可能在闲聊中说出手机号、住址、公司内部信息这些内容如果进了长期记忆库等于把敏感数据放在一个随时可能被检索到的位置。我设计的处理规则很简单分三级。一级信息可以直接记忆包括昵称、兴趣爱好、工作行业、常用软件等。二级信息需要脱敏比如手机号、家庭住址记忆库只存“用户联系方式已验证”这个事实绝不存完整号码和详细地址。三级信息直接禁止入库包括密码、密钥、银行卡号、身份证号、疾病诊断等提取模型如果识别到这类内容只返回“已识别到敏感度较高的信息拒绝存储”。实现时就是在提取模型的 prompt 里写清楚分级规则并要求输出 sensitive_level 字段应用层再根据这个层级决定落库还是丢弃。我给写库接口加了一层强制校验敏感级别为三级的记忆即使模型误判也会被后端的过滤规则拦截。6.2 遗忘权用户说删就必须真的删用户要求删除记忆的时候系统必须做到从里到外彻底清除。这比写代码听上去难因为数据可能存在多个副本主数据库、向量库、缓存、备份。任何一处漏删都可能在未来某次检索中“复活”。我的删除流程是先在记忆表里把目标记忆标记为 deleted并写入一个不可见的墓碑清单然后从主库删掉记录最后调用向量库的删除接口删除对应向量。墓碑清单的作用是防止备份恢复后已经删除的向量又通过缓存或其他路径被召回。这个清单不需要长期保留但至少覆盖备份的留存周期。这里有个容易被忽视的点用户说“删除记忆”可能不是指具体某一条而是指“所有与我相关的记忆”。所以产品层面我设计了两个级别单条删除和全量清除。全量清除会触发整个用户命名空间的清理流程包括归档区和缓存层。删除操作的每一步都要留审计日志方便出问题时追踪。6.3 记忆投毒与“用户否定优先”原则记忆投毒是我在研究安全边界时发现的一个不可忽视的问题。攻击者可以伪装成用户发表一些误导性内容让提取模型错误地写入一条完全不符合事实的记忆。比如某用户明明没有说过某句话系统却把“用户表示自己非常喜欢某种危险操作”当成事实存了下来后续所有对话都会被这条污染记忆误导。防御不能只靠提取模型自觉。我加了两道防线。第一道是来源可信度只有用户主动陈述的事实才允许写入高重要性档案模型从闲聊里推断的内容重要度会自动降档。第二道是用户否定优先用户明确说“不对我没说过这个”“我不喜欢这样”系统会立刻把那类记忆全部标记为不可用并以用户当前的否定表述为准。这两个机制合起来配合定期的记忆审计日志抽查基本能挡住绝大多数记忆投毒场景。做记忆系统的人必须时刻记住记忆的目的是更好地服务用户而不是把未经核实的内容固化成对用户的错误画像。做长期记忆服务一年下来我的最大感触是这个系统最难的从来不是存储和检索而是搞清楚“什么时候该记住什么时候该忘掉”。我现在养成了一个习惯每周导出一份本周新增记忆清单逐个扫一眼看到那些“看似有用但永远不会被用到”的记忆就顺手清理。真正好用的记忆系统不是让 AI 事无巨细地记住一切而是让它在最关键的时候恰好想起你最需要的那句话。
返回列表