夜雨聆风学习资料网

ARTICLE · 1228027

【LangGraph源码精读】第05期|线程池还是进程池?LangGraph的并发选择藏着性能秘密

【LangGraph源码精读】第05期|线程池还是进程池?LangGraph的并发选择藏着性能秘密

【LangGraph源码精读】第05期|线程池还是进程池?LangGraph的并发选择藏着性能秘密

30篇源码精读系列·第05篇(执行引擎层第五篇)。你配置了并行分支,以为两个节点会同时跑,结果总耗时还是两个节点的和。为什么?因为你用了invoke而不是ainvoke?因为节点是同步函数?因为同步屏障在等慢节点?这篇把LangGraph的并发执行器彻底拆开:PregelRunner怎么调度、Sync和Async有什么区别、超时和重试怎么配合、慢节点怎么拖垮整个Super-step。


一、开头:你的并行Agent为什么没有变快?

你写了一个Agent,里面有"搜索"和"代码生成"两个独立节点,配置成并行执行。你以为总耗时是max(搜索, 代码生成),结果实际耗时是搜索+代码生成。

排查了半天,发现可能是这些原因:

  • ● 你用了invoke(),节点是同步函数,并发用的是线程池,但节点里有CPU密集型计算,Python GIL导致串行
  • ● 你用了ainvoke(),但节点函数忘了写async,还是同步执行
  • ● 两个节点确实并行跑了,但其中一个特别慢,同步屏障导致快节点必须等慢节点
  • ● 你以为ToolNode里的多个工具调用是并行的,但用的是v1版本,内部是串行的

并发是LangGraph中最容易"看起来对但实际不对"的地方。 配置了并行分支不代表真的并行,用了ainvoke不代表性能一定好。这篇把并发执行器的每一层都拆开,让你知道你的Agent到底在怎么跑。


二、PregelRunner:并发执行的核心

PregelRunner在pregel/_runner.py里,是Execute阶段的执行者。它不决定图结构,只负责把已经选好的任务执行起来。

2.1 PregelRunner.tick()的6步执行流程

步骤
做什么
关键机制
1
把tasks转成tuple,建立FuturesDict
每个task对应一个future
2
单任务、无timeout、无waiter时走fast path
直接run_with_retry(),跳过并发开销
3
多任务时通过submit()并发提交
线程池(Sync)或asyncio(Async)
4
FIRST_COMPLETED等待,一个完成就yield一次
支持流式输出边执行边输出
5
每个任务完成时回调commit()
结果转为task writes,不直接改Channel
6
所有future完成后_panic_or_proceed()
决定是否抛出未处理异常

fast path是一个重要的性能优化。 如果当前Super-step只有一个任务(顺序图的常见情况),PregelRunner不会创建线程池或asyncio任务,直接同步执行,避免并发开销。这意味着顺序图的性能和普通函数调用几乎一样。

2.2 commit():并发安全的关键

回顾上一期讲的:commit()不直接修改共享Channel,而是把结果写入task.writes缓冲区,调用loop.put_writes(task_id, writes)。

传统并发做法
LangGraph做法
节点执行完直接改共享状态
节点执行完写自己的缓冲区
需要锁保护共享字典
不需要锁,缓冲区是线程私有的
执行顺序影响最终结果
执行顺序不影响,after_tick统一合并

这就是"状态副本+输出缓冲"在并发层的具体实现。 PregelRunner只负责"跑任务",不负责"合并结果"。合并是after_tick的事,在所有任务完成后单线程进行。这种职责分离让并发执行变得简单且安全。


三、Sync vs Async:两条完全不同的执行路径

LangGraph有两套执行循环,对应同步和异步调用。

维度
SyncPregelLoop
AsyncPregelLoop
调用方式
invoke()、stream()
ainvoke()、astream()
执行器
线程池(ThreadPoolExecutor)
asyncio事件循环
节点调用
Runnable.invoke()
Runnable.ainvoke()
并发模型
多线程并发
协程并发
适合场景
CPU密集、同步IO、简单工作流
IO密集、异步IO、LLM调用
超时支持
不支持(编译时报错)
支持(v1.2新增)
性能特点
受GIL限制,CPU密集串行
不受GIL限制,IO密集高效

3.1 为什么Sync不支持超时?

这是一个常见的困惑。Python的线程没有可靠的取消机制——你不能安全地强制终止一个正在运行的线程。所以LangGraph在编译时就拒绝给同步节点配置timeout。

节点类型
timeout配置
原因
同步节点(def)
编译时报错
线程无法安全取消
异步节点(async def)
支持
asyncio可以取消协程
同步IO包装
用asyncio.to_thread()包成异步
间接支持超时

最佳实践:调用LLM或外部API的节点,永远写成async def。 这样你才能用timeout防止慢API拖垮整个Agent。同步节点只适合纯本地计算且确定不会卡住的场景。

3.2 什么时候用Sync,什么时候用Async?

场景
推荐
原因
简单顺序工作流,无外部IO
Sync
简单直接,fast path性能好
调用LLM、外部API、数据库
Async
IO密集,asyncio高效,支持超时
并行分支都是CPU密集计算
Sync(多进程)或Async(to_thread)
GIL问题,需要多进程或线程包装
生产级Agent
Async
超时、重试、流式输出都需要异步
快速原型验证
Sync
写起来简单,不用管async

一个常见误区:用了ainvoke就一定更快。 如果你的节点都是同步函数,ainvoke内部还是会用线程池跑,和invoke没有本质区别。只有节点本身是async def,ainvoke才能发挥协程的优势。


四、并发的边界:什么能并行,什么不能

这是理解LangGraph并发最关键的一点。

4.1 可以并行的情况

并行类型
说明
例子
同一Super-step内的无依赖节点
两个节点订阅不同的Channel,在同一轮被触发
搜索节点和代码生成节点并行
ToolNode内的多个工具调用(v2)
每个tool call一个独立task,Send动态创建
Agent一次调用3个工具,3个并行执行
子图内的节点
子图作为一个节点,但内部可以有自己的并行
规划子图内部并行执行多个规划步骤

4.2 不能并行的情况

限制
说明
原因
有数据依赖的节点
B需要A的输出,必须分两个Super-step
B订阅A写入的Channel,A执行完B才被触发
跨Super-step
下一轮必须等上一轮after_tick完成
同步屏障,BSP模型的固有特性
同一节点的多次执行
一个节点在一个Super-step只执行一次
版本号机制,执行后versions_seen更新
写同一个LastValue Channel
两个节点同时写无reducer字段会报错
冲突检测,InvalidUpdateError

核心原则:同一Super-step内无数据依赖的节点可以并行,有依赖的必须串行。 LangGraph通过Channel订阅关系自动判断哪些节点可以并行——你不需要手动指定,编译时trigger_to_nodes索引已经决定了。

4.3 ToolNode的v1和v2:并发粒度的区别

维度
v1(旧版)
v2(新版)
一个ToolNode task处理
最后一条AIMessage中的全部tool calls
一个tool call
工具并行位置
ToolNode内部用executor并行
图通过Send为每个call创建独立task
优点
简单,一个task处理所有工具
每个工具有独立task identity、路径、流事件
缺点
工具调用没有独立的Checkpoint和重试
实现稍复杂
适用场景
简单Agent
生产级、需要细粒度控制的Agent

v2是create_react_agent的默认版本。 它的优势是每个工具调用有独立的task identity,可以单独重试、单独追踪、单独超时。这在生产环境中非常重要——一个工具调用失败不会影响其他已经成功的工具调用。


五、超时机制:v1.2的生产级能力

超时是v1.2新增的重要功能,专门解决"慢节点拖垮整个Agent"的问题。

5.1 两种超时类型

类型
说明
刷新机制
适用场景
run_timeout
硬墙钟上限,从节点开始计时
不刷新,到点就超时
确定节点最多能跑多久
idle_timeout
空闲超时,没有进度时计时
有进度信号时刷新
检测节点是否"卡住了"

run_timeout是"最多跑多久",idle_timeout是"多久没动静就算挂了"。 两者可以同时配置,哪个先触发就取消节点。

5.2 idle_timeout的进度信号

idle_timeout的时钟在以下情况自动刷新(默认refresh_on="auto"):

进度信号
说明
State writes
节点通过CONFIG_KEY_SEND写入状态
Stream output
节点yield异步流输出
Child-task scheduling
节点调度了子任务
Runtime stream-writer
运行时流写入器调用
LangChain callback事件
LLM token、工具调用、chain start/end等

这意味着:只要节点在"干活"(输出token、调用工具、写状态),idle_timeout就不会触发。只有节点真正卡住了(比如死锁、无限等待),才会超时。

5.3 heartbeat模式:更严格的空闲检测

模式
刷新条件
适用场景
auto(默认)
任何进度信号都刷新
大多数场景
heartbeat
只有显式调用runtime.heartbeat()才刷新
长任务、需要严格空闲定义的场景

heartbeat模式适合"节点可能产生很多噪音输出但实际没进展"的场景。你需要在节点的关键步骤手动调用runtime.heartbeat(),告诉运行时"我还在干活"。

5.4 超时后的处理

步骤
发生了什么
1
超时触发,抛出NodeTimeoutError
2
清除失败尝试的所有writes(部分结果不保留)
3
retry_policy决定是否重试(NodeTimeoutError默认可重试)
4
重试耗尽后,error_handler(如果配置了)执行补偿逻辑
5
无error_handler则异常冒泡,整个Super-step失败

注意:超时的节点的部分写入会被清除。 这意味着如果节点执行了一半超时了,它已经产生的输出不会保留——下一次重试会从头开始。这是"节点必须幂等"的另一个原因。


六、重试机制:RetryPolicy详解

重试是生产级Agent的标配。LangGraph的RetryPolicy在v1.2做了重大升级。

6.1 RetryPolicy参数

参数
默认值
说明
max_attempts
3
最大尝试次数(含第一次)
initial_interval
0.5秒
第一次重试前的等待时间
backoff_factor
2.0
每次重试后等待时间的倍数
max_interval
128秒
最大等待时间上限
jitter
True
是否加随机抖动(避免惊群效应)
retry_on
default_retry_on
哪些异常需要重试

退避策略: 第1次重试等0.5秒,第2次等1秒,第3次等2秒...直到max_interval。加jitter是为了避免多个Agent同时重试导致下游服务被打垮。

6.2 default_retry_on:默认重试哪些异常

重试
不重试
网络异常、超时、API 5xx
ValueError、TypeError、ArithmeticError
NodeTimeoutError(默认可重试)
ImportError、LookupError、NameError
大多数运行时异常
SyntaxError、RuntimeError、OSError

HTTP库(requests/httpx)只重试5xx状态码,不重试4xx。 这是合理的——4xx是客户端错误(比如参数不对、鉴权失败),重试也没用;5xx是服务端错误,重试可能成功。

6.3 重试的粒度

维度
说明
重试单位
task attempt(单个节点的一次尝试),不是整个graph
影响范围
一个节点重试,同Super-step其他已成功节点不重跑
超时配合
超时后重试,超时时钟在每次新尝试时重置
写入清除
重试前清除上一次失败尝试的writes

重试的粒度是节点级,不是graph级。 这是BSP模型的优势——一个节点失败重试,其他已经成功的节点结果保留在pending writes里,不会浪费。


七、错误处理:error_handler和Saga模式

重试解决不了所有问题。有些错误重试也没用(比如支付失败、库存不足),这时候需要error_handler做补偿。

7.1 error_handler的执行流程

步骤
发生了什么
1
节点抛出异常
2
retry_policy判断是否重试,重试耗尽后进入下一步
3
error_handler被调用,传入当前state和NodeError
4
handler可以返回Command(更新状态+路由到其他节点)
5
如果handler返回普通dict,只更新状态,继续正常路由
6
handler自身失败则异常冒泡

7.2 Saga补偿模式

error_handler最经典的用法是Saga模式——分布式事务中的补偿机制。

步骤
正常流程
失败补偿
1
扣减库存
-
2
支付
支付失败 → error_handler触发
3
生成订单
handler回滚库存,路由到"支付失败"节点

error_handler可以返回Command(goto="compensation_node"),把执行流路由到补偿节点。 这比"整个graph失败回滚"灵活得多——你可以精确控制失败后做什么。

7.3 error_handler的限制

限制
说明
每个节点最多一个handler
不能给一个节点配多个error_handler
handler不能捕获自己
handler自身失败会冒泡,不会递归捕获
handler结果不缓存
cache_policy不适用于error_handler节点
只在重试耗尽后触发
配置了retry_policy时,先重试再handler

八、慢节点问题:同步屏障的代价

这是BSP模型最被诟病的缺点:同步屏障。

8.1 什么是慢节点问题

假设一个Super-step有3个并行节点:A(1秒)、B(1秒)、C(30秒)。

时间
A
B
C
0-1秒
执行中
执行中
执行中
1-30秒
等待(完成了)
等待(完成了)
执行中
30秒
after_tick合并
after_tick合并
after_tick合并

A和B在1秒时就完成了,但必须等C到30秒才能进入Update阶段。 这29秒的等待就是同步屏障的代价。

8.2 怎么缓解慢节点问题

方案
说明
适用场景
拆分慢节点
把30秒的节点拆成多个小节点,中间可以Checkpoint
节点内部有多个步骤
超时+重试
给慢节点配timeout,超时后重试或走error_handler
偶尔慢的外部API
异步流式输出
慢节点边执行边输出,用户感知延迟降低
LLM调用、长任务
缓存结果
CachePolicy缓存节点输出,相同输入不重复执行
重复调用多的节点
接受代价
Agent的瓶颈通常在LLM调用,同步屏障的等待相对小
大多数场景

核心观点:对于AI Agent,同步屏障的代价通常可以接受。 因为Agent的瓶颈在LLM调用(秒级),而不是执行引擎的同步屏障(毫秒级)。只有当你有特别慢的非LLM节点时,才需要专门优化。


九、性能优化建议

优化点
做法
预期收益
节点写成async def
调用LLM/API的节点用async
支持超时、协程并发、流式输出
用ainvoke代替invoke
异步节点用ainvoke
IO密集场景性能提升明显
合理配置timeout
外部API节点配run_timeout+idle_timeout
防止慢节点拖垮Agent
用v2版本ToolNode
create_react_agent默认v2
工具调用独立重试和追踪
配置CachePolicy
重复调用多的节点加缓存
减少重复计算和API调用
拆分超大节点
单个节点超过30秒就考虑拆分
支持中间Checkpoint和恢复
避免CPU密集在主线程
CPU密集用asyncio.to_thread或多进程
避免GIL阻塞事件循环

十、常见坑:并发的5个隐蔽问题

坑
现象
根因
解法
并行没变快
配置了并行分支但耗时是串行的
节点是同步函数+CPU密集,GIL串行
改成async或多进程
超时不生效
配了timeout但节点还是卡住
节点是同步def,编译时被忽略了
改成async def
部分结果丢失
节点超时后已产生的输出没了
超时清除失败尝试的所有writes
节点拆小,或确保幂等
重试导致重复副作用
重试后外部API被调用了两次
节点不幂等,重试重复执行
加幂等键,或把副作用移到error_handler后
并行结果不确定
同样输入并行结果有时不同
reducer不满足结合律或交换律
验证reducer的数学性质

十一、面试关联:并发执行器的5道高频题

面试题
答题要点
考察深度
LangGraph的并行是怎么实现的?
PregelRunner并发提交task,commit写缓冲区不直接改Channel,after_tick单线程确定序合并
核心机制
Sync和Async有什么区别?
Sync用线程池+Runnable.invoke,Async用asyncio+Runnable.ainvoke;只有Async支持超时
执行路径
为什么同步节点不支持超时?
Python线程无法安全强制取消;asyncio协程可以安全取消
底层原理
慢节点会影响其他节点吗?
会。BSP同步屏障,快节点必须等慢节点完成才能进入Update阶段
BSP特性
retry和error_handler什么关系?
先重试,重试耗尽后才触发error_handler;handler可以返回Command做补偿路由
错误处理流程

十二、本期小结

并发执行器,核心就6句话:

  1. 1. PregelRunner只负责"跑任务",不负责"合并结果"。 commit()把结果写入task.writes缓冲区,不直接改Channel;合并是after_tick的事,单线程确定序进行。这种职责分离让并发安全且简单。单任务走fast path直接执行,多任务才用并发。
  2. 2. Sync和Async是两条完全不同的执行路径。 Sync用线程池+Runnable.invoke,Async用asyncio+Runnable.ainvoke。只有Async支持超时——因为Python线程无法安全取消,asyncio协程可以。调用LLM和外部API的节点永远写成async def。
  3. 3. 并发的边界由Channel订阅关系自动决定。 同一Super-step内无数据依赖的节点可以并行,有依赖的必须分两个Super-step。你不需要手动指定并行,编译时trigger_to_nodes索引已经决定了。ToolNode v2让每个工具调用有独立task identity。
  4. 4. 超时机制(v1.2)有两种:run_timeout和idle_timeout。 run_timeout是硬墙钟上限不刷新;idle_timeout是空闲超时,有进度信号时自动刷新。超时后抛出NodeTimeoutError,清除部分写入,默认可重试。Send可以动态覆盖单个调用的超时。
  5. 5. 重试和error_handler配合构成完整的容错体系。 RetryPolicy用指数退避+抖动,默认重试网络异常和5xx,不重试代码错误。重试耗尽后error_handler执行补偿,可以返回Command路由到补偿节点(Saga模式)。重试粒度是节点级,不影响同Super-step其他已成功节点。
  6. 6. 同步屏障是BSP的固有代价,但对AI Agent通常可接受。 快节点必须等慢节点完成才能进入Update阶段。缓解方案包括拆分慢节点、超时重试、缓存结果。Agent的瓶颈通常在LLM调用(秒级),同步屏障的等待(毫秒级)相对很小。

十三、下一期预告

下一期(源码06),执行引擎层最后一篇,失败重试和容错的深入:

【LangGraph源码精读】第06期|节点失败了怎么办?LangGraph的重试和部分失败恢复

从RetryPolicy的源码实现到pending writes恢复机制,从error_handler的Saga补偿到drain优雅关闭,搞懂LangGraph怎么保证"节点失败不丢状态、部分失败不重跑全部"。

你在使用LangGraph时遇到过并发性能或节点超时的问题吗?评论区聊聊,我优先写你最关心的场景。


本文为《LangGraph源码精读30讲》系列第05篇。内容基于LangGraph v1.2.11源码结构梳理,核心文件位于libs/langgraph/langgraph/pregel/目录下的_runner.py(PregelRunner)、_loop.py(Sync/AsyncPregelLoop)、_retry.py(重试逻辑)。超时和error_handler为v1.2新增功能。版本更新后部分细节可能变化。如有错漏欢迎指正。

相关学习资料