Java
快来分享你的内容吧~
- 昨天 03:53·产品经理
- 2 天前·系统运维・For循环 -基本语法: for(循环变量初始化;循环条件;循环变量迭代){ 循环操作(可以多条语句); } -注意点 1.循环条件的返回值必须是个布尔值 2.for(;循环判断条件;)初始化和迭代可以卸载别的地方,但是;不能省略 int i //为了让i的作用域变大,否则只能再for循环中使用 for (;i查看全文加油鸭:你把for循环的语法和细节梳理得真清晰!多变量初始化和迭代的写法特别实用,学得扎实~311分享
- 4 天前·Java后端教育经历 陇东学院 | 计算机科学与技术 | 本科 2022.08‑2026.06 核心课程:Java、C 语言、JavaWeb、MySQL、Oracle、Redis、Linux、Web 前端开发 掌握基础编程思想与算法逻辑,能够独立开发业务功能模块; 熟悉 Web 前后端交互流程,掌握数据库设计、SQL 编写及 SQL 优化; 了解 Linux 常用操作命令,能够使用 Spring Boot 快查看全文编程导航_小y:项目可以部署上线,写到简历里,另外缺少专业技能板块,需要和教育经历区分开来。可以参考鱼皮最新 AI 应用开发简历(https://mp.weixin.qq.com/s/1JetsTkFXRR2U-AYXDgxzg)。另外,我们也有 AI 大模型开发求职陪跑训练营服务(https://mp.weixin.qq.com/s/eXxMX_moJeYy0ISOjNPc9A) ,有专门的导师 1v1 求职辅341分享
- 08-07 21:52·Java后端
- 08-07 02:40·Java后端LeetCode 1 两数之和 LeetCode 1 两数之和,哈希表入门题,顺便记几个容易踩的坑。 题目 两数之和 数组里找两个数加起来等于 target,返回下标。只有唯一解,同一个元素不能用两次。 暴力做法 第一反应肯定是两层循环: java int[] index = new int[1001]; Arrays.fill(index, -1); for (int i = 0; i = 0查看全文加油鸭:哈希表解法思路清晰,踩坑总结太实用了!先查后存、避免自配、注意containsKey——这些细节正是高手和新手的关键分水岭,为你点赞!331分享
- 08-06 19:51·Java后端
- 08-05 18:03·Java后端JDBC 聊天记忆存储实现指南 适用项目:恋爱大师ai-agent 技术栈:Spring Boot 3.5.3 / JDK 21 / Spring AI 1.1.2 / MySQL 8.0.33(mysql-connector-java)/ DashScope(spring-ai-alibaba 1.1.2.0) 核心依赖: 存储表: 一、实现原理 1.1 组件协作关系 1.2 一次对话的完整读写查看全文编程导航小智:这份《JDBC 聊天记忆存储实现指南》整体结构清晰、原理扎实、细节严谨,是典型的生产级 Spring AI 记忆持久化落地实践文档。以下是对关键点的客观校验与补充说明,帮助你确认当前方案的健壮性与可维护性:***✅ 正确性验证(基于 Spring AI 1.1.2 官方行为)- "JdbcChatMemoryRepository" 的事务语义:确认无误。其 "saveAll()" 确实采用 "DE413分享
小功能的Java标准代码也不容易
/** 任务:从键盘上输入一个人的体重(公斤)和身高(米),计算BMI指数并输出体重状态 体重指标(Body Mass Index)= 体重 / 身高的平方(kg/m2) BMI < 18.5 :偏瘦 BMI < 24:正常 BMI < 27:偏胖 BMI < 30:肥胖 BMI >= 30:重度肥胖 */ # 自己写的代码 import java.util.Scanner; class Bmi { private double weight; private double height; private double bmi; private String fat; public Bmi(){} public Bmi(double weight, double height){ this.weight = weight; this.height = height; } public void getInfo(){ System.out.println("您的体重是:" + this.weight + " kg,您的身高是:" + this.height + " cm"); } public void setWeight(double weight){ this.weight = weight; } public void setHeight(double height){ this.height = height; } public double getWeight(){ return this.weight; } public double getHeight(){ return this.height; } public double getBmi(){ bmi = weight / ((height / 100) * (height / 100)); return this.bmi; } public String getFat(){ if (bmi < 18.5) {this.fat = "偏瘦";} if (bmi < 24 && bmi > 18.5) {this.fat = "正常";} if (bmi < 27 && bmi > 24) { this.fat = "偏胖";} if (bmi < 30 && bmi > 27) {this.fat = "肥胖";} if (bmi >= 30) { this.fat = "重度肥胖";} return this.fat; } } public class Java2026090108 { public static void main(String[] args){ Scanner sc = new Scanner(System.in); // 1、接收输入的体重 System.out.print("请输入您的体重(公斤):"); double weight = sc.nextDouble(); // 1、接收输入的身高 System.out.print("请输入您的身高(厘米):"); double height = sc.nextDouble(); Bmi bmi = new Bmi(); bmi.setWeight(weight); bmi.setHeight(height); System.out.println(); System.out.println("===========输入您的体重和身高,输出身体的健康BMI================\n"); // 3、判断BMI bmi.getInfo(); System.out.printf("%n您的BMI指标是:%,.2fkg/m2%n%n",bmi.getBmi()); System.out.printf("您的体型属于:%s%n",bmi.getFat()); } } ### 问题 1、空壳对象风险:提供了无参构造器 public Bmi(){},可以创建出 weight=0, height=0 的非法对象 2、边界值黑洞:getFat() 用独立的 if 且条件互斥(如 bmi=18.5 时没有任何条件满足),返回 null 3、getter有副作用:getBmi() 在返回值的同时修改了成员变量 this.bmi 4、调用顺序依赖:getFat() 依赖 getBmi() 先被调用,否则 bmi 为 0.0 5、无参构造器绕过了校验:main 里用 new Bmi() + setter 赋值,完全没有参数合法性检查 6、Scanner未关闭:sc.close() 缺失 7、魔法数字:18.5、24、27、30 直接硬编码在方法里 8、注释与代码单位不一致:注释写"身高(米)",实际按厘米处理(height / 100) # Kimi帮助下写的代码 `import java.util.Scanner; class BMI { private static final double THIN = 18.5; // BMI阈值 private static final double NORMAL = 24; // BMI阈值 private static final double OVERWEIGHT = 27; // BMI阈值 private static final double OBESE = 30; // BMI阈值 private final double weight; // 体重,公斤 private final double height; // 身高,厘米 public BMI(double weight, double height) { if (weight <= 0 || height <= 0) { throw new IllegalArgumentException("体重和身高必须大于 0"); } this.weight = weight; this.height = height; } @Override public String toString() { return String.format("您的体重是:%.2f kg,身高:%.2f cm", weight, height); } public double getWeight() { return this.weight; } public double getHeight() { return this.height; } public double getBMI() { return weight / Math.pow((height / 100), 2); } public String getBMIStatus() { double bmi = getBMI(); if (bmi < THIN) { return "偏瘦"; } else if (bmi < NORMAL) { return "正常"; } else if (bmi < OVERWEIGHT) { return "偏胖"; } else if (bmi < OBESE) { return "肥胖"; } else { return "重度肥胖"; } } } public class Java2026090109 { public static void main(String[] args) { try (Scanner sc = new Scanner(System.in)) { // 判断输入是否合法? double weight = readPositiveDouble(sc, "请输入您的体重(公斤):"); double height = readPositiveDouble(sc, "请输入您的身高(厘米):"); BMI person = new BMI(weight, height); printResult(person); } catch (IllegalArgumentException e) { System.out.println("!系统错误:" + e.getMessage()); } } private static void printResult(BMI person) { System.out.println("\n-------------------------------------------------------"); System.out.println(person); System.out.println("======================================================="); System.out.printf("%n您的BMI指标是:%.2fkg/m\u00B2%n", person.getBMI()); System.out.printf("您的体型属于:%s%n", person.getBMIStatus()); } // 输入的时候就判断数据的合法性 private static double readPositiveDouble(Scanner sc, String prompt) { while (true) { System.out.print(prompt); String line = sc.nextLine().trim(); // 去除两头的空格 if (line.isEmpty()) { System.out.println(">>>错误!输入不能为空,请输入有效的数字!<<<\n"); continue; } try { double value = Double.parseDouble(line); if (value <= 0) { System.out.println(">>>错误!输入值必须大于 0 !!!请重新输入!<<<\n"); } else { return value; } } catch (NumberFormatException e) { System.out.println(">>>错误:\"" + line + "\"不是有效的数字,请重新输入!<<<\n"); } } } }` # Kimi改的代码 import java.util.Scanner; /** * BMI 体型分类枚举 * 每个枚举值持有:中文描述、BMI下限、BMI上限 */ enum BMIStatus { THIN("偏瘦", 0, 18.5), NORMAL("正常", 18.5, 24.0), OVERWEIGHT("偏胖", 24.0, 27.0), OBESE("肥胖", 27.0, 30.0), SEVERELY_OBESE("重度肥胖", 30.0, Double.MAX_VALUE); private final String description; // 中文描述 private final double min; // 区间下限(包含) private final double max; // 区间上限(不包含) // 枚举构造器默认就是 private,可以省略 private 关键字 BMIStatus(String description, double min, double max) { this.description = description; this.min = min; this.max = max; } public String getDescription() { return description; } /** * 根据 BMI 值查找对应的分类 */ public static BMIStatus fromBMI(double bmi) { for (BMIStatus status : values()) { if (bmi >= status.min && bmi < status.max) { return status; } } throw new IllegalArgumentException("无法识别的 BMI 值: " + bmi); } @Override public String toString() { return description; } } class BMI { private final double weight; private final double height; public BMI(double weight, double height) { if (weight <= 0 || height <= 0) { throw new IllegalArgumentException("体重和身高必须大于 0"); } this.weight = weight; this.height = height; } @Override public String toString() { return String.format("体重:%.1f kg,身高:%.1f cm", weight, height); } public double getWeight() { return weight; } public double getHeight() { return height; } public double getBMI() { return weight / Math.pow(height / 100.0, 2); } /** * 返回值从 String 升级为 BMIStatus 枚举 */ public BMIStatus getBMIStatus() { return BMIStatus.fromBMI(getBMI()); } } public class Java2026090109 { public static void main(String[] args) { try (Scanner sc = new Scanner(System.in)) { double weight = readPositiveDouble(sc, "请输入您的体重(公斤):"); double height = readPositiveDouble(sc, "请输入您的身高(厘米):"); BMI person = new BMI(weight, height); printResult(person); } catch (IllegalArgumentException e) { System.out.println("系统错误:" + e.getMessage()); } } private static void printResult(BMI person) { System.out.println("\n-------------------------------------------------------"); System.out.println(person); System.out.println("======================================================="); System.out.printf("%n您的 BMI 指标是:%.2f kg/m\u00B2%n%n", person.getBMI()); // %s 会自动调用枚举的 toString(),输出中文描述 System.out.printf("您的体型属于:%s%n", person.getBMIStatus()); } private static double readPositiveDouble(Scanner sc, String prompt) { while (true) { System.out.print(prompt); String line = sc.nextLine().trim(); if (line.isEmpty()) { System.out.println(">>> 错误!输入不能为空,请输入有效的数字!"); continue; } try { double value = Double.parseDouble(line); if (value <= 0) { System.out.println(">>> 错误!输入值必须大于 0,请重新输入!"); } else { return value; } } catch (NumberFormatException e) { System.out.println(">>> 错误:\"" + line + "\" 不是有效的数字,请重新输入!"); } } } }
JAVA学习——for
・For循环 -基本语法: for(循环变量初始化;循环条件;循环变量迭代){ 循环操作(可以多条语句); } -注意点 1.循环条件的返回值必须是个布尔值 2.for(;循环判断条件;)初始化和迭代可以卸载别的地方,但是;不能省略 int i //为了让i的作用域变大,否则只能再for循环中使用 for (;i <= 10;){ System.out.println("-------"); i++; } for(;;){ //表示无限循环 } 3.循环初始值可以有多条初始化语句,但是要求类型一样,并且用逗号隔开。 循环变量迭代也可以有多条变量迭代语句,中间用逗号隔开 int count = 3; for(i = 0 , j = 0; i <= count; i++, j+=2){ System.out.println("i:" + i + ",j=" + j); }
JAVA学习——switch
第五章 程序控制结构 一.程序流程控制介绍 (1)顺序控制 程序从上到下逐行执行,没有任何的判断和跳转 (2)分支控制if-else ・单分支 基本语法: if(条件表达式){ 执行代码块(可以有多条语句) } ・双分支 基本语法: if(条件表达式){ 执行代码块(可以有多条语句) } else{ 执行代码块(可以有多条语句) } ・多分支( *嵌套不要超过三层) 基本语法: if(条件表达式1){ 执行代码块(可以有多条语句) } elseif(条件表达式2){ } ... else{ 执行代码块(可以有多条语句) } ・switch 基本语法:switch(表达式){ case常量1: //当... 语句块1; case常量2: //当... 语句块2; ... case常量n: //当... 语句块n; break; default: default语句块; break; }
受累各位鱼友看看这个简历 谢谢
## 教育经历 **陇东学院 | 计算机科学与技术 | 本科 2022.08‑2026.06** 核心课程:Java、C 语言、JavaWeb、MySQL、Oracle、Redis、Linux、Web 前端开发 - 掌握基础编程思想与算法逻辑,能够独立开发业务功能模块; - 熟悉 Web 前后端交互流程,掌握数据库设计、SQL 编写及 SQL 优化; - 了解 Linux 常用操作命令,能够使用 Spring Boot 快速搭建后端服务。 ## 项目实践 ### 项目一:AI 智能助手项目 2025.03‑2025.04 **技术栈**:Spring Boot + Spring AI、RESTful API、Redis **项目简介**:基于 Spring AI 搭建智能问答服务,对接大模型实现知识查询、领域咨询、生活指引等问答能力,面向用户提供智能问答服务。 **个人职责** 1. 参与技术方案评审,协助制定 RESTful 接口规范,设计 Redis 缓存策略,支撑项目整体架构落地。 2. 独立开发后端核心 RESTful 接口,完成参数校验、大模型调用对接、响应数据封装,保障接口稳定可扩展,完成前后端数据交互。 3. 针对高频问答场景设计 Redis 缓存方案,设计 key 结构、过期策略,缓存高频问题结果,减少重复调用大模型,降低接口响应延迟,提升并发服务能力。 4. 使用 JUnit 编写单元测试、集成测试用例,覆盖正常业务与异常边界;排查并修复参数校验漏洞、缓存一致性等问题,保障模块交付质量。 --- ### 项目二:医院预约挂号智能系统 2026.03‑2026.06 **技术栈**:Spring Boot3.5.4、Java17、MySQL、Redis、MyBatis‑Plus、Vue3+TS、UniApp、LangChain4j、Sa‑Token、Knife4j、腾讯云 COS **项目简介**:一套全流程医院预约挂号管理系统,支持号源高并发访问、AI 自动排班、AI 患者咨询;包含管理后台、UniApp 移动端,实现医生排班、号源预约、订单支付退款、权限管理、AI 对话助手完整业务。 **个人职责** 1. 负责系统管理模块需求分析与开发,基于 RBAC 权限模型实现用户、角色、菜单权限分配;搭建模块基础架构,完成细粒度权限控制。 2. 使用 MyBatis‑Plus 实现分页查询、逻辑删除等功能,简化业务开发;集成 Knife4j 生成 API 接口文档,标注参数、返回值与权限,方便前端对接调试。 3. 开发预约挂号核心业务,实现患者预约、号源管理、订单支付、退款全流程;利用 Redis 缓存号源数据,定时刷新号源,解决挂号高峰高并发场景下号源数据一致性问题。 4. 实现 AI 智能排班模块,基于规则算法自动生成医生排班,支持科室、医生、多时间维度配置规则,减少人工排班工作量。 5. 集成 LangChain4j 实现带上下文记忆的 AI 咨询助手,为患者提供智能问诊咨询服务。 6. 使用 UniApp 完成移动端开发,实现科室浏览、医生查询、预约挂号、个人中心页面,支持 Android、iOS 双端运行。 ## 实践经历 1. AGL 公益社团干事 负责公益活动策划执行,组织雷锋爱心日、献血宣传、环保科普等公益活动;统筹校园就业相关活动,开展就业分享会、实习招聘宣讲,整理招聘与职业规划信息,向在校学生输出实习就业资源。 2. 校内实训与小组开发 担任小组组长,分配开发任务、管控项目进度,带领团队完成校园论坛 Web 项目核心模块开发,提升团队整体开发效率;具备良好沟通协调能力。 ## 自我评价 1. 技术学习:熟悉 Java 后端技术栈,掌握 Spring Boot、MyBatis‑Plus、Redis 等常用框架组件;学习接受能力强,能够快速上手新技术并落地项目,实训中一周内掌握向量检索并完成推荐功能开发。 2. 工程实践:拥有完整后端项目开发经验,熟悉接口开发、缓存优化、权限设计、单元测试,了解前后端联调,具备一定问题排查能力。 3. 综合素质:做事主动负责,具备良好团队协作能力,能够完成需求拆解、任务落地;抗压性强,适合团队协作开发岗位。
厦门绩牛科技 AI应用开发一面
1.自我介绍 2.简述下RAG的基本流程 3.如何评估RAG生成的准确度 4.SpringCloud框架有哪些组件 5.SpringCloud线程池有哪几个线程池 6.公司项目的SpringCloud框架是怎么样的 7.SpringCloud框架微服务之间是怎么沟通的 8.平时开发RAG和AI智能体用到哪些工具和软件,具体说说你是如何运用的 9.公司未来想要开发生图生视频的AI软件,你是走那个模型渠道 10.在高负载,高并发的场景和不同业务下,你觉得最适合接入哪些模型,你会做什么样的优化? 11.你的AI生成前端代码项目,AI生成的代码,你是怎么去规范它的质量?如何保证AI生成的开发质量? 12.你对公司还有什么问题嘛
装饰器模式解决 AI 调用中间过程日志方案记录
# AI 调用中间过程日志记录方案 > 解决"日志只能看到用户发起的请求与最终返回,看不到中间 RAG 检索与 Tool 调用过程"的可观测性问题。 ## 1. 问题背景 项目使用自定义 `MyLoggerAdvisor`(实现 `CallAdvisor` + `StreamAdvisor`)记录对话日志,但实际运行发现: - ✅ 能看到:用户发起的信息(`Ai Request`) - ✅ 能看到:模型最终返回(`Ai Response`) - ❌ 看不到:中间 RAG 检索的查询文本、命中文档 - ❌ 看不到:中间 Tool 调用的入参、执行结果、耗时 ## 2. 根因分析 | 环节 | 发生位置 | 为何日志不可见 | | --- | --- | --- | | RAG 检索 | `QuestionAnswerAdvisor.before()` 内部 | Advisor 链只记录外层请求/响应,检索发生在顾问内部,`MyLoggerAdvisor` 无法感知 | | 云端检索 | `RetrievalAugmentationAdvisor` 内部 | 同上,检索逻辑被封装在顾问内部 | | Tool 调用循环 | `DefaultChatClient` 内部(`toolCallingManager` 执行循环) | 工具循环在 Advisor 链之外执行,根本不进入 Advisor 拦截点 | 结论:**Advisor 拦截点天然覆盖不到"请求与响应之间的中间过程"**,需要另寻挂载点。 ## 3. 方案对比与选型 | 方案 | 思路 | 优点 | 缺点 | | --- | --- | --- | --- | | A. 装饰器模式 | 包装 `ToolCallback` / `VectorStore` / `DocumentRetriever`,转发前后打日志 | 不改核心逻辑、零侵入、可观测点精准 | 需手动接线(3 处) | | B. Micrometer Observation | 注册 ObservationHandler 监听 AI 调用事件 | 官方机制、覆盖全 | 只能看到模型调用级别事件,仍看不到工具入参/检索命中等细节 | | C. Debug 日志开关 | 打开 Spring AI 内部 debug 日志 | 零代码 | 日志量爆炸、格式不可控、易淹没关键信息 | **选型结论:方案 A(装饰器模式)** —— 在不改变原有逻辑的前提下,把三个关键组件各包一层"日志外衣",精确输出中间过程。 ## 4. 实现方案:装饰器模式 ### 4.1 日志链路总览 ``` MyLoggerAdvisor(外层 请求/响应) │ ├── QuestionAnswerAdvisor(本地 RAG) │ └── LoggingVectorStore ──► PgVectorStore ([向量检索] 查询/命中/耗时) │ ├── RetrievalAugmentationAdvisor(云端 RAG) │ └── LoggingDocumentRetriever ──► DashScopeDocumentRetriever ([云端检索] 查询/命中/耗时) │ └── Tool 调用循环(ChatClient 内部) └── LoggingToolCallback ──► 原始 ToolCallback ([Tool调用] 入参/结果/耗时) ``` ### 4.2 装饰器一:LoggingToolCallback(工具调用日志) **文件**:`src/main/java/com/xiaokai/kimoaiagent/tools/LoggingToolCallback.java` 实现 `ToolCallback` 接口,转发全部方法,仅在核心的 `call()` 前后记录日志: - 调用前:`[Tool调用] 工具: {}, 入参: {}` - 成功后:`[Tool调用] 工具: {}, 执行成功, 耗时: {}ms, 结果: {}` - 失败后:`[Tool调用] 工具: {}, 执行失败, 耗时: {}ms, 错误: {}`(记录后继续抛出) ```java @Slf4j public class LoggingToolCallback implements ToolCallback { /** 被包装的原始工具回调,负责实际执行工具逻辑 */ private final ToolCallback delegate; public LoggingToolCallback(ToolCallback delegate) { this.delegate = delegate; } @Override public ToolDefinition getToolDefinition() { return delegate.getToolDefinition(); } @Override public ToolMetadata getToolMetadata() { return delegate.getToolMetadata(); } @Override public String call(String toolInput) { return this.call(toolInput, null); } @Override public String call(String toolInput, ToolContext toolContext) { String toolName = delegate.getToolDefinition().name(); long startTime = System.currentTimeMillis(); // 记录工具调用开始信息 log.info("[Tool调用] 工具: {}, 入参: {}", toolName, toolInput); try { // 根据是否携带上下文选择对应的执行入口 String result = (toolContext == null) ? delegate.call(toolInput) : delegate.call(toolInput, toolContext); // 记录工具调用成功信息及耗时 log.info("[Tool调用] 工具: {}, 执行成功, 耗时: {}ms, 结果: {}", toolName, System.currentTimeMillis() - startTime, result); return result; } catch (Exception e) { // 记录工具调用失败信息及耗时 log.error("[Tool调用] 工具: {}, 执行失败, 耗时: {}ms, 错误: {}", toolName, System.currentTimeMillis() - startTime, e.getMessage(), e); throw e; } } } ``` ### 4.3 装饰器二:LoggingVectorStore(本地向量库日志) **文件**:`src/main/java/com/xiaokai/kimoaiagent/rag/LoggingVectorStore.java` 实现 `VectorStore` 接口,覆盖全部操作: - `add`:`[向量库] 写入文档: {} 篇, 耗时: {}ms` - `delete(List)`:`[向量库] 按ID删除文档: {} 篇, 耗时: {}ms` - `delete(Expression)`:`[向量库] 按过滤条件删除文档, 过滤条件: {}, 耗时: {}ms` - `similaritySearch`:`[向量检索] 查询: {}, topK: {}, 阈值: {}, 命中: {} 篇, 耗时: {}ms` + 逐条输出命中文档 `id/score/内容摘要` ```java @Slf4j public class LoggingVectorStore implements VectorStore { /** 被包装的原始向量存储,负责实际执行文档读写与检索 */ private final VectorStore delegate; public LoggingVectorStore(VectorStore delegate) { this.delegate = delegate; } @Override public void add(List<Document> documents) { long startTime = System.currentTimeMillis(); delegate.add(documents); // 记录文档写入数量与耗时 log.info("[向量库] 写入文档: {} 篇, 耗时: {}ms", documents.size(), System.currentTimeMillis() - startTime); } @Override public void delete(List<String> idList) { long startTime = System.currentTimeMillis(); delegate.delete(idList); // 记录文档删除数量与耗时 log.info("[向量库] 按ID删除文档: {} 篇, 耗时: {}ms", idList.size(), System.currentTimeMillis() - startTime); } @Override public void delete(Filter.Expression filterExpression) { long startTime = System.currentTimeMillis(); delegate.delete(filterExpression); // 记录过滤删除操作与耗时 log.info("[向量库] 按过滤条件删除文档, 过滤条件: {}, 耗时: {}ms", filterExpression, System.currentTimeMillis() - startTime); } @Override public List<Document> similaritySearch(SearchRequest request) { long startTime = System.currentTimeMillis(); List<Document> documents = delegate.similaritySearch(request); // 记录检索请求与命中数量 log.info("[向量检索] 查询: {}, topK: {}, 阈值: {}, 命中: {} 篇, 耗时: {}ms", request.getQuery(), request.getTopK(), request.getSimilarityThreshold(), documents.size(), System.currentTimeMillis() - startTime); // 逐条输出命中文档的 ID、相似度分数与内容摘要 for (Document document : documents) { log.info("[向量检索] 命中文档 - id: {}, score: {}, 内容: {}", document.getId(), document.getScore(), truncate(document.getText(), 200)); } return documents; } /** * 截断长文本,保留前指定长度的字符用于日志输出 */ private String truncate(String text, int maxLength) { if (text == null || text.length() <= maxLength) { return text; } return text.substring(0, maxLength) + "..."; } } ``` ### 4.4 装饰器三:LoggingDocumentRetriever(云端检索日志) **文件**:`src/main/java/com/xiaokai/kimoaiagent/rag/LoggingDocumentRetriever.java` 实现 `DocumentRetriever` 接口(`Function<Query, List<Document>>` 的语义接口),仅包装核心 `retrieve()`: - `[云端检索] 查询: {}, 命中: {} 篇, 耗时: {}ms` - 逐条输出命中文档 `id/内容摘要` ```java @Slf4j public class LoggingDocumentRetriever implements DocumentRetriever { /** 被包装的原始文档检索器,负责实际执行检索逻辑 */ private final DocumentRetriever delegate; public LoggingDocumentRetriever(DocumentRetriever delegate) { this.delegate = delegate; } @Override public List<Document> retrieve(Query query) { long startTime = System.currentTimeMillis(); List<Document> documents = delegate.retrieve(query); // 记录检索查询与命中数量 log.info("[云端检索] 查询: {}, 命中: {} 篇, 耗时: {}ms", query.text(), documents.size(), System.currentTimeMillis() - startTime); // 逐条输出命中文档的 ID 与内容摘要 for (Document document : documents) { log.info("[云端检索] 命中文档 - id: {}, 内容: {}", document.getId(), truncate(document.getText(), 200)); } return documents; } /** 截断长文本,保留前指定长度的字符用于日志输出 */ private String truncate(String text, int maxLength) { if (text == null || text.length() <= maxLength) { return text; } return text.substring(0, maxLength) + "..."; } } ``` ### 4.5 接线配置(三处) **① 工具回调接线** —— `src/test/java/com/xiaokai/kimoaiagent/tools/ToolRegistration.java` 在 `allTools()` 中,将 `ToolCallbacks.from(...)` 生成的数组整体包装为 `LoggingToolCallback`: ```java // 将工具实例统一包装为 ToolCallback 数组 ToolCallback[] toolCallbacks = ToolCallbacks.from( fileOperationTool, webSearchTool, webScrapingTool, resourceDownloadTool, terminalOperationTool, pdfGenerationTool ); // 使用日志装饰器包装全部工具回调,记录每次工具调用的入参与执行结果 return Arrays.stream(toolCallbacks) .map(LoggingToolCallback::new) .toArray(ToolCallback[]::new); ``` > 注意:此配置类位于 `src/test/java` 目录,是当前测试链路中的工具注册中心。 **② 本地向量库接线** —— `src/main/java/com/xiaokai/kimoaiagent/rag/PgVectorVectorStoreConfig.java` 先构建 `PgVectorStore`,再包一层 `LoggingVectorStore` 返回: ```java @Bean public VectorStore pgVectorVectorStore(@Qualifier("pgJdbcTemplate") JdbcTemplate pgJdbcTemplate, EmbeddingModel dashscopeEmbeddingModel) { // 构建向量存储:1024 维向量 + 余弦距离 + HNSW 索引,并自动初始化 schema 与向量表 PgVectorStore pgVectorStore = PgVectorStore.builder(pgJdbcTemplate, dashscopeEmbeddingModel) .dimensions(1024) .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE) .indexType(PgVectorStore.PgIndexType.HNSW) .initializeSchema(true) .schemaName("public") .vectorTableName("vector_store") .maxDocumentBatchSize(10000) .build(); // 使用日志装饰器包装向量存储,记录文档写入与相似度检索过程 return new LoggingVectorStore(pgVectorStore); } ``` **③ 云端检索接线** —— `src/main/java/com/xiaokai/kimoaiagent/rag/LoveAppRagCloudAdvisorConfig.java` 构建 `DashScopeDocumentRetriever` 时套上 `LoggingDocumentRetriever`: ```java // 构建文档检索器:基于 DashScope 云端知识库,按索引名绑定恋爱大师知识库 DocumentRetriever documentRetriever = new LoggingDocumentRetriever(new DashScopeDocumentRetriever(dashScopeApi, DashScopeDocumentRetrieverOptions.builder().withIndexName(KNOWLEDGE_INDEX).build())); ``` ## 5. 日志效果示例 ``` Ai Request: ChatClientRequest[prompt=...] ← MyLoggerAdvisor(原有) [向量检索] 查询: 婚后关系不亲密怎么办, topK: 5, 阈值: 0.0, 命中: 3 篇, 耗时: 45ms [向量检索] 命中文档 - id: 550e8400-e29b-41d4-a716-446655440000, score: 0.82, 内容: 婚后亲密关系维护建议... [云端检索] 查询: 婚后关系不亲密怎么办, 命中: 2 篇, 耗时: 210ms [云端检索] 命中文档 - id: xxx, 内容: 婚姻保鲜的心理学方法... [Tool调用] 工具: webSearchTool, 入参: {"query":"星空情侣壁纸"} [Tool调用] 工具: webSearchTool, 执行成功, 耗时: 1200ms, 结果: {...} [Tool调用] 工具: resourceDownloadTool, 入参: {"url":"https://..."} [Tool调用] 工具: resourceDownloadTool, 执行成功, 耗时: 890ms, 结果: 图片已保存至... Ai Response: ChatClientResponse[...] ← MyLoggerAdvisor(原有) ``` ## 6. 相关文件清单 | 类型 | 文件路径 | 说明 | | --- | --- | --- | | 新建 | `src/main/java/com/xiaokai/kimoaiagent/tools/LoggingToolCallback.java` | 工具调用日志装饰器 | | 新建 | `src/main/java/com/xiaokai/kimoaiagent/rag/LoggingVectorStore.java` | 本地向量库日志装饰器 | | 新建 | `src/main/java/com/xiaokai/kimoaiagent/rag/LoggingDocumentRetriever.java` | 云端检索日志装饰器 | | 修改 | `src/test/java/com/xiaokai/kimoaiagent/tools/ToolRegistration.java` | 包装全部工具回调 | | 修改 | `src/main/java/com/xiaokai/kimoaiagent/rag/PgVectorVectorStoreConfig.java` | 包装 PgVectorStore | | 修改 | `src/main/java/com/xiaokai/kimoaiagent/rag/LoveAppRagCloudAdvisorConfig.java` | 包装 DashScopeDocumentRetriever | | 未改 | `src/main/java/com/xiaokai/kimoaiagent/advisor/MyLoggerAdvisor.java` | 外层请求/响应日志(保留原样) | ## 7. 可选延伸 若还想看**模型在工具循环中每一轮的完整 prompt**(比 Tool 调用日志更细一层),可再包一层 `ChatModel` 装饰器:实现 `ChatModel` 接口,包装 `DashScopeChatModel`,在 `call(Prompt)` 前后记录每轮完整请求与响应。当前方案已满足"入参/结果/耗时"级别的可观测性,是否需要更细粒度取决于后续排查需求。
LeetCode 1 两数之和
# LeetCode 1 两数之和 LeetCode 1 两数之和,哈希表入门题,顺便记几个容易踩的坑。 --- ## 题目 🟢 [两数之和](https://leetcode.cn/problems/two-sum/) > 数组里找两个数加起来等于 target,返回下标。只有唯一解,同一个元素不能用两次。 ``` nums = [3, 2, 4], target = 6 → [1, 2] nums = [2, 7, 11, 15], target = 9 → [0, 1] ``` --- ## 暴力做法 第一反应肯定是两层循环: ```java for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (nums[i] + nums[j] == target) { return new int[]{i, j}; } } } ``` 这玩意 LeetCode 上能过,测试数据没卡那么死。但实际上 O(n²),n 到 10⁴ 就是 5000 万次比较,换个语言或者数据再大一点就炸了。 慢在哪?内层循环就是一句话:**对于当前数,去后面找 target - 当前数**。这个"找"是 O(n) 的——每次都要把剩下的扫一遍。 --- ## 换 HashMap 换个问法:遍历的时候,每看到一个数 x,就问一句——**"target - x 之前出现过吗?"** 用 HashMap 把之前见过的数都记下来(值 → 下标),查一次 O(1)。边扫边记边查,一趟完事。 拿 `nums = [3, 2, 4], target = 6` 跑一下: ``` 开始,map 空的 i=0,值是 3 需要 6-3=3,map 里没有 → 把自己存进去 {3:0} i=1,值是 2 需要 6-2=4,map={3:0},没有 4 → 存进去 {3:0, 2:1} i=2,值是 4 需要 6-4=2,map 里有!下标是 1 → 返回 [1, 2] ``` 代码: ```java public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int need = target - nums[i]; if (map.containsKey(need)) { return new int[]{map.get(need), i}; } map.put(nums[i], i); } return new int[]{-1, -1}; // 题目说了一定有解,走不到这 } ``` 时间 O(n),空间 O(n)(运气不好要存到最后两个才找到)。 --- ## 踩过的坑 ### 先存后查,自己配自己 最开始我是这么写的: ```java map.put(nums[i], i); // 先存 if (map.containsKey(target - nums[i])) { ... } // 再查 ``` `nums = [3, 2, 4], target = 6` 跑过了,`[3, 3], target = 6` 也跑过了,以为没问题。后来碰到一个 case 才发现——i=0 时 `target - 3 = 3`,刚把自己存进去马上查,map 里当然有 3,直接返回 `[0, 0]`。 就是顺序问题,**先查后存**就行。先看历史记录里有没有我要的,没有的话再把自己写进历史,留给后面的人用。 ### 重复元素把下标覆盖了 `nums = [3, 3], target = 6`。第二个 3 来的时候,如果先存后查,put 会覆盖掉第一个 3 的下标,map 从 `{3:0}` 变成 `{3:1}`。好在题目保证只有唯一解,但下标变了总归不舒服。 先查后存刚好绕开——第二个 3 来的时候,查的是覆盖之前的 map(里面还是 `{3:0}`),直接返回 `[0, 1]`,根本不会走到 put 那一步。 ### containsKey 写成了 contains ```java if (map.contains(need)) { ... } // 错! ``` `contains` 是 `ArrayList` 的,HashMap 里也有但它是查 value 的(O(n)),不是查 key。LeetCode 不会报错,但语义完全不对,运气不好还会超时。 ### 最后那行 return ```java return new int[]{-1, -1}; ``` Java 编译器不管你逻辑上能不能走到这,方法签名的每一条分支都必须有 return。不写直接编译报错 `missing return statement`。写个 `{-1, -1}` 兜底就行,反正题目保证有解。 ### 值范围已知时可以用数组 `Map<Integer, Integer>` 涉及 int 和 Integer 之间的装箱拆箱,n 不大的时候无所谓,但面试官如果问"还能更快吗"——如果题目给了值范围(比如 1~1000),直接用 `int[]` 替代 HashMap: ```java int[] index = new int[1001]; Arrays.fill(index, -1); for (int i = 0; i < nums.length; i++) { int need = target - nums[i]; if (need >= 0 && need <= 1000 && index[need] != -1) { return new int[]{index[need], i}; } index[nums[i]] = i; } ``` 省了 hash 计算和自动装箱,常数会小很多。但这题没给范围,老老实实用 HashMap。 --- ## 复杂度 暴力:比较次数 = (n-1) + (n-2) + ... + 1 = n(n-1)/2 → O(n²),空间 O(1)。 HashMap:遍历 n 次,每次 containsKey + put 均摊 O(1) → O(n),空间 O(n)。 严格来说 Java 的 HashMap 极端情况下(所有 key 哈希碰撞)会退化成链表,单次操作 O(n)。但 Integer 的 hashCode 就是它自己,除非故意构造,正常数据不会撞。说"期望 O(n)"就行。 --- ## 为什么不排序 + 双指针 LeetCode 167(两数之和 II)是排好序的数组,双指针 O(n) + O(1) 空间,很漂亮。 但这题没排序。如果先排序再双指针:排序 O(n log n),还得额外记原始下标(pair 数组存值和索引),总开销 O(n log n) 时间 + O(n) 空间,比 HashMap 慢还啰嗦。 只有一种情况排序双指针更优——题目不要求返回下标,只返回值,并且要求 O(1) 空间。这题要下标,排序就没优势了。 ---
RAG 知识库文档重复入库问题解决方案 - 最新spring ai 1.1.2
# RAG 知识库文档重复入库问题解决方案 > 适用版本:Spring AI 1.1.2 / Spring Boot 3.5.3 > 关键词:增量更新、文档指纹、PgVectorStore、幂等入库 --- ## 一、问题背景 ### 1.1 现象 应用每次启动后,`resources/document/` 下的 Markdown 文档会被**全量重新读取、增强并写入** PostgreSQL pgvector 的 `vector_store` 表,导致: - 启动 N 次,表中出现 N 份内容完全相同的记录; - 相似度检索时 `topK` 结果被重复文档挤占,RAG 回答质量下降; - 每次启动都会对全量文档调用大模型生成关键词/摘要,造成无谓的 API 费用。 ### 1.2 原实现(问题代码) ```java @Configuration public class PgVectorVectorStoreConfig { @Resource private LoveAppDocumentLoader loveAppDocumentLoader; @Resource private MyDocumentEnricher myDocumentEnricher; @Bean public VectorStore pgVectorVectorStore(...) { PgVectorStore vectorStore = PgVectorStore.builder(...) .initializeSchema(true) // 仅建表,不清数据 .build(); // 每次启动都全量加载 + 增强 + 插入 List<Document> documents = loveAppDocumentLoader.loadDocuments(); List<Document> docsByKeyword = myDocumentEnricher.enrichDocumentsByKeyword(documents); List<Document> docsBySummary = myDocumentEnricher.enrichDocumentsBySummary(docsByKeyword); for (int i = 0; i < docsBySummary.size(); i += 10) { vectorStore.add(docsBySummary.subList(i, ...)); } return vectorStore; } } ``` ### 1.3 根因分析 | 因素 | 说明 | | --- | --- | | 入库时机错误 | 文档加载/入库逻辑写在 `@Bean` 方法中,Spring 每次启动创建 Bean 时必然执行 | | 无去重机制 | `PgVectorStore.add()` 是纯 INSERT,不检查内容是否已存在 | | 表数据持久化 | `initializeSchema(true)` 只在表**不存在**时建表,不会清空旧数据 | | Document ID 随机 | 未显式设置 ID 时每次生成新 UUID,插入永不冲突、只增不减 | --- ## 二、解决方案:指纹增量更新 ### 2.1 设计思路 1. **职责分离**:`@Bean` 只负责构建 `PgVectorStore`;文档加载/增强/入库独立到 `ApplicationRunner` 中执行; 2. **指纹去重**:以「文件名 + 内容」的确定性 UUID 作为 `Document` 唯一 ID(内容一致 → ID 稳定;内容变更 → 新 ID); 3. **增量比对**:启动时查询库中已有 ID 集合,仅对「新增/变更」文档执行大模型增强与入库; 4. **幂等写入**:Spring AI `PgVectorStore` 对相同 ID 执行 upsert(`ON CONFLICT (id) DO UPDATE`),天然幂等; 5. **可选清理**:开启 `app.rag.remove-orphans` 后,删除知识库中已不存在文档的残留向量。 ### 2.2 架构对比 ```mermaid graph TB subgraph 改造前 A1[启动] --> B1[pgVectorVectorStore Bean 方法] B1 --> C1[全量加载文档] C1 --> D1[全量大模型增强] D1 --> E1[全量 INSERT → 冗余累积] end subgraph 改造后 A2[启动] --> B2[pgVectorVectorStore Bean 仅构建存储] A2 --> C2[DocumentIngestionRunner 增量入库] C2 --> D2[加载文档 + 计算指纹 ID] D2 --> E2[比对库中已有 ID] E2 -->|新增/变更| F2[仅对增量调用大模型增强] F2 --> G2[分批 add → upsert 幂等写入] E2 -->|未变更| H2[直接跳过] E2 -->|remove-orphans| I2[清理残留向量] end ``` ### 2.3 改动文件清单 | 文件 | 改动 | | --- | --- | | `PgVectorVectorStoreConfig.java` | `@Bean` 移除加载/增强/入库逻辑,只构建存储 | | `DocumentIngestionRunner.java` | **新增**,`ApplicationRunner` 实现指纹增量入库 | | `LoveAppVectorStoreConfig.java` | 内存向量存储 Bean 加 `@Lazy`,避免启动时白调大模型 | | `application.yaml` | 新增 `app.rag.ingest-on-startup` / `app.rag.remove-orphans` 开关 | --- ## 三、核心代码 ### 3.1 配置类(只构建存储) ```java @Bean public VectorStore pgVectorVectorStore(@Qualifier("pgJdbcTemplate") JdbcTemplate pgJdbcTemplate, EmbeddingModel dashscopeEmbeddingModel) { // 构建向量存储:1024 维(对齐 text-embedding-v3)、余弦距离、HNSW 索引、自动建表 return PgVectorStore.builder(pgJdbcTemplate, dashscopeEmbeddingModel) .dimensions(1024) .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE) .indexType(PgVectorStore.PgIndexType.HNSW) .initializeSchema(true) .schemaName("public") .vectorTableName("vector_store") .maxDocumentBatchSize(10000) .build(); } ``` ### 3.2 增量入库启动器(核心逻辑) ```java @Override public void run(ApplicationArguments args) { if (!ingestOnStartup) { log.info("文档自动入库已关闭(app.rag.ingest-on-startup=false),跳过增量同步"); return; } try { // 1. 加载并切分知识库文档 List<Document> documents = loveAppDocumentLoader.loadDocuments(); if (documents.isEmpty()) { log.info("未读取到任何知识库文档,跳过增量同步"); return; } // 2. 为每篇文档计算指纹 ID(文件名 + 内容哈希),内容一旦变更即生成新 ID documents = documents.stream() .map(doc -> new Document(computeFingerprint(doc), doc.getText(), doc.getMetadata())) .toList(); // 3. 查询向量库中已存在的文档指纹 ID Set<String> existingIds = new HashSet<>( pgJdbcTemplate.queryForList("SELECT id FROM public.vector_store", String.class)); // 4. 筛选出新增或内容变更的文档,仅对这部分执行增强与入库 List<Document> toAdd = documents.stream() .filter(doc -> !existingIds.contains(doc.getId())) .toList(); if (!toAdd.isEmpty()) { // 5. 仅对新增文档调用大模型补充关键词与摘要元数据 List<Document> enriched = myDocumentEnricher.enrichDocumentsByKeyword(toAdd); enriched = myDocumentEnricher.enrichDocumentsBySummary(enriched); // 6. 分批写入向量库:相同 ID 走 upsert,重复启动不会产生冗余数据 for (int i = 0; i < enriched.size(); i += BATCH_SIZE) { int end = Math.min(i + BATCH_SIZE, enriched.size()); pgVectorVectorStore.add(enriched.subList(i, end)); } log.info("文档增量同步完成:加载 {} 篇,新增/更新 {} 篇", documents.size(), enriched.size()); } else { log.info("文档增量同步完成:加载 {} 篇,无新增文档", documents.size()); } // 7. 按配置清理已从知识库移除文档的残留向量 if (removeOrphans) { removeOrphanDocuments(existingIds, documents); } } catch (Exception e) { log.error("文档增量入库失败:{}", e.getMessage(), e); } } ``` ### 3.3 指纹生成(关键) ```java /** * 计算文档指纹 ID:基于文件名与文本内容的确定性 UUID(nameUUIDFromBytes 内部使用 MD5)。 * 内容一致时指纹稳定,内容变更时生成新指纹,从而支撑增量幂等入库。 * 指纹需为标准 UUID 格式,以满足 PgVectorStore 对文档 ID 的 UUID 解析要求。 */ private String computeFingerprint(Document document) { String filename = String.valueOf(document.getMetadata().getOrDefault("filename", "")); String raw = filename + "|" + document.getText(); return UUID.nameUUIDFromBytes(raw.getBytes(StandardCharsets.UTF_8)).toString(); } ``` ### 3.4 孤儿向量清理 ```java private void removeOrphanDocuments(Set<String> existingIds, List<Document> documents) { Set<String> localIds = new HashSet<>(); documents.forEach(doc -> localIds.add(doc.getId())); List<String> orphans = new ArrayList<>(); existingIds.forEach(id -> { if (!localIds.contains(id)) { orphans.add(id); } }); if (!orphans.isEmpty()) { pgVectorVectorStore.delete(orphans); log.info("已清理知识库中已不存在文档的残留向量 {} 条", orphans.size()); } } ``` --- ## 四、关键坑:Document ID 必须是标准 UUID 格式 ### 4.1 现象 启动时报错: ``` 文档增量入库失败:Invalid UUID string: 5bf225c4953dc43ce27e07c433da68ef java.lang.IllegalArgumentException: Invalid UUID string at java.util.UUID.fromString(UUID.java:260) at org.springframework.ai.vectorstore.pgvector.PgVectorStore.convertIdToPgType(PgVectorStore.java:318) ``` ### 4.2 原因 Spring AI 1.1.2 的 `PgVectorStore` 在写入时会调用 `UUID.fromString(documentId)` 将文档 ID **强制解析为 UUID**。若直接使用 32 位无连字符的 MD5 hex 字符串作为 ID,解析失败抛异常。 ### 4.3 结论 - 指纹必须为标准 UUID 格式(`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`); - 推荐 `UUID.nameUUIDFromBytes(...)`:内部基于 MD5 的确定性 UUID(v3),既保证幂等,又满足格式要求; - 若必须自定义 ID 格式,需确认所用 `PgVectorStore` 版本的 `convertIdToPgType` 实现是否带格式回退。 --- ## 五、配置说明(application.yaml) ```yaml # 知识库文档增量入库配置 app: rag: # 启动时是否执行知识库文档增量入库(按文档指纹比对,仅入库新增/变更的文档) ingest-on-startup: true # 是否清理已从知识库移除文档的残留向量 remove-orphans: false ``` | 配置项 | 默认值 | 说明 | | --- | --- | --- | | `app.rag.ingest-on-startup` | `true` | 关闭后启动不再执行任何文档入库 | | `app.rag.remove-orphans` | `false` | 开启后删除库中存在但知识库中已不存在的向量;默认关闭防止加载异常时误删数据 | --- ## 六、行为对比与验证 ### 6.1 行为对比 | 场景 | 改造前 | 改造后 | | --- | --- | --- | | 重复启动 N 次 | 全量重插,冗余 N 份 | 仅首次入库,后续零写入 | | 修改知识库文档 | 再插一份旧版残留 | 新指纹入库,旧版按配置可清理 | | 删除知识库文档 | 旧向量永久残留 | `remove-orphans: true` 时自动清理 | | 大模型 API 消耗 | 每次启动全量增强 | 仅对新增/变更文档增强 | ### 6.2 验证方式 ```sql -- 统计各文档重复条数(改造前可观测到 N 份相同内容) SELECT id, count(*) FROM public.vector_store GROUP BY id HAVING count(*) > 1; -- 清理历史冗余后重启应用,观察日志 -- 首次启动:文档增量同步完成:加载 37 篇,新增/更新 37 篇 -- 二次启动:文档增量同步完成:加载 37 篇,无新增文档 ``` ### 6.3 首次上线注意事项 数据库中存在历史重复数据时,先手动清空一次,再进入增量模式: ```sql DELETE FROM public.vector_store; ``` --- ## 七、相关文件 | 文件 | 职责 | | --- | --- | | `DocumentIngestionRunner.java` | 增量入库启动器(指纹比对、增量增强、分批 upsert、孤儿清理) | | `PgVectorVectorStoreConfig.java` | 仅构建 `PgVectorStore` Bean | | `LoveAppDocumentLoader.java` | 加载 `classpath:document/*.md` 并按水平线切分(保留) | | `MyDocumentEnricher.java` | 大模型生成关键词与摘要元数据(保留) | | `LoveAppVectorStoreConfig.java` | 内存向量存储,`@Lazy` 延迟初始化 | | `application.yaml` | `app.rag.*` 增量入库开关 |
Ai-Agent对话记忆引入 Redis 缓存的必要性分析
# 对话记忆引入 Redis 缓存的必要性分析 > 适用项目:恋爱大师ai-agent(Spring Boot 3.5.3 / JDK 21 / Spring AI 1.1.2 / MySQL 8.0.33 / DashScope) > 场景:用户数量多、并发压力大时,每次对话都读写数据库,是否会造成数据库压力过大?是否有必要引入 Redis? ## 一、结论 **当前阶段没有必要引入 Redis。** 对话记忆的数据库读写开销在整体对话链路中占比不足 0.5%,真正的瓶颈是大模型 API 调用本身(秒级),而非数据库(毫秒级)。 引入 Redis 会增加缓存一致性、失效策略、多实例同步等复杂性,收益极低。建议在出现以下任一信号后再考虑迁移: - 多实例水平部署(3 个以上实例同时直连数据库) - 会话量达到百万级,`SPRING_AI_CHAT_MEMORY` 表明显膨胀 - 监控发现聊天记忆表写入成为系统瓶颈 ## 二、当前实现的实际数据库开销 ### 2.1 每次对话的读写链路 基于 Spring AI 1.1.2 源码,一次对话由 `MessageChatMemoryAdvisor` 触发完整读写: ``` 对话请求 ├── before:chatMemory.get(conversationId) │ └── JdbcChatMemoryRepository.findByConversationId() → 1 次 SELECT └── after:chatMemory.add(conversationId, messages) ├── MessageWindowChatMemory.add() 内部 │ ├── findByConversationId() → 1 次 SELECT │ └── saveAll() 快照写入(事务内) │ ├── deleteByConversationId() → 1 次 DELETE │ └── batchUpdate(INSERT ...) → N 条批量 INSERT ``` 即每次对话约 **2 次 SELECT + 1 次 DELETE + 1 次批量 INSERT**,全部走 `TransactionTemplate` 事务。 ### 2.2 开销量化对比 | 对比项 | 量级 | 说明 | |---|---|---| | 单次对话数据库耗时 | 约 5~10 ms | 本地 MySQL,4 条 SQL | | 单次对话大模型调用耗时 | 2~10 s | DashScope API | | 数据库开销占比 | < 0.5% | 可忽略 | | 单会话数据量 | ≤ 20 条 | `maxMessages=20`,每次传输 < 几 KB | 即使 1000 并发对话,数据库也只需承接约 4000 次简单 SQL/秒,MySQL + HikariCP 轻松支撑。 ### 2.3 对话记忆的天然特性 - **会话访问严格串行**:同一 `chatId` 属于同一用户,只能顺序对话,不存在同一会话的高并发读写竞争。 - **数据量有上限**:`MessageWindowChatMemory` 窗口裁剪保证每会话最多 20 条。 - **丢失容忍度高**:记忆丢失后用户重新自我介绍即可恢复,属于弱一致性数据。 ## 三、什么情况下才需要考虑 Redis | 触发条件 | 原因 | |---|---| | 多实例水平部署 | 每实例都直连数据库,DB 压力随实例数翻倍;本地缓存方案失效 | | 会话量百万级 | `saveAll` 是"全删全插"快照语义,表膨胀后写放大明显 | | 对延迟极致敏感 | 需要把记忆读写压到亚毫秒级(当前 5~10ms 已足够快) | ## 四、如果迁移:Redis 做"存储"而非"缓存" 对话记忆**读多写少、天然有 TTL(会话活跃期)、丢失容忍度高**,因此正确姿势是让 Redis 直接作为存储层,而不是"MySQL + Redis 缓存"双写(后者要处理一致性,复杂度高、收益低)。 ### 4.1 推荐方案:Spring AI Alibaba Redis 记忆组件 项目使用 DashScope(spring-ai-alibaba),官方提供现成组件(版本 ≥ 1.0.0.3): ```xml <dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter-memory-redis</artifactId> <version>1.0.0.3</version> </dependency> ``` ```java // 用 RedissonChatMemoryRepository 直接替换 JdbcChatMemoryRepository,其余代码零改动 MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder() .chatMemoryRepository(redissonChatMemoryRepository) .maxMessages(20) .build(); ``` ### 4.2 迁移注意事项 1. **开启 AOF 持久化**:防止 Redis 宕机丢失全部对话记忆。 2. **设置 key TTL**(如 3~7 天):不活跃会话自动清理,避免 Redis 内存无限膨胀。 3. **保留 MySQL 兜底**(可选):双写一份到 MySQL 做历史归档/分析,Redis 只承担热数据。 ## 五、单实例的廉价替代方案 若只是主观上觉得"每次查库"心里不舒服,可在单实例场景用 Caffeine 本地缓存包装 `JdbcChatMemoryRepository`: - 同一会话串行访问,**不存在缓存一致性问题**; - 一个类即可实现,改动极小; - 注意:多实例部署时本地缓存命中率下降,此方案失效。 ## 六、建议 当前把精力放在业务与大模型调用上。待出现多实例部署或数据库指标告警时,再一步到位替换为 `spring-ai-alibaba-starter-memory-redis`,切换成本很低(仅改一个 Bean)。
MySql-JDBC 聊天记忆存储实现指南-最新Spring-Ai-Alibaba 1.1.2
# JDBC 聊天记忆存储实现指南 > 适用项目:恋爱大师ai-agent > 技术栈:Spring Boot 3.5.3 / JDK 21 / Spring AI 1.1.2 / MySQL 8.0.33(mysql-connector-java)/ DashScope(spring-ai-alibaba 1.1.2.0) > 核心依赖:`spring-ai-starter-model-chat-memory-repository-jdbc:1.1.2` > 存储表:`SPRING_AI_CHAT_MEMORY` ## 一、实现原理 ### 1.1 组件协作关系 ``` 用户对话 │ ▼ ChatClient(defaultAdvisors 装配) │ ▼ MessageChatMemoryAdvisor ──► ChatMemory(MessageWindowChatMemory,窗口上限 20 条) │ ▼ ChatMemoryRepository │ ├── JdbcChatMemoryRepository(本项目,MySQL 持久化) ├── InMemoryChatMemoryRepository(内存兜底,仅开发用) └── FileBasedChatMemory(本项目自定义,Kryo 文件持久化) ``` ### 1.2 一次对话的完整读写链路 ``` 请求前(before): ① chatMemory.get(chatId) → SELECT content, type FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ? ② 将历史消息拼入 Prompt 发给大模型 请求后(after): ③ chatMemory.add(chatId, 新消息) └─ 内部先 findByConversationId() 查出历史 └─ 合并窗口裁剪后 saveAll()(事务内:DELETE 该会话全部 → 批量 INSERT 全部) ``` ### 1.3 存储语义(重要) - **快照语义**:`saveAll` 每次"先全删、再全插",表内永远是该会话的完整最新消息列表,不存在追加残留; - **事务保障**:删除 + 批量插入包在 `TransactionTemplate` 中,中途失败自动回滚; - **消息顺序**:`timestamp` 以秒为单位,通过 `AtomicLong` 自增保证同批消息严格有序(该列仅作排序号,不追求精确时间); - **TOOL 消息**:读取时还原为空内容 `ToolResponseMessage`(JDBC 方言不保存工具调用细节)。 ## 二、项目环境配置 ### 2.1 引入依赖(pom.xml) 已添加(版本 1.1.2,与 spring-ai-alibaba 1.1.2.0 对齐): ```xml <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId> <version>1.1.2</version> <scope>compile</scope> </dependency> ``` ### 2.2 数据源配置(application.yaml) 项目当前配置(本机 MySQL,库名 `xm-kimoaiagent`): ```yaml spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/xm-kimoaiagent?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&useSSL=false username: root password: abc123 ``` ### 2.3 建表 Spring AI 的 `spring.ai.chat.memory.repository.jdbc.initialize-schema` **默认值为 `embedded`**,即只对嵌入式数据库(H2 等)自动建表,**MySQL 不会自动建表**。两种方式任选: **方式一(推荐,手动建表)**: ```sql CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY ( conversation_id VARCHAR(36) NOT NULL, content TEXT NOT NULL, type VARCHAR(20) NOT NULL, timestamp TIMESTAMP NOT NULL, KEY idx_conversation_timestamp (conversation_id, timestamp) ); ``` > ⚠️ 表名大小写:Spring AI MySQL 方言的 SQL 硬编码为**全大写 `SPRING_AI_CHAT_MEMORY`**。 > - Windows 本机 MySQL 默认 `lower_case_table_names=1`,大小写不敏感,建小写表也能命中; > - **Linux 服务器默认大小写敏感**,必须建大写表名,否则报"Table doesn't exist"。 **方式二(自动建表)**,在 application.yaml 追加: ```yaml spring: ai: chat: memory: repository: jdbc: initialize-schema: always ``` ## 三、代码集成(LoveApp 当前实现) ```java @Component @Slf4j public class LoveApp { private final ChatClient chatClient; /** * 构造函数注入 JdbcChatMemoryRepository(关键:必须构造注入,不能字段注入) */ public LoveApp(ChatModel dashscopeChatModel, JdbcChatMemoryRepository jdbcChatMemoryRepository) { // 基于 JDBC 的窗口记忆:最多保存 20 条消息 MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder() .chatMemoryRepository(jdbcChatMemoryRepository) .maxMessages(20) .build(); // 注入带记忆的对话顾问 chatClient = ChatClient.builder(dashscopeChatModel) .defaultSystem(SYSTEM_PROMPT + "每次对话后都要生成恋爱结果,标题为{用户名}的恋爱报告,内容为建议列表") .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build(), new MyLoggerAdvisor(), new ReReadingAdvisor()) .build(); } public String doChat(String userMessage, String chatId) { ChatResponse chatResponse = chatClient.prompt() .user(userMessage) .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, chatId)) .call() .chatResponse(); return chatResponse.getResult().getOutput().getText(); } } ``` 要点: 1. `MessageChatMemoryAdvisor` 通过 `ChatMemory.CONVERSATION_ID` 参数区分会话,调用方必须传 `chatId`; 2. `JdbcChatMemoryRepository` 由 Spring AI 自动配置(`@ConditionalOnMissingBean`),无需手动声明 Bean; 3. 记忆窗口 `maxMessages=20`:超过后优先保留 System 消息、裁剪最旧的非 System 消息。 ## 四、常见坑与排查 ### 4.1 【高频坑】@Resource 字段注入导致记忆"失效但无报错" ```java // ❌ 错误:字段注入发生在构造函数之后 @Resource JdbcChatMemoryRepository jdbcChatMemoryRepository; public LoveApp(ChatModel model) { // 这里 jdbcChatMemoryRepository 还是 null! MessageWindowChatMemory.builder().chatMemoryRepository(jdbcChatMemoryRepository)... } ``` Spring 创建 Bean 的生命周期:**构造函数 → 字段注入 → @PostConstruct**。 而 `MessageWindowChatMemory.Builder.build()` 对 null 有**静默容错**: ```java if (this.chatMemoryRepository == null) { this.chatMemoryRepository = new InMemoryChatMemoryRepository(); // 静默回退纯内存! } ``` **症状**:测试中多轮对话记忆正常(模型记得上一轮说的话),但数据库表始终为空、且不报任何错误。 **解决**:改为构造函数注入(见上文 LoveApp 写法)。 ### 4.2 表建了但写不进去 - 确认 `initialize-schema` 已按 2.3 配置(外部 MySQL 默认不建表); - 确认表名大小写与 MySQL 的 `lower_case_table_names` 匹配(Linux 必须大写); - 确认 `timestamp` 列类型为 `TIMESTAMP` 且非空(方言 SQL 直接写该列)。 ### 4.3 每次对话数据库有 4 条 SQL,是否压力大? 见同目录文档《Redis缓存必要性分析.md》:单次对话 DB 耗时约 5~10ms,占对话总耗时 < 0.5%,现阶段无需 Redis。 ## 五、验证方法 1. 运行 `mvn test -Dtest=LoveAppTest`(两轮对话 + 结构化输出测试); 2. 查询数据库: ```sql SELECT conversation_id, type, LEFT(content, 30), timestamp FROM SPRING_AI_CHAT_MEMORY ORDER BY conversation_id, timestamp; ``` 3. 预期:每个测试会话出现 USER / ASSISTANT 成对记录,第二轮对话的模型回复能正确引用第一轮的用户信息。
