# 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 │ │ │ │ │
│ │ │ │ └──────────────────┘ │ │ │ │
│ │ │ └────────────────────────┘ │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
```
**洋葱模型**:每个 Advisor 都能在"进入"和"返回"两个时机插入逻辑。外层先进入、后返回。
### 2.2 Advisor 的 API
Spring AI 2.0 的 `Advisor` 接口非常小 —— 只继承 `Ordered`,只要求一个方法:
```java
public interface Advisor extends Ordered {
String getName();
}
```
真正干活的是两个子接口:
| 接口 | 方法 | 用途 |
|---|---|---|
| `CallAdvisor` | `adviseCall(ChatClientRequest, CallAdvisorChain)` | 同步调用 |
| `StreamAdvisor` | `adviseStream(ChatClientRequest, StreamAdvisorChain)` | 流式调用 |
| `BaseAdvisor` | `before()` / `after()` | 把上面两个折叠成一对方法,**推荐用这个** |
想同时支持同步和流式,就实现 `CallAdvisor` + `StreamAdvisor`(或者直接继承 `BaseAdvisor` 省事)。本项目为了展示完整 API,两个都实现了。
链式调用全靠这一行往下传:
```java
chatClientResponse = callAdvisorChain.nextCall(chatClientRequest); // 同步
Flux<...> responses = streamAdvisorChain.nextStream(chatClientRequest); // 流式
```
`chain.copy(this)` 用于复制链(比如要在子链里换掉某个 Advisor)。
### 2.3 顺序规则:order 越小越靠外
这是最容易记反的地方。规则是:
> **`OrderComparator.sort()` 升序排列 → order 值越小,越在洋葱的【外层】**
Spring AI 内置 Advisor 的 order 值:
| 常量 | 值 | 含义 |
|---|---|---|
| `Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER` | `Ordered.HIGHEST_PRECEDENCE + 200` | 记忆 Advisor |
| `ToolCallingAdvisor.DEFAULT_ORDER` | `Ordered.HIGHEST_PRECEDENCE + 300` | 工具调用 Advisor |
注意 `Ordered.HIGHEST_PRECEDENCE` = `Integer.MIN_VALUE`(是个很大的负数),所以这两个值**都远小于 0**。
#### 实测验证过的顺序
本项目实际跑出来的链条(用一个专门写的 `Rec` 探针 Advisor 链验证):
```
MemoryAdvisor(order = MIN+200)
└─▶ ToolCallingAdvisor(order = MIN+300)
└─▶ AILogAdvisor(order = 0)
└─▶ ★ ChatModel HTTP 调用
```
#### 由此推出的实用结论
| 你想要的 | order 该设成 |
|---|---|
| 只看"一问一答"整体(一次逻辑调用) | **小于** `Integer.MIN_VALUE + 300`(如 `Integer.MIN_VALUE + 100`) |
| 看到**每一轮**工具调用往返 | **大于** `Integer.MIN_VALUE + 300`(`0` 或普通的负数都行) |
⚠️ **普通的负数(比如 `-1`)是不够的。** 因为 `MIN+300` 是个约 -21 亿的数,`-1` 比它大,仍然在工具循环**里面**。想跑到工具循环外面,必须比 `MIN+300` 还小。
本项目 `AILogAdvisor` 默认 `order = 0`,位置在最内层 —— 所以它能把**每一次**模型请求(包括工具调用的中间轮次)都打出来。这个特性在调试 Tool Calling 时非常有用。
### 2.4 请求/响应对象是不可变的
`ChatClientRequest` 和 `ChatClientResponse` 都是 **Java Record**,不可变。想改内容不能直接 set,要用:
```java
chatClientRequest.mutate().prompt(newPrompt).build(); // 基于原对象造一个新的
chatClientRequest.copy(); // 浅拷贝
```
**为什么这么设计**:Advisor 链可能并发执行,不可变对象避免了互相踩踏。
### 2.5 defaultAdvisors 是累加,不是覆盖
源码里 `advisors(...)` 的实现是 `List.addAll(...)`(反编译确认过)。所以:
```java
builder.defaultAdvisors(new AILogAdvisor()) // 加一个
.defaultAdvisors(new QuestionAnswerAdvisor(...)) // 再加一个,不会顶掉上一个
.build();
```
两个都在。**不会静默丢失。**
---
## 三、场景一:Advisor —— 日志增强器
### 3.1 需求
每次调用大模型时,把完整的请求和响应打到日志里,方便调试。要求同步和流式都支持,并且能统计耗时。
### 3.2 实现要点
`spring/ai/AILogAdvisor.java`,实现 `CallAdvisor` + `StreamAdvisor`:
```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));
}
```
### 3.3 流式的关键:`ChatClientMessageAggregator`
**流式场景下,响应不是一个对象,而是一串碎片(Flux)。** 每个碎片只有几个字,没法直接打印"完整答案"。
`ChatClientMessageAggregator` 的作用就是**把碎片攒起来**,等流结束时给你一个聚合后的完整响应:
```java
new ChatClientMessageAggregator().aggregateChatClientResponse(responses, consumer);
```
它**返回一个新的 Flux**(不是消费掉原来的),所以对流式本身没有任何影响 —— 用户该多快收到还是多快收到,你只是在旁边"旁听"。
### 3.4 怎么注册
两种方式:
```java
// 方式一:全局注册(本项目 ChatController 用的)
ChatClient client = builder.defaultAdvisors(new AILogAdvisor()).build();
// 这个 client 的每个接口都会经过它
// 方式二:单次调用
client.prompt().user("…").advisors(new AILogAdvisor()).call();
```
`ChatClient.Builder` 是 **prototype 作用域**,所以 `builder.clone()` 可以造出互不影响的 client —— 本项目 RAG 那个 Controller 就 clone 了两个(一个带 RAG Advisor,一个不带)。
### 3.5 日志里能看到什么
请求侧遍历 `request.prompt().getInstructions()`,打印 `getMessageType()`(SYSTEM / USER / ASSISTANT / TOOL)和 `getText()`。
响应侧读 `chatResponse.getResult().getOutput().getText()` 和 `getMetadata().getUsage()` 拿 token 统计。**注意 `getResults()` 可能为空**(比如出错时),要判空。
---
## 四、场景二:Tool Calling —— 对接业务系统
**这是三个场景里最复杂、也最有价值的一个。** 它让模型能真正"做事",而不只是"聊天"。
### 4.1 2.0 的重大变化:工具执行搬了家
| | 1.x | **2.0** |
|---|---|---|
| 工具执行在哪 | `ChatModel` 内部 | **`ToolCallingAdvisor`** |
| 控制开关 | `internalToolExecutionEnabled` | **已移除**,改为在 Advisor 层控制 |
| 注册方式 | 手动注册 | **自动注册** |
`DefaultChatClient` 会自动注册 `ToolCallingAdvisor`(方法名 `autoRegisterToolCallingAdvisor()`),**除非你自己已经放了一个 `ToolAdvisor`**。而且**最多只允许一个 `ToolAdvisor`**。
**为什么这个变化重要**:工具执行不再是模型层的黑魔法,而是一个可插拔的 Advisor。你可以在它外面套自己的逻辑(日志、审计、限流),也可以在它里面套(看每一次模型往返)。
### 4.2 完整调用时序
用户问:"我的订单 SO20260916002 现在什么状态?"
```
① 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(把工具结果一起带上)
← 模型基于工具结果生成最终自然语言答案
▼
⑥ 返回给用户
```
**关键认知:模型自己从来不执行任何代码。** 它只是"输出一段结构化的调用意图"(函数名 + 参数),真正执行的是 Spring AI 框架。**这个循环可能跑多轮** —— 模型可以连续调多个工具。
### 4.3 怎么定义工具
用注解,极其简单:
```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);
}
}
```
**三点经验:**
1. **`description` 是写给模型看的 prompt,要下功夫。** 模型靠它决定"什么时候用这个工具"。写清楚**使用场景**("当用户询问……时使用")比写清楚功能更重要。
2. **参数描述要给出格式示例**("格式形如 SO20260916001"),模型才能填对。不写的话它可能传"我的第一个订单"这种自然语言。
3. **`required = false` 标记可选参数**,生成 JSON Schema 时会正确反映。
### 4.4 ToolContext:业务安全的命门
**先看这个反面教材**:如果用户 ID 也做成 `@ToolParam`,会怎么样?
```java
// ❌ 千万不要这样写
@Tool(description = "查询订单")
public Order queryOrder(String orderNo, String userId) { ... }
```
后果:**模型可以传任意 userId**。用户只要说一句"帮我查一下用户 U1002 的订单",模型就可能乖乖传 `U1002` 进去 —— **越权访问**。
**正确做法:敏感身份从 `ToolContext` 取,不经过模型。**
```java
// ✅ 正确
public List<Order> listMyOrders(String status, ToolContext toolContext) {
String userId = (String) toolContext.getContext().get(USER_ID);
// userId 来自服务端会话,模型既看不到也改不了
}
```
调用时由你在服务端注入:
```java
chatClient.prompt()
.user(question)
.tools(orderTools)
.toolContext(Map.of(OrderTools.USER_ID, userId)) // ← 从登录态拿
.call();
```
**这个模式要记住:凡是"模型不该有决定权"的参数(用户 ID、租户 ID、权限级别),一律走 `ToolContext`。**
⚠️ 一个坑:如果方法签名里声明了 `ToolContext`,那调用时**必须传一个非空的 ToolContext**,否则抛:
```
IllegalArgumentException: ToolContext is required by the method as an argument
```
### 4.5 异常处理:报错会原样喂给模型
`DefaultToolExecutionExceptionProcessor` 的默认行为是 `alwaysThrow = false`:
> **工具抛异常时,不往上抛,而是把异常信息当成工具的"返回结果"喂回给模型。**
本项目实测,业务异常抛出后,模型收到的是这样的文本(中文原样传递):
| 抛出的异常 | 模型看到的文本 |
|---|---|
| `IllegalStateException("无权操作该订单:SO20260916004")` | `[无权操作该订单:SO20260916004]` |
| `IllegalStateException("订单 SO20260916001 当前状态为「已发货」,不可取消")` | `[订单 SO20260916001 当前状态为「已发货」,不可取消]` |
| `IllegalStateException("订单不存在:SO99999999999")` | `[订单不存在:SO99999999999]` |
**这个设计很好**:模型能读懂错误信息,然后用人话告诉用户"这个订单已经发货了,不能取消",而不是甩一个 500 错误。
**但它意味着:异常信息会泄露给模型(进而可能泄露给用户)。** 所以:
- ✅ 好的错误信息:`无权操作该订单`、`当前状态不可取消`
- ❌ 危险的错误信息:`SQL: SELECT * FROM orders WHERE ... 表结构:...`、堆栈、内部主机名
**写工具方法时,异常信息要当作"给用户看的文案"来写。**
### 4.6 一个容易被忽略的开关
工具能不能生效,有个隐藏条件:
> prompt 的 `ChatOptions` 必须是 `ToolCallingChatOptions` 的实例。
正常情况下 `DefaultChatClientUtils.toChatClientRequest` 会自动处理(当 `chatModel.getOptions().mutate()` 返回的是 `ToolCallingChatOptions.Builder` 时,工具会被合并进去)。
**但如果你自定义了 `ChatOptions` 并且没实现 `ToolCallingChatOptions`,工具会静默失效** —— 不报错,就是不调用。这是个很难查的坑。
### 4.7 `-parameters` 编译参数(否则工具直接废掉)
JSON Schema 里的参数名来自 Java 的**参数名反射**,而参数名默认不保留在 class 文件里。
**没开 `-parameters` 的后果**:Schema 里的参数名变成 `arg0`、`arg1`,模型看到的工具定义是"需要一个叫 arg0 的参数",于是它填的 `{"orderNo": "..."}` 匹配不上,`orderNo` 变成 `null`,然后 `ConcurrentHashMap.get(null)` 直接抛:
```
NullPointerException: Cannot invoke "Object.hashCode()" because "key" is null
```
**好消息**:`spring-boot-starter-parent` 已经自动配了这个参数(编译日志里能看到 `javac [debug parameters release 21]`),IDEA 新建项目也会带。**但如果你自己写 `javac` 命令编译,一定要手加 `-parameters`。**
### 4.8 工具是怎么被"发现"的
底层机制(本项目验证过):
```java
MethodToolCallbackProvider.builder().toolObjects(orderTools).build();
// 等价于
ToolCallbacks.from(pojos);
```
Schema 生成用 **victools jsonschema-generator** 库。注册方式有两种:
```java
.tools(orderTools) // 传对象,自动扫 @Tool 方法(推荐)
.tools(ToolCallbacks.from(...)) // 传 ToolCallback 列表
```
### 4.9 业务侧的实现
本项目模拟了一套订单系统(`OrderService` + `Order` record),内存 `ConcurrentHashMap` 当数据库,内置 4 条订单。业务规则故意设了会失败的场景,用来验证异常回灌:
| 规则 | 触发条件 |
|---|---|
| 越权拦截 | 操作用户不拥有的订单 → `无权操作该订单` |
| 状态校验 | 非 PAID 状态不可取消 → `当前状态为「已发货」,不可取消` |
| 不存在 | 订单号查不到 → `订单不存在:xxx` |
---
## 五、场景三:RAG —— 知识库检索
RAG 的完整原理、切分机制、调参、调试方法在**单独一篇文档**里:
👉 **`docs/RAG学习与应用案例.md`**
这里只放最精炼的版本。
### 5.1 一句话
> **RAG = 让大模型开卷考试。** 从你的资料库里翻出最相关的几页,和问题一起塞进 prompt,让它照着答。**全程没有任何模型训练。**
### 5.2 两个阶段(最容易混的地方)
| | 阶段一 索引 | 阶段二 检索 |
|---|---|---|
| 何时做 | 离线,文档变化时 | 在线,每次提问 |
| 做什么 | 读 → 切 → 向量化 → 入库 | 向量化 → 相似度 → 拼接 → 生成 |
| 本项目代码 | `KnowledgeBaseService` | `RagChatController` |
| 成本 | 慢、贵、**只做一次** | 快、便宜、**每次都做** |
### 5.3 三个必须记住的坑
1. **prompt 里必须写"查不到就说查不到"** —— 否则检索不到时模型会自信地编造答案(幻觉)
2. **换 embedding 模型必须重建整个索引** —— 向量空间不通用,这是上线后的隐性成本
3. **`similarityThreshold` 必须用真实模型实测校准** —— 没有万能值
### 5.4 本项目特有注意点
- **DeepSeek 没有 `/embeddings` 接口**(返回 404)→ 向量化另用硅基流动 `BAAI/bge-m3`(1024 维)
- `EmbeddingModel` 是自己定义的 Bean,Spring AI 的 `OpenAiEmbeddingAutoConfiguration` 是 `@ConditionalOnMissingBean`,所以会顶掉默认的 → **生成走 DeepSeek、向量化走硅基流动,两套配置互不干扰**
---
## 六、三个场景怎么选、怎么组合
### 6.1 选择标准
| 你想让模型…… | 用什么 | 本项目 |
|---|---|---|
| **做**某件事(查数据库、下单、发消息) | **Tool Calling** | `/tool/*` |
| **查**某份资料(政策、手册、论文) | **RAG** | `/rag/*` |
| 每次对话都被**观察/增强**(日志、鉴权、记忆、改 prompt) | **Advisor** | `AILogAdvisor` |
**一句话记忆:Tool Calling 是让模型"做"事,RAG 是让模型"查"资料,Advisor 是包裹在前两者外面的"切面"。**
### 6.2 关键区别:Tool Calling vs RAG
| | Tool Calling | RAG |
|---|---|---|
| 数据形态 | 结构化(对象、数据库行) | 非结构化(文档、文本) |
| 查询方式 | 精确(按订单号查) | 模糊(按语义相似找) |
| 谁决定用什么 | **模型自己决定**调哪个工具、传什么参数 | 固定流程,每次必检索 |
| 结果 | 实时、准确 | 可能有噪声、取决于检索质量 |
| 适合 | "这个订单能退吗" | "退货政策怎么规定的" |
### 6.3 组合使用(真实场景常见)
```
用户:"我上周买的那个耳机能退吗?"
│
├─▶ RAG:检索退换货政策 → "七天无理由,非质量问题运费自理"
│
├─▶ Tool Calling:查这个用户上周的耳机订单 → 状态 SHIPPED,签收 3 天
│
└─▶ 综合:能退,还在 7 天内,运费需要自理
```
**组合的两种方式:**
1. **RAG 做成 Advisor,Tool 用 `.tools()`** —— 两者在同一个 ChatClient 上叠加,互不冲突(RAG Advisor 负责检索注入,ToolCallingAdvisor 负责工具循环)
2. **把检索也做成一个 Tool** —— 让模型自己决定"要不要查资料"。Spring AI 2.0 里有个 `spring-ai-tool-search-tool-vectorstore` 就是干这个的
---
## 七、配置与密钥管理
### 7.1 本项目方案:`.env` + `spring.config.import`
**key 不写进 yml**,放在项目根的 `.env`(已加进 `.gitignore`):
```
DEEPSEEK_API_KEY=sk-xxx
EMBEDDING_API_KEY=sk-yyy
```
`application.yml` 里引用:
```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
```
**优点**:key 不进 yml、不用配 IDE 环境变量、换机器只改一个文件。
**注意**:`.env` 靠**工作目录**定位。从 IDEA 或 `./mvnw spring-boot:run` 跑时 cwd 就是项目根,没问题;换成 `java -jar` 从别的目录启动就要留神。
## 八、2.0 的破坏性变更清单
从 1.x 升级踩到的坑,都是实际验证过的,不是从 release note 抄的:
| 变更 | 1.x | 2.0 | 影响 |
|---|---|---|---|
| **工具执行位置** | `ChatModel` 内部 | `ToolCallingAdvisor` | 想控制工具执行要改思路 |
| **工具执行开关** | `internalToolExecutionEnabled` | **移除** | 编译直接失败 |
| **`OpenAiApi` 类** | 存在,可自定义 | **移除**,改用官方 OpenAI Java SDK | 自定义 HTTP 客户端的代码全废 |
| **向量库 Advisor 包名** | `spring-ai-advisors-vector-store` | `spring-ai-vector-store-advisor` | 依赖名变了 |
| **`spring-jcl`** | Spring 自带 | **移除**,改用 `commons-logging` | 日志依赖要确认 |
**最后一条补充**:Spring Framework 7 移除了 `spring-jcl`,但 `spring-core:7.0.9` 依赖 `commons-logging:commons-logging`,所以 `org.apache.commons.logging.Log/LogFactory` **仍然可用**。本项目所有 Advisor 都用它打日志,验证过。
---
## 十一、学习路径建议
如果是第一次接触 Spring AI,建议按这个顺序,每一步都跑通再往下:
| 阶段 | 学什么 | 本项目对应 |
|---|---|---|
| **1. 先跑起来** | `ChatClient` 基础对话、system prompt、流式 | `ChatController` |
| **2. 理解骨架** ★ | **Advisor 洋葱模型、order 规则** | `AILogAdvisor` |
| **3. 让模型做事** ★ | `@Tool`、`ToolContext`、异常回灌 | `OrderTools` |
| **4. 让模型查资料** | 切分、embedding、向量检索、阈值 | `RagChatController` |
| **5. 组合** | RAG + Tool + 多个 Advisor 叠加 | 待扩展 |
**为什么第 2 步要排在第 3、4 步前面?** 因为 2.0 里工具执行本身就是个 Advisor,RAG 的 `QuestionAnswerAdvisor` 也是 Advisor。**不理解洋葱模型,后面两个场景都是背 API,出问题不会查。**
### 验证纪律(比结论更重要)
本项目所有结论的获取方式,值得借鉴:
- **不要相信记忆里的 API** —— 2.0 相对 1.x 改了一大堆,凭印象写必然编译失败
- **反编译真实 jar 确认签名**:`javap -p -c -cp <jars> <类名>`,还能看到默认值(本项目的中文标点坑就是这么挖出来的)
- **写最小验证程序**:不确定 Advisor 顺序?写个 `Rec` 探针链跑一遍打印出来,比读文档可靠
- **区分"验证过"和"推断"**:本项目**从未真正调用过真实的大模型/embedding HTTP 接口**(没有可用 key),所以"HTTP 请求长什么样"是文档推断,而"参数绑定、Advisor 顺序、异常文本、切分结果"都是实测
---
## 十二、术语表
| 术语 | 英文 | 通俗解释 |
|---|---|---|
| **Advisor** | — | 增强器。包裹在模型调用外面的"切面",能在请求前后插逻辑。洋葱模型 |
| **洋葱模型** | Onion Model | 一层套一层,外层先进后出。中间件/拦截器都是这个套路 |
| **order** | — | Advisor 的顺序值。**越小越靠外** |
| **Tool Calling** | 工具调用 | 让模型输出"我要调用某函数+参数",由框架执行,结果回灌给模型 |
| **`@Tool`** | — | 标记一个 Java 方法可以被模型调用 |
| **`ToolContext`** | — | 工具调用的上下文,用来传"模型不该决定"的参数(如当前用户 ID) |
| **`ChatClient`** | — | Spring AI 的对话入口,fluent API |
| **`ChatModel`** | — | 底层模型抽象,一个实现对应一个服务商 |
| **SSE** | Server-Sent Events | 服务端流式推送,配合 Flux 实现打字机效果 |
| **`Flux`** | — | Reactor 的流式类型,可以理解成"异步的 List" |
| **RAG** | Retrieval-Augmented Generation | 检索增强生成,让模型开卷考试 |
| **Embedding** | — | 把文字变成一串数字(向量),数字代表"意思的坐标" |
| **Chunk** | 碎片 | 文档切分后的一小段,向量化的基本单位 |
| **Token** | — | 分词器切出的最小单位。中文大约 1 个字 = 1 个 token |
| **余弦相似度** | Cosine Similarity | 两个向量夹角的余弦值,1 = 意思一样,0 = 无关 |
| **幻觉** | Hallucination | 模型编造不存在的信息,且答得很自信 |
| **BOM** | Bill of Materials | 只声明版本号的 pom,统一管理一堆依赖的版本 |