SpringAI+DeepSeek大模型应用开发——4 对话机器人
目录
项目初始化
pom文件
配置模型
ChatClient
同步调用
流式调用
日志功能
对接前端
解决跨域
会话记忆功能
ChatMemory
添加会话记忆功能
会话历史
管理会话id
保存会话id
查询会话历史
完善会话记忆
定义可序列化的Message
方案一:定期持久化
方案二:自定义ChatMemory
方案三:Cassandra
项目初始化
创建一个新的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-aiai:ollama:base-url: http://localhost:11434chat:model: deepseek-r1:7bopenai:base-url: https://dashscope.aliyuncs.com/compatible-modeapi-key: 填百炼大模型平台自己的api keychat:options:model: qwen-plusembedding:options:model: text-embedding-v3dimensions: 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@Beanpublic 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@Beanpublic 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: debugcom.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 {@Overridepublic 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 usagesdefault 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();
}@Beanpublic 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,避免重复@Overridepublic void save(String type, String chatId) {List<String> chatIds = chatHistory.computeIfAbsent(type, k -> new ArrayList<>());if (chatIds.contains(chatId)) {return;}chatIds.add(chatId);}//根据类型返回对应的聊天 ID 列表@Overridepublic 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并不符合页面的需要,所以需要自己定义一个VOreturn 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转为我们的Msgpublic 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的Messagepublic 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:";@Overridepublic 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);}@Overridepublic 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();}@Overridepublic 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:9042local-datacenter: datacenter1
-
基于Cassandra的ChatMemory已经实现了,其它不变。
-
注意:多种ChatMemory实现方案不能共存,只能选择其一。