Spring ai alibaba的简单学习:源码介绍
 个人随笔
目录
Spring ai alibaba的简单学习:源码介绍
2026-09-06 22:42:34

下面是spring ai alibaba简单学习的源码介绍,包括依赖、配置文件、数据源配置类、模型配置类、向量数据库配置类、Agent配置类和各种案例的Contoller.

源码结构


本次项目用的是jdk17

pom.xml

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <project xmlns="http://maven.apache.org/POM/4.0.0"
  3. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  5. <modelVersion>4.0.0</modelVersion>
  6. <parent>
  7. <groupId>org.springframework.boot</groupId>
  8. <artifactId>spring-boot-starter-parent</artifactId>
  9. <version>3.4.5</version>
  10. <relativePath/>
  11. </parent>
  12. <groupId>com.study</groupId>
  13. <artifactId>spring-ai-alibaba-study</artifactId>
  14. <version>1.0.0</version>
  15. <name>spring-ai-alibaba-study</name>
  16. <description>Spring AI Alibaba 学习项目:ChatClient/ChatModel/Embedding/Tool/Memory/Prompt/结构化输出/向量存储/RAG/Graph Agent</description>
  17. <properties>
  18. <java.version>17</java.version>
  19. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  20. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  21. <!-- Spring AI Alibaba 版本(官网 https://java2ai.com) -->
  22. <spring-ai-alibaba.version>1.0.0.2</spring-ai-alibaba.version>
  23. <!-- Spring AI 版本,与 Spring AI Alibaba 1.0.0.2 配套 -->
  24. <spring-ai.version>1.0.0</spring-ai.version>
  25. </properties>
  26. <dependencyManagement>
  27. <dependencies>
  28. <!-- Spring AI Alibaba BOM:统一管理 dashscope starter / graph-core 等版本 -->
  29. <dependency>
  30. <groupId>com.alibaba.cloud.ai</groupId>
  31. <artifactId>spring-ai-alibaba-bom</artifactId>
  32. <version>${spring-ai-alibaba.version}</version>
  33. <type>pom</type>
  34. <scope>import</scope>
  35. </dependency>
  36. <!-- Spring AI BOM:统一管理 spring-ai 官方组件版本 -->
  37. <dependency>
  38. <groupId>org.springframework.ai</groupId>
  39. <artifactId>spring-ai-bom</artifactId>
  40. <version>${spring-ai.version}</version>
  41. <type>pom</type>
  42. <scope>import</scope>
  43. </dependency>
  44. </dependencies>
  45. </dependencyManagement>
  46. <dependencies>
  47. <!-- Web MVC -->
  48. <dependency>
  49. <groupId>org.springframework.boot</groupId>
  50. <artifactId>spring-boot-starter-web</artifactId>
  51. </dependency>
  52. <!-- FreeMarker 页面模板 -->
  53. <dependency>
  54. <groupId>org.springframework.boot</groupId>
  55. <artifactId>spring-boot-starter-freemarker</artifactId>
  56. </dependency>
  57. <!-- JDBC(DataSource、JdbcTemplate、对话记忆持久化都用它) -->
  58. <dependency>
  59. <groupId>org.springframework.boot</groupId>
  60. <artifactId>spring-boot-starter-jdbc</artifactId>
  61. </dependency>
  62. <!-- Spring AI Alibaba:DashScope(阿里云百炼/通义) 模型适配 Starter
  63. 自动装配 ChatModel / EmbeddingModel / ChatClient.Builder 等 Bean -->
  64. <dependency>
  65. <groupId>com.alibaba.cloud.ai</groupId>
  66. <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
  67. </dependency>
  68. <!-- Spring AI Alibaba Graph:工作流 / 多智能体框架(Agent 示例使用) -->
  69. <dependency>
  70. <groupId>com.alibaba.cloud.ai</groupId>
  71. <artifactId>spring-ai-alibaba-graph-core</artifactId>
  72. </dependency>
  73. <!-- Gson:Graph 框架默认状态序列化器所需 -->
  74. <dependency>
  75. <groupId>com.google.code.gson</groupId>
  76. <artifactId>gson</artifactId>
  77. </dependency>
  78. <!-- Spring AI OpenAI 模块(非 starter,手动构建 Bean):
  79. DeepSeek / 腾讯混元 / 字节豆包 / Kimi / GLM 等厂商都提供 OpenAI 兼容接口,
  80. 通过 study.model.provider=openai 切换,见 ModelProviderConfig -->
  81. <dependency>
  82. <groupId>org.springframework.ai</groupId>
  83. <artifactId>spring-ai-openai</artifactId>
  84. </dependency>
  85. <!-- Spring AI 向量存储抽象 + 内存实现 SimpleVectorStore -->
  86. <dependency>
  87. <groupId>org.springframework.ai</groupId>
  88. <artifactId>spring-ai-vector-store</artifactId>
  89. </dependency>
  90. <!-- ============ 可选向量数据库实现(study.vectorstore.type 切换,默认 simple 内存版) ============ -->
  91. <!-- Redis Stack(本地最轻量的持久化向量库;协议与阿里云 Tair 兼容) -->
  92. <dependency>
  93. <groupId>org.springframework.ai</groupId>
  94. <artifactId>spring-ai-starter-vector-store-redis</artifactId>
  95. </dependency>
  96. <!-- Milvus(开源向量数据库,本地 Docker 一行命令即可跑) -->
  97. <dependency>
  98. <groupId>org.springframework.ai</groupId>
  99. <artifactId>spring-ai-starter-vector-store-milvus</artifactId>
  100. </dependency>
  101. <!-- Chroma(轻量本地向量数据库,适合个人项目) -->
  102. <dependency>
  103. <groupId>org.springframework.ai</groupId>
  104. <artifactId>spring-ai-starter-vector-store-chroma</artifactId>
  105. </dependency>
  106. <!-- 阿里云 Tair(阿里云自研 Redis 兼容数据库,支持向量检索;用云上实例时选它) -->
  107. <dependency>
  108. <groupId>com.alibaba.cloud.ai</groupId>
  109. <artifactId>spring-ai-alibaba-starter-store-tair</artifactId>
  110. </dependency>
  111. <!-- H2 内存数据库(study.db-type=h2 时使用,零配置直接跑) -->
  112. <dependency>
  113. <groupId>com.h2database</groupId>
  114. <artifactId>h2</artifactId>
  115. <scope>runtime</scope>
  116. </dependency>
  117. <!-- MySQL 驱动(study.db-type=mysql 时使用) -->
  118. <dependency>
  119. <groupId>com.mysql</groupId>
  120. <artifactId>mysql-connector-j</artifactId>
  121. <scope>runtime</scope>
  122. </dependency>
  123. <dependency>
  124. <groupId>org.springframework.boot</groupId>
  125. <artifactId>spring-boot-starter-test</artifactId>
  126. <scope>test</scope>
  127. </dependency>
  128. </dependencies>
  129. <build>
  130. <plugins>
  131. <plugin>
  132. <groupId>org.springframework.boot</groupId>
  133. <artifactId>spring-boot-maven-plugin</artifactId>
  134. </plugin>
  135. </plugins>
  136. </build>
  137. </project>

application.yml

  1. server:
  2. port: 8080
  3. spring:
  4. application:
  5. name: spring-ai-alibaba-study
  6. freemarker:
  7. charset: UTF-8
  8. content-type: text/html;charset=UTF-8
  9. enabled: true
  10. suffix: .ftl
  11. web:
  12. resources:
  13. add-mappings: true
  14. ai:
  15. dashscope:
  16. # 大模型密钥:优先读环境变量 AI_DASHSCOPE_API_KEY,也可以直接把 key 写在下面
  17. # 获取地址:https://bailian.console.aliyun.com/(阿里云百炼平台)
  18. # 说明:默认给了一个占位值,保证未配置 Key 时项目也能正常启动(此时调用模型接口会返回友好报错)
  19. api-key: ${AI_DASHSCOPE_API_KEY:xxxxxxx}
  20. chat:
  21. options:
  22. # 对话模型:必须是阿里云百炼(DashScope)上真实存在的模型名,
  23. # 常用:qwen-plus(推荐,性价比高) / qwen-max(最强) / qwen-turbo(便宜快速) / qwen-flash(超快)
  24. # 注意:glm、deepseek 官方版等非阿里模型不能填在这里,会报 "url error, please check url"
  25. model: qwen-plus
  26. temperature: 0.7
  27. embedding:
  28. options:
  29. # 嵌入(向量化)模型
  30. model: text-embedding-v3
  31. # 重试策略:失败快速返回(默认重试 10 次等待太久,不适合学习项目)
  32. retry:
  33. max-attempts: 3
  34. backoff:
  35. initial-interval: 2000
  36. multiplier: 2
  37. max-interval: 10000
  38. vectorstore:
  39. # 关键开关:chroma/milvus/redis 的官方自动装配在该属性"缺失"时会默认激活
  40. # 并互相冲突(Bean 名都叫 vectorStore)。这里显式指定一个不匹配任何
  41. # starter 的值,让它们全部让位 —— 向量库统一由下面的 study.vectorstore.type 控制。
  42. # (这也是 Spring AI 1.0 官方的多向量库切换属性,若想用官方自动装配可改回
  43. # redis / milvus / chroma,但本项目为了学习价值选择手动装配)
  44. type: simple
  45. # ================= 自定义学习项目配置 =================
  46. study:
  47. # ---------- 模型来源切换 ----------
  48. model:
  49. # 对话模型来源:
  50. # dashscope - 阿里云百炼(qwen 系列,走 spring-ai-alibaba starter)
  51. # openai - 任意 OpenAI 兼容接口:DeepSeek / 腾讯混元 / 字节豆包 / Kimi / GLM ...
  52. provider: openai
  53. # 嵌入模型来源:auto(跟随 provider) / dashscope / openai
  54. # 注意:DeepSeek 不提供 embedding 接口 —— 对话选 DeepSeek 时保持 auto 即可,
  55. # 嵌入会自动回退到 DashScope(需保留有效的百炼 Key)
  56. embedding-provider: auto
  57. # provider=openai 时生效(各厂商速查,任选其一改这三项即可):
  58. # DeepSeek base-url: https://api.deepseek.com chat-model: deepseek-chat
  59. # 腾讯混元 base-url: https://api.hunyuan.cloud.tencent.com/v1 chat-model: hunyuan-turbos-latest
  60. # 字节豆包 base-url: https://ark.cn-beijing.volces.com/api/v3 chat-model: doubao-seed-1-6-250615
  61. # Kimi base-url: https://api.moonshot.cn/v1 chat-model: moonshot-v1-8k
  62. # 智谱GLM base-url: https://open.bigmodel.cn/api/paas/v4 chat-model: glm-4-flash
  63. openai:
  64. base-url: https://api.deepseek.com
  65. # 对应厂商的 API Key(也可用环境变量 OPENAI_COMPATIBLE_API_KEY 覆盖)
  66. api-key: ${OPENAI_COMPATIBLE_API_KEY:xxxxx}
  67. chat-model: deepseek-v4-flash
  68. # 嵌入模型名:混元/豆包/Kimi 等有 embedding 的厂商填对应模型(如 doubao-embedding-large);
  69. # DeepSeek 留空即可(自动回退 DashScope 嵌入)
  70. embedding-model: ""
  71. temperature: 0.7
  72. # ---------- 数据库切换 ----------
  73. # 数据库类型开关:h2(默认,内存库模拟) / mysql(真实博客库)
  74. # 注意:仅 h2 模式会自动建 content 表并插入示例数据;mysql 模式只读真实库,不做任何建表/写入
  75. db-type: h2
  76. mysql:
  77. # study.db-type=mysql 时生效,改成你真实的博客数据库连接
  78. url: jdbc:mysql://192.168.192.19:3306/xxx?useUnicode=true&serverTimezone=Asia/Shanghai&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&autoReconnect=true&allowMultiQueries=true&useSSL=false&allowPublicKeyRetrieval=true
  79. username: xxxxxx
  80. password: xxxxxx
  81. # study.db-type=mysql 时生效:自定义查询 SQL(从真实博客库读取要向量化的内容)
  82. # 要求返回三列且列名/别名为 id、title、content;改成你需要的表和过滤条件即可
  83. content-sql: "SELECT id,content_title as title,content_details as content FROM content where id='2062803744227950592'"
  84. # ---------- 向量数据库切换 ----------
  85. vectorstore:
  86. # 向量数据库类型:
  87. # simple - 内存版(默认,零部署直接跑,重启后需重新初始化 RAG)
  88. # tair - 阿里云 Tair(Redis 协议兼容 + 向量检索,Spring AI Alibaba 官方适配)
  89. # redis - Redis Stack(本地最轻量持久化方案,docker run -p 6379:6379 redis/redis-stack-server)
  90. # milvus - Milvus(开源向量数据库,本地 docker 部署,默认端口 19530)
  91. # chroma - Chroma(轻量本地向量数据库,docker run -p 8000:8000 chromadb/chroma)
  92. type: simple
  93. # 嵌入向量维度:必须与嵌入模型一致!阿里 text-embedding-v3 = 1024
  94. embedding-dimensions: 1024
  95. # type=tair 时生效(阿里云 Tair 实例连接信息,控制台获取)
  96. tair:
  97. host: r-bp1xxxxxx.redis.rds.aliyuncs.com
  98. port: 6379
  99. username: ""
  100. password: ""
  101. index-name: study-rag-index
  102. # type=redis 时生效
  103. redis:
  104. host: localhost
  105. port: 6379
  106. username: ""
  107. password: ""
  108. index-name: study-rag-index
  109. # type=milvus 时生效
  110. milvus:
  111. host: localhost
  112. port: 19530
  113. database-name: default
  114. collection-name: study_rag
  115. # type=chroma 时生效
  116. chroma:
  117. base-url: http://localhost:8000
  118. collection-name: study_rag
  119. rag:
  120. # 启动时是否自动执行 RAG 初始化(向量化 content 表内容,需要有效的 API Key)
  121. auto-init: false
  122. # 检索时返回最相似的前 K 段
  123. top-k: 4
  124. # content 表为空时是否插入示例博文(方便开箱即用;自己的库有数据则不会插入)
  125. sample-data-on-empty: true
  126. logging:
  127. level:
  128. com.study.saa: info
  129. org.springframework.ai: info

StudyApplication.java

  1. package com.study.saa;
  2. import org.springframework.boot.SpringApplication;
  3. import org.springframework.boot.autoconfigure.SpringBootApplication;
  4. import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
  5. /**
  6. * Spring AI Alibaba 学习项目启动类。
  7. *
  8. * <p>启动前请配置通义大模型 API Key(二选一):
  9. * <ul>
  10. * <li>环境变量 AI_DASHSCOPE_API_KEY=sk-xxx</li>
  11. * <li>或直接写入 application.yml: spring.ai.dashscope.api-key</li>
  12. * </ul>
  13. *
  14. * <p>启动后访问首页:http://localhost:8080/
  15. */
  16. @SpringBootApplication
  17. @ConfigurationPropertiesScan
  18. public class StudyApplication {
  19. public static void main(String[] args) {
  20. SpringApplication.run(StudyApplication.class, args);
  21. }
  22. }

StudyTools.java

  1. package com.study.saa.tools;
  2. import org.springframework.ai.tool.annotation.Tool;
  3. import org.springframework.ai.tool.annotation.ToolParam;
  4. import org.springframework.stereotype.Component;
  5. import java.util.Map;
  6. import java.util.concurrent.ThreadLocalRandom;
  7. /**
  8. * 工具调用(Function Calling)示例:普通 Spring Bean 中的方法加上 @Tool 注解,
  9. * 即可被大模型在对话过程中自动选择并调用。
  10. *
  11. * <p>这里提供两个模拟工具:天气查询、四则运算计算器。
  12. */
  13. @Component
  14. public class StudyTools {
  15. /**
  16. * 模拟天气查询工具(真实项目可对接天气 API)。
  17. */
  18. @Tool(description = "查询指定城市的实时天气情况,返回天气现象和温度")
  19. public String getWeather(
  20. @ToolParam(description = "城市名称,例如:北京、上海、杭州") String city) {
  21. // 模拟数据:真实场景替换为天气 API 调用
  22. Map<String, String> weather = Map.of(
  23. "北京", "晴, 12°C, 北风3级",
  24. "上海", "多云, 18°C, 东风2级",
  25. "杭州", "小雨, 16°C, 东南风3级",
  26. "深圳", "阴, 26°C, 微风");
  27. String result = weather.get(city);
  28. if (result == null) {
  29. result = "晴转多云, " + (15 + ThreadLocalRandom.current().nextInt(10)) + "°C, 微风";
  30. }
  31. return city + "当前天气:" + result;
  32. }
  33. /**
  34. * 四则运算计算器工具。
  35. */
  36. @Tool(description = "计算两个整数的四则运算结果,operation 取值:add(加)/subtract(减)/multiply(乘)/divide(除)")
  37. public double calculate(
  38. @ToolParam(description = "第一个整数") int a,
  39. @ToolParam(description = "运算类型:add/subtract/multiply/divide") String operation,
  40. @ToolParam(description = "第二个整数") int b) {
  41. return switch (operation) {
  42. case "add" -> (double) a + b;
  43. case "subtract" -> (double) a - b;
  44. case "multiply" -> (double) a * b;
  45. case "divide" -> b == 0 ? Double.NaN : (double) a / b;
  46. default -> throw new IllegalArgumentException("不支持的运算类型: " + operation);
  47. };
  48. }
  49. }

StudyAgentConfig.java

  1. package com.study.saa.agent;
  2. import com.alibaba.cloud.ai.graph.CompiledGraph;
  3. import com.alibaba.cloud.ai.graph.KeyStrategy;
  4. import com.alibaba.cloud.ai.graph.OverAllState;
  5. import com.alibaba.cloud.ai.graph.OverAllStateFactory;
  6. import com.alibaba.cloud.ai.graph.StateGraph;
  7. import com.alibaba.cloud.ai.graph.action.AsyncEdgeAction;
  8. import com.alibaba.cloud.ai.graph.action.AsyncNodeAction;
  9. import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
  10. import org.springframework.ai.chat.client.ChatClient;
  11. import org.springframework.ai.chat.model.ChatModel;
  12. import org.springframework.context.annotation.Bean;
  13. import org.springframework.context.annotation.Configuration;
  14. import java.util.Map;
  15. import static com.alibaba.cloud.ai.graph.StateGraph.END;
  16. import static com.alibaba.cloud.ai.graph.StateGraph.START;
  17. /**
  18. * 示例十:Agent 的创建与使用 —— 基于 Spring AI Alibaba Graph 的工作流智能体。
  19. *
  20. * <p>本示例实现一个「学习助手 Agent」,流程:
  21. * <pre>
  22. * ┌────────────┐
  23. * START ──▶│ 分类节点 │ LLM 判断问题类型:concept(概念) / code(代码) / chat(闲聊)
  24. * └─────┬──────┘
  25. * ┌───────────┼─────────────┐
  26. * ▼ ▼ ▼ (条件边按分类结果路由)
  27. * ┌─────────┐ ┌─────────┐ ┌─────────┐
  28. * │概念讲解 │ │代码助手 │ │闲聊陪伴 │ 三个不同的专家节点,各自使用不同的系统提示词
  29. * └────┬────┘ └────┬────┘ └────┬────┘
  30. * └───────────┼─────────────┘
  31. * ▼
  32. * END
  33. * </pre>
  34. *
  35. * <p>核心概念:
  36. * <ul>
  37. * <li>StateGraph:状态图,声明节点(Node)与边(Edge)</li>
  38. * <li>NodeAction:节点动作,读取 OverAllState、返回要写入的状态更新</li>
  39. * <li>条件边(Conditional Edge) + EdgeAction:根据状态动态决定下一个节点</li>
  40. * <li>OverAllState:全局状态,在节点间传递共享数据(input/category/answer)</li>
  41. * </ul>
  42. */
  43. @Configuration
  44. public class StudyAgentConfig {
  45. @Bean
  46. public CompiledGraph studyAgentGraph(ChatModel chatModel) throws Exception {
  47. ChatClient chatClient = ChatClient.builder(chatModel).build();
  48. // 全局状态工厂:注册状态 Key 及其更新策略(ReplaceStrategy 每次写入覆盖旧值)
  49. OverAllStateFactory stateFactory = () -> {
  50. OverAllState state = new OverAllState();
  51. state.registerKeyAndStrategy("input", new ReplaceStrategy());
  52. state.registerKeyAndStrategy("category", new ReplaceStrategy());
  53. state.registerKeyAndStrategy("answer", new ReplaceStrategy());
  54. return state;
  55. };
  56. // ---------- 节点 1:意图分类(调用 LLM 判断问题类别) ----------
  57. var classifyNode = AsyncNodeAction.node_async(state -> {
  58. String input = readString(state, "input");
  59. String category = chatClient.prompt()
  60. .system("""
  61. 你是一个问题分类器。判断用户输入属于以下哪一类,只输出类别名,不要任何其他内容:
  62. - concept:概念、原理、理论知识类问题
  63. - code:代码编写、调试、报错类问题
  64. - chat:闲聊、打招呼、与学习无关的内容
  65. """)
  66. .user(input)
  67. .call()
  68. .content();
  69. String result = category == null ? "chat" : category.trim().toLowerCase();
  70. if (!result.contains("concept") && !result.contains("code")) {
  71. result = "chat";
  72. } else if (result.contains("concept")) {
  73. result = "concept";
  74. } else {
  75. result = "code";
  76. }
  77. return Map.of("category", result);
  78. });
  79. // ---------- 节点 2a:概念讲解专家 ----------
  80. var conceptNode = AsyncNodeAction.node_async(state -> {
  81. String input = readString(state, "input");
  82. String answer = chatClient.prompt()
  83. .system("""
  84. 你是一位耐心的技术导师,擅长把复杂概念讲得通俗易懂。
  85. 回答结构:一句话定义 -> 生活化类比 -> 展开要点(3 条以内)。
  86. """)
  87. .user(input)
  88. .call()
  89. .content();
  90. return Map.of("answer", "[概念讲解专家] " + (answer == null ? "" : answer));
  91. });
  92. // ---------- 节点 2b:代码助手专家 ----------
  93. var codeNode = AsyncNodeAction.node_async(state -> {
  94. String input = readString(state, "input");
  95. String answer = chatClient.prompt()
  96. .system("""
  97. 你是一位资深 Java 工程师。回答代码类问题时:
  98. 先给出可直接运行的示例代码,再逐段解释关键点,最后提醒常见坑。
  99. """)
  100. .user(input)
  101. .call()
  102. .content();
  103. return Map.of("answer", "[代码助手专家] " + (answer == null ? "" : answer));
  104. });
  105. // ---------- 节点 2c:闲聊陪伴 ----------
  106. var chatNode = AsyncNodeAction.node_async(state -> {
  107. String input = readString(state, "input");
  108. String answer = chatClient.prompt()
  109. .system("你是一个友好的学习伙伴,轻松简短地回应闲聊,并顺势引导对方聊点技术学习话题。")
  110. .user(input)
  111. .call()
  112. .content();
  113. return Map.of("answer", "[闲聊伙伴] " + (answer == null ? "" : answer));
  114. });
  115. // ---------- 条件边:根据分类结果路由到不同专家节点 ----------
  116. var dispatcher = AsyncEdgeAction.edge_async(state -> readString(state, "category"));
  117. // ---------- 组装状态图 ----------
  118. StateGraph graph = new StateGraph("study-agent", stateFactory)
  119. .addNode("classify", classifyNode)
  120. .addNode("concept_expert", conceptNode)
  121. .addNode("code_expert", codeNode)
  122. .addNode("chat_expert", chatNode)
  123. .addEdge(START, "classify")
  124. .addConditionalEdges("classify", dispatcher, Map.of(
  125. "concept", "concept_expert",
  126. "code", "code_expert",
  127. "chat", "chat_expert"))
  128. .addEdge("concept_expert", END)
  129. .addEdge("code_expert", END)
  130. .addEdge("chat_expert", END);
  131. return graph.compile();
  132. }
  133. private static String readString(OverAllState state, String key) {
  134. return state.value(key).map(Object::toString).orElse("");
  135. }
  136. }

AiConfig.java

  1. package com.study.saa.config;
  2. import org.springframework.ai.chat.client.ChatClient;
  3. import org.springframework.ai.chat.memory.ChatMemory;
  4. import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
  5. import org.springframework.ai.chat.memory.MessageWindowChatMemory;
  6. import org.springframework.ai.chat.model.ChatModel;
  7. import org.springframework.context.annotation.Bean;
  8. import org.springframework.context.annotation.Configuration;
  9. /**
  10. * AI 核心组件配置:
  11. *
  12. * <ul>
  13. * <li>ChatClient —— Spring AI 提供的高层对话 API(推荐日常使用)</li>
  14. * <li>ChatMemory —— 对话记忆:内存实现(每个会话保留最近 20 条消息)。
  15. * 如需持久化,可引入 spring-ai-starter-model-chat-memory-repository-jdbc
  16. * 并把 ChatMemoryRepository 换成 JdbcChatMemoryRepository(MySQL 模式)</li>
  17. * <li>VectorStore —— 向量存储:已迁移到 {@link VectorStoreConfig},
  18. * 支持 simple/tair/redis/milvus/chroma 配置化切换</li>
  19. * </ul>
  20. */
  21. @Configuration
  22. public class AiConfig {
  23. /**
  24. * 通用 ChatClient:不带记忆,供基础示例使用。
  25. *
  26. * <p>注意:这里基于 ModelProviderConfig 中 @Primary 的 ChatModel 构建,
  27. * 而不是 starter 自动装配的 ChatClient.Builder —— 这样切换模型来源
  28. * (DashScope / DeepSeek / 混元 / 豆包...)后所有示例自动生效。
  29. */
  30. @Bean
  31. public ChatClient chatClient(ChatModel chatModel) {
  32. return ChatClient.builder(chatModel).build();
  33. }
  34. /**
  35. * 对话记忆:MessageWindowChatMemory 基于 InMemoryChatMemoryRepository,
  36. * 按会话 ID 隔离,每个会话保留最近 20 条消息(重启后清空,学习演示够用)。
  37. */
  38. @Bean
  39. public ChatMemory chatMemory() {
  40. return MessageWindowChatMemory.builder()
  41. .chatMemoryRepository(new InMemoryChatMemoryRepository())
  42. .maxMessages(20)
  43. .build();
  44. }
  45. }

DataSourceConfig.java

  1. package com.study.saa.config;
  2. import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  3. import org.springframework.boot.context.properties.EnableConfigurationProperties;
  4. import org.springframework.context.annotation.Bean;
  5. import org.springframework.context.annotation.Configuration;
  6. import org.springframework.jdbc.datasource.DriverManagerDataSource;
  7. import javax.sql.DataSource;
  8. /**
  9. * 数据库配置:通过 study.db-type 开关切换数据源。
  10. *
  11. * <p>study.db-type=h2 -> 使用 H2 内存数据库(MySQL 兼容模式),零配置,适合本地学习演示。
  12. * <p>study.db-type=mysql -> 使用 application.yml 中 study.mysql.* 配置的真实 MySQL 连接。
  13. *
  14. * <p>说明:学习项目为了结构清晰直接使用 DriverManagerDataSource(无连接池);
  15. * 生产项目请换成 HikariCP 等连接池。
  16. */
  17. @Configuration
  18. @EnableConfigurationProperties(StudyProperties.class)
  19. public class DataSourceConfig {
  20. /**
  21. * H2 内存库(默认)。DB_CLOSE_DELAY=-1 保证应用运行期间库不销毁;MODE=MySQL 兼容 MySQL 语法。
  22. */
  23. @Bean
  24. @ConditionalOnProperty(name = "study.db-type", havingValue = "h2", matchIfMissing = true)
  25. public DataSource h2DataSource() {
  26. DriverManagerDataSource ds = new DriverManagerDataSource();
  27. ds.setDriverClassName("org.h2.Driver");
  28. ds.setUrl("jdbc:h2:mem:studydb;DB_CLOSE_DELAY=-1;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE");
  29. ds.setUsername("sa");
  30. ds.setPassword("");
  31. return ds;
  32. }
  33. /**
  34. * 真实 MySQL(study.db-type=mysql 时生效),连接博客数据库中的 content 表。
  35. */
  36. @Bean
  37. @ConditionalOnProperty(name = "study.db-type", havingValue = "mysql")
  38. public DataSource mysqlDataSource(StudyProperties properties) {
  39. DriverManagerDataSource ds = new DriverManagerDataSource();
  40. ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
  41. ds.setUrl(properties.getMysql().getUrl());
  42. ds.setUsername(properties.getMysql().getUsername());
  43. ds.setPassword(properties.getMysql().getPassword());
  44. return ds;
  45. }
  46. }

ModelProviderConfig.java

  1. package com.study.saa.config;
  2. import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
  3. import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel;
  4. import org.slf4j.Logger;
  5. import org.slf4j.LoggerFactory;
  6. import org.springframework.ai.chat.model.ChatModel;
  7. import org.springframework.ai.document.MetadataMode;
  8. import org.springframework.ai.embedding.EmbeddingModel;
  9. import org.springframework.ai.openai.OpenAiChatModel;
  10. import org.springframework.ai.openai.OpenAiEmbeddingModel;
  11. import org.springframework.ai.openai.OpenAiChatOptions;
  12. import org.springframework.ai.openai.OpenAiEmbeddingOptions;
  13. import org.springframework.ai.openai.api.OpenAiApi;
  14. import org.springframework.beans.factory.ObjectProvider;
  15. import org.springframework.beans.factory.config.BeanDefinition;
  16. import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
  17. import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
  18. import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  19. import org.springframework.context.annotation.Bean;
  20. import org.springframework.context.annotation.Conditional;
  21. import org.springframework.context.annotation.Configuration;
  22. import org.springframework.context.annotation.Primary;
  23. import org.springframework.core.type.AnnotatedTypeMetadata;
  24. /**
  25. * 模型来源(Provider)切换配置 —— 学习重点:多模型厂商接入。
  26. *
  27. * <p>Spring AI 中不同厂商的模型都以 ChatModel / EmbeddingModel 接口暴露,
  28. * 业务代码(ChatClient、RAG、Agent...)只面向接口,切换厂商零改动。
  29. *
  30. * <p>本项目支持两条通道(由 study.model.provider 决定):
  31. * <ul>
  32. * <li><b>dashscope</b>:阿里云百炼(通义千问 qwen 系列),
  33. * 直接使用 spring-ai-alibaba-starter-dashscope 自动装配的 Bean;</li>
  34. * <li><b>openai</b>:任意 OpenAI 兼容接口 —— DeepSeek、腾讯混元、字节豆包(火山方舟)、
  35. * Kimi、智谱 GLM 等厂商都提供该协议,只需改 base-url / api-key / model 三个配置。</li>
  36. * </ul>
  37. *
  38. * <p>常见厂商 OpenAI 兼容接口速查(填到 study.model.openai.base-url):
  39. * <pre>
  40. * DeepSeek https://api.deepseek.com 模型: deepseek-chat / deepseek-reasoner(无 embedding 接口)
  41. * 腾讯混元 https://api.hunyuan.cloud.tencent.com/v1 模型: hunyuan-turbos-latest 等
  42. * 字节豆包 https://ark.cn-beijing.volces.com/api/v3 模型: doubao-seed-1-6-250615 / doubao-embedding-large 等
  43. * Kimi https://api.moonshot.cn/v1 模型: moonshot-v1-8k 等
  44. * 智谱 GLM https://open.bigmodel.cn/api/paas/v4 模型: glm-4-flash 等
  45. * </pre>
  46. *
  47. * <p>实现要点(值得学习的 Spring 技巧):
  48. * DashScope starter 自动装配出来的 Bean 自带 @Primary;当切换到 openai 通道时,
  49. * 通过 {@link #dashScopePrimaryDemoter()}(BeanFactoryPostProcessor)把 starter Bean
  50. * 的 primary 标记降级,再装配自己的 @Primary Bean,从而避免「两个 primary」注入歧义。
  51. */
  52. @Configuration
  53. public class ModelProviderConfig {
  54. private static final Logger log = LoggerFactory.getLogger(ModelProviderConfig.class);
  55. /**
  56. * 切换通道时降级 DashScope starter 的 primary Bean(只改标记,不删除 Bean ——
  57. * 嵌入模型回退等场景仍可能用到它)。
  58. */
  59. @Bean
  60. public static BeanFactoryPostProcessor dashScopePrimaryDemoter() {
  61. return beanFactory -> {
  62. // Environment 是容器启动时手动注册的单例,此处可安全获取
  63. org.springframework.core.env.Environment env =
  64. beanFactory.getBean(org.springframework.core.env.Environment.class);
  65. String provider = env.getProperty("study.model.provider", "dashscope");
  66. String embeddingProvider = env.getProperty("study.model.embedding-provider", "auto");
  67. boolean chatUseOpenai = "openai".equalsIgnoreCase(provider);
  68. boolean embeddingUseOpenai = "openai".equalsIgnoreCase(embeddingProvider)
  69. || ("auto".equalsIgnoreCase(embeddingProvider) && chatUseOpenai);
  70. if (chatUseOpenai) {
  71. demotePrimary(beanFactory, DashScopeChatModel.class, "对话");
  72. }
  73. if (embeddingUseOpenai) {
  74. demotePrimary(beanFactory, DashScopeEmbeddingModel.class, "嵌入");
  75. }
  76. };
  77. }
  78. private static void demotePrimary(ConfigurableListableBeanFactory beanFactory,
  79. Class<?> beanClass, String label) {
  80. for (String name : beanFactory.getBeanDefinitionNames()) {
  81. // 自动配置的 Bean 通常由 @Bean 工厂方法创建(getBeanClassName 为 null),
  82. // 因此用 getType(name) 解析实际类型再比较
  83. Class<?> type = beanFactory.getType(name);
  84. if (type != null && beanClass.isAssignableFrom(type)) {
  85. BeanDefinition bd = beanFactory.getBeanDefinition(name);
  86. if (bd.isPrimary()) {
  87. bd.setPrimary(false);
  88. log.info("{}通道切换为 OpenAI 兼容接口,已降级 DashScope Bean [{}] 的 primary 标记", label, name);
  89. }
  90. }
  91. }
  92. }
  93. /**
  94. * 对话模型(openai 通道):study.model.provider=openai 时生效。
  95. * 所有注入 ChatModel 的地方(ChatClient、RAG、Agent...)自动拿到它。
  96. */
  97. @Bean
  98. @Primary
  99. @ConditionalOnProperty(name = "study.model.provider", havingValue = "openai")
  100. public ChatModel chatModel(StudyProperties properties,
  101. ObjectProvider<DashScopeChatModel> dashscopeChatModel) {
  102. StudyProperties.Model.OpenAi o = properties.getModel().getOpenai();
  103. log.info("对话模型走 OpenAI 兼容通道:{},模型:{}", o.getBaseUrl(), o.getChatModel());
  104. OpenAiApi api = OpenAiApi.builder()
  105. .baseUrl(o.getBaseUrl())
  106. .apiKey(o.getApiKey())
  107. .build();
  108. return OpenAiChatModel.builder()
  109. .openAiApi(api)
  110. .defaultOptions(OpenAiChatOptions.builder()
  111. .model(o.getChatModel())
  112. .temperature(o.getTemperature())
  113. .build())
  114. .build();
  115. }
  116. /**
  117. * 嵌入模型(openai 通道):study.model.embedding-provider=openai,
  118. * 或 =auto 且 provider=openai 时生效。
  119. *
  120. * <p>典型组合:
  121. * <ul>
  122. * <li>对话 DeepSeek + 嵌入 DashScope(DeepSeek 无 embedding 接口,
  123. * 此时保持 embedding-provider=auto 且不填 embedding-model,自动回退)</li>
  124. * <li>对话与嵌入都走混元/豆包/Kimi(填上对应的 embedding-model 即可)</li>
  125. * </ul>
  126. */
  127. @Bean
  128. @Primary
  129. @Conditional(ModelProviderConfig.EmbeddingUseOpenAiCondition.class)
  130. public EmbeddingModel embeddingModel(StudyProperties properties,
  131. ObjectProvider<DashScopeEmbeddingModel> dashscopeEmbeddingModel) {
  132. StudyProperties.Model.OpenAi o = properties.getModel().getOpenai();
  133. if (o.getEmbeddingModel() == null || o.getEmbeddingModel().isBlank()) {
  134. // 未配置 OpenAI 兼容嵌入模型(例如只配了 DeepSeek)→ 回退 DashScope 嵌入模型
  135. log.info("未配置 OpenAI 兼容嵌入模型,嵌入模型回退 DashScope(text-embedding-v3)");
  136. return dashscopeEmbeddingModel.getIfAvailable(() -> {
  137. throw new IllegalStateException(
  138. "嵌入模型不可用:请在 study.model.openai.embedding-model 配置嵌入模型,或提供 DashScope API Key 作为回退");
  139. });
  140. }
  141. log.info("嵌入模型走 OpenAI 兼容通道:{},模型:{}", o.getBaseUrl(), o.getEmbeddingModel());
  142. OpenAiApi api = OpenAiApi.builder()
  143. .baseUrl(o.getBaseUrl())
  144. .apiKey(o.getApiKey())
  145. .build();
  146. return new OpenAiEmbeddingModel(api, MetadataMode.EMBED,
  147. OpenAiEmbeddingOptions.builder()
  148. .model(o.getEmbeddingModel())
  149. .build());
  150. }
  151. /** 条件:嵌入模型走 OpenAI 兼容通道(embedding-provider=openai,或 auto 且 provider=openai) */
  152. static class EmbeddingUseOpenAiCondition implements org.springframework.context.annotation.Condition {
  153. @Override
  154. public boolean matches(org.springframework.context.annotation.ConditionContext context,
  155. AnnotatedTypeMetadata metadata) {
  156. String provider = context.getEnvironment().getProperty("study.model.provider", "dashscope");
  157. String embeddingProvider = context.getEnvironment()
  158. .getProperty("study.model.embedding-provider", "auto");
  159. return "openai".equalsIgnoreCase(embeddingProvider)
  160. || ("auto".equalsIgnoreCase(embeddingProvider) && "openai".equalsIgnoreCase(provider));
  161. }
  162. }
  163. }

StudyProperties.java

  1. package com.study.saa.config;
  2. import org.springframework.boot.context.properties.ConfigurationProperties;
  3. /**
  4. * 学习项目自定义配置(对应 application.yml 中 study.* 前缀)。
  5. */
  6. @ConfigurationProperties(prefix = "study")
  7. public class StudyProperties {
  8. /** 数据库类型开关:h2 / mysql */
  9. private String dbType = "h2";
  10. private Mysql mysql = new Mysql();
  11. private Rag rag = new Rag();
  12. private Model model = new Model();
  13. private VectorStoreGroup vectorStore = new VectorStoreGroup();
  14. /**
  15. * 向量数据库配置:支持内存 / 阿里云 Tair / Redis / Milvus / Chroma 五种,
  16. * 由 study.vectorstore.type 一键切换,业务代码零改动(统一面向 VectorStore 接口)。
  17. */
  18. public static class VectorStoreGroup {
  19. /**
  20. * 向量数据库类型:
  21. * - simple:内存版(默认,零部署直接跑)
  22. * - tair :阿里云 Tair(Redis 协议兼容 + 向量检索增强)
  23. * - redis :Redis Stack(本地 docker run redis-stack 即可)
  24. * - milvus:Milvus(开源向量数据库,本地 docker 部署)
  25. * - chroma:Chroma(轻量本地向量数据库)
  26. */
  27. private String type = "simple";
  28. /**
  29. * 嵌入向量维度:必须与所用嵌入模型输出维度一致!
  30. * 阿里 text-embedding-v3 = 1024;切换嵌入模型时记得同步修改。
  31. */
  32. private int embeddingDimensions = 1024;
  33. private Tair tair = new Tair();
  34. private Redis redis = new Redis();
  35. private Milvus milvus = new Milvus();
  36. private Chroma chroma = new Chroma();
  37. /** 阿里云 Tair 连接配置(type=tair 时生效) */
  38. public static class Tair {
  39. private String host = "r-xxx.redis.rds.aliyuncs.com";
  40. private int port = 6379;
  41. /** ACL 用户名,经典部署(仅密码)可留空 */
  42. private String username = "";
  43. private String password = "";
  44. /** 向量索引名 */
  45. private String indexName = "study-rag-index";
  46. public String getHost() { return host; }
  47. public void setHost(String host) { this.host = host; }
  48. public int getPort() { return port; }
  49. public void setPort(int port) { this.port = port; }
  50. public String getUsername() { return username; }
  51. public void setUsername(String username) { this.username = username; }
  52. public String getPassword() { return password; }
  53. public void setPassword(String password) { this.password = password; }
  54. public String getIndexName() { return indexName; }
  55. public void setIndexName(String indexName) { this.indexName = indexName; }
  56. }
  57. /** Redis Stack 连接配置(type=redis 时生效) */
  58. public static class Redis {
  59. private String host = "localhost";
  60. private int port = 6379;
  61. private String username = "";
  62. private String password = "";
  63. private String indexName = "study-rag-index";
  64. public String getHost() { return host; }
  65. public void setHost(String host) { this.host = host; }
  66. public int getPort() { return port; }
  67. public void setPort(int port) { this.port = port; }
  68. public String getUsername() { return username; }
  69. public void setUsername(String username) { this.username = username; }
  70. public String getPassword() { return password; }
  71. public void setPassword(String password) { this.password = password; }
  72. public String getIndexName() { return indexName; }
  73. public void setIndexName(String indexName) { this.indexName = indexName; }
  74. }
  75. /** Milvus 连接配置(type=milvus 时生效) */
  76. public static class Milvus {
  77. private String host = "localhost";
  78. private int port = 19530;
  79. private String databaseName = "default";
  80. private String collectionName = "study_rag";
  81. public String getHost() { return host; }
  82. public void setHost(String host) { this.host = host; }
  83. public int getPort() { return port; }
  84. public void setPort(int port) { this.port = port; }
  85. public String getDatabaseName() { return databaseName; }
  86. public void setDatabaseName(String databaseName) { this.databaseName = databaseName; }
  87. public String getCollectionName() { return collectionName; }
  88. public void setCollectionName(String collectionName) { this.collectionName = collectionName; }
  89. }
  90. /** Chroma 连接配置(type=chroma 时生效) */
  91. public static class Chroma {
  92. private String baseUrl = "http://localhost:8000";
  93. private String collectionName = "study_rag";
  94. public String getBaseUrl() { return baseUrl; }
  95. public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
  96. public String getCollectionName() { return collectionName; }
  97. public void setCollectionName(String collectionName) { this.collectionName = collectionName; }
  98. }
  99. public String getType() { return type; }
  100. public void setType(String type) { this.type = type; }
  101. public int getEmbeddingDimensions() { return embeddingDimensions; }
  102. public void setEmbeddingDimensions(int embeddingDimensions) { this.embeddingDimensions = embeddingDimensions; }
  103. public Tair getTair() { return tair; }
  104. public void setTair(Tair tair) { this.tair = tair; }
  105. public Redis getRedis() { return redis; }
  106. public void setRedis(Redis redis) { this.redis = redis; }
  107. public Milvus getMilvus() { return milvus; }
  108. public void setMilvus(Milvus milvus) { this.milvus = milvus; }
  109. public Chroma getChroma() { return chroma; }
  110. public void setChroma(Chroma chroma) { this.chroma = chroma; }
  111. }
  112. public VectorStoreGroup getVectorStore() { return vectorStore; }
  113. public void setVectorStore(VectorStoreGroup vectorStore) { this.vectorStore = vectorStore; }
  114. /**
  115. * 模型来源配置:支持阿里云百炼(DashScope) 与 任意 OpenAI 兼容接口 两种通道。
  116. */
  117. public static class Model {
  118. /**
  119. * 对话模型来源:
  120. * - dashscope:阿里云百炼(qwen 系列,走 spring-ai-alibaba-starter-dashscope)
  121. * - openai:任意 OpenAI 兼容接口(DeepSeek / 腾讯混元 / 字节豆包 / Kimi / GLM ...)
  122. */
  123. private String provider = "dashscope";
  124. /**
  125. * 嵌入模型来源:auto(跟随 provider) / dashscope / openai。
  126. * 注意:DeepSeek 不提供 embedding 接口 —— 对话选 DeepSeek 时,
  127. * 建议这里配 dashscope(混元/豆包/Kimi/GLM 则可配 openai)。
  128. */
  129. private String embeddingProvider = "auto";
  130. private OpenAi openai = new OpenAi();
  131. public static class OpenAi {
  132. /** OpenAI 兼容接口地址,例如 https://api.deepseek.com */
  133. private String baseUrl = "https://api.deepseek.com";
  134. /** 对应厂商的 API Key(也可用环境变量 OPENAI_COMPATIBLE_API_KEY) */
  135. private String apiKey = "";
  136. /** 对话模型名,例如 deepseek-chat / deepseek-reasoner */
  137. private String chatModel = "deepseek-chat";
  138. /** 嵌入模型名,留空则回退 DashScope 嵌入模型 */
  139. private String embeddingModel = "";
  140. /** 对话温度 */
  141. private Double temperature = 0.7;
  142. public String getBaseUrl() { return baseUrl; }
  143. public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
  144. public String getApiKey() { return apiKey; }
  145. public void setApiKey(String apiKey) { this.apiKey = apiKey; }
  146. public String getChatModel() { return chatModel; }
  147. public void setChatModel(String chatModel) { this.chatModel = chatModel; }
  148. public String getEmbeddingModel() { return embeddingModel; }
  149. public void setEmbeddingModel(String embeddingModel) { this.embeddingModel = embeddingModel; }
  150. public Double getTemperature() { return temperature; }
  151. public void setTemperature(Double temperature) { this.temperature = temperature; }
  152. }
  153. public String getProvider() { return provider; }
  154. public void setProvider(String provider) { this.provider = provider; }
  155. public String getEmbeddingProvider() { return embeddingProvider; }
  156. public void setEmbeddingProvider(String embeddingProvider) { this.embeddingProvider = embeddingProvider; }
  157. public OpenAi getOpenai() { return openai; }
  158. public void setOpenai(OpenAi openai) { this.openai = openai; }
  159. /** 解析后的对话通道:true 表示走 OpenAI 兼容接口 */
  160. public boolean isOpenAiCompatible() { return "openai".equalsIgnoreCase(provider); }
  161. /** 解析后的嵌入通道:true 表示走 OpenAI 兼容接口 */
  162. public boolean embeddingUseOpenAi() {
  163. if ("auto".equalsIgnoreCase(embeddingProvider)) return isOpenAiCompatible();
  164. return "openai".equalsIgnoreCase(embeddingProvider);
  165. }
  166. }
  167. public Model getModel() { return model; }
  168. public void setModel(Model model) { this.model = model; }
  169. public static class Mysql {
  170. private String url;
  171. private String username;
  172. private String password;
  173. /**
  174. * 自定义查询 SQL(study.db-type=mysql 时生效):
  175. * 从你的真实博客库读取用于 RAG 向量化的内容。
  176. * 要求返回三列且列名/别名为 id、title、content。
  177. */
  178. private String contentSql = "SELECT id, title, content FROM content ORDER BY id";
  179. public String getUrl() {
  180. return url;
  181. }
  182. public void setUrl(String url) {
  183. this.url = url;
  184. }
  185. public String getUsername() {
  186. return username;
  187. }
  188. public void setUsername(String username) {
  189. this.username = username;
  190. }
  191. public String getPassword() {
  192. return password;
  193. }
  194. public void setPassword(String password) {
  195. this.password = password;
  196. }
  197. public String getContentSql() {
  198. return contentSql;
  199. }
  200. public void setContentSql(String contentSql) {
  201. this.contentSql = contentSql;
  202. }
  203. }
  204. public static class Rag {
  205. /** 启动时是否自动执行 RAG 初始化 */
  206. private boolean autoInit = true;
  207. /** 检索返回 Top-K */
  208. private int topK = 4;
  209. /** content 表为空时是否插入示例博文 */
  210. private boolean sampleDataOnEmpty = true;
  211. public boolean isAutoInit() {
  212. return autoInit;
  213. }
  214. public void setAutoInit(boolean autoInit) {
  215. this.autoInit = autoInit;
  216. }
  217. public int getTopK() {
  218. return topK;
  219. }
  220. public void setTopK(int topK) {
  221. this.topK = topK;
  222. }
  223. public boolean isSampleDataOnEmpty() {
  224. return sampleDataOnEmpty;
  225. }
  226. public void setSampleDataOnEmpty(boolean sampleDataOnEmpty) {
  227. this.sampleDataOnEmpty = sampleDataOnEmpty;
  228. }
  229. }
  230. public String getDbType() {
  231. return dbType;
  232. }
  233. public void setDbType(String dbType) {
  234. this.dbType = dbType;
  235. }
  236. public Mysql getMysql() {
  237. return mysql;
  238. }
  239. public void setMysql(Mysql mysql) {
  240. this.mysql = mysql;
  241. }
  242. public Rag getRag() {
  243. return rag;
  244. }
  245. public void setRag(Rag rag) {
  246. this.rag = rag;
  247. }
  248. }

VectorStoreConfig.java

  1. package com.study.saa.config;
  2. import com.alibaba.cloud.ai.vectorstore.tair.TairVectorApi;
  3. import com.alibaba.cloud.ai.vectorstore.tair.TairVectorStore;
  4. import com.alibaba.cloud.ai.vectorstore.tair.TairVectorStoreOptions;
  5. import io.milvus.client.MilvusServiceClient;
  6. import io.milvus.param.ConnectParam;
  7. import org.slf4j.Logger;
  8. import org.slf4j.LoggerFactory;
  9. import org.springframework.ai.chroma.vectorstore.ChromaApi;
  10. import org.springframework.ai.chroma.vectorstore.ChromaVectorStore;
  11. import org.springframework.ai.embedding.EmbeddingModel;
  12. import org.springframework.ai.vectorstore.SimpleVectorStore;
  13. import org.springframework.ai.vectorstore.VectorStore;
  14. import org.springframework.ai.vectorstore.milvus.MilvusVectorStore;
  15. import org.springframework.ai.vectorstore.redis.RedisVectorStore;
  16. import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  17. import org.springframework.context.annotation.Bean;
  18. import org.springframework.context.annotation.Configuration;
  19. import redis.clients.jedis.JedisPool;
  20. import redis.clients.jedis.JedisPooled;
  21. /**
  22. * 向量数据库切换配置 —— 学习重点:VectorStore 抽象与多种实现。
  23. *
  24. * <p>Spring AI 把所有向量数据库抽象为同一个 {@link VectorStore} 接口
  25. * (add / delete / similaritySearch),RAG 业务代码完全不用关心底层实现,
  26. * 一个配置项 study.vectorstore.type 即可在以下实现间自由切换:
  27. *
  28. * <ul>
  29. * <li><b>simple</b>:Spring AI 自带内存向量库(默认,零部署、开箱即用)</li>
  30. * <li><b>tair</b>:阿里云 Tair(Redis 协议兼容 + TairVector 向量检索,
  31. * Spring AI Alibaba 官方适配,云上个人博客推荐)</li>
  32. * <li><b>redis</b>:Redis Stack(本地最轻量的持久化向量库,
  33. * docker run redis/redis-stack-server 即可)</li>
  34. * <li><b>milvus</b>:Milvus(最流行的开源向量数据库,支持大规模数据)</li>
  35. * <li><b>chroma</b>:Chroma(轻量本地向量数据库,个人项目常用)</li>
  36. * </ul>
  37. *
  38. * <p>注意事项:
  39. * <ol>
  40. * <li>除 simple 外,其他类型都需要先把对应服务跑起来再切换(连接信息见 application.yml);</li>
  41. * <li>嵌入向量维度必须与嵌入模型一致(text-embedding-v3 = 1024),
  42. * 由 study.vectorstore.embedding-dimensions 配置;</li>
  43. * <li>切换向量库后需要重新执行「RAG 初始化」重建索引。</li>
  44. * </ol>
  45. */
  46. @Configuration
  47. public class VectorStoreConfig {
  48. private static final Logger log = LoggerFactory.getLogger(VectorStoreConfig.class);
  49. /** ============ 1. simple:内存向量库(默认) ============ */
  50. @Bean
  51. @ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "simple", matchIfMissing = true)
  52. public VectorStore simpleVectorStore(EmbeddingModel embeddingModel) {
  53. log.info("向量数据库使用:SimpleVectorStore(内存实现,重启后需重新初始化 RAG)");
  54. return SimpleVectorStore.builder(embeddingModel).build();
  55. }
  56. /** ============ 2. tair:阿里云 Tair ============ */
  57. @Bean
  58. @ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "tair")
  59. public VectorStore tairVectorStore(StudyProperties properties, EmbeddingModel embeddingModel) {
  60. StudyProperties.VectorStoreGroup.Tair t = properties.getVectorStore().getTair();
  61. log.info("向量数据库使用:阿里云 Tair({}:{}),索引:{}", t.getHost(), t.getPort(), t.getIndexName());
  62. JedisPool pool = new JedisPool(t.getHost(), t.getPort(),
  63. blankToNull(t.getUsername()), blankToNull(t.getPassword()));
  64. TairVectorApi api = new TairVectorApi(pool);
  65. TairVectorStoreOptions options = new TairVectorStoreOptions();
  66. options.setIndexName(t.getIndexName());
  67. options.setDimensions(properties.getVectorStore().getEmbeddingDimensions());
  68. return TairVectorStore.builder(api, embeddingModel)
  69. .options(options)
  70. .build();
  71. }
  72. /** ============ 3. redis:Redis Stack(需 Redis 8.x / Redis Stack 向量检索支持) ============ */
  73. @Bean
  74. @ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "redis")
  75. public VectorStore redisVectorStore(StudyProperties properties, EmbeddingModel embeddingModel) {
  76. StudyProperties.VectorStoreGroup.Redis r = properties.getVectorStore().getRedis();
  77. log.info("向量数据库使用:Redis Stack({}:{}),索引:{}", r.getHost(), r.getPort(), r.getIndexName());
  78. JedisPooled jedis = new JedisPooled(r.getHost(), r.getPort(),
  79. blankToNull(r.getUsername()), blankToNull(r.getPassword()));
  80. return RedisVectorStore.builder(jedis, embeddingModel)
  81. .indexName(r.getIndexName())
  82. // 启动时自动创建向量索引(FT.CREATE);维度取自嵌入模型首次调用结果
  83. .initializeSchema(true)
  84. .build();
  85. }
  86. /** ============ 4. milvus:Milvus 向量数据库 ============ */
  87. @Bean
  88. @ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "milvus")
  89. public VectorStore milvusVectorStore(StudyProperties properties, EmbeddingModel embeddingModel) {
  90. StudyProperties.VectorStoreGroup.Milvus m = properties.getVectorStore().getMilvus();
  91. log.info("向量数据库使用:Milvus({}:{}),集合:{}", m.getHost(), m.getPort(), m.getCollectionName());
  92. MilvusServiceClient client = new MilvusServiceClient(ConnectParam.newBuilder()
  93. .withHost(m.getHost())
  94. .withPort(m.getPort())
  95. .withDatabaseName(m.getDatabaseName())
  96. .build());
  97. return MilvusVectorStore.builder(client, embeddingModel)
  98. .collectionName(m.getCollectionName())
  99. .embeddingDimension(properties.getVectorStore().getEmbeddingDimensions())
  100. // 自动创建 Collection(若不存在),无需手工建表
  101. .initializeSchema(true)
  102. .build();
  103. }
  104. /** ============ 5. chroma:Chroma 向量数据库 ============ */
  105. @Bean
  106. @ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "chroma")
  107. public VectorStore chromaVectorStore(StudyProperties properties, EmbeddingModel embeddingModel) {
  108. StudyProperties.VectorStoreGroup.Chroma c = properties.getVectorStore().getChroma();
  109. log.info("向量数据库使用:Chroma({}),集合:{}", c.getBaseUrl(), c.getCollectionName());
  110. ChromaApi api = new ChromaApi.Builder()
  111. .baseUrl(c.getBaseUrl())
  112. .build();
  113. return ChromaVectorStore.builder(api, embeddingModel)
  114. .collectionName(c.getCollectionName())
  115. // 自动创建 Collection;注意:启动时会连接 Chroma,服务未启动会报错
  116. .initializeSchema(true)
  117. .build();
  118. }
  119. private static String blankToNull(String s) {
  120. return (s == null || s.isBlank()) ? null : s;
  121. }
  122. }

ChatClientController.java

  1. package com.study.saa.controller;
  2. import org.springframework.ai.chat.client.ChatClient;
  3. import org.springframework.web.bind.annotation.PostMapping;
  4. import org.springframework.web.bind.annotation.RequestBody;
  5. import org.springframework.web.bind.annotation.RequestMapping;
  6. import org.springframework.web.bind.annotation.RestController;
  7. import java.util.Map;
  8. /**
  9. * 示例一:ChatClient 的使用。
  10. *
  11. * <p>ChatClient 是 Spring AI 提供的「高层」流式对话 API,是日常开发推荐的方式。
  12. * 它屏蔽了底层 ChatModel 的细节,支持链式调用:prompt() -> user() -> call() -> content()。
  13. */
  14. @RestController
  15. @RequestMapping("/api/chat")
  16. public class ChatClientController {
  17. private final ChatClient chatClient;
  18. public ChatClientController(ChatClient chatClient) {
  19. this.chatClient = chatClient;
  20. }
  21. /**
  22. * 最基础的 ChatClient 对话。
  23. */
  24. @PostMapping("/client")
  25. public Map<String, Object> chatWithClient(@RequestBody Map<String, String> body) {
  26. String input = body.getOrDefault("input", "");
  27. try {
  28. String answer = chatClient.prompt()
  29. .user(input)
  30. .call()
  31. .content();
  32. return Map.of("ok", true, "data", answer == null ? "" : answer);
  33. } catch (Exception e) {
  34. return Map.of("ok", false, "data", "调用失败:" + e.getMessage()
  35. + "\n(请检查 API Key 配置:DashScope 通道看 spring.ai.dashscope.api-key;"
  36. + "OpenAI 兼容通道(DeepSeek/混元/豆包等)看 study.model.openai.api-key)");
  37. }
  38. }
  39. /**
  40. * system 系统提示词 + user 用户输入的组合用法:让模型扮演指定角色。
  41. */
  42. @PostMapping("/role")
  43. public Map<String, Object> chatWithRole(@RequestBody Map<String, String> body) {
  44. String input = body.getOrDefault("input", "");
  45. try {
  46. String answer = chatClient.prompt()
  47. .system("你是一位资深的 Java 技术面试官,回答简洁、专业,必要时给出代码示例。")
  48. .user(input)
  49. .call()
  50. .content();
  51. return Map.of("ok", true, "data", answer == null ? "" : answer);
  52. } catch (Exception e) {
  53. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  54. }
  55. }
  56. }

ChatModelController.java

  1. package com.study.saa.controller;
  2. import org.springframework.ai.chat.messages.UserMessage;
  3. import org.springframework.ai.chat.model.ChatModel;
  4. import org.springframework.ai.chat.model.ChatResponse;
  5. import org.springframework.ai.chat.prompt.ChatOptions;
  6. import org.springframework.ai.chat.prompt.Prompt;
  7. import org.springframework.web.bind.annotation.PostMapping;
  8. import org.springframework.web.bind.annotation.RequestBody;
  9. import org.springframework.web.bind.annotation.RequestMapping;
  10. import org.springframework.web.bind.annotation.RestController;
  11. import java.util.Map;
  12. /**
  13. * 示例二:ChatModel 的使用(底层 API)。
  14. *
  15. * <p>ChatModel 是 Spring AI 的「底层」模型接口,ChatClient 内部也是调用它。
  16. * 直接使用 ChatModel 可以看到完整的 ChatResponse 结构(内容、token 用量等),
  17. * 并且可以通过通用的 ChatOptions 动态指定模型名与温度
  18. * (不绑定任何厂商,DashScope / DeepSeek / 混元 / 豆包 通用)。
  19. */
  20. @RestController
  21. @RequestMapping("/api/chat")
  22. public class ChatModelController {
  23. private final ChatModel chatModel;
  24. public ChatModelController(ChatModel chatModel) {
  25. this.chatModel = chatModel;
  26. }
  27. /**
  28. * 直接调用 ChatModel,演示通过 ChatOptions 动态指定模型与温度。
  29. * body: { "input": "问题", "model": "可选,动态切换模型名" }
  30. */
  31. @PostMapping("/model")
  32. public Map<String, Object> chatWithModel(@RequestBody Map<String, String> body) {
  33. String input = body.getOrDefault("input", "");
  34. String model = body.get("model");
  35. try {
  36. // 通用 ChatOptions:model 不传则使用配置文件里的默认模型
  37. ChatOptions.Builder optionsBuilder = ChatOptions.builder().temperature(0.3D);
  38. if (model != null && !model.isBlank()) {
  39. optionsBuilder.model(model);
  40. } else {
  41. model = "(默认模型)";
  42. }
  43. ChatResponse response = chatModel.call(
  44. new Prompt(new UserMessage(input), optionsBuilder.build()));
  45. String content = response.getResult().getOutput().getText();
  46. Integer totalTokens = response.getMetadata() != null
  47. && response.getMetadata().getUsage() != null
  48. ? response.getMetadata().getUsage().getTotalTokens() : null;
  49. String report = "【模型】" + model + "\n\n" + content
  50. + "\n\n----\n本次对话消耗 token:" + totalTokens;
  51. return Map.of("ok", true, "data", report);
  52. } catch (Exception e) {
  53. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  54. }
  55. }
  56. }

EmbeddingController.java

  1. package com.study.saa.controller;
  2. import org.springframework.ai.embedding.EmbeddingModel;
  3. import org.springframework.ai.embedding.EmbeddingResponse;
  4. import org.springframework.web.bind.annotation.PostMapping;
  5. import org.springframework.web.bind.annotation.RequestBody;
  6. import org.springframework.web.bind.annotation.RequestMapping;
  7. import org.springframework.web.bind.annotation.RestController;
  8. import java.util.List;
  9. import java.util.Map;
  10. /**
  11. * 示例三:嵌入模型(EmbeddingModel)的使用。
  12. *
  13. * <p>嵌入模型把文本转换成高维向量(如 text-embedding-v3 输出 1024 维),
  14. * 是语义检索 / RAG 的基础:语义相近的文本,向量距离更近。
  15. */
  16. @RestController
  17. @RequestMapping("/api/embedding")
  18. public class EmbeddingController {
  19. private final EmbeddingModel embeddingModel;
  20. public EmbeddingController(EmbeddingModel embeddingModel) {
  21. this.embeddingModel = embeddingModel;
  22. }
  23. /**
  24. * 把输入文本向量化,展示向量维度与前几个分量。
  25. */
  26. @PostMapping("/embed")
  27. public Map<String, Object> embed(@RequestBody Map<String, String> body) {
  28. String input = body.getOrDefault("input", "");
  29. try {
  30. long start = System.currentTimeMillis();
  31. EmbeddingResponse response = embeddingModel.embedForResponse(List.of(input));
  32. float[] vector = response.getResult().getOutput();
  33. long cost = System.currentTimeMillis() - start;
  34. // 拼接前 5 个分量用于展示
  35. StringBuilder sb = new StringBuilder();
  36. for (int i = 0; i < Math.min(5, vector.length); i++) {
  37. sb.append(String.format("%.4f", vector[i])).append(", ");
  38. }
  39. String report = "文本:" + input + "\n\n"
  40. + "向量维度:" + vector.length + "\n"
  41. + "前 5 个分量:[" + sb + "...]\n"
  42. + "模型:" + response.getMetadata().get("model") + "\n"
  43. + "耗时:" + cost + " ms";
  44. return Map.of("ok", true, "data", report);
  45. } catch (Exception e) {
  46. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  47. }
  48. }
  49. /**
  50. * 计算两段文本的相似度(余弦相似度),直观感受语义距离。
  51. */
  52. @PostMapping("/similarity")
  53. public Map<String, Object> similarity(@RequestBody Map<String, String> body) {
  54. String text1 = body.getOrDefault("text1", "");
  55. String text2 = body.getOrDefault("text2", "");
  56. try {
  57. float[] v1 = embeddingModel.embed(text1);
  58. float[] v2 = embeddingModel.embed(text2);
  59. double similarity = cosine(v1, v2);
  60. String report = "文本A:" + text1 + "\n文本B:" + text2
  61. + "\n\n余弦相似度:" + String.format("%.4f", similarity)
  62. + "\n(越接近 1 表示语义越相近)";
  63. return Map.of("ok", true, "data", report);
  64. } catch (Exception e) {
  65. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  66. }
  67. }
  68. private double cosine(float[] a, float[] b) {
  69. double dot = 0, normA = 0, normB = 0;
  70. for (int i = 0; i < a.length; i++) {
  71. dot += (double) a[i] * b[i];
  72. normA += (double) a[i] * a[i];
  73. normB += (double) b[i] * b[i];
  74. }
  75. if (normA == 0 || normB == 0) {
  76. return 0;
  77. }
  78. return dot / (Math.sqrt(normA) * Math.sqrt(normB));
  79. }
  80. }

MemoryController.java

  1. package com.study.saa.controller;
  2. import org.springframework.ai.chat.client.ChatClient;
  3. import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
  4. import org.springframework.ai.chat.memory.ChatMemory;
  5. import org.springframework.ai.chat.model.ChatModel;
  6. import org.springframework.web.bind.annotation.GetMapping;
  7. import org.springframework.web.bind.annotation.PostMapping;
  8. import org.springframework.web.bind.annotation.RequestBody;
  9. import org.springframework.web.bind.annotation.RequestMapping;
  10. import org.springframework.web.bind.annotation.RestController;
  11. import java.util.List;
  12. import java.util.Map;
  13. /**
  14. * 示例五:对话记忆(Chat Memory)的使用。
  15. *
  16. * <p>通过 MessageChatMemoryAdvisor 为 ChatClient 挂载记忆:
  17. * 每次调用自动携带同一 conversationId 下的历史消息,实现多轮连续对话。
  18. *
  19. * <p>试试在同一个会话 ID 下先说"我叫小明,在做 Java 后端开发",
  20. * 再问"我刚才说我叫什么?",模型能记住上文。
  21. */
  22. @RestController
  23. @RequestMapping("/api/memory")
  24. public class MemoryController {
  25. private final ChatClient chatClient;
  26. private final ChatMemory chatMemory;
  27. public MemoryController(ChatModel chatModel, ChatMemory chatMemory) {
  28. // 基于「当前生效」的 ChatModel 构建,并为其挂载记忆 Advisor
  29. // (用 ChatClient.builder() 而非注入 ChatClient.Builder,保证切换模型来源后同样生效)
  30. this.chatClient = ChatClient.builder(chatModel)
  31. .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
  32. .build();
  33. this.chatMemory = chatMemory;
  34. }
  35. /**
  36. * 带记忆的多轮对话。前端传 conversationId 区分不同会话。
  37. */
  38. @PostMapping("/chat")
  39. public Map<String, Object> chatWithMemory(@RequestBody Map<String, String> body) {
  40. String input = body.getOrDefault("input", "");
  41. String conversationId = body.getOrDefault("conversationId", "default");
  42. try {
  43. String answer = chatClient.prompt()
  44. .user(input)
  45. .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, conversationId))
  46. .call()
  47. .content();
  48. return Map.of("ok", true, "data", answer == null ? "" : answer);
  49. } catch (Exception e) {
  50. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  51. }
  52. }
  53. /**
  54. * 查看某个会话当前记住的全部历史消息。
  55. */
  56. @GetMapping("/history")
  57. public Map<String, Object> history(@org.springframework.web.bind.annotation.RequestParam(
  58. value = "conversationId", defaultValue = "default") String conversationId) {
  59. List<String> messages = chatMemory.get(conversationId).stream()
  60. .map(m -> m.getMessageType() + ": " + m.getText())
  61. .toList();
  62. return Map.of("ok", true, "data", String.join("\n", messages));
  63. }
  64. }

PromptController.java

  1. package com.study.saa.controller;
  2. import org.springframework.ai.chat.client.ChatClient;
  3. import org.springframework.ai.chat.prompt.PromptTemplate;
  4. import org.springframework.web.bind.annotation.PostMapping;
  5. import org.springframework.web.bind.annotation.RequestBody;
  6. import org.springframework.web.bind.annotation.RequestMapping;
  7. import org.springframework.web.bind.annotation.RestController;
  8. import java.util.Map;
  9. /**
  10. * 示例六:提示词模板(Prompt Template)的使用。
  11. *
  12. * <p>PromptTemplate 把「固定的提示词骨架」和「动态的变量」分离:
  13. * 模板中用 {变量名} 占位,渲染时填充,避免手工字符串拼接。
  14. */
  15. @RestController
  16. @RequestMapping("/api/prompt")
  17. public class PromptController {
  18. private final ChatClient chatClient;
  19. public PromptController(ChatClient chatClient) {
  20. this.chatClient = chatClient;
  21. }
  22. /**
  23. * 使用 PromptTemplate 渲染提示词并交给大模型。
  24. * 变量:topic(主题)、style(风格)、count(要点数量)。
  25. */
  26. @PostMapping("/generate")
  27. public Map<String, Object> generate(@RequestBody Map<String, String> body) {
  28. String topic = body.getOrDefault("topic", "Spring AI");
  29. String style = body.getOrDefault("style", "通俗易懂");
  30. try {
  31. PromptTemplate template = PromptTemplate.builder()
  32. .template("""
  33. 你是一位技术博主,请用{style}的风格,围绕主题「{topic}」写一段入门介绍,
  34. 要求包含 {count} 个核心要点,每个要点用一句话概括,末尾给一句学习建议。
  35. """)
  36. .variables(Map.of(
  37. "topic", topic,
  38. "style", style,
  39. "count", "3"))
  40. .build();
  41. String rendered = template.render();
  42. String answer = chatClient.prompt()
  43. .user(rendered)
  44. .call()
  45. .content();
  46. String report = "===== 渲染后的提示词 =====\n" + rendered
  47. + "\n\n===== 模型输出 =====\n" + answer;
  48. return Map.of("ok", true, "data", report);
  49. } catch (Exception e) {
  50. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  51. }
  52. }
  53. }

RagController.java

  1. package com.study.saa.controller;
  2. import com.study.saa.config.StudyProperties;
  3. import com.study.saa.service.RagService;
  4. import org.springframework.web.bind.annotation.GetMapping;
  5. import org.springframework.web.bind.annotation.PostMapping;
  6. import org.springframework.web.bind.annotation.RequestBody;
  7. import org.springframework.web.bind.annotation.RequestMapping;
  8. import org.springframework.web.bind.annotation.RestController;
  9. import java.util.HashMap;
  10. import java.util.Map;
  11. /**
  12. * 示例九:RAG(检索增强生成)—— 个人博客笔记问答。
  13. *
  14. * <p>POST /api/rag/init —— 初始化:查 content 表 -> 段落分片 -> 嵌入向量化 -> 入向量库
  15. * <p>POST /api/rag/query —— 查询:问题向量化 -> 相似检索 -> 检索结果+问题交给大模型 -> 返回答案与来源
  16. * <p>GET /api/rag/status —— 查看初始化状态
  17. */
  18. @RestController
  19. @RequestMapping("/api/rag")
  20. public class RagController {
  21. private final RagService ragService;
  22. private final StudyProperties properties;
  23. public RagController(RagService ragService, StudyProperties properties) {
  24. this.ragService = ragService;
  25. this.properties = properties;
  26. }
  27. @PostMapping("/init")
  28. public Map<String, Object> init() {
  29. try {
  30. Map<String, Object> result = ragService.initialize();
  31. Map<String, Object> data = new HashMap<>(result);
  32. data.put("message", "初始化成功");
  33. return Map.of("ok", true, "data", data);
  34. } catch (Exception e) {
  35. return Map.of("ok", false, "data",
  36. "初始化失败:" + e.getMessage() + "(请检查数据库连接与 API Key 配置)");
  37. }
  38. }
  39. @PostMapping("/query")
  40. public Map<String, Object> query(@RequestBody Map<String, String> body) {
  41. String question = body.getOrDefault("query", "");
  42. try {
  43. Map<String, Object> result = ragService.query(question);
  44. return Map.of("ok", true, "data", result);
  45. } catch (Exception e) {
  46. return Map.of("ok", false, "data", "查询失败:" + e.getMessage());
  47. }
  48. }
  49. @GetMapping("/status")
  50. public Map<String, Object> status() {
  51. return Map.of("ok", true, "data", Map.of(
  52. "initialized", ragService.isInitialized(),
  53. "chunkCount", ragService.getChunkCount(),
  54. "dbType", properties.getDbType(),
  55. "vectorStoreType", properties.getVectorStore().getType()));
  56. }
  57. }

StructuredOutputController.java

  1. package com.study.saa.controller;
  2. import org.springframework.ai.chat.client.ChatClient;
  3. import org.springframework.web.bind.annotation.PostMapping;
  4. import org.springframework.web.bind.annotation.RequestBody;
  5. import org.springframework.web.bind.annotation.RequestMapping;
  6. import org.springframework.web.bind.annotation.RestController;
  7. import java.util.List;
  8. import java.util.Map;
  9. /**
  10. * 示例七:结构化输出(Structured Output / 格式化输出)的使用。
  11. *
  12. * <p>通过 .entity(Xxx.class) 让模型输出自动反序列化为 Java 对象(record/POJO),
  13. * 无需手工解析 JSON —— 框架会自动在提示词中注入输出格式约束并完成转换。
  14. */
  15. @RestController
  16. @RequestMapping("/api/format")
  17. public class StructuredOutputController {
  18. private final ChatClient chatClient;
  19. public StructuredOutputController(ChatClient chatClient) {
  20. this.chatClient = chatClient;
  21. }
  22. /**
  23. * 定义输出结构:演员 + 其代表作品列表。
  24. */
  25. public record ActorFilms(String actor, List<String> movies) {
  26. }
  27. /**
  28. * 输入演员名字,返回结构化的 ActorFilms 对象。
  29. */
  30. @PostMapping("/actor")
  31. public Map<String, Object> actorFilms(@RequestBody Map<String, String> body) {
  32. String input = body.getOrDefault("input", "周星驰");
  33. try {
  34. ActorFilms films = chatClient.prompt()
  35. .user("请列出演员「" + input + "」的 3 部代表电影作品")
  36. .call()
  37. .entity(ActorFilms.class);
  38. String report = "结构化对象:ActorFilms{actor='" + films.actor()
  39. + "', movies=" + films.movies() + "}\n\n"
  40. + "说明:模型返回的 JSON 已被自动转换成 Java record,可直接字段访问。";
  41. return Map.of("ok", true, "data", report);
  42. } catch (Exception e) {
  43. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  44. }
  45. }
  46. }

ToolController.java

  1. package com.study.saa.controller;
  2. import org.springframework.ai.chat.client.ChatClient;
  3. import org.springframework.web.bind.annotation.PostMapping;
  4. import org.springframework.web.bind.annotation.RequestBody;
  5. import org.springframework.web.bind.annotation.RequestMapping;
  6. import org.springframework.web.bind.annotation.RestController;
  7. import com.study.saa.tools.StudyTools;
  8. import java.util.Map;
  9. /**
  10. * 示例四:工具调用(Tool Calling / Function Calling)的使用。
  11. *
  12. * <p>把 @Tool 注解标记的 Bean 通过 .tools(...) 绑定到 ChatClient,
  13. * 大模型会在需要时自动决定调用哪个工具、传什么参数,再把工具结果融入最终回答。
  14. *
  15. * <p>试试问:
  16. * <ul>
  17. * <li>"杭州今天天气怎么样?"(触发天气工具)</li>
  18. * <li>"帮我算一下 125 乘以 88 等于多少"(触发计算器工具)</li>
  19. * <li>"北京和上海的天气对比,两地温差是多少?"(多次工具调用组合)</li>
  20. * </ul>
  21. */
  22. @RestController
  23. @RequestMapping("/api/tool")
  24. public class ToolController {
  25. private final ChatClient chatClient;
  26. private final StudyTools studyTools;
  27. public ToolController(ChatClient chatClient, StudyTools studyTools) {
  28. this.chatClient = chatClient;
  29. this.studyTools = studyTools;
  30. }
  31. @PostMapping("/chat")
  32. public Map<String, Object> chatWithTools(@RequestBody Map<String, String> body) {
  33. String input = body.getOrDefault("input", "");
  34. try {
  35. String answer = chatClient.prompt()
  36. .user(input)
  37. .tools(studyTools)
  38. .call()
  39. .content();
  40. return Map.of("ok", true, "data", answer == null ? "" : answer);
  41. } catch (Exception e) {
  42. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  43. }
  44. }
  45. }

VectorStoreController.java

  1. package com.study.saa.controller;
  2. import org.springframework.ai.document.Document;
  3. import org.springframework.ai.vectorstore.SearchRequest;
  4. import org.springframework.ai.vectorstore.VectorStore;
  5. import org.springframework.web.bind.annotation.PostMapping;
  6. import org.springframework.web.bind.annotation.RequestBody;
  7. import org.springframework.web.bind.annotation.RequestMapping;
  8. import org.springframework.web.bind.annotation.RestController;
  9. import java.util.List;
  10. import java.util.Map;
  11. import java.util.UUID;
  12. /**
  13. * 示例八:向量存储(VectorStore)的使用。
  14. *
  15. * <p>流程:文档(Document) --嵌入模型--> 向量 --> VectorStore 保存;
  16. * 查询时把查询文本向量化,按相似度检索最相近的 Top-K 文档。
  17. *
  18. * <p>先用「添加文档」写入几段知识,再用「相似检索」试试语义匹配。
  19. */
  20. @RestController
  21. @RequestMapping("/api/vector")
  22. public class VectorStoreController {
  23. private final VectorStore vectorStore;
  24. public VectorStoreController(VectorStore vectorStore) {
  25. this.vectorStore = vectorStore;
  26. }
  27. /**
  28. * 添加一段文本到向量库(自动向量化)。
  29. */
  30. @PostMapping("/add")
  31. public Map<String, Object> add(@RequestBody Map<String, String> body) {
  32. String text = body.getOrDefault("text", "");
  33. if (text.isBlank()) {
  34. return Map.of("ok", false, "data", "请输入要添加的文本内容");
  35. }
  36. try {
  37. Document doc = new Document(text, Map.of("source", "manual-input"));
  38. vectorStore.add(List.of(doc));
  39. return Map.of("ok", true, "data", "已添加并完成向量化,文档ID:" + doc.getId());
  40. } catch (Exception e) {
  41. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  42. }
  43. }
  44. /**
  45. * 相似度检索 Top-K。
  46. */
  47. @PostMapping("/search")
  48. public Map<String, Object> search(@RequestBody Map<String, String> body) {
  49. String query = body.getOrDefault("query", "");
  50. int topK;
  51. try {
  52. topK = Integer.parseInt(body.getOrDefault("topK", "3"));
  53. } catch (NumberFormatException e) {
  54. topK = 3;
  55. }
  56. try {
  57. List<Document> hits = vectorStore.similaritySearch(
  58. SearchRequest.builder().query(query).topK(topK).build());
  59. if (hits == null || hits.isEmpty()) {
  60. return Map.of("ok", true, "data", "没有检索到相关内容,请先添加一些文档");
  61. }
  62. StringBuilder sb = new StringBuilder("检索到 " + hits.size() + " 条最相似的内容:\n\n");
  63. int i = 1;
  64. for (Document d : hits) {
  65. sb.append("【").append(i++).append("】")
  66. .append("score=").append(String.format("%.4f", d.getScore()))
  67. .append(", source=").append(d.getMetadata().get("source"))
  68. .append("\n").append(d.getText()).append("\n\n");
  69. }
  70. return Map.of("ok", true, "data", sb.toString());
  71. } catch (Exception e) {
  72. return Map.of("ok", false, "data", "调用失败:" + e.getMessage());
  73. }
  74. }
  75. }

ContentService.java

  1. package com.study.saa.service;
  2. import com.study.saa.config.StudyProperties;
  3. import org.slf4j.Logger;
  4. import org.slf4j.LoggerFactory;
  5. import org.springframework.boot.ApplicationArguments;
  6. import org.springframework.boot.ApplicationRunner;
  7. import org.springframework.jdbc.core.JdbcTemplate;
  8. import org.springframework.stereotype.Service;
  9. import java.util.List;
  10. /**
  11. * 博文笔记数据访问:对接博客数据库中的 content 表。
  12. *
  13. * <p>两种数据库模式行为不同:
  14. * <ul>
  15. * <li><b>h2</b>(默认):启动时自动建表 + 空表时插入示例博文,开箱即用;
  16. * 查询使用固定 SQL:SELECT id, title, content FROM content ORDER BY id。</li>
  17. * <li><b>mysql</b>:连接你真实的博客库,<b>不执行任何建表/写入操作</b>(只读);
  18. * 查询使用自定义 SQL(study.mysql.content-sql,要求返回 id / title / content 三列),
  19. * 可以从 blog 等真实业务表中按自己的条件筛选要向量化的内容。</li>
  20. * </ul>
  21. */
  22. @Service
  23. public class ContentService implements ApplicationRunner {
  24. private static final Logger log = LoggerFactory.getLogger(ContentService.class);
  25. /** H2 模式固定查询 SQL */
  26. private static final String H2_QUERY_SQL = "SELECT id, title, content FROM content ORDER BY id";
  27. private final JdbcTemplate jdbcTemplate;
  28. private final StudyProperties properties;
  29. public ContentService(JdbcTemplate jdbcTemplate, StudyProperties properties) {
  30. this.jdbcTemplate = jdbcTemplate;
  31. this.properties = properties;
  32. }
  33. /**
  34. * 启动时初始化表结构 —— 仅 H2 模式执行(MySQL 连的是真实博客库,只读,不建表不插数据)。
  35. */
  36. @Override
  37. public void run(ApplicationArguments args) {
  38. if (!"h2".equalsIgnoreCase(properties.getDbType())) {
  39. log.info("数据库类型:{}(真实库,跳过建表与示例数据初始化)", properties.getDbType());
  40. return;
  41. }
  42. try {
  43. jdbcTemplate.execute("""
  44. CREATE TABLE IF NOT EXISTS content (
  45. id BIGINT AUTO_INCREMENT PRIMARY KEY,
  46. title VARCHAR(255) NOT NULL,
  47. content TEXT NOT NULL
  48. )
  49. """);
  50. log.info("content 表已就绪(数据库类型:{})", properties.getDbType());
  51. if (properties.getRag().isSampleDataOnEmpty() && isTableEmpty()) {
  52. insertSampleNotes();
  53. log.info("content 表为空,已插入 {} 条示例博文", SAMPLE_NOTES.length);
  54. }
  55. } catch (Exception e) {
  56. // 数据库异常不应阻断应用启动(页面仍可访问,接口会给出明确报错)
  57. log.error("content 表初始化失败:{}", e.getMessage());
  58. }
  59. }
  60. /**
  61. * 查询所有博文笔记。
  62. * <p>H2 模式查 content 表全量;MySQL 模式使用 study.mysql.content-sql 自定义 SQL。
  63. */
  64. public List<Note> findAll() {
  65. String sql = "h2".equalsIgnoreCase(properties.getDbType())
  66. ? H2_QUERY_SQL
  67. : properties.getMysql().getContentSql();
  68. log.info("查询博文笔记 SQL(db-type={}):{}", properties.getDbType(), sql);
  69. return jdbcTemplate.query(
  70. sql,
  71. (rs, rowNum) -> new Note(
  72. rs.getLong("id"),
  73. rs.getString("title"),
  74. rs.getString("content")));
  75. }
  76. public boolean isTableEmpty() {
  77. Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM content", Long.class);
  78. return count == null || count == 0;
  79. }
  80. /**
  81. * 博文笔记实体。
  82. */
  83. public record Note(long id, String title, String content) {
  84. }
  85. private void insertSampleNotes() {
  86. for (String[] note : SAMPLE_NOTES) {
  87. jdbcTemplate.update("INSERT INTO content (title, content) VALUES (?, ?)", note[0], note[1]);
  88. }
  89. }
  90. /**
  91. * 示例博文(模拟个人博客中的笔记内容,段落之间用空行分隔)。
  92. */
  93. private static final String[][] SAMPLE_NOTES = {
  94. {
  95. "Spring AI 入门笔记",
  96. """
  97. Spring AI 是 Spring 官方推出的 AI 应用开发框架,它把大模型能力抽象成统一的编程接口。
  98. 核心抽象有三个:ChatModel 负责与对话模型通信,EmbeddingModel 负责把文本转成向量,VectorStore 负责向量的存取与相似检索。
  99. ChatClient 是推荐使用的门面 API,支持链式调用:prompt() 设置提示词、user() 传用户输入、call() 同步调用、stream() 流式返回。
  100. 我的学习心得:先用 ChatClient 跑通最小闭环,再逐步加上工具调用、记忆、RAG 等高级能力。"""
  101. },
  102. {
  103. "RAG 检索增强生成实践笔记",
  104. """
  105. RAG 的全称是 Retrieval-Augmented Generation,检索增强生成,解决大模型不知道私有知识的问题。
  106. 离线阶段:把文档按段落分片,调用嵌入模型向量化,存入向量数据库。
  107. 在线阶段:用户提问先向量化,再去向量库做相似度检索取回 Top-K 相关片段,最后把片段拼进提示词交给大模型生成答案。
  108. 实践经验:分片粒度很重要,段落级分片对博文笔记类内容效果最好;检索时 topK 取 3 到 5 比较合适。"""
  109. },
  110. {
  111. "Prompt 提示词工程技巧笔记",
  112. """
  113. 提示词的基本结构是:角色设定、任务描述、上下文、输出格式约束。
  114. 角色设定用 system 消息,比如你是一位资深 Java 面试官,能让回答风格更专业。
  115. 要求结构化输出时,明确告诉模型返回 JSON 并给出字段说明,配合 Spring AI 的 entity() 方法可以直接转成 Java 对象。
  116. 少样本示例(few-shot)很有效:给一到两个输入输出示例,模型的输出格式会稳定很多。"""
  117. },
  118. {
  119. "大模型 Agent 智能体学习笔记",
  120. """
  121. Agent 与普通对话的区别在于:Agent 能自主规划步骤并调用工具完成任务。
  122. Spring AI Alibaba 提供了 Graph 框架来编排工作流:StateGraph 定义节点和边,OverAllState 在节点之间传递共享状态。
  123. 内置了 ReAct Agent、Supervisor 等多智能体模式,可以把意图分类、专业问答、结果汇总拆成不同节点,用条件边路由。
  124. 工具调用是 Agent 的手脚,用 @Tool 注解标记方法并绑定到 ChatClient,模型会自动决定何时调用。"""
  125. },
  126. {
  127. "MySQL 索引优化经验笔记",
  128. """
  129. 索引的最左前缀原则:联合索引 (a, b, c) 只对 a、ab、abc 这样的查询条件生效。
  130. 避免索引失效的常见坑:对索引列使用函数或隐式类型转换、like 以百分号开头、or 连接非索引列。
  131. explain 是最好的朋友:type 至少达到 range 级别,extra 出现 Using filesort 说明排序没有走索引。
  132. 大表分页优化:用游标方式 where id > lastId limit n 代替 limit offset, n。"""
  133. },
  134. {
  135. "JVM 垃圾回收调优笔记",
  136. """
  137. 常见的垃圾收集器:CMS 已废弃,JDK 17 默认是 G1,大堆低延迟场景可以试 ZGC。
  138. G1 的核心概念是 Region 分区,通过 -XX:MaxGCPauseMillis 设定期望停顿时间,默认 200ms。
  139. 调优第一步永远是看 GC 日志:-Xlog:gc* 加上 jstat -gcutil 观察回收频率与耗时。
  140. 经验法则:年轻代对象朝生夕死,Survivor 区太小会提前晋升导致 Full GC,可以适当调大 -XX:SurvivorRatio。"""
  141. }
  142. };
  143. }

RagService.java

  1. package com.study.saa.service;
  2. import com.study.saa.config.StudyProperties;
  3. import org.slf4j.Logger;
  4. import org.slf4j.LoggerFactory;
  5. import org.springframework.ai.chat.client.ChatClient;
  6. import org.springframework.ai.document.Document;
  7. import org.springframework.ai.vectorstore.SearchRequest;
  8. import org.springframework.ai.vectorstore.VectorStore;
  9. import org.springframework.boot.ApplicationRunner;
  10. import org.springframework.stereotype.Service;
  11. import java.util.ArrayList;
  12. import java.util.List;
  13. import java.util.Map;
  14. import java.util.UUID;
  15. import java.util.concurrent.atomic.AtomicBoolean;
  16. /**
  17. * RAG(检索增强生成)核心服务,实现个人博客笔记问答。
  18. *
  19. * <p>初始化流程(对应「初始化方法」):
  20. * <ol>
  21. * <li>连接数据库(h2/mysql 由 study.db-type 决定,DataSource 已注入)</li>
  22. * <li>查询 content 表的所有博文笔记</li>
  23. * <li>按段落分片,为每段生成 Document(带 title 等元数据)</li>
  24. * <li>调用大模型嵌入模型向量化,批量写入向量存储</li>
  25. * </ol>
  26. *
  27. * <p>查询流程(对应「前端输入查询」):
  28. * <ol>
  29. * <li>把用户输入向量化并检索向量库,取回最相似的 Top-K 段落</li>
  30. * <li>把检索到的笔记片段 + 用户问题组装进提示词,提交给大模型</li>
  31. * <li>返回生成的答案与引用来源,实现「查询笔记内容」</li>
  32. * </ol>
  33. */
  34. @Service
  35. public class RagService implements ApplicationRunner {
  36. private static final Logger log = LoggerFactory.getLogger(RagService.class);
  37. /** 每批向量化的文档数(DashScope 嵌入接口有批量上限,取小值稳妥) */
  38. private static final int BATCH_SIZE = 5;
  39. /**
  40. * 单个分片的最大字符数。嵌入模型 text-embedding-v3 单条输入上限 8192 token,
  41. * 中文约 1 字 = 1~2 token,取 1000 字符的保守值既保证不超限,也利于检索精度
  42. * (分片太长会导致向量语义稀释,检索命中变差)。
  43. */
  44. private static final int MAX_CHUNK_CHARS = 1000;
  45. /** 相邻分片的重叠字符数:切分长段落时保留上下文,避免关键句被拦腰截断 */
  46. private static final int CHUNK_OVERLAP = 100;
  47. /** 句子结束符(中文为主),长段落优先按句子边界切分 */
  48. private static final String SENTENCE_END = "[。!?!?;;\\n]";
  49. private final ContentService contentService;
  50. private final VectorStore vectorStore;
  51. private final ChatClient chatClient;
  52. private final StudyProperties properties;
  53. /** 是否已完成初始化 */
  54. private final AtomicBoolean initialized = new AtomicBoolean(false);
  55. /** 已入库的文档 ID,重复初始化时先删除旧数据 */
  56. private final List<String> indexedDocIds = new ArrayList<>();
  57. /** 已向量化的分段数量 */
  58. private volatile int chunkCount = 0;
  59. public RagService(ContentService contentService,
  60. VectorStore vectorStore,
  61. ChatClient chatClient,
  62. StudyProperties properties) {
  63. this.contentService = contentService;
  64. this.vectorStore = vectorStore;
  65. this.chatClient = chatClient;
  66. this.properties = properties;
  67. }
  68. /**
  69. * 启动时按配置决定是否自动初始化。
  70. */
  71. @Override
  72. public void run(org.springframework.boot.ApplicationArguments args) {
  73. if (properties.getRag().isAutoInit()) {
  74. try {
  75. initialize();
  76. } catch (Exception e) {
  77. // 自动初始化失败不阻断启动(常见原因:API Key 未配置),可稍后在页面手动点初始化
  78. log.error("RAG 自动初始化失败(可在页面手动重试):{}", e.getMessage());
  79. }
  80. }
  81. }
  82. /**
  83. * RAG 初始化:content 表 -> 段落分片 -> 嵌入向量化 -> 向量存储。
  84. */
  85. public synchronized Map<String, Object> initialize() {
  86. long start = System.currentTimeMillis();
  87. // 1. 查询数据库中所有博文笔记
  88. List<ContentService.Note> notes = contentService.findAll();
  89. if (notes.isEmpty()) {
  90. throw new IllegalStateException("content 表中没有数据,请先插入博文笔记");
  91. }
  92. // 2. 重复初始化时清理旧向量
  93. cleanOldIndex();
  94. // 3. 按段落分片并构建 Document(超长段落会进一步按句子/固定长度切分,
  95. // 否则会触发嵌入接口 "Range of input length should be [1, 8192]" 报错)
  96. List<Document> documents = new ArrayList<>();
  97. for (ContentService.Note note : notes) {
  98. String[] paragraphs = note.content().split("\\n\\s*\\n");
  99. int index = 0;
  100. for (String paragraph : paragraphs) {
  101. String text = paragraph.strip();
  102. if (text.isEmpty()) {
  103. continue;
  104. }
  105. for (String chunk : splitLongText(text)) {
  106. documents.add(new Document(
  107. UUID.randomUUID().toString(),
  108. chunk,
  109. Map.of("contentId", String.valueOf(note.id()),
  110. "title", note.title(),
  111. "paragraph", String.valueOf(index))));
  112. }
  113. index++;
  114. }
  115. }
  116. // 4. 分批调用嵌入模型向量化并写入向量存储
  117. for (int i = 0; i < documents.size(); i += BATCH_SIZE) {
  118. List<Document> batch = documents.subList(i, Math.min(i + BATCH_SIZE, documents.size()));
  119. vectorStore.add(batch);
  120. batch.forEach(d -> indexedDocIds.add(d.getId()));
  121. }
  122. chunkCount = documents.size();
  123. initialized.set(true);
  124. long cost = System.currentTimeMillis() - start;
  125. log.info("RAG 初始化完成:{} 篇博文 -> {} 个段落分片,耗时 {} ms", notes.size(), documents.size(), cost);
  126. return Map.of(
  127. "noteCount", notes.size(),
  128. "chunkCount", chunkCount,
  129. "costMillis", cost,
  130. "dbType", properties.getDbType());
  131. }
  132. /**
  133. * RAG 查询:向量化检索 + 大模型生成。
  134. */
  135. public Map<String, Object> query(String question) {
  136. if (!initialized.get()) {
  137. throw new IllegalStateException("RAG 尚未初始化,请先点击「初始化知识库」");
  138. }
  139. // 1. 查询文本向量化并检索相似段落
  140. List<Document> hits = vectorStore.similaritySearch(SearchRequest.builder()
  141. .query(question)
  142. .topK(properties.getRag().getTopK())
  143. .build());
  144. if (hits == null || hits.isEmpty()) {
  145. return Map.of(
  146. "answer", "在笔记库中没有找到与问题相关的内容,换个问法试试?",
  147. "sources", List.of());
  148. }
  149. // 2. 组装检索到的笔记上下文
  150. StringBuilder context = new StringBuilder();
  151. List<Map<String, String>> sources = new ArrayList<>();
  152. int i = 1;
  153. for (Document d : hits) {
  154. String title = String.valueOf(d.getMetadata().getOrDefault("title", "未知来源"));
  155. context.append("[片段").append(i++).append("|来自笔记《").append(title).append("》]\n")
  156. .append(d.getText()).append("\n\n");
  157. sources.add(Map.of(
  158. "title", title,
  159. "snippet", d.getText().length() > 120 ? d.getText().substring(0, 120) + "..." : d.getText()));
  160. }
  161. // 3. 检索结果 + 问题一起提交给大模型
  162. String answer = chatClient.prompt()
  163. .system("""
  164. 你是一个个人博客笔记问答助手。请只依据下面提供的笔记片段回答用户问题:
  165. - 如果片段中有答案,用中文简洁准确地回答,并注明出自哪篇笔记;
  166. - 如果片段不足以回答,直接说明笔记中没有相关内容,不要编造。
  167. """)
  168. .user("以下是检索到的笔记片段:\n\n" + context + "\n用户问题:" + question)
  169. .call()
  170. .content();
  171. return Map.of(
  172. "answer", answer == null ? "" : answer,
  173. "sources", sources);
  174. }
  175. public boolean isInitialized() {
  176. return initialized.get();
  177. }
  178. public int getChunkCount() {
  179. return chunkCount;
  180. }
  181. private void cleanOldIndex() {
  182. if (!indexedDocIds.isEmpty()) {
  183. try {
  184. vectorStore.delete(indexedDocIds);
  185. } catch (Exception e) {
  186. log.warn("清理旧向量失败(忽略,继续写入新向量):{}", e.getMessage());
  187. }
  188. indexedDocIds.clear();
  189. }
  190. }
  191. /**
  192. * 文本长度超过 {@link #MAX_CHUNK_CHARS} 时进一步切分:
  193. * 优先按句子边界(。!?;等)聚合切分,单句超长时硬切;
  194. * 相邻分片保留 {@link #CHUNK_OVERLAP} 字符重叠,避免关键信息被截断丢失。
  195. */
  196. private List<String> splitLongText(String text) {
  197. List<String> result = new ArrayList<>();
  198. if (text.length() <= MAX_CHUNK_CHARS) {
  199. result.add(text);
  200. return result;
  201. }
  202. // 按句子结束符切成句子片段
  203. List<String> sentences = new ArrayList<>();
  204. for (String part : text.split("(?<=" + SENTENCE_END + ")")) {
  205. String s = part.strip();
  206. if (!s.isEmpty()) {
  207. sentences.add(s);
  208. }
  209. }
  210. StringBuilder current = new StringBuilder();
  211. for (String sentence : sentences) {
  212. // 单句本身超长 → 先把已聚合的内容收掉,再对这句硬切
  213. if (sentence.length() > MAX_CHUNK_CHARS) {
  214. if (current.length() > 0) {
  215. result.add(current.toString());
  216. current.setLength(0);
  217. }
  218. for (int i = 0; i < sentence.length(); i += MAX_CHUNK_CHARS - CHUNK_OVERLAP) {
  219. int end = Math.min(i + MAX_CHUNK_CHARS, sentence.length());
  220. result.add(sentence.substring(i, end));
  221. if (end == sentence.length()) {
  222. break;
  223. }
  224. }
  225. continue;
  226. }
  227. if (current.length() + sentence.length() > MAX_CHUNK_CHARS && current.length() > 0) {
  228. result.add(current.toString());
  229. // 保留重叠上下文
  230. int overlapStart = Math.max(0, current.length() - CHUNK_OVERLAP);
  231. String tail = current.substring(overlapStart);
  232. current.setLength(0);
  233. current.append(tail);
  234. }
  235. current.append(sentence);
  236. }
  237. if (current.length() > 0) {
  238. result.add(current.toString());
  239. }
  240. log.info("超长段落已切分:{} 字符 -> {} 个分片", text.length(), result.size());
  241. return result;
  242. }
  243. }

AgentController.java

  1. package com.study.saa.controller;
  2. import com.alibaba.cloud.ai.graph.CompiledGraph;
  3. import com.alibaba.cloud.ai.graph.OverAllState;
  4. import org.springframework.web.bind.annotation.PostMapping;
  5. import org.springframework.web.bind.annotation.RequestBody;
  6. import org.springframework.web.bind.annotation.RequestMapping;
  7. import org.springframework.web.bind.annotation.RestController;
  8. import java.util.HashMap;
  9. import java.util.Map;
  10. import java.util.Optional;
  11. /**
  12. * 示例十(入口):Graph Agent 的调用。
  13. *
  14. * <p>执行链路:分类节点 -> 条件边路由 -> 专家节点 -> END,
  15. * 展示多节点 + 条件路由的工作流智能体如何一次 invoke 完成任务。
  16. */
  17. @RestController
  18. @RequestMapping("/api/agent")
  19. public class AgentController {
  20. private final CompiledGraph studyAgentGraph;
  21. public AgentController(CompiledGraph studyAgentGraph) {
  22. this.studyAgentGraph = studyAgentGraph;
  23. }
  24. @PostMapping("/run")
  25. public Map<String, Object> run(@RequestBody Map<String, String> body) {
  26. String input = body.getOrDefault("input", "");
  27. try {
  28. Optional<OverAllState> result = studyAgentGraph.invoke(Map.of("input", input));
  29. String category = result.flatMap(s -> s.value("category")).map(Object::toString).orElse("unknown");
  30. String answer = result.flatMap(s -> s.value("answer")).map(Object::toString).orElse("没有产出结果");
  31. Map<String, Object> data = new HashMap<>();
  32. data.put("category", category);
  33. data.put("answer", answer);
  34. data.put("trace", "执行路径:START -> classify(分类结果:" + category + ") -> "
  35. + expertNode(category) + " -> END");
  36. return Map.of("ok", true, "data", data);
  37. } catch (Exception e) {
  38. return Map.of("ok", false, "data", "Agent 执行失败:" + e.getMessage());
  39. }
  40. }
  41. private String expertNode(String category) {
  42. return switch (category) {
  43. case "concept" -> "concept_expert";
  44. case "code" -> "code_expert";
  45. default -> "chat_expert";
  46. };
  47. }
  48. }

PageController.java

  1. package com.study.saa.controller;
  2. import com.study.saa.config.StudyProperties;
  3. import org.springframework.stereotype.Controller;
  4. import org.springframework.ui.Model;
  5. import org.springframework.web.bind.annotation.GetMapping;
  6. /**
  7. * 页面入口:渲染 FreeMarker 单页面(多个菜单对应各功能示例)。
  8. */
  9. @Controller
  10. public class PageController {
  11. private final StudyProperties properties;
  12. private final org.springframework.core.env.Environment environment;
  13. public PageController(StudyProperties properties,
  14. org.springframework.core.env.Environment environment) {
  15. this.properties = properties;
  16. this.environment = environment;
  17. }
  18. @GetMapping("/")
  19. public String index(Model model) {
  20. model.addAttribute("dbType", properties.getDbType());
  21. model.addAttribute("provider", properties.getModel().getProvider());
  22. model.addAttribute("defaultChatModel", resolveDefaultChatModel());
  23. return "index";
  24. }
  25. private String resolveDefaultChatModel() {
  26. String provider = properties.getModel().getProvider();
  27. if ("openai".equalsIgnoreCase(provider)) {
  28. return properties.getModel().getOpenai().getChatModel();
  29. }
  30. return environment.getProperty("spring.ai.dashscope.chat.options.model", "qwen-plus");
  31. }
  32. }

index.ftl

  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Spring AI Alibaba 学习中心</title>
  7. <style>
  8. * { margin: 0; padding: 0; box-sizing: border-box; }
  9. body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f0f2f5; color: #26303d; }
  10. .layout { display: flex; min-height: 100vh; }
  11. /* ---------- 侧边菜单 ---------- */
  12. .sidebar { width: 240px; background: #1e2a38; color: #cfd8e3; flex-shrink: 0; display: flex; flex-direction: column; }
  13. .sidebar .logo { padding: 22px 20px 16px; border-bottom: 1px solid rgba(255,255,255,.08); }
  14. .sidebar .logo h1 { font-size: 17px; color: #fff; letter-spacing: .5px; }
  15. .sidebar .logo p { font-size: 11px; margin-top: 6px; color: #8fa3ba; }
  16. .menu { flex: 1; overflow-y: auto; padding: 10px 0; }
  17. .menu-item { display: flex; align-items: center; gap: 10px; padding: 12px 20px; cursor: pointer;
  18. font-size: 13.5px; border-left: 3px solid transparent; transition: all .15s; }
  19. .menu-item:hover { background: rgba(255,255,255,.06); color: #fff; }
  20. .menu-item.active { background: rgba(64,150,255,.15); border-left-color: #4096ff; color: #fff; }
  21. .menu-item .tag { font-size: 10px; background: rgba(255,255,255,.12); border-radius: 3px; padding: 1px 5px; margin-left: auto; }
  22. .menu-item .tag.hot { background: #d4380d; color: #fff; }
  23. .sidebar .dbinfo { padding: 14px 20px; font-size: 11px; color: #8fa3ba; border-top: 1px solid rgba(255,255,255,.08); line-height: 1.8; }
  24. /* ---------- 内容区 ---------- */
  25. .main { flex: 1; padding: 26px 32px; overflow-y: auto; max-width: 1060px; }
  26. .panel { display: none; }
  27. .panel.active { display: block; }
  28. .panel h2 { font-size: 19px; margin-bottom: 8px; }
  29. .panel .desc { font-size: 13px; color: #5d6b7e; background: #eef4ff; border-left: 3px solid #4096ff;
  30. padding: 10px 14px; border-radius: 4px; margin-bottom: 18px; line-height: 1.8; }
  31. .panel .desc code { background: #dde7f7; padding: 1px 6px; border-radius: 3px; font-size: 12px; color: #1d39c4; }
  32. .card { background: #fff; border-radius: 10px; padding: 20px; box-shadow: 0 1px 4px rgba(31,45,61,.08); margin-bottom: 16px; }
  33. .card label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 8px; color: #3b4757; }
  34. .card textarea, .card input[type=text], .card select {
  35. width: 100%; border: 1px solid #d4dbe4; border-radius: 6px; padding: 9px 12px;
  36. font-size: 13.5px; font-family: inherit; outline: none; transition: border .15s; }
  37. .card textarea:focus, .card input:focus { border-color: #4096ff; }
  38. .card textarea { min-height: 88px; resize: vertical; }
  39. .row { display: flex; gap: 12px; }
  40. .row > div { flex: 1; }
  41. .btn { display: inline-flex; align-items: center; gap: 6px; background: #1677ff; color: #fff; border: none;
  42. border-radius: 6px; padding: 9px 22px; font-size: 13.5px; cursor: pointer; margin-top: 14px; transition: background .15s; }
  43. .btn:hover { background: #4096ff; }
  44. .btn:disabled { background: #9db8d9; cursor: not-allowed; }
  45. .btn.ghost { background: #fff; color: #1677ff; border: 1px solid #1677ff; }
  46. .btn.ghost:hover { background: #e8f2ff; }
  47. .output { margin-top: 16px; background: #0f1720; color: #d7e2ee; border-radius: 8px; padding: 16px;
  48. font-size: 13px; line-height: 1.9; white-space: pre-wrap; word-break: break-all;
  49. font-family: Consolas, "Courier New", monospace; min-height: 60px; }
  50. .output .placeholder { color: #5d7188; }
  51. .output.error { background: #2b1215; color: #ff9f9f; }
  52. .sources { margin-top: 12px; }
  53. .sources .src { background: #f6f8fb; border: 1px solid #e3e9f1; border-radius: 6px; padding: 10px 12px;
  54. margin-bottom: 8px; font-size: 12.5px; color: #43536a; }
  55. .sources .src b { color: #1677ff; }
  56. .hint { font-size: 12px; color: #8a97a8; margin-top: 10px; line-height: 1.8; }
  57. .badge { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-right: 6px; }
  58. .badge.green { background: #e6f7ef; color: #0f9d58; }
  59. .badge.gray { background: #eef1f5; color: #7a8798; }
  60. .status-bar { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; font-size: 13px; }
  61. </style>
  62. </head>
  63. <body>
  64. <div class="layout">
  65. <div class="sidebar">
  66. <div class="logo">
  67. <h1>Spring AI Alibaba</h1>
  68. <p>学习中心 · 10 个核心示例</p>
  69. </div>
  70. <div class="menu" id="menu">
  71. <div class="menu-item active" data-panel="chat-client"><span>💬 ChatClient 对话</span></div>
  72. <div class="menu-item" data-panel="chat-model"><span>⚙️ ChatModel 底层调用</span></div>
  73. <div class="menu-item" data-panel="embedding"><span>🧮 嵌入模型</span></div>
  74. <div class="menu-item" data-panel="tool"><span>🔧 工具调用</span><span class="tag">Agent基础</span></div>
  75. <div class="menu-item" data-panel="memory"><span>🧠 对话记忆</span></div>
  76. <div class="menu-item" data-panel="prompt"><span>📝 提示词模板</span></div>
  77. <div class="menu-item" data-panel="format"><span>📦 结构化输出</span></div>
  78. <div class="menu-item" data-panel="vector"><span>🗄️ 向量存储</span></div>
  79. <div class="menu-item" data-panel="rag"><span>📚 RAG 知识库问答</span><span class="tag hot">核心</span></div>
  80. <div class="menu-item" data-panel="agent"><span>🤖 Graph Agent</span><span class="tag hot">核心</span></div>
  81. </div>
  82. <div class="dbinfo">
  83. 当前数据库:<b>${dbType}</b><br>
  84. 切换方式:application.yml<br>→ study.db-type: h2 / mysql
  85. </div>
  86. </div>
  87. <div class="main">
  88. <!-- ================= 1. ChatClient ================= -->
  89. <div class="panel active" id="panel-chat-client">
  90. <h2>💬 ChatClient 的使用</h2>
  91. <div class="desc">
  92. <code>ChatClient</code> 是 Spring AI 推荐的高层对话 API:链式调用
  93. <code>prompt().user(...).call().content()</code>。下方第二个输入演示
  94. <code>system</code> 系统提示词的角色扮演用法。
  95. </div>
  96. <div class="card">
  97. <label>基础对话</label>
  98. <textarea id="cc-input" placeholder="例如:用一句话介绍 Spring AI Alibaba 是什么?"></textarea>
  99. <button class="btn" onclick="callText('/api/chat/client', {input: val('cc-input')}, 'cc-out')">发送</button>
  100. <div class="output" id="cc-out"><span class="placeholder">回答将显示在这里…</span></div>
  101. </div>
  102. <div class="card">
  103. <label>角色扮演(system 消息)</label>
  104. <textarea id="cc-role-input" placeholder="例如:请问我一个 Java 并发方面的问题"></textarea>
  105. <button class="btn" onclick="callText('/api/chat/role', {input: val('cc-role-input')}, 'cc-role-out')">以面试官身份回答</button>
  106. <div class="output" id="cc-role-out"><span class="placeholder">回答将显示在这里…</span></div>
  107. </div>
  108. </div>
  109. <!-- ================= 2. ChatModel ================= -->
  110. <div class="panel" id="panel-chat-model">
  111. <h2>⚙️ ChatModel 的使用(底层 API)</h2>
  112. <div class="desc">
  113. 直接注入 <code>ChatModel</code> 接口调用模型,使用 application.yml 中配置的默认模型
  114. <code>${defaultChatModel?html}</code>(provider: <code>${provider?html}</code>)。
  115. 这里演示的是 ChatModel 的底层调用与 token 消耗等元信息,模型由配置统一决定。
  116. </div>
  117. <div class="card">
  118. <div class="row">
  119. <div style="flex:3">
  120. <label>问题</label>
  121. <textarea id="cm-input" placeholder="例如:用三句话讲清楚什么是 JVM"></textarea>
  122. </div>
  123. <div style="flex:1">
  124. <label>当前模型</label>
  125. <input type="text" id="cm-model" value="${defaultChatModel?html}" readonly>
  126. </div>
  127. </div>
  128. <button class="btn" onclick="callText('/api/chat/model', {input: val('cm-input')}, 'cm-out')">调用 ChatModel</button>
  129. <div class="output" id="cm-out"><span class="placeholder">回答与 token 用量将显示在这里…</span></div>
  130. </div>
  131. </div>
  132. <!-- ================= 3. Embedding ================= -->
  133. <div class="panel" id="panel-embedding">
  134. <h2>🧮 嵌入模型(EmbeddingModel)</h2>
  135. <div class="desc">
  136. 嵌入模型把文本变成高维向量(<code>text-embedding-v3</code>),是语义检索和 RAG 的基础。
  137. 第二个卡片计算两段文本的余弦相似度,直观感受「语义距离」。
  138. </div>
  139. <div class="card">
  140. <label>文本向量化</label>
  141. <textarea id="emb-input" placeholder="例如:Spring AI 让 Java 工程师也能优雅地开发 AI 应用"></textarea>
  142. <button class="btn" onclick="callText('/api/embedding/embed', {input: val('emb-input')}, 'emb-out')">生成向量</button>
  143. <div class="output" id="emb-out"><span class="placeholder">向量维度与前几个分量将显示在这里…</span></div>
  144. </div>
  145. <div class="card">
  146. <label>语义相似度计算</label>
  147. <div class="row">
  148. <div><input type="text" id="emb-t1" placeholder="文本A:我想学习大模型开发"></div>
  149. <div><input type="text" id="emb-t2" placeholder="文本B:如何入门 AI 应用开发"></div>
  150. </div>
  151. <button class="btn" onclick="callText('/api/embedding/similarity', {text1: val('emb-t1'), text2: val('emb-t2')}, 'emb-sim-out')">计算相似度</button>
  152. <div class="output" id="emb-sim-out"><span class="placeholder">余弦相似度将显示在这里…</span></div>
  153. </div>
  154. </div>
  155. <!-- ================= 4. Tool ================= -->
  156. <div class="panel" id="panel-tool">
  157. <h2>🔧 工具调用(Tool Calling)</h2>
  158. <div class="desc">
  159. 给模型绑定 <code>@Tool</code> 标记的 Java 方法(天气查询 + 计算器),模型会自动决定何时调用。
  160. 试试:<code>杭州今天天气怎么样?</code><code>帮我算一下 125 乘以 88</code>
  161. <code>北京和上海的天气对比,两地温差多少?</code>
  162. </div>
  163. <div class="card">
  164. <label>向绑定了工具的模型提问</label>
  165. <textarea id="tool-input" placeholder="例如:查一下北京和杭州的天气,并算出两地温差"></textarea>
  166. <button class="btn" onclick="callText('/api/tool/chat', {input: val('tool-input')}, 'tool-out')">发送(模型自动选工具)</button>
  167. <div class="output" id="tool-out"><span class="placeholder">模型回答将显示在这里…</span></div>
  168. </div>
  169. </div>
  170. <!-- ================= 5. Memory ================= -->
  171. <div class="panel" id="panel-memory">
  172. <h2>🧠 对话记忆(Chat Memory)</h2>
  173. <div class="desc">
  174. 通过 <code>MessageChatMemoryAdvisor</code> 挂载记忆,同一会话 ID 下模型能记住上文。
  175. 试试先说「我叫小明,正在学 Java」,再问「我叫什么?」。
  176. </div>
  177. <div class="card">
  178. <div class="row">
  179. <div style="flex:1">
  180. <label>会话 ID</label>
  181. <input type="text" id="mem-conv" value="session-001">
  182. </div>
  183. </div>
  184. <div style="height:12px"></div>
  185. <label>消息内容</label>
  186. <textarea id="mem-input" placeholder="例如:我叫小明,正在学习 Spring AI"></textarea>
  187. <button class="btn" onclick="callText('/api/memory/chat', {input: val('mem-input'), conversationId: val('mem-conv')}, 'mem-out')">发送(记住上文)</button>
  188. <button class="btn ghost" onclick="callText('/api/memory/history?conversationId=' + encodeURIComponent(val('mem-conv')), null, 'mem-out', 'GET')">查看当前记忆</button>
  189. <div class="output" id="mem-out"><span class="placeholder">回答将显示在这里…</span></div>
  190. </div>
  191. </div>
  192. <!-- ================= 6. Prompt ================= -->
  193. <div class="panel" id="panel-prompt">
  194. <h2>📝 提示词模板(Prompt Template)</h2>
  195. <div class="desc">
  196. <code>PromptTemplate</code><code>{变量}</code> 占位,把提示词骨架与动态变量分离。
  197. 页面会同时展示「渲染后的提示词」和「模型输出」。
  198. </div>
  199. <div class="card">
  200. <div class="row">
  201. <div style="flex:2"><label>主题(topic)</label><input type="text" id="pt-topic" value="RAG 检索增强生成"></div>
  202. <div style="flex:1"><label>风格(style)</label>
  203. <select id="pt-style">
  204. <option value="通俗易懂">通俗易懂</option>
  205. <option value="幽默风趣">幽默风趣</option>
  206. <option value="严谨学术">严谨学术</option>
  207. </select>
  208. </div>
  209. </div>
  210. <button class="btn" onclick="callText('/api/prompt/generate', {topic: val('pt-topic'), style: document.getElementById('pt-style').value}, 'pt-out')">渲染并生成</button>
  211. <div class="output" id="pt-out"><span class="placeholder">渲染后的提示词与模型输出将显示在这里…</span></div>
  212. </div>
  213. </div>
  214. <!-- ================= 7. Format ================= -->
  215. <div class="panel" id="panel-format">
  216. <h2>📦 结构化输出(Structured Output)</h2>
  217. <div class="desc">
  218. 使用 <code>.entity(Class)</code> 让模型输出自动转成 Java record(ActorFilms),
  219. 无需手工解析 JSON。输入演员名字试试。
  220. </div>
  221. <div class="card">
  222. <label>演员名字</label>
  223. <input type="text" id="fmt-input" value="周星驰" placeholder="例如:周星驰">
  224. <button class="btn" onclick="callText('/api/format/actor', {input: val('fmt-input')}, 'fmt-out')">生成结构化对象</button>
  225. <div class="output" id="fmt-out"><span class="placeholder">结构化结果将显示在这里…</span></div>
  226. </div>
  227. </div>
  228. <!-- ================= 8. Vector ================= -->
  229. <div class="panel" id="panel-vector">
  230. <h2>🗄️ 向量存储(VectorStore)</h2>
  231. <div class="desc">
  232. 先「添加文档」把文本向量化入库,再「相似检索」体验语义搜索(不是关键词匹配)。
  233. 例如添加「iPhone 15 Pro 搭载 A17 芯片」后,搜索「苹果手机用的什么处理器」也能命中。
  234. </div>
  235. <div class="card">
  236. <label>添加文档</label>
  237. <textarea id="vec-add" placeholder="输入一段要向量化存储的文本,例如:iPhone 15 Pro 搭载 A17 仿生芯片,性能提升 10%"></textarea>
  238. <button class="btn" onclick="callText('/api/vector/add', {text: val('vec-add')}, 'vec-add-out')">添加并向量化</button>
  239. <div class="output" id="vec-add-out"><span class="placeholder">添加结果…</span></div>
  240. </div>
  241. <div class="card">
  242. <div class="row">
  243. <div style="flex:3"><label>相似检索</label><input type="text" id="vec-query" placeholder="例如:苹果手机用的什么处理器"></div>
  244. <div style="flex:1"><label>Top-K</label>
  245. <select id="vec-topk">
  246. <option>3</option><option>5</option><option>10</option>
  247. </select>
  248. </div>
  249. </div>
  250. <button class="btn" onclick="callText('/api/vector/search', {query: val('vec-query'), topK: document.getElementById('vec-topk').value}, 'vec-search-out')">语义检索</button>
  251. <div class="output" id="vec-search-out"><span class="placeholder">检索结果(含相似度分数)…</span></div>
  252. </div>
  253. </div>
  254. <!-- ================= 9. RAG ================= -->
  255. <div class="panel" id="panel-rag">
  256. <h2>📚 RAG 知识库问答(博客笔记检索)</h2>
  257. <div class="desc">
  258. 流程:<b>初始化</b>——查询 content 表全部博文 → 按段落分片 → 嵌入模型向量化 → 写入向量库;
  259. <b>提问</b>——问题向量化 → 相似检索 Top-K → 检索片段 + 问题交给大模型 → 带来源的回答。
  260. <br>试试问:「RAG 的离线阶段要做哪些事」「MySQL 索引怎么优化」「G1 垃圾收集器怎么调优」。
  261. </div>
  262. <div class="status-bar" id="rag-status-bar">
  263. <span>初始化状态:</span><span id="rag-status">检查中…</span>
  264. </div>
  265. <div class="card">
  266. <button class="btn" id="rag-init-btn" onclick="ragInit()">🔄 初始化知识库(content 表 → 向量库)</button>
  267. <div class="hint">初始化会重新向量化全部博文(重复执行会先清理旧数据)。数据库类型:${dbType}(h2=内存模拟,mysql=真实博客库)</div>
  268. <div class="output" id="rag-init-out"><span class="placeholder">初始化结果…</span></div>
  269. </div>
  270. <div class="card">
  271. <label>向知识库提问</label>
  272. <textarea id="rag-query" placeholder="例如:RAG 离线阶段要做什么?"></textarea>
  273. <button class="btn" onclick="ragQuery()">检索并回答</button>
  274. <div class="output" id="rag-answer-out"><span class="placeholder">基于笔记内容的回答将显示在这里…</span></div>
  275. <div class="sources" id="rag-sources"></div>
  276. </div>
  277. </div>
  278. <!-- ================= 10. Agent ================= -->
  279. <div class="panel" id="panel-agent">
  280. <h2>🤖 Graph Agent(多节点工作流智能体)</h2>
  281. <div class="desc">
  282. 基于 Spring AI Alibaba <code>Graph</code> 的工作流智能体:
  283. 分类节点(LLM 判断问题类型)→ 条件边路由 → 概念讲解 / 代码助手 / 闲聊伙伴 三个专家节点之一 → 汇总输出。
  284. 同一个问题会走不同的执行路径,回答下方会展示实际执行轨迹。
  285. </div>
  286. <div class="card">
  287. <label>向学习助手 Agent 提问</label>
  288. <textarea id="agent-input" placeholder="概念类:什么是向量数据库? / 代码类:帮我写一个 Java 线程池示例 / 闲聊类:今天好累啊"></textarea>
  289. <button class="btn" onclick="agentRun()">🚀 执行 Agent 工作流</button>
  290. <div class="output" id="agent-out"><span class="placeholder">Agent 执行结果与路径将显示在这里…</span></div>
  291. </div>
  292. </div>
  293. </div>
  294. </div>
  295. <script>
  296. // ---------- 菜单切换 ----------
  297. var menu = document.getElementById('menu');
  298. menu.addEventListener('click', function (e) {
  299. var item = e.target.closest('.menu-item');
  300. if (!item) return;
  301. document.querySelectorAll('.menu-item').forEach(function (m) { m.classList.remove('active'); });
  302. item.classList.add('active');
  303. document.querySelectorAll('.panel').forEach(function (p) { p.classList.remove('active'); });
  304. document.getElementById('panel-' + item.dataset.panel).classList.add('active');
  305. if (item.dataset.panel === 'rag') refreshRagStatus();
  306. });
  307. // ---------- 通用请求 ----------
  308. function val(id) { return document.getElementById(id).value.trim(); }
  309. function showOut(id, text, isError) {
  310. var el = document.getElementById(id);
  311. el.textContent = text;
  312. el.classList.toggle('error', !!isError);
  313. }
  314. function callText(url, body, outId, method) {
  315. var opts = { method: method || 'POST', headers: { 'Content-Type': 'application/json' } };
  316. if (body !== null && (method || 'POST') === 'POST') opts.body = JSON.stringify(body);
  317. showOut(outId, '请求中,请稍候…(首次调用大模型可能需要几秒)');
  318. fetch(url, opts)
  319. .then(function (r) { return r.json(); })
  320. .then(function (res) {
  321. var data = res.data;
  322. var text;
  323. if (data && typeof data === 'object' && 'answer' in data) {
  324. text = data.answer;
  325. if (data.trace) text += '\n\n---- 执行轨迹 ----\n' + data.trace;
  326. if (data.sources && data.sources.length) text += '\n\n---- 引用来源见下方 ----';
  327. } else if (data && typeof data === 'object') {
  328. text = data.answer || data.message || JSON.stringify(data, null, 2);
  329. } else {
  330. text = data === undefined || data === null ? '(空)' : String(data);
  331. }
  332. showOut(outId, text, !res.ok);
  333. if (data && typeof data === 'object' && Array.isArray(data.sources)) renderSources(data.sources);
  334. })
  335. .catch(function (err) { showOut(outId, '请求异常:' + err.message, true); });
  336. }
  337. function renderSources(sources) {
  338. var box = document.getElementById('rag-sources');
  339. box.innerHTML = '';
  340. sources.forEach(function (s) {
  341. var div = document.createElement('div');
  342. div.className = 'src';
  343. var b = document.createElement('b'); b.textContent = '《' + s.title + '》';
  344. div.appendChild(b);
  345. div.appendChild(document.createElement('br'));
  346. div.appendChild(document.createTextNode(s.snippet));
  347. box.appendChild(div);
  348. });
  349. }
  350. // ---------- RAG ----------
  351. function ragInit() {
  352. var btn = document.getElementById('rag-init-btn');
  353. btn.disabled = true; btn.textContent = '⏳ 初始化中(向量化需要时间)…';
  354. showOut('rag-init-out', '正在查询 content 表并调用嵌入模型向量化…');
  355. fetch('/api/rag/init', { method: 'POST' })
  356. .then(function (r) { return r.json(); })
  357. .then(function (res) {
  358. var d = res.data;
  359. var text = res.ok
  360. ? '✅ ' + d.message + '\n博文数:' + d.noteCount + ' 篇\n分段数:' + d.chunkCount + ' 段\n耗时:' + d.costMillis + ' ms\n数据库:' + d.dbType
  361. : '❌ ' + d;
  362. showOut('rag-init-out', text, !res.ok);
  363. refreshRagStatus();
  364. })
  365. .catch(function (e) { showOut('rag-init-out', '请求异常:' + e.message, true); })
  366. .finally(function () { btn.disabled = false; btn.textContent = '🔄 初始化知识库(content 表 → 向量库)'; });
  367. }
  368. function ragQuery() {
  369. var q = val('rag-query');
  370. if (!q) { showOut('rag-answer-out', '请输入问题', true); return; }
  371. document.getElementById('rag-sources').innerHTML = '';
  372. callText('/api/rag/query', { query: q }, 'rag-answer-out');
  373. }
  374. function refreshRagStatus() {
  375. fetch('/api/rag/status').then(function (r) { return r.json(); }).then(function (res) {
  376. var d = res.data;
  377. var el = document.getElementById('rag-status');
  378. if (d.initialized) {
  379. el.innerHTML = '<span class="badge green">已初始化</span> 已向量化 ' + d.chunkCount + ' 个段落(' + d.dbType + ')';
  380. } else {
  381. el.innerHTML = '<span class="badge gray">未初始化</span> 请先点击下方按钮初始化(' + d.dbType + ')';
  382. }
  383. }).catch(function () {
  384. document.getElementById('rag-status').textContent = '状态获取失败';
  385. });
  386. }
  387. // ---------- Agent ----------
  388. function agentRun() {
  389. var input = val('agent-input');
  390. if (!input) { showOut('agent-out', '请输入内容', true); return; }
  391. callText('/api/agent/run', { input: input }, 'agent-out');
  392. }
  393. // 页面加载后检查 RAG 状态
  394. refreshRagStatus();
  395. </script>
  396. </body>
  397. </html>

init.sql

  1. -- ============================================================
  2. -- Spring AI Alibaba 学习项目 - MySQL 初始化脚本
  3. -- 使用场景:study.db-type=mysql 时,在真实的博客数据库中执行
  4. -- content 表通常你的博客系统已经有了;没有时执行下面的建表语句即可)
  5. -- ============================================================
  6. -- 博文笔记表(RAG 数据源)
  7. CREATE TABLE IF NOT EXISTS content (
  8. id BIGINT AUTO_INCREMENT PRIMARY KEY,
  9. title VARCHAR(255) NOT NULL COMMENT '笔记标题',
  10. content TEXT NOT NULL COMMENT '笔记内容,段落之间用空行分隔'
  11. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='博文笔记表';
  12. -- (可选)插入几条示例数据,已有博客数据则跳过
  13. -- INSERT INTO content (title, content) VALUES
  14. -- ('Spring AI 入门笔记', '第一段内容...\n\n第二段内容...'),
  15. -- ('RAG 实践笔记', '第一段内容...\n\n第二段内容...');
  16. -- 3.(可选扩展)对话记忆持久化:如需把多轮对话历史存到 MySQL
  17. -- 可在 pom.xml 引入 spring-ai-starter-model-chat-memory-repository-jdbc 并执行下表:
  18. -- CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
  19. -- conversation_id VARCHAR(36) NOT NULL,
  20. -- content TEXT NOT NULL,
  21. -- type ENUM('USER', 'ASSISTANT', 'SYSTEM', 'TOOL') NOT NULL,
  22. -- `timestamp` TIMESTAMP NOT NULL,
  23. -- INDEX SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX (conversation_id, `timestamp`)
  24. -- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Spring AI 对话记忆表';
  25. -- 本项目默认使用内存对话记忆(重启清空),无需此表。
 6

啊!这个可能是世界上最丑的留言输入框功能~


当然,也是最丑的留言列表

有疑问发邮件到 : suibibk@qq.com 侵权立删
Copyright : 个人随笔   备案号 : 粤ICP备18099399号-2