培歌行学LangGraph(20):静态断点-让图在每个节点门口停下来给你看

0 阅读8分钟
  1. 培歌行学LangGraph(1):从 LangChain 到 LangGraph 的思维跃迁]
  2. 培歌行学LangGraph(2):Reducer(归约器)彻底搞懂
  3. 培歌行学LangGraph(3):编译、执行与可视化
  4. 培歌行学LangGraph(4):一文搞懂图的状态(State)管理
  5. 培歌行学LangGraph(5):状态管理与graph.invoke入参深度解析
  6. 培歌行学LangGraph(6):Multi Schema多状态管理详解
  7. 培歌行学LangGraph(7):预定义状态MessagesState与AgentState
  8. 培歌行学LangGraph(8):控制流详解
  9. 培歌行学LangGraph(9):控制流详解:defer延迟节点——让收尾工作自动排到最后
  10. 培歌行学LangGraph(10):多分支汇聚Fan-in——多个分支如何汇聚到一起?
  11. 培歌行学LangGraph(11):用循环结构实现 ReAct Agent
  12. 培歌行学LangGraph(12):递归限制——别让你的图无限跑下去
  13. 培歌行学LangGraph(13):节点执行与容错机制
  14. 培歌行学LangGraph(14):启用可恢复执行(Checkpoint)
  15. 培歌行学LangGraph(15):持久化模式——何时保存检查点?
  16. 培歌行学LangGraph(16):持久化模式——查看历史检查点——你的图走过的每一步都有记录
  17. 培歌行学LangGraph(17):检查点的四大使用场景
  18. 培歌行学LangGraph(18):图记忆管理-让你的 AI 记住该记的事
  19. 培歌行学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_anode_b前后设了断点,但它们的并行兄弟节点 node_dnode_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_idNone告诉 LangGraph "继续运行,不是新任务"。


静态断点就像给图装的"监控摄像头"。

  • 在每个节点门口装一个
  • 到了就停下来拍张照(返回当前状态)
  • 你看完照片说"继续",它就继续走

调试的时候特别好用,能让你看清楚每一步状态是怎么变的。