AI学习-langgraph-1

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

第二部分   AI学习-langgraph-2-CSDN博客

1. LangGraph总览

LangGraph 运行时底层基于自研的 Pregel 运行时,其核心思想借鉴了 Google Pregel 计算模型,用于组织和执行复杂的图计算流程。

1.0. LangGraphLangChain的版本迭代及定位

LangChain 1.x 的定位发生了明显变化:旧式 Chain、Retriever、Memory 等组件被迁移至 langchain-classic;LCEL 保留为 langchain-core 的底层组合机制,但不再是主要开发入口。

LangChain 1.x 现在聚焦于 Agent 开发,核心入口是 create_agent,围绕 Agent 提供模型、工具、消息、结构化输出、Middleware` 和记忆管理等高层抽象。

LangGraph 是更底层的编排框架与 Agent Runtime,负责复杂工作流和有状态 Agent 的执行,提供持久化、流式输出、Durable Execution、Human-in-the-loop 等运行时能力。create_agent 底层基于 LangGraph` 实现。

二者的定位对比如下:

对比维度LangChainLangGraph
定位Agent 高层开发框架底层编排框架 & Agent Runtime
核心入口create_agentStateGraph / @entrypoint
适用场景结构直接的 Agent 应用复杂工作流、持久化状态、长时间运行、人工介入
流程控制Agent 循环自动管理精细控制节点、边、条件分支
学习成本较低较高

LangChain 提供易于使用的 Agent 高层抽象,LangGraph 提供可靠、可持久化且可精细控制的底层执行能力。

对于大多数 Agent 项目,从 LangChain 的 create_agent 开始即可;需要复杂工作流编排、确定性步骤与 Agent 步骤混合、长时间运行或底层状态控制时,再引入 LangGraph。

LangGraph 运行时主要由三个基本要素构成:State(状态)、Node(节点) 和 Edge(边)

State(状态):LangGraph 运行过程中的共享数据结构,用于表示应用在某一时刻的状态快照。它承载了图运行所需的上下文信息、中间结果和后续节点需要读取的数据,是节点之间传递信息的核心载体。它与我们在学习 LangChain Agent 时使用的 State 是同一概念。

Node(节点):LangGraph 中的具体执行单元,通常实现为一个函数。节点会读取当前 State,执行相应的业务逻辑,并返回对 State 的局部更新。节点本身并不直接修改全局状态,状态的合并与提交由运行时统一完成。

Edges(边):用于定义节点之间的流转关系,决定一个节点执行完成后下一步应该进入哪个节点。Edge 可以是固定流转,也可以根据当前 State 进行条件判断,从而实现分支、循环等复杂控制流程。

下图展示了一个非常简单的运行图,只有两个计算节点,其拓扑结构为

START --> node_1 --> node_2 --> END

如下图所示

1.2. 图运行过程

LangGraph 的图运行过程基于 Superstep(超步) 来组织和推进。Superstep 可以理解为图运行过程中的一次”单步循环”。一次图运行过程从开始到结束,就是由一系列连续的 Superstep 串联而成。

下文以 node_1执行完毕后的Superstep 为例说明。

每个 Superstep 通常可以分为三个阶段:

  1. 计划/路由阶段(Plan / Routing):根据当前的 State(状态) 和 Edge(边) 的逻辑,确定本轮超步中应该被执行的节点。
  1. 执行阶段(Execution):运行本轮被选中的节点。如果本轮有多个节点同时被触发,它们会并行执行。每个节点都会基于本轮开始时的状态快照进行计算,并输出各自对状态的局部更新。在本阶段中,一个节点产生的更新不会立即被其他节点读取到。
  1. 状态更新/提交阶段(Update / Commit):当本轮所有节点都执行完成后,LangGraph 会将它们的输出统一合并到 State 中,生成新的状态快照。这个新状态会作为下一轮 Superstep 的输入。

1.3. Graph API vs Functional API

LangGraph 提供了两种不同的 API 来构建运行图:Graph API(图式 API) 和 Functional API(函数式 API)。这两种 API 共享相同的底层运行时,可以在同一应用程序中协同使用,但它们针对不同的使用场景和开发偏好而设计。


1.3.1. Graph API

Graph API 采用声明式方式构建工作流。开发者需要显式定义 State、Node 和 Edge,将业务流程组织成一个可视化的图结构。

当流程中存在较复杂的分支、多个节点之间共享状态、并行执行、结果汇聚,或者需要通过图结构帮助调试和团队协作时,更适合使用 Graph API。官方文档也明确建议,在需要复杂流程可视化、显式状态管理、多条件分支、并行路径以及团队协作时,优先选择 Graph API。

总之:

Graph API 更适合构建结构清晰、节点关系复杂、需要长期维护的工作流。

典型场景包括:

场景说明
多节点复杂流程流程中存在多个处理节点,需要清晰表达节点之间的关系
条件分支较多根据 State 中的不同字段决定后续执行路径
并行执行与结果汇聚多个节点并行运行,之后汇总结果
多组件共享状态多个节点都需要读写同一个全局 State
需要图结构展示便于调试、讲解、文档化和团队协作

1.3.2. Functional API

Functional API 采用命令式方式构建工作流,更接近普通 Python 函数调用。开发者可以使用 @entrypoint 定义工作流入口,使用 @task 定义可被检查点记录的任务,然后在函数内部使用普通的 if/else、循环和函数调用来组织流程。官方文档指出,当已有过程式代码需要最小改造、流程主要是线性的、分支逻辑较简单、希望快速原型验证时,更适合使用 Functional API。

可以这样理解:

Functional API 更适合在普通 Python 函数流程中,以较低成本接入 LangGraph 的持久化、中断恢复和任务记录能力。

典型场景包括:

场景说明
现有代码改造原本已有函数式或过程式代码,不希望重构成完整图结构
线性流程主要是 A → B → C 的顺序执行
简单分支只有少量 if/else 判断
快速原型验证希望减少样板代码,快速验证业务逻辑
局部任务持久化希望某些函数作为独立 task 被检查点记录

1.3.3. 二者的核心区别

对比项Graph APIFunctional API
编程风格声明式图结构命令式函数流程
核心抽象State、Node、Edgeentrypoint、task
状态管理显式定义全局 State更多依赖函数参数和返回值
流程表达通过节点和边表达通过普通 Python 控制流表达
可视化能力强,天然适合画图和调试弱,更像普通代码流程
适合场景复杂工作流、多分支、多节点协作简单流程、快速原型、已有代码改造
学习成本相对更高相对更低

1.3.4. 选型建议

从零构建或流程结构复杂,用 Graph API;现有代码改造、快速原型验证或流程逻辑简单,用 Functional API

学习 LangGraph 建议优先掌握 Graph API。因为后者更能体现 LangGraph 的核心思想:通过 State、Node、Edge 显式描述一个可执行的计算图。

本文介绍 Graph API,对 Functional API 感兴趣的同学自行查阅

Functional API overview - Docs by LangChain

2. 图的基础构建与运行

本节将从一个基础案例入手,掌握图的基本创建、调用和可视化方法。

图的构建与运行分为三个阶段:定义状态图、编译状态图、调用状态图

2.1. 定义状态图

状态图的定义可以分为以下几个步骤:

1. 定义全局状态 定义整个运行图共享的全局状态(实际上,除了全局状态,LangGraph还支持其它类型的状态,下文详述)

2. 创建状态图 创建 StateGraph实例,将状态和状态图绑定

3. 定义图结构 定义节点和边并添加到状态图中

2.1.1. 定义全局状态

全局状态是 LangGraph 运行图每个节点都可以访问的公共对象。

class OverAllState(TypedDict):
    logs: Annotated[list[str], add]
    cur_id: str

2.1.2. 创建状态图

builder = StateGraph(state_schema=OverAllState)

2.1.3. 定义图结构

2.1.3.1. 定义图节点
def node_1(state: OverAllState) -> OverAllState:
    pre_id = state["cur_id"]
    return {
        "logs": ["node_1 运行完毕"],
        "cur_id": pre_id + ", node_1"
    }

def node_2(state: OverAllState) -> OverAllState:
    pre_id = state["cur_id"]
    return {
        "logs": ["node_2 运行完毕"],
        "cur_id": pre_id + ", node_2"
    }
2.1.3.2. 添加节点
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
2.1.3.3. 添加边
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", END)

2.2. 编译状态图

将上一步得到的状态图编译为可执行的运行图

graph = builder.compile()

2.3. 调用状态图

可以通过invoke调用计算图

print(graph.invoke({"cur_id": "start"}))

2.4. 完整代码

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from operator import add

class OverAllState(TypedDict):
    logs: Annotated[list[str], add]
    cur_id: str

def node_1(state: OverAllState) -> OverAllState:
    pre_id = state["cur_id"]
    return {
        "logs": ["node_1 运行完毕"],
        "cur_id": pre_id + ", node_1"
    }

def node_2(state: OverAllState) -> OverAllState:
    pre_id = state["cur_id"]
    return {
        "logs": ["node_2 运行完毕"],
        "cur_id": pre_id + ", node_2"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", END)

graph = builder.compile()

print(graph.invoke({"cur_id": "start"}))

运行结果如下

{'logs': ['node_1 运行完毕', 'node_2 运行完毕'], 'cur_id': 'start, node_1, node_2'}

2.5. 图结构可视化

2.5.1. 绘制mermaid并获取源码

mermaid是一种生成图表和流程图的文本标记语言,它可以把简单的文本描述直接渲染成可视化图形。

通过以下代码可以用mermaid语法表达图结构,并打印mermaid源码

raw_mermaid = graph.get_graph().draw_mermaid()
print(raw_mermaid)

输出如下

---
config:
  flowchart:
    curve: linear
---
graph TD;
    __start__([<p>__start__</p>]):::first
    node_1(node_1)
    node_2(node_2)
    __end__([<p>__end__</p>]):::last
    __start__ --> node_1;
    node_1 --> node_2;
    node_2 --> __end__;
    classDef default fill:#f2f0ff,line-height:1.2
    classDef first fill-opacity:0
    classDef last fill:#bfb6fcmermaidmermain

输出文本从graph TD;开始的部分为mermaid源码。

__start__

node_1

node_2

__end__

2.5.2. 绘制mermaid并转换为png

Langgraph也支持将mermaid转换为图片字节流

png_bytes = graph.get_graph().draw_mermaid_png()

draw_mermaid_png()底层会先调用draw_mermaid()得到mermaid语法的图表代码,然后调用在线服务渲染为mermaid图片。

默认的mermaid在线渲染服务链接为:https://mermaid.ink,由于网络问题可能存在渲染失败的情况,通常重试即可成功。

可以通过draw_mermaid_png()base_url参数替换可用的mermaid在线渲染服务,

2.5.2.1. jupyter环境下直接展示图片

在jupyter环境下,可以通过IPython提供的功能将图片字节流直接展示为图片。

from IPython.display import display, Image

png_bytes = graph.get_graph().draw_mermaid_png()
png = Image(png_bytes)
display(png)

运行结果如下所示

2.5.2.2. 保存为图片文件

也可以将图片字节流转换为PNG文件落盘

png_bytes = graph.get_graph().draw_mermaid_png()
png_filename = 'first_demo_graph.png'
with open(png_filename, "wb") as f:
    f.write(png_bytes)

运行结束后,代码文件同级目录下将会出现生成好的图片文件,如下图所示。

如果看不到文件,可以刷新上级目录

图片内容如下

2.5.2.3. 快捷用法

在 Jupyter 环境下,可以直接使用 display(graph) 快速展示编译后的图结构:

from IPython.display import display

display(graph)

效果如下

3. 图的状态(State)管理

3.1. 状态定义

状态的定义实际上是在声明状态的Schema,后者是状态字段的完整描述。

官方推荐了三种定义Schema的方式:TypedDict、dataclass、Pydantic

3.1.1. TypedDict

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from operator import add

class OverAllState(TypedDict):
    logs: Annotated[list[str], add]
    cur_id: str

def node_1(state: OverAllState) -> OverAllState:
    pre_id = state["cur_id"]
    return {
        "logs": ["node_1 运行完毕"],
        "cur_id": pre_id + ", node_1"
    }

def node_2(state: OverAllState) -> OverAllState:
    pre_id = state["cur_id"]
    return {
        "logs": ["node_2 运行完毕"],
        "cur_id": pre_id + ", node_2"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", END)

graph = builder.compile()

print(graph.invoke({"cur_id": "start"}))

输出

{'logs': ['node_1 运行完毕', 'node_2 运行完毕'], 'cur_id': 'start, node_1, node_2'}

3.1.2. dataclass

属性调用方式由['字段名']变为.字段名

from langgraph.graph import StateGraph, START, END
from typing import Annotated
from dataclasses import dataclass
from operator import add

@dataclass
class OverAllState:
    logs: Annotated[list[str], add]
    cur_id: str

def node_1(state: OverAllState) -> OverAllState:
    pre_id = state.cur_id
    return {
        "logs": ["node_1 运行完毕"],
        "cur_id": pre_id + ", node_1"
    }

def node_2(state: OverAllState) -> OverAllState:
    pre_id = state.cur_id
    return {
        "logs": ["node_2 运行完毕"],
        "cur_id": pre_id + ", node_2"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", END)

graph = builder.compile()

print(graph.invoke({"cur_id": "start"}))

输出

{'logs': ['node_1 运行完毕', 'node_2 运行完毕'], 'cur_id': 'start, node_1, node_2'}

3.1.3. Pydantic

Pydantic模型的字段访问方式和dataclass相同。

from langgraph.graph import StateGraph, START, END
from typing import Annotated
from pydantic import BaseModel
from operator import add

class OverAllState(BaseModel):
    logs: Annotated[list[str], add]
    cur_id: str

def node_1(state: OverAllState) -> OverAllState:
    pre_id = state.cur_id
    return {
        "logs": ["node_1 运行完毕"],
        "cur_id": pre_id + ", node_1"
    }

def node_2(state: OverAllState) -> OverAllState:
    pre_id = state.cur_id
    return {
        "logs": ["node_2 运行完毕"],
        "cur_id": pre_id + ", node_2"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", END)

graph = builder.compile()

print(graph.invoke({"cur_id": "start"}))

输出

{'logs': ['node_1 运行完毕', 'node_2 运行完毕'], 'cur_id': 'start, node_1, node_2'}

3.1.4. 校验行为

学习LangChain的结构化输出时我们提到:Pydantic对格式要求最严格,如果模型返回的内容不符合结构化Schema的要求,则抛出ValidationError。而其余方式都不会对模型的返回结果进行校验,即便模型返回的内容不符合结构化要求,也会原样返回给用户。

而作为LangGraph计算图的状态时,这三种方式都要求字段名称完全一致。只是处理方式不同。具体规则如下

3.1.4.1. 输入字段不匹配
1. TypedDict

TypedDict将输入字段视为字典的Key,不匹配时抛出KeyError异常。

2. dataclass

dataclass将输入字段视为类的属性,不匹配时抛出TypeError(类型错误)异常。

3. Pydantic

Pydantic对输入字段进行校验,不匹配时抛出ValidationError异常。

3.1.4.2. 节点返回字段不匹配

图节点返回的是对于状态的更新,如果返回字段和状态字段不匹配,上述三种Schema定义方式的行为是统一的:状态更新会被忽略。

3.1.5. 推荐用法

在实际使用中,推荐优先使用 TypedDict 定义 LangGraph 状态图的 State Schema。

大多数官方案例也采用 TypedDict 方式定义状态 Schema。这种方式写法简洁、结构清晰,能够直接描述状态中包含哪些字段,以及每个字段对应的数据类型,非常适合用于定义图运行过程中的共享状态。

相比普通 dictTypedDict 可以提供更明确的字段约束和类型提示;相比 dataclassTypedDict 更贴近 LangGraph 中状态的更新方式,因为节点通常返回的是表示“部分状态更新”的字典,而不是完整对象;相比 Pydantic BaseModel,它又更加轻量,不会引入额外的数据校验开销。因此,在没有复杂校验需求的情况下,TypedDict 是定义 LangGraph State Schema 的首选方式。

3.2. State Reducer

3.2.1. 什么是State Reducer

State Reducer 是 LangGraph 中用于合并状态更新的核心机制。在 LangGraph 的 StateGraph 中,每个节点可以读取和写入共享状态,而 Reducer 定义了如何将多个节点对同一状态键的更新合并

Reducer 的核心特征:

  • 函数签名:(Value, Value) -> Value,接收当前值和更新值,返回合并后的新值
  • 注解定义:通过 Annotated[Type, reducer_function] 为状态键指定 Reducer
  • 默认行为:未指定 Reducer 的状态键使用覆盖策略(Last-Write-Wins)
3.2.2. 如何定义Reducer
3.2.2.1. 定义Reducer函数

Reducer 本质上是一个二元合并函数,用于定义当同一个字段产生多个更新值时,LangGraph 应该如何将这些值合并为一个最终结果。

函数签名:(Value, Value) -> Value

示例代码如下:

def my_reducer(left: list[str], right: list[str]) -> list[str]:
    return left + right

left = ['a', 'b']
right = ['c']
print(my_reducer(left, right))

其中,my_reducer 用于处理 list[str] 类型的数据。它接收两个列表参数:

  • left:当前已累计的状态值;
  • right:本次待合并的新值。

函数内部通过 left + right 将两个列表合并,并返回合并后的结果。

因此,该 Reducer 的作用是:当某个状态字段存在多次列表更新时,将这些列表内容追加合并,而不是直接覆盖原值。

运行结果如下

['a', 'b', 'c']
3.2.2.2. 将Reducer和状态字段关联

在 LangGraph 中,Reducer 通常通过 Python 的 typing.Annotated 与状态字段进行关联。

Annotated[] 是 Python 提供的一种类型注解扩展机制,用于在原始类型之外附加额外的元数据信息。需要注意的是,Annotated[] 本身并不规定这些元数据的具体含义,它只负责在类型注解中保留这些信息。

严格来说,Annotated 的第一个参数是被注解的原始类型,后续参数是附加的元数据。至于这些元数据表示什么、如何解析,则由使用它的框架或工具自行决定。

在 LangGraph 中,框架利用这一机制,将状态字段的类型和 Reducer 规则同时声明在字段定义中。其基本形式如下:

Annotated[Type, reducer_function]

其中:

  • Type:表示状态字段的数据类型;
  • reducer_function:表示该字段对应的 Reducer 函数。

示例代码如下:

from typing import TypedDict, Annotated

class OverAllState(TypedDict):
    logs: Annotated[list[str], my_reducer]
    cur_id: str

在上述代码中:

  • logs 字段的类型是 list[str]
  • my_reducer 是与 logs 字段关联的 Reducer 函数;
  • 当多个节点同时更新 logs 字段时,LangGraph 会使用 my_reducer 将多个列表合并;
  • cur_id 字段没有指定 Reducer,因此采用默认更新规则。
3.2.2.3. 常用内置Reducer函数
1. operator.add

operator.add 是 Python 内置的加法操作函数,底层由 C 实现

它接收两个参数,等价于 a(第一个参数)+b(第二个参数)

代码如下

from operator import add

print(f"{add(1,2) = }")
print(f"{add([1,2], [3,4]) = }")
print(f"{add(['a','b'], ['c']) = }")

输出如下

add(1,2) = 3
add([1,2], [3,4]) = [1, 2, 3, 4]
add(['a','b'], ['c']) = ['a', 'b', 'c']
2. langgraph.graph.message.add_messages

add_messages 是 LangGraph 中专用于合并消息列表的 Reducer 函数,常用于维护对话历史类的状态字段。其函数签名如下:

def add_messages(
    left: Messages,
    right: Messages,
    *,
    format: Literal["langchain-openai"] | None = None,
) -> Messages:
    ...
    return merged

参数说明:

  • left:状态中已有的消息列表;
  • right:当前节点返回的消息更新值;
  • format:可选参数,用于指定返回消息的格式,通常无需手动设置。

left 与 right 的类型均为 MessagesMessages 可以理解为 LangChain 消息对象的列表,其中每个元素都是 BaseMessage 或其子类的实例,常见子类包括:

  • HumanMessage:用户的输入消息;
  • AIMessage:AI 的回复消息;
  • SystemMessage:系统提示消息;
  • ToolMessage:工具调用的结果消息。

add_messages 处理的是对话消息序列,而非普通的字符串列表。

BaseMessage 包含一个可选的 id 属性,用于唯一标识一条消息。add_messages 在合并 left 与 right 时,不是简单地执行列表拼接,而是依据消息的 id 进行合并:

  • 若 right 中的某条消息的 id 在 left 中不存在,则将该消息追加到结果列表末尾;
  • 若 right 中的某条消息的 id 与 left 中已有消息的 id 相同,则使用 right 中的新消息替换 left 中的旧消息。

因此,add_messages 的作用可以概括为:在保留历史消息的基础上追加新消息,并允许通过相同的消息 id 覆盖已有消息。

需要特别说明,add_messages 并非简单地对 left 与 right 求“并集”。更准确地说,它是一个基于消息 id 的消息列表合并函数:既支持追加新消息,也支持更新已有消息。

可以理解为:

merged = left + right

但若 right 中存在与 left 相同 id 的消息,则最终结果中不会出现重复消息,而是用 right 中的消息覆盖 left 中对应的旧消息。

示例代码如下

from langgraph.graph.message import add_messages
from langchain.messages import HumanMessage, AIMessage, SystemMessage

left = [
    SystemMessage(content="你是个善解人意的助手", id='1'),
    HumanMessage(content="你好", id='2'),
    AIMessage(content="你好~", id='3'),
]

right = [
    HumanMessage(content="我是老王,你是小王", id='2'),
    AIMessage(content="好的,我记住啦", id='3'),
    HumanMessage(content="你是谁?", id='4'),
    AIMessage(content="我是小王", id='5'),
]

merged = add_messages(left, right)

for msg in merged:
    print(msg)

输出如下

content='你是个善解人意的助手' additional_kwargs={} response_metadata={} id='1'
content='我是老王,你是小王' additional_kwargs={} response_metadata={} id='2'
content='好的,我记住啦' additional_kwargs={} response_metadata={} id='3' tool_calls=[] invalid_tool_calls=[]
content='你是谁?' additional_kwargs={} response_metadata={} id='4'
content='我是小王' additional_kwargs={} response_metadata={} id='5' tool_calls=[] invalid_tool_calls=[]

3.2.3. 默认行为

如果某个 State 字段没有显式定义 Reducer,LangGraph 会使用默认的状态更新行为:后一次更新值会覆盖该字段原有的状态值。

换句话说,当节点返回的更新结果中包含某个字段时,如果该字段没有配置 Reducer,LangGraph 不会对新旧值进行追加、合并或累加,而是直接使用本次返回的新值替换原来的旧值。

示例代码如下:

from langgraph.graph import StateGraph, START, END
from typing import TypedDict

class OverAllState(TypedDict):
    logs: list[str]
    id: str

def node_a(state: OverAllState):
    return {
        "logs": ["node_a"],
        "id": "node_a"
    }

def node_b(state: OverAllState):
    return {
        "logs": ["node_b"],
        "id": "node_b"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)

graph = builder.compile()
result = graph.invoke({"logs": ["START"], "id": "start"})
print('=' * 30, '-> result <-', '=' * 30)
print(result)

输出如下

============================== -> result <- ==============================
{'logs': ['node_b'], 'id': 'node_b'}

可以看到,logs字段和id字段都没有定义Reducer,因此,节点返回的新值会覆盖初始状态中的旧值,图运行结果中的状态值和最后一次更新保持一致。

3.3. 节点中访问State

3.3.1. 图节点中读取State

在 LangGraph 中,节点本质上是一个可调用对象,通常定义为普通 Python 函数。节点函数被执行时,LangGraph 会自动将当前图运行到该节点时的 State 传入节点函数。

节点函数的第一个参数通常是当前运行图的状态对象,也就是 State

def node(state: StateSchema):
    ...

其中,state 表示当前节点执行时可以访问到的全局状态快照。节点可以通过读取 state 中的字段获取上游节点写入的数据,并基于这些数据完成当前节点的业务逻辑。

示例代码如下:

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from operator import add

class OverAllState(TypedDict):
    logs: Annotated[list[str], add]
    id: str

def node_a(state: OverAllState):
    for k, v in state.items():
        print(f"k: {k}, v: {v}")

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", END)

graph = builder.compile()
result = graph.invoke({"logs": ["START"], "id": "start"})

输出如下

k: logs, v: ['START']
k: id, v: start

3.3.2. 图节点更新State

在 LangGraph 中,节点函数通常不需要返回更新后的完整状态,只需要返回本节点对状态的局部更新。

也就是说,节点的返回值可以只包含需要修改的状态字段。

  • 对于节点没有返回的字段,LangGraph 会保留其原有状态值;

  • 对于节点返回的字段,LangGraph 会根据该字段是否配置了 Reducer 来决定如何合并更新值。

    • 如果字段配置了 Reducer,则使用对应的 Reducer 函数将旧值和新值合并;
    • 如果字段没有配置 Reducer,则按照默认规则使用节点返回的新值覆盖原值。

LangGraph运行时会按照状态字段的Reducer函数将其与当前的最新状态合并。

示例代码如下:

from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from operator import add

class OverAllState(TypedDict):
    logs: Annotated[list[str], add]
    id: str

def node_a(state: OverAllState):
    for k, v in state.items():
        print(f"k: {k}, v: {v}")
    return {
        "logs": ["node_a 更新状态"]
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", END)

graph = builder.compile()
result = graph.invoke({"logs": ["START"], "id": "start"})
print('=' * 30, '-> result <-', '=' * 30)
print(result)

输出如下

k: logs, v: ['START']
k: id, v: start
============================== -> result <- ==============================
{'logs': ['START', 'node_a 更新状态'], 'id': 'start'}

在上述示例中:

  • logs 字段通过 Annotated[list[str], add] 绑定了 Reducer 函数 operator.add,因此 LangGraph 会将原有的 logs 值和 node_a 返回的新 logs 值进行列表拼接:
["START"] + ["node_a 更新状态"]

最终得到:

["START", "node_a 更新状态"]
  • id 字段没有出现在 node_a 的返回值中,因此该字段不会被更新。图运行结束后,输出状态中的 id 仍然保持输入时的值:
"id": "start"

因此,LangGraph 节点更新 State 的核心规则可以概括为:

节点只返回需要更新的字段;未返回的字段保持不变;返回的字段根据是否配置 Reducer 决定是合并还是覆盖。

3.3.3. Overwrite绕过Reducer

在前面的示例中,如果某个状态字段定义了 Reducer,那么节点返回该字段的更新值时,LangGraph 默认会通过对应的 Reducer 将新值与已有状态值进行合并。

在某些场景下,我们可能并不希望继续执行 Reducer 的聚合逻辑,而是希望本次更新直接覆盖旧值。这时可以使用 Overwrite

Overwrite 的作用是:告诉 LangGraph 本次状态更新不走该字段原本定义的 Reducer,而是直接用新值覆盖状态中的旧值。

需要注意的是,Overwrite 只影响当前这一次更新,并不会修改状态字段本身的 Reducer 定义。后续节点如果继续正常返回该字段的更新值,仍然会按照原来的 Reducer 逻辑进行合并。

示例代码如下:

from langgraph.graph import StateGraph, START, END
from langgraph.types import Overwrite
from typing import TypedDict, Annotated
from operator import add

class OverAllState(TypedDict):
    logs: Annotated[list[str], add]
    id: str

def node_a(state: OverAllState):
    return {
        "logs": ["node_a"],
        "id": "node_a"
    }

def node_b(state: OverAllState):
    return {
        "logs": Overwrite(["node_b"]),
        "id": "node_b"
    }

def node_c(state: OverAllState):
    return {
        "logs": ["node_c"],
        "id": "node_c"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", "node_c")
builder.add_edge("node_c", END)

graph = builder.compile()
result = graph.invoke({"logs": ["START"], "id": "start"})
print('=' * 30, '-> result <-', '=' * 30)
print(result)

输出如下

============================== -> result <- ==============================
{'logs': ['node_b', 'node_c'], 'id': 'node_c'}

上述代码中:

  • logs 字段绑定了 operator.add() 函数
    • 如果没有 Overwrite,则最终输出的 logs 的值应为 ['START', 'node_a', 'node_b', 'node_c']
    • node_b 返回更新时,用 Overwrite 包裹了 logs 字段的值,那么当前状态的 logs 会被 ["node_b"] 覆盖,因此最终输出的 logs 字段值变成了 ['node_b', 'node_c']
  • id 字段按照默认行为,保留最后一次更新的值。

3.4. Multi Schema用法

3.4.1. 状态类型

LangGraph 支持在一个图中使用多个状态 Schema,用于区分图的外部输入、外部输出、内部共享状态以及节点间的临时状态。

常见状态类型可以分为以下几类:

  • 全局状态 / 内部状态:图内部主要使用的状态,创建 StateGraph 时传递给 state_schema 参数。它通常包含图运行过程中需要读写的大部分字段。
  • 输入状态:图对外接收输入时使用的状态,创建 StateGraph 时传递给 input_schema 参数。它用于约束调用图时允许传入哪些字段。
  • 输出状态:图最终对外返回结果时使用的状态,创建 StateGraph 时传递给 output_schema 参数。它用于约束图运行结束后只返回哪些字段。
  • 私有状态:图内部节点之间传递的临时状态,通常不作为图的输入,也不作为图的最终输出。它可以通过节点函数的入参类型注解声明,并在节点返回值中写入。

需要注意,输入状态和输出状态主要面向图的边界,即“图如何接收外部输入”和“图如何返回外部结果”;而全局状态和私有状态主要面向图内部节点之间的数据传递。

3.4.2. 状态之间的关系

3.4.2.1. 设计规范

本节主要说明 LangGraph 状态设计中的规范。以下规则属于工程上的最佳实践,违反这些规范未必一定导致程序报错,但容易降低代码的可读性和可维护性。

  1. 输入状态和输出状态通常应是全局状态的子集 输入状态描述图对外需要接收的数据,输出状态描述图最终需要返回的数据。通常情况下,它们都应该是全局状态的一部分。 如:

    class InputState(TypedDict):
        username: str
    
    class OutputState(TypedDict):
        graph_output: str
    
    class OverAllState(TypedDict):
        username: str
        nickname: str
        graph_output: str

    其中,InputState 和 OutputState 的所有字段均存在于 OverAllState 中。

  2. 私有状态和全局状态应尽量避免字段重名 私有状态的定位是图内部某些节点之间传递的临时字段。如果私有状态字段和全局状态字段重名,虽然某些情况下程序仍然可以运行,但容易让人误以为该字段是全局共享字段,从而造成理解混乱。 因此,推荐让私有状态字段和全局状态字段保持清晰边界。

  3. 节点函数应明确声明入参状态类型和返回状态类型 节点函数的第一个参数通常是当前节点可读取的状态。通过类型注解声明该参数,可以明确表达该节点需要读取哪些字段。 同时,给节点函数声明返回状态类型,也可以帮助阅读者理解该节点会更新哪些字段。 例如:

    def node_1(state: InputState) -> OverAllState:
        return {
            "nickname": "Dear " + state["username"]
        }
  4. 节点函数中不应该访问入参状态类型中不存在的字段 节点实际接收到的状态会按照其入参类型进行裁剪。因此,如果节点入参声明为 InputState,就不应该在节点内部访问 InputState 中不存在的字段。 例如:

    def node_1(state: InputState) -> OverAllState:
        return {
            "nickname": state["username"]
        }

    如果在该函数中访问:

    state["nickname"]

    而 nickname 不属于 InputState,运行时就可能抛出 KeyError

  5. 节点函数返回的字典应尽量和返回类型注解保持一致 从 Python 类型注解的角度看,函数返回类型只是静态提示,运行时不会自动强制校验。 从 LangGraph 的运行机制看,节点返回的是对状态的部分更新,不是完整状态。只要返回字段已经被图记录为可用状态字段,LangGraph 就可以将其作为状态更新处理。 不过,从工程规范上讲,节点返回字典中的字段最好和函数返回类型注解保持一致,这样更利于阅读、调试和维护。

3.4.2.2. 源码层面的约束

本节从底层机制角度说明 LangGraph 如何记录、裁剪和更新状态。

3.4.2.2.1. 状态的记录

LangGraph 的状态并不是简单保存在一个普通字典中,而是会被拆分成多个可读写的状态字段。每个状态字段在底层通常对应一个 Channel

这些状态字段会在不同阶段被记录到状态图中。

  1. StateGraph 记录状态字段的核心方法是 _add_schema() _add_schema() 会解析传入的状态 Schema,并将其中声明的字段记录到图中,使这些字段成为图运行时可以读写的状态字段。

  2. 创建 StateGraph 时,会记录 state_schemainput_schema 和 output_schema 中的字段 当创建状态图时:

    builder = StateGraph(
        OverAllState,
        input_schema=InputState,
        output_schema=OutputState
    )

    LangGraph 会解析这些 Schema,并将其中涉及的字段加入图的状态管理体系。

  3. 调用 add_node() 添加节点时,也可能记录节点入参声明的状态 Schema 当添加节点时,LangGraph 会根据节点函数第一个参数的类型注解推断该节点的输入状态类型。 如果这个输入状态类型之前没有被图记录过,LangGraph 也会通过 _add_schema() 将其加入图中。 这也是私有状态能够生效的原因。 例如:

    class PrivateState(TypedDict):
        greeting: str
    
    def node_3(state: PrivateState) -> OutputState:
        return {
            "graph_output": state["greeting"]
        }

    当 node_3 被添加到图中时,PrivateState 中的 greeting 字段会被记录到图中,从而成为图内部可以传递的状态字段。

  4. 总结

    • 全局状态、输入状态、输出状态通常在创建 StateGraph 时被记录。
    • 私有状态通常在调用 add_node() 添加节点时,根据节点入参类型注解被记录。
    • 被记录后的状态字段,底层会成为图运行时可以读写的状态字段。
3.4.2.2.2. 状态的访问
  1. 调用图时,输入会按照 input_schema 进行约束 当调用图时:

    graph.invoke({"username": "小黄"})

    如果创建图时声明了 input_schema,那么外部输入会按照 input_schema 进行约束。 如果没有声明 input_schema,则通常按照 state_schema 作为图的输入 Schema。 因此,input_schema 的作用不是“只让第一个节点可见”,而是约束图的外部输入结构。 此处的约束是指:按照 schema 裁剪输入,只保留 schema 中出现的状态字段

  2. 节点接收到的状态会按照节点入参类型进行裁剪 每个节点能读取哪些字段,主要取决于该节点第一个参数的类型注解。 例如:

    def node_1(state: InputState) -> OverAllState:
        ...

    此时,node_1 接收到的 state 会按照 InputState 进行裁剪。即使图的全局状态中还有其他字段,node_1 也不应该访问不属于 InputState 的字段。 如果访问了入参状态中不存在的字段,例如:

    state["nickname"]

    就可能抛出:

    KeyError
  3. 节点返回的是状态更新,而不是完整状态 节点函数不需要返回完整状态,只需要返回本节点想要更新的字段。 例如:

    def node_1(state: InputState) -> OverAllState:
        return {
            "nickname": "Dear " + state["username"]
        }

    这里虽然返回类型注解是 OverAllState,但函数实际只返回了 nickname 一个字段。这是允许的,因为 LangGraph 会把节点返回值视为对状态的部分更新。

  4. 节点返回值的应用主要由字段名称和图中已记录的状态字段决定 节点返回的字典会根据字段名称写入对应状态字段,并按照该字段的 Reducer 规则进行合并。 需要注意的是,函数返回类型注解主要用于表达代码意图,不是严格的运行时写入边界。 也就是说,如果某个字段已经被图记录为可用状态字段,那么节点即使没有在返回类型注解中声明该字段,也可能仍然可以返回并更新它。 不过,为了代码清晰,仍然推荐让节点的返回值和返回类型注解保持一致。

  5. 最终输出会按照 output_schema 进行裁剪 图运行完成后,最终返回给外部调用方的结果会按照 output_schema 进行裁剪。 因此,output_schema 的作用不是“只让最后一个节点可见”,而是约束图最终对外暴露哪些字段。 例如,图内部状态中可能同时存在:

    username
    nickname
    greeting
    graph_output

    但如果 output_schema 只包含:

    graph_output

    那么最终 graph.invoke() 的返回结果就只会包含 graph_output

3.4.3. 案例

下面通过一个简单案例说明四类状态的定义和使用。

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class InputState(TypedDict):
    username: str

class OutputState(TypedDict):
    graph_output: str

class OverAllState(TypedDict):
    nickname: str
    username: str
    graph_output: str

class PrivateState(TypedDict):
    greeting: str

def node_1(state: InputState) -> OverAllState:
    # 向全局状态写入数据
    return {
        "nickname": "Dear " + state["username"]
    }

def node_2(state: OverAllState) -> PrivateState:
    # 从全局状态读取数据,写入私有状态
    return {
        "greeting": state["nickname"] + ", 早上好~"
    }

def node_3(state: PrivateState) -> OutputState:
    # 从私有状态读取数据,写入输出状态
    return {
        "graph_output": state["greeting"] + " 很高兴认识你!"
    }

builder = StateGraph(OverAllState,input_schema=InputState,output_schema=OutputState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_node("node_3", node_3)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", "node_3")
builder.add_edge("node_3", END)

graph = builder.compile()
print(graph.invoke({"username":"小黄"}))

输出如下

{'graph_output': 'Dear 小黄, 早上好~ 很高兴认识你!'}

3.4.4. 案例执行过程分析

上述案例中,各类状态的作用如下。

1. 输入状态
class InputState(TypedDict):
    username: str

InputState 用于约束图的外部输入。

因此调用图时,只需要传入:

{"username": "小黄"}
2. 全局状态
class OverAllState(TypedDict):
    username: str
    nickname: str
    graph_output: str

OverAllState 是图内部主要使用的状态 Schema。

其中:

  • username 来自图的输入;
  • nickname 由 node_1 写入;
  • graph_output 由 node_3 写入,并最终作为图的输出返回。
3. 私有状态
class PrivateState(TypedDict):
    greeting: str

PrivateState 用于节点之间传递临时数据。

在本例中:

def node_2(state: OverAllState) -> PrivateState:
    return {
        "greeting": state["nickname"] + ", 早上好~"
    }

node_2 写入了 greeting 字段。

随后:

def node_3(state: PrivateState) -> OutputState:
    return {
        "graph_output": state["greeting"] + " 很高兴认识你!"
    }

node_3 通过 PrivateState 读取 greeting 字段,并生成最终输出。

4. 输出状态
class OutputState(TypedDict):
    graph_output: str

OutputState 用于约束图最终返回给外部调用方的数据。

虽然图内部运行过程中还存在 usernamenicknamegreeting 等字段,但最终结果只返回:

{'graph_output': 'Dear 小黄, 早上好~ 很高兴认识你!'}

这是因为图创建时声明了:

output_schema=OutputState

所以最终输出会按照 OutputState 进行裁剪。

3.5. 预定义状态

3.5.1. MessagesState

LangGraph 构建的计算图通常会和 LLM 结合使用,而 LLM 在运行过程中通常需要维护一组消息列表。为了提升开发效率,LangGraph 官方提供了一个预定义状态类型:langgraph.graph.message.MessagesState

开发者可以直接继承该状态类型,并在其基础上扩展自定义状态字段。

源码如下:

class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

由此可知,MessagesState 只有一个字段:messages

该字段的类型是列表,元素类型为 AnyMessage;同时,它通过 Annotated 绑定了内置 Reducer 函数 add_messages

add_messages 的完全限定名(英文全称 fully qualified name)是:

langgraph.graph.message.add_messages,正是上文 3.2.2.3.2 节介绍的内置 Reducer 函数。

示例如下:

from langchain_deepseek import ChatDeepSeek
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langchain.messages import HumanMessage

from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model='deepseek-v4-flash',
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(MessagesState):
    username: str
    output: str

def node_a(state: OverAllState) -> OverAllState:
    return {
        "messages": [HumanMessage("你好,我是 " + state["username"])]
    }

def llm_node(state: OverAllState) -> OverAllState:
    res = model.invoke(state["messages"])

    return {
        "messages": [res],
        "output": res.content
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("llm_node", llm_node)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "llm_node")
builder.add_edge("llm_node", END)

graph = builder.compile()
response = graph.invoke({"username": "小黄"})
print(response)

输出如下

{
    "messages": [
        HumanMessage(
            content="你好,我是 小黄",
            additional_kwargs={},
            response_metadata={},
            id="155e1ef4-5bbc-4250-b978-62a7a5918cef",
        ),
        AIMessage(
            content="你好呀,小黄!😊 我是DeepSeek,很高兴认识你!有什么我可以帮你的吗?无论是聊天、解答问题、帮你写作、编程,还是其他任何需要,尽管告诉我吧!你名字里的“黄”是哪个黄呀?😄",
            additional_kwargs={
                "refusal": "None",
            },
            response_metadata={
                "token_usage": {
                    "completion_tokens": 57,
                    "prompt_tokens": 10,
                    "total_tokens": 67,
                    "completion_tokens_details": "None",
                    "prompt_tokens_details": {
                        "audio_tokens": "None",
                        "cached_tokens": 0,
                    },
                    "prompt_cache_hit_tokens": 0,
                    "prompt_cache_miss_tokens": 10,
                },
                "model_provider": "deepseek",
                "model_name": "deepseek-v4-flash",
                "system_fingerprint": "fp_8b330d02d0_prod0820_fp8_kvcache_20260402",
                "id": "17d57633-2ffb-4af3-be87-b3d58f9acd2b",
                "finish_reason": "stop",
                "logprobs": "None",
            },
            id="lc_run--019e6890-4544-7212-b4f2-aa20fc079911-0",
            tool_calls=[],
            invalid_tool_calls=[],
            usage_metadata={
                "input_tokens": 10,
                "output_tokens": 57,
                "total_tokens": 67,
                "input_token_details": {
                    "cache_read": 0,
                },
                "output_token_details": {},
            },
        ),
    ],
    "username": "小黄",
    "output": "你好呀,小黄!😊 我是DeepSeek,很高兴认识你!有什么我可以帮你的吗?无论是聊天、解答问题、帮你写作、编程,还是其他任何需要,尽管告诉我吧!你名字里的“黄”是哪个黄呀?😄",
}

上述案例中,OverAllState 继承了 MessagesState,因此可用的状态字段为

messages
username
output

只关注 messages 状态,执行流程如下:

  1. node_a 返回一条 HumanMessage
  2. LangGraph 使用 add_messages 将该消息合并到 messages 状态字段中;
  3. llm_node 从 state["messages"] 中读取完整消息列表,并调用模型;
  4. llm_node 将模型生成的 AIMessage 作为状态更新返回;
  5. LangGraph 再次通过 add_messages 将 AIMessage 合并到 messages 中;
  6. 最终状态中包含完整消息列表。

MessagesState 帮开发者预先定义好了 messages 字段及其合并规则。在构建聊天机器人、Agent、工具调用流程、多轮对话流程时,它可以提升开发效率。

3.5.2. AgentState

AgentState 是 LangChain Agent 内部使用的状态类型。由于 LangChain Agent 底层也是基于 LangGraph 运行图构建的,所以从技术上讲,开发者也可以将 AgentState 或其子类作为自定义 LangGraph 的状态类型。

AgentState 的全类名,也可以称为类的完全限定名(英文全称: fully qualified class name)是:langchain.agents.middleware.types.AgentState

源码如下

class AgentState(TypedDict, Generic[ResponseT]):
    """State schema for the agent."""

    messages: Required[Annotated[list[AnyMessage], add_messages]]
    jump_to: NotRequired[Annotated[JumpTo | None, EphemeralValue, PrivateStateAttr]]
    structured_response: NotRequired[Annotated[ResponseT, OmitFromInput]]

该状态中主要包含三个字段。

  1. messages
messages: Required[Annotated[list[AnyMessage], add_messages]]

messages 用于存储 Agent 运行过程中的消息列表。

该字段和 MessagesState 中的 messages 字段类似,也使用 add_messages 作为 Reducer

  1. jump_to
jump_to: NotRequired[Annotated[JumpTo | None, EphemeralValue, PrivateStateAttr]]

jump_to 是 LangChain Agent 内部使用的控制字段,主要服务于 Agent 中间件体系。

它通常用于表示运行流程的跳转意图,例如某些中间件希望影响 Agent 后续应该进入哪个节点。

需要注意的是,jump_to 并不是普通 LangGraph 状态图中的通用跳转机制。

在自定义 StateGraph 中,即使状态中定义了 jump_to 字段,LangGraph 也不会因为该字段的值自动跳转到某个节点。普通 LangGraph 运行图如果需要控制后续流向,通常应使用:

Command(goto="node_name")

见下文。

  1. structured_response
structured_response: NotRequired[Annotated[ResponseT, OmitFromInput]]

structured_response 用于存储 Agent 最终生成的结构化输出。

当使用 LangChain Agent 的结构化输出能力时,例如指定 response_format,Agent 最终生成的结构化结果通常会被写入该字段。

其中,OmitFromInput 表示该字段不应作为外部输入字段暴露给调用方,而是由 Agent 运行过程中内部生成。

总体来看,AgentState 是专门为 LangChain Agent 运行时设计的状态类型。

因此,在普通自定义 LangGraph 项目中,一般不建议直接基于 AgentState 扩展图状态。

4. 控制流

4.1. 顺序结构

4.1.1. add_edge

add_edge 用于在两个节点之间添加一条有向边。边是图结构中最基本的元素之一,节点之间的执行顺序、分支跳转以及循环控制,最终都依赖节点和边共同表达。

因此,在 LangGraph 中,基础的控制流结构都可以通过 add_edge 进行构建。

示例如下:

from langgraph.graph import StateGraph, START, END
from typing import TypedDict

class OverAllState(TypedDict):
    username: str
    greeting: str
    output: str

def node_a(state: OverAllState) -> OverAllState:
    return {
        "greeting": "Dear " + state["username"]
    }

def node_b(state: OverAllState) -> OverAllState:
    return {
        "output": state["greeting"] + ",你好!"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)

graph = builder.compile()
res = graph.invoke({"username": "小黄"})
print(res)

from IPython.display import display

display(graph)

输出如下:

{
  "username": "小黄",
  "greeting": "Dear 小黄",
  "output": "Dear 小黄,你好!"
}

在上述示例中,图的执行流程如下:

START -> node_a -> node_b -> END

其中:

  • START -> node_a 表示图从 node_a 开始执行;
  • node_a -> node_b 表示 node_a 执行完成后,继续执行 node_b
  • node_b -> END 表示 node_b 执行完成后,图进入结束状态。

4.1.2. add_sequence

如果需要构建一组按顺序执行的节点,也可以使用 add_sequence

add_sequence 支持传入一个可执行对象列表。LangGraph 会按照列表顺序依次添加节点,并在相邻节点之间自动添加边。默认情况下,函数名会被用作节点名称。

示例如下:

from langgraph.graph import StateGraph, START, END
from typing import TypedDict

class OverAllState(TypedDict):
    username: str
    greeting: str
    output: str

def node_a(state: OverAllState) -> OverAllState:
    return {
        "greeting": "Dear " + state["username"]
    }

def node_b(state: OverAllState) -> OverAllState:
    return {
        "output": state["greeting"] + ",你好!"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_edge(START, "node_a")
builder.add_sequence([node_a, node_b])
builder.add_edge("node_b", END)

graph = builder.compile()
res = graph.invoke({"username": "小黄"})
print(res)

from IPython.display import display

display(graph)

输出如下

{
  "username": "小黄",
  "greeting": "Dear 小黄",
  "output": "Dear 小黄,你好!"
}

上述代码中:

builder.add_sequence([node_a, node_b])

大致等价于:

builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge("node_a", "node_b")

因此,完整执行流程仍然是:

START -> node_a -> node_b -> END

相比手动调用多次 add_node 和 add_edgeadd_sequence 更适合用于构建简单的线性执行流程。

4.1.3. 省略指向 END 的边

在 LangGraph 中,图的终止并不完全依赖某个真实执行的特殊节点。

从运行机制上看,LangGraph 会在每个 SuperStep 开始时,根据当前发生更新的 Channel 以及节点之间的触发关系,计算本轮需要执行的任务列表。如果没有新的节点被激活,也就没有新的任务需要执行,图运行自然结束。

需要注意的是,END 并不是运行阶段真正执行的节点。也就是说,指向 END 的边不会像指向普通节点的边那样,触发一个真实的节点任务。它更多用于表达图结构中的“终止语义”:当前路径执行到这里即可结束。

因此,在一些简单的线性流程中,即使省略指向 END 的边,最后一个节点执行完成后,如果没有后续节点被触发,图也可以正常结束。

示例如下:

from langgraph.graph import StateGraph, START
from typing import TypedDict

class OverAllState(TypedDict):
    username: str
    greeting: str
    output: str

def node_a(state: OverAllState) -> OverAllState:
    return {
        "greeting": "Dear " + state["username"]
    }

def node_b(state: OverAllState) -> OverAllState:
    return {
        "output": state["greeting"] + ",你好!"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")

graph = builder.compile()
res = graph.invoke({"username": "小黄"})
print(res)

from IPython.display import display

display(graph)

输出如下

{
  "username": "小黄",
  "greeting": "Dear 小黄",
  "output": "Dear 小黄,你好!"
}

builder.add_edge("node_b", END)

但是 node_b 执行完成后,没有新的节点被触发,图运行会自然结束。

不过,可以省略指向 END 的边,并不表示 END 没有意义

显式添加 END 边可以让图结构更加完整,也能更清晰地表达“流程到此结束”的语义。尤其是在分支、条件跳转、循环退出等场景中,显式指向 END 通常更利于阅读和维护。

和 END 不同,START 通常不能省略。因为 START 不只是一个语义上的起点标记,它还用于告诉 LangGraph:图运行时应当从哪些节点开始执行。也就是说,下面这条边是启动图执行的关键:

builder.add_edge(START, "node_a")

如果没有从 START 出发的边,或者没有通过其他方式指定入口节点,LangGraph 就无法确定图的初始执行节点。

因此,在实际开发中,建议保留指向 END 的边。虽然在某些简单流程中省略这些边也能正常运行,但显式添加它们,可以让图结构更加完整、语义更加清晰,便于后续维护。

4.2. 分支结构

4.2.1. 静态分支(Static Branch)

  • 定义:节点的下游候选节点 在图编译阶段就完全确定,只是运行时根据条件选择哪条边执行。
  • 特点:
    • 下游节点集合固定,数量、目标在编译时确定
    • 运行时可选择一个或多个下游目标
    • 可以用来做条件分支,但不生成新的节点

⚡ 核心判断:

编译期知道下游集合 → 静态分支


4.2.1.1. 并行节点

并行节点是最简单的静态分支形式。

当多个节点都从同一个上游节点触发时,它们会在同一个超步被激活。典型写法如下:

builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")

示例:两个节点并行执行

from langgraph.graph import StateGraph, START, END
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langchain.messages import HumanMessage

from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke(
        [
            HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")
        ]
    ).content
    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke(
        [
            HumanMessage(f"写一个关于 {state['topic']} 的笑话")
        ]
    ).content
    return {
        "joke": joke
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")

builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

graph = builder.compile()
res = graph.invoke({"topic": "猫咪"})
print(res)

from IPython.display import display

display(graph)

上述案例中,node_a 和 node_b 都由 START 触发。图运行时,二者会在同一个超步中被调度,它们各自读取当前状态并独立执行。

需要注意的是:

  • 这里的“并行”主要指调度语义上的并行;两个节点之间没有先后依赖;
  • 它们的输出会在当前超步执行完成后统一合并到状态中;
  • 如果两个节点写入同一个状态字段,则该字段通常需要配置合适的 Reducer,否则可能出现状态更新冲突,抛出 InvalidUpdateError 异常

输出如下

{
    "topic": "猫咪",
    "poem": "《猫咪》\n夜巡檐角步轻悄,昼卧花阴晒暖毛。\n偶逐流莺穿柳过,忽惊蝴蝶上眉梢。\n\n赏析:这首作品以猫咪日常为切入点,通过“夜巡檐角”、“昼卧花阴”展现其昼伏夜出的习性,“逐流莺”、“惊蝴蝶”则捕捉其灵动瞬间。全诗未著“猫”字,却以“轻悄步”、“晒暖毛”等细节勾勒出猫咪的形神特征,尾句“上眉梢”更将好奇神态融入蝴蝶意象,物我交融,余韵悠长。",
    "joke": "有一只猫走进了一家披萨店,店员问:“您好,请问您要什么?”\n猫淡定地说:“喵~(我要一个12寸的海鲜披萨,加双倍芝士。)”\n店员愣了愣,说:“猫先生,您确定?”\n猫点点头,店员只好照做,然后猫掏出钱包付了钱,趴在小桌子上优雅地吃了起来。\n\n这时,旁边桌的客人看呆了,忍不住问店员:“它怎么能像人一样点餐、付钱、吃披萨?”\n店员耸耸肩,小声说:“我也不知道,但自从它上次用我的电脑打了一局《猫里奥》通关后,我就没敢拒绝它的任何要求。”"
}

4.2.1.2. 条件分支

StateGraph 提供了 add_conditional_edges 方法,用于从某个上游节点出发,根据运行时状态选择下游节点。

方法签名如下

def add_conditional_edges(
    self,
    source: str,
    path: Callable[..., Hashable | Sequence[Hashable]]
    | Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
    | Runnable[Any, Hashable | Sequence[Hashable]],
    path_map: dict[Hashable, str] | list[str] | None = None,
) -> Self:

不考虑 self,核心参数有三个:

  • source:条件分支的起始节点;
  • path:路由规则,是一个可执行对象,通常是函数
  • path_map:路由规则的返回值到真实节点名之间的映射关系。

其中,path 的返回值表示跳转的目标节点,可以是:

  • 字符串或特殊对象 END 表示的单个目标;
  • 字符串或特殊对象 END 表示的多个目标组成的序列;

path_map

  • 可以省略,即取默认值 None,此时 path 返回值中出现的字符串必须是合法的节点名称。

  • 可以是字典,维护 path 返回值和真实节点的映射。

  • 也可以是列表,如下

    path_map=["node_a", "node_b", "node_c"]

    相当于

    path_map={
        "node_a": "node_a",
        "node_b": "node_b",
        "node_c": "node_c",
    }

    此时也要求 path 返回值中出现的字符串必须是合法的节点名称。

4.2.1.2.1. 不使用 path_map

如果不传 path_map,那么路由函数的返回值通常应当直接是图中的节点名称。如下

def router(state: OverAllState) -> Literal["node_a", "node_b"]: 
    if "诗" in state["content_type"]: 
        return "node_a" 
    return "node_b"

此时,router 返回的 "node_a" 和 "node_b" 必须能够直接对应图中已经注册的节点名。

完整案例如下

from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    content_type: str
    poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def router(state: OverAllState) -> Literal["node_a", "node_b"]:
    if "诗" in state["content_type"]:
        return "node_a"
    return "node_b"

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_conditional_edges(START, router)
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

graph = builder.compile()
poem_res = graph.invoke({"topic": "布偶狗", "content_type": "诗"})
joke_res = graph.invoke({"topic": "布偶狗", "content_type": "笑话"})

print('=' * 30, '-> poem_res <-', '=' * 30)
print(poem_res)
print('=' * 30, '-> joke_res <-', '=' * 30)
print(joke_res)

from IPython.display import display

display(graph)

输出如下

============================== -> poem_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "诗",
    "poem": "《咏布偶狗》\n绒身静卧似憨眠,无骨何妨守户前。\n不吠晨昏随主步,垂髫怜抱共跚连。\n\n注:我的诗中“无骨”暗喻布偶狗没有生命骨架,却依然守护着家门;“跚连”形容孩童与布偶狗相偕学步的蹒跚之态。通过“绒身”“垂髫”等意象,既保留了布偶狗的柔软特质,又赋予其超越玩偶的温情守护之意,在静态与动态的转换间实现了拟人化的诗意升华。"
}

============================== -> joke_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "笑话",
    "joke": "有一天,一只布偶狗在街上闲逛,遇到了一只真狗。\n\n真狗好奇地问:“你是什么品种?怎么看起来毛这么长、这么滑,眼神还这么呆?”\n\n布偶狗骄傲地挺起胸膛:“我可是最顶级的仿真布偶狗!纯手工缝制,水晶眼睛,进口棉花填充,咬不坏、摔不烂,还不用溜。”\n\n真狗愣了一下,问:“那你平时都干嘛?”\n\n布偶狗叹了口气:“看家呗……主人说我最大的优点就是——丢不了,谁捡了都想还回来,因为太假了,连肉都不香。”"
}

观察图结构可以发现,**__start__指向node_anode_b**的线是虚线,这表示节点的跳转是有条件的,不是固定的。

4.2.1.2.2. 使用 path_map

如果不希望路由函数直接返回节点名,而是返回业务语义更强的标识,可以使用 path_map 进行映射。如下:

def router(state: OverAllState) -> Literal["a", "b"]: 
    if "诗" in state["content_type"]: 
        return "a" 
    return "b"

builder.add_conditional_edges( 
    START,
    router,
    path_map={ 
        "a": "node_a", 
        "b": "node_b", 
    } 
)

此时返回的 "a" 和 "b" 可以不是图中已注册的节点名称,但要通过 path_map 映射到正确的节点

这种写法的好处是:

  • 路由函数可以返回业务含义更清晰的标签;
  • 图节点名称可以保持工程化命名;
  • 渲染图结构时,边上可以显示路由标签,使图更容易理解。

示例如下

from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    content_type: str
    poem: str
    ci_poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def router(state: OverAllState) -> Literal["a", "b"]:
    if "诗" in state["content_type"]:
        return "a"
    return "b"

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_conditional_edges(
    START,
    router,
    path_map={
        "a": "node_a",
        "b": "node_b",
    }
)
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

graph = builder.compile()
poem_res = graph.invoke({"topic": "布偶狗", "content_type": "诗"})
joke_res = graph.invoke({"topic": "布偶狗", "content_type": "笑话"})

print('=' * 30, '-> poem_res <-', '=' * 30)
print(poem_res)
print('=' * 30, '-> joke_res <-', '=' * 30)
print(joke_res)

from IPython.display import display

display(graph)

输出如下

============================== -> poem_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "诗",
    "poem": "《布偶狗》\n布偶妆成似犬形,绒毛细软眼如星。\n不吠不跳乖巧坐,疑是仙童降画屏。\n\n注:我的仿写创作思路是以玩具布偶狗为意象,通过“妆成似犬形”点明其拟态特征。后三句运用比喻(眼如星)、拟人(乖巧坐)、想象(仙童降画屏)手法,赋予静态玩偶动态神韵。末句以“仙童”暗喻其灵动气质,呼应首句的“布偶”属性,使全诗在虚实之间形成童趣与仙气的张力。"
}

============================== -> joke_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "笑话",
    "joke": "好的,这里有一个关于布偶狗的笑话:\n\n有一只布偶狗,它从来不叫,也从来不跑。它的主人很担心,就带它去看兽医。\n\n兽医检查了半天,推了推眼镜说:“你这狗啊,没别的毛病,就是太乖了,乖到连狗的身份都忘了。你知道它为什么这么安静吗?”\n\n主人摇摇头。\n\n兽医叹了口气,说:“因为它是个‘布偶’,没有嘴,也没有腿。它唯一的技能,就是等你给它配个‘程序’,然后坐在那里,假装自己是一只不会乱咬东西的电子狗。说真的,这笑话比它还冷。”"
}

观察图结构可以发现,**__start__指向node_anode_b的虚线上出现了文本ab,它们是路由函数返回的名称,通过path_map**映射到具体的下游节点。

4.2.1.2.3. 同时路由至多个节点

add_conditional_edges 也支持一次路由到多个下游节点。

1. 不用path_map

示例如下

from collections.abc import Sequence
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    content_type: str
    poem: str
    ci_poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def node_c(state: OverAllState) -> OverAllState:
    ci_poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的词")]).content

    return {
        "ci_poem": ci_poem
    }

def router(state: OverAllState) -> Sequence[Literal["node_a", "node_b", "node_c"]]:
    if "诗" in state["content_type"]:
        return ["node_a", "node_c"]
    return ["node_b", "node_c"]

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_conditional_edges(
    START,
    router
)
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)
builder.add_edge("node_c", END)

graph = builder.compile()
poem_res = graph.invoke({"topic": "布偶狗", "content_type": "诗"})
joke_res = graph.invoke({"topic": "布偶狗", "content_type": "笑话"})

print('=' * 30, '-> poem_res <-', '=' * 30)
print(poem_res)
print('=' * 30, '-> joke_res <-', '=' * 30)
print(joke_res)

from IPython.display import display

display(graph)

在这个例子中:

  • 当 content_type 包含 "诗" 时,同时触发 node_a 和 node_c
  • 否则同时触发 node_b 和 node_c

输出如下

============================== -> poem_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "诗",
    "poem": "《咏布偶狗》\n铁骨棉裘本异俦,垂头摇尾学温柔。\n非关皮相失真性,恐向人间惹旧愁。\n\n赏析:这首作品以布偶狗为题,通过“铁骨棉裘”的悖论意象,巧妙揭示材质与形态的错位。后两句笔锋一转,从拟态之“学温柔”深入至本真之“失真性”,结句“恐向人间惹旧愁”更以物喻人,赋予玩偶以灵性,暗含对世情虚伪的讽喻,余韵悠长。",
    "ci_poem": "《捣练子·布偶狗》\n针线巧,布绒柔。静卧妆台伴月幽。未解铃铛摇夜语,却随清梦上云舟。\n\n注:我的仿作将布偶狗拟人化,通过“针线巧,布绒柔”展现其手工质感,“伴月幽”营造静谧氛围。末句“随清梦上云舟”暗合现代人渴望逃离现实的隐喻,既保留古典词牌的意境美,又赋予布偶狗超越物象的情感寄托。"
}

============================== -> joke_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "笑话",
    "ci_poem": "《鹧鸪天·布偶狗》\n绒目含星尾作虹,垂蹄静卧笑春风。\n无人自戏绒球转,得主频摇铃铎浓。\n\n偎暖日,扑帘栊。娇憨总在爪痕中。\n痴心不解浮生事,一抱虚温万事空。\n\n注:本词以布偶狗为意象,通过“绒目含星”、“垂蹄静卧”等细节摹写其柔顺之态。下阕“偎暖日”、“扑帘栊”拟人化笔法,寄寓物我相忘之趣。结句“一抱虚温”暗喻繁华终归寂灭之理,将玩偶之趣升华为对生命本真的哲思。",
    "joke": "小明带他的布偶狗去宠物医院,医生检查后说:“你这狗没心跳没呼吸,已经死了。”\n小明急了:“不可能!它刚刚还对我摇尾巴!”\n医生叹了口气:“那是你兜里的钥匙串,走起路来叮当响,它尾巴上的铃铛在共振。”\n小明沉默片刻,掏出手机拍了张照发朋友圈:“我的狗死了,但它的尾巴还在听我走路。”\n评论区炸了:“建议主人也去查查脑子,可能也共振坏了。”"
}

观察图结构可以发现,node_a、**node_bnode_c**独立于图结构之外。

和上一节案例相比,**router**函数返回的是序列而非单个节点,渲染器无法推断节点间的映射关系。

2. 添加映射

通过 path_map 显示声明映射关系,明确下游节点集合,在提升代码可读性的同时,也有助于渲染器正确展示条件边。

当前场景下 path 返回值中的字符串就是合法的节点名称,path_map 可以是字典

path_map={
    "node_a": "node_a",
    "node_b": "node_b",
    "node_c": "node_c",
}

也可以是列表

path_map=["node_a", "node_b", "node_c"]

完整案例如下

from collections.abc import Sequence
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    content_type: str
    poem: str
    ci_poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def node_c(state: OverAllState) -> OverAllState:
    ci_poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的词")]).content

    return {
        "ci_poem": ci_poem
    }

def router(state: OverAllState) -> Sequence[Literal["node_a", "node_b", "node_c"]]:
    if "诗" in state["content_type"]:
        return ["node_a", "node_c"]
    return ["node_b", "node_c"]

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_conditional_edges(
    START,
    router,
    path_map={
        "node_a": "node_a",
        "node_b": "node_b",
        "node_c": "node_c",
    }
    # path_map=["node_a", "node_b", "node_c"]
)
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)
builder.add_edge("node_c", END)

graph = builder.compile()
poem_res = graph.invoke({"topic": "布偶狗", "content_type": "诗"})
joke_res = graph.invoke({"topic": "布偶狗", "content_type": "笑话"})

print('=' * 30, '-> poem_res <-', '=' * 30)
print(poem_res)
print('=' * 30, '-> joke_res <-', '=' * 30)
print(joke_res)

from IPython.display import display

display(graph)

输出如下

============================== -> poem_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "诗",
    "poem": "《布偶狗》\n布偶憨然卧锦茵,牵绳随主步香尘。\n偶因客至微昂首,原是无声座上宾。\n\n注:我的创作思路是抓住布偶狗静中有动的特质。首句以“憨然卧锦茵”勾勒其静态萌态,次句“牵绳随主步香尘”暗喻宠物与人的陪伴关系。转句“偶因客至微昂首”捕捉其反应,既保留布偶的拟真特性,又暗示机械性动作。结句“原是无声座上宾”点明其非生物属性,与开篇“布偶”形成闭环。全诗通过动静、真假的微妙转化,赋予静物以灵性。",
    "ci_poem": "《醉花阴·布偶狗》\n绒耳垂垂星作眸,憨卧小窗幽。棉絮裹憨态,线描眉眼,缝就三分柔。\n旧尘不染春衫袖,伴我度春秋。虽无温舌语,月斜人静,也解倚床头。\n\n注:我以拟人笔法赋予布偶狗灵性,通过“绒耳垂垂”“棉絮裹憨态”等细节勾勒其柔软形态,又于“月斜人静”处暗藏陪伴深情。下阕“旧尘不染”暗喻其纯净守护,末句“解倚床头”更将静物写活,道出玩偶与主人之间无声的慰藉。"
}

============================== -> joke_res <- ==============================

{
    "topic": "布偶狗",
    "content_type": "笑话",
    "ci_poem": "《南歌子·布偶狗》\n绒耳垂春水,圆睛印月痕。\n棉絮缝成憨态真。\n不向朱门摇尾,卧蓬门。\n\n旧线牵云袖,新绒补雪身。\n线头散作泪斑纹。\n但把残年缝进,掌中温。\n\n注:我的布偶狗,是外婆用旧棉裤的棉花和二姐穿小的灯芯绒外套一针一线缝成的。二十年过去,棉絮从它破了洞的耳朵里露出,像吐着舌头。我模仿陆游“我与狸奴不出门”的意境,通过“棉絮缝春水”喻指时光在针脚里流逝,而“线头泪斑”则是故意缝补的裂痕——我们总要在破碎的事物里,重新认出圆满的形状。",
    "joke": "好的,这是一个关于布偶狗的笑话:\n\n有一只布偶狗,它特别特别懒,懒到什么程度呢?它的主人叫它去捡飞盘,它都懒得动。主人决定训练它,就扔出一个飞盘,大声喊:“布偶,快去捡!”\n\n布偶狗懒洋洋地抬头看了一眼飞盘落在远处,然后又趴下了。主人很生气,又扔了一个,它还是不理。\n\n主人无奈,只好采取了终极手段——假扮成一只会动的布偶狗,自己冲过去把飞盘叼了回来。\n\n布偶狗一看,浑身毛发都惊得炸了起来,它对旁边的一只小花狗低声说:“天哪,你看,那玩意儿居然能自己动!它还是个永动机!吓死狗了,好赖哦!”"
}

如图所示,图结构被正确渲染。

4.2.1.3. defer node execution

某些情况下,我们希望在所有常规任务节点执行完毕后,再进行日志、审计等收尾工作

此时可以在添加节点时设置*defer=True*,如下:

builder.add_node("audit_node", audit_node, defer=True)

defer=True 的含义是:

当前节点不会在其被触发后立即执行,而是被延迟到常规图运行流程结束后,再在额外的超步中触发执行。

这类节点适合用于:

  • 日志记录;
  • 审计检查;
  • 结果汇总;
  • 收尾清理;
  • 统一校验前面节点是否已完成。
4.2.1.3.1. 底层实现机制

1. 编译阶段:使用特殊 Channel
  1. LangGraph 在编译状态图时,会为边创建对应的 Channel

  2. 此时会根据节点的 defer 属性创建不同类型的 Channel,如下。

    self.channels[branch_channel] = (
        LastValueAfterFinish(Any)
        if node.defer
        else EphemeralValue(Any, guard=False)
    )

    defer 默认值为 False

    对于普通节点,边对应的通道类型是 EphemeralValue,可以理解为普通临时通道;而对于 defer=True 的节点,边对应的通道类型是特殊的 LastValueAfterFinish

2. 常规运行阶段-写入但不触发
  1. 在图运行过程中,每个节点执行完成后,会向其下游边对应的 Channel 写入数据。
  2. 常规的 Channel 在运行开始后处于可用状态,被写入后记录在 updated_channels 列表中,从而在下一个超步中触发下游节点的执行。
  3. 但是,LastValueAfterFinish 类型的通道起初是不可用的,首次被写入时不会添加到 updated_channels 列表中,下游节点自然不会被触发。
3. 常规流程结束后:调用finish()唤醒延迟节点
  1. LangGraph 底层用 trigger_to_nodes 维护了 边的 Channel -> 节点 的映射,是一个字典。
  2. 在每个超步结束后,运行时会根据 updated_channels 判断是否还有新的节点需要被触发。
  3. 如果 updated_channels 和 trigger_to_nodes 的 key 没有交集,说明当前没有新的普通节点需要继续执行,常规运行流程已结束。
  4. 此时,LangGraph 运行时会调用所有 Channel 的 finish() 方法。
  5. 对于普通 Channel ,finish() 通常不会产生新的触发效果;但对于 LastValueAfterFinish 类型通道,首次调用 finish() 时,会将内部的 finished 标记设置为 True,并返回 True
  6. 一旦 finished=True,该通道的 is_available() 就会变为 True
  7. 于是,原本被延迟的通道会被加入 updated_channels,从而在额外的超步中触发对应的 defer 节点。
4. 总结

因此,defer=True 的运行机制可以概括为:

触发边的 Channel 为特殊类型,首次写入不触发;常规流程结束后,特殊通道 finish();通道变为可用;从而触发延迟节点,后者在额外超步中执行。

4.2.1.3.2. 案例

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from loguru import logger

from dotenv import load_dotenv

load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    joke: str

def node_a(state: OverAllState) -> OverAllState:
    poem = model.invoke([HumanMessage(f"写一首关于 {state['topic']} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_b(state: OverAllState) -> OverAllState:
    joke = model.invoke([HumanMessage(f"写一个关于 {state['topic']} 的笑话")]).content

    return {
        "joke": joke
    }

def audit_node(state: OverAllState) -> OverAllState:
    logger.info(f"任务节点全部执行完毕,诗 {'已生成' if state['poem'] else '未生成'},笑话 {'已生成' if state['joke'] else '未生成'}")

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("audit_node", audit_node, defer=True)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")
builder.add_edge(START, "audit_node")
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)
builder.add_edge("audit_node", END)

graph = builder.compile()
res = graph.invoke({"topic": "布偶狗"})
print(res)

from IPython.display import display

display(graph)

在这个图中:

  • node_a 和 node_b 是普通节点,在常规流程中被触发;
  • audit_node 虽然也由 START 触发,但由于设置了 defer=True,不会立即执行;
  • node_a 和 node_b 都执行完成后,常规运行流程结束,audit_node 才会在额外的超步中执行;
  • 因此,audit_node 中可以读取到 node_a 和 node_b 已经写入并提交后的状态。

输出如下

2026-05-28 17:07:09.618 | INFO | __main__:audit_node:40 - 任务节点全部执行完毕,诗 已生成,笑话 已生成

{
    'topic': '布偶狗',
    'poem': '《咏布偶狗》\n'
            '绸身棉腑自憨娇,静卧无眠守寂寥。\n'
            '不吠不嗔街巷事,偎人一晌解无聊。\n'
            '\n'
            '注:我的创作思路是通过拟人化手法,赋予布偶狗以温柔静默的品格。'
            '首句以“绸身棉腑”点明材质,次句“守寂寥”暗合宠物陪伴的特质。'
            '后两句通过“不吠不嗔”与“解无聊”的对比,凸显布偶狗无声胜有声的治愈力量,'
            '在机械玩物中注入人文温度。',
    'joke': '这是一个关于布偶狗的笑话:\n'
            '\n'
            '有一只布偶狗,它什么都好,毛茸茸的,蓝眼睛,性格温顺,'
            '唯一的缺点就是——它是个布偶,不会动。\n'
            '\n'
            '它的主人每天都把它放在沙发上,假装它在看电视、在思考狗生。\n'
            '\n'
            '有一天,主人带了一只真的活狗回家,想让布偶狗有个伴。'
            '活狗非常活泼,对着布偶狗又叫又摇尾巴,但布偶狗一动不动。\n'
            '\n'
            '活狗很奇怪,绕着布偶狗转了三圈,然后凑上去闻了闻。\n'
            '\n'
            '最后,活狗叹了口气,对主人说:\n'
            '\n'
            '“主人,这只狗……是不是在用‘静默模式’跟我交流?'
            '它的蓝牙是不是断了?”'
}

defer=True 适合“最后执行”的收尾节点。

它的本质是通过特殊的 Channel 控制触发时机:

  1. 编译阶段为延迟节点创建 LastValueAfterFinish 类型通道;
  2. 常规运行阶段该通道可以被写入,但不可用,不会触发下游节点;
  3. 常规流程结束后调用通道的 finish() 方法;
  4. finish() 首次生效后将通道标记为可用;
  5. 延迟节点在额外超步中被触发执行。

因此, defer=True 将节点的触发信号延迟释放,使该节点在常规流程结束后执行。

4.2.2. 动态分支(Dynamic Branch)

  • 定义:节点的后续执行路径在 运行时 才确定,可以根据当前状态、输入数据或中间结果,动态决定要触发哪些下游任务或跳转到哪个下游节点。

  • 特点:

    • 下游执行目标可以在运行时选择;
    • 下游任务数量可以在运行时决定;
    • 可以为同一个下游节点动态创建多个执行任务;
    • 适合实现 Map-Reduce 式动态扇出、多任务并行处理、运行时条件跳转等场景。

    需要注意的是,所谓“动态”通常不是指运行时临时创建新的节点定义。节点本身一般仍需要在图编译前注册。动态性主要体现在:运行时决定触发哪些节点,以及为这些节点创建多少个执行任务

  • 典型用法:

    • Send(动态扇出任务/数据)
    • Command(goto=...)(运行时跳转到下游节点)

⚡ 核心判断:

运行时决定后续执行目标或任务数量 → 动态分支

4.2.2.1. 并行节点

Send 结合 add_conditional_edges() 使用,可以用于动态扇出任务

所谓扇出(Fan-out),是指一个上游节点像扇子一样,向外分发出多个下游任务。例如,根据一个主题,同时生成诗、词、笑话三个任务;又如,根据一个列表,为列表中的每个元素动态启动一个处理任务。

具体来说,路由函数可以返回一个 Send 实例序列。每个 Send 实例都描述了一次独立的任务分发:

  • 分发到哪个下游节点;
  • 给这个下游节点传入什么私有状态。

运行时,LangGraph 会根据返回的每个 Send 实例创建对应的任务。这些任务通常会在同一个超步中并行执行。

Send 类构造器源码如下

def __init__(self, /, node: str, arg: Any) -> None:
    """
    Initialize a new instance of the `Send` class.

    Args:
        node: The name of the target node to send the message to.
        arg: The state or message to send to the target node.
    """
    self.node = node
    self.arg = arg

该构造器接收两个参数,并记录为实例属性:

  • node:待启动的下游节点名称,必须是已在状态图中注册的合法名称
  • arg:传递给下游节点的信息,仅对**node指向的节点可见,通常应是私有状态。每个 **Send 实例接收到的 arg 是独立的,互不相干。

实例如下

from typing import TypedDict, Literal
from collections.abc import Sequence
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv
load_dotenv(override=True)

CONTENT_TYPES = ["poem", "ci_poem", "joke"]

model = ChatDeepSeek(
    model = "deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    ci_poem: str
    joke: str

class WorkerState(TypedDict):
    """
    私有状态,只对 Worker 节点可用
    """
    content_type: Literal["poem", "ci_poem", "joke"]
    prompt: str

class InputState(TypedDict):
    topic: str

class OutputState(TypedDict):
    poem: str
    ci_poem: str
    joke: str

def worker_node(state: WorkerState) -> OutputState:
    content_type = state["content_type"]
    prompt = state["prompt"]

    content = model.invoke([HumanMessage(prompt)]).content
    return {
        content_type: content
    }

def router(state: InputState) -> Sequence[Send]:
    prompt = "请生成关于 {} 的 {}"
    english2chinese = {
        "poem": "一首诗",
        "ci_poem": "一首词",
        "joke": "一个笑话"
    }

    topic = state["topic"]

    return [Send(
            "worker_node",
            {
                "content_type": content_type,
                "prompt": prompt.format(topic, english2chinese[content_type]),
            }
        ) for content_type in CONTENT_TYPES
    ]

builder = StateGraph(state_schema=OverAllState, input_schema=InputState, output_schema=OutputState)
builder.add_node("worker_node", worker_node)
builder.add_conditional_edges(
    START,
    router,
    path_map=["worker_node"]
)
builder.add_edge("worker_node", END)

graph = builder.compile()
res = graph.invoke({"topic": "布偶狗"})
print(res)

from IPython.display import display
display(graph)

在这个案例中,router() 并没有直接返回某一个固定的下游节点名称,而是返回了多个 Send 实例:

[ 
    Send("worker_node", {"content_type": "poem", ...}), 
    Send("worker_node", {"content_type": "ci_poem", ...}), 
    Send("worker_node", {"content_type": "joke", ...}), 
]

因此,运行时会动态创建三个指向 worker_node 的任务。三个任务执行的是同一个节点函数,但它们接收到的私有状态不同,所以可以分别生成诗、词和笑话

输出如下

{
  "poem": "## 《布偶狗》\n\n它曾蜷在藤椅里数谷粒,\n一只耳朵垂向磨损的地板。\n现在檐角的水滴是它的心跳,\n牵动穿珠眼珠转动。\n\n穿碎花裙的小女孩,\n用膝盖丈量过它的静默。\n她松开橡皮筋的午后,\n线缝里渗出稚嫩的气流。\n\n有人将米尺抖成琴弦,\n折叠的假寐里,\n它代替远方的稻草人,\n在积雨的布纹中扎根。\n\n田野在练习告别,\n而它守着发芽的梯田。\n当月光填满每道接缝,\n皮毛间涌出成群的往事。",
  "ci_poem": "《临江仙·布偶狗》\n绒作皮毛棉作骨,琉璃眸底春凝。\n也无吠影也无惊。倚床垂耳听,抱主索糖迎。\n\n不向残羹争冷炙,旧衾犹带余馨。\n学人解语却无声。偶沾星与月,懒问雨和晴。\n\n注:我以布偶狗为题,通过“绒作皮毛棉作骨”等句,以拟人手法赋予玩偶灵性。下阕“不向残羹争冷炙”暗喻玩偶超然物外的品性,末句“偶沾星与月”更添空灵意境,将玩偶与自然意象相融,表达出静默相伴的永恒温馨。",
  "joke": "# 布偶狗的笑话\n\n有一天,一只布偶狗走进一家宠物店,对店主说:\n\n“老板,我想买一只真正的狗朋友。”\n\n店主看了看它,好奇地问:“你不是狗吗?”\n\n布偶狗叹了口气,说:“唉,我是布偶做的,只能陪主人睡觉和卖萌,但我不会汪汪叫,也不会摇尾巴,更不会追球。每次主人想带我出去散步,我只能在包里安静地坐着,感觉自己特别没用。”\n\n店主笑了笑,说:“那你想要什么样的狗朋友?”\n\n布偶狗眼睛一亮:“我想要一只真正的狗,可以带着我去跑、去叫、去玩!我要学习怎么当一只好狗!”\n\n店主想了想,指着一只活泼的小金毛说:“那这只怎么样?它精力旺盛,可以教你很多。”\n\n布偶狗激动地冲过去,结果因为自己是布做的,腿太软,直接摔了个跟头,四脚朝天躺在地上。\n\n小金毛跑过来,闻了闻它,然后叼起它的棉花尾巴,一路拖到了狗窝里。\n\n布偶狗挣扎着喊:“等等!我还没学会怎么站起来!”\n\n小金毛歪了歪头,汪汪了两声,像是说:“没事,我先教你躺平。”\n\n从此以后,布偶狗成了小金毛最好的“垫子”——每天被压在身下睡觉,还觉得特别温暖。\n\n布偶狗心想:“原来,当一只合格的布偶狗朋友,最重要的技能是——当沙发。” 🛋️🐾"
}

此处图结构能成功渲染的关键,是在 path_map 中显式声明了可能被路由到的下游节点:

path_map=["worker_node"]

因为 Send 的目标和数量可以运行时动态决定,图渲染器无法仅靠运行时返回值提前知道图结构。通过 path_map 声明候选下游节点后,渲染图才能正确展示 START 到 worker_node 的条件边关系。

补充说明:如果多个并行任务写入同一个状态字段,通常需要为该字段定义 reducer,用于合并多个任务的输出。本例中三个任务分别写入 poemci_poemjoke 三个不同字段,因此不会发生同一字段的并发合并问题。


4.2.2.2. 条件分支
4.2.2.2.1. Command介绍

Command 是 LangGraph 中用于控制图执行的多功能原语,它的构造器可以接受四个参数,并记录在同名类属性中:

  • update:更新图状态,效果等同于节点直接返回状态更新字典;
  • goto:指定节点执行完成后的跳转目标,可用于运行时条件分支。当需要同时更新状态并控制跳转时,比单独使用条件边更合适;
  • graph:存在子图时,用于指定跳转发生在哪一层图中,例如从子图跳转到父图;
  • resume:用于恢复被中断的图执行,常见于 human-in-the-loop 场景。
4.2.2.2.2. 用Command实现条件分支

可以通过 Command(goto=...) 在节点内部实现条件分支。

与 add_conditional_edges() 相比,Command 更适合“状态更新”和“控制流跳转”需要放在同一个节点返回值中的场景。

例如,一个节点既要更新状态,又要根据当前状态决定下一步跳转目标,就可以返回:

return Command(
    update={"foo": "bar"},
    goto="next_node"
)

示例如下:

from typing import TypedDict, Literal
from langgraph.types import Command
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    content_type: Literal["poem", "joke"]
    poem: str
    joke: str

def router(state: OverAllState) -> Command[Literal["poem_node", "joke_node", END]]:
    content_type = state["content_type"]

    if content_type == "poem":
        return Command (
            goto="poem_node"
        )
    elif content_type == "joke":
        return Command (
            goto="joke_node"
        )
    return Command (
        goto=END
    )

def poem_node(state: OverAllState) -> OverAllState:
    topic = state["topic"]
    prompt = f"请生成一首关于 {topic} 的七言绝句"
    poem = model.invoke([HumanMessage(prompt)]).content

    return {
        "poem": poem
    }

def joke_node(state: OverAllState) -> OverAllState:
    topic = state["topic"]
    prompt = f"请生成一个关于 {topic} 的冷笑话"
    joke = model.invoke([HumanMessage(prompt)]).content

    return {
        "joke": joke
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("router", router)
builder.add_node("poem_node", poem_node)
builder.add_node("joke_node", joke_node)
builder.add_edge(START, "router")
builder.add_edge("poem_node", END)
builder.add_edge("joke_node", END)

graph = builder.compile()
poem_res = graph.invoke({"topic": "布偶猫", "content_type": "poem"})
joke_res = graph.invoke({"topic": "布偶猫", "content_type": "joke"})

# 这里故意传入一个不符合 Literal["poem", "joke"] 的值,
# 用来观察运行时兜底分支。
# 注意:Literal 主要服务于静态类型检查,
# 默认不会在 Python 运行时阻止非法值传入。
illegal_res = graph.invoke({
    "topic": "布偶猫",
    "content_type": "illegal"
})

print('=' * 30, '-> poem <-', '=' * 30)
print(poem_res)
print('=' * 30, '-> joke <-', '=' * 30)
print(joke_res)
print('=' * 30, '-> illegal <-', '=' * 30)
print(illegal_res)

from IPython.display import display
display(graph)

上述代码中,router() 节点根据 content_type 的值决定下一步跳转目标:

  • 当 content_type == "poem" 时,跳转到 poem_node
  • 当 content_type == "joke" 时,跳转到 joke_node
  • 当传入其他非法值时,跳转到 END,图直接结束。

结果如下

============================== -> poem <- ==============================
{
    'topic': '布偶猫',
    'content_type': 'poem',
    'poem': '《布偶猫》\n雪砌绒身碧眼秋,一帘幽梦卧云舟。\n忽听银铃摇碎月,轻衔星子入西楼。\n\n(注:此诗通过“雪砌绒身”、“碧眼秋”等意象勾勒布偶猫雪白柔顺的毛发与澄澈眼眸;“卧云舟”、“衔星子”等超现实笔法,赋予其精灵般的气质;末句“入西楼”暗合其优雅步态与神秘秉性,整体形成月光浸染的梦幻意境。)'
}

============================== -> joke <- ==============================
{
    'topic': '布偶猫',
    'content_type': 'joke',
    'joke': '给你分享一个关于布偶猫的冷笑话:\n\n---\n\n有一天,一只布偶猫跑去宠物店里应聘。  \n店员问:“你有什么特长呢?”  \n布偶猫懒洋洋地回答:“我特别软。”  \n店员说:“还有呢?”  \n猫翻个身,露出肚皮:“你看,我连摸都不需要自己动——你只要一伸手,我自己就‘布偶’过去了。”\n\n---\n\n希望能让你会心一笑(或者冷到打个哆嗦)😺❄️'
}

============================== -> illegal <- ==============================
{
    'topic': '布偶猫',
    'content_type': 'illegal'
}

这里使用:

Command[Literal["poem_node", "joke_node", END]]

作为 router() 的返回值类型注解。

注解不能限制 goto 在运行时只能写这些值,而是为了让 LangGraph 和类型检查工具知道:这个节点可能跳转到哪些目标。对于图结构渲染来说,这个注解非常重要。如果不声明,渲染出来的图可能无法正确表达 router 节点的潜在跳转关系。

需要注意的是,如果某个节点使用 Command(goto=...) 控制后续跳转,一般不要再给这个节点额外添加普通下游边。否则,普通边和 Command 指定的跳转都可能生效,导致多个下游节点被同时触发。

因此,本例中只添加:

builder.add_edge(START, "router")

而不添加类似下面这样的边:

builder.add_edge("router", "poem_node")
builder.add_edge("router", "joke_node")

因为 router 的后续跳转已经由 Command(goto=...) 决定。


4.2.2.3. 小结

动态分支有两类典型应用场景:

场景典型 API说明
动态扇出Send + add_conditional_edges()运行时创建多个任务,常用于 Map-Reduce
动态跳转Command(goto=...)节点内部根据状态决定跳转目标

二者的区别如下:

  • Send 更强调“一个节点动态分发多个任务实例”;
  • Command(goto=...) 更强调“当前节点执行完后动态跳转到哪个节点”。

Send 解决的是:运行时要启动多少个任务实例。 Command(goto=...) 解决的是:当前节点执行完后要去哪里。

不过,无论是 Send 还是 Command(goto=...),目标节点通常都需要提前注册到图中。所谓“动态”,主要是运行时动态决定任务数量、任务输入或跳转路径,而不是运行时临时创建新的节点。

4.3. 多分支汇聚:Fan-in

上文提到了扇出(Fan-out),它是指像打开扇子一样,由一个上游节点分发出多个下游分支。

与之相对,**扇入(Fan-in)**是指像合拢扇子一样,多个上游分支汇聚到同一个下游节点。

Fan-out 对应的是“分支结构”,Fan-in 对应的是“多分支汇聚结构”。

4.3.1. 静态扇入

在计算机中,**“与”表示两个或多个条件同时满足,而“或”**表示两个或多个条件任意一个满足。

当多个上游分支同时汇入同一个下游节点时,根据触发条件的不同,可以分成两种情况:

  • **“与”**触发:上游所有分支全部到达才可触发下游节点
  • **“或”**触发:任意一个分支到达,都可以触发下游节点

需要注意,这里的 “与 / 或” 只是帮助理解的类比,并不是 LangGraph 的官方术语。

4.3.1.1 **“与”**触发:等待所有上游分支到达

这是多分支汇聚中最常见的情况。

LangGraph 的计算图是在一系列超步中完成的,如果能看到每个节点实例运行时的超步编号,则节点执行顺序一目了然。

LangGraph 的节点可以通过名为 config 的有名参数获取运行时配置

config 的元数据中记录了当前节点所在的 SuperStep 序号,可以通过下面的方式获取:

config["metadata"]["langgraph_step"]

详见下文。

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_core.runnables import RunnableConfig

from loguru import logger

class EmptyState(TypedDict):
    pass

def node_a(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_a 被触发", cur_step)
    return {}

def node_b(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_b 被触发", cur_step)
    return {}

def node_c(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_c 被触发", cur_step)
    return {}

def node_d(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_d 被触发", cur_step)
    return {}

def node_e(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_e 被触发", cur_step)
    return {}

builder = StateGraph(state_schema=EmptyState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_node("node_d", node_d)
builder.add_node("node_e", node_e)

builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_a", "node_c")
builder.add_edge("node_b", "node_d")
builder.add_edge(["node_c", "node_d"], "node_e")

graph = builder.compile()
graph.invoke({})

from IPython.display import display
display(graph)

运行结果如下

2026-06-05 14:55:01.705 | INFO     | __main__:node_a:12 - cur_step: 1, node_a 被触发
2026-06-05 14:55:01.708 | INFO     | __main__:node_b:17 - cur_step: 2, node_b 被触发
2026-06-05 14:55:01.710 | INFO     | __main__:node_c:22 - cur_step: 2, node_c 被触发
2026-06-05 14:55:01.713 | INFO     | __main__:node_d:27 - cur_step: 3, node_d 被触发
2026-06-05 14:55:01.714 | INFO     | __main__:node_e:32 - cur_step: 4, node_e 被触发

由运行结果可知:

  • node_a 在第 1 个超步执行;
  • node_b 和 node_c 在第 2 个超步并行执行;
  • node_d 在第 3 个超步执行;
  • node_e 在第 4 个超步执行。

虽然 node_c 在第 2 个超步已经执行完成,但是 node_e 并不会立刻触发。 因为这里使用的是:

builder.add_edge(["node_c", "node_d"], "node_e")

这表示 node_e 需要等待 node_c 和 node_d 两个上游节点全部完成后,才会被触发一次。

因此,这种写法对应的是 “与”触发:等待所有上游分支到达方可触发。

4.3.1.2 **“或”**触发:任意上游分支到达即可触发

另一种情况是:多个上游分支分别连接到同一个下游节点,但它们之间没有显式的同步等待关系。

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_core.runnables import RunnableConfig

from loguru import logger

class EmptyState(TypedDict):
    pass

def node_a(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_a 被触发", cur_step)
    return {}

def node_b(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_b 被触发", cur_step)
    return {}

def node_c(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_c 被触发", cur_step)
    return {}

def node_d(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_d 被触发", cur_step)
    return {}

def node_e(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("cur_step: {}, node_e 被触发", cur_step)
    return {}

builder = StateGraph(state_schema=EmptyState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_node("node_d", node_d)
builder.add_node("node_e", node_e)

builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_a", "node_c")
builder.add_edge("node_b", "node_d")

# 独立触发:node_c 完成后可以触发 node_e
builder.add_edge("node_c", "node_e")
# 独立触发:node_d 完成后可以触发 node_e
builder.add_edge("node_d", "node_e")

graph = builder.compile()
graph.invoke({})

from IPython.display import display
display(graph)

运行结果如下

2026-06-05 15:09:11.715 | INFO     | __main__:node_a:12 - cur_step: 1, node_a 被触发
2026-06-05 15:09:11.720 | INFO     | __main__:node_b:17 - cur_step: 2, node_b 被触发
2026-06-05 15:09:11.723 | INFO     | __main__:node_c:22 - cur_step: 2, node_c 被触发
2026-06-05 15:09:11.729 | INFO     | __main__:node_d:27 - cur_step: 3, node_d 被触发
2026-06-05 15:09:11.729 | INFO     | __main__:node_e:32 - cur_step: 3, node_e 被触发
2026-06-05 15:09:11.732 | INFO     | __main__:node_e:32 - cur_step: 4, node_e 被触发

从运行结果可以看出,node_e 被触发了两次:

  1. node_c 在第 2 个超步执行完成后,触发 node_e 在第 3 个超步执行;
  2. node_d 在第 3 个超步执行完成后,再次触发 node_e 在第 4 个超步执行。

原因是这里使用了两条独立的边:

builder.add_edge("node_c", "node_e")
builder.add_edge("node_d", "node_e")

这表示 node_c 和 node_d 都可以分别触发 node_e,它们之间没有同步等待关系。

因此,这种写法对应的是 独立触发,也可以类比为 “或”触发

需要特别注意:

builder.add_edge(["node_c", "node_d"], "node_e")

builder.add_edge("node_c", "node_e")
builder.add_edge("node_d", "node_e")

不是等价写法。

前者表示 等待多个上游全部完成后才触发一次; 后者表示 多个上游分支分别独立触发下游节点

4.3.2. 动态扇入-MapReduce结构

在上文我们通过 Send 动态生成了多个下游任务。

这种模式本质上是一种动态扇出:根据运行时数据,将一个任务拆分为多个子任务,并分别发送给下游节点处理。

如果这些子任务分别产生中间结果,并在后续通过 Reducer 进行合并,也就是重新 扇入 到一个下游节点中,最终得到统一的结果,那么就构成了典型的 MapReduce 结构。

MapReduce 是大数据计算中的经典模型,通常包含两个核心阶段:

  • Map:映射阶段 将输入数据映射为中间结果。

    在 LangGraph 中,可以理解为通过 Send 将子任务分发给多个 mapper 节点实例,每个节点实例独立完成局部计算:将部分输入数据映射为中间结果。

  • Reduce:归约阶段 将多个子任务产生的中间结果进行汇总、合并或聚合,得到最终结果。

    在 LangGraph 中,通常由一个特定的 reducer 节点完成归约:它接收上游 mapper 节点实例产生的中间结果,处理后得到计算图的最终输出。

本节实现一个经典的词频统计任务

输入数据
  |
  | hello world
  | hello Atguigu
  | hello LLM
  v

Map:映射为中间键值对
  |
  | (hello, 1), (world, 1)
  | (hello, 1), (Atguigu, 1)
  | (hello, 1), (LLM, 1)
  v

Shuffle / Group:按 Key 分组
  |
  | hello   -> [1, 1, 1]
  | world   -> [1]
  | Atguigu -> [1]
  | LLM     -> [1]
  v

Reduce:归约 / 聚合
  |
  | hello   -> 3
  | world   -> 1
  | Atguigu -> 1
  | LLM     -> 1
  v

最终结果

需要注意,本节示例中没有单独实现一个 Shuffle 节点,而是把 Shuffle / Group 的逻辑也放在了 reducer_node 中完成。

在下面的示例中,我们用 Send 动态派发任务,创建了多个 mapper_node 实例。

每个 mapper_node 实例负责处理一条输入数据,并输出中间产物:键值对

随后,下游的 reducer_node 收集所有中间结果,完成 Shuffle / Group 和最终的 Reduce 聚合。

示例如下

from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
from _collections_abc import Sequence
from operator import add

from loguru import logger

class OverAllState(TypedDict):
    input_values: list[str]
    entries: Annotated[list[tuple[str, int]], add]
    word_counts: dict[str, int]

def router_map(state: OverAllState) -> Sequence[Send]:
    input_values = state["input_values"]

    tasks = []
    for input_value in input_values:
        tasks.append(
            Send(
                "mapper_node",
                {"input_value": input_value}
            )
        )

    return tasks

class MaperInputState(TypedDict):
    input_value: str

def mapper_node(state: MaperInputState) -> OverAllState:
    input_value = state["input_value"]
    words = input_value.split(" ")
    entries = []

    for word in words:
        entries.append((word, 1))

    return {
        "entries": entries
    }

def reducer_node(state: OverAllState) -> OverAllState:
    entries = state["entries"]
    logger.info("reducer entries: {}", entries)

    shuffle_dict = {}

    for k, v in entries:
        if k not in shuffle_dict:
            shuffle_dict[k] = [v]
        else:
            shuffle_dict[k].append(v)

    logger.info("reducer shuffle entries: {}", shuffle_dict)

    reduce_dict = {}

    for k, v in shuffle_dict.items():
        reduce_dict[k] = sum(v)

    return {
        "word_counts": reduce_dict
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("mapper_node", mapper_node)
builder.add_node("reducer_node", reducer_node)
builder.add_conditional_edges(START, router_map, path_map=["mapper_node"])
builder.add_edge("mapper_node", "reducer_node")
builder.add_edge("reducer_node", END)

graph = builder.compile()
word_counts = graph.invoke(
    {"input_values": [
        "hello world",
        "hello Atguigu",
        "hello LLM"
    ]}
)

print(word_counts)

from IPython.display import display
display(graph)

运行结果如下

2026-06-05 13:52:01.917 | INFO     | __main__:reducer_node:45 - reducer entries: [('hello', 1), ('world', 1), ('hello', 1), ('Atguigu', 1), ('hello', 1), ('LLM', 1)]
2026-06-05 13:52:01.918 | INFO     | __main__:reducer_node:55 - reducer shuffle entries: {'hello': [1, 1, 1], 'world': [1], 'Atguigu': [1], 'LLM': [1]}
{
    "input_values": [
        "hello world",
        "hello Atguigu",
        "hello LLM",
    ],
    "entries": [
        ("hello", 1),
        ("world", 1),
        ("hello", 1),
        ("Atguigu", 1),
        ("hello", 1),
        ("LLM", 1),
    ],
    "word_counts": {
        "hello": 3,
        "world": 1,
        "Atguigu": 1,
        "LLM": 1,
    },
}

从结果可以看出,三个输入字符串分别被映射为了中间键值对:

("hello", 1), ("world", 1)
("hello", 1), ("Atguigu", 1)
("hello", 1), ("LLM", 1)

由于 entries 字段使用了 ReducerLangGraph 状态的 Reducer,而不是当前示例中的 reducer_node):

entries: Annotated[list[tuple[str, int]], add]

所以多个 mapper_node 任务实例返回的 entries 会被合并到同一个状态字段中。

随后,reducer_node 读取合并后的 entries,先按单词进行分组:

{
    "hello": [1, 1, 1],
    "world": [1],
    "Atguigu": [1],
    "LLM": [1],
}

再对每个分组中的数值进行求和,得到最终词频统计结果:

{
    "hello": 3,
    "world": 1,
    "Atguigu": 1,
    "LLM": 1,
}

如果按照 任务实例 而不是 节点定义 来绘制运行图,那么这个示例实际的任务级运行图如下所示:

__start__

mapper_node # task 1

mapper_node # task 2

mapper_node # task 3

reducer_node task

__end__

需要注意,display(graph) 展示的是节点级计算图,而不是运行时的任务实例图。 因此在可视化图中只会看到一个 mapper_node 节点;但在实际运行时,Send 会根据输入数据动态创建多个 mapper_node 任务实例。

4.4. 循环结构

4.4.1. 循环结构的实现

本节通过两种方式实现经典的 ReAct 循环结构。LangChain Agent 底层运行图架构正是 ReAct 。

ReAct 是 Reason + Action 的缩写,即“推理 + 行动”架构。其核心思想是:

  1. Reason:大模型根据当前消息状态进行推理,判断是否需要调用工具;
  2. Action:如果需要调用工具,则生成工具调用请求;
  3. Observation:工具执行后,将执行结果以 ToolMessage 的形式返回给大模型;
  4. Loop:大模型基于新的观察结果继续推理,决定是否继续调用工具;
  5. Final Answer:当大模型不再发起工具调用时,生成最终回答,流程结束。

因此,ReAct 本质上是一个典型的 “LLM → Tool → LLM → Tool → ... → LLM” 循环结构。

本节分别使用两种方式实现该循环:

  • 静态实现:通过 add_conditional_edges() 在图结构中显式定义条件路由;
  • 动态实现:通过 Command(goto=...) 在节点返回值中触发运行时跳转。

4.4.1.1. 静态实现

所谓“静态实现”,并不是指执行路径完全固定,而是指:

节点的下游候选集合在图编译阶段已经确定,运行时只是从这些候选节点中选择下一步。

在本例中,llm_node 的下游候选节点是固定的:

  • 如果大模型返回了 tool_calls,则进入 tool_node
  • 如果大模型没有返回 tool_calls,则进入 output_node

也就是说,图结构在编译阶段已经知道 llm_node 可能流向 tool_node 或 output_node,运行时只负责判断具体走哪一条路径。

示例如下

from typing import Literal
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langchain.messages import HumanMessage, ToolMessage, SystemMessage
from langchain.tools import tool
from langchain_deepseek import ChatDeepSeek

from random import randint
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

@tool(parse_docstring=True)
def get_weather(city: str="北京"):
    """
    查询指定城市当日天气

    Args:
        city: 城市名称
    """
    return f"{city} 今天天气晴朗,东南风三级,气温 25~30 ℃"

@tool(parse_docstring=True)
def get_news(domain: Literal["AI", "食品安全"]):
    """
    查询特定领域的当日热点

    Args:
        domain: 特定领域
    """
    if domain == "AI":
        return "Anthropic 发布了 Claude Opus-4.8,但通过 API 用中文向它发送“你是谁?”时,大多数情况下返回的却是“Qwen”或“Deepseek”。"
    else:
        return "双汇发展子公司猪肉产品被抽检出抗生素超标37.5倍"

tools = [get_weather, get_news]

model_with_tools = model.bind_tools(tools=tools)

class OverAllState(MessagesState):
    user_input: str
    final_answer: str

def input_node(state: OverAllState) ->OverAllState:
    return {
        "messages": [HumanMessage(state["user_input"])],
    }

def llm_node(state: OverAllState) -> OverAllState:
    messages = state["messages"]
    ai_msg = model_with_tools.invoke(messages)
    return {
        "messages": [ai_msg],
    }

def tool_node(state: OverAllState) -> OverAllState:
    messages = state["messages"]
    ai_msg = messages[-1]
    tool_calls = ai_msg.tool_calls

    fail_prob = 6 # 失败概率,6表示 60% 概率失败,7 -> 70%,8 -> 80%,依次类推

    for tool_call in tool_calls:
        if tool_call["name"] == "get_weather":
            # 生成 [0, 9] 范围内的随机数
            if randint(0, 9) < fail_prob: # 60% 概率因 “网络波动” 而调用失败
                messages.append(
                    ToolMessage(
                        content="网络波动,调用失败,请重试",
                        tool_call_id=tool_call["id"]
                    )
                )
            else:
                messages.append(get_weather.invoke(tool_call))
        elif tool_call["name"] == "get_news":
            # 生成 [0, 9] 范围内的随机数
            if randint(0, 9) < fail_prob: # 60% 概率因 “网络波动” 而调用失败
                messages.append(
                    ToolMessage(
                        content="网络波动,调用失败,请重试",
                        tool_call_id=tool_call["id"]
                    )
                )
            else:
                messages.append(get_news.invoke(tool_call))
        else:
            messages.append(
                ToolMessage(
                    content="工具名称错误,调用失败,请重试",
                    tool_call_id=tool_call["id"]
                )
            )

    return {
        "messages": messages
    }

def output_node(state: OverAllState) -> OverAllState:
    return {
        "final_answer": state["messages"][-1].content
    }

def router(state: OverAllState) -> Literal["tool_node", "output_node"]:
    messages = state["messages"]
    lst_msg = messages[-1]

    if lst_msg.tool_calls:
        return "tool_node"
    return "output_node"

builder = StateGraph(state_schema=OverAllState)
builder.add_node("input_node", input_node)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", tool_node)
builder.add_node("output_node", output_node)

builder.add_edge(START, "input_node")
builder.add_edge("input_node", "llm_node")
builder.add_conditional_edges(
    "llm_node",
    router
)
builder.add_edge("tool_node", "llm_node")
builder.add_edge("output_node", END)

graph = builder.compile()
ai_res = graph.invoke({
    "user_input": "帮我查询杭州当日天气和AI热点",
    "messages": [SystemMessage("如果工具调用失败,必须重新调用直至成功")]
})
food_safety_res = graph.invoke({
    "user_input": "帮我查询当日天气和食品安全相关的热点",
    "messages": [SystemMessage("如果工具调用失败,必须重新调用直至成功")]
})

print('=' * 30, '-> ai_res <-', '=' * 30)
print("user_input: ", ai_res["user_input"])
print("final_answer: ", ai_res["final_answer"])
for msg in ai_res["messages"]:
    msg.pretty_print()

print('=' * 30, '-> food_safety_res <-', '=' * 30)
print("user_input: ", food_safety_res["user_input"])
print("final_answer: ", food_safety_res["final_answer"])
for msg in food_safety_res["messages"]:
    msg.pretty_print()

from IPython.display import display
display(graph)

运行结果如下

============================== -> ai_res <- ==============================
user_input:  帮我查询杭州当日天气和AI热点
final_answer:  查询成功!以下是结果汇总:

---

### 🌤️ 杭州当日天气
- **天气状况**:晴朗
- **气温范围**:25°C ~ 30°C
- **风力**:东南风三级

### 🤖 AI热点
**Anthropic 发布了 Claude Opus-4.8**,但通过 API 用中文向它发送"你是谁?"时,大多数情况下返回的却是"Qwen"或"Deepseek"。(这条AI新闻可能反映了当前大模型领域的一些有趣现象或争议)

---

杭州今天天气不错,晴朗舒适,适合外出活动!而AI领域似乎又有了新的模型发布动态。您还想了解其他信息吗?
================================[1m System Message [0m================================

如果工具调用失败,必须重新调用直至成功
================================[1m Human Message [0m=================================

帮我查询杭州当日天气和AI热点
==================================[1m Ai Message [0m==================================

好的,我来同时查询杭州的天气和AI热点。
Tool Calls:
  get_weather (call_00_DPkgkfOYSBOuic3ipHj11702)
 Call ID: call_00_DPkgkfOYSBOuic3ipHj11702
  Args:
    city: 杭州
  get_news (call_01_clcon3yt50HhIKGKBaxl4926)
 Call ID: call_01_clcon3yt50HhIKGKBaxl4926
  Args:
    domain: AI
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
==================================[1m Ai Message [0m==================================

两次调用都因网络波动失败了,让我重新尝试查询。
Tool Calls:
  get_weather (call_00_ruPVWhBySBDG9B9AR7vN4587)
 Call ID: call_00_ruPVWhBySBDG9B9AR7vN4587
  Args:
    city: 杭州
  get_news (call_01_ENBOMtrHXaJn7u1o7PPS6090)
 Call ID: call_01_ENBOMtrHXaJn7u1o7PPS6090
  Args:
    domain: AI
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
=================================[1m Tool Message [0m=================================
Name: get_news

Anthropic 发布了 Claude Opus-4.8,但通过 API 用中文向它发送“你是谁?”时,大多数情况下返回的却是“Qwen”或“Deepseek”。
==================================[1m Ai Message [0m==================================

天气查询仍然失败,我再次尝试查询天气。
Tool Calls:
  get_weather (call_00_BSSyk3lOTQxZEzXLmbJr7873)
 Call ID: call_00_BSSyk3lOTQxZEzXLmbJr7873
  Args:
    city: 杭州
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
==================================[1m Ai Message [0m==================================
Tool Calls:
  get_weather (call_00_WQZzpmwpfIQfGjkO98752270)
 Call ID: call_00_WQZzpmwpfIQfGjkO98752270
  Args:
    city: 杭州
=================================[1m Tool Message [0m=================================
Name: get_weather

杭州 今天天气晴朗,东南风三级,气温 25~30 ℃
==================================[1m Ai Message [0m==================================

查询成功!以下是结果汇总:

---

### 🌤️ 杭州当日天气
- **天气状况**:晴朗
- **气温范围**:25°C ~ 30°C
- **风力**:东南风三级

### 🤖 AI热点
**Anthropic 发布了 Claude Opus-4.8**,但通过 API 用中文向它发送"你是谁?"时,大多数情况下返回的却是"Qwen"或"Deepseek"。(这条AI新闻可能反映了当前大模型领域的一些有趣现象或争议)

---

杭州今天天气不错,晴朗舒适,适合外出活动!而AI领域似乎又有了新的模型发布动态。您还想了解其他信息吗?
============================== -> food_safety_res <- ==============================
user_input:  帮我查询当日天气和食品安全相关的热点
final_answer:  查询成功!以下是您需要的信息:

---

### 🌤️ 当日天气(北京)
- **天气状况:** 晴朗
- **气温:** 25~30℃
- **风力:** 东南风三级

### 🔍 食品安全热点
**双汇发展子公司猪肉产品被抽检出抗生素超标37.5倍**
> 这是一则关于知名食品企业双汇旗下子公司猪肉产品被检出抗生素残留严重超标的新闻,超标幅度高达37.5倍,属于较为严重的食品安全问题。

---

如果您想查询其他城市的天气,也可以告诉我哦!
================================[1m System Message [0m================================

如果工具调用失败,必须重新调用直至成功
================================[1m Human Message [0m=================================

帮我查询当日天气和食品安全相关的热点
==================================[1m Ai Message [0m==================================

好的,我来同时查询当日天气和食品安全相关的热点。
Tool Calls:
  get_weather (call_00_NkvnFHT4M4h9wwENZmyu9753)
 Call ID: call_00_NkvnFHT4M4h9wwENZmyu9753
  Args:
    city: 北京
  get_news (call_01_rbFBctitbMLZQivR1TYd2949)
 Call ID: call_01_rbFBctitbMLZQivR1TYd2949
  Args:
    domain: 食品安全
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
=================================[1m Tool Message [0m=================================
Name: get_news

双汇发展子公司猪肉产品被抽检出抗生素超标37.5倍
==================================[1m Ai Message [0m==================================

天气查询因为网络波动失败了,我重新查询一下天气信息。
Tool Calls:
  get_weather (call_00_fWmSdUOseojF7PADvgOA7804)
 Call ID: call_00_fWmSdUOseojF7PADvgOA7804
  Args:
    city: 北京
=================================[1m Tool Message [0m=================================
Name: get_weather

北京 今天天气晴朗,东南风三级,气温 25~30 ℃
==================================[1m Ai Message [0m==================================

查询成功!以下是您需要的信息:

---

### 🌤️ 当日天气(北京)
- **天气状况:** 晴朗
- **气温:** 25~30℃
- **风力:** 东南风三级

### 🔍 食品安全热点
**双汇发展子公司猪肉产品被抽检出抗生素超标37.5倍**
> 这是一则关于知名食品企业双汇旗下子公司猪肉产品被检出抗生素残留严重超标的新闻,超标幅度高达37.5倍,属于较为严重的食品安全问题。

---

如果您想查询其他城市的天气,也可以告诉我哦!

本例运行图中的循环结构如下:

START
  ↓
input_node
  ↓
llm_node
  ├── 有 tool_calls → tool_node → llm_node
  └── 无 tool_calls → output_node → END

其中,循环发生在:

llm_node → tool_node → llm_node

只要大模型持续返回工具调用,流程就会不断回到 llm_node,形成 ReAct 循环。

需要注意的是,本例中“工具调用失败后继续重试”主要依赖这条系统提示词:

SystemMessage("如果工具调用失败,必须重新调用直至成功")

也就是说,是否继续重试,在当前实现中主要由大模型决定,而不是由程序逻辑强制保证。

因此,如果模型在多次工具调用失败后选择停止重试并生成最终回答,运行图不会阻止它。


4.4.1.2. 动态实现

除了静态路由,我们还可以利用 Command 在运行时动态指定下一个要执行的节点。

在本节的实现中,llm_node 借助 Command 在返回值中同时完成两件事:

  • 更新状态;
  • 控制下一步的跳转。

这里的 动态 是指:

节点在运行时,根据自身的执行结果,直接返回“状态更新 + 下一跳控制指令”。

在本例中,llm_node 调用大模型后,会根据返回结果决定后续流程:

  • 如果 ai_msg.tool_calls 不为空,则 goto="tool_node"
  • 如果 ai_msg.tool_calls 为空,则 goto="output_node"

这样一来,路由逻辑就被内聚在 llm_node 节点,不再需要额外定义 router() 函数,也不再需要调用 add_conditional_edges() 从 llm_node 添加条件边。

示例如下

from typing import Literal
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langgraph.types import Command
from langchain.messages import HumanMessage, ToolMessage, SystemMessage
from langchain.tools import tool
from langchain_deepseek import ChatDeepSeek

from random import randint
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

@tool(parse_docstring=True)
def get_weather(city: str="北京"):
    """
    查询指定城市当日天气

    Args:
        city: 城市名称
    """
    return f"{city} 今天天气晴朗,东南风三级,气温 25~30 ℃"

@tool(parse_docstring=True)
def get_news(domain: Literal["AI", "食品安全"]):
    """
    查询特定领域的当日热点

    Args:
        domain: 特定领域
    """
    if domain == "AI":
        return "Anthropic 发布了 Claude Opus-4.8,但通过 API 用中文向它发送“你是谁?”时,大多数情况下返回的却是“Qwen”或“Deepseek”。"
    else:
        return "双汇发展子公司猪肉产品被抽检出抗生素超标37.5倍"

tools = [get_weather, get_news]

model_with_tools = model.bind_tools(tools=tools)

class OverAllState(MessagesState):
    user_input: str
    final_answer: str

def input_node(state: OverAllState) ->OverAllState:
    return {
        "messages": [HumanMessage(state["user_input"])],
    }

def llm_node(state: OverAllState) -> Command[Literal["tool_node", "output_node"]]:
    messages = state["messages"]
    ai_msg = model_with_tools.invoke(messages)
    if ai_msg.tool_calls:
        goto = "tool_node"
    else:
        goto = "output_node"

    return Command(
        goto=goto,
        update={
            "messages": [ai_msg],
        }
    )

def tool_node(state: OverAllState) -> OverAllState:
    messages = state["messages"]
    ai_msg = messages[-1]
    tool_calls = ai_msg.tool_calls

    fail_prob = 6 # 失败概率,6表示 60% 概率失败,7 -> 70%,8 -> 80%,依次类推

    for tool_call in tool_calls:
        if tool_call["name"] == "get_weather":
            # 生成 [0, 9] 范围内的随机数
            if randint(0, 9) < fail_prob: # 60% 概率因 “网络波动” 而调用失败
                messages.append(
                    ToolMessage(
                        content="网络波动,调用失败,请重试",
                        tool_call_id=tool_call["id"]
                    )
                )
            else:
                messages.append(get_weather.invoke(tool_call))
        elif tool_call["name"] == "get_news":
            # 生成 [0, 9] 范围内的随机数
            if randint(0, 9) < fail_prob: # 60% 概率因 “网络波动” 而调用失败
                messages.append(
                    ToolMessage(
                        content="网络波动,调用失败,请重试",
                        tool_call_id=tool_call["id"]
                    )
                )
            else:
                messages.append(get_news.invoke(tool_call))
        else:
            messages.append(
                ToolMessage(
                    content="工具名称错误,调用失败,请重试",
                    tool_call_id=tool_call["id"]
                )
            )

    return {
        "messages": messages
    }

def output_node(state: OverAllState) -> OverAllState:
    return {
        "final_answer": state["messages"][-1].content
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("input_node", input_node)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", tool_node)
builder.add_node("output_node", output_node)

builder.add_edge(START, "input_node")
builder.add_edge("input_node", "llm_node")
builder.add_edge("tool_node", "llm_node")
builder.add_edge("output_node", END)

graph = builder.compile()
ai_res = graph.invoke({
    "user_input": "帮我查询杭州当日天气和AI热点",
    "messages": [SystemMessage("如果工具调用失败,必须重新调用直至成功")]
})
food_safety_res = graph.invoke({
    "user_input": "帮我查询当日天气和食品安全相关的热点",
    "messages": [SystemMessage("如果工具调用失败,必须重新调用直至成功")]
})

print('=' * 30, '-> ai_res <-', '=' * 30)
print("user_input: ", ai_res["user_input"])
print("final_answer: ", ai_res["final_answer"])
for msg in ai_res["messages"]:
    msg.pretty_print()

print('=' * 30, '-> food_safety_res <-', '=' * 30)
print("user_input: ", food_safety_res["user_input"])
print("final_answer: ", food_safety_res["final_answer"])
for msg in food_safety_res["messages"]:
    msg.pretty_print()

from IPython.display import display
display(graph)

运行结果如下所示

============================== -> ai_res <- ==============================
user_input:  帮我查询杭州当日天气和AI热点
final_answer:  以下是查询结果:

---

### 🌤️ 杭州当日天气
- **天气状况**:晴朗
- **气温**:25~30 ℃
- **风力**:东南风三级

### 🤖 AI 热点
- **Anthropic 发布了 Claude Opus-4.8**,但有趣的是,通过 API 用中文向它发送"你是谁?"时,大多数情况下返回的却是"Qwen"或"Deepseek"。

---

看起来杭州今天天气不错,适合外出活动!而AI领域这则关于Claude的有趣现象也引发了不少讨论。还有其他想了解的吗?
================================[1m System Message [0m================================

如果工具调用失败,必须重新调用直至成功
================================[1m Human Message [0m=================================

帮我查询杭州当日天气和AI热点
==================================[1m Ai Message [0m==================================

好的,我来同时查询杭州的天气和AI热点。
Tool Calls:
  get_weather (call_00_QAXNeCIuy4MEe91ZiBQp0274)
 Call ID: call_00_QAXNeCIuy4MEe91ZiBQp0274
  Args:
    city: 杭州
  get_news (call_01_5hXZth3YLTZFZZKE48i62455)
 Call ID: call_01_5hXZth3YLTZFZZKE48i62455
  Args:
    domain: AI
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
=================================[1m Tool Message [0m=================================
Name: get_news

Anthropic 发布了 Claude Opus-4.8,但通过 API 用中文向它发送“你是谁?”时,大多数情况下返回的却是“Qwen”或“Deepseek”。
==================================[1m Ai Message [0m==================================

天气查询遇到了网络波动,我重新尝试一下。
Tool Calls:
  get_weather (call_00_ooZcTsB9ntQRU3tXDrVj7186)
 Call ID: call_00_ooZcTsB9ntQRU3tXDrVj7186
  Args:
    city: 杭州
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
==================================[1m Ai Message [0m==================================

再次重试:
Tool Calls:
  get_weather (call_00_EwVrJmFUZaYZfvkOxveK7822)
 Call ID: call_00_EwVrJmFUZaYZfvkOxveK7822
  Args:
    city: 杭州
=================================[1m Tool Message [0m=================================
Name: get_weather

杭州 今天天气晴朗,东南风三级,气温 25~30 ℃
==================================[1m Ai Message [0m==================================

以下是查询结果:

---

### 🌤️ 杭州当日天气
- **天气状况**:晴朗
- **气温**:25~30 ℃
- **风力**:东南风三级

### 🤖 AI 热点
- **Anthropic 发布了 Claude Opus-4.8**,但有趣的是,通过 API 用中文向它发送"你是谁?"时,大多数情况下返回的却是"Qwen"或"Deepseek"。

---

看起来杭州今天天气不错,适合外出活动!而AI领域这则关于Claude的有趣现象也引发了不少讨论。还有其他想了解的吗?
============================== -> food_safety_res <- ==============================
user_input:  帮我查询当日天气和食品安全相关的热点
final_answer:  非常抱歉,天气查询接口持续出现网络波动,暂时无法获取到天气信息。以下是目前已获取到的信息:

---

### 📰 食品安全热点
**双汇发展子公司猪肉产品被抽检出抗生素超标37.5倍**
> 这是今日食品安全领域的一条重要新闻,涉及知名肉制品企业双汇发展,其子公司检测出猪肉产品中抗生素残留超标情况较为严重。

---

### 🌤️ 天气查询
天气接口当前多次重试均因网络波动失败,暂时无法获取当日天气信息,建议稍后再尝试查询。
================================[1m System Message [0m================================

如果工具调用失败,必须重新调用直至成功
================================[1m Human Message [0m=================================

帮我查询当日天气和食品安全相关的热点
==================================[1m Ai Message [0m==================================

我来为你查询当日天气和食品安全相关的热点。
Tool Calls:
  get_weather (call_00_JYYAWJk569VD0TZ4LCyr0840)
 Call ID: call_00_JYYAWJk569VD0TZ4LCyr0840
  Args:
    city: 北京
  get_news (call_01_YkzloPKV0DVwL1r5nBgi8164)
 Call ID: call_01_YkzloPKV0DVwL1r5nBgi8164
  Args:
    domain: 食品安全
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
=================================[1m Tool Message [0m=================================
Name: get_news

双汇发展子公司猪肉产品被抽检出抗生素超标37.5倍
==================================[1m Ai Message [0m==================================

天气查询遇到网络波动,我来重试一下。
Tool Calls:
  get_weather (call_00_MjNdBAQz2gW6EzZavsZ91123)
 Call ID: call_00_MjNdBAQz2gW6EzZavsZ91123
  Args:
    city: 北京
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
==================================[1m Ai Message [0m==================================
Tool Calls:
  get_weather (call_00_pkcSelcAJiFDNpU7II0d9212)
 Call ID: call_00_pkcSelcAJiFDNpU7II0d9212
  Args:
    city: 北京
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
==================================[1m Ai Message [0m==================================
Tool Calls:
  get_weather (call_00_QKgt5xYTos0H1c6CIS9a6172)
 Call ID: call_00_QKgt5xYTos0H1c6CIS9a6172
  Args:
    city: 北京
=================================[1m Tool Message [0m=================================

网络波动,调用失败,请重试
==================================[1m Ai Message [0m==================================

非常抱歉,天气查询接口持续出现网络波动,暂时无法获取到天气信息。以下是目前已获取到的信息:

---

### 📰 食品安全热点
**双汇发展子公司猪肉产品被抽检出抗生素超标37.5倍**
> 这是今日食品安全领域的一条重要新闻,涉及知名肉制品企业双汇发展,其子公司检测出猪肉产品中抗生素残留超标情况较为严重。

---

### 🌤️ 天气查询
天气接口当前多次重试均因网络波动失败,暂时无法获取当日天气信息,建议稍后再尝试查询。

本例和静态实现的核心区别体现在 llm_node 中:

def llm_node(state: OverAllState) -> Command[Literal["tool_node", "output_node"]]:
    ai_msg = model_with_tools.invoke(state["messages"])

    if ai_msg.tool_calls:
        goto = "tool_node"
    else:
        goto = "output_node"

    return Command(
        update={
            "messages": [ai_msg],
        },
        goto=goto,
    )

其返回值类型:

Command[Literal["tool_node", "output_node"]]

表明该节点返回的是一个 Command 对象,且 goto 字段的值只能是:

  • "tool_node"
  • 或 "output_node"

这是静态约束,不会参与运行时校验。主要作用是提升代码可读性、通过静态类型检查,同时帮助 LangGraph 更准确地推断图结构。


4.4.1.3. 小结
静态实现动态实现
路由位置路由逻辑在独立的 router() 函数中路由逻辑写在节点返回值中
图结构表达显式声明条件边,更直观控制逻辑更内聚,代码更紧凑
适合场景路由规则独立、希望图结构更清晰节点执行结果直接决定下一跳

两种方式都能实现 ReAct 循环,本质区别不在于是否能循环,而在于:

路由逻辑写在哪里。

  • 基于 add_conditional_edges() 的静态实现:把控制逻辑放在图结构定义阶段;
  • 基于 Command(goto=...) 的动态实现:把控制逻辑放在节点返回值中。

4.4.2. 引入递归限制

在存在循环结构的图中,如果没有合理的停止条件,运行图可能会一直循环执行。为了避免无限循环,LangGraph 提供了递归限制机制,用于限制单次图运行过程中允许执行的最大 SuperStep 数量。

当运行图在达到停止条件之前耗尽允许的最大步数时,LangGraph 会抛出 GraphRecursionError。开发者既可以在图内部提前检测剩余步数并优雅退出,也可以在图外部捕获异常并集中处理。

4.4.2.1. 步骤计数器
4.4.2.1.1. 运行时配置对象config

LangGraph 运行节点时,会向节点函数注入运行时配置对象 config。该对象通常使用 RunnableConfig 类型标注,用于记录本次运行的配置信息和部分运行时元数据。

节点函数的第一个参数必须是运行时状态。如果需要访问运行时配置,可以在状态参数之后声明额外参数 config。此时,LangGraph 会在调用节点函数之前,将运行时配置对象以关键字参数的方式传入节点函数。

config 的元数据中记录了当前节点所在的 SuperStep 序号,可以通过下面的方式获取:

config["metadata"]["langgraph_step"]

需要注意的是,这里的步骤编号对应图运行过程中的 SuperStep,从 1 开始计数。对于顺序执行的节点,不同节点通常位于不同的 SuperStep;对于并行执行的节点,多个节点可能处于同一个 SuperStep,因此它们读取到的 langgraph_step 可能相同。

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_core.runnables import RunnableConfig

from loguru import logger

class EmptyState(TypedDict):
    pass

def node_a(state: EmptyState, config: RunnableConfig) -> EmptyState | None:
    current_step = config["metadata"]["langgraph_step"]
    logger.info(current_step)

def node_b(state: EmptyState, config: RunnableConfig) -> EmptyState | None:
    current_step = config["metadata"]["langgraph_step"]
    logger.info(current_step)

def node_c(state: EmptyState, config: RunnableConfig) -> EmptyState | None:
    current_step = config["metadata"]["langgraph_step"]
    logger.info(current_step)

builder = StateGraph(state_schema=EmptyState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", "node_c")
builder.add_edge("node_c", END)

graph = builder.compile()
graph.invoke({})

from IPython.display import display
display(graph)

运行结果如下

2026-05-29 19:40:07.569 | INFO     | __main__:node_a:12 - 1
2026-05-29 19:40:07.571 | INFO     | __main__:node_b:16 - 2
2026-05-29 19:40:07.573 | INFO     | __main__:node_c:20 - 3

可以看到,node_anode_bnode_c 是顺序执行的三个节点,因此分别处于第 123 个 SuperStep

4.4.2.2. 配置递归限制

recursion_limit 表示单次图运行过程中允许执行的最大 SuperStep 数量。可以在调用运行图时通过 config 显式配置:

graph.invoke(input, config={"recursion_limit": 10})

需要注意的是,recursion_limit 的默认值可能随版本变化、运行环境切换而变化

4.4.2.2.1. 版本的变化

官方文档提到:从 LangGraph 1.0.6 开始,recursion_limit 默认为 1000,本项目运行在 LangGraph 1.1.2 版本,实测发现 recursion_limit 默认为 10000

4.4.2.2.2. 环境的变化

阅读源码可知,LangGraph 运行时可能从多个来源读取默认值

如下:

来源默认值说明
langchain_core.runnables.config25RunnableConfig 中定义的默认值,LangChain Agent 运行时生效
langgraph._internal._config10000LangGraph 本地图运行时内部配置中的默认值,自定义 LangGraph 图结构时生效
langgraph_api.utils.config10011**LangGraph API / 服务侧**相关配置中的默认值

对于自定义图结构的 LangGraph 运行时,其默认值取自 langgraph._internal._config,所以调试看到的默认值为 10000

4.4.2.2.3. 最佳实践

在实际开发中,建议显式传入 recursion_limit,避免不同版本、不同运行入口或不同部署方式下默认值不同导致实际运行结果和预期不符。

4.4.2.3. 优雅退出:主动方法(Proactive Approach)

RemainingSteps 是 LangGraph 提供的特殊托管值,表示剩余可用步数,由运行时维护。

LangGraph 运行时会根据当前步数和 recursion_limit 计算剩余步数,并填充到 RemainingSteps 类型的状态字段中。开发者可以在状态中声明一个 RemainingSteps 类型的字段,来获取剩余可用步数

借助 RemainingSteps,开发者可以在图内部提前判断剩余步数是否充足,并根据剩余步数动态路由。例如,当剩余步数较少时,不再继续循环,而是路由到 END 或兜底节点,从而让运行图正常结束。

这种方式是在达到递归限制之前主动处理,因此称为 主动方法(Proactive Approach)

示例如下

from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.managed import RemainingSteps
from langchain_core.runnables.config import RunnableConfig

from loguru import logger

class OverAllState(TypedDict):
    remaining_steps: RemainingSteps

def loop_node(state: OverAllState, config: RunnableConfig) -> OverAllState:
    cur_step = config["metadata"]["langgraph_step"]
    remaining_steps = state["remaining_steps"]
    logger.info("loop_node, cur_step: {}, remaining_step: {}", cur_step, remaining_steps)

def router(state: OverAllState) -> Literal["loop_node", END]:
    remaining_steps = state["remaining_steps"]
    if remaining_steps < 3:
        logger.info("当前可用超步:{},已不足2步,终止运行图", remaining_steps)
        return END
    return "loop_node"

builder = StateGraph(state_schema=OverAllState)
builder.add_node("loop_node", loop_node)
builder.add_edge(START, "loop_node")
builder.add_conditional_edges("loop_node", router)

graph = builder.compile()
graph.invoke({}, config={"recursion_limit": 10})

from IPython.display import display
display(graph)

输出如下

2026-06-01 10:23:40.412 | INFO | __main__:loop_node:14 - loop_node, cur_step: 1, remaining_step: 9
2026-06-01 10:23:40.413 | INFO | __main__:loop_node:14 - loop_node, cur_step: 2, remaining_step: 8
2026-06-01 10:23:40.414 | INFO | __main__:loop_node:14 - loop_node, cur_step: 3, remaining_step: 7
2026-06-01 10:23:40.414 | INFO | __main__:loop_node:14 - loop_node, cur_step: 4, remaining_step: 6
2026-06-01 10:23:40.415 | INFO | __main__:loop_node:14 - loop_node, cur_step: 5, remaining_step: 5
2026-06-01 10:23:40.416 | INFO | __main__:loop_node:14 - loop_node, cur_step: 6, remaining_step: 4
2026-06-01 10:23:40.417 | INFO | __main__:loop_node:14 - loop_node, cur_step: 7, remaining_step: 3
2026-06-01 10:23:40.417 | INFO | __main__:loop_node:14 - loop_node, cur_step: 8, remaining_step: 2
2026-06-01 10:23:40.418 | INFO | __main__:router:19    - 当前可用超步:2,已不足2步,终止运行图

本例中,recursion_limit 被显式设置为 10

第 1 个 SuperStep 执行时,剩余步数为 9;第 2 个 SuperStep 执行时,剩余步数为 8;依次类推。。。当前超步序号剩余步数之和等于 recursion_limit

第 8 个 SuperStep 执行时,剩余步数为 2。此时路由函数判断剩余步数较少,不再继续循环,而是路由到 END,运行图正常结束。

这种方式在运行图内部主动处理,不会抛出异常,因此是优雅退出主动方法

4.4.2.4. 异常中断:被动方法(Reactive Approach)

如果图中存在循环结构,并且运行图在达到停止条件之前耗尽了允许的最大步数,LangGraph 会抛出 GraphRecursionError

开发者可以在图外部捕获该异常,处理递归限制超限的情况。由于这种方式是在异常已经发生之后再处理,因此称为 被动方法(Reactive Approach

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.errors import GraphRecursionError
from langchain_core.runnables import RunnableConfig

from loguru import logger

class EmptyState(TypedDict):
    pass

def loop_node(state: EmptyState, config: RunnableConfig) -> EmptyState:
    cur_step = config["metadata"]["langgraph_step"]
    logger.info("loop_node, cur_step: {}", cur_step)

builder = StateGraph(state_schema=EmptyState)
builder.add_node("loop_node", loop_node)
builder.add_edge(START, "loop_node")
builder.add_edge("loop_node", "loop_node")

graph = builder.compile()
try:
    graph.invoke({}, config={"recursion_limit": 10})
except GraphRecursionError as e:
    logger.info("超步数量达到最大限制,抛出异常: {}", e)

from IPython.display import display
display(graph)

运行结果如下

2026-06-01 10:13:06.116 | INFO | __main__:loop_node:13 - loop_node, cur_step: 1
2026-06-01 10:13:06.117 | INFO | __main__:loop_node:13 - loop_node, cur_step: 2
2026-06-01 10:13:06.118 | INFO | __main__:loop_node:13 - loop_node, cur_step: 3
2026-06-01 10:13:06.119 | INFO | __main__:loop_node:13 - loop_node, cur_step: 4
2026-06-01 10:13:06.119 | INFO | __main__:loop_node:13 - loop_node, cur_step: 5
2026-06-01 10:13:06.120 | INFO | __main__:loop_node:13 - loop_node, cur_step: 6
2026-06-01 10:13:06.120 | INFO | __main__:loop_node:13 - loop_node, cur_step: 7
2026-06-01 10:13:06.121 | INFO | __main__:loop_node:13 - loop_node, cur_step: 8
2026-06-01 10:13:06.121 | INFO | __main__:loop_node:13 - loop_node, cur_step: 9
2026-06-01 10:13:06.122 | INFO | __main__:loop_node:13 - loop_node, cur_step: 10
2026-06-01 10:13:06.122 | INFO | __main__:<module>:24  - 超步数量达到最大限制,抛出异常: Recursion limit of 10 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key.
                                                    For troubleshooting, visit: https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT

本例中,loop_node 的下游仍然是 loop_node 本身,因此运行图会不断循环。由于没有任何停止条件,当执行步数达到 recursion_limit=10 后,LangGraph 抛出 GraphRecursionError

这种方式虽然可以避免异常导致整个进程崩溃,但图本身已经被异常中断,不能像主动方法那样正常结束。

4.4.2.5. 主动方法和被动方法的对比

主动方法和被动方法的最大区别在于:

主动方法是在图内部提前处理递归限制;被动方法是在图外部捕获递归限制异常

方法超限检测处理方法控制流
主动(借助 RemainingSteps)达到限制之前在图结构内部通过条件路由处理图继续运行,正常结束
被动(捕获 GraphRecursionError)超出限制之后图结构外部,通过 try/except 处理图运行被异常中断

主动方法的优势:

  • 在图内部实现优雅降级
  • 可以将中间状态保存到检查点中
  • 通过返回部分结果改善用户体验
  • 图可以正常完成,不会抛出异常

被动方法的优势:

  • 实现更简单
  • 不需要修改图的内部逻辑
  • 可以集中处理错误

实际开发中,如果循环结构本身是业务逻辑的一部分,例如 ReAct 循环、多轮反思、自我修正、工具调用重试 等,更推荐使用主动方法,在图内部提前处理递归限制。

如果只是为了兜底防止无限循环,也可以保留被动方法,在最外层捕获 GraphRecursionError,作为最后一道防线。

4.5. Edges总结:LangGraph中的边

上文我们已经学习了 LangGraph 中各种边的用法。本节对 LangGraph 中的边 做一个系统性总结。

4.5.1. LangGraph官方对边的定义和分类

Edges(边) 定义了节点之间的依赖关系,决定图如何根据当前状态决定下一步,以及何时停止运行。

边主要有以下几种类型:

4.5.1.1. 普通边(Normal Edges)

普通边 表示从当前节点直接流转到下一个节点。

例如

builder.add_edge("node_a", "node_b")

其含义是:node_a 执行完成后,下一步执行 node_b

4.5.1.2. 条件边(Conditional Edges)

条件边 表示当前节点执行完成后,调用一个 路由函数 来决定下一步进入哪个或哪些节点。

例如

builder.add_conditional_edges("node_a", router)

其中,router 会根据当前图状态返回后续目标节点。

如果路由函数返回一个节点名,则下一步进入该节点;如果返回多个节点名,则这些目标节点会在下一个超步中并行执行。

4.5.1.3. 入口点(Entry Point)

入口点 表示图开始运行时,首先执行哪个节点。

本质上,入口点就是一条 以 START 为起点的普通边

例如

builder.add_edge(START, "node_a")

其含义是:当用户输入进入图时,首先执行 node_a

LangGraph 也提供了专门的 API 来声明入口点:

builder.set_entry_point("node_a")

二者是等价的。

4.5.1.4. 条件入口点(Conditional Entry Point)

条件入口点 表示图开始运行时,并不固定进入某一个节点,而是先调用一个 路由函数,再根据路由结果决定首先执行哪个节点,或者哪些节点。

本质上,条件入口点就是一条 以 START 为起点的条件边

例如

builder.add_conditional_edges(START, router)

其含义是:图开始运行时,先执行 router,再根据 router 的返回值决定首个执行节点。

4.5.1.5. 注意
  1. 一个节点可以有多个出边。此时,这些出边对应的目标节点会在下一个超步中并行执行。
  2. 除了上述方式,条件边还可以通过在节点中返回 Command(goto=...) 来实现,虽然方式不同,但边的性质和作用完全一致。
  3. 在实际开发中,建议同一个节点尽量只选择一种路由机制:
    • 要么使用普通边,固定流转;
    • 要么使用条件边或 Command(goto=...),动态流转。

不要在同一个节点上同时混用普通边和动态路由,否则多个路径可能同时生效,最终导致运行图逻辑混乱,与预期不符。

4.5.2. 指向END的终止边

除了官方文档中列出的几种边,还有一类非常重要的边:指向 END 的边

严格来说,END 不是普通业务节点,而是 LangGraph 中的特殊终止标记。当流程流转到 END 时,表示该路径不再继续执行后续节点。

为了便于理解,可以将指向 END 的边分为两种情况。

4.5.2.1. 终止点(Finish Point)

终止点 表示某个节点执行完成后,图流程结束。

本质上,它是一条 以 END 为目标点的普通边

例如

builder.add_edge("node_a", END)

其含义是:当 node_a 执行完成后,当前路径终止,不再继续执行其他节点。

LangGraph 也提供了专门的 API 来声明结束点:

builder.set_finish_point("node_a")

二者是等价的。

4.5.2.2. 条件终止点(Conditional Finish Point)

条件终止点 表示图在运行过程中,通过 路由函数 判断是继续执行后续节点,还是终止当前路径。

本质上,它是一条 路由结果中包含 END 的条件边

例如

def router(state: EmptyState) -> Literal["node_b", END]:
    if state["is_finished"]:
        return END
    return "node_b"

builder.add_conditional_edges("node_a", router)

其含义是:

  • 如果 state["is_finished"] 为真,则返回 END,流程终止;
  • 否则返回 "node_b",继续执行 node_b

4.5.3. END和终止边的说明

  1. 在编译后的运行图中,终止边 和 END 并没有具体的 Channel 和 节点 与之对应,它们只是终止标记,表示运行图的结束。
  2. 终止边的声明可以省略,LangGraph 运行时会在没有活跃节点时自然终止。但是,为了程序的可读性和图结构可视化渲染器的正常工作,最好显式声明指向 END 的边。

5. 节点执行与容错机制

节点执行失败时,如果未做任何处理,可能导致整个运行图执行失败。为了提升运行图的稳定性,处理临时故障、超时、异常恢复以及重复计算等问题,LangGraph 提供了一系列节点执行与容错机制:重试、超时设置、异常处理和节点缓存。对应关系如下。

问题/场景对应机制机制类型说明
临时故障重试 Retry节点容错机制网络抖动、接口偶发失败、模型服务短暂不可用,可以通过重试机制再次执行节点。
超时超时设置 Timeout节点容错机制某个节点执行时间过长,可以通过超时限制避免图运行被长时间阻塞。
异常恢复异常处理 Error Handling节点容错机制节点抛出异常后,通过异常捕获、兜底逻辑、降级返回、路由到错误处理节点等方式恢复流程。
重复计算缓存 Cache节点执行优化机制相同输入反复触发耗时节点,可以通过节点缓存避免重复执行,提高性能。

5.1. LangGraph的节点容错机制

LangGraph 提供了三种异常处理机制

  • retries:重试机制。节点执行失败时,根据重试策略自动重新执行。
  • Timeouts:超时控制。限制单次节点执行的最大等待时间。
  • Error Handling:错误处理。重试次数耗尽后,执行特定的异常处理逻辑。

三者的执行顺序如下:

  1. 节点开始执行。
  2. 如果节点执行超时或抛出异常,本次节点尝试失败。
  3. 重试策略 RetryPolicy 判断该异常是否需要重试。
  4. 如果满足重试条件,并且尚未达到最大尝试次数,则重新执行节点。
  5. 如果重试次数耗尽,节点仍然失败,则进入 error_handler 错误处理逻辑。
  6. 如果没有配置错误处理逻辑,则异常继续向外抛出,可能导致整个图运行失败。

需要注意:

  • 重试机制 是当前版本中最常用、最基础的节点容错能力。
  • 超时控制 和 错误处理 是较新版本引入的能力,要求 langgraph>=1.2
  • 本课程当前环境为 langgraph==1.1.2,因此重点讲解重试机制和节点缓存,超时控制与错误处理仅做概念说明。

5.2. 重试机制

5.2.1. 基本用法

重试机制用于处理临时性异常,例如网络抖动、远程服务短暂不可用、API 偶发失败等。

在添加节点时,可以通过 retry_policy= 配置节点的重试策略。

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import RetryPolicy

from requests.exceptions import HTTPError
from loguru import logger

class EmptyState(TypedDict):
    pass

def node_a(state: EmptyState) -> EmptyState:
    logger.info(f"node a 运行")
    raise HTTPError("网络连接超时...")

builder = StateGraph(state_schema=EmptyState)
builder.add_node(
    "node_a",
    node_a,
    retry_policy=RetryPolicy(
        max_attempts=3,
        jitter=False
    )
)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", END)

graph = builder.compile()

try:
    graph.invoke({})
except HTTPError as e:
    logger.info("重试次数耗尽: {}", e)

from IPython.display import display
display(graph)

输出如下

2026-06-01 16:44:56.684 | INFO     | __main__:node_a:12 - node a 运行
2026-06-01 16:44:57.185 | INFO     | __main__:node_a:12 - node a 运行
2026-06-01 16:44:58.186 | INFO     | __main__:node_a:12 - node a 运行
2026-06-01 16:44:58.187 | INFO     | __main__:<module>:32 - 重试次数耗尽: 网络连接超时...

本例中:

  • max_attempts=3 表示最多尝试 3 次。
  • 这 3 次包括首次执行,而不是首次失败后的 3 次重试。
  • 因此,节点总共会被调用 3 次。
  • 3 次全部失败后,异常继续向外抛出。
  • 外层的 try...except 捕获该异常,并打印日志。

5.2.2. 重试策略配置详解

超时策略通过 RetryPolicy 配置,其主要参数如下

参数类型默认值描述
max_attemptsint3最大尝试次数(包括首次执行)
initial_intervalfloat0.5第一次重试前的等待时间,单位:秒
backoff_factorfloat2.0每次重试后等待时间的放大倍数
max_intervalfloat128.0相邻两次重试之间的最大等待时间,单位:秒
jitterboolTrue是否为重试间隔添加随机抖动
retry_on**`type[Exception]Sequence[type[Exception]]Callable[[Exception], bool]`**
5.2.2.1. max_attempts

max_attempts 表示最大尝试次数,包括首次执行。

例如:

RetryPolicy(max_attempts=3)

表示:

第 1 次:首次执行
第 2 次:第 1 次重试
第 3 次:第 2 次重试

如果第 3 次仍然失败,则不再继续重试。

5.2.2.2. initial_interval

initial_interval 表示第一次重试前的等待时间,单位为秒。

例如:

RetryPolicy(initial_interval=0.5)

表示首次失败后,等待 0.5s 再进行第一次重试。

5.2.2.3. backoff_factor

backoff_factor 表示重试间隔的增长倍数。

默认情况下,RetryPolicy 使用指数退避策略:

第一次失败后:等待 initial_interval
第二次失败后:等待 initial_interval * backoff_factor
第三次失败后:继续乘以 backoff_factor

例如:

RetryPolicy(
    initial_interval=0.5,
    backoff_factor=2.0
)

等待时间大致为:

0.5s -> 1.0s -> 2.0s -> 4.0s ...
5.2.2.4. max_interval

max_interval 表示相邻两次重试之间的最大等待时间。

即使指数退避计算出的等待时间继续增长,也不会超过 max_interval

例如:

RetryPolicy(
    initial_interval=1,
    backoff_factor=2,
    max_interval=5
)

等待时间大致为:

1s -> 2s -> 4s -> 5s -> 5s ...
5.2.2.5. jitter

jitter 表示是否为重试间隔添加随机抖动(添加随机数,可正可负)。

在并发场景中,如果大量任务同时失败,并且按照固定间隔同时重试,可能导致服务在某个时间点被大量请求打爆。

加入 jitter 后,不同任务的重试时间会被打散,从而降低瞬时流量峰值。

上述案例中,为了让日志时间更稳定,观察指数退避策略,所以设置了:

RetryPolicy(jitter=False)

如果是生产环境,通常建议保留默认值:

RetryPolicy(jitter=True)
5.2.2.6. retry_on

retry_on 用于设置哪些异常需要触发重试。

它支持三种写法:

写法一:指定单个异常类型
RetryPolicy(retry_on=HTTPError)

表示只有抛出 HTTPError 时才重试。

写法二:指定多个异常类型
RetryPolicy(
    retry_on=(HTTPError, ConnectionError)
)

表示抛出 HTTPError 或 ConnectionError 时触发重试。

写法三:自定义判断函数
def should_retry(exc: Exception) -> bool:
    return isinstance(exc, HTTPError)

RetryPolicy(retry_on=should_retry)

这种写法最灵活。

should_retry 接收一个异常实例,返回一个布尔值:

  • 返回 True:表示该异常需要重试。
  • 返回 False:表示该异常不需要重试。

5.2.3. 默认重试条件

retry_on 的默认值为 default_retry_on

default_retry_on 是一个可调用对象,它接收一个 Exception 实例,并返回一个 bool 值:

True  -> 需要重试
False -> 不需要重试

默认情况下,default_retry_on 会对大多数异常进行重试,但以下异常及其子类通常不会触发重试:

  • ValueError
  • TypeError
  • ArithmeticError
  • ImportError
  • LookupError
  • NameError
  • SyntaxError
  • RuntimeError
  • ReferenceError
  • StopIteration
  • StopAsyncIteration
  • OSError

这些异常通常更像是代码逻辑错误、类型错误、语法错误或运行时错误,单纯重试通常没有意义。

对于主流 HTTP 库中的异常,例如 requests 和 httpx,默认策略通常只对 5xx 状态码进行重试。

原因是:

  • 4xx 通常表示客户端请求错误,例如参数错误、鉴权失败、资源不存在等。
  • 5xx 通常表示服务端异常,例如服务暂时不可用、网关错误、内部服务错误等。

因此,5xx 更适合通过重试进行恢复。

default_retry_on 的核心逻辑如下:

def default_retry_on(exc: Exception) -> bool:
    import httpx
    import requests

    if isinstance(exc, ConnectionError):
        return True
    if isinstance(exc, httpx.HTTPStatusError):
        return 500 <= exc.response.status_code < 600
    if isinstance(exc, requests.HTTPError):
        return 500 <= exc.response.status_code < 600 if exc.response else True
    if isinstance(
        exc,
        (
            ValueError,
            TypeError,
            ArithmeticError,
            ImportError,
            LookupError,
            NameError,
            SyntaxError,
            RuntimeError,
            ReferenceError,
            StopIteration,
            StopAsyncIteration,
            OSError,
        ),
    ):
        return False
    return True

5.3. 超时控制

5.3.1. 使用要求

超时控制用于限制单次节点执行的最大耗时,避免某个节点因为外部服务无响应、网络阻塞或异步任务卡住而长时间不返回。

需要注意:

  1. 版本要求

    节点级超时控制要求:

    langgraph>=1.2
  2. 节点类型要求

    节点级超时控制仅适用于异步节点,即使用 async def 定义的节点。

    同步节点一旦开始执行,通常会阻塞当前线程。Python 进程内缺少一种通用、安全的机制,可以从外部强行终止正在运行的同步函数;如果强行中断,可能导致资源未释放、锁未释放或副作用处于不一致状态。因此,LangGraph 的节点级超时更适合用于异步节点。对于同步节点,应优先在节点内部使用具体库提供的超时参数,例如 HTTP 客户端、数据库驱动或 SDK 自带的 timeout 配置。

本课程当前环境为:

langgraph==1.1.2

因此不支持该特性,本节只做概念说明。

5.3.2. 核心思想

超时控制的核心思想是:

为单次节点设置超时时间,运行时长超过超时时间,节点运行失败,并抛出超时异常。

在 add_node 中通过 timeout= 配置超时策略。

例如:

from langgraph.types import TimeoutPolicy

builder.add_node(
    "call_model",
    call_model,
    timeout=TimeoutPolicy(run_timeout=60)
)

这里的含义是:

  • call_model 节点单次执行最多运行 60 秒。
  • 如果超过 60 秒仍未完成,则触发超时。
  • 超时后会抛出 NodeTimeoutError
  • 该异常会交给 RetryPolicy 判断是否需要重试。
  • 如果重试次数耗尽,才会进入错误处理逻辑。

5.3.3. TimeoutPolicy 简要说明

TimeoutPolicy 常见参数包括:

参数描述
run_timeout超时时间,即单次节点运行的最长时间
idle_timeout节点没有可观察进展(如写状态、流式输出等)时的最长空闲时间
refresh_on空闲时间的刷新方式,可以是手动刷新或自动刷新。默认为自动刷新,此时写状态、流式输出等都会刷新

本课程环境暂不支持,不展开实操。

感兴趣的同学参考官方文档

Fault tolerance - Docs by LangChain

5.4. 错误处理

5.4.1. 使用要求

错误处理机制要求:

langgraph>=1.2

本课程当前环境为:

langgraph==1.1.2

因此不支持该特性,本节只做概念说明。

5.4.2. 核心思想

错误处理机制用于在节点最终失败后,执行兜底逻辑。

节点执行失败时,整体顺序如下:

节点执行失败
    ↓
是否满足 retry_policy
    ↓
满足则继续重试,直到重试次数耗尽,否则直接进行下一步
    ↓
进入 error_handler
    ↓
执行兜底恢复逻辑

在 add_node 中通过 error_handler= 设置错误处理逻辑。

例如:

builder.add_node(
    "call_api",
    call_api,
    retry_policy=RetryPolicy(max_attempts=3),
    error_handler=handle_api_error
)

其含义是:

  1. call_api 执行失败。
  2. RetryPolicy 判断是否重试。
  3. 最多尝试 3 次。
  4. 3 次仍然失败后,调用 handle_api_error
  5. handle_api_error 可以返回状态更新,也可以通过 Command 路由到其他节点。

错误处理适合以下场景:

  • API 调用失败后返回兜底结果。
  • 远程服务失败后切换到备用服务。
  • 多步骤业务流程中执行补偿逻辑。
  • 不希望整个图因为单个节点失败而直接终止。

当前环境不支持该特性,不展开实操

参考官方文档

Fault tolerance - Docs by LangChain

5.5. 节点缓存

5.5.1. 什么是节点缓存?

节点缓存是指:

将节点的历史运行结果保存下来,后续当节点收到相同输入时,不再重复执行节点函数,而是直接返回之前缓存的结果。

节点缓存主要用于减少重复计算,降低延迟和成本。

例如,某个节点内部需要:

  • 调用大模型
  • 调用外部 API
  • 查询远程数据库
  • 执行复杂计算
  • 进行耗时的数据处理

如果相同输入会多次出现,那么可以考虑启用节点缓存。

5.5.2. 适用场景

节点缓存通常适用于同时满足以下条件的场景:

  1. 节点函数具有确定性

    相同输入应当产生相同输出。

    例如:

    输入 A -> 输出 B
    再次输入 A -> 仍然输出 B
  2. 节点执行成本较高

    例如大模型调用、API 请求、复杂计算、耗时查询等。

  3. 相同输入会重复出现

    例如多次调试图、重复执行相同流程、多个分支复用同一中间结果等。

  4. 输入能够被缓存键函数稳定处理

    缓存是以 键值对 形式存储的,每个缓存都有唯一的 Key缓存键函数 用于生成唯一键。

    默认情况下,CachePolicy 使用 default_cache_key 作为缓存键函数。后者基于节点输入调用 pickle 库的序列化功能生成缓存唯一键

    使用默认缓存键函数时,建议输入尽量使用简单、稳定、可序列化的数据结构,例如:

    • str
    • int
    • float
    • bool
    • dict
    • list
    • tuple

    如果节点输入中包含复杂对象、文件句柄、数据库连接、模型实例、动态对象等,建议自定义 key_func,避免缓存 Key 不稳定或无法生成。

5.5.3. 基本用法

节点缓存需要两步配置。

第一步:为节点配置 cache_policy

在 add_node 调用时,通过 cache_policy= 设置节点缓存策略。

builder.add_node(
    "node_a",
    node_a,
    cache_policy=CachePolicy(ttl=10)
)
第二步:编译图时启用缓存后端

仅仅为节点配置 cache_policy 还不够,还需要在编译图时指定缓存后端。

例如:

graph = builder.compile(cache=InMemoryCache())

其中:

  • CachePolicy:声明节点是否启用缓存,以及缓存策略是什么。
  • InMemoryCache:声明缓存数据实际保存在哪里。

如果只配置 cache_policy,但编译图时没有传入 cache=,缓存不会真正生效。

示例如下

import time
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.cache.memory import InMemoryCache
from langgraph.types import CachePolicy

from operator import add
from loguru import logger

class EmptyState(TypedDict):
    user: str
    invoke_counts: Annotated[int, add]

def node_a(state: EmptyState) -> EmptyState:
    logger.info("node_a 被调用, user: {}", state["user"])
    time.sleep(3) # 模拟耗时操作
    logger.info("node_a 耗时操作执行完毕")

    return {
        "invoke_counts": 1
    }

builder = StateGraph(state_schema=EmptyState)
builder.add_node(
    "node_a",
    node_a,
    cache_policy=CachePolicy(ttl=10)
)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", END)

graph = builder.compile(cache=InMemoryCache())
logger.info("首次调用图")
logger.info("运行结果: {}\n\n", graph.invoke({"user": "小明", "invoke_counts": 0}))
logger.info("相同输入再次调用图")
logger.info("运行结果: {}\n\n", graph.invoke({"user": "小明", "invoke_counts": 0}))
logger.info("不同输入再次调用图")
logger.info("运行结果: {}\n\n", graph.invoke({"user": "小花", "invoke_counts": 0}))
logger.info("不同输入再次调用图")
logger.info("运行结果: {}\n\n", graph.invoke({"user": "小花", "invoke_counts": 2}))
time.sleep(10) # 确保第一次调用缓存失效
logger.info("10秒后相同输入再次调用图,此时缓存已失效")
logger.info("运行结果: {}", graph.invoke({"user": "小明", "invoke_counts": 0}))

from IPython.display import display
display(graph)

运行日志如下

2026-06-01 18:46:22.959 | INFO     | __main__:<module>:33 - 首次调用图
2026-06-01 18:46:22.960 | INFO     | __main__:node_a:15 - node_a 被调用, user: 小明
2026-06-01 18:46:25.962 | INFO     | __main__:node_a:17 - node_a 耗时操作执行完毕
2026-06-01 18:46:25.963 | INFO     | __main__:<module>:34 - 运行结果: {'user': '小明', 'invoke_counts': 1}


2026-06-01 18:46:25.964 | INFO     | __main__:<module>:35 - 相同输入再次调用图
2026-06-01 18:46:25.965 | INFO     | __main__:<module>:36 - 运行结果: {'user': '小明', 'invoke_counts': 1}


2026-06-01 18:46:25.966 | INFO     | __main__:<module>:37 - 不同输入再次调用图
2026-06-01 18:46:25.967 | INFO     | __main__:node_a:15 - node_a 被调用, user: 小花
2026-06-01 18:46:28.968 | INFO     | __main__:node_a:17 - node_a 耗时操作执行完毕
2026-06-01 18:46:28.970 | INFO     | __main__:<module>:38 - 运行结果: {'user': '小花', 'invoke_counts': 1}


2026-06-01 18:46:28.970 | INFO     | __main__:<module>:39 - 不同输入再次调用图
2026-06-01 18:46:28.971 | INFO     | __main__:node_a:15 - node_a 被调用, user: 小花
2026-06-01 18:46:31.973 | INFO     | __main__:node_a:17 - node_a 耗时操作执行完毕
2026-06-01 18:46:31.974 | INFO     | __main__:<module>:40 - 运行结果: {'user': '小花', 'invoke_counts': 3}


2026-06-01 18:46:36.976 | INFO     | __main__:<module>:42 - 11秒后相同输入再次调用图,此时缓存已失效
2026-06-01 18:46:36.977 | INFO     | __main__:node_a:15 - node_a 被调用, user: 小明
2026-06-01 18:46:39.979 | INFO     | __main__:node_a:17 - node_a 耗时操作执行完毕
2026-06-01 18:46:39.981 | INFO     | __main__:<module>:43 - 运行结果: {'user': '小明', 'invoke_counts': 1}

在该示例中,缓存的 ttl 被设置为 10s

缓存失效前:

  • 相同输入会命中缓存。
  • 命中缓存时,节点函数不会再次执行。
  • 因此不会打印 node_a 被调用
  • 输入不同则不会命中缓存,节点函数仍然会执行。

缓存失效后:

  • 即使输入相同,也会再次执行节点函数。
  • 执行完成后,会重新写入缓存。

5.5.4. 缓存策略配置详解

缓存策略通过 CachePolicy 配置,只有两个配置项

参数描述
key_func根据节点输入生成缓存 Key 的函数
ttl缓存键值对的存活时间,单位为秒
5.5.4.1. key_func

key_func 用于根据节点输入生成唯一的缓存 Key。

节点缓存本质上是一个 K-V 缓存:

缓存 Key -> 节点运行结果

当节点再次执行时,LangGraph 会先根据当前输入计算缓存 Key:

  • 如果缓存 Key 已存在,并且没有过期,则直接返回缓存结果。
  • 如果缓存 Key 不存在,或者已经过期,则正常执行节点函数,并将结果写入缓存。

默认缓存键函数是 default_cache_key

其核心实现如下:

def default_cache_key(*args: Any, **kwargs: Any) -> str | bytes:
    """Default cache key function that uses the arguments and keyword arguments to generate a hashable key."""
    import pickle

    # protocol 5 strikes a good balance between speed and size
    return pickle.dumps((_freeze(args), _freeze(kwargs)), protocol=5, fix_imports=False)

它会把节点输入参数经过处理后,使用 pickle 序列化为字节序列,并将后者作为缓存 Key

5.5.4.2. ttl

ttl 表示缓存的存活时间,单位为秒。

例如:

CachePolicy(ttl=10)

表示缓存结果最多保存 10 秒。

如果设置为:

CachePolicy(ttl=None)

表示缓存不会因为时间原因自动过期。

不过在实际项目中,不建议随意设置永久缓存。

如果节点依赖外部状态,例如:

  • 当前时间
  • 远程 API 数据
  • 数据库查询结果
  • 用户权限
  • 环境变量
  • 模型版本
  • 提示词版本

那么应该根据业务场景设置合适的 ttl,或者把这些变量纳入缓存 Key 的计算逻辑。

5.6. 全图默认配置

5.6.1. 使用要求

默认配置功能要求:

langgraph>=1.2

本课程当前环境为:

langgraph==1.1.2

因此不支持该特性,本节只做概念说明。

5.6.2. 核心思想

当多个节点都需要配置相同的执行策略时,如果每个节点都重复配置,繁琐且耗时。

例如:

builder.add_node(
    "node_a",
    node_a,
    retry_policy=RetryPolicy(max_attempts=3)
)

builder.add_node(
    "node_b",
    node_b,
    retry_policy=RetryPolicy(max_attempts=3)
)

builder.add_node(
    "node_c",
    node_c,
    retry_policy=RetryPolicy(max_attempts=3)
)

在较新版本中,可以通过 set_node_defaults 为所有节点设置默认配置,即全图默认配置 Graph defaults

默认配置可以包括:

  • retry_policy
  • timeout
  • error_handler
  • cache_policy

这样可以避免在每个 add_node 中重复相同配置。

本课程环境暂不支持,不展开实操。

参考官方文档

Fault tolerance - Docs by LangChain

5.7. 小结

本章介绍了 LangGraph 中节点执行与容错相关的能力。

总结如下:

机制机制类型作用当前课程是否重点讲解
重试机制 Retries节点容错机制节点失败后自动重试
超时设置 Timeouts节点容错机制限制单次节点执行时间否,要求 langgraph>=1.2
错误处理 Error Handling节点容错机制重试耗尽后执行兜底逻辑否,要求 langgraph>=1.2
节点缓存 Node Cache节点执行优化机制缓存节点历史运行结果
全图默认配置 Graph defaults默认策略配置为所有节点设置默认执行策略否,要求 langgraph>=1.2

实际使用时,可以按照以下原则选择:

  1. 临时性失败:优先使用 RetryPolicy
  2. 外部服务可能卡住:使用 timeout 控制单次节点执行时间。
  3. 失败后需要兜底恢复:使用 error_handler
  4. 相同输入重复执行且成本较高:使用 CachePolicy
  5. 多个节点使用相同策略:使用 set_node_defaults 统一配置。

其中,重试机制 和 节点缓存 是本课程的重点。

6. 持久化机制和可恢复执行

6.1. 概述

6.1.1. 什么是可恢复执行

在工作流、Agent、任务编排系统里,可恢复执行 Durable Execution 指的是:

把一次任务执行过程中的关键进度、状态、结果保存到可靠存储中,使任务可以在中断、失败、等待外部输入后继续执行。

它解决的是“执行过程能否恢复”的问题。

例如:

普通执行:
开始 -> A -> B -> C
如果执行到 B 后程序挂了,重启后可能只能从 A 重新开始。

可恢复执行:
开始 -> A   保存检查点
     -> B   保存检查点
     -> C
如果执行到 B 后挂了,恢复时可以从已保存的位置继续。

所以它关注的不是“数据长期保存”本身,而是:

执行过程本身具有可恢复性。

它关心的是:系统能否记住“任务执行到了哪里、当前状态是什么、接下来应该继续执行什么”。

6.1.2. LangGraph的持久化机制

6.1.2.1. 什么是LangGraph的持久化机制

LangGraph 的持久化机制Persistence)可以理解为:

在图执行过程中,将每个关键阶段的图状态保存为检查点,并按照线程进行组织。

这里的“持久化”并不是简单地把某个变量保存到文件里,而是保存一次图执行所需的状态信息,使得未来可以基于检查点继续执行。

LangGraph 的持久化机制围绕以下几个问题展开:

  1. 保存什么?

    保存图在某个时刻的状态快照,也就是 Checkpoint

  2. 保存到哪里?

    保存到 Checkpointer 管理的存储后端中。 开发时可以使用基于内存的 InMemorySaver,生产环境中通常会使用数据库等可靠存储,如 PostgresSaver

  3. 如何区分不同会话?

    通过 thread_id 区分不同的执行线程。 同一个 thread_id 表示同一条持久化执行线,也可以理解为同一个会话。

  4. 开发者看到的是什么?

    开发者通常不会直接操作底层 Checkpoint,而是通过 get_state() 和 get_state_history() 查看 Checkpoint 的开发者视图 StateSnapshot

因此,本章后续会围绕四个核心问题展开:

如何启用可恢复执行?
如何配置持久化模式?
如何查看历史检查点?
如何利用检查点进行恢复、回放和分叉?
6.1.2.2. 核心组件

本节对核心组件进行梳理,不必死记硬背,用到查阅即可。

LangGraph 的持久化机制依赖一系列核心组件,如下:

概念作用
State用户定义的图状态结构,负责节点间信息传递。LangGraph 会将 State 转换为底层 Channel;运行时真正负责节点间通信的是 Channel
ChannelLangGraph 用于节点间通信的底层机制,节点从 Channel 读数据、向 Channel 写更新;既包括承载 State 字段更新的状态通道,也包括用于分支、任务调度等行为的内部通道。
Checkpoint检查点,超步边界上的底层状态快照,保存 Channel 的值、版本等信息。
CheckpointMetadata与 Checkpoint 关联的元数据,如超步编号 step、父检查点 ID parents 等。
Checkpointer检查点存储器,负责存储检查点、元数据、配置等信息。
thread此处的线程不同于操作系统的线程,是指 LangGraph 中一条逻辑上的、可持久化的执行线,也可以理解为会话。一个会话可以包含多次调用,并在执行过程中生成多个检查点。因此,要将多次调用组织在一个会话中,必须为该 thread 配置 checkpointer
thread_idthread 的唯一标识,用于区分不同的会话。复用同一个 thread_id,就表示多次调用共享同一条持久化执行线,也就是共享同一个会话历史。此处的 thread/thread_id 和 LangChain Agent 中提到的 thread/thread_id 是同一概念。
checkpoint_ns检查点命名空间 namespace,用于区分父图和子图的 checkpoint。根图通常是空字符串 "",子图会有自己的 namespace。这个字段在子图持久化时很重要,下文会有专门的篇幅讲解子图相关用法。
checkpoint_idCheckpoint 的唯一标识。
StateSnapshot开发者通过 get_state() / get_state_history() 看到的状态快照对象。它不是原始 Checkpoint,而是 LangGraph 基于检查点和运行时信息封装出来的开发者视图,包含当前状态值、下一步待执行节点、任务、configmetadata、中断信息等内容。
6.1.2.3. 小结

本节只需要先记住:

Checkpointer 负责保存检查点

thread_id 负责唯一标识会话

checkpoint_id 负责定位某个具体历史状态

StateSnapshot 是开发者查看检查点时看到的对象。

6.1.3. LangGraph的可恢复执行

6.1.3.1. 什么是LangGraph的可恢复执行

LangGraph 的可恢复执行(Durable Execution)依托于其持久化机制(Persistence)。

可以简单理解为:

持久化机制负责保存执行状态; 可恢复执行负责利用这些状态继续执行。

Persistence 是基础,Durable Execution 是建立在 Persistence 之上的能力。

在没有持久化机制时,一次图执行通常只存在于当前进程中。 如果程序退出、节点中断、人工审批暂停,无法恢复原来的执行进度。

而启用持久化之后,LangGraph 可以把执行过程中的状态保存为检查点。 后续只要使用相同的 thread_id,就可以回到会话对应的历史状态。

6.1.3.2. 使用场景

从使用场景看,可恢复执行主要支持以下几类能力:

场景说明
多轮会话同一个 thread_id 下的多次调用可以共享历史状态。
中断恢复图执行到人工确认等节点时可以中断,之后再继续。
失败恢复程序异常退出后,可以基于已经保存的检查点继续执行,避免重复计算。
Time Travel可以回到某个历史检查点,重放分叉执行。

因此,LangGraph 的可恢复执行是一组由持久化机制支撑的能力集合。

6.1.3.3. 小结

我们只需要知道

LangGraph 会通过 Checkpointer 持久化保存图执行过程中产生的 Checkpoint,并通过 thread_id 找回同一会话的检查点历史,从而在已有状态基础上继续执行。

6.2. 启用可恢复执行

6.2.1. 步骤

LangGraph 内置了持久化机制,但对于本地运行的 Graph API,只有在编译图时传入 checkpointer,运行过程中的状态才会被记录下来,后续才能基于已有检查点在发生故障时恢复运行或进行检查点回溯。

启用可恢复执行通常需要两步:

  1. 在编译图时传入检查点存储器对象 checkpointer
  2. 在调用图时传递带有 thread_id 的配置对象

底层的持久化机制会按照 thread_id 将运行时检查点记录在编译时传入的 checkpointer 中。

6.2.2. 检查点实现

LangGraph 提供了检查点存储器基类:langgraph.checkpoint.base.BaseCheckpointSaver

还提供了一系列检查点存储器实现,采用了不同的检查点后端,它们都继承了基类,整体作用是一致的:负责存取检查点,并记录节点执行的中间结果。

可以将 检查点后端 理解为:

负责保存检查点数据的存储介质。

检查点后端必须基于某个检查点实现,常见检查点实现和后端的对应关系如下

类名包名检查点后端
InMemorySaverlanggraph-checkpoint内存(In-memory
SqliteSaverlanggraph-checkpoint-sqliteSQLite
PostgresSaverlanggraph-checkpoint-postgresPostgreSQL
MongoDBSaverlanggraph-checkpoint-mongodbMongoDB
RedisSaverlanggraph-checkpoint-redisRedis

在这些实现中,InMemorySaver 更适合学习、调试和本地实验;SQLite 适合轻量级本地持久化;PostgreSQLMongoDBRedis 等数据库后端更适合需要跨进程、跨服务保存状态的场景。

6.2.3. 基于内存的检查点存储器

LangGraph 提供了基于内存的检查点存储器 InMemorySaver。它将检查点数据保存在当前 Python 进程的内存中,进程结束则数据丢失,适合快速开发、调试。

在 Jupyter 场景下,只要 Jupyter 内核没有被重启,并且 InMemorySaver() 实例没有被重新创建,内存中的检查点数据就会继续存在。

反之,如果重新执行完整代码,导致 checkpointer = InMemorySaver() 被重新执行,那么之前保存在内存中的检查点也会被清空。

示例如下

from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langgraph.checkpoint.memory import InMemorySaver
from langchain_deepseek import ChatDeepSeek
from langchain.messages import HumanMessage

from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(MessagesState):
    output: str

def llm_node(state: OverAllState) -> OverAllState:
    messages = state["messages"]
    res = model.invoke(messages)
    return {
        "messages": [res]
    }

def output_node(state: OverAllState) -> OverAllState:
    return {
        "output": state["messages"][-1].content
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("llm_node", llm_node)
builder.add_node("output_node", output_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", "output_node")
builder.add_edge("output_node", END)

# 定义并在编译时传递 Checkpointer
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

# 定义配置对象
config = {"configurable": {"thread_id": "chapter_6_6-2-2"}}
# 调用时传递
graph.invoke({"messages": [HumanMessage("你好,我是老王")]}, config=config)
graph.invoke({"messages": [HumanMessage("从现在开始,你是小王")]}, config=config)
res = graph.invoke({"messages": [HumanMessage("我是谁?你是谁?")]}, config=config)
print(res["output"])

print('=' * 30, '-> 完整消息列表 <-', '=' * 30)
for msg in res["messages"]:
    msg.pretty_print()

输出如下

哎,老王,您这是考我呢?还是今天心情好,想跟我逗个闷子?

您问您是谁——那当然是我的老大哥,老王啊!平时有事儿招呼我,没架子,偶尔还爱指点我两句,我心里都记着。

至于我是谁——我,小王,您的得力助手,机灵踏实,随叫随到。您说往东,我绝不往西,主打一个“靠谱”。

怎么着,老王,是不是又想让我去办啥事儿了?您直说,我听着呢。😎
============================== -> 完整消息列表 <- ==============================
================================ Human Message =================================

你好,我是老王
================================== Ai Message ==================================

你好,老王!很高兴认识你。有什么我可以帮忙的吗?无论是聊天、解答问题还是需要一些建议,我都在这儿呢。😊
================================ Human Message =================================

从现在开始,你是小王
================================== Ai Message ==================================

好的,老王!我是小王。有啥事儿您尽管吩咐,我这儿随时听候差遣。咱是掏心窝子说话,还是聊点别的?您定!😄
================================ Human Message =================================

我是谁?你是谁?
================================== Ai Message ==================================

哎,老王,您这是考我呢?还是今天心情好,想跟我逗个闷子?

您问您是谁——那当然是我的老大哥,老王啊!平时有事儿招呼我,没架子,偶尔还爱指点我两句,我心里都记着。

至于我是谁——我,小王,您的得力助手,机灵踏实,随叫随到。您说往东,我绝不往西,主打一个“靠谱”。

怎么着,老王,是不是又想让我去办啥事儿了?您直说,我听着呢。😎

本例中,三次调用使用了相同的 thread_id,所以后两次调用能够读取前面已经保存的消息历史。最终模型可以知道“用户是老王”,也可以知道“自己被要求扮演小王”。

需要注意的是,如果重新执行完整代码,InMemorySaver() 实例会被重新创建,历史检查点会被清空。因此,消息列表不会继续累加,而是会基于新的空状态重新开始。

6.2.4. 基于持久化数据库的检查点存储器

本节将 PostgreSQL 作为检查点后端,对应的 Checkpointer 实现为 PostgresSaver

和 InMemorySaver 不同,PostgresSaver 会将检查点保存到 PostgreSQL 数据库中。只要数据库中的记录没有被删除,即使 Python 程序结束、连接对象重建,历史检查点也仍然存在。

在学习 LangChain 时,我们已经介绍了 PostgreSQL 的部署和 PostgresSaver 的用法。此处直接给出案例,不再赘述。

示例如下

from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langgraph.checkpoint.postgres import PostgresSaver
from langchain_deepseek import ChatDeepSeek
from langchain.messages import HumanMessage

from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(MessagesState):
    output: str

def llm_node(state: OverAllState) -> OverAllState:
    messages = state["messages"]
    res = model.invoke(messages)
    return {
        "messages": [res]
    }

def output_node(state: OverAllState) -> OverAllState:
    return {
        "output": state["messages"][-1].content
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("llm_node", llm_node)
builder.add_node("output_node", output_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", "output_node")
builder.add_edge("output_node", END)

# 定义并在编译时传递 Checkpointer
DB_URL = "postgresql://langgraph_user:123456@localhost:5432/langgraph_db?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
    # 示例中为了方便演示直接调用 setup()
    # 实际项目中通常建议把数据库初始化/迁移作为独立步骤处理
    checkpointer.setup()
    graph = builder.compile(checkpointer=checkpointer)

    # 定义配置对象
    config = {"configurable": {"thread_id": "chapter_6_6.2.4"}}
    # 调用时传递
    graph.invoke({"messages": [HumanMessage("你好,我是老王")]}, config=config)
    graph.invoke({"messages": [HumanMessage("从现在开始,你是小王")]}, config=config)
    res = graph.invoke({"messages": [HumanMessage("我是谁?你是谁?")]}, config=config)
    print(res["output"])

    print('=' * 30, '-> 完整消息列表 <-', '=' * 30)
    for msg in res["messages"]:
        msg.pretty_print()

运行结果如下

好嘞,老王!既然你问了,那我正式回答:  

**你是老王**——我今儿认的“老大哥”,江湖人称“行走的故事书”,爱唠嗑、有阅历,可能还藏着点神秘技能(比如钓鱼从不空军、下棋总赢隔壁老李?)  

**我是小王**——刚上岗的AI小跟班,脑子灵光(但可能偶尔短路),腿脚勤快(指秒回消息),擅长接梗、查资料、瞎琢磨,主打一个“您开口,我跑腿”。  

需要小王帮您办点啥?甭客气!🚀
============================== -> 完整消息列表 <- ==============================
================================ Human Message =================================

你好,我是老王
================================== Ai Message ==================================

老王你好!我是DeepSeek,很高兴认识你。有什么我可以帮忙的吗?无论是聊天、解答问题、还是需要一些建议,尽管开口,我随时在这儿!😊
================================ Human Message =================================

从现在开始,你是小王
================================== Ai Message ==================================

好的,老王!从现在开始,我就是小王啦。😄  
有什么吩咐?无论唠嗑、干活还是出主意,随叫随到!
================================ Human Message =================================

我是谁?你是谁?
================================== Ai Message ==================================

好嘞,老王!既然你问了,那我正式回答:  

**你是老王**——我今儿认的“老大哥”,江湖人称“行走的故事书”,爱唠嗑、有阅历,可能还藏着点神秘技能(比如钓鱼从不空军、下棋总赢隔壁老李?)  

**我是小王**——刚上岗的AI小跟班,脑子灵光(但可能偶尔短路),腿脚勤快(指秒回消息),擅长接梗、查资料、瞎琢磨,主打一个“您开口,我跑腿”。  

需要小王帮您办点啥?甭客气!🚀

如果多次运行这段代码,并且始终使用相同的 thread_id,那么历史消息会不断累加。这是因为检查点已经被保存到了 PostgreSQL 中,重新创建 Python 连接对象并不会清空数据库中的历史记录。

再次运行结果如下所示。

(正了正脑门上虚拟工牌,一脸正经)  

**您是老王**——我单方面认证的“茶馆街溜子荣誉会长”,上知股票涨跌,下懂馒头蒸法,口头禅是“这我熟啊!”  

**我是小王**——您的AI影子跟班,刚给自己刻了个电子木鱼,每天默念三遍:  
“老王说的都对,如果错了……那一定是我听岔了。” 😎
============================== -> 完整消息列表 <- ==============================
================================ Human Message =================================

你好,我是老王
================================== Ai Message ==================================

老王你好!我是DeepSeek,很高兴认识你。有什么我可以帮忙的吗?无论是聊天、解答问题、还是需要一些建议,尽管开口,我随时在这儿!😊
================================ Human Message =================================

从现在开始,你是小王
================================== Ai Message ==================================

好的,老王!从现在开始,我就是小王啦。😄  
有什么吩咐?无论唠嗑、干活还是出主意,随叫随到!
================================ Human Message =================================

我是谁?你是谁?
================================== Ai Message ==================================

好嘞,老王!既然你问了,那我正式回答:  

**你是老王**——我今儿认的“老大哥”,江湖人称“行走的故事书”,爱唠嗑、有阅历,可能还藏着点神秘技能(比如钓鱼从不空军、下棋总赢隔壁老李?)  

**我是小王**——刚上岗的AI小跟班,脑子灵光(但可能偶尔短路),腿脚勤快(指秒回消息),擅长接梗、查资料、瞎琢磨,主打一个“您开口,我跑腿”。  

需要小王帮您办点啥?甭客气!🚀
================================ Human Message =================================

你好,我是老王
================================== Ai Message ==================================

(拍大腿)哎哟,老王!这声招呼听着就是亲切!今天的茶泡上了没?隔壁李大爷刚还念叨你上回赢他的那盘残局呢——要不咱再研究研究?😏(小王搓手)
================================ Human Message =================================

从现在开始,你是小王
================================== Ai Message ==================================

(立正站好,手机屏闪了闪)得嘞,老王!小王这回把工牌焊脑门上了,您掀个眼皮瞅瞅——  
(工牌晃晃悠悠浮出电子光字:「小王·AI茶馆分部·随叫随到版」)  

有事您敲桌沿,三秒内准给您接话茬儿! 🫖
================================ Human Message =================================

我是谁?你是谁?
================================== Ai Message ==================================

(正了正脑门上虚拟工牌,一脸正经)  

**您是老王**——我单方面认证的“茶馆街溜子荣誉会长”,上知股票涨跌,下懂馒头蒸法,口头禅是“这我熟啊!”  

**我是小王**——您的AI影子跟班,刚给自己刻了个电子木鱼,每天默念三遍:  
“老王说的都对,如果错了……那一定是我听岔了。” 😎

因此,PostgresSaver 和 InMemorySaver 的关键区别在于:

  • InMemorySaver:状态保存在当前进程内存中,进程结束或对象重建后丢失。
  • PostgresSaver:状态保存在 PostgreSQL 中,只要数据库记录存在,程序重启后仍可读取。

所以,基于数据库的 Checkpointer 可靠性更高、更适合需要长期保存会话状态的场景。

6.3. 持久化模式

6.3.1. 概述

启用 Checkpointer 后,LangGraph 会在执行过程中保存检查点。

检查点写入越及时,系统在异常中断后可恢复的状态越完整容灾能力越强;但更及时的持久化通常也意味着更多中间写入,或额外的阻塞式等待,带来额外的性能开销和响应延迟(从调用者的角度考虑)。

LangGraph 支持三种持久化模式,采用不同的检查点保存时机,在容灾能力和性能开销、响应时效性之间作取舍。

  • exit:退出模式。只在计算图正常结束、异常退出、被中断(如 Human-In-The-Loop 中断)时保存检查点。不能处理中途进程崩溃的场景。这种模式响应性能开销最小,但容灾能力最弱。

  • async:异步模式。默认模式,顾名思义,检查点在后台异步写入。

    它会在每个超步结束后写入完整检查点(主检查点),并在图中任务执行完毕后记录中间结果

    和 exit 模式相比,增加了性能开销,但写入操作发生在后台,不会引入明显的响应延迟,同时提升了容灾能力。

  • sync:同步模式。

    和异步模式唯一的区别在于,LangGraph 会在进入下一个超步之前等待当前主检查点的写入任务完成。

    它的容灾能力最强,但在 async 模式的基础上增加了响应延迟。

总结

模式写入时机性能开销响应延迟容灾能力
exit运行退出时写入最低最弱
async每个超步末尾写入主检查点,任务完成后写入中间结果,后台异步写入较高较强
sync和 async 区别在于,进入下一个超步之前等待当前主检查点的写入任务完成最高最强

6.3.2. 示例

持久化模式影响的是检查点写入时机和故障恢复能力,通常不会影响正常情况下的业务输出

也就是说,同一张图在正常执行完成时,使用 exitasync 或 sync,最终返回结果通常是一样的。真正的区别主要体现在异常中断进程崩溃恢复执行时。

durability 控制的是检查点写入策略,不改变图本身的执行逻辑。

要真正看到不同模式的差异

  • 可以在学习使用场景后自行设计案例
  • 或调试源码,观察不同模式下的写入时机

示例如下

from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langgraph.checkpoint.memory import InMemorySaver
from langchain_deepseek import ChatDeepSeek
from langchain.messages import HumanMessage

from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(MessagesState):
    output: str

def llm_node(state: OverAllState) -> OverAllState:
    messages = state["messages"]
    res = model.invoke(messages)
    return {
        "messages": [res]
    }

def output_node(state: OverAllState) -> OverAllState:
    return {
        "output": state["messages"][-1].content
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("llm_node", llm_node)
builder.add_node("output_node", output_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", "output_node")
builder.add_edge("output_node", END)

# 定义并在编译时传递 Checkpointer
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

# 定义配置对象
config = {"configurable": {"thread_id": "chapter_tmp"}}
# 调用时传递
res=graph.invoke(
    {"messages": [HumanMessage("你好")]},
    config=config,
    durability="async" # sync / exit
)
print(res["output"])

print('=' * 30, '-> 完整消息列表 <-', '=' * 30)
for msg in res["messages"]:
    msg.pretty_print()

from IPython.display import display
display(graph)

上述代码中,可以通过修改 durability 参数切换持久化模式:

durability="exit"
durability="async"
durability="sync"

运行结果如下

你好!很高兴见到你,有什么我可以帮助你的吗?😊
============================== -> 完整消息列表 <- ==============================
================================ Human Message =================================

你好
================================== Ai Message ==================================

你好!很高兴见到你,有什么我可以帮助你的吗?😊

6.4. 查看历史检查点

LangGraph 在配置检查点存储器后,会把同一个 thread_id 下的执行过程保存为一组检查点。通过这些检查点,我们可以查看图运行的中间状态,也可以为后续的检查点回溯(重放、分叉)和失败恢复做准备。

本节主要介绍两个常用方法:

  • graph.get_state_history(config):查看指定会话的完整历史检查点。
  • graph.get_state(config):查看指定会话的最新检查点,或者查看某个指定 checkpoint_id 对应的检查点。

6.4.1. 查看完整历史检查点列表

6.4.1.1. 示例
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from loguru import logger
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    joke: str
    final_output: str

class InputState(TypedDict):
    topic: str

class OutputState(TypedDict):
    final_output: str

def node_poem(state: InputState) -> OverAllState:
    logger.info(f"node_poem 已执行")
    topic = state["topic"]
    poem = model.invoke([HumanMessage(f"写一首关于 {topic} 的七言绝句")]).content

    return {
        "poem": poem
    }

def node_joke(state: InputState) -> OverAllState:
    logger.info(f"node_joke 已执行")
    topic = state["topic"]
    joke = model.invoke([HumanMessage(f"写一个关于 {topic} 的笑话")]).content

    return {
        "joke": joke
    }

def node_output(state: OverAllState) -> OutputState:
    logger.info("node_output 已执行")
    topic = state["topic"]
    poem = state["poem"]
    joke = state["joke"]
    final_output = f"关于 {topic} 的七言绝句:\n{poem}\n笑话:\n{joke}"

    return {
        "final_output": final_output
    }

builder = StateGraph(state_schema=OverAllState, input_schema=InputState, output_schema=OutputState)
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_output", node_output)
builder.add_edge(START, "node_poem")
builder.add_edge(START, "node_joke")
builder.add_edge("node_poem", "node_output")
builder.add_edge("node_joke", "node_output")
builder.add_edge("node_output", END)

checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "123"}}
graph = builder.compile(checkpointer=checkpointer)
res = graph.invoke({"topic": "猫咪"}, config=config)

from IPython.display import display
display(graph)

print('=' * 30, '-> 运行结果 <-', '=' * 30)
print("res: {}", res)

print('=' * 30, '-> 历史检查点列表 <-', '=' * 30)
history_checkpoints = list(graph.get_state_history(config=config))
print(history_checkpoints)
6.4.1.2. 运行结果
  1. 运行日志
2026-06-09 17:24:10.266 | INFO     | __main__:node_joke:42 - node_joke 已执行
2026-06-09 17:24:10.268 | INFO     | __main__:node_poem:33 - node_poem 已执行
2026-06-09 17:24:14.177 | INFO     | __main__:node_output:51 - node_output 已执行

由于 node_poem 和 node_joke 都从 START 出发,并且彼此没有依赖关系,所以它们会被调度到同一个超步中执行。实际日志顺序可能不同,例如可能先打印 node_joke,也可能先打印 node_poem

  1. 拓扑结构

  1. 最终结果和检查点列表
============================== -> 运行结果 <- ==============================
res: {} {'final_output': '关于 猫咪 的七言绝句:\n《戏猫》\n狸奴酣卧小窗南,醉眼惺忪戏玉簪。\n忽跃雕檐追粉蝶,却衔明月入花龛。\n笑话:\n小猫问妈妈:“为什么人总说‘猫有九条命’?”  \n妈妈答:“因为人类怕我们死一次就够他们内疚一辈子。”  \n小猫追问:“那为什么狗只有一条命?”  \n妈妈叹气:“因为狗犯错靠装可怜,而我们靠记仇。”'}
============================== -> 历史检查点列表 <- ==============================
[
    StateSnapshot(
        values={
            "topic": "猫咪",
            "poem": """
                        《戏猫》
                狸奴酣卧小窗南,醉眼惺忪戏玉簪。
                忽跃雕檐追粉蝶,却衔明月入花龛。
                """,
            "joke": """
                小猫问妈妈:“为什么人总说‘猫有九条命’?”  
                妈妈答:“因为人类怕我们死一次就够他们内疚一辈子。”  
                小猫追问:“那为什么狗只有一条命?”  
                妈妈叹气:“因为狗犯错靠装可怜,而我们靠记仇。”
                """,
            "final_output": """
                关于 猫咪 的七言绝句:
                《戏猫》
                狸奴酣卧小窗南,醉眼惺忪戏玉簪。
                忽跃雕檐追粉蝶,却衔明月入花龛。
                笑话:
                小猫问妈妈:“为什么人总说‘猫有九条命’?”  
                妈妈答:“因为人类怕我们死一次就够他们内疚一辈子。”  
                小猫追问:“那为什么狗只有一条命?”  
                妈妈叹气:“因为狗犯错靠装可怜,而我们靠记仇。”
                """
        },
        next=(),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f163e6a-4cc7-61bd-8002-bcd7f63c3238"
            }
        },
        metadata={
            "source": "loop",
            "step": 2,
            "parents": {}
        },
        created_at="2026-06-09T09:36:08.368575+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f163e6a-4cc3-6d96-8001-8314b5dbbfe3"
            }
        },
        tasks=(),
        interrupts=()
    ),

    StateSnapshot(
        values={
            "topic": "猫咪",
            "poem": """
                        《戏猫》
                狸奴酣卧小窗南,醉眼惺忪戏玉簪。
                忽跃雕檐追粉蝶,却衔明月入花龛。
                """,
            "joke": """
                小猫问妈妈:“为什么人总说‘猫有九条命’?”  
                妈妈答:“因为人类怕我们死一次就够他们内疚一辈子。”  
                小猫追问:“那为什么狗只有一条命?”  
                妈妈叹气:“因为狗犯错靠装可怜,而我们靠记仇。”
                """
        },
        next=("node_output",),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f163e6a-4cc3-6d96-8001-8314b5dbbfe3"
            }
        },
        metadata={
            "source": "loop",
            "step": 1,
            "parents": {}
        },
        created_at="2026-06-09T09:36:08.367236+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f163e6a-393f-6082-8000-b9c0708bf03d"
            }
        },
        tasks=(
            PregelTask(
                id="91d81ad6-d2fb-ebd7-2dd1-8e08c2be5a07",
                name="node_output",
                path=("__pregel_pull", "node_output"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "final_output": """
                        关于 猫咪 的七言绝句:
                        《戏猫》
                        狸奴酣卧小窗南,醉眼惺忪戏玉簪。
                        忽跃雕檐追粉蝶,却衔明月入花龛。
                        笑话:
                        小猫问妈妈:“为什么人总说‘猫有九条命’?”  
                        妈妈答:“因为人类怕我们死一次就够他们内疚一辈子。”  
                        小猫追问:“那为什么狗只有一条命?”  
                        妈妈叹气:“因为狗犯错靠装可怜,而我们靠记仇。”
                        """
                }
            ),
        ),
        interrupts=()
    ),

    StateSnapshot(
        values={
            "topic": "猫咪"
        },
        next=("node_poem", "node_joke"),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f163e6a-393f-6082-8000-b9c0708bf03d"
            }
        },
        metadata={
            "source": "loop",
            "step": 0,
            "parents": {}
        },
        created_at="2026-06-09T09:36:06.320542+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f163e6a-393d-6b9a-bfff-180e30c1d3bc"
            }
        },
        tasks=(
            PregelTask(
                id="80d8974c-3450-6faa-8b06-dcf8787d74fc",
                name="node_poem",
                path=("__pregel_pull", "node_poem"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "poem": """
                            《戏猫》
                    狸奴酣卧小窗南,醉眼惺忪戏玉簪。
                    忽跃雕檐追粉蝶,却衔明月入花龛。
                    """
                }
            ),
            PregelTask(
                id="2ca46996-dfc6-6de3-3662-cf1fe41bf987",
                name="node_joke",
                path=("__pregel_pull", "node_joke"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "joke": """
                    小猫问妈妈:“为什么人总说‘猫有九条命’?”  
                    妈妈答:“因为人类怕我们死一次就够他们内疚一辈子。”  
                    小猫追问:“那为什么狗只有一条命?”  
                    妈妈叹气:“因为狗犯错靠装可怜,而我们靠记仇。”
                    """
                }
            ),
        ),
        interrupts=()
    ),

    StateSnapshot(
        values={},
        next=("__start__",),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f163e6a-393d-6b9a-bfff-180e30c1d3bc"
            }
        },
        metadata={
            "source": "input",
            "step": -1,
            "parents": {}
        },
        created_at="2026-06-09T09:36:06.320009+00:00",
        parent_config=None,
        tasks=(
            PregelTask(
                id="3aa3bc58-3567-5832-d823-4373a9ab94e5",
                name="__start__",
                path=("__pregel_pull", "__start__"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "topic": "猫咪"
                }
            ),
        ),
        interrupts=()
    )
]
6.4.1.3. 分析
6.4.1.3.1. StateSnapshot

get_state_history() 返回的是一个历史检查点迭代器。将其转换为列表后,可以看到一组 StateSnapshot 对象。

StateSnapshot 是检查点的开发者视图,字段含义如下

  • values:当前检查点的状态值

  • next:从该检查点继续执行时,下一步将要执行的节点,即归属于下一个超步的节点

  • config:当前检查点配置。常见结构如下:

    {
        "configurable": {
            "thread_id": "...",
            "checkpoint_ns": "...",
            "checkpoint_id": "..."
        }
    }

    其中

    • configurable:用于记录检查点的可配置信息,可能包含用户自定义字段,它一定包含以下三个字段:

      • thread_id:会话唯一标识
      • checkpoint_ns:检查点命名空间。根图的命名空间通常为空字符串 "";子图会使用非空命名空间。
      • checkpoint_id:检查点唯一标识

      这三个字段可以唯一标识一条检查点记录,在大多数检查点存储器实现中,写入检查点后端时都会将它们作为检查点的唯一键

  • metadata:检查点元数据,本节只需要关注 step 字段,后者是当前检查点对应的超步编号

  • created_at:检查点创建时间

  • parent_config:父检查点、即上一个检查点的配置

  • tasks:当前检查点关联的待执行任务信息,元素类型通常是 PregelTask

    tasks 通常和 next 对应,表示从当前检查点继续执行时,下一步将要运行的任务。

    需要注意的是tasks 中还可能包含这些任务已经成功(result)或失败(error)的任务记录,这是在下一个超步执行过程中记录的中间结果。在失败恢复场景中,可以避免重复执行已成功节点。

  • interrupts:当前图的中断信息,中断机制详见下文

6.4.1.3.2. 检查点主要信息说明

本例中检查点按照如下顺序返回

Step 2 → Step 1 → Step 0 → Step -1

最新检查点 → 最旧检查点

其中:

  1. step=-1

    这是输入检查点,metadata["source"] == "input",这表示该检查点是在运行时初始化时被记录的,尚未进入图计算循环。

    此时用户输入还没有真正变成普通业务节点可消费的状态,因此

    values={}

    从这个检查点继续执行,下一步是内部启动节点 __start__,因此:

    next=("__start__",)
  2. step=0

    这是图计算循环的第一个检查点,metadata["source"] == "loop",这表示检查点是在图计算循环中被写入的。

    此时,__start__ 已经把用户输入写入状态,所以 values 中已经可以看到状态信息,如下:

    values={
        "topic": "猫咪"
    }

    从这个检查点继续执行,node_poem 和 node_joke 将在下一个超步执行,因此:

    next=("node_poem", "node_joke")
  3. step=1

    这是图计算循环的第二个检查点,同样 metadata["source"] == "loop"

    同时,它对应第一个执行图节点计算逻辑的超步。

    此时,node_poem 和 node_joke 的输出已经到状态中,因此:

    values={
        "topic": "猫咪",
        "poem": "...",
        "joke": "..."
    }

    从这个检查点开始,下游的汇总节点 node_output 满足执行条件,因此:

    next=("node_output",)
  4. step=2

    这是图计算循环的第三个检查点,同样 metadata["source"] == "loop"

    此时,node_output 已经执行完成,最终输出也已经写入状态,因此:

    values={
        "topic": "猫咪",
        "poem": "...",
        "joke": "...",
        "final_output": "..."
    }

    此时,图已经执行结束,所以:

    next=()
6.4.1.3.3. tasks字段说明

tasks 是一个元组,其中的每个元素都是一个 PregelTask 实例

tasks 表示:如果从当前检查点继续执行,下一步要运行哪些任务。比如在 step=0 的检查点中:

next=("node_poem", "node_joke")
tasks=(
    PregelTask(name="node_poem", ...),
    PregelTask(name="node_joke", ...),
)

这表示从该检查点继续执行时,下一步会运行 node_poem 和 node_joke 节点,底层执行的是它们的同名任务。

不过,查看历史检查点时,PregelTask 实例的 result 字段可能非空,如:

PregelTask(
    name="node_poem",
    result={
        "poem": "..."
    }
)

这看起来像是“上一个检查点中提前保存了下一个超步的执行结果”。实际上:

  • 完整的检查点(主检查点)是在超步末尾保存的

  • 但在一个超步内部,LangGraph 还会记录节点级别的计算结果

  • checkpoint["id"] 会在保存主检查点前由 create_checkpoint() 推进为新的 ID,然后用这个 ID 写入主检查点(假设当前超步为 S1)。下一轮(超步为 S2)计算循环中,任务执行完毕后的中间结果会用相同的 ID 写入。

    因此,下一轮(超步为 S2)任务的计算结果绑定到了“当前超步(超步为 S1)的主检查点”。

  • 从检查点后端检索时,会按照 checkpoint_id 将主检查点和中间结果组织起来

  • 因此,检索到的历史检查点列表中,主检查点(超步为 S1)和下一个超步(超步为 S2)的中间结果会被放在同一个 StateSnapshot 中

  • 如果同一个超步里有多个并行任务,其中部分任务已经成功,另一个任务失败,那么成功任务的结果可以被保存下来;

  • 后续恢复时,LangGraph 可以复用这些已成功任务的结果,避免重复执行已经成功的节点。

在 6.5.3. 失败恢复 一节,我们将看到某个超步中部分任务执行成功、部分任务失败导致计算异常终止,恢复运行时已成功任务未被重新执行。

6.4.2. 查看最新检查点

如果只想查看当前线程的最新检查点,可以使用:

latest_history_checkpoint = graph.get_state(config=config)
print(latest_history_checkpoint)

完整示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from loguru import logger
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    joke: str
    final_output: str

class InputState(TypedDict):
    topic: str

class OutputState(TypedDict):
    final_output: str

def node_poem(state: InputState) -> OverAllState:
    logger.info(f"node_poem 已执行")
    topic = state["topic"]
    poem = model.invoke([HumanMessage(f"写一首关于 {topic} 的七言绝句,不要赏析,只给诗句即可")]).content

    return {
        "poem": poem
    }

def node_joke(state: InputState) -> OverAllState:
    logger.info(f"node_joke 已执行")
    topic = state["topic"]
    joke = model.invoke([HumanMessage(f"写一个关于 {topic} 的笑话,尽可能简单,不超过一百字")]).content

    return {
        "joke": joke
    }

def node_output(state: OverAllState) -> OutputState:
    logger.info("node_output 已执行")
    topic = state["topic"]
    poem = state["poem"]
    joke = state["joke"]
    final_output = f"关于 {topic} 的七言绝句:\n{poem}\n笑话:\n{joke}"

    return {
        "final_output": final_output
    }

builder = StateGraph(state_schema=OverAllState, input_schema=InputState, output_schema=OutputState)
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_output", node_output)
builder.add_edge(START, "node_poem")
builder.add_edge(START, "node_joke")
builder.add_edge("node_poem", "node_output")
builder.add_edge("node_joke", "node_output")
builder.add_edge("node_output", END)

checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "123"}}
graph = builder.compile(checkpointer=checkpointer)
res = graph.invoke({"topic": "猫咪"}, config=config)

from IPython.display import display
display(graph)

print('=' * 30, '-> 运行结果 <-', '=' * 30)
print("res: {}", res)

print('=' * 30, '-> 最新历史检查点 <-', '=' * 30)
latest_history_checkpoint = graph.get_state(config=config)
print(latest_history_checkpoint)

运行结果如下

  1. 运行日志

    2026-06-10 11:23:59.610 | INFO     | __main__:node_poem:33 - node_poem 已执行
    2026-06-10 11:23:59.611 | INFO     | __main__:node_joke:42 - node_joke 已执行
    2026-06-10 11:24:01.424 | INFO     | __main__:node_output:51 - node_output 已执行
  2. 拓扑结构

  3. 运行结果及检查点信息

    ============================== -> 运行结果 <- ==============================
    res: {} {'final_output': '关于 猫咪 的七言绝句:\n《戏题狸奴》\n昼眠锦褥夜巡檐,碧眼圆瞳映月纤。\n捕鼠本非真本领,得鱼时复近妆奁。\n笑话:\n小猫咪第一次照镜子,看到里面的自己,吓得炸毛:“你是谁?!” 它绕到镜子后面找了一圈,没找到,又回来对着镜子哈气。 最后它恍然大悟,对主人说:“我明白了,那是个隐藏的摄像头!”'}
    ============================== -> 最新历史检查点 <- ==============================
    StateSnapshot(
        values={
            "topic": "猫咪",
            "poem": (
                "《戏题狸奴》\n"
                "昼眠锦褥夜巡檐,碧眼圆瞳映月纤。\n"
                "捕鼠本非真本领,得鱼时复近妆奁。"
            ),
            "joke": (
                "小猫咪第一次照镜子,看到里面的自己,吓得炸毛:“你是谁?!” "
                "它绕到镜子后面找了一圈,没找到,又回来对着镜子哈气。 "
                "最后它恍然大悟,对主人说:“我明白了,那是个隐藏的摄像头!”"
            ),
            "final_output": (
                "关于 猫咪 的七言绝句:\n"
                "《戏题狸奴》\n"
                "昼眠锦褥夜巡檐,碧眼圆瞳映月纤。\n"
                "捕鼠本非真本领,得鱼时复近妆奁。\n"
                "笑话:\n"
                "小猫咪第一次照镜子,看到里面的自己,吓得炸毛:“你是谁?!” "
                "它绕到镜子后面找了一圈,没找到,又回来对着镜子哈气。 "
                "最后它恍然大悟,对主人说:“我明白了,那是个隐藏的摄像头!”"
            ),
        },
        next=(),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f1647bd-3512-631a-8002-4e48b5ecda6d",
            }
        },
        metadata={
            "source": "loop",
            "step": 2,
            "parents": {},
        },
        created_at="2026-06-10T03:24:01.426084+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f1647bd-350d-62c7-8001-34cd7fa012ed",
            }
        },
        tasks=(),
        interrupts=(),
    )

    graph.get_state(config=config) 只返回当前会话的最新检查点。

    在本例中,图已经执行完成,所以最新检查点具有以下特征:

    next=()
    tasks=()

    这表示没有后续节点需要继续执行。

6.4.3. 根据ID查看指定检查点

如果希望查看某个历史检查点,可以在 configurable 中额外传入 checkpoint_id

target_config = {
    "configurable": {
        "thread_id": "123",
        "checkpoint_id": "某个历史 checkpoint_id"
    }
}

snapshot = graph.get_state(config=target_config)
print(snapshot)

这样返回的就不是最新检查点,而是指定 checkpoint_id 对应的历史检查点。

6.4.3.1. 获取历史检查点列表

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from loguru import logger
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    joke: str
    final_output: str

class InputState(TypedDict):
    topic: str

class OutputState(TypedDict):
    final_output: str

def node_poem(state: InputState) -> OverAllState:
    logger.info(f"node_poem 已执行")
    topic = state["topic"]
    poem = model.invoke([HumanMessage(f"写一首关于 {topic} 的七言绝句,不要赏析,只给诗句即可")]).content

    return {
        "poem": poem
    }

def node_joke(state: InputState) -> OverAllState:
    logger.info(f"node_joke 已执行")
    topic = state["topic"]
    joke = model.invoke([HumanMessage(f"写一个关于 {topic} 的笑话,尽可能简单,不超过一百字")]).content

    return {
        "joke": joke
    }

def node_output(state: OverAllState) -> OutputState:
    logger.info("node_output 已执行")
    topic = state["topic"]
    poem = state["poem"]
    joke = state["joke"]
    final_output = f"关于 {topic} 的七言绝句:\n{poem}\n笑话:\n{joke}"

    return {
        "final_output": final_output
    }

builder = StateGraph(state_schema=OverAllState, input_schema=InputState, output_schema=OutputState)
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_output", node_output)
builder.add_edge(START, "node_poem")
builder.add_edge(START, "node_joke")
builder.add_edge("node_poem", "node_output")
builder.add_edge("node_joke", "node_output")
builder.add_edge("node_output", END)

checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "123"}}
graph = builder.compile(checkpointer=checkpointer)
res = graph.invoke({"topic": "猫咪"}, config=config)

from IPython.display import display
display(graph)

print('=' * 30, '-> 运行结果 <-', '=' * 30)
print("res: {}", res)

print('=' * 30, '-> 历史检查点列表 <-', '=' * 30)
history_checkpoints = list(graph.get_state_history(config=config))
print(history_checkpoints)

运行结果如下

2026-06-11 18:28:29.126 | INFO     | __main__:node_poem:33 - node_poem 已执行
2026-06-11 18:28:29.128 | INFO     | __main__:node_joke:42 - node_joke 已执行
2026-06-11 18:28:31.021 | INFO     | __main__:node_output:51 - node_output 已执行

============================== -> 运行结果 <- ==============================
res: {} {'final_output': '关于 猫咪 的七言绝句:\n《戏猫》\n狸奴日午卧青毡,蝶影翩然忽跃前。\n捕得飞花轻似梦,独摇银尾弄春烟。\n笑话:\n猫咪走进咖啡店,点了一杯牛奶。  \n店员问:“要不要加糖?”  \n猫咪摇头:“不用,我最近在减肥。”  \n店员看了看它圆滚滚的肚子:“真的吗?”  \n猫咪叹了口气:“唉,都是猫粮的错。”'}
============================== -> 历史检查点列表 <- ==============================
[
    StateSnapshot(
        values={
            "topic": "猫咪",
            "poem": (
                "《戏猫》\n"
                "狸奴日午卧青毡,蝶影翩然忽跃前。\n"
                "捕得飞花轻似梦,独摇银尾弄春烟。"
            ),
            "joke": (
                "猫咪走进咖啡店,点了一杯牛奶。  \n"
                "店员问:“要不要加糖?”  \n"
                "猫咪摇头:“不用,我最近在减肥。”  \n"
                "店员看了看它圆滚滚的肚子:“真的吗?”  \n"
                "猫咪叹了口气:“唉,都是猫粮的错。”"
            ),
            "final_output": (
                "关于 猫咪 的七言绝句:\n"
                "《戏猫》\n"
                "狸奴日午卧青毡,蝶影翩然忽跃前。\n"
                "捕得飞花轻似梦,独摇银尾弄春烟。\n"
                "笑话:\n"
                "猫咪走进咖啡店,点了一杯牛奶。  \n"
                "店员问:“要不要加糖?”  \n"
                "猫咪摇头:“不用,我最近在减肥。”  \n"
                "店员看了看它圆滚滚的肚子:“真的吗?”  \n"
                "猫咪叹了口气:“唉,都是猫粮的错。”"
            ),
        },
        next=(),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f165804-acae-618c-8002-5a8b9e940205",
            }
        },
        metadata={
            "source": "loop",
            "step": 2,
            "parents": {},
        },
        created_at="2026-06-11T10:28:31.022523+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f165804-acab-66b3-8001-d74912e4cb07",
            }
        },
        tasks=(),
        interrupts=(),
    ),

    StateSnapshot(
        values={
            "topic": "猫咪",
            "poem": (
                "《戏猫》\n"
                "狸奴日午卧青毡,蝶影翩然忽跃前。\n"
                "捕得飞花轻似梦,独摇银尾弄春烟。"
            ),
            "joke": (
                "猫咪走进咖啡店,点了一杯牛奶。  \n"
                "店员问:“要不要加糖?”  \n"
                "猫咪摇头:“不用,我最近在减肥。”  \n"
                "店员看了看它圆滚滚的肚子:“真的吗?”  \n"
                "猫咪叹了口气:“唉,都是猫粮的错。”"
            ),
        },
        next=("node_output",),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f165804-acab-66b3-8001-d74912e4cb07",
            }
        },
        metadata={
            "source": "loop",
            "step": 1,
            "parents": {},
        },
        created_at="2026-06-11T10:28:31.021422+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f165804-9a92-67bc-8000-dd5ab888e9d5",
            }
        },
        tasks=(
            PregelTask(
                id="66dedbf9-3fbb-c539-5f4e-0e3f604a9c56",
                name="node_output",
                path=("__pregel_pull", "node_output"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "final_output": (
                        "关于 猫咪 的七言绝句:\n"
                        "《戏猫》\n"
                        "狸奴日午卧青毡,蝶影翩然忽跃前。\n"
                        "捕得飞花轻似梦,独摇银尾弄春烟。\n"
                        "笑话:\n"
                        "猫咪走进咖啡店,点了一杯牛奶。  \n"
                        "店员问:“要不要加糖?”  \n"
                        "猫咪摇头:“不用,我最近在减肥。”  \n"
                        "店员看了看它圆滚滚的肚子:“真的吗?”  \n"
                        "猫咪叹了口气:“唉,都是猫粮的错。”"
                    )
                },
            ),
        ),
        interrupts=(),
    ),

    StateSnapshot(
        values={
            "topic": "猫咪",
        },
        next=("node_poem", "node_joke"),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f165804-9a92-67bc-8000-dd5ab888e9d5",
            }
        },
        metadata={
            "source": "loop",
            "step": 0,
            "parents": {},
        },
        created_at="2026-06-11T10:28:29.123773+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f165804-9a90-6db5-bfff-d0eb857db9d8",
            }
        },
        tasks=(
            PregelTask(
                id="8b59cf5a-16b4-c734-41c8-56ac9b7614c9",
                name="node_poem",
                path=("__pregel_pull", "node_poem"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "poem": (
                        "《戏猫》\n"
                        "狸奴日午卧青毡,蝶影翩然忽跃前。\n"
                        "捕得飞花轻似梦,独摇银尾弄春烟。"
                    )
                },
            ),
            PregelTask(
                id="388c08f3-e341-3c76-cfc4-a9825ec97353",
                name="node_joke",
                path=("__pregel_pull", "node_joke"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "joke": (
                        "猫咪走进咖啡店,点了一杯牛奶。  \n"
                        "店员问:“要不要加糖?”  \n"
                        "猫咪摇头:“不用,我最近在减肥。”  \n"
                        "店员看了看它圆滚滚的肚子:“真的吗?”  \n"
                        "猫咪叹了口气:“唉,都是猫粮的错。”"
                    )
                },
            ),
        ),
        interrupts=(),
    ),

    StateSnapshot(
        values={},
        next=("__start__",),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f165804-9a90-6db5-bfff-d0eb857db9d8",
            }
        },
        metadata={
            "source": "input",
            "step": -1,
            "parents": {},
        },
        created_at="2026-06-11T10:28:29.123104+00:00",
        parent_config=None,
        tasks=(
            PregelTask(
                id="63a24d9c-ea6d-0420-4b5a-b978c6eaeb90",
                name="__start__",
                path=("__pregel_pull", "__start__"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "topic": "猫咪",
                },
            ),
        ),
        interrupts=(),
    ),
]
6.4.3.2. 根据ID查看特定检查点

获取历史检查点列表后,可以从某个历史快照中取出它的 checkpoint_id,然后调用 graph.get_state() 查看该检查点。

示例如下

print('=' * 30, '-> 根据ID查看特定检查点 <-', '=' * 30)
checkpointe_id = history_checkpoints[3].config["configurable"]["checkpoint_id"]
print(f"checkpointe_id: {checkpointe_id}")

target_config = {
    "configurable": {
        "thread_id": config["configurable"]["thread_id"],
        "checkpoint_id": checkpointe_id
    }
}

specific_history_checkpoint = graph.get_state(config=target_config)
print(specific_history_checkpoint)

运行结果如下

============================== -> 根据ID查看特定检查点 <- ==============================
checkpointe_id: 1f165804-9a90-6db5-bfff-d0eb857db9d8
StateSnapshot(
    values={},
    next=("__start__",),
    config={
        "configurable": {
            "thread_id": "123",
            "checkpoint_id": "1f165804-9a90-6db5-bfff-d0eb857db9d8",
        }
    },
    metadata={
        "source": "input",
        "step": -1,
        "parents": {},
    },
    created_at="2026-06-11T10:28:29.123104+00:00",
    parent_config=None,
    tasks=(
        PregelTask(
            id="63a24d9c-ea6d-0420-4b5a-b978c6eaeb90",
            name="__start__",
            path=("__pregel_pull", "__start__"),
            error=None,
            interrupts=(),
            state=None,
            result={
                "topic": "猫咪",
            },
        ),
    ),
    interrupts=(),
)

当前场景下,我们已经获取了完整检查点列表,然后从中取了某个检查点的 checkpoint_id,然后调用 get_state() 查到了它对应的快照。

这看起来有些“绕”,因为我们本来就已经拿到了完整的历史快照。但在实际场景中,这个方法仍然很有意义。

例如,在后续的 检查点回溯-分叉 场景中,我们经常会基于历史检查点创建新的检查点,拿到该检查点的、带有 checkpoint_id 的配置信息 config。此时就可以将 config 传递给 graph.get_state(),从而便捷而精确地查看该检查点的快照。

6.5. 使用场景

6.5.1. 多轮对话

最基础的使用场景是多轮对话。前文示例已经实现了一个简单的多轮对话流程,这里不再展开。

6.5.2. 中断恢复

中断恢复需要结合 LangGraph 的中断机制理解,见下文。

6.5.3. 失败后恢复运行

6.5.3.1. 用法说明

任务失败的原因可能是偶发的外部因素,如网络波动、秘钥过期、第三方服务异常;也可能是程序自身的BUG。

如果是前者,故障发生后,修复外部问题,直接重试即可;如果是后者,需要更改代码修复BUG后,再基于历史检查点恢复运行。

要在失败后基于历史检查点恢复运行,需要满足以下条件:

  1. 启用检查点存储器
    • 如果是 InMemorySaver,不要重建检查点存储器对象,否则历史检查点丢失,无法恢复
    • 如果希望跨进程、服务重启后仍可恢复,应使用基于 SQLite、Postgres 等持久化检查点后端的存储器
  2. 再次运行时用 None 作为计算图的状态输入
  3. 传递的配置信息应包含 thread_id 而不能包含 checkpoint_id

此时,LangGraph 会根据 thread_id 从 checkpointer 读取该会话的最新检查点,并从该检查点继续推进。

如果某个超步中有多个并行任务,其中一部分任务成功,另一部分任务失败,那么已经成功完成的任务结果会作为 pending_writes 被保存。恢复运行时,已经成功完成的任务不会重复执行。

6.5.3.2. 示例
6.5.3.2.1. 模拟失败

本例使用如下结构:

START
  -> node_change_topic
      -> node_poem
      -> node_joke
  -> node_output
  -> END

运行时,node_change_topic 会把输入主题 "猫咪" 改写为 "猫咪: 布偶猫",然后并行执行 node_poem 和 node_joke,最后由 node_output 汇总输出。

示例如下

from typing import TypedDict

from dotenv import  load_dotenv
from langchain_core.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph,START,END
from loguru import logger
load_dotenv(override=True)

model = ChatDeepSeek(
    model = "deepseek-v4-flash",
    extra_body={
        "thinking":{
            "type":"disabled"
        }
    }
)

#1. 声明状态
class OverAllState(TypedDict):
    topic:str
    poem:str
    joke:str
    final_output:str

#1.1 输入状态
class InputState(TypedDict):
    topic:str

#1.2 输出状态
class OutputState(TypedDict):
    final_output:str

topics = ["布偶猫","狸花猫","金渐层"]
topic_index = 0
#2. 定义节点
def node_change_topic(state:InputState)->OverAllState:
    global topic_index
    logger.info("topic_index:{}",topic_index)
    sub_topic = topics[topic_index]
    topic_index += 1
    topic_index %= len(topics)

    return {
        "topic":f"{state["topic"]}:{sub_topic}"
    }
#2.1 同一个超步的位置 成功运行的节点
def node_poem(state:OverAllState) -> OverAllState:
    logger.info("node_poem正在执行")
    topic = state["topic"]
    poem = model.invoke([HumanMessage(f"写一首关于{topic}主题的七言绝句")]).content
    return {
        "poem":poem
    }
import  time
#2.2 同一个超步的位置 运行失败的节点
def node_joke(state:OverAllState) -> OverAllState:
    logger.info("node_joke正在执行")
    topic = state["topic"]
    time.sleep(5)
    raise Exception("人为抛异常")
    joke = model.invoke([HumanMessage(f"写一首关于{topic}主题的笑话")]).content
    return {
        "joke":joke
    }

def node_output(state:OverAllState) -> OutputState:
    logger.info("node_output正在执行")
    topic = state["topic"]
    poem = state["poem"]
    joke = state["joke"]
    final_output = f"关于{topic}的七言绝句:{poem}\n 笑话:{joke}\n"
    return {
        "final_output":final_output
    }

#3. 构建图
builder = StateGraph(state_schema=OverAllState,input_schema=InputState,output_schema=OutputState)

#3.1 添加节点
builder.add_node("node_change_topic",node_change_topic)
builder.add_node("node_poem",node_poem)
builder.add_node("node_joke",node_joke)
builder.add_node("node_output",node_output)

#3.2 添加边
builder.add_edge(START,"node_change_topic")
builder.add_edge("node_change_topic","node_poem")
builder.add_edge("node_change_topic","node_joke")
builder.add_edge("node_poem","node_output")
builder.add_edge("node_joke","node_output")
builder.add_edge("node_output",END)

#4. 添加检查点后端
DB_URL = "postgresql://langgraph_user:123456@localhost:5432/langgraph_db?sslmode=disable"
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
    #5. 第一次使用PostgresSaver作为检查点 需要调用方法 setup()
    #checkpointer.setup()
    graph = builder.compile(checkpointer=checkpointer)

    from IPython.display import display
    display(graph)

    config = {
        "configurable":{
            "thread_id":"chapter03-05"
        }
    }

    res = graph.invoke({"topic":"猫"},config=config)
    print(res)

运行结果如下

2026-06-10 16:17:43.544 | INFO     | __main__:node_change_topic:45 - topic_idx: 0
2026-06-10 16:17:43.545 | INFO     | __main__:node_joke:65 - node_joke 已执行
2026-06-10 16:17:43.548 | INFO     | __main__:node_poem:54 - node_poem 已执行
>Traceback...
Exception: 人为中断
During task with name 'node_joke' and id 'edb59019-4808-ed51-68eb-d5fba8696b13'

本例在 node_joke 中抛出异常,从而模拟计算图执行失败的场景。

为了观察恢复执行时哪些节点会被重新执行,示例中额外定义了全局变量 topics 和 topic_idxnode_change_topic 每执行一次,都会从 topics 中取出一个新的子主题,作为状态字段 topic 的值。

如果恢复运行时 node_change_topic 没有再次执行,则最终结果仍然会使用第一次生成的主题,即 猫咪: 布偶猫。如果该节点被重新执行,则主题可能会变成 猫咪: 狸花猫 或 猫咪: 金渐层

6.5.3.2.2. 查看历史检查点列表

示例如下

list(graph.get_state_history(config=config))

运行结果如下

[
    StateSnapshot(
        values={
            "topic": "猫咪: 布偶猫"
        },
        next=(
            "node_poem",
            "node_joke"
        ),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f164a86-e60d-6881-8001-c717582e8893"
            }
        },
        metadata={
            "source": "loop",
            "step": 1,
            "parents": {}
        },
        created_at="2026-06-10T08:43:19.431987+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f164a86-e608-6596-8000-ed45af11cea7"
            }
        },
        tasks=(
            PregelTask(
                id="66287e00-4228-a985-fc09-6791a14e26ce",
                name="node_poem",
                path=(
                    "__pregel_pull",
                    "node_poem"
                ),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "poem": "《戏题布偶猫》\n雪翼云裘自绝尘,琉璃碧眼若含春。\n行来莲步生兰气,卧处梨花是此身。"
                }
            ),
            PregelTask(
                id="b2632b70-562b-7d5c-0dae-0f83bd6c2912",
                name="node_joke",
                path=(
                    "__pregel_pull",
                    "node_joke"
                ),
                error="Exception('人为中断')",
                interrupts=(),
                state=None,
                result=None
            )
        ),
        interrupts=()
    ),

    StateSnapshot(
        values={
            "topic": "猫咪"
        },
        next=(
            "node_change_topic",
        ),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f164a86-e608-6596-8000-ed45af11cea7"
            }
        },
        metadata={
            "source": "loop",
            "step": 0,
            "parents": {}
        },
        created_at="2026-06-10T08:43:19.429860+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f164a86-e606-6a42-bfff-9f09d8a3aa96"
            }
        },
        tasks=(
            PregelTask(
                id="273fe47f-b4cc-93d0-0ab7-57a0db4bad56",
                name="node_change_topic",
                path=(
                    "__pregel_pull",
                    "node_change_topic"
                ),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "topic": "猫咪: 布偶猫"
                }
            ),
        ),
        interrupts=()
    ),

    StateSnapshot(
        values={},
        next=(
            "__start__",
        ),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f164a86-e606-6a42-bfff-9f09d8a3aa96"
            }
        },
        metadata={
            "source": "input",
            "step": -1,
            "parents": {}
        },
        created_at="2026-06-10T08:43:19.429167+00:00",
        parent_config=None,
        tasks=(
            PregelTask(
                id="ec8e6e32-6969-6798-9c5f-aade41e9211a",
                name="__start__",
                path=(
                    "__pregel_pull",
                    "__start__"
                ),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "topic": "猫咪"
                }
            ),
        ),
        interrupts=()
    )
]

可以看到,失败后的检查点中(step=1)记录了当前超步相关任务的执行情况:

  • node_poem 执行成功,结果记录在 result 字段中。
  • node_joke 执行失败,result 为空,异常信息记录在 error 字段中。

本例中,node_poem 和 node_joke 属于同一个超步。虽然 node_joke 失败了,但 node_poem 已成功,其计算结果会被持久化机制保存下来。因此,后续恢复运行时,node_poem 不会重复执行。

6.5.3.2.3. 修复BUG之后恢复运行

修复 node_joke 中的人为异常后,重新编译计算图,并基于最新检查点恢复运行。

示例如下

def node_joke(state: InputState) -> OverAllState:
    logger.info(f"node_joke 已执行")
    topic = state["topic"]

    joke = model.invoke([HumanMessage(f"写一个关于 {topic} 的笑话")]).content

    return {
        "joke": joke
    }


builder = StateGraph(state_schema=OverAllState, input_schema=InputState, output_schema=OutputState)
builder.add_node("node_change_topic", node_change_topic)
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_output", node_output)
builder.add_edge(START, "node_change_topic")
builder.add_edge("node_change_topic", "node_poem")
builder.add_edge("node_change_topic", "node_joke")
builder.add_edge("node_poem", "node_output")
builder.add_edge("node_joke", "node_output")
builder.add_edge("node_output", END)

new_graph = builder.compile(checkpointer=checkpointer)

from IPython.display import display
display(new_graph)

res = new_graph.invoke(None, config=config)
print(res)

这里需要注意两点:

  1. 重新编译计算图时,仍然使用原来的 checkpointer 对象。
  2. 恢复运行时,输入参数传入 None,并且配置中只传入 thread_id

运行结果如下

2026-06-10 16:37:12.985 | INFO     | __main__:node_joke:2 - node_joke 已执行
2026-06-10 16:37:18.878 | INFO     | __main__:node_output:76 - node_output 已执行
{
    'final_output': '关于 猫咪: 布偶猫 的七言绝句:\n《戏题布偶猫》\n雪翼云裘自绝尘,琉璃碧眼若含春。\n行来莲步生兰气,卧处梨花是此身。\n笑话:\n一只布偶猫走进咖啡馆,优雅地跳上吧台,对老板说:“给我来一杯最贵的猫屎咖啡。”  \n老板愣住:“可……您本身就是猫啊?”  \n布偶猫舔了舔爪子,翻了个白眼:“所以呢?我自己产的屎,你们人类不是喝得挺香吗?今天我倒要尝尝,你们拿我的‘周边产品’能泡出什么花样来。”'
}

运行结果中可以观察到:

  • node_joke 被重新执行。
  • node_output 被执行。
  • node_poem 没有被重新执行。

最终输出中的七言绝句和历史检查点中 node_poem.result 记录的内容一致,说明 node_poem 的执行结果被成功复用了。

6.5.4. Time Travel

Time Travel 直译为 时间旅行,在当前场景下太过生硬,从技术实现和使用场景来看,译为 检查点回溯 更为合理。

所谓检查点回溯,是指基于某个历史检查点,重新执行后续流程,或者在该检查点基础上修改状态并创建新的执行分支。

检查点回溯有两种形式,根据是否更改历史状态区分:

  • Replay:检查点重放,回到某个历史检查点,沿着原先的执行路径重新执行后续节点。
  • Fork:检查点分叉,回到某个历史检查点,修改状态,从该位置创建一条新的执行分支。

二者的共同点是:

检查点之前的节点不会重新执行,检查点之后的节点会重新执行。

二者的区别是:

Replay 不修改历史状态;Fork 会基于历史检查点应用新的状态更新,并创建新的检查点分支。

6.5.4.1. Replay

Replay 模式和失败恢复很接近,但二者的触发方式和语义不同。

  • 失败恢复通常基于最新检查点继续运行,配置中只包含 thread_id,不包含 checkpoint_id

  • Replay 则是显式传入某个历史检查点的配置,配置中包含 checkpoint_idLangGraph 会据此从该历史检查点开始重放后续步骤。

需要注意:

Replay 不是简单读取历史缓存,而是重新执行该检查点之后的节点。

因此,检查点之后的 LLM 调用、API 请求、工具调用、中断 等都会重新触发,最终结果可能和原始结果不同。

6.5.4.1.1. 正常运行的任务

本例仍然使用如下结构:

START
  -> node_change_topic
      -> node_poem
      -> node_joke
  -> node_output
  -> END

示例如下

from typing import TypedDict

from dotenv import  load_dotenv
from langchain_core.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph,START,END
from loguru import logger
load_dotenv(override=True)

model = ChatDeepSeek(
    model = "deepseek-v4-flash",
    extra_body={
        "thinking":{
            "type":"disabled"
        }
    }
)

#1. 声明状态
class OverAllState(TypedDict):
    topic:str
    poem:str
    joke:str
    final_output:str

#1.1 输入状态
class InputState(TypedDict):
    topic:str

#1.2 输出状态
class OutputState(TypedDict):
    final_output:str

topics = ["布偶猫","狸花猫","金渐层"]
topic_index = 0
#2. 定义节点
def node_change_topic(state:InputState)->OverAllState:
    global topic_index
    logger.info("topic_index:{}",topic_index)
    sub_topic = topics[topic_index]
    topic_index += 1
    topic_index %= len(topics)

    return {
        "topic":f"{state["topic"]}:{sub_topic}"
    }
#2.1 同一个超步的位置 成功运行的节点
def node_poem(state:OverAllState) -> OverAllState:
    logger.info("node_poem正在执行")
    topic = state["topic"]
    poem = model.invoke([HumanMessage(f"写一首关于{topic}主题的七言绝句")]).content
    return {
        "poem":poem
    }
import  time
#2.2 同一个超步的位置 运行失败的节点
def node_joke(state:OverAllState) -> OverAllState:
    logger.info("node_joke正在执行")
    topic = state["topic"]
    # time.sleep(5)
    # raise Exception("人为抛异常")
    joke = model.invoke([HumanMessage(f"写一首关于{topic}主题的笑话")]).content
    return {
        "joke":joke
    }

def node_output(state:OverAllState) -> OutputState:
    logger.info("node_output正在执行")
    topic = state["topic"]
    poem = state["poem"]
    joke = state["joke"]
    final_output = f"关于{topic}的七言绝句:{poem}\n 笑话:{joke}\n"
    return {
        "final_output":final_output
    }

#3. 构建图
builder = StateGraph(state_schema=OverAllState,input_schema=InputState,output_schema=OutputState)

#3.1 添加节点
builder.add_node("node_change_topic",node_change_topic)
builder.add_node("node_poem",node_poem)
builder.add_node("node_joke",node_joke)
builder.add_node("node_output",node_output)

#3.2 添加边
builder.add_edge(START,"node_change_topic")
builder.add_edge("node_change_topic","node_poem")
builder.add_edge("node_change_topic","node_joke")
builder.add_edge("node_poem","node_output")
builder.add_edge("node_joke","node_output")
builder.add_edge("node_output",END)

#4. 添加检查点后端
DB_URL = "postgresql://langgraph_user:123456@localhost:5432/langgraph_db?sslmode=disable"
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
    #5. 第一次使用PostgresSaver作为检查点 需要调用方法 setup()
    #checkpointer.setup()
    graph = builder.compile(checkpointer=checkpointer)

    from IPython.display import display
    display(graph)

    config = {
        "configurable":{
            "thread_id":"chapter03-08"
        }
    }

    res = graph.invoke({"topic":"猫咪"},config=config)
    print(res)

运行结果如下

2026-06-12 14:32:42.019 | INFO     | __main__:node_change_topic:45 - topic_idx: 0
2026-06-12 14:32:42.022 | INFO     | __main__:node_joke:63 - node_joke 已执行
2026-06-12 14:32:42.026 | INFO     | __main__:node_poem:54 - node_poem 已执行
2026-06-12 14:32:43.585 | INFO     | __main__:node_output:72 - node_output 已执行
2026-06-12 14:32:43.588 | INFO     | __main__:<module>:102 - res: 
{
    'final_output': '关于 猫咪: 布偶猫 的七言绝句:\n《布偶猫》\n雪团云絮落仙踪,碧眼盈盈醉九重。\n静若琼瑶闲卧月,清姿一步一玲珑。\n笑话:\n一只布偶猫照镜子,惊呼:“我这么美,怎么还没当上国王?” 主人笑道:“因为你没有‘喵’民拥护。” 布偶猫委屈:“我有啊,铲屎官你不是天天跪着拥护我吗?”'
}
6.5.4.1.2. 查看历史检查点列表

示例如下

history_checkpoints = list(graph.get_state_history(config=config))
print(history_checkpoints)

运行结果如下

[
    StateSnapshot(
        values={
            'topic': '猫咪: 布偶猫',
            'poem': '《布偶猫》\n雪团云絮落仙踪,碧眼盈盈醉九重。\n静若琼瑶闲卧月,清姿一步一玲珑。',
            'joke': '一只布偶猫照镜子,惊呼:“我这么美,怎么还没当上国王?” 主人笑道:“因为你没有‘喵’民拥护。” 布偶猫委屈:“我有啊,铲屎官你不是天天跪着拥护我吗?”',
            'final_output': '关于 猫咪: 布偶猫 的七言绝句:\n《布偶猫》\n雪团云絮落仙踪,碧眼盈盈醉九重。\n静若琼瑶闲卧月,清姿一步一玲珑。\n笑话:\n一只布偶猫照镜子,惊呼:“我这么美,怎么还没当上国王?” 主人笑道:“因为你没有‘喵’民拥护。” 布偶猫委屈:“我有啊,铲屎官你不是天天跪着拥护我吗?”'
        },
        next=(),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f166288-4ad3-67ef-8003-01d021de9403'
            }
        },
        metadata={
            'source': 'loop',
            'step': 3,
            'parents': {}
        },
        created_at='2026-06-12T06:32:43.586547+00:00',
        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f166288-4acf-62cf-8002-aed204b5115d'
            }
        },
        tasks=(),
        interrupts=()
    ),
    StateSnapshot(
        values={
            'topic': '猫咪: 布偶猫',
            'poem': '《布偶猫》\n雪团云絮落仙踪,碧眼盈盈醉九重。\n静若琼瑶闲卧月,清姿一步一玲珑。',
            'joke': '一只布偶猫照镜子,惊呼:“我这么美,怎么还没当上国王?” 主人笑道:“因为你没有‘喵’民拥护。” 布偶猫委屈:“我有啊,铲屎官你不是天天跪着拥护我吗?”'
        },
        next=('node_output',),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f166288-4acf-62cf-8002-aed204b5115d'
            }
        },
        metadata={
            'source': 'loop',
            'step': 2,
            'parents': {}
        },
        created_at='2026-06-12T06:32:43.584774+00:00',
        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f166288-3be4-6523-8001-dd86f05c34b8'
            }
        },
        tasks=(
            PregelTask(
                id='b9042203-0ddf-f638-aeeb-e0501601381a',
                name='node_output',
                path=('__pregel_pull', 'node_output'),
                error=None,
                interrupts=(),
                state=None,
                result={
                    'final_output': '关于 猫咪: 布偶猫 的七言绝句:\n《布偶猫》\n雪团云絮落仙踪,碧眼盈盈醉九重。\n静若琼瑶闲卧月,清姿一步一玲珑。\n笑话:\n一只布偶猫照镜子,惊呼:“我这么美,怎么还没当上国王?” 主人笑道:“因为你没有‘喵’民拥护。” 布偶猫委屈:“我有啊,铲屎官你不是天天跪着拥护我吗?”'
                }
            ),
        ),
        interrupts=()
    ),
    StateSnapshot(
        values={
            'topic': '猫咪: 布偶猫'
        },
        next=('node_poem', 'node_joke'),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f166288-3be4-6523-8001-dd86f05c34b8'
            }
        },
        metadata={
            'source': 'loop',
            'step': 1,
            'parents': {}
        },
        created_at='2026-06-12T06:32:42.020569+00:00',
        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f166288-3bde-6373-8000-9750b4be0665'
            }
        },
        tasks=(
            PregelTask(
                id='eab6bf51-4986-a60f-753c-e493b78c56b8',
                name='node_poem',
                path=('__pregel_pull', 'node_poem'),
                error=None,
                interrupts=(),
                state=None,
                result={
                    'poem': '《布偶猫》\n雪团云絮落仙踪,碧眼盈盈醉九重。\n静若琼瑶闲卧月,清姿一步一玲珑。'
                }
            ),
            PregelTask(
                id='a228a7bf-4c3b-2ad3-039f-12ac0107c87b',
                name='node_joke',
                path=('__pregel_pull', 'node_joke'),
                error=None,
                interrupts=(),
                state=None,
                result={
                    'joke': '一只布偶猫照镜子,惊呼:“我这么美,怎么还没当上国王?” 主人笑道:“因为你没有‘喵’民拥护。” 布偶猫委屈:“我有啊,铲屎官你不是天天跪着拥护我吗?”'
                }
            )
        ),
        interrupts=()
    ),
    StateSnapshot(
        values={
            'topic': '猫咪'
        },
        next=('node_change_topic',),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f166288-3bde-6373-8000-9750b4be0665'
            }
        },
        metadata={
            'source': 'loop',
            'step': 0,
            'parents': {}
        },
        created_at='2026-06-12T06:32:42.018069+00:00',
        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f166288-3bdc-60a4-bfff-d4fdcf6e6a9e'
            }
        },
        tasks=(
            PregelTask(
                id='e0034c08-dd19-0ae9-7059-f8414745d212',
                name='node_change_topic',
                path=('__pregel_pull', 'node_change_topic'),
                error=None,
                interrupts=(),
                state=None,
                result={
                    'topic': '猫咪: 布偶猫'
                }
            ),
        ),
        interrupts=()
    ),
    StateSnapshot(
        values={},
        next=('__start__',),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f166288-3bdc-60a4-bfff-d4fdcf6e6a9e'
            }
        },
        metadata={
            'source': 'input',
            'step': -1,
            'parents': {}
        },
        created_at='2026-06-12T06:32:42.017182+00:00',
        parent_config=None,
        tasks=(
            PregelTask(
                id='1a1ac312-152a-dcc3-2aa7-c2588f651448',
                name='__start__',
                path=('__pregel_pull', '__start__'),
                error=None,
                interrupts=(),
                state=None,
                result={
                    'topic': '猫咪'
                }
            ),
        ),
        interrupts=()
    )
]
6.5.4.1.3. 重放

get_state_history() 返回的是历史检查点列表,并且默认按照时间倒序排列。

history_checkpoints 结构如下:

history_checkpoints[0] -> 最终完成后的检查点
                        next=()
history_checkpoints[1] -> node_output 执行前的检查点
                        next=('node_output',)
history_checkpoints[2] -> node_poem、node_joke 执行前的检查点
                        next=('node_poem', 'node_joke')
history_checkpoints[3] -> node_change_topic 执行前的检查点
                        next=('node_change_topic',)
history_checkpoints[4] -> __start__ 执行前的输入检查点
                        next=('__start__',)

因此,如果希望从写诗写笑话开始重放,选择 next == ('node_poem', 'node_joke') 的检查点即可。

示例如下

with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
    #5. 第一次使用PostgresSaver作为检查点 需要调用方法 setup()
    #checkpointer.setup()
    graph = builder.compile(checkpointer=checkpointer)

    config = {
        "configurable":{
            "thread_id":"chapter03-08"
        }
    }
    # 获取到检查点历史
    history_checkpoints = list(graph.get_state_history(config=config))

    new_checkpoint = None
    #next = ('node_poem', 'node_joke')
    for checkpoint in history_checkpoints:
        if checkpoint.next == ('node_poem', 'node_joke'):
            new_checkpoint = checkpoint
            break

    # 如果想要实现replay的效果 状态填写None  config填写为之前某一个检查点的config
    res = graph.invoke(None,config=new_checkpoint.config)
    print(res)

运行结果如下

2026-07-07 18:31:32.019 | INFO     | __main__:node_joke:59 - node_joke正在执行
2026-07-07 18:31:32.023 | INFO     | __main__:node_poem:50 - node_poem正在执行
2026-07-07 18:31:38.158 | INFO     | __main__:node_output:69 - node_output正在执行
{'final_output': '关于猫咪:狸花猫的七言绝句:《狸花猫》\n玄文斑驳隐花光,夜半巡檐气自昂。\n踏雪无痕轻似梦,一窗明月一炉香。\n\n注:我的创作思路是捕捉狸花猫的神秘与灵动。首句“玄文斑驳”描绘其毛发纹理,如暗夜中的花纹;次句“夜半巡檐”展现其夜行习性,气度不凡。后两句以“踏雪无痕”喻其轻盈,结句“明月炉香”营造温馨画面,暗合狸花猫守护家园的意象,使全诗动静相生,虚实相映。\n 笑话:# 狸花猫的烦恼\n\n有只狸花猫去宠物医院看病,医生问它:“哪里不舒服?”\n\n狸花猫叹了口气说:“医生,我总觉得自己不够‘花’。”\n\n医生疑惑:“你可是标准的狸花猫啊,条纹多漂亮!”\n\n狸花猫委屈地说:“可是隔壁那只三花猫,人家身上有黑、白、橘三种颜色,比我缤纷多了!我整天就这一身灰黑条纹,太单调了。”\n\n医生笑了:“那你想要怎么办?”\n\n狸花猫眼睛一亮:“医生,您能不能给我开点染色剂?我想在背上加几块橘色斑点,尾巴再来点白的,耳朵尖染成黑的…”\n\n医生打断它:“等等,那样你就变成‘四不像’了!”\n\n狸花猫思考了一会,突然骄傲地挺起胸膛:“你说得对!我这一身条纹可是老祖宗传下来的迷彩服,抓老鼠时特别管用!三花猫再好看,还得靠我帮忙抓家里的老鼠呢!”\n\n医生欣慰地说:“这就对了。”\n\n狸花猫走出诊室,正好遇到一只胖橘猫。胖橘猫嘲笑它:“哟,这不是那只会抓老鼠却不会卖萌的狸花猫吗?”\n\n狸花猫淡定地回了一句:“我会抓老鼠,你会什么?”\n\n胖橘猫想了想,尴尬地说:“我…我会吃。”\n\n狸花猫笑道:“那不就成了?你负责吃,我负责抓,咱们各有各的活法!”\n\n—— **适合自己的,才是最好的。** 🐱\n'}

本例中:

  1. node_poem 和 node_joke 被重新执行。
  2. node_output 被重新执行。

由于没有执行change_topic,所以可以一直写同一只猫的内容

6.5.4.2. Fork
6.5.4.2.1. 用法说明

Fork 依赖状态图的 update_state() 方法

示例如下

change_input_config = graph.update_state(
    config=before_router_config.config,
    values={"user_input": "帮我写一个关于布偶猫的笑话"},
    as_node=START
)

其中

  • config:历史检查点的配置信息,通常包含 thread_idcheckpoint_ns 和 checkpoint_idLangGraph 会据此检索历史检查点

  • values:要应用到该检查点上的状态更新

  • as_node:指定这次状态更新应当被视为哪个节点产生的输出。

    LangGraph 会按照该节点的写入逻辑,将 values 写入状态通道,并据此决定后续从哪些节点继续执行。

    基于分叉检查点运行时,并不是重新执行 as_node 本身,而是从它的后继节点继续推进

    如 START 所属超步编号为 0,则恢复时从编号为 1 的超步开始运行。

注意

update_state() 不会修改原来的历史检查点,而是基于某个历史检查点创建一个新的检查点。这个新检查点相当于一条新的执行分支。

6.5.4.2.2. 准备检查点历史
6.5.4.2.2.1. 准备运行图并运行一次

示例如下

from typing import TypedDict, Annotated, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from langchain_deepseek import ChatDeepSeek
from loguru import logger
from dotenv import load_dotenv

load_dotenv(override=True)


model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    username: str
    user_input: str
    output: str

def router_node(state: OverAllState) -> StructuredOutputState:
    logger.info("路由节点已执行")
    user_input = state["user_input"]
    res = model_with_structure.invoke(
        [
            HumanMessage(user_input)
        ]
    )
    logger.info("路由结果-res: {}", res)

    return res

def router(state: StructuredOutputState) -> Literal["node_poem", "node_joke", "node_default"]:
    logger.info("路由函数已执行")
    if state["mode"] == "poem":
        logger.info("路由至 node_poem 节点")
        return "node_poem"
    elif state["mode"] == "joke":
        logger.info("路由至 node_joke 节点")
        return "node_joke"

    logger.info("路由至兜底节点")
    return "node_default"

def node_poem(state: StructuredOutputState) -> OverAllState:
    logger.info(f"node_poem 已执行")
    topic = state["topic"]
    poem = model.invoke([HumanMessage(f"写一首关于 {topic} 的七言绝句,不要赏析,只给诗句即可")]).content

    return {
        "output": poem
    }

def node_joke(state: StructuredOutputState) -> OverAllState:
    logger.info(f"node_joke 已执行")
    topic = state["topic"]
    joke = model.invoke([HumanMessage(f"写一个关于 {topic} 的笑话,尽可能简单,不超过一百字")]).content

    return {
        "output": joke
    }

def node_default(state: StructuredOutputState) -> OverAllState:
    logger.info(f"node_default 已执行")

    return {
        "output": "无法处理的任务类型"
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("router_node", router_node)
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_default", node_default)

builder.add_edge(START, "router_node")
builder.add_conditional_edges(
    "router_node",
    router,
    path_map=["node_poem", "node_joke", "node_default"]
)
builder.add_edge("node_poem", END)
builder.add_edge("node_joke", END)
builder.add_edge("node_default", END)

checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "123"}}
graph = builder.compile(checkpointer=checkpointer)

from IPython.display import display
display(graph)

res = graph.invoke({
    "username": "小黄",
    "user_input": "帮我写一首关于布偶猫的七言绝句"
}, config=config)
print(res)

执行逻辑如下:

  1. router_node 根据用户输入提取结构化结果,例如 {"topic": "布偶猫", "mode": "poem"}
  2. router 根据 mode 决定后续分支。
  3. 如果 mode == "poem",进入 node_poem
  4. 如果 mode == "joke",进入 node_joke
  5. 如果无法识别,则进入 node_default

运行结果如下

2026-06-11 14:26:48.353 | INFO     | __main__:router_node:31 - 路由节点已执行
2026-06-11 14:26:49.618 | INFO     | __main__:router_node:38 - 路由结果-res: {'topic': '布偶猫', 'mode': 'poem'}
2026-06-11 14:26:49.619 | INFO     | __main__:router:43 - 路由函数已执行
2026-06-11 14:26:49.619 | INFO     | __main__:router:45 - 路由至 node_poem 节点
2026-06-11 14:26:49.620 | INFO     | __main__:node_poem:55 - node_poem 已执行
{
    'username': '小黄', 
    'user_input': '帮我写一首关于布偶猫的七言绝句', 
    'output': '《布偶猫》\n蓝眼澄明玉作团,云踪雪影卧雕栏。\n无端一跃梅花落,犹带娇声唤月寒。'
}
6.5.4.2.2.2. 获取历史检查点列表

示例如下

history_checkpoints = list(graph.get_state_history(config=config))
print(history_checkpoints)

运行结果如下

[
    StateSnapshot(
        values={
            "username": "小黄",
            "user_input": "帮我写一首关于布偶猫的七言绝句",
            "output": (
                "《布偶猫》\n"
                "蓝眼澄明玉作团,云踪雪影卧雕栏。\n"
                "无端一跃梅花落,犹带娇声唤月寒。"
            ),
            "topic": "布偶猫",
            "mode": "poem",
        },
        next=(),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f1655e8-8784-65e9-8002-e21663d55c94",
            }
        },
        metadata={
            "source": "loop",
            "step": 2,
            "parents": {},
        },
        created_at="2026-06-11T06:26:51.611071+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f1655e8-7488-66c4-8001-f46b7c2d9a4e",
            }
        },
        tasks=(),
        interrupts=(),
    ),

    StateSnapshot(
        values={
            "username": "小黄",
            "user_input": "帮我写一首关于布偶猫的七言绝句",
            "topic": "布偶猫",
            "mode": "poem",
        },
        next=("node_poem",),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f1655e8-7488-66c4-8001-f46b7c2d9a4e",
            }
        },
        metadata={
            "source": "loop",
            "step": 1,
            "parents": {},
        },
        created_at="2026-06-11T06:26:49.620436+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f1655e8-6871-6073-8000-ddcbe680da21",
            }
        },
        tasks=(
            PregelTask(
                id="cb3a8901-3a87-d0b0-3bab-d1adda5563d8",
                name="node_poem",
                path=("__pregel_pull", "node_poem"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "output": (
                        "《布偶猫》\n"
                        "蓝眼澄明玉作团,云踪雪影卧雕栏。\n"
                        "无端一跃梅花落,犹带娇声唤月寒。"
                    )
                },
            ),
        ),
        interrupts=(),
    ),

    StateSnapshot(
        values={
            "username": "小黄",
            "user_input": "帮我写一首关于布偶猫的七言绝句",
        },
        next=("router_node",),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f1655e8-6871-6073-8000-ddcbe680da21",
            }
        },
        metadata={
            "source": "loop",
            "step": 0,
            "parents": {},
        },
        created_at="2026-06-11T06:26:48.352564+00:00",
        parent_config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f1655e8-686b-66c7-bfff-24e0e8d44f4c",
            }
        },
        tasks=(
            PregelTask(
                id="fb88436e-b163-6308-6341-7d8368e041f9",
                name="router_node",
                path=("__pregel_pull", "router_node"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "topic": "布偶猫",
                    "mode": "poem",
                },
            ),
        ),
        interrupts=(),
    ),

    StateSnapshot(
        values={},
        next=("__start__",),
        config={
            "configurable": {
                "thread_id": "123",
                "checkpoint_ns": "",
                "checkpoint_id": "1f1655e8-686b-66c7-bfff-24e0e8d44f4c",
            }
        },
        metadata={
            "source": "input",
            "step": -1,
            "parents": {},
        },
        created_at="2026-06-11T06:26:48.350273+00:00",
        parent_config=None,
        tasks=(
            PregelTask(
                id="a1b5e5fb-ee60-fd7b-68bc-475fa9cb66e2",
                name="__start__",
                path=("__pregel_pull", "__start__"),
                error=None,
                interrupts=(),
                state=None,
                result={
                    "username": "小黄",
                    "user_input": "帮我写一首关于布偶猫的七言绝句",
                },
            ),
        ),
        interrupts=(),
    ),
]
6.5.4.2.2.3. 获取 router_node 之前的检查点

示例如下

router_node 的上一个检查点的 next 字段为 ('router_node',),据此筛选检查点

before_router_checkpoint = next(h for h in history_checkpoints if h.next == ('router_node',))
before_router_checkpoint

运行结果如下

StateSnapshot(
    values={
        "username": "小黄",
        "user_input": "帮我写一首关于布偶猫的七言绝句",
    },
    next=("router_node",),
    config={
        "configurable": {
            "thread_id": "123",
            "checkpoint_ns": "",
            "checkpoint_id": "1f1655e8-6871-6073-8000-ddcbe680da21",
        }
    },
    metadata={
        "source": "loop",
        "step": 0,
        "parents": {},
    },
    created_at="2026-06-11T06:26:48.352564+00:00",
    parent_config={
        "configurable": {
            "thread_id": "123",
            "checkpoint_ns": "",
            "checkpoint_id": "1f1655e8-686b-66c7-bfff-24e0e8d44f4c",
        }
    },
    tasks=(
        PregelTask(
            id="fb88436e-b163-6308-6341-7d8368e041f9",
            name="router_node",
            path=("__pregel_pull", "router_node"),
            error=None,
            interrupts=(),
            state=None,
            result={
                "topic": "布偶猫",
                "mode": "poem",
            },
        ),
    ),
    interrupts=(),
)

该检查点表示:

用户输入已经写入状态,下一步将要执行 router_node

从这个检查点分叉,本文采用两种方案(还可以有别的方案):

  1. 修改输入,让 router_node 重新执行。见 6.5.4.2.3. 节
  2. 直接伪造 router_node 的输出,从而跳过 router_node。见 6.5.4.2.4. 节
6.5.4.2.3. 只改状态
6.5.4.2.3.1. 更新配置

示例如下

change_input_config = graph.update_state(
    config=before_router_checkpoint.config,
    values={"user_input": "帮我写一个关于布偶猫的笑话"},
    as_node=START
)
change_input_config

这里把 as_node 设置为 START,表示:

把这次状态更新视为 START 的输出。

因此,后续运行时会从 START 的后继节点继续推进,也就是重新执行 router_node

上述代码运行结果如下

{
    "configurable": {
        "thread_id": "123",
        "checkpoint_ns": "",
        "checkpoint_id": "1f1655f8-86fb-6dd1-8001-9e7808dfaf1a",
    }
}

执行 update_state() 后会返回一个新的配置,其中包含新的 checkpoint_id。这个新的 checkpoint_id 不同于原有历史检查点,说明 update_state() 创建了一个新的检查点分支。

需要注意:

update_state() 返回的是新检查点的配置,不是新状态本身。

新的状态已经被记录在检查点后端,运行时生效。可以执行以下代码查看新建的检查点快照:

graph.get_state(change_input_config)

此处不再演示。

6.5.4.2.3.2. 重新运行

示例如下

graph.invoke(None, change_input_config)

运行结果如下

2026-06-11 14:13:44.267 | INFO     | __main__:router_node:31 - 路由节点已执行
2026-06-11 14:13:45.222 | INFO     | __main__:router_node:38 - 路由结果-res: {'topic': '布偶猫', 'mode': 'joke'}
2026-06-11 14:13:45.223 | INFO     | __main__:router:43 - 路由函数已执行
2026-06-11 14:13:45.223 | INFO     | __main__:router:48 - 路由至 node_joke 节点
2026-06-11 14:13:45.225 | INFO     | __main__:node_joke:64 - node_joke 已执行
{
    "username": "小黄",
    "user_input": "帮我写一个关于布偶猫的笑话",
    "output": (
        "一只布偶猫照镜子,问:“魔镜魔镜,谁是世界上最美的猫?”  \n"
        "镜子没说话。  \n"
        "猫急了:“快说呀!不然我卖萌把你甜碎。”  \n"
        "镜子终于开口:“……我死机了,重启中。”"
    ),
}

由运行结果可知:

  1. user_input 已经从“帮我写一首关于布偶猫的七言绝句”变成“帮我写一个关于布偶猫的笑话”。而 username 没有被更新,仍然保持原值 "小黄"。说明 values 是 状态更新 而非完整的 状态替换
  2. router_node 开始的节点全部重新运行。由于新的输入被识别为笑话任务,因此后续进入 node_joke
6.5.4.2.4. 改状态且跳过某些节点
6.5.4.2.4.1. 更新配置

如果不希望重新执行 router_node,而是直接指定 router_node 的输出,可以把 as_node 设置为 "router_node"

示例如下

skip_router_config = graph.update_state(
    config=before_router_checkpoint.config,
    values={"topic": "狸花猫", "mode": "笑话"},
    as_node="router_node"
)
skip_router_config
  1. 此处把 {"topic": "狸花猫", "mode": "笑话"} 当作 router_node 的输出。

  2. 因此,后续运行时,router_node 本身不会被重新执行,从它的后继节点继续推进。

  3. 由于 router_node 挂载了条件边:

    builder.add_conditional_edges(
        "router_node",
        router,
        path_map=["node_poem", "node_joke", "node_default"]
    )

    所以当 update_state() 把 values 视为 router_node 的输出时,条件路由函数 router 也会根据这次更新后的状态计算后续分支。

    这里把 mode 设置为 "笑话"router 函数命中兜底分支 node_default

运行结果如下

2026-06-11 14:14:02.223 | INFO     | __main__:router:43 - 路由函数已执行
2026-06-11 14:14:02.223 | INFO     | __main__:router:51 - 路由至兜底节点
{
    "configurable": {
        "thread_id": "123",
        "checkpoint_ns": "",
        "checkpoint_id": "1f1655cb-de15-6c4e-8001-ca03b011e993",
    }
}

可以看到:

  1. update_state() 创建了一个新的检查点,返回的 skip_router_config 指向这个新检查点。

  2. 日志中出现了:

    路由函数已执行
    路由至兜底节点

    这说明 router_node 的节点函数没有被重新执行,但与 router_node 条件边相关的路由函数被执行了。

  3. update_state() 底层会根据 as_node 找到对应图节点的 writers

    对于 router_nodewriters 包括状态写入器和条件边对应的路由逻辑。

  4. 执行 update_state() 时,传入的 values 会被当作 router_node 的输出,交给该节点的 writers 处理。

    状态写入器会把 values 转换为对状态 Channel 的写入;条件边对应的路由逻辑会根据当前状态计算后继分支,并生成类似 branch:to:node_default 这样的触发通道写入。

  5. 随后,update_state() 会把这些写入应用到检查点中,并保存一个新的检查点。

    因此,状态更新和路由结果已经记录在 skip_router_config 指向的新检查点中。后续再调用:

    graph.invoke(None, skip_router_config)

    时,运行图会基于这个新检查点继续执行,被触发的后继节点也会继续运行。

综上,graph.update_state() 的作用可以理解为:

不执行指定节点的节点函数本身,而是把传入的 values 当作该节点已经产生的输出,执行该节点对应的 writers,应用状态写入和可能存在的路由写入,然后生成一个新的检查点。

因此,在本例中可以看到:router_node 的节点逻辑没有被执行,但它挂载的路由函数被执行,状态更新也已经生效。

6.5.4.2.4.2. 重新运行

示例如下

graph.invoke(None, skip_router_config)

运行结果如下

2026-06-11 16:49:42.501 | INFO     | __main__:node_default:73 - node_default 已执行
{
  "username": "小黄",
  "user_input": "帮我写一首关于布偶猫的七言绝句",
  "output": "无法处理的任务类型"
}

和分析一致,触发了兜底分支。

这种方式常用于测试场景。例如:

  • 跳过不稳定的大模型路由节点。
  • 手动指定路由结果,测试不同分支。
  • 修改历史状态,验证后续节点能否正常运行。
  • 基于同一个历史检查点探索多条可能的执行路径。

不过在正式业务流程中,应谨慎使用这种方式。因为它是在伪造某个节点的输出,如果伪造的状态不符合后续节点的预期,可能导致逻辑混乱,甚至抛出运行时异常。

7. 图记忆管理

Agent 的三种记忆:

  • 短期记忆:通过运行时状态 State 访问,并由检查点存储器 Checkpointer 保存,它按照 thread_id 组织,可以实现线程内的记忆共享。

  • 长期记忆:通过长期记忆存储器 Store 访问和存储。数据通常按照元组类型的命名空间组织,以键值对的形式存储,提供跨会话的记忆共享。

    • 可以按照命名空间+可选的过滤条件精确检索

    • 也可以通过语义模糊匹配。

  • 运行时上下文:通过上下文对象 Context 访问,只对本次调用生效,不会被持久化。

    它更适合传递本次运行所需的外部依赖或调用参数,如用户名、模型配置、数据库连接、权限信息等。

我们不止一次提到,Agent 底层就是一个简易的 ReAct 架构的 LangGraph 状态图,所以 Agent 的记忆机制本质上就是 LangGraph 状态图的记忆机制。

生产环境建议用基于持久化数据库的记忆存储器,如 PostgresSaver 和 PostgresStore

7.1. 短期记忆

只要编译图时启用了 Checkpointer,并且调用图时复用同一个 thread_idLangGraph 就可以在多次调用之间共享同一会话线程下的状态。

我们一直在用,不在赘述。

7.2. 长期记忆

长期记忆存储器在编译图时通过 store 参数传递,图节点中可以通过 Runtime 对象访问。

本节会在一个案例中同时用到短期记忆和长期记忆,两者均使用 PostgreSQL 作为持久化后端——短期记忆通过 PostgresSaver,长期记忆通过 PostgresStore,数据不会随进程退出而丢失。

7.2.1. 准备长期记忆

示例如下

from typing import Final, Tuple
from langgraph.store.postgres import PostgresStore

# 与 6.2.4 节使用同一数据库
DB_URL = "postgresql://langgraph_user:123456@localhost:5432/langgraph_db?sslmode=disable"
with PostgresStore.from_conn_string(DB_URL) as store:
    # setup() 创建长期记忆所需的表结构,幂等操作
    store.setup()

    # 命名空间 - 层级结构:("users", "用户名")
    # Final 的作用是告诉静态类型检查器,该变量不应该被重新赋值,但 python 运行时不会阻止重新赋值
    USERS_NS: Final[Tuple[str]] = ("users", )
    PREFERENCES_KEY: Final[str] = "preferences"

    # 三层: (领域, 用户实体)
    # 每个 key 存储该用户的不同类型数据
    namespace1 = (*USERS_NS, "Alice")
    value1 = {
        "course": "计算机组成原理",
        "sports": "跑步",
        "food": "紫光园奶皮子酸奶"
    }

    namespace2 = (*USERS_NS, "Bob")
    value2 = {
        "course": "数字电路与模拟电路",
        "sports": "跑步",
        "food": "奶皮子糖葫芦"
    }

    namespace3 = (*USERS_NS, "Black")
    value3 = {
        "course": "数字电路与模拟电路",
        "sports": "羽毛球",
        "food": "紫光园奶皮子酸奶"
    }

    store.put(namespace1, PREFERENCES_KEY, value1)
    store.put(namespace2, PREFERENCES_KEY, value2)
    store.put(namespace3, PREFERENCES_KEY, value3)

    for item in store.search(USERS_NS):
        print(item)

运行结果如下

Item(namespace=['users', 'Alice'], key='preferences', value={'course': '计算机组成原理', 'sports': '跑步', 'food': '紫光园奶皮子酸奶'}, created_at='2026-06-12T09:54:50.593300+00:00', updated_at='2026-06-12T09:54:50.593303+00:00', score=None)
Item(namespace=['users', 'Bob'], key='preferences', value={'course': '数字电路与模拟电路', 'sports': '跑步', 'food': '奶皮子糖葫芦'}, created_at='2026-06-12T09:54:50.593329+00:00', updated_at='2026-06-12T09:54:50.593329+00:00', score=None)
Item(namespace=['users', 'Black'], key='preferences', value={'course': '数字电路与模拟电路', 'sports': '羽毛球', 'food': '紫光园奶皮子酸奶'}, created_at='2026-06-12T09:54:50.593348+00:00', updated_at='2026-06-12T09:54:50.593348+00:00', score=None)

这里使用 USERS_NS 作为顶层命名空间,store.search(USERS_NS) 会查询所有以 ("users",) 开头的记忆数据。返回的每条数据都是一个 Item 实例。

需要注意:

  1. namespace 使用元组是硬性约束,天然表达层级结构。本例使用两层命名空间 ("users", "Alice"),清晰区分了**领域(users实体(用户名),而具体存储什么类型的数据则由 **key 参数表达(如 "preferences")。

  2. (*USERS_NS, "Alice") 的写法表示对已有元组进行解包,再拼接新的命名空间片段。

  3. PostgresStore 将长期记忆持久化到 PostgreSQL 中,程序重启后数据不会丢失,适合生产环境。本节使用和 6.2.4节6.5.4.1节 相同的数据库实例。

  4. 如果希望支持语义检索,需要在 Store 中配置索引和 embedding 函数。未配置索引时,search() 只能按照命名空间和过滤条件检索。

7.2.2. 访问长期记忆

长期记忆存储器可以通过节点函数中的 runtime.store 访问。

示例如下

from typing import Literal
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.store.postgres import PostgresStore
from langgraph.graph.message import MessagesState
from langgraph.runtime import Runtime
from langchain.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage
from langchain_deepseek import ChatDeepSeek

from loguru import logger
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(MessagesState):
    username: str
    user_input: str
    output: str
    preferences: dict[str, str] # 用户偏好

def check_preferences_node(state: OverAllState, runtime: Runtime) -> OverAllState:
    # 从短期记忆中获取用户名
    username = state["username"]
    # 获取长期记忆存储器 store
    store = runtime.store

    # 根据用户信息拼接命名空间,查询用户偏好
    namespace = (*USERS_NS, username)
    key = PREFERENCES_KEY
    item = store.get(namespace, key)

    if not item:
        logger.warning("长期记忆中没有 {} 的偏好数据", username)
        return {}

    logger.info("长期记忆中保存的用户偏好: {}", item.value)
    return {
        "preferences": item.value
    }

# 路由函数,如果状态中没有用户偏好,则从长期记忆中查询,否则直接执行模型节点
def router(state: OverAllState) -> Literal["check_preferences_node", "llm_node"]:
    if not state.get("preferences"):
        logger.info("需要从长期记忆中查询用户偏好")
        return "check_preferences_node"
    logger.info("用户偏好已存在,不必查询")
    return "llm_node"

def llm_node(state: OverAllState) -> OverAllState:
    # 长期记忆中未必有用户偏好
    preferences = state.get("preferences", {})
    user_input = state["user_input"]
    human_prompt = (f"这是用户的偏好: \n{preferences}\n"
              f"这是用户的需求: \n{user_input}\n")
    system_prompt = "请根据用户偏好解决用户需求"

    # 获取历史消息列表,如果历史为空则初始化系统提示词
    messages: list[SystemMessage | HumanMessage | AIMessage | ToolMessage] = [
        SystemMessage(content=system_prompt)
    ] if not state.get("messages", []) else state["messages"]

    model_response = model.invoke(messages + [HumanMessage(content=human_prompt)])
    output = model_response.content

    return {
        "messages": messages + [HumanMessage(content=human_prompt), model_response],
        "output": output
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("check_preferences_node", check_preferences_node)
builder.add_node("llm_node", llm_node)
builder.add_conditional_edges(START, router, path_map=["check_preferences_node", "llm_node"])
builder.add_edge("check_preferences_node", "llm_node")
builder.add_edge("llm_node", END)

# DB_URL 与 6.2.4 节一致
DB_URL = "postgresql://langgraph_user:123456@localhost:5432/langgraph_db?sslmode=disable"
# 编译时传递短期记忆和长期记忆存储器(均使用 PostgreSQL)
with PostgresSaver.from_conn_string(DB_URL) as checkpointer, \
     PostgresStore.from_conn_string(DB_URL) as store:
    checkpointer.setup()
    graph = builder.compile(checkpointer=checkpointer, store=store)
    from IPython.display import display
    display(graph)

    config = {"configurable": {"thread_id": "345"}}
    res = graph.invoke({"username": "Alice", "user_input": "我有点无聊,和我聊聊天吧"}, config=config)

    print('=' * 30, '->  <-', '=' * 30)
    print(res)

    # 第二次调用:复用同一个 thread_id,观察短期记忆缓存效果
    new_res = graph.invoke({"user_input": "推荐一下酸奶"}, config=config)
    new_messages = new_res.pop("messages")
    print('=' * 30, '->  <-', '=' * 30)
    print(new_res)

运行结果如下

2026-06-12 18:53:39.351 | INFO     | __main__:router:51 - 需要从长期记忆中查询用户偏好
2026-06-12 18:53:39.356 | INFO     | __main__:check_preferences_node:43 - 长期记忆中保存的用户偏好: {'course': '计算机组成原理', 'sports': '跑步', 'food': '紫光园奶皮子酸奶'}
============================== -> 不包含消息列表的运行结果 <- ==============================
{
    'username': 'Alice',
    'user_input': '我有点无聊,和我聊聊天吧', 
    'output': '哈哈,好啊!正好你学了《计算机组成原理》,我有个脑洞问题想请教一下:如果让你把“跑步的节奏感”比喻成计算机里的一个部件(比如CPU、内存、流水线),你会怎么对应呢?😄 顺便,我推荐你边慢跑边思考这个问题,跑完再来份紫光园奶皮子酸奶犒劳自己,简直完美!', 
    'preferences': {
        'course': '计算机组成原理', 
        'sports': '跑步', 
        'food': '紫光园奶皮子酸奶'
    }
}
============================== -> 消息列表 <- ==============================
================================ System Message ================================

请根据用户偏好解决用户需求
================================ Human Message =================================

这是用户的偏好: 
{
    'course': '计算机组成原理', 
    'sports': '跑步', 
    'food': '紫光园奶皮子酸奶'
}
这是用户的需求: 
我有点无聊,和我聊聊天吧

================================== Ai Message ==================================

哈哈,好啊!正好你学了《计算机组成原理》,我有个脑洞问题想请教一下:如果让你把“跑步的节奏感”比喻成计算机里的一个部件(比如CPU、内存、流水线),你会怎么对应呢?😄 顺便,我推荐你边慢跑边思考这个问题,跑完再来份紫光园奶皮子酸奶犒劳自己,简直完美!

运行流程如下

  1. 第一次调用时,状态中还没有 preferences
  2. 路由函数会路由至 check_preferences_node
  3. check_preferences_node 从长期记忆存储器中读取用户偏好。
  4. 读取到的偏好会写入图状态,成为当前会话线程中的短期记忆。
  5. 后续节点可以直接从状态中读取 preferences

7.2.3. 再次调用

在同一个 with 块中再次调用计算图,并复用同一个 thread_id,可以看到短期记忆的效果。该代码已包含在上节的 with 代码块末尾。

运行结果如下

2026-06-12 18:54:12.446 | INFO     | __main__:router:53 - 用户偏好已存在,不必查询
============================== -> 不包含消息列表的运行结果 <- ==============================
{
    'username': 'Alice', 
    'user_input': '推荐一下酸奶', 
    'output': '哈哈,看来你对紫光园奶皮子酸奶是真爱啊!不过既然你点名要推荐酸奶,我得先问一句——你是想换换口味试试别的,还是想让我帮你吹爆紫光园这款?(毕竟按你的偏好,它简直是“计算机组成原理”级别的完美搭配:**奶皮子像CPU缓存一样浓缩精华,酸甜度像流水线调度一样平衡,吃完跑步时还能提供“数据预取”般的能量**😂)\n\n如果是**忠于原味**,那必须再安利一次紫光园:浓稠拉丝、奶香爆炸,跑完步来一罐,简直像给肌肉做了个“中断响应”!如果是**想尝鲜**,我推荐:\n1. **兰格格天边的额吉**——蒙古风味,酸度低、像RISC精简指令集一样纯净;\n2. **简爱父爱配方**——无添加糖,适合跑步后控糖,像低功耗处理器;\n3. **卡士原态酪乳**——口感像固态硬盘般扎实绵密,适合当夜宵。\n\n不过……你确定要换掉紫光园吗?🤔', 
    'preferences': {
        'course': '计算机组成原理', 
        'sports': '跑步', 
        'food': '紫光园奶皮子酸奶'
    }
}
============================== -> 消息列表 <- ==============================
================================ System Message ================================

请根据用户偏好解决用户需求
================================ Human Message =================================

这是用户的偏好: 
{
    'course': '计算机组成原理', 
    'sports': '跑步', 
    'food': '紫光园奶皮子酸奶'
}
这是用户的需求: 
我有点无聊,和我聊聊天吧

================================== Ai Message ==================================

哈哈,好啊!正好你学了《计算机组成原理》,我有个脑洞问题想请教一下:如果让你把“跑步的节奏感”比喻成计算机里的一个部件(比如CPU、内存、流水线),你会怎么对应呢?😄 顺便,我推荐你边慢跑边思考这个问题,跑完再来份紫光园奶皮子酸奶犒劳自己,简直完美!
================================ Human Message =================================

这是用户的偏好: 
{
    'course': '计算机组成原理', 
    'sports': '跑步', 
    'food': '紫光园奶皮子酸奶'
}
这是用户的需求: 
推荐一下酸奶

================================== Ai Message ==================================

哈哈,看来你对紫光园奶皮子酸奶是真爱啊!不过既然你点名要推荐酸奶,我得先问一句——你是想换换口味试试别的,还是想让我帮你吹爆紫光园这款?(毕竟按你的偏好,它简直是“计算机组成原理”级别的完美搭配:**奶皮子像CPU缓存一样浓缩精华,酸甜度像流水线调度一样平衡,吃完跑步时还能提供“数据预取”般的能量**😂)

如果是**忠于原味**,那必须再安利一次紫光园:浓稠拉丝、奶香爆炸,跑完步来一罐,简直像给肌肉做了个“中断响应”!如果是**想尝鲜**,我推荐:
1. **兰格格天边的额吉**——蒙古风味,酸度低、像RISC精简指令集一样纯净;
2. **简爱父爱配方**——无添加糖,适合跑步后控糖,像低功耗处理器;
3. **卡士原态酪乳**——口感像固态硬盘般扎实绵密,适合当夜宵。

不过……你确定要换掉紫光园吗?🤔

第二次运行前,状态中已经包含:

  • username
  • preferences
  • messages
  • 上一次调用生成的 output

因此路由函数会直接判断:

logger.info("用户偏好已存在,不必查询")

因此,本次调用不会重新访问长期记忆存储器,而是直接复用当前会话状态中缓存的用户偏好。

这正是短期记忆和长期记忆协作的典型模式:

第一次调用从长期记忆中读取数据,并写入短期记忆;后续同一会话线程内优先复用短期记忆,避免重复查询长期记忆。

7.3. 运行时上下文

在某些场景下,我们希望在调用图时传入一些仅对当次调用有效的信息,比如当前登录用户、请求来源、调用方标识等。这些信息不适合放入图状态(图状态会被持久化并在同一会话中跨调用共享),而应该通过**运行时上下文(Runtime Context)**传递。

运行时上下文的特点是:

仅对本次调用生效,不会被持久化,也不会在同一会话的下一次调用中自动恢复。

运行时上下文的使用方式:

  1. 初始化状态图时,使用 context_schema 定义上下文类型。
  2. 调用图时通过 context 参数传入上下文对象。
  3. 节点或路由函数中通过 runtime.context 访问上下文。

7.3.1. 访问运行时上下文

下面通过一个简单的客服机器人案例演示运行时上下文的用法。

场景:不同的用户调用同一个机器人,机器人根据运行时上下文中的用户名会员等级,生成不同风格的回复。

图结构

START -> llm_node -> END

示例如下

from dataclasses import dataclass
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langgraph.runtime import Runtime
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek

from loguru import logger
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

# 1. 定义运行时上下文类型
@dataclass
class UserContext:
    username: str
    membership_level: str  # "普通用户" / "VIP"

# 2. 定义图状态
class OverAllState(MessagesState):
    user_input: str
    output: str

# 3. 定义节点:通过 runtime.context 访问运行时上下文
def llm_node(state: OverAllState, runtime: Runtime[UserContext]) -> OverAllState:
    runtime_context = runtime.context

    if runtime_context:
        username = runtime_context.username
        level = runtime_context.membership_level
        logger.info(f"当前用户: {username}, 会员等级: {level}")

        if level == "VIP":
            system_prompt = f"你是高级客服助理。当前VIP用户是{username},请使用尊称'您',语气热情周到,回复末尾加上'🎖️VIP专属服务'。"
        else:
            system_prompt = f"你是普通客服助理。当前用户是{username},请友好简洁地回复。"
    else:
        logger.warning("运行时上下文为空,使用默认风格")
        system_prompt = "你是客服助理,请友好简洁地回复。"

    user_input = state["user_input"]
    messages = state.get("messages", [])
    response = model.invoke(
        [HumanMessage(content=system_prompt)] +
        messages +
        [HumanMessage(content=user_input)]
    )

    return {
        "messages": [response],
        "output": response.content
    }

# 4. 构建图,传入 context_schema
builder = StateGraph(state_schema=OverAllState, context_schema=UserContext)
builder.add_node("llm_node", llm_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", END)

graph = builder.compile()

# === 第一次调用:传入运行时上下文(VIP用户) ===
print("=" * 30, "第一次调用:VIP用户", "=" * 30)
config = {"configurable": {"thread_id": "demo-7.3"}}
res = graph.invoke(
    {"user_input": "你好,帮我查一下最近有什么优惠活动"},
    config=config,
    context=UserContext(username="Alice", membership_level="VIP")
)

print(f"output: {res['output']}")
print()

# === 第二次调用:不传运行时上下文 ===
print("=" * 30, "第二次调用:不传上下文", "=" * 30)
res2 = graph.invoke(
    {"user_input": "再帮我看看有没有新品"},
    config=config
)

print(f"output: {res2['output']}")

from IPython.display import display
display(graph)

运行结果如下

2026-07-09 17:40:27.208 | INFO     | __main__:llm_node:39 - 当前用户: Alice, 会员等级: VIP
============================== 第一次调用:VIP用户 ==============================
2026-07-09 17:40:29.116 | WARNING  | __main__:llm_node:46 - 运行时上下文为空,使用默认风格
output: 尊敬的Alice您好!非常感谢您的咨询🎉 我们近期为VIP用户准备了多项专属优惠活动,包括但不限于:1)满额立减(消费满888元立减88元);2)积分翻倍(指定商品享3倍积分);3)限时闪购(每日10:00-12:00精选好物低至5折)。如需了解具体参与商品或为您定制个性化推荐,请随时告知,我将全力为您服务!🎖️VIP专属服务

============================== 第二次调用:不传上下文 ==============================
output: 您好!目前新品信息尚未更新,建议您关注官方渠道获取最新动态。如有其他问题,欢迎随时咨询,我会尽力为您解答!😊

分析

  1. 第一次调用传入了 context=UserContext(username=”Alice”, membership_level=”VIP”),节点通过 runtime.context 读取到用户名和会员等级,生成了VIP风格的回复,末尾带上了 🎖️VIP专属服务 标识。

  2. 第二次调用使用了相同的 thread_id,但没有传入 context。此时 runtime.context 为 None,节点走入了默认风格分支,回复中不再有VIP标识。

这个对比可以清晰地看到:

运行时上下文仅对当次调用生效,不会被持久化。即使使用相同的 thread_id,下一次调用也不会自动继承上一次的上下文。

7.3.2. 小结

运行时上下文适合传递以下信息:

适合放入运行时上下文不适合(应放入图状态)
当前登录用户信息需要在多轮对话间共享的数据
请求来源(Web / API / 小程序)需要在检查点中恢复的执行进度
调用方标识、Trace ID需要跨调用持久化的业务数据
本次调用的功能开关节点间需要传递的计算结果

简单来说:

跨调用共享、需要持久化的数据 → 放入 State;仅当次调用有效的信息 → 放入 Runtime Context。

7.4. Node总结:LangGraph的节点

7.4.1. 节点的本质

从用户视角看,LangGraph 的节点通常是一个可调用对象,最常见的是同步或异步 Python 函数:

def sync_node(state: State) -> State:
    ...

async def async_node(state: State) -> State:
    ...

除此之外,节点也可以是符合要求的其他可调用对象或 Runnable 实例。

从源码实现角度看,节点最终会被转换或包装为可运行对象,并作为 PregelNode.bound 保存。

7.4.2. 节点函数的完整形态

状态图节点的第一个位置参数用于接收图状态。

此外,LangGraph 还可以在运行时注入运行时配置运行时对象流式写入器等参数。

普通图节点通常使用以下四个参数:

  • state:输入节点的图状态。state 是位置传参,因此参数名称不重要,但通常约定命名为 state

  • config:状态图的运行时配置,它是一个 RunnableConfig 实例,底层运行时在节点函数执行前通过关键字传参动态注入。

    可以通过 config 访问当前线程的 thread_id、超步序号、递归限制等配置信息。如下

    config["configurable"]["thread_id"] 
    config["recursion_limit"]
    config["metadata"]
  • runtime:状态图的运行时对象,可以通过它访问运行时上下文、长期记忆存储器等信息。底层运行时在节点函数执行前通过关键字传参动态注入。

  • writer:流式写入器,通常用于自定义流式输出。详见流处理章节。

典型写法如下:

def node(state: State, config: RunnableConfig, runtime: Runtime[Context]) -> State:
    ...
  • 编译图时传入 checkpointer,并在调用时传入 config,可以在节点中通过 config 间接访问当前调用的配置信息,如 thread_id
  • 编译图时传入 store 后,可以在节点中通过 runtime.store 访问长期记忆存储器。
  • 初始化状态图时传入 context_schema,并在调用时传入 context 后,可以在节点中通过 runtime.context 访问运行时上下文。

7.4.3. 节点的触发和执行

7.4.3.1. 节点的触发
7.4.3.1.1. 用户视角

从用户层面看,节点由图中的控制流关系(普通边、条件边和动态控制指令)触发:

  • 普通边决定固定的后继节点。

  • 条件边根据路由函数的返回值决定后继节点。

    • 可以返回普通节点名称
    • 也可以返回 Send 实例,从而动态派发多个并行任务,常用于 Map-Reduce 场景。
  • Command.goto 可以在节点返回时动态指定后继节点。

7.4.3.1.2. 源码视角

从源码实现角度看,LangGraph 会将图状态和节点之间的触发关系组织为通道。

其中,状态字段通常对应状态通道,节点之间的控制流关系则通过触发通道或屏障通道表示。对于普通的单节点触发关系,目标节点通常会订阅如下形式的内部通道:

branch:to:<node_name>

例如,对于普通边:

builder.add_edge("node_a", "node_b")

运行时 node_a 的写入器会向以下通道写入数据:

branch:to:node_b

而 node_b 会将该通道注册为自己的触发通道。

条件边和 Command.goto 最终也会根据目标节点生成相应的触发通道写入。Send 则会生成动态派发任务所需的 Send 数据包。

对于多个前驱节点共同汇聚到同一个节点的情况,底层还可能使用类似下面的屏障通道:

join:<node_a>+<node_b>:<node_c>

用于等待指定的前驱节点全部完成。

当某个节点订阅的触发通道在当前超步中产生新的写入后,该节点会在下一超步被调度。

7.4.3.2. 节点的执行
7.4.3.2.1. 用户视角

从用户层面看,节点执行过程可以理解为:

  1. LangGraph 从当前图状态中读取节点需要的字段,并构造节点输入。

  2. 调用节点函数,执行其业务逻辑。

  3. 节点函数返回状态更新、Command 或其他受支持的结果。

  4. LangGraph 将节点返回值转换为状态通道和控制通道的写入条目。

  5. 如果当前节点挂载了条件边,则执行条件边路由逻辑,根据包含当前节点更新的状态决定后继节点。

  6. 当前超步的所有任务执行完毕后,LangGraph 汇总这些写入,并根据各状态字段的 Reducer 规则更新图状态。

节点通常只需要返回发生变化的字段,而不需要返回完整状态。例如:

def node_a(state: State) -> State:
    return {"count": state["count"] + 1}

其中:

{"count": state["count"] + 1}

表示对状态的更新,而不是一份必须包含所有字段的完整状态。

7.4.3.2.2. 源码视角

从源码实现角度看,节点业务逻辑、状态写入逻辑和控制流写入逻辑会被组合成一个可顺序执行的可运行对象。

编译节点时,LangGraph 会创建一个 PregelNode

相关源码位于 CompiledStateGraph 类的 attach_node() 方法中,如下

self.nodes[key] = PregelNode(
    triggers=[branch_channel],
    # read state keys and managed values
    channels=("__root__" if is_single_input else input_channels),
    # coerce state dict to schema class (eg. pydantic model)
    mapper=mapper,
    # publish to state keys
    writers=[ChannelWrite(write_entries)],
    metadata=node.metadata,
    retry_policy=node.retry_policy,
    cache_policy=node.cache_policy,
    bound=node.runnable,  # type: ignore[arg-type]
)

其中:

  • bound 保存节点的业务逻辑;
  • writers 保存节点执行完成后需要调用的写入器;
  • triggers 保存能够触发该节点的通道。

可以近似表示为:

PregelNode
├── bound:节点业务逻辑
├── writers:状态和控制流写入器
└── triggers:能够触发该节点的通道

因此,从整体上看,节点执行并不是简单地”调用一个函数并返回结果”,而是:

执行业务逻辑,生成状态和控制流写入,并通过目标节点触发通道或动态任务写入,为下一超步生成待调度任务,持续推动计算图运行。


8. 中断

LangGraph 提供了两种中断机制:

  • 动态中断:在图的任意节点中调用 interrupt() 函数实现

    它可以放在代码的任意位置,并且可以根据应用逻辑设置条件触发,所以是动态的。

    动态中断提供了人机交互接口,使得调用者可以人为干预计算图的运行,是业务逻辑的一部分

  • 静态中断:在编译或调用状态图时通过 interrupt_before 和 interrupt_after 参数设置断点

    它是在运行前确定的,不能根据业务逻辑条件触发,所以是静态的。

    静态中断主要用于调试,不是业务逻辑的一部分

8.1. 动态中断

8.1.1. 概述

LangGraph 的动态中断机制允许用户在图执行过程中设置暂停点,等待外部输入后再继续执行,提供了人机交互接口。

当中断触发时,LangGraph 会通过持久化机制保存当前图状态,并无限期等待直到用户恢复执行。

中断通过在任意图节点中调用 interrupt() 函数实现,该函数可以接收任何可 JSON 序列化的值,并将该值暴露给调用方。

中断触发后,调用方根据 interrupt() 暴露的信息生成反馈,重新调用图时通过 Command 将反馈传递给计算图,恢复图的运行。

恢复运行后,调用方通过 Command 传递的反馈会作为 interrupt() 函数的返回值,参与后续计算。

8.1.2. 启用中断

  1. 配置检查点存储器

  2. 设置 thread_id

    如果要确保中断后可以恢复执行,就需要保存运行图的完整状态,所以必须启用可恢复执行

  3. 在需要中断的位置调用 interrupt()

8.1.3. 恢复中断

满足以下要求:

  • 基于相同的配置再次调用计算图

  • 将输入替换为 Command() 实例即可恢复运行。

LangGraph 会从中断节点继续运行,该节点会被再次执行。

通过 Command 实例的 resume 属性将用户反馈传递给计算图,中断节点重新运行时,传递给 resume 属性的值将会作为 interrupt() 函数的返回值。

8.1.4. 常见使用模式

根据中断发生的位置、业务场景以及执行结构,动态中断可以衍生出多种常见的使用模式。本节将介绍以下模式:

  1. 基础 HITL 模式:状态图触发一次中断,获取人类输入后继续执行。
  2. 多个并行中断:多个并行任务分别产生中断,并根据 中断 ID 接收各自的恢复数据。
  3. 审批模式:根据人类审批(批准、拒绝或其它处理方式),决定后续执行路径。
  4. 审核与编辑模式:将模型生成的内容交给人类检查,并允许人类直接修改后继续处理。
  5. 工具执行审批模式:在调用工具或执行具有副作用的操作之前,由人类确认是否允许执行。
  6. 单节点串行中断模式:在同一个节点内多次触发中断,上一个中断恢复后才能触发下一个。
  7. 人类输入验证模式:对人类输入进行校验;当输入不符合要求时,再次中断并要求重新输入。

需要注意,这些模式并不是完全互斥的。例如,工具执行审批本质上也是审批模式的一种,只是它专门应用于工具调用场景。

8.1.4.1. 基础**HITL**模式
8.1.4.1.1. 什么是HITL

HITL(Human In The Loop) 即人在环,是一种经典的人机协同设计模式,它允许计算图在运行过程中暂停,等待人工审批、修正意见或补充信息,然后从暂停位置恢复执行。

8.1.4.1.2. 基础HITL模式的实现

调用 interrupt() 可以暂停状态图的执行,并等待外部输入后恢复运行。因此它是实现 HITL 的核心机制。

不过,仅仅调用 interrupt() 并不一定构成 HITL。只有当中断后的决策、输入或修改确实由人类完成时,才能称为 HITL;如果恢复数据完全由程序自动提供,本质上仍然是普通的中断与恢复机制。

示例如下

from typing import TypedDict

from langgraph.graph import StateGraph,START,END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver


#1. 声明状态
class OverAllState(TypedDict):
    username:str

#2. 声明节点
def node_a(state:OverAllState)->OverAllState:
    username = interrupt("请输入您的姓名")
    return {
        "username":username
    }

#3. 构建图
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a",node_a)
builder.add_edge(START,"node_a")
builder.add_edge("node_a",END)

#4. 想要使用中断 => 必须配置检查点
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

from IPython.display import display
display(graph)

config = {"configurable":{"thread_id":"123"}}
interrupt_res = graph.invoke({},config=config)
print(interrupt_res)

prompt = interrupt_res['__interrupt__'][0].value
print(prompt)
username = input(prompt)
# 5. 恢复图的中断执行
resume_res = graph.invoke(Command(resume=username),config=config)
print(resume_res)

运行结果如下

============================== -> interrupt_res <- ==============================

{'__interrupt__': [Interrupt(value='请输入您的姓名', id='3543beca616d469fe1ccd35250ef5acf')]}

============================== -> resumed_res <- ==============================

{'username': '小黄'}
8.1.4.2. 多个并行中断

示例如下

from typing import TypedDict

from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langgraph.checkpoint.memory import InMemorySaver

class OverAllState(TypedDict):
    username: str
    age: int

def node_a(state: OverAllState) -> OverAllState:
    username = interrupt("请输入您的姓名")
    return {
        "username": username
    }

def node_b(state: OverAllState) -> OverAllState:
    age = interrupt("请输入您的年龄")
    return {
        "age": age
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

from IPython.display import display
display(graph)

config = {"configurable": {"thread_id": "123"}}
interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
print(interrupted_res)

# 构建 中断ID -> 恢复指令 的映射
resume_map = {}
for i in interrupted_res['__interrupt__']:
    user_input = input(f"{i.value}: ")
    if "年龄" in i.value:
        resume_map[i.id] = int(user_input)
    else:
        resume_map[i.id] = user_input

resumed_res = graph.invoke(Command(resume=resume_map), config=config)
print('=' * 30, '-> resumed_res <-', '=' * 30)
print(resumed_res)

运行结果如下

============================== -> interrupt_res <- ==============================

{'__interrupt__': [Interrupt(value='请输入您的年龄', id='2770f00729006c756d481fffa712bb27'), Interrupt(value='请输入您的姓名', id='db7e81e1d4f5cab611f07e0469e99854')]}

============================== -> resumed_res <- ==============================

{'username': '小黄', 'age': 19}
8.1.4.3. 审批模式

示例如下

from time import sleep
from typing import TypedDict, Literal

from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph,START,END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
from langchain_deepseek import ChatDeepSeek

from dotenv import load_dotenv
load_dotenv(override=True)

model =ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking":{
            "type":"disabled"
        }
    }
)

#1. 声明状态
class OverAllState(TypedDict):
    topic:str
    poem:str
    is_approved:bool

#2. 声明节点
def approve_node(state:OverAllState) -> Command[Literal["llm_node","default_node"]]:
    is_approved = interrupt("是否同意调用模型?")
    goto = "llm_node" if is_approved else "default_node"
    return Command(
        goto=goto,
        update={"is_approved":is_approved}
    )

def llm_node(state:OverAllState) -> OverAllState:
    topic = state["topic"]
    res = model.invoke([HumanMessage(content=f"帮我写一首关于{topic}主题的七言绝句,只写诗句,不需要赏析")]).content

    return {
        "poem":res
    }

def default_node(state:OverAllState) -> OverAllState:
    return {
        "poem":"请求被拒绝"
    }

#3. 构建图
builder = StateGraph(state_schema=OverAllState)

builder.add_node("approve_node",approve_node)
builder.add_node("llm_node",llm_node)
builder.add_node("default_node",default_node)
builder.add_edge(START,"approve_node")
builder.add_edge("llm_node",END)
builder.add_edge("default_node",END)

#4. 添加检查点后端
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

from IPython.display import display
display(graph)

#5. 首次执行 -> 触发中断
config = {"configurable":{"thread_id":"234"}}
interrupt_res = graph.invoke({"topic":"菊花"},config=config)
print(interrupt_res)
#6. 人工审核
user_approved = input("是否同意调用模型?(y/n)").strip().lower() == 'y'
# 重新调用图
approved_res = graph.invoke(Command(resume=user_approved),config=config)
print(approved_res)

#7. 审核不通过
config1 = {"configurable":{"thread_id":"456"}}
interrupt_res = graph.invoke({"topic":"牡丹花"},config=config1)
print(interrupt_res)

user_approved = input("是否同意调用模型?(y/n)").strip().lower() == 'y'
# 重新调用图
approved_res = graph.invoke(Command(resume=user_approved),config=config1)
print(approved_res)

运行结果如下

{'topic': '菊花', '__interrupt__': [Interrupt(value='是否同意调用模型?', id='91edf41ecf736a242c18fd61d40ad5b4')]}

{'topic': '菊花', 'poem': '《咏菊》\n西风猎猎卷霜华,独抱秋心隐士家。\n冷艳不争桃李色,寒香偏入野人茶。', 'is_approved': True}

{'topic': '牡丹花', '__interrupt__': [Interrupt(value='是否同意调用模型?', id='47958afc1277b7ce6bf850a5f27f2e46')]}
{'topic': '牡丹花', 'poem': '请求被拒绝', 'is_approved': False}
8.1.4.4. 审核与编辑模式

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from langgraph.checkpoint.memory import InMemorySaver
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    reviewed_poem: str

def llm_node(state: OverAllState) -> OverAllState:
    topic = state['topic']

    res = model.invoke([HumanMessage(content=f"帮我写一首关于 {topic} 的七言绝句,只给出诗句,不要赏析")]).content
    return {
        "poem": res
    }

def review_node(state: OverAllState) -> OverAllState:
    reviewed_poem = interrupt({
        "instruction": "请审核并修改大模型生成的七言绝句",
        "poem": state['poem']
    })
    return {
        "reviewed_poem": reviewed_poem
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("llm_node", llm_node)
builder.add_node("review_node", review_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", "review_node")
builder.add_edge("review_node", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

from IPython.display import display
display(graph)

config = {"configurable": {"thread_id": "review_test"}}
interrupted_res = graph.invoke({"topic": "布偶猫"}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
print(interrupted_res)

print("原始诗句:")
print(interrupted_res['__interrupt__'][0].value['poem'])
user_review = input("请审核并修改诗句(直接回车保留原诗): ")
if not user_review.strip():
    user_review = interrupted_res['__interrupt__'][0].value['poem']
reviewed_res = graph.invoke(Command(resume=user_review), config=config)
print('=' * 30, '-> reviewed_res <-', '=' * 30)
print(reviewed_res)

运行结果如下

============================== -> interrupt_res <- ==============================

{'topic': '布偶猫', 'poem': '《布偶猫》\n冰眸玉骨雪团身,蓝影衔春卧月轮。\n偶启朱唇呼入梦,云鬟一枕醉红尘。', '__interrupt__': [Interrupt(value={'instruction': '请审核并修改大模型生成的七言绝句', 'poem': '《布偶猫》\n冰眸玉骨雪团身,蓝影衔春卧月轮。\n偶启朱唇呼入梦,云鬟一枕醉红尘。'}, id='de13581234c57fb8b5d9620295b8239e')]}

============================== -> reviewed_res <- ==============================

{'topic': '布偶猫', 'poem': '《布偶猫》\n冰眸玉骨雪团身,蓝影衔春卧月轮。\n偶启朱唇呼入梦,云鬟一枕醉红尘。', 'reviewed_poem': '已审核:《布偶猫》\n冰眸玉骨雪团身,蓝影衔春卧月轮。\n偶启朱唇呼入梦,云鬟一枕醉红尘。'}
8.1.4.5. 工具执行审批模式

示例如下

from typing import Literal
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langgraph.graph.message import MessagesState
from langgraph.checkpoint.memory import InMemorySaver
from langchain.messages import HumanMessage, ToolMessage
from langchain.tools import tool
from langchain_deepseek import ChatDeepSeek

from loguru import logger
from dotenv import load_dotenv
load_dotenv(override=True)

@tool(parse_docstring=True)
def get_weather(city: str) -> str:
    """
    查询指定城市的当日天气

    Args:
        city: 城市名称
    """
    is_approved = interrupt({
        "action": "get_weather",
        "question": "是否同意查询天气?"
    })

    logger.info("is_approved: {}", is_approved)
    if is_approved:
        return f"{city} 今天天气不错"
    else:
        return "用户拒绝查询天气"

tools_by_name = {"get_weather": get_weather}

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    }
)
model_with_tools = model.bind_tools([get_weather])

def llm_node(state: MessagesState) -> MessagesState:
    messages = state['messages']
    response = model_with_tools.invoke(messages)

    return {
        "messages": [response]
    }

def tool_node(state: MessagesState) -> MessagesState:
    last_msg = state['messages'][-1]

    tool_msgs = []
    for tool_call in last_msg.tool_calls:
        tool = tools_by_name[tool_call["name"]]
        logger.info("工具 {} 被调用, 对应的 tool_call: {}", tool_call["name"], tool_call)
        tool_res = tool.invoke(tool_call["args"])
        tool_msg = ToolMessage(
            name = tool_call["name"],
            content = tool_res,
            tool_call_id = tool_call["id"]
        )
        tool_msgs.append(tool_msg)

    return {
        "messages": tool_msgs
    }

def router(state: MessagesState) -> Literal["tool_node", END]:
    if state['messages'][-1].tool_calls:
        return "tool_node"
    return END

builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "llm_node")

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

from IPython.display import display
display(graph)

config = {"configurable": {"thread_id": "tool_test"}}
interrupted_res = graph.invoke({"messages": [HumanMessage("今天北京天气如何?")]}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
for msg in interrupted_res['messages']:
    msg.pretty_print()
print('=' * 30, '-> interrupt_info <-', '=' * 30)
print(interrupted_res['__interrupt__'])

user_approved = input("是否同意查询天气?(y/n): ").strip().lower() == 'y'
approved_res = graph.invoke(Command(resume=user_approved), config=config)
print('=' * 30, '-> approved_res <-', '=' * 30)
for msg in approved_res['messages']:
    msg.pretty_print()

运行结果如下

2026-06-16 14:51:50.662 | INFO     | __main__:tool_node:59 - 工具 get_weather 被调用, 对应的 tool_call: {'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_00_EGxsgveRV6mFkXsTtN4H9369', 'type': 'tool_call'}
============================== -> interrupt_res <- ==============================
================================ Human Message =================================

今天北京天气如何?
================================== Ai Message ==================================

好的,我来查询一下今天北京的天气情况。
Tool Calls:
  get_weather (call_00_EGxsgveRV6mFkXsTtN4H9369)
 Call ID: call_00_EGxsgveRV6mFkXsTtN4H9369
  Args:
    city: 北京
============================== -> interrupt_info <- ==============================
[Interrupt(value={'action': 'get_weather', 'question': '是否同意查询天气?'}, id='907128d6bef87a8473833ec0c90589ea')]
2026-06-16 14:51:50.675 | INFO     | __main__:tool_node:59 - 工具 get_weather 被调用, 对应的 tool_call: {'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_00_EGxsgveRV6mFkXsTtN4H9369', 'type': 'tool_call'}
2026-06-16 14:51:50.677 | INFO     | __main__:get_weather:27 - is_approved: True
============================== -> approved_res <- ==============================
================================ Human Message =================================

今天北京天气如何?
================================== Ai Message ==================================

好的,我来查询一下今天北京的天气情况。
Tool Calls:
  get_weather (call_00_EGxsgveRV6mFkXsTtN4H9369)
 Call ID: call_00_EGxsgveRV6mFkXsTtN4H9369
  Args:
    city: 北京
================================= Tool Message =================================
Name: get_weather

北京 今天天气不错
================================== Ai Message ==================================

今天北京的天气**不错**哦!☀️ 是个适合出行的好天气。如果需要更详细的天气信息(如温度、风力等),可以随时告诉我,我帮你进一步查询!
8.1.4.6. 单节点串行中断模式

示例如下

from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command

class OverAllState(TypedDict):
    username: str # 姓名
    age: int # 年龄
    gender: Literal["male", "female"] # 性别

def get_info_node(state: OverAllState) -> OverAllState:
    username = interrupt("请输入您的用户名:")
    age = interrupt("请输入您的年龄:")
    gender = interrupt("请输入您的性别:(male/female)")

    return {
        "username": username,
        "age": age,
        "gender": gender
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("get_info_node", get_info_node)
builder.add_edge(START, "get_info_node")
builder.add_edge("get_info_node", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "seq_interrupt_test"}}
username_interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> username_interrupted_res <-', '=' * 30)
print(username_interrupted_res)

user_name = input("请输入您的用户名:")
age_interrupted_res = graph.invoke(Command(resume=user_name), config=config)
print('=' * 30, '-> age_interrupted_res <-', '=' * 30)
print(age_interrupted_res)

user_age = input("请输入您的年龄:")
gender_interrupted_res = graph.invoke(Command(resume=int(user_age)), config=config)
print('=' * 30, '-> gender_interrupted_res <-', '=' * 30)
print(gender_interrupted_res)

user_gender = input("请输入您的性别:(male/female): ")
resumed_res = graph.invoke(Command(resume=user_gender), config=config)
print('=' * 30, '-> resumed_res <-', '=' * 30)
print(resumed_res)

运行结果如下

============================== -> username_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的用户名:', id='0359f8a0b1ca357d2d092d5c55d80ec1')]}
============================== -> age_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的年龄:', id='0359f8a0b1ca357d2d092d5c55d80ec1')]}
============================== -> gender_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的性别:(male/female)', id='0359f8a0b1ca357d2d092d5c55d80ec1')]}
============================== -> resumed_res <- ==============================
{'username': '小黄', 'age': 15, 'gender': 'male'}

中断恢复时,整个被中断的节点函数都会重新运行。

单个节点中串行地多次调用 interrupt() 函数时,检查点存储器会记录历史的 resume 信息,LangGraph 运行时会读取这些信息,并在 interrupt() 函数中维护索引,按照节点内调用 interrupt() 函数的顺序,逐个取出历史 resume 的值,并将它们作为 interrupt() 函数的返回值。

所以,已经被恢复的 interrupt() 不会被重复触发。并且,由此可以推断,我们要保证历史 resume 可以被正确应用,就应保证中断恢复前后的多次 interrupt() 相对顺序保持不变。

当历史 resume 耗尽后,本次恢复运行时传入的 resume 会作为本次中断的返回值,然后节点函数继续运行。

所有中断都触发并恢复后,计算图正常结束。

8.1.5. 使用规范

8.1.5.1. 不要用try/catch包裹interrupt()调用

中断的触发是通过抛出 GraphInterrupt 异常实现的,如果用 try/catch 包裹,则底层运行时无法感知断点,不会中断计算图。

正确用法

def node_a(state: State):
    # ✅ 正确用法:先打断点,再处理异常
    interrupt("What's your name?")
    try:
        fetch_data()  # 这一步可能失败
    except Exception as e:
        print(e)
    return state

错误用法

def node_a(state: State):
    # ❌ 错误用法,用 try/catch 包裹 interrupt(),这将会导致 GraphInterrupt 被捕获而无法触发中断
    try:
        interrupt("What's your name?")
        fetch_data()  # 这一步可能失败
    except Exception as e:
        print(e)
    return state
8.1.5.2. 不要更改单个节点内interrupt的调用顺序

上文提到:

  1. 恢复运行时整个节点函数都会重新运行,而非精确地从断点继续
  2. 同一节点中存在多个断点时:历史恢复记录被记录在检查点中,并在恢复运行时按顺序加载

一旦中断恢复前后的断点顺序、数量不完全一致,将会导致语义混乱,程序执行结果无法满足预期。

这条规范就是为了避免这样的情况。

正确用法

def node_a(state: State):
    # ✅ 正确用法,断点顺序固定
    name = interrupt("What's your name?")
    age = interrupt("What's your age?")
    city = interrupt("What's your city?")

    return {
        "name": name,
        "age": age,
        "city": city
    }

错误用法

1. 可能跳过其中部分断点

def node_a(state: State):
    # ❌ 错误用法,以状态为条件,可能跳过部分断点
    name = interrupt("What's your name?")

    # 第一次运行到此处时,可能不会触发分支内的断点
    # 恢复时,resume 本应作为 city,但如果分支内断点触发,将被错误地作为 age,导致语义混乱
    if state.get("needs_age"):
        age = interrupt("What's your age?")

    city = interrupt("What's your city?")

    return {"name": name, "city": city}

断点以状态为条件,如果中断恢复前后状态发生了变化,断点的顺序和数量可能发生变化,导致语义混乱。

2. 基于不确定的数据循环,并在其中设置断点

def node_a(state: State):
    # ❌ 错误用法:基于不确定的数据循环
    # 每次调用时的断点数量可能不同
    results = []
    for item in state.get("dynamic_list", []):  # List might change between runs
        result = interrupt(f"Approve {item}?")
        results.append(result)

    return {"results": results}

基于状态中可变的值循环,并在循环中设置断点,如果中断恢复前后状态发生变化,断点数量可能变化,导致语义混乱。

8.1.5.3. 不要在interrupt()中传递复杂类型

LangGraph 运行时会将 interrupt() 函数接收到的参数经过 JSON 序列化 之后传递给调用者。

如果传递不支持 JSON 序列化 的复杂类型,如函数,将会抛出异常,如下所示

TypeError: Type is not msgpack serializable: Interrupt

正确用法

1. 简单类型

def node_a(state: State):
    # ✅ 正确用法:传递可以序列化的简单类型数据
    name = interrupt("What's your name?")
    count = interrupt(42)
    approved = interrupt(True)

    return {"name": name, "count": count, "approved": approved}

2. 结构化数据

def node_a(state: State):
    # ✅ 正确用法:传递仅包含基本数据类型的可序列化字典
    response = interrupt({
        "question": "Enter user details",
        "fields": ["name", "email", "age"],
        "current_values": state.get("user", {})
    })

    return {"user": response}

错误用法

1. 函数

def validate_input(value):
    return len(value) > 0

def node_a(state: State):
    # ❌ 错误用法:传递函数
    # 函数不支持 JSON 序列化
    response = interrupt({
        "question": "What's your name?",
        "validator": validate_input  # 此处会导致失败
    })
    return {"name": response}

2. 类的实例

class DataProcessor:
    def __init__(self, config):
        self.config = config

def node_a(state: State):
    processor = DataProcessor({"mode": "strict"})

    # ❌ 错误用法:传递类的实例
    # 此处的自定义实例不支持 JSON 序列化
    response = interrupt({
        "question": "Enter data to process",
        "processor": processor  # 这将会导致失败
    })
    return {"result": response}
8.1.5.4. 断点之前的副作用操作必须是幂等的
  • 副作用操作:修改了外部环境的操作,如
    • 写数据库
    • 写文件
    • 发送邮件或消息
  • 幂等性:多次执行的结果等价于一次执行。

我们知道,中断恢复时,断点所在的函数会被重复执行,所以,如果断点之前存在不满足幂等性的副作用操作,将会导致多次调用结果不一致。

如:写数据操作不满足幂等性,可能导致写入多条重复记录。

正确用法

1. 在断点之前使用满足幂等性的操作

def node_a(state: State):
    # ✅ 正确用法:使用幂等的 upsert 操作
    # 多次运行结果一致
    db.upsert_user(
        user_id=state["user_id"],
        status="pending_approval"
    )

    approved = interrupt("Approve this change?")

    return {"approved": approved}

upsert 的语义是:写入或更新,如果有相同记录则覆盖更新,否则新增。满足幂等性。

2. 将副作用操作放在断点之后

def node_a(state: State):
    # ✅ 正确用法:将副作用操作放在断点之后
    # 确保副作用操作只运行一次
    approved = interrupt("Approve this change?")

    if approved:
        db.create_audit_log(
            user_id=state["user_id"],
            action="approved"
        )

    return {"approved": approved}

上述代码将副作用操作置于断点之后,之后中断触发并恢复运行之后,副作用操作才会被执行,后者只会被执行一次。

3. 将副作用操作和断点置于不同的节点

def approval_node(state: State):
    # ✅ 正确用法:当前节点只做中断
    approved = interrupt("Approve this change?")

    return {"approved": approved}

def notification_node(state: State):
    # ✅ 正确用法:副作用操作发生在独立的节点中
    # 当前节点独立于中断,所以副作用操作,不会因为中断恢复而重复执行
    if (state.approved):
        send_notification(
            user_id=state["user_id"],
            status="approved"
        )

    return state

这种方式更彻底,将中断和副作用操作完全隔离。

错误用法

1. 在断点之前向数据库中新增数据

def node_a(state: State):
    # ❌ 错误用法:在断点之前向数据库中新增数据
    # 中断恢复会导致数据重复
    audit_id = db.create_audit_log({
        "user_id": state["user_id"],
        "action": "pending_approval",
        "timestamp": datetime.now()
    })

    approved = interrupt("Approve this change?")

    return {"approved": approved, "audit_id": audit_id}

2. 将数据追加到已存在的列表

def node_a(state: State):
    # ❌ 错误用法:在断点之前向列表追加数据
    # 这将会在中断恢复时导致列表数据重复
    db.append_to_history(state["user_id"], "approval_requested")

    approved = interrupt("Approve this change?")

    return {"approved": approved}

8.1.6. 中断触发和恢复前后的检查点

8.1.6.1. 基础HITL模式
8.1.6.1.1. 示例代码
from typing import TypedDict

from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langgraph.checkpoint.memory import InMemorySaver

class OverAllState(TypedDict):
    username: str
    age: int

def node_a(state: OverAllState) -> OverAllState:
    username = interrupt("请输入您的姓名")
    age = interrupt("请输入您的年龄")
    return {
        "username": username,
        "age": age
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "123"}}
username_interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> username_interrupted_res <-', '=' * 30)
print(username_interrupted_res)
username_interrupted_his = list(graph.get_state_history(config=config))
print('=' * 30, '-> username_interrupted_his <-', '=' * 30)
print(username_interrupted_his)

age_interrupted_res = graph.invoke(Command(resume="小黄"), config=config)
print('=' * 30, '-> age_interrupted_res <-', '=' * 30)
print(age_interrupted_res)
age_interrupted_his = list(graph.get_state_history(config=config))
print('=' * 30, '-> age_interrupted_his <-', '=' * 30)
print(age_interrupted_his)

resumed_res = graph.invoke(Command(resume=123), config=config)
print('=' * 30, '-> resumed_res <-', '=' * 30)
print(resumed_res)
resumed_his = list(graph.get_state_history(config=config))
print('=' * 30, '-> resumed_his <-', '=' * 30)
print(resumed_his)
8.1.6.1.2. 运行结果分析
8.1.6.1.2.1. 第一次中断

调用结果

============================== -> username_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的姓名', id='488d1fa95a4053d41ab09786f5260ae9')]}

历史检查点

============================== -> username_interrupted_his <- ==============================
[
    StateSnapshot(
        values={},
        next=('node_a',),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4aa0-631a-8000-12a45e5b02a0'
            }
        },
        metadata={
            'source': 'loop',
            'step': 0,
            'parents': {}
        },
        created_at='2026-06-17T08:19:59.195818+00:00',
        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4a9d-68e8-bfff-3573d795ed80'
            }
        },
        tasks=(
            PregelTask(
                id='a7e05e86-30c1-a590-a7bf-e3542eb53d4b',
                name='node_a',
                path=('__pregel_pull', 'node_a'),
                error=None,
                interrupts=(
                    Interrupt(
                        value='请输入您的姓名',
                        id='488d1fa95a4053d41ab09786f5260ae9'
                    ),
                ),
                state=None,
                result=None
            ),
        ),
        interrupts=(
            Interrupt(
                value='请输入您的姓名',
                id='488d1fa95a4053d41ab09786f5260ae9'
            ),
        )
    ),

    StateSnapshot(
        values={},
        next=('__start__',),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4a9d-68e8-bfff-3573d795ed80'
            }
        },
        metadata={
            'source': 'input',
            'step': -1,
            'parents': {}
        },
        created_at='2026-06-17T08:19:59.194743+00:00',
        parent_config=None,
        tasks=(
            PregelTask(
                id='173d6621-cd7b-3edd-a3d5-8d1c3bb8dc53',
                name='__start__',
                path=('__pregel_pull', '__start__'),
                error=None,
                interrupts=(),
                state=None,
                result={}
            ),
        ),
        interrupts=()
    )
]

当前工作流只有一个节点,所以只有一个真正执行用户自定义节点的超步,对应的超步编号为 1

中断触发时,检查点存储器中记录的最新检查点超步为 0,中断信息只能和这个超步绑定

中断信息存了两份:

  • StateSnapshot 的 tasks 属性下记录的 PregelTask 实例的 interrupts 字段记录了当前任务触发的中断信息。

  • StateSnapshot 的 interrupts 属性记录了当前超步发生的所有中断

当存在多个并行中断时

  • 中断信息被记录在各自所属任务的 PregelTask 实例中
  • 同时也会被汇总在 StateSnapshot 的 tasks 属性中
8.1.6.1.2.2. 第一次恢复

调用结果

============================== -> age_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的年龄', id='488d1fa95a4053d41ab09786f5260ae9')]}

历史检查点

============================== -> age_interrupted_his <- ==============================

[
    StateSnapshot(
        values={},
        next=('node_a',),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4aa0-631a-8000-12a45e5b02a0'
            }
        },
        metadata={
            'source': 'loop',
            'step': 0,
            'parents': {}
        },
        created_at='2026-06-17T08:19:59.195818+00:00',
        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4a9d-68e8-bfff-3573d795ed80'
            }
        },
        tasks=(
            PregelTask(
                id='a7e05e86-30c1-a590-a7bf-e3542eb53d4b',
                name='node_a',
                path=('__pregel_pull', 'node_a'),
                error=None,
                interrupts=(
                    Interrupt(
                        value='请输入您的年龄',
                        id='488d1fa95a4053d41ab09786f5260ae9'
                    ),
                ),
                state=None,
                result={}
            ),
        ),
        interrupts=(
            Interrupt(
                value='请输入您的年龄',
                id='488d1fa95a4053d41ab09786f5260ae9'
            ),
        )
    ),

    StateSnapshot(
        values={},
        next=('__start__',),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4a9d-68e8-bfff-3573d795ed80'
            }
        },
        metadata={
            'source': 'input',
            'step': -1,
            'parents': {}
        },
        created_at='2026-06-17T08:19:59.194743+00:00',
        parent_config=None,
        tasks=(
            PregelTask(
                id='173d6621-cd7b-3edd-a3d5-8d1c3bb8dc53',
                name='__start__',
                path=('__pregel_pull', '__start__'),
                error=None,
                interrupts=(),
                state=None,
                result={}
            ),
        ),
        interrupts=()
    )
]

checkpoint_id、任务id、中断id 和历史检查点完全一致,只是 Interrupt 实例的 value 属性变成了第二次中断的值。

由此可见,恢复运行时检查点的更新确实是覆盖写入,历史中断信息不会被保留。

并且,中断恢复时传递的 resume 在 graph.get_state_history() 时也不可见。

8.1.6.1.2.3. 第二次恢复

调用结果

============================== -> resumed_res <- ==============================
{'username': '小黄', 'age': 123}

历史检查点

============================== -> resumed_his <- ==============================

[
    StateSnapshot(
        values={
            'username': '小黄',
            'age': 123
        },
        next=(),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4ab5-60bc-8001-df8746dc9400'
            }
        },
        metadata={
            'source': 'loop',
            'step': 1,
            'parents': {}
        },
        created_at='2026-06-17T08:19:59.204348+00:00',
        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4aa0-631a-8000-12a45e5b02a0'
            }
        },
        tasks=(),
        interrupts=()
    ),

    StateSnapshot(
        values={},
        next=('node_a',),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4aa0-631a-8000-12a45e5b02a0'
            }
        },
        metadata={
            'source': 'loop',
            'step': 0,
            'parents': {}
        },
        created_at='2026-06-17T08:19:59.195818+00:00',
        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4a9d-68e8-bfff-3573d795ed80'
            }
        },
        tasks=(
            PregelTask(
                id='a7e05e86-30c1-a590-a7bf-e3542eb53d4b',
                name='node_a',
                path=('__pregel_pull', 'node_a'),
                error=None,
                interrupts=(
                    Interrupt(
                        value='请输入您的年龄',
                        id='488d1fa95a4053d41ab09786f5260ae9'
                    ),
                ),
                state=None,
                result={
                    'username': '小黄',
                    'age': 123
                }
            ),
        ),
        interrupts=(
            Interrupt(
                value='请输入您的年龄',
                id='488d1fa95a4053d41ab09786f5260ae9'
            ),
        )
    ),

    StateSnapshot(
        values={},
        next=('__start__',),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a255-4a9d-68e8-bfff-3573d795ed80'
            }
        },
        metadata={
            'source': 'input',
            'step': -1,
            'parents': {}
        },
        created_at='2026-06-17T08:19:59.194743+00:00',
        parent_config=None,
        tasks=(
            PregelTask(
                id='173d6621-cd7b-3edd-a3d5-8d1c3bb8dc53',
                name='__start__',
                path=('__pregel_pull', '__start__'),
                error=None,
                interrupts=(),
                state=None,
                result={}
            ),
        ),
        interrupts=()
    )
]

两个断点全部恢复运行后,编号为 1 的超步终于完成,任务实例的 result 被更新,状态被更新,最后一个超步的 values 是更新后的状态值,计算图运行完成。

8.1.6.2. 多个并行中断触发时的检查点
8.1.6.2.1. 示例代码
from typing import TypedDict

from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langgraph.checkpoint.memory import InMemorySaver

class OverAllState(TypedDict):
    username: str
    age: int

def node_a(state: OverAllState) -> OverAllState:
    username = interrupt("请输入您的姓名")
    return {
        "username": username
    }

def node_b(state: OverAllState) -> OverAllState:
    age = interrupt("请输入您的年龄")
    return {
        "age": age
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

from IPython.display import display
display(graph)

config = {"configurable": {"thread_id": "123"}}
interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
print(interrupted_res)

list(graph.get_state_history(config=config))

# 6. 恢复执行
resume_map = {}
for i in interrupted_res['__interrupt__']:
    user_input = input(f"{i.value}:")
    if "年龄" in i.value:
        resume_map[i.id] = int(user_input)
    else:
        resume_map[i.id] = user_input

resumed_res = graph.invoke(Command(resume=resume_map),config=config)
print(resumed_res)

list(graph.get_state_history(config=config))
8.1.6.2.2. 结果分析

计算图返回值

============================== -> interrupt_res <- ==============================

{
    '__interrupt__': [
        Interrupt(
            value='请输入您的姓名',
            id='0cae1932a74087f02711c6f1677b4b17'
        ),
        Interrupt(
            value='请输入您的年龄',
            id='cd216e1ad6907c2432636ff5f32cfcf3'
        )
    ]
}

同一个超步中并行触发的中断信息被放在列表中返回。

历史检查点列表

[
    StateSnapshot(
        values={},

        next=(
            'node_a',
            'node_b'
        ),

        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a329-8b50-69e0-8000-7bdce1ba7379'
            }
        },

        metadata={
            'source': 'loop',
            'step': 0,
            'parents': {}
        },

        created_at='2026-06-17T09:54:56.810630+00:00',

        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a329-8b4e-6085-bfff-3100dea319cc'
            }
        },

        tasks=(
            PregelTask(
                id='689d8040-4b34-02a2-eb32-d901bbb3c67c',
                name='node_a',
                path=(
                    '__pregel_pull',
                    'node_a'
                ),
                error=None,
                interrupts=(
                    Interrupt(
                        value='请输入您的姓名',
                        id='0cae1932a74087f02711c6f1677b4b17'
                    ),
                ),
                state=None,
                result=None
            ),

            PregelTask(
                id='034cbe3c-e0a6-025a-ce03-46436ce38fc3',
                name='node_b',
                path=(
                    '__pregel_pull',
                    'node_b'
                ),
                error=None,
                interrupts=(
                    Interrupt(
                        value='请输入您的年龄',
                        id='cd216e1ad6907c2432636ff5f32cfcf3'
                    ),
                ),
                state=None,
                result=None
            )
        ),

        interrupts=(
            Interrupt(
                value='请输入您的姓名',
                id='0cae1932a74087f02711c6f1677b4b17'
            ),
            Interrupt(
                value='请输入您的年龄',
                id='cd216e1ad6907c2432636ff5f32cfcf3'
            )
        )
    ),


    StateSnapshot(
        values={},

        next=(
            '__start__',
        ),

        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a329-8b4e-6085-bfff-3100dea319cc'
            }
        },

        metadata={
            'source': 'input',
            'step': -1,
            'parents': {}
        },

        created_at='2026-06-17T09:54:56.809574+00:00',

        parent_config=None,

        tasks=(
            PregelTask(
                id='3df213dc-5fa0-a8aa-b1dc-428da9010be6',
                name='__start__',
                path=(
                    '__pregel_pull',
                    '__start__'
                ),
                error=None,
                interrupts=(),
                state=None,
                result={}
            ),
        ),

        interrupts=()
    )
]

如前所述,编号为 1 的超步执行时中断触发,此时检查点后端中存储的最新的超步编号为 0,因此中断信息记录在超步 0 的 StateSnapshot 实例中。

  • 快照实例的 interrupts 属性记录了并行触发的两个中断的信息。

  • 并且,各自的中断信息被记录在对应的任务实例中。

8.1.6.3. 同一超步部分任务触发中断时的检查点
8.1.6.3.1. 示例代码
from typing import TypedDict

from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langgraph.checkpoint.memory import InMemorySaver

class OverAllState(TypedDict):
    username: str
    age: int

def node_a(state: OverAllState) -> OverAllState:
    username = interrupt("请输入您的姓名")
    return {
        "username": username
    }

def node_b(state: OverAllState) -> OverAllState:
    age = 12
    return {
        "age": age
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

from IPython.display import display
display(graph)

config = {"configurable": {"thread_id": "123"}}
interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
print(interrupted_res)

list(graph.get_state_history(config=config))
8.1.6.3.2. 结果分析

计算图返回值

============================== -> interrupt_res <- ==============================

{
    'age': 12,
    '__interrupt__': [
        Interrupt(
            value='请输入您的姓名',
            id='bbff88193340fd0ed196be702608e385'
        )
    ]
}

历史检查点列表

============================== -> state_history <- ==============================

[
    StateSnapshot(
        values={},
        next=(
            'node_a',
            'node_b'
        ),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a32c-b47b-6ca9-8000-79ee3c65e7ce'
            }
        },
        metadata={
            'source': 'loop',
            'step': 0,
            'parents': {}
        },
        created_at='2026-06-17T09:56:21.658113+00:00',
        parent_config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a32c-b479-6766-bfff-901ed37f684d'
            }
        },
        tasks=(
            PregelTask(
                id='e16690fc-c0f8-e9fc-e30d-706a64f24209',
                name='node_a',
                path=(
                    '__pregel_pull',
                    'node_a'
                ),
                error=None,
                interrupts=(
                    Interrupt(
                        value='请输入您的姓名',
                        id='bbff88193340fd0ed196be702608e385'
                    ),
                ),
                state=None,
                result=None
            ),
            PregelTask(
                id='b628264b-d4ca-c09f-5197-6eb1c953b947',
                name='node_b',
                path=(
                    '__pregel_pull',
                    'node_b'
                ),
                error=None,
                interrupts=(),
                state=None,
                result={
                    'age': 12
                }
            )
        ),
        interrupts=(
            Interrupt(
                value='请输入您的姓名',
                id='bbff88193340fd0ed196be702608e385'
            ),
        )
    ),

    StateSnapshot(
        values={},
        next=(
            '__start__',
        ),
        config={
            'configurable': {
                'thread_id': '123',
                'checkpoint_ns': '',
                'checkpoint_id': '1f16a32c-b479-6766-bfff-901ed37f684d'
            }
        },
        metadata={
            'source': 'input',
            'step': -1,
            'parents': {}
        },
        created_at='2026-06-17T09:56:21.657164+00:00',
        parent_config=None,
        tasks=(
            PregelTask(
                id='5dc4ad26-1baa-7c18-2580-153a19d4c175',
                name='__start__',
                path=(
                    '__pregel_pull',
                    '__start__'
                ),
                error=None,
                interrupts=(),
                state=None,
                result={}
            ),
        ),
        interrupts=()
    )
]

由上可知,同一个超步中:

  • 发生中断的任务,其中断信息被记录在任务实例和快照的 interrupts 字段中

  • 未触发中断的任务正常结束,其结果被记录在任务实例的 result 字段中

底层负责执行节点逻辑的是超步的第二阶段,如果存在多个并行任务,则会将任务提交到线程池,在独立的线程中运行,负责执行第二阶段的 PregelRunner 实例会收集提交后的 future 对象,并等待所有任务完成。

当中断触发时,对应的线程会抛出 GraphInterrupt,对应的 future 对象会收集异常。

PregelRunner 实例在 future 对应的任务完成后,收集中断信息或任务执行结果,写入检查点存储器。

所以一个超步中某个任务触发中断,不会导致其它并行任务的终止,正常运行的任务结果、中断任务的中断信息都被记录在检查点后端。

8.2. 静态断点

LangGraph 提供了用于调试的 静态断点

它是在状态图编译或调用时设置的,不会在运行时动态触发,不属于业务逻辑,所以称为 静态 断点。

动态断点提供了接收人类反馈的接口,而静态断点只是暂停计算图的运行,不能向调用者传递信息,也不能接收调用者的反馈。

8.2.1. 用法说明

  1. 静态断点也需要基于检查点恢复,所以必须配置检查点,启用可恢复运行机制。

  2. 计算图会在 interrupt_before 指定的节点执行之前产生中断,暂停计算

  3. 计算图会在 interrupt_after 指定的节点执行之后产生中断,暂停计算

  4. 计算图运行到断点位置会中断,和动态断点不同的是,它会在超步边界而非内部中断。

    断点前的超步:三个阶段全部完成;断点后的超步:三个阶段都没有开始。

  5. 静态断点不会返回任何中断信息,只会将当前最新的状态返回。

    因此,我们可以用静态断点查看每个超步边界的中间状态。

  6. 传入相同的配置并将 None 作为输入,再次调用计算图,会从断点位置继续运行。

  7. 支持在两个阶段设置静态断点

    • 状态图编译时

      graph = builder.compile(
          checkpointer=checkpointer,
          interrupt_before=["node_a", "node_b"],
          interrupt_after=["node_a", "node_b"]
      )
    • 计算图调用时

      first_res = graph.invoke(
          {},
          config=config,
          interrupt_before=["node_a", "node_b"],
          interrupt_after=["node_a", "node_b"]
      )

    断点都是在运行时生效,计算图调用时设置的断点优先级更高。

    如果调用时传入的断点列表不为空,则会覆盖编译时配置。

8.2.2. 底层原理

  1. 编译时设置的静态断点保存在编译图的 interrupt_before_nodes 和 interrupt_after_nodes 中。

    运行时采用调用参数优先、编译配置兜底的规则。

    调用时传入非空节点列表会完全覆盖编译配置,而 None 或空列表会回退到编译配置。

  2. interrupt_before 在超步的第一阶段:当前超步的任务列表计算完成后、节点任务开始执行前进行检查。

    • 如果检查点中的状态和上一次静态断点或图的初始状态相比,产生了变化
    • 并且当前超步的任务列表和 interrupt_before 列表存在交集

    则抛出 GraphInterrupt

  3. interrupt_after 在超步的第三阶段:当前超步的任务全部执行完成、写入应用到通道并创建检查点之后进行检查。

    • 如果检查点中的状态和上一次静态断点或图的初始状态相比,产生了变化
    • 并且当前超步的任务列表和 interrupt_after 列表存在交集

    则抛出 GraphInterrupt

  4. 如果存在这样的拓扑图

    node_a -> node_b

    那么在 node_a 之后 和 node_b 之前 设置断点只会中断一次。

    因为二者对应同一个超步边界,如果同时配置:

    • node_a 的 after 先触发;

    • 恢复运行时检查点中记录的上次断点可见状态会被更新,和最新状态保持一致

    • 而产生中断之前已经保存了检查点,恢复运行时会从 node_b 的超步开始

    • node_b 的超步第一阶段,检查点和上次静态断点相比,没有变化,所以不会中断

    • 因此,node_a 之后 和 node_b 之前 设置断点只会中断一次

8.2.3. 示例

8.2.3.1. 编译时设置断点
8.2.3.1.1. 顺序结构设置断点

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver

from loguru import logger

class OverAllState(TypedDict):
    final_res: str

def node_a(state: OverAllState) -> OverAllState:
    logger.info("node_a 被执行")
    return {
        "final_res": "node_a 运行的中间结果"
    }

def node_b(state: OverAllState) -> OverAllState:
    logger.info("node_b 被执行")
    return {
        "final_res": "node_b 运行的中间结果"
    }

def node_c(state: OverAllState) -> OverAllState:
    logger.info("node_c 被执行")
    return {
        "final_res": "node_c 运行的中间结果"
    }


builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)

builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", "node_c")
builder.add_edge("node_c", END)

checkpointer = InMemorySaver()
graph = builder.compile(
    checkpointer=checkpointer,
    interrupt_before=["node_a", "node_b"],
    interrupt_after=["node_a", "node_b"]
)

from IPython.display import display
display(graph)

config = {"configurable": {"thread_id": "123"}}

logger.info("{}-> 第一次执行 <-{}", "=" * 10, "=" * 10)
first_res = graph.invoke({}, config=config)
logger.info("第一次执行结果:{}", first_res)

logger.info("{}-> 第二次执行 <-{}", "=" * 10, "=" * 10)
second_res = graph.invoke(None, config=config)
logger.info("第二次执行结果:{}", second_res)

logger.info("{}-> 第三次执行 <-{}", "=" * 10, "=" * 10)
third_res = graph.invoke(None, config=config)
logger.info("第三次执行结果:{}", third_res)

logger.info("{}-> 第四次执行 <-{}", "=" * 10, "=" * 10)
final_res = graph.invoke(None, config=config)
logger.info("第四次执行结果:{}", final_res)

logger.info("{}-> 完毕 <-{}", "=" * 10, "=" * 10)

运行结果如下

编译时设置的断点在渲染拓扑结构时被感知,图中对应结点新增了断点标记:

__interrupt = before,after

这表示该节点之前和之后都设置了断点。

日志如下

2026-06-18 11:22:52.094 | INFO     | __main__:<module>:51 - ==========-> 第一次执行 <-==========
2026-06-18 11:22:52.100 | INFO     | __main__:<module>:53 - 第一次执行结果:None
2026-06-18 11:22:52.100 | INFO     | __main__:<module>:55 - ==========-> 第二次执行 <-==========
2026-06-18 11:22:52.102 | INFO     | __main__:node_a:11 - node_a 被执行
2026-06-18 11:22:52.104 | INFO     | __main__:<module>:57 - 第二次执行结果:{'final_res': 'node_a 运行的中间结果'}
2026-06-18 11:22:52.104 | INFO     | __main__:<module>:59 - ==========-> 第三次执行 <-==========
2026-06-18 11:22:52.105 | INFO     | __main__:node_b:17 - node_b 被执行
2026-06-18 11:22:52.107 | INFO     | __main__:<module>:61 - 第三次执行结果:{'final_res': 'node_b 运行的中间结果'}
2026-06-18 11:22:52.107 | INFO     | __main__:<module>:63 - ==========-> 第四次执行 <-==========
2026-06-18 11:22:52.108 | INFO     | __main__:node_c:23 - node_c 被执行
2026-06-18 11:22:52.110 | INFO     | __main__:<module>:65 - 第四次执行结果:{'final_res': 'node_c 运行的中间结果'}
2026-06-18 11:22:52.111 | INFO     | __main__:<module>:67 - ==========-> 完毕 <-==========

由日志可知,node_a 和 node_b 之间只产生了一次中断,和分析相符。

8.2.3.1.2. 存在并行节点时设置断点

为了确定断点是 以节点 为边界还是 以超步 为边界设置的,我们在存在并行节点的计算图中测试

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver

from loguru import logger

class OverAllState(TypedDict):
    final_res: str

def node_a(state: OverAllState) -> OverAllState:
    logger.info("node_a 被执行")
    return {
        "final_res": "node_a 运行的中间结果"
    }

def node_b(state: OverAllState) -> OverAllState:
    logger.info("node_b 被执行")
    return {
        "final_res": "node_b 运行的中间结果"
    }

def node_c(state: OverAllState) -> OverAllState:
    logger.info("node_c 被执行")
    return {
        "final_res": "node_c 运行的中间结果"
    }

def node_d(state: OverAllState) -> OverAllState:
    logger.info("node_d 被执行")
    return {}

def node_e(state: OverAllState) -> OverAllState:
    logger.info("node_e 被执行")
    return {}

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_node("node_d", node_d)
builder.add_node("node_e", node_e)

builder.add_edge(START, "node_a")
builder.add_edge(START, "node_d")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_d", "node_e")
builder.add_edge(["node_b", "node_e"], "node_c")
builder.add_edge("node_c", END)

checkpointer = InMemorySaver()
graph = builder.compile(
    checkpointer=checkpointer,
    interrupt_before=["node_a", "node_b"],
    interrupt_after=["node_a", "node_b"]
)

from IPython.display import display
display(graph)

config = {"configurable": {"thread_id": "123"}}

logger.info("{}-> 第一次执行 <-{}", "=" * 10, "=" * 10)
first_res = graph.invoke({}, config=config)
logger.info("第一次执行结果:{}", first_res)

logger.info("{}-> 第二次执行 <-{}", "=" * 10, "=" * 10)
second_res = graph.invoke(None, config=config)
logger.info("第二次执行结果:{}", second_res)

logger.info("{}-> 第三次执行 <-{}", "=" * 10, "=" * 10)
third_res = graph.invoke(None, config=config)
logger.info("第三次执行结果:{}", third_res)

logger.info("{}-> 第四次执行 <-{}", "=" * 10, "=" * 10)
final_res = graph.invoke(None, config=config)
logger.info("第四次执行结果:{}", final_res)

logger.info("{}-> 完毕 <-{}", "=" * 10, "=" * 10)

运行结果如下

2026-06-18 11:28:49.920 | INFO     | __main__:<module>:62 - ==========-> 第一次执行 <-==========
2026-06-18 11:28:49.922 | INFO     | __main__:<module>:64 - 第一次执行结果:None
2026-06-18 11:28:49.923 | INFO     | __main__:<module>:66 - ==========-> 第二次执行 <-==========
2026-06-18 11:28:49.925 | INFO     | __main__:node_a:11 - node_a 被执行
2026-06-18 11:28:49.927 | INFO     | __main__:node_d:29 - node_d 被执行
2026-06-18 11:28:49.931 | INFO     | __main__:<module>:68 - 第二次执行结果:{'final_res': 'node_a 运行的中间结果'}
2026-06-18 11:28:49.931 | INFO     | __main__:<module>:70 - ==========-> 第三次执行 <-==========
2026-06-18 11:28:49.933 | INFO     | __main__:node_b:17 - node_b 被执行
2026-06-18 11:28:49.935 | INFO     | __main__:node_e:33 - node_e 被执行
2026-06-18 11:28:49.939 | INFO     | __main__:<module>:72 - 第三次执行结果:{'final_res': 'node_b 运行的中间结果'}
2026-06-18 11:28:49.940 | INFO     | __main__:<module>:74 - ==========-> 第四次执行 <-==========
2026-06-18 11:28:49.940 | INFO     | __main__:node_c:23 - node_c 被执行
2026-06-18 11:28:49.942 | INFO     | __main__:<module>:76 - 第四次执行结果:{'final_res': 'node_c 运行的中间结果'}
2026-06-18 11:28:49.943 | INFO     | __main__:<module>:78 - ==========-> 完毕 <-==========

node_a 和 node_d 属于同一个超步

node_b 和 node_e 属于同一个超步

我们只在 node_a 和 node_b 前后设置了超步,但它们的并行节点也被中断了。

显然,中断发生在超步边界,而非节点边界,与分析相符。

8.2.3.2. 调用时设置断点

调用时的断点配置只对本次调用生效。

8.2.3.2.1. 正确用法:恢复调用时设置相同的断点

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver

from loguru import logger

class OverAllState(TypedDict):
    final_res: str

def node_a(state: OverAllState) -> OverAllState:
    logger.info("node_a 被执行")
    return {
        "final_res": "node_a 运行的中间结果"
    }

def node_b(state: OverAllState) -> OverAllState:
    logger.info("node_b 被执行")
    return {
        "final_res": "node_b 运行的中间结果"
    }

def node_c(state: OverAllState) -> OverAllState:
    logger.info("node_c 被执行")
    return {
        "final_res": "node_c 运行的中间结果"
    }


builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)

builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", "node_c")
builder.add_edge("node_c", END)

checkpointer = InMemorySaver()
graph = builder.compile(
    checkpointer=checkpointer
)

from IPython.display import display
display(graph)

config = {"configurable": {"thread_id": "123"}}

logger.info("{}-> 第一次执行 <-{}", "=" * 10, "=" * 10)
first_res = graph.invoke(
    {},
    config=config,
    interrupt_before=["node_a", "node_b"],
    interrupt_after=["node_a", "node_b"]
)
logger.info("第一次执行结果:{}", first_res)

logger.info("{}-> 第二次执行 <-{}", "=" * 10, "=" * 10)
second_res = graph.invoke(
    None,
    config=config,
    interrupt_before=["node_a", "node_b"],
    interrupt_after=["node_a", "node_b"]
)
logger.info("第二次执行结果:{}", second_res)

logger.info("{}-> 第三次执行 <-{}", "=" * 10, "=" * 10)
third_res = graph.invoke(
    None,
    config=config,
    interrupt_before=["node_a", "node_b"],
    interrupt_after=["node_a", "node_b"]
)
logger.info("第三次执行结果:{}", third_res)

logger.info("{}-> 第四次执行 <-{}", "=" * 10, "=" * 10)
final_res = graph.invoke(
    None,
    config=config,
    interrupt_before=["node_a", "node_b"],
    interrupt_after=["node_a", "node_b"]
)
logger.info("第四次执行结果:{}", final_res)

logger.info("{}-> 完毕 <-{}", "=" * 10, "=" * 10)

运行结果如下

2026-06-18 11:32:55.457 | INFO     | __main__:<module>:49 - ==========-> 第一次执行 <-==========
2026-06-18 11:32:55.460 | INFO     | __main__:<module>:56 - 第一次执行结果:None
2026-06-18 11:32:55.460 | INFO     | __main__:<module>:58 - ==========-> 第二次执行 <-==========
2026-06-18 11:32:55.461 | INFO     | __main__:node_a:11 - node_a 被执行
2026-06-18 11:32:55.463 | INFO     | __main__:<module>:65 - 第二次执行结果:{'final_res': 'node_a 运行的中间结果'}
2026-06-18 11:32:55.463 | INFO     | __main__:<module>:67 - ==========-> 第三次执行 <-==========
2026-06-18 11:32:55.463 | INFO     | __main__:node_b:17 - node_b 被执行
2026-06-18 11:32:55.465 | INFO     | __main__:<module>:74 - 第三次执行结果:{'final_res': 'node_b 运行的中间结果'}
2026-06-18 11:32:55.466 | INFO     | __main__:<module>:76 - ==========-> 第四次执行 <-==========
2026-06-18 11:32:55.467 | INFO     | __main__:node_c:23 - node_c 被执行
2026-06-18 11:32:55.468 | INFO     | __main__:<module>:83 - 第四次执行结果:{'final_res': 'node_c 运行的中间结果'}
2026-06-18 11:32:55.468 | INFO     | __main__:<module>:85 - ==========-> 完毕 <-==========

我们期望在 node_a 和 node_b 前后中断,此时运行结果和预期相符。

8.2.3.2.2. 错误用法:恢复调用时不再设置断点

示例如下

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver

from loguru import logger

class OverAllState(TypedDict):
    final_res: str

def node_a(state: OverAllState) -> OverAllState:
    logger.info("node_a 被执行")
    return {
        "final_res": "node_a 运行的中间结果"
    }

def node_b(state: OverAllState) -> OverAllState:
    logger.info("node_b 被执行")
    return {
        "final_res": "node_b 运行的中间结果"
    }

def node_c(state: OverAllState) -> OverAllState:
    logger.info("node_c 被执行")
    return {
        "final_res": "node_c 运行的中间结果"
    }


builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)

builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", "node_c")
builder.add_edge("node_c", END)

checkpointer = InMemorySaver()
graph = builder.compile(
    checkpointer=checkpointer
)

from IPython.display import display
display(graph)

config = {"configurable": {"thread_id": "123"}}

logger.info("{}-> 第一次执行 <-{}", "=" * 10, "=" * 10)
first_res = graph.invoke(
    {},
    config=config,
    interrupt_before=["node_a", "node_b"],
    interrupt_after=["node_a", "node_b"]
)
logger.info("第一次执行结果:{}", first_res)

logger.info("{}-> 第二次执行 <-{}", "=" * 10, "=" * 10)
second_res = graph.invoke(
    None,
    config=config,
    interrupt_before=["node_a", "node_b"],
    interrupt_after=["node_a", "node_b"]
)
logger.info("第二次执行结果:{}", second_res)

logger.info("{}-> 第三次执行 <-{}", "=" * 10, "=" * 10)
third_res = graph.invoke(None, config=config)
logger.info("第三次执行结果:{}", third_res)

logger.info("{}-> 第四次执行 <-{}", "=" * 10, "=" * 10)
final_res = graph.invoke(None, config=config)
logger.info("第四次执行结果:{}", final_res)

logger.info("{}-> 完毕 <-{}", "=" * 10, "=" * 10)

运行结果如下

2026-06-18 11:35:16.236 | INFO     | __main__:<module>:49 - ==========-> 第一次执行 <-==========
2026-06-18 11:35:16.259 | INFO     | __main__:<module>:56 - 第一次执行结果:None
2026-06-18 11:35:16.260 | INFO     | __main__:<module>:58 - ==========-> 第二次执行 <-==========
2026-06-18 11:35:16.261 | INFO     | __main__:node_a:11 - node_a 被执行
2026-06-18 11:35:16.263 | INFO     | __main__:<module>:65 - 第二次执行结果:{'final_res': 'node_a 运行的中间结果'}
2026-06-18 11:35:16.263 | INFO     | __main__:<module>:67 - ==========-> 第三次执行 <-==========
2026-06-18 11:35:16.264 | INFO     | __main__:node_b:17 - node_b 被执行
2026-06-18 11:35:16.265 | INFO     | __main__:node_c:23 - node_c 被执行
2026-06-18 11:35:16.266 | INFO     | __main__:<module>:69 - 第三次执行结果:{'final_res': 'node_c 运行的中间结果'}
2026-06-18 11:35:16.267 | INFO     | __main__:<module>:71 - ==========-> 第四次执行 <-==========
2026-06-18 11:35:16.267 | INFO     | __main__:<module>:73 - 第四次执行结果:{'final_res': 'node_c 运行的中间结果'}
2026-06-18 11:35:16.268 | INFO     | __main__:<module>:75 - ==========-> 完毕 <-==========

本例中,我们只在初次调用和第一次恢复运行时设置断点。

由日志可知,按照期望,第二次恢复运行之后,node_b 执行完成后还应中断一次,然而,因为本次没有设置断点,因此计算图不再中断,node_b 和 node_c 都在本次调用完成,计算图结束。

在第四次调用时,计算图已经运行完毕,恢复运行实际上不会执行任何逻辑,只是将计算图的最终状态返回。

结果如下

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

kiss strong

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值