【LangGraph新手村系列】(4)人机协作中断:让 Agent 在关键节点停下来等你

简介: 解决"全自动Agent无法审查"的问题。在tools节点前插入interrupt_before中断点,改用stream流式运行实现暂停,通过get_state().next判断中断位置,审查工具调用后用Command(resume=True)恢复执行,实现人机协作的三重干预:审查放行、修改参数、拒绝执行。

第四章 人机协作中断:让 Agent 在关键节点停下来等你

"完全自动化的 Agent 是危险的——你永远不知道它会调用什么工具、花掉多少钱、泄露什么数据。真正可控的 Agent 知道在哪里停下来,等人类点头。"

一、问题

前三章的图是全自动的:模型自动思考、自动调用工具、自动返回结果。但放到真实业务中,"全自动"是双刃剑:

  • 不可审查的决策:模型决定调用 send_emaildelete_user,没有设闸就会真的执行。
  • 不可控的副作用:第三方 API 可能花钱、可能触发不可逆操作、可能泄露隐私。
  • 调试困难:自动执行的流程出错后只能翻日志。如果能暂停在"执行工具"之前,排查会快得多。
  • 信任危机:用户不信任一言不合就自动执行的系统。

核心矛盾:我们解决了"状态怎么流转"和"状态怎么持久化",但没有解决"流程怎么被人类控制"。Agent 需要暂停点

LangGraph 把这个能力叫做 Human-in-the-loop(HIL)——在图的任意节点之前或之后插入"中断点",执行流到达时停下来,暴露状态给调用方,等待人类介入后再继续。

二、解决方案

三件事:

  1. 声明中断点:编译图时告诉 LangGraph,在 tools 节点前暂停。
  2. 改用 stream 运行:用 stream 把执行变成"可暂停的直播"。
  3. 实现审查与恢复:用 get_state() 查看断点,用 input() 收集用户意愿,用 Command(resume=True) 继续。
START → agent → [should_continue 返回 "tools"] → [中断点] → 人类审查 → tools → agent → END
                                              ↑
                                        interrupt_before=["tools"]

三、工作原理

1. 为什么 invoke 不行?

final_state = app.invoke(inputs, config)
# 跑到 tools 前被 interrupt 拦住 → 抛出 GraphInterrupt → 程序崩溃

invoke 的设计是"一次性跑完并给我最终结果"。遇到中断时它不知道该怎么返回,只能抛异常。人机协作需要的是"平静地停下来"。

2. stream:把"黑盒"变成"直播"

# ❌ 不遍历 = 图根本不跑
app.stream(inputs, config, stream_mode="values")

# ✅ for 循环驱动生成器,图一步步执行
for chunk in app.stream(inputs, config, stream_mode="values"):
    pass  # pass = 不看中间战报,但要遍历完

stream 返回生成器,Python 生成器是"懒"的——你不索要下一个值,它就原地不动。遇到中断点时,stream 平静结束,不抛异常,你可以在循环结束后查看状态。

3. stream_mode="values"

每个 chunk 是当前完整状态快照

# 第一次 stream:跑完 agent,在中断点停住
{
   'messages': [HumanMessage(...), AIMessage(tool_calls=[...])], ...}

# 第二次 stream(Command(resume=True)):继续跑完剩余流程
{
   'messages': [..., ToolMessage(...), AIMessage("上海现在30℃...")], ...}

4. 判断中断:get_state().next

state = app.get_state(config)
if state.next:  # 空 () 表示跑完;('tools',) 表示被拦住了
    print(f">>> 中断:图即将进入节点 {state.next}")
属性 含义
state.values 当前所有状态字段的值
state.next 待执行的节点名元组。空=跑完,有值=中断暂停

5. 审查工具调用请求

last_message = state.values["messages"][-1]
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
    for tc in last_message.tool_calls:
        print(f"工具名: {tc['name']}, 参数: {tc['args']}")

中断发生时,模型已经输出了 tool_calls,但工具函数还没有执行。这是人类介入的最佳时机。

6. Command(resume=True) 的含义

# 用户同意,继续执行
for chunk in app.stream(Command(resume=True), config, stream_mode="values"):
    pass

Command(resume=True) 告诉 LangGraph:"从中断点继续执行,被拦住的节点(tools)自己去状态里读取所需数据,继续跑。"

resume 参数是"喂给被中断节点的新输入"。但 ToolNode 是全自动的——它会自己从 state["messages"][-1].tool_calls 读取工具调用指令,不需要你额外喂数据。所以 True 就是"我同意,继续"。

7. 完整执行流程

# 第1步:启动 stream,图跑到中断点停住
for chunk in app.stream(inputs, config, stream_mode="values"):
    pass

# 第2步:检查是否真的被拦住了
state = app.get_state(config)
if state.next:
    # 第3步:审查
    print("模型想调用:", state.values["messages"][-1].tool_calls)
    user_input = input("是否同意?(y/n): ")

    # 第4步:恢复或终止
    if user_input.lower() == "y":
        for chunk in app.stream(Command(resume=True), config, stream_mode="values"):
            pass
    else:
        print("用户拒绝执行")

# 第5步:输出最终结果
final = app.get_state(config)
print(final.values["messages"][-1].content)

四、核心组件一览

组件 类 / 函数 / 参数 作用
中断声明 interrupt_before=["tools"] 编译图时声明:执行流进入 tools 节点前自动暂停
流式执行 app.stream(...) 返回生成器,遍历驱动图执行,遇中断平静结束
战报格式 stream_mode="values" 每次 chunk 返回当前完整状态快照
状态读取 app.get_state(config) 获取指定 thread 的最新检查点 + 中断信息
断点判断 state.next 元组,空表示跑完,有元素表示在中断点暂停
恢复命令 Command(resume=True) 从中断点继续执行被拦住的节点
用户输入 input() 阻塞等待用户键盘输入,实现真正的人机协作

五、试一试

前置条件

.env 配置(同第三章):

OPENAI_API_KEY=sk-...
POSTGRES_URI=postgresql://postgres:你的密码@localhost:5432/langgraph_demo

执行脚本

cd demo-01
python langgraph-04.py

预期交互:

>>> [人机协作中断] 图即将进入节点: ('tools',)
模型请求调用以下工具:
  - 工具名: search
    参数: {'query': '上海天气'}

是否同意执行?(y/n): y
=== 最终回答 ===
上海目前的天气情况:温度30℃,有雾。

验证中断机制

  1. 输入 n 拒绝:程序打印"用户拒绝执行"并结束,工具函数不会被执行。
  2. 查看检查点:用 SQL 查询 checkpoints 表,能看到步骤数只累加到中断前的那一轮——拒绝后不会生成新的完整快照。
  3. 脏数据问题:如果之前运行失败(比如流被意外中断),同一个 thread_id 可能残留"模型喊了 tool_calls 但没等工具结果"的脏状态,下次运行会报 400 错误。解决方案:换个新 thread_id 或清理数据库。

六、完整代码差异

与第三章相比,只有执行逻辑变了。图的定义、状态、工具、模型、检查点——全部复用。

from langgraph.types import Command  # 新增导入

# 编译图时声明中断点
app = workflow.compile(
    checkpointer=checkpointer,
    interrupt_before=["tools"]
)

thread_id = "chapter-5"
config = {
   "configurable": {
   "thread_id": thread_id}}

inputs = {
   
    "messages": [HumanMessage(content="你好,我想查询一下上海的天气")],
    "temperature_unit": "摄氏度"
}

# 第1步:启动,跑到中断点停住
for chunk in app.stream(inputs, config, stream_mode="values"):
    pass

# 第2步:检查中断
state = app.get_state(config)
if state.next:
    print(f">>> [人机协作中断] 图即将进入节点: {state.next}")

    last_message = state.values["messages"][-1]
    if hasattr(last_message, "tool_calls") and last_message.tool_calls:
        print("模型请求调用以下工具:")
        for tc in last_message.tool_calls:
            print(f"  - 工具名: {tc['name']}")
            print(f"    参数: {tc['args']}")

    # 第3步:人工确认
    user_input = input("是否同意执行?(y/n):")
    if user_input.lower() == "y":
        # 第4步:恢复执行
        for chunk in app.stream(Command(resume=True), config, stream_mode="values"):
            pass
    else:
        print("用户拒绝执行")

# 第5步:输出最终回答
final_state = app.get_state(config)
print("\n=== 最终回答 ===")
print(final_state.values["messages"][-1].content)

七、其他

三种人工干预模式

LangGraph 的中断点不是只能"看和放行",你可以根据业务需求选择三种干预深度:

模式 操作 代码体现
审查放行 只看不改,用户确认后继续 Command(resume=True)
修改参数 审查后改掉工具调用的参数 app.update_state(config, {...}) + Command(resume=True)
拒绝执行 不让工具跑,塞一条假结果让模型重新回答 app.update_state(config, {"messages": [...]}) + Command(resume=True)

interrupt_before vs interrupt_after

参数 中断时机 适用场景
interrupt_before=["tools"] 进入节点之前 审查模型想调用什么工具,人工确认后再执行
interrupt_after=["tools"] 离开节点之后 审查工具返回的结果,人工修正后再交给模型

可以同时使用:interrupt_before=["tools"], interrupt_after=["tools"],实现"进入前审查请求 + 离开后审查结果"的双重控制。

关于 resume 参数的版本差异

理论上 Command(resume=None) 表示"不给被中断节点额外输入,让它自己读状态"。但部分 LangGraph 版本在处理 None 时存在内部变量未初始化的 bug,会报 UnboundLocalError: cannot access local variable 'resume_is_map'。如果你的版本遇到这个问题,改用 Command(resume=True) 即可。

脏数据陷阱

每次实验建议用新的 thread_id,或在数据库中清理旧记录:

DELETE FROM checkpoints WHERE thread_id = 'chapter-5';
DELETE FROM checkpoint_writes WHERE thread_id = 'chapter-5';
DELETE FROM checkpoint_blobs WHERE thread_id = 'chapter-5';

原因是:如果之前某次运行流被意外中断(比如程序崩溃),状态里可能残留一条"模型喊了 tool_calls 但还没等工具结果"的消息。下次恢复运行时,模型 API 会拒绝这种不完整的历史,报 400 - tool_call_ids did not have response messages

目录
相关文章
|
7天前
|
人工智能 JSON 供应链
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
LucianaiB分享零成本畅用JVS Claw教程(学生认证享7个月使用权),并开源GeoMind项目——将JVS改造为科研与产业地理情报可视化AI助手,支持飞书文档解析、地理编码与腾讯地图可视化,助力产业关系图谱构建。
23420 7
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
|
17天前
|
缓存 人工智能 自然语言处理
我对比了8个Claude API中转站,踩了不少坑,总结给你
本文是个人开发者耗时1周实测的8大Claude中转平台横向评测,聚焦Claude Code真实体验:以加权均价(¥/M token)、内部汇率、缓存支持、模型真实性及稳定性为核心指标。
6108 25
|
11天前
|
人工智能 缓存 BI
Claude Code + DeepSeek V4-Pro 真实评测:除了贵,没别的毛病
JeecgBoot AI专题研究 把 Claude Code 接入 DeepSeek V4Pro,跑完 Skills —— OA 审批、大屏、报表、部署 5 大实战场景后的真实体验 ![](https://oscimg.oschina.net/oscnet/up608d34aeb6bafc47f
3893 11
Claude Code + DeepSeek V4-Pro 真实评测:除了贵,没别的毛病
|
12天前
|
人工智能 JSON BI
DeepSeek V4 来了!超越 Claude Sonnet 4.5,赶紧对接 Claude Code 体验一把
JeecgBoot AI专题研究 把 Claude Code 接入 DeepSeek V4Pro 的真实体验与避坑记录 本文记录我将 Claude Code 对接 DeepSeek 最新模型(V4Pro)后的真实体验,测试了 Skills 自动化查询和积木报表 AI 建表两个场景——有惊喜,也踩
4723 13
|
29天前
|
人工智能 自然语言处理 安全
Claude Code 全攻略:命令大全 + 实战工作流(建议收藏)
本文介绍了Claude Code终端AI助手的使用指南,主要内容包括:1)常用命令如版本查看、项目启动和更新;2)三种工作模式切换及界面说明;3)核心功能指令速查表,包含初始化、压缩对话、清除历史等操作;4)详细解析了/init、/help、/clear、/compact、/memory等关键命令的使用场景和语法。文章通过丰富的界面截图和场景示例,帮助开发者快速掌握如何通过命令行和交互界面高效使用Claude Code进行项目开发,特别强调了CLAUDE.md文件作为项目知识库的核心作用。
22654 64
Claude Code 全攻略:命令大全 + 实战工作流(建议收藏)