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

本次项目用的是jdk17
pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.4.5</version><relativePath/></parent><groupId>com.study</groupId><artifactId>spring-ai-alibaba-study</artifactId><version>1.0.0</version><name>spring-ai-alibaba-study</name><description>Spring AI Alibaba 学习项目:ChatClient/ChatModel/Embedding/Tool/Memory/Prompt/结构化输出/向量存储/RAG/Graph Agent</description><properties><java.version>17</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding><!-- Spring AI Alibaba 版本(官网 https://java2ai.com) --><spring-ai-alibaba.version>1.0.0.2</spring-ai-alibaba.version><!-- Spring AI 版本,与 Spring AI Alibaba 1.0.0.2 配套 --><spring-ai.version>1.0.0</spring-ai.version></properties><dependencyManagement><dependencies><!-- Spring AI Alibaba BOM:统一管理 dashscope starter / graph-core 等版本 --><dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-bom</artifactId><version>${spring-ai-alibaba.version}</version><type>pom</type><scope>import</scope></dependency><!-- Spring AI BOM:统一管理 spring-ai 官方组件版本 --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>${spring-ai.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><dependencies><!-- Web MVC --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- FreeMarker 页面模板 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-freemarker</artifactId></dependency><!-- JDBC(DataSource、JdbcTemplate、对话记忆持久化都用它) --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><!-- Spring AI Alibaba:DashScope(阿里云百炼/通义) 模型适配 Starter自动装配 ChatModel / EmbeddingModel / ChatClient.Builder 等 Bean --><dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-starter-dashscope</artifactId></dependency><!-- Spring AI Alibaba Graph:工作流 / 多智能体框架(Agent 示例使用) --><dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-graph-core</artifactId></dependency><!-- Gson:Graph 框架默认状态序列化器所需 --><dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId></dependency><!-- Spring AI OpenAI 模块(非 starter,手动构建 Bean):DeepSeek / 腾讯混元 / 字节豆包 / Kimi / GLM 等厂商都提供 OpenAI 兼容接口,通过 study.model.provider=openai 切换,见 ModelProviderConfig --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-openai</artifactId></dependency><!-- Spring AI 向量存储抽象 + 内存实现 SimpleVectorStore --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-vector-store</artifactId></dependency><!-- ============ 可选向量数据库实现(study.vectorstore.type 切换,默认 simple 内存版) ============ --><!-- Redis Stack(本地最轻量的持久化向量库;协议与阿里云 Tair 兼容) --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-vector-store-redis</artifactId></dependency><!-- Milvus(开源向量数据库,本地 Docker 一行命令即可跑) --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-vector-store-milvus</artifactId></dependency><!-- Chroma(轻量本地向量数据库,适合个人项目) --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-vector-store-chroma</artifactId></dependency><!-- 阿里云 Tair(阿里云自研 Redis 兼容数据库,支持向量检索;用云上实例时选它) --><dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-starter-store-tair</artifactId></dependency><!-- H2 内存数据库(study.db-type=h2 时使用,零配置直接跑) --><dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency><!-- MySQL 驱动(study.db-type=mysql 时使用) --><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
application.yml
server:port: 8080spring:application:name: spring-ai-alibaba-studyfreemarker:charset: UTF-8content-type: text/html;charset=UTF-8enabled: truesuffix: .ftlweb:resources:add-mappings: trueai:dashscope:# 大模型密钥:优先读环境变量 AI_DASHSCOPE_API_KEY,也可以直接把 key 写在下面# 获取地址:https://bailian.console.aliyun.com/(阿里云百炼平台)# 说明:默认给了一个占位值,保证未配置 Key 时项目也能正常启动(此时调用模型接口会返回友好报错)api-key: ${AI_DASHSCOPE_API_KEY:xxxxxxx}chat:options:# 对话模型:必须是阿里云百炼(DashScope)上真实存在的模型名,# 常用:qwen-plus(推荐,性价比高) / qwen-max(最强) / qwen-turbo(便宜快速) / qwen-flash(超快)# 注意:glm、deepseek 官方版等非阿里模型不能填在这里,会报 "url error, please check url"model: qwen-plustemperature: 0.7embedding:options:# 嵌入(向量化)模型model: text-embedding-v3# 重试策略:失败快速返回(默认重试 10 次等待太久,不适合学习项目)retry:max-attempts: 3backoff:initial-interval: 2000multiplier: 2max-interval: 10000vectorstore:# 关键开关:chroma/milvus/redis 的官方自动装配在该属性"缺失"时会默认激活# 并互相冲突(Bean 名都叫 vectorStore)。这里显式指定一个不匹配任何# starter 的值,让它们全部让位 —— 向量库统一由下面的 study.vectorstore.type 控制。# (这也是 Spring AI 1.0 官方的多向量库切换属性,若想用官方自动装配可改回# redis / milvus / chroma,但本项目为了学习价值选择手动装配)type: simple# ================= 自定义学习项目配置 =================study:# ---------- 模型来源切换 ----------model:# 对话模型来源:# dashscope - 阿里云百炼(qwen 系列,走 spring-ai-alibaba starter)# openai - 任意 OpenAI 兼容接口:DeepSeek / 腾讯混元 / 字节豆包 / Kimi / GLM ...provider: openai# 嵌入模型来源:auto(跟随 provider) / dashscope / openai# 注意:DeepSeek 不提供 embedding 接口 —— 对话选 DeepSeek 时保持 auto 即可,# 嵌入会自动回退到 DashScope(需保留有效的百炼 Key)embedding-provider: auto# provider=openai 时生效(各厂商速查,任选其一改这三项即可):# DeepSeek base-url: https://api.deepseek.com chat-model: deepseek-chat# 腾讯混元 base-url: https://api.hunyuan.cloud.tencent.com/v1 chat-model: hunyuan-turbos-latest# 字节豆包 base-url: https://ark.cn-beijing.volces.com/api/v3 chat-model: doubao-seed-1-6-250615# Kimi base-url: https://api.moonshot.cn/v1 chat-model: moonshot-v1-8k# 智谱GLM base-url: https://open.bigmodel.cn/api/paas/v4 chat-model: glm-4-flashopenai:base-url: https://api.deepseek.com# 对应厂商的 API Key(也可用环境变量 OPENAI_COMPATIBLE_API_KEY 覆盖)api-key: ${OPENAI_COMPATIBLE_API_KEY:xxxxx}chat-model: deepseek-v4-flash# 嵌入模型名:混元/豆包/Kimi 等有 embedding 的厂商填对应模型(如 doubao-embedding-large);# DeepSeek 留空即可(自动回退 DashScope 嵌入)embedding-model: ""temperature: 0.7# ---------- 数据库切换 ----------# 数据库类型开关:h2(默认,内存库模拟) / mysql(真实博客库)# 注意:仅 h2 模式会自动建 content 表并插入示例数据;mysql 模式只读真实库,不做任何建表/写入db-type: h2mysql:# study.db-type=mysql 时生效,改成你真实的博客数据库连接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=trueusername: xxxxxxpassword: xxxxxx# study.db-type=mysql 时生效:自定义查询 SQL(从真实博客库读取要向量化的内容)# 要求返回三列且列名/别名为 id、title、content;改成你需要的表和过滤条件即可content-sql: "SELECT id,content_title as title,content_details as content FROM content where id='2062803744227950592'"# ---------- 向量数据库切换 ----------vectorstore:# 向量数据库类型:# simple - 内存版(默认,零部署直接跑,重启后需重新初始化 RAG)# tair - 阿里云 Tair(Redis 协议兼容 + 向量检索,Spring AI Alibaba 官方适配)# redis - Redis Stack(本地最轻量持久化方案,docker run -p 6379:6379 redis/redis-stack-server)# milvus - Milvus(开源向量数据库,本地 docker 部署,默认端口 19530)# chroma - Chroma(轻量本地向量数据库,docker run -p 8000:8000 chromadb/chroma)type: simple# 嵌入向量维度:必须与嵌入模型一致!阿里 text-embedding-v3 = 1024embedding-dimensions: 1024# type=tair 时生效(阿里云 Tair 实例连接信息,控制台获取)tair:host: r-bp1xxxxxx.redis.rds.aliyuncs.comport: 6379username: ""password: ""index-name: study-rag-index# type=redis 时生效redis:host: localhostport: 6379username: ""password: ""index-name: study-rag-index# type=milvus 时生效milvus:host: localhostport: 19530database-name: defaultcollection-name: study_rag# type=chroma 时生效chroma:base-url: http://localhost:8000collection-name: study_ragrag:# 启动时是否自动执行 RAG 初始化(向量化 content 表内容,需要有效的 API Key)auto-init: false# 检索时返回最相似的前 K 段top-k: 4# content 表为空时是否插入示例博文(方便开箱即用;自己的库有数据则不会插入)sample-data-on-empty: truelogging:level:com.study.saa: infoorg.springframework.ai: info
StudyApplication.java
package com.study.saa;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.boot.context.properties.ConfigurationPropertiesScan;/*** Spring AI Alibaba 学习项目启动类。** <p>启动前请配置通义大模型 API Key(二选一):* <ul>* <li>环境变量 AI_DASHSCOPE_API_KEY=sk-xxx</li>* <li>或直接写入 application.yml: spring.ai.dashscope.api-key</li>* </ul>** <p>启动后访问首页:http://localhost:8080/*/@SpringBootApplication@ConfigurationPropertiesScanpublic class StudyApplication {public static void main(String[] args) {SpringApplication.run(StudyApplication.class, args);}}
StudyTools.java
package com.study.saa.tools;import org.springframework.ai.tool.annotation.Tool;import org.springframework.ai.tool.annotation.ToolParam;import org.springframework.stereotype.Component;import java.util.Map;import java.util.concurrent.ThreadLocalRandom;/*** 工具调用(Function Calling)示例:普通 Spring Bean 中的方法加上 @Tool 注解,* 即可被大模型在对话过程中自动选择并调用。** <p>这里提供两个模拟工具:天气查询、四则运算计算器。*/@Componentpublic class StudyTools {/*** 模拟天气查询工具(真实项目可对接天气 API)。*/@Tool(description = "查询指定城市的实时天气情况,返回天气现象和温度")public String getWeather(@ToolParam(description = "城市名称,例如:北京、上海、杭州") String city) {// 模拟数据:真实场景替换为天气 API 调用Map<String, String> weather = Map.of("北京", "晴, 12°C, 北风3级","上海", "多云, 18°C, 东风2级","杭州", "小雨, 16°C, 东南风3级","深圳", "阴, 26°C, 微风");String result = weather.get(city);if (result == null) {result = "晴转多云, " + (15 + ThreadLocalRandom.current().nextInt(10)) + "°C, 微风";}return city + "当前天气:" + result;}/*** 四则运算计算器工具。*/@Tool(description = "计算两个整数的四则运算结果,operation 取值:add(加)/subtract(减)/multiply(乘)/divide(除)")public double calculate(@ToolParam(description = "第一个整数") int a,@ToolParam(description = "运算类型:add/subtract/multiply/divide") String operation,@ToolParam(description = "第二个整数") int b) {return switch (operation) {case "add" -> (double) a + b;case "subtract" -> (double) a - b;case "multiply" -> (double) a * b;case "divide" -> b == 0 ? Double.NaN : (double) a / b;default -> throw new IllegalArgumentException("不支持的运算类型: " + operation);};}}
StudyAgentConfig.java
package com.study.saa.agent;import com.alibaba.cloud.ai.graph.CompiledGraph;import com.alibaba.cloud.ai.graph.KeyStrategy;import com.alibaba.cloud.ai.graph.OverAllState;import com.alibaba.cloud.ai.graph.OverAllStateFactory;import com.alibaba.cloud.ai.graph.StateGraph;import com.alibaba.cloud.ai.graph.action.AsyncEdgeAction;import com.alibaba.cloud.ai.graph.action.AsyncNodeAction;import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.model.ChatModel;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import java.util.Map;import static com.alibaba.cloud.ai.graph.StateGraph.END;import static com.alibaba.cloud.ai.graph.StateGraph.START;/*** 示例十:Agent 的创建与使用 —— 基于 Spring AI Alibaba Graph 的工作流智能体。** <p>本示例实现一个「学习助手 Agent」,流程:* <pre>* ┌────────────┐* START ──▶│ 分类节点 │ LLM 判断问题类型:concept(概念) / code(代码) / chat(闲聊)* └─────┬──────┘* ┌───────────┼─────────────┐* ▼ ▼ ▼ (条件边按分类结果路由)* ┌─────────┐ ┌─────────┐ ┌─────────┐* │概念讲解 │ │代码助手 │ │闲聊陪伴 │ 三个不同的专家节点,各自使用不同的系统提示词* └────┬────┘ └────┬────┘ └────┬────┘* └───────────┼─────────────┘* ▼* END* </pre>** <p>核心概念:* <ul>* <li>StateGraph:状态图,声明节点(Node)与边(Edge)</li>* <li>NodeAction:节点动作,读取 OverAllState、返回要写入的状态更新</li>* <li>条件边(Conditional Edge) + EdgeAction:根据状态动态决定下一个节点</li>* <li>OverAllState:全局状态,在节点间传递共享数据(input/category/answer)</li>* </ul>*/@Configurationpublic class StudyAgentConfig {@Beanpublic CompiledGraph studyAgentGraph(ChatModel chatModel) throws Exception {ChatClient chatClient = ChatClient.builder(chatModel).build();// 全局状态工厂:注册状态 Key 及其更新策略(ReplaceStrategy 每次写入覆盖旧值)OverAllStateFactory stateFactory = () -> {OverAllState state = new OverAllState();state.registerKeyAndStrategy("input", new ReplaceStrategy());state.registerKeyAndStrategy("category", new ReplaceStrategy());state.registerKeyAndStrategy("answer", new ReplaceStrategy());return state;};// ---------- 节点 1:意图分类(调用 LLM 判断问题类别) ----------var classifyNode = AsyncNodeAction.node_async(state -> {String input = readString(state, "input");String category = chatClient.prompt().system("""你是一个问题分类器。判断用户输入属于以下哪一类,只输出类别名,不要任何其他内容:- concept:概念、原理、理论知识类问题- code:代码编写、调试、报错类问题- chat:闲聊、打招呼、与学习无关的内容""").user(input).call().content();String result = category == null ? "chat" : category.trim().toLowerCase();if (!result.contains("concept") && !result.contains("code")) {result = "chat";} else if (result.contains("concept")) {result = "concept";} else {result = "code";}return Map.of("category", result);});// ---------- 节点 2a:概念讲解专家 ----------var conceptNode = AsyncNodeAction.node_async(state -> {String input = readString(state, "input");String answer = chatClient.prompt().system("""你是一位耐心的技术导师,擅长把复杂概念讲得通俗易懂。回答结构:一句话定义 -> 生活化类比 -> 展开要点(3 条以内)。""").user(input).call().content();return Map.of("answer", "[概念讲解专家] " + (answer == null ? "" : answer));});// ---------- 节点 2b:代码助手专家 ----------var codeNode = AsyncNodeAction.node_async(state -> {String input = readString(state, "input");String answer = chatClient.prompt().system("""你是一位资深 Java 工程师。回答代码类问题时:先给出可直接运行的示例代码,再逐段解释关键点,最后提醒常见坑。""").user(input).call().content();return Map.of("answer", "[代码助手专家] " + (answer == null ? "" : answer));});// ---------- 节点 2c:闲聊陪伴 ----------var chatNode = AsyncNodeAction.node_async(state -> {String input = readString(state, "input");String answer = chatClient.prompt().system("你是一个友好的学习伙伴,轻松简短地回应闲聊,并顺势引导对方聊点技术学习话题。").user(input).call().content();return Map.of("answer", "[闲聊伙伴] " + (answer == null ? "" : answer));});// ---------- 条件边:根据分类结果路由到不同专家节点 ----------var dispatcher = AsyncEdgeAction.edge_async(state -> readString(state, "category"));// ---------- 组装状态图 ----------StateGraph graph = new StateGraph("study-agent", stateFactory).addNode("classify", classifyNode).addNode("concept_expert", conceptNode).addNode("code_expert", codeNode).addNode("chat_expert", chatNode).addEdge(START, "classify").addConditionalEdges("classify", dispatcher, Map.of("concept", "concept_expert","code", "code_expert","chat", "chat_expert")).addEdge("concept_expert", END).addEdge("code_expert", END).addEdge("chat_expert", END);return graph.compile();}private static String readString(OverAllState state, String key) {return state.value(key).map(Object::toString).orElse("");}}
AiConfig.java
package com.study.saa.config;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.memory.ChatMemory;import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;import org.springframework.ai.chat.memory.MessageWindowChatMemory;import org.springframework.ai.chat.model.ChatModel;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;/*** AI 核心组件配置:** <ul>* <li>ChatClient —— Spring AI 提供的高层对话 API(推荐日常使用)</li>* <li>ChatMemory —— 对话记忆:内存实现(每个会话保留最近 20 条消息)。* 如需持久化,可引入 spring-ai-starter-model-chat-memory-repository-jdbc* 并把 ChatMemoryRepository 换成 JdbcChatMemoryRepository(MySQL 模式)</li>* <li>VectorStore —— 向量存储:已迁移到 {@link VectorStoreConfig},* 支持 simple/tair/redis/milvus/chroma 配置化切换</li>* </ul>*/@Configurationpublic class AiConfig {/*** 通用 ChatClient:不带记忆,供基础示例使用。** <p>注意:这里基于 ModelProviderConfig 中 @Primary 的 ChatModel 构建,* 而不是 starter 自动装配的 ChatClient.Builder —— 这样切换模型来源* (DashScope / DeepSeek / 混元 / 豆包...)后所有示例自动生效。*/@Beanpublic ChatClient chatClient(ChatModel chatModel) {return ChatClient.builder(chatModel).build();}/*** 对话记忆:MessageWindowChatMemory 基于 InMemoryChatMemoryRepository,* 按会话 ID 隔离,每个会话保留最近 20 条消息(重启后清空,学习演示够用)。*/@Beanpublic ChatMemory chatMemory() {return MessageWindowChatMemory.builder().chatMemoryRepository(new InMemoryChatMemoryRepository()).maxMessages(20).build();}}
DataSourceConfig.java
package com.study.saa.config;import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;import org.springframework.boot.context.properties.EnableConfigurationProperties;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.jdbc.datasource.DriverManagerDataSource;import javax.sql.DataSource;/*** 数据库配置:通过 study.db-type 开关切换数据源。** <p>study.db-type=h2 -> 使用 H2 内存数据库(MySQL 兼容模式),零配置,适合本地学习演示。* <p>study.db-type=mysql -> 使用 application.yml 中 study.mysql.* 配置的真实 MySQL 连接。** <p>说明:学习项目为了结构清晰直接使用 DriverManagerDataSource(无连接池);* 生产项目请换成 HikariCP 等连接池。*/@Configuration@EnableConfigurationProperties(StudyProperties.class)public class DataSourceConfig {/*** H2 内存库(默认)。DB_CLOSE_DELAY=-1 保证应用运行期间库不销毁;MODE=MySQL 兼容 MySQL 语法。*/@Bean@ConditionalOnProperty(name = "study.db-type", havingValue = "h2", matchIfMissing = true)public DataSource h2DataSource() {DriverManagerDataSource ds = new DriverManagerDataSource();ds.setDriverClassName("org.h2.Driver");ds.setUrl("jdbc:h2:mem:studydb;DB_CLOSE_DELAY=-1;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE");ds.setUsername("sa");ds.setPassword("");return ds;}/*** 真实 MySQL(study.db-type=mysql 时生效),连接博客数据库中的 content 表。*/@Bean@ConditionalOnProperty(name = "study.db-type", havingValue = "mysql")public DataSource mysqlDataSource(StudyProperties properties) {DriverManagerDataSource ds = new DriverManagerDataSource();ds.setDriverClassName("com.mysql.cj.jdbc.Driver");ds.setUrl(properties.getMysql().getUrl());ds.setUsername(properties.getMysql().getUsername());ds.setPassword(properties.getMysql().getPassword());return ds;}}
ModelProviderConfig.java
package com.study.saa.config;import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.ai.chat.model.ChatModel;import org.springframework.ai.document.MetadataMode;import org.springframework.ai.embedding.EmbeddingModel;import org.springframework.ai.openai.OpenAiChatModel;import org.springframework.ai.openai.OpenAiEmbeddingModel;import org.springframework.ai.openai.OpenAiChatOptions;import org.springframework.ai.openai.OpenAiEmbeddingOptions;import org.springframework.ai.openai.api.OpenAiApi;import org.springframework.beans.factory.ObjectProvider;import org.springframework.beans.factory.config.BeanDefinition;import org.springframework.beans.factory.config.BeanFactoryPostProcessor;import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Conditional;import org.springframework.context.annotation.Configuration;import org.springframework.context.annotation.Primary;import org.springframework.core.type.AnnotatedTypeMetadata;/*** 模型来源(Provider)切换配置 —— 学习重点:多模型厂商接入。** <p>Spring AI 中不同厂商的模型都以 ChatModel / EmbeddingModel 接口暴露,* 业务代码(ChatClient、RAG、Agent...)只面向接口,切换厂商零改动。** <p>本项目支持两条通道(由 study.model.provider 决定):* <ul>* <li><b>dashscope</b>:阿里云百炼(通义千问 qwen 系列),* 直接使用 spring-ai-alibaba-starter-dashscope 自动装配的 Bean;</li>* <li><b>openai</b>:任意 OpenAI 兼容接口 —— DeepSeek、腾讯混元、字节豆包(火山方舟)、* Kimi、智谱 GLM 等厂商都提供该协议,只需改 base-url / api-key / model 三个配置。</li>* </ul>** <p>常见厂商 OpenAI 兼容接口速查(填到 study.model.openai.base-url):* <pre>* DeepSeek https://api.deepseek.com 模型: deepseek-chat / deepseek-reasoner(无 embedding 接口)* 腾讯混元 https://api.hunyuan.cloud.tencent.com/v1 模型: hunyuan-turbos-latest 等* 字节豆包 https://ark.cn-beijing.volces.com/api/v3 模型: doubao-seed-1-6-250615 / doubao-embedding-large 等* Kimi https://api.moonshot.cn/v1 模型: moonshot-v1-8k 等* 智谱 GLM https://open.bigmodel.cn/api/paas/v4 模型: glm-4-flash 等* </pre>** <p>实现要点(值得学习的 Spring 技巧):* DashScope starter 自动装配出来的 Bean 自带 @Primary;当切换到 openai 通道时,* 通过 {@link #dashScopePrimaryDemoter()}(BeanFactoryPostProcessor)把 starter Bean* 的 primary 标记降级,再装配自己的 @Primary Bean,从而避免「两个 primary」注入歧义。*/@Configurationpublic class ModelProviderConfig {private static final Logger log = LoggerFactory.getLogger(ModelProviderConfig.class);/*** 切换通道时降级 DashScope starter 的 primary Bean(只改标记,不删除 Bean ——* 嵌入模型回退等场景仍可能用到它)。*/@Beanpublic static BeanFactoryPostProcessor dashScopePrimaryDemoter() {return beanFactory -> {// Environment 是容器启动时手动注册的单例,此处可安全获取org.springframework.core.env.Environment env =beanFactory.getBean(org.springframework.core.env.Environment.class);String provider = env.getProperty("study.model.provider", "dashscope");String embeddingProvider = env.getProperty("study.model.embedding-provider", "auto");boolean chatUseOpenai = "openai".equalsIgnoreCase(provider);boolean embeddingUseOpenai = "openai".equalsIgnoreCase(embeddingProvider)|| ("auto".equalsIgnoreCase(embeddingProvider) && chatUseOpenai);if (chatUseOpenai) {demotePrimary(beanFactory, DashScopeChatModel.class, "对话");}if (embeddingUseOpenai) {demotePrimary(beanFactory, DashScopeEmbeddingModel.class, "嵌入");}};}private static void demotePrimary(ConfigurableListableBeanFactory beanFactory,Class<?> beanClass, String label) {for (String name : beanFactory.getBeanDefinitionNames()) {// 自动配置的 Bean 通常由 @Bean 工厂方法创建(getBeanClassName 为 null),// 因此用 getType(name) 解析实际类型再比较Class<?> type = beanFactory.getType(name);if (type != null && beanClass.isAssignableFrom(type)) {BeanDefinition bd = beanFactory.getBeanDefinition(name);if (bd.isPrimary()) {bd.setPrimary(false);log.info("{}通道切换为 OpenAI 兼容接口,已降级 DashScope Bean [{}] 的 primary 标记", label, name);}}}}/*** 对话模型(openai 通道):study.model.provider=openai 时生效。* 所有注入 ChatModel 的地方(ChatClient、RAG、Agent...)自动拿到它。*/@Bean@Primary@ConditionalOnProperty(name = "study.model.provider", havingValue = "openai")public ChatModel chatModel(StudyProperties properties,ObjectProvider<DashScopeChatModel> dashscopeChatModel) {StudyProperties.Model.OpenAi o = properties.getModel().getOpenai();log.info("对话模型走 OpenAI 兼容通道:{},模型:{}", o.getBaseUrl(), o.getChatModel());OpenAiApi api = OpenAiApi.builder().baseUrl(o.getBaseUrl()).apiKey(o.getApiKey()).build();return OpenAiChatModel.builder().openAiApi(api).defaultOptions(OpenAiChatOptions.builder().model(o.getChatModel()).temperature(o.getTemperature()).build()).build();}/*** 嵌入模型(openai 通道):study.model.embedding-provider=openai,* 或 =auto 且 provider=openai 时生效。** <p>典型组合:* <ul>* <li>对话 DeepSeek + 嵌入 DashScope(DeepSeek 无 embedding 接口,* 此时保持 embedding-provider=auto 且不填 embedding-model,自动回退)</li>* <li>对话与嵌入都走混元/豆包/Kimi(填上对应的 embedding-model 即可)</li>* </ul>*/@Bean@Primary@Conditional(ModelProviderConfig.EmbeddingUseOpenAiCondition.class)public EmbeddingModel embeddingModel(StudyProperties properties,ObjectProvider<DashScopeEmbeddingModel> dashscopeEmbeddingModel) {StudyProperties.Model.OpenAi o = properties.getModel().getOpenai();if (o.getEmbeddingModel() == null || o.getEmbeddingModel().isBlank()) {// 未配置 OpenAI 兼容嵌入模型(例如只配了 DeepSeek)→ 回退 DashScope 嵌入模型log.info("未配置 OpenAI 兼容嵌入模型,嵌入模型回退 DashScope(text-embedding-v3)");return dashscopeEmbeddingModel.getIfAvailable(() -> {throw new IllegalStateException("嵌入模型不可用:请在 study.model.openai.embedding-model 配置嵌入模型,或提供 DashScope API Key 作为回退");});}log.info("嵌入模型走 OpenAI 兼容通道:{},模型:{}", o.getBaseUrl(), o.getEmbeddingModel());OpenAiApi api = OpenAiApi.builder().baseUrl(o.getBaseUrl()).apiKey(o.getApiKey()).build();return new OpenAiEmbeddingModel(api, MetadataMode.EMBED,OpenAiEmbeddingOptions.builder().model(o.getEmbeddingModel()).build());}/** 条件:嵌入模型走 OpenAI 兼容通道(embedding-provider=openai,或 auto 且 provider=openai) */static class EmbeddingUseOpenAiCondition implements org.springframework.context.annotation.Condition {@Overridepublic boolean matches(org.springframework.context.annotation.ConditionContext context,AnnotatedTypeMetadata metadata) {String provider = context.getEnvironment().getProperty("study.model.provider", "dashscope");String embeddingProvider = context.getEnvironment().getProperty("study.model.embedding-provider", "auto");return "openai".equalsIgnoreCase(embeddingProvider)|| ("auto".equalsIgnoreCase(embeddingProvider) && "openai".equalsIgnoreCase(provider));}}}
StudyProperties.java
package com.study.saa.config;import org.springframework.boot.context.properties.ConfigurationProperties;/*** 学习项目自定义配置(对应 application.yml 中 study.* 前缀)。*/@ConfigurationProperties(prefix = "study")public class StudyProperties {/** 数据库类型开关:h2 / mysql */private String dbType = "h2";private Mysql mysql = new Mysql();private Rag rag = new Rag();private Model model = new Model();private VectorStoreGroup vectorStore = new VectorStoreGroup();/*** 向量数据库配置:支持内存 / 阿里云 Tair / Redis / Milvus / Chroma 五种,* 由 study.vectorstore.type 一键切换,业务代码零改动(统一面向 VectorStore 接口)。*/public static class VectorStoreGroup {/*** 向量数据库类型:* - simple:内存版(默认,零部署直接跑)* - tair :阿里云 Tair(Redis 协议兼容 + 向量检索增强)* - redis :Redis Stack(本地 docker run redis-stack 即可)* - milvus:Milvus(开源向量数据库,本地 docker 部署)* - chroma:Chroma(轻量本地向量数据库)*/private String type = "simple";/*** 嵌入向量维度:必须与所用嵌入模型输出维度一致!* 阿里 text-embedding-v3 = 1024;切换嵌入模型时记得同步修改。*/private int embeddingDimensions = 1024;private Tair tair = new Tair();private Redis redis = new Redis();private Milvus milvus = new Milvus();private Chroma chroma = new Chroma();/** 阿里云 Tair 连接配置(type=tair 时生效) */public static class Tair {private String host = "r-xxx.redis.rds.aliyuncs.com";private int port = 6379;/** ACL 用户名,经典部署(仅密码)可留空 */private String username = "";private String password = "";/** 向量索引名 */private String indexName = "study-rag-index";public String getHost() { return host; }public void setHost(String host) { this.host = host; }public int getPort() { return port; }public void setPort(int port) { this.port = port; }public String getUsername() { return username; }public void setUsername(String username) { this.username = username; }public String getPassword() { return password; }public void setPassword(String password) { this.password = password; }public String getIndexName() { return indexName; }public void setIndexName(String indexName) { this.indexName = indexName; }}/** Redis Stack 连接配置(type=redis 时生效) */public static class Redis {private String host = "localhost";private int port = 6379;private String username = "";private String password = "";private String indexName = "study-rag-index";public String getHost() { return host; }public void setHost(String host) { this.host = host; }public int getPort() { return port; }public void setPort(int port) { this.port = port; }public String getUsername() { return username; }public void setUsername(String username) { this.username = username; }public String getPassword() { return password; }public void setPassword(String password) { this.password = password; }public String getIndexName() { return indexName; }public void setIndexName(String indexName) { this.indexName = indexName; }}/** Milvus 连接配置(type=milvus 时生效) */public static class Milvus {private String host = "localhost";private int port = 19530;private String databaseName = "default";private String collectionName = "study_rag";public String getHost() { return host; }public void setHost(String host) { this.host = host; }public int getPort() { return port; }public void setPort(int port) { this.port = port; }public String getDatabaseName() { return databaseName; }public void setDatabaseName(String databaseName) { this.databaseName = databaseName; }public String getCollectionName() { return collectionName; }public void setCollectionName(String collectionName) { this.collectionName = collectionName; }}/** Chroma 连接配置(type=chroma 时生效) */public static class Chroma {private String baseUrl = "http://localhost:8000";private String collectionName = "study_rag";public String getBaseUrl() { return baseUrl; }public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }public String getCollectionName() { return collectionName; }public void setCollectionName(String collectionName) { this.collectionName = collectionName; }}public String getType() { return type; }public void setType(String type) { this.type = type; }public int getEmbeddingDimensions() { return embeddingDimensions; }public void setEmbeddingDimensions(int embeddingDimensions) { this.embeddingDimensions = embeddingDimensions; }public Tair getTair() { return tair; }public void setTair(Tair tair) { this.tair = tair; }public Redis getRedis() { return redis; }public void setRedis(Redis redis) { this.redis = redis; }public Milvus getMilvus() { return milvus; }public void setMilvus(Milvus milvus) { this.milvus = milvus; }public Chroma getChroma() { return chroma; }public void setChroma(Chroma chroma) { this.chroma = chroma; }}public VectorStoreGroup getVectorStore() { return vectorStore; }public void setVectorStore(VectorStoreGroup vectorStore) { this.vectorStore = vectorStore; }/*** 模型来源配置:支持阿里云百炼(DashScope) 与 任意 OpenAI 兼容接口 两种通道。*/public static class Model {/*** 对话模型来源:* - dashscope:阿里云百炼(qwen 系列,走 spring-ai-alibaba-starter-dashscope)* - openai:任意 OpenAI 兼容接口(DeepSeek / 腾讯混元 / 字节豆包 / Kimi / GLM ...)*/private String provider = "dashscope";/*** 嵌入模型来源:auto(跟随 provider) / dashscope / openai。* 注意:DeepSeek 不提供 embedding 接口 —— 对话选 DeepSeek 时,* 建议这里配 dashscope(混元/豆包/Kimi/GLM 则可配 openai)。*/private String embeddingProvider = "auto";private OpenAi openai = new OpenAi();public static class OpenAi {/** OpenAI 兼容接口地址,例如 https://api.deepseek.com */private String baseUrl = "https://api.deepseek.com";/** 对应厂商的 API Key(也可用环境变量 OPENAI_COMPATIBLE_API_KEY) */private String apiKey = "";/** 对话模型名,例如 deepseek-chat / deepseek-reasoner */private String chatModel = "deepseek-chat";/** 嵌入模型名,留空则回退 DashScope 嵌入模型 */private String embeddingModel = "";/** 对话温度 */private Double temperature = 0.7;public String getBaseUrl() { return baseUrl; }public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }public String getApiKey() { return apiKey; }public void setApiKey(String apiKey) { this.apiKey = apiKey; }public String getChatModel() { return chatModel; }public void setChatModel(String chatModel) { this.chatModel = chatModel; }public String getEmbeddingModel() { return embeddingModel; }public void setEmbeddingModel(String embeddingModel) { this.embeddingModel = embeddingModel; }public Double getTemperature() { return temperature; }public void setTemperature(Double temperature) { this.temperature = temperature; }}public String getProvider() { return provider; }public void setProvider(String provider) { this.provider = provider; }public String getEmbeddingProvider() { return embeddingProvider; }public void setEmbeddingProvider(String embeddingProvider) { this.embeddingProvider = embeddingProvider; }public OpenAi getOpenai() { return openai; }public void setOpenai(OpenAi openai) { this.openai = openai; }/** 解析后的对话通道:true 表示走 OpenAI 兼容接口 */public boolean isOpenAiCompatible() { return "openai".equalsIgnoreCase(provider); }/** 解析后的嵌入通道:true 表示走 OpenAI 兼容接口 */public boolean embeddingUseOpenAi() {if ("auto".equalsIgnoreCase(embeddingProvider)) return isOpenAiCompatible();return "openai".equalsIgnoreCase(embeddingProvider);}}public Model getModel() { return model; }public void setModel(Model model) { this.model = model; }public static class Mysql {private String url;private String username;private String password;/*** 自定义查询 SQL(study.db-type=mysql 时生效):* 从你的真实博客库读取用于 RAG 向量化的内容。* 要求返回三列且列名/别名为 id、title、content。*/private String contentSql = "SELECT id, title, content FROM content ORDER BY id";public String getUrl() {return url;}public void setUrl(String url) {this.url = url;}public String getUsername() {return username;}public void setUsername(String username) {this.username = username;}public String getPassword() {return password;}public void setPassword(String password) {this.password = password;}public String getContentSql() {return contentSql;}public void setContentSql(String contentSql) {this.contentSql = contentSql;}}public static class Rag {/** 启动时是否自动执行 RAG 初始化 */private boolean autoInit = true;/** 检索返回 Top-K */private int topK = 4;/** content 表为空时是否插入示例博文 */private boolean sampleDataOnEmpty = true;public boolean isAutoInit() {return autoInit;}public void setAutoInit(boolean autoInit) {this.autoInit = autoInit;}public int getTopK() {return topK;}public void setTopK(int topK) {this.topK = topK;}public boolean isSampleDataOnEmpty() {return sampleDataOnEmpty;}public void setSampleDataOnEmpty(boolean sampleDataOnEmpty) {this.sampleDataOnEmpty = sampleDataOnEmpty;}}public String getDbType() {return dbType;}public void setDbType(String dbType) {this.dbType = dbType;}public Mysql getMysql() {return mysql;}public void setMysql(Mysql mysql) {this.mysql = mysql;}public Rag getRag() {return rag;}public void setRag(Rag rag) {this.rag = rag;}}
VectorStoreConfig.java
package com.study.saa.config;import com.alibaba.cloud.ai.vectorstore.tair.TairVectorApi;import com.alibaba.cloud.ai.vectorstore.tair.TairVectorStore;import com.alibaba.cloud.ai.vectorstore.tair.TairVectorStoreOptions;import io.milvus.client.MilvusServiceClient;import io.milvus.param.ConnectParam;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.ai.chroma.vectorstore.ChromaApi;import org.springframework.ai.chroma.vectorstore.ChromaVectorStore;import org.springframework.ai.embedding.EmbeddingModel;import org.springframework.ai.vectorstore.SimpleVectorStore;import org.springframework.ai.vectorstore.VectorStore;import org.springframework.ai.vectorstore.milvus.MilvusVectorStore;import org.springframework.ai.vectorstore.redis.RedisVectorStore;import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import redis.clients.jedis.JedisPool;import redis.clients.jedis.JedisPooled;/*** 向量数据库切换配置 —— 学习重点:VectorStore 抽象与多种实现。** <p>Spring AI 把所有向量数据库抽象为同一个 {@link VectorStore} 接口* (add / delete / similaritySearch),RAG 业务代码完全不用关心底层实现,* 一个配置项 study.vectorstore.type 即可在以下实现间自由切换:** <ul>* <li><b>simple</b>:Spring AI 自带内存向量库(默认,零部署、开箱即用)</li>* <li><b>tair</b>:阿里云 Tair(Redis 协议兼容 + TairVector 向量检索,* Spring AI Alibaba 官方适配,云上个人博客推荐)</li>* <li><b>redis</b>:Redis Stack(本地最轻量的持久化向量库,* docker run redis/redis-stack-server 即可)</li>* <li><b>milvus</b>:Milvus(最流行的开源向量数据库,支持大规模数据)</li>* <li><b>chroma</b>:Chroma(轻量本地向量数据库,个人项目常用)</li>* </ul>** <p>注意事项:* <ol>* <li>除 simple 外,其他类型都需要先把对应服务跑起来再切换(连接信息见 application.yml);</li>* <li>嵌入向量维度必须与嵌入模型一致(text-embedding-v3 = 1024),* 由 study.vectorstore.embedding-dimensions 配置;</li>* <li>切换向量库后需要重新执行「RAG 初始化」重建索引。</li>* </ol>*/@Configurationpublic class VectorStoreConfig {private static final Logger log = LoggerFactory.getLogger(VectorStoreConfig.class);/** ============ 1. simple:内存向量库(默认) ============ */@Bean@ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "simple", matchIfMissing = true)public VectorStore simpleVectorStore(EmbeddingModel embeddingModel) {log.info("向量数据库使用:SimpleVectorStore(内存实现,重启后需重新初始化 RAG)");return SimpleVectorStore.builder(embeddingModel).build();}/** ============ 2. tair:阿里云 Tair ============ */@Bean@ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "tair")public VectorStore tairVectorStore(StudyProperties properties, EmbeddingModel embeddingModel) {StudyProperties.VectorStoreGroup.Tair t = properties.getVectorStore().getTair();log.info("向量数据库使用:阿里云 Tair({}:{}),索引:{}", t.getHost(), t.getPort(), t.getIndexName());JedisPool pool = new JedisPool(t.getHost(), t.getPort(),blankToNull(t.getUsername()), blankToNull(t.getPassword()));TairVectorApi api = new TairVectorApi(pool);TairVectorStoreOptions options = new TairVectorStoreOptions();options.setIndexName(t.getIndexName());options.setDimensions(properties.getVectorStore().getEmbeddingDimensions());return TairVectorStore.builder(api, embeddingModel).options(options).build();}/** ============ 3. redis:Redis Stack(需 Redis 8.x / Redis Stack 向量检索支持) ============ */@Bean@ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "redis")public VectorStore redisVectorStore(StudyProperties properties, EmbeddingModel embeddingModel) {StudyProperties.VectorStoreGroup.Redis r = properties.getVectorStore().getRedis();log.info("向量数据库使用:Redis Stack({}:{}),索引:{}", r.getHost(), r.getPort(), r.getIndexName());JedisPooled jedis = new JedisPooled(r.getHost(), r.getPort(),blankToNull(r.getUsername()), blankToNull(r.getPassword()));return RedisVectorStore.builder(jedis, embeddingModel).indexName(r.getIndexName())// 启动时自动创建向量索引(FT.CREATE);维度取自嵌入模型首次调用结果.initializeSchema(true).build();}/** ============ 4. milvus:Milvus 向量数据库 ============ */@Bean@ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "milvus")public VectorStore milvusVectorStore(StudyProperties properties, EmbeddingModel embeddingModel) {StudyProperties.VectorStoreGroup.Milvus m = properties.getVectorStore().getMilvus();log.info("向量数据库使用:Milvus({}:{}),集合:{}", m.getHost(), m.getPort(), m.getCollectionName());MilvusServiceClient client = new MilvusServiceClient(ConnectParam.newBuilder().withHost(m.getHost()).withPort(m.getPort()).withDatabaseName(m.getDatabaseName()).build());return MilvusVectorStore.builder(client, embeddingModel).collectionName(m.getCollectionName()).embeddingDimension(properties.getVectorStore().getEmbeddingDimensions())// 自动创建 Collection(若不存在),无需手工建表.initializeSchema(true).build();}/** ============ 5. chroma:Chroma 向量数据库 ============ */@Bean@ConditionalOnProperty(name = "study.vectorstore.type", havingValue = "chroma")public VectorStore chromaVectorStore(StudyProperties properties, EmbeddingModel embeddingModel) {StudyProperties.VectorStoreGroup.Chroma c = properties.getVectorStore().getChroma();log.info("向量数据库使用:Chroma({}),集合:{}", c.getBaseUrl(), c.getCollectionName());ChromaApi api = new ChromaApi.Builder().baseUrl(c.getBaseUrl()).build();return ChromaVectorStore.builder(api, embeddingModel).collectionName(c.getCollectionName())// 自动创建 Collection;注意:启动时会连接 Chroma,服务未启动会报错.initializeSchema(true).build();}private static String blankToNull(String s) {return (s == null || s.isBlank()) ? null : s;}}
ChatClientController.java
package com.study.saa.controller;import org.springframework.ai.chat.client.ChatClient;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.Map;/*** 示例一:ChatClient 的使用。** <p>ChatClient 是 Spring AI 提供的「高层」流式对话 API,是日常开发推荐的方式。* 它屏蔽了底层 ChatModel 的细节,支持链式调用:prompt() -> user() -> call() -> content()。*/@RestController@RequestMapping("/api/chat")public class ChatClientController {private final ChatClient chatClient;public ChatClientController(ChatClient chatClient) {this.chatClient = chatClient;}/*** 最基础的 ChatClient 对话。*/@PostMapping("/client")public Map<String, Object> chatWithClient(@RequestBody Map<String, String> body) {String input = body.getOrDefault("input", "");try {String answer = chatClient.prompt().user(input).call().content();return Map.of("ok", true, "data", answer == null ? "" : answer);} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage()+ "\n(请检查 API Key 配置:DashScope 通道看 spring.ai.dashscope.api-key;"+ "OpenAI 兼容通道(DeepSeek/混元/豆包等)看 study.model.openai.api-key)");}}/*** system 系统提示词 + user 用户输入的组合用法:让模型扮演指定角色。*/@PostMapping("/role")public Map<String, Object> chatWithRole(@RequestBody Map<String, String> body) {String input = body.getOrDefault("input", "");try {String answer = chatClient.prompt().system("你是一位资深的 Java 技术面试官,回答简洁、专业,必要时给出代码示例。").user(input).call().content();return Map.of("ok", true, "data", answer == null ? "" : answer);} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}}
ChatModelController.java
package com.study.saa.controller;import org.springframework.ai.chat.messages.UserMessage;import org.springframework.ai.chat.model.ChatModel;import org.springframework.ai.chat.model.ChatResponse;import org.springframework.ai.chat.prompt.ChatOptions;import org.springframework.ai.chat.prompt.Prompt;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.Map;/*** 示例二:ChatModel 的使用(底层 API)。** <p>ChatModel 是 Spring AI 的「底层」模型接口,ChatClient 内部也是调用它。* 直接使用 ChatModel 可以看到完整的 ChatResponse 结构(内容、token 用量等),* 并且可以通过通用的 ChatOptions 动态指定模型名与温度* (不绑定任何厂商,DashScope / DeepSeek / 混元 / 豆包 通用)。*/@RestController@RequestMapping("/api/chat")public class ChatModelController {private final ChatModel chatModel;public ChatModelController(ChatModel chatModel) {this.chatModel = chatModel;}/*** 直接调用 ChatModel,演示通过 ChatOptions 动态指定模型与温度。* body: { "input": "问题", "model": "可选,动态切换模型名" }*/@PostMapping("/model")public Map<String, Object> chatWithModel(@RequestBody Map<String, String> body) {String input = body.getOrDefault("input", "");String model = body.get("model");try {// 通用 ChatOptions:model 不传则使用配置文件里的默认模型ChatOptions.Builder optionsBuilder = ChatOptions.builder().temperature(0.3D);if (model != null && !model.isBlank()) {optionsBuilder.model(model);} else {model = "(默认模型)";}ChatResponse response = chatModel.call(new Prompt(new UserMessage(input), optionsBuilder.build()));String content = response.getResult().getOutput().getText();Integer totalTokens = response.getMetadata() != null&& response.getMetadata().getUsage() != null? response.getMetadata().getUsage().getTotalTokens() : null;String report = "【模型】" + model + "\n\n" + content+ "\n\n----\n本次对话消耗 token:" + totalTokens;return Map.of("ok", true, "data", report);} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}}
EmbeddingController.java
package com.study.saa.controller;import org.springframework.ai.embedding.EmbeddingModel;import org.springframework.ai.embedding.EmbeddingResponse;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.List;import java.util.Map;/*** 示例三:嵌入模型(EmbeddingModel)的使用。** <p>嵌入模型把文本转换成高维向量(如 text-embedding-v3 输出 1024 维),* 是语义检索 / RAG 的基础:语义相近的文本,向量距离更近。*/@RestController@RequestMapping("/api/embedding")public class EmbeddingController {private final EmbeddingModel embeddingModel;public EmbeddingController(EmbeddingModel embeddingModel) {this.embeddingModel = embeddingModel;}/*** 把输入文本向量化,展示向量维度与前几个分量。*/@PostMapping("/embed")public Map<String, Object> embed(@RequestBody Map<String, String> body) {String input = body.getOrDefault("input", "");try {long start = System.currentTimeMillis();EmbeddingResponse response = embeddingModel.embedForResponse(List.of(input));float[] vector = response.getResult().getOutput();long cost = System.currentTimeMillis() - start;// 拼接前 5 个分量用于展示StringBuilder sb = new StringBuilder();for (int i = 0; i < Math.min(5, vector.length); i++) {sb.append(String.format("%.4f", vector[i])).append(", ");}String report = "文本:" + input + "\n\n"+ "向量维度:" + vector.length + "\n"+ "前 5 个分量:[" + sb + "...]\n"+ "模型:" + response.getMetadata().get("model") + "\n"+ "耗时:" + cost + " ms";return Map.of("ok", true, "data", report);} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}/*** 计算两段文本的相似度(余弦相似度),直观感受语义距离。*/@PostMapping("/similarity")public Map<String, Object> similarity(@RequestBody Map<String, String> body) {String text1 = body.getOrDefault("text1", "");String text2 = body.getOrDefault("text2", "");try {float[] v1 = embeddingModel.embed(text1);float[] v2 = embeddingModel.embed(text2);double similarity = cosine(v1, v2);String report = "文本A:" + text1 + "\n文本B:" + text2+ "\n\n余弦相似度:" + String.format("%.4f", similarity)+ "\n(越接近 1 表示语义越相近)";return Map.of("ok", true, "data", report);} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}private double cosine(float[] a, float[] b) {double dot = 0, normA = 0, normB = 0;for (int i = 0; i < a.length; i++) {dot += (double) a[i] * b[i];normA += (double) a[i] * a[i];normB += (double) b[i] * b[i];}if (normA == 0 || normB == 0) {return 0;}return dot / (Math.sqrt(normA) * Math.sqrt(normB));}}
MemoryController.java
package com.study.saa.controller;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;import org.springframework.ai.chat.memory.ChatMemory;import org.springframework.ai.chat.model.ChatModel;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.List;import java.util.Map;/*** 示例五:对话记忆(Chat Memory)的使用。** <p>通过 MessageChatMemoryAdvisor 为 ChatClient 挂载记忆:* 每次调用自动携带同一 conversationId 下的历史消息,实现多轮连续对话。** <p>试试在同一个会话 ID 下先说"我叫小明,在做 Java 后端开发",* 再问"我刚才说我叫什么?",模型能记住上文。*/@RestController@RequestMapping("/api/memory")public class MemoryController {private final ChatClient chatClient;private final ChatMemory chatMemory;public MemoryController(ChatModel chatModel, ChatMemory chatMemory) {// 基于「当前生效」的 ChatModel 构建,并为其挂载记忆 Advisor// (用 ChatClient.builder() 而非注入 ChatClient.Builder,保证切换模型来源后同样生效)this.chatClient = ChatClient.builder(chatModel).defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()).build();this.chatMemory = chatMemory;}/*** 带记忆的多轮对话。前端传 conversationId 区分不同会话。*/@PostMapping("/chat")public Map<String, Object> chatWithMemory(@RequestBody Map<String, String> body) {String input = body.getOrDefault("input", "");String conversationId = body.getOrDefault("conversationId", "default");try {String answer = chatClient.prompt().user(input).advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, conversationId)).call().content();return Map.of("ok", true, "data", answer == null ? "" : answer);} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}/*** 查看某个会话当前记住的全部历史消息。*/@GetMapping("/history")public Map<String, Object> history(@org.springframework.web.bind.annotation.RequestParam(value = "conversationId", defaultValue = "default") String conversationId) {List<String> messages = chatMemory.get(conversationId).stream().map(m -> m.getMessageType() + ": " + m.getText()).toList();return Map.of("ok", true, "data", String.join("\n", messages));}}
PromptController.java
package com.study.saa.controller;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.prompt.PromptTemplate;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.Map;/*** 示例六:提示词模板(Prompt Template)的使用。** <p>PromptTemplate 把「固定的提示词骨架」和「动态的变量」分离:* 模板中用 {变量名} 占位,渲染时填充,避免手工字符串拼接。*/@RestController@RequestMapping("/api/prompt")public class PromptController {private final ChatClient chatClient;public PromptController(ChatClient chatClient) {this.chatClient = chatClient;}/*** 使用 PromptTemplate 渲染提示词并交给大模型。* 变量:topic(主题)、style(风格)、count(要点数量)。*/@PostMapping("/generate")public Map<String, Object> generate(@RequestBody Map<String, String> body) {String topic = body.getOrDefault("topic", "Spring AI");String style = body.getOrDefault("style", "通俗易懂");try {PromptTemplate template = PromptTemplate.builder().template("""你是一位技术博主,请用{style}的风格,围绕主题「{topic}」写一段入门介绍,要求包含 {count} 个核心要点,每个要点用一句话概括,末尾给一句学习建议。""").variables(Map.of("topic", topic,"style", style,"count", "3")).build();String rendered = template.render();String answer = chatClient.prompt().user(rendered).call().content();String report = "===== 渲染后的提示词 =====\n" + rendered+ "\n\n===== 模型输出 =====\n" + answer;return Map.of("ok", true, "data", report);} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}}
RagController.java
package com.study.saa.controller;import com.study.saa.config.StudyProperties;import com.study.saa.service.RagService;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.HashMap;import java.util.Map;/*** 示例九:RAG(检索增强生成)—— 个人博客笔记问答。** <p>POST /api/rag/init —— 初始化:查 content 表 -> 段落分片 -> 嵌入向量化 -> 入向量库* <p>POST /api/rag/query —— 查询:问题向量化 -> 相似检索 -> 检索结果+问题交给大模型 -> 返回答案与来源* <p>GET /api/rag/status —— 查看初始化状态*/@RestController@RequestMapping("/api/rag")public class RagController {private final RagService ragService;private final StudyProperties properties;public RagController(RagService ragService, StudyProperties properties) {this.ragService = ragService;this.properties = properties;}@PostMapping("/init")public Map<String, Object> init() {try {Map<String, Object> result = ragService.initialize();Map<String, Object> data = new HashMap<>(result);data.put("message", "初始化成功");return Map.of("ok", true, "data", data);} catch (Exception e) {return Map.of("ok", false, "data","初始化失败:" + e.getMessage() + "(请检查数据库连接与 API Key 配置)");}}@PostMapping("/query")public Map<String, Object> query(@RequestBody Map<String, String> body) {String question = body.getOrDefault("query", "");try {Map<String, Object> result = ragService.query(question);return Map.of("ok", true, "data", result);} catch (Exception e) {return Map.of("ok", false, "data", "查询失败:" + e.getMessage());}}@GetMapping("/status")public Map<String, Object> status() {return Map.of("ok", true, "data", Map.of("initialized", ragService.isInitialized(),"chunkCount", ragService.getChunkCount(),"dbType", properties.getDbType(),"vectorStoreType", properties.getVectorStore().getType()));}}
StructuredOutputController.java
package com.study.saa.controller;import org.springframework.ai.chat.client.ChatClient;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.List;import java.util.Map;/*** 示例七:结构化输出(Structured Output / 格式化输出)的使用。** <p>通过 .entity(Xxx.class) 让模型输出自动反序列化为 Java 对象(record/POJO),* 无需手工解析 JSON —— 框架会自动在提示词中注入输出格式约束并完成转换。*/@RestController@RequestMapping("/api/format")public class StructuredOutputController {private final ChatClient chatClient;public StructuredOutputController(ChatClient chatClient) {this.chatClient = chatClient;}/*** 定义输出结构:演员 + 其代表作品列表。*/public record ActorFilms(String actor, List<String> movies) {}/*** 输入演员名字,返回结构化的 ActorFilms 对象。*/@PostMapping("/actor")public Map<String, Object> actorFilms(@RequestBody Map<String, String> body) {String input = body.getOrDefault("input", "周星驰");try {ActorFilms films = chatClient.prompt().user("请列出演员「" + input + "」的 3 部代表电影作品").call().entity(ActorFilms.class);String report = "结构化对象:ActorFilms{actor='" + films.actor()+ "', movies=" + films.movies() + "}\n\n"+ "说明:模型返回的 JSON 已被自动转换成 Java record,可直接字段访问。";return Map.of("ok", true, "data", report);} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}}
ToolController.java
package com.study.saa.controller;import org.springframework.ai.chat.client.ChatClient;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import com.study.saa.tools.StudyTools;import java.util.Map;/*** 示例四:工具调用(Tool Calling / Function Calling)的使用。** <p>把 @Tool 注解标记的 Bean 通过 .tools(...) 绑定到 ChatClient,* 大模型会在需要时自动决定调用哪个工具、传什么参数,再把工具结果融入最终回答。** <p>试试问:* <ul>* <li>"杭州今天天气怎么样?"(触发天气工具)</li>* <li>"帮我算一下 125 乘以 88 等于多少"(触发计算器工具)</li>* <li>"北京和上海的天气对比,两地温差是多少?"(多次工具调用组合)</li>* </ul>*/@RestController@RequestMapping("/api/tool")public class ToolController {private final ChatClient chatClient;private final StudyTools studyTools;public ToolController(ChatClient chatClient, StudyTools studyTools) {this.chatClient = chatClient;this.studyTools = studyTools;}@PostMapping("/chat")public Map<String, Object> chatWithTools(@RequestBody Map<String, String> body) {String input = body.getOrDefault("input", "");try {String answer = chatClient.prompt().user(input).tools(studyTools).call().content();return Map.of("ok", true, "data", answer == null ? "" : answer);} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}}
VectorStoreController.java
package com.study.saa.controller;import org.springframework.ai.document.Document;import org.springframework.ai.vectorstore.SearchRequest;import org.springframework.ai.vectorstore.VectorStore;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.List;import java.util.Map;import java.util.UUID;/*** 示例八:向量存储(VectorStore)的使用。** <p>流程:文档(Document) --嵌入模型--> 向量 --> VectorStore 保存;* 查询时把查询文本向量化,按相似度检索最相近的 Top-K 文档。** <p>先用「添加文档」写入几段知识,再用「相似检索」试试语义匹配。*/@RestController@RequestMapping("/api/vector")public class VectorStoreController {private final VectorStore vectorStore;public VectorStoreController(VectorStore vectorStore) {this.vectorStore = vectorStore;}/*** 添加一段文本到向量库(自动向量化)。*/@PostMapping("/add")public Map<String, Object> add(@RequestBody Map<String, String> body) {String text = body.getOrDefault("text", "");if (text.isBlank()) {return Map.of("ok", false, "data", "请输入要添加的文本内容");}try {Document doc = new Document(text, Map.of("source", "manual-input"));vectorStore.add(List.of(doc));return Map.of("ok", true, "data", "已添加并完成向量化,文档ID:" + doc.getId());} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}/*** 相似度检索 Top-K。*/@PostMapping("/search")public Map<String, Object> search(@RequestBody Map<String, String> body) {String query = body.getOrDefault("query", "");int topK;try {topK = Integer.parseInt(body.getOrDefault("topK", "3"));} catch (NumberFormatException e) {topK = 3;}try {List<Document> hits = vectorStore.similaritySearch(SearchRequest.builder().query(query).topK(topK).build());if (hits == null || hits.isEmpty()) {return Map.of("ok", true, "data", "没有检索到相关内容,请先添加一些文档");}StringBuilder sb = new StringBuilder("检索到 " + hits.size() + " 条最相似的内容:\n\n");int i = 1;for (Document d : hits) {sb.append("【").append(i++).append("】").append("score=").append(String.format("%.4f", d.getScore())).append(", source=").append(d.getMetadata().get("source")).append("\n").append(d.getText()).append("\n\n");}return Map.of("ok", true, "data", sb.toString());} catch (Exception e) {return Map.of("ok", false, "data", "调用失败:" + e.getMessage());}}}
ContentService.java
package com.study.saa.service;import com.study.saa.config.StudyProperties;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.boot.ApplicationArguments;import org.springframework.boot.ApplicationRunner;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.stereotype.Service;import java.util.List;/*** 博文笔记数据访问:对接博客数据库中的 content 表。** <p>两种数据库模式行为不同:* <ul>* <li><b>h2</b>(默认):启动时自动建表 + 空表时插入示例博文,开箱即用;* 查询使用固定 SQL:SELECT id, title, content FROM content ORDER BY id。</li>* <li><b>mysql</b>:连接你真实的博客库,<b>不执行任何建表/写入操作</b>(只读);* 查询使用自定义 SQL(study.mysql.content-sql,要求返回 id / title / content 三列),* 可以从 blog 等真实业务表中按自己的条件筛选要向量化的内容。</li>* </ul>*/@Servicepublic class ContentService implements ApplicationRunner {private static final Logger log = LoggerFactory.getLogger(ContentService.class);/** H2 模式固定查询 SQL */private static final String H2_QUERY_SQL = "SELECT id, title, content FROM content ORDER BY id";private final JdbcTemplate jdbcTemplate;private final StudyProperties properties;public ContentService(JdbcTemplate jdbcTemplate, StudyProperties properties) {this.jdbcTemplate = jdbcTemplate;this.properties = properties;}/*** 启动时初始化表结构 —— 仅 H2 模式执行(MySQL 连的是真实博客库,只读,不建表不插数据)。*/@Overridepublic void run(ApplicationArguments args) {if (!"h2".equalsIgnoreCase(properties.getDbType())) {log.info("数据库类型:{}(真实库,跳过建表与示例数据初始化)", properties.getDbType());return;}try {jdbcTemplate.execute("""CREATE TABLE IF NOT EXISTS content (id BIGINT AUTO_INCREMENT PRIMARY KEY,title VARCHAR(255) NOT NULL,content TEXT NOT NULL)""");log.info("content 表已就绪(数据库类型:{})", properties.getDbType());if (properties.getRag().isSampleDataOnEmpty() && isTableEmpty()) {insertSampleNotes();log.info("content 表为空,已插入 {} 条示例博文", SAMPLE_NOTES.length);}} catch (Exception e) {// 数据库异常不应阻断应用启动(页面仍可访问,接口会给出明确报错)log.error("content 表初始化失败:{}", e.getMessage());}}/*** 查询所有博文笔记。* <p>H2 模式查 content 表全量;MySQL 模式使用 study.mysql.content-sql 自定义 SQL。*/public List<Note> findAll() {String sql = "h2".equalsIgnoreCase(properties.getDbType())? H2_QUERY_SQL: properties.getMysql().getContentSql();log.info("查询博文笔记 SQL(db-type={}):{}", properties.getDbType(), sql);return jdbcTemplate.query(sql,(rs, rowNum) -> new Note(rs.getLong("id"),rs.getString("title"),rs.getString("content")));}public boolean isTableEmpty() {Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM content", Long.class);return count == null || count == 0;}/*** 博文笔记实体。*/public record Note(long id, String title, String content) {}private void insertSampleNotes() {for (String[] note : SAMPLE_NOTES) {jdbcTemplate.update("INSERT INTO content (title, content) VALUES (?, ?)", note[0], note[1]);}}/*** 示例博文(模拟个人博客中的笔记内容,段落之间用空行分隔)。*/private static final String[][] SAMPLE_NOTES = {{"Spring AI 入门笔记","""Spring AI 是 Spring 官方推出的 AI 应用开发框架,它把大模型能力抽象成统一的编程接口。核心抽象有三个:ChatModel 负责与对话模型通信,EmbeddingModel 负责把文本转成向量,VectorStore 负责向量的存取与相似检索。ChatClient 是推荐使用的门面 API,支持链式调用:prompt() 设置提示词、user() 传用户输入、call() 同步调用、stream() 流式返回。我的学习心得:先用 ChatClient 跑通最小闭环,再逐步加上工具调用、记忆、RAG 等高级能力。"""},{"RAG 检索增强生成实践笔记","""RAG 的全称是 Retrieval-Augmented Generation,检索增强生成,解决大模型不知道私有知识的问题。离线阶段:把文档按段落分片,调用嵌入模型向量化,存入向量数据库。在线阶段:用户提问先向量化,再去向量库做相似度检索取回 Top-K 相关片段,最后把片段拼进提示词交给大模型生成答案。实践经验:分片粒度很重要,段落级分片对博文笔记类内容效果最好;检索时 topK 取 3 到 5 比较合适。"""},{"Prompt 提示词工程技巧笔记","""提示词的基本结构是:角色设定、任务描述、上下文、输出格式约束。角色设定用 system 消息,比如你是一位资深 Java 面试官,能让回答风格更专业。要求结构化输出时,明确告诉模型返回 JSON 并给出字段说明,配合 Spring AI 的 entity() 方法可以直接转成 Java 对象。少样本示例(few-shot)很有效:给一到两个输入输出示例,模型的输出格式会稳定很多。"""},{"大模型 Agent 智能体学习笔记","""Agent 与普通对话的区别在于:Agent 能自主规划步骤并调用工具完成任务。Spring AI Alibaba 提供了 Graph 框架来编排工作流:StateGraph 定义节点和边,OverAllState 在节点之间传递共享状态。内置了 ReAct Agent、Supervisor 等多智能体模式,可以把意图分类、专业问答、结果汇总拆成不同节点,用条件边路由。工具调用是 Agent 的手脚,用 @Tool 注解标记方法并绑定到 ChatClient,模型会自动决定何时调用。"""},{"MySQL 索引优化经验笔记","""索引的最左前缀原则:联合索引 (a, b, c) 只对 a、ab、abc 这样的查询条件生效。避免索引失效的常见坑:对索引列使用函数或隐式类型转换、like 以百分号开头、or 连接非索引列。explain 是最好的朋友:type 至少达到 range 级别,extra 出现 Using filesort 说明排序没有走索引。大表分页优化:用游标方式 where id > lastId limit n 代替 limit offset, n。"""},{"JVM 垃圾回收调优笔记","""常见的垃圾收集器:CMS 已废弃,JDK 17 默认是 G1,大堆低延迟场景可以试 ZGC。G1 的核心概念是 Region 分区,通过 -XX:MaxGCPauseMillis 设定期望停顿时间,默认 200ms。调优第一步永远是看 GC 日志:-Xlog:gc* 加上 jstat -gcutil 观察回收频率与耗时。经验法则:年轻代对象朝生夕死,Survivor 区太小会提前晋升导致 Full GC,可以适当调大 -XX:SurvivorRatio。"""}};}
RagService.java
package com.study.saa.service;import com.study.saa.config.StudyProperties;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.document.Document;import org.springframework.ai.vectorstore.SearchRequest;import org.springframework.ai.vectorstore.VectorStore;import org.springframework.boot.ApplicationRunner;import org.springframework.stereotype.Service;import java.util.ArrayList;import java.util.List;import java.util.Map;import java.util.UUID;import java.util.concurrent.atomic.AtomicBoolean;/*** RAG(检索增强生成)核心服务,实现个人博客笔记问答。** <p>初始化流程(对应「初始化方法」):* <ol>* <li>连接数据库(h2/mysql 由 study.db-type 决定,DataSource 已注入)</li>* <li>查询 content 表的所有博文笔记</li>* <li>按段落分片,为每段生成 Document(带 title 等元数据)</li>* <li>调用大模型嵌入模型向量化,批量写入向量存储</li>* </ol>** <p>查询流程(对应「前端输入查询」):* <ol>* <li>把用户输入向量化并检索向量库,取回最相似的 Top-K 段落</li>* <li>把检索到的笔记片段 + 用户问题组装进提示词,提交给大模型</li>* <li>返回生成的答案与引用来源,实现「查询笔记内容」</li>* </ol>*/@Servicepublic class RagService implements ApplicationRunner {private static final Logger log = LoggerFactory.getLogger(RagService.class);/** 每批向量化的文档数(DashScope 嵌入接口有批量上限,取小值稳妥) */private static final int BATCH_SIZE = 5;/*** 单个分片的最大字符数。嵌入模型 text-embedding-v3 单条输入上限 8192 token,* 中文约 1 字 = 1~2 token,取 1000 字符的保守值既保证不超限,也利于检索精度* (分片太长会导致向量语义稀释,检索命中变差)。*/private static final int MAX_CHUNK_CHARS = 1000;/** 相邻分片的重叠字符数:切分长段落时保留上下文,避免关键句被拦腰截断 */private static final int CHUNK_OVERLAP = 100;/** 句子结束符(中文为主),长段落优先按句子边界切分 */private static final String SENTENCE_END = "[。!?!?;;\\n]";private final ContentService contentService;private final VectorStore vectorStore;private final ChatClient chatClient;private final StudyProperties properties;/** 是否已完成初始化 */private final AtomicBoolean initialized = new AtomicBoolean(false);/** 已入库的文档 ID,重复初始化时先删除旧数据 */private final List<String> indexedDocIds = new ArrayList<>();/** 已向量化的分段数量 */private volatile int chunkCount = 0;public RagService(ContentService contentService,VectorStore vectorStore,ChatClient chatClient,StudyProperties properties) {this.contentService = contentService;this.vectorStore = vectorStore;this.chatClient = chatClient;this.properties = properties;}/*** 启动时按配置决定是否自动初始化。*/@Overridepublic void run(org.springframework.boot.ApplicationArguments args) {if (properties.getRag().isAutoInit()) {try {initialize();} catch (Exception e) {// 自动初始化失败不阻断启动(常见原因:API Key 未配置),可稍后在页面手动点初始化log.error("RAG 自动初始化失败(可在页面手动重试):{}", e.getMessage());}}}/*** RAG 初始化:content 表 -> 段落分片 -> 嵌入向量化 -> 向量存储。*/public synchronized Map<String, Object> initialize() {long start = System.currentTimeMillis();// 1. 查询数据库中所有博文笔记List<ContentService.Note> notes = contentService.findAll();if (notes.isEmpty()) {throw new IllegalStateException("content 表中没有数据,请先插入博文笔记");}// 2. 重复初始化时清理旧向量cleanOldIndex();// 3. 按段落分片并构建 Document(超长段落会进一步按句子/固定长度切分,// 否则会触发嵌入接口 "Range of input length should be [1, 8192]" 报错)List<Document> documents = new ArrayList<>();for (ContentService.Note note : notes) {String[] paragraphs = note.content().split("\\n\\s*\\n");int index = 0;for (String paragraph : paragraphs) {String text = paragraph.strip();if (text.isEmpty()) {continue;}for (String chunk : splitLongText(text)) {documents.add(new Document(UUID.randomUUID().toString(),chunk,Map.of("contentId", String.valueOf(note.id()),"title", note.title(),"paragraph", String.valueOf(index))));}index++;}}// 4. 分批调用嵌入模型向量化并写入向量存储for (int i = 0; i < documents.size(); i += BATCH_SIZE) {List<Document> batch = documents.subList(i, Math.min(i + BATCH_SIZE, documents.size()));vectorStore.add(batch);batch.forEach(d -> indexedDocIds.add(d.getId()));}chunkCount = documents.size();initialized.set(true);long cost = System.currentTimeMillis() - start;log.info("RAG 初始化完成:{} 篇博文 -> {} 个段落分片,耗时 {} ms", notes.size(), documents.size(), cost);return Map.of("noteCount", notes.size(),"chunkCount", chunkCount,"costMillis", cost,"dbType", properties.getDbType());}/*** RAG 查询:向量化检索 + 大模型生成。*/public Map<String, Object> query(String question) {if (!initialized.get()) {throw new IllegalStateException("RAG 尚未初始化,请先点击「初始化知识库」");}// 1. 查询文本向量化并检索相似段落List<Document> hits = vectorStore.similaritySearch(SearchRequest.builder().query(question).topK(properties.getRag().getTopK()).build());if (hits == null || hits.isEmpty()) {return Map.of("answer", "在笔记库中没有找到与问题相关的内容,换个问法试试?","sources", List.of());}// 2. 组装检索到的笔记上下文StringBuilder context = new StringBuilder();List<Map<String, String>> sources = new ArrayList<>();int i = 1;for (Document d : hits) {String title = String.valueOf(d.getMetadata().getOrDefault("title", "未知来源"));context.append("[片段").append(i++).append("|来自笔记《").append(title).append("》]\n").append(d.getText()).append("\n\n");sources.add(Map.of("title", title,"snippet", d.getText().length() > 120 ? d.getText().substring(0, 120) + "..." : d.getText()));}// 3. 检索结果 + 问题一起提交给大模型String answer = chatClient.prompt().system("""你是一个个人博客笔记问答助手。请只依据下面提供的笔记片段回答用户问题:- 如果片段中有答案,用中文简洁准确地回答,并注明出自哪篇笔记;- 如果片段不足以回答,直接说明笔记中没有相关内容,不要编造。""").user("以下是检索到的笔记片段:\n\n" + context + "\n用户问题:" + question).call().content();return Map.of("answer", answer == null ? "" : answer,"sources", sources);}public boolean isInitialized() {return initialized.get();}public int getChunkCount() {return chunkCount;}private void cleanOldIndex() {if (!indexedDocIds.isEmpty()) {try {vectorStore.delete(indexedDocIds);} catch (Exception e) {log.warn("清理旧向量失败(忽略,继续写入新向量):{}", e.getMessage());}indexedDocIds.clear();}}/*** 文本长度超过 {@link #MAX_CHUNK_CHARS} 时进一步切分:* 优先按句子边界(。!?;等)聚合切分,单句超长时硬切;* 相邻分片保留 {@link #CHUNK_OVERLAP} 字符重叠,避免关键信息被截断丢失。*/private List<String> splitLongText(String text) {List<String> result = new ArrayList<>();if (text.length() <= MAX_CHUNK_CHARS) {result.add(text);return result;}// 按句子结束符切成句子片段List<String> sentences = new ArrayList<>();for (String part : text.split("(?<=" + SENTENCE_END + ")")) {String s = part.strip();if (!s.isEmpty()) {sentences.add(s);}}StringBuilder current = new StringBuilder();for (String sentence : sentences) {// 单句本身超长 → 先把已聚合的内容收掉,再对这句硬切if (sentence.length() > MAX_CHUNK_CHARS) {if (current.length() > 0) {result.add(current.toString());current.setLength(0);}for (int i = 0; i < sentence.length(); i += MAX_CHUNK_CHARS - CHUNK_OVERLAP) {int end = Math.min(i + MAX_CHUNK_CHARS, sentence.length());result.add(sentence.substring(i, end));if (end == sentence.length()) {break;}}continue;}if (current.length() + sentence.length() > MAX_CHUNK_CHARS && current.length() > 0) {result.add(current.toString());// 保留重叠上下文int overlapStart = Math.max(0, current.length() - CHUNK_OVERLAP);String tail = current.substring(overlapStart);current.setLength(0);current.append(tail);}current.append(sentence);}if (current.length() > 0) {result.add(current.toString());}log.info("超长段落已切分:{} 字符 -> {} 个分片", text.length(), result.size());return result;}}
AgentController.java
package com.study.saa.controller;import com.alibaba.cloud.ai.graph.CompiledGraph;import com.alibaba.cloud.ai.graph.OverAllState;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.HashMap;import java.util.Map;import java.util.Optional;/*** 示例十(入口):Graph Agent 的调用。** <p>执行链路:分类节点 -> 条件边路由 -> 专家节点 -> END,* 展示多节点 + 条件路由的工作流智能体如何一次 invoke 完成任务。*/@RestController@RequestMapping("/api/agent")public class AgentController {private final CompiledGraph studyAgentGraph;public AgentController(CompiledGraph studyAgentGraph) {this.studyAgentGraph = studyAgentGraph;}@PostMapping("/run")public Map<String, Object> run(@RequestBody Map<String, String> body) {String input = body.getOrDefault("input", "");try {Optional<OverAllState> result = studyAgentGraph.invoke(Map.of("input", input));String category = result.flatMap(s -> s.value("category")).map(Object::toString).orElse("unknown");String answer = result.flatMap(s -> s.value("answer")).map(Object::toString).orElse("没有产出结果");Map<String, Object> data = new HashMap<>();data.put("category", category);data.put("answer", answer);data.put("trace", "执行路径:START -> classify(分类结果:" + category + ") -> "+ expertNode(category) + " -> END");return Map.of("ok", true, "data", data);} catch (Exception e) {return Map.of("ok", false, "data", "Agent 执行失败:" + e.getMessage());}}private String expertNode(String category) {return switch (category) {case "concept" -> "concept_expert";case "code" -> "code_expert";default -> "chat_expert";};}}
PageController.java
package com.study.saa.controller;import com.study.saa.config.StudyProperties;import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.GetMapping;/*** 页面入口:渲染 FreeMarker 单页面(多个菜单对应各功能示例)。*/@Controllerpublic class PageController {private final StudyProperties properties;private final org.springframework.core.env.Environment environment;public PageController(StudyProperties properties,org.springframework.core.env.Environment environment) {this.properties = properties;this.environment = environment;}@GetMapping("/")public String index(Model model) {model.addAttribute("dbType", properties.getDbType());model.addAttribute("provider", properties.getModel().getProvider());model.addAttribute("defaultChatModel", resolveDefaultChatModel());return "index";}private String resolveDefaultChatModel() {String provider = properties.getModel().getProvider();if ("openai".equalsIgnoreCase(provider)) {return properties.getModel().getOpenai().getChatModel();}return environment.getProperty("spring.ai.dashscope.chat.options.model", "qwen-plus");}}
index.ftl
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Spring AI Alibaba 学习中心</title><style>* { margin: 0; padding: 0; box-sizing: border-box; }body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f0f2f5; color: #26303d; }.layout { display: flex; min-height: 100vh; }/* ---------- 侧边菜单 ---------- */.sidebar { width: 240px; background: #1e2a38; color: #cfd8e3; flex-shrink: 0; display: flex; flex-direction: column; }.sidebar .logo { padding: 22px 20px 16px; border-bottom: 1px solid rgba(255,255,255,.08); }.sidebar .logo h1 { font-size: 17px; color: #fff; letter-spacing: .5px; }.sidebar .logo p { font-size: 11px; margin-top: 6px; color: #8fa3ba; }.menu { flex: 1; overflow-y: auto; padding: 10px 0; }.menu-item { display: flex; align-items: center; gap: 10px; padding: 12px 20px; cursor: pointer;font-size: 13.5px; border-left: 3px solid transparent; transition: all .15s; }.menu-item:hover { background: rgba(255,255,255,.06); color: #fff; }.menu-item.active { background: rgba(64,150,255,.15); border-left-color: #4096ff; color: #fff; }.menu-item .tag { font-size: 10px; background: rgba(255,255,255,.12); border-radius: 3px; padding: 1px 5px; margin-left: auto; }.menu-item .tag.hot { background: #d4380d; color: #fff; }.sidebar .dbinfo { padding: 14px 20px; font-size: 11px; color: #8fa3ba; border-top: 1px solid rgba(255,255,255,.08); line-height: 1.8; }/* ---------- 内容区 ---------- */.main { flex: 1; padding: 26px 32px; overflow-y: auto; max-width: 1060px; }.panel { display: none; }.panel.active { display: block; }.panel h2 { font-size: 19px; margin-bottom: 8px; }.panel .desc { font-size: 13px; color: #5d6b7e; background: #eef4ff; border-left: 3px solid #4096ff;padding: 10px 14px; border-radius: 4px; margin-bottom: 18px; line-height: 1.8; }.panel .desc code { background: #dde7f7; padding: 1px 6px; border-radius: 3px; font-size: 12px; color: #1d39c4; }.card { background: #fff; border-radius: 10px; padding: 20px; box-shadow: 0 1px 4px rgba(31,45,61,.08); margin-bottom: 16px; }.card label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 8px; color: #3b4757; }.card textarea, .card input[type=text], .card select {width: 100%; border: 1px solid #d4dbe4; border-radius: 6px; padding: 9px 12px;font-size: 13.5px; font-family: inherit; outline: none; transition: border .15s; }.card textarea:focus, .card input:focus { border-color: #4096ff; }.card textarea { min-height: 88px; resize: vertical; }.row { display: flex; gap: 12px; }.row > div { flex: 1; }.btn { display: inline-flex; align-items: center; gap: 6px; background: #1677ff; color: #fff; border: none;border-radius: 6px; padding: 9px 22px; font-size: 13.5px; cursor: pointer; margin-top: 14px; transition: background .15s; }.btn:hover { background: #4096ff; }.btn:disabled { background: #9db8d9; cursor: not-allowed; }.btn.ghost { background: #fff; color: #1677ff; border: 1px solid #1677ff; }.btn.ghost:hover { background: #e8f2ff; }.output { margin-top: 16px; background: #0f1720; color: #d7e2ee; border-radius: 8px; padding: 16px;font-size: 13px; line-height: 1.9; white-space: pre-wrap; word-break: break-all;font-family: Consolas, "Courier New", monospace; min-height: 60px; }.output .placeholder { color: #5d7188; }.output.error { background: #2b1215; color: #ff9f9f; }.sources { margin-top: 12px; }.sources .src { background: #f6f8fb; border: 1px solid #e3e9f1; border-radius: 6px; padding: 10px 12px;margin-bottom: 8px; font-size: 12.5px; color: #43536a; }.sources .src b { color: #1677ff; }.hint { font-size: 12px; color: #8a97a8; margin-top: 10px; line-height: 1.8; }.badge { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-right: 6px; }.badge.green { background: #e6f7ef; color: #0f9d58; }.badge.gray { background: #eef1f5; color: #7a8798; }.status-bar { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; font-size: 13px; }</style></head><body><div class="layout"><div class="sidebar"><div class="logo"><h1>Spring AI Alibaba</h1><p>学习中心 · 10 个核心示例</p></div><div class="menu" id="menu"><div class="menu-item active" data-panel="chat-client"><span>💬 ChatClient 对话</span></div><div class="menu-item" data-panel="chat-model"><span>⚙️ ChatModel 底层调用</span></div><div class="menu-item" data-panel="embedding"><span>🧮 嵌入模型</span></div><div class="menu-item" data-panel="tool"><span>🔧 工具调用</span><span class="tag">Agent基础</span></div><div class="menu-item" data-panel="memory"><span>🧠 对话记忆</span></div><div class="menu-item" data-panel="prompt"><span>📝 提示词模板</span></div><div class="menu-item" data-panel="format"><span>📦 结构化输出</span></div><div class="menu-item" data-panel="vector"><span>🗄️ 向量存储</span></div><div class="menu-item" data-panel="rag"><span>📚 RAG 知识库问答</span><span class="tag hot">核心</span></div><div class="menu-item" data-panel="agent"><span>🤖 Graph Agent</span><span class="tag hot">核心</span></div></div><div class="dbinfo">当前数据库:<b>${dbType}</b><br>切换方式:application.yml<br>→ study.db-type: h2 / mysql</div></div><div class="main"><!-- ================= 1. ChatClient ================= --><div class="panel active" id="panel-chat-client"><h2>💬 ChatClient 的使用</h2><div class="desc"><code>ChatClient</code> 是 Spring AI 推荐的高层对话 API:链式调用<code>prompt().user(...).call().content()</code>。下方第二个输入演示<code>system</code> 系统提示词的角色扮演用法。</div><div class="card"><label>基础对话</label><textarea id="cc-input" placeholder="例如:用一句话介绍 Spring AI Alibaba 是什么?"></textarea><button class="btn" onclick="callText('/api/chat/client', {input: val('cc-input')}, 'cc-out')">发送</button><div class="output" id="cc-out"><span class="placeholder">回答将显示在这里…</span></div></div><div class="card"><label>角色扮演(system 消息)</label><textarea id="cc-role-input" placeholder="例如:请问我一个 Java 并发方面的问题"></textarea><button class="btn" onclick="callText('/api/chat/role', {input: val('cc-role-input')}, 'cc-role-out')">以面试官身份回答</button><div class="output" id="cc-role-out"><span class="placeholder">回答将显示在这里…</span></div></div></div><!-- ================= 2. ChatModel ================= --><div class="panel" id="panel-chat-model"><h2>⚙️ ChatModel 的使用(底层 API)</h2><div class="desc">直接注入 <code>ChatModel</code> 接口调用模型,使用 application.yml 中配置的默认模型<code>${defaultChatModel?html}</code>(provider: <code>${provider?html}</code>)。这里演示的是 ChatModel 的底层调用与 token 消耗等元信息,模型由配置统一决定。</div><div class="card"><div class="row"><div style="flex:3"><label>问题</label><textarea id="cm-input" placeholder="例如:用三句话讲清楚什么是 JVM"></textarea></div><div style="flex:1"><label>当前模型</label><input type="text" id="cm-model" value="${defaultChatModel?html}" readonly></div></div><button class="btn" onclick="callText('/api/chat/model', {input: val('cm-input')}, 'cm-out')">调用 ChatModel</button><div class="output" id="cm-out"><span class="placeholder">回答与 token 用量将显示在这里…</span></div></div></div><!-- ================= 3. Embedding ================= --><div class="panel" id="panel-embedding"><h2>🧮 嵌入模型(EmbeddingModel)</h2><div class="desc">嵌入模型把文本变成高维向量(<code>text-embedding-v3</code>),是语义检索和 RAG 的基础。第二个卡片计算两段文本的余弦相似度,直观感受「语义距离」。</div><div class="card"><label>文本向量化</label><textarea id="emb-input" placeholder="例如:Spring AI 让 Java 工程师也能优雅地开发 AI 应用"></textarea><button class="btn" onclick="callText('/api/embedding/embed', {input: val('emb-input')}, 'emb-out')">生成向量</button><div class="output" id="emb-out"><span class="placeholder">向量维度与前几个分量将显示在这里…</span></div></div><div class="card"><label>语义相似度计算</label><div class="row"><div><input type="text" id="emb-t1" placeholder="文本A:我想学习大模型开发"></div><div><input type="text" id="emb-t2" placeholder="文本B:如何入门 AI 应用开发"></div></div><button class="btn" onclick="callText('/api/embedding/similarity', {text1: val('emb-t1'), text2: val('emb-t2')}, 'emb-sim-out')">计算相似度</button><div class="output" id="emb-sim-out"><span class="placeholder">余弦相似度将显示在这里…</span></div></div></div><!-- ================= 4. Tool ================= --><div class="panel" id="panel-tool"><h2>🔧 工具调用(Tool Calling)</h2><div class="desc">给模型绑定 <code>@Tool</code> 标记的 Java 方法(天气查询 + 计算器),模型会自动决定何时调用。试试:<code>杭州今天天气怎么样?</code>、<code>帮我算一下 125 乘以 88</code>、<code>北京和上海的天气对比,两地温差多少?</code></div><div class="card"><label>向绑定了工具的模型提问</label><textarea id="tool-input" placeholder="例如:查一下北京和杭州的天气,并算出两地温差"></textarea><button class="btn" onclick="callText('/api/tool/chat', {input: val('tool-input')}, 'tool-out')">发送(模型自动选工具)</button><div class="output" id="tool-out"><span class="placeholder">模型回答将显示在这里…</span></div></div></div><!-- ================= 5. Memory ================= --><div class="panel" id="panel-memory"><h2>🧠 对话记忆(Chat Memory)</h2><div class="desc">通过 <code>MessageChatMemoryAdvisor</code> 挂载记忆,同一会话 ID 下模型能记住上文。试试先说「我叫小明,正在学 Java」,再问「我叫什么?」。</div><div class="card"><div class="row"><div style="flex:1"><label>会话 ID</label><input type="text" id="mem-conv" value="session-001"></div></div><div style="height:12px"></div><label>消息内容</label><textarea id="mem-input" placeholder="例如:我叫小明,正在学习 Spring AI"></textarea><button class="btn" onclick="callText('/api/memory/chat', {input: val('mem-input'), conversationId: val('mem-conv')}, 'mem-out')">发送(记住上文)</button><button class="btn ghost" onclick="callText('/api/memory/history?conversationId=' + encodeURIComponent(val('mem-conv')), null, 'mem-out', 'GET')">查看当前记忆</button><div class="output" id="mem-out"><span class="placeholder">回答将显示在这里…</span></div></div></div><!-- ================= 6. Prompt ================= --><div class="panel" id="panel-prompt"><h2>📝 提示词模板(Prompt Template)</h2><div class="desc"><code>PromptTemplate</code> 用 <code>{变量}</code> 占位,把提示词骨架与动态变量分离。页面会同时展示「渲染后的提示词」和「模型输出」。</div><div class="card"><div class="row"><div style="flex:2"><label>主题(topic)</label><input type="text" id="pt-topic" value="RAG 检索增强生成"></div><div style="flex:1"><label>风格(style)</label><select id="pt-style"><option value="通俗易懂">通俗易懂</option><option value="幽默风趣">幽默风趣</option><option value="严谨学术">严谨学术</option></select></div></div><button class="btn" onclick="callText('/api/prompt/generate', {topic: val('pt-topic'), style: document.getElementById('pt-style').value}, 'pt-out')">渲染并生成</button><div class="output" id="pt-out"><span class="placeholder">渲染后的提示词与模型输出将显示在这里…</span></div></div></div><!-- ================= 7. Format ================= --><div class="panel" id="panel-format"><h2>📦 结构化输出(Structured Output)</h2><div class="desc">使用 <code>.entity(Class)</code> 让模型输出自动转成 Java record(ActorFilms),无需手工解析 JSON。输入演员名字试试。</div><div class="card"><label>演员名字</label><input type="text" id="fmt-input" value="周星驰" placeholder="例如:周星驰"><button class="btn" onclick="callText('/api/format/actor', {input: val('fmt-input')}, 'fmt-out')">生成结构化对象</button><div class="output" id="fmt-out"><span class="placeholder">结构化结果将显示在这里…</span></div></div></div><!-- ================= 8. Vector ================= --><div class="panel" id="panel-vector"><h2>🗄️ 向量存储(VectorStore)</h2><div class="desc">先「添加文档」把文本向量化入库,再「相似检索」体验语义搜索(不是关键词匹配)。例如添加「iPhone 15 Pro 搭载 A17 芯片」后,搜索「苹果手机用的什么处理器」也能命中。</div><div class="card"><label>添加文档</label><textarea id="vec-add" placeholder="输入一段要向量化存储的文本,例如:iPhone 15 Pro 搭载 A17 仿生芯片,性能提升 10%"></textarea><button class="btn" onclick="callText('/api/vector/add', {text: val('vec-add')}, 'vec-add-out')">添加并向量化</button><div class="output" id="vec-add-out"><span class="placeholder">添加结果…</span></div></div><div class="card"><div class="row"><div style="flex:3"><label>相似检索</label><input type="text" id="vec-query" placeholder="例如:苹果手机用的什么处理器"></div><div style="flex:1"><label>Top-K</label><select id="vec-topk"><option>3</option><option>5</option><option>10</option></select></div></div><button class="btn" onclick="callText('/api/vector/search', {query: val('vec-query'), topK: document.getElementById('vec-topk').value}, 'vec-search-out')">语义检索</button><div class="output" id="vec-search-out"><span class="placeholder">检索结果(含相似度分数)…</span></div></div></div><!-- ================= 9. RAG ================= --><div class="panel" id="panel-rag"><h2>📚 RAG 知识库问答(博客笔记检索)</h2><div class="desc">流程:<b>初始化</b>——查询 content 表全部博文 → 按段落分片 → 嵌入模型向量化 → 写入向量库;<b>提问</b>——问题向量化 → 相似检索 Top-K → 检索片段 + 问题交给大模型 → 带来源的回答。<br>试试问:「RAG 的离线阶段要做哪些事」「MySQL 索引怎么优化」「G1 垃圾收集器怎么调优」。</div><div class="status-bar" id="rag-status-bar"><span>初始化状态:</span><span id="rag-status">检查中…</span></div><div class="card"><button class="btn" id="rag-init-btn" onclick="ragInit()">🔄 初始化知识库(content 表 → 向量库)</button><div class="hint">初始化会重新向量化全部博文(重复执行会先清理旧数据)。数据库类型:${dbType}(h2=内存模拟,mysql=真实博客库)</div><div class="output" id="rag-init-out"><span class="placeholder">初始化结果…</span></div></div><div class="card"><label>向知识库提问</label><textarea id="rag-query" placeholder="例如:RAG 离线阶段要做什么?"></textarea><button class="btn" onclick="ragQuery()">检索并回答</button><div class="output" id="rag-answer-out"><span class="placeholder">基于笔记内容的回答将显示在这里…</span></div><div class="sources" id="rag-sources"></div></div></div><!-- ================= 10. Agent ================= --><div class="panel" id="panel-agent"><h2>🤖 Graph Agent(多节点工作流智能体)</h2><div class="desc">基于 Spring AI Alibaba <code>Graph</code> 的工作流智能体:分类节点(LLM 判断问题类型)→ 条件边路由 → 概念讲解 / 代码助手 / 闲聊伙伴 三个专家节点之一 → 汇总输出。同一个问题会走不同的执行路径,回答下方会展示实际执行轨迹。</div><div class="card"><label>向学习助手 Agent 提问</label><textarea id="agent-input" placeholder="概念类:什么是向量数据库? / 代码类:帮我写一个 Java 线程池示例 / 闲聊类:今天好累啊"></textarea><button class="btn" onclick="agentRun()">🚀 执行 Agent 工作流</button><div class="output" id="agent-out"><span class="placeholder">Agent 执行结果与路径将显示在这里…</span></div></div></div></div></div><script>// ---------- 菜单切换 ----------var menu = document.getElementById('menu');menu.addEventListener('click', function (e) {var item = e.target.closest('.menu-item');if (!item) return;document.querySelectorAll('.menu-item').forEach(function (m) { m.classList.remove('active'); });item.classList.add('active');document.querySelectorAll('.panel').forEach(function (p) { p.classList.remove('active'); });document.getElementById('panel-' + item.dataset.panel).classList.add('active');if (item.dataset.panel === 'rag') refreshRagStatus();});// ---------- 通用请求 ----------function val(id) { return document.getElementById(id).value.trim(); }function showOut(id, text, isError) {var el = document.getElementById(id);el.textContent = text;el.classList.toggle('error', !!isError);}function callText(url, body, outId, method) {var opts = { method: method || 'POST', headers: { 'Content-Type': 'application/json' } };if (body !== null && (method || 'POST') === 'POST') opts.body = JSON.stringify(body);showOut(outId, '请求中,请稍候…(首次调用大模型可能需要几秒)');fetch(url, opts).then(function (r) { return r.json(); }).then(function (res) {var data = res.data;var text;if (data && typeof data === 'object' && 'answer' in data) {text = data.answer;if (data.trace) text += '\n\n---- 执行轨迹 ----\n' + data.trace;if (data.sources && data.sources.length) text += '\n\n---- 引用来源见下方 ----';} else if (data && typeof data === 'object') {text = data.answer || data.message || JSON.stringify(data, null, 2);} else {text = data === undefined || data === null ? '(空)' : String(data);}showOut(outId, text, !res.ok);if (data && typeof data === 'object' && Array.isArray(data.sources)) renderSources(data.sources);}).catch(function (err) { showOut(outId, '请求异常:' + err.message, true); });}function renderSources(sources) {var box = document.getElementById('rag-sources');box.innerHTML = '';sources.forEach(function (s) {var div = document.createElement('div');div.className = 'src';var b = document.createElement('b'); b.textContent = '《' + s.title + '》';div.appendChild(b);div.appendChild(document.createElement('br'));div.appendChild(document.createTextNode(s.snippet));box.appendChild(div);});}// ---------- RAG ----------function ragInit() {var btn = document.getElementById('rag-init-btn');btn.disabled = true; btn.textContent = '⏳ 初始化中(向量化需要时间)…';showOut('rag-init-out', '正在查询 content 表并调用嵌入模型向量化…');fetch('/api/rag/init', { method: 'POST' }).then(function (r) { return r.json(); }).then(function (res) {var d = res.data;var text = res.ok? '✅ ' + d.message + '\n博文数:' + d.noteCount + ' 篇\n分段数:' + d.chunkCount + ' 段\n耗时:' + d.costMillis + ' ms\n数据库:' + d.dbType: '❌ ' + d;showOut('rag-init-out', text, !res.ok);refreshRagStatus();}).catch(function (e) { showOut('rag-init-out', '请求异常:' + e.message, true); }).finally(function () { btn.disabled = false; btn.textContent = '🔄 初始化知识库(content 表 → 向量库)'; });}function ragQuery() {var q = val('rag-query');if (!q) { showOut('rag-answer-out', '请输入问题', true); return; }document.getElementById('rag-sources').innerHTML = '';callText('/api/rag/query', { query: q }, 'rag-answer-out');}function refreshRagStatus() {fetch('/api/rag/status').then(function (r) { return r.json(); }).then(function (res) {var d = res.data;var el = document.getElementById('rag-status');if (d.initialized) {el.innerHTML = '<span class="badge green">已初始化</span> 已向量化 ' + d.chunkCount + ' 个段落(' + d.dbType + ')';} else {el.innerHTML = '<span class="badge gray">未初始化</span> 请先点击下方按钮初始化(' + d.dbType + ')';}}).catch(function () {document.getElementById('rag-status').textContent = '状态获取失败';});}// ---------- Agent ----------function agentRun() {var input = val('agent-input');if (!input) { showOut('agent-out', '请输入内容', true); return; }callText('/api/agent/run', { input: input }, 'agent-out');}// 页面加载后检查 RAG 状态refreshRagStatus();</script></body></html>
init.sql
-- ============================================================-- Spring AI Alibaba 学习项目 - MySQL 初始化脚本-- 使用场景:study.db-type=mysql 时,在真实的博客数据库中执行-- (content 表通常你的博客系统已经有了;没有时执行下面的建表语句即可)-- ============================================================-- 博文笔记表(RAG 数据源)CREATE TABLE IF NOT EXISTS content (id BIGINT AUTO_INCREMENT PRIMARY KEY,title VARCHAR(255) NOT NULL COMMENT '笔记标题',content TEXT NOT NULL COMMENT '笔记内容,段落之间用空行分隔') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='博文笔记表';-- (可选)插入几条示例数据,已有博客数据则跳过-- INSERT INTO content (title, content) VALUES-- ('Spring AI 入门笔记', '第一段内容...\n\n第二段内容...'),-- ('RAG 实践笔记', '第一段内容...\n\n第二段内容...');-- 3.(可选扩展)对话记忆持久化:如需把多轮对话历史存到 MySQL,-- 可在 pom.xml 引入 spring-ai-starter-model-chat-memory-repository-jdbc 并执行下表:-- CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (-- conversation_id VARCHAR(36) NOT NULL,-- content TEXT NOT NULL,-- type ENUM('USER', 'ASSISTANT', 'SYSTEM', 'TOOL') NOT NULL,-- `timestamp` TIMESTAMP NOT NULL,-- INDEX SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX (conversation_id, `timestamp`)-- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Spring AI 对话记忆表';-- 本项目默认使用内存对话记忆(重启清空),无需此表。
