自建大模型记忆层:解决跨会话失忆的实践指南

发布时间:2026/10/12 13:29:07
自建大模型记忆层:解决跨会话失忆的实践指南
连续加了三天班把一套 prompt 调试到能稳定产出结构化结果。第四天早上打开电脑发现会话窗口已经被系统重置前一天晚上调整的十几个参数、几轮关键的修正对话全部找不回来。重新开一个会话模型完全不记得之前定义过的输出格式我又花了将近两小时把背景重复了一遍。就是从那次开始我决定自己动手做一个记忆层项目代号就叫 claude-mem。这个项目的定位很简单不是把聊天记录存下来当档案而是把散落在多轮对话里的有效信息蒸馏成可以被下一次会话直接使用的上下文。它能做到三件事跨会话记住用户偏好、自动提取进行中任务的状态、在不污染当前任务的前提下按需注入历史记忆。如果你也经常被模型又失忆这个问题折磨或者想给自己的对话应用加一层本地可管的记忆能力这篇文章应该能给你一个可以复现的参考。1. 为什么我需要一个独立于模型服务商的记忆层1.1 会话式 API 的本质是无状态做过对话应用的人应该都有体会调用模型接口时多轮对话靠的是把历史消息一遍遍塞进请求参数里。模型本身并不保留任何状态服务商也不会替你把上一次会话的内容带到下一次。你关掉页面或者代码里新建一个会话一切就归零了。这就像你每次走进办公室都会遇到一个完全不认识你的新同事你必须重新自我介绍一遍我是谁、我之前做过什么、我们现在在哪个项目上、今天要解决什么。短时间内重复三次还能忍但到了第十几次你一定会想找一个备忘本出来。我当时遇到的情况更崩溃前端页面本身有 localStorage 存聊天记录但它只是把历史消息原样展示在界面上。新会话发起时程序并不会把这些消息自动塞回上下文。模型收到的还是一张白纸。所以问题的关键不在于数据没了而在于数据在但模型用不上。1.2 聊天记录存档不等于记忆很多人的第一反应是把聊天记录导出下次全量塞进提示词里不就行了我一开始也这么试过结果没到 20 轮对话上下文窗口就快满了。一次三小时的调试对话累计 token 可能上万甚至几万直接塞回去既费钱又会让模型把注意力分散到大量无关细节。更深一层的问题是聊天记录里包含大量噪音。客套话、过渡句、重复的试错过程这些对下一次会话毫无价值。真正需要被记住的是三类东西用户明确表达过的偏好、任务当前进展到哪里、之前踩过的坑或给出的关键结论。这些信息藏在对话的字里行间需要被提炼出来。纯粹的关键词搜索和原文翻找解决不了这个问题因为模型不会把别用大模型做正则解析这句话的用意直接写进下一轮请求里。必须有个东西在对话结束后替我把这些要点捞出来、结构化、存好。1.3 用现成平台功能为什么不够市面上其实有很多第三方记忆插件和商业方案。但我需要的是一个能完全跑在自己服务器上、数据不出内网、存储格式自己可控的组件。商业方案往往把记忆数据托管在人家那边要么按量付费要么存在隐私风险。更重要的是我需要记忆触发逻辑可编程什么时候该写入、什么时候该召回、注入位置怎么安排这些细节都希望按自己的场景定制。所以 claude-mem 的架构原则从一开始就定了模型调用层与记忆层完全解耦。记忆层是一个独立服务通过规则和少量模型调用完成摘要—存储—召回的闭环。模型服务商是谁无所谓换掉底层模型时记忆层代码一行都不用改。这也是我把项目起名叫 mem 而不是绑定某个具体产品名的原因。2. claude-mem 的记忆机制拆解2.1 记忆的三层划分刚开始设计时我想做得很复杂后来发现记忆这回事用三层模型就能说清楚层级生命周期典型内容存储位置瞬时记忆单次请求内当前轮到第几步、刚拿到什么数据请求变量短期记忆单次会话内输出格式约定、临时决定、中间结果会话上下文长期记忆跨会话用户偏好、长期目标、项目背景本地向量库摘要库瞬时记忆根本不需要落盘模型上下文天然携带。短期记忆靠多轮消息传递也能解决问题不大。真正需要 claude-mem 去做的是长期记忆层每次会话结束后把本次会话的关键信息蒸馏成记忆条目存进本地下次新会话开始时再根据新的问题把相关条目捞出来。2.2 为什么选结构化摘要向量召回而不是全文检索这个决定我纠结了很久。最直观的方案是全文索引用关键词匹配实现简单但效果很差。原因在于记忆查询大多是语义相近但措辞不同的。比如上次对话说的是用户不喜欢过于冗长的输出下次你问的问题可能是新需求的报告要简洁一点两段话没有共同关键词但语义高度相关全文索引根本没辙。所以 claude-mem 走的是双通道结构化摘要通道每次会话结束调用模型把对话内容压缩成 5 到 8 条结构化记忆每条包含主体、行为、对象、约束、来源会话 ID、时间。向量检索通道对每一条摘要生成向量表征存入本地向量索引。新会话进行中把最近几轮用户输入也向量化和记忆库做相似度查询取 Top K 再注入。实际跑下来这个组合召回质量比我预想好很多。哪怕用户只是说上次那个东西接着弄也能匹配到用户正在推进 XX 功能开发这条记忆。2.3 一次完整的读写生命周期抛开细节只看骨架claude-mem 的一次工作流程分成四个阶段前置加载新会话发起时接收本次会话的首条用户输入先做一次向量化从记忆库召回 Top 3 相关记忆。注入拼接把召回结果格式化成历史记忆块与系统提示词拼接再和用户输入一起发给模型。会话进行中每轮交互都会暂存原始消息到会话缓冲区但此时不写长期记忆避免过早固化错误结论。结束归档会话终止或达到轮数阈值后对缓冲区内容做摘要抽取、向量化、写库并把原始记录按会话 ID 归档到 jsonl 文件。这个设计的核心是先读后写、异步归档。读取发生在会话一开始写入发生在会话结束中间过程完全不阻塞模型调用性能损耗几乎可以忽略。3. 本地可跑的 claude-mem 实现存储与核心代码3.1 存储选型与目录结构存储层我用了最朴素的方案SQLite 存摘要和各种索引字段JSONL 存原始对话归档向量索引单独用一个本地目录。没有上重型数据库因为单机场景下 SQLite 的读写能力完全够用而且备份迁移特别省事。claude-mem/ ├── data/ │ ├── archives/ │ │ └── session_20250412_0930.jsonl │ ├── mem.sqlite3 │ └── vector_index/ ├── src/ │ ├── recall.py │ ├── summarize.py │ └── inject.py └── config.yamlmem.sqlite3里最核心的表就三张sessions记录会话 ID、开始时间、结束时间、消息轮数。memories每行一条长期记忆字段包括content、source_session、created_at、embedding_id。meta存全局统计和 token 消耗方便估算成本。JSONL 归档的作用是回放和审计。向量和摘要都只是蒸馏产物万一发现某条记忆有误我还能回到原始对话去核对。3.2 摘要生成与写库摘要生成是 claude-mem 里离不了模型的一环。我把这部分做成一个独立函数传入一段原始对话文本返回结构化记忆列表。关键点是给模型的指令要足够严格否则它会输出一堆感叹句和废话。# summarize.py def extract_memories(conversation_text: str, session_id: str) - list[dict]: prompt f 请从下面的对话中提取值得长期记住的信息。 只输出 JSON 数组每条包含三个字段 - subject: 信息主体人、项目、任务 - behavior: 主体的行为或状态 - constraint: 约束条件没有则留空 规则 1. 忽略寒暄和过程性噪音 2. 每条不超过 30 个字 3. 如果对话里没有任何值得长期记忆的信息输出空数组 --- {conversation_text} result call_model(prompt, max_tokens800) memories parse_json(result) for m in memories: m[session_id] session_id m[created_at] now() return memories写库的时候有个细节容易被忽略每条记忆要生成对应的向量 ID但不要把向量直接塞进 SQLite。我单独维护一个本地向量索引目录SQLite 只存embedding_id这个外键。这样将来如果要换向量数据库SQLite 里的表结构不用动。3.3 上下文注入与裁剪策略召回容易注入难。直接把 3 条记忆拼在用户消息前面往往会让模型产生一种用户正在要求我处理历史问题的错觉。我的做法是把召回的记忆包装成背景档案明确告诉模型这是参考信息不是当前指令。# inject.py def build_injected_context(memories: list[str]) - str: if not memories: return section 以下是对应用户相关的历史背景摘录仅用于参考请勿直接回应这些内容\n for i, mem in enumerate(memories, 1): section f{i}. {mem}\n section 请基于以上背景结合用户最新的问题做出回应。\n return section拼接位置我放在系统提示词的最后、用户消息之前。放在系统提示词之前模型容易把背景当成最高优先级指令放在用户消息之后又容易让历史内容压过用户当下的输入。这个位置是做了大量对照实验后确定的后面会展开说。另外上下文窗口有限注入的记忆量必须动态调整。我设置了一个简单规则当最近输入已经很长时注入条数从 3 降到 1当最近输入很短时可以加到 5 条。核心是保证历史记忆占比不超过整段上下文的 15%。超过之后模型对当前任务的注意力会肉眼可见地下降。3.4 检索排序时间衰减与相似度加权从向量库召回 Top K 之后如果只按相似度排序会出现一个问题三个月前的一条高度相似记忆可能和当前场景已经完全不相关了。用户的想法、项目阶段、甚至使用习惯都可能已经变过好几轮。所以我给召回排序加了一个时间衰减因子。实际计算时用下面的公式score sim * exp(-days_ago / 30) keyword_boostsim是向量相似度days_ago是距离今天的天数30 是半衰期参数keyword_boost是当记忆文本与当前用户输入存在明显实词重叠时加的小分。这个公式不复杂但效果立竿见影旧记忆不会消失但除非相似度极高否则很难把新记忆挤下去。我一开始用的是纯向量相似度结果用户换了个更具体的说法后老是召回最初那几条泛泛的旧记忆。加了时间衰减后最近一个月内用户强调过的偏好明显更容易被捞出来这更符合人脑的遗忘规律。4. 参数调优与实测效果4.1 摘要触发阈值怎么调摘要触发时机直接影响记忆质量。触发太频繁会把一些临时决定固化成长期记忆污染记忆库触发太晚上下文太长摘要生成时会丢失细节。我最终设置的触发阈值是双条件任一满足会话轮数达到 6 轮或累计 token 达到 3000。两个条件都跟信息量挂钩。轮数是过程维度token 是内容维度。比如用户一连问了五个快速的小问题轮数到了但真正有信息量的可能只有最后一问这时候按 3000 token 的阈值再等等更合适。反之如果用户在很长的上下文里反复打磨一句话token 早早爆表就该立刻归档。4.2 上下文门控防止历史记忆污染当前任务调优过程中遇到的最大问题是记忆串味明明用户只是随口问一句天气注入的历史记忆里却有用户偏好详细技术方案这种条目模型就开始长篇大论讲架构把天气问题都忘了。后来我加了上下文门控逻辑简单说就是每次注入前先判断当前用户输入是否与召回的几条记忆属于同一主题域。判断方式不复杂先做关键词分类再算一下用户输入向量和每条记忆向量的主题距离。如果最相似的记忆主题距离也超过阈值说明这次会话和旧记忆完全无关那就只注入系统提示词不带历史背景。这个门控看起来简单但实际价值非常大。它的本质是承认不是每次对话都需要记忆让记忆层保持沉默反而能提高模型的专注度。加了门控之后无关会话中的历史注入数量下降了 70%回答偏题的情况基本消失。4.3 三组对照实验的数据为了验证 claude-mem 的真实收益我拿一个固定任务做了对照测试让模型在不同模式下完成同一套根据历史对话续写项目周报的活儿。三种模式分别是无记忆、全量原始记录注入、claude-mem 摘要注入。结果如下模式平均响应时间单次 token 消耗周报信息完整度主观评分无记忆无法完成任务-0全量原始记录注入8.6 秒约 120007.5claude-mem 摘要注入3.2 秒约 35009全量注入虽然也能完成任务但 token 消耗是 claude-mem 的三倍多耗时也长。摘要注入的评分反而更高原因是原始记录里大量冗余消息干扰了模型对重点信息的提取而记忆层提前帮助模型过滤了一遍噪音。当然这个评分是我自己主观打的不是严格的双盲测试但对一个自建工具来说已经能说明问题记忆的价值不在于数据多而在于口径清晰。5. 踩坑记录我在自建 claude-mem 时踩过的三个问题5.1 摘要互相引用导致的幻觉放大第一次跑通全链路后我发现一个诡异的现象记忆库里出现了一条用户并没有说过的话。查了老半天才找到原因——摘要模型在提炼对话时把用户引用我之前某条回复的句子当成用户自己的观点写进了记忆。比如用户说你上次说的那个方案我觉得可以先按它执行摘要模型如果不够聪明可能会拆成用户认可了某个方案并决定执行这没问题。但如果它把后半句引用也展开成用户提到方案执行步骤是第一步、第二步……这就凭空生成了新信息。这类错误有个累计效应今天生成一条幻觉记忆明天召回后又注入给模型模型基于幻觉继续回答后天再把这轮回答也摘要成新记忆。最后记忆库和真实情况越来越远整个系统都在一本正经地胡说八道。解决办法是给摘要模型加一条硬规则所有摘要内容必须能在原始对话中找到对应原文。如果原始对话里没有这句话摘要宁可直接截取原文片段也不要转述。甚至可以在提示词里直接要求涉及用户决策时尽量保留原文而不是改写。改了之后幻觉类记忆基本消失了。5.2 注入位置对回答风格的影响前面提到注入位置我做过很多实验这里把具体的差异列出来给后来人一个参考位置 A系统提示词之前。模型把历史背景当成最高优先级指令回答时完全跳不出旧记忆的框架甚至会出现用户没问历史相关问题模型却主动汇报历史进展的情况。位置 B用户消息之后。模型容易把历史记忆混进当前用户话语里导致它以为用户说了很多原本没说的话逻辑混乱。位置 C系统提示词末尾、用户消息之前。模型能区分背景资料和当前指令表现最稳定。按位置 C 注入后我还特意加了仅用于参考请勿直接回应这些内容这句话。没有这句话时部分模型会把记忆条目当成问题逐条回复加完这句才真正变成背景信息。5.3 并发会话写入导致记忆丢失本机使用时我偶尔会同时开两个窗口调同一个项目结果发现归档时经常只保留最后一次会话的摘要另一个会话写进去的记忆被覆盖了。排查下来发现是写库逻辑太粗暴直接用INSERT OR REPLACE两个会话同时写入时后者把前者整行替换掉。修复方案有两个我两个都做了给每个记忆条目生成独立的 UUID不再用会话 ID 序号作为主键写入时改成INSERT ... ON CONFLICT DO UPDATE但只更新content字段不碰created_at和source_session。这个坑提醒我即使是一个单人使用的本地工具也必须从一开始就假设可能有并发调用否则后面接上自动化脚本时问题会被放大十倍。6. 下一步准备做的三个扩展方向6.1 用户级画像聚合目前的记忆条目都是独立的还没有把它们按用户维度聚合起来。比如 A 用户周三提到希望输出简洁一点周末又提到上次那种简洁风格很好两条记忆之间是有关系的但当前系统只是各自存储、各自召回。下一步我想加一个轻量的聚合层每周对当前用户的全部记忆做一次聚类提炼出 3 到 5 条用户长期偏好画像作为更高级别的记忆注入。这样即使近期对话里没有明确表达偏好模型也知道该用户大概喜欢什么风格。6.2 定时遗忘与隐私过期策略记忆不能只进不出。信息时间越久价值越低还可能在几个月后成为误导因素。我计划给记忆条目加一个生命周期配置普通记忆 30 天未命中自动衰减权重90 天未命中直接清理和用户隐私强相关的条目比如家庭信息、财务细节默认 7 天后过期。实现上只需要在召回时加一个时间过滤条件配合 SQLite 里的定期清理任务。做这个功能不仅是为了隐私合规更是为了让记忆库保持高质量小体积的状态。6.3 从单机到小团队共享等单机版本稳定之后我想把它改造成一个小团队也能使用的共享记忆服务。思路是中间加一层权限控制同一个项目组内的成员可以互相检索对方留下的项目相关记忆但个人偏好类记忆只对本人可见。这需要给记忆条目增加一个scope字段查询时带着用户身份过滤。难点在于项目记忆和个人记忆的边界判断不能简单按关键词分得按记忆来源会话的话题域来分。这块我还在做实验等跑出一版稳定方案再单独写一篇记录。最后说一个我在实际使用中发现的小技巧写摘要时每条记忆的subject字段尽量用专属名词开头比如用户 A、项目 X而不要只写他、这个项目。原因很简单召回时多条记忆拼在一起如果主语都是他模型很难分辨哪条是哪条。把主语写完整会让后续注入的上下文清晰度上一个台阶。这个细节没花什么成本但对整体效果的影响非常明显。