一、开篇:为什么Java开发者需要Spring AI
1.1 大模型浪潮下,Java 开发者的机遇与挑战
过去两年,大语言模型(LLM)以 ChatGPT 为代表席卷全球,从对话助手到代码生成,从内容创作到企业知识库,AI 能力正以前所未有的速度重塑软件开发。Python 凭借其丰富的 AI 库(如 LangChain、Transformers)成为这场变革的主角,而作为企业级后端的中流砥柱------Java 开发者,自然要思考:如何将大模型的能力无缝集成到现有的 Java 系统中?
直接调用大模型 API(例如 OpenAI 的接口)听起来很简单------发一个 HTTP 请求,拿回一段文本。但在实际生产环境中,我们会面临一系列棘手的问题:
- 复杂的调用细节:需要手动构建 JSON 请求体、处理 HTTP 连接、解析流式响应、处理鉴权和错误重试。
- 提示词管理困难:业务场景往往需要动态构造提示词,拼接用户输入、历史对话、系统指令,代码很快就变得难以维护。
- 对话状态维护:实现一个多轮对话机器人,必须自己维护会话历史,并在每次请求时把历史消息都带上。
- 输出不可控:大模型返回的是自然语言文本,如果想让 AI 返回结构化的数据(例如 JSON、对象),还需要自己编写解析器和异常处理。
- 知识库集成复杂:要让模型基于企业内部知识回答问题(RAG),需要自己实现文档加载、文本分割、向量化、向量检索等一系列组件。
如果每个项目都从零开始重复造这些轮子,不仅开发效率低,而且容易出错,更难以应对模型切换、版本升级等变化。
1.2 原始调用方式:一个直观的对比
让我们先看一段最原始的 Java 代码,它使用 HttpClient 调用 OpenAI 的聊天接口:
java
// 原始方式:直接调用 OpenAI API
HttpClient client = HttpClient.newHttpClient();
String requestBody = """
{
"model": "gpt-3.5-turbo",
"messages": [
{"role": "user", "content": "你好,请介绍一下自己"}
]
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.openai.com/v1/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer YOUR_API_KEY")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
// 手动解析 JSON 响应
JSONObject json = new JSONObject(response.body());
String answer = json.getJSONArray("choices")
.getJSONObject(0)
.getJSONObject("message")
.getString("content");
System.out.println("AI 回答:" + answer);
} catch (Exception e) {
e.printStackTrace();
}
这段代码虽然能工作,但存在明显的问题:
- 硬编码 API 地址和密钥,难以管理。
- JSON 构造和解析冗长且脆弱(字段名变化、空值处理)。
- 没有错误重试、超时控制等生产级必备机制。
- 如果要支持多轮对话,需要自己拼接历史消息数组,代码复杂度会急剧上升。
1.3 Spring AI 登场:Spring 官方出品的 AI 集成框架
Spring AI 正是为了解决上述痛点而诞生的。它是 Spring 官方团队(VMware 旗下)推出的全新项目,旨在将 Spring 生态的核心理念------依赖注入、自动配置、POJO 编程------带入 AI 开发领域。它的目标很简单:让 Java 开发者用最熟悉的方式,像调用普通方法一样使用大模型。
让我们用 Spring AI 重写上面的功能:
java
// Spring AI 方式:简洁、直观
@RestController
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping("/chat")
public String chat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
}
短短几行代码,没有 JSON 处理,没有 HTTP 细节,甚至不需要自己解析响应。这就是 Spring AI 的魅力------它将所有底层复杂性封装起来,让你专注于业务逻辑。而且,它与 Spring Boot 无缝集成,配置简单,开箱即用。
1.4 Spring AI 的核心优势
| 优势 | 说明 |
|---|---|
| 遵循 Spring 生态设计哲学 | 依赖注入、自动配置、面向接口编程,让你用最熟悉的 Spring 方式开发 AI 应用。ChatClient 就像 RestTemplate 一样简单。 |
| 统一 API 抽象 | 支持 OpenAI、Azure、Ollama、通义千问、DeepSeek 等主流模型提供商,切换模型只需修改配置文件,业务代码无需改动。 |
| 与 Spring Boot 无缝集成 | 通过 Starter 自动配置,只需引入依赖并配置 API 密钥,即可注入 ChatClient、EmbeddingClient 等核心 Bean。 |
| 企业级特性 | 内置流式响应(SSE)、函数调用、向量存储抽象、缓存集成、监控指标(Actuator),满足生产环境需求。 |
| 可扩展性 | 基于 Spring 的扩展机制,可以轻松定制 ChatClient 的行为,添加拦截器、自定义输出转换器等。 |
| 面向 RAG 的完整工具链 | 提供 VectorStore 抽象、文档加载器、分割器、嵌入客户端,让构建知识库问答系统变得轻而易举。 |
下图展示了 Spring AI 的核心组件及其关系:
1.5 本文目标:零基础全组件实战
如果你是一名 Java 开发者,但从未接触过大模型开发,不用担心。本文将带你从零开始,一步一步亲手实践 Spring AI 的所有核心组件。你将学到:
- 基础篇 :
ChatClient的基本用法、提示词模板、流式响应 - 结构化输出:让 AI 直接返回 Java 对象,告别手动解析
- 函数调用:让 AI 调用你的 Java 方法(工具),获取实时信息
- 多模态:处理图片、语音(如适用)
- RAG 篇:构建基于私有知识库的问答系统(向量存储、文档处理)
- 注解式开发:用自定义注解简化 AI 服务调用
- 生产级特性:缓存、监控与 Spring Boot Actuator 集成
- 微服务集成:与 Spring Cloud 生态结合,实现配置中心、灰度发布
二、环境准备:5分钟搭建第一个Spring AI应用
在开始动手之前,我们先确保你的开发环境就绪。本章将带领你完成从零到第一个可运行REST API的全过程,全程只需5分钟。
2.1 开发环境要求
- JDK 17 或更高版本(Spring Boot 3.x 要求 JDK 17+)
- Maven (3.6+)或 Gradle(7.x+)------ 任选一种你熟悉的构建工具
- IDE:IntelliJ IDEA、Eclipse 或 VS Code(Java插件)
- 网络连接:能够访问公网(因为需要调用大模型API)
如果你还没有安装 JDK 或 Maven/Gradle,请先自行安装。这里不再赘述。
2.2 在项目中引入 Spring AI 依赖
我们将创建一个最简单的 Spring Boot Web 项目,并添加 Spring AI 的依赖。
方式一:使用 Spring Initializr 快速创建(推荐)
- 访问 start.spring.io/
- 选择以下选项:
- Project:Maven 或 Gradle(这里以 Maven 为例)
- Language:Java
- Spring Boot:选择 3.2.x 或更高版本(3.1+ 也可以,但建议 3.2)
- Group :
com.example - Artifact :
spring-ai-demo - Dependencies :添加 Spring Web 和 OpenAI(Spring AI 的 OpenAI Starter)
- 点击 Generate 下载项目压缩包,解压后导入 IDE。
方式二:手动配置 Maven
如果不想通过 Initializr,可以手动创建 pom.xml 文件,内容如下:
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
http://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.2.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>spring-ai-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M2</spring-ai.version> <!-- 使用最新里程碑版本 -->
</properties>
<dependencies>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI OpenAI Starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<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>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<!-- Spring AI 里程碑仓库(如果使用里程碑版本需要添加) -->
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</project>
注意 :Spring AI 目前(2025年)仍处于快速迭代阶段,最新版本可能为 1.0.0-M2 或更高。请访问 Spring AI 官方文档 查看最新版本号。里程碑版本需要添加 Spring Milestones 仓库。
2.3 配置大模型 API 凭证
在 src/main/resources/application.yml 中配置 OpenAI 相关参数。如果你使用 OpenAI 官方 API,配置如下:
yaml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY} # 从环境变量读取,避免硬编码
base-url: https://api.openai.com # 可选,默认就是官方地址
chat:
options:
model: gpt-4o-mini # 或 gpt-3.5-turbo
temperature: 0.7
如果你使用的是兼容 OpenAI API 的其他服务商 (如 DeepSeek、通义千问等),只需修改 base-url 和 api-key。例如 DeepSeek:
yaml
spring:
ai:
openai:
api-key: ${DEEPSEEK_API_KEY}
base-url: https://api.deepseek.com/v1
chat:
options:
model: deepseek-chat
为了安全,不要将 API 密钥直接写在配置文件中。推荐通过环境变量设置:
- Linux/Mac :
export OPENAI_API_KEY=sk-xxxx - Windows :
set OPENAI_API_KEY=sk-xxxx
或者在 IDE 的运行配置中设置环境变量。如果你只是快速测试,也可以临时在配置文件中写死,但切记不要提交到代码仓库。
2.4 编写第一个程序:ChatClient 基础用法
现在我们来创建一个 REST 控制器,注入 ChatClient,实现一个简单的聊天接口。
2.4.1 创建 Controller
在 com.example.demo 包下创建 ChatController.java:
java
package com.example.demo;
import org.springframework.ai.chat.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ChatController {
private final ChatClient chatClient;
// 通过构造器注入 ChatClient.Builder,然后构建 ChatClient
public ChatController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping("/chat")
public String chat(@RequestParam(defaultValue = "你好,请介绍一下自己") String message) {
// 使用 prompt().user() 设置用户消息,call() 调用模型,content() 获取文本回复
return chatClient.prompt()
.user(message)
.call()
.content();
}
}
2.4.2 代码逐行解释
ChatClient.Builder:由 Spring AI 自动配置创建的构建器,用于创建ChatClient实例。我们通过构造器注入它,然后调用build()方法得到ChatClient。chatClient.prompt():开始构建一个提示词(Prompt)。.user(message):设置用户消息。也可以设置系统消息(.system())。.call():发起同步调用,等待模型返回完整响应。.content():从响应中提取文本内容。如果需要完整响应对象(包含 Token 用量等),可以调用call()返回ChatResponse。
2.4.3 启动类
确保项目有标准的 Spring Boot 启动类(Initializr 会自动生成):
java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
2.4.4 运行测试
-
启动应用(运行
DemoApplication的main方法)。 -
打开浏览器或使用 curl 访问:
http://localhost:8080/chat?message=你好 -
你将看到类似如下的 JSON 格式响应(纯文本):
你好!我是AI助手,有什么可以帮你的吗?
完整流程示意图:
2.4.5 可能遇到的问题及解决
- 启动失败,提示找不到 ChatClient.Builder :检查依赖是否正确引入,以及 Spring AI 的自动配置是否生效。可以尝试在配置类上添加
@EnableAutoConfiguration或检查@SpringBootApplication是否正常扫描。 - 调用时报错 401 :API 密钥配置错误或未设置环境变量。检查
application.yml中的api-key是否正确。 - 响应慢:网络问题,可尝试更换网络或使用代理。
2.5 本章小结
通过本章的学习,你成功搭建了第一个 Spring AI 应用,并实现了最基础的聊天接口。你学会了:
- 使用 Spring Initializr 快速创建包含 Spring AI 的项目
- 配置 OpenAI(或其他兼容 API)的凭证
- 编写 REST 控制器,注入并使用
ChatClient - 运行并测试第一个 AI 接口
三、核心交互:ChatClient 与提示词管理
通过上一章,你已经成功搭建了第一个 Spring AI 应用,并实现了最简单的"一问一答"接口。但实际应用中,我们往往需要更精细的控制:给 AI 设定角色(系统消息)、动态构造提问内容、获取完整的响应元数据(如 Token 消耗)等。本章将带你深入掌握 ChatClient 的核心 API,并学习如何使用提示词模板来管理复杂的提示词。
3.1 ChatClient 的核心 API 解析
ChatClient 是 Spring AI 中用于与大模型交互的核心接口。它采用流式(fluent)API 设计,让你可以像搭积木一样构建请求。我们先用一个更完整的例子来展示它的主要方法。
3.1.1 基础用法回顾
最简单的用法:
java
String response = chatClient.prompt()
.user("你好")
.call()
.content();
但 prompt() 返回的 Prompt 构建器远不止这些功能。
3.1.2 主要方法详解
| 方法 | 描述 | 示例 |
|---|---|---|
prompt() |
开始构建一个新的提示词(Prompt) | chatClient.prompt() |
.user(String text) |
添加用户消息(纯文本) | .user("你好") |
.system(String text) |
添加系统消息 | .system("你是一个友好的助手") |
.messages(List<Message> messages) |
直接添加多条消息(用于多轮对话) | 见下文示例 |
.options(ChatOptions options) |
设置模型参数(如温度、最大 Token 等) | .options(OpenAiChatOptions.builder().temperature(0.8).build()) |
.advisors(Advisor... advisors) |
添加顾问(用于增强功能,如记忆、RAG 等) | 后续章节介绍 |
call() |
发起同步调用,返回 ChatResponse |
.call() |
stream() |
发起流式调用,返回 Flux<ChatResponse> |
下一章介绍 |
.content() |
从 ChatResponse 中提取文本内容(快捷方式) |
.call().content() |
.entity(Class<T> type) |
将响应解析为指定类型的 Java 对象 | 第五章介绍 |
3.1.3 获取完整响应对象
除了直接获取文本内容,有时我们需要获取 Token 消耗、响应元数据等信息。此时可以调用 call() 得到 ChatResponse 对象:
java
ChatResponse response = chatClient.prompt()
.user("你好")
.call();
// 获取生成的文本
String content = response.getResult().getOutput().getContent();
// 获取 Token 用量
Generation generation = response.getResult();
if (generation.getOutput() != null) {
// 有些模型会返回 Token 使用情况
var usage = response.getMetadata();
// 具体获取方式视版本而定,可能通过 response.getMetadata() 或 generation.getMetadata()
}
// 打印完整响应以便调试
System.out.println(response);
注意:Token 用量的获取方式在不同版本中可能有所变化,建议参考当前版本的 Javadoc 或文档。
3.1.4 系统消息与多轮对话
系统消息用于设定 AI 的角色和行为。例如,让 AI 扮演一个 Java 编程导师:
java
String response = chatClient.prompt()
.system("你是一个 Java 编程导师,回答要简洁并给出代码示例。")
.user("请解释一下什么是多态?")
.call()
.content();
对于多轮对话,你需要维护一个消息列表,包含之前的用户消息和 AI 回复。Spring AI 的 Prompt 对象可以接受一个消息列表:
java
import org.springframework.ai.chat.messages.*;
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一个乐于助人的助手。"));
messages.add(new UserMessage("我叫小明。"));
messages.add(new AssistantMessage("你好小明,我是你的助手。"));
messages.add(new UserMessage("我叫什么名字?"));
String response = chatClient.prompt(new Prompt(messages))
.call()
.content();
这种方式需要你手动维护消息列表,比较繁琐。Spring AI 提供了 ChatMemory 抽象来简化多轮对话管理,我们将在后续章节介绍。
3.2 提示词模板(PromptTemplate)的使用
在实际业务中,用户消息往往需要动态插入变量,例如"我的订单号是 {orderId},请查询状态"。如果每次都用字符串拼接,不仅繁琐,而且容易出错(如注入风险)。Spring AI 提供了 提示词模板 功能,让你可以定义带占位符的模板文件,然后通过参数填充。
3.2.1 创建模板文件
在 src/main/resources 目录下创建 prompts 文件夹(可选),然后创建一个文本文件,例如 order-status.st:
我的订单号是 {{orderId}},请帮我查询当前状态。如果订单存在,请告诉我物流信息;如果不存在,请提示我检查订单号。
占位符使用双大括号 {{变量名}} 表示。
3.2.2 加载模板并填充
Spring AI 提供了 PromptTemplate 类来加载模板文件并进行变量替换。
java
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.core.io.ClassPathResource;
// 加载模板文件
PromptTemplate promptTemplate = new PromptTemplate(new ClassPathResource("prompts/order-status.st"));
// 准备变量
Map<String, Object> variables = Map.of("orderId", "NO123456789");
// 渲染模板,生成 Prompt 对象
Prompt prompt = promptTemplate.create(variables);
// 发送请求
String response = chatClient.prompt(prompt)
.call()
.content();
PromptTemplate 还可以直接接受字符串模板,而不必从文件加载:
java
String template = "请将以下文本翻译成 {{targetLanguage}}:{{text}}";
PromptTemplate promptTemplate = new PromptTemplate(template);
Prompt prompt = promptTemplate.create(Map.of(
"targetLanguage", "中文",
"text", "Hello, world"
));
3.2.3 与 @Value 注解结合使用
如果你更习惯使用 Spring 的 @Value 注解注入文件内容,可以这样做:
java
@Component
public class OrderService {
@Value("classpath:prompts/order-status.st")
private Resource orderStatusTemplate;
public String queryOrderStatus(String orderId) {
PromptTemplate promptTemplate = new PromptTemplate(orderStatusTemplate);
Prompt prompt = promptTemplate.create(Map.of("orderId", orderId));
return chatClient.prompt(prompt).call().content();
}
}
3.2.4 模板的优势
- 关注点分离:提示词与 Java 代码分离,便于维护和修改。
- 复用性:同一模板可用于不同场景,只需传入不同变量。
- 安全性:避免字符串拼接导致的注入风险(不过大模型提示词注入是另一回事,但至少代码更清晰)。
3.3 实践:构建一个带角色设定的聊天助手
现在让我们综合运用本章所学,构建一个更实用的聊天助手:一个能记住用户名字并提供个性化问候的助手。我们将使用系统消息设定角色,使用提示词模板动态构造用户消息。
3.3.1 定义接口
创建 GreetingController.java:
java
package com.example.demo;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/greet")
public class GreetingController {
private final ChatClient chatClient;
@Autowired
public GreetingController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping
public String greet(@RequestParam String name) {
// 系统消息:设定角色
String systemMessage = "你是一个友好的接待员,用热情的语气问候客人。";
// 用户消息模板
String userTemplate = "你好,我叫 {{name}},请向我问好。";
// 创建 PromptTemplate
PromptTemplate promptTemplate = new PromptTemplate(userTemplate);
Prompt prompt = promptTemplate.create(Map.of("name", name));
// 发送请求(注意:这里需要将系统消息和用户消息合并)
// 目前 PromptTemplate.create() 只返回用户消息,我们需要手动组合系统消息
// 方案:使用 Prompt 的构造器接受消息列表
List<Message> messages = Arrays.asList(
new SystemMessage(systemMessage),
new UserMessage(prompt.getContents()) // prompt.getContents() 返回渲染后的文本
);
Prompt fullPrompt = new Prompt(messages);
return chatClient.prompt(fullPrompt)
.call()
.content();
}
}
3.3.2 简化版本:使用 builder 方法
上述代码略显冗长。实际上 ChatClient 的 fluent API 支持直接添加系统消息和用户消息,我们可以更简洁:
java
@GetMapping("/simple")
public String greetSimple(@RequestParam String name) {
return chatClient.prompt()
.system("你是一个友好的接待员,用热情的语气问候客人。")
.user("你好,我叫 " + name + ",请向我问好。") // 简单拼接
.call()
.content();
}
但如果想使用模板,可以这样:
java
@GetMapping("/template")
public String greetTemplate(@RequestParam String name) {
PromptTemplate userPromptTemplate = new PromptTemplate("你好,我叫 {{name}},请向我问好。");
Prompt userPrompt = userPromptTemplate.create(Map.of("name", name));
return chatClient.prompt()
.system("你是一个友好的接待员,用热情的语气问候客人。")
.user(userPrompt.getContents()) // 直接使用渲染后的文本
.call()
.content();
}
3.3.3 测试
启动应用,访问:
http://localhost:8080/greet?name=张三http://localhost:8080/greet/simple?name=李四http://localhost:8080/greet/template?name=王五
观察返回的问候语是否符合预期(应该包含名字,且语气热情)。
3.3.4 流程图
3.4 本章小结
通过本章的学习,你掌握了:
- ChatClient 核心 API :
prompt()、user()、system()、call()、content()等。 - 获取完整响应对象:了解如何获取 Token 用量等元数据。
- 系统消息的作用:设定 AI 角色和行为。
- 提示词模板 :使用
PromptTemplate动态构造用户消息,避免硬编码和字符串拼接。 - 实践:构建了一个带角色设定的个性化问候助手。
四、流式响应:实现打字机效果
在前面的章节中,我们使用 call() 方法同步调用大模型,等待模型生成完整回答后才一次性返回结果。这种方式简单直接,但用户体验上存在一个明显的问题:用户需要等待模型完全生成后才能看到内容,对于长回答,等待时间可能长达数秒甚至十几秒,体验不够流畅。
流式响应(Streaming Response)可以很好地解决这个问题。它允许模型一边生成内容,一边将生成的文本块(Token)逐块推送给客户端,客户端可以实时展示,形成"打字机"效果,大大提升了交互的实时感和用户体验。
4.1 为什么需要流式响应?
| 对比维度 | 同步调用(call) | 流式调用(stream) |
|---|---|---|
| 响应时间 | 等待全部生成,延迟较高 | 首字延迟低,边生边推 |
| 用户体验 | 长时间空白等待,用户可能焦虑 | 实时看到内容,体验流畅 |
| 技术实现 | 简单,返回完整字符串 | 较复杂,需处理流式数据 |
| 适用场景 | 后台处理、非实时交互 | 聊天机器人、实时生成 |
在现代 AI 应用中,流式响应已成为标配。Spring AI 内置了对流式响应的支持,通过 ChatClient 的 stream() 方法即可轻松实现。
4.2 Spring AI 流式 API 的使用
ChatClient 提供了 stream() 方法,它返回一个 Reactor Flux 对象(Flux<ChatResponse>)。Flux 是响应式编程中的一种发布者,可以发射 0 到 N 个元素,这里每个元素代表模型生成的一个响应块(可能是一个 Token 或一段文本)。
4.2.1 基本用法
java
Flux<ChatResponse> flux = chatClient.prompt()
.user("讲一个简短的笑话")
.stream();
// 订阅并处理每个响应块
flux.subscribe(chatResponse -> {
String chunk = chatResponse.getResult().getOutput().getContent();
System.out.print(chunk); // 逐块打印,形成打字机效果
});
但在 Web 应用中,我们通常希望将流式数据直接推送给前端,而不是打印到控制台。这就需要结合 Spring WebFlux 的 Server-Sent Events (SSE)。
4.3 结合 Spring WebFlux 实现 SSE
Server-Sent Events 是一种基于 HTTP 的轻量级推送技术,允许服务器向客户端推送文本数据。前端可以使用 JavaScript 的 EventSource API 轻松接收。
4.3.1 引入 WebFlux 依赖
首先,在项目中添加 Spring WebFlux 依赖(如果之前只加了 Spring Web,需要补充):
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
注意:Spring WebFlux 与 Spring Web MVC 可以共存,但流式响应通常使用 WebFlux 的响应式支持。
4.3.2 Controller 返回 Flux
创建 StreamController.java:
java
package com.example.demo;
import org.springframework.ai.chat.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
public class StreamController {
private final ChatClient chatClient;
public StreamController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.map(chatResponse -> chatResponse.getResult().getOutput().getContent());
}
}
这里的关键点:
produces = MediaType.TEXT_EVENT_STREAM_VALUE告诉 Spring 返回的是 SSE 流。stream()返回Flux<ChatResponse>,我们用map将每个ChatResponse转换为文本块(String)。- 最终返回
Flux<String>,Spring 会自动将每个元素包装为 SSE 事件发送给客户端。
4.3.3 前端接收示例
创建一个简单的 HTML 页面(放在 src/main/resources/static/index.html),使用 JavaScript 的 EventSource 接收流:
html
<!DOCTYPE html>
<html>
<head>
<title>AI 流式聊天</title>
</head>
<body>
<h2>流式聊天演示</h2>
<input type="text" id="message" placeholder="输入消息" value="讲一个简短的笑话">
<button onclick="send()">发送</button>
<div id="response" style="margin-top:20px; border:1px solid #ccc; padding:10px; min-height:100px;"></div>
<script>
function send() {
const msg = document.getElementById('message').value;
const responseDiv = document.getElementById('response');
responseDiv.innerHTML = ''; // 清空
// 创建 EventSource 连接到流接口
const eventSource = new EventSource(`/chat/stream?message=${encodeURIComponent(msg)}`);
// 收到消息时追加内容
eventSource.onmessage = function(event) {
responseDiv.innerHTML += event.data;
};
// 错误处理
eventSource.onerror = function() {
responseDiv.innerHTML += '<br>连接关闭或出错';
eventSource.close();
};
// 流结束时(服务端关闭连接),EventSource 会自动触发 onerror 并重连,这里我们主动关闭
// 更好的做法是服务端发送一个特定事件标识结束,但简单起见我们等几秒后关闭
setTimeout(() => {
eventSource.close();
}, 10000);
}
</script>
</body>
</html>
运行应用,访问 http://localhost:8080,点击发送,即可看到文字逐字出现的效果。
4.4 实践:构建流式聊天接口
为了更贴近实际应用,我们来构建一个完整的流式聊天 REST API,并处理一些细节。
4.4.1 增强的 Controller
增加系统消息设定,并处理可能出现的错误:
java
@RestController
@RequestMapping("/api/chat")
public class StreamingChatController {
private final ChatClient chatClient;
public StreamingChatController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamChat(@RequestParam String message) {
return chatClient.prompt()
.system("你是一个友好的聊天助手,用中文回答。")
.user(message)
.stream()
.map(chatResponse -> {
String content = chatResponse.getResult().getOutput().getContent();
// 将每个文本块包装为 SSE 事件
return ServerSentEvent.<String>builder()
.data(content)
.event("message") // 事件类型,前端可监听特定事件
.build();
})
.doOnError(e -> {
// 错误处理,可以记录日志
System.err.println("流式响应出错: " + e.getMessage());
});
}
}
这里使用了 ServerSentEvent 包装,可以指定事件类型、ID 等元数据。前端可以监听特定事件(如 message)来处理数据。
4.4.2 前端监听特定事件
修改前端,监听 message 事件:
javascript
eventSource.addEventListener('message', function(event) {
responseDiv.innerHTML += event.data;
});
这样更清晰。
4.5 流式响应的注意事项
4.5.1 超时设置
SSE 连接可能会因为网络问题或模型响应过慢而超时。可以在 Spring Boot 配置中调整 WebFlux 的超时时间:
yaml
spring:
webflux:
base-path:
mvc:
async:
request-timeout: 60000 # 60秒
或者在代码中设置请求超时(需要更底层的配置)。
4.5.2 背压处理
Flux 支持背压(backpressure),但 SSE 本身是基于 HTTP 的,客户端接收速度可能影响服务端发送。如果客户端处理慢,服务端可以限制发送速率,但通常模型生成速度不会太快,不需要特别处理。
4.5.3 错误处理与重连
EventSource 在连接断开时会自动重连,可能导致重复接收数据。服务端可以在流结束时发送一个特殊标记,前端检测到后主动关闭连接。另一种方式是服务端在流结束后关闭连接(默认行为),前端在 onerror 中判断是否正常结束。
4.5.4 内存泄漏
使用 Flux 时,确保订阅被正确取消,避免内存泄漏。在 Spring WebFlux 中,当客户端断开连接时,框架会自动取消订阅,开发者一般无需操心。
4.6 流式响应流程示意图
4.7 本章小结
通过本章的学习,你掌握了:
- 流式响应的价值:提升用户体验,降低首字延迟。
- Spring AI 流式 API :
stream()方法返回Flux<ChatResponse>。 - 与 WebFlux 集成 :返回
Flux<String>或Flux<ServerSentEvent>,实现 SSE。 - 前端接收 :使用 JavaScript 的
EventSource逐字展示内容。 - 注意事项:超时、错误处理、连接管理。
第五章:结构化输出------让 AI 返回 Java 对象
在前几章中,我们一直让 AI 返回自然语言文本。但在企业级应用中,我们往往需要从 AI 的回答中提取结构化数据,例如从用户描述中提取姓名、年龄、地址,或者让 AI 返回一个包含多个字段的 JSON 对象。手动解析自然语言不仅繁琐,而且容易出错。Spring AI 提供了强大的 结构化输出 功能,可以自动将 AI 的响应转换为 Java 对象。
5.1 场景:从自然语言提取结构化数据
假设我们有一个需求:用户输入一段描述,系统从中提取出人物信息,包括姓名、年龄、城市。我们希望 AI 直接返回一个 Java 对象,而不是让开发者自己写正则表达式或调用其他 NLP 库。
5.2 使用 entity() 方法映射 POJO
Spring AI 的 ChatClient 提供了 .entity(Class<T> type) 方法,它可以:
- 在发送给模型的提示词中,自动要求模型以 JSON 格式返回。
- 将模型返回的 JSON 字符串反序列化为指定类型的 Java 对象。
5.2.1 定义 POJO
首先,我们定义一个简单的 Java 类来接收数据。可以使用普通的 class 或 Java 14+ 的 record。
java
arduino
// 使用 record(推荐,简洁)
public record Person(String name, int age, String city) {}
// 或者使用传统 class
public class Person {
private String name;
private int age;
private String city;
// 必须有无参构造函数和 getter/setter
public Person() {}
// getter/setter...
}
注意:如果使用 class,必须提供无参构造函数和 getter/setter,因为反序列化需要。
5.2.2 使用 entity() 方法
在 Controller 中注入 ChatClient,然后调用 entity():
java
kotlin
@RestController
public class StructuredOutputController {
private final ChatClient chatClient;
public StructuredOutputController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@PostMapping("/extract/person")
public Person extractPerson(@RequestBody String description) {
return chatClient.prompt()
.user(description)
.call()
.entity(Person.class); // 指定目标类型
}
}
当调用此接口时,Spring AI 会自动构建一个内部提示词,要求模型以 JSON 格式返回,然后解析为 Person 对象。
5.2.3 背后原理
Spring AI 内部使用 BeanOutputConverter 来生成格式指令和解析响应。它会在用户消息后附加类似这样的指令:
text
typescript
Your response should be in JSON format.
The response should contain only the JSON object.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Use this schema:
{
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" },
"city": { "type": "string" }
},
"required": ["name", "age", "city"]
}
模型返回的 JSON 会被 Jackson 反序列化为 Person 对象。
5.3 支持的类型
entity() 方法支持多种返回类型:
- 简单 POJO:如上例所示。
- 集合类型 :例如
List<Person>,需要特殊处理,因为泛型擦除。可以使用entity(new ParameterizedTypeReference<List<Person>>() {})。 - 枚举:如果 AI 需要返回固定的枚举值,可以直接指定枚举类。
- 基本类型 :如
String、Integer,但通常直接使用content()即可。
5.3.1 处理集合类型
假设我们需要 AI 返回一个包含多个人的列表:
java
swift
import org.springframework.core.ParameterizedTypeReference;
List<Person> persons = chatClient.prompt()
.user("提取以下文本中的所有人物信息:张三25岁北京,李四30岁上海")
.call()
.entity(new ParameterizedTypeReference<List<Person>>() {});
注意:ParameterizedTypeReference 用于保留泛型信息,避免类型擦除。
5.3.2 处理枚举
定义枚举:
java
arduino
public enum Sentiment {
POSITIVE, NEUTRAL, NEGATIVE
}
使用:
java
scss
Sentiment sentiment = chatClient.prompt()
.user("分析评论的情感:这个产品太棒了!")
.call()
.entity(Sentiment.class);
模型会被要求返回枚举值的字符串表示,如 "POSITIVE",然后自动转换。
5.4 自定义输出转换器
如果默认的 JSON 格式不满足需求,或者需要更精细的控制,可以使用 OutputConverter 接口。Spring AI 提供了 BeanOutputConverter 和 MapOutputConverter 等实现。
5.4.1 使用 BeanOutputConverter 直接构建提示词
你可以手动创建 BeanOutputConverter,获取格式指令,然后添加到提示词中:
java
ini
BeanOutputConverter<Person> converter = new BeanOutputConverter<>(Person.class);
String formatInstructions = converter.getFormatInstructions();
Prompt prompt = new Prompt(new UserMessage("提取人物信息:" + description + "\n" + formatInstructions));
ChatResponse response = chatClient.prompt(prompt).call();
Person person = converter.convert(response.getResult().getOutput().getContent());
这种方式给了你更大的控制权,比如可以自定义格式指令的位置或内容。
5.4.2 使用 MapOutputConverter 获取动态结构
如果返回的 JSON 结构不确定,可以使用 MapOutputConverter 将其转换为 Map:
java
ini
MapOutputConverter converter = new MapOutputConverter();
String formatInstructions = converter.getFormatInstructions();
Prompt prompt = new Prompt(new UserMessage("提取信息:" + description + "\n" + formatInstructions));
ChatResponse response = chatClient.prompt(prompt).call();
Map<String, Object> result = converter.convert(response.getResult().getOutput().getContent());
5.5 实践:从用户描述中提取人员信息
让我们构建一个完整的 REST 服务,接收用户描述,返回结构化的 Person 对象。
5.5.1 创建 Controller
java
kotlin
package com.example.demo;
import org.springframework.ai.chat.ChatClient;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/extract")
public class ExtractionController {
private final ChatClient chatClient;
public ExtractionController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@PostMapping("/person")
public Person extractPerson(@RequestBody String description) {
return chatClient.prompt()
.system("你是一个信息提取助手,从用户描述中提取人物信息,以JSON格式返回。")
.user(description)
.call()
.entity(Person.class);
}
@PostMapping("/persons")
public List<Person> extractPersons(@RequestBody String description) {
return chatClient.prompt()
.system("你是一个信息提取助手,从文本中提取所有人物的姓名、年龄和城市,以JSON数组返回。")
.user(description)
.call()
.entity(new ParameterizedTypeReference<List<Person>>() {});
}
}
5.5.2 测试
使用 curl 测试:
bash
bash
curl -X POST http://localhost:8080/api/extract/person \
-H "Content-Type: text/plain" \
-d "我叫张三,今年28岁,住在上海。"
期望返回:
json
json
{
"name": "张三",
"age": 28,
"city": "上海"
}
测试提取多个:
bash
bash
curl -X POST http://localhost:8080/api/extract/persons \
-H "Content-Type: text/plain" \
-d "张三25岁北京,李四30岁上海,王五35岁广州"
期望返回 JSON 数组。
5.5.3 可能遇到的问题及解决
- 模型返回的 JSON 格式错误 :如果模型偶尔返回非 JSON 内容,
entity()会抛出异常。可以添加错误处理,如使用 try-catch,或要求模型重新生成。 - 字段缺失 :如果描述中缺少某些字段,模型可能不返回该字段。在 POJO 中可以将字段设为可选(如使用
Optional或默认值)。 - 枚举值不匹配 :确保枚举的字符串值与模型返回的一致,可以通过
@JsonProperty或自定义反序列化解决。
5.6 高级:使用 Record 与 Jackson 注解
Spring AI 使用 Jackson 进行 JSON 解析,因此你可以使用 Jackson 注解来控制序列化/反序列化行为。
java
less
import com.fasterxml.jackson.annotation.JsonProperty;
public record Person(
@JsonProperty("full_name") String name,
int age,
String city
) {}
这样模型返回的 JSON 中如果包含 full_name 字段,会自动映射到 name 属性。
5.7 本章小结
通过本章的学习,你掌握了:
- 结构化输出的价值:从自然语言到 Java 对象的自动转换。
entity()方法:直接指定目标类型,简化代码。- 支持的类型:POJO、集合、枚举等。
- 自定义转换器 :
BeanOutputConverter和MapOutputConverter的使用。 - 实践:构建了信息提取服务,并测试了效果。
六、函数调用:赋予 AI 行动能力
在前面的章节中,我们的 AI 应用只能基于模型训练时学到的知识回答问题。如果用户问"现在几点了?"、"帮我查一下订单状态"、"今天天气怎么样",纯文本模型是无法直接获取这些实时信息的------它没有时钟,也无法访问你的数据库。
函数调用(Function Calling)正是为了解决这个问题而生。它允许大模型在需要时"调用"你编写的 Java 方法,获取实时数据或执行操作,然后将结果整合到回答中。
6.1 什么是函数调用?AI 如何调用外部方法?
函数调用的核心思想是:你提供一组工具(Java 方法),并告诉 AI 这些工具的存在、用途以及参数。当 AI 认为需要某个工具来回答问题时,它会返回一个特殊的请求,要求执行该工具并提供参数。你的应用负责执行对应方法,并将结果返回给 AI,AI 再根据结果生成最终回答。
工作流程示意图:
6.2 在 Spring AI 中定义工具
Spring AI 通过 @Tool 注解来标记一个 Bean 的方法作为可调用的工具。
6.2.1 引入依赖
函数调用功能需要额外的依赖(Spring AI 自动包含,但确保版本支持)。如果使用 OpenAI,它原生支持函数调用。无需额外依赖。
6.2.2 定义工具类
创建一个 Spring 组件,其中的方法用 @Tool 注解标记。@Tool 注解需要指定工具的名称和描述,描述会被传递给模型,帮助模型理解何时调用该工具。
java
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
@Component
public class TimeTools {
@Tool(name = "getCurrentTime", description = "获取当前时间")
public String getCurrentTime() {
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
}
}
6.2.3 工具描述的重要性
@Tool 注解的 description 属性非常重要,它告诉 AI 这个工具是做什么的,在什么情况下应该调用。描述越清晰,AI 调用工具的准确率越高。
对于带参数的工具,参数描述也需要提供。Spring AI 会从方法参数名和 Javadoc 中提取描述,也可以使用 @ToolParam 注解(如果有)来显式说明。
6.2.4 带参数的工具
假设我们需要一个查询天气的工具,它需要城市名作为参数:
java
@Component
public class WeatherTools {
@Tool(name = "getWeather", description = "查询指定城市的天气")
public String getWeather(String city) {
// 这里应该是真实的天气 API 调用,为了演示,我们返回模拟数据
return switch (city) {
case "北京" -> "晴,25℃";
case "上海" -> "多云,28℃";
default -> city + "的天气数据暂未收录";
};
}
}
6.3 将工具注册到 ChatClient
我们需要将工具类实例注册到 ChatClient 中,以便它知道有哪些工具可用。
java
@RestController
public class FunctionCallingController {
private final ChatClient chatClient;
// 注入工具类
public FunctionCallingController(ChatClient.Builder chatClientBuilder,
TimeTools timeTools,
WeatherTools weatherTools) {
this.chatClient = chatClientBuilder
.build();
}
@GetMapping("/ask")
public String ask(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
但是上面的代码并没有将工具传递给 ChatClient。我们需要在构建时指定工具:
java
this.chatClient = chatClientBuilder
.defaultTools(timeTools, weatherTools) // 注册默认工具
.build();
或者在每次调用时动态指定工具:
java
return chatClient.prompt()
.user(question)
.tools(timeTools, weatherTools) // 临时指定工具
.call()
.content();
建议使用 defaultTools,这样工具对所有请求都可用。
6.4 实践:让 AI 查询实时信息
让我们构建一个完整的示例,包含两个工具:获取时间和查询天气。我们将创建一个 REST 接口,用户输入问题,AI 自动决定是否调用工具。
6.4.1 完整代码
java
package com.example.demo;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
@RestController
public class AssistantController {
private final ChatClient chatClient;
public AssistantController(ChatClient.Builder chatClientBuilder,
TimeTools timeTools,
WeatherTools weatherTools) {
this.chatClient = chatClientBuilder
.defaultTools(timeTools, weatherTools)
.build();
}
@GetMapping("/assistant")
public String assistant(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
@Component
class TimeTools {
@Tool(name = "getCurrentTime", description = "获取当前时间")
public String getCurrentTime() {
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
}
}
@Component
class WeatherTools {
@Tool(name = "getWeather", description = "查询指定城市的天气,需要传入城市名称")
public String getWeather(String city) {
Map<String, String> mockWeather = Map.of(
"北京", "晴,25℃",
"上海", "多云,28℃",
"广州", "雷阵雨,30℃"
);
return mockWeather.getOrDefault(city, city + "的天气数据暂未收录");
}
}
6.4.2 测试
启动应用,用浏览器或 curl 测试:
http://localhost:8080/assistant?question=现在几点了?http://localhost:8080/assistant?question=上海天气怎么样?http://localhost:8080/assistant?question=帮我查一下北京的天气http://localhost:8080/assistant?question=计算 123+456(没有对应工具,AI 会尝试自己计算或表示无法计算)
6.4.3 观察输出
对于第一个问题,你应该会看到类似"现在是下午2点30分45秒"的回答,说明工具被成功调用。对于第二个问题,应该返回模拟的天气信息。
6.4.4 工作原理
当用户提问时,Spring AI 会:
- 将问题发送给模型,同时附上可用的工具列表(名称、描述、参数)。
- 模型判断是否需要调用工具。如果需要,它会返回一个工具调用请求,包含工具名称和参数。
- Spring AI 接收到请求后,根据工具名称找到对应的 Bean 方法,执行该方法。
- 将工具执行结果返回给模型。
- 模型根据工具结果生成最终回答。
6.5 函数调用的注意事项
6.5.1 工具方法应该是线程安全的
工具类通常是单例 Bean,因此方法需要是线程安全的。上面的例子中,getCurrentTime 是纯函数,安全;getWeather 也是只读操作,安全。如果工具修改了共享状态,需要考虑同步。
6.5.2 工具方法的执行时间
工具方法执行时间不宜过长,因为整个调用是同步的(模型在等待工具结果)。如果工具需要调用外部 API 或数据库,考虑设置超时,或采用异步方式(但 Spring AI 目前主要支持同步工具调用)。
6.5.3 错误处理
工具方法可能抛出异常,Spring AI 会捕获异常并将错误信息返回给模型。模型会根据错误信息决定如何回应(例如提示用户稍后重试)。你可以在工具方法内部处理异常,返回友好的错误消息。
6.5.4 工具数量
不要注册过多无关的工具,因为工具描述会消耗 Token,且可能让模型混淆。只注册必要且描述清晰的工具。
6.5.5 参数类型
工具方法的参数支持基本类型、String、复杂对象等,但模型需要能够生成对应的 JSON。建议使用简单类型,并配合清晰描述。
6.6 本章小结
通过本章的学习,你掌握了:
- 函数调用的概念:让 AI 调用外部方法获取实时信息或执行操作。
- 定义工具 :使用
@Tool注解标记方法,并描述其用途。 - 注册工具 :通过
defaultTools或tools()将工具注入ChatClient。 - 实践:构建了能查时间和天气的智能助手。
- 注意事项:线程安全、超时、错误处理。
七、多模态探索:图像与语音模型
在前面的章节中,我们所有的交互都基于文本。但现实世界的信息是多样的------图片、语音、视频......大语言模型正在向多模态进化,能够理解和生成非文本内容。Spring AI 也紧跟潮流,提供了对图像和语音模型的支持,让你可以轻松构建能够"看懂"图片、"听懂"语音的智能应用。
本章将带你探索 Spring AI 的多模态能力,包括让 AI 描述图片内容、将文本转为语音、以及将语音转为文本。虽然多模态功能还在快速发展中,但掌握基础用法可以为你的应用打开更广阔的场景。
7.1 Spring AI 对多模态的支持现状
Spring AI 目前主要支持以下多模态能力:
- 图像生成 :通过
ImageModel接口,支持 DALL-E、Stable Diffusion 等图像生成模型。 - 图像理解 :通过支持多模态的
ChatModel(如 OpenAI 的 GPT-4 Vision),可以在聊天中发送图片,让 AI 描述或分析图片。 - 语音 :通过
AudioModel接口,支持文本转语音(TTS)和语音识别(ASR),例如 OpenAI 的 TTS 模型和 Whisper 模型。
需要注意的是,多模态功能需要模型本身支持 。本章示例将使用 OpenAI 的相关模型(如 gpt-4-vision-preview、tts-1、whisper-1),你需要拥有 OpenAI API 密钥并确保账户有权限访问这些模型。如果使用其他服务商,原理类似,只需调整配置。
7.2 图像模型(ImageModel)的使用
ImageModel 是 Spring AI 中用于图像生成的接口。目前主要支持文本生成图像(text-to-image)。如果你需要让 AI 描述图片(图像理解),则需要使用支持视觉的 ChatModel。
我们先介绍图像生成,再介绍图像理解。
7.2.1 配置图像模型
在 application.yml 中添加图像模型的配置:
yaml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
image:
options:
model: dall-e-3 # 或 dall-e-2
quality: standard # 仅 dall-e-3 支持
size: 1024x1024 # 图片尺寸
n: 1 # 生成图片数量
Spring AI 会根据配置自动创建 ImageModel 的 Bean。
7.2.2 图像生成示例
创建一个 ImageController,接收文本提示词,返回生成的图片 URL。
java
package com.example.demo;
import org.springframework.ai.image.ImageClient;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.ai.openai.OpenAiImageOptions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ImageController {
private final ImageClient imageClient;
public ImageController(ImageClient imageClient) {
this.imageClient = imageClient;
}
@GetMapping("/generate-image")
public String generateImage(@RequestParam String prompt) {
// 创建图像生成请求,可以覆盖默认选项
ImagePrompt imagePrompt = new ImagePrompt(prompt,
OpenAiImageOptions.builder()
.withModel("dall-e-3")
.withQuality("hd") // 高清
.withN(1)
.withHeight(1024)
.withWidth(1024)
.build());
ImageResponse response = imageClient.call(imagePrompt);
// 返回生成的图片 URL
return response.getResult().getOutput().getUrl();
}
}
测试:访问 http://localhost:8080/generate-image?prompt=a cute cat,会返回一个图片 URL,打开即可看到生成的图片。
7.2.3 图像理解(让 AI 描述图片)
图像理解需要使用支持视觉的聊天模型,如 OpenAI 的 gpt-4-vision-preview。在聊天中,我们可以将图片作为用户消息的一部分发送。
配置支持视觉的聊天模型:
yaml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4-turbo # 或 gpt-4-vision-preview
构造包含图片的用户消息:
Spring AI 的 Message 接口支持多种内容类型,包括文本和图片。我们可以创建一个 UserMessage,包含文本和图片 URL。
java
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.Media;
import org.springframework.util.MimeTypeUtils;
// 构造包含图片的用户消息
UserMessage userMessage = new UserMessage("请描述这张图片的内容",
new Media(MimeTypeUtils.IMAGE_JPEG, "https://example.com/cat.jpg"));
然后调用 ChatClient 处理这个 Prompt。
完整示例:
java
@RestController
public class VisionController {
private final ChatClient chatClient;
public VisionController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@GetMapping("/describe-image")
public String describeImage(@RequestParam String imageUrl) {
UserMessage userMessage = new UserMessage("请用中文详细描述这张图片的内容",
new Media(MimeTypeUtils.IMAGE_JPEG, imageUrl));
Prompt prompt = new Prompt(userMessage);
return chatClient.prompt(prompt).call().content();
}
}
测试时传入一张图片的 URL,例如: http://localhost:8080/describe-image?imageUrl=https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg
AI 会返回对图片的描述。
注意 :Media 的 MIME 类型需要根据实际图片类型设置,可以是 IMAGE_JPEG、IMAGE_PNG、IMAGE_GIF 等。对于本地图片,你需要先将图片上传到可访问的 URL,或者将图片转为 Base64 编码后作为 data URL 传入(OpenAI 支持 data URL 格式)。
7.3 语音模型(AudioModel)的使用
Spring AI 通过 AudioModel 接口支持文本转语音(TTS)和语音识别(ASR)。目前主要实现是 OpenAI 的 TTS 和 Whisper 模型。
7.3.1 配置语音模型
yaml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
audio:
options:
model: tts-1 # 或 tts-1-hd
voice: alloy # 语音风格 (alloy, echo, fable, onyx, nova, shimmer)
response-format: mp3 # 输出格式
Spring AI 会自动创建 AudioModel 的 Bean。
7.3.2 文本转语音(TTS)
创建一个接口,将文本转为语音文件并返回给客户端。
java
import org.springframework.ai.audio.AudioModel;
import org.springframework.ai.audio.AudioPrompt;
import org.springframework.ai.audio.AudioResponse;
import org.springframework.ai.openai.OpenAiAudioOptions;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TtsController {
private final AudioModel audioModel;
public TtsController(AudioModel audioModel) {
this.audioModel = audioModel;
}
@GetMapping(value = "/tts", produces = "audio/mpeg")
public byte[] textToSpeech(@RequestParam String text) {
// 创建语音选项
OpenAiAudioOptions options = OpenAiAudioOptions.builder()
.withModel("tts-1")
.withVoice("alloy")
.withResponseFormat("mp3")
.build();
AudioPrompt prompt = new AudioPrompt(text, options);
AudioResponse response = audioModel.call(prompt);
// 获取音频资源的字节数组
Resource audioResource = response.getResult().getOutput();
try {
return audioResource.getContentAsByteArray();
} catch (Exception e) {
throw new RuntimeException("读取音频失败", e);
}
}
}
返回类型设置为 audio/mpeg,浏览器会直接播放音频。你可以用前端 <audio> 标签播放,或者直接访问接口测试。
7.3.3 语音识别(ASR)
使用 OpenAI Whisper 模型将语音文件转为文本。首先需要能够接收文件上传。
添加 Spring Boot 的文件上传支持(已在 web starter 中)。
java
import org.springframework.ai.audio.AudioModel;
import org.springframework.ai.audio.AudioPrompt;
import org.springframework.ai.audio.AudioResponse;
import org.springframework.ai.openai.OpenAiAudioOptions;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController
public class AsrController {
private final AudioModel audioModel;
public AsrController(AudioModel audioModel) {
this.audioModel = audioModel;
}
@PostMapping(value = "/asr", produces = "text/plain")
public String speechToText(@RequestParam("file") MultipartFile file) throws IOException {
// 将上传的文件转换为 Resource
org.springframework.core.io.ByteArrayResource audioResource =
new org.springframework.core.io.ByteArrayResource(file.getBytes());
// 创建 ASR 选项
OpenAiAudioOptions options = OpenAiAudioOptions.builder()
.withModel("whisper-1")
.withResponseFormat("text")
.build();
AudioPrompt prompt = new AudioPrompt(audioResource, options);
AudioResponse response = audioModel.call(prompt);
return response.getResult().getOutput().toString(); // 返回识别出的文本
}
}
使用 Postman 或前端表单上传音频文件(如 mp3、wav)进行测试。
7.4 实践:构建图片描述服务
将图像理解功能封装成一个完整的 REST 服务,并添加一些错误处理和日志。
7.4.1 创建 Service 层
java
package com.example.demo.service;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.Media;
import org.springframework.stereotype.Service;
import org.springframework.util.MimeTypeUtils;
@Service
public class ImageDescriptionService {
private final ChatClient chatClient;
public ImageDescriptionService(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
public String describeImage(String imageUrl, String question) {
UserMessage userMessage = new UserMessage(question,
new Media(MimeTypeUtils.IMAGE_JPEG, imageUrl));
Prompt prompt = new Prompt(userMessage);
return chatClient.prompt(prompt).call().content();
}
// 重载方法,使用默认问题
public String describeImage(String imageUrl) {
return describeImage(imageUrl, "请详细描述这张图片的内容,包括主体、颜色、动作、背景等。");
}
}
7.4.2 创建 Controller
java
package com.example.demo.controller;
import com.example.demo.service.ImageDescriptionService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ImageDescriptionController {
private final ImageDescriptionService descriptionService;
public ImageDescriptionController(ImageDescriptionService descriptionService) {
this.descriptionService = descriptionService;
}
@GetMapping("/api/describe-image")
public String describeImage(@RequestParam String url,
@RequestParam(required = false) String question) {
if (question == null || question.isBlank()) {
return descriptionService.describeImage(url);
} else {
return descriptionService.describeImage(url, question);
}
}
}
7.4.3 测试
启动应用,访问: http://localhost:8080/api/describe-image?url=https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg
你应该会得到一段详细的图片描述。
7.4.4 处理本地图片
如果图片在本地,你需要将其转为 Base64 的 data URL。可以编写一个工具方法:
java
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
public static String imageFileToDataUrl(String filePath) throws IOException {
Path path = Path.of(filePath);
String mimeType = Files.probeContentType(path);
byte[] bytes = Files.readAllBytes(path);
String base64 = Base64.getEncoder().encodeToString(bytes);
return "data:" + mimeType + ";base64," + base64;
}
然后将生成的 data URL 传入描述接口。
7.5 本章小结
通过本章的学习,你探索了 Spring AI 的多模态能力:
- 图像生成 :使用
ImageModel从文本生成图片。 - 图像理解 :通过支持视觉的
ChatModel,将图片作为消息的一部分发送,让 AI 描述图片。 - 语音 :使用
AudioModel实现文本转语音(TTS)和语音识别(ASR)。 - 实践:构建了图片描述服务,可以分析任意公开图片。
八、构建企业级知识库:RAG 实现(上)
在前面的章节中,我们已经能够与 AI 进行流畅的对话,甚至让 AI 调用外部工具获取实时信息。但是,当用户问到公司内部的规章制度、产品文档、技术规范等私有知识时,AI 就无能为力了------因为它从未见过这些资料。
RAG(Retrieval-Augmented Generation,检索增强生成) 正是为了解决这个问题而生。它允许 AI 在回答问题时,先从你的私有知识库中检索相关文档片段,然后基于这些片段生成答案,从而将模型的知识边界扩展到你的企业内部资料。
本章将带你一步步构建一个基于私有知识库的问答系统,涵盖从文档加载、分割、向量化到存储的完整知识摄入流程。下一章我们将实现检索与生成部分。
8.1 RAG 核心概念回顾
RAG 的标准流程分为两大阶段:
- 知识摄入(Ingestion):将原始文档(PDF、TXT、Word 等)处理成可供检索的格式,并存入向量数据库。
- 问答检索(Retrieval & Generation):用户提问时,先从向量数据库中检索相关文档片段,然后将这些片段作为上下文与问题一起发送给大模型,生成最终答案。
为什么需要 RAG?
- 知识时效性:大模型的知识截止日期之后的信息,它不知道。
- 私有知识:公司内部文档、产品手册等,模型从未见过。
- 减少幻觉:基于检索到的真实文档生成答案,大大降低编造的可能。
- 可解释性:可以引用来源,增强用户信任。
RAG 整体流程示意图:
8.2 Spring AI 的向量存储抽象(VectorStore)
Spring AI 提供了一个统一的 VectorStore 接口,用于抽象各种向量数据库的操作。目前支持的实现包括:
- PGvector:PostgreSQL 的向量插件
- Milvus / Zilliz Cloud:专业的向量数据库
- Redis:通过 Redis Stack 的向量搜索能力
- Elasticsearch:通过 Elasticsearch 的向量检索功能
- Neo4j:图数据库的向量支持
- Azure Cosmos DB 、Pinecone 、Qdrant 等
在本教程中,我们将使用 PGvector ,因为它可以与关系数据共存,且对 Java 开发者非常友好。如果你没有 PostgreSQL 环境,也可以使用简单的内存实现(如 SimpleVectorStore)进行测试,但生产环境建议使用持久化存储。
8.2.1 配置向量数据库(以 PGvector 为例)
首先,在你的项目中引入 PGvector 的 Spring Boot Starter:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
然后,在 application.yml 中配置数据库连接:
yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/postgres
username: postgres
password: postgres
ai:
vectorstore:
pgvector:
index-type: HNSW # 索引类型
distance-type: COSINE # 距离度量方式
dimensions: 1536 # 向量维度(取决于嵌入模型,OpenAI ada-002 是 1536)
Spring AI 会自动创建一个 VectorStore 的 Bean,可以直接注入使用。
如果你没有 PostgreSQL 环境,可以先用内存存储调试:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-simple-vector-store</artifactId>
<version>${spring-ai.version}</version>
</dependency>
然后无需配置,Spring Boot 会自动创建一个 SimpleVectorStore 的 Bean。
8.3 文档处理与嵌入
在将文档存入向量数据库之前,需要进行一系列处理:加载文档、分割成块、转换为向量。
8.3.1 文档加载器(DocumentReader)
Spring AI 提供了多种文档读取器,用于从不同格式的文件中提取文本。目前支持:
- TextReader:读取纯文本文件(.txt)
- JsonReader:读取 JSON 文件
- PagePdfDocumentReader:读取 PDF 文件(基于 Apache PDFBox)
- MarkdownDocumentReader:读取 Markdown 文件
首先引入 PDF 解析器的依赖(如果需要处理 PDF):
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
<version>${spring-ai.version}</version>
</dependency>
然后,使用 PagePdfDocumentReader 加载 PDF:
java
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
// 加载 PDF 文件
Resource pdfResource = new UrlResource("file:///path/to/document.pdf");
PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(pdfResource);
List<Document> documents = pdfReader.get();
对于纯文本文件,可以使用 TextReader:
java
import org.springframework.ai.reader.TextReader;
Resource textResource = new UrlResource("file:///path/to/knowledge.txt");
TextReader textReader = new TextReader(textResource);
// 可以设置元数据,如文件名
textReader.setCustomMetadata("source", "knowledge.txt");
List<Document> documents = textReader.get();
Document 对象包含文本内容和元数据(如文件名、页码等),后续会用于分割和嵌入。
8.3.2 文档分割器(DocumentSplitter)
大模型对输入长度有限制,且检索时需要小块才能精确匹配。因此需要将文档切分成多个段落。Spring AI 提供了 DocumentSplitter 接口,常用实现有:
- TokenTextSplitter:基于 Token 数分割(推荐,因为模型按 Token 计费)
- SentenceSplitter:按句子分割
- RecursiveTextSplitter:递归分割,尝试保持段落完整
使用 TokenTextSplitter 的示例:
java
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
// 创建分割器,设置每块最大 Token 数和重叠 Token 数
TokenTextSplitter splitter = new TokenTextSplitter(500, 100, 5, 5000, true);
List<Document> splitDocuments = splitter.apply(documents);
参数说明:
chunkSize:每块的最大 Token 数(通常 500-1000 比较合适)chunkOverlap:相邻块之间的重叠 Token 数,避免切在关键位置丢失上下文- 其他参数可以保持默认
8.3.3 嵌入客户端(EmbeddingClient)
嵌入模型将文本转换为向量。Spring AI 提供了 EmbeddingClient 接口,支持多种实现:
- OpenAiEmbeddingClient :使用 OpenAI 的
text-embedding-ada-002模型 - OllamaEmbeddingClient:使用本地 Ollama 嵌入模型
- AzureOpenAiEmbeddingClient:Azure 版本
在配置文件中配置嵌入客户端(以 OpenAI 为例):
yaml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
embedding:
options:
model: text-embedding-ada-002
Spring AI 会自动创建 EmbeddingClient 的 Bean,可以直接注入使用。
8.4 知识摄入实践
现在我们将以上组件组合起来,完成一个完整的知识摄入流程。
8.4.1 创建摄入服务
java
package com.example.demo.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class IngestionService {
private final EmbeddingClient embeddingClient;
private final VectorStore vectorStore;
public IngestionService(EmbeddingClient embeddingClient, VectorStore vectorStore) {
this.embeddingClient = embeddingClient;
this.vectorStore = vectorStore;
}
/**
* 摄入文本文件
* @param fileResource 文件资源
* @param sourceName 来源名称(用于元数据)
*/
public void ingestTextFile(Resource fileResource, String sourceName) {
// 1. 读取文档
TextReader textReader = new TextReader(fileResource);
textReader.setCustomMetadata("source", sourceName);
List<Document> documents = textReader.get();
// 2. 分割文档
TokenTextSplitter splitter = new TokenTextSplitter(500, 100, 5, 5000, true);
List<Document> splitDocuments = splitter.apply(documents);
// 3. 计算向量并存储
// 注意:VectorStore 的 add 方法内部会调用 embeddingClient 生成向量
vectorStore.add(splitDocuments);
System.out.println("成功摄入 " + splitDocuments.size() + " 个文档片段");
}
}
这里的关键是 vectorStore.add(documents) 方法。它会自动调用 embeddingClient 为每个 Document 生成向量,并将文档与向量一起存储到底层数据库。
8.4.2 在应用启动时摄入知识
我们可以在 Spring Boot 启动后自动加载一些预定义的知识文档。创建一个 IngestionRunner 实现 ApplicationRunner 接口:
java
package com.example.demo;
import com.example.demo.service.IngestionService;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
@Component
public class IngestionRunner implements ApplicationRunner {
private final IngestionService ingestionService;
public IngestionRunner(IngestionService ingestionService) {
this.ingestionService = ingestionService;
}
@Override
public void run(ApplicationArguments args) throws Exception {
// 从 classpath 加载 knowledge.txt 文件
ClassPathResource resource = new ClassPathResource("knowledge.txt");
ingestionService.ingestTextFile(resource, "knowledge.txt");
}
}
在 src/main/resources 目录下创建 knowledge.txt 文件,内容可以是你的私有知识,例如:
Spring AI 是一个为 Java 开发者设计的 AI 集成框架,由 Spring 官方团队开发。
它提供了统一的 API 来对接大语言模型,如 OpenAI、Azure、Ollama 等。
RAG(检索增强生成)是 Spring AI 的核心功能之一,可以帮助企业构建基于私有知识库的问答系统。
Spring AI 的 VectorStore 抽象支持多种向量数据库,包括 PGvector、Milvus、Redis 等。
使用 Spring AI,开发者可以像调用普通方法一样使用 AI 能力,极大地简化了开发。
启动应用,你会看到控制台输出摄入成功的日志。
8.4.3 验证摄入结果
为了确保文档已经成功存入向量数据库,我们可以编写一个简单的查询测试:
java
package com.example.demo.controller;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.stream.Collectors;
@RestController
public class VectorStoreTestController {
private final VectorStore vectorStore;
private final EmbeddingClient embeddingClient;
public VectorStoreTestController(VectorStore vectorStore, EmbeddingClient embeddingClient) {
this.vectorStore = vectorStore;
this.embeddingClient = embeddingClient;
}
@GetMapping("/test-search")
public List<String> testSearch(@RequestParam String query) {
// 创建搜索请求,返回最相似的 3 个文档
SearchRequest request = SearchRequest.query(query).withTopK(3);
List<Document> results = vectorStore.similaritySearch(request);
return results.stream()
.map(doc -> "【相似度: " + doc.getScore() + "】\n" + doc.getContent())
.collect(Collectors.toList());
}
}
访问 /test-search?query=什么是RAG,你应该能看到检索到的文档片段及其相似度得分。
8.5 本章小结
通过本章的学习,你已经掌握了 RAG 的核心第一步------知识摄入:
- 向量存储抽象 :Spring AI 的
VectorStore统一了多种向量数据库的操作。 - 文档加载 :使用
DocumentReader从不同格式文件中提取文本。 - 文档分割 :用
TokenTextSplitter将长文档切成小块。 - 嵌入与存储 :通过
VectorStore.add()自动完成向量化并存入数据库。 - 实践:编写了摄入服务,并在应用启动时自动加载知识文档。