Day 31:带记忆的聊天机器人 —— 结合 RAG 与 Memory

欢迎来到第三十一天!今天我们终于将记忆与知识库结合起来,构建一个真正实用的带记忆的聊天机器人。这个机器人不仅能记住用户的名字、喜好等个性化信息,还能基于本地文档(RAG)回答问题,并且在多轮对话中保持上下文连贯。这个项目将综合运用前几天的知识:RAG 管道、向量存储、对话记忆管理,以及 LangChain 的链式组合。


在这里插入图片描述

一、今日学习目标

  1. 学会将 RAG 检索器和对话记忆组件整合到同一个聊天链中。
  2. 掌握如何在多轮对话中同时维护用户个性化信息和知识库检索。
  3. 理解 LangChain 中 RunnableWithMessageHistory 或手动管理消息历史的方法。
  4. 通过实战项目“书童机器人”,体验带记忆的 RAG 聊天机器人的完整实现。
  5. 能够根据需求选择合适的记忆策略和 RAG 参数。

二、详细实现步骤

步骤 1:设计“书童机器人”的功能

我们的书童机器人应具备以下能力:

  • 记住用户的名字、喜好(例如喜欢读什么类型的书、喜欢的作家等)。
  • 基于本地知识库(例如几篇关于文学、历史的文章)回答问题。
  • 多轮对话中保持上下文,例如用户问“我最喜欢的作家是谁?”,机器人能根据之前的记忆回答。

我们将使用:

  • 短期记忆:用 ConversationBufferMemory 或 ConversationSummaryMemory 保存对话历史(包括用户个性化信息)。
  • RAG:使用 Chroma 向量库存储知识文档,检索相关片段。
  • LangChain 的 LCEL:组合检索器、提示词模板、模型和记忆。
步骤 2:环境准备

确保已安装所有必要库:

pip install langchain langchain-openai langchain-community chromadb

新建 memory_rag_chatbot.py,导入所需模块:

import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain.memory import ConversationBufferMemory
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.messages import HumanMessage, AIMessage

加载环境变量并初始化模型和 Embedding:

load_dotenv()

llm = ChatOpenAI(
    model="deepseek-chat",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",
    temperature=0.3
)

embeddings = OpenAIEmbeddings(
    model="embedding-2",
    api_key=os.getenv("ZHIPU_API_KEY"),
    base_url="https://open.bigmodel.cn/api/paas/v4"
)
步骤 3:准备知识库

我们准备几个文本文件,内容可以是关于文学、历史的知识。为了简单,我们直接在代码中定义几个段落,或者从 docs/ 目录加载(假设之前已创建)。这里我们直接创建一些示例文档,存入 Chroma。

# 定义知识库文档
documents = [
    "《红楼梦》是中国古典四大名著之一,作者曹雪芹。小说以贾宝玉和林黛玉的爱情悲剧为主线,展现了封建社会的衰落。",
    "《百年孤独》是哥伦比亚作家加西亚·马尔克斯的代表作,属于魔幻现实主义文学流派。",
    "《三体》是刘慈欣创作的科幻小说,讲述了地球文明与三体文明的信息交流、生死搏杀及两个文明在宇宙中的兴衰历程。",
    "《活着》是余华的代表作,讲述了一个人历经世间沧桑和苦难的一生。",
    "《小王子》是法国作家安托万·德·圣埃克苏佩里的著名童话,讲述了一个来自外星球的小王子的旅行故事。"
]

# 创建向量存储(使用 Chroma)
vectorstore = Chroma.from_texts(
    texts=documents,
    embedding=embeddings,
    collection_name="book_knowledge"
)
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})  # 检索前2个相关片段
步骤 4:定义提示词模板

我们需要一个包含对话历史和检索上下文的提示词。对话历史通过 MessagesPlaceholder 注入,检索上下文通过 context 变量注入。

prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个博学的书童,名叫小书。你能记住用户的姓名和喜好,并且可以根据知识库回答关于书籍的问题。\n"
                "回答问题时,请优先使用提供的上下文;如果上下文没有相关信息,可以结合你自己的知识。"
                "\n\n知识库检索结果:\n{context}"),
    MessagesPlaceholder(variable_name="history"),
    ("human", "{input}")
])
步骤 5:组合链并加入记忆

我们使用 LCEL 构建链,并手动管理记忆(因为 ConversationChain 不适合直接与 RAG 组合)。思路:

  1. 每次调用时,从记忆中加载历史消息。
  2. 将用户输入与历史消息合并,执行检索(仅基于当前输入,或基于整个对话进行检索,这里简化只用当前输入检索)。
  3. 将检索结果、历史消息和用户输入填入提示词。
  4. 调用模型生成回答。
  5. 将用户输入和模型回答存入记忆。

我们编写一个自定义函数 chat() 来完成这些步骤。

# 创建记忆对象
memory = ConversationBufferMemory(return_messages=True)

def chat(user_input: str) -> str:
    # 1. 检索相关文档(只根据当前输入)
    docs = retriever.get_relevant_documents(user_input)
    context = "\n\n".join(doc.page_content for doc in docs)

    # 2. 获取历史消息
    history = memory.chat_memory.messages

    # 3. 构造消息列表
    messages = [
        ("system", prompt.messages[0].prompt.template.format(context=context)),
        *[(msg.type, msg.content) for msg in history],
        ("human", user_input)
    ]

    # 4. 调用模型(直接使用 llm.invoke,传入消息列表)
    response = llm.invoke(messages)
    answer = response.content

    # 5. 更新记忆
    memory.chat_memory.add_user_message(user_input)
    memory.chat_memory.add_ai_message(answer)

    return answer

注意:这里我们手动处理了提示词格式化,因为 LCEL 与记忆集成的标准方式需要更复杂的设置(如使用 RunnableWithMessageHistory)。为了清晰展示原理,我们采用手动方式,这也有助于你理解底层流程。

步骤 6:测试多轮对话

现在测试书童机器人:

print("===== 书童机器人对话测试 =====")
print("用户:我叫小明,我喜欢科幻小说。")
print("小书:", chat("我叫小明,我喜欢科幻小说。"))

print("\n用户:你记得我叫什么吗?")
print("小书:", chat("你记得我叫什么吗?"))

print("\n用户:推荐一本我可能喜欢的书。")
print("小书:", chat("推荐一本我可能喜欢的书。"))

print("\n用户:《三体》的作者是谁?")
print("小书:", chat("《三体》的作者是谁?"))

观察输出,机器人应该能够:

  • 记住用户的名字和喜好。
  • 在推荐书籍时,结合用户喜好(科幻)推荐《三体》。
  • 回答关于《三体》作者的问题(从知识库检索或模型自身知识)。
步骤 7:优化与扩展

如果知识库文档较多,可以将文档从文件中加载(参考 Day 18 的 TextLoader 和 DirectoryLoader)。还可以改进检索策略,例如结合对话历史进行查询扩展(比如提取用户喜好用于检索)。但今天先保持简单。

同时,我们可以使用 ConversationSummaryMemory 替代 ConversationBufferMemory 来节省 Token,但为了保留精确的用户信息(如名字、喜好),Buffer 更合适,因为摘要可能丢失细节。


三、常见问题与调试

Q1:历史消息中包含系统消息吗?
→ 我们的 memory.chat_memory.messages 只存储 HumanMessage 和 AIMessage,不包含系统消息。系统消息在每次调用时通过提示词模板动态生成,这是合理的。

Q2:为什么使用 llm.invoke(messages) 而不是构建完整的 LCEL 链?
→ 因为记忆的加入使得流程稍微复杂,手动调用更直观,也更容易理解。当项目变复杂时,可以使用 LangChain 的 RunnableWithMessageHistory 来标准化处理,但今天手动方式已经足够。

Q3:检索只根据当前输入,如果用户问“我最喜欢的作家是谁”,而之前说过“我喜欢曹雪芹”,检索不到相关信息怎么办?
→ 这个问题需要将用户个性化信息(记忆)也用于检索,或者直接从记忆中提取答案。我们的书童机器人可以做到:它从历史消息中知道用户喜欢曹雪芹(因为之前聊过),所以即使检索不到,模型也能根据历史回答。这就是为什么我们保留了历史消息——它让模型拥有上下文。

Q4:知识库中的文档没有覆盖用户的问题,模型会怎么回答?
→ 根据提示词设计,如果上下文没有相关信息,模型可以结合自身知识回答。如果模型也不确定,可能会说不知道(取决于提示词约束,我们可以在系统提示中强调“如果无法从上下文或自身知识确定,请说不知道”)。

Q5:如何将记忆持久化到数据库,以便重启后仍然保留?
→ 可以使用 RedisChatMessageHistory 或 SQLChatMessageHistory,将消息保存到外部存储。在创建记忆时指定 chat_memory 参数即可。例如:

from langchain.memory.chat_message_histories import SQLChatMessageHistory
history = SQLChatMessageHistory(session_id="user_123", connection_string="sqlite:///memory.db")
memory = ConversationBufferMemory(chat_memory=history, return_messages=True)

这样每个用户有独立的 session,实现多用户隔离。

Q6:为什么记忆中没有自动保存用户个性化信息?
→ 我们的记忆保存的是完整的对话消息,模型可以通过历史消息了解用户信息。如果需要显式提取并存储用户画像,可以使用更高级的记忆类型(如 ConversationEntityMemory),它会自动提取实体(如名字、喜好)并存储。但今天不需要那么复杂。


四、今日总结与作业

今天你完成了:

  • ✅ 将 RAG 检索与对话记忆结合,构建了一个带记忆的聊天机器人。
  • ✅ 手动管理了消息历史,理解了多轮对话中记忆与检索的协作方式。
  • ✅ 通过“书童机器人”实战,体验了个性化与知识库问答的融合。
  • ✅ 学会了根据场景选择合适的记忆策略。

今日作业(必做):

  1. 为书童机器人添加更多知识库文档(至少 10 条关于不同类型的书籍),并测试它能否根据用户喜好(如历史小说、推理小说)推荐合适的书。
  2. 将记忆改为 ConversationSummaryMemory,运行同样的对话,对比 Token 消耗和信息保留情况(比如用户名字是否还能记住)。
  3. 实现一个简单的用户偏好提取功能:在对话中,当用户说“我喜欢 XX”时,自动将这条信息存储到一个独立的变量中(例如 user_profile 字典),并在后续对话中优先使用该信息进行检索(比如用偏好关键词检索)。描述你的实现思路,可选写代码。
  4. (思考题)在构建客服 Agent 时,记忆不仅包括对话历史,还可能包括用户订单信息、历史工单等结构化数据。你如何将结构化数据与对话记忆结合?请设计一个方案。

明日预告: 我们将学习如何处理超长对话历史,实现一个裁剪函数,当对话历史超过阈值时自动总结或丢弃最早的消息,确保 Agent 永远不会因 Token 超限而崩溃。

有任何问题欢迎随时提问!

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐