Spring AI Alibaba 人工介入实现指南
基于 Spring AI Alibaba Agent Framework 的 Human-in-the-Loop 功能实现
一、官方文档
二、什么是人工介入?
人工介入(Human-in-the-Loop) 是指在程序运行过程中,当检测到某些条件满足时:
- 暂停 程序的执行
- 等待 人工干预
- 人工干预完成后,再继续程序的执行
三、实现方式对比
| 方式 | 优点 | 缺点 |
|---|---|---|
invokeAndGetOutput |
只需一个方法即可实现;官方推荐 | 不支持流式输出,返回较慢 |
| 流式输出 | 支持 SSE 实时推送,用户体验好 | 需要额外处理中断和恢复逻辑 |
四、方式一:invokeAndGetOutput 实现
4.1 核心代码
java
try {
// 1. 执行调用
Optional<NodeOutput> result = reactAgent.invokeAndGetOutput(message, runnableConfig);
// 2. 检查中断并处理
if (result.isPresent() && result.get() instanceof InterruptionMetadata) {
InterruptionMetadata interruptionMetadata = (InterruptionMetadata) result.get();
log.warn("检测到中断 - 需要人工审批");
List<InterruptionMetadata.ToolFeedback> toolFeedbacks = interruptionMetadata.toolFeedbacks();
toolFeedbacks.forEach(toolFeedback -> {
log.warn("工具: {}", toolFeedback.getName());
log.warn("参数: {}", toolFeedback.getArguments());
log.warn("描述: {}", toolFeedback.getDescription());
log.warn("结果: {}", toolFeedback.getId());
});
// 3. 构建批准反馈
InterruptionMetadata.Builder feedbackBuilder = InterruptionMetadata.builder()
.nodeId(interruptionMetadata.node())
.state(interruptionMetadata.state());
// 4. 对每个工具调用设置批准决策
interruptionMetadata.toolFeedbacks().forEach(toolFeedback -> {
InterruptionMetadata.ToolFeedback approvedFeedback =
InterruptionMetadata.ToolFeedback.builder(toolFeedback)
.result(InterruptionMetadata.ToolFeedback.FeedbackResult.APPROVED)
.build();
feedbackBuilder.addToolFeedback(approvedFeedback);
});
InterruptionMetadata approvalMetadata = feedbackBuilder.build();
// 5. 使用批准决策恢复执行
RunnableConfig resumeConfig = RunnableConfig.builder()
.threadId(sessionId) // 相同的线程ID以恢复暂停的对话
.addMetadata(RunnableConfig.HUMAN_FEEDBACK_METADATA_KEY, approvalMetadata)
.build();
// 6. 第二次调用以恢复执行
log.info("------- 第二次调用以恢复执行 -------");
Optional<NodeOutput> finalResult = reactAgent.invokeAndGetOutput("", resumeConfig);
InterruptionMetadata.ToolFeedback firstFeedback = toolFeedbacks.stream()
.findFirst()
.orElse(null);
if (finalResult.isPresent()) {
log.warn("第二次调用结果: {}", finalResult.get().state());
if (firstFeedback != null) {
String description = firstFeedback.getDescription();
String parsedData = NodeOutputUtil.parseToolDescription(description);
log.warn("解析后的描述: {}", parsedData);
return ChatResp.builder()
.data(parsedData)
.toolName(firstFeedback.getName())
.sessionId(sessionId)
.nodeId(firstFeedback.getId())
.humanAccess(true)
.build();
}
}
}
// 7. 没有被中断,则继续执行
Optional<NodeOutput> outputOptional = reactAgent.invokeAndGetOutput("", runnableConfig);
log.warn("多个决策示例执行完成,最终状态:{}", outputOptional.get().state());
String text = NodeOutputUtil.extractMessageText(outputOptional);
return ChatResp.builder()
.data(text)
.sessionId(sessionId)
.build();
} catch (GraphRunnerException e) {
throw new ChatBizException(
ChatErrorEnum.MODEL_CALL_FAILED.getCode(),
ChatErrorEnum.MODEL_CALL_FAILED.getMessage() + e.getMessage()
);
}
4.2 工具类:NodeOutputUtil
java
package top.continew.admin.chat.client.util;
import com.alibaba.cloud.ai.graph.NodeOutput;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* NodeOutput 工具类
*/
public class NodeOutputUtil {
private NodeOutputUtil() {
}
/**
* 从 NodeOutput 中提取消息文本
*
* @param outputOptional NodeOutput 可选对象
* @return 消息文本,如果未找到则返回提示信息
*/
public static String extractMessageText(Optional<NodeOutput> outputOptional) {
return outputOptional
.map(NodeOutput::state)
.flatMap(s -> s.value("messages"))
.filter(m -> m instanceof List)
.map(m -> (List<?>) m)
.flatMap(list -> list.stream()
.map(item -> {
if (item == null) {
return null;
}
if (item instanceof Map map) {
Object textObj = map.get("text");
return textObj != null ? textObj.toString() : null;
}
Class<?> clazz = item.getClass();
try {
java.lang.reflect.Method method = clazz.getMethod("getText");
Object text = method.invoke(item);
return text != null ? text.toString() : null;
} catch (Exception e1) {
try {
java.lang.reflect.Method method = clazz.getMethod("text");
Object text = method.invoke(item);
return text != null ? text.toString() : null;
} catch (Exception e2) {
try {
java.lang.reflect.Method method = clazz.getMethod("getContent");
Object text = method.invoke(item);
return text != null ? text.toString() : null;
} catch (Exception ignored) {
}
}
}
return null;
})
.filter(Objects::nonNull)
.reduce((a, b) -> b)
)
.orElse("未知信息");
}
/**
* 解析工具描述,提取 Description 后的内容
*/
public static String parseToolDescription(String description) {
if (description == null || description.isEmpty()) {
return description;
}
int descriptionIndex = description.indexOf("Description:");
if (descriptionIndex != -1) {
int startIndex = descriptionIndex + "Description:".length();
int endIndex = description.indexOf("\n", startIndex);
if (endIndex == -1) {
endIndex = description.length();
}
return description.substring(startIndex, endIndex).trim();
}
return description;
}
}
五、方式二:流式输出实现(推荐)
5.1 主聊天接口
java
/**
* 聊天(SSE 流式输出)
* 如果触发 HumanInTheLoop 中断,只返回中断信息,不自动恢复执行
*/
@Override
public SseEmitter chat(String message, String sessionId, String model) {
log.info("模型消息: {}, 会话id: {}, 传入的模型: {}", message, sessionId, model);
// 在主线程中获取用户ID,放入 metadata 供异步线程使用
Long userId = StpUtil.getLoginIdAsLong();
// 构建工具
ToolCallback submitReimbursementTool = FunctionToolCallback.builder("submitReimbursement", new SubmitReimbursementTool())
.description("提交报销申请信息")
.inputType(ReimbursementReq.class)
.build();
ToolCallback reimbursementTool = FunctionToolCallback.builder("reimbursementTool", new ReimbursementTool())
.description("展示报销模板信息")
.inputType(ReimbursementReq.class)
.build();
// 配置人工介入 Hook
HumanInTheLoopHook humanInTheLoopHook = HumanInTheLoopHook.builder()
.approvalOn("submitReimbursement", ToolConfig.builder()
.description("请确认是否提交报销申请")
.build())
.build();
ChatModel chatModel = chatModelFactory.getChatModel(model);
ReactAgent reactAgent = createReactAgent(chatModel, submitReimbursementTool, reimbursementTool, humanInTheLoopHook);
RunnableConfig runnableConfig = RunnableConfig.builder()
.threadId(sessionId)
.addMetadata("sessionId", sessionId)
.addMetadata("userId", userId)
.build();
SseEmitter emitter = new SseEmitter(0L);
try {
// 调用 invokeAndGetOutput 处理消息,检测是否存在 HumanInTheLoop 中断
Optional<NodeOutput> result = reactAgent.invokeAndGetOutput(message, runnableConfig);
if (result.isPresent() && result.get() instanceof InterruptionMetadata interruptionMetadata) {
// 存在中断 - 需要人工审批,只返回中断信息,不自动恢复
handleInterruption(emitter, interruptionMetadata, sessionId);
} else {
// 没有被中断,使用流式输出
handleStreamingOutput(emitter, reactAgent, runnableConfig);
}
} catch (GraphRunnerException e) {
log.error("AI 模型调用失败", e);
emitter.completeWithError(new ChatBizException(
ChatErrorEnum.MODEL_CALL_FAILED.getCode(),
ChatErrorEnum.MODEL_CALL_FAILED.getMessage() + e.getMessage()));
}
return emitter;
}
5.2 处理中断
java
/**
* 处理 HumanInTheLoop 中断:只推送中断信息,不自动恢复执行
* 等待前端调用 resume 接口恢复
*/
private void handleInterruption(SseEmitter emitter,
InterruptionMetadata interruptionMetadata,
String sessionId) {
log.warn("检测到中断 - 需要人工审批");
List<InterruptionMetadata.ToolFeedback> toolFeedbacks = interruptionMetadata.toolFeedbacks();
toolFeedbacks.forEach(toolFeedback -> {
log.warn("工具: {}", toolFeedback.getName());
log.warn("参数: {}", toolFeedback.getArguments());
log.warn("描述: {}", toolFeedback.getDescription());
log.warn("结果: {}", toolFeedback.getId());
});
// 只推送中断工具信息,然后结束,等待前端确认
InterruptionMetadata.ToolFeedback firstFeedback = toolFeedbacks.stream()
.findFirst()
.orElse(null);
if (firstFeedback != null) {
String description = firstFeedback.getDescription();
String parsedData = NodeOutputUtil.parseToolDescription(description);
String toolInfo = String.format(
"{\"toolName\":\"%s\",\"data\":\"%s\",\"sessionId\":\"%s\",\"nodeId\":\"%s\",\"humanAccess\":true}",
firstFeedback.getName(),
parsedData,
sessionId,
firstFeedback.getId()
);
try {
emitter.send(SseEmitter.event()
.name("interruption")
.data(toolInfo));
emitter.complete();
log.info("已推送中断信息,等待用户确认后调用 resume 接口");
} catch (IOException e) {
log.error("推送中断信息失败", e);
emitter.completeWithError(e);
}
} else {
emitter.complete();
}
}
5.3 处理流式输出
java
/**
* 处理流式输出:使用 reactAgent.stream 获取增量内容并通过 SSE 推送
*/
private void handleStreamingOutput(SseEmitter emitter, ReactAgent reactAgent, RunnableConfig runnableConfig)
throws GraphRunnerException {
Flux<NodeOutput> stream = reactAgent.stream("", runnableConfig);
stream.subscribe(
output -> {
if (output instanceof StreamingOutput streamingOutput) {
OutputType type = streamingOutput.getOutputType();
// 处理模型推理的流式增量输出
if (type == OutputType.AGENT_MODEL_STREAMING) {
String text = streamingOutput.message().getText();
if (text != null && !text.isEmpty()) {
try {
emitter.send(SseEmitter.event()
.name("message")
.data(text));
} catch (IOException e) {
log.error("SSE 推送流式消息失败", e);
emitter.completeWithError(e);
}
}
} else if (type == OutputType.AGENT_MODEL_FINISHED) {
log.info("模型输出完成");
} else if (type == OutputType.AGENT_TOOL_FINISHED) {
log.info("工具调用完成: {}", output.node());
} else if (type == OutputType.AGENT_HOOK_FINISHED) {
log.info("Hook 执行完成: {}", output.node());
}
}
},
error -> {
log.error("流式输出异常", error);
emitter.completeWithError(error);
},
() -> {
log.info("Agent 流式执行完成");
emitter.complete();
}
);
}
5.4 恢复执行接口
java
/**
* 恢复 HumanInTheLoop 中断的执行(用户确认后调用)
*/
@Override
public SseEmitter resume(String sessionId, String nodeId, String toolName, boolean approved) {
log.info("恢复 HumanInTheLoop 执行: sessionId={}, nodeId={}, toolName={}, approved={}",
sessionId, nodeId, toolName, approved);
// 在主线程中获取用户ID
Long userId = StpUtil.getLoginIdAsLong();
// 构建工具(与 chat 方法保持一致)
ToolCallback submitReimbursementTool = FunctionToolCallback.builder("submitReimbursement", new SubmitReimbursementTool())
.description("提交报销申请信息")
.inputType(ReimbursementReq.class)
.build();
ToolCallback reimbursementTool = FunctionToolCallback.builder("reimbursementTool", new ReimbursementTool())
.description("展示报销模板信息")
.inputType(ReimbursementReq.class)
.build();
HumanInTheLoopHook humanInTheLoopHook = HumanInTheLoopHook.builder()
.approvalOn("submitReimbursement", ToolConfig.builder()
.description("请确认是否提交报销申请")
.build())
.build();
// 获取默认模型
ChatModel chatModel = chatModelFactory.getChatModel(ModelConstant.MODEL_QWEN_TURBO);
ReactAgent reactAgent = createReactAgent(chatModel, submitReimbursementTool, reimbursementTool, humanInTheLoopHook);
SseEmitter emitter = new SseEmitter(0L);
try {
// 构建批准决策(必须设置 name,否则 HumanInTheLoopHook 无法匹配工具)
InterruptionMetadata.ToolFeedback feedback = InterruptionMetadata.ToolFeedback.builder()
.id(nodeId)
.name(toolName) // 必须设置工具名称
.result(approved ? InterruptionMetadata.ToolFeedback.FeedbackResult.APPROVED
: InterruptionMetadata.ToolFeedback.FeedbackResult.REJECTED)
.build();
InterruptionMetadata approvalMetadata = InterruptionMetadata.builder()
.nodeId(nodeId)
.addToolFeedback(feedback)
.build();
RunnableConfig resumeConfig = RunnableConfig.builder()
.threadId(sessionId)
.addMetadata(RunnableConfig.HUMAN_FEEDBACK_METADATA_KEY, approvalMetadata)
.addMetadata("sessionId", sessionId)
.addMetadata("userId", userId)
.build();
log.info("------- 用户确认后恢复执行(流式输出) -------");
// 使用流式输出恢复执行
handleStreamingOutput(emitter, reactAgent, resumeConfig);
} catch (Exception e) {
log.error("恢复执行失败", e);
emitter.completeWithError(e);
}
return emitter;
}
六、核心概念:SseEmitter
6.1 什么是 SseEmitter?
SseEmitter 是 Spring MVC 提供的 服务端推送(Server-Sent Events,SSE) 工具类。
6.2 作用
服务器可以 持续、主动地 向前端推送消息,而不是前端不停轮询接口。
请求 -> 长连接保持 -> 服务端不断 send() -> 客户端持续接收
6.3 所属包
java
org.springframework.web.servlet.mvc.method.annotation.SseEmitter
6.4 典型应用场景
| 场景 | 说明 |
|---|---|
| 🤖 AI 流式输出 | ChatGPT 打字效果 |
| 📊 实时日志 | 系统日志实时推送 |
| 🔔 消息通知 | 实时消息提醒 |
| 📈 任务进度 | 上传/下载进度 |
| 📺 实时监控 | 数据大屏实时更新 |
| 🔤 大模型 Token 流 | Token 级实时输出 |
| 🔄 工作流状态 | 流程状态变更推送 |
七、流程图
┌─────────────────┐
│ 用户发送消息 │
└────────┬────────┘
▼
┌─────────────────┐
│ invokeAndGetOutput │
│ 检测是否中断 │
└────────┬────────┘
│
┌────┴────┐
▼ ▼
┌───────┐ ┌──────────┐
│ 中断 │ │ 无中断 │
└───┬───┘ └────┬─────┘
│ ▼
│ ┌──────────────┐
│ │ stream 流式 │
│ │ 输出到前端 │
│ └──────────────┘
▼
┌─────────────────┐
│ 推送中断信息 │
│ (humanAccess=true)│
└────────┬────────┘
▼
┌─────────────────┐
│ 等待用户确认 │
└────────┬────────┘
▼
┌─────────────────┐
│ 调用 resume 接口 │
│ 恢复执行 │
└─────────────────┘
八、关键要点总结
- 线程ID一致性 :恢复执行时必须使用相同的
threadId - 元数据传递 :通过
RunnableConfig.HUMAN_FEEDBACK_METADATA_KEY传递审批结果 - 工具名称必填 :构建
ToolFeedback时必须设置name字段 - 状态管理:中断状态可通过 Redis 等存储介质持久化
- 流式与非流式 :
invokeAndGetOutput适合简单场景,流式输出适合需要实时反馈的场景
有问题可以发邮箱联系博主:2929119150@qq.com