)
Spring AI Alibaba ——人工介入Human-in-the-Loop核心结论一句话先记住如果说 Agent 是个不知疲倦的打工人那HITLHuman-in-the-Loop人工介入就是给它配了一个“拥有一票否决权的主管”。大白话当 AI 打算干一些“危险动作”比如写文件、删数据库、发正式邮件时系统会自动按下暂停键把操作卡住等你人工看一眼。你点头了Approve它才继续干你觉得不行可以帮它改参数Edit或者直接打回重做Reject。在 Spring AI Alibaba 中这个机制底层依赖于检查点Checkpoint机制Agent 暂停时会把当前的脑图记忆存进硬盘或内存等你审批完再原封不动地取出来继续跑。一、 老板批奏折的三种决策Decision Types当 Agent 被拦截下来时你需要给它一个反馈系统内置了三种决策✅approve批准没毛病原样执行。比如生成的邮件内容很好直接发。✏️edit修改思路对了但细节有误我帮你改改参数再执行。比如邮件收件人填错了手动改对后发送。❌reject拒绝纯属胡扯打回去并告诉它为什么拒绝让它重新想。比如这封邮件语气太凶了重写。二、 怎么给危险工具挂上“审批流”基础配置配置大白话第一步必须要有记忆存储如MemorySaver不然暂停后 Agent 就失忆了第二步搞一个HumanInTheLoopHook告诉它遇到哪些工具需要卡住。配置中断代码示例import com.alibaba.cloud.ai.graph.agent.ReactAgent; import com.alibaba.cloud.ai.graph.agent.hook.hip.HumanInTheLoopHook; import com.alibaba.cloud.ai.graph.agent.hook.hip.ToolConfig; import com.alibaba.cloud.ai.graph.checkpoint.savers.MemorySaver; // ⭐ 1. 必须配置检查点保存器因为人工介入必须保存案发现场 MemorySaver memorySaver new MemorySaver(); // ⭐ 2. 核心创建人工介入 Hook指定哪些工具需要审批 HumanInTheLoopHook humanInTheLoopHook HumanInTheLoopHook.builder() // 发现它调用 write_file 工具时给我拦住 .approvalOn(write_file, ToolConfig.builder().description(文件写入操作需要审批).build()) // 发现它调用 execute_sql 时也拦住 .approvalOn(execute_sql, ToolConfig.builder().description(SQL执行操作需要审批).build()) .build(); // 3. 把 Hook 和 Saver 挂载到 Agent 身上 ReactAgent agent ReactAgent.builder() .name(approval_agent) .model(chatModel) .tools(writeFileTool, executeSqlTool, readDataTool) // 里面包含了各种工具 .hooks(List.of(humanInTheLoopHook)) // 注入审批流 .saver(memorySaver) // 注入记忆 .build();✋三、 怎么响应中断并给反馈完整生命周期大白话当你调用 Agent 发现它没返回答案而是返回了一个InterruptionMetadata这就说明“它被卡住了在等你批示”。1. 发现并查看中断响应中断示例import com.alibaba.cloud.ai.graph.RunnableConfig; import com.alibaba.cloud.ai.graph.NodeOutput; import com.alibaba.cloud.ai.graph.action.InterruptionMetadata; // ⭐ 必须提供 threadId不然系统不知道这是谁的会话 String threadId user-session-123; RunnableConfig config RunnableConfig.builder().threadId(threadId).build(); // 运行它 OptionalNodeOutput result agent.invokeAndGetOutput(删除数据库中的旧记录, config); // ⭐ 检查是不是被中断了 if (result.isPresent() result.get() instanceof InterruptionMetadata) { InterruptionMetadata interruptionMetadata (InterruptionMetadata) result.get(); // 把它打算干的坏事打印出来看看 ListInterruptionMetadata.ToolFeedback toolFeedbacks interruptionMetadata.toolFeedbacks(); for (InterruptionMetadata.ToolFeedback feedback : toolFeedbacks) { System.out.println(工具: feedback.getName()); System.out.println(参数: feedback.getArguments()); System.out.println(描述: feedback.getDescription()); } }2. 给出决策并恢复运行完整示例接上文import com.alibaba.cloud.ai.graph.agent.ReactAgent; import com.alibaba.cloud.ai.graph.agent.hook.hip.HumanInTheLoopHook; import com.alibaba.cloud.ai.graph.agent.hook.hip.ToolConfig; import com.alibaba.cloud.ai.graph.RunnableConfig; import com.alibaba.cloud.ai.graph.NodeOutput; import com.alibaba.cloud.ai.graph.action.InterruptionMetadata; import com.alibaba.cloud.ai.graph.checkpoint.savers.MemorySaver; public class HumanInTheLoopExample { public static void main(String[] args) throws Exception { // ... (此处省略前面初始化 Agent 和 Hook 的代码) ... String threadId user-session-001; RunnableConfig config RunnableConfig.builder().threadId(threadId).build(); // 第一次调用期望它被拦住 OptionalNodeOutput result agent.invokeAndGetOutput(帮我写一首100字左右的诗, config); if (result.isPresent() result.get() instanceof InterruptionMetadata interruptionMetadata) { System.out.println(检测到中断需要人工审批); // ⭐ 1. 模拟老板批奏折构建审批意见这里选择全部 APPROVED 批准 InterruptionMetadata.Builder feedbackBuilder InterruptionMetadata.builder() .nodeId(interruptionMetadata.node()) .state(interruptionMetadata.state()); interruptionMetadata.toolFeedbacks().forEach(toolFeedback - { InterruptionMetadata.ToolFeedback approvedFeedback InterruptionMetadata.ToolFeedback.builder(toolFeedback) .result(InterruptionMetadata.ToolFeedback.FeedbackResult.APPROVED) // 决策批准 .build(); feedbackBuilder.addToolFeedback(approvedFeedback); }); InterruptionMetadata approvalMetadata feedbackBuilder.build(); // ⭐ 2. 将圣旨反馈意见塞进 Config 里 RunnableConfig resumeConfig RunnableConfig.builder() .threadId(threadId) .addMetadata(RunnableConfig.HUMAN_FEEDBACK_METADATA_KEY, approvalMetadata) // 带着反馈意见 .build(); // 第二次调用带着老板的决定恢复执行(注意传的 input 是空串因为状态已经记在脑子里了) OptionalNodeOutput finalResult agent.invokeAndGetOutput(, resumeConfig); if (finalResult.isPresent()) { System.out.println(最终结果: finalResult.get()); } } } }️四、 终极复杂场景Workflow工作流里的连环套大白话有时候 Agent 不是单打独斗而是被嵌套在一个巨大的StateGraph图工作流里的一个小小节点。这时候怎么审批区别在于记忆Saver必须注册在工作流全局的CompileConfig上中断和恢复也是对着CompiledGraph发号施令。工作流嵌套审批代码// ... 省略 imports ... // 1. 创建工具和 Saver ToolCallback searchTool FunctionToolCallback.builder(search, (args) - 搜索结果...).build(); MemorySaver saver new MemorySaver(); // ⭐ 全局共享的 Saver // 2. 创建带审批 Hook 的 Agent ReactAgent qaAgent ReactAgent.builder() .name(qa_agent) .model(chatModel) .saver(saver) // Agent 要挂载 .hooks(HumanInTheLoopHook.builder() .approvalOn(search, ToolConfig.builder().description(搜索操作需审批).build()) .build()) .tools(searchTool).build(); // ... 省略 PreprocessorNode 和 ValidatorNode 的定义 ... // 3. 构建工作流 StateGraph workflow new StateGraph(keyStrategyFactory); workflow.addNode(preprocess, node_async(new PreprocessorNode())); workflow.addNode(validate, node_async(new ValidatorNode())); // ⭐ 把 Agent 作为一个 Node 塞进图里 workflow.addNode(qaAgent.name(), qaAgent.asNode(true, false)); // ... 省略连接边的代码 ... // 4. 编译工作流⭐ 关键必须在 CompileConfig 中注册检查点保存器 CompiledGraph compiledGraph workflow.compile(CompileConfig.builder() .saverConfig(SaverConfig.builder().register(saver).build()) .build()); // 5. 执行工作流并处理中断 String threadId workflow-hilt-001; MapString, Object input Map.of(input, 请解释量子计算); // 第一次调用 Graph OptionalNodeOutput nodeOutputOptional compiledGraph.invokeAndGetOutput(input, RunnableConfig.builder().threadId(threadId).build()); if (nodeOutputOptional.isPresent() nodeOutputOptional.get() instanceof InterruptionMetadata interruptionMetadata) { System.out.println(工作流被中断等待人工审核。中断节点: interruptionMetadata.node()); // ⭐ 构建同意的反馈同上 InterruptionMetadata.Builder feedbackBuilder InterruptionMetadata.builder() .nodeId(interruptionMetadata.node()) .state(interruptionMetadata.state()); interruptionMetadata.toolFeedbacks().forEach(toolFeedback - { feedbackBuilder.addToolFeedback(InterruptionMetadata.ToolFeedback.builder(toolFeedback) .result(InterruptionMetadata.ToolFeedback.FeedbackResult.APPROVED).build()); }); InterruptionMetadata approvalMetadata feedbackBuilder.build(); // 第二次调用恢复工作流执行 RunnableConfig resumableConfig RunnableConfig.builder() .threadId(threadId) // 必须是同一个线程 .addHumanFeedback(approvalMetadata) .build(); // 传入空 Map因为状态已保存在全局检查点中 nodeOutputOptional compiledGraph.invokeAndGetOutput(Map.of(), resumableConfig); }️五、 官方送你的“批奏折”快捷键 (HITLHelper)大白话每次遇到中断都要写一堆Builder去循环构建同意/拒绝太啰嗦了你可以封装一个HITLHelper实用工具类一键审批HITLHelper 工具类代码public class HITLHelper { /** 批准所有工具调用 */ public static InterruptionMetadata approveAll(InterruptionMetadata interruptionMetadata) { InterruptionMetadata.Builder builder InterruptionMetadata.builder() .nodeId(interruptionMetadata.node()) .state(interruptionMetadata.state()); interruptionMetadata.toolFeedbacks().forEach(toolFeedback - { builder.addToolFeedback(InterruptionMetadata.ToolFeedback.builder(toolFeedback) .result(InterruptionMetadata.ToolFeedback.FeedbackResult.APPROVED).build()); }); return builder.build(); } /** 拒绝所有工具调用并给出理由 */ public static InterruptionMetadata rejectAll(InterruptionMetadata interruptionMetadata, String reason) { InterruptionMetadata.Builder builder InterruptionMetadata.builder() .nodeId(interruptionMetadata.node()) .state(interruptionMetadata.state()); interruptionMetadata.toolFeedbacks().forEach(toolFeedback - { builder.addToolFeedback(InterruptionMetadata.ToolFeedback.builder(toolFeedback) .result(InterruptionMetadata.ToolFeedback.FeedbackResult.REJECTED) .description(reason).build()); }); return builder.build(); } /** 修改特定工具的参数其他的统统批准 */ public static InterruptionMetadata editTool(InterruptionMetadata interruptionMetadata, String toolName, String newArguments) { InterruptionMetadata.Builder builder InterruptionMetadata.builder() .nodeId(interruptionMetadata.node()) .state(interruptionMetadata.state()); interruptionMetadata.toolFeedbacks().forEach(toolFeedback - { if (toolFeedback.getName().equals(toolName)) { builder.addToolFeedback(InterruptionMetadata.ToolFeedback.builder(toolFeedback) .arguments(newArguments) .result(InterruptionMetadata.ToolFeedback.FeedbackResult.EDITED).build()); } else { builder.addToolFeedback(InterruptionMetadata.ToolFeedback.builder(toolFeedback) .result(InterruptionMetadata.ToolFeedback.FeedbackResult.APPROVED).build()); } }); return builder.build(); } } // 实际使用起来爽多了 InterruptionMetadata approvalMetadata HITLHelper.approveAll(interruptionMetadata); // 一键全过 InterruptionMetadata rejectMetadata HITLHelper.rejectAll(interruptionMetadata, 操作不安全); // 一键打回 InterruptionMetadata editMetadata HITLHelper.editTool(interruptionMetadata, execute_sql, {\query\: \SELECT * FROM records LIMIT 10\}); // 手动改个分页再跑️最佳实践避坑指南没脑子千万别审批必须使用检查点Saver否则恢复时找不到状态。话要说清楚配置ToolConfig时描述写清楚点不然人工审核的时候看着一团代码懵圈。不要落下任何一只手遇到多个并发的工具中断必须对每一个ToolFeedback都给出决策是杀是留。认准唯一单号恢复执行的时候threadId必须和之前中断的完全一致。处理死等最好加个超时机制不能让 Agent 等老板审批等一整天。终极秒记口诀智能 Agent 爱自由危险动作需防守加入 HITL 做拦截Saver 留存防弄丢Approve 批准任它走Edit 帮你修一修要是胡扯全 Reject批完奏折再回头