摘要
直接使用 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:default–model}
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 没有创建
排查顺序:
不要只看“项目能启动”,还要检查实际注入的模型和配置。
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、响应式流、消息持久化、断线重连、取消请求和前后端事件协议。
网硕互联帮助中心






评论前必须登录!
注册