LangGraph4j 实战(下)

一、剩余课程总规划

关卡 小节 核心学习内容
第 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

一、本节学习目标

上一节我们设计了整体架构,现在开始写代码。

本节只完成三件事:

  1. 创建 GradeAgentState,统一保存工作流状态。

  2. 创建 ResultNode,处理成功结果。

  3. 创建 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,重点观察:

  1. AiNode 是否执行。

  2. LLM 是否实际调用 GradeTools。

  3. Conditional Edge 是否进入 ResultNode。

  4. 是否到达 END。

  5. 工具审计和指标是否正确。

把 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,运行一次。

把控制台输出发给我,我们重点检查三个地方:

  1. GradeTools 是否拒绝了无权限查询。

  2. 审计是否记录 PERMISSION_DENIED。

  3. 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

本关重点验证两个条件:

  1. Graph State 正确传递 sessionId 和 question。

  2. 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 的平均成绩,分析他的学习情况,并给出学习建议。

这个任务包含三个不同的职责:

  1. 查询 Agent:负责获取成绩数据。

  2. 分析 Agent:根据成绩数据分析学习情况。

  3. 建议 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 分析节点

这一关我们做两个升级:

  1. 查询失败时,直接进入失败节点,不再生成学习建议。

  2. 查询成功时,让 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");
        }
    }
}

这次做了三层检查:

  1. 检查成绩数据是否有效。

  2. 检查 JSON 能否解析、字段是否合法。

  3. 检查 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);
    }
}

这里有三个重要知识点

  1. AtomicInteger

用于安全地进行原子计数。本例是顺序调用,普通 int 也能实现;这里使用 AtomicInteger 是为了让 Lambda 内部能够更新计数器,并顺便认识并发工具类。

  1. maxAttempts = 3

表示最多调用三次,而不是第一次调用后再重试三次。

  1. 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;
        }
    }
}

这段代码有三个重要知识点

  1. 包装器模式

    delegate.apply(state);

真正的业务逻辑仍由原来的节点完成。

TraceNode 只负责增加日志和耗时统计。

  1. System.nanoTime()

用于计算经过的时间,比用 currentTimeMillis() 计算耗时更合适。

  1. 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 构建逻辑,最后做综合场景验收。

第一阶段:清理重复测试,执行全项目测试

先处理目前明确发现的问题,不改动业务逻辑。

  1. 检查 src/test/java/com/TraceIdTest.java 和 src/test/java/com/example/langgraph/multiagent/TraceIdTest.java,确认是否重复。

  2. 如果内容相同,保留后者,删除前者。

  3. 执行完整 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 后端常见概念:

  1. 单一职责原则(SRP)

GradeAgentGraphFactory 负责构建 Graph;GraphSuccessTest 负责验证 Graph。各自只承担一类主要职责。

  1. 依赖注入思想

    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

重点检查三个结果:

  1. Spring Boot 依赖能够正常下载和解析。

  2. 原有的 7 个测试仍然通过。

  3. 最后出现 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 还没有正式的权限验证和学生身份校验,所以本阶段只使用练习数据,不要传入真实学生的敏感信息。

第四步:知识巩固

相关推荐
天诰33 分钟前
SA-K-11 检索会消失吗与四个未解问题
java·服务器·人工智能·python·机器学习·ai编程
这个昵称不容易4 小时前
SpringBoot「衣依」服装销售平台:144个Java文件180个接口,从挑衣服到收货评价整条链路都在
java·springboot·毕设
计算机毕设定制辅导-无忧学长5 小时前
《基于微信小程序的居家医疗陪护预约系统设计与实现》
java·spring boot·微信小程序·uniapp·居家医疗陪护预约系统
文丑颜不良啊6 小时前
LeetCode2221~2240题目解析
java·leetcode
秋饼6 小时前
Computer Use Agent Java 落地实战:感知-行动循环、执行器与安全围栏生产级实现
java·ai·技术分享·后端开发
文丑颜不良啊8 小时前
LeetCode2161~2180题目解析
java·leetcode
文丑颜不良啊10 小时前
LeetCode2141~2160题目解析
java·leetcode
LabVIEW开发10 小时前
LabVIEW 连接板:28 个端子怎么排,优先级怎么定
java·算法·labview
天天被压力10 小时前
【A股分钟级高频数据完全手册 #07】北交所历史分钟K线接口:京市Pro版分时批量
java·人工智能·python