【三个月 AI Agent 实战学习】Day 31:带记忆的聊天机器人 —— 结合 RAG 与 Memory
Day 31:带记忆的聊天机器人 —— 结合 RAG 与 Memory
欢迎来到第三十一天!今天我们终于将记忆与知识库结合起来,构建一个真正实用的带记忆的聊天机器人。这个机器人不仅能记住用户的名字、喜好等个性化信息,还能基于本地文档(RAG)回答问题,并且在多轮对话中保持上下文连贯。这个项目将综合运用前几天的知识:RAG 管道、向量存储、对话记忆管理,以及 LangChain 的链式组合。
一、今日学习目标
- 学会将 RAG 检索器和对话记忆组件整合到同一个聊天链中。
- 掌握如何在多轮对话中同时维护用户个性化信息和知识库检索。
- 理解 LangChain 中
RunnableWithMessageHistory或手动管理消息历史的方法。 - 通过实战项目“书童机器人”,体验带记忆的 RAG 聊天机器人的完整实现。
- 能够根据需求选择合适的记忆策略和 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 组合)。思路:
- 每次调用时,从记忆中加载历史消息。
- 将用户输入与历史消息合并,执行检索(仅基于当前输入,或基于整个对话进行检索,这里简化只用当前输入检索)。
- 将检索结果、历史消息和用户输入填入提示词。
- 调用模型生成回答。
- 将用户输入和模型回答存入记忆。
我们编写一个自定义函数 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 检索与对话记忆结合,构建了一个带记忆的聊天机器人。
- ✅ 手动管理了消息历史,理解了多轮对话中记忆与检索的协作方式。
- ✅ 通过“书童机器人”实战,体验了个性化与知识库问答的融合。
- ✅ 学会了根据场景选择合适的记忆策略。
今日作业(必做):
- 为书童机器人添加更多知识库文档(至少 10 条关于不同类型的书籍),并测试它能否根据用户喜好(如历史小说、推理小说)推荐合适的书。
- 将记忆改为
ConversationSummaryMemory,运行同样的对话,对比 Token 消耗和信息保留情况(比如用户名字是否还能记住)。 - 实现一个简单的用户偏好提取功能:在对话中,当用户说“我喜欢 XX”时,自动将这条信息存储到一个独立的变量中(例如
user_profile字典),并在后续对话中优先使用该信息进行检索(比如用偏好关键词检索)。描述你的实现思路,可选写代码。 - (思考题)在构建客服 Agent 时,记忆不仅包括对话历史,还可能包括用户订单信息、历史工单等结构化数据。你如何将结构化数据与对话记忆结合?请设计一个方案。
明日预告: 我们将学习如何处理超长对话历史,实现一个裁剪函数,当对话历史超过阈值时自动总结或丢弃最早的消息,确保 Agent 永远不会因 Token 超限而崩溃。
有任何问题欢迎随时提问!
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐




所有评论(0)