Skip to content

第 7 章 编译期:从 StateGraph 到 CompiledStateGraph ​

7.1 compile() 的作用与全流程 ​

StateGraph 是图的声明式描述:开发者通过 add_node、add_edge、add_conditional_edges 注册节点与边,此时图只是一组 Python 数据结构(节点字典、边列表、分支配置),没有任何执行能力,不能被 invoke 或 stream 调用。compile() 方法负责将这份声明转换为可运行的 Pregel 实例——CompiledStateGraph。这是一次"语义翻译"而非简单序列化:用户视角的节点与边被翻译成 Pregel 引擎能理解的 Channel、PregelNode、Branch 等内部原语,并在翻译过程中完成全部静态校验。该过程在源码 langgraph/graph/state.py 的 StateGraph.compile 方法中按固定顺序执行七步,整个过程同步、幂等、无副作用(不写盘、不建立网络连接),适合在应用启动期执行一次并长期复用。

compile() 的签名如下(LangGraph 1.2.x):

python
def compile(
    self,
    checkpointer: BaseCheckpointSaver | None = None,
    *,
    store: BaseStore | None = None,
    interrupt_before: All | list[str] | None = None,
    interrupt_after: All | list[str] | None = None,
    debug: bool = False,
    name: str | None = None,
    cache: BaseCache | None = None,
) -> CompiledStateGraph: ...
参数类型说明
checkpointerBaseCheckpointSaver / None检查点存储器,启用后图自动在每个超步末落盘快照,是 interrupt、时间旅行、多轮记忆的基础
storeBaseStore / None跨会话长期记忆存储器,节点内通过 get_store() 获取
interrupt_before"all" / list[str] / None编译期静态断点,在指定节点执行前挂起
interrupt_after"all" / list[str] / None编译期静态断点,在指定节点执行后挂起
debugbool调试模式,开启后每个 Superstep 输出详细调度日志
namestr / None图名称,影响 LangSmith trace 与子图标识
cacheBaseCache / NoneLLM 调用缓存,复用同参数结果,支持按 run_id 失效

需要特别说明的是:所有 compile 参数都在编译期确定,运行期(invoke/stream 时)无法动态切换。如果需要不同 checkpointer 或不同断点集合,必须编译多张图实例。对比之下,config 参数(thread_id、user_id、model 名等)在每次调用时动态传入,不要求重新编译。

编译流程按以下顺序执行:

第 1 步:validate_graph()(图结构校验)。编译器在执行任何转换前对图合法性做全量检查:所有被边或条件分支引用的节点必须已经通过 add_node 注册(除 START/END 保留字外);interrupt_before/after 指定的节点名必须存在于图中,若传入不存在的名字立即抛 ValueError: Interrupt target node 'X' not found;不允许存在节点自身到自身的直接自环(同一节点 add_edge 到自身);不允许边以 START 或 END 作为环的一部分(START 只能出边、END 只能入边);所有注册节点必须存在从 START 可达的路径,孤立节点会触发警告或错误。该校验只做静态拓扑检查,不运行任何节点代码。

第 2 步:prepare_output_channels(Channel 准备)。编译器遍历 State Schema 中声明的全部字段,按字段的类型注解和 Annotated reducer 元数据为每个字段创建对应的 Channel 实例:声明了 reducer 的字段(Annotated[T, reducer])生成 BinaryOperatorAggregate 通道,保存 reducer 函数与初始值;未声明 reducer 的字段生成 LastValue 通道,保存最后一次写入;同时为图的内部通信创建 EphemeralValue 通道(用于保存 __start__ 输入、branch:to:xxx 路由结果等一次性消息,每轮超步后清空)和 Topic 通道(发布-订阅语义,用于广播与事件流)。该阶段还确定两个通道集合:output_channels(invoke 返回的字段列表,受 output_schema 裁剪)与 stream_channels(流式输出时默认推送的字段集合)。

第 3 步:创建 CompiledStateGraph 实例。CompiledStateGraph 继承自 Pregel 基类(langgraph/pregel/main.py)。构造时传入 builder 自身引用、schema 到 mapper 的映射、已生成的 Channel 字典(含 START 对应的 EphemeralValue)、input_channels 固定为 START、默认 stream_mode="updates"、checkpointer/store/debug/name 等编译期参数。构造完成时 nodes 字典为空、branches 字典为空,等待后续步骤填充。

第 4 步:attach_node(节点挂载)。编译器按固定顺序注册 START 节点与全部用户节点,每个节点被包装为一个 PregelNode。PregelNode 是 Pregel 引擎的执行单元,包含三部分核心结构:triggers(订阅的 Channel 列表,任一 Channel 更新都会触发本节点)、channels(可读 Channel 映射,声明节点执行时能读取哪些 State 字段,默认订阅全部)、writers(Channel 写入器列表,节点返回的增量字典按 writers 映射写入对应 Channel)。START 节点特殊处理为 EphemeralValue 通道,只在超步 0 接收 invoke 传入的初始输入,不会被任何节点的输出触发。用户节点的 triggers 默认设置为"其前驱写入的 Channel 与边触发信号"的并集。

第 5 步:attach_edge(边挂载)。普通边 add_edge(a, b) 被翻译为一条写入指令:"节点 a 的 writers 中加入一个 ChannelWrite,向内部 Channel branch:to:{b} 写入一个触发信号"。当多个前驱节点都 add_edge 到同一目标 b 时,编译器识别出 Fan-in(barrier,屏障汇聚) 语义:目标 b 的 PregelNode.triggers 中会加入所有前驱对应的 branch:to: 信号 Channel,并在 Join 逻辑中等待所有前驱在同一超步完成并产生信号后才调度 b,b 执行时读取的是所有前驱写入合并后的新状态版本。

第 6 步:attach_branch(分支挂载)。条件边被翻译为 Branch 对象并注册到源节点的 writers 后。Branch 持有 path 函数与 path_map;超步末 Update 阶段,path 函数在最新状态上被调用,返回值(节点名字符串、字符串列表、Send 对象或 Send 列表)通过 path_map 映射为实际目标,再向对应 branch:to:{target} Channel 写入触发信号。字符串列表对应静态 Fan-out(多个目标并发),Send 列表对应动态 Fan-out(每个 Send 携带独立入参派发到目标节点)。path_map 在编译期将语义 key(如 "end")绑定到实际节点名或 END 常量,运行期不再做字符串解析。

第 7 步:最终校验。调用 compiled.validate() 方法,从 START 出发做一次完整的图遍历,确认从任何可达节点出发都存在通向 END 的路径,且不存在"死路"(节点没有出边也未连到 END、或条件分支的某个返回值未映射到任何目标)。若发现不可达或死路,抛出 ValueError: Node 'X' has no paths to END 或 ValueError: Branch 'b' returns unknown destination 'x'。该校验是对 validate_graph 的二次防御,因为 attach_edge/attach_branch 过程可能引入用户未显式声明的路由漏洞。

编译过程完成后返回 CompiledStateGraph 实例,可立即调用 invoke/stream/ainvoke/astream 等方法执行。

7.2 关键编译参数深度解析 ​

checkpointer 参数。传入任何 BaseCheckpointSaver 实例(MemorySaver、PostgresSaver、SqliteSaver 等)后,编译期会在 Pregel 的 Update 阶段末尾自动注册写入钩子(hook):每个超步的 Channel 合并完成后,钩子将新的 Channel 值快照、Channel 版本号、versions_seen、pending_sends 元数据一并通过 put() 写入 checkpointer;节点执行中若抛出 GraphInterrupt(即调用了 interrupt() 函数),钩子会先调用 put_writes() 写入 interrupt 事件并落盘完整快照,然后释放执行线程挂起等待 resume。未传 checkpointer 时调用 interrupt() 会抛 NotImplementedError: Checkpointer is required for interrupt,原因是运行时无处保存恢复点,resume 时也无处读取被中断的状态。

store 参数。传入 BaseStore 实例后,该实例通过 Python 的 ContextVar 注入到运行时上下文中,节点函数、工具函数、条件路由函数均可通过 langgraph.config.get_store() 获取该实例,无需在参数列表中显式传递。Store 与 Checkpointer 的关键区别在于:Store 的读写不会自动进入 checkpoint,put/get/search/delete 都是业务代码手动调用,结果只影响长期记忆层而不影响当前超步的状态合并。

interrupt_before / interrupt_after。传入节点名列表(或特殊字符串 "all" 表示所有用户节点),编译器会在对应节点的 PregelNode 执行入口前(interrupt_before)或出口后(interrupt_after)自动插入一条 interrupt 指令。该机制属于编译期静态断点,适合位置固定、业务上必经的审批点(如"工具调用前需要人工确认"、"最终答案发出前需要审核")。运行期条件性中断(例如"只有当金额大于 10000 元时才中断")则应通过在节点函数内部调用 interrupt() 原语实现,该原语抛特殊异常冒泡到超步边界后由运行时挂起,不要求编译期预知断点位置。两种断点都会在超步边界精确挂起,等待调用方通过 Command(resume=value) 恢复,resume 值会作为 interrupt() 调用的返回值注入被中断节点。

debug 模式。debug=True 时,Pregel 运行时在每个超步的 Plan/Execute/Update 三个阶段将详细调度信息打印到标准错误流:本超步被调度的节点集合与触发原因、每个节点读取的 Channel 版本号、节点产生的写入(key→value)、Channel 合并后的新版本号、条件路由的 path 函数返回值与目标节点列表、是否触发中断等。debug 输出量较大(每个超步约数十行),适合开发期在 Notebook 或本地调试时排查"为什么某个节点没被触发""为什么路由走了错误分支"等问题。生产环境必须关闭 debug,避免日志文件膨胀、敏感信息进入日志、以及打印逻辑对超步调度的性能损耗。

name 与 cache 参数。name 用于标识图实例,会出现在 LangSmith trace 的 span 名称中,也作为子图被父图引用时的命名空间前缀。cache 参数传入 BaseCache(如 InMemoryCache、RedisCache)后,图内所有 LLM 调用会通过缓存层查找同参数历史结果,命中则跳过远程调用直接返回,适合幂等性高的批量处理场景。

7.3 CompiledStateGraph 的方法与产物结构 ​

编译完成后,CompiledStateGraph 作为 Pregel 的子类对外暴露以下核心方法:

方法同步/异步说明
invoke / ainvoke同步/异步输入初始 State,阻塞/await 到图执行到 END,返回完整终态(经 output_schema 裁剪)
stream / astream同步/异步按指定 stream_mode 流式输出中间产物;支持 "values"(每轮完整 State)、"updates"(节点增量)、"messages"(消息 token 流)、"custom"(自定义事件)、"debug"(超步调试事件)等模式
stream_events / astream_events同步/异步输出 LangChain 标准事件流(on_chain_start/on_chat_model_stream/on_tool_end 等),供前端逐 token 渲染、工具进度展示
get_graph同步返回可视化图对象,支持 draw_mermaid()(输出 Mermaid 源码)、draw_mermaid_png()(渲染为 PNG 字节)
get_state同步/异步根据 config 读取某 thread_id 的当前状态快照,返回 StateSnapshot 对象(含 values、next、config、metadata、created_at、parent_config)
update_state同步/异步不执行任何节点,将给定 values 作为某节点(由 as_node 指定,默认为特殊的 __start__ 节点)的写入合并到 State,生成 source="update" 的新 checkpoint
get_state_history同步/异步返回某 thread_id 的历史 StateSnapshot 迭代器,按 checkpoint 时间倒序排列,支持时间旅行浏览与 fork 执行

编译产物的内部结构由三个核心字典构成,运行期调度完全依赖这三张表:

  • channels:dict[str, BaseChannel],键为 State 字段名(如 "messages"、"total")和内部通道名(如 __start__、branch:to:tools),值为 Channel 实例(LastValue / BinaryOperatorAggregate / EphemeralValue / Topic)。每个 Channel 持有当前值、版本号、对应的 reducer/初始值等信息。
  • nodes:dict[str, PregelNode],键为节点名(含保留字 "__start__"),值为 PregelNode 对象。每个 PregelNode 持有:triggers(订阅的 Channel 列表,任一 Channel 版本变更则触发节点);channels(可读 Channel 映射,__all__ 表示订阅全部);writers(节点返回后要执行的 ChannelWrite 列表,包含普通字段写入与 branch:to: 触发信号写入);bound(节点的实际可调用对象,可能是用户函数、Runnable、子图)。
  • branches:dict[str, dict[str, Branch]],外层键为源节点名,内层键为分支标识(单一条件边默认 "default",多次调用 add_conditional_edges 时使用用户指定名),值为 Branch 对象,持有 path 函数、path_map、结束标志(是否到 END)。

最小可运行示例:

python
# langgraph 1.2.x
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command

class CalcState(TypedDict):
    numbers: Annotated[list[int], operator.add]
    total: int

def add_one(state: CalcState) -> dict:
    """向 numbers 列表追加一个元素 1。"""
    return {"numbers": [1]}

def sum_all(state: CalcState) -> dict:
    """对 numbers 列表求和并写入 total 字段。"""
    return {"total": sum(state["numbers"])}

builder = StateGraph(CalcState)
builder.add_node("add_one", add_one)
builder.add_node("sum_all", sum_all)
builder.add_edge(START, "add_one")
builder.add_edge("add_one", "sum_all")
builder.add_edge("sum_all", END)

# 编译:传入 checkpointer 以启用持久化,debug=False 关闭详细日志
graph = builder.compile(
    checkpointer=MemorySaver(),
    interrupt_before=["sum_all"],  # 在 sum_all 执行前静态中断
    debug=False,
    name="calc_demo",
)

# 查看编译产物结构
print("channels:", list(graph.channels.keys()))
print("nodes:", list(graph.nodes.keys()))
print("branches:", {k: list(v.keys()) for k, v in graph.branches.items()})

# 第一次调用:会在 sum_all 前挂起
config = {"configurable": {"thread_id": "demo-1"}}
graph.invoke({"numbers": [], "total": 0}, config=config)
snapshot = graph.get_state(config)
print("挂起时 numbers =", snapshot.values["numbers"])  # [1]
print("下一个待执行节点 =", snapshot.next)              # ("sum_all",)

# 恢复执行:传入 resume 值(此处 sum_all 不读 resume,传 None 即可)
result = graph.invoke(Command(resume=None), config=config)
print("最终结果:", result)  # {'numbers': [1], 'total': 1}

上述代码中,numbers 字段在编译期被识别为带 operator.add reducer,生成 BinaryOperatorAggregate 通道;total 字段无 reducer,生成 LastValue 通道。执行路径为 START→add_one(写入 numbers=[1],超步 1 结束、快照落盘)→因 interrupt_before=["sum_all"] 挂起在 sum_all 前→resume 后进入超步 2,sum_all 读取合并后的 numbers=[1] 并写入 total=1→END。

本章小结 ​

compile() 是声明式 StateGraph 到可执行 CompiledStateGraph(Pregel 实例)的唯一入口,严格按"图校验→Channel 准备→实例创建→节点挂载→边挂载→分支挂载→终校验"七步顺序执行。checkpointer、store、interrupt_before/after、debug、name、cache 等参数在编译期决定图的运行时能力,运行期不可动态修改。编译产物的 channels、nodes、branches 三个核心字典完整描述了 Pregel 引擎的运行时拓扑:Channel 承载状态与版本、PregelNode 承载计算单元与触发订阅、Branch 承载动态路由逻辑。CompiledStateGraph 对外暴露 invoke/stream/stream_events/get_graph/get_state/update_state/get_state_history 七类核心方法,覆盖执行、流式、可视化、状态读取与人工纠错全场景。

常见错误 ​

  1. 编译时报 "Found node 'X' with no paths to END"。现象:compile() 抛 ValueError,指出某个节点无路径到终点。原因:节点 X 已 add_node 注册但没有任何出边连到 END 或其他可达 END 的节点,形成死路;或条件分支某个返回值未通过 path_map 映射到任何目标。修复:为 X 添加指向 END 或下游节点的边,未使用的节点直接删除;检查条件路由 path 函数的所有可能返回值是否都在 path_map 中。
  2. interrupt_before 指定了不存在的节点。现象:compile() 抛 ValueError: Interrupt target node 'X' not found。原因:节点名拼写错误、大小写不一致、或 interrupt_before 列表在 add_node 之前引用了尚未注册的节点名。修复:按 snake_case 规范核对节点名;将 interrupt_before/after 参数放在所有 add_node 之后配置。
  3. 未传 checkpointer 却调用 interrupt()。现象:执行到含 interrupt() 的节点时抛 NotImplementedError: Checkpointer is required for interrupt。原因:compile 时 checkpointer=None(默认值),运行时没有持久化后端保存中断快照。修复:compile 时传入 MemorySaver(开发期)或 PostgresSaver/SqliteSaver(生产);若业务不需要中断能力,移除节点内的 interrupt() 调用。
  4. State Schema 字段类型与 reducer 不兼容。现象:运行时 Update 阶段抛 TypeError: unsupported operand type(s) for +: 'int' and 'str' 等合并错误。原因:字段声明为 Annotated[list, operator.add] 但某节点返回了非 list 值,或 reducer 与字段类型不匹配。修复:所有写入该字段的节点都必须返回 reducer 期望的类型(对 operator.add 的 list 字段返回 list);自定义 reducer 内部做类型检查与容错。
  5. 在每次请求中重复调用 compile()。现象:服务 QPS 提升后 CPU 占用异常高,首次请求延迟大。原因:把 builder.compile() 放在请求处理函数内部,每次请求都重新做图校验、Channel 创建、节点包装。修复:compile 在应用启动期(模块顶层或 lifespan 中)执行一次,将编译结果保存在全局变量或 app.state 中复用。

生产建议 ​

  • 编译调用在应用启动期执行一次,结果通过全局单例或依赖注入容器复用,禁止在请求路径上反复 compile;
  • 生产环境 debug 参数必须关闭(默认 False),避免日志量膨胀、敏感 prompt 进入日志、以及打印逻辑对超步调度的性能损耗;
  • interrupt_before/after 只用于位置固定的审批点,条件性中断统一使用节点内 interrupt() 原语,避免静态断点过多导致图结构难以维护;
  • 编译后立即调用 get_graph().draw_mermaid_png() 输出图结构 PNG,纳入代码仓库的 docs 目录与 PR 评审;
  • name 参数按业务功能命名(如 "customer_support_agent"、"paper_review_pipeline"),便于 LangSmith 中按图名聚合 trace;
  • 对同一套业务逻辑需要不同配置(如不同 checkpointer、不同断点集合)时,封装一个 build_graph(config) 工厂函数返回编译结果,避免散落在各处的 compile 调用。