跳到正文

第 8 章:多轮对话与记忆管理

能讲清记忆模块是什么、短期/长期记忆怎么管,会用 30 行代码实现"带记忆 + 指代消解"的多轮对话,并讲清主流框架的记忆方案

📊 学习时长:50-65 分钟 🎯 完成后能力:能讲清记忆模块是什么、短期/长期记忆怎么管,会用 30 行代码实现"带记忆 + 指代消解"的多轮对话,并讲清主流框架的记忆方案 🔗 关联面试题:5 道(覆盖 5 个不同角度,见 Part 3)

这一章你会学到什么

  • ✓ 零基础也能跟着做:用一段零依赖代码,亲眼看到"没记忆,多轮里的'它'就无解;有记忆才接得住"
  • ✓ 看懂记忆模块是什么:它是和检索模块并行的"动态上下文来源"
  • ✓ 分清短期记忆(本次会话)和长期记忆(跨会话、用户画像)
  • ✓ 学会对话历史怎么选、上下文怎么裁剪(截断 vs 摘要)
  • ✓ 理解长期记忆的管理:用户画像、遗忘机制、存储选型
  • ✓ 讲清 LangChain / LlamaIndex 的记忆方案区别

本章按三段式组织,不同基础的读者各取所需:

段落 给谁看 内容 占比
🚀 Part 1 · 主线实战 零基础,想先跑起来 概念白话 + 多轮记忆 demo ~45%
🎯 Part 2 · 面试深度 想讲透、备战面试 历史选择 + 裁剪 + 长期记忆 + 框架 ~45%
🏆 Part 3 · 验收串题 检验学到位没 关联面试题 + 自检清单 ~10%

🚀 Part 1 · 主线实战 | ~45% 这部分零基础也能跟着做。先用一段不依赖任何库的代码,亲眼看到"记忆"对多轮对话有多关键。

在开始之前

你需要:

  • ✅ 装好 Python(3.9+),能看懂基础 Python(类、列表、字典)
  • ✅ 本章 demo 零依赖,直接跑
  • ✅ 最好先读过第 7 章,那章讲检索前怎么"读懂一句问题",这章讲怎么"记住前几句问题"

先看一个多轮失败演练(这是本章的"为什么")

下面是用合成对话构造的教学场景,不是客服反馈或线上事故。给保险问答接上多轮后,如果没有记忆模块,机器人会像"金鱼记忆":上一句说的,下一句就忘。

  • 用户第 1 句问"重疾险的犹豫期多久?",答得好好的;第 2 句追问 "那它过了还能退吗?",机器人直接懵了:"它"指谁?"过了"过了啥?这句单独拿去检索,什么都匹配不到。
  • 还有用户聊了十几轮后,前面说过的"我给孩子买的"这种关键前提被挤出了上下文窗口,机器人又开始问"请问是给谁投保呢?",用户当场炸毛。

检索和生成默认只处理当前输入;多轮对话的连贯还需要记忆模块。 它负责保留前几轮的关键信息、把"它/这个"补全,并在对话变长时决定"留什么、丢什么"。是否改善多轮体验,要用指代消解准确率、任务完成率和人工评审等指标验证。

一句话:单轮看检索,多轮看记忆。记忆没做好,再聪明的模型也会"前言不搭后语"。

这一章,我们就先用一段零依赖代码,亲手把"有记忆 vs 没记忆"的差别跑出来。

3 个核心概念(先白话,再术语)

概念 1:什么是"记忆模块"?

前面 7 章讲的检索,都是从固定知识库里捞事实(条款、制度),它不随对话变,谁来问、第几轮问,查到的都一样。但多轮对话还需要记住这次聊天本身:你是谁、上一句问了啥、你的偏好。负责这件事的,就是记忆模块

它可以理解成和检索模块并行的"第二个上下文来源":检索管"看即时知识",记忆管"记往日事"。

引入记忆后,系统的一轮问答变成一个循环:双重检索(查知识 + 查记忆)→ 融合 → 生成 → 把本轮新信息存回记忆。下一轮就"记得"了,这就是"越聊越懂你"的由来。

概念 2:短期记忆 vs 长期记忆

  • 短期记忆:只活在本次会话里:最近几轮对话、当前任务状态。会话结束就可以丢。它解决的是"接住上一句"。
  • 长期记忆:跨会话长期保留,你的偏好、聊过的主题、画像。它解决的是"记得你上周说过的话"。

一句话记住:短期记忆管"这次聊天的连贯",长期记忆管"跨次聊天的懂你"。 小项目先把短期做好就够用了。

概念 3:为什么对话历史不能"全塞给模型"?

最朴素的想法是:把所有历史都拼进 prompt 不就行了?不行:大模型一次能读的 token 有上限(第 1 章讲过),对话一长就塞不下;而且塞太多旧的、无关的,反而会干扰生成(还记得第 6 章的 Lost in the Middle 吗)。所以记忆模块的核心工作之一,就是有策略地"选"和"裁":留住该留的,压缩或丢掉没用的。

🛠️ 跟着做:30 行实现"带记忆 + 指代消解"的多轮对话

Step 1:写一个对话记忆类

新建 memory_demo.py(完整文件见 code-snippets/memory_demo.py,这里是核心):

class ConversationMemory:
    """最朴素的对话记忆:滑动窗口保留最近几轮 + 更早的压成一句摘要。"""

    def __init__(self, window=2):
        self.window = window          # 滑动窗口:上下文里只保留最近 window 轮
        self.history = []             # [(用户问, 助手答), ...]
        self.summary = ""             # 挤出窗口的旧对话,压成摘要(真实系统用 LLM 概括)

    def add(self, user, bot):
        self.history.append((user, bot))          # 新一轮先进历史
        while len(self.history) > self.window:     # 超出窗口 → 最旧一轮挤出
            old_user, _ = self.history.pop(0)
            self.summary += f"用户先前问过「{old_user}」;"   # 朴素"摘要"

def resolve(query, mem):                 # 朴素指代消解:把"它"换成最近提到的产品
    ent = last_entity(mem)               # 从历史里找最后提到的产品(重疾险/意外险…)
    if "它" in query and ent:
        return query.replace("它", ent)  # "那它过了能退吗" → "那重疾险过了能退吗"
    return query

Step 2:运行

python memory_demo.py

你应该看到(知识库固定,结果是确定的):

=== 带记忆 + 指代消解 ===
轮1 用户:重疾险的犹豫期多久?
     助手:重疾险犹豫期为 15 天,自签收次日起算。

轮2 用户原话:那它过了还能退吗?
     补全后 :那重疾险过了还能退吗?
     助手   :重疾险在犹豫期内可全额退保,过了犹豫期退保只退现金价值。

=== 对照:不带记忆,直接拿原话检索 ===
轮2 用户:那它过了还能退吗?
     助手:【没接住】问题里没有明确的产品,检索失败   <- '它'无法解析,检索失败

🐛 跑不通看这里

  • 轮2 补全后还是"没接住"?检查 KB 的键有没有改:demo 靠"产品 + 关键词"双命中,改了键就匹配不上。
  • resolve 没把"它"换掉?确认轮1 的问题里出现了 PRODUCTS 列表中的某个产品(如"重疾险"),否则 last_entity 找不到实体可替换。
  • AttributeError?确认 ConversationMemoryresolveanswer 都粘进了同一个文件,且 mem.add(...) 在轮2 之前调用过。

Step 3:看懂这组对照

  • 带记忆:轮1 提过"重疾险"被存进 history;轮2 的"它"被 resolve 补全成"重疾险",于是检索命中退保条款,答对了;
  • 不带记忆:直接拿"那它过了还能退吗"去检索,"它"指谁系统不知道,检索失败

同一句话、同一个检索系统,差别只在:系统记不记得上一轮聊的是什么。 这就是记忆模块的价值。

Step 4:🛠️ 动手实验,把窗口调小,看信息怎么丢

ConversationMemory(window=2) 改成 window=1,再多聊几轮:

  • 你会看到,只要超过 1 轮,更早的对话就被挤进 summary(摘要),不再逐字出现在上下文里
  • 如果某个关键前提(比如"我是给孩子买的")被挤出窗口、又没被摘要好好记住,后面就会"断片",这正是开篇那个"机器人反复问'给谁投保'"的根因。

亲手把 window 从 1 调到 5,感受"窗口越小越省 token、但越容易忘事"的取舍。这就是 Part 2 要讲的'历史选择'和'上下文裁剪'。

🧭 你刚才做的,对应工程系统里的什么职责?

  • 你的 ConversationMemory(滑动窗口 + 摘要) = 一种短期记忆原型;工程实现可以选进程内存、缓存或数据库,是否用 LLM 摘要要按质量、延迟和成本实测;
  • 你的 resolve()(把"它"补全) = 指代消解,本质是第 7 章 Query 重写的一种,只是信息来源是对话历史;
  • 你这个"先补全、再检索"的流程 = 多轮 RAG 在线链路里,记忆模块坐在检索之前的位置。 你手搓的是它最朴素的样子;Part 2 讲工程系统可以怎样选历史、控制上下文、管理长期记忆和用户画像。

这一节你掌握了什么?

  • ✅ 记忆模块是什么、为什么多轮对话离不开它
  • ✅ 短期 / 长期记忆的区别
  • ✅ 亲手实现了"带记忆 + 指代消解",看清了"没记忆就断片"

🎯 Part 2 · 面试深度 | ~45% 这部分讲透对话历史怎么选、上下文怎么裁、长期记忆怎么管、主流框架怎么做。

2.1 对话历史怎么选

每轮生成时,不能把全部历史都塞给模型(token 有限,且旧信息会干扰)。怎么挑该带哪些历史?三种典型办法:

  • 最近 N 轮(滑动窗口):只取最近几轮。实现最简单、最稳,基于"最近的通常最相关"的假设。缺点:用户突然聊回很早的话题,窗口里就没那段了。
  • 语义相关:把历史也向量化,按当前问题检索出语义最相关的几条历史(哪怕在很久之前)。效果好,但要为历史建向量索引,还可能捞到"主题相似但语境不同"的旧内容。
  • 主题 + 重要度:标注出对话里的关键事实/决策点,在当前问题属于同一主题时才取用。最准,但要预处理标注。

业内行话:三种策略可以组合,但不是默认答案。只有当同一评估集显示它们分别补足了时效、旧话题召回和关键事实保留,并且新增延迟与维护成本可接受时,才值得组合;简单场景用最近 N 轮也可能已经足够。

2.2 上下文裁剪:截断 vs 摘要替换

当候选上下文超过模型可用预算时,需要控制输入长度,常见做法包括裁剪、摘要或把信息外置后按需检索:

  • 截断:直接按先进先出砍掉最旧的几轮。实现最简单,但老对话里的有用信息一刀切掉了。
  • 摘要替换:把较早的多轮压成一段摘要,用摘要替换原文。省 token 又留住"梗概",代价是要多一次摘要调用(通常用 LLM 生成)。

业内行话:裁剪的通用原则是:优先保住最近几轮(权重最高),更远的用摘要压缩而不是硬砍,明显跑题的闲聊可直接删,关键事实单独留底别被压没。LangChain 的 LangGraph 已经把"按 token 上限自动裁剪/摘要"做成了内置能力,不用自己从零写。核心目标永远是:在"信息别丢"和"长度别超"之间取平衡。

2.3 长期记忆:用户画像与遗忘机制

短期记忆解决"这次聊天的连贯",但要做到"跨天还记得你",得上长期记忆。它通常把记忆以向量形式存进一个语义库,需要时按相似度"回忆":本质上就是把记忆库当成一个小型 RAG 语料(第 4、5 章那套又能复用了)。

两个关键设计:

  • 用户画像:从历次对话里提炼出偏好、事实、长期目标(像 MemoryBank 那样维护一个"用户画像"),每次对话把它作为 System 上下文注入,模型就"记得"并"在意"这些因素。注意画像要随对话持续刷新:人的偏好会变。
  • 遗忘机制:长期记忆不加管理会越积越多、引入噪音。参考艾宾浩斯遗忘曲线:给每条记忆记访问次数/时间戳,常被用到的就"巩固"(加权),长期没用的就衰减、甚至淘汰。再配合去重、层次化摘要、按重要度截断,记忆库才能保持精简又新鲜。

业内行话:长期记忆的常见风险不是容量本身,而是陈旧、重复或无关记忆污染当前回答。面试讨论长期记忆时,应结合场景说明去重、衰减、权限、生命周期和重要度等管理策略。长期记忆检索可以借鉴 RAG,但还要处理用户隔离、一致性、更新与删除等问题,不能简单等同于一次普通 RAG。

2.4 记忆存哪:三种存储组合使用

记忆的存储要按"时效性 + 检索方式"分层选型,主流是三种组合:

  • Redis / 内存(短期):低延迟、高吞吐,按会话 ID 存最近几轮,配 TTL 超时失效。缺点:不能做语义模糊查。
  • 向量库(语义回忆):每条记忆存成向量,按"意思相近"检索,能翻出久远但相关的事。缺点:算嵌入有开销。
  • NoSQL / 持久 DB(长期):MongoDB 这类,容量大、按用户 ID 长期保留画像和历史决策。缺点:缺语义检索,常要配合向量索引。

业内行话:"Redis 存最近对话 + 向量索引做语义回忆 + 持久数据库存长期画像"是一套候选组合,也可以在同一存储中使用不同索引。应按数据规模、延迟、权限、删除要求和运维成本选型,不要把某三个组件当成固定生产答案。

2.5 框架怎么做:LangChain vs LlamaIndex

不用自己从零造,两大框架都封装了记忆:

  • LangChain:提供多种短期记忆策略:BufferMemory(全留)、BufferWindowMemory(最近 N 轮)、SummaryMemory(摘要)、VectorStoreRetrieverMemory(语义检索历史);统一接口 load_memory_variables / save_context。长期记忆需要自己接外部库。胜在灵活
  • LlamaIndex:把短期 + 长期统一进一个 ChatMemoryBuffer,设 token_limit,超限自动转入长期 Memory Block(Static 画像 / FactExtraction 事实 / Vector 语义),后端可选 Redis/Mongo/Postgres。胜在一体化、开箱即用

业内行话:这两个框架"理念其实一致",区别是封装程度:要灵活、想自己拼,选 LangChain;要长期对话开箱即用,选 LlamaIndex。面试如果被问"你用什么做记忆",答得出"我知道它们底层都是 buffer/window/summary/vector 这几招,框架只是封装"的人,比只会背 API 名字的人成熟得多。


🏆 Part 3 · 验收串题 | ~10% 学到这一步,做几道题验证一下,知道自己学到位没。

关联面试题(5 道,覆盖 5 个角度)

  1. 【多轮挑战的成因】 多轮对话里的上下文遗忘、指代不清、一致性缺失,技术成因是什么?有哪些建模/架构层面的改进?
  2. 【记忆系统架构】 如何设计一个高效可扩展的 Agent 记忆架构(短期/长期/检索机制、关键模块与数据流)?
  3. 【长期记忆存储选型】 长期记忆常用哪些存储形式?向量库 / 结构化知识库 / 外部符号记忆各自优缺点与适用场景?
  4. 【对话状态管理】 多轮系统里怎么设计对话上下文的状态管理(存储策略、动态更新、长度截断)?
  5. 【记忆衰退机制】 为防止过时历史干扰新任务,是否需要记忆衰退机制?可以怎么实现时效性衰减或优先级重估?

学完这一章你应该能干嘛

跟着做(Part 1):

  • 能跑通多轮记忆 demo,讲清"没记忆为什么会断片"
  • 能说清短期记忆和长期记忆的区别

讲深度(Part 2):

  • 能讲清对话历史选择的三种办法及怎么组合
  • 能讲清上下文裁剪的截断 vs 摘要,以及裁剪原则
  • 能讲清长期记忆为什么要带"管理策略"(画像/遗忘/去重)
  • 能说出记忆存储三件套,以及 LangChain / LlamaIndex 的区别

4 项以下 → 回去 Part 1 重做;4–5 项 → Part 2 再看一遍;6 项以上 → 进入下一章。

完整代码 + 数据集

本书每章的完整可运行代码 + 测试样本,都在配套 GitHub 仓库:

🔗 github.com/MisterBooo/rag-from-zero

  • 跟着教程 clone 下来就能跑
  • 本章的多轮记忆 demo(零依赖)也在里面
  • 欢迎 Star ⭐ / Issue 反馈

导航

← 上一章:Query 理解与改写 | 下一章:上下文问答与引用溯源 → | 回到项目首页