小汉同学 - AI 超级智能体教程 - 3 - AI 应用开发 笔记

向鱼总和各位小伙伴汇报一下做的内容(有些东西不太好,多多指教,(,,´•ω•)ノ"(´っω•`。))

扩展思路

1)自定义 Advisor,比如权限校验、违禁词校验 Advisor ✅

  • 思路:使用 Advisor 实现了简易违禁词校验。在 resources 下创建了一个违禁词文件,读取后进行判断。(有点垃圾)权限校验准备在aop中去实现
java
复制代码
package com.xiaoh.haiagent.advisor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.advisor.api.*; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; import org.springframework.core.io.ClassPathResource; import reactor.core.publisher.Flux; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; /** * @author: xiaohan * @dataTime: 2025/5/8 13:33 * @desc: 自定义违禁词校验Advisor (从外部文件加载词库) 简易版(学习 AC自动机 + ahocorasick 后尝试重构) */ @Slf4j public class ForbiddenWordsAdvisor implements CallAroundAdvisor, StreamAroundAdvisor { // 违禁词集合 private final Set<String> forbiddenWordsSet; // 违禁词文件路径 private final String forbiddenWordsResourcePath; // 默认构造函数,使用默认路径 public ForbiddenWordsAdvisor() { this("forbidden/default-forbidden-words.txt"); // 默认词库文件 } /** * 构造函数,指定违禁词文件路径 * * @param forbiddenWordsResourcePath classpath下的违禁词文件路径,例如 "forbidden/words.txt" */ public ForbiddenWordsAdvisor(String forbiddenWordsResourcePath) { this.forbiddenWordsResourcePath = forbiddenWordsResourcePath; this.forbiddenWordsSet = loadForbiddenWords(); log.info("Loaded {}(number) forbiddenWords from {}", forbiddenWordsSet.size(), forbiddenWordsResourcePath); } /** * 加载违禁词 * * @return 违禁词集合 */ private Set<String> loadForbiddenWords() { ClassPathResource resource = new ClassPathResource(forbiddenWordsResourcePath); if (!resource.exists()) { log.warn("Forbidden words file not found at: {}", this.forbiddenWordsResourcePath); return new HashSet<>(); } try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { return reader.lines() // 获取每一行 .map(String::trim) // 去除空格 .filter(line -> !line.isEmpty() && !line.startsWith("#")) // 过滤掉空行和注释行 .collect(Collectors.toSet()); } catch (IOException e) { log.error("Error loading forbidden words from {}: {}", this.forbiddenWordsResourcePath, e.getMessage(), e); return new HashSet<>(); } } @Override public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { String userText = advisedRequest.userText(); log.info("{} before advice: Checking for forbidden words in request: {}", getName(), userText); for (String forbiddenWord : forbiddenWordsSet) { if (userText.contains(forbiddenWord)) { log.warn("{} Forbidden word '{}' found in request: {}", getName(), forbiddenWord, advisedRequest.userText()); return createForbiddenWordDetectedResponse(forbiddenWord, advisedRequest.adviseContext(), advisedRequest.userText()); } } return chain.nextAroundCall(advisedRequest); } private AdvisedResponse createForbiddenWordDetectedResponse(String detectedWord, Map<String, Object> adviseContext, String originalText) { String errorMessage = String.format("抱歉,您的请求中包含不当词语,无法处理。检测到: '%s'", detectedWord); log.info("Original text containing forbidden word: {}", originalText); AssistantMessage assistantMessage = new AssistantMessage(errorMessage); Generation generation = new Generation(assistantMessage); ChatResponse chatResponse = new ChatResponse(List.of(generation), new ChatResponseMetadata()); // 添加一些额外的上下文信息 adviseContext.putAll( Map.of("advisor.name", getName(), "advisor.action", "blocked", "advisor.reason", "Forbidden Word Detected", "advisor.detectedWord", detectedWord // 可以选择是否暴露具体哪个词 )); return new AdvisedResponse(chatResponse, adviseContext); } @Override public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { String userText = advisedRequest.userText(); log.info("{} before advice (stream): Checking for forbidden words in request: {}", getName(), userText); for (String forbiddenWord : forbiddenWordsSet) { if (userText.contains(forbiddenWord)) { log.warn("{} Forbidden word '{}' found in request: {}", getName(), forbiddenWord, advisedRequest.userText()); return Flux.just(createForbiddenWordDetectedResponse(forbiddenWord, advisedRequest.adviseContext(), advisedRequest.userText())); } } log.info("{} No forbidden words found (stream). Proceeding with request.", getName()); return chain.nextAroundStream(advisedRequest); } @Override public String getName() { return getClass().getSimpleName(); } @Override public int getOrder() { return 2; } }

2)自定义对话记忆,比如持久化对话到 MySQL 或 Redis 存储中✅

  • 思路:通过实现 ChatMemory 接口中的 3 个方法,然后在创建 client 的时候通过 Advisors 方法设置。使用 mybatis-plus 进行 crud(还是有点垃圾,看了官方文档,它是在 repository 中写的数据库操作,然后在 memory 管理中直接调用,官方文档不是很理解,就是类和类之间的结构联系)
plsql
复制代码
# 创建数据库 CREATE DATABASE IF NOT EXISTS `h_ai_agent` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; # 使用数据库 USE `h_ai_agent`; # 聊天消息历史表 CREATE TABLE IF NOT EXISTS `chat_message_history` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `conversation_id` VARCHAR(255) NOT NULL COMMENT '对话ID', `message_content` TEXT COMMENT '消息内容', `message_type` VARCHAR(50) NOT NULL COMMENT '消息类型 (USER, ASSISTANT, SYSTEM, TOOL_RESPONSE 等)', `metadata_json` TEXT COMMENT '消息元数据 (JSON格式)', `is_deleted` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标志 (0:未删除, 1:已删除)', `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), INDEX `idx_conversation_id_deleted_created` (`conversation_id`, `is_deleted`, `created_at` DESC) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT ='聊天消息历史表';
java
复制代码
package com.xiaoh.haiagent.chatmemeory; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.xiaoh.haiagent.dao.ChatMessageHistory; import com.xiaoh.haiagent.service.ChatMessageHistoryService; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.messages.*; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; /** * @author: xiaohan * @dataTime: 2025/5/8 15:18 * @desc: */ @Slf4j // @Component("MysqlChatMemory") public class MysqlChatMemory implements ChatMemory { private final ChatMessageHistoryService chatMessageHistoryService; private final ObjectMapper objectMapper; public MysqlChatMemory(ChatMessageHistoryService chatMessageHistoryService) { this.chatMessageHistoryService = chatMessageHistoryService; this.objectMapper = new ObjectMapper(); // 支持 Java 8 时间类型序列化/反序列化 // this.objectMapper.registerModule(new JavaTimeModule()); // 可以配置忽略未知属性等,如果需要 // this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } @Override public void add(String conversationId, List<Message> messages) { if (CollectionUtils.isEmpty(messages)) { return; } // todo 简单处理 正常的是一问一答 即每次向数据库中添加两条记录 待完善 List<ChatMessageHistory> list = messages.stream() .skip(Math.max(0, messages.size() - 2)) .map(message -> { try { HashMap<String, Object> metadata = new HashMap<>(message.getMetadata()); // todo 先简化 先弄清楚spring AI中这些对象的属性 // todo 这里应该会报错,有些对象转不了JSON,需要在Jackson 中添加支持 String metadataJson = objectMapper.writeValueAsString(metadata); return ChatMessageHistory.builder() .conversationId(conversationId) .messageContent(message.getText()) .messageType(message.getMessageType().getValue()) .metadataJson(metadataJson) .build(); } catch (JsonProcessingException e) { log.error("Error serializing message metadata to JSON for conversationId: {}, message: {}", conversationId, message.getText(), e); // 根据策略决定是否抛出异常或跳过 throw new RuntimeException("Error serializing message metadata", e); } }).toList(); // 使用 MyBatis Plus 的批量插入功能 chatMessageHistoryService.saveBatch(list); log.debug("Added {} messages to conversationId: {}", messages.size(), conversationId); } @Override public List<Message> get(String conversationId, int lastN) { if (lastN <= 0) { return Collections.emptyList(); } // 构建查询条件 LambdaQueryWrapper<ChatMessageHistory> wrapper = Wrappers.lambdaQuery(ChatMessageHistory.class) .eq(ChatMessageHistory::getConversationId, conversationId) // 根据 conversationId 查询 .orderByDesc(ChatMessageHistory::getCreatedAt)// 按创建时间降序排序 .last("LIMIT " + lastN);// 限制返回的记录数 List<ChatMessageHistory> historyList = chatMessageHistoryService.list(wrapper); // 如果historyList为空,则返回空列表 if (CollectionUtils.isEmpty(historyList)) { return Collections.emptyList(); } // 将结果翻转,随时间升序排列 Collections.reverse(historyList); return historyList.stream() .map(this::convertToSpringAiMessage) .toList(); } private Message convertToSpringAiMessage(ChatMessageHistory entity) { // todo 先简化 先弄清楚spring AI中这些对象的属性 try { String content = entity.getMessageContent(); MessageType messageType = MessageType.fromValue(entity.getMessageType()); Map<String, Object> metadata = new HashMap<>(); if (StringUtils.hasText(entity.getMetadataJson())) { metadata.putAll(objectMapper.readValue(entity.getMetadataJson(), new TypeReference<Map<String, Object>>() {})); } return switch (messageType) { // todo 消息的类型中的数据拼接待完善 case USER -> new UserMessage(content, List.of(), metadata); case ASSISTANT -> new AssistantMessage(content); case SYSTEM -> new SystemMessage(content); default -> throw new IllegalArgumentException("Unsupported message type: " + messageType); }; } catch (JsonProcessingException e) { log.error("Error deserializing metadata for entity: {}", entity.getId(), e); throw new RuntimeException(e); } } @Override public void clear(String conversationId) { // 逻辑删除 conversationId 的数据 LambdaUpdateWrapper<ChatMessageHistory> wrapper = Wrappers.lambdaUpdate(ChatMessageHistory.class) .eq(ChatMessageHistory::getConversationId, conversationId) .set(ChatMessageHistory::getUpdatedAt, LocalDateTime.now()) .set(ChatMessageHistory::getIsDeleted, 1); /* 有问题先看官方文档!!! 妈的 AI瞎编 在 update(T entity, Wrapper<T> updateWrapper) 时,entity 不能为空,否则自动填充失效。 在 update(Wrapper<T> updateWrapper) 时不会自动填充,需要手动赋值字段条件。 */ // 批量执行更新 int updatedCount = chatMessageHistoryService.getBaseMapper().update(wrapper); log.info("Logically cleared {} messages for conversationId: {}", updatedCount, conversationId); } }

3)编写一套包含变量的 Prompt 模板,并保存为资源文件,从文件加载模板✅

  • 使用PromptTemplate 进行填充模版中的预留位置。在 resources 下创建了模版,读取后进行填充。
java
复制代码
/* 模版文件generic_assistant.st 你是一个{assistant_role}。 你的主要任务是:{task_description}。 请根据用户提供的信息来完成任务。 如果你需要更多信息,请明确提出。 */ package com.xiaoh.haiagent.service.prompt; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import java.util.Map; /** * @author: xiaohan * @dataTime: 2025/5/9 11:10 * @desc: 提示语服务 */ @Service @Slf4j public class PromptService { public String getTranslationPrompt(String inputLanguage, String outputLanguage) { PromptTemplate promptTemplate = new PromptTemplate(new ClassPathResource("prompts/translator_assistant.st")); Map<String, Object> map = Map.of("input_language", inputLanguage, "output_language", outputLanguage); return promptTemplate.create(map).getContents(); } public String getSummarizationPrompt(String maxWords, String articleContent) { PromptTemplate promptTemplate = new PromptTemplate(new ClassPathResource("prompts/summarizer_assistant.st")); Map<String, Object> map = Map.of("max_words", maxWords, "article_content", articleContent); return promptTemplate.create(map).getContents(); } public String getGenericAssistantPrompt(String assistantRole, String taskDescription) { PromptTemplate template = new PromptTemplate(new ClassPathResource("prompts/generic_assistant.st")); Map<String, Object> map = Map.of( "assistant_role", assistantRole, "task_description", taskDescription ); return template.create(map).getContents(); } }

4)开发一个多模态对话助手,能够让 AI 解释图片(建议使用国内的 AI 大模型)❌

  • 思路:这个还没有做,我目前的认为是,只要处理好把图片发给 AI 就行了,后面写接口用 multipartFile 完成。

5)阅读 Spring AI 官方的 ChatMemory 文档,了解如何自主构造 ChatMemory✅

  • 思路:通过豆包的翻译+原文对比看,了解了一些(repository 实现数据库操作,memory 调用前者方法完成管理),其他的还是有点不太理解

本节作业

1)完成 AI 恋爱大师应用的开发,或者自己定义一种类型的应用✅

  • 思路:模仿 LoveApp 进行编写
java
复制代码
package com.xiaoh.haiagent.app; import com.xiaoh.haiagent.advisor.ForbiddenWordsAdvisor; import com.xiaoh.haiagent.advisor.InfoLoggerAdvisor; import com.xiaoh.haiagent.chatmemeory.MysqlChatMemory; import com.xiaoh.haiagent.service.ChatMessageHistoryService; import com.xiaoh.haiagent.service.prompt.PromptService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.model.ChatModel; import org.springframework.stereotype.Component; import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY; import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY; /** * @author: xiaohan * @dataTime: 2025/5/9 11:20 * @desc: 智能助手 */ @Component @Slf4j public class AssistantApp { @Resource private PromptService promptService; private final MysqlChatMemory chatMemory; private final ChatClient chatClient; public AssistantApp(ChatModel dashscopeChatModel, ChatMessageHistoryService historyService) { chatMemory = new MysqlChatMemory(historyService); chatClient = ChatClient.builder(dashscopeChatModel) .defaultAdvisors( new MessageChatMemoryAdvisor(chatMemory), new InfoLoggerAdvisor(), new ForbiddenWordsAdvisor() ).build(); } public String doChatWithTranslatorAssistant(String inputLanguage, String outputLanguage, String text, String chatId) { log.info("doChatWithTranslatorAssistant inputLanguage:{}, outputLanguage:{}, text:{}", inputLanguage, outputLanguage, text); String systemPrompt = promptService.getTranslationPrompt(inputLanguage, outputLanguage); return chatClient.prompt() .system(systemPrompt) .user(text) .advisors(advisorSpec -> { advisorSpec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId); advisorSpec.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10); } ).call() .chatResponse() .getResult() .getOutput() .getText(); } public String doChatWithGenericAssistant(String assistantRole, String taskDescription, String question, String chatId) { log.info("doChatWithGenericAssistant assistantRole:{}, taskDescription:{}, question:{}", assistantRole, taskDescription, question); String systemPrompt = promptService.getGenericAssistantPrompt(assistantRole, taskDescription); return chatClient.prompt() .system(systemPrompt) .user(question) .advisors(advisorSpec -> { advisorSpec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId); advisorSpec.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10); }) .call() .chatResponse() .getResult() .getOutput() .getText(); } }

2)理解对话记忆、Advisor、结构化输出的工作流程和原理✅

  • 鱼总笔记中写了的:(我的理解尝试描述)

  • 对话记忆:下次和 AI 对话需要的上下文(和对话历史不一样,可见官方文档)

  • Advisor:拦截器,在和 AI 对话前后可以做增强操作。比如鱼总带着写的 InfoLoggerAdvisor,实现 CallAroundAdvisorStreamAroundAdvisor 接口

  • 结构化输出:通过在和 AI 对话前,在 adviseContextformatParam写入提示 Your response should be in JSON format ...

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

  • 鱼总带着写的建议 record,在调用 AI 的时候调用 entity 设置即可

个人问题

  • 由于时间问题,很多功能只是自己过手一下,并不是很好的设计,想法是先把项目跟完,后面在尝试优化。
  • 用AI讨论思路比较好,因为它比我懂得多,很多思路可以问一问看一看。但是,不要完全相信...
  • 其实过程还遇到很多问题,JSON格式化输出算一个,最先使用gson,都是各种报错,然后后面使用了一种简单的,效果还行的方法
  • 可以使用Jackson打印JSON,这样比较好观察请求和响应的结构,有利于熟悉其结构
    • 效果如下
image.png
java
复制代码
private static final ObjectMapper objectMapper = new ObjectMapper(); static { // 配置 ObjectMapper 使其默认启用缩进输出(即美化打印) objectMapper.enable(SerializationFeature.INDENT_OUTPUT); // 序列化失败时不抛出异常 objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); // 注册 Java 8 Date/Time 模块 objectMapper.registerModule(new JavaTimeModule()); } private AdvisedRequest before(AdvisedRequest advisedRequest) { log.info("{} before advice: {}", getName(), objectJsonString(advisedRequest)); return advisedRequest; } private void observeAfter(AdvisedResponse advisedResponse) { log.info("{} observeAfter advice: {}", getName(), objectJsonString(advisedResponse)); } private String objectJsonString(Object obj) { try { return objectMapper.writeValueAsString(obj); } catch (JsonProcessingException e) { e.printStackTrace(); } return null; }

最后,感谢鱼总的教程,感谢!

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
小汉同学
作者分享
26届,春招,校招。 不知道怎么弄了。。。焦虑中,请教以下各位大佬。 最先的几个月,我在慢吞吞的做项目,感觉需要自己手过一遍才算自己的。然后,又要自己准备得感觉差不多才心里踏实。现在感觉没有方向了。 我前不久准备好了简历,然后复习了两周的面试题。想着开始投简历。但是,我觉得很多岗位的JD我匹配不上,也不知道该不该投。我看了老鱼简历中的信息列表,点进去看感觉都不适合我。。。 看了抖音上的一些视频,去国聘行动,就业平台等,我看了。有一些岗位,但是,它们都必须在它们的平台上弄好简历,还不能上传我做好的简历。要填的东西很多。让我感觉会填很久,然后也不一定有响应。 之前待了一家实习,实习半个月,找我谈签三方,但是我觉得是Python路线,我就放弃了。在学校我线下去投递过,最近这几天去投了来校内的几家,目前还没有消息,感觉是没了。 图片是简历,已经脱敏。希望大佬们可以给我指点一下。目前不知道怎么弄了。 问题: 在哪里找中小公司的校招岗位? 如果JD自我感觉不匹配,是不是不需要投递了? 智联招聘、BOSS是不是和网上说的不适合校招毕业生? 麻烦各位大佬的回复了。谢谢。
4
AI 超级智能体项目教程 - 6 - 工具调用 笔记
1
小汉同学 - AI 超级智能体教程 - 4 - RAG 知识库基础 笔记
4
小汉同学 - AI 超级智能体 - 2 - AI 大模型接入 笔记
5
各位大佬晚上好。想问一下问题,蓝桥杯该怎么准备。我在官方网站上面看见有对应的培训课程。但是有点贵(比较穷)。之前过完了B站满老师的数据结构课程,算是入门小白了吧(我也不知道是不是)。目前在根据满老师提供的文档进行复习。因为自己比较迟钝。所以想各位大佬给我提提方法,如何准备才够这个比赛。如果是选择他们官方的课程的话,我想去pdd去找一下有没有去年课程的,便宜一点😂 感谢各位大佬,感谢感谢!!!😁
3
下载 APP