静态断点在编译期固定,而动态断点让节点在运行中根据条件触发中断。
学会用 langgraph.types.interrupt 实现真正灵活的人机协作流程。
在 Module 3 的前几节课中,我们学习了静态断点(Static Breakpoints):在编译图时,通过 compile(interrupt_before=["node_name"]) 或 interrupt_after= 来指定哪些节点会触发暂停。
静态断点有一个根本性的限制:它是"编译期"决定的,与运行时的数据完全无关。
| 维度 | 静态断点(Static Breakpoint) |
|---|---|
| 设置位置 | 图编译时:builder.compile(interrupt_before=[...]) |
| 触发条件 | 每次执行到该节点,无条件中断 |
| 灵活性 | 无法根据输入数据决定"要不要中断" |
| 中断原因 | 固定的节点名称,没有附加信息 |
| 典型场景 | 调试、开发期间强制观察某个节点的输出 |
想象一个审批工作流:当 AI 生成的内容超过某个风险阈值时才需要人工审核,低风险内容应该直接通过。用静态断点实现时,你只能"要么总中断,要么永不中断"——无法做到条件性中断。
这就是动态断点(Dynamic Breakpoints)存在的原因:让节点函数内部的代码,根据运行时数据,自主决定是否触发中断,并且能向外界传达中断的具体原因。
(1) 审批(Approval):中断图,将状态呈现给用户,让用户决定是否继续
(2) 调试(Debugging):回滚图到某个检查点,复现或规避问题
(3) 编辑(Editing):修改中间状态,改变图的后续行为
动态断点主要服务于第 (1) 个场景,但比静态断点更精准——只在需要时才打扰人类。
LangGraph 通过 langgraph.types.interrupt 函数实现动态断点。当节点函数在执行过程中调用 interrupt(...) 时,LangGraph 运行时会:
interrupt() 会在内部触发 LangGraph 可识别的受控中断,运行时会接管它,不会让程序以未捕获异常的方式崩溃。
图的完整状态(包括已执行节点的输出)被持久化到 MemorySaver 或其他 checkpointer 中。
传递给 interrupt() 的 payload(建议使用 JSON 可序列化的字符串、字典等值)会暴露给调用方,并记录在 state.tasks 的 interrupts 字段中。
graph.stream() 或 graph.invoke() 调用返回,流程停在触发中断的节点上;后续可以通过人工输入、状态修改或恢复命令继续。
from langgraph.types import interrupt
# 调用 interrupt() 会暂停当前图执行
# payload 会作为中断原因暴露给外部调用方
interrupt("需要人工审核:输入内容超出长度限制")
interrupt({"reason": "high_risk", "score": 0.95})
与静态断点在"图结构层面"设置不同,interrupt() 是在节点函数的业务逻辑层面触发的。这意味着你可以用任意 Python 逻辑决定是否中断:if 条件、模型置信度阈值、规则引擎、外部 API 调用……完全自由。
Notebook 中的示例构建了一个三步骤的简单图:step_1 → step_2 → step_3,其中 step_2 会在输入字符串超过 5 个字符时调用 interrupt()。
from typing_extensions import TypedDict
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, END, StateGraph
from langgraph.types import interrupt # ← 导入动态中断函数
# State 只有一个字段:input 字符串
class State(TypedDict):
input: str
# ── step_1:普通节点,直接返回 state ──
def step_1(state: State) -> State:
print("---Step 1---")
return state
# ── step_2:包含动态断点逻辑的关键节点 ──
def step_2(state: State) -> State:
# 运行时条件判断:如果输入超过 5 个字符,则中断
if len(state['input']) > 5:
# 调用 interrupt(),并附带说明原因的 payload
interrupt(
f"Received input that is longer than 5 characters: {state['input']}"
)
# 如果没有触发中断,则正常执行
print("---Step 2---")
return state
# ── step_3:普通节点 ──
def step_3(state: State) -> State:
print("---Step 3---")
return state
if 条件满足时执行 interrupt(...),本次图执行会在这里暂停,不会继续执行 print("---Step 2---")。只有条件不满足时,才会往下走到 print 和 return state。这正是"条件中断"的精髓。
# 构建图结构
builder = StateGraph(State)
builder.add_node("step_1", step_1)
builder.add_node("step_2", step_2)
builder.add_node("step_3", step_3)
builder.add_edge(START, "step_1")
builder.add_edge("step_1", "step_2")
builder.add_edge("step_2", "step_3")
builder.add_edge("step_3", END)
# 准备 MemorySaver(动态断点依赖 checkpointer 保存状态)
memory = MemorySaver()
# 编译时 ★ 不需要 ★ 指定任何 interrupt_before / interrupt_after!
# 动态断点完全由节点内部的 interrupt() 调用控制
graph = builder.compile(checkpointer=memory)
静态断点:builder.compile(checkpointer=memory, interrupt_before=["step_2"])
动态断点:builder.compile(checkpointer=memory) ← 无需额外参数
动态断点的所有逻辑都在节点函数里,编译器不需要知道任何信息。
从图结构本身看不出任何"中断"的痕迹——动态断点在运行时才会"显现"。这与静态断点(编译期即固定了中断节点)的可见性是不同的。
# 配置:使用 thread_id 追踪这次执行的状态
initial_input = {"input": "hello world"} # 11 个字符,超过 5
thread_config = {"configurable": {"thread_id": "1"}}
# 使用 stream 运行图
for event in graph.stream(initial_input, thread_config, stream_mode="values"):
print(event)
观察输出:
{'input': 'hello world'}:图启动时的初始 State 事件---Step 1---:step_1 正常执行并打印{'input': 'hello world'}:step_1 执行完后的 State 事件__interrupt__:step_2 调用了 interrupt(),payload 被返回给调用方stream() 返回interrupt() 不会让程序抛出未捕获异常或崩溃。stream() 正常返回,只是提前结束了迭代。所有中断信息都被保存在 checkpointer 中,等待查询或恢复。
state = graph.get_state(thread_config)
print(state.next)
state.next 显示 ('step_2',),表示图停在了 step_2 "即将执行"的位置——step_2 触发了中断,但本次执行被中止,所以它仍然是"下一个待执行节点"。
print(state.tasks)
解读 state.tasks 的关键字段:
| 字段 | 值 | 含义 |
|---|---|---|
name |
'step_2' |
触发中断的节点名称 |
interrupts[0].value |
'Received input that is longer than 5 characters: hello world' |
传递给 interrupt() 的 payload——中断原因,可供人类或代码读取 |
interrupts[0].when |
'during' |
中断发生在节点执行"期间"(区别于 'before'/'after',即静态断点的位置) |
error |
None |
这不是错误,是受控中断 |
传给 interrupt() 的值不限于字符串,推荐使用 JSON 可序列化的结构化数据。例如:
interrupt({"reason": "high_risk", "score": 0.97, "flagged_content": text})
这让人机界面能展示详细信息,帮助人类做出更准确的判断。
初学者容易犯的错误:以为不修改状态、也不提供 Command(resume=...),直接 graph.stream(None, thread_config) 就能继续。让我们看看会发生什么:
# 尝试直接恢复(不修改 state)
for event in graph.stream(None, thread_config, stream_mode="values"):
print(event)
state = graph.get_state(thread_config)
print(state.next) # 仍然是 step_2!
因为 state['input'] 仍然是 "hello world"(11 个字符),step_2 执行时条件 len(state['input']) > 5 依然为真,所以 interrupt() 会再次被调用。如果一直用 None 恢复且不改变条件,图会反复停在同一个中断点。
这与静态断点的行为有所不同:
interrupt_before=["step_2"] 是在 step_2 执行之前中断,恢复后 step_2 才开始执行,通过与否取决于 step_2 的代码逻辑。None 恢复会重新跑 step_2,如果状态没变,中断条件仍成立,就会再次中断。动态断点的中断条件是数据驱动的。本例采用"先修改 state,再用 None 恢复"的方式,让节点重新运行时不再满足中断条件。另一类人机交互流程则会用 Command(resume=...) 把人工输入传回 interrupt() 调用点。
正确的处理方式是:先用 graph.update_state() 修改状态,让中断条件不再满足,然后再恢复执行。
# 将 input 改为短字符串(5 个字符以内),让 step_2 的条件不再触发
graph.update_state(
thread_config,
{"input": "hi"}, # "hi" 只有 2 个字符,len("hi") = 2 ≤ 5
)
update_state 返回新的 checkpoint 配置,说明状态已成功写入。
# 传入 None 作为 input,表示"从上次中断处继续,不添加新输入"
for event in graph.stream(None, thread_config, stream_mode="values"):
print(event)
这次执行成功了!执行流程分析:
{'input': 'hi'}:恢复时的当前 State(已被修改)---Step 2---:step_2 正常执行(len("hi") = 2 ≤ 5,不触发中断){'input': 'hi'}:step_2 执行后的 State 事件---Step 3---:step_3 正常执行graph.stream(initial_input, config)state.tasks[0].interrupts[0].valuegraph.update_state(config, new_values),使中断条件不再成立graph.stream(None, config),pending 节点重新运行如果要把人工输入作为 interrupt() 的返回值传回节点,应使用 Command(resume=...)。本例演示的是状态编辑流程:先用 update_state() 消除中断条件,再用 graph.stream(None, config) 让 pending 节点重新运行。
在生产环境中,图通常部署在 LangGraph 服务端(通过 langgraph dev 启动本地开发服务器),客户端通过 LangGraph SDK 与其交互。动态断点在 API 场景下同样适用,操作逻辑与本地完全一致。
在模块的 /studio 目录下运行:
langgraph dev
服务启动后会显示:
from langgraph_sdk import get_client
# 连接到本地开发服务器
URL = "http://127.0.0.1:2024"
client = get_client(url=URL)
# 创建新线程(相当于一次独立的图执行会话)
thread = await client.threads.create()
input_dict = {"input": "hello world"} # 超过 5 字符,会触发 interrupt()
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id="dynamic_breakpoints", # 图的名称(在 langgraph.json 中定义)
input=input_dict,
stream_mode="values",
):
print(f"Receiving new event of type: {chunk.event}...")
print(chunk.data)
流事件结束——图在 step_2 的 interrupt() 处暂停。
current_state = await client.threads.get_state(thread['thread_id'])
print(current_state['next'])
# 更新 state:将 input 改为短字符串
await client.threads.update_state(thread['thread_id'], {"input": "hi!"})
# 恢复执行:传入 input=None 表示从中断处继续
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id="dynamic_breakpoints",
input=None, # ← 关键:None 表示恢复,而非新的输入
stream_mode="values",
):
print(f"Receiving new event of type: {chunk.event}...")
print(chunk.data)
图成功恢复并执行完毕(step_2 和 step_3 均正常执行)。
在生产系统中,图运行在服务器上,而"修改状态"和"恢复执行"的操作来自另一个服务(比如人工审核界面、消息队列、webhook 回调)。LangGraph SDK 提供了跨进程、跨服务的状态操作能力,让分布式的人机协作工作流成为可能。
两种断点机制各有适用场景,理解它们的差异有助于在实际项目中做出正确选择。
| 对比维度 | 静态断点(Static Breakpoint) | 动态断点(Dynamic Breakpoint) |
|---|---|---|
| 设置位置 | 图编译时,compile(interrupt_before/after=[...]) |
节点函数内部,interrupt(...) |
| 导入 | 无需额外导入 | from langgraph.types import interrupt |
| 中断时机 | 节点执行之前(before)或之后(after) |
节点执行期间(when='during') |
| 触发条件 | 无条件:每次到达指定节点都中断 | 有条件:由开发者在节点函数中用 if 判断 |
| 中断原因传递 | 只知道"在哪个节点中断了" | 可传递 JSON 可序列化的字符串、字典等 payload 作为原因 |
| 直接 resume 效果 | 可以直接 resume,节点会正常执行 | 用 None 恢复且条件不变时会再次中断;用 Command(resume=...) 可把人工输入传回节点 |
| 恢复前的要求 | 可以选择修改或不修改 state 再 resume | 本例先修改 state 解除条件;需要传回人工输入时使用 Command(resume=...) |
| 读取中断信息 | state.next(下一个节点名) |
state.tasks[0].interrupts[0].value(具体原因) |
| 适合场景 | 调试、开发期间需要固定观察某节点输出 | 生产环境中按条件触发的人工审核、风险拦截 |
| 典型代码 | graph = builder.compile(checkpointer=memory, interrupt_before=["step_2"]) |
节点内 if condition: interrupt("reason") |
静态断点和动态断点并不互斥。你可以在同一张图中,既用 compile(interrupt_before=[...]) 设置静态断点,又在某些节点函数内用 interrupt(...) 设置动态断点。它们独立工作,互不干扰。
| 业务场景 | 使用哪种断点 | 中断条件 |
|---|---|---|
| AI 内容审核系统 | 动态断点 | 当内容风险评分 > 0.8 时中断,由人工复核 |
| 财务审批工作流 | 动态断点 | 当报销金额 > 1000 元时中断,等待主管批准 |
| 代码生成 Agent 调试 | 静态断点 | 开发期间每次生成代码后都暂停,让开发者检查 |
| 法律文书生成 | 动态断点 | 当涉及特定敏感条款时中断,法务人员确认后继续 |
| 工具调用 Agent 安全防护 | 动态断点 | 当 LLM 决定调用"危险工具"(删除、外发数据)时中断 |
| 研究助手原型验证 | 静态断点 | 每次搜索节点完成后暂停,验证搜索结果质量 |
在生产系统中,建议向 interrupt() 传递结构化的字典而不是简单字符串,这样审核界面可以更好地解析和展示信息:
interrupt({
"reason": "high_risk_content",
"risk_score": 0.94,
"flagged_text": state["generated_content"][:200],
"suggested_action": "请人工审核后选择批准或拒绝"
})