☰
LangGraph并行执行与异步优化:Superstep调度与Send API实战
2026/10/27 13:11:07 网站建设 项目流程

1. 为什么单靠链式调用撑不住复杂场景

刚接触 LangGraph 那会儿,我习惯性地把每个节点都写成串行——A 跑完跑 B,B 跑完跑 C。简单问答链路确实够用,但只要业务里出现"同时查三个数据源再汇总"或者"一个节点要扇出成十几个子任务"的需求,串行方案的延迟就会肉眼可见地堆起来。我实测过一个场景:三个互不依赖的检索节点串行执行,单次响应 2.3 秒;改成并行之后直接压到 0.9 秒,差距非常直观。

LangGraph 的并行执行和异步优化,本质上解决的就是这类"节点之间没有依赖关系,却被执行顺序绑死"的问题。它适合已经写过基础 StateGraph、想进一步榨干吞吐的开发者,也适合正在做多智能体协作、批量数据处理、多路检索汇总这类场景的团队。核心关键词就三个:并行执行、异步优化、Superstep,外加一个容易被忽略但极其关键的Send API。

这篇文章我会按"设计思路 → 核心机制 → 实操落地 → 踩坑排查"的顺序展开,把 Superstep 的调度逻辑、Send API 的扇出用法、异步节点的写法、以及并行状态合并的坑全部讲透。你不需要提前精通 asyncio,但至少要写过一两个 LangGraph 节点,否则有些细节会看得云里雾里。

2. 并行执行的整体设计与调度思路

2.1 从"边"的角度理解并行

LangGraph 里判断两个节点能不能并行,看的不是节点本身,而是边(edge)的结构。只要从同一个节点出发有多条出边指向不同节点,这些目标节点就会被放进同一个 Superstep 里并发调度。这是最朴素也最常用的并行方式,我把它叫做"静态扇出"。

举个具体例子:一个入口节点router同时连到search_web、search_db、search_cache三个节点,那么这三个节点天然并行。你不需要写任何asyncio.gather,框架自己会处理。这一点很多人一开始没意识到,白白在节点内部手写并发,反而把状态管理搞乱了。

静态扇出的优势是结构清晰、可视化好看、调试简单。缺点是扇出的分支数量在编译期就固定了,遇到"根据输入动态决定跑几个子任务"的场景就力不从心——这时候就轮到 Send API 登场。

2.2 Superstep:并行的调度单位

Superstep 是 LangGraph 执行模型里的核心概念,可以理解为"一轮并行执行的批次"。每一轮里,所有被激活的节点同时开跑,全部跑完之后统一做一次状态合并,然后进入下一轮。这个"统一合并"的时机非常关键,它决定了并行节点之间能不能看到彼此的输出。

我用一个生活化的类比:Superstep 就像公司开晨会。同一批人同时领任务(并行执行),所有人做完后回到会议室汇报(状态合并),然后基于汇报结果再分配下一批任务(下一轮 Superstep)。你不能在别人还没汇报完的时候就去改他的成果,否则会议室就乱套了。

这个模型带来两个直接结论。第一,同一个 Superstep 内的节点互相看不到对方写入的状态,因为它们是在同一批次里并发跑的。第二,状态合并发生在 Superstep 边界,如果多个并行节点写了同一个 state key,就会触发冲突,需要你用 reducer 来定义合并规则。这两点后面会反复提到。

2.3 异步优化的定位

并行解决的是"多个节点同时跑",异步解决的是"单个节点内部不阻塞"。这两件事经常被混为一谈,但它们是不同层次的优化。

一个节点如果内部是同步的 HTTP 请求,即使它和其他节点并行,它自己占用的线程依然会被阻塞。当并行分支数量上去之后(比如扇出 50 个子任务),同步阻塞会迅速耗尽线程池,性能不升反降。这时候就需要把节点函数写成async def,用await让出控制权,让事件循环去调度其他任务。

我的经验是:并行是结构层面的优化,异步是执行层面的优化,两者要配合使用。只并行不同步,扇出大了会崩;只异步不并行,延迟降不下来。真正的高吞吐方案一定是"静态/动态扇出 + 全异步节点 + 合理的 reducer"三件套。

2.4 方案选型对照

为了让你快速判断该用哪种方式,我整理了一张对照表:

场景特征推荐方案理由
分支数量固定、结构清晰静态扇出(多出边)编译期确定,调试直观
分支数量由输入动态决定Send API 动态扇出运行期生成,灵活
节点内部有 IO 等待async def + await释放事件循环,提升并发
多分支写同一状态键配置 reducer避免状态覆盖冲突
需要限制并发上限信号量 / 分批调度防止资源打满

这张表是我踩了不少坑之后总结的,建议你在动手前先对号入座,能省掉大量返工。

3. 核心机制拆解与关键细节

3.1 静态扇出的状态合并陷阱

静态扇出写起来简单,但状态合并是最容易翻车的地方。假设三个并行检索节点都往results这个 key 里写数据,如果你在 State 定义时用的是普通类型标注,LangGraph 会认为这是"覆盖写",最后一个完成的节点会覆盖掉前面的结果,你只能拿到一份数据。

正确的做法是给这个 key 配一个 reducer。最常见的 reducer 是operator.add,它会把各分支的返回值拼成一个列表:

import operator from typing import Annotated, TypedDict class SearchState(TypedDict): query: str results: Annotated[list, operator.add]

这里Annotated[list, operator.add]的含义是:每次有节点往results写值,不是覆盖,而是把新值追加进去。三个并行节点各返回一个 list,最终results就是三个 list 拼接的结果。

注意:reducer 只在"多个节点写同一个 key"时才需要。如果每个并行节点写的是不同的 key,就不需要 reducer,直接普通标注即可。很多人一上来就给所有 key 加 reducer,反而让状态变得难以预测。

3.2 Send API 的动态扇出

Send API 是 LangGraph 里做动态并行的利器。它的核心思想是:在某个节点里,根据运行时的数据,动态生成一批"要执行的任务",每个任务携带自己的参数,框架会把这些任务并行调度。

典型写法是在一个"分发节点"里返回一个 Send 列表:

from langgraph.types import Send def dispatch(state): return [ Send("process_item", {"item": item}) for item in state["items"] ]

这段代码的意思是:对items里的每一项,都生成一个process_item任务,参数是{"item": item}。如果items有 20 项,就会并行跑 20 个process_item节点。

Send API 有几个必须记住的细节。第一,Send 的第一个参数是目标节点名,必须和add_node时注册的名字完全一致,拼错了不会报错,只会静默不执行,这个坑我踩过。第二,Send 的第二个参数是传给目标节点的局部状态,它不会自动合并进全局 State,目标节点只能看到这个局部字典加上全局 State 的只读视图。第三,Send 生成的任务数量在运行期才确定,所以可视化图里看不到具体分支,调试时要靠日志。

3.3 异步节点的写法与边界

把节点写成异步函数,语法上就是加async def并在 IO 操作前加await:

async def search_web(state): async with httpx.AsyncClient() as client: resp = await client.get(f"https://api.example.com/search?q={state['query']}") return {"results": [resp.json()]}

但异步不是免费的午餐,有几个边界必须清楚。第一,不要在异步节点里调用阻塞的同步库,比如requests.get、time.sleep、同步的文件读写。这些会卡住整个事件循环,让其他并行任务全部排队。要用httpx、aiofiles这类异步库替代。第二,如果实在绕不开同步库,用asyncio.to_thread把它丢到线程池里,别硬扛。第三,异步节点的异常处理要用try/except包住await,否则一个分支抛异常可能影响整批任务的收尾。

3.4 Superstep 边界的可见性规则

再强调一遍 Superstep 的可见性规则,因为它是并行调试中最反直觉的部分。同一个 Superstep 内的节点 A 和节点 B,即使 A 先跑完,B 也看不到A 写入的状态。只有当这一批全部结束、状态合并之后,下一轮节点才能看到完整结果。

这意味着你不能设计出"节点 B 依赖节点 A 的中间输出,但两者又在同一批并行"的结构。如果真有依赖,就必须拆成两个 Superstep:A 单独一批,B 在下一批。判断依据很简单——有数据依赖的节点不能并行,这是铁律。

4. 实操落地:从零搭一个并行检索流程

4.1 环境准备与依赖

先把依赖装齐。我用的版本组合是 langgraph 0.2.x 加 langchain-core,Python 3.10 以上:

pip install langgraph langchain-core httpx

如果你要用到持久化和检查点,再加langgraph-checkpoint-sqlite。这里不展开持久化,专注并行本身。

4.2 定义带 reducer 的状态

先定义 State。假设我们要做一个"多源检索汇总"的流程,三个检索源并行跑,结果汇总到一个列表:

import operator from typing import Annotated, TypedDict class RAGState(TypedDict): query: str results: Annotated[list, operator.add] summary: str

results用operator.add做 reducer,保证三个并行检索节点的输出能拼接而不是互相覆盖。summary是最后汇总节点写的,只有一个写入者,不需要 reducer。

4.3 编写三个异步检索节点

三个节点结构类似,我以其中一个为例,另外两个换数据源即可:

import httpx async def search_web(state: RAGState): async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( "https://api.example.com/web", params={"q": state["query"]}, ) return {"results": [{"source": "web", "data": resp.json()}]} async def search_db(state: RAGState): async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( "https://api.example.com/db", params={"q": state["query"]}, ) return {"results": [{"source": "db", "data": resp.json()}]} async def search_cache(state: RAGState): async with httpx.AsyncClient(timeout=5) as client: resp = await client.get( "https://api.example.com/cache", params={"q": state["query"]}, ) return {"results": [{"source": "cache", "data": resp.json()}]}

三个节点都是async def,都用httpx.AsyncClient,这样在同一个 Superstep 里并发时,事件循环能高效切换,不会互相阻塞。注意每个节点返回的都是{"results": [...]},reducer 会把它们拼起来。

4.4 组装图并触发并行

接下来把节点和边连起来:

from langgraph.graph import StateGraph, START, END builder = StateGraph(RAGState) builder.add_node("search_web", search_web) builder.add_node("search_db", search_db) builder.add_node("search_cache", search_cache) builder.add_node("summarize", summarize) builder.add_edge(START, "search_web") builder.add_edge(START, "search_db") builder.add_edge(START, "search_cache") builder.add_edge("search_web", "summarize") builder.add_edge("search_db", "summarize") builder.add_edge("search_cache", "summarize") builder.add_edge("summarize", END) graph = builder.compile()

关键在START出发的三条边。它们让三个检索节点进入同一个 Superstep 并行执行。三个节点都指向summarize,summarize会在下一轮 Superstep 里执行,此时它能看到合并后的完整results。

这里有个细节:summarize有三个入边,LangGraph 会等三个上游全部完成才触发它,这天然实现了"汇合"语义,不需要你手动 join。

4.5 用 Send API 做动态扇出

如果检索源数量是动态的,比如从配置里读出来有 N 个源,静态边就写不出来了。这时候用 Send:

from langgraph.types import Send def fan_out(state: RAGState): sources = state.get("sources", ["web", "db", "cache"]) return [ Send("search_one", {"source": s, "query": state["query"]}) for s in sources ] async def search_one(payload: dict): source = payload["source"] query = payload["query"] async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"https://api.example.com/{source}", params={"q": query}, ) return {"results": [{"source": source, "data": resp.json()}]}

然后在图里加一个分发节点:

builder.add_node("fan_out", fan_out) builder.add_node("search_one", search_one) builder.add_conditional_edges("fan_out", fan_out, ["search_one"])

add_conditional_edges配合返回 Send 列表的函数,就能实现运行期动态扇出。sources有几个,就并行跑几个search_one。

4.6 参数选择与并发上限控制

扇出数量不是越多越好。我实测过,扇出 100 个并发 HTTP 请求时,如果目标服务有速率限制,大量请求会返回 429,反而拖慢整体。这时候需要控制并发上限。

最直接的办法是用asyncio.Semaphore:

import asyncio sem = asyncio.Semaphore(10) async def search_one(payload: dict): async with sem: # 实际请求逻辑 ...

信号量设为 10,意味着最多 10 个请求同时在飞,其余排队。这个值怎么定?我的经验公式是:并发上限 ≈ 目标服务 QPS 上限 × 平均响应时间。比如服务允许 50 QPS,平均响应 0.2 秒,那并发上限大约 10 就够,设太高只会触发限流。

另一个思路是分批调度,把 100 个任务拆成 10 批,每批 10 个,批间加个小延迟。这种方式对目标服务更友好,但总延迟会略高。选哪种取决于你对延迟和稳定性的权衡。

5. 常见问题与排查技巧实录

5.1 并行结果丢失或覆盖

这是最高频的问题。现象是:明明三个节点都跑了,最终results里只有一份数据。原因几乎都是没配 reducer。检查方法很简单,看 State 定义里那个 key 有没有Annotated[..., reducer]。没有就加上,operator.add适用于列表拼接,字典合并可以用自定义 reducer。

还有一种隐蔽情况:reducer 配了,但节点返回的不是列表而是单个元素,导致拼接结果结构错乱。记住 reducer 的输入输出类型要一致,operator.add要求两边都是可相加的类型。

5.2 Send 任务静默不执行

Send 的目标节点名拼错时,LangGraph 不会抛异常,任务直接消失。排查方法是打印fan_out的返回值,确认每个 Send 的第一个参数和add_node注册名完全一致。我建议把节点名抽成常量,避免手写字符串。

另一个原因是add_conditional_edges的路径列表没包含目标节点。Send 的目标必须在条件边的可选路径里,否则调度器找不到。

5.3 异步节点里混入阻塞调用

现象是并行扇出后延迟不降反升,CPU 占用不高但吞吐上不去。八成是异步节点里混了同步阻塞调用。排查手段是在节点里打时间戳,看单个节点的耗时是否异常。常见元凶:requests、time.sleep、同步 ORM 查询、同步文件 IO。

替换方案对照:

阻塞写法异步替代
requests.gethttpx.AsyncClient
time.sleepasyncio.sleep
同步文件读写aiofiles
同步 DB 驱动asyncpg / aiomysql
无法替换的同步库asyncio.to_thread 包裹

5.4 Superstep 内状态不可见导致的逻辑错误

有依赖关系的节点被放进了同一批并行,导致下游读不到上游数据。判断方法:画出节点依赖图,凡是"B 需要 A 的输出"的边,都不能让 A、B 处于同一 Superstep。解决办法是插入一个中间节点或调整边结构,强制拆成两轮。

5.5 并发过高触发限流

前面提过,扇出过大容易触发目标服务限流。除了信号量,还可以在节点内做重试加退避:

async def search_with_retry(payload, max_retry=3): for i in range(max_retry): try: return await do_search(payload) except RateLimitError: await asyncio.sleep(2 ** i) raise RuntimeError("retry exhausted")

指数退避的间隔是 1、2、4 秒,能有效缓解瞬时压力。注意重试次数别设太高,否则一个慢任务会拖住整批 Superstep 的收尾。

5.6 问题速查表

现象可能原因排查方向
结果被覆盖缺 reducer检查 State 标注
Send 不执行节点名拼错打印 Send 返回值
延迟不降混入阻塞调用节点内打时间戳
下游读不到数据同 Superstep 依赖检查边结构
大量 429并发过高加信号量/退避
部分分支丢失异常未捕获加 try/except 日志

6. 我在实际项目里沉淀的几条经验

第一条,先串行跑通,再改并行。我见过太多人一上来就设计复杂的并行图,结果状态合并、依赖关系全乱套,调试成本极高。正确姿势是先用串行把业务逻辑跑对,确认每个节点的输入输出,再逐步把无依赖的节点改成并行。这样每一步都有对照,出问题能快速定位。

第二条,reducer 是并行的地基,不是可选项。只要涉及多分支写同一状态,reducer 必须提前设计好。我习惯在画图之前先把 State 的每个 key 列出来,标注"单写者"还是"多写者",多写者一律配 reducer。这个习惯帮我省掉了大量返工。

第三条,异步节点的异常一定要兜住。并行场景下,一个分支抛异常,如果没捕获,可能让整批 Superstep 的收尾逻辑出问题。我的做法是在每个异步节点外层包一层 try/except,把异常转成结构化的错误信息写进状态,让下游节点决定怎么处理,而不是让异常直接冒泡。

第四条,并发上限宁低勿高。刚开始调优时,我总想把并发拉满,结果频繁触发限流和超时。后来改成从 5 或 10 起步,观察目标服务的响应时间和错误率,再逐步往上加。稳定比峰值吞吐更重要,尤其是生产环境。

第五条,日志要带 Superstep 标识。并行执行时,多个节点的日志会交错输出,很难分辨哪条属于哪一批。我在节点入口统一打上superstep_id和node_name,排查问题时能快速还原执行顺序。这个小改动在复杂图里价值巨大。

最后分享一个我常用的调试技巧:把图编译后,用graph.get_graph().draw_mermaid()导出结构图(这里只是导出文本,方便肉眼检查边结构),重点看有没有意外的并行分支或者缺失的汇合边。结构对了,并行逻辑基本就对了。至于 Send API 生成的动态分支,图里看不到,得靠运行日志确认每个任务是否都进了调度队列。

这套并行加异步的组合,我在多源检索、批量内容处理、多智能体协作三个场景里都跑过,稳定性没问题。关键是把 Superstep 的边界规则吃透,把 reducer 设计对,剩下的就是调并发参数和补异常处理了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询