← 返回博客
2026-07-24 14:12:24

Spring AI 零基础实战:从第一个聊天接口到企业级 RAG 知识库

Spring AI 零基础实战:从第一个聊天接口到企业级 RAG 知识库

上个月,Leader 找到你说:「咱们那个客服后台,每天有一半的咨询都是重复问题——退货流程、运费规则、发货时效。你能不能搞个 AI 自动回复?」

你的反应可能是:AI?那不得学 Python、搞模型部署、折腾 CUDA?写的可是 Java。

然后你发现了 Spring AI —— 一个直接把大模型能力接入 Spring Boot 生态的框架。它做的事情一句话就能说清楚:让你用写 Spring Boot 的方式调用大模型@RestController 你怎么写,调用 ChatGPT/DeepSeek/通义千问你就怎么写。

这篇文章用一个完整的电商智能客服项目贯穿始终,从零搭建到上线,每一步都有能跑通的代码。

1. 为什么要在 Java 里调 AI

先回答一个问题:如果用 Python 包一个 AI 服务,Java 去调它,不也行吗?

确实行。但如果你只是想做「把用户问题发给大模型,把答案返回来」,引入一个 Python 服务就多了好几层复杂度:服务间通信、错误处理、部署和运维、两套代码库的维护。

Spring AI 让你在同一个 Spring Boot 应用里完成这些事:

如果你现在的项目是 Spring Boot,那引入 AI 能力只需要加一个依赖、写几行配置。

2. 10 分钟跑起来:第一个聊天接口

2.1 创建项目

用 Spring Initializr 或者在你的 pom.xml 里加这几个依赖:

<!-- Spring Boot 3.x Web -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Spring AI OpenAI Starter —— 兼容所有 OpenAI 格式的 API -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-openai</artifactId>
    <version>1.0.0-M6</version>
</dependency>

为什么用 OpenAI Starter 而不是某个特定模型的 Starter?因为国内几乎所有大模型服务——DeepSeek、通义千问、智谱 GLM、Moonshot——都提供了 OpenAI 兼容的 API 端点。用这一个依赖,换个 Base URL 和 API Key 就能在它们之间切换,不需要改代码。

2.2 配置

application.yml

spring:
  ai:
    openai:
      # DeepSeek 的 OpenAI 兼容端点
      base-url: https://api.deepseek.com/v1
      api-key: ${DEEPSEEK_API_KEY}
      chat:
        options:
          model: deepseek-chat
          temperature: 0.7

${DEEPSEEK_API_KEY} 用环境变量注入,不要硬编码在配置文件里。如果你换通义千问,只需要改 base-urlapi-keymodel 三个值。

这里有一个容易忽略的点:temperature 控制回答的随机性。客服场景设 0.3~0.5 比较合适——太低了回答机械,太高了可能胡说。0.7 偏创意,0.1 偏保守。这个值你要根据业务场景在做测试时调整,不要一次设完就不管了。

2.3 第一行代码

@RestController
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping("/chat")
    public String chat(@RequestParam String question) {
        return chatClient.prompt()
            .user(question)
            .call()
            .content();
    }
}

启动项目,浏览器打开:

http://localhost:8080/chat?question=退货流程是什么

你会收到大模型的回复。

关键认知ChatClient 是 Spring AI 的核心入口,类似于 RestTemplate 在 HTTP 调用中的地位。.prompt().user(...).call().content() 这个链式调用背后,Spring AI 帮你做了:序列化请求 → 调 OpenAI 兼容 API → 解析响应 → 提取文本内容。你不需要关心 HTTP 细节、Token 拼接、流式处理——至少入门阶段不需要。

2.4 加上系统提示词

裸调大模型有两个问题:第一,它不知道它是谁;第二,它可能回答超出客服范围的问题。

@GetMapping("/chat")
public String chat(@RequestParam String question) {
    return chatClient.prompt()
        .system("你是一个电商客服助手。只回答跟订单、退货、物流、商品相关的问题。" +
                "如果用户问其他问题,礼貌地拒绝。语气友好简洁。")
        .user(question)
        .call()
        .content();
}

system 消息是「角色设定」,它不会被用户看到,但会严重影响模型的行为。写 system prompt 有几点经验:

3. 让 AI 返回结构化数据

现在客服能聊天了,但 Leader 提了新需求:「能不能让 AI 判断用户的意图?是退货、查物流、还是投诉?」

这意味着我们不能只拿一段文本回来——我们需要 AI 返回一个 JSON。

3.1 定义输出结构

public record IntentResult(
    @JsonProperty("intent") String intent,      // "refund" / "logistics" / "complaint" / "other"
    @JsonProperty("confidence") double confidence,
    @JsonProperty("summary") String summary    // 一句话概括用户诉求
) {}

3.2 让 AI 输出 JSON

@GetMapping("/intent")
public IntentResult detectIntent(@RequestParam String question) {
    return chatClient.prompt()
        .system("""
            你是一个客服意图识别器。根据用户输入判断意图,以 JSON 格式返回。
            
            意图分类:
            - refund: 退货、退款、换货
            - logistics: 物流查询、发货时间、快递
            - complaint: 投诉、不满、要求赔偿
            - other: 其他问题
            
            返回格式:{"intent": "...", "confidence": 0.0-1.0, "summary": "..."}
            只返回 JSON,不要加任何解释文字。
            """)
        .user(question)
        .call()
        .entity(IntentResult.class);  // 自动反序列化为 Java 对象
}

.entity(IntentResult.class) 这行是 Spring AI 帮你做的事情:拿到模型返回的文本 → 自动解析 JSON → 映射到你的 Java Record。如果模型返回的不是合法 JSON(比如前面多了「好的,这是识别结果:」这种废话),这里会抛异常。

生产环境的经验:永远不要假设模型会严格遵守你的 JSON 格式要求。加上「只返回 JSON,不要加任何解释文字」这句话能解决大部分情况,但少数时候模型还是会加废话。更稳健的做法是自定义一个 Converter,在解析前先把文本里的 JSON 提取出来:

@Component
public class JsonExtractorConverter implements Converter<String, IntentResult> {
    
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public IntentResult convert(String source) {
        // 提取 { 到 } 之间的内容,防止模型加了额外文字
        int start = source.indexOf('{');
        int end = source.lastIndexOf('}');
        if (start >= 0 && end > start) {
            source = source.substring(start, end + 1);
        }
        try {
            return objectMapper.readValue(source, IntentResult.class);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("AI 返回了非法 JSON: " + source, e);
        }
    }
}

Converter 而不是直接 .entity() 的思路是:把容错逻辑封装起来,不要散落在每个调用点。你写得越健壮,线上半夜被叫醒的概率就越低。

4. 让 AI 读懂你的业务文档——RAG 实战

意图识别做好之后,Leader 说:「现在 AI 能知道用户要退货,但它不知道退货规则是什么。咱们有退货政策、运费标准、常见问题 FAQ 这些文档,能不能让 AI 去读?」

这就是 RAG(检索增强生成)要解决的问题:先检索相关文档,再把文档内容作为上下文喂给大模型

4.1 RAG 三步走

整个 RAG 流程分三步,每一步 Spring AI 都有对应的抽象:

文档导入 → 向量化存储 → 检索 + 生成答案
(ETL)      (VectorStore)   (RetrievalAugmentation)

第一步:把文档拆成小块,转成向量,存进向量库。

为什么要拆小块?大模型一次能处理的上下文有限(比如 deepseek-chat 是 64K token)。如果你把整篇退货政策(可能几千字)全部塞进去,留给用户问题和历史对话的空间就不多了。拆成几百字的小块,每次只检索最相关的几块。

第二步:用户提问时,把问题也转成向量,在向量库里找最相似的文档块。

用向量的「距离」来度量语义相似度:「退货怎么操作」和「如何申请退款」距离很近,「退货怎么操作」和「今天天气不错」距离很远。

第三步:把找到的文档块拼进 system prompt,让模型基于这些资料回答。

4.2 代码实现

先用内存向量库快速跑通(生产环境换成 Redis 或 PostgreSQL + PGVector):

@Configuration
public class RagConfig {

    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return new SimpleVectorStore(embeddingModel);
    }
    
    // ETL 管道:读取文档 → 分块 → 向量化 → 存入向量库
    @Bean
    public CommandLineRunner loadDocuments(VectorStore vectorStore) {
        return args -> {
            // 读取 resources/faq/ 下的所有 txt 文件
            Resource[] resources = new PathMatchingResourcePatternResolver()
                .getResources("classpath:/faq/*.txt");
            
            // TokenTextSplitter:按 token 数分块,每块 500 token,块间重叠 50 token
            // 重叠是为了避免一个完整知识点被切在两块的边界上
            TextSplitter splitter = new TokenTextSplitter(
                500, 50, 10, 1000, true
            );
            
            List<Document> documents = new ArrayList<>();
            for (Resource resource : resources) {
                String text = resource.getContentAsString(StandardCharsets.UTF_8);
                // 分块后每个 Document 带上来源文件名作为元数据
                List<Document> splitDocs = splitter.split(
                    new Document(text, Map.of("source", resource.getFilename()))
                );
                documents.addAll(splitDocs);
            }
            
            // 批量向量化并写入向量库
            vectorStore.add(documents);
            System.out.println("已加载 " + documents.size() + " 个文档块");
        };
    }
}

这段代码在应用启动时自动执行,把 resources/faq/ 目录下的文档向量化后存入内存向量库。生产环境你不会每次都重新加载——数据量大时应该用计划任务增量同步。

第二步和第三步:检索 + 生成

@RestController
public class CustomerServiceController {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    public CustomerServiceController(ChatClient.Builder builder, VectorStore vectorStore) {
        this.chatClient = builder.build();
        this.vectorStore = vectorStore;
    }

    @GetMapping("/ask")
    public String ask(@RequestParam String question) {
        // 1. 从向量库检索最相关的 3 个文档块
        List<Document> relevantDocs = vectorStore.similaritySearch(
            SearchRequest.query(question).withTopK(3)
        );
        
        // 2. 把文档内容拼成上下文
        String context = relevantDocs.stream()
            .map(doc -> "【来源:" + doc.getMetadata().get("source") + "】\n" + doc.getContent())
            .collect(Collectors.joining("\n\n"));
        
        // 3. 用这个上下文构造 prompt
        return chatClient.prompt()
            .system("""
                你是一个电商客服助手。请根据以下资料回答用户问题。
                如果资料中没有相关信息,请如实说「抱歉,我目前没有这方面的信息」。
                不要编造任何资料中没有的内容。
                
                === 参考资料 ===
                %s
                """.formatted(context))
            .user(question)
            .call()
            .content();
    }
}

这段代码三个步骤一一对应:检索 → 拼上下文 → 生成。withTopK(3) 取最相关的 3 个文档块——不要太多,多了会稀释关键信息;也不要太少,少了可能漏掉相关信息。3 到 5 是一个经验范围。

4.3 生产环境的向量库选择

SimpleVectorStore 是内存实现,重启就没了。生产环境推荐:

方案适用场景理由
Redis Stack已有 Redis 的项目零额外部署,spring-ai-starter-redis 直接切换
PGVector已有 PostgreSQL 的项目向量和业务数据同库,一条 SQL 搞定
Elasticsearch已有 ES 的项目全文搜索 + 向量搜索一体化

切换方式就是改一个 Bean,比如 Redis:

@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel, RedisTemplate<String, Object> redis) {
    return new RedisVectorStore(/* ... */);
}

Spring AI 的 VectorStore 接口屏蔽了底层实现差异,你的检索代码不需要改。

5. AI 帮你查订单——Function Calling

现在客服 AI 能回答政策问题了,但 Leader 又提了需求:「用户问『我的订单到哪了』,AI 能查出来吗?」

这需要 AI 能调用你的业务系统——查询数据库、调物流接口。在 AI 领域这个能力叫 Function Calling:你定义一些函数(工具)给 AI,AI 自己判断什么时候该调用哪个函数,调用完了把结果带回对话。

5.1 定义工具

@Component
public class OrderTools {

    private final OrderService orderService;

    public OrderTools(OrderService orderService) {
        this.orderService = orderService;
    }

    @Tool(description = "根据用户手机号查询最近的订单列表")
    public List<OrderInfo> getUserOrders(
        @ToolParam(description = "用户手机号") String phone) {
        return orderService.findByPhone(phone);
    }

    @Tool(description = "根据订单号查询物流详情")
    public LogisticsInfo getLogistics(
        @ToolParam(description = "订单号") String orderId) {
        return orderService.getLogistics(orderId);
    }
}

@Tool@ToolParamdescription 不是写给人看的注释——它们是写给 AI 看的。AI 会根据这些描述判断「用户问的是不是这个函数能解决的问题」。描述写模糊了,AI 就可能不调用或者乱调用。

描述写好的几个原则:

5.2 注册工具并使用

@RestController
public class AgentController {

    private final ChatClient chatClient;
    private final OrderTools orderTools;

    public AgentController(ChatClient.Builder builder, OrderTools orderTools) {
        this.orderTools = orderTools;
        this.chatClient = builder
            .defaultTools(orderTools)  // 注册工具
            .build();
    }

    @GetMapping("/agent")
    public String agent(@RequestParam String question) {
        return chatClient.prompt()
            .system("""
                你是电商智能客服。你可以查询用户的订单信息和物流状态。
                
                当用户询问订单相关问题时:
                1. 先获取用户手机号(如果用户没提供,先问手机号)
                2. 用手机号查询订单
                3. 整理成清晰的信息回复用户
                
                回复时把关键信息(订单号、状态、物流)用简洁的格式展示。
                """)
            .user(question)
            .call()
            .content();
    }
}

运行测试:

用户:我的订单到哪了?
AI:请问您的手机号是?
用户:13812345678
AI:[AI 自动调用 getUserOrders → 拿到订单列表 → 再调用 getLogistics → 整理回复]
    您最近有两个订单:
    1. 订单 ORD20240701001 — 已发货,快递单号 SF12345678,当前在【上海分拣中心】
    2. 订单 ORD20240703005 — 正在拣货中

这里面发生了什么?

当你调用 .call() 时,Spring AI 做了这样一个循环:

  1. 把用户消息 + 可用工具列表发给大模型
  2. 大模型判断:我需要调 getUserOrders("13812345678")
  3. Spring AI 拦截这个请求,在你的 JVM 里执行 getUserOrders 方法
  4. 把函数返回值作为一条新消息发给大模型
  5. 大模型基于返回值生成最终回复

这就是一个最简单的 Agent 循环——模型思考、调用工具、拿到结果、再思考。Spring AI 帮你封装了这个循环,你的代码跟写普通 Service 没什么区别。

5.3 为什么这个能力很关键

没有 Function Calling 之前,你跟大模型说「帮我查订单」,它只能编一个。这是大模型最被诟病的问题——幻觉

有了 Function Calling,大模型不需要「记住」订单数据,它只需要知道「有这个函数可以查」,数据还是从你的数据库实时拿的。AI 从「一本正经地胡说八道」变成了「知道该找谁要证据」。

6. 生产环境落地的几个关键点

以上代码跑通了 Demo。但 Demo 和上线的差距,就在这几个细节里。

6.1 流式输出(SSE)

聊天场景下,等 5 秒才一次性吐出全部回复,用户体验很差。流式输出让字一个一个蹦出来:

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String question) {
    return chatClient.prompt()
        .user(question)
        .stream()
        .content();
}

前端用 EventSource 接收:

const eventSource = new EventSource('/chat/stream?question=退货流程');
eventSource.onmessage = (event) => {
    // event.data 是一段一段的文本,追加到聊天框里
    chatBox.innerHTML += event.data;
};

注意Flux 默认在调用线程上发送数据。如果你的 SSE 连接很多,记得配置一个专门的 Scheduler,避免阻塞 Tomcat 线程。

6.2 异常处理

大模型 API 可能超时、限流、返回格式错误。不能把这些异常直接抛给用户。

@GetMapping("/chat")
public ResponseEntity<?> chat(@RequestParam String question) {
    try {
        String result = chatClient.prompt()
            .user(question)
            .call()
            .content();
        return ResponseEntity.ok(Map.of("answer", result));
    } catch (AiClientException e) {
        // API 层面的错误:超时、限流、认证失败
        log.error("AI API 调用失败", e);
        return ResponseEntity.status(502)
            .body(Map.of("error", "AI 服务暂时不可用,请稍后重试"));
    } catch (AiOutputParserException e) {
        // 返回内容解析失败(比如期望 JSON 但拿到了文本)
        log.error("AI 返回内容解析失败", e);
        return ResponseEntity.status(500)
            .body(Map.of("error", "系统处理异常,请重试"));
    }
}

两种异常要区分处理:

6.3 费用控制

大模型 API 按 Token 计费。一个客服系统每天可能有上千次调用,如果不控制,月底账单能让人血压升高。

两个最有效的控制手段:

1. 缓存常见问题

@Cacheable(value = "faq_cache", key = "#question")
public String ask(String question) {
    // RAG 查询 + 大模型调用
}

用户问「退货流程」和「怎么退货」虽然文字不同,但答案可能完全一样。用 Redis 缓存高频问题的答案,命中一次就省一次 API 调用。

2. 设置 Token 上限

spring:
  ai:
    openai:
      chat:
        options:
          max-tokens: 500  # 单次回答最长 500 token

客服回答一般不需要太长,设 300~500 token 足够。既控制了成本,也避免了模型啰嗦。

7. 从 Demo 到上线

回顾一下我们做了什么:

  1. 一个 /chat 接口 —— 10 分钟,AI 能聊天了
  2. 结构化输出 —— 意图识别能返回 JSON 了
  3. RAG —— AI 能查你的业务文档了
  4. Function Calling —— AI 能调你的数据库和接口了
  5. 流式输出 + 异常处理 + 缓存 —— 可以上线了

这五个步骤不是平级的:1 和 2 是基本功,3 和 4 是核心价值,5 是生产保障。

上线 checklist

在把 Spring AI 项目推上生产之前,按这个清单过一遍:

下一步建议

如果你现在有一个 Spring Boot 项目,最推荐的切入点不是聊天机器人,而是用结构化输出改造一个现有的文本处理场景。比如:

这些场景不需要 RAG 不需要 Function Calling,只需要「调 API → 拿 JSON → 写入数据库」。改动最小、见效最快、风险最低。等团队熟悉了 AI 调用的节奏,再上 RAG 和 Function Calling 解决更复杂的问题。


写于 2026 年 7 月 24 日