LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 3 · Human in the Loop
1 3-1 Streaming
2 3-2 Breakpoints
3 3-3 Edit State
4 3-4 Dyn Breakpts
5 3-5 Time Travel
SUM Module Summary
HomeLangGraphModule 3 · Human in the Loop3-4 Dyn Breakpts
📓 Notebook 📖 Explained
LANGGRAPH MODULE 3 · LESSON 4

动态断点:interrupt()
让图在运行时自行决定是否暂停

静态断点在编译期固定,而动态断点让节点在运行中根据条件触发中断。
学会用 langgraph.types.interrupt 实现真正灵活的人机协作流程。

1 静态断点的局限性:为什么还需要动态断点

在 Module 3 的前几节课中,我们学习了静态断点(Static Breakpoints):在编译图时,通过 compile(interrupt_before=["node_name"])interrupt_after= 来指定哪些节点会触发暂停。

静态断点有一个根本性的限制:它是"编译期"决定的,与运行时的数据完全无关

维度 静态断点(Static Breakpoint)
设置位置 图编译时:builder.compile(interrupt_before=[...])
触发条件 每次执行到该节点,无条件中断
灵活性 无法根据输入数据决定"要不要中断"
中断原因 固定的节点名称,没有附加信息
典型场景 调试、开发期间强制观察某个节点的输出
典型痛点

想象一个审批工作流:当 AI 生成的内容超过某个风险阈值时才需要人工审核,低风险内容应该直接通过。用静态断点实现时,你只能"要么总中断,要么永不中断"——无法做到条件性中断

这就是动态断点(Dynamic Breakpoints)存在的原因:让节点函数内部的代码,根据运行时数据,自主决定是否触发中断,并且能向外界传达中断的具体原因。

Module 3 人机协作三大场景回顾

(1) 审批(Approval):中断图,将状态呈现给用户,让用户决定是否继续
(2) 调试(Debugging):回滚图到某个检查点,复现或规避问题
(3) 编辑(Editing):修改中间状态,改变图的后续行为

动态断点主要服务于第 (1) 个场景,但比静态断点更精准——只在需要时才打扰人类

2 动态断点的核心:interrupt() 函数

LangGraph 通过 langgraph.types.interrupt 函数实现动态断点。当节点函数在执行过程中调用 interrupt(...) 时,LangGraph 运行时会:

导入方式

from langgraph.types import interrupt

# 调用 interrupt() 会暂停当前图执行
# payload 会作为中断原因暴露给外部调用方
interrupt("需要人工审核:输入内容超出长度限制")
interrupt({"reason": "high_risk", "score": 0.95})
关键设计思想

与静态断点在"图结构层面"设置不同,interrupt() 是在节点函数的业务逻辑层面触发的。这意味着你可以用任意 Python 逻辑决定是否中断:if 条件、模型置信度阈值、规则引擎、外部 API 调用……完全自由。

动态断点的触发机制示意

__start__
step_1
正常执行,返回 state
输入较短(len ≤ 5)
step_2
正常执行,继续流程
step_3
__end__
输入较长(len > 5)
step_2
interrupt(...)
图暂停,等待外部操作
state.tasks[0].interrupts 中
记录中断原因

3 完整示例:构建带动态断点的图

Notebook 中的示例构建了一个三步骤的简单图:step_1 → step_2 → step_3,其中 step_2 会在输入字符串超过 5 个字符时调用 interrupt()

第一步:定义 State 和节点函数

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
注意:step_2 函数体结构

if 条件满足时执行 interrupt(...),本次图执行会在这里暂停,不会继续执行 print("---Step 2---")。只有条件不满足时,才会往下走到 printreturn 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)  ←  无需额外参数

动态断点的所有逻辑都在节点函数里,编译器不需要知道任何信息。

图的可视化结构

图拓扑(编译期看起来是完全线性的)

__start__
step_1
无条件执行
step_2
可能调用 interrupt()
step_3
无条件执行
__end__

从图结构本身看不出任何"中断"的痕迹——动态断点在运行时才会"显现"。这与静态断点(编译期即固定了中断节点)的可见性是不同的。

4 触发中断:检查状态与中断原因

运行图,触发 interrupt()

# 配置:使用 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'} ---Step 1--- {'input': 'hello world'} {'input': 'hello world', '__interrupt__': (Interrupt(value='Received input that is longer than 5 characters: hello world', id='placeholder-id'),)}

观察输出:

注意:没有报错

interrupt() 不会让程序抛出未捕获异常或崩溃。stream() 正常返回,只是提前结束了迭代。所有中断信息都被保存在 checkpointer 中,等待查询或恢复。

检查 state.next:下一个等待执行的节点

state = graph.get_state(thread_config)
print(state.next)
('step_2',)

state.next 显示 ('step_2',),表示图停在了 step_2 "即将执行"的位置——step_2 触发了中断,但本次执行被中止,所以它仍然是"下一个待执行节点"。

检查 state.tasks:读取中断原因

print(state.tasks)
(PregelTask( id='6eb3910d-e231-5ba2-b25e-28ad575690bd', name='step_2', error=None, interrupts=( Interrupt( value='Received input that is longer than 5 characters: hello world', when='during' ), ), state=None ),)

解读 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})
这让人机界面能展示详细信息,帮助人类做出更准确的判断。

5 陷阱:简单恢复会重新触发中断

初学者容易犯的错误:以为不修改状态、也不提供 Command(resume=...),直接 graph.stream(None, thread_config) 就能继续。让我们看看会发生什么:

# 尝试直接恢复(不修改 state)
for event in graph.stream(None, thread_config, stream_mode="values"):
    print(event)
{'input': 'hello world'}
state = graph.get_state(thread_config)
print(state.next)  # 仍然是 step_2!
('step_2',)
死循环警告

因为 state['input'] 仍然是 "hello world"(11 个字符),step_2 执行时条件 len(state['input']) > 5 依然为真,所以 interrupt()再次被调用。如果一直用 None 恢复且不改变条件,图会反复停在同一个中断点。

这与静态断点的行为有所不同:

根本原因

动态断点的中断条件是数据驱动的。本例采用"先修改 state,再用 None 恢复"的方式,让节点重新运行时不再满足中断条件。另一类人机交互流程则会用 Command(resume=...) 把人工输入传回 interrupt() 调用点。

6 正确流程:修改状态后再恢复

正确的处理方式是:先用 graph.update_state() 修改状态,让中断条件不再满足,然后再恢复执行。

第一步:修改 state,消除中断条件

# 将 input 改为短字符串(5 个字符以内),让 step_2 的条件不再触发
graph.update_state(
    thread_config,
    {"input": "hi"},  # "hi" 只有 2 个字符,len("hi") = 2 ≤ 5
)
{'configurable': { 'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef6a434-06cf-6f1e-8002-0ea6dc69e075' }}

update_state 返回新的 checkpoint 配置,说明状态已成功写入。

第二步:恢复执行

# 传入 None 作为 input,表示"从上次中断处继续,不添加新输入"
for event in graph.stream(None, thread_config, stream_mode="values"):
    print(event)
{'input': 'hi'} ---Step 2--- {'input': 'hi'} ---Step 3--- {'input': 'hi'}

这次执行成功了!执行流程分析:

完整操作流程总结

动态断点的完整人机协作流程

1
启动执行graph.stream(initial_input, config)
2
interrupt() 触发:图自动暂停,stream 返回
3
检查中断原因:读取 state.tasks[0].interrupts[0].value
4
(可选)人工审核:根据中断原因决定下一步操作
5
修改 Stategraph.update_state(config, new_values),使中断条件不再成立
6
恢复执行graph.stream(None, config),pending 节点重新运行
两种恢复策略

如果要把人工输入作为 interrupt() 的返回值传回节点,应使用 Command(resume=...)。本例演示的是状态编辑流程:先用 update_state() 消除中断条件,再用 graph.stream(None, config) 让 pending 节点重新运行。

7 通过 LangGraph API 使用动态断点

在生产环境中,图通常部署在 LangGraph 服务端(通过 langgraph dev 启动本地开发服务器),客户端通过 LangGraph SDK 与其交互。动态断点在 API 场景下同样适用,操作逻辑与本地完全一致。

启动本地开发服务器

在模块的 /studio 目录下运行:

langgraph dev

服务启动后会显示:

- API: http://127.0.0.1:2024 - Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 - API Docs: http://127.0.0.1:2024/docs

通过 SDK 连接并运行图(触发动态断点)

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)
Receiving new event of type: metadata... {'run_id': '1ef6a43a-1b04-64d0-9a79-1caff72c8a89'} Receiving new event of type: values... {'input': 'hello world'} Receiving new event of type: values... {'input': 'hello world'}

流事件结束——图在 step_2 的 interrupt() 处暂停。

检查远程线程状态

current_state = await client.threads.get_state(thread['thread_id'])
print(current_state['next'])
['step_2']

远程修改状态,然后恢复

# 更新 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)
Receiving new event of type: metadata... {'run_id': '1ef64c33-fb34-6eaf-8b59-1d85c5b8acc9'} Receiving new event of type: values... {'input': 'hi!'} Receiving new event of type: values... {'input': 'hi!'}

图成功恢复并执行完毕(step_2 和 step_3 均正常执行)。

API 场景的真实价值

在生产系统中,图运行在服务器上,而"修改状态"和"恢复执行"的操作来自另一个服务(比如人工审核界面、消息队列、webhook 回调)。LangGraph SDK 提供了跨进程、跨服务的状态操作能力,让分布式的人机协作工作流成为可能。

8 静态断点 vs 动态断点:完整对比

两种断点机制各有适用场景,理解它们的差异有助于在实际项目中做出正确选择。

对比维度 静态断点(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() payload 设计建议

在生产系统中,建议向 interrupt() 传递结构化的字典而不是简单字符串,这样审核界面可以更好地解析和展示信息:

interrupt({
    "reason": "high_risk_content",
    "risk_score": 0.94,
    "flagged_text": state["generated_content"][:200],
    "suggested_action": "请人工审核后选择批准或拒绝"
})