01. LCEL 与 AgentExecutor 的局限性

LangChain 提供的 LCEL 表达式,虽然可以很便捷的创建链应用,并且将 知识库、LLM、prompt、工具调用、输出解析器 等内容串联起来,构成一个 有向无环图,例如下方这种结构

虽然已经极大降低了 LLM 应用的开发难度,但是链式应用在处理复杂、动态的对话流程时存在着一些局限性:

  1. 线性流程:链通常是线性的,这意味着它们只能按照预定义的顺序执行步骤,这种线性结构限制了在对话中进行动态路由和条件分支的能力。
  2. 状态管理:链在处理多轮对话时,状态管理变得非常复杂,每次调用链时,都需要手动传递和更新状态,增加了代码的复杂性和出错的可能性。
  3. 工具集成:虽然链可以调用外部工具,但在链的内部结构中集成和协调多个工具的使用并不直观,尤其是在需要根据对话上下文动态选择工具时。

例如在前面课时中学习的 问题分解策略-并行子问题优化策略 中,工具嵌套层级稍微多一些,链的构建就会变得很复杂(而且必须是无环结构才可以使用 LCEL 表达式构建),例如

# 分解问题链

decomposition_chain = (

        {"question": RunnablePassthrough()}

        | decomposition_prompt

        | ChatOpenAI(model="gpt-4o-mini", temperature=0)

        | StrOutputParser()

        | (lambda x: x.strip().split("\n"))

)

# 子问题答案生成链

sub_question_chain = (

        {"context": retriever, "question": RunnablePassthrough()}

        | sub_question_prompt

        | ChatOpenAI(model="gpt-4o-mini")

        | StrOutputParser()

)

# 组装链

chain = (

        {

            "question": RunnablePassthrough(),

            "context": decomposition_chain | {

                "questions": RunnablePassthrough(),

                "answers": sub_question_chain.map()

            } | RunnableLambda(format_qa_pairs)

        } | prompt | llm_output_str

)

AgentExecutor 的诞生解决了 LCEL 的部分缺陷,它允许智能体根据输入动态选择工具和操作,尽管 AgentExecutor 提供了一定的灵活性,但它也存在着一些局限性:

  1. 复杂性:AgentExecutor 的配置和使用相对复杂,尤其是在处理复杂的对话流程和多轮对话时,需要手动管理智能体的状态和工具调用,这增加了开发的难度。
  2. 动态路由:AgentExecutor 虽然支持动态选择工具,但在处理复杂的条件分支和动态路由时,仍然不够灵活。缺乏一种直观的方式来定义和执行复杂的对话流程。
  3. 状态持久性:AgentExecutor 在处理长时间运行的对话时,缺乏内置的状态持久性机制。每次对话重启时,都需要从头开始,无法恢复之前的对话状态。
  4. 过度封装:AgentExecutor 要求被包装的 Agent 必须符合特定的要求才能使用,例如 输入变量固定、输入prompt固定、解析器固定 等,想要二次开发 AgentExecutor 难度非常大。
  5. 黑盒不可控:在构建复杂 Agent 时无法修改工具的使用顺序,无法在执行过程中添加人机交互。

面对 LCEL链应用 与 AgentExecutor 的局限性,LangGraph 应运而生,LangGraph 的设计目标是解决这些局限性,提供一个更灵活、更强大的框架来构建复杂的智能体应用,在我们正式学习 LangGraph 之前,让我们先通过一个简单的概念来帮助大家认识 图 和 状态机。

02. 从“带娃状态图”认识"图"与"状态机"

图 和 状态机 这两个词看起来有点莫名其妙,为什么会和 LLM/Agent 应用开发扯上关系呢?其实图和状态机是计算机科学中非常重要的概念,在很多场合下都有它们的身影。

先来认识 状态机,可以将 状态机 就是一个 记录有限行为状态的盒子,例如一个婴儿的行为状态存在 饿/饱、睡觉/清醒、想晒太阳/想遮阴 等状态,我们可以定义一个 状态机 记录这些事件,例如

baby_status = {

    "饥饿程度": "饿",

    "是否睡觉": "清醒",

    "体感温度": "想遮阴",

}

而任何具有状态的事物,都有一个 初始状态,也有一个 最终状态,不同状态之间是可以通过 事件 进行转换的,并且 转换 和 事件 是确定性的,这意味着每个转换和事件总是指向相同的下一个状态,就像 哄宝宝入睡 这个 事件 在执行完成后,是否睡觉 这个状态肯定会变为 睡觉,你永远不会把 宝宝哄入睡 后,它还醒着,或者叫起床后,还在睡着,这就是 有限状态。

有了 状态、N个事件 之后,我们可以把相关联的 事件 连接起来,一个 事件 接受 状态 作为输入,同时也输出 状态,并将输出状态传递给连接的下一个事件,最后通过一定约束和条件设置好一个初始状态与最终状态,这样就构成了一张 图,在这张 Graph(图) 中,每个节点就是 事件,边就是连接 节点 与 节点 的桥梁(通信逻辑),而 状态 就是 节点 与 边 的输入模式。

例如,我们有以下假设:

  1. 以 婴儿的行为 作为 状态,涵盖了 是否需要喂奶、是否需要换尿布、是否涨肚、是否需要晒太阳、是否想玩游戏、是否需要安抚睡觉 等。
  2. 以 指导带娃的妈妈(知道该执行什么操作)、判断带娃是否正确的奶奶/外婆(可以判断操作是否合理)、执行带娃的爸爸(执行操作) 作为 事件节点。
  3. 由妈妈下命令带娃(判断婴儿需要什么),奶奶/外婆检测妈妈的指挥是否正确进行判断,由爸爸执行命令,直到带娃成功(婴儿不哭不闹),整个流程结束。

有上述的 3 个假设后,我们就可以基于这些信息构建一个 状态图,如下:

在上述的状态图中,妈妈 主要负责决定需要执行什么事件,例如 换尿布、游戏互动,亦或者做出决定 不用带娃 了;老大人 主要对妈妈做的事件决定进行判断,检测是否可执行;而 爸爸 主要负责执行具体的事件,并且执行完特定事件后(更新婴儿状态),由 妈妈 检测 婴儿状态 继续做决定,直到符合特定的状态,整个带娃流程结束。

学习到这里,其实你就已经通过 带娃状态图 理解 图 和 状态机 究竟是什么了,一个 状态 经过特定的 节点 处理后,会得到一个符合需求的 最终状态,有没有发现和 LLM/Agent 应用很接近,对于一个 Agent 应用来说,我们提出一个 初始状态(需求),让 Agent 经过一系列的复杂操作得到一个 最终状态(答案),简化下整个流程,其实就变成了:

而利用 LangGraph 就可以快速构建流程图中 图结构 的部分,对比 LCEL 链应用和 AgentExecutor,图结构的优点也非常多:

  1. 图结构:LangGraph 采用图(Graph)结构来表示对话流程,允许开发者定义复杂的非线性流程和条件分支。这种图结构提供了更大的灵活性,使得动态路由和条件分支变得直观和简单。
  2. 状态管理:LangGraph 内置了强大的状态管理机制,可以无缝地管理多轮对话的状态。开发者无需手动传递和更新状态,框架会自动处理状态的持久化和恢复。
  3. 工具集成:LangGraph 简化了工具集成和使用,可以轻松地将多个工具集成到对话流程中,并根据对话上下文动态选择和调用工具。
  4. 持久性:LangGraph 提供了内置状态持久性机制,支持长时间运行的对话。开发者可以随时暂停和恢复对话,无需担心状态丢失。

并且 LangGraph 不是一个颠覆性的框架,利用 LangGraph 构建的组件仍然是一个 Runnable 可运行组件,所以它是 LCEL 的扩展,不仅可以单独使用,还可以和 LCEL 链应用、原始的 LangChain Runnable 可运行组件、丰富的大量第三方集成、甚至与其他图结构应用进行嵌套结合,从而让 LLM/Agent 应用的开发变得非常简单。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐