LangGraph 设计与实现

第18章 设计模式与架构决策

作者 杨艺韬 · 13,165 字 · 发布于 · 更新于

18.1 引言

经过前面十七章的深入剖析,我们已经从源码层面理解了 LangGraph 的每一个核心组件——StateGraph 的编译流程、Channel 的类型体系、Pregel 的超步调度、Checkpoint 的持久化、Send 的动态并行、Runtime 的依赖注入、Store 的长期记忆、以及预构建的 Agent 组件。

本章将从更高的视角审视这些设计选择。我们不再逐行分析源码,而是提炼出 LangGraph 中可迁移的设计模式——那些超越 LLM 应用框架本身、在更广泛的软件工程领域中具有通用价值的架构思想。同时,我们也将诚实地评估每个关键决策的权衡,帮助读者在设计自己的系统时做出更明智的选择。

本章要点

  1. Pregel 计算模型的选择——为什么图 + 消息传递胜过其他范式
  2. Channel 版本追踪——通过版本号实现精确的变更检测
  3. Checkpoint 时间旅行——检查点快照 + pending writes 的配合
  4. 中断/恢复模式——从 GraphInterrupt 异常到确定性重放
  5. 构建你自己的工作流引擎——从 LangGraph 中提炼的设计原则

18.2 Pregel 计算模型的选择

18.2.1 为什么选择 Pregel?

LangGraph 以 Google Pregel 作为计算模型的灵感来源。让我们对比几种候选模型:

graph TB
    subgraph 候选计算模型
        A["Actor 模型<br/>每个节点是独立 Actor<br/>异步消息传递"]
        B["数据流模型<br/>算子之间连接管道<br/>连续流处理"]
        C["Pregel/BSP 模型<br/>同步超步<br/>Channel 消息传递"]
        D["Petri 网<br/>令牌驱动<br/>并发形式化"]
    end
    C -->|LangGraph 的选择| Why["为什么?"]
    Why --> W1["超步提供确定性边界"]
    Why --> W2["Channel 解耦生产者消费者"]
    Why --> W3["天然支持快照一致性"]
    Why --> W4["简单直观的编程模型"]

Pregel 模型的核心优势:

  1. 超步边界提供确定性:每个超步内,被触发的节点读取同一份步初状态、并发执行,写入在超步结束时按任务 path 排序后统一应用。这消除了步内的读写竞态:哪些任务被创建、写入按什么顺序合并,在相同输入下是确定的;至于节点本身(例如调用 LLM)的输出是否确定,则不由框架保证。

  2. Channel 解耦:生产者写入 Channel,消费者在下一个超步读取。这种间接通信让节点不需要知道谁在监听,也不需要等待消费者就绪。

  3. 快照友好:超步边界是天然的 Checkpoint 点——所有 Channel 值稳定,没有"进行中"的状态。

  4. 简单的编程模型:开发者只需要定义"给定当前状态,节点输出什么",不需要管理并发、同步或消息队列。

18.2.2 超步 vs 事件驱动

sequenceDiagram
    participant S1 as 超步 N
    participant CH as Channels
    participant S2 as 超步 N+1
    Note over S1: 所有节点读取同一快照
    S1->>CH: NodeA 写入 channel_x
    S1->>CH: NodeB 写入 channel_y
    Note over CH: 超步结束:应用所有写入
    CH->>S2: 所有 Channel 更新后的新快照
    Note over S2: 基于新快照触发下一批节点

超步模型的关键约束是写入延迟一步可见——NodeA 在超步 N 写入的值,NodeB 要在超步 N+1 才能读取。这看似是限制,实际上是优势:它避免了在同一步中"读到尚未稳定的中间值"的问题。

18.2.3 从 Pregel 到 LangGraph 的适配

原始 Pregel 设计用于大规模图计算(如 PageRank),LangGraph 做了几个关键适配:

  1. Channel 替代顶点消息:原始 Pregel 每个顶点接收邻居消息,LangGraph 使用 Channel 提供更丰富的聚合语义(LastValue、BinaryOperatorAggregate、Topic)
  2. 有限步数:LangGraph 通过 recursion_limit 保证终止,而非依赖算法收敛
  3. 可中断:原始 Pregel 设计为批处理,LangGraph 支持人机交互的中断/恢复
  4. 异构节点:原始 Pregel 所有顶点运行相同程序,LangGraph 每个节点可以是不同的函数

18.3 Channel 版本追踪

18.3.1 版本号机制

LangGraph 使用单调递增的版本号追踪每个 Channel 的更新历史。这是整个调度系统的基石——版本号决定了哪些节点在下一个超步中需要被触发。

# Checkpoint 中的版本追踪结构
checkpoint = {
    "channel_versions": {
        "messages": 5,       # messages Channel 最后更新于版本 5
        "status": 3,         # status Channel 最后更新于版本 3
        "__start__": 1,      # 入口 Channel 版本 1
    },
    "versions_seen": {
        "agent": {           # agent 节点上次看到的版本
            "messages": 4,   # agent 看到 messages 时是版本 4
            "status": 3,     # agent 看到 status 时是版本 3
        },
        "tools": {
            "messages": 5,   # tools 看到 messages 时是版本 5
        },
    }
}

18.3.2 触发判定算法

# libs/langgraph/langgraph/pregel/_algo.py:1058
def _triggers(channels, versions, seen, null_version, proc) -> bool:
    if seen is None:
        for chan in proc.triggers:
            if channels[chan].is_available():
                return True
    else:
        for chan in proc.triggers:
            if channels[chan].is_available() and versions.get(
                chan, null_version
            ) > seen.get(chan, null_version):
                return True
    return False

核心逻辑有两个合取项:channel 里必须真的有值(is_available()),并且它的当前版本 > 该节点上次看到的版本,才触发该节点。seen is None(节点从未执行过)时只看第一个条件——从没执行过的节点,只要触发 channel 有值就该跑一次。这个简单的比较实现了精确的增量计算——只有真正需要处理新数据的节点才会被执行。

flowchart TB
    subgraph 版本比较示例
        CV["channel_versions:<br/>messages=5, status=3"]
        VS["versions_seen[agent]:<br/>messages=4, status=3"]
        Compare{"messages: 5 > 4?"}
        CV --> Compare
        VS --> Compare
        Compare -->|是| Trigger["触发 agent 节点"]
        Compare2{"status: 3 > 3?"}
        CV --> Compare2
        VS --> Compare2
        Compare2 -->|否| Skip["不触发(未更新)"]
    end

18.3.3 版本号的递增策略

def increment(current: int | None, channel: None) -> int:
    """默认的版本号递增函数"""
    return current + 1 if current is not None else 1

increment 是没有配置 checkpointer 时使用的 GetNextVersion;配了 checkpointer 则改用它的 get_next_version(pregel/_loop.py:1067-1074)。每当 apply_writes 更新 Channel 时,Channel 的版本号递增。关键点在于同一个超步中所有被更新的 Channel 共享同一个版本号:

# apply_writes 中(_algo.py:258)
next_version = get_next_version(
    max(checkpoint["channel_versions"].values())
    if checkpoint["channel_versions"]
    else None,
    None,   # 第二个参数是废弃的 channel 形参,恒传 None
)
for chan, vals in pending_writes_by_channel.items():
    if channels[chan].update(vals) and next_version is not None:
        checkpoint["channel_versions"][chan] = next_version  # 同一版本号

这确保了"同一超步的所有更新"在版本上不可区分,避免了步内的偏序关系。

18.3.4 这个模式的可迁移性

Channel 版本追踪模式适用于任何需要"增量计算"的场景:

  • 数据管道:只重新计算受上游变更影响的下游节点
  • UI 框架:只重新渲染依赖了变更数据的组件
  • 构建系统:只重新编译依赖了修改文件的目标

核心抽象:数据版本 + 消费者已见版本 -> 触发判定。

18.3.5 null_version 的存在与用意

看 _triggers 函数的签名——它接受一个 null_version 参数。这个参数的含义是 "版本号的零值"——在 apply_writes 代码里:

next_version = get_next_version(max(checkpoint["channel_versions"].values()), None)

null_version 代表 "从未写入过" 的状态。Channel 刚创建时版本号是 null_version(默认 0 或 None)、第一次被写入后变成 1、之后 2、3……

为什么要特殊 null_version?——两个场景:

① 节点执行过、但没记录过某个 channel——versions_seen[node] 里没有这个 key、.get(chan, null_version) 返回 null_version。只要 channel 版本 > null_version(即被写入过)、就触发。

② Channel 从未被写入——versions.get(chan, null_version) 也返回 null_version、比较时 null_version > null_version == False、不触发。

这避免了 "节点被触发但其触发 channel 从没被写过" 的 impossible 状态。真实的 null_version 由 checkpoint_null_version()(libs/langgraph/langgraph/pregel/_algo.py:1049)算出——它取 channel_versions 里任意一个版本值的类型、返回该类型的零值(int() 就是 0);channel_versions 为空时返回 None。用值的类型反推零值,而不是硬编码 0——因为版本类型是可插拔的(见下一节)。

这种 "显式表示 unknown/not-yet-set 状态" 的模式在版本化系统里很常见——Git 用 0000... 作为 null commit、数据库 MVCC 用 0 作为 no-transaction、Rust Option<NonZeroU64> 用 None 表示 unset。LangGraph 的 null_version 是同一种思想——用专门的哨兵值表示 "没信息"。

18.3.6 increment 函数作为可插拔依赖

GetNextVersion 是一个 protocol/trait:

# _algo.py:85
GetNextVersion = Callable[[V | None, None], V]

# _algo.py:213
def increment(current: int | None, channel: None) -> int:
    """Default channel versioning function, increments the current int version."""
    return current + 1 if current is not None else 1

第二个形参在 BaseCheckpointSaver.get_next_version 的 docstring 里被明确标为 "Deprecated argument, kept for backwards compatibility"——调用方恒传 None。

默认实现是简单递增、但这是可替换的——用户可以通过自定义 Checkpointer 提供不同的版本生成策略:

  • Timestamp-based:用高精度时间戳作为版本、便于按时间查询历史
  • 有序字符串:BaseCheckpointSaver.get_next_version 的 docstring 写了 "you can use str/int/float versions, as long as they are monotonically increasing"
  • 带随机后缀的字符串:InMemorySaver 就是现成的例子,返回 32 位零填充的主版本号加一段随机后缀(libs/checkpoint/langgraph/checkpoint/memory/__init__.py:518-527,第 8 章 §8.4.1)

注意单调递增是硬约束:_triggers 靠 > 比较判断触发,apply_writes 靠 max(...) 取当前最大版本。所以内容 hash 这类无序值不能当版本号——基类默认实现遇到 str 直接 raise NotImplementedError,就是在提醒你自定义时必须自己保证有序。

为什么 LangGraph 默认用 integer increment?——因为最简单、最快、一致性强。所有 channel 在同一超步共享一个版本号(max(...) + 1)——提供清晰的时间线视图。

自定义版本生成的场景——InMemorySaver 的随机后缀就是为了一个整数版本解决不了的问题:从同一个历史检查点分叉出的两条分支会算出相同的主版本号,而它的 blobs 以 (thread_id, checkpoint_ns, channel, version) 为键,没有后缀就会互相覆盖。有类似需求时,在自定义 checkpointer 里覆写 get_next_version 即可——只要保证主序单调递增。

"默认值简单 + 通过接口允许高级覆盖" 是 LangGraph API 设计的一致哲学——大多数用户走默认路径、少数高级用户按需定制。

18.4 Checkpoint 时间旅行

18.4.1 检查点快照 + pending writes

LangGraph 的持久化由两部分配合完成(机制详见第 8 章):

graph TB
    subgraph "检查点(超步边界)"
        S1["Checkpoint N<br/>Channel 值快照<br/>channel_versions 版本表<br/>versions_seen 已读表"]
    end
    subgraph "pending writes(挂在 Checkpoint N 上)"
        W1["任务 A 完成 → put_writes (channel, value)…"]
        W2["任务 B 完成 → put_writes (channel, value)…"]
        W3["任务 C 中断 → put_writes (__interrupt__, …)"]
    end
    S1 -->|"本步任务全部完成后 apply_writes"| S2["Checkpoint N+1"]
    W1 --> S2
    W2 --> S2
  • 检查点:在超步边界生成,逻辑上包含所有 Channel 的值和版本表,是一个完整的状态
  • pending writes:超步执行期间,每个任务一完成就把自己的写入(以及中断、错误等特殊写入)通过 put_writes 存下来,挂在本步开始时的那个检查点上;等本步全部任务完成,apply_writes 合并写入、生成下一个检查点

它和数据库的"快照 + WAL"有相似之处——都是"一份完整状态 + 其后的增量记录",但不要照搬 WAL 的理解:pending writes 只覆盖当前这一个超步,默认持久化模式下每一步都会产生新的检查点,恢复某个检查点并不需要从头重放写入日志。它真正的用途是:

  1. 超步内的故障恢复:一个超步里部分任务已完成、另一些出错或被中断时,恢复后已完成任务的写入直接复用、不再重跑,只有未完成的任务(包括被中断的节点)从头执行(第 8 章 §8.8.2)
  2. 调试能力:pending writes 保留了每个任务的原始输出,便于溯源

至于存储开销,靠的是另一套机制:InMemorySaver 和 Postgres 把 Channel 值按 (channel, version) 单独存放,未变化的 Channel 在相邻检查点之间只存一份(第 8 章 §8.4.2、§8.8.1;SqliteSaver 没有这层去重)。

18.4.2 时间旅行的实现

# 获取历史状态
for snapshot in graph.get_state_history(config):
    print(f"Step: {snapshot.metadata['step']}")
    print(f"Values: {snapshot.values}")
    print(f"Next: {snapshot.next}")

# 回溯到特定 Checkpoint
past_config = snapshot.config
# 从该点恢复执行
result = graph.invoke(None, past_config)
stateDiagram-v2
    CP0: Checkpoint 0<br/>初始状态
    CP1: Checkpoint 1<br/>agent 执行后
    CP2: Checkpoint 2<br/>tools 执行后
    CP3: Checkpoint 3<br/>agent 再次执行后
    [*] --> CP0
    CP0 --> CP1
    CP1 --> CP2
    CP2 --> CP3
    CP3 --> [*]
    note right of CP1 : 可以回溯到此<br/>修改工具结果<br/>重新执行

时间旅行不仅是调试工具,更是产品功能。用户可以在对话中"撤销"到之前的某一步,修改输入后继续。这在 Agent 应用中特别有价值——当 Agent 走错方向时,可以回退到分歧点重新尝试。

18.4.3 CheckpointMetadata 的设计

# libs/checkpoint/langgraph/checkpoint/base/__init__.py:35
class CheckpointMetadata(TypedDict, total=False):
    source: Literal["input", "loop", "update", "fork"]
    step: int                # -1 是首个 "input" checkpoint,0 是首个 "loop"
    parents: dict[str, str]  # checkpoint namespace -> checkpoint ID
    run_id: str              # 创建它的那次 run 的 ID

source 字段区分了四种 Checkpoint 来源:

  • "input":图接收输入时创建
  • "loop":Pregel 循环每步创建
  • "update":update_state API 手动创建
  • "fork":作为另一个 checkpoint 的副本创建(时间旅行分叉)

注意四个字段都是 total=False——metadata 是尽力而为的注解、不是强契约;也没有 writes 字段(每个任务的写入记录在 checkpointer 的 pending writes 表里,不在 metadata 里)。

这使得 Checkpoint 历史不仅是状态序列,更是带有因果关系注解的执行日志。

18.4.4 pending_writes 与 pending_sends 的去向

Checkpoint 快照本身和"未决数据"是两套结构。先看 Checkpoint 在 langgraph-checkpoint 4.0.1 里的真实字段(libs/checkpoint/langgraph/checkpoint/base/__init__.py:65):

class Checkpoint(TypedDict):
    v: int                                   # 格式版本;Pregel 层写出的是 4(第 8 章 §8.2)
    id: str                                  # 单调递增的 UUID6
    ts: str                                  # ISO 8601 时间戳
    channel_values: dict[str, Any]
    channel_versions: ChannelVersions
    versions_seen: dict[str, ChannelVersions]
    updated_channels: list[str] | None        # 本 checkpoint 更新过的 channel

这里有一条容易过时的知识点:早期版本的 Checkpoint 里有一个独立的 pending_sends: list[Send] 字段,用来存"整个超步收到的所有 Send"。当前版本它已经不是 Checkpoint 的声明字段了——Send 被统一收进了 TASKS 这个特殊 channel。Pregel._migrate_checkpoint(libs/langgraph/langgraph/pregel/main.py:1013)负责把老 checkpoint 迁过来:

def _migrate_checkpoint(self, checkpoint: Checkpoint) -> None:
    """Migrate a saved checkpoint to new channel layout."""
    if checkpoint["v"] < 4 and checkpoint.get("pending_sends"):
        pending_sends: list[Send] = checkpoint.pop("pending_sends")
        checkpoint["channel_values"][TASKS] = pending_sends
        checkpoint["channel_versions"][TASKS] = max(
            checkpoint["channel_versions"].values()
        )

(copy_checkpoint / create_checkpoint 里仍然带着 pending_sends=... 的透传,是为了让旧 checkpointer 存下来的数据不丢——读得懂、但不再产生。)

于是当前的分工是:

pending_writes:存在 checkpointer 的独立表里、Postgres 的主键是 (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)——每个 task 的每条写入独立记录。恢复时按 task_id 认领:已经留下写入的任务直接复用、不再重跑。

Send:以 channel_values[TASKS] 的形式回到 channel 体系内部——_control_branch 把 Send 写进 TASKS 通道(第 10 章 §10.3.4,另见第 17 章 §17.4.5 的 handoff 视角),prepare_push_task_send 下一超步按 idx 从 TASKS 里取出来展开(第 12 章 §12.5.3)。

这次收敛本身就是一条设计教训:Send 一开始被当成"channel 装不下的特殊物"另开了一个字段,后来发现它其实可以被建模成一个 channel 的值——于是 checkpoint 结构少了一个字段、Send 的持久化/恢复/版本号全部复用 channel 的既有机制。能塞回统一抽象的特例,就不要留成特例。

18.4.5 Checkpoint 的三个 ID:thread / checkpoint / parent

一个 Checkpoint 有三个 ID:

  • thread_id——对话/会话的 ID、跨 Checkpoint 串联同一个"历史"
  • checkpoint_id——本 Checkpoint 的唯一 ID、典型是 UUID
  • parent_checkpoint_id——上一个 Checkpoint(除了第一个、每个都有 parent)

为什么要这三个?——

  • thread_id:用户视角的 "同一次对话"——Agent 应用里每个 session 一个 thread
  • checkpoint_id:数据库主键、确定性查询
  • parent_checkpoint_id:形成 Checkpoint 链——从当前往前回溯全部历史

当用户 update_state 分叉 时——新 Checkpoint 的 parent_checkpoint_id 指向 分叉点、而不是最新 Checkpoint。这让 thread 里可以有多条历史线——用户在分叉点更新、后续执行走新分支、但老分支依然可查。

典型应用——"undo/redo"——用户回退到某个历史 Checkpoint、改输入、继续执行——新分支不覆盖旧分支、用户随时能切换回去。

这套 ID 体系和 Git 的 commit graph 很像——checkpoint_id 对应 commit、parent_checkpoint_id 对应 parent commit,而 thread_id 更像一个仓库(上面说过,同一 thread 里可以有多条分支);不同之处在于 Git 按内容哈希寻址,checkpoint_id 则是按时间有序的 UUID6——可以把 checkpoint 历史理解成 Agent state 的 Git。

18.5 中断/恢复模式

18.5.1 从异常到控制流

LangGraph 的中断机制使用 Python 异常作为控制流工具:

# libs/langgraph/langgraph/types.py:705
def interrupt(value: Any) -> Any:
    """Interrupt the graph with a resumable exception."""
    conf = get_config()["configurable"]
    scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
    idx = scratchpad.interrupt_counter()

    # 1. 本 task 已经记录过的 resume 值,按索引取
    if scratchpad.resume:
        if idx < len(scratchpad.resume):
            conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
            return scratchpad.resume[idx]

    # 2. 全局 resume 值(Command(resume=...) 不指定 id 时走这条),消费掉并追加
    v = scratchpad.get_null_resume(True)
    if v is not None:
        assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
        scratchpad.resume.append(v)
        conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
        return v

    # 3. 都没有,抛出中断异常
    raise GraphInterrupt(
        (Interrupt.from_ns(value=value, ns=conf[CONFIG_KEY_CHECKPOINT_NS]),)
    )
sequenceDiagram
    participant Node as 节点函数
    participant Int as interrupt()
    participant Pregel as Pregel 循环
    participant Client as 调用方
    Note over Node: 首次执行
    Node->>Int: interrupt("请确认")
    Int-->>Pregel: raise GraphInterrupt
    Pregel-->>Client: 返回中断信息
    Note over Node: 恢复执行
    Client->>Pregel: Command(resume="确认")
    Pregel->>Node: 从头重新执行节点
    Node->>Int: interrupt("请确认")
    Note over Int: 找到恢复值
    Int-->>Node: return "确认"
    Node->>Node: 继续执行后续逻辑

18.5.2 确定性重放的关键

中断恢复的核心挑战是确定性——恢复时节点从头重新执行,必须保证之前的所有 interrupt() 调用按照相同的顺序获得相同的恢复值。

这通过 PregelScratchpad 的 interrupt_counter 实现:

# libs/langgraph/langgraph/_internal/_scratchpad.py:9
@dataclasses.dataclass(**_DC_KWARGS)   # kw_only + slots + frozen
class PregelScratchpad:
    step: int
    stop: int
    call_counter: Callable[[], int]
    interrupt_counter: Callable[[], int]   # ← 不是方法,是可调用字段
    get_null_resume: Callable[[bool], Any]
    resume: list[Any]
    subgraph_counter: Callable[[], int]

注意 interrupt_counter 不是方法而是一个 Callable 字段——scratchpad 本身是 frozen dataclass,计数状态藏在闭包里。填进去的是 LazyAtomicCounter(libs/langgraph/langgraph/pregel/_algo.py:1224),它懒初始化一个 itertools.count(0).__next__。

libs/langgraph/langgraph/pregel/_algo.py:1131 那行注释说明了动机:"using itertools.count as an atomic counter (+= 1 is not thread-safe)"——itertools.count.__next__ 是 C 层的原子操作,而 Python 层的 self._idx += 1 在多线程下会丢计数。

每个 interrupt() 调用递增索引,恢复值列表按索引匹配。这意味着一个节点中的多个 interrupt() 调用必须保持相同的顺序——这是一个隐式的约束。

18.5.3 中断 ID 与精确恢复

@final
@dataclass(init=False, slots=True)
class Interrupt:
    value: Any
    id: str  # 基于 checkpoint_ns 的确定性哈希

    @classmethod
    def from_ns(cls, value: Any, ns: str) -> Interrupt:
        return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))

中断 ID 是任务 checkpoint 命名空间(形如 节点名:任务ID)的哈希,所以它是任务级的:同一节点里的多次 interrupt() 共用一个 ID。调用方可以通过 ID 精确地恢复特定任务上的中断:

# 精确恢复
Command(resume={interrupt_id: resume_value})

# 不指定 ID(只有一个挂起中断时可用;多个任务同时挂起会抛 RuntimeError)
Command(resume=resume_value)

18.5.4 中断模式的可迁移性

LangGraph 的中断/恢复模式本质上是一个协程式的人机交互协议:

  1. 执行流遇到需要人工输入的点,发起中断
  2. 框架保存当前状态(Checkpoint)
  3. 人工提供输入
  4. 框架恢复执行,使用人工输入继续

这个模式适用于任何需要"暂停-等待-恢复"语义的长时间运行的工作流:审批流程、多步表单、交互式数据标注等。

18.5.5 GraphInterrupt 异常的 Pregel 主循环处理

interrupt() raise GraphInterrupt 后,接住它的不是 main.py,而是 PregelRunner(libs/langgraph/langgraph/pregel/_runner.py)里的三处:

# 1) commit():把中断写进 checkpointer(_runner.py:435-442)
elif exception:
    if isinstance(exception, GraphInterrupt):
        # save interrupt to checkpointer
        if exception.args[0]:
            writes = [(INTERRUPT, exception.args[0])]
            if resumes := [w for w in task.writes if w[0] == RESUME]:
                writes.extend(resumes)
            self.put_writes()(task.id, writes)

# 2) _should_stop_others():中断不算失败,不取消其它在跑的任务(_runner.py:462-474)
#    docstring 原文:GraphInterrupts are not considered failures.
if not isinstance(exc, GraphBubbleUp) and fut not in SKIP_RERAISE_SET:
    return True

# 3) _panic_or_proceed():本超步收口时把所有中断合并成一个抛出(_runner.py:506-523)
interrupts: list[GraphInterrupt] = []
...
if isinstance(exc, GraphInterrupt):
    interrupts.append(exc)
elif fut not in SKIP_RERAISE_SET:
    raise exc
if interrupts:
    raise GraphInterrupt(tuple(i for exc in interrupts for i in exc.args[0]))

关键观察——GraphInterrupt 不会让整个超步立刻挂掉:_should_stop_others 的 docstring 写明 "GraphInterrupts are not considered failures",所以其他任务继续执行,完成后各自的写入作为 pending writes 存下;等本超步的任务全部收口,_panic_or_proceed 才把这一步收集到的所有中断合并成一个 GraphInterrupt 抛出。

这就让 并行节点独立决定要不要中断 成为可能——一个节点中断等用户输入、其他并行节点照常跑,当前检查点的 pending writes 里同时包含已完成任务的写入 + 挂起的中断(这一步不做 apply_writes,恢复时已完成的任务不再重跑)。

超步结束时:

  • 有 Interrupt → 返回给调用方、等 Command(resume=...)
  • 没 Interrupt → 进入下一超步

GraphInterrupt 用异常做控制流的真正优势——立刻脱离当前函数栈、不需要手动返回判断。用户代码写得像同步函数(value = interrupt("confirm?"))、但实际通过异常机制与 Pregel 交互。

这是有异常机制的语言里用异常模拟 continuation的一种惯用手法——coroutine 可以替代、但异常更简单、不需要特殊 runtime 支持。

18.5.6 scratchpad.resume 的索引匹配——多 interrupt 的顺序约定

回看 interrupt() 实现(极简示意,完整代码见 §18.5.1):

def interrupt(value):
    scratchpad = get_config()["configurable"][CONFIG_KEY_SCRATCHPAD]
    idx = scratchpad.interrupt_counter()  # 递增计数器
    if scratchpad.resume:
        if idx < len(scratchpad.resume):
            return scratchpad.resume[idx]  # 按索引匹配
    raise GraphInterrupt(...)

scratchpad.resume[idx] 是关键——多个 interrupt 时、按调用顺序的 index 匹配 resume 值。

隐式约定:用户提供 resume values 的顺序、必须和 interrupt() 调用顺序一致。

风险场景:

def node(state):
    if state["flag"]:
        name = interrupt("name?")  # idx 0 只在 flag=True 时
        age = interrupt("age?")    # idx 1 只在 flag=True 时
    email = interrupt("email?")    # idx 0 if flag=False, idx 2 if flag=True

如果第一次执行 flag=True、收集了 [name, age, email]——重新执行时、flag 若变成 False(比如它取决于外部数据或当前时间,而节点每次恢复都从头重跑)——"email?" 的 idx 变成 0、但 resume[0] 是 "name"——错误匹配!

LangGraph 对此的默认处理——依赖用户写 "幂等节点"——同样的 input 产生同样的 interrupt 序列。如果用户违反、行为未定义。

按 ID 恢复并不能绕开这个陷阱——§18.5.3 说过,中断 ID 是任务级的:Command(resume={id: value}) 解决的是"多个任务同时挂起时,恢复值给谁",命中的值被追加到该任务的 resume 列表末尾(_scratchpad(),pregel/_algo.py:1110-1112),同一节点内仍按调用顺序匹配(pin 版实跑:同一节点两次 interrupt() 的 ID 相同)。要避开索引陷阱,得让节点里的 interrupt() 调用序列本身确定——不依赖会变的外部条件,或者把多次询问拆到不同节点。

这种 "接口简单但有隐式约束" 的 trade-off 是 LangGraph API 的常见模式——文档里要清楚这些约定、否则用户会踩坑。

18.6 可迁移的设计模式

18.6.1 模式一:Channel 作为通信抽象

graph TB
    subgraph Channel 模式
        P1[生产者 A] -->|write| CH[Channel]
        P2[生产者 B] -->|write| CH
        CH -->|read| C1[消费者 X]
        CH -->|read| C2[消费者 Y]
    end

核心思想:用有类型的中间容器解耦生产者和消费者。Channel 不仅是数据管道,更定义了聚合语义(覆盖、追加、合并)。

可迁移场景:

  • 微服务之间的事件通道
  • UI 组件之间的状态共享
  • 数据流引擎的算子连接

18.6.2 模式二:不可变状态 + 版本追踪

graph LR
    V1["Version 1<br/>State A"] --> Apply["apply_writes"]
    Apply --> V2["Version 2<br/>State B"]
    V2 --> Apply2["apply_writes"]
    Apply2 --> V3["Version 3<br/>State C"]
    V1 -.->|"可回溯"| Fork["分叉执行"]

核心思想:在检查点层面,状态不是"修改"的,而是"产生新版本"的。每次状态变更都产生一个新的、带版本号的快照(运行中的 Channel 对象本身是原地更新的,不可变的是存下来的检查点历史)。这使得时间旅行和分支执行成为可能。

可迁移场景:

  • 文档编辑器的撤销/重做
  • 配置管理的版本化
  • 数据库的 MVCC(多版本并发控制)

18.6.3 模式三:声明式图 + 编译优化

flowchart LR
    subgraph 声明阶段
        Declare["add_node / add_edge<br/>构建抽象图"]
    end
    subgraph 编译阶段
        Compile["compile()<br/>验证、优化、实体化"]
    end
    subgraph 执行阶段
        Execute["invoke / stream<br/>运行编译后的图"]
    end
    Declare --> Compile --> Execute

核心思想:将图的"定义"和"执行"分为两个阶段。编译阶段可以做验证(边是否连通)、优化(trigger_to_nodes 映射表)和转换(Channel 初始化),而不影响运行时性能。

可迁移场景:

  • SQL 查询的解析 -> 优化 -> 执行
  • 正则表达式的编译 -> 匹配
  • 深度学习模型的定义 -> 编译 -> 推理

18.6.4 replace() 函数与 frozen dataclass 的配合

Python 的 dataclasses.replace() 是 frozen 模式的关键搭档:

from dataclasses import dataclass, replace
from typing import Any

@dataclass(frozen=True)
class Runtime:  # 示意用的精简版,真实定义见 §18.6.7
    context: Any
    store: Any = None
    previous: Any = None

runtime1 = Runtime(context="alice")
runtime2 = replace(runtime1, context="bob")  # 返回新实例、runtime1 不变

replace 不是魔法——它内部调用 dataclass 的 __init__ + 原对象字段值做默认、用户传的 kwargs 覆盖。对 frozen 类、这是"改字段"的惯用方式(绕过 frozen 只能靠 object.__setattr__ 这类手段)。

dataclass(frozen=True) + replace 相比 pydantic.BaseModel.model_copy 有什么好处?

  • dataclass 更轻量——Pydantic 模型有 validation 和 schema 生成的 overhead、不需要这些时 dataclass 更快
  • dataclass(slots=True) 内存紧凑——Runtime 被频繁创建(准备每个任务时都 override 出一个新实例,pregel/_algo.py:669 等处)、slots 省掉了每个实例的 __dict__
  • 类型签名简洁——dataclass 的 __init__ 签名就是 field 列表、IDE 补全好

Pydantic 适合什么场景?——state schema、用户输入验证——需要 runtime validation 时用 Pydantic、纯数据容器时用 dataclass。LangGraph 根据用途精确选型——state 用 TypedDict/Pydantic、Runtime/ExecutionInfo 等内部类用 frozen dataclass。

"按数据用途选类型系统" 在 Python 里尤其值得注意——Python 同时有 dict / TypedDict / dataclass / BaseModel 等多种选择,选错要么白付校验开销、要么丢掉需要的校验。

18.6.5 声明式图的编译期优化是什么

§18.6.3 的 声明式图 + 编译优化 是 LangGraph 最被低估的模式。拆开看编译期做了什么:

  1. 节点 triggers 推导——add_edge(A, B) 编译成 "A 写 branch:to:B、B 订阅 branch:to:B"(graph/state.py:1339 attach_edge)——不需要用户显式声明
  2. channel 聚合策略选择——State schema 里的 Annotated[list, operator.add] 解析成 BinaryOperatorAggregate channel(严格说这一步发生在 StateGraph 登记 schema 时,_add_schema → _get_channels,早于 compile())
  3. 汇合语义——多个独立的 add_edge(X, node) 都写同一个 branch:to:node,任一上游写入就触发 node;add_edge([A, B], node) 则编译成一个 NamedBarrierValue 汇合 channel,等 A、B 都写过才触发
  4. 终止条件——add_edge(node, END) 在编译时不生成任何写入(attach_edge 里 end != END 才挂 writer);图在某一步没有任务被触发时自然结束,而不是靠某个"停止"标记
  5. 子图识别——add_node("name", compiled_subgraph) 传进来的 Pregel 实例会被记进 PregelNode.subgraphs,供 get_subgraphs()(get_state(subgraphs=True) 等内省接口)使用(并不会被拍平,子图仍跑自己的 Pregel 循环,见第 11 章)
  6. validation——validate() 检查入口点存在、所有边的源与目标都是已注册节点、中断节点存在(graph/state.py:989-1035);不做可达性分析或环检测——环本来就是 LangGraph 的一等公民

这些工作都在 compile() 时完成一次——运行时直接查预计算结果、不做图分析。对同一个 graph 的多次 invoke、编译成本摊到很多次调用上。

"声明式描述 + 编译期优化" 是跨领域的通用模式:

  • SQL——声明 SELECT、编译器产 execution plan
  • React JSX——声明 UI、编译器产 render function
  • React Compiler——独立于 React 运行时发布的编译器插件,把 memoization 挪到编译期
  • Rust 的泛型——声明 trait 约束、编译器 monomorphize 成具体函数

LangGraph 把这个模式应用到 agent workflow——用户声明图结构、编译期完成结构推导与校验——让 Agent 从"手写状态机"变成"声明式描述"——就像 React 让 UI 从 imperative DOM 变成 declarative JSX。

18.6.6 模式四:冻结数据类 + override 方法

@dataclass(frozen=True, slots=True)
class Runtime:
    context: ContextT
    store: BaseStore | None

    def override(self, **kwargs) -> Runtime:
        return replace(self, **kwargs)

核心思想:用不可变对象保证并发安全和引用透明,通过 replace 创建修改后的副本而非原地修改。

可迁移场景:

  • 配置对象的层层覆盖
  • 中间件的上下文传递
  • 函数式编程中的状态管理

18.6.7 override() 方法在 frozen 类型上的必要性

Runtime 是 frozen dataclass、不能直接改字段——需要 override() 返回新实例:

# libs/langgraph/langgraph/runtime.py:89-90(节选;override 在 :204-208)
@dataclass(**_DC_KWARGS)   # kw_only=True, slots=True, frozen=True
class Runtime(Generic[ContextT]):
    context: ContextT = field(default=None)
    store: BaseStore | None = field(default=None)
    stream_writer: StreamWriter = field(default=_no_op_stream_writer)
    # ... previous / execution_info / server_info

    def override(self, **overrides) -> Runtime[ContextT]:  # 原文参数带 Unpack[_RuntimeOverrides] 类型
        return replace(self, **overrides)

frozen + override 是 Python 版的 "persistent data structure"——每次修改产生新实例、老实例不变。

对比 mutable 版本:

# 不推荐
runtime.context = new_context  # 直接修改

mutable 的问题:

  • 线程不安全——一个 task 改 runtime、另一个 task 读、race condition
  • 丢失历史——无法回溯 "上一个版本的 runtime"
  • 意外的共享状态——两个 subgraph 用同一个 runtime 对象、一个改了另一个也跟着变(Python 引用语义)

frozen + override 完美避免这些坑——每个子作用域拿到自己的 runtime 副本、修改不影响别处。

代价——每次 override 都创建新对象。准备每个任务时都会 override 一次,但与节点本身的执行相比,创建一个带 slots 的小对象开销很小。

这种 "用 frozen 对象强制 functional 风格" 的模式和 Rust 的 immutable-by-default、React 的 setState 产生新状态、LangGraph 的 Checkpoint 不可变都是同一种思想——immutability 是正确性的基础。Python 原生是 mutable、但关键系统类型用 frozen 限制——精确平衡。

18.6.8 模式五:batch 优先的接口设计

class BaseStore(ABC):
    @abstractmethod
    def batch(self, ops: Iterable[Op]) -> list[Result]:
        """所有操作通过 batch 执行"""

    def get(self, namespace, key) -> Item | None:
        """便捷方法,委托给 batch"""
        return self.batch([GetOp(namespace, key)])[0]

核心思想:核心接口设计为批量操作,单个操作是特殊情况。这使得优化(如批量网络请求)成为默认行为,而非事后优化。

可迁移场景:

  • 数据库驱动的批量查询
  • API 客户端的请求合并
  • 消息队列的批量发布

18.6.9 Topic channel 和 LastValue channel 的聚合语义对比

第 4 章 Channel 讲过几种类型。这里拉出来对比聚合语义的哲学差异:

LastValue——跨超步保留最后写入的那个值,但一个超步内最多只允许一次写入:

channel.update([1])        # 超步 N:内部值变成 1
channel.update([2])        # 超步 N+1:内部值变成 2
channel.update([1, 2, 3])  # 同一超步收到 3 个值 -> raise InvalidUpdateError
#   "At key '...': Can receive only one value per step.
#    Use an Annotated key to handle multiple values."(last_value.py:55-66)

用例——状态字段(user_id、current_step)——最新值就是当前状态;并行节点想同时写同一个键,就必须换成带 reducer 的 channel。

Topic——一个超步内的多次写入按顺序全保留,跨超步是否累积由 accumulate 开关决定(Topic.__init__ 的默认值是 accumulate=False):

# accumulate=False(默认):每步开头清空
channel.update([1, 2, 3])  # 内部值变成 [1, 2, 3]
channel.update([4])        # 内部值变成 [4]

# accumulate=True:跨步累积
channel.update([1, 2, 3])  # [1, 2, 3]
channel.update([4])        # [1, 2, 3, 4]

update 还会 _flatten 一层——写进去的 list 会被摊平成多个元素。用例——一对多的扇出/扇入(多个节点往同一个 topic 投递、下游一次收齐);默认不累积正是为了"这一步的投递只属于这一步"。顺带澄清一个常见误解:messages 字段不是 Topic——Annotated[list, add_messages] 编译出来的是 BinaryOperatorAggregate(list, add_messages)。

BinaryOperatorAggregate——用户自定义二元运算累积:

channel = BinaryOperatorAggregate(int, operator.add)
channel.update([1, 2, 3])  # 内部值变成 6(0+1+2+3)

用例——计数器、聚合统计——任意二元运算。

EphemeralValue——本超步可见、下超步清零:

用例——本轮交互的临时信号——下一步就被清空,不会在后续 checkpoint 里一直留着(EphemeralValue.checkpoint() 照常返回当前值,所以写入它的那一步的 checkpoint 里仍有这个值)。

四种聚合语义 对应四种不同的 agent 状态模式。LangGraph 不是逼用户用一种、而是让用户选最合适的。State schema 里不加注解的字段是 LastValue,Annotated[T, reducer] 编译成 BinaryOperatorAggregate,也可以在 Annotated 里直接放 Channel 实例或类(如 Annotated[list, Topic(str)])指定其他 channel(graph/state.py:1664 _is_field_channel)。

这种 "用类型系统表达语义差异" 的思路在 Rust (trait bounds 表达 capability) 和 TypeScript (discriminated union) 里反复出现——LangGraph 把它应用到 agent state。

18.6.10 batch 优先接口——abatch + asingle 的关系

AsyncBatchedBaseStore(libs/checkpoint/langgraph/store/base/batch.py:58,第 15 章 §15.9.7)把这一点推得更远:

# 简化示意,省略 refresh_ttl、取消处理与去重
class AsyncBatchedBaseStore(BaseStore):
    async def aget(self, namespace, key):
        # 单次查询不直接执行,而是连同一个 future 放进队列
        fut = self._loop.create_future()
        self._aqueue.put_nowait((fut, GetOp(namespace, key)))
        return await fut

# 后台任务 _run(batch.py:326):取出一个 op 后,把同一 tick 里已排队的 op 一并取走,
# 调一次 abatch(ops),再把结果逐个 set_result 给各自的 future

重要的设计选择——abatch 是 primary method、其他是 convenience wrappers。

为什么反直觉?——常见设计是 "single 是 primary、batch 是 convenience"——但 LangGraph 反过来:

  • Store 后端(Postgres / SQLite)天然支持 batch——一次 SQL WHERE id IN (...) 省掉 N-1 次往返,行数越多、网络延迟越高,优势越明显
  • HTTP/gRPC 客户端的 round-trip 成本决定了 batch 优势显著
  • 并发多个 get 自然合并成 batch——不是 asyncio 自带的能力,而是 AsyncBatchedBaseStore 的队列 + 后台任务把同一 tick 内的独立调用聚成一次 abatch

所以 "让 batch 是一等公民" 是对真实 performance profile 的响应。单次操作反而是"特殊化"——用 batch([single_op]) 包一下即可。

这种"batch-first"设计在分布式系统里是主流——Kafka 的 producer 默认 batch、MongoDB driver 的 bulkWrite——都是因为网络/磁盘成本驱动的。LangGraph Store 接口与这套成熟模式一脉相承。

LangChain 的 Runnable 恰好相反——invoke 是抽象方法,batch 的默认实现是用线程池并行调用 invoke(langchain_core/runnables/base.py:867)。两种取舍各有道理:Runnable 的单次调用是主流用法,而 Store 后端的批量能力是实打实的往返节省。

18.7 构建你自己的工作流引擎

18.7.1 最小可行架构

如果你要从零构建一个工作流引擎,LangGraph 给出了一个清晰的参考架构:

graph TB
    subgraph "1. 图定义层"
        Node[节点定义]
        Edge[边定义]
        Schema[状态 Schema]
    end
    subgraph "2. 编译层"
        Validate[图验证]
        Optimize[优化结构]
        Init[初始化 Channel]
    end
    subgraph "3. 调度层"
        Trigger[触发判定]
        TaskPrep[任务准备]
        Execute[并行执行]
        Apply[应用写入]
    end
    subgraph "4. 持久化层"
        Snapshot[状态快照]
        WAL[任务写入 pending writes]
        Restore[恢复重放]
    end
    subgraph "5. 交互层"
        Stream[流式输出]
        Interrupt[中断恢复]
        TimeTravel[时间旅行]
    end
    Node --> Validate
    Edge --> Validate
    Schema --> Init
    Validate --> Trigger
    Init --> Trigger
    Trigger --> TaskPrep
    TaskPrep --> Execute
    Execute --> Apply
    Apply --> Snapshot
    Apply --> Trigger
    Snapshot --> Restore
    WAL --> Restore
    Execute --> Stream
    Execute --> Interrupt
    Snapshot --> TimeTravel

18.7.2 核心设计原则

从 LangGraph 的源码中,我们可以提炼出以下设计原则:

  1. 超步边界是一切的基础

    • 在超步边界做快照:确保一致性
    • 在超步边界做触发判定:避免竞态
    • 在超步边界输出 values;updates / messages / custom 则在任务完成或运行中即时输出
  2. Channel 是唯一的通信路径

    • 节点之间不直接通信
    • 所有数据通过 Channel 流转
    • Channel 定义了聚合语义
  3. 写入延迟一步可见

    • 本步的写入在下一步才生效
    • 避免读到不稳定的中间状态
    • 简化并发模型
  4. 确定性优先

    • 相同输入 + 相同状态 = 相同的任务调度与写入合并顺序
    • 任务 ID 基于确定性哈希
    • 中断恢复通过索引匹配
  5. 分层抽象

    • 底层:Channel、Pregel、Checkpoint
    • 中层:StateGraph、Runtime、Store
    • 上层:create_react_agent(1.0 起已弃用,迁往 langchain.agents.create_agent,见第 16 章 §16.1.1)、ToolNode

18.7.3 你可能不需要的部分

并非 LangGraph 的所有设计都适合每个场景。以下是可以简化的部分:

  • 如果不需要 LLM 集成:可以省去 StreamMessagesHandler 和 ToolNode
  • 如果不需要动态并行:可以省去 Send 和 Topic Channel
  • 如果不需要人机交互:可以省去中断/恢复机制
  • 如果图是静态的:可以简化编译层,直接构建调度结构

18.7.4 最小 MVP vs 生产版本的规模差异

§18.7 的 最小架构 用几十到一百来行代码就能写出来——但 LangGraph 的生产实现大得多:pin 版本下 libs/langgraph、libs/checkpoint、libs/prebuilt 及两个官方后端的包内 Python 源码合计约 3.6 万行(wc -l 统计,含注释与 docstring,不含 tests)。多出来的代码去哪了?大致可以归为这几类:

  • Channel 类型(LastValue / Topic / BinaryOperatorAggregate / EphemeralValue / NamedBarrierValue 等)
  • Checkpointer / Store 后端(本仓库里的 libs/checkpoint、libs/checkpoint-postgres、libs/checkpoint-sqlite + 各自的 migrations;Redis 等后端由社区仓库提供)
  • Streaming(StreamMessagesHandler / StreamProtocol / DuplexStream、v1/v2 两套输出格式)
  • Runtime / Store 相关抽象
  • Prebuilt agents / tools 整合
  • 类型推导和 validation(StateGraph 的 schema 反射、type checks)
  • Debugging / observability(debug / tasks / checkpoints 等流式模式的 payload)
  • 异常处理和边界情况(GraphBubbleUp / ParentCommand / GraphRecursionError 的分层传播、并发写冲突检测、未知 channel/节点名的降级告警)
  • 向后兼容层(旧 checkpoint 格式的迁移如 _migrate_checkpoint、LangGraphDeprecatedSinceV10 这类弃用告警)

每一块都服务于一个真实需求——砍掉任意一块都会让某个场景不能用。MVP 有创造性、生产版本是耐力——这是所有成熟开源项目的真相。

对普通开发者的意义——不要试图从零重写一个 LangGraph——除非你愿意投入几年时间补齐这些细节。用 LangGraph 并在必要处扩展是更合理的选择。

18.7.5 最小 MVP 的 apply_writes 伪代码——Pregel 的 20 行核心

用 §18.7 的最小架构思路、apply_writes 可以写成 ~20 行 Python:

def apply_writes(checkpoint, channels, pending_writes, get_next_version=increment):
    # 1. 按 channel 分组
    by_channel = defaultdict(list)
    for task_id, chan, val in pending_writes:
        by_channel[chan].append(val)

    # 2. 计算下一个 version
    current_max = max(checkpoint["channel_versions"].values(), default=0)
    next_version = get_next_version(current_max, None)

    # 3. 应用到 channel
    updated_channels = set()
    for chan, vals in by_channel.items():
        if channels[chan].update(vals):  # channel 决定要不要吸收
            checkpoint["channel_versions"][chan] = next_version
            updated_channels.add(chan)

    # 4. 更新 checkpoint 的 channel_values
    for chan in updated_channels:
        checkpoint["channel_values"][chan] = channels[chan].get()

    return updated_channels

这 20 行覆盖了 Pregel 超步的核心语义:

  • 写入按 channel 聚合
  • 同超步共享同一 version
  • channel 决定是否吸收(幂等写可以跳过)
  • 更新 checkpoint 供下一步读取

真实 LangGraph 的 apply_writes(libs/langgraph/langgraph/pregel/_algo.py:218-323)约 105 行(含 docstring)——多出来的部分处理:

  • 先按任务 path 的前三段排序,保证写入的合并顺序确定;再把各任务的 triggers 版本记进 versions_seen
  • consume() 本步被读过的 trigger channel(RESERVED 里的除外)
  • 过滤掉 NO_WRITES/PUSH/RESUME/INTERRUPT/RETURN/ERROR 这些控制用伪 channel,写到未知 channel 时只 log warning 不报错
  • bump_step 时给"本步没被更新"的 channel 发一次空更新(update(EMPTY_SEQ)),让 EphemeralValue 这类按步失效的 channel 有机会翻页
  • 当本步更新的 channel 和 trigger_to_nodes 无交集(即这多半是最后一个超步)时,再调一轮 channel.finish()
  • 每一处写入后都要判 channels[chan].is_available()——不可用的 channel 不进 updated_channels,因为它触发不了任何任务

20 行 vs 105 行——核心思想集中在 20 行、工程细节占其余 85 行——这就是教学代码和生产代码的差异。本专栏目的是让你看清那 20 行的思想、以便用它做自己的设计决策——其余的工程细节留给生产库。

18.8 LangGraph 的演进方向

18.8.1 从 v0 到 v1 的关键变化

回顾 LangGraph 的演进,几个关键决策塑造了当前的架构:

timeline
    title LangGraph 架构演进
    section 0.2.x 及以前
        config_schema : 运行时依赖通过 config 传递
        stream v1 : 流式输出返回裸值或元组
        tool_node v1 : 工具在单节点内并行
        tool_node v2 : Send API 分发工具调用(0.2.70)
    section 0.6.0
        Runtime/Context : context_schema 替代 config_schema
    section 1.0.2
        Overwrite : 绕过 reducer 直接写入
        ToolRuntime : 工具级统一依赖注入(prebuilt 1.0.2)
    section 1.1.x
        stream v2 : StreamPart 类型体系(1.1.0)
        GraphOutput : invoke 的类型安全返回(1.1.0)
        ExecutionInfo : 结构化执行元数据(1.1.3)
        ServerInfo : 服务端元数据注入(1.1.5)

18.8.2 设计的稳定核心

尽管 API 层面不断演进,LangGraph 的核心架构自诞生以来保持稳定:

  • Pregel 超步模型 从未改变
  • Channel 类型体系 以增补为主(也有精简:0.5.0 移除了 DynamicBarrierValue 与 Context channel)
  • Checkpoint 格式 向后兼容
  • StateGraph 编译流程 本质不变

这种"稳定核心 + 演进外壳"的策略值得任何框架设计者学习。

18.8.3 三处代表性演进——Runtime、v2 Stream、RemoveMessage

下面三项常被笼统地说成"1.0 的变化",实际引入版本各不相同(本节的版本号与 PR 号都在本仓库的 git 历史里核过):

① Runtime 对象统一依赖注入(0.6.0 引入,PR #5243;第 14 章深入)——之前是分散的 config / store / writer、现在统一到 Runtime 对象。好处:

  • 类型安全——Runtime[UserContext] 让 context 字段类型可知
  • 一致的注入机制——所有依赖走同一个入口
  • 向前兼容——未来要加新的运行时依赖、扩展 Runtime 即可、不用改 StateGraph 接口

② v2 Stream API(1.1.0 引入,PR #6961,不是 1.0;第 13 章深入)——之前多 mode 返回元组 (mode, data)、现在统一 StreamPart TypedDict with type tag。注意 version="v1" 仍是默认值,v2 要显式开。好处:

  • 类型窄化——part["type"] == "messages" 后、TypedDict 精确推导 data 字段
  • namespace 内联——每个 part 带 ns 字段、subgraph 溯源
  • interrupts 内联——ValuesStreamPart 直接包含 interrupts 字段

③ REMOVE_ALL_MESSAGES 的全量清空语义(0.3.23 引入,PR #4117;RemoveMessage 本身来自 langchain-core,更早就有)——按 id 删单条之外,现在还能用 RemoveMessage(id=REMOVE_ALL_MESSAGES) 一次清空历史(add_messages 里的处理见第 3 章 §3.9.2)。好处:

  • 语义明确——一条 RemoveMessage 表达"清空",不必逐条列出消息 id
  • 位置语义清楚——同一次更新里排在它之后的消息保留、之前的全部丢弃(graph/message.py:209-213)
  • checkpoint 友好——清空本身也是一次普通写入,照样进入 pending writes 与检查点历史、可追溯

三处演进的共同主题——从 "靠约定" 变成 "类型化"——原先靠约定维持的接口,被写成了明确的类型契约。

18.8.4 LangGraph 对未来的三个猜测方向

基于当前架构、LangGraph 可能的未来演进:

① 更原生的 streaming 协议——可能基于 SSE + TypedEvents 的标准化 wire format、让 LangGraph stream 能被更多 observability 工具直接消费。

② 分布式 Pregel——当前 Pregel loop 是单进程、多 agent 协作需要跨机器——未来可能有 DistributedPregel 跑在多个 worker 上、Send 跨网络。

③ 更智能的 checkpointer——基于访问模式自动选 backend——热 thread 在 Redis、冷 thread 在 Postgres——减少用户手动选 checkpointer 的负担。

这些猜测基于当前架构的演进方向感,属于预测而非官方 roadmap——看代码里哪些抽象有 extension point、哪些待办注释——能推断发展方向。

读到此——你应该具备 "看架构判断未来" 的能力——下次评估任何库、看它的 extension points、维护者待办、deprecation warnings——就能大致判断它未来几年往哪走。这是技术选型最重要的技能之一——不是选"现在好"的库、是选"未来几年会更好"的库。

18.9 总结与回顾

18.9.1 全专栏架构回顾

graph TB
    subgraph "第1-3章:基础概念"
        C1[为什么需要 LangGraph]
        C2[整体架构]
        C3[StateGraph]
    end
    subgraph "第4-7章:核心引擎"
        C4[Channel 类型体系]
        C5[编译流程]
        C6[Pregel 执行]
        C7[任务调度]
    end
    subgraph "第8-11章:高级机制"
        C8[Checkpoint]
        C9[中断恢复]
        C10[Command]
        C11[子图]
    end
    subgraph "第12-15章:运行时能力"
        C12[Send 动态并行]
        C13[流式输出]
        C14[Runtime Context]
        C15[Store 长期记忆]
    end
    subgraph "第16-18章:应用与设计"
        C16[预构建组件]
        C17[多 Agent 模式]
        C18[设计模式]
    end
    C1 --> C4
    C4 --> C8
    C8 --> C12
    C12 --> C16

18.9.2 关键源码文件索引

源码文件 核心内容 涉及章节
libs/langgraph/langgraph/types.py Send, Command, StreamMode, StreamPart, Interrupt 10, 12, 13
libs/langgraph/langgraph/channels/*.py Channel 类型体系 4
libs/langgraph/langgraph/graph/state.py StateGraph 编译 3, 5
libs/langgraph/langgraph/pregel/main.py Pregel 类 6
libs/langgraph/langgraph/pregel/_algo.py prepare_next_tasks, apply_writes 7, 12
libs/langgraph/langgraph/pregel/_loop.py PregelLoop 超步循环 6, 7
libs/langgraph/langgraph/pregel/_runner.py PregelRunner 并行执行 7
libs/langgraph/langgraph/pregel/_io.py 输入输出映射 12, 13
libs/langgraph/langgraph/pregel/_messages.py StreamMessagesHandler 13
libs/langgraph/langgraph/pregel/protocol.py StreamProtocol 13
libs/langgraph/langgraph/runtime.py Runtime, ExecutionInfo, ServerInfo 14
libs/checkpoint/langgraph/store/base/__init__.py BaseStore, Item, Ops 15
libs/checkpoint/langgraph/store/memory/__init__.py InMemoryStore 15
libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py create_react_agent(已弃用) 16
libs/prebuilt/langgraph/prebuilt/tool_node.py ToolNode, tools_condition, ToolRuntime 16
libs/checkpoint/langgraph/checkpoint/base/__init__.py Checkpoint / CheckpointMetadata / BaseCheckpointSaver 8

18.9.3 架构层次回顾

LangGraph 的分层结构:底层是 Pregel/BSP 计算模型提供确定性超步边界;中间层通过 Channel、Runtime、Store 等抽象提供灵活的状态管理与依赖注入;上层通过 ToolNode、create_react_agent(已弃用,由 langchain.agents.create_agent 接替)等预构建组件降低使用门槛。每一层都可以被独立理解和替换,层与层之间通过明确的接口连接。

这些可迁移的设计模式——超步边界的确定性保证、Channel 的解耦通信、版本号的增量计算、检查点 + pending writes 的持久化、冻结对象的并发安全、batch 优先的接口设计——它们不依赖 LangGraph 的具体版本,根植于计算机科学原理之中。

18.10 跨章节呼应

  • 第 6 章(Pregel 执行)——本章回过头讨论 "为什么选 Pregel"、深化第 6 章讲的计算模型选择。
  • 第 4 章(Channel)——版本号追踪机制在第 4 章讲 Channel 时就埋下伏笔、本章明确化为可迁移模式。
  • 第 8 章(Checkpoint)——检查点快照与 pending writes 的配合在第 8 章详细讲过实现、本章提炼为数据持久化的通用模式,并指出它与数据库 WAL 的异同。
  • 第 9 章(Interrupt)——中断/恢复模式在第 9 章源码级讲过、本章提炼为协程式人机交互协议。
  • 第 14 章(Runtime)——Runtime 的 frozen + override 模式在第 14 章分析过、本章提炼为不可变对象管理可变世界的通用策略。

18.11 17 条跨越本专栏的工程原则

最后提炼整个专栏 17 章 + 本章、形成 LangGraph 的核心设计原则清单:

① Pregel 超步提供确定性边界(§18.2) ② Channel 解耦生产者消费者(§18.6.1) ③ 版本号实现增量计算(§18.3) ④ 检查点快照 + pending writes(§18.4.1) ⑤ frozen 对象 + override 方法保不可变(§18.6.6) ⑥ batch 优先接口(§18.6.8) ⑦ 声明式图 + 编译优化(§18.6.3) ⑧ 异常作控制流(GraphInterrupt)(§18.5) ⑨ scratchpad 索引确定性重放(§18.5.2) ⑩ null_version 哨兵表达 unknown(§18.3.5) ⑪ 特例收编进统一抽象(Send 从 pending_sends 字段并入 TASKS channel)(§18.4.4) ⑫ 三 ID 系统(thread/checkpoint/parent)(§18.4.5) ⑬ 默认简单 + 可插拔高级(§18.3.6 increment / 第 8 章 checkpointer 的 get_next_version) ⑭ 渐进复杂度(progressive disclosure)(第 13 章 streaming level 1-5) ⑮ 类型化错误(GraphBubbleUp 及其子类 GraphInterrupt / ParentCommand) ⑯ 命名空间追踪(流式事件的 ns 元组、ExecutionInfo.checkpoint_ns)(第 11、13、14 章) ⑰ 跨接口的统一 ns/id 体系——让 subgraph、interrupt、checkpoint 都能 trace back

这 17 条是 LangGraph 作为工业级 AI 框架的技术 DNA——其中任何一条都能成为独立的设计课题、合起来构成一个 系统工程的教学范本。

18.12 17 条原则在其他系统里的映射

这 17 条原则里、大部分都能在其他系统找到对应物——说明它们是普适的工程智慧:

LangGraph 原则 其他系统的对应
Pregel 超步 MapReduce stages、Spark RDD、GraphX
Channel 解耦 Kafka topics、Go channels、React Context
版本号增量 Git commits、MVCC、React useMemo 依赖
检查点 + pending writes PostgreSQL 基础备份 + WAL、Redis RDB + AOF(只是形似,见 §18.4.1)
frozen 对象 Immutable.js、Rust 默认不可变绑定、Clojure persistent collections
batch 优先接口 Kafka producer、MongoDB bulkWrite、GraphQL batching
声明式图 + 编译 SQL → execution plan、React JSX → render function
异常作控制流 Python StopIteration(迭代结束靠异常通知)
索引确定性重放 event sourcing、blockchain transactions
null 哨兵 Git 0000...、SQL NULL、Rust Option
特例收编进统一抽象 Unix "一切皆文件"
三 ID 系统 Git(仓库 / commit / parent)、Kubernetes (namespace/resource/uid)
默认简单 + 高级可插拔 Linux sysctl、PostgreSQL GUC、JVM -XX flags
渐进复杂度 Keras 的 Sequential / Functional / 子类化三层 API
类型化错误 Rust Result<T, E>、Go error types、GraphQL union errors
命名空间追踪 OpenTelemetry trace/span/baggage、DNS 层级命名
统一 ns/id 体系 FQDN DNS、URL 路径、Python module paths

这些对应物覆盖从数据库到消息队列到前端框架到云原生平台。LangGraph 不是发明了这些原则、是系统地应用它们到 AI agent workflow 这个新领域。

18.13 进一步阅读

① LangChain Core — LangGraph 构建在 LangChain Core 之上、Runnable 协议 / BaseMessage / Callback 系统都来自 Core。如果只读 LangGraph、你会对某些边界(比如 StreamMessagesHandler 的回调机制)感到困惑。

② Pregel 原论文 — Google 2010 年的 "Pregel: A System for Large-Scale Graph Processing"——LangGraph 的计算模型源头。读完会发现 LangGraph 对 Pregel 做了哪些取舍。

③ Ray Workflows / Prefect / Temporal——三个成熟的分布式 workflow 引擎。对比 LangGraph 能看到 "专攻 AI agent workflow" vs "通用 workflow" 的架构差异。

④ 本系列的其他专栏——《LangChain 设计与实现》讲 LangGraph 底下那层的 Runnable / 消息 / 回调,《Hyper 与 Tower:工业级 HTTP 栈》讲 Rust 异步服务,《Vite 设计与实现》讲前端构建,《Serde 元编程》讲类型化序列化,《React 19 内核探秘》讲声明式 UI 的调度与状态。

⑤ 自己写一个 mini 工作流引擎——§18.7 讲过"你可能不需要所有部分"——挑 20% 核心自己实现一遍、是最深的学习方式。

18.14 关于 "为什么 LangGraph 而不是别的" 的最终答复

全专栏写到这里、对一个核心问题给出最终答复——为什么要选 LangGraph、而不是 OpenAI Swarm、AutoGen、LlamaIndex Agents 等?

LangGraph 的独特价值:

① 状态显式化——Channel + TypedDict state 让状态成为一等公民、可序列化、可 checkpoint、可审计。

② Pregel 执行模型——确定性的超步调度、按任务的重试、从任意检查点恢复(已完成的任务不重跑)。若框架没有超步边界这类结构,恢复时的行为就更难保证。

③ 生产 checkpointer——PostgresSaver / SqliteSaver 官方仓库内置(libs/checkpoint-postgres、libs/checkpoint-sqlite),Redis 等有社区实现。

④ 丰富的 streaming——7 种 stream mode 覆盖 UX / observability / audit。

⑤ 与 LangChain 生态无缝——tools / chat models / message types 都直接复用。

LangGraph 的代价:

① 学习曲线陡——State / Channel / Node / Edge / Interrupt / Command ...——概念多、刚上手会困惑。

② 灵活度低——强制图模型。想做非图的 async coordination(比如纯 pub/sub)、LangGraph 不如 asyncio 直接。

③ 语言绑定不对等——本专栏基准的 langchain-ai/langgraph 仓库是 Python 实现(同仓另有 libs/sdk-js 客户端 SDK);JS/TS 的图执行实现在独立的 langgraphjs 仓库,特性节奏与 Python 版并不同步。

④ 性能不是第一优先级——flatten/nested Python function calls、单进程——极致性能场景不如手写 Rust/Go。

结论:

  • 生产级 AI agent 应用——需要显式状态、检查点恢复和人机中断时,LangGraph 是很有力的候选
  • 快速原型 / simple chat bot——OpenAI SDK 或 LangChain 直接 + 自己的 state 管理可能更快
  • 极限性能 / 大规模并发——用 Rust + 自定义 workflow engine 更合适

工具选型没有银弹,本章希望帮你认清 LangGraph 的定位,准确地决策是否用它、以及用它的哪些部分。