LangGraph设计与实现-第2章-架构纵览

0 阅读27分钟

《LangGraph 设计与实现》完整目录

第2章 架构总览

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

理解一个框架的最佳方式,是先从高处俯瞰全貌,再逐层深入细节。本章将带领读者完成三件事:认识 LangGraph 的包结构与代码组织方式,跟踪一次 graph.invoke() 调用从入口到出口的完整旅程,以及理解贯穿整个框架的四层架构模型。

:::tip 本章要点

  • 三大包的职责边界langgraph(核心引擎)、langgraph-checkpoint(持久化协议)、langgraph-prebuilt(上层组件)
  • graph.invoke() 的完整旅程:从用户调用到 Pregel BSP 循环再到结果返回的全链路追踪
  • 四层架构模型:图定义层 --> Channel 层 --> Pregel 运行时层 --> Checkpoint 持久化层
  • 核心目录结构导航:知道每个文件的职责,后续章节不再迷路 :::

2.1 包结构概览

LangGraph 的代码仓库在 libs/ 目录下划分为多个独立的 Python 包,每个包有明确的职责边界:

libs/
├── langgraph/              # 核心包:图定义、编译、Pregel 运行时
├── checkpoint/             # 持久化协议:BaseCheckpointSaver 接口及内存实现
├── checkpoint-postgres/    # PostgreSQL 检查点存储实现
├── checkpoint-sqlite/      # SQLite 检查点存储实现
├── prebuilt/               # 上层组件:预构建的 Agent、工具节点
├── cli/                    # 命令行工具
├── sdk-py/                 # Python SDK(用于与 LangGraph Server 通信)
└── sdk-js/                 # JavaScript SDK

这种分包策略遵循了"最小依赖原则"——核心包 langgraph 不依赖任何特定的数据库驱动,开发者可以按需引入 checkpoint-postgrescheckpoint-sqlite。在大规模的企业环境中,依赖管理是一个重要的工程问题。最小依赖原则确保了核心包的安装足迹尽可能小,避免了不必要的依赖冲突,也降低了安全审计的复杂度。

2.1.1 langgraph 核心包

这是整个框架的心脏,包含了图定义、编译、运行时引擎和所有 Channel 实现。核心包内部的模块组织严格遵循"关注点分离"的设计原则——图定义、状态管理、运行时执行和持久化操作各自在独立的目录中,通过定义良好的接口协作。这种组织方式使得开发者在阅读某个功能的源码时,不需要理解整个框架的全部细节:

graph TD
    subgraph langgraph 核心包
        G[graph/] -->|编译为| P[pregel/]
        P -->|使用| C[channels/]
        P -->|使用| M[managed/]
        G -->|定义| T[types.py]
        G -->|引用| K[constants.py]
        P -->|配置| CF[config.py]
        P -->|内部工具| I[_internal/]
    end
    style G fill:#e8f5e9
    style P fill:#e1f5fe
    style C fill:#fff3e0

graph/ 目录——图定义层:

文件职责
state.pyStateGraphCompiledStateGraph,核心构建器和编译产物
_branch.pyBranchSpec,条件边的内部表示
_node.pyStateNodeSpec 和各种节点类型协议定义
message.pyadd_messages reducer、MessagesState 和已弃用的 MessageGraph

channels/ 目录——状态管理层:

文件职责
base.pyBaseChannel 抽象基类,定义 Channel 协议
last_value.pyLastValue,存储最后一个值的 Channel(默认类型)
binop.pyBinaryOperatorAggregate,使用 Reducer 函数聚合值
topic.pyTopic,发布/订阅模式的 Channel
ephemeral_value.pyEphemeralValue,步骤间清除的临时 Channel
named_barrier_value.pyNamedBarrierValue,等待所有命名值到达的屏障 Channel
any_value.pyAnyValue,存储任意值,多写入时取最后一个
untracked_value.pyUntrackedValue,不参与检查点的 Channel

pregel/ 目录——运行时引擎层:

文件职责
main.pyPregel 类和 NodeBuilder,运行时引擎核心
_algo.pyBSP 算法核心:apply_writesprepare_next_tasks
_loop.pySyncPregelLoopAsyncPregelLoop,执行循环
_runner.pyPregelRunner,任务并行执行器
_checkpoint.py检查点创建、恢复和 Channel 重建
_io.py输入映射和输出映射
_read.pyChannelReadPregelNode,状态读取机制
_write.pyChannelWriteChannelWriteEntry,状态写入机制
_validate.py图结构验证
protocol.pyPregelProtocol,运行时协议接口

2.1.2 langgraph-checkpoint 包

持久化层被专门设计为一个完全独立的包,这个架构决策有深远的工程意义。它意味着核心包不依赖任何特定的数据库驱动——不需要安装 PostgreSQL 驱动就能运行 LangGraph,不需要安装 SQLite 就能进行开发和测试。这种解耦大大降低了入门门槛,也避免了依赖冲突的问题。

持久化层的接口定义,独立于任何具体存储实现:

# checkpoint 包的核心接口
from langgraph.checkpoint.base import (
    BaseCheckpointSaver,  # 抽象基类:所有检查点存储必须实现
    Checkpoint,           # TypedDict:检查点数据结构
    CheckpointTuple,      # NamedTuple:检查点 + 元数据 + 待处理写入
    CheckpointMetadata,   # TypedDict:检查点元数据
    ChannelVersions,      # dict[str, Union[str, int]]:Channel 版本号映射
)

Checkpoint 的数据结构是理解整个持久化机制的关键:

# 源码位置:langgraph-checkpoint/langgraph/checkpoint/base/__init__.py
class Checkpoint(TypedDict):
    v: int                              # 检查点格式版本号
    id: str                             # 唯一标识(UUID6,包含时间戳)
    ts: str                             # ISO 格式时间戳
    channel_values: dict[str, Any]      # 每个 Channel 的序列化状态
    channel_versions: ChannelVersions   # 每个 Channel 的当前版本号
    versions_seen: dict[str, ChannelVersions]  # 每个节点"见过"的版本号

2.1.3 langgraph-prebuilt 包

预构建组件包位于核心包之上,封装了常见的 Agent 模式。它的设计理念是"开箱即用"——对于标准的 ReAct Agent 场景,开发者不需要手动定义图拓扑和节点,只需调用一个工厂函数即可获得完整的可执行图。同时,由于预构建组件内部使用的是标准的 StateGraph API,开发者可以随时"拆开"它们进行定制。

建立在核心包之上的高层组件:

# prebuilt 包的主要导出
from langgraph.prebuilt import (
    create_react_agent,  # 创建 ReAct Agent 的工厂函数
    ToolNode,           # 执行工具调用的预构建节点
)

create_react_agent 内部使用 StateGraph 构建了一个标准的"LLM 思考到工具调用"循环,是 LangGraph 核心能力的最佳实践封装。通过查看 create_react_agent 的源码,开发者可以学习如何用 StateGraph 构建生产级的 Agent,包括消息状态管理、工具调用路由、错误处理和流式输出支持等方面的最佳实践。这个函数也可以作为自定义 Agent 的起点——先用它快速验证思路,然后将其"拆开"进行深度定制。

2.2 四层架构模型

理解 LangGraph 的架构,最有效的方式是将其抽象为四个层次。每一层都有明确的职责边界和接口契约,层与层之间通过定义良好的协议交互。这种分层设计使得每一层都可以独立演进——比如你可以在不改变图定义层的前提下替换持久化存储后端,也可以在不改变运行时引擎的前提下扩展新的 Channel 类型。

LangGraph 的整体架构可以抽象为四个层次,每一层建立在下一层之上:

graph TB
    subgraph Layer1[第1层:图定义层]
        SG[StateGraph] --> AN[add_node]
        SG --> AE[add_edge]
        SG --> ACE[add_conditional_edges]
        SG --> CO[compile]
    end
    subgraph Layer2[第2层:Channel 状态管理层]
        LV[LastValue] & BOA[BinaryOperatorAggregate] & TP[Topic]
        EV[EphemeralValue] & NBV[NamedBarrierValue] & AV[AnyValue]
    end
    subgraph Layer3[第3层:Pregel 运行时层]
        PR[Pregel] --> PL[PregelLoop]
        PL --> PT[prepare_next_tasks]
        PL --> AW[apply_writes]
        PL --> RN[PregelRunner]
    end
    subgraph Layer4[第4层:Checkpoint 持久化层]
        BCS[BaseCheckpointSaver]
        BCS --> IMS[InMemorySaver]
        BCS --> PGS[PostgresSaver]
        BCS --> SLS[SqliteSaver]
    end
    Layer1 -->|编译| Layer2
    Layer1 -->|编译| Layer3
    Layer2 -->|状态读写| Layer3
    Layer3 -->|持久化| Layer4
    style Layer1 fill:#e8f5e9
    style Layer2 fill:#fff3e0
    style Layer3 fill:#e1f5fe
    style Layer4 fill:#fce4ec

2.2.1 第1层:图定义层

图定义层是开发者与 LangGraph 交互的主要界面。它的设计理念是"声明式优于命令式"——开发者不需要编写执行逻辑,只需要声明工作流的拓扑结构(哪些节点存在、它们之间如何连接),框架负责将这些声明转换为可执行的运行时表示。这种声明式的方式不仅让代码更加简洁和易于理解,还使得图的结构可以被静态分析、验证和可视化。

这是开发者直接接触的 API 层。StateGraph 提供了声明式的图构建接口:

# 用户代码 -> 图定义层
builder = StateGraph(MyState)
builder.add_node("agent", agent_fn)
builder.add_node("tools", tool_fn)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", route_fn, {"continue": "tools", "end": END})
builder.add_edge("tools", "agent")
graph = builder.compile(checkpointer=MemorySaver())

图定义层的核心职责是收集用户的声明(节点、边、条件边),并在 compile() 时将其转换为底层的 Pregel 原语。StateGraph 维护以下数据结构:

# 源码位置:langgraph/graph/state.py,StateGraph 类
class StateGraph:
    nodes: dict[str, StateNodeSpec]        # 节点名 -> 节点规格
    edges: set[tuple[str, str]]             # 直接边的集合
    branches: defaultdict[str, dict[str, BranchSpec]]  # 条件边
    channels: dict[str, BaseChannel]        # 状态模式解析出的 Channel
    managed: dict[str, ManagedValueSpec]    # 托管值
    schemas: dict[type, dict[str, BaseChannel | ManagedValueSpec]]  # schema 映射
    waiting_edges: set[tuple[tuple[str, ...], str]]  # 等待边(多源汇聚)

2.2.2 第2层:Channel 状态管理层

如果说图定义层回答了"工作流长什么样"这个问题,那么 Channel 层回答的就是"数据如何在工作流中流动"。Channel 是 LangGraph 最独特的抽象之一——它不仅是一个简单的数据存储容器,更是一个具有明确更新语义的有状态组件。不同类型的 Channel 以不同的方式处理并发写入,这使得开发者可以通过选择合适的 Channel 类型来声明式地表达数据合并策略。

Channel 是 LangGraph 的数据流通基础设施。每个状态字段对应一个 Channel 实例,Channel 决定了值如何被存储、更新和合并:

# 状态定义和 Channel 的对应关系
class State(TypedDict):
    name: str                              # -> LastValue(str)
    messages: Annotated[list, add_messages] # -> BinaryOperatorAggregate(list, add_messages)
    count: int                             # -> LastValue(int)

Channel 层定义了 BaseChannel 协议,所有 Channel 必须实现的核心方法:

# 源码位置:langgraph/channels/base.py
class BaseChannel(ABC):
    def get(self) -> Value:           # 读取当前值
    def update(self, values) -> bool: # 接收更新序列
    def checkpoint(self) -> Any:      # 序列化为检查点
    def from_checkpoint(self, cp):    # 从检查点恢复
    def consume(self) -> bool:        # 通知值已被消费
    def finish(self) -> bool:         # 通知运行即将结束

2.2.3 第3层:Pregel 运行时层

这是整个框架最复杂也最核心的层。如果说图定义层和 Channel 层定义了"做什么"和"怎么存",那么 Pregel 运行时层就是定义了"怎么执行"。它将 BSP 模型的理论概念转化为实际的执行逻辑——管理超级步骤的循环、协调节点的并行执行、在步骤边界应用所有写入、判断终止条件,以及在必要时创建检查点。

Pregel 类(及其子类 CompiledStateGraph)负责执行 BSP 循环:

# Pregel 运行时的核心属性
class Pregel:
    nodes: dict[str, PregelNode]           # 节点名 -> PregelNode
    channels: dict[str, BaseChannel]       # Channel 名 -> Channel 实例
    input_channels: str | Sequence[str]    # 输入 Channel
    output_channels: str | Sequence[str]   # 输出 Channel
    checkpointer: Checkpointer            # 检查点存储
    interrupt_before_nodes: Sequence[str]  # 在这些节点前中断
    interrupt_after_nodes: Sequence[str]   # 在这些节点后中断

运行时层的核心循环在 PregelLoop 中实现,每个迭代对应 BSP 的一个超级步骤。循环的终止条件有两个:没有活跃的触发 Channel(自然终止),或者达到了递归深度限制(防护性终止)。在循环的每个迭代中,引擎依次执行"准备任务、执行任务、应用写入"三个阶段,严格遵循 BSP 模型的"计算-通信-同步"节奏。

2.2.4 第4层:Checkpoint 持久化层

持久化是 LangGraph 区别于大多数 AI 编排框架的关键特性。它使得工作流可以在任意步骤暂停、在不同进程中恢复、回退到历史状态、从同一检查点出发探索不同分支。这些能力对于需要人机协作的长时间运行工作流至关重要。持久化层通过 BaseCheckpointSaver 接口与运行时层解耦:

# 检查点存储的核心接口
class BaseCheckpointSaver(ABC):
    def put(self, config, checkpoint, metadata, new_versions): ...
    def get_tuple(self, config): ...
    def list(self, config, *, filter, before, limit): ...
    def put_writes(self, config, writes, task_id): ...

这种分层使得 LangGraph 可以灵活地支持不同的存储后端——从内存、SQLite 到 PostgreSQL,开发者只需更换 checkpointer 的实现即可。在开发阶段可以使用零配置的内存存储快速迭代,在测试阶段切换到 SQLite 进行持久化测试,在生产阶段部署到 PostgreSQL 获得高可用性和可扩展性。存储后端的切换不需要修改任何业务代码,只需要在 compile() 方法中传入不同的 checkpointer 实例。这种解耦是四层架构设计的直接收益。

2.3 跟踪 graph.invoke() 的完整旅程

理解一个框架的最佳方式,不是阅读它的类继承关系或接口文档,而是跟踪一次完整的执行过程。通过观察数据从输入到输出的全链路流动,我们可以自然地发现每个组件在什么时刻发挥什么作用,以及组件之间如何协作。

现在让我们跟踪一次完整的 graph.invoke() 调用,看数据如何流经这四层架构。为了让追踪尽可能清晰,我们使用一个只有单节点的最简图——足以展示完整的执行流程,又不会被多节点的复杂度所干扰。以下面的简单图为例:

class State(TypedDict):
    x: int

def increment(state: State) -> dict:
    return {"x": state["x"] + 1}

builder = StateGraph(State)
builder.add_node("inc", increment)
builder.add_edge(START, "inc")
builder.add_edge("inc", END)
graph = builder.compile()
result = graph.invoke({"x": 0})  # 我们要跟踪这一行

2.3.1 阶段一:编译(compile)

invoke 之前,compile() 已经完成了关键的准备工作:

sequenceDiagram
    participant U as 用户代码
    participant SG as StateGraph
    participant CSG as CompiledStateGraph
    participant CH as Channels

    U->>SG: StateGraph(State)
    SG->>CH: _get_channels(State)
    CH-->>SG: {"x": LastValue(int)}
    U->>SG: add_node("inc", increment)
    SG->>SG: nodes["inc"] = StateNodeSpec(...)
    U->>SG: add_edge(START, "inc")
    SG->>SG: edges.add((START, "inc"))
    U->>SG: compile()
    SG->>CSG: 创建 CompiledStateGraph
    CSG->>CSG: attach_node(START, ...)
    CSG->>CSG: attach_node("inc", ...)
    CSG->>CSG: attach_edge(START, "inc")
    CSG->>CSG: attach_edge("inc", END)
    CSG-->>U: 编译后的 graph

下面详细分析编译过程中发生的三个关键转换,每个转换都将开发者熟悉的高层概念映射为运行时引擎需要的底层表示:

  1. 状态模式 --> ChannelStatex: int 字段被转换为 LastValue(int) Channel
  2. 节点 --> PregelNodeadd_node("inc", increment) 被转换为一个 PregelNode,它订阅 branch:to:inc Channel,读取状态 Channel,并通过 ChannelWrite 写回更新
  3. 边 --> Channel 写入add_edge(START, "inc") 被转换为:START 节点的 writer 在执行后向 branch:to:inc Channel 写入一个信号值

编译后的 Channel 布局如下:

# 编译产生的 Channel 集合
channels = {
    "x": LastValue(int),           # 状态字段
    "__start__": EphemeralValue,   # START 输入 Channel
    "branch:to:inc": EphemeralValue,  # 触发 inc 节点的 Channel
    "__pregel_tasks": Topic(Send),    # 内部任务 Channel(自动添加)
}

2.3.2 阶段二:invoke 入口

理解 invoke 的内部机制,需要知道 LangGraph 的一个核心设计决策:所有执行都通过流式引擎完成。即使调用的是 invoke(看起来是同步的、一次性返回结果的),内部也是通过 stream 方法驱动的——只是在外部将流式输出收集为单个结果返回。这种"流式优先"的设计确保了 invokestream 的行为完全一致,不存在两套并行的执行路径。对于开发者而言,这意味着在 stream 模式下验证过的行为,在 invoke 模式下也一定是正确的。调试时推荐优先使用 stream 模式,因为它能提供更丰富的中间状态信息,帮助开发者理解每一步发生了什么。

# 用户调用
result = graph.invoke({"x": 0})

invoke 方法在 Pregel 类中定义(继承自 LangChain 的 Runnable),它最终调用内部的流式执行引擎。简化的调用链:

graph.invoke({"x": 0})
  -> Pregel.invoke(input, config)
    -> Pregel.stream(input, config, stream_mode="values")
      -> SyncPregelLoop.__init__(...)      # 创建执行循环
      -> SyncPregelLoop.__aiter__()/tick() # 开始 BSP 循环

2.3.3 阶段三:BSP 循环

这是整个执行过程的核心。PregelLoop 维护着执行状态,每次 tick() 对应一个超级步骤:

sequenceDiagram
    participant L as PregelLoop
    participant CH as Channels
    participant A as _algo
    participant R as PregelRunner
    participant N as 节点函数

    Note over L: Step 0: 输入处理
    L->>CH: 写入 {"x": 0} 到 x Channel
    L->>CH: 写入信号到 __start__ Channel
    L->>A: prepare_next_tasks()
    A-->>L: [Task: __start__ 节点]
    L->>R: 执行 __start__ 任务
    R->>N: __start__ 节点处理输入
    N-->>R: 写入 branch:to:inc Channel
    R-->>L: 任务完成
    L->>A: apply_writes()
    A->>CH: 更新所有 Channel

    Note over L: Step 1: 执行 inc 节点
    L->>A: prepare_next_tasks()
    A-->>L: [Task: inc 节点]
    L->>R: 执行 inc 任务
    R->>N: increment({"x": 0})
    N-->>R: 返回 {"x": 1}
    R-->>L: 写入 (x, 1)
    L->>A: apply_writes()
    A->>CH: x.update([1])

    Note over L: Step 2: 终止判断
    L->>A: prepare_next_tasks()
    A-->>L: [] (无活跃任务)
    Note over L: 循环结束
    L->>CH: 读取输出 Channel
    L-->>L: 返回 {"x": 1}

让我们逐步分析每个阶段对应的源码:

Step 0 - 输入处理

# 源码位置:langgraph/pregel/_io.py
def map_input(input_channels, chunk):
    """将输入映射为 (channel, value) 对"""
    if isinstance(input_channels, str):
        yield (input_channels, chunk)
    else:
        for k in chunk:
            if k in input_channels:
                yield (k, chunk[k])

输入 {"x": 0} 通过 map_input 被转换为 [("__start__", {"x": 0})],写入 START Channel。然后 START 节点被触发,它将输入数据分发到各个状态 Channel。

Step 1 - 任务准备与执行

prepare_next_tasks(在 _algo.py 中)扫描所有 PregelNode,检查它们的触发 Channel 是否有新的更新。如果 branch:to:inc Channel 在上一步被写入,则 inc 节点被选中执行。

节点执行时,ChannelRead 从 Channel 中读取当前状态,组装成节点的输入。节点函数返回的 dict 通过 ChannelWrite 写入到对应的 Channel。

Step 2 - 写入应用与终止判断

# 源码位置:langgraph/pregel/_algo.py(简化)
def apply_writes(
    checkpoint, channels, tasks, get_next_version
):
    """将所有任务的写入应用到 Channel"""
    updated_channels = set()
    for chan, values in grouped_writes.items():
        if channels[chan].update(values):
            updated_channels.add(chan)
    # 更新 channel_versions
    for chan in updated_channels:
        checkpoint["channel_versions"][chan] = get_next_version(...)

写入应用后,再次调用 prepare_next_tasks。由于 incEND 的边不会触发新的节点(END 是虚拟节点),没有活跃任务,循环结束。

2.3.4 阶段四:输出与返回

循环结束后,从输出 Channel 读取最终状态:

# 源码位置:langgraph/pregel/_io.py
def map_output_values(output_channels, pending_writes, channels):
    """从 Channel 读取输出值"""
    if isinstance(output_channels, str):
        yield read_channel(channels, output_channels)
    else:
        yield read_channels(channels, output_channels)

对于我们的例子,输出 Channel 是 ["x"](因为 State 只有一个字段),所以读取 x Channel 的当前值 1,组装为 {"x": 1} 返回给用户。至此,一次完整的 invoke 调用就结束了。整个过程虽然涉及多个模块的协作,但核心逻辑是清晰的:输入映射、循环执行、输出提取。

2.4 核心数据流:Channel 的读写路径

理解了完整旅程后,让我们聚焦于最关键的数据流——Channel 的读写路径。这是贯穿整个框架的核心机制。在 LangGraph 中,所有节点间的通信都通过 Channel 进行,没有例外。节点不能直接调用另一个节点,也不能直接修改共享的内存变量。这种"间接通信"的约束看似增加了复杂度,实则简化了并发管理和状态追踪——因为所有的数据流动都经过 Channel 这个统一的门户,框架可以在这个门户上实施版本追踪、并发控制和检查点序列化:

graph LR
    subgraph 写入路径
        NF[节点函数返回 dict] --> CW[ChannelWrite]
        CW --> CWE[ChannelWriteEntry]
        CWE --> |"channel, value 对"| AW[apply_writes]
        AW --> CH[Channel.update]
    end
    subgraph 读取路径
        TR[触发判断] --> PNT[prepare_next_tasks]
        PNT --> CR[ChannelRead]
        CR --> CHG[Channel.get]
        CHG --> NI[节点输入]
    end
    CH -->|版本更新| TR

写入路径的关键在于 ChannelWrite。在 CompiledStateGraph.attach_node() 中,每个节点都被附加了一个 ChannelWrite writer:

# 源码位置:langgraph/graph/state.py,attach_node 方法(简化)
write_entries = (
    ChannelWriteTupleEntry(mapper=_get_updates),    # 状态更新
    ChannelWriteTupleEntry(mapper=_control_branch),  # 控制流
)
self.nodes[key] = PregelNode(
    triggers=[branch_channel],
    channels=input_channels,
    writers=[ChannelWrite(write_entries)],
    bound=node.runnable,
)

节点函数返回的 dict(如 {"x": 1})首先经过 _get_updates 映射为 [("x", 1)] 元组列表,然后经过 _control_branch 处理 Command 对象中的控制流指令。最终这些 (channel, value) 对被 apply_writes 统一写入到 Channel。

读取路径的关键在于 ChannelRead。当节点被触发执行时,PregelNode 通过 ChannelRead 从 Channel 中读取当前状态:

# 源码位置:langgraph/pregel/_read.py(简化)
class ChannelRead:
    @staticmethod
    def do_read(config, *, select, fresh, mapper):
        read = config["configurable"]["__pregel_read"]
        if mapper:
            return mapper(read(select, fresh))
        else:
            return read(select, fresh)

这里的 config["configurable"]["__pregel_read"] 是在任务准备阶段注入的函数引用,它指向 local_read(在 _algo.py 中),能够读取 Channel 的当前值并考虑本步骤内的本地写入。

2.5 关键设计决策

在理解了完整的执行流程之后,让我们从更高的视角审视 LangGraph 的关键设计决策。这些决策并非偶然的技术选择,而是经过深思熟虑的架构权衡——它们共同构成了 LangGraph 与其他框架的本质差异。

2.5.1 编译与运行时分离

LangGraph 采用了"先编译再执行"的两阶段模型。StateGraph 是构建器(Builder),CompiledStateGraph 是编译产物。两者的职责清晰分离:

# StateGraph:收集声明,Builder 模式
builder = StateGraph(State)           # 可变的构建器
builder.add_node(...)                  # 声明节点
builder.add_edge(...)                  # 声明边

# CompiledStateGraph:不可变的执行单元
graph = builder.compile()              # 一次性编译
graph.invoke(...)                      # 可多次执行
graph.stream(...)                      # 可多次执行

这种分离的好处:编译阶段可以做完整的图验证(检查悬空节点、缺失边等),并预计算 Channel 布局和触发关系,使运行时的每个 step 尽可能高效。

2.5.2 Channel 作为唯一通信机制

节点之间不能直接通信——所有数据交换必须通过 Channel。这个约束乍看之下限制了灵活性,但实际上是经过深思熟虑的设计选择。在并发编程领域,共享内存的直接访问是大多数 bug 的根源——竞态条件、死锁、数据损坏等问题层出不穷。通过强制所有通信经过 Channel,LangGraph 将并发复杂度封装在了框架内部,对外提供了一个简洁安全的编程模型。

这个约束是 BSP 模型的核心要求,它带来了三个关键保证:

  1. 并发安全:节点在同一步骤内并行执行时,各自看到的是步骤开始时的 Channel 快照,不会看到其他节点的中间写入。这消除了竞态条件的可能性。
  2. 确定性:相同的输入和 Channel 状态一定产生相同的执行顺序。这对于调试和测试至关重要——你可以可靠地重现问题场景。
  3. 可检查点:Channel 是系统状态的完整表示,保存所有 Channel 就是保存完整状态。不存在"隐藏的"状态散落在节点内部的情况。

2.5.3 内部 Channel 的隐藏世界

用户在定义状态模式时看到的只是冰山一角。编译过程会在用户定义的状态 Channel 之外,自动创建大量内部 Channel 来实现边的触发、汇聚等待、动态任务分发等机制。理解这些隐藏 Channel 的存在和用途,是理解 LangGraph 内部工作原理的关键。

# 编译后的完整 Channel 集合(以简单的两节点图为例)
{
    # 用户状态 Channel
    "x": LastValue(int),
    "messages": BinaryOperatorAggregate(list, add_messages),

    # 自动创建的内部 Channel
    "__start__": EphemeralValue,           # START 输入
    "branch:to:agent": EphemeralValue,     # 触发 agent 节点
    "branch:to:tools": EphemeralValue,     # 触发 tools 节点
    "__pregel_tasks": Topic(Send),         # Send 对象通道

    # 如果有多源汇聚边:add_edge(["a", "b"], "c")
    "join:a+b:c": NamedBarrierValue(str, {"a", "b"}),
}

branch:to:{node} Channel 使用 EphemeralValue——它在每个步骤结束后自动清除,确保节点不会被重复触发。NamedBarrierValue 用于等待多个源节点全部完成后才触发目标节点,实现了"等待所有前置节点"的语义。

2.5.4 版本号驱动的触发机制

触发机制是 BSP 调度的核心。一个最朴素的实现是"检查 Channel 是否有值"——有值就触发对应节点。但这种方式无法区分"新写入的值"和"上一步遗留的值",会导致节点被重复触发。LangGraph 采用了更精确的版本号比较机制来解决这个问题。

Pregel 判断哪些节点需要执行,靠的是 Channel 版本号比较:

# 源码位置:langgraph/pregel/_algo.py,prepare_next_tasks(简化逻辑)
# 对于每个 PregelNode,检查其触发 Channel 是否有新更新
for trigger in node.triggers:
    if channel_versions[trigger] > versions_seen[node_name].get(trigger, 0):
        # 这个节点需要执行
        tasks.append(create_task(node_name, ...))

每个 Channel 维护一个版本号(channel_versions),每次更新时递增。每个节点维护一个"已见版本"映射(versions_seen)。当某个触发 Channel 的版本号大于节点已见的版本号时,该节点被激活。这种机制比简单的"是否有值"检查更加精确,能正确处理跨步骤的触发关系。版本号的设计还带来了一个额外的好处——它天然支持检查点恢复后的正确触发判断,因为版本号信息被完整保存在检查点中。从检查点恢复后,引擎可以准确知道哪些节点已经处理过哪些更新,不会遗漏也不会重复。

2.6 目录结构导航指南

在深入后续章节之前,熟悉代码的目录结构至关重要。LangGraph 的代码组织遵循"功能聚合"原则——相关的功能放在同一个目录下,同时使用下划线前缀(_)来标记内部模块,区分公共 API 和内部实现。以下完整的目录导航将帮助读者在阅读源码时快速定位到目标文件,建议收藏备查:

langgraph/langgraph/
├── __init__.py
├── constants.py          # 公共常量:START, END, TAG_HIDDEN, TAG_NOSTREAM
├── types.py              # 公共类型:Send, Command, Interrupt, RetryPolicy...
├── config.py             # get_config() 工具函数
├── errors.py             # 异常类:GraphRecursionError, InvalidUpdateError...
├── typing.py             # 类型变量:StateT, InputT, OutputT, ContextT
├── runtime.py            # Runtime 上下文对象
├── version.py            # 包版本号
├── warnings.py           # 弃用警告类
│
├── graph/                # [第3章] 图定义层
│   ├── state.py          #   StateGraph, CompiledStateGraph, _get_channels
│   ├── _branch.py        #   BranchSpec 条件边
│   ├── _node.py          #   StateNodeSpec 节点规格
│   ├── message.py        #   add_messages, MessagesState
│   └── ui.py             #   UI 相关工具
│
├── channels/             # [第4章] Channel 层
│   ├── base.py           #   BaseChannel 抽象基类
│   ├── last_value.py     #   LastValue, LastValueAfterFinish
│   ├── binop.py          #   BinaryOperatorAggregate
│   ├── topic.py          #   Topic
│   ├── ephemeral_value.py #  EphemeralValue
│   ├── named_barrier_value.py # NamedBarrierValue
│   ├── any_value.py      #   AnyValue
│   └── untracked_value.py #  UntrackedValue
│
├── pregel/               # [第6-7章] Pregel 运行时
│   ├── main.py           #   Pregel, NodeBuilder
│   ├── _algo.py          #   BSP 算法核心
│   ├── _loop.py          #   执行循环
│   ├── _runner.py        #   任务并行执行
│   ├── _checkpoint.py    #   检查点操作
│   ├── _io.py            #   输入输出映射
│   ├── _read.py          #   ChannelRead, PregelNode
│   ├── _write.py         #   ChannelWrite
│   ├── _validate.py      #   图验证
│   ├── _config.py        #   配置工具
│   ├── _retry.py         #   重试策略
│   ├── _call.py          #   任务调用
│   ├── _messages.py      #   消息流处理
│   ├── _draw.py          #   图可视化
│   ├── _utils.py         #   工具函数
│   ├── protocol.py       #   PregelProtocol
│   ├── debug.py          #   调试输出
│   ├── remote.py         #   远程图
│   └── types.py          #   Pregel 内部类型
│
├── managed/              # 托管值
│   ├── base.py           #   ManagedValueSpec
│   └── is_last_step.py   #   IsLastStep
│
├── _internal/            # 内部工具
│   ├── _config.py        #   配置合并、补丁
│   ├── _constants.py     #   内部常量:CONF, CONFIG_KEY_*
│   ├── _fields.py        #   字段处理:get_field_default, get_update_as_tuples
│   ├── _pydantic.py      #   Pydantic 集成
│   ├── _runnable.py      #   RunnableCallable, coerce_to_runnable
│   ├── _typing.py        #   MISSING 哨兵值
│   ├── _serde.py         #   序列化/反序列化
│   ├── _scratchpad.py    #   临时存储
│   ├── _replay.py        #   回放状态
│   ├── _retry.py         #   重试逻辑
│   ├── _cache.py         #   缓存键生成
│   ├── _future.py        #   Future 包装
│   └── _queue.py         #   同步/异步队列
│
├── func/                 # 函数式 API
│   └── __init__.py       #   entrypoint, task
│
└── utils/                # 工具函数
    ├── config.py         #   配置工具
    └── runnable.py       #   Runnable 工具

2.7 常见问题与误解

在结束本章之前,让我们澄清几个初学者常见的架构层面的误解。这些误解往往源于对四层架构边界的不清晰认知,提前澄清可以避免后续学习中的很多困惑。

2.7.1 误解:StateGraph 就是可执行的图

很多初学者会认为 StateGraph 本身就是可以执行的。实际上,StateGraph 只是一个构建器——它收集声明但不执行任何计算。真正可执行的是 CompiledStateGraphPregel 的子类)。如果你试图在未编译的 StateGraph 上调用 invoke,会发现它根本没有这个方法。

这种分离的一个实际影响是:编译后修改 StateGraph(如添加新节点)不会影响已编译的图。源码中的 self.compiled 标记和相应的警告日志就是为此设计的——提醒开发者编译后的修改不会生效。

2.7.2 误解:Channel 只用于存储用户状态

如第 2.5.3 节所述,Channel 不仅存储用户定义的状态字段,还承载了边触发信号、汇聚屏障、动态任务分发等内部机制。实际上,在一个典型的 LangGraph 应用中,内部 Channel 的数量往往超过用户状态 Channel。理解这一点对于调试"为什么我的节点没有被触发"类问题至关重要——问题可能出在内部触发 Channel 而非用户状态。

2.7.3 误解:invoke 和 stream 是两套独立的执行路径

如前所述,invoke 内部通过 stream 实现。这个设计保证了两种调用方式的行为完全一致。一个实际的推论是:如果你在 stream 模式下发现了某个行为问题,在 invoke 模式下也一定存在同样的问题。在调试时,使用 stream 模式可以获得更多的中间信息,建议优先使用。

2.7.4 误解:Pregel 只是一个名字

有些开发者认为 Pregel 只是 LangGraph 运行时类的名字,与 Google 的 Pregel 论文没有实质关联。实际上,LangGraph 的 Pregel 严格遵循了 BSP(批量同步并行)模型的核心原则——超级步骤、同步屏障、写入延迟可见。理解这些原则对于预测运行时行为(特别是并行节点的执行语义)至关重要。当你发现并行节点看不到彼此的写入时,这不是 bug,而是 BSP 模型的核心设计约束。

2.7.5 误解:LangGraph 只能用于 LLM 应用

虽然 LangGraph 的设计场景主要是 LLM 驱动的 AI 工作流,但它的核心机制(图拓扑、Channel 状态管理、BSP 执行、检查点持久化)是完全通用的。你完全可以用 LangGraph 来编排不涉及 LLM 的复杂数据处理管道、多步骤审批流程,或者任何需要有状态执行和持久化能力的工作流。框架的依赖项中只有 langchain-core 是与 LLM 生态相关的,而且主要用于 Runnable 接口和配置传播,并不要求实际调用 LLM。

2.8 从架构视角看 LangGraph 的扩展能力

理解了四层架构之后,我们可以从架构视角分析 LangGraph 的扩展能力,这对于需要深度定制的高级用户尤为重要。

图定义层的扩展:开发者可以通过继承 StateGraph 来创建专用的图构建器,比如为特定领域预定义节点和边模板。MessageGraph 就是这种扩展的一个早期例子(虽然已被弃用)。函数式 API(func/ 目录下的 entrypointtask)是另一种扩展方式,它允许用装饰器风格定义图,而非显式的构建器模式。

Channel 层的扩展:只要实现 BaseChannel 的六个核心方法,就可以创建全新的 Channel 类型。这为实现特殊的状态管理语义打开了大门——比如基于时间窗口的值过期、带优先级的队列 Channel、或者支持版本化回滚的 Channel。

Pregel 运行时层的扩展:通过 NodeBuilder 类,高级用户可以直接构建底层的 PregelNode,绕过 StateGraph 的抽象层。这在需要精细控制 Channel 订阅和触发关系时非常有用。Pregel 类本身也可以直接实例化,虽然这在实践中很少需要。

Checkpoint 层的扩展:这是最常见也是最实用的扩展点。通过实现 BaseCheckpointSaver 接口,可以将状态持久化到任何存储后端——Redis、MongoDB、DynamoDB 或自定义的企业存储服务。官方已经提供了内存、SQLite 和 PostgreSQL 的实现,社区也贡献了更多存储后端的支持。在生产环境中,选择合适的持久化后端对于工作流的可靠性和性能至关重要。

这种层层可扩展的架构设计,使得 LangGraph 既可以作为开箱即用的框架直接使用,也可以作为构建更高层框架的基础设施。开发者可以根据自己的需求选择在哪一层进行定制:简单场景使用图定义层的标准接口;中等复杂度的场景可能需要自定义 Channel 或 Reducer;高级场景可以直接操作底层的 Pregel 原语。这种多层次的扩展能力正是"低层基础设施"定位的体现。

2.9 小结

本章从宏观视角建立了对 LangGraph 的整体认知。核心要点回顾:

  1. 三大包的职责边界清晰明确:langgraph 负责图定义、Channel 和运行时;checkpoint 定义持久化接口;prebuilt 提供上层组件。最小依赖原则确保了灵活的组合方式。这种分包策略让开发者可以按需引入依赖项,避免不必要的包体积膨胀和潜在的依赖冲突问题。

  2. 四层架构模型自顶向下:图定义层提供开发者友好的声明式 API;Channel 层管理有状态的数据流;Pregel 运行时层执行 BSP 循环;Checkpoint 层负责持久化。每一层都有清晰明确的接口和职责边界,层与层之间通过精心定义的协议进行交互,使得每一层都可以独立地演进和扩展。

  3. graph.invoke() 的完整旅程经历了编译、输入映射、BSP 循环(计划-执行-更新)和输出映射四个阶段。理解这个旅程是理解所有高级特性的基础。特别要注意的是 invoke 内部通过 stream 实现,这保证了两种调用模式行为的完全一致性。

  4. 关键设计决策包括:编译阶段与运行时阶段的分离确保了图结构的完整性验证和运行时执行效率;Channel 作为唯一通信机制保证了并发安全和确定性;内部 Channel 的自动创建将边的语义转换为数据流触发;版本号驱动的触发机制精确控制了哪些节点在每个步骤中被执行。这些决策共同构成了 LangGraph 的架构基石。

下一章将深入第一层——StateGraph 的图构建 API,详细分析节点注册、边定义和条件分支的实现机制,以及编译过程如何将开发者友好的高层声明转换为 Pregel 运行时引擎可以直接执行的底层原语。