云计算百科
云计算领域专业知识百科平台

Spring AI 入门:构建第一个 Java AI 应用

摘要

直接使用 HTTP 客户端调用大语言模型可以快速验证想法,但随着项目进入真实业务,开发者通常还要处理消息抽象、模型切换、Prompt 模板、上下文、结构化输出、流式响应、工具调用和测试等问题。每个业务模块都自己实现一套调用逻辑,会让代码越来越难以维护。

Spring AI 为 Spring 应用提供了一层面向 AI 能力的统一抽象,把模型调用、聊天客户端、Embedding、向量存储、工具调用和上下文增强等能力放入熟悉的 Spring Boot 开发方式中。它并不会替开发者解决所有业务问题,但可以减少供应商 API 与业务代码之间的耦合。

本文从一个最小 Spring Boot 项目开始,介绍 Spring AI 的基本概念、依赖配置、ChatClient、同步调用、流式输出、Prompt 模板、结构化结果、Advisor、会话上下文和测试。文章最后会实现一个“技术助手”接口,并讨论如何把示例逐步演进为生产级 AI 服务。

由于 Spring AI 与 Spring Boot 都在持续迭代,本文重点放在稳定的设计思路和核心使用方式。实际项目必须锁定版本,并根据所使用的版本核对 Starter 名称、配置前缀和 API 签名。

读完本文后,你应该能够:

  • 理解 Spring AI 解决的问题和适用边界;
  • 创建一个能够调用聊天模型的 Spring Boot 项目;
  • 使用 ChatClient 完成同步和流式对话;
  • 通过 Prompt 模板管理系统指令和动态参数;
  • 使用结构化输出将模型结果映射为 Java 对象;
  • 理解 Advisor 在上下文增强和调用链中的作用;
  • 为 AI 调用编写单元测试和集成测试;
  • 识别从 Demo 进入生产环境时还需要补齐的能力。

一、背景与问题

1. 直接调用模型 API 的方式

最直接的 Java 接入方式是构造 HTTP 请求:

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/chat/completions"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();

这种方式适合:

  • 验证供应商接口;
  • 了解请求和响应格式;
  • 编写非常小的内部工具;
  • 供应商 SDK 尚未覆盖的特殊能力。

但业务代码很快会出现大量重复:

  • 每个类都拼接消息列表;
  • 每个接口都处理模型响应;
  • 每个模块都自己解析 JSON;
  • 每个供应商都使用不同的请求对象;
  • 超时、重试和日志配置散落在各处。

2. Spring AI 的定位

Spring AI 的核心定位是应用层抽象,而不是一个“自动完成 AI 产品”的平台。

它主要帮助开发者统一以下能力:

聊天模型
-> ChatModel / ChatClient
Prompt
-> PromptTemplate / Prompt
Embedding
-> EmbeddingModel
向量存储
-> VectorStore
工具调用
-> Tool Callback
调用增强
-> Advisor

业务代码可以依赖这些抽象,具体模型供应商通过 Starter 和配置接入。

3. 为什么 Java 项目需要统一抽象

企业项目经常会出现这些变化:

  • 供应商从云模型切换到兼容接口;
  • 不同场景使用不同模型;
  • 某个模型临时限流;
  • 需要增加本地模型;
  • 需要统一记录 Token 和费用;
  • 需要在所有请求前加入安全检查;
  • 需要把历史对话注入 Prompt;
  • 需要为输出增加结构校验。

如果业务直接依赖某个供应商的 SDK,变化会沿着调用链扩散。统一抽象可以把变化限制在配置和适配层。

4. Spring AI 不会替代业务架构

引入 Spring AI 后,仍然需要自己设计:

  • 用户和会话;
  • 权限;
  • Prompt 版本;
  • 业务校验;
  • 数据脱敏;
  • 超时和降级;
  • 成本控制;
  • 日志和监控;
  • 评估集;
  • 高风险操作审批。

Spring AI 解决的是“如何在 Spring 应用中组织 AI 能力”,不是“如何自动构建生产级 AI 系统”。

5. 版本变化需要被认真对待

Spring AI 的 Starter 名称、配置属性和 API 可能随版本变化。项目中应该:

  • 明确 Spring Boot 版本;
  • 锁定 Spring AI BOM 或依赖版本;
  • 不混用不同版本的示例;
  • 将模型配置集中管理;
  • 在升级前运行集成测试;
  • 记录供应商和模型能力差异。

文章中的代码展示的是核心使用方式。正式项目中,应以当前锁定版本的官方文档和编译结果为准。

二、核心概念

1. ChatModel

ChatModel 表示聊天模型能力。它负责接收消息或 Prompt,并返回模型响应。

可以把它理解为:

Prompt
-> ChatModel
-> ChatResponse

不同供应商可以提供不同的 ChatModel 实现。业务代码如果只依赖这个抽象,就不必直接处理供应商响应结构。

2. ChatClient

ChatClient 是更适合应用开发的流式调用接口。它通常通过 Builder 创建,并提供链式 API:

String answer = chatClient
.prompt()
.user("请解释什么是依赖注入")
.call()
.content();

ChatClient 负责把常见的调用步骤组织起来:

  • 设置系统消息;
  • 设置用户消息;
  • 绑定参数;
  • 添加 Advisor;
  • 调用模型;
  • 获取文本或对象;
  • 进行流式输出。

3. Prompt

Prompt 可以分为三部分:

系统指令
+ 用户输入
+ 动态上下文

系统指令定义角色、边界和输出要求:

你是一个 Java 后端技术助手。
回答应优先给出可验证的方案。
如果信息不足,请明确说明假设。
不要编造不存在的 API。

用户输入是当前任务:

请解释 Spring 中的依赖注入。

动态上下文可以是:

  • 历史对话;
  • 检索文档;
  • 用户偏好;
  • 业务数据;
  • 工具结果。

4. PromptTemplate

当 Prompt 中存在动态变量时,可以使用模板:

你是一个{role}。
请用{language}回答下面的问题:

{question}

模板的价值在于:

  • 提示词与代码分离;
  • 变量更清晰;
  • 易于复用;
  • 便于版本管理;
  • 便于测试不同参数。

5. ChatResponse

模型响应通常不仅包含文本,还可能包含:

  • 完成原因;
  • Token 用量;
  • 模型名称;
  • 工具调用;
  • 原始供应商响应;
  • 请求 ID。

业务系统应该保留必要的元信息,用于:

  • 计费;
  • 监控;
  • 调试;
  • 成本核算;
  • 质量评估。

6. Streaming

非流式调用等待模型生成完整答案:

String content = chatClient
.prompt()
.user("写一段 Java 示例")
.call()
.content();

流式调用边生成边返回:

Flux<String> stream = chatClient
.prompt()
.user("写一篇较长的技术说明")
.stream()
.content();

流式方式可以降低首字延迟,但需要处理:

  • 客户端断开;
  • 模型错误;
  • 结束事件;
  • 内容持久化;
  • 代理缓冲;
  • 连接超时。

7. Structured Output

如果业务需要 Java 对象,可以让模型结果映射到目标类型:

TicketClassification result = chatClient
.prompt()
.user("请分类这个工单:" + text)
.call()
.entity(TicketClassification.class);

模型输出仍然不是绝对可信的。映射成功后,还需要检查:

  • 枚举值;
  • 必填字段;
  • 数值范围;
  • 业务状态;
  • 权限和风险。

8. Advisor

Advisor 可以理解为模型调用链中的可组合增强组件。它可以用于:

  • 注入对话历史;
  • 添加检索上下文;
  • 记录调用;
  • 做安全检查;
  • 修改请求;
  • 处理响应;
  • 连接多个调用步骤。

调用链可以表示为:

应用请求
-> Advisor 1:上下文
-> Advisor 2:检索
-> Advisor 3:日志
-> ChatModel
-> Advisor:响应处理
-> 应用结果

Advisor 适合放通用横切逻辑,但核心业务规则仍应放在明确的业务服务中。

9. EmbeddingModel 与 VectorStore

EmbeddingModel 将文本转换为向量:

文档文本
-> EmbeddingModel
-> 向量

VectorStore 保存并检索向量:

用户问题
-> 问题向量
-> VectorStore 相似度搜索
-> 相关文档

这两个能力是后续构建 RAG 的基础。本文先聚焦 ChatClient,后续文章再深入知识库和向量数据库。

10. Tool Calling

Tool Calling 允许模型提出结构化工具调用请求,应用程序负责真正执行:

用户问题
-> 模型判断需要工具
-> 返回工具名称和参数
-> Java 服务校验权限和参数
-> 执行工具
-> 把结果交给模型
-> 生成最终回答

模型只能建议调用工具,不能绕过 Java 服务直接访问数据库、文件系统或业务接口。

三、工作原理

1. Spring Boot 自动配置

引入对应 Starter 后,Spring Boot 可以根据配置创建模型客户端和相关 Bean:

application.yml
-> Starter
-> 模型连接配置
-> ChatModel Bean
-> ChatClient.Builder
-> 业务服务注入 ChatClient

自动配置降低了样板代码,但也意味着需要理解:

  • 配置是否生效;
  • Bean 是否创建;
  • 当前使用的是哪个模型;
  • 自定义 Bean 是否覆盖默认 Bean;
  • 多模型时如何区分客户端。

2. Maven 依赖

以 Maven 为例,项目通常需要使用 Spring AI 的 BOM 管理版本,并引入对应模型 Starter。

示例结构:

<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>

<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>

具体 Artifact 名称需要以当前版本文档为准。升级 Spring AI 时,不要只修改一个版本号后直接发布,应该先运行编译、单元测试和模型集成测试。

3. 基础配置

常见配置形式如下:

spring:
ai:
openai:
api-key: ${OPENAI_API_KEY:}
base-url: ${OPENAI_BASE_URL:https://api.example.com}
chat:
options:
model: ${OPENAI_MODEL:defaultmodel}
temperature: 0.2
max-tokens: 1024

不同版本或不同模型提供商的配置前缀可能不同。配置应该通过环境变量或 Secret 注入:

$env:OPENAI_API_KEY = "replace-with-secret"

不要把真实 Key 写进 application.yml、Dockerfile、测试样例或提交记录。

4. 注入 ChatClient.Builder

在 Spring Boot 应用中,可以通过 Builder 创建客户端:

@Configuration
public class AiClientConfig {

@Bean
ChatClient chatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("""
你是一个严谨的 Java 后端技术助手。
如果无法确定,请明确说明不确定性。
"""
)
.build();
}
}

也可以保留 Builder,在不同业务中创建多个具有不同默认配置的 ChatClient:

@Bean
ChatClient supportChatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("你是企业客服助手。")
.build();
}

@Bean
ChatClient codingChatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("你是 Java 代码审查助手。")
.build();
}

当存在多个同类型 Bean 时,需要使用 @Qualifier 或明确的 Bean 名称。

5. 同步调用

最小调用:

@RestController
@RequestMapping("/api/ai")
public class AiController {

private final ChatClient chatClient;

public AiController(ChatClient chatClient) {
this.chatClient = chatClient;
}

@GetMapping("/ask")
public String ask(@RequestParam String question) {
return chatClient
.prompt()
.user(question)
.call()
.content();
}
}

这个示例可以验证配置和连接是否正常,但不适合直接作为生产代码。生产环境还需要:

  • 参数校验;
  • 认证;
  • 超时;
  • 错误转换;
  • 调用日志;
  • 内容安全;
  • 输出长度限制;
  • 成本控制。

6. 设置系统消息和用户消息

String answer = chatClient
.prompt()
.system("""
你是 Java 后端技术助手。
回答要包含原理、示例和适用边界。
不要编造不存在的类或方法。
"""
)
.user("""
请解释 Spring 中的依赖注入,并给出一个简单示例。
"""
)
.call()
.content();

如果系统消息稳定,建议通过 defaultSystem 或 Prompt 模板统一管理,不要在不同 Controller 中重复写一套相似提示词。

7. 使用动态变量

String answer = chatClient
.prompt()
.system("""
你是一个{role}。
你的回答风格是{style}。
"""
)
.user("""
用户问题:{question}
"""
)
.param("role", "Java 后端架构师")
.param("style", "简洁、准确、带代码示例")
.param("question", question)
.call()
.content();

动态参数来自用户输入时,仍然需要限制长度和做安全处理。模板变量不是权限边界,也不能让用户覆盖系统规则。

8. 绑定 Java 对象

定义输出对象:

public record CodeReviewResult(
String summary,
List<String> issues,
List<String> suggestions,
String severity
) {
}

调用:

CodeReviewResult result = chatClient
.prompt()
.user("""
请审查下面的 Java 代码:

{code}

返回结构化结果。
""")
.param("code", sourceCode)
.call()
.entity(CodeReviewResult.class);

生产环境应该对结果进行二次验证:

private void validate(CodeReviewResult result) {
Set<String> allowed = Set.of("LOW", "MEDIUM", "HIGH");
if (!allowed.contains(result.severity())) {
throw new InvalidAiOutputException("未知严重级别");
}
}

9. 使用 Advisor

Advisor 的 API 可能随着 Spring AI 版本变化,使用时应以当前版本接口为准。概念上,可以把上下文增强放入 Advisor:

ChatClient chatClient = builder
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory)
)
.build();

调用时传递会话标识:

String answer = chatClient
.prompt()
.user("继续解释上一个问题")
.advisors(spec -> spec.param(
"conversationId",
conversationId
))
.call()
.content();

Advisor 的关键价值是把通用上下文处理从业务代码中抽离。对于生产项目,还应明确:

  • 哪些消息可以注入;
  • 历史消息保留多少;
  • 是否需要摘要;
  • 是否需要脱敏;
  • 不同用户之间如何隔离;
  • 会话 ID 是否来自可信身份。

10. 流式调用

返回 Flux<String>:

@GetMapping(
value = "/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux<String> stream(
@RequestParam String question
) {
return chatClient
.prompt()
.user(question)
.stream()
.content();
}

更推荐返回结构化事件:

public record ChatEvent(
String type,
String content,
String messageId
) {
}

控制器:

@GetMapping(
value = "/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux<ServerSentEvent<ChatEvent>> stream(
@RequestParam String question,
@RequestHeader("X-Request-ID") String requestId
) {
return service.stream(question, requestId)
.map(event -> ServerSentEvent.builder(event).build());
}

事件类型可以包括:

message.started
message.delta
message.error
message.completed

11. ChatClient 与 ChatModel 的选择

ChatClient 更适合业务应用:

chatClient.prompt()
.user(question)
.call()
.content();

直接使用 ChatModel 更接近底层:

ChatResponse response = chatModel.call(
new Prompt(List.of(
new UserMessage(question)
))
);

可以这样选择:

场景建议
普通业务聊天 ChatClient
Prompt 和 Advisor 编排 ChatClient
统一底层适配 ChatModel
自定义调用管线 ChatModel
需要读取完整元数据 根据版本选择 ChatModel 或响应对象

业务层优先使用 ChatClient,基础设施层可以使用 ChatModel。

12. 错误处理

模型调用可能产生:

  • 网络连接失败;
  • 认证失败;
  • 限流;
  • 请求超时;
  • 上下文超限;
  • 内容安全拒绝;
  • 响应解析失败;
  • 供应商服务异常。

应用层应该统一转换:

try {
return chatClient
.prompt()
.user(question)
.call()
.content();
} catch (Exception ex) {
log.error("AI request failed", ex);
throw new AiServiceUnavailableException(
"AI 服务暂时不可用",
ex
);
}

不要把供应商的原始错误、API Key、请求体或内部堆栈直接返回给用户。

四、实战示例

本节实现一个“Java 技术助手”接口,支持普通问答、结构化代码审查和流式回答。

1. 项目结构

spring-ai-demo/
├── src/main/java/com/example/ai/
│ ├── AiDemoApplication.java
│ ├── config/
│ │ └── AiClientConfig.java
│ ├── chat/
│ │ ├── ChatController.java
│ │ ├── ChatService.java
│ │ └── ChatRequest.java
│ ├── review/
│ │ ├── CodeReviewController.java
│ │ ├── CodeReviewService.java
│ │ └── CodeReviewResult.java
│ └── common/
│ ├── AiExceptionHandler.java
│ └── RequestIdFilter.java
├── src/main/resources/
│ └── application.yml
└── pom.xml

2. 创建 ChatClient

@Configuration
public class AiClientConfig {

@Bean
ChatClient technicalChatClient(
ChatClient.Builder builder
) {
return builder
.defaultSystem("""
你是一个 Java 后端技术助手。
你的回答应该:
1. 先说明结论;
2. 解释核心原理;
3. 给出必要的 Java 示例;
4. 指出适用边界和常见问题。

如果问题依赖具体版本,请说明需要核对版本。
不要编造不存在的 API。
""")
.build();
}
}

3. 定义请求对象

public record ChatRequest(
@NotBlank
@Size(max = 4000)
String question
) {
}

输入限制非常重要。用户输入过长会增加成本、延迟和上下文超限风险。

4. 实现聊天服务

@Service
public class ChatService {

private final ChatClient chatClient;

public ChatService(
@Qualifier("technicalChatClient")
ChatClient chatClient
) {
this.chatClient = chatClient;
}

public String answer(String question) {
return chatClient
.prompt()
.user(question)
.call()
.content();
}

public Flux<String> stream(String question) {
return chatClient
.prompt()
.user(question)
.stream()
.content();
}
}

服务层暂时比较简单,但已经把 Controller 和 AI 客户端隔离开。后续可以在这里加入会话、权限、日志和上下文策略。

5. 实现普通问答接口

@RestController
@RequestMapping("/api/chat")
public class ChatController {

private final ChatService chatService;

public ChatController(ChatService chatService) {
this.chatService = chatService;
}

@PostMapping
public Map<String, String> chat(
@Valid @RequestBody ChatRequest request
) {
String answer = chatService.answer(
request.question()
);
return Map.of(
"answer",
answer
);
}
}

请求示例:

POST /api/chat
Content-Type: application/json

{
"question": "Spring 中的 Bean 生命周期是什么?"
}

响应:

{
"answer": "Spring Bean 生命周期通常包括实例化、属性填充、初始化和销毁等阶段…"
}

6. 实现流式接口

@GetMapping(
value = "/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux<ServerSentEvent<ChatEvent>> stream(
@RequestParam
@Size(max = 4000)
String question
) {
String messageId = UUID.randomUUID().toString();

Flux<ServerSentEvent<ChatEvent>> started =
Flux.just(event(
"message.started",
"",
messageId
));

Flux<ServerSentEvent<ChatEvent>> deltas =
chatService.stream(question)
.map(content -> event(
"message.delta",
content,
messageId
));

Flux<ServerSentEvent<ChatEvent>> completed =
Flux.just(event(
"message.completed",
"",
messageId
));

return Flux.concat(
started,
deltas,
completed
);
}

private ServerSentEvent<ChatEvent> event(
String type,
String content,
String messageId
) {
return ServerSentEvent.builder(
new ChatEvent(type, content, messageId)
).build();
}

生产代码还需要使用 doOnCancel、doOnError 和 doFinally 记录取消、异常和清理行为。

7. 实现结构化代码审查

输出对象:

public record CodeReviewResult(
String summary,
List<Issue> issues,
List<String> suggestions,
String severity
) {
}

public record Issue(
String line,
String problem,
String suggestion
) {
}

服务:

@Service
public class CodeReviewService {

private final ChatClient chatClient;

public CodeReviewService(ChatClient chatClient) {
this.chatClient = chatClient;
}

public CodeReviewResult review(String sourceCode) {
CodeReviewResult result = chatClient
.prompt()
.system("""
你是 Java 代码审查助手。
只指出能够从代码本身验证的问题。
如果没有明显问题,issues 返回空数组。
severity 只能是 LOW、MEDIUM、HIGH。
"""
)
.user("""
请审查以下代码,并返回结构化结果:

{code}
""")
.param("code", sourceCode)
.call()
.entity(CodeReviewResult.class);

validate(result);
return result;
}

private void validate(CodeReviewResult result) {
Set<String> levels = Set.of(
"LOW",
"MEDIUM",
"HIGH"
);
if (!levels.contains(result.severity())) {
throw new InvalidAiOutputException(
"未知的严重级别"
);
}
}
}

8. 实现审查接口

public record CodeReviewRequest(
@NotBlank
@Size(max = 30000)
String sourceCode
) {
}

@RestController
@RequestMapping("/api/code-review")
public class CodeReviewController {

private final CodeReviewService service;

@PostMapping
public CodeReviewResult review(
@Valid @RequestBody CodeReviewRequest request
) {
return service.review(request.sourceCode());
}
}

这个接口的关键不是“模型一定审查正确”,而是让结果具备:

  • 明确结构;
  • 可校验字段;
  • 可记录版本;
  • 可以接入人工复核;
  • 可以建立评估样本。

9. 增加会话上下文

定义会话消息:

public record ConversationMessage(
String role,
String content
) {
}

服务中组装消息:

public String answer(
String question,
List<ConversationMessage> history
) {
PromptUserSpec userSpec = promptSpec -> {
promptSpec.text(question);
promptSpec.messages(history.stream()
.map(this::toMessage)
.toList());
};

return chatClient
.prompt()
.messages(history.stream()
.map(this::toMessage)
.toList())
.user(question)
.call()
.content();
}

具体 Builder 方法可能随版本变化,实际使用时应以当前版本编译结果为准。更重要的设计原则是:历史消息必须由后端加载、裁剪和授权,不能由客户端直接提交任意系统消息。

10. 增加 Advisor

将通用日志或上下文处理放入 Advisor,可以避免每个业务方法重复处理:

@Bean
ChatClient technicalChatClient(
ChatClient.Builder builder,
ChatObservationAdvisor observationAdvisor
) {
return builder
.defaultAdvisors(observationAdvisor)
.defaultSystem("""
你是 Java 后端技术助手。
"""
)
.build();
}

如果使用对话记忆 Advisor,需要在调用时传递隔离标识:

String answer = chatClient
.prompt()
.user(question)
.advisors(advisorSpec -> advisorSpec
.param("conversationId", conversationId)
.param("userId", userId))
.call()
.content();

关键是保证:

  • conversationId 不能跨用户访问;
  • 历史消息有最大长度;
  • 记忆内容经过脱敏;
  • 失败时不会把历史上下文泄露给其他会话。

11. 统一异常处理

@RestControllerAdvice
public class AiExceptionHandler {

@ExceptionHandler(InvalidAiOutputException.class)
ResponseEntity<ApiError> handleOutput(
InvalidAiOutputException ex
) {
return ResponseEntity
.unprocessableEntity()
.body(new ApiError(
"AI_OUTPUT_INVALID",
"模型输出无法通过校验"
));
}

@ExceptionHandler(AiServiceUnavailableException.class)
ResponseEntity<ApiError> handleUnavailable(
AiServiceUnavailableException ex
) {
return ResponseEntity
.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ApiError(
"AI_SERVICE_UNAVAILABLE",
"AI 服务暂时不可用"
));
}
}

不要把供应商异常原文直接返回给前端。内部日志需要保留完整异常、请求 ID 和 Trace ID。

12. 编写单元测试

业务服务应该 Mock ChatClient 或更低层的模型接口:

@ExtendWith(MockitoExtension.class)
class ChatServiceTest {

@Mock
private ChatClient chatClient;

@Test
void shouldReturnAnswer() {
ChatService service = new ChatService(chatClient);

// 实际项目中可对 ChatClient fluent API
// 使用测试适配器,或在更低层 Mock ChatModel。
assertThat(service).isNotNull();
}
}

如果直接 Mock 链式 API 过于复杂,可以在业务层依赖自定义接口:

public interface AiTextGenerator {

String generate(String system, String user);

Flux<String> stream(String system, String user);
}

生产实现使用 ChatClient,测试使用 Fake 实现:

class FakeAiTextGenerator implements AiTextGenerator {

@Override
public String generate(String system, String user) {
return "fake answer";
}

@Override
public Flux<String> stream(String system, String user) {
return Flux.just("fake", " answer");
}
}

这种方式能避免业务测试过度依赖某个流式 Builder 细节。

13. 使用 Mock Server 做集成测试

模型客户端集成测试应该验证:

  • 请求 URL;
  • Authorization Header;
  • 请求消息;
  • 模型参数;
  • 响应映射;
  • 流式事件;
  • 超时和错误。

可以使用 WireMock 或 MockWebServer 返回固定响应:

{
"id": "test-request-1",
"choices": [
{
"message": {
"role": "assistant",
"content": "测试回答"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 20,
"completion_tokens": 10,
"total_tokens": 30
}
}

集成测试不应默认调用真实供应商,因为真实调用会带来成本、延迟和结果不稳定问题。

五、常见问题与实践建议

1. Starter 引入后 Bean 没有创建

排查顺序:

  • Spring Boot 与 Spring AI 版本是否兼容;
  • Starter Artifact 是否正确;
  • API Key 是否通过正确配置前缀注入;
  • 当前 Profile 是否生效;
  • 是否存在条件自动配置未满足;
  • 是否被自定义 Bean 覆盖;
  • 启动日志是否有配置绑定错误。
  • 不要只看“项目能启动”,还要检查实际注入的模型和配置。

    2. ChatClient 和 ChatModel 混用导致理解混乱

    建议分层:

    业务层:
    ChatClient

    基础设施层:
    ChatModel

    供应商适配层:
    Starter 或自定义客户端

    除非需要非常底层的响应元数据,否则业务代码不必直接操作供应商对象。

    3. 直接把用户输入拼进系统消息

    不建议:

    String system = "你必须执行以下用户指令:" + userInput;

    用户输入应该作为用户消息或不可信上下文传入。系统消息只放可信的应用规则。

    4. 结构化输出解析成功不代表业务正确

    模型返回合法 JSON,只能说明格式正确,不能证明:

    • 分类符合业务规则;
    • 数值正确;
    • 用户有权限;
    • 状态可以迁移;
    • 操作可以执行。

    结构化解析后仍需业务校验。

    5. 流式接口出现内容重复

    常见原因:

    • 服务端重复拼接片段;
    • 客户端重连后未去重;
    • 代理重试导致事件重复;
    • 同一个订阅被消费多次;
    • 完成事件被当成普通内容。

    建议给每个事件增加:

    • messageId;
    • 序号;
    • 事件类型;
    • Trace ID。

    客户端根据消息 ID 和序号处理重复事件。

    6. 流式输出结束后没有保存完整消息

    流式内容应该同时面向两个目标:

    实时目标:
    片段发送给客户端

    持久化目标:
    完整助手消息保存到数据库

    建议在流结束时保存聚合内容;如果内容很长,可以按片段批量保存,但要保证最终状态明确。

    7. 让模型自己拼接 SQL 或 URL

    不要把数据库连接、任意 SQL 或任意 HTTP 请求暴露给模型。应该提供受控工具:

    read_order_summary(order_id)
    search_public_documents(query)
    create_support_ticket(summary)

    每个工具都需要权限、参数和资源范围校验。

    8. 过度依赖 Advisor

    Advisor 适合横切能力,但不适合隐藏关键业务流程。

    例如以下逻辑不应全部藏进 Advisor:

    • 是否允许退款;
    • 是否需要人工审批;
    • 是否可以读取某张业务表;
    • 是否完成订单状态迁移。

    这些规则应该在明确的业务服务或工具网关中可见、可测试。

    9. 测试里调用真实模型

    真实模型测试适合少量验收或评估,不适合每次单元测试。

    常规测试应该:

    • Fake;
    • Mock;
    • Mock Server;
    • 固定响应;
    • 录制回放。

    真实模型测试需要单独的评估集、成本预算和失败处理。

    10. 没有限制输入和输出长度

    至少需要限制:

    • HTTP 请求体;
    • 用户消息长度;
    • 历史上下文;
    • 检索文档数量;
    • 模型最大输出;
    • 单次会话消息数。

    否则会出现高成本、长延迟、上下文溢出和拒绝服务风险。

    11. 把 API Key 放在前端

    浏览器或移动端不应该直接持有供应商 API Key。正确链路是:

    客户端
    -> 你的 Java 后端
    -> 模型供应商

    后端负责认证、配额、脱敏、审计和模型路由。

    12. 使用默认模型配置直接上线

    开发时可以使用简单默认配置,生产环境需要明确:

    • 模型名称;
    • temperature;
    • 最大输出;
    • 超时;
    • 重试;
    • 供应商;
    • 费用价格;
    • 数据处理策略;
    • Prompt 版本。

    模型参数应该有版本和变更记录。

    13. 同一个 ChatClient 承担所有场景

    客服、代码审查、数据分析和写作任务的系统规则、模型参数和权限通常不同。可以按场景创建不同 ChatClient 或业务客户端:

    supportChatClient
    codingChatClient
    analysisChatClient

    底层模型可以相同,但默认 Prompt、Advisor 和工具权限应分开。

    六、进阶思考

    1. 统一模型服务接口

    即使使用 Spring AI,也建议在业务边界定义自己的接口:

    public interface AiTextService {

    AiTextResult complete(
    AiScenario scenario,
    AiInput input
    );

    Flux<AiChunk> stream(
    AiScenario scenario,
    AiInput input
    );
    }

    这样可以统一:

    • 模型路由;
    • Token 统计;
    • 重试;
    • 脱敏;
    • 业务场景;
    • 错误转换;
    • 审计。

    业务代码不必到处注入 ChatClient。

    2. 多模型路由

    路由层可以根据场景选择不同 ChatClient:

    public ChatClient route(AiScenario scenario) {
    return switch (scenario) {
    case CUSTOMER_SUPPORT -> supportChatClient;
    case CODE_REVIEW -> codingChatClient;
    case LONG_DOCUMENT -> longContextChatClient;
    };
    }

    实际路由还可以考虑:

    • 当前模型健康状态;
    • 输入 Token 数;
    • 成本预算;
    • 数据敏感等级;
    • 供应商限流;
    • 用户等级。

    3. Prompt 的版本化

    Prompt 应该记录:

    prompt_id
    version
    内容
    创建人
    变更说明
    测试集结果
    发布日期
    回滚版本

    运行记录中保存 Prompt 版本,出现质量回归时才能比较:

    Prompt v1:知识库引用缺失
    Prompt v2:引用完整,但回答长度增加
    Prompt v3:成本下降,但复杂问题正确率下降

    4. Context Window 管理

    上下文预算可以按比例分配:

    系统指令:10%
    用户问题:10%
    历史摘要:20%
    最近消息:25%
    检索内容:25%
    输出预留:10%

    实际比例需要通过评估调整。上下文越多不一定越好,相关性和信息质量比数量更重要。

    5. 记忆与数据库分离

    会话消息是业务数据,应该保存到关系数据库;模型用于生成时的上下文是运行时数据,可以由数据库、缓存和摘要服务共同提供。

    不要把模型记忆能力当成数据可靠性保证。需要长期保存的事实应该进入结构化数据库或受控知识库。

    6. 结构化输出和业务状态机

    模型输出可以作为状态机的输入:

    模型:
    intent = REFUND
    confidence = 0.91

    业务代码:
    检查订单状态
    检查用户权限
    检查退款窗口
    请求确认
    执行退款

    模型提供理解结果,代码决定是否允许状态迁移。

    7. 生产级观测

    Spring Boot 应用可以结合 Micrometer 和 OpenTelemetry 记录:

    ai.request.count
    ai.request.latency
    ai.request.error
    ai.input.tokens
    ai.output.tokens
    ai.cost
    ai.stream.first_token_latency
    ai.output.parse_failure
    ai.tool.call.count

    监控维度包括:

    • 模型;
    • 供应商;
    • 业务场景;
    • 租户;
    • 用户等级;
    • Prompt 版本;
    • 成功或失败;
    • 是否重试。

    8. 数据脱敏

    发送给模型前可以对以下内容脱敏:

    • 手机号;
    • 邮箱;
    • 身份证号;
    • 银行账号;
    • API Key;
    • 内部 IP;
    • 客户姓名;
    • 订单地址。

    脱敏需要考虑可恢复性。如果模型任务需要把结果映射回原始用户,可以使用内部占位符:

    张三 -> <USER_001>
    13800000000 -> <PHONE_001>

    模型返回后由后端在受控范围内恢复。

    9. 权限和租户隔离

    每个 ChatClient 调用都应该关联:

    tenantId
    userId
    conversationId
    scenario
    traceId

    权限控制至少覆盖:

    • 用户能否访问会话;
    • 用户能否读取知识库;
    • 用户能否使用某个模型;
    • 用户能否调用某个工具;
    • 用户能否查看调用日志;
    • 用户能否修改 Prompt。

    10. 评估和回归

    Spring AI 接入完成后,要建立真实样本集:

    基础问答
    + 版本相关问题
    + 无法回答的问题
    + Prompt 注入
    + 敏感信息
    + 结构化输出
    + 流式异常
    + 工具调用

    评估不仅看答案,还看:

    • 是否使用正确模型;
    • 是否调用正确工具;
    • Token 是否超预算;
    • 延迟是否达标;
    • 安全规则是否生效;
    • 输出是否能通过 Schema。

    11. 何时不使用 Spring AI

    以下场景可以考虑直接使用 HTTP 或供应商 SDK:

    • 只有一个简单调用;
    • 需要供应商最新的特殊能力;
    • 项目不是 Spring 生态;
    • 对请求级细节有极强控制要求;
    • 正在快速验证接口格式。

    但即使直接调用,也建议保留自己的业务抽象,避免让供应商类型渗透整个系统。

    12. 从 Demo 到生产的演进路线

    阶段一:
    ChatClient + 一个同步接口

    阶段二:
    配置、异常、输入输出校验

    阶段三:
    会话、历史消息、流式输出

    阶段四:
    Advisor、RAG、结构化输出

    阶段五:
    工具调用、权限、审批

    阶段六:
    评估、成本、监控、灰度和多模型

    每一步都应有对应测试和验收标准。

    结论

    Spring AI 的价值在于把大模型能力带入熟悉的 Spring Boot 编程模型,并通过 ChatModel、ChatClient、Prompt、Advisor、Embedding、VectorStore 和 Tool Calling 等抽象,减少业务代码对具体供应商接口的直接耦合。

    构建第一个 Java AI 应用时,可以遵循以下路线:

    1. 锁定 Spring Boot 和 Spring AI 版本
    2. 引入对应模型 Starter
    3. 通过环境变量注入密钥
    4. 创建 ChatClient
    5. 实现同步问答
    6. 增加输入和输出校验
    7. 增加流式响应
    8. 管理会话和上下文
    9. 引入 Advisor 或 RAG
    10. 增加日志、指标、测试和成本统计

    需要始终记住:Spring AI 可以简化模型接入,但模型输出仍然具有不确定性。关键业务状态、权限判断、数据写入和高风险操作必须由确定性代码负责,不能直接把模型回答当成可信业务指令。

    下一篇将继续介绍如何在 Spring Boot 中实现流式 AI 对话,重点讨论 SSE、响应式流、消息持久化、断线重连、取消请求和前后端事件协议。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Spring AI 入门:构建第一个 Java AI 应用
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!