数据截至 (上游 commit c988e72ab728)
Spring AI — 架构与原理
30 秒导读: Spring AI 让"调大模型"变成 Spring 应用里最普通的一件事——注入一个
ChatClient,像写RestClient那样把话说完、拿到结果。要加记忆、加检索、加工具、加日志,就往 Advisor 责任链上插一环;要换供应商(OpenAI → Anthropic → 本地 Ollama),换 starter 和配置即可,业务代码不动。
1. 这是什么(零基础也能懂)
一句话定义: Spring AI 是 Spring 官方出的 AI 应用框架,它给"大模型、向量库、工具调用"这些新东西套上了 Spring 一贯的端口 + 自动配置 + 拦截链三件套。
解决什么问题、给谁用。 假设你是个 Java 后端,老板让你在现有 Spring Boot 服务里加个"问答助手":要能读公司文档、要能调你已有的几个 Service 方法、要记住对话、上线后还得有监控。裸调 OpenAI SDK 你会写出一堆一次性胶水;换成 Anthropic 就得重写 一遍。Spring AI 就是把这堆胶水做成框架。
它对外提供这些能力(as-of 本 commit,按仓库目录点数):
| 能力 | 具体是什么 | 落在哪个模块 |
|---|---|---|
| 聊天 / 嵌入 / 图像 / 语音 / 审核 | 五类模型的统一接口 | spring-ai-model |
| 多供应商适配 | 15 个模型模块(OpenAI、Anthropic、Ollama、Bedrock、Google GenAI、DeepSeek…) | models/ |
| 向量存储 | 22 个向量库模块(PGVector、Qdrant、Redis、Milvus…) | vector-stores/ |
| 开箱即用装配 | 48 个 Spring Boot starter | starters/ |
| 工具调用 / MCP | @Tool 注解、MCP 客户端与服务端 | spring-ai-model、mcp/ |
| RAG 流水线 | 查询改写 → 检索 → 拼接 → 增强 | spring-ai-rag |
用起来什么样。 最小可用的一段业务代码长这样:
// 示意,非源码
@RestController
class AskController {
private final ChatClient chatClient;
AskController(ChatClient.Builder builder) { // starter 已经把 Builder 装好了
this.chatClient = builder
.defaultSystem("你是公司知识库助手,只用中文回答") // 每次请求都带上的系统提示
.build();
}
@GetMapping("/ask")
String ask(String q) {
return chatClient.prompt() // 开始攒一个请求
.user(q) // 用户这句话
.tools(new OrderTools()) // 把带 @Tool 的方法交给模型调用
.call() // 同步发出去(.stream() 就是流式)
.content(); // 只要文本
}
}
重点看:从 prompt() 到 content() 是一条流式(fluent)链,中间任何一步都只是"往请求上加料",真正发请求发生在 call() 之后。
一句话直觉: 如果你写过 Spring Web——ChatClient 之于 ChatModel,约等于 RestClient 之于 HTTP 客户端;Advisor 之于一次模型调用,约等于 Filter 之于一次 HTTP 请求。(这只是帮你建立心智模型,后文一律用 ChatClient / Advisor 这些原名。)
版本前提: 本仓库主干是 2.0.x(pom.xml:8 为 2.0.1-SNAPSHOT),要求 Spring Boot 4.x;1.1.x 分支才配 Spring Boot 3.5.x(README.md:16-18)。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从上往下就是一次 call() 的走向,四个格子按先后顺序执行;箭头右侧是这一步产出的东西。
你的应用代码
│ chatClient.prompt().user(q).tools(...).call().content()
▼
┌──────────────────────────────────────┐
│ ① 组装:攒出一个不可变请求 │ ──▶ ChatClientRequest
│ system → 历史消息 → user 依次排好 │ (Prompt + 一个 context 字典)
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ ② Advisor 责任链(按 order 排队) │ ──▶ 记忆 / RAG / 工具循环 / 日志
│ 每环都能改请求、也能改响应 │ 都是链上的一环
└──────────────────────────────────────┘
│ 链底固定是"真的去调模型"这一环
▼
┌──────────────────────────────────────┐
│ ③ ChatModel 端口(可移植抽象) │ ──▶ ChatResponse
└──────────────────────────────────────┘
│
▼
OpenAI / Anthropic / Ollama / Bedrock … 的 HTTP API
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
ChatClient | 面向用户的流式 API,负责"把话说完" | spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java:61 |
ChatClientRequest | 组装完成的不可变请求:Prompt + 一个 context 字典(advisor 之间传数据用) | spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientRequest.java:36 |
DefaultAroundAdvisorChain | 责任链本体:从队列里弹一个 advisor 执行,顺带打一层 observation | spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java:59 |
ToolCallingAdvisor | 链上的"工具调用循环":调模型 → 执行工具 → 带结果再调模型,直到模型不再要工具 | spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java:65 |
ChatModelCallAdvisor | 链底那一环,真正 chatModel.call(prompt);order 是最低优先级,保证排最后 | spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java:43 |
ChatModel / EmbeddingModel / VectorStore | 三个可移植端口,每家供应商各写一个实现 | spring-ai-model/src/main/java/org/springframework/ai/chat/model/ChatModel.java:30 等 |
| 自动配置 | 按 classpath 和配置项把上面这些装成 bean | auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java:72 |
主线走一遍(不进代码):
- 收集。 你在
prompt()...上写的 system 文本、消息、user 文本、options、tools,全被暂存在一个 request spec 里。 - 组装。
call()触发toChatClientRequest,把 system 放第一条、历史消息放中间、user 放最后,渲染完模板、合并完 options,产出ChatClientRequest(DefaultChatClientUtils.java:50-137)。 - 建链。 同时建出这次调用专用的 advisor 链:你注册的 advisor + 自动补上的
ToolCallingAdvisor+ 链底的ChatModelCallAdvisor/ChatModelStreamAdvisor(DefaultChatClient.java:1189-1203)。 - 穿链。
advisorChain.nextCall(request)一环环往里走;每环可以在调下一环之前改请求、之后改响应(DefaultAroundAdvisorChain.java:98-121)。 - 触底。 走到
ChatModelCallAdvisor,它把结构化输出的格式说明拼进 user 消息,然后chatModel.call(prompt)(ChatModelCallAdvisor.java:53-58)。 - 回卷。 响应沿原路返回,每环按相反顺序做"后处理"(记忆落库、日志、用量累加),最后
content()/entity()把它变成字符串或 POJO。
3. 阅读地图
建议按下表顺序读;每章都能独立打开,但 01 和 02 是后面几章的地基。
| 顺序 | 章节 | 讲什么 | 什么时候读它 |
|---|---|---|---|
| 1 | ChatClient:从流式 API 到一个 ChatClientRequest | 流式 API 的三层结构、消息排序规则、options 怎么合并、entity() 怎么走 | 想搞清"我写的那串点号最后变成了什么" |
| 2 | Advisor 责任链:Spring AI 的拦截器模型 | CallAdvisor/StreamAdvisor/BaseAdvisor、order 语义、链的弹栈执行与 copy() | 想自己写一个拦截器,或搞清多个 advisor 的先后 |
| 3 | 工具调用:从 @Tool 注解到多轮循环 | @Tool 如何变成 ToolCallback、ToolCallingManager 如何执行、循环为何搬上链 | 做 agent、做函数调用、排查工具没被调用 |
| 4 | 可移植模型层:一套抽象罩住十几家供应商 | ChatModel/ChatOptions 端口设计、供应商特有选项怎么留、结构化输出两条路径 | 要换供应商,或要把回答转成 POJO |
| 5 | 记忆、RAG 与向量存储:把企业数据接进模型 | ChatMemory 与会话 id、RAG 六段流水线、VectorStore 端口与过滤 DSL | 做知识库问答、做多轮对话 |
| 6 | 落到 Spring Boot:starter、可观测性、MCP 与工具检索 | 自动配置装了什么、gen_ai.* 观测指标、MCP 工具桥接、工具检索(tool search) | 要上线、要接监控、要接 MCP 生态 |
4. 巧妙之处(可借鉴的技术)
4.1 把工具循环从模型里搬到链上,再用 copy(this) 防自递归
妙在哪: 1.x 时代"模型说要调工具 → 执行 → 再问模型"这个循环藏在每个 ChatModel 实现内部,链上的 advisor 只看得见最终答案,拦不到中间轮次。2.0 把它抽成 ToolCallingAdvisor,循环体变成链上的一个 do-while,于是每一轮都会重新穿过链的下半截,日志/记忆/审计 advisor 因此能看见每一轮(ToolCallingAdvisor.java:149-216)。
关键一行是 callAdvisorChain.copy(this).nextCall(...):它复制一条排除自己之后的链,否则每轮都会再次进入自己而无限递归(ToolCallingAdvisor.java:167、DefaultAroundAdvisorChain.java:178-195)。
配套证据:全仓非测试代码里 executeToolCalls 的调用点只剩 advisor 和 manager,任何 ChatModel 实现都不再调它;OpenAiChatModel 只用 resolveToolDefinitions 把工具描述塞进请求(models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java:879)。
4.2 用 order 数值让"记忆"天然套在"工具循环"外面
妙在哪: 一个真实的坑是— —工具循环跑三轮,记忆 advisor 如果在循环内,就会把三轮中间态全写进历史。Spring AI 不靠文档提醒,而是靠两个默认常量把层次固定死:
| 常量 | 值 | 效果 |
|---|---|---|
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER | HIGHEST_PRECEDENCE + 200 | 记忆 advisor 先执行 → 在外层 |
ToolCallingAdvisor.DEFAULT_ORDER | HIGHEST_PRECEDENCE + 300 | 工具循环在内层,中间轮次不落进记忆 |
依据:advisor/api/Advisor.java:39、advisor/ToolCallingAdvisor.java:75。
更细的一手:如果检测到有 MemoryAdvisor 排在自己下游(order 更大,即在循环里面),ToolCallingAdvisor 会主动关掉自己维护的中间历史,把记账权交出去,避免两边重复(DefaultChatClient.java:1232-1237,配合 doGetNextInstructionsForToolCall,ToolCallingAdvisor.java:222-234)。
4.3 结构化输出走 context 字典,而不是塞进方法签名
妙在哪: 你调 .entity(Order.class),格式说明并不是当场拼进 prompt 的。entity() 先把格式字符串放进请求的 context 字典,等链走到底,ChatModelCallAdvisor 才决定怎么用它:
- 模型不支持原生结构化输出 → 把格式说明追加到 user 消息末尾;
- 支持原生(选项实现了
StructuredOutputChatOptions)→ 把 JSON Schema 写进 options,不动 prompt。
依据:DefaultChatClient.java:592-607(写 context)、ChatModelCallAdvisor.java:66-101(两条分支)、键名见 ChatClientAttributes.java:29-33。
好处是中间任何 advisor 都能看见甚至改写这个意图,而不需要在链的每一层增加一个参数。
4.4 给循环留 protected 钩子,扩展不用改源码
妙在哪: ToolCallingAdvisor 把循环的四个时刻开成 protected 空方法——doInitializeLoop / doBeforeCall / doAfterCall / doFinalizeLoop(ToolCallingAdvisor.java:236-252);流式侧另有同款钩子 doInitializeLoopStream / doBeforeStream / doAfterStream / doFinalizeLoopStream(ToolCallingAdvisor.java:413-451)。
这不是摆设:仓库自带的工具检索功能就是靠继承实现的——ToolSearchToolCallingAdvisor extends ToolCallingAdvisor,在 doBeforeCall 里按当前对话动态换掉这一轮要暴露给模型的工具集(advisors/spring-ai-tool-search-advisor/src/main/java/org/springframework/ai/chat/client/advisor/toolsearch/ToolSearchToolCallingAdvisor.java:76,146-173)。当你有几百个工具、塞不进上下文时,这就是解法。
4.5 "至多一个 ToolAdvisor" 用启动期校验兜住
妙在哪: 框架会自动补一个 ToolCallingAdvisor,而用户也可能手动注册一个(比如上面的工具检索版)。两个循环叠在一起会产生难查的重复调用。Spring AI 的处理是两段式:先看链里有没有 ToolAdvisor 标记接口的实现,有就不自动补(DefaultChatClient.java:1217-1238);建链前再数一遍,超过一个直接抛异常并打印各自的 order(DefaultChatClient.java:1240-1248)。
可借鉴的模式: 用一个空标记接口(ToolAdvisor、MemoryAdvisor)表达"这一环属于哪一类",框架就能对链做语义级校验,而不必反射类名。
5. 代码地图(导航索引)
按主题跳源码。行号 as-of c988e72a;行号漂移时用「符号名」列 grep 更稳。
| 主题 | 文件路径 | 符号名 | 锚点 |
|---|---|---|---|
| 流式 API 入口 | spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java | ChatClient、prompt()、ChatClientRequestSpec | :61、:128 |