Spring AI 基础学习与应用

文章来源声明: 原文作者:伊信; 来源站点:掘金; 原文链接:https://juejin.cn/post/7686798164308230178; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

Advisor 洋葱模型与 ToolContext 权限隔离是全文最实用的两点,适合已上手 Spring AI、准备把工具调用和 RAG 接入生产项目的后端开发者精读。

``` # Spring AI 学习与应用

> 本文档以一个真实可运行的项目为主线,把 Spring AI 的三个核心应用场景讲清楚: > Advisor(增强器)Tool Calling(工具调用)RAG(知识库检索) >

环境:Spring Boot 4.1.1 + Spring AI 2.0.0 + DeepSeek(OpenAI 兼容接口) > 所有技术细节都在真实 jar 上核验过(javap 反编译 + 实际启动验证),不是凭印象写的。 > 凡是没有验证过的地方,文中会明确标注。


## 目录

- [一、项目总览](#一项目总览) - [二、骨架:ChatClient 与 Advisor 洋葱模型](#二骨架chatclient-与-advisor-洋葱模型) - [三、场景一:Advisor —— 日志增强器](#三场景一advisor--日志增强器) - [四、场景二:Tool Calling —— 对接业务系统](#四场景二tool-calling--对接业务系统) - [五、场景三:RAG —— 知识库检索](#五场景三rag--知识库检索) - [六、三个场景怎么选、怎么组合](#六三个场景怎么选怎么组合) - [七、配置与密钥管理](#七配置与密钥管理) - [八、2.0 的破坏性变更清单](#八20-的破坏性变更清单) - [九、环境与构建的坑](#九环境与构建的坑) - [十、排查手册](#十排查手册) - [十一、学习路径建议](#十一学习路径建议) - [十二、术语表](#十二术语表)


## 一、项目总览

### 1.1 技术栈

组件 版本 说明
Spring Boot 4.1.1 注意这是 Framework 7 时代,有破坏性变更
Spring AI 2.0.0 通过 spring-ai-bom 统一管版本
JDK 21 spring-boot-starter-parent 自动开 -parameters
对话模型 DeepSeek deepseek-chat 走 OpenAI 兼容接口
向量模型 硅基流动 BAAI/bge-m3 ⚠️ DeepSeek 没有 /embeddings 接口

## 二、骨架:ChatClient 与 Advisor 洋葱模型

这是理解后面三个场景的地基。 不理解 Advisor,Tool Calling 在 2.0 里就会看不懂。

### 2.1 分层结构

``` 你的代码 │ chatClient.prompt().user("…").call() ▼ ┌──────────────────────────────────────────┐ │ Advisor 链(洋葱) │ │ ┌────────────────────────────────────┐ │ │ │ MemoryAdvisor │ │ │ │ ┌──────────────────────────────┐ │ │ │ │ │ ToolCallingAdvisor │ │ │ │ │ │ ┌────────────────────────┐ │ │ │ │ │ │ │ 你的自定义 Advisor │ │ │ │ │ │ │ │ ┌──────────────────┐ │ │ │ │ │ │ │ │ │ ChatModel │ │ │ │ │ │ │ │ │ │ ← 真正的 HTTP │ │ │ │ │ │ │ │ │ └──────────────────┘ │ │ │ │ │ │ │ └────────────────────────┘ │ │ │ │ │ └──────────────────────────────┘ │ │ │ └────────────────────────────────────┘ │ └──────────────────────────────────────────┘


<span>**洋葱模型**</span>:每个 Advisor 都能在"进入"和"返回"两个时机插入逻辑。外层先进入、后返回。

<span>### 2.2 Advisor 的 API</span>

Spring AI 2.0 的 <span>`Advisor`</span> 接口非常小 —— 只继承 <span>`Ordered`</span>,只要求一个方法:

<span>```java
public interface Advisor extends Ordered {
    String getName();
}
```</span>

真正干活的是两个子接口:

| 接口 | 方法 | 用途 |
|---|---|---|
| <span>`CallAdvisor`</span> | <span>`adviseCall(ChatClientRequest, CallAdvisorChain)`</span> | 同步调用 |
| <span>`StreamAdvisor`</span> | <span>`adviseStream(ChatClientRequest, StreamAdvisorChain)`</span> | 流式调用 |
| <span>`BaseAdvisor`</span> | <span>`before()`</span> / <span>`after()`</span> | 把上面两个折叠成一对方法,<span>**推荐用这个**</span> |

想同时支持同步和流式,就实现 <span>`CallAdvisor`</span> + <span>`StreamAdvisor`</span>(或者直接继承 <span>`BaseAdvisor`</span> 省事)。本项目为了展示完整 API,两个都实现了。

链式调用全靠这一行往下传:

<span>```java
chatClientResponse = callAdvisorChain.nextCall(chatClientRequest);   // 同步
Flux<...> responses = streamAdvisorChain.nextStream(chatClientRequest); // 流式
```</span>

<span>`chain.copy(this)`</span> 用于复制链(比如要在子链里换掉某个 Advisor)。

<span>### 2.3 顺序规则:order 越小越靠外</span>

这是最容易记反的地方。规则是:

<span>> <span>**`OrderComparator.sort()` 升序排列 → order 值越小,越在洋葱的【外层】**</span></span>

Spring AI 内置 Advisor 的 order 值:

| 常量 | 值 | 含义 |
|---|---|---|
| <span>`Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER`</span> | <span>`Ordered.HIGHEST_PRECEDENCE + 200`</span> | 记忆 Advisor |
| <span>`ToolCallingAdvisor.DEFAULT_ORDER`</span> | <span>`Ordered.HIGHEST_PRECEDENCE + 300`</span> | 工具调用 Advisor |

注意 <span>`Ordered.HIGHEST_PRECEDENCE`</span> = <span>`Integer.MIN_VALUE`</span>(是个很大的负数),所以这两个值<span>**都远小于 0**</span>。

<span>#### 实测验证过的顺序</span>

本项目实际跑出来的链条(用一个专门写的 <span>`Rec`</span> 探针 Advisor 链验证):

<span>```
MemoryAdvisor(order = MIN+200)
  └─▶ ToolCallingAdvisor(order = MIN+300)
        └─▶ AILogAdvisor(order = 0)
              └─▶ ★ ChatModel HTTP 调用
```</span>

<span>#### 由此推出的实用结论</span>

| 你想要的 | order 该设成 |
|---|---|
| 只看"一问一答"整体(一次逻辑调用) | <span>**小于**</span> <span>`Integer.MIN_VALUE + 300`</span>(如 <span>`Integer.MIN_VALUE + 100`</span>) |
| 看到<span>**每一轮**</span>工具调用往返 | <span>**大于**</span> <span>`Integer.MIN_VALUE + 300`</span>(<span>`0`</span> 或普通的负数都行) |

⚠️ <span>**普通的负数(比如 `-1`)是不够的。**</span> 因为 <span>`MIN+300`</span> 是个约 -21 亿的数,<span>`-1`</span> 比它大,仍然在工具循环<span>**里面**</span>。想跑到工具循环外面,必须比 <span>`MIN+300`</span> 还小。

本项目 <span>`AILogAdvisor`</span> 默认 <span>`order = 0`</span>,位置在最内层 —— 所以它能把<span>**每一次**</span>模型请求(包括工具调用的中间轮次)都打出来。这个特性在调试 Tool Calling 时非常有用。

<span>### 2.4 请求/响应对象是不可变的</span>

<span>`ChatClientRequest`</span> 和 <span>`ChatClientResponse`</span> 都是 <span>**Java Record**</span>,不可变。想改内容不能直接 set,要用:

<span>```java
chatClientRequest.mutate().prompt(newPrompt).build();   // 基于原对象造一个新的
chatClientRequest.copy();                               // 浅拷贝
```</span>

<span>**为什么这么设计**</span>:Advisor 链可能并发执行,不可变对象避免了互相踩踏。

<span>### 2.5 defaultAdvisors 是累加,不是覆盖</span>

源码里 <span>`advisors(...)`</span> 的实现是 <span>`List.addAll(...)`</span>(反编译确认过)。所以:

<span>```java
builder.defaultAdvisors(new AILogAdvisor())              // 加一个
       .defaultAdvisors(new QuestionAnswerAdvisor(...))  // 再加一个,不会顶掉上一个
       .build();
```</span>

两个都在。<span>**不会静默丢失。**</span>

---

<span>## 三、场景一:Advisor —— 日志增强器</span>

<span>### 3.1 需求</span>

每次调用大模型时,把完整的请求和响应打到日志里,方便调试。要求同步和流式都支持,并且能统计耗时。

<span>### 3.2 实现要点</span>

<span>`spring/ai/AILogAdvisor.java`</span>,实现 <span>`CallAdvisor`</span> + <span>`StreamAdvisor`</span>:

<span>```java
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
    long startNanos = System.nanoTime();
    logRequest("call", request);
    ChatClientResponse response = chain.nextCall(request);   // ← 往下传
    logResponse("call", response, startNanos);
    return response;
}

@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain) {
    long startNanos = System.nanoTime();
    logRequest("stream", request);
    Flux<ChatClientResponse> responses = chain.nextStream(request);
    return new ChatClientMessageAggregator()
            .aggregateChatClientResponse(responses, r -> logResponse("stream", r, startNanos));
}
```</span>

<span>### 3.3 流式的关键:`ChatClientMessageAggregator`</span>

<span>**流式场景下,响应不是一个对象,而是一串碎片(Flux)。**</span> 每个碎片只有几个字,没法直接打印"完整答案"。

<span>`ChatClientMessageAggregator`</span> 的作用就是<span>**把碎片攒起来**</span>,等流结束时给你一个聚合后的完整响应:

<span>```java
new ChatClientMessageAggregator().aggregateChatClientResponse(responses, consumer);
```</span>

它<span>**返回一个新的 Flux**</span>(不是消费掉原来的),所以对流式本身没有任何影响 —— 用户该多快收到还是多快收到,你只是在旁边"旁听"。

<span>### 3.4 怎么注册</span>

两种方式:

<span>```java
// 方式一:全局注册(本项目 ChatController 用的)
ChatClient client = builder.defaultAdvisors(new AILogAdvisor()).build();
// 这个 client 的每个接口都会经过它

// 方式二:单次调用
client.prompt().user("…").advisors(new AILogAdvisor()).call();
```</span>

<span>`ChatClient.Builder`</span> 是 <span>**prototype 作用域**</span>,所以 <span>`builder.clone()`</span> 可以造出互不影响的 client —— 本项目 RAG 那个 Controller 就 clone 了两个(一个带 RAG Advisor,一个不带)。

<span>### 3.5 日志里能看到什么</span>

请求侧遍历 <span>`request.prompt().getInstructions()`</span>,打印 <span>`getMessageType()`</span>(SYSTEM / USER / ASSISTANT / TOOL)和 <span>`getText()`</span>。

响应侧读 <span>`chatResponse.getResult().getOutput().getText()`</span> 和 <span>`getMetadata().getUsage()`</span> 拿 token 统计。<span>**注意 `getResults()` 可能为空**</span>(比如出错时),要判空。

---

<span>## 四、场景二:Tool Calling —— 对接业务系统</span>

<span>**这是三个场景里最复杂、也最有价值的一个。**</span> 它让模型能真正"做事",而不只是"聊天"。

<span>### 4.1 2.0 的重大变化:工具执行搬了家</span>

| | 1.x | <span>**2.0**</span> |
|---|---|---|
| 工具执行在哪 | <span>`ChatModel`</span> 内部 | <span>**`ToolCallingAdvisor`**</span> |
| 控制开关 | <span>`internalToolExecutionEnabled`</span> | <span>**已移除**</span>,改为在 Advisor 层控制 |
| 注册方式 | 手动注册 | <span>**自动注册**</span> |

<span>`DefaultChatClient`</span> 会自动注册 <span>`ToolCallingAdvisor`</span>(方法名 <span>`autoRegisterToolCallingAdvisor()`</span>),<span>**除非你自己已经放了一个 `ToolAdvisor`**</span>。而且<span>**最多只允许一个 `ToolAdvisor`**</span>。

<span>**为什么这个变化重要**</span>:工具执行不再是模型层的黑魔法,而是一个可插拔的 Advisor。你可以在它外面套自己的逻辑(日志、审计、限流),也可以在它里面套(看每一次模型往返)。

<span>### 4.2 完整调用时序</span>

用户问:"我的订单 SO20260916002 现在什么状态?"

<span>```
① ChatClient.prompt().user("…").tools(orderTools).toolContext(…).call()
        │
        ▼
② ToolCallingAdvisor 进入
        │
        ▼
③ 第 1 轮 → ChatModel HTTP
        ← 模型返回的不是文字,而是"我要调用 list_my_orders"
        │
        ▼
④ ToolCallingAdvisor 看到 tool_calls,【真的去执行 Java 方法】
        │  OrderTools.listMyOrders(...) 被反射调用
        │  返回的 Order 对象序列化成 JSON 文本
        ▼
⑤ 第 2 轮 → ChatModel HTTP(把工具结果一起带上)
        ← 模型基于工具结果生成最终自然语言答案
        ▼
⑥ 返回给用户
```</span>

<span>**关键认知:模型自己从来不执行任何代码。**</span> 它只是"输出一段结构化的调用意图"(函数名 + 参数),真正执行的是 Spring AI 框架。<span>**这个循环可能跑多轮**</span> —— 模型可以连续调多个工具。

<span>### 4.3 怎么定义工具</span>

用注解,极其简单:

<span>```java
@Component
public class OrderTools {

    public static final String USER_ID = "userId";

    @Tool(name = "query_order", description = "根据订单号查询订单详情。当用户询问某个具体订单的状态、金额、商品时使用。")
    public Order queryOrder(
            @ToolParam(description = "订单号,格式形如 SO20260916001") String orderNo) {
        return orderService.getByNo(orderNo);
    }

    @Tool(name = "list_my_orders", description = "查询当前用户的订单列表。当用户想知道'我有哪些订单'时使用。")
    public List<Order> listMyOrders(
            @ToolParam(description = "订单状态,可选:PAID/SHIPPED/COMPLETED/CANCELLED", required = false) String status,
            ToolContext toolContext) {
        String userId = (String) toolContext.getContext().get(USER_ID);
        return orderService.listByUser(userId, status);
    }
}
```</span>

<span>**三点经验:**</span>

<span>1.</span> <span>**`description` 是写给模型看的 prompt,要下功夫。**</span> 模型靠它决定"什么时候用这个工具"。写清楚<span>**使用场景**</span>("当用户询问……时使用")比写清楚功能更重要。
<span>2.</span> <span>**参数描述要给出格式示例**</span>("格式形如 SO20260916001"),模型才能填对。不写的话它可能传"我的第一个订单"这种自然语言。
<span>3.</span> <span>**`required = false` 标记可选参数**</span>,生成 JSON Schema 时会正确反映。

<span>### 4.4 ToolContext:业务安全的命门</span>

<span>**先看这个反面教材**</span>:如果用户 ID 也做成 <span>`@ToolParam`</span>,会怎么样?

<span>```java
// ❌ 千万不要这样写
@Tool(description = "查询订单")
public Order queryOrder(String orderNo, String userId) { ... }
```</span>

后果:<span>**模型可以传任意 userId**</span>。用户只要说一句"帮我查一下用户 U1002 的订单",模型就可能乖乖传 <span>`U1002`</span> 进去 —— <span>**越权访问**</span>。

<span>**正确做法:敏感身份从 `ToolContext` 取,不经过模型。**</span>

<span>```java
// ✅ 正确
public List<Order> listMyOrders(String status, ToolContext toolContext) {
    String userId = (String) toolContext.getContext().get(USER_ID);
    // userId 来自服务端会话,模型既看不到也改不了
}
```</span>

调用时由你在服务端注入:

<span>```java
chatClient.prompt()
    .user(question)
    .tools(orderTools)
    .toolContext(Map.of(OrderTools.USER_ID, userId))   // ← 从登录态拿
    .call();
```</span>

<span>**这个模式要记住:凡是"模型不该有决定权"的参数(用户 ID、租户 ID、权限级别),一律走 `ToolContext`。**</span>

⚠️ 一个坑:如果方法签名里声明了 <span>`ToolContext`</span>,那调用时<span>**必须传一个非空的 ToolContext**</span>,否则抛:

<span>```
IllegalArgumentException: ToolContext is required by the method as an argument
```</span>

<span>### 4.5 异常处理:报错会原样喂给模型</span>

<span>`DefaultToolExecutionExceptionProcessor`</span> 的默认行为是 <span>`alwaysThrow = false`</span>:

<span>> <span>**工具抛异常时,不往上抛,而是把异常信息当成工具的"返回结果"喂回给模型。**</span></span>

本项目实测,业务异常抛出后,模型收到的是这样的文本(中文原样传递):

| 抛出的异常 | 模型看到的文本 |
|---|---|
| <span>`IllegalStateException("无权操作该订单:SO20260916004")`</span> | <span>`[无权操作该订单:SO20260916004]`</span> |
| <span>`IllegalStateException("订单 SO20260916001 当前状态为「已发货」,不可取消")`</span> | <span>`[订单 SO20260916001 当前状态为「已发货」,不可取消]`</span> |
| <span>`IllegalStateException("订单不存在:SO99999999999")`</span> | <span>`[订单不存在:SO99999999999]`</span> |

<span>**这个设计很好**</span>:模型能读懂错误信息,然后用人话告诉用户"这个订单已经发货了,不能取消",而不是甩一个 500 错误。

<span>**但它意味着:异常信息会泄露给模型(进而可能泄露给用户)。**</span> 所以:

<span>-</span> ✅ 好的错误信息:<span>`无权操作该订单`</span>、<span>`当前状态不可取消`</span>
<span>-</span> ❌ 危险的错误信息:<span>`SQL: SELECT * FROM orders WHERE ... 表结构:...`</span>、堆栈、内部主机名

<span>**写工具方法时,异常信息要当作"给用户看的文案"来写。**</span>

<span>### 4.6 一个容易被忽略的开关</span>

工具能不能生效,有个隐藏条件:

<span>> prompt 的 `ChatOptions` 必须是 `ToolCallingChatOptions` 的实例。</span>

正常情况下 <span>`DefaultChatClientUtils.toChatClientRequest`</span> 会自动处理(当 <span>`chatModel.getOptions().mutate()`</span> 返回的是 <span>`ToolCallingChatOptions.Builder`</span> 时,工具会被合并进去)。

<span>**但如果你自定义了 `ChatOptions` 并且没实现 `ToolCallingChatOptions`,工具会静默失效**</span> —— 不报错,就是不调用。这是个很难查的坑。

<span>### 4.7 `-parameters` 编译参数(否则工具直接废掉)</span>

JSON Schema 里的参数名来自 Java 的<span>**参数名反射**</span>,而参数名默认不保留在 class 文件里。

<span>**没开 `-parameters` 的后果**</span>:Schema 里的参数名变成 <span>`arg0`</span>、<span>`arg1`</span>,模型看到的工具定义是"需要一个叫 arg0 的参数",于是它填的 <span>`{"orderNo": "..."}`</span> 匹配不上,<span>`orderNo`</span> 变成 <span>`null`</span>,然后 <span>`ConcurrentHashMap.get(null)`</span> 直接抛:

<span>```
NullPointerException: Cannot invoke "Object.hashCode()" because "key" is null
```</span>

<span>**好消息**</span>:<span>`spring-boot-starter-parent`</span> 已经自动配了这个参数(编译日志里能看到 <span>`javac [debug parameters release 21]`</span>),IDEA 新建项目也会带。<span>**但如果你自己写 `javac` 命令编译,一定要手加 `-parameters`。**</span>

<span>### 4.8 工具是怎么被"发现"的</span>

底层机制(本项目验证过):

<span>```java
MethodToolCallbackProvider.builder().toolObjects(orderTools).build();
// 等价于
ToolCallbacks.from(pojos);
```</span>

Schema 生成用 <span>**victools jsonschema-generator**</span> 库。注册方式有两种:

<span>```java
.tools(orderTools)                  // 传对象,自动扫 @Tool 方法(推荐)
.tools(ToolCallbacks.from(...))     // 传 ToolCallback 列表
```</span>

<span>### 4.9 业务侧的实现</span>

本项目模拟了一套订单系统(<span>`OrderService`</span> + <span>`Order`</span> record),内存 <span>`ConcurrentHashMap`</span> 当数据库,内置 4 条订单。业务规则故意设了会失败的场景,用来验证异常回灌:

| 规则 | 触发条件 |
|---|---|
| 越权拦截 | 操作用户不拥有的订单 → <span>`无权操作该订单`</span> |
| 状态校验 | 非 PAID 状态不可取消 → <span>`当前状态为「已发货」,不可取消`</span> |
| 不存在 | 订单号查不到 → <span>`订单不存在:xxx`</span> |

---

<span>## 五、场景三:RAG —— 知识库检索</span>

RAG 的完整原理、切分机制、调参、调试方法在<span>**单独一篇文档**</span>里:
👉 <span>**`docs/RAG学习与应用案例.md`**</span>

这里只放最精炼的版本。

<span>### 5.1 一句话</span>

<span>> <span>**RAG = 让大模型开卷考试。**</span> 从你的资料库里翻出最相关的几页,和问题一起塞进 prompt,让它照着答。<span>**全程没有任何模型训练。**</span></span>

<span>### 5.2 两个阶段(最容易混的地方)</span>

| | 阶段一 索引 | 阶段二 检索 |
|---|---|---|
| 何时做 | 离线,文档变化时 | 在线,每次提问 |
| 做什么 | 读 → 切 → 向量化 → 入库 | 向量化 → 相似度 → 拼接 → 生成 |
| 本项目代码 | <span>`KnowledgeBaseService`</span> | <span>`RagChatController`</span> |
| 成本 | 慢、贵、<span>**只做一次**</span> | 快、便宜、<span>**每次都做**</span> |

<span>### 5.3 三个必须记住的坑</span>

<span>1.</span> <span>**prompt 里必须写"查不到就说查不到"**</span> —— 否则检索不到时模型会自信地编造答案(幻觉)
<span>2.</span> <span>**换 embedding 模型必须重建整个索引**</span> —— 向量空间不通用,这是上线后的隐性成本
<span>3.</span> <span>**`similarityThreshold` 必须用真实模型实测校准**</span> —— 没有万能值

<span>### 5.4 本项目特有注意点</span>

<span>-</span> <span>**DeepSeek 没有 `/embeddings` 接口**</span>(返回 404)→ 向量化另用硅基流动 <span>`BAAI/bge-m3`</span>(1024 维)
<span>-</span> <span>`EmbeddingModel`</span> 是自己定义的 Bean,Spring AI 的 <span>`OpenAiEmbeddingAutoConfiguration`</span> 是 <span>`@ConditionalOnMissingBean`</span>,所以会顶掉默认的 → <span>**生成走 DeepSeek、向量化走硅基流动,两套配置互不干扰**</span>

---

<span>## 六、三个场景怎么选、怎么组合</span>

<span>### 6.1 选择标准</span>

| 你想让模型…… | 用什么 | 本项目 |
|---|---|---|
| <span>**做**</span>某件事(查数据库、下单、发消息) | <span>**Tool Calling**</span> | <span>`/tool/*`</span> |
| <span>**查**</span>某份资料(政策、手册、论文) | <span>**RAG**</span> | <span>`/rag/*`</span> |
| 每次对话都被<span>**观察/增强**</span>(日志、鉴权、记忆、改 prompt) | <span>**Advisor**</span> | <span>`AILogAdvisor`</span> |

<span>**一句话记忆:Tool Calling 是让模型"做"事,RAG 是让模型"查"资料,Advisor 是包裹在前两者外面的"切面"。**</span>

<span>### 6.2 关键区别:Tool Calling vs RAG</span>

| | Tool Calling | RAG |
|---|---|---|
| 数据形态 | 结构化(对象、数据库行) | 非结构化(文档、文本) |
| 查询方式 | 精确(按订单号查) | 模糊(按语义相似找) |
| 谁决定用什么 | <span>**模型自己决定**</span>调哪个工具、传什么参数 | 固定流程,每次必检索 |
| 结果 | 实时、准确 | 可能有噪声、取决于检索质量 |
| 适合 | "这个订单能退吗" | "退货政策怎么规定的" |

<span>### 6.3 组合使用(真实场景常见)</span>

<span>```
用户:"我上周买的那个耳机能退吗?"
   │
   ├─▶ RAG:检索退换货政策 → "七天无理由,非质量问题运费自理"
   │
   ├─▶ Tool Calling:查这个用户上周的耳机订单 → 状态 SHIPPED,签收 3 天
   │
   └─▶ 综合:能退,还在 7 天内,运费需要自理
```</span>

<span>**组合的两种方式:**</span>

<span>1.</span> <span>**RAG 做成 Advisor,Tool 用 `.tools()`**</span> —— 两者在同一个 ChatClient 上叠加,互不冲突(RAG Advisor 负责检索注入,ToolCallingAdvisor 负责工具循环)
<span>2.</span> <span>**把检索也做成一个 Tool**</span> —— 让模型自己决定"要不要查资料"。Spring AI 2.0 里有个 <span>`spring-ai-tool-search-tool-vectorstore`</span> 就是干这个的

---

<span>## 七、配置与密钥管理</span>

<span>### 7.1 本项目方案:`.env` + `spring.config.import`</span>

<span>**key 不写进 yml**</span>,放在项目根的 <span>`.env`</span>(已加进 <span>`.gitignore`</span>):

<span>```
DEEPSEEK_API_KEY=sk-xxx
EMBEDDING_API_KEY=sk-yyy
```</span>

<span>`application.yml`</span> 里引用:

<span>```yaml
spring:
  config:
    import: optional:file:.env[.properties]   # optional: 文件不存在也不报错
  ai:
    openai:
      api-key: ${DEEPSEEK_API_KEY}
      base-url: https://api.deepseek.com/v1
      chat:
        options:
          model: deepseek-chat
          temperature: 0.7
rag:
  embedding:
    base-url: https://api.siliconflow.cn/v1
    api-key: ${EMBEDDING_API_KEY:}
    model: BAAI/bge-m3
  top-k: 5
  similarity-threshold: 0.4
```</span>

<span>**优点**</span>:key 不进 yml、不用配 IDE 环境变量、换机器只改一个文件。
<span>**注意**</span>:<span>`.env`</span> 靠<span>**工作目录**</span>定位。从 IDEA 或 <span>`./mvnw spring-boot:run`</span> 跑时 cwd 就是项目根,没问题;换成 <span>`java -jar`</span> 从别的目录启动就要留神。


<span>## 八、2.0 的破坏性变更清单</span>

从 1.x 升级踩到的坑,都是实际验证过的,不是从 release note 抄的:

| 变更 | 1.x | 2.0 | 影响 |
|---|---|---|---|
| <span>**工具执行位置**</span> | <span>`ChatModel`</span> 内部 | <span>`ToolCallingAdvisor`</span> | 想控制工具执行要改思路 |
| <span>**工具执行开关**</span> | <span>`internalToolExecutionEnabled`</span> | <span>**移除**</span> | 编译直接失败 |
| <span>**`OpenAiApi` 类**</span> | 存在,可自定义 | <span>**移除**</span>,改用官方 OpenAI Java SDK | 自定义 HTTP 客户端的代码全废 |
| <span>**向量库 Advisor 包名**</span> | <span>`spring-ai-advisors-vector-store`</span> | <span>`spring-ai-vector-store-advisor`</span> | 依赖名变了 |
| <span>**`spring-jcl`**</span> | Spring 自带 | <span>**移除**</span>,改用 <span>`commons-logging`</span> | 日志依赖要确认 |

<span>**最后一条补充**</span>:Spring Framework 7 移除了 <span>`spring-jcl`</span>,但 <span>`spring-core:7.0.9`</span> 依赖 <span>`commons-logging:commons-logging`</span>,所以 <span>`org.apache.commons.logging.Log/LogFactory`</span> <span>**仍然可用**</span>。本项目所有 Advisor 都用它打日志,验证过。

---

<span>## 十一、学习路径建议</span>

如果是第一次接触 Spring AI,建议按这个顺序,每一步都跑通再往下:

| 阶段 | 学什么 | 本项目对应 |
|---|---|---|
| <span>**1. 先跑起来**</span> | <span>`ChatClient`</span> 基础对话、system prompt、流式 | <span>`ChatController`</span> |
| <span>**2. 理解骨架**</span> ★ | <span>**Advisor 洋葱模型、order 规则**</span> | <span>`AILogAdvisor`</span> |
| <span>**3. 让模型做事**</span> ★ | <span>`@Tool`</span>、<span>`ToolContext`</span>、异常回灌 | <span>`OrderTools`</span> |
| <span>**4. 让模型查资料**</span> | 切分、embedding、向量检索、阈值 | <span>`RagChatController`</span> |
| <span>**5. 组合**</span> | RAG + Tool + 多个 Advisor 叠加 | 待扩展 |

<span>**为什么第 2 步要排在第 3、4 步前面?**</span> 因为 2.0 里工具执行本身就是个 Advisor,RAG 的 <span>`QuestionAnswerAdvisor`</span> 也是 Advisor。<span>**不理解洋葱模型,后面两个场景都是背 API,出问题不会查。**</span>

<span>### 验证纪律(比结论更重要)</span>

本项目所有结论的获取方式,值得借鉴:

<span>-</span> <span>**不要相信记忆里的 API**</span> —— 2.0 相对 1.x 改了一大堆,凭印象写必然编译失败
<span>-</span> <span>**反编译真实 jar 确认签名**</span>:<span>`javap -p -c -cp <jars> <类名>`</span>,还能看到默认值(本项目的中文标点坑就是这么挖出来的)
<span>-</span> <span>**写最小验证程序**</span>:不确定 Advisor 顺序?写个 <span>`Rec`</span> 探针链跑一遍打印出来,比读文档可靠
<span>-</span> <span>**区分"验证过"和"推断"**</span>:本项目<span>**从未真正调用过真实的大模型/embedding HTTP 接口**</span>(没有可用 key),所以"HTTP 请求长什么样"是文档推断,而"参数绑定、Advisor 顺序、异常文本、切分结果"都是实测

---

<span>## 十二、术语表</span>

| 术语 | 英文 | 通俗解释 |
|---|---|---|
| <span>**Advisor**</span> | — | 增强器。包裹在模型调用外面的"切面",能在请求前后插逻辑。洋葱模型 |
| <span>**洋葱模型**</span> | Onion Model | 一层套一层,外层先进后出。中间件/拦截器都是这个套路 |
| <span>**order**</span> | — | Advisor 的顺序值。<span>**越小越靠外**</span> |
| <span>**Tool Calling**</span> | 工具调用 | 让模型输出"我要调用某函数+参数",由框架执行,结果回灌给模型 |
| <span>**`@Tool`**</span> | — | 标记一个 Java 方法可以被模型调用 |
| <span>**`ToolContext`**</span> | — | 工具调用的上下文,用来传"模型不该决定"的参数(如当前用户 ID) |
| <span>**`ChatClient`**</span> | — | Spring AI 的对话入口,fluent API |
| <span>**`ChatModel`**</span> | — | 底层模型抽象,一个实现对应一个服务商 |
| <span>**SSE**</span> | Server-Sent Events | 服务端流式推送,配合 Flux 实现打字机效果 |
| <span>**`Flux`**</span> | — | Reactor 的流式类型,可以理解成"异步的 List" |
| <span>**RAG**</span> | Retrieval-Augmented Generation | 检索增强生成,让模型开卷考试 |
| <span>**Embedding**</span> | — | 把文字变成一串数字(向量),数字代表"意思的坐标" |
| <span>**Chunk**</span> | 碎片 | 文档切分后的一小段,向量化的基本单位 |
| <span>**Token**</span> | — | 分词器切出的最小单位。中文大约 1 个字 = 1 个 token |
| <span>**余弦相似度**</span> | Cosine Similarity | 两个向量夹角的余弦值,1 = 意思一样,0 = 无关 |
| <span>**幻觉**</span> | Hallucination | 模型编造不存在的信息,且答得很自信 |
| <span>**BOM**</span> | Bill of Materials | 只声明版本号的 pom,统一管理一堆依赖的版本 |