前言

去年我们团队在做一个智能客服项目时,需要在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的优势在于:

  1. 统一的API抽象层,切换模型提供商只需要改配置
  2. 与Spring生态无缝集成(依赖注入、配置管理、响应式编程)
  3. 提供了完整的RAG(检索增强生成)支持
  4. 社区活跃,文档完善

核心概念解析

1. Model API统一抽象

Spring AI最核心的设计是ChatModel接口,它统一了所有聊天模型的操作:

public interface ChatModel {
    ChatResponse call(Prompt prompt);
    Flux<ChatResponse> stream(Prompt prompt);
}

不同厂商的实现:

  • OpenAiChatModel - OpenAI GPT系列
  • AzureOpenAiChatModel - Azure OpenAI
  • QianFanChatModel - 百度千帆
  • 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:向量检索召回率低

问题:有些问题的答案明明在文档里,但是向量检索召回不了。

原因

  1. 文档切片不合理,把完整的语义切成两半
  2. 查询语句和文档用词不一致(比如用户问”怎么连接数据库”,文档里写的是”DataSource配置”)
  3. 相似度阈值设得太高(0.8),导致一些相关但不完全匹配的内容被过滤掉

解决

  1. 改进切片算法,优先在段落、标题处切分
  2. 引入查询重写(Query Rewriting),用LLM把用户问题改写成多个变体
  3. 降低相似度阈值到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,欢迎交流经验。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐