Python & AI Tutorials Logo
LangChain & LangGraph

11. 对话式 RAG:为检索添加记忆

在第 10 章中,我们提升了 RAG 系统的检索质量。但仍然存在一个局限:每个问题都是独立处理的。当用户问"你们的退款政策是什么?"时,我们的系统会从文档中找到相关内容并作出回答。当下一个问题到来时,系统会在没有任何先前对话记忆的情况下作答。

让我们看看为什么这在真实对话中会成为问题。用户问"你们的退款政策是什么?",接着追问"那也适用于数字产品吗?"这个追问假定了上一轮"退款政策"的上下文,但问题文本本身并不包含这样的信息。如果我们直接用"那也适用于数字产品吗?"作为搜索查询,检索器会拉取与"数字产品"相关的无关信息(例如定价或规格),RAG 系统则会生成与用户意图不符的答案。

在本章中,我们将学习如何解决这个问题。我们将学习把含糊的追问重写完整问题,运用这一技巧构建一个对话式 RAG(conversational RAG)系统,并介绍如何在对话变长时管理对话历史

11.1) 将追问重写为完整问题

正如我们在引言中看到的,追问建立在先前对话的上下文之上,因此人们往往会省略大量信息。结果就是,追问本身往往是不完整的。我们该如何解决这个问题?

在第 8 章中,我们学习了如何通过将对话历史与每条消息一起传递来帮助 LLM 理解对话上下文。我们可以在这里应用同样的方法。我们将追问连同对话历史一起传递给 LLM,并要求它将其重写为一个反映上下文的完整问题。例如,追问"那也适用于数字产品吗?"连同对话历史一起被重写为"数字产品符合退款条件吗?"有了这个重写后的问题,搜索就能找到关于数字产品退款政策的正确文档。

这一技巧被称为查询重写(query rewriting)。让我们为它创建一个系统提示。

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
 
llm = ChatOpenAI(model="gpt-5-mini")
 
system_prompt = (
    "Given a chat history and the latest user question "
    "which might reference context in the chat history, "
    "formulate a standalone question "
    "which can be understood without the chat history. "
    "Do NOT answer the question, just reformulate it if needed "
    "and otherwise return it as is."
)

这个系统提示中的核心指令是"使用聊天历史将追问重写为完整问题"。其中有两个具体的指令很重要。

首先,"Do NOT answer the question, just reformulate it.(不要回答问题,只需重新表述它。)" 这告诉 LLM 只重写问题,而不是回答它。如果没有这个指令,LLM 往往会去回答问题而不是重写它。我们在这里想要的不是答案,而是一个无需聊天历史也能理解的完整问题。

其次,"otherwise return it as is.(否则原样返回它。)" 这告诉 LLM 在问题不需要重写时保持其原样。如果没有这一点,LLM 可能会不必要地重新措辞,从而可能改变问题的原始含义或范围。

现在让我们使用这个系统提示来实际重写一个追问。

python
messages = [
    SystemMessage(content=system_prompt),
    # 聊天历史
    HumanMessage(content="What is your refund policy?"),
    AIMessage(content="All physical products may be returned within 30 days of purchase for a full refund."),
    # 追问
    HumanMessage(content="Does that apply to digital products too?"),
]
 
response = llm.invoke(messages)
print(response.content)

输出:

数字产品符合退款条件吗?

LLM 阅读了对话历史,识别出这个问题是关于"退款政策"的,并将其重写为一个完整问题。用这个重写后的问题进行搜索,将返回符合用户意图的文档。

在下一节中,我们会将这个重写步骤整合到 RAG 流程中,使重写、检索和答案生成都在一次调用中完成。

11.2) 构建对话式 RAG

在上一节中,我们学习了如何通过将对话历史传递给 LLM,把追问重写为完整问题。现在我们将把这个重写步骤整合到 RAG 流程中,构建一个对话式 RAG,使重写 → 检索 → 答案生成都在一次调用中完成。

LangChain 提供了用于构建对话式 RAG 的链工具(create_history_aware_retrievercreate_retrieval_chain 等),但这些函数位于 langchain-classic 包中,该包将于 2026 年 12 月停止支持。LangChain 官方文档现在推荐改用代理。

因此,我们将在本章中使用代理来实现对话式 RAG。代理会在第五部分(第 15–17 章)中详细介绍,所以这里我们只介绍对话式 RAG 实现所需的内容。

11.2.1) 我们这里将用到的代理组件

在第 5 章中,我们简要了解了代理的核心概念。当 LLM 分析用户的请求并决定使用哪个工具时,系统会执行这个决定。当时我们是手动实现这个过程的,但 LangChain 提供了使其变得简单得多的 API。下面简要介绍我们将用到的三个组件。

@tool:一个装饰器,将普通的 Python 函数转换为代理可以使用的工具。代理会根据用户的请求,从其注册的工具中自主选择并调用合适的工具。

create_agent:一个函数,它接受一个 LLM、一个工具列表和一个系统提示来创建代理。它在内部处理代理的决策-执行流程。

InMemorySaver:一个自动管理对话历史的检查点保存器(checkpointer)。它按 thread_id 组织对话,因此当用相同的 thread_id 调用代理时,它会自动加载先前的对话历史。

11.2.2) 创建检索工具

首先,让我们把第 10 章中构建的向量存储搜索转换为代理可以使用的工具。

python
from langchain.tools import tool
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# 连接到第 10 章中构建的向量存储
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
@tool
def retrieve_context(query: str):
    """在文档中搜索与查询相关的内容。"""
    retrieved_docs = vector_store.similarity_search(query, k=3)
    serialized = "\n\n".join(
        f"Source: {doc.metadata['source']}\nContent: {doc.page_content}"
        for doc in retrieved_docs
    )
    return serialized

@tool 装饰器将 retrieve_context 函数转换为代理可以使用的工具。代理会根据用户的问题自主决定是否调用这个工具。

11.2.3) 创建代理

我们将检索工具、系统提示和检查点保存器传递给 create_agent 来创建代理。

python
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver  # 随 langchain 自动安装
 
agent = create_agent(
    model="gpt-5-mini",
    tools=[retrieve_context],
    system_prompt=(
        "You are a helpful assistant that answers questions about company policies. "
        "Use the retrieve_context tool to search for relevant information. "
        "If the retrieved context does not contain relevant information, "
        "say that you don't know. "
        "Keep the answer concise, three sentences maximum."
    ),
    checkpointer=InMemorySaver(),
)
  • model:代理将使用的 LLM。
  • tools:代理可用的工具列表。我们注册了上面创建的文档检索工具(retrieve_context)。
  • system_prompt:代理的行为指令。它告诉代理使用检索工具来回答有关公司政策的问题,并在检索到的上下文缺乏相关信息时回答"不知道"。
  • checkpointer:自动管理对话历史。InMemorySaver() 将对话存储在内存中,自动处理我们在第 8 章中手动管理的对话历史。

用户问题

代理

对话历史
(InMemorySaver)

工具调用?

retrieve_context
工具执行

最终答案

当代理收到用户问题时,它会查阅对话历史并决定是否需要在向量存储中进行文档搜索。如果需要,它会调用 retrieve_context 工具来获取相关文档,并通过 LLM 生成答案。对话历史由 InMemorySaver 自动管理。

11.2.4) 运行多轮对话

让我们运行一个实际的两轮对话,以验证它能否正确处理追问。

python
# thread_id 是区分对话的标识符
# 使用相同的 thread_id 会延续同一段对话
thread_config = {"configurable": {"thread_id": "1"}}
 
# --- 第 1 轮:一个完整的问题 ---
response1 = agent.invoke(
    {"messages": [{"role": "user", "content": "What is your refund policy?"}]},
    thread_config,
)
print("Q: What is your refund policy?")
print("A:", response1["messages"][-1].content)
 
# --- 第 2 轮:一个依赖于第 1 轮的追问 ---
response2 = agent.invoke(
    {"messages": [{"role": "user", "content": "Does that apply to digital products too?"}]},
    thread_config,
)
print("\nQ: Does that apply to digital products too?")
print("A:", response2["messages"][-1].content)

输出:

Q: 你们的退款政策是什么?
A: 所有实体产品均可在购买后 30 天内退货并获得全额退款。
需要提供原始收据或订单确认邮件,且商品必须处于
原始包装且未使用的状态。
30 天之后,退货仅接受兑换为商店积分。
 
Q: 那也适用于数字产品吗?
A: 数字产品(软件许可证、电子书、在线课程)一旦
下载或访问链接被激活,即不可退款。
但是,如果您遇到妨碍访问的技术问题,可以在 7 天内
联系支持以获取替换或退款。

在第二轮中,我们传递了"那也适用于数字产品吗?",但代理从对话历史中识别出这是关于退款政策的追问,并准确地从退款政策文档中检索出数字产品部分。

等等 —— 对于这个代理,我们并没有添加像 11.1 节那样的查询重写步骤。那么,追问是如何被正确处理的呢?当 LLM 调用工具(带有 @tool 装饰器的函数)时,它会自行生成工具的参数。传递给 retrieve_context 的用户查询也是由 LLM 生成的 —— 由于 LLM 能看到完整的对话历史,它在调用之前将追问改写成了一个完整、独立的问题。我们没有设置专门的重写步骤,但查询重写实际上在工具调用过程中发生了。

另外请注意,我们无需手动管理对话历史 —— InMemorySaver 会按 thread_id 自动管理对话历史。

下一节将介绍随着对话变长、历史变大而出现的问题,以及如何解决它。

11.3) 管理更长的对话

我们构建的对话式 RAG 一开始运行得很好,但随着对话变长,问题可能会出现。正如我们在第 8 章中学到的,LLM 在单次调用中能处理的输入大小有一个上限。系统提示、对话历史、检索到的文档和用户问题都需要装在这个限制之内。

随着对话变长,对话历史占用的 token 越来越多,最终超出最大输入大小并导致 API 调用失败。由于是按 token 计费,每次调用的成本也会增加。这意味着我们需要管理对话历史的大小。

在第 8 章中,我们用滑动窗口(sliding window)解决了这个问题:只保留最近的 N 条消息并丢弃较旧的消息。同样的概念也适用于代理环境。create_agent 支持中间件(middleware),这是一种可以在调用 LLM 之前修改消息的处理步骤。我们可以使用中间件来修剪旧历史。

11.3.1) 用中间件限制历史

@before_model 装饰器的工作方式与我们在 11.2 节中看到的 @tool 装饰器类似。正如 @tool 将函数转换为代理可以使用的工具一样,@before_model 将函数转换为在每次 LLM 调用之前运行的中间件。转换后的中间件通过在 create_agentmiddleware 参数中注册它来激活。

python
from langchain.agents import create_agent, AgentState
from langchain.agents.middleware import before_model
from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
 
@before_model
def trim_old_messages(state: AgentState, runtime) -> dict | None:
    """在每次 LLM 调用之前移除旧消息。"""
    messages = state["messages"]
    # 如果消息足够少,则不做任何处理
    if len(messages) <= 10:
        return None
    # 仅保留系统消息(第一条)和最近的 10 条消息
    return {
        "messages": [
            RemoveMessage(id=REMOVE_ALL_MESSAGES),
            messages[0],     # 系统消息
            *messages[-10:], # 最近的 10 条消息(5 轮)
        ]
    }

AgentState 是一个保存代理状态数据的对象,其中 state["messages"] 包含到目前为止的对话消息列表。中间件的返回值决定了如何修改这个对话列表。

  • 返回 None 会让现有的代理状态数据保持不变。
  • 返回字典会将其内容应用到现有的消息列表。在上面的代码中,RemoveMessage(id=REMOVE_ALL_MESSAGES) 首先删除所有现有消息,然后只重新添加系统消息和最近的 10 条消息。结果是,只有这些消息会被传递给 LLM。

将这个中间件注册到代理:

python
agent = create_agent(
    model="gpt-5-mini",
    tools=[retrieve_context],
    system_prompt=(
        "You are a helpful assistant that answers questions about company policies. "
        "Use the retrieve_context tool to search for relevant information. "
        "If the retrieved context does not contain relevant information, "
        "say that you don't know. "
        "Keep the answer concise, three sentences maximum."
    ),
    checkpointer=InMemorySaver(),
    middleware=[trim_old_messages],  # 注册中间件
)

这与 11.2 节中的代理相同,只是添加了 middleware=[trim_old_messages]。现在,无论对话变得多长,只有最近的消息会被传递给 LLM。

11.3.2) 滑动窗口的权衡

当旧消息被修剪后,代理就无法再引用它们的内容了。如果用户提起他们十轮之前问过的东西,代理将无从得知那个上下文。这是滑动窗口方法的一个根本性局限。

当需要保留较旧的对话内容时,一个替代方案是用 LLM 生成的摘要(summary)来替换旧消息,而不是删除它们。LangChain 为此提供了 SummarizationMiddleware,我们将在第五部分(第 15 章及以后)深入探讨代理和图架构时介绍它。