Java 篇-项目实战-AI 天机学堂(从0到1)-day3

Java 篇-项目实战-AI 天机学堂(从0到1)-day3 java 篇 1.基础地基 2.设计原理 3.项目实战购买课程与知识库-查询课程-实现分析调用这个 OpenFeign 接口实现流程因为如果直接调用返回的数据比较多并不一定都需要所以需要定义一个类。定义 DTO:package com.tianji.aigc.tools.result; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.NumberUtil; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.tianji.api.dto.course.CourseBaseInfoDTO; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Optional; Data Builder NoArgsConstructor AllArgsConstructor public class CourseInfo { JsonPropertyDescription(课程id) private Long id; JsonPropertyDescription(课程名称) private String name; JsonPropertyDescription(课程价格单位为元货币为人民币) private double price; JsonPropertyDescription(课程学习有效期单位月) private Integer validDuration; JsonPropertyDescription(适用人群例如初学者) private String usePeople; JsonPropertyDescription(课程详细介绍) private String detail; _/**_ _ * 将CourseBaseInfoDTO转换为CourseInfo对象_ _ *_ _ * param courseBaseInfoDTO 课程基础信息数据传输对象包含原始课程数据_ _ * return 转换后的课程信息实体对象包含格式化后的价格和详情页URL_ _ */_ _ _public static CourseInfo of(CourseBaseInfoDTO courseBaseInfoDTO) { if (null courseBaseInfoDTO) { return null; } // 基础对象属性拷贝忽略转换错误 CourseInfo courseInfo BeanUtil._toBeanIgnoreError_(courseBaseInfoDTO, CourseInfo.class); // 价格格式化处理分转元 - 四舍五入保留两位小数 - 默认值0.0 courseInfo.setPrice(Optional._ofNullable_(courseBaseInfoDTO.getPrice()) .map(num - num.doubleValue() / 100d) .map(num - NumberUtil._round_(num, 2).doubleValue()) .orElse(0.0d)); return courseInfo; } }public static工具方法可以直接通过类名调用CourseInfo返回类型是课程信息实体of工厂方法命名惯例表示转换/创建参数原始的课程基础信息 DTO基础属性拷贝BeanUtil.toBeanIgnoreError()Hutool 工具方法作用将 DTO 中同名的属性自动拷贝到 CourseInfo 对象中IgnoreError遇到类型不匹配等错误时忽略不中断执行例如DTO 的 name → CourseInfo 的 nameDTO 的 description → CourseInfo 的 description价格格式化处理核心逻辑逐步解析① Optional.ofNullable(courseBaseInfoDTO.getPrice())将价格包装成 Optional避免空指针如果价格为 null后面会走 orElse(0.0d)② .map(num - num.doubleValue() / 100d)分转元数据库通常存储分为单位避免浮点精度问题例如1299 分 → 12.99 元除以 100d 得到元d 是 Java 中表示 double 类型字面量 的后缀。③ .map(num - NumberUtil.round(num, 2).doubleValue())四舍五入保留两位小数NumberUtil.round()Hutool 的精确四舍五入例如12.999 → 13.00④ .orElse(0.0d)如果原始价格为 null默认设置为 0.0定义课程工具类工具描述和工具参数描述放到常量类当中定义常量类package com.tianji.aigc.constants; public interface Constant { interface Tools { String _QUERY_COURSE_BY_ID _ 根据课程id查询课程详细信息; } interface ToolParams { String _COURSE_ID _ 课程id; } }可以直接都罗列但不优雅。这里就用到了之前的 of 方法写好之后就需要在 SpringAIConfig 把它注入进来。下面进行测试在工具里面打个断点输入内容查询课程课程 id 为1589905661084430337发现进到这个工具里面了调用工具成功购买课程与知识库-查询课程-课程卡片实现思路要想实现这个效果就必须给前端返回相应的参数数据前端才能展示卡片但是上述的内容都是大模型返回的都是些文字数据而我们需要给前端的是格式化的数据例如 json 数据该怎么做呢实际上就是在 Flux 输出流的最后做判断如果调用了工具拿到工具的结果追加到输出流的结束标签之前即可。像这样这个参数数据结构是这样的**这个是和前端约定好的结构**这里有一件很重要的事情还没搞清楚就是Tool 执行的结果已经给了大模型我们在 Flux 输出时如何获取到呢**这个问题很重要**要想解决这个问题就必须在全局有个容器工具执行完后将结果放入容器流输出的最后进行判断判断这个容器中是否有数据如果有就添加到流中反之就不需要添加。这样就可以解决问题了。仔细想想其实还有一个问题就是存入这个容器的数据怎么确保是这次请求的结果数据呢能不能和 sessionId 关联这其实是不可以的因为同一个 sessionid 也可能有并发的情况所以不能使用 sessionId那就需要重新生成一个 requestId这个请求 id每次发起大模型时都会生成一个新的 id用这个请求 id 和容器的数据关联起来问题就解决了。基本的流程如下生成一个唯一的请求 ID并传递给工具方法toolContext() 方法这是 ChatClient 或类似类的一个方法作用设置工具调用的上下文参数参数接收一个 Map 类型的参数返回返回自身对象支持继续链式调用Map.of() 静态方法Java 9 引入的静态工厂方法作用快速创建不可变的 Map最多支持 10 对键值对语法Map.of(key1, value1, key2, value2, ...)在 CourseTools 添加接收这个参数定义一个工具结果保持器也就是容器package com.tianji.aigc.config; import cn.hutool.core.lang.Assert; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; _/**_ _ * 工具结果保持器用来存储tools中得到的结果请求id 作为key value为键值对数据_ _ *_ _ * author zzj_ _ * version 1.0_ _ */_ public class ToolResultHolder { private static final MapString, MapString, Object _HANDLER_MAP _ new ConcurrentHashMap(); _/**_ _ * 工具类禁止实例化_ _ */_ _ _private ToolResultHolder() { } public static void put(String key, String field, Object result) { Assert._notNull_(key, key is not null!); Assert._notNull_(field, field is not null!); _HANDLER_MAP_.computeIfAbsent(key, k - new HashMap()).put(field, result); } public static MapString, Object get(String key) { return key null ? null : _HANDLER_MAP_.get(key); } public static Object get(String key, String field) { Assert._notNull_(key, key is not null!); Assert._notNull_(field, field is not null!); return Optional._ofNullable_(_HANDLER_MAP_.get(key)) .map(map - map.get(field)) .orElse(null); } public static void remove(String key) { Assert._notNull_(key, key is not null!); _HANDLER_MAP_.remove(key); } }前面的大 key 对应 request_id而小 key 对应如下HANDLER_MAP.computeIfAbsent(key, k - new HashMap()).put(field, result);获取指定 key 对应的 Map如果不存在就创建一个新的然后往这个 Map 中放入键值对。工具保存数据:整体功能将查询到的课程信息缓存到 ToolResultHolder 中以便在同一次请求的其他地方复用避免重复查询。常量定义定义缓存 key 的格式模板最终会生成类似 courseInfo_12345 这样的 key核心逻辑在 map 中执行MapUtil.get(toolContext.getContext(), Constant.REQUEST_ID, String.class)从 toolContext 的上下文 Map 中取出 Constant.REQUEST_ID 这个 key 对应的值并且把这个值转为 String 类型返回。var field StrUtil.format(FIELD_NAME_FORMAT, StrUtil.lowerFirst(CourseInfo.class.getSimpleName()), courseId);获取类名项目说明CourseInfo.class获取 CourseInfo 类的 Class 对象getSimpleName()获取类的简单名称不包含包名返回值CourseInfo首字母小写项目说明StrUtil.lowerFirst()Hutool 工具将字符串首字母转为小写返回值courseInfo格式化字符串项目说明StrUtil.format()Hutool 的字符串格式化工具执行过程将 {} 占位符依次替换为后面的参数返回值courseInfo_12345ToolResultHolder. _put_ (requestId, field, courseInfo);这段代码只是中间处理副作用操作对 courseInfo 对象本身没有任何实际修改。输出流中添加结果步骤操作目的1concatWith(Flux.defer(...))在响应流结束后追加逻辑2ToolResultHolder.get(requestId)获取缓存的工具调用结果3ToolResultHolder.remove(requestId)清理缓存释放内存4构建 PARAM 事件将参数数据传给前端5发送 STOP_EVENT告诉前端流结束作用创建一个 Flux 流依次发出指定的元素然后自动结束。Flux.just(chatEventVO, STOP_EVENT) 创建了一个响应式流这个流会依次发出参数事件和结束事件然后自动关闭。测试一下还是输入查询课程课程 id 为1589905661084430337测试成功购买课程与知识库-无法存储 params 的 bug 修复params 参数没有内容查看历史记录数据无法回显。定位问题解决思路这个 bug 的解决思路就是在 RedisChatMemoryRepository 中保存数据时获取到 ToolResultHolder 中的数据将数据保存到 params 中即可。但是ToolResultHolder 中的数据是与 requestId 关联的requestId 是我们自己生成的在 RedisChatMemory 中是没有的所以这个问题的关键就是如何获取到 requestId 了只要有了 requestId 就可以获取到数据进行保存了。如何传递 requestId其实同样也是可以借助于 ToolResultHolder 来完成我们可以把 ToolResultHolder 看作是一个**通用的容器**可以放 Tool 的结果也可以放其他的内容只要及时的删除即可。在 ChatServiceImpl 中将 messageId 和 requestId 关联起来在 MessageUtil 当中添加测试一下:params 有值了购买课程与知识库-代码优化:因为是流式输出所以现在是每次都去获取消息 id,但是其实在要结束的时候去获取关联就行了。获取结束原因作用获取 AI 响应的结束原因数据流向购买课程与知识库-反序列化的处理:因为 SpringAI 没有这个参数所以我们需要定义一个类然后继承它来实现MessageUtil 当中**MyAssistantMessage**package com.tianji.aigc.memory; import lombok.Getter; import lombok.Setter; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.content.Media; import java.util.List; import java.util.Map; Getter Setter public class MyAssistantMessage extends AssistantMessage { private MapString, Object params; public MyAssistantMessage(String content, MapString, Object properties, ListToolCall toolCalls, ListMedia media, MapString, Object params) { super(content, properties, toolCalls, media); this.params params; } }要实现原来最多参数的那个方法。MessageUtil 替换原来的 **AssistantMessage**查询的时候也用到了所以 ChatSessionServiceImpl 当中 queryBySessionId 的方法也得改。测试一下测试通过。购买课程与知识库-RAG 基本原理:之所以要使用知识库是因为我们在做课程推荐时需要先从知识库匹配到课程再通过课程 id 查询课程信息进行推荐如果没有知识库就无法根据学生的需求进行推荐所以必须要用到知识库了。实现流程如下部署 es:#win平台 docker run -d \ --name es2 \ -e discovery.typesingle-node \ -e xpack.security.enabledfalse \ -v es2-data:/usr/share/elasticsearch/data \ -v es2-plugins:/usr/share/elasticsearch/plugins \ --privileged \ --network es2-net \ --restartalways \ -p 19200:9200 \ -p 19300:9300 \ registry.cn-beijing.aliyuncs.com/itcast/elasticsearch:8.13.4 --------------------------------------------------------------------------------- #M系列MAC虚拟机 docker run -d \ --name es2 \ -e discovery.typesingle-node \ -e xpack.security.enabledfalse \ -v es2-data:/usr/share/elasticsearch/data \ -v es2-plugins:/usr/share/elasticsearch/plugins \ --privileged \ --network es2-net \ --restartalways \ -p 19200:9200 \ -p 19300:9300 \ docker.1ms.run/elasticsearch:8.13.4 #如果容器已经存在可以先删除再创建 #删除 docker rm -f es2 #清理挂载目录中无用的数据 docker volume prune之后访问[http://192.168.150.101:19200/](http://192.168.150.101:19200/)看到下面就成功了然后在项目中集成 es:在 tj-aigc 项目中导入 SpringAI 集成 ES 的依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-advisors-vector-store/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-vector-store-elasticsearch/artifactId exclusions exclusion groupIdco.elastic.clients/groupId artifactIdelasticsearch-java/artifactId /exclusion /exclusions /dependency dependency groupIdco.elastic.clients/groupId artifactIdelasticsearch-java/artifactId version8.15.5/version /dependency在 nacos 的配置中心的 aigc-service.yaml 文件中进行配置spring: elasticsearch: uris: http://192.168.150.101:19200 ai: dashscope: api-key: ${tj.ai.dashscope.key} chat: enabled: true options: model: qwen-plus # model: qwen-plus # model: qwen2.5-1.5b-instruct 免费模型 embedding: enabled: true options: model: text-embedding-v3 #向量模型 dimensions: 1024 #向量维度维度 vectorstore: #向量库配置 elasticsearch: #使用ES作为向量库存储 initialize-schema: true #开启初始化向量库结构 dimensions: 1024 #向量维度维度放 Nacos放 application.yml✅ 数据库/ES/Redis 连接地址✅ 应用名称、端口✅ API Key、密码、密钥✅ 固定开关enabled: true✅ 环境差异配置✅ 固定维度、模型名✅ 动态开关可实时修改✅ 日志格式、编码✅ 不同环境不同值✅ 本地开发专用配置核心原则会变的放 Nacos不变的放本地敏感信息放 Nacos框架配置放本地。当然也可以都在 nacos 当中配但是不推荐。这里所有关于 spring-ai 的配置都在 nacos 当中配部署 kibana可视化界面部署 kibana 的目的是用于查看 ES 中是否已经创建了索引库。#win平台 docker run -d \ --name kibana2 \ -e ELASTICSEARCH_HOSTShttp://192.168.150.101:19200 \ -p 15601:5601 \ docker.elastic.co/kibana/kibana:8.13.4 #M系列MAC虚拟机 docker run -d \ --name kibana2 \ -e ELASTICSEARCH_HOSTShttp://192.168.150.101:19200 \ -p 15601:5601 \ docker.1ms.run/kibana:8.13.4等待 kibana 启动好之后访问地址[http://192.168.150.101:15601/app/dev_tools#/console](http://192.168.150.101:15601/app/dev_tools#/console)看到页面就成功了现在是直接进入 dev_tools 开发工具如果是首页可以点击这里或者直接搜索默认索引库名字查下看下索引库在不在在不在搜索索引库数据购买课程与知识库-写入数据到知识库:在知识库中需要写入一些数据以供推荐课程使用package com.tianji.aigc.controller; import cn.hutool.core.collection.CollStreamUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; Slf4j RestController RequestMapping(/embedding) RequiredArgsConstructor public class EmbeddingController { private final VectorStore vectorStore; PostMapping public void saveVectorStore(RequestParam(messages) ListString messages) { _log_.info(保存到向量数据库中消息数据{}, messages); //构建文档 ListDocument documents CollStreamUtil._toList_(messages, message - Document._builder_() .text(message) .build()); //存储到向量数据库中 this.vectorStore.add(documents); _log_.info(保存到向量数据库成功, 数量{}, messages.size()); } }将传入的文本消息转换为向量并存储到向量数据库用于后续的相似度搜索和语义检索。依赖注入VectorStore向量数据库接口可能是 Chroma、Pinecone、Milvus 等final RequiredArgsConstructor自动生成构造器注入接口定义注解说明PostMapping处理 POST 请求RequestParam(messages)从请求参数中获取 messages请求示例构建文档对象作用将字符串消息转换为向量数据库的 Document 对象CollStreamUtil.toList()Hutool 工具结合了 Collection 和 Stream存储到向量数据库将每个 Document 的 text 字段转换为向量embedding vector存储向量和原始文本建立索引支持相似度搜索完整数据流准备写入数据注入的是 es先用向量模型进行向量化然后进行保存现在就又多了 3 条数据下面是 1024 维上的数值购买课程与知识库-集成到 chatClient:依赖注入VectorStore向量数据库接口存储了之前通过 /embedding 接口保存的文本向量创建 RAG Advisor作用构建一个 RAG 增强器配置检索参数应用到 AI 对话RAG 工作流程参数详解similarityThreshold(0.6d)相似度阈值只返回相似度 0.6 的结果阈值效果0.9严格匹配返回少但精准0.6-0.7平衡推荐值0.5-宽松返回多但可能有噪音topK(6)最多返回 6 条最相似的结果topK效果小(1-3)上下文少回答简洁中(4-6)平衡推荐值大(10)上下文多但可能引入噪音消耗更多 token先在 apifox 上测下可以,然后前端测试测试通过这里是因为调用工具但那个工具还没有实现。有些模型看到下单购买不会去看 ID所以可以换下模型试下Max 效果比 Plus 好购买课程与知识库-实战任务:练习 1前面我们只实现了向量库的新增操作除了新增操作外我们还可以实现如下的几个接口实现了这几个接口后我们就不需要在 kibana 中操作了。参考代码package com.tianji.aigc.controller; import cn.hutool.core.collection.CollStreamUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.web.bind.annotation.*; import java.util.List; Slf4j RestController RequestMapping(/embedding) RequiredArgsConstructor public class EmbeddingController { private final VectorStore vectorStore; private final EmbeddingModel embeddingModel; PostMapping public void saveVectorStore(RequestParam(messages) ListString messages) { log.info(保存到向量数据库中消息数据{}, messages); //构建文档 ListDocument documents CollStreamUtil.toList(messages, message - Document.builder() .text(message) .build()); //存储到向量数据库中 this.vectorStore.add(documents); log.info(保存到向量数据库成功, 数量{}, messages.size()); } GetMapping public EmbeddingResponse embed(RequestParam(message) String message) { return this.embeddingModel.embedForResponse(List.of(message)); } DeleteMapping public void deleteVectorStore(RequestParam(ids) ListString ids) { // 删除向量数据库中的数据 this.vectorStore.delete(ids); } GetMapping(/search) public ListDocument search(RequestParam(message) String message) { return this.vectorStore.similaritySearch(SearchRequest.builder().query(message).topK(5).build()); } GetMapping(/search/all) public ListDocument searchAll() { // 搜索全部数据 return this.vectorStore.similaritySearch(SearchRequest.builder().query().topK(999).build()); } }练习 2按照SpringAI官方文档实现使用Redis作为向量库存储替换课程中的ES部分。 Redis :: Spring AI Reference实现提示使用Redis实现向量库需要使用到redis-stack。Docker 镜像网址1ms.runhttps://1ms.run/r/redis/redis-stackcopy 过来之后记得改下要走镜像的话加下当然最好不要用 latest 最新版本因为你不知道是哪个版本docker run -d \ --name redis-stack \ -p 16379:6379 \ -p 18001:8001 \ -e REDIS_ARGS--requirepass 123456 \ docker.lms.run/redis/redis-stack:7.2.0-v17 docker run -d \ --name redis-stack \ -p 16379:6379 \ -p 18001:8001 \ -e REDIS_ARGS--requirepass 123456 \ redis/redis-stack:7.2.0-v17第一个端口是 redis 的第二个端口是 redisInsight可视化界面的。下次运行的时候记得启动 redis-stack 服务.执行一下这里我有魔法就不用这个镜像了完整格式逐部分解析各部分组成部分值说明协议redis://使用 Redis 协议连接密码:123456冒号后面跟密码前面没有用户名主机192.168.150.101Redis 服务器的 IP 地址端口16379Redis 服务的端口号http://192.168.150.101:18001/redis-stack/browser这样就有个可视化界面了项目集成 spring-ai-redis 向量存储先注释掉 es 向量库然后加入 redis 向量库依赖vectorstore 才不会出现歧义改下 application-local.yml 当中的配置先把原先的配置注释掉不然两个会覆盖原先的只是提供基础的 redis 操作而新的不仅还。在 nacos 配置中把 es 的注释掉加入 redisinitialize-schema: true项目说明作用应用启动时自动创建 Redis 索引结构值true 自动创建false 手动创建首次使用建议设为 true让框架自动建索引生产环境可以设为 false由 DBA 统一管理index-name: spring-ai-index项目说明作用Redis 索引的名称值spring-ai-index用途标识这个索引是 Spring AI 使用的查询时需要通过这个名字指定使用哪个索引prefix: embedding:项目说明作用Redis Key 的前缀值embedding:用途区分不同用途的数据Redis 中实际存储的 Key这个时候启动还是会报错的得在配置当中加上这个client-type: jedis 用于指定 Spring Boot 使用 Jedis 作为 Redis 的客户端驱动。Spring Boot 支持三种 Redis 客户端client-type说明特点jedisApache Jedis同步阻塞线程不安全需用连接池lettuceLettuce默认异步非阻塞线程安全基于 Nettynone禁用自动配置手动配置对比项JedisLettuce线程安全❌ 需要连接池✅ 线程安全连接池✅ 必须⚠️ 可选异步支持❌✅响应式支持❌✅默认配置❌✅ (Spring Boot)适用场景传统同步项目现代项目推荐client-type: jedis 指定使用 Jedis 作为 Redis 客户端驱动但 Spring Boot 默认推荐 Lettuce除非有特殊需求否则可以省略此配置。但这里就是得用 jedis不然不能生效。测试一下:测试成功前端测试通过如果对你有帮助的话请点赞关注收藏。热爱可抵一切 ❤️