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)

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