静态断点:不改节点代码,也能"在某步前/后停"
前两天的 interrupt() 是动态断点——你得在节点函数里亲手写一句。但有时你只想"调试时在某节点前停一下看看状态",不想改业务代码。LangGraph 给了静态断点:编译或运行时传 interrupt_before=["risky_node"],框架就在超步边界替你停。今天看它在主循环的哪两个点触发、靠什么判断"这次该不该停"。
动态 vs 静态:两种"停"的分工
interrupt() 要写进节点函数,还要接收返回值。可我只是想调试时在某个节点前停一下,看看当前状态对不对,看完就删——为这个改业务代码、上线再删掉,太重了。而且有些节点是别人写的、或是预制件(如 ToolNode),我根本改不动它内部,怎么在它执行前插一脚?interrupt_before=["n"] / interrupt_after=["n"] 不碰节点代码一个字,而是告诉执行引擎:"准备执行 n 前(或刚执行完 n 后)请在超步边界停下"。停的位置是节点与节点之间,不是节点内部——所以它拿不到"人给的返回值"(那是 interrupt() 的活),它只是纯粹的暂停点,让你有机会 get_state 查看、update_state 修改,然后 Command(resume=None) 或直接再 stream 让它继续。类比:
interrupt() 像代码里的 input()——就地要一个输入。静态断点像调试器的断点(breakpoint)——在某行"之前/之后"挂起,你在旁边观察、改变量,再放行。| 动态 interrupt() | 静态 interrupt_before/after | |
|---|---|---|
| 写在哪 | 节点函数内部 | compile/stream 参数,图外部 |
| 停的位置 | 节点内部那一行 | 节点边界(前/后) |
| 能收人给的值吗 | 能(返回值) | 不能,只是暂停 |
| 典型用途 | 审批、要输入 | 调试、检查、外部改状态 |
参数从哪进来:compile 存、stream 可覆盖
编译时的断点存在 Pregel 实例上(main.py:721-815):
# main.py:721
interrupt_after_nodes: All | Sequence[str]
interrupt_before_nodes: All | Sequence[str]
# main.py:768(__init__ 参数,默认空元组)
interrupt_after_nodes: All | Sequence[str] = (),
interrupt_before_nodes: All | Sequence[str] = (),
# main.py:814
self.interrupt_after_nodes = interrupt_after_nodes
self.interrupt_before_nodes = interrupt_before_nodes
运行时(stream/invoke)可以传参覆盖,逻辑在 main.py:2549-2570:
# main.py:2549 (stream 内部的 defaults 解析)
interrupt_before: All | Sequence[str] | None,
interrupt_after: All | Sequence[str] | None,
...
# main.py:2569
interrupt_before = interrupt_before or self.interrupt_before_nodes
interrupt_after = interrupt_after or self.interrupt_after_nodes
All | Sequence[str]类型说明两种取值:给一串节点名(["n1","n2"]),或字符串 "*"(All)表示所有节点都停。= () 默认空默认不设任何静态断点,零开销。x or self.x覆盖策略:运行时传了就用运行时的,没传(None/空)就回落到编译时设的。让你能"编译时定基线、单次运行临时加断点"。interrupt_before:在"准备执行前"触发
断点在主循环 PregelLoop 里生效。before 在每个超步开始、任务已备好但还没执行时检查,见 _loop.py:666-671:
# _loop.py:666 (在 tick() 里,任务准备完之后)
# before execution, check if we should interrupt
if self.interrupt_before and should_interrupt(
self.checkpoint, self.interrupt_before, self.tasks.values()
):
self.status = "interrupt_before"
raise GraphInterrupt()
self.interrupt_before and ...先短路:没设 before 断点就完全跳过,零成本。should_interrupt(checkpoint, 断点, tasks)核心判定(L05 细看):本超步待执行的任务里,有没有落在断点名单里、且状态自上次中断后变过的。返回要停的任务列表。self.status = "interrupt_before"标记状态,让上层知道"是 before 断点停的"。raise GraphInterrupt()复用昨天的机制——抛空 GraphInterrupt(没有 Interrupt 货物,因为静态断点不带值)。一路冒泡到顶层消音。此时任务还没执行,所以停在节点"前"。GraphInterrupt——和 D41 动态中断同一个异常类,所以后续存档、消音、恢复全都走同一套代码。区别仅在:动态中断由节点内部抛、带 Interrupt 值;静态中断由主循环抛、不带值。框架用一套暂停基础设施支撑了两种断点。GraphInterrupt(),不带 Interrupt 货物。原因是职责分离:静态断点停在节点边界,此时没有任何 interrupt() 调用在等一个返回值,硬塞值也没人接、语义悬空。"要人给值"这件事天然属于节点内部的 interrupt()(它明确有个返回值等着被填)。静态断点的定位就是"观察/介入点"——你要改状态用 update_state(D44),要放行就继续。把"暂停"和"要值"拆成两个机制,各自语义干净,而不是造一个又能停又能收值的万能断点,那样反而模糊了"值到底给谁"。interrupt_after:在"执行完、写入后"触发
after 在超步结束、写已 apply、checkpoint 已存之后检查,见 _loop.py:719-724(在 after_tick() 里):
# _loop.py:717 (after_tick 尾部)
# save checkpoint
self._put_checkpoint({"source": "loop"})
# after execution, check if we should interrupt
if self.interrupt_after and should_interrupt(
self.checkpoint, self.interrupt_after, self.tasks.values()
):
self.status = "interrupt_after"
raise GraphInterrupt()
顺序:先 _put_checkpoint 再判断关键:after 断点触发前,本超步的写已经 apply、checkpoint 已经存好。所以停在这里,节点 n 的结果已生效并落盘——你查状态能看到 n 的产出。same should_interrupt用的是同一个判定函数,只是名单换成 interrupt_after。raise GraphInterrupt()同样抛空异常暂停。区别是执行时机:before 停在"跑之前"、after 停在"跑完并存档之后"。should_interrupt:到底停哪些任务?
判定核心 _algo.py:155-185:
# _algo.py:155
def should_interrupt(checkpoint, interrupt_nodes, tasks) -> list[PregelExecutableTask]:
"""Check if the graph should be interrupted based on current state."""
version_type = type(next(iter(checkpoint["channel_versions"].values()), None))
null_version = version_type()
seen = checkpoint["versions_seen"].get(INTERRUPT, {}) # ① 上次中断时"看到"的版本
# interrupt if any channel has been updated since last interrupt
any_updates_since_prev_interrupt = any(
version > seen.get(chan, null_version) # ② 有通道版本比上次新?
for chan, version in checkpoint["channel_versions"].items()
)
# and any triggered node is in interrupt_nodes list
return (
[
task for task in tasks
if (
(not task.config or TAG_HIDDEN not in task.config.get("tags", EMPTY_SEQ))
if interrupt_nodes == "*" # ③ "*"=所有(除隐藏)
else task.name in interrupt_nodes # ④ 或名字在名单里
)
]
if any_updates_since_prev_interrupt # ⑤ 且"确实有更新"才停
else []
)
seen = versions_seen[INTERRUPT]取出"上一次中断发生时,各通道的版本号"。INTERRUPT 是个保留的伪节点名,专门记"中断这个观察者看到过的版本"。version > seen.get(chan)逐通道比:只要有任何通道的版本比上次中断时新,说明"自上次停之后状态推进了"。interrupt_nodes == "*"名单是 "*":所有任务都算候选(但排除带 TAG_HIDDEN 标签的内部隐藏节点)。task.name in interrupt_nodes否则按名字过滤:只有名字在断点名单里的任务才被选中。if any_updates.. else []关键闸门:只有"自上次中断后有更新"才可能停;否则返回空列表 = 不停。这是防止"刚恢复又立刻停在同一个断点"死循环的关键。versions_seen:为什么恢复后不会卡死在原地
L05 那个"自上次中断后有更新才停"的闸门,靠的是 versions_seen[INTERRUPT]。它是一本"观察者账本"——记录"INTERRUPT 这个观察者上次看到各通道到哪个版本了"。
interrupt_before=["n"] 停在了 n 前。你不改任何状态,直接再 stream 想让它继续。如果 should_interrupt 只看"n 是不是在名单里",那恢复后它还在名单里、还停——你永远过不了 n!有了 versions_seen[INTERRUPT]:第一次停时框架记下"INTERRUPT 看到 x=v3";恢复继续、n 执行后 x 变 v4,账本更新;但就在停的那一刻到恢复的那一刻之间,如果状态没被推进,any_updates_since_prev_interrupt 为假 → 不再停,于是放行。这就是"停一次就能过"的保证。反过来提醒你:静态断点是"每当状态在此处有新推进就停一次"的语义,不是"路过就停"。versions_seen 是 checkpoint 里"每个节点/观察者看过哪些版本"的账本,普通节点用它做"触发去重"(同样的输入不重复触发)。这里 INTERRUPT 只是借用同一套账本机制当"中断观察者",一鱼两吃。怎么恢复静态断点 + 今日小结
静态断点不带值,所以恢复更简单——通常直接再跑一次(输入传 None),或用 Command(resume=None):
app = builder.compile(checkpointer=saver, interrupt_before=["risky"])
cfg = {"configurable": {"thread_id": "t1"}}
app.invoke({"x": 0}, cfg) # 跑到 risky 前停下
print(app.get_state(cfg).next) # ('risky',) —— 下一步是 risky
# ...人工检查,甚至改状态...
app.update_state(cfg, {"x": 99}) # 可选:介入修改(D44 讲)
app.invoke(None, cfg) # 传 None 恢复 → 从 risky 继续跑到底
传 None 作输入时,框架识别为"恢复已有 thread"而非"新运行",读回现场继续。因为静态断点没有要回答的 interrupt,不需要 resume 值。👶 小白:静态断点和 interrupt() 能一起用吗?会打架吗?
👨🏫 能一起用,不打架,因为它们停的位置不同层:静态断点在节点边界(超步头/尾),interrupt() 在节点内部。而且它们抛的是同一个 GraphInterrupt、走同一套存档恢复通路。实践中:生产里的"要人审批"用 interrupt()(能收人的输入);调试时临时"我想看看这步前后的状态"用静态断点(一行参数、看完删)。
🧠 今天你应该能回答
- 静态断点和动态 interrupt() 的三点区别?(写在图外/内、停在节点边界/内部、不带值/带值)
- interrupt_before / after 分别在超步的哪个时刻触发?(before=任务准备后执行前;after=执行+存档之后)
- should_interrupt 的两个条件?(任务名在名单/为"*",且自上次中断后状态有更新)
- versions_seen[INTERRUPT] 防的是什么?(恢复后没状态推进就不再停,避免原地死循环)
- 静态断点抛什么异常?(和动态一样是 GraphInterrupt,但不带 Interrupt 值)
- 怎么恢复静态断点?(输入传 None 或 Command(resume=None),无需 resume 值)
✋ 10 分钟动手
# 1. 读两个触发点与判定
sed -n '666,671p' libs/langgraph/langgraph/pregel/_loop.py
sed -n '719,724p' libs/langgraph/langgraph/pregel/_loop.py
sed -n '155,185p' libs/langgraph/langgraph/pregel/_algo.py
# 2. 亲手设静态断点
python - <<'PY'
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
class S(TypedDict):
x: int
g=StateGraph(S)
g.add_node("a", lambda s:{"x":s["x"]+1})
g.add_node("b", lambda s:{"x":s["x"]*10})
g.add_edge(START,"a"); g.add_edge("a","b"); g.add_edge("b",END)
app=g.compile(checkpointer=InMemorySaver(), interrupt_before=["b"])
cfg={"configurable":{"thread_id":"t"}}
app.invoke({"x":0}, cfg)
print("停在 b 前, next =", app.get_state(cfg).next) # ('b',)
print("此刻 x =", app.get_state(cfg).values["x"]) # 1(a 跑完,b 未跑)
app.invoke(None, cfg) # 恢复
print("跑完 x =", app.get_state(cfg).values["x"]) # 10
PY
get_state_history() 翻看图跑过的每一份存档,用 update_state() 修改某个历史点的状态、甚至从过去某个点重新分叉。看它俩在 pregel/main.py 里怎么实现。