news 2026/10/14 4:45:12

LangGraph 并行分支与人工介入中断

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LangGraph 并行分支与人工介入中断

本文档系统梳理 LangGraph 中控制流的核心机制:add_edge、Send、Command的差异与用法,并行分支的多种实现方式,以及人工介入中断(interrupt)的完整恢复逻辑。所有结论均基于实际运行测试。


第一部分:控制流三大机制

在 LangGraph 中,add_edge、Send、Command都可以控制节点之间的流转,甚至都能产生并行分支,但设计目标和适用场景截然不同。

1.1 add_edge:静态边

add_edge是图的声明式结构,在编译期就固定了节点之间的连接关系。

builder.add_edge("node_a", "node_b")
  • 返回者:无返回值,纯粹声明。

  • 决定时机:编译期(compile()时)。

  • 拓扑:固定的一对一或多对一。

  • 节点职责:节点函数只返回dict(状态更新),不关心下一步去哪。

适用场景:流程固定、分支明确、无需运行时动态决策。

1.2 Send:路由中的动态扇出

Send用于在条件边的路由函数中动态生成多个并行任务,特别适合 map-reduce 模式。

from langgraph.types import Send def fanout_router(state) -> list[Send]: return [ Send("worker", {"item": "A"}), Send("worker", {"item": "B"}), ] builder.add_conditional_edges("entry", fanout_router)
  • 返回者:路由函数。

  • 决定时机:运行时,根据当前 state 动态计算。

  • 关键特征:

    • 同一个目标节点可以被并行调用多次,每次携带独立的输入状态(Send的第二个参数)。

    • 每个Send的arg会替换目标节点收到的 state,而不是合并进主图状态。

    • 只有目标节点返回值中、且主图State里定义了 reducer 的键,才会合并回主图。

  • 必须配合 reducer:多个并行分支写入同一个键时,该键必须用Annotated指定 reducer(如operator.add),否则会抛出InvalidUpdateError。

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

适用场景:动态数量的并行任务、每个任务需要不同输入、结果需要合并。

1.3 Command:节点内的动态跳转

Command允许节点函数在运行时决定下一步去哪个节点,甚至形成循环。

from langgraph.types import Command def node_a(state) -> Command: if state["condition"]: return Command(goto="node_x", update={"key": "value"}) else: return Command(goto="node_y")
  • 返回者:普通节点函数。

  • 决定时机:运行时,节点内部自行决策。

  • 关键特征:

    • 可以跳转到任意节点(包括之前执行过的节点),形成循环。

    • 可以同时携带状态更新(update参数)。

    • Command(goto=[...])也可以接受节点列表,实现并行跳转。

  • 特殊形式:Command(resume=...)专用于恢复interrupt()造成的中断。

适用场景:Agent 路由、条件循环、需要根据推理结果动态选择下一步。

1.4 三者对比

维度add_edgeSendCommand
返回者无(声明式)路由函数节点函数
决定时机编译期运行时运行时
拓扑变化固定动态扇出(1→N)动态跳转(任意目标)
输入替换否是(arg替换目标节点 state)否
并行能力静态并行动态并行支持goto列表
循环支持需预先声明不直接支持支持任意循环
典型用途顺序流程Map-ReduceAgent 路由、循环、恢复中断

选择原则:

  • 流程固定 →add_edge

  • 动态数量的并行任务 →Send

  • 运行时决定下一步(含循环) →Command


第二部分:并行分支的实现方式

2.1 add_edge:静态并行

从同一个源节点添加多条边:

builder.add_edge("entry", "node_a") builder.add_edge("entry", "node_b") # entry 执行完后,node_a 和 node_b 并行

原理:entry节点完成后,引擎发现它有两条出边,就把node_a和node_b都加入同一超步(superstep)并行执行。

限制:

  • 编译期固定,运行时无法改变。

  • 并行的是不同的节点,不能让同一个节点并行跑多次。

  • 多个并行分支写入同一个无 reducer 的键时,会抛InvalidUpdateError。

2.2 路由返回节点名列表:动态并行

路由函数可以返回节点名列表,实现运行时动态决定并行哪些节点:

def router(state) -> list[str]: return ["node_a", "node_b"] builder.add_conditional_edges("entry", router, {...})

原理:与Command(goto=[...])一致,引擎将列表中的节点加入同一超步并行执行。此写法已通过实际测试验证可行。

2.3 Command(goto=[...]):节点内动态并行

return Command(goto=["node_a", "node_b"])

原理:Command的goto参数类型定义为Send | Sequence[Send | N] | N,支持节点名列表,引擎将列表中的节点加入同一超步并行执行。

2.4 Send:动态并行 + 同节点多实例

def router(state) -> list[Send]: return [Send("worker", {"item": item}) for item in state["items"]]

原理:每个Send创建一个独立的并行任务,目标节点可以相同,但arg不同。worker 节点收到的是Send传递的独立状态,而不是主图状态。

限制:结果合并回主图时,只有主图State中定义了 reducer 的键才会被合并。

2.5 关键限制:哪些写法不能用节点名列表

并非所有构件都支持列表形式的节点名。经过实际验证:

  • add_edge** 不能用节点名列表**。它只接受单个节点名作为参数,想并行多条边,只能多次调用:

    builder.add_edge("entry", "node_a") builder.add_edge("entry", "node_b") # ✅ 正确 # builder.add_edge("entry", ["node_a", "node_b"]) ❌ 报错
  • Send** 的第一个参数不能用节点名列表**。Send(node, arg)的node必须是单个字符串:

    return [Send("node_a", {...}), Send("node_b", {...})] # ✅ 正确 # Send(["node_a", "node_b"], {...}) ❌ 报错
  • 只有路由函数和Command(goto=...)支持节点名列表:

    # 路由函数 def router(state) -> list[str]: return ["node_a", "node_b"] # ✅ # Command return Command(goto=["node_a", "node_b"]) # ✅ # 如果要写path_map,应该写成这样 path_map={"node_a": "node_a", "node_b": "node_b"}

原因:add_edge和Send是“单目标”构件,设计假设就是“一个目标”;而路由函数和Command是“调度决策”构件,返回值本身是路由决策,列表自然表示“去多个地方”。

2.6 关键问题:能否在节点内直接返回 Send?

不能直接return [Send(...)],但可以通过Command间接使用。

普通节点函数的返回值被 LangGraph 严格限制为dict。如果在节点里直接返回Send列表:

def my_node(state) -> list[Send]: return [Send("worker", {"item": "A"})] # ❌ 报错

运行时会抛出:

InvalidUpdateError: Expected dict, got [Send(...)]

正确做法:通过Command的goto参数传递。Command的goto类型定义为Send | Sequence[Send | N] | N,明确支持Send对象或Send序列:

from langgraph.types import Command, Send def fanout_node(state) -> Command: sends = [Send("worker", {"item": item}) for item in state["items"]] return Command(goto=sends) # ✅ 合法

2.7 并行方式对比

方式写法位置并行对象能否同节点多实例能否用节点名列表决策时机
add_edge× N图声明不同节点❌❌编译期固定
路由返回节点名列表路由函数不同节点❌✅运行时
Command(goto=[...])节点函数不同节点❌✅运行时
Send列表()路由函数任意节点(可相同)✅不适用(用多个 Send)运行时
Command(goto=[Send...])节点函数任意节点(可相同)✅不适用(用多个 Send)运行时

选择原则:

  • 并行的是不同节点,且数量固定 →add_edge

  • 并行的是不同节点,但运行时才知道是哪些 →Command(goto=[...])或路由函数返回节点名列表

  • 需要同一个节点并行多次,每次输入不同 →Send(路由或Command(goto=[Send...]))

  • 所有并行场景,共享键必须配置 reducer(如Annotated[list, operator.add])


第三部分:人工介入中断详解

interrupt()用于在节点执行过程中暂停图,等待外部输入(如人工审批),然后通过Command(resume=...)恢复执行。

3.1 基本用法

from langgraph.types import interrupt def approval_node(state): answer = interrupt({"question": "是否批准?"}) return {"result": answer}

首次调用图时,执行到interrupt()会暂停,返回结果包含__interrupt__字段:

result = graph.invoke({"request": "..."}, config) # result["__interrupt__"] 是 Interrupt 对象列表 # 每个 Interrupt 有 id、value 等属性

恢复执行:

from langgraph.types import Command graph.invoke(Command(resume=恢复值), config) # 必须使用相同的 thread_id

3.2 恢复机制与 ID 映射

Command(resume=...)的行为取决于当前待处理中断的数量。

单个待处理中断

LangGraph 对单中断做宽松处理:

  • 传裸值:Command(resume="批准")→interrupt()返回"批准"。

  • 传字典,键不是真实 ****interrupt_id:整个字典作为值传递。

    Command(resume={"id": "fake"}) # interrupt() 收到 {'id': 'fake'}
  • 传字典,键是真实 ****interrupt_id:按 ID 映射,字典的值传递给中断。

    Command(resume={真实id: "fake"}) # interrupt() 收到 "fake"
多个待处理中断

必须使用{interrupt_id: value}字典,否则抛出RuntimeError:

RuntimeError: When there are multiple pending interrupts, you must specify the interrupt id when resuming.

正确写法:

resume_map = { interrupt_1.id: "批准-财务", interrupt_2.id: "批准-法务", } graph.invoke(Command(resume=resume_map), config)
实测结论
场景resume 写法结果
单中断,非 ID 键{"id": "fake"}✅ 整个字典作为值传给中断
单中断,真实 ID 键{真实id: "fake"}✅ 按 ID 映射,值传给中断
多中断,非 ID 键{"id": "fake"}❌RuntimeError
多中断,真实 ID 映射{id1: v1, id2: v2}✅ 各中断收到各自的值

3.3 Checkpoint 与恢复流程

LangGraph 使用 checkpoint 保存执行状态。每次超步结束或中断时写入 checkpoint,包含:

  • values:当前已合并的状态。

  • next:下一步计划执行的节点元组。中断发生时,next仍包含该超步计划执行的所有节点,而不是只包含未完成的节点。

  • interrupts:待处理的中断列表。

恢复时,引擎加载最新 checkpoint,根据超步内任务的完成标记:

  • 跳过已完成的节点(不重放,但保留其写入结果)。

  • 重放被中断的节点(从头执行,interrupt()返回恢复值)。

  • 所有写入合并后,继续推进后续超步。

3.4 注意事项

  • 节点重放:恢复时,包含interrupt()的节点会从函数开头重新执行。interrupt()之前的代码(如 API 调用、数据库写入)会再次运行。必须确保这些操作幂等,或将副作用移到interrupt()之后。

  • 相同 ****thread_id:恢复时必须使用与中断时相同的thread_id,否则会启动全新线程。

  • 一次性恢复所有中断:多个中断待处理时,必须为所有中断提供恢复值,只提供部分值会导致图再次暂停。

  • 同一节点内多个 ****interrupt():恢复值按调用顺序匹配,而不是按 ID。例如节点内有两个interrupt(),Command(resume=[v1, v2])会依次返回。

3.5 单中断完整示例

from typing import TypedDict from langgraph.graph import StateGraph, START, END from langgraph.types import interrupt, Command from langgraph.checkpoint.memory import InMemorySaver class State(TypedDict): result: str def only_node(state: State) -> dict: answer = interrupt({"question": "审批?"}) return {"result": f"interrupt 收到: {answer}"} builder = StateGraph(State) builder.add_node("only", only_node) builder.add_edge(START, "only") builder.add_edge("only", END) graph = builder.compile(checkpointer=InMemorySaver()) config = {"configurable": {"thread_id": "t1"}} r1 = graph.invoke({"result": ""}, config) real_id = r1["__interrupt__"][0].id r1_resume = graph.invoke(Command(resume="批准"), config) print(r1_resume["result"]) # interrupt 收到: 批准

3.6 多中断完整示例

import operator from typing import Annotated, TypedDict from langgraph.graph import StateGraph, START, END from langgraph.types import interrupt, Command, Send from langgraph.checkpoint.memory import InMemorySaver class State2(TypedDict): results: Annotated[list[str], operator.add] def entry(state: State2) -> dict: return {} def a(state: dict) -> dict: ans = interrupt({"node": "a"}) return {"results": [f"a: {ans}"]} def b(state: dict) -> dict: ans = interrupt({"node": "b"}) return {"results": [f"b: {ans}"]} def fanout_router(state: State2) -> list[Send]: return [Send("a", {}), Send("b", {})] builder2 = StateGraph(State2) builder2.add_node("entry", entry) builder2.add_node("a", a) builder2.add_node("b", b) builder2.add_edge(START, "entry") builder2.add_conditional_edges("entry", fanout_router) builder2.add_edge("a", END) builder2.add_edge("b", END) graph2 = builder2.compile(checkpointer=InMemorySaver()) config2 = {"configurable": {"thread_id": "t2"}} r2 = graph2.invoke({"results": []}, config2) resume_map = {} for intr in r2["__interrupt__"]: node = intr.value.get("node") resume_map[intr.id] = f"批准-{node}" r2_resume = graph2.invoke(Command(resume=resume_map), config2) print(r2_resume["results"]) # ['a: 批准-a', 'b: 批准-b']

第四部分:总结速查

控制流机制

机制用途写在哪里返回类型
add_edge静态流程图声明无
Send动态扇出(可同节点多实例)路由函数list[Send]
Command动态跳转(含循环)节点函数Command
Command(goto=[Send...])节点内扇出节点函数Command

并行分支

场景推荐方式能否用节点名列表
固定并行,不同节点add_edge× N❌
运行时决定并行哪些节点Command(goto=[...])或条件边路由返回节点名列表✅
同一节点并行多次,输入不同Send列表 或Command(goto=[Send...])不适用
所有并行场景共享键必须配置 reducer(如Annotated[list, operator.add])—

中断恢复

待处理中断数量resume 写法结果
1 个裸值 或 任意字典值直接传给中断
1 个{真实id: 值}按 ID 映射
多个{真实id: 值}字典按 ID 精确分发
多个非 ID 键字典❌RuntimeError

核心陷阱

  1. 节点函数不能返回Send列表,必须通过路由函数或Command(goto=...)返回。

  2. add_edge** 和Send不接受节点名列表**,只有路由函数和Command(goto=[...])可以。

  3. 并行写入同一键必须配置 reducer,否则抛InvalidUpdateError。

  4. 多中断必须用interrupt_id映射,单中断的宽松处理不适用于多中断。

  5. 恢复时节点从头重放,interrupt()之前的副作用必须幂等。

  6. Send** 传递的arg替换目标节点 state**,而非合并进主图,只有节点返回值中带 reducer 的键才会合并回主图。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 4:45:00

CTF-MISC从零到一:核心题型分类、工具链与解题思维全解析

1. 从零拆解CTF-MISC:这个方向到底在考什么刚接触CTF那会儿,我一度觉得MISC是所有方向里最“不讲道理”的一个。Web有漏洞类型可循,Reverse有汇编逻辑可推,Crypto有数学公式可算,唯独MISC,每次拿到题目都像…

作者头像 李华
网站建设 2026/10/14 4:45:00

GoLand调试报错DWARF too short?从Delve原理到排查全链路

1. 这个报错到底在说什么:从DWARF调试信息说起如果你在用GoLand点下那个绿色小虫子图标,结果等来的不是断点命中,而是一行红字:could not launch process: decoding dwarf section info at offset 0x0: too short,那你…

作者头像 李华
网站建设 2026/10/14 4:43:27

如何开始写技术分享文章?从选题到发布的完整指南

我周围有不少朋友问过同一个问题:到底怎么开始写技术分享文章。很多人觉得自己水平不够、担心没人看、怕写错丢脸,结果拖了一年半载还没动笔。我自己也是从这种状态过来的,从写第一篇磕磕绊绊,到现在能比较稳定地产出,…

作者头像 李华
网站建设 2026/10/14 4:43:02

PPT自动化生成引擎:基于Open XML的可编程幻灯片编译系统

1. 项目概述:这不是一个PPT模板库,而是一套可编程的幻灯片生成引擎“ppt-master”这个名字乍一听像某个网红PPT资源站,但实际接触过的人很快会意识到——它根本不是传统意义上的“模板合集”。我第一次在某技术分享会上听到这个词时&#xff…

作者头像 李华
网站建设 2026/10/14 4:43:00

Linux 驱动开发学习笔记:I2C 子系统从入门到实践

1. 引言I2C(Inter-Integrated Circuit)总线是一种由 Philips 公司(现 NXP)开发的两线式串行通信总线,广泛应用于嵌入式系统中连接低速外设,如传感器、EEPROM、RTC 时钟芯片、触摸控制器等。在 Linux 内核中…

作者头像 李华
网站建设 2026/10/14 4:43:00

微信小程序支付后台Java实现:从下单到回调的完整闭环

简介:这份资源是面向Java后端开发者与小程序开发者的微信小程序支付后台实现实例,聚焦于打通小程序端与微信支付接口的完整链路,适合已具备一定Java与网络通信基础、希望快速落地支付功能的中级开发者参考。资源包共1个文件,为1个…

作者头像 李华