15. 使用 LangGraph 构建你的第一个图
在第四部分中,我们定义了工具,将它们连接到 LLM,并完成了一个反复执行"决策-执行"循环的代理(agent)。驱动循环、在 LLM 请求时运行工具、判断何时停止 — 这些流程的每一部分都是我们手动编写的。
在本章中,我们将以一种完全不同的方式构建同样的代理。我们不再直接编写流程,而是向 LangGraph 框架注册步骤(节点)和连接规则(边),并让它来处理执行。行为与第 14 章完全相同,但构建的方式发生了变化。
本章涵盖四个核心概念 — StateGraph、节点(node)、边(edge)和状态(State) — 然后将第 14 章的代理循环重构为 LangGraph 图。在后续章节中,第 16 章介绍条件路由和预构建组件,第 17 章介绍让代理从中断处恢复的状态持久化。
15.1) 为什么使用图?
15.1.1) 现有代理循环的局限性
让我们回顾一下第 14 章的代理循环。去掉错误处理和其他细节后,核心结构如下所示:
# 第 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 章的代理循环表示为图后的样子:
矩形框是节点,箭头是边。菱形表示一个条件边,它根据条件分支到不同的路径。
在第 14 章中,整个工作流都存在于 for 循环、if 检查和其他手写代码中。使用 LangGraph,你定义每个节点做什么,并用边将节点连接起来。简而言之,你从编写工作流转变为将其声明为结构。
LangGraph 不会取代你在第 12–14 章中学到的任何东西。工具定义、bind_tools()、tool_calls、ToolMessage — 所有这些仍然在节点内部使用,与之前完全一样。
下一节将逐一介绍 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 中介绍细节。
from langgraph.graph import StateGraph, MessagesState
builder = StateGraph(MessagesState)添加节点
使用 add_node() 注册一个节点。节点是一个 Python 函数,它接收当前 State 并返回它想要更改的部分。我们将在 15.2.3 中详细介绍节点函数。
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 是边的终点。START 和 END 是图的入口点和出口点的特殊标记。我们将在 15.2.4 中介绍边。
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 值。
graph = builder.compile()
initial_state = {"messages": [{"role": "user", "content": "hi!"}]}
result = graph.invoke(initial_state)现在让我们把这一切组合起来,构建一个简单的图:
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() 时,图按照 START → say_hello → END 的顺序运行。say_hello 返回了一个以 messages 为键的字典,该值被追加到 MessagesState 中的 messages 列表。我们将在 15.2.2 中深入探讨这是如何工作的。结论是,取出最后一条消息的内容,我们得到 "hello world"。
15.2.2) State:流经图的数据
State 是图中每个节点共享的数据。当一个节点运行时,它接收当前 State,完成它的工作,然后只返回它想要更改的部分。LangGraph 将这些更改合并回 State,并将更新后的版本交给下一个节点。
定义 State
你通过继承 TypedDict 来定义 State。选择与你的代理需要跟踪的内容相匹配的字段和类型。以下是一个简单的例子:
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 中将归约器指定为第二个参数:
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。以下是它底层的样子:
class MessagesState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]与我们刚才介绍的结构相同 — 一个已经连接好 add_messages 归约器的 messages 字段。
如果你需要额外的字段,只需继承它:
from langgraph.graph import MessagesState
class AgentState(MessagesState):
llm_calls: int # 默认覆盖行为15.2.3) 节点:更新 State 的函数
节点是在图内部执行单个特定任务的 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:
builder.add_node(say_hello) # 函数名 "say_hello" 变为节点名
builder.add_node("my_node", my_func) # 你也可以显式指定名称15.2.4) 边:连接节点的规则
边决定了"这个节点完成后,接下来运行什么?"。边有两种类型。
普通边
普通边在两个节点之间连接一个固定的"跳转"。使用 add_edge(source, target) — source 是起始节点,target 是目标节点。
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 并返回下一个节点名称的函数:
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 的请求执行,循环反复进行直到完成。唯一改变的是我们如何构建这个流程。
以下是完成后的图的样子:
图在 llm_call 和 tool_node 之间循环,直到 LLM 停止请求工具调用,此时退出到 END。让我们一步步构建它。
15.3.1) 定义 State
我们继承 15.2.2 中的 MessagesState 来定义代理的 State。messages 字段从 MessagesState 继承而来,我们添加一个 llm_calls 字段来跟踪 LLM 调用的次数。
from langgraph.graph import MessagesState
class AgentState(MessagesState):
llm_calls: int # LLM 调用次数(覆盖)messages 列表将按顺序累积用户输入(HumanMessage)、LLM 响应(AIMessage)和工具执行结果(ToolMessage)。
15.3.2) 构建节点
首先,让我们设置第 14 章的工具和模型:
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 并返回响应:
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 中现有的 messages。llm_calls 返回当前计数加 1,覆盖之前的值。
tool_node 节点 — 执行 LLM 请求的工具并返回结果:
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 中相同的模式:
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 并编译:
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 返回最终答案而不是请求另一个工具调用,此时循环退出。
让我们运行它:
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 中:
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() 会将图结构直接渲染为图像并显示在单元格输出中。
from IPython.display import Image, display
display(Image(agent.get_graph().draw_mermaid_png()))在终端环境中,则将它保存为 PNG 文件。
agent.get_graph().draw_mermaid_png(output_file_path="agent_graph.png")生成的图像:
实线是普通边,虚线是条件边。这个图表是根据代码自动生成的。
15.3.6) 递归限制
正如我们在第 14 章中使用 max_steps 来防止无限循环一样,LangGraph 也有一个内置的安全网。在图执行期间每次节点运行时,一个内部计数器都会加 1。当该计数器超过配置的限制时,LangGraph 会抛出 GraphRecursionError。
要了解计数是如何工作的,请看之前的运行。调用 get_weather 和 calculate 时按以下顺序访问节点:
llm_call(1) → tool_node(2) → llm_call(3) → tool_node(4) → llm_call(5) → END
总共访问了 5 个节点。如果你将 recursion_limit 设置为 3,限制会在第 3 次访问时生效,运行被提前中断:
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} 来设置限制。合适的值取决于你的用例和图的复杂性。从一个宽裕的数字开始,并通过测试来调整它。