spring ai alibaba -流式+invoke的人工介入的实现

Spring AI Alibaba 人工介入实现指南

基于 Spring AI Alibaba Agent Framework 的 Human-in-the-Loop 功能实现


一、官方文档


二、什么是人工介入?

人工介入(Human-in-the-Loop) 是指在程序运行过程中,当检测到某些条件满足时:

  1. 暂停 程序的执行
  2. 等待 人工干预
  3. 人工干预完成后,再继续程序的执行

三、实现方式对比

方式 优点 缺点
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?

SseEmitterSpring 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 接口 │
│ 恢复执行        │
└─────────────────┘

八、关键要点总结

  1. 线程ID一致性 :恢复执行时必须使用相同的 threadId
  2. 元数据传递 :通过 RunnableConfig.HUMAN_FEEDBACK_METADATA_KEY 传递审批结果
  3. 工具名称必填 :构建 ToolFeedback 时必须设置 name 字段
  4. 状态管理:中断状态可通过 Redis 等存储介质持久化
  5. 流式与非流式invokeAndGetOutput 适合简单场景,流式输出适合需要实时反馈的场景

有问题可以发邮箱联系博主:2929119150@qq.com

相关推荐
TE-茶叶蛋2 小时前
mvn test
java
fliter2 小时前
4 个字节拿到 root 权限:Linux 内核漏洞"Copy Fail"与 Cloudflare 的应急处置全记录
后端
fliter2 小时前
Cloudflare 推出 Flagship:为 AI 时代重新设计的功能开关服务
后端·算法
niucloud-admin2 小时前
JAVA V6 多商户商城 开发文档——插件安装
java·开发语言
人道领域2 小时前
【黑马点评日记】RedisGEO实战:黑马点评附近商铺功能
java·数据库·redis·adb
逸Y 仙X3 小时前
文章二十六:ElasticSearch 异步查询执行重度任务
java·大数据·linux·运维·elasticsearch·搜索引擎·全文检索
洛阳泰山3 小时前
Maxkb4j集成sqlbot MCP实现企业智能问数智能体
java·ai·springboot·agent·智能问数
掘金者阿豪3 小时前
折腾了两天,终于把SQLAlchemy连上了金仓数据库
后端
SamDeepThinking3 小时前
RocketMQ消息可靠性的三道关卡
java·后端·程序员