Spring AI 1.0 实战:从原理到落地的完整指南
前言
去年我们团队在做一个智能客服项目时,需要在Java应用中集成大语言模型能力。当时Spring AI还没发布1.0版本,我们踩了不少坑。现在Spring AI 1.0已经发布,API稳定了很多,我来分享一下在生产环境中的实战经验。
为什么选择Spring AI
在Spring AI出现之前,要在Java项目中使用AI模型,通常需要:
- 直接调用各个AI平台的HTTP API(OpenAI、Azure、千帆等)
- 使用LangChain4j(功能强大但学习曲线陡峭)
- 自己封装SDK(维护成本高)
Spring AI的优势在于:
- 统一的API抽象层,切换模型提供商只需要改配置
- 与Spring生态无缝集成(依赖注入、配置管理、响应式编程)
- 提供了完整的RAG(检索增强生成)支持
- 社区活跃,文档完善
核心概念解析
1. Model API统一抽象
Spring AI最核心的设计是ChatModel接口,它统一了所有聊天模型的操作:
public interface ChatModel {
ChatResponse call(Prompt prompt);
Flux<ChatResponse> stream(Prompt prompt);
}
不同厂商的实现:
OpenAiChatModel- OpenAI GPT系列AzureOpenAiChatModel- Azure OpenAIQianFanChatModel- 百度千帆OllamaChatModel- 本地Ollama部署的开源模型
这种设计让我们在切换模型时,业务代码完全不用改。
2. Prompt模板化
在实际项目中,提示词(Prompt)往往是多行的、带变量的。Spring AI提供了PromptTemplate:
String template = """
你是一个专业的Java技术顾问。
请回答用户关于{technology}的问题。
要求:
1. 回答要准确、专业
2. 提供代码示例
3. 说明适用场景
用户问题:{question}
""";
PromptTemplate promptTemplate = new PromptTemplate(template);
Prompt prompt = promptTemplate.create(
Map.of("technology", "Spring AI", "question", "如何集成向量数据库?")
);
我们项目中用这个特性实现了动态提示词管理,把模板存在数据库里,支持运营人员在线修改。
3. 结构化输出(Structured Output)
这是Spring AI 1.0的一个杀手级功能。可以让AI直接返回Java对象,而不是字符串:
@Data
public class CodeReviewResult {
private boolean hasIssue;
private String issueType;
private String description;
private List<String> suggestions;
private Integer severity; // 1-5
}
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
CodeReviewResult result = chatClient.prompt()
.user("请审查以下Java代码:\n" + code)
.call()
.entity(CodeReviewResult.class);
原理是Spring AI在底层使用了JSON Mode或者Function Calling,让模型输出结构化数据,然后自动反序列化成Java对象。
我们在代码审查机器人中用了这个特性,效果非常好。模型返回的数据可以直接入库,不用写复杂的解析逻辑。
实战案例:智能文档问答系统
业务场景
我们有一套内部技术文档系统,包含:
- 2000+篇技术文章(Markdown格式)
- 500+个API接口文档
- 300+个故障案例
传统的关键词搜索效果很差,用户很难找到想要的信息。我们需要一个基于RAG的智能问答系统。
技术架构
用户提问
↓
向量化(EmbeddingModel)
↓
向量数据库检索(PgVectorStore)
↓
相关文档片段召回
↓
构建Prompt(包含上下文)
↓
调用LLM生成答案
↓
返回用户
核心代码实现
1. 文档切片与向量化
@Service
@RequiredArgsConstructor
public class DocumentIngestionService {
private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
/**
* 将技术文档切片并存入向量库
*/
public void ingestDocument(String title, String content) {
// 1. 文档切片(500字符/片,重叠50字符)
List<String> chunks = splitDocument(content, 500, 50);
// 2. 批量向量化
List<Document> documents = chunks.stream()
.map(chunk -> {
Document doc = new Document(chunk);
doc.getMetadata().put("title", title);
doc.getMetadata().put("timestamp", Instant.now().toString());
return doc;
})
.collect(Collectors.toList());
// 3. 存入向量数据库
vectorStore.add(documents);
}
/**
* 智能切片:按段落分割,保持语义完整
*/
private List<String> splitDocument(String content, int chunkSize, int overlap) {
List<String> chunks = new ArrayList<>();
String[] paragraphs = content.split("\n\n");
StringBuilder currentChunk = new StringBuilder();
for (String para : paragraphs) {
if (currentChunk.length() + para.length() > chunkSize) {
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString());
}
// 保留重叠部分
String overlapStr = currentChunk.length() > overlap
? currentChunk.substring(currentChunk.length() - overlap)
: currentChunk.toString();
currentChunk = new StringBuilder(overlapStr);
}
currentChunk.append(para).append("\n\n");
}
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString());
}
return chunks;
}
}
2. 向量检索与RAG
@Service
@RequiredArgsConstructor
public class RAGQueryService {
private final ChatModel chatModel;
private final VectorStore vectorStore;
/**
* 执行RAG查询
*/
public String ragQuery(String question) {
// 1. 检索相关文档(Top 5)
SearchRequest searchRequest = SearchRequest.query(question)
.withTopK(5)
.withSimilarityThreshold(0.75);
List<Document> relevantDocs = vectorStore.similaritySearch(searchRequest);
if (relevantDocs.isEmpty()) {
return "抱歉,我没有找到相关的技术文档。";
}
// 2. 构建上下文
String context = relevantDocs.stream()
.map(doc -> {
String title = doc.getMetadata().get("title").toString();
return String.format("[%s]\n%s", title, doc.getText());
})
.collect(Collectors.joining("\n\n---\n\n"));
// 3. 构建Prompt
String promptText = """
你是一个Java技术专家,请根据以下技术文档内容回答用户的问题。
如果文档中没有相关内容,请明确说明。
## 参考资料:
%s
## 用户问题:
%s
## 回答要求:
1. 基于文档内容回答,不要编造
2. 引用文档标题
3. 如果涉及代码,提供完整示例
4. 语言简洁专业
""".formatted(context, question);
// 4. 调用LLM
ChatResponse response = chatModel.call(new Prompt(promptText));
return response.getResult().getOutput().getText();
}
}
3. 配置类
@Configuration
public class AiConfig {
/**
* 使用OpenAI的Embedding模型
*/
@Bean
public EmbeddingModel embeddingModel(OpenAiApi openAiApi) {
return new OpenAiEmbeddingModel(openAiApi);
}
/**
* PostgreSQL向量数据库(pgvector扩展)
*/
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel,
DataSource dataSource) {
return PgVectorStore.builder(dataSource, embeddingModel)
.dimensions(1536) // text-embedding-3-small的维度
.distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE)
.indexingOptions(PgIndexingOptions.builder()
.createIndex(true)
.indexMethod(PgIndexMethod.HNSW) // 使用HNSW索引,查询更快
.build())
.build();
}
/**
* 聊天客户端(带记忆功能)
*/
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(
// 会话记忆(保留最后10轮对话)
new MessageChatMemoryAdvisor(
new InMemoryChatMemoryRepository(),
"chat_memory",
10
),
// 日志记录
new SimpleLoggerAdvisor()
)
.build();
}
}
踩坑记录
坑1:向量化维度不匹配
问题:一开始用了text-embedding-ada-002(1536维),后来切换到text-embedding-3-small(也是1536维),看起来没问题。但是当我们尝试用本地的nomic-embed-text(768维)时,PgVectorStore报维度不匹配错误。
解决:在切换Embedding模型时,必须清空向量数据库,重新向量化所有文档。我们在配置类里加了一个@ConditionalOnProperty,允许在应用启动时自动重建索引。
@Bean
@ConditionalOnProperty(name = "app.vector-store.rebuild", havingValue = "true")
public CommandLineRunner rebuildVectorStore(VectorStore vectorStore,
DocumentIngestionService ingestionService) {
return args -> {
// 清空现有向量
((PgVectorStore) vectorStore).deleteAll();
// 重新导入所有文档
List<DocumentEntity> allDocs = documentRepository.findAll();
for (DocumentEntity doc : allDocs) {
ingestionService.ingestDocument(doc.getTitle(), doc.getContent());
}
};
}
坑2:Prompt太长生令牌超限
问题:当召回的文档片段太多时,拼接起来的上下文可能超过模型的上下文窗口(比如GPT-3.5-turbo是16K tokens)。
解决:实现了一个ContextCompressor,根据令牌数动态裁剪:
@Service
public class ContextCompressor {
private final TokenCountEstimator tokenEstimator;
/**
* 压缩上下文,确保不超过模型限制
*/
public String compressContext(List<Document> docs, int maxTokens) {
// 按相似度排序
docs.sort((a, b) -> {
double scoreA = (double) a.getMetadata().get("score");
double scoreB = (double) b.getMetadata().get("score");
return Double.compare(scoreB, scoreA);
});
StringBuilder context = new StringBuilder();
int currentTokens = 0;
for (Document doc : docs) {
int docTokens = tokenEstimator.estimateTokens(doc.getText());
if (currentTokens + docTokens > maxTokens * 0.8) { // 留20%给Prompt和回答
break;
}
context.append(doc.getText()).append("\n\n");
currentTokens += docTokens;
}
return context.toString();
}
}
坑3:向量检索召回率低
问题:有些问题的答案明明在文档里,但是向量检索召回不了。
原因:
- 文档切片不合理,把完整的语义切成两半
- 查询语句和文档用词不一致(比如用户问”怎么连接数据库”,文档里写的是”DataSource配置”)
- 相似度阈值设得太高(0.8),导致一些相关但不完全匹配的内容被过滤掉
解决:
- 改进切片算法,优先在段落、标题处切分
- 引入查询重写(Query Rewriting),用LLM把用户问题改写成多个变体
- 降低相似度阈值到0.7,并在Prompt里让模型判断上下文是否相关
/**
* 查询重写:生成多个查询变体
*/
public List<String> rewriteQuery(String originalQuery) {
String prompt = """
请将以下用户问题改写成3种不同的表达方式,以扩大检索召回率。
要求:
1. 保持原意不变
2. 使用技术文档常用的表达方式
3. 使用同义词替换
原问题:%s
输出格式:每行一个改写后的问题
""".formatted(originalQuery);
ChatResponse response = chatModel.call(new Prompt(prompt));
String result = response.getResult().getOutput().getText();
return Arrays.stream(result.split("\n"))
.map(String::trim)
.filter(line -> !line.isEmpty())
.collect(Collectors.toList());
}
性能数据
我们上线后做了一轮性能测试:
| 指标 | 数值 |
|---|---|
| 文档总数 | 2800+ |
| 向量化耗时(单线程) | 约45分钟 |
| 向量化耗时(多线程,8并发) | 约8分钟 |
| 向量检索平均耗时 | 120ms |
| LLM生成答案平均耗时 | 2.5s(GPT-3.5-turbo) |
| 召回率(Top 5) | 87% |
| 答案准确率 | 92% |
优化点:
- 向量化时使用批量API(Batch Embedding),可以显著提升速度
- PgVectorStore使用HNSW索引,查询速度比暴力扫描快100倍
- 对于高频问题,可以缓存LLM的回答(我们用Caffeine做本地缓存)
@Bean
public Cache<String, String> ragCache() {
return Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
}
Spring AI的其他实用功能
1. 图片生成
@Autowired
private ImageModel imageModel;
public String generateDiagram(String description) {
ImagePrompt prompt = new ImagePrompt(description,
OpenAiImageOptions.builder()
.withModel("dall-e-3")
.withQuality("hd")
.withN(1)
.withHeight(1024)
.withWidth(1024)
.build()
);
ImageResponse response = imageModel.call(prompt);
return response.getResult().getOutput().getUrl();
}
我们用来自动生成技术架构图的配图,效果还不错。
2. 音频转写(Speech-to-Text)
@Autowired
private AudioTranscriptionModel transcriptionModel;
public String transcribeAudio(MultipartFile audioFile) {
Resource audioResource = new ByteArrayResource(audioFile.getBytes());
AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(audioResource);
AudioTranscriptionResponse response = transcriptionModel.call(prompt);
return response.getResult().getOutput();
}
客服系统中用来把用户的语音留言转成文字,方便后续处理。
3. TTS(Text-to-Speech)
@Autowired
private SpeechModel speechModel;
public byte[] textToSpeech(String text) {
SpeechPrompt prompt = new SpeechPrompt(text,
OpenAiSpeechOptions.builder()
.withModel("tts-1")
.withVoice(OpenAiSpeechOptions.Voice.ALLOY)
.build()
);
SpeechResponse response = speechModel.call(prompt);
return response.getResult().getOutput();
}
用来做语音播报,比如告警通知。
生产环境最佳实践
1. 模型调用熔断降级
@RestController
@RequestMapping("/api/ai")
public class AiController {
@Autowired
private RAGQueryService ragQueryService;
@PostMapping("/query")
@CircuitBreaker(name = "aiService", fallbackMethod = "fallbackQuery")
public ResponseEntity<?> query(@RequestBody QueryRequest request) {
String answer = ragQueryService.ragQuery(request.getQuestion());
return ResponseEntity.ok(Map.of("answer", answer));
}
/**
* 降级方法:返回预设的默认回答
*/
public ResponseEntity<?> fallbackQuery(QueryRequest request, Exception ex) {
log.warn("AI服务调用失败,触发降级", ex);
return ResponseEntity.ok(Map.of(
"answer", "抱歉,AI服务暂时不可用。请稍后再试,或联系技术支持。",
"fallback", true
));
}
}
使用Resilience4j实现熔断,防止模型服务不稳定影响主业务。
2. 调用监控与日志
@Aspect
@Component
@Slf4j
public class AiCallMonitoringAspect {
@Around("@annotation(org.springframework.ai.chat.client.ChatClient) || " +
"execution(* org.springframework.ai.chat.model.ChatModel.call(..))")
public Object monitorAiCall(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
String method = joinPoint.getSignature().toShortString();
try {
Object result = joinPoint.proceed();
long elapsedTime = System.currentTimeMillis() - startTime;
log.info("AI调用成功 | 方法={} | 耗时={}ms", method, elapsedTime);
// 记录到监控系统(Prometheus)
AiMetrics.aiCallDuration.record(elapsedTime,
"method", method,
"status", "success"
);
return result;
} catch (Exception e) {
long elapsedTime = System.currentTimeMillis() - startTime;
log.error("AI调用失败 | 方法={} | 耗时={}ms | 错误={}",
method, elapsedTime, e.getMessage());
AiMetrics.aiCallDuration.record(elapsedTime,
"method", method,
"status", "error"
);
AiMetrics.aiCallErrors.increment(
"method", method,
"error", e.getClass().getSimpleName()
);
throw e;
}
}
}
3. 敏感信息过滤
@Component
public class SensitiveDataFilter {
/**
* 过滤用户输入中的敏感信息
*/
public String filterSensitiveInfo(String input) {
String filtered = input;
// 过滤手机号
filtered = filtered.replaceAll("1[3-9]\\d{9}", "***");
// 过滤邮箱
filtered = filtered.replaceAll("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "***@***.***");
// 过滤身份证号
filtered = filtered.replaceAll("\\d{17}[\\dXx]", "***");
return filtered;
}
}
在把用户问题发给LLM之前,先过滤掉敏感信息,避免泄露隐私。
总结
Spring AI 1.0已经是一个可以投入生产的成熟框架。它的统一API设计让我们在切换模型提供商时非常方便,RAG支持也很完善。
适用场景:
- 智能客服、问答系统
- 文档摘要、内容生成
- 代码审查、SQL生成
- 数据分析和报表生成
不建议的场景:
- 对延迟要求极高的实时系统(LLM调用耗时通常在1-5秒)
- 需要100%准确率的场景(LLM会幻觉)
- 数据安全要求极高不允许发送到低模型(可以用本地部署的开源模型)
未来展望:
- 函数调用(Function Calling)支持会更完善
- 多模态(图片、视频理解)会有更多应用场景
- 与Spring Modulith、Spring Flow的结合会更深
如果你也在用Spring AI,欢迎交流经验。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)