1. 从一次节点串行拖垮整条链路说起
如果你用 LangGraph 搭过一个稍微复杂点的 Agent 工作流,大概率遇到过这种场景:一个图里有七八个节点,其中三四个节点各自要调一次外部接口或者跑一次本地检索,结果整条链路跑下来慢得让人想砸键盘。我最早做多路检索增强生成的时候就是这样,一个查询进来,要同时去向量库、关键词索引、外部知识源三个地方捞数据,每个节点单独跑,串行下来光等待就吃掉好几秒。后来把这三个节点改成并行执行,整体耗时直接砍掉一大半。
这就是 LangGraph 并行执行与异步优化要解决的核心问题。LangGraph 本身是基于图结构的状态机框架,节点之间通过边来定义执行顺序,默认情况下如果多个节点共享同一个上游节点,它们其实是可以被调度到同一个 Superstep 里并行跑的。但很多人写图的时候没意识到这一点,或者因为节点内部用了同步阻塞的写法,白白浪费了并行的机会。再加上 Send API 这个专门用来做动态扇出的机制,以及异步节点配合事件循环的优化,整套组合拳打下来,一个原本要跑十几秒的图,压到两三秒是完全可行的。
这篇文章适合两类人看:一类是已经用 LangGraph 搭过图、但对执行调度机制一知半解的开发者;另一类是正准备把单机原型往生产环境推、被延迟问题卡住的工程师。我会从 Superstep 的调度原理讲起,把并行执行的触发条件、Send API 的动态扇出、异步节点的写法、以及实测中踩过的坑,一条条拆开说清楚。文中涉及的具体参数和代码,都是我在实际项目里跑通过的,你可以直接拿去改。
2. Superstep 调度机制:并行到底是怎么被触发的
2.1 什么是 Superstep,为什么它决定了并行度
LangGraph 的执行模型借鉴了 Pregel 那套批量同步并行的思路。简单说,图的执行被切成一个个 Superstep,每个 Superstep 里,所有满足触发条件的节点会被一起调度执行,等这一批全部跑完,才进入下一个 Superstep。你可以把它想象成流水线上的一个节拍:同一个节拍内,能同时开工的工位一起干,干完了统一交棒给下一批。
关键点在于,一个节点什么时候被触发,取决于它的所有入边是否都"激活"了。如果节点 A 有两条出边分别指向 B 和 C,那么 A 执行完之后,B 和 C 会在同一个 Superstep 里被同时调度。这就是最朴素的并行。反过来,如果 B 和 C 之间还有一条边 B→C,那 C 就必须等 B 跑完,只能落到下一个 Superstep,并行就没了。
我见过不少人画图的时候随手加边,本来想表达"数据流向",结果无意中制造了依赖,把并行给锁死了。所以第一件事,就是检查你的图里有没有这种"非必要依赖边"。
2.2 并行触发的三个硬条件
不是所有看起来并行的结构都能真并行,实测下来要同时满足三个条件:
- 共享同一个上游节点,且上游只执行一次。如果 B 和 C 都从 A 出发,A 跑完后 B、C 同批调度。但如果 A 被设计成会被多次触发(比如在循环里),那并行关系就复杂了。
- 节点之间没有直接或间接的依赖边。B 和 C 之间不能有任何路径相连,否则调度器会认为存在顺序约束。
- 节点内部不能有阻塞整个事件循环的操作。这一条最容易被忽略,下面单独说。
前两条是图结构层面的,第三条是运行时层面的。很多人图结构画对了,但节点函数里写了个同步的requests.get(),结果并行调度是发生了,可每个节点都在阻塞主线程,实际效果跟串行差不多,甚至更慢,因为还多了调度开销。
2.3 用一张对比表看清串行与并行的差异
| 维度 | 串行执行 | 并行执行(同 Superstep) |
|---|---|---|
| 调度方式 | 一个节点跑完再跑下一个 | 同批节点一起调度 |
| 总耗时 | 各节点耗时之和 | 约等于最慢那个节点的耗时 |
| 资源占用 | 峰值低,但持续时间长 | 峰值高,但持续时间短 |
| 适用场景 | 有强依赖、需要前序结果 | 多路独立检索、多模型投票 |
| 调试难度 | 低,顺序清晰 | 稍高,需关注状态合并 |
这张表里最值得琢磨的是"总耗时"那一行。假设三个节点各耗时 1 秒,串行就是 3 秒,并行理论上是 1 秒。但实际中因为调度和状态合并的开销,可能是 1.2 秒左右。这个收益在节点越多、单个节点越慢的时候越明显。
2.4 状态合并:并行带来的隐藏成本
并行执行有个绕不开的问题:多个节点同时往同一个 State 里写数据,怎么保证不冲突?LangGraph 的做法是要求你为可能被并发写入的字段定义 reducer。比如一个列表类型的字段,你得告诉框架"多个节点往这个列表里追加时,用相加的方式合并",而不是直接覆盖。
我踩过的坑是:一开始没定义 reducer,两个并行节点都往同一个 key 写,结果后写的把先写的覆盖了,数据莫名其妙丢了一半。排查了半天才反应过来是状态合并的问题。所以只要你的图里有并行节点会写同一个字段,务必提前定义好 reducer,这是并行能正确工作的前提,不是可选项。
3. Send API:动态扇出的正确打开方式
3.1 静态边和动态扇出的本质区别
前面说的并行,是图结构在编译期就确定好的——A 连 B 和 C,这个关系是写死的。但实际业务里经常遇到"数量不确定"的并行:比如用户输入一句话,你要根据这句话拆出 N 个子问题,每个子问题单独去检索,N 是多少事先不知道。这种场景静态边就无能为力了,得靠 Send API。
Send API 的核心作用是:在运行时动态地创建一批节点调用,每个调用可以携带不同的参数(也就是不同的 State 片段),它们会被调度到同一个 Superstep 里并行执行。这跟静态并行的区别在于,扇出的宽度是运行时决定的,而不是编译期写死的。
3.2 Send API 的典型使用结构
用 Send API 一般分两步。第一步是写一个"分发节点",它的职责不是干活,而是根据当前 State 计算出要扇出多少个任务、每个任务带什么参数。第二步是在条件边里返回一个 Send 对象的列表,每个 Send 指定目标节点和该次调用的输入。
from langgraph.types import Send def dispatch_node(state): # 根据用户输入拆出子问题列表 sub_questions = split_questions(state["query"]) # 为每个子问题创建一个 Send,指向检索节点 return [Send("retrieve", {"question": q}) for q in sub_questions] # 在图中,dispatch_node 后面接条件边 builder.add_conditional_edges("dispatch", dispatch_node, ["retrieve"])这段代码里,split_questions返回几个子问题,就会扇出几个retrieve调用,它们全部落在同一个 Superstep 里并行跑。跑完之后,结果会通过 reducer 合并回主 State。
3.3 扇出宽度和资源控制
Send API 用起来爽,但有个现实问题:如果子问题拆出来 50 个,那就是 50 个并发调用同时打出去。如果每个调用都是外部 API,很可能触发限流,或者把本地连接池打满。我实测过一次,扇出 30 个并发请求,结果对方接口直接返回 429。
解决办法有两个方向。一是控制扇出宽度,在分发节点里对子问题做分批,比如每批最多 8 个,用循环或者递归的方式分多轮跑。二是给节点内部加信号量或连接池限制,让并发数可控。LangGraph 本身不直接提供并发上限参数,这个得在节点实现里自己兜住。
提示:Send API 扇出的任务如果写同一个 State 字段,同样需要 reducer。而且因为扇出数量动态,reducer 的正确性更关键,建议在开发阶段打印每次合并前后的 State 做验证。
3.4 一个容易混淆的点:Send 和普通条件边的区别
很多人第一次接触 Send 会把它和普通条件边搞混。普通条件边是"根据条件选择走哪一条路",一次只走一条(或者几条固定的路);而 Send 是"根据条件创建一批任务",数量是动态的。前者是路由,后者是扇出。理解这个区别,你就能判断什么时候该用哪个:需要"多选一"用条件边,需要"一对多"用 Send。
4. 异步节点:让并行真正跑起来的关键
4.1 同步节点为什么拖累并行
前面反复提到,节点内部如果有阻塞操作,并行就是假的。原因在于 Python 的 GIL 和事件循环机制。当你用同步的requests.get()或者time.sleep()时,整个线程被卡住,其他本该并行的节点根本没机会执行。LangGraph 的调度器虽然把多个节点放进了同一个 Superstep,但执行时还是一个一个来,因为线程被占着。
这就是为什么很多人明明图结构对了,并行却没效果。问题不在图,在节点实现。
4.2 把节点改成 async 的标准写法
解决办法是把节点函数定义成async def,内部所有 IO 操作都用异步版本。比如把requests.get()换成httpx.AsyncClient的调用,把同步的数据库查询换成异步驱动。
import httpx async def retrieve_node(state): async with httpx.AsyncClient() as client: resp = await client.get( "https://api.example.com/search", params={"q": state["question"]}, timeout=10.0 ) return {"results": [resp.json()]}改成这样之后,多个retrieve_node在同一个 Superstep 里就能真正并发执行了——当一个节点在等网络响应时,事件循环会切去跑另一个节点,CPU 不会空等。
4.3 异步改造中的三个实操细节
第一,超时一定要设。异步并发下,一个节点卡住不返回,整个 Superstep 都得等它。我一般给外部调用设 10 到 15 秒超时,超了就返回降级结果,别让整条链路陪葬。
第二,异常要就地捕获。并行节点里某个抛异常,如果不处理,可能影响整个 Superstep 的合并。稳妥的做法是在节点内部 try/except,把错误信息作为结果的一部分返回,让下游决定怎么处理。
第三,别在异步节点里调同步阻塞函数。这是最常见的翻车点。比如你在async def里调了一个同步的 ORM 查询,整个事件循环照样被卡。要么用异步驱动,要么用run_in_executor把同步调用丢到线程池里。
4.4 异步与并行的配合效果实测
我在一个多路检索的图里做过对比测试。三个检索节点,每个模拟 1 秒的 IO 等待:
| 实现方式 | 总耗时(约) | 说明 |
|---|---|---|
| 全同步串行 | 3.0 秒 | 基线 |
| 同步节点 + 并行调度 | 3.0 秒 | 调度并行了,但被阻塞抵消 |
| 异步节点 + 并行调度 | 1.1 秒 | 真正并发,接近理论值 |
| 异步 + Send 扇出 6 路 | 1.2 秒 | 扇出翻倍,耗时几乎不变 |
这张表最能说明问题:光有并行调度不够,节点必须异步,两者配合才能拿到收益。而且从 3 路扩到 6 路,耗时几乎没涨,这就是异步并行的扩展性优势。
5. 实测踩坑:那些文档里不会写的细节
5.1 状态 reducer 写错导致数据静默丢失
这个坑我在 2.4 提过,但值得再展开。当时我的图里有两个并行节点都往documents这个列表字段追加数据,我没定义 reducer,默认行为是后写覆盖先写。结果每次跑完,documents里只有最后一个节点的结果,另一个节点的数据凭空消失,而且不报错。这种静默失败最坑,因为你不打印 State 根本发现不了。
正确做法是给字段定义Annotated加 reducer:
from typing import Annotated from operator import add class State(TypedDict): documents: Annotated[list, add]这样多个节点并发追加时,列表会被正确合并。
5.2 扇出任务共享可变对象引发的串数据
Send API 扇出时,每个任务拿到的输入如果是同一个可变对象的引用,一个任务改了它,其他任务也会受影响。我遇到过一次,扇出的几个检索任务共享了一个字典,结果某个任务往里塞了个临时字段,其他任务的逻辑读到了这个脏数据,行为变得诡异。后来改成每个 Send 传独立的字典副本,问题消失。记住:扇出时给每个任务传不可变数据或者深拷贝,别图省事共享引用。
5.3 异步上下文里误用全局客户端
有人为了省事,在模块顶层创建一个全局的httpx.AsyncClient,所有节点共用。这在单事件循环下通常没问题,但如果你的图被放在多线程或者多次事件循环里跑(比如某些 Web 框架的请求处理),全局客户端可能绑定到已经关闭的事件循环,报出莫名其妙的错误。稳妥做法是在节点内部按需创建客户端,或者用依赖注入的方式管理生命周期。
5.4 并行节点数量过多导致调度开销反超
并行不是越多越好。我试过扇出 50 个节点,结果发现总耗时比扇出 20 个还长。原因是调度器管理这么多并发任务本身有开销,加上状态合并时要遍历所有结果,开销累积起来很可观。经验值是单次 Superstep 的并行节点控制在 10 到 20 个比较舒服,超过就分批。
5.5 调试并行图的实用手段
并行图的执行顺序不像串行那么直观,调试起来费劲。我的做法是在每个节点入口和出口打日志,带上节点名和一个自增的调用序号,跑完之后看日志的时间戳就能还原出哪些节点是真正并发的。另外 LangGraph 支持流式输出执行事件,可以订阅每个节点的开始和结束事件,实时观察调度情况,比事后看日志更直观。
6. 把并行和异步组合成一套可复用的优化套路
6.1 先诊断再优化:找出真正的瓶颈
别一上来就改并行。先用最朴素的串行版本跑一遍,记录每个节点的耗时。如果某个节点占了总耗时的 70%,那优化重点应该是这个节点本身(比如换更快的接口、加缓存),而不是并行。并行只对"多个节点各自耗时都不短、且相互独立"的场景有效。诊断这一步省不得,否则容易优化错方向。
6.2 优化顺序:先异步,再并行,最后扇出
我的经验顺序是这样的。第一步,把所有涉及 IO 的节点改成异步,这一步通常就能带来明显收益,因为消除了阻塞。第二步,检查图结构,把能并行的节点之间的非必要依赖边去掉,让它们落进同一个 Superstep。第三步,如果存在数量动态的并行需求,引入 Send API 做扇出。这个顺序的好处是每一步的收益都能单独验证,出问题也容易定位。
6.3 一套可复用的检查清单
在把图推向生产之前,我会过一遍这份清单:
- 所有 IO 节点是否都是
async def,内部无同步阻塞调用 - 所有可能被并发写入的 State 字段是否都定义了 reducer
- 外部调用是否都设了超时和异常兜底
- Send 扇出的宽度是否有上限控制
- 单次 Superstep 的并行节点数是否在合理范围
- 是否有日志或事件订阅能还原并行执行情况
这份清单帮我避免过好几次线上事故,尤其是 reducer 和超时这两条,几乎每次偷懒都会出问题。
6.4 关于收益的理性预期
最后说点实在的。并行和异步优化能带来多少提升,取决于你的图里有多少可并行的独立 IO。如果整条链路本来就是强依赖的串行逻辑,那优化空间有限,别指望有质变。但如果你的场景是多路检索、多模型投票、批量数据处理这类天然可并行的结构,那收益会非常可观,几倍的提速是常态。关键是先看清楚自己的图属于哪一类,再决定投入多少精力去优化。
我在实际项目里最大的体会是:LangGraph 的并行能力一直都在,只是被很多人的同步写法给埋没了。把节点改成异步、把依赖边理清楚、把 reducer 配对,这三件事做完,大部分图的性能问题就解决了一大半。剩下的就是根据具体业务调扇出宽度和并发上限,这部分没有标准答案,得靠实测慢慢磨。