一次对大型语言模型(LLM)的调用可能已经非常强大,但如果将您的逻辑置于一个循环中,目标是完成更复杂的任务,那么您可以将检索增强生成(RAG)开发提升到一个全新的水平。这就是代理(agents)的概念所在。过去一年,LangChain的开发工作集中在改进对代理工作流的支持,新增了能够更精确地控制代理行为和功能的功能。这一进展的一部分体现在LangGraph的出现,这是LangChain的另一个相对较新的部分。代理和LangGraph结合起来,作为提升RAG应用的强大方法,效果显著。
在本章中,我们将重点深入理解可以在RAG中使用的代理元素,并将它们与您的RAG工作结合起来,涵盖以下主题:
- AI代理和RAG集成的基础
- 图形、AI代理和LangGraph
- 将LangGraph检索代理添加到您的RAG应用中
- 工具和工具包
- 代理状态
- 图论的核心概念
通过本章的学习,您将牢牢掌握AI代理和LangGraph如何增强您的RAG应用。在接下来的部分,我们将深入探讨AI代理和RAG集成的基础,为后续的概念讲解和代码实验做好准备。
技术要求
本章的代码位于以下GitHub仓库:github.com/PacktPublis…
AI代理和RAG集成的基础
在与新开发者讨论生成式AI时,我们发现AI代理的概念通常是最难理解的概念之一。当专家谈论代理时,他们往往使用非常抽象的术语,重点讨论AI代理在RAG应用中可能负责的各种任务,但却没有深入解释AI代理究竟是什么,以及它是如何工作的。
我发现,最简单的方法是通过解释AI代理的真正含义来打破这种神秘感,其实它是一个非常简单的概念。要构建一个AI代理,在最基本的形式上,您只是将您在这些章节中已经使用的LLM概念加上一个循环,当任务完成时循环终止。就这么简单!它只是一个循环而已!
图12.1展示了您即将进入的代码实验中将要使用的RAG代理循环:
这代表了一组相对简单的逻辑步骤,代理会循环执行,直到它决定已成功完成您赋予的任务。椭圆框(如代理和检索)被称为节点,连接线被称为边。虚线也是边,但它们是特殊类型的边,称为条件边,这些边也是决策点。
尽管概念简单,但在您的LLM调用中添加一个循环确实使它比直接使用LLM更强大,因为它更充分地利用了LLM推理和将任务分解为更简单任务的能力。这提高了您追求的任务成功的几率,并且在更复杂的多步骤RAG任务中尤其有用。
当您的LLM循环处理代理任务时,您还会为代理提供一些称为工具的功能,LLM会利用其推理能力来决定使用哪个工具,如何使用该工具,以及为其提供哪些数据。在这一点上,事情可以变得非常复杂,您可能会有多个代理、众多工具、集成的知识图谱来帮助引导代理走向特定路径、不同种类的代理框架、不同的代理架构方法等等。但在本章中,我们将专注于AI代理如何帮助提升RAG应用。一旦您看到使用AI代理的强大功能,我毫不怀疑您会希望将它用于其他生成式AI应用,您应该这么做!
生活在AI代理的世界中
在所有关于代理的兴奋中,您可能会认为LLM已经开始过时了。但事实远非如此。通过AI代理,您实际上是在利用LLM的一个更强大的版本,一个LLM作为代理的“大脑”的版本,让它能够推理并提出多步骤的解决方案,远超大多数人用于一次性聊天问题的用途。代理只是在用户和LLM之间提供了一层,使LLM能够完成一个可能需要多次查询LLM的任务,但最终从理论上讲会得到更好的结果。
如果您仔细思考,这与现实世界中问题的解决方式更为契合,即便是简单的决策也可能很复杂。我们大多数任务都是基于一长串的观察、推理和对新经验的调整。我们在现实世界中与人、任务和事物的互动,往往不是与LLM在线互动的那种简单方式。通常情况下,会有一个逐步建立理解、知识和背景的过程,这有助于我们找到最佳解决方案。AI代理能够更好地处理这种问题解决方式。
代理可以对您的RAG工作产生巨大影响,那么LLM作为它们的大脑这一概念又是怎样的呢?我们接下来将进一步探讨这个概念。
LLM作为代理的大脑
如果您将LLM视为AI代理的大脑,那么下一个合乎逻辑的步骤就是,您可能希望找到最智能的LLM作为这个“大脑”。LLM的能力将影响您的AI代理推理和决策的能力,这无疑会影响RAG应用查询的结果。
然而,这种将LLM比作大脑的比喻有一个主要的突破,但这是一个非常好的突破。与现实世界中的代理不同,AI代理总是可以将它们的LLM“大脑”替换成另一个LLM“大脑”。我们甚至可以为它提供多个LLM“大脑”,让它们互相检查,确保事情按计划进行。这为我们提供了更大的灵活性,有助于我们不断改进代理的能力。
那么,LangGraph,或一般的图形,如何与AI代理相关呢?我们接下来将讨论这个问题。
图形、AI代理和LangGraph
LangChain在2024年引入了LangGraph,因此它仍然是相对较新的概念。LangGraph是一个基于LangChain表达式语言(LCEL)构建的扩展,用于创建可组合和可定制的代理工作负载。LangGraph深度依赖于图论的概念,例如节点和边(如前所述),但其重点在于使用这些概念来管理您的AI代理。虽然旧的代理管理方式——AgentExecutor类仍然存在,但LangGraph现在是构建LangChain代理的推荐方式。
LangGraph为支持代理添加了两个重要组件:
- 轻松定义循环(循环图)的能力
- 内置记忆功能
它提供了一个与AgentExecutor等效的预构建对象,允许开发者使用基于图形的方法来协调代理。
在过去几年中,许多论文、概念和方法相继出现,用于将代理构建到RAG应用中,例如编排代理(orchestration agents)、ReAct代理、自我改进代理(self-refine agents)和多代理框架。一个共同的主题是使用循环图来表示代理的控制流。尽管从实现角度看,许多这些方法已经过时,但它们的概念仍然非常有用,并且在LangGraph的基于图形的环境中得到了应用。
LangGraph已成为支持代理以及管理它们在RAG应用中流程和过程的强大工具。它使开发者能够将单一和多代理的流程描述和表示为图形,从而提供极其可控的流程。这种可控性对于避免开发者在早期创建代理时遇到的陷阱至关重要。
举个例子,流行的ReAct方法是早期构建代理的一个范式。ReAct代表推理(reason)+行动(act)。在这种模式中,LLM首先思考应该做什么,然后决定采取什么行动。然后在环境中执行该行动并返回观察结果。根据这些观察结果,LLM会重复这个过程。它利用推理来思考接下来应该做什么,决定另一个行动,并持续下去,直到确定目标已经达成。如果您将这个过程映射出来,它可能看起来像图12.2所示的样子:
图12.2中的一系列循环可以通过LangGraph中的循环图来表示,每个步骤由节点和边表示。通过这种图形化范式,您可以看到像LangGraph这样的工具,作为LangChain中构建图形的工具,如何成为您代理框架的核心部分。当我们构建代理框架时,可以使用LangGraph来表示这些代理循环,这有助于您描述和编排控制流。专注于控制流对解决代理早期的挑战至关重要,因为缺乏控制可能导致代理无法完成其循环或专注于错误的任务。
LangGraph内置的另一个关键元素是持久性。持久性可以用来维持代理的记忆,赋予它反思其所有行动所需的信息,并表示图12.2中的OBSERVE组件。这对于同时进行多个对话或记住之前的迭代和行动非常有帮助。持久性还使得人类介入(human-in-the-loop)功能成为可能,在代理执行行动的关键时刻给予您更好的控制。
介绍ReAct代理构建方法的论文可以在这里找到:ReAct 论文。
让我们深入到构建代理的代码实验中,并在编码过程中逐步解析每个关键概念。
代码实验12.1 – 将LangGraph代理添加到RAG中
在本代码实验中,我们将向现有的RAG管道中添加一个代理,能够决定是从索引中检索数据还是使用网络搜索。我们将展示代理在处理检索到的数据时的内在思考过程,目标是为您提供更全面的回答。当我们为代理添加代码时,我们将看到新的组件,如工具、工具包、图形、节点、边,当然还有代理本身。对于每个组件,我们将深入探讨它如何与RAG应用程序互动并支持应用程序。我们还将添加代码,使其更像一个聊天会话,而不仅仅是一个问答会话:
首先,我们将安装一些新包以支持代理开发:
%pip install tiktoken
%pip install langgraph
在第一行中,我们安装了tiktoken
包,这是一个用于在将文本数据输入语言模型之前进行分词的OpenAI包。接着,我们引入了之前讨论的langgraph
包。
接下来,我们添加一个新的LLM定义并更新现有的定义:
llm = ChatOpenAI(model_name="gpt-4o-mini", temperature=0, streaming=True)
agent_llm = ChatOpenAI(model_name="gpt-4o-mini", temperature=0, streaming=True)
新的agent_llm
LLM实例将作为代理的大脑,处理代理任务的推理和执行,而原始的llm
实例将仍然存在,用于执行我们过去常用的LLM任务。虽然在我们的示例中,这两个LLM使用相同的模型和参数进行定义,但您可以并应该尝试使用不同的LLM来处理这些不同的任务,看看是否有某种组合更适合您的RAG应用。您甚至可以添加额外的LLM来处理特定任务,比如处理improve
或score_documents
函数,如果您发现某个LLM在这些任务上表现得更好,或者您为这些特定操作训练或微调了自己的LLM。例如,简单任务通常由更快速、低成本的LLM来处理,只要它们能够成功完成任务。这个代码提供了很多灵活性,您可以充分利用!另外,注意我们在LLM定义中添加了streaming=True
。这启用了来自LLM的流式数据,适合那些可能进行多个调用(有时是并行的)并与LLM不断交互的代理。
现在,我们将跳到检索器定义(dense_retriever
、sparse_retriever
和 ensemble_retriever
)之后,添加我们的第一个工具。工具在代理中具有非常特定且重要的含义,接下来我们就来讨论这一点。
工具和工具包
在接下来的代码中,我们将添加一个网络搜索工具:
from langchain_community.tools.tavily_search import TavilySearchResults
_ = load_dotenv(dotenv_path='env.txt')
os.environ['TAVILY_API_KEY'] = os.getenv('TAVILY_API_KEY')
!export TAVILY_API_KEY=os.environ['TAVILY_API_KEY']
web_search = TavilySearchResults(max_results=4)
web_search_name = web_search.name
您需要获取另一个API密钥,并将其添加到我们之前用于OpenAI和Together API的env.txt
文件中。和这些API一样,您需要访问该网站,设置API密钥,然后将其复制到env.txt
文件中。Tavily的网站可以在以下URL找到:tavily.com/
我们再次运行加载env.txt
文件数据的代码,然后设置TavilySearchResults
对象,max_results
设为4,这意味着当我们运行搜索时,我们最多只希望返回四个搜索结果。接着,我们将web_search.name
变量赋值给一个名为web_search_name
的变量,这样我们以后可以在需要时告诉代理使用它。您可以直接通过以下代码运行此工具:
web_search.invoke(user_query)
运行此工具代码与user_query
一起使用,将返回如下结果(为简便起见进行了截断):
[{'url': 'http://sustainability.google/',
'content': "Google Maps\nChoose the most fuel-efficient route\nGoogle Shopping\nShop for more efficient appliances for your home\nGoogle Flights\nFind a flight with lower per-traveler carbon emissions\nGoogle Nest...[TRUNCATED HERE]"},
…
'content': "2023 Environmental Report. Google's 2023 Environmental Report outlines how we're driving positive environmental outcomes throughout our business in three key ways: developing products and technology that empower individuals on their journey to a more sustainable life, working together with partners and organizations everywhere to transition to resilient, low-carbon systems, and operating ..."}]
我们截断了部分内容,以减少书中的空间,但如果您在代码中运行它,您将看到四个结果,正如我们要求的那样,它们似乎都与用户查询的话题高度相关。请注意,您在代码中不需要像刚才那样直接运行这个工具。
到目前为止,您已经建立了第一个代理工具!这是一个搜索引擎工具,您的代理可以使用它从互联网上检索更多信息,帮助它实现回答用户问题的目标。
LangChain中的工具概念,以及在构建代理时使用工具的概念,来源于这样一个想法:您希望为代理提供可用的动作,以便它能够执行任务。工具是实现这一目标的机制。您像刚才为网络搜索所做的那样定义工具,然后将其添加到代理可用于执行任务的工具列表中。我们还需要创建另一个对RAG应用至关重要的工具:检索器工具:
from langchain.tools.retriever import create_retriever_tool
retriever_tool = create_retriever_tool(
ensemble_retriever,
"retrieve_google_environmental_question_answers",
"Extensive information about Google environmental efforts from 2023.",
)
retriever_tool_name = retriever_tool.name
请注意,在网络搜索工具中,我们从langchain_community.tools.tavily_search
导入,而在这个工具中,我们使用了langchain.tools.retriever
。这反映了Tavily是一个第三方工具,而我们在这里创建的检索器工具是LangChain核心功能的一部分。在导入create_retriever_tool
函数后,我们使用它为我们的代理创建了retriever_tool
工具。和web_search_name
一样,我们提取了retriever_tool.name
变量,这样在后续需要时可以引用它。您可能会注意到这个工具将使用的实际检索器名称,即ensemble_retriever
,这是我们在第8章的8.3代码实验中创建的!
您还应该注意到,作为代理所使用的工具名称是在第二个字段中定义的,我们将其命名为retrieve_google_environmental_question_answers
。当我们在代码中命名变量时,通常会尽量简短,但对于代理使用的工具,提供更详细的名称有助于代理充分理解可用工具。
现在,我们有了两个工具供代理使用!然而,我们仍然需要最终告诉代理这些工具;因此,我们将它们打包到一个列表中,稍后可以与代理共享:
tools = [web_search, retriever_tool]
在这里,您看到我们之前创建的两个工具,web_search
工具和retriever_tool
工具,已经被添加到tools
列表中。如果我们有其他工具想要提供给代理,我们也可以将它们添加到列表中。在LangChain生态系统中,有数百个可用工具:python.langchain.com/v0.2/docs/i…
您需要确保使用的LLM在推理和使用工具方面是“优秀”的。一般来说,聊天模型通常已经针对调用工具进行了微调,因此会更擅长使用工具。未经过微调的非聊天模型可能无法使用工具,特别是当工具复杂或需要多次调用时。使用编写良好的名称和描述也能在设置代理LLM成功时起到重要作用。
在我们正在构建的代理中,我们已经具备了所需的所有工具,但您还需要了解工具包,这是工具的方便集合。LangChain提供了当前可用工具包的列表:python.langchain.com/v0.2/docs/i…
例如,如果您的数据基础设施使用pandas DataFrame,您可以使用pandas DataFrame工具包,为您的代理提供多种访问这些DataFrame的工具。直接来自LangChain网站的描述,工具包如下所示:python.langchain.com/v0.1/docs/m…
对于许多常见任务,代理需要一组相关的工具。为此,LangChain提供了工具包的概念——一组大约3到5个工具,用于完成特定的目标。例如,GitHub工具包包含一个用于搜索GitHub问题的工具、一个用于读取文件的工具、一个用于评论的工具等。
所以,基本上,如果您专注于代理的常见任务集合,或者LangChain的一个流行集成伙伴(如Salesforce集成),可能会有一个工具包,能够一次性提供您所需的所有工具。
现在我们已经建立了工具,接下来让我们开始构建代理的组件,从代理状态开始。
代理状态
代理状态是您使用LangGraph构建的任何代理的关键组成部分。在LangGraph中,您创建一个AgentState
类,用于建立代理的“状态”并随时间跟踪它。这个状态是代理的本地机制,您可以将其提供给图中的所有部分,并且可以将其存储在持久化层中。
在这里,我们为我们的RAG代理设置了这个状态:
from typing import Annotated, Literal, Sequence, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], add_messages]
这段代码导入了设置AgentState
所需的相关包。例如,BaseMessage
是一个基类,用于表示用户与AI代理之间对话中的消息。它将用于定义对话中消息的结构和属性。接着,它定义了一个图和一个“状态”对象,并将其传递给每个节点。您可以将状态设置为多种类型的对象,用来存储不同类型的数据,但对于我们的RAG代理,我们将状态设置为“消息”的列表。
然后,我们需要导入一轮新的包,以设置代理的其他部分:
from langchain_core.messages import HumanMessage
from langchain_core.pydantic_v1 import BaseModel, Field
from langgraph.prebuilt import tools_condition
在这段代码中,我们首先导入了HumanMessage
。HumanMessage
是表示由人类用户发送的消息的特定类型。当构造代理生成响应的提示时,它将被使用。我们还导入了BaseModel
和Field
。BaseModel
是Pydantic库中的一个类,用于定义数据模型并验证数据。Field
是Pydantic中的一个类,用于定义数据模型中字段的属性和验证规则。最后,我们导入了tools_condition
。tools_condition
是LangGraph库提供的一个预构建函数,用于根据当前对话状态评估代理是否使用特定的工具。
这些导入的类和函数将在代码中广泛使用,用于定义消息结构、验证数据,并根据代理的决策控制对话流程。它们为使用LangGraph库构建语言模型应用程序提供了必要的构建块和实用工具。
接下来,我们定义了我们的主要提示(表示用户的输入),如下所示:
generation_prompt = PromptTemplate.from_template(
"""You are an assistant for question-answering tasks.
Use the following pieces of retrieved context to answer
the question. If you don't know the answer, just say
that you don't know. Provide a thorough description to
fully answer the question, utilizing any relevant
information you find.
Question: {question}
Context: {context}
Answer:"""
)
这是我们过去在代码实验中使用的代码的替代:
prompt = hub.pull("jclemens24/rag-prompt")
我们将名称更改为generation_prompt
,使得这个提示的用途更加明确。
我们的图使用将在代码中进一步展开,但首先,我们需要介绍一些基本的图论概念。
图论的核心概念
为了更好地理解我们在接下来的代码块中如何使用LangGraph,回顾一些图论中的关键概念是非常有帮助的。图是可以用来表示不同对象之间关系的数学结构。对象被称为节点,而它们之间的关系,通常用线条表示,称为边。你已经在图12.1中看到过这些概念,但理解它们如何与任何图形相关,以及它们如何在LangGraph中使用,仍然是非常重要的。
在LangGraph中,还有一些特定类型的边,表示不同类型的关系。例如,我们在图12.1中提到的“条件边”表示当你需要决定下一个应该去哪个节点时的情况;因此,它们代表了决策。当谈到ReAct范式时,这种边也被称为动作边,因为它是在这里进行动作的,涉及到ReAct中的理由+行动方法。图12.3展示了一个由节点和边组成的基本图。
在这个循环图中(见图12.3),你可以看到代表开始、代理、检索工具、生成、观察和结束的节点。关键的边是LLM做出决策的地方,决定使用哪个工具(在这里只有检索工具可用),观察检索的内容是否足够,然后推送到生成阶段。如果决定检索的数据不足,便有一条边将观察结果发送回代理,决定是否要重新尝试。这些决策点就是我们讨论的条件边。
代理中的节点和边
好,现在让我们回顾一下。我们提到过,一个代理RAG图有三个关键组件:我们已经讨论过的状态,更新或追加状态的节点,以及决定下一个访问哪个节点的条件边。现在我们可以逐步通过代码块来看这三个组件是如何相互作用的。
在有了这些背景后,我们将在代码中首先添加条件边,也就是做出决策的地方。在这个例子中,我们将定义一条边,来判断检索的文档是否与问题相关。这是决定是否进入生成阶段或返回尝试的功能:
我们将分步骤讲解这段代码,但请记住,这是一个大的函数,首先是定义部分:
def score_documents(state) -> Literal[ "generate", "improve"]:
这段代码定义了一个名为score_documents
的函数,用于判断检索的文档是否与给定问题相关。该函数接受我们之前讨论过的状态作为参数,状态是一组已收集的消息。这就是如何让状态对这个条件边函数可用的方式。
接下来,我们构建数据模型:
class scoring(BaseModel):
binary_score: str = Field(
description="Relevance score 'yes' or 'no'")
这段代码使用Pydantic的BaseModel
定义了一个数据模型类scoring
。scoring
类只有一个字段binary_score
,它是一个字符串,表示相关性得分,取值为“yes”或“no”。
接着,我们添加LLM来做出这个决策:
llm_with_tool = llm.with_structured_output(scoring)
通过调用llm.with_structured_output(scoring)
,我们将LLM与scoring
数据模型结合起来,以进行结构化输出验证。
正如我们在过去所见,我们需要设置一个PromptTemplate
类来传递给LLM。这里是那个提示:
prompt = PromptTemplate(
template="""You are assessing relevance of a retrieved document to a user question with a binary grade. Here is the retrieved document:
{context}
Here is the user question: {question}
If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.""",
input_variables=["context", "question"],
)
这段代码定义了一个PromptTemplate
,提供了如何根据给定问题对检索的文档应用二元得分的说明。
接下来,我们使用LCEL构建链,将提示与我们刚才设置的llm_with_tool
工具结合起来:
chain = prompt | llm_with_tool
这个链表示了评分文档的管道。我们定义了链,但还没有调用它。
首先,我们想要获取状态。接着,我们把状态中的“消息”传递到函数中,以便使用它,并获取最后一条消息:
messages = state["messages"]
last_message = messages[-1]
question = messages[0].content
docs = last_message.content
这段代码从“状态”参数中提取了必要的信息,并准备好将状态/消息作为上下文传递给代理(LLM)。提取的具体组件包括:
messages
:会话中的消息列表last_message
:会话中的最后一条消息question
:假定为用户问题的第一条消息的内容docs
:假定为检索到的文档的最后一条消息的内容
然后,最终我们调用这个链,使用填充了问题和上下文文档的提示(如果你记得的话,我们称之为“注水”提示)来获得评分结果:
scored_result = chain.invoke({"question": question, "context": docs})
score = scored_result.binary_score
这段代码从scored_result
对象中提取binary_score
变量,并将其赋值给score
变量。llm_with_tool
步骤,即LangChain链中的最后一步,恰当地命名为chain
,将返回一个基于评分函数响应的字符串二元结果:
if score == "yes":
print("---DECISION: DOCS RELEVANT---")
return "generate"
else:
print("---DECISION: DOCS NOT RELEVANT---")
print(score)
return "improve"
这段代码检查score
的值。如果score
值为“yes”,它打印一条消息,表示文档是相关的,并返回“generate”作为score_documents
函数的最终输出,表明下一步是生成响应。如果score
值为“no”或技术上为“yes”以外的任何值,它会打印消息,表示文档不相关,并返回“improve”,表明下一步是改进用户的查询。
总体而言,这个函数充当了工作流中的决策点,决定检索的文档是否与问题相关,并根据相关性评分引导流程,进入生成响应或重写问题的步骤。
有了条件边定义后,我们接下来要定义节点,首先是代理节点:
def agent(state):
print("---CALL AGENT---")
messages = state["messages"]
llm = llm.bind_tools(tools)
response = llm.invoke(messages)
return {"messages": [response]}
这个函数表示我们图中的代理节点,并调用代理模型来根据当前状态生成响应。agent
函数接受当前状态(“state”)作为输入,它包含会话中的消息,打印出调用代理的消息,从状态字典中提取消息,使用之前定义的agent_llm
实例(代表代理大脑),然后通过bind_tools
方法将工具绑定到模型。接着,我们使用消息调用代理的llm
实例,并将结果赋值给response
变量。
接下来的节点improve
负责在代理决定需要改进时,将用户查询转化为一个更好的问题:
def improve(state):
print("---TRANSFORM QUERY---")
messages = state["messages"]
question = messages[0].content
msg = [
HumanMessage(content=f"""\n
Look at the input and try to reason about
the underlying semantic intent / meaning.
\n
Here is the initial question:
\n ------- \n
{question}
\n ------- \n
Formulate an improved question:
"""),
]
response = llm.invoke(msg)
return {"messages": [response]}
该函数与所有与节点和边相关的函数一样,接受当前状态(“state”)作为输入。它返回一个字典,将响应添加到消息列表中。这个函数打印出正在转化查询的消息,从状态字典中提取消息,获取第一条消息的内容(假设为初始问题),并将其赋值给question
变量。然后,使用HumanMessage
类设置一条消息,要求LLM推理问题的潜在语义意图,并形成一个改进后的问题。LLM实例的结果赋值给response
变量。最后,它返回一个字典,将响应添加到消息列表中。
接下来是generate
函数:
def generate(state):
print("---GENERATE---")
messages = state["messages"]
question = messages[0].content
last_message = messages[-1]
question = messages[0].content
docs = last_message.content
rag_chain = generation_prompt | llm | str_output_parser
response = rag_chain.invoke({"context": docs, "question": question})
return {"messages": [response]}
这个函数类似于我们在上一章代码实验中的生成步骤,但简化了,只提供生成的响应。它基于检索的文档和问题生成答案。该函数接受当前状态(“state”)作为输入,包含会话中的消息,打印出生成答案的消息,从状态字典中提取消息,获取第一条消息的内容(假定为问题),并将其赋值给question
变量。
接着,它检索最后一条消息(messages[-1]
),并将其赋值给last_message
变量。docs
变量被赋值为last_message
的内容,假定为检索到的文档。此时,我们通过组合generation_prompt
、llm
和str_output_parser
变量,使用|
操作符创建一个链,称为rag_chain
。像其他LLM提示一样,我们将预定义的generation_prompt
作为生成答案的提示进行注水,最终返回一个包含响应的字典。
接下来,我们希望使用LangGraph设置我们的循环图,并将节点和边按所需的顺序排列:
tool = Tool(
name="SearchDocuments",
func=search_documents,
description="searches documents and retrieves relevant results for a given question",
)
tools = [tool]
这样,我们定义了一个新的工具search_documents
,并将其绑定到一个名称为“SearchDocuments”的节点,您可以看到工具的调用顺序以及工具节点如何工作。
循环图设置
我们代码中的下一个重要步骤是使用 LangGraph 设置我们的图: 首先,我们导入一些重要的包来开始工作:
from langgraph.graph import END, StateGraph
from langgraph.prebuilt import ToolNode
这段代码从 langgraph
库中导入了以下必要的类和函数:
END
:表示工作流结束的特殊节点StateGraph
:用于定义工作流状态图的类ToolNode
:用于定义表示工具或动作的节点的类
接下来,我们将 AgentState
作为参数传递给刚才导入的 StateGraph
类,用来定义工作流的状态图:
workflow = StateGraph(AgentState)
这行代码创建了一个名为 workflow
的新 StateGraph
实例,并为该实例定义了一个新的图。
然后,我们定义我们将要循环使用的节点,并将节点功能分配给它们:
workflow.add_node("agent", agent) # agent
retrieve = ToolNode(tools)
workflow.add_node("retrieve", retrieve)
# 从网页和/或检索工具中进行检索
workflow.add_node("improve", improve)
# 改进问题以便更好地进行检索
workflow.add_node("generate", generate) # 在确认文档相关后生成响应
这段代码使用 add_node
方法向工作流实例添加多个节点:
"agent"
:这个节点代表代理节点,调用agent
函数。"retrieve"
:这个节点代表检索节点,是一个特殊的ToolNode
,包含我们之前定义的tools
列表,其中包含web_search
和retriever_tool
工具。在这段代码中,为了提高可读性,我们显式地实例化了ToolNode
类,并用retrieve
变量定义它,这样更明确地表示了该节点的“检索”功能。然后我们将retrieve
变量传递给add_node
函数。"improve"
:这个节点代表改进问题的节点,调用improve
函数。"generate"
:这个节点代表生成响应的节点,调用generate
函数。
接下来,我们需要定义工作流的起始节点:
workflow.set_entry_point("agent")
这行代码将工作流实例的入口点设置为 "agent"
节点。
接下来,我们调用 "agent"
节点来决定是否需要进行检索:
workflow.add_conditional_edges("agent", tools_condition,
{
"tools": "retrieve",
END: END,
},
)
在这段代码中,tools_condition
用作工作流图中的条件边。它根据代理的决策决定是否进入检索步骤("tools": "retrieve"
)或结束对话(END: END
)。检索步骤代表我们为代理提供的两个工具,而另一个选项则是结束对话,直接结束工作流。
接下来,我们为 "action"
节点调用后添加更多边:
workflow.add_conditional_edges("retrieve", score_documents)
workflow.add_edge("generate", END)
workflow.add_edge("improve", "agent")
在调用 "retrieve"
节点后,使用 workflow.add_conditional_edges("retrieve", score_documents)
添加条件边。它使用 score_documents
函数评估检索到的文档,并根据评分决定下一个节点。这段代码还使用 workflow.add_edge("generate", END)
添加了一条从 "generate"
节点到 END
节点的边,表示在生成响应后,工作流结束。最后,使用 workflow.add_edge("improve", "agent")
添加了一条从 "improve"
节点到 "agent"
节点的边,创建了一个循环,将改进后的问题返回给代理以进行进一步处理。
现在,我们准备编译图:
graph = workflow.compile()
这行代码使用 workflow.compile
编译工作流图,并将编译后的图分配给 graph
变量,graph
现在代表我们最初创建的 StateGraph
实例的编译版本。
我们之前在本章的第 12.1 图中已经展示了这个图的可视化效果,如果你想自己运行可视化,可以使用以下代码:
from IPython.display import Image, display
try:
display(Image(graph.get_graph(
xray=True).draw_mermaid_png()))
except:
pass
我们可以使用 IPython 来生成这个可视化图。
最后,我们将启动代理进行工作:
import pprint
inputs = {
"messages": [
("user", user_query),
]
}
这段代码导入了 pprint
模块,该模块提供了一个漂亮打印功能,用于格式化和打印数据结构,使我们能够看到代理输出的更具可读性的版本。然后我们定义了一个字典 inputs
,表示工作流图的初始输入。inputs
字典包含一个 "messages"
键,其值是一个包含元组的列表。在此案例中,它包含一个元组 ("user", user_query)
,其中 "user"
字符串表示消息发送者的角色(即用户),user_query
是用户的查询或问题。
接着,我们初始化一个空字符串变量 final_answer
来存储工作流生成的最终答案:
final_answer = ''
接下来,我们使用 graph
实例作为基础启动代理循环:
for output in graph.stream(inputs):
for key, value in output.items():
pprint.pprint(f"Output from node '{key}':")
pprint.pprint("---")
pprint.pprint(value, indent=2, width=80, depth=None)
final_answer = value
这段代码通过 graph.stream(inputs)
启动了一个双层循环,遍历 graph
实例在处理输入时生成的输出。graph.stream(inputs)
方法流式传输来自图实例执行的输出。
在外层循环中,它为两个变量 key
和 value
启动了另一个循环,key
和 value
分别表示 output.items
中的键值对。它遍历这些键值对,key
变量表示节点名称,value
变量表示该节点生成的输出。代码通过 pprint.pprint(f"Output from node '{key}':")
打印出节点名称,表示哪个节点生成了输出。
接着,代码使用 pprint.pprint(value, indent=2, width=80, depth=None)
对输出(value
)进行漂亮打印。indent
参数指定缩进级别,width
指定输出的最大宽度,depth
指定打印的嵌套数据结构的最大深度(None
表示不限制)。它将 value
(输出)赋值给 final_answer
变量,在每次迭代时覆盖它。循环结束后,final_answer
将包含工作流中最后一个节点生成的输出。
这段代码的一个优点是,它允许你查看每个节点生成的中间输出,并跟踪查询处理的进展。这些打印输出表示代理在循环中的“思考”过程。漂亮打印有助于格式化输出,便于阅读。
当我们启动代理并开始查看输出时,我们会发现有很多内容在发生!
我将截断大量的输出,但这将给你一个大致的概念:
---CALL AGENT---
"Output from node 'agent':"
'---'
{ 'messages': [ AIMessage(content='', additional_kwargs={'tool_calls': [{'index': 0, 'id': 'call_46NqZuz3gN2F9IR5jq0MRdVm', 'function': {'arguments': '{"query":"Google's environmental initiatives"}', 'name': 'retrieve_google_environmental_question_answers'}, 'type': 'function'}]}, response_metadata={'finish_reason': 'tool_calls'}, id='run-eba27f1e-1c32-4ffc-a161-55a32d645498-0', tool_calls=[{'name': 'retrieve_google_environmental_question_answers', 'args': {'query': "Google's environmental initiatives"}, 'id': 'call_46NqZuz3gN2F9IR5jq0MRdVm'}])]}
'\n---\n'
这是我们输出的第一部分。在这里,我们看到代理决定使用 retrieve_google_environmental_question_answers
工具。如果你还记得,这是我们在定义时给检索工具起的基于文本的名称。做得好!
接下来,代理将确定它是否认为检索到的文档是相关的:
---CHECK RELEVANCE---
---DECISION: DOCS RELEVANT--- 决定是它们相关。再次,代理的思维非常聪明。
最后,我们看到代理所查看的输出,来自 PDF 文档和我们使用的联合检索器(这里的检索数据很多,我截断了大部分内容):
"Output from node 'retrieve':"
'---'
{ 'messages': [ ToolMessage(content='iMasons Climate AccordGoogle是 iMasons Climate Accord 的创始成员之一,致力于减少数字基础设施中的碳排放。该公司已经承诺到 2030 年实现 100% 的可再生能源购买,并且其数据中心在全球范围内实现了碳中和。', additional_kwargs={}, id='retrieve-0', tool_calls=[], response_metadata={}) ]}
'\n---\n'
---END OF THE ROAD---
你可以看到,现在我们已经从代理节点到检索节点,整个流程已经结束。
总结
在本章中,我们探讨了如何将 AI 代理和 LangGraph 结合起来,以创建更强大、更复杂的 RAG 应用。我们了解到,AI 代理本质上是一个具备循环功能的 LLM(大语言模型),它可以进行推理并将任务分解为更简单的步骤,从而提高复杂 RAG 任务的成功率。LangGraph 是一个基于 LCEL(Language-Conditioned Execution Logic)构建的扩展,它为构建可组合和可定制的代理工作负载提供了支持,使开发人员能够使用基于图的方式来编排代理。
我们深入了解了 AI 代理和 RAG 集成的基础知识,讨论了代理可以用来执行任务的工具概念,以及 LangGraph 的 AgentState
类如何跟踪代理随时间变化的状态。我们还介绍了图论的核心概念,包括节点、边和条件边,这些都是理解 LangGraph 工作原理的关键。
在代码实验部分,我们为 RAG 应用构建了一个 LangGraph 检索代理,演示了如何创建工具、定义代理状态、设置提示语以及使用 LangGraph 构建循环图。我们看到代理如何利用其推理能力来决定使用哪些工具、如何使用它们以及提供哪些数据,从而最终为用户的问题提供更全面的响应。
展望未来,下一章将重点讨论如何通过提示工程来改进 RAG 应用。