langgraph的分支结构之 - 静态分支详解

文章目录

      • [静态分支(Static Branch)](#静态分支(Static Branch))
        • 并行节点
        • 条件分支
          • [不使用 `path_map`](#不使用 path_map)
          • [使用 `path_map`](#使用 path_map)
          • 同时路由至多个节点
            • [1. 不用`path_map`](#1. 不用path_map)
            • [2. 添加映射](#2. 添加映射)
        • [defer node execution](#defer node execution)
          • 底层实现机制
            • [1. 编译阶段:使用特殊 Channel](#1. 编译阶段:使用特殊 Channel)
            • [2. 常规运行阶段-写入但不触发](#2. 常规运行阶段-写入但不触发)
            • [3. 常规流程结束后:调用`finish()`唤醒延迟节点](#3. 常规流程结束后:调用finish()唤醒延迟节点)
            • [4. 总结](#4. 总结)
          • 案例
          • 小结

静态分支(Static Branch)

  • 定义 :节点的下游候选节点 在图编译阶段就完全确定,只是运行时根据条件选择哪条边执行。
  • 特点:
    • 下游节点集合固定,数量、目标在编译时确定
    • 运行时可选择一个或多个下游目标
    • 可以用来做条件分支,但不生成新的节点

⚡ 核心判断:

编译期知道下游集合 → 静态分支


并行节点

并行节点是最简单的静态分支形式。

当多个节点都从同一个上游节点触发时,它们会在同一个超步被激活。典型写法如下:

python 复制代码
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")

示例:两个节点并行执行

python 复制代码
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langchain.messages import HumanMessage

from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke(
        [
            HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")
        ]
    ).content
    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke(
        [
            HumanMessage(f"写一个关于 {state['topic']} 的笑话")
        ]
    ).content
    return {
        "joke": joke
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")

builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

graph = builder.compile()
res = graph.invoke({"topic": "猫咪"})
print(res)

from IPython.display import display

display(graph)

上述案例中,node_anode_b 都由 START 触发。图运行时,二者会在同一个超步中被调度,它们各自读取当前状态并独立执行。

需要注意的是:

  • 这里的"并行"主要指调度语义上的并行;两个节点之间没有先后依赖;
  • 它们的输出会在当前超步执行完成后统一合并到状态中;
  • 如果两个节点写入同一个状态字段,则该字段通常需要配置合适的 Reducer ,否则可能出现状态更新冲突,抛出 InvalidUpdateError 异常

输出如下

json 复制代码
{
    "topic": "猫咪",
    "poem": "《猫咪》\n夜巡檐角步轻悄,昼卧花阴晒暖毛。\n偶逐流莺穿柳过,忽惊蝴蝶上眉梢。\n\n赏析:这首作品以猫咪日常为切入点,通过"夜巡檐角"、"昼卧花阴"展现其昼伏夜出的习性,"逐流莺"、"惊蝴蝶"则捕捉其灵动瞬间。全诗未著"猫"字,却以"轻悄步"、"晒暖毛"等细节勾勒出猫咪的形神特征,尾句"上眉梢"更将好奇神态融入蝴蝶意象,物我交融,余韵悠长。",
    "joke": "有一只猫走进了一家披萨店,店员问:"您好,请问您要什么?"\n猫淡定地说:"喵~(我要一个12寸的海鲜披萨,加双倍芝士。)"\n店员愣了愣,说:"猫先生,您确定?"\n猫点点头,店员只好照做,然后猫掏出钱包付了钱,趴在小桌子上优雅地吃了起来。\n\n这时,旁边桌的客人看呆了,忍不住问店员:"它怎么能像人一样点餐、付钱、吃披萨?"\n店员耸耸肩,小声说:"我也不知道,但自从它上次用我的电脑打了一局《猫里奥》通关后,我就没敢拒绝它的任何要求。""
}
条件分支

StateGraph 提供了 add_conditional_edges 方法,用于从某个上游节点出发,根据运行时状态选择下游节点。

方法签名如下

python 复制代码
def add_conditional_edges(
    self,
    source: str,
    path: Callable[..., Hashable | Sequence[Hashable]]
    | Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
    | Runnable[Any, Hashable | Sequence[Hashable]],
    path_map: dict[Hashable, str] | list[str] | None = None,
) -> Self:

不考虑 self,核心参数有三个:

  • source:条件分支的起始节点;
  • path:路由规则,是一个可执行对象,通常是函数
  • path_map:路由规则的返回值到真实节点名之间的映射关系。

其中,path 的返回值表示跳转的目标节点,可以是:

  • 字符串或特殊对象 END 表示的单个目标;
  • 字符串或特殊对象 END 表示的多个目标组成的序列;

path_map

  • 可以省略,即取默认值 None ,此时 path 返回值中出现的字符串必须是合法的节点名称。

  • 可以是字典,维护 path 返回值和真实节点的映射。

  • 也可以是列表,如下

    python 复制代码
    path_map=["node_a", "node_b", "node_c"]

    相当于

    python 复制代码
    path_map={
        "node_a": "node_a",
        "node_b": "node_b",
        "node_c": "node_c",
    }

    此时也要求 path 返回值中出现的字符串必须是合法的节点名称。

不使用 path_map

如果不传 path_map,那么路由函数的返回值通常应当直接是图中的节点名称。如下

python 复制代码
def router(state: OverAllState) -> Literal["node_a", "node_b"]: 
    if "诗" in state["content_type"]: 
        return "node_a" 
    return "node_b"

此时,router 返回的 "node_a""node_b" 必须能够直接对应图中已经注册的节点名。

完整案例如下

python 复制代码
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    content_type: str
    poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def router(state: OverAllState) -> Literal["node_a", "node_b"]:
    if "诗" in state["content_type"]:
        return "node_a"
    return "node_b"

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_conditional_edges(START, router)
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

graph = builder.compile()
poem_res = graph.invoke({"topic": "布偶狗", "content_type": "诗"})
joke_res = graph.invoke({"topic": "布偶狗", "content_type": "笑话"})

print('=' * 30, '-> poem_res <-', '=' * 30)
print(poem_res)
print('=' * 30, '-> joke_res <-', '=' * 30)
print(joke_res)

from IPython.display import display

display(graph)

输出如下

json 复制代码
============================== -> poem_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "诗",
    "poem": "《咏布偶狗》\n绒身静卧似憨眠,无骨何妨守户前。\n不吠晨昏随主步,垂髫怜抱共跚连。\n\n注:我的诗中"无骨"暗喻布偶狗没有生命骨架,却依然守护着家门;"跚连"形容孩童与布偶狗相偕学步的蹒跚之态。通过"绒身""垂髫"等意象,既保留了布偶狗的柔软特质,又赋予其超越玩偶的温情守护之意,在静态与动态的转换间实现了拟人化的诗意升华。"
}

============================== -> joke_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "笑话",
    "joke": "有一天,一只布偶狗在街上闲逛,遇到了一只真狗。\n\n真狗好奇地问:"你是什么品种?怎么看起来毛这么长、这么滑,眼神还这么呆?"\n\n布偶狗骄傲地挺起胸膛:"我可是最顶级的仿真布偶狗!纯手工缝制,水晶眼睛,进口棉花填充,咬不坏、摔不烂,还不用溜。"\n\n真狗愣了一下,问:"那你平时都干嘛?"\n\n布偶狗叹了口气:"看家呗......主人说我最大的优点就是------丢不了,谁捡了都想还回来,因为太假了,连肉都不香。""
}

观察图结构可以发现,**__start__指向 node_a node_b**的线是虚线,这表示节点的跳转是有条件的,不是固定的。

使用 path_map

如果不希望路由函数直接返回节点名,而是返回业务语义更强的标识,可以使用 path_map 进行映射。如下:

python 复制代码
def router(state: OverAllState) -> Literal["a", "b"]: 
    if "诗" in state["content_type"]: 
        return "a" 
    return "b"

builder.add_conditional_edges( 
    START,
    router,
    path_map={ 
        "a": "node_a", 
        "b": "node_b", 
    } 
)

此时返回的 "a""b" 可以不是图中已注册的节点名称,但要通过 path_map 映射到正确的节点

这种写法的好处是:

  • 路由函数可以返回业务含义更清晰的标签;
  • 图节点名称可以保持工程化命名;
  • 渲染图结构时,边上可以显示路由标签,使图更容易理解。

示例如下

python 复制代码
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    content_type: str
    poem: str
    ci_poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def router(state: OverAllState) -> Literal["a", "b"]:
    if "诗" in state["content_type"]:
        return "a"
    return "b"

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_conditional_edges(
    START,
    router,
    path_map={
        "a": "node_a",
        "b": "node_b",
    }
)
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

graph = builder.compile()
poem_res = graph.invoke({"topic": "布偶狗", "content_type": "诗"})
joke_res = graph.invoke({"topic": "布偶狗", "content_type": "笑话"})

print('=' * 30, '-> poem_res <-', '=' * 30)
print(poem_res)
print('=' * 30, '-> joke_res <-', '=' * 30)
print(joke_res)

from IPython.display import display

display(graph)

输出如下

json 复制代码
============================== -> poem_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "诗",
    "poem": "《布偶狗》\n布偶妆成似犬形,绒毛细软眼如星。\n不吠不跳乖巧坐,疑是仙童降画屏。\n\n注:我的仿写创作思路是以玩具布偶狗为意象,通过"妆成似犬形"点明其拟态特征。后三句运用比喻(眼如星)、拟人(乖巧坐)、想象(仙童降画屏)手法,赋予静态玩偶动态神韵。末句以"仙童"暗喻其灵动气质,呼应首句的"布偶"属性,使全诗在虚实之间形成童趣与仙气的张力。"
}

============================== -> joke_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "笑话",
    "joke": "好的,这里有一个关于布偶狗的笑话:\n\n有一只布偶狗,它从来不叫,也从来不跑。它的主人很担心,就带它去看兽医。\n\n兽医检查了半天,推了推眼镜说:"你这狗啊,没别的毛病,就是太乖了,乖到连狗的身份都忘了。你知道它为什么这么安静吗?"\n\n主人摇摇头。\n\n兽医叹了口气,说:"因为它是个'布偶',没有嘴,也没有腿。它唯一的技能,就是等你给它配个'程序',然后坐在那里,假装自己是一只不会乱咬东西的电子狗。说真的,这笑话比它还冷。""
}

观察图结构可以发现,__start__指向 node_a node_b的虚线上出现了文本 a b ,它们是路由函数返回的名称,通过**path_map**映射到具体的下游节点。

同时路由至多个节点

add_conditional_edges 也支持一次路由到多个下游节点。

1. 不用path_map

示例如下

python 复制代码
from collections.abc import Sequence
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    content_type: str
    poem: str
    ci_poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def node_c(state: OverAllState) -> OverAllState:
    ci_poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的词")]).content

    return {
        "ci_poem": ci_poem
    }

def router(state: OverAllState) -> Sequence[Literal["node_a", "node_b", "node_c"]]:
    if "诗" in state["content_type"]:
        return ["node_a", "node_c"]
    return ["node_b", "node_c"]

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_conditional_edges(
    START,
    router
)
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)
builder.add_edge("node_c", END)

graph = builder.compile()
poem_res = graph.invoke({"topic": "布偶狗", "content_type": "诗"})
joke_res = graph.invoke({"topic": "布偶狗", "content_type": "笑话"})

print('=' * 30, '-> poem_res <-', '=' * 30)
print(poem_res)
print('=' * 30, '-> joke_res <-', '=' * 30)
print(joke_res)

from IPython.display import display

display(graph)

在这个例子中:

  • content_type 包含 "诗" 时,同时触发 node_anode_c
  • 否则同时触发 node_bnode_c

输出如下

json 复制代码
============================== -> poem_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "诗",
    "poem": "《咏布偶狗》\n铁骨棉裘本异俦,垂头摇尾学温柔。\n非关皮相失真性,恐向人间惹旧愁。\n\n赏析:这首作品以布偶狗为题,通过"铁骨棉裘"的悖论意象,巧妙揭示材质与形态的错位。后两句笔锋一转,从拟态之"学温柔"深入至本真之"失真性",结句"恐向人间惹旧愁"更以物喻人,赋予玩偶以灵性,暗含对世情虚伪的讽喻,余韵悠长。",
    "ci_poem": "《捣练子·布偶狗》\n针线巧,布绒柔。静卧妆台伴月幽。未解铃铛摇夜语,却随清梦上云舟。\n\n注:我的仿作将布偶狗拟人化,通过"针线巧,布绒柔"展现其手工质感,"伴月幽"营造静谧氛围。末句"随清梦上云舟"暗合现代人渴望逃离现实的隐喻,既保留古典词牌的意境美,又赋予布偶狗超越物象的情感寄托。"
}

============================== -> joke_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "笑话",
    "ci_poem": "《鹧鸪天·布偶狗》\n绒目含星尾作虹,垂蹄静卧笑春风。\n无人自戏绒球转,得主频摇铃铎浓。\n\n偎暖日,扑帘栊。娇憨总在爪痕中。\n痴心不解浮生事,一抱虚温万事空。\n\n注:本词以布偶狗为意象,通过"绒目含星"、"垂蹄静卧"等细节摹写其柔顺之态。下阕"偎暖日"、"扑帘栊"拟人化笔法,寄寓物我相忘之趣。结句"一抱虚温"暗喻繁华终归寂灭之理,将玩偶之趣升华为对生命本真的哲思。",
    "joke": "小明带他的布偶狗去宠物医院,医生检查后说:"你这狗没心跳没呼吸,已经死了。"\n小明急了:"不可能!它刚刚还对我摇尾巴!"\n医生叹了口气:"那是你兜里的钥匙串,走起路来叮当响,它尾巴上的铃铛在共振。"\n小明沉默片刻,掏出手机拍了张照发朋友圈:"我的狗死了,但它的尾巴还在听我走路。"\n评论区炸了:"建议主人也去查查脑子,可能也共振坏了。""
}

观察图结构可以发现,node_a 、**node_b node_c**独立于图结构之外。

和上一节案例相比,**router**函数返回的是序列而非单个节点,渲染器无法推断节点间的映射关系。

2. 添加映射

通过 path_map 显示声明映射关系,明确下游节点集合,在提升代码可读性的同时,也有助于渲染器正确展示条件边。

当前场景下 path 返回值中的字符串就是合法的节点名称,path_map 可以是字典

python 复制代码
path_map={
    "node_a": "node_a",
    "node_b": "node_b",
    "node_c": "node_c",
}

也可以是列表

python 复制代码
path_map=["node_a", "node_b", "node_c"]

完整案例如下

python 复制代码
from collections.abc import Sequence
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    content_type: str
    poem: str
    ci_poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def node_c(state: OverAllState) -> OverAllState:
    ci_poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的词")]).content

    return {
        "ci_poem": ci_poem
    }

def router(state: OverAllState) -> Sequence[Literal["node_a", "node_b", "node_c"]]:
    if "诗" in state["content_type"]:
        return ["node_a", "node_c"]
    return ["node_b", "node_c"]

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_conditional_edges(
    START,
    router,
    path_map={
        "node_a": "node_a",
        "node_b": "node_b",
        "node_c": "node_c",
    }
    # path_map=["node_a", "node_b", "node_c"]
)
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)
builder.add_edge("node_c", END)

graph = builder.compile()
poem_res = graph.invoke({"topic": "布偶狗", "content_type": "诗"})
joke_res = graph.invoke({"topic": "布偶狗", "content_type": "笑话"})

print('=' * 30, '-> poem_res <-', '=' * 30)
print(poem_res)
print('=' * 30, '-> joke_res <-', '=' * 30)
print(joke_res)

from IPython.display import display

display(graph)

输出如下

json 复制代码
============================== -> poem_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "诗",
    "poem": "《布偶狗》\n布偶憨然卧锦茵,牵绳随主步香尘。\n偶因客至微昂首,原是无声座上宾。\n\n注:我的创作思路是抓住布偶狗静中有动的特质。首句以"憨然卧锦茵"勾勒其静态萌态,次句"牵绳随主步香尘"暗喻宠物与人的陪伴关系。转句"偶因客至微昂首"捕捉其反应,既保留布偶的拟真特性,又暗示机械性动作。结句"原是无声座上宾"点明其非生物属性,与开篇"布偶"形成闭环。全诗通过动静、真假的微妙转化,赋予静物以灵性。",
    "ci_poem": "《醉花阴·布偶狗》\n绒耳垂垂星作眸,憨卧小窗幽。棉絮裹憨态,线描眉眼,缝就三分柔。\n旧尘不染春衫袖,伴我度春秋。虽无温舌语,月斜人静,也解倚床头。\n\n注:我以拟人笔法赋予布偶狗灵性,通过"绒耳垂垂""棉絮裹憨态"等细节勾勒其柔软形态,又于"月斜人静"处暗藏陪伴深情。下阕"旧尘不染"暗喻其纯净守护,末句"解倚床头"更将静物写活,道出玩偶与主人之间无声的慰藉。"
}

============================== -> joke_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "笑话",
    "ci_poem": "《南歌子·布偶狗》\n绒耳垂春水,圆睛印月痕。\n棉絮缝成憨态真。\n不向朱门摇尾,卧蓬门。\n\n旧线牵云袖,新绒补雪身。\n线头散作泪斑纹。\n但把残年缝进,掌中温。\n\n注:我的布偶狗,是外婆用旧棉裤的棉花和二姐穿小的灯芯绒外套一针一线缝成的。二十年过去,棉絮从它破了洞的耳朵里露出,像吐着舌头。我模仿陆游"我与狸奴不出门"的意境,通过"棉絮缝春水"喻指时光在针脚里流逝,而"线头泪斑"则是故意缝补的裂痕------我们总要在破碎的事物里,重新认出圆满的形状。",
    "joke": "好的,这是一个关于布偶狗的笑话:\n\n有一只布偶狗,它特别特别懒,懒到什么程度呢?它的主人叫它去捡飞盘,它都懒得动。主人决定训练它,就扔出一个飞盘,大声喊:"布偶,快去捡!"\n\n布偶狗懒洋洋地抬头看了一眼飞盘落在远处,然后又趴下了。主人很生气,又扔了一个,它还是不理。\n\n主人无奈,只好采取了终极手段------假扮成一只会动的布偶狗,自己冲过去把飞盘叼了回来。\n\n布偶狗一看,浑身毛发都惊得炸了起来,它对旁边的一只小花狗低声说:"天哪,你看,那玩意儿居然能自己动!它还是个永动机!吓死狗了,好赖哦!""
}

如图所示,图结构被正确渲染。

defer node execution

某些情况下,我们希望在所有常规任务节点执行完毕后,再进行日志、审计等收尾工作

此时可以在添加节点时设置**defer=True**,如下:

python 复制代码
builder.add_node("audit_node", audit_node, defer=True)

defer=True 的含义是:

当前节点不会在其被触发后立即执行,而是被延迟到常规图运行流程结束后,再在额外的超步中触发执行。

这类节点适合用于:

  • 日志记录;
  • 审计检查;
  • 结果汇总;
  • 收尾清理;
  • 统一校验前面节点是否已完成。
底层实现机制
1. 编译阶段:使用特殊 Channel
  1. LangGraph 在编译状态图时,会为边创建对应的 Channel

  2. 此时会根据节点的 defer 属性创建不同类型的 Channel,如下。

    python 复制代码
    self.channels[branch_channel] = (
        LastValueAfterFinish(Any)
        if node.defer
        else EphemeralValue(Any, guard=False)
    )

    defer 默认值为 False

    对于普通节点,边对应的通道类型是 EphemeralValue ,可以理解为普通临时通道;而对于 defer=True 的节点,边对应的通道类型是特殊的 LastValueAfterFinish

2. 常规运行阶段-写入但不触发
  1. 在图运行过程中,每个节点执行完成后,会向其下游边对应的 Channel 写入数据。
  2. 常规的 Channel 在运行开始后处于可用状态,被写入后记录在 updated_channels 列表中,从而在下一个超步中触发下游节点的执行。
  3. 但是,LastValueAfterFinish 类型的通道起初是不可用的,首次被写入时不会添加到 updated_channels 列表中,下游节点自然不会被触发。
3. 常规流程结束后:调用finish()唤醒延迟节点
  1. LangGraph 底层用 trigger_to_nodes 维护了 边的 Channel -> 节点 的映射,是一个字典。
  2. 在每个超步结束后,运行时会根据 updated_channels 判断是否还有新的节点需要被触发。
  3. 如果 updated_channelstrigger_to_nodes 的 key 没有交集,说明当前没有新的普通节点需要继续执行,常规运行流程已结束。
  4. 此时,LangGraph 运行时会调用所有 Channelfinish() 方法。
  5. 对于普通 Channelfinish() 通常不会产生新的触发效果;但对于 LastValueAfterFinish 类型通道,首次调用 finish() 时,会将内部的 finished 标记设置为 True ,并返回 True
  6. 一旦 finished=True ,该通道的 is_available() 就会变为 True
  7. 于是,原本被延迟的通道会被加入 updated_channels ,从而在额外的超步中触发对应的 defer 节点。
4. 总结

因此,defer=True 的运行机制可以概括为:

触发边的 Channel 为特殊类型,首次写入不触发;常规流程结束后,特殊通道 finish();通道变为可用;从而触发延迟节点,后者在额外超步中执行。

案例

示例如下

python 复制代码
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from loguru import logger

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def audit_node(state: OverAllState) -> OverAllState:
    logger.info(f"任务节点全部执行完毕,诗 {'已生成' if state['poem'] else '未生成'},笑话 {'已生成' if state['joke'] else '未生成'}")

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("audit_node", audit_node, defer=True)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")
builder.add_edge(START, "audit_node")
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)
builder.add_edge("audit_node", END)

graph = builder.compile()
res = graph.invoke({"topic": "布偶狗"})
print(res)

from IPython.display import display

display(graph)

在这个图中:

  • node_anode_b 是普通节点,在常规流程中被触发;
  • audit_node 虽然也由 START 触发,但由于设置了 defer=True,不会立即执行;
  • node_anode_b 都执行完成后,常规运行流程结束,audit_node 才会在额外的超步中执行;
  • 因此,audit_node 中可以读取到 node_anode_b 已经写入并提交后的状态。

输出如下

json 复制代码
2026-05-28 17:07:09.618 | INFO | __main__:audit_node:40 - 任务节点全部执行完毕,诗 已生成,笑话 已生成

{
    'topic': '布偶狗',
    'poem': '《咏布偶狗》\n'
            '绸身棉腑自憨娇,静卧无眠守寂寥。\n'
            '不吠不嗔街巷事,偎人一晌解无聊。\n'
            '\n'
            '注:我的创作思路是通过拟人化手法,赋予布偶狗以温柔静默的品格。'
            '首句以"绸身棉腑"点明材质,次句"守寂寥"暗合宠物陪伴的特质。'
            '后两句通过"不吠不嗔"与"解无聊"的对比,凸显布偶狗无声胜有声的治愈力量,'
            '在机械玩物中注入人文温度。',
    'joke': '这是一个关于布偶狗的笑话:\n'
            '\n'
            '有一只布偶狗,它什么都好,毛茸茸的,蓝眼睛,性格温顺,'
            '唯一的缺点就是------它是个布偶,不会动。\n'
            '\n'
            '它的主人每天都把它放在沙发上,假装它在看电视、在思考狗生。\n'
            '\n'
            '有一天,主人带了一只真的活狗回家,想让布偶狗有个伴。'
            '活狗非常活泼,对着布偶狗又叫又摇尾巴,但布偶狗一动不动。\n'
            '\n'
            '活狗很奇怪,绕着布偶狗转了三圈,然后凑上去闻了闻。\n'
            '\n'
            '最后,活狗叹了口气,对主人说:\n'
            '\n'
            '"主人,这只狗......是不是在用'静默模式'跟我交流?'
            '它的蓝牙是不是断了?"'
}
小结

defer=True 适合"最后执行"的收尾节点。

它的本质是通过特殊的 Channel 控制触发时机:

  1. 编译阶段为延迟节点创建 LastValueAfterFinish 类型通道;
  2. 常规运行阶段该通道可以被写入,但不可用,不会触发下游节点;
  3. 常规流程结束后调用通道的 finish() 方法;
  4. finish() 首次生效后将通道标记为可用;
  5. 延迟节点在额外超步中被触发执行。

因此, defer=True 将节点的触发信号延迟释放,使该节点在常规流程结束后执行。

相关推荐
imc.111 小时前
linux基础IO
linux·运维·服务器
躺不平的理查德1 小时前
让Ubuntu 的第二块网卡 ens37 永久固定为 192.168.30.111
运维·服务器
nianniannnn1 小时前
Qt QMessageBox知识点
开发语言·数据库·qt
—Miss. Z—1 小时前
计算机二级MySQL——简单应用题(存储过程&存储函数)
数据库·oracle·php
其美杰布-富贵-李2 小时前
Spring Boot 依赖注入说明文档
java·spring boot·python
tachibana22 小时前
hot100 数组中的第K个最大元素(215)
java·数据结构·算法·leetcode
bitbrowser2 小时前
Facebook 验证反复回到原页面,应该从哪里排查
运维·服务器·facebook
our_times2 小时前
2026年Java开发者破局指南:Spring AI 2.0 与 Agent 开发实战
java·人工智能·spring
Vect__2 小时前
MySQL 数据类型和约束:从字段设计到表结构建模
数据库·mysql