LangGraph 入门实战(3)

LangGraph 进阶实战:并行分支与 Annotated 状态聚合

在简单的线性工作流中,一个节点执行完成后通常只会进入下一个节点。但真实业务往往需要把一个任务拆成多个分支同时处理,例如并行查询多个数据源、同时调用多个工具,或让多个 Agent 分别完成不同任务。

本文通过一个可直接运行的 LangGraph 示例,演示以下能力:

  • 使用多条普通边创建并行分支;
  • 使用 Annotated 为状态字段绑定 reducer;
  • 使用 operator.add 聚合多个节点返回的列表;
  • 编译并执行 StateGraph;
  • 将图结构导出为 PNG 图片;
  • 理解 thread_id 与 checkpointer 的关系。

整个示例不调用大模型,不需要 API Key,适合用来理解 LangGraph 的状态更新机制。

1. 最终流程结构

本文构建的图可以表示为:

text 复制代码
START -> step1 -> step2 -> step3 -> END
                          -> step4 -> END

step2 有两条出边,因此执行完 step2 后,step3step4 都会被调度执行。这是一种 fan-out(扇出)结构,而不是"二选一"的条件分支。

实际生成的流程图如下:

2. 运行环境

本文代码已经在以下环境中实际运行:

text 复制代码
Python 3.14.7
LangGraph 1.2.10

建议为项目创建独立虚拟环境:

bash 复制代码
python -m venv .venv
source .venv/bin/activate
python -m pip install -U langgraph typing-extensions

Windows PowerShell 的虚拟环境激活命令为:

powershell 复制代码
.venv\Scripts\Activate.ps1

3. 为什么需要 reducer

先看本例的状态定义:

python 复制代码
import operator
from typing import Annotated, Any

from typing_extensions import TypedDict


class State(TypedDict):
    aggregate: Annotated[list[Any], operator.add]

State 中只有一个字段 aggregate,它负责记录所有节点的执行结果。

Annotated 的基本结构是:

python 复制代码
Annotated[字段类型, reducer]

本例中的两个参数分别表示:

  • list[Any]aggregate 是一个列表;
  • operator.add:新值写入时,使用加法运算合并旧值和新值。

对于列表,operator.add 等价于列表拼接:

python 复制代码
operator.add(["step1"], ["step2"])

# 结果
["step1", "step2"]

LangGraph 会把 reducer 看作下面这样的函数:

python 复制代码
def reducer(current_value, new_value):
    return merged_value

因此,节点不需要读取并复制完整列表。每个节点只需返回自己产生的新内容,LangGraph 会负责合并。

4. 编写节点

示例一共定义了四个节点。

step1

python 复制代码
def step1(state: State):
    print("这是step1")
    return {"aggregate": ["step1"]}

step1 返回一个只包含 step1 的列表。初始状态中的 aggregate 是空列表,因此 reducer 的计算过程为:

python 复制代码
[] + ["step1"]

执行后得到:

python 复制代码
["step1"]

step2

python 复制代码
def step2(state: State):
    print("这是step2")
    return {"aggregate": ["step2"]}

step2 完成后,状态更新过程为:

python 复制代码
["step1"] + ["step2"]

结果为:

python 复制代码
["step1", "step2"]

step3 和 step4

python 复制代码
def step3(state: State):
    print("step3")
    return {"aggregate": ["step3"]}


def step4(state: State):
    print("step4")
    return {"aggregate": ["step4"]}

这两个节点会在 step2 之后进入同一个执行阶段。它们都更新 aggregate,因此 reducer 会把两个节点的结果合并到已有列表中。

需要注意:并行节点的控制台打印先后顺序不应该被当作业务执行顺序。本文的实际输出先打印 step3,再打印 step4,但业务逻辑不应依赖并行任务的日志顺序。如果两个步骤必须严格按顺序执行,就应该在它们之间添加边。

5. 注册节点

首先使用 State 创建图构建器:

python 复制代码
graph_builder = StateGraph(State)

然后注册四个节点:

python 复制代码
graph_builder.add_node(step1).add_node(step2).add_node(step3).add_node(step4)

这里没有手动指定节点名称,因此 LangGraph 会使用函数名作为节点名:

text 复制代码
step1
step2
step3
step4

add_node() 返回图构建器本身,所以可以进行链式调用。

6. 使用多条边创建并行分支

线性部分的边如下:

python 复制代码
graph_builder.add_edge(START, "step1")
graph_builder.add_edge("step1", "step2")

它们表示程序从 START 进入 step1,然后继续执行 step2

关键代码是从 step2 出发的两条边:

python 复制代码
graph_builder.add_edge("step2", "step3")
graph_builder.add_edge("step2", "step4")

普通节点拥有多条出边时,LangGraph 会调度所有目标节点。因此这里不是根据条件选择 step3step4,而是两个节点都会执行。

如果业务需求是"根据状态选择其中一条路径",应该使用 add_conditional_edges(),而不是为同一节点添加多条普通边。

示例显式地把 step4 连接到 END

python 复制代码
graph_builder.add_edge("step4", END)

step3 没有后继节点,当前版本会将它作为终止分支,并在生成的结构图中连接到 END。为了让代码意图更加明确,实际项目中可以同时添加:

python 复制代码
graph_builder.add_edge("step3", END)

7. 编译工作流

节点和边定义完成后,调用 compile() 生成可执行图:

python 复制代码
graph = graph_builder.compile()

构建阶段只负责描述图结构。编译后的 graph 才能调用 invoke()stream()、图结构查看和检查点等运行能力。

8. 生成流程图

通过 get_graph() 获取可视化结构,再调用 draw_mermaid_png() 渲染 PNG:

python 复制代码
png_bytes = graph.get_graph().draw_mermaid_png()

with open("langgraph_chain.png", "wb") as f:
    f.write(png_bytes)

运行后,当前目录会生成:

text 复制代码
langgraph_chain.png

draw_mermaid_png() 默认可能需要访问 Mermaid 渲染服务。如果执行逻辑正常,但图片生成失败,可以优先检查网络连接,或在受限环境中配置本地 Mermaid 渲染方式。

9. 执行图

示例使用空列表作为初始状态:

python 复制代码
res = graph.invoke(
    {"aggregate": []},
    {"configurable": {"thread_id": "1"}},
)
print("执行结果:", res)

invoke() 的第一个参数是输入状态,第二个参数是运行配置。

这里传入了:

python 复制代码
{"configurable": {"thread_id": "1"}}

thread_id 通常用于区分不同会话的检查点状态。不过当前代码在 compile() 时没有配置 checkpointer:

python 复制代码
graph = graph_builder.compile()

因此,本例中的 thread_id 不会让状态自动持久化。只有在编译图时接入检查点存储后,它才会用于定位对应线程的历史状态。例如内存检查点的基本形式为:

python 复制代码
from langgraph.checkpoint.memory import InMemorySaver

checkpointer = InMemorySaver()
graph = graph_builder.compile(checkpointer=checkpointer)

10. reducer 的完整执行过程

从空列表开始,各节点产生的更新如下:

阶段 节点返回值 合并后的 aggregate
初始状态 - []
step1 ["step1"] ["step1"]
step2 ["step2"] ["step1", "step2"]
step3 ["step3"] ["step1", "step2", "step3"]
step4 ["step4"] ["step1", "step2", "step3", "step4"]

可以把整个过程理解为:

python 复制代码
[] + ["step1"] + ["step2"] + ["step3"] + ["step4"]

最终得到:

python 复制代码
["step1", "step2", "step3", "step4"]

如果删除 Annotated[..., operator.add]aggregate 会成为普通的单值状态通道。此时 step3step4 在同一个执行阶段同时写入该字段,LangGraph 无法确定应该保留哪个值,通常会报告并发更新冲突。因此,并行节点写入同一字段时,需要提前设计明确的 reducer。

11. 完整代码

以下是本文实际运行的完整代码:

python 复制代码
import operator
from typing import Annotated, Any

from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict


# Annotated 可以在类型中提供额外的元数据,而不影响类型检查对类型的理解
class State(TypedDict):
    aggregate: Annotated[list[Any], operator.add]


def step1(state: State):
    print("这是step1")
    return {"aggregate": ["step1"]}


def step2(state: State):
    print("这是step2")
    return {"aggregate": ["step2"]}


def step3(state: State):
    print("step3")
    return {"aggregate": ["step3"]}


def step4(state: State):
    print("step4")
    return {"aggregate": ["step4"]}


graph_builder = StateGraph(State)
graph_builder.add_node(step1).add_node(step2).add_node(step3).add_node(step4)
graph_builder.add_edge(START, "step1")
graph_builder.add_edge("step1", "step2")
graph_builder.add_edge("step2", "step3")
graph_builder.add_edge("step2", "step4")
graph_builder.add_edge("step4", END)

# 编译图
graph = graph_builder.compile()

# 绘制结构图
png_bytes = graph.get_graph().draw_mermaid_png()
with open("langgraph_chain.png", "wb") as f:
    f.write(png_bytes)

res = graph.invoke({"aggregate": []}, {"configurable": {"thread_id": "1"}})
print("执行结果:", res)

12. 运行命令和真实输出

进入 index.py 所在目录后运行:

bash 复制代码
python index.py

本文实际执行得到的完整控制台输出如下:

text 复制代码
这是step1
这是step2
step3
step4
执行结果: {'aggregate': ['step1', 'step2', 'step3', 'step4']}

与此同时,程序成功生成了 langgraph_chain.png 流程图。

13. 常见问题

13.1 Pylance 提示无法解析 Annotated

在现代 Python 中,应从标准库 typing 导入:

python 复制代码
from typing import Annotated

如果项目需要兼容较老的 Python,也可以从 typing_extensions 导入:

python 复制代码
from typing_extensions import Annotated

不要写成下面这种无效语法:

python 复制代码
import typing import Annotated

13.2 为什么 Annotated 使用方括号

Annotated 是类型标注语法,需要写成:

python 复制代码
Annotated[list[Any], operator.add]

下面的函数调用形式是错误的:

python 复制代码
Annotated(list, operator.add)

13.3 节点为什么必须返回列表

aggregate 的基础类型是 list[Any],reducer 使用列表加法,所以节点更新也应该是列表:

python 复制代码
return {"aggregate": ["step1"]}

如果直接返回字符串,reducer 会尝试把列表和字符串相加,从而产生类型错误。

13.4 多条普通边和条件边有什么区别

多条普通边表示所有目标节点都会执行:

python 复制代码
graph_builder.add_edge("step2", "step3")
graph_builder.add_edge("step2", "step4")

条件边表示根据路由函数的结果选择后续节点:

python 复制代码
graph_builder.add_conditional_edges("step2", route_function)

前者适合并行拆分任务,后者适合 if/else 一类的动态路由。

13.5 为什么 VS Code 能运行代码,却仍提示找不到 langgraph

通常是终端和 Pylance 使用了不同的 Python 解释器。可以在终端检查:

bash 复制代码
python -c "import sys; print(sys.executable)"
python -m pip show langgraph

然后在 VS Code 中执行 Python: Select Interpreter,选择安装了 LangGraph 的 .venv/bin/python

总结

这个示例展示了 LangGraph 中非常重要的两个机制:

  1. 一个节点连接多个普通出边时,会形成 fan-out,并调度所有目标节点。
  2. Annotated[字段类型, reducer] 可以定义状态更新的合并方式。

在并行工作流中,多个节点经常需要把结果写入同一个字段。此时不能只关注节点如何执行,还需要明确状态应该如何合并。使用 Annotated[list[Any], operator.add] 可以让每个节点只返回自己的增量结果,再由 LangGraph 自动拼接为完整列表。

掌握并行分支和 reducer 后,就可以进一步构建多工具调用、多 Agent 协作、并行数据处理以及带检查点的复杂工作流。

相关推荐
TAN-90°-1 小时前
Deep Learning for Computer Vision——Image Classification with Linear Classifiers
python·深度学习·算法·计算机视觉·线性回归
AINative软件工程2 小时前
LLM API 成本失控怎么办?工程师的实时异常检测指南
python
练习两年半的攻城狮2 小时前
LlamaIndex ResponseMode 深度解析
python·llamaindex
阿pin2 小时前
Java随笔-红黑树
java·python·算法·红黑树
前端 贾公子2 小时前
LangChain 实战:让 LLM 拥有记忆与结构化输出能力
langchain
2603_965148112 小时前
eBay商品数据API:寻找海外仓与价格洼地
大数据·人工智能·windows·python·microsoft
2601_956319882 小时前
TqSdk Tick 数据适合做什么?获取方法与使用边界
人工智能·python
苏灿烤鱼3 小时前
把 Agent 做成一家公司,真比通用提示词好用吗?
python·agent·shell