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

0 阅读38分钟

九、构建企业级知识库: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。可以在配置类中定义:

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. 将检索到的文档内容格式化后插入到用户消息前面,形成增强后的提示词。

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

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

9.1.3 创建 RAG 控制器

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

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:将查询翻译成其他语言后再检索(如果文档是多语言的)。

使用示例:

QueryTransformer transformer = new CompressingQueryTransformer(chatModel);

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

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:

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 配置类

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 控制器支持流式

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 而不是纯文本来获取这些信息。

@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 处理的方法。

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 起步依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

10.3.2 创建切面类

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 接口

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 生成代理。这里我们采用最简单的方式:创建一个实现类,但实现类中什么也不做,因为切面会拦截。

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 中使用

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)

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

然后在切面中判断:

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 依赖:

<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 缓存:

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

或者在 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。我们可以这样添加缓存:

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 依赖

<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 中暴露需要的端点:

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

11.2.3 自定义指标收集

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

首先,注入 MeterRegistry

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 的方法:

@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 服务类添加缓存

@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 控制器

@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 为例,因为它功能强大且在国内广泛使用。

引入依赖:

<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

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,内容可以包含提示词模板:

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

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

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

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。

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

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

@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 引入依赖

<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 中添加:

spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
        service: ai-service  # 注册的服务名

12.3.3 启用服务发现

在主类上添加 @EnableDiscoveryClient

@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 来调用。

订单服务配置:

spring:
  application:
    name: order-service
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

配置 RestTemplate:

@Configuration
public class OrderServiceConfig {
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

调用 AI 服务:

@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 中:

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 依赖:

<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 配置文件示例:

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 自定义负载均衡规则

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

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 服务:

@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 的自定义负载均衡:

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 中添加检索顾问,自动注入检索结果
高级 RAGQueryTransformer, 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. 参考资源链接