犀牛鸟 2026 研究笔记 estelledc.github.io

第六章:LangGraph Channel 与 Pregel 引擎精读

本章目标:深入 LangGraph 的核心——Channel 通信系统和 Pregel 执行引擎,理解”状态如何在节点间流动”以及”图引擎如何调度节点执行”的底层机制。


6.1 为什么需要深入 Channel 和 Pregel?

日常类比:工厂流水线的”传送带”和”调度室”

想象一个巧克力工厂:

在 LangGraph 中:

前面章节我们用的 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 恢复"""
        ...

为什么需要三个独立的泛型?因为这三个操作可能涉及不同的类型:

这个设计带来了极大的灵活性,但也增加了理解成本。

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),每个超步内:

  1. 所有准备好的节点并行执行
  2. 执行完毕后,进入同步屏障(barrier)——等待所有节点完成
  3. 同步完成后,交换数据——各节点的输出写入 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?

有几个关键原因:

  1. 确定性:同一个输入,无论并行执行的时序如何,结果总是相同的。这对于 AI 应用的可复现性至关重要。
  2. Checkpoint 友好:超步之间是天然的持久化点——每个超步结束后保存一次状态即可。
  3. 推理简单:开发者不需要考虑竞争条件或锁——同一个超步内的节点互相看不到对方的写入。
  4. 与图模型天然匹配:图中的”层”(从入口到出口的拓扑层级)天然对应超步。

6.4.3 BSP 的代价

当然,BSP 也有代价:


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 循环的:

循环自然形成,不需要显式的”循环边”!这是 Pregel/BSP 模型的优雅之处——循环通过版本号差异隐式驱动。

6.6.3 Checkpoint 的用途

  1. 故障恢复:进程崩溃后,从最近的 Checkpoint 恢复,重新执行未完成的超步
  2. Human-in-the-Loop:在中断点保存 Checkpoint,用户操作后从 Checkpoint 恢复
  3. 时间旅行(Time Travel):回到任意历史 Checkpoint,重放或修改执行
  4. 分支执行:从同一个 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:

版本号解决了这个问题:每个节点记录自己看到的版本号,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 更偏向命令式——你显式指定每条边、每个条件、每个汇聚点。这像手写循环:你精确控制每一步。

两种风格各有优势:

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 系统——数据如何在节点间流动:

Pregel 引擎——节点如何被调度执行:

Checkpoint——执行状态如何持久化:

与 tRPC-Agent 的核心差异在于:LangGraph 用 Channel 版本号隐式驱动循环和调度,tRPC-Agent 用条件边显式控制流向。两者的 Checkpoint 设计也反映了这个哲学差异——LangGraph 存版本号矩阵,tRPC-Agent 存状态快照。


6.14 思考题

  1. 设计题:假设你需要实现一个 Channel 类型叫 MaxValue——它永远保留所有写入值中的最大值(数值型)。请写出它的 update()get() 方法。思考:这个 Channel 的 checkpoint() 方法需要保存什么信息?

  2. 调试题:一个 LangGraph 图有 3 个节点 A→B→C。执行时发现 B 被执行了两次。你检查 Checkpoint 发现 versions_seen["B"]["input"] = 2channel_versions["input"] = 3。请解释为什么 B 会被执行两次,以及什么操作导致 input Channel 被写入了 3 次。

  3. 对比分析:LangGraph 的 LastValue Channel 在多个节点同时写入时会报错。假设 tRPC-Agent 也遇到了”两个并行节点写同一个字段”的情况,它会怎么处理?LangGraph 报错的设计选择有什么优缺点?

  4. 架构思考:BSP 模型要求同一超步内的所有节点完成后才能进入下一个超步。如果一个图中 Node A 执行需要 100ms,Node B 需要 10 秒(它要调用一个很慢的外部 API),这对整体延迟有什么影响?你能想到什么优化方案?

  5. 实践题:设计一个 messages 字段的自定义 reducer,要求:(a) 保留所有 HumanMessage 和 AIMessage;(b) 对于 ToolMessage,只保留最近 10 条;(c) 如果总消息数超过 50 条,自动对早期消息做摘要压缩(保留最近 20 条原文,其余生成摘要)。写出 reducer 函数的伪代码。


上一章:第五章 tRPC-Agent-Go StateGraph 图引擎

下一章:第七章 CrewAI 角色扮演与 Flow 事件驱动

返回目录:Agent 框架深度导读系列