代理
“What is an agent?”
特别是那些充当推理引擎并与外部数据和计算源交互的应用程序,在LangChain中,可以构建工具来帮助开发人员构建LLM应用程序。这些系统通常被称为“代理”。
每个人对人工智能代理的定义似乎都略有不同。这个定义可能比大多数人更具技术性:AI代理是一个使用LLM来决定应用程序控制流的系统
即使在这里,也承认这个定义并不完美。人们通常认为代理是先进的、自主的、类人的——但对于一个LLM在两条不同路径之间路由的简单系统呢?这符合我的技术定义,但不符合人们对代理应该具备的能力的普遍看法。这很难准确定义什么是代理!
“与其争论哪些工作应该包括或排除为真正的人工智能代理,我们可以承认系统可以在不同程度上具有代理性。”例如,就像自动驾驶汽车具有不同程度的自主性一样,我们也可以将人工智能代理能力视为一个频率谱图。
如果被问及什么是代理时,可以转而讨论“代理性”的含义。
What does it mean to be agentic?
LLM在不同应用程序中的自主性
决定这一步的输出 | 决定获取哪一个步骤 | 决定获取可用的步骤 | ||
人为驱动 | 代码 | × | × | × |
调用LLM | √只能决定一步 | × | × | |
链 | √决定多步 | × | × | |
路由 | √ | √无法循环获取 | × | |
代理执行 | 状态机 | √ | √可以循环获取 | × |
自发的 | √ | √ | √ |
LLM越能决定系统的行为方式,系统就越“angentic”。
使用LLM将输入路由到特定的下游工作流中有一些少量的“代理”行为。这将属于上图中的路由器类别。
如果确实使用多个LLM来执行多个路由步骤?这将介于路由器和状态机之间
如果其中一个步骤是决定是继续还是完成——有效地允许系统在循环中运行直到完成?这属于状态机。
如果系统正在构建工具,记住这些工具,然后在未来的步骤中采用这些工具?属于更高的自主代理类别。
代理架构
许多LLM在调用之前/之后实现特定的步骤控制流。例如,RAG执行与用户问题相关的文档检索,并将这些文档传递给LLM,以便将模型的响应建立在提供的文档上下文中。
我们有时希望LLM能够选择自己的控制流来解决更复杂的问题,而不是硬编码固定的控制流!这是代理的一个定义:代理是一个使用LLM来决定应用程序控制流的系统。LLM可以通过多种方式控制应用程序:
- LLM可以在两条路径之间路由
- LLM可以在多个调用工具中决定使用哪个一个调用工具
- LLM可以决定生成的答案是否足够,或者是否需要更多的工作
因此,有许多不同类型的代理架构,它们为LLM提供了不同级别的控制。
https://langchain-ai.github.io/langgraph/concepts/img/agent_types.png
路由
LLM通过路由从一组指定的选项中选择一个步骤。这是一种代理架构,其控制水平相对有限,因为LLM通常专注于做出单一决策,并从有限的一组预定义选项中产生特定的输出。路由通常采用一些不同的概念来实现这一点。
结构化输出
LLM的结构化输出通过提供LLM在响应中应遵循的特定格式或模式来工作。这类似于工具调用,但更通用。虽然工具调用通常涉及选择和使用预定义函数,但结构化输出可用于任何类型的格式化响应。实现结构化输出的常见方法包括:
- 提示工程:指示LLM通过系统提示以特定格式进行响应。
- 输出解析器:使用后处理从LLM响应中提取结构化数据。
- 工具调用:利用一些LLM的内置工具调用功能来生成结构化输出。
结构化输出对于路由至关重要,因为它们确保LLM的决策能够被系统可靠地解释和执行。
工具调用代理
虽然路由允许LLM做出单一决策,但更复杂的代理架构通过两种方式扩展了LLM的控制:
- 多步骤决策:LLM可以一个接一个地做出一系列决策,而不仅仅是一个。
- 工具访问:LLM可以选择和使用各种工具来完成任务。
ReAct是一种流行的通用代理架构,它结合了这些扩展,集成了三个核心概念。[2210.03629] ReAct: Synergizing Reasoning and Acting in Language Models
- 工具调用:允许LLM根据需要选择和使用各种工具。
- 记忆:使代理能够保留和使用前面步骤中的信息。
- 计划:让LLM制定并遵循多步骤计划以实现目标 。
这种架构允许更复杂和灵活的代理行为,超越了简单的路由,实现了多步骤的动态问题解决。可以将其与create_react_agent一起使用。
工具调用
每当代理与外部系统交互时,工具都很有用。外部系统(如API)通常需要特定的输入模式或有效载荷,而不是自然语言。例如,当我们将API绑定为工具时,我们为模型提供所需输入模式的感知。模型将根据用户的自然语言输入选择调用工具,并返回符合工具所需模式的输出。
许多LLM提供程序支持工具调用,LangChain中的工具调用接口很简单:可以简单地将任何Python函数传递到ChatModel.bind_tools(函数)中。
https://langchain-ai.github.io/langgraph/concepts/img/tool_call.png
记忆
记忆对代理至关重要,使他们能够在解决问题的多个步骤中保留和利用信息。
- 短期记忆:允许代理访问在序列的早期步骤中获取的信息。
- 长期记忆:使代理能够回忆起以前交互中的信息,例如对话中的过去消息。
LangGraph提供对记忆的完全控制:
- 状态:用户定义的模式指定要保留的记忆的确切结构。
- 检查点:存储不同交互中每一步状态的机制
这种灵活的方法允许你根据特定的代理架构需求定制记忆系统。向图添加记忆指南详见How to add thread-level persistence to your graph
有效的记忆管理增强了代理维护上下文、从过去的经验中学习以及随着时间的推移做出更明智决策的能力。
计划
在ReAct架构中,LLM在while循环中被重复调用。在每一步中,代理都决定调用哪些工具,以及这些工具的输入应该是什么。然后执行这些工具,并将输出作为观察反馈到LLM中。当代理确定它有足够的信息来解决用户请求并且不值得再调用任何工具时,while循环终止。
ReAct(一篇论文)实现
ReAct与预构建的create_react_agent实现之间存在几个差异:
- 首先,create_react_agent使用工具调用让LLM调用工具,而ReAct使用提示+解析原始输出。这是因为撰写ReAct时不存在工具调用,但这通常更好、更可靠。
- 其次,我们使用消息来提示LLM,而ReAct使用了字符串格式。这是因为在撰写ReAct时,LLM甚至没有公开基于消息的接口,而现在这是它们公开的唯一接口。
- 第三,ReAct要求工具的所有输入都是一个字符串。这在很大程度上是由于LLM当时没有能力,只能真正生成一个输入。create_react_agent允许使用需要多个输入的工具。
- 第四,ReAct当时只着眼于调用单个工具,这主要是由于当时LLM性能的限制。create_react_agent的实现允许一次调用多个工具。
- 最后,ReAct要求LLM在决定调用哪些工具之前明确地生成一个“思考”步骤。这是“ReAct”的“推理”部分。create_react_agent的实现默认情况下不会这样做,主要是因为LLM已经变得更好了,但这并不是必要的。当然,如果你想提示它这样做,你当然可以。
自定义代理架构
虽然路由和工具调用代理(如ReAct)很常见,但定制代理架构通常会为特定任务带来更好的性能。LangGraph为构建定制的代理系统提供了几个强大的功能:
Human-in-the-loop
人工参与可以显著提高代理的可靠性,特别是对于敏感任务。这可能涉及:
- 提供反馈以更新代理的状态
- 批准具体行动
- 在复杂的决策过程中提供指导
当完全自动化不可行或不可取时,人为干预至关重要 Human-in-the-loop
并行
并行处理对于高效的多智能体系统和复杂任务至关重要。LangGraph通过其Send API支持并行化,从而实现:
- 多个状态的并发处理
- 实现map-reduce类似的操作How to create map-reduce branches for parallel execution
- 高效处理独立子任务
子图
子图对于管理复杂的代理架构至关重要,特别是在多代理系统中。
- 单个代理的隔离状态管理
- 代理团队组织的分层
- 控制代理与主系统之间通信
子图通过状态模式中的重叠键与父图通信。这实现了灵活的模块化代理设计How to use subgraphs
反射
反射机制可以通过以下方式显著提高代理的可靠性:
- 评估任务完成情况和正确性
- 为迭代改进提供反馈
- 实现自我纠正和学习
多代理系统
代理是一个使用LLM来决定应用程序控制流的系统。随着您开发这些系统,它们可能会随着时间的推移变得更加复杂,使其更难管理和扩展。可能会遇到以下问题:
- 代理有太多的工具可供使用,并且很难决定下一个调用应该调用哪个工具
- 上下文变得过于复杂,单个代理无法跟踪
- 系统中需要多个专业领域(例如规划师、研究员、数学专家等)
为了解决这些问题,可以考虑将应用程序分解为多个较小的独立代理,并将它们组合成一个多代理系统。这些独立代理可以像提示和LLM调用一样简单,也可以像ReAct代理一样复杂(甚至更多!)。
使用多代理系统的主要好处是:
- 模块化:独立的代理使开发、测试和维护代理系统变得更加容易。
- 专业化:您可以创建专注于特定领域的专家代理,这有助于提高整体系统性能。
- 控制:可以显式控制代理的通信方式(而不是依赖于函数调用)。
多代理架构
在多代理系统中,有以下几种方法可以连接代理:
- 网络:每个代理都可以与其他代理通信。任何代理都可以决定下一个呼叫哪个其他代理。
- 主管:每个代理与单个主管代理通信。指导代理决定下一个应该叫哪个代理。
- 主管(工具调用):这是主管架构的一个特例。单个代理可以表示为工具。在这种情况下,主管代理使用调用LLM的工具来决定调用哪个代理工具,以及传递给这些代理的参数。
- 区分等级:你可以定义一个多代理系统,在多个主管中还有有一个主管。这是主管架构的概括,允许更复杂的控制流。
- 自定义多代理工作流:每个代理只与代理的一个子集通信。部分流是确定的,只有一些代理可以决定下一步调用哪些其他代理。
切换(Handoffs)
在多代理架构中,代理可以表示为图节点。每个代理节点执行其步骤,并决定是完成执行还是路由到另一个代理,包括可能路由到自身(例如,在循环中运行)。多代理交互中的一种常见模式是切换,其中一个代理将控制权交给另一个代理。切换可以明确规定:
- 目的地:要导航到的目标代理(例如,要转到的节点的名称)
- 荷载:要传递给该代理的信息(例如,状态更新)
要在LangGraph中实现切换,代理节点可以返回Command对象,该对象允许您组合控制流和状态更新:
def agent(state) -> Command[Literal["agent", "another_agent"]]:
# the condition for routing/halting can be anything, e.g. LLM tool call / structured output, etc.
goto = get_next_agent(...) # 'agent' / 'another_agent'
return Command(
# Specify which agent to call next
goto=goto,
# Update the graph state
update={"my_state_key": "my_state_value"}
)
在更复杂的场景中,每个代理节点本身都是一个图(即子图),其中一个代理子图中的节点可能希望导航到不同的代理。例如,如果你有两个代理,alice和bob(父图中的子图节点),并且alice需要导航到bob,你可以在Command对象中设置graph=Command.PARENT:
def some_node_inside_alice(state):
return Command(
goto="bob",
update={"my_state_key": "my_state_value"},
# specify which graph to navigate to (defaults to the current graph)
graph=Command.PARENT,
)
注意:如果你需要支持使用Command(graph=Command.PARENT)进行通信的子图,你需要将它们包装在一个带有Command的节点函数中,例如:
builder.add_node(alice)
def call_alice(state) -> Command[Literal["bob"]]:
return alice.invoke(state)
builder.add_node("alice", call_alice)
使用工具切换
最常见的代理类型之一是调用代理的ReAct风格的工具。对于这些类型的代理,一种常见的模式是在工具调用中包装切换,例如:
def transfer_to_bob(state):
"""Transfer to bob."""
return Command(
goto="bob",
update={"my_state_key": "my_state_value"},
graph=Command.PARENT,
)
这是从工具更新图形状态的一种特殊情况,除了状态更新外,还包括控制流。
如果你想使用返回Command的工具,你可以使用预构建的create_react_agent/ToolNode组件,也可以实现自己的工具执行节点,该节点收集工具返回的Command对象并返回它们的列表,例如:
def call_tools(state):
...
commands = [tools_by_name[tool_call["name"]].invoke(tool_call) for tool_call in tool_calls]
return commands
网络
在这种架构中,代理被定义为图节点。每个代理都可以与其他代理(多对多连接)通信,并可以决定下一个调用哪个代理。这种架构适用于没有明确的代理层次结构或调用代理的特定顺序的问题。
from typing import Literal
from langchain_openai import ChatOpenAI
from langgraph.types import Command
from langgraph.graph import StateGraph, MessagesState, START, END
model = ChatOpenAI()
def agent_1(state: MessagesState) -> Command[Literal["agent_2", "agent_3", END]]:
# you can pass relevant parts of the state to the LLM (e.g., state["messages"])
# to determine which agent to call next. a common pattern is to call the model
# with a structured output (e.g. force it to return an output with a "next_agent" field)
response = model.invoke(...)
# route to one of the agents or exit based on the LLM's decision
# if the LLM returns "__end__", the graph will finish execution
return Command(
goto=response["next_agent"],
update={"messages": [response["content"]]},
)
def agent_2(state: MessagesState) -> Command[Literal["agent_1", "agent_3", END]]:
response = model.invoke(...)
return Command(
goto=response["next_agent"],
update={"messages": [response["content"]]},
)
def agent_3(state: MessagesState) -> Command[Literal["agent_1", "agent_2", END]]:
...
return Command(
goto=response["next_agent"],
update={"messages": [response["content"]]},
)
builder = StateGraph(MessagesState)
builder.add_node(agent_1)
builder.add_node(agent_2)
builder.add_node(agent_3)
builder.add_edge(START, "agent_1")
network = builder.compile()
主管
在这种架构中,我们将代理定义为节点,并添加一个主管节点(LLM)来决定接下来应该调用哪些代理节点。我们使用Command根据主管的决定将执行路由到适当的代理节点。这种架构也非常适合并行运行多个代理或使用map-reduce模式。
from typing import Literal
from langchain_openai import ChatOpenAI
from langgraph.types import Command
from langgraph.graph import StateGraph, MessagesState, START, END
model = ChatOpenAI()
def supervisor(state: MessagesState) -> Command[Literal["agent_1", "agent_2", END]]:
# you can pass relevant parts of the state to the LLM (e.g., state["messages"])
# to determine which agent to call next. a common pattern is to call the model
# with a structured output (e.g. force it to return an output with a "next_agent" field)
response = model.invoke(...)
# route to one of the agents or exit based on the supervisor's decision
# if the supervisor returns "__end__", the graph will finish execution
return Command(goto=response["next_agent"])
def agent_1(state: MessagesState) -> Command[Literal["supervisor"]]:
# you can pass relevant parts of the state to the LLM (e.g., state["messages"])
# and add any additional logic (different models, custom prompts, structured output, etc.)
response = model.invoke(...)
return Command(
goto="supervisor",
update={"messages": [response]},
)
def agent_2(state: MessagesState) -> Command[Literal["supervisor"]]:
response = model.invoke(...)
return Command(
goto="supervisor",
update={"messages": [response]},
)
builder = StateGraph(MessagesState)
builder.add_node(supervisor)
builder.add_node(agent_1)
builder.add_node(agent_2)
builder.add_edge(START, "supervisor")
supervisor = builder.compile()
主管(工具调用)
在这种监督程序架构变体中,我们将单个代理定义为工具,并在监督程序节点中使用调用LLM的工具。这可以实现为具有两个节点的ReAct风格的代理——一个LLM节点(主管)和一个执行工具的工具调用节点(在本例中为代理)。
from typing import Annotated
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import InjectedState, create_react_agent
model = ChatOpenAI()
# this is the agent function that will be called as tool
# notice that you can pass the state to the tool via InjectedState annotation
def agent_1(state: Annotated[dict, InjectedState]):
# you can pass relevant parts of the state to the LLM (e.g., state["messages"])
# and add any additional logic (different models, custom prompts, structured output, etc.)
response = model.invoke(...)
# return the LLM response as a string (expected tool response format)
# this will be automatically turned to ToolMessage
# by the prebuilt create_react_agent (supervisor)
return response.content
def agent_2(state: Annotated[dict, InjectedState]):
response = model.invoke(...)
return response.content
tools = [agent_1, agent_2]
# the simplest way to build a supervisor w/ tool-calling is to use prebuilt ReAct agent graph
# that consists of a tool-calling LLM node (i.e. supervisor) and a tool-executing node
supervisor = create_react_agent(model, tools)
区分等级
随着在系统中添加更多代理,主管可能很难管理所有代理。主管可能会开始对下一个呼叫哪个代理做出糟糕的决定,或者上下文可能变得过于复杂,以至于单个主管无法跟踪。换句话说,您最终会遇到最初推动多代理架构的相同问题。
为了解决这个问题,您可以分层设计您的系统。例如,您可以创建由单个主管管理的独立、专门的代理团队,以及一个管理团队的顶级主管。
from typing import Literal
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.types import Command
model = ChatOpenAI()
# define team 1 (same as the single supervisor example above)
def team_1_supervisor(state: MessagesState) -> Command[Literal["team_1_agent_1", "team_1_agent_2", END]]:
response = model.invoke(...)
return Command(goto=response["next_agent"])
def team_1_agent_1(state: MessagesState) -> Command[Literal["team_1_supervisor"]]:
response = model.invoke(...)
return Command(goto="team_1_supervisor", update={"messages": [response]})
def team_1_agent_2(state: MessagesState) -> Command[Literal["team_1_supervisor"]]:
response = model.invoke(...)
return Command(goto="team_1_supervisor", update={"messages": [response]})
team_1_builder = StateGraph(Team1State)
team_1_builder.add_node(team_1_supervisor)
team_1_builder.add_node(team_1_agent_1)
team_1_builder.add_node(team_1_agent_2)
team_1_builder.add_edge(START, "team_1_supervisor")
team_1_graph = team_1_builder.compile()
# define team 2 (same as the single supervisor example above)
class Team2State(MessagesState):
next: Literal["team_2_agent_1", "team_2_agent_2", "__end__"]
def team_2_supervisor(state: Team2State):
...
def team_2_agent_1(state: Team2State):
...
def team_2_agent_2(state: Team2State):
...
team_2_builder = StateGraph(Team2State)
...
team_2_graph = team_2_builder.compile()
# define top-level supervisor
builder = StateGraph(MessagesState)
def top_level_supervisor(state: MessagesState) -> Command[Literal["team_1_graph", "team_2_graph", END]]:
# you can pass relevant parts of the state to the LLM (e.g., state["messages"])
# to determine which team to call next. a common pattern is to call the model
# with a structured output (e.g. force it to return an output with a "next_team" field)
response = model.invoke(...)
# route to one of the teams or exit based on the supervisor's decision
# if the supervisor returns "__end__", the graph will finish execution
return Command(goto=response["next_team"])
builder = StateGraph(MessagesState)
builder.add_node(top_level_supervisor)
builder.add_node("team_1_graph", team_1_graph)
builder.add_node("team_2_graph", team_2_graph)
builder.add_edge(START, "top_level_supervisor")
builder.add_edge("team_1_graph", "top_level_supervisor")
builder.add_edge("team_2_graph", "top_level_supervisor")
graph = builder.compile()
自定义多代理工作流
在这种架构中,我们将单个代理添加为图节点,并在自定义工作流中定义提前调用代理的顺序。在LangGraph中,工作流可以通过两种方式定义:
- 显式控制流(正常边):LangGraph允许通过正常的图边显式定义应用程序的控制流(即代理如何通信的顺序)。这是上述架构中最具确定性的变体——我们总是知道下一个代理将被提前调用。
- 动态控制流(命令):在LangGraph中,允许LLM决定应用程序控制流的各个部分。这可以通过使用命令来实现。一个特殊的例子是监督工具调用架构。在这种情况下,调用LLM的工具将决定调用工具(代理)的顺序。
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, START
model = ChatOpenAI()
def agent_1(state: MessagesState):
response = model.invoke(...)
return {"messages": [response]}
def agent_2(state: MessagesState):
response = model.invoke(...)
return {"messages": [response]}
builder = StateGraph(MessagesState)
builder.add_node(agent_1)
builder.add_node(agent_2)
# define the flow explicitly
builder.add_edge(START, "agent_1")
builder.add_edge("agent_1", "agent_2")
代理之间的通讯
构建多代理系统时最重要的是弄清楚代理是如何通信的。有几个不同的考虑因素:
- 代理是通过图状态还是通过工具调用进行通信?
- 如果两个代理具有不同的状态模式怎么办?
- 如何通过共享消息列表进行通信?
图状态 vs 工具调用
代理之间传递的“有效载荷”是什么?在上述讨论的大多数架构中,代理通过图状态进行通信。对于具有工具调用的主管,有效载荷是工具调用参数。
为了通过图状态进行通信,需要将单个代理定义为图节点。这些可以作为函数或整个子图添加。在图执行的每个步骤中,代理节点接收图的当前状态,执行代理代码,然后将更新的状态传递给下一个节点。通常,代理节点共享单个状态模式。但是,你可能希望设计具有不同状态模式的代理节点。
不同的状态模式
代理可能需要与其他代理具有不同的状态模式。例如,搜索代理可能只需要跟踪查询和检索到的文档。在LangGraph中有两种方法可以实现这一点:
- 使用单独的状态模式定义子图代理。如果子图和父图之间没有共享的状态键(通道),则添加输入/输出转换以使父图知道如何与子图通信非常重要。
- 使用与整体图状态模式不同的私有输入状态模式定义代理节点函数。这允许传递仅执行特定代理所需的信息。
共享消息列表
代理最常见的通信方式是通过共享状态通道,通常是消息列表。这假设代理共享的状态中始终至少有一个通道(密钥)。当通过共享消息列表进行通信时,还有一个额外的考虑因素:代理应该共享其思维过程的完整历史,还是只共享最终结果?
共享所有历史
代理可以与所有其他代理共享其思维过程的完整历史(即“草稿”)。这个“草稿栏”通常看起来像一个消息列表。共享完整思维过程的好处是,它可能有助于其他智能体做出更好的决策,并提高整个系统的推理能力。缺点是,随着代理数量及其复杂性的增加,“草稿”将迅速增长,可能需要额外的记忆管理策略。
共享最终结果
代理可以拥有自己的私人“草稿栏”,并且只能与其他代理共享最终结果。这种方法可能更适合具有许多代理或更复杂的代理的系统。在这种情况下,你需要定义具有不同状态模式的代理。
对于称为工具的代理,主管根据工具模式确定输入。此外,LangGraph允许在运行时将状态传递给各个工具,因此如果需要,从属代理可以访问父状态。
断点
断点在特定点暂停图执行,并允许逐步执行。断点由LangGraph的持久层提供支持,该层在每个图步骤后保存状态。尽管建议为此使用中断函数,断点也可用于启用人在循环工作流。
必要条件
- 指定一个检查指针,以便在每个步骤后保存图形状态。
- 设置断点以指定执行应暂停的位置。
- 使用线程ID运行图以在断点处暂停执行。
- 使用invoke/ainvoke/stream/astream恢复执行
设置断点
有两个地方可以设置断点:
- 在编译时或运行时通过设置断点来执行节点之前或之后。我们称这些为静态断点。
- 在节点内部使用NodeInterrupt Exeception。
静态断点
静态断点在节点执行之前或之后触发。您可以通过在“编译”时或运行时指定interrupt_before和interrupt_after来设置静态断点。
graph = graph_builder.compile(
interrupt_before=["node_a"],
interrupt_after=["node_b", "node_c"],
checkpointer=..., # Specify a checkpointer
)
thread_config = {
"configurable": {
"thread_id": "some_thread"
}
}
# Run the graph until the breakpoint
graph.invoke(inputs, config=thread_config)
# Optionally update the graph state based on user input
graph.update_state(update, config=thread_config)
# Resume the graph
graph.invoke(None, config=thread_config)
如果您想一次逐步执行一个节点的图,或者想在特定节点暂停图的执行,静态断点对于调试特别有用。
NodeInterrupt exception
如果你试图实现人工介入工作流,我们建议你使用中断函数而不是NodeInterrupt exception。中断功能更易于使用,也更灵活。
开发人员触发断点必须满足的一些条件。当开发人员希望在特定条件下停止时,动态断点非常有用。可以使用NodeInterrupt,这是一种特殊类型的异常,可以根据某些条件从节点内引发。例如,我们可以定义一个动态断点,当输入超过5个字符时触发。
def my_node(state: State) -> State:
if len(state['input']) > 5:
raise NodeInterrupt(f"Received input that is longer than 5 characters: {state['input']}")
return state
假设我们使用一个触发动态断点的输入运行图,然后通过为输入传递None来尝试恢复图的执行。
# Attempt to continue the graph execution with no change to state after we hit the dynamic breakpoint
for event in graph.stream(None, thread_config, stream_mode="values"):
print(event)
图形将再次中断,因为此节点将以相同的图形状态重新运行。我们需要更改图状态,使触发动态断点的条件不再满足。因此,我们可以简单地将图形状态编辑为符合动态断点条件(<5个字符)的输入,然后重新运行节点。
# Update the state to pass the dynamic breakpoint
graph.update_state(config=thread_config, values={"input": "foo"})
for event in graph.stream(None, thread_config, stream_mode="values"):
print(event)
或者,如果我们想保留当前输入并跳过执行检查的节点(my_node)呢?为此,我们可以简单地使用as_node=“my_node”执行图更新,并为值传递None。这将不会更新图形状态,而是以my_node运行更新,有效地跳过节点并绕过动态断点。
# This update will skip the node `my_node` altogether
graph.update_state(config=thread_config, values=None, as_node="my_node")
for event in graph.stream(None, thread_config, stream_mode="values"):
print(event)
Human-in-the-loop
从LangGraph 0.2.57开始,设置断点的推荐方法是使用中断函数,因为它简化了human-in-the-loop。
Human-in-the-loop(或“on-the-loop”)工作流将人工输入集成到自动化流程中,允许在关键阶段进行决策、验证或纠正。这在基于LLM的应用程序中特别有用,在这些应用程序中,底层模型可能会偶尔产生不准确之处。在合规性、决策或内容生成等低容错场景中,人工参与通过允许审查、纠正或覆盖模型输出来确保可靠性。
Use cases
- 审查工具调用:在工具执行之前,人类可以审查、编辑或批准LLM请求的工具调用。
- 验证LLM输出:人类可以审查、编辑或批准LLM生成的内容。
- 提供上下文:使LLM能够明确地请求人工输入以澄清或提供更多细节,或支持多回合对话。
interrupt
LangGraph中的interrupt函数通过在特定节点暂停图、向人类呈现信息以及用他们的输入恢复图,实现了human-in-the-loop的工作流程。此功能对于审批、编辑或收集其他输入等任务非常有用。中断函数与Command对象结合使用,以使用人类提供的值恢复图。
from langgraph.types import interrupt
# state:当前图的状态,通常是一个字典,包含图中各个节点产生的数据
def human_node(state: State):
value = interrupt(
# 接受state中的“some_text”信息,作为传递给外界的上下文
{
"text_to_revise": state["some_text"]
}
)
# 将外部系统的输入(value),更新到state的“some_text”
return {
"some_text": value
}
graph = graph_builder.compile(
# interrupt依赖于checkpointer,如果图被中断,checkpointer确保恢复时能够从正确的状态继续执行
checkpointer=checkpointer
)
# 运行图,直到被打断
thread_config = {"configurable": {"thread_id": "some_id"}}
graph.invoke(some_input, config=thread_config)
# 恢复图的执行,value_from_human是外部系统(用户)提供的输入
graph.invoke(Command(resume=value_from_human), config=thread_config)
注意:interrupt既强大又符合人体工程学。然而,尽管它们在开发人员体验方面可能类似于Python的input()函数,但重要的是要注意,它们不会从中断点自动恢复执行。相反,他们重新运行使用中断的整个节点。因此,中断通常最好放置在节点的开头或专用节点中。详细见本文后续。
下面是在图中完整使用interrupt的例子:
from typing import TypedDict
import uuid
from langgraph.checkpoint.memory import MemorySaver
from langgraph.constants import START
from langgraph.graph import StateGraph
from langgraph.types import interrupt, Command
class State(TypedDict):
some_text: str
def human_node(state: State):
value = interrupt(
{
"text_to_revise": state["some_text"]
}
)
return {
"some_text": value
}
graph_builder = StateGraph(State)
graph_builder.add_node("human_node", human_node)
graph_builder.add_edge(START, "human_node")
checkpointer = MemorySaver()
graph = graph_builder.compile(
checkpointer=checkpointer
)
thread_config = {"configurable": {"thread_id": uuid.uuid4()}}
# 打印中断信息
for chunk in graph.stream({"some_text": "Original text"}, config=thread_config):
print(chunk)
# 恢复中断
for chunk in graph.stream(Command(resume="Edited text"), config=thread_config):
print(chunk)
{'__interrupt__': (
Interrupt(
value={'question': 'Please revise the text', 'some_text': 'Original text'},
resumable=True,
ns=['human_node:10fe492f-3688-c8c6-0d0a-ec61a43fecd6'],
when='during'
),
)
}
{'human_node': {'some_text': 'Edited text'}}
Requirements
- 指定一个检查指针,以便在每个步骤后保存图形状态
- 在适当的位置调用interrupt()。
- 使用线程ID运行图
- 使用invoke/ainvoke/stream/astream恢复执行
设计模式
-
批准或拒绝:在关键步骤(如API调用)之前暂停图形,以审查和批准操作。如果操作被拒绝,您可以阻止图执行该步骤,并可能采取其他操作。这种模式通常涉及根据人类的输入对图进行路由。
-
编辑图状态:暂停图以查看和编辑图状态。这对于纠正错误或用其他信息更新状态非常有用。这种模式通常涉及用人的输入更新状态。
-
获取输入:在图中的特定步骤明确请求人工输入。这对于收集额外的信息或上下文以告知代理的决策过程或支持多回合对话非常有用
批准或拒绝
在关键步骤(如API调用)之前暂停图形,以检查和批准操作。如果操作被拒绝,您可以阻止图形执行该步骤,并可能采取其他操作。
from typing import Literal
from langgraph.types import interrupt, Command
def human_approval(state: State) -> Command[Literal["some_node", "another_node"]]:
is_approved = interrupt(
{
"question": "Is this correct?",
"llm_output": state["llm_output"]
}
)
if is_approved:
return Command(goto="some_node")
else:
return Command(goto="another_node")
graph_builder.add_node("human_approval", human_approval)
graph = graph_builder.compile(checkpointer=checkpointer)
thread_config = {"configurable": {"thread_id": "some_id"}}
graph.invoke(Command(resume=True), config=thread_config)
查看和编辑图状态
from langgraph.types import interrupt
def human_editing(state: State):
...
result = interrupt(
{
"task": "Review the output from the LLM and make any necessary edits.",
"llm_generated_summary": state["llm_generated_summary"]
}
)
return {
"llm_generated_summary": result["edited_text"]
}
graph_builder.add_node("human_editing", human_editing)
graph = graph_builder.compile(checkpointer=checkpointer)
...
thread_config = {"configurable": {"thread_id": "some_id"}}
graph.invoke(
Command(resume={"edited_text": "The edited text"}),
config=thread_config
)
审查工具调用
def human_review_node(state) -> Command[Literal["call_llm", "run_tool"]]:
human_review = interrupt(
{
"question": "Is this correct?",
# Surface tool calls for review
"tool_call": tool_call
}
)
review_action, review_data = human_review
# Approve the tool call and continue
if review_action == "continue":
return Command(goto="run_tool")
# Modify the tool call manually and then continue
elif review_action == "update":
...
updated_msg = get_updated_msg(review_data)
# Remember that to modify an existing message you will need
# to pass the message with a matching ID.
return Command(goto="run_tool", update={"messages": [updated_message]})
# Give natural language feedback, and then pass that back to the agent
elif review_action == "feedback":
...
feedback_msg = get_feedback_msg(review_data)
return Command(goto="call_llm", update={"messages": [feedback_msg]})
多回合对话
多回合对话涉及代理和人类之间的多次来回交互,这可以让代理以对话的方式从人类那里收集额外的信息。
这种设计模式在由多个代理组成的LLM应用程序中很有用。一个或多个代理可能需要与人类进行多轮对话,其中人类在对话的不同阶段提供输入或反馈。为简单起见,下面的代理实现被示为单个节点,但实际上它可能是由多个节点组成的更大图的一部分,并包括条件边。
在这种模式中,每个代理都有自己的人工节点来收集用户输入。这可以通过用唯一的名称命名人类节点或使用子图来实现,其中子图包含人类节点和代理节点。
from langgraph.types import interrupt
def human_input(state: State):
human_message = interrupt("human_input")
return {
"messages": [
{
"role": "human",
"content": human_message
}
]
}
def agent(state: State):
# Agent logic
...
graph_builder.add_node("human_input", human_input)
graph_builder.add_edge("human_input", "agent")
graph = graph_builder.compile(checkpointer=checkpointer)
# After running the graph and hitting the interrupt, the graph will pause.
# Resume it with the human's input.
graph.invoke(
Command(resume="hello!"),
config=thread_config
)
验证人的输入
如果你需要在图本身(而不是客户端)验证人类提供的输入,你可以通过在单个节点内使用多个中断调用来实现。
from langgraph.types import interrupt
def human_node(state: State):
"""Human node with validation."""
question = "What is your age?"
while True:
answer = interrupt(question)
# Validate answer, if the answer isn't valid ask for input again.
if not isinstance(answer, int) or answer < 0:
question = f"'{answer} is not a valid age. What is your age?"
answer = None
continue
else:
# If the answer is valid, we can proceed.
break
print(f"The human in the loop is {answer} years old.")
return {
"age": answer
}
The Command primitive
使用中断功能时,图形将在中断时暂停并等待用户输入。
可以使用Command primitive恢复图的执行,可以通过invoke、ainvoke、stream或astream方法传递。
Command primitive提供了几个选项,用于在恢复过程中控制和修改图的状态:
1、向中断传递一个值:使用Command(resume=value)向图提供数据,如用户的响应。执行从使用中断的节点开始恢复,但是,这次interrupt(…)调用将返回命令中传递的值(resume=value),而不是暂停图。
# Resume graph execution with the user's input.
graph.invoke(Command(resume={"age": "25"}), thread_config)
2、更新图形状态:使用命令修改图形状态(Update=Update)。请注意,恢复从使用中断的节点的开头开始。执行从使用中断的节点开始恢复,但状态已更新。
# Update the graph state and resume.
# You must provide a `resume` value if using an `interrupt`.
graph.invoke(Command(update={"foo": "bar"}, resume="Let's go!!!"), thread_config)
通过利用Command,您可以恢复图的执行,处理用户输入,并动态调整图的状态。
使用invoke和ainvoke
当你使用流或astream运行图形时,您将收到一个中断事件,通知中断已被触发。
invoke和ainvoke不返回中断信息。要访问此信息,必须在调用invoke或ainvoke后使用get_state方法检索图状态
# Run the graph up to the interrupt
result = graph.invoke(inputs, thread_config)
# Get the graph state to get interrupt information.
state = graph.get_state(thread_config)
# Print the state values
print(state.values)
# Print the pending tasks
print(state.tasks)
# Resume the graph with the user's input.
graph.invoke(Command(resume={"age": "25"}), thread_config)
{'foo': 'bar'} # State values
(
PregelTask(
id='5d8ffc92-8011-0c9b-8b59-9d3545b7e553',
name='node_foo',
path=('__pregel_pull', 'node_foo'),
error=None,
interrupts=(Interrupt(value='value_in_interrupt', resumable=True, ns=['node_foo:5d8ffc92-8011-0c9b-8b59-9d3545b7e553'], when='during'),), state=None,
result=None
),
) # Pending tasks. interrupts
如何从中断中恢复?
注意:在中断中恢复与Python的input()函数不同,在Python中,从input()函数的指定的位置恢复。
使用中断的一个关键方面是理解恢复是如何工作的。当在中断后恢复执行时,图执行将从触发最后一个中断的图节点的开头开始。
从节点开始到中断的所有代码都将被重新执行。
counter = 0
def node(state: State):
# All the code from the beginning of the node to the interrupt will be re-executed
# when the graph resumes.
global counter
counter += 1
print(f"> Entered the node: {counter} # of times")
# Pause the graph and wait for user input.
answer = interrupt()
print("The value of counter is:", counter)
...
恢复后,计数器将再次递增,产生以下输出:
> Entered the node: 2 # of times
The value of counter is: 2
常见的陷阱
副作用
将带有副作用的代码(如API调用)放在中断后以避免重复,因为每次恢复节点时都会重复这些副作用。
1、中断前的副作用
当节点从中断中恢复时,此代码将在另一时间重新执行API调用。
如果API调用不是幂等的,或者只是代价高昂,那么这可能是有问题的。
from langgraph.types import interrupt
def human_node(state: State):
"""Human node with validation."""
api_call(...) # This code will be re-executed when the node is resumed.
answer = interrupt(question)
2、中断后的副作用
from langgraph.types import interrupt
def human_node(state: State):
"""Human node with validation."""
answer = interrupt(question)
api_call(answer) # OK as it's after the interrupt
3、单独节点中的副作用
from langgraph.types import interrupt
def human_node(state: State):
"""Human node with validation."""
answer = interrupt(question)
return {
"answer": answer
}
def api_call_node(state: State):
api_call(...) # OK as it's in a separate node
子图被函数调用
当将子图作为函数调用时,父图将从调用子图的节点(以及触发中断的节点)的开头恢复执行。同样,子图将从调用interrupt()函数的节点的开头恢复。
def node_in_parent_graph(state: State):
some_code() # 当子图恢复时,重新执行
# 子图中包含中断
subgraph_result = subgraph.invoke(some_input)
...
假设我们有一个有3个节点的父图:
父图:node_1→node_2(子图调用)→node_3
子图有3个节点,其中第二个节点包含一个中断:
子图:sub_node_1→sub_node_2(中断)→sub_node_3
恢复图时,执行将按如下方式进行:
- 跳过父图中的node_1(已执行,图状态已保存在快照中)。
- 从头开始重新执行父图中的node_2。
- 跳过子图中的sub_node_1(已执行,图状态已保存在快照中)。
- 从头开始重新执行子图中的sub_node_2。
- 继续使用sub_node_3和后续节点。
这里是一个简化的示例代码,您可以使用它来理解子图如何处理中断。它计算每个节点被输入的次数并打印计数。
import uuid
from typing import TypedDict
from langgraph.graph import StateGraph
from langgraph.constants import START
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import MemorySaver
class State(TypedDict):
"""The graph state."""
state_counter: int
counter_node_in_subgraph = 0
def node_in_subgraph(state: State):
"""A node in the sub-graph."""
global counter_node_in_subgraph
counter_node_in_subgraph += 1 # This code will **NOT** run again!
print(f"Entered `node_in_subgraph` a total of {counter_node_in_subgraph} times")
counter_human_node = 0
def human_node(state: State):
global counter_human_node
counter_human_node += 1 # This code will run again!
print(f"Entered human_node in sub-graph a total of {counter_human_node} times")
answer = interrupt("what is your name?")
print(f"Got an answer of {answer}")
checkpointer = MemorySaver()
subgraph_builder = StateGraph(State)
subgraph_builder.add_node("some_node", node_in_subgraph)
subgraph_builder.add_node("human_node", human_node)
subgraph_builder.add_edge(START, "some_node")
subgraph_builder.add_edge("some_node", "human_node")
subgraph = subgraph_builder.compile(checkpointer=checkpointer)
counter_parent_node = 0
def parent_node(state: State):
"""This parent node will invoke the subgraph."""
global counter_parent_node
counter_parent_node += 1 # This code will run again on resuming!
print(f"Entered `parent_node` a total of {counter_parent_node} times")
# Please note that we're intentionally incrementing the state counter
# in the graph state as well to demonstrate that the subgraph update
# of the same key will not conflict with the parent graph (until
subgraph_state = subgraph.invoke(state)
return subgraph_state
builder = StateGraph(State)
builder.add_node("parent_node", parent_node)
builder.add_edge(START, "parent_node")
# A checkpointer must be enabled for interrupts to work!
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {
"configurable": {
"thread_id": uuid.uuid4(),
}
}
for chunk in graph.stream({"state_counter": 1}, config):
print(chunk)
print('--- Resuming ---')
for chunk in graph.stream(Command(resume="35"), config):
print(chunk)
# ------------print---------------
Entered `parent_node` a total of 1 times
Entered `node_in_subgraph` a total of 1 times
Entered human_node in sub-graph a total of 1 times
{'__interrupt__': (Interrupt(value='what is your name?', resumable=True, ns=['parent_node:4c3a0248-21f0-1287-eacf-3002bc304db4', 'human_node:2fe86d52-6f70-2a3f-6b2f-b1eededd6348'], when='during'),)}
--- Resuming ---
Entered `parent_node` a total of 2 times
Entered human_node in sub-graph a total of 2 times
Got an answer of 35
{'parent_node': {'state_counter': 1}}
使用多次中断
在单个节点内使用多个中断有助于验证人类输入。但是,如果不小心处理,在同一节点中使用多个中断可能会导致意外行为。
当一个节点包含多个中断调用时,LangGraph会保留一个特定于执行该节点的任务的恢复值列表。每当执行恢复时,它都会从节点的开头开始。对于遇到的每个中断,LangGraph都会检查任务的恢复列表中是否存在匹配的值。匹配严格基于索引,因此节点内中断调用的顺序至关重要。
为了避免问题,请避免在执行之间动态更改节点的结构。这包括添加、删除或重新排序中断调用,因为这些更改可能会导致索引不匹配。这些问题通常源于非传统模式,例如通过命令更改状态(resume=…,update=SOME_state_MOTATION)或依赖全局变量动态修改节点的结构。
import uuid
from typing import TypedDict, Optional
from langgraph.graph import StateGraph
from langgraph.constants import START
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import MemorySaver
class State(TypedDict):
"""The graph state."""
age: Optional[str]
name: Optional[str]
def human_node(state: State):
if not state.get('name'):
name = interrupt("what is your name?")
else:
name = "N/A"
if not state.get('age'):
age = interrupt("what is your age?")
else:
age = "N/A"
print(f"Name: {name}. Age: {age}")
return {
"age": age,
"name": name,
}
builder = StateGraph(State)
builder.add_node("human_node", human_node)
builder.add_edge(START, "human_node")
# A checkpointer must be enabled for interrupts to work!
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {
"configurable": {
"thread_id": uuid.uuid4(),
}
}
for chunk in graph.stream({"age": None, "name": None}, config):
print(chunk)
for chunk in graph.stream(Command(resume="John", update={"name": "foo"}), config):
print(chunk)
{'__interrupt__': (Interrupt(value='what is your name?', resumable=True, ns=['human_node:3a007ef9-c30d-c357-1ec1-86a1a70d8fba'], when='during'),)}
Name: N/A. Age: John
{'human_node': {'age': 'John', 'name': 'N/A'}}