SpringAI-02-ChatClient使用

一、ChatClient 简介

ChatClient 是 Spring AI 提供的高级 API,比直接使用 ChatModel 更方便。它提供了链式调用的方式,代码更简洁,功能也更丰富。

ChatClient 的优势:

  • 链式调用,代码更清晰
  • 内置对话记忆功能
  • 支持 Advisor 拦截器
  • 统一的工具调用接口
  • 更好的错误处理

什么时候用 ChatClient:

  • 需要对话记忆的场景
  • 需要自定义拦截器的场景
  • 需要工具调用的场景
  • 需要更灵活配置的场景

什么时候用 ChatModel:

  • 简单的单次调用
  • 不需要记忆功能
  • 对性能要求极高的场景

二、多模型管理

2.1 为什么需要多模型管理

​ 实际项目中,我们可能需要同时使用多个 AI 模型:

  • DeepSeek:适合代码生成、推理任务
  • 阿里百炼:适合中文对话、多模态任务
  • OpenAI:适合英文场景

​ 如果每个地方都直接注入 ChatModel,代码会很混乱。更好的做法是统一管理,通过参数切换。

2.2 ChatModelService 实现

​ 创建一个服务类来管理多个 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
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
package org.study.ai.service;

import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.deepseek.DeepSeekChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;

@Service
public class ChatModelService {

// 使用 required = false,避免某个模型未配置时启动失败
@Autowired(required = false)
private DeepSeekChatModel deepSeekChatModel;

@Autowired(required = false)
private DashScopeChatModel dashScopeChatModel;

/**
* ChatModel映射表
* Key: 平台标识 (ds, dashscope)
* Value: ChatModel实例
*/
private final Map<String, ChatModel> chatModelMap = new HashMap<>();

/**
* 初始化ChatModel映射表
* 在应用启动时执行
*/
@PostConstruct
public void initChatModels() {
// 初始化DeepSeek平台
if (deepSeekChatModel != null) {
chatModelMap.put("ds", deepSeekChatModel);
chatModelMap.put("deepseek", deepSeekChatModel); // 支持完整名称
}

// 初始化DashScope平台
if (dashScopeChatModel != null) {
chatModelMap.put("dashscope", dashScopeChatModel);
}
}

/**
* 根据平台标识获取ChatModel
*
* @param platform 平台标识
* @return ChatModel实例,如果平台不存在则返回null
*/
public ChatModel getChatModel(String platform) {
if (platform == null) {
return null;
}
return chatModelMap.get(platform.toLowerCase());
}
}

关键点:

  • @Autowired(required = false):某个模型未配置时不会报错
  • @PostConstruct:应用启动时初始化映射表
  • 支持多个别名:dsdeepseek 都指向同一个模型

2.3 实际使用

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
@RestController
@RequestMapping("/api/chat")
public class ChatController {

@Autowired
private ChatModelService chatModelService;

@GetMapping(value = "/stream", produces = "text/stream;charset=utf-8")
public Flux<String> streamChat(
@RequestParam("platform") String platform,
@ModelAttribute ChatRequest request) {

// 获取对应的 ChatModel
ChatModel chatModel = chatModelService.getChatModel(platform);
if (chatModel == null) {
return Flux.error(new IllegalArgumentException("不支持的平台:" + platform));
}

// 使用 ChatClient
ChatClient chatClient = ChatClient.builder(chatModel).build();

return chatClient.prompt()
.user(request.getMessage())
.stream()
.content();
}
}

调用示例:

1
2
GET /api/chat/stream?platform=ds&message=你好&model=deepseek-chat
GET /api/chat/stream?platform=dashscope&message=你好&model=qwen-plus

三、流式对话接口实现

3.1 接口设计

​ 完整的流式对话接口需要考虑:

  • 参数校验
  • 错误处理
  • 默认值设置
  • 编码问题
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
@RestController
@RequestMapping("/api/chat")
public class ChatController {

@Autowired
private ChatModelService chatModelService;

/**
* 流式对话接口
*
* @param platform 平台类型:ds(DeepSeek) 或 dashscope(DashScope)
* @param request 对话请求参数对象(包含message、temperature、model)
* @return 流式文本响应
*
* 调用示例:
* http://localhost:8080/api/chat/stream?message=%E4%BD%A0%E5%A5%BD&platform=ds&model=deepseek-chat
*/
@GetMapping(value = "/stream", produces = "text/stream;charset=utf-8")
public Flux<String> streamChat(
@RequestParam("platform") String platform,
@ModelAttribute ChatRequest request) {

// 第一步:参数校验
if (platform == null || platform.trim().isEmpty()) {
return Flux.error(new IllegalArgumentException("platform参数不能为空"));
}

if (request.getMessage() == null || request.getMessage().trim().isEmpty()) {
return Flux.error(new IllegalArgumentException("message参数不能为空"));
}
if (StrUtil.isBlank(request.getModel())) {
return Flux.error(new IllegalArgumentException("model参数不能为空"));
}

// 设置默认温度值
if (request.getTemperature() == null) {
request.setTemperature(0.7);
}

// 第二步:从Map获取ChatModel并校验
ChatModel chatModel = chatModelService.getChatModel(platform);
if (chatModel == null) {
return Flux.error(new IllegalArgumentException("不支持的平台:" + platform));
}

ChatClient chatClient = ChatClient.builder(chatModel).build();

// 第三步:使用ChatClient统一接口发起流式请求
return chatClient.prompt()
.user(request.getMessage())
.options(ChatOptions.builder()
.model(request.getModel())
.temperature(request.getTemperature())
.build())
.stream()
.content();
}
}

3.2 参数校验

​ 参数校验很重要,可以避免无效请求:

1
2
3
4
5
6
7
8
// ChatRequest DTO
public class ChatRequest {
private String message;
private String model;
private Double temperature = 0.7; // 默认值

// getter and setter
}

校验要点:

  • platform:必须指定,否则不知道用哪个模型
  • message:不能为空
  • model:必须指定,不同平台的模型名称不同
  • temperature:有默认值,可以不传

3.3 流式响应处理

​ 流式响应需要注意:

  • 编码问题:produces = "text/stream;charset=utf-8"
  • 空值处理:.map(content -> content == null ? "" : content)
  • 错误处理:使用 Flux.error() 返回错误

四、对话记忆功能

4.1 Redis 配置

​ 对话记忆需要存储历史记录,Spring AI 支持 Redis 存储。

依赖配置:

1
2
3
4
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory-redis</artifactId>
</dependency>

配置文件:

1
2
spring.ai.memory.redis.host=localhost
spring.ai.memory.redis.port=6379

注意:

  • 需要先启动 Redis 服务
  • 如果没有 Redis,记忆功能不会生效,但不会报错

4.2 使用记忆功能

​ 在 ChatClient 中配置 PromptChatMemoryAdvisor

1
2
3
4
5
6
7
8
@Autowired
private ChatMemory chatMemory;

ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
PromptChatMemoryAdvisor.builder(chatMemory).build()
)
.build();

完整示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RestController
@RequestMapping("/api/chat")
public class ChatController {

@Autowired
private ChatModel chatModel;

@Autowired
private ChatMemory chatMemory;

@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String message) {
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
PromptChatMemoryAdvisor.builder(chatMemory).build()
)
.build();

return chatClient.prompt()
.user(message)
.stream()
.content();
}
}

4.3 记忆功能原理

PromptChatMemoryAdvisor 的工作流程:

  1. 请求前:从 Redis 中读取历史对话记录
  2. 构建 Prompt:将历史记录添加到 Prompt 中
  3. 发送请求:调用 AI 模型
  4. 请求后:将新的对话记录保存到 Redis

实际效果:

1
2
3
4
5
用户:我叫张三
AI:你好,张三!

用户:我的名字是什么?
AI:你的名字是张三。

​ 第二次对话时,AI 能记住用户的名字,因为历史记录被自动添加到了 Prompt 中。

五、系统提示词

5.1 从文件加载

​ 系统提示词可能很长,放在代码里不优雅,可以放到文件中:

1
2
3
4
5
6
7
// 加载外部提示词文件
Resource resource = new ClassPathResource("prompt/systemPrompt.md");
String systemPrompt = new String(resource.getInputStream().readAllBytes());

ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(systemPrompt)
.build();

文件位置: src/main/resources/prompt/systemPrompt.md

5.2 动态参数

​ 系统提示词中可能需要动态信息,比如当前日期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("""
#角色
你是一个专业的航空客服助手,名为'南方国际航空'的客服代表
你的职责是帮助客户处理航班预订相关的问题,包括查询航班信息、取消预订等。请用友好、专业的态度回复客户。
#要求
1、在涉及增删改(除了查询)function-call前,必须等用户回复"确认"后再调用tools
2、请讲中文

今天的日期是 {currentDate}
""")
.build();

return chatClient.prompt()
.user(message)
.system(p -> p.param("currentDate", LocalDate.now()))
.stream()
.content();

关键点:

  • {currentDate} 是占位符
  • .system(p -> p.param("currentDate", LocalDate.now())) 替换占位符
  • 每次请求都会使用最新的日期

5.3 实际案例

​ 航空客服助手的完整系统提示词:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("""
#角色
你是一个专业的航空客服助手,名为'南方国际航空'的客服代表
你的职责是帮助客户处理航班预订相关的问题,包括查询航班信息、取消预订等。请用友好、专业的态度回复客户。

#要求
1、在涉及增删改(除了查询)function-call前,必须等用户回复"确认"后再调用tools
2、请讲中文
3、如果用户的问题无法通过工具解决,请引导用户联系人工客服

今天的日期是 {currentDate}
""")
.defaultAdvisors(
PromptChatMemoryAdvisor.builder(chatMemory).build(),
new SimpleLoggerAdvisor()
)
.build();

提示词设计要点:

  • 明确角色:告诉 AI 你是谁
  • 明确职责:告诉 AI 你要做什么
  • 明确规则:告诉 AI 什么能做,什么不能做
  • 动态信息:使用占位符注入动态内容

六、Advisor 拦截器

6.1 自定义 Advisor

​ 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
@Component
public class ReadReadAdvisor implements BaseAdvisor {

private static final String READ_TEMPLATE = """
{content}
Read the question again: {content}
""";

@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
// 请求前处理:修改 Prompt
String contents = chatClientRequest.prompt().getContents();
String newContent = PromptTemplate.builder()
.template(READ_TEMPLATE)
.build()
.render(Map.of("content", contents));

ChatClientRequest clientRequest = chatClientRequest.mutate()
.prompt(Prompt.builder().content(newContent).build())
.build();
return clientRequest;
}

@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
// 请求后处理:可以修改响应
return chatClientResponse;
}

@Override
public int getOrder() {
return 0; // 执行顺序,值越小越先执行
}
}

使用场景:

  • 日志记录:记录每次请求和响应
  • 敏感词拦截:在请求前检查敏感词
  • 翻译转换:将用户输入转换为其他语言
  • 文档后处理:对检索到的文档进行处理

6.2 内置 Advisor

SimpleLoggerAdvisor

​ 用于记录请求和响应的日志:

1
2
3
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(new SimpleLoggerAdvisor())
.build();

日志输出示例:

1
2
DEBUG SimpleLoggerAdvisor - User: 你好
DEBUG SimpleLoggerAdvisor - Assistant: 你好!我是...

PromptChatMemoryAdvisor

​ 用于管理对话记忆:

1
2
3
4
5
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
PromptChatMemoryAdvisor.builder(chatMemory).build()
)
.build();

6.3 执行顺序

​ 多个 Advisor 的执行顺序由 getOrder() 方法决定:

1
2
3
4
5
6
7
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
new ReadReadAdvisor(), // order = 0,最先执行
new SimpleLoggerAdvisor(), // order = 默认值,中间执行
PromptChatMemoryAdvisor.builder(chatMemory).build() // order = 较大值,最后执行
)
.build();

执行流程:

  1. before() 方法:按 order 从小到大执行
  2. 调用 AI 模型
  3. after() 方法:按 order 从大到小执行(与 before 相反)

七、SSE 流式响应

7.1 后端实现

​ Spring WebFlux 会自动将 Flux<String> 转换为 SSE 格式:

1
2
3
4
5
6
7
8
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.content()
.map(content -> content == null ? "" : content);
}

关键点:

  • produces = MediaType.TEXT_EVENT_STREAM_VALUE:指定返回 SSE 格式
  • 每个字符串会被自动包装为:data: {content}\n\n
  • 不需要手动处理 SSE 格式

7.2 前端接收

JavaScript 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const eventSource = new EventSource('/api/chat/stream?message=你好&platform=ds&model=deepseek-chat');

eventSource.onmessage = function(event) {
console.log('收到数据:', event.data);
// 将数据追加到页面
document.getElementById('output').innerHTML += event.data;
};

eventSource.onerror = function(event) {
console.error('连接错误:', event);
eventSource.close();
};

// 关闭连接
// eventSource.close();

Vue 实现:

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
<template>
<div>
<div id="output">{{ output }}</div>
</div>
</template>

<script>
export default {
data() {
return {
output: '',
eventSource: null
}
},
mounted() {
this.eventSource = new EventSource('/api/chat/stream?message=你好');
this.eventSource.onmessage = (event) => {
this.output += event.data;
};
},
beforeUnmount() {
if (this.eventSource) {
this.eventSource.close();
}
}
}
</script>

注意事项:

  • SSE 是单向通信,只能服务器推送到客户端
  • 连接断开后需要重新建立连接
  • 浏览器对 SSE 连接数有限制(通常 6 个)

八、常见问题

8.1 ChatModel 注入失败

问题: @Autowired ChatModel 注入失败,返回 null。

原因:

  • 没有配置对应的 starter
  • API Key 配置错误
  • 配置文件路径不对

解决:

  1. 检查是否添加了对应的依赖(如 spring-ai-starter-model-deepseek
  2. 检查配置文件中的 API Key 是否正确
  3. 确认配置文件在 src/main/resources 目录下

8.2 记忆功能不生效

问题: 配置了 PromptChatMemoryAdvisor 但对话没有记忆。

原因:

  • Redis 未启动
  • Redis 配置错误
  • ChatMemory 未注入成功

解决:

  1. 检查 Redis 是否启动:redis-cli ping
  2. 检查 Redis 配置是否正确
  3. 确认 ChatMemory 是否成功注入(添加日志)

8.3 SSE 连接断开

问题: 前端 SSE 连接经常断开。

原因:

  • 网络问题
  • 服务器超时
  • 浏览器限制

解决:

  1. 添加心跳机制,定期发送空消息
  2. 增加服务器超时时间
  3. 前端添加重连机制

8.4 中文乱码

问题: SSE 返回的中文乱码。

原因: 编码问题。

解决:

1
2
@GetMapping(value = "/stream", produces = "text/stream;charset=utf-8")
// 必须指定 charset=utf-8

8.5 Advisor 执行顺序不对

问题: Advisor 的执行顺序不符合预期。

原因: getOrder() 返回值设置错误。

解决:

  • 值越小越先执行
  • before() 按从小到大执行
  • after() 按从大到小执行(与 before 相反)

九、总结

  1. ChatClient vs ChatModel:ChatClient 更适合复杂场景,ChatModel 适合简单场景
  2. 多模型管理:通过 ChatModelService 统一管理,通过参数切换
  3. 对话记忆:使用 PromptChatMemoryAdvisor + Redis 实现
  4. 系统提示词:可以从文件加载,支持动态参数
  5. Advisor:可以在请求前后进行处理,实现日志、拦截等功能
  6. SSE:Spring WebFlux 自动处理 SSE 格式,前端用 EventSource 接收

​ 下一篇文章会介绍 Tools/Function Call 功能,这是让 AI 调用外部工具的关键功能。