第六章:LangGraph Channel 与 Pregel 引擎精读
本章目标:深入 LangGraph 的核心——Channel 通信系统和 Pregel 执行引擎,理解”状态如何在节点间流动”以及”图引擎如何调度节点执行”的底层机制。
6.1 为什么需要深入 Channel 和 Pregel?
日常类比:工厂流水线的”传送带”和”调度室”
想象一个巧克力工厂:
- 传送带(Channel):不同工序之间传递半成品。有的传送带只放一块巧克力(拿走旧的放新的),有的传送带像集装箱一样把所有巧克力都堆在一起运走。
- 调度室(Pregel):决定哪个工序可以开工——只有当传送带上有原料到位时,下一道工序才能启动。调度室按”批次”管理:一批原料全部处理完,再开始下一批。
在 LangGraph 中:
- Channel 是节点间传递数据的抽象管道
- Pregel 是调度所有节点按正确顺序执行的引擎
前面章节我们用的 StateGraph 是面向用户的高层 API,底层都是由 Channel + Pregel 驱动的。理解这一层,才能理解为什么 LangGraph 在复杂场景下表现如此——有时候精妙优雅,有时候让人困惑。
6.2 Channel 系统核心设计
6.2.1 BaseChannel 的三个泛型
每个 Channel 都是 BaseChannel[Value, Update, Checkpoint] 的实例:
class BaseChannel(ABC, Generic[Value, Update, C]):
"""
Value — 从 Channel 中读取时得到的类型
Update — 向 Channel 写入时接受的类型
C — 序列化到 Checkpoint 时的类型
"""
@abstractmethod
def get(self) -> Value:
"""读取当前值。如果 Channel 为空,抛出 EmptyChannelError"""
...
@abstractmethod
def update(self, values: Sequence[Update]) -> bool:
"""接收一批写入值,返回是否有变化"""
...
@abstractmethod
def checkpoint(self) -> C:
"""导出当前状态用于持久化"""
...
@abstractmethod
def from_checkpoint(self, checkpoint: C) -> Self:
"""从 checkpoint 恢复"""
...
为什么需要三个独立的泛型?因为这三个操作可能涉及不同的类型:
- Value vs Update:比如一个”投票统计”Channel,写入的是单张选票(Update=Vote),读取的是统计结果(Value=dict[str, int])
- Value vs Checkpoint:读取时可能是计算后的视图,存储时是原始数据
这个设计带来了极大的灵活性,但也增加了理解成本。
6.2.2 LastValue Channel:最简单的”覆盖型”通道
class LastValue(BaseChannel[Value, Value, Value]):
"""
只保留最后一次写入的值。
核心约束:每个超步(superstep)中,最多只能有一个节点写入。
"""
def __init__(self, typ: Type[Value]) -> None:
self.typ = typ
self.value: Value | None = None
def get(self) -> Value:
if self.value is None:
raise EmptyChannelError()
return self.value
def update(self, values: Sequence[Value]) -> bool:
if len(values) == 0:
return False
if len(values) > 1:
raise InvalidUpdateError(
"LastValue channel 在同一个超步中收到了多个写入!"
"这通常意味着多个节点同时写入了同一个字段。"
)
self.value = values[0]
return True
def checkpoint(self) -> Value:
return self.value
关键约束:如果两个并行节点同时向同一个 LastValue Channel 写入,LangGraph 会直接报错而不是静默覆盖。这是一个重要的安全机制——它强制你在设计图时就明确”谁负责写这个字段”。
日常类比:一个会议室的白板。同一时间只能有一个人在上面写。如果两个人同时拿起笔要写,系统不是让后写的覆盖先写的(那样会丢信息),而是直接喊停:”你们得协调一下谁来写!”
6.2.3 BinaryOperatorAggregate Channel:合并多个写入
class BinaryOperatorAggregate(BaseChannel[Value, Value, Value]):
"""
使用二元运算符(reducer)合并所有写入值。
允许多个节点在同一个超步中同时写入。
"""
def __init__(self, typ: Type[Value], operator: Callable[[Value, Value], Value]) -> None:
self.typ = typ
self.operator = operator # 如 operator.add
self.value: Value | None = None
def update(self, values: Sequence[Value]) -> bool:
if len(values) == 0:
return False
new_value = values[0]
for v in values[1:]:
new_value = self.operator(new_value, v)
if self.value is None:
self.value = new_value
else:
self.value = self.operator(self.value, new_value)
return True
最常见的用法是 operator.add 合并列表:
import operator
from typing import Annotated
class AgentState(TypedDict):
# messages 字段使用 add 合并 → BinaryOperatorAggregate Channel
messages: Annotated[list[BaseMessage], operator.add]
当 Node A 返回 {"messages": [msg1]} 同时 Node B 返回 {"messages": [msg2]} 时,Channel 会用 operator.add 把它们合并为 [..., msg1, msg2]。
日常类比:一个共享的购物清单。家里每个人都可以往上加东西,最终清单 = 所有人加的东西的合并。不会因为爸爸写了”牛奶”就把妈妈写的”鸡蛋”覆盖掉。
6.2.4 其他 Channel 类型
LangGraph 还提供了几种特殊 Channel:
# EphemeralValue: 每个超步后自动清除,不持久化
class EphemeralValue(BaseChannel[Value, Value, Value]):
"""读取后即清空。用于一次性信号传递。"""
pass
# Topic: 发布-订阅模式,可以有多个读者
class Topic(BaseChannel[Sequence[Value], Value, list[Value]]):
"""积累所有写入值,读取时返回全部,然后清空。"""
pass
# Context: 只读 Channel,由外部注入,节点不能写入
class Context(BaseChannel[Value, Value, Value]):
"""用于注入配置、API key 等不变信息。"""
pass
6.3 Annotated 类型标注如何决定 Channel 类型
这是连接”用户定义 State”和”底层 Channel 系统”的桥梁。
规则映射
from typing import Annotated, TypedDict
import operator
class MyState(TypedDict):
# 规则 1: 普通类型 → LastValue Channel
current_plan: str # → LastValue[str]
# 规则 2: Annotated + 函数 → BinaryOperatorAggregate Channel
messages: Annotated[list, operator.add] # → BinaryOperatorAggregate[list]
# 规则 3: Annotated + 自定义 reducer
recent: Annotated[list, lambda old, new: (old + new)[-5:]]
# ↑ 只保留最近 5 条
StateGraph 构建时的 Channel 创建
当你调用 StateGraph(MyState) 时,LangGraph 内部做了这些:
# 伪代码:StateGraph 初始化
def _create_channels(state_type: type) -> dict[str, BaseChannel]:
channels = {}
for field_name, field_type in get_type_hints(state_type).items():
if is_annotated(field_type):
# 提取 reducer 函数
base_type, reducer = extract_annotated(field_type)
channels[field_name] = BinaryOperatorAggregate(base_type, reducer)
else:
channels[field_name] = LastValue(field_type)
return channels
这个映射过程是隐式的——你只需要写类型标注,LangGraph 自动创建对应的 Channel。这很优雅,但也意味着如果你不理解 Channel 系统,就无法理解为什么某些状态行为”不符合预期”。
常见陷阱
class BadState(TypedDict):
# 陷阱 1: 忘记 Annotated,多节点并行写入会报错!
results: list # → LastValue[list],不是 BinaryOperatorAggregate!
# 正确写法:
results: Annotated[list, operator.add] # → 支持多节点并行写入
# 陷阱 2: reducer 函数的返回类型必须与字段类型匹配
class AlsoBadState(TypedDict):
count: Annotated[int, operator.add] # ← 这其实是合法的!
# int + int = int,operator.add 对 int 也适用
# 但语义是"累加"而非"覆盖"——确保这是你想要的
6.4 Pregel 引擎的 BSP 模型
6.4.1 什么是 BSP?
BSP(Bulk Synchronous Parallel,批量同步并行)是一种并行计算模型,由 Leslie Valiant 在 1990 年提出。它把计算分成一系列超步(superstep),每个超步内:
- 所有准备好的节点并行执行
- 执行完毕后,进入同步屏障(barrier)——等待所有节点完成
- 同步完成后,交换数据——各节点的输出写入 Channel
时间轴 ──────────────────────────────────────────→
超步 1 超步 2 超步 3
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Node A ████ │ │ Node C ██ │ │ Node E █████ │
│ Node B ██ │ │ Node D ████ │ │ │
├──────────────┤ ├──────────────┤ ├──────────────┤
│ ▓▓ barrier ▓▓│ │ ▓▓ barrier ▓▓│ │ ▓▓ barrier ▓▓│
│ ↕ write CH ↕│ │ ↕ write CH ↕│ │ ↕ write CH ↕│
└──────────────┘ └──────────────┘ └──────────────┘
日常类比:高考批改试卷。第一天(超步 1)语文、数学、英语三科同时批改。等三科全部改完(barrier),分数汇总(写入 Channel)。第二天(超步 2)根据汇总分数进行排名和录取。不能数学还没改完就开始排名。
6.4.2 为什么 LangGraph 选择 BSP?
有几个关键原因:
- 确定性:同一个输入,无论并行执行的时序如何,结果总是相同的。这对于 AI 应用的可复现性至关重要。
- Checkpoint 友好:超步之间是天然的持久化点——每个超步结束后保存一次状态即可。
- 推理简单:开发者不需要考虑竞争条件或锁——同一个超步内的节点互相看不到对方的写入。
- 与图模型天然匹配:图中的”层”(从入口到出口的拓扑层级)天然对应超步。
6.4.3 BSP 的代价
当然,BSP 也有代价:
- 同步开销:快节点要等慢节点。如果 Node A 1ms 完成,Node B 需要 10s,整个超步要等 10s。
- 内存开销:需要缓存所有节点的写入,直到 barrier 后才能统一应用。
- 延迟不友好:不能”一边执行一边传递”,必须等批次完成。
6.5 Pregel 的超步执行流程
6.5.1 Plan → Execute → Update 三阶段
每个超步由三个阶段组成:
┌────────────────────────────────────────────────────────────┐
│ 超步 N │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ PLAN │ → │ EXECUTE │ → │ UPDATE │ │
│ │ │ │ │ │ │ │
│ │ 哪些节点 │ │ 并行执行 │ │ 写入 Channel │ │
│ │ 该执行? │ │ 这些节点 │ │ 保存 Checkpoint │ │
│ └──────────┘ └──────────────┘ └──────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
Plan 阶段:确定哪些节点”准备好了”可以执行。判断依据是:节点订阅的 Channel 是否有新数据。
Execute 阶段:并行执行所有准备好的节点。每个节点读取 Channel 当前值,执行逻辑,产出写入。
Update 阶段:将所有节点的输出写入对应 Channel,更新版本号,保存 Checkpoint。
6.5.2 tick() 方法:判断哪些节点可以执行
tick() 是 Pregel 引擎的心跳——每次调用决定下一批要执行的节点。
# 简化版 tick() 逻辑
def tick(self) -> list[str]:
"""返回当前超步应该执行的节点列表"""
ready_nodes = []
for node_name, node in self.nodes.items():
# 获取该节点订阅的 Channel 列表
subscribed_channels = node.triggers
# 获取该节点上次执行时看到的 Channel 版本
last_seen_versions = self.checkpoint.versions_seen.get(node_name, {})
# 判断:是否有任何订阅的 Channel 在上次执行后被更新了?
should_run = False
for ch_name in subscribed_channels:
current_version = self.checkpoint.channel_versions.get(ch_name, 0)
seen_version = last_seen_versions.get(ch_name, 0)
if current_version > seen_version:
should_run = True
break
if should_run:
ready_nodes.append(node_name)
return ready_nodes
核心逻辑可以总结为一句话:如果一个节点订阅的某个 Channel 的版本号比该节点上次看到的版本号更大,说明有新数据了,该节点应该执行。
这是一种基于版本号比较的调度策略,避免了复杂的依赖分析——不需要知道”谁写了这个 Channel”,只需要比较版本号就能判断是否有更新。
6.5.3 after_tick() 方法:写入与持久化
# 简化版 after_tick() 逻辑
def after_tick(self, node_outputs: dict[str, dict]) -> None:
"""处理所有节点的执行结果"""
# 1. Apply writes: 将节点输出写入 Channel
for node_name, output in node_outputs.items():
for channel_name, value in output.items():
channel = self.channels[channel_name]
# 调用 Channel 的 update 方法
# LastValue: 直接覆盖(但如果多个节点写同一个 LastValue 会报错)
# BinaryOperatorAggregate: 用 reducer 合并
changed = channel.update([value])
if changed:
# 更新 Channel 版本号
self.checkpoint.channel_versions[channel_name] += 1
# 记录该节点看到的 Channel 版本
for ch_name in self.nodes[node_name].triggers:
self.checkpoint.versions_seen[node_name][ch_name] = \
self.checkpoint.channel_versions[ch_name]
# 2. 保存 Checkpoint
if self.checkpointer:
self.checkpointer.put(self.checkpoint)
6.6 Checkpoint 数据结构
Checkpoint 是 Pregel 引擎的”存档点”——完整记录了图在某个超步结束后的全部状态,使得图可以从任意超步恢复执行。
6.6.1 核心字段
@dataclass
class Checkpoint:
# 唯一标识
id: str # checkpoint UUID
# Channel 的当前值
channel_values: dict[str, Any]
# 例: {"messages": [...], "current_plan": "...", "draft": "..."}
# Channel 的当前版本号
channel_versions: dict[str, int]
# 例: {"messages": 5, "current_plan": 2, "draft": 3}
# 含义:messages 被写入了 5 次,current_plan 被写入了 2 次...
# 每个节点上次执行时看到的版本号
versions_seen: dict[str, dict[str, int]]
# 例: {
# "writer": {"messages": 3, "current_plan": 2},
# "reviewer": {"messages": 4, "draft": 2}
# }
# 含义:writer 上次执行时,messages 的版本是 3
# 如果现在 messages 版本是 5(>3),writer 应该再次执行
# 元信息
ts: str # 时间戳
parent_id: str | None # 上一个 checkpoint 的 ID(形成链表)
# 待处理的写入(用于 pending writes / interrupt 恢复)
pending_writes: list[tuple[str, str, Any]]
6.6.2 版本号驱动调度的完整示例
让我们走一遍具体的执行过程:
# 图结构:
# start → agent → tools → agent → ... → end
# (典型 ReAct 循环)
# 初始状态
checkpoint = Checkpoint(
channel_values={"messages": [HumanMessage("你好")]},
channel_versions={"messages": 1},
versions_seen={"agent": {}, "tools": {}}, # 都没执行过
)
# === 超步 1 ===
# tick(): agent 订阅 messages,版本 1 > 0(没见过) → agent 应该执行
# Execute: agent 读取 messages,调用 LLM,返回 AIMessage(tool_calls=[...])
# after_tick():
# channel_values["messages"] = [..., AIMessage(tool_calls=[...])]
# channel_versions["messages"] = 2
# versions_seen["agent"]["messages"] = 2 ← agent 已经看到了版本 2
# === 超步 2 ===
# tick():
# - agent: messages 版本 2 = seen 版本 2 → 不执行
# - tools: 订阅 messages,版本 2 > 0(没见过) → tools 应该执行
# Execute: tools 执行工具调用,返回 ToolMessage(result="...")
# after_tick():
# channel_values["messages"] = [..., ToolMessage(result="...")]
# channel_versions["messages"] = 3
# versions_seen["tools"]["messages"] = 3
# === 超步 3 ===
# tick():
# - agent: messages 版本 3 > seen 版本 2 → 应该执行!
# - tools: messages 版本 3 = seen 版本 3 → 不执行
# Execute: agent 读取包含工具结果的完整消息列表,决定是否需要更多工具调用
# ...
注意看 versions_seen 是如何驱动整个 ReAct 循环的:
- agent 写了消息 → messages 版本升级
- tools 看到新版本 → 执行工具
- tools 写了结果 → messages 版本再升级
- agent 看到新版本 → 再次思考
循环自然形成,不需要显式的”循环边”!这是 Pregel/BSP 模型的优雅之处——循环通过版本号差异隐式驱动。
6.6.3 Checkpoint 的用途
- 故障恢复:进程崩溃后,从最近的 Checkpoint 恢复,重新执行未完成的超步
- Human-in-the-Loop:在中断点保存 Checkpoint,用户操作后从 Checkpoint 恢复
- 时间旅行(Time Travel):回到任意历史 Checkpoint,重放或修改执行
- 分支执行:从同一个 Checkpoint 出发,用不同输入执行不同分支
# 时间旅行示例
checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)
# 执行
config = {"configurable": {"thread_id": "user-123"}}
result = app.invoke({"messages": [HumanMessage("帮我写代码")]}, config)
# 查看所有 checkpoint
for cp in checkpointer.list(config):
print(f"超步 {cp.metadata['step']}: {cp.id}")
# 回到第 2 步重新执行
old_config = {"configurable": {"thread_id": "user-123", "checkpoint_id": "cp-step-2"}}
new_result = app.invoke({"messages": [HumanMessage("换个方案")]}, old_config)
6.7 GraphInterrupt:Human-in-the-Loop 的实现
6.7.1 中断机制
GraphInterrupt 是 LangGraph 实现人机协作的核心机制。它的本质是:在指定节点执行前或后,保存 Checkpoint 并暂停执行。
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
graph = StateGraph(AgentState)
graph.add_node("draft", draft_node)
graph.add_node("review", review_node) # ← 我们想在这里暂停
graph.add_node("publish", publish_node)
graph.add_edge("draft", "review")
graph.add_edge("review", "publish")
# 编译时指定中断点
app = graph.compile(
checkpointer=MemorySaver(),
interrupt_before=["review"], # review 节点执行前暂停
)
6.7.2 中断与恢复的完整流程
# 第一次执行
config = {"configurable": {"thread_id": "article-1"}}
result = app.invoke({"topic": "AI Agent 入门"}, config)
# 执行到 review 前暂停
# result 包含 draft 节点的输出
# 查看当前状态
state = app.get_state(config)
print(state.values["draft"]) # 查看草稿内容
print(state.next) # ['review'] — 下一步要执行 review
# 方式 1: 直接恢复(不修改状态)
result = app.invoke(None, config) # 传 None 表示"继续"
# 方式 2: 修改状态后恢复
app.update_state(config, {"draft": "人工修改后的草稿"})
result = app.invoke(None, config)
# 方式 3: 跳转到其他节点
app.update_state(config, {"draft": "..."}, as_node="review")
# 这告诉引擎:"假装 review 已经执行了,直接去下一步"
6.7.3 底层实现原理
当引擎在 tick() 阶段发现下一批要执行的节点包含被中断的节点时:
# 伪代码:中断检查
def _should_interrupt(self, ready_nodes: list[str]) -> bool:
for node in ready_nodes:
if node in self.interrupt_before:
# 保存当前 checkpoint
self.checkpointer.put(self.checkpoint)
# 抛出 GraphInterrupt
raise GraphInterrupt(
interrupts=[Interrupt(
value=f"等待确认后执行 {node}",
resumable=True,
ns=[node],
)]
)
return False
对于 interrupt_after,则是在节点执行完成、after_tick() 中触发:
def after_tick(self, outputs, executed_nodes):
# ... 正常的 apply_writes 和 checkpoint ...
for node in executed_nodes:
if node in self.interrupt_after:
raise GraphInterrupt(
interrupts=[Interrupt(
value=outputs[node], # 把节点输出作为中断值
resumable=True,
)]
)
6.8 完整执行示例:从代码到超步
让我们用一个完整的例子,跟踪 Pregel 引擎的每一步执行。
6.8.1 图定义
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, END
import operator
class ResearchState(TypedDict):
query: str
sources: Annotated[list[str], operator.add] # 多节点可同时写入
summary: str # 只能单节点写入
quality_score: float
def search_web(state):
return {"sources": [f"Web result for: {state['query']}"]}
def search_papers(state):
return {"sources": [f"Paper result for: {state['query']}"]}
def summarize(state):
all_sources = "\n".join(state["sources"])
return {"summary": f"Summary of {len(state['sources'])} sources"}
def evaluate(state):
score = 0.8 if len(state["sources"]) > 1 else 0.4
return {"quality_score": score}
def route_after_eval(state):
if state["quality_score"] >= 0.7:
return "done"
return "need_more"
# 构建图
graph = StateGraph(ResearchState)
graph.add_node("search_web", search_web)
graph.add_node("search_papers", search_papers)
graph.add_node("summarize", summarize)
graph.add_node("evaluate", evaluate)
graph.set_entry_point("search_web")
# search_web 和 search_papers 并行
graph.add_edge("search_web", "summarize")
graph.add_edge("search_papers", "summarize")
graph.set_conditional_entry_points(["search_web", "search_papers"]) # 并行入口
graph.add_edge("summarize", "evaluate")
graph.add_conditional_edges("evaluate", route_after_eval, {
"done": END,
"need_more": "search_web",
})
app = graph.compile()
6.8.2 超步执行追踪
=== 初始状态 ===
channel_values: {query: "LLM Agent 框架对比"}
channel_versions: {query: 1, sources: 0, summary: 0, quality_score: 0}
versions_seen: {all nodes: {}}
=== 超步 1: Plan ===
tick() 检查:
- search_web: 订阅 query,版本 1 > seen 0 → ✓ 执行
- search_papers: 订阅 query,版本 1 > seen 0 → ✓ 执行
- summarize: 订阅 sources,版本 0 = seen 0 → ✗ 不执行
- evaluate: 订阅 summary,版本 0 = seen 0 → ✗ 不执行
→ 并行执行 [search_web, search_papers]
=== 超步 1: Execute ===
search_web → {"sources": ["Web result..."]}
search_papers → {"sources": ["Paper result..."]}
(两者并行执行,互不干扰)
=== 超步 1: Update (after_tick) ===
sources Channel (BinaryOperatorAggregate, operator.add):
update(["Web result...", "Paper result..."])
→ value = ["Web result...", "Paper result..."]
channel_versions: {query: 1, sources: 1, summary: 0, quality_score: 0}
versions_seen: {
search_web: {query: 1, sources: 1},
search_papers: {query: 1, sources: 1}
}
→ 保存 Checkpoint #1
=== 超步 2: Plan ===
tick() 检查:
- search_web: query 版本 1 = seen 1 → ✗
- search_papers: query 版本 1 = seen 1 → ✗
- summarize: sources 版本 1 > seen 0 → ✓ 执行
- evaluate: summary 版本 0 = seen 0 → ✗
→ 执行 [summarize]
=== 超步 2: Execute ===
summarize → {"summary": "Summary of 2 sources"}
=== 超步 2: Update ===
summary Channel (LastValue): value = "Summary of 2 sources"
channel_versions: {query: 1, sources: 1, summary: 1, quality_score: 0}
→ 保存 Checkpoint #2
=== 超步 3: Plan ===
- evaluate: summary 版本 1 > seen 0 → ✓ 执行
→ 执行 [evaluate]
=== 超步 3: Execute ===
evaluate → {"quality_score": 0.8}
=== 超步 3: Update ===
quality_score Channel (LastValue): value = 0.8
→ 保存 Checkpoint #3
→ 条件路由: quality_score >= 0.7 → "done" → END
→ 图执行完毕!
最终结果: {
query: "LLM Agent 框架对比",
sources: ["Web result...", "Paper result..."],
summary: "Summary of 2 sources",
quality_score: 0.8
}
6.9 Channel 版本号的精妙设计
6.9.1 为什么不用简单的”dirty flag”?
你可能会想:为什么不用一个简单的布尔值 has_new_data 来标记 Channel 是否有更新?
原因:dirty flag 无法区分”谁看过了”。如果 Node A 和 Node B 都订阅 Channel X:
- 超步 1:Channel X 被写入,标记 dirty
- 超步 2:Node A 执行了(看到了新数据),dirty 还是 true
- 超步 3:Node B 也想执行——但如果 Node A 执行后把 dirty 设为 false,Node B 就错过了!
版本号解决了这个问题:每个节点记录自己看到的版本号,Channel 记录当前版本号。两者对比即可知道”这个节点是否有未处理的更新”。
6.9.2 版本号与拓扑排序的关系
在一个线性图(A → B → C)中,版本号驱动的调度等价于拓扑排序。但在有并行和循环的图中,版本号提供了更灵活的调度——它自动处理了”等待所有上游完成”和”循环重入”的问题。
并行场景:
A 和 B 同时写入 Channel X(版本从 1 变为 2)
C 订阅 X:版本 2 > seen 1 → C 执行
自动等待了 A 和 B 都完成!
循环场景:
A 写入 X(版本变为 3)
B 订阅 X:版本 3 > seen 2 → B 再次执行
循环自然重入!
6.10 与 tRPC-Agent StateGraph 的差异
6.10.1 调度模型
| 维度 | LangGraph (Pregel/BSP) | tRPC-Agent StateGraph |
|---|---|---|
| 调度单位 | 超步(批量并行) | 节点(逐个或显式并行) |
| 循环驱动 | Channel 版本号隐式驱动 | 条件边显式指回 |
| 并行触发 | 多节点订阅同一 Channel 时自动并行 | 需要用 MultiConditionalEdges 显式触发 |
| 同步机制 | BSP barrier(同一超步所有节点完成后才进入下一步) | JoinEdge(显式等待指定节点) |
6.10.2 状态管理
| 维度 | LangGraph | tRPC-Agent |
|---|---|---|
| 状态定义 | TypedDict + Annotated | Go struct + tag |
| Channel 类型 | 多种(LastValue, BinaryOp, Topic, Ephemeral) | 两种(覆盖, 追加) |
| 自定义 reducer | 任意函数 | 仅 append |
| 多写入冲突 | LastValue 报错,BinaryOp 合并 | 运行时冲突由开发者负责 |
| 类型安全 | 运行时(Python) | 编译时(Go 泛型) |
6.10.3 设计哲学
LangGraph 更偏向声明式——你声明状态类型和 reducer,引擎自动推导出执行顺序。这像 SQL:你说你要什么,数据库决定怎么做。
tRPC-Agent 更偏向命令式——你显式指定每条边、每个条件、每个汇聚点。这像手写循环:你精确控制每一步。
两种风格各有优势:
- LangGraph 的自动调度在简单场景下更省事,但在复杂场景下可能产生”为什么这个节点执行了/没执行”的困惑
- tRPC-Agent 的显式控制在复杂场景下更清晰,但在简单场景下需要写更多样板代码
6.10.4 Checkpoint 实现差异
# LangGraph: Checkpoint 包含完整的 Channel 状态 + 版本信息
# 支持 Time Travel(回到任意历史节点)
# 支持分支(从同一个 Checkpoint 产生不同的执行路径)
checkpoint = {
"channel_values": {...}, # 所有 Channel 的当前值
"channel_versions": {...}, # 全局版本号
"versions_seen": {...}, # 每个节点的版本记录
"pending_writes": [...], # 未完成的写入
}
// tRPC-Agent: Checkpoint 主要存储 State 快照
// 侧重于中断恢复,Time Travel 支持较弱
type Checkpoint struct {
ID string
State S // 完整状态快照
NodeName string // 当前执行到的节点
Timestamp time.Time
}
6.11 Channel 系统的高级用法
6.11.1 自定义 Reducer 实现滑动窗口
def sliding_window(max_size: int):
"""只保留最近 max_size 条消息的 reducer"""
def reducer(old: list, new: list) -> list:
combined = old + new
return combined[-max_size:] # 只保留最后 N 条
return reducer
class ChatState(TypedDict):
# 只保留最近 20 条消息,防止 context window 溢出
messages: Annotated[list[BaseMessage], sliding_window(20)]
# 只保留最近 5 次工具调用结果
tool_results: Annotated[list[str], sliding_window(5)]
6.11.2 去重 Reducer
def deduplicate(old: list, new: list) -> list:
"""去重合并"""
seen = set()
result = []
for item in old + new:
key = item if isinstance(item, str) else id(item)
if key not in seen:
seen.add(key)
result.append(item)
return result
class SearchState(TypedDict):
# 多个搜索节点可能返回重复结果,自动去重
results: Annotated[list[str], deduplicate]
6.11.3 条件累积 Reducer
def accumulate_if_relevant(threshold: float):
"""只累积相关度超过阈值的结果"""
def reducer(old: list, new: list) -> list:
filtered_new = [item for item in new if item.get("relevance", 0) > threshold]
return old + filtered_new
return reducer
class RAGState(TypedDict):
# 只保留相关度 > 0.7 的检索结果
documents: Annotated[list[dict], accumulate_if_relevant(0.7)]
6.12 Pregel 引擎的高级特性
6.12.1 子图(Subgraph)
LangGraph 支持将一个完整的图作为另一个图的节点——这就是子图。子图有自己的 Channel 和执行上下文。
# 定义子图
research_subgraph = StateGraph(ResearchSubState)
research_subgraph.add_node("search", search_node)
research_subgraph.add_node("filter", filter_node)
research_subgraph.add_edge("search", "filter")
research_sub = research_subgraph.compile()
# 在主图中使用子图作为节点
main_graph = StateGraph(MainState)
main_graph.add_node("research", research_sub) # 子图作为节点!
main_graph.add_node("write", write_node)
main_graph.add_edge("research", "write")
子图的 Checkpoint 是嵌套的——主图的 Checkpoint 包含子图的 Checkpoint。这意味着即使在子图内部中断,也能正确恢复。
6.12.2 动态节点(Send API)
有时候你不知道会有多少个并行任务,需要在运行时动态创建节点。LangGraph 的 Send API 支持这一点:
from langgraph.constants import Send
def route_to_workers(state):
"""动态决定启动几个 worker"""
tasks = state["pending_tasks"]
# 为每个任务动态创建一个 worker 执行
return [Send("worker", {"task": task}) for task in tasks]
graph.add_conditional_edges("planner", route_to_workers)
这在 tRPC-Agent StateGraph 中没有直接等价物——你需要在编译前就确定所有节点。
6.12.3 流式输出
Pregel 引擎原生支持流式输出——你可以在图执行过程中实时获取每个节点的输出:
# 流式输出:实时看到每个节点的结果
async for event in app.astream({"query": "什么是 Agent?"}, config):
for node_name, output in event.items():
print(f"[{node_name}] → {output}")
# 更细粒度:流式获取 LLM 的每个 token
async for event in app.astream_events(input, config, version="v2"):
if event["event"] == "on_chat_model_stream":
print(event["data"]["chunk"].content, end="")
6.13 本章总结
LangGraph 的底层由两个精妙的系统驱动:
Channel 系统——数据如何在节点间流动:
- BaseChannel 用三个泛型分离了读/写/存储的类型
- LastValue 保证单写入者的语义
- BinaryOperatorAggregate 通过 reducer 支持多写入者合并
- Annotated 类型标注是连接用户 State 和底层 Channel 的桥梁
Pregel 引擎——节点如何被调度执行:
- BSP 模型保证了确定性和 Checkpoint 友好性
- tick() 通过版本号比较决定哪些节点该执行
- after_tick() 负责写入 Channel 和保存 Checkpoint
- 超步之间是天然的持久化和中断点
Checkpoint——执行状态如何持久化:
- channel_values + channel_versions + versions_seen 完整描述了图的状态
- 支持故障恢复、Human-in-the-Loop、Time Travel、分支执行
与 tRPC-Agent 的核心差异在于:LangGraph 用 Channel 版本号隐式驱动循环和调度,tRPC-Agent 用条件边显式控制流向。两者的 Checkpoint 设计也反映了这个哲学差异——LangGraph 存版本号矩阵,tRPC-Agent 存状态快照。
6.14 思考题
-
设计题:假设你需要实现一个 Channel 类型叫
MaxValue——它永远保留所有写入值中的最大值(数值型)。请写出它的update()和get()方法。思考:这个 Channel 的checkpoint()方法需要保存什么信息? -
调试题:一个 LangGraph 图有 3 个节点 A→B→C。执行时发现 B 被执行了两次。你检查 Checkpoint 发现
versions_seen["B"]["input"] = 2而channel_versions["input"] = 3。请解释为什么 B 会被执行两次,以及什么操作导致 input Channel 被写入了 3 次。 -
对比分析:LangGraph 的 LastValue Channel 在多个节点同时写入时会报错。假设 tRPC-Agent 也遇到了”两个并行节点写同一个字段”的情况,它会怎么处理?LangGraph 报错的设计选择有什么优缺点?
-
架构思考:BSP 模型要求同一超步内的所有节点完成后才能进入下一个超步。如果一个图中 Node A 执行需要 100ms,Node B 需要 10 秒(它要调用一个很慢的外部 API),这对整体延迟有什么影响?你能想到什么优化方案?
-
实践题:设计一个
messages字段的自定义 reducer,要求:(a) 保留所有 HumanMessage 和 AIMessage;(b) 对于 ToolMessage,只保留最近 10 条;(c) 如果总消息数超过 50 条,自动对早期消息做摘要压缩(保留最近 20 条原文,其余生成摘要)。写出 reducer 函数的伪代码。
上一章:第五章 tRPC-Agent-Go StateGraph 图引擎
下一章:第七章 CrewAI 角色扮演与 Flow 事件驱动
返回目录:Agent 框架深度导读系列