我觉得的后端真言:
先保证业务正确,再保证数据可靠,然后通过监控定位性能瓶颈,最后选择成本合适的优化方案
借助文章学习:
https://www.cnblogs.com/wgwyanfs/p/19955589
按照 Java 程序员学习一个新框架的思路:遇到什么问题 → 为什么需要这个框架 → 框架如何解决 → 核心源码怎么理解 → 自己动手实现 → 如何用于真实项目。
一、先建立整体认知:LangGraph4j 到底解决什么问题?
先记住一句话:
LangGraph4j 是一个用来编排有状态 、多步骤任务执行流程 的 Java 框架,特别适合需要调用大模型、工具和多个 Agent的 AI 应用。
你可以把它理解成一个能够管理执行顺序、分支判断、状态传递和循环的工作流引擎。
1.1 问题:不用 LangGraph4j,我们怎么写程序?
假设你现在接到一个需求:
用户输入一句话:"帮我生成一个个人博客网站",系统自动生成网站源码。
作为 Java 后端开发者,你可能会先写出这样的代码:
public Website generate(String prompt) {
// 1. 收集图片
List<Image> images = imageService.search(prompt);
// 2. 增强提示词
String enhancedPrompt =
promptService.enhance(prompt, images);
// 3. 选择生成方式
CodeType type =
routerService.choose(enhancedPrompt);
// 4. 生成代码
String code =
codeService.generate(enhancedPrompt, type);
// 5. 构建项目
return buildService.build(code);
}
这段代码有没有问题?
没有。对于简单的线性流程,这甚至可能是更合适的方案。
但现在产品经理提出了新需求:
-
如果用户要求 Vue 项目,就走 Vue 代码生成器。
-
如果用户要求 HTML,就走 HTML 代码生成器。
-
如果代码编译失败,需要让 AI 自动修复后重新编译。
-
如果图片搜索失败,可以跳过图片搜索。
-
前端需要实时显示当前执行到哪一步。
-
某些步骤需要等待人工确认后继续执行。
原来的代码就会逐渐变成:
if (type == VUE) {
// Vue 生成
} else if (type == HTML) {
// HTML 生成
}
while (!buildSuccess && retryCount < 3) {
// AI 修复
// 重新构建
}
if (needHumanApproval) {
// 保存状态
// 等待用户确认
}
随着业务复杂度提高,你会遇到几个典型问题:
| 问题 | 传统写法的痛点 |
|---|---|
| 流程复杂 | if/else、循环和调用链交织 |
| 状态共享 | 大量参数在方法之间传递 |
| 动态路由 | 不同条件需要执行不同步骤 |
| 失败恢复 | 重试、恢复逻辑需要自行维护 |
| 执行可视化 | 难以统一描述流程结构 |
| 人工介入 | 暂停和恢复需要额外设计 |
这正是 LangGraph4j 值得学习的场景。
1.2 解决方案:把程序执行过程抽象成一张图
文章中的 AI 网站生成流程,可以抽象成:

这样,业务逻辑和流程编排就分开了。
原来你需要写:
collectImages();
enhancePrompt();
chooseMode();
generateCode();
buildProject();
现在可以用图来描述执行关系:
.addEdge(START, "image_collector")
.addEdge("image_collector", "prompt_enhancer")
.addEdge("prompt_enhancer", "router")
.addEdge("router", "code_generator")
.addEdge("code_generator", "project_builder")
.addEdge("project_builder", END)
注意:LangGraph4j 并不会替你完成图片搜索、AI 调用或代码生成。它负责组织这些步骤如何执行。
这也是理解整个框架的第一把钥匙。
注意:LangGraph4j 并不会替你完成图片搜索、AI 调用或代码生成(java业务执行)。它负责组织这些步骤如何执行。
这也是理解整个框架的第一把钥匙。
二、重新规划这篇文章的学习路线
我建议分成 7 个阶段,每个阶段解决一个具体的工程问题。

其中,第 1~4 阶段对应文章的核心内容,第 5~7 阶段是我建议补充的工程实践能力。
下面先把最关键的基础概念讲透,再带你分析文章中的代码。
三、第一课:理解 LangGraph4j 的 5 个核心概念
这是整篇文章最重要的基础。后面无论代码多复杂,本质上都离不开这五个概念。


可以用 Java 开发中熟悉的概念来类比:
| LangGraph4j | Java 开发中的类比 |
|---|---|
| Graph | 整个业务流程的编排定义 |
| Node | Service 中的业务方法 |
| Edge | 方法调用顺序和条件跳转 |
| State | 在各步骤间传递的上下文对象 |
| Channel | 上下文字段的更新合并策略 |
这些只是帮助理解的类比,并非内部实现完全相同。
3.1 第一个问题:Node 为什么要返回 Map?
文章中有这样一段代码:
java
class GreeterNode implements NodeAction<SimpleState> {
@Override
public Map<String, Object> apply(SimpleState state) {
return Map.of(
"messages",
"Hello from GreeterNode!"
);
}
}
你可能会疑惑:

这里要记住一个关键区别:
Node 返回的 Map 通常是状态更新量 ,并不要求包含整个 State。
State 是整个工作流运行时的数据上下文
例如:
return Map.of("enhancedPrompt", "增强后的提示词");
意味着这个节点希望更新 enhancedPrompt 字段。
这种设计让节点之间通过状态交换数据 ,而不 是互相依赖对方的 Java 方法。
node返回map就是为了传输变化的state
3.2 第二个问题:Channel 是什么?
这是文章中比较容易被忽略、但非常关键的概念。
文章定义了:
java
public static final Map<String, Channel<?>> SCHEMA =
Map.of(
"messages",
Channels.appender(ArrayList::new)
);
Schema 可以类比 C 语言的结构体(struct),或者 Java 的实体类(POJO)
这里的 Channels.appender(...) 是什么意思?
假设初始状态是:
messages = ["用户发起请求"]
第一个节点返回:
Map.of("messages", "图片收集完成")
第二个节点返回:
Map.of("messages", "提示词增强完成")
采用追加型 Channel 后,逻辑上的最终状态是:
messages = [
"用户发起请求",
"图片收集完成",
"提示词增强完成"
]
而不是只剩下最后一条消息。


所以:
-
追加 策略适合消息历史 、事件记录。
-
覆盖 策略适合当前 步骤、生成类型 、最终结果。
Channel 决定了多个节点更新同一个字段时 ,状态如何变化。
(即: Node 返回的更新数据,应该如何合并到 State 对应字段中)
这对后面学习并行节点尤其重要。
四、第二课:亲手理解文章中的第一个 Demo
现在看文章开头的 Demo。
它实际上只做了一件事:
第一个节点生成问候语 ,第二个节点读取问候语并作出响应。
4.1 先看执行结构

第一个节点:
java
class GreeterNode implements NodeAction<SimpleState> {
@Override
public Map<String, Object> apply(SimpleState state) {
return Map.of(
"messages",
"Hello from GreeterNode!"
);
}
}
第二个节点:
java
class ResponderNode implements NodeAction<SimpleState> {
@Override
public Map<String, Object> apply(SimpleState state) {
List<String> messages = state.messages();
if (messages.contains("Hello from GreeterNode!")) {
return Map.of(
"messages",
"Acknowledged greeting!"
);
}
return Map.of(
"messages",
"No greeting found."
);
}
}
请重点观察:
第一个节点并没有调用第二个节点。
第二个节点也没有接收第一个节点的返回值作为方法参数。
是 LangGraph4j 执行引擎根据 Channel 的规则更新 State,然后把更新后的 State 传给第二个节点
它们之间的通信是通过 State 完成的,执行顺序则由 Edge 决定。
这就是你需要建立的工作流思维
最终记住:
-
顺序节点:前一个节点返回 Map → 更新 State → 后一个节点接收更新后的 State。
-
并行节点:同一执行步骤中的节点可以读取同一份状态快照,更新结果在相应的同步阶段合并

4.2 再看 Graph 是如何组装的
下面保留文章的核心 API,省略 import:
java
var graph = new StateGraph<>(
SimpleState.SCHEMA,
SimpleState::new
)
.addNode("greeter", node_async(new GreeterNode()))
.addNode("responder", node_async(new ResponderNode()))
.addEdge(START, "greeter")
.addEdge("greeter", "responder")
.addEdge("responder", END);
var compiledGraph = graph.compile();
注意:
先理解var
这里的:
var graph = ...
编译器会根据右边的表达式推断 graph 的类型。
在这个例子中,通常相当于:
StateGraph<SimpleState> graph = ...
再看:
var compiledGraph = graph.compile();
compile() 返回编译后的图对象,因此通常相当于:
CompiledGraph<SimpleState> compiledGraph = graph.compile();
先把 var 理解成编译器自动补全类型即可。
你可以记住:
-
StateGraph<SimpleState>:正在构建的图。 -
CompiledGraph<SimpleState>:编译后准备执行的图。 -
var:省略左边的类型声明,不会改变程序的执行逻辑。
逐行理解:
new StateGraph<>(...):创建一个工作流定义,并告诉框架状态结构是什么。
addNode(...):注册业务节点。
node_async(...):将节点动作适配为异步节点动作形式。
addEdge(...):告诉框架节点之间如何连接。
compile():把图定义编译成可执行的工作流对象,并进行相应的结构校验。
最后调用:
java
compiledGraph.stream(
Map.of("messages", "Let's begin!")
);
开始执行工作流并逐步获取执行输出。
这里的 stream 是工作流执行过程的流式输出,不要直接把它等同于大模型逐 token 输出,两者是不同层次的概念。
4.3 你应该掌握的执行过程




到这里,你应该能回答三个问题:
-
节点之间如何传递数据?通过共享状态。
-
节点执行顺序由谁决定?由图中的边决定。
-
节点返回的 Map 用来干什么?提交状态更新。
如果这三个问题能回答清楚,LangGraph4j 的基本执行模型就理解了。
五、第三课:为什么文章要引入 WorkflowContext?
这是文章从 Demo 走向实际项目最重要的一次改造。
5.1 遇到的问题
最初的 Demo 只需要存储:
最初的 Demo 只需要存储:
messages
但是网站生成项目需要存储:
originalPrompt
imageList
enhancedPrompt
generationType
generatedCodeDir
buildResultDir
errorMessage
如果把这些信息全部塞进 messages,会出现类型不明确、数据解析麻烦、业务含义不清晰等问题。
所以文章定义了:
java
@Data
@Builder
public class WorkflowContext implements Serializable {
private String currentStep;
private String originalPrompt;
private List<ImageResource> imageList;
private String enhancedPrompt;
private CodeGenTypeEnum generationType;
private String generatedCodeDir;
private String buildResultDir;
private String errorMessage;
}
你可以把它理解成:
整个网站生成任务的业务上下文。
例如:
java
WorkflowContext context = WorkflowContext.builder()
.originalPrompt("帮我生成一个个人博客网站")
.currentStep("初始化")
.build();
接下来每个节点都需要读取或更新它。
5.2 为什么还要把 WorkflowContext 放进 MessagesState?
文章采用的是一种包装方式:
MessagesState
|
+-- messages
|
+-- workflowContext
|
+-- originalPrompt
+-- imageList
+-- enhancedPrompt
+-- generationType
+-- generatedCodeDir
+-- buildResultDir
这样可以继续使用 MessagesStateGraph ,同时在状态里携带自己的业务上下文。
文章通过两个方法来封装操作:
public static WorkflowContext getContext(
MessagesState state) {
return (WorkflowContext) state.data()
.get("workflowContext");
}
public static Map<String, Object> saveContext(
WorkflowContext context) {
return Map.of("workflowContext", context);
}
例如图片收集节点:
java
WorkflowContext context =
WorkflowContext.getContext(state);
context.setImageList(images);
return WorkflowContext.saveContext(context);
整个过程可以理解为:
读取上下文 → 执行业务 → 更新上下文 → 返回状态更新。
5.3 这里有一个重要的工程设计问题(重点)
文章采用直接修改 WorkflowContext 对象的方式:
context.setImageList(images);
作为教学 Demo,这种方式比较直观。
但在真实项目中,需要特别注意:
修改一个可变对象,不等于框架一定能够可靠地识别、保存和恢复这次状态变化。
特别是未来引入并行节点、Checkpoint、状态回放时,共享可变对象容易产生问题。
更稳妥的方向是:
-
尽量让节点返回明确的状态更新。
-
避免多个节点同时修改同一个可变对象。
-
根据实际 Schema 设计各字段的更新策略。
-
如果业务字段很多,可以考虑直接自定义
AgentState,不一定要把所有业务数据包装进MessagesState。
这一点是文章没有充分展开、但你实际做项目必须理解的。
六、第四课:为什么文章坚持先 Mock,再实现真实节点?
mock
Mock(模拟)就是在开发或测试时,用假的对象、数据或接口行为,代替真实的依赖。
JUnit 5 + Mockito 类比成 LangGraph4j 的 Graph + Node,这样比较容易记忆。
但要注意,它们只是职责相似,不是一一对应。
| LangGraph4j | 单元测试 | 类比关系 |
|---|---|---|
| Graph 执行引擎 | JUnit 5 | 负责组织、执行 |
| Node | 被测试的 Service 方法 | 负责执行具体逻辑 |
| State | 测试数据 | 提供处理所需的数据 |
| 模拟的外部依赖 | Mockito | 模拟 Node/Service 所依赖的功能 |
| State 更新结果 | JUnit 断言的实际结果 | 检查执行后的数据 |
用一个例子记住
LangGraph4j:
Graph 执行引擎
↓
Node 接收 State
↓
执行逻辑
↓
返回 Map
↓
Graph 根据 Channel 更新 State
JUnit 5 + Mockito:
JUnit 5 执行测试
↓
Mockito 模拟 Mapper 返回数据
↓
调用真正的 Service 方法
↓
Service 执行业务逻辑
↓
JUnit 断言结果是否正确
最好记的方式
你可以把它们想象成两套不同的系统:
-
LangGraph4j:Graph 是调度员,Node 是工作人员,State 是工作资料,Channel 是资料更新规则。
-
单元测试:JUnit 5 是考官,Service 是考生,Mockito 是模拟考试环境的工具,
assertEquals()是评分标准。
一句话记忆:
LangGraph4j 管业务流程怎么执行,JUnit 5 管测试怎么执行,Mockito 管测试时依赖怎么模拟。
所以 Mockito 并不是 JUnit 的 State,而是为被测试方法提供可控依赖的工具。
文章后半部分采用了非常值得借鉴的开发方法:
先创建五个节点,让每个节点返回假数据,等整个流程跑通后,再逐个替换成真实实现。
例如:
// 图片收集节点
context.setImageList(mockImages);
// 提示词增强节点
context.setEnhancedPrompt("增强后的提示词");
// 路由节点
context.setGenerationType(CodeGenTypeEnum.HTML);
// 代码生成节点
context.setGeneratedCodeDir("/tmp/generated");
// 项目构建节点
context.setBuildResultDir("/tmp/build");
为什么要这样?
因为你实际上面对两类完全不同的问题:
第一类:工作流是否正确?
例如节点能否按顺序执行、状态能否传递、图能否成功编译。
第二类:业务实现是否正确?
例如 Pexels API 能否搜索图片、LLM 是否生成有效代码、项目能否成功编译。
如果一开始就全部写真实逻辑,一旦执行失败,你很难判断到底是工作流编排错误,还是某个外部服务出错。
先 Mock 可以把两类问题分开。
这是我建议你真正掌握的开发顺序:
定义业务流程
↓
设计状态字段
↓
定义节点输入输出
↓
使用 Mock 实现节点
↓
验证完整状态流转
↓
替换真实业务逻辑
↓
加入分支、重试、持久化
↓
编写自动化测试
七、文章中还有哪些容易误解的地方?
有三个地方,我建议你特别注意。
第一,文章中的 RouterNode 还不是真正的动态路由。
文章的 RouterNode 只是执行:
context.setGenerationType(CodeGenTypeEnum.HTML);
然后无条件进入同一个 code_generator 节点。
它只是保存了一个路由选择结果,并没有 在图结构上根据结果选择不同分支。
真正的条件路由应该类似:

在 LangGraph4j 中,通常需要通过条件边来实现。这个是后续必须补充的内容。
第二,文章展示的代码还不等于完整的 AI Agent 系统。
文章中的真实业务节点,大多保留了 TODO 或 Mock 数据。真正的LLM 调用、工具调用、代码编译修复和持久化恢复,还需要继续实现。
第三,文章的测试还比较基础。
测试仅仅检查:
Assertions.assertNotNull(result);
远远不够。
更有价值的是验证:
assertEquals(
CodeGenTypeEnum.VUE,
result.getGenerationType()
);
assertNotNull(result.getGeneratedCodeDir());
assertNotNull(result.getBuildResultDir());
还要分别测试 HTML 路径、Vue 路径、图片搜索失败、构建失败、重试次数达到上限等场景。
另外,文章使用 langgraph4j-core:1.6.0-rc2,这是预发布版本。跟着文章实践时建议先固定同一版本,避免混用其他版本的 API;后续升级再核对官方文档。
八、现在开始你的第一个学习任务
不要急着实现整篇文章的五节点项目。
我建议你先完成一个只有两个节点的最小程序:
START
↓
InputNode
↓
UppercaseNode
↓
END
要求:
输入:
hello langgraph4j
第一个节点负责接收输入,第二个节点把它转换成大写。
最终输出:
HELLO LANGGRAPH4J
通过这个练习,你需要真正搞懂:
-
State 如何定义?
-
Node 如何读取状态?
-
Node 如何更新状态?
-
Edge 如何连接节点?
-
compile 和 stream 分别做什么?
学完后,再把两个节点扩展成文章中的五节点工作流。
下一课直接从 Maven 项目开始,手把手实现 AgentState → NodeAction → StateGraph → compile → stream,每一段代码都解释为什么这么写,以及框架内部的数据如何流转。这样你不仅能照着文章写代码,也能理解它的设计原理,并最终独立完成一个 Spring Boot + LangGraph4j 的 AI 工作流项目。
LangGraph4j 实战 · 第 1 关:从零搭建 Maven 项目
正式进入编码实战
这一课我们不接入大模型,也不接入数据库,先把 LangGraph4j 最核心的执行机制跑通。
你已经理解了:
Node 读取 State,执行业务逻辑,返回 Map 作为状态更新量,由框架合并到 State 中。
现在我们要亲自验证这个过程。
一、本关要解决什么问题?
假设我们需要开发一个文本处理服务:
用户输入:
hello langgraph4j
系统先接收输入,然后转换成大写,最后输出:
HELLO LANGGRAPH4J
用普通 Java,可能只需要:
String result = input.toUpperCase();
为什么还要学习 LangGraph4j?
因为我们的真正目标不是实现大写转换,而是掌握一个可以扩展成 AI Agent 的工作流架构。
今天只有两个节点,将来可以扩展成十个节点,并加入条件分支、工具调用、失败重试和人工审核。
二、我们要实现的工作流

本关涉及五个核心 API:
| 核心概念 | 负责什么 |
|---|---|
AgentState |
保存工作流共享状态 |
NodeAction |
定义节点业务逻辑 |
StateGraph |
定义节点及其执行关系 |
compile() |
编译工作流 |
stream() |
执行并获取各步骤结果 |
本关验收标准: Maven 项目能够运行,两个节点按顺序执行,第二个节点能读取第一个节点更新后的 State,并最终输出大写文本。
三、创建 Maven 项目
我们先使用纯 Java Maven 项目,不立即引入 Spring Boot。
原因是:先学会工作流的执行原理,再学习如何将它封装成 Spring Boot Service。
3.1 开发环境
| 项目 | 本课配置 |
|---|---|
| JDK | Java 21 |
| 构建工具 | Maven |
| IDE | IntelliJ IDEA |
| 项目名称 | langgraph4j-demo |
| LangGraph4j | 1.9.3 |
| 数据库 | 暂不需要 |
| 大模型 | 暂不需要 |
这里选用官方 README 当前列出的 1.9.3 版本。你提供的博客使用较早的 1.6.0-rc2,因此不要混用两个版本的示例 API。
在 IDEA 中依次选择:
New Project → Maven → JDK 21 → Create
项目结构如下:
langgraph4j-demo
├── pom.xml
└── src
└── main
└── java
└── com.example.langgraph
├── Main.java
├── state
│ └── TextState.java
└── node
├── InputNode.java
└── UppercaseNode.java
3.2 配置 pom.xml
<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>
<groupId>com.example</groupId>
<artifactId>langgraph4j-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-core</artifactId>
<version>1.9.3</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
</dependency>
</dependencies>
</project>
这里引入 langgraph4j-core 就够了。它包含 State、Node、Graph 等核心能力。我们还没有接入 LangChain4j,所以暂时不需要添加 AI 模型相关依赖。
四、第一步:实现 AgentState
4.1 为什么需要 State?
想象两个普通 Java 方法:
java
String inputNode(String input) {
return input;
}
String uppercaseNode(String input) {
return input.toUpperCase();
}
它们通过方法参数传递数据。
但 LangGraph4j 的节点由框架调度,节点之间通常不直接调用。
因此需要一个共享状态:
State
├── input = "hello langgraph4j"
└── result = "HELLO LANGGRAPH4J"
4.2 编写 TextState.java
java
package com.example.langgraph.state;
import org.bsc.langgraph4j.state.AgentState;
import java.util.Map;
public class TextState extends AgentState {
public TextState(Map<String, Object> data) {
super(data);
}
public String input() {
return (String) data().getOrDefault("input", "");
}
public String result() {
return (String) data().getOrDefault("result", "");
}
}
逐行理解:
extends AgentState:声明这个类是工作流状态对象。
super(data):将框架提供的状态数据交给父类管理。
input():封装读取输入字段的操作。
result():封装读取处理结果的操作。
为什么不直接在每个 Node 里面写:
state.data().get("input");
因为将状态访问封装成方法,可以避免业务节点到处出现字符串 Key 和强制类型转换。
这与 Java 后端中封装 DTO 或领域对象的思路类似。
五、第二步:实现 NodeAction
我们现在创建两个节点。
5.1 InputNode.java
package com.example.langgraph.node;
import com.example.langgraph.state.TextState;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class InputNode implements NodeAction<TextState> {
@Override
public Map<String, Object> apply(TextState state) {
String input = state.input();
System.out.println("InputNode 收到:" + input);
return Map.of("input", input);
}
}
重点看:
public Map<String, Object> apply(TextState state)
这里的 state 是框架传进来的当前状态。
节点读取:
String input = state.input();
然后返回:
return Map.of("input", input);
这表示提交一个 input 字段的更新。
实际上,本例的 InputNode 并没有改变输入内容,因此返回空 Map 也可以。这里特意返回 input,只是为了让你观察节点返回状态更新的形式。
5.2 UppercaseNode.java
package com.example.langgraph.node;
import com.example.langgraph.state.TextState;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class UppercaseNode implements NodeAction<TextState> {
@Override
public Map<String, Object> apply(TextState state) {
String input = state.input();
String result = input.toUpperCase();
System.out.println("UppercaseNode 处理:" + result);
return Map.of("result", result);
}
}
注意这段:
return Map.of("result", result);
它没有返回整个 State,而是只返回需要更新的 result 字段。
执行完毕后,状态将包含:
{
"input": "hello langgraph4j",
"result": "HELLO LANGGRAPH4J"
}
这就对应了你之前理解的:
Node 返回 Map,是为了把当前节点产生的状态变化提交给框架。
六、第三步:创建 StateGraph
现在两个节点都有了,但它们还不知道应该按照什么顺序执行。
因此我们需要创建 Graph。
Main.java
java
package com.example.langgraph;
import com.example.langgraph.node.InputNode;
import com.example.langgraph.node.UppercaseNode;
import com.example.langgraph.state.TextState;
import org.bsc.langgraph4j.StateGraph;
import java.util.Map;
import static org.bsc.langgraph4j.StateGraph.START;
import static org.bsc.langgraph4j.StateGraph.END;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
public class Main {
public static void main(String[] args) throws Exception {
// 1. 创建工作流
var graph = new StateGraph<TextState>(
Map.of(),
TextState::new
);
// 2. 注册节点
graph.addNode("input_node",
node_async(new InputNode()));
graph.addNode("uppercase_node",
node_async(new UppercaseNode()));
// 3. 连接节点
graph.addEdge(START, "input_node");
graph.addEdge("input_node", "uppercase_node");
graph.addEdge("uppercase_node", END);
// 4. 编译工作流
var compiledGraph = graph.compile();
// 5. 执行工作流
var stream = compiledGraph.stream(
Map.of("input", "hello langgraph4j")
);
// 6. 查看各节点执行结果
for (var output : stream) {
System.out.println("工作流输出:" + output);
}
}
}
这里使用 Map.of() 作为最简 Schema,没有配置追加型 Channel。我们当前只需要普通字段的更新,不需要保存消息列表。
这一写法与官方的 StateGraph、NodeAction、Edge 和 stream 示例采用相同的核心结构。
七、第四步:逐行理解 StateGraph → compile → stream
7.1 StateGraph 做了什么?
java
var graph = new StateGraph<TextState>(
Map.of(),
TextState::new
);
可以拆成两部分理解:
Map.of():定义状态字段的 Channel 配置。本例不需要特殊的追加合并规则。
TextState::new:方法引用,用来告诉框架如何根据状态数据创建 TextState 对象。
你可以把 TextState::new 理解成:
data -> new TextState(data)
这是 Java 8 开始就有的方法引用语法,并不是 LangGraph4j 独有的。
7.2 addNode 和 addEdge 有什么区别?
graph.addNode("input_node", ...);
这是告诉框架:
我这里有一个节点,名字叫 input_node,它负责执行指定的 NodeAction。
而:
graph.addEdge("input_node", "uppercase_node");
是告诉框架:
input_node 执行完成后,下一步执行 uppercase_node。
可以类比为:
-
addNode:定义有哪些任务。 -
addEdge:定义任务之间的执行关系。
7.3 compile() 做了什么?
var compiledGraph = graph.compile();
这里的 compile 不是 Java 源代码编译成字节码。
它是将你定义的图转换成可以执行的工作流,并进行图结构相关校验。
例如节点之间是否存在非法连接、节点是否正确注册等。
官方文档也明确要求,图必须编译后才能执行。
7.4 stream() 做了什么?
compiledGraph.stream(
Map.of("input", "hello langgraph4j")
);
它启动工作流执行,并提供执行过程中的输出。
在本例中,框架会沿着:
START → InputNode → UppercaseNode → END
依次执行节点。
它与 invoke() 的主要区别是:
| 方法 | 用途 |
|---|---|
stream() |
获取工作流执行过程中的输出 |
invoke() |
主要关注执行结束后的最终状态 |
后面开发 AI Agent 时,stream() 很适合用于观察任务进度,但它本身不意味着模型正在逐字输出。
八、第五步:模拟运行与验收
下面是预期执行过程,属于模拟输出,不代表已经在你的电脑上运行验证。
InputNode 收到:hello langgraph4j
UppercaseNode 处理:HELLO LANGGRAPH4J
工作流输出:NodeOutput(...)
工作流输出:NodeOutput(...)
具体 NodeOutput 的打印格式会由版本实现决定。
你需要在 IDEA 中运行 Main.main(),观察:
-
是否先打印 InputNode 的日志。
-
是否再打印 UppercaseNode 的日志。
-
UppercaseNode 是否能读取到正确的
input。 -
最终 State 是否包含
result = HELLO LANGGRAPH4J。
如果 Maven 依赖或 API 编译失败,先检查项目 JDK、Maven 下载状态和 LangGraph4j 版本,不要直接把旧博客中的代码混进来。
九、从框架内部看,State 是如何流转的?
这是本课最重要的一部分。
工作流状态变化演示



框架执行过程可以简化为:
1. 根据输入创建 State
↓
2. 调用 InputNode.apply(state)
↓
3. 得到 Map 更新量
↓
4. 根据 Channel 规则更新 State
↓
5. 调用 UppercaseNode.apply(state)
↓
6. 得到新的 Map 更新量
↓
7. 再次更新 State
↓
8. 工作流结束
请特别注意:框架并不是简单地把 Node A 的 Map 原封不动传给 Node B。
而是先将更新量合并进 State,再由 Node B 读取更新后的 State。
这就是 AgentState 和 NodeAction 配合工作的核心。
十、本课知识检查
1. NodeAction 的 apply() 为什么返回 Map?
为了返回完整的 HTTP 响应
为了提交需要更新的 State 字段
为了直接调用下一个 Node
2. 谁决定 InputNode 后面执行 UppercaseNode?
StateGraph 中的 Edge
InputNode 的 return
AgentState 的构造方法
3. compile() 的主要作用是什么?
把 Java 编译成 class
调用大模型
将图定义编译成可执行工作流并校验结构
十一、下一课预告
下一关我们将深入学习 Channel 和 Reducer。
因为你已经知道 Node 返回 Map 是为了更新 State,接下来就会遇到一个更深层的问题:
如果 Node A 和 Node B 都返回
Map.of("messages", ...),框架怎么知道应该覆盖旧数据,还是把新数据追加进去?
这就是 Channel 要解决的问题。
学完第二关,你就能真正理解 LangGraph4j 的状态合并机制,也为后面学习并行节点、多 Agent 协作和持久化打下基础。
第 2 关:Channel、Reducer 与 State 状态合并
教的是:框架如何根据 Channel,把 Node 返回的 Map 合并进 State。
只需要掌握四个知识点:
| 知识点 | 你需要理解什么 |
|---|---|
AgentState |
工作流的数据存在哪里 |
SCHEMA |
哪些字段需要特殊的合并规则 |
Channel |
新旧数据如何合并 |
NodeAction |
节点如何提交状态更新 |
上一关我们实现了:
AgentState → NodeAction → StateGraph → compile → stream
你已经理解:
Node 读取 State → 执行业务 → 返回 Map 更新量 → 框架合并 State。
这一关要解决一个更深入的问题:
如果两个 Node 都修改同一个 State 字段,LangGraph4j 到底是覆盖原来的值,还是追加到原来的值?
答案是:由 Channel 的状态更新策略决定。
一、先看一个实际问题
假设你正在开发一个 AI Agent,用户让它:
帮我查询某个学生的成绩,分析挂科情况,最后生成学习建议。
整个工作流是:

我们设计两个状态字段:
{
"currentStep": "查询成绩",
"logs": ["开始执行"]
}
每个节点执行完毕后,都要更新这两个字段。
但是两个字段的更新需求不同:
-
currentStep:应该始终显示最新执行的步骤。 -
logs:应该保留每个节点产生的日志。
如果所有字段都直接覆盖,执行到最后,日志就只剩下一条。
如果所有字段都追加,currentStep 又会变成一个列表,无法直接表示当前步骤。
所以,我们需要针对不同字段定义不同的更新规则。
二、Channel 和 Reducer 到底是什么?
可以用一个公式理解:

其中:
-
State:工作流当前保存的数据。
-
Node 更新量:节点返回的
Map<String, Object>。 -
Channel:为状态字段配置如何接收和合并更新。
-
Reducer:实际执行合并逻辑的函数 或操作规则。
在 LangGraph4j 中,你通常通过 Channel 接口及 Channels 工具类配置合并行为,而不一定需要自己创建一个名叫 Reducer 的 Java 类。

这里最重要的一点是:
Node 只负责产生更新,Channel 负责决定更新如何与旧值合并。
三、覆盖和追加的区别




你可以把这两种策略记成:
| 更新策略 | 旧值 | 新值 | 合并结果 |
|---|---|---|---|
| 覆盖 | A | B | B |
| 追加 | A | B | A, B |
接下来我们用 Java 真正实现它们。
四、开始 Java 实战:修改上一关的 Maven 项目
本关继续使用原来的 langgraph4j-demo,不创建新项目。
我们新增一个独立案例,不删除上一关的 TextState、InputNode 和 UppercaseNode。
代码按上一关的 langgraph4j-core:1.9.3 编写。官方 1.9 文档确认:未配置 Channel 的字段默认覆盖,Channels.appender(...) 用于累积列表。
4.1 项目结构
src/main/java/com/example/langgraph
├── Main.java
├── state
│ ├── TextState.java
│ └── GradeState.java ← 新增
├── node
│ ├── InputNode.java
│ ├── UppercaseNode.java
│ ├── GradeQueryNode.java ← 新增
│ ├── GradeAnalysisNode.java
│ └── AdviceNode.java
└── ChannelDemo.java ← 新增
4.2 定义 GradeState
创建 state/GradeState.java。
java
package com.example.langgraph.state;
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;
public class GradeState extends AgentState {
public static final Map<String, Channel<?>> SCHEMA =
Map.of(
"logs", Channels.appender(ArrayList::new)
);
public GradeState(Map<String, Object> data) {
super(data);
}
public String currentStep() {
return this.<String>value("currentStep")
.orElse("未开始");
}
public List<String> logs() {
return this.<List<String>>value("logs")
.orElseGet(List::of);
}
}
这段代码是本课最重要的代码。
首先:
public static final Map<String, Channel<?>> SCHEMA
定义状态更新规则。
接着:
"logs", Channels.appender(ArrayList::new)
表示 logs 字段使用追加策略。
但是你会发现,我们没有给 currentStep 配置 Channel。
为什么?
因为官方文档规定:没有显式配置 Channel 的状态字段,默认采用覆盖策略。
所以这份 Schema 实际表达了:
| 字段 | 更新规则 |
|---|---|
currentStep |
默认覆盖 |
logs |
追加 |
注意,SCHEMA 不是 Java 类的字段声明表,而是状态更新规则表 。未写在 Schema 中的字段仍然可以存在于 State。
4.3 编写第一个节点:GradeQueryNode
java
package com.example.langgraph.node;
import com.example.langgraph.state.GradeState;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class GradeQueryNode
implements NodeAction<GradeState> {
@Override
public Map<String, Object> apply(GradeState state) {
System.out.println("执行:成绩查询");
return Map.of(
"currentStep", "成绩查询",
"logs", "成绩查询完成"
);
}
}
这个节点同时更新两个字段。
但框架会根据各字段的 Channel,分别应用更新规则。
4.4 编写第二个节点:GradeAnalysisNode
package com.example.langgraph.node;
import com.example.langgraph.state.GradeState;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class GradeAnalysisNode
implements NodeAction<GradeState> {
@Override
public Map<String, Object> apply(GradeState state) {
System.out.println("上一步:" + state.currentStep());
System.out.println("已有日志:" + state.logs());
return Map.of(
"currentStep", "成绩分析",
"logs", "成绩分析完成"
);
}
}
这里我们专门读取:
state.currentStep();
state.logs();
目的是验证框架是否已经合并第一个节点返回的更新。
如果状态流转正确,第二个节点就应该能够读取到:
currentStep = 成绩查询
logs = [开始执行, 成绩查询完成]
4.5 编写第三个节点:AdviceNode
package com.example.langgraph.node;
import com.example.langgraph.state.GradeState;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class AdviceNode
implements NodeAction<GradeState> {
@Override
public Map<String, Object> apply(GradeState state) {
System.out.println("上一步:" + state.currentStep());
System.out.println("已有日志:" + state.logs());
return Map.of(
"currentStep", "生成建议",
"logs", "学习建议生成完成"
);
}
}
这里依然只返回状态更新,不直接修改 State 内部的日志列表。
这是一个值得保持的工程习惯:让节点描述变化,让框架负责合并。
五、组装 Graph 并执行
创建 ChannelDemo.java。
java
package com.example.langgraph;
import com.example.langgraph.state.GradeState;
import com.example.langgraph.node.GradeQueryNode;
import com.example.langgraph.node.GradeAnalysisNode;
import com.example.langgraph.node.AdviceNode;
import org.bsc.langgraph4j.StateGraph;
import java.util.Map;
import static org.bsc.langgraph4j.StateGraph.START;
import static org.bsc.langgraph4j.StateGraph.END;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
public class ChannelDemo {
public static void main(String[] args) throws Exception {
var graph = new StateGraph<>(
GradeState.SCHEMA,
GradeState::new
);
graph.addNode(
"query",
node_async(new GradeQueryNode())
);
graph.addNode(
"analysis",
node_async(new GradeAnalysisNode())
);
graph.addNode(
"advice",
node_async(new AdviceNode())
);
graph.addEdge(START, "query");
graph.addEdge("query", "analysis");
graph.addEdge("analysis", "advice");
graph.addEdge("advice", END);
var compiled = graph.compile();
var initialState = Map.<String, Object>of(
"currentStep", "初始化",
"logs", "开始执行"
);
for (var output : compiled.stream(initialState)) {
System.out.println("节点输出:" + output);
}
}
}
这里你应该特别关注两处:
第一处:
GradeState.SCHEMA
框架通过它知道如何合并每个字段。
第二处:
GradeState::new
框架通过它构造新的 GradeState 对象。
它们分别负责不同的事情:
-
SCHEMA决定怎么更新状态。 -
GradeState::new决定怎么创建状态对象。
六、运行后应该观察什么?
在 IDEA 中运行 ChannelDemo.main()。
下面是模拟的关键业务日志,并非本机实际运行结果;框架额外输出的节点对象格式可能不同。
执行:成绩查询
上一步:成绩查询
已有日志:[开始执行, 成绩查询完成]
上一步:成绩分析
已有日志:[开始执行, 成绩查询完成, 成绩分析完成]
最终状态应该等价于:
{
"currentStep": "生成建议",
"logs": [
"开始执行",
"成绩查询完成",
"成绩分析完成",
"学习建议生成完成"
]
}
对比一下:
currentStep 被更新了三次,但最终只保存最新值。
logs 也被更新了三次,但每次更新都被追加到原有列表中。
这就是我们要验证的 Channel 状态合并规则。
七、进阶:自定义 Reducer
到这里你已经掌握了两种合并策略:
-
覆盖:保留最新值。
-
追加:累积历史值。
但真实业务不只有这两种需求。
例如,一个 Agent 工作流执行多个评分节点:
Node A:评分 70
Node B:评分 85
Node C:评分 90
我们希望 State 中的 maxScore 永远保存最高分。
如果使用覆盖策略,最终值取决于最后一次更新。
如果使用追加策略,最终会得到一个列表。
我们真正需要的是:

7.1 自定义最大值 Reducer
在 LangGraph4j 中,可以使用 Channel.of(...) 定义自定义合并函数。
例如:
public static final Map<String, Channel<?>> SCHEMA =
Map.of(
"logs", Channels.appender(ArrayList::new),
"maxScore", Channel.<Integer>of(
(oldValue, newValue) ->
Math.max(oldValue, newValue)
)
);
这段代码表示:
当某个 Node 返回:
return Map.of("maxScore", 85);
框架不会简单地覆盖旧值,而是执行我们定义的合并函数。
交互实验:自定义最大值 Reducer




7.2 自定义 Reducer 的工程注意事项
Reducer 最好满足以下要求:
-
尽量不修改输入对象,而是返回合并后的新值。
-
对于并行节点,优先使用满足结合律的合并规则。
-
如果并行执行顺序不确定,最好也满足交换律。
-
避免在 Reducer 中调用数据库、HTTP 接口等有副作用的操作。
例如 Math.max() 既满足结合律也满足交换律,很适合这类状态归约。
而字符串拼接虽然满足结合律,但通常不满足交换律,因此并行节点的输出顺序需要特别考虑。
八、自动化验收:用 JUnit 验证最终 State
仅仅观察控制台日志还不够。后端开发最好用自动化测试明确验证结果。
在 pom.xml 增加 JUnit 5 依赖:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
并配置 Maven Surefire 插件:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
</plugins>
</build>
8.1 编写测试
创建:
src/test/java/com/example/langgraph/ChannelTest.java
package com.example.langgraph;
import com.example.langgraph.state.GradeState;
import com.example.langgraph.node.*;
import org.bsc.langgraph4j.StateGraph;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.bsc.langgraph4j.StateGraph.*;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
import static org.junit.jupiter.api.Assertions.*;
class ChannelTest {
@Test
void shouldReplaceStepAndAppendLogs() throws Exception {
var graph = new StateGraph<>(
GradeState.SCHEMA,
GradeState::new
)
.addNode("query", node_async(new GradeQueryNode()))
.addNode("analysis", node_async(new GradeAnalysisNode()))
.addNode("advice", node_async(new AdviceNode()))
.addEdge(START, "query")
.addEdge("query", "analysis")
.addEdge("analysis", "advice")
.addEdge("advice", END)
.compile();
var result = graph.invoke(
Map.of(
"currentStep", "初始化",
"logs", "开始执行"
)
);
assertTrue(result.isPresent());
var state = result.orElseThrow();
assertEquals("生成建议", state.currentStep());
assertEquals(
List.of(
"开始执行",
"成绩查询完成",
"成绩分析完成",
"学习建议生成完成"
),
state.logs()
);
}
}
这里使用 invoke() 获取最终状态,比通过 stream() 的中间输出断言更直接。
在 Maven 项目根目录执行:
mvn test
预期验收结果:
Tests run: 1, Failures: 0, Errors: 0
BUILD SUCCESS
这是预期输出,不代表你的项目已经通过测试。如果编译或运行结果不同,需要根据实际错误调整。
九、深入理解:State、Channel、Reducer 的关系
现在我们从框架设计的角度再看一次。

三个概念的职责非常明确:
| 概念 | 负责什么 | 类比 |
|---|---|---|
AgentState |
保存当前数据 | 工作流上下文 |
Channel |
定义字段更新规则 | 字段的合并配置 |
Reducer |
计算旧值和新值如何合并 | 合并函数 |
还有一个经常被问到的问题:
为什么不在 Node 里面自己更新 State?
例如:
state.logs().add("成绩查询完成");
因为这样会让节点直接修改共享的可变对象,增加状态追踪、并行执行和恢复时的复杂度。
而使用:
return Map.of("logs", "成绩查询完成");
可以把业务逻辑与状态合并逻辑分开。
这也是 LangGraph4j 设计中非常值得学习的一点。
十、结合实际后端开发,应该怎么选 Channel?
将来你开发 Spring Boot + LangGraph4j Agent 时,可以按下面的方式设计:
| State 字段 | 推荐策略 | 原因 |
|---|---|---|
userInput |
覆盖 | 当前用户输入 |
currentStep |
覆盖 | 当前执行步骤 |
queryResult |
覆盖 | 最新查询结果 |
messages |
追加或专用消息 Channel | 保存对话历史 |
toolCalls |
追加 | 保存工具调用记录 |
errorLogs |
追加 | 累积错误信息 |
maxScore |
自定义 Max Reducer | 保留最高分 |
retryCount |
按业务设计计数规则 | 可能是覆盖或累加 |
注意 retryCount:
如果 Node 返回的是当前总次数,例如 3,应使用覆盖。
如果 Node 返回的是增量,例如 1,则可以使用累加 Reducer。
所以不是字段名称决定 Channel,而是 Node 返回的数据语义决定合并策略。
这点在真实项目中非常重要。
十一、本关验收清单
学习验收进度
0/7
理解 State、Channel、Reducer 三者的关系
完成 GradeState 和三个 Node
成功执行 ChannelDemo.main()
验证 currentStep 使用覆盖策略
验证 logs 使用追加策略
理解自定义 Math.max Reducer
运行 mvn test 并通过自动化测试
十二、本关思考题
1. Node 返回 Map.of("logs", "A"),为什么框架可能不会覆盖旧 logs?(规则)
2. 如果 currentStep 没有配置 Channel,多次写入会怎样?(代替)
3. 如果希望 maxScore 始终保存最高值,最合适的方案是什么?(reduce设置)
十三、下一关:Conditional Edge 动态路由
目前我们的工作流都是固定顺序:
START → A → B → C → END
但真实 AI Agent 会遇到:
如果学生成绩合格,就生成正常评价;如果存在挂科,就生成预警建议。
这就需要根据 State 动态选择执行路径。
下一关我们会实现:

届时你会真正理解 LangGraph4j 如何利用 Conditional Edge 实现业务分支,并逐渐从普通工作流走向真正的 AI Agent 编排。
第三关:Conditional Edge 条件路由
第三关 · 动态工作流
前两关你已经掌握:
-
第一关:
AgentState → NodeAction → StateGraph → compile → stream -
第二关:
Channel → Reducer → 覆盖与追加
现在开始第三关,我们要解决一个新问题:
Node 执行完后,如何根据 State 中的数据,决定下一个执行哪个 Node?
这一关我们继续使用你的 langgraph4j-demo Maven 项目、Java 21 和 LangGraph4j 1.9.3,不接入 AI 模型,也不修改现有学校系统。
一、问题:固定 Edge 无法实现业务分支
上一关我们写的是:
java
graph.addEdge(START, "query");
graph.addEdge("query", "analysis");
graph.addEdge("analysis", "advice");
graph.addEdge("advice", END);
这意味着不管成绩如何,执行路径都固定。
但现在产品经理提出需求:
查询学生成绩,如果存在不及格科目,就生成学业预警;如果全部及格,就生成正常评价。
这时我们需要根据查询结果,选择不同的节点。
我们假设满分 100 分,60 分及格。
先体验一下动态路由

注意:这里的条件判断不是由 AI 自动完成的,而是我们自己写的 Java 逻辑。
二、解决方案:Conditional Edge
LangGraph4j 中有两种需要区分的边。
| 类型 | 作用 | 示例 |
|---|---|---|
| 普通 Edge | 固定连接下一个节点 | query → analysis |
| Conditional Edge | 根据 State 选择下一节点 | query → warning / normal |
普通 Edge
graph.addEdge("query", "analysis");
不需要判断,固定执行 analysis。
Conditional Edge
核心思想是:
state -> {
if (state.score() < 60) {
return "warning";
}
return "normal";
}
这段代码叫做路由函数。
它和 NodeAction 有一个关键区别:
-
NodeAction:执行任务,返回 Map 状态更新。 -
路由函数:读取 State,返回路由选择结果。
所以 Conditional Edge 本身不是一个新的业务 Node。
它更像普通 Java 中的:
java
if (score < 60) {
warningService.execute();
} else {
normalService.execute();
}
只不过我们把执行分支交给 Graph 管理。
三、开始编码:在现有 Maven 项目中实现条件路由
这次不重写整个项目,而是在上一关的基础上增加三个节点和一个状态字段。
为了避免你再次出现"代码能跑,但不理解为什么"的情况,我们每增加一段代码,就解释它解决了什么问题。
3.1 第一步:给 GradeState 增加 score
打开上一关的 GradeState.java。
保留原来的 SCHEMA、currentStep()、logs(),增加:
java
public int score() {
return this.<Integer>value("score")
.orElse(0);
}
现在你的 GradeState 可以读取三个字段:
| 字段 | 类型 | 用途 |
|---|---|---|
score |
Integer |
判断成绩是否及格 |
currentStep |
String |
保存当前执行步骤 |
logs |
List<String> |
追加执行日志 |
为什么 score 不需要配置 Channel?
因为我们只需要读取当前分数,不需要把多次分数累加起来。
另外,orElse(0) 是本次教学的简化写法。真实成绩业务中,缺失成绩不应该自动当成 0 分,应该单独处理。
3.2 第二步:创建 GradeQueryNode
上一关你已经有 GradeQueryNode,这次修改它的 apply():
java
@Override
public Map<String, Object> apply(GradeState state) {
int score = state.score();
System.out.println("查询到学生成绩:" + score);
return Map.of(
"currentStep", "成绩查询",
"logs", "成绩查询完成"
);
}
注意,我们没有在这里写:
java
if (score < 60) {
// 执行 WarningNode
}
为什么?
因为 GradeQueryNode 只负责查询或准备成绩数据。
执行哪个节点,是 Graph 的路由职责,而不是 GradeQueryNode 的职责。
这就是工作流编排与业务逻辑分离。
3.3 第三步:创建 WarningNode
新建 WarningNode.java:
java
package com.example.langgraph.node;
import com.example.langgraph.state.GradeState;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class WarningNode implements NodeAction<GradeState> {
@Override
public Map<String, Object> apply(GradeState state) {
System.out.println("执行学业预警节点");
return Map.of(
"currentStep", "学业预警",
"logs", "成绩不及格,已生成预警"
);
}
}
这个 Node 只做一件事:生成预警结果。
它不需要自己判断分数是否及格,因为只有条件满足时,Graph 才会执行它。
3.4 第四步:创建 NormalNode
新建 NormalNode.java:
java
package com.example.langgraph.node;
import com.example.langgraph.state.GradeState;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class NormalNode implements NodeAction<GradeState> {
@Override
public Map<String, Object> apply(GradeState state) {
System.out.println("执行正常评价节点");
return Map.of(
"currentStep", "正常评价",
"logs", "成绩及格,已生成正常评价"
);
}
}
现在两个业务分支都有了。
但是我们还没有告诉 Graph,应该什么时候执行哪个 Node。
这就需要 Conditional Edge。
四、核心代码:addConditionalEdges()
这是本关最重要的 API。
官方 LangGraph4j 文档采用:
java
.addConditionalEdges(
"query",
edge_async(state -> {
return state.score() < 60
? "fail"
: "pass";
}),
Map.of(
"fail", "warning",
"pass", "normal"
)
)
这个写法与官方条件边示例的结构一致。
我们逐个参数理解。
第一个参数 "query"
java
.addConditionalEdges("query", ...)
意思是:
当 query 节点执行完成后,进行条件路由。
注意是执行完成后,而不是执行前。
所以路由函数读取到的是 query 节点提交更新后的 State。
第二个参数 edge_async(...)
java
edge_async(state -> {
return state.score() < 60
? "fail"
: "pass";
})
这里有两个 Java 知识点。
第一个是 Lambda:
java
state -> { ... }
相当于定义一个接收 GradeState(state参数传入)、返回 String 的路由函数。
第二个是三元运算符:
java
score < 60 ? "fail" : "pass"
等价于:
java
if (score < 60) {
return "fail";
} else {
return "pass";
}
那 edge_async 呢?
它是把同步的路由函数适配成框架需要的异步动作形式。
它不意味着一定会创建新线程,也不意味着两个分支会并行执行。
第三个参数 Map.of(...)
java
Map.of(
"fail", "warning",
"pass", "normal"
)
这是路由映射表:
| 路由函数返回值 | 实际执行节点 |
|---|---|
"fail" |
WarningNode,节点 ID 为 warning |
"pass" |
NormalNode,节点 ID 为 normal |
所以路由函数返回 "fail",并不是直接调用 WarningNode。
而是:
-
路由函数返回
"fail"。 -
框架在映射表中查找
"fail"。 -
找到节点 ID
"warning"。 -
框架调度
WarningNode执行。
这里还有一个容易混淆的地方:
这个 Map.of() 是路由映射,不是 Node 返回的状态更新 Map。
虽然都是 Java Map,但用途完全不同。
五、完整代码:ConditionalDemo.java
在 com.example.langgraph 下创建 ConditionalDemo.java。
java
package com.example.langgraph;
import com.example.langgraph.state.GradeState;
import com.example.langgraph.node.GradeQueryNode;
import com.example.langgraph.node.WarningNode;
import com.example.langgraph.node.NormalNode;
import org.bsc.langgraph4j.StateGraph;
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;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
public class ConditionalDemo {
public static void main(String[] args) throws Exception {
// 1. 创建 Graph
var graph = new StateGraph<>(
GradeState.SCHEMA,
GradeState::new
);
// 2. 注册业务节点
graph.addNode("query",
node_async(new GradeQueryNode()));
graph.addNode("warning",
node_async(new WarningNode()));
graph.addNode("normal",
node_async(new NormalNode()));
// 3. 起始节点
graph.addEdge(START, "query");
// 4. 根据成绩选择下一节点
graph.addConditionalEdges(
"query",
edge_async(state -> {
int score = state.score();
System.out.println(
"路由判断,当前成绩:" + score
);
return score < 60 ? "fail" : "pass";
}),
Map.of(
"fail", "warning",
"pass", "normal"
)
);
// 5. 两条路径最终都结束
graph.addEdge("warning", END);
graph.addEdge("normal", END);
// 6. 编译
var compiled = graph.compile();
// 7. 执行:测试不及格情况
var initialState = Map.<String, Object>of(
"score", 55,
"currentStep", "初始化",
"logs", "开始执行"
);
for (var output : compiled.stream(initialState)) {
System.out.println("节点输出:" + output);
}
}
}
这里最值得注意的是:我们不再使用上一关的固定边:
graph.addEdge("query", "analysis");
而是将它替换成了条件边:
graph.addConditionalEdges("query", ...);
这样 query 节点执行完之后,下一步不再固定,而是根据 State 选择。
六、模拟运行:观察两个分支
情况 A:score = 55
运行:
"score", 55
预期关键日志:
查询到学生成绩:55
路由判断,当前成绩:55
执行学业预警节点
预期节点路径:
__START__
↓
query
↓
warning
↓
__END__
最终状态:
score = 55
currentStep = 学业预警
logs = [
开始执行,
成绩查询完成,
成绩不及格,已生成预警
]
情况 B:score = 85
现在只修改一个地方:
"score", 85
再次运行。
预期关键日志:
查询到学生成绩:85
路由判断,当前成绩:85
执行正常评价节点
预期节点路径:
__START__
↓
query
↓
normal
↓
__END__
最终状态:
score = 85
currentStep = 正常评价
logs = [
开始执行,
成绩查询完成,
成绩及格,已生成正常评价
]
以上均为模拟预期结果,需要以你本机实际运行输出为准。
七、深入理解:NodeAction 和 EdgeAction 有什么区别?
这也是第三关最容易混淆的地方。
| 对比 | NodeAction | EdgeAction |
|---|---|---|
| 核心职责 | 执行业务 | 决定执行路径 |
| 输入 | 当前 State | 当前 State |
| 返回 | Map<String,Object> |
String 路由标识 |
| 是否提交状态更新 | 是 | 通常不负责 |
| 示例 | 查询成绩 | 判断是否及格 |
比如:
// NodeAction
state -> Map.of("score", 55)
表示:
将 score 更新为 55。
而:
// EdgeAction
state -> state.score() < 60 ? "fail" : "pass"
表示:
根据当前成绩选择下一条执行路径。
注意:EdgeAction 返回的 "fail" 不会自动保存到 State。
如果你想在 State 中保存判断结果,应该让 Node 返回对应的状态更新。
再看框架内部的数据流转
以 score = 55 为例

你现在应该能看出,第二关和第三关是联系在一起的:
第二关解决数据怎么更新,第三关解决根据更新后的数据往哪里执行。
八、第三关必须做的三个验证
在 IDEA 中分别修改 score,观察运行结果。

这里测试 score = 60 特别重要,因为它验证的是条件边的边界行为:score < 60,不包括 60。
九、本关思考题

十、本关总结
你现在需要记住三个公式:
NodeAction:
State → 业务逻辑 → Map 更新量
Channel:
旧 State + Map 更新量 → 新 State
Conditional Edge:
新 State → 路由判断 → 下一个 Node
第三关完成后,你就掌握了 LangGraph4j 的三个基础能力:节点执行、状态管理、动态路由。
下一关我们将学习循环与重试:例如 AI 生成代码后进行编译,如果失败就进入修复节点,然后重新编译,最多尝试三次。这会把 Conditional Edge 从简单的二选一扩展成真正的 Agent 工作流。
第四关:循环、重试与退出条件
前三关,我们已经学会了:
-
NodeAction:执行节点的业务逻辑。
-
Channel / Reducer:决定状态如何更新。
-
Conditional Edge:根据 State 选择下一条路径。
第四关,我们要把它们组合起来,解决一个真正的 Agent 工作流问题:
AI 生成了一段代码,编译发现错误,应该自动返回修复节点;修复后重新编译。如果成功就结束,如果连续失败三次也要结束。
这就是 循环工作流(Loop)+ 重试机制(Retry)。
本关仍然使用 Java 21、Maven、LangGraph4j 1.9.3,不需要 Ollama 或大模型。先用模拟编译结果学会控制流程,再考虑接入真实 AI。
一、为什么需要循环?
第三关的条件边是二选一:
┌── WarningNode ── END
QueryNode ── 判断 ┤
└── NormalNode ─── END
但是实际 Agent 不一定执行一次就能成功。
例如 AI 自动修复代码:

注意这里有一条非常关键的边:
graph.addEdge("fix", "compile");
它让工作流从修复节点返回编译节点。
循环不是使用 Java 的 for 或 while 实现的,而是通过 Graph 的边形成回路。
当然,普通 Java 也可以用 while 实现类似流程,但 LangGraph4j 的优势是把每一步拆成独立 Node,便于管理状态、观察执行过程,以及后续接入 AI。
二、这一关需要理解的三个新概念
| 概念 | 作用 |
|---|---|
| 循环边 | 让某个节点返回之前的节点 |
retryCount |
在 State 中记录已经尝试的次数 |
| 退出条件 | 防止工作流无限循环 |
比如:
if (compiledSuccessfully) {
return "success";
}
if (retryCount >= 3) {
return "stop";
}
return "retry";
这里最容易犯的错误是:只写循环边,却没有设置退出条件。
那样一旦编译始终失败,工作流就可能一直循环,直到被外部限制中断。
三、先明确本关的执行规则

接下来正式写代码。
四、项目结构
建议你在现有项目中新建 loop 包,不要删除前面三关的代码。
src/main/java/com/example/langgraph/
│
├── ChannelDemo.java
├── ConditionalDemo.java
│
└── loop/
├── CodeState.java
├── GenerateNode.java
├── CompileNode.java
├── FixNode.java
├── StopNode.java
└── LoopDemo.java
这次一共写六个 Java 文件。
第一步:定义 CodeState
创建 CodeState.java:
package com.example.langgraph.loop;
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;
public class CodeState extends AgentState {
public static final Map<String, Channel<?>> SCHEMA =
Map.of(
"logs", Channels.appender(ArrayList::new)
);
public CodeState(Map<String, Object> data) {
super(data);
}
// 当前代码版本
public String code() {
return this.<String>value("code")
.orElse("");
}
// 编译尝试次数
public int retryCount() {
return this.<Integer>value("retryCount")
.orElse(0);
}
// 编译是否成功
public boolean compileSuccess() {
return this.<Boolean>value("compileSuccess")
.orElse(false);
}
// 模拟第几次编译成功;0 表示始终失败
public int successAt() {
return this.<Integer>value("successAt")
.orElse(0);
}
// 执行日志
public List<String> logs() {
return this.<List<String>>value("logs")
.orElseGet(List::of);
}
}
重点观察这几个字段:
| 字段 | 初始值 | 更新方式 |
|---|---|---|
code |
"" |
覆盖 |
retryCount |
0 |
覆盖 |
compileSuccess |
false |
覆盖 |
successAt |
例如 2 |
作为模拟参数读取 |
logs |
"开始执行" |
追加 |
你可能会问:
retryCount 为什么不用 Reducer 累加?
因为我们打算在 CompileNode 中计算:
int nextCount = state.retryCount() + 1;
然后把新值提交给框架。
这属于 Node 中的业务计算。
如果使用自定义累加 Reducer,也可以让 Node 每次提交 1,由 Reducer 负责累加。但本关先采用最容易调试的覆盖方式。
第二步:GenerateNode 生成代码
创建 GenerateNode.java:
package com.example.langgraph.loop;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class GenerateNode implements NodeAction<CodeState> {
@Override
public Map<String, Object> apply(CodeState state) {
System.out.println("生成初始代码");
return Map.of(
"code", "version-1",
"logs", "已生成初始代码"
);
}
}
这里的 "version-1" 只是模拟代码版本。
我们暂时不调用真实编译器,因为本关的重点是循环控制,而不是 Java 编译 API。
第三步:CompileNode 编译检查
创建 CompileNode.java:
package com.example.langgraph.loop;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class CompileNode implements NodeAction<CodeState> {
@Override
public Map<String, Object> apply(CodeState state) {
// 读取旧的尝试次数
int oldCount = state.retryCount();
// 本次编译次数
int nextCount = oldCount + 1;
// 模拟编译结果
boolean success =
state.successAt() > 0
&& nextCount >= state.successAt();
System.out.println(
"第 " + nextCount + " 次编译:" +
(success ? "成功" : "失败")
);
return Map.of(
"retryCount", nextCount,
"compileSuccess", success,
"logs", "第 " + nextCount + " 次编译" +
(success ? "成功" : "失败")
);
}
}
这段代码是本关第一个核心。
假设进入 Node 前:
retryCount = 1
compileSuccess = false
successAt = 2
执行:
int nextCount = state.retryCount() + 1;
得到:
nextCount = 2
再执行:
boolean success =
state.successAt() > 0
&& nextCount >= state.successAt();
因为:
2 > 0 → true
2 >= 2 → true
所以:
success = true
Node 最终返回:
Map.of(
"retryCount", 2,
"compileSuccess", true,
"logs", "第 2 次编译成功"
);
注意:这个 Map 仍然只是更新量。
真正更新 State 的还是 LangGraph4j 框架。
第四步:FixNode 修复代码
创建 FixNode.java:
package com.example.langgraph.loop;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class FixNode implements NodeAction<CodeState> {
@Override
public Map<String, Object> apply(CodeState state) {
String newCode =
"version-" + (state.retryCount() + 1);
System.out.println("修复代码:" + newCode);
return Map.of(
"code", newCode,
"logs", "已修复代码:" + newCode
);
}
}
比如第一次编译失败后:
retryCount = 1
执行 FixNode:
"version-" + (1 + 1)
得到:
version-2
这里还有一个非常重要的设计细节:
FixNode 不增加 retryCount。
因为我们规定 retryCount 记录的是编译次数,只有 CompileNode 才负责增加。
如果 CompileNode 和 FixNode 都加一次,就会导致计数混乱。
第五步:StopNode 终止任务
创建 StopNode.java:
package com.example.langgraph.loop;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class StopNode implements NodeAction<CodeState> {
@Override
public Map<String, Object> apply(CodeState state) {
System.out.println("达到最大编译次数,停止修复");
return Map.of(
"logs", "连续编译失败,任务终止"
);
}
}
StopNode 的作用是记录终止原因。
它不会再次执行修复。
五、最重要的部分:创建 LoopDemo
创建 LoopDemo.java:
java
package com.example.langgraph.loop;
import org.bsc.langgraph4j.StateGraph;
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;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
public class LoopDemo {
public static void main(String[] args) throws Exception {
// 1. 创建图
var graph = new StateGraph<>(
CodeState.SCHEMA,
CodeState::new
);
// 2. 注册节点
graph.addNode("generate", node_async(new GenerateNode()));
graph.addNode("compile", node_async(new CompileNode()));
graph.addNode("fix", node_async(new FixNode()));
graph.addNode("stop", node_async(new StopNode()));
// 3. 起点
graph.addEdge(START, "generate");
// 4. 生成代码后,进入编译
graph.addEdge("generate", "compile");
// 5. 编译完成后,根据 State 判断
graph.addConditionalEdges(
"compile",
edge_async(state -> {
if (state.compileSuccess()) {
return "success";
}
if (state.retryCount() >= 3) {
return "stop";
}
return "retry";
}),
Map.of(
"success", END,
"stop", "stop",
"retry", "fix"
)
);
// 6. 修复后重新编译,形成循环
graph.addEdge("fix", "compile");
// 7. 达到上限后结束
graph.addEdge("stop", END);
// 8. 编译
var compiled = graph.compile();
// 9. 初始状态
var initialState = Map.<String, Object>of(
"code", "",
"retryCount", 0,
"compileSuccess", false,
"successAt", 2,
"logs", "开始执行"
);
// 10. 执行并观察每个节点
for (var output : compiled.stream(initialState)) {
System.out.println(
"节点:" + output.node()
);
System.out.println(
"状态:" + output.state()
);
}
}
}
这里最重要的不是整段代码,而是下面两部分。
1. 条件边负责决定是否继续
graph.addConditionalEdges(
"compile",
edge_async(state -> {
if (state.compileSuccess()) {
return "success";
}
if (state.retryCount() >= 3) {
return "stop";
}
return "retry";
}),
Map.of(
"success", END,
"stop", "stop",
"retry", "fix"
)
);
路由顺序很重要:
先判断是否成功,再判断是否达到次数上限。
这样如果第三次编译成功,工作流仍然会正常结束,而不是误判成失败。
2. 循环边负责重新执行
graph.addEdge("fix", "compile");
这是第四关和第三关最明显的区别。
第三关是:
query → warning → END
第四关可以是:
compile → fix → compile → fix → compile
同一个 CompileNode 可以在一次工作流中执行多次。
但是每次执行时,它读取到的 CodeState 都已经包含之前节点提交的状态更新。
这也是为什么 retryCount 能够从 0 逐渐变成 1、2、3。
六、运行测试
在 IDEA 中运行 LoopDemo.main()。
先测试:
"successAt", 2
模拟预期控制台输出
生成初始代码
节点:__START__
节点:generate
第 1 次编译:失败
节点:compile
修复代码:version-2
节点:fix
第 2 次编译:成功
节点:compile
节点:__END__
实际日志的打印顺序可能略有差异;验收以节点执行顺序和最终 State 为准。
最终 State 应该包含:
retryCount = 2
compileSuccess = true
code = version-2
logs = [
开始执行,
已生成初始代码,
第 1 次编译失败,
已修复代码:version-2,
第 2 次编译成功
]
验收:测试四种情况

特别注意 successAt = 0:
它应该在第三次失败后进入 stop,不能无限循环。
七、理解一个进阶问题:为什么循环不会丢失之前的状态?
假设第一次编译后:
retryCount = 1
code = version-1
FixNode 返回:
Map.of(
"code", "version-2",
"logs", "已修复代码"
);
框架合并后:
retryCount = 1
code = version-2
因为 FixNode 没有提交 retryCount 更新,所以它保持原值。
第二次进入 CompileNode 时:
state.retryCount()
仍然得到 1。
因此:
int nextCount = 1 + 1;
得到 2。
这就是 State 在循环工作流中持续传递和更新的核心原理。
还有一点:在这个示例中,code 字段是覆盖式更新,logs 字段是追加式更新,恰好把第二关的 Channel 知识重新用了一遍。
八、第四关思考题

九、本关总结
第四关你只需要记住下面这个公式:
循环工作流 =
普通 Edge 形成回路
+ Conditional Edge 决定是否继续
+ State 记录执行次数
+ 退出条件防止无限循环
你现在已经接触了 LangGraph4j 最基础的四个核心能力:
| 关卡 | 核心能力 |
|---|---|
| 第一关 | 定义并执行 Graph |
| 第二关 | State、Channel、Reducer |
| 第三关 | Conditional Edge 条件分支 |
| 第四关 | Loop、Retry、退出条件 |
本关实际验收标准: 四种 successAt 都运行正确,日志不会丢失,失败最多编译三次。
下一关我们可以继续学习 Checkpoint(检查点)与持久化 :工作流执行到一半中断 ,如何保存 State,以及之后如何恢复执行。这是构建真正可用的 Agent 系统时非常重要的一部分。
第五关:Checkpoint 检查点与状态恢复
前四关,你已经学会了构建一个能执行、分支、循环和重试的工作流。
第五关要解决一个更接近真实项目的问题:
Agent 执行到一半,突然需要等待人工审核,或者程序暂时中断。如何保留执行进度,之后继续,而不是从 START 重新执行?
这就涉及 Checkpoint(检查点)与持久化。
一、先弄清三个容易混淆的概念
| 概念 | 作用 | 类比 |
|---|---|---|
State |
保存当前业务数据 | 游戏角色的血量、背包 |
Checkpoint |
保存某一时刻的工作流快照 | 游戏存档 |
Saver |
负责存储和读取检查点 | 游戏存档管理器 |
例如第四关的自动修复工作流:
START → Generate → Compile → Fix → Compile → END
假设执行到第一次编译失败后,我们保存一个检查点。

这个检查点不仅要考虑业务字段:
code = version-1
retryCount = 1
compileSuccess = false
还需要考虑工作流的执行位置等信息。
因为只保存 State,不一定能让工作流从原来的节点位置继续执行。
这一点是第五关最重要的知识。
二、这一关我们怎么学?
为了让你真正理解实现细节,我们分成两个小实验。
实验 A:暂停与恢复。 在 GenerateNode 执行完成后暂停,检查 State,再恢复执行 CompileNode。
实验 B:持久化。 解释为什么 MemorySaver 只能在当前进程内保存检查点,以及真实项目如何使用数据库保存。
先完成实验 A。我们继续复用第四关的 CodeState、GenerateNode、CompileNode、FixNode 和 StopNode,只新增一个主类。
三、核心 API
先认识四个对象:
MemorySaver saver = new MemorySaver();
它负责在内存中保存检查点。
CompileConfig compileConfig = CompileConfig.builder()
.checkpointSaver(saver)
.interruptBefore("compile")
.build();
这里有两个设置:
-
checkpointSaver(saver):启用检查点保存。 -
interruptBefore("compile"):在执行compile节点前暂停。RunnableConfig config = RunnableConfig.builder()
.threadId("checkpoint-demo-001")
.build();
threadId 是执行会话的标识。
你可以把它理解成数据库中的业务会话 ID :通过它找到同一条工作流执行记录。
最后是恢复:
compiled.stream(GraphInput.resume(), config);
它告诉框架:使用已有检查点继续执行,而不是提交一份新的初始状态。
这些 API 均可在 LangGraph4j 1.9 官方核心文档中找到。
四、动手实现:CheckpointDemo
为了让第一次实验更容易理解,我们暂时只使用两个节点:
START → generate → compile → END
这样先排除第四关的循环逻辑干扰。
新建:
com.example.langgraph.loop.CheckpointDemo
java
package com.example.langgraph.loop;
import org.bsc.langgraph4j.CompileConfig;
import org.bsc.langgraph4j.GraphInput;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.checkpoint.MemorySaver;
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 CheckpointDemo {
public static void main(String[] args) throws Exception {
// 1. 定义图
var graph = new StateGraph<>(
CodeState.SCHEMA,
CodeState::new
);
graph.addNode("generate", node_async(new GenerateNode()));
graph.addNode("compile", node_async(new CompileNode()));
graph.addEdge(START, "generate");
graph.addEdge("generate", "compile");
graph.addEdge("compile", END);
// 2. 创建内存检查点保存器
var saver = new MemorySaver();
// 3. 编译配置:在 compile 前暂停
var compileConfig = CompileConfig.builder()
.checkpointSaver(saver)
.interruptBefore("compile")
.build();
var compiled = graph.compile(compileConfig);
// 4. 同一个 threadId 对应同一条执行会话
var config = RunnableConfig.builder()
.threadId("checkpoint-demo-001")
.build();
// 5. 第一次运行
var initialState = Map.<String, Object>of(
"code", "",
"retryCount", 0,
"compileSuccess", false,
"successAt", 1,
"logs", "开始执行"
);
System.out.println("===== 第一次运行 =====");
for (var output : compiled.stream(initialState, config)) {
System.out.println(output);
}
// 6. 查看暂停时保存的状态
System.out.println("===== 检查点 =====");
var snapshot = compiled.getState(config);
System.out.println("上一个节点:" + snapshot.node());
System.out.println("下一个节点:" + snapshot.next());
System.out.println("保存的 State:" + snapshot.state());
// 7. 从检查点恢复
System.out.println("===== 恢复运行 =====");
for (var output : compiled.stream(GraphInput.resume(), config)) {
System.out.println(output);
}
}
}
这里有一个版本相关细节:如果 IDEA 提示 GraphInput 或 MemorySaver 的包名无法解析,请先查看 1.9.3 依赖中的实际类位置,不要随意引入其他 LangGraph4j 版本。
五、这段代码究竟怎么运行?
检查点执行过程

第一次运行时
预期会执行:
START
↓
GenerateNode
↓
保存检查点
↓
暂停(尚未执行 CompileNode)
所以你应该看到:
生成初始代码
但此时不应该已经出现:
第 1 次编译:成功
读取检查点时
预期包含:
code = version-1
retryCount = 0
compileSuccess = false
而执行位置应该指向:
next = compile
恢复执行时
框架读取检查点,然后继续执行 CompileNode。
预期:
第 1 次编译:成功
最终:
retryCount = 1
compileSuccess = true
以上是模拟预期行为,不代表你的本机已经运行成功。
六、一个重要问题:MemorySaver 能不能在重启程序后恢复?
不能。
MemorySaver 保存 的数据在当前 Java 进程的内存中。
例如:
第一次启动 Java
↓
创建 MemorySaver
↓
执行 GenerateNode
↓
保存检查点
↓
停止 Java 程序
当程序退出后,内存中的检查点也会丢失。
下一次重新运行:
new MemorySaver();
得到的是一个新的空保存器。
所以本关代码演示的是:
同一个 Java 进程内,暂停后恢复执行。
它不是跨进程、跨服务器重启的持久化恢复。
真正要实现重启后恢复,就需要使用文件或数据库等持久化 Saver。LangGraph4j 提供了文件系统以及 PostgreSQL、Redis 等相关持久化实现.
七、结合你未来的 Agent 项目理解
假设你开发一个智能学业分析 Agent:
START
↓
查询学生成绩
↓
AI 分析学业风险
↓
等待辅导员审核
↓
发送预警通知
↓
END
这里可能需要人工审核几个小时,甚至几天。
如果只用内存存储,一旦服务重启,待审核工作流就可能无法恢复。
而使用持久化检查点后,可以设计成:
-
Agent 完成学业风险分析。
-
将 State 和执行位置保存到数据库。
-
工作流暂停,等待辅导员审批。
-
辅导员通过前端提交审核结果。
-
后端根据工作流会话 ID 找到检查点。
-
更新审核结果 ,并恢复后续节点。
这也是为什么 Checkpoint 是实现 Human-in-the-loop(人在回路中)的重要基础。
不过在真实系统中,还需要处理权限校验、重复提交、通知幂等性等问题,不能只依赖 Checkpoint。
八、第五关验收与思考题

本关实际验收需要你在 IDEA 中确认三件事:
-
第一次执行只运行
GenerateNode,在CompileNode前暂停。 -
getState(config)能读取到保存的状态,且下一节点是compile。 -
调用
GraphInput.resume()后执行CompileNode,不重复执行GenerateNode。
第五关最重要的公式:
Checkpoint = 状态数据 + 执行进度
MemorySaver = 当前进程内保存检查点
RunnableConfig.threadId = 标识工作流会话
GraphInput.resume() = 从检查点继续执行
建议你先运行这段代码,把 IDEA 的真实输出发给我。我们重点拆解 CompileConfig、RunnableConfig 和 GraphInput.resume() 之间的调用关系,再进入下一关的人工审核 Agent。
第六关:Human-in-the-loop(人工审核)
前五关,你已经学习了 LangGraph4j 的核心机制:
| 关卡 | 学习内容 | 解决的问题 |
|---|---|---|
| 第一关 | StateGraph、Node、Edge | 如何组织工作流 |
| 第二关 | State 、Channel 、Reducer | 节点之间如何共享和更新数据 |
| 第三关 | Conditional Edge | 如何根据状态选择执行路径 |
| 第四关 | Loop、Retry | 如何循环 执行和控制重试 |
| 第五关 | Checkpoint、MemorySaver | 如何暂停并恢复工作流 |
| 第六关 | Human-in-the-loop | 如何让人参与工作流决策 |
你第五关已经在 IDEA 中成功验证了:
START → GenerateNode → 暂停 → 恢复 → CompileNode → END
这次我们继续使用你的环境:
- Java 21
- Maven
- LangGraph4j 1.9.3
- IntelliJ IDEA
- 原来的
langgraph4j-demo项目
不接入大模型,也不修改系统。
一、本关要解决什么问题?
假设我们正在开发一个 AI 智能助手。
用户提出:
帮我删除数据库中的 100 条测试数据。
AI 可以生成操作方案,但不能直接执行删除操作。
我们希望:
- AI 生成操作方案。
- 工作流暂停。
- 等待管理员审核。
- 管理员批准,才执行操作。
- 管理员拒绝,则取消操作。
工作流如下:
START
│
▼
PlanNode
生成操作方案
│
▼
Checkpoint 暂停
│
▼
人工审核
│
┌────────┴────────┐
│ │
批准 拒绝
│ │
▼ ▼
ExecuteNode RejectNode
│ │
└────────┬────────┘
▼
END
注意:本关的 ExecuteNode 只打印模拟执行结果,不会连接数据库,更不会真正删除数据。
与第五关有什么区别?
第五关:
compiled.stream(GraphInput.resume(), config);
恢复后直接继续执行。
第六关则需要在恢复前增加一步:
暂停
↓
人工输入审核结果
↓
更新 Checkpoint 中的 State
↓
恢复执行
↓
根据审核结果选择节点
因此本关最重要的不是 if/else,而是:
如何修改已经暂停的工作流状态。
二、本关核心 API:updateState()
上一关你使用过:
compiled.getState(config);
它负责读取检查点。
这次新增:
compiled.updateState(config, Map.of("approved", true));
它负责修改检查点中保存的状态。
LangGraph4j 的 CompiledGraph 提供了这个方法:
RunnableConfig updateState(
RunnableConfig config,
Map<String, Object> values
)
它返回更新后的 RunnableConfig。另外,状态更新仍然遵循 Channel 的合并规则,而不是无条件覆盖。LangGraph4j
例如:
暂停时:
approved = false
plan = "删除100条测试数据"
人工审核后:
var updatedConfig = compiled.updateState(
config,
Map.of("approved", true)
);
检查点中的状态变为:
approved = true
plan = "删除100条测试数据"
注意:updateState() 只更新状态,不会自动执行下一个节点。
还需要调用:
compiled.stream(GraphInput.resume(), updatedConfig);
才能恢复工作流。
三、开始编码
我们在现有项目下新建 approval 包。
项目结构:
com.example.langgraph
│
├── loop/
│ └── CheckpointDemo.java
│
└── approval/
├── ApprovalState.java
├── PlanNode.java
├── ReviewNode.java
├── ExecuteNode.java
├── RejectNode.java
└── ApprovalDemo.java
第一步:定义 ApprovalState
创建 ApprovalState.java。
package com.example.langgraph.approval;
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;
public class ApprovalState extends AgentState {
public static final Map<String, Channel<?>> SCHEMA =
Map.of(
"logs", Channels.appender(ArrayList::new)
);
public ApprovalState(Map<String, Object> data) {
super(data);
}
public String plan() {
return this.<String>value("plan")
.orElse("");
}
public boolean approved() {
return this.<Boolean>value("approved")
.orElse(false);
}
public List<String> logs() {
return this.<List<String>>value("logs")
.orElseGet(List::of);
}
}
这段代码与你第二关的 GradeState 基本相同。
唯一新增的核心字段是:
public boolean approved() {
return this.<Boolean>value("approved")
.orElse(false);
}
它表示人工审核结果。
我们规定:
approved = true → 批准
approved = false → 拒绝
为了便于学习,本次只有批准和拒绝两种结果。
真实项目通常还应该有:
PENDING 待审核
APPROVED 已批准
REJECTED 已拒绝
这样才能区分"还没审核"和"明确拒绝"。
第二步:PlanNode 生成操作方案
创建 PlanNode.java:
package com.example.langgraph.approval;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class PlanNode implements NodeAction<ApprovalState> {
@Override
public Map<String, Object> apply(ApprovalState state) {
System.out.println("生成操作方案");
return Map.of(
"plan", "模拟清理100条测试数据",
"logs", "操作方案已生成"
);
}
}
这个 Node 不执行任何删除操作,只生成方案。
你应该已经知道:
return Map.of(...)
返回的是状态更新量。
框架随后会将 plan 写入 State,并向 logs 追加日志。
第三步:ReviewNode 读取审核结果
创建 ReviewNode.java:
package com.example.langgraph.approval;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class ReviewNode implements NodeAction<ApprovalState> {
@Override
public Map<String, Object> apply(ApprovalState state) {
System.out.println("进入审核结果处理节点");
System.out.println("操作方案:" + state.plan());
System.out.println("审核结果:" + state.approved());
return Map.of(
"logs", "审核结果已读取"
);
}
}
为什么要增加这个节点?
因为我们希望:
PlanNode
↓
暂停
↓
人工更新 State
↓
ReviewNode
↓
Conditional Edge
这样恢复后,ReviewNode 就能读取人工修改的 approved。
注意:ReviewNode 不负责等待用户输入。
等待是由工作流暂停机制实现的。
第四步:ExecuteNode 和 RejectNode
创建 ExecuteNode.java:
package com.example.langgraph.approval;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class ExecuteNode implements NodeAction<ApprovalState> {
@Override
public Map<String, Object> apply(ApprovalState state) {
if (!state.approved()) {
throw new IllegalStateException("未经批准,禁止执行");
}
System.out.println("模拟执行操作:" + state.plan());
return Map.of(
"logs", "模拟操作执行完成"
);
}
}
这里增加了一个保护:
if (!state.approved()) {
throw new IllegalStateException(...);
}
即使以后路由代码写错,ExecuteNode 也会再次检查审核结果。
这属于防御性编程。
不过在真实系统中,单纯的布尔值不能作为安全授权凭据。还需要验证审批人身份、权限、审批记录,以及审批方案是否被篡改。
接着创建 RejectNode.java:
package com.example.langgraph.approval;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class RejectNode implements NodeAction<ApprovalState> {
@Override
public Map<String, Object> apply(ApprovalState state) {
System.out.println("管理员拒绝,操作取消");
return Map.of(
"logs", "人工审核拒绝,任务取消"
);
}
}
四、核心代码:ApprovalDemo
现在把前面所有节点连接起来。
package com.example.langgraph.approval;
import org.bsc.langgraph4j.CompileConfig;
import org.bsc.langgraph4j.GraphInput;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.checkpoint.MemorySaver;
import java.util.Map;
import java.util.Scanner;
import static org.bsc.langgraph4j.GraphDefinition.START;
import static org.bsc.langgraph4j.GraphDefinition.END;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
public class ApprovalDemo {
public static void main(String[] args) throws Exception {
// 1. 创建 Graph
var graph = new StateGraph<>(
ApprovalState.SCHEMA,
ApprovalState::new
);
// 2. 注册节点
graph.addNode("plan", node_async(new PlanNode()));
graph.addNode("review", node_async(new ReviewNode()));
graph.addNode("execute", node_async(new ExecuteNode()));
graph.addNode("reject", node_async(new RejectNode()));
// 3. 普通边
graph.addEdge(START, "plan");
graph.addEdge("plan", "review");
// 4. 条件边
graph.addConditionalEdges(
"review",
edge_async(state -> {
return state.approved()
? "yes"
: "no";
}),
Map.of(
"yes", "execute",
"no", "reject"
)
);
graph.addEdge("execute", END);
graph.addEdge("reject", END);
// 5. 配置检查点
var saver = new MemorySaver();
var compileConfig = CompileConfig.builder()
.checkpointSaver(saver)
.interruptBefore("review")
.build();
var compiled = graph.compile(compileConfig);
// 6. 创建会话
var config = RunnableConfig.builder()
.threadId("approval-demo-001")
.build();
// 7. 初始化 State
var initialState = Map.<String, Object>of(
"plan", "",
"approved", false,
"logs", "开始执行"
);
// 8. 第一次执行
System.out.println("===== 第一次执行 =====");
for (var output : compiled.stream(initialState, config)) {
System.out.println(output);
}
// 9. 查看检查点
System.out.println("===== 工作流已暂停 =====");
var snapshot = compiled.getState(config);
System.out.println("上一个节点:" + snapshot.node());
System.out.println("下一个节点:" + snapshot.next());
System.out.println("当前状态:" + snapshot.state());
if (!"review".equals(snapshot.next())) {
throw new IllegalStateException("工作流没有停在审核节点");
}
// 10. 模拟人工审核
Scanner scanner = new Scanner(System.in);
System.out.print("是否批准?输入 yes 或 no:");
String answer = scanner.nextLine().trim();
if (!answer.equalsIgnoreCase("yes")
&& !answer.equalsIgnoreCase("no")) {
throw new IllegalArgumentException("只能输入 yes 或 no");
}
boolean approved = answer.equalsIgnoreCase("yes");
// 11. 修改暂停状态
var updatedConfig = compiled.updateState(
config,
Map.of(
"approved", approved
)
);
System.out.println("===== 状态更新完成 =====");
System.out.println(
compiled.getState(updatedConfig).state()
);
// 12. 恢复工作流
System.out.println("===== 恢复执行 =====");
for (var output :
compiled.stream(GraphInput.resume(), updatedConfig)) {
System.out.println(output);
}
}
}
这里使用了你在第五关已经验证过的 MemorySaver、interruptBefore() 和 GraphInput.resume()。
新增的 updateState() 用于在恢复前修改保存的状态。它的返回配置可能包含新的检查点标识,因此我们用 updatedConfig 继续读取和恢复,而不是忽略返回值。GitHub
五、最重要的执行过程
假设管理员输入:
yes
我们一步一步分析。
① 第一次执行
初始 State:
plan = ""
approved = false
logs = ["开始执行"]
执行 PlanNode:
return Map.of(
"plan", "模拟清理100条测试数据",
"logs", "操作方案已生成"
);
框架合并后:
plan = "模拟清理100条测试数据"
approved = false
logs = [
"开始执行",
"操作方案已生成"
]
接下来准备执行:
review
但是我们配置了:
.interruptBefore("review")
因此框架暂停。
② 人工审核
控制台提示:
是否批准?输入 yes 或 no:
假设输入:
yes
程序执行:
boolean approved = true;
然后:
var updatedConfig = compiled.updateState(
config,
Map.of("approved", true)
);
这一步修改的是检查点中的状态。
此时 ReviewNode 仍然没有执行。
③ 恢复执行
调用:
compiled.stream(GraphInput.resume(), updatedConfig);
框架读取检查点,继续执行 ReviewNode。
ReviewNode 中:
state.approved()
现在返回:
true
然后执行条件边:
edge_async(state -> {
return state.approved() ? "yes" : "no";
})
返回:
yes
框架查找:
Map.of(
"yes", "execute",
"no", "reject"
)
得到:
execute
最终执行 ExecuteNode。
整个过程没有重新执行 PlanNode。
六、模拟运行结果
以下是预期输出的简化示例,不是你本机的真实运行结果。
情况 A:管理员批准
===== 第一次执行 =====
生成操作方案
===== 工作流已暂停 =====
上一个节点:plan
下一个节点:review
是否批准?输入 yes 或 no:yes
===== 状态更新完成 =====
approved=true
===== 恢复执行 =====
进入审核结果处理节点
操作方案:模拟清理100条测试数据
审核结果:true
模拟执行操作:模拟清理100条测试数据
NodeOutput{ node=__END__, ... }
预期执行路径:
START
↓
plan
↓
暂停
↓
人工批准
↓
review
↓
execute
↓
END
情况 B:管理员拒绝
重新运行程序,输入:
no
预期关键输出:
生成操作方案
是否批准?输入 yes 或 no:no
进入审核结果处理节点
审核结果:false
管理员拒绝,操作取消
预期执行路径:
START
↓
plan
↓
暂停
↓
人工拒绝
↓
review
↓
reject
↓
END
拒绝时不能出现"模拟执行操作"。
七、为什么不能直接修改原来的 Map?
这是本关最值得理解的 Java 与框架细节。
假设你写:
var initialState = Map.<String, Object>of(
"approved", false
);
暂停后,你想直接修改:
initialState.put("approved", true);
这样不行。
首先,Map.of() 返回不可修改的 Map,调用 put() 会抛出 UnsupportedOperationException。
即使你换成:
Map<String, Object> initialState = new HashMap<>();
也不能认为修改这个 Map 就会同步修改框架保存的检查点。
因为框架管理的是自己的工作流状态及检查点。
所以应该使用:
compiled.updateState(
config,
Map.of("approved", true)
);
你可以这样记忆:
| 操作 | 作用 |
|---|---|
state.approved() |
读取当前 State |
Node 返回 Map.of(...) |
在节点执行过程中提交状态更新 |
compiled.getState(config) |
从检查点读取状态快照 |
compiled.updateState(config, ...) |
从工作流外部更新检查点状态 |
GraphInput.resume() |
恢复工作流执行 |
这里有一个重要细节:
updateState() 也会遵循 Channel / Reducer 规则。
所以如果更新的是 logs,由于配置了 Appender,通常会追加日志,而不是覆盖整个日志列表。
八、真实 Spring Boot 项目中怎么使用?
现在我们把 IDEA 控制台的审核过程,转换成后端接口思路。
你未来可以设计两个接口:
POST /api/agent/tasks
创建 Agent 任务,生成方案并暂停,返回:
{
"taskId": "task-001",
"status": "WAITING_APPROVAL",
"plan": "模拟清理100条测试数据"
}
管理员审核:
POST /api/agent/tasks/task-001/approve
请求:
{
"approved": true
}
后端需要:
- 验证当前用户是否有审批权限。
- 根据
taskId查找工作流会话。 - 检查任务是否仍处于待审核状态。
- 验证审批对应的操作方案没有被替换。
- 更新 Checkpoint 中的审核结果。
- 恢复工作流。
- 保存执行结果。
对于真实项目,还需要使用持久化 Saver,避免 Spring Boot 重启后丢失检查点。
尤其是删除数据、发送通知、修改成绩 等操作,必须有独立的授权检查和审计记录,不能只凭 State 中的 approved=true 就执行。
九、第六关验收标准
请你在 IDEA 中运行 ApprovalDemo.main() 两次。
| 测试 | 操作 | 预期 |
|---|---|---|
| 测试 1 | 输入 yes |
执行 ExecuteNode |
| 测试 2 | 输入 no |
执行 RejectNode |
| 暂停验证 | 查看 snapshot.next() |
review |
| 状态验证 | 查看 updateState() 后的状态 |
approved 正确更新 |
| 恢复验证 | 查看执行日志 | PlanNode 不重复执行 |
| 安全验证 | 输入其他内容 | 拒绝无效输入,不恢复执行 |
这里还有一个区别需要注意:当前实验通过控制台输入模拟人工审核,整个过程仍然发生在同一次 Java 程序运行中。它并没有实现跨进程恢复。
十、本关思考题
假设工作流已经执行完 PlanNode,并暂停在 ReviewNode 之前。
管理员输入:
yes
程序执行:
var updatedConfig = compiled.updateState(
config,
Map.of("approved", true)
);
问题:此时 ExecuteNode 是否已经执行?
A. 已经执行,因为 approved=true。
B. 没有执行,因为 updateState() 只修改检查点状态,还需要恢复工作流。
C. 已经执行,因为 Conditional Edge 会自动监听 State 变化。
本关最重要的公式:
Human-in-the-loop
=
Checkpoint 暂停
+
人工审核
+
updateState 更新状态
+
GraphInput.resume 恢复
+
Conditional Edge 选择路径
这一关完成后,你就能够理解 Agent 系统中最基础的人工审批机制。
下一关我们将学习 Tool Calling(工具调用):让 LangGraph4j 工作流调用真实 Java Service,并逐步与 LangChain4j 集成。
第七关:Tool Calling(工具调用)
前六关,我们已经掌握:
State → Node → Edge → Conditional Edge → Loop → Checkpoint → Human-in-the-loop
现在正式进入第七关:让 Agent 调用 Java 后端的业务工具。
这一关非常重要,因为你是学习 Java 后端的。未来开发 Agent 时,不仅需要让 AI 回答问题,还需要让它能够查询数据库、调用 Service、计算数据,甚至在授权后执行具体业务操作。
一、本关学习目标
完成本关后,你应该能够回答以下问题:
NodeAction和@Tool有什么区别?- 什么是 Tool Calling?和普通 Java 方法调用有什么不同?
- LangGraph4j 如何调用 Java Service?
- LangChain4j 的
@Tool如何与 LangGraph4j 结合? - 为什么工具调用需要权限控制?
我们继续使用原来的环境:
| 项目 | 配置 |
|---|---|
| JDK | Java 21 |
| 构建工具 | Maven |
| 工作流框架 | LangGraph4j 1.9.3 |
| IDE | IntelliJ IDEA |
| 项目 | langgraph4j-demo |
| 本关依赖 | 暂时只用已有依赖 |
本关先不接入真实大模型。 我们先实现一个可运行的 Java 工具调用工作流,再学习由 LLM 自主选择工具。
这样你就能清楚地区分:
- Java 代码决定调用什么工具。
- AI 模型决定调用什么工具。
这两种方式的差别。
二、先理解:什么是 Tool?
假设用户提出:
查询学生 1001 的平均成绩。
你的后端可能已经有一个 Service:
public class GradeService {
public double getAverageScore(Long studentId) {
// 模拟查询数据库
return 86.5;
}
}
普通 Java 程序调用:
GradeService service = new GradeService();
double score = service.getAverageScore(1001L);
执行过程:
Java 代码
↓
调用 GradeService
↓
执行 getAverageScore()
↓
返回 86.5
这里没有 Agent,也没有 AI。
如果我们把它放进 LangGraph4j:
START
↓
接收用户请求
↓
GradeQueryNode
↓
调用 GradeService
↓
返回成绩
↓
END
这个时候,GradeService 就是工作流使用的一个业务工具。
但是要注意:
LangGraph4j 节点调用 Java Service,并不自动等于 LLM Tool Calling。
我们把两种方式分开学习。
三、NodeAction 和 Tool 的区别
| 对比 | NodeAction | Tool |
|---|---|---|
| 所属概念 | 工作流节点 | 可调用的业务能力 |
| 主要作用 | 控制工作流中的一个执行步骤 | 完成某个具体任务 |
| 输入 | State | 工具参数 |
| 输出 | State 更新量 | 工具执行结果 |
| 谁决定执行 | Graph 的边和路由 | Java 代码或 AI 模型 |
| 示例 | GradeQueryNode | GradeService.getAverageScore() |
例如:
public class GradeQueryNode implements NodeAction<GradeState> {
private final GradeService gradeService;
public GradeQueryNode(GradeService gradeService) {
this.gradeService = gradeService;
}
@Override
public Map<String, Object> apply(GradeState state) {
double average = gradeService.getAverageScore(
state.studentId()
);
return Map.of(
"averageScore", average
);
}
}
这里:
gradeService.getAverageScore(...)
是普通 Java 方法调用。
而:
apply(GradeState state)
是 LangGraph4j 的节点执行入口。
两者可以结合,但职责不同。
你可以把 Node 理解为工作流中的步骤,把 Tool 理解为这个步骤能够使用的能力。
四、开始实战:让 LangGraph4j 调用 Java Service
这一关我们做一个简单的成绩查询工作流。
目标:输入学生学号,调用 Java Service 查询模拟成绩,再输出分析结果。
执行流程:
START
↓
GradeQueryNode
↓
调用 GradeService
↓
GradeResultNode
↓
END
为了不影响你现有的项目,我们新建一个包:
com.example.langgraph.tool
│
├── ToolState.java
├── GradeService.java
├── GradeQueryNode.java
├── GradeResultNode.java
└── ToolDemo.java
第一步:创建 ToolState
ToolState.java
package com.example.langgraph.tool;
import org.bsc.langgraph4j.state.AgentState;
import java.util.Map;
public class ToolState extends AgentState {
public ToolState(Map<String, Object> data) {
super(data);
}
// 学号
public Long studentId() {
return this.<Long>value("studentId")
.orElse(0L);
}
// 平均成绩
public Double averageScore() {
return this.<Double>value("averageScore")
.orElse(0.0);
}
// 最终分析结果
public String result() {
return this.<String>value("result")
.orElse("");
}
}
代码解释
重点看:
public Long studentId() {
return this.<Long>value("studentId")
.orElse(0L);
}
这里的:
<Long>
是显式指定泛型类型。
告诉编译器:
我希望从 State 中读取一个 Long 类型的数据。
而:
.orElse(0L)
表示如果没有对应的值,就返回默认值 0L。
其中 L 表示 Long 类型的整数字面量。
你之前已经学习过 Optional.orElse(),这里正好复习。
第二步:创建 GradeService
GradeService.java
package com.example.langgraph.tool;
public class GradeService {
public double getAverageScore(Long studentId) {
System.out.println(
"GradeService 正在查询学生:" + studentId
);
// 模拟数据库查询
if (studentId == 1001L) {
return 86.5;
}
if (studentId == 1002L) {
return 58.0;
}
throw new IllegalArgumentException(
"学生不存在:" + studentId
);
}
}
这里暂时不用数据库。
模拟两条学生数据:
| 学号 | 平均成绩 |
|---|---|
| 1001 | 86.5 |
| 1002 | 58.0 |
注意:
if (studentId == 1001L)
由于 studentId 是 Long,右侧是基本类型 long,Java 会对 studentId 自动拆箱,再比较数值。
但如果 studentId 是 null,自动拆箱会抛出 NullPointerException。实际项目中应该提前校验参数。
第三步:创建 GradeQueryNode
GradeQueryNode.java
package com.example.langgraph.tool;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class GradeQueryNode implements NodeAction<ToolState> {
private final GradeService gradeService;
public GradeQueryNode(GradeService gradeService) {
this.gradeService = gradeService;
}
@Override
public Map<String, Object> apply(ToolState state) {
Long studentId = state.studentId();
System.out.println("GradeQueryNode 开始执行");
// 调用 Java Service
double averageScore =
gradeService.getAverageScore(studentId);
// 返回 State 更新量
return Map.of(
"averageScore", averageScore
);
}
}
重点解释:构造器注入
private final GradeService gradeService;
表示这个节点持有一个 GradeService 对象。
构造器:
public GradeQueryNode(GradeService gradeService) {
this.gradeService = gradeService;
}
负责接收外部传入的 Service。
例如:
GradeService service = new GradeService();
GradeQueryNode node = new GradeQueryNode(service);
这里:
new GradeQueryNode(service)
调用的是构造方法。
而:
node.apply(state)
调用的是 NodeAction 接口定义的业务方法。
这与你之前学习的 Function.apply() 是同一种函数式接口调用思想。
第四步:创建 GradeResultNode
GradeResultNode.java
package com.example.langgraph.tool;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class GradeResultNode implements NodeAction<ToolState> {
@Override
public Map<String, Object> apply(ToolState state) {
double score = state.averageScore();
String result;
if (score >= 60) {
result = "成绩正常,平均分:" + score;
} else {
result = "存在学业风险,平均分:" + score;
}
System.out.println("GradeResultNode:" + result);
return Map.of(
"result", result
);
}
}
这个节点不查询成绩。
它只读取前一个节点写入的:
state.averageScore()
然后生成分析结果。
因此,两个节点的职责不同:
GradeQueryNode
│
├── 调用 Service
└── 写入 averageScore
│
▼
GradeResultNode
│
├── 读取 averageScore
└── 写入 result
五、创建 ToolDemo 主程序
ToolDemo.java
package com.example.langgraph.tool;
import org.bsc.langgraph4j.StateGraph;
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 ToolDemo {
public static void main(String[] args) throws Exception {
// 1. 创建 Service
GradeService gradeService = new GradeService();
// 2. 创建工作流
var graph = new StateGraph<>(
ToolState::new
);
// 3. 注册节点
graph.addNode(
"query",
node_async(new GradeQueryNode(gradeService))
);
graph.addNode(
"result",
node_async(new GradeResultNode())
);
// 4. 连接节点
graph.addEdge(START, "query");
graph.addEdge("query", "result");
graph.addEdge("result", END);
// 5. 编译工作流
var compiled = graph.compile();
// 6. 初始状态
Map<String, Object> initialState = Map.of(
"studentId", 1001L,
"averageScore", 0.0,
"result", ""
);
// 7. 执行
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
}
}
这里有一个与你前面课程不同的地方:
new StateGraph<>(ToolState::new)
这次没有传入 SCHEMA。
因为本关只需要默认覆盖行为,没有需要追加的 logs Channel。
如果你的 1.9.3 版本提示该构造方法不可用,可以继续沿用之前已经验证过的双参数写法:
new StateGraph<>(
Map.of(),
ToolState::new
)
六、模拟运行结果
运行 ToolDemo.main()。
以下是模拟预期输出,不代表你的本机已经通过验证。
NodeOutput{ node=__START__, ... }
GradeQueryNode 开始执行
GradeService 正在查询学生:1001
NodeOutput{ node=query, state={
studentId=1001
averageScore=86.5
result=
}}
GradeResultNode:成绩正常,平均分:86.5
NodeOutput{ node=result, state={
studentId=1001
averageScore=86.5
result=成绩正常,平均分:86.5
}}
NodeOutput{ node=__END__, ... }
然后把:
"studentId", 1001L
修改为:
"studentId", 1002L
预期:
GradeService 正在查询学生:1002
GradeResultNode:存在学业风险,平均分:58.0
如果这两个结果都正确,说明你已经完成了 LangGraph4j 调用 Java Service 的实验。
七、真正的 AI Tool Calling 是怎么工作的?
前面的代码:
gradeService.getAverageScore(studentId);
本质上还是 Java 程序主动调用方法。
真正的 AI Tool Calling 需要模型参与工具选择。
例如用户提问:
学生 1001 的平均成绩是多少?
模型收到可用工具的信息:
工具名称:getAverageScore
工具描述:查询学生平均成绩
参数:
studentId: Long
模型可能生成工具调用请求:
{
"name": "getAverageScore",
"arguments": {
"studentId": 1001
}
}
注意:模型通常只是生成工具调用请求,不会直接执行你的 Java 方法。
LangChain4j 等框架负责解析请求、调用已注册的 Java 工具,并把结果交回模型。LangChain4j
执行过程:
用户提问
↓
LLM 判断需要查询成绩
↓
LLM 生成工具调用请求
↓
LangChain4j 解析请求
↓
调用 GradeService
↓
返回 86.5
↓
LLM 生成最终回答
这才是通常所说的 LLM Tool Calling。
八、LangChain4j 的 @Tool
在已经引入 LangChain4j 相应依赖的项目中,可以这样定义工具:
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.P;
public class GradeTools {
private final GradeService gradeService;
public GradeTools(GradeService gradeService) {
this.gradeService = gradeService;
}
@Tool("查询指定学生的平均成绩")
public double getAverageScore(
@P("学生学号") Long studentId
) {
return gradeService.getAverageScore(studentId);
}
}
这里有两个注解。
@Tool:
@Tool("查询指定学生的平均成绩")
告诉 LangChain4j:
这个方法可以作为工具提供给大模型。
@P:
@P("学生学号")
描述工具参数,帮助模型理解应该传入什么数据。
但要注意:加上 @Tool 不代表模型就能自动调用这个方法。
你还需要把工具注册到 AI Service。
例如:
interface GradeAssistant {
String chat(String message);
}
配置:
GradeAssistant assistant =
AiServices.builder(GradeAssistant.class)
.chatModel(chatModel)
.tools(new GradeTools(gradeService))
.build();
这里的 chatModel 是已经创建好的 LangChain4j 模型对象。
以上是集成示意代码,需要加入匹配版本的 LangChain4j 依赖并配置支持工具调用的模型后才能运行。
LangChain4j 官方文档也采用 @Tool 配合 AiServices.builder(...).tools(...) 的方式注册 Java 工具。GitHub
你可以查看:LangChain4j 官方 Tool Calling 教程。
九、LangGraph4j + LangChain4j 应该怎么结合?
你已经学过 LangChain4j 的 RAG、Memory,现在又学习了 LangGraph4j。
两者可以结合使用。
例如:
START
│
▼
AnalyzeNode
│
│ 调用 LangChain4j
▼
AI 判断意图
│
Conditional Edge
│
┌─────────┴─────────┐
│ │
▼ ▼
GradeQueryNode RAGQueryNode
│ │
│ │
GradeService VectorStore
│ │
└─────────┬─────────┘
│
▼
AnswerNode
│
▼
END
你可以这样理解:
LangGraph4j 负责工作流编排,LangChain4j 负责模型交互和工具调用等 AI 能力。
它们不是互相替代的关系。
实际项目中,LangGraph4j 的某个 Node 可以调用 LangChain4j 的 AI Service;AI Service 再根据模型返回的工具调用请求执行已注册的 Java 工具。
当然,并不是每个 Node 都需要调用 LLM。
对于固定的成绩查询、权限检查、数据库操作,直接调用 Java Service 往往更简单、稳定。
十、本关验收与思考题
这次先完成不依赖大模型的实验。
验收任务:
| 测试 | 输入 | 预期结果 |
|---|---|---|
| 测试 1 | studentId=1001L |
平均分 86.5,成绩正常 |
| 测试 2 | studentId=1002L |
平均分 58.0,存在学业风险 |
| 测试 3 | studentId=9999L |
抛出学生不存在异常 |
第三个测试用于观察节点中的 Service 抛出异常后,工作流如何处理错误。暂时不需要添加自动重试。
思考题
观察下面这段代码:
public Map<String, Object> apply(ToolState state) {
double score = gradeService.getAverageScore(
state.studentId()
);
return Map.of("averageScore", score);
}
问题:谁真正执行了 getAverageScore() 方法?
A. LangGraph4j 直接根据 @Tool 自动选择并调用。
B. GradeQueryNode 的 apply() 方法通过普通 Java 方法调用执行。
C. LLM 根据用户问题自动生成工具调用请求。
本关重点:
NodeAction = 工作流执行节点
Java Service = 业务能力
@Tool = 向 LLM 工具调用机制暴露方法
Tool Calling = 模型提出工具调用请求,
框架执行工具并返回结果
你先运行 ToolDemo,完成两个学生的成绩查询,把真实控制台输出发给我。
下一步我们将让 LangChain4j 的模型真正参与工具选择,实现从"Java 固定调用工具"到"AI 根据用户问题选择工具"的升级。
第八关:LangChain4j + LangGraph4j 集成
本关目标:让大模型根据用户问题,自动选择并调用 Java 工具。
第七关你已经理解了:
gradeService.getAverageScore(studentId);
这是普通 Java 方法调用,工具由程序员提前指定。
第八关我们升级为:
用户问:"1001 号学生平均分是多少?"
模型理解问题后,决定调用 getAverageScore 工具,Java 程序执行查询,再由模型整理回答。
一、这次与第七关有什么不同?
第七关:
START
↓
GradeQueryNode
↓
Java 代码指定调用 GradeService
↓
GradeResultNode
↓
END
第八关:
START
↓
AiNode
↓
LangChain4j 调用 LLM
↓
LLM 判断是否需要工具
↓
LLM 生成工具调用请求
↓
LangChain4j 执行 Java @Tool
↓
LLM 根据工具结果生成回答
↓
AnswerNode
↓
END
这里有一个重要区别:
LangGraph4j 管理整个工作流,LangChain4j 管理模型交互和工具调用。
本关采用最容易理解的方案:在 LangGraph4j 的 Node 中调用 LangChain4j AI Service。
等你理解这一层集成,再学习把工具调用拆成独立 Graph 节点,以便实现更细粒度的审批、重试和监控。
二、本关项目结构
继续使用你的独立练习项目 langgraph4j-demo。
com.example.langgraph
│
├── tool/ 第七关
│
└── integration/ 第八关
├── AgentState.java
├── GradeService.java
├── GradeTools.java
├── GradeAssistant.java
├── AiNode.java
├── AnswerNode.java
└── IntegrationDemo.java
环境仍然是 Java 21、Maven、LangGraph4j 1.9.3。
模型使用你之前学习 RAG 时接触过的 Ollama。本关需要选择支持 Tool Calling 的聊天模型 ,不能直接把 Embedding 模型 nomic-embed-text 当作聊天模型使用。
三、第一步:准备依赖和 Ollama
1. 添加 Maven 依赖
先在 pom.xml 中保留原来的 LangGraph4j 依赖,再加入 LangChain4j。
这里统一使用 LangChain4j 1.22.0,不要混用不同版本。
<properties>
<maven.compiler.release>21</maven.compiler.release>
<langchain4j.version>1.22.0</langchain4j.version>
</properties>
<dependencies>
<!-- LangGraph4j:保留你原来的版本 -->
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-core</artifactId>
<version>1.9.3</version>
</dependency>
<!-- LangChain4j 核心 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- Ollama 模型集成 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId>
<version>${langchain4j.version}</version>
</dependency>
</dependencies>
注意: 上面是相关配置片段。如果你的 pom.xml 已有 properties 或 dependencies,应合并内容,不要重复创建这些标签。
官方参考:LangChain4j Ollama 集成文档。
2. 检查 Ollama
打开 Windows PowerShell:
ollama list
查看本机已安装的模型。
本关示例使用:
llama3.1:8b
如果没有安装,并且电脑资源允许,可以执行:
ollama pull llama3.1:8b
如果你已经有支持工具调用的其他模型,也可以使用现有模型,不必重复下载。
然后检查 Ollama 服务:
curl.exe http://localhost:11434/api/tags
正常情况下会返回模型列表的 JSON。
这里先不要把 Ollama 服务正常等同于 Tool Calling 正常。 工具调用还取决于具体模型是否支持,以及模型能否正确生成工具参数。
四、第二步:创建业务工具
我们继续沿用第七关的成绩查询场景。
1. GradeService.java
package com.example.langgraph.integration;
public class GradeService {
public double getAverageScore(Long studentId) {
System.out.println(
"[GradeService] 查询学生:" + studentId
);
if (studentId == 1001L) {
return 86.5;
}
if (studentId == 1002L) {
return 58.0;
}
throw new IllegalArgumentException(
"学生不存在:" + studentId
);
}
}
这里仍然是模拟数据,不访问真实数据库。
2. GradeTools.java
package com.example.langgraph.integration;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.P;
public class GradeTools {
private final GradeService gradeService;
public GradeTools(GradeService gradeService) {
this.gradeService = gradeService;
}
@Tool("根据学生学号查询平均成绩")
public double getAverageScore(
@P("学生学号,例如1001") Long studentId
) {
System.out.println(
"[Tool] 大模型请求查询学号:" + studentId
);
return gradeService.getAverageScore(studentId);
}
}
重点看:
@Tool("根据学生学号查询平均成绩")
它向 LangChain4j 声明:
这个 Java 方法可以作为一个工具提供给模型。
而:
@P("学生学号,例如1001")
用于描述参数的含义。
但模型不会直接运行 Java 代码。
真正的执行链是:
LLM 生成工具调用请求
↓
LangChain4j 解析请求
↓
GradeTools.getAverageScore()
↓
GradeService.getAverageScore()
↓
返回工具结果
这也是 LangChain4j 官方 Tool Calling 教程 中的基本工作方式。
五、第三步:创建 AI Service
GradeAssistant.java
package com.example.langgraph.integration;
import dev.langchain4j.service.SystemMessage;
public interface GradeAssistant {
@SystemMessage("""
你是一个学生成绩查询助手。
规则:
1. 用户询问具体学生成绩时,使用已提供的成绩查询工具。
2. 不允许编造学生成绩。
3. 工具返回什么成绩,就依据真实工具结果回答。
4. 使用简洁中文回答。
5. 如果没有学生数据,明确说明无法查询。
""")
String chat(String userMessage);
}
这里出现了一个你需要理解的新概念:
public interface GradeAssistant
我们只定义接口,没有自己编写实现类。
那么:
assistant.chat("查询1001的平均分");
究竟由谁实现?
答案是:
LangChain4j 的 AiServices 会在运行时创建接口的代理实现。
你之前学过:
Function<Integer, Integer> f = x -> x + 10;
这是通过 Lambda 提供函数式接口的实现。
而 AI Service 使用的是另一种机制:动态代理。
它可以拦截接口方法调用,将用户消息交给模型,再处理模型返回的内容和工具调用。
六、第四步:创建 AgentState
这里要区分两套状态:
- LangGraph4j 的
AgentState:保存整个工作流的数据。 - LangChain4j 的 AI Service:负责一次模型交互及其中的工具调用。
创建 AgentState.java:
package com.example.langgraph.integration;
import java.util.Map;
public class AgentState
extends org.bsc.langgraph4j.state.AgentState {
public AgentState(Map<String, Object> data) {
super(data);
}
public String question() {
return this.<String>value("question")
.orElse("");
}
public String answer() {
return this.<String>value("answer")
.orElse("");
}
}
本关只有两个主要字段:
question:用户问题
answer:模型回答
不需要额外的 Channel,因为默认覆盖行为已经足够。
七、第五步:创建 AiNode
这是本关最重要的类。
AiNode.java
package com.example.langgraph.integration;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.model.chat.ChatModel;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class AiNode implements NodeAction<AgentState> {
private final GradeAssistant assistant;
public AiNode(
ChatModel chatModel,
GradeTools gradeTools
) {
this.assistant = AiServices
.builder(GradeAssistant.class)
.chatModel(chatModel)
.tools(gradeTools)
.build();
}
@Override
public Map<String, Object> apply(AgentState state) {
String question = state.question();
System.out.println(
"[AiNode] 用户问题:" + question
);
// 调用 LangChain4j AI Service
String answer = assistant.chat(question);
System.out.println(
"[AiNode] 模型回答:" + answer
);
return Map.of(
"answer", answer
);
}
}
重点解释三个方法
① AiServices.builder()
AiServices.builder(GradeAssistant.class)
这里的 GradeAssistant.class 表示接口对应的 Class 对象。
LangChain4j 根据这个接口创建代理对象。
② .tools(gradeTools)
.tools(gradeTools)
这一步将工具注册给 AI Service。
如果没有注册:
.tools(gradeTools)
即使你给方法添加了 @Tool,这个 AI Service 也不会因此自动获得该工具。
③ assistant.chat(question)
String answer = assistant.chat(question);
这一行可能触发多个内部步骤:
assistant.chat(question)
↓
第一次请求 LLM
↓
模型决定调用工具
↓
LangChain4j 执行 GradeTools
↓
将工具结果发送给 LLM
↓
LLM 生成最终回答
↓
返回 String
注意:模型也可能不调用工具而直接回答,因此我们后面要通过日志验证工具是否真的执行。
八、第六步:创建 AnswerNode
package com.example.langgraph.integration;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class AnswerNode implements NodeAction<AgentState> {
@Override
public Map<String, Object> apply(AgentState state) {
System.out.println("========== 最终回答 ==========");
System.out.println(state.answer());
return Map.of();
}
}
这个节点只负责展示结果。
你可能会问:
为什么返回 Map.of()?
因为:
NodeAction.apply()
要求返回:
Map<String, Object>
但 AnswerNode 不需要修改任何 State 字段。
所以返回空 Map:
return Map.of();
表示:
本节点执行完成,但没有新的状态更新量。
九、第七步:创建 IntegrationDemo
现在将所有组件连接起来。
package com.example.langgraph.integration;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import org.bsc.langgraph4j.StateGraph;
import java.time.Duration;
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 IntegrationDemo {
public static void main(String[] args) throws Exception {
// 1. 创建 Ollama 聊天模型
ChatModel chatModel = OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.temperature(0.0)
.timeout(Duration.ofSeconds(120))
.build();
// 2. 创建业务 Service
GradeService gradeService = new GradeService();
// 3. 创建 AI 工具
GradeTools gradeTools = new GradeTools(gradeService);
// 4. 创建 LangGraph4j 工作流
var graph = new StateGraph<>(
Map.of(),
AgentState::new
);
// 5. 注册节点
graph.addNode(
"ai",
node_async(new AiNode(chatModel, gradeTools))
);
graph.addNode(
"answer",
node_async(new AnswerNode())
);
// 6. 添加边
graph.addEdge(START, "ai");
graph.addEdge("ai", "answer");
graph.addEdge("answer", END);
// 7. 编译
var compiled = graph.compile();
// 8. 初始状态
Map<String, Object> initialState = Map.of(
"question", "请查询学号1001的平均成绩",
"answer", ""
);
// 9. 执行
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
}
}
这里的核心就是:
graph.addNode(
"ai",
node_async(new AiNode(chatModel, gradeTools))
);
LangGraph4j 负责执行 AiNode。
AiNode 内部调用 LangChain4j。
LangChain4j 再负责模型与工具之间的交互。
十、模拟运行结果
以下为模拟预期输出,不是你电脑上的真实验证结果。
[AiNode] 用户问题:请查询学号1001的平均成绩
[Tool] 大模型请求查询学号:1001
[GradeService] 查询学生:1001
[AiNode] 模型回答:学号1001的平均成绩为86.5分。
========== 最终回答 ==========
学号1001的平均成绩为86.5分。
NodeOutput{ node=__END__, ... }
这里最重要的不是模型回答了多少分,而是有没有出现:
[Tool] 大模型请求查询学号:1001
以及:
[GradeService] 查询学生:1001
因为这两条日志能够证明:
模型确实触发了 Java 工具调用。
如果只出现:
[AiNode] 模型回答:学号1001的平均成绩为86.5分。
却没有工具执行日志,就不能认定 Tool Calling 已经成功。
模型也可能只是猜测了答案。
十一、验收任务
本关需要完成三次测试。
| 测试 | 用户问题 | 预期 |
|---|---|---|
| 1 | 查询学号1001的平均成绩 | 调用工具,返回86.5 |
| 2 | 查询学号1002的平均成绩 | 调用工具,返回58.0 |
| 3 | 你好,你是谁? | 正常回答,通常不需要调用成绩工具 |
其中,第三次测试很重要。
它可以帮助你理解:
工具注册成功,不代表每次调用 AI Service 都必须执行工具。
工具是否被选择,取决于模型的判断和工具调用配置。
本关先不处理不存在的学号,因为那涉及工具执行异常、异常信息是否反馈给模型等问题,我们留到后续关卡。
十二、思考题
看下面的代码:
@Override
public Map<String, Object> apply(AgentState state) {
String question = state.question();
String answer = assistant.chat(question);
return Map.of("answer", answer);
}
假设模型决定调用成绩查询工具。
问题:assistant.chat(question) 内部可能发生什么?
A. 只调用一次 LLM,LLM 直接执行 Java 方法。
B. LangChain4j 请求 LLM,模型生成工具调用请求,LangChain4j 执行 Java 工具,再将结果交给模型生成回答。
C. LangGraph4j 自动扫描项目中所有 Service,并随机选择一个执行。
你可以结合前面学过的 NodeAction、@Tool、AiServices 来判断。
十三、本关知识总结
| 概念 | 职责 |
|---|---|
| LangGraph4j | 管理整个工作流 |
NodeAction |
定义工作流节点的执行逻辑 |
| LangChain4j | 提供模型交互、AI Service、工具调用等能力 |
@Tool |
将 Java 方法声明为可供模型调用的工具 |
AiServices.builder() |
创建 AI Service 代理 |
.tools(...) |
注册可用工具 |
assistant.chat() |
发起模型交互,并可能触发工具调用 |
Map.of("answer", answer) |
将最终回答写回 LangGraph4j State |
下一关预告
第九关:Agent 工具调用的异常处理、权限控制与可观测性。
我们会在第八关的基础上增加:
- 工具执行失败后的处理。
- 禁止模型越权查询。
- 记录工具调用日志。
- 区分模型回答与真实工具结果。
- 为后续 Spring Boot Agent 项目做准备。
现在先完成第八关的代码运行。 如果 IDEA 出现依赖或 API 报错,把具体报错发给我;如果运行成功,把控制台输出发给我,我们根据真实结果验收。
第九关:Agent 工具调用的异常处理与权限控制
前八关,你已经学会了:
LangGraph4j
↓
AiNode
↓
LangChain4j AI Service
↓
LLM 选择工具
↓
@Tool 调用 Java Service
↓
返回结果
第九关,我们开始解决一个真正的后端开发问题:
如果 AI 调用了错误的工具、传入不存在的学生学号,或者试图查询没有权限访问的数据,后端应该怎么办?
这一关很重要,因为真实项目中的 Agent 不能只追求"能调用工具",还必须保证调用过程安全、可控、可追踪。
你目前已经完成第八关的理论题,但尚未提供 IntegrationDemo 的真实运行结果。因此,本关继续使用独立的模拟 Service,不把第八关的工具调用视为已经通过实际验证。
一、本关学习目标
我们分三个阶段:
| 阶段 | 学习内容 | 核心问题 |
|---|---|---|
| 9A | 异常处理 | 工具执行失败怎么办? |
| 9B | 权限控制 | AI 能不能查询所有学生? |
| 9C | 可观测性 | 如何知道 Agent 调用了什么工具? |
这次先完成 9A:工具调用异常处理。
后面的 9B 和 9C 会在这套代码上继续扩展。
二、先理解:Tool Calling 为什么会失败?
例如你之前写的:
@Tool("查询学生平均成绩")
public double getAverageScore(Long studentId) {
return gradeService.getAverageScore(studentId);
}
可能遇到以下情况:
| 场景 | 结果 |
|---|---|
| 学号 1001 | 正常返回 86.5 |
| 学号 1002 | 正常返回 58.0 |
| 学号 9999 | 学生不存在 |
| 学号为 null | 参数无效 |
| 数据库连接失败 | 系统异常 |
| 用户无权查询 | 权限拒绝 |
这些异常不能全部采用同一种处理方式。
尤其需要区分:
业务异常:
学生不存在
参数不合法
系统异常:
数据库连接失败
服务超时
程序内部错误
业务异常可以返回明确的业务结果。
系统异常则需要记录日志,并避免将数据库密码、SQL 语句、服务器内部信息直接暴露给模型或用户。
三、第一步:定义业务异常
继续使用第八关的包:
com.example.langgraph.integration
新建:
StudentNotFoundException.java
package com.example.langgraph.integration;
public class StudentNotFoundException
extends RuntimeException {
public StudentNotFoundException(Long studentId) {
super("学生不存在,学号:" + studentId);
}
}
代码解释
extends RuntimeException
表示自定义运行时异常。
你之前在 GradeService 中写过:
throw new IllegalArgumentException(
"学生不存在:" + studentId
);
这虽然能工作,但异常含义不够准确。
现在改为:
throw new StudentNotFoundException(studentId);
调用方就可以针对"学生不存在"单独处理。
四、第二步:改造 GradeService
把第八关的 GradeService 修改为:
package com.example.langgraph.integration;
public class GradeService {
public double getAverageScore(Long studentId) {
if (studentId == null || studentId <= 0) {
throw new IllegalArgumentException(
"学生学号必须是正整数"
);
}
System.out.println(
"[GradeService] 查询学生:" + studentId
);
if (studentId == 1001L) {
return 86.5;
}
if (studentId == 1002L) {
return 58.0;
}
throw new StudentNotFoundException(studentId);
}
}
这里使用了:
if (studentId == null || studentId <= 0)
为什么先判断 null?
因为 studentId 是包装类型 Long。
执行:
studentId <= 0
时,Java 需要自动拆箱。
如果它是 null,就会抛出 NullPointerException。
而 || 具有短路求值特性:
studentId == null
↓
true
↓
右边不再执行
所以这个判断是安全的。
五、第三步:定义统一的工具返回结果
以前:
public double getAverageScore(Long studentId)
只能返回一个 double。
现在我们希望返回:
{
"success": true,
"code": "SUCCESS",
"message": "查询成功",
"data": 86.5
}
查询失败时:
{
"success": false,
"code": "STUDENT_NOT_FOUND",
"message": "学生不存在",
"data": null
}
这是一种结构化的工具结果。
创建:
ToolResult.java
package com.example.langgraph.integration;
public record ToolResult<T>(
boolean success,
String code,
String message,
T data
) {
public static <T> ToolResult<T> ok(T data) {
return new ToolResult<>(
true,
"SUCCESS",
"查询成功",
data
);
}
public static <T> ToolResult<T> fail(
String code,
String message
) {
return new ToolResult<>(
false,
code,
message,
null
);
}
}
这里出现了一个 Java 21 知识点:record
你以前可能会写:
public class ToolResult {
private boolean success;
private String code;
private String message;
private Object data;
// 构造器
// getter
// equals
// hashCode
// toString
}
而使用:
public record ToolResult<T>(...)
Java 会自动生成构造器、访问器、equals()、hashCode() 和 toString() 等。
例如:
ToolResult<Double> result = ToolResult.ok(86.5);
读取:
result.success();
result.code();
result.data();
注意:record 不是传统意义上所有字段都可修改的 JavaBean。它适合表达不可重新赋值的数据载体。
六、第四步:在工具层处理异常
修改 GradeTools.java:
package com.example.langgraph.integration;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.P;
public class GradeTools {
private final GradeService gradeService;
public GradeTools(GradeService gradeService) {
this.gradeService = gradeService;
}
@Tool("根据学生学号查询平均成绩")
public ToolResult<Double> getAverageScore(
@P("学生学号") Long studentId
) {
System.out.println(
"[Tool] 开始查询:" + studentId
);
try {
double score =
gradeService.getAverageScore(studentId);
return ToolResult.ok(score);
} catch (StudentNotFoundException e) {
return ToolResult.fail(
"STUDENT_NOT_FOUND",
"学生不存在"
);
} catch (IllegalArgumentException e) {
return ToolResult.fail(
"INVALID_ARGUMENT",
"学生学号不合法"
);
} catch (Exception e) {
// 演示用:真实项目应使用 SLF4J 等日志框架
System.err.println(
"[Tool] 查询失败:" + e.getClass().getSimpleName()
);
return ToolResult.fail(
"INTERNAL_ERROR",
"成绩查询服务暂时不可用"
);
}
}
}
这里要理解 try-catch 的作用。
正常情况
double score =
gradeService.getAverageScore(1001L);
返回:
86.5
然后:
return ToolResult.ok(score);
得到:
success=true
code=SUCCESS
data=86.5
学生不存在
gradeService.getAverageScore(9999L);
内部执行:
throw new StudentNotFoundException(9999L);
异常会向上传播,直到被这里捕获:
catch (StudentNotFoundException e)
然后返回:
success=false
code=STUDENT_NOT_FOUND
message=学生不存在
这样,工具就把预期内的业务失败转换成了结构化结果。
为什么不直接把异常消息交给 AI?
因为真实系统的异常可能包含:
- SQL 语句
- 数据库表名
- 内部服务器地址
- 文件路径
- 敏感业务信息
因此我们使用固定的安全错误消息:
"成绩查询服务暂时不可用"
需要注意:这只是基础示例。生产环境还应设置异常处理策略、审计日志、超时和调用限制。
另外,LangChain4j 对自定义对象返回值的序列化行为与具体版本有关。本关的 ToolResult 用于表达结构化业务结果;实际接入 AI Service 后,还需要验证模型是否能正确理解序列化后的结果。
七、第五步:先不接入 LLM,直接测试工具
为什么?
因为我们需要区分:
Java 业务逻辑错误
和:
LLM 工具调用错误
如果一开始就把所有东西放在一起,出了问题很难定位。
新建:
ToolErrorDemo.java
package com.example.langgraph.integration;
public class ToolErrorDemo {
public static void main(String[] args) {
GradeService gradeService = new GradeService();
GradeTools tools = new GradeTools(gradeService);
System.out.println("===== 测试1:正常学生 =====");
System.out.println(
tools.getAverageScore(1001L)
);
System.out.println("===== 测试2:学生不存在 =====");
System.out.println(
tools.getAverageScore(9999L)
);
System.out.println("===== 测试3:非法学号 =====");
System.out.println(
tools.getAverageScore(-1L)
);
}
}
模拟预期输出
以下为模拟输出,不代表你的本机已经验证成功。
===== 测试1:正常学生 =====
[Tool] 开始查询:1001
[GradeService] 查询学生:1001
ToolResult[
success=true,
code=SUCCESS,
message=查询成功,
data=86.5
]
===== 测试2:学生不存在 =====
[Tool] 开始查询:9999
[GradeService] 查询学生:9999
ToolResult[
success=false,
code=STUDENT_NOT_FOUND,
message=学生不存在,
data=null
]
===== 测试3:非法学号 =====
[Tool] 开始查询:-1
ToolResult[
success=false,
code=INVALID_ARGUMENT,
message=学生学号不合法,
data=null
]
这里有一个非常重要的现象:
工具调用失败,不一定意味着 Java 程序必须崩溃。
对于可预期的业务异常,我们可以返回失败结果,让上层决定如何处理。
但不能把所有异常都简单吞掉。例如数据库长时间不可用时,除了返回错误,还需要监控告警,并根据情况进行有限重试。
八、第六步:再接入 LangChain4j
当 ToolErrorDemo 的三组测试通过后,再运行第八关的 IntegrationDemo。
将问题修改为:
Map<String, Object> initialState = Map.of(
"question", "请查询学号9999的平均成绩",
"answer", ""
);
我们期望:
用户提问
↓
LLM 选择 getAverageScore
↓
GradeTools.getAverageScore(9999)
↓
GradeService 抛出 StudentNotFoundException
↓
GradeTools 捕获异常
↓
返回 STUDENT_NOT_FOUND
↓
LLM 根据失败结果回答
模拟回答可能是:
未查询到学号 9999 的学生成绩,请确认学号是否正确。
但这里有两个需要真实验证的地方:
- 模型是否真的调用了工具。
- 模型是否正确理解工具返回的失败状态。
不能仅凭模型回答"学生不存在",就断定工具调用成功。
九、本关思考题
观察:
try {
double score =
gradeService.getAverageScore(studentId);
return ToolResult.ok(score);
} catch (StudentNotFoundException e) {
return ToolResult.fail(
"STUDENT_NOT_FOUND",
"学生不存在"
);
}
假设:
studentId = 9999L;
GradeService 抛出:
throw new StudentNotFoundException(studentId);
问题:程序接下来会发生什么?
A. 整个 Java 程序立即退出,无法继续执行。
B. 异常被 catch (StudentNotFoundException e) 捕获,工具返回失败结果。
C. LangGraph4j 自动重新执行 GradeService,直到查询成功。
十、第九关当前进度
| 阶段 | 内容 | 状态 |
|---|---|---|
| 9A | 工具异常处理 | 本关 |
| 9B | 工具权限控制 | 下一阶段 |
| 9C | 工具日志与可观测性 | 后续阶段 |
现在先运行 ToolErrorDemo,验证正常、学生不存在、非法学号三种情况。
下一阶段我们会重点解决:
即使模型正确调用了
getAverageScore(1001),如果当前登录用户没有查看该学生成绩的权限,后端应该如何拒绝?
届时我们会加入 UserContext、权限校验和安全的工具执行流程,理解为什么 LLM 选择工具不等于获得执行权限。
第九关 9B:Agent 工具调用权限控制。
一、本关目标
在第九关 9A 中,我们实现了:
LLM
↓
GradeTools
↓
GradeService
↓
返回学生成绩
现在增加权限校验:
LLM
↓
GradeTools
↓
PermissionService(权限校验)
├── 无权限 → PERMISSION_DENIED
│
└── 有权限
↓
GradeService
↓
返回学生成绩
我们使用一个独立的 Java 模拟环境,暂时不接入 Spring Boot 和 Sa-Token。
二、第一步:创建 UserContext
新建 UserContext.java:
package com.example.langgraph.integration;
public record UserContext(
Long userId,
String username
) {
}
它代表当前已认证用户的身份。
例如:
UserContext user = new UserContext(
1L,
"辅导员A"
);
注意:在真实后端中,UserContext 应当来自服务端验证过的登录会话或 Token,不能由 LLM 提供,也不能直接相信用户在聊天中声称的身份。
三、第二步:创建 PermissionService
新建 PermissionService.java:
package com.example.langgraph.integration;
import java.util.Map;
import java.util.Set;
public class PermissionService {
// 模拟数据库中的用户与学生授权关系
private final Map<Long, Set<Long>> permissions = Map.of(
1L, Set.of(1001L, 1002L),
2L, Set.of(1002L)
);
public boolean canViewGrade(
Long userId,
Long studentId
) {
return permissions
.getOrDefault(userId, Set.of())
.contains(studentId);
}
}
这段代码的含义:
| 用户 | 允许查询的学生 |
|---|---|
| 辅导员 A(1) | 1001、1002 |
| 辅导员 B(2) | 1002 |
其中:
permissions.getOrDefault(userId, Set.of())
表示:
如果找不到这个用户,就返回空集合。
.contains(studentId)
表示判断目标学生是否在授权集合中。
这样,未知用户默认没有权限。
这叫 默认拒绝(Default Deny)。
四、第三步:修改 GradeTools
我们在工具层增加权限校验。
package com.example.langgraph.integration;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
public class GradeTools {
private final GradeService gradeService;
private final PermissionService permissionService;
private final UserContext userContext;
public GradeTools(
GradeService gradeService,
PermissionService permissionService,
UserContext userContext
) {
this.gradeService = gradeService;
this.permissionService = permissionService;
this.userContext = userContext;
}
@Tool("根据学生学号查询平均成绩")
public ToolResult<Double> getAverageScore(
@P(name = "studentId", value = "学生学号")
Long studentId
) {
System.out.println(
"[Tool] 用户:" + userContext.username()
);
System.out.println(
"[Tool] 请求查询学生:" + studentId
);
// 1. 先检查权限
if (!permissionService.canViewGrade(
userContext.userId(),
studentId
)) {
System.out.println("[Security] 权限拒绝");
return ToolResult.fail(
"PERMISSION_DENIED",
"无权查看该学生的成绩"
);
}
// 2. 有权限才执行查询
try {
double score =
gradeService.getAverageScore(studentId);
return ToolResult.ok(score);
} catch (StudentNotFoundException e) {
return ToolResult.fail(
"STUDENT_NOT_FOUND",
"学生不存在"
);
} catch (IllegalArgumentException e) {
return ToolResult.fail(
"INVALID_ARGUMENT",
"学生学号不合法"
);
} catch (Exception e) {
System.err.println(
"[Tool] 查询异常:" + e.getClass().getSimpleName()
);
return ToolResult.fail(
"INTERNAL_ERROR",
"成绩查询服务暂时不可用"
);
}
}
}
重点理解执行顺序
if (!permissionService.canViewGrade(...)) {
return ToolResult.fail(...);
}
这里的 return 非常重要。
它会直接结束当前方法。
因此,无权限时不会继续执行:
gradeService.getAverageScore(studentId);
也就是说,权限校验发生在实际数据查询之前。
对于真实项目,还应在业务服务或数据访问边界强制执行授权,避免其他调用路径绕过 GradeTools。
五、第四步:创建权限测试类
新建 PermissionDemo.java:
package com.example.langgraph.integration;
public class PermissionDemo {
public static void main(String[] args) {
GradeService gradeService = new GradeService();
PermissionService permissionService =
new PermissionService();
UserContext userContext =
new UserContext(1L, "辅导员A");
GradeTools tools = new GradeTools(
gradeService,
permissionService,
userContext
);
System.out.println("===== 测试1:有权限 =====");
System.out.println(
tools.getAverageScore(1001L)
);
System.out.println("===== 测试2:无权限 =====");
System.out.println(
tools.getAverageScore(9999L)
);
System.out.println("===== 测试3:另一个有权限的学生 =====");
System.out.println(
tools.getAverageScore(1002L)
);
}
}
六、模拟运行结果
以下是模拟预期输出,不是你的真实运行结果。
===== 测试1:有权限 =====
[Tool] 用户:辅导员A
[Tool] 请求查询学生:1001
[GradeService] 查询学生:1001
ToolResult[
success=true,
code=SUCCESS,
message=查询成功,
data=86.5
]
===== 测试2:无权限 =====
[Tool] 用户:辅导员A
[Tool] 请求查询学生:9999
[Security] 权限拒绝
ToolResult[
success=false,
code=PERMISSION_DENIED,
message=无权查看该学生的成绩,
data=null
]
===== 测试3:另一个有权限的学生 =====
[Tool] 用户:辅导员A
[Tool] 请求查询学生:1002
[GradeService] 查询学生:1002
ToolResult[
success=true,
code=SUCCESS,
message=查询成功,
data=58.0
]
观察第二次测试:
[Security] 权限拒绝
后面没有:
[GradeService] 查询学生:9999
这就是我们要验证的核心安全性质:
未授权请求不能进入实际成绩查询逻辑。
七、第五步:接入 LangGraph4j
你原来的 IntegrationDemo 中:
GradeService gradeService = new GradeService();
GradeTools gradeTools = new GradeTools(gradeService);
现在需要修改为:
GradeService gradeService = new GradeService();
PermissionService permissionService =
new PermissionService();
UserContext userContext =
new UserContext(1L, "辅导员A");
GradeTools gradeTools = new GradeTools(
gradeService,
permissionService,
userContext
);
其他工作流代码暂时不需要修改。
然后继续测试:
Map<String, Object> initialState = Map.of(
"question", "请查询学号9999的平均成绩",
"answer", ""
);
预期过程:
LangGraph4j
↓
AiNode
↓
Ollama
↓
模型选择 getAverageScore(9999)
↓
GradeTools
↓
PermissionService
↓
PERMISSION_DENIED
↓
LLM 根据工具结果回答
↓
AnswerNode
↓
END
注意:在这个演示中,UserContext 是程序创建并注入的固定身份。真实 Web 项目中必须按请求或用户会话安全地绑定身份,不能将某个用户的身份放入所有用户共享的单例工具对象。
另外,模型的自然语言回答不能代替后端授权结果。即使模型错误地说"查询成功",只要权限检查阻止了 GradeService 执行,数据访问就仍然被拦截了。
八、本关思考题
观察:
if (!permissionService.canViewGrade(
userContext.userId(),
studentId
)) {
return ToolResult.fail(
"PERMISSION_DENIED",
"无权查看该学生的成绩"
);
}
假设:
当前用户:辅导员A
userId:1
studentId:9999
而:
permissions = Map.of(
1L, Set.of(1001L, 1002L)
);
问题:为什么不会执行后面的 GradeService.getAverageScore(9999)?
A. 因为 LLM 会自动停止调用工具。
B. 因为 return 直接结束 GradeTools.getAverageScore(),后面的查询代码不会执行。
C. 因为 LangGraph4j 自动检测到用户没有权限。
本关验收要求: 先运行 PermissionDemo,确认有权限和无权限两种情况;再运行 IntegrationDemo,确认真实 LLM 工具调用也无法绕过权限校验。
完成后进入 第九关 9C:Agent 工具调用日志、审计与可观测性。
第九关 9C:Agent 工具调用日志、审计与可观测性
我们直接进入 9C。
这一关不需要先解决 Ollama 超时问题。我们先使用普通 Java 方法调用,验证日志和审计功能,再将它们接入 LangChain4j + LangGraph4j。
目前学习进度:
| 阶段 | 内容 | 状态 |
|---|---|---|
| 9A | 工具异常处理 | ✅ 学生不存在场景已真实验证 |
| 9B | 工具权限控制 | 🟡 代码已学习,真实 LLM 验收尚未完成 |
| 9C | 工具日志与可观测性 | 🚀 现在开始 |
一、本关目标
假设你的 Agent 接收了一个请求:
查询学号 1001 的平均成绩。
你之前的执行过程是:
用户提问
↓
LangGraph4j
↓
AiNode
↓
LangChain4j
↓
LLM
↓
GradeTools
↓
PermissionService
↓
GradeService
↓
返回结果
现在我们希望记录:
谁发起的请求?
调用了哪个工具?
查询了哪个学生?
权限是否通过?
工具执行成功还是失败?
执行耗时多少?
最终形成类似这样的审计记录:
traceId: 8b6a...
userId: 1
toolName: getAverageScore
studentId: 1001
resultCode: SUCCESS
durationMs: 12
这就是本关的核心。
1. 日志、审计、可观测性有什么区别?
| 概念 | 主要作用 | 示例 |
|---|---|---|
| Log 日志 | 记录程序运行过程 | 工具开始执行 |
| Audit 审计 | 记录谁做了什么、结果如何 | 辅导员 A 查询学生 1001 |
| Metrics 指标 | 统计系统运行情况 | 工具成功率、平均耗时 |
| Trace 链路追踪 | 串联一次请求经过的组件 | AiNode → GradeTools → GradeService |
你可以这样记忆:
日志看过程,审计看行为,指标看整体,Trace 看调用链。
这一关先实现前三者的基础功能,以及简单的 traceId 关联。
真正的分布式链路追踪,例如 OpenTelemetry,我们后面再学习。
二、项目结构
继续使用你的独立练习项目:
langgraph4j-demo
└── src/main/java
└── com/example/langgraph/integration
├── AgentState.java
├── AiNode.java
├── AnswerNode.java
├── GradeAssistant.java
├── GradeService.java
├── GradeTools.java
├── PermissionService.java
├── UserContext.java
├── ToolResult.java
│
├── ToolAuditRecord.java 新增
├── ToolAuditService.java 新增
├── ToolMetrics.java 新增
└── AuditDemo.java 新增
本关继续使用:
- Java 21
- Maven
- LangChain4j 1.22.0
- LangGraph4j 1.9.3
不修改现有学校学工系统。
三、第一步:创建审计记录
新建 ToolAuditRecord.java。
package com.example.langgraph.integration;
import java.time.Instant;
public record ToolAuditRecord(
String traceId,
Long userId,
String toolName,
Long studentId,
String resultCode,
long durationMs,
Instant timestamp
) {
}
代码解释
这里继续使用你已经学过的 record。
例如:
ToolAuditRecord record = new ToolAuditRecord(
"trace-001",
1L,
"getAverageScore",
1001L,
"SUCCESS",
15L,
Instant.now()
);
读取:
record.traceId();
record.userId();
record.resultCode();
其中有一个新知识点:
Instant.now()
它返回当前时间点,适合记录审计事件发生的时间。
注意:
审计记录中的时间应该由服务端生成,而不是由 LLM 提供。
四、第二步:创建审计服务
新建 ToolAuditService.java。
package com.example.langgraph.integration;
import java.util.ArrayList;
import java.util.List;
public class ToolAuditService {
private final List<ToolAuditRecord> records =
new ArrayList<>();
public synchronized void save(ToolAuditRecord record) {
records.add(record);
System.out.println(
"[AUDIT] " + record
);
}
public synchronized List<ToolAuditRecord> findAll() {
return List.copyOf(records);
}
}
重点解释
① 为什么使用 List?
private final List<ToolAuditRecord> records =
new ArrayList<>();
因为一次 Agent 执行可能调用多个工具。
例如:
查询成绩
↓
查询考勤
↓
查询综测
每调用一次工具,就可以生成一条审计记录。
② 为什么使用 synchronized?
public synchronized void save(...)
因为多个 Agent 请求可能同时执行。
如果多个线程同时修改普通 ArrayList,可能产生线程安全问题。
这里使用 synchronized,保证同一时间只有一个线程进入该对象的同步方法。
但需要注意:
这是教学版内存审计服务,不是生产级审计系统。
真实项目应将审计记录持久化到数据库或可靠的日志系统,并考虑并发、故障恢复、访问控制和数据保留策略。
五、第三步:创建指标统计类
新建 ToolMetrics.java。
package com.example.langgraph.integration;
import java.util.concurrent.atomic.AtomicLong;
public class ToolMetrics {
private final AtomicLong totalCalls =
new AtomicLong();
private final AtomicLong successCalls =
new AtomicLong();
private final AtomicLong failedCalls =
new AtomicLong();
private final AtomicLong totalDurationMs =
new AtomicLong();
public void record(
boolean success,
long durationMs
) {
totalCalls.incrementAndGet();
if (success) {
successCalls.incrementAndGet();
} else {
failedCalls.incrementAndGet();
}
totalDurationMs.addAndGet(durationMs);
}
public void printReport() {
long total = totalCalls.get();
double averageDuration =
total == 0
? 0
: (double) totalDurationMs.get() / total;
System.out.println("===== 工具指标 =====");
System.out.println(
"总调用次数:" + total
);
System.out.println(
"成功次数:" + successCalls.get()
);
System.out.println(
"失败次数:" + failedCalls.get()
);
System.out.println(
"平均耗时:" + averageDuration + "ms"
);
}
}
重点:AtomicLong 是什么?
你之前可能写过:
long count = 0;
count++;
但在多线程环境下:
count++;
不是一个不可分割的操作。
它实际上包含:
读取 count
↓
加 1
↓
写回 count
多个线程同时执行,可能导致计数丢失。
所以这里使用:
AtomicLong
例如:
totalCalls.incrementAndGet();
它可以原子地完成自增。
不过,本例中多个计数器之间并不是一个原子事务,因此并发读取时,短时间内可能出现总次数与成功、失败次数之和不完全一致的快照。
后续学习 Micrometer 时,我们会使用更成熟的指标采集方案。
六、第四步:改造 GradeTools
现在把审计功能加入工具调用。
为了不影响你之前的代码,我们保留原有的权限检查和异常处理逻辑。
将 GradeTools.java 修改为:
package com.example.langgraph.integration;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import java.time.Instant;
import java.util.UUID;
public class GradeTools {
private final GradeService gradeService;
private final PermissionService permissionService;
private final UserContext userContext;
private final ToolAuditService auditService;
private final ToolMetrics metrics;
public GradeTools(
GradeService gradeService,
PermissionService permissionService,
UserContext userContext,
ToolAuditService auditService,
ToolMetrics metrics
) {
this.gradeService = gradeService;
this.permissionService = permissionService;
this.userContext = userContext;
this.auditService = auditService;
this.metrics = metrics;
}
@Tool("根据学生学号查询平均成绩")
public ToolResult<Double> getAverageScore(
@P(name = "studentId", value = "学生学号")
Long studentId
) {
String traceId = UUID.randomUUID().toString();
long start = System.nanoTime();
String resultCode = "INTERNAL_ERROR";
try {
System.out.println(
"[TRACE] " + traceId
);
System.out.println(
"[TOOL] 开始执行 getAverageScore"
);
// 1. 参数校验
if (studentId == null || studentId <= 0) {
resultCode = "INVALID_ARGUMENT";
return ToolResult.fail(
resultCode,
"学生学号不合法"
);
}
// 2. 权限校验
if (!permissionService.canViewGrade(
userContext.userId(),
studentId
)) {
resultCode = "PERMISSION_DENIED";
return ToolResult.fail(
resultCode,
"无权查看该学生的成绩"
);
}
// 3. 执行业务
double score =
gradeService.getAverageScore(studentId);
resultCode = "SUCCESS";
return ToolResult.ok(score);
} catch (StudentNotFoundException e) {
resultCode = "STUDENT_NOT_FOUND";
return ToolResult.fail(
resultCode,
"学生不存在"
);
} catch (Exception e) {
resultCode = "INTERNAL_ERROR";
System.err.println(
"[TOOL] 内部异常:" +
e.getClass().getSimpleName()
);
return ToolResult.fail(
resultCode,
"成绩查询服务暂时不可用"
);
} finally {
// 4. 无论成功还是失败,都执行
long durationMs =
(System.nanoTime() - start) / 1_000_000;
// 5. 记录审计
auditService.save(
new ToolAuditRecord(
traceId,
userContext.userId(),
"getAverageScore",
studentId,
resultCode,
durationMs,
Instant.now()
)
);
// 6. 更新指标
metrics.record(
"SUCCESS".equals(resultCode),
durationMs
);
}
}
}
七、最重要的知识点:finally
观察:
try {
return ToolResult.ok(score);
} catch (Exception e) {
return ToolResult.fail(
"INTERNAL_ERROR",
"查询失败"
);
} finally {
System.out.println("记录审计日志");
}
你可能会产生一个疑问:
既然 try 中已经执行了 return,为什么 finally 还能执行?
因为 Java 的执行机制是:
进入 try
↓
执行 return
↓
先执行 finally
↓
方法真正返回
因此:
finally
非常适合执行:
- 释放资源
- 记录执行耗时
- 记录审计事件
- 清理上下文
不过要注意两个问题。
第一,finally 不代表绝对可靠。进程被强制终止等情况仍可能导致审计记录丢失。
第二,如果 finally 自身抛出异常,就可能覆盖原本准备返回的结果。
所以生产环境中,审计服务自身的异常处理也需要单独设计。
八、第五步:创建 AuditDemo
现在先不调用 Ollama。
新建:
AuditDemo.java
package com.example.langgraph.integration;
public class AuditDemo {
public static void main(String[] args) {
// 1. 创建业务服务
GradeService gradeService =
new GradeService();
// 2. 创建权限服务
PermissionService permissionService =
new PermissionService();
// 3. 模拟已登录用户
UserContext userContext =
new UserContext(1L, "辅导员A");
// 4. 创建审计服务
ToolAuditService auditService =
new ToolAuditService();
// 5. 创建指标服务
ToolMetrics metrics =
new ToolMetrics();
// 6. 创建工具
GradeTools tools = new GradeTools(
gradeService,
permissionService,
userContext,
auditService,
metrics
);
System.out.println("===== 测试1:有权限 =====");
System.out.println(
tools.getAverageScore(1001L)
);
System.out.println("===== 测试2:无权限 =====");
System.out.println(
tools.getAverageScore(9999L)
);
System.out.println("===== 测试3:非法参数 =====");
System.out.println(
tools.getAverageScore(-1L)
);
// 输出审计记录
System.out.println("===== 审计记录 =====");
auditService.findAll()
.forEach(System.out::println);
// 输出指标
metrics.printReport();
}
}
模拟预期输出
以下输出为模拟,不是你电脑的真实运行结果。
===== 测试1:有权限 =====
[TRACE] 7c2a...
[TOOL] 开始执行 getAverageScore
[GradeService] 查询学生:1001
[AUDIT] ToolAuditRecord[
traceId=7c2a...,
userId=1,
toolName=getAverageScore,
studentId=1001,
resultCode=SUCCESS,
durationMs=8,
timestamp=...
]
ToolResult[
success=true,
code=SUCCESS,
message=查询成功,
data=86.5
]
===== 测试2:无权限 =====
[TRACE] 6b4d...
[TOOL] 开始执行 getAverageScore
[AUDIT] ToolAuditRecord[
traceId=6b4d...,
userId=1,
toolName=getAverageScore,
studentId=9999,
resultCode=PERMISSION_DENIED,
durationMs=1,
timestamp=...
]
ToolResult[
success=false,
code=PERMISSION_DENIED,
message=无权查看该学生的成绩,
data=null
]
===== 测试3:非法参数 =====
[TRACE] 2d91...
[TOOL] 开始执行 getAverageScore
[AUDIT] ToolAuditRecord[
traceId=2d91...,
userId=1,
toolName=getAverageScore,
studentId=-1,
resultCode=INVALID_ARGUMENT,
durationMs=0,
timestamp=...
]
ToolResult[
success=false,
code=INVALID_ARGUMENT,
message=学生学号不合法,
data=null
]
===== 工具指标 =====
总调用次数:3
成功次数:1
失败次数:2
平均耗时:3.0ms
UUID、时间戳和耗时每次运行都会不同。
九、第六步:接入 LangGraph4j
等 AuditDemo 测试通过后,我们再修改第八关的 IntegrationDemo。
原来的代码:
GradeTools gradeTools = new GradeTools(
gradeService,
permissionService,
userContext
);
现在改为:
ToolAuditService auditService =
new ToolAuditService();
ToolMetrics metrics =
new ToolMetrics();
GradeTools gradeTools = new GradeTools(
gradeService,
permissionService,
userContext,
auditService,
metrics
);
然后在工作流执行完成后输出:
metrics.printReport();
这样就实现了:
LangGraph4j
↓
AiNode
↓
LangChain4j
↓
Ollama
↓
GradeTools
├── PermissionService
├── GradeService
├── ToolAuditService
└── ToolMetrics
↓
AnswerNode
↓
END
需要注意,当前代码中的 traceId 是每次工具调用生成的标识,并不是贯穿整个 Agent 请求的完整 Trace。
例如,一个 Agent 请求连续调用三个工具,会生成三个不同的 UUID。
后续我们会学习如何为一次请求创建统一的 traceId,再为每次工具调用创建独立的 spanId。
这才更接近真正的分布式链路追踪。
十、真实项目应该如何设计?
你是 Java 后端开发方向,因此需要知道这套教学代码与真实项目之间的区别。
| 教学版本 | 真实项目建议 |
|---|---|
System.out.println() |
SLF4J + Logback |
ArrayList 保存审计 |
MySQL 审计表或可靠日志系统 |
AtomicLong 统计指标 |
Micrometer |
| 随机 UUID | 请求级 Trace ID + Span ID |
| 固定 UserContext | 服务端认证上下文 |
| 模拟 PermissionService | RBAC + 数据范围权限 |
| 控制台查看日志 | 日志检索与监控平台 |
真实项目中还需要考虑:
1. 审计日志不能包含敏感数据。
例如,查询成绩可以记录学生标识和结果代码,但不应默认把身份证号、联系方式、完整成绩内容或模型对话全文写入普通日志。
2. 审计记录必须防止被普通用户修改。
3. 不能因为模型认为自己有权限,就跳过 Java 授权校验。
4. 审计服务失败时,需要明确处理策略。
对于高风险写操作,可能需要采用持久化审计或事务性机制,不能简单地忽略审计失败。
5. 指标统计需要区分业务失败和系统失败。
例如:
SUCCESS
PERMISSION_DENIED
STUDENT_NOT_FOUND
INVALID_ARGUMENT
INTERNAL_ERROR
这些结果不应该全部混为同一种错误。
十一、本关思考题
观察下面的代码:
try {
return ToolResult.ok(86.5);
} finally {
auditService.save(record);
}
假设 try 中已经执行:
return ToolResult.ok(86.5);
问题:finally 中的 auditService.save(record) 是否还会执行?
A. 不会,因为 return 已经结束方法。
B. 会,Java 会先执行 finally,再完成方法返回。
C. 只有发生异常时才会执行 finally。
十二、第九关 9C 验收标准
你现在需要运行:
AuditDemo.main()
检查四件事:
- 学号
1001查询成功,审计结果为SUCCESS。 - 学号
9999被权限拒绝,审计结果为PERMISSION_DENIED。 - 学号
-1被参数校验拒绝,审计结果为INVALID_ARGUMENT。 - 指标显示总调用次数为
3、成功1、失败2。
本关暂时不依赖 Ollama,因此即使之前的模型请求仍然超时,也不影响你完成 9C 的核心练习。
完成后,我们将进入第十关:Agent 工作流中的重试、超时与失败恢复,把你之前学习过的 LangGraph4j 循环、Checkpoint 和现在的工具调用结合起来。
第十关:LangGraph4j Agent 的重试、超时与失败恢复
10.1 为什么 Agent 需要失败恢复?
你之前遇到过一个真实问题:
LangGraph4j
↓
AiNode
↓
LangChain4j
↓
Ollama
↓
TimeoutException: request timed out
如果 Agent 只要遇到一次超时就直接失败,用户体验会很差。
但是,如果无限重试,也可能导致:
- 请求长时间不结束。
- Ollama 负载越来越高。
- 重复执行工具。
- 数据库被重复修改。
所以我们需要设计一个有限重试机制。
10.2 第十关分为三个阶段
| 阶段 | 学习内容 | 核心目标 |
|---|---|---|
| 10A | Conditional Edge + 有限重试 | 失败后最多重试指定次数 |
| 10B | 超时与异常分类 | 只重试可恢复的错误 |
| 10C | Checkpoint + 失败恢复 | 保存执行状态,支持恢复 |
我们先学习 10A:有限重试。
这次继续使用你熟悉的 Java 21 + LangGraph4j 1.9.3,不依赖 Ollama,先把工作流逻辑跑通。
二、10A:有限重试的工作流
假设 Agent 调用成绩服务。
第一次失败:
查询失败:服务暂时不可用
第二次成功:
查询成功:86.5
我们希望:
START
↓
QueryNode
↓
判断是否成功
│
├── 成功 ─────────────→ SuccessNode → END
│
└── 失败
↓
是否还能重试?
│
├── 是 → QueryNode
│
└── 否 → FailNode → END
这里会用到你之前学习过的:
addConditionalEdges()
以及:
edge_async()
核心思想是:
Node 负责执行操作,State 负责保存结果,Conditional Edge 负责决定下一步。
三、第一步:创建 RetryState
新建包:
com.example.langgraph.retry
创建 RetryState.java:
package com.example.langgraph.retry;
import org.bsc.langgraph4j.state.AgentState;
import java.util.Map;
public class RetryState extends AgentState {
public RetryState(Map<String, Object> data) {
super(data);
}
public int retryCount() {
return this.<Integer>value("retryCount")
.orElse(0);
}
public int maxRetries() {
return this.<Integer>value("maxRetries")
.orElse(2);
}
public boolean success() {
return this.<Boolean>value("success")
.orElse(false);
}
public String result() {
return this.<String>value("result")
.orElse("");
}
}
字段解释
| 字段 | 含义 |
|---|---|
retryCount |
已经重试的次数 |
maxRetries |
最多允许重试几次 |
success |
当前操作是否成功 |
result |
操作结果 |
注意:
最多重试 2 次,不代表总共执行 2 次。
如果第一次执行失败,最多再重试两次,那么:
首次执行 + 2 次重试 = 最多 3 次执行
四、第二步:创建模拟查询节点
新建 QueryNode.java:
package com.example.langgraph.retry;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
public class QueryNode implements NodeAction<RetryState> {
// 模拟服务端实际收到的调用次数
private final AtomicInteger attempts = new AtomicInteger();
@Override
public Map<String, Object> apply(RetryState state) {
int attempt = attempts.incrementAndGet();
System.out.println(
"[QueryNode] 第 " + attempt + " 次执行"
);
// 模拟第一次失败,第二次成功
if (attempt == 1) {
System.out.println(
"[QueryNode] 模拟服务暂时不可用"
);
return Map.of(
"success", false,
"retryCount", state.retryCount() + 1,
"result", "服务暂时不可用"
);
}
System.out.println(
"[QueryNode] 查询成功"
);
return Map.of(
"success", true,
"result", "平均成绩:86.5"
);
}
}
这里使用:
AtomicInteger attempts
是为了模拟一个外部服务:
第一次调用失败
第二次调用成功
需要注意,attempts 是节点对象中的演示计数器,并不属于 LangGraph4j State。
因此它不会自动进入 Checkpoint,也不适合直接用作真实业务的持久化重试次数。
后面学习 10C 时,我们会专门处理这个问题。
五、第三步:创建成功和失败节点
SuccessNode.java
package com.example.langgraph.retry;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class SuccessNode implements NodeAction<RetryState> {
@Override
public Map<String, Object> apply(RetryState state) {
System.out.println(
"[SuccessNode] " + state.result()
);
return Map.of();
}
}
FailNode.java
package com.example.langgraph.retry;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class FailNode implements NodeAction<RetryState> {
@Override
public Map<String, Object> apply(RetryState state) {
System.out.println(
"[FailNode] 已达到重试上限:" + state.result()
);
return Map.of();
}
}
六、第四步:创建 RetryDemo
这是本关最重要的代码。
package com.example.langgraph.retry;
import org.bsc.langgraph4j.StateGraph;
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;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
public class RetryDemo {
public static void main(String[] args) throws Exception {
var graph = new StateGraph<>(
Map.of(),
RetryState::new
);
graph.addNode(
"query",
node_async(new QueryNode())
);
graph.addNode(
"success",
node_async(new SuccessNode())
);
graph.addNode(
"fail",
node_async(new FailNode())
);
graph.addEdge(START, "query");
graph.addConditionalEdges(
"query",
edge_async(state -> {
if (state.success()) {
return "success";
}
if (state.retryCount() <= state.maxRetries()) {
return "retry";
}
return "fail";
}),
Map.of(
"success", "success",
"retry", "query",
"fail", "fail"
)
);
graph.addEdge("success", END);
graph.addEdge("fail", END);
var compiled = graph.compile();
Map<String, Object> initialState = Map.of(
"retryCount", 0,
"maxRetries", 2,
"success", false,
"result", ""
);
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
}
}
重点解释 Conditional Edge
if (state.success()) {
return "success";
}
如果成功,直接结束重试。
if (state.retryCount() <= state.maxRetries()) {
return "retry";
}
如果失败,并且仍有重试机会,则重新执行查询节点。
return "fail";
如果重试次数已经超过上限,就进入失败节点。
这里采用的计数约定是:retryCount 表示失败后已经消耗的尝试次数。它能让本例完成有限重试,但在真实项目中建议将"总尝试次数"和"额外重试次数"明确区分,避免边界混淆。
七、模拟运行结果
以下为模拟预期输出,不代表你本机已经验证。
[QueryNode] 第 1 次执行
[QueryNode] 模拟服务暂时不可用
NodeOutput{ node=query, ... }
[QueryNode] 第 2 次执行
[QueryNode] 查询成功
NodeOutput{ node=query, ... }
[SuccessNode] 平均成绩:86.5
NodeOutput{ node=success, ... }
NodeOutput{ node=__END__, ... }
进程已结束,退出代码为 0
这里的关键是:
第一次失败
↓
Conditional Edge 判断可以重试
↓
再次执行 QueryNode
↓
第二次成功
↓
SuccessNode
↓
END
八、本关思考题
假设:
retryCount = 2;
maxRetries = 2;
success = false;
执行:
if (state.retryCount() <= state.maxRetries()) {
return "retry";
}
问题:会进入哪个分支?
A. fail,因为已经达到最大重试次数。
B. retry,因为 2 <= 2 为 true。
C. success,因为达到最大重试次数代表成功。
注意:这道题考的是当前代码实际会怎么执行,不代表该边界判断一定是最合理的业务设计。
九、接下来怎么做?
你现在只需要:
- 创建
RetryState、QueryNode、SuccessNode、FailNode和RetryDemo。 - 在 IDEA 中运行
RetryDemo.main()。 - 观察是否第一次失败、第二次成功。
- 回答上面的选择题。
完成后,我们进入 10B:区分超时、业务失败和权限拒绝,实现只对可恢复错误进行有限重试。
这一阶段会直接结合你之前真实遇到的 Ollama TimeoutException,让 Agent 的错误处理更接近实际后端项目。
第十关 10B:Agent 异常分类与选择性重试
二、为什么不能所有错误都重试?
假设 Agent 调用成绩查询工具,可能遇到:
| 错误 | 是否重试 | 原因 |
|---|---|---|
TIMEOUT |
✅ 可以有限重试 | 可能是暂时性故障 |
SERVICE_UNAVAILABLE |
✅ 可以有限重试 | 服务可能恢复 |
PERMISSION_DENIED |
❌ 不重试 | 重试不会获得权限 |
STUDENT_NOT_FOUND |
❌ 不重试 | 重试不会让学生突然存在 |
INVALID_ARGUMENT |
❌ 不直接重试 | 应先修正参数 |
SUCCESS |
❌ 不重试 | 已成功 |
这里的关键思想是:
不是失败就重试,而是先判断错误是否具有可恢复性。
例如,你之前遇到的:
dev.langchain4j.exception.TimeoutException:
request timed out
属于可能可以重试的错误。
但需要注意:超时不一定意味着服务端没有执行操作。对于会修改数据的工具,必须先考虑幂等性,不能盲目重试。
三、10B 工作流设计
START
↓
QueryNode
↓
检查结果
│
├── SUCCESS
│ ↓
│ SuccessNode → END
│
├── 可重试错误
│ ↓
│ 判断剩余次数
│ ├── 有 → QueryNode
│ └── 无 → FailNode → END
│
└── 不可重试错误
↓
FailNode → END
我们在 10A 的基础上修改。
第一步:修改 RetryState
增加:
public int attemptCount() {
return this.<Integer>value("attemptCount")
.orElse(0);
}
public int maxAttempts() {
return this.<Integer>value("maxAttempts")
.orElse(3);
}
public String resultCode() {
return this.<String>value("resultCode")
.orElse("");
}
其中:
attemptCount:已经执行了多少次
maxAttempts:最多允许执行多少次
例如:
attemptCount = 2
maxAttempts = 3
表示已经执行两次,还允许再执行一次。
第二步:创建 RetryPolicy
新建:
RetryPolicy.java
package com.example.langgraph.retry;
import java.util.Set;
public class RetryPolicy {
private static final Set<String> RETRYABLE_CODES =
Set.of(
"TIMEOUT",
"SERVICE_UNAVAILABLE"
);
public boolean shouldRetry(RetryState state) {
boolean retryable =
RETRYABLE_CODES.contains(state.resultCode());
boolean hasAttempts =
state.attemptCount() < state.maxAttempts();
return retryable && hasAttempts;
}
}
重点解释
return retryable && hasAttempts;
表示两个条件必须同时满足:
- 错误属于可重试类型。
- 还没有达到最大执行次数。
例如:
resultCode = TIMEOUT
attemptCount = 2
maxAttempts = 3
结果:
retryable = true
hasAttempts = true
shouldRetry = true
但是:
resultCode = PERMISSION_DENIED
attemptCount = 1
maxAttempts = 3
结果:
retryable = false
hasAttempts = true
shouldRetry = false
虽然还有执行次数,但权限错误不能重试。
第三步:创建模拟异常查询节点
为了避免覆盖 10A 的代码,我们新建:
ErrorQueryNode.java
package com.example.langgraph.retry;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class ErrorQueryNode implements NodeAction<RetryState> {
@Override
public Map<String, Object> apply(RetryState state) {
int attempt = state.attemptCount() + 1;
System.out.println(
"[Query] 第 " + attempt + " 次执行"
);
// 第一次:模拟超时
if (attempt == 1) {
return Map.of(
"attemptCount", attempt,
"success", false,
"resultCode", "TIMEOUT",
"result", "模型请求超时"
);
}
// 第二次:模拟服务不可用
if (attempt == 2) {
return Map.of(
"attemptCount", attempt,
"success", false,
"resultCode", "SERVICE_UNAVAILABLE",
"result", "服务暂时不可用"
);
}
// 第三次:模拟成功
return Map.of(
"attemptCount", attempt,
"success", true,
"resultCode", "SUCCESS",
"result", "平均成绩:86.5"
);
}
}
这里和 10A 最大的区别是:
不再使用节点对象里的 AtomicInteger,而是直接从 State 中读取执行次数。
int attempt = state.attemptCount() + 1;
这样执行次数就属于工作流状态,更适合后续学习 Checkpoint。
不过,即使次数保存在 State 中,也不代表外部工具调用已经具备幂等性。Checkpoint 恢复后,仍可能重新执行某个节点。
第四步:创建 ErrorRetryDemo
package com.example.langgraph.retry;
import org.bsc.langgraph4j.StateGraph;
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;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
public class ErrorRetryDemo {
public static void main(String[] args) throws Exception {
RetryPolicy retryPolicy = new RetryPolicy();
var graph = new StateGraph<>(
Map.of(),
RetryState::new
);
graph.addNode(
"query",
node_async(new ErrorQueryNode())
);
graph.addNode(
"success",
node_async(new SuccessNode())
);
graph.addNode(
"fail",
node_async(new FailNode())
);
graph.addEdge(START, "query");
graph.addConditionalEdges(
"query",
edge_async(state -> {
if (state.success()) {
return "success";
}
if (retryPolicy.shouldRetry(state)) {
return "retry";
}
return "fail";
}),
Map.of(
"success", "success",
"retry", "query",
"fail", "fail"
)
);
graph.addEdge("success", END);
graph.addEdge("fail", END);
var compiled = graph.compile();
Map<String, Object> initialState = Map.of(
"attemptCount", 0,
"maxAttempts", 3,
"success", false,
"resultCode", "",
"result", ""
);
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
}
}
这里继续复用你之前创建的 SuccessNode 和 FailNode。
四、模拟预期输出
以下是模拟输出,不代表你本机已经验证。
[Query] 第 1 次执行
resultCode=TIMEOUT
attemptCount=1
[Query] 第 2 次执行
resultCode=SERVICE_UNAVAILABLE
attemptCount=2
[Query] 第 3 次执行
resultCode=SUCCESS
attemptCount=3
[SuccessNode] 平均成绩:86.5
NodeOutput{ node=__END__, ... }
进程已结束,退出代码为 0
整个过程:
TIMEOUT
↓
允许重试
↓
SERVICE_UNAVAILABLE
↓
允许重试
↓
SUCCESS
↓
停止重试
↓
END
五、再做一个权限拒绝测试
把 ErrorQueryNode 中第一次执行的结果改为:
if (attempt == 1) {
return Map.of(
"attemptCount", attempt,
"success", false,
"resultCode", "PERMISSION_DENIED",
"result", "无权查看该学生成绩"
);
}
重新运行。
预期:
[Query] 第 1 次执行
[FailNode] 已达到重试上限:无权查看该学生成绩
NodeOutput{ node=__END__, ... }
这里有一个小问题:
FailNode 当前打印的是:
"[FailNode] 已达到重试上限:"
但权限拒绝不是因为达到重试上限。
所以建议改成:
System.out.println(
"[FailNode] 工作流失败,错误码:"
+ state.resultCode()
+ ",原因:" + state.result()
);
这样能同时适用于:
- 达到最大重试次数。
- 权限不足。
- 参数错误。
- 学生不存在。
六、10B 思考题
假设:
resultCode = PERMISSION_DENIED
attemptCount = 1
maxAttempts = 3
执行:
boolean retryable =
RETRYABLE_CODES.contains(state.resultCode());
boolean hasAttempts =
state.attemptCount() < state.maxAttempts();
return retryable && hasAttempts;
问题:最终结果是什么?
A. true,因为还可以执行两次。
B. false,因为 PERMISSION_DENIED 不属于可重试错误。
C. true,因为所有工具异常都应该重试。
七、本关验收要求
这次需要验证两个场景:
| 测试 | 预期 |
|---|---|
| TIMEOUT → SERVICE_UNAVAILABLE → SUCCESS | 执行 3 次后成功 |
| PERMISSION_DENIED | 执行 1 次后失败,不重试 |
先运行 ErrorRetryDemo,确认两种结果,再回答选择题。
完成后进入 10C:Checkpoint + Agent 失败恢复,我们会把第十关的重试状态保存到 Checkpoint,学习如何从中断位置恢复工作流,而不是每次都从 START 重新执行。
第十关 10C:Checkpoint + Agent 失败恢复
现在我们开始学习真正的工作流恢复机制。
你之前已经学过:
MemorySaver
以及:
GraphInput.resume()
但那时候主要学习的是人工审批和中断恢复。
这次我们把它们与重试机制结合起来。
二、为什么已经有重试,还需要 Checkpoint?
假设 Agent 正在执行:
START
↓
QueryNode 第1次执行
↓
TIMEOUT
↓
QueryNode 第2次执行
↓
SERVICE_UNAVAILABLE
↓
程序意外中断
如果没有 Checkpoint,重新执行可能从头开始:
START
↓
attemptCount = 0
这样之前的状态就丢失了。
我们希望恢复时能够读取:
attemptCount = 2
resultCode = SERVICE_UNAVAILABLE
maxAttempts = 3
然后继续:
QueryNode 第3次执行
↓
SUCCESS
↓
END
这就是 Checkpoint 的作用。
Checkpoint 保存工作流执行状态,RetryPolicy 决定是否继续重试。
但需要注意:
Checkpoint 不等于数据库事务,也不保证外部工具只执行一次。
对于修改数据库、发送消息、创建订单等操作,仍然需要幂等性保护。
三、本关工作流设计
我们在 query 节点执行前设置一个中断点。
START
↓
QueryNode
↓
TIMEOUT
↓
Conditional Edge → retry
↓
【Checkpoint:即将执行 QueryNode】
↓
程序暂停
↓
GraphInput.resume()
↓
QueryNode
↓
SERVICE_UNAVAILABLE
↓
再次重试
↓
QueryNode
↓
SUCCESS
↓
END
为了方便观察,本关先演示主动暂停与恢复,不直接模拟进程崩溃。
原因是:
MemorySaver
只在内存中保存 Checkpoint。
如果 JVM 退出,内存中的检查点就会丢失。
因此,本关不能把 MemorySaver 的演示当作真正的跨进程故障恢复。
四、第一步:复用现有代码
继续使用:
com.example.langgraph.retry
复用以下类:
RetryState.java
RetryPolicy.java
ErrorQueryNode.java
SuccessNode.java
FailNode.java
请先把 ErrorQueryNode 恢复为:
第一次:TIMEOUT
第二次:SERVICE_UNAVAILABLE
第三次:SUCCESS
这样方便观察恢复过程。
五、第二步:创建 CheckpointRetryDemo
新建:
CheckpointRetryDemo.java
package com.example.langgraph.retry;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.GraphInput;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.CompileConfig;
import org.bsc.langgraph4j.checkpoint.savers.MemorySaver;
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;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
public class CheckpointRetryDemo {
public static void main(String[] args) throws Exception {
// 1. 创建 Checkpoint Saver
MemorySaver saver = new MemorySaver();
RetryPolicy retryPolicy = new RetryPolicy();
// 2. 创建工作流
var graph = new StateGraph<>(
Map.of(),
RetryState::new
);
graph.addNode(
"query",
node_async(new ErrorQueryNode())
);
graph.addNode(
"success",
node_async(new SuccessNode())
);
graph.addNode(
"fail",
node_async(new FailNode())
);
graph.addEdge(START, "query");
graph.addConditionalEdges(
"query",
edge_async(state -> {
if (state.success()) {
return "success";
}
if (retryPolicy.shouldRetry(state)) {
return "retry";
}
return "fail";
}),
Map.of(
"success", "success",
"retry", "query",
"fail", "fail"
)
);
graph.addEdge("success", END);
graph.addEdge("fail", END);
// 3. 编译时配置 Checkpoint
var compiled = graph.compile(
CompileConfig.builder()
.checkpointSaver(saver)
.interruptBefore("query")
.build()
);
// 4. 指定线程 ID
RunnableConfig config = RunnableConfig.builder()
.threadId("retry-checkpoint-001")
.build();
// 5. 初始状态
Map<String, Object> initialState = Map.of(
"attemptCount", 0,
"maxAttempts", 3,
"success", false,
"resultCode", "",
"result", ""
);
System.out.println("===== 第一次运行 =====");
for (var output : compiled.stream(
initialState,
config
)) {
System.out.println(output);
}
// 6. 查看检查点
var snapshot = compiled.getState(config);
System.out.println(
"当前状态:" + snapshot.state()
);
System.out.println(
"下一节点:" + snapshot.next()
);
// 7. 恢复执行
System.out.println("===== 恢复执行 =====");
for (var output : compiled.stream(
GraphInput.resume(),
config
)) {
System.out.println(output);
}
}
}
一个重要说明
这里设置:
.interruptBefore("query")
意味着工作流在第一次进入 query 前就会暂停。
所以首次运行不会先产生 TIMEOUT,而是先保存待执行节点。
此外,在包含循环的图中,中断点可能在再次到达 query 时触发。因此,恢复一次不一定会直接执行到 END。
我们需要根据真实运行结果,决定如何组织后续恢复步骤。
这也是本关要观察的重点。
六、重点讲解三个 API
1. MemorySaver
MemorySaver saver = new MemorySaver();
负责保存 Checkpoint。
可以理解为:
工作流执行
↓
保存当前 State
↓
保存待执行节点
↓
等待恢复
但它只保存在 JVM 内存中。
2. threadId
RunnableConfig config = RunnableConfig.builder()
.threadId("retry-checkpoint-001")
.build();
threadId 用于区分不同工作流执行上下文。
例如:
threadId = student-1001
threadId = student-1002
两条工作流就可以分别保存自己的检查点。
不过,在真实系统中,通常应使用独立的工作流实例 ID,而不是直接用学生学号作为 threadId,避免不同请求互相覆盖。
3. GraphInput.resume()
compiled.stream(
GraphInput.resume(),
config
);
这里不是重新传入:
initialState
而是告诉 LangGraph4j:
根据这个 threadId 对应的 Checkpoint,继续执行之前暂停的工作流。
因此,Checkpoint 恢复和重新从 START 执行是不同的。
七、模拟预期输出
以下为模拟输出,仅用于理解,不代表你本机的真实执行结果。
首次执行:
===== 第一次运行 =====
NodeOutput{ node=__START__, ... }
当前状态:
attemptCount=0
maxAttempts=3
下一节点:query
恢复后可能出现:
===== 恢复执行 =====
[Query] 第 1 次执行
resultCode=TIMEOUT
attemptCount=1
随后工作流再次到达 query 中断点。
由于当前配置会在每次进入 query 前检查中断条件,实际执行过程可能需要多次恢复。
本关不要求一次 resume() 就执行完整个循环。
八、本关思考题
假设:
threadId = retry-checkpoint-001
对应的 Checkpoint 保存了:
attemptCount = 2
resultCode = SERVICE_UNAVAILABLE
next = query
现在执行:
compiled.stream(
GraphInput.resume(),
config
);
问题:恢复后应该从哪里继续?
A. 从 START 重新执行,`attemptCount` 重置为 0。
B. 从 Checkpoint 保存的待执行节点 query 继续,保留已有的 attemptCount=2。
C. 自动跳过所有节点,直接执行 END。
九、10C 验收要求
本关先验证三个现象:
- 首次执行在
query前暂停。 compiled.getState(config)能读取保存的状态与待执行节点。GraphInput.resume()能从保存的位置继续,而不是重新从 START 执行。
如果这三点成立,就说明你已经掌握了 Checkpoint + Resume 的基础机制。
之后我们再进一步研究如何处理循环中的多次中断,以及如何使用持久化 Checkpoint 支持真正的进程重启恢复。
现在先运行 CheckpointRetryDemo,把实际输出发给我。
