LangGraph 设计与实现
第18章 设计模式与架构决策
18.1 引言
经过前面十七章的深入剖析,我们已经从源码层面理解了 LangGraph 的每一个核心组件——StateGraph 的编译流程、Channel 的类型体系、Pregel 的超步调度、Checkpoint 的持久化、Send 的动态并行、Runtime 的依赖注入、Store 的长期记忆、以及预构建的 Agent 组件。
本章将从更高的视角审视这些设计选择。我们不再逐行分析源码,而是提炼出 LangGraph 中可迁移的设计模式——那些超越 LLM 应用框架本身、在更广泛的软件工程领域中具有通用价值的架构思想。同时,我们也将诚实地评估每个关键决策的权衡,帮助读者在设计自己的系统时做出更明智的选择。
本章要点
- Pregel 计算模型的选择——为什么图 + 消息传递胜过其他范式
- Channel 版本追踪——通过版本号实现精确的变更检测
- Checkpoint 时间旅行——检查点快照 + pending writes 的配合
- 中断/恢复模式——从 GraphInterrupt 异常到确定性重放
- 构建你自己的工作流引擎——从 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 模型的核心优势:
-
超步边界提供确定性:每个超步内,被触发的节点读取同一份步初状态、并发执行,写入在超步结束时按任务 path 排序后统一应用。这消除了步内的读写竞态:哪些任务被创建、写入按什么顺序合并,在相同输入下是确定的;至于节点本身(例如调用 LLM)的输出是否确定,则不由框架保证。
-
Channel 解耦:生产者写入 Channel,消费者在下一个超步读取。这种间接通信让节点不需要知道谁在监听,也不需要等待消费者就绪。
-
快照友好:超步边界是天然的 Checkpoint 点——所有 Channel 值稳定,没有"进行中"的状态。
-
简单的编程模型:开发者只需要定义"给定当前状态,节点输出什么",不需要管理并发、同步或消息队列。
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 做了几个关键适配:
- Channel 替代顶点消息:原始 Pregel 每个顶点接收邻居消息,LangGraph 使用 Channel 提供更丰富的聚合语义(LastValue、BinaryOperatorAggregate、Topic)
- 有限步数:LangGraph 通过
recursion_limit保证终止,而非依赖算法收敛 - 可中断:原始 Pregel 设计为批处理,LangGraph 支持人机交互的中断/恢复
- 异构节点:原始 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 usestr/int/floatversions, 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 只覆盖当前这一个超步,默认持久化模式下每一步都会产生新的检查点,恢复某个检查点并不需要从头重放写入日志。它真正的用途是:
- 超步内的故障恢复:一个超步里部分任务已完成、另一些出错或被中断时,恢复后已完成任务的写入直接复用、不再重跑,只有未完成的任务(包括被中断的节点)从头执行(第 8 章 §8.8.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_stateAPI 手动创建"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、典型是 UUIDparent_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 的中断/恢复模式本质上是一个协程式的人机交互协议:
- 执行流遇到需要人工输入的点,发起中断
- 框架保存当前状态(Checkpoint)
- 人工提供输入
- 框架恢复执行,使用人工输入继续
这个模式适用于任何需要"暂停-等待-恢复"语义的长时间运行的工作流:审批流程、多步表单、交互式数据标注等。
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 最被低估的模式。拆开看编译期做了什么:
- 节点 triggers 推导——
add_edge(A, B)编译成 "A 写branch:to:B、B 订阅branch:to:B"(graph/state.py:1339attach_edge)——不需要用户显式声明 - channel 聚合策略选择——State schema 里的
Annotated[list, operator.add]解析成BinaryOperatorAggregatechannel(严格说这一步发生在StateGraph登记 schema 时,_add_schema→_get_channels,早于compile()) - 汇合语义——多个独立的
add_edge(X, node)都写同一个branch:to:node,任一上游写入就触发 node;add_edge([A, B], node)则编译成一个NamedBarrierValue汇合 channel,等 A、B 都写过才触发 - 终止条件——
add_edge(node, END)在编译时不生成任何写入(attach_edge里end != END才挂 writer);图在某一步没有任务被触发时自然结束,而不是靠某个"停止"标记 - 子图识别——
add_node("name", compiled_subgraph)传进来的Pregel实例会被记进PregelNode.subgraphs,供get_subgraphs()(get_state(subgraphs=True)等内省接口)使用(并不会被拍平,子图仍跑自己的 Pregel 循环,见第 11 章) - 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 的源码中,我们可以提炼出以下设计原则:
-
超步边界是一切的基础
- 在超步边界做快照:确保一致性
- 在超步边界做触发判定:避免竞态
- 在超步边界输出
values;updates/messages/custom则在任务完成或运行中即时输出
-
Channel 是唯一的通信路径
- 节点之间不直接通信
- 所有数据通过 Channel 流转
- Channel 定义了聚合语义
-
写入延迟一步可见
- 本步的写入在下一步才生效
- 避免读到不稳定的中间状态
- 简化并发模型
-
确定性优先
- 相同输入 + 相同状态 = 相同的任务调度与写入合并顺序
- 任务 ID 基于确定性哈希
- 中断恢复通过索引匹配
-
分层抽象
- 底层: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与Contextchannel) - 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 的定位,准确地决策是否用它、以及用它的哪些部分。