LangGraph 设计与实现

第3章 StateGraph 图构建 API

作者 杨艺韬 · 10,911 字 · 发布于 · 更新于

本章基于 LangGraph 1.1.6 / langgraph-checkpoint 4.0.1 源码分析。源码路径:libs/langgraph/langgraph/graph/ 目录。

StateGraph 是开发者与 LangGraph 交互的首要入口。它提供了一套声明式的 API,让开发者可以用自然直觉的方式定义节点、边和条件分支,然后通过 compile() 一键转换为可执行的 Pregel 运行时。本章将深入 libs/langgraph/langgraph/graph/state.py 源码,逐行剖析 StateGraph 的构建 API、节点类型系统、边的三种形态,以及编译过程中发生的关键转换。

本章要点

  • StateGraph 类的完整解剖:构造器、状态模式解析、内部数据结构
  • add_node 的五重重载:名称推断、输入模式推断、Command 返回类型解析
  • 三种边的实现差异:add_edge(直接边)、add_conditional_edges(条件边)、waiting_edges(汇聚边)
  • START/END 常量的本质:START 是编译时自动生成的隐藏节点,END 则没有节点实体,只是"不写入触发 Channel"
  • StateNodeSpec 与节点类型协议:理解节点函数的多种合法签名
  • MessageGraph 与 MessagesState:消息状态的便捷封装

3.1 StateGraph 类的构造

StateGraph 是开发者构建 LangGraph 应用的第一个接触点。它的设计目标是让图的定义尽可能直观和声明式——开发者只需要关注"做什么"(定义节点和边),而不需要关注"怎么执行"(Pregel 循环、Channel 管理等底层细节)。但要真正理解 StateGraph 的行为,特别是在遇到边界情况和错误时,我们需要深入其构造过程。

3.1.1 构造器签名

StateGraph 的构造器接受状态模式作为核心参数,并可选地指定输入/输出模式和上下文模式:

# 源码位置:langgraph/graph/state.py
class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
    def __init__(
        self,
        state_schema: type[StateT],
        context_schema: type[ContextT] | None = None,
        *,
        input_schema: type[InputT] | None = None,
        output_schema: type[OutputT] | None = None,
        **kwargs: Unpack[DeprecatedKwargs],
    ) -> None:

这里的泛型参数 StateT, ContextT, InputT, OutputT 为类型检查器提供了推断依据,但在运行时并不强制约束。**kwargs 只用来接住已弃用的旧参数:config_schema 在 0.6.0(PR #5243)随 context_schema 的引入而弃用,传入时发 LangGraphDeprecatedSinceV10 警告,并在未显式传 context_schema 时充当它;input / output 分别让位于 input_schema / output_schema,发 LangGraphDeprecatedSinceV05 警告。处理完这些旧参数后,构造器的核心逻辑分为两步:

第一步:初始化内部数据结构

# 源码位置:langgraph/graph/state.py,__init__ 方法
self.nodes = {}           # dict[str, StateNodeSpec] 节点注册表
self.edges = set()        # set[tuple[str, str]] 直接边集合
self.branches = defaultdict(dict)  # 条件边注册表
self.schemas = {}         # schema -> channel 映射缓存
self.channels = {}        # 全局 Channel 注册表
self.managed = {}         # 托管值注册表
self.compiled = False     # 是否已编译的标记
self.waiting_edges = set() # 多源汇聚边集合

第二步:解析状态模式

# libs/langgraph/langgraph/graph/state.py:245
self.state_schema = state_schema
self.input_schema = cast(type[InputT], input_schema or state_schema)
self.output_schema = cast(type[OutputT], output_schema or state_schema)
self.context_schema = context_schema

# 核心:解析每个 schema 的字段,创建对应的 Channel
self._add_schema(self.state_schema)
self._add_schema(self.input_schema, allow_managed=False)
self._add_schema(self.output_schema, allow_managed=False)

注意 input_schema 和 output_schema 默认为 state_schema。这意味着如果不显式指定,图的输入和输出与状态具有相同的模式。但当你需要"输入只接受部分字段"或"输出只暴露部分字段"时,可以指定不同的 schema。

3.1.2 状态模式到 Channel 的转换

这是 StateGraph 构造过程中最重要的环节。LangGraph 的核心理念之一是让开发者用熟悉的 Python 类型系统来定义状态,然后由框架自动将类型注解转换为内部的 Channel 表示。这意味着开发者不需要直接与 Channel 打交道——他们只需要定义一个普通的 TypedDict 或 Pydantic 模型,框架就能理解每个字段应该使用什么样的存储和更新策略。

_add_schema 方法是理解 StateGraph 的关键。它调用 _get_channels 函数,将 Python 类型注解转换为 Channel 实例:

# 源码位置:langgraph/graph/state.py
def _get_channels(
    schema: type[dict],
) -> tuple[dict[str, BaseChannel], dict[str, ManagedValueSpec], dict[str, Any]]:
    if not hasattr(schema, "__annotations__"):
        # 没有字段注解的类型(如 Annotated[list, add_messages])
        # 使用 __root__ 作为单一 Channel
        return (
            {"__root__": _get_channel("__root__", schema, allow_managed=False)},
            {},
            {},
        )

    # 有字段注解的类型(TypedDict, dataclass, Pydantic BaseModel)
    type_hints = get_type_hints(schema, include_extras=True)
    all_keys = {
        name: _get_channel(name, typ)
        for name, typ in type_hints.items()
        if name != "__slots__"
    }
    return (
        {k: v for k, v in all_keys.items() if isinstance(v, BaseChannel)},
        {k: v for k, v in all_keys.items() if is_managed_value(v)},
        type_hints,
    )

对于每个字段,_get_channel 函数按优先级检查三种情况:

graph TD
    A[字段类型注解] --> S[剥掉 Required / NotRequired 包装]
    S --> H{是否为 ManagedValue?}
    H -->|是| I[返回 ManagedValueSpec]
    H -->|否| C{元数据里有 BaseChannel<br/>实例或子类?}
    C -->|是| D[直接使用该 Channel]
    C -->|否| E{最后一个元数据是否为<br/>可调用的二参函数?}
    E -->|是| G[创建 BinaryOperatorAggregate]
    E -->|否| F[兜底:创建 LastValue Channel]

注意判断顺序:源码里 ManagedValue 排第一、显式 Channel 第二、Reducer 第三、LastValue 兜底。

对应的源码逻辑:

# 源码位置:langgraph/graph/state.py(简化:省略了开头剥掉 Required / NotRequired
# 的处理,以及 allow_managed=False 时遇到 ManagedValue 抛 ValueError 的分支)
def _get_channel(name, annotation, *, allow_managed=True):
    # 1. 检查是否为 ManagedValue(如 IsLastStep)
    if manager := _is_field_managed_value(name, annotation):
        return manager

    # 2. 检查是否有显式 Channel 注解
    #    如 Annotated[str, EphemeralValue]
    elif channel := _is_field_channel(annotation):
        channel.key = name
        return channel

    # 3. 检查是否有 Reducer 函数注解
    #    如 Annotated[list, operator.add]
    elif channel := _is_field_binop(annotation):
        channel.key = name
        return channel

    # 4. 默认:创建 LastValue Channel
    fallback: LastValue = LastValue(annotation)
    fallback.key = name
    return fallback

让我们看几个具体的映射例子:

from typing import Annotated
import operator

from typing_extensions import TypedDict
from langgraph.channels import EphemeralValue
from langgraph.graph.message import add_messages

class State(TypedDict):
    # 情况1:无注解 -> LastValue
    name: str                              # LastValue(str)

    # 情况2:Reducer 函数 -> BinaryOperatorAggregate
    items: Annotated[list, operator.add]   # BinaryOperatorAggregate(list, operator.add)

    # 情况3:自定义 Reducer
    messages: Annotated[list, add_messages] # BinaryOperatorAggregate(list, add_messages)

    # 情况4:显式 Channel 类型
    temp: Annotated[str, EphemeralValue]   # EphemeralValue(str)

3.1.3 _is_field_binop 的 Reducer 检测

Reducer 检测逻辑值得特别关注。_is_field_binop 函数检查 Annotated 的最后一个元数据是否为接受两个参数的可调用对象:

# 源码位置:langgraph/graph/state.py
def _is_field_binop(typ):
    if hasattr(typ, "__metadata__"):
        meta = typ.__metadata__
        if len(meta) >= 1 and callable(meta[-1]):
            sig = signature(meta[-1])
            params = list(sig.parameters.values())
            if (
                sum(
                    p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
                    for p in params
                ) == 2
            ):
                return BinaryOperatorAggregate(typ, meta[-1])
            else:
                raise ValueError(
                    f"Invalid reducer signature. Expected (a, b) -> c. Got {sig}"
                )
    return None

这段代码做了两件事:(1) 检查最后一个元数据是否可调用;(2) 检查它是否恰好接受两个位置参数。如果签名不匹配(比如只有一个参数),会抛出明确的错误信息。这个设计确保了 Reducer 函数的签名正确性。

3.2 add_node:节点注册的五重重载

节点是 LangGraph 工作流中最基本的计算单元。每个节点封装了一段独立的处理逻辑——可以是调用大语言模型、执行外部工具、进行数据转换处理,或者任何 Python 可调用对象。add_node 是 StateGraph 最常用的方法,它被设计得足够灵活,以适应从简单函数到复杂 Runnable 对象的各种场景。它有五种重载签名来适应不同的调用方式。除了位置参数 node / action,其余参数都只能按关键字传入:defer、metadata、input_schema、retry_policy、cache_policy、destinations(1.1.6 签名)。

graph TD
    A[add_node 调用] --> B{第一个参数类型?}
    B -->|函数/Runnable| C[自动推断名称]
    B -->|字符串| D[显式指定名称]
    C --> E{有 input_schema?}
    D --> F{有 input_schema?}
    E -->|否| G[从首参类型注解推断<br/>推断不到用 state_schema]
    E -->|是| H[使用指定 schema]
    F -->|否| G
    F -->|是| H
    G --> I[创建 StateNodeSpec]
    H --> I

3.2.1 名称推断机制

当第一个参数不是字符串时,LangGraph 会自动推断节点名称:

# 源码位置:langgraph/graph/state.py,add_node 方法
if not isinstance(node, str):
    action = node
    if isinstance(action, Runnable):
        node = action.get_name()      # Runnable 使用 get_name()
    else:
        node = getattr(action, "__name__", action.__class__.__name__)
        # 函数使用 __name__,类实例使用类名

这意味着:

def my_agent(state): ...
builder.add_node(my_agent)         # 名称自动推断为 "my_agent"

class MyAgent:
    def __call__(self, state): ...
builder.add_node(MyAgent())        # 名称自动推断为 "MyAgent"

builder.add_node("custom_name", my_agent)  # 显式指定名称

3.2.2 输入模式推断

输入模式推断是 StateGraph 的一个强大特性。它允许不同的节点看到不同的状态子集——一个只需要 query 字段的检索节点不需要接收完整的包含 messages、tools 等字段的状态。这种选择性输入不仅减少了数据传递的开销,更重要的是明确了节点的依赖关系,使得代码的意图更加清晰。

add_node 不仅推断名称,还会尝试从函数签名中推断输入模式:

# 源码位置:langgraph/graph/state.py,add_node 方法(简化)
inferred_input_schema = None

if isfunction(action) or ismethod(action):
    hints = get_type_hints(action)
    if input_schema is None:
        first_parameter_name = next(
            iter(inspect.signature(action).parameters.keys())
        )
        if input_hint := hints.get(first_parameter_name):
            if isinstance(input_hint, type) and get_type_hints(input_hint):
                inferred_input_schema = input_hint

这段代码的含义是:如果节点函数的第一个参数有类型注解,且该注解是一个带有字段的类型(TypedDict/dataclass/Pydantic),则将其作为该节点的输入模式。这使得以下用法成立:

class FullState(TypedDict):
    x: int
    y: str
    z: list

class AgentInput(TypedDict):
    x: int
    y: str

# 通过类型注解推断,agent 只会接收 x 和 y 字段
def agent(state: AgentInput) -> dict:
    return {"z": [state["x"]]}

builder = StateGraph(FullState)
builder.add_node(agent)  # input_schema 自动推断为 AgentInput

3.2.3 Command 返回类型解析

Command 类型在 0.2.46 落地(由更早的 Control 改名而来),允许节点通过返回值来控制流程。add_node 会解析函数的返回类型注解来提取目的地信息:

# 源码位置:langgraph/graph/state.py,add_node 方法(简化)
if rtn := hints.get("return"):
    rtn_origin = get_origin(rtn)
    # 处理 Union 类型:Union[dict, Command[Literal["a", "b"]]]
    if rtn_origin is Union:
        for arg in get_args(rtn):
            if get_origin(arg) is Command:
                rtn = arg
                break

    # 提取 Command[Literal["a", "b"]] 中的目的地
    if get_origin(rtn) is Command and (rargs := get_args(rtn)):
        if get_origin(rargs[0]) is Literal:
            ends = get_args(rargs[0])  # ("a", "b")

这使得以下模式可以正确渲染图的边:

def route_node(state: State) -> Command[Literal["agent", "tools"]]:
    if should_use_tools(state):
        return Command(goto="tools", update={"step": "tools"})
    return Command(goto="agent", update={"step": "agent"})

builder.add_node(route_node, destinations=("agent", "tools"))
# 或者依赖返回类型注解自动推断

显式传入的 destinations 会覆盖从注解推断出的目的地。两者都只服务于图渲染(以及 validate() 对目标节点是否存在的检查),真正跳到哪里由运行时返回的 Command.goto 决定。

3.2.4 StateNodeSpec 数据类

经过名称推断、输入模式推断和返回类型解析后,节点的所有元信息被封装到一个统一的数据类中。这个数据类是图定义层和编译层之间的桥梁——它保存了编译过程所需的全部信息,使得 compile() 方法可以将其转换为底层的 PregelNode。

节点最终被封装为 StateNodeSpec:

# 源码位置:langgraph/graph/_node.py
@dataclass(slots=True)
class StateNodeSpec(Generic[NodeInputT, ContextT]):
    runnable: StateNode[NodeInputT, ContextT]  # 节点函数/Runnable
    metadata: dict[str, Any] | None            # 元数据
    input_schema: type[NodeInputT]             # 输入模式
    retry_policy: RetryPolicy | Sequence[RetryPolicy] | None  # 重试策略
    cache_policy: CachePolicy | None           # 缓存策略
    ends: tuple[str, ...] | dict[str, str] | None = EMPTY_SEQ  # 目的地(用于图渲染)
    defer: bool = False                         # 是否延迟执行

defer 参数在 0.4.4 引入(PR #4269)。当 defer=True 时,节点使用 LastValueAfterFinish Channel 作为触发器,意味着该节点会延迟到运行即将结束时才执行。

3.2.5 节点类型协议

LangGraph 支持多种节点函数签名,通过 Protocol 类型来约束:

# 源码位置:langgraph/graph/_node.py
class _Node(Protocol[NodeInputT_contra]):
    def __call__(self, state: NodeInputT_contra) -> Any: ...

class _NodeWithConfig(Protocol[NodeInputT_contra]):
    def __call__(self, state: NodeInputT_contra, config: RunnableConfig) -> Any: ...

class _NodeWithWriter(Protocol[NodeInputT_contra]):
    def __call__(self, state: NodeInputT_contra, *, writer: StreamWriter) -> Any: ...

class _NodeWithStore(Protocol[NodeInputT_contra]):
    def __call__(self, state: NodeInputT_contra, *, store: BaseStore) -> Any: ...

class _NodeWithRuntime(Protocol[NodeInputT_contra, ContextT]):
    def __call__(self, state: NodeInputT_contra, *, runtime: Runtime[ContextT]) -> Any: ...

所有这些协议被组合为联合类型 StateNode:

StateNode = (
    _Node[NodeInputT]
    | _NodeWithConfig[NodeInputT]
    | _NodeWithWriter[NodeInputT]
    | _NodeWithStore[NodeInputT]
    | _NodeWithRuntime[NodeInputT, ContextT]
    | Runnable[NodeInputT, Any]
    # ... 及更多组合
)

这意味着以下所有签名都是合法的节点函数:

# 最简签名
def node1(state: State) -> dict: ...

# 带配置
def node2(state: State, config: RunnableConfig) -> dict: ...

# 带流式写入器
def node3(state: State, *, writer: StreamWriter) -> dict: ...

# 带存储
def node4(state: State, *, store: BaseStore) -> dict: ...

# 带运行时上下文(Runtime 与 context_schema 在 0.6.0 引入,PR #5243)
def node5(state: State, *, runtime: Runtime[MyContext]) -> dict: ...

# 组合
def node6(state: State, config: RunnableConfig, *, writer: StreamWriter, store: BaseStore) -> dict: ...

3.3 add_edge:直接边

边是 LangGraph 图中连接节点的纽带,决定了工作流的控制流方向。LangGraph 支持三种类型的边:直接边(add_edge)表示无条件的顺序执行;条件边(add_conditional_edges)表示基于运行时状态的动态路由;汇聚边(add_edge 带列表参数)表示等待多个前置节点全部完成。我们先从最简单的直接边开始。

3.3.1 单源边

最简单的边连接形式:

# 源码位置:langgraph/graph/state.py
def add_edge(self, start_key: str | list[str], end_key: str) -> Self:
    if isinstance(start_key, str):
        if start_key == END:
            raise ValueError("END cannot be a start node")
        if end_key == START:
            raise ValueError("START cannot be an end node")
        self.edges.add((start_key, end_key))
        return self

直接边的语义非常直接:当 start_key 节点执行完毕后,end_key 节点将在下一步被触发。

3.3.2 多源汇聚边

当 start_key 是一个列表时,创建的是"等待所有"语义的汇聚边:

# 源码位置:langgraph/graph/state.py(节选;其后还校验 end_key 不是 START、且已注册或为 END)
    for start in start_key:
        if start == END:
            raise ValueError("END cannot be a start node")
        if start not in self.nodes:
            raise ValueError(f"Need to add_node `{start}` first")

    self.waiting_edges.add((tuple(start_key), end_key))

在编译阶段,汇聚边被转换为 NamedBarrierValue Channel:

# 源码位置:langgraph/graph/state.py,CompiledStateGraph.attach_edge
elif end != END:
    channel_name = f"join:{'+'.join(starts)}:{end}"
    if self.builder.nodes[end].defer:
        self.channels[channel_name] = NamedBarrierValueAfterFinish(str, set(starts))
    else:
        self.channels[channel_name] = NamedBarrierValue(str, set(starts))
    # 目标节点订阅此 Channel
    self.nodes[end].triggers.append(channel_name)
    # 每个源节点在完成后向此 Channel 报告
    for start in starts:
        self.nodes[start].writers.append(
            ChannelWrite((ChannelWriteEntry(channel_name, start),))
        )

这里的设计非常精巧。以 add_edge(["a", "b"], "c") 为例:

graph TD
    A["节点 a"] -->|写入 a| J["join barrier<br/>NamedBarrierValue"]
    B["节点 b"] -->|写入 b| J
    J -->|seen == names 时触发| C["节点 c"]
    style J fill:#fff3e0

NamedBarrierValue 维护一个 seen 集合,每当收到一个值时检查是否在 names 集合中。只有当 seen == names 时(即所有源节点都已报告),Channel 才变为可用状态,触发目标节点。

3.3.3 defer 与 AfterFinish 变体

当目标节点设置了 defer=True 时,使用 NamedBarrierValueAfterFinish 代替 NamedBarrierValue。区别在于后者增加了 finish() 方法的配合——只有当 Pregel 循环即将结束时(调用 finish()),Channel 才真正变为可用:

# 源码位置:langgraph/channels/named_barrier_value.py
class NamedBarrierValueAfterFinish:
    def get(self):
        if not self.finished or self.seen != self.names:
            raise EmptyChannelError()
        return None

    def finish(self) -> bool:
        if not self.finished and self.seen == self.names:
            self.finished = True
            return True
        return False

这种机制使得 defer 节点在所有常规节点执行完毕后才被触发,适用于"清理"或"汇总"场景。

3.4 add_conditional_edges:条件边

条件边是 LangGraph 最强大的控制流机制——它允许工作流根据运行时状态动态选择下一步的执行方向。几乎所有涉及"判断"的场景都依赖条件边:Agent 决定是否调用工具、质量检查决定是否需要重试、分类器将请求路由到不同的处理节点。理解条件边的内部实现,对于调试复杂工作流中的路由问题至关重要。

# 源码位置:langgraph/graph/state.py
def add_conditional_edges(
    self,
    source: str,
    path: Callable | Runnable,
    path_map: dict[Hashable, str] | list[str] | None = None,
) -> Self:
    # 将 path 转换为 Runnable
    path = coerce_to_runnable(path, name=None, trace=True)
    name = path.name or "condition"

    # 验证唯一性
    if name in self.branches[source]:
        raise ValueError(f"Branch with name `{path.name}` already exists...")

    # 保存 BranchSpec
    self.branches[source][name] = BranchSpec.from_path(path, path_map, True)

3.4.1 BranchSpec 的构建

BranchSpec 是条件边的内部表示:

# 源码位置:langgraph/graph/_branch.py
class BranchSpec(NamedTuple):
    path: Runnable[Any, Hashable | list[Hashable]]  # 路由函数
    ends: dict[Hashable, str] | None                  # 返回值 -> 目标节点映射
    input_schema: type[Any] | None = None             # 路由函数的输入模式

BranchSpec.from_path 工厂方法负责将各种形式的 path_map 统一化:

# 源码位置:langgraph/graph/_branch.py(简化)
@classmethod
def from_path(cls, path, path_map, infer_schema=False):
    path_map_ = None
    if isinstance(path_map, dict):
        path_map_ = path_map.copy()          # 直接使用字典映射
    elif isinstance(path_map, list):
        path_map_ = {name: name for name in path_map}  # 列表转为同名映射
    else:
        # 尝试从返回类型注解推断(func 为从 path 中取出的底层函数,取法略)
        if rtn_type := get_type_hints(func).get("return"):
            if get_origin(rtn_type) is Literal:
                path_map_ = {name: name for name in get_args(rtn_type)}
    # infer_schema=True 时,再从路由函数首参的类型注解推断分支的 input_schema
    input_schema = _get_branch_path_input_schema(path) if infer_schema else None
    return cls(path=path, ends=path_map_, input_schema=input_schema)

3.4.2 条件边的编译:attach_branch

在编译阶段,条件边被转换为节点的 writer:

# 源码位置:langgraph/graph/state.py,CompiledStateGraph.attach_branch
def attach_branch(self, start, name, branch, *, with_reader=True):
    def get_writes(packets, static=False):
        # 将目标节点名转换为 ChannelWriteEntry
        writes = [
            ChannelWriteEntry(
                p if p == END else f"branch:to:{p}", None
            )
            if not isinstance(p, Send) else p
            for p in packets
            if (True if static else p != END)
        ]
        return writes

    # 创建 reader(读取当前状态供路由函数使用)
    reader = partial(
        ChannelRead.do_read,
        select=channels,
        fresh=True,
        mapper=mapper,
    )

    # 将路由逻辑附加到源节点的 writers 列表
    self.nodes[start].writers.append(branch.run(get_writes, reader))
graph TD
    subgraph 条件边执行流程
        A[源节点执行完毕] --> B[writer 链执行]
        B --> C[ChannelRead 读取最新状态]
        C --> D[路由函数执行]
        D -->|返回 tools| E["写入 branch to tools"]
        D -->|返回 END| F[不写入任何触发 Channel]
        D -->|返回 Send| G["写入 __pregel_tasks"]
    end

3.4.3 BranchSpec.run 的运行时逻辑

条件边的运行时行为封装在 BranchSpec.run 返回的 RunnableCallable 中:

# 源码位置:langgraph/graph/_branch.py
def _route(self, input, config, *, reader, writer):
    if reader:
        value = reader(config)
        # 合并节点输出和状态(仅当分支没有独立 input_schema 时)
        if (
            isinstance(value, dict)
            and isinstance(input, dict)
            and self.input_schema is None
        ):
            value = {**input, **value}
    else:
        value = input
    result = self.path.invoke(value, config)
    return self._finish(writer, input, result, config)

注意 reader 的 fresh=True 参数。这意味着条件边的路由函数看到的是包含了当前节点写入的最新状态,而不是步骤开始时的快照。这个设计使得路由函数可以根据节点的输出来决定下一步去向。

3.5 START 和 END 常量

START 和 END 是 LangGraph 中两个最基本的常量,几乎出现在每一个图定义中。虽然从用户的角度看它们似乎是对等的——一个表示起点,一个表示终点——但在内部实现中,它们的本质截然不同。理解这种不对等性对于理解编译过程和运行时行为至关重要。

3.5.1 常量定义

# 源码位置:langgraph/constants.py
END = sys.intern("__end__")
START = sys.intern("__start__")

sys.intern 确保这些字符串在整个解释器中只有一份副本,使得身份比较(is)和哈希操作更加高效。

3.5.2 START 节点的本质

START 不是用户定义的节点,而是编译时自动创建的输入分发节点。它负责将用户输入(按 input_schema 的字段)分发到各个状态 Channel,再经由出边触发后继节点:

# 源码位置:langgraph/graph/state.py,attach_node 方法
if key == START:
    self.nodes[key] = PregelNode(
        tags=[TAG_HIDDEN],       # 在追踪中隐藏
        triggers=[START],        # 由 __start__ Channel 触发
        channels=START,          # 从 __start__ Channel 读取输入
        writers=[ChannelWrite(write_entries)],  # 分发到状态 Channel
    )

START 节点使用 TAG_HIDDEN 标记,使其在 LangSmith 追踪和流式输出中不可见。用户不需要知道它的存在。

3.5.3 END 的本质

END 更加特殊——它甚至没有对应的节点。当一条边指向 END 时,编译器不做任何处理:

# 源码位置:langgraph/graph/state.py,attach_edge 方法
def attach_edge(self, starts, end):
    if isinstance(starts, str):
        if end != END:  # 指向 END 时不添加任何写入
            self.nodes[starts].writers.append(
                ChannelWrite(
                    (ChannelWriteEntry(f"branch:to:{end}", None),)
                )
            )

END 的语义是"不触发任何后续节点"。当一个节点的所有 writer 都不产生触发信号时,在下一个 step 的 prepare_next_tasks 中就不会有新任务,BSP 循环自然终止。

graph TD
    subgraph START 和 END 的实质
        S["__start__<br/>EphemeralValue Channel"] -->|触发| SN["START 节点<br/>(TAG_HIDDEN)"]
        SN -->|分发输入到| SC[状态 Channels]
        SN -->|写入触发| TC["触发 Channel"]
        EN["END = 不写入触发 Channel"]
        style EN fill:#ffcdd2
        style SN fill:#e8f5e9
    end

3.6 set_entry_point 和 set_finish_point

这两个方法是 add_edge 的便捷封装:

# 源码位置:langgraph/graph/state.py
def set_entry_point(self, key: str) -> Self:
    return self.add_edge(START, key)

def set_finish_point(self, key: str) -> Self:
    return self.add_edge(key, END)

以及条件入口点:

def set_conditional_entry_point(self, path, path_map=None) -> Self:
    return self.add_conditional_edges(START, path, path_map)

3.7 add_sequence:序列便捷方法

在很多实际的工作流应用中,有相当一部分步骤是严格按照线性顺序执行的——没有条件分支,没有循环回路,就是简单直接的"A 做完了交给 B,B 做完了交给 C"。对于这种常见模式,逐一调用 add_node 和 add_edge 会产生大量重复代码。add_sequence 方法就是为了消除这种冗余而设计的。

add_sequence 方法提供了将多个节点串联的便捷方式:

# 源码位置:langgraph/graph/state.py
def add_sequence(self, nodes):
    if len(nodes) < 1:
        raise ValueError("Sequence requires at least one node.")

    previous_name = None
    for node in nodes:
        if isinstance(node, tuple) and len(node) == 2:
            name, node = node
        else:
            name = _get_node_name(node)  # 从函数/类推断名称

        if name in self.nodes:
            raise ValueError(f"Node names must be unique: '{name}' already exists.")

        self.add_node(name, node)
        if previous_name is not None:
            self.add_edge(previous_name, name)
        previous_name = name
    return self

使用示例:

builder.add_sequence([step1, step2, step3])
# 等价于:
builder.add_node(step1)
builder.add_node(step2)
builder.add_node(step3)
builder.add_edge("step1", "step2")
builder.add_edge("step2", "step3")

3.8 compile():从 StateGraph 到 CompiledStateGraph

编译是 StateGraph 完整生命周期中最为关键的核心环节。在这一步,开发者友好的高层声明被转换为 Pregel 引擎可以直接执行的底层原语。这个过程涉及状态模式解析、Channel 布局计算、节点包装、边的转换和图结构验证等多个步骤。理解编译过程不仅有助于调试图构建错误,更能帮助开发者理解运行时行为与声明之间的映射关系。

3.8.1 编译入口

# 源码位置:langgraph/graph/state.py
def compile(
    self,
    checkpointer=None,
    *,
    cache=None,
    store=None,
    interrupt_before=None,
    interrupt_after=None,
    debug=False,
    name=None,
) -> CompiledStateGraph:

编译过程按以下顺序执行:

graph TD
    A[compile 入口] --> B[验证图结构]
    B --> C[准备输出/流 Channel]
    C --> D[创建 CompiledStateGraph]
    D --> E[attach_node: START]
    E --> F["attach_node: 每个用户节点"]
    F --> G["attach_edge: 每条直接边"]
    G --> H["attach_edge: 每条汇聚边"]
    H --> I["attach_branch: 每个条件边"]
    I --> J[validate: 最终验证]
    J --> K[返回 CompiledStateGraph]

3.8.2 图验证

validate() 方法确保图的结构完整性:

# 源码位置:langgraph/graph/state.py(简化:省略了 all_sources / all_targets 的汇总,
# 以及条件边 path_map 目标的逐一校验)
def validate(self, interrupt=None):
    # 1. 检查所有边的源节点存在
    for source in all_sources:
        if source not in self.nodes and source != START:
            raise ValueError(f"Found edge starting at unknown node '{source}'")

    # 2. 检查必须有入口点
    if START not in all_sources:
        raise ValueError(
            "Graph must have an entrypoint: add at least one edge from START to another node"
        )

    # 3. 检查所有边的目标节点存在
    for target in all_targets:
        if target not in self.nodes and target != END:
            raise ValueError(f"Found edge ending at unknown node `{target}`")

    # 4. 检查中断节点存在
    if interrupt:
        for node in interrupt:
            if node not in self.nodes:
                raise ValueError(f"Interrupt node `{node}` not found")

3.8.3 CompiledStateGraph 的继承关系

class CompiledStateGraph(
    Pregel[StateT, ContextT, InputT, OutputT],
    Generic[StateT, ContextT, InputT, OutputT],
):
    builder: StateGraph  # 保留对原始 builder 的引用

CompiledStateGraph 继承自 Pregel,这意味着它拥有 Pregel 的所有运行时能力——invoke、stream、get_state、update_state 等。编译过程的本质就是将 StateGraph 的声明式定义转换为 Pregel 的运行时数据结构。这个继承关系也说明了为什么 CompiledStateGraph 可以直接作为子图嵌入到另一个图中——它本身就是一个完整的 Pregel 实例,拥有独立的执行能力和状态管理。

3.8.4 attach_node 的完整逻辑

attach_node 是编译过程中最关键的方法,它将 StateNodeSpec 转换为 PregelNode:

# 源码位置:langgraph/graph/state.py(简化)
def attach_node(self, key, node):
    # 创建触发 Channel
    branch_channel = f"branch:to:{key}"
    if node.defer:
        self.channels[branch_channel] = LastValueAfterFinish(Any)
    else:
        self.channels[branch_channel] = EphemeralValue(Any, guard=False)

    # 创建 PregelNode
    self.nodes[key] = PregelNode(
        triggers=[branch_channel],           # 被哪个 Channel 触发
        channels=input_channels,             # 读取哪些 Channel 作为输入
        mapper=mapper,                       # 输入转换函数(如 dict -> Pydantic)
        writers=[ChannelWrite(write_entries)], # 输出写入器
        metadata=node.metadata,
        retry_policy=node.retry_policy,
        cache_policy=node.cache_policy,
        bound=node.runnable,                 # 实际执行的函数/Runnable
    )

这段代码揭示了一个关键细节:每个节点都有自己专属的触发 Channel。节点之间不会直接触发彼此,所有的触发关系都通过 Channel 间接完成。这正是批量同步并行模型中"通过 Channel 通信"这一核心原则在编译阶段的具体体现。理解这个间接触发机制是理解整个运行时执行流程的关键前提。

3.9 MessageGraph 与 MessagesState

在 LLM 应用中,以消息列表为核心的状态模式是最常见的场景。无论是简单的聊天机器人还是复杂的多工具 Agent,消息历史都是状态的主要组成部分。LangGraph 为这种常见场景提供了专门的便捷封装,减少样板代码的同时确保消息处理的正确性。

3.9.1 MessagesState

MessagesState 是一个预定义的 TypedDict,专门用于以消息列表为核心状态的场景:

# 源码位置:langgraph/graph/message.py
class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

使用 MessagesState 可以简化聊天应用的状态定义:

from langgraph.graph import StateGraph
from langgraph.graph.message import MessagesState

builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot_fn)

3.9.2 add_messages Reducer

add_messages 是 LangGraph 内置的消息合并 Reducer。它的行为比简单的列表拼接更加智能:

# 源码位置:langgraph/graph/message.py(简化)
def add_messages(left, right, *, format=None):
    # 1. 将输入转换为 Message 对象
    left = convert_to_messages(left)
    right = convert_to_messages(right)

    # 2. 为没有 ID 的消息生成 UUID,并记下 right 中
    #    REMOVE_ALL_MESSAGES 标记的位置 remove_all_idx
    for m in left + right:
        if m.id is None:
            m.id = str(uuid.uuid4())

    # 3. 处理 REMOVE_ALL_MESSAGES 特殊标记:只保留标记之后的新消息
    if remove_all_idx is not None:
        return right[remove_all_idx + 1:]

    # 4. 按 ID 合并:相同 ID 的消息会被替换
    merged = left.copy()
    merged_by_id = {m.id: i for i, m in enumerate(merged)}
    ids_to_remove = set()
    for m in right:
        if (existing_idx := merged_by_id.get(m.id)) is not None:
            if isinstance(m, RemoveMessage):
                ids_to_remove.add(m.id)
            else:
                ids_to_remove.discard(m.id)
                merged[existing_idx] = m  # 替换已有消息
        else:
            if isinstance(m, RemoveMessage):  # 删除不存在的 ID 直接报错
                raise ValueError(f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')")
            merged_by_id[m.id] = len(merged)
            merged.append(m)  # 追加新消息

    # 5. 真正删除被 RemoveMessage 标记的消息
    merged = [m for m in merged if m.id not in ids_to_remove]
    return merged

add_messages 的三个关键特性:

  • 按 ID 去重:相同 ID 的消息会被新版本替换,而非重复追加
  • 删除支持:通过 RemoveMessage 可以删除指定 ID 的消息
  • 全量清除:通过 REMOVE_ALL_MESSAGES 特殊标记可以清空所有历史消息

3.9.3 MessageGraph(已弃用)

MessageGraph 是 LangGraph 早期的简化入口,1.0.0 起弃用(实例化时发 LangGraphDeprecatedSinceV10 警告,计划 2.0.0 移除):

# 源码位置:langgraph/graph/message.py
@deprecated("...Please use StateGraph with a `messages` key instead.")
class MessageGraph(StateGraph):
    def __init__(self) -> None:
        super().__init__(Annotated[list[AnyMessage], add_messages])

它的整个状态就是一个消息列表(使用 __root__ 作为单一 Channel),不支持额外的状态字段。这意味着你无法在消息列表之外存储任何辅助信息(如当前步骤、错误计数等)。迁移到 StateGraph(MessagesState) 是推荐的做法,因为后者支持在消息列表之外添加任意数量的额外状态字段。

3.10 设计决策分析

StateGraph 的 API 设计中蕴含了大量的工程权衡。理解这些权衡不仅能帮助我们更好地使用 LangGraph,也为我们自己在进行框架和系统设计时提供了非常有价值的参考经验。以下分析三个最为关键的设计决策及其背后深层的考量。

3.10.1 为什么用 Builder 模式

StateGraph 采用了经典的 Builder 模式——先通过一系列 add_* 方法收集声明,最后通过 compile() 一次性构建。这个设计有三个重要原因:

验证需要全局视角:只有在所有节点和边都声明完毕后,才能验证图的完整性(如入口点是否存在、所有边的源与目标是否都是已注册的节点)。如果在每次 add_edge 时就验证,会导致声明顺序的脆弱依赖——比如你必须先添加目标节点再添加指向它的边,这种约束会严重降低开发体验。单源的 add_edge 正是这样把检查推迟到 compile();例外是列表形式的汇聚边,它在 add_edge 当场就要求源节点和目标节点已注册(否则抛 Need to add_node ... first)。

编译需要全局信息:Channel 布局、触发关系、输入输出映射都需要综合考虑所有节点和边才能确定。例如,每个节点的 branch:to: 触发 Channel 用 EphemeralValue 还是 LastValueAfterFinish,取决于该节点是否 defer;汇聚 Channel 也要等节点都注册完、知道目标节点是否 defer,才能选定 NamedBarrierValue 或其 AfterFinish 变体。分步构建、一次编译是最自然的流程。

不变性保证:编译后的 CompiledStateGraph 可以被视为不可变对象,可以安全地在多个线程或请求之间共享。如果没有编译步骤,每次执行都需要重新解析图结构,这既浪费性能又增加了并发风险。

3.10.2 为什么每个节点都有专属触发 Channel

这是一个容易被忽略但非常重要的设计决策。一个替代方案是使用一个全局的"激活"Channel,所有节点共享——节点通过检查 Channel 中的值来判断自己是否应该执行。但 LangGraph 选择了为每个节点分配专属的触发 Channel,这种看似增加了 Channel 数量的设计实际上大大简化了触发逻辑和版本追踪。

早期版本中,边是通过 start:{node}、branch:{source}:{condition}:{node}、以源节点名命名的 Channel 等多种格式实现的。0.3.15 起条件边改写 branch:to:{node},0.3.22 起 START 出边与普通直接边也并入这一格式,0.3.24 补上 CompiledStateGraph._migrate_checkpoint 负责把旧检查点里的老格式迁移过来(这段代码 1.1.6 仍在)。每个节点有自己的触发 Channel 而非共享一个全局 Channel,原因在于:

精确触发:只有被边指向的节点才会被触发。如果使用共享 Channel,需要额外的过滤逻辑。

版本追踪:每个触发 Channel 独立维护版本号,Pregel 可以精确判断哪些节点需要执行,而非广播触发所有节点。

支持并行触发:一个源节点可以同时触发多个目标节点,只需向多个触发 Channel 写入即可。例如条件边返回多个目标时,框架只需向对应的多个触发 Channel 各写入一个信号,这些目标节点就会在下一步同时被执行。这种设计的优雅之处在于并行触发不需要特殊的机制——它是单独触发的自然推广。

3.10.3 为什么条件边需要 reader

条件边的路由函数需要看到包含当前节点写入的最新状态。这通过 ChannelRead.do_read(fresh=True) 实现。fresh=True 的含义是:"不使用步骤开始时的快照,而是在当前节点的写入上重新计算 Channel 值"。

这个设计确保了路由函数可以根据节点的实际输出来做决策,而不是基于过时的状态。这对于 Agent 场景尤其重要——LLM 节点的输出决定了是否需要调用工具。如果路由函数看到的是步骤开始时的状态(不包含 LLM 的最新回复),它就无法做出正确的路由决策。fresh=True 参数让条件边的路由函数能够看到"当前节点写入后"的最新状态视图,这是一个精心设计的局部可见性机制——它只对条件边的路由函数开放,普通节点在执行时仍然只能看到步骤开始时的快照。

3.11 常见陷阱与最佳实践

在实际使用 StateGraph 构建应用时,开发者经常会遇到一些不太直观的行为。理解这些陷阱的根因,可以让调试效率大幅提升。

3.11.1 陷阱一:忘记从起始节点添加入口边

最常见的错误是定义了节点但没有从 START 添加入口边。validate() 方法会检查这个条件,抛出明确的错误信息提示图必须有入口点。但容易被忽略的是,使用 add_sequence 时不会自动添加从 START 到序列第一个节点的边——你仍然需要手动调用 add_edge(START, first_node) 或 set_entry_point(first_node)。这是因为 add_sequence 的职责只是将多个节点按顺序串联,它不知道这个序列是否是整个图的入口。

3.11.2 陷阱二:节点名称冲突

add_node 使用函数名作为默认节点名。如果你有两个同名函数来自不同模块,或者多次使用同一个函数作为不同节点,会触发节点名称已存在的错误。解决方案是使用两参数形式显式指定不同的名称。节点名称不仅用于内部标识,还会出现在流式输出、追踪日志和错误信息中,因此选择有意义的名称很重要。

3.11.3 陷阱三:条件边的路由函数签名

条件边的路由函数的第一个参数接收的是包含源节点最新写入的当前状态,而不仅仅是源节点的返回值。这一点经常让开发者困惑——他们期望路由函数收到的是节点的直接输出。实际上,路由函数看到的是整个状态的最新快照(包含了刚刚完成的节点的写入),这是因为编译阶段使用了"新鲜读取"模式来获取最新状态。准确地说,读取范围由 schema 决定:路由函数第一个参数若标注了带字段的类型,它就成为该分支的 input_schema,只读取其中的字段;未标注时沿用源节点的输入模式(默认即完整状态)。因此给路由函数标注一个字段不全的类型,函数里访问缺失字段时就会出错。

3.11.4 陷阱四:并行写入同一状态键

如果同一步并行执行的两个节点(例如被同一个源节点的两条出边同时触发)同时写入同一个没有 Reducer 的状态键,会在 apply_writes 阶段抛出 InvalidUpdateError。错误信息会明确提示使用 Annotated 来添加 Reducer 函数。这是 LastValue Channel 的"最多一次写入"约束在运行时的体现。这个错误在开发的早期阶段非常常见——当你开始在图中引入并行执行路径时,就需要重新审视所有可能被并行写入的状态字段,为它们添加合适的 Reducer。一个经验法则是:只要你的图中存在并行节点,就应该检查每个状态字段是否需要 Reducer。

3.11.5 最佳实践:状态模式设计

状态模式的设计对整个应用的可维护性有深远影响。以下是经过实践验证的设计原则:

  • 对于可能被并行节点写入的字段,始终使用 Annotated 加 Reducer。这是并发安全的基本保障,宁可过度使用也不要漏掉
  • 对于消息列表,使用内置的 add_messages Reducer 而非简单的 operator.add。前者支持按 ID 更新和删除消息,后者只会无条件追加,导致重复消息和无法修正历史
  • 使用 input_schema 和 output_schema 将图的接口与内部状态分离。内部状态可能包含中间变量、调试信息等不应该暴露给外部的字段
  • 节点函数使用类型注解标注返回类型,特别是 Command 的 Literal 参数。这不仅有助于图的可视化渲染,还能让 IDE 提供更好的类型检查和自动补全
  • 为频繁变化的字段考虑使用 Annotated 加自定义 Reducer,而不是在每个节点中手动管理合并逻辑。这种声明式的方式更不容易出错

3.11.6 最佳实践:图结构设计

图的拓扑结构直接影响工作流的可理解性和可调试性。好的图结构设计应该让开发者一眼就能理解数据的流向和控制逻辑:

  • 保持图的拓扑简洁,避免过深的条件嵌套。如果发现条件边的路由函数逻辑过于复杂,考虑将其拆分为多个节点
  • 对于复杂的子任务,使用子图封装而非在同一个图中堆叠节点。子图有独立的状态空间和执行上下文,可以大大降低图的复杂度
  • 使用 add_sequence 简化线性步骤的定义,减少样板代码
  • 为节点指定有意义的名称,这些名称会出现在追踪、流式输出和错误信息中。好的命名让调试效率事半功倍
  • 在开发阶段使用图的可视化方法渲染出图的拓扑结构,直观地确认节点之间的连接关系是否符合设计预期

3.11.7 实测:add_node "五重重载" 在源码里就是 4 @overload + 1 impl

§3.2 标题"add_node:节点注册的五重重载"——把这 5 个签名在 state.py 里精确定位——

序号 起始行 装饰器 签名要点
1 293 @overload node: StateNode + input_schema: None——节点函数 + 自动推断输入 schema,名字从函数名推断
2 360 @overload node: StateNode + input_schema: type[NodeInputT]——显式声明输入 schema 类型
3 431 @overload node: str + action: StateNode + input_schema: None——显式命名、名字与动作分开传
4 498 @overload node: str | StateNode + 可选 action + input_schema: type——两种传法均可,再加显式 schema
5 572 (impl) 真实实现——签名是上面 4 种的 union,内部 if isinstance(...) 分发

两条值得记住的物理事实——

  1. 同样的「多个 @overload + 一个实现」模式在这套系列专栏里反复出现——《LangChain 设计与实现》专栏第 8 章 §8.4.4 的 @tool、《MCP 协议设计与实现》专栏第 17 章 §17.10.1 的 createMessage,以及 LangGraph 自己的 prepare_next_tasks(_algo.py 里 2 个 @overload + 1 impl)、_get_channel(state.py 里 2 个 @overload + 1 impl)和本节的 add_node(4 个 @overload + 1 impl)——其中 prepare_next_tasks 与 _get_channel 按 Literal[True] / Literal[False] 参数收窄返回类型,add_node 则按第一个参数是函数还是字符串、input_schema 是不是 None 区分。@overload 只供 mypy/Pyright 在调用方静态推断类型,运行时执行的始终是最后那个实现,分发仍靠其中的 isinstance 判断
  2. 每个 overload 都带 **kwargs: Unpack[DeprecatedKwargs](第一个 overload 在 state.py:303)——PEP 692 的 typed **kwargs。1.1.6 里 DeprecatedKwargs 这个 TypedDict 本身是空的(_internal/_typing.py:49,只有 docstring 没有字段),所以类型检查器会把废弃参数(add_node 的 retry / input、StateGraph.__init__ 的 config_schema / input / output)当作未知关键字报错(mypy 实测报 Unexpected keyword argument);运行时则仍通过 kwargs.get("input", MISSING) 这类写法捕获它们并发弃用警告(config_schema 为 LangGraphDeprecatedSinceV10,其余为 LangGraphDeprecatedSinceV05)——这是支持向后兼容的同时保持类型严格的工程技巧,和《LangChain 设计与实现》专栏第 13 章 §13.11.4 揭示的 langchain_classic/ 重定向 shim 是同一类"优雅维护遗留 API"的设计哲学

3.12 小结

本章深入剖析了 StateGraph 的图构建 API,从构造器到编译过程,覆盖了开发者与 LangGraph 交互的全部接口。核心要点回顾:

  1. StateGraph 构造器通过 _get_channels 将 Python 类型注解(TypedDict、Pydantic、dataclass)转换为 Channel 实例。默认使用 LastValue,带有 Reducer 注解的字段使用 BinaryOperatorAggregate。这个转换过程是自动且透明的——开发者只需要定义标准的 Python 类型,框架负责将其映射为内部的 Channel 机制。支持独立的输入模式和输出模式使得图的对外接口可以与内部状态解耦。

  2. add_node 支持多种调用方式,自动推断节点名称和输入模式。节点被封装为 StateNodeSpec,包含运行时所需的所有元信息。节点函数支持丰富的签名协议——从最简单的单参数函数到包含配置、存储、流式写入器和运行时上下文的复杂签名。这种灵活性确保了不同复杂度的应用都能找到合适的编写方式。

  3. 三种边在编译时被转换为不同的 Channel 机制:直接边写入 branch:to:{name} Channel,实现了"源节点完成后触发目标节点"的语义;汇聚边创建 NamedBarrierValue Channel,实现了"等待所有前置节点完成"的语义;条件边附加带有 reader 的路由逻辑到源节点的 writer 链,实现了"基于运行时状态动态选择目标"的语义。理解这三种边到 Channel 的转换关系,是理解 LangGraph 运行时行为的关键。

  4. START 和 END 不是对等的:START 是一个真实的隐藏节点负责输入分发——它读取 __start__ Channel 的输入数据,将其分发到各个状态 Channel,并触发与 START 相连的节点;END 只是"不写入触发 Channel"的语义,没有对应的节点实体——指向 END 的边只是简单地不产生触发信号,BSP 循环因为没有新任务而自然终止。

  5. compile() 过程将高层的 StateGraph 声明转换为 Pregel 理解的底层原语(PregelNode、Channel、ChannelWrite),完成从图定义层到 Pregel 运行时层的关键跨越。编译过程包括图结构验证、Channel 布局计算、节点包装(attach_node)、边转换(attach_edge/attach_branch)等多个步骤。编译产物 CompiledStateGraph 继承自 Pregel,拥有完整的运行时能力。

物理事实:add_node 的"五重重载"实测就是 4 @overload + 1 impl(state.py:293/360/431/498/572);同样的「多个 @overload + 一个实现」模式在《LangChain 设计与实现》专栏第 8 章 §8.4.4、《MCP 协议设计与实现》专栏第 17 章 §17.10.1,以及 LangGraph 自己的 prepare_next_tasks 与 _get_channel 上都能看到。每个 overload 带 **kwargs: Unpack[DeprecatedKwargs](PEP 692 typed kwargs)——1.1.6 里这个 TypedDict 是空的,类型检查器会把废弃参数当未知关键字报出,运行时则由 kwargs.get(...) 捕获并告警。