一、剩余课程总规划

| 关卡 | 小节 | 核心学习内容 |
|---|---|---|
| 第 11 关:综合 Agent 实战 | 11A~11E,5 节 | 架构、State、Tool Calling、Graph 集成、验收 |
| 第 12 关:Agent 记忆与上下文 | 12A~12C,3 节 | 多轮对话、Memory、上下文管理 |
| 第 13 关:多 Agent 协作 | 13A~13D,4 节 | Supervisor、任务分发、多个 Agent 协作、验收 |
| 第 14 关:Agent 工程化 | 14A~14C,3 节 | Spring Boot API、持久化、可观测性与安全 |
| 第 15 关:综合项目验收 | 15A~15C,3 节 | 自动化测试、故障演练、架构复盘与简历表达 |
最终目标是独立实现一个Java 后端 Agent 服务,而不仅仅是会调用 LangGraph4j API。
二、第十一关:综合 Agent 实战
本关共 5 节:

三、11A:综合 Agent 架构设计与模块职责划分
1. 先明确我们要解决什么问题
假设用户输入:
查询学号 1001 的平均成绩,并分析是否需要重点关注。
普通 Java 后端可以直接调用:
gradeService.getAverageScore(1001L);
但是现在需求更复杂:
-
用户使用自然语言提问。
-
系统需要识别要查询哪个学生。
-
需要判断用户是否有权限。
-
需要查询成绩。
-
根据成绩 给出简单分析。
-
如果模型超时,需要有限重试。
-
每次工具调用需要审计记录。
如果把所有代码写在一个 Service 里面,后续扩展会很困难。
因此,我们要将业务分成不同职责。
2. 完整架构图
这是教学用的逻辑分层图,不代表每个请求都必须调用所有层。实际调用过程中,LangGraph4j 负责协调节点与状态流转。

关键职责
| 组件 | 负责什么 | 不应该负责什么 |
|---|---|---|
| LangGraph4j | 工作流、节点路由、状态管理 | 直接操作成绩数据库 |
| LangChain4j | 模型交互、AI Service、Tool Calling | 决定用户是否拥有权限 |
| GradeTools | 参数校验、权限校验、工具结果封装 | 复杂工作流编排 |
| GradeService | 成绩查询和业务计算 | LLM Prompt |
| RetryPolicy | 判断哪些异常允许重试 | 执行数据库查询 |
| ToolAuditService | 保存工具调用审计 | 生成 AI 回答 |
最重要的一句话 :LangGraph4j 管流程,LangChain4j 管 AI,Java Service 管业务。
权限 必须由可信 的 Java 后端判断,而不是让大模型自己决定。
3. 本次项目的目录结构
继续使用你已经有的独立项目:
E:\project\java-ai-langchain4j\langgraph4j-demo
我们新建 com.example.langgraph.agent 包,避免破坏前面已通过验收的 integration 和 retry 示例。
src/main/java/com/example/langgraph/
│
├── integration/ # 第8、9关:已有
│ ├── GradeService.java
│ ├── GradeTools.java
│ ├── GradeAssistant.java
│ ├── PermissionService.java
│ ├── ToolResult.java
│ ├── ToolAuditService.java
│ └── ...
│
├── retry/ # 第10关:已有
│ ├── RetryState.java
│ ├── RetryPolicy.java
│ ├── CheckpointRetryDemo.java
│ └── ...
│
└── agent/ # 第11关:新增
├── AgentDemo.java
├── state/
│ └── GradeAgentState.java
├── node/
│ ├── AiNode.java
│ ├── ResultNode.java
│ └── FailureNode.java
├── graph/
│ └── GradeAgentGraph.java
└── config/
└── AgentConfig.java
这里先确定职责,不要求马上把所有类写出来。
部分已有类可以复用,必要时做适配,但不要为了复用而把原来的教学示例改坏。
4. 设计 State:Agent 之间传递什么?
你之前已经学过:
Map<String, Object>
以及:
AgentState
这次我们设计一个真正服务于成绩查询的状态。
| 字段 | Java 类型 | 用途 |
|---|---|---|
question |
String | 用户原始问题 |
userId |
Long | 当前可信登录用户 ID |
answer |
String | AI 最终回答 |
success |
Boolean | 当前执行是否成功 |
resultCode |
String | 业务结果或错误码 |
attemptCount |
Integer | 执行尝试次数 |
maxAttempts |
Integer | 最大尝试次数 |
traceId |
String | 本次请求的追踪标识 |
例如:
Map<String, Object> initialState = Map.of(
"question", "查询学号1001的平均成绩",
"userId", 1L,
"answer", "",
"success", false,
"resultCode", "",
"attemptCount", 0,
"maxAttempts", 3,
"traceId", "demo-trace-001"
);
注意:
userId 必须由可信的登录上下文提供,不能直接相信用户在自然语言中说"我是管理员"。
另外,traceId 适合贯穿整个请求,而不是每个节点重新生成一个不相关的 ID。
5. 本关要掌握的三个架构原则
原则一:State 是数据载体,Node 是执行逻辑。
AgentState
↓
AiNode 读取 question
↓
调用 AI Service
↓
返回 answer 等更新字段
↓
State 合并更新
原则二:Tool 是 AI 与 Java 业务之间的受控入口。
LLM 生成工具调用
↓
GradeTools
↓
权限校验
↓
GradeService
↓
ToolResult
即使模型调用了工具,也不能绕过 Java 权限检查。
原则三:RetryPolicy 只处理允许重试的故障。
TIMEOUT → 可以有限重试
PERMISSION_DENIED → 直接失败
STUDENT_NOT_FOUND → 直接失败
对于真实 Tool Calling,还需要明确错误如何从 ToolResult 传递到 Graph State。这个问题我们将在 11D 处理,不能假设大模型的文本回答就等同于可靠的业务错误码。
6. 11A 验收题

本节属于 架构设计阶段 ,不需要运行代码。等 11A 确认后,我们进入 11B:编写 GradeAgentState 和业务节点,届时再进行真实 Java 编译、运行和验收。
按照你之前的开发分工习惯,如果使用 Codex 协助本节架构设计,可以用 Sol;从 11B 开始编码则切换 Luna。
选择 B,回答正确!架构设计阶段通过。
原因是:
-
LangChain4j 负责让 LLM 理解用户问题 、发起 Tool Calling。
-
GradeTools 负责参数校验 、权限校验 和调用业务服务。
-
GradeService 负责真正的成绩查询。
-
LangGraph4j 负责组织整个执行流程、管理 State 和异常路由。
即使 LLM 判断用户有权限,也不能代替 Java 后端的权限校验。
第十一关 11B:AgentState 与业务节点设计
第 11 关 · 共 5 节
当前 2 / 5
一、本节学习目标
上一节我们设计了整体架构,现在开始写代码。
本节只完成三件事:
-
创建
GradeAgentState,统一保存工作流状态。 -
创建
ResultNode,处理成功结果。 -
创建
FailureNode,处理失败结果。
暂时不接入 Ollama,也不调用真实 GradeTools。这样可以先把State 和 Node的关系彻底弄明白。
二、理解本节的工作流

三、第一步:创建 GradeAgentState
在 IDEA 中新建:
com.example.langgraph.agent.state.GradeAgentState
java
package com.example.langgraph.agent.state;
import org.bsc.langgraph4j.state.AgentState;
import java.util.Map;
public class GradeAgentState extends AgentState {
public GradeAgentState(Map<String, Object> data) {
super(data);
}
public String question() {
return this.<String>value("question").orElse("");
}
public Long userId() {
return this.<Long>value("userId").orElse(0L);
}
public String answer() {
return this.<String>value("answer").orElse("");
}
public boolean success() {
return this.<Boolean>value("success").orElse(false);
}
public String resultCode() {
return this.<String>value("resultCode").orElse("");
}
public int attemptCount() {
return this.<Integer>value("attemptCount").orElse(0);
}
public int maxAttempts() {
return this.<Integer>value("maxAttempts").orElse(3);
}
public String traceId() {
return this.<String>value("traceId").orElse("");
}
}
为什么这样设计?
以前你写过:
Map<String, Object> initialState = Map.of(
"success", false,
"resultCode", ""
);
但如果每个节点都直接操作字符串键:
state.value("success");
state.value("resultCode");
业务代码会出现大量重复,而且容易写错字段名。
现在封装成:
state.success();
state.resultCode();
就更容易阅读和维护。
注意:GradeAgentState 的这些方法只是读取状态 ,并不是修改状态。
Node 更新状态仍然需要返回:
Map<String, Object>
四、第二步:创建 ResultNode
新建:
com.example.langgraph.agent.node.ResultNode
package com.example.langgraph.agent.node;
import com.example.langgraph.agent.state.GradeAgentState;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class ResultNode implements NodeAction<GradeAgentState> {
@Override
public Map<String, Object> apply(GradeAgentState state) {
System.out.println("===== Agent 执行成功 =====");
System.out.println("TraceId:" + state.traceId());
System.out.println("问题:" + state.question());
System.out.println("回答:" + state.answer());
return Map.of();
}
}
为什么返回 Map.of()?
因为 ResultNode 只负责读取并展示结果,不需要修改 State。
return Map.of();
表示:
当前节点没有新的状态更新。
它不代表清空 State。
五、第三步:创建 FailureNode
新建:
com.example.langgraph.agent.node.FailureNode
package com.example.langgraph.agent.node;
import com.example.langgraph.agent.state.GradeAgentState;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class FailureNode implements NodeAction<GradeAgentState> {
@Override
public Map<String, Object> apply(GradeAgentState state) {
System.out.println("===== Agent 执行失败 =====");
System.out.println("TraceId:" + state.traceId());
System.out.println("错误码:" + state.resultCode());
System.out.println("失败原因:" + state.answer());
return Map.of();
}
}
这里的 answer 暂时兼作失败时展示给用户的消息。
后续工程化时,可以拆成 errorMessage 和 answer,让业务错误与自然语言回答分开。
六、第四步:创建 11B 的独立测试入口
这一步非常重要。
我们不必等 11C 接入模型,才验证刚写好的代码。
新建:
com.example.langgraph.agent.AgentStateDemo
package com.example.langgraph.agent;
import com.example.langgraph.agent.state.GradeAgentState;
import com.example.langgraph.agent.node.ResultNode;
import com.example.langgraph.agent.node.FailureNode;
import java.util.Map;
public class AgentStateDemo {
public static void main(String[] args) throws Exception {
// 1. 模拟成功状态
GradeAgentState successState = new GradeAgentState(
Map.of(
"question", "查询学号1001的平均成绩",
"userId", 1L,
"answer", "学号1001的平均成绩是86.5分",
"success", true,
"resultCode", "SUCCESS",
"attemptCount", 1,
"maxAttempts", 3,
"traceId", "trace-001"
)
);
// 2. 执行成功节点
new ResultNode().apply(successState);
System.out.println();
// 3. 模拟失败状态
GradeAgentState failureState = new GradeAgentState(
Map.of(
"question", "查询学号9999的平均成绩",
"userId", 1L,
"answer", "无权查看该学生成绩",
"success", false,
"resultCode", "PERMISSION_DENIED",
"attemptCount", 1,
"maxAttempts", 3,
"traceId", "trace-002"
)
);
// 4. 执行失败节点
new FailureNode().apply(failureState);
}
}
这里没有创建 StateGraph,因为本节要先独立测试 State 与 Node。
等到 11D,我们再通过:
graph.addNode(...)
把这些节点真正接入 LangGraph4j 工作流。
七、模拟预期输出
以下是模拟结果,不是你本机已经运行通过的证明。
===== Agent 执行成功 =====
TraceId:trace-001
问题:查询学号1001的平均成绩
回答:学号1001的平均成绩是86.5分
===== Agent 执行失败 =====
TraceId:trace-002
错误码:PERMISSION_DENIED
失败原因:无权查看该学生成绩
进程已结束,退出代码为 0
八、11B 知识巩固
假设:
@Override
public Map<String, Object> apply(GradeAgentState state) {
System.out.println(state.answer());
return Map.of();
}

正确答案是:
B. 不产生新的状态更新,原来的 State 保持不变。

本节验收标准: AgentStateDemo 能分别输出成功和失败信息,且程序退出代码为 0。
你先在 IDEA 中创建这四个类并运行,把控制台结果发给我。确认通过后,我们再进入 11C:LangChain4j AI Service + Tool Calling,接入真实 Ollama 模型。
第十一关 11C:LangChain4j AI Service + Tool Calling
]
一、本节学习目标
我们要实现:
用户提问
↓
LangChain4j AI Service
↓
Ollama llama3.1:8b
↓
LLM 生成 Tool Call
↓
GradeTools.getAverageScore()
↓
权限校验
↓
GradeService
↓
ToolResult
↓
LLM 生成最终回答
注意:本节先单独验证 AI Service 和 Tool Calling,暂时不接入 LangGraph4j 工作流 。11D 再把它们放进 AiNode。
你之前在第 8、9 关已经写过相关代码,因此这次尽量复用,不重复造轮子。
二、第一步:检查已有的类
打开:
com.example.langgraph.integration
确认这些类还存在:
| 类 | 用途 |
|---|---|
GradeService |
模拟成绩查询 |
GradeTools |
提供 @Tool 方法 |
GradeAssistant |
AI Service 接口 |
PermissionService |
判断学生成绩访问权限 |
UserContext |
模拟当前登录用户 |
ToolAuditService |
记录工具审计 |
ToolMetrics |
统计工具调用次数 |
尤其检查 GradeTools 的构造方法是否仍然接受:
GradeTools(
GradeService gradeService,
PermissionService permissionService,
UserContext userContext,
ToolAuditService auditService,
ToolMetrics metrics
)
如果你的实际代码与这里不同,以你当前已经通过第 9 关验证的版本为准,不要直接覆盖。
三、第二步:检查 GradeAssistant
如果你已有的 GradeAssistant 可以正常使用,就不需要重新创建。
确认它大致如下:
package com.example.langgraph.integration;
import dev.langchain4j.service.SystemMessage;
public interface GradeAssistant {
@SystemMessage("""
你是一个学生成绩查询助手。
当用户询问具体学生的成绩时,
应使用提供的成绩查询工具。
工具返回权限不足时,
告知用户无权查看该学生成绩。
不得编造学生成绩。
""")
String chat(String userMessage);
}
这里的 @SystemMessage 是给模型的行为指导。
但它不是安全边界,真正的权限判断仍然在 GradeTools 中。
四、第三步:创建独立测试入口
新建:
com.example.langgraph.agent.AgentToolDemo
package com.example.langgraph.agent;
import com.example.langgraph.integration.*;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.service.AiServices;
import java.time.Duration;
public class AgentToolDemo {
public static void main(String[] args) {
// 1. 连接本地 Ollama
var chatModel = OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.temperature(0.0)
.timeout(Duration.ofSeconds(120))
.build();
// 2. 创建业务服务
var gradeService = new GradeService();
var permissionService = new PermissionService();
// 模拟当前登录用户
var userContext = new UserContext(1L, "test-user");
// 3. 创建审计和指标组件
var auditService = new ToolAuditService();
var metrics = new ToolMetrics();
// 4. 创建工具
var gradeTools = new GradeTools(
gradeService,
permissionService,
userContext,
auditService,
metrics
);
// 5. 创建 AI Service
GradeAssistant assistant = AiServices
.builder(GradeAssistant.class)
.chatModel(chatModel)
.tools(gradeTools)
.build();
// 6. 发起自然语言请求
System.out.println("===== Agent Tool Calling 测试 =====");
String answer = assistant.chat(
"请查询学号1001的平均成绩"
);
System.out.println("===== 最终回答 =====");
System.out.println(answer);
// 7. 打印指标
System.out.println("===== 工具调用指标 =====");
metrics.printReport();
// 8. 查看审计
System.out.println("===== 工具审计 =====");
auditService.findAll()
.forEach(System.out::println);
}
}
这次最重要的代码是:
GradeAssistant assistant = AiServices
.builder(GradeAssistant.class)
.chatModel(chatModel)
.tools(gradeTools)
.build();
可以这样理解:
| 方法 | 作用 |
|---|---|
builder(GradeAssistant.class) |
创建 AI Service |
.chatModel(chatModel) |
指定调用哪个模型 |
.tools(gradeTools) |
把 Java 工具提供给模型 |
.build() |
构建 AI Service 实例 |
然后:
assistant.chat("请查询学号1001的平均成绩");
LangChain4j 会负责处理模型交互和工具调用流程。
注意:模型是否真正选择调用工具,需要通过真实日志验证,不能只看最终回答。
五、第四步:运行前检查 Ollama
在 Windows 终端执行:
ollama list
确认存在:
llama3.1:8b
然后:
ollama ps
如果没有正在运行的模型,也不一定是异常。模型可能在第一次请求时才加载。
接着在 IDEA 中运行:
com.example.langgraph.agent.AgentToolDemo
第一次加载模型可能需要等待一段时间。
六、模拟预期结果
以下仅为模拟示例,不是本机真实验证结果:
===== Agent Tool Calling 测试 =====
[TOOL] 开始执行 getAverageScore
[GradeService] 查询学生:1001
[AUDIT] ToolAuditRecord[
userId=1,
toolName=getAverageScore,
studentId=1001,
resultCode=SUCCESS
]
===== 最终回答 =====
学号1001的平均成绩是86.5分。
===== 工具调用指标 =====
总调用次数:1
成功次数:1
失败次数:0
===== 工具审计 =====
ToolAuditRecord[...]
进程已结束,退出代码为 0
如果工具调用成功,但模型回答措辞不同,这是正常的。
如果指标显示 总调用次数:0,则说明工具没有被调用,需要检查模型行为和 AI Service 配置。
七、本节知识巩固
问题:assistant.chat() 和 gradeTools.getAverageScore() 的最大区别是什么?
A. 两者都是普通 Java 方法调用,没有区别。
B. assistant.chat() 通过 LLM 理解问题并可能发起工具调用;gradeTools.getAverageScore() 是直接执行 Java 工具方法。
C. assistant.chat() 会自动绕过权限校验。
提交答案
八、11C 验收标准
这次只验收一个场景:有权限的用户查询学号 1001 的成绩。
需要同时看到工具调用日志、审计记录、指标记录和最终回答,并且程序正常结束。
权限拒绝、模型异常以及 Graph State 集成,留到 11D 统一处理。
你先运行 AgentToolDemo,把控制台结果发给我。验证通过后,我们再进入 11D:LangGraph4j 工作流集成与异常处理。
第十一关 11D:LangGraph4j 工作流集成与异常处理
一、本节目标
前面已经完成:
-
11B:
GradeAgentState、ResultNode、FailureNode -
11C:
GradeAssistant、GradeTools、真实 Ollama 调用
现在要把它们组合起来:

本节先实现最小可运行的 Graph,只对模型调用抛出的异常进行分类。
有一个工程细节需要先明确:
你目前的 GradeAssistant.chat() 返回的是 String。当 GradeTools 返回 ToolResult 中的 PERMISSION_DENIED 时,AI Service 可能把它转化成自然语言回答。因此,我们不能仅凭回答文本可靠地判断业务错误码。
本节先保证成功路径和模型异常路径正确;11E 再完成权限拒绝等端到端测试。
二、第一步:创建 AiNode
新建:
com.example.langgraph.agent.node.AiNode
package com.example.langgraph.agent.node;
import com.example.langgraph.agent.state.GradeAgentState;
import com.example.langgraph.integration.GradeAssistant;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class AiNode implements NodeAction<GradeAgentState> {
private final GradeAssistant assistant;
public AiNode(GradeAssistant assistant) {
this.assistant = assistant;
}
@Override
public Map<String, Object> apply(GradeAgentState state) {
System.out.println("[AiNode] 开始处理问题");
System.out.println("[AiNode] TraceId:" + state.traceId());
try {
String answer = assistant.chat(state.question());
return Map.of(
"answer", answer,
"success", true,
"resultCode", "SUCCESS"
);
} catch (Exception e) {
System.out.println(
"[AiNode] 调用异常:" + e.getClass().getSimpleName()
);
return Map.of(
"answer", "模型服务调用失败",
"success", false,
"resultCode", "MODEL_ERROR"
);
}
}
}
这里:
return Map.of(
"answer", answer,
"success", true,
"resultCode", "SUCCESS"
);
是 NodeAction 返回的State 更新量。
但需要注意:这里的 SUCCESS 暂时表示 AI Service 正常返回,不代表业务查询一定成功。我们将在 11E 改进这个问题。
三、第二步:创建 GradeAgentGraph
新建:
com.example.langgraph.agent.graph.GradeAgentGraph
package com.example.langgraph.agent.graph;
import com.example.langgraph.agent.node.AiNode;
import com.example.langgraph.agent.node.ResultNode;
import com.example.langgraph.agent.node.FailureNode;
import com.example.langgraph.agent.state.GradeAgentState;
import com.example.langgraph.integration.GradeAssistant;
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 GradeAgentGraph {
public static StateGraph<GradeAgentState> create(
GradeAssistant assistant
) throws Exception {
var graph = new StateGraph<>(
Map.of(),
GradeAgentState::new
);
graph.addNode(
"ai",
node_async(new AiNode(assistant))
);
graph.addNode(
"result",
node_async(new ResultNode())
);
graph.addNode(
"failure",
node_async(new FailureNode())
);
graph.addEdge(START, "ai");
graph.addConditionalEdges(
"ai",
edge_async(state ->
state.success() ? "success" : "failure"
),
Map.of(
"success", "result",
"failure", "failure"
)
);
graph.addEdge("result", END);
graph.addEdge("failure", END);
return graph;
}
}
这段代码的核心是:
state.success() ? "success" : "failure"
它相当于:
if (state.success()) {
return "success";
} else {
return "failure";
}
然后通过映射:
Map.of(
"success", "result",
"failure", "failure"
)
决定进入哪个节点。
四、第三步:创建 AgentDemo
新建:
com.example.langgraph.agent.AgentDemo
package com.example.langgraph.agent;
import com.example.langgraph.agent.graph.GradeAgentGraph;
import com.example.langgraph.integration.*;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.service.AiServices;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
public class AgentDemo {
public static void main(String[] args) throws Exception {
var chatModel = OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.temperature(0.0)
.timeout(Duration.ofSeconds(120))
.build();
var gradeService = new GradeService();
var permissionService = new PermissionService();
var userContext = new UserContext(1L, "test-user");
var auditService = new ToolAuditService();
var metrics = new ToolMetrics();
var gradeTools = new GradeTools(
gradeService,
permissionService,
userContext,
auditService,
metrics
);
GradeAssistant assistant = AiServices
.builder(GradeAssistant.class)
.chatModel(chatModel)
.tools(gradeTools)
.build();
var graph = GradeAgentGraph.create(assistant);
var compiled = graph.compile();
Map<String, Object> initialState = Map.of(
"question", "查询学号1001的平均成绩",
"userId", 1L,
"answer", "",
"success", false,
"resultCode", "",
"attemptCount", 0,
"maxAttempts", 3,
"traceId", UUID.randomUUID().toString()
);
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
System.out.println("===== 工具指标 =====");
metrics.printReport();
System.out.println("===== 工具审计 =====");
auditService.findAll().forEach(System.out::println);
}
}
注意:当前 userId=1L 是演示值,并且与 UserContext 一致。正式集成时,两者必须来自同一个可信身份来源,不能让调用者任意填写。
五、模拟预期输出
以下为模拟示例,尚未在你的本机验证。
[AiNode] 开始处理问题
[AiNode] TraceId:...
[TOOL] 开始执行 getAverageScore
[GradeService] 查询学生:1001
[AUDIT] ... resultCode=SUCCESS
NodeOutput{ node=ai, state={
answer=学号1001的平均成绩是86.5分
success=true
resultCode=SUCCESS
}}
===== Agent 执行成功 =====
回答:学号1001的平均成绩是86.5分
NodeOutput{ node=result, ... }
NodeOutput{ node=__END__, ... }
===== 工具指标 =====
总调用次数:1
成功次数:1
失败次数:0
进程已结束,退出代码为 0
本节先不加入自动重试。因为我们之前学过的 RetryPolicy 需要基于可靠的错误码,而不是把所有 MODEL_ERROR 都当作可重试异常。
六、11D 思考题
假设 AiNode 返回:
return Map.of(
"success", false,
"resultCode", "MODEL_ERROR",
"answer", "模型服务调用失败"
);
Conditional Edge 会进入哪个节点?
A. result
B. failure
C. 自动重新执行 ai
提交答案
七、本节验收
先运行 AgentDemo,重点观察:
-
AiNode是否执行。 -
LLM 是否实际调用
GradeTools。 -
Conditional Edge是否进入ResultNode。 -
是否到达
END。 -
工具审计和指标是否正确。
把 IDEA 控制台输出发给我。 确认成功路径后,我们再进入 11E:综合测试与最终验收。
第十一关 11E:综合测试与最终验收
这节的目标不是再堆新功能,而是验证:Agent 不仅在查询成功时表现正确,在权限不足、学生不存在、模型异常时也能正确处理。
一、11E 验收计划
| 测试 | 场景 | 预期 |
|---|---|---|
| T1 | 有权限查询 1001 | SUCCESS,86.5 分 |
| T2 | 无权限查询 9999 | PERMISSION_DENIED,不能查询成绩 |
| T3 | 有权限但学生不存在 | STUDENT_NOT_FOUND |
| T4 | Ollama 服务不可用 | 进入 FailureNode |
| T5 | 审计与 TraceId | 请求和工具调用可关联 |
| T6 | 自动化测试 | 不依赖真实 LLM 也能验证 Graph 路由 |
T1 已经由你刚刚的真实日志验证通过。
二、先做 T2:权限拒绝测试
我们先只做这一步,不一次性修改全部代码。
在 AgentDemo.java 中,把:
"question", "查询学号1001的平均成绩",
修改为:
"question", "查询学号9999的平均成绩",
其他配置不变,继续使用:
var userContext = new UserContext(1L, "test-user");
然后运行 AgentDemo。
预期的关键日志(模拟)
[TOOL] 开始执行 getAverageScore
[AUDIT] ToolAuditRecord[
...
studentId=9999,
resultCode=PERMISSION_DENIED
]
===== 工具指标 =====
总调用次数:1
成功次数:0
失败次数:1
但这里有一个我们刻意要找出的设计问题:
当前 AiNode 的代码是:
String answer = assistant.chat(state.question());
return Map.of(
"answer", answer,
"success", true,
"resultCode", "SUCCESS"
);
即使工具返回 PERMISSION_DENIED,只要 AI Service 正常返回文字,Graph 仍然可能把本次执行标记成 SUCCESS,错误地进入 ResultNode。
这正是 11E 要修复的问题。
正确的工程设计应该区分:
-
modelCallSuccess:模型调用是否成功。 -
businessSuccess:业务操作是否成功。 -
resultCode:由可信的 Java 业务执行结果确定,而不是依靠 LLM 自然语言推断。
后续我们会把工具的结构化结果传递到 Graph 的业务状态,再进行条件路由。
三、本次操作
现在只修改查询学号为 9999,运行一次。
把控制台输出发给我,我们重点检查三个地方:
-
GradeTools是否拒绝了无权限查询。 -
审计是否记录
PERMISSION_DENIED。 -
Graph 是否错误地进入了
ResultNode。
如果第三点出现,也不代表你写错了代码,而是我们已经定位到当前架构需要改进的地方。
等这一步完成,我再带你修改 AiNode 与 ToolResult 的结果传递机制,然后继续后面的测试,最终完成第 11 关。
你的这次运行非常关键:11E 的 T2 权限拒绝测试成功,而且准确复现了我们预期的 Graph 状态判断问题。
一、真实运行结果分析
| 检查项 | 实际结果 | 判断 |
|---|---|---|
LLM 调用 GradeTools |
成功调用 | |
| Java 权限校验 | [Security] 权限拒绝 |
|
| 是否查询 GradeService | 没有进入查询 | |
| 审计记录 | PERMISSION_DENIED |
|
| 工具指标 | 成功 0、失败 1 | |
| Graph 业务状态 | SUCCESS |
|
| Graph 路由 | 错误进入 ResultNode |
问题就出在这段代码:
String answer = assistant.chat(state.question());
return Map.of(
"answer", answer,
"success", true,
"resultCode", "SUCCESS"
);
assistant.chat() 正常返回,只能说明 AI Service 调用成功,不能说明成绩查询业务成功。
你的日志已经证明了这一点:
ToolResult:PERMISSION_DENIED
↓
LLM:抱歉,没有权限查看成绩
↓
AiNode:success=true
↓
ResultNode(错误路由)
我们现在修复它。
二、11E 第二步:让 Graph 获取真实业务结果
核心思想是:
GradeTools 返回给 LLM 的结果,同时保留一份可信的 Java 结构化结果,供 Graph 判断。
这里先采用适合当前单线程教学 Demo 的方案:ToolExecutionContext。
1. 创建 ToolExecutionContext
在 com.example.langgraph.agent 下创建:
package com.example.langgraph.agent;
public class ToolExecutionContext {
private String resultCode;
private boolean toolCalled;
public void record(String code) {
this.resultCode = code;
this.toolCalled = true;
}
public boolean toolCalled() {
return toolCalled;
}
public String resultCode() {
return resultCode;
}
public void reset() {
resultCode = null;
toolCalled = false;
}
}
它的职责非常简单:
GradeTools 执行
↓
记录 resultCode
↓
AiNode 读取 resultCode
↓
更新 Graph State
注意:这个可变对象只适合当前单线程、单请求演示,不能作为多个并发用户共享的全局状态。后面工程化时会改成请求隔离的执行上下文。
2. 修改 GradeTools
打开已有的 GradeTools.java。
增加字段:
private final ToolExecutionContext executionContext;
通过构造方法注入它。
在你现有的 finally 中,原本已经有审计和指标逻辑:
finally {
// 原有审计记录
// 原有指标统计
}
现在额外增加:
executionContext.record(resultCode);
例如:
finally {
executionContext.record(resultCode);
// 保留原有审计和指标代码
}
这里不要删除你第 9 关已经完成的审计、权限校验和指标统计。
关键是保证 resultCode 在每条返回路径上都被正确赋值。你之前已经修复过权限拒绝时审计码未更新的问题,这次继续沿用修复后的版本。
另外,原有五参数构造方法可以保留,再增加一个接收 ToolExecutionContext 的构造方法,避免影响前面已经通过验收的 Demo。
3. 修改 AiNode
增加执行上下文依赖:
private final GradeAssistant assistant;
private final ToolExecutionContext executionContext;
public AiNode(
GradeAssistant assistant,
ToolExecutionContext executionContext
) {
this.assistant = assistant;
this.executionContext = executionContext;
}
然后修改 apply():
@Override
public Map<String, Object> apply(GradeAgentState state) {
executionContext.reset();
try {
String answer = assistant.chat(state.question());
if (!executionContext.toolCalled()) {
return Map.of(
"answer", answer,
"success", false,
"resultCode", "TOOL_NOT_CALLED"
);
}
String code = executionContext.resultCode();
return Map.of(
"answer", answer,
"success", "SUCCESS".equals(code),
"resultCode", code
);
} catch (Exception e) {
System.out.println(
"[AiNode] 调用异常:" + e.getClass().getSimpleName()
);
return Map.of(
"answer", "模型服务调用失败",
"success", false,
"resultCode", "MODEL_ERROR"
);
}
}
现在 success 由 Java 工具执行结果决定 ,而不是由 assistant.chat() 是否抛异常决定。
这里暂时把未调用工具视为失败,是因为我们当前测试的是必须查询成绩的请求。将来支持普通聊天时,需要根据意图决定是否必须调用工具。
4. 修改 Graph 和 Demo 的构造代码
在 GradeAgentGraph 中,将 ToolExecutionContext 作为参数传入,并修改 AiNode 的创建:
graph.addNode(
"ai",
node_async(new AiNode(assistant, executionContext))
);
在 AgentDemo 中:
var executionContext = new ToolExecutionContext();
创建 GradeTools 时也传入同一个 executionContext。
这样 GradeTools 和 AiNode 才能读取同一次工具执行的结果。
三、修改后的预期结果
重新查询:
查询学号9999的平均成绩
预期输出(模拟):
[Security] 权限拒绝
[AUDIT] resultCode=PERMISSION_DENIED
NodeOutput{
node=ai,
state={
success=false,
resultCode=PERMISSION_DENIED,
answer=抱歉,无权查看该学生成绩
}
}
===== Agent 执行失败 =====
错误码:PERMISSION_DENIED
NodeOutput{ node=failure, ... }
NodeOutput{ node=__END__, ... }
这次验收的关键不是 LLM 怎么回答,而是 Graph 是否正确进入 FailureNode。
另外,这个方案仍有一个限制:如果模型一次调用多个工具,单个 resultCode 只保留最后一次结果。第 11 关先用单次成绩查询验证;后续需要改为请求级结果集合,并为每次调用关联独立 ID。
四、11E 当前进度
| 测试 | 状态 |
|---|---|
| T1:有权限查询 1001 | 通过 |
| T2:权限拒绝 9999 | 拒绝有效,Graph 路由待修复 |
| T3:学生不存在 | 待测试 |
| T4:模型异常 | 待测试 |
| T5:TraceId 关联 | 待改进 |
| T6:自动化测试 | 待测试 |
你现在先完成这一轮修改,重新运行学号 9999 的测试,把控制台输出发给我。
我们确认 PERMISSION_DENIED → FailureNode → END 真实通过后,再继续下一项,不提前进入第 12 关。

第十二关:Agent 记忆与多轮对话
12A:为什么 Agent 需要记忆?
先看一个问题。
用户第一轮问:
查询学号 1001 的平均成绩。
Agent 回答:
学号 1001 的平均成绩是 86.5 分。
用户第二轮问:
那他的成绩算优秀吗?
这里的 "他" 指的是谁?
如果 Agent 每次只接收当前这一句话,就可能不知道"他"指的是学号 1001 的学生。
所以我们需要让 Agent 保留适当的对话上下文。

这里要区分两个概念:
| 机制 | 主要作用 |
|---|---|
| LangChain4j Chat Memory | 保存或管理 LLM 对话历史,让模型理解上下文 |
| LangGraph4j Checkpoint | 保存工作流状态,支持恢复、继续执行等 |
Chat Memory 关注对话历史,Checkpoint 关注工作流执行状态。 两者可以结合,但不是同一种东西。
还要注意:在真实学工系统中,记住学号不代表获得查询权限。每次执行涉及学生数据的 Tool,都必须重新进行权限校验。
三、先做一道理解题

下一步我们会在现有独立 Maven 练习项目中加入 LangChain4j 的对话记忆,让 Agent 真正支持连续提问,而不是只讲概念。
12A 实操:使用 LangChain4j 实现多轮对话记忆。
这一关先不接入 LangGraph4j,也不修改第十一关已经完成的 AgentDemo。我们先单独掌握 Chat Memory,再把它整合进 Graph。
一、本关目标
实现下面这种效果:
用户:查询学号1001的平均成绩
AI:学号1001的平均成绩是86.5分。
用户:我刚才查询的是哪个学号?
AI:你刚才查询的是学号1001。
第二轮不再提供学号,但 AI 能从之前的对话历史中找到它。
二、先理解工作原理

MessageWindowChatMemory 可以理解成一个有容量限制的对话记录本。它会保留最近一定数量的消息,而不是无限保存。
需要特别注意:Chat Memory 不等于数据库持久化。本关使用内存保存,Java 进程结束后,对话历史不会自动保留。
三、实操第一步:创建独立 Demo
在现有项目中创建包:
src/main/java/com/example/langgraph/memory/
创建两个 Java 文件:
memory/
├── MemoryAssistant.java
└── MemoryDemo.java
文件 1:MemoryAssistant.java
package com.example.langgraph.memory;
import dev.langchain4j.service.SystemMessage;
public interface MemoryAssistant {
@SystemMessage("""
你是一个学生成绩助手。
请结合对话历史回答用户问题。
不知道的信息不要编造。
""")
String chat(String message);
}
这里的 @SystemMessage 是系统提示词,规定 AI 的基本行为。
chat() 只是接口方法,真正的实现由 LangChain4j 的 AiServices 动态生成。
文件 2:MemoryDemo.java
package com.example.langgraph.memory;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.service.AiServices;
import java.time.Duration;
public class MemoryDemo {
public static void main(String[] args) {
// 1. 创建 Ollama 模型
var chatModel = OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.temperature(0.0)
.timeout(Duration.ofSeconds(120))
.build();
// 2. 创建对话记忆
var memory = MessageWindowChatMemory.withMaxMessages(10);
// 3. 创建带记忆的 AI Service
MemoryAssistant assistant = AiServices
.builder(MemoryAssistant.class)
.chatModel(chatModel)
.chatMemory(memory)
.build();
// 4. 第一轮对话
String answer1 = assistant.chat(
"请记住:我现在查询的学生学号是1001。"
);
System.out.println("===== 第一轮 =====");
System.out.println(answer1);
// 5. 第二轮对话
String answer2 = assistant.chat(
"我刚才查询的学生学号是多少?"
);
System.out.println("===== 第二轮 =====");
System.out.println(answer2);
}
}
这里故意使用"请记住学号"而不直接查询真实成绩,是为了先验证对话记忆机制,避免把 LLM 自己编造的成绩当成数据库结果。
四、重点理解三行代码
var memory = MessageWindowChatMemory.withMaxMessages(10);
创建一个最多保留约 10 条对话消息的记忆窗口。注意是消息数量,不是 10 轮完整对话。
.chatMemory(memory)
把记忆交给 AI Service 管理。后续调用 assistant.chat() 时,LangChain4j 会使用相应的历史消息。
assistant.chat("我刚才查询的学生学号是多少?");
第二轮没有直接写出 1001,如果模型能够正确回答,就说明它使用了之前的对话上下文。
这里使用的是单个 AI Service 实例、单个对话上下文,适合当前教学。多用户系统需要使用独立的会话 ID 隔离记忆,不能把所有用户的对话放进同一份 Memory。
五、运行和验收
在 IDEA 中运行 MemoryDemo.main()。
预期输出(模拟,非本机真实验证):
===== 第一轮 =====
好的,我会在当前对话中记住学号1001。
===== 第二轮 =====
你刚才查询的学生学号是1001。
进程已结束,退出代码为 0
实际措辞可能不同,只要第二轮能够准确回答 1001,就达到了本次基础测试目标。
六、一道知识巩固题

当前只需要完成这一件事:运行 MemoryDemo,验证第二轮是否能正确回答学号 1001。
通过后我们进入12B:多用户 Chat Memory 隔离 ,再学习如何通过 @MemoryId 防止不同用户的对话串在一起。
第十二关 12B:多用户对话记忆隔离
一、为什么需要 MemoryId?
假设学工系统有两个用户同时使用 Agent:

12A 中我们使用:
.chatMemory(memory)
它绑定的是一份固定的对话记忆,适合单会话演示。
12B 要改成:
.chatMemoryProvider(memoryId ->
MessageWindowChatMemory.withMaxMessages(10))
这样 LangChain4j 就可以根据不同的 memoryId 管理独立的对话记忆。
二、第一步:创建 MultiUserAssistant.java
路径:
src/main/java/com/example/langgraph/memory/MultiUserAssistant.java
package com.example.langgraph.memory;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.SystemMessage;
public interface MultiUserAssistant {
@SystemMessage("""
你是一个学生成绩助手。
请根据当前会话的历史记录回答问题。
区分登录用户与被查询学生。
不知道的信息不要编造。
""")
String chat(
@MemoryId String sessionId,
@UserMessage String message
);
}
重点理解:
@MemoryId 告诉 LangChain4j 当前消息属于哪个会话;@UserMessage 表示用户输入的实际内容。
三、第二步:创建 MultiUserMemoryDemo.java
package com.example.langgraph.memory;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.service.AiServices;
import java.time.Duration;
public class MultiUserMemoryDemo {
public static void main(String[] args) {
var chatModel = OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.temperature(0.0)
.timeout(Duration.ofSeconds(120))
.build();
MultiUserAssistant assistant = AiServices
.builder(MultiUserAssistant.class)
.chatModel(chatModel)
.chatMemoryProvider(memoryId ->
MessageWindowChatMemory.withMaxMessages(10))
.build();
// 用户 A:第一轮
System.out.println("===== 用户A 第一轮 =====");
System.out.println(assistant.chat(
"session-A",
"请记住:我正在查询的学生学号是1001。"
));
// 用户 B:第一轮
System.out.println("===== 用户B 第一轮 =====");
System.out.println(assistant.chat(
"session-B",
"请记住:我正在查询的学生学号是1002。"
));
// 用户 A:第二轮
System.out.println("===== 用户A 第二轮 =====");
System.out.println(assistant.chat(
"session-A",
"我刚才查询的是哪个学生学号?"
));
// 用户 B:第二轮
System.out.println("===== 用户B 第二轮 =====");
System.out.println(assistant.chat(
"session-B",
"我刚才查询的是哪个学生学号?"
));
}
}
这里不需要自己创建 Map 来保存记忆。chatMemoryProvider 负责按 memoryId 提供记忆对象,AI Service 在当前进程中管理这些会话。
注意:session-A 和 session-B 只是模拟会话标识。实际 Spring Boot 项目应从经过认证的会话中确定用户身份,并校验会话归属,不能直接相信客户端随意提交的 ID。
四、运行验收
运行 MultiUserMemoryDemo.main()。
预期输出(模拟):
===== 用户A 第一轮 =====
好的,当前查询的学生学号是1001。
===== 用户B 第一轮 =====
好的,当前查询的学生学号是1002。
===== 用户A 第二轮 =====
你刚才查询的是学号1001的学生。
===== 用户B 第二轮 =====
你刚才查询的是学号1002的学生。
进程已结束,退出代码为 0
重点不是回答措辞,而是:
-
用户 A 第二轮必须回答
1001。 -
用户 B 第二轮必须回答
1002。 -
两个会话不能串数据。
如果两个会话的结果互相混淆,就说明记忆隔离存在问题,需要检查 @MemoryId 和 chatMemoryProvider 的配置。
五、知识巩固

第十二关 12C:Chat Memory、Graph State 和 Checkpoint
这一关要把前面学过的三个概念联系起来。
一、先理解三者的区别
| 组件 | 负责什么 | 举例 |
|---|---|---|
| Chat Memory | 保存 LLM 对话历史 | 用户刚才问过学号 1001 |
| Graph State | 保存当前工作流的业务状态 | question、answer、success、resultCode |
| Checkpoint | 保存工作流状态快照,用于恢复执行 | 暂停审批后继续执行 |
可以这样理解:

特别注意:Checkpoint 不会自动等于 LangChain4j 的 Chat Memory。 如果要让二者在重启后都恢复,需要分别设计持久化 与恢复机制。
二、12C 实操:把多轮记忆接入 LangGraph4j
我们先实现一个最小版本:
用户提问
↓
Graph State
↓
MemoryNode
↓
LangChain4j AI Service
↓
Chat Memory(按 sessionId 区分)
↓
Graph State.answer
↓
END
本关先验证 Chat Memory 与 Graph State 的结合。Checkpoint 的持久化恢复暂不实现,避免同时引入多个复杂问题。
第一步:创建 MemoryAgentState.java
路径:
com.example.langgraph.memory.graph.MemoryAgentState
package com.example.langgraph.memory.graph;
import org.bsc.langgraph4j.state.AgentState;
import java.util.Map;
public class MemoryAgentState extends AgentState {
public MemoryAgentState(Map<String, Object> initData) {
super(initData);
}
public String question() {
return this.<String>value("question").orElse("");
}
public String sessionId() {
return this.<String>value("sessionId").orElse("");
}
public String answer() {
return this.<String>value("answer").orElse("");
}
}
这里的 sessionId 是 Graph State 中的业务字段,用于传递到 LangChain4j 的 @MemoryId。
第二步:创建 MemoryNode.java
package com.example.langgraph.memory.graph;
import com.example.langgraph.memory.MultiUserAssistant;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class MemoryNode implements NodeAction<MemoryAgentState> {
private final MultiUserAssistant assistant;
public MemoryNode(MultiUserAssistant assistant) {
this.assistant = assistant;
}
@Override
public Map<String, Object> apply(MemoryAgentState state) {
String answer = assistant.chat(
state.sessionId(),
state.question()
);
return Map.of("answer", answer);
}
}
这里最关键的一行:
assistant.chat(state.sessionId(), state.question());
Graph State 负责传递 sessionId,而 LangChain4j 使用这个 ID 选择对应的 Chat Memory。
第三步:创建 MemoryGraphDemo.java
package com.example.langgraph.memory.graph;
import com.example.langgraph.memory.MultiUserAssistant;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.service.AiServices;
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 MemoryGraphDemo {
public static void main(String[] args) throws Exception {
var model = OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.temperature(0.0)
.timeout(Duration.ofSeconds(120))
.build();
MultiUserAssistant assistant = AiServices
.builder(MultiUserAssistant.class)
.chatModel(model)
.chatMemoryProvider(id ->
MessageWindowChatMemory.withMaxMessages(10))
.build();
var graph = new StateGraph<>(
MemoryAgentState::new
);
graph.addNode("memory", node_async(new MemoryNode(assistant)));
graph.addEdge(START, "memory");
graph.addEdge("memory", END);
var compiled = graph.compile();
run(compiled, "session-A",
"请记住:我正在查询的学生学号是1001。");
run(compiled, "session-B",
"请记住:我正在查询的学生学号是1002。");
run(compiled, "session-A",
"我刚才查询的是哪个学生学号?");
run(compiled, "session-B",
"我刚才查询的是哪个学生学号?");
}
private static void run(
org.bsc.langgraph4j.CompiledGraph<MemoryAgentState> compiled,
String sessionId,
String question
) {
System.out.println("===== " + sessionId + " =====");
Map<String, Object> input = Map.of(
"sessionId", sessionId,
"question", question,
"answer", ""
);
for (var output : compiled.stream(input)) {
if ("memory".equals(output.node())) {
System.out.println(
"回答:" + output.state().answer()
);
}
}
}
}
这里的 compiled 是同一个 Graph,但每次 stream(input) 都启动一次新的工作流执行。
它们之所以能保留对话历史,是因为 四次调用共享同一个 assistant,而 assistant 内部根据 sessionId 维护不同的 Chat Memory,不是因为 Graph 自动保存了历史状态。
三、运行验收
预期输出(模拟):
===== session-A =====
回答:好的,当前查询的学生学号是1001。
===== session-B =====
回答:好的,当前查询的学生学号是1002。
===== session-A =====
回答:你刚才查询的是学号1001的学生。
===== session-B =====
回答:你刚才查询的是学号1002的学生。
进程已结束,退出代码为 0
本关重点验证两个条件:
-
Graph State正确传递sessionId和question。 -
MemoryNode调用 LangChain4j 后,第二轮能够取回对应会话的历史信息。
四、知识巩固

Graph State 负责把 sessionId 传递给节点,LangChain4j 根据 @MemoryId 找到对应的历史对话。
"session-A" 的旅程
run() 的实参 ──▶ input map("sessionId"→"session-A") ──▶ MemoryAgentState
│
state.sessionId() 取出
▼
assistant.chat("session-A", question)
│
langchain4j 内部:
▼
记忆仓库.get("session-A") ──▶ 实例1(历史对话)
▼
历史 + 新问题 ──▶ LLM ──▶ 回答 ──▶ 存回实例1
总结成一句人话:前半程(sessionId 从 main 走到 chat 调用)是"传参",你自己写的代码完成的;后半程(拿 sessionId 去 HashMap 里查历史)是 langchain4j 在 chat() 内部自动完成的。中间的分界线就是 assistant.chat(...) 这一行。
第十三关:多 Agent 协作
13A:认识多 Agent 架构
前面我们实现的 Agent 主要是一个 AI 助手负责完成任务。
但假设现在用户提出:
查询学号 1001 的平均成绩,分析他的学习情况,并给出学习建议。
这个任务包含三个不同的职责:
-
查询 Agent:负责获取成绩数据。
-
分析 Agent:根据成绩数据分析学习情况。
-
建议 Agent:根据分析结果生成建议。
可以通过 LangGraph4j 把它们串联起来。

不过这里有一个重要概念:
三个 Node 不一定等于三个 Agent。
如果三个节点只是普通 Java 方法,那么它们属于多节点工作流。只有节点内部具有独立的 AI 推理、工具调用或自主决策等能力时,才更适合称为 Agent。
三、13A 实操:先定义多 Agent 共享状态
本关先创建状态类,不急着接入 Ollama。
在练习项目中创建:
src/main/java/com/example/langgraph/multiagent/
└── MultiAgentState.java
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.state.AgentState;
import java.util.Map;
public class MultiAgentState extends AgentState {
public MultiAgentState(Map<String, Object> initData) {
super(initData);
}
public Long studentId() {
return this.<Long>value("studentId").orElse(null);
}
public Double averageScore() {
return this.<Double>value("averageScore").orElse(null);
}
public String analysis() {
return this.<String>value("analysis").orElse("");
}
public String advice() {
return this.<String>value("advice").orElse("");
}
public String resultCode() {
return this.<String>value("resultCode").orElse("");
}
}
为什么这样设计?
| 字段 | 作用 | 主要使用方 |
|---|---|---|
studentId |
被查询学生的学号 | 查询节点 |
averageScore |
成绩服务返回的平均分 | 查询、分析节点 |
analysis |
学习情况分析 | 分析、建议节点 |
advice |
最终学习建议 | 建议节点 |
resultCode |
查询成功或失败状态 | Graph 条件路由 |
其中 averageScore 必须来自可信的成绩服务,不能让 LLM 随意生成。真实业务中还需要在查询前进行权限校验,避免把无权限的学生成绩传递给后续 Agent。
四、13A 知识巩固

现在先创建 MultiAgentState.java,理解这五个字段的用途。 下一关 13B 我们会实现查询节点、分析节点、建议节点的完整数据流,并通过控制台验证节点之间如何传递状态
第十三关 13B:实现多节点协作工作流
这一关我们先完成 查询 → 分析 → 建议 的完整流程。
本关采用普通 Java 节点模拟业务协作,暂时不调用 Ollama。这样可以先弄清楚数据是如何在节点之间传递的。这属于多节点工作流,还不是真正的多 AI Agent 协作。 下一关再逐步引入独立的 AI Agent。
一、目标效果

二、创建项目结构
继续使用你的独立练习项目 langgraph4j-demo。
src/main/java/com/example/langgraph/multiagent/
├── MultiAgentState.java // 13A 已创建
├── QueryNode.java
├── AnalysisNode.java
├── AdviceNode.java
└── MultiAgentDemo.java
第一步:QueryNode.java
查询节点负责获取成绩数据。
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class QueryNode implements NodeAction<MultiAgentState> {
@Override
public Map<String, Object> apply(MultiAgentState state) {
Long studentId = state.studentId();
System.out.println("[QueryNode] 查询学号:" + studentId);
// 本关使用模拟数据,不连接真实数据库
if (Long.valueOf(1001L).equals(studentId)) {
return Map.of(
"averageScore", 86.5,
"resultCode", "SUCCESS"
);
}
return Map.of(
"resultCode", "STUDENT_NOT_FOUND"
);
}
}
这里有两个重点:
-
state.studentId():读取 Graph State。 -
return Map.of(...):返回需要更新的字段,由 Graph 合并进 State。
Node 不需要自己修改整个 State。
第二步:AnalysisNode.java
分析节点读取查询节点产生的 averageScore。
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class AnalysisNode implements NodeAction<MultiAgentState> {
@Override
public Map<String, Object> apply(MultiAgentState state) {
Double score = state.averageScore();
System.out.println("[AnalysisNode] 获取平均分:" + score);
if (score == null) {
return Map.of(
"analysis", "没有可分析的成绩数据"
);
}
String analysis;
// 教学示例规则,不代表学校实际成绩评价标准
if (score >= 85) {
analysis = "成绩表现良好";
} else if (score >= 60) {
analysis = "成绩达到及格水平,仍有提升空间";
} else {
analysis = "成绩需要重点关注";
}
return Map.of("analysis", analysis);
}
}
注意:这里没有重新查询成绩。
它只读取 state.averageScore(),也就是前一个节点写入的数据。
这正是 Graph State 的价值。
第三步:AdviceNode.java
建议节点读取分析结果,并生成建议。
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class AdviceNode implements NodeAction<MultiAgentState> {
@Override
public Map<String, Object> apply(MultiAgentState state) {
String analysis = state.analysis();
System.out.println("[AdviceNode] 获取分析:" + analysis);
String advice;
if ("成绩表现良好".equals(analysis)) {
advice = "建议保持当前学习节奏,并加强薄弱科目。";
} else if ("成绩需要重点关注".equals(analysis)) {
advice = "建议制定学习计划,优先巩固基础知识。";
} else {
advice = "建议定期复习,并针对薄弱知识点进行训练。";
}
return Map.of("advice", advice);
}
}
到这里,你应该能发现:
QueryNode 不需要知道 AdviceNode 怎么实现。
AdviceNode 也不需要直接调用 QueryNode。
它们通过 Graph State 解耦。
三、创建 MultiAgentDemo.java
package com.example.langgraph.multiagent;
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 MultiAgentDemo {
public static void main(String[] args) throws Exception {
var graph = new StateGraph<>(MultiAgentState::new);
// 注册节点
graph.addNode("query", node_async(new QueryNode()));
graph.addNode("analysis", node_async(new AnalysisNode()));
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();
// 初始化 State
Map<String, Object> initialState = Map.of(
"studentId", 1001L,
"analysis", "",
"advice", "",
"resultCode", ""
);
// 运行并打印每个节点执行后的 State
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
}
}
注意:这段代码没有在初始 State 中设置 averageScore。
因为它应该由 QueryNode 产生,而不是提前写死。
四、运行验收
在 IDEA 中运行:
MultiAgentDemo.main()
预期关键输出(模拟,并非你本机的实际结果):
[QueryNode] 查询学号:1001
NodeOutput{ node=query, state={
studentId=1001
averageScore=86.5
resultCode=SUCCESS
...
}}
[AnalysisNode] 获取平均分:86.5
NodeOutput{ node=analysis, state={
averageScore=86.5
analysis=成绩表现良好
...
}}
[AdviceNode] 获取分析:成绩表现良好
NodeOutput{ node=advice, state={
advice=建议保持当前学习节奏,并加强薄弱科目。
...
}}
NodeOutput{ node=__END__, ... }
进程已结束,退出代码为 0
验收标准
| 检查项 | 期望结果 |
|---|---|
| QueryNode | 生成 86.5 |
| AnalysisNode | 读取 86.5,生成分析 |
| AdviceNode | 读取分析,生成建议 |
| END | 保留前面所有节点产生的数据 |
| 退出代码 | 0 |
本次只验证正常路径。当前 Graph 是固定顺序,学生不存在时仍会继续执行分析和建议节点,这是一个需要在下一步通过条件边修正的问题。
五、理解为什么这叫节点协作
三个节点分别负责不同的事情:
// QueryNode
return Map.of("averageScore", 86.5);
// AnalysisNode
Double score = state.averageScore();
return Map.of("analysis", "成绩表现良好");
// AdviceNode
String analysis = state.analysis();
return Map.of("advice", "建议保持当前学习节奏");
可以类比为后端分层开发:
-
QueryNode:获取业务数据。
-
AnalysisNode:处理和分析数据。
-
AdviceNode:形成最终业务输出。
但 Graph 比普通方法顺序调用多了一层能力:它可以通过 Edge、条件边、State、Checkpoint 等机制管理执行流程。
六、13B 知识巩固

第十三关 13C:条件路由 + AI 分析节点
这一关我们做两个升级:
-
查询失败时,直接进入失败节点,不再生成学习建议。
-
查询成功时,让 Ollama 负责分析成绩,而不是使用 Java
if/else生成固定分析。
一、升级后的架构

这里的原则是:Java 决定能不能查询,LLM 负责分析已经授权的数据。
本关的 QueryNode 仍然使用模拟数据,不代表真实数据库和权限验证已经接入。
二、第一步:创建 AIAnalysisAssistant.java
路径:
com.example.langgraph.multiagent.AIAnalysisAssistant
package com.example.langgraph.multiagent;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
public interface AIAnalysisAssistant {
@SystemMessage("""
你是一名学习成绩分析助手。
只根据提供的平均分进行简短分析。
不要编造课程成绩、排名或学生个人信息。
回答控制在80字以内。
""")
String analyze(@UserMessage String message);
}
这里我们让 LangChain4j 创建一个专门负责成绩分析的 AI Service。
它不负责查数据库,也不负责权限判断。
三、第二步:创建 AIAnalysisNode.java
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class AIAnalysisNode implements NodeAction<MultiAgentState> {
private final AIAnalysisAssistant assistant;
public AIAnalysisNode(AIAnalysisAssistant assistant) {
this.assistant = assistant;
}
@Override
public Map<String, Object> apply(MultiAgentState state) {
Double score = state.averageScore();
if (score == null) {
return Map.of(
"analysis", "没有成绩数据",
"resultCode", "NO_SCORE"
);
}
try {
String analysis = assistant.analyze(
"学生平均分为:" + score + "。请分析学习情况。"
);
System.out.println("[AIAnalysisNode] " + analysis);
return Map.of("analysis", analysis);
} catch (RuntimeException e) {
System.out.println(
"[AIAnalysisNode] 模型异常:" +
e.getClass().getSimpleName()
);
return Map.of(
"analysis", "模型分析失败",
"resultCode", "MODEL_ERROR"
);
}
}
}
注意这里的 try/catch。
如果 Ollama 调用失败,我们不能让工作流假装成功,而应该更新 resultCode。
因此接下来还需要一条条件边,避免模型失败后继续生成建议。
四、第三步:创建 FailureNode.java
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class FailureNode implements NodeAction<MultiAgentState> {
@Override
public Map<String, Object> apply(MultiAgentState state) {
System.out.println("===== 工作流执行失败 =====");
System.out.println("学号:" + state.studentId());
System.out.println("错误码:" + state.resultCode());
return Map.of();
}
}
五、第四步:创建 MultiAgentAiDemo.java
保留原来的 MultiAgentDemo,新建一个 Demo,避免覆盖已经验收通过的代码。
package com.example.langgraph.multiagent;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.service.AiServices;
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;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
public class MultiAgentAiDemo {
public static void main(String[] args) throws Exception {
var model = OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.temperature(0.0)
.timeout(Duration.ofSeconds(120))
.build();
AIAnalysisAssistant assistant = AiServices
.builder(AIAnalysisAssistant.class)
.chatModel(model)
.build();
var graph = new StateGraph<>(MultiAgentState::new);
graph.addNode("query", node_async(new QueryNode()));
graph.addNode("analysis", node_async(new AIAnalysisNode(assistant)));
graph.addNode("advice", node_async(new AdviceNode()));
graph.addNode("failure", node_async(new FailureNode()));
graph.addEdge(START, "query");
// 查询成功才允许进入 AI 分析
graph.addConditionalEdges(
"query",
edge_async(state ->
"SUCCESS".equals(state.resultCode())
? "success"
: "failure"
),
Map.of(
"success", "analysis",
"failure", "failure"
)
);
// AI 分析成功才允许生成建议
graph.addConditionalEdges(
"analysis",
edge_async(state ->
"SUCCESS".equals(state.resultCode())
? "success"
: "failure"
),
Map.of(
"success", "advice",
"failure", "failure"
)
);
graph.addEdge("advice", END);
graph.addEdge("failure", END);
var compiled = graph.compile();
Map<String, Object> initialState = Map.of(
"studentId", 1001L,
"analysis", "",
"advice", "",
"resultCode", ""
);
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
}
}
六、运行验收
本次分两次运行。
测试 A:正常成绩查询
保持:
"studentId", 1001L
预期(模拟):
[QueryNode] 查询学号:1001
[AIAnalysisNode] 该学生平均分为86.5分,整体表现较好......
[AdviceNode] 获取分析:该学生平均分为86.5分......
NodeOutput{ node=advice, ... }
NodeOutput{ node=__END__, ... }
进程已结束,退出代码为 0
这里还有一个你需要观察的细节: 原来的 AdviceNode 使用固定字符串匹配:
if ("成绩表现良好".equals(analysis))
现在 AI 返回的是自然语言,通常不会刚好等于这五个字,因此很可能进入默认建议分支。
这正是下一关要解决的内容:AI 输出如何结构化,才能让 Java 业务逻辑可靠地使用。
测试 B:学生不存在
将学号改为:
"studentId", 9999L
预期(模拟):
[QueryNode] 查询学号:9999
===== 工作流执行失败 =====
学号:9999
错误码:STUDENT_NOT_FOUND
NodeOutput{ node=failure, ... }
NodeOutput{ node=__END__, ... }
进程已结束,退出代码为 0
这次应该完全不出现:
[AIAnalysisNode]
[AdviceNode]
因为查询失败时,Graph 必须阻止后续 AI 节点执行。
七、13C 知识巩固

一、测试 B 验收结果
| 检查项 | 实际结果 | 状态 |
|---|---|---|
| QueryNode 查询 | 学号 9999 | |
| 查询结果 | STUDENT_NOT_FOUND |
|
| 条件边路由 | 进入 failure |
|
| AIAnalysisNode | 未执行 | |
| AdviceNode | 未执行 | |
| 工作流结束 | __END__ |
|
| 退出代码 | 0 |
你现在已经掌握了 LangGraph4j 的一个重要能力:根据业务结果决定后续执行哪些节点,而不是无条件执行整个流程。
接下来解决 13C 暴露出来的另一个问题:AI 返回自然语言不稳定,Java 不适合直接通过字符串匹配来判断分析等级。
第十三关 13D:结构化 AI 输出与协作完善
一、本关要解决什么?
上一关,Ollama 返回了:
学生平均分为86.5,表明学生的学习成绩非常优秀......
但 AdviceNode 使用的是:
"成绩表现良好".equals(analysis)
所以判断没有命中。
我们希望模型返回固定结构,让 Java 读取明确的字段。
目标结构
{
"level": "GOOD",
"analysis": "平均分为86.5,按演示规则属于良好水平。"
}
Java 根据 level 选择建议,而不是分析整段自然语言。
本关使用你项目中已有的 Jackson 解析 JSON,不额外安装依赖。
二、第一步:扩展 MultiAgentState
在已有的 MultiAgentState.java 中增加一个 getter:
public String analysisLevel() {
return this.<String>value("analysisLevel").orElse("");
}
以后状态中就有两个不同的分析字段:
-
analysis:给人阅读的分析文字。 -
analysisLevel:给 Java 判断的固定等级。
三、第二步:创建结构化结果类型
新建 GradeAnalysisResult.java:
package com.example.langgraph.multiagent;
public record GradeAnalysisResult(
String level,
String analysis
) {
}
record 非常适合这种只保存数据的对象。
例如:
new GradeAnalysisResult(
"GOOD",
"平均分86.5,按演示规则属于良好水平。"
);
四、第三步:修改 AIAnalysisAssistant
把之前的接口修改为:
package com.example.langgraph.multiagent;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
public interface AIAnalysisAssistant {
@SystemMessage("""
你是一名学生成绩分析助手。
演示等级规则:
平均分 >= 85:GOOD
平均分 >= 60 且 < 85:NORMAL
平均分 < 60:NEEDS_ATTENTION
只能根据提供的平均分进行分析。
不得推断班级排名、学习效率、个人能力等未知信息。
只返回合法 JSON,不要 Markdown 代码块。
格式:
{"level":"GOOD","analysis":"简短分析"}
level 必须是 GOOD、NORMAL、NEEDS_ATTENTION 之一。
""")
String analyze(@UserMessage String message);
}
注意:这是提示词约束,不是强制保证。LLM 仍可能返回错误 JSON,因此 Java 必须校验。
五、第四步:修改 AIAnalysisNode
把原来的 AIAnalysisNode.java 替换为下面的版本。
package com.example.langgraph.multiagent;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
import java.util.Set;
public class AIAnalysisNode implements NodeAction<MultiAgentState> {
private final AIAnalysisAssistant assistant;
private final ObjectMapper mapper = new ObjectMapper();
private static final Set<String> LEVELS = Set.of(
"GOOD", "NORMAL", "NEEDS_ATTENTION"
);
public AIAnalysisNode(AIAnalysisAssistant assistant) {
this.assistant = assistant;
}
@Override
public Map<String, Object> apply(MultiAgentState state) {
Double score = state.averageScore();
if (score == null || !Double.isFinite(score)
|| score < 0 || score > 100) {
return Map.of("resultCode", "INVALID_SCORE");
}
try {
String json = assistant.analyze(
"学生平均分为:" + score
);
GradeAnalysisResult result =
mapper.readValue(json, GradeAnalysisResult.class);
if (result.level() == null
|| !LEVELS.contains(result.level())
|| result.analysis() == null
|| result.analysis().isBlank()) {
return Map.of("resultCode", "AI_OUTPUT_INVALID");
}
// Java 根据可信成绩计算等级
String expectedLevel = score >= 85
? "GOOD"
: score >= 60
? "NORMAL"
: "NEEDS_ATTENTION";
if (!expectedLevel.equals(result.level())) {
return Map.of("resultCode", "AI_OUTPUT_INVALID");
}
System.out.println("[AIAnalysisNode] 等级:" + result.level());
System.out.println("[AIAnalysisNode] 分析:" + result.analysis());
return Map.of(
"analysisLevel", result.level(),
"analysis", result.analysis(),
"resultCode", "SUCCESS"
);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
System.out.println("[AIAnalysisNode] JSON解析失败");
return Map.of("resultCode", "AI_OUTPUT_INVALID");
} catch (RuntimeException e) {
System.out.println("[AIAnalysisNode] 模型调用异常:"
+ e.getClass().getSimpleName());
return Map.of("resultCode", "MODEL_ERROR");
}
}
}
这次做了三层检查:
-
检查成绩数据是否有效。
-
检查 JSON 能否解析、字段是否合法。
-
检查 LLM 给出的等级是否符合 Java 中的演示规则。
第三点尤其重要:LLM 可以解释数据,但不能仅凭它的输出决定权威业务等级。
六、第五步:创建 StructuredAdviceNode
保留原来的 AdviceNode,新建 StructuredAdviceNode.java。
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class StructuredAdviceNode implements NodeAction<MultiAgentState> {
@Override
public Map<String, Object> apply(MultiAgentState state) {
String level = state.analysisLevel();
String advice = switch (level) {
case "GOOD" ->
"建议保持当前学习节奏,并关注薄弱科目。";
case "NORMAL" ->
"建议定期复习,逐步提升学习效果。";
case "NEEDS_ATTENTION" ->
"建议优先巩固基础知识,制定阶段学习计划。";
default ->
throw new IllegalStateException(
"不支持的分析等级:" + level
);
};
System.out.println("[StructuredAdviceNode] 等级:" + level);
System.out.println("[StructuredAdviceNode] 建议:" + advice);
return Map.of("advice", advice);
}
}
这里用到了 Java 21 支持的 switch 表达式。
和原来相比,最大的变化是:
String level = state.analysisLevel();
不再依赖 AI 的自然语言描述进行判断。
七、第六步:修改 MultiAgentAiDemo
只修改两个位置。
将原来的建议节点:
graph.addNode("advice", node_async(new AdviceNode()));
改为:
graph.addNode(
"advice",
node_async(new StructuredAdviceNode())
);
然后在 initialState 中增加:
"analysisLevel", ""
原来的两条条件边可以保留,因为新的 AIAnalysisNode 已经会返回 SUCCESS 或明确的失败码。
八、运行验收
把学号恢复为:
"studentId", 1001L
运行 MultiAgentAiDemo。
预期关键输出(模拟):
[QueryNode] 查询学号:1001
[AIAnalysisNode] 等级:GOOD
[AIAnalysisNode] 分析:平均分86.5,按演示规则属于良好水平。
[StructuredAdviceNode] 等级:GOOD
[StructuredAdviceNode] 建议:建议保持当前学习节奏,并关注薄弱科目。
NodeOutput{ node=__END__, state={
studentId=1001
averageScore=86.5
resultCode=SUCCESS
analysisLevel=GOOD
analysis=平均分86.5,按演示规则属于良好水平。
advice=建议保持当前学习节奏,并关注薄弱科目。
}}
进程已结束,退出代码为 0
如果模型返回无效 JSON,预期应进入 FailureNode,错误码为 AI_OUTPUT_INVALID,而不是继续生成建议。这也是正确的失败保护行为。
本关还没有实现严格的 JSON Schema 约束、AI 文本事实校验或真正独立的建议 Agent,这些可以留到工程化阶段进一步完善。
九、13D 知识巩固


你已经掌握了一条完整的工作流:
业务查询 → 条件判断 → AI 分析 → 结构化校验 → 建议生成 → 最终输出。
需要明确的是:当前实现属于 Java 业务节点 + 一个 AI 分析节点的混合工作流,还不是多个独立 LLM Agent 之间的自主协作。并且当前查询数据仍是教学模拟数据,尚未接入真实权限系统或数据库。
第十四关:Agent 工程化与可靠性
接下来不急着增加新的 Agent,而是学习怎样把已经能运行的 Agent 变成更可靠的后端组件。
第十四关分为三个阶段:
-
14A:统一错误码与执行结果
-
14B:超时、重试与故障处理
-
14C:日志、TraceId 与自动化测试
14A:为什么需要统一结果对象?
你现在的 Graph State 通过字符串表示执行结果:
"resultCode", "SUCCESS"
这在教学 Demo 中没有问题,但实际开发时可能出现:
"SUCESS" // 拼写错误
"MODEL_ERROR"
"AI_OUTPUT_INVALID"
字符串散落在不同节点中,容易拼错,而且没有统一管理。
因此本关先学习使用 枚举 Enum 统一错误码。
第一步:创建 AgentResultCode.java
在独立练习项目中新建:
com.example.langgraph.multiagent.AgentResultCode
package com.example.langgraph.multiagent;
public enum AgentResultCode {
SUCCESS,
STUDENT_NOT_FOUND,
PERMISSION_DENIED,
INVALID_SCORE,
AI_OUTPUT_INVALID,
MODEL_ERROR
}
这样以后就能写:
AgentResultCode.SUCCESS.name()
而不是每次手写:
"SUCCESS"
第二步:改造 QueryNode
把原来的:
return Map.of(
"averageScore", 86.5,
"resultCode", "SUCCESS"
);
改成:
return Map.of(
"averageScore", 86.5,
"resultCode", AgentResultCode.SUCCESS.name()
);
学生不存在时同样修改:
return Map.of(
"resultCode",
AgentResultCode.STUDENT_NOT_FOUND.name()
);
这里使用 .name(),是为了保持现有 Graph State 中 resultCode 的字符串格式不变,不影响之前的条件边。
第三步:理解 Enum 的价值
假设你写错:
AgentResultCode.SUCESS
IDEA 就会提示编译错误。
而如果直接写:
"SUCESS"
Java 编译器无法知道你拼错了。
这就是枚举在真实后端项目中的一个重要价值:让部分错误从运行阶段提前到编译阶段发现。
14A 知识巩固

下一步我们再把统一错误码扩展到整个工作流,并进行真实运行验证
第十四关 14A(第二阶段):统一整个工作流的错误码
这一阶段我们只修改已有代码,不增加新框架,也不接入数据库。
一、先理解重构目标
原来的代码:
return Map.of("resultCode", "MODEL_ERROR");
重构后:
return Map.of(
"resultCode",
AgentResultCode.MODEL_ERROR.name()
);

二、第一步:检查枚举类
确认 AgentResultCode.java 包含:
package com.example.langgraph.multiagent;
public enum AgentResultCode {
SUCCESS,
STUDENT_NOT_FOUND,
PERMISSION_DENIED,
INVALID_SCORE,
AI_OUTPUT_INVALID,
MODEL_ERROR
}
三、第二步:改造 AIAnalysisNode
打开 AIAnalysisNode.java。
将所有返回的错误码替换为枚举。
1. 无效成绩
if (score == null || !Double.isFinite(score)
|| score < 0 || score > 100) {
return Map.of(
"resultCode",
AgentResultCode.INVALID_SCORE.name()
);
}
2. JSON 字段不合法
原来的:
return Map.of("resultCode", "AI_OUTPUT_INVALID");
修改为:
return Map.of(
"resultCode",
AgentResultCode.AI_OUTPUT_INVALID.name()
);
注意:这个返回语句在你原来的代码中出现了两次,两处都需要修改。
3. AI 分析成功
return Map.of(
"analysisLevel", result.level(),
"analysis", result.analysis(),
"resultCode", AgentResultCode.SUCCESS.name()
);
4. 模型异常
catch (RuntimeException e) {
System.out.println(
"[AIAnalysisNode] 模型调用异常:" +
e.getClass().getSimpleName()
);
return Map.of(
"resultCode",
AgentResultCode.MODEL_ERROR.name()
);
}
JSON 解析异常的 catch 同样返回 AgentResultCode.AI_OUTPUT_INVALID.name()。
这样 AIAnalysisNode 中就不再需要手写业务错误码字符串。
四、第三步:改造 MultiAgentAiDemo 的条件边
原来是:
"SUCCESS".equals(state.resultCode())
现在改成:
AgentResultCode.SUCCESS.name().equals(state.resultCode())
例如查询节点后面的条件边:
graph.addConditionalEdges(
"query",
edge_async(state ->
AgentResultCode.SUCCESS.name()
.equals(state.resultCode())
? "success"
: "failure"
),
Map.of(
"success", "analysis",
"failure", "failure"
)
);
analysis 后面的条件边也做相同修改。
这里的 "success" 和 "failure" 暂时不改,因为它们是条件边的分支标签,不是业务错误码。
五、第四步:编译检查
在 IDEA 终端执行:
mvn clean compile
预期(模拟):
[INFO] BUILD SUCCESS
如果编译成功,再运行你之前的 InvalidJsonDemo。
注意:InvalidJsonDemo 仍使用旧的字符串条件判断也能正常运行,因为我们保留了 resultCode 的字符串契约。后续可以把它的条件判断同步改成枚举,但不影响本次验收。
本次验收标准
| 验收项目 | 预期 |
|---|---|
AgentResultCode |
枚举类存在 |
QueryNode |
使用枚举返回错误码 |
AIAnalysisNode |
使用枚举返回错误码 |
MultiAgentAiDemo |
条件边使用枚举判断 |
| Maven | BUILD SUCCESS |
InvalidJsonDemo |
仍能进入 failure |
六、知识点:为什么不直接使用枚举作为 State 字段?
其实可以。
例如:
return Map.of(
"resultCode",
AgentResultCode.SUCCESS
);
但是你当前的 MultiAgentState 使用:
public String resultCode() {
return this.<String>value("resultCode").orElse("");
}
如果直接存入枚举,再按照 String 读取,就可能发生类型转换错误。
因此我们当前保留:
AgentResultCode.SUCCESS.name()
等后续专门重构 State 类型时,再统一改为枚举。
这也说明:重构不仅要考虑代码写法,还要考虑上下游的数据类型契约。
七、14A 知识巩固

第十四关 14B:Agent 超时、重试与故障处理
这一关非常接近真实 Java 后端开发。
假设你的 Agent 调用 Ollama 时遇到以下情况:
-
Ollama 暂时不可用。
-
网络连接超时。
-
模型请求偶尔失败。
-
模型返回非法 JSON。
是不是所有错误都应该重试?
不是。
例如 JSON 结构不合法,盲目重试不一定有用;但临时网络故障可以考虑有限次数重试。
一、先理解重试流程

二、14B 第一阶段:先实现最简单的有限重试
这次不修改你已经验收通过的 AIAnalysisNode,我们新建一个独立的重试演示。
在 com.example.langgraph.multiagent 下创建:
RetryAnalysisDemo.java
package com.example.langgraph.multiagent;
import java.util.concurrent.atomic.AtomicInteger;
public class RetryAnalysisDemo {
public static void main(String[] args) {
AtomicInteger calls = new AtomicInteger();
// 模拟 AI 前两次调用失败,第三次成功
AIAnalysisAssistant fakeAssistant = message -> {
int current = calls.incrementAndGet();
System.out.println("AI调用次数:" + current);
if (current < 3) {
throw new RuntimeException("模拟临时网络故障");
}
return """
{"level":"GOOD","analysis":"成绩表现良好"}
""";
};
int maxAttempts = 3;
String result = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
result = fakeAssistant.analyze("平均分86.5");
System.out.println("调用成功");
break;
} catch (RuntimeException e) {
System.out.println(
"第" + attempt + "次调用失败:" + e.getMessage()
);
if (attempt == maxAttempts) {
System.out.println("达到最大尝试次数,停止重试");
}
}
}
System.out.println("最终结果:" + result);
}
}
这里有三个重要知识点
AtomicInteger
用于安全地进行原子计数。本例是顺序调用,普通 int 也能实现;这里使用 AtomicInteger 是为了让 Lambda 内部能够更新计数器,并顺便认识并发工具类。
maxAttempts = 3
表示最多调用三次,而不是第一次调用后再重试三次。
break
调用成功后立即跳出循环,避免重复调用模型。
三、运行验收
运行 RetryAnalysisDemo.main()。
预期输出(模拟):
AI调用次数:1
第1次调用失败:模拟临时网络故障
AI调用次数:2
第2次调用失败:模拟临时网络故障
AI调用次数:3
调用成功
最终结果:{"level":"GOOD","analysis":"成绩表现良好"}
进程已结束,退出代码为 0
这段代码只是学习重试机制的基础版本,并非生产级实现。目前它会重试所有 RuntimeException,没有区分异常类型,也没有退避等待、整体超时控制或 JSON 校验。后续我们会逐步改进。
四、知识巩固

第十四关 14B(第二阶段):哪些异常应该重试?
第一阶段存在一个问题:
catch (RuntimeException e) {
// 所有异常都重试
}
这种做法不适合直接用于真实后端,因为不同异常需要不同处理方式。
例如:
| 错误类型 | 是否重试 | 原因 |
|---|---|---|
| 临时网络连接失败 | 可以有限重试 | 网络可能恢复 |
| 请求超时 | 视情况重试 | 需要考虑重复执行和总耗时 |
| JSON 格式不合法 | 默认不重试 | 不属于网络故障 |
| 学生不存在 | 不重试 | 重试不会让学生出现 |
| 权限不足 | 不重试 | 重试不能绕过权限校验 |
一、先学习两个异常类
这次我们不修改之前已经通过的 RetryAnalysisDemo,而是新建独立练习。
1. RetryableAiException.java
package com.example.langgraph.multiagent;
public class RetryableAiException extends RuntimeException {
public RetryableAiException(String message) {
super(message);
}
}
表示:允许重试的 AI 调用异常。
2. NonRetryableAiException.java
package com.example.langgraph.multiagent;
public class NonRetryableAiException extends RuntimeException {
public NonRetryableAiException(String message) {
super(message);
}
}
表示:不允许重试的异常。
这里我们使用继承:
RuntimeException
├── RetryableAiException
└── NonRetryableAiException
它们都是运行时异常,但我们可以通过异常类型决定不同的处理策略。
二、编写第二阶段代码
创建 RetryClassificationDemo.java:
package com.example.langgraph.multiagent;
import java.util.concurrent.atomic.AtomicInteger;
public class RetryClassificationDemo {
public static void main(String[] args) {
AtomicInteger calls = new AtomicInteger();
AIAnalysisAssistant fakeAssistant = message -> {
int current = calls.incrementAndGet();
System.out.println("AI调用次数:" + current);
if (current == 1) {
throw new RetryableAiException("模拟网络超时");
}
if (current == 2) {
throw new NonRetryableAiException("模拟请求参数错误");
}
return """
{"level":"GOOD","analysis":"成绩表现良好"}
""";
};
int maxAttempts = 3;
String result = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
result = fakeAssistant.analyze("平均分86.5");
System.out.println("调用成功");
break;
} catch (NonRetryableAiException e) {
System.out.println("不可重试异常:" + e.getMessage());
System.out.println("立即停止");
break;
} catch (RetryableAiException e) {
System.out.println("可重试异常:" + e.getMessage());
if (attempt == maxAttempts) {
System.out.println("达到最大尝试次数");
}
} catch (RuntimeException e) {
System.out.println("未知异常:" + e.getMessage());
System.out.println("默认停止重试");
break;
}
}
System.out.println("总调用次数:" + calls.get());
System.out.println("最终结果:" + result);
}
}
三、这段代码的执行过程

预期输出(模拟)
AI调用次数:1
可重试异常:模拟网络超时
AI调用次数:2
不可重试异常:模拟请求参数错误
立即停止
总调用次数:2
最终结果:null
这里 null 表示没有成功取得结果,不代表程序出错。
需要注意:这两个异常类目前是我们自己定义的教学异常。真正接入 LangChain4j/Ollama 时,还需要根据底层实际异常及其原因分类,不能简单认为所有 RuntimeException 都是网络故障。
四、14B 第二阶段知识题

最大重试次数只是上限,不代表每次都必须重试到上限。
第十四关 14B(第三阶段):将重试机制接入 LangGraph4j
前两阶段是在普通 Java 循环中学习重试。现在我们把它接入真正的 Graph 工作流。
这次采用一个简单、适合你当前学习阶段的方案:在 AI 节点内部执行有限重试,再通过 Graph State 决定成功或失败的后续路线。

一、创建 RetryingAIAnalysisNode.java
放在 com.example.langgraph.multiagent 包下。
这个节点复用你已经写好的 AIAnalysisNode,因此不用重复编写 JSON 解析和等级校验逻辑。
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class RetryingAIAnalysisNode implements NodeAction<MultiAgentState> {
private final AIAnalysisAssistant assistant;
private final int maxAttempts;
public RetryingAIAnalysisNode(
AIAnalysisAssistant assistant,
int maxAttempts
) {
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts 必须大于 0");
}
this.assistant = assistant;
this.maxAttempts = maxAttempts;
}
@Override
public Map<String, Object> apply(MultiAgentState state) throws Exception {
// 复用已经完成的 JSON 解析、等级校验
AIAnalysisNode delegate = new AIAnalysisNode(assistant);
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
System.out.println("[RetryNode] 第 " + attempt + " 次尝试");
try {
Map<String, Object> result = delegate.apply(state);
// 当前 AIAnalysisNode 将普通模型异常转换为 MODEL_ERROR。
// 这里保守处理:不自动重试无法确认原因的失败。
return result;
} catch (RetryableAiException e) {
System.out.println("[RetryNode] 可重试异常:" + e.getMessage());
if (attempt == maxAttempts) {
return Map.of(
"resultCode",
AgentResultCode.MODEL_ERROR.name()
);
}
} catch (NonRetryableAiException e) {
System.out.println("[RetryNode] 不可重试:" + e.getMessage());
return Map.of(
"resultCode",
AgentResultCode.MODEL_ERROR.name()
);
}
}
return Map.of(
"resultCode",
AgentResultCode.MODEL_ERROR.name()
);
}
}
这里有一个值得注意的地方:你原来的 AIAnalysisNode 已经使用 catch (RuntimeException e) 把模型异常转换成了 MODEL_ERROR,所以它不会把这些异常继续抛给外层的重试节点。
因此上面的版本暂时还不能真正重试模型调用失败。 我们需要先调整异常处理边界。
二、修改 AIAnalysisNode 的异常处理
找到 AIAnalysisNode.java 中的:
catch (RuntimeException e) {
System.out.println(
"[AIAnalysisNode] 模型调用异常:" +
e.getClass().getSimpleName()
);
return Map.of(
"resultCode",
AgentResultCode.MODEL_ERROR.name()
);
}
在它前面增加两个 catch:
catch (RetryableAiException e) {
throw e;
} catch (NonRetryableAiException e) {
throw e;
}
最终顺序应该是:
catch (com.fasterxml.jackson.core.JsonProcessingException e) {
System.out.println("[AIAnalysisNode] JSON解析失败");
return Map.of(
"resultCode",
AgentResultCode.AI_OUTPUT_INVALID.name()
);
} catch (RetryableAiException e) {
throw e;
} catch (NonRetryableAiException e) {
throw e;
} catch (RuntimeException e) {
System.out.println(
"[AIAnalysisNode] 模型调用异常:" +
e.getClass().getSimpleName()
);
return Map.of(
"resultCode",
AgentResultCode.MODEL_ERROR.name()
);
}
这样,明确标记为可重试或不可重试的异常就会交给外层节点处理。其他未知异常仍保守地返回 MODEL_ERROR。
这一步属于异常处理契约调整。之前的 MultiAgentAiDemo 正常调用路径不受影响,但最好后续补充回归测试。
三、创建 RetryGraphDemo.java
这次不修改原来的 MultiAgentAiDemo,我们新建一个演示入口。
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.StateGraph;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
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 RetryGraphDemo {
public static void main(String[] args) throws Exception {
AtomicInteger calls = new AtomicInteger();
// 模拟前两次失败,第三次成功
AIAnalysisAssistant fakeAssistant = message -> {
int count = calls.incrementAndGet();
System.out.println("[FakeAI] 调用次数:" + count);
if (count <= 2) {
throw new RetryableAiException("模拟临时网络故障");
}
return """
{"level":"GOOD","analysis":"成绩表现良好"}
""";
};
var graph = new StateGraph<>(MultiAgentState::new);
graph.addNode("query", node_async(new QueryNode()));
graph.addNode(
"analysis",
node_async(new RetryingAIAnalysisNode(fakeAssistant, 3))
);
graph.addNode(
"advice",
node_async(new StructuredAdviceNode())
);
graph.addNode(
"failure",
node_async(new FailureNode())
);
graph.addEdge(START, "query");
graph.addConditionalEdges(
"query",
edge_async(state ->
AgentResultCode.SUCCESS.name()
.equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "analysis",
"failure", "failure"
)
);
graph.addConditionalEdges(
"analysis",
edge_async(state ->
AgentResultCode.SUCCESS.name()
.equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "advice",
"failure", "failure"
)
);
graph.addEdge("advice", END);
graph.addEdge("failure", END);
var compiled = graph.compile();
Map<String, Object> initialState = Map.of(
"studentId", 1001L,
"resultCode", "",
"analysis", "",
"analysisLevel", "",
"advice", ""
);
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
System.out.println("AI总调用次数:" + calls.get());
}
}
四、运行与验收
运行 RetryGraphDemo.main()。
预期关键输出(模拟):
[QueryNode] 查询学号:1001
[RetryNode] 第 1 次尝试
[FakeAI] 调用次数:1
[RetryNode] 可重试异常:模拟临时网络故障
[RetryNode] 第 2 次尝试
[FakeAI] 调用次数:2
[RetryNode] 可重试异常:模拟临时网络故障
[RetryNode] 第 3 次尝试
[FakeAI] 调用次数:3
[AIAnalysisNode] 等级:GOOD
[AIAnalysisNode] 分析:成绩表现良好
[StructuredAdviceNode] 等级:GOOD
[StructuredAdviceNode] 建议:建议保持当前学习节奏,并关注薄弱科目。
NodeOutput{ node=__END__, state={
resultCode=SUCCESS
analysisLevel=GOOD
...
}}
AI总调用次数:3
这次重点检查:第三次成功后,Graph 能否继续执行 StructuredAdviceNode 并正常结束。
五、14B 第三阶段知识题

别让兜底 catch 把它的"类型标签"抹掉。
最重要的是,你现在能区分两层职责:
RetryingAIAnalysisNode: 负责调用重试策略。
LangGraph4j 条件边: 根据最终结果决定进入建议节点还是失败节点。
需要注意:这次验证的是模拟异常后成功恢复,还没有验证三次全部失败时的 Graph 路由,也没有实现退避等待或真实网络超时控制。
第十四关 14B(第四阶段):验证重试耗尽后的失败路由
这一阶段只改一个地方,验证 Agent 不会在模型持续故障时无限重试。
第一步:修改 RetryGraphDemo
找到:
if (count <= 2) {
throw new RetryableAiException("模拟临时网络故障");
}
暂时修改为:
if (count <= 3) {
throw new RetryableAiException("模拟持续网络故障");
}
其他代码保持不变,maxAttempts 仍然是 3。
这样前三次调用都会失败。
第二步:重新运行 RetryGraphDemo
预期关键输出(模拟):
[RetryNode] 第 1 次尝试
[FakeAI] 调用次数:1
[RetryNode] 可重试异常:模拟持续网络故障
[RetryNode] 第 2 次尝试
[FakeAI] 调用次数:2
[RetryNode] 可重试异常:模拟持续网络故障
[RetryNode] 第 3 次尝试
[FakeAI] 调用次数:3
[RetryNode] 可重试异常:模拟持续网络故障
NodeOutput{ node=analysis, state={
resultCode=MODEL_ERROR
...
}}
===== 工作流执行失败 =====
学号:1001
错误码:MODEL_ERROR
NodeOutput{ node=failure, ... }
NodeOutput{ node=__END__, ... }
AI总调用次数:3
进程已结束,退出代码为 0
这次要验证三个关键条件:最多调用三次、进入 FailureNode、不执行 StructuredAdviceNode。
第三步:理解为什么不能无限重试
假设 Ollama 服务已经宕机,Agent 每次调用都失败。如果不设置重试上限,后端请求就可能一直占用线程或其他资源,最终影响整个系统。
实际项目中通常还需要结合超时、指数退避、熔断等机制,但当前先把最基础的重试上限与失败路由验证完整。
知识巩固

第十四关 14C:TraceId、日志与自动化测试
这一关我们开始学习真实 Java 后端开发中非常重要的可观测性(Observability)。
你之前已经接触过 TraceId。现在要把它用到多节点工作流中,让一次请求经过的每个节点都能关联起来。
一、14C 第一阶段:TraceId 在 Graph 中传递
假设有两个用户同时请求 Agent:
请求 A:查询学生1001
请求 B:查询学生1002
如果日志只打印:
[QueryNode] 查询成功
[AIAnalysisNode] 分析成功
当两个请求的日志交错时,就很难判断哪些日志属于同一次请求。
我们可以给每次请求分配一个 traceId。

这里的 traceId 是我们手动传递的请求标识,暂时不涉及 OpenTelemetry 或分布式链路追踪系统。
二、第一步:扩展 MultiAgentState
打开 MultiAgentState.java,增加:
public String traceId() {
return this.<String>value("traceId").orElse("");
}
这个方法和你之前写的 studentId()、resultCode() 一样,都是从 Graph State 读取数据。
三、第二步:创建 TraceQueryNode
为了不影响已经验收通过的 QueryNode,我们新建一个包装节点。
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class TraceQueryNode implements NodeAction<MultiAgentState> {
private final QueryNode delegate = new QueryNode();
@Override
public Map<String, Object> apply(MultiAgentState state) throws Exception {
System.out.println(
"[traceId=" + state.traceId() + "] QueryNode 开始"
);
Map<String, Object> result = delegate.apply(state);
System.out.println(
"[traceId=" + state.traceId() + "] QueryNode 结束"
);
return result;
}
}
这里使用的是你之前学过的**委托(Delegation)**思想。
TraceQueryNode 不重新实现查询业务,而是调用原来的 QueryNode。
不修改原来的 QueryNode 查询逻辑,而是在查询执行前后增加日志

四、第三步:创建 TraceDemo
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.StateGraph;
import java.util.Map;
import java.util.UUID;
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 TraceDemo {
public static void main(String[] args) throws Exception {
String traceId = UUID.randomUUID().toString();
var graph = new StateGraph<>(MultiAgentState::new);
graph.addNode("query", node_async(new TraceQueryNode()));
graph.addEdge(START, "query");
graph.addEdge("query", END);
var compiled = graph.compile();
Map<String, Object> initialState = Map.of(
"studentId", 1001L,
"traceId", traceId,
"resultCode", ""
);
System.out.println("本次请求 traceId:" + traceId);
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
}
}
这里暂时只保留 QueryNode,是为了先验证 TraceId 在 Graph State 中的传递机制。
五、运行验收
运行 TraceDemo.main()。
预期输出(模拟,UUID 每次不同):
本次请求 traceId:abc-123
NodeOutput{ node=__START__, state={
studentId=1001
traceId=abc-123
resultCode=
}}
[traceId=abc-123] QueryNode 开始
[QueryNode] 查询学号:1001
[traceId=abc-123] QueryNode 结束
NodeOutput{ node=query, state={
studentId=1001
traceId=abc-123
resultCode=SUCCESS
averageScore=86.5
}}
NodeOutput{ node=__END__, ... }
进程已结束,退出代码为 0
验收时重点看:开始日志、结束日志、Graph State 中的 traceId 是否一致。
六、知识巩固

第十四关 14C(第二阶段):TraceId 贯穿完整 Agent 工作流
上一阶段只有 QueryNode。这次把 TraceId 扩展到 AI 分析、建议生成和失败节点。
为了不破坏之前通过验收的代码,我们继续采用**包装节点(Decorator / Delegation)**的方式。
一、先理解设计

所有节点使用同一个 traceId,而不是每个节点重新生成 UUID。
二、第一步:创建通用包装节点
这次不再为每个节点单独写一个 Trace 类,而是写一个可以复用的包装器。
创建 TraceNode.java:
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.action.NodeAction;
import java.util.Map;
public class TraceNode implements NodeAction<MultiAgentState> {
private final String nodeName;
private final NodeAction<MultiAgentState> delegate;
public TraceNode(
String nodeName,
NodeAction<MultiAgentState> delegate
) {
this.nodeName = nodeName;
this.delegate = delegate;
}
@Override
public Map<String, Object> apply(MultiAgentState state) throws Exception {
String traceId = state.traceId();
long start = System.nanoTime();
System.out.printf(
"[traceId=%s] [%s] START%n",
traceId, nodeName
);
try {
Map<String, Object> result = delegate.apply(state);
long durationMs =
(System.nanoTime() - start) / 1_000_000;
System.out.printf(
"[traceId=%s] [%s] END durationMs=%d resultCode=%s%n",
traceId,
nodeName,
durationMs,
result.getOrDefault(
"resultCode",
state.resultCode()
)
);
return result;
} catch (Exception e) {
long durationMs =
(System.nanoTime() - start) / 1_000_000;
System.out.printf(
"[traceId=%s] [%s] ERROR durationMs=%d type=%s%n",
traceId,
nodeName,
durationMs,
e.getClass().getSimpleName()
);
throw e;
}
}
}
这段代码有三个重要知识点
-
包装器模式
delegate.apply(state);
真正的业务逻辑仍由原来的节点完成。
TraceNode 只负责增加日志和耗时统计。
System.nanoTime()
用于计算经过的时间,比用 currentTimeMillis() 计算耗时更合适。
try-catch后重新抛出异常
包装器记录错误,但不擅自吞掉异常,也不改变原来节点的业务行为。
注意:这是教学用控制台日志。生产项目通常使用 SLF4J、结构化日志和统一的请求上下文。
三、第二步:创建 TraceGraphDemo.java
为了避免调用真实 Ollama,这次使用一个固定返回 JSON 的假 AI。
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.StateGraph;
import java.util.Map;
import java.util.UUID;
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 TraceGraphDemo {
public static void main(String[] args) throws Exception {
String traceId = UUID.randomUUID().toString();
AIAnalysisAssistant fakeAssistant = message -> """
{"level":"GOOD","analysis":"成绩表现良好"}
""";
var graph = new StateGraph<>(MultiAgentState::new);
graph.addNode(
"query",
node_async(new TraceNode("query", new QueryNode()))
);
graph.addNode(
"analysis",
node_async(new TraceNode(
"analysis",
new RetryingAIAnalysisNode(fakeAssistant, 3)
))
);
graph.addNode(
"advice",
node_async(new TraceNode(
"advice",
new StructuredAdviceNode()
))
);
graph.addNode(
"failure",
node_async(new TraceNode(
"failure",
new FailureNode()
))
);
graph.addEdge(START, "query");
graph.addConditionalEdges(
"query",
edge_async(state ->
AgentResultCode.SUCCESS.name()
.equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "analysis",
"failure", "failure"
)
);
graph.addConditionalEdges(
"analysis",
edge_async(state ->
AgentResultCode.SUCCESS.name()
.equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "advice",
"failure", "failure"
)
);
graph.addEdge("advice", END);
graph.addEdge("failure", END);
var compiled = graph.compile();
Map<String, Object> initialState = Map.of(
"studentId", 1001L,
"traceId", traceId,
"resultCode", "",
"analysis", "",
"analysisLevel", "",
"advice", ""
);
System.out.println("本次请求 traceId:" + traceId);
for (var output : compiled.stream(initialState)) {
System.out.println(output);
}
}
}
四、运行验收
运行 TraceGraphDemo.main()。
预期关键输出(模拟):
本次请求 traceId:abc-123
[traceId=abc-123] [query] START
[QueryNode] 查询学号:1001
[traceId=abc-123] [query] END durationMs=2 resultCode=SUCCESS
[traceId=abc-123] [analysis] START
[RetryNode] 第 1 次尝试
[AIAnalysisNode] 等级:GOOD
[traceId=abc-123] [analysis] END durationMs=8 resultCode=SUCCESS
[traceId=abc-123] [advice] START
[StructuredAdviceNode] 等级:GOOD
[traceId=abc-123] [advice] END durationMs=1 resultCode=SUCCESS
NodeOutput{ node=__END__, state={
traceId=abc-123
resultCode=SUCCESS
analysisLevel=GOOD
...
}}
实际耗时会不同,不需要与模拟数字一致。
这次的验收标准是:三个业务节点日志中的 TraceId 完全一致,最终 State 保留 TraceId,且 Graph 正常结束。
五、知识巩固

第十四关 14C(第三阶段):JUnit 5 自动化测试
之前我们都是手动运行 main(),然后观察控制台。
现在要学习 Java 后端开发中非常重要的技能:让程序自动判断运行结果是否正确。
例如:
assertEquals("SUCCESS", resultCode);
如果实际结果不是 SUCCESS,JUnit 就会自动将测试判定为失败。
第一阶段任务:先让 JUnit 5 跑起来
这次只做一个简单测试,不立即修改现有 Graph 代码。
第一步:检查 pom.xml
打开项目的 pom.xml,在 <dependencies> 内确认是否已经有 JUnit Jupiter。
如果没有,添加:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
再检查 <build><plugins> 中是否配置 Maven Surefire Plugin。如果没有,可以添加:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
这里 JUnit Jupiter 负责编写和运行测试,Surefire 负责让 Maven 在 test 阶段发现并执行测试。
第二步:创建测试类
注意目录必须是:
langgraph4j-demo
├── src
│ ├── main
│ │ └── java
│ │ └── com/example/langgraph/multiagent
│ │
│ └── test
│ └── java
│ └── com/example/langgraph/multiagent
│ └── TraceIdTest.java
创建 TraceIdTest.java:
package com.example.langgraph.multiagent;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
public class TraceIdTest {
@Test
void shouldPreserveTraceIdInState() {
// Arrange:准备测试数据
String traceId = UUID.randomUUID().toString();
MultiAgentState state = new MultiAgentState(
Map.of("traceId", traceId)
);
// Act:执行被测试的方法
String actual = state.traceId();
// Assert:断言结果正确
assertEquals(traceId, actual);
assertFalse(actual.isBlank());
}
}
这里使用的是 AAA 测试结构:
Arrange --- 准备
创建测试数据与 State
Act --- 执行
调用 state.traceId()
Assert --- 验证
判断返回的 TraceId 是否与预期一致
第三步:运行测试
在 IDEA 终端中执行:
mvn -Dtest=TraceIdTest test
预期关键输出(模拟):
[INFO] Running com.example.langgraph.multiagent.TraceIdTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
如果出现构造方法不匹配,先不要随意修改 MultiAgentState 的业务逻辑,把错误发给我,我们根据你当前类的实际构造方法调整测试。
本阶段知识题

第十四关 14C(第四阶段):自动测试完整 Graph
接下来不再只测试 state.traceId(),而是让 JUnit 真正运行一次 LangGraph4j 工作流。
本阶段先完成成功路径自动化测试,之后再测试重试耗尽和失败路由。
第一步:创建 GraphSuccessTest.java
在 src/test/java/com/example/langgraph/multiagent/ 下创建:
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.StateGraph;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.UUID;
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;
import static org.junit.jupiter.api.Assertions.*;
class GraphSuccessTest {
@Test
void shouldCompleteGraphSuccessfully() throws Exception {
// Arrange:准备测试数据
String traceId = UUID.randomUUID().toString();
AIAnalysisAssistant fakeAssistant = message -> """
{"level":"GOOD","analysis":"成绩表现良好"}
""";
var graph = new StateGraph<>(MultiAgentState::new);
graph.addNode(
"query",
node_async(new TraceNode("query", new QueryNode()))
);
graph.addNode(
"analysis",
node_async(new TraceNode(
"analysis",
new RetryingAIAnalysisNode(fakeAssistant, 3)
))
);
graph.addNode(
"advice",
node_async(new TraceNode(
"advice",
new StructuredAdviceNode()
))
);
graph.addNode(
"failure",
node_async(new TraceNode("failure", new FailureNode()))
);
graph.addEdge(START, "query");
graph.addConditionalEdges(
"query",
edge_async(state ->
AgentResultCode.SUCCESS.name().equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "analysis",
"failure", "failure"
)
);
graph.addConditionalEdges(
"analysis",
edge_async(state ->
AgentResultCode.SUCCESS.name().equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "advice",
"failure", "failure"
)
);
graph.addEdge("advice", END);
graph.addEdge("failure", END);
var compiled = graph.compile();
Map<String, Object> initialState = Map.of(
"studentId", 1001L,
"traceId", traceId,
"resultCode", "",
"analysis", "",
"analysisLevel", "",
"advice", ""
);
// Act:运行 Graph,并获取最后一个节点的状态
MultiAgentState finalState = null;
for (var output : compiled.stream(initialState)) {
finalState = new MultiAgentState(output.state().data());
}
// Assert:验证最终业务结果
assertNotNull(finalState);
assertEquals("SUCCESS", finalState.resultCode());
assertEquals("GOOD", finalState.analysisLevel());
assertEquals(traceId, finalState.traceId());
assertFalse(finalState.advice().isBlank());
}
}
这里的 output.state().data() 是根据你之前使用的 NodeOutput 状态对象设计的写法。如果 IDEA 对 data() 报错,先把错误发给我,不要为了通过测试随意修改 Graph 的核心实现。
第二步:运行测试
在 IDEA 终端执行:
mvn -Dtest=GraphSuccessTest test
预期关键结果(模拟):
[INFO] Running com.example.langgraph.multiagent.GraphSuccessTest
[traceId=...] [query] START
[traceId=...] [query] END durationMs=0 resultCode=SUCCESS
[traceId=...] [analysis] START
[traceId=...] [analysis] END durationMs=... resultCode=SUCCESS
[traceId=...] [advice] START
[traceId=...] [advice] END durationMs=0 resultCode=SUCCESS
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
第三步:理解这次测试的价值
上一阶段:
assertEquals(traceId, state.traceId());
只是验证一个 State 方法。
这一阶段:
assertEquals("SUCCESS", finalState.resultCode());
assertEquals("GOOD", finalState.analysisLevel());
assertEquals(traceId, finalState.traceId());
assertFalse(finalState.advice().isBlank());
则验证整个工作流执行后的业务结果。
这就是从单元测试逐步过渡到工作流集成测试。
不过,这个测试使用假 AI,不会验证真实 Ollama 的可用性;而且当前只验证最终状态,还没有直接断言实际经过了哪些节点。
本阶段知识题

第十四关 14C(第五阶段):自动验证失败路由
你已经验证了成功路径,现在要验证另一种情况:
如果 AI 连续失败三次,Graph 是否能正确停止重试并进入 FailureNode?
这次使用 JUnit 自动判断,不再依靠肉眼检查日志。
第一步:创建 GraphFailureTest.java
文件位置:
src/test/java/com/example/langgraph/multiagent/GraphFailureTest.java
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.StateGraph;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.ArrayList;
import java.util.List;
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;
import static org.junit.jupiter.api.Assertions.*;
class GraphFailureTest {
@Test
void shouldStopAfterThreeFailures() throws Exception {
// Arrange:准备数据
String traceId = UUID.randomUUID().toString();
AtomicInteger calls = new AtomicInteger();
AIAnalysisAssistant fakeAssistant = message -> {
calls.incrementAndGet();
throw new RetryableAiException("模拟网络持续故障");
};
var graph = new StateGraph<>(MultiAgentState::new);
graph.addNode(
"query",
node_async(new TraceNode("query", new QueryNode()))
);
graph.addNode(
"analysis",
node_async(new TraceNode(
"analysis",
new RetryingAIAnalysisNode(fakeAssistant, 3)
))
);
graph.addNode(
"advice",
node_async(new TraceNode(
"advice",
new StructuredAdviceNode()
))
);
graph.addNode(
"failure",
node_async(new TraceNode(
"failure",
new FailureNode()
))
);
graph.addEdge(START, "query");
graph.addConditionalEdges(
"query",
edge_async(state ->
AgentResultCode.SUCCESS.name().equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "analysis",
"failure", "failure"
)
);
graph.addConditionalEdges(
"analysis",
edge_async(state ->
AgentResultCode.SUCCESS.name().equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "advice",
"failure", "failure"
)
);
graph.addEdge("advice", END);
graph.addEdge("failure", END);
var compiled = graph.compile();
Map<String, Object> initialState = Map.of(
"studentId", 1001L,
"traceId", traceId,
"resultCode", "",
"analysis", "",
"analysisLevel", "",
"advice", ""
);
// Act:执行 Graph
MultiAgentState finalState = null;
List<String> visitedNodes = new ArrayList<>();
for (var output : compiled.stream(initialState)) {
visitedNodes.add(output.node());
finalState = new MultiAgentState(output.state().data());
}
// Assert:自动判断结果
assertNotNull(finalState);
// 1. 最多调用三次
assertEquals(3, calls.get());
// 2. 最终返回模型错误
assertEquals(
AgentResultCode.MODEL_ERROR.name(),
finalState.resultCode()
);
// 3. TraceId 没有丢失
assertEquals(traceId, finalState.traceId());
// 4. 确实进入失败节点
assertTrue(visitedNodes.contains("failure"));
// 5. 没有错误地执行建议节点
assertFalse(visitedNodes.contains("advice"));
// 6. Graph 正常到达 END
assertTrue(visitedNodes.contains(END));
}
}
第二步:理解这次新增的断言
与上一关不同,这次不仅验证最终 State,还验证实际经过的节点。
List<String> visitedNodes = new ArrayList<>();
它记录 Graph 的执行轨迹。
例如,失败路径应该是:
__START__
query
analysis
failure
__END__
然后通过:
assertTrue(visitedNodes.contains("failure"));
assertFalse(visitedNodes.contains("advice"));
验证路由正确。
这是一个很重要的进步:最终状态正确,不代表执行过程一定正确。
例如,某个错误实现可能先执行 AdviceNode,最后再把 resultCode 改成 MODEL_ERROR。如果只检查最终错误码,就可能漏掉这种问题。
第三步:运行测试
在 IDEA 终端执行:
mvn -Dtest=GraphFailureTest test
预期关键输出(模拟):
[RetryNode] 第 1 次尝试
[RetryNode] 可重试异常:模拟网络持续故障
[RetryNode] 第 2 次尝试
[RetryNode] 可重试异常:模拟网络持续故障
[RetryNode] 第 3 次尝试
[RetryNode] 可重试异常:模拟网络持续故障
[traceId=...] [analysis] END ... resultCode=MODEL_ERROR
===== 工作流执行失败 =====
错误码:MODEL_ERROR
[traceId=...] [failure] START
[traceId=...] [failure] END ...
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
第四步:知识巩固

第十四关 14C(最终阶段):运行完整回归测试
现在把之前写的三个测试一起执行,验证它们能否同时通过。
第一步:执行 Maven 命令
在 IDEA 的 PowerShell 终端执行:
mvn '-Dtest=TraceIdTest,GraphSuccessTest,GraphFailureTest' test
这里使用单引号包住 -Dtest 参数,避免 PowerShell 对包含逗号的参数产生解析问题。
三个测试分别验证:
| 测试类 | 验证目标 |
|---|---|
TraceIdTest |
State 正确保存 TraceId |
GraphSuccessTest |
Agent 成功执行并生成建议 |
GraphFailureTest |
重试耗尽后进入失败路由 |
预期结果(模拟)
[INFO] Running com.TraceIdTest
[INFO] Tests run: 1, Failures: 0, Errors: 0
[INFO] Running com.example.langgraph.multiagent.GraphSuccessTest
[INFO] Tests run: 1, Failures: 0, Errors: 0
[INFO] Running com.example.langgraph.multiagent.GraphFailureTest
[INFO] Tests run: 1, Failures: 0, Errors: 0
[INFO] Results:
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
注意:这是预期示例,是否真正通过以你本机的 Maven 日志为准。
第二步:理解回归测试
回归测试的作用是:当你修改某个功能后,检查以前正常的功能有没有被意外破坏。
例如,未来你修改 RetryingAIAnalysisNode,增加指数退避等待。
虽然你只修改了重试逻辑,但也可能影响成功路径和失败路由。
因此修改后重新运行三个测试,可以帮助你发现问题。
第三步:解决一个非阻塞问题
你目前的 Maven 日志存在中文乱码:
[QueryNode] 鏌ヨ瀛﹀彿...
可以先在 PowerShell 执行:
chcp 65001
$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
然后重新运行测试,观察乱码是否改善。若没有改善,我们再排查 Maven 启动的 JVM 编码及输出文件编码,不需要为了这个问题修改 Graph 业务代码。
最后一道知识题

第十四关知识总结
你现在已经掌握:
-
统一错误码:使用
AgentResultCode管理业务结果。 -
异常分类:区分可重试和不可重试异常。
-
有限重试:限制 AI 调用次数,避免无限循环。
-
TraceId:关联同一次 Graph 执行的日志。
-
包装器模式:为业务节点统一增加日志和耗时统计。
-
JUnit 5:通过断言验证成功路径和失败路径。
-
回归测试:修改代码后重新运行已有测试,检查是否破坏原有功能。
这些知识已经从单纯的代码讲解进入了真实运行和自动化验证阶段。
第十五关:Agent 工程化综合验收
这一关不急着增加新框架,而是把你已经学过的内容整理成一个更接近真实 Java 后端项目的 Agent 模块。

第十五关我们按三个阶段推进:先整理测试和工程结构,再抽取可复用的 Graph 构建逻辑,最后做综合场景验收。
第一阶段:清理重复测试,执行全项目测试
先处理目前明确发现的问题,不改动业务逻辑。
-
检查
src/test/java/com/TraceIdTest.java和src/test/java/com/example/langgraph/multiagent/TraceIdTest.java,确认是否重复。 -
如果内容相同,保留后者,删除前者。
-
执行完整 Maven 测试:
mvn clean test
这次与之前的命令不同:不使用 -Dtest 限定测试类,而是执行项目中所有能被 Surefire 发现的测试。
验收标准: 所有测试通过、Failures: 0、Errors: 0、BUILD SUCCESS。
如果其他历史练习测试失败,我们就根据具体失败原因排查,不直接删除测试来让构建变绿。
三、进入第十五关前的理解题

第十五关 · 第二阶段:抽取可复用的 Agent Graph
你现在的 TraceGraphDemo、GraphSuccessTest、GraphFailureTest 有一个明显的工程问题:
三处都在重复编写 Graph 的节点和边。
例如:
graph.addNode("query", ...);
graph.addNode("analysis", ...);
graph.addNode("advice", ...);
graph.addNode("failure", ...);
graph.addEdge(START, "query");
graph.addConditionalEdges(...);
将来你增加一个权限检查节点,就需要同时修改多份代码,容易遗漏。
我们的目标是把 Graph 构建逻辑抽取成一个专门的类。

第一步:创建 GradeAgentGraphFactory.java
在 com.example.langgraph.multiagent 包下创建:
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.StateGraph;
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;
import java.util.Map;
public class GradeAgentGraphFactory {
public static StateGraph<MultiAgentState> create(
AIAnalysisAssistant assistant
) throws Exception {
var graph = new StateGraph<>(MultiAgentState::new);
graph.addNode(
"query",
node_async(new TraceNode("query", new QueryNode()))
);
graph.addNode(
"analysis",
node_async(new TraceNode(
"analysis",
new RetryingAIAnalysisNode(assistant, 3)
))
);
graph.addNode(
"advice",
node_async(new TraceNode(
"advice",
new StructuredAdviceNode()
))
);
graph.addNode(
"failure",
node_async(new TraceNode(
"failure",
new FailureNode()
))
);
graph.addEdge(START, "query");
graph.addConditionalEdges(
"query",
edge_async(state ->
AgentResultCode.SUCCESS.name()
.equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "analysis",
"failure", "failure"
)
);
graph.addConditionalEdges(
"analysis",
edge_async(state ->
AgentResultCode.SUCCESS.name()
.equals(state.resultCode())
? "success" : "failure"
),
Map.of(
"success", "advice",
"failure", "failure"
)
);
graph.addEdge("advice", END);
graph.addEdge("failure", END);
return graph;
}
}
这里先返回 StateGraph<MultiAgentState>,让调用方决定何时 compile(),方便你理解构建与编译的区别。
第二步:修改 GraphSuccessTest
只替换之前测试中创建 Graph、添加节点和边的那一整段代码。
原来:
var graph = new StateGraph<>(MultiAgentState::new);
// 大量 addNode、addEdge、addConditionalEdges ...
var compiled = graph.compile();
改成:
var graph = GradeAgentGraphFactory.create(fakeAssistant);
var compiled = graph.compile();
其余的初始 State、stream() 和 JUnit 断言全部保持不变。
暂时不要修改 GraphFailureTest,我们先验证工厂类是否正确,再逐步迁移。
第三步:运行测试
mvn '-Dtest=GraphSuccessTest' test
预期结果(模拟):
[INFO] Running com.example.langgraph.multiagent.GraphSuccessTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
如果通过,就说明我们成功把 Graph 的构建职责从测试代码中分离出来,而且没有破坏成功路径。
第四步:理解设计思想
这里涉及两个 Java 后端常见概念:
- 单一职责原则(SRP)
GradeAgentGraphFactory 负责构建 Graph;GraphSuccessTest 负责验证 Graph。各自只承担一类主要职责。
-
依赖注入思想
GradeAgentGraphFactory.create(fakeAssistant);
工厂不直接创建真实 Ollama 模型,而是接收一个 AIAnalysisAssistant。
因此同一个 Graph 可以使用真实 AI,也可以使用测试中的假 AI。
这与 Spring 的依赖注入理念相通,但这里采用的是普通 Java 方法参数传递,并没有使用 Spring 容器。
本阶段知识题

重点
真实 AI 和假 AI 是什么意思?
假设你的接口是:
public interface AIAnalysisAssistant {
String analyze(String gradeData);
}
下面是简化示例,实际接口以你项目中的定义为准。
情况 A:真实 AI
AIAnalysisAssistant realAssistant =
AiServices.create(
AIAnalysisAssistant.class,
ollamaChatModel
);
var graph = GradeAgentGraphFactory.create(realAssistant);
运行到分析节点时,会调用配置好的真实模型。
情况 B:假 AI(用于测试)
AIAnalysisAssistant fakeAssistant =
gradeData -> "该学生成绩良好,建议继续努力";
var graph = GradeAgentGraphFactory.create(fakeAssistant);
这里假设接口只有上面那个抽象方法,因此可以使用 Lambda。
这个假 AI 不会请求 Ollama,而是直接返回预先写好的内容。
四、你会发现一个关键点

第十五关 · 第三阶段:统一 Graph 构建与回归测试
上一阶段我们只修改了 GraphSuccessTest。
现在继续完成重构:让 GraphFailureTest 也复用同一个工厂类。
第一步:修改 GraphFailureTest
打开:
src/test/java/com/example/langgraph/multiagent/GraphFailureTest.java
找到原来创建 Graph、添加节点、添加条件边的整段代码。
将其替换为:
var graph = GradeAgentGraphFactory.create(fakeAssistant);
var compiled = graph.compile();
其他部分全部保持不变,尤其是:
AtomicInteger calls = new AtomicInteger();
AIAnalysisAssistant fakeAssistant = message -> {
calls.incrementAndGet();
throw new RetryableAiException("模拟网络持续故障");
};
以及测试断言:
assertEquals(3, calls.get());
assertEquals("MODEL_ERROR", finalState.resultCode());
assertEquals(traceId, finalState.traceId());
assertTrue(visitedNodes.contains("failure"));
assertFalse(visitedNodes.contains("advice"));
assertTrue(visitedNodes.contains(END));
这样,两个测试使用相同的 Graph 结构,只是注入的 AI 行为不同。

第二步:运行完整回归测试
执行:
mvn clean test
这次我们要检查:
-
成功路径没有被破坏。
-
失败路径仍然重试三次。
-
TraceId 仍然正确。
-
之前的 Channel 和 Agent 测试也没有受到影响。
根据上一轮基线,预期是 5 个测试全部通过,但最终以你的真实 Maven 输出为准。
第三步:理解这次重构的价值
以前:
// GraphSuccessTest 中构建一遍 Graph
// GraphFailureTest 中再构建一遍 Graph
// TraceGraphDemo 中又构建一遍 Graph
现在:
var graph = GradeAgentGraphFactory.create(assistant);
这样以后新增权限检查节点,只需要修改工厂中的 Graph 构建逻辑,调用方就能使用新的工作流结构。
但要注意,工厂统一后,测试会共享同一份 Graph 结构定义,因此更需要通过不同的假 AI、不同输入来覆盖成功和失败场景。
本阶段知识题

第十五关 · 第四阶段:抽取 AgentService
现在我们已经有:
GradeAgentGraphFactory
↓
构建 Graph
↓
compile()
↓
stream()
↓
获取最终 State
但是目前调用方仍然需要自己编译 Graph、创建初始 State、遍历输出。
真实后端开发通常不希望 Controller 直接操作这些底层细节。
因此,下一步我们要抽取 GradeAgentService,负责统一执行工作流并返回业务结果。
第一步:理解 Service 的职责

这样做的好处是:以后无论你通过 HTTP、定时任务还是其他 Java 服务调用 Agent,都可以复用同一个 GradeAgentService。
第二步:创建统一返回对象
在 com.example.langgraph.multiagent 包下创建:
GradeAgentResult.java
package com.example.langgraph.multiagent;
public record GradeAgentResult(
String traceId,
String resultCode,
Double averageScore,
String analysisLevel,
String analysis,
String advice
) {
}
这里使用 Java 21 支持的 record,它适合承载不可变的数据结果。
例如:
GradeAgentResult result = new GradeAgentResult(
"trace-001",
"SUCCESS",
86.5,
"GOOD",
"成绩表现良好",
"建议保持当前学习节奏"
);
它不负责执行业务,只负责保存结果。
第三步:创建 GradeAgentService
创建:
GradeAgentService.java
package com.example.langgraph.multiagent;
import org.bsc.langgraph4j.CompiledGraph;
import org.bsc.langgraph4j.GraphStateException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class GradeAgentService {
private final CompiledGraph<MultiAgentState> graph;
public GradeAgentService(
AIAnalysisAssistant assistant
) throws GraphStateException {
this.graph = GradeAgentGraphFactory
.create(assistant)
.compile();
}
public GradeAgentResult execute(Long studentId) {
String traceId = UUID.randomUUID().toString();
Map<String, Object> initialState = new HashMap<>();
initialState.put("studentId", studentId);
initialState.put("traceId", traceId);
initialState.put("resultCode", "");
initialState.put("analysis", "");
initialState.put("analysisLevel", "");
initialState.put("advice", "");
MultiAgentState finalState = null;
for (var output : graph.stream(initialState)) {
finalState = new MultiAgentState(
output.state().data()
);
}
if (finalState == null) {
throw new IllegalStateException(
"Graph 未产生任何输出"
);
}
return new GradeAgentResult(
finalState.traceId(),
finalState.resultCode(),
finalState.averageScore(),
finalState.analysisLevel(),
finalState.analysis(),
finalState.advice()
);
}
}
这里的 graph.stream(initialState) 沿用你目前 LangGraph4j 1.9.3 练习项目中已验证的调用方式。它有弃用警告,后续再单独迁移新 API。
如果你的 GradeAgentGraphFactory.create() 目前声明了 throws Exception,请先按 IDEA 的编译提示调整异常声明,不要为了消除异常而直接吞掉它。
第四步:理解为什么构造器只编译一次
注意这段代码:
private final CompiledGraph<MultiAgentState> graph;
以及:
this.graph = GradeAgentGraphFactory
.create(assistant)
.compile();
我们把 Graph 编译放在构造器里,而不是每次执行 execute() 都重新编译。
也就是说:
-
创建
GradeAgentService时,构建并编译 Graph。 -
每次调用
execute()时,使用新的初始 State 执行工作流。
这是在练习初始化一次、重复使用的设计思想。这里不意味着已经验证了并发安全;多线程复用和 Checkpoint 配置需要另外测试。
第五步:本阶段先验证编译
执行:
mvn -DskipTests compile
这条命令只验证主代码编译,不代表 JUnit 测试通过。
如果编译成功,我们下一阶段再编写 GradeAgentServiceTest,验证 Service 返回的业务结果。
本阶段知识题

第五阶段:GradeAgentService 自动化测试
1. 本阶段目标
以前我们测试的是 Graph 本身:
var graph = GradeAgentGraphFactory.create(fakeAssistant);
var compiled = graph.compile();
现在我们要测试封装后的 Service:
GradeAgentService service = new GradeAgentService(fakeAssistant);
GradeAgentResult result = service.execute(1001L);
这就更接近真实后端开发了。
2. 创建测试类
在 IDEA 中打开:
src/test/java/com/example/langgraph/multiagent/
右键该包 → New → Java Class → 输入 GradeAgentServiceTest。
粘贴以下代码:
package com.example.langgraph.multiagent;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class GradeAgentServiceTest {
@Test
void shouldReturnSuccessResult() throws Exception {
// 1. 使用假 AI,不依赖真实 Ollama
AIAnalysisAssistant fakeAssistant = message ->
"""
{
"level": "GOOD",
"analysis": "成绩表现良好"
}
""";
// 2. 创建 Service
GradeAgentService service =
new GradeAgentService(fakeAssistant);
// 3. 执行业务
GradeAgentResult result = service.execute(1001L);
// 4. 验证业务结果
assertNotNull(result);
assertEquals(
AgentResultCode.SUCCESS.name(),
result.resultCode()
);
assertEquals("GOOD", result.analysisLevel());
assertEquals("成绩表现良好", result.analysis());
assertNotNull(result.advice());
assertFalse(result.advice().isBlank());
// 5. 验证 TraceId 是合法 UUID
assertNotNull(result.traceId());
assertDoesNotThrow(
() -> UUID.fromString(result.traceId())
);
}
@Test
void shouldReturnModelErrorAfterRetries() throws Exception {
// 1. 记录 AI 实际调用次数
AtomicInteger calls = new AtomicInteger();
AIAnalysisAssistant fakeAssistant = message -> {
calls.incrementAndGet();
throw new RetryableAiException("模拟网络故障");
};
// 2. 创建 Service
GradeAgentService service =
new GradeAgentService(fakeAssistant);
// 3. 执行业务
GradeAgentResult result = service.execute(1001L);
// 4. 验证重试与最终错误码
assertNotNull(result);
assertEquals(3, calls.get());
assertEquals(
AgentResultCode.MODEL_ERROR.name(),
result.resultCode()
);
// 5. 失败时仍然具有 TraceId
assertNotNull(result.traceId());
assertDoesNotThrow(
() -> UUID.fromString(result.traceId())
);
}
}
3. 这两个测试有什么区别?
| 测试方法 | 验证内容 |
|---|---|
shouldReturnSuccessResult() |
Service 正确返回分析结果与建议 |
shouldReturnModelErrorAfterRetries() |
Service 在 AI 持续故障时正确返回错误码 |
注意:这里模拟的是 AI 的正常响应和网络故障,不是对真实 Ollama 服务的集成测试。
4. 运行测试
在 PowerShell 中执行:
mvn '-Dtest=GradeAgentServiceTest' test
预期结果(模拟):
[INFO] Running com.example.langgraph.multiagent.GradeAgentServiceTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
这次我们先验证 Service 层,不急着修改 Controller,也不接入学校项目。
5. 本阶段知识题

第六阶段:最终回归测试
目前你的工程结构已经形成:

现在需要确认:新增 Service 后,原有测试是否仍然正常。
第一步:执行完整测试
在 IDEA 的 PowerShell 终端运行:
mvn clean test
上一轮有 5 个测试,这次新增了 2 个 Service 测试。
因此预期是:
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
这是预期结果,不是已经完成的真实验证。
第二步:理解为什么还要完整测试
你已经分别验证了:
-
Graph 层:节点连接、条件路由、重试机制。
-
Service 层:业务入口、执行工作流、统一返回结果。
但是,单独测试通过,不代表所有测试一起运行也一定通过。
例如,未来某个测试修改了共享状态,就可能影响其他测试。
因此我们需要完整回归测试来检查这些问题。
第三步:本阶段知识题

第十六关:Spring Boot 集成 Agent
现在进入新的学习阶段。
我们要把已经写好的 GradeAgentService 变成一个真正可以通过 HTTP 调用的后端服务。
一、先理解新的架构

二、第十六关分为四个阶段
| 阶段 | 学习内容 | 验收方式 |
|---|---|---|
| 16A | Spring Boot 项目配置 | Maven 编译 |
| 16B | Bean、依赖注入与配置类 | Spring 容器启动 |
| 16C | REST Controller | Apifox 请求 |
| 16D | HTTP 集成测试 | JUnit + 真实 HTTP |
我们仍然使用独立的 langgraph4j-demo 练习项目,不修改学校学工系统。
三、第一步:检查现有 pom.xml
在 IDEA 中打开 pom.xml,检查是否已经存在:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
如果已经存在,暂时不要重复添加。
如果没有,也先不要直接复制添加,因为还需要检查 Spring Boot 的版本管理配置,例如是否使用 Spring Boot Parent 或 BOM。
这一阶段先不写 Controller。 我们先确认 Maven 依赖,避免 Spring Boot 版本与现有 LangChain4j、LangGraph4j 依赖产生冲突。
四、本关知识题

一、现有 pom.xml 分析
| 配置 | 当前情况 | 处理 |
|---|---|---|
| Java | 21 | 保留 |
| LangGraph4j | 1.9.3 | 保留 |
| LangChain4j | 1.22.0 BOM | 保留 |
| Spring Boot | 尚未引入 | 新增 |
| SLF4J Simple | 2.0.17 | 移除,避免日志实现冲突 |
| JUnit Jupiter | 5.11.4 | 暂时保留 |
| Maven Surefire | 3.5.2 | 保留 |
我们先使用 Spring Boot 3.5.x 系列进行练习。这里以 3.5.7 为明确版本,不需要改变你原来的 LangGraph4j 和 LangChain4j 版本。
二、第一步:增加 Spring Boot 版本管理
找到原来的:
<properties>
<maven.compiler.release>21</maven.compiler.release>
<maven.compiler.parameters>true</maven.compiler.parameters>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<langchain4j.version>1.22.0</langchain4j.version>
</properties>
增加一行:
<spring-boot.version>3.5.7</spring-boot.version>
然后找到 dependencyManagement,改成:
<dependencyManagement>
<dependencies>
<!-- Spring Boot 依赖版本管理 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- LangChain4j 依赖版本管理 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>${langchain4j.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
这里要理解一个知识点:
BOM 负责管理依赖版本,不会自动把所有依赖添加到项目中。
所以还需要下一步。
三、第二步:增加 Spring Boot Web 依赖
在 <dependencies> 中增加:
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
它会引入 Spring MVC、嵌入式 Tomcat,以及相关的 Web 和日志依赖。
因此,原来的:
<!-- 日志 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
</dependency>
请删除这个依赖块。
Spring Boot Web Starter 默认使用 Logback。保留 slf4j-simple 可能造成多个 SLF4J 日志实现同时存在。
四、第三步:Maven 验证
保存 pom.xml 后,在 IDEA 右侧 Maven 面板点击 Reload All Maven Projects。
然后运行:
mvn clean test
重点检查三个结果:
-
Spring Boot 依赖能够正常下载和解析。
-
原有的 7 个测试仍然通过。
-
最后出现
BUILD SUCCESS。
如果出现依赖冲突或测试异常,我们再根据日志修复,不要一次性升级所有框架。
五、为什么暂时不添加 Spring Boot Parent?
Spring Boot 通常有两种版本管理方式:
| 方式 | 说明 |
|---|---|
spring-boot-starter-parent |
管理依赖版本,同时提供 Maven 插件等默认配置 |
spring-boot-dependencies BOM |
主要管理依赖版本,适合逐步接入现有 Maven 项目 |
你现在已经有自己的 Maven 插件配置和 LangChain4j BOM,因此我们先采用第二种方式,改动更小。
但要注意,只导入 Spring Boot BOM 不会自动配置 Spring Boot Maven 插件。后续需要启动和打包 Spring Boot 应用时,我们再补充插件配置。
六、本阶段知识题

第十六关 · 16B:Spring Boot 容器与依赖注入
本阶段只做三件事:创建启动类、配置 Bean、启动 Spring Boot。暂时不写 Controller。
一、创建 Spring Boot 启动类
在以下目录创建:
src/main/java/com/example/langgraph/LanggraphApplication.java
package com.example.langgraph;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LanggraphApplication {
public static void main(String[] args) {
SpringApplication.run(
LanggraphApplication.class,
args
);
}
}
这里的 @SpringBootApplication 包含组件扫描能力。由于启动类位于 com.example.langgraph 包下,因此可以扫描其子包中的 Spring 组件。
二、创建 Spring 配置类
在 com.example.langgraph.multiagent 包下创建:
GradeAgentConfiguration.java
package com.example.langgraph.multiagent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class GradeAgentConfiguration {
@Bean
public AIAnalysisAssistant aiAnalysisAssistant() {
return message -> """
{
"level": "GOOD",
"analysis": "成绩表现良好"
}
""";
}
@Bean
public GradeAgentService gradeAgentService(
AIAnalysisAssistant assistant
) throws Exception {
return new GradeAgentService(assistant);
}
}
注意:这里使用的是模拟 AI,目的是先验证 Spring Bean 的依赖注入,不需要启动 Ollama。
依赖注入过程
你可以把 @Bean 理解为:告诉 Spring,这个方法返回的对象需要交给容器管理。
而:
public GradeAgentService gradeAgentService(
AIAnalysisAssistant assistant
)
其中的 assistant 会由 Spring 根据类型寻找对应的 Bean 并注入。
这与我们之前学习的普通 Java 工厂方法有关,但多了一层 Spring 容器管理。
三、添加 Spring Boot Maven 插件
在 pom.xml 的 <build><plugins> 中增加:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
由于当前项目使用的是 BOM 而不是 Spring Boot Parent,因此显式指定插件版本。
四、启动 Spring Boot
先执行:
mvn clean test
确认原有测试没有回归问题。
然后执行:
mvn spring-boot:run
预期出现类似日志(模拟):
Tomcat started on port 8080 (http)
Started LanggraphApplication in 2.1 seconds
如果 8080 端口被占用,可以在 src/main/resources/application.properties 中配置其他端口,例如:
server.port=8082
这一步成功后,就证明 Spring Boot Web 容器能够启动,并且 GradeAgentService 可以通过 Spring 配置创建。
五、本阶段知识题

第十六关 · 16C:编写第一个 Agent REST 接口
现在我们把已经实现的 GradeAgentService 暴露为 HTTP 接口,让 Apifox 可以调用。
1. 理解 Controller 的作用

2. 创建 Controller
在 IDEA 中创建:
src/main/java/com/example/langgraph/multiagent/GradeAgentController.java
package com.example.langgraph.multiagent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/agent/grade")
public class GradeAgentController {
private final GradeAgentService gradeAgentService;
public GradeAgentController(
GradeAgentService gradeAgentService
) {
this.gradeAgentService = gradeAgentService;
}
@GetMapping("/{studentId}")
public GradeAgentResult analyzeGrade(
@PathVariable Long studentId
) {
return gradeAgentService.execute(studentId);
}
}
逐行理解
| 代码 | 作用 |
|---|---|
@RestController |
声明 REST 控制器,返回对象会序列化为 JSON |
@RequestMapping |
定义接口公共路径 |
@GetMapping("/{studentId}") |
处理指定路径的 GET 请求 |
@PathVariable |
将 URL 中的学号绑定到方法参数 |
private final |
保存构造器注入的 Service 引用 |
最重要的是这段:
public GradeAgentController(
GradeAgentService gradeAgentService
) {
this.gradeAgentService = gradeAgentService;
}
Spring 创建 Controller 时,会自动寻找 GradeAgentService Bean,并将它传入构造器。
这叫做构造器依赖注入。
3. 重新启动 Spring Boot
如果上一轮 mvn spring-boot:run 仍在运行,先按 Ctrl + C 停止。
然后重新运行:
mvn spring-boot:run
等待出现:
Tomcat started on port 8080 (http)
Started LanggraphApplication
4. 使用 Apifox 测试
接口测试配置
GET
http://localhost:8080/api/agent/grade/1001
复制
请求体:无。当前使用模拟 AI,不需要 Ollama。
在 Apifox 中新建 HTTP 接口,方法选择 GET,填写上述 URL,点击发送。
预期响应(模拟,实际 traceId 会不同):
{
"traceId": "b72d35e0-7b51-4d14-aefa-25d1c9b5a123",
"resultCode": "SUCCESS",
"averageScore": 85.0,
"analysisLevel": "GOOD",
"analysis": "成绩表现良好",
"advice": "建议保持当前学习节奏,并关注薄弱科目。"
}
这里的 averageScore 是演示值,实际以 QueryNode 的数据为准。当前接口没有接入真实学生数据库,不能用于查询真实学生成绩。
5. 本阶段知识题

Controller 负责接收 HTTP 请求和返回响应,Service 负责业务逻辑,GraphFactory 负责构建工作流。这就是我们一直在学习的分层设计与职责分离。
第十六关 · 16D:Spring Boot HTTP 集成测试
这一阶段要解决一个问题:
既然 Apifox 已经测试成功,为什么还要写 HTTP 集成测试?
因为 Apifox 主要用于手动验证,而 JUnit 可以在每次修改代码后自动检查接口是否正常。
一、先理解测试层次

二、第一步:添加测试依赖
打开 pom.xml,在 <dependencies> 中增加:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
原有 junit-jupiter 依赖先保留,避免同时进行不必要的依赖调整。
三、第二步:创建 HTTP 集成测试
文件位置:
src/test/java/com/example/langgraph/multiagent/GradeAgentControllerHttpTest.java
package com.example.langgraph.multiagent;
import com.example.langgraph.LanggraphApplication;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(
classes = LanggraphApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
class GradeAgentControllerHttpTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldReturnSuccessFromHttp() {
ResponseEntity<GradeAgentResult> response =
restTemplate.getForEntity(
"/api/agent/grade/1001",
GradeAgentResult.class
);
assertEquals(200, response.getStatusCode().value());
GradeAgentResult result = response.getBody();
assertNotNull(result);
assertEquals("SUCCESS", result.resultCode());
assertEquals("GOOD", result.analysisLevel());
assertEquals(86.5, result.averageScore());
assertNotNull(result.traceId());
assertNotNull(result.advice());
assertFalse(result.advice().isBlank());
}
}
关键知识点
@SpringBootTest 会启动 Spring Boot 测试上下文。
RANDOM_PORT 会使用随机可用端口启动嵌入式 Web 服务器,避免与正在运行的 8080 端口冲突。
TestRestTemplate 会发送真实 HTTP 请求,而不是直接调用 Controller 方法。
这里使用 86.5 作为断言,是根据你刚才的实际 Apifox 响应设置的。若后续更改了 QueryNode 的模拟成绩,需要同步调整测试期望值。
四、第三步:运行测试
在 IDEA 的 PowerShell 终端运行:
mvn '-Dtest=GradeAgentControllerHttpTest' test
预期(模拟):
Running GradeAgentControllerHttpTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
如果单独测试通过,再运行:
mvn clean test
预期测试总数从 7 增加到 8 个。
五、本阶段知识题

第十七关:真实 Ollama 模型接入
接下来我们开始把模拟 AI 替换为真实模型。
不过不直接替换现有配置。我们先学习 Spring Boot 的 @Profile,让模拟环境和真实环境可以切换。
第一阶段:理解为什么需要 Profile
目前 GradeAgentConfiguration 中:
@Bean
public AIAnalysisAssistant aiAnalysisAssistant() {
return message -> """
{
"level": "GOOD",
"analysis": "成绩表现良好"
}
""";
}
无论传入什么成绩,都会返回固定的模拟结果。
真实开发中,我们希望:
| 环境 | AI 实现 | 用途 |
|---|---|---|
mock |
固定模拟结果 | JUnit、HTTP 自动化测试 |
ollama |
真实本地模型 | 实际 AI 分析 |
Spring Profile 可以帮助我们根据环境选择不同的 Bean 实现,避免每次切换都手动修改 Java 代码。
第一项任务:为现有模拟 Bean 增加 Profile
打开 GradeAgentConfiguration.java,在 aiAnalysisAssistant() 方法上添加 @Profile("mock"):
import org.springframework.context.annotation.Profile;
@Bean
@Profile("mock")
public AIAnalysisAssistant aiAnalysisAssistant() {
return message -> """
{
"level": "GOOD",
"analysis": "成绩表现良好"
}
""";
}
gradeAgentService() 方法暂时不需要修改。
接下来在:
src/main/resources/application.properties
添加:
spring.profiles.default=mock
这样,普通启动时默认使用模拟 AI。
后续真实 Ollama 配置将使用 @Profile("ollama"),而自动化测试会明确指定 mock 环境,避免误调用真实模型。
本阶段先只做这两处修改。
本阶段知识题

完成修改后运行:
mvn clean test
第十七关 · 第二阶段:接入真实 Ollama
这一阶段我们要实现:

这就是面向接口编程的好处:Service 只依赖 AIAnalysisAssistant 接口,不关心具体使用模拟实现还是真实模型实现。
第一步:创建 Ollama 配置文件
在 src/main/resources 下新建:
application-ollama.properties
ollama.base-url=http://localhost:11434
ollama.model-name=llama3.1:8b
这里使用你之前练习的本地 Ollama 地址和模型名称。运行时仍需要确认本机模型是否存在。
第二步:创建真实 AI 配置类
在 com.example.langgraph.multiagent 包下新建:
OllamaAgentConfiguration.java
package com.example.langgraph.multiagent;
import dev.langchain4j.model.ollama.OllamaChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import java.time.Duration;
@Configuration
@Profile("ollama")
public class OllamaAgentConfiguration {
@Bean
public AIAnalysisAssistant ollamaAnalysisAssistant(
@Value("${ollama.base-url}") String baseUrl,
@Value("${ollama.model-name}") String modelName
) {
OllamaChatModel model = OllamaChatModel.builder()
.baseUrl(baseUrl)
.modelName(modelName)
.timeout(Duration.ofSeconds(120))
.build();
return message -> model.chat(message);
}
}
这里有一个关键设计:
OllamaChatModel 负责真正调用 Ollama,而 Lambda 将模型调用适配为 AIAnalysisAssistant 接口。
不过目前我们还没有确认模型的返回格式是否符合 AIAnalysisNode 的 JSON 解析要求,因此这一阶段先验证 Bean 创建和模型连接,不直接宣称完整分析流程已经通过。
第三步:检查 Ollama 是否正常运行
打开新的 PowerShell 终端,执行:
ollama list
确认能看到 llama3.1:8b。
然后执行:
curl.exe http://localhost:11434/api/tags
如果 Ollama 正常运行,应返回包含模型信息的 JSON。
第四步:启动真实模型环境
保留原有 application.properties 中的:
spring.profiles.default=mock
运行时通过命令行覆盖默认环境:
mvn spring-boot:run '-Dspring-boot.run.profiles=ollama'
如果配置正确,启动日志应显示:
The following 1 profile is active: "ollama"
这意味着 Spring 会使用 OllamaAgentConfiguration 创建真实 AI Bean,而不是模拟 Bean。
注意: 当前只要求 Spring Boot 能在 ollama 环境下启动。真正的模型推理将在下一阶段单独验证。
第五步:知识题

第十七关 · 第三阶段:真实模型推理验证
这一阶段,我们不急着修改 Graph,而是先验证整条 HTTP 调用链能否真正调用 Ollama。
第一步:检查 Ollama
打开一个新的 PowerShell 终端,执行:
ollama list
确认有 llama3.1:8b,再执行:
curl.exe http://localhost:11434/api/tags
如果能返回模型列表,说明 Ollama HTTP 服务可以访问。
第二步:通过 Apifox 调用真实模型
保持 Spring Boot 的 ollama Profile 运行。
GET
真实 AI 分析接口
http://localhost:8080/api/agent/grade/1001
复制
第一次真实模型请求可能需要较长时间,因为 Ollama 需要加载模型。
现在的调用链应该是:

第三步:观察返回结果
真实模型可能出现以下情况:
| 结果 | 含义 | 下一步 |
|---|---|---|
SUCCESS |
模型输出被成功解析 | 验证分析内容 |
AI_OUTPUT_INVALID |
模型返回格式不符合约定 | 优化 Prompt 和 JSON 校验 |
MODEL_ERROR |
可能是调用异常、超时或重试耗尽 | 检查异常日志 |
| HTTP 500 | 请求处理过程中出现未处理异常 | 查看 Spring Boot 堆栈 |
由于目前 OllamaAgentConfiguration 只是直接调用 model.chat(message),模型不一定严格输出 JSON。
因此,如果第一次出现 AI_OUTPUT_INVALID,不代表 Ollama 没有连接成功。 我们需要结合模型原始输出和异常日志判断。
另外,现有 Controller 还没有正式的权限验证和学生身份校验,所以本阶段只使用练习数据,不要传入真实学生的敏感信息。
第四步:知识巩固

