使用 LlamaIndex 构建聊天机器人和智能体

1,662 阅读59分钟

本章深入探讨了使用 LlamaIndex 的功能实现聊天机器人和智能体。我们将探索各种聊天引擎模式,从简单的聊天机器人到更高级的上下文感知和问题精简引擎。接着,我们将深入分析智能体架构,包括工具、推理循环和并行执行方法。你将获得实际知识,以便构建由 LLM 驱动的对话界面,这些界面能够理解用户需求,并通过利用工具和数据源来协调响应或行动。

在本章中,我们将涵盖以下主要主题:

  • 理解聊天机器人和智能体
  • 在应用程序中实现智能策略
  • 实践——实现 PITS 的对话跟踪

技术要求

以下 LlamaIndex 集成包是示例代码所需的:

本章中的所有代码示例可以在本书 GitHub 仓库的 ch8 子文件夹中找到:GitHub 仓库

理解聊天机器人和智能体

chat_engine = index.as_chat_engine()
response = chat_engine.chat("Hi, how are you?")
chat_engine.chat_repl()
chat_engine.reset()

from llama_index.core.storage.chat_store import SimpleChatStore
from llama_index.core.chat_engine import SimpleChatEngine
from llama_index.core.memory import ChatMemoryBuffer

try:
    chat_store = SimpleChatStore.from_persist_path(
        persist_path="chat_memory.json"
    )
except FileNotFoundError:
    chat_store = SimpleChatStore()

memory = ChatMemoryBuffer.from_defaults(
    token_limit=2000,
    chat_store=chat_store,
    chat_store_key="user_X"
)

chat_engine = SimpleChatEngine.from_defaults(memory=memory)

while True:
    user_message = input("You: ")
    if user_message.lower() == 'exit':
        print("Exiting chat")
        break
    response = chat_engine.chat(user_message)
    print(f"Chatbot: {response}")

chat_store.persist(persist_path="chat_memory.json")

from llama_index.core.chat_engine import SimpleChatEngine

chat_engine = SimpleChatEngine.from_defaults()
chat_engine.chat_repl()

from llama_index.llms.openai import OpenAI

llm = OpenAI(temperature=0.8, model="gpt-4")
chat_engine = SimpleChatEngine.from_defaults(llm=llm)

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader

docs = SimpleDirectoryReader(input_dir="files").load_data()
index = VectorStoreIndex.from_documents(docs)
chat_engine = index.as_chat_engine(
    chat_mode="context",
    system_prompt=(
        "You're a chatbot, able to talk about "
        "general topics, as well as answering specific "
        "questions about ancient Rome."
    ),
)
chat_engine.chat_repl()

retriever = index.as_retriever(retriever_mode='default')
chat_engine = ContextChatEngine.from_defaults(
    retriever=retriever
)

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from llama_index.core.chat_engine import CondenseQuestionChatEngine
from llama_index.core.llms import ChatMessage

documents = SimpleDirectoryReader("files").load_data()
index = VectorStoreIndex.from_documents(documents)
query_engine = index.as_query_engine()

chat_history = [
    ChatMessage(
        role="user",
        content="Arch of Constantine is a famous building in Rome"
    ),
    ChatMessage(
        role="user",
        content="The Pantheon should not be regarded as a famous building"
    ),
]

chat_engine = CondenseQuestionChatEngine.from_defaults(
    query_engine=query_engine,
    chat_history=chat_history
)

response = chat_engine.chat(
    "What are two of the most famous structures in ancient Rome?"
)
print(response)

# Output: The Colosseum and the Pantheon. The Colosseum and the Arch of Constantine are two famous buildings in ancient Rome.

index.as_chat_engine(chat_mode="condense_question")
index.as_chat_engine(chat_mode="condense_plus_context")

在现代商业生态系统中,聊天机器人系统的作用越来越重要。聊天机器人首次出现在 1960 年代(ELIZA),一直以来都让开发者和技术用户着迷。图 8.1 显示了其中一个早期系统的用户界面:

image.png

虽然这些系统最初较为原始,并被视为一种实验,但随着 NLP 技术的进步,它们提供的体验变得越来越有趣且对用户越来越有价值。

基于聊天机器人的支持系统为今天的消费者提供了自助服务体验。对于用户来说,自助服务支持相较于人工支持有两个主要优势:

  • 它们 24/7 全天候可用,即使在正常工作时间之外也是如此。
  • 用户不需要等待才能访问这些服务。

即使一开始对这些系统存在一些抵触情绪,一旦发现这些优势,用户很快会习惯与它们互动。

不要把聊天机器人视为完全替代人工支持和互动的技术。尽管近年来取得了巨大进步,这些技术虽然越来越先进,但仍然存在不足之处。

即使在理想的操作条件下,缺乏真正的同理心和人情味,基于聊天机器人的服务也不太可能完全取代人工支持。但这并不意味着它们不具有极大的价值,对组织和用户而言都是如此。

它们带来的最大价值可能是在混合体验中发挥作用,即用户可以同时获得人工支持和与聊天机器人技术接口的自助服务平台。战略性地实施这些系统不仅可以大大改善最终消费者的支持,还可以改善组织内部员工之间的互动。

例如,ChatOps 是现代组织越来越多使用的一种模型(ChatOps 的好处)。

定义

ChatOps 指的是将聊天平台与操作工作流集成的能力,促进团队成员、流程、工具和自动化机器人之间的透明协作,以提高服务可靠性,加快恢复速度,并提升协作生产力。

基于对话驱动的协作理念,ChatOps 模型结合了 DevOps 原则(DevOps),通过使用聊天机器人简化和加速团队成员之间的互动。

无论我们是用于内部沟通还是与用户的互动,聊天机器人的价值仅限于它们能否解决实际问题。这取决于它们理解互动上下文的能力以及提供的回答的相关性。

图 8.2 提供了 ChatOps 模型的视觉表示:

image.png

如果说最初聊天机器人的主要限制来自于与用户交互的笨拙方式,那么随着 NLP 技术的发展,最近的主要缺陷已经变成了与组织知识库的缺乏集成。

毕竟,如果系统提供的答案对解决用户请求没有帮助,那么自然的沟通体验又有什么意义呢?

这就引出了 RAG(检索增强生成)。

现在,我认为已经很明显,如果不与组织的知识库连接,聊天机器人充其量只能被视为一种技术实验。即便是基于强大 LLM 的对话引擎,如 GPT-4,也只能提供泛泛的回答,这些回答并不总能解决每个组织的具体问题。更糟糕的是,如果没有经过验证的文档作为依据,它们可能会非常令人信服地产生幻觉,从而创建出令人不愉快甚至潜在危险的体验。

正如你可能已经猜到的那样,LlamaIndex 也提供了 RAG 工具来实现聊天机器人技术。在本章中,我们将探索可用的选项,并了解如何实现从非常简单的系统到高级聊天机制。

但首先,让我们看看这个功能是如何融入 LlamaIndex 的。

发现 ChatEngine

在之前的章节中,我们看到了如何构建一个查询引擎来基于我们的数据运行查询。这个机制允许我们同时集成多种类型的索引、检索器、节点后处理器和响应合成器,从而可以以多种方式访问我们的专有数据。不幸的是,QueryEngine 类没有提供保持对话历史的机制。这意味着每个查询都是一次独立的互动,没有上下文记忆来实现真正的对话。

然而,为了这个目的,我们有 ChatEngine。与查询引擎不同,ChatEngine 允许我们进行实际的对话,不仅提供我们专有数据的上下文,还提供聊天历史。为了进一步简化这个概念,可以把 ChatEngine 想象成一个带有记忆的 QueryEngine 类。

在最简单的形式下,可以基于索引轻松初始化聊天引擎:

一旦初始化,聊天引擎可以使用各种方法进行查询:

  • chat():此方法启动一个同步聊天会话,处理用户的消息并立即返回响应。
  • achat():此方法类似于 chat(),但以异步方式执行查询,允许同时处理多个请求。这在 web 或移动应用中尤其有用,因为我们想要避免在服务器查询期间阻塞主线程。
  • stream_chat():此方法打开一个流式聊天会话,其中响应可以在生成时返回,以实现更动态的互动。这对于需要大量处理时间的长响应或复杂响应特别有用,允许用户在所有处理完成之前开始查看响应的部分内容。
  • astream_chat():此方法是 stream_chat() 的异步版本,允许我们在异步上下文中处理流式交互。

另一种选择是使用 ChatEngine 启动一个 Read-Eval-Print (REPL) 循环: REPL 聊天类似于 ChatGPT 界面,用户发送消息或问题,LLM 处理输入,生成响应,并立即显示给用户。只要用户持续提供输入,这个循环就会继续,创建一个互动对话。

要重置聊天对话,可以使用以下命令: 这在你想要清除历史记录并开始一个新的对话线程时很有用。

基本操作非常简单。接下来,让我们讨论一下 LlamaIndex 中可用的不同内置聊天模式。

理解不同的聊天模式

在初始化聊天引擎时,我们可以使用 chat_mode 参数来调用 LlamaIndex 中预定义的各种聊天引擎类型。我将向你展示这些引擎的工作原理。我们将逐一讨论它们,并对每种引擎的优点和最适用的用例有一个清晰的理解。

但首先,让我们简要介绍一下 LlamaIndex 中聊天记忆的管理方式。

理解聊天记忆的工作原理

ChatMemoryBuffer 类是一个专门设计的内存缓冲区,用于高效地存储聊天历史,同时管理不同 LLM(大语言模型)施加的令牌限制。这个结构很重要,因为我们可以在初始化聊天引擎时通过 memory 参数传递它。通过在一个会话到另一个会话之间保存和恢复这个缓冲区,我们可以实现对话的持久化。

聊天存储有两种不同的存储选项:

  • 默认的 SimpleChatStore,它将对话存储在内存中。
  • 更高级的 RedisChatStore,它将聊天历史存储在 Redis 数据库中,消除了手动持久化和加载聊天历史的需要。

chat_store 属性是 BaseChatStore 类的一个实例,用于实际存储和检索聊天消息。这种模块化方法允许不同的存储实现,例如简单的内存存储或更复杂的数据库支持存储。

我们还有 chat_store_key 参数,它用于唯一标识聊天会话或对话。这在当同一个聊天存储中存储了多个对话时很有用,可以用来检索正确的对话历史。以下是使用 SimpleChatStore 实现对话历史持久化的基本示例:

在导入必要的库后,我们可以尝试加载之前的对话。如果没有之前的对话保存文件,我们只需初始化一个空的 chat_store

接下来,我们使用 chat_store 作为参数初始化我们的内存缓冲区。虽然在这里并不需要,但为了更详细的说明,我们也将自定义 token_limitchat_store_key

好,我们已经拥有了所有必要的组件。让我们将它们组合到一个 SimpleChatEngine 类中,并创建一个聊天循环:

一旦用户输入 exit 并退出循环,我们使用 persist() 方法来存储当前对话,以便将来使用:

如果你在想为什么我们没有使用之前展示的 chat_repl() 方法而是创建了一个聊天循环,答案在下面的说明中。

重要说明

虽然 chat()achat()stream_chat()astream_chat() 方法可以从加载和恢复之前的对话中受益,但 chat_repl() 方法在初始化时会重置对话历史,这是设计使然。

ChatMemoryBuffer 还在确保对话的上下文保持在所使用模型的令牌限制内方面发挥着重要作用。在 ChatMemoryBuffer 中,还有其他参数可用,其中 token_limit 属性指定可以存储在内存缓冲区中的最大令牌数量。这个限制对于确保我们保持在当前 LLM 的最大上下文窗口大小内是至关重要的。

当对话超出上下文限制时,会应用滑动窗口方法。旧的对话部分会被截断,以确保保留和处理最新且相关的部分,并在 LLM 的令牌约束内进行处理。

滑动窗口方法的类比

想象与 LLM 的对话就像是一段火车旅程,每段对话增加一节车厢。然而,由于轨道的长度限制,火车只能有一定的长度,这代表了模型的上下文窗口限制。为了保持旅程的继续并增加新的车厢——在我们的例子中是消息——旧的车厢需要被拆卸并留下。这确保了火车可以继续行驶,携带对话中最新且相关的部分,同时保持在轨道的限制内。就像在火车旅程中,我们可能会根据重要性来优先考虑保留哪些车厢,滑动窗口方法也优先保留新的对话部分,保持对话流畅。

现在我们理解了内存是如何工作的,让我们谈谈不同的聊天模式。

简单模式

这是最基本的聊天引擎。它允许与 LLM 进行简单直接的对话,而无需连接到我们的专有数据。图 8.3 以视觉方式解释了这种聊天模式:

image.png

在这种模式下,用户的体验由 LLM 的固有能力和限制决定,比如其上下文窗口大小和整体性能。

要初始化这种模式,我们可以使用以下代码:

如果我们愿意,也可以使用 llm 参数自定义 LLM:

由于你可能不会在 RAG 设计中过多使用这种模式,让我们谈谈更高级的选项。

上下文模式

ContextChatEngine 旨在通过利用我们的专有知识来增强聊天交互。它通过从索引中检索与用户输入相关的文本,将这些检索到的信息整合到系统提示中以提供上下文,然后利用 LLM 生成响应。

请查看图 8.4 以获取这种聊天模式的视觉表示:

image.png

对于这种聊天引擎,我们可以自定义多个参数:

  • retriever:用于根据用户消息从索引中检索相关文本的实际检索器。当聊天引擎直接从索引初始化时,它将使用该特定索引类型的默认检索器。

  • llm:用于生成响应的 LLM 实例。

  • memory:用于存储和管理聊天历史记录的 ChatMemoryBuffer 对象。

  • chat_history:这是一个可选的 ChatMessage 实例列表,表示对话历史记录。它可以用于保持对话的连续性。这个历史记录包括在聊天会话中交换的所有消息,包括用户和聊天机器人的消息。例如,它可以用于从某个点继续对话。ChatMessage 对象包含三个属性:

    • role:默认值为用户(user)。
    • content:实际的消息内容。
    • 任何通过 additional_kwargs 提供的可选参数。
  • prefix_messages:一个 ChatMessage 实例列表,可以在实际用户消息之前用作预定义消息或提示。这对于设置聊天的特定语气或上下文很有用。

  • node_postprocessors:一个可选的 BaseNodePostprocessor 实例列表,用于进一步处理检索器检索到的节点。这可以用于实现保护措施、从上下文中删除敏感信息,或根据需要对检索到的节点进行其他调整。

  • context_template:一个字符串模板,可用于格式化将上下文提供给 LLM 的提示。

  • callback_manager:一个可选的 CallbackManager 实例,用于在聊天过程中管理回调。这对于跟踪和调试很有用。

  • system_prompt:一个可选的字符串,用作系统提示,为聊天机器人提供初始上下文或指令。

  • service_context:一个可选的 ServiceContext 实例,用于对聊天引擎进行额外的自定义。

要实现 ContextChatEngine,我们必须加载数据并构建索引,然后根据需要选择性地配置聊天引擎的不同参数。

以下是基于我们示例数据文件的快速示例,这些文件可以在本书 GitHub 仓库的 ch8/files 子文件夹中找到:

在这个示例中,我们从索引初始化了 chat_engine。或者,我们也可以将其定义为独立实例,提供检索器作为参数,如下所示:

总体而言,这种聊天模式对于涉及我们数据中知识的查询特别有效,支持基于索引内容的普通对话和更具体的讨论。

由于引擎首先从索引中检索上下文并用它来生成响应,这种方法使得聊天体验对于寻求特定信息的用户变得更加有用和自然。

问题精简模式

CondenseQuestionChatEngine 通过首先将对话和最新用户消息精简为一个独立的问题来简化用户交互。这个独立的问题旨在捕捉对话的核心要素,然后发送到基于我们专有数据构建的查询引擎,以生成响应。

使用这种方法的主要好处是它保持对话专注于话题,在每次交互中保留对话的核心点。并且它始终在我们专有数据的背景下作出响应。

图 8.5 描述了这种特定聊天模式的操作:

image.png

最终响应来自于我们检索到的专有数据而不是直接来自 LLM,这有时也可能成为一个劣势。由于依赖于每次响应都从知识库中查询,这种聊天模式可能在处理更一般性的问题时遇到困难,例如询问之前的互动内容。

让我们看看 CondenseQuestionChatEngine 的一些关键参数:

  • query_engine:这是一个 BaseQueryEngine 实例,用于查询精简后的问题。这里可以使用任何类型的查询引擎,包括具有路由功能的复杂构造。
  • condense_question_prompt:这是一个 BasePromptTemplate 实例,用于将对话和用户消息精简为一个独立的问题。
  • memory:一个 ChatMemoryBuffer 实例,用于管理和存储聊天历史记录。
  • llm:用于生成精简问题的语言模型实例。
  • verbose:一个布尔标志,用于在操作期间打印详细日志。
  • callback_manager:一个可选的 CallbackManager 实例,用于管理回调。

要实现这种聊天引擎,我们通常会用一个查询引擎初始化它,并根据需要选择性地配置自定义参数。对话通过一个预定义的模板精简成一个问题,这个模板可以通过 condense_question_prompt 参数进行定制。生成的问题随后被发送到查询引擎。

以下是一个简要的实现示例:

在代码的第一部分,我们加载了示例文件,创建了一个索引,然后创建了一个简单的查询引擎。接下来,我们通过创建包含两个 ChatMessage 对象的聊天历史记录来引入之前的对话上下文。具体来说,我们指示聊天引擎不要将万神殿(Pantheon)视为著名建筑。

现在,让我们创建我们的聊天引擎并进行查询:

让我们看看在后台发生了什么:

CondenseQuestionChatEngine 将用户的消息和提供的聊天历史记录精简成一个独立的问题。这个过程使用了 LLM 和 condense_question_prompt,生成了一个 encapsulates 对话上下文和用户最新查询的本质的问题。

然后,引擎将这个精简的问题转发给查询引擎,查询引擎在索引数据中搜索相关信息。

查询引擎访问 VectorStoreIndex 中的信息,处理问题并返回答案。这个答案反映了之前对话的整体上下文以及有关古罗马著名建筑的具体查询。

如果没有添加聊天历史记录,示例的输出将类似于以下内容:

这是因为这两个建筑在我们的示例数据中被明确提到。

然而,一旦我们添加了新的对话上下文,输出看起来像这样:

另一种初始化这个聊天引擎的方式是直接从索引中进行,如下所示:

这种聊天模式对于复杂的对话特别有用,其中先前交流的上下文和细微差别在理解和准确回应最新查询时扮演着关键角色。它确保聊天机器人对对话历史保持关注,从而使互动更加连贯和上下文相关。

接下来我们将讨论的聊天模式结合了其他两种方法的优点。

精简加上下文模式

CondensePlusContextChatEngine 提供了更全面的聊天互动,通过结合精简问题和上下文检索的优点来实现。

尽管我们讨论的前一种聊天引擎更为简单,专注于将对话简化为问题以生成响应,但 CondensePlusContextChatEngine 通过从索引数据中提取额外的上下文来进一步丰富对话,从而提供更详细和上下文相关的响应。这里的权衡是,由于执行了额外的步骤,响应生成时间会有所增加。让我们通过查看图 8.6 来深入了解它的工作原理:

image.png

首先,这种引擎将对话和最新的用户消息精简为一个独立的问题。然后,它使用这个精简的问题从索引中检索相关的上下文。最后,它结合检索到的上下文和精简的问题,通过 LLM 生成响应。

以下是 CondensePlusContextChatEngine 的一些关键参数:

  • retriever:用于根据精简的问题提取上下文
  • llm:用于生成精简的问题和最终响应的 LLM
  • memory:一个 ChatMemoryBuffer 实例,用于存储和管理聊天历史
  • context_prompt:用于格式化系统提示中上下文的提示模板
  • condense_prompt:用于将对话精简为独立问题的提示
  • system_prompt:用于提供聊天机器人的指令的提示
  • skip_condense:一个布尔标志,用于在需要时跳过精简步骤
  • node_postprocessors:一个可选的 BaseNodePostprocessors 列表,用于对检索到的节点进行额外处理
  • callback_manager:用于管理回调的实例
  • verbose:一个布尔标志,用于在操作过程中启用详细日志

要从索引构建这个特定的聊天引擎,我们可以使用以下命令:

这个聊天模式在对话的上下文和从索引数据中提取的特定信息对生成准确和相关响应至关重要的场景中非常理想。它通过确保响应在上下文上相关且包含来自索引内容的具体细节,从而增强了聊天体验。

好的,现在是时候探索更高级的聊天模式了。

在应用程序中实现代理策略

以下代码示例展示了如何在应用程序中实现不同的代理策略,涵盖了如何设置工具、配置代理、执行任务和处理响应。

from llama_index.core.tools import FunctionTool

def calculate_average(*values):
    """
    计算提供值的平均值。
    """
    return sum(values) / len(values)

average_tool = FunctionTool.from_defaults(
    fn=calculate_average
)

# 安装数据库工具包
pip install llama-index-tools-database

from llama_index.tools.database import DatabaseToolSpec

db_tools = DatabaseToolSpec(<db_specific_configuration>)
tool_list = db_tools.to_tool_list()

# 安装 OpenAI 代理工具包
pip install llama-index-agent-openai

from llama_index.tools.database import DatabaseToolSpec
from llama_index.core.tools import FunctionTool
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI

def write_text_to_file(text, filename):
    """
    将文本写入指定文件。
    
    参数:
        text (str): 要写入文件的文本。
        filename (str): 要写入文本的文件名。
    
    返回:
        None
    """
    with open(filename, 'w') as file:
        file.write(text)

save_tool = FunctionTool.from_defaults(fn=write_text_to_file)

db_tools = DatabaseToolSpec(uri="sqlite:///files//database//employees.db")
tools = [save_tool] + db_tools.to_tool_list()

llm = OpenAI(model="gpt-4")
agent = OpenAIAgent.from_tools(
    tools=tools,
    llm=llm,
    verbose=True,
    max_function_calls=20
)

response = agent.chat(
    "对于每位IT部门员工,"
    "如果他们的薪水低于公司平均薪水,"
    "请写一封电子邮件,"
    "宣布加薪10%,并将所有电子邮件保存到"
    "一个名为 'emails.txt' 的文件中"
)
print(response)

from llama_index.agent.react import ReActAgent

agent = ReActAgent.from_tools(tools)

from llama_index.core.tools.tool_spec.load_and_search.base import (
    LoadAndSearchToolSpec
)
from llama_index.tools.database import DatabaseToolSpec
from llama_index.agent.openai import OpenAIAgent
from llama_index.llms.openai import OpenAI

db_tools = DatabaseToolSpec(
    uri="sqlite:///files//database//employees.db"
)

tool_list = db_tools.to_tool_list()
tools = LoadAndSearchToolSpec.from_defaults(
    tool_list[0]
).to_tool_list()

llm = OpenAI(model="gpt-4")
agent = OpenAIAgent.from_tools(
    tools=tools,
    llm=llm,
    verbose=True
)

response = agent.chat(
    "谁在员工表中薪水最高?"
)
print(response)

# 安装 Wikipedia 阅读器工具包
pip install llama-index-readers-wikipedia

from llama_index.agent.openai import OpenAIAgent
from llama_index.core.tools.ondemand_loader_tool import (
    OnDemandLoaderTool
)
from llama_index.readers.wikipedia import WikipediaReader

tool = OnDemandLoaderTool.from_defaults(
    WikipediaReader(),
    name="WikipediaReader",
    description="args: {'pages': [<list of pages>], 'query_str': <query>}"
)

agent = OpenAIAgent.from_tools(
    tools=[tool],
    verbose=True
)

response = agent.chat(
    "古罗马有哪些著名建筑?"
)
print(response)

# 安装 LLM 编译器代理工具包
pip install llama-index-packs-agents-llm-compiler

from llama_index.tools.database import DatabaseToolSpec
from llama_index.packs.agents_llm_compiler import LLMCompilerAgentPack

db_tools = DatabaseToolSpec(
    uri="sqlite:///files//database//employees.db"
)

agent = LLMCompilerAgentPack(db_tools.to_tool_list())

response = agent.run(
    "仅使用现有工具,"
    "列出薪水最高的HR部门员工"
)

from llama_index.core.agent import AgentRunner
from llama_index.agent.openai import OpenAIAgentWorker
from llama_index.tools.database import DatabaseToolSpec

db_tools = DatabaseToolSpec(
    uri="sqlite:///files//database//employees.db"
)

tools = db_tools.to_tool_list()
step_engine = OpenAIAgentWorker.from_tools(
    tools,
    verbose=True
)

agent = AgentRunner(step_engine)
input = (
    "找到薪水最高的HR员工,并给他们写一封电子邮件,"
    "宣布奖金"
)

response = agent.chat(input)
print(response)

task = agent.create_task(input)
step_output = agent.run_step(task.task_id)
while not step_output.is_last:
    step_output = agent.run_step(task.task_id)

response = agent.finalize_response(task.task_id)
print(response)

代理策略概述

在本章开始时,我们讨论了 ChatOps 模型的日益普及。这一模型基于人类操作员与 AI 代理之间的互动,代理能够理解讨论的上下文,提供回答并执行某些功能,从而充当虚拟助手的角色。

然而,之前讨论的聊天引擎模型只能回答问题,不能执行功能或以其他方式与后台数据进行交互。对于这些用例,我们需要代理。

代理与简单聊天引擎的主要区别在于,代理基于推理循环操作,并拥有多种工具可供使用。毕竟,没有 Q 提供的各种小工具,007 又怎能称之为邦德呢?

与简单的聊天机器人相比,代理不仅能够回答问题,还能处理更复杂的场景,这使得它们在商业环境中具有更大的实用性,因为 AI 增强的人际互动变得越来越普遍。

让我们理解代理的核心组成部分:工具和推理循环。

为我们的代理构建工具和 ToolSpec 类

我们在第6章《查询我们的数据,第一部分——上下文检索》中简要讨论了工具。然而,由于第6章的主要话题是数据查询,我只展示了如何将不同的查询引擎或检索器包装成工具,然后成为路由器的组件。从某种程度上来说,你可以把路由器看作是一种非常简单的代理。它利用 LLM 的推理来决定应该使用哪个查询引擎或检索器,这取决于它们的指定用途和实际的用户查询。

但工具的作用远不止于此。

工具还可以是任何用户定义函数的封装器,能够读取或写入数据、调用外部 API 的函数,或执行任何类型的代码。这意味着工具有两种不同的类型:

  • QueryEngineTool:这可以封装任何现有的查询引擎。我们在第6章中讨论的就是这种工具,它只能提供对数据的只读访问。
  • FunctionTool:这允许将任何用户定义的函数转换为工具。这是一种通用类型的工具,因为它允许执行任何类型的操作。

由于我们已经见过 QueryEngineTool 的工作原理,让我们重点关注 FunctionTool。

以下是如何定义一个 FunctionTool 的示例:

from llama_index.core.tools import FunctionTool

def calculate_average(*values):
    """
    计算提供值的平均值。
    """
    return sum(values) / len(values)

average_tool = FunctionTool.from_defaults(
    fn=calculate_average
)

为了使代理能够将我们的函数作为工具进行 assimilate,它们必须包含描述性文档字符串,就像之前的示例一样。LlamaIndex 依赖这些文档字符串来向代理提供有关封装用户定义函数的特定工具的目的和正确用法的理解。

定义

在 Python 中,文档字符串(docstring)是作为模块、函数、类或方法定义中的第一个语句出现的字符串字面量。它用于记录代码块的目的和用法。文档字符串可以通过代码中的 __doc__ 属性在运行时访问,它们也是 Python 中生成文档的主要方式。

这些描述将被代理的推理循环使用,以确定哪个特定工具适合解决特定任务,从而允许代理决定执行路径。

然而,能力强的代理通常能够处理不止一个工具。

为此,LlamaIndex 还提供了 ToolSpec 类。类似于一组单独的工具,ToolSpec 为特定服务指定了一整套工具。这就像是为我们的代理配备了特定类型技术的完整 API。

我们可以构建自定义的 ToolSpec 类,但在 LlamaHub 上也有越来越多的现成的 ToolSpec 类可供使用:llamahub.ai/?tab=tools 。这些类涵盖了不同类型的服务集成,例如 Gmail、Slack、SalesForce、Shopify 等等。

LlamaHub 代理工具库

LlamaHub 代理工具库是 LlamaHub 的一个重要补充,提供了一个策划的工具规格集合,使代理能够与各种服务进行交互并扩展功能。这个库简化了各种 API 的代理设计过程,并在其笔记本中包含了许多实用的示例,便于集成和使用。

让我们以 LlamaHub 上可用的 DatabaseToolSpec 类为例。

这个 ToolSpec 类可以在这里找到:llamahub.ai/l/tools-dat… 。首先,让我们看一下图8.7 以了解其结构:

image.png

建立在 SQLAlchemy 库(www.sqlalchemy.org/)之上,这套工具集能够访问多种类型的数据库,并提供了三个简单的工具:

  • list_tables:一个列出数据库模式中所有表的工具。
  • describe_tables:一个描述表的模式的工具。
  • load_data:一个接受 SQL 查询作为输入并返回结果数据的工具。

简要说明

SQLAlchemy 是一个强大且多功能的 Python 工具包,允许开发人员以更 Pythonic 的方式与各种数据库(如 Microsoft SQL Server、OracleDB、MySQL 等)进行交互,抽象了许多数据库交互和查询构建的复杂性。

由于这不是 LlamaIndex 的核心组件,而是作为集成包提供的,它必须首先在我们的环境中安装:

接下来,为了初始化这个 ToolSpec,我们只需导入它:

然后,我们需要配置我们的数据库访问,如下所示:

一旦 ToolSpec 类构建完成,如果我们想用它初始化一个代理,我们必须使用 to_tool_list() 方法将其转换为工具列表。这是因为代理期望工具作为列表参数传递。

以下是如何将 ToolSpec 类轻松转换为工具对象列表的方法:

from llama_index.tools.database import DatabaseToolSpec

# 创建 ToolSpec 实例
db_tools = DatabaseToolSpec(uri="sqlite:///files//database//employees.db")

# 将 ToolSpec 转换为工具列表
tool_list = db_tools.to_tool_list()

此时,我们可以在初始化任何类型的代理时将 tool_list 作为参数传递。我们的代理现在将能够理解数据库的模式并从其表中提取所需的信息。你可以在本章的 OpenAIAgent 部分找到如何使用这个 ToolSpec 类的完整示例。接下来,让我们看看推理循环如何工作。

理解推理循环

拥有如此多的专用工具对我们的代理来说是一个很大的优势。但不幸的是,一箱高质量的工具并不总是足够的。我们的代理还需要知道何时使用这些工具。

具体来说,我们构建的 RAG 应用需要尽可能自主地决定使用哪个工具,这取决于特定的用户查询和它们操作的数据集。任何硬编码的解决方案只能在有限的场景中提供良好的结果。这就是推理循环发挥作用的地方。

推理循环是代理的一个基本方面,使其能够智能地决定在不同场景下使用哪些工具。这一点非常重要,因为在复杂的实际应用中,需求可能会显著变化,而静态方法会限制代理的有效性。

图 8.8 展示了推理循环概念的视觉表示:

image.png

推理循环负责决策过程。它评估上下文,理解当前任务的要求,然后从工具库中选择合适的工具来完成任务。这种动态的方法使代理能够适应各种场景,使其具有多功能性和高效性。

在 LlamaIndex 中,推理循环的实现是根据代理的类型量身定制的。例如,OpenAIAgent 使用 Function API 来做决策,而 ReActAgent 则依赖于聊天或文本完成端点进行推理过程。

然而,这个循环不仅仅是选择合适的工具;它还涉及确定工具使用的顺序和应应用的具体参数。它是代理的大脑,协调工具无缝合作,就像熟练的工匠使用组合工具来创造出比工具总和更为出色的作品一样。

这种智能地与各种工具和数据源交互,并动态读取和修改数据的能力,使代理与更简单的聊天引擎区分开来,使其在需要适应性和智能的业务环境中显得尤为重要。

接下来我要描述的聊天模式类型不仅仅是简单的聊天引擎,而是核心本质上都是代理。它们都使用工具列表进行操作,但以不同的方式实现推理循环。

OpenAIAgent

这种专用代理利用了 OpenAI 模型的能力,特别是那些支持函数调用 API 的模型。它与已经设计为支持函数调用 API 的 OpenAI 模型配合工作。这些模型可以解释和执行函数调用,作为其能力的一部分。

快速说明
这些模型旨在解释提示和上下文,以确定何时适合进行函数调用。它们根据训练期间学习到的模式,以符合函数定义结构的输出做出响应。有关更多信息和支持的模型列表,可以参考官方 OpenAI 文档:OpenAI 函数调用指南

这种代理类型的关键优势在于工具选择逻辑直接在模型上实现。当用户向 OpenAIAgent 提供任务以及任何先前的聊天历史时,函数 API 会分析上下文并决定是否需要调用其他工具或是否可以返回最终响应。如果确定需要其他工具,函数 API 将输出该工具的名称。OpenAIAgent 然后执行该工具,将工具的响应传回聊天历史。这一循环会继续,直到 API 返回最终消息,表示推理循环已完成。

图 8.9 以视觉方式解释了这个过程:

image.png

推理循环负责决策过程。它评估上下文,理解当前任务的要求,然后从工具库中选择合适的工具来完成任务。这种动态的方法允许代理适应各种场景,使其具有多功能性和高效性。

在 LlamaIndex 中,推理循环的实现根据代理的类型进行了调整。例如,OpenAIAgent 使用 Function API 来做决策,而 ReActAgent 则依赖于聊天或文本完成端点进行推理过程。

这个循环不仅仅是选择合适的工具;它还涉及确定工具使用的顺序和应应用的具体参数。它是代理的大脑,协调工具无缝合作,就像熟练的工匠使用组合工具来创造出比工具总和更为出色的作品一样。

这种智能地与各种工具和数据源交互,并动态读取和修改数据的能力,使代理与更简单的聊天引擎区分开来,使其在需要适应性和智能的业务环境中显得尤为重要。

接下来我要描述的聊天模式类型不仅仅是简单的聊天引擎,而是核心本质上都是代理。它们都使用工具列表进行操作,但以不同的方式实现推理循环。

OpenAIAgent

这种专用代理利用了 OpenAI 模型的能力,特别是那些支持函数调用 API 的模型。它与已经设计为支持函数调用 API 的 OpenAI 模型配合工作。这些模型可以解释和执行函数调用,作为其能力的一部分。

快速说明
这些模型旨在解释提示和上下文,以确定何时适合进行函数调用。它们根据训练期间学习到的模式,以符合函数定义结构的输出做出响应。有关更多信息和支持的模型列表,可以参考官方 OpenAI 文档:OpenAI 函数调用指南

这种代理类型的关键优势在于工具选择逻辑直接在模型上实现。当用户向 OpenAIAgent 提供任务以及任何先前的聊天历史时,函数 API 会分析上下文并决定是否需要调用其他工具或是否可以返回最终响应。如果确定需要其他工具,函数 API 将输出该工具的名称。OpenAIAgent 然后执行该工具,将工具的响应传回聊天历史。这一循环会继续,直到 API 返回最终消息,表示推理循环已完成。

图 8.9 以视觉方式解释了这个过程:

image.png

模型处理工具选择和链式调用的复杂逻辑,OpenAIAgent 是一个出色的工具编排解决方案。一个权衡是与其他架构相比,其灵活性较差,因为工具选择逻辑硬编码在 LLM 中。

然而,对于许多用例来说,函数 API 模型的预训练能力足以实现有效的工具编排和任务完成。

在继续下一个示例之前,请确保安装所需的集成包:

要实现 OpenAIAgent,我们必须定义可用的工具,然后使用这些组件初始化代理,并添加任何其他自定义参数。最好的解释方式是通过一个示例。

在以下示例中,我们使用一个包含名为 Employees 的单一表的 SQLite 数据库。该表包含来自不同部门的 10 名员工的随机薪资数据。表 8.1 显示了 Employees 表的内容:

IDNameDepartmentSalaryEmail
1AliceIT36420.77Alice_IT@org.com
2KarenFinance57705.06Alice_Finance@org.com
3HelenIT52612.51Helen_IT@org.com
4JackieFinance61374.58Jack_Finance@org.com
5DavidFinance32242.72David_Finance@org.com
6CoraHR62040.53Alice_HR@org.com
7IngridIT70821.96Alice_IT@org.com
8JackIT57268.89Jack_IT@org.com
9BobFinance76868.23Bob_Finance@org.com
10BillHR74161.45Bob_HR@org.com

表 8.1 – 员工数据库的示例 Employees 表

数据库文件本身可以在本书 GitHub 仓库的 ch8/files/database 子文件夹中找到。让我们来看一下代码:

第一部分负责导入。

接下来,定义一个简单的函数,这个函数将成为我们代理的自定义工具。这个简单工具将允许我们将文件保存到本地文件夹。注意我们提供给代理的详细 docstring:

一旦函数定义完成,我们必须将其封装到一个名为 save_tool 的新工具中。

我们还从导入的 DatabaseToolSpec 初始化一个完整的 ToolSpec 类。我们需要这些工具,因为代理必须从我们的 SQLite 数据库中读取数据以解决任务:

创建 db_tools 后,我们必须将其与 save_tool 结合,并将它们放入一个名为 tools 的单一列表中。我们将使用这个列表作为初始化代理的参数。

现在,让我们构建我们的代理。请注意,我们在这种情况下没有使用默认的 LLM;相反,我们配置我们的代理使用 GPT-4 以获得更高的准确性:

在之前的代码中,我们使用准备好的工具列表初始化了代理。verbose 参数将使代理显示每个执行步骤,以便更好地查看推理过程。我们还将 max_function_calls 设置为较大的值,因为对于复杂的任务,默认值可能不足以让代理完成任务。

关于 max_function_calls 参数的快速说明
虽然将其设置为非常大的值以避免耗尽函数调用并增加代理解决任务的机会可能很诱人,但请记住,每次函数调用都需要成本,有时代理有进入无限循环的坏习惯。当它们这样做时,我称它们为流氓代理。如果你的代理实现需要大量的 LLM 调用来解决即使是简单的任务,可能是在定义或描述底层工具时做错了什么。

让我们继续我们的代码。现在是将任务分派给我们的代理的时候了:

如你所见,我们提供的任务相对复杂。需要多个步骤来解决它。由于我们在查询中没有提供太多细节,代理将需要弄清楚数据库的结构,然后编写 SQL 查询以提取组织中的平均薪资和 IT 部门中薪资低于平均水平的员工列表。

由于 verbose 参数设置为 True,运行这个示例将显示代理的整个推理逻辑和执行步骤。

注意,在每一步中,代理如何将工具的输出融入到其持续的推理过程中。一旦获得员工列表,它将为每个人编写电子邮件。任务的最终步骤是使用我们自定义创建的工具,并将结果保存到本地文件中。

这只是一个简单的示例。在更复杂的实现中,例如,我们可以导入 GmailToolSpec 从 LlamaHub 并创建电子邮件草稿,稍后可以手动审核并由用户发送。不幸的是,这会使示例变得更长,因为 GmailToolSpec 需要存储的 Google API 凭证,但我留给你去实验那个 ToolSpec 类(GmailToolSpec)和 LlamaHub 上的所有其他工具。

OpenAIAgent 的可自定义参数如下:

  • tools:代理在聊天会话中可以使用的 BaseTool 实例列表。这些工具可以从专用查询引擎到自定义处理模块或从 ToolSpec 类中提取的工具集合。
  • llm:任何支持函数调用 API 的 OpenAI 模型。默认使用的模型是 gpt-3.5-turbo-0613。
  • memory:与任何聊天引擎一样,这是一个 ChatMemoryBuffer 实例,用于存储和管理聊天历史。
  • prefix_messages:一组 ChatMessage 实例,作为聊天会话开始时的预配置消息或提示。
  • max_function_calls:在单次聊天交互中可以对 OpenAI 模型进行的最大函数调用次数。默认值是 5。
  • default_tool_choice:一个字符串,指示在多个工具可用时的默认工具选择。这对于强制代理使用特定工具很有用。
  • callback_manager:一个可选的 CallbackManager 实例,用于在聊天过程中管理回调,帮助跟踪和调试。
  • system_prompt:一个可选的初始系统提示,提供代理的上下文或指令。
  • verbose:一个布尔标志,用于在操作过程中启用详细日志记录。

总体而言,OpenAIAgent 由于能够执行复杂的函数调用以及进行富有上下文的对话,区别于其他聊天引擎。这使得它特别适用于需要高级功能的场景,例如集成外部工具或以更复杂的方式处理用户查询。OpenAIAgent 提供了一个多功能和强大的平台,用于创建引人入胜和智能的聊天体验。

但请等一下,还有其他类型的代理。

ReActAgent

与 OpenAIAgent 相比,ReActAgent 使用更通用的文本完成端点,这些端点可以由任何 LLM 驱动。它基于一个 ReAct 循环在构建在工具集之上的聊天模式中操作。

这个循环包括决定是否使用可用的任何工具,可能会使用它并观察其输出,然后决定是否重复这个过程或提供最终的响应。这种灵活性允许它在使用工具或仅依赖 LLM 之间进行选择。然而,这也意味着它的性能在很大程度上依赖于 LLM 的质量,通常需要更精细的提示以确保准确的知识库查询,而不是依赖可能不准确的模型生成响应。

ReActAgent 的输入提示经过精心设计,以引导模型在工具选择时使用一种格式,这种格式受到 Yao, S. 等(2022)发表的 ReAct 论文的启发(ReAct: Synergizing Reasoning and Acting in Language Models)。

它展示了一系列可用的工具,并要求模型选择一个并以 JSON 格式提供所需的参数。这种明确的提示对于代理的决策过程至关重要。选择工具后,代理执行它并将响应集成到聊天历史中。这种提示、执行和响应集成的循环会持续进行,直到获得满意的响应。有关整体工作流程的视觉表示,请参考图 8.9 中为 OpenAIAgent 提供的示例。

与 OpenAIAgent 使用能够选择和串联多个工具的函数调用 API 不同,ReActAgent 类的逻辑必须通过其提示完全编码。

ReActAgent 使用预定义的循环和最大迭代次数,以及战略性提示来模拟推理循环。然而,通过战略性的提示工程,ReActAgent 可以实现有效的工具编排和链式执行,类似于 OpenAI Function API 的输出。

关键区别在于,尽管 OpenAI Function API 的逻辑嵌入在模型中,ReActAgent 依赖其提示的结构来诱导所需的工具选择行为。这种方法提供了相当大的灵活性,因为它可以适应不同的语言模型后端,允许不同的实现和应用。

在这种情况下,我们有与 OpenAIAgent 讨论过的常见可自定义参数:toolsllmmemorycallback_managerverbose

此外,ReActAgent 还具有一些特定参数:

  • max_iterations:类似于 max_function_calls,这个参数设置了 ReAct 循环可以执行的最大迭代次数。这个限制确保代理不会进入无尽的处理循环。
  • react_chat_formatter:将聊天历史格式化为结构化的 ChatMessages 列表,交替显示用户和助手角色,基于提供的工具、聊天历史和推理步骤。这有助于保持推理循环的清晰性和一致性。
  • output_parser:一个可选的 ReActOutputParser 类实例。该解析器处理代理生成的输出,帮助解释和适当地格式化它们。
  • tool_retriever:一个可选的 ObjectRetriever 实例,适用于 BaseTool。这个检索器可以根据某些标准动态获取工具。类似于如何索引节点,还有一个选项来创建 ObjectIndex 索引以索引一组工具。这在我们需要处理大量工具时特别有用。有关此功能的更多信息,请参考官方文档:Function Retrieval Agents
  • context:一个可选的字符串,提供代理的初始指令。

初始化和使用 ReActAgent 的方式与 OpenAI 的类似,不过这次你不需要首先安装任何集成包——这种类型的代理是 LlamaIndex 核心组件的一部分。

总体而言,ReActAgent 以其灵活性脱颖而出,因为它可以使用任何 LLM 驱动其独特的 ReAct 循环,使其能够智能地选择和使用各种工具。它就像一个虚拟助手,不仅回答问题,还智能地决定何时咨询外部资源,从而使对话更具上下文相关性,提升用户体验。

如何与代理互动?

我们可以使用两种主要方法与代理互动:chat()query()。第一种方法利用存储的对话历史提供上下文信息的回应,非常适合持续对话。

另一方面,query() 方法在无状态模式下操作,每次调用都是独立的,不参考过去的互动。这种方法更适合单独的请求。

使用实用工具增强代理功能

为了提高现有工具的能力,LlamaIndex 还提供了两个非常有用的实用工具——OnDemandLoaderToolLoadAndSearchToolSpec。这些工具是通用的,可以与任何类型的代理一起使用,以增强标准工具在某些场景下的功能。

在与 API 互动时,一个常见的问题是我们可能会收到非常长的响应。我们的代理可能无法处理如此大输出的问题。

问题可能在于这些响应可能会溢出 LLM 的上下文窗口,或者有时,关键上下文可能会被大量数据稀释,降低代理推理逻辑的准确性。

要理解这个问题,可以回顾我们之前为 OpenAIAgent 使用的示例。在那个案例中,我们使用了一个叫做 DatabaseToolSpec 的工具集合,从我们的示例 Employees 表中检索数据。如果你以 Verbose 参数设置为 True 运行了那个代理,那么你可能注意到,

image.png

这意味着每当代理调用 load_data 工具,通过 SQL 查询数据库时,它不会直接收到查询的结果,而是返回一个完整的文档——包括文档的 ID、元数据字段、哈希值等附加数据。代理需要使用 LLM 从这些数据中提取实际的查询结果,这就是之前提到的潜在问题所在。

那么,如果我们只想提取查询结果,而不包含所有附加数据呢?这就是 LoadAndSearchToolSpec 的作用。

理解 LoadAndSearchToolSpec 实用工具

这个实用工具旨在帮助代理处理来自 API 端点的大量数据,如图 8.11 所示:

image.png

它将一个现有工具生成两个独立的工具:一个用于加载和索引数据——默认使用向量索引——另一个用于在这些已索引的数据上进行搜索。代理现在会使用加载工具来导入数据,类似于缓存机制,它会将数据存储在一个索引中。在下一步中,代理将使用搜索工具,通过内置的查询引擎提取所需的信息。

让我们看看这如何转化为代码。我们将修改之前的 OpenAIAgent 示例,使其使用 LoadAndSearchToolSpec

一旦完成导入,我们初始化了 DatabaseToolSpec 实用工具,它指向与之前示例中相同的 SQLite 数据库。不过,这一次,我们没有添加任何额外的工具,因为我们只会运行一个简单的查询。因此,我们仅将 ToolSpec 中的第一个工具——即 tool_list[0]——作为参数传递给 LoadAndSearchToolSpec。顺便提一下,这就是 load_data 函数。这次我们不需要数据库 ToolSpec 中的其他两个函数。

从这一点开始,代码非常简单明了:

如果你查看输出——如图 8.12 所示——你会注意到这次代理需要处理的数据量减少了很多:

image.png

与接收整个文档作为响应不同,第一次调用仅返回一个确认消息,表示数据已经加载并索引,而第二次调用则使用查询提取最终响应。接下来我们将讨论另一种实用工具。

理解 OnDemandLoaderTool

另一个重要的实用工具是 OnDemandLoaderTool。这个工具旨在使加载、索引和查询数据的过程在代理的工作流中变得无缝且高效,特别是在处理来自各种来源的大量数据时。

它简化了代理使用数据加载器的过程,通过单次工具调用来触发数据的加载、索引和查询。

在 RAG 工作流中,通常的做法是在应用启动时加载所有数据,然后将其拆分、索引,并构建查询引擎。但这并不总是最有效的方法。

假设我们有大量的数据源。在启动期间加载和索引所有数据将花费很长时间,负面影响用户体验。如果用户提出一个问题,而代理仅基于已加载的数据源无法回答,这时这样的功能就显得特别有用。

OnDemandLoaderTool 在数据需求动态且不可预测的场景中特别有用。它允许代理按需获取、索引和查询数据,而不是在启动时预加载大量可能与用户当前需求无关的数据。这种方法显著提高了效率,因为它允许代理仅关注当前时间相关的数据,而不是处理可能暂时不需要的大型数据集。

它是如何工作的?它将任何现有的数据加载器封装成一个工具,代理可以根据需要使用。在运行代码之前,请确保安装了维基百科集成包:

以下是示例代码。我们从导入开始:

接下来,我们基于 WikipediaReader 为代理定义一个按需工具:

注意我在描述参数中提供了使用说明。这些说明应该帮助代理更好地理解如何正确使用工具,尽管它可能仍需几次尝试才能正确使用。现在,初始化代理的时间到了:

重要的附带说明

使用这种方法的一个重要优势是,一旦数据被加载到索引中,它也会被缓存。因此,对同一主题的后续查询将更快。

此外,OnDemandLoaderTool 可以与其他常规工具链式组合,允许代理处理更复杂的场景。

到此为止,我们已经覆盖了基础知识。现在,让我们看看更高级的代理类型。

使用 LLMCompiler 代理处理更高级的场景

我将最精彩的部分留到最后。

尽管 OpenAI 和 ReAct 代理在许多场景中表现良好,但它们也有一些缺点。由于当前的 LLM 在长期规划方面不够出色,它们有时可能陷入无限循环而无法找到期望的解决方案。其他时候,它们的注意力可能被执行过程中收到的某些输出分散,从而导致在解决给定任务之前就停止。

但这些代理类型最大的缺点可能是它们的序列化工作方式。换句话说,步骤的执行是按顺序进行的。这些代理等待一个步骤生成的输出来触发下一个步骤。在许多实际场景中,这是一种非常低效的方法。通常,一系列步骤可以并行执行,从而显著提高应用性能和用户体验。基于这些前提,我现在将介绍一种更高级的代理形式。

受 Kim, S., 等人(2023)论文《LLM Compiler for Parallel Function Calling》(arxiv.org/abs/2312.04… LLM 并行执行多个功能的能力,并从经典编译器中汲取灵感,以高效地协调多功能执行。

LLMCompiler 代理通过一个三部分系统来协调这些并行功能调用,该系统计划、调度和执行任务,从而比顺序方法更快、更准确地进行多功能调用。就像编译器将代码转换和优化以提高运行效率一样,LLMCompiler 将自然语言查询转换为优化的功能调用序列,当依赖关系允许时可以并行执行。这使得使用 LLM 调用多个工具更快、更便宜且可能更准确。另一个优势是,它适用于任何类型的 LLM,包括开源和闭源模型。

在幕后,LLMCompiler 代理有三个主要组件:

  • LLM 规划器:根据用户输入和示例制定执行策略和依赖关系。
  • 任务获取单元:根据依赖关系发送和更新功能调用任务。
  • 执行器:使用相关工具并行执行任务。

图 8.13 以视觉形式解释了 LLMCompiler 代理的结构:

image.png

LLM 规划器根据用户输入确定功能调用的顺序及其相互依赖关系。接下来,任务获取单元启动这些函数的并行执行,将变量替换为先前任务的输出。执行器随后使用相关工具执行这些功能调用。这些元素的结合提高了 LLM 中并行功能调用的效率。

任务的有向无环图(DAG)是由 LLM 规划器根据用户输入和示例创建的关键数据结构。这个规划图捕捉任务的依赖关系,并实现优化的并行执行(en.wikipedia.org/wiki/Direct…

DAG 促进了不相互依赖任务的同时执行。如果某个任务依赖于另一个任务的完成,则先决任务必须在依赖任务开始之前完成。另一方面,独立任务可以在没有任何依赖约束的情况下并行执行。

快速说明

尽管 OpenAI 已经将并行功能调用引入了他们的 API,LLMCompiler 的方法仍然优越,因为它在遇到错误的 LLM 决策时展现出容错能力,并且可以根据生成的输出重新规划。

为了理解如何使用 LLMCompiler 实现代理,让我们看看一个简单的例子。不过首先,要运行这个示例,你需要安装必要的集成包:

以下是代码示例:

在导入 LLMCompilerAgentPackDatabaseToolSpec 后,我们初始化了数据库工具,并使用工具列表初始化了代理。现在是与代理交互的时候了,这次使用 run() 方法:

图 8.14 显示了前面代码的输出:

image.png

查看输出,我们可以看到代理生成的执行计划以及实际执行的步骤。相当整洁,不是吗?

总结

基于 LLMCompiler 的代理代表了在解决传统代理中串行执行的局限性方面的一次飞跃,推动了聊天机器人实现和用户互动的可能性边界。

使用低级代理协议 API

受 Agent Protocol(agentprotocol.ai/)及若干研究论文的启发… 社区也创建了一种更细粒度的代理控制方式。这提供了增强的控制和灵活性,用于执行用户查询,使用户能够以更精细的方式管理代理的操作,从而促进了更复杂的代理系统的发展。

整个概念基于两个主要组件:AgentRunner 和 AgentWorker,工作原理如图 8.15 所示:

image.png

我们使用代理运行器(agent runners)来协调任务并存储对话记忆。代理工作者(agent workers)则逐步控制每个任务的执行,而不自己存储状态。代理运行器管理整体过程并整合结果。

优点

使用这种类型的代理有多个好处。首先,它允许明确的职责分离:代理运行器管理任务的整体协调和记忆,而代理工作者则专注于执行任务的特定步骤。这种分工提高了系统的可维护性和可扩展性。

此外,这种架构提升了对代理决策过程的可见性和控制。我们可以在每一步观察和干预,对代理的操作有非常好的洞察。这对于调试和优化代理行为特别有用。

另一个关键好处是提供的灵活性。我们可以根据应用程序的具体需求定制代理的行为。我们可以修改或扩展代理工作者的功能,或在代理运行器中集成自定义逻辑,使系统高度适应不同需求。这种设置还支持模块化开发。我们可以构建或更新单独的组件而不影响整个系统,从而方便进行更新和迭代。

示例实现

以下是一个示例实现,将之前的一个例子应用于这种更细粒度的方法。我们将使用 AgentRunner 和 OpenAIAgentWorker 以低级方式实现 OpenAIAgent:

首先,我们导入了必要的组件并准备了代理的工具列表。我们使用了之前相同的 employees.db 数据库。接下来,我们将定义代理工作者:

现在是初始化我们的代理运行器并准备包含任务的输入的时候了:

现在我们有两种不同的方法来与代理互动。让我们来看看。

选项 A – 端到端交互,使用 chat() 方法

chat() 方法提供了无缝的端到端交互,执行任务时无需在每个推理步骤上进行干预:

这非常简单:只需两行代码,我们就等待代理完成任务并在所有步骤完成时提供最终响应。

选项 B – 步骤交互,使用 create_task() 方法

为了获得更细粒度的控制,我们可以利用代理运行器并使用逐步方法,这允许我们创建任务,逐步运行每个步骤,然后最终化响应:

在第一部分,我们为代理运行器创建了一个新任务,并执行了任务的第一步。由于此方法提供了对每一步执行的手动控制,我们必须在代码中手动实现一个循环。我们将重复调用 run_step(),直到输出指示所有步骤都已完成:

上面的循环将一直运行,直到最后一步完成。然后,是时候合成并显示最终答案了:

这允许我们逐步执行并观察每个推理步骤。create_task() 方法初始化一个新任务,run_step() 执行每个步骤,返回输出,而 finalize_response() 在所有步骤完成后生成最终响应。

总体而言,当你需要密切监控代理的决策或在某些点干预以指导过程或处理异常时,这个选项特别有用。

现在,是时候将这些新知识应用到我们的 PITS 项目中,并添加一些聊天功能了。

实践操作 – 为 PITS 实现对话跟踪

import os
import json
import streamlit as st
from openai import OpenAI
from llama_index.core import load_index_from_storage
from llama_index.core import StorageContext
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.core.tools import QueryEngineTool, ToolMetadata
from llama_index.agent.openai import OpenAIAgent
from llama_index.core.storage.chat_store import SimpleChatStore
from global_settings import INDEX_STORAGE, CONVERSATION_FILE

def load_chat_store():
    try:
        chat_store = SimpleChatStore.from_persist_path(
            CONVERSATION_FILE
        )
    except FileNotFoundError:
        chat_store = SimpleChatStore()
    return chat_store

def display_messages(chat_store, container):
    with container:
        for message in chat_store.get_messages(key="0"):
            with st.chat_message(message.role):
                st.markdown(message.content)

def initialize_chatbot(user_name, study_subject,
                      chat_store, container, context):
    memory = ChatMemoryBuffer.from_defaults(
        token_limit=3000,
        chat_store=chat_store,
        chat_store_key="0"
    )
    storage_context = StorageContext.from_defaults(
        persist_dir=INDEX_STORAGE
    )
    index = load_index_from_storage(
        storage_context, index_id="vector"
    )
    study_materials_engine = index.as_query_engine(
        similarity_top_k=3
    )
    study_materials_tool = QueryEngineTool(
        query_engine=study_materials_engine,
        metadata=ToolMetadata(
            name="study_materials",
            description=(
                f"Provides official information about "
                f"{study_subject}. Use a detailed plain "
                f"text question as input to the tool."
            ),
        )
    )
    agent = OpenAIAgent.from_tools(
        tools=[study_materials_tool],
        memory=memory,
        system_prompt=(
            f"Your name is PITS, a personal tutor. Your "
            f"purpose is to help {user_name} study and "
            f"better understand the topic of: "
            f"{study_subject}. We are now discussing the "
            f"slide with the following content: {context}"
        )
    )
    display_messages(chat_store, container)
    return agent

def chat_interface(agent, chat_store, container):
    prompt = st.chat_input("Type your question here:")
    if prompt:
        with container:
            with st.chat_message("user"):
                st.markdown(prompt)
            response = str(agent.chat(prompt))
            with st.chat_message("assistant"):
                st.markdown(response)
        chat_store.persist(CONVERSATION_FILE)

在这一实践部分,我们将利用我们刚学到的知识进一步改进个人辅导项目。像任何专业的辅导员一样,渴望教导学生并回答他们的问题,PITS 应该有一个合适的对话引擎作为核心。它应该能够理解话题,了解当前的上下文,并跟踪与学生的整个互动。由于学习过程可能会通过多次会话进行,PITS 必须能够保存整个对话,并在启动新会话时恢复互动。我们将在 coversation_engine.py 中实现所有这些功能。该模块并不打算直接用于我们的应用架构中。相反,它将提供三个可调用的函数,我们将在稍后导入并在 training_UI.py 模块中使用:

  • load_chat_store:这个函数负责检索之前会话的聊天记录。我们使用了一个通用的 chat_store_key="0" 键。在多用户场景下,这个键可以用来在同一个聊天存储中存储不同用户的聊天记录。
  • initialize_chatbot:这个函数负责从存储中加载训练材料向量索引,定义基于索引的查询引擎工具,然后使用该工具初始化 OpenAIAgent。它还为代理提供了一个包含上下文信息的系统提示,描述了代理的目的、用户名和学习主题,以及当前幻灯片的内容。该函数返回初始化的代理,随后将由 chat_interface 使用以实现实际对话。
  • chat_interface:这个函数通过获取用户输入并从代理生成答案来实现持续对话。它还在每次交互后保存对话。如果用户结束当前会话,重新启动时对话将从该点继续。

一旦在主要的培训界面中实现,该聊天界面应类似于图 8.16 所示的样子。

image.png

让我们来看看代码。第一部分包含了所有必要的导入:

你会注意到在代码的第一部分,我们导入了很多组件。osjson 模块将用于聊天持久化功能。特定的 LlamaIndex 元素将用于实现具有所有必要组件的代理。

我们还从 global_settings.py 模块中导入了 INDEX_STORAGECONVERSATION_FILE 的位置。由于聊天对话将使用 Streamlit 实现,我们还必须导入 Streamlit 库。

接下来,让我们看看 load_chat_store 函数,它负责通过从由 CONVERSATION_FILE 指定的本地存储文件中加载聊天记录来恢复先前的对话:

如我们所见,load_chat_store 函数尝试从本地存储文件中检索对话历史。如果存储文件不存在,将创建一个新的空 chat_store。该函数返回 chat_store

下一个函数负责在 Streamlit 界面中显示整个对话历史:

display_messages 函数接受一个聊天存储和一个 Streamlit 容器作为参数。它使用 get_messages() 从聊天存储中提取所有消息。该函数遍历并显示聊天存储中的每条消息,为每条消息分配适当的角色——用户或助手。

这些消息通过 Streamlit 的 chat_message() 方法在 Streamlit 容器中显示,该方法具有自动为每个角色添加相应图标的优点。

下一个函数负责初始化代理。此函数接受五个参数:

  • user_name:用户的名字——以提供更个人化的体验。
  • study_subject:学习材料所涵盖的主题。
  • chat_store:用于初始化对话历史。
  • container:这是将显示聊天对话的 Streamlit 容器。该函数本身不会使用此参数,而是将其传递给 display_messages 函数。
  • context:这是在培训界面中显示的当前幻灯片的内容。该上下文将被输入到代理的系统提示中,以使答案基于用户的当前上下文。

让我们看看函数的第一部分:

在这里,我们为代理定义了一个 ChatMemoryBuffer 对象,指定了包含对话历史的 chat_store 属性。我们使用了之前相同的 chat_store_key。这很重要,以便代理可以正确检索聊天历史。

接下来,我们将准备代理的工具:

在这里,我们首先使用 StorageContext 对象和 load_index_from_storage() 方法检索了我们的向量索引。我们需要指定索引的 ID——vector——因为在我们的情况下,存储中包含多个索引。

加载索引后,我们创建了一个简单的查询引擎,配置了 similarity_top_k=3,然后创建了一个 QueryEngineTool 工具,提供了适当的描述以便代理能够理解其目的和用法。top-k 相似度参数设置为 3,以从索引中检索三条最相关的信息。

下一部分将初始化 OpenAIAgent

在前面的代码中,我们初始化了 OpenAIAgent,并将 QueryEngineToolmemorysystem_prompt 作为参数提供。这个提示用于向 LLM 提供背景信息,以使其回答具有上下文性,确保与当前讨论话题和用户的学习需求相关。

正如你所看到的,我尽量将代码保持简单。许多地方可以在实现中改进。初始化代理后,我们调用 display_messages 显示现有对话。

我们的最后一个函数负责处理实际对话。它接受三个参数:

  • agent:将用于运行聊天的代理引擎
  • chat_store:用于持久化对话的 chat_store 参数
  • container:消息将显示在其中的 Streamlit 容器

让我们看看代码:

chat_interface 函数使用 Streamlit 的 chat_input() 方法显示一个聊天输入小部件。接收到输入后,它执行以下操作:

  • 将用户的问题添加到指定容器中的聊天界面
  • 调用 OpenAIAgentchat 方法处理问题并生成响应
  • 将聊天机器人的响应添加到指定容器中的聊天界面
  • 使用聊天存储的 persist 方法将新对话持久化到 CONVERSATION_FILE,以确保会话之间的连续性

就这些了。我们将在接下来的几章中讨论 PITS 的更多功能。

总结

本章深入探讨了如何使用 LlamaIndex 构建聊天机器人和代理。我们介绍了 ChatEngine 用于对话跟踪以及不同的内置聊天模式,如简单模式、上下文模式、精简问题模式和精简加上下文模式。

接着,我们探讨了不同的代理架构和策略,包括 OpenAIAgentReActAgent 和更高级的 LLMCompiler 代理。我们解释了关键概念,如工具、工具协调、推理循环和并行执行。

本章最后通过实践实现了 PITS 教学应用的对话跟踪功能。

总体而言,你现在应该对如何利用 LlamaIndex 的功能创建有用且引人入胜的对话界面有了全面的理解。

在接下来的章节中,我们将探索如何自定义我们的 RAG 管道,并提供一个简单的指南,介绍如何使用 Streamlit 部署它。我们还将探讨高级跟踪方法以实现无缝调试,并揭示评估我们应用程序的策略。