《Spring Ai 2.1 + LangGraph4j》系列教程(上):从零建项目到可恢复的 Java Graph

《Spring Ai 2.1 + LangGraph4j》系列教程(上):从零建项目到可恢复的 Java Graph

你可能已经用 Spring AI 写过一个聊天接口:接收一句话,调用模型,再返回一句话。接下来,用户希望助手查订单、申请退款、等待审批,还希望服务重启以后继续处理。此时需要同时解决两件事:让模型理解对话,让程序可靠地推进业务。

这个系列使用一个 Java 项目,把两件事逐步组合起来。先写最小聊天调用,再写第一张图,接着学习状态、条件分支、持久化和恢复,最后实现"聊天助手通过工具调用退款图"。

本文是上篇;下篇:流式、子图与完整退款助手继续讲解流式输出、子图、人工审批、HTTP/SSE 和工程化验证。

本项目不讲解AI Agent有关相关文章请参考过往文章:《Java + Spring 实现 Hermes Agent:从源码看多模型接入、子代理、人审与沙箱》、《从向量检索到混合搜索优化:Spring AI 2.0.1 + pgvector RAG 实战》

阅读约定与版本

本教程对应这个开源项目的已验证组合:

组件 本文版本 用途
Java 25 应用和 CLI 运行环境
Maven 3.9+ 依赖管理与构建
Spring Boot 4.1.0 Web 服务、配置和依赖注入
Spring AI 2.1.0-RC1 模型、原生工具调用、Session 会话
LangGraph4j 1.9.7 状态图、checkpoint、中断、恢复和子图

RC1 是候选发布版。这里明确锁定版本,不把它写成已发布的 2.1.0 正式版,也不承诺未来版本的 API 完全相同。项目的 pom.xml是依赖版本的依据。

系列围绕五项核心能力展开:Graph API、持久化、持续运行、流式输出、子图。每一项都先解释要解决的问题,再给出 Java 实现,最后说明如何运行和验证。阅读只需要本系列文章和本项目源码。

文中的代码分为三类:

  • 完整示例:可以按指定文件名直接保存、编译。
  • 源码摘录/学习片段:省略 import、装配或其他节点;可在链接的源文件中查看完整实现。
  • 设计伪代码:解释业务设计,不能当作框架原生 API 直接粘贴运行。

所有源码链接都相对于本仓库,可在 Git 平台和本地 Markdown 阅读器中打开。若希望查看完整实现,优先跟随每节结尾的源码链接。

先跑通,再读源码

第一次阅读时,建议先用离线模式完成实验。你不需要先申请模型 key,也不需要启动 Web 服务或数据库:

powershell 复制代码
# 在项目根目录执行;使用 JDK 25,Maven 3.9+
./scripts/run-graph-examples.ps1 -Example graph-api -Offline

脚本会先编译、计算依赖 classpath,再运行 GraphExamples.main()。-Offline 表示使用项目内的 OfflineChatModel,它替代外部模型服务,不是 Maven 的离线依赖模式。首次执行仍可能下载 Maven 依赖。

运行成功后会看到 Mermaid 图定义、status、topic、笑话正文和 trace。这时先找 trace:它记录实际执行了哪些节点,比模型回答的措辞更能说明图是否按预期运行。

阅读阶段 先回答什么问题 对应源码
第 3 讲 一次模型请求如何发出?工具调用如何回来? AssistantConfig
第 4 讲 一份数据如何经过节点、合并更新、选择分支? LearningState、GraphApiExample
第 5 讲 如何找到一次执行以及其中的某个历史位置? PersistenceExample
第 6 讲 进程结束后,如何带着人的决定继续? DurableExecutionExample

每个实验都按"输入 → 节点返回的更新 → 合并后的状态 → 下一节点 → 观察结果"阅读。暂时不理解的框架类型,可以先回到这五步定位它的作用。文中的固定文本只用于解释数据变化,真实模型不保证生成相同句子。

第 1 讲:先分清 Spring AI 和 LangGraph4j

假设用户说:"O-2001 买错了,帮我退款。"

Spring AI 负责把自然语言、历史对话和工具定义交给模型,并执行模型请求的工具。LangGraph4j 负责按预先定义的节点、边和状态推进退款。

text 复制代码
用户消息
  → Spring AI ChatClient:理解意图、追问、选择工具
  → start_refund_workflow:结构化业务入口
  → LangGraph4j:查订单 → 校验规则 → 审批 → 执行退款
  → 结构化结果
  → Spring AI:向用户解释结果

这里并不要求每个图节点都调用模型。检查订单是否超过 30 天,用 Java 比较时间就足够;计算退款金额,也应使用业务数据和 BigDecimal。在写笑话、理解文本等任务中,节点再调用 ChatClient。

两种入口结构都成立:

结构 适合的情况 需要关注
用户 → 图 → 图节点调用 Spring AI 任务有明确阶段,整个处理路径需要统一编排 普通聊天、追问也需要设计相应路由
用户 → Spring AI → 工具 → 业务图 通用助手同时提供咨询和多种业务能力 模型选工具具有不确定性,业务规则仍需服务端校验

本项目的主应用采用第二种;五个独立 Graph 示例则演示第一种中的"图节点调用模型"。两者放在同一项目,可以帮助你理解它们如何组合。

1.1 为什么不把所有步骤写在一个方法里

只做"查订单,然后退款",普通 Java 方法当然可以实现。问题出现在方法执行到一半时:用户还没有审批,HTTP 请求应该返回什么?服务重启后该从哪里继续?支付成功但响应丢失,再点一次会怎样?

状态图把这些问题变成可检查的数据:订单信息保存在哪个字段,下一步是哪个节点,流程是否需要人工决定,以及恢复时哪些工作可以复用。图不会自动替你实现业务事务;它提供执行结构和保存执行进度的机制。

可以把一次运行想成"带着一份表单沿办理步骤前进"。State 是表单内容,Node 是办理步骤,Edge 是转交规则,Checkpoint 是办理到某个位置时保存的表单和位置。这个类比只用于入门:Java 中 State 由 Map 和类型化 getter 表达,节点返回局部更新,不需要原地改同一个表单对象。

1.2 一次聊天与一笔业务流程的时间跨度

text 复制代码
第 1 轮:用户提供订单号 → 助手追问原因                 没有退款图
第 2 轮:用户补充原因 → 启动退款图 → 返回待审批         图停在审批前
过了十分钟:用户点击批准 → 恢复原退款图 → 返回退款结果   沿用原 threadId
第 3 轮:用户询问进度 → 助手查询原退款流程              不创建新图

一次聊天可能不启动图,也可能关联多个订单的图;一张图也可能跨多个请求继续。因此后面会分别使用 conversationId、threadId 和 requestId,不能用一个"聊天 ID"承担所有职责。

第 2 讲:从一个空目录创建项目

2.1 准备目录

新建一个 Maven 项目,目录如下:

text 复制代码
spring-ai-graph-demo/
  pom.xml
  src/main/java/com/example/refunddemo/
    RefundDemoApplication.java
  src/main/resources/
    application.yml
  src/test/java/com/example/refunddemo/

在终端执行 java -version 和 mvn -version。本项目使用 Java 25;如果 Maven 显示的 Java 版本不一致,先修正 JAVA_HOME 和 PATH,再排查编译错误。

2.2 添加依赖

下面是可以用于新建项目的完整基础 POM。完整仓库还配置了真实模型集成测试 profile,后面会介绍。

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>spring-ai-graph-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <properties>
        <java.version>25</java.version>
        <spring-ai.version>2.1.0-RC1</spring-ai.version>
        <langgraph4j.version>1.9.7</langgraph4j.version>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-jackson2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-client-chat</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-session-advisor</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-deepseek</artifactId>
        </dependency>
        <dependency>
            <groupId>org.bsc.langgraph4j</groupId>
            <artifactId>langgraph4j-core</artifactId>
            <version>${langgraph4j.version}</version>
        </dependency>
        <dependency>
            <groupId>org.bsc.langgraph4j</groupId>
            <artifactId>langgraph4j-spring-ai</artifactId>
            <version>${langgraph4j.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

为什么有 spring-boot-jackson2?本项目使用的 LangGraph4j Spring AI 状态序列化适配器仍依赖 Jackson 2,而 Spring AI 2.x 的一些组件使用 Jackson 3。两个包名体系不同,不应仅根据类名把所有 ObjectMapper import 替换掉。后面的序列化章节会解释具体处理。

先按职责理解依赖,暂时不必逐个背 artifactId:

依赖 在本项目中做什么 什么时候会用到
spring-ai-client-chat 提供 ChatClient 调用入口 组织提示词、消息、工具
spring-ai-starter-model-deepseek 创建具体 ChatModel,访问模型服务 真实模型请求
spring-ai-session-advisor 加载和记录聊天会话 多轮聊天
langgraph4j-core StateGraph、节点、边、checkpoint 编排与恢复
langgraph4j-spring-ai Spring AI 消息序列化、流式适配等 在图中使用 Spring AI

BOM 在 dependencyManagement 中统一规定 Spring AI 版本;它本身不会把所有 Spring AI 模块加入应用。真正要使用的模块仍需写在 dependencies 中。LangGraph4j 使用另一套版本号,所以这里单独指定 1.9.7。

2.3 添加启动类和模型配置

完整启动类 RefundDemoApplication.java:

java 复制代码
package com.example.refunddemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RefundDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(RefundDemoApplication.class, args);
    }
}

模型配置片段:

yaml 复制代码
spring:
  ai:
    model:
      chat: deepseek
    deepseek:
      api-key: ${DEEPSEEK_API_KEY:}
      base-url: ${DEEPSEEK_BASE_URL:https://xxxx/v}
      chat:
        model: ${DEEPSEEK_MODEL:deepseek-v4-flash}
        thinking:
          type: disabled
        max-tokens: 1024
server:
  port: ${SERVER_PORT:8080}

这是本文验证的兼容接口地址和模型名。切换服务商时,应同时核实 base URL、模型标识、工具调用和流式协议兼容性。不能只更换 key,就假设其他服务支持全部参数。

PowerShell 启动:

powershell 复制代码
$env:DEEPSEEK_API_KEY = '<你的 API key>'
$env:DEEPSEEK_BASE_URL = 'https://xxxx/v3'
$env:DEEPSEEK_MODEL = 'deepseek-v4-flash'
mvn spring-boot:run

key 只放在环境变量或受保护的部署配置中,不写进 YAML 默认值。本文代码不会输出 key。

源码参考:(../../src/main/resources/application.yml)、(../../src/main/java/com/example/refunddemo/config/RefundDemoConfig.java)。仓库为普通调用和流式调用统一配置了 JDK HTTP 客户端,使用系统 DNS、HTTP/1.1 和连接超时。

2.4 先确认自己运行的是哪一种入口

本项目有两个入口,运行方式和所需环境不同:

入口 命令 观察什么
独立 Graph CLI ./scripts/run-graph-examples.ps1 -Example graph-api -Offline 终端里的图、状态和执行轨迹
Spring Boot 主服务 mvn spring-boot:run HTTP 聊天、工具调用和 SSE;需要配置模型 key

GraphExamples 手工创建 ChatClient,不会启动 Spring 容器;RefundDemoApplication 才会扫描 Bean、读取 YAML、启动 HTTP 服务。CLI 的 -Offline 开关不会改变 Web 服务使用的模型。

Windows 上如果 Maven 没有加入 PATH,可以给脚本传安装位置:

powershell 复制代码
$mavenCommand = 'C:\你的Maven目录\bin\mvn.cmd'
./scripts/run-graph-examples.ps1 -Example graph-api -Offline -Maven $mavenCommand

如果 java -version 是 25,mvn -version 却显示 17,说明两个命令选取了不同的 JDK。release version 25 not supported 应先检查工具环境;HTTP 401 应检查模型服务凭据;找不到模型名称应检查提供方配置。这三类问题尚未涉及图的路由逻辑。

第 3 讲:先理解一个 Spring AI 助手

3.1 ChatModel、ChatClient 和 Advisor

ChatModel 对接具体模型提供方;ChatClient 提供组织提示词、消息、工具、流式输出的调用入口;Advisor 在请求经过模型前后处理会话、工具循环等逻辑。

最小学习片段,假设 model 是 Spring 注入的 ChatModel:

java 复制代码
ChatClient client = ChatClient.builder(model).build();
String answer = client.prompt()
        .system("你是一个中文学习助手。")
        .user("用一句话解释状态图。")
        .call()
        .content();

这只代表一次请求。模型不会因为你复用了同一个 Java ChatClient 对象,就自动记住上次消息。记忆要由应用显式配置。

逐行看这个调用:builder(model) 选择提供方,prompt() 创建本轮请求,system() 指定助手行为,user() 加入用户消息,call() 选择普通调用,content() 取出文本。它们没有定义任何 Graph 节点或 checkpoint。

String 适合直接显示答案,但会丢掉部分结构。如果接下来需要保存模型消息,应读取 .call().chatResponse(),再从响应中取得输出 AssistantMessage。本项目写作节点使用后一种方式,同时保存正文与消息,供重写和序列化实验使用。

另外,提示词里的"最多 80 字"只是要求,模型可能超出。后面的图会用 Java 检查 String.length() 并决定是否重写。这个计数是 Java 字符串长度,遇到某些 emoji 不等于用户看到的字符个数,也不是模型 token 数;这里用它演示确定性的业务检查。

3.2 工具是业务能力的入口

先用一个无副作用的工具理解机制。学习片段:

java 复制代码
class PolicyTools {
    @Tool(description = "查询本演示的退款期限和审批规则")
    String refundPolicy() {
        return "已支付订单签收不超过30天可申请;金额达到300元或有风控标记时需要审批。";
    }
}

String answer = client.prompt()
        .user("你们的退款规则是什么?")
        .tools(new PolicyTools())
        .call()
        .content();

工具描述交给模型。模型可能返回"调用 refundPolicy"而不是普通文字;Spring AI 找到对应工具并执行,再把结果交给模型组织回答。

text 复制代码
消息 + 工具定义 → 模型请求调用工具
               → Spring AI 执行 Java 方法
               → 工具结果加入上下文
               → 模型生成最终回答

在本文版本中,ChatClient 的工具循环由 ToolCallingAdvisor 管理。通常不需要再自己写一套 while 循环。工具注册也不是调用保证:如果用户点击的是明确的业务按钮,后端可以直接调用业务服务,无需让模型再决定一次是否执行。

更具体地说,一次前端请求可能触发两次或更多次模型请求:

text 复制代码
第 1 次模型请求:用户问题 + refundPolicy 的名称、说明、参数 schema
第 1 次模型响应:ToolCall(name=refundPolicy, arguments={})
框架在应用中执行:PolicyTools.refundPolicy()
第 2 次模型请求:原消息 + 工具调用消息 + 工具执行结果
第 2 次模型响应:适合给用户阅读的中文答案

模型生成的是工具名称和参数,Java 方法实际在应用进程中执行。@Tool 的说明用于帮助模型选择;参数是否合法、用户能否执行、重复请求如何处理,仍由服务端判断。不要把"模型选择正确工具"理解为"工具执行必然成功"。

这里 @Tool 来自 org.springframework.ai.tool.annotation.Tool,不是 LangChain4j 的同名注解。工程名称中即使出现 langchain4j,也应以当前 POM 和 import 为准。

3.3 Spring AI 2.1 的会话

本项目使用 SessionService 和 SessionAdvisors。下面是装配学习片段,完整类见 AssistantConfig.java:

java 复制代码
SessionService sessions = DefaultSessionService.builder()
        .sessionRepository(InMemorySessionRepository.builder().build())
        .build();

ChatClient client = ChatClient.builder(model)
        .defaultAdvisors(SessionAdvisors.builder(sessions).build())
        .build();

String reply = client.prompt()
        .user("我的代号是蓝鲸42,请记住。")
        .advisors(SessionAdvisors.session("conversation-001"))
        .call().content();

下一轮继续使用同一会话 ID,才会加载同一份会话记录。切换 ID 应隔离历史。生产应用还要验证 ID 属于当前登录用户,不能把可猜测的会话 ID 当作身份认证。

本演示使用内存 repository,所以应用重启后聊天记录丢失。会话记忆和图 checkpoint 是不同的数据:前者保存聊过什么,后者保存流程走到哪里。

本版本已经提供新的 Session 机制。不要把其他版本教程里的 MessageChatMemoryAdvisor、手写消息列表和 SessionAdvisors 同时套上去,否则可能重复注入历史。

3.4 用两轮消息理解会话记忆

下面接着 3.3 的 client 执行,是学习片段:

java 复制代码
// 与上一轮相同 ID:Session Advisor 加载"我的代号是蓝鲸42"等历史。
String sameSession = client.prompt()
        .user("我的代号是什么?")
        .advisors(SessionAdvisors.session("conversation-001"))
        .call().content();

// 不同 ID:查找另一份历史,而不是继续 conversation-001。
String otherSession = client.prompt()
        .user("我的代号是什么?")
        .advisors(SessionAdvisors.session("conversation-002"))
        .call().content();

可观察的预期是:同会话能够根据历史回答代号,另一会话不应获得前一会话的内容。模型具体措辞不固定,所以测试应检查是否泄漏事实,而非要求回答整句完全一致。

官方 Session 机制把消息等内容记为事件,再构建模型看到的历史视图;不是让模型永久保存你的 Java 对象。该版本要求每次请求都指定 session ID,没有一个可安全共用的默认会话。未来如果配置历史压缩,压缩改变的是读取视图,不能因此认为业务 checkpoint 也被压缩或删除。

官方对照:ChatClient 工具循环、工具调用、Session Memory。

第 4 讲:Graph API------从第一张图开始

4.1 三个概念:State、Node、Edge

先看最简单的"主题 → 文本"流程:

text 复制代码
START → write → END
  • State :节点共享的执行数据,例如 topic、joke。
  • Node:读取状态,完成工作,返回需要修改的字段。
  • Edge:定义接下来运行哪个节点。

图是有向图,不是可视化图片本身。图片只是帮助我们理解实际执行定义。

4.2 一张不依赖模型的完整图

下面是完整示例 HelloGraph.java,可以放到已有 Maven 工程的 src/main/java,使用 IDE 运行 main。先不接模型,减少变量。

java 复制代码
import org.bsc.langgraph4j.GraphInput;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.state.AgentState;

import java.util.Map;

import static org.bsc.langgraph4j.GraphDefinition.START;
import static org.bsc.langgraph4j.GraphDefinition.END;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;

public class HelloGraph {
    public static void main(String[] args) throws Exception {
        // 1. AgentState::new 告诉框架怎样从状态 Map 创建可读取的状态对象。
        var graph = new StateGraph<AgentState>(AgentState::new)
                .addNode("write", node_async(state -> {
                    // 2. 读取输入;orElseThrow 表示缺少 topic 就明确报错。
                    String topic = state.<String>value("topic").orElseThrow();
                    // 3. 仅返回变化字段:topic 不会因为未出现在返回值中而消失。
                    return Map.of("joke", "程序员给" + topic + "写了一个接口。");
                }))
                // 4. START/END 是框架的入口/出口标记,不是需要实现的业务节点。
                .addEdge(START, "write")
                .addEdge("write", END)
                // 5. 编译定义;此时尚未运行 write 节点。
                .compile();

        // 6. 开始一次新执行,并等待它结束;本例没有配置持久化。
        var result = graph.invoke(GraphInput.args(Map.of("topic", "猫")), RunnableConfig.empty())
                .orElseThrow();
        System.out.println(result.<String>value("joke").orElseThrow());
    }
}

compile() 把构建定义转为可执行图,并检查图结构。输入通过 GraphInput.args(...) 传入。无初始参数的新执行可用 GraphInput.noArgs();恢复旧执行使用 GraphInput.resume(),三者不要混淆。

节点返回的是 joke 的更新,不必返回整个 state。原来的 topic 仍然保留。

4.2.1 跟着一份状态走完整个过程
时间点 本次输入或节点返回值 框架合并后的状态
调用图 {topic: "猫"} {topic: "猫"}
write 返回 {joke: "程序员给猫写了一个接口。"} {topic: "猫", joke: "程序员给猫写了一个接口。"}
到达 END 没有新业务更新 保留上一步状态,作为最终结果

所以 result.value("topic") 仍能取到"猫",result.value("joke") 才是新生成的文本。invoke() 返回 Optional;这个最小图预期有结果,所以使用 orElseThrow() 显式拒绝"没有结果"。

新手常问 state.<String>value("topic") 的点号后为什么有类型参数。因为 State 内部存的是 Object,这里告诉 Java 本次期望读取 String。它不等于 JSON Schema 验证;如果写入的是数字,类型约定仍会出错。实际业务用常量、getter、命令 DTO 约束读写。

state -> {...} 是 lambda 节点函数;node_async 把同步函数适配成框架要求的异步接口。它没有把你的阻塞 HTTP 请求自动改为非阻塞,也没有单独配置一个业务线程池。

4.2.2 为什么每次返回新 Map

推荐把节点写成"读取快照 → 计算 → 返回更新"。不要对 state.data() 或其中的可变 List 原地增删,再试图让框架猜发生了什么。明确返回更新,框架才知道哪些字段需要经过 channel 合并。

Map.of(...) 和 List.of(...) 创建的集合不可修改,且不接受 null。没有订单时可以不返回 order 字段,或用显式业务状态表示;不要写 Map.of("order", null),那会先抛 NullPointerException,还没有进入任何 reducer。

第一张图不需要自定义 State 类。字段增多后,把 "topic" 抽为 LearningState.TOPIC,再提供 topic()、attempts() 等 getter,可以减少字符串拼写错误,让节点只关注它实际使用的业务字段。

4.3 接入模型,做一个会修改笑话的图

把上一节固定文本的节点替换为 ChatClient 调用,然后增加主题细化和长度检查:

核心源码摘录:

java 复制代码
.addNode("write_joke", node_async(state -> {
    UserMessage request = new UserMessage("写一个关于「" + state.topic() + "」的简短笑话。");
    var answer = client.prompt()
            .messages(state.messages()).messages(request)
            .call().chatResponse().getResult().getOutput();
    return Map.of(
            LearningState.JOKE, answer.getText(),
            LearningState.ATTEMPTS, state.attempts() + 1,
            LearningState.MESSAGES_STATE, List.<Message>of(request, answer),
            LearningState.TRACE, List.of("write_joke"));
}))

真实实现还给模型提示长度限制,并处理空响应。模型生成文本,Java 检查是否符合长度要求。超过两次仍不满足时返回 LIMIT_REACHED,不会偷偷把失败结果写成成功。

完整源码:GraphApiExample。运行:

powershell 复制代码
./scripts/run-graph-examples.ps1 -Example graph-api -Topic '程序员与猫'

先加 -Offline 观察固定结果,再去掉它运行真实模型。同一张图在两种模式下使用相同的节点、边和状态合并逻辑,替换的只是模型提供方。

4.3.1 把循环展开一次

假设 maxCharacters=80,第一次生成 100 字符,第二次生成 60 字符:

刚完成的节点 状态更新重点 条件边如何判断 下一步
refine_topic topic 被细化;trace 追加一次 固定边 write_joke
第一次 write_joke joke=100 字符;attempts=1;追加两条消息 100 > 80 && 1 < 2 为 true 再次 write_joke
第二次 write_joke joke 覆盖为 60 字符;attempts=2;再追加两条消息 长度已达标 finish
finish status=ACCEPTED 固定边 END

最终 trace 应是 [refine_topic, write_joke, write_joke, finish]。attempts 是累计写作次数,joke 只保存最后一版;messages 保留两轮写作的问答,供第二次请求看到前次文本。

如果第二次仍是 100 字符,条件边也会去 finish,但状态是 LIMIT_REACHED。这表示"尝试结束但质量条件未满足",调用方应据此提示或转人工,不能把到达 END 一律解释成业务成功。

4.4 Reducer:一个字段收到更新后怎么合并

假设旧状态是 attempts=1、trace=[write_joke],节点返回 attempts=2、trace=[write_joke]。

普通字段覆盖以后,attempts 变为 2。trace 希望保留两次实际执行,所以需要列表累加 reducer。

java 复制代码
public static final Map<String, Channel<?>> SCHEMA = Map.of(
        MESSAGES_STATE, Channels.appenderWithDuplicate(ArrayList::new),
        TRACE, Channels.appenderWithDuplicate(ArrayList::new));

本项目使用 appenderWithDuplicate 保留重复项,因此两次执行 write_joke 会留下两条记录。Channels.appender(...) 默认去重,更适合希望消除重复元素的字段。选择哪一种,取决于字段表达的是执行历史还是唯一元素集合。

节点应返回本次新增消息。若把"旧历史 + 新消息"整份返回给追加 reducer,旧历史会再次追加,越聊越重复。

LearningState 继承 MessagesState<Message>,为普通字段提供类型化 getter,并显式配置消息和 trace 的 channel。源码:LearningState。

4.4.1 用一个不调用模型的实验验证 reducer

下面是完整独立示例 StateMergeDemo.java,和 HelloGraph 一样可放进现有项目运行。刻意让两个节点都返回相同的轨迹,便于观察重复项是否保留:

java 复制代码
import org.bsc.langgraph4j.GraphInput;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.state.AgentState;
import org.bsc.langgraph4j.state.Channel;
import org.bsc.langgraph4j.state.Channels;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import static org.bsc.langgraph4j.GraphDefinition.START;
import static org.bsc.langgraph4j.GraphDefinition.END;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;

public class StateMergeDemo {
    public static void main(String[] args) throws Exception {
        // 仅 trace 使用追加策略,name 仍采用默认覆盖行为。
        Map<String, Channel<?>> channels = Map.of(
                "trace", Channels.appenderWithDuplicate(ArrayList::new));
        var graph = new StateGraph<AgentState>(channels, AgentState::new)
                .addNode("first", node_async(state -> Map.of(
                        "name", "张三", "trace", List.of("visited"))))
                .addNode("second", node_async(state -> Map.of(
                        "name", "李四", "trace", List.of("visited"))))
                .addEdge(START, "first")
                .addEdge("first", "second")
                .addEdge("second", END)
                .compile();
        var state = graph.invoke(GraphInput.args(Map.of("topic", "猫")),
                RunnableConfig.empty()).orElseThrow();
        System.out.println(state.<String>value("topic").orElseThrow());
        System.out.println(state.<String>value("name").orElseThrow());
        System.out.println(state.<List<String>>value("trace").orElseThrow());
    }
}

预期输出:

text 复制代码
猫
李四
[visited, visited]

topic 从未被修改,所以保留;name 被第二个节点覆盖;trace 追加两次。将 appenderWithDuplicate 改成 appender,相同字符串会去重,结果变成 [visited]。去重依据元素相等性,不能把它当成"模型语义上相似的消息会自动合并"。

4.4.2 默认值、消息和运行历史

ArrayList::new 为缺失的列表提供初值,attempts() 的 orElse(0) 为未写入的计数提供读取默认值。getter 的默认返回值不表示这个字段已经被写入 checkpoint。例如第一次读取次数为 0,第一次节点返回更新后才有 attempts=1。

Spring AI 的 UserMessage、AssistantMessage 是消息对象,不能简单用任意 Map 冒充。MessagesState<Message> 提供访问方法,SpringAIJacksonStateSerializer 处理消息类型的保存和恢复。历史只存在 State 中还不够,节点必须通过 .messages(state.messages()) 显式交给模型。

这里的追加策略也不等同于 Python LangGraph 的 add_messages 按消息 ID 更新语义。需要编辑或删除旧消息时,应核实当前 Java channel 的实际行为,设计对应更新方式,不能照搬 Python 的消息操作对象。

4.5 普通边、条件边与入口

普通边固定执行顺序:

java 复制代码
.addEdge(START, "refine_topic")
.addEdge("refine_topic", "write_joke")

条件边返回路由键,再映射到节点:

java 复制代码
.addConditionalEdges("write_joke", state -> CompletableFuture.completedFuture(
        state.joke().length() > maxCharacters && state.attempts() < 2
                ? "rewrite" : "done"),
        Map.of("rewrite", "write_joke", "done", "finish"))

rewrite/done 是路由标签;write_joke/finish 才是节点名。把业务判断与节点名映射分开,路由逻辑更容易测试。

入口通过 START 的边声明。需要按输入选择入口时,可以让 START 使用条件路由,或增加一个显式分发节点。例如根据请求类型,分别进入订单查询或退款办理分支。

CompletableFuture.completedFuture("rewrite") 表示路由判断已经算完,把结果包装成异步接口所需的值;它不会再启动一个后台任务。条件边读取的是前一个节点更新已经合并后的 State,所以能够看到新的 attempts 和 joke。

注册边时检查三个名字:来源节点是否存在,路由返回值是否包含在映射键中,目标节点是否存在。比如返回 "retry",却只配置 rewrite/done,就无法决定下一步。

学习分支时先让一个来源节点只有一种明确的出边定义:固定边或条件边。不要以为把两种写在一起就相当于 Java 的 if/else。需要并行时使用框架支持的并行结构,并单独考虑多个分支同时更新相同字段的合并规则。

4.6 输入、输出与内部状态

图执行过程中需要的数据,通常比客户端提交和接收的数据更多。可以在业务边界使用不同 DTO,明确区分输入、内部状态和输出:

text 复制代码
RefundCommand(外部输入)
    → RefundState(图内部执行数据)
    → RefundResult(对外结果)

例如 RefundCommand 只有订单号和原因;金额从真实订单得到,审批由审批接口写入;内部工具消息和运行依赖不需要全部暴露给客户端。

命令进入图时映射为 State,图返回时再转换为 Result。这样既能限制外部可写的字段,也能避免把内部执行细节直接泄露给客户端。源码:退款 schema。

4.7 动态任务分发:分别执行,再汇总

假设用户给出三个主题,希望分别生成笑话再汇总。每个写作任务都有不同输入,可以独立运行,最后把三个结果合并。这种模式通常称为 fan-out/fan-in,即任务分发与结果汇聚。

固定分支可以使用图的并行边;运行时数量不确定的任务,可以在节点中使用受限并发的 Java 任务,或为每项任务启动独立子图。先区分"图结构已知的并行分支"和"输入决定数量的一批任务",再选择实现方式。

下面是设计伪代码 ,其中 boundedExecutor、writerGraph、toChildInput 由应用定义:

java 复制代码
// 设计伪代码:控制并发、超时和任务标识后再落地
var jobs = topics.stream()
        .map(topic -> CompletableFuture.supplyAsync(
                () -> writerGraph.invoke(toChildInput(topic)).orElseThrow(),
                boundedExecutor))
        .toList();
var outputs = jobs.stream().map(CompletableFuture::join).toList();
return Map.of("results", outputs);

这个写法中的任务结果暂存在当前节点内。如果父节点失败重跑,已经完成的部分也可能再次执行。需要独立恢复每一项时,应持久化 taskId、完成状态和子流程 ID,并明确失败后的重试、超时与取消策略。

4.8 Command:同时更新状态与选择下一步

LangGraph4j 的 org.bsc.langgraph4j.action.Command 包含 gotoNode 和 update。可以在条件动作中同时选择路由并更新状态。学习片段:

java 复制代码
AsyncCommandAction<LearningState> route = (state, config) ->
        CompletableFuture.completedFuture(
                new Command("done", Map.of(LearningState.STATUS, "CHECKED")));
builder.addConditionalEdges("check", route, Map.of("done", "finish"));

这里的 builder/check/finish 是已定义的图及其节点。Command 让路由决定和相应状态更新放在一起。暂停后的流程恢复则通过 updateState(...) 保存外部决定,再使用 GraphInput.resume() 继续,后面会给出完整示例。

上游定义:Command.java(v1.9.7)。

4.9 配置、递归限制与可视化

编译配置和单次运行配置分开:

java 复制代码
var compileConfig = CompileConfig.builder().recursionLimit(10).build();
var runConfig = RunnableConfig.builder()
        .threadId("joke-001")
        .putMetadata("tenantId", "demo")
        .build();

recursionLimit 是图运行的步骤保护,不是"允许模型重试十次"。业务层仍应有明确的尝试次数、超时和终止条件。node_async 适配同步函数,也不意味着自动把阻塞工作迁移到专用线程池;并发与执行器需要按实际调用路径设计。

可视化读取真正的图定义:

java 复制代码
String mermaid = graph.getGraph(GraphRepresentation.Type.MERMAID).content();

把 Mermaid 放进支持它的 Markdown 阅读器即可查看。图变更后重新生成定义,可以让代码评审和博客中的执行路径保持一致;需要图片时,再通过 Mermaid 渲染器导出。

4.10 本讲自查:构建、调用、状态分别在什么时候发生

text 复制代码
new StateGraph(...)      规定如何创建状态、如何合并字段
addNode(...)             登记节点动作,此时不执行动作
addEdge(...)             登记后继关系,此时不选择路线
compile(...)             检查图定义、绑定编译配置,得到 CompiledGraph
invoke/stream(...)       提交输入并执行,节点和路由在这时发生

输入形式也应分开记:

Java API 本文用途 示例
GraphInput.args(map) 提交本次输入;本文用于启动新任务 主题、退款命令
GraphInput.noArgs() 开始无显式输入的运行 节点自行生成主题
GraphInput.resume() 从已保存的位置继续 审批后、失败后恢复

同一 thread 上输入与已有状态的合并有框架规则;本项目的 start 方法主动拒绝复用已有 thread,以免读者把"提交输入"和"恢复"混为一谈。恢复时要使用原 saver、原 threadId 和正确配置。

官方阅读顺序:核心概念 → Getting started 示例 → Channels 源码。官方文档中涉及具体枚举和默认值时,需再核对同 tag 源码;例如本文使用的 StreamMode 只有 VALUES/SNAPSHOTS。

第 5 讲:持久化------知道流程走到哪里

5.1 Thread 和 Checkpoint 分别是什么

这里的 thread 是一个逻辑执行标识,不是 Java 操作系统线程。

text 复制代码
threadId = joke-001
  checkpoint A:初始状态
  checkpoint B:主题已生成,下一步 write_joke
  checkpoint C:笑话已生成

每个 checkpoint 保存某个执行边界上的状态和下一步信息。使用同一 threadId 才能找到同一流程;每次恢复时随机生成新 ID,就会失去关联。

把 threadId 理解为一本流程档案的编号,checkpointId 是档案内某一页的编号。同一本档案可以有多页,不同档案即使都经过 write_joke,也不会因此成为同一流程。

名称 表达什么 是否需要恢复时沿用
Java Thread 当前由哪个执行线程运行代码 不需要,恢复可以换进程、换执行线程
LangGraph4j threadId 要找到哪笔流程的 checkpoint 需要
checkpointId 该流程中的某个保存位置 从指定历史位置继续时需要
Spring AI session ID 要加载哪份聊天历史 多轮聊天时需要,与图 ID 分开

运行 Graph 本身不一定需要 saver;但"查历史、暂停后恢复"依赖 saver。只有一个 threadId 字符串而没有存储实现,不会凭空生成持久化数据。

5.2 给图添加 Saver

源码摘录:

java 复制代码
MemorySaver saver = new MemorySaver();
var graph = builder.compile(CompileConfig.builder()
        .checkpointSaver(saver)
        .releaseThread(false)
        .build());
var config = RunnableConfig.builder().threadId("joke-001").build();
graph.invoke(GraphInput.noArgs(), config);

MemorySaver 保存在当前进程内。它能演示 checkpoint,但不能跨进程恢复。

releaseThread(false) 用于在流程到达 END 后仍保留活动线程状态,便于查询与时间旅行。默认释放行为与 saver 的归档实现有关,不能假设 END 后永远能继续通过活动历史接口查询。

这里特意显式配置的原因是:1.9.7 的 CompileConfig 源码将 releaseThread 初始化为 true,而同 tag 概念文档的配置表写了 false。本教程按源码行为处理,不依赖该表的默认值。

注意对象的生命周期:若每次 HTTP 请求都重新创建 new MemorySaver(),上一请求的存储就不会被这次调用看见。主应用把工作流和 saver 作为长期存活的 Bean 使用;学习示例则把二者一起返回。

5.2.1 一次执行会保存哪些内容

PersistenceExample 的路径是:

text 复制代码
START → generate_topic → write_joke → END

从业务角度,可以观察到以下快照。c0/c1/c2 是说明用的别名,真实 checkpointId 由框架产生:

说明用编号 已关联的节点 关键状态 后续位置
c0 START 尚无生成主题 generate_topic
c1 generate_topic topic 已有值,trace 包含 generate_topic write_joke
c2 write_joke joke、messages 已有值 流程结束

关键不是记住"永远有几个快照",而是检查实际打印的 node/nextNodeId。复杂图、子图和版本变化可能增加不同的保存边界,业务代码不能靠"取历史数组的第 2 项"来定位审批。

5.3 查看当前状态和历史

java 复制代码
var current = graph.getState(config);
var history = graph.getStateHistory(config);
for (var snapshot : history) {
    System.out.println(snapshot.node() + " -> " + snapshot.nextNodeId());
}

node 表示快照关联的节点,nextNodeId 表示恢复时的下一步。不要仅根据"最后一个日志字符串"推断恢复位置。

需要可选查询时,项目也使用 graph.stateOf(config),不存在时返回空 Optional,再由业务层决定 404 或其他行为。

先运行观察命令:

powershell 复制代码
./scripts/run-graph-examples.ps1 -Example persistence -Offline

输出中的 checkpoint 行形如:

text 复制代码
checkpoint=<实际ID> node=write_joke next=... createdAt=...
checkpoint=<实际ID> node=generate_topic next=write_joke createdAt=...
checkpoint=<实际ID> node=__START__ next=generate_topic createdAt=...

这里省略真实 ID、时间和结束标记的打印形式,重点观察中间一行。getStateHistory 按较新快照在前的顺序返回,所以它不是直接按时间从早到晚排列的执行日志。

5.3.1 查询一个确定的历史快照

以下接着已有的 graph/config 执行,是可放入 CLI 持久化分支的学习片段:

java 复制代码
// 按节点含义选择,不按列表序号猜位置。
var selected = graph.getStateHistory(config).stream()
        .filter(snapshot -> "generate_topic".equals(snapshot.node()))
        .findFirst().orElseThrow();

// checkPointId 的大小写就是该版本 API 的拼法。
var historicalConfig = RunnableConfig.builder(config)
        .checkPointId(selected.checkpointId())
        .build();
var historicalState = graph.getState(historicalConfig);
System.out.println(historicalState.state().topic());
System.out.println(historicalState.nextNodeId()); // write_joke

getState 只是读取,不会再次调用模型。getStateHistory 也不会重放历史;只有后面的 invoke(GraphInput.resume(), ...) 才会继续执行节点。

无 checkpoint 时,stateOf 返回空 Optional;getState 抛异常。未配置 saver 时,这些状态查询本身无法工作。处理"不存在"和"存储访问失败"时也应区分:存储超时不能被当成流程不存在,然后再创建第二笔申请。

5.4 时间旅行:换一个主题,从写作步骤重跑

假设原流程生成主题"猫",你希望改成"小鸟",同时保留原答案。

先选择生成主题之后的 checkpoint,再修改主题并恢复即可。不过,LangGraph4j 1.9.7 的 updateState 会替换选中的 checkpoint 槽位。为了同时保留原答案,本示例先复制到新 thread,再修改:

java 复制代码
RunnableConfig branch = RunnableConfig.builder().threadId("joke-branch-001").build();
RunnableConfig historical = saver.put(branch, Checkpoint.builder()
        .state(selected.state().data())
        .nodeId(selected.node())
        .nextNodeId(selected.nextNodeId())
        .build());
RunnableConfig edited = graph.updateState(
        historical, Map.of(LearningState.TOPIC, "小鸟"), "generate_topic");
LearningState replayed = graph.invoke(GraphInput.resume(), edited).orElseThrow();

selected 必须是 generate_topic 完成后的快照,新 thread 也不能已存在。完整方法包含这两项校验。

第三个参数 "generate_topic" 表示这次更新视为该节点的结果,因此后面从 write_joke 继续。原来的生成主题节点不会重跑,后续模型调用则可能产生新的文本。

重放不是回滚真实世界。已发出的邮件、已完成的支付不会因为你选择旧 checkpoint 自动撤销。涉及副作用的节点需要额外保护。

5.4.1 updateState 的三个动作

把这一行拆开理解:

java 复制代码
RunnableConfig edited = graph.updateState(
        historical, Map.of(LearningState.TOPIC, "小鸟"), "generate_topic");
  1. 读取 historical 指向的 checkpoint,并按 channel 规则合并新值;普通 topic 被替换,未传入的字段保留。
  2. 把这次更新视为 generate_topic 的结果,根据它的出边计算下一节点。
  3. 保存更新,返回包含 checkpoint 和后续执行位置的 RunnableConfig。

第三个参数 asNode 是"视为哪个节点完成",不是"下一次运行哪个节点"。若填成 write_joke,框架就会按写作已经完成计算后继,不符合"修改主题后重新写作"的目的。

updateState 不会自动执行后继节点,也不会替你调用模型。必须接着用返回的 edited 恢复。更新追加字段时同样会触发 reducer:给 trace 传 ["manual_edit"] 是追加,传整份 trace 会再次追加旧历史。

5.4.2 比较原线程和分支
text 复制代码
原 thread:  主题"猫"  → 原笑话       保留
                   ↘ 复制 c1 到新 thread
新 thread:  主题改成"小鸟" → 新笑话   只重新执行 write_joke

所以时间旅行实验检查两件事:新分支确实采用"小鸟";原 thread 仍保留原来的主题与答案。只看到新文本生成,不能证明分支没有改坏旧历史。

这与"失败恢复"也不同:时间旅行主动选旧位置探索新结果;失败恢复通常沿最新可靠进度继续,尽量不重做已完成工作。两者使用相似的 checkpoint 能力,但业务目的不同。

源码:PersistenceExample.java 运行:

powershell 复制代码
./scripts/run-graph-examples.ps1 -Example persistence

5.5 长期记忆与 checkpoint 的边界

如果助手需要在新的对话中仍然记得用户偏好,就需要跨会话保存的长期记忆。它与某次流程的 checkpoint 有不同的用途和生命周期:

数据 典型键 生命周期
聊天记录 conversationId 一次对话及其后续轮次
图执行状态 threadId / checkpointId 一笔业务流程
长期用户偏好 tenantId + userId 可跨多个对话和流程
退款账本 refundId / orderId 业务系统长期记录

Java 中可以定义自己的 PreferenceRepository,用关系库或其他存储实现。下面是设计伪代码:

java 复制代码
// 由认证上下文取得身份;不要让模型指定别人的 userId
var preference = preferenceRepository.load(tenantId, authenticatedUserId);
var request = chatClient.prompt().system(renderPreference(preference)).user(message);

本仓库没有接入长期记忆数据库。扩展时可以从一个保存用户偏好的 Repository 开始,明确数据的归属、更新时机和保留期限,再决定是否增加检索能力。

官方对照:Persistence 示例、Time travel 示例、CompiledGraph 状态查询与更新实现。本文在上游示例的概念上增加了独立 thread 的分支复制,避免修改原记录。

第 6 讲:持续运行------暂停、恢复与失败重试

6.1 把写笑话扩展成"草稿审批后发布"

草稿由模型生成。发布是本地文件写入,用它模拟真实业务副作用,方便观察是否重复执行。

编译时换成磁盘 saver:

java 复制代码
.compile(CompileConfig.builder()
        .checkpointSaver(new FileSystemSaver(directory, builder.getStateSerializer()))
        .releaseThread(false)
        .build());

服务重建图时,需要相同的状态序列化协议、相同 checkpoint 目录和 threadId。只保存 threadId,没有保存状态内容,不能恢复。

6.1.1 这里的"持续运行"是什么意思

不是让同一个 Java 方法睡眠十分钟等待用户,也不是把堆栈写进磁盘。程序在暂停处保存业务状态和后续位置,当前调用结束;之后重建图、加载数据,再通过新的调用继续。

阶段 已持久化的业务数据 尚未发生的操作
draft 完成 主题、笑话正文、模型消息、请求标识 发布
review 前暂停 上述状态,下一节点为 review 人工决定、发布
审批已保存 approved=true/false,后续位置已选择 publish 或 reject
publish 成功 收据、PUBLISHED 状态 无后续发布

模型已经生成的草稿应复用;人的决定也应复用。只有尚未可靠完成的步骤才需要继续。节点如何划分,直接影响失败后会重做多少工作。

6.2 动态中断:先检查,后执行

源码中的 ReviewGate 同时实现 NodeActionWithConfig 和 InterruptibleAction。核心学习片段:

java 复制代码
@Override
public Optional<InterruptionMetadata<LearningState>> interrupt(
        String nodeId, LearningState state, RunnableConfig config) {
    return state.value(LearningState.APPROVED).isPresent()
            ? Optional.empty()
            : Optional.of(InterruptionMetadata.builder(nodeId, state)
                    .reason("请确认草稿后再发布")
                    .putMetadata("draft", state.joke())
                    .build());
}

没有审批决定时,中断发生在该节点执行之前。一次调用返回待审批状态;之后人可以离开页面、稍后回来继续,应用无需一直等待在同一个 HTTP 连接上。

6.2.1 为什么检查"存在",而不是检查"为 true"

这里有三种情况:

approved 字段 含义 中断检查结果
不存在 人还没有决定 返回中断元数据,暂停
true 明确同意 不暂停,路由到 publish
false 明确拒绝 不暂停,路由到 reject

如果写成"不是 true 就暂停",人的拒绝也会被当作未审批,流程永远到不了 reject。HTTP 审批 DTO 使用 @NotNull Boolean 也是为了区分"没提交"与"明确 false"。

ReviewGate.apply() 返回空 Map,因为门节点不需要修改正文或调用发布器。中断条件由 interrupt() 判断,执行顺序是"先检查是否中断,再决定是否运行节点动作"。和在普通节点中调用 Thread.sleep() 有本质不同的生命周期。

6.2.2 固定断点与业务中断

固定调试位置可以在 CompileConfig 配置 interruptBefore("review");本例需要根据审批字段决定是否暂停,因此使用 InterruptibleAction。固定断点适合检查某一步,动态门节点适合表达业务等待条件。

Python LangGraph 文档常见的是在节点体中调用 interrupt(...),再用 Command(resume=...) 恢复。这里采用的是 LangGraph4j 1.9.7 的执行前中断接口与状态更新,不能把 Python 的调用直接改成 Java 方法名。

6.3 用同一个 threadId 恢复

审批处理的核心代码:

java 复制代码
var config = RunnableConfig.builder().threadId(threadId).build();
var snapshot = graph.getState(config);
if (!"review".equals(snapshot.nextNodeId())) {
    throw new IllegalStateException("当前流程不在等待审批");
}
var updated = graph.updateState(config, Map.of(LearningState.APPROVED, approved), "review");
var result = graph.invoke(GraphInput.resume(), updated).orElseThrow();

这里先保存明确决定,再从 review 后面的条件边继续。不会重新生成草稿,也不是重新调用 start。

审批值必须由明确的外部操作提供,恢复前也要检查当前等待位置。已经发布或拒绝的流程,不应再次接受同一审批操作;这些约束由业务层负责检查。

为了理解源码,注意两条恢复路径的差别:

text 复制代码
本例审批:保存 approved,并视为 review 已完成 → 直接选择 publish/reject → resume
仅恢复等待:没有写入 approved,仍准备执行 review → 中断条件仍成立 → 再次等待

因此,单独调用 GraphInput.resume() 不能凭空替人批准。updateState(..., "review") 还意味着门节点的 apply() 在这条审批路径中被视为已完成;不要把必须执行的审计或支付操作藏在这个空门节点里。

6.4 真正跨进程观察

先运行一条命令,生成草稿后 Java 进程退出:

powershell 复制代码
./scripts/run-graph-examples.ps1 -Example durable-start -Thread tutorial-001 -Topic '不想上班的猫'

另起一次 Java 执行,加载磁盘状态并批准:

powershell 复制代码
./scripts/run-graph-examples.ps1 -Example durable-resume -Thread tutorial-001 -Approved true -SkipBuild

-SkipBuild 只适用于已编译且源码未变的情况。用 -Approved false 验证拒绝分支。每次新实验使用新 threadId,或不同的 -CheckpointDir,不要直接覆盖旧流程。

6.4.1 无模型 key 也能验证跨进程恢复

下面两条命令可直接执行,每次实验使用新的 ID;它们会各自启动 Java 进程。离线模型只负责生成固定草稿,磁盘 saver 和恢复都真实执行:

powershell 复制代码
$flowId = 'learn-' + [Guid]::NewGuid().ToString('N')
$checkpointDir = 'target/tutorial-checkpoints'
./scripts/run-graph-examples.ps1 -Example durable-start -Offline `
    -Thread $flowId -CheckpointDir $checkpointDir -Topic '不想上班的猫'

# 上一条进程已经退出;这一条重建图,从同一目录加载同一流程。
./scripts/run-graph-examples.ps1 -Example durable-resume -Offline `
    -Thread $flowId -CheckpointDir $checkpointDir -Approved true -SkipBuild

第一次输出 AWAITING_APPROVAL,轨迹中已有 draft,发布文件尚未产生;第二次输出 PUBLISHED,收据指向 target/tutorial-checkpoints/publications/<flowId>.txt。如果改为明确拒绝,应输出 REJECTED,且不产生该发布文件。

all -Offline 会在一次 CLI 中创建新图实例验证重新加载;上面拆成两条命令,才能更直观地观察两个进程之间没有共享 Java 内存。文件 saver 是教学存储,不代表生产环境已具备事务化持久化。

可在 IDE 中观察这些位置:draft 返回值、ReviewGate.interrupt()、approve() 里的 snapshot.nextNodeId()、updateState() 返回配置、发布器收到的 requestId。它们分别对应"已有什么数据""为什么暂停""从哪里继续""如何防重复"。

6.5 失败重试与副作用幂等

如果发布接口已经成功,但节点随后抛错,checkpoint 可能仍指向 publish。恢复时 publish 会再次执行。

所以发布器必须理解幂等键:

text 复制代码
相同 requestId + 相同内容 → 返回已有收据
相同 requestId + 不同内容 → 报冲突
新 requestId             → 执行一次业务写入

示例使用 CREATE_NEW 写入 <requestId>.txt;文件已存在时比较内容,匹配则复用。真实支付系统应使用数据库唯一约束、事务、支付渠道幂等键和结果查询能力。

演示失败与恢复:

powershell 复制代码
./scripts/run-graph-examples.ps1 -Example durable-start -Thread tutorial-retry-001
./scripts/run-graph-examples.ps1 -Example durable-resume -Thread tutorial-retry-001 -Approved true -FailPublish -SkipBuild
# 上一条预期失败;下一条恢复已审批的发布步骤
./scripts/run-graph-examples.ps1 -Example durable-retry -Thread tutorial-retry-001 -SkipBuild

重试方法只允许恢复"已经批准且下一步是 publish"的流程。它不会重新生成草稿,也不会为同一任务创建第二个 thread。

6.5.1 失败可能发生在副作用之前,也可能之后
失败位置 外部结果 checkpoint 可能看到什么 重试要求
调用发布器之前 没有发布 下一步仍是 publish 可以执行发布
发布器成功,节点随后抛错 已有发布文件或支付结果 仍未记录发布成功 按原幂等键查询或复用,不能再发布一份
节点结果已保存,客户端没收到响应 已完成且有收据 最终状态已保存 查询状态或回放成功响应

-FailPublish 演示的是发布器直接抛错,外部副作用尚未成功。更困难的"已成功但返回前失败"由现有测试额外覆盖,不能只靠这个 CLI 开关证明。

filePublisher 的核心摘录:

java 复制代码
try {
    // 同一个 requestId 对应同一个文件,禁止覆盖。
    Files.writeString(receipt, content, StandardOpenOption.CREATE_NEW);
} catch (FileAlreadyExistsException ex) {
    // 再次执行时比较正文;相同内容视为重试,复用已有收据。
    if (!Files.readString(receipt).equals(content)) {
        throw new IOException("Idempotency key already belongs to different content", ex);
    }
}
return receipt.toAbsolutePath().toString();

这是应用针对本地 mock 定义的去重协议。它没有覆盖文件写到一半断电等全部故障,也没有把多个业务系统与 checkpoint 包在同一个事务里。真实退款接口需要长期保存幂等键与执行结果,并能在"结果未知"时查询确认。

6.6 节点如何拆分,状态何时才算可靠保存

假设一个节点依次完成"生成草稿、发送邮件、写入数据库"。邮件发送成功后数据库写入失败,恢复时如果重新执行整个节点,就可能再次生成草稿、再次发送邮件。

本项目将草稿生成、审批和发布拆成独立节点,通过 checkpoint 记录节点之间的执行进度。这样发布失败时可以恢复发布步骤,复用已经保存的草稿和审批结果。

如果一个节点里仍有多次外部调用,可以继续拆分节点,或者为每项操作持久化独立结果。设计时先回答:失败后允许重做哪些工作?哪些结果必须复用?哪些副作用必须按幂等键去重?这些答案决定了恢复边界。

持久化还涉及"什么时候写入、写入完成意味着什么"。常见设计取舍如下;这是存储策略的讨论,不是给 CompileConfig 增加同名参数:

保存策略 作用 需要处理的问题
等待状态写入后再继续 缩小已执行步骤与保存进度之间的差距 写入延迟、存储异常如何阻止后续执行
执行与后台保存重叠 减少等待,提高吞吐 故障前尚未完成的写入、flush 和恢复窗口
主要在结束或暂停时保存 减少中间写入 中途故障后可能需要重做更多步骤

本示例通过 FileSystemSaver 演示跨进程读取和恢复。文件写入中断、多个进程同时写入,以及外部支付与 checkpoint 的一致性,仍需要另外设计。选择持久化实现时,要核实它的原子写入、并发控制和错误传播行为。

源码:DurableExecutionExample。测试还覆盖"发布成功后节点失败,再次恢复不重复发布"的情况,见 GraphExamplesTest。

6.7 对照 Python LangGraph 时,哪些可以迁移,哪些要重新实现

如果你先读过 Python LangGraph 的入门材料,可以迁移"状态、节点、边、checkpoint、中断、子图"的思路;不能假设两种库的 API、默认值和恢复粒度完全相同:

Python 材料中常见写法 本系列采用的 Java 写法 需要注意
TypedDict / Pydantic State AgentState 子类、Map、DTO 校验 getter 的泛型不是自动字段验证
Annotated[list, reducer] SCHEMA 中的 Channel 显式选择覆盖、追加与去重
add_messages Spring AI Message + 所选消息 channel 不承诺按消息 ID 替换的相同语义
configurable.thread_id RunnableConfig.builder().threadId(...) 是逻辑流程 ID,不是执行线程
get_state/get_state_history getState/getStateHistory 检查 Java 快照的 node/nextNodeId
interrupt() / Command(resume=...) InterruptibleAction、updateState、GraphInput.resume 本例在节点执行前暂停
@task 保存节点内调用结果 本例拆节点并对副作用实现幂等 没有直接使用同等 task 结果缓存机制
durability="sync/async/exit" 核实 saver 实现与保存边界 本例未提供这些同名配置项
stream_mode="messages/updates/custom" StreamingOutput、节点 hook、Context writer Java StreamMode 的枚举不能照抄 Python

尤其不能把"节点失败后继续"解释成"从出错的 Java 代码行继续"。一个尚未保存成功结果的节点可能从头执行,其中的模型调用和业务调用可能重做。拆节点能缩小重做范围,幂等与结果查询则保护必须去重的副作用。

官方依据:等待用户输入示例、InterruptibleAction、FileSystemSaver。先用本系列的磁盘实验观察真实行为,再扩展数据库存储或更复杂的恢复。

6.8 常见现象应该从哪里排查

现象 优先检查 原因
trace 少了一次 write_joke 是否误用了去重 appender 相同节点名被当作重复元素
旧消息越来越多地重复 节点是否返回整份 messages 追加 reducer 再次合并了旧历史
END 后查不到历史 releaseThread 和 saver 生命周期 活动记录被释放,或换了内存存储
修改主题后又变回原主题 updateState 的 asNode、resume 使用的配置 可能重新经过 generate_topic
审批拒绝仍一直等待 是否把 false 当成"没决定" 应判断字段存在与否
重启后找不到流程 目录、threadId、saver、序列化协议 恢复依赖这些信息同时匹配
重试多写了一份发布 副作用键是否稳定 每次新建 requestId 会绕过去重

上篇练习与下一步

项目源码:github.com/git-syl/spr...

可以按顺序完成下面四个练习:

  1. 把固定文本图的 topic 改成命令行参数,观察局部更新仍保留输入。
  2. 将笑话最大长度调小,观察第二次 write_joke 和 LIMIT_REACHED。
  3. 选择历史快照,在新 thread 修改主题,确认原答案没有改变。
  4. 生成草稿后退出进程,再审批恢复;模拟发布失败并检查文件只产生一份。

扩展阅读:《Spring Statemachine》 docs.spring.io/spring-stat... 实现langgraph4j功能。

当前 LangGraph4j 实现 Spring Statemachine 实现思路
查单、退款等节点 Action 执行业务动作
条件边与政策判断 Guard、Choice 条件分支
RefundState 保存订单、审批等数据 ExtendedState 或独立业务对象
approval_gate 中断和恢复 停留在待审批状态,接收 APPROVE/REJECT 事件
checkpoint 保存和恢复 StateMachinePersister / RuntimePersister
节点事件推送 SSE Listener / Interceptor 转换为现有 SSE 事件

这些练习对应的图都可以通过 ./scripts/run-graph-examples.ps1 -Example all -Offline 离线运行。离线模式使用固定模型响应,只证明图逻辑;真实模型验证需要环境变量中的 key。

继续阅读:我们将让文本和步骤实时输出,复用子图,然后把完整退款工作流接入 Spring AI 聊天助手。

相关推荐
王小希ww1 小时前
【Agent & RSI】OpenCode离线部署:终端版 & Web 版
llm·agent·opencode
武天旭1 小时前
AI安全--AI大模型的关键技术组成
人工智能·深度学习·安全·机器学习
jerrywus1 小时前
普通人怎么用 Codex:从表格、文件和小工具开始
chatgpt·agent·mcp
无心水1 小时前
22、数学与经济管理(运筹学) 核心考点与学习笔记
人工智能·笔记·学习·架构师·软考·软考架构师·软考系统架构师
日日大王学编程1 小时前
AI 时代后端方向新人的未来规划与看法
人工智能·后端·python·ai·golang
小哈里1 小时前
【Agent】从Prompt工程、Context工程到 Harness工程,让 AI 能真正完成工作
人工智能·prompt·agent·ai编程·harness
Juicedata2 小时前
从 Meta 的工程实践看 EB 级 AI 存储架构
人工智能·架构
土星云SaturnCloud2 小时前
基于边缘计算与AI视觉的智慧街面治理方案:架构设计与场景落地
服务器·人工智能·ai·边缘计算
武子康2 小时前
DOA 100%、GSC 改善 12 dB,ESP32-P4 远场拾音就能用了吗?
人工智能·后端·agent