AI Agent 开发实录 03:Java 程序员用 Spring AI Alibaba 1.1.2.0 从0到1手搓生产级 AI Agent —— 完结篇
本系列是跟着课程《Java转AI高薪领域必备——从0到1打通生产级AI Agent开发》的实现实录:业务场景与实现主线以课程为准,技术栈沿用本库已验证的组合——Spring AI Alibaba 1.1.2.0(Graph)+ tk-mybatis + MySQL 8.x,项目代号 double-ai-agent,共三期连载。本期为第 03 期:在前两期基础上接入人机协同审批——HumanApprovalNode 实现 Graph 执行中断、Redis 持久化检查点解决 Missing Checkpoint 问题、审批回调接口完成状态恢复与分支路由,最终打通「数据采集 → AI 预测 → 邮件通知 → 人工审批 → 审核通过/拒绝 → 创建调拨单/直接结束」的完整闭环。 前置阅读:本期依赖第 01 期的环境搭建与第 02 期的完整 Graph 编排,建议先看完前两期再动手。
前两期已完成环境搭建与全部节点串联——销售数据采集、历史调拨数据采集、AI 预测分析、JSON 提取、邮件通知、调拨单自动创建,整条链路已经跑通。但少了一个关键环节:人工审批。AI 给出的调拨建议不能直接入库,得有人看一眼,点个「采纳」或「拒绝」。本期就把这个缺口补上,把整个业务流程真正闭环。
这里有一个背景得先交代:Spring AI Alibaba 从 1.0.0.4 升到 1.1.2.0 之后,人机交互的 API 做了很大的改变。课程原版用的是旧版 API,我这边只能对着官方文档和源码摸索,踩了不少坑——Missing Checkpoint、数据丢失、拒绝分支不生效——这些踩坑过程都记在下面了。
完整 Graph
一. 人机协同
1.1 HumanApprovalNode
Spring AI Alibaba 1.1.2.0 的人机交互机制跟旧版完全不同:旧版用的是 state.withHumanFeedback() 和 graph.call(),新版换成了 InterruptableAction 接口 + InterruptionMetadata + graph.updateState() / graph.invoke() 这套组合。变化之大,我一开始也是对着官方 Demo 一行行改,改了好几版才跑通。
1.1.1 官方 Demo
先看官方给的 Demo,理解中断机制的核心思路:
/**
* 可中断的节点动作
* 实现 InterruptableAction 接口,可以在任意节点中断执行
*/
public static class InterruptableNodeAction implements AsyncNodeActionWithConfig, InterruptableAction {
private final String nodeId;
private final String message;
public InterruptableNodeAction(String nodeId, String message) {
this.nodeId = nodeId;
this.message = message;
}
@Override
public CompletableFuture<Map<String, Object>> apply(OverAllState state, RunnableConfig config) {
// 正常节点逻辑:更新状态
return CompletableFuture.completedFuture(Map.of("messages", message));
}
@Override
public Optional<InterruptionMetadata> interrupt(String nodeId, OverAllState state, RunnableConfig config) {
// 检查是否需要中断
// 如果状态中没有 human_feedback,则中断等待用户输入
Optional<Object> humanFeedback = state.value("human_feedback");
if (humanFeedback.isEmpty()) {
// 返回 InterruptionMetadata 来中断执行
InterruptionMetadata interruption = InterruptionMetadata.builder(nodeId, state)
.addMetadata("message", "等待用户输入...")
.addMetadata("node", nodeId)
.build();
return Optional.of(interruption);
}
// 如果已经有 human_feedback,继续执行
return Optional.empty();
}
}
核心逻辑就两步:
interrupt()方法在节点执行前被调用,检查状态中是否已有审批结果。没有 → 返回InterruptionMetadata,Graph 暂停;有 → 返回Optional.empty(),继续执行。apply()方法是节点正常执行的业务逻辑,中断恢复后走到这里。
这个机制设计得挺巧妙的——它不是把审批做成一个单独的步骤,而是让任何节点都可以变成一个"中断点"。理解了这个思路,后面就好办了。
1.1.2 HumanApprovalNode 第一版
对着 Demo 改出第一版:
package vip.wayhua.ivy.ai.agent.nodes;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.AsyncNodeActionWithConfig;
import com.alibaba.cloud.ai.graph.action.InterruptableAction;
import com.alibaba.cloud.ai.graph.action.InterruptionMetadata;
import vip.wayhua.ivy.ai.agent.constant.Constant;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
/***
* 人工审批
*/
public class HumanApprovalNode implements AsyncNodeActionWithConfig, InterruptableAction {
private final String nodeId;
private final String message;
public HumanApprovalNode() {
this.nodeId = "human_approval_node";
this.message = "等待用户确认";
}
@Override
public CompletableFuture<Map<String, Object>> apply(OverAllState state, RunnableConfig config) {
// 正常节点逻辑:更新状态
return CompletableFuture.completedFuture(Map.of("messages", message));
}
@Override
public Optional<InterruptionMetadata> interrupt(String nodeId, OverAllState state, RunnableConfig config) {
// 检查是否需要中断
// 如果状态中没有 human_feedback,则中断等待用户输入
Optional<Object> humanFeedback = state.value(Constant.KeyName.APPROVAL);
if (humanFeedback.isEmpty()) {
// 返回 InterruptionMetadata 来中断执行
InterruptionMetadata interruption = InterruptionMetadata.builder(nodeId, state)
.addMetadata("message", "等待用户输入...")
.addMetadata("node", nodeId)
.build();
return Optional.of(interruption);
}
// 如果已经有 human_feedback,继续执行
return Optional.empty();
}
}
1.1.3 添加配置
把人工审批节点插到发送邮件和创建调拨单之间——邮件通知发出去之后,停下来等人点确认:
stateGraph.addNode(Constant.NodeName.HUMAN_APPROVAL_NODE, new HumanApprovalNode());
//...
stateGraph.addEdge(Constant.NodeName.SEND_EMAIL_NODE, Constant.NodeName.HUMAN_APPROVAL_NODE);
stateGraph.addEdge(Constant.NodeName.HUMAN_APPROVAL_NODE, Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE);
stateGraph.addEdge(Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE, StateGraph.END);
1.1.4 Controller 调整
Controller 调用处也得改——需要传 threadId,不然没法区分是哪个会话的中断:
@Operation(summary = "销售测试", description = "销售测试,输入productId")
@GetMapping("/sale")
public Map<String, Object> sale(@RequestParam("productId") String productId) {
String threadId = Sid.next();
RunnableConfig runnableConfig = RunnableConfig.builder().threadId(threadId).build();
OverAllState overAllState = graph.invoke(
Map.of(Constant.KeyName.PRODUCT_ID, productId,
Constant.KeyName.THREAD_ID, threadId),runnableConfig
).get();
Map<String, Object> data = overAllState.data();
return data;
}
1.1.5 测试——没反应
第一版跑起来,前面没什么反应,看不到中断效果。于是加上日志,同时把审批后的分支逻辑也补进去。
1.1.6 HumanApprovalNode 修改——加日志、加分支逻辑
package vip.wayhua.ivy.ai.agent.nodes;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.AsyncNodeActionWithConfig;
import com.alibaba.cloud.ai.graph.action.InterruptableAction;
import com.alibaba.cloud.ai.graph.action.InterruptionMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import vip.wayhua.ivy.ai.agent.constant.Constant;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
/***
* 人工审批
*/
public class HumanApprovalNode implements AsyncNodeActionWithConfig, InterruptableAction {
private static final Logger log = LoggerFactory.getLogger(HumanApprovalNode.class);
private final String nodeId;
private final String message;
public HumanApprovalNode() {
this.nodeId = "human_approval_node";
this.message = "等待用户确认";
}
@Override
public CompletableFuture<Map<String, Object>> apply(OverAllState state, RunnableConfig config) {
log.error("HumanApprovalNode->------------------");
Optional<Boolean> humanFeedback = state.value(Constant.KeyName.APPROVAL);
if (!humanFeedback.isEmpty()) {
Boolean approval = humanFeedback.get();
String nextStep = StateGraph.END;
if (approval) {
nextStep =Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE ;
}
CompletableFuture.completedFuture(Map.of("messages", message,
Constant.KeyName.HUMAN_APPROVAL_NODE, nextStep
));
}
// 正常节点逻辑:更新状态
return CompletableFuture.completedFuture(Map.of("messages", message));
}
@Override
public Optional<InterruptionMetadata> interrupt(String nodeId, OverAllState state, RunnableConfig config) {
log.error("HumanApprovalNode-->interrupt " + nodeId);
// 检查是否需要中断
// 如果状态中没有 human_feedback,则中断等待用户输入
Optional<Object> humanFeedback = state.value(Constant.KeyName.APPROVAL);
if (humanFeedback.isEmpty()) {
// 返回 InterruptionMetadata 来中断执行
log.error("已经中断了.....");
InterruptionMetadata interruption = InterruptionMetadata.builder(nodeId, state)
.addMetadata("message", "等待用户输入...")
.addMetadata("node", nodeId)
.build();
return Optional.of(interruption);
}
// 如果已经有 human_feedback,继续执行
return Optional.empty();
}
}
这里的思路是:中断恢复后进入 apply(),检查审批结果是采纳还是拒绝,把对应的下一步路由写到状态里。后面配合条件边,就能走到不同的分支。
1.1.7 测试——中断成功

已经中断,需要人工审批。日志里能看到"已经中断了.....",说明 interrupt() 方法被正确触发。
先保存 threadId 和 productId,后面审批回调要用:
threadId: "260727736843029540765696",
productId: "1",
二. 审批回调与状态恢复
中断是中断了,但还得有个接口让人点「采纳」或「拒绝」。这就是审批回调接口。
2.1 审批回调接口——第一版
// 触发邮件回调的接口
@Operation(summary = "触发邮件回调的接口", description = "触发邮件回调的接口")
@GetMapping("/approval")
public R approval(@RequestParam(value = "approval") Boolean approval,
@RequestParam(value = "threadId") String threadId) {
RunnableConfig runnableConfig = RunnableConfig.builder().threadId(threadId).build();
try {
// 1. 根据threadId拿到中断时保存的状态快照
StateSnapshot stateSnapshot = graph.getState(runnableConfig);
OverAllState state = stateSnapshot.state();
// 2. 【核心替换】替代旧 state.withHumanFeedback
// 将审批结果写入状态中 KEY_APPROVAL = "approval"
Map<String, Object> feedbackData = Map.of(Constant.KeyName.APPROVAL, approval);
RunnableConfig resumeConfig = graph.updateState(runnableConfig, feedbackData, null);
// 3. 【核心替换】替代 graph.call(),继续运行工作流
OverAllState overAllState = graph.invoke(feedbackData,resumeConfig).get();
return R.success(overAllState.data());
} catch (Exception e) {
return R.error(e.getMessage());
}
}
这个接口做三件事:
- 根据 threadId 拿到中断时的状态快照
- 把审批结果写入状态(旧版
withHumanFeedback的替代方案) - 恢复 Graph 执行(旧版
graph.call()的替代方案)
2.2 测试——Missing Checkpoint!
前面保存的 threadId:
threadId: "260727736843029540765596",
Missing Checkpoint! 这个报错很明确——Graph 的中断检查点存在内存里,服务重启或者会话超时就没了。要解决这个问题,得把检查点持久化到外部存储。
三. Redis 检查点持久化:解决 Missing Checkpoint
参考官方文档:java2ai.com/docs/framew…
3.1 添加 Redisson
<dependency>
<groupId>vip.wayhua.ivy.ai</groupId>
<artifactId>ivy-starter-redission</artifactId>
<version>1.0.1</version>
</dependency>
这个 Redisson 前面在 DocSolo 里已经配好了,直接复用。
3.2 Redis Stack Docker 部署
Redis 不仅要跑起来,还需要 Redis Stack 版本(带 RedisJSON、RediSearch 等模块),因为 Graph 的检查点存储用到了这些能力:
version: '3'
services:
redis-stack-server:
image: redis/redis-stack-server:latest
container_name: redis-stack-server
restart: always
ports:
- "6379:6379"
environment:
# 设置访问密码,取消注释启用
REDIS_ARGS: "--requirepass wayhua"
volumes:
- ./redis-data:/data
3.3 添加持久化配置
在 Graph 编译时注册 RedisSaver:
SaverConfig saverConfig=SaverConfig.builder()
.register( new RedisSaver
.Builder()
.redisson(redissonClient)
.build())
.build();
CompileConfig compileConfig=CompileConfig.builder()
.saverConfig(saverConfig)
.build();
CompiledGraph compile = stateGraph.compile(compileConfig);
3.4 测试——又报错了

报错了。排查之后发现是重启项目导致之前的数据丢失,拿不到之前的状态。这也从侧面印证了:旧数据在内存中,重启就没了。Redis 持久化只有对新产生的会话才生效。
3.5 添加审批按钮链接
邮件里得带上审批链接,让人点了就能批:
Constant.KeyName.ADOPT_LINK, "http://localhost:8900/saleProduct/approval?approval=true&threadId="+threadId,
Constant.KeyName.REJECT_LINK, "http://localhost:8900/saleProduct/approval?approval=false&threadId="+threadId));
3.6 approval 新版的数据丢失问题
一直会丢失一些数据,查了好久,才算解决。
问题出在 graph.updateState() 之后直接调 graph.invoke(feedbackData, resumeConfig),这时候状态还没完全同步。解决方法是 updateState 之后再重新获取一次 OverAllState,用最新的状态去 invoke:
// 触发邮件回调的接口
@Operation(summary = "触发邮件回调的接口", description = "触发邮件回调的接口")
@GetMapping("/approval")
public R approval(@RequestParam(value = "approval") Boolean approval,
@RequestParam(value = "threadId") String threadId) {
RunnableConfig runnableConfig = RunnableConfig.builder()
.threadId(threadId)
.build();
try {
// 1. 根据threadId拿到中断时保存的状态快照
StateSnapshot stateSnapshot = graph.getState(runnableConfig);
OverAllState state = stateSnapshot.state();
log.error("-->"+ JsonUtils.toJson(state.data()));
// 2. 【核心替换】替代旧 state.withHumanFeedback
// 将审批结果写入状态中 KEY_APPROVAL = "approval"
Map<String, Object> feedbackData = Map.of(Constant.KeyName.APPROVAL, approval);
RunnableConfig resumeConfig = graph.updateState(runnableConfig, feedbackData, null);
StateSnapshot latestSnapshot = graph.getState(resumeConfig);
// 3. 【核心替换】替代 graph.call(),继续运行工作流
// OverAllState overAllState = graph.invoke(feedbackData, resumeConfig).get();
OverAllState resumeState = latestSnapshot.state();
Map<String, Object> data = resumeState.data();
log.error("-->"+ JsonUtils.toJson(data));
OverAllState overAllState = graph.invoke(resumeState, resumeConfig).get();
return R.success(overAllState.data());
} catch (Exception e) {
return R.error(e.getMessage());
}
}
关键点:updateState 之后调 getState 重新获取 OverAllState,再调 invoke。不这么做的话,invoke 拿到的状态可能是旧的,之前写入的审批结果就丢了。这个问题折腾了我好一阵子。
3.7 发送测试
收到的邮件里带了审批链接。
在页面调用接口:
点击采纳,数据库中成功生成了调拨单:
拒绝测试——出问题了:
点了拒绝,数据库里还是生成了一条调拨单。
这说明什么?说明拒绝之后,Graph 还是走到了
CREATE_INVENTORY_TRANSFER_NODE。边是写死的,不管审批结果如何都会往下走。
四. 条件分支:拒绝退出
原因在这:
stateGraph.addEdge(Constant.NodeName.HUMAN_APPROVAL_NODE, Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE);
stateGraph.addEdge(Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE, StateGraph.END);
这是固定边——不管审批结果是什么,一定走到创建调拨单。正确的做法是把这条固定边换成条件边:采纳 → 创建调拨单,拒绝 → 结束。
4.1 ApprovalEdge
新增条件边,根据状态中的路由标记决定下一步:
package vip.wayhua.ivy.ai.agent.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.wayhua.ivy.ai.agent.constant.Constant;
public class ApprovalEdge implements EdgeAction {
@Override
public String apply(OverAllState state) throws Exception {
String humanApprovalNextStep = state.value(Constant.KeyName .HUMAN_APPROVAL_NODE, "");
if (Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE.equals(humanApprovalNextStep)){
return Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE;
}
return StateGraph.END;
}
}
4.2 配置条件边
stateGraph.addConditionalEdges(Constant.NodeName.HUMAN_APPROVAL_NODE,
AsyncEdgeAction.edge_async(new ApprovalEdge()),
Map.of(Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE,Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE,
StateGraph.END ,StateGraph.END)
);
stateGraph.addEdge(Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE, StateGraph.END);
同时 HumanApprovalNode 的 apply() 方法也得把路由数据写进状态——前面写了判断逻辑,但要确保返回的 Map 带了 HUMAN_APPROVAL_NODE 的 key。
说到这,我仔细查了一遍之前的代码,发现一个低级错误:
package vip.wayhua.ivy.ai.agent.nodes;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.AsyncNodeActionWithConfig;
import com.alibaba.cloud.ai.graph.action.InterruptableAction;
import com.alibaba.cloud.ai.graph.action.InterruptionMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import vip.wayhua.ivy.ai.agent.constant.Constant;
import vip.wayhua.ivy.ai.core.utils.JsonUtils;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
/***
* 人工审批
*/
public class HumanApprovalNode implements AsyncNodeActionWithConfig, InterruptableAction {
private static final Logger log = LoggerFactory.getLogger(HumanApprovalNode.class);
private final String nodeId;
private final String message;
public HumanApprovalNode() {
this.nodeId = "human_approval_node";
this.message = "等待用户确认";
}
@Override
public CompletableFuture<Map<String, Object>> apply(OverAllState state, RunnableConfig config) {
log.error("HumanApprovalNode->------------------");
Map<String, Object> data = state.data();
log.error("--------------> json-->\n"+ JsonUtils.toJson(data));
Optional<Boolean> humanFeedback = state.value(Constant.KeyName.APPROVAL);
if (!humanFeedback.isEmpty()) {
Boolean approval = humanFeedback.get();
String nextStep = StateGraph.END;
if (approval) {
nextStep = Constant.NodeName.CREATE_INVENTORY_TRANSFER_NODE;
}
return CompletableFuture.completedFuture(Map.of("messages", message,
Constant.KeyName.HUMAN_APPROVAL_NODE, nextStep
));
}
// 正常节点逻辑:更新状态
return CompletableFuture.completedFuture(Map.of("messages", message));
}
@Override
public Optional<InterruptionMetadata> interrupt(String nodeId, OverAllState state, RunnableConfig config) {
log.error("HumanApprovalNode-->interrupt " + nodeId);
// 检查是否需要中断
// 如果状态中没有 human_feedback,则中断等待用户输入
Optional<Object> humanFeedback = state.value(Constant.KeyName.APPROVAL);
if (humanFeedback.isEmpty()) {
// 返回 InterruptionMetadata 来中断执行
log.error("已经中断了.....");
InterruptionMetadata interruption = InterruptionMetadata.builder(nodeId, state)
.addMetadata("message", "等待用户输入...")
.addMetadata("node", nodeId)
.build();
return Optional.of(interruption);
}
// 如果已经有 human_feedback,继续执行
return Optional.empty();
}
}
原来问题是少了一个 return,哎。
return CompletableFuture.completedFuture(Map.of("messages", message,
Constant.KeyName.HUMAN_APPROVAL_NODE, nextStep
));
之前审批分支逻辑里的 CompletableFuture.completedFuture(...) 前面没有 return,这行代码执行了但返回值被丢弃了,方法继续往下走到最后的 return,返回的 Map 里没有 HUMAN_APPROVAL_NODE 这个 key。所以 ApprovalEdge 读到的永远是空字符串,永远走 StateGraph.END,拒绝能结束但采纳也创建不了调拨单。
这种 bug 是最难查的——代码不报错、编译不报错、逻辑看起来也对,就是行为不对。最后还是加了日志把每一步的返回值打印出来才定位到。写 Java 这么多年,还是会在这种地方翻车。
4.3 测试——终于对了
采纳后正常生成调拨单。
拒绝后不再生成调拨单,流程在审批节点后直接结束。
五. 小结
到此,整个流程的控制基本上完成了。回顾这一期,踩了几个实打实的坑:
1. Missing Checkpoint。 Spring AI Alibaba 1.1.2.0 的 Graph 默认把检查点放在内存里,服务一重启就没了。解决办法是用 RedisSaver 持久化到 Redis Stack,这样即使服务重启,中断的会话也能恢复。这个设计在官方文档里有提,但第一次接触很容易忽略。
2. updateState 后数据丢失。 审批回调里调了 graph.updateState() 之后直接 graph.invoke(feedbackData, ...),状态没完全同步。正确的做法是 updateState 之后重新 getState() 拿到最新状态,再 invoke。这个坑是我自己排查了好久才发现的,官方 Demo 里没有这种场景。
3. 拒绝分支不生效。 根本原因是条件边需要从状态中读取路由标记,但 HumanApprovalNode 的 apply() 方法少了一个 return,导致路由标记根本没写进状态。加上条件边 ApprovalEdge 配合修正后的返回值,采纳走创建调拨单,拒绝直接结束,分支逻辑才真正生效。
4. 阻塞调用问题。 目前 Controller 里 graph.invoke(...).get() 是同步阻塞的,如果整个链路比较长,接口容易超时。这个以后可以改成异步或者用 SSE 推送进度,但不在本期范围内。
这一篇的内容和课程《Java转AI高薪领域必备-从0到1打通生产级AI Agent开发》有很大区别——课程用的是 Spring AI Alibaba 1.0.0.4,我这边升级到了 1.1.2.0,人机交互 API 完全变了。好在有官方文档 java2ai.com/ 可以参考,算不上从零摸索,但确实花了不少时间读源码和做实验。
后面的微服务化、接入 Kafka 等,都是数据业务层面的——增加订单时根据告警规则触发仓库调拨,这些是 Spring Cloud 相关的内容,不属于 AI Agent 的范畴了,就不在这过多展开了。
这一系列到此结束,后面是 BI 相关的内容。
代码
gitee.com/wavaya88/Do… 3.all 分支