Day 41 / 共 60 天 · 阶段 7 中断与人在环
interrupt():一个函数如何让整张图"暂停等人"
前 8 天我们把"存档"(checkpoint)看透了。今天开始进入阶段 7——人在环(human-in-the-loop)。核心就一个魔法函数:在节点里写一句 interrupt("请审批"),整张图就停下来、把值抛给你、把现场存档,等你给答案再从这里接着跑。今天先把"停"这一半看透:它靠的其实是一个受控的异常,加上昨天学的 checkpoint 存档。
📍 阶段 7 · 中断与人在环(6 天)你在这里
D41 interrupt原理→
D42 Command恢复→
D43 静态断点→
D44 时间旅行→
D45 durability→
D46 replay幂等
L01
痛点:AI 图跑到一半,要人拍板怎么办?
🤔 真实痛点你写了个 Agent:它会自动查数据、写 SQL、然后执行删除操作。问题来了——删库这种事总得有人点"确认"吧?可图一旦
invoke() 就一路跑到 END,中间根本没地方插一句"等等,让我看看"。难道要把图拆成两半、自己存中间状态、再手写恢复逻辑?💡 本质:把"暂停"变成语言里的一等公民LangGraph 的答案优雅到离谱:在节点里写一句
生活类比:像玩单机游戏按了"存档并退出"。你退出的那一刻,游戏把进度写盘(checkpoint);下次进来读档,角色还站在原地,仿佛从没离开。
value = interrupt("要删库,确认?")。执行到这里,图会就地停住,把 "要删库,确认?" 抛给外面的调用方;你在外面看到后,用 Command(resume="yes") 再喂回来,图就从这个节点开头重新跑,而这次 interrupt(...) 直接返回 "yes",代码继续往下走。生活类比:像玩单机游戏按了"存档并退出"。你退出的那一刻,游戏把进度写盘(checkpoint);下次进来读档,角色还站在原地,仿佛从没离开。
interrupt 就是节点里的"存档并退出",Command(resume=) 就是"读档继续"。🍼 一句话
interrupt() = 暂停 + 把问题递出去;Command(resume=)(明天讲)= 把答案递回来 + 继续。今天只看前半段"怎么停"。L02
interrupt() 函数全貌:就 20 行
别被"魔法"吓到,源码短得惊人。定义在 types.py:811,核心逻辑在 types.py:901-934:
# types.py:811 def interrupt(value: Any) -> Any: (省略了长文档字符串)
# types.py:901 起:
from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_NS, CONFIG_KEY_SCRATCHPAD, CONFIG_KEY_SEND, RESUME,
)
from langgraph.config import get_config
from langgraph.errors import GraphInterrupt
conf = get_config()["configurable"]
# track interrupt index
scratchpad = conf[CONFIG_KEY_SCRATCHPAD] # ① 拿到"本次任务的便签本"
idx = scratchpad.interrupt_counter() # ② 这是本节点里第几个 interrupt
# find previous resume values
if scratchpad.resume: # ③ 便签本里已有恢复值?(重跑时)
if idx < len(scratchpad.resume):
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return scratchpad.resume[idx] # 直接返回,不再暂停
# find current resume value
v = scratchpad.get_null_resume(True) # ④ 有没有"这次刚喂进来的"恢复值
if v is not None:
assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
scratchpad.resume.append(v)
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return v # 返回它,继续执行
# no resume value found
raise GraphInterrupt( # ⑤ 什么恢复值都没有 → 抛异常暂停
(Interrupt.from_ns(value=value, ns=conf[CONFIG_KEY_CHECKPOINT_NS]),)
)
scratchpad"便签本"(PregelScratchpad,明天细讲)——每个任务执行时都有一本,专门记"这个节点里 interrupt 走到第几个了、有没有恢复值"。interrupt_counter()一个原子计数器:节点里第 1 个 interrupt 拿到 idx=0,第 2 个拿到 1……用来把"多个 interrupt"和"多个恢复值"一一对上号。if scratchpad.resume分支③:如果便签本里已经有一批恢复值(说明节点在重跑、之前的 interrupt 已答过),按 idx 直接取答案返回,不再暂停。get_null_resume(True)分支④:查有没有"这次新喂进来、还没被认领"的恢复值。True 表示"取了就消费掉"。有就返回它。raise GraphInterrupt(...)分支⑤:啥答案都没有 → 抛异常。这就是"暂停"的真身。value 被包进一个 Interrupt 对象随异常带出去。💡 本质:一个函数三种命运,全看"有没有答案"同一句
interrupt("..."),第一次跑(无答案)→ 抛异常暂停;恢复后重跑(有答案)→ 直接返回答案。所以你的节点代码写一遍,却能表现出"停"和"继续"两种行为,全靠便签本里有没有恢复值来切换。这就是为什么文档强调:恢复时整个节点会从头重新执行——因为 Python 没法从函数中间"续上",只能重跑,但重跑时 interrupt 不再抛异常而是直接吐出答案。L03
GraphInterrupt:暂停靠的是"受控异常"
"暂停"没有黑魔法,就是 raise 一个特殊异常,让它一路往上冒。异常家谱在 errors.py:50-107:
# errors.py:50
class GraphBubbleUp(Exception): # 所有"该往上冒、不算失败"的异常的爹
pass
# errors.py:102
class GraphInterrupt(GraphBubbleUp):
"""Raised when a subgraph is interrupted, suppressed by the root graph.
Never raised directly, or surfaced to the user."""
def __init__(self, interrupts: Sequence[Interrupt] = ()) -> None:
super().__init__(interrupts) # 把 Interrupt 元组塞进 args[0]
GraphBubbleUp基类,语义是"这不是错误,是需要冒泡的控制信号"。它的兄弟还有 GraphDrained(优雅退出)。执行器专门认这一族,不会当报错处理。GraphInterrupt(GraphBubbleUp)暂停信号。注释写得直白:"绝不直接抛给用户"——它只在内部冒泡,到了顶层会被"吞掉"转成正常返回(L06)。args[0] = interrupts异常携带的"货物"就是一串 Interrupt 对象。runner 会把它拆出来存档、也会顺着流给你看到。💡 设计取舍①:为什么用"异常"来暂停,而不是让节点函数 return 一个特殊值?朴素做法:约定节点
return {"__pause__": "要删库?"},执行器看到这个特殊键就停。问题:① 节点里可能嵌套调了好几层普通函数(比如一个工具函数内部想暂停),return 只能层层往上传、每层都要写透传逻辑,极易漏;② 会污染正常返回值的语义。异常做法:raise 天生就是"无视调用栈、一路往上冒到有人接"的机制——不管 interrupt 埋在多深的函数里,一个 raise 就能穿透所有中间栈帧直达执行器。用语言自带的异常传播,换来了"任意深度都能暂停"的能力。代价是:Python 异常一抛,函数栈就没了,所以恢复只能整节点重跑(这也是明天幂等要解决的问题)。L04
runner 接住异常:把 interrupt 当"写"存进档
异常冒到执行器(runner)这一层被接住。看 _runner.py:584-591 的 commit 逻辑:
# _runner.py:584
elif exception:
if isinstance(exception, GraphInterrupt):
# save interrupt to checkpointer
if exception.args[0]: # ① 异常里带了 Interrupt
writes = [(INTERRUPT, exception.args[0])] # ② 造一条 INTERRUPT "写"
if resumes := [w for w in task.writes if w[0] == RESUME]:
writes.extend(resumes) # ③ 连带已有的 RESUME 写一起
self.put_writes()(task.id, writes) # ④ 存进 checkpointer
elif isinstance(exception, GraphBubbleUp):
pass # 其他冒泡异常:放行,别当错
else:
task.writes.append((ERROR, exception)) # 真错误才记 ERROR
isinstance(.., GraphInterrupt)runner 专门认出"这是暂停不是崩溃"。注意它排在 GraphBubbleUp 之前——因为 GraphInterrupt 是它的子类,要先判具体的。writes=[(INTERRUPT, ...)]关键设计:把中断当成节点的一次"写",通道名是特殊的 INTERRUPT。于是它和普通状态更新走同一套存档管线——存进 pending_writes,随 checkpoint 落盘。put_writes(task.id, writes)把这条 INTERRUPT 写存到 checkpointer(就是 D34/D35 学的 put_writes)。现场被冻结:下次读这个 thread,就知道"卡在一个中断上"。GraphBubbleUp: pass兜底:其它冒泡类异常直接放过,交给上层 _panic_or_proceed 处理,绝不塞进 ERROR 通道。💡 本质:中断不是特例,是"一种特殊的写"LangGraph 没有为中断单开一套存储,而是复用了"节点写通道 → 存 pending_writes → 落 checkpoint"的既有管线,只是通道名换成保留字
INTERRUPT。这就是为什么昨天学的 checkpoint 今天能无缝支撑中断——中断的现场,本质就是一份带 INTERRUPT 写的 checkpoint。也正因如此,官方要求:用 interrupt 必须配 checkpointer,没存档就没法冻结现场。L05
Interrupt 对象与 id:怎么认出"是哪个中断"
抛出去的货物是 Interrupt,定义在 types.py:533-578:
# types.py:533
@final
@dataclass(init=False, slots=True)
class Interrupt:
value: Any # 给客户端看的值,比如 "要删库?"
id: str # 中断的身份证,用来精确恢复
# types.py:576
@classmethod
def from_ns(cls, value: Any, ns: str) -> Interrupt:
return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))
value你写 interrupt("要删库?") 里那个字符串(或任意可序列化对象),原样带给客户端展示。id中断的唯一身份。恢复时可以按 id 精确指定"我回答的是哪个中断"(明天 Command(resume={id: 值}) 的映射用它)。from_ns(value, ns)回看 L02 分支⑤ 就是调它:拿当前命名空间(CONFIG_KEY_CHECKPOINT_NS,标识"哪张图哪个节点")用 xxh3_128 哈希成 id。同一位置的中断 id 稳定可复现。📝 真实值文档里的例子跑出来长这样:
{'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06'),)}。__interrupt__ 是流式输出里中断的固定 key,value 是你的提问,id 就是那串 128 位哈希(源码例子见 types.py:881)。⚠️ 边界:一个节点里多个 interrupt,靠"顺序"配对,别乱改代码L02 用
idx = interrupt_counter() 给节点里每个 interrupt 编号(0,1,2…),恢复值也按这个顺序回填(文档 types.py:826-828 明说"按出现顺序匹配")。这意味着:如果你在两次运行之间调整了节点里 interrupt 的顺序、或加了条件让某个 interrupt 有时不执行,编号就会错位,恢复值会喂给错误的 interrupt。反模式就是把 interrupt 放进不稳定的分支里。稳妥做法:让节点里的 interrupt 调用序列确定且稳定。L06
顶层如何"吞掉"异常:暂停对用户是正常返回
如果 GraphInterrupt 一路冒到用户面前,那就成崩溃了。所以在主循环退出时有个"消音器" _loop.py:1317-1339:
# _loop.py:1317
def _suppress_interrupt(self, exc_type, exc_value, traceback) -> bool | None:
# persist current checkpoint and writes
if self.durability == "exit" and (...): # 退出模式:此时才落盘(D45)
self._put_exit_delta_writes()
self._put_checkpoint(self.checkpoint_metadata)
self._put_pending_writes()
# suppress interrupt
if isinstance(exc_value, GraphInterrupt) and not self.is_nested:
interrupt = exc_value
interrupts = tuple(interrupt.args[0]) if interrupt.args else ()
self._push_graph_lifecycle_event("interrupt", interrupts=interrupts)
# ... 返回 True 表示"这个异常我处理了,别再往外抛"
_suppress_interrupt它是主循环上下文管理器的 __exit__ 钩子。异常从这里"出关"时被检查。and not self.is_nested关键条件:只有顶层图才吞异常。子图里的中断要继续往上冒,直到顶层图统一处理——保证"整棵图"作为一个整体暂停,而不是子图自己偷偷停了。_push_graph_lifecycle_event("interrupt")发一个"图暂停了"的生命周期事件,并把 interrupts 带出去。这就是你在 stream 里收到 __interrupt__ 的来源。return True (隐含)Python 里 __exit__ 返回真值 = "异常已消化,别再传播"。于是 invoke() 正常返回,用户拿到的是"图停了"而非 traceback。💡 设计取舍②:为什么中断要"内部当异常、边界转正常返回"?两全其美。内部当异常:借异常的冒泡能力,让任意深度的 interrupt 都能瞬间穿透栈帧、并被 runner 统一存档——省掉海量透传样板代码。边界转正常:用户根本不该看到 GraphInterrupt 这种内部异常(errors.py 注释白纸黑字"never surfaced to the user")。如果直接把异常抛给用户,就得人人写
try/except GraphInterrupt,中断这个"正常业务功能"却要用 except 来接,语义别扭。所以在 is_nested=False 的顶层把它消音、转成流里的 __interrupt__ 事件。异常只活在框架肚子里,对外永远是干净的数据。L07
全链路串讲 + 今日小结
图注:节点抛异常 → runner 存 INTERRUPT 写 → checkpoint 落盘 → 顶层消音转成 __interrupt__。明天讲返程(恢复)。
图注:value 装进 Interrupt,Interrupt 装进 GraphInterrupt.args[0],最终以 (INTERRUPT, ...) 写形式落盘。
👶 小白:图停在 interrupt 那里,进程还在跑吗?会一直占着内存等我吗?
👨🏫 不会。invoke()/stream() 会正常返回(异常被顶层消音了),Python 进程该干嘛干嘛甚至可以关掉。现场早已落盘到 checkpointer。你可以过一小时、甚至重启服务,再用同一个 thread_id + Command(resume=...) 恢复。"暂停"是持久化的暂停,不是挂起一个活着的线程——这正是它比"起个线程 sleep 等输入"高明的地方。
🧠 今天你应该能回答
- interrupt() 第一次跑做什么?(无恢复值 → raise GraphInterrupt 暂停)
- "暂停"的真实机制是什么?(抛一个 GraphBubbleUp 族的受控异常,一路冒泡)
- runner 拿到 GraphInterrupt 后怎么处理?(存成 (INTERRUPT, ...) 写,落 checkpoint,不当错误)
- 为什么 interrupt 必须配 checkpointer?(暂停 = 冻结现场到存档,没存档没法恢复)
- 用户为什么看不到 GraphInterrupt 异常?(顶层 _suppress_interrupt 消音,转成 __interrupt__ 事件)
- Interrupt.id 怎么来、有什么用?(命名空间哈希;恢复时精确指定回答哪个中断)
✋ 10 分钟动手
# 1. 读 interrupt() 全身(就 20 多行核心)
sed -n '901,934p' libs/langgraph/langgraph/types.py
# 2. 看 runner 如何把中断当"写"存档
sed -n '584,603p' libs/langgraph/langgraph/pregel/_runner.py
# 3. 亲手触发一次中断,观察 __interrupt__
python - <<'PY'
from langgraph.graph import StateGraph, START
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt
from typing_extensions import TypedDict
class S(TypedDict):
x: int
def node(s):
ans = interrupt("确认删库?") # 第一次跑到这就停
return {"x": 1}
g=StateGraph(S); g.add_node("n",node); g.add_edge(START,"n")
app=g.compile(checkpointer=InMemorySaver())
cfg={"configurable":{"thread_id":"t1"}}
for chunk in app.stream({"x":0}, cfg):
print(chunk) # 你会看到 {'__interrupt__': (Interrupt(...),)}
print("状态卡在:", app.get_state(cfg).next) # ('n',) —— 还没跑完
PY
明天预告 · Day 42:讲返程——
Command(resume="yes") 怎么把答案喂回去。核心是"便签本"PregelScratchpad 和 _scratchpad() 如何把恢复值塞进去,让重跑的 interrupt 从"抛异常"变成"直接返回答案"。