培歌行学LangGraph(19):动态中断-让图停下来等你

0 阅读12分钟
  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 记住该记的事

一、什么是动态中断?

中断就是让你的图在执行过程中暂停,等外部输入(比如人类的审批、修改)之后再继续。

打个比方:

你写了一个自动写诗的图,写到一半停下来问用户:"这首诗行不行?要不要改?"用户点头了再继续。

这就是人在环(HITL) ——人和机器一起干活,机器干到关键步骤停下来等人拍板。

中断怎么用?

就两步:

  1. 在节点里调用 interrupt() :告诉图"停在这里,等消息"
  2. 恢复时传 Command(resume=...) :把用户输入传给图

二、中断的七大使用模式(完整代码+人工输入)

模式1:基础 HITL(人在环)

最简单的用法:图停下来问用户一句话,用户回答了再继续。

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver

class OverAllState(TypedDict):
    username: str

def node_a(state: OverAllState) -> OverAllState:
    username = interrupt("请输入您的姓名")
    return {"username": username}

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "basic_hitl"}}

# 第一步:触发中断
interrupt_res = graph.invoke({}, config=config)
print("中断信息:", interrupt_res)

# 第二步:人工输入(真正的 input 等待)
user_input = input("请输入您的姓名:")  # ← 这里真的停下来等用户打字

# 第三步:恢复运行
resumed_res = graph.invoke(Command(resume=user_input), config=config)
print("最终结果:", resumed_res)

运行过程:

中断信息: {'__interrupt__': [Interrupt(value='请输入您的姓名', id='...')]}
请输入您的姓名:小黄          ← 用户手动输入
最终结果: {'username': '小黄'}

模式2:多个并行中断

两个节点同时跑,各自都要问用户问题。

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver

class OverAllState(TypedDict):
    username: str
    age: int

def node_a(state: OverAllState) -> OverAllState:
    username = interrupt("请输入您的姓名")
    return {"username": username}

def node_b(state: OverAllState) -> OverAllState:
    age = interrupt("请输入您的年龄")
    return {"age": age}

builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "parallel_interrupt"}}

# 第一步:触发中断(两个并行中断)
interrupt_res = graph.invoke({}, config=config)
print("中断信息:", interrupt_res)

# 第二步:遍历所有中断,逐一让用户输入
resume_map = {}
for i in interrupt_res['__interrupt__']:
    user_input = input(f"{i.value}: ")  # ← 每个中断都等用户输入
    if "年龄" in i.value:
        resume_map[i.id] = int(user_input)  # 年龄转整数
    else:
        resume_map[i.id] = user_input

# 第三步:恢复运行
resumed_res = graph.invoke(Command(resume=resume_map), config=config)
print("最终结果:", resumed_res)

运行过程:

中断信息: {'__interrupt__': [Interrupt(value='请输入您的年龄', id='...'), Interrupt(value='请输入您的姓名', id='...')]}
请输入您的年龄: 19            ← 用户手动输入
请输入您的姓名: 小黄          ← 用户手动输入
最终结果: {'username': '小黄', 'age': 19}

模式3:审批模式

图停下来问用户"批不批准?",根据答案走不同分支。

from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={"thinking": {"type": "disabled"}}
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    is_approved: bool

def approve_node(state: OverAllState) -> Command[Literal["llm_node", "default_node"]]:
    is_approved = interrupt("是否同意调用模型生成诗句?")
    goto = "llm_node" if is_approved else "default_node"
    return Command(goto=goto, update={"is_approved": is_approved})

def llm_node(state: OverAllState) -> OverAllState:
    topic = state["topic"]
    res = model.invoke([HumanMessage(content=f"帮我写一首关于{topic}的七言绝句,只写诗句,不需要赏析")]).content
    return {"poem": res}

def default_node(state: OverAllState) -> OverAllState:
    return {"poem": "请求被拒绝"}

builder = StateGraph(state_schema=OverAllState)
builder.add_node("approve_node", approve_node)
builder.add_node("llm_node", llm_node)
builder.add_node("default_node", default_node)
builder.add_edge(START, "approve_node")
builder.add_edge("llm_node", END)
builder.add_edge("default_node", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "approval_mode"}}

# 第一次:触发审批中断
interrupt_res = graph.invoke({"topic": "菊花"}, config=config)
print("中断信息:", interrupt_res)

# 人工审批
user_input = input("是否同意调用模型?(y/n): ").strip().lower()  # ← 等用户输入
is_approved = (user_input == 'y')

# 恢复运行
approved_res = graph.invoke(Command(resume=is_approved), config=config)
print("审批结果:", approved_res)

# 第二次:再试一次,这次拒绝
config2 = {"configurable": {"thread_id": "approval_mode_2"}}
interrupt_res2 = graph.invoke({"topic": "牡丹花"}, config=config2)
print("\n中断信息:", interrupt_res2)

user_input2 = input("是否同意调用模型?(y/n): ").strip().lower()  # ← 等用户输入
is_approved2 = (user_input2 == 'y')

denied_res = graph.invoke(Command(resume=is_approved2), config=config2)
print("审批结果:", denied_res)

运行过程:

中断信息: {'topic': '菊花', '__interrupt__': [Interrupt(value='是否同意调用模型生成诗句?', id='...')]}
是否同意调用模型?(y/n): y    ← 用户手动输入
审批结果: {'topic': '菊花', 'poem': '《咏菊》\n西风猎猎卷霜华...', 'is_approved': True}

中断信息: {'topic': '牡丹花', '__interrupt__': [Interrupt(value='是否同意调用模型生成诗句?', id='...')]}
是否同意调用模型?(y/n): n    ← 用户手动输入
审批结果: {'topic': '牡丹花', 'poem': '请求被拒绝', 'is_approved': False}

模式4:审核与编辑模式

模型生成内容后,停下来让人类审核修改。

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from dotenv import load_dotenv
load_dotenv(override=True)

model = ChatDeepSeek(
    model="deepseek-v4-flash",
    extra_body={"thinking": {"type": "disabled"}}
)

class OverAllState(TypedDict):
    topic: str
    poem: str
    reviewed_poem: str

def llm_node(state: OverAllState) -> OverAllState:
    topic = state['topic']
    res = model.invoke([HumanMessage(content=f"帮我写一首关于{topic}的七言绝句,只给出诗句,不要赏析")]).content
    return {"poem": res}

def review_node(state: OverAllState) -> OverAllState:
    reviewed_poem = interrupt({
        "instruction": "请审核并修改大模型生成的七言绝句",
        "poem": state['poem']
    })
    return {"reviewed_poem": reviewed_poem}

builder = StateGraph(state_schema=OverAllState)
builder.add_node("llm_node", llm_node)
builder.add_node("review_node", review_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", "review_node")
builder.add_edge("review_node", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "review_mode"}}

# 触发中断
interrupt_res = graph.invoke({"topic": "布偶猫"}, config=config)
print("中断信息:", interrupt_res)

# 显示原始诗句
original_poem = interrupt_res['__interrupt__'][0].value['poem']
print("\n原始诗句:")
print(original_poem)

# 人工审核修改
user_input = input("\n请审核并修改诗句(直接回车保留原诗): ")  # ← 等用户输入
if not user_input.strip():
    user_input = original_poem  # 用户没改,保留原诗

# 恢复运行
reviewed_res = graph.invoke(Command(resume=user_input), config=config)
print("\n最终结果:")
print(reviewed_res)

运行过程:

中断信息: {'topic': '布偶猫', 'poem': '《布偶猫》\n冰眸玉骨雪团身...', '__interrupt__': [...]}

原始诗句:
《布偶猫》
冰眸玉骨雪团身,蓝影衔春卧月轮。
偶启朱唇呼入梦,云鬟一枕醉红尘。

请审核并修改诗句(直接回车保留原诗): 已审核,诗句没问题  ← 用户手动输入

最终结果:
{'topic': '布偶猫', 'poem': '《布偶猫》\n冰眸玉骨雪团身...', 'reviewed_poem': '已审核,诗句没问题'}

模式5:工具执行审批模式

调用外部工具之前,先问问用户同不同意。

from typing import Literal
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langgraph.graph.message import MessagesState
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage, ToolMessage
from langchain.tools import tool
from langchain_deepseek import ChatDeepSeek
from dotenv import load_dotenv
load_dotenv(override=True)

@tool
def get_weather(city: str) -> str:
    """查询指定城市的当日天气"""
    is_approved = interrupt({
        "action": "get_weather",
        "question": f"是否同意查询{city}的天气?"
    })
    if is_approved:
        return f"{city} 今天天气不错"
    else:
        return "用户拒绝查询天气"

tools_by_name = {"get_weather": get_weather}

model = ChatDeepSeek(model="deepseek-v4-flash")
model_with_tools = model.bind_tools([get_weather])

def llm_node(state: MessagesState) -> MessagesState:
    messages = state['messages']
    response = model_with_tools.invoke(messages)
    return {"messages": [response]}

def tool_node(state: MessagesState) -> MessagesState:
    last_msg = state['messages'][-1]
    tool_msgs = []
    for tool_call in last_msg.tool_calls:
        tool = tools_by_name[tool_call["name"]]
        tool_res = tool.invoke(tool_call["args"])
        tool_msg = ToolMessage(
            name=tool_call["name"],
            content=tool_res,
            tool_call_id=tool_call["id"]
        )
        tool_msgs.append(tool_msg)
    return {"messages": tool_msgs}

def router(state: MessagesState) -> Literal["tool_node", END]:
    if state['messages'][-1].tool_calls:
        return "tool_node"
    return END

builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "llm_node")

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "tool_approval"}}

# 触发中断(工具调用前)
interrupt_res = graph.invoke({"messages": [HumanMessage("今天北京天气如何?")]}, config=config)
print("中断信息:", interrupt_res['__interrupt__'])

# 人工确认
user_input = input("是否同意查询天气?(y/n): ").strip().lower()  # ← 等用户输入
is_approved = (user_input == 'y')

# 恢复运行
approved_res = graph.invoke(Command(resume=is_approved), config=config)
print("\n最终回复:")
for msg in approved_res['messages']:
    if hasattr(msg, 'pretty_print'):
        msg.pretty_print()

运行过程:

中断信息: [Interrupt(value={'action': 'get_weather', 'question': '是否同意查询北京的天气?'}, id='...')]
是否同意查询天气?(y/n): y    ← 用户手动输入

最终回复:
================================ Human Message =================================
今天北京天气如何?
================================== Ai Message ==================================
今天北京的天气不错哦!☀️

模式6:单节点串行中断

同一个节点里连续问好几个问题,一个一个来。

from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command

class OverAllState(TypedDict):
    username: str
    age: int
    gender: Literal["male", "female"]

def get_info_node(state: OverAllState) -> OverAllState:
    username = interrupt("请输入您的用户名:")
    age = interrupt("请输入您的年龄:")
    gender = interrupt("请输入您的性别:(male/female)")
    
    return {
        "username": username,
        "age": age,
        "gender": gender
    }

builder = StateGraph(state_schema=OverAllState)
builder.add_node("get_info_node", get_info_node)
builder.add_edge(START, "get_info_node")
builder.add_edge("get_info_node", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "serial_interrupt"}}

# 第一次中断:问用户名
username_res = graph.invoke({}, config=config)
print("中断信息:", username_res)

user_name = input("请输入您的用户名:")  # ← 等用户输入

# 第二次中断:问年龄
age_res = graph.invoke(Command(resume=user_name), config=config)
print("\n中断信息:", age_res)

user_age = input("请输入您的年龄:")  # ← 等用户输入

# 第三次中断:问性别
gender_res = graph.invoke(Command(resume=int(user_age)), config=config)
print("\n中断信息:", gender_res)

user_gender = input("请输入您的性别:(male/female): ")  # ← 等用户输入

# 最终恢复
final_res = graph.invoke(Command(resume=user_gender), config=config)
print("\n最终结果:", final_res)

运行过程:

中断信息: {'__interrupt__': [Interrupt(value='请输入您的用户名:', id='...')]}
请输入您的用户名:小黄          ← 用户手动输入

中断信息: {'__interrupt__': [Interrupt(value='请输入您的年龄:', id='...')]}
请输入您的年龄:15             ← 用户手动输入

中断信息: {'__interrupt__': [Interrupt(value='请输入您的性别:(male/female)', id='...')]}
请输入您的性别:(male/female): male  ← 用户手动输入

最终结果: {'username': '小黄', 'age': 15, 'gender': 'male'}

三、使用规范(避坑指南)

规范1:别用 try/catch 包 interrupt

# ❌ 错误:interrupt 是靠抛异常实现的,catch 了就失效了
def node_a(state):
    try:
        name = interrupt("请输入姓名")
    except Exception as e:
        print(e)

# ✅ 正确:interrupt 单独在外面
def node_a(state):
    name = interrupt("请输入姓名")
    try:
        fetch_data()  # 这里可以 catch
    except Exception as e:
        print(e)

规范2:别改变 interrupt 的顺序和数量

# ❌ 错误:根据条件跳过中断
def node_a(state):
    name = interrupt("请输入姓名")
    if state.get("needs_age"):  # 条件可能变化
        age = interrupt("请输入年龄")  # 有时触发,有时不触发
    city = interrupt("请输入城市")
    # 恢复时顺序乱了,数据对不上

# ✅ 正确:中断顺序固定
def node_a(state):
    name = interrupt("请输入姓名")
    age = interrupt("请输入年龄")
    city = interrupt("请输入城市")

规范3:别传复杂类型给 interrupt

# ❌ 错误:传函数
interrupt({"validator": validate_input})  # 函数不能 JSON 序列化

# ❌ 错误:传类实例
interrupt({"processor": DataProcessor()})  # 自定义类不能 JSON 序列化

# ✅ 正确:传基本类型或简单字典
interrupt("请输入姓名")
interrupt({"question": "请输入姓名", "hint": "中文名"})

规范4:中断前的副作用操作必须是幂等的

# ❌ 错误:中断前插入数据(不幂等)
def node_a(state):
    db.insert_log("pending_approval")  # 恢复时会再插一条
    approved = interrupt("是否批准?")

# ✅ 正确:用 upsert(幂等)
def node_a(state):
    db.upsert_log(status="pending_approval")  # 多次执行结果一样
    approved = interrupt("是否批准?")

# ✅ 正确:副作用放中断后面
def node_a(state):
    approved = interrupt("是否批准?")
    if approved:
        db.send_notification()  # 只执行一次

# ✅ 正确:副作用放另一个节点
def approval_node(state):
    approved = interrupt("是否批准?")
    return {"approved": approved}

def notify_node(state):
    if state["approved"]:
        db.send_notification()  # 独立节点,不受中断影响

四、面试题

面试题1:interrupt() 是怎么实现暂停的?

interrupt()通过抛出 GraphInterrupt异常来实现暂停。所以不能用 try/catch包裹它,否则异常被捕获,中断就失效了。

面试题2:中断恢复时,节点函数会重新执行吗?

:会。整个节点函数会从头重新运行。之前已经恢复的中断不会重复触发,因为检查点记住了历史 resume值。

面试题3:多个并行中断怎么恢复?

:用字典按中断 ID 分别传值:

resume_map = {
    "中断ID_1": "小黄",
    "中断ID_2": 19
}
graph.invoke(Command(resume=resume_map), config=config)

面试题4:中断前的副作用操作要注意什么?

:必须是幂等的,即执行多次和一次效果一样。否则中断恢复时会重复执行,导致数据重复。

面试题5:同一个超步里,一个任务中断了,其他并行任务会怎样?

:其他任务不受影响,会正常执行完毕。已完成任务的结果会被保存,恢复时复用。


中断就是给你的图装了个"暂停键"。

  • 需要人审批?停一下,等人按键
  • 需要人修改?停一下,等人打字
  • 需要人确认?停一下,等人点头

用好中断,你的图就不再是"闷头干到底"的自动化脚本,而是能和人类配合默契的好帮手。