面向 Java 开发者的 Spring AI 完整入门到进阶指南(下篇)
版本 :Spring AI
1.1.2· Spring Boot3.5.x· Java 17+下篇涵盖:第 7~14 章 ------ Function Calling、向量数据库、RAG、多模态、MCP、可观测性与评测、参数调整、关键 API 速查。
第三部分 · 让模型「动手」与「找资料」
第 7 章 Function Calling(工具调用)
模型本身无法查实时数据、调业务系统。Function Calling 让模型在需要时发起工具调用,由你的代码执行并回传结果。
7.1 完整交互流程
Function Calling 的「调用」发生在你的代码里,而不是模型里。模型只负责决定要不要调用、调用哪个工具,真正的执行始终由本地 Java 代码完成。一次完整的工具调用流程如下:
text
用户提问 → LLM 判断 → 输出 tool_calls → SDK 匹配本地工具 → 执行 Java → 封装 ToolMessage → 回传 LLM → 最终回答
-
注册工具(开发阶段) :开发者在本地注册工具,每个工具只提交三部分元信息 ------
name(工具名)、description(功能描述)、入参 JSON Schema (参数名称、类型、约束等)。注意:传给大模型的是元信息,Java 源代码本身不会被传给模型。 -
随请求提交工具清单 :每次请求大模型时,Spring AI SDK 会把所有已注册工具的元信息一并提交给 LLM,模型由此「知道」自己现在有哪些工具可用、各自能干什么。
-
模型决策:LLM 结合用户问题的语义,判断是否需要调用工具:
- 不需要 → 直接返回普通文本回答;
- 需要 → 输出
tool_calls结构,携带function.name(要调用的工具名)与arguments(参数 JSON,例如{"city":"北京"})。
-
本地匹配并执行 :Spring AI SDK 拿到响应后,根据
function.name去本地已注册的工具回调 中匹配,找到对应方法并执行真正的 Java 业务逻辑。模型本身不能运行任何代码,它只是「点名」要哪个工具、传什么参数。 -
回传结果并生成回答 :SDK 把工具的执行结果封装成
ToolMessage再次发给大模型,由模型结合工具返回结果整理、润色,输出最终回答给用户。
核心要点:整个流程里,模型只负责「选择」工具,代码的「执行」始终在本地完成------这正是 Function Calling 安全可控的原因。
7.2 用 @Tool 定义工具
Spring AI 1.1 推荐用 @Tool / @ToolParam 注解,旧 FunctionCallback 已废弃。
java
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 常用属性:
| 注解 | 属性 | 默认 | 说明 |
|---|---|---|---|
@Tool |
name |
方法名 | 暴露给 LLM 的工具名,可自定义避免重名 |
@Tool |
description |
空 | 触发条件与返回值说明,直接影响工具选择 |
@Tool |
returnDirect |
false | true 时结果直接返回用户,不再让模型二次加工 |
@Tool |
resultConverter |
JSON | 自定义结果转换器 |
@ToolParam |
name |
参数名 | 参数名 |
@ToolParam |
description |
空 | 参数说明,帮助模型正确取值 |
@ToolParam |
required |
true | 是否必填,可选参数设 false 或用 @Nullable |
注意:用 record 作为参数时,字段默认可选,必填字段需在方法体内自行校验。
7.3 注册工具并调用
java
@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();
}
}
测试:
shell
curl "http://localhost:8080/weather?message=北京今天天气怎么样"
# 模型识别到需要查天气 → 触发 getWeather 工具 → 回传结果 → 生成最终回答
7.4 返回对象与多参数(进阶)
工具方法不一定要返回 String ,可以直接返回 Java 对象(record / DTO),Spring AI 会通过 ToolCallResultConverter 自动序列化成 JSON 回传给模型,模型再据此组织自然语言回答:
java
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);
}
}
说明:返回对象被序列化为 JSON 传给模型。若要对回传格式做定制(例如改成 YAML/XML),可自定义
ToolCallResultConverter;参数校验可用 JSR-303(如@NotNull)配合@Validated开启方法校验。
7.5 实战:一次请求触发多个工具
当用户的一句话涉及多个能力时,模型会在同一轮里发起多个工具调用,Spring AI 逐个执行后汇总回传,模型再综合生成最终回答。
一次注册多个工具:
java
@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 同时需要「天气」和「股价」两类工具):
shell
curl "http://localhost:8080/assistant?message=帮我查一下北京今天的天气,再查一下 AAPL 和 TSLA 的股价,并说说今天更适合关注哪只股票"
执行流程:
scss
用户提问(一句话)
→ 模型识别出 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 框架的问题,而是大模型本身的行为特性------模型是基于概率的生成,而非确定性程序,因此「要不要调工具、调哪个、参数对不对」每一步都可能出错。
-
不确定性来自哪里:
- 选错工具:多个工具语义相近时,模型可能选错或漏选;
- 参数填错:参数类型、取值不符合预期,或该填的字段没填、格式不对;
- 漏调用 / 过度调用:该调用时不调用(转而「编造」答案),或不该调用时乱调用;
- 返回不稳定:同样的输入,每次输出可能不完全一致;
- 描述歧义 :工具的
description写得含糊,模型理解产生偏差。
-
生产工程上如何降低不确定性:
① 写好工具描述(正例 vs 反例) :
description是模型判断「何时用、用什么」的唯一依据,务必写清触发场景、输入输出与边界。java// ❌ 反例:描述太泛,模型容易误判触发条件、填错参数 @Tool(description = "处理订单") public String handleOrder(@ToolParam(description = "参数") String param) { // ... } // ✅ 正例:写清「何时用、返回什么、不做什么」 @Tool(description = "根据订单号查询订单状态(待支付/已发货/已签收)。" + "仅在用户询问「订单到哪了 / 发货了吗 / 订单状态」时调用;" + "不处理退款、改地址、下单等操作") public String queryOrderStatus( @ToolParam(description = "订单号,20 位数字,例如 20240101000000000001") String orderNo) { // ... }② Prompt 层强制约束(SystemPrompt):在系统提示词里给模型设定全局行为边界------什么时候必须调工具、什么时候禁止硬答。
text你是一个订单助手。规则: 1. 涉及实时数据(订单状态、库存、价格)时必须调用对应工具,不得凭记忆编造; 2. 工具未返回结果前,禁止输出确定性的结论; 3. 匹配不到合适工具时,直接说明「该问题超出我的能力范围」,不要硬答。③ 做校验层:不要信任模型输出 :模型返回的
arguments只是「建议」,正式执行前必须校验。工具内部用 JSR-303(@NotNull、@Pattern)或手动校验入参;对tool_calls里的工具名做白名单匹配,非法工具名 / 参数直接拒绝,并把错误信息回传模型,让它修正重试。④ 区分业务重要程度:不同工具不能同等对待------
- 低风险工具(查天气、查资料等读操作):可放宽校验,失败重试即可;
- 高风险工具(转账、下单、删除、发短信等写操作):需二次确认、权限校验、限额控制,必要时不让模型直接触发,只让模型「填表」再由人工确认。
⑤ 兜底策略 :模型调不出结果或调用失败时,要有「最后一道防线」------返回明确的错误提示引导用户换种问法、降级为人工客服、或走默认只读逻辑,而不是抛异常中断整个链路。同时要限制工具连环调用的次数,防止模型在「调用 → 结果不理想 → 再调用」中陷入死循环:
javaChatOptions options = ChatOptions.builder() .toolCallMaxIterations(3) // 最多连环调用 3 次工具,防死循环 .build(); String answer = chatClient.prompt() .user(message) .options(options) .call() .content();⑥ 选型层面 :对 function-call 要求高的场景,优先选指令遵循强的模型(如 DeepSeek、Qwen 等);很多开源小模型的 function-call 能力很差,工具一多就容易选错或漏调------此时要么换更强的模型,要么收敛工具数量、简化描述来迁就模型能力。
一句话总结:不确定性无法彻底消除,只能靠「清晰的工具描述 + Prompt 约束 + 严格校验 + 分级授权 + 兜底降级」层层兜住,把它压到可接受的范围。
7.7 工具异常处理
工具方法抛异常时,由 ToolExecutionException 包装,ToolExecutionExceptionProcessor 处理。关键开关 spring.ai.tools.throw-exception-on-error(默认 false):
yaml
spring:
ai:
tools:
throw-exception-on-error: false # false:异常以错误消息回传模型;true:直接抛给调用方
最佳实践:
- 工具内部
try-catch,不要让堆栈信息泄露给用户; - 优先 return 错误描述(而非抛异常),让模型能据此调整或向用户解释;
- 对外部 API 调用加超时保护,捕获
TimeoutException。
7.8 ToolContext 传递上下文
某些数据(tenantId、当前用户身份)不该交给模型当参数 ,而应在工具执行时由服务端注入------多租户、权限校验的推荐做法。ToolContext 里装的是不发给模型、仅在工具执行侧使用 的数据,工具方法通过 call(String toolInput, ToolContext toolContext) 拿到它。
为什么不能让模型传 :如果 tenantId / userId 变成 @ToolParam,模型就掌握了对它们的「决定权」,恶意 prompt 可以诱导模型改传别人的租户或越权操作(prompt 注入)。所以这类数据必须由服务端从登录态里取,塞进 ToolContext。
① 调用侧:从登录态取数,注入 ToolContext
java
import org.springframework.ai.tool.context.ToolContext;
@GetMapping("/order")
public String query(@RequestParam("message") String message) {
// 服务端可信数据:从登录态/请求上下文取(示例,真实项目换成你的安全框架)
String tenantId = "tenant-001";
String userId = "u_10086";
ToolContext toolContext = ToolContext.builder()
.with("tenantId", tenantId)
.with("userId", userId)
.build();
return chatClient.prompt()
.user(message)
.toolContext(toolContext) // 随请求注入,但不进入发给模型的 JSON
.call()
.content();
}
② 工具侧:用 ToolContext 参数读取(不进 JSON Schema)
java
@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;这两个值由服务端注入、工具内部读取,天然防篡改。
核心原则:不要把用户身份/权限交给模型参数 (易被 prompt 注入篡改),而应通过ToolContext由服务端注入,工具执行时从 context 读取。
7.9 底层机制(二次开发速览)
了解 @Tool 背后的核心组件,方便二次开发与排错:
| 组件 | 职责 |
|---|---|
ToolCallback |
一次工具调用的封装(名称 + 描述 + 入参 schema + 执行逻辑) |
ToolCallbackProvider |
提供一组 ToolCallback(@Tool 注解扫描、MCP 工具都基于它) |
ToolCallingManager |
执行工具调用,并把结果回传模型 |
ToolCallResultConverter |
把工具返回值转换成回传模型的字符串(默认 JSON) |
7.10 全链路日志调试
Function Calling 排查问题时的最大痛点:你只看到最终答案,看不到中间「模型 → 工具 → 模型」的每一跳。工具选错、参数填错、漏调用时,光看结果根本定位不了。把整条链路打印出来,从粗到细有三种方式:
① 开启框架底层日志
最省事,改 application.yml 打开 Spring AI 相关包的 DEBUG 日志:
yaml
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,不够聚焦业务。
② 自定义 Advisor 拦截全链路(推荐)
Advisor 是 Spring AI 的请求/响应拦截链 ,aroundCall 会把「调用模型」这一步完整包裹:调用前能拿到发给模型的全部消息,调用后能拿到模型原始返回(含是否发起 tool_calls)。把日志 Advisor 挂在最外层,即可打印每一跳:
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse;
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
public class FullChainLoggingAdvisor implements CallAroundAdvisor {
private static final Logger log = LoggerFactory.getLogger(FullChainLoggingAdvisor.class);
@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
// 1) 打印「发给模型」的完整消息列表(system / user / 历史 tool 消息)
log.info("[请求] 发给模型的消息:{}", advisedRequest.request().messages());
// 2) 放行:执行后续 Advisor 链,最终调用模型
AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest);
// 3) 打印「模型返回」的原始响应(含是否发起工具调用)
ChatResponse response = advisedResponse.response();
AssistantMessage assistant = response.getResult().getOutput();
log.info("[响应] 模型文本输出:{}", assistant.getText());
log.info("[响应] 工具调用:{}", assistant.getToolCalls());
return advisedResponse;
}
@Override
public String getName() {
return "FullChainLoggingAdvisor";
}
@Override
public int getOrder() {
return 0; // 数值越小越靠外层;0 = 最外层,能包裹住其它 Advisor
}
}
注册:
java
this.chatClient = builder
.defaultAdvisors(new FullChainLoggingAdvisor())
.defaultTools(weatherTools)
.build();
提示:Spring AI 也内置了
SimpleLoggerAdvisor,想快速看每一步消息可直接用;自定义 Advisor 的好处是可以按需裁剪字段、统一格式、接入自己的日志平台。
③ 包装 ToolCallback,打印本地工具调用的入参、返回值
Advisor 看到的是「模型侧」的消息,但工具真正执行的入参与返回发生在 ToolCallback.call()。用一个包装类代理原 ToolCallback,在调用前后各打一条日志,即可定位「模型传了什么参数进来、工具回了什么出去」:
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.context.ToolContext;
import org.springframework.ai.tool.definition.ToolDefinition;
public class LoggingToolCallback implements ToolCallback {
private static final Logger log = LoggerFactory.getLogger(LoggingToolCallback.class);
private final ToolCallback delegate;
public LoggingToolCallback(ToolCallback delegate) {
this.delegate = delegate;
}
@Override
public String call(String toolInput, ToolContext toolContext) {
String name = getToolDefinition().name();
log.info("[工具入参] {} -> {}", name, toolInput);
String result = delegate.call(toolInput, toolContext);
log.info("[工具返回] {} -> {}", name, result);
return result;
}
@Override
public ToolDefinition getToolDefinition() {
return delegate.getToolDefinition();
}
}
如何接入 :ChatClient 的 .tools() / .defaultTools() 既能接收带 @Tool 注解的 bean,也能直接接收 ToolCallback 或 ToolCallbackProvider。所以把 @Tool 方法手动转成 ToolCallback,套上日志包装再注册即可。
java
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.method.MethodToolCallback;
// 方式一:单个工具 ------ MethodToolCallback 把 @Tool 方法转成 ToolCallback,再包装
ToolCallback logged = new LoggingToolCallback(
MethodToolCallback.builder()
.toolObject(weatherTools) // 持有 @Tool 方法的 bean
.toolMethod("getWeather") // 要包装的方法名
.build());
this.chatClient = builder
.defaultTools(logged) // 注册包装后的工具
.build();
工具多时逐个写方法名较繁琐,用 ToolCallbackProvider 统一包装更省事:
java
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallback;
import org.springframework.stereotype.Component;
@Component
public class LoggingToolCallbackProvider implements ToolCallbackProvider {
private final List<ToolCallback> callbacks;
public LoggingToolCallbackProvider(WeatherTools weatherTools, StockTools stockTools) {
this.callbacks = List.of(
new LoggingToolCallback(MethodToolCallback.builder()
.toolObject(weatherTools).toolMethod("getWeather").build()),
new LoggingToolCallback(MethodToolCallback.builder()
.toolObject(stockTools).toolMethod("getStockPrice").build()));
}
@Override
public ToolCallback[] getToolCallbacks() {
return callbacks.toArray(ToolCallback[]::new);
}
}
java
this.chatClient = builder
.defaultTools(loggingToolCallbackProvider) // 传入 Provider,一次注册全部
.build();
如果只想给
@Tool方法加日志、不想引入包装类,也可直接用 Spring AOP 切@Tool注解方法(@Around("@annotation(org.springframework.ai.tool.annotation.Tool)")),更省事;包装ToolCallback的好处是能拿到 Spring AI 已解析好的ToolDefinition(name、description、schema)和序列化后的入参 JSON。
三种方式互补:① 看底层框架/HTTP 日志,② 看「模型 ↔ 消息」的每一跳,③ 看「工具 ↔ 入参/返回」的细节。三者叠加即可还原一条请求的完整现场。
日志关键调试看哪些信息
排查时重点盯这几个字段:
| 关注点 | 看什么 | 能发现的问题 |
|---|---|---|
| 工具选择 | 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-text768 维、text-embedding-3-small1536 维),所以要保证向量库的dimensions和它一致。
先厘清三个词的关系:插件 / 数据库 / 向量数据库
本文以 pgvector 为例,很多人误以为「pgvector 就是一个独立的向量数据库」------其实它是「数据库 + 插件」的组合:
text
向量数据库(本文方案) = 数据库(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.9 节能「换 starter + 配置即可切换」的原因。
8.2 用 SimpleVectorStore 快速验证(免装库)
在接数据库之前,可以用 Spring AI 内置的内存向量库 SimpleVectorStore 先把 RAG 流程跑通------零外部数据库依赖,适合本地开发、单元测试。
依赖说明:
SimpleVectorStore在spring-ai-vector-store模块里(Spring AI 1.0 起从spring-ai-core拆出)。若只引入了聊天模型 starter(如第 2 章的 Ollama),需要额外补一个依赖:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vector-store</artifactId>
</dependency>
依赖就绪后,写一个 @Bean 即可:
java
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 的「嵌入模型怎么选」。
用法与 pgvector 完全一致(同一个 VectorStore 接口),add / similaritySearch / delete 照写不误。验证通过后,只需把上面的 @Bean 换成 PgVectorStore 或其它实现,业务代码零改动。
特点:无需外部数据库、秒级启动、随时可跑;缺点是内存存储、重启即丢、不支持多实例共享,只用于开发验证,生产必须换持久化向量库。
8.3 准备 pgvector 环境
验证流程没问题后,生产要换成真正持久化的向量库。本文以 pgvector(PostgreSQL + 扩展)为例,先用 Docker 把它跑起来:
shell
# 用官方 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 里配置连接与向量参数:
xml
<!-- 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 的那个依赖。
yaml
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 显式指定:
yaml
spring:
ai:
model:
embedding: ollama # 决定默认注入的 EmbeddingModel
ollama:
embedding:
options:
model: nomic-embed-text # 768 维
openai:
embedding:
options:
model: text-embedding-3-small # 1536 维
java
// 多模型并存时,用 @Qualifier 显式指定嵌入模型(SimpleVectorStore / PgVectorStore 同理)
@Bean
public VectorStore vectorStore(@Qualifier("ollamaEmbeddingModel") EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
@Qualifier的 bean 名:Ollama 是ollamaEmbeddingModel,OpenAI 是openAiEmbeddingModel。选哪个模型,dimensions就设成对应维度(768 或 1536)。
chat 与 embedding 供应商不同时 :如果 chat 用某个账号/端点、embedding 想走另一个端点(不同账号或不同的 OpenAI 兼容服务),可以给 embedding 单独配 base-url / api-key,它们会覆盖共用的 spring.ai.openai.base-url / api-key:
yaml
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_DISTANCE |
L2 欧氏距离 | 数值型向量 |
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,适合需要动态传参(如从配置中心读维度)的场景:
java
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 建好后,写入和检索各是一个方法调用------写入时框架会自动调用嵌入模型把文本向量化,你只传文本和元数据即可:
java
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());
}
上面的
Document是手工new的,实际项目里文本通常来自文件。Spring AI 提供DocumentReader系列解析器(统一实现DocumentReader接口),能把磁盘文件 /MultipartFile直接解析成List<Document>:
java
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。
java
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 越高越相似:
java
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 | 过严,容易一条都查不到 |
阈值是「最低可接受的相似度」,不同嵌入模型分数分布略有差异,以实际打印为准。
开发实操建议:
- 开发调试阶段先设 0.65 ,把检索返回的文档逐条打印
score,观察真实业务数据的分数分布(相关文档大概多少分、无关文档大概多少分)。 - 根据打印出来的实际 score 再上调到 0.7 ~ 0.8 。不要拍脑袋写死 0.9------阈值过高很容易「查不到任何文档」,模型拿不到资料只能瞎编,反而产生幻觉。
一句话:阈值靠数据说话,不靠猜------先打印分数分布,再在「相关」和「无关」的分数之间取阈值。
8.8 元数据过滤与删除文档
检索时按元数据过滤(例如只查某个来源),避免跨领域误召回:
java
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());
删除文档:
java
// 按 id 删除(id 在写入时通过 Document.builder().id(...) 指定,未指定则自动生成 UUID)
vectorStore.delete(List.of("doc-1", "doc-2"));
// 按过滤条件批量删除
vectorStore.delete("source == 'old.md'");
更新文档 = 先删后加:
java
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(流式):
java
// ------ 字符串表达式(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 = 先从知识库检索 相关文档,再把这些文档作为上下文注入提示词,让模型基于真实资料回答,减少幻觉。
text
离线入库:文件 → 读取 → 切分 → 向量化 → 存入向量库
在线问答:用户提问 → 检索相关文档 → 注入提示词 → 模型生成回答
RAG 分离线 和在线两段:离线把知识「切块 + 向量化」灌进向量库(9.1),在线把提问「检索 + 注入」交给模型回答(9.2 起)。
9.1 ETL:数据摄入
三步走:读取 → 切分 → 写入向量库。其中「读取」用 TikaDocumentReader(底层 Apache Tika)解析 PDF/Word/PPT/HTML 等文件------RAG 知识库导入文件必须,需要先引入依赖:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
java
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 = new TokenTextSplitter(800, 350, 5, 10000, true);
// 3. 写入向量库(自动 embedding)
List<Document> chunks = splitter.apply(reader.get());
vectorStore.add(chunks);
⚠️ 图片不会被自动解析 :
TikaDocumentReader这类普通解析器只提取文本,碰到图片既不报错、也不会识别,而是直接跳过------图片里承载的信息就丢了。框架不会自动帮你判断文档里有没有图片,需要业务层自己处理:图片带业务信息(如架构图、流程图、截图里的关键内容)时,要手动把图片抽出来,调用多模态模型转成文字描述,再参与切块和向量化;纯装饰的配图则无需处理。
切分(Chunk)策略 :长文档必须切成小块再入库,块大小直接决定检索质量。上面 TokenTextSplitter(800, 350, 5, 10000, true) 五个参数:
| 参数 | 值 | 含义 |
|---|---|---|
defaultChunkSize |
800 | 每块的目标大小(token 数) |
minChunkSizeChars |
350 | 块的最小字符数,避免切得太碎 |
minChunkLengthToEmbed |
5 | 短于此的碎片不向量化,直接丢弃 |
maxNumChunks |
10000 | 单文档最多切多少块(防超大文档) |
keepSeparator |
true | 切分时是否保留换行等分隔符 |
块大小怎么选:块太大 → 检索结果噪声多、语义不聚焦;块太小 → 一句话被切到两个块、上下文断裂。一般 300~1000 token 起步,没有银弹------先用默认值跑通,再按检索效果微调。
进阶分块策略:固定大小切块简单,但容易把完整语义切碎。对检索质量要求高时,常用三种进阶策略:
| 策略 | 思路 | 适用 |
|---|---|---|
| 父子分块(parent-child) | 检索用小块 (精准命中),喂模型时带回父块(补全上下文) | 文档结构清晰、需要完整上下文 |
| 语义分块 | 按语义边界(段落/标题)切,而非按 token 硬切 | 避免把一句话切到两个块 |
| small-to-big | 检索小片段 → 扩展回更大的原文块 | 兼顾精准命中 + 上下文完整 |
入门先用固定大小切块跑通即可;发现「检索命中了但答案缺上下文」时,再考虑父子分块。
入库去重 :重复导入同一文档会产生重复向量、污染检索结果。给Document指定稳定的id(如「文件名 + 块序号」),配合「更新 = 先删后加」(见 8.8)即可避免重复入库。
9.2 朴素 RAG:QuestionAnswerAdvisor
最简单的方式,检索 + 注入 + 问答一气呵成。需要引入 spring-ai-advisors-vector-store 依赖(和 6.4 的长期记忆 VectorStoreChatMemoryAdvisor 是同一个模块):
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
java
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();
9.3 自定义提示词模板
QuestionAnswerAdvisor 默认用英文模板合并「问题 + 检索结果」,中文/业务场景应自定义。模板必须含 {query} 和 {question_answer_context} 两个占位符:
java
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();
自定义模板常用于:① 中文提示;② 强制「资料里没有就说不确定」;③ 控制回答格式。
引用溯源(Citation) :企业知识库常要回答「依据哪份文档」。关键是入库时把来源存进 Document 的 metadata(如 source,见 9.1),回答时把来源带出来。注意:QuestionAnswerAdvisor 默认只注入正文、不注入 metadata,要做溯源通常用 9.5 的手动 RAG(docs 自带 metadata)把来源拼进提示词或随回答返回------核心 Spring AI 不内置 citation。
9.4 元数据过滤
按文档元数据缩小检索范围(例如只查某个来源):
java
String answer = chatClient.prompt()
.user("退货政策是什么?")
.advisors(a -> a.param(
QuestionAnswerAdvisor.FILTER_EXPRESSION, "source == 'policy.md'"))
.call()
.content();
9.5 模块化 RAG:RetrievalAugmentationAdvisor
1.1.0 新增,属于 spring-ai-rag 模块,需要引入依赖:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-rag</artifactId>
</dependency>
先理解「揉在一起」是什么意思------对比手动 RAG,每一步都自己写:
java
// 手动 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)间接干预:
java
// 朴素 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,有成本)。以电商客服知识库为例,用户问「这东西咋退啊」:
-
① 查询改写:用户提问口语化、和文档用词不一致时能明显提升命中率。
- 例:用户「这东西咋退啊」→ 改写为「如何申请退货」,才能对上库里文档「退货流程与政策」;提问已经很规范就不必加。
-
② 检索:始终生效------它就是真正从库里捞文档的那一步。
- 例:拿「如何申请退货」去检索,返回「退货政策」「退款说明」等 topK 条。
-
③ 后处理(Rerank):向量召回「排序不够准」时,用更精准的 reranker 重排取前几条。
- 例:向量检索可能把「发货时效」排在「退货政策」前面(语义相近但非目标),Rerank 后把「退货政策」提到第 1 位;数据量小、召回已够准就别加。
-
④ 增强:需要控制检索结果怎么拼进提示词、或检索为空时怎么处理时再加。
- 例:把重排后的文档拼进「请基于资料回答」的提示词;一条都没检索到时
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 差不多:
java
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」(③)、「增强」(④):
java
// 新增 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 一样用:
java
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 精排。
text
用户提问
├─ 向量检索(语义相近)──────────┐
└─ BM25 关键词检索(精确匹配)────┤→ 融合(如 RRF)→ 精排(Rerank)→ 注入提示词
- 什么时候上:知识库里编号、型号、专有名词多,纯向量查不准时。
- 怎么落地 :核心
VectorStore接口只做向量检索,关键词检索 / 融合需配合具体向量库(Milvus / Elasticsearch 原生支持混合检索),或用 Spring AI Alibaba 的混合检索方案------比纯向量重,按需引入。
简单场景先用「纯向量 + 查询改写 + Rerank」就够了;发现「编号/型号查不准」再上混合检索。
9.7 多轮对话 RAG(RAG + 记忆)
用户会追问(「那它的价格呢?」「和上一个比呢?」),指代消解需要结合对话记忆 (第 6 章)。做法:给 ChatClient 同时挂「记忆 Advisor」和「RAG Advisor」即可:
java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(memory).build(), // 记忆:带上历史对话
qaAdvisor) // RAG:检索 + 注入
.build();
关键:① 历史对话(含指代)会随记忆进到 RAG 的检索/查询改写里,帮助消解「它」指什么;② 配
RewriteQueryTransformer时多轮指代消解效果更好;③ 记忆会让 token 持续增长,配合第 6 章的窗口截断/摘要使用。
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 视觉输入(图片理解)
java
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 构建:
java
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):
yaml
spring:
ai:
model:
image: openai # 指定 image 模型供应商(类似 chat / embedding)
openai:
api-key: ${OPENAI_API_KEY}
image:
options:
model: dall-e-3 # 图像生成模型,也可用 gpt-image-1
size: 1024x1024
quality: standard
注意:本地 Ollama 没有官方文生图实现(见章首「Ollama 现状」),文生图需用云端图像模型。
ImageModel 由 Spring AI 自动配置,注入即可用:
java
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 提供。先配置语音模型:
yaml
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 自动配置,注入即可用:
java
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、向量库都不用改):
- 文档解析时,把 docx / PDF 里的图片提取出来;
- 调 Qwen-VL 描述图片/图表(如「详细描述这张图,包含图表数据、趋势,输出文本描述」);
- 把生成的文本摘要当成普通
Document切片存入向量库; - 查询阶段走普通文本检索,给 LLM 的上下文是图片的文字描述。
- ✅ 优点:复用现有全部 RAG 代码,改动小、性能好;
- ❌ 缺点:模型看不到原图,只能看文字摘要,复杂图表细节会丢失。
下面是方案 A 的完整实现,五步照抄即可:
① 依赖:提取文档内图片,docx 用 POI、PDF 用 pdfbox(Tika 提取不了内嵌图片):
xml
<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 把图片转成文字摘要(输入图片二进制,输出专供检索用的文字描述):
java
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/ 下):
java
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,遍历每一页、拿出页面内的图片字节即可。
④ 入库:文本切片 + 图片摘要一起写:
java
// 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(检索能召回原图)
- 入库:提取图片 → 调多模态 Embedding 生成图片向量 → 图片文件存 OSS → 向量库保存「图片向量 + 图片 URL」;
- 检索:支持「文本搜图片、图片搜文本」;
- 命中后:把图片 URL 和文本一起塞给 Qwen-VL,模型拿到真实图片直接看图回答。
⚠️ 坑:普通文本 Embedding 不能编码图片,必须用专门的多模态 Embedding;向量库要同时存「文本向量 + 图片向量」两套。
方案 B 完整实现(架构 + 代码骨架,Java + Spring AI + 阿里云百炼):
text
入库:文档 → 提取图片(POI/PDFBox)
├→ 上传 OSS → 图片 URL
└→ 多模态 Embedding → 图片向量
→ 写入向量库(图片向量 + URL 元数据)
查询:提问 → 文本向量化 → 检索向量库 → 命中图片文档(含 URL)
→ 图片 URL + 文本 传给 Qwen-VL → 看图回答
java
// ① 入库:图片上传 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 够用,再考虑升级。
生产要区分两种情况:
-
用户对话时上传图片 (聊天框上传)------这是对话侧多模态 ,直接用
Media传给 Qwen-VL(见 10.1),不走向量库、不属于 RAG 知识库。用户临时传一张图提问,直接给 VL 看就行。 -
知识库文档内部自带图片 (PDF/Word 里的图)------优先图片摘要方案(方案 A),不要指望多模态模型自动识别,因为 Tika 只提取文字、图片压根没进库。
生产注意事项:
- 千万不要同步做「文档解析 + VL 调用」------文件上传接口必须异步化,否则大文件直接超时;
- VL 调用有成本:图片多 token 消耗很高,要做限流、开关、失败降级;
- Tika 提取不了内嵌图片,docx 用 POI、PDF 用 PDFBox;
- 普通 Embedding 不能编码图片------方案 A 只存图片的文字摘要,不需要多模态 Embedding;
- 完整多模态 RAG 开发维护成本高、不是必选项,优先图片摘要够用再考虑升级。
一句话:Qwen-VL 这类多模态模型只是推理侧支持图文输入,直接替换模型不会自动实现多模态 RAG;知识库图片要「进库」,必须走「图片转文字摘要」或「多模态 Embedding 完整方案」。
第 11 章 MCP(Model Context Protocol)
MCP 是一个开放的「模型-工具」标准协议,让 AI 应用能按统一方式接入海量现成工具服务(数据库、GitHub、文件系统等)。Spring AI 1.1 深度集成 MCP。
MCP 协议提供三类能力:
| 能力 | 说明 |
|---|---|
| tools | 可调用的工具(查数据库、读写文件等),Spring AI 主要集成这一类 |
| resources | 资源(文件内容、数据库 schema),供客户端读取 |
| prompts | 提示词模板 |
社区已有数千个现成 MCP server(GitHub、PostgreSQL、Slack 等),按统一协议接入成本极低。
Spring AI 当前完整支持 tools (自动转成
ToolCallback参与 Function Calling);resources 和 prompts 没有自动集成 ,需要手动用McpSyncClient调用(见 11.2)。
11.1 客户端:把 MCP 工具自动转成 ToolCallback
引入客户端 starter:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
配置 MCP 服务器连接(STDIO / SSE 两种传输):
yaml
spring:
ai:
mcp:
client:
enabled: true
toolcallback:
enabled: true # 自动把 MCP 工具转成 ToolCallback
# STDIO 方式:本地启动一个 MCP server 进程
stdio:
connections:
filesystem:
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
# SSE 方式:连接远程 MCP server
# sse:
# connections:
# remote:
# url: http://localhost:8080
# Streamable HTTP 方式(生产推荐)
# streamable-http:
# connections:
# remote:
# url: http://localhost:8080
# endpoint: /mcp # 可选,默认 /mcp
传输方式选择:
| 传输 | 说明 | 适用 |
|---|---|---|
| STDIO | 本地进程,通过 stdin/stdout 通信 | 本地工具、开发调试 |
| SSE | 基于 HTTP 的远程服务 | 远程 MCP server |
| Streamable HTTP | 1.1 新增的新一代传输 | 生产环境更推荐 |
本地工具用 STDIO 最简单;生产连远程 server 优先考虑 Streamable HTTP。MCP 客户端是懒连接 的------第一次调用工具时才建立连接、初始化握手;
enabled控制是否启用客户端,toolcallback.enabled控制是否把 MCP 工具自动暴露成ToolCallback。
注入 ToolCallbackProvider 即可使用:
java
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.chat.client.ChatClient;
@Bean
CommandLineRunner demo(ChatClient.Builder builder, ToolCallbackProvider mcpTools) {
return args -> {
String answer = builder.build().prompt()
.user("列出 /tmp 目录下有哪些文件")
.toolCallbacks(mcpTools) // 注入 MCP 工具
.call()
.content();
System.out.println(answer);
};
}
注意:接入多个 MCP server 时,工具可能和本地
@Tool重名、或互相重名。Spring AI 提供McpToolNamePrefixGenerator(给 MCP 工具统一加前缀)和McpToolFilter(按需过滤掉不想要的工具),多 server 接入时按需配置。
11.2 手动用法:McpSyncClient(读 resources / prompts)
自动转 ToolCallback 只覆盖 tools 能力;要读 resources (文件、数据库 schema)或 prompts (提示词模板),需要用 McpSyncClient(同步)或 McpAsyncClient(异步):
java
// import 来自 MCP SDK(Spring AI 封装,类名 McpClient / McpSyncClient / McpSchema / HttpClientStreamableHttpTransport)
// 1. 建一个 Streamable HTTP 传输,连到 MCP server
HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport
.builder("http://localhost:8080") // MCP server 地址
.build();
// 2. 建同步客户端并初始化握手
McpSyncClient client = McpClient.sync(transport)
.requestTimeout(Duration.ofSeconds(60))
.build();
client.initialize();
// 3. 列工具 / 调工具
McpSchema.ListToolsResult tools = client.listTools();
McpSchema.CallToolResult result = client.callTool(
McpSchema.CallToolRequest.builder("get-weather")
.arguments(Map.of("city", "西安"))
.build());
// 4. 读资源(自动转 ToolCallback 覆盖不到的能力)
McpSchema.ListResourcesResult resources = client.listResources();
McpSchema.ReadResourceResult resource = client.readResource(
McpSchema.ReadResourceRequest.builder("resource://uri").build());
// 5. 用完关闭
client.closeGracefully();
关键:①
McpClient.sync(transport)建同步客户端,异步用McpClient.async(...)返回McpAsyncClient;②listTools/listResources/listPrompts都支持分页(传cursor,循环到nextCursor为空);③ 大多数场景用 11.1 的自动ToolCallback就够了,只有要读 resources / prompts、或精细控制连接时才走到这一步。
11.3 服务端:暴露你自己的 MCP 工具
服务端 starter 有三种:spring-ai-starter-mcp-server(STDIO)、...-server-webmvc(SSE / Streamable HTTP)、...-server-webflux(响应式)。
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
用 @Tool 标注的方法即可被自动暴露为 MCP 工具(与第 7 章 Function Calling 复用同一套注解),配置服务端传输协议:
yaml
spring:
ai:
mcp:
server:
protocol: STREAMABLE # 服务端传输协议:SSE / STREAMABLE
生产安全:对外暴露 MCP server 时要做鉴权(网关层加 API Key / OAuth 等),MCP 协议本身不带认证,别把工具裸暴露到公网。
11.4 MCP 与 Function Calling 的关系
- Function Calling :你的应用内直接定义的
@Tool方法。 - MCP :通过标准协议接入的外部/第三方 工具,经
ToolCallbackProvider自动转成同样的ToolCallback,最终都走同一条工具调用链路。
第 12 章 可观测性与评测
12.1 可观测性
Spring AI 内置 Micrometer 观测埋点,接入 Actuator 与 OpenTelemetry 即可采集指标与链路:
xml
<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>
yaml
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 |
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-attempts |
10 | 最大重试次数 |
on-client-errors |
false | 为 false 时 4xx 不重试(视为不可重试) |
on-http-codes |
空 | 强制触发重试的状态码,如 429,502,503,504 |
exclude-on-http-codes |
空 | 明确不重试的状态码,如 401,403,404 |
backoff.initial-interval |
2s | 首次重试等待 |
backoff.multiplier |
5 | 指数退避倍数 |
backoff.max-interval |
3m | 退避上限 |
yaml
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 工具调用参数
工具调用的核心开关是 internalToolExecutionEnabled(内部工具执行模式):
| 参数 | 取值 | 说明 |
|---|---|---|
internalToolExecutionEnabled |
true(默认) | 自动模式:Spring AI 内部完成「调模型→执行工具→回传→再调模型」多轮循环,一次 .call() 搞定 |
internalToolExecutionEnabled |
false | 手动模式:由你控制多轮工具调用,适合复杂 Agent、条件分支、逐步调试 |
toolChoice |
auto / none / 指定 | 强制、禁止或指定工具选择 |
@Tool(description) |
--- | 直接决定模型选哪个工具,务必写清触发条件 |
java
import org.springframework.ai.openai.OpenAiChatOptions;
// 手动模式:自己编排多轮工具调用(复杂 Agent 场景)
OpenAiChatOptions options = OpenAiChatOptions.builder()
.internalToolExecutionEnabled(false)
.build();
要点:① 简单场景用默认自动模式即可;② 复杂多轮工具编排、需要自定义错误处理/条件分支时,切手动模式自己编排;③ 工具描述质量比参数更重要------描述不清,模型会选错工具或传错参。
13.4 RAG 向量库参数
RAG 效果对参数极敏感,重点在「分块」和「检索」:
| 参数 | 说明 | 调优方向 |
|---|---|---|
chunkSize |
分块 token 数 | 最关键:太大稀释语义、太小丢上下文 |
chunkOverlap |
块间重叠 token(默认 50) | 取 chunkSize 的 10%~20%,保跨块连贯 |
SearchRequest.topK |
检索返回条数 | 太大引入噪声、太小漏信息,常取 3~8 |
SearchRequest.similarityThreshold |
相似度阈值 | 过低召回无关内容,过高漏检,按数据实测 |
SearchRequest.filterExpression |
元数据过滤 | 按 source 等过滤,缩小检索范围 |
index-type |
HNSW / 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 |
空上下文是否作答 | 检索为空时避免幻觉可关闭 |
java
ChatMemory memory = MessageWindowChatMemory.builder()
.maxMessages(10) // 按「条数」粗粒度控制窗口
.build();
要点:① 记忆窗口按「条数」截断是粗粒度,需按 token 精确控制要自行实现计数截断;② Advisor 顺序错误会导致历史未注入或检索未生效;③ 长期记忆(
VectorStoreChatMemoryAdvisor)注意 prompt 注入风险。
第五部分 · 速查
第 14 章 关键 API 与供应商切换速查表
14.1 关键 API 速查
按「使用链路」分类,每个类标注干什么 + 关键方法怎么用。
① 聊天入口:ChatClient
java
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(ctx) // 注入 ToolContext
// ------ 执行 + 取结果(四选一)------
.call().content(); // 同步 → String
.call().entity(Person.class); // 同步 → 反序列化对象
.call().chatResponse(); // 同步 → 完整 ChatResponse(含 token 用量)
.stream().content(); // 流式 → Flux<String>
拿
String用.content();拿强类型对象用.entity();要 token 用量/元数据用.chatResponse()。
② 核心数据模型
java
// 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)
.toolCallMaxIterations(3) // 工具连环调用上限
.build();
③ 结构化输出
java
// 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
java
PromptTemplate tpl = new PromptTemplate("把这段翻译成 {lang}:{text}");
String rendered = tpl.render(Map.of("lang", "英文", "text", "你好")); // 渲染成 String
Prompt prompt = tpl.create(Map.of("lang", "英文", "text", "你好")); // 渲染成 Prompt 对象
⑤ 对话记忆
java
// 内存窗口记忆(最常用)
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 工具
java
// 定义工具:@Tool / @ToolParam 标注方法
@Tool(description = "查询天气")
public String getWeather(@ToolParam(description = "城市") String city) { ... }
// 注册:默认(所有请求)或按请求
builder.defaultTools(weatherTools);
client.prompt().user("......").tools(weatherTools).call().content();
// 服务端注入上下文(不进模型,防 prompt 注入)
ToolContext ctx = ToolContext.builder().with("tenantId", "t1").build();
client.prompt().user("......").toolContext(ctx).call().content();
// 限制连环调用次数(防死循环)
ChatOptions.builder().toolCallMaxIterations(3).build();
⑦ 向量库与 RAG
java
// 写入 / 检索 / 删除
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();
⑧ 多模态
java
// 视觉输入: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
java
// 配置 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 扩展机制
java
// 自定义 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-ollama |
spring.ai.ollama.* |
qwen2.5 / llama3.2 |
否 |
| OpenAI(云端) | spring-ai-starter-model-openai |
spring.ai.openai.* |
gpt-4o-mini / gpt-4o |
是 |
| Anthropic(云端) | spring-ai-starter-model-anthropic |
spring.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 关键配置
按主题汇总常用配置(按需取用,不需要的删掉即可):
yaml
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 各有独立前缀。切换供应商 = 换 starter + 改model.chat+ 填对应前缀,业务代码零改动。
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时,务必与所选嵌入模型维度一致。
结语
本文按「快速开始 → 核心能力 → 动手与找资料 → 进阶主题 → 速查」的顺序,覆盖了 Spring AI 1.1.2 的核心能力:从零搭建对话机器人、Advisors 扩展机制、提示词与结构化输出、对话记忆、Function Calling、向量数据库与 RAG,再到多模态、MCP 与可观测性。
学习建议 :先用本地 Ollama 把第 2 章跑通,再逐章叠加第 6(记忆)、7(工具)、8-9(RAG)章能力;进阶部分(10-13 章)可按需查阅。所有能力都收敛在 ChatClient 一个入口上,掌握它即可举一反三。