Spring AI 2.0.1 深度解析-第 01 章 全景:模块拓扑、依赖层次与设计哲学

Spring AI 2.0.1 深度解析-第 01 章 全景:模块拓扑、依赖层次与设计哲学

本章回答一个问题:Spring AI 2.0.1 到底是什么形状的? 我们会从物理目录一路走到依赖有向图,再走到它背后的三条设计信条。读完后你应该能在不看 IDE 的情况下,准确说出任何一个类大概住在哪个模块里。


1.1 先定位:这是个什么项目

打开根 pom.xml,第 8 行附近:

xml 复制代码
<artifactId>spring-ai-parent</artifactId>
<version>2.0.1</version>

pom.xml:240:

xml 复制代码
<java.version>17</java.version>

pom.xml:252:

xml 复制代码
<spring-boot.version>4.1.1</spring-boot.version>

这是第一个必须建立的正确认知 :Spring AI 2.x 对应的不是 Spring Boot 3.x,而是 Spring Boot 4.1.1 。README.md:15-17 给出的兼容矩阵写得很直白:

markdown 复制代码
Spring Boot Version Compatibility:
* **Spring AI 2.x.x** (main branch) - Spring Boot `4.x`
* **Spring AI 1.1.x** (1.1.x branch) - Spring Boot `3.5.x`

这解释了本系列后面会反复遇到的一系列"为什么":

  • 为什么 RetryUtils 里 import 的是 org.springframework.core.retry.RetryTemplate 而不是 org.springframework.retry.support.RetryTemplate------因为 Spring Framework 7(Boot 4 的底座)把重试能力收进了核心,Spring Retry 这个独立项目不再是必需项。
  • 为什么 JacksonUtils.getDefaultJsonMapper() 返回的是 Jackson 3 的 tools.jackson.databind.json.JsonMapper------Boot 4 全线迁到 Jackson 3。
  • 为什么 spring-ai-openai 的 OpenAiChatOptions 里 ObjectMapper 字段旁写着注释 // Jackson 2 required due to OpenAI deserializers------因为 openai-java SDK 内部仍是 Jackson 2,Spring AI 不得不同时容纳两个 Jackson 世代。

这些不是设计缺陷,而是一个框架在被上游平台推着走时必然留下的地层痕迹。本系列的目标之一就是帮你读懂这些地层。

1.1.1 版本基线全表

从根 pom.xml 摘录的真实属性(行号已标):

属性 值 行号
java.version 17 240
kotlin.version 2.3.20 245
spring-boot.version 4.1.1 252
anthropic-sdk.version 2.52.0 258
openai-sdk.version 4.49.0 259
okhttp3.version 4.12.0 ---
awssdk.version 2.51.2 261
bedrockruntime.version 2.51.2 262
milvus.version 2.6.21 273
pgvector.version 0.1.6 276
pinecone.version 6.2.0 277
qdrant.version 1.18.0 278
mcp.sdk.version 2.0.0 255
jsonschema.version 5.0.0 288
lucene.version 9.12.3 290
swagger-annotations.version 2.2.38 293
json-schema-validator.version 3.0.1 297

其中 mcp.sdk.version 为 2.0.0,通过 pom.xml:990-996 以 BOM 形式导入:

xml 复制代码
<dependency>
    <groupId>io.modelcontextprotocol.sdk</groupId>
    <artifactId>mcp-bom</artifactId>
    <version>${mcp.sdk.version}</version>
    <type>pom</type><scope>import</scope>
</dependency>

这一点很重要:MCP Java SDK 在 2.x 里已经不是 0.x/1.x,而是 2.0.0 。upgrade-notes.adoc:1493-1495 明确记了这次跳跃:

复制代码
MCP Java SDK 1.1.x → 2.0.0

1.1.2 构建的前提条件(一个真实的坑)

CONTRIBUTING.md:213:

javascript 复制代码
The project targets and builds artifacts compatible with Java 17+, and requires a JDK with
support for the `-XDaddTypeAnnotationsToSymbol` javac argument, like Liberica 17.0.19+,
for nullability checks.

也就是说:不是任何一个 JDK 17 都能构建 Spring AI 。它要求 javac 支持 -XDaddTypeAnnotationsToSymbol,因为项目用 jspecify 做全量空安全标注,编译器需要把类型注解写进符号表。如果你用普通的 OpenJDK 17 早期版本构建,会在 NullAway 检查阶段挂掉。

这条要求直接塑造了整个代码库的观感------你会在几乎每个包下看到:

java 复制代码
@NullMarked
package org.springframework.ai.chat.prompt;

import org.jspecify.annotations.Nullable;

以及每个可空字段上的 @Nullable、每个不可空参数上的紧致构造器断言。这不是装饰,是编译期契约 。本系列后面讲 ChatOptions 全 @Nullable 时,会回到这里。


1.2 2.x 最大的结构性变化:spring-ai-core 被拆了

如果你带着 Spring AI 1.x 的经验来读 2.0.1,第一个会撞上的事实是:spring-ai-core 这个 artifact 消失了。

upgrade-notes.adoc:2852 原文:

vbnet 复制代码
The project has undergone significant changes to its module and artifact structure.
Previously, `spring-ai-core` contained all central interfaces, but this has now been split
into specialized domain modules to reduce unnecessary dependencies in your applications.

动机写得非常清楚:减少应用的非必要依赖。

在 1.x 里,你只想用 ChatClient 调个 OpenAI,也得把向量存储、ETL、文档读取、模板引擎等一大坨传递依赖拖进 classpath。2.x 把它们切成五个正交的模块,让你可以按需取用。

1.2.1 五个核心模块及其职责(upgrade-notes.adoc:2856-2886 原文摘录)

spring-ai-commons:

javascript 复制代码
Base module with no dependencies on other Spring AI modules. Contains:
 - Core domain models (Document, TextSplitter)
 - JSON utilities and resource handling
 - Structured logging and observability support

spring-ai-model:

markdown 复制代码
 - Interfaces like ChatModel, EmbeddingModel, and ImageModel
 - Message types and prompt templates
 - Function-calling framework (ToolDefinition, ToolCallback)
 - Content filtering and observation support

spring-ai-client-chat:

markdown 复制代码
 - ChatClient interface
 - Conversation persistence via ChatMemory
 - Response conversion with OutputConverter
 - Advisor-based interception
 - Synchronous and reactive streaming support

注意官方文档里还写着 "Function-calling framework",这是 1.x 的措辞残留------2.x 里这个概念已经改叫 Tool Calling,FunctionCallback 也变成了 ToolCallback(见第 07 章)。文档与代码不同步,是本仓库的常态,读的时候要交叉验证。

1.2.2 官方给出的依赖层次(upgrade-notes.adoc:2912-2918)

markdown 复制代码
* `spring-ai-commons` (foundation)
* `spring-ai-model` (depends on commons)
* `spring-ai-vector-store` and `spring-ai-client-chat` (both depend on model)
* `spring-ai-vector-store-advisor` and `spring-ai-rag` (depend on both client-chat and vector-store)
* `spring-ai-model-chat-memory-*` modules (depend on client-chat)

画成有向图:

scss 复制代码
                          spring-ai-commons
                          (零 Spring AI 依赖)
                                  ▲
                                  │
                          spring-ai-model
                        (ChatModel / EmbeddingModel /
                         ImageModel / Tool / Prompt)
                            ▲               ▲
                            │               │
          spring-ai-client-chat      spring-ai-vector-store
          (ChatClient / Advisor)     (VectorStore / Filter)
                            ▲               ▲
                            └───────┬───────┘
                                    │
                ┌───────────────────┼───────────────────┐
                │                   │                   │
   spring-ai-vector-store-advisor  spring-ai-rag    spring-ai-model-chat-memory-*
   (QuestionAnswerAdvisor,         (RetrievalAugmentation
    VectorStoreChatMemoryAdvisor)   Advisor 等)        (仅依赖 client-chat)

这张图里藏着几个值得记住的事实:

  1. spring-ai-commons 是真正的地基,且零 Spring AI 内部依赖。 它甚至不依赖 spring-ai-model。这意味着你可以只引 spring-ai-commons 来用它的 Document、JsonHelper、ObservabilityHelper,而不拖进任何模型抽象。
  2. spring-ai-vector-store 和 spring-ai-client-chat 是兄弟,互相不知道对方存在。 这是 RAG 必须放在第三个模块(spring-ai-rag)的根本原因------它要同时看见两者。
  3. spring-ai-model-chat-memory-* 只依赖 client-chat,不依赖 vector-store。 但 VectorStoreChatMemoryAdvisor 需要 vector-store,所以它住在 advisors/spring-ai-vector-store-advisor 里,而不是 spring-ai-model-chat-memory-* 里。这个"记忆的存储后端"与"记忆的 advisor"被拆到不同模块的设计,是理解后面 chat memory 章节的关键。

1.2.3 用源码验证依赖方向

不要只信文档,我们直接看 spring-ai-model/pom.xml:19,25:

xml 复制代码
<artifactId>spring-ai-commons</artifactId>
...
<artifactId>spring-ai-template-st</artifactId>

spring-ai-model 依赖 spring-ai-commons + spring-ai-template-st。这里有个容易忽略的点:模板渲染器 spring-ai-template-st 是一个独立 artifact,且被 spring-ai-model 直接依赖。

也就是说,只要你用了 spring-ai-model(几乎必然),你就自动获得了 StringTemplate 4(ST4)和 ANTLR4 runtime。spring-ai-template-st/pom.xml:20,26,33:

xml 复制代码
<artifactId>spring-ai-commons</artifactId>
...
<artifactId>ST4</artifactId>
...
<artifactId>antlr4-runtime</artifactId>

spring-ai-rag/pom.xml:17-29 只依赖两项:

xml 复制代码
<artifactId>spring-ai-client-chat</artifactId>
<artifactId>spring-ai-vector-store</artifactId>

与文档完全吻合。


1.3 物理目录:顶层是怎么摆的

根目录一级子目录(来自项目结构快照):

arduino 复制代码
advisors/                   ← 两个需要跨模块的 advisor
auto-configurations/        ← 所有 Spring Boot 自动装配
design/                     ← 3 个 .adoc 设计文档
document-readers/           ← 4 个文档读取器
mcp/                        ← MCP 核心(common + annotations + 两个 transport)
memory-repositories/        ← 6 个 chat memory 持久化实现
models/                     ← 15 个模型供应商模块
spring-ai-bom/              ← BOM
spring-ai-client-chat/      ← 五大核心之一
spring-ai-commons/          ← 五大核心之一
spring-ai-docs/             ← Antora 官方文档
spring-ai-integration-tests/
spring-ai-model/            ← 五大核心之一
spring-ai-rag/              ← 五大核心之一
spring-ai-retry/            ← 重试工具
spring-ai-spring-boot-docker-compose/
spring-ai-spring-boot-testcontainers/
spring-ai-template-st/      ← StringTemplate 集成
spring-ai-test/             ← 测试基类集合
spring-ai-tool-search-tool/ ← 工具检索
spring-ai-vector-store/     ← 五大核心之一
src/                        ← 构建脚本/配置
starters/                   ← 48 个 starter
vector-stores/              ← 23 个向量库实现

注意一个反直觉点 :spring-ai-retry 不在"五大核心"之列,它是独立的。但它被 spring-ai-model 依赖(HTTP 客户端重试)。而 spring-ai-commons 不 依赖 spring-ai-retry------重试是模型调用侧的事,不是通用底座的事。

1.3.1 pom.xml 的模块声明顺序(这是有含义的)

pom.xml:17-30:

xml 复制代码
<module>spring-ai-bom</module>
<module>spring-ai-client-chat</module>
<module>spring-ai-commons</module>
<module>spring-ai-docs</module>
<module>spring-ai-integration-tests</module>
<module>spring-ai-model</module>
<module>spring-ai-rag</module>
<module>spring-ai-retry</module>
<module>spring-ai-spring-boot-docker-compose</module>
<module>spring-ai-spring-boot-testcontainers</module>
<module>spring-ai-template-st</module>
<module>spring-ai-test</module>
<module>spring-ai-tool-search-tool</module>
<module>spring-ai-vector-store</module>

pom.xml:32-33:

xml 复制代码
<module>advisors/spring-ai-tool-search-advisor</module>
<module>advisors/spring-ai-vector-store-advisor</module>

前 14 个是"核心 + 基础设施",按字母序(除了 spring-ai-bom 打头)。后面跟着 models/、vector-stores/、advisors/、memory-repositories/、document-readers/、mcp/、starters/、auto-configurations/ 等分类目录。

这个物理布局本身就是一份文档:它告诉你"核心"和"适配器"是两类东西。核心 14 个模块是所有人都需要的;适配器(模型、向量库、starter)是你按需挑选的。

1.3.2 十五个模型模块

models/ 下:

arduino 复制代码
spring-ai-anthropic
spring-ai-bedrock
spring-ai-bedrock-converse
spring-ai-deepseek
spring-ai-elevenlabs
spring-ai-google-genai
spring-ai-google-genai-embedding
spring-ai-google-genai-image
spring-ai-mistral-ai
spring-ai-ollama
spring-ai-openai
spring-ai-postgresml
spring-ai-stability-ai
spring-ai-transformers
spring-ai-vertex-ai-embedding

注意 Google 被拆成三个(google-genai 主模块 + embedding + image),Bedrock 被拆成两个(bedrock 老 API + bedrock-converse 新 Converse API)。这种拆分是为了让你只引需要的那部分------比如你只想用 Google 的嵌入模型,就不需要把它的聊天/图像依赖都拖进来。

1.3.3 二十三个向量库模块

vector-stores/ 下:

arduino 复制代码
spring-ai-azure-store
spring-ai-bedrock-knowledgebase-store
spring-ai-cassandra-store
spring-ai-chroma-store
spring-ai-coherence-store
spring-ai-couchbase-store
spring-ai-elasticsearch-store
spring-ai-gemfire-store
spring-ai-mariadb-store
spring-ai-milvus-store
spring-ai-mongodb-atlas-store
spring-ai-neo4j-store
spring-ai-opensearch-store
spring-ai-oracle-store
spring-ai-pgvector-store
spring-ai-pinecone-store
spring-ai-qdrant-store
spring-ai-redis-semantic-cache
spring-ai-redis-store
spring-ai-s3-vector-store
spring-ai-typesense-store
spring-ai-weaviate-store

这里混进了一个异类:spring-ai-redis-semantic-cache 。它不是向量库,而是基于 Redis 向量检索实现的语义缓存 (SemanticCacheAdvisor)。把它放在 vector-stores/ 是因为它复用了 Redis 的向量检索能力。第 12 章会专门讲它。



1.4 spring-ai-commons 解剖:地基里到底有什么

这个模块最容易被人忽略,但它是理解很多"这个类为什么在这个包"的关键。完整包结构(来自源码树):

dart 复制代码
org.springframework.ai.content     → Content, Media, MediaContent
org.springframework.ai.document    → Document, DocumentMetadata, ContentFormatter,
                                     DefaultContentFormatter, DocumentReader,
                                     DocumentTransformer, DocumentWriter, MetadataMode
org.springframework.ai.document.id → IdGenerator, JdkSha256HexIdGenerator, RandomIdGenerator
org.springframework.ai.evaluation  → EvaluationRequest, EvaluationResponse, Evaluator
org.springframework.ai.observation → AiOperationMetadata, ObservabilityHelper,
                                     TracingAwareLoggingObservationHandler
org.springframework.ai.observation.conventions
                                   → AiObservationAttributes, AiObservationMetricAttributes,
                                     AiObservationMetricNames, AiOperationType, AiProvider,
                                     AiTokenType, SpringAiKind,
                                     VectorStoreObservationAttributes,
                                     VectorStoreProvider, VectorStoreSimilarityMetric
org.springframework.ai.reader      → TextReader, JsonReader, JsonMetadataGenerator,
                                     EmptyJsonMetadataGenerator, ExtractedTextFormatter
org.springframework.ai.template    → TemplateRenderer, NoOpTemplateRenderer, ValidationMode
org.springframework.ai.tokenizer   → TokenCountEstimator, JTokkitTokenCountEstimator
org.springframework.ai.transformer → ContentFormatTransformer
org.springframework.ai.transformer.splitter
                                   → TextSplitter, TokenTextSplitter
org.springframework.ai.util        → JacksonUtils, JsonHelper, ParsingUtils, ResourceUtils
org.springframework.ai.writer      → FileDocumentWriter

1.4.1 三个"被下沉"的类型

这是 2.x 的一处真实包迁移 (upgrade-notes.adoc:2841-2847):

go 复制代码
`Content` / `MediaContent` / `Media`:`org.springframework.ai.model` → `org.springframework.ai.content`

在 1.x 里 Media 住在 org.springframework.ai.chat.messages 包下,跟消息类型混在一起。2.x 把它下沉到 commons 的 content 包,理由是:媒体内容不只是消息的附属品,向量库的 Document、ETL 的 reader 也都需要它。

这解释了为什么 ChatClient 接口里的 import 是:

java 复制代码
import org.springframework.ai.content.Media;   // ChatClient.java:39

而不是 1.x 的 org.springframework.ai.chat.messages.Media。

Media 的定义(spring-ai-commons/.../content/Media.java:73-118):

java 复制代码
public class Media {

	private static final String NAME_PREFIX = "media-";

	/**
	 * An Id of the media object, usually defined when the model returns a reference to
	 * media it has been passed.
	 */
	private final @Nullable String id;

	private final MimeType mimeType;

	private final Object data;

	/**
	 * The name of the media object that can be referenced by the AI model.
	 * <p>
	 * Important security note: This field is vulnerable to prompt injections, as the
	 * model might inadvertently interpret it as instructions. It is recommended to
	 * specify neutral names.
	 * ...
	 */
	private final String name;

这段 Javadoc 值得单独拎出来讲 :Media.name 字段被明确标注为 prompt injection 的攻击面 。因为这个名字会被喂给模型,模型可能把它误读成指令。框架的应对只是"建议用中性名字"------它不帮你转义。这是 Spring AI 一贯的立场:框架提供能力,安全边界由使用者负责 。本系列后面讲 VectorStoreChatMemoryAdvisor 的 XML 转义时,会看到少数几个框架主动做防护的地方,但那是例外而非常态。

Media 的 mimeType 用的是 Spring Framework 自带的 org.springframework.util.MimeType(Media.java:27),不是 Spring AI 自定义类型。

Media.Format 内部类(Media.java:361-486)提供了一批常量,分三类:

java 复制代码
// Document formats
public static final MimeType DOC_PDF  = MimeType.valueOf("application/pdf");
public static final MimeType DOC_CSV  = MimeType.valueOf("text/csv");
public static final MimeType DOC_DOC  = MimeType.valueOf("application/msword");
public static final MimeType DOC_DOCX = MimeType
    .valueOf("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
public static final MimeType DOC_XLS  = MimeType.valueOf("application/vnd.ms-excel");
public static final MimeType DOC_XLSX = MimeType
    .valueOf("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
public static final MimeType DOC_HTML = MimeType.valueOf("text/html");
public static final MimeType DOC_TXT  = MimeType.valueOf("text/plain");
public static final MimeType DOC_MD   = MimeType.valueOf("text/markdown");

// Video Formats
public static final MimeType VIDEO_MKV = MimeType.valueOf("video/x-matros");
public static final MimeType VIDEO_MOV = MimeType.valueOf("video/quicktime");
public static final MimeType VIDEO_MP4 = MimeType.valueOf("video/mp4");
public static final MimeType VIDEO_WEBM = MimeType.valueOf("video/webm");
public static final MimeType VIDEO_FLV = MimeType.valueOf("video/x-flv");
public static final MimeType VIDEO_MPEG = MimeType.valueOf("video/mpeg");
public static final MimeType VIDEO_MPG = MimeType.valueOf("video/mpeg");
public static final MimeType VIDEO_WMV = MimeType.valueOf("video/x-ms-wmv");
public static final MimeType VIDEO_THREE_GP = MimeType.valueOf("video/3gpp");

// Image Formats
public static final MimeType IMAGE_PNG  = MimeType.valueOf("image/png");
public static final MimeType IMAGE_JPEG = MimeType.valueOf("image/jpeg");
public static final MimeType IMAGE_GIF  = MimeType.valueOf("image/gif");
public static final MimeType IMAGE_WEBP = MimeType.valueOf("image/webp");

顺手记一个源码 bug:VIDEO_MKV 的值是 "video/x-matros",标准 MIME 应该是 video/x-matroska。这是原样摘录,不是笔误。如果你要跟外部系统对接 MKV,别直接用这个常量。

1.4.2 Content 接口:一个极简但影响深远的抽象

spring-ai-commons/.../content/Content.java:31-45:

java 复制代码
public interface Content {

	@Nullable String getText();

	Map<String, Object> getMetadata();

}

就这么两个方法。但它的存在决定了:

  • Message extends Content(spring-ai-model/.../chat/messages/Message.java:29-37)------所以所有消息都有 getText() 和 getMetadata()。
  • MediaContent extends Content(commons/.../content/MediaContent.java)------额外提供 List<Media> getMedia(),由 UserMessage 和 AssistantMessage 实现。

于是消息类型的完整继承关系是:

scss 复制代码
Content (commons)
 ├── MediaContent (commons)          ← 追加 getMedia()
 │    ├── UserMessage      (实现 MediaContent, extends AbstractMessage)
 │    └── AssistantMessage (实现 MediaContent, extends AbstractMessage)
 └── Message (model) extends Content ← 追加 getMessageType()
      ├── AbstractMessage
      │    ├── UserMessage
      │    ├── SystemMessage
      │    ├── AssistantMessage
      │    └── ToolResponseMessage

注意 UserMessage 同时走了两条线:它 extends AbstractMessage(拿到 MessageType 与 metadata 处理)又 implements MediaContent(拿到媒体附件能力)。这是 Java 单继承下的典型折中。

1.4.3 Document:跨模块的通用货币

spring-ai-commons/.../document/Document.java 是整个框架里出场率最高的类。它的字段(Document.java:127-166):

java 复制代码
private final String id;
private final @Nullable String text;
private final @Nullable Media media;
private final Map<String, Object> metadata;
private final @Nullable Double score;         // ← 2.x 新增,相似度分数
// L165-166
@JsonIgnore
private ContentFormatter contentFormatter = DEFAULT_CONTENT_FORMATTER;   // 可变的、非序列化

关于 score 字段 :这是 2.x 的一个真实新增。在 1.x 里,相似度分数只能塞进 metadata(key 为 "distance"),2.x 把它提升为一等公民字段。第 11 章会详细讲各向量库如何计算它,以及为什么DocumentMetadata 枚举里现在只剩一个 DISTANCE("distance")。

互斥约束 (Document.java:224):

java 复制代码
Assert.isTrue(text != null ^ media != null, "exactly one of text or media must be specified");

异或断言------text 和 media 必须恰好一个非空。这意味着:

  • 你不能创建一个既无文本也无媒体的 Document。
  • 你也不能创建一个既有文本又有媒体的 Document(在 2.0.1 的约束下)。

Builder 的 build()(Document.java:526-532):

java 复制代码
if (!StringUtils.hasText(this.id)) {
    var text = this.text != null ? this.text : "";
    this.id = this.idGenerator.generateId(text, this.metadata);
}
return new Document(this.id, this.text, this.media, this.metadata, this.score);

默认 ID 生成器是 RandomIdGenerator(Document.java:415),即 UUID 随机。如果你需要内容寻址 (同一份文本产生同一个 ID,便于幂等写入),要显式换成 JdkSha256HexIdGenerator。这是一个很实用的生产技巧:ETL 重复跑时,用 UUID 会不断产生重复文档,用 SHA-256 则天然幂等。

还有一个序列化细节(Document.java:450):

java 复制代码
@JsonAlias("content")
public Builder text(@Nullable String text)

JSON 里的 "content" 字段会被映射到 text。这是为了兼容某些外部系统(比如某些向量库的 REST API)返回的字段名。

1.4.4 MetadataMode:一个容易被忽略但很重要的枚举

java 复制代码
public enum MetadataMode { ALL, EMBED, INFERENCE, NONE }

它控制"生成文档的字符串表示时,要不要带上 metadata"。为什么需要这个?

因为在 RAG 里,同一份 Document 要被用于两个完全不同的目的:

  1. 送去 embedding ------ 你希望把 metadata 里的标题、作者等语义信息一起编码进去,这样检索更准。→ MetadataMode.EMBED
  2. 拼进 prompt ------ 你希望只给模型看正文,避免 metadata 里的噪音干扰。→ MetadataMode.INFERENCE

DefaultContentFormatter(commons/.../document/DefaultContentFormatter.java:37-51)的默认模板:

java 复制代码
private static final String TEMPLATE_CONTENT_PLACEHOLDER = "{content}";
private static final String TEMPLATE_METADATA_STRING_PLACEHOLDER = "{metadata_string}";
private static final String TEMPLATE_VALUE_PLACEHOLDER = "{value}";
private static final String TEMPLATE_KEY_PLACEHOLDER = "{key}";
private static final String DEFAULT_METADATA_TEMPLATE = "{key}: {value}";
private static final String DEFAULT_METADATA_SEPARATOR = System.lineSeparator();
private static final String DEFAULT_TEXT_TEMPLATE = "{metadata_string}\n\n{content}";

即默认渲染格式是:

makefile 复制代码
key1: value1
key2: value2

正文内容

这个格式会直接影响你喂给模型的东西长什么样。生产上很多人抱怨"RAG 召回的上下文里混进了一堆乱七八糟的字段",根因就在这里------默认 MetadataMode.ALL 把所有 metadata 都拼上了。

1.4.5 TokenCountEstimator:jtokkit 的薄封装

commons/.../tokenizer/JTokkitTokenCountEstimator.java 包装了 jtokkit(com.knuddels:jtokkit)。它在两个地方被用到:

  1. TokenTextSplitter ------ 按 token 数切分文本(第 13 章详讲)。
  2. TokenCountBatchingStrategy ------ 按 token 数聚合批量 embedding 请求(第 13 章详讲)。

有个细节:JdbcChatMemoryRepository 的 AOT hints 里注册了一个叫 KnuddelsRuntimeHints 的类(spring-ai-model/.../aot/KnuddelsRuntimeHints.java),就是为了 jtokkit(公司名 Knuddels)的反射需求。这说明 jtokkit 内部用了反射或资源加载,GraalVM 原生镜像下必须显式注册。


1.5 spring-ai-model 解剖:抽象的心脏

包结构(主干):

erlang 复制代码
org.springframework.ai.model              → Model, StreamingModel, ModelRequest,
                                            ModelResponse, ModelResult, ModelOptions,
                                            NoopApiKey(?), ...
org.springframework.ai.model.tool         → ToolCallingChatOptions, StructuredOutputChatOptions,
                                            ToolCallingManager, DefaultToolCallingManager,
                                            ToolExecutionResult, ToolExecutionEligibilityChecker,
                                            ToolCallLimits, ToolCallLimitBehavior,
                                            ToolCallLimitExceededException
org.springframework.ai.model.transformer  → KeywordMetadataEnricher, SummaryMetadataEnricher
org.springframework.ai.model.observation  → ModelObservationContext, ModelUsageMetricsGenerator,
                                            ErrorLoggingObservationHandler
org.springframework.ai.chat.model         → ChatModel, StreamingChatModel, ChatResponse,
                                            Generation, ToolContext
org.springframework.ai.chat.messages      → Message, AbstractMessage, UserMessage,
                                            SystemMessage, AssistantMessage,
                                            ToolResponseMessage, MessageType, MessageUtils
org.springframework.ai.chat.metadata      → Usage, DefaultUsage, RateLimit, EmptyUsage,
                                            EmptyRateLimit, ChatResponseMetadata,
                                            ChatGenerationMetadata, PromptMetadata
org.springframework.ai.chat.memory        → ChatMemory, ChatMemoryRepository,
                                            MessageWindowChatMemory, InMemoryChatMemoryRepository
org.springframework.ai.chat.observation   → ChatModelObservationContext,
                                            ChatModelObservationDocumentation,
                                            DefaultChatModelObservationConvention,
                                            ChatModelMeterObservationHandler, ...
org.springframework.ai.chat.prompt        → Prompt, ChatOptions, DefaultChatOptions,
                                            DefaultChatOptionsBuilder, PromptTemplate,
                                            SystemPromptTemplate, AssistantPromptTemplate,
                                            ChatPromptTemplate, 各 Action 接口
org.springframework.ai.embedding          → EmbeddingModel, AbstractEmbeddingModel,
                                            EmbeddingRequest, EmbeddingResponse, Embedding,
                                            EmbeddingOptions, BatchingStrategy,
                                            TokenCountBatchingStrategy
org.springframework.ai.image              → ImageModel, ImagePrompt, ImageResponse, ImageOptions
org.springframework.ai.audio.tts          → TextToSpeechModel, StreamingTextToSpeechModel,
                                            TextToSpeechPrompt, TextToSpeechResponse
org.springframework.ai.audio.transcription→ TranscriptionModel, StreamingTranscriptionModel,
                                            AudioTranscriptionPrompt/Response/Options
org.springframework.ai.moderation         → ModerationModel, ModerationPrompt, ModerationResponse
org.springframework.ai.tool               → ToolCallback, ToolCallbackProvider,
                                            StaticToolCallbackProvider
org.springframework.ai.tool.annotation    → Tool, ToolParam
org.springframework.ai.tool.definition    → ToolDefinition, DefaultToolDefinition
org.springframework.ai.tool.execution     → ToolCallResultConverter, DefaultToolCallResultConverter,
                                            ToolExecutionException, ToolExecutionExceptionProcessor
org.springframework.ai.tool.function      → FunctionToolCallback
org.springframework.ai.tool.method        → MethodToolCallback, MethodToolCallbackProvider
org.springframework.ai.tool.metadata      → ToolMetadata, DefaultToolMetadata
org.springframework.ai.tool.resolution    → ToolCallbackResolver, DelegatingToolCallbackResolver,
                                            StaticToolCallbackResolver, TypeResolverHelper
org.springframework.ai.tool.support       → ToolDefinitions, ToolUtils
org.springframework.ai.tool.augment       → AugmentedToolCallbackProvider
org.springframework.ai.tool.observation   → DefaultToolCallingObservationConvention, ...
org.springframework.ai.converter          → StructuredOutputConverter, BeanOutputConverter,
                                            MapOutputConverter, ListOutputConverter,
                                            各 Cleaner
org.springframework.ai.util.json.schema   → JsonSchemaGenerator, SpringAiSchemaModule,
                                            AbstractSpringAiSchemaModule, JsonSchemaUtils, SchemaType
org.springframework.ai.support            → ToolCallbacks
org.springframework.ai.aot                → SpringAiCoreRuntimeHints, AiRuntimeHints,
                                            ToolRuntimeHints, ToolBeanRegistrationAotProcessor

1.5.1 泛型根接口:整个框架的 L0

spring-ai-model/src/main/java/org/springframework/ai/model/Model.java:31-40:

java 复制代码
public interface Model<TReq extends ModelRequest<?>, TRes extends ModelResponse<?>> {

	/**
	 * Executes a method call to the AI model.
	 * @param request the request object to be sent to the AI model
	 * @return the response from the AI model
	 */
	TRes call(TReq request);

}

StreamingModel.java:34-43:

java 复制代码
public interface StreamingModel<TReq extends ModelRequest<?>, TResChunk extends ModelResponse<?>> {

	/**
	 * Executes a method call to the AI model.
	 * @param request the request object to be sent to the AI model
	 * @return the streaming response from the AI model
	 */
	Flux<TResChunk> stream(TReq request);

}

配套的四个支撑接口:

ModelRequest.java:32-46:

java 复制代码
public interface ModelRequest<T> {

	T getInstructions(); // required input

	@Nullable ModelOptions getOptions();

}

ModelResponse.java:34-54:

java 复制代码
public interface ModelResponse<T extends ModelResult<?>> {

	@Nullable T getResult();

	List<T> getResults();

	ResponseMetadata getMetadata();

}

ModelResult.java:29-43:

java 复制代码
public interface ModelResult<T> {

	T getOutput();

	ResultMetadata getMetadata();

}

ModelOptions.java:29-31 ------纯 marker 接口,一个方法都没有:

java 复制代码
public interface ModelOptions {

}

这套泛型设计值得停下来欣赏一下。

它用四个接口(Model / StreamingModel / ModelRequest / ModelResponse / ModelResult / ModelOptions)统一了所有 AI 模型的调用形态,不管你是聊天、嵌入、生图、语音合成还是内容审核:

模型类型 Request Response Result
ChatModel Prompt ChatResponse Generation(output = AssistantMessage)
EmbeddingModel EmbeddingRequest EmbeddingResponse Embedding(output = float[])
ImageModel ImagePrompt ImageResponse ImageGeneration(output = Image)
TextToSpeechModel TextToSpeechPrompt TextToSpeechResponse ModelResult<byte[]>
TranscriptionModel AudioTranscriptionPrompt AudioTranscriptionResponse AudioTranscription(output = String)
ModerationModel ModerationPrompt ModerationResponse Moderation(output = ModerationResult)

好处是什么?可观测性代码只需要写一遍 。ModelObservationContext<REQ, RES> 是泛型的,ChatModelObservationContext、EmbeddingModelObservationContext、ImageModelObservationContext 都继承它,于是 Micrometer 的埋点逻辑完全复用(第 17 章详讲)。

代价是什么?抽象泄漏 。比如 ModelResponse.getResult() 返回 @Nullable T,意味着你必须处理"响应为空"的情况。再比如 ModelRequest.getInstructions() 这个方法名对聊天模型(List<Message>)、嵌入模型(List<String>)都还算贴切,但对图像模型(ImageMessage)就有点勉强了。这是所有"统一抽象"框架都要付的税。

1.5.2 spring-ai-model 里一个容易找错位置的包

注意 org.springframework.ai.model.transformer 与 org.springframework.ai.transformer 的区别:

  • org.springframework.ai.transformer(在 commons )------ 放不需要模型的 transformer(ContentFormatTransformer、TokenTextSplitter)。
  • org.springframework.ai.model.transformer(在 model )------ 放需要 ChatModel 的 transformer(KeywordMetadataEnricher、SummaryMetadataEnricher)。

这不是随意的:KeywordMetadataEnricher 要调 LLM 抽取关键词,所以它必须依赖 ChatModel,就必须住在能看见 ChatModel 的模块里。第 13 章会详细讲这两个 enricher。

同样的逻辑也解释了为什么 DocumentReader / DocumentTransformer / DocumentWriter 三个接口 在 commons(因为它们不需要模型),而具体的 PDF/Tika/Jsoup/Markdown reader 在独立的 document-readers/ 模块(因为它们各自拖着 PDFBox / Tika / Jsoup 这些重依赖)。



1.6 spring-ai-client-chat 解剖:2.x 独立出来的新模块

spring-ai-client-chat/pom.xml:11,13:

xml 复制代码
<artifactId>spring-ai-client-chat</artifactId>
<name>Spring AI Chat Client</name>

这是 2.x 新增的独立 artifact 。在 1.x 里 ChatClient 跟模型抽象同属一个 artifact;2.x 把它切成独立模块,理由是:你可以只用 ChatModel 而完全不引入 ChatClient,反之亦然。

包结构:

csharp 复制代码
org.springframework.ai.chat.client
 ├── ChatClient.java                  ← 主接口(约 593 行)
 ├── ChatClientRequest.java           ← record
 ├── ChatClientResponse.java          ← record
 ├── ChatClientAttributes.java        ← 预定义 context key 枚举
 ├── AdvisorParams.java               ← 预定义 advisor 参数
 ├── DefaultChatClient.java           ← 主实现(约 1250+ 行)
 ├── DefaultChatClientBuilder.java
 ├── DefaultChatClientUtils.java
 ├── ChatClientExtensions.kt          ← Kotlin 扩展(唯一一个 Kotlin 生产文件)
 └── advisor/
      ├── api/                        ← 12 个接口(契约层)
      │    ├── Advisor.java
      │    ├── AdvisorChain.java
      │    ├── BaseAdvisor.java
      │    ├── BaseAdvisorChain.java
      │    ├── BaseChatMemoryAdvisor.java
      │    ├── CallAdvisor.java
      │    ├── CallAdvisorChain.java
      │    ├── MemoryAdvisor.java
      │    ├── StreamAdvisor.java
      │    ├── StreamAdvisorChain.java
      │    ├── ToolAdvisor.java
      │    └── package-info.java
      ├── DefaultAroundAdvisorChain.java
      ├── ChatModelCallAdvisor.java
      ├── ChatModelStreamAdvisor.java
      ├── ToolCallingAdvisor.java       ← 645 行,2.0 新增
      ├── ToolCallAdvisor.java          ← 63 行,deprecated 兼容壳
      ├── MessageChatMemoryAdvisor.java
      ├── SimpleLoggerAdvisor.java
      ├── SafeGuardAdvisor.java
      ├── StructuredOutputValidationAdvisor.java
      ├── UsageAccumulator.java
      ├── AdvisorUtils.java
      └── observation/

注意目录划分的语义 :advisor/api/ 里放的是契约 (你可以拿来扩展的接口),advisor/ 根下放的是实现 (框架内置的)。这是一个非常清晰的"SPI vs 内置实现"分层,写自定义 advisor 时你只需要看 api/ 包。

advisor/api/ 下正好 12 个文件,这个数字不是巧合------它是一套最小完备的拦截契约:

scss 复制代码
Advisor (extends Ordered)
 ├── CallAdvisor      → adviseCall(request, chain)
 ├── StreamAdvisor    → adviseStream(request, chain)
 ├── BaseAdvisor      → before/after 模板(同时是 Call + Stream)
 ├── MemoryAdvisor    → marker("我管理记忆生命周期")
 ├── ToolAdvisor      → marker("我管理工具调用生命周期")
 └── BaseChatMemoryAdvisor → BaseAdvisor + MemoryAdvisor

AdvisorChain (getObservationRegistry)
 ├── CallAdvisorChain   → nextCall / getCallAdvisors / copy(after)
 ├── StreamAdvisorChain → nextStream / getStreamAdvisors / copy(after)
 └── BaseAdvisorChain   → 两者合并 + builder()

第 05 章会把这套契约逐行拆开。

1.6.1 spring-ai-client-chat 的 Kotlin 支持

整个模块只有一个 Kotlin 生产文件,spring-ai-client-chat/src/main/kotlin/org/springframework/ai/chat/client/ChatClientExtensions.kt,全文 33 行,有效代码只有 6 行:

kotlin 复制代码
package org.springframework.ai.chat.client

import org.springframework.ai.chat.model.ChatResponse
import org.springframework.core.ParameterizedTypeReference

/**
 * Extensions for [ChatClient] providing a reified generic adapters for `entity` and `responseEntity`
 *
 * @author Josh Long
 */

inline fun <reified T : Any> ChatClient.CallResponseSpec.entity(): T =
	entity(object : ParameterizedTypeReference<T>() {}) as T

inline fun <reified T : Any> ChatClient.CallResponseSpec.responseEntity(): ResponseEntity<ChatResponse, T> =
	responseEntity(object : ParameterizedTypeReference<T>() {})

它解决的是 Kotlin 的泛型擦除痛点。Java 侧你得写:

java 复制代码
.entity(new ParameterizedTypeReference<List<ActorFilms>>() {})

Kotlin 侧有了这个扩展就能写:

kotlin 复制代码
.entity<List<ActorFilms>>()

为什么根 POM 里 kotlin.version=2.3.20 且 starter 里带 kotlin-reflect :就是为了支持这类 reified 泛型扩展。如果你的项目是纯 Java 且想瘦身,理论上可以排除 kotlin-reflect,但 starter 默认带上了。


1.7 spring-ai-vector-store 与 spring-ai-rag

1.7.1 向量存储核心模块

spring-ai-vector-store/ 下的关键文件:

css 复制代码
org.springframework.ai.vectorstore
 ├── VectorStore.java                  ← 主接口
 ├── VectorStoreRetriever.java         ← 只读检索视图(2.x 新拆分)
 ├── SimpleVectorStore.java            ← 内存实现
 ├── SearchRequest.java
 ├── AbstractVectorStoreBuilder.java
 ├── SpringAIVectorStoreTypes.java     ← 类型选择常量
 ├── filter/
 │    ├── Filter.java                  ← ExpressionType 枚举 + Key/Value/Expression/Group
 │    ├── FilterExpressionBuilder.java ← DSL
 │    ├── FilterExpressionTextParser.java ← ANTLR 文本解析
 │    ├── FilterHelper.java
 │    └── converter/
 │         ├── AbstractFilterExpressionConverter.java
 │         └── PrintFilterExpressionConverter.java(调试用)
 └── observation/
      ├── AbstractObservationVectorStore.java
      ├── VectorStoreObservationContext.java
      ├── VectorStoreObservationDocumentation.java
      └── DefaultVectorStoreObservationConvention.java

外加一个 ANTLR 语法文件:

css 复制代码
spring-ai-vector-store/src/main/antlr4/org/springframework/ai/vectorstore/filter/antlr4/Filters.g4

ANTLR4 在这里的出现值得注意 :Spring AI 用 ANTLR 定义了一套跨数据库的可移植过滤语法 (类似 SQL 的 WHERE 子句),然后在运行时编译成各向量库的原生查询语法。这是 Spring AI 官方文档里自豪地称为 "a novel SQL-like metadata filter API" 的东西(README.md:49)。第 11 章会把这套语法完整讲透。

1.7.2 一个 2.x 的新拆分:VectorStoreRetriever

spring-ai-vector-store/.../VectorStoreRetriever.java:35-58:

java 复制代码
@FunctionalInterface
public interface VectorStoreRetriever {
	List<Document> similaritySearch(SearchRequest request);
	default List<Document> similaritySearch(String query) {
		return this.similaritySearch(SearchRequest.builder().query(query).build());
	}
}

而 VectorStore 继承了它:

java 复制代码
public interface VectorStore extends DocumentWriter, VectorStoreRetriever {

为什么要拆? 因为有些场景你只想"读",不想暴露"写"。比如一个只读的检索服务、一个把检索能力注入给某个 agent 的接口。有了 VectorStoreRetriever,你可以把类型声明成只读视图,编译期就杜绝误写。

同时注意 VectorStore extends DocumentWriter------所以 vectorStore.write(docs) 等价于 vectorStore.add(docs)(VectorStore.java:53-56 的桥接默认方法)。不存在独立的 VectorStoreWriter 类,这是很多人(包括我一开始)会找错的地方。

1.7.3 RAG 模块:只有 15 个 java 文件

spring-ai-rag/src/main/java/org/springframework/ai/rag/:

perl 复制代码
org.springframework.ai.rag
 ├── Query.java                       ← record(text, history, context)
 ├── package-info.java                ← 引用 arXiv:2407.21059 / 2312.10997 / 2410.20878
 ├── util/PromptAssert.java
 ├── advisor/
 │    └── RetrievalAugmentationAdvisor.java
 ├── preretrieval/
 │    ├── package-info.java
 │    ├── query.transformation/
 │    │    ├── QueryTransformer.java
 │    │    ├── CompressionQueryTransformer.java
 │    │    ├── RewriteQueryTransformer.java
 │    │    └── TranslationQueryTransformer.java
 │    └── query.expansion/
 │         ├── QueryExpander.java
 │         └── MultiQueryExpander.java
 ├── retrieval/
 │    ├── search/
 │    │    ├── DocumentRetriever.java
 │    │    └── VectorStoreDocumentRetriever.java
 │    └── join/
 │         ├── DocumentJoiner.java
 │         └── ConcatenationDocumentJoiner.java
 ├── postretrieval/
 │    ├── package-info.java
 │    └── document/
 │         └── DocumentPostProcessor.java    ← 只有接口,无实现!
 └── generation/
      ├── package-info.java
      └── augmentation/
           ├── QueryAugmenter.java
           └── ContextualQueryAugmenter.java

package-info.java:17-27 的注释点明了设计来源:"inspired by the Modular RAG Architecture",并引用了三篇 arXiv 论文(2407.21059 是 Modular RAG 综述)。Spring AI 的 RAG 模块不是拍脑袋设计的,它是对学术论文的直接工程化。第 14 章会详细讲。

两个必须提前澄清的不存在:

  1. DocumentRanker 在 2.0.1 中不存在。 全库 grep 零命中。排序职责下沉到了 DocumentJoiner(ConcatenationDocumentJoiner 按 score 降序排)。如果你在别处看到 documentRanker(...) 的用法,那是过时的信息。
  2. DocumentPostProcessor 只有接口,没有内置实现。 官方文档提到的 "lost-in-the-middle" 问题,框架给了你扩展点但没给默认解法。这意味着你必须自己写。

1.8 BOM:168 个受管 artifact

spring-ai-bom/pom.xml 是唯一文件,1339 行。它的特点:

xml 复制代码
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<packaging>pom</packaging>

没有 <parent> ------ BOM 不继承父 POM,是完全独立的。所有受管依赖的 version 都是 ${project.version},即自版本。这是一个单 groupId、自版本的 BOM。

它用 flatten-maven-plugin(行 1234-1266,flattenMode=ossrh)产出扁平化发布 POM:保留 dependencyManagement,移除 distributionManagement 与 repositories。这是 Maven Central 发布的标准做法。

1.8.1 分组清单(按 BOM 注释分区)

A. 核心 / 通用(行 66-160)

sql 复制代码
spring-ai-commons
spring-ai-template-st
spring-ai-model
spring-ai-vector-store
spring-ai-rag
spring-ai-vector-store-advisor
spring-ai-tool-search-advisor
spring-ai-tool-search-tool-advisor
spring-ai-tool-search-tool
spring-ai-tool-search-tool-lucene
spring-ai-tool-search-tool-vectorstore
spring-ai-retry
spring-ai-client-chat

注意 spring-ai-tool-search-tool 被拆成了三个:-tool(核心)、-lucene(Lucene 索引实现)、-vectorstore(向量库索引实现)。这是把可选依赖切出去的典型手法。

B. MCP(行 162-180)

复制代码
spring-ai-mcp
mcp-spring-webflux
mcp-spring-webmvc

注意 mcp-spring-webflux / mcp-spring-webmvc 没有 spring-ai- 前缀 ,且 groupId 在 2.x 已从 io.modelcontextprotocol.sdk 改为 org.springframework.ai(upgrade-notes.adoc:1768-1878)。

C. Document Readers(行 182-206)

javascript 复制代码
spring-ai-jsoup-document-reader
spring-ai-markdown-document-reader
spring-ai-pdf-document-reader
spring-ai-tika-document-reader

D. Chat Memory 实现(行 208-238)

复制代码
spring-ai-model-chat-memory-repository-cassandra
spring-ai-model-chat-memory-repository-jdbc
spring-ai-model-chat-memory-repository-mongodb
spring-ai-model-chat-memory-repository-neo4j
spring-ai-model-chat-memory-repository-redis

E. 模型实现(行 240-331)

arduino 复制代码
spring-ai-anthropic
spring-ai-bedrock
spring-ai-bedrock-converse
spring-ai-elevenlabs          ← 唯一带 <optional>true</optional> 的条目(行 264)
spring-ai-google-genai
spring-ai-google-genai-embedding
spring-ai-google-genai-image
spring-ai-mistral-ai
spring-ai-ollama
spring-ai-openai
spring-ai-postgresml
spring-ai-stability-ai
spring-ai-transformers
spring-ai-vertex-ai-embedding
spring-ai-deepseek

为什么 spring-ai-elevenlabs 是唯一的 optional? 因为 ElevenLabs 是 TTS 服务,它的依赖会带入一堆音频相关的传递依赖。把它标 optional,意味着只有当你的应用显式声明时才会引入。这是 BOM 作者对依赖污染的控制。

F. Vector Stores(行 333-463) ------ 22 个,见 1.3.3。

G. 自动配置(行 465-813) ------ 数量最多的一组。其中几个值得注意:

  • spring-ai-autoconfigure-model-chat-client ------ ChatClient 的自动装配
  • spring-ai-autoconfigure-model-chat-memory ------ 通用 chat memory
  • spring-ai-autoconfigure-model-chat-memory-redis ------ 行 530 有明确注释标注为 deprecated ,替代品是 -chat-memory-repository-redis
  • spring-ai-autoconfigure-model-chat-observation / -embedding-observation / -image-observation ------ 观测能力被拆成独立 artifact
  • spring-ai-autoconfigure-mcp-client-{common,httpclient,webflux}
  • spring-ai-autoconfigure-mcp-server-{common,webmvc,webflux}

H. Starters(行 815-1119)

arduino 复制代码
vector-store starters 22 个(含 spring-ai-starter-vector-store-aws-opensearch)
model starters 16 个
MCP starters 5 个 + spring-ai-mcp-annotations
chat memory starters 6 个
spring-ai-starter-tool-search-advisor

I. 工具类(行 1121-1139)

复制代码
spring-ai-test
spring-ai-spring-boot-docker-compose
spring-ai-spring-boot-testcontainers

1.8.2 BOM 里没有但文档里有的东西(重要提醒)

spring-ai-docs/.../nav.adoc 里列了不少页面,比如 groq-chat、perplexity-chat、minimax-chat、nvidia-chat、oci-genai-chat、azure-openai-chat、dmr-chat。但 BOM 和 starters 里并没有对应的 artifact。

原因有两类:

  • spring-ai-oci-genai 在 2.0 被移除,迁到了 Oracle 自己的仓库(upgrade-notes.adoc:1489-1491)。
  • spring-ai-azure-openai / spring-ai-openai-sdk 模块在 2.0 被删除(upgrade-notes.adoc:1477-1487),Azure OpenAI 现在通过 spring-ai-openai 的 Microsoft Foundry 分支支持。

结论:文档覆盖 ≠ 官方支持。 选模块时以 BOM 为准。



1.9 Starters 的三种组合模式

starters/ 下 48 个目录。看起来很多,但归纳起来只有三种配方。

模式一:模型 starter("全家桶"型)

starters/spring-ai-starter-model-openai/pom.xml:16-63:

scss 复制代码
spring-boot-starter-webclient          (Boot 托管)
spring-boot-starter-restclient         (Boot 托管)
spring-ai-autoconfigure-model-openai
spring-ai-openai
spring-ai-client-chat                  ← 把 ChatClient 一并带入
spring-ai-autoconfigure-model-chat-client
spring-ai-autoconfigure-model-chat-memory
kotlin-reflect

这是一个值得注意的设计决策 :模型和 ChatClient 两类 starter 在同一个 POM 里合流了。你引入 spring-ai-starter-model-openai,就直接拿到了 ChatClient.Builder。

好处:开箱即用,一行依赖就能写第一个 AI 调用。 代价:如果你只想用底层 ChatModel 而不要 ChatClient(比如你要在响应式管道里精细控制),你仍然会把 spring-ai-client-chat 拖进来。

同时它带上了 spring-ai-autoconfigure-model-chat-memory,意味着 InMemory 的 chat memory 是默认可用的(不需要额外依赖,但重启即丢)。

模式二:基础设施 starter("聚焦"型)

starters/spring-ai-starter-model-chat-memory-repository-jdbc/pom.xml:16-41:

复制代码
spring-boot-starter-jdbc
spring-ai-autoconfigure-model-chat-memory
spring-ai-autoconfigure-model-chat-memory-repository-jdbc
spring-ai-model-chat-memory-repository-jdbc

配方:基础设施 starter + 通用能力 autoconfigure + 专用 autoconfigure + 底层实现。

注意这里没有 spring-ai-client-chat------它假设你已经通过模型 starter 拿到了。这类 starter 是"增量"而非"全家桶"。

模式三:向量库 starter("观测显式化"型)

starters/spring-ai-starter-vector-store-pgvector/pom.xml:16-36:

arduino 复制代码
spring-boot-starter-jdbc
spring-ai-autoconfigure-vector-store-pgvector
spring-ai-autoconfigure-vector-store-observation   ← 观测能力是显式独立依赖
spring-ai-pgvector-store

关键观察点 :观测能力被拆成了独立 artifact(spring-ai-autoconfigure-vector-store-observation),每个向量库 starter 都显式声明它。

chat 侧同理拆出三个:spring-ai-autoconfigure-model-chat-observation、-embedding-observation、-image-observation(BOM 行 537-559)。

为什么这么做? 因为 Micrometer Observation 是可选能力。如果你的应用不接入任何监控系统,你不需要这些自动配置类。把它们独立出来,让你可以精确控制 classpath。这是 Spring 生态一贯的"可选能力独立成模块"哲学。


1.10 auto-configurations/ 的组织方式

auto-configurations/ 下有 65 个文件(55 个 pom.xml),按类别分目录:

scss 复制代码
auto-configurations/
 ├── advisors/
 │    └── spring-ai-autoconfigure-tool-search-advisor
 ├── mcp/
 │    ├── spring-ai-autoconfigure-mcp-client-common
 │    ├── spring-ai-autoconfigure-mcp-client-httpclient
 │    ├── spring-ai-autoconfigure-mcp-client-webflux
 │    ├── spring-ai-autoconfigure-mcp-server-common
 │    ├── spring-ai-autoconfigure-mcp-server-webmvc
 │    └── spring-ai-autoconfigure-mcp-server-webflux
 ├── models/
 │    ├── chat/
 │    │    ├── client/spring-ai-autoconfigure-model-chat-client
 │    │    ├── memory/
 │    │    └── observation/
 │    ├── spring-ai-autoconfigure-model-anthropic
 │    ├── spring-ai-autoconfigure-model-openai
 │    ├── spring-ai-autoconfigure-model-ollama
 │    ├── ... (13 个模型)
 ├── vector-stores/
 │    └── ... (21 个)
 └── spring-ai-autoconfigure-retry
     spring-ai-autoconfigure-model-tool

1.10.1 注册机制:Boot 4 的新位置

注意注册文件的路径是:

bash 复制代码
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

而不是 1.x 的 META-INF/spring.factories。这是 Spring Boot 3.x 起推行的新约定,Boot 4 里 spring.factories 对自动配置已彻底失效。

但不是所有东西都迁了 。看 spring-ai-spring-boot-docker-compose/src/main/resources/META-INF/spring.factories:

ini 复制代码
org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
org.springframework.ai.docker.compose.service.connection.chroma.ChromaDockerComposeConnectionDetailsFactory,\
...

ConnectionDetailsFactory 仍然用 spring.factories------因为它是 Boot 的 Service Connection SPI,不是自动配置。这个区分要记住,否则你会找不到东西。

1.10.2 一个典型的自动配置类长什么样

以 ChatClientAutoConfiguration 为例(完整分析在第 18 章,这里只看结构):

java 复制代码
@AutoConfiguration(after = ToolCallingAutoConfiguration.class)
@ConditionalOnClass(ChatClient.class)
@EnableConfigurationProperties(ChatClientBuilderProperties.class)
@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX, name = "enabled",
		havingValue = "true", matchIfMissing = true)
public class ChatClientAutoConfiguration {

四个注解各有其职:

  1. @AutoConfiguration(after = ToolCallingAutoConfiguration.class) ------ 声明顺序依赖:ChatClient 装配必须排在工具调用装配之后,因为它要消费 ToolCallingManager bean。
  2. @ConditionalOnClass(ChatClient.class) ------ classpath 守卫。
  3. @EnableConfigurationProperties(...) ------ 绑定配置属性。
  4. @ConditionalOnProperty(..., matchIfMissing = true) ------ 默认开启,可关闭。

其中 matchIfMissing = true 是 Spring AI 的普遍风格:几乎所有能力默认开启,你可以显式关掉。这与 Spring 生态"默认保守"的传统略有不同,理由是 AI 应用通常希望开箱即用。


1.11 构建体系:父 POM 里装了什么

根 pom.xml 42582 字节,是个大文件。挑几个对理解项目有影响的插件:

插件 作用 对你的影响
maven-site-plugin 站点生成 无
error-prone + NullAway 编译期空指针静态检查 你写的代码必须显式标注 @Nullable
flatten-maven-plugin 扁平化发布 POM 无
spring-javaformat-maven-plugin 代码格式化(Tab/LF/90列Javadoc) ./mvnw process-sources 会自动格式化
maven-checkstyle-plugin + spring-javaformat-checkstyle 风格校验 构建会失败如果不合规
spring-boot-maven-plugin Boot 打包 无
rest-assured-bom 测试 无

1.11.1 NullAway:为什么会塑造整个代码库

NullAway 是 Uber 开源的 ErrorProne 插件,它要求:

  • 被 @NullMarked 标记的包内,所有引用类型默认非空。
  • 可空的必须显式写 @Nullable(jspecify 的 org.jspecify.annotations.Nullable)。
  • 违反则编译失败。

这就是你在 ChatOptions 里看到这样的原因:

java 复制代码
@Nullable String getModel();
@Nullable Double getFrequencyPenalty();
@Nullable Integer getMaxTokens();

而不是 1.x 的:

java 复制代码
String getModel();           // 返回 "" 表示未设置
double getTemperature();     // 返回 0.7 表示默认值

这个变化是 2.x 最重要的语义变化之一,我在第 03 章会用整节讲它。这里先给出一个直觉:

2.x 把"未设置"和"设置为默认值"这两件事在类型层面区分开了 。1.x 里 getTemperature() 返回 0.7 你不知道这是用户设的还是框架默认的;2.x 里返回 null 就是没设,返回 0.7 就是用户明确设了 0.7。

这对自动装配至关重要:Spring Boot 要决定"配置文件里的值是否覆盖代码里的值",如果两者都用"默认值"表示"未设置",就无法区分了。

1.11.2 构建命令全集(来自 CONTRIBUTING.md:206-274)

用途 命令
全量构建 ./mvnw clean package
仅格式化 ./mvnw process-sources
关闭构建缓存 ./mvnw -Dmaven.build.cache.enabled=false clean package
清缓存 rm -rf ~/.m2/build-cache/
单模块集成测试 ./mvnw -am -pl spring-ai-spring-boot-testcontainers -Pintegration-tests verify
单模块 + 重试 2 次 ./mvnw -am -pl vector-stores/spring-ai-pgvector-store -Pintegration-tests -Dfailsafe.failIfNoSpecifiedTests=false -Dfailsafe.rerunFailingTestsCount=2 -Dit.test=PgVectorStoreIT verify
快速集成测试 -Pci-fast-integration-tests
Javadoc ./mvnw javadoc:javadoc
文档 ./mvnw -pl spring-ai-docs antora

注意 -am(also make)参数:它会同时构建被依赖的模块。测试单个向量库时如果不加 -am,会因为找不到 spring-ai-vector-store 的本地快照而失败。

1.11.3 代码风格(会被 checkstyle 拦下的点)

CONTRIBUTING.md:124-157:

markdown 复制代码
* UTF-8 encoding
* Tabs, not spaces
* Unix (LF), not DOS (CRLF) line endings
* Eliminate all trailing whitespace
* Wrap Javadoc at 90 characters
* Aim to wrap code at 120 characters, but favor readability over wrapping

import 顺序(L137-148):

markdown 复制代码
* import `java.*`
* blank line
* import `javax.*`
* import `jakarta.*`
* blank line
* import all other imports
* blank line
* import `org.springframework.*`
* blank line
* import static all other imports

注意 org.springframework.* 被单独放在"其他 imports"之后------这是 Spring 项目的惯例,跟普通 Java 项目的字母序排列不同。你在源码里会看到大量这样的排布。

Commit 规范(L64-70):

markdown 复制代码
A commit message should include a short title (if possible less than 50 characters) starting
with an uppercase verb. The title should *not* include prefixes like `fix:`, `feat:` or
`docs:` or the issue/PR number.

- ✅ Add Contribution Guidelines
- ❌ GH-123: Add Contribution Guidelines
- ❌ docs: Add Contribution Guidelines
- ❌ Add Contribution Guidelines (#123)

这与 Conventional Commits 完全相反,是 Spring 项目的传统。另外所有 commit 必须带 DCO 签名(L77-78)。

最后一条很有意思(L11-12):

csharp 复制代码
We accept contributions created with the help of AI coding agents, but they must be carefully
reviewed by a human who remains accountable for the quality of the contribution.
Contributions submitted by GitHub accounts controlled by autonomous AI bots are forbidden and
will result in permanent bans.

1.12 三条设计信条

读到这里,我们可以抽象出 Spring AI 2.x 的三条核心设计信条。理解它们,比记住任何单个 API 都重要。

信条一:可移植性优先于 표현力

Spring AI 提供的是跨供应商的可移植 API 。ChatModel 接口只有一个抽象方法 ChatResponse call(Prompt),ChatOptions 只有 7 个可移植参数(model / frequencyPenalty / maxTokens / presencePenalty / stopSequences / temperature / topK / topP)。

代价是:供应商特有能力无法统一表达 。OpenAI 的 reasoningEffort、Anthropic 的 thinking、Ollama 的 numCtx 都不在可移植 API 里。

Spring AI 的解法是分层:

scss 复制代码
ChatOptions (可移植,7 个参数)
 ├── ToolCallingChatOptions        (+ toolCallbacks, toolContext)
 ├── StructuredOutputChatOptions   (+ outputSchema)
 └── OpenAiChatOptions / AnthropicChatOptions / ... (供应商全部参数,实现上面几个接口)

于是你可以:

java 复制代码
// 可移植写法
chatClient.prompt().user("hi").options(ChatOptions.builder().temperature(0.7).build()).call();

// 供应商特化写法
chatClient.prompt().user("hi")
    .options(OpenAiChatOptions.builder().temperature(0.7).reasoningEffort("high").build())
    .call();

注意 ChatClient 的 .options() 签名 (ChatClient.java:404):

java 复制代码
<B extends ChatOptions.Builder<?>> ChatClientRequestSpec options(B customizer);

它接受任意 ChatOptions.Builder 子类,所以两种写法都能编译。而 ChatModel 层不合并 options (upgrade-notes.adoc:1463 明确说明),这是第 04 章的重点。

信条二:拦截与编排优于继承

Spring AI 2.x 把越来越多的能力从"模型内部"搬到了"Advisor 层"。最典型的例子是工具调用:

  • 1.x:每个 ChatModel 实现内部有一个工具执行循环,通过 internalToolExecutionEnabled 开关控制。
  • 2.x:删除了所有 ChatModel 内部的工具执行循环 ,改为 ChatClient 自动注册一个 ToolCallingAdvisor,由它在 advisor 链上递归执行工具循环。

upgrade-notes.adoc:539 原文:

javascript 复制代码
In 2.0, this internal loop has been removed from every `ChatModel` implementation (OpenAI,
Anthropic, Ollama, DeepSeek, Bedrock, MiniMax, Mistral, Google GenAI). `ChatModel.call(prompt)`
and `ChatModel.stream(prompt)` now return the model's raw response --- tool calls are not
executed automatically.

这个变化的深远影响:

  1. 工具循环变成了一个可被其他 advisor 拦截的普通环节。你可以在每一次工具调用前后插入日志、限流、审计。
  2. 递归 advisor 机制(copy(after))被发明出来支撑它。
  3. token 用量必须跨轮次累计,于是有了 UsageAccumulator。
  4. 工具调用次数必须有上限(否则死循环),于是有了 40/150 的默认限制。

这一串连锁反应会在第 08 章完整展开。

信条三:观测是一等公民,不是事后补丁

Spring AI 从 1.0 起就把 Micrometer Observation 埋进了每一个关键路径:

  • ChatModel.call() / stream() → gen_ai.client.operation
  • EmbeddingModel.call() → gen_ai.client.operation(靠 tag 区分)
  • ImageModel.call() → gen_ai.client.operation
  • VectorStore.add() / delete() / similaritySearch() → db.vector.client.operation
  • ChatClient → spring.ai.chat.client
  • 每个 Advisor → spring.ai.advisor
  • 每次工具调用 → spring.ai.tool

且严格遵循一条规则(observability/index.adoc:9):

perl 复制代码
Low-cardinality keys will be added to metrics and traces, while high-cardinality keys will
only be added to traces.

为什么这条规则重要? 因为高基数标签(比如完整的 prompt 文本、每个请求的 ID)如果进了 Prometheus 指标,会导致时间序列爆炸,把监控系统打垮。Spring AI 在类型层面就把它约束住了:KeyValues.getLowCardinalityKeyValues() 和 getHighCardinalityKeyValues() 是两个方法,指标生成器只用前者。

第 17 章会完整拆解这套体系。


1.13 一个贯穿全书的提醒:源码与文档的不同步

在探查过程中,我发现了若干处文档与源码不一致的地方。列举几个,帮你在阅读官方文档时保持警惕:

  1. upgrade-notes.adoc 有三处悬空 xref :<<run-all-m3-migrations>> 在 L1766 / L1911 / L1993 被引用,但全文 3234 行内没有该锚点的定义。即 Umbrella recipe 小节缺失。
  2. memory-repositories/.../README.md 正文只有一行,链接指向 .../api/chatmemory.html,而实际页面文件是 chat-memory.adoc。
  3. nav.adoc 列了无对应 starter 的页面(groq / perplexity / minimax / nvidia / oci-genai / azure-openai / dmr)。
  4. 官方文档仍用 "Function-calling framework" 描述 spring-ai-model (upgrade-notes.adoc:2863-2869),但 2.x 早已改叫 Tool Calling。
  5. spring-ai-docs 里 api/generic-model.adoc 在 nav 中被注释掉 (nav 行 68:// ** xref:api/generic-model.adoc[]),api/mcp/mcp-helpers.adoc 同样被注释(nav 行 83)。

本系列的立场:一切以源码为准。凡是文档里有但源码里没有的,我会明确标注。


1.14 本章小结

我们在这一章建立了五个认知锚点:

  1. 版本基线 :Spring AI 2.0.1 = Spring Boot 4.1.1 + Java 17 + MCP SDK 2.0.0 + OpenAI SDK 4.49.0。构建 JDK 需支持 -XDaddTypeAnnotationsToSymbol。
  2. 模块拓扑 :spring-ai-core 已拆为 commons / model / client-chat / vector-store / rag 五块,依赖方向严格单向。
  3. 依赖分层 :client-chat 与 vector-store 互不可见,这才逼出了独立的 spring-ai-rag 与 advisors/spring-ai-vector-store-advisor。
  4. 空安全是编译期契约 :jspecify + NullAway 塑造了全 @Nullable 的 ChatOptions,这个设计直接决定了自动装配的合并语义。
  5. 三条设计信条:可移植性优先、拦截优于继承、观测一等公民。

下一章我们进入 spring-ai-model 的核心抽象层,从 ChatModel 这个只有 68 行却信息量巨大的接口开始,逐行拆解。


1.15 附录 A:两个"小"模块的深挖

spring-ai-retry 和 spring-ai-template-st 都是只有三四个源文件的模块,但它们各自藏着 2.x 的一处重大变化和一个值得学习的实现。

1.15.1 spring-ai-retry:从 Spring Retry 到 Spring Framework core

只有 4 个源文件:

css 复制代码
spring-ai-retry/src/main/java/org/springframework/ai/retry/
 ├── NonTransientAiException.java
 ├── RetryUtils.java
 ├── TransientAiException.java
 └── package-info.java

2.x 的重大变化在 import 里 (RetryUtils.java:29-33):

java 复制代码
import org.springframework.core.retry.RetryException;
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.core.retry.Retryable;

1.x 用的是 org.springframework.retry.support.RetryTemplate(Spring Retry 项目)。2.x 换成了 Spring Framework 核心自带的 org.springframework.core.retry.*。这是 Spring Framework 7 把重试能力收进核心的结果------Spring Retry 这个独立项目对新版本 Spring 用户来说不再是必需项。

这个变化的实际影响 :如果你在自己的代码里声明了 org.springframework.retry.support.RetryTemplate 并想传给 Spring AI 的 builder,编译会失败。

异常分类:4xx 不重试,5xx 重试

RetryUtils.java:65-96 的默认 ResponseErrorHandler:

java 复制代码
public static final ResponseErrorHandler DEFAULT_RESPONSE_ERROR_HANDLER = new ResponseErrorHandler() {

	@Override
	public boolean hasError(final ClientHttpResponse response) throws IOException {
		return response.getStatusCode().isError();
	}

	@Override
	public void handleError(final URI url, final HttpMethod method, final ClientHttpResponse response)
			throws IOException {
		handleError(response);
	}

	@SuppressWarnings("removal")
	public void handleError(final ClientHttpResponse response) throws IOException {
		if (response.getStatusCode().isError()) {
			String error = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
			String message = String.format("%s - %s", response.getStatusCode().value(), error);
			/*
			 * Thrown on 4xx client errors, such as 401 - Incorrect API key provided,
			 * 401 - You must be a member of an organization to use the API, 429 -
			 * Rate limit reached for requests, 429 - You exceeded your current quota,
			 * please check your plan and billing details.
			 */
			if (response.getStatusCode().is4xxClientError()) {
				throw new NonTransientAiException(message);
			}
			throw new TransientAiException(message);
		}
	}

};

这里有个生产上必须知道的坑,就在那段注释里:

429(Rate limit reached)是 4xx ,所以它抛出的是 NonTransientAiException,不会被重试。

注释里明确列出了 429,说明作者知道这件事,但仍然把它归类为不可重试。理由是 Spring Retry 的退避策略对"配额耗尽"这类问题无效------重试只会更快耗尽。

如果你希望 429 也重试 (对很多应用是合理的,因为限流通常是短时的),你必须自定义 ResponseErrorHandler 或自定义 RetryPolicy。这是 Spring AI 不替你做的决定。

顺带说一句:TransientAiException 和 NonTransientAiException 都 extends RuntimeException,且都标注 @since 0.8.1------它们是 Spring AI 里最古老的几个类之一,从 0.8.1 一直活到 2.0.1。

重试参数(真实默认值)

RetryUtils.java:50-60:

java 复制代码
private static final int DEFAULT_MAX_ATTEMPTS = 10;
private static final long DEFAULT_INITIAL_INTERVAL = 2000;
private static final int DEFAULT_MULTIPLIER = 5;
private static final long DEFAULT_MAX_INTERVAL = 3 * 60000;
private static final long SHORT_INITIAL_INTERVAL = 100;

即:最多 10 次重试,初始延迟 2000 ms,指数乘数 5,最大延迟 180000 ms(3 分钟)。

指数退避序列大致是:

erlang 复制代码
第 1 次失败 → 等 2s
第 2 次失败 → 等 10s
第 3 次失败 → 等 50s
第 4 次失败 → 等 180s(已封顶)
...

累计最长等待时间约 13 分钟 (2 + 10 + 50 + 180×7)。如果你的 HTTP 客户端超时是 60 秒(Spring AI 默认就是 Duration.ofSeconds(60)),那么到了第 3 次重试时,外层超时早就触发了。

所以生产上的真相是:真正生效的往往只有前 2~3 次重试 ,后面的退避时间已经超出了客户端超时。如果你要精确控制重试行为,需要同时调 spring.ai.openai.timeout(之类)和 RetryTemplate。

三个公开常量(RetryUtils.java:65,101,106):

java 复制代码
public static final ResponseErrorHandler DEFAULT_RESPONSE_ERROR_HANDLER = new ResponseErrorHandler() { ... };
public static final RetryTemplate DEFAULT_RETRY_TEMPLATE = createDefaultRetryTemplate();
public static final RetryTemplate SHORT_RETRY_TEMPLATE = createShortRetryTemplate();

SHORT_RETRY_TEMPLATE 的差异(RetryUtils.java:134-161):固定 100 ms 延迟,无 multiplier / maxDelay,即无指数退避。它的 Javadoc 写得很清楚:"Useful in testing scenarios where you don't want to wait long for retry and don't need to show stack trace."

一个容易被忽略的工具方法

RetryUtils.java:163-178:

java 复制代码
public static <R extends @Nullable Object> R execute(RetryTemplate retryTemplate, Retryable<R> retryable) {
	try {
		return retryTemplate.execute(retryable);
	}
	catch (RetryException e) {
		throw (e.getCause() instanceof RuntimeException runtime) ? runtime
				: new RuntimeException(e.getMessage(), e.getCause());
	}
}

它的作用是拆包 :Spring core retry 在重试耗尽后抛 RetryException,里面包着真正的业务异常。这个方法把 RetryException 拆开,还原成原始的 RuntimeException。

为什么这很重要? 因为如果你直接调用 retryTemplate.execute(...),调用方拿到的是 RetryException,你的 catch (TransientAiException e) 不会命中。Spring AI 内部的模型实现都用这个 execute() 包装,所以你看到的是原始异常。如果你自己写重试逻辑,务必用同样的方式拆包。

1.15.2 spring-ai-template-st:StringTemplate 集成的三个细节

只有 3 个源文件:

arduino 复制代码
spring-ai-template-st/src/main/java/org/springframework/ai/template/st/
 ├── CommonsLoggingStErrorListener.java
 ├── StTemplateRenderer.java
 └── package-info.java

SPI 接口在 commons(spring-ai-commons/.../template/TemplateRenderer.java:30-35):

java 复制代码
public interface TemplateRenderer extends BiFunction<String, Map<String, ? extends @Nullable Object>, String> {

	@Override
	String apply(String template, Map<String, ? extends @Nullable Object> variables);

}

它就是一个 BiFunction<String, Map, String> ------(template, variables) -> rendered。这个极简设计意味着你可以用 lambda 实现一个自定义渲染器:

java 复制代码
TemplateRenderer myRenderer = (template, vars) -> template.replace("${x}", String.valueOf(vars.get("x")));
细节一:线程安全契约写在 Javadoc 里

StTemplateRenderer.java:48-52:

java 复制代码
 * <p>
 * <b>Thread safety:</b> This class is safe for concurrent use. Each call to
 * {@link #apply(String, Map)} creates a new StringTemplate instance, and no mutable state
 * is shared between threads.

这是显式承诺 。为什么需要承诺?因为 StringTemplate 的 ST 实例本身是可变的(你可以 st.add(...)),如果框架缓存了 ST 实例就会有并发问题。实现方式是每次 apply() 都 new ST(group, template)。

细节二:默认分隔符与校验模式

StTemplateRenderer.java:57-71:

java 复制代码
private static final String VALIDATION_MESSAGE = "Not all variables were replaced in the template. Missing variable names are: %s.";
private static final char DEFAULT_START_DELIMITER_TOKEN = '{';
private static final char DEFAULT_END_DELIMITER_TOKEN = '}';
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false;

ValidationMode(commons/.../template/ValidationMode.java:25-43):

java 复制代码
public enum ValidationMode {

	/**
	 * If the validation fails, an exception is thrown. This is the default mode.
	 */
	THROW,

	/**
	 * If the validation fails, a warning is logged. The template is rendered with the
	 * missing placeholders/variables. This mode is not recommended for production use.
	 */
	WARN,

	/**
	 * No validation is performed.
	 */
	NONE

}

默认 THROW------模板里有未提供的变量就抛异常。这对生产是好的(快速失败),但对"可选变量"场景不友好。

细节三:变量提取走 ANTLR token 流

StTemplateRenderer.java:127-196 的 getInputVariables(ST st) 直接遍历 ANTLR STLexer 的 token 流:

java 复制代码
private Set<String> getInputVariables(ST st) {
	TokenStream tokens = st.impl.tokens;
	Set<String> inputVariables = new HashSet<>();
	boolean isInsideList = false;

	for (int i = 0; i < tokens.size(); i++) {
		Token token = tokens.get(i);

		// Handle list variables with option (e.g., {items; separator=", "})
		if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size()
				&& tokens.get(i + 1).getType() == STLexer.ID) {
			if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) {
				String text = tokens.get(i + 1).getText();
				if (!Compiler.funcs.containsKey(text) || this.validateStFunctions) {
					inputVariables.add(text);
					isInsideList = true;
				}
			}
		}
		else if (token.getType() == STLexer.RDELIM) {
			isInsideList = false;
		}
		// Handle regular variables - only add IDs that are at the start of an
		// expression
		else if (!isInsideList && token.getType() == STLexer.ID) {
			// Check if this ID is a function call
			boolean isFunctionCall = (i + 1 < tokens.size() && tokens.get(i + 1).getType() == STLexer.LPAREN);

			// Check if this ID is at the beginning of an expression (not a property
			// access)
			boolean isAfterDot = (i > 0 && tokens.get(i - 1).getType() == STLexer.DOT);
			...
		}
	}
	return inputVariables;
}

它做了三件精细的事:

  1. 跳过 ST 内置函数 :Compiler.funcs.containsKey(text) ------ 像 {first(items)}、{length(x)} 里的 first/length 是函数名,不是变量,不应报缺失。
  2. 区分属性访问 :{user.name} 里的 name 前面是 .,它是属性而非顶层变量,不应报缺失。
  3. 处理列表分隔符选项 :{items; separator=", "} 这种带选项的语法,用一个 isInsideList 标志位跳过后续 token。

这段代码是本模块技术含量最高的部分 ,也是为什么 Spring AI 选 StringTemplate 而不是简单的 String.format 或正则替换------StringTemplate 有真正的语法分析能力,能区分变量、函数、属性。

顺带提一句:CommonsLoggingStErrorListener 把 StringTemplate 的错误事件桥接到 Apache Commons Logging,这样 ST 的语法错误会出现在你的应用日志里而不是被吞掉。


1.16 附录 B:spring-ai-tool-search-tool 模块解剖

这是一个 2.x 引入的、解决"工具太多"问题的模块。很多人在文档里看到 "Tool Search Tool" 一头雾水,这里先把它讲清楚(第 08 章会深入使用层面)。

1.16.1 它解决什么问题

当你给模型注册了 200 个工具时:

  1. 上下文爆炸:每个工具的 JSON Schema 都要塞进 prompt,200 个工具可能占掉几万 token。
  2. 选择困难:工具越多,模型选错的概率越高。

解法:不要每轮把所有工具定义都发给模型 。只暴露一个名为 toolSearchTool 的"元工具",模型需要什么能力时用自然语言搜索,搜到的工具再注入后续轮次。

1.16.2 核心接口 ToolIndex

spring-ai-tool-search-tool/src/main/java/org/springframework/ai/tool/toolsearch/ToolIndex.java:32-72:

java 复制代码
public interface ToolIndex {
	void indexTool(String sessionId, ToolReference toolReference);

	@SuppressWarnings("null")
	default void indexTools(String sessionId, List<ToolReference> toolReferences) {
		for (ToolReference ref : toolReferences) {
			indexTool(sessionId, ref);
		}
	}

	ToolSearchResponse search(ToolSearchRequest toolSearchRequest);

	void clearIndex(String sessionId);
}

数据模型(records):

java 复制代码
// ToolReference.java:33
public record ToolReference(String toolName, @Nullable Double relevanceScore, String summary)

// ToolSearchRequest.java:38-39
public record ToolSearchRequest(String sessionId, String query,
                                @Nullable Integer maxResults, @Nullable String categoryFilter)

// ToolSearchResponse.java:36-37
public record ToolSearchResponse(List<ToolReference> toolReferences,
                                 @Nullable Integer totalMatches,
                                 @Nullable SearchMetadata searchMetadata)
// 内嵌 SearchMetadata(String searchType, String query, @Nullable Long searchTimeMs) (L90)

1.16.3 暴露给模型的那个"元工具"

.../tool/toolsearch/ToolSearchTool.java:28-65:

java 复制代码
public class ToolSearchTool {

	public static final String TOOL_SEARCH_TOOL_SESSION_ID_KEY = "toolSearchToolSessionId";
	...
	@Tool(name = "toolSearchTool", description = """
			Search for tools in the tool registry to discover capabilities for completing the current task.
			Use this when you need functionality not provided by your currently available tools.
			The search queries against tool names, descriptions, and parameter information to find the most relevant tools.
			Returns references to matching tools which will be expanded into full definitions you can then invoke.
			""")
	public List<String> toolSearchTool(
		@ToolParam(description = "A natural language search query describing the tool capability you need. Be specific and include relevant keywords.") String query,
		@ToolParam(description = "Maximum number of tool references to return (1-10). Default is 5.", required = false) Integer maxResults,
		@ToolParam(description = "Optional filter to narrow search to a specific tool category.", required = false) String categoryFilter,
		ToolContext toolContext) { // @formatter:on

注意返回值是 List<String>------只有工具名,不是完整定义。 完整定义由 advisor 在下一轮注入。这是一个"两阶段"设计:先搜索拿到名字,再注入。

1.16.4 三种索引实现

类 关键默认 实现要点
RegexToolIndex DEFAULT_MAX_RESULTS = 10、MAX_PATTERN_LENGTH = 200 把自然语言查询转成 `(?i)(token1
LuceneToolIndex DEFAULT_MAX_RESULTS = 10;无参构造 minScoreThreshold = 0.25f StandardAnalyzer + ByteBuffersDirectory(纯内存)
VectorToolIndex DEFAULT_MAX_RESULTS = 10、DEFAULT_SIMILARITY_THRESHOLD = 0.2 把 summary 作为 Document 文本写入 VectorStore,metadata 含 sessionId/id/toolName/toolDescription;用 b.eq(METADATA_SESSION_ID, sessionId) 过滤;不支持 categoryFilter,会 warn

三者的选择逻辑很清晰:

  • Regex:零依赖,启动快,适合工具数量 < 500 且描述是英文的场景。
  • Lucene:需要语义相关性排序,且不介意引入 Lucene(约 3 MB)。
  • Vector:工具描述复杂、需要跨语言/跨语义匹配,但你已经有 VectorStore 了。

1.16.5 驱逐策略:五种,其中两种是"陷阱"

.../eviction/ToolIndexEvictionStrategy.java:38-60:

java 复制代码
public interface ToolIndexEvictionStrategy {
	Set<String> onAccess(String sessionId);
	void onRemoved(String sessionId);
}
实现 行为 备注
LruEvictionStrategy 基于 access-order LinkedHashMap,超过 maxSessions 淘汰 LRU 默认,容量 1000
TtlEvictionStrategy 惰性检查(无后台线程),TTL 内空闲则淘汰;每次访问重置 TTL 惰性意味着不访问就不淘汰
CompositeEvictionStrategy 多策略取并集,任一策略要求淘汰即淘汰
NeverEvictStrategy 单例 INSTANCE Javadoc 带内存泄漏警告
AlwaysEvictStrategy 单例 INSTANCE;onAccess 故意返回当前 sessionId 强制每轮重建索引 用于测试

NeverEvictStrategy 的 Javadoc 明确警告内存泄漏------因为索引是常驻的,session 永不释放。AlwaysEvictStrategy 则是"每轮重建索引",性能极差,只用于验证索引逻辑正确性。

1.16.6 顾问层:ToolSearchToolCallingAdvisor

advisors/spring-ai-tool-search-advisor/.../ToolSearchToolCallingAdvisor.java:76:

java 复制代码
public class ToolSearchToolCallingAdvisor extends ToolCallingAdvisor {

它继承 ToolCallingAdvisor 并复写四个钩子:doInitializeLoop / doBeforeCall / doInitializeLoopStream / doBeforeStream。

两个值得记住的实现技巧:

技巧一:指纹去重建索引(L338-352)

java 复制代码
// Re-index only when the tool set has changed for this session.
String fingerprint = computeFingerprint(toolReferences);
this.indexedSessionFingerprints.compute(sessionId, (id, current) -> {
    if (!fingerprint.equals(current)) {
        this.toolIndex.clearIndex(id);
        this.toolIndex.indexTools(id, toolReferences);
    }
    return fingerprint;
});

指纹算法:按 toolName 排序后,对 name + 0x00 + summary + 0x01 做 SHA-256。避免每轮工具循环都重建索引------这是性能关键,因为工具循环可能跑十几轮。

技巧二:每轮只下发已召回的工具(L244-272)

java 复制代码
Set<ToolCallback> selectedToolCallbacks = new HashSet<>(List.of(this.toolSearchToolCallback));
...
this.extractToolNameReferences(chatClientRequest.prompt().getInstructions()).forEach(toolName -> {
    if (cachedToolCallbacks.containsKey(toolName)) {
        selectedToolCallbacks.add(cachedToolCallbacks.get(toolName));
    }
});
...
ToolCallingChatOptions toolOptionsCopy = ((ToolCallingChatOptions.Builder<?>) toolOptions.mutate())
    .toolCallbacks(new ArrayList<>(selectedToolCallbacks))
    .toolContext(ToolSearchTool.TOOL_SEARCH_TOOL_SESSION_ID_KEY, ...)
    .build();

即每轮发给模型的只有 toolSearchTool + 已经被搜索到的工具。

默认系统提示后缀(来自 spring-ai-tool-search-tool/src/main/resources/DEFAULT_SYSTEM_PROMPT_SUFFIX.md:2):

vbnet 复制代码
You have access to a special tool called `toolSearchTool` that allows you to discover relevant tools on-demand. When you need to perform an action but don't see an appropriate tool in your current context, use `toolSearchTool` to search for it.

这段会被追加到 system message 末尾。所以如果你自己写了 system prompt,它不会覆盖你的------是追加。


1.17 附录 C:spring-ai-test 到底提供什么(一个常见误解)

它不提供 @SpringAiTest,也不提供 MockChatModel。 全仓库 grep SpringAiTest|MockChatModel|MockChatClient|MockEmbeddingModel → 零匹配。

spring-ai-test 实际是一组抽象测试基类 + 测试工具,21 个文件:

类 路径 作用
BaseVectorStoreTests org/springframework/ai/test/vectorstore/BaseVectorStoreTests.java:44 向量库的契约测试基类 。抽象方法 executeTest(Consumer<VectorStore>)(L52),提供 createDocument(L54)、setupTestDocuments(L63)、deleteById(L101)、deleteWithStringFilterExpression(L125)、deleteByFilter(L149)
AbstractChatOptionsTests<O, B> org/springframework/ai/test/options/AbstractChatOptionsTests.java:32 ChatOptions 的契约测试基类 。测 builderShouldReturnNewInstances(L35)、testMutateBehavior(L47);子类实现 getConcreteOptionsClass()(L69)、readyToBuildBuilder()(L79)
ObservationTestUtil org/springframework/ai/test/vectorstore/ObservationTestUtil.java:31 静态方法 assertObservationRegistry(...)(L37)
CurlyBracketEscaper org/springframework/ai/test/CurlyBracketEscaper.java:27 escapeCurlyBrackets(L38)/ unescapeCurlyBrackets(L50)
AudioPlayer org/springframework/ai/utils/AudioPlayer.java:32 play(byte[])(L42)/ play(InputStream)(L46)
AbstractToolCallingAdvisorIT .../test/chat/client/advisor/ 工具调用 advisor 的集成测试基类
AbstractToolCallingAdvisorAutoRegistrationIT 同上 自动注册场景基类
MockWeatherService 同上 示例工具服务(是工具 mock,不是 ChatModel mock)

1.17.1 为什么这个"契约测试基类"的设计值得学习

BaseVectorStoreTests 是一个典型的 "可复用契约测试" 模式:

java 复制代码
public abstract class BaseVectorStoreTests {

	protected abstract void executeTest(Consumer<VectorStore> testConsumer);
	...
}

每个向量库模块(PgVector、Redis、Milvus......)写一个子类,实现 executeTest()(通常是"建表 → 构造 store → 执行 → 清理"),然后继承父类里所有已经写好的测试用例 (deleteById、deleteByFilter 等)。

好处:23 个向量库实现共享同一套行为断言 ,保证它们的行为一致。任何一个实现违反了契约(比如 similaritySearch 返回的 score 语义不对),测试会立刻发现。

这是大型多实现框架的经典手法------JUnit 的契约测试、JDBC 的 TCK 都是这个思路。如果你在做类似的事情(多个实现类要保证行为一致),可以直接抄这个模式。

1.17.2 CurlyBracketEscaper 的存在说明什么

这个工具用来转义大括号。为什么需要?因为 Spring AI 的模板用 {variable} 语法,而测试里经常要构造"看起来像模板但不是模板"的字符串,或者反过来。

它折射出一个事实:大括号是 Spring AI 里的一个持续痛点 。你在写 prompt 时如果正文里有 { 或 },StringTemplate 会尝试解析它。解决方案有几种(第 03 章会讲),但这个工具类的存在本身就是一个信号。


1.18 附录 D:开发期服务的九种支持

spring-ai-spring-boot-docker-compose 与 spring-ai-spring-boot-testcontainers 是完全对称的两个模块,各支持 9 个服务:

复制代码
chroma
docker-mcp-gateway
milvus
ollama
opensearch(含 AWS 专用变体 aws-opensearch)
qdrant
typesense
weaviate

(外加一个 AwsOpenSearch 变体,所以是 9 个逻辑服务。)

它们通过 Boot 的 ConnectionDetailsFactory SPI 注册,实现模式(以 Ollama 为例,OllamaContainerConnectionDetailsFactory.java:28-51):

java 复制代码
class OllamaContainerConnectionDetailsFactory
		extends ContainerConnectionDetailsFactory<OllamaContainer, OllamaConnectionDetails> {

	@Override
	public OllamaConnectionDetails getContainerConnectionDetails(ContainerConnectionSource<OllamaContainer> source) {
		return new OllamaContainerConnectionDetails(source);
	}

	private static final class OllamaContainerConnectionDetails extends ContainerConnectionDetails<OllamaContainer>
			implements OllamaConnectionDetails {

		private OllamaContainerConnectionDetails(ContainerConnectionSource<OllamaContainer> source) {
			super(source);
		}

		@Override
		public String getBaseUrl() {
			return getContainer().getEndpoint();
		}
	}
}

注意两个细节:

  1. 工厂类是 package-private (class 而非 public class),只通过 spring.factories 暴露。这是 Spring 项目的常见手法------SPI 实现类不需要公开。
  2. getBaseUrl() 直接转发容器的 getEndpoint()。这意味着你不需要在 application.yml 里手写 Ollama 地址------容器起来了,连接信息自动注入。

这就是 Boot 的 Service Connection 机制:本地开发时 docker compose up,应用自动连上;CI 时 Testcontainers 自动起容器,应用也自动连上。同一份代码,零配置切换。

注意 OpensearchContainer → OpenSearchContainer 的重命名 (upgrade-notes.adoc:2132):Testcontainers 升级到 2.0.1 → 4.1.0 时改了类名大小写。如果你在旧的测试代码里用了 OpensearchContainer,升级后会编译失败。


1.19 附录 E:推演一个"最小可用"依赖清单

理论讲完了,我们来做一个实际推演。假设你要构建一个带 RAG 的对话服务,用 OpenAI + PgVector + JDBC 记忆。

步骤 1:确定核心

你必然需要:

arduino 复制代码
spring-ai-client-chat     (ChatClient + Advisor)
spring-ai-model           (ChatModel + Prompt)
spring-ai-commons         (Document + 工具类)
spring-ai-vector-store    (VectorStore + Filter)
spring-ai-rag             (RetrievalAugmentationAdvisor)
spring-ai-template-st     (模板渲染)
spring-ai-retry           (重试)

步骤 2:确定适配器

复制代码
spring-ai-openai                        (ChatModel + EmbeddingModel)
spring-ai-pgvector-store                (VectorStore)
spring-ai-model-chat-memory-repository-jdbc   (持久化记忆)

步骤 3:确定自动配置

arduino 复制代码
spring-ai-autoconfigure-model-openai
spring-ai-autoconfigure-model-chat-client
spring-ai-autoconfigure-model-chat-memory
spring-ai-autoconfigure-model-chat-memory-repository-jdbc
spring-ai-autoconfigure-vector-store-pgvector
spring-ai-autoconfigure-vector-store-observation
spring-ai-autoconfigure-model-chat-observation
spring-ai-autoconfigure-model-embedding-observation

步骤 4:用 starter 简化

你不需要手写上面这 15 个,只需要 3 个 starter:

xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>

但注意 :这三个 starter 没有 带来 spring-ai-rag!因为 RAG 不是自动装配的一部分------RetrievalAugmentationAdvisor 是你要自己 new 的,不是 Spring bean。

所以你还得显式加:

xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-rag</artifactId>
</dependency>

这是一个容易踩的坑 :很多人以为引了模型和向量库 starter 就能用 RetrievalAugmentationAdvisor,结果发现类找不到。原因就在依赖图里------spring-ai-rag 是核心模块之一,但没有任何 starter 传递依赖它。

步骤 5:BOM 管理版本

xml 复制代码
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>2.0.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

这样上面所有依赖都不需要写 <version>。

步骤 6:验证清单

最终你的 classpath 上会有(不含传递依赖):

arduino 复制代码
spring-ai-bom                                    (仅 dependencyManagement)
spring-ai-starter-model-openai
spring-ai-starter-vector-store-pgvector
spring-ai-starter-model-chat-memory-repository-jdbc
spring-ai-rag

如果你不需要 JDBC 持久化记忆 (比如只是做个 demo),去掉第三个 starter,MessageWindowChatMemory 会默认用 InMemoryChatMemoryRepository(MessageWindowChatMemory.java:138),仍然可用。

如果你不需要 RAG (纯对话 + 工具调用),去掉 spring-ai-rag,ChatClient 依然完整可用。

这个推演展示了 2.x 模块化的实际收益:你只为用到的东西付费。


1.20 本章的思考题

留几个问题,答案在后续章节:

  1. 为什么 spring-ai-model 依赖 spring-ai-template-st,而不是反过来或者都不依赖? 提示:想想 PromptTemplate 在哪个模块,它需要什么能力。(第 03 章)

  2. spring-ai-commons 里有 org.springframework.ai.evaluation 包(Evaluator / EvaluationRequest / EvaluationResponse),但没有任何实现。它为什么在这里? 提示:想想"评测"需要什么依赖。(第 20 章)

  3. VectorStore 同时 extends DocumentWriter 和 VectorStoreRetriever,但 similaritySearch 不在 VectorStore 接口里声明。这样设计的好处是什么? 提示:想想 @FunctionalInterface 注解。(第 11 章)

  4. 为什么 spring-ai-retry 不被 spring-ai-commons 依赖? 提示:重试发生在哪一层。(第 19 章)

  5. BOM 里 spring-ai-elevenlabs 是唯一 optional 的条目。它为什么特殊? 提示:想想 TTS 会拖进什么依赖。


1.21 关键文件索引(便于复核)

css 复制代码
spring-ai-2.0.1\pom.xml                                    (根 POM,版本与依赖管理)
spring-ai-2.0.1\README.md                                  (版本兼容矩阵)
spring-ai-2.0.1\CONTRIBUTING.md                            (构建命令、代码风格、commit 规范)
spring-ai-2.0.1\spring-ai-bom\pom.xml                      (168 个受管 artifact)
spring-ai-2.0.1\spring-ai-commons\src\main\java\org\springframework\ai\content\Media.java
spring-ai-2.0.1\spring-ai-commons\src\main\java\org\springframework\ai\content\Content.java
spring-ai-2.0.1\spring-ai-commons\src\main\java\org\springframework\ai\document\Document.java
spring-ai-2.0.1\spring-ai-commons\src\main\java\org\springframework\ai\document\DefaultContentFormatter.java
spring-ai-2.0.1\spring-ai-commons\src\main\java\org\springframework\ai\template\TemplateRenderer.java
spring-ai-2.0.1\spring-ai-commons\src\main\java\org\springframework\ai\template\ValidationMode.java
spring-ai-2.0.1\spring-ai-model\src\main\java\org\springframework\ai\model\Model.java
spring-ai-2.0.1\spring-ai-model\src\main\java\org\springframework\ai\model\StreamingModel.java
spring-ai-2.0.1\spring-ai-model\src\main\java\org\springframework\ai\model\ModelRequest.java
spring-ai-2.0.1\spring-ai-model\src\main\java\org\springframework\ai\model\ModelResponse.java
spring-ai-2.0.1\spring-ai-model\src\main\java\org\springframework\ai\model\ModelResult.java
spring-ai-2.0.1\spring-ai-model\src\main\java\org\springframework\ai\model\ModelOptions.java
spring-ai-2.0.1\spring-ai-retry\src\main\java\org\springframework\ai\retry\RetryUtils.java
spring-ai-2.0.1\spring-ai-retry\src\main\java\org\springframework\ai\retry\TransientAiException.java
spring-ai-2.0.1\spring-ai-retry\src\main\java\org\springframework\ai\retry\NonTransientAiException.java
spring-ai-2.0.1\spring-ai-template-st\src\main\java\org\springframework\ai\template\st\StTemplateRenderer.java
spring-ai-2.0.1\spring-ai-tool-search-tool\src\main\java\org\springframework\ai\tool\toolsearch\ToolIndex.java
spring-ai-2.0.1\spring-ai-tool-search-tool\src\main\java\org\springframework\ai\tool\toolsearch\ToolSearchTool.java
spring-ai-2.0.1\spring-ai-tool-search-tool\src\main\resources\DEFAULT_SYSTEM_PROMPT_SUFFIX.md
spring-ai-2.0.1\spring-ai-test\src\main\java\org\springframework\ai\test\vectorstore\BaseVectorStoreTests.java
spring-ai-2.0.1\spring-ai-test\src\main\java\org\springframework\ai\test\options\AbstractChatOptionsTests.java
spring-ai-2.0.1\spring-ai-docs\src\main\antora\modules\ROOT\pages\upgrade-notes.adoc
spring-ai-2.0.1\spring-ai-docs\src\main\antora\modules\ROOT\nav.adoc

1.22 附录 F:一条自下而上的阅读路径(给想啃源码的人)

到目前为止,本章一直在"横切"式地描述这个仓库的静态结构。但如果你的目标不是了解它长什么样,而是真的把它读下来,横切式的阅读顺序是低效的。这一节给出一条我认为最省力的路径,并说明理由。

第一步:从 spring-ai-commons 的四个类型开始(约 1 小时)

arduino 复制代码
org.springframework.ai.content.Content
org.springframework.ai.content.Media
org.springframework.ai.document.Document
org.springframework.ai.template.TemplateRenderer

为什么从这里开始?因为这四个类型没有任何对上层的依赖,却被几乎所有上层模块引用。它们是这个框架的"词汇表"------不认识它们,后面每一个类的签名你都要回头查。

重点体会 Content 接口只有两个方法(getText() / getMetadata())却能同时被 Document、Media、ChatResponse 的实现所共享这件事。它说明 Spring AI 的设计者认为:"一段文本 + 一组键值"是比"文档"或"消息"更本质的东西 。这个判断贯穿全书------你会发现 RAG 的 Document、聊天里的 AssistantMessage、向量检索的结果,最终都收敛到同一个形状。

第二步:spring-ai-model 的 L0 泛型(约 2 小时)

复制代码
org.springframework.ai.model.Model          ← 全部 4 个方法
org.springframework.ai.model.StreamingModel  ← 注意它的返回类型
org.springframework.ai.model.ModelRequest
org.springframework.ai.model.ModelResponse
org.springframework.ai.model.ModelOptions

这一步的目标只有一个:把 Model<Req extends ModelRequest, Res extends ModelResponse> 这个泛型三元组刻进脑子 。后面所有 ChatModel、EmbeddingModel、ImageModel 都只是它的特化。看不懂这五个类,后面二十个模块的代码你都会读得似是而非。

读的时候请特别留意 StreamingModel 的返回类型不是 Flux<Res> 而是 Flux<Res> 的某种包装,以及为什么需要 getStream() 而不是直接 stream()------这与 Reactor 的冷流语义有关:模型调用是一次性动作,但返回的流可以被订阅多次。第 02 章会完整展开。

第三步:挑一个"最规整"的供应商实现读通(约 1 天)

不要一上来读 OpenAI。OpenAI 模块在 2.x 换成了官方 SDK,代码里有一层适配,会干扰你对框架本身的理解。

我推荐从 spring-ai-ollama 开始,理由:

  1. 它的 API 是 Spring AI 自己写的 OllamaApi(不是第三方 SDK 的薄封装),你能看到完整的 HTTP 交互;
  2. 它的 OllamaChatModel 结构最"教科书"------构造、options 合并、调用、观测、流式,五段清晰分离;
  3. 它本地可跑(ollama serve),你可以打断点真跑一遍。

读通 Ollama 之后,再读 OpenAI,你会发现差异只剩下"HTTP 客户端是谁"和"响应解析细节",框架骨架是一致的。这就是可移植性设计的直接证据。

第四步:回到 spring-ai-client-chat,读三条链(约 1 天)

r 复制代码
ChatClient.Builder → DefaultChatClientBuilder
DefaultChatClient(call 链路)
DefaultChatClient(stream 链路)
DefaultAroundAdvisorChain(advisor 链的栈式执行)

到这一步你才有资格读 ChatClient。因为它不是"对 ChatModel 的简单封装",而是一条带有拦截链的编排管道 。在不了解 ChatModel 的前提下读它,你会把 advisor 链和模型调用混为一谈。

第五步:按你的实际需求挑分支(各约半天)

  • 需要 RAG → spring-ai-rag 的 RetrievalAugmentationAdvisor(只有 15 个 java 文件,一晚上能读完)
  • 需要工具调用 → spring-ai-model 的 org.springframework.ai.tool + mcp/
  • 需要向量库 → spring-ai-vector-store 的 VectorStore 接口 + 你用的那一个实现
  • 需要记忆 → org.springframework.ai.chat.memory + 对应的 repository

第六步:最后才看自动装配(约半天)

auto-configurations/ 是最不重要 的一层------它只是把上面这些类变成 Bean。很多人从 starter 开始学,结果学了一堆 @ConditionalOnProperty 却不知道 ChatModel 是什么。请把它放在最后,你会读得又快又轻松。

一条反直觉的建议:先读测试

Spring AI 的测试代码质量很高,尤其是:

bash 复制代码
spring-ai-model/src/test/java/org/springframework/ai/chat/memory/MessageWindowChatMemoryTests.java
spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/.../...Tests.java
spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/...Tests.java

单元测试是框架作者写给自己看的用法说明 。当你不确定某个 API 的语义(比如 MessageWindowChatMemory 裁剪时到底保留几条),去测试里找答案,比读实现快十倍,而且不会误读。

本系列后续每一章都会标注"对应的测试文件在哪",就是出于这个理由。


1.23 附录 G:本章结论的复核清单

如果你打算验证本章的任意一条结论,下面是可执行的命令。所有命令在仓库根目录执行。

shell 复制代码
# 1. 验证模块数量(应为 15 个模型模块)
ls models/ | wc -l

# 2. 验证向量库模块数量(应为 23 个)
ls vector-stores/ | wc -l

# 3. 验证 BOM 受管 artifact 数量(应为 168)
grep -c "<artifactId>" spring-ai-bom/pom.xml

# 4. 验证 spring-ai-core 是否真的不存在
ls | grep -i "spring-ai-core" ; echo "exit=$?"   # 应无输出

# 5. 验证内部重试依赖的是 spring-retry 还是 spring-core
grep -n "spring-retry" pom.xml spring-ai-retry/pom.xml
# 2.x 期望:无 spring-retry 依赖,RetryUtils 用 org.springframework.core
grep -n "RetryTemplate" spring-ai-retry/src/main/java/org/springframework/ai/retry/RetryUtils.java

# 6. 验证 NullAway 是否开启
grep -n "NullAway" pom.xml

# 7. 验证部分模型模块被 profile 排除
grep -n "models/" pom.xml

# 8. 验证 1.x 的类确实已被移除(以下应全部无输出)
grep -rn "internalToolExecutionEnabled" --include=*.java . | head -5
grep -rn "class SqlFilterExpressionConverter" --include=*.java . | head -5
grep -rn "interface DocumentRanker" --include=*.java . | head -5

第 8 条是本系列反复强调的方法论:当你从博客或旧文档里看到一个类名,先在源码里 grep 它。2.x 的一次升级移除/重命名了大量 API,凭记忆写代码的失败率极高。本系列会在每一章末尾重复这个提醒。


下一章 :我们进入 spring-ai-model 的核心抽象层,从 Model 这个泛型根接口开始,逐行拆解 ChatModel 为什么同时继承两个接口、stream() 的默认实现为什么是抛异常、以及 ChatOptions 的 @Nullable 策略背后隐藏的"合并语义"契约。

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 05 章 Advisor 架构:接口体系、栈式链与 context 传播
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 04 章 `ChatClient` 全流程:Builder、两条链路与结构化输出
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 08 章 RAG 组件逐个拆解:实现细节与八个可投产组件
java·人工智能·开源
Ward的AI产品实战2 小时前
多个订阅接多个 Agent 怎么管?我从 CC-Switch 迁移到了开源 AI 网关 Magpie(上篇)
开源·ai编程
匠测AI说2 小时前
AI for Testing 提效实战 · 执行自动化(五):这次该跑哪些用例,让 AI 圈回归范围
人工智能·测试
SL_staff2 小时前
告别报表重复开发:JVS-BI 模板市场的参数化继承实践
java·数据分析·数据可视化
PingCAP3 小时前
从一句需求到一套可追溯机制:我们这样推进电子病历改造
人工智能·agent·工作流引擎
PingCAP3 小时前
平凯星辰刘松:稳定交付严肃任务正成为企业级智能体的重要考验
人工智能·agent·工作流引擎