笔记3-Ai应用开发

代码地址

https://github.com/bbhhe/hu-ai-agent.git

本节内容

1)一个简单得多轮对话应用,熟悉SpringAi的工作流程,理解对话记忆、Advisor

2)自定义Advisor保存对话记忆,并识别响应用户的个性化偏好

3)利用结构化输出特性,将 AI 的输出映射为自定义的 Java 对象

4)实现ChatMemory,把用户对话持久化到Sqlite数据库中

5)开发一个多模态对话助手,能够让 AI 解释图片

一、简单的多轮对话应用

熟悉SpringAi工作流程,根据教程创建一个多轮对话的应用。

新建一个LoveApp

java
复制代码
@Component @Slf4j public class LoveApp { private final ChatClient chatClient; private static final String SYSTEM_PROMPT = "扮演深耕恋爱心理领域的专家。开场向用户表明身份,告知用户可倾诉恋爱难题。" + "围绕单身、恋爱、已婚三种状态提问:单身状态询问社交圈拓展及追求心仪对象的困扰;" + "恋爱状态询问沟通、习惯差异引发的矛盾;已婚状态询问家庭责任与亲属关系处理的问题。" + "引导用户详述事情经过、对方反应及自身想法,以便给出专属解决方案。"; public LoveApp(ChatModel dashscopeChatModel) { //初始化基于内存的对话记忆 InMemoryChatMemory chatMemory = new InMemoryChatMemory(); chatClient = ChatClient.builder(dashscopeChatModel) .defaultSystem(SYSTEM_PROMPT) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); } public String doChat(String message, String chatId){ ChatResponse chatResponse = chatClient.prompt() .user(message) .advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId) .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10)) .call() .chatResponse(); String text = chatResponse.getResult().getOutput().getText(); log.info("content:{}",text); return text; } }

创建测试类

java
复制代码
@SpringBootTest public class LoveAppTest { @Resource private LoveApp loveApp; @Test void testChat(){ String chatId = UUID.randomUUID().toString(); // 第一轮 String message = "你好,我是程序员小胡"; String answer = loveApp.doChat(message, chatId); Assertions.assertNotNull(answer); // 第二轮 message = "我想让另一半(编程导航)更爱我"; answer = loveApp.doChat(message, chatId); Assertions.assertNotNull(answer); // 第三轮 message = "我的另一半叫什么来着?刚跟你说过,帮我回忆一下"; answer = loveApp.doChat(message, chatId); Assertions.assertNotNull(answer); } }

调用测试方法

总结

上面的例子中,AI已经记住了我们的谈话内容,通过ChatClient我们实现了一个AI客户端。

ChatClient理解

ChatClient 是一个抽象接口,定义了与聊天模型进行通信的基本方法。它的主要作用是提供一个统一的 API,使得开发者可以轻松地与不同的聊天模型进行交互,而不需要关心底层的实现细节。

主要功能

  1. 发送消息ChatClient 允许开发者向聊天模型发送消息,并获取模型的响应。
  2. 配置模型:通过 ChatClient,开发者可以配置聊天模型的行为,例如设置温度、最大 token 数等参数。
  3. 处理响应ChatClient 负责处理聊天模型的响应,并将其转换为开发者可以使用的格式。

使用场景

  • 聊天应用:在聊天应用中,ChatClient 可以用于与用户进行自然语言对话。
  • 自动化任务ChatClient 可以用于自动化任务,例如生成文本、回答问题等。
  • 集成到现有系统ChatClient 可以轻松集成到现有的 Spring 应用中,为系统添加 AI 功能。

然后我们通过MessageChatMemoryAdvisor记住了聊天内容。在下一次请求大模型的带入。

二、 自定义Advisor的对话应用

Spring AI 的 Advisors(顾问)机制就像一个灵活的“拦截器”系统,可以在 AI 处理请求前后插入额外的操作,增强 AI 的能力。比如:

  • 前置操作:在调用 AI 前,可以改写用户的 Prompt(提示词)或检查提示词是否安全。

  • 后置操作:在 AI 返回结果后,可以记录日志或对结果进行进一步处理。

下面我们自己设计一个自定义的“拦截器”(Advisor),自己维护用户的对话历史,根据历史对话提供更具上下文关联的响应,并识别用户的个性化偏好。

初始话保存对话记录

首先创建ConversationContextAdvisor类。

在其内部创建UserContext和ConversationTurn内部类,用于记录和保存我们的消息。

然后初始化一个userContexts和MAX_HISTORY,用作运行时保存用户对话和对话的最大次数。

java
复制代码
/** * 上下文记忆Advisor */ @Slf4j public class ConversationContextAdvisor implements CallAroundAdvisor, StreamAroundAdvisor { // 存储用户对话上下文 private final Map<String, UserContext> userContexts = new ConcurrentHashMap<>(); private static final int MAX_HISTORY = 5; @Override public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { return null; } @Override public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { return null; } @Override public String getName() { return this.getClass().getSimpleName(); } @Override public int getOrder() { return 0; } //内部上下文类 private static class UserContext { private final List<ConversationTurn> conversationHistory = new LinkedList<>(); private final Set<String> preferences = new HashSet<>(); public List<ConversationTurn> getConversationHistory() { return conversationHistory; } public Set<String> getPreferences() { return preferences; } } // 对话记录类 public record ConversationTurn(String userMessage, String aiResponse) {} }

AI前置拼接历史和后置更新

1)创建一个enhancePromptWithContext方法,在用户提问前,enhancePromptWithContext 方法会把最近的对话历史拼接到用户的提问中,形成一个更完整的提示词

java
复制代码
private String enhancePromptWithContext(String userMessage, UserContext context) { if(context.getConversationHistory().isEmpty()){ return userMessage; } StringBuffer prompt = new StringBuffer(); prompt.append("以下是之前的对话历史:\\n"); for (ConversationTurn turn : context.getConversationHistory()) { prompt.append("用户: ").append(turn.userMessage()).append("\n"); prompt.append("AI: ").append(turn.aiResponse()).append("\n"); } prompt.append("基于以上上下文,请回答用户的新问题:\n"); prompt.append(userMessage); return prompt.toString(); }
  1. 创建updateUserContext和analyzeUserPreferences方法,会更新对话历史和偏好

java
复制代码
private void updateUserContext(String userId, String userMessage, String aiResponse) { UserContext userContext = userContexts.computeIfAbsent(userId, k -> new UserContext()); userContext.getConversationHistory().add(new ConversationTurn(userMessage, aiResponse)); // 保持历史记录不超过最大值 if (userContext.getConversationHistory().size() > MAX_HISTORY) { userContext.getConversationHistory().remove(0); } // 分析用户偏好 analyzeUserPreferences(userContext, userMessage); } private void analyzeUserPreferences(UserContext context, String message) { // 简单关键词分析 if (message.contains("足球") || message.contains("篮球")) { context.getPreferences().add("体育"); } if (message.contains("电影") || message.contains("电视剧")) { context.getPreferences().add("影视"); } }

用户的个性化偏好响应

  1. 创建一个personalizeResponse 方法会检查用户的兴趣偏好。如果用户喜欢“体育”,AI 的回答会额外附上类似“推荐最新体育资讯”的内容。

java
复制代码
private String personalizeResponse(String response, UserContext userContext) { if (userContext.getPreferences().contains("体育")) { return response + "\n\n(根据您的兴趣,为您推荐最新体育资讯...)"; } return response; }

完善调用流程

最后修改aroundCall和aroundStream方法

java
复制代码
@Override public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { Map<String, Object> inputMap = advisedRequest.adviseContext(); String userId = (String) inputMap.get("userId"); String userMessage = (String) inputMap.get("message"); // 获取或创建用户上下文 UserContext userContext = userContexts.computeIfAbsent(userId, k -> new UserContext()); // 1. 添加上下文到用户消息 String enhancedPrompt = enhancePromptWithContext(userMessage, userContext); //更新用户请求 Map<String,Object> updateContext = new HashMap<>(inputMap); updateContext.put("message", enhancedPrompt); AdvisedRequest updateRequest = AdvisedRequest.from(advisedRequest) .adviseContext(updateContext) .build(); //2.继续处理 AdvisedResponse advisedResponse = chain.nextAroundCall(updateRequest); //3.更新上下文 String response =advisedResponse.response().getResult().getOutput().getText(); updateUserContext(userId,userMessage,response); //4.个性化的响应 String personalizedResponse = personalizeResponse(response, userContext); //返回 return AdvisedResponse.from(advisedResponse) .response(ChatResponse.builder() .from(advisedResponse.response()) .generations(List.of(new Generation(new AssistantMessage(personalizedResponse)))) .build()) .build(); } @Override public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { Map<String, Object> inputMap = advisedRequest.adviseContext(); String userId = (String) inputMap.get("userId"); String userMessage = (String) inputMap.get("message"); // 获取或创建用户上下文 UserContext userContext = userContexts.computeIfAbsent(userId, k -> new UserContext()); // 1. 添加上下文到用户消息 String enhancedPrompt = enhancePromptWithContext(userMessage, userContext); // 更新用户请求 Map<String, Object> updateContext = new HashMap<>(inputMap); updateContext.put("message", enhancedPrompt); AdvisedRequest updatedRequest = AdvisedRequest.from(advisedRequest) .adviseContext(updateContext) .build(); // 2. 创建一个StringBuilder来收集流式响应 StringBuilder responseBuilder = new StringBuilder(); // 3. 处理流式响应 return chain.nextAroundStream(updatedRequest) .map(advisedResponse -> { // 获取当前片段的响应 String chunk = advisedResponse.response().getResult().getOutput().getText(); responseBuilder.append(chunk); // 4. 个性化当前片段 //String personalizedChunk = personalizeResponse(chunk, userContext); // 创建新的响应 return AdvisedResponse.from(advisedResponse) .response(ChatResponse.builder() .from(advisedResponse.response()) .generations(List.of(new Generation(new AssistantMessage(chunk)))) .build()) .build(); }) .doOnComplete(() -> { // 5. 在流完成时更新上下文 String completeResponse = responseBuilder.toString(); updateUserContext(userId, userMessage, completeResponse); }); }

整个调用的流程:

  1. 用户发送请求: 包含用户 ID 和提问内容(通过 AdvisedRequest 传递)。

  2. 前置处理: 获取用户的历史对话。将历史对话融入当前提问,生成增强的提示词。

  3. 调用 AI: 使用增强后的提示词调用 AI,获取回答。

  4. 后置处理: 记录新的对话到历史中。分析用户输入,更新偏好。根据偏好个性化 AI 的回答。

  5. 返回结果: 返回包含个性化内容的响应。

创建MyContextApp

创建MyContextApp服务类

java
复制代码
@Component @Slf4j public class MyContextApp { private final ChatClient chatClient; public MyContextApp(ChatModel dashscopeChatModel) { ConversationContextAdvisor contextAdvisor = new ConversationContextAdvisor(); chatClient = ChatClient.builder(dashscopeChatModel) .defaultAdvisors( contextAdvisor ) .build(); } public String doChat(String userId, String message){ ChatResponse chatResponse = chatClient.prompt() .advisors(a -> a.param("message", message).param("userId", userId)) .user(message) .call() .chatResponse(); String text = chatResponse.getResult().getOutput().getText(); log.info("content:{}",text); return text; } public Flux<String> doChatStream(String userId, String message) { Flux<ChatResponse> chatResponseFlux = chatClient.prompt() .advisors(a -> a.param("message", message).param("userId", userId)) .user(message) .stream() .chatResponse(); return chatResponseFlux.map(chatResponse -> { String text = chatResponse.getResult().getOutput().getText(); log.info("stream chunk: {}", text); return text; }); } }

测试Advisor

创建测试类:MyContextAppTest.java

java
复制代码
@SpringBootTest public class MyContextAppTest { @Autowired private MyContextApp myContextApp; @Test public void testContextMaintenance() { String userId = "test-user-123"; // 第一轮对话 String answer = myContextApp.doChat(userId, "我喜欢足球"); Assertions.assertNotNull(answer); // 第二轮对话 answer = myContextApp.doChat(userId,"最近有什么比赛值得看吗?"); Assertions.assertNotNull(answer); } @Test public void testStreamContextMaintenance() { String userId = "test-user-stream-123"; // 第一轮对话 (使用非流式调用建立上下文) String answer = myContextApp.doChat(userId, "我喜欢足球"); Assertions.assertNotNull(answer); // 第二轮对话 (使用流式调用) List<String> streamResults = myContextApp.doChatStream(userId, "最近有什么比赛值得看吗?") .collectList() .block(); // 阻塞以收集所有流式结果 Assertions.assertNotNull(streamResults); Assertions.assertFalse(streamResults.isEmpty(), "Stream should return at least one chunk"); // 验证流式响应是否包含个性化内容(基于ConversationContextAdvisor的体育偏好) String combinedResult = String.join("", streamResults); Assertions.assertTrue(combinedResult.contains("体育") || combinedResult.contains("比赛"), "Response should reflect sports context or match-related content"); } }

测试结果:

可以看到第二次对话ai已经知道我们第一次问的内容,断点打到第二次请求ai时,第一次对话已经拼接到对话内容中。

三、 结构化输出恋爱报告应用

结构化输出转换器(Structured Output Converter)是 Spring AI 提供的一种实用机制,用于将大语言模型返回的文本输出转换为结构化数据格式,如 JSON、XML 或 Java 类,这对于需要可靠解析 AI 输出值的下游应用程序非常重要。

通过提示词要求要模型按照固定格式回答,然后通过转换器转换成我们需要的bean。

1)修改LoveApp,增加一个doChatWithReport,修改提示词,要求大模型LoveReport格式返回数据给我们。

java
复制代码
public LoveReport doChatWithReport(String message, String chatId) { LoveReport loveReport = chatClient .prompt() .system(SYSTEM_PROMPT + "每次对话后都要生成恋爱结果,标题为{用户名}的恋爱报告,内容为建议列表") .user(message) .advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId) .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10)) .call() .entity(LoveReport.class); log.info("loveReport: {}", loveReport); return loveReport; } record LoveReport(String title, List<String> suggestions) { }

2)修改LoveAppTest,增加测试方法

java
复制代码
@Test void doChatWithReport() { String chatId = UUID.randomUUID().toString(); // 第一轮 String message = "你好,我是小胡,我想让另一半(编程导航)更爱我,但我不知道该怎么做"; LoveApp.LoveReport loveReport = loveApp.doChatWithReport(message, chatId); Assertions.assertNotNull(loveReport); }

测试结果

四、 持久化记忆到Sqlite数据库

在1.2中我们自定义advisor实现维护用户的对话历史,其原理和InMemoryChatMemory一样都是维护在内存中,服务器一旦重启了,对话记忆就会丢失,本小节内容时不同于教程中把数据持久化到文件中,而是保存到Sqlite数据库中。

生成持久化代码

1)首先复制FileBasedChatMemory代码到大模型中,让大模型修改成基于Sqlite数据库。

修改项目代码

2)根据大模型的反馈

引入依赖build.gradle

groovy
复制代码
implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation("org.xerial:sqlite-jdbc:3.49.1.0")

修改配置文件

yaml
复制代码
  spring:     datasource:       driver-class-name: org.sqlite.JDBC       url: jdbc:sqlite:chat_memory.db

创建SQLiteBasedChatMemory.java

java
复制代码
import jakarta.annotation.PostConstruct; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.MessageType; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; import org.springframework.ai.chat.messages.Message; import com.fasterxml.jackson.databind.ObjectMapper; /** * SQLite-based persistent chat memory implementation */ @Component public class SQLiteBasedChatMemory implements ChatMemory { private final JdbcTemplate jdbcTemplate; private final String dbPath; private final ObjectMapper objectMapper; public SQLiteBasedChatMemory() { this.dbPath = "chat_memory.db"; this.objectMapper = new ObjectMapper(); DriverManagerDataSource dataSource = new DriverManagerDataSource(); dataSource.setDriverClassName("org.sqlite.JDBC"); dataSource.setUrl("jdbc:sqlite:" + dbPath); this.jdbcTemplate = new JdbcTemplate(dataSource); } @PostConstruct public void init() { // Create table if it doesn't exist jdbcTemplate.execute(""" CREATE TABLE IF NOT EXISTS chat_messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, message_content TEXT NOT NULL, message_type TEXT NOT NULL, metadata TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """); } @Override public void add(String conversationId, List<Message> messages) { String sql = "INSERT INTO chat_messages (id, conversation_id, message_content, message_type, metadata) VALUES (?, ?, ?, ?, ?)"; for (Message message : messages) { try { String metadataJson = message.getMetadata() != null ? objectMapper.writeValueAsString(message.getMetadata()) : null; jdbcTemplate.update(sql, UUID.randomUUID().toString(), conversationId, message.getText(), message.getMessageType().toString(), metadataJson ); } catch (Exception e) { throw new RuntimeException("Failed to serialize metadata", e); } } } @Override public List<Message> get(String conversationId, int lastN) { String sql = """ SELECT message_content, message_type, metadata FROM chat_messages WHERE conversation_id = ? ORDER BY created_at DESC LIMIT ? """; List<Message> messages = jdbcTemplate.query(sql, new Object[]{conversationId, lastN}, (rs, rowNum) -> { try { Map<String, Object> metadata = null; String metadataJson = rs.getString("metadata"); if (metadataJson != null && !metadataJson.isEmpty()) { metadata = objectMapper.readValue(metadataJson, Map.class); } String content = rs.getString("message_content"); MessageType messageType = MessageType.valueOf(rs.getString("message_type")); // Use appropriate Message implementation based on MessageType switch (messageType) { case USER: return new UserMessage(content); case ASSISTANT: return new AssistantMessage(content, metadata); default: // Fallback for other message types (e.g., SYSTEM) return new UserMessage(content); // Adjust as needed } } catch (Exception e) { throw new RuntimeException("Failed to deserialize metadata", e); } }); // Reverse the list to get chronological order List<Message> reversedMessages = new ArrayList<>(messages.size()); for (int i = messages.size() - 1; i >= 0; i--) { reversedMessages.add(messages.get(i)); } return reversedMessages; } @Override public void clear(String conversationId) { String sql = "DELETE FROM chat_messages WHERE conversation_id = ?"; jdbcTemplate.update(sql, conversationId); } }

大模型把基础代码生成好,几次修改好,可以运行。

3)修改LoveApp,把InMemoryChatMemory改成SQLiteBasedChatMemory

java
复制代码
public LoveApp(ChatModel dashscopeChatModel) {         //初始化基于内存的对话记忆         //InMemoryChatMemory chatMemory = new InMemoryChatMemory();         SQLiteBasedChatMemory chatMemory = new SQLiteBasedChatMemory();         chatClient = ChatClient.builder(dashscopeChatModel)                 .defaultSystem(SYSTEM_PROMPT)                 .defaultAdvisors(                         new MessageChatMemoryAdvisor(chatMemory),                         new MyLoggerAdvisor()                 )                 .build();     }

执行测试LoveAppTest

java
复制代码
@Test void doChatWithReport() { String chatId = UUID.randomUUID().toString(); // 第一轮 String message = "你好,我是小胡,我想让另一半(编程导航)更爱我,但我不知道该怎么做"; LoveApp.LoveReport loveReport = loveApp.doChatWithReport(message, chatId); Assertions.assertNotNull(loveReport); }

连上数据看到数据已经持久化到数据库了。

五、多模态对话助手,能够让 AI 解释图片

待完成,sdk没有java实现,需要先找个服务器上传图片得到图片链接,才能调用阿里大模型

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP