文章目录
- [1. 概述](#1. 概述)
-
- [1.1 Human-in-the-Loop(人在回路)](#1.1 Human-in-the-Loop(人在回路))
- [1.2 Interrupts(执行中断)](#1.2 Interrupts(执行中断))
- [1.3 执行中断 VS 人在回路](#1.3 执行中断 VS 人在回路)
- [2. interruptBefore、interruptsAfter 模式](#2. interruptBefore、interruptsAfter 模式)
-
- [2.1 配置项说明](#2.1 配置项说明)
- [2.2 interruptsBefore 配置](#2.2 interruptsBefore 配置)
- [2.3 定义中断反馈结果](#2.3 定义中断反馈结果)
- [2.4 流程中断](#2.4 流程中断)
- [2.5 流程恢复](#2.5 流程恢复)
- [2.6 interruptsAfter 配置](#2.6 interruptsAfter 配置)
- [2.7 interruptBeforeEdge 配置](#2.7 interruptBeforeEdge 配置)
- [3. InterruptableAction 模式](#3. InterruptableAction 模式)
-
- [3.1 定义可中断节点](#3.1 定义可中断节点)
- [3.2 添加节点](#3.2 添加节点)
- [3.3 流程中断](#3.3 流程中断)
- [3.4 流程恢复](#3.4 流程恢复)
1. 概述
1.1 Human-in-the-Loop(人在回路)
Human-in-the-Loop 为智能体的工具调用 增加人工审核机制。当大模型拟执行存在风险的操作(如写入文件、执行 SQL 语句)时,该中间件可暂停流程执行,等待人工决策。
内置三种人工响应中断的决策方式:
| 决策类型 | 说明 | 适用场景示例 |
|---|---|---|
| ✅ 批准(approve) | 直接认可原操作,无修改执行 | 原样发送邮件草稿 |
| ✏️ 编辑(edit) | 修改工具调用参数后再执行 | 发送邮件前修改收件人 |
| ❌ 拒绝(reject) | 驳回工具调用,并在对话中附上拒绝理由 | 拒绝邮件草稿并说明改写建议 |
在之前我们介绍过 React 基于 HumanInTheLoopHook 模型后置钩子(模型生成响应后、工具调用执行前触发)实现,处理逻辑:
- 智能体调用大模型生成响应
- 中间件解析响应中的工具调用
- 若需人工介入,构造含操作请求、审核配置的
HITL请求并触发中断 - 智能体挂起,等待人工决策
- 根据人工决策:执行批准/编辑的调用、为拒绝操作生成工具消息、将人工回复直接作为工具返回结果,最终恢复流程执行
1.2 Interrupts(执行中断)
执行中断 允许在流程图的指定节点暂停执行,等待外部输入后再继续运行。该机制是实现人在回路 流程的核心,适用于需要外部人工介入才能继续执行的场景。触发中断时,Graph 会通过持久化层保存流程图完整状态,并无限等待直到手动恢复执行。
中断核心价值:暂停执行、等待外部输入
典型应用场景:
- 审批流程:执行高危操作前暂停(接口调用、数据库变更、金融交易)
- 多中断并行处理:单次调用批量恢复多个并行分支的中断
- 审核与编辑:人工审核、修改大模型输出或工具调用参数后再继续
- 工具调用中断:工具执行前暂停,人工审核编辑后再执行
- 输入校验:步骤流转前校验人工输入,非法则重新询问
Spring AI Alibaba Graph 提供了提供了两种方式来实现重点:
- 通过实现
InterruptableAction接口来控制中断时机,可以在任意时刻返回InterruptionMetadata来中断执行 - 编译
Graph时指定中断点(interruptBefore、interruptsAfter),在指定节点执行前后自动中断
两种中断模式对比:
| 特性 | InterruptionMetadata 模式 | interruptBefore\ interruptsAfter模式 |
|---|---|---|
| 中断时机 | 运行时动态决定 | 编译时预先定义 |
| 节点要求 | 需要实现 InterruptableAction 接口 |
普通节点即可 |
| 灵活性 | 高,可根据运行时状态动态中断 | 中等,需在编译时固定中断位置 |
| 配置复杂度 | 较高,需实现接口方法 | 低,仅配置节点名称即可 |
| 适用场景 | 需依据运行时业务状态动态判断是否中断 | 业务流程中已知、固定的中断点位 |
HITL规定了只能在工具调用前暂停 ,而Interrupts更灵活支持任意节点、任意时机、任意触发条件都能暂停
1.3 执行中断 VS 人在回路
| 对比维度 | 执行中断(Interrupts) | 人在回路(Human-in-the-Loop, HITL) |
|---|---|---|
| 核心定位 | Graph 底层技术原语、基础工具 | AI 流程的业务设计模式、人机协作规范 |
| 本质 | 底层实现恢复指令,实现暂停/恢复 | 受控的人机协作机制,封装了固定的审批逻辑(批准/编辑/驳回/应答) |
| 核心作用 | 在流程图任意位置暂停执行、保存状态,等待外部输入后恢复运行 | 将人纳入自动化流程,关键节点(多为工具调用)需人工审批/编辑后才能继续 |
| 灵活性 | 极高,可在任意节点、任意时机、任意条件下触发暂停 | 较低,仅针对工具调用场景,按预设规则触发暂停,决策方式固定 |
| 触发场景 | 调试断点、多轮对话等待用户输入、任意节点条件暂停(如风险判断)、非工具环节人工确认等 | 工具调用前审批(如执行SQL、写文件、发邮件)、高危操作把关、合规校验、工具调用参数审核修改 |
| 实现方式 | 执行中断机制,自定义暂停时机和恢复逻辑 | 集成 HITL 钩子,配置 interrupt_on 定义需审批的工具及允许的决策类型 |
| 依赖关系 | 独立技术能力,不依赖 HITL |
依赖 Interrupts 实现暂停功能,是 Interrupts 的业务化封装 |
| 核心用途 | 满足所有需要暂停流程的场景,通用且灵活 | 生产级安全场景,重点管控工具调用,保障流程合规、安全 |
一句话总结:
- Interrupts :
Graph的底层暂停/恢复技术,万能工具 - HITL :基于
Interrupts的人机协作安全模式,专用于执行时人工审批
2. interruptBefore、interruptsAfter 模式
在编译 Graph 时提前指定中断点,在指定节点执行前后自动中断。这种方式适合已知的中断点,配置简单直接。
优势:
- 配置简单:只需在编译配置中指定中断点
- 无需修改节点:普通节点即可,不需要实现特殊接口
- 明确的中断点:中断位置在编译时确定,易于理解和维护
2.1 配置项说明
CompileConfig 中断配置项说明:
| 配置项 | 类型 | 作用 | 示例 |
|---|---|---|---|
interruptsBefore |
Set<String> |
在指定节点执行前中断 | interruptBefore("llm_node") |
interruptsAfter |
Set<String> |
在指定节点执行后中断 | interruptAfter("tool_node") |
interruptBeforeEdge |
boolean |
恢复执行后动态计算下一个节点(配合 interruptsAfter) |
interruptBeforeEdge(true) |
示例:
java
CompileConfig config = CompileConfig.builder()
// 方式1:执行前中断
.interruptBefore("approval_node", "review_node")
// 方式2:执行后中断
.interruptAfter("tool_call_node")
// 方式3:边评估前中断(需配合 interruptsAfter)
.interruptAfter("decision_node")
.interruptBeforeEdge(true)
.saverConfig(SaverConfig.builder().register(MemorySaver.builder().build()).build())
.build();
StateGraph graph = new StateGraph(...)
.compile(config);
2.2 interruptsBefore 配置
CompileConfig 提供了两种【节点执行前触发中断】的配置方法:
java
/**
* 从集合中设置【节点执行前触发】的多个中断点
* @param interruptsBefore 存储中断点标识的字符串集合
* @return 当前构建器实例,用于方法链式调用
*/
public Builder interruptsBefore(Collection<String> interruptsBefore) {
// 将集合转换为不可修改的Set集合,赋值给中断点配置
this.config.interruptsBefore = interruptsBefore.stream().collect(Collectors.toUnmodifiableSet());
return this;
}
/**
* 通过可变参数方式,设置【节点执行前触发】的单个/多个中断点
* @param interruptBefore 一个或多个表示中断点的字符串
* @return 当前构建器实例,用于方法链式调用
*/
public Builder interruptBefore(String... interruptBefore) {
// 将可变参数转换为不可变Set集合,赋值给中断点配置
this.config.interruptsBefore = Set.of(interruptBefore);
return this;
}
节点名称就是我们在添加节点时指定的名称:
java
workflow.addNode("read_email", readEmail)
ReactAgent 只有模型、工具节点,是在 ReactAgent#initGraph 方法中定义的:
java
graph.addNode(AGENT_MODEL_NAME, node_async(this.llmNode));
if (hasTools) {
graph.addNode(AGENT_TOOL_NAME, node_async(this.toolNode));
}
常量定义在 RunnableConfig 类中:
java
public static final String AGENT_MODEL_NAME = "_AGENT_MODEL_";
public static final String AGENT_TOOL_NAME = "_AGENT_TOOL_";
自定义状态图、ReactAgent 都需要通过 CompileConfig 配置中断节点,这里的配置标识在 classify_intent 节点执行前进行中断:
java
// 自定义状态图
CompileConfig compileConfig = CompileConfig.builder()
.saverConfig(saverConfig)
.interruptBefore("classify_intent")
.store(new MemoryStore())
.build();
CompiledGraph compiledGraph = workflow.compile(compileConfig);
// ReactAgent
ReactAgent chatAgent = ReactAgent.builder()
.name("email-chat-agent")
.observationRegistry(observationRegistry)
.compileConfig(compileConfig)
.enableLogging(true)
.tools()
.saver(new MemorySaver())
.model(chatModel)
.instruction("你是一个邮件处理助手,可以帮助用户处理邮件分类、文档搜索、Bug追踪和回复起草等问题。请用中文回答用户的问题。")
.build();
2.3 定义中断反馈结果
在 HITL 中定义了三种工具审批结果:
java
// ==================== FeedbackResult 枚举 ====================
/**
* FeedbackResult - 工具审批结果枚举
*
* <p>表示用户对工具调用的审批决定。
*/
public enum FeedbackResult {
/**
* 已批准 - 允许执行工具
*/
APPROVED,
/**
* 已拒绝 - 拒绝执行工具
*/
REJECTED,
/**
* 已编辑 - 用户修改了工具参数后批准
*/
EDITED;
}
Interrupts 更加灵活,我们可以自定义多种处理结果,比如在以上三种基础上加:
| 决策类型 | 说明 | 适用场景示例 |
|---|---|---|
| 💬 回复(respond) | 跳过节点执行,人工输入内容直接作为节点返回结果 | 直接回复「询问用户」类提示 |
自定义中断反馈结果枚举类:
java
public enum InterruptFeedbackResult {
APPROVED("批准", "APPROVED"),
EDITED("编辑", "EDITED"),
REJECTED("拒绝", "REJECTED"),
RESPOND("回复", "RESPOND");
private final String name;
private final String code;
InterruptFeedbackResult(String name, String code) {
this.name = name;
this.code = code;
}
public String getName() {
return name;
}
public String getCode() {
return code;
}
public static InterruptFeedbackResult fromCode(String code) {
for (InterruptFeedbackResult action : values()) {
if (action.getCode().equals(code)) {
return action;
}
}
return null;
}
}
2.4 流程中断
执行前中断执行逻辑:
- 流程在
interruptBefore调用前精准挂起 - 检查点器保存完整状态,支持后续恢复;生产环境需使用数据库等持久化检查点
- 返回
InterruptionMetadata数据,我们需要根据输出类型进行处理
interruptBefore 模式下只要执行 Graph 到中断节点前,一定会执行自动中断返回 InterruptionMetadata 数据,我们需要根据输出类型进行处理:
- 如果是
InterruptionMetadata类型说明流程暂停了 - 检测到中断点时,输出相关处理页面
部分处理代码:
java
return stream
.map(output -> {
String nodeName = output.node();
OverAllState state = output.state();
boolean isInterrupted = output instanceof InterruptionMetadata;
StringBuilder result = new StringBuilder();
// 检测中断点
if (isInterrupted) {
result.append("⏸️ 执行已中断,等待审核...\n");
result.append("节点: ").append(nodeName).append("\n");
result.append("sessionId: ").append(sessionId).append("\n");
result.append("\n请选择审核操作:\n");
result.append("<div class=\"action-buttons\">\n");
result.append(" <button class=\"action-btn approved-btn\" onclick=\"handleInterruption('APPROVED')\">");
result.append("✅ 批准 - 继续执行</button>\n");
result.append(" <button class=\"action-btn rejected-btn\" onclick=\"handleInterruption('REJECTED')\">");
result.append("❌ 拒绝 - 终止执行</button>\n");
result.append(" <button class=\"action-btn edited-btn\" onclick=\"showEditStateForm()\">");
result.append("✏ 编辑 - 修改状态后继续</button>\n");
result.append("</div>\n");
result.append("<div id=\"editStateForm\" style=\"display:none;margin-top:10px;\">\n");
result.append(" <textarea id=\"editedStateInput\" placeholder='输入JSON格式的状态更新,例如:{\"draft_response\":\"修改后的回复\"}' style=\"width:100%;height:80px;padding:8px;border:1px solid #ddd;border-radius:8px;resize:vertical;\"></textarea>\n");
result.append(" <button class=\"action-btn confirm-edited-btn\" onclick=\"handleInterruption('EDITED')\" style=\"margin-top:8px;\">确认修改并继续</button>\n");
result.append("</div>\n");
// 输出当前状态信息供审核
state.value("classification").ifPresent(v -> {
result.append("\n分类结果: ").append(v).append("\n");
});
state.value("draft_response").ifPresent(v -> {
result.append("\n回复草稿: ").append(v).append("\n");
});
}
InterruptionMetadata 数据:

前端显示效果:

2.5 流程恢复
中断暂停后,通过 RunnableConfig 携带恢复元数据再次调用流程图即可恢复,节点据此继续执行业务逻辑。
恢复中断关键要点:
- 恢复时必须使用触发中断时相同的
thread_id - 使用
RunnableConfig.builder()构建HUMAN_FEEDBACK元数据 - 如果存在
HUMAN_FEEDBACK,流程会继续执行
人工批准恢复执行示例:
java
// 获取中断后的当前状态(参考标准中断恢复流程)
var currentState = emailAgentGraph.getState(config);
Map<String, Object> stateData = currentState.state().data();
switch (action) {
case APPROVED:
// 人工批准:恢复执行
RunnableConfig approvedResumeConfig = RunnableConfig.builder()
.threadId(sessionId)
.resume() // 构建 `HUMAN_FEEDBACK` 元数据
.build();
// 继续执行 Graph(使用之前的状态)
stream = emailAgentGraph.stream(stateData, approvedResumeConfig);
log.info("Approved - updated state and resuming graph execution for sessionId: {}", sessionId);
break;
如果是人工拒绝,可以在状态中添加一个拒绝标识,会调用检查点进行更新和持久化,下次再执行这个会话时,可以提示当前会话流程已终止:
java
case REJECTED:
// 人工拒绝:更新状态标记拒绝后终止执行
emailAgentGraph.updateState(
config,
Map.of("human_feedback", "rejected"),
null
);
log.info("Rejected - terminating execution for sessionId: {}", sessionId);
return Flux.just("❌ 执行已被拒绝终止。");
如果是人工编辑,是需要将状态中的数据展示给用户进行修改,再使用修改后的状态恢复执行,部分示例:
java
case EDITED:
// 人工编辑:合并用户更新与审核标记后恢复执行
// 用户修改后的状态
@SuppressWarnings("unchecked")
Map<String, Object> stateUpdate = (Map<String, Object>) request.get("stateUpdate");
if (stateUpdate == null) {
stateUpdate = Map.of();
}
// 构建运行时配置
RunnableConfig resumeWithEditConfig = RunnableConfig.builder()
.threadId(sessionId)
.resume()
.build();
// 使用更新后的状态执行
stream = emailAgentGraph.stream(stateUpdate, resumeWithEditConfig);
log.info("Edited state and resuming for sessionId: {}, stateUpdate: {}", sessionId, stateUpdate);
break;
2.6 interruptsAfter 配置
interruptsAfter 用于配置【节点执行后】执行中断,指定节点执行完成结束后再暂停流程。
适用场景:
- 文案生成、合同解析、内容抽取等节点跑完,暂停供人工校对、修改;
- 节点产出业务结果后,需要外部系统回调、消费数据,再继续往下走;
- 子图 / 子流程执行完毕后暂停,等待外部指令再进入主流程下一环节;
- 数据加工、统计计算节点执行完,需先落库、发通知,再手动恢复续跑。
示例,定义classify_intent (邮件分类)节点执行后进行中断:
java
CompileConfig compileConfig = CompileConfig.builder()
.saverConfig(saverConfig)
.interruptAfter("classify_intent")
.store(new MemoryStore())
.build();
输入测试:

2.7 interruptBeforeEdge 配置
中断时只暂停不规划,恢复时再动态算下一步,专门为「动态条件路由边」设计,等人工改完数据状态后,再让流程做条件判断,保证路由正确。
对配置了 interruptsAfter 的节点,interruptBeforeEdge 可以配置:
false(默认值):中断后,直接计算下一个节点true:中断后,不提前计算下一个业务节点,只把nextNodeId标记为__INTERRUPTED__,恢复后才根据当前状态重新计算真正的下一个节点
配置示例:
java
CompileConfig compileConfig = CompileConfig.builder()
.saverConfig(saverConfig)
.interruptAfter("read_email")
.interruptBeforeEdge(true)
.store(new MemoryStore())
.build();
如果有【邮件读取】、【邮件分类】两个节点,当配置了【邮件读取】配置了执行后中断时,并开启了 interruptBeforeEdge = true ,下一个节点设置为(__INTERRUPTED__),而不是【邮件分类】:

【邮件读取】执行完成后会进行中断,在恢复执行时,会再将下一个节点设置为本身应该执行的节点(实时计算):

3. InterruptableAction 模式
InterruptionMetadata 模式允许节点在运行时动态决定是否需要中断,提供了最大的灵活性。节点通过实现 InterruptableAction 接口,可以在任意时刻返回 InterruptionMetadata 来中断执行。
优势:
- 灵活性强:可以在任意节点根据运行时状态决定是否中断
- 动态控制:中断逻辑由节点自身控制,不需要提前配置
- 状态感知:可以根据当前状态动态决定是否需要等待用户输入
3.1 定义可中断节点
在之前的邮件处理案例中,我们使用 interruptBefore 模式配置了一个人工审核节点:
java
public class HumanReviewNode implements NodeAction {
private static final Logger log = LoggerFactory.getLogger(HumanReviewNode.class);
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
EmailClassification classification = state.value("classification")
.map(v -> (EmailClassification) v)
.orElse(new EmailClassification());
// 准备审核数据
@SuppressWarnings("unchecked")
Map<String, Object> reviewData = Map.of(
"email_id", state.value("email_id").map(v -> (String) v).orElse(""),
"original_email", state.value("email_content").map(v -> (String) v).orElse(""),
"draft_response", state.value("draft_response").map(v -> (String) v).orElse(""),
"urgency", classification.getUrgency(),
"intent", classification.getIntent(),
"action", "请审核并批准/编辑此响应"
);
log.info("Waiting for human review: {}", reviewData);
// 返回审核数据和下一个节点
// 注意:在 interruptBefore 模式下,此节点在人工输入后才会执行
return Map.of(
"review_data", reviewData,
"status", "waiting_for_review",
"next_node", "send_reply"
);
}
}
将其改造为使用 InterruptableAction 实现中断,不需要 interruptBefore 配置:
java
public class InterruptableHumanReviewNode implements AsyncNodeActionWithConfig, InterruptableAction {
private static final Logger log = LoggerFactory.getLogger(InterruptableHumanReviewNode.class);
private final String nodeId;
public InterruptableHumanReviewNode(String nodeId) {
this.nodeId = nodeId;
}
/**
* 判断是否需要中断执行
* @param nodeId 当前节点ID
* @param state 当前状态
* @param config 运行配置
* @return 如果需要中断返回 InterruptionMetadata,否则返回 empty
*/
@Override
public Optional<InterruptionMetadata> interrupt(String nodeId, OverAllState state, RunnableConfig config) {
// 检查是否已经有人工反馈
boolean hasHumanFeedback = state.value("human_feedback").isPresent();
// 也可以根据检查用户输入的信息是否完整进行判断
if (!hasHumanFeedback) {
// 没有人工反馈,需要中断等待人工输入
log.info("Node [{}] requires human review, interrupting execution...", nodeId);
// 准备审核数据
EmailClassification classification = state.value("classification")
.map(v -> (EmailClassification) v)
.orElse(new EmailClassification());
Map<String, Object> reviewData = Map.of(
"email_id", state.value("email_id").map(v -> (String) v).orElse(""),
"original_email", state.value("email_content").map(v -> (String) v).orElse(""),
"draft_response", state.value("draft_response").map(v -> (String) v).orElse(""),
"urgency", classification.getUrgency(),
"intent", classification.getIntent(),
"action", "请审核并批准/编辑此响应,完成后设置 human_feedback 字段"
);
// 创建中断元数据对象,包含审核数据供外部展示
InterruptionMetadata interruptionMetadata = InterruptionMetadata.builder(nodeId, state)
.addMetadata("review_data", reviewData)
.addMetadata("interruption_type", "HUMAN_REVIEW_REQUIRED")
.build();
return Optional.of(interruptionMetadata);
}
// 如果已经有 human_feedback,继续执行节点逻辑
log.info("Human feedback received, resuming execution of node [{}]", nodeId);
return Optional.empty();
}
/**
* 节点实际执行逻辑(收到人工反馈后异步执行)
* @param state 当前状态
* @param config 运行配置
* @return 更新后的状态(CompletableFuture 包装)
*/
@Override
public CompletableFuture<Map<String, Object>> apply(OverAllState state, RunnableConfig config) {
// 获取人工审核结果
String humanFeedback = state.value("human_feedback")
.map(v -> (String) v)
.orElse("approved");
// 获取原始邮件分类
EmailClassification classification = state.value("classification")
.map(v -> (EmailClassification) v)
.orElse(new EmailClassification());
// 根据审核结果决定最终响应
String finalResponse;
String status;
if ("approved".equalsIgnoreCase(humanFeedback)) {
finalResponse = state.value("draft_response").map(v -> (String) v).orElse("");
status = "review_approved";
log.info("Human review approved, using draft response");
} else if (humanFeedback.startsWith("edit:")) {
finalResponse = humanFeedback.substring(5);
status = "review_edited";
log.info("Human review edited response");
} else {
finalResponse = "Request rejected by reviewer";
status = "review_rejected";
log.info("Human review rejected request");
}
Map<String, Object> result = Map.of(
"final_response", finalResponse,
"review_status", status,
"reviewer_feedback", humanFeedback,
"next_node", "send_reply"
);
return CompletableFuture.completedFuture(result);
}
}
3.2 添加节点
只需要将可中断的节点添加到状态图中即可:
java
var humanReview = new InterruptableHumanReviewNode("human_review");
workflow..addNode("human_review", humanReview)
3.3 流程中断
和上面的模式一样,通过 InterruptionMetadata 输出类型判断是否中断。
3.4 流程恢复
和上面的模式一样,通过会话 ID、修改后的状态执行恢复。