LangGraph 搭工作流,最容易踩的一环是动态扇出。Send 把任务并行派给多个 worker,结果怎么合并,全看 state 里那个 reducer 配没配对。我在本机装 langgraph 1.2.21 跑了一遍。配对了出三个结果。配错了它直接抛异常。报错原文贴在下面。这篇讲三件事:StateGraph 的节点和边、子图、Send 动态边。

StateGraph 的节点和边

LangGraph 的核心是一个状态图。你定义一个 state,往图里加节点,节点是吃 state 返回更新的函数,再用边把节点串起来。这套接口借鉴 NetworkX,执行模型受 Pregel 和 Apache Beam 启发,这是官方文档的原话。

from langgraph.graph import StateGraph, START, END

class S(dict):
    pass

g = StateGraph(S)
g.add_node("planner", planner)
g.add_edge(START, "planner")
g.add_edge("planner", END)
app = g.compile()

add_node 挂函数,add_edge 定流向,START 和 END 是入口出口,compile() 之后得到一个能 invoke 和 stream 的图。这是最基础的骨架。

条件边是 add_conditional_edges,比 add_edge 多一个路由函数,返回一个字符串指到下一个节点名,到这里都还是线性的。真正让它跟普通 DAG 拉开差距的是 Send。

Send 是动态边,一次派多个

Send 解决一件事:一个节点跑完,才知道要派几个下游、每个下游吃什么数据。比如 planner 把大任务拆成 N 个子任务,每个派一个 worker 并行做。

from langgraph.types import Send

def fan_out(state):
    return [Send("worker", {"subtask": t}) for t in state["subtasks"]]

g.add_conditional_edges("planner", fan_out, ["worker"])

fan_out 返回一个 Send 列表,每个指定目标和传给它的 state。LangGraph 看到列表,就并行跑那 N 个 worker。两个关键点。一是每个 worker 只看到 Send 传进去的 scoped state,读不到父图其它字段,想要 topic 就自己塞进 payload。二是下游节点是隐式 barrier,等所有并行 worker 都完事才启动。

Send 还有三个常见的坑,我从多个来源核对过。第一个坑在 add_conditional_edges 的第三参,别省,省了图可视化不对、类型系统也帮你定位不到。第二个坑在 worker 里别读父图的 state,它只看得到 Send 传进去的那份,想读 topic 就自己塞进 payload。第三个坑在合并通道的 reducer,就是我下面踩的那个,普通 list 会抛 InvalidUpdateError。这三个坑的根子是同一个。Send 的 scoped state 语义,跟普通节点读全量 state 不一样。

我本机跑了个最小例子。planner 拆出 A、B、C 三个子任务,Send 并行派三个 worker,每个返回 done 加自己的 subtask,最后 aggregator 拼起来。跑出来的 final 是 done-A,done-B,done-C。三个一个不少。这个输出是我本机 Python 3.13.12 加 langgraph 1.2.21 实际打印的,不是抄的。

reducer 配错,直接抛异常

真正值得写的是踩坑。合并并行结果靠 state 字段的 reducer。我第一版偷懒,results 用了普通 list,没配 reducer。我以为结果会互相覆盖、最后只剩一个。实际不是。它直接抛异常。

langgraph.errors.InvalidUpdateError: At key 'results':
Can receive only one value per step. Use an Annotated key to handle multiple values.

这个报错我一个字没改。它说得很明白:一个 step 里 results 只能收一个值,要用 Annotated 键处理多个值。这跟我从二手文章读到的旧行为不一样。有文章写「没配 reducer 会静默覆盖、丢结果」。但 langgraph 1.2.21 现在是显式抛 InvalidUpdateError。这个落差本身就是一手实测的价值。二手说的旧行为,跑一下才知道改了。

修法是一行。把字段声明成带 reducer 的 Annotated 类型。

from typing import Annotated
import operator

class S(dict):
    results: Annotated[list, operator.add]

operator.add 告诉 LangGraph,并行写进来的值用加法拼,不覆盖。改成这样重跑,final 又回到 done-A,done-B,done-C。同一个例子,一个 reducer 之差,从抛异常到正确合并。

子图是 compile 后的复用单元

子图把一段多步流程封装成一个节点。子流程编译好,直接 add_node 挂进父图。父图把它当单个节点,state 流进去,子图内部节点跑完,结果流回父图。官方 how-tos 里给了两级子图例子,父套子再套孙。

subgraph = StateGraph(SubState)
subgraph.add_node("search", search)
subgraph.add_edge(START, "search")
subgraph.add_edge("search", END)
compiled = subgraph.compile()

parent = StateGraph(ParentState)
parent.add_node("researcher", compiled)

这个结构值钱在复用。一段带内部逻辑的流程能当积木塞进更大的图。官方 how-tos 里的例子还给了个细节,graph.stream({"foo": "foo"}, subgraphs=True) 会把子图内部节点也打出来,括号里套着子图名。Send 的目标可以是子图,fan-out 到 N 个完整子流水线,一行胶水做出 map-reduce。这是 LangGraph 真正显威力的地方。

给个判断。你的工作流是线性的一步接一步,用 add_edge 串起来就够,别上 Send。你的任务要「一个结果拆成 N 个并行分支再汇总」,Send 加 reducer 是正解,但 reducer 必须配,不然它抛异常不是你的运气。子图适合一段逻辑多处复用,不复用就别拆,拆了反而绕。

一句话。Send 是 LangGraph 里最像真并行的原语。它的正确性不在派发,在合并那一步的 reducer。配对了三路稳稳回来。配错了它当场报错,不给你静默丢数据的错觉。