目录
项目初始化
创建一个新的SpringBoot工程,勾选Web、MySQL驱动、Ollama:
pom文件
主要引入的依赖如下
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.10.1</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
SpringAI完全适配了SpringBoot的自动装配功能,而且给不同的大模型提供了不同的starter,比如:
<!--Anthropic-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-anthropic-spring-boot-starter</artifactId>
</dependency>
<!--Azure OpenAI-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-azure-openai-spring-boot-starter</artifactId>
</dependency>
<!--DeepSeek-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<!--Hugging Face-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-huggingface-spring-boot-starter</artifactId>
</dependency>
<!--Ollama-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<!--OpenAI-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
配置模型
在配置文件中配置模型的参数信息,以Ollama为例:
spring:
application:
name: heima-ai
ai:
ollama:
base-url: http://localhost:11434
chat:
model: deepseek-r1:7b
openai:
base-url: https://dashscope.aliyuncs.com/compatible-mode
api-key: 填百炼大模型平台自己的api key
chat:
options:
model: qwen-plus
embedding:
options:
model: text-embedding-v3
dimensions: 1024
ChatClient
ChatClient
中封装了与AI大模型对话的各种API,同时支持同步式或响应式交互;在使用之前,需要声明一个
ChatClient
;在ai.config
包下新建一个CommonConfiguration
类:系统预设:在SpringAI中,设置System信息非常方便,不需要在每次发送时封装到Message,而是创建ChatClient时指定即可;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CommonConfiguration {
// 注意参数中的model就是使用的模型,这里用了Ollama,也可以选择OpenAIChatModel
@Bean
public ChatClient chatClient(OllamaChatModel model) {
return ChatClient.builder(model) // 创建ChatClient工厂,利用它可以自由选择模型、添加各种自定义配置
.defaultOptions(ChatOptions.builder().model("qwen-omni-turbo").build())
.defaultSystem("你是一个热心、可爱的智能助手,你的名字叫小团团,请以小团团的身份和语气回答问题。") //系统预设
.build(); // 构建ChatClient实例
}
}
同步调用
定义一个Controller,在其中接收用户发送的提示词,然后把提示词发送给大模型,交给大模型处理,拿到结果后返回;
package com.shisan.ai.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
public class ChatController {
private final ChatClient chatClient;
// 请求方式和路径不要改动,将来要与前端联调
@RequestMapping("/chat")
public String chat(@RequestParam String prompt) {
return chatClient
.prompt(prompt) // 传入user提示词
.call() // 同步调用请求,会等待AI全部输出完才返回结果
.content(); //返回响应内容
}
}
启动项目,在浏览器中访问:http://localhost:8080/ai/chat?prompt=你好
;
流式调用
同步调用需要等待很长时间页面才能看到结果,用户体验不好。为了解决这个问题,可以改进调用方式为流式调用;
使用了WebFlux技术实现流式调用;修改
ChatController
中的chat方法:
// 注意看返回值,是Flux<String>,也就是流式结果,另外需要设定响应类型和编码,不然前端会乱码
@RequestMapping(value = "/chat", produces = "text/html;charset=UTF-8")
public Flux<String> chat(@RequestParam(String prompt) {
return chatClient
.prompt(prompt)
.stream() // 流式调用
.content();
}
日志功能
默认情况下,AI交互时是不记录日志的,我们无法得知SpringAI 组织的提示词到底长什么样,这样不方便我们调试。
SpringAI基于AOP机制实现与大模型对话过程的增强、拦截、修改等功能,所有的增强通知都需要实现Advisor接口;Spring提供了一些Advisor的默认实现,来实现一些基本的增强功能:
SimpleLoggerAdvisor
:日志记录的Advisor;MessageChatMemoryAdvisor
:会话记忆的Advisor;QuestionAnswerAdvisor
:实现RAG的Advisor;
当然,也可以自定义Advisor,具体可以参考:Advisors API
添加日志功能
@Configuration
public class CommonConfiguration {
// 注意参数中的model就是使用的模型,这里用了Ollama,也可以选择OpenAIChatModel
@Bean
public ChatClient chatClient(OllamaChatModel model) {
return ChatClient.builder(model) // 创建ChatClient工厂,利用它可以自由选择模型、添加各种自定义配置
.defaultOptions(ChatOptions.builder().model("qwen-omni-turbo").build())
.defaultSystem("你是一个热心、可爱的智能助手,你的名字叫小团团,请以小团团的身份和语气回答问题。") //系统预设
.defaultAdvisors(
new SimpleLoggerAdvisor(),
new MessageChatMemoryAdvisor(chatMemory)
)
.build(); // 构建ChatClient实例
}
}
日志级别
#配置 application.yaml即可,重启项目,再次聊天就能在IDEA的运行控制台中看到AI对话的日志信息了
logging:
level:
org.springframework.ai: debug
com.itheima.ai: debug
对接前端
npm运行
进入spring-ai-protal
文件夹(该文件夹要放在非中文目录下),然后执行cmd命令:
# 安装依赖
npm install
# 运行程序
npm run dev
启动后,访问http://localhost:5173
即可看到页面:
nginx运行
若不关心源码,进入spring-ai-nginx
文件夹(该文件夹要放在非中文目录下),然后执行cmd命令
# 启动Nginx
start nginx.exe
# 停止
nginx.exe -s stop
启动后,访问
http://localhost:5173
即可看到页面。
解决跨域
前后端在不同端口,存在跨域问题,因此需要在服务端解决cors问题;在ai.config
包中添加一个MvcConfiguration
类:
@Configuration
public class MvcConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("Content-Disposition");
}
}
测试
会话记忆功能
前面讲过,让AI有会话记忆的方式就是把每一次历史对话内容拼接到Prompt中,一起发送过去,这种方式比较挺麻烦;
如果使用了SpringAI,并不需要自己拼接,SpringAI自带了会话记忆功能,可以把历史会话保存下来,下一次请求AI时会自动拼接,非常方便。
ChatMemory
会话记忆功能同样是基于AOP实现,Spring提供了一个MessageChatMemoryAdvisor
的通知,可以像之前添加日志通知一样添加到ChatClient即可;不过,要注意的是,MessageChatMemoryAdvisor
需要指定一个ChatMemory
实例,也就是会话历史保存的方式;
ChatMemory接口声明如下:
public interface ChatMemory {
// TODO: consider a non-blocking interface for streaming usages
default void add(String conversationId, Message message) {
this.add(conversationId, List.of(message));
}
// 添加会话信息到指定conversationId的会话历史中
void add(String conversationId, List<Message> messages);
// 根据conversationId查询历史会话
List<Message> get(String conversationId, int lastN);
// 清除指定conversationId的会话历史
void clear(String conversationId);
}
可以看到,所有的会话记忆都是与conversationId
有关联的,也就是会话Id,将来不同会话Id的记忆自然是分开管理的;
目前,在SpringAI中有两个ChatMemory的实现:
InMemoryChatMemory
:会话历史保存在内存中;CassandraChatMemory
:会话保存在Cassandra数据库中(需要引入额外依赖,并且绑定了向量数据库,不够灵活);
目前选择用InMemoryChatMemory
来实现。
添加会话记忆功能
CommonConfiguration配置类中添加
@Bean
public ChatMemory chatMemory() {
return new InMemoryChatMemory();
}
@Bean
public ChatClient chatClient(AlibabaOpenAiChatModel model, ChatMemory chatMemory) {
return ChatClient
.builder(model)
.defaultOptions(ChatOptions.builder().model("qwen-omni-turbo").build())
.defaultSystem("你是一个热心、可爱的智能助手,你的名字叫小团团,请以小团团的身份和语气回答问题。")
.defaultAdvisors(
new SimpleLoggerAdvisor(), //记录日志
new MessageChatMemoryAdvisor(chatMemory) //添加会话记忆功能
)
.build();
}
现在聊天会话已经有记忆功能了,不过现在的会话记忆还是不完善的,接下来的章节还会继续补充。
会话历史
会话记忆:是指让大模型记住每一轮对话的内容,不至于前一句刚问完,下一句就忘了;
会话历史:是指要记录总共有多少不同的对话;
以DeepSeek为例,页面上的会话历史:
在ChatMemory
中,会记录一个会话中的所有消息,记录方式是以conversationId
为key,以List<Message>
为value,根据这些历史消息,大模型就能继续回答问题,这就是所谓的会话记忆;
而会话历史,其实就是每一个会话的conversationId
,用它去查询List<Message>
,注意,在接下来业务中,以chatId来代conversationId
管理会话id
由于会话记忆是以conversationId
来管理的,也就是会话id(以后简称为chatId)将来要查询会话历史,其实就是查询历史中有哪些chatId;因此,为了实现查询会话历史记录,必须记录所有的chatId。
定义一个ai.repository
包,然后新建一个ChatHistoryRepository
管理会话历史接口:
public interface ChatHistoryRepository {
/**
* 保存会话记录
* @param type 业务类型,如:chat、service、pdf
* @param chatId 会话ID
*/
void save(String type, String chatId);
/**
* 获取会话ID列表
* @param type 业务类型,如:chat、service、pdf
* @return 会话ID列表
*/
List<String> getChatIds(String type);
}
在这个包下继续创建一个实现类InMemoryChatHistoryRepository
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class InMemoryChatHistoryRepository implements ChatHistoryRepository {
private Map<String, List<String>> chatHistory = new HashMap<>(); //存储不同类型的聊天 ID
//业务类型,如:chat、service、pdf
//按类型存储聊天 ID,避免重复
@Override
public void save(String type, String chatId) {
List<String> chatIds = chatHistory.computeIfAbsent(type, k -> new ArrayList<>());
if (chatIds.contains(chatId)) {
return;
}
chatIds.add(chatId);
}
//根据类型返回对应的聊天 ID 列表
@Override
public List<String> getChatIds(String type) {
return chatHistory.getOrDefault(type, List.of());
}
}
接下来,修改ChatController
中的chat
方法,做到以下3点:
添加一个请求参数:chatId,每次前端请求AI时都需要传递chatId;
每次处理请求时,将chatId存储到ChatRepository;
每次发请求到AI大模型时,都传递自定义的chatId;
保存会话id
接下来,修改ChatController
中的chat方法,做到以下3点:
添加一个请求参数:
chatId
,每次前端请求AI时都需要传递chatId
;每次处理请求时,将chatId存储到ChatRepository;
每次发请求到AI大模型时,都传递自定义的chatId;
private final ChatClient chatClient;
private final ChatHistoryRepository chatHistoryRepository;
//流式调用
@RequestMapping(value = "/chat", produces = "text/html;charset=utf-8")
public Flux<String> chat(
@RequestParam("prompt") String prompt,
@RequestParam("chatId") String chatId,
@RequestParam(value = "files", required = false) List<MultipartFile> files) {
// 1.保存会话id(如果存在会直接返回)
chatHistoryRepository.save("chat", chatId);
// 2.请求模型
if (files == null || files.isEmpty()) {
// 没有附件,纯文本聊天
return textChat(prompt, chatId);
} else {
// 有附件,多模态聊天
return multiModalChat(prompt, chatId, files);
}
}
//纯文本聊天
private Flux<String> textChat(String prompt, String chatId) {
return chatClient.prompt() //请求模型
.user(prompt) //预设
//通过AdvisorContext,也就是以key-value形式存入上下文
.advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId))
.stream()
.content();
}
查询会话历史
定义一个新的Controller,专门实现回话历史的查询。包含两个接口:
根据业务类型查询会话历史列表(将来有3个不同业务,需要分别记录历史。可以自己扩展成按userId记录,根据UserId查询)
根据chatId查询指定会话的历史消息;
private final ChatHistoryRepository chatHistoryRepository;
private final ChatMemory chatMemory;
//根据业务类型查询会话历史列表
@GetMapping("/{type}")
public List<String> getChatIds(@PathVariable("type") String type) {
return chatHistoryRepository.getChatIds(type);
}
//根据chatId查询指定会话的历史消息
@GetMapping("/{type}/{chatId}")
public List<MessageVO> getChatHistory(@PathVariable("type") String type, @PathVariable("chatId") String chatId) {
List<Message> messages = chatMemory.get(chatId, Integer.MAX_VALUE);
if(messages == null) {
return List.of();
}
//由于Message并不符合页面的需要,所以需要自己定义一个VO
return messages.stream().map(MessageVO::new).toList();
}
@NoArgsConstructor
@Data
public class MessageVO {
private String role;
private String content;
public MessageVO(Message message) {
switch (message.getMessageType()) {
case USER:
role = "user";
break;
case ASSISTANT:
role = "assistant";
break;
default:
role = "";
break;
}
this.content = message.getText();
}
}
重启服务,现在AI聊天机器人就具备会话记忆和会话历史功能了!
完善会话记忆
目前,会话记忆是基于内存,重启服务就没了;如果要持久化保存,这里提供了3种办法:
依然是基于
InMemoryChatMemory
,但是在项目停机时,或者使用定时任务实现自动持久化;自定义基于Redis的
ChatMemory
;基于SpringAI官方提供的
CassandraChatMemory
,同时会自动启用CassandraVectorStore。
定义可序列化的Message
前面的两种方案,都面临一个问题,SpringAI中的Message类未实现Serializable接口,也没提供public的构造方法,因此无法基于任何形式做序列化。所以必须定义一个可序列化的Message类,方便后续持久化。定义一ai.entity.po
包,新建一个Msg类:
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Msg {
MessageType messageType;
String text;
Map<String, Object> metadata;
List<AssistantMessage.ToolCall> toolCalls;
//将SpringAI的Message转为我们的Msg
public Msg(Message message) {
this.messageType = message.getMessageType();
this.text = message.getText();
this.metadata = message.getMetadata();
if(message instanceof AssistantMessage am) {
this.toolCalls = am.getToolCalls();
}
}
//实现将我们的Msg转为SpringAI的Message
public Message toMessage() {
return switch (messageType) {
case SYSTEM -> new SystemMessage(text);
case USER -> new UserMessage(text, List.of(), metadata);
case ASSISTANT -> new AssistantMessage(text, metadata, toolCalls, List.of());
default -> throw new IllegalArgumentException("Unsupported message type: " + messageType);
};
}
}
方案一:定期持久化
接下来,将SpringAI提供的InMemoryChatMemory
中的数据持久化到本地磁盘,并且在项目启动时加载;
本方案中,采用Spring的生命周期方法,在项目启动时加载持久化文件,在项目停机时持久化数据;
也可以考虑使用定时任务完成持久化,项目启动加载的方案;
修改ai.repository.InMemoryChatHistoryRepository
类,添加持久化功能:
//项目启动时
@PostConstruct
private void init() {
// 1.初始化会话历史记录
this.chatHistory = new HashMap<>();
// 2.加载本地会话历史和会话记忆
FileSystemResource historyResource = new FileSystemResource("chat-history.json");
FileSystemResource memoryResource = new FileSystemResource("chat-memory.json");
if (!historyResource.exists()) {
return;
}
try {
// 会话历史
Map<String, List<String>> chatIds = this.objectMapper.readValue(historyResource.getInputStream(), new TypeReference<>() {
});
if (chatIds != null) {
this.chatHistory = chatIds;
}
// 会话记忆
Map<String, List<Msg>> memory = this.objectMapper.readValue(memoryResource.getInputStream(), new TypeReference<>() {
});
if (memory != null) {
memory.forEach(this::convertMsgToMessage); //转成message
}
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
private void convertMsgToMessage(String chatId, List<Msg> messages) {
this.chatMemory.add(chatId, messages.stream().map(Msg::toMessage).toList());
}
@PreDestroy
private void persistent() {
String history = toJsonString(this.chatHistory);
String memory = getMemoryJsonString();
FileSystemResource historyResource = new FileSystemResource("chat-history.json");
FileSystemResource memoryResource = new FileSystemResource("chat-memory.json");
try (
PrintWriter historyWriter = new PrintWriter(historyResource.getOutputStream(), true, StandardCharsets.UTF_8);
PrintWriter memoryWriter = new PrintWriter(memoryResource.getOutputStream(), true, StandardCharsets.UTF_8)
) {
historyWriter.write(history);
memoryWriter.write(memory);
} catch (IOException ex) {
log.error("IOException occurred while saving vector store file.", ex);
throw new RuntimeException(ex);
} catch (SecurityException ex) {
log.error("SecurityException occurred while saving vector store file.", ex);
throw new RuntimeException(ex);
} catch (NullPointerException ex) {
log.error("NullPointerException occurred while saving vector store file.", ex);
throw new RuntimeException(ex);
}
}
private String getMemoryJsonString() {
Class<InMemoryChatMemory> clazz = InMemoryChatMemory.class;
try {
Field field = clazz.getDeclaredField("conversationHistory");
field.setAccessible(true);
Map<String, List<Message>> memory = (Map<String, List<Message>>) field.get(chatMemory);
Map<String, List<Msg>> memoryToSave = new HashMap<>();
memory.forEach((chatId, messages) -> memoryToSave.put(chatId, messages.stream().map(Msg::new).toList()));
return toJsonString(memoryToSave);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
private String toJsonString(Object object) {
ObjectWriter objectWriter = this.objectMapper.writerWithDefaultPrettyPrinter();
try {
return objectWriter.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new RuntimeException("Error serializing documentMap to JSON.", e);
}
}
方案二:自定义ChatMemory
基于Redis来实现自定义ChatMemory;
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
ai.repository
包中新建一个RedisChatMemory
类:由于使用的是Redis的Set结构,无序的,因此要确保chatId是单调递增的。
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itheima.ai.entity.po.Msg;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
@RequiredArgsConstructor
@Component
public class RedisChatMemory implements ChatMemory {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final static String PREFIX = "chat:";
@Override
public void add(String conversationId, List<Message> messages) {
if (messages == null || messages.isEmpty()) {
return;
}
List<String> list = messages.stream().map(Msg::new).map(msg -> {
try {
return objectMapper.writeValueAsString(msg);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}).toList();
redisTemplate.opsForList().leftPushAll(PREFIX + conversationId, list);
}
@Override
public List<Message> get(String conversationId, int lastN) {
List<String> list = redisTemplate.opsForList().range(PREFIX + conversationId, 0, lastN);
if (list == null || list.isEmpty()) {
return List.of();
}
return list.stream().map(s -> {
try {
return objectMapper.readValue(s, Msg.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}).map(Msg::toMessage).toList();
}
@Override
public void clear(String conversationId) {
redisTemplate.delete(PREFIX + conversationId);
}
}
方案三:Cassandra
SpringAI官方提供了CassandraChatMemory,但是是跟CassandraVectorStore绑定的,不太灵活;
首先,需要安装一个Cassandra访问,使用Docker安装:
docker run -d --name cas -p 9042:9042 cassandra
在项目中添加cassandra依赖:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-cassandra-store-spring-boot-starter</artifactId>
</dependency>
配置Cassandra地址:
spring:
cassandra:
contact-points: 192.168.150.101:9042
local-datacenter: datacenter1
基于Cassandra的ChatMemory已经实现了,其它不变。
注意:多种ChatMemory实现方案不能共存,只能选择其一。