Python & AI Tutorials Logo
LangChain & LangGraph

15. 使用 LangGraph 构建你的第一个图

在第四部分中,我们定义了工具,将它们连接到 LLM,并完成了一个反复执行"决策-执行"循环的代理(agent)。驱动循环、在 LLM 请求时运行工具、判断何时停止 — 这些流程的每一部分都是我们手动编写的。

在本章中,我们将以一种完全不同的方式构建同样的代理。我们不再直接编写流程,而是向 LangGraph 框架注册步骤(节点)连接规则(边),并让它来处理执行。行为与第 14 章完全相同,但构建的方式发生了变化。

本章涵盖四个核心概念 — StateGraph节点(node)边(edge)状态(State) — 然后将第 14 章的代理循环重构为 LangGraph 图。在后续章节中,第 16 章介绍条件路由和预构建组件,第 17 章介绍让代理从中断处恢复的状态持久化。

15.1) 为什么使用图?

15.1.1) 现有代理循环的局限性

让我们回顾一下第 14 章的代理循环。去掉错误处理和其他细节后,核心结构如下所示:

python
# 第 14 章代理循环 — 核心结构(简化版)
messages = [
    SystemMessage(content="你是一个有帮助的助手。"),
    HumanMessage(content=user_input),
]
 
for step in range(max_steps):
    # 请求 LLM 决定下一步操作
    ai_message = llm_with_tools.invoke(messages)
    messages.append(ai_message)
 
    # 如果没有请求工具调用,返回最终答案
    if not ai_message.tool_calls:
        return ai_message.content
 
    # 执行请求的工具
    for tool_call in ai_message.tool_calls:
        selected_tool = tool_map[tool_call["name"]]
        tool_message = selected_tool.invoke(tool_call)
        messages.append(tool_message)

这段代码仅涵盖基础功能,别无其他。但在真实的生产环境中,需要的东西要多得多。以下是几个例子。

  • 崩溃恢复 — 如果一个代理在 10 步研究任务的第 7 步崩溃,它应该能够从第 7 步恢复,而不是从头开始。
  • 审批请求 — 在代理执行关键操作之前,它应该能够暂停并询问人类"可以继续吗?"
  • 实时监控 — 用户应该能够看到代理当前正在做什么以及它正在调用哪些工具。
  • 可视化和调试 — 应该有一个展示代理如何运作的图表,这样当问题出现时,你就能追踪出是哪一步出了错。

自己实现这些功能并非不可能,但也不容易。仅崩溃恢复一项就需要编写代码来序列化每一步的状态、保存到磁盘、恢复它,并在准确的位置继续。你最终可能会写出比业务逻辑还多的基础设施代码。

LangGraph 就是为了在框架层面提供这些功能而构建的。崩溃恢复、审批请求、监控、可视化 — 框架都能处理这些。但有一个要求:你必须以框架能够理解的结构来构建你的代理。

第 14 章的代理循环直接处理所有逻辑,因此框架没有可以介入的地方。要利用 LangGraph 提供的功能,我们需要以 LangGraph 能够理解的结构 — 一个 — 来重新构建代理。这就是本章要讲的内容。

15.1.2) 什么是 LangGraph?

LangGraph 是一个编排框架,它将代理工作流定义并执行为。这里的图指的是一种结构,其中代理执行的每个节点(步骤)通过边(连接规则)相互连接。

在 LangGraph 中,你将工作流拆分成独立的节点,并用边将它们连接起来。然后 LangGraph 遍历这张图,沿途执行每个节点。以下是第 14 章的代理循环表示为图后的样子:

START

LLM 调用

请求工具调用?

工具执行

END

矩形框是节点,箭头是。菱形表示一个条件边,它根据条件分支到不同的路径。

在第 14 章中,整个工作流都存在于 for 循环、if 检查和其他手写代码中。使用 LangGraph,你定义每个节点做什么,并用边将节点连接起来。简而言之,你从编写工作流转变为将其声明为结构。

LangGraph 不会取代你在第 12–14 章中学到的任何东西。工具定义、bind_tools()tool_callsToolMessage — 所有这些仍然在节点内部使用,与之前完全一样。

下一节将逐一介绍 LangGraph 的核心组件 — StateGraph、State、节点和边。

15.2) LangGraph 组件:StateGraph、State、节点、边

本节将逐一讲解 LangGraph 的四个核心组件。我们将从 StateGraph 开始 — 这个类将 State、节点和边捆绑成一张图 — 然后介绍它内部的各个部分(State、节点、边)。

15.2.1) StateGraph

StateGraph 是在 LangGraph 中用于构建图的类。你指定图将管理的 State,添加节点,用边连接它们,然后编译(compile)以生成可执行的图。

让我们看看它是如何工作的。

创建 StateGraph 实例

调用 StateGraph 构造函数来创建实例。你需要将 State 模式(类本身)作为参数传入。这里我们使用 MessagesState,这是 LangGraph 提供的用于管理消息列表的预定义 State。我们将在 15.2.2 中介绍细节。

python
from langgraph.graph import StateGraph, MessagesState
 
builder = StateGraph(MessagesState)

添加节点

使用 add_node() 注册一个节点。节点是一个 Python 函数,它接收当前 State 并返回它想要更改的部分。我们将在 15.2.3 中详细介绍节点函数。

python
def say_hello(state: MessagesState):
    return {"messages": [{"role": "ai", "content": "hello world"}]}
 
builder.add_node(say_hello)    # 节点名变为 "say_hello"

连接边

使用 add_edge(source, target) 连接节点。source 是边的起点;target 是边的终点。STARTEND 是图的入口点和出口点的特殊标记。我们将在 15.2.4 中介绍边。

python
from langgraph.graph import START, END
 
builder.add_edge(START, "say_hello")   # 图开始 → 运行 say_hello
builder.add_edge("say_hello", END)     # say_hello 完成 → 结束图

编译和运行

调用 compile() 会验证图结构并生成一个可执行对象。你通过 invoke() 运行编译后的图,并传入初始 State 值。

python
graph = builder.compile()
 
initial_state = {"messages": [{"role": "user", "content": "hi!"}]}
result = graph.invoke(initial_state)

现在让我们把这一切组合起来,构建一个简单的图:

python
from langgraph.graph import StateGraph, MessagesState, START, END
 
def say_hello(state: MessagesState):
    return {"messages": [{"role": "ai", "content": "hello world"}]}
 
builder = StateGraph(MessagesState)
 
builder.add_node(say_hello)
builder.add_edge(START, "say_hello")
builder.add_edge("say_hello", END)
 
graph = builder.compile()
 
initial_state = {"messages": [{"role": "user", "content": "hi!"}]}
result = graph.invoke(initial_state)
print(result["messages"][-1].content)

输出:

hello world

当你调用 invoke() 时,图按照 STARTsay_helloEND 的顺序运行。say_hello 返回了一个以 messages 为键的字典,该值被追加到 MessagesState 中的 messages 列表。我们将在 15.2.2 中深入探讨这是如何工作的。结论是,取出最后一条消息的内容,我们得到 "hello world"

15.2.2) State:流经图的数据

State 是图中每个节点共享的数据。当一个节点运行时,它接收当前 State,完成它的工作,然后只返回它想要更改的部分。LangGraph 将这些更改合并回 State,并将更新后的版本交给下一个节点。

定义 State

你通过继承 TypedDict 来定义 State。选择与你的代理需要跟踪的内容相匹配的字段和类型。以下是一个简单的例子:

python
from typing_extensions import TypedDict
 
class AgentState(TypedDict):
    messages: list       # 消息列表
    llm_calls: int       # LLM 调用次数

从这里开始,你在创建 StateGraph 时传入 AgentState,并将其用作节点函数的类型提示。

归约器(Reducer)

当一个节点返回一个值时,相应的 State 字段会被更新。默认行为是覆盖 — 如果一个节点返回 {"llm_calls": 3},那么 llm_calls 就会变成 3,无论它之前是什么。

但有些字段需要追加,而不是覆盖。如果 messages 被覆盖会发生什么?每次节点返回一条新消息时,整个对话历史都会消失。对于 messages,追加才是正确的行为。

LangGraph 允许你通过归约器(reducer)函数为每个字段设置不同的更新策略。你在 Annotated 中将归约器指定为第二个参数:

python
from typing_extensions import TypedDict, Annotated
from langgraph.graph.message import add_messages
 
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]   # 归约器:追加
    llm_calls: int                            # 无归约器:覆盖

add_messages 是 LangGraph 提供的一个归约器。它不替换列表,而是将新消息追加到已有的内容中。因为这个归约器设置在 messages 字段上,所以节点为 messages 返回的任何值都会被追加。llm_calls 没有归约器,所以返回的值只会覆盖之前的内容。

这正是为什么 15.2.1 中 say_hello 返回的消息被追加messages 而不是替换它 — 归约器处理了这件事。

MessagesState

LangGraph 自带一个名为 MessagesState 的预定义 State。以下是它底层的样子:

python
class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

与我们刚才介绍的结构相同 — 一个已经连接好 add_messages 归约器的 messages 字段。

如果你需要额外的字段,只需继承它:

python
from langgraph.graph import MessagesState
 
class AgentState(MessagesState):
    llm_calls: int  # 默认覆盖行为

15.2.3) 节点:更新 State 的函数

节点是在图内部执行单个特定任务的 Python 函数。

python
def say_hello(state: MessagesState):
    return {"messages": [{"role": "ai", "content": "hello world"}]}

编写节点函数时需要了解两件事:

规则 1:它接收当前 State 作为参数。 LangGraph 在运行节点时传入当前的 State 对象。

规则 2:它只返回想要更改的部分,而不是完整的 State。 节点不直接修改 State。只需返回你想要更新的字段,LangGraph 就会根据每个字段的归约器规则将它们合并到现有的 State 中。

使用 add_node() 将节点添加到 StateGraph:

python
builder.add_node(say_hello)          # 函数名 "say_hello" 变为节点名
builder.add_node("my_node", my_func) # 你也可以显式指定名称

15.2.4) 边:连接节点的规则

决定了"这个节点完成后,接下来运行什么?"。边有两种类型。

普通边

普通边在两个节点之间连接一个固定的"跳转"。使用 add_edge(source, target)source 是起始节点,target 是目标节点。

python
builder.add_edge(START, "say_hello")       # 图开始时,运行 say_hello
builder.add_edge("say_hello", "llm_call")  # say_hello 之后,运行 llm_call
builder.add_edge("llm_call", END)          # llm_call 之后,结束图

条件边

条件边在运行时根据当前 State 选择下一个节点。使用 add_conditional_edges(source, routing_function)source 是起始节点,routing_function 是一个接收当前 State 并返回下一个节点名称的函数:

python
from langgraph.graph import END
 
def should_continue(state: AgentState):
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tool_node"   # 请求了工具调用 → 前往 tool_node
    return END               # 没有工具调用 → 结束
 
builder.add_conditional_edges("llm_call", should_continue)

add_conditional_edges("llm_call", should_continue) 告诉 LangGraph:"当 llm_call 完成时,调用 should_continue 来决定接下来运行什么。" 如果最后一条消息有 tool_calls,should_continue 就路由到 "tool_node",否则路由到 END。实际上,这意味着当 LLM 请求工具调用时,图会继续前往工具执行节点,而当它不请求时,图就终止。

现在我们已经介绍了全部四个组件,下一节将使用它们把第 14 章的代理循环重构为 LangGraph 图。

15.3) 将代理循环重构为图

让我们使用 LangGraph 重建第 14 章的代理循环。行为与第 14 章完全相同 — LLM 做决策,工具根据 LLM 的请求执行,循环反复进行直到完成。唯一改变的是我们如何构建这个流程。

以下是完成后的图的样子:

START

llm_call

请求工具调用?

tool_node

END

图在 llm_calltool_node 之间循环,直到 LLM 停止请求工具调用,此时退出到 END。让我们一步步构建它。

15.3.1) 定义 State

我们继承 15.2.2 中的 MessagesState 来定义代理的 State。messages 字段从 MessagesState 继承而来,我们添加一个 llm_calls 字段来跟踪 LLM 调用的次数。

python
from langgraph.graph import MessagesState
 
class AgentState(MessagesState):
    llm_calls: int    # LLM 调用次数(覆盖)

messages 列表将按顺序累积用户输入(HumanMessage)、LLM 响应(AIMessage)和工具执行结果(ToolMessage)。

15.3.2) 构建节点

首先,让我们设置第 14 章的工具和模型:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import ToolMessage
from langchain.tools import tool
 
@tool
def get_weather(city: str) -> str:
    """获取某个城市的当前天气。"""
    fake_data = {"Tokyo": "18°C, cloudy", "Cairo": "31°C, sunny"}
    return fake_data.get(city, f"No weather data for {city}.")
 
@tool
def calculate(expression: str) -> str:
    """计算一个简单的算术表达式。示例: '3 * 21'。"""
    return str(eval(expression))  # 警告:eval() 存在安全风险。请勿在生产环境中使用。
 
tools = [get_weather, calculate]
tool_map = {t.name: t for t in tools}
 
llm = ChatOpenAI(model="gpt-5-mini")
model_with_tools = llm.bind_tools(tools)

现在让我们编写两个节点函数。

llm_call 节点 — 调用 LLM 并返回响应:

python
def llm_call(state: AgentState):
    """调用 LLM 并返回响应。"""
    response = model_with_tools.invoke(state["messages"])
    return {
        "messages": [response],
        "llm_calls": state.get("llm_calls", 0) + 1,
    }

model_with_tools.invoke() 调用 LLM,响应被打包在返回字典中的 messages 键下。归约器将它追加到 AgentState 中现有的 messagesllm_calls 返回当前计数加 1,覆盖之前的值。

tool_node 节点 — 执行 LLM 请求的工具并返回结果:

python
def tool_node(state: AgentState):
    """执行 LLM 请求的工具。"""
    last_message = state["messages"][-1]
    results = []
    for tool_call in last_message.tool_calls:
        selected_tool = tool_map[tool_call["name"]]
        tool_message = selected_tool.invoke(tool_call)
        results.append(tool_message)
    return {"messages": results}

因为 tool_node 总是紧接着 llm_call 运行,所以 messages 中的最后一条消息保证是 LLM 刚刚生成的 AIMessage。那条消息的 tool_calls 字段包含 LLM 请求的工具调用。节点运行每个工具,将结果收集到 results 中,并将它们在 messages 键下返回 — 归约器会负责将它们追加到现有列表中。

15.3.3) 条件边

一旦 llm_call 完成,我们需要一个条件边来决定是运行 tool_node 还是结束图。这遵循 15.2.4 中相同的模式:

python
from typing import Literal
from langgraph.graph import END
 
def should_continue(state: AgentState) -> Literal["tool_node", "__end__"]:
    """决定是执行工具还是结束图。"""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tool_node"
    return END

如果存在 last_message.tool_calls,说明 LLM 正在请求工具调用,所以我们路由到 tool_node。否则,我们路由到 END,图就终止。

Literal["tool_node", "__end__"] 返回类型提示声明了此函数可能返回的目的地。LangGraph 需要这个提示才能在图可视化中正确绘制条件边路径。它对运行时行为没有影响。

"__end__"END 底层的字符串值。因为 Literal 只接受字符串字面量,所以我们写 "__end__" 而不是 END

15.3.4) 组装和运行图

是时候把一切连接起来了。让我们将 State、节点和条件边组装成一个 StateGraph 并编译:

python
from langgraph.graph import StateGraph, START, END
 
builder = StateGraph(AgentState)
 
builder.add_node("llm_call", llm_call)
builder.add_node("tool_node", tool_node)
 
builder.add_edge(START, "llm_call")                         # 开始 → llm_call
builder.add_conditional_edges("llm_call", should_continue)  # llm_call → tool_node 或 END
builder.add_edge("tool_node", "llm_call")                   # tool_node → llm_call(循环)
 
agent = builder.compile()

tool_node 回到 llm_call 的边创建了一个循环。执行会持续循环,直到 LLM 返回最终答案而不是请求另一个工具调用,此时循环退出。

让我们运行它:

python
from langchain_core.messages import HumanMessage
 
result = agent.invoke({
    "messages": [HumanMessage(content="获取开罗的气温,然后将这个数字乘以 3。")],
    "llm_calls": 0,
})
 
print(result["messages"][-1].content)
print(f"\nTotal LLM calls: {result['llm_calls']}")

输出:

开罗当前气温:31°C。乘以 3 = 93。
 
Total LLM calls: 3

代理调用了 get_weather("Cairo"),看到结果后,调用了 calculate("31 * 3"),并生成了最终答案 — 与我们在第 14 章中得到的结果相同。

检查完整的消息历史,可以看到每一步都按顺序记录在 messages 中:

python
for message in result["messages"]:
    message.pretty_print()

输出:

================================ Human Message =================================
获取开罗的气温,然后将这个数字乘以 3。
================================== Ai Message ==================================
Tool Calls:
  get_weather (call_DiL9WF)
  Args:
    city: Cairo
================================= Tool Message =================================
Name: get_weather
31°C, sunny
================================== Ai Message ==================================
Tool Calls:
  calculate (call_wa6RqWST)
  Args:
    expression: 31 * 3
================================= Tool Message =================================
Name: calculate
93
================================== Ai Message ==================================
开罗当前气温:31°C。乘以 3 = 93。

15.3.5) 图可视化

在 Jupyter notebook 中,agent.get_graph().draw_mermaid_png() 会将图结构直接渲染为图像并显示在单元格输出中。

python
from IPython.display import Image, display
 
display(Image(agent.get_graph().draw_mermaid_png()))

在终端环境中,则将它保存为 PNG 文件。

python
agent.get_graph().draw_mermaid_png(output_file_path="agent_graph.png")

生成的图像:

__start__

llm_call

tool_node

__end__

实线是普通边,虚线是条件边。这个图表是根据代码自动生成的。

15.3.6) 递归限制

正如我们在第 14 章中使用 max_steps 来防止无限循环一样,LangGraph 也有一个内置的安全网。在图执行期间每次节点运行时,一个内部计数器都会加 1。当该计数器超过配置的限制时,LangGraph 会抛出 GraphRecursionError

要了解计数是如何工作的,请看之前的运行。调用 get_weathercalculate 时按以下顺序访问节点:

llm_call(1) → tool_node(2) → llm_call(3) → tool_node(4) → llm_call(5) → END

总共访问了 5 个节点。如果你将 recursion_limit 设置为 3,限制会在第 3 次访问时生效,运行被提前中断:

python
from langgraph.errors import GraphRecursionError
 
try:
    result = agent.invoke(
        {"messages": [HumanMessage(content="获取开罗的气温,然后将这个数字乘以 3。")],
         "llm_calls": 0},
        config={"recursion_limit": 3},
    )
except GraphRecursionError:
    print("代理达到了递归限制 — 停止执行。")

输出:

代理达到了递归限制 — 停止执行。

通过向 invoke() 传递 config={"recursion_limit": number} 来设置限制。合适的值取决于你的用例和图的复杂性。从一个宽裕的数字开始,并通过测试来调整它。