Spring AI 2.0 来了,Java AI智能体开发正式起飞。。。

0 阅读17分钟

大家好,我是Java1234_小锋老师。

Spring AI 2.0 从入门到精通:架构解析与实战指南

一篇写给 Java 后端工程师的 Spring AI 2.0 全景手册。我会按照"它是什么 → 怎么用 → 怎么设计的"这条主线来组织内容,中间穿插架构图、流程图、可直接落地的代码片段,以及在生产环境踩过的一些经验。


一、写在前面:为什么是 Spring AI 2.0

最近两年,Java 生态里关于"如何把大模型搬进自家系统"这件事,一直处于一种半明半暗的状态。Python 那边 LangChain、LlamaIndex 已经卷出了花,而 Java 这边大多数团队还停留在自己写 HTTP 客户端、手动拼 Prompt、一遍遍处理 JSON 的阶段。

Spring AI 的出现就是为了把这件事做得更"Spring 一点"——也就是:约定优于配置、面向接口编程、可观测、可测试、可替换。

到了 2.0 版本,几个关键能力被正式确立下来:

  • 统一的 ChatClient API,所有模型厂商一个写法;
  • Tool Calling / Function Calling 完整支持,并和 Java 方法签名打通;
  • Advisors 机制,让 RAG、记忆、日志、限流这些"横切关注点"可以像 Servlet Filter 一样组合;
  • MCP(Model Context Protocol) 客户端与服务端能力,开始向 Agent 时代靠拢;
  • Micrometer + OpenTelemetry 全链路可观测;
  • Spring Boot 3.5+ / Java 17+ 作为基线,全面拥抱新特性。

如果说 1.x 是"试试看",那么 2.0 就是"可以放进生产了"。下面我会带你从最简单的 Hello World,一直走到一个完整的企业知识库问答系统。


二、Spring AI 2.0 是什么

2.1 它要解决什么问题

把一个大模型调用塞进 Spring Boot 项目,理论上一个 RestTemplate 就够用。但真正在做的时候,你会发现需要解决一堆"不那么 AI"的问题:

  1. 模型可替换:今天用 OpenAI,明天老板说要切阿里通义,再后天上私有化 Ollama,代码不能跟着改三遍。
  2. Prompt 工程化:模板、变量替换、版本管理、A/B 测试,全靠字符串拼接很快就会失控。
  3. 结构化输出:模型返回一段散文,但你的下游系统要 List<Order>,谁来负责解析、校验、重试?
  4. 工具调用:模型说"帮我查一下今天的订单",怎么把这句话变成对 OrderService.queryToday() 的真实调用?
  5. 检索增强:业务数据是私有的,模型不可能知道,需要从向量库里捞出来再喂给它。
  6. 可观测:调用花了多少 Token?平均延迟多少?哪个 Prompt 命中率最高?没有这些数据,调优就是玄学。

Spring AI 2.0 把上面这些问题,一个一个都给了官方答案。

2.2 2.0 相比 1.x 的关键变化

维度1.x 时代2.0 时代
主入口ChatModel.call(...) 偏底层ChatClient 流畅链式 API
工具调用@AiFunction 注解,限制较多@Tool 注解,支持 POJO 参数、可空值、嵌套对象
Advisor概念存在但生态薄官方提供 RAG、Memory、SafeGuard、Logging 等开箱即用
MCP不支持内建 spring-ai-starter-mcp-client / -server
可观测仅日志Micrometer Metrics + Tracing + Token 统计
向量库5、6 种15+ 种主流向量库官方 Starter
Java 版本Java 17Java 17,推荐 Java 21
Spring Boot3.2+3.5+

简单一句话:1.x 是"能用",2.0 是"好用"。


三、五分钟快速入门

3.1 环境与依赖

先确保本机:

  • JDK 17 或更高(推荐 21);
  • Maven 3.9+ 或 Gradle 8+;
  • 一个可用的模型 API Key(这里以 OpenAI 为例,国内可换 DashScope、ZhipuAI,写法基本一致)。

新建一个 Spring Boot 3.5 工程,pom.xml 关键依赖:

<properties>
    <java.version>21</java.version>
    <spring-ai.version>2.0.0</spring-ai.version>
</properties><dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement><dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
​
    <!-- 选用 OpenAI 适配器;要换厂商,改这一个依赖即可 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

application.yml 中配置 API Key:

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini
          temperature: 0.7

小提示:把 Key 放到环境变量里,不要直接写到 yaml 提交到仓库,这是底线。

3.2 第一个 ChatClient 调用

@RestController
@RequiredArgsConstructor
public class HelloAiController {
​
    private final ChatClient chatClient;
​
    public HelloAiController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }
​
    @GetMapping("/ai/chat")
    public String chat(@RequestParam String q) {
        return chatClient.prompt()
                .user(q)
                .call()
                .content();
    }
}

启动应用,访问 http://localhost:8080/ai/chat?q=用一句话介绍 Spring AI,模型的回答就回来了。

注意几个细节:

  • ChatClient.Builder 由 starter 自动装配进来,你只需要 build()
  • prompt().user(...).call().content() 是最常用的"傻瓜链路";
  • 如果你换成阿里通义,只需把依赖换成 spring-ai-starter-model-dashscope,代码一行不动。

3.3 流式输出与 SSE

聊天场景一定要支持流式,不然用户盯着转圈会很难受。Spring AI 用 Reactor 的 Flux<String> 表达流:

@GetMapping(value = "/ai/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam String q) {
    return chatClient.prompt()
            .user(q)
            .stream()
            .content();
}

前端用 EventSource 监听,模型每吐一个 Token 就推一段,体验立刻就上来了。


四、整体架构与分层

4.1 架构总览

下面这张图是 Spring AI 2.0 的整体分层,从上到下分别是应用层、核心 API、增强能力、集成层和适配层:

spring-ai-architecture.png

逐层解释一下:

  • Application Layer:你的业务代码,可能是 MVC 控制器、WebFlux、批处理任务,甚至是一个命令行工具;
  • Core APIChatClientChatModelEmbeddingModelImageModel 等,是面向开发者的统一抽象;
  • Advanced Features:Advisors、Tool Calling、结构化输出、RAG、Memory、Observability,都是 2.0 重点打磨的部分;
  • Integration Layer:向量库 API、文档读取与切分、MCP 协议、Prompt 模板,处于"管道"位置;
  • Provider Adapters:把上面所有抽象,落到具体的 OpenAI、Anthropic、Ollama、PGVector、Milvus 等实现上。

这种分层带来的最大好处是——业务代码只依赖上两层,下面的实现可以随时换

4.2 一次请求在内部的旅行

我们把"用户发了一个问题"这件事,画成一个时序图看一看:

image.png

整个调用链清晰、可插拔——每一个 Advisor 都可以独立测试,也可以独立替换。


五、核心技术深入

5.1 ChatClient:流畅的链式 API

ChatClient 是 2.0 推荐的主入口。它把"准备 Prompt → 调用模型 → 处理响应"这三步,封装成一条链路:

ChatResponse response = chatClient.prompt()
        .system("你是一名资深 Java 架构师,回答尽量简洁。")
        .user(u -> u.text("如何理解 {topic}?").param("topic", "Spring AI Advisor"))
        .options(OpenAiChatOptions.builder()
                .model("gpt-4o")
                .temperature(0.3)
                .build())
        .call()
        .chatResponse();

链式 API 的好处是"读起来像在描述需求"。每一步都是可选的,没有调用的就用默认值,符合最小惊讶原则。

构造 ChatClient 的几种典型姿势:

@Configuration
public class ChatClientConfig {
​
    // 默认的 ChatClient
    @Bean
    ChatClient defaultChatClient(ChatClient.Builder builder) {
        return builder.build();
    }
​
    // 给客服场景定制一个:带固定 system prompt + 默认 advisor
    @Bean("supportChatClient")
    ChatClient supportChatClient(ChatClient.Builder builder,
                                 ChatMemory chatMemory) {
        return builder
                .defaultSystem("你是公司客服,请用礼貌的语气回答。")
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }
}

不同业务场景用不同的 ChatClient,互不污染,这是我比较推崇的做法。

5.2 Prompt 与 PromptTemplate

Prompt 看似就是一段字符串,但生产环境里它的复杂度远超想象。Spring AI 提供了 PromptTemplate 来管理这些"带变量的字符串":

String template = """
        请基于下面的产品资料,回答用户问题。
        ---
        资料:
        {context}
        ---
        用户问题:{question}
        要求:
        1. 只使用资料中的信息;
        2. 如果资料中没有,请回答"暂未收录"。
        """;
​
PromptTemplate pt = new PromptTemplate(template);
Prompt prompt = pt.create(Map.of(
        "context", retrievedDocs,
        "question", "保修期是多久?"
));
​
String answer = chatClient.prompt(prompt).call().content();

更进一步,PromptTemplate 支持从 Resource 加载,方便把 Prompt 当成"配置"管理:

@Value("classpath:/prompts/qa-template.st")
private Resource qaTemplate;

PromptTemplate pt = new PromptTemplate(qaTemplate);

这样产品经理修改文案时,连应用都不用重新打包。

5.3 结构化输出(Structured Output)

模型默认返回散文,但绝大多数业务场景需要结构化数据。2.0 提供了 entity() 方法,直接把结果反序列化成 Java 对象:

public record TripPlan(String city, int days, List<String> highlights) {}

TripPlan plan = chatClient.prompt()
        .user("帮我安排一个 3 天的杭州行程,列出 5 个亮点景点。")
        .call()
        .entity(TripPlan.class);

它的内部其实做了三件事:

  1. 用反射读出 TripPlan 的 JSON Schema;
  2. 自动在 Prompt 里追加 "请按下面的 JSON Schema 返回..." 的指令;
  3. 收到响应后用 Jackson 反序列化,失败会抛 RuntimeException,方便上层重试。

如果需要返回集合,用 ParameterizedTypeReference

List<TripPlan> plans = chatClient.prompt()
        .user("分别给上海、杭州、苏州各设计一个 2 天行程")
        .call()
        .entity(new ParameterizedTypeReference<List<TripPlan>>() {});

我个人很喜欢这个特性——它把"模型 → 业务对象"的鸿沟基本抹平了。

5.4 Tool / Function Calling

Function Calling 是大模型从"会聊天"走向"能干活"的关键一步。Spring AI 2.0 用 @Tool 注解,让普通的 Java 方法变成模型可调用的工具:

@Component
public class OrderTools {

    private final OrderService orderService;

    public OrderTools(OrderService orderService) {
        this.orderService = orderService;
    }

    @Tool(description = "根据订单号查询订单详情")
    public OrderVO getOrderById(
            @ToolParam(description = "订单号,例如 NO20260101001") String orderId) {
        return orderService.getById(orderId);
    }

    @Tool(description = "根据用户ID查询最近 N 天的订单")
    public List<OrderVO> recentOrders(Long userId, int days) {
        return orderService.recent(userId, days);
    }
}

调用时把工具注册进去:

String reply = chatClient.prompt()
        .user("帮我查一下订单号 NO20260101001 的发货状态")
        .tools(orderTools)
        .call()
        .content();

整个调用过程的内部流转:

image.png

这套机制让"AI 能调用业务方法"变得几乎零成本。需要注意几点:

  • 方法描述(description)一定要写清楚,模型靠这个判断"该不该用、该怎么用";
  • 参数最好用基本类型或简单 POJO,过度复杂的对象会让模型生成的参数容易出错;
  • 工具方法要做好幂等和权限校验,永远不要相信模型生成的参数

5.5 Advisors:横切关注点的"过滤器链"

Advisor 可以理解成 ChatClient 的 "Filter Chain"。它在请求进入模型前可以改写 Prompt,在响应返回后可以做加工。常见 Advisor 包括:

Advisor作用
SimpleLoggerAdvisor打印请求/响应日志,调试神器
MessageChatMemoryAdvisor自动把历史消息拼到 Prompt 里
QuestionAnswerAdvisor自动做 RAG,从向量库捞 context 注入
SafeGuardAdvisor关键词拦截、敏感词替换
自定义 Advisor限流、审计、缓存、Token 控制等

注册方式很 Spring:

ChatClient client = builder
        .defaultAdvisors(
                new SimpleLoggerAdvisor(),
                MessageChatMemoryAdvisor.builder(chatMemory).build(),
                QuestionAnswerAdvisor.builder(vectorStore)
                        .searchRequest(SearchRequest.builder().topK(4).build())
                        .build()
        )
        .build();

写一个自己的 Advisor 也不难,比如做"请求计数":

public class CountingAdvisor implements CallAdvisor {

    private final AtomicLong counter = new AtomicLong();

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request,
                                         CallAdvisorChain chain) {
        counter.incrementAndGet();
        log.info("第 {} 次调用 LLM", counter.get());
        return chain.nextCall(request);
    }

    @Override public String getName() { return "counting"; }
    @Override public int getOrder()  { return 0; }
}

写法和 Servlet Filter / Spring Interceptor 几乎是同一个心智模型,老 Spring 玩家上手没难度。

5.6 Embedding 与 Vector Store

要做 RAG,先得有 Embedding 和 Vector Store。Spring AI 的抽象很干净:

@Autowired EmbeddingModel embeddingModel;
@Autowired VectorStore vectorStore;

// 1. 算 Embedding
EmbeddingResponse resp = embeddingModel.embedForResponse(List.of("你好,世界"));
float[] vector = resp.getResults().get(0).getOutput();

// 2. 存进向量库
vectorStore.add(List.of(
        new Document("Spring AI 是一个 Java 的 AI 框架。",
                Map.of("source", "official-doc", "version", "2.0"))
));

// 3. 检索
List<Document> hits = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("什么是 Spring AI?")
                .topK(3)
                .similarityThreshold(0.7)
                .build());

Vector Store 有 15+ 种官方实现,PG Vector 是我比较推荐的入门选择,因为:

  • 直接复用 PostgreSQL,无需新增中间件;
  • 支持精确过滤 + 向量混合检索;
  • 运维成熟,备份恢复都有现成方案。

引入也只是改一行依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>

5.7 RAG:基于检索的增强生成

RAG 的核心思想:模型不需要无所不知,只需要在被问到时,去合适的地方查一下,再基于查到的内容回答。它分两条流水线,一条索引,一条问答。整个流程如下图:

spring-ai-rag-pipeline.png

具体到代码,索引这边长这样:

@Service
@RequiredArgsConstructor
public class IndexingService {

    private final VectorStore vectorStore;

    public void indexPdf(Resource pdf) {
        var reader = new PagePdfDocumentReader(pdf);
        var splitter = new TokenTextSplitter(800, 80, 5, 10000, true);

        List<Document> chunks = splitter.apply(reader.get());
        vectorStore.add(chunks);
    }
}

问答这边,最简单的写法是直接挂上 QuestionAnswerAdvisor

String answer = chatClient.prompt()
        .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(4).build())
                .build())
        .user(question)
        .call()
        .content();

一行代码,RAG 就跑起来了。这背后其实做了非常多的事情:把问题转成 embedding、去向量库捞 topK、把命中文档塞进 system prompt、再调用模型。这个抽象的成熟度,是 2.0 给我最大的惊喜之一。

5.8 Memory:让对话拥有记忆

模型本身是无状态的,每次调用都是"失忆症患者"。ChatMemory 接口就是用来管理对话历史的:

@Bean
ChatMemory chatMemory() {
    return MessageWindowChatMemory.builder()
            .maxMessages(20)
            .build();
}

@Bean
ChatClient chatClient(ChatClient.Builder builder, ChatMemory memory) {
    return builder
            .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
            .build();
}

调用时通过 conversationId 区分不同会话:

String reply = chatClient.prompt()
        .user(q)
        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
        .call()
        .content();

MessageWindowChatMemory 是基于"最近 N 条"的窗口策略,简单但够用。生产环境如果对话很长,建议改成"摘要 + 窗口"的混合策略,或者把历史落到 Redis、PostgreSQL 里持久化,避免重启就丢光。

5.9 MCP(Model Context Protocol)支持

MCP 是 Anthropic 牵头推动的协议,目标是给"AI 怎么调用外部工具"建立一套标准。Spring AI 2.0 内置了 MCP 客户端和服务端的 Starter:

<!-- 把现有 Spring 应用变成 MCP Server -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-server</artifactId>
</dependency>

<!-- 让 ChatClient 能调用任意 MCP Server -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>

服务端配置一下要暴露的工具:

spring:
  ai:
    mcp:
      server:
        name: order-mcp
        version: 1.0.0

客户端连过去:

spring:
  ai:
    mcp:
      client:
        sse:
          connections:
            order:
              url: http://internal.order-mcp.svc/mcp

注入并使用:

@Autowired
List<McpSyncClient> mcpClients;

String resp = chatClient.prompt()
        .user("帮我看看上周的订单异常情况")
        .toolCallbacks(mcpClients.stream()
                .flatMap(c -> Arrays.stream(c.listTools().tools()))
                .toList())
        .call()
        .content();

MCP 的最大价值在于"工具的解耦"——一个工具实现,可以被任何遵循 MCP 协议的 Agent / Client 复用,而不再绑死在某一个框架里。

5.10 Observability:可观测性是生产线的灯

Spring AI 2.0 把 Micrometer 和 OpenTelemetry 接得很彻底,几乎所有关键节点都打了点:

  • spring.ai.chat.client:ChatClient 调用次数、耗时;
  • spring.ai.chat.model:模型层调用次数、耗时;
  • gen_ai.client.token.usage:Prompt Token、Completion Token、Total Token;
  • spring.ai.advisor:每个 Advisor 的耗时。

只需要引入 actuator 和 micrometer-prometheus 即可:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

接到 Grafana 后,可以画出 Token 消耗、平均延迟、模型错误率这些核心指标。没有这些指标,就不要谈 AI 上生产


六、综合实战:构建一个企业知识库问答系统

理论再好,不落地都是耍流氓。下面我们用前面所有积木,搭一个最小可用的"企业知识库问答系统"。

6.1 需求与技术选型

需求:

  • 上传 PDF/Markdown 文档,自动切分入库;
  • 用户提问时,先检索后回答,回答末尾附引用;
  • 支持多轮对话,按 sessionId 隔离;
  • 关键指标可观测。

技术选型:

选型
WebSpring Boot 3.5 + Spring MVC
AI 框架Spring AI 2.0
模型OpenAI gpt-4o-mini(也可换通义千问)
EmbeddingOpenAI text-embedding-3-small
向量库PostgreSQL + pgvector
业务库MySQL 8.0
监控Prometheus + Grafana

6.2 数据库设计

按要求,库名以 db_ 开头,表名以 t_ 开头。这里业务库设计如下:

CREATE DATABASE db_kb_qa DEFAULT CHARACTER SET utf8mb4;
USE db_kb_qa;

CREATE TABLE t_user (
    id           BIGINT PRIMARY KEY AUTO_INCREMENT,
    username     VARCHAR(64)  NOT NULL UNIQUE,
    password_md5 CHAR(32)     NOT NULL COMMENT '密码 MD5',
    role         VARCHAR(16)  NOT NULL DEFAULT 'USER',
    create_time  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB COMMENT = '用户表';

CREATE TABLE t_document (
    id           BIGINT PRIMARY KEY AUTO_INCREMENT,
    title        VARCHAR(256) NOT NULL,
    file_path    VARCHAR(512) NOT NULL,
    chunk_count  INT          NOT NULL DEFAULT 0,
    upload_user  BIGINT       NOT NULL,
    create_time  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB COMMENT = '知识文档表';

CREATE TABLE t_chat_log (
    id           BIGINT PRIMARY KEY AUTO_INCREMENT,
    session_id   VARCHAR(64)  NOT NULL,
    user_id      BIGINT       NOT NULL,
    question     TEXT         NOT NULL,
    answer       MEDIUMTEXT   NOT NULL,
    prompt_token INT          NOT NULL,
    reply_token  INT          NOT NULL,
    cost_ms      INT          NOT NULL,
    create_time  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_session (session_id),
    INDEX idx_user (user_id)
) ENGINE = InnoDB COMMENT = '对话日志表';

-- 初始化一个管理员账号,原密码 123456,MD5 后入库
INSERT INTO t_user(username, password_md5, role)
VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', 'ADMIN');

application.yml 中数据库连接:

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/db_kb_qa?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: "123456"

6.3 文档导入服务

@Service
@RequiredArgsConstructor
@Slf4j
public class DocumentImportService {

    private final VectorStore vectorStore;
    private final DocumentRepository documentRepo;

    @Transactional
    public Long importPdf(MultipartFile file, Long uploadUserId) throws IOException {
        Path saved = saveToDisk(file);

        var reader = new PagePdfDocumentReader(new FileSystemResource(saved));
        var splitter = new TokenTextSplitter(800, 80, 5, 10000, true);

        List<Document> chunks = splitter.apply(reader.get()).stream()
                .peek(d -> d.getMetadata().put("source", file.getOriginalFilename()))
                .toList();

        vectorStore.add(chunks);

        DocumentEntity entity = new DocumentEntity();
        entity.setTitle(file.getOriginalFilename());
        entity.setFilePath(saved.toString());
        entity.setChunkCount(chunks.size());
        entity.setUploadUser(uploadUserId);
        documentRepo.save(entity);

        log.info("导入文档 {} 完成,共 {} 个 chunk", file.getOriginalFilename(), chunks.size());
        return entity.getId();
    }

    private Path saveToDisk(MultipartFile file) throws IOException {
        Path dir = Paths.get("upload");
        Files.createDirectories(dir);
        Path target = dir.resolve(System.currentTimeMillis() + "_" + file.getOriginalFilename());
        file.transferTo(target);
        return target;
    }
}

6.4 RAG 问答接口

@Service
@RequiredArgsConstructor
public class QaService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;
    private final ChatLogRepository chatLogRepo;

    public QaResult ask(String sessionId, Long userId, String question) {
        long start = System.currentTimeMillis();

        ChatResponse response = chatClient.prompt()
                .system("""
                        你是公司内部知识库问答助手。
                        请基于检索到的资料回答问题;如果资料中没有,请明确说明"暂未收录"。
                        回答末尾用 [来源] 标注引用文档名。
                        """)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
                .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                        .searchRequest(SearchRequest.builder()
                                .topK(4)
                                .similarityThreshold(0.65)
                                .build())
                        .build())
                .user(question)
                .call()
                .chatResponse();

        String answer = response.getResult().getOutput().getText();
        Usage usage = response.getMetadata().getUsage();

        ChatLogEntity log = new ChatLogEntity();
        log.setSessionId(sessionId);
        log.setUserId(userId);
        log.setQuestion(question);
        log.setAnswer(answer);
        log.setPromptToken(usage.getPromptTokens());
        log.setReplyToken(usage.getCompletionTokens());
        log.setCostMs((int) (System.currentTimeMillis() - start));
        chatLogRepo.save(log);

        return new QaResult(answer, usage.getTotalTokens(), log.getCostMs());
    }
}

控制器层就是 thin wrapper:

@RestController
@RequestMapping("/qa")
@RequiredArgsConstructor
public class QaController {

    private final QaService qaService;

    @PostMapping
    public QaResult ask(@RequestHeader("X-Session-Id") String sessionId,
                        @RequestAttribute("userId") Long userId,
                        @RequestBody @Valid QaRequest req) {
        return qaService.ask(sessionId, userId, req.question());
    }

    public record QaRequest(@NotBlank String question) {}
}

6.5 调用链路图

把整个系统的关键调用串起来,画一张端到端的流程图:

image.png

到这里,一个具备 RAG、记忆、可观测性的企业级问答系统就成形了。所有零件都是 Spring AI 2.0 提供的标准抽象,没有任何"魔改"。


七、生产环境的几个建议

写到这里,分享一些我自己在项目里踩过的坑:

  1. Token 一定要有预算控制。给每个会话、每个用户设上限,避免某个 Bug 把你公司账单干爆。可以自定义一个 BudgetAdvisoradviseCall 里做拦截。
  2. Prompt 要放进版本管理。把 system prompt、模板都放在 resources/prompts/ 下,配合 Git,谁改了什么、什么时候改的,一目了然。
  3. 结果要做幂等缓存。同一个用户问同一个问题,没必要每次都调一次模型。可以基于 Prompt 的 hash 做 Redis 缓存,命中直接返回。
  4. 工具调用必须做权限校验。不要因为是模型调用过来的就放过权限检查,模型可不会管你哪个用户能看哪条数据。
  5. 流式响应要处理超时。SSE 在网络抖动下很容易"假活",给前端配上心跳和重连机制。
  6. Embedding 模型不要轻易换。一旦换了模型,旧向量基本作废,需要全量重建索引,工作量很大。
  7. 可观测性从第一天就接上。不要等出事了再补埋点,那时候黄花菜都凉了。

八、写在最后

Spring AI 2.0 不是"在 Spring Boot 里加几行调 OpenAI 的代码",而是把 AI 应用的常见模式(RAG、记忆、Agent、可观测)做成了一等公民。它最大的价值在于——让 Java 后端工程师不需要切换语言栈,就能写出接近一线水准的 AI 应用

如果你已经熟悉 Spring 那一套(Bean、Starter、Filter、Actuator),那么 Spring AI 2.0 几乎是"零摩擦"的:你能感觉到熟悉的设计味道,每一个抽象都像是它本来就该长成这样。

下一步建议:

  • 把本文 Demo 跑一遍,跑通一个最小闭环;
  • 找一个团队内部的真实场景(FAQ、客服、运维助手),用 Spring AI 落一个 MVP;
  • 持续关注 MCP 的演进,那是接下来 Agent 互联互通的关键。

工具已经够好了,剩下的就是把它用起来。


附录:常见问题速查

Q1:ChatClientChatModel 有什么区别? ChatModel 是底层调用接口,一次性发送 messages、拿回 response;ChatClient 是基于 ChatModel 之上的链式 API,多了 Advisor、Tool、StructuredOutput 等增强。日常开发优先用 ChatClient

Q2:能不能不依赖云厂商,本地跑? 完全可以。换成 spring-ai-starter-model-ollama,本地起一个 Ollama,下载 llama3qwen 系列模型,代码一行不改即可切换。

Q3:Spring AI 2.0 支持 WebFlux 吗? 支持。所有 stream() 调用返回 Flux,并且 starter 兼容 spring-boot-starter-webflux

Q4:如何降低 RAG 的"幻觉"? 四个方向:① 提高 chunk 质量,避免过长或过短;② 给检索加 metadata 过滤;③ 在 system prompt 里强调"未检索到必须说不知道";④ 后置加一层 ModerationModel 或人工审核。

Q5:测试时不想真调模型怎么办? 可以注入一个 ChatModel 的 mock 实现,或者使用 Spring AI 提供的 TestChatModel,固定返回某段文本,便于在 CI 里跑用例。


本文示例代码均基于 Spring AI 2.0.0 与 Spring Boot 3.5.x 编写,部分 API 细节可能在后续小版本中微调,请以官方文档为准。