07-检查点与状态持久化

检查点与状态持久化:服务重启之后,那封没点"采纳"的审批邮件去哪了

这是我"Java 转 AI 工程"系列的第 7 篇。前面把库存调拨的 Agent 图搭起来、能跑通人工审核了,这一篇处理一个更现实的问题:跑到一半的流程,进程没了怎么办。顺便把 PlantUML 接进来,让流程图不再靠截图维护。

一、事故:邮件发出去了,服务没了

先复盘一次我自己制造(也值得制造)的故障。

这条链路长这样:START →(saleRecordDataNode ∥ inventoryOrderDataNode 并行采集)→ predictNode → extractNode → sendEmailNode → humanApprovalNode →(条件边:采纳)createInventoryTransferNode → END。

关键在编译配置里这一行:

java 复制代码
CompileConfig compileConfig = CompileConfig.builder()
        .interruptBefore("humanApprovalNode")
        .build();

interruptBefore 让图在人工审批节点之前停下来等外部指令,这正是第 3 篇里说的"能中途停下来让人看一眼"的落地形态------邮件发给管理员,他点"采纳"或"拒绝",流程再继续。

事故时间线我在日志里翻得清清楚楚:

text 复制代码
2025-11-27 22:23:17.437 [http-nio-8876-exec-1] INFO  c.c.a.t.nodes.ExtractNode - ExtractNode result=[{"sourceWarehouseId":3,"targetWarehouseId":1,...}]
2025-11-27 22:23:19.198 [http-nio-8876-exec-1] INFO  c.c.a.t.s.impl.EmailServiceImpl - HTML email sent success
2025-11-27 22:23:49.424 [SpringApplicationShutdownHook] INFO  o.s.boot.jdbc - Evicting Hikari connections
Process finished with exit code 130

模型分析完了、JSON 提取出来了、审批邮件也发出去了,三十秒后我停了进程。第二天管理员悠哉悠哉点开邮件里那个链接:

text 复制代码
http://localhost:8876/saleProduct/approve?approval=true&threadId=551917bfda407973d3141d1bd1ab...

接口回我一句 Missing Checkpoint。调拨单没生成,业务链路断在半空。

原因不复杂:工作流的状态默认存在 JVM 内存里 。图停在 humanApprovalNode 之前,靠的是"会话 threadId → 那份状态快照"的映射;进程一没,映射跟着蒸发,重启后的新进程拿这个 threadId 去查,什么都查不到。这类故障的共同特征是:它不在你的代码路径上炸,而是在时间轴的另一端炸------你测的时候一切正常,因为没人真的会在审批邮件发出后 kill 进程。

二、检查点到底存了什么

Spring AI Alibaba Graph 内置了一层持久化机制,载体叫检查点(Checkpoint) :在每个超级步骤(super-step)结束时 保存一次图状态快照,用 StateSnapshot 对象表示,用途就是在稍后的某个时间点把会话恢复回来。

一个检查点里到底有什么,我按字段整理了一遍:

字段 含义 我排查时怎么用它
config 关联的运行配置(含 threadId) 会话隔离的依据,恢复时先拿它对上号
metadata 元数据 看步骤、来源这类附加信息
values 状态通道(State)的当前值 最值钱的一栏:各节点写进 State 的结果都在这
next 下一个要执行的节点名称 判断"当时停在哪",一眼看出是不是卡在 humanApprovalNode
tasks 下一个任务的信息(PregelTask) 恢复时要知道该调度谁,别自己猜

values + next 合起来就是"跑到一半"的完整描述:数据到哪一步了、下一步该谁干活。这两样能跨进程活下来,恢复就是水到渠成的事。

落到 Redis 里,RedisSaver 的键结构是这样(前缀直接抄自 com.alibaba.cloud.ai.graph.checkpoint.savers.RedisSaver):

Key 作用 说明
graph:checkpoint:content:{threadId} 存检查点内容 值是 JSON,序列化器由 Redisson 的 JsonJacksonCodec 决定
graph:checkpoint:lock:{threadId} 并发写锁 同一个会话的检查点写入互斥,避免两个请求互相踩

两个细节值得记住:

  • threadId 是会话隔离的边界。 控制器里必须显式传 RunnableConfig.builder().threadId(threadId).build(),不传就落到接口常量 BaseCheckpointSaver.THREAD_ID_DEFAULT(即 "$default")------所有会话挤在同一个 key 里,测试时看着"能恢复",上线必串数据。
  • 锁和内容是两个 key。 我一开始以为只有一个 hash,实际成对出现,Redis 里看到两个 key 是正常的。

三、把状态搬进 Redis:Redis Stack + 图配置改造

1. 为什么是 Redis Stack,以及它和 Redis 的区别

Redis Stack 是 Redis 的功能扩展版本,兼容 Redis 全部功能。两者差异压缩成一行:

  • 数据结构 :基本结构 → 基本 + 复杂结构;全文搜索 :不支持 → 支持高效全文搜索;图形功能 :不支持 → 支持图形存储与查询;时间序列 :基本支持 → 专门优化;安装复杂度:较简单 → 较复杂(涉及多个组件)。

说实话,这一章我只用到"当 KV 存储"这一项,普通 Redis 也够;选 Stack 是因为后面 BI 项目要用全文搜索,而它端口和协议跟 Redis 一致,不用二次迁移。

Windows 上先装 Docker Desktop,然后一条命令起单机版:

bash 复制代码
docker run --name redis-stack -d -p 6379:6379 redis/redis-stack-server

容器名撞了先停掉已有容器;生产环境记得配持久化方案。

2. 依赖:Redisson

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.4</version>
</dependency>

RedisSaver 的构造方法要的是 RedissonClient,不是 RedisTemplate------Spring Data Redis 那套在这里用不上,这是最容易搞混的一点。

3. 配置:application.yml

yaml 复制代码
server:
  port: 8876

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/ai-transfer?useUnicode=true&characterEncoding=utf-8&useSSL=false
    username: root
    password: 123456
  data:
    redis:
      host: localhost
      port: 6379
      database: 2
      timeout: 5s
      connect-timeout: 5s

database: 2 是我特意挑的------本机 Redis 已被别的项目占了 0 和 1,检查点混进去,清库时容易出事;独立库还有个附带好处:FLUSHDB 只影响检查点。

4. Redisson 客户端配置类

java 复制代码
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        // 自定义 key-value 的序列化方式:JSON 可读性最好,排障时能直接看懂
        config.setCodec(new JsonJacksonCodec());
        config.useSingleServer()
                .setAddress("redis://127.0.0.1:6379")
                .setDatabase(2);
        return Redisson.create(config);
    }
}

主机、端口、库号我这里写死了,正式项目建议从 spring.data.redis.host / port / database 注入,配置只留一份。顺带一句:Config 要导的是 org.redisson.config.Config。序列化器我的经验是能选 JSON 就别用 Java 原生序列化,检查点是要人肉去 Redis 里看的,性能上 JSON 也优于原生和 XML。

5. 改造 GraphConfig:把 Saver 挂进编译阶段

持久化配置必须写在图的编译阶段,不是节点里,也不是 StateGraph 上:

java 复制代码
@Configuration
@Slf4j
public class GraphConfig {

    @Resource
    private RedissonClient redissonClient;

    @Bean
    public CompiledGraph graph(ChatClient.Builder chatClientBuild) throws GraphStateException {
        // ... 键策略、addNode、addEdge、addConditionalEdges 与前几篇一致,此处略 ...

        // 1) 创建持久化配置
        SaverConfig saverConfig = SaverConfig.builder()
                .register(SaverEnum.REDIS.getValue(), new RedisSaver(redissonClient))
                .build();

        // 2) 定义编译配置:中断点 + 存储
        CompileConfig compileConfig = CompileConfig.builder()
                .interruptBefore("humanApprovalNode")
                .saverConfig(saverConfig)
                .build();

        // 3) 编译
        return stateGraph.compile(compileConfig);
    }
}

CompileConfig 除了指定中断点,还承载保存配置;SaverEnum 五个值:DB("db")、REDIS("redis")、MEMORY("memory")、FILE("file")、NONE("none"),register(type, saver) 就是把存储类型和 BaseCheckpointSaver 实现类绑起来。

BaseCheckpointSaver 这个接口本身很薄,四个抽象方法:

java 复制代码
public interface BaseCheckpointSaver {
    String THREAD_ID_DEFAULT = "$default";

    Collection<Checkpoint> list(RunnableConfig config);
    Optional<Checkpoint> get(RunnableConfig config);
    RunnableConfig put(RunnableConfig config, Checkpoint checkpoint) throws Exception;
    boolean clear(RunnableConfig config);
}

框架在 1.0.0.4 里给了五个实现:MemorySaver、RedisSaver、FileSystemSaver、MongoSaver、VersionedMemorySaver。换存储只需要换一个实现类,业务代码一行不动。

6. 恢复侧:控制器怎么把流程"唤醒"

存下来只是第一步,让它续起来靠这段:

java 复制代码
@RestController
@RequestMapping("/saleProduct")
public class SaleProductController {

    @Resource
    private CompiledGraph graph;

    @GetMapping("/approve")
    public R approve(@RequestParam("approval") Boolean approval,
                     @RequestParam(value = "threadId", required = false) String threadId) {
        // threadId 用于会话隔离,让 Agent 知道当前处理哪个对话
        RunnableConfig runnableConfig = RunnableConfig.builder().threadId(threadId).build();
        try {
            StateSnapshot stateSnapshot = graph.getState(runnableConfig);
            OverAllState state = stateSnapshot.state();
            log.info("stateSnapshot state=[{}]", state);

            state.withResume();                       // 通知工作流:我要激活了
            Map<String, Object> map = new HashMap<>();
            map.put("approval", approval);
            state.withHumanFeedback(new OverAllState.HumanFeedback(map, ""));  // 人工反馈写回 State

            OverAllState overAllState = graph.call(state, runnableConfig);
            return R.success(overAllState.data());
        } catch (Exception e) {
            log.error("error msg=[{}]", e.getMessage());
        }
        return R.success();
    }
}

三步走:取快照 → 标记恢复 + 注入人工反馈 → 重新驱动图 。getState(runnableConfig) 就是之前报 Missing Checkpoint 的那个调用;withResume() 告诉框架"别当新流程跑,从断点续";HumanFeedback 把审批结果塞回 State,供后面的条件边(ApprovalEdge)决定走 createInventoryTransferNode 还是 END。

四、这条链路怎么测

"跑通了"这三个字我最不信。持久化链路的测试要覆盖时间维度:存进去、进程死了、活过来、还能续上。我把它拆成五个用例:

用例 操作 断言点
TC1 基线(不重启) GET /saleProduct/sale?productId=1 日志依次出现采集 → PredictNode → ExtractNode → HTML email sent success;停在 humanApprovalNode 之前;Redis DB2 出现 graph:checkpoint:content:{threadId}
TC2 宕机恢复 kill 进程 → 重启 → 点 approve?approval=true&threadId=... 不再报 Missing Checkpoint;日志出现 INSERT INTO bb_transfer_order(_item)、Updates: 1、事务 commit
TC3 否决分支 同上,approval=false 不生成调拨单,图走 END
TC4 会话隔离 换另一个 threadId 调 approve 恢复的是对应会话的快照;不传时能看出它落到 $default
TC5 可视化 启动阶段看日志 打印出完整 PlantUML 脚本(下一节)

几个我认为有效的做法:

1)先看空库,再看有库。 启动前用 Another Redis Desktop Manager 连上去确认 DB2 是空的,跑完 TC1 再刷新。这一步是为了证明"数据真的是这次写进去的",而不是上次遗留。

2)断言落在三个地方,不只看 HTTP 状态。 日志(框架行为)、Redis(状态是否可恢复)、MySQL(业务是否闭环)。只调接口拿个 200 是测不出持久化问题的------业务单据在恢复之后才生成。

3)把恢复前后的数据串起来看。 TC2 重启前 bb_transfer_order 最新主单 ID 是 12,采纳之后主表新增 13,子表跟着插入 (transfer_order_id=13, product_id=1, transfer_quantity=80, remark='调拨以支持华北仓2026年Q1预期销售回升'):

text 复制代码
==>  Preparing: INSERT INTO bb_transfer_order_item ( transfer_order_id, product_id, transfer_quantity, remark ) VALUES ( ?, ?, ?, ? )
==> Parameters: 13(Long), 1(Long), 80(BigDecimal), 调拨以支持华北仓2026年Q1预期销售回升(String)
<== Updates: 1
Transaction synchronization committing SqlSession

"从华南仓调 80 件到华北仓"这个结论是模型给的,但80 这个数字真的落库了------这才是端到端。

4)宕机要"真宕"。 别用优雅停机测恢复,exit code 130 / -1 这种被强杀的场景才接近线上------优雅关闭时 shutdown hook 还会去 evict Hikari 连接,行为和直接 kill 不一样。

顺带两个坑

  • @Resource private RedissonClient redissonClient; 注入不上。 不是写法问题,是依赖没进来:这个类来自 Redisson,pom 里少了 starter,要么找不到符号,要么找到了类但容器里没有 Bean。
  • IDEA 启动报 Command line is too long。 多模块 + Redisson + Thymeleaf 依赖一多就撞上 Windows 命令行长度限制,提示是 "Shorten the command line and rerun",在 Run Configuration 里把 shortening 方式改成 JAR manifest 即可。它和持久化无关,但一定会在你第一次加完依赖启动时出现,别像我一样先去查 Redis。

五、让领导一眼看懂你的流程:PlantUML 一键生成

这是我这一章最喜欢的部分。ToB 项目里,"你做的东西长什么样"往往比"你怎么实现的"更早被问。传统做法是在低代码平台上拖一张图、截图、贴进文档,它有两个死穴:流程一改(调个节点顺序、换个方案)图就过期;你得反复截图、更新、同步给每个人,维护成本最后全落在你头上。

PlantUML 的思路是用代码画图:图由脚本渲染,脚本跟代码一起进版本库,代码改了图就跟着改。Spring AI Alibaba 已经内置支持,能自动把工作流定义转成 PlantUML 脚本。

1. 改造编译方法

有个小门槛:不能直接 return stateGraph.compile(compileConfig) ,得先把 CompiledGraph 接住,才能对它做可视化处理。

java 复制代码
// 编译
CompiledGraph compiledGraph = stateGraph.compile(compileConfig);

// 实现 PlantUML 可视化
GraphRepresentation transferGraph = compiledGraph.getGraph(GraphRepresentation.Type.PLANTUML,
        "transferGraph", true);
log.info("plantUML code=[{}]", transferGraph.content());

// TODO: 保存到数据库中,后续通过接口查询与转换
return compiledGraph;

getGraph 最完整的重载是 getGraph(Type, String title, boolean printConditionalEdges);printConditionalEdges 传 true 才看得到条件分支------给开发看打开,给业务方看可以关掉。

GraphRepresentation 本身是个 record,里面就两个访问器:

java 复制代码
public record GraphRepresentation(Type type, String content) {
    public static enum Type {
        PLANTUML(new PlantUMLGenerator()),
        MERMAID(new MermaidGenerator());
        final DiagramGenerator generator;
    }
}

也就是说,把 Type.PLANTUML 换成 Type.MERMAID,同样的图会吐一份 Mermaid 脚本------掘金/公众号这类 Markdown 平台直接就能渲染,这是我最常用的取巧方式。

2. 框架吐出来的脚本长这样

这是 transferGraph.content() 打印出来的真实内容(头部 skinparam 省略若干),可以直接抄去改:

plantuml 复制代码
@startuml
skinparam usecaseStereotypeFontSize 12
skinparam hexagonFontSize 14
skinparam hexagonStereotypeFontSize 12
title "transferGraph"
footer

  powered by spring-ai-alibaba
end footer
circle start<<input>> as __START__
circle stop as __END__
usecase "saleRecordDataNode"<<Node>>
usecase "inventoryOrderDataNode"<<Node>>
usecase "predictNode"<<Node>>
usecase "extractNode"<<Node>>
usecase "sendEmailNode"<<Node>>
usecase "createInventoryTransferNode"<<Node>>
usecase "humanApprovalNode"<<Node>>
hexagon "check state" as condition1<<Condition>>
"__START__" -down-> "saleRecordDataNode"
"__START__" -down-> "inventoryOrderDataNode"
"saleRecordDataNode" -down-> "predictNode"
"inventoryOrderDataNode" -down-> "predictNode"
"predictNode" -down-> "extractNode"
"extractNode" -down-> "sendEmailNode"
"sendEmailNode" -down-> "humanApprovalNode"
"humanApprovalNode" .down.> "condition1"
"condition1" .down.> "createInventoryTransferNode"
"humanApprovalNode" .down.> "createInventoryTransferNode"
"condition1" .down.> "__END__"
"humanApprovalNode" .down.> "__END__"
"createInventoryTransferNode" -down-> "__END__"
@enduml

对着脚本读一遍图,几件事一目了然:

  • 实线 -down-> 是固定边,虚线 .down.> 是条件边------printConditionalEdges=true 的效果就是它。
  • hexagon "check state" as condition1 是条件节点,ApprovalEdge 的判断逻辑挂在这里。
  • __START__ 有两条出边,说明两个采集节点是并行入口 ,都汇到 predictNode。
  • humanApprovalNode 同时连着 condition1、createInventoryTransferNode 和 __END__------这正是我之前调整过的结构:删掉人工节点直连创建节点的边、改走条件边,图才既清晰又不报错。

把这段脚本粘进在线编辑器(app.timelessq.com/office/plantuml-editor)就出图,不用装 Graphviz。

3. 更进一步:让接口直接返回图片

content() 是文本,前端要的是图。路子是拿 PlantUML 官方库把脚本渲染成图片:

java 复制代码
static java.awt.Image plantUML2PNG(String code) throws IOException {
    var reader = new SourceStringReader(code);   // net.sourceforge.plantuml.SourceStringReader
    try (var out = new java.io.ByteArrayOutputStream()) {
        reader.outputImage(out, new FileFormatOption(FileFormat.PNG));
        return javax.imageio.ImageIO.read(
                new java.io.ByteArrayInputStream(out.toByteArray()));
    }
}

// 从 GraphRepresentation 生成图像
static void displayDiagram(GraphRepresentation representation) {
    display(plantUML2PNG(representation.content()));
}

往生产方向推,就是"脚本存进数据库表 + 一个 /graph/png?threadId= 接口",审批页面上直接展示流程走到哪。笔记里那句 // TODO: 保存列表中 就是留给这一步的。

六、写完这一章,我改了三个看法

1)"状态"从 JVM 里的成员变量,变成了一份可寻址的数据。 以前状态就是内存对象,进程没了就没了;现在它是 graph:checkpoint:content:{threadId} 这个 key 下的 JSON。状态一旦变成数据,就能被查询、被审计、被跨实例恢复,甚至被人肉打开看一眼当时卡在哪。

2)中断不是异常,是一等公民。 interruptBefore + withResume 把"等人"变成了流程里的正常一步,而 threadId 是这一切的锚点------会话隔离没做好,持久化做得再稳也是错的。

3)Redis 不是终点。 检查点全塞 Redis,数据量一大就是内存账单。所以 BaseCheckpointSaver 这个薄接口设计得很聪明:四个方法,换 MongoDB(MongoSaver,1.0.0.4 里唯一的数据库方案)、换 PostgreSQL/Oracle 都只是实现同一个接口,表里放检查点内容 + 线程 ID,关系库再补上事务管理。选型问题被接口吸收掉了,这是我喜欢的工程结构。

下一篇进入第 8 章:高并发场景下的微服务与 Kafka 改造。这一章解决的是"一个流程别丢状态",下一章要面对的是"一万个流程同时进来,别把下游压垮"------同步调用链在流量面前撑不住,得把节点之间的耦合交给消息队列。


本系列是我学习 Spring AI Alibaba Graph 的工程复盘,代码依据课程讲义与截图重建(spring-ai-alibaba 1.0.0.4 + spring-ai 1.0.3 + JDK 17 + Spring Boot 3.x + Redisson 3.23.4),未在最新版本上重新验证。若你的版本里 SaverConfig / SaverEnum 有出入,以 IDE 反编译的 BaseCheckpointSaver 实现类为准。

相关推荐
Latchh1 小时前
PDF打开不要密码却显示已加密,前端怎么判断
前端·图像处理·人工智能·计算机视觉·pdf
GoodStudyAndDayDayUp1 小时前
一个简单的java jar跑docker
java·docker·jar
thinking_talk1 小时前
企业AI记忆产品科学选型框架
人工智能·机器学习·ai记忆
卿卿的产品经理日记1 小时前
【AI产品经理实战】Day 33|计算机科学速成:从巴贝奇到“AI是围墙“
人工智能·aigc·产品经理
桃西西呀1 小时前
Spring AI Alibaba 之三:graph-core 状态图引擎深拆
人工智能·spring·llm
mit6.8241 小时前
乔布斯1983年对ai的预测
人工智能
原子延迟1 小时前
PDF权限密码和打开密码差在哪?我拿7份文件试了
图像处理·人工智能·计算机视觉·pdf
丁希希哇1 小时前
强化学习与偏好学习基础:PPO,DPO,GRPO
人工智能·学习·机器学习·大语言模型
richard_yuu1 小时前
OpenCV 实战第 7 篇:Canny 阈值自适配、findContours 层级与 approxPolyDP
人工智能·opencv·计算机视觉