给大模型补上长期记忆:从上下文窗口到外部记忆层的工程实践

发布时间:2026/10/12 21:11:53
给大模型补上长期记忆:从上下文窗口到外部记忆层的工程实践 1. 从“对话”到“记忆”先搞清楚AI记忆到底缺什么做AI应用开发的朋友应该都有这种体验跟模型聊得正high上下文窗口一滚前面说的关键约定直接“失忆”了。更尴尬的是你昨天让助手记住的偏好、项目背景、代码规范今天再开一个新会话它一脸茫然地看着你。这个痛点就是“claude-mem”这类项目存在的根本原因——给大语言模型补上一套可以跨会话、长期生效的记忆机制。先别急着写代码我们得把“记忆”这个词拆开看。很多文章一说记忆就想到向量数据库好像把对话文本切片、embedding、存起来就万事大吉了。但真正做下来你会发现向量检索只是整条链路里最不费脑的一环。记忆系统真正难的地方在于什么该记、怎么存才能让模型用得上、什么时候把哪些记忆拿出来放进提示词。这三件事没想清楚哪怕你把整本聊天记录都塞进向量库出来的效果也只会是“好像想起了什么又好像什么都没想”。1.1 上下文窗口的本质局限聊上下文窗口之前得先明确一个常被忽视的事实上下文窗口本质上是模型前向计算时的一个固定长度输入区。窗口越大能塞进去的token越多但模型对中间位置的注意力天然偏弱——这已经在不少实验里被验证过。所以哪怕有一百万token的窗口也并不意味着模型“记得住”窗口里的所有细节更不意味着它能在多轮对话里持续跟踪很久之前的约定。更现实的问题是成本。把所有历史会话全部塞进prompt每次调用都是几万甚至几十万token的输入费用和延迟都吃不消。我在实际项目里测过一段中等规模的开发辅助会话全量塞回上下文的成本大概是精炼记忆方案的5到10倍延迟也会从一两秒跳到五六秒。这种体验用户根本不能接受。所以“claude-mem”一类项目做的事情本质上不是让模型自己“记住”而是在我们这一侧维护一个外部记忆层在合适的时机把关键信息重新注入上下文。模型还是那个模型但有了我们帮它“翻笔记”它就能表现得更像一个有长期记忆的助手。1.2 记忆的两种类型会话内上下文与跨会话长期记忆我把记忆分成两类这个区分会直接影响后续存储和检索方案的设计。第一类是会话内记忆。它指的是当前对话里需要持续跟踪的信息比如用户刚刚提到的需求、上一步生成的代码、待修改变量名。这类记忆要求实时、准确通常直接依赖上下文窗口不需要做持久化但需要做一定的结构化梳理——比如把一段长聊天压缩成“当前目标已完成步骤下一步计划”的摘要方便模型快速定位。第二类是跨会话长期记忆。这才是“claude-mem”这类项目的核心目标。它要回答的问题是用户下一次打开应用怎么让模型还记得“这个项目用Python 3.10 FastAPI依赖Redis做缓存用户偏好直接改配置文件而不是环境变量”。这类记忆必须持久化、可检索、有优先级并且需要考虑记忆之间的冲突和过期问题。搞清楚这两类记忆的区别以后你就能理解为什么“给模型加记忆”不能靠简单地把聊天记录存下来完事。会话内记忆讲究压缩和即时性跨会话记忆讲究结构化和可检索性两者必须分开设计混在一起只会让整个系统又慢又乱。2. 记忆系统整体设计用什么存、存什么、怎么取我见过不少人一上来就选向量数据库理由是“记忆嘛肯定要语义检索”。这个思路本身没错但问题在于向量检索适合的是“模糊的、语义相似的内容召回”而用户对AI的记忆期望往往是“精确的、带条件的知识”。你把用户偏好“不要用ES做全文搜索”存成向量下次聊到数据库选型向量相似度能不能召回到这条不一定因为谈论的上下文和关键词可能完全不同。所以我的建议是记忆系统应该是分层混合的不要指望一种存储吃遍所有场景。2.1 存储选型的关键权衡从实用角度出发我把记忆存储分三层第一层是KV结构键值存储。适合存那些“确定性强、更新频繁”的记忆项。比如用户ID、当前项目路径、最近使用的模型配置。这类数据用Redis或者SQLite都能搞定优势是读写快、更新简单缺点是没法做语义检索。第二层是结构化表关系型数据库。适合存带属性、需要筛选的记忆条目。比如“关于项目X的偏好”“关于用户Y的背景信息”每条记忆可以有类型、创建时间、更新时间、来源会话、重要程度等字段。这种结构让你能按项目、按类型、按时间精确过滤做“前提条件匹配”比纯向量检索可靠得多。第三层才是向量索引。适合存“长文本、语义化强、无法用关键词概括”的记忆比如一段设计讨论的总结、一篇技术方案的要点。用embedding模型把它向量化查询时用余弦相似度召回候选。我更倾向于的落地组合是SQLite存结构化记忆 本地或托管的向量索引存语义记忆。如果你处在项目早期甚至可以先只用SQLite加一个简单的关键词匹配/正则规则撑住第一批记忆需求等真实用户反馈来了再决定要不要上向量检索。“claude-mem”这类项目最好的学习路径不是一上来就堆组件而是像我这样先确认需求边界。2.2 记忆数据结构从对话到可检索知识存什么比用什么存更重要。直接从聊天记录里拿原文存进去看起来省事但检索出来给模型的时候信息密度太低上下文都被无意义的话占据了。不过反过来说如果压缩得太狠——比如把对话总结成一句话摘要——又容易丢失关键细节。我的经验是记忆条目应该由“主体谁/什么项目 陈述偏好/决策/事实 限定条件在什么情况下有效 元信息来源/时间/重要度”这几个部分组成。举个例子。用户在对话里说“这个接口别用同步调用之前生产环境出过问题全部改成异步任务吧。”原始聊天记录里有这句话但直接塞回去并不合适因为里面有太多口语化杂质。你需要的是一条结构化的记忆主体订单服务接口 陈述不使用同步调用改异步任务处理 限定条件生产环境 来源2024-06-12 会话#5 重要度高这样一条记忆不仅可以直接注入给模型还可以被精确查询。比如下次聊到订单服务性能优化系统可以把这条记忆直接抛给模型。这比“语义相似度召回一大段聊天文本”要高效得多。还要注意记忆的版本问题。偏好是会变的。用户今天说“不要异步同步调用简单”明天可能说“还是改回异步吧”。如果你只存最新一条会丢失变化轨迹但如果你把所有历史都存着检索时会自相矛盾。我的做法是每条记忆保持“当前有效值 历史变更记录”检索时只返回当前有效值但保留一个可查询的变更日志。这样既保证了注入给模型的记忆的一致性又能在需要时回溯变更原因。3. 核心工作链路把一段对话变成一条记忆的完整流程很多人以为记忆系统的工作方式就是“存对话 → embedding → 检索 → 注入”但实际操作里每一环都有必须想清楚的细节。我把自己在“claude-mem”实践里的完整链路拆给你们看可能跟论文里讲的会不太一样但它是在真实可用性压力下打磨出来的。3.1 记忆提取不是所有对话都值得记这是整条链路里最需要设计的一环。如果你不分青红皂白把每轮用户和助手的对话全部做摘要写入记忆库很快存储就会变得又杂又大检索时噪声压过信号。我见过最失败的实现就是用户随口问了个“今天天气怎么样”系统都认真地把“用户关心天气”写进长期记忆。记忆提取要考虑三个条件是否包含对未来的指导价值。比如用户纠正了模型的一个错误理解、明确了某项偏好、做了个技术选型决策。这些内容对后续对话有约束或指导意义。是否包含具体的事实信息。比如项目名、依赖版本、接口约定、配置文件路径。这类信息是后续对话的锚点值得记录。是否明显带有情感或安全边界。比如“不要用钉钉发消息给我”“这个目录别删”。这类指令通常比较硬属于用户对助手行为的限制优先级最高。实际操作时我一般让模型在每轮对话结束或触发特定条件时输出一段候选记忆的JSON。我一开始的做法是在系统提示词里写一堆规则让模型自己判断“这条对话值不值得记”。但后来发现模型自己判断有偏差有时候过度记录有时候漏掉关键信息。更好的做法是用异步任务隔一段对话再统一做一次记忆提取——比如每完成一个任务、或聊到五轮以上时让模型站在更高视角回看这段历史提炼出真正值得长期保留的东西。3.2 记忆结构化与去重合并提取出候选记忆后你不能直接写入存储。必须先和库里已有的记忆做比对判断三条事这条记忆是否已经存在如果存在是不是需要更新这条记忆是否和已有记忆矛盾比如之前存了“使用MySQL”现在说“改用PostgreSQL”这种不是合并是冲突处理。这条记忆是否过于碎片化比如十条都是关于同一模块的小细节应该合并成一条更完整的描述。我踩过最大的坑是去重合并做得太激进。曾经我把关于“日志存储”的五条小记忆合并成一条大记忆结果下一次检索时模型需要的是“用户偏好JSON日志格式”这一条细节但因为被合并进了一大段文字召回后信息密度反而变低了。后来我的策略是保留条目独立性但在元数据里建立关联。每条记忆还是独立的一条记录但它可以有一个“相关记忆组”的标记。检索时按相关性排序如果命中了N条同组记忆就按顺序把它们拼接后一起注入上下文。这样既保持了细节完整又实现了关联聚合。存储器分层的最终效果是当你检索“订单接口性能问题”时可以在结构化表里直接找到关联条目再去向量索引里做一次相似度补充召回最后把所有命中的记忆按优先级排序拼成一段引导词填入prompt的特定位置。3.3 记忆注入什么时候放、放在哪、放多少记忆不是越多越好。我见过一个让人哭笑不得的案例系统全文注入了五十条记忆模型确实“记得”很多偏好但面对用户当前具体问题时反而变得犹豫甚至在无关的偏好上反复跟用户确认。这其实就是记忆注入过载导致的注意力稀释。要控制注入量我总结出三条原则。第一注入的记忆必须和当前对话有明确的关联条件。触发条件可以在记忆条目里预先定义。比如上面那条“订单服务用异步任务”可以标记触发条件是“用户提到订单服务、接口设计、生产环境问题”只有当这些关键词或语义相似度满足阈值时系统才会把这条记忆取出来放进去。没有条件触发的记忆哪怕再重要也不要放。第二记忆的位置会影响模型的关注程度。实践中我发现把与当前任务直接相关的记忆放在system prompt靠前的位置效果最好把作为背景的长文记忆放在用户输入的中间或末尾效果会弱一些。这其实利用了注意力机制对开头和结尾更敏感的特性。第三记忆总量要设上限。我按token数来控制正常情况下不超过整个上下文窗口的15%。超过这个比例宁可放弃一些低优先级的记忆也要保证当前任务的上下文空间充裕。4. 落地实现一个最小可跑的claude-mem方案讲完设计思路进入动手环节。我不准备给你一个封装好的库而是让你理解核心模块的内部结构和它们之间的接口。这样不管你是要在自己的项目里集成还是想改写扩展都不至于一头雾水。4.1 模块划分与核心代码思路一套最小可用的“claude-mem”实现大致要包含四个模块会话接口层、记忆提取器、记忆存储层、检索注入器。会话接口层负责跟Claude或其他LLM的API打交道。它不只是发请求、收响应还得在响应的同时触发记忆提取流水线。我的做法是在对话完成一轮后把全程的transcript交给记忆提取器处理。记忆提取器的核心逻辑是让大模型根据prompt模板输出一个结构化JSON列出候选记忆列表。这一步的代码思路是这样的import json from typing import List MEMORY_EXTRACT_PROMPT 请你分析下面的对话历史提取出对后续对话有价值的长期记忆。 只提取符合以下条件的条目 1. 明确的用户偏好或行为规则 2. 项目相关的技术选型、依赖约束 3. 用户纠正助手错误的内容 4. 影响后续协作的事实性信息 排除 - 单次性的问答 - 与项目无关的闲聊 - 已经过时的临时信息 输出格式为JSON数组每个元素包含 { subject: 主体如项目名或模块名, statement: 记忆内容一句话概括, condition: 该记忆在什么条件下有效, keywords: [触发检索的关键词], importance: high/medium/low } def extract_memories(transcript: str, sample_memory: List[dict]) - List[dict]: messages [ {role: system, content: MEMORY_EXTRACT_PROMPT}, {role: user, content: f对话历史\n{transcript}\n\n参考已有记忆格式\n{json.dumps(sample_memory, ensure_asciiFalse)}} ] result call_model(messages, temperature0) try: return json.loads(result) except json.JSONDecodeError: # 常见坑模型返回了带markdown代码块的内容需要清理后再解析 cleaned result.strip().strip(json).strip() return json.loads(cleaned)注意里面那行清理代码。大模型有时会在JSON外包一层markdown代码块标记你不处理的话json.loads直接报错这是实际使用中遇到概率最高的解析失败类型。存储层我用SQLite做例子因为它零配置、单文件、容易迁移。表结构可以简化成刚提到的记忆条目模型CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, subject TEXT NOT NULL, statement TEXT NOT NULL, condition TEXT, keywords TEXT, importance TEXT DEFAULT medium, created_at TEXT, updated_at TEXT, current_valid INTEGER DEFAULT 1 ); CREATE INDEX idx_memories_subject ON memories(subject);注意我加了current_valid字段用来处理记忆冲突。当一条新记忆和旧记忆矛盾时你不直接删除旧记录只是把旧的置为不可用。这样既保证检索结果始终一致也不会丢失变更轨迹。4.2 检索策略与相关性控制检索的设计目标很简单在减少噪声的前提下尽可能召回真正对当前对话有用的记忆。我用的检索方式是“关键词粗筛 语义精排”两阶段。关键词粗筛指在SQLite里用LIKE或全文索引先按记忆条目里的keywords字段、condition字段做过滤把候选集缩到很小。这一步计算成本低、速度快能过滤掉大部分无关记忆。但也别太依赖它因为用户表达方式可能变这时就能看出语义精排的价值。语义精排指对粗筛后的候选集分别和当前对话内容计算cosine相似度按分数排序后取top-K。我建议K取5到8之间。K太小容易漏K太大容易把边际记忆带入上下文。def retrieve_memories(current_context: str, candidate_keywords: List[str], k: int 5) - List[dict]: # 第一阶段关键词粗筛 sql SELECT * FROM memories WHERE current_valid 1 # 用SQL拼条件按keywords LIKE匹配这里省略具体拼接逻辑 candidates query_memories(sql, candidate_keywords) # 第二阶段语义精排 context_vec embed_text(current_context) scored [] for mem in candidates: mem_vec embed_text(mem[statement]) score cosine_similarity(context_vec, mem_vec) scored.append((score, mem)) scored.sort(keylambda x: x[0], reverseTrue) return [mem for _, mem in scored[:k]]这段代码里值得用心调的是“当前上下文”的选取。你有没有发现用户当前说的一句话往往包含的信息量不足以做可靠的语义匹配比如用户只说“那个接口跑得好慢”没有提“订单”也没有提“异步”单靠这句话去匹配“订单接口异步任务”这条记忆相似度可能是不达标的。所以我的做法是把当前对话的最后几轮内容拼接起来再做embedding。这样的话上一轮用户提到的具体项目名、报错信息就能参与匹配显著提高了语义召回的精度。5. 实测中的两个头痛问题记忆污染与成本失控“claude-mem”这类项目demo跑通容易真的放到实际环境里长期用会遇到两个绕不开的头痛问题。这两个问题我在多个项目里都撞上了单独拿出来聊聊。5.1 记忆污染错误记忆比没有记忆更可怕记忆污染指的是系统保存了错误的、过期偏好的、或带有误导性的记忆并在后续对话中被当作真实背景注入给模型导致模型一本正经地按错误信息回答。我实际遇到过的最典型场景是用户在某次调试中说“先临时把超时时间改成3秒看看效果”这句话本身带着“临时调整”的意图但在记忆提取时如果没加限定条件系统就存成一条“超时时间为3秒”。第二天用户再问起这个接口的配置时模型就一本正经地告诉他“超时时间为3秒”而那个值其实早已改回去了。要解决记忆污染首先要从提取端下功夫。在记忆提取prompt里我加了一条明确规则凡是带有临时性、试探性、条件性表述的内容不得写入长期记忆除非用户明确说“以后都这样”。其次在注入端也要设防。我引入了“记忆来源显示”机制。当某条记忆被注入上下文时同时附上它的来源会话ID和记录时间。模型在回答中引用时会自带来源信息。这样做的好处是即便某条记忆是错的至少用户能看出模型是从哪里得到这个信息的可以快速纠正他下次记忆再更新成对的。没有来源的记忆用户根本无从质疑错误就会在对话里悄悄传导。第三层防线是定期清理和用户反馈机制。我会设置一个“记忆回顾”接口在用户没有主动提问的时候每周自动生成一份记忆清单给用户确认这些记忆是正确的吗哪些可以删除虽然这个机制需要用户配合但在多用户产品里它是最可靠的质量闸门。5.2 成本与延迟加记忆不能拖垮主流程记忆系统的引入有一个隐性代价每次对话可能要额外调用一次embedding、一次查询、一次模型记忆提取。这些操作叠加起来如果设计得不好会显著拖慢主对话延迟甚至翻倍。我踩过的一个具体坑是记忆提取是同步调用的。用户每发完一轮消息系统要等记忆提取模型完成后才继续执行下一步。这意味着多了一个完整的模型调用延迟用户体感立刻变卡。解决方案是把记忆提取改成异步任务主对话流程立刻返回给用户记忆提取在后台队列里慢慢做。由于记忆提取对实时性没有要求哪怕延迟五分钟都没关系。你只需要在消息发送前检查一下“最近是否已完成记忆更新”如果没有就先把旧记忆拿来用。embedding调用也要控制频率。不必每轮对话都对整段历史重新embedding。我通常只在对话终结或任务切换时才执行新一轮embedding和记忆更新。这样整体成本可以压到只占主流程调用量的10%左右延迟几乎无感。还有一个小建议向量检索召回的结果如果语义相似度低于某个阈值宁可注入空结果也别硬塞一些不相关内容。在实践中我发现低相似度召回不仅没有帮助反而会因为上下文噪声干扰经常导致模型给出模棱两可的答案。阈值我一般设在0.35到0.45之间具体值跟你的embedding模型表现有关系需要反复试。6. 进一步想清楚的使用边界与扩展思路最后聊几个容易被忽略的边界问题以及一些我认为值得尝试的演进方向。6.1 记忆的隐私边界一旦开始跨会话记忆就必须面对隐私问题。用户可能没有意识到他在对话里透露的偏好、行为习惯、甚至敏感信息会被系统永久记录并在未来任意一个会话里使用。作为开发者你有责任在设计和产品层面管理这种边界。我的基本做法是先分三层会话内记忆只存在当前请求上下文里对话结束即失效跨会话记忆默认只存用户显式标为“需要记住”的内容超出业务需要的信息比如身份信息、地址等能做脱敏处理就脱敏处理。另外给用户一个完全优雅退出的入口——一键清空全部记忆。我在产品里见过太多人忽略这个按钮直到用户问“你凭什么记得我的事”才发现问题的严重性。6.2 扩展方向从“记住”到“主动回忆”如果你已经把“记住”这件事跑顺了下一步值得思考的是记忆系统能不能从被动检索升级到主动回忆被动检索的逻辑是“用户问了系统查了把相关记忆放进去”。而主动回忆的逻辑是在对话的某些特定节点系统自己意识到“这里应该补充一个背景信息”不等用户明确提起就先把相关记忆注入或通过提示引导用户回忆。比如用户重新打开一个长时间未访问的项目时系统可以主动说“距离上次项目讨论已经两周了当时你提到限制条件包括……”这种主动回忆的实现难度不在技术而在触发策略设计。太主动会变成打扰太被动又没价值。理想状态是用记忆条目的condition字段配合对话阶段来判断。比如当用户切换到某个项目上下文时触发“项目背景”类记忆的注入当对话中出现某个重要关键词时触发“决策约束”类记忆的注入。从可扩展性的角度还有一个方向是让记忆系统支持多种语言、多种模型后端。因为同一个记忆层理论上可以服务于任意大模型产品而不只是Claude。你做出来的这套“记忆提取-结构化-分层存储-相关召回”骨架换一个模型照样能跑。这实际上也是“claude-mem”这类项目最有价值的部分——它本质上是在给模型外面套一层“外脑”而不依赖某个特定模型内部的记忆能力。我在实际使用中发现最影响这套系统长期可用的品质不是算法而是记忆质量的维护意识。代码层面的bug好修记忆库里的脏数据难清。所以如果让我给第一次尝试做记忆功能的开发者一个建议先跑两周真实对话定期翻看记忆库里自动生成的条目手动清理那些没用的、错误的记录。这个过程会逼你对“什么值得记”产生直觉比看一百篇教程都有用。等你的提取提示词打磨得足够稳定了再逐步放开自动写入也不迟。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询