☰
自建大模型记忆层:解决跨会话失忆的实践指南
2026/10/12 13:29:06 网站建设 项目流程

连续加了三天班,把一套 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 的一次工作流程分成四个阶段:

  1. 前置加载:新会话发起时,接收本次会话的首条用户输入,先做一次向量化,从记忆库召回 Top 3 相关记忆。
  2. 注入拼接:把召回结果格式化成"历史记忆"块,与系统提示词拼接,再和用户输入一起发给模型。
  3. 会话进行中:每轮交互都会暂存原始消息到会话缓冲区,但此时不写长期记忆,避免过早固化错误结论。
  4. 结束归档:会话终止或达到轮数阈值后,对缓冲区内容做摘要抽取、向量化、写库,并把原始记录按会话 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.yaml

mem.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_tokens=800) 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_boost

sim是向量相似度,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.5
claude-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",而不要只写"他"、"这个项目"。原因很简单,召回时多条记忆拼在一起,如果主语都是"他",模型很难分辨哪条是哪条。把主语写完整,会让后续注入的上下文清晰度上一个台阶。这个细节没花什么成本,但对整体效果的影响非常明显。

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

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

立即咨询