- 培歌行学LangGraph(1):从 LangChain 到 LangGraph 的思维跃迁]
- 培歌行学LangGraph(2):Reducer(归约器)彻底搞懂
- 培歌行学LangGraph(3):编译、执行与可视化
- 培歌行学LangGraph(4):一文搞懂图的状态(State)管理
- 培歌行学LangGraph(5):状态管理与
graph.invoke入参深度解析 - 培歌行学LangGraph(6):Multi Schema多状态管理详解
- 培歌行学LangGraph(7):预定义状态MessagesState与AgentState
- 培歌行学LangGraph(8):控制流详解
- 培歌行学LangGraph(9):控制流详解:defer延迟节点——让收尾工作自动排到最后
- 培歌行学LangGraph(10):多分支汇聚Fan-in——多个分支如何汇聚到一起?
- 培歌行学LangGraph(11):用循环结构实现 ReAct Agent
- 培歌行学LangGraph(12):递归限制——别让你的图无限跑下去
- 培歌行学LangGraph(13):节点执行与容错机制
- 培歌行学LangGraph(14):启用可恢复执行(Checkpoint)
- 培歌行学LangGraph(15):持久化模式——何时保存检查点?
- 培歌行学LangGraph(16):持久化模式——查看历史检查点——你的图走过的每一步都有记录
- 培歌行学LangGraph(17):检查点的四大使用场景
- 培歌行学LangGraph(18):图记忆管理-让你的 AI 记住该记的事
- 培歌行学LangGraph(19):动态中断-让图停下来等你
一、什么是静态断点?
静态断点就是在编译图或调用图的时候,提前告诉 LangGraph:"在这个节点之前/之后停下来,让我看看状态长什么样。"
跟动态中断的区别:
| 特性 | 动态中断(interrupt) | 静态断点 |
|---|---|---|
| 触发时机 | 运行时,节点内部调用 | 编译/调用时预设 |
| 能否传信息给调用者 | ✅ 能 | ❌ 不能 |
| 能否接收用户反馈 | ✅ 能(resume) | ❌ 不能 |
| 用途 | 人机交互(HITL) | 调试、查看中间状态 |
静态断点说白了就是: 在每个节点门口设个"关卡",到了就停,让你看看当前状态,然后你喊"继续"它就继续走。
二、怎么用?
2.1 在哪设置?
两种方式:
方式一:编译时设置(全局生效)
graph = builder.compile(
checkpointer=checkpointer,
interrupt_before=["node_a", "node_b"], # 这两个节点之前停
interrupt_after=["node_a", "node_b"] # 这两个节点之后停
)
方式二:调用时设置(仅本次生效)
res = graph.invoke(
{},
config=config,
interrupt_before=["node_a"],
interrupt_after=["node_b"]
)
调用时设置会覆盖编译时的设置。
2.2 怎么恢复?
跟动态中断一样,传 None作为输入,同一个 thread_id:
res = graph.invoke(None, config=config) # 继续跑
2.3 返回什么?
静态断点不会返回 __interrupt__信息,它只是把当前状态返回给你。
# 第一次调用:停在 node_a 之前
res = graph.invoke({}, config=config)
# res = None (还没执行任何节点)
# 第二次调用:执行 node_a,停在 node_b 之前
res = graph.invoke(None, config=config)
# res = {'final_res': 'node_a 运行的中间结果'}
三、完整示例
3.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"], # node_a和node_b之前停
interrupt_after=["node_a", "node_b"] # node_a和node_b之后停
)
config = {"configurable": {"thread_id": "123"}}
# 第1次:还没开始,停在 node_a 之前
logger.info("=== 第1次调用 ===")
res = graph.invoke({}, config=config)
logger.info("结果: {}", res)
# 第2次:执行 node_a,停在 node_b 之前
logger.info("=== 第2次调用 ===")
res = graph.invoke(None, config=config)
logger.info("结果: {}", res)
# 第3次:执行 node_b,停在 node_c 之前
logger.info("=== 第3次调用 ===")
res = graph.invoke(None, config=config)
logger.info("结果: {}", res)
# 第4次:执行 node_c,结束
logger.info("=== 第4次调用 ===")
res = graph.invoke(None, config=config)
logger.info("结果: {}", res)
运行过程:
=== 第1次调用 ===
结果: None ← 还没执行任何节点
=== 第2次调用 ===
node_a 被执行 ← 执行了 node_a
结果: {'final_res': 'node_a 的结果'} ← 停在 node_b 之前
=== 第3次调用 ===
node_b 被执行 ← 执行了 node_b
结果: {'final_res': 'node_b 的结果'} ← 停在 node_c 之前
=== 第4次调用 ===
node_c 被执行 ← 执行了 node_c
结果: {'final_res': 'node_c 的结果'} ← 结束
3.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):
logger.info("node_a 被执行")
return {"final_res": "node_a 的结果"}
def node_b(state):
logger.info("node_b 被执行")
return {"final_res": "node_b 的结果"}
def node_c(state):
logger.info("node_c 被执行")
return {"final_res": "node_c 的结果"}
def node_d(state):
logger.info("node_d 被执行")
return {}
def node_e(state):
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") # node_a 和 node_d 并行
builder.add_edge("node_a", "node_b")
builder.add_edge("node_d", "node_e") # node_b 和 node_e 并行
builder.add_edge(["node_b", "node_e"], "node_c") # 都完了才到 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"]
)
config = {"configurable": {"thread_id": "123"}}
# 第1次:停在第一个超步之前
logger.info("=== 第1次调用 ===")
res = graph.invoke({}, config=config)
logger.info("结果: {}", res)
# 第2次:执行 node_a + node_d(同一个超步),停在下一个超步之前
logger.info("=== 第2次调用 ===")
res = graph.invoke(None, config=config)
logger.info("结果: {}", res)
# 第3次:执行 node_b + node_e(同一个超步),停在 node_c 之前
logger.info("=== 第3次调用 ===")
res = graph.invoke(None, config=config)
logger.info("结果: {}", res)
# 第4次:执行 node_c,结束
logger.info("=== 第4次调用 ===")
res = graph.invoke(None, config=config)
logger.info("结果: {}", res)
运行过程:
=== 第1次调用 ===
结果: None
=== 第2次调用 ===
node_a 被执行
node_d 被执行 ← node_a 和 node_d 在同一个超步
结果: {'final_res': 'node_a 的结果'}
=== 第3次调用 ===
node_b 被执行
node_e 被执行 ← node_b 和 node_e 在同一个超步
结果: {'final_res': 'node_b 的结果'}
=== 第4次调用 ===
node_c 被执行
结果: {'final_res': 'node_c 的结果'}
关键发现:虽然我们只在 node_a和 node_b前后设了断点,但它们的并行兄弟节点 node_d和 node_e也被"连带"中断了。
这说明:静态断点是以超步为边界,不是以节点为边界。
四、调用时设置断点
4.1 正确用法:每次调用都传同样的断点
# 每次调用都传同样的断点
res = graph.invoke({}, config=config, interrupt_before=["node_a"])
res = graph.invoke(None, config=config, interrupt_before=["node_a"])
res = graph.invoke(None, config=config, interrupt_before=["node_a"])
这样每次都会在 node_a之前停下。
4.2 错误用法:中途不传断点了
# 第一次传了断点
res = graph.invoke({}, config=config, interrupt_before=["node_a"])
# 第二次没传断点 → 断点消失,一口气跑完
res = graph.invoke(None, config=config)
后果:第二次调用时,因为没有设置断点,图会一口气跑完剩下的所有节点。
# 第1次:停在 node_a 之前
res = graph.invoke({}, config=config, interrupt_before=["node_a", "node_b"])
# 结果: None
# 第2次:执行 node_a,停在 node_b 之前
res = graph.invoke(None, config=config, interrupt_before=["node_a", "node_b"])
# 结果: {'final_res': 'node_a 的结果'}
# 第3次:没传断点 → node_b + node_c 一口气跑完
res = graph.invoke(None, config=config)
# 结果: {'final_res': 'node_c 的结果'} ← 跳过了 node_b 的断点
五、底层原理
5.1 中断发生在哪里?
- interrupt_before:在超步的第一阶段检查。节点执行之前,如果当前超步的任务列表和
interrupt_before有交集,就中断。 - interrupt_after:在超步的第三阶段检查。节点执行完之后,如果当前超步的任务列表和
interrupt_after有交集,就中断。
5.2 为什么 node_a 之后 和 node_b 之前 只中断一次?
假设拓扑是:node_a → node_b
如果你同时设置了:
interrupt_after=["node_a"]interrupt_before=["node_b"]
它们对应的是同一个超步边界,所以只会中断一次。
恢复运行后,检查点已经更新,node_b之前的状态和上次中断时一样,所以不会再中断。
5.3 静态断点 vs 动态中断
| 特性 | 静态断点 | 动态中断 |
|---|---|---|
| 设置方式 | 编译/调用时预设 | 节点内调用 interrupt() |
| 能否传信息 | ❌ 不能 | ✅ 能 |
| 能否接收反馈 | ❌ 不能 | ✅ 能(resume) |
| 中断粒度 | 超步边界 | 节点内部任意位置 |
| 主要用途 | 调试、查看中间状态 | 人机交互(HITL) |
六、面试题
面试题1:静态断点和动态中断的根本区别是什么?
答:
- 静态断点:编译/调用时预设,不能传信息也不能收反馈,纯粹用于调试
- 动态中断:运行时触发,可以向调用者传信息,也能接收反馈,用于人机交互
面试题2:静态断点是以什么为单位中断的?
答:以超步为单位,不是以节点为单位。同一个超步里的所有节点会一起被中断。
面试题3:interrupt_before 和 interrupt_after 同时设置同一个节点,会中断几次?
答:如果拓扑是 node_a → node_b,那么 interrupt_after=["node_a"]和 interrupt_before=["node_b"]对应同一个超步边界,只会中断一次。
面试题4:调用时设置断点和编译时设置断点,哪个优先级高?
答:调用时设置的断点优先级更高。调用时传非空列表会覆盖编译时的配置,传 None或空列表则回退到编译配置。
面试题5:静态断点恢复运行时,输入应该传什么?
答:传 None,并且使用同一个 thread_id。None告诉 LangGraph "继续运行,不是新任务"。
静态断点就像给图装的"监控摄像头"。
- 在每个节点门口装一个
- 到了就停下来拍张照(返回当前状态)
- 你看完照片说"继续",它就继续走
调试的时候特别好用,能让你看清楚每一步状态是怎么变的。