Java开发者的大模型入门:Spring AI组件全攻略(二)

九、构建企业级知识库:RAG 实现(下)

上一章我们完成了知识摄入,将私有文档处理成了向量并存入向量数据库。现在,我们要实现 RAG 的在线阶段:当用户提问时,从向量数据库中检索相关文档片段,并将其作为上下文提供给大模型,生成基于知识的答案

本章将带你完成检索增强的问答接口,并探讨一些高级 RAG 策略,帮助你在生产环境中获得更好的效果。

9.1 检索增强的问答实现

Spring AI 提供了非常简洁的方式将向量检索与聊天模型集成。核心思路是:在构建 ChatClient 时,通过 .advisors() 方法添加一个 检索增强顾问(Retrieval Augmentation Advisor),它会自动在每次请求时执行向量检索,并将检索到的文档片段注入到提示词中。

9.1.1 添加检索顾问依赖

Spring AI 内置了 QuestionAnswerAdvisor,它实现了最基本的 RAG 流程:将用户问题作为查询,检索最相关的文档,然后将文档内容插入到用户消息之前。要使用它,需要引入相应的顾问模块(通常已在核心中)。

确保你的项目中已经包含了 spring-ai-core,它会自动包含基础顾问。

9.1.2 在 ChatClient 中集成 VectorStore

我们首先需要创建一个带有检索能力的 ChatClient Bean。可以在配置类中定义:

java 复制代码
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.ChatClientBuilder;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.rag.preretrieval.query.transformation.QueryTransformer;
import org.springframework.ai.rag.retrieval.search.DocumentRetriever;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RagConfig {

    @Bean
    public ChatClient ragChatClient(ChatClient.Builder builder, VectorStore vectorStore) {
        // 创建一个文档检索器,从 VectorStore 中检索
        DocumentRetriever retriever = VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .similarityThreshold(0.5)   // 相似度阈值,低于此值的不返回
                .topK(3)                     // 返回最相似的 3 个文档
                .build();

        // 构建 ChatClient,并添加检索顾问
        return builder
                .defaultAdvisors(new QuestionAnswerAdvisor(retriever))
                .build();
    }
}

QuestionAnswerAdvisor 会在每次调用时:

  1. 获取用户消息作为查询。
  2. 调用 DocumentRetriever 检索相关文档。
  3. 将检索到的文档内容格式化后插入到用户消息前面,形成增强后的提示词。

默认的提示词模板类似于:

markdown 复制代码
上下文信息:
---------------------
[文档1内容]
[文档2内容]
---------------------
根据以上上下文信息,请回答用户的问题:{用户问题}

9.1.3 创建 RAG 控制器

现在我们可以注入这个 ragChatClient,并提供一个 REST 接口用于问答。

java 复制代码
package com.example.demo.controller;

import org.springframework.ai.chat.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RagController {

    private final ChatClient ragChatClient;

    public RagController(ChatClient ragChatClient) {
        this.ragChatClient = ragChatClient;
    }

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

注意:这里注入的 ragChatClient 是我们刚刚配置的带有检索顾问的 Bean,不是普通的 ChatClient

9.1.4 测试 RAG 问答

启动应用,访问 /ask?question=什么是RAG。你应该会看到 AI 基于 knowledge.txt 中的内容进行回答,而不是凭空编造。

如果问题超出知识库范围(例如问"明天天气怎么样"),AI 可能会说不知道,或者根据自身知识回答(取决于你的设置)。你可以在系统消息中进一步约束 AI 的行为。

9.1.5 完整 RAG 流程示意图

sequenceDiagram participant 用户 participant Controller participant ChatClient participant 检索顾问 participant VectorStore participant 大模型 用户->>Controller: GET /ask?question=什么是RAG Controller->>ChatClient: prompt().user(question).call() ChatClient->>检索顾问: 执行检索增强 检索顾问->>VectorStore: 相似度搜索(question) VectorStore-->>检索顾问: 返回最相关文档片段 检索顾问->>检索顾问: 构建增强提示词(上下文+问题) 检索顾问-->>ChatClient: 返回增强后的Prompt ChatClient->>大模型: 发送增强后的Prompt 大模型-->>ChatClient: 返回AI回答 ChatClient-->>Controller: 返回content() Controller-->>用户: 返回最终答案

9.2 高级 RAG 策略简介

基本的 QuestionAnswerAdvisor 已经能满足许多场景,但在生产环境中,你可能需要更精细的控制来提升检索质量和回答准确性。Spring AI 提供了一系列可插拔的组件,允许你定制 RAG 流程的每个环节。

9.2.1 查询转换(Query Transformation)

用户的问题可能不够精确,或者需要结合对话历史才能理解。查询转换可以在检索前对问题进行改写,以提高检索效果。

Spring AI 提供了 QueryTransformer 接口,常用实现有:

  • CompressingQueryTransformer:结合对话历史压缩查询(例如将"它是什么意思"扩展为完整问题)。
  • ExpandingQueryTransformer:生成多个查询变体,检索后合并结果。
  • TranslationQueryTransformer:将查询翻译成其他语言后再检索(如果文档是多语言的)。

使用示例:

java 复制代码
QueryTransformer transformer = new CompressingQueryTransformer(chatModel);

然后可以在构建检索器时传入:

java 复制代码
DocumentRetriever retriever = VectorStoreDocumentRetriever.builder()
        .vectorStore(vectorStore)
        .queryTransformer(transformer)   // 添加查询转换
        .topK(3)
        .build();

9.2.2 混合检索(Hybrid Search)

向量检索擅长语义匹配,但关键词检索在某些场景下更精确(如精确匹配产品型号)。混合检索结合了两者,通常能取得更好的效果。

Spring AI 目前没有内置的混合检索器,但你可以通过组合多个检索器来实现。例如,同时使用向量检索和 Elasticsearch 关键词检索,然后合并结果。

9.2.3 重排序(Reranking)

检索出的文档片段按相似度得分排序,但有时最相似的未必最有用。重排序可以使用专门的模型(如 Cross-encoder)对检索结果重新打分,提高相关性。

Spring AI 提供了 DocumentRanker 接口,你可以实现自己的重排序逻辑,或调用第三方服务。

9.2.4 自定义提示词模板

QuestionAnswerAdvisor 使用默认的提示词模板,但你可以通过覆盖其行为来自定义。例如,你希望 AI 在无法回答时明确说"根据现有知识库无法回答"。

可以创建自定义的 Advisor:

java 复制代码
public class CustomQuestionAnswerAdvisor implements ChatMemoryAdvisor {

    private final DocumentRetriever retriever;
    private final String template;

    public CustomQuestionAnswerAdvisor(DocumentRetriever retriever, String template) {
        this.retriever = retriever;
        this.template = template;
    }

    @Override
    public Prompt advise(Prompt prompt, Map<String, Object> context) {
        // 1. 从 prompt 中提取用户消息(可能需要处理多条消息)
        String userMessage = ...;
        // 2. 检索文档
        List<Document> docs = retriever.retrieve(userMessage);
        // 3. 格式化文档为上下文
        String contextStr = docs.stream().map(Document::getContent).collect(Collectors.joining("\n---\n"));
        // 4. 构建新提示词
        String enhancedUserMessage = template.replace("{context}", contextStr)
                                             .replace("{question}", userMessage);
        // 5. 返回新的 Prompt(可能保留系统消息等)
        return new Prompt(enhancedUserMessage, prompt.getOptions());
    }
}

然后在构建 ChatClient 时使用这个自定义顾问。

9.3 实践:构建一个带高级功能的 RAG 助手

为了让你体验更完整的 RAG 实现,我们将构建一个具备以下功能的助手:

  • 在应用启动时摄入知识文档
  • 使用查询压缩(CompressingQueryTransformer)支持多轮对话
  • 提供流式响应
  • 返回检索到的文档来源(作为元数据)

9.3.1 添加依赖

确保有 WebFlux 依赖(用于流式)和对话记忆的支持(后续章节会用到)。

9.3.2 配置类

java 复制代码
package com.example.demo.config;

import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.ChatClientBuilder;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.rag.preretrieval.query.transformation.CompressingQueryTransformer;
import org.springframework.ai.rag.retrieval.search.DocumentRetriever;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AdvancedRagConfig {

    @Bean
    public ChatMemory chatMemory() {
        return new InMemoryChatMemory();
    }

    @Bean
    public ChatClient advancedRagChatClient(ChatClient.Builder builder,
                                            VectorStore vectorStore,
                                            ChatClient chatModel, // 用于查询压缩的模型
                                            ChatMemory chatMemory) {
        // 创建查询转换器(压缩)
        CompressingQueryTransformer queryTransformer = new CompressingQueryTransformer(chatModel);

        // 创建检索器
        DocumentRetriever retriever = VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .queryTransformer(queryTransformer)
                .similarityThreshold(0.6)
                .topK(5)
                .build();

        // 构建 ChatClient,添加多个顾问:记忆顾问 + 检索顾问
        return builder
                .defaultAdvisors(
                        new MessageChatMemoryAdvisor(chatMemory), // 对话记忆(需要引入相关包)
                        new QuestionAnswerAdvisor(retriever)
                )
                .build();
    }
}

9.3.3 控制器支持流式

java 复制代码
package com.example.demo.controller;

import org.springframework.ai.chat.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RestController
public class AdvancedRagController {

    private final ChatClient advancedRagChatClient;

    public AdvancedRagController(ChatClient advancedRagChatClient) {
        this.advancedRagChatClient = advancedRagChatClient;
    }

    @GetMapping(value = "/ask-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> askStream(@RequestParam String question) {
        return advancedRagChatClient.prompt()
                .user(question)
                .stream()
                .map(chatResponse -> chatResponse.getResult().getOutput().getContent());
    }
}

9.3.4 返回文档来源

如果你希望在前端展示答案的来源(引用的文档片段),可以修改响应结构。Spring AI 的 ChatResponse 中包含了 metadata,其中可能包含检索到的文档列表。但需要顾问将文档信息放入 metadata 中。

QuestionAnswerAdvisor 默认会将检索到的文档放在 ChatResponse 的 metadata 中,键为 "retrievedDocuments"。我们可以通过返回 ChatResponse 而不是纯文本来获取这些信息。

java 复制代码
@GetMapping(value = "/ask-with-sources")
public Map<String, Object> askWithSources(@RequestParam String question) {
    ChatResponse response = advancedRagChatClient.prompt()
            .user(question)
            .call();
    String answer = response.getResult().getOutput().getContent();
    List<Document> sources = (List<Document>) response.getMetadata().get("retrievedDocuments");
    return Map.of(
        "answer", answer,
        "sources", sources.stream().map(Document::getContent).collect(Collectors.toList())
    );
}

9.4 本章小结

通过本章的学习,你完成了 RAG 的最后一个环节------检索增强的问答

  • 集成检索顾问 :使用 QuestionAnswerAdvisor 将向量检索自动融入 ChatClient。
  • 自定义检索器:配置相似度阈值、返回数量等参数。
  • 查询转换 :引入 CompressingQueryTransformer 提升多轮对话中的检索准确性。
  • 流式响应:与 WebFlux 结合,提供打字机效果。
  • 来源返回:获取并返回引用的文档片段,增强可信度。

十、注解式开发:声明式AI服务

在前面的章节中,我们已经熟悉了使用 ChatClient 进行 AI 交互的编程方式。这种方式非常灵活,但每次调用都需要编写类似的代码:构建 prompt、调用、获取结果。随着业务中 AI 功能点的增多,重复代码会变得臃肿,提示词也会散落在各处,不利于维护。

注解式开发 提供了一种更优雅的解决方案:通过自定义注解,将提示词定义与业务逻辑分离,让开发者只需关注接口定义和注解配置,底层调用完全自动化。这类似于 Spring 的 @RequestMapping 注解将 HTTP 请求映射到方法,我们的 @AiPrompt 注解将自然语言交互映射到 Java 方法。

10.1 什么是注解式开发?为何需要?

注解式开发的核心思想是:让开发者通过注解声明 AI 的行为,框架自动实现调用细节。具体来说:

  • 你定义一个 Java 接口,在接口方法上添加 @AiPrompt 注解,注解值就是提示词模板。
  • 框架(通过 AOP)自动为该接口生成代理实现,在调用方法时,根据注解模板和方法参数构造真正的提示词,调用大模型,并返回结果。

这样做的好处:

  • 关注点分离:提示词与业务代码分离,便于维护和修改。
  • 极简调用:业务代码只需注入接口并调用方法,就像调用普通方法一样。
  • 类型安全:方法参数和返回值都是 Java 类型,无需手动解析 JSON。
  • 复用性强:同一套接口可在多处复用,减少重复代码。

10.2 自定义 @AiPrompt 注解的设计

我们先来设计一个简单的注解 @AiPrompt,用于标记需要 AI 处理的方法。

java 复制代码
package com.example.demo.annotation;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AiPrompt {
    /**
     * 提示词模板,支持占位符 {name} 形式
     */
    String value();

    /**
     * 系统消息模板(可选)
     */
    String system() default "";

    /**
     * 模型参数,如温度等(可选,简化版暂不支持)
     */
    // double temperature() default 0.7;
}

这个注解用于方法上,value 是用户消息模板,system 是可选的系统消息模板。我们将在切面中解析模板并填充方法参数。

10.3 使用 AOP 实现注解处理切面

Spring AOP 可以帮助我们拦截所有带有 @AiPrompt 注解的方法,并执行统一的 AI 调用逻辑。

10.3.1 引入 AOP 依赖

pom.xml 中添加 Spring AOP 起步依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

10.3.2 创建切面类

java 复制代码
package com.example.demo.aspect;

import com.example.demo.annotation.AiPrompt;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

@Aspect
@Component
public class AiPromptAspect {

    @Autowired
    private ChatClient chatClient;

    @Around("@annotation(com.example.demo.annotation.AiPrompt)")
    public Object handleAiPrompt(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. 获取方法上的注解
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        AiPrompt aiPrompt = method.getAnnotation(AiPrompt.class);

        // 2. 获取方法参数名和参数值
        String[] paramNames = signature.getParameterNames();
        Object[] paramValues = joinPoint.getArgs();

        // 3. 构建变量映射
        Map<String, Object> variables = new HashMap<>();
        if (paramNames != null) {
            for (int i = 0; i < paramNames.length; i++) {
                variables.put(paramNames[i], paramValues[i]);
            }
        }

        // 4. 渲染系统消息(如果有)
        PromptTemplate systemTemplate = null;
        String systemText = null;
        if (!aiPrompt.system().isEmpty()) {
            systemTemplate = new PromptTemplate(aiPrompt.system());
            systemText = systemTemplate.render(variables);
        }

        // 5. 渲染用户消息
        PromptTemplate userTemplate = new PromptTemplate(aiPrompt.value());
        String userText = userTemplate.render(variables);

        // 6. 构建 Prompt(支持系统消息)
        Prompt prompt;
        if (systemText != null) {
            prompt = new Prompt(userText, systemText);
        } else {
            prompt = new Prompt(userText);
        }

        // 7. 调用 AI 客户端
        String result = chatClient.prompt(prompt).call().content();

        // 8. 返回结果(目前只支持 String 返回类型)
        return result;
    }
}

切面逻辑说明:

  • 通过 @Around 拦截所有带有 @AiPrompt 注解的方法。
  • 获取方法参数名和参数值,构建变量 Map。
  • 使用 PromptTemplate 渲染注解中的模板(支持占位符)。
  • 调用 ChatClient 获取 AI 回复。
  • 返回结果给调用方。

注意: 此切面仅处理返回类型为 String 的方法。如果需要返回复杂对象,可以进一步扩展(结合第五章的结构化输出),但本章先保持简单。

10.4 实践:用注解简化 AI 服务调用

现在我们来实际使用这个注解,构建一个简单的 AI 服务。

10.4.1 创建 AI Service 接口

java 复制代码
package com.example.demo.service;

import com.example.demo.annotation.AiPrompt;

public interface AiAssistant {

    @AiPrompt(value = "你好,我叫 {name},请用热情的语气向我问好。",
              system = "你是一个热情的接待员,总是用感叹号结尾。")
    String greet(String name);

    @AiPrompt("请解释一下什么是 {concept},用通俗易懂的语言。")
    String explain(String concept);

    @AiPrompt("将以下文本翻译成 {targetLanguage}:{text}")
    String translate(String text, String targetLanguage);
}

注意方法参数名与模板中的占位符名称一致。

10.4.2 实现接口(无需手动实现)

我们不需要编写实现类,因为切面会动态处理。但是 Spring 需要能够注入接口的实例,所以我们需要通过某种方式创建 Bean。

一种简单的方式是在配置类中通过 @Bean 返回代理对象,但更常见的是结合 Spring 的 @Service 和工厂方法。为了简化,我们可以使用 @Service 注解一个抽象类,但 AOP 只能作用于 Spring Bean 的方法,所以我们需要确保接口的实现类是一个 Spring Bean。

方案:使用动态代理手动创建 Bean

在配置类中,我们可以为每个接口创建代理 Bean,但那样太麻烦。更好的做法是让 Spring 自动扫描接口并使用工厂 Bean 生成代理。这里我们采用最简单的方式:创建一个实现类,但实现类中什么也不做,因为切面会拦截。

java 复制代码
package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class AiAssistantImpl implements AiAssistant {
    @Override
    public String greet(String name) {
        // 切面会拦截,实际不会执行到这里
        return null;
    }

    @Override
    public String explain(String concept) {
        return null;
    }

    @Override
    public String translate(String text, String targetLanguage) {
        return null;
    }
}

这个实现类只是为了让 Spring 创建一个 Bean,所有方法体为空。当这些方法被调用时,AOP 切面会拦截并执行 AI 逻辑,不会进入方法体。

10.4.3 在 Controller 中使用

java 复制代码
package com.example.demo.controller;

import com.example.demo.service.AiAssistant;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AiAssistantController {

    private final AiAssistant aiAssistant;

    public AiAssistantController(AiAssistant aiAssistant) {
        this.aiAssistant = aiAssistant;
    }

    @GetMapping("/greet")
    public String greet(@RequestParam String name) {
        return aiAssistant.greet(name);
    }

    @GetMapping("/explain")
    public String explain(@RequestParam String concept) {
        return aiAssistant.explain(concept);
    }

    @GetMapping("/translate")
    public String translate(@RequestParam String text,
                            @RequestParam String targetLanguage) {
        return aiAssistant.translate(text, targetLanguage);
    }
}

10.4.4 测试

启动应用,访问:

  • http://localhost:8080/greet?name=张三
  • http://localhost:8080/explain?concept=多态
  • http://localhost:8080/translate?text=Hello&targetLanguage=中文

你会看到 AI 根据注解中的提示词模板生成的回复。

注解式开发流程示意图:

graph TD A[Controller调用aiAssistant.greet name] --> B[AOP切面拦截AiPrompt方法] B --> C[获取注解模板和方法参数] C --> D[用参数渲染模板] D --> E[调用ChatClient] E --> F[获取AI回复] F --> G[返回结果给Controller]

10.5 注解式开发的优势

通过上面的实践,你已经感受到了注解式开发的魅力:

  1. 代码极简 :业务代码中只需一行 aiAssistant.greet(name),所有 AI 交互细节都被封装在切面中。
  2. 提示词集中管理:所有提示词都定义在注解中,一目了然,修改方便。
  3. 类型安全:方法参数明确,编译期就能发现参数错误。
  4. 易于测试:可以轻松 Mock AI 服务,进行单元测试。
  5. 可扩展性:可以在注解中添加更多属性(如温度、模型名称等),切面中支持更多配置。

10.6 扩展:支持返回 Java 对象

如果你希望注解方法直接返回 Java 对象,可以结合 Spring AI 的 OutputParser 实现。大致思路是:在注解中增加 outputClass 属性,切面中根据该属性调用 chatClient.prompt().call().entity(outputClass)

java 复制代码
// 扩展注解
public @interface AiPrompt {
    String value();
    String system() default "";
    Class<?> outputClass() default String.class; // 默认 String
}

然后在切面中判断:

java 复制代码
if (aiPrompt.outputClass() != String.class) {
    Object result = chatClient.prompt(prompt).call().entity(aiPrompt.outputClass());
    return result;
} else {
    return chatClient.prompt(prompt).call().content();
}

但要注意,返回类型必须与 outputClass 一致,否则会转换错误。

10.7 本章小结

通过本章的学习,你掌握了:

  • 注解式开发的概念:将提示词通过注解声明,利用 AOP 自动化 AI 调用。
  • 自定义注解 :设计 @AiPrompt 包含模板和系统消息。
  • AOP 切面实现 :拦截方法,渲染模板,调用 ChatClient
  • 实践:构建了基于注解的 AI 助手,实现了问候、解释、翻译功能。
  • 优势总结:代码简洁、提示词集中、类型安全、易于维护。

注解式开发是构建企业级 AI 服务的高级模式,尤其适合团队中需要大量使用 AI 功能的场景,可以显著提升开发效率和代码质量。

十一、生产级特性:缓存与监控

当你的 AI 应用从原型走向生产,性能成本可观测性就成为必须考虑的因素。不加控制的 AI 调用可能导致响应缓慢、Token 费用飙升,而缺乏监控则让你对应用的实际运行情况一无所知。

本章将教你如何为 Spring AI 应用添加两大生产级特性:

  • 缓存:减少重复请求,提升响应速度,节省 Token 消耗。
  • 监控:通过指标收集,了解调用频率、耗时和 Token 用量,为容量规划和成本控制提供依据。

11.1 缓存优化减少重复调用

在实际业务中,用户可能会反复询问相同或相似的问题(例如常见的 FAQ)。每次都将同样的问题发送给大模型,不仅浪费 Token,还会增加响应延迟。通过引入缓存,我们可以将 AI 的回复缓存起来,当遇到相同问题时直接返回缓存结果。

11.1.1 Spring Cache 抽象

Spring 提供了强大的缓存抽象,通过注解就能轻松为方法添加缓存能力。核心注解:

  • @Cacheable:在方法执行前先检查缓存,如果缓存存在则直接返回,否则执行方法并将结果缓存。
  • @CacheEvict:清除缓存。
  • @CachePut:更新缓存。

我们需要选择一个缓存实现,比如 Caffeine(高性能本地缓存)或 Redis(分布式缓存)。本教程以 Caffeine 为例。

11.1.2 引入依赖

pom.xml 中添加 Spring Cache 和 Caffeine 依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

11.1.3 配置缓存管理器

application.yml 中配置 Caffeine 缓存:

yaml 复制代码
spring:
  cache:
    cache-names: ai-responses
    caffeine:
      spec: maximumSize=1000, expireAfterWrite=1h  # 最多缓存1000条,写入1小时后过期

或者在 Java 配置类中更精细地配置:

java 复制代码
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching  // 启用缓存注解
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("ai-responses");
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(1000)
                .expireAfterWrite(1, TimeUnit.HOURS));
        return cacheManager;
    }
}

11.1.4 在 AI 服务方法上添加 @Cacheable

假设我们有一个 AiAssistant 服务,其中的 chat 方法调用 AI。我们可以这样添加缓存:

java 复制代码
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class AiAssistant {

    private final ChatClient chatClient;

    public AiAssistant(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Cacheable(value = "ai-responses", key = "#userMessage")
    public String chat(String userMessage) {
        return chatClient.prompt()
                .user(userMessage)
                .call()
                .content();
    }
}
  • value = "ai-responses":指定缓存名称,对应配置中的缓存区域。
  • key = "#userMessage":使用 SpEL 表达式,以 userMessage 参数作为缓存 key。这意味着完全相同的消息才会命中缓存。

如果你的方法包含多个参数(如系统消息、温度等),可以将它们组合成 key,例如 key = "#userMessage + #systemMessage"

11.1.5 注意事项

  • 缓存粒度:仅当用户消息完全相同时才会命中缓存。如果消息稍有不同(如标点符号),将视为不同请求。可以考虑对消息进行归一化处理(如去除多余空格、转为小写)来提升命中率,但需谨慎避免改变语义。
  • 缓存过期策略:根据业务需求设置合理的过期时间。例如 FAQ 可以缓存较长时间,而实时性要求高的内容应设置较短的过期时间或不缓存。
  • 缓存污染:避免缓存错误或异常的响应。可以在调用 AI 后对结果进行校验,只有成功结果才缓存。
  • 分布式环境:如果应用多实例部署,本地缓存会导致每个实例有自己的缓存,可能不一致。此时应考虑使用 Redis 等分布式缓存。

11.2 监控与可观测性

为了了解 AI 服务的运行状况,我们需要收集以下指标:

  • 调用次数:总调用量,成功/失败次数。
  • 响应时间:每次调用的耗时分布。
  • Token 用量:输入 Token、输出 Token、总 Token,用于成本核算。
  • 缓存命中率:缓存的有效性。

Spring Boot Actuator 结合 Micrometer 可以轻松暴露这些指标。

11.2.1 引入 Actuator 依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId> <!-- 如果要用 Prometheus -->
</dependency>

11.2.2 配置 Actuator 端点

application.yml 中暴露需要的端点:

yaml 复制代码
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  metrics:
    tags:
      application: ai-service  # 为所有指标添加应用标签

11.2.3 自定义指标收集

我们可以通过 AOP 切面或拦截器来记录每次 AI 调用的耗时和 Token 用量,并使用 Micrometer 的 CounterTimer 记录指标。

首先,注入 MeterRegistry

java 复制代码
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
public class AiMonitoringAspect {

    private final MeterRegistry meterRegistry;

    // 定义指标名称
    private static final String AI_CALL_COUNT = "ai.call.count";
    private static final String AI_CALL_DURATION = "ai.call.duration";
    private static final String AI_TOKEN_TOTAL = "ai.token.total";
    private static final String AI_TOKEN_INPUT = "ai.token.input";
    private static final String AI_TOKEN_OUTPUT = "ai.token.output";

    public AiMonitoringAspect(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Around("execution(* com.example.demo.service.AiAssistant.chat(..))")
    public Object monitorAiCall(ProceedingJoinPoint joinPoint) throws Throwable {
        // 记录开始时间
        long start = System.nanoTime();

        // 执行原始方法
        Object result = joinPoint.proceed();

        // 计算耗时
        long durationNanos = System.nanoTime() - start;
        Timer timer = meterRegistry.timer(AI_CALL_DURATION, "method", "chat");
        timer.record(durationNanos, TimeUnit.NANOSECONDS);

        // 增加调用计数
        meterRegistry.counter(AI_CALL_COUNT, "method", "chat", "status", "success").increment();

        // 尝试获取 Token 用量(需要从 ChatResponse 中提取)
        if (result instanceof ChatResponse) {
            ChatResponse response = (ChatResponse) result;
            // 注意:Token 用量的获取方式取决于 Spring AI 版本,此处为示例
            var usage = response.getMetadata().get("usage");
            if (usage instanceof org.springframework.ai.openai.metadata.OpenAiUsage) {
                var openAiUsage = (org.springframework.ai.openai.metadata.OpenAiUsage) usage;
                meterRegistry.counter(AI_TOKEN_INPUT, "method", "chat").increment(openAiUsage.getPromptTokens());
                meterRegistry.counter(AI_TOKEN_OUTPUT, "method", "chat").increment(openAiUsage.getCompletionTokens());
                meterRegistry.counter(AI_TOKEN_TOTAL, "method", "chat").increment(openAiUsage.getTotalTokens());
            }
        }

        return result;
    }
}

如果我们的方法返回的是字符串(而不是 ChatResponse),则无法获取 Token 用量。为了收集 Token,可以让方法返回 ChatResponse,或者通过其他方式获取(如从请求上下文中提取)。更优雅的方式是使用 ChatClientcall() 方法返回 ChatResponse,然后从中提取 Token。

11.2.4 创建可返回 Token 用量的服务

我们可以修改 AiAssistant 服务,提供一个返回 ChatResponse 的方法:

java 复制代码
@Cacheable(value = "ai-responses", key = "#userMessage")
public ChatResponse chatWithDetails(String userMessage) {
    return chatClient.prompt()
            .user(userMessage)
            .call();
}

然后在 Controller 中调用此方法,并提取内容和 Token。

或者保持返回字符串,但通过监听器或拦截器在底层收集 Token(Spring AI 可能提供类似功能,但当前版本需手动实现)。

11.2.5 查看指标

启动应用,访问 http://localhost:8080/actuator/metrics/ai.call.count 可以看到调用计数。如果配置了 Prometheus,访问 /actuator/prometheus 可以看到所有指标。

11.3 实践:为 RAG 服务添加缓存和监控

结合上一章的 RAG 服务,我们来添加缓存和监控。

11.3.1 服务类添加缓存

java 复制代码
@Service
public class RagService {

    private final ChatClient ragChatClient;

    public RagService(ChatClient ragChatClient) {
        this.ragChatClient = ragChatClient;
    }

    @Cacheable(value = "ai-responses", key = "#question")
    public String ask(String question) {
        return ragChatClient.prompt()
                .user(question)
                .call()
                .content();
    }
}

11.3.2 监控切面

使用上面的 AiMonitoringAspect,但需要调整切入点,指向 RagService.ask 方法。也可以定义一个通用的注解,然后切面拦截该注解。

11.3.3 控制器

java 复制代码
@RestController
public class RagController {

    private final RagService ragService;

    public RagController(RagService ragService) {
        this.ragService = ragService;
    }

    @GetMapping("/ask")
    public String ask(@RequestParam String question) {
        return ragService.ask(question);
    }
}

11.3.4 测试

  • 第一次访问 /ask?question=什么是RAG,会调用 AI,耗时较长,缓存中存入结果。
  • 第二次相同请求,直接从缓存返回,响应极快。
  • 查看 /actuator/metrics/ai.call.duration 可以看到两次调用的耗时分布(第二次可能因为缓存而不被切面记录?这取决于切面的位置。如果切面在服务层,第二次不会经过服务方法,所以不会记录。需要决定是否缓存命中也要记录为一次"调用"。通常我们只记录实际调用 AI 的次数,缓存命中不算。因此切面应在实际调用 AI 的方法上,即 ChatClient 的调用处,而不是服务方法。可以进一步细化。)

为了准确监控实际 AI 调用,我们应该将切面放在更底层,例如自定义一个 ChatClient 的包装器,或者在调用 chatClient.prompt().call() 的地方拦截。但为了简化,我们可以接受服务方法被缓存命中时不记录。

11.4 流程图

缓存命中流程

sequenceDiagram participant 用户 participant Controller participant Service participant 缓存 用户->>Controller: 请求 /ask?question=... Controller->>Service: ask(question) Service->>缓存: 根据 key 查询缓存 缓存-->>Service: 返回缓存结果 Service-->>Controller: 返回结果 Controller-->>用户: 响应

缓存未命中流程

sequenceDiagram participant 用户 participant Controller participant Service participant 缓存 participant AI 用户->>Controller: 请求 /ask?question=... Controller->>Service: ask(question) Service->>缓存: 根据 key 查询缓存 缓存-->>Service: 未找到 Service->>AI: 调用大模型 AI-->>Service: 返回回答 Service->>缓存: 存入结果 Service-->>Controller: 返回结果 Controller-->>用户: 响应

监控数据收集流程

graph TD A[AI调用] --> B[监控切面拦截] B --> C[记录开始时间] B --> D[执行原始方法] D --> E[记录耗时] E --> F[更新Timer指标] D --> G[获取Token用量] G --> H[更新Counter指标] H --> I[Micrometer Registry] I --> J[Actuator端点暴露] J --> K[Prometheus拉取] J --> L[管理员查看]

11.5 本章小结

通过本章的学习,你掌握了为 Spring AI 应用添加生产级特性的方法:

  • 缓存:使用 Spring Cache 和 Caffeine 减少重复 AI 调用,提升性能,节省成本。
  • 监控:利用 Micrometer 和 Actuator 收集调用次数、耗时和 Token 用量,为运维和成本控制提供数据支持。
  • 实践:为 RAG 服务添加缓存和监控,并了解了注意事项(缓存粒度、分布式缓存、Token 获取方式)。

十二、与 Spring Cloud 生态集成

在前面的章节中,我们已经构建了一个功能完备的 AI 服务,并为其添加了缓存和监控。但在真实的微服务架构中,一个服务往往不是孤立存在的------它需要配置管理、服务发现、负载均衡、灰度发布等能力。Spring Cloud 生态提供了这些基础设施,而 Spring AI 可以无缝融入其中,让你的 AI 服务成为整个微服务体系的一部分。

本章将带你探索如何将 Spring AI 与 Spring Cloud 组件集成,实现:

  • 将提示词模板(Prompts)存储在配置中心,实现动态更新,无需重启服务。
  • 将 AI 服务注册到服务发现中心,供其他服务调用。
  • 通过灰度发布机制,平滑升级模型或切换不同版本。

12.1 Spring AI 在微服务架构中的定位

在微服务架构中,AI 能力通常以独立服务的形式提供,称为 AI 服务智能服务。它对外提供统一的 API 接口(如聊天、问答、图像生成),内部封装了与大模型交互的复杂逻辑。其他业务服务(如订单服务、客服服务)通过 HTTP 或 RPC 调用 AI 服务,获取 AI 能力。

典型架构示意图:

graph TD subgraph 业务服务层 A[订单服务] B[客服服务] C[商品服务] end subgraph AI服务层 D[AI服务 - 聊天] E[AI服务 - 图像] end subgraph 基础设施层 F[配置中心<br>Nacos/Config] G[服务注册中心<br>Eureka/Nacos] end A -->|调用| D B -->|调用| D C -->|调用| E D -->|获取配置| F E -->|获取配置| F D -->|注册| G E -->|注册| G

这种架构的优势:

  • 职责分离:业务服务无需关心 AI 调用的细节,只需调用 AI 服务的接口。
  • 独立演进:AI 服务可以独立升级模型、调整提示词,不影响业务服务。
  • 统一管理:通过配置中心统一管理提示词模板,通过注册中心实现服务发现和负载均衡。
  • 弹性伸缩:AI 服务可以根据负载水平伸缩,业务服务通过客户端负载均衡调用。

12.2 配置中心管理 Prompt 模板

提示词模板(Prompts)是 AI 服务的核心资产,它们经常需要调整(例如优化措辞、增加约束)。如果模板硬编码在代码中,每次修改都需要重新编译、部署,非常不便。通过配置中心,我们可以将模板存储在外部,并支持动态刷新,无需重启服务即可生效。

12.2.1 使用 Spring Cloud Config 管理模板

Spring Cloud Config 是 Spring 官方提供的配置中心,可以基于 Git 仓库管理配置文件。我们也可以使用 Nacos、Apollo 等。这里以 Nacos 为例,因为它功能强大且在国内广泛使用。

引入依赖:

xml 复制代码
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    <version>2022.0.0.0</version> <!-- 版本需与 Spring Cloud Alibaba 对应 -->
</dependency>

配置文件 bootstrap.yml

yaml 复制代码
spring:
  application:
    name: ai-service
  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848  # Nacos 服务器地址
        file-extension: yaml          # 配置文件格式
  profiles:
    active: dev

在 Nacos 中创建配置文件 ai-service-dev.yaml,内容可以包含提示词模板:

yaml 复制代码
ai:
  prompts:
    greeting: "你好,我叫 {name},请用热情的语气向我问好。"
    explain: "请解释一下什么是 {concept},用通俗易懂的语言。"
    translate: "将以下文本翻译成 {targetLanguage}:{text}"

12.2.2 在 Java 代码中动态加载模板

我们可以使用 @ConfigurationProperties 将配置映射为 Java 对象,并利用 @RefreshScope 实现动态刷新。

java 复制代码
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
@RefreshScope
@ConfigurationProperties(prefix = "ai.prompts")
public class PromptProperties {
    private Map<String, String> prompts;

    public Map<String, String> getPrompts() {
        return prompts;
    }

    public void setPrompts(Map<String, String> prompts) {
        this.prompts = prompts;
    }

    // 根据 key 获取模板
    public String getPrompt(String key) {
        return prompts.get(key);
    }
}

12.2.3 在 AI 服务中使用动态模板

修改之前的注解式 AI 服务,让注解值支持占位符,并从配置中心获取实际模板。

首先,我们可能需要调整 @AiPrompt 注解,让它支持一个 key 而不是直接写死模板。或者保持原样,但模板内容动态获取。为了演示,我们创建一个新的注解 @DynamicPrompt,其值为配置 key。

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DynamicPrompt {
    String value();   // 配置 key,如 "greeting"
    String system() default "";
}

修改切面,从 PromptProperties 中获取模板:

java 复制代码
@Aspect
@Component
public class DynamicPromptAspect {

    @Autowired
    private ChatClient chatClient;

    @Autowired
    private PromptProperties promptProperties;

    @Around("@annotation(dynamicPrompt)")
    public Object handleDynamicPrompt(ProceedingJoinPoint joinPoint, DynamicPrompt dynamicPrompt) throws Throwable {
        // 获取模板 key
        String key = dynamicPrompt.value();
        String template = promptProperties.getPrompt(key);
        if (template == null) {
            throw new IllegalArgumentException("未找到 prompt key: " + key);
        }

        // 后续渲染与之前相同...
        // ...
    }
}

这样,当我们修改 Nacos 中的模板内容后,通过调用 Spring Cloud Config 的刷新端点(或 Nacos 自动推送),PromptProperties 会更新,后续调用将使用新模板。

12.2.4 测试动态刷新

  1. 启动 Nacos 服务(本地可下载并启动)。
  2. 在 Nacos 配置列表中创建 ai-service-dev.yaml,填入上述内容。
  3. 启动 AI 服务。
  4. 调用接口,验证使用配置中的模板。
  5. 在 Nacos 中修改模板内容并发布。
  6. 调用 curl -X POST http://localhost:8080/actuator/refresh(需引入 actuator 并暴露 refresh 端点),触发配置刷新。
  7. 再次调用接口,观察是否使用新模板。

12.3 服务注册与发现

为了让其他业务服务能够发现并调用 AI 服务,我们需要将 AI 服务注册到服务注册中心。这里以 Nacos 作为注册中心。

12.3.1 引入依赖

xml 复制代码
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    <version>2022.0.0.0</version>
</dependency>

12.3.2 配置文件

application.yml 中添加:

yaml 复制代码
spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
        service: ai-service  # 注册的服务名

12.3.3 启用服务发现

在主类上添加 @EnableDiscoveryClient

java 复制代码
@SpringBootApplication
@EnableDiscoveryClient
public class AiServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(AiServiceApplication.class, args);
    }
}

启动服务后,可以在 Nacos 控制台的服务列表看到 ai-service 实例。

12.3.4 业务服务调用 AI 服务

现在假设有一个订单服务需要调用 AI 服务的聊天接口。它可以使用 @LoadBalancedRestTemplate 或 WebClient 来调用。

订单服务配置:

yaml 复制代码
spring:
  application:
    name: order-service
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

配置 RestTemplate:

java 复制代码
@Configuration
public class OrderServiceConfig {
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

调用 AI 服务:

java 复制代码
@Service
public class OrderService {
    @Autowired
    private RestTemplate restTemplate;

    public String askAI(String question) {
        String url = "http://ai-service/ask?question=" + question;
        return restTemplate.getForObject(url, String.class);
    }
}

http://ai-service 中的 ai-service 是注册的服务名,Ribbon 或 Spring Cloud LoadBalancer 会负责将请求负载均衡到多个 AI 服务实例。

12.4 灰度发布与 A/B 测试

当我们需要升级 AI 模型(例如从 GPT-3.5 切换到 GPT-4)或修改提示词时,直接全量发布存在风险。通过灰度发布(金丝雀发布),我们可以让一小部分流量先使用新版本,验证无误后再逐步扩大范围。

12.4.1 基于元数据的版本标识

在服务注册时,可以为实例添加元数据,标识其版本。例如在 application.yml 中:

yaml 复制代码
spring:
  cloud:
    nacos:
      discovery:
        metadata:
          version: v1   # 或 v2

12.4.2 在 AI 服务中支持多模型

我们可以让一个 AI 服务实例内部支持多个模型,但更常见的做法是部署两个不同版本的服务实例(如 v1 使用 GPT-3.5,v2 使用 GPT-4),通过网关或客户端负载均衡根据策略选择调用哪个版本。

12.4.3 使用 Spring Cloud Gateway 进行灰度路由

Spring Cloud Gateway 可以根据请求头、参数等条件将请求路由到不同版本的服务。

引入 Gateway 依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

Gateway 配置文件示例:

yaml 复制代码
spring:
  cloud:
    gateway:
      routes:
        - id: ai-service-v1
          uri: lb://ai-service
          predicates:
            - Path=/ask/**
            - Header=version, v1   # 请求头 version=v1 时路由到 v1 版本
          filters:
            - SetPath=/ask
        - id: ai-service-v2
          uri: lb://ai-service
          predicates:
            - Path=/ask/**
            - Header=version, v2   # 请求头 version=v2 时路由到 v2 版本
          filters:
            - SetPath=/ask
        - id: ai-service-default
          uri: lb://ai-service
          predicates:
            - Path=/ask/**
          filters:
            - SetPath=/ask

但上述配置是基于服务级别的,无法直接区分同一服务的不同版本实例。要实现版本感知的路由,需要结合 服务发现元数据负载均衡策略

12.4.4 自定义负载均衡规则

我们可以编写自定义的负载均衡规则,根据请求的版本标识选择对应元数据的实例。

java 复制代码
public class VersionLoadBalancer implements ReactorServiceInstanceLoadBalancer {

    private final String serviceId;
    private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;

    public VersionLoadBalancer(String serviceId,
                               ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider) {
        this.serviceId = serviceId;
        this.serviceInstanceListSupplierProvider = serviceInstanceListSupplierProvider;
    }

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        // 从请求中获取版本信息(例如从 Header 中)
        String version = null;
        if (request instanceof RequestDataContext) {
            RequestDataContext context = (RequestDataContext) request;
            HttpHeaders headers = context.getClientRequest().getHeaders();
            version = headers.getFirst("version");
        }

        String finalVersion = version;
        return serviceInstanceListSupplierProvider.get()
                .select(serviceId)
                .next()
                .map(instances -> {
                    List<ServiceInstance> filteredInstances = instances;
                    if (finalVersion != null) {
                        filteredInstances = instances.stream()
                                .filter(inst -> finalVersion.equals(inst.getMetadata().get("version")))
                                .collect(Collectors.toList());
                    }
                    if (filteredInstances.isEmpty()) {
                        return Response.error(new NoAvailableInstanceException("No instance for version: " + finalVersion));
                    }
                    // 随机选择一个
                    int index = new Random().nextInt(filteredInstances.size());
                    return Response.of(filteredInstances.get(index));
                });
    }
}

然后通过配置类将自定义负载均衡器应用到 AI 服务:

java 复制代码
@Configuration
public class LoadBalancerConfig {

    @Bean
    public ReactorLoadBalancer<ServiceInstance> reactorServiceInstanceLoadBalancer(
            Environment environment,
            LoadBalancerClientFactory loadBalancerClientFactory) {
        String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
        return new VersionLoadBalancer(name,
                () -> loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class));
    }
}

bootstrap.yml 中开启针对 ai-service 的自定义负载均衡:

yaml 复制代码
spring:
  cloud:
    loadbalancer:
      configurations: health-check
    nacos:
      discovery:
        metadata:
          version: v1

ai-service:
  ribbon:
    NFLoadBalancerRuleClassName: com.example.demo.loadbalancer.VersionLoadBalancer # 如果使用 Ribbon,但推荐使用 Spring Cloud LoadBalancer

由于 Spring Cloud 2020 以后默认使用 Spring Cloud LoadBalancer,上述配置方式更贴合新版本。

12.4.5 测试灰度发布

  1. 启动两个 AI 服务实例,一个 metadata.version=v1,另一个 metadata.version=v2。
  2. 启动 Gateway(或直接使用客户端负载均衡的调用方,如订单服务)。
  3. 调用时携带 Header version: v1,请求应被路由到 v1 实例;携带 version: v2 路由到 v2 实例;不携带则随机选择一个。
  4. 通过调整灰度策略(例如按用户 ID 取模),可以实现小流量测试。

12.4.6 基于配置中心的动态切换

灰度策略可以存储在配置中心,通过动态刷新实现实时调整。例如,配置 gray.ratio=10 表示 10% 流量走新版本,然后在负载均衡器中读取该配置,根据随机数决定版本。

12.5 本章小结

通过本章的学习,你将 Spring AI 服务成功融入了 Spring Cloud 生态:

  • 配置中心:使用 Nacos 管理提示词模板,实现动态刷新,无需重启服务。
  • 服务发现:将 AI 服务注册到 Nacos,供其他业务服务通过负载均衡调用。
  • 灰度发布:通过实例元数据和自定义负载均衡策略,实现版本感知的路由,支持平滑升级和 A/B 测试。

十三、总结与最佳实践

恭喜你完成了整个 Spring AI 学习之旅!从最初的环境搭建到最后的微服务集成,你已经系统地掌握了 Spring AI 的所有核心组件,并亲手实践了从简单聊天到复杂 RAG 知识库的构建。现在,让我们一起来回顾所学内容,并探讨在生产环境中如何做出明智的技术选型,最后为你指明继续深入的方向。

13.1 回顾 Spring AI 所有组件及适用场景

在整个教程中,我们逐步探索了以下组件,每个组件都有其独特的用途。下面的表格可以帮助你快速回顾:

组件类别 核心组件/概念 适用场景 你学到的实践
基础模型 ChatClient, ChatModel 任何需要与大模型对话的地方 使用 prompt().user().call().content() 发送消息,获取回复
流式响应 stream(), Flux<ChatResponse> 需要实时展示生成内容(打字机效果) 结合 WebFlux 返回 text/event-stream,前端用 EventSource 接收
提示词管理 PromptTemplate, @Value 加载模板 动态构造用户消息,避免硬编码 在 resources 中定义模板文件,用变量替换占位符
结构化输出 entity(Class<T>) 让 AI 返回 Java 对象,无需手动解析 JSON 定义 POJO,调用 call().entity(Person.class) 直接获取对象
函数调用 @Tool 注解,ChatClient.tools() 让 AI 获取实时信息或执行操作(如查天气、查订单) 编写工具 Bean,注册到 ChatClient,AI 自动调用
多模态 ImageModel, AudioModel, 支持图片的 UserMessage 图像生成、图像理解、语音合成/识别 使用 ImageClient 生成图片,在聊天中附加图片让 AI 描述
文档处理 DocumentReader (PDF, TXT), TokenTextSplitter 从各种格式文件中提取文本并分割 加载 PDF 或 TXT 文档,分割成适合嵌入的块
向量存储 VectorStore (PGvector, Redis, Milvus 等) 存储文档向量,用于相似度检索 配置向量数据库,调用 vectorStore.add(documents)
检索增强 QuestionAnswerAdvisor, DocumentRetriever 让 AI 基于私有知识库回答问题(RAG) 在 ChatClient 中添加检索顾问,自动注入检索结果
高级 RAG QueryTransformer, HybridSearch, Reranking 提升检索准确率,处理复杂查询 可扩展检索流程,结合压缩查询、多路召回等
注解式开发 自定义 @AiPrompt + AOP 简化 AI 服务调用,提示词与业务代码分离 定义注解,切面自动渲染模板并调用 ChatClient
缓存 Spring Cache (@Cacheable) + Caffeine/Redis 减少重复调用,提升响应速度,节省成本 在服务方法上添加 @Cacheable,配置缓存管理器
监控 Micrometer + Actuator 收集调用次数、耗时、Token 用量 自定义切面记录指标,通过 /actuator/metrics 暴露
微服务集成 Spring Cloud Config, Nacos, 服务发现, 灰度发布 将 AI 服务融入微服务体系,实现配置中心、负载均衡、版本控制 使用配置中心管理提示词,注册到 Nacos,自定义负载均衡实现灰度

一句话总结:Spring AI 通过组件化设计,让你像搭积木一样组合这些模块,快速构建从简单到复杂的 AI 应用,并自然融入 Spring 生态。

13.2 生产环境选型建议

当你准备将应用部署到生产环境时,需要根据实际场景做出更细致的选择。以下是一些关键决策点的建议。

13.2.1 向量数据库选型

在 RAG 应用中,向量存储是核心组件。Spring AI 通过 VectorStore 抽象支持多种实现。

向量数据库 优点 适用场景
PGvector (PostgreSQL 插件) - 如果你已经在使用 PostgreSQL,可以复用现有数据库 - 支持 SQL 查询,与关系数据无缝结合 - 开源免费 中小型项目,数据量在百万级以下,希望简化架构
Milvus / Zilliz Cloud - 专业向量数据库,功能强大,支持十亿级向量 - 丰富的索引类型(IVF、HNSW) - 云服务或自托管 大规模生产环境,对性能和扩展性要求高
Elasticsearch - 强大的全文检索 + 向量检索混合能力 - 分布式、高可用 - 适合日志、文档类数据 需要同时支持关键词搜索和语义搜索,数据量大
Redis (Redis Stack) - 内存数据库,性能极高 - 支持向量搜索和二级索引 - 可作为缓存和向量存储合一 需要低延迟检索,数据量适中(受内存限制)
SimpleVectorStore (内存) - 无需额外基础设施,测试方便 - 数据不持久化 开发测试,小型演示

建议 :对于大多数 Java 后端团队,如果已有 PostgreSQL,PGvector 是最平滑的选择;如果对性能有更高要求且预算充足,可以考虑 MilvusZilliz Cloud;如果团队熟悉 Elasticsearch,它也是一个强大的多面手。

13.2.2 模型选型(云端 vs 本地)

大模型的选择直接影响成本、响应速度和数据隐私。

模型类型 优点 缺点 适用场景
云端模型 (OpenAI, Azure, 通义千问, DeepSeek 等) - 能力强大,持续更新 - 无需自己部署硬件 - 开箱即用 - 调用付费,长期成本高 - 数据需发送到第三方(有隐私风险) - 依赖网络 通用对话、需要强大推理能力的场景,对数据隐私要求不高的项目
本地模型 (Ollama, llama.cpp, Hugging Face) - 数据完全私有 - 一次部署,免费调用(除硬件成本) - 延迟低(无网络开销) - 需要 GPU 资源,硬件成本高 - 模型能力相对较弱(尤其小参数模型) - 部署运维复杂 数据隐私要求极高(如金融、医疗),或需要离线运行

建议

  • 初期可以用云端模型快速验证,如 OpenAI 的 gpt-4o-mini 或通义千问的 qwen-plus,成本较低。
  • 当应用成熟且数据敏感时,可考虑用本地模型替代,例如通过 Ollama 运行 llama3qwen2
  • Spring AI 支持通过配置轻松切换模型提供商(如 spring.ai.openai 换成 spring.ai.ollama),方便做 A/B 测试或灰度迁移。

13.2.3 记忆持久化方案

对于多轮对话,我们需要保存对话历史。Spring AI 提供了 ChatMemory 接口,默认实现是 InMemoryChatMemory(非持久化)。生产环境需要持久化:

  • 基于 Redis :用 Redis 存储对话历史,速度快,支持过期。可以实现 ChatMemory 接口,使用 Redis 的 List 或 Hash 结构。
  • 基于数据库:使用 JPA 将对话存入关系库,适合需要长期保存并分析对话的场景。
  • 基于向量数据库:也可以将对话作为文档存入向量库,用于后续检索,但通常对话记忆是短期需求。

建议 :对于大多数 Web 应用,用 Redis 存储对话历史是最佳选择:性能好,支持自动过期,避免数据库压力。Spring 生态有 spring-data-redis,可以轻松集成。

13.2.4 性能与成本优化

  • 缓存:对于常见问题(如 FAQ),使用 Spring Cache 缓存 AI 回复,可大幅减少重复调用,降低 Token 消耗。注意设置合理的过期时间。
  • 流式响应:对于长回复,使用流式接口提升用户体验,同时避免超时。
  • Token 监控:通过监听器或切面记录每次调用的 Token 用量,设置每日/每月上限,及时告警。
  • 降级方案:当模型服务不可用或超时时,返回预设的默认回复,或切换到备用模型。
  • 请求合并:对于非实时场景,可以将多个请求合并成一个批量请求发送给支持批处理的模型(如有),减少网络开销。
  • 模型蒸馏:对于特定任务,可以微调一个小模型替代大模型,降低成本。

13.2.5 安全合规

  • 内容审核:在用户输入和 AI 输出两端进行审核,防止不当内容。可以使用 OpenAI 的 Moderation API 或集成第三方审核服务。
  • 数据脱敏:在发送给模型之前,对用户输入中的敏感信息(如身份证号、手机号)进行脱敏处理。
  • API 密钥管理:使用环境变量或配置中心管理密钥,避免硬编码。定期轮换密钥。

13.3 后续学习路径推荐

你已经掌握了 Spring AI 的绝大部分功能,接下来可以朝以下方向深入:

  1. 深入学习 Spring AI 官方文档 :Spring AI 正在快速发展,关注 官方文档 获取最新特性和最佳实践。
  2. 探索 RAG 高级技术
    • 混合检索:结合向量检索和关键词检索,提升准确率。
    • 重排序:使用 Cross-encoder 模型对检索结果重新排序,提高相关性。
    • 查询规划:对于复杂问题,拆分成多个子查询再聚合答案。
  3. 尝试 Agent 开发:构建能自主调用多工具的 Agent,例如规划、执行、观察的循环。Spring AI 正在发展 Agent 能力,可关注相关更新。
  4. 关注 Spring AI Alibaba:阿里云提供的 Spring AI 实现,适配通义千问等国产模型,适合国内用户。
  5. 参与社区:给 Spring AI 项目点个 Star,在 GitHub 上提 Issue 或 PR,与其他开发者交流经验。
  6. 结合实际业务落地:将 AI 能力集成到现有系统中,如智能客服、内部知识库、代码生成助手等,并持续优化成本和效果。

附录

A. 常见问题解答(FAQ)

Q1: 为什么我使用 ChatClient 时出现 No qualifying bean of type 'ChatClient.Builder' 错误? A: 确保引入了正确的 Spring AI Starter 依赖(如 spring-ai-openai-spring-boot-starter),并且 Spring Boot 主类上有 @SpringBootApplication 注解。自动配置会创建 ChatClient.Builder 的 Bean。

Q2: 如何切换使用不同的模型(例如从 OpenAI 切换到 Ollama)? A: 修改 application.yml 中的配置,将 spring.ai.openai 替换为 spring.ai.ollama,并引入对应的 Starter 依赖。业务代码中的 ChatClient 注入保持不变。

Q3: 流式响应时,前端收到乱码或无法正确解析? A: 确保 Controller 的 produces = MediaType.TEXT_EVENT_STREAM_VALUE,并且返回的是 Flux<String>Flux<ServerSentEvent>。前端使用 EventSource 时,注意设置正确的字符编码(默认 UTF-8)。

Q4: 结构化输出时,AI 返回的 JSON 无法解析为我的 Java 类? A: 检查你的 Java 类是否有无参构造和 getter/setter(或使用 record)。另外,可以在提示词中明确要求输出 JSON 格式,例如"请以 JSON 格式返回,包含 name、age 字段"。

Q5: 函数调用时,AI 不调用我期望的工具? A: 检查工具的描述是否清晰,参数描述是否准确。可以调整 @Tool 的 name 和 description,确保 AI 能理解何时调用。另外,注意工具方法必须是 public 的,且工具类需要是 Spring Bean。

Q6: RAG 检索结果不相关怎么办? A: 尝试调整分割块大小(chunk size)和重叠(overlap),或者提高相似度阈值(minScore)。也可以考虑使用混合检索或重排序技术。

Q7: 如何获取 Token 用量? A: 使用 ChatResponse response = chatClient.prompt().call();,然后从 response.getMetadata() 中获取。具体实现因模型而异,OpenAI 的 Token 用量可以通过 OpenAiUsage 类获取。

Q8: Spring AI 和 LangChain4j 有什么区别? A: Spring AI 是 Spring 官方项目,深度集成 Spring 生态,适合已有 Spring 技术栈的团队;LangChain4j 是社区驱动的独立框架,更加轻量,组件更丰富。两者各有优势,可根据团队偏好选择。

B. 完整代码示例仓库地址

为了方便你查阅和运行,本教程的所有代码示例已整理到一个 GitHub 仓库中:

👉 gitee.com/youhei/spri...

仓库结构按照章节组织,每个示例都是独立的可运行模块,并包含详细的 README 说明。

C. 参考资源链接

相关推荐
独泪了无痕3 小时前
Vue3中防御XSS攻击的“特效药”-DOMPurify
前端·vue.js·安全
小小19923 小时前
idea 配置less转化为css
前端·css·less
hhb_6183 小时前
Less嵌套避坑:优先级冲突实战解析
前端·css·less
云水一下3 小时前
Vue.js从零到精通系列(五):全局状态管理——Pinia 核心与实践
前端·javascript·vue.js
我不是外星人4 小时前
浅谈我对 AI 发展的看法
前端·ai编程·claude
码不停蹄的玄黓4 小时前
Spring Bean 生命周期
java·后端·spring
西安邮电大学4 小时前
分治算法详细讲解
java·后端·其他·算法·面试
老马聊技术4 小时前
AI对话功能之SpringBoot整合Vue3
vue.js·人工智能·spring boot·后端
甲维斯4 小时前
测一波Kimi K2.7,消耗一周配额!
前端·人工智能·游戏开发
Dick5074 小时前
ROS2 多机器人通用 Driver 层复盘:BaseRobotDriver 到多平台 Mock 切换实现
前端·javascript·机器人