看的黑马的SpringAi教学视频,他用的是1.0.0-m6版本,很多东西都变了,但那又是个测试版,而正式版的1.0.0又有很多不同,所以自己跑官网来看看文档:聊天记忆 (Chat Memory) | Spring AI1.0.0中文文档|Spring官方文档|SpringBoot 教程|Spring中文网
我是以Deepseek模型为例子,推荐统一openai依赖:用了openai就需要加配个base_url
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 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > <dependency > <groupId > com.alibaba</groupId > <artifactId > druid-spring-boot-starter</artifactId > <version > 1.2.16</version > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > <version > 8.0.33</version > </dependency > <dependency > <groupId > org.springframework.ai</groupId > <artifactId > spring-ai-starter-model-chat-memory-repository-jdbc</artifactId > </dependency > <dependency > <groupId > org.springframework.ai</groupId > <artifactId > spring-ai-advisors-vector-store</artifactId > </dependency > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <optional > true</optional > </dependency > <dependency > <groupId > org.springframework.ai</groupId > <artifactId > spring-ai-starter-model-openai</artifactId > </dependency > <dependency > <groupId > org.springframework.ai</groupId > <artifactId > spring-ai-pdf-document-reader</artifactId > </dependency >
内存记忆存储 首先第一个变化就是记忆对话 ,先上代码:
配置类 :
记得去官网看看你的模型是否支持embedding嵌入:
config完整代码(包含了后面的记忆和rag配置)
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 @Configuration public class AiConfig { @Bean public ChatMemoryRepository chatMemoryRepository () { return new InMemoryChatMemoryRepository (); } @Bean public VectorStore vectorStore (ZhiPuAiEmbeddingModel embeddingModel) { return SimpleVectorStore.builder(embeddingModel).build(); } @Bean public ChatMemory chatMemory (ChatMemoryRepository chatMemoryRepository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(chatMemoryRepository) .maxMessages(15 ) .build(); } @Bean public ChatClient aiService (OpenAiChatModel model, ChatMemory chatMemory) { return ChatClient.builder(model) .defaultSystem("你叫何平安,是一个高冷酷酷的IT高手" ) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .build(); } @Bean CommandLineRunner commandLineRunner (@Autowired VectorStore vectorStore, @Value("classpath:static/test.txt") Resource testTxt) { return args -> { vectorStore.write(new TokenTextSplitter ().transform(new TextReader (testTxt).read())); }; } }
其中classpath:static/test.txt为我的rag嵌入文本
接口 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @RestController @RequiredArgsConstructor public class ChatController { private final ChatClient chatClient; private final VectorStore vectorStore; @RequestMapping(value = "/chat",produces = "text/html; charset=UTF-8") public Flux<String> chat (String message,String chatId) { return chatClient.prompt() .user(message) .advisors(QuestionAnswerAdvisor.builder(vectorStore) .searchRequest(SearchRequest.builder() .topK(5 ) .similarityThreshold(0.6 ) .build()).build()) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, chatId)) .stream() .content(); } }
这种是将对话数据存在内存的
区别下1.0.0-m6 的写法:
反正我用了1.0.0-m6后使用advisor会报错:”Handler dispatch failed: java.lang.NoSuchMethodError: ‘reactor.core.publisher.Flux org.springframework.ai.chat.model.MessageAggregator.aggregateAdvisedResponse(reactor.core.publisher.Flux, java.util.function.Consumer)’”所以我就用1.0.0版本了
数据库记忆存储 首先引入依赖:
1 2 3 4 <dependency > <groupId > org.springframework.ai</groupId > <artifactId > spring-ai-starter-model-chat-memory-repository-jdbc</artifactId > </dependency >
支持的数据库:
PostgreSQL 数据库
MySQL / MariaDB
SQL 服务器
HSQLDB 数据库
修改刚刚的配置文件(把ChatMemoryRepository改成JdbcChatMemoryRepository就行):
1 2 3 4 5 6 7 @Bean public ChatMemory chatMemory (JdbcChatMemoryRepository chatMemoryRepository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(chatMemoryRepository) .maxMessages(15 ) .build(); }
使用 JDBC URL 时,可以从 JDBC URL 中自动检测正确的方言 JdbcChatMemoryRepositoryDialect.from(DataSource)
.您可以通过实现JdbcChatMemoryRepositoryDialect
接口,即spring.datasource.url
使用数据库存储记忆后会自动进行初始化创建一张表,官网话说就是Schema 初始化 会自动配置将自动创建SPRING_AI_CHAT_MEMORY
表,使用特定于提供商的数据库 SQL 脚本。默认情况下,架构初始化仅针对嵌入式数据库(H2、HSQL、Derby 等)运行
您可以使用spring.ai.chat.memory.repository.jdbc.initialize-schema
财产:
1 2 3 spring.ai.chat.memory.repository.jdbc.initialize-schema =embedded # 仅适用于嵌入式数据库(默认) spring.ai.chat.memory.repository.jdbc.initialize-schema =always # 总是初始化 spring.ai.chat.memory.repository.jdbc.initialize-schema =never # #不要初始化(对Flyway/ liquebase有用)
目前最佳实践是将initialize-schema设置为never,并设置platform为你的数据库语言像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 spring: datasource: url: jdbc:mysql://localhost:3306/h?useUnicode=true&serverTimezone=Asia/Shanghai&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&autoReconnect=true&allowMultiQueries=true&useSSL=true username: root password: 1234 type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver ai: chat: memory: repository: jdbc: platform: mysql initialize-schema: never
然后再手动创建数据库表,参考mysql.sql:
1 2 3 4 5 6 7 8 9 10 11 -- schema-mysql.sql CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY ( conversation_id VARCHAR(36) NOT NULL, content TEXT NOT NULL, type VARCHAR(10) NOT NULL, `timestamp` TIMESTAMP NOT NULL, CONSTRAINT TYPE_CHECK CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')) ); CREATE INDEX SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX ON SPRING_AI_CHAT_MEMORY(conversation_id, `timestamp`);
扩展方言
要添加对新数据库的支持,请实现JdbcChatMemoryRepositoryDialect
接口,并提供用于选择、插入和删除消息的 SQL。然后,您可以将自定义 dialect 传递给存储库构建器。
1 2 3 4 ChatMemoryRepository chatMemoryRepository = JdbcChatMemoryRepository.builder() .jdbcTemplate(jdbcTemplate) .dialect(new MyCustomDbDialect ()) .build();
还有两种存储方式:CassandraChatMemoryRepository和Neo4jChatMemoryRepository可以去官网看看,这里就不介绍了,新手不怎么用;
检索增强生成(Rag) 首先先引入依赖
1 2 3 4 5 <dependency > <groupId > org.springframework.ai</groupId > <artifactId > spring-ai-advisors-vector-store</artifactId > <version > 1.0.0</version > </dependency >
我这里用的智谱的模型它支持嵌入
1 2 3 4 @Bean public VectorStore vectorStore (ZhiPuAiEmbeddingModel embeddingModel) { return SimpleVectorStore.builder(embeddingModel).build(); }
1 2 3 4 5 6 @Bean CommandLineRunner commandLineRunner (@Autowired VectorStore vectorStore, @Value("classpath:static/test.txt") Resource testTxt) { return args -> { vectorStore.write(new TokenTextSplitter ().transform(new TextReader (testTxt).read())); }; }
我这里把deepseek用作了对话模型,zhipu用于嵌入模型
屎山代码自用 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 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 @Override public Flux<String> aiChat (MessageDto messageDto) { ConversationUser conversationUser = conversationUserMapper.selectOne(new QueryWrapper <ConversationUser>().lambda() .eq(ConversationUser::getConversationId, messageDto.getChatId())); Long currentLoginId = UserUtils.getCurrentLoginId(); if (conversationUser == null ){ conversationUserMapper.insert(new ConversationUser () .setConversationId(messageDto.getChatId()) .setCreatedTime(LocalDateTime.now()) .setTitle(LocalDateTime.now().toString().substring(5 ,9 )+"新对话" ) .setUserId(currentLoginId)); } if (messageDto.getModel() ==null || messageDto.getModel().isEmpty()){ messageDto.setModel("DEEPSEEK" ); } switch (messageDto.getModel()) { case "GPT" ->{ if (messageDto.getFile() == null ){ return ChatClient.builder(openChatModel) .defaultSystem("你叫何平安,是一个高冷酷酷的IT高手" ) .build() .prompt() .user(messageDto.getMessage()) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, messageDto.getChatId())) .stream() .content(); } String fileContent = "" ; MediaType mediaType = MediaType.valueOf(Objects.requireNonNull(messageDto.getFile().getContentType())); String type = mediaType.getType(); boolean isImage = "image" .equals(type); String originalFilename = messageDto.getFile().getOriginalFilename(); String fileExtension = originalFilename.substring(originalFilename.lastIndexOf("." ) + 1 ); final String fileName = UUID.randomUUID() + "." +fileExtension; String url = domin+aliUploadUtils.uploadFile(messageDto.getFile(), "chat" , fileName, isImage); Media media = new Media (mediaType, URI.create(url)); if (isImage){ fileContent ="<picture>" +url+"</picture>" ; }else { fileContent ="<file>" +url+"</file>" ; } OpenAiChatOptions zhiPuAiChatOptions = OpenAiChatOptions.builder() .model("gpt-5" ) .build(); UserMessage userMessage = UserMessage.builder().media(media).text(messageDto.getMessage()).build(); ChatResponse chatResponseFlux = openChatModel.call(new Prompt (List.of(userMessage), zhiPuAiChatOptions)); String text = chatResponseFlux.getResult().getOutput().getText(); String finalFileContent = fileContent; springAiChatMemoryMapper.insert(new SpringAiChatMemory () .setContent(messageDto.getMessage()+ finalFileContent) .setType("USER" ) .setConversationId(messageDto.getChatId()) .setTimestamp(LocalDateTime.now())); springAiChatMemoryMapper.insert(new SpringAiChatMemory () .setContent(text) .setType("ASSISTANT" ) .setConversationId(messageDto.getChatId()) .setTimestamp(LocalDateTime.now())); return Flux.just(text); } case "DEEPSEEK" -> { return ChatClient.builder(deepseekModel) .defaultSystem("你叫何平安,是一个高冷酷酷的IT高手" ) .build() .prompt() .user(messageDto.getMessage()) .advisors(QuestionAnswerAdvisor.builder(vectorStore) .searchRequest(SearchRequest.builder() .topK(5 ) .similarityThreshold(0.6 ) .build()).build()) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, messageDto.getChatId())) .stream() .content(); } case "QWEN" ->{ return ChatClient.builder(qwenModel) .defaultSystem("你叫何平安,是一个高冷酷酷的IT高手" ) .build() .prompt() .user(messageDto.getMessage()) .advisors(QuestionAnswerAdvisor.builder(vectorStore) .searchRequest(SearchRequest.builder() .topK(5 ) .similarityThreshold(0.6 ) .build()).build()) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, messageDto.getChatId())) .stream() .content(); } case "GEMINI" -> { return ChatClient.builder(geminiModel) .defaultSystem("你叫何平安,是一个高冷酷酷的IT高手" ) .build() .prompt() .user(messageDto.getMessage()) .advisors(QuestionAnswerAdvisor.builder(vectorStore) .searchRequest(SearchRequest.builder() .topK(5 ) .similarityThreshold(0.6 ) .build()).build()) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, messageDto.getChatId())) .stream() .content(); } case "GROK" ->{ return ChatClient.builder(gorkModel) .defaultSystem("你叫何平安,是一个高冷酷酷的IT高手" ) .build() .prompt() .user(messageDto.getMessage()) .advisors(QuestionAnswerAdvisor.builder(vectorStore) .searchRequest(SearchRequest.builder() .topK(5 ) .similarityThreshold(0.6 ) .build()).build()) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, messageDto.getChatId())) .stream() .content(); } case "GLM" ->{ if (messageDto.getFile() == null ){ return ChatClient.builder(zhiPuAiChatModel) .defaultSystem("你名字叫何平安,是一个高冷酷酷的IT高手" ) .build() .prompt() .user(messageDto.getMessage()) .advisors(QuestionAnswerAdvisor.builder(vectorStore) .searchRequest(SearchRequest.builder() .topK(5 ) .similarityThreshold(0.6 ) .build()).build()) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, messageDto.getChatId())) .stream() .content(); } String fileContent = "" ; MediaType mediaType = MediaType.valueOf(Objects.requireNonNull(messageDto.getFile().getContentType())); String type = mediaType.getType(); boolean isImage = "image" .equals(type); String originalFilename = messageDto.getFile().getOriginalFilename(); String fileExtension = originalFilename.substring(originalFilename.lastIndexOf("." ) + 1 ); final String fileName = UUID.randomUUID() + "." +fileExtension; String url = domin+aliUploadUtils.uploadFile(messageDto.getFile(), "chat" , fileName, isImage); Media media = new Media (mediaType, URI.create(url)); if (isImage){ fileContent ="<picture>" +url+"</picture>" ; }else { fileContent ="<file>" +url+"</file>" ; } ZhiPuAiChatOptions zhiPuAiChatOptions = ZhiPuAiChatOptions.builder() .model(AiModelConstant.GLM) .build(); UserMessage userMessage = UserMessage.builder().media(media).text(messageDto.getMessage()).build(); Flux<ChatResponse> chatResponseFlux =zhiPuAiChatModel.stream(new Prompt (List.of(userMessage), zhiPuAiChatOptions)); Flux<String> map = chatResponseFlux.mapNotNull(chatResponse -> chatResponse.getResult().getOutput().getText()); String finalFileContent = fileContent; map.doOnNext(chunk -> {}).reduce((s1, s2) -> s1 + s2).subscribe( result -> { springAiChatMemoryMapper.insert(new SpringAiChatMemory () .setContent(messageDto.getMessage()+ finalFileContent) .setType("USER" ) .setConversationId(messageDto.getChatId()) .setTimestamp(LocalDateTime.now())); springAiChatMemoryMapper.insert(new SpringAiChatMemory () .setContent(result) .setType("ASSISTANT" ) .setConversationId(messageDto.getChatId()) .setTimestamp(LocalDateTime.now())); } ); return map; } } return Flux.empty(); }