第1862篇:Spring Boot 3 + Spring AI的完整项目模板——生产就绪的脚手架设计
第1862篇:Spring Boot 3 + Spring AI的完整项目模板——生产就绪的脚手架设计
每次开新项目,最烦的不是写业务逻辑,而是搭脚手架。特别是 AI 项目,涉及的技术栈不少:模型调用、向量存储、流式响应、提示词管理、监控告警……每个都要处理,漏掉一个上了生产就是麻烦。
我自己做了五六个 Spring AI 项目之后,逐渐沉淀出了一套"能打仗"的项目模板。所谓生产就绪,不是说代码写完能跑,而是说它能抗住真实用户流量、能被运维监控、能在出问题时快速定位、能在迭代时不乱。
这篇文章就把这套模板完整分享出来,涵盖目录结构设计、核心配置、关键组件的写法,以及一些只有在实际项目中才会踩到的坑。
一、先想清楚目标
很多人搭脚手架的方式是:先把代码跑起来,然后发现缺什么加什么,最后项目越来越乱。
我的做法是反过来:先想清楚生产环境会有哪些需求,然后倒推脚手架需要包含什么。
一个面向生产的 Spring AI 项目,通常需要应对这些场景:
- 多环境(dev/test/prod)配置隔离
- API Key 等敏感配置的安全管理
- AI 请求的超时和重试
- 流式响应的稳定性
- 请求日志和 token 用量追踪
- 健康检查和监控指标
- 向量存储的初始化和管理
- 错误处理的统一规范
这些如果在搭脚手架时不考虑,后期补救的成本会很高。下面的模板就是以这些需求为出发点设计的。
二、项目结构设计
spring-ai-scaffold/
├── src/
│ ├── main/
│ │ ├── java/com/example/aiapp/
│ │ │ ├── AiApplication.java # 启动类
│ │ │ ├── config/
│ │ │ │ ├── AiConfig.java # AI 模型和客户端配置
│ │ │ │ ├── VectorStoreConfig.java # 向量存储配置
│ │ │ │ ├── WebConfig.java # Web 层配置(CORS、序列化等)
│ │ │ │ └── SecurityConfig.java # 安全配置
│ │ │ ├── advisor/
│ │ │ │ ├── TokenUsageAdvisor.java # Token 用量统计 Advisor
│ │ │ │ ├── RateLimitAdvisor.java # 限流 Advisor
│ │ │ │ └── AuditLogAdvisor.java # 审计日志 Advisor
│ │ │ ├── controller/
│ │ │ │ ├── ChatController.java # 对话接口
│ │ │ │ └── EmbeddingController.java # Embedding 接口
│ │ │ ├── service/
│ │ │ │ ├── ChatService.java # 对话服务
│ │ │ │ ├── RagService.java # RAG 检索服务
│ │ │ │ └── DocumentService.java # 文档处理服务
│ │ │ ├── domain/
│ │ │ │ ├── ChatRequest.java # 请求 DTO
│ │ │ │ ├── ChatResponse.java # 响应 DTO
│ │ │ │ └── ApiResult.java # 统一响应包装
│ │ │ ├── exception/
│ │ │ │ ├── AiException.java # AI 相关异常
│ │ │ │ └── GlobalExceptionHandler.java # 全局异常处理
│ │ │ └── util/
│ │ │ ├── PromptUtils.java # 提示词工具
│ │ │ └── TokenCounter.java # Token 计数工具
│ │ └── resources/
│ │ ├── application.yml # 基础配置
│ │ ├── application-dev.yml # 开发环境配置
│ │ ├── application-prod.yml # 生产环境配置
│ │ └── prompts/ # 提示词模板目录
│ │ ├── system-default.st # 默认 system 提示词
│ │ └── rag-query.st # RAG 查询提示词
│ └── test/
│ ├── java/com/example/aiapp/
│ │ ├── controller/
│ │ │ └── ChatControllerTest.java
│ │ ├── service/
│ │ │ └── ChatServiceTest.java
│ │ └── advisor/
│ │ └── TokenUsageAdvisorTest.java
│ └── resources/
│ └── application-test.yml
└── pom.xml这个结构的设计原则是:关注点分离要彻底。config、advisor、controller、service 各司其职,不要把业务逻辑写进 config,也不要在 controller 里直接用 ChatClient。
三、pom.xml 依赖配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
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.3.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>spring-ai-scaffold</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<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>
<!-- Spring Boot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<!-- 向量存储:按需选择,这里用 PgVector -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
<!-- 监控 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- 工具 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>四、核心配置文件
application.yml(基础配置):
spring:
application:
name: spring-ai-scaffold
profiles:
active: dev
# AI 配置基础结构(敏感值在各环境配置中覆盖)
ai:
openai:
chat:
options:
model: gpt-4o
temperature: 0.7
max-tokens: 2000
embedding:
options:
model: text-embedding-3-small
# Actuator 配置
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
metrics:
tags:
application: ${spring.application.name}
# 应用级配置
app:
ai:
# 请求超时(毫秒)
timeout: 30000
# 最大重试次数
max-retries: 3
# 流式响应 SSE 超时(毫秒)
stream-timeout: 60000
rate-limit:
# 每分钟每用户最大请求数
requests-per-minute: 60
logging:
level:
com.example.aiapp: INFO
org.springframework.ai: WARNapplication-dev.yml(开发环境):
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY:sk-test-placeholder}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
logging:
level:
com.example.aiapp: DEBUG
org.springframework.ai: DEBUG
# 开发环境放宽限制
app:
rate-limit:
requests-per-minute: 600application-prod.yml(生产环境):
spring:
ai:
openai:
# 生产环境 API Key 通过环境变量注入,不写死在配置文件
api-key: ${OPENAI_API_KEY}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
# 生产环境日志级别
logging:
level:
com.example.aiapp: INFO
org.springframework.ai: WARN
file:
name: /var/log/ai-app/application.log五、AiConfig 核心配置类
@Configuration
@EnableConfigurationProperties(AiProperties.class)
public class AiConfig {
private final AiProperties aiProperties;
private final TokenUsageAdvisor tokenUsageAdvisor;
private final AuditLogAdvisor auditLogAdvisor;
public AiConfig(AiProperties aiProperties,
TokenUsageAdvisor tokenUsageAdvisor,
AuditLogAdvisor auditLogAdvisor) {
this.aiProperties = aiProperties;
this.tokenUsageAdvisor = tokenUsageAdvisor;
this.auditLogAdvisor = auditLogAdvisor;
}
@Bean
public ChatClient chatClient(ChatClient.Builder builder,
ChatMemory chatMemory) {
return builder
.defaultSystem(loadSystemPrompt())
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory),
tokenUsageAdvisor,
auditLogAdvisor
)
.build();
}
@Bean
public ChatMemory chatMemory() {
// 生产环境建议换成 Redis 或数据库实现
return new InMemoryChatMemory();
}
@Bean
public RetryTemplate aiRetryTemplate() {
return RetryTemplate.builder()
.maxAttempts(aiProperties.getMaxRetries())
.exponentialBackoff(1000, 2.0, 10000)
.retryOn(Exception.class)
.build();
}
private String loadSystemPrompt() {
// 从 classpath 加载提示词模板
try {
Resource resource = new ClassPathResource("prompts/system-default.st");
return resource.getContentAsString(StandardCharsets.UTF_8);
} catch (IOException e) {
return "你是一个专业的AI助手,请用中文回答用户的问题。";
}
}
}
@ConfigurationProperties(prefix = "app.ai")
@Data
public class AiProperties {
private long timeout = 30000;
private int maxRetries = 3;
private long streamTimeout = 60000;
}六、统一响应结构和异常处理
这是很多团队搭脚手架时忽略的部分,上了生产后前端反馈"AI接口报错格式和其他接口不一样"。
@Data
@Builder
public class ApiResult<T> {
private int code;
private String message;
private T data;
private long timestamp;
public static <T> ApiResult<T> success(T data) {
return ApiResult.<T>builder()
.code(200)
.message("success")
.data(data)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> ApiResult<T> error(int code, String message) {
return ApiResult.<T>builder()
.code(code)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(AiException.class)
public ResponseEntity<ApiResult<Void>> handleAiException(AiException e) {
log.error("AI服务异常: {}", e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(ApiResult.error(503, "AI服务暂时不可用,请稍后重试"));
}
@ExceptionHandler(RateLimitExceededException.class)
public ResponseEntity<ApiResult<Void>> handleRateLimit(RateLimitExceededException e) {
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.body(ApiResult.error(429, "请求频率超限,请稍后再试"));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResult<Void>> handleValidation(MethodArgumentNotValidException e) {
String errorMessage = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResult.error(400, errorMessage));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResult<Void>> handleGeneral(Exception e) {
log.error("未预期的异常: {}", e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResult.error(500, "服务器内部错误"));
}
}七、ChatController 的正确写法
@RestController
@RequestMapping("/api/v1/chat")
@Validated
@Slf4j
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
/**
* 普通对话接口
*/
@PostMapping
public ResponseEntity<ApiResult<ChatResponseDTO>> chat(
@RequestBody @Valid ChatRequestDTO request,
@AuthenticationPrincipal UserDetails userDetails) {
String userId = userDetails.getUsername();
String result = chatService.chat(userId, request.getConversationId(),
request.getMessage());
return ResponseEntity.ok(ApiResult.success(
new ChatResponseDTO(result, request.getConversationId())
));
}
/**
* 流式对话接口(SSE)
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamChat(
@RequestBody @Valid ChatRequestDTO request,
@AuthenticationPrincipal UserDetails userDetails) {
String userId = userDetails.getUsername();
return chatService.streamChat(userId, request.getConversationId(),
request.getMessage())
.map(content -> ServerSentEvent.<String>builder()
.data(content)
.build())
.concatWith(Flux.just(ServerSentEvent.<String>builder()
.event("done")
.data("[DONE]")
.build()))
.onErrorResume(e -> {
log.error("流式响应异常 userId={}", userId, e);
return Flux.just(ServerSentEvent.<String>builder()
.event("error")
.data("服务异常,请稍后重试")
.build());
});
}
}八、Token 用量追踪 Advisor
这个 Advisor 非常实用,生产环境必备,用来统计每次调用消耗的 token 数量:
@Component
@Slf4j
public class TokenUsageAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
private final MeterRegistry meterRegistry;
public TokenUsageAdvisor(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest,
CallAroundAdvisorChain chain) {
AdvisedResponse response = chain.nextAroundCall(advisedRequest);
recordTokenUsage(response.response());
return response;
}
@Override
public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest,
StreamAroundAdvisorChain chain) {
return chain.nextAroundStream(advisedRequest)
.doOnNext(response -> {
if (response.response() != null) {
recordTokenUsage(response.response());
}
});
}
private void recordTokenUsage(ChatResponse response) {
if (response == null || response.getMetadata() == null) return;
Usage usage = response.getMetadata().getUsage();
if (usage != null) {
log.info("Token用量 - 输入:{}, 输出:{}, 总计:{}",
usage.getPromptTokens(),
usage.getGenerationTokens(),
usage.getTotalTokens());
// 上报 Prometheus 指标
meterRegistry.counter("ai.token.usage", "type", "prompt")
.increment(usage.getPromptTokens());
meterRegistry.counter("ai.token.usage", "type", "completion")
.increment(usage.getGenerationTokens());
}
}
@Override
public String getName() {
return "TokenUsageAdvisor";
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}九、提示词模板管理
生产项目的提示词不要硬编码在 Java 里,单独放在 resources/prompts/ 目录下,方便运营同学调整:
resources/prompts/system-default.st:
你是「AI助手」,专注于为用户提供专业、准确的信息。
行为准则:
1. 回答要简洁清晰,避免冗余
2. 不确定的内容要如实说明
3. 涉及专业领域时给出参考来源
4. 全程使用中文回答resources/prompts/rag-query.st:
请基于以下背景知识回答用户问题。
背景知识:
{context}
用户问题:{question}
要求:
- 优先使用背景知识中的信息
- 如果背景知识中没有相关内容,如实告知
- 不要编造不存在的信息加载提示词的工具类:
@Component
public class PromptUtils {
@Value("classpath:prompts/rag-query.st")
private Resource ragQueryTemplate;
public String buildRagPrompt(String context, String question) {
try {
String template = ragQueryTemplate.getContentAsString(StandardCharsets.UTF_8);
return template
.replace("{context}", context)
.replace("{question}", question);
} catch (IOException e) {
throw new RuntimeException("加载提示词模板失败", e);
}
}
}十、Docker Compose 本地开发环境
开发环境的基础设施用 Docker Compose 管理,避免各开发环境不一致:
version: '3.8'
services:
# PostgreSQL + pgvector
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: aiapp
POSTGRES_USER: aiapp
POSTGRES_PASSWORD: aiapp123
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
# Redis(用于生产级对话历史存储)
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
pgdata:十一、几个容易忽视的细节
细节1:流式接口要显式设置超时
@Bean
public WebClient webClient() {
return WebClient.builder()
.codecs(configurer ->
configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
.build();
}细节2:Spring AI 的 ChatMemory 不要跨用户共享
InMemoryChatMemory 是一个 Map,key 是 conversationId。如果不同用户用相同的 conversationId,会串对话。要在业务层确保 conversationId 包含用户身份信息:
public String buildConversationId(String userId, String sessionId) {
return userId + ":" + sessionId;
}细节3:生产环境的对话历史要设置过期时间
InMemoryChatMemory 没有过期机制,长期运行会内存泄漏。生产环境要实现一个 Redis 版本的 ChatMemory,并设置 TTL。
这套脚手架虽然不是最简洁的,但它经过了多个项目的验证。"生产就绪"不是一句空话,每一个组件的存在都有理由。复制粘贴用,比从零搭能省不少时间。
