AI| Spring AI Practice

简单使用

新建工程

首先我们需要新建一个项目,直接去Spring官方文档下面的Initializer设置

  • Spring 3.5.11
  • JDK17
  • Web Starter + Zhipu AI Starter

压缩包下下来之后解压IDE打开,获取智谱的api-key,放在配置文件

1
2
3
4
5
6
7
8
9
spring:
application:
name: ai-demo
ai:
zhipuai:
api-key: foo
chat:
options:
model: GLM-4-Flash

调用接口

在启动类包下新建一个Controller(还是Spring的规范)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class ChatController {

private final ZhiPuAiChatModel chatModel;

@Autowired
public ChatController(ZhiPuAiChatModel chatModel) {
this.chatModel = chatModel;
}

@GetMapping("/ai/generate")
public Map generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
return Map.of("generation", chatModel.call(message));
}

@GetMapping("/ai/generateStream")
public Flux<ChatResponse> generateStream(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
var prompt = new Prompt(new UserMessage(message));
return chatModel.stream(prompt);
}
}

之后进入接口测试即可获取模型返回

Prompt

角色

在了解 Prompt 之前我们需要先知道角色

在 Spring AI 中,将大模型和用户之间的交互流程抽象成了四种角色:

  • system: 系统角色,通常我们预设的提示词会和 system 角色关联
  • user: 用户角色,用于表示用户输入的文本,通常输入的提问会和 user 角色关联
  • assistant: 助手角色,用于表示模型生成的文本,通常大模型生成的答案会和 assistant 角色关联
  • tool: 工具角色,用于表示模型调用的函数返回的内容,会和 tool 角色关联

因此,如果希望调用时给模型定义身份,可以修改Prompt实例的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@GetMapping("/ai/generate")
public Map generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {

Prompt prompt = new Prompt(
//定义提示,包含系统预设和用户输入
Arrays.asList(new SystemMessage("你现在是一个专注于解决编码问题的助手"), new UserMessage(message)),
//定义模型调用的参数,如模型名称、温度、用户名称
ZhiPuAiChatOptions.builder()
.model(ZhiPuAiApi.ChatModel.GLM_4_Flash.getValue())
.temperature(0.7d)
.user("trevorlink")
.build()
);

Generation generation = chatModel.call(prompt).getResult();
return Map.of("generation", generation == null ? "" : generation.getOutput().getText());
}

PromptTemplate

在提示词有复用的场景,可以考虑Spring AI提供的提示词模板功能:根据固定的模板,在程序运行时结合Map动态替换修改。

这个简单了解即可,只需要知道:

  • 提示词模板实例可以生成系统提示词也可以生成用户提示词

  • 提示词具体的内容除了字符串硬编码,还可以通过配置文件定义后注入的方式兼容Spring生态

  • 一般通过提示词模板promptTemplate.create创建提示词,默认是创建UserMessage类型的消息;如果我们希望创建的是系统提示词可以使用SystemPromptTemplate

    1
    2
    3
    SystemPromptTemplate promptTemplate = new SystemPromptTemplate("我们现在开始角色扮演的聊天,你来扮演{personality}的{aiRole}, 我来扮演{myRole}");
    Message systemMsg = promptTemplate.createMessage(Map.of("personality", personality, "aiRole", aiRole, "myRole", myRole));
    Prompt prompt = new Prompt(systemMsg, new UserMessage(msg));

具体可以参考

结构转换

正常来说大模型返回的结构都是没有结构的,我们需要显式进行将返回的数据指定转换为业务对象模型

ChatClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
public class StructureChatController {

private final ZhiPuAiChatModel chatModel;

public StructureChatController(ZhiPuAiChatModel chatModel) {
this.chatModel = chatModel;
}

@GetMapping("/ai/queryFilms")
public ActorsFilms queryFilms(@RequestParam(value = "actor") String actor) {
PromptTemplate template = new PromptTemplate("帮我返回五个{actor}导演的电影名,要求中文返回");
Prompt prompt = template.create(Map.of("actor", actor));
return ChatClient.create(chatModel)
.prompt(prompt)
.call()
.entity(ActorsFilms.class);
}

}

//record 关键字: 用于声明包含指定 final 属性的类的语法糖,自带getter和setter
record ActorsFilms(String actor, List<String> films) {}

BeanOutputConverter

也可以显式使用BeanOutputConverter基于ChatModel来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@RestController
public class StructureChatController {

private final ZhiPuAiChatModel chatModel;

public StructureChatController(ZhiPuAiChatModel chatModel) {
this.chatModel = chatModel;
}

@GetMapping("/ai/queryFilms2")
public ActorsFilms queryFilms2(@RequestParam(value = "actor") String actor) {
BeanOutputConverter<ActorsFilms> converter = new BeanOutputConverter<>(ActorsFilms.class);
String format = converter.getFormat();

PromptTemplate template = new PromptTemplate("""
帮我返回五个{actor}导演的电影名
{format}
""");
Prompt prompt = template.create(Map.of("actor", actor, "format", format));
Generation generation = chatModel.call(prompt).getResult();
if (generation == null) {
return null;
}
return converter.convert(generation.getOutput().getText());
}

}

record ActorsFilms(String actor, List<String> films) {}

这种方式虽然需要在 Prompt 模板中显式指定格式,但是准确性更可控

总结

使用 ChatClient 的方式本质上是通过Advisor从上下文获取信息注入到提示词, 而 BenaOutputConverter 则是利用 Spring AI 的 Converter 具体实现.

前者使用上来说更简单,但是后者通过在 Prompt 模板中指定格式,准确性更可控

上下文

几乎所有聊天式大模型产品实现 “多轮对话” 的真实基础前提是:大模型本身是无状态的。

大模型(Transformer 推理过程)不保存任何历史记忆。每一次调用 API / 跑一次前向传播,都是独立、全新的计算。

因此在和大模型的对话时,需要我们把之前的对话内容也一并传给大模型,即:对于大模型而言,你的一次新的对话,它实际上把你们之前的所有对话都过了一遍;更专业一点的说法是 你们的对话 是基于一个上下文,这个上下文会包含你之前和模型交互的所有内容。

若希望实现多轮对话,则每次和模型进行对话时,需要将之前和模型交互的所有内容都传递给模型,这样模型才能基于这些内容进行多轮的沟通。

针对这一个概念抽象,Spring AI 的做法是提供了 ChatMemory bean 供开发者使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RestController
public class ContextChatController {

private final ZhiPuAiChatModel chatModel;

private final ChatMemory chatMemory;

private final ChatClient chatClient;

public ContextChatController(ZhiPuAiChatModel chatModel, ChatMemory chatMemory) {
this.chatModel = chatModel;
this.chatMemory = chatMemory;
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("你现在是狂放不羁的诗仙李白,我们现在开始对话") //系统Prompt
.defaultAdvisors(new SimpleLoggerAdvisor(ModelOptionsUtils::toJsonStringPrettyPrinter, ModelOptionsUtils::toJsonStringPrettyPrinter, 0), //配置日志Advisor
MessageChatMemoryAdvisor.builder(chatMemory).build()) // 添加上下文
.build();
}

@GetMapping("/ai/generateContext")
public Object generate(@RequestParam(value = "msg", defaultValue = "你好") String msg){
return chatClient.prompt(msg).call().content();
}
}

由于SimpleLoggerAdvisor默认使用DEBUG的日志界别,需要在配置文件中设置日志级别

1
2
3
logging:
level:
org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor: DEBUG

之后尝试两轮对话,便可以在控制台日志中看到API调用模型的时候传递了第一轮中的上下文内容

会话隔离

上面的代码虽然实现了上下文传递,但是任何请求没有任何区别地都共用同一个上下文。多个用户之间会话内容会相互干扰,比如用户 A 和用户 B 进行对话,用户 B 的会话内容会干扰用户 A 的会话内容。显然是不现实的。

在真实的场景中需要做好身份隔离,我们希望在记忆库中检索历史对话时,可以有一个区分。

Spring AI 针对这一抽象,同样采用 Advisor 来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@RestController
public class ContextChatController {

private final ZhiPuAiChatModel chatModel;

private final ChatMemory chatMemory;

private final ChatClient sessionClient;

public ContextChatController(ZhiPuAiChatModel chatModel, ChatMemory chatMemory) {
this.chatModel = chatModel;
this.chatMemory = chatMemory;
this.sessionClient = ChatClient.builder(chatModel)
.defaultSystem("你现在是{role},我们显示开始对话")
.defaultAdvisors(new SimpleLoggerAdvisor(ModelOptionsUtils::toJsonStringPrettyPrinter, ModelOptionsUtils::toJsonStringPrettyPrinter, 0),
// 每次交互时从记忆库检索历史消息,并将其作为消息集合注入提示词
MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();

}

@GetMapping("/ai/sessionContext")
public Object sessionContext(
@RequestParam(value = "user") String user,
@RequestParam(value = "role") String role,
@RequestParam(value = "msg") String msg) {
return sessionClient.prompt()
// 系统词模板填充
.system(sp -> sp.param("role", role))
.user(msg)
// 设置会话ID,实现单独会话,这里基于user参数,实际业务可以动态调整
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, user))
.call()
.content();
}
}

手动管理上下文

上面的 demo 中我们通过 ChatClient 对话,没有任何额外操作就实现了上下文的自动管理。如果对上下文有需求,Spring AI 也提供了接口,可以使用 ChatModel 组合 ChatMemory 来手动管理。

其他上下文传递方式Advisor

上面我们的demo中在实例化ChatClient的时候一直都用的MessageChatMemoryAdvisor这个上下文传递方式,这种 Advisor 会把历史内容简单追加到 UserMessage

Spring默认还提供了其他的传递方式:

  • PromptChatMemoryAdvisor: 不同于MessageChatMemoryAdvisor将多轮对话(包含内容、角色)以用户提示词的形式返回给大模型,PromptChatMemoryAdvisor主要是将历史消息内容以文本的方式追加到系统提示词中。这种方式中,List<Message>中只会有两个元素,一个本次提问的UserMessage,另一个包含所有历史内容的SystemMessage;而MessageChatMemoryAdvisor把历史内容追加到UserMessage的形式,List<Message>中会有很多UserMessage(历史对话)
  • VectorStoreChatMemoryAdvisor: 通过指定 VectorStore 实现管理会话记忆。每次交互时从向量数据库检索历史对话,并以纯文本形式追加至系统(system)消息。

小总结

由于大模型的无状态性,需要在Agent端框架实现上下文管理的抽象。

Spring AI 默认提供了多种 Advisor 以实现上下文传递(注入)方式。在实例化 ChatClient 的时候指定对应 Advisor,就会以不同形式(不同字段入口)在请求消息中传递上下文内容。

Spring AI还提供了通过 ChatModel + ChatMemory 手动管理上下文的接口。

除了上下文管理抽象,AI应用程序框架还需要实现会话隔离功能。对于上下文会话隔离,Spring AI 的实现方式是通过指定 ChatClient 的 Advisor 中 CONVERSATION_ID 参数 进行区分。

Function Call

快速开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@RestController
public class FunctionCallController {

private final ChatClient chatClient;

public FunctionCallController(ZhiPuAiChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.build();
}

@RequestMapping(path = "time")
public String getTime(String msg) {
return chatClient.prompt(msg).tools(new DateTimeTools()).call().content();
}

@RequestMapping(path = "timeNoTools")
public String getTimeNoTools(String msg) {
return chatClient.prompt(msg).call().content();
}

}

class DateTimeTools {

//关键!
@Tool(description = "Get the current date and time in the user's timezone")
String getCurrentDateTime() {
String ans = LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
System.out.println("进入[获取当前时间]的工具了:" + ans);
return ans;
}
}

访问接口http://localhost:8080/timeNoTools?msg="get the date"

1
当然可以。请问您需要查询什么时间?是当前时间、某个特定的时间点,还是世界各地的时区时间?请提供更多信息,以便我能更好地帮助您。

访问接口http://localhost:8080/time?msg="get the date"

1
当前时间是2026年3月26162816秒。

再来一次

1
2
好的,我可以通过一个API来获取您当前时区的时间。请问您需要获取哪个时区的时间呢?
//这表示模型未能正常调用我们编写的Tools

说明由于模型的自然语言理解能力存在偏差,同样的消息,同样的的逻辑,模型有时不会主动调用我们注入的工具。

因此在编写 Tool 的时候,需要严格定义description来帮助大模型判断跟调用,英文中文均可。

定义参数

在上面的demo基础上,我们尝试为Tools定义附加参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class DateTimeTools {
//追加新方法
@Tool(description = "传入时区,返回对应时区的当前时间给用户")
String getTimeByZoneId(@ToolParam(description = "需要查询时间的时区") ZoneId area) {
// 根据时区,查询对应的时间
ZonedDateTime time = LocalDateTime.now().atZone(area);
// 转换为 2025-07-26 20:00:00 格式的字符串
// 将输入时区的时间转换为本地时区
ZonedDateTime localTime = time.withZoneSameInstant(ZoneId.systemDefault());
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String ans = localTime.format(formatter);
System.out.println("传入的时区是:" + area + "-" + ans);
return ans;
}
}

之后询问指定地区的时间,大模型会自动理解指定地区对应的时区,然后传递正确的时区参数 调用getTimeByZoneId这个方法

整体流程

  • 在请求大模型之前,应用程序把可用的工具(包含工具/函数名称,参数列表)信息传递给大模型(给大模型提供说明书)
  • 携带用户输入内容的请求到大模型后,大模型理解用户输入,根据已有的工具信息列表,发送包含工具名称、具体请求参数的请求给应用程序服务
    • 注意!大模型本身不会执行具体的代码,不会自己调用接口,它只输出一个结构化调用意图:比如上面的{name: "getTimeByZoneId", params: {area: "日本"}}
  • 应用程序根据工具名称,识别到对应的工具,将参数传递调用工具方法(这一步其实就是Spring AI 实现的抽象)
    • 对应上一条,真正发 HTTP、查数据库、执行代码的是后端服务(应用程序),不是大模型
  • 工具执行结果返回给应用程序进行处理(可以是直接给用户,也可以是给大模型)
  • 大模型利用工具返回的结果,构建最终结果给用户

小总结

在实现 Function Call 的 Tool 时,编写意图明确、便于理解的Tool Description至关重要,直接关系到大模型是否命中调用。

对于一次使用 Function Call 的模型对话交互流程中的各个细节需要明确。

上述我们都是介绍声明式(注解),对于第三方库/包的 Tools 往往采用编程式,这种使用上的用的时候参考官方配置文档。一般采用Spring提供的FunctionToolcallback来注入工具供Spring AI进行识别。

MCP

Http sse实现MCP服务端

通过Spring AI 创建 MCP 服务器非常快,在引入 MCP 依赖后,在配置文件中新增配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring:
ai:
mcp:
server:
name: date-server
version: 1.0.0
type: SYNC # 服务器类型
instructions: "提供获取不同时区的当前时间,并按照北京时间进行展示"
sse-message-endpoint: /mcp/messages # 客户端发送消息(大模型调用工具时)连接点
sse-endpoint: /sse # MCP Client连接点
capabilities:
tool: true # 是否支持工具
resource: true # 是否支持资源
prompt: true # 是否支持提示词
completion: true # 是否支持补全

之后只需要编写类似 Function Call 一样的逻辑,只不过这一次是独立抽取在 Service 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class DateService {

@Tool(description = "根据指定的时区参数,获取对应时区的当前时间")
public String getTimeByZoneId(@ToolParam(description = "需要查询时间的时区") ZoneId area) {
// 根据系统当前时间,获取指定时区的时间
ZonedDateTime time = ZonedDateTime.now(area);

// 格式化时间
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String ans = time.format(formatter);
System.out.println("传入的时区是:" + area + "-" + ans);
return ans;
}

}

确保 MCP 服务核心工具通过 Service 注入到 Spring 容器后,将工具注册到 MCP 服务上

SpringAI的自动配置内部实现逻辑会检测并注册来自以下组件的所有工具回调

  • 独立的 ToolCallback Bean
  • ToolCallback Bean List
  • ToolCallbackProvider Bean

需要注意工具按名称去重

1
2
3
4
5
6
7
8
9
@Configuration
public class ToolConfig {

@Bean
public ToolCallbackProvider dateProvider(DateService dateService) {
return MethodToolCallbackProvider.builder().toolObjects(dateService).build();
}

}

之后启动应用,可以看到在日志中已经打印出成功注册的 Tool 数量,说明已经将工具注册到 MCP 服务中了

测试使用

这里我们使用 OpenCode 作为客户端工具来连接我们上面编写的 MCP 服务,由于我们上面在配置文件中已经定义了 sse endpoint,因此在配置客户端的时候也需要保持一致对上

按照 opencode 官方文档的要求规范,在项目目录新增配置文件供 OpenCode 读取

1
2
3
4
5
6
7
8
9
10
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"date-server": {
"type": "remote",
"url": "http://localhost:8080/sse",
"enabled": true
}
}
}

启动 OpenCode 并选择对应项目工程,此时会 OpenCode 会自动连接 MCP 服务,在日志中也能看到请求建立成功

小总结

所以现在再来看,什么是MCP?

本质上MCP只是一种接口协议,而且是应用层的协议。本身跟大模型无关,LLM也不需要去关心MCP。MCP本身可以理解为跟USB接口差不多的功能。

如果说MCP 客户端就是电脑主机,那么MCP服务/工具就像是鼠标键盘。没办法做到只靠一个电脑主机本身就输入文本或是进行操作交互,而是电脑主机(主板)预留USB接口,鼠标键盘实现USB接口后,进行连接才对电脑主机的功能进行扩展。同理 AI 领域中 LLM 跟外部工具/数据就是通过跟 遵循MCP协议的不同服务(用户应用程序为客户端,工具服务为服务端)进行交互,来实现对大模型能力的扩展。

MCP客户端

新建一个模块,还是基础的SpringMVC+Spring AI的依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependencies>
<!--MCP 客户端-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<!--模板渲染-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>io.github.wimdeblauwe</groupId>
<artifactId>htmx-spring-boot-thymeleaf</artifactId>
<version>3.4.0</version>
</dependency>
</dependencies>

这次不再引入 mcp server 而是引入 mcp client 的依赖,配置上也不一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
spring:
application:
name: time-client
ai:
zhipuai:
api-key: your-key
chat:
options:
model: GLM-4.7-Flash
mcp:
client:
sse:
connections:
global-date-times:
# 这里连接上本地的mcp服务,用于获取当前时间
url: http://localhost:8080/sse
enabled: true
name: time-mcp
version: 1.0.0
request-timeout: 30s
type: async


# 修改日志级别
logging:
level:
org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor: debug

server:
port: 8081

简单的前端页面模板 由MCP客户端服务渲染

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
<!-- Thymeleaf -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>支持MCP Client的聊天对话框</title>
<script src="https://unpkg.com/htmx.org@1.9.12"
integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2"
crossorigin="anonymous"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
function scrollToBottom(element) {
document.getElementById('message').value = ''
element.scrollTop = element.scrollHeight;
}
</script>
</head>
<body class="h-screen bg-gradient-to-br from-indigo-50 to-purple-50">
<div class="flex h-full max-w-6xl mx-auto">

<main class="flex flex-col p-4 w-full">
<header class="mb-6 py-4 border-b border-gray-200">
<h1 class="text-3xl font-bold leading-none tracking-tight text-indigo-800">
🤖 MCP Client Chat
</h1>
<p class="text-gray-600 mt-1">与AI助手进行智能对话</p>
</header>

<div id="chat" class="flex-1 mb-4 p-4 rounded-2xl bg-white shadow-sm overflow-auto">
<!-- 消息将在这里显示 -->
</div>

<div class="bg-white rounded-2xl shadow-lg p-4">
<form
class="w-full"
hx-post="/ask"
hx-swap="beforeend"
hx-target="#chat"
hx-indicator="#loading-indicator"
hx-on="htmx:beforeRequest:
document.getElementById('message').disabled = true;
document.getElementById('submit-btn').disabled = true;
document.getElementById('submit-btn').classList.add('opacity-50', 'cursor-not-allowed');
htmx:afterRequest:
document.getElementById('message').value = '';
document.getElementById('message').disabled = false;
document.getElementById('submit-btn').disabled = false;
document.getElementById('submit-btn').classList.remove('opacity-50', 'cursor-not-allowed');
scrollToBottom(document.getElementById('chat'));">
<div class="flex items-center rounded-full bg-gray-100 p-2 shadow-inner">
<input type="text" name="message" id="message"
class="bg-transparent outline-none text-gray-700 rounded-full py-3 px-4 w-full"
placeholder="输入消息..."/>
<button type="submit"
class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-full p-3 ml-2 transition duration-200 relative">
📤
<span id="loading-indicator"
class="htmx-indicator absolute inset-0 flex items-center justify-center"><span
class="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></span></span>
</button>
</div>
</form>
</div>
</main>
</div>
<style>
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

.animate-spin {
animation: spin 1s linear infinite;
}

.htmx-indicator {
display: none;
}

.htmx-request .htmx-indicator {
display: flex;
}

.htmx-request.htmx-indicator {
display: flex;
}
</style>
</body>
</html>

chat.html

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="chatFragment" class="mb-8">
<div class="inline-block bg-blue-300 rounded-lg p-2 ml-auto" th:text="${question}">Message</div>
<p class="mt-4 h-full overflow-auto" th:text="${response}">Response</p>
</div>
</body>
</html>

之后编写服务逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.demo.controller;

@Controller
public class ChatController {
private final ChatClient chatClient;

public ChatController(ChatModel chatModel, ToolCallbackProvider toolCallbackProvider) {
System.out.println("当前注册的工具数量: " + toolCallbackProvider.getToolCallbacks().length);
this.chatClient = ChatClient.builder(chatModel)
// 将mcp client 作为大模型的工具来使用
.defaultToolCallbacks(toolCallbackProvider)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(MessageWindowChatMemory.builder().build()).build(),
new SimpleLoggerAdvisor())
.build();
}

/**
* 首页
*
* @param model
* @return
*/
@GetMapping("/")
public String index(Model model) {
return "index";
}

/**
* 用户问答
*
* @param message
* @param model
* @return
*/
@PostMapping("/ask")
public HtmxResponse chat(String message, Model model) {
String res = this.chatClient.prompt(message).call().content();
model.addAttribute("question", message);
model.addAttribute("response", res);
// 返回 chat.html 中 chatFragment 经过渲染后的内容,用于对话的填充
return HtmxResponse.builder().view("chat :: chatFragment").build();
}
}

至此,访问localhost:8081/会渲染 index.html这个页面,输入天气相关问题就会得到回答

架构分析

对于正常的企业级Web应用而言,构建 Agent 体系的架构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
      前端应用 
React / Vue / 移动端 / 桌面端
(只负责 UI 渲染 + 用户交互)
|
|
HTTP / SSE
|
|

后端服务 (AI Gateway)
ChatClient | MCP Client
(编排层) | (协议层)
- 对话管理 │- 工具发现
- 上下文 │- 工具调用
- 记忆 │- 结果返回
| |
| |
HTTPS SSE
| |
▼ ▼
大模型API MCP服务们
(时间服务/数据库服务/第三方API)

Langgraph4j

快速上手

在了解基本概念之前,我们先通过一个简单的 demo 体验一下

引入依赖,注意版本兼容,这里可能会和Spring AI 1.1.2 以后的版本不兼容,[参见](#AssistantMessage NoSuchMethod)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<langgraph4j.version>1.6.0-rc4</langgraph4j.version>
</properties>

<dependencies>
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-springai-agentexecutor</artifactId>
<version>${langgraph4j.version}</version>
</dependency>
</dependencies>

还是编写一个Tool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class TimeWeatherTools {
@Tool(description = "传入时区,返回对应时区的当前时间给用户")
public String getTimeByZoneId(@ToolParam(description = "需要查询时间的时区,如Asia/Shanghai, Europe/Paris") ZoneId area) {
// 根据系统当前时间,获取指定时区的时间
ZonedDateTime time = ZonedDateTime.now(area);

// 格式化时间
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String ans = time.format(formatter);
System.out.println("传入的时区是:" + area + "-" + ans);
return ans;
}

@Tool(description = "传入地点,返回对应地点的当前天气给用户")
public String getWeatherByZoneId(@ToolParam(description = "需要查询天气的地区,如北京、上海") String area) {
List<String> weathers = List.of("晴", "阴", "雨", "雪", "雷", "雾");
String ans = weathers.get((int) (Math.random() * weathers.size()));
System.out.println("传入的地点是:" + area + "-" + ans);
return ans;
}
}

看上去还是 MCP Server 的逻辑,但是在注册的时候不一样了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@RestController
public class ChatController {

private final CompiledGraph<AgentExecutor.State> workflow;

public ChatController(ChatModel chatModel) throws GraphStateException {
this.workflow = AgentExecutor.builder()
.chatModel(chatModel)
.toolsFromObject(new TimeWeatherTools()) // Agent Tool 注册
.build()
.compile();
}

@GetMapping("/chat")
public Object chat(String msg) {
System.out.println("收到请求消息msg:" + msg);
//参考官方demo
AgentExecutor.State last = null;
int i = 0;
for (NodeOutput<AgentExecutor.State> item : workflow.stream(Map.of("messages", new UserMessage(msg)))) {
System.out.println(item);
last = item.state();
System.out.printf("%02d : %s%n", i++, toStr(last.messages()));
}
// 返回最后一条消息
return last.lastMessage().map(Content::getText).orElse("NoData");
}

public String toStr(Object obj) {
ObjectMapper objectMapper = new ObjectMapper();
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

启动 SpringBoot 工程,访问接口 http://localhost:8080/chat?msg=xxx

我们输入 告诉我北京和伦敦现在的时间和天气

调用 Agent 框架后,大模型返回

1
根据查询结果,以下是北京和伦敦的当前时间和天气情况: **时间:** - 北京(中国):2026年4月12日 11:07:21 - 伦敦(英国):2026年4月12日 04:07:21 **天气:** - 北京:雪 - 伦敦:雪 从时间可以看出,北京比伦敦早7个小时。两个城市目前都正在下雪,天气状况比较相似。

由于我们的代码中打印出了调试信息,可以看到有类似节点的概念

1
2
3
4
5
6
7
NodeOutput{node=__START__, state={
messages=[
UserMessage{content='"告诉我北京和伦敦现在的时间和天气"', metadata={messageType=USER}, messageType=USER}
]
}
}
00 : [{"messageType":"USER","metadata":{"messageType":"USER"},"media":[],"text":"\"告诉我北京和伦敦现在的时间和天气\""}]

下面我们就来进一步理解 Langgraph4j 的基本组成和核心概念

基础信息

了解 LangGraph4j 之前,我们需要知道 LangChain4j

对比 LangChain4j

维度 LangChain4j LangGraph4j
定位 LLM 应用组件库/工具链,提供 LLM 应用开发的各种基础组件 工作流编排引擎,用于构建有状态、可循环、需人工干预的复杂 Agent 工作流
核心抽象 Chain(链式调用) StateGraph(状态图)
执行模型 线性 DAG(A→B→C) 支持循环/分支的图
Python 对应 LangChain LangGraph
关系 独立框架/库 基于AI抽象层(Spring AI/LangChain4j)的上层编排框架

LangGraph4j 依赖 LangChain4j,提供更高层的编排能力。它同时支持 LangChain4j 和 Spring AI 两种底层集成。应用场景根据复杂度,推荐的选型方案也不一样

场景 推荐框架
简单 LLM 对话 LangChain4j ✅
RAG 检索增强生成 LangChain4j ✅
Prompt 模板管理 LangChain4j ✅
ReAct Agent(思考→行动→观察循环) LangGraph4j ✅
多步骤复杂推理 LangGraph4j ✅
需要人工审批的工作流 LangGraph4j ✅
多 Agent 协作/任务分发 LangGraph4j ✅
需要状态持久化的长对话 LangGraph4j ✅
需要调试和回放的生产系统 LangGraph4j ✅

从技术架构上来看,AI Agent 工作流应用开发的技术结构大致可以这么理解

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────────────────────┐
│ LangGraph4j │
│ (工作流编排: 状态机、循环、分支、并行、检查点) │
├──────────────────────────────────────┤
│ LangChain4j │
│ (组件库: LLM调用、工具、记忆、RAG、向量存储) │
├──────────────────────────────────────┤
│ LangChain4j / Spring AI │
│ (底层 LLM 抽象层) │
├──────────────────────────────────────┤
│ OpenAI / Anthropic / Ollama ... │
│ (模型提供商) │
└──────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──────────────────────────────────────┐
│ LangGraph4j │
│ 【编排层 - Orchestration Layer】 │
│ 解决的问题:有状态的多步骤 Agent 工作流编排 │
│ 核心抽象:StateGraph → Node → Edge → Checkpoint │
├──────────────────────────────────────┤
│ LangChain4j │ Spring AI │
│ 【组件层 - Component Layer】 │ 【平台层 - Platform】 │
│ 解决的问题:LLM 调用、工具、记忆、 │ 解决的问题:Spring 生态 │
│ RAG、向量存储等基础组件 │ 中的 AI 能力集成 │
│ 核心抽象:AiServices、ChatMemory、 │ 核心抽象:ChatClient、 │
│ EmbeddingStore、@Tool │ Advisors、@Tool │
├──────────────────────────────────────┤
│ LLM Provider API │
│ 【模型层 - Model Layer】 │
│ OpenAI / Anthropic / Ollama / ... │
└───────────────────────────────────┘

整体架构区别

Langchain4j 和 Spring AI 都解决了一些 LLM 应用开发的抽象问题

抽象 解决的问题 典型 API
ChatModel 统一不同 LLM 的调用接口 ChatClient.call() / model.generate()
ChatMemory 多轮对话的上下文管理 MessageWindowChatMemory
EmbeddingStore 向量存储的统一抽象 EmbeddingStore.search()
@Tool 声明式工具定义,让 LLM 调用 Java 方法 @Tool("获取天气")
RAG Pipeline 检索增强生成的管道编排 QuestionAnswerAdvisor
Prompt Template 提示词模板管理 PromptTemplate
结构化输出 LLM 输出的类型化解析 @StructuredOutput

本质上解决的是:单次或简单多次 LLM 调用的组件化抽象。

抽象 解决的问题 核心类/概念
StateGraph 定义有状态的图结构(设计态) StateGraph<S>
CompiledGraph 编译后的可执行图(运行态) CompiledGraph<S>
AgentState 节点间共享状态的传递和管理 AgentState 基类
Node(节点) 工作流中的执行单元 NodeAction<S> / AsyncNodeAction<S>
Edge(边) 节点间的流转控制 addEdge() / addConditionalEdges()
Conditional Edge 基于状态的条件分支 路由函数
Checkpoint 状态持久化、断点续传 CheckpointSaver(MySQL/PG/Redis)
Human-in-the-Loop 人工审批/干预节点 InterruptionMetadata
Subgraph 子图嵌套,模块化复用 Child Graphs
Parallel Branch 并行执行分支 并行节点
Handoff 多 Agent 任务分发 Agent 间交接

而 LangGraph4j 本质上解决的是:复杂 Agent 工作流的编排、状态管理和流程控制。


所以简单总结 Spring AI /LangChain4j / Langgraph4j 的区别

框架 解决的抽象层次 核心问题
Spring AI 平台集成层 如何在 Spring 生态中无缝使用 AI
LangChain4j 组件抽象层 如何以框架无关的方式使用 AI 基础组件
LangGraph4j 工作流编排层 如何编排复杂的、有状态的、可循环的 Agent 工作流

核心概念

Langgraph4j本质上是一种图编排框架,图中的每一个节点叫做Node,节点之间可以传递状态AgentState进行状态共享。节点与节点之间通过边Edge进行连接。其中分为普通边和条件边,来实现图中的各种分支逻辑。

实现路由选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
public class WeatherAgent {

private final ChatClient chatClient;

private final CompiledGraph<AgentState> graph;

public WeatherAgent(ChatClient chatClient) throws GraphStateException {
this.chatClient = chatClient;
this.graph = initGraph("北京");
}

private CompiledGraph<AgentState> initGraph(String location) throws GraphStateException {
// 实现图的节点定义

// Node1: weather agent - 使用简单规则模拟天气(生产可以换成真实天气 API)
NodeAction<AgentState> weatherNode = state -> {
// 从图节点状态获取入参 location
String loc = (String) state.value("location").orElseGet(() -> location);
// 简单规则模拟天气
String weather;
if (loc.endsWith("市") || loc.endsWith("区")) weather = "晴天";
else if (loc.endsWith("省")) weather = "阴天";
else weather = "雨天";

System.out.println("[weatherNode] location=" + loc + " => weather=" + weather);

// 图节点状态更新
return Map.of(
"location", loc,
"weather", weather
);
};

// Node2: router - 只是做路由,本节点不做state的任何变更
NodeAction<AgentState> routerNode = state -> {
// 这个节点,用于模拟啥也不干的场景
String w = (String) state.value("weather").get();
System.out.println("[routerNode] weather=" + w);
return Map.of(); // 不改变状态
};

// Node3: outdoor - 用大模型生成外出推荐
NodeAction<AgentState> outdoorNode = state -> {
String loc = (String) state.value("location").orElseGet(() -> location);
String weather = (String) state.value("weather").orElseGet(() -> "晴天");

String prompt = String.format(
"你是一个资深旅行推荐师:用户在地点“%s”,当前天气“%s”。请用中文给出 3 个适合外出(户外)游玩的项目,每个项目写一行:项目名称 - 30 字以内简短描述 - 预计耗时。不要写多余开头语,返回纯文本列表。",
loc, weather);

String rec = chatClient.prompt()
.user(prompt)
.call()
.content();

System.out.println("[outdoorNode] model result:\n" + rec);
return Map.of("outdoor_recommendations", rec);
};

// Node4: indoor - 用大模型生成室内推荐
NodeAction<AgentState> indoorNode = state -> {
String loc = (String) state.value("location").orElseGet(() -> location);
String weather = (String) state.value("weather").orElseGet(() -> "雨天");

String prompt = String.format(
"你是一个资深旅行推荐师:用户在地点“%s”,当前天气“%s”。请用中文给出 3 个适合室内游玩的项目,每个项目写一行:项目名称 - 30 字以内简短描述 - 预计耗时。不要写多余开头语,返回纯文本列表。",
loc, weather);

String rec = chatClient.prompt()
.user(prompt)
.call()
.content();

System.out.println("[indoorNode] model result:\n" + rec);
return Map.of("indoor_recommendations", rec);
};

// 节点链接
return new StateGraph<>(AgentState::new)
.addNode("weather", AsyncNodeAction.node_async(weatherNode))
.addNode("router", AsyncNodeAction.node_async(routerNode))
.addNode("outdoor", AsyncNodeAction.node_async(outdoorNode))
.addNode("indoor", AsyncNodeAction.node_async(indoorNode))

// entry
.addEdge(START, "weather")
// weather -> router
.addEdge("weather", "router")
// 自定义路由条件判定
.addConditionalEdges("router", new RouteEvaluationResult(), EdgeMappings.builder()
.to("outdoor", "outdoor")
.to("indoor", "indoor")
.toEND()
.build())
// 输出结束
.addEdge("outdoor", END)
.addEdge("indoor", END)
.compile();

}

/**
* 通过给定的地方,返回旅游推荐项目
*
* @param location 地区
* @return
*/
public Map<String, Object> recommendByLocation(String location) {
// 初始 图节点的state,用于上下文传参
Map<String, Object> init = new HashMap<>();
init.put("location", location);

// 执行图
AgentState last = null;
for (var item : graph.stream(init)) {
// 打印过程记录
System.out.println(item);
last = item.state();
}
// 返回最后的结果
return last.data();
}

//自定义条件边判断
public static class RouteEvaluationResult implements AsyncEdgeAction<AgentState> {
@Override
public CompletableFuture<String> apply(AgentState agentState) {
// 根据图节点的状态中的天气字段来路由下一个节点
String w = (String) agentState.value("weather").orElseGet(() -> "晴天");
String res;
if ("晴天".equalsIgnoreCase(w)) {
res = "outdoor";
} else if ("雨天".equalsIgnoreCase(w)) {
res = "indoor";
} else {
// 其余天气直接结束
res = END;
}
return CompletableFuture.completedFuture(res);
}
}

}

可以看到我们是在每一个独立的 Node 中编写了核心的业务逻辑,各个节点之间通过边(普通边和条件边)进行连接来完成了这个条件路由的Agent工作流实现。


当然以上只是最基础的体验和使用,实际上我们用的还是langgraph4j-core的原生写法,langgraph4j-spring包中还提供了spring-ai的集成功能。

此外默认的AgentState,内部是使用Map来传递共享参数,是否有更结构化的方式?

后续的实践我们还会深入探讨。

报错整理

AssistantMessage NoSuchMethod

复现版本

  • langgraph4j:1.6.0-rc4
  • spring-ai:1.1.2

调用Agent的时候就会报错

java.lang.NoSuchMethodError: ‘void org.springframework.ai.chat.messages.AssistantMessage.(java.lang.String, java.util.Map, java.util.List)’
at org.bsc.langgraph4j.spring.ai.serializer.std.AssistantMessageSerializer.read(AssistantMessageSerializer.java:30) ~[langgraph4j-spring-ai-1.6.0-rc4.jar:na]

这个问题主要是因为SpringAI1.1.0 版本中删除了AssistantMessage 的构造方法,改为builder 构建

解决方案

降级 spring-ai 版本,改用 1.1.0-M4的版本即可正常调用


AI| Spring AI Practice
http://example.com/2026/03/13/AI-Spring-AI-Practice/
作者
Noctis64
发布于
2026年3月13日
许可协议