Spring AI 教程(下篇)

230 阅读1小时+

面向 Java 开发者的 Spring AI 完整入门到进阶指南(下篇)

版本:Spring AI 1.1.2 · Spring Boot 3.5.x · Java 21+

下篇涵盖:第 7~14 章 —— Function Calling、向量数据库、RAG、多模态、MCP、可观测性与评测、参数调整、关键 API 速查。


第三部分 · 让模型「动手」与「找资料」

第 7 章 Function Calling(工具调用)

模型本身无法查实时数据、调业务系统。Function Calling 让模型在需要时发起工具调用,由你的代码执行并回传结果。

7.1 完整交互流程

Function Calling 的核心是**「决策与执行分离」**:模型只负责判断要不要调用工具、调用哪个、传什么参数,真正的执行始终由你的 Java 代码完成。一次完整流程如下:

注册工具 → 随请求提交 → 模型决策 → 本地执行 → 回传结果 → 生成回答
环节谁来做做什么
注册工具开发者用 @Tool 定义方法,只提交 name、description、入参 Schema 三份元信息(源码不会发给模型)
随请求提交SDK每次请求把已注册工具的元信息一并带给 LLM
模型决策LLM不需要工具就返回文本;需要则输出 tool_calls(工具名 + 参数 JSON,如 {"city":"北京"})
本地执行Java 代码按工具名匹配本地方法,执行真实业务逻辑
回传结果SDK把执行结果封装成 ToolMessage 再发给 LLM
生成回答LLM结合工具结果整理润色,输出最终回答

一句话记住:模型只「点名」调用哪个工具、传什么参数,不能运行任何代码——代码执行与结果回传都由 SDK 和你的 Java 代码完成。

可选捷径 returnDirect:默认流程走完上表六步。若给 @Tool 设 returnDirect = true,则「本地执行」的结果原样直接返回给用户,跳过「回传结果」和「生成回答」两步——不再封装 ToolMessage,也不再让模型二次润色。适合工具本身已返回完整人类可读答案的场景(详见 7.2 的属性表)。

7.2 用 @Tool 定义工具

Spring AI 1.1 推荐用 @Tool / @ToolParam 注解。

说明:@Tool 只是定义工具的方式之一。除此之外还支持函数式定义(FunctionToolCallback)、直接实现 ToolCallback / ToolCallbackProvider、用 MethodToolCallback 手动包装方法,以及接入 MCP 工具等。本文聚焦最常用的 @Tool 方式,其余参见 Function‑Calling 使用手册。

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

@Component
public class WeatherTools {

    @Tool(description = "查询指定城市的当前天气,返回温度和天气状况")
    public String getWeather(@ToolParam(description = "城市名称,例如:北京") String city) {
        // 真实场景这里调用天气 API / 数据库
        return city + " 今天晴,28℃,微风。";
    }
}

@Tool 与 @ToolParam 常用属性:

注解属性默认说明
@Toolname方法名暴露给 LLM 的工具名,可自定义避免重名
@Tooldescription空触发条件与返回值说明,直接影响工具选择
@ToolreturnDirectfalsetrue 时结果直接返回用户,不再让模型二次加工
@ToolresultConverterJSON自定义结果转换器
@ToolParamname参数名参数名
@ToolParamdescription空参数说明,帮助模型正确取值
@ToolParamrequiredtrue是否必填,可选参数设 false 或用 @Nullable

注意:用 record 作为参数时,字段默认可选,必填字段需在方法体内自行校验。

7.3 注册工具并调用

@RestController
public class WeatherController {

    private final ChatClient chatClient;

    public WeatherController(ChatClient.Builder builder, WeatherTools weatherTools) {
        // 方式一:默认工具,所有请求都带上
        this.chatClient = builder.defaultTools(weatherTools).build();
    }

    @GetMapping("/weather")
    public String weather(@RequestParam("message") String message) {
        // 方式二:按请求注册(更灵活,按需加载)
        // return chatClient.prompt().user(message).tools(weatherTools).call().content();
        return chatClient.prompt().user(message).call().content();
    }
}

测试:

curl "http://localhost:8080/weather?message=北京今天天气怎么样"
# 模型识别到需要查天气 → 触发 getWeather 工具 → 回传结果 → 生成最终回答

7.4 返回对象与多参数(进阶)

工具方法不一定要返回 String,可以直接返回 Java 对象(record / DTO),Spring AI 会通过 ToolCallResultConverter 自动序列化成 JSON 回传给模型,模型再据此组织自然语言回答:

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

// 返回类型:record,会被自动转成 JSON 传给模型
public record StockInfo(String symbol, String name, double price, String currency) {}

@Component
public class StockTools {

    // 一个工具方法支持多个参数
    @Tool(description = "查询股票实时价格,返回代码、名称、价格与币种")
    public StockInfo getStockPrice(
            @ToolParam(description = "股票代码,如 AAPL") String symbol,
            @ToolParam(description = "币种,如 USD 或 CNY") String currency) {
        // 真实场景这里调用行情 API
        return new StockInfo(symbol, "Apple Inc.", 189.5, currency);
    }
}

若想改回传格式(例如 YAML / XML),可实现 ToolCallResultConverter 自定义转换器,再用 @Tool 的 resultConverter 属性按工具挂载:

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.tool.converter.ToolCallResultConverter;
import org.springframework.lang.Nullable;
import java.lang.reflect.Type;

// 自定义转换器:把返回对象转成 YAML 文本回传(默认是 JSON)
public class YamlResultConverter implements ToolCallResultConverter {
    @Override
    public String convert(@Nullable Object result, @Nullable Type returnType) {
        if (result == null) return "Done";
        if (result instanceof String) return (String) result;
        return new org.yaml.snakeyaml.Yaml().dump(result); // SnakeYAML 由 Spring Boot 提供
    }
}

@Component
public class StockTools {

    @Tool(
        description = "查询股票实时价格,返回代码、名称、价格与币种",
        resultConverter = YamlResultConverter.class   // 仅该工具生效
    )
    public StockInfo getStockPrice(
            @ToolParam(description = "股票代码,如 AAPL") String symbol,
            @ToolParam(description = "币种,如 USD 或 CNY") String currency) {
        return new StockInfo(symbol, "Apple Inc.", 189.5, currency);
    }
}

说明:ToolCallResultConverter 目前不能作为 Spring bean 全局覆盖,只能逐工具用 resultConverter 指定;且转换器会被框架反射实例化,需无参构造、内部不要依赖注入(否则用静态单例或手动 new)。参数校验可用 JSR-303(如 @NotNull)配合 @Validated 开启方法校验。

7.5 实战:一次请求触发多个工具

当用户的一句话涉及多个能力时,模型会在同一轮里发起多个工具调用,Spring AI 逐个执行后汇总回传,模型再综合生成最终回答。

一次注册多个工具:

@RestController
public class AssistantController {

    private final ChatClient chatClient;

    public AssistantController(ChatClient.Builder builder,
                               WeatherTools weatherTools,   // 见 7.2
                               StockTools stockTools) {     // 见 7.4
        this.chatClient = builder
                .defaultTools(weatherTools, stockTools)    // 一次注册多个工具
                .build();
    }

    @GetMapping("/assistant")
    public String assistant(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }
}

用户提问(一条 prompt 同时需要「天气」和「股价」两类工具):

curl "http://localhost:8080/assistant?message=帮我查一下北京今天的天气,再查一下 AAPL 和 TSLA 的股价,并说说今天更适合关注哪只股票"

执行流程:

用户提问(一句话)
   → 模型识别出 3 个工具调用:
        getWeather("北京") 、 getStockPrice("AAPL") 、 getStockPrice("TSLA")
   → Spring AI 逐个串行执行这些工具(1.x 默认)
   → 3 个结果汇总后回传模型
   → 模型综合生成最终回答(如:北京今天晴、AAPL 涨 TSLA 跌,建议关注 AAPL……)

说明:模型在一次响应里可以同时发起多个工具调用——既可以是不同工具,也可以是同一工具的不同参数(如上面两次 getStockPrice);具体调哪些、怎么调由模型根据提问自行决策,业务代码无需干预。

⚠️ 执行方式:Spring AI 1.x 默认串行执行——即使模型一次返回多个工具调用,底层也是循环逐个执行;要并发执行需升级到 Spring AI 2.0 的并行工具管理器(Parallel Tool Manager),且需手动配置开启。

7.6 不确定性与应对

Function Calling 本身就存在天然不确定性,并不是 100% 可靠——这是工程落地时最头疼的点。先明确:这不是 Spring AI 框架的问题,而是大模型本身的行为特性——模型是基于概率的生成,而非确定性程序,因此「要不要调工具、调哪个、参数对不对」每一步都可能出错。

  1. 不确定性来自哪里:

    • 选错工具:多个工具语义相近时,模型可能选错或漏选;
    • 参数填错:参数类型、取值不符合预期,或该填的字段没填、格式不对;
    • 漏调用 / 过度调用:该调用时不调用(转而「编造」答案),或不该调用时乱调用;
    • 返回不稳定:同样的输入,每次输出可能不完全一致;
    • 描述歧义:工具的 description 写得含糊,模型理解产生偏差。
  2. 生产工程上如何降低不确定性:

    ① 写好工具描述(正例 vs 反例):description 是模型判断「何时用、用什么」的唯一依据,务必写清触发场景、输入输出与边界。

    // ❌ 反例:描述太泛,模型容易误判触发条件、填错参数
    @Tool(description = "处理订单")
    public String handleOrder(@ToolParam(description = "参数") String param) {
        // ...
    }
    
    // ✅ 正例:写清「何时用、返回什么、不做什么」
    @Tool(description = "根据订单号查询订单状态(待支付/已发货/已签收)。"
            + "仅在用户询问「订单到哪了 / 发货了吗 / 订单状态」时调用;"
            + "不处理退款、改地址、下单等操作")
    public String queryOrderStatus(
            @ToolParam(description = "订单号,20 位数字,例如 20240101000000000001") String orderNo) {
        // ...
    }
    

    ② Prompt 层强制约束(SystemPrompt):在系统提示词里给模型设定全局行为边界——什么时候必须调工具、什么时候禁止硬答。

    你是一个订单助手。规则:
    1. 涉及实时数据(订单状态、库存、价格)时必须调用对应工具,不得凭记忆编造;
    2. 工具未返回结果前,禁止输出确定性的结论;
    3. 匹配不到合适工具时,直接说明「该问题超出我的能力范围」,不要硬答。
    

    ③ 做校验层:不要信任模型输出:模型返回的 arguments 只是「建议」,正式执行前必须校验。工具内部用 JSR-303(@NotNull、@Pattern)或手动校验入参;对 tool_calls 里的工具名做白名单匹配,非法工具名 / 参数直接拒绝,并把错误信息回传模型,让它修正重试。

    ④ 区分业务重要程度:不同工具不能同等对待——

    • 低风险工具(查天气、查资料等读操作):可放宽校验,失败重试即可;
    • 高风险工具(转账、下单、删除、发短信等写操作):需二次确认、权限校验、限额控制,必要时不让模型直接触发,只让模型「填表」再由人工确认。

    ⑤ 兜底策略:模型调不出结果或调用失败时,要有「最后一道防线」——返回明确的错误提示引导用户换种问法、降级为人工客服、或走默认只读逻辑,而不是抛异常中断整个链路。同时要注意:Spring AI 1.1.2 无法通过配置限制工具调用轮次/次数(往返轮次硬编码 10 轮,DefaultToolCallingManager 也没有次数限制 API),防死循环主要靠 system prompt 软约束或手写 while 循环硬计数(详见第 13.3 章):

    你是一个订单助手。规则:
    1. 最多执行 3 轮工具调用,拿到所需信息后直接输出最终答案,不要反复调用工具;
    2. 匹配不到合适工具时,直接说明「该问题超出我的能力范围」,不要硬答。
    

    ⑥ 选型层面:对 function-call 要求高的场景,优先选指令遵循强的模型(如 DeepSeek、Qwen 等);很多开源小模型的 function-call 能力很差,工具一多就容易选错或漏调——此时要么换更强的模型,要么收敛工具数量、简化描述来迁就模型能力。

一句话总结:不确定性无法彻底消除,只能靠「清晰的工具描述 + Prompt 约束 + 严格校验 + 分级授权 + 兜底降级」层层兜住,把它压到可接受的范围。

7.7 工具异常处理

工具方法抛异常时,由 ToolExecutionException 包装,ToolExecutionExceptionProcessor 处理。关键开关 spring.ai.tools.throw-exception-on-error(默认 false):

spring:
  ai:
    tools:
      throw-exception-on-error: false   # false:异常以错误消息回传模型;true:直接抛给调用方

最佳实践:

  • 工具内部 try-catch,不要让堆栈信息泄露给用户;
  • 优先 return 错误描述(而非抛异常),让模型能据此调整或向用户解释;
  • 对外部 API 调用加超时保护,捕获 TimeoutException。

7.8 ToolContext 传递上下文

ToolContext 用于传递不发给模型、仅在工具执行侧使用的数据(如 tenantId、当前用户身份),是多租户、权限校验的推荐做法。

为什么不能让模型传:如果 tenantId / userId 变成 @ToolParam,模型就掌握了对它们的「决定权」,恶意 prompt 可以诱导模型改传别人的租户或越权操作(prompt 注入)。所以这类数据必须由服务端从登录态里取,塞进 ToolContext。

① 调用侧:从登录态取数,注入 ToolContext

import java.util.HashMap;
import java.util.Map;

@GetMapping("/order")
public String query(@RequestParam("message") String message) {
    // 服务端可信数据:从登录态/请求上下文取(示例,真实项目换成你的安全框架)
    String tenantId = "tenant-001";
    String userId = "u_10086";

    // 方式一:单个值,直接内联 Map.of(不可变)
    // chatClient.prompt().user(message).toolContext(Map.of("userId", userId)).call().content();

    // 方式二:多个 / 可变数据,先组装成 Map 再传入
    Map<String, Object> ctxMap = new HashMap<>();
    ctxMap.put("tenantId", tenantId);
    ctxMap.put("userId", userId);

    return chatClient.prompt()
            .user(message)
            .toolContext(ctxMap)   // 入参直接是 Map,无需 new ToolContext
            .call()
            .content();
}

💡 API 说明(Spring AI 1.1.2):.toolContext() 的入参直接是 Map<String, Object>,所以调用侧直接传 Map 即可,无需 new ToolContext;ToolContext 对象只作为工具方法的特殊参数出现,工具内部用 toolContext.getContext() 取这个 map。

② 工具侧:用 ToolContext 参数读取(不进 JSON Schema)

@Tool(description = "查询当前租户下的订单状态")
public String queryOrder(
        @ToolParam(description = "订单号") String orderNo,
        ToolContext toolContext) {   // 特殊参数:框架自动注入,不出现在工具的入参 Schema 里

    String tenantId = toolContext.getContext().get("tenantId");
    String userId = toolContext.getContext().get("userId");

    // 用 tenantId 做数据隔离、userId 做权限校验——它们都来自服务端,模型碰不到
    return orderService.query(orderNo, tenantId, userId);
}

对比:模型在 arguments 里只会看到 {"orderNo":"..."},永远看不到 tenantId / userId;这两个值由服务端注入、工具内部读取,天然防篡改。

7.9 底层机制(二次开发速览)

了解 @Tool 背后的核心组件,方便二次开发与排错:

组件职责
ToolCallback一次工具调用的封装(名称 + 描述 + 入参 schema + 执行逻辑)
ToolCallbackProvider提供一组 ToolCallback(@Tool 注解扫描、MCP 工具都基于它)
ToolCallingManager执行工具调用,并把结果回传模型
ToolCallResultConverter把工具返回值转换成回传模型的字符串(默认 JSON)

7.10 全链路日志调试

Function Calling 排查问题时的最大痛点:你只看到最终答案,看不到中间「模型 → 工具 → 模型」的每一跳。工具选错、参数填错、漏调用时,光看结果根本定位不了。把整条链路打印出来,从粗到细有两种方式:

① 开启框架底层日志

最省事,改 application.yml 打开 Spring AI 相关包的 DEBUG 日志:

logging:
  level:
    org.springframework.ai.chat.client: DEBUG   # 聊天客户端
    org.springframework.ai.chat.model: DEBUG     # 模型调用
    org.springframework.ai.tool: DEBUG           # 工具调用
    org.springframework.ai.openai.api: DEBUG     # 原始 HTTP 请求/响应(含 tool_calls);Ollama 换 org.springframework.ai.ollama.api

优点:零代码;缺点:日志量大、噪音多、偏底层 HTTP,不够聚焦业务。

② SimpleLoggerAdvisor(兜底,最简单)

不想写自定义 Advisor 时,直接用 Spring AI 内置的 SimpleLoggerAdvisor,零代码打印每一轮「发给模型 / 模型返回(含 tool_calls)」:

import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;

this.chatClient = builder
        .defaultAdvisors(new SimpleLoggerAdvisor())
        .defaultTools(weatherTools)
        .build();

它默认把每一轮请求消息与模型响应(含 tool_calls)打到日志里,够用就直接拿它兜底;需要裁剪字段、统一格式或接入自己的日志平台时,再自己实现 Advisor 定制。

两种方式互补:① 看底层框架/HTTP 日志,② 用 SimpleLoggerAdvisor 看「模型 ↔ 消息」的每一跳(含 tool_calls)。两者叠加即可还原一条请求的完整现场。

日志关键调试看哪些信息

排查时重点盯这几个字段:

关注点看什么能发现的问题
工具选择tool_calls 里的 function.name选错工具 / 漏调用
入参arguments(JSON)参数填错、缺字段、类型不符
调用次数迭代轮数 / 是否反复调用同一工具死循环、参数一直不对
工具返回工具方法实际返回值返回内容是否够模型组织回答、是否异常
最终回答模型基于工具结果生成的文本是否「编造」、是否偏离工具结果
性能token 消耗、每跳耗时工具过多导致 token 暴涨、慢接口拖累

一句话:Function Calling 的 bug 几乎都藏在「中间那几跳」,看不到链路就只能靠猜——所以先把日志打通,再谈优化。


第 8 章 向量数据库

向量数据库用于存储文本的向量表示,支撑语义检索(RAG 的核心)。Spring AI 用 VectorStore 接口统一抽象,业务代码不感知底层是哪种库——本文以 pgvector 为例。

8.1 概念与接口

  • 嵌入(Embedding):把文本转成高维浮点向量(本质是一串数字),语义相近的文本向量距离更近。这一步由嵌入模型(EmbeddingModel)完成——它就是专门负责「文本 → 向量」的模型,和生成回答的聊天模型(ChatModel)是两回事。
  • VectorStore 核心方法:add(List<Document>) 写入、similaritySearch(SearchRequest) 相似度检索。

所谓「embedding 配置里指定模型」(如 spring.ai.ollama.embedding.options.model: nomic-embed-text),就是告诉程序用哪个模型来做文本向量化。同一件「文本 → 向量」的事,不同模型算出的向量维度不同(nomic-embed-text 768 维、text-embedding-3-small 1536 维),所以要保证向量库的 dimensions 和它一致。

先厘清三个词的关系:插件 / 数据库 / 向量数据库

本文以 pgvector 为例,很多人误以为「pgvector 就是一个独立的向量数据库」——其实它是「数据库 + 插件」的组合:

向量数据库(本文方案) = 数据库(PostgreSQL) + 插件(pgvector 扩展)
概念是什么角色
数据库(PostgreSQL)老牌关系型数据库提供存储、事务、权限等基础能力,原生没有向量类型
插件(pgvector)PostgreSQL 的扩展(extension)给 PG 补上 vector 类型、相似度算子、HNSW/IVFFLAT 索引
向量数据库泛指「专门存向量、做相似度检索」的库分两条路线:原生向量库(Milvus、Qdrant、Chroma)与关系库 + 插件(本文)

关键:pgvector 不是能单独运行的数据库,必须装进 PostgreSQL 才能用。第 8.3 节的 CREATE EXTENSION vector; 就是在「这个数据库里安装这个插件」,装上之后 PostgreSQL 才变成一个有向量检索能力的向量数据库。

Spring AI 用 VectorStore 接口把底层统一抽象,所以无论底层是 pgvector(插件路线)还是 Milvus(原生路线),业务代码都是同一个 add / similaritySearch / delete。

8.2 用 SimpleVectorStore 快速验证(免装库)

在接数据库之前,可以用 Spring AI 内置的内存向量库 SimpleVectorStore 先把 RAG 流程跑通——零外部数据库依赖,适合本地开发、单元测试。

依赖说明:SimpleVectorStore 在 spring-ai-vector-store 模块里(Spring AI 1.0 起从 spring-ai-core 拆出)。若只引入了聊天模型 starter(如第 2 章的 Ollama),需要额外补一个依赖:

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

依赖就绪后,写一个 @Bean 即可:

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DevVectorStoreConfig {

    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        // 纯内存实现:数据存在 JVM 里,重启即丢
        return SimpleVectorStore.builder(embeddingModel).build();
    }
}

若同时引入了多个模型 starter(第 2 章的 Ollama + OpenAI 就是如此),容器里会有多个 EmbeddingModel,此时该用哪个、维度如何对齐,见 8.4 的「嵌入模型怎么选」。

验证通过后,只需把上面的 @Bean 换成 PgVectorStore 或其它实现,业务代码零改动。

特点:无需外部数据库、秒级启动、随时可跑;缺点是内存存储、重启即丢、不支持多实例共享,只用于开发验证,生产必须换持久化向量库。

8.3 准备 pgvector 环境

验证流程没问题后,生产要换成真正持久化的向量库。本文以 pgvector(PostgreSQL + 扩展)为例,先用 Docker 把它跑起来:

# 用官方 pgvector 镜像起一个 PostgreSQL(内置 pgvector 扩展)
docker run -d --name pgvector \
  -e POSTGRES_PASSWORD=postgres \
  -p 5432:5432 \
  pgvector/pgvector:pg16

# 连接后启用扩展
docker exec -it pgvector psql -U postgres -c "CREATE EXTENSION vector;"

8.4 依赖与配置

引入 pgvector 的 starter 和 JDBC 驱动,再在 application.yml 里配置连接与向量参数:

<!-- pgvector 向量存储 starter -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

spring-ai-starter-vector-store-pgvector 已传递包含 spring-ai-vector-store,所以直接从 pgvector 开始时,无需再单独加 8.2 的那个依赖。

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/postgres
    username: postgres
    password: postgres
  ai:
    vectorstore:
      pgvector:
        initialize-schema: true        # 启动时自动建表
        index-type: HNSW               # 索引类型
        distance-type: COSINE_DISTANCE # 距离度量:余弦
        dimensions: 768                # 必须与嵌入模型维度一致

⚠️ 维度必须匹配:dimensions 要和嵌入模型产出的向量维度一致,否则检索会静默失败或报错。若未显式指定,PgVectorStore 会自动从 EmbeddingModel 读取;但改维度需重建表并重新向量化。

嵌入模型怎么选(多模型并存时):第 2 章同时引入了 Ollama 和 OpenAI,容器里会有多个 EmbeddingModel(ollamaEmbeddingModel、openAiEmbeddingModel)。框架用 spring.ai.model.embedding 决定默认注入哪个,也可以在注入时用 @Qualifier 显式指定:

spring:
  ai:
    model:
      embedding: ollama             # 决定默认注入的 EmbeddingModel
    ollama:
      embedding:
        options:
          model: nomic-embed-text   # 768 维
    openai:
      embedding:
        options:
          model: text-embedding-3-small  # 1536 维
// 多模型并存时,用 @Qualifier 显式指定嵌入模型(SimpleVectorStore / PgVectorStore 同理)
@Bean
public VectorStore vectorStore(@Qualifier("ollamaEmbeddingModel") EmbeddingModel embeddingModel) {
    return SimpleVectorStore.builder(embeddingModel).build();
}

@Qualifier 的 bean 名:Ollama 是 ollamaEmbeddingModel,OpenAI 是 openAiEmbeddingModel。

chat 与 embedding 供应商不同时:如果 chat 用某个账号/端点、embedding 想走另一个端点(不同账号或不同的 OpenAI 兼容服务),可以给 embedding 单独配 base-url / api-key,它们会覆盖共用的 spring.ai.openai.base-url / api-key:

spring:
  ai:
    openai:
      base-url: https://api.openai.com      # chat 用的公共地址
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini
      # embedding 单独走另一个端点(覆盖上面的公共 base-url / api-key)
      embedding:
        base-url: https://your-embedding-endpoint
        api-key: ${EMBEDDING_API_KEY}
        options:
          model: text-embedding-3-small

若 chat 与 embedding 是完全不同的供应商(如 chat=OpenAI、embedding=智谱),则各自配 spring.ai.<provider>.*,再用 spring.ai.model.chat / spring.ai.model.embedding 分别指定即可,两者互不影响。

下面分别说明 distance-type(距离度量)和 index-type(索引类型)怎么选:

距离度量选型:

距离度量说明适用
COSINE_DISTANCE余弦距离(默认)大多数场景
EUCLIDEAN_DISTANCEL2 欧氏距离数值型向量
NEGATIVE_INNER_PRODUCT内积(取负),适合归一化向量OpenAI 等归一化嵌入,性能更优

索引类型选型:

索引类型构建速度查询性能内存说明
HNSW慢(默认)快、召回高高可在空表上建索引
IVFFLAT快较低低需先有数据聚类,需调 lists/probes
NONE—精确检索—不建索引

生产建议:默认 HNSW;数据量大、写入频繁或内存受限时可考虑 IVFFLAT。注意 pgvector 的 HNSW 索引最多支持 2000 维。

⚠️ 多数据源冲突:项目里若配置了多个 DataSource,pgvector 自动配置可能报「required single bean but found 2」,需排除 DataSourceAutoConfiguration 或显式指定用哪个数据源。

8.5 编程式创建 VectorStore

除了 application.yml 自动配置,也可以直接用代码创建 PgVectorStore,适合需要动态传参(如从配置中心读维度)的场景:

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.pgvector.PgVectorStore;
import org.springframework.ai.vectorstore.pgvector.PgDistanceType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;

@Configuration
public class VectorStoreConfig {

    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel, JdbcTemplate jdbcTemplate) {
        return PgVectorStore.builder(jdbcTemplate, embeddingModel)
                .dimensions(768)                       // 与嵌入模型一致
                .distanceType(PgDistanceType.COSINE_DISTANCE)
                .build();
    }
}

8.6 写入与检索

VectorStore 建好后,写入和检索各是一个方法调用——写入时框架会自动调用嵌入模型把文本向量化,你只传文本和元数据即可:

import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;

// 写入文档(文本 + 元数据)
vectorStore.add(List.of(
        new Document("Spring AI 是 Spring 官方的 AI 框架。", Map.of("source", "intro.md")),
        new Document("pgvector 是 PostgreSQL 的向量扩展。", Map.of("source", "vector.md"))));

// 语义检索:topK 取前 3,similarityThreshold 过滤低相似度
List<Document> hits = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("Spring 的 AI 框架是什么")
                .topK(3)
                .similarityThreshold(0.5)
                .build());

for (Document doc : hits) {
    System.out.println(doc.getText() + " -> " + doc.getMetadata());
}

关于 id 与去重:上面 add 未指定 id,PgVector 内部会为每条 Document 自动生成全新 UUID 主键。于是每次 add 都直接 INSERT、永不冲突——内容相同也会重复新增多条。PgVectorStore 只认 id 主键,不做文本 / 向量相似度去重:语义相同的两段文字只要 id 不同,就会存两条。

推荐:内容哈希生成稳定 id(增量入库)。用 content + source 做 MD5 作为 id——文本不变则 id 不变,重复 add 变成更新而非新增;文本改动则 id 变化,自然新增一条。

注意:PgVectorStore 底层表是 id uuid PRIMARY KEY,内部会 UUID.fromString(id) 校验,id 必须是合法 UUID 字符串(带横杠)。而 MD5 是 32 位十六进制,需先插入横杠转成标准 UUID 格式(8-4-4-4-12):

import org.springframework.util.DigestUtils;
import java.nio.charset.StandardCharsets;

// 32 位 MD5 → 标准 UUID 格式
public static String md5ToUuid(String md5Hex) {
    return md5Hex.substring(0, 8) + "-"
            + md5Hex.substring(8, 12) + "-"
            + md5Hex.substring(12, 16) + "-"
            + md5Hex.substring(16, 20) + "-"
            + md5Hex.substring(20);
}

String content = "Spring AI 是 Spring 官方的 AI 框架。";
String source = "intro.md";
String docId = md5ToUuid(DigestUtils.md5DigestAsHex((content + source).getBytes(StandardCharsets.UTF_8)));
Document doc = new Document(docId, content, Map.of("source", source));
vectorStore.add(List.of(doc));

自己指定 id 的三个好处:

  1. 幂等入库、天然去重:同一文档多次 add,相同 id 触发 ON CONFLICT (id) DO UPDATE 覆盖旧记录,不会无限插入重复向量;不指定 id 则每次生成新 UUID,反复执行就堆出一堆重复向量,检索召回重复文本,白费 Embedding 与存储。
  2. 按文档 / 文件粒度删除更简单:id 与文件 / 分片绑定后,直接按 id 精准删除,稳定可靠;而按 metadata source 删除容易踩坑——JSON 字段空格 / 大小写问题、metadata 无索引过滤慢、多文档共用同一 source 时批量误删。
  3. 便于链路追踪:Document id 关联原始文件 id 或业务主键,检索出向量后可直接溯源到原始业务数据。

生产实践建议:

  1. 生产 RAG 知识库强制自己指定 Document id。
  2. id 建议与业务资源绑定(文件 id、业务主键 + 分片号),而不是单纯用内容哈希。
  3. 更新文件的标准流程:① 删除该文件所有 chunk 向量 → ② 重新切片 → ③ 用固定规则生成 chunk id 后 add 入库。
  4. metadata 只放检索过滤的辅助字段,不要依赖 metadata 做批量删除。

文件解析器(TikaDocumentReader 等)读出的 Document 通常不带 id,建议同样在 add 前补上稳定 id,避免重复入库。

上面的 Document 是手工 new 的,实际项目里文本通常来自文件。Spring AI 提供 DocumentReader 系列解析器(统一实现 DocumentReader 接口),能把磁盘文件 / MultipartFile 直接解析成 List<Document>:

import org.springframework.ai.reader.tika.TikaDocumentReader;   // 底层 Apache Tika,支持 PDF/Word/HTML/TXT
import org.springframework.web.multipart.MultipartFile;

// 磁盘文件
List<Document> docs = new TikaDocumentReader("file:./docs/handbook.pdf").get();
vectorStore.add(docs);

// 上传的 MultipartFile
MultipartFile file = ...;
vectorStore.add(new TikaDocumentReader(file.getResource()).get());

除 TikaDocumentReader 外,还有 TextReader(纯文本)、JsonReader(JSON)、PagePdfDocumentReader / ParagraphPdfDocumentReader(PDF 逐页/逐段)等;长文档先切块(见第 9 章 ETL)再 add。

8.7 Document 结构与相似度分数

Document 有四个字段:id、text、metadata、score。

Document doc = Document.builder()
        .id("doc-1")                       // 唯一标识,删除/更新靠它
        .text("Spring AI 是 Spring 官方的 AI 框架。")
        .metadata(Map.of("source", "intro.md"))
        .score(0.92)                       // 相似度分数(检索时由框架填充)
        .build();

检索返回的 Document 自带 score(相似度)和 metadata["distance"](原始距离)。以 pgvector 为例,score = 1 - distance——余弦距离越小越相似,score 越高越相似:

for (Document doc : hits) {
    System.out.println(doc.getScore() + " -> " + doc.getText());
    // 输出示例:0.91 -> Spring AI 是 Spring 官方的 AI 框架。
}

similarityThreshold 相似度阈值:检索时只返回 score ≥ 阈值 的文档,低于阈值的直接丢弃。它是「相关」与「不相关」的分界——设太低会把无关文档混进来,设太高会漏掉本该命中的文档。

阈值效果
< 0.4太松,几乎不过滤,噪声多
0.4 ~ 0.6偏松,适合「召回优先」、结果多再精排
0.65 ~ 0.8常用区间,平衡准确与召回
> 0.85过严,容易一条都查不到

阈值是「最低可接受的相似度」,不同嵌入模型分数分布略有差异,以实际打印为准。

开发实操建议:

  1. 开发调试阶段先设 0.65,把检索返回的文档逐条打印 score,观察真实业务数据的分数分布(相关文档大概多少分、无关文档大概多少分)。
  2. 根据打印出来的实际 score 再上调到 0.7 ~ 0.8。不要拍脑袋写死 0.9——阈值过高很容易「查不到任何文档」,模型拿不到资料只能瞎编,反而产生幻觉。

一句话:阈值靠数据说话,不靠猜——先打印分数分布,再在「相关」和「无关」的分数之间取阈值。

8.8 元数据过滤与删除文档

检索时按元数据过滤(例如只查某个来源),避免跨领域误召回:

import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;

// 方式一:字符串表达式(SQL 风格)
List<Document> hits1 = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("Spring AI")
                .topK(3)
                .filterExpression("source == 'intro.md'")
                .build());

// 方式二:FilterExpressionBuilder 流式构建(可组合 and/or)
FilterExpressionBuilder b = new FilterExpressionBuilder();
List<Document> hits2 = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("Spring AI")
                .topK(3)
                .filterExpression(b.and(
                        b.eq("source", "intro.md"),
                        b.gte("year", 2024)).build())
                .build());

删除文档:

// 按 id 删除(id 在写入时通过 Document.builder().id(...) 指定,未指定则自动生成 UUID)
vectorStore.delete(List.of("doc-1", "doc-2"));

// 按过滤条件批量删除
vectorStore.delete("source == 'old.md'");

更新文档 = 先删后加:

vectorStore.delete(List.of("doc-1"));
vectorStore.add(List.of(Document.builder()
        .id("doc-1")
        .text("更新后的内容...")
        .metadata(Map.of("source", "intro.md"))
        .build()));

常用操作符:eq / ne / gt / gte / lt / lte / in / nin,可用 and / or / not 组合。两种写法等价——字符串表达式(SQL 风格)与 FilterExpressionBuilder(流式):

// —— 字符串表达式(SQL 风格,支持 == != > >= < <= && || ! 和 in [...])——
.filterExpression("source == 'intro.md'")                 // 等于
.filterExpression("category != 'news'")                   // 不等于
.filterExpression("year >= 2024")                         // 大于等于(也支持 > / < / <=)
.filterExpression("category in ['tech', 'ai']")           // 属于集合
.filterExpression("tag nin ['spam']")                     // 不属于集合
.filterExpression("source == 'intro.md' && year >= 2024") // 且
.filterExpression("category == 'ai' || category == 'tech'") // 或
.filterExpression("!(tag == 'spam')")                     // 非

// —— FilterExpressionBuilder 流式(等价于上面)——
FilterExpressionBuilder b = new FilterExpressionBuilder();
b.eq("source", "intro.md");          // ==
b.ne("category", "news");            // !=
b.gt("year", 2023);                  // >
b.gte("year", 2024);                 // >=
b.lt("price", 100);                  // <
b.lte("price", 100);                 // <=
b.in("category", "tech", "ai");      // in
b.nin("tag", "spam");                // not in

b.and(b.eq("source", "intro.md"), b.gte("year", 2024));   // 且
b.or(b.eq("category", "ai"), b.eq("category", "tech"));   // 或
b.not(b.eq("tag", "spam"));                               // 非

提示:字符串写起来快、适合简单条件;条件多、需要动态拼接时用 FilterExpressionBuilder 更清晰。

8.9 其他向量库实现

Spring AI 提供同一套 VectorStore 接口,切换存储只需换 starter + 配置:spring-ai-starter-vector-store-redis、...-chroma、...-milvus、...-elasticsearch、...-cassandra 等。

选型建议:先用 SimpleVectorStore 验证 RAG 流程 → 再按团队技术栈选持久化库(PostgreSQL 用 pgvector,追求大规模检索性能用 Milvus / Qdrant 等原生向量库)。


第 9 章 RAG(检索增强生成)

RAG = 先从知识库检索相关文档,再把这些文档作为上下文注入提示词,让模型基于真实资料回答,减少幻觉。

为什么需要 RAG:

  1. 模型的知识「冻结」在训练那一刻:大模型有知识截止时间,训练之后的新数据、新政策、新事件它一概不知。
  2. 不知道你的私有数据:企业内部文档、数据库、业务系统里的资料模型从未见过,直接问只能得到通用答案或编造。
  3. 会一本正经地编(幻觉):模型不知道时会用看似合理的内容硬答,在合同条款、财务数据、法规等需要准确、可溯源的场景是致命的。

RAG 的思路是不改模型参数,而是把相关资料检索出来、塞进提示词,让模型「照着资料回答」——既补上新鲜与私有知识,又让答案可溯源、可更新,成本远低于微调。

离线入库:文件 → 读取 → 切分 → 向量化 → 存入向量库
在线问答:用户提问 → 检索相关文档 → 注入提示词 → 模型生成回答

RAG 分离线和在线两段:离线把知识「切块 + 向量化」灌进向量库(9.1),在线把提问「检索 + 注入」交给模型回答(9.2 起)。

9.1 ETL:数据摄入

三步走:读取 → 切分 → 写入向量库。其中「读取」用 TikaDocumentReader(底层 Apache Tika)解析 PDF/Word/PPT/HTML 等文件——RAG 知识库导入文件必须,需要先引入依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;

// 1. 读取(支持 PDF/Word/HTML 等,底层用 Apache Tika)
var reader = new TikaDocumentReader("file:./docs/handbook.pdf");

// 2. 切分(按 token 分块,避免单块过长)
TokenTextSplitter splitter = TokenTextSplitter.builder()
        .withChunkSize(800)      // 每个 chunk 的目标 token 数
        .withMinChunkSizeChars(350)     // 每个 chunk 的最小字符数
        .withMinChunkLengthToEmbed(5)   // 小于该 token 数的 chunk 不参与嵌入
        .withMaxNumChunks(10000)        // 单篇文档最大切块数
        .withKeepSeparator(true)        // 切分后保留分隔符
        .build();

// 3. 写入向量库(自动 embedding)
List<Document> chunks = splitter.apply(reader.get());
vectorStore.add(chunks);

⚠️ 图片不会被自动解析:TikaDocumentReader 这类普通解析器只提取文本,碰到图片既不报错、也不会识别,而是直接跳过——图片里承载的信息就丢了。框架不会自动帮你判断文档里有没有图片,需要业务层自己处理:图片带业务信息(如架构图、流程图、截图里的关键内容)时,要手动把图片抽出来,调用多模态模型转成文字描述,再参与切块和向量化;纯装饰的配图则无需处理。

切分(Chunk)策略:长文档必须切成小块再入库,块大小直接决定检索质量。上面 builder 里的五个参数(均为默认值):

参数值含义
chunkSize800每块的目标大小(token 数)
minChunkSizeChars350块的最小字符数,避免切得太碎
minChunkLengthToEmbed5短于此的碎片不向量化,直接丢弃
maxNumChunks10000单文档最多切多少块(防超大文档)
keepSeparatortrue切分时是否保留换行等分隔符

三个「大小」参数怎么区分(最容易混):

  • chunkSize(token):决定「每块多大」,是切分的主目标;
  • minChunkSizeChars(字符):块太小时合并到上一块,防止切出碎片——注意单位是字符、不是 token;
  • minChunkLengthToEmbed(token):切完后,短于此 token 数的块直接丢弃、不参与向量化。

简记:chunkSize 定大小 → minChunkSizeChars 兜底合并碎片(字符)→ minChunkLengthToEmbed 过滤丢弃碎片(token)。前两个发生在「切分」阶段,第三个发生在「嵌入」阶段。

块大小怎么选:块太大 → 检索结果噪声多、语义不聚焦;块太小 → 一句话被切到两个块、上下文断裂。一般 300~1000 token 起步,没有银弹——先用默认值跑通,再按检索效果微调。

进阶分块策略:固定大小切块简单,但容易把完整语义切碎。对检索质量要求高时,常用三种进阶策略:

策略思路适用
父子分块(parent-child)检索用小块(精准命中),喂模型时带回父块(补全上下文)文档结构清晰、需要完整上下文
语义分块按语义边界(段落/标题)切,而非按 token 硬切避免把一句话切到两个块
small-to-big检索小片段 → 扩展回更大的原文块兼顾精准命中 + 上下文完整

入门先用固定大小切块跑通即可;发现「检索命中了但答案缺上下文」时,再考虑父子分块。

apply() 的本质与生产后置增强

TokenTextSplitter#apply() 是标准 DocumentTransformer 接口实现,用于 ETL 流水线的文本分片:输入原始 Document 列表(承载全文 + 文件级 metadata),按配置的 token 大小切分,输出多个子 Document,子文档自动继承父文档 metadata,方便后续串联文本清洗、过滤等转换器。

但它有两个不足,直接入库会埋坑:

  1. 子文档 id 由框架随机生成 UUID,apply() 内部无法自定义 id。重复执行导入会不断生成新 id,造成向量数据重复堆积。
  2. metadata 只继承父文档的文件级粗粒度信息,不会自动追加分片维度字段(如 chunkIndex、totalChunks、chunkSize),检索命中后难以溯源到「第几个块」。

生产做法:保留 apply() 做标准分片,分片完成后遍历子文档做后置增强——复制 metadata 并追加分片维度字段,同时把 id 覆盖为业务可控的合法 UUID,实现幂等入库:

import org.springframework.ai.document.Document;
import org.springframework.util.DigestUtils;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

List<Document> rawChunks = splitter.apply(reader.get());
List<Document> chunks = new ArrayList<>();

for (int i = 0; i < rawChunks.size(); i++) {
    Document raw = rawChunks.get(i);

    // 1) 复制父 metadata(框架可能返回不可变 Map,必须 new HashMap 才能 put)
    Map<String, Object> metadata = new HashMap<>(raw.getMetadata());
    // 2) 追加分片维度元数据
    metadata.put("chunkIndex", i);
    metadata.put("totalChunks", rawChunks.size());
    metadata.put("chunkSize", 800);

    // 3) 生成稳定 id:content + 块序号 做 MD5 转 UUID(md5ToUuid 见 8.6)
    String id = md5ToUuid(DigestUtils.md5DigestAsHex(
            (raw.getText() + i).getBytes(StandardCharsets.UTF_8)));
    chunks.add(new Document(id, raw.getText(), metadata));
}

// 4) 清理旧向量 → 批量写入(见 8.8)
vectorStore.add(chunks);

整套流程:文档读取器加载文件生成原始 Document → TokenTextSplitter.apply() 分片 → 遍历子文档覆盖 id、补充分片 metadata → 清理旧向量数据 → 批量写入向量库。

metadata 规范习惯:

  • 父文档 metadata(文件维度):source、fileName、fileType、fileModifyTime;
  • 后置补充 metadata(分片维度):chunkIndex、totalChunks、chunkSize、chunkOverlap;
  • 处理 metadata 时永远用 new HashMap<>(rawDoc.getMetadata()) 复制,防止框架返回不可变 Map、直接 put 抛异常。

9.2 朴素 RAG:QuestionAnswerAdvisor

最简单的方式,检索 + 注入 + 问答一气呵成。需要引入 spring-ai-advisors-vector-store 依赖(和 6.4 的长期记忆 VectorStoreChatMemoryAdvisor 是同一个模块):

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;

QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
        .searchRequest(SearchRequest.builder()
                .topK(5)
                .similarityThreshold(0.45)
                .build())
        .build();

ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(qaAdvisor)
        .build();

// 提问时会自动检索并注入上下文
String answer = chatClient.prompt()
        .user("我们的产品支持哪些支付方式?")
        .call()
        .content();

关于 query:SearchRequest 有个 query 参数,即检索用的查询文本(用户问题)。把它传给 QuestionAnswerAdvisor 时,它只当作模板——topK、similarityThreshold、filterExpression 是静态配置、在模板里预先定义;而 query 会由每次的用户提问动态填充。所以不要在模板里设置 query,设了也无效、会被用户输入覆盖:上面例子中 .user("我们的产品支持哪些支付方式?") 的文本,最终就成了实际检索的 query。

开发调试:拿到召回文档

开发/调试时经常要看到「到底召回了哪些文档」,有两种方案:

① 链式追加一个自定义 Advisor(推荐,侵入最小,生产常用)

自定义 Advisor 在 QuestionAnswerAdvisor 之后执行,从其放入共享上下文的 RETRIEVED_DOCUMENTS 里取出召回文档:

import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.ChatClientRequest;
import org.springframework.ai.chat.client.advisor.api.ChatClientResponse;
import org.springframework.ai.document.Document;
import java.util.List;

public class CaptureRetrieveDocAdvisor implements CallAdvisor {

    private List<Document> retrievedDocs;

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        // 执行链上前面 Advisor(QuestionAnswerAdvisor 先执行检索)
        ChatClientResponse response = chain.nextCall(request);
        // 从上下文取出 QuestionAnswerAdvisor 存放的召回文档
        this.retrievedDocs = (List<Document>) request.context().get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS);
        return response;
    }

    public List<Document> getRetrievedDocs() {
        return retrievedDocs;
    }

    @Override
    public String getName() {
        return "CaptureRetrieveDocAdvisor";
    }

    // order:大于 QuestionAnswerAdvisor 默认 order(0),保证 QA 先执行检索
    @Override
    public int getOrder() {
        return 10;
    }
}

使用:

QuestionAnswerAdvisor answerAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
        .searchRequest(SearchRequest.builder()
                .topK(3)
                .similarityThreshold(0.5)
                .build())
        .build();

CaptureRetrieveDocAdvisor captureAdvisor = new CaptureRetrieveDocAdvisor();

ChatClient chatClient = ChatClient.builder(chatModel).build();

String answer = chatClient.prompt()
        .system("你是一名 Java 开发工作者,不胡编乱造")
        .user("Spring 依赖注入是什么")
        .advisors(answerAdvisor, captureAdvisor)   // answerAdvisor 先检索,captureAdvisor 捕获结果
        .call()
        .content();

// 获取召回的文档(带 metadata)
List<Document> docs = captureAdvisor.getRetrievedDocs();
System.out.println("回答:" + answer);
System.out.println("====召回文档====");
docs.forEach(doc -> {
    System.out.println("content:" + doc.getText());
    System.out.println("metadata:" + doc.getMetadata());
});

② 拆成两段:先手动检索,再交给 ChatClient(简单测试场景)

import java.util.stream.Collectors;

ChatClient chatClient = ChatClient.builder(chatModel).build();

// 1. 手动检索,直接拿到召回文档
String query = "Spring 依赖注入是什么";
List<Document> docs = vectorStore.similaritySearch(SearchRequest.builder()
        .query(query)
        .topK(3)
        .similarityThreshold(0.5)
        .build());

// 2. 拼接上下文
String context = docs.stream()
        .map(Document::getText)
        .collect(Collectors.joining("\n"));

// 3. 传给大模型
String promptText = """
        参考下面上下文回答问题,不要编造。
        上下文:%s
        问题:%s
        """.formatted(context, query);

String answer = chatClient.prompt()
        .system("你是一名 Java 开发工作者,不胡编乱造")
        .user(promptText)
        .call()
        .content();

方案① 复用 QuestionAnswerAdvisor 的检索逻辑、对业务侵入最小;方案② 更直白、适合快速验证,但需自己拼提示词。两者都只能拿到「召回了什么」,拿不到「引用了哪篇文档」的 citation——溯源见 9.3 末尾的「引用溯源」。

9.3 自定义提示词模板

QuestionAnswerAdvisor 默认用英文模板合并「问题 + 检索结果」,中文/业务场景应自定义。模板必须含 {query} 和 {question_answer_context} 两个占位符:

import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.vectorstore.VectorStore;

PromptTemplate customTemplate = PromptTemplate.builder()
        .template("""
                请基于以下资料回答问题,资料中没有的直接说「我不知道」。

                问题:{query}

                资料:
                {question_answer_context}
                """)
        .build();

QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
        .promptTemplate(customTemplate)
        .build();

使用:

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;

ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(qaAdvisor)   // 挂上带自定义模板的 Advisor
        .build();

String answer = chatClient.prompt()
        .user("我们公司去年的营收是多少?")
        .call()
        .content();
// 模板生效后的效果:资料里没有相关文档时,回答「我不知道」,而不是硬编一个数字

自定义模板常用于:① 中文提示;② 强制「资料里没有就说不确定」;③ 控制回答格式。

占位符能不能扩展:{query} 和 {question_answer_context} 是 QuestionAnswerAdvisor 固定且必需的两个占位符,advisor 只负责填这两个。可以再加其他占位符(如 {date}、{userName}),但 advisor 不会帮你填,需自己通过 advisor 上下文参数提供值:.advisors(a -> a.param("date", "2026-09-15")),这些参数会被合并进模板渲染。若模板里写了占位符却没提供值,渲染时会抛「找不到属性」的异常。

引用溯源(Citation):企业知识库常要回答「依据哪份文档」。关键是入库时把来源存进 Document 的 metadata(如 source,见 9.1),回答时把来源带出来。注意:QuestionAnswerAdvisor 默认只注入正文、不注入 metadata,要做溯源通常用 9.5 的手动 RAG(docs 自带 metadata)把来源拼进提示词或随回答返回——核心 Spring AI 不内置 citation。

9.4 元数据过滤

按文档元数据缩小检索范围(例如只查某个来源):

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;

String answer = chatClient.prompt()
        .user("退货政策是什么?")
        // advisorSpec 是 ChatClient.AdvisorSpec(advisor 上下文配置器):
        // param(key, value) 往共享上下文塞一组键值,QuestionAnswerAdvisor 读取 FILTER_EXPRESSION 作为检索过滤条件
        .advisors((ChatClient.AdvisorSpec advisorSpec) -> advisorSpec.param(
                QuestionAnswerAdvisor.FILTER_EXPRESSION, "source == 'policy.md'"))
        .call()
        .content();

a(lambda 参数)的类型是 ChatClient.AdvisorSpec,不是某个 advisor 实例。它有两个作用:① param(key, value) 往 advisor 共享上下文塞键值对;② advisors(...) 指定本次请求用哪些 advisor。QuestionAnswerAdvisor.FILTER_EXPRESSION 是约定好的 key,QuestionAnswerAdvisor 检索时会读它作为过滤表达式(FilterExpression)。

9.5 模块化 RAG:RetrievalAugmentationAdvisor

1.1.0 新增,属于 spring-ai-rag 模块,需要引入依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-rag</artifactId>
</dependency>

先理解「揉在一起」是什么意思——对比手动 RAG,每一步都自己写:

// 手动 RAG:检索、注入、生成三步都自己来
List<Document> docs = vectorStore.similaritySearch(
        SearchRequest.builder().query(question).topK(5).build());

// 把检索到的文档拼成上下文
String context = docs.stream().map(Document::getText).collect(Collectors.joining("\n"));

// 注入提示词 + 生成回答
String answer = chatClient.prompt()
        .system("基于资料回答:\n" + context)
        .user(question)
        .call()
        .content();

手动 RAG 每一步都看得见、能单独调:先打印 docs 看检索质量、再改提示词模板。而 QuestionAnswerAdvisor 把「检索 → 注入 → 生成」封装成一个 Advisor,你只给 vectorStore 和问题,它内部自动跑完,中间的检索结果、提示词拼接都被藏起来了,想单独调某一环就只能靠少数参数(如 FILTER_EXPRESSION)间接干预:

// 朴素 RAG(QuestionAnswerAdvisor):同样的事,封装成一个 Advisor 一步到位
QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
        .searchRequest(SearchRequest.builder().topK(5).build())
        .build();

String answer = chatClient.prompt()
        .advisors(qaAdvisor)   // 只挂 Advisor,内部自动「检索 → 注入 → 生成」
        .user(question)
        .call()
        .content();

模块化 RAG 就是折中——把流程拆成四段,每段可独立开关、独立配置:

阶段作用对应 builder 方法必填
① 查询改写用 LLM 把口语化提问改写成更利于检索的形式queryTransformers(...)否
② 检索从向量库召回最相关的 topK 条documentRetriever(...)是
③ 后处理对召回结果再加工(如 Rerank 重排序)documentPostProcessors(...)否
④ 增强把检索结果拼进提示词、控制空上下文queryAugmenter(...)否

每段什么时候才需要加(不一定要全加——查询改写 / Rerank 都要额外调一次 LLM,有成本)。以电商客服知识库为例,用户问「这东西咋退啊」:

  1. ① 查询改写:用户提问口语化、和文档用词不一致时能明显提升命中率。

    • 例:用户「这东西咋退啊」→ 改写为「如何申请退货」,才能对上库里文档「退货流程与政策」;提问已经很规范就不必加。
  2. ② 检索:始终生效——它就是真正从库里捞文档的那一步。

    • 例:拿「如何申请退货」去检索,返回「退货政策」「退款说明」等 topK 条。
  3. ③ 后处理(Rerank):向量召回「排序不够准」时,用更精准的 reranker 重排取前几条。

    • 例:向量检索可能把「发货时效」排在「退货政策」前面(语义相近但非目标),Rerank 后把「退货政策」提到第 1 位;数据量小、召回已够准就别加。
  4. ④ 增强:需要控制检索结果怎么拼进提示词、或检索为空时怎么处理时再加。

    • 例:把重排后的文档拼进「请基于资料回答」的提示词;一条都没检索到时 allowEmptyContext(false) 让它回「我不知道」而不是瞎编。

关于 Rerank 的通俗理解:它就是「把检索到的文档重新排个序」。你不用在业务代码里手动排序,而是写一个「重排器」(DocumentPostProcessor,内部调用某个 reranker 模型给每篇文档打分)交给框架;框架会在检索之后、交给模型之前,自动用它把结果排一遍。核心 Spring AI 不自带现成的重排器,需要你自己实现 DocumentPostProcessor(内部通过 HTTP 调你部署的 rerank 服务)。

Reranker 是什么:一类专门训练的重排序模型——既不是 Embedding 模型,也不是生成大模型。它和 Embedding 的区别:

  • Embedding(双塔 / bi-encoder):把 query 和文档分别编码成向量再算相似度,快但粗糙,用于「粗召回」;
  • Reranker(交叉编码器 / cross-encoder):把 query + 文档拼在一起输入,直接输出一个相关度分数,慢但准,用于「精排」。

主流开源 Rerank 模型:BAAI bge-reranker(base / large / v2-m3,中文好)、阿里 gte-rerank / qwen3-rerank(百炼)、jina-reranker-v2、轻量英文 ms-marco-MiniLM;商业的有 Cohere Rerank。

部署注意:不要试图在 Java 进程里跑 Rerank 模型——Java 没有成熟的 transformer 推理生态,加载 / 推理 Rerank 模型很别扭。一般把 Rerank 部署成独立的 Python 服务(如 FastAPI + sentence-transformers / FlagEmbedding),Java 侧通过 HTTP 调用;或者直接用云厂商的 rerank API(百炼 qwen3-rerank、Cohere Rerank)。

最简用法——只配「检索」(②),效果和朴素 RAG 差不多:

import org.springframework.ai.advisor.Advisor;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;

Advisor ragAdvisor = RetrievalAugmentationAdvisor.builder()
        .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .topK(5)
                .similarityThreshold(0.45)
                .build())
        .build();

按需再加「查询改写」(①)、「后处理 Rerank」(③)、「增强」(④):

// 新增 import(其余同上):RewriteQueryTransformer、DocumentPostProcessor、ContextualQueryAugmenter、RestTemplate
import org.springframework.ai.rag.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.rag.postretrieval.document.DocumentPostProcessor;
import org.springframework.ai.rag.query.transformation.RewriteQueryTransformer;
import org.springframework.web.client.RestTemplate;

// ③ 后处理:写一个「重排器」,内部通过 HTTP 调你自己部署的 rerank 服务给文档打分,按相关度取前 3
@Component
public class RerankPostProcessor implements DocumentPostProcessor {

    private final RestTemplate restTemplate;   // 普通 Spring 的 HTTP 客户端

    public RerankPostProcessor(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @Override
    public List<Document> process(Query query, List<Document> documents) {
        if (documents.size() <= 3) {
            return documents;
        }
        // 把 query + 各文档文本 POST 给 rerank 服务,返回按相关度降序的文本列表
        Map<String, Object> req = Map.of(
                "query", query.text(),
                "documents", documents.stream().map(Document::getText).toList());
        List<String> ranked = restTemplate.postForObject(
                "http://localhost:8001/rerank", req, List.class);   // 换成你的 rerank 服务地址

        Map<String, Document> byText = documents.stream()
                .collect(Collectors.toMap(Document::getText, d -> d, (a, b) -> a));
        return ranked.stream().map(byText::get).filter(Objects::nonNull).limit(3).toList();
    }
}

// 构建时把重排器注册到「后处理」阶段
Advisor ragAdvisor = RetrievalAugmentationAdvisor.builder()
        // ① 查询改写:内部要调 LLM 重写提问,需传入注入的 ChatClient.Builder(见 1.3)
        .queryTransformers(RewriteQueryTransformer.builder()
                .chatClientBuilder(chatClientBuilder)   // chatClientBuilder = 注入的 ChatClient.Builder
                .build())
        // ② 检索:必填
        .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .topK(5)
                .similarityThreshold(0.45)
                .build())
        // ③ 后处理:交给 RerankPostProcessor 精排
        .documentPostProcessors(rerankPostProcessor)
        // ④ 增强:allowEmptyContext 控制检索为空时是否回答
        .queryAugmenter(ContextualQueryAugmenter.builder()
                .allowEmptyContext(false)   // 检索为空时拒绝回答,防幻觉
                .build())
        .build();

核心 Spring AI 不自带 reranker 模型,所以上面 RerankPostProcessor 里是通过 HTTP 调你自己部署的 rerank 服务(部署方式见上面「部署注意」)。如果你用的是 Spring AI Alibaba 生态,它有现成的 RerankModel(如 DashScopeRerankModel)和开箱即用的 RetrievalRerankAdvisor,可以直接用、省去自己写 HTTP 调用。

最后把它挂到 ChatClient 上,和普通 Advisor 一样用:

ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(ragAdvisor)   // 或每次请求 .advisors(ragAdvisor)
        .build();

String answer = chatClient.prompt()
        .user("我们的产品支持哪些支付方式?")
        .call()
        .content();

chatClientBuilder 是注入的 ChatClient.Builder(Spring AI 自动配置,见 1.3),在 @Bean 方法里作为参数注入即可——RewriteQueryTransformer 要用 LLM 改写提问,所以需要它。

常用组件:RewriteQueryTransformer(改写查询)、MultiQueryExpander(扩展成多个语义变体,.numberOfQueries(3))、ContextualQueryAugmenter(控制上下文拼接 + allowEmptyContext 空上下文开关)。查询改写建议用低温(如 0.0)让结果更确定;多查询扩展会显著增加检索次数,按需使用。

选型小结:手动 RAG 全可控但啰嗦;QuestionAnswerAdvisor 省事但黑盒。推荐 RetrievalAugmentationAdvisor 这个折中方案——先只配「检索」快速跑通,需要时再逐段加「查询改写 / 后处理 / 增强」,省事和可控兼得。

9.6 混合检索(向量 + 关键词)

纯向量检索对精确关键词不敏感——SKU 编号、型号、专有名词、缩写(「P0 故障」)、人名地名,靠语义向量经常召回不准。生产上常用混合检索:向量检索 + 关键词检索(BM25)两条路召回,再融合或交给 Rerank 精排。

用户提问
   ├─ 向量检索(语义相近)──────────┐
   └─ BM25 关键词检索(精确匹配)────┤→ 融合(如 RRF)→ 精排(Rerank)→ 注入提示词
  • 什么时候上:知识库里编号、型号、专有名词多,纯向量查不准时。
  • 怎么落地:核心 VectorStore 接口只做向量检索,关键词检索 / 融合需配合具体向量库(Milvus / Elasticsearch 原生支持混合检索),或用 Spring AI Alibaba 的混合检索方案——比纯向量重,按需引入。

简单场景先用「纯向量 + 查询改写 + Rerank」就够了;发现「编号/型号查不准」再上混合检索。

9.7 多轮对话 RAG(RAG + 记忆)

用户会追问(「那它的价格呢?」「和上一个比呢?」),此时直接拿残缺问句去向量库检索会失败——向量库不知道「它」指代谁。多轮 RAG 需要两层:对话记忆(ChatMemory) 存历史,Query 改写 消解指代/补全省略。

核心结论:MessageChatMemoryAdvisor 只把历史对话给大模型「看」,不会改写向量检索用的 query。用户带代词追问时,QuestionAnswerAdvisor 仍拿原始短句检索,召回必错——这是最常见的踩坑点。

① 简易版:只挂记忆 + QuestionAnswerAdvisor(Demo 够用,生产不够)

ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(
                MessageChatMemoryAdvisor.builder(memory).build(),   // 记忆:带上历史对话
                qaAdvisor)                                         // RAG:检索 + 注入
        .build();

局限:历史只进了「给大模型的 prompt」,没进「给向量库的检索 query」;追问「它的价格呢」仍拿原句检索。

② 生产版:记忆 + 官方 Query 改写(推荐)

用 RetrievalAugmentationAdvisor + RewriteQueryTransformer 组装「改写 → 检索 → 注入」流水线,无需手写改写 Advisor。先引入 spring-ai-rag 依赖(见 9.5),再配置:

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.postretrieval.generation.ContextualQueryAugmenter;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
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 RagConfig {

    @Bean
    public ChatClient ragChatClient(ChatClient.Builder builder, ChatModel chatModel,
                                    VectorStore vectorStore, ChatMemory chatMemory) {

        // ① 检索器:向量召回(唯一必需组件)
        VectorStoreDocumentRetriever retriever = VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .topK(3)
                .similarityThreshold(0.5)
                .build();

        // ② 查询改写:结合历史消解代词 / 补全省略
        RewriteQueryTransformer queryTransformer = RewriteQueryTransformer.builder()
                .chatClientBuilder(ChatClient.builder(chatModel))   // 独立 builder,避免递归触发 RAG
                .build();

        // ③ 上下文注入:资料为空时不硬答
        ContextualQueryAugmenter queryAugmenter = ContextualQueryAugmenter.builder()
                .allowEmptyContext(false)
                .build();

        // ④ 记忆:多轮历史,按 conversationId 隔离
        MessageChatMemoryAdvisor memoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory).build();

        // ⑤ RAG 流水线:改写 → 检索 → 注入
        RetrievalAugmentationAdvisor ragAdvisor = RetrievalAugmentationAdvisor.builder()
                .queryTransformers(queryTransformer)
                .queryAugmenter(queryAugmenter)
                .documentRetriever(retriever)
                .build();

        // 记忆在前(先注入历史),RAG 在后(拿历史改写 + 检索)
        return builder
                .defaultAdvisors(memoryAdvisor, ragAdvisor)
                .build();
    }

    @Bean
    public ChatMemory chatMemory() {
        // 窗口裁剪,防 token 无限膨胀;生产换 JdbcChatMemoryRepository 持久化
        return MessageWindowChatMemory.builder()
                .maxMessages(10)
                .build();
    }
}

多轮调用(同一个 conversationId):

String conversationId = "session_001";

String answer1 = ragChatClient.prompt()
        .system("你是一名 Java 开发工作者,不胡编乱造")
        .user("Spring 依赖注入是什么")
        .advisors(spec -> spec.param(
                MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
        .call()
        .content();

// 代词「它」→ RewriteQueryTransformer 结合历史改写后再检索
String answer2 = ragChatClient.prompt()
        .system("你是一名 Java 开发工作者,不胡编乱造")
        .user("它的使用场景是什么")
        .advisors(spec -> spec.param(
                MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
        .call()
        .content();

关键:① 记忆在前、RAG 在后,顺序不能颠倒——否则检索会用残缺原句先执行,改写失效;② 两段上下文自动分开:改写后的 query 用于检索,完整历史 + 召回文档交给大模型;③ 记忆会让 token 持续增长,用 MessageWindowChatMemory 窗口截断(配合第 6 章摘要/截断);④ ContextualQueryAugmenter 默认英文模板,中文可 .promptTemplate(...) 自定义(占位符规则同 9.3)。

要不要同一个向量表? 不需要。MessageChatMemoryAdvisor 用 ChatMemory 存对话历史,qaAdvisor 用 VectorStore 存知识库文档,两者独立、互不干扰。即便第 6 章用 VectorStoreChatMemoryAdvisor 把记忆也存进向量库,也建议用独立的向量表 / collection,别和知识库文档混在一起——否则对话记忆会被当成「文档」检索出来,污染 RAG。

9.8 RAG 常见问题与调优

RAG 落地最常遇到三类问题,先判断属于哪一种,再对症下药:

症状根因对策
检索不到(召回低)相关文档没被搜出来降低 similarityThreshold;用 RewriteQueryTransformer 改写查询;换更强的嵌入模型
检索不准(精准低)搜出一堆无关文档提高阈值;元数据过滤(9.4)缩小范围;Rerank 重排序精排
幻觉资料里没有却乱编allowEmptyContext(false) 拒绝空上下文;提示词强制「没有就说不知道」(9.3);降低温度

几点补充:

  • 阈值是检索的「闸门」:查不到先降阈值、查太杂先升阈值,配合打印 score 观察分布(见 8.7)。
  • 查询改写:用户口语化提问(「这东西咋退」)和文档书面语(「退货流程」)有差距,RewriteQueryTransformer 用 LLM 把提问改写成更利于检索的形式,是提升命中率最有效的手段之一。
  • Rerank(重排序):先粗召回一批(如 topK=20),再用更精准的 reranker 模型重排取前几条。适合对检索质量要求高的场景。

排查顺序:先看检索(该命中的有没有命中、score 分布对不对),再看生成(拿到资料后是否老实回答)——八成问题出在检索侧。

9.9 两种 RAG 对比

场景推荐
快速验证、代码最少QuestionAnswerAdvisor
需要查询改写、多路检索、空上下文控制RetrievalAugmentationAdvisor

第四部分 · 进阶主题

第 10 章 多模态

多模态 = 输入/输出不限于纯文本。Spring AI 通过 Message 的 media 字段支持图片/音频输入,用独立模型接口支持图像生成与语音。

多模态能力高度依赖具体厂商与模型,接入前请先确认所选模型(本地/云端)是否支持对应模态。

Ollama 现状:本地 Ollama 支持多模态看图(llava),但没有官方文生图(ImageModel)实现,也没有内置 ASR/TTS 语音接口(语音要自己接 whisper 等外部服务)。

视觉输入、图像生成、语音原则上都需要对应能力的专用模型,普通纯文本大模型做不了。Spring AI 只是把能力做了接口抽象、封装了调用,底层还是依赖模型本身具备该能力,框架不会自己做 AI 运算。

10.1 视觉输入(图片理解)

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.MimeTypeUtils;

String answer = chatClient.prompt()
        .user(u -> u.text("这张图片里有什么?请详细描述。")
                .media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/photo.png")))
        .call()
        .content();

也可用 UserMessage + Media 构建:

import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.model.Media;

UserMessage msg = UserMessage.builder()
        .text("解释这张图")
        .media(new Media(MimeTypeUtils.IMAGE_JPEG, new ClassPathResource("/photo.jpg")))
        .build();

注意:本地视觉需用多模态模型(如 llava),纯文本模型不支持图片输入。

视觉输入(图片理解 Vision)属于 ChatModel,不是 ImageModel——它和普通聊天共用同一个 ChatModel,只是把模型名换成多模态模型(如 llava、qwen-vl)。yml 不需要单独开「图片配置」,只需指定多模态模型名;Media 传图片是代码层面做的事,yml 主要配置 api-key、模型名、参数。

10.2 图像生成(文生图)

用 ImageModel 接口,OpenAI 实现为 OpenAiImageModel。先配置图像模型(OpenAI 为 DALL-E / gpt-image-1):

spring:
  ai:
    model:
      image: openai            # 指定 image 模型供应商(类似 chat / embedding)
    openai:
      api-key: ${OPENAI_API_KEY}
      image:
        options:
          model: dall-e-3      # 图像生成模型,也可用 gpt-image-1

注意:本地 Ollama 没有官方文生图实现(见章首「Ollama 现状」),文生图需用云端图像模型。

ImageModel 由 Spring AI 自动配置,注入即可用:

import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.stereotype.Service;

@Service
public class ImageService {

    private final ImageModel imageModel;   // 自动配置,直接注入

    public ImageService(ImageModel imageModel) {
        this.imageModel = imageModel;
    }

    public String generateImage(String prompt) {
        ImageResponse response = imageModel.call(new ImagePrompt(prompt));
        return response.getResult().getOutput().getUrl();   // 返回图片 URL
    }
}

10.3 语音(转写与合成)

Spring AI 提供 TranscriptionModel(语音转文字)与 SpeechModel(文字转语音),OpenAI 实现为 OpenAiAudioTranscriptionModel(Whisper)与 OpenAiAudioSpeechModel(TTS),同样通过 spring-ai-starter-model-openai 提供。先配置语音模型:

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      audio:
        speech:                          # TTS 文字转语音
          options:
            model: tts-1                  # 也可 tts-1-hd / gpt-4o-mini-tts
            voice: alloy                  # alloy / echo / fable / onyx / nova / shimmer
            response-format: mp3          # 音频格式
        transcription:                    # ASR 语音转写
          options:
            model: whisper-1
            response-format: text

注意:本地 Ollama 没有内置 ASR/TTS 语音接口(见章首「Ollama 现状」),语音能力需用云端模型。

两个接口都由 Spring AI 自动配置,注入即可用:

import org.springframework.stereotype.Service;

@Service
public class AudioService {
    // TranscriptionModel(语音转文字)/ SpeechModel(文字转语音)由 Spring AI 自动配置,直接注入
    private final TranscriptionModel transcriptionModel;
    private final SpeechModel speechModel;

    public AudioService(TranscriptionModel transcriptionModel, SpeechModel speechModel) {
        this.transcriptionModel = transcriptionModel;
        this.speechModel = speechModel;
    }

    // 语音转文字(ASR)
    public String transcribe(Resource audio) {
        return transcriptionModel.transcribe(audio);
    }

    // 文字转语音(TTS)
    public byte[] synthesize(String text) {
        return speechModel.call(new SpeechPrompt(text)).getResult().getOutput();
    }
}

10.4 多模态 RAG(知识库里的图片)

先澄清一个关键误区:把推理模型换成多模态模型(如 Qwen-VL)≠ 自动变成多模态 RAG。

TikaDocumentReader 只会提取文档文字,文档里的图片、图表会被直接丢弃。就算你把后端聊天模型换成 Qwen-VL,向量库里依然只有文本切片,图片信息照样丢失——因为图片压根没进库。

分清两个概念:

概念能力作用阶段
多模态推理模型(Qwen-VL)接收「文本 + 图片 URL/base64」,看得懂图片,能问答、看图分析生成回答阶段看图
多模态 RAG入库时把文档图片/图表提取出来、向量化入库,检索时召回图片片段入库 + 检索阶段

Spring AI 原生支持传 Media 图片对象给 Qwen-VL(见 10.1),但没有开箱即用的多模态 RAG 流水线,需要自己开发。下面两种方案是工程上的主流做法。

方案 A:图片转文字摘要(最常用、改造最小,推荐优先用)

兼容现有文本 RAG 整套链路(RetrievalAugmentationAdvisor、向量库都不用改):

  1. 文档解析时,把 docx / PDF 里的图片提取出来;
  2. 调 Qwen-VL 描述图片/图表(如「详细描述这张图,包含图表数据、趋势,输出文本描述」);
  3. 把生成的文本摘要当成普通 Document 切片存入向量库;
  4. 查询阶段走普通文本检索,给 LLM 的上下文是图片的文字描述。
  • ✅ 优点:复用现有全部 RAG 代码,改动小、性能好;
  • ❌ 缺点:模型看不到原图,只能看文字摘要,复杂图表细节会丢失。

下面是方案 A 的完整实现,五步照抄即可:

① 依赖:提取文档内图片,docx 用 POI、PDF 用 pdfbox(Tika 提取不了内嵌图片):

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.2.5</version>
</dependency>
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>2.0.32</version>
</dependency>

② 调 Qwen-VL 把图片转成文字摘要(输入图片二进制,输出专供检索用的文字描述):

public String imageToSummary(byte[] imageBytes) {
    // 原生 Spring AI:直接把图片字节作为 Media 传给多模态 ChatModel
    return chatClient.prompt()
            .user(u -> u.text("""
                    你是文档图片解析助手。请详细描述这张图片/图表。
                    如果是图表,把数据、趋势、关键信息完整输出;
                    如果是流程图,输出流程步骤;
                    输出内容用于知识库检索,保证信息完整,不要多余客套话。
                    """)
                    .media(MimeTypeUtils.IMAGE_JPEG, imageBytes))   // 直接传图片字节,MIME 按实际图片格式填
            .options(ChatOptions.builder()
                    .model("qwen-vl-max")              // 换成你的多模态模型名
                    .build())
            .call()
            .content();
}

③ 提取 docx 里的图片(docx 是 zip 包,图片都在 word/media/ 下):

public List<byte[]> extractDocxImages(Path docxFilePath) throws IOException {
    List<byte[]> imageBytesList = new ArrayList<>();
    try (OPCPackage opcPackage = OPCPackage.open(docxFilePath.toFile())) {
        XWPFDocument document = new XWPFDocument(opcPackage);
        for (XWPFPictureData pictureData : document.getAllPictures()) {
            imageBytesList.add(pictureData.getData());
        }
    }
    return imageBytesList;
}

PDF 的图片提取用 pdfbox,遍历每一页、拿出页面内的图片字节即可。

④ 入库:文本切片 + 图片摘要一起写:

// 1. 普通文本切片(tikaDocs = TikaDocumentReader.get() 读出的 List<Document>)
List<Document> textDocuments = textSplitter.split(tikaDocs);

// 2. 提取文档里全部图片 → 逐张转文字摘要
List<Document> imageSummaryDocs = new ArrayList<>();
for (byte[] imgBytes : extractDocxImages(filePath)) {
    String summary = imageToSummary(imgBytes);
    Document imgDoc = new Document(summary);
    imgDoc.getMetadata().put("source_type", "image_summary");   // 标记来源是图片摘要,方便调试
    imageSummaryDocs.add(imgDoc);
}

// 3. 合并后统一入库
List<Document> allDocs = new ArrayList<>();
allDocs.addAll(textDocuments);
allDocs.addAll(imageSummaryDocs);
vectorStore.add(allDocs);

⑤ 查询阶段完全不用改:直接复用 RetrievalAugmentationAdvisor,检索时图片摘要和普通文本一样被召回——因为向量库存的是图片的文字描述,不是原图。

方案 B:完整多模态 RAG(检索能召回原图)

  1. 入库:提取图片 → 调多模态 Embedding 生成图片向量 → 图片文件存 OSS → 向量库保存「图片向量 + 图片 URL」;
  2. 检索:支持「文本搜图片、图片搜文本」;
  3. 命中后:把图片 URL 和文本一起塞给 Qwen-VL,模型拿到真实图片直接看图回答。

⚠️ 坑:普通文本 Embedding 不能编码图片,必须用专门的多模态 Embedding;向量库要同时存「文本向量 + 图片向量」两套。

方案 B 完整实现(架构 + 代码骨架,Java + Spring AI + 阿里云百炼):

入库:文档 → 提取图片(POI/PDFBox)
              ├→ 上传 OSS → 图片 URL
              └→ 多模态 Embedding → 图片向量
              → 写入向量库(图片向量 + URL 元数据)

查询:提问 → 文本向量化 → 检索向量库 → 命中图片文档(含 URL)
            → 图片 URL + 文本 传给 Qwen-VL → 看图回答
// ① 入库:图片上传 OSS + 多模态向量化 + 写库
public void ingestImage(byte[] imageBytes, String docId) {
    String imageUrl = ossService.upload("knowledge/" + docId + ".png", imageBytes);  // 存 OSS,拿到 URL
    float[] imageVector = multimodalEmbedding.embedImage(imageBytes);   // 百炼多模态 Embedding 生成图片向量
    imageVectorStore.save(docId, imageVector, Map.of("url", imageUrl));  // 图片向量 + URL 元数据写库
}

// ② 检索:文本搜图片
public List<String> searchImages(String question) {
    float[] queryVector = embeddingModel.embed(question);   // 文本向量化(普通 Embedding 即可)
    return imageVectorStore.search(queryVector, 5);          // 返回命中的图片 URL 列表
}

// ③ 生成:命中图片后,把图片 URL + 文本 给 Qwen-VL 看图回答
public String answerWithImages(String question, List<String> imageUrls) {
    return chatClient.prompt()
            .user(u -> u.text(question)
                    .media(MimeTypeUtils.IMAGE_PNG, imageUrls.get(0)))   // 多张图可链式 .media() 或传 List<Media>
            .options(ChatOptions.builder().model("qwen-vl-max").build())
            .call()
            .content();
}

说明:① multimodalEmbedding.embedImage() 是百炼多模态 Embedding(不是 Spring AI 内置的文本 EmbeddingModel,文本 Embedding 编码不了图片);② ossService 用阿里云 OSS SDK;③ imageVectorStore 需要向量库支持存图片向量(普通 pgvector 只存文本向量,通常单独建一张图片向量表或存两套向量)。这三处正是完整多模态 RAG 比方案 A 多出来的、成本高的部分,也是它生产用得少的原因。

选型结论:现实中绝大多数企业 RAG 项目优先用「图片转文字摘要」方案(方案 A);完整多模态 RAG(检索出图片、传给 VL 看原图)在生产用得不多——成本高、坑多,先用方案 A 够用,再考虑升级。

生产要区分两种情况:

  1. 用户对话时上传图片(聊天框上传)——这是对话侧多模态,直接用 Media 传给 Qwen-VL(见 10.1),不走向量库、不属于 RAG 知识库。用户临时传一张图提问,直接给 VL 看就行。

  2. 知识库文档内部自带图片(PDF/Word 里的图)——优先图片摘要方案(方案 A),不要指望多模态模型自动识别,因为 Tika 只提取文字、图片压根没进库。

生产注意事项:

  1. 千万不要同步做「文档解析 + VL 调用」——文件上传接口必须异步化,否则大文件直接超时;
  2. VL 调用有成本:图片多 token 消耗很高,要做限流、开关、失败降级;
  3. Tika 提取不了内嵌图片,docx 用 POI、PDF 用 PDFBox;
  4. 普通 Embedding 不能编码图片——方案 A 只存图片的文字摘要,不需要多模态 Embedding;
  5. 完整多模态 RAG 开发维护成本高、不是必选项,优先图片摘要够用再考虑升级。

一句话:Qwen-VL 这类多模态模型只是推理侧支持图文输入,直接替换模型不会自动实现多模态 RAG;知识库图片要「进库」,必须走「图片转文字摘要」或「多模态 Embedding 完整方案」。


第 11 章 MCP(Model Context Protocol)

一句话理解:MCP 就像 AI 工具之间的「USB-C 接口」。以前每个工具(数据库、GitHub、文件系统…)接口都不一样,AI 应用得一个个写集成代码;MCP 把接口统一了,AI 应用按一个标准就能接入海量现成工具,省去重复开发。

MCP 能提供三类东西:

类别通俗解释例子
tools能「调用」的功能查数据库、读写文件、搜 GitHub
resources能「读」的资料文件内容、数据库表结构
prompts现成的提示词模板一段写好的问答模板

社区已有数千个现成 MCP server(GitHub、PostgreSQL、Slack 等),就像一堆「即插即用」的 USB 外设。

几个关键点:

  1. Spring AI 只自动接 tools:tools 会被自动转成 Function Calling 里的 ToolCallback,直接参与工具调用;resources 和 prompts 要自己手动调(用 McpSyncClient)。
  2. 连接方式三种:STDIO = 本地启动一个进程(最省事);SSE / Streamable HTTP = 连远程服务(生产用 Streamable HTTP)。
  3. 你也能当服务端:用 @Tool 标的方法就能暴露成 MCP 工具(和第 7 章同一套注解),让别的 AI 应用接你的工具。
  4. MCP vs Function Calling:本质是「工具从哪来」的区别——Function Calling 是你自己写的 @Tool;MCP 是别人提供的现成工具,接入后都变成一样的 ToolCallback,后面走同一条调用链路。

完整配置与代码示例(客户端接入、手动调用、服务端暴露、传输协议选择)见:MCP 理论与实战


第 12 章 可观测性与评测

12.1 可观测性

Spring AI 内置 Micrometer 观测埋点,接入 Actuator 与 OpenTelemetry 即可采集指标与链路:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
spring:
  ai:
    chat:
      client:
        observations:
          include-prompt: true        # 是否在观测中记录提示词(含敏感信息需谨慎)
          include-completion: true    # 是否记录补全结果
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus

接入后,能采集到两类信息:

  • 指标(Metrics):token 用量、调用耗时、调用次数、错误率等;
  • 链路(Tracing):一次请求的完整调用链——ChatModel 调用 → 工具调用 → 向量检索 → 生成,定位慢在哪一步、错在哪一步。

和日志(第 7 章「全链路日志调试」)的关系:日志是「出问题时手动翻」的排查手段,观测是「系统化自动采集」的指标/链路,两者互补——日常看指标,出问题看日志 + 链路。

12.2 评测

评测是离线的质量评估(区别于 12.1 的运行时观测):构造带标准答案的数据集,打分后迭代提示词/工具/检索参数。评测要分两个维度分开测:

维度测什么常见指标
检索质量该命中的文档有没有被召回Recall@k、MRR、Hit Rate
生成质量拿到资料后答没答对、有没有幻觉Faithfulness(忠实度)、Answer Relevance(相关性)、Correctness(正确性)

关键:先测检索、再测生成——检索错了,生成一定错(呼应第 9.6 章「八成问题出在检索侧」)。

自动化评分基线:Spring AI 生态可结合 EmbeddingModel 算「回答 vs 标准答案」的语义相似度做近似打分(快、但只是基线,不能替代人工标注);更完整的评测可用 RAGAS、LangSmith、DeepEval 等框架。

三层能力递进:调试(第 7 章日志)→ 观测(12.1 指标/链路)→ 评测(12.2 打分迭代),分别对应「出问题排错 → 线上看运行 → 离线迭代质量」。


第 13 章 参数调整难点

参数调优是 AI 应用「从能跑到好用」的关键。本章把散落在各环节的关键参数集中梳理,给出调优方向。

13.1 模型生成参数

控制模型「怎么生成」,直接影响输出质量。以 OpenAI 为例,配置前缀 spring.ai.openai.chat.options.*(本地 Ollama 对应 spring.ai.ollama.chat.options.*),也可在代码里用 OpenAiChatOptions.builder() 按次覆盖:

参数说明调优方向
temperature随机性(0~2),越低越确定代码/SQL/提取用 0 ~ 0.3;创意用 0.7 ~ 1.0
top-p核采样(0~1),候选词累计概率与 temperature 二选一调,勿同时大幅调
top-k只从概率前 k 个词采样降低可减少跑题(部分模型支持)
max-tokens输出长度上限防超长/截断,按需设置
frequency-penalty惩罚已出现词,抑制重复出现复读/重复时调大
presence-penalty惩罚出现过的词,鼓励新话题与 frequency-penalty 配合
stop命中即停止的字符串需精确控制结尾时使用
seed随机种子需要结果可复现时设置

经验:「乱码/复读」先降 temperature 并加 frequency-penalty;「答非所问/不精确」先降 temperature 并写清提示词。多数问题先改提示词,再动参数。

13.2 网络 & 重试参数

大模型 API 常有瞬时失败(限流 429、网关 502/503/504、超时)。Spring AI 内置重试,前缀 spring.ai.retry.*:

参数默认值说明
max-attempts10最大重试次数
on-client-errorsfalse为 false 时 4xx 不重试(视为不可重试)
on-http-codes空强制触发重试的状态码,如 429,502,503,504
exclude-on-http-codes空明确不重试的状态码,如 401,403,404
backoff.initial-interval2s首次重试等待
backoff.multiplier5指数退避倍数
backoff.max-interval3m退避上限
spring:
  ai:
    retry:
      max-attempts: 5
      on-http-codes: 429,503,502,504
      exclude-on-http-codes: 401,403,400,404
      backoff:
        initial-interval: 2s
        multiplier: 5
        max-interval: 3m

退避公式:min(initial-interval × multiplier^(次数-1), max-interval)。 要点:4xx 客户端错误重试无意义(401 鉴权失败、404 模型不存在),默认不重试;429/5xx 才值得重试。连接超时等网络异常抛出 ResourceAccessException,也会被自动重试。

13.3 Function-Calling 工具调用参数

① 工具调用的核心参数

参数取值说明
internalToolExecutionEnabledtrue(默认)自动模式:Spring AI 内部完成「调模型→执行工具→回传→再调模型」多轮循环,一次 .call() 搞定
internalToolExecutionEnabledfalse手动模式:由你控制多轮工具调用,适合复杂 Agent、条件分支、逐步调试
toolChoiceauto / none / 指定强制、禁止或指定工具选择
@Tool(description)—直接决定模型选哪个工具,务必写清触发条件
import org.springframework.ai.openai.OpenAiChatOptions;

// 手动模式:自己编排多轮工具调用(复杂 Agent 场景)
OpenAiChatOptions options = OpenAiChatOptions.builder()
        .internalToolExecutionEnabled(false)
        .build();

要点:① 简单场景用默认自动模式即可;② 复杂多轮工具编排、需要自定义错误处理/条件分支时,切手动模式自己编排;③ 工具描述质量比参数更重要——描述不清,模型会选错工具或传错参。

② 关键真相:1.1.2 无法配置工具调用轮次 / 次数

网上很多「限制工具调用」的示例是从 2.x 直接复制过来的,放到 1.1.2 会编译报错。1.1.2 的现状是:

  1. ❌ 没有 ToolCallingAdvisor 类(2.0 才有),不能配置 Agent 往返迭代轮次;框架内部硬编码最大 10 轮往返,无法修改。
  2. ❌ DefaultToolCallingManager 没有 builder 的次数限制 API——maxTotalToolCalls() / maxCallsPerTool() 是 2.0 才新增;它的 Builder 只能传 observationRegistry / toolCallbackResolver / toolExecutionExceptionProcessor。
  3. ❌ 没有 yml 配置项控制轮次 / 调用次数。

1.1.2 仅有的 3 种防死循环方案

方案一:System Prompt 软约束(最简单,优先)

在 system 消息明确告知大模型:

「最多执行 3 轮工具调用,拿到所需信息后直接输出最终答案,不要反复调用工具。」

缺点:大模型有可能忽略提示词,属于软约束。

方案二:放弃 ChatClient 全自动 .call(),自己手写 while 循环硬计数(最可靠)

绕开框架内置自动循环,自己控制最大往返轮次,同时自己统计工具调用次数,双重限制。

import org.springframework.ai.chat.messages.*;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.execution.ToolExecutionResult;
import java.util.ArrayList;
import java.util.List;

public class AgentDemo {

    private final ChatModel chatModel;
    private final ToolCallbackProvider toolCallbackProvider;

    // 硬编码上限
    private static final int MAX_ROUND = 3;      // 最大往返轮次
    private static final int MAX_TOOL_CALL = 20; // 最大工具调用总次数

    public String agentRun(String userQuestion) {
        List<Message> messages = new ArrayList<>();
        messages.add(new UserMessage(userQuestion));

        int round = 0;
        int toolCallCount = 0;
        ChatResponse resp = null;

        while (round < MAX_ROUND) {
            resp = chatModel.call(new Prompt(messages, toolCallbackProvider.getToolCallbacks()));
            var output = resp.getResult().getOutput();

            // 没有工具调用,直接结束
            if (output.getToolCalls() == null || output.getToolCalls().isEmpty()) {
                break;
            }

            // 统计本次要执行的工具数量
            int currentBatchSize = output.getToolCalls().size();
            if (toolCallCount + currentBatchSize > MAX_TOOL_CALL) {
                // 超过工具总调用上限,终止循环,直接返回现有内容
                break;
            }
            toolCallCount += currentBatchSize;

            // 手动执行工具
            ToolExecutionResult toolResult = toolCallbackProvider.getToolCallbacks()[0].executeToolCalls(new Prompt(messages), resp);
            List<ToolResponseMessage> toolRespMsgs = toolResult.toolResponseMessages();

            // 追加消息到会话
            messages.add(output);
            messages.addAll(toolRespMsgs);

            round++;
        }
        return resp != null ? resp.getResult().getOutput().getText() : "";
    }
}

方案三:升级 Spring AI 到 2.x 版本

升级之后同时拥有:

  1. ToolCallingAdvisor 配置 maxIterations(3) 控制往返轮次
  2. DefaultToolCallingManager.builder().maxTotalToolCalls(20).maxCallsPerTool(10) 配置工具调用次数
  3. 支持 yml 配置

13.4 RAG 向量库参数

RAG 效果对参数极敏感,重点在「分块」和「检索」:

参数说明调优方向
chunkSize分块 token 数最关键:太大稀释语义、太小丢上下文
chunkOverlap块间重叠 token(默认 50)取 chunkSize 的 10%~20%,保跨块连贯
SearchRequest.topK检索返回条数太大引入噪声、太小漏信息,常取 3~8
SearchRequest.similarityThreshold相似度阈值过低召回无关内容,过高漏检,按数据实测
SearchRequest.filterExpression元数据过滤按 source 等过滤,缩小检索范围
index-typeHNSW / IVFFLAT生产默认 HNSW,数据量极大可试 IVFFLAT
distance-type余弦/欧氏/内积归一化嵌入用 NEGATIVE_INNER_PRODUCT 更优
dimensions向量维度必须与嵌入模型一致

经验:RAG 效果不好,先调分块(chunkSize/chunkOverlap),再调检索(topK/threshold),最后才考虑换嵌入模型或加重排。

13.5 框架自身 Advisor / 记忆参数

Advisor 与记忆的调优集中在「顺序」和「窗口」:

参数说明调优方向
MessageWindowChatMemory.maxMessages记忆窗口(条数)太大 token 超限、太小丢上下文
MessageChatMemoryAdvisor conversationId会话隔离 id必须按请求覆盖,禁止写死
Advisor order执行顺序记忆→RAG→日志
spring.ai.chat.client.observations.include-prompt是否记录提示词含敏感信息时关闭
RetrievalAugmentationAdvisor.allowEmptyContext空上下文是否作答检索为空时避免幻觉可关闭
ChatMemory memory = MessageWindowChatMemory.builder()
        .maxMessages(10)   // 按「条数」粗粒度控制窗口
        .build();

要点:① 记忆窗口按「条数」截断是粗粒度,需按 token 精确控制要自行实现计数截断;② Advisor 顺序错误会导致历史未注入或检索未生效;③ 长期记忆(VectorStoreChatMemoryAdvisor)注意 prompt 注入风险。


第五部分 · 速查

第 14 章 关键 API 与供应商切换速查表

14.1 关键 API 速查

按「使用链路」分类,每个类标注干什么 + 关键方法怎么用。

① 聊天入口:ChatClient

ChatClient client = ChatClient.builder(chatModel).build();   // 或用容器里的 ChatClient.Builder 注入

client.prompt()
        .system("你是……")                    // system 角色
        .user("……")                          // 用户输入
        .user(u -> u.text("……")              // 多模态输入(图片)
                .media(MimeTypeUtils.IMAGE_PNG, resource))
        .options(chatOptions)                 // 覆盖模型参数
        .advisors(advisor)                    // 挂 Advisor(记忆/RAG/日志)
        .tools(toolObj)                       // 挂工具
        .toolContext(ctxMap)                  // 注入上下文 Map(不进模型)
        // —— 执行 + 取结果(四选一)——
        .call().content();                    // 同步 → String
        .call().entity(Person.class);         // 同步 → 反序列化对象
        .call().chatResponse();               // 同步 → 完整 ChatResponse(含 token 用量)
        .stream().content();                  // 流式 → Flux<String>

拿 String 用 .content();拿强类型对象用 .entity();要 token 用量/元数据用 .chatResponse()。

② 核心数据模型

// Message 体系:SystemMessage / UserMessage / AssistantMessage
Message msg = new UserMessage("你好");

// ChatResponse:完整响应(上面 .chatResponse() 返回)
ChatResponse resp = client.prompt().user("……").call().chatResponse();
AssistantMessage out = resp.getResult().getOutput();   // 模型输出消息
resp.getResult().getMetadata().getFinishReason();      // 结束原因(STOP/TOOL_CALL…)
resp.getMetadata().getUsage();                         // token 用量

// ChatOptions:模型参数(第 13 章)
ChatOptions options = ChatOptions.builder()
        .model("qwen2.5").temperature(0.7).topP(0.8).maxTokens(1000)
        .build();

③ 结构化输出

// 1.1 推荐:.entity() 原生反序列化
Person p = client.prompt().user("……").call().entity(Person.class);

// 泛型(List/Map)必须用 ParameterizedTypeReference,不能用 List.class
List<Person> ps = client.prompt().user("……").call()
        .entity(new ParameterizedTypeReference<List<Person>>() {});

// 旧方式:BeanOutputConverter 手动转换(了解即可)
var conv = new BeanOutputConverter<>(new ParameterizedTypeReference<List<Person>>() {});
List<Person> ps2 = conv.convert(client.prompt().user("……").call().content());

④ 提示词模板:PromptTemplate

PromptTemplate tpl = new PromptTemplate("把这段翻译成 {lang}:{text}");
String rendered = tpl.render(Map.of("lang", "英文", "text", "你好"));   // 渲染成 String
Prompt prompt = tpl.create(Map.of("lang", "英文", "text", "你好"));     // 渲染成 Prompt 对象

⑤ 对话记忆

// 内存窗口记忆(最常用)
ChatMemory memory = MessageWindowChatMemory.builder().maxMessages(10).build();
client = builder.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build()).build();

// 长期记忆(写入向量库,语义检索历史)
VectorStoreChatMemoryAdvisor.builder(vectorStore).build();

// 手动 API:预置 / 读取 / 清空
memory.add(conversationId, List.of(new UserMessage("……")));
List<Message> history = memory.get(conversationId);
memory.clear(conversationId);

⑥ Function Calling 工具

// 定义工具:@Tool / @ToolParam 标注方法
@Tool(description = "查询天气")
public String getWeather(@ToolParam(description = "城市") String city) { … }

// 注册:默认(所有请求)或按请求
builder.defaultTools(weatherTools);
client.prompt().user("……").tools(weatherTools).call().content();

// 服务端注入上下文(直接传 Map,不进模型,防 prompt 注入)
client.prompt().user("……").toolContext(Map.of("tenantId", "t1")).call().content();

// 防死循环:1.1.2 无法配置工具调用轮次/次数(硬编码 10 轮;DefaultToolCallingManager 无次数限制 API),见 13.3
// 只能靠 system prompt 软约束或手写 while 循环硬计数

⑦ 向量库与 RAG

// 写入 / 检索 / 删除
vectorStore.add(List.of(new Document("文本", Map.of("source", "x.md"))));
List<Document> hits = vectorStore.similaritySearch(
        SearchRequest.builder().query("……").topK(5).similarityThreshold(0.5).build());
vectorStore.delete(List.of("doc-1"));

// Document 四字段:id / text / metadata / score
Document.builder().id("1").text("……").metadata(...).score(0.9).build();

// 嵌入
float[] vec = embeddingModel.embed("一段文本");                       // 单条 → float[]
EmbeddingResponse er = embeddingModel.embedForResponse(List.of("a","b")); // 批量

// 朴素 RAG
QuestionAnswerAdvisor.builder(vectorStore)
        .searchRequest(SearchRequest.builder().topK(5).similarityThreshold(0.45).build())
        .build();

// 模块化 RAG(查询改写 + 检索 + 增强)
RetrievalAugmentationAdvisor.builder()
        .documentRetriever(VectorStoreDocumentRetriever.builder().vectorStore(vectorStore).build())
        .queryAugmenter(ContextualQueryAugmenter.builder().allowEmptyContext(false).build())
        .build();

⑧ 多模态

// 视觉输入:user() 里挂 media,或 UserMessage + Media
client.prompt().user(u -> u.text("描述图片").media(MimeTypeUtils.IMAGE_PNG, resource)).call().content();

// 文生图
ImageResponse imgResp = imageModel.call(new ImagePrompt("一只金毛"));
String url = imgResp.getResult().getOutput().getUrl();

// 语音:接口由厂商实现(OpenAI 为 OpenAiAudio* 系列)
SpeechModel / SpeechPrompt / SpeechResponse            // 文字 → 语音(TTS)
TranscriptionModel / AudioTranscriptionPrompt          // 语音 → 文字(STT)

⑨ MCP

// 配置 spring.ai.mcp.client.* 后,直接注入已转好的工具(ToolCallbackProvider)
@Bean 
CommandLineRunner demo(ChatClient.Builder builder, ToolCallbackProvider mcpTools) {
    return args -> builder.build().prompt()
            .user("列出 /tmp 下的文件")
            .toolCallbacks(mcpTools)          // MCP 工具已自动转成 ToolCallback
            .call().content();
}

⑩ Advisor 扩展机制

// 自定义 Advisor:aroundCall 包裹「调用模型」这一步(前置看请求、后置看响应)
public class MyAdvisor implements CallAroundAdvisor {
    public AdvisedResponse aroundCall(AdvisedRequest req, CallAroundAdvisorChain chain) {
        req.request().messages();                     // 前置:发给模型的全部消息
        AdvisedResponse resp = chain.nextAroundCall(req);
        resp.response();                              // 后置:模型原始返回
        return resp;
    }
    public String getName() { return "MyAdvisor"; }
    public int getOrder() { return 0; }               // 数值越小越靠外层
}

// 内置 Advisor:MessageChatMemoryAdvisor(记忆)、QuestionAnswerAdvisor(RAG)、
//              SafeGuardAdvisor(防注入)、SimpleLoggerAdvisor(日志)

14.2 模型来源切换对照表

模型来源starter 依赖配置前缀模型名示例需 API Key
Ollama(本地)spring-ai-starter-model-ollamaspring.ai.ollama.*qwen2.5 / llama3.2否
OpenAI(云端)spring-ai-starter-model-openaispring.ai.openai.*gpt-4o-mini / gpt-4o是
Anthropic(云端)spring-ai-starter-model-anthropicspring.ai.anthropic.*claude-sonnet-4-5是

切换方式:换依赖 + 改 spring.ai.model.chat + 填对应配置前缀即可,业务代码零改动。

国内云端大模型:智谱(spring-ai-starter-model-zhipuai)、月之暗面 Kimi(spring-ai-starter-model-moonshot)、百度千帆(spring-ai-starter-model-qianfan)等亦有官方 starter;阿里云通义千问 DashScope 由 Spring AI Alibaba 生态(spring-ai-alibaba-starter-dashscope)提供,用法与上文一致。

14.3 application.yml 关键配置

按主题汇总常用配置(按需取用,不需要的删掉即可):

spring:
  ai:
    # ① 模型选择:多 starter 并存时决定激活哪个(换供应商只改这里 + 换依赖)
    model:
      chat: ollama            # ollama / openai / anthropic / none
      embedding: ollama

    # ② 本地 Ollama
    ollama:
      base-url: http://localhost:11434
      chat:
        options:
          model: qwen2.5
          temperature: 0.7
      embedding:
        options:
          model: nomic-embed-text      # 768 维

    # ③ 云端 OpenAI(Anthropic 类似,前缀 spring.ai.anthropic.*)
    openai:
      api-key: ${OPENAI_API_KEY}       # 从环境变量读,不要写死
      base-url: https://...            # 兼容端点(阿里百炼等),官方地址可省略
      chat:
        options:
          model: gpt-4o-mini
          temperature: 0.7
      embedding:
        options:
          model: text-embedding-3-small   # 1536 维

    # ④ 重试(429/5xx 瞬时失败自动重试)
    retry:
      max-attempts: 5
      on-http-codes: 429,502,503,504
      exclude-on-http-codes: 401,403,400,404
      backoff:
        initial-interval: 2s
        multiplier: 5
        max-interval: 3m

    # ⑤ 工具调用(Function Calling)
    tools:
      throw-exception-on-error: false  # false:异常回传模型;true:抛给调用方

    # ⑥ 向量库 pgvector
    vectorstore:
      pgvector:
        initialize-schema: true
        index-type: HNSW                 # HNSW / IVFFLAT / NONE
        distance-type: COSINE_DISTANCE   # COSINE / EUCLIDEAN / NEGATIVE_INNER_PRODUCT
        dimensions: 768                  # 必须与嵌入模型维度一致

    # ⑦ MCP 客户端
    mcp:
      client:
        enabled: true
        toolcallback:
          enabled: true                  # 自动把 MCP 工具转成 ToolCallback
        stdio:
          connections:
            filesystem:
              command: npx
              args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]

  # ⑧ pgvector 依赖的数据源
  datasource:
    url: jdbc:postgresql://localhost:5432/postgres
    username: postgres
    password: postgres

记住三点:① spring.ai.model.chat/embedding 决定激活哪个供应商;② 每个供应商在自己的 spring.ai.<provider>.* 下配 key / base-url / model;③ 重试、工具、向量库、MCP 各有独立前缀。

14.4 嵌入模型维度对照

嵌入模型维度
nomic-embed-text(Ollama 本地)768
text-embedding-3-small(OpenAI)1536
text-embedding-3-large(OpenAI)3072
text-embedding-ada-002(OpenAI)1536

配置 pgvector 等向量库的 dimensions 时,务必与所选嵌入模型维度一致。