Spring AI简介

Spring AI 是 Spring 官方提供的 AI 应用开发框架,目标是用熟悉的 Spring 编程方式接入大语言模型、Embedding 模型和向量数据库。

日常开发中常用的能力主要有:

能力 作用
Chat Model 统一不同模型厂商的对话调用方式
ChatClient 使用链式 API 构造提示词并调用模型
Chat Memory 保存多轮对话需要的上下文
Advisor 在模型调用前后统一处理日志、记忆和 RAG
Tool Calling 让模型按需调用 Java 方法
Embedding Model 将文本转换为向量
Vector Store 保存向量并进行相似度检索
RAG 检索私有知识并作为上下文交给模型回答

本文代码以 Spring AI 1.1.0 为例。Spring AI 不同版本之间的依赖名称和部分 API 可能发生变化,升级版本时需要同时查看对应版本的参考文档和 Javadoc。

基本使用

引入Spring AI BOM

使用 BOM 统一管理 Spring AI 组件版本,后续引入 Spring AI 依赖时不需要再单独填写版本号。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.1.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

引入模型依赖

下面使用 OpenAI Starter。它同时提供 Chat Model、Embedding Model、ChatClient 自动配置和 Tool Calling 支持。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

如果使用 Anthropic、Ollama、Azure OpenAI 等模型,需要替换成对应的 Starter。业务代码尽量依赖 ChatModelEmbeddingModelChatClient 等通用接口,切换模型时改动会更小。

配置模型

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}

      # 使用 OpenAI 兼容接口时再配置
      # base-url: ${OPENAI_BASE_URL}

      chat:
        options:
          model: ${CHAT_MODEL}
          temperature: 0.7

      embedding:
        options:
          model: ${EMBEDDING_MODEL}

API Key 应通过环境变量、配置中心或密钥管理服务提供,不要直接提交到代码仓库。

chat.options.modelembedding.options.model 是两种不同类型的模型,不能混用:

  • Chat Model 负责生成回答。
  • Embedding Model 负责把文本转换成向量。

创建ChatClient

Spring Boot 会自动创建一个 ChatClient.Builder,可以直接注入并构建 ChatClient

@Configuration
public class AiConfiguration {

    @Bean
    public ChatClient chatClient(ChatClient.Builder builder) {
        return builder
                .defaultSystem("你是一个严谨、简洁的技术助手")
                .build();
    }
}

基本调用:

@Service
public class AiChatService {

    private final ChatClient chatClient;

    public AiChatService(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    public String chat(String question) {
        return chatClient.prompt()
                .user(question)
                .call()
                .content();
    }
}

call() 表示同步调用,content() 只返回最终文本。如果还需要 Token 使用量、Generation 或模型元数据,应获取完整的 ChatResponse

ChatClient更多使用

设置System和User消息

System 消息用于定义角色、边界和输出要求,User 消息用于传递本次请求。

String answer = chatClient.prompt()
        .system("""
                你是一个 Java 代码审查助手。
                只指出能够确认的问题,并给出修改建议。
                """)
        .user("""
                请检查下面的方法:

                public int divide(int a, int b) {
                    return a / b;
                }
                """)
        .call()
        .content();

需要动态替换提示词参数时,可以使用模板变量:

String answer = chatClient.prompt()
        .system(system -> system
                .text("你是一个技术助手,回答必须使用 {language}")
                .param("language", "中文"))
        .user("解释什么是向量检索")
        .call()
        .content();

不要直接用字符串拼接来自用户的内容构造 System Prompt。固定规则和用户输入应放在不同消息中,降低提示词注入带来的影响。

获取完整响应

ChatResponse response = chatClient.prompt()
        .user("解释 Spring AI Advisor")
        .call()
        .chatResponse();

只需要回答正文时使用 content();需要 Token 使用情况或其他模型信息时使用 chatResponse()

返回Java对象

可以让 ChatClient 将模型输出转换为 Java 类型:

public record BookRecommendation(
        String title,
        String author,
        String reason
) {
}
BookRecommendation recommendation = chatClient.prompt()
        .user("推荐一本适合 Java 开发者学习分布式系统的书")
        .call()
        .entity(BookRecommendation.class);

结构化输出仍由模型生成,反序列化成功不代表内容一定正确。重要业务字段仍需要校验。

流式输出

流式调用返回 Flux,需要 Reactive Web 支持:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
public Flux<String> stream(String question) {
    return chatClient.prompt()
            .user(question)
            .stream()
            .content();
}

流式响应适合聊天页面逐字展示。Tool Calling 本身可能包含阻塞操作,使用流式接口不代表整条调用链都是非阻塞的。

Chat Memory

大语言模型本身是无状态的。需要多轮对话时,应用必须在每次请求中把相关历史消息重新发送给模型。

Chat Memory 用于维护模型当前需要的上下文,不等同于完整聊天记录。需要审计或永久保存全部消息时,应单独设计聊天历史表。

内存会话

MessageWindowChatMemory 只保留指定数量的最近消息:

@Configuration
public class ChatMemoryConfiguration {

    @Bean
    public ChatMemory chatMemory() {
        return MessageWindowChatMemory.builder()
                .maxMessages(20)
                .build();
    }

    @Bean
    public ChatClient memoryChatClient(
            ChatClient.Builder builder,
            ChatMemory chatMemory) {

        return builder
                .defaultAdvisors(
                        MessageChatMemoryAdvisor.builder(chatMemory).build()
                )
                .build();
    }
}

内存存储会在应用重启后丢失,适合本地开发、测试和不需要持久化的临时会话。

指定会话ID

每次使用 Memory Advisor 时都应传入明确的 conversation ID:

public String chat(String conversationId, String question) {
    return chatClient.prompt()
            .user(question)
            .advisors(advisor -> advisor
                    .param(ChatMemory.CONVERSATION_ID, conversationId))
            .call()
            .content();
}

会话 ID 的设计原则:

  • 同一段多轮对话使用相同 ID。
  • 不同用户、租户或业务会话使用不同 ID。
  • 不要把可猜测的会话 ID 直接当成权限凭证。
  • 服务端应校验当前用户是否有权访问该会话。

清理指定会话:

chatMemory.clear(conversationId);

JDBC持久化

需要在应用重启后保留会话上下文时,可以使用 JDBC Chat Memory。

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<!-- 根据实际数据库替换 JDBC Driver -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

  ai:
    chat:
      memory:
        repository:
          jdbc:
            initialize-schema: always
            platform: postgresql

使用自动配置的 JdbcChatMemoryRepository

@Bean
public ChatMemory chatMemory(
        JdbcChatMemoryRepository repository) {

    return MessageWindowChatMemory.builder()
            .chatMemoryRepository(repository)
            .maxMessages(20)
            .build();
}

生产环境通常应使用 Flyway 或 Liquibase 管理表结构,并将 initialize-schema 设置为 never,避免应用启动时自动修改数据库。

Advisor

Advisor 类似 ChatClient 的拦截器,可以在请求发送给模型之前补充内容,也可以在模型响应后执行统一处理。

常见用途:

  • 添加多轮对话记忆。
  • 记录请求和响应。
  • 执行 RAG 检索。
  • 添加公共上下文。
  • 统一修改 Prompt。

注册默认Advisor

@Bean
public ChatClient chatClient(
        ChatClient.Builder builder,
        ChatMemory chatMemory) {

    return builder
            .defaultAdvisors(
                    MessageChatMemoryAdvisor.builder(chatMemory).build(),
                    new SimpleLoggerAdvisor()
            )
            .build();
}

默认 Advisor 会参与该 ChatClient 的每一次请求。

只在当前请求使用

String answer = chatClient.prompt()
        .user(question)
        .advisors(new SimpleLoggerAdvisor())
        .call()
        .content();

Advisor 会根据 getOrder() 排序:

  • order 数值越小,请求阶段越先执行。
  • 响应会沿 Advisor 链反向返回。
  • 多个 Advisor 存在依赖关系时,应明确设置顺序。

开启 SimpleLoggerAdvisor 日志:

logging:
  level:
    org.springframework.ai.chat.client.advisor: DEBUG

日志可能包含用户问题、模型响应和 RAG 上下文。生产环境需要脱敏并限制日志访问权限。

Tool Calling

Tool Calling 允许模型根据工具描述决定是否调用 Java 方法。模型负责生成工具名称和参数,真正的方法执行发生在应用本地。

定义Tool

@Component
public class TimeTools {

    @Tool(description = "获取用户所在时区的当前日期和时间")
    public String getCurrentDateTime() {
        ZoneId zoneId = LocaleContextHolder
                .getTimeZone()
                .toZoneId();

        return ZonedDateTime.now(zoneId).toString();
    }
}

带参数的工具:

@Tool(description = "查询指定城市的天气")
public String getWeather(
        @ToolParam(description = "城市名称,例如:杭州")
        String city) {

    return weatherClient.query(city);
}

工具描述应说明:

  • 什么情况下调用。
  • 每个参数的含义和格式。
  • 返回结果代表什么。

描述不清楚时,模型可能不调用工具,也可能生成错误参数。

注册Tool

为 ChatClient 的所有请求注册:

@Bean
public ChatClient chatClient(
        ChatClient.Builder builder,
        TimeTools timeTools) {

    return builder
            .defaultTools(timeTools)
            .build();
}

只为当前请求注册:

String answer = chatClient.prompt()
        .user("现在几点?")
        .tools(timeTools)
        .call()
        .content();

Tool异常处理

spring:
  ai:
    tools:
      throw-exception-on-error: false
配置 行为
false 将可处理的工具错误交回模型,由模型继续生成响应
true 工具异常直接向调用方抛出

Tool 本质上是本地代码,必须像普通业务接口一样处理参数校验、权限检查、超时、幂等和审计。删除数据、支付、发消息等有副作用的操作,不应仅依赖模型判断是否执行。

Embedding

Embedding Model 会把文本转换为数值向量。语义越接近的文本,其向量通常也越接近。

常见用途:

  • 语义搜索。
  • 文本聚类。
  • 相似内容推荐。
  • RAG 知识检索。

文本转向量

spring-ai-starter-model-openai 已经提供 EmbeddingModel

@Service
public class TextVectorizer {

    private final EmbeddingModel embeddingModel;

    public TextVectorizer(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    public float[] embed(String text) {
        return embeddingModel.embed(text);
    }
}

向量维度由 Embedding Model 决定。更换模型后维度可能变化,已有向量索引通常需要重新创建并重新写入数据。

创建Document

Spring AI 使用 Document 表示需要向量化和检索的内容:

Document document = new Document(
        "Spring AI 提供了统一的模型和向量数据库抽象。",
        Map.of(
                "source_id", "article-001",
                "category", "spring",
                "title", "Spring AI"
        )
);

metadata 用于保存来源、分类和业务 ID。后续需要按 metadata 过滤或删除时,应在写入前设计好字段。

文档切块

长文档不应整体生成一个向量。通常需要先拆成较小的文本块:

TokenTextSplitter splitter =
        new TokenTextSplitter(2000, 100, 5, 10000, true);

List<Document> chunks = splitter.apply(documents);

切块参数需要根据内容和模型上下文调整:

  • 块太大:检索命中后携带的无关内容较多。
  • 块太小:语义不完整,回答缺少上下文。
  • 不同业务文档应分别评估切块大小,不要只使用固定经验值。

按Token分批

切块决定检索粒度,分批只决定一次 Embedding 请求发送多少内容。

@Bean
public BatchingStrategy batchingStrategy() {
    TokenCountEstimator estimator =
            new JTokkitTokenCountEstimator(
                    EncodingType.CL100K_BASE
            );

    return new TokenCountBatchingStrategy(
            estimator,
            8000,
            0.1,
            Document.DEFAULT_CONTENT_FORMATTER,
            MetadataMode.NONE
    );
}
List<List<Document>> batches =
        batchingStrategy.batch(chunks);

上面的 0.1 表示预留 10% Token 空间,降低请求接近模型上限时被拒绝的概率。MetadataMode.NONE 表示 metadata 不参与嵌入文本,但仍可以保存在向量文档中用于过滤。

Vector Store

Vector Store 用于保存文本向量并执行相似度检索。Spring AI 提供统一的 VectorStore 接口,底层可以使用 Redis、PGVector、Milvus、Qdrant、Pinecone 等实现。

基本流程:

  1. 将原始内容封装成 Document
  2. 对长文档进行切块。
  3. 使用 Embedding Model 生成向量。
  4. 写入 Vector Store。
  5. 将用户问题转换为向量并检索相似文档。

Redis Vector Store

Redis 方案需要 Redis Stack,普通 Redis 只支持基础数据结构,不能直接使用向量索引和相似度搜索。

引入依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-redis</artifactId>
</dependency>

基本配置:

spring:
  data:
    redis:
      host: ${REDIS_HOST}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}

  ai:
    vectorstore:
      redis:
        initialize-schema: true
        index-name: spring-ai-index
        prefix: "spring-ai:"

需要声明可过滤的 metadata 字段时,可以手动创建 RedisVectorStore

@Configuration
public class RedisVectorConfiguration {

    @Bean
    public JedisPooled jedisPooled(
            @Value("${spring.data.redis.host}") String host,
            @Value("${spring.data.redis.port}") int port) {

        return new JedisPooled(host, port);
    }

    @Bean
    public RedisVectorStore redisVectorStore(
            JedisPooled jedisPooled,
            EmbeddingModel embeddingModel,
            @Value("${spring.ai.vectorstore.redis.index-name}")
            String indexName,
            @Value("${spring.ai.vectorstore.redis.prefix}")
            String prefix) {

        return RedisVectorStore
                .builder(jedisPooled, embeddingModel)
                .indexName(indexName)
                .prefix(prefix)
                .initializeSchema(true)
                .metadataFields(
                        RedisVectorStore.MetadataField.tag("source_id"),
                        RedisVectorStore.MetadataField.tag("category")
                )
                .build();
    }
}

如果 Redis 开启了用户名、密码或 TLS,应使用相应的 JedisClientConfig 创建连接,不要把密码写在 Java 代码中。

写入文档

Document document = new Document(
        "Spring AI 可以通过 Advisor 组合聊天记忆和 RAG。",
        Map.of(
                "source_id", "article-001",
                "category", "spring"
        )
);

vectorStore.add(List.of(document));

写入时会调用 Embedding Model,因此需要同时保证模型服务和 Vector Store 可用。

写入长文档:

List<Document> chunks = splitter.apply(documents);

for (List<Document> batch : batchingStrategy.batch(chunks)) {
    vectorStore.add(batch);
}

相似度搜索

SearchRequest request = SearchRequest.builder()
        .query("如何给对话增加历史记忆?")
        .topK(5)
        .similarityThreshold(0.7)
        .filterExpression("category == 'spring'")
        .build();

List<Document> results =
        vectorStore.similaritySearch(request);

常用参数:

参数 作用
query 需要检索的自然语言问题
topK 最多返回多少条文档
similarityThreshold 最低相似度阈值
filterExpression 按 metadata 过滤

检索为空时,先去掉 metadata 过滤条件,再降低相似度阈值排查。不要一开始就把阈值设置得过高。

删除文档

已知文档 ID 时可以按 ID 删除:

vectorStore.delete(List.of(documentId));

需要删除一篇原始文档产生的全部切块时,使用稳定的业务 ID:

vectorStore.delete("source_id == 'article-001'");

不要使用空字符串相似度搜索后再根据 topK 删除,检索结果可能被截断,导致部分切块残留。

更新文档

大多数 Vector Store 没有通用的原地更新语义。更新原始文档通常采用“删除旧切块,再写入新切块”:

String sourceId = "article-001";

vectorStore.delete(
        "source_id == '" + sourceId + "'"
);

Document replacement = new Document(
        newContent,
        Map.of(
                "source_id", sourceId,
                "category", "spring"
        )
);

List<Document> newChunks =
        splitter.apply(List.of(replacement));

vectorStore.add(newChunks);

生产代码需要考虑“删除成功但新增失败”的情况,可以使用重试、补偿任务、版本字段或新旧索引切换降低数据不一致风险。

metadata字段变更

initializeSchema(true) 通常只负责创建不存在的索引,不会自动修改已经存在的索引结构。

新增可过滤 metadata 字段后,需要:

  1. 修改 Vector Store 中的 metadata 字段声明。
  2. 创建新索引或重建旧索引。
  3. 重新写入文档向量。

生产环境不建议直接删除正在使用的索引,可以创建带版本号的新索引,完成数据迁移和验证后再切换。

RAG

RAG(Retrieval Augmented Generation,检索增强生成)的核心流程是:

  1. 接收用户问题。
  2. 从 Vector Store 检索相关文档。
  3. 将文档作为上下文加入 Prompt。
  4. 调用 Chat Model 生成回答。

RAG 不会自动保证答案正确。检索质量、切块策略、Prompt 约束和模型能力都会影响最终结果。

QuestionAnswerAdvisor

只需要常规的“检索 → 注入上下文 → 回答”流程时,可以使用 QuestionAnswerAdvisor

引入依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
QuestionAnswerAdvisor qaAdvisor =
        QuestionAnswerAdvisor.builder(vectorStore)
                .build();

String answer = chatClient.prompt()
        .user("Spring AI 如何保存多轮对话?")
        .advisors(qaAdvisor)
        .call()
        .content();

自定义 RAG Prompt:

PromptTemplate promptTemplate = PromptTemplate.builder()
        .renderer(StTemplateRenderer.builder()
                .startDelimiterToken('<')
                .endDelimiterToken('>')
                .build())
        .template("""
                <query>

                请只根据下面的资料回答问题。
                如果资料中没有答案,请直接回答“不知道”。

                ---------------------
                <question_answer_context>
                ---------------------
                """)
        .build();

QuestionAnswerAdvisor qaAdvisor =
        QuestionAnswerAdvisor.builder(vectorStore)
                .promptTemplate(promptTemplate)
                .build();

Spring AI 1.1.0 的自定义模板需要保留 <query><question_answer_context> 占位符,否则 Advisor 无法正确注入问题和检索结果。

RetrievalAugmentationAdvisor

需要自定义查询改写、文档检索和上下文增强流程时,使用模块化 RAG。

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-rag</artifactId>
</dependency>
RewriteQueryTransformer queryTransformer =
        RewriteQueryTransformer.builder()
                .chatClientBuilder(
                        ChatClient.builder(chatModel)
                )
                .build();

DocumentRetriever documentRetriever =
        VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .similarityThreshold(0.5)
                .topK(4)
                .build();

Advisor ragAdvisor =
        RetrievalAugmentationAdvisor.builder()
                .queryTransformers(queryTransformer)
                .documentRetriever(documentRetriever)
                .queryAugmenter(
                        ContextualQueryAugmenter.builder()
                                .allowEmptyContext(false)
                                .build()
                )
                .build();

String answer = chatClient.prompt()
        .user(question)
        .advisors(ragAdvisor)
        .call()
        .content();

查询改写最好使用独立的裸 ChatClient

ChatClient.builder(chatModel)

如果从业务 ChatClient 调用 mutate(),查询改写可能继承默认 System Prompt、Chat Memory、Tools 或其他 Advisor,从而干扰改写结果。

空上下文策略:

设置 行为
allowEmptyContext(false) 没有检索到资料时,不允许模型脱离知识库自由回答
allowEmptyContext(true) 没有检索结果时仍继续调用模型

企业知识、法规、财务等强约束场景通常应设置为 false,并明确设计“未找到答案”的响应。

常见问题

现象 常见原因 处理方法
ChatClient 无法注入 没有引入模型 Starter,或存在多个 Chat Model 检查依赖;为主要模型设置 @Primary 或手动创建 Builder
模型调用返回 401 API Key 无效或没有传入环境变量 检查密钥、环境变量和模型平台权限
模型名称不存在 Chat Model 与 Embedding Model 配置混用 分别检查 chat 和 embedding 下的 model
流式调用无法工作 没有引入 WebFlux,或使用的模型不支持流式输出 引入 WebFlux 并检查模型能力
多轮对话没有上下文 没有注册 Memory Advisor,或 conversation ID 不一致 检查默认 Advisor 和每次请求的会话 ID
不同用户对话串线 多个用户共用 conversation ID 按用户和业务会话生成隔离的 ID,并进行权限校验
Tool 没有执行 模型不支持 Tool Calling、工具未注册或描述不清 检查模型能力、.tools()/.defaultTools() 和工具描述
普通 Redis 可用但向量检索失败 Redis 不包含 Search 和 Vector 能力 使用 Redis Stack 或支持向量检索的 Redis 服务
Redis 报索引不存在 索引未初始化或索引名不一致 检查 initialize-schemaindex-name
metadata 过滤无效 字段没有加入索引,或旧索引结构未更新 声明 metadata 字段并重建索引
向量检索一直为空 未写入数据、阈值过高或过滤条件不匹配 确认数据,去掉过滤条件并逐步降低阈值
RAG 不使用知识库 没有注册 Advisor、检索为空或模板占位符错误 检查检索结果、Advisor 和 PromptTemplate
RAG 产生无依据回答 允许空上下文,或 Prompt 没有限制 禁止空上下文并明确无答案策略
日志包含敏感内容 开启了 Advisor DEBUG 日志 关闭生产 DEBUG 日志并进行脱敏

开发注意事项

  • API Key、数据库密码和 Redis 密码不要写入代码或提交到仓库。
  • Prompt、Tool 参数和模型结构化输出都需要做业务校验。
  • Tool 具有本地代码权限,涉及副作用时必须增加鉴权、幂等和审计。
  • Chat Memory 只保存模型所需上下文,完整聊天记录应独立存储。
  • Embedding Model 变更后,应检查向量维度并重新生成已有向量。
  • 文档写入 Vector Store 前,应先确定业务 ID、metadata 和切块策略。
  • metadata 索引结构发生变化时,应执行索引迁移。
  • RAG 上线前,应准备固定问题集评估召回率和回答准确率。
  • 模型、数据库和向量库调用都可能失败,应设置超时、重试和降级策略。
  • 对用户输入、RAG 上下文和模型输出进行日志脱敏。

参考