Spring AI 摸鱼指南:ChatClient 是接线员,PromptTemplate 是剧本,组合起来就是开发神器

45 阅读23分钟

Spring AI 摸鱼指南:ChatClient 是接线员,PromptTemplate 是剧本,组合起来就是开发神器

作者说在前头:这篇文章不是让你背 API 文档的,而是让你笑着学会怎么用 Spring AI 开发应用。如果你看完还觉得 Spring AI 是个玄学,那一定是我写得不够接地气,欢迎来打我(开玩笑的)。

重要提示:本文基于 Spring AI 1.1.x 稳定版本编写,代码示例可直接运行。使用里程碑版本(如 M4)可能存在 API 变更,请谨慎使用。

一、开篇:Spring AI 是什么?——你的"翻译官+保姆"

想象一下这个场景:你是个 Java 程序员,老板突然说"咱们接个大模型,做个智能客服吧"。你打开 ChatGPT 的 API 文档,看着一堆 HTTP 请求、JSON 解析、错误处理,瞬间头大——这玩意儿比写 CRUD 复杂多了!

Spring AI 就是来拯救你的。它不是什么高大上的黑科技,而是 Spring 官方推出的一个框架,专门帮 Java 程序员"驯服"大模型。你可以把它理解成:

  • 翻译官:把 Java 代码翻译成大模型能听懂的话,不用你手写 HTTP 请求
  • 保姆:自动处理连接、重试、错误处理这些脏活累活,你只管写业务逻辑

今天我们要聊的两个核心概念——ChatClientPromptTemplate,就是 Spring AI 里的"黄金搭档":

  • PromptTemplate:大模型的"剧本模板",负责把提示词写得规范、可复用
  • ChatClient:你的"大模型专属接线员",负责帮你传话、把回复带回来

有了这俩,开发 AI 应用就像点外卖一样简单:选好模板(剧本),告诉接线员(ChatClient)要什么,坐等结果。


二、Spring AI 基础准备:先有锅和油,才能炒菜

2.1 环境要求:版本匹配很重要

就像炒菜要先有锅和油,用 Spring AI 之前,你得先准备好这些:

  • JDK 17+:别拿 JDK 8 硬刚,Spring AI 最低要求 JDK 17,不然启动就报错,就像用平底锅做爆炒,会翻车的
  • Spring Boot 3.1+:Spring AI 是基于 Spring Boot 3.x 的,2.x 版本不支持

版本匹配关系(重要!):

  • Spring AI 1.0.x → 适配 Spring Boot 3.1.x - 3.2.x
  • Spring AI 1.1.x → 适配 Spring Boot 3.2.x - 3.3.x
  • 里程碑版本(如 1.0.0-M4)仅适配特定 Spring Boot 版本,且 API 可能变更,生产环境不推荐使用

Maven 依赖配置(使用稳定版本):

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
</parent>

<dependencies>
    <!-- Spring AI 全家桶套餐,一键配齐所有工具 -->
    <!-- 使用稳定版本,避免 API 变更导致的问题 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
        <version>1.1.0</version>
    </dependency>
    
    <!-- 如果要用其他大模型,比如智谱 AI -->
    <!--
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-zhipuai-spring-boot-starter</artifactId>
        <version>1.1.0</version>
    </dependency>
    -->
    
    <!-- 测试依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Gradle 配置

dependencies {
    implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.1.0'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

关键依赖说明

  • spring-ai-openai-spring-boot-starter:这是 Spring AI 的"全家桶套餐",包含了 ChatClient、PromptTemplate 等所有核心组件,还自动配置好了连接池、重试机制等,不用你手写
  • 版本选择:优先使用稳定版本(1.1.x),里程碑版本(M4、M5)仅用于尝鲜,生产环境请使用稳定版

2.2 配置 API 密钥:别把密钥写在代码里,钱包会哭

重要警告:千万别把 API 密钥直接写在代码里!我见过好几个程序员把密钥硬编码在代码里,结果代码一上传 GitHub,第二天就收到"大模型欠费账单",几百块说没就没。

正确做法:用环境变量或配置文件

application.yml 中配置:

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}  # 从环境变量读取,安全第一
      # 自定义 API 端点 URL(可选,默认使用官方端点)
      # 使用场景:① 通过代理访问(国内访问 OpenAI);② 使用自建大模型服务;③ 使用不同区域的 API 端点
      base-url: https://api.openai.com  # 默认值,可改为代理地址或自建服务地址
      chat:
        options:
          model: gpt-3.5-turbo  # 指定模型,gpt-3.5-turbo 或 gpt-4
          temperature: 0.7      # 生成随机性(0-2),0.7 是平衡值
          max-tokens: 500       # 最大回复令牌数(注意:gpt-3.5-turbo 默认上限 4096,gpt-4 上限更高)

或者在 application.properties 中:

spring.ai.openai.api-key=${OPENAI_API_KEY}
# 自定义 API 端点 URL(可选)
spring.ai.openai.base-url=https://api.openai.com
spring.ai.openai.chat.options.model=gpt-3.5-turbo
spring.ai.openai.chat.options.temperature=0.7
spring.ai.openai.chat.options.max-tokens=500

自定义 API URL 的使用场景

  1. 通过代理访问 OpenAI(国内开发者常用):

    spring:
      ai:
        openai:
          api-key: ${OPENAI_API_KEY}
          base-url: https://your-proxy-server.com  # 代理服务器地址
    
  2. 使用自建大模型服务(如本地部署的模型):

    spring:
      ai:
        openai:
          api-key: your-local-api-key  # 自建服务的密钥
          base-url: http://localhost:8080/v1  # 自建服务的 API 地址
    

    注意:自建服务需要兼容 OpenAI API 格式,否则可能无法正常工作。

  3. 使用不同区域的 API 端点

    spring:
      ai:
        openai:
          api-key: ${OPENAI_API_KEY}
          base-url: https://api.openai.com  # 默认美国区域
          # 或使用其他区域(如果 OpenAI 提供)
          # base-url: https://api-eu.openai.com  # 欧洲区域(示例)
    

设置环境变量(Linux/Mac):

export OPENAI_API_KEY=sk-xxxxxxxxxxxxx

Windows 设置

set OPENAI_API_KEY=sk-xxxxxxxxxxxxx

生产环境配置密钥的落地方法

  1. 使用配置中心(Nacos)

    spring:
      cloud:
        nacos:
          config:
            server-addr: localhost:8848
            file-extension: yml
    

    在 Nacos 控制台创建配置,key 为 spring.ai.openai.api-key,value 为加密后的密钥。

  2. 使用 Kubernetes Secret

    # 在 Spring Boot 中通过环境变量读取
    env:
      - name: OPENAI_API_KEY
        valueFrom:
          secretKeyRef:
            name: openai-secret
            key: api-key
    
  3. API 密钥权限最小化

    • OpenAI:在 API 密钥设置中,仅开通 chat.completions 接口权限
    • 设置调用额度限制(如每月 100 美元),避免异常调用导致巨额费用
    • 定期轮换密钥,降低泄露风险

三、核心概念 1:PromptTemplate——大模型的"剧本模板"

3.1 什么是 PromptTemplate?——拍电影的剧本模板

PromptTemplate 就像拍电影的剧本模板:固定好台词框架,只需要填演员名字、场景就能直接用。

没有模板时的痛点

想象一下,每次写提示词都要重新拼接字符串:

String prompt = "请帮我写一段关于" + topic + "的朋友圈文案,要求" + length + "字以内,带点小幽默";

这样写有几个问题:

  1. 容易出错:少个引号、多个空格,大模型就"一脸懵"
  2. 不可复用:换个场景又要重新写,就像"每次写信都要重新发明信封格式"
  3. 难维护:提示词改个标点,要在代码里找半天

有了 PromptTemplate 后

import org.springframework.ai.chat.prompt.PromptTemplate;
import java.util.HashMap;
import java.util.Map;

PromptTemplate template = new PromptTemplate(
    "请帮我写一段关于 {topic} 的朋友圈文案,要求 {length} 字以内,带点小幽默,别太官方"
);

就像填快递单,固定格式,只换收件人信息。

3.2 核心特性:占位符语法和模板复用

占位符语法:用 {} 包裹变量名

PromptTemplate template = new PromptTemplate(
    "请给 {name} 写一句 {festival} 祝福语"
);

渲染模板:传入参数,生成最终提示词

Map<String, Object> variables = new HashMap<>();
variables.put("name", "张三");
variables.put("festival", "春节");

String renderedPrompt = template.render(variables);
// 结果:请给 张三 写一句 春节 祝福语

模板复用:一个模板可以给不同的业务场景用,就像"万能检讨书模板",改个名字就能交差。

3.3 高级特性:默认值、多段模板、模板校验

1. 设置默认值:如果某个参数未传入,使用默认值

PromptTemplate template = PromptTemplate.create(
    "请帮我写一段关于 {topic} 的朋友圈文案,要求 {length} 字以内"
)
.withDefault("length", 50);  // 如果未传入 length,默认使用 50

Map<String, Object> variables = new HashMap<>();
variables.put("topic", "摸鱼学 Spring AI");
// 不传 length,会自动使用默认值 50
String prompt = template.render(variables);

2. 多段模板拼接:组合多个模板片段

PromptTemplate systemTemplate = new PromptTemplate("你是一个专业的朋友圈文案写手");
PromptTemplate userTemplate = new PromptTemplate("请写一段关于 {topic} 的文案");

// 在实际使用中,systemTemplate 会作为系统提示,userTemplate 作为用户提示
// 这涉及到 Prompt 对象的多角色消息,后面会详细说明

3. PromptTemplate 与 Prompt 的关系

  • PromptTemplate 渲染后生成 Prompt 对象
  • Prompt 包含多个 Message(系统提示、用户提示等),支持多角色对话
  • 简单场景下,PromptTemplate.render() 返回字符串即可;复杂场景需要构建 Prompt 对象
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;

// 构建多角色 Prompt
Prompt prompt = new Prompt(
    Arrays.asList(
        new SystemMessage("你是一个专业的朋友圈文案写手"),
        new UserMessage("请写一段关于摸鱼学 Spring AI 的文案")
    )
);

3.4 实战案例:朋友圈文案生成器(完整版,含异常处理)

完整代码实现

package com.example.springai.service;

import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.HashMap;
import java.util.Map;

@Service
public class WeChatMomentService {
    
    // 定义模板:固定格式,只换话题
    private final PromptTemplate template = PromptTemplate.create(
        "帮我写一段关于 {topic} 的朋友圈文案,要求 {length} 字以内,带点小幽默,别太官方"
    )
    .withDefault("length", 50);  // 设置默认长度
    
    /**
     * 生成朋友圈文案提示词
     * @param topic 话题,比如"摸鱼学 Spring AI"
     * @param length 文案长度(可选,默认 50)
     * @return 渲染后的提示词(可以传给大模型)
     * @throws IllegalArgumentException 如果 topic 为空
     */
    public String generatePrompt(String topic, Integer length) {
        // 参数校验:生产环境必须做
        if (!StringUtils.hasText(topic)) {
            throw new IllegalArgumentException("话题不能为空");
        }
        
        Map<String, Object> variables = new HashMap<>();
        variables.put("topic", topic);
        if (length != null && length > 0) {
            variables.put("length", length);
        }
        
        try {
            // 渲染模板,生成最终提示词
            return template.render(variables);
        } catch (Exception e) {
            // 模板渲染失败(如占位符错误),记录日志并抛出异常
            throw new RuntimeException("模板渲染失败: " + e.getMessage(), e);
        }
    }
}

测试代码(含完整导入):

package com.example.springai;

import com.example.springai.service.WeChatMomentService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class WeChatMomentServiceTest {
    
    @Autowired
    private WeChatMomentService service;
    
    @Test
    void testGeneratePrompt() {
        String prompt = service.generatePrompt("摸鱼学 Spring AI", null);
        System.out.println(prompt);
        assertNotNull(prompt);
        assertTrue(prompt.contains("摸鱼学 Spring AI"));
    }
    
    @Test
    void testGeneratePromptWithLength() {
        String prompt = service.generatePrompt("摸鱼学 Spring AI", 100);
        System.out.println(prompt);
        assertNotNull(prompt);
    }
    
    @Test
    void testGeneratePromptWithEmptyTopic() {
        // 测试异常处理
        assertThrows(IllegalArgumentException.class, () -> {
            service.generatePrompt("", null);
        });
    }
}

运行结果

帮我写一段关于 摸鱼学 Spring AI 的朋友圈文案,要求 50 字以内,带点小幽默,别太官方

吐槽:有了模板,再也不用每次写提示词都薅秃头发了。一个模板,改个参数就能用,爽!


四、核心概念 2:ChatClient——你的"大模型专属接线员"

4.1 什么是 ChatClient?——你和大模型之间的接线员

ChatClient 就像你和大模型之间的接线员:你不用直接拨号(调用原生 API),只需要告诉接线员"我要发啥内容",它就帮你转接、传话、把对方的回复带回来。

没有 ChatClient 时的痛点

以前调用大模型,你得手写 HTTP 请求:

// 手写 HTTP 请求(累死)
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);

Map<String, Object> body = new HashMap<>();
body.put("model", "gpt-3.5-turbo");
body.put("messages", Arrays.asList(
    Map.of("role", "user", "content", prompt)
));

HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(
    "https://api.openai.com/v1/chat/completions",
    request,
    Map.class
);

// 还要从 JSON 里抠字段(更累)
String content = ((Map)((List)((Map)response.getBody().get("choices")).get(0)).get("message")).get("content").toString();

写十几行代码,就为了发一条消息,累不累?

有了 ChatClient 后(正确用法):

import org.springframework.ai.chat.client.ChatClient;

// Spring AI 1.1.x 标准用法:链式调用
String response = chatClient.prompt()
    .text(prompt)
    .call()
    .content();

一行链式调用搞定,爽!

4.2 核心特性:自动配置、链式调用、响应处理

自动配置:只要引入依赖,Spring Boot 就自动给你生成一个 ChatClient 实例,就像点外卖时自动送筷子,不用额外要。

@Service
public class AIService {
    
    // 直接注入,不用自己 new
    @Autowired
    private ChatClient chatClient;
    
    public String chat(String prompt) {
        // Spring AI 1.1.x 标准用法:链式调用
        return chatClient.prompt()
            .text(prompt)
            .call()
            .content();
    }
}

链式调用的完整流程

  • chatClient.prompt():开始构建提示
  • .text(prompt):设置用户消息内容
  • .call():调用大模型 API
  • .content():提取回复的文本内容

与 PromptTemplate 的联动:剧本写好了,接线员负责递剧本。

@Service
public class WeChatMomentService {
    
    @Autowired
    private ChatClient chatClient;  // 接线员
    
    private final PromptTemplate template = PromptTemplate.create(
        "帮我写一段关于 {topic} 的朋友圈文案,要求 {length} 字以内,带点小幽默,别太官方"
    )
    .withDefault("length", 50);
    
    /**
     * 生成朋友圈文案(完整版,含异常处理)
     */
    public String generateMoment(String topic, Integer length) {
        // 1. 参数校验
        if (!StringUtils.hasText(topic)) {
            throw new IllegalArgumentException("话题不能为空");
        }
        
        try {
            // 2. 渲染模板,生成提示词
            Map<String, Object> variables = new HashMap<>();
            variables.put("topic", topic);
            if (length != null && length > 0) {
                variables.put("length", length);
            }
            String prompt = template.render(variables);
            
            // 3. 调用大模型,使用链式调用
            String response = chatClient.prompt()
                .text(prompt)
                .call()
                .content();
            
            // 4. 校验响应(大模型可能返回空内容)
            if (!StringUtils.hasText(response)) {
                throw new RuntimeException("大模型返回内容为空");
            }
            
            return response;
        } catch (Exception e) {
            // 5. 异常处理:API 调用失败、网络异常、密钥过期等
            // 生产环境应记录日志,并根据异常类型做不同处理
            throw new RuntimeException("生成朋友圈文案失败: " + e.getMessage(), e);
        }
    }
}

响应处理:直接提取文本,不用从 JSON 里抠字段,就像外卖直接送上门,不用自己去后厨端。

4.3 自定义 ChatClient:何时需要自定义?

默认 ChatClient 已足够大多数场景,但在以下情况需要自定义:

  1. 指定特定模型(如强制使用 gpt-4)
  2. 设置默认参数(如全局 temperature、max-tokens)
  3. 配置重试策略(如失败重试 3 次)

自定义 ChatClient 示例

package com.example.springai.config;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class SpringAIConfig {
    
    /**
     * 自定义 ChatClient:指定模型和默认参数
     * 说明:Spring AI 的自动配置会扫描 spring.ai.openai.api-key,自动创建 ChatClient Bean,
     * 一般无需手动声明。本示例仅演示「为什么需要自定义」:比如强制指定模型、默认温度、令牌上限或重试策略。
     * @param chatModel 自动注入的 ChatModel(由 Spring AI 自动配置)
     * @return 自定义的 ChatClient
     */
    @Bean
    @Primary  // 标记为默认 Bean,避免多个 ChatClient 冲突
    public ChatClient customChatClient(ChatModel chatModel) {
        return ChatClient.builder(chatModel)
            .defaultSystem("你是一个专业的内容创作助手")  // 设置默认系统提示
            .defaultOptions(opts -> opts
                .withTemperature(0.7f)  // 设置默认温度
                .withMaxTokens(500)      // 设置默认最大令牌数
            )
            .build();
    }
}

使用场景说明

  • 如果只是简单调用,直接用自动配置的 ChatClient 即可
  • 如果需要全局设置参数,才需要自定义 Bean

4.4 实战案例:朋友圈文案生成器(完整版,含异常处理)

完整代码实现(已在上文展示,这里补充测试代码):

package com.example.springai;

import com.example.springai.service.WeChatMomentService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class WeChatMomentServiceTest {
    
    @Autowired
    private WeChatMomentService service;
    
    @Test
    void testGenerateMoment() {
        try {
            String result = service.generateMoment("摸鱼学 Spring AI", null);
            System.out.println("生成的朋友圈文案:");
            System.out.println(result);
            assertNotNull(result);
            assertTrue(result.length() > 0);
        } catch (Exception e) {
            // 如果 API 调用失败(如密钥错误、网络异常),会抛出异常
            System.err.println("生成失败: " + e.getMessage());
            // 生产环境应记录日志,这里仅做演示
        }
    }
}

运行结果(成功时):

生成的朋友圈文案:
今天摸鱼学了 Spring AI,发现这玩意儿比写 CRUD 有意思多了!ChatClient 一行代码搞定大模型调用,PromptTemplate 让提示词写起来像填表,以后开发 AI 应用,别人还在写 HTTP 请求,我已经摸完鱼下班了 😂

异常处理场景

  • API 密钥错误:抛出 401 Unauthorized 异常
  • 网络异常:抛出 ConnectionException
  • 大模型返回空内容:抛出自定义异常
  • 模板渲染失败:抛出 RuntimeException

吐槽:以前调用大模型要写十几行 HTTP 代码,现在一行链式调用搞定,爽!这就是 Spring AI 的魅力——把复杂的事情变简单。


五、进阶实践:黄金搭档组合拳——实现"摸鱼代码解释器"

5.1 场景设计:程序员刚需功能

你有没有遇到过这种情况:祖传代码里一行 lambda 写了 50 个字符,看了半小时,结果是个简单的遍历?或者接手别人的代码,看到一段嵌套的 stream 操作,一脸懵?

这个工具帮你搞定:输入一段看不懂的代码,返回"人话解释+摸鱼小贴士"。

5.2 完整代码实现(生产级,含异常处理)

1. 定义 PromptTemplate

package com.example.springai.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.HashMap;
import java.util.Map;

@Service
public class CodeExplainerService {
    
    @Autowired
    private ChatClient chatClient;
    
    // 定义模板:要求用大白话解释,别讲术语,还要给摸鱼小贴士
    private final PromptTemplate template = PromptTemplate.create(
        "请解释这段 Java 代码:\n{code}\n\n" +
        "要求:\n" +
        "1. 用大白话解释,别讲术语\n" +
        "2. 给一句摸鱼小贴士,比如'这段代码可以用 Stream 简化,省出时间摸鱼'\n" +
        "3. 如果代码有问题,指出问题并给出优化建议"
    );
    
    /**
     * 解释代码(生产级,含完整异常处理)
     * @param code 要解释的代码
     * @return 解释结果
     * @throws IllegalArgumentException 如果代码为空
     * @throws RuntimeException 如果 API 调用失败或返回空内容
     */
    public String explainCode(String code) {
        // 1. 参数校验
        if (!StringUtils.hasText(code)) {
            throw new IllegalArgumentException("代码不能为空");
        }
        
        // 2. 代码长度校验(避免超出令牌限制)
        if (code.length() > 2000) {
            throw new IllegalArgumentException("代码过长,请分段解释(单次不超过 2000 字符)");
        }
        
        try {
            // 3. 渲染模板
            Map<String, Object> variables = new HashMap<>();
            variables.put("code", code);
            String prompt = template.render(variables);
            
            // 4. 调用大模型
            String response = chatClient.prompt()
                .text(prompt)
                .call()
                .content();
            
            // 5. 校验响应
            if (!StringUtils.hasText(response)) {
                throw new RuntimeException("大模型返回内容为空");
            }
            
            return response;
        } catch (org.springframework.ai.chat.client.UnknownApiException e) {
            // API 调用失败(如密钥错误、模型不存在)
            throw new RuntimeException("API 调用失败,请检查密钥和模型配置: " + e.getMessage(), e);
        } catch (java.net.ConnectException e) {
            // 网络异常
            throw new RuntimeException("网络连接失败,请检查网络: " + e.getMessage(), e);
        } catch (Exception e) {
            // 其他异常(模板渲染失败、响应解析失败等)
            throw new RuntimeException("解释代码失败: " + e.getMessage(), e);
        }
    }
}

2. 测试方法(含完整导入):

package com.example.springai;

import com.example.springai.service.CodeExplainerService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class CodeExplainerServiceTest {
    
    @Autowired
    private CodeExplainerService service;
    
    @Test
    void testExplainCode() {
        // 输入一段复杂代码(嵌套的 stream 操作)
        String code = """
            List<String> result = list.stream()
                .filter(s -> s.length() > 5)
                .map(s -> s.toUpperCase())
                .sorted()
                .collect(Collectors.toList());
            """;
        
        try {
            String explanation = service.explainCode(code);
            System.out.println("代码解释:");
            System.out.println(explanation);
            assertNotNull(explanation);
        } catch (Exception e) {
            System.err.println("解释失败: " + e.getMessage());
            // 生产环境应记录日志
        }
    }
    
    @Test
    void testExplainCodeWithEmptyInput() {
        // 测试空值校验
        assertThrows(IllegalArgumentException.class, () -> {
            service.explainCode("");
        });
    }
    
    @Test
    void testExplainCodeWithLongCode() {
        // 测试长度校验
        String longCode = "a".repeat(3000);
        assertThrows(IllegalArgumentException.class, () -> {
            service.explainCode(longCode);
        });
    }
}

运行结果(成功时):

代码解释:
这段代码做了三件事:
1. 过滤:从列表里挑出长度大于 5 的字符串(就像筛子筛豆子,只留大的)
2. 转大写:把每个字符串都转成大写(就像把"hello"变成"HELLO")
3. 排序:按字母顺序排好(就像字典排序)
4. 收集:把结果装进新列表里

摸鱼小贴士:这段代码可以用 parallelStream() 并行处理,如果数据量大,能省出不少时间摸鱼。不过要注意线程安全,别把数据搞乱了。

优化建议:如果 list 是空的,可以加个判空,避免不必要的操作。

异常处理场景

  • 代码为空:抛出 IllegalArgumentException
  • 代码过长:抛出 IllegalArgumentException(避免超出令牌限制)
  • API 调用失败:捕获 UnknownApiException,提示检查密钥和模型配置
  • 网络异常:捕获 ConnectException,提示检查网络
  • 大模型返回空内容:抛出 RuntimeException

吐槽:看,大模型不仅帮你解释了代码,还教你摸鱼,这波血赚!而且代码里加了完整的异常处理,生产环境也能用。


六、常见问题与避坑指南(完整版)

6.1 依赖冲突:多 starter 冲突的完整解决方案

问题:同时引入了 OpenAI 和智谱 AI 的 starter,启动报错。

Error: Multiple ChatClient beans found. Expected single matching bean.

原因:Spring 懵了:你到底想找哪个大模型聊天?

完整解决方案

方案 1:使用 @Primary 指定默认 ChatClient

@Configuration
public class SpringAIConfig {
    
    @Bean
    @Primary  // 标记为默认 Bean
    public ChatClient openAIChatClient(ChatModel chatModel) {
        return ChatClient.builder(chatModel).build();
    }
}

方案 2:使用 @Qualifier 注入特定 ChatClient

如果同时需要多个大模型的 ChatClient,可以这样:

@Configuration
public class SpringAIConfig {
    
    @Bean("openAIChatClient")
    public ChatClient openAIChatClient(
        @Qualifier("openAiChatModel") ChatModel chatModel
    ) {
        return ChatClient.builder(chatModel).build();
    }
    
    @Bean("zhipuAIChatClient")
    public ChatClient zhipuAIChatClient(
        @Qualifier("zhipuAiChatModel") ChatModel chatModel
    ) {
        return ChatClient.builder(chatModel).build();
    }
}

使用时通过 @Qualifier 注入:

@Service
public class AIService {
    
    @Autowired
    @Qualifier("openAIChatClient")
    private ChatClient openAIChatClient;
    
    @Autowired
    @Qualifier("zhipuAIChatClient")
    private ChatClient zhipuAIChatClient;
}

6.2 API 密钥错误:完整排查指南

问题:报错 invalid api key401 Unauthorized

完整排查步骤

  1. 检查密钥格式:别把 sk-xxx 写成 ks-xxx,我见过好几个程序员栽在这
  2. 检查环境变量echo $OPENAI_API_KEY(Linux/Mac)或 echo %OPENAI_API_KEY%(Windows)
  3. 检查配置文件:确认 application.yml 中的 spring.ai.openai.api-key 配置正确
  4. 检查密钥权限
    • OpenAI:确认密钥有 chat.completions 接口权限
    • 如果仅开通 GPT-3.5 却调用 GPT-4,会报权限错误
  5. 检查区域限制
    • OpenAI 密钥可能不支持国内 IP,需要使用代理或 VPN
    • 智谱 AI 等国内大模型无此限制
  6. 检查密钥是否过期:去大模型官网重新生成一个密钥(如果旧的泄露了)

6.2.1 API URL 配置问题:代理和自建服务的常见错误

问题:配置了自定义 base-url,但调用失败,报错 Connection refused404 Not Found

常见错误场景

  1. 代理 URL 配置错误

    # 错误:URL 末尾多了斜杠或路径不对
    base-url: https://your-proxy.com/v1/  # 错误:末尾斜杠
    
    # 正确:只配置基础域名,Spring AI 会自动拼接路径
    base-url: https://your-proxy.com
    
  2. 自建服务 API 格式不兼容

    • Spring AI 期望的 API 格式:POST /v1/chat/completions
    • 如果自建服务不兼容 OpenAI API 格式,需要修改服务端或使用其他适配方案
  3. URL 协议错误

    # 错误:缺少协议
    base-url: your-proxy.com
    
    # 正确:包含协议
    base-url: https://your-proxy.com
    

完整排查步骤

  1. 检查 URL 格式:确保包含协议(https://http://),末尾不要加斜杠
  2. 测试代理连通性:用 curl 测试代理是否可用
    curl -X POST https://your-proxy.com/v1/chat/completions \
      -H "Authorization: Bearer $OPENAI_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"test"}]}'
    
  3. 检查代理配置:如果使用 Nginx 等反向代理,确保正确转发请求头(特别是 Authorization
  4. 查看日志:Spring AI 会记录请求 URL,检查日志确认实际请求的地址是否正确

国内访问 OpenAI 的推荐方案

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      # 使用代理服务器(需要自己搭建或购买代理服务)
      base-url: https://your-proxy-server.com
      # 如果需要设置代理的认证信息,可以通过系统属性或环境变量设置
      # 注意:Spring AI 本身不直接支持代理认证,需要在代理服务器层面配置

6.3 提示词渲染失败:占位符错误的完整排查

问题:模板渲染失败,报错 Template variable not found

原因:占位符写错了,比如把 {code} 写成了 [code],大模型:你猜我认不认识这个符号?

完整排查步骤

  1. 占位符格式:必须用 {} 包裹,不能用 []()
  2. 变量名匹配:变量名要和传入的 Map key 一致(区分大小写)
// 错误示例
PromptTemplate template = new PromptTemplate("请解释 [code]");  // 错误:用了 []

// 正确示例
PromptTemplate template = new PromptTemplate("请解释 {code}");  // 正确:用了 {}
  1. 变量名规范:建议使用英文变量名,符合 Java 编码规范
// 不推荐:中文变量名
variables.put("姓名", "张三");

// 推荐:英文变量名
variables.put("name", "张三");

6.4 响应超时:完整配置指南

问题:大模型回复慢,或者超时。

原因分析

  • 问题太复杂:比如"帮我写一个淘宝",大模型:你猜我要写多久?
  • 令牌数过多:输入 + 输出超过模型限制
  • 网络延迟:国内访问 OpenAI 可能较慢

完整解决方案

1. 配置超时时间(正确配置):

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-3.5-turbo
          temperature: 0.7
          max-tokens: 500
        # 超时配置(注意:这是连接超时,不是生成超时)
        timeout: 30s  # 30 秒超时

2. 优化提示词

  • 避免过于复杂的问题(如"帮我写一个淘宝")
  • 明确输出格式和长度(如"50 字以内")
  • 使用简单问题测试(如"帮我写一个 Hello World")

3. 令牌数限制说明

  • gpt-3.5-turbo:默认最大 4096 令牌(输入 + 输出)
  • gpt-4:默认最大 8192 令牌
  • 如果输入过长,应分段处理或使用更高版本模型

重要提示temperaturemax-tokens 不是超时配置!

  • temperature:生成随机性参数(0-2),0.7 是平衡值
  • max-tokens:回复最大令牌数,不是超时时间
  • 真正的超时配置是 timeout(如 spring.ai.openai.chat.timeout=30s

6.5 版本兼容性问题:完整升级指南

问题:用了过时的 API,代码跑不起来。

版本选择建议

  • 生产环境:使用稳定版本(1.1.x),别用 -M 开头的里程碑版本(除非你想当小白鼠)
  • 查询版本适配表:访问 Spring AI 官方文档,查看版本与 Spring Boot 的匹配关系

旧版本升级到正式版的改动点

  1. ChatClient 用法变更

    // 旧版本(M4):简化调用
    String response = chatClient.call(prompt);
    
    // 新版本(1.1.x):链式调用
    String response = chatClient.prompt()
        .text(prompt)
        .call()
        .content();
    
  2. 依赖版本更新

    <!-- 旧版本 -->
    <version>1.0.0-M4</version>
    
    <!-- 新版本 -->
    <version>1.1.0</version>
    
  3. 配置项变更:部分配置项名称可能调整,请参考官方文档

6.6 多模块项目 Bean 扫描问题

症状:在多模块项目中注入 ChatClient 时报「No qualifying bean」。

排查清单

  1. 子模块依赖:确认业务子模块已引入 spring-ai-openai-spring-boot-starter(或对应模型的 starter)
  2. 包扫描范围:启动类的 @SpringBootApplication 是否能扫描到业务类所在包;如不在同级,可通过 scanBasePackages 显式指定
  3. 配置文件可见性:子模块能否读取到 spring.ai.openai.api-key 等配置(多模块的资源合并是否正确)

示例:启动类跨模块扫描

@SpringBootApplication(scanBasePackages = {
    "com.example.springai",       // 公共配置与自动配置
    "com.example.business.module" // 业务子模块包名
})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

七、拓展方向:多轮对话、函数调用、RAG 落地指引

7.1 多轮对话:使用 ChatMemory 记住上下文

场景:就像和大模型唠嗑,记住之前的对话内容。

实现步骤

  1. 引入依赖(如果使用内存存储):
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pgvector-store</artifactId>
    <version>1.1.0</version>
</dependency>
  1. 配置 ChatMemory
@Configuration
public class ChatMemoryConfig {
    
    @Bean
    public ChatMemory chatMemory() {
        // 使用内存存储(简单场景)
        return new InMemoryChatMemory();
        
        // 或使用 Redis 存储(生产环境推荐)
        // return new RedisChatMemory(redisTemplate);
    }
}
  1. 使用多轮对话
@Service
public class ConversationService {
    
    @Autowired
    private ChatClient chatClient;
    
    @Autowired
    private ChatMemory chatMemory;
    
    public String chat(String userMessage, String conversationId) {
        // 1. 获取历史对话
        List<Message> history = chatMemory.get(conversationId);
        
        // 2. 构建多轮对话 Prompt
        Prompt prompt = new Prompt(
            Stream.concat(
                history.stream(),  // 历史消息
                Stream.of(new UserMessage(userMessage))  // 当前消息
            ).collect(Collectors.toList())
        );
        
        // 3. 调用大模型
        String response = chatClient.prompt()
            .messages(prompt.getInstructions())
            .call()
            .content();
        
        // 4. 保存对话历史
        chatMemory.add(conversationId, new UserMessage(userMessage));
        chatMemory.add(conversationId, new AssistantMessage(response));
        
        return response;
    }
}

7.2 函数调用:让大模型调用本地方法

场景:让大模型帮你调用本地方法,如查询数据库、调用外部 API。

实现步骤

  1. 定义函数接口
public interface WeatherFunction {
    @Function(description = "获取指定城市的天气")
    String getWeather(@Parameter(description = "城市名称") String city);
}
  1. 实现函数
@Component
public class WeatherFunctionImpl implements WeatherFunction {
    
    @Override
    public String getWeather(String city) {
        // 调用天气 API 或查询数据库
        return "北京:晴天,25°C";
    }
}
  1. 配置函数调用
@Configuration
public class FunctionCallConfig {
    
    @Bean
    public ChatClient functionCallChatClient(
        ChatModel chatModel,
        WeatherFunction weatherFunction
    ) {
        return ChatClient.builder(chatModel)
            .defaultFunctions(weatherFunction)  // 注册函数
            .build();
    }
}
  1. 使用函数调用
@Service
public class FunctionCallService {
    
    @Autowired
    private ChatClient chatClient;
    
    public String chat(String userMessage) {
        // 大模型会自动判断是否需要调用函数
        String response = chatClient.prompt()
            .text(userMessage)
            .call()
            .content();
        return response;
    }
}

7.3 RAG(检索增强生成):结合向量数据库

场景:让大模型记住你的数据,做知识库问答。

实现步骤

  1. 引入向量数据库依赖(以 PostgreSQL + pgvector 为例):
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pgvector-store</artifactId>
    <version>1.1.0</version>
</dependency>
  1. 配置向量数据库
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/vectordb
    username: postgres
    password: postgres
  1. 存储文档向量
@Service
public class VectorStoreService {
    
    @Autowired
    private VectorStore vectorStore;
    
    public void storeDocument(String document) {
        // 将文档转换为向量并存储
        Document doc = new Document(document);
        vectorStore.add(List.of(doc));
    }
}
  1. RAG 查询
@Service
public class RAGService {
    
    @Autowired
    private ChatClient chatClient;
    
    @Autowired
    private VectorStore vectorStore;
    
    public String query(String question) {
        // 1. 从向量数据库检索相关文档
        List<Document> relevantDocs = vectorStore.similaritySearch(question);
        
        // 2. 构建 RAG Prompt
        String context = relevantDocs.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n"));
        
        String prompt = String.format(
            "基于以下上下文回答问题:\n%s\n\n问题:%s",
            context, question
        );
        
        // 3. 调用大模型
        return chatClient.prompt()
            .text(prompt)
            .call()
            .content();
    }
}

八、生产环境最佳实践

8.1 核心准则总结

  1. 模板复用:将常用提示词封装成 PromptTemplate,避免重复编写
  2. 异常重试:API 调用失败时,实现重试机制(如重试 3 次,每次间隔 1 秒)
  3. 限流降级:避免频繁调用导致 API 限流,实现请求限流和降级策略
  4. 日志埋点:记录 API 调用日志、响应时间、令牌消耗等,便于监控和优化
  5. 参数校验:所有输入参数必须校验,避免无效请求
  6. 响应校验:校验大模型返回内容,避免空内容或格式错误

8.2 完整生产级代码示例

package com.example.springai.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.extern.slf4j.Slf4j;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@Service
public class ProductionAIService {
    
    @Autowired
    private ChatClient chatClient;
    
    private final PromptTemplate template = PromptTemplate.create(
        "帮我写一段关于 {topic} 的朋友圈文案,要求 {length} 字以内"
    )
    .withDefault("length", 50);
    
    /**
     * 生产级方法:含参数校验、异常重试、日志埋点
     */
    @Retryable(
        value = {Exception.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public String generateMoment(String topic, Integer length) {
        // 1. 参数校验
        if (!StringUtils.hasText(topic)) {
            log.warn("话题为空,拒绝请求");
            throw new IllegalArgumentException("话题不能为空");
        }
        
        long startTime = System.currentTimeMillis();
        
        try {
            // 2. 渲染模板
            Map<String, Object> variables = new HashMap<>();
            variables.put("topic", topic);
            if (length != null && length > 0) {
                variables.put("length", length);
            }
            String prompt = template.render(variables);
            
            // 3. 调用大模型
            String response = chatClient.prompt()
                .text(prompt)
                .call()
                .content();
            
            // 4. 响应校验
            if (!StringUtils.hasText(response)) {
                log.error("大模型返回内容为空,topic: {}", topic);
                throw new RuntimeException("大模型返回内容为空");
            }
            
            // 5. 记录日志(生产环境必须)
            long duration = System.currentTimeMillis() - startTime;
            log.info("生成朋友圈文案成功,topic: {}, length: {}, duration: {}ms", 
                topic, length, duration);
            
            return response;
        } catch (Exception e) {
            // 6. 异常日志
            long duration = System.currentTimeMillis() - startTime;
            log.error("生成朋友圈文案失败,topic: {}, duration: {}ms, error: {}", 
                topic, duration, e.getMessage(), e);
            throw e;
        }
    }
}

九、总结与思考

9.1 核心知识点回顾

  • PromptTemplate:大模型的"剧本模板",负责把提示词写得规范、可复用
  • ChatClient:你的"大模型专属接线员",负责帮你传话、把回复带回来
  • 两者组合:开发大模型应用的"快捷键",一行链式调用搞定

9.2 生产环境使用要点

  1. 版本选择:使用稳定版本(1.1.x),避免里程碑版本
  2. 异常处理:完整的参数校验、响应校验、异常捕获
  3. 日志埋点:记录调用日志、响应时间、令牌消耗
  4. 安全配置:API 密钥用环境变量或配置中心,别写代码里
  5. 性能优化:合理设置超时、令牌数限制,避免无效请求

9.3 最后的话

学会这俩玩意,以后开发 AI 应用,别人还在写 HTTP 请求,你已经摸完鱼下班了。

记住

  • 模板写得好,提示词不用愁
  • 接线员(ChatClient)帮你传话,代码写得爽
  • 密钥别写代码里,钱包会哭
  • 生产环境加异常处理和日志,别让线上出问题

十、思考与练习

练习题目:实现一个"古诗生成器"(完整版)

需求

  • 输入主题(比如"摸鱼")
  • 生成一首七言绝句
  • 要求押韵、有意境
  • 含完整的异常处理和参数校验

提示

  • 改改 PromptTemplate 就行,超简单
  • 模板示例:"请以 {theme} 为主题,生成一首七言绝句,要求押韵、有意境"
  • 用 ChatClient 链式调用大模型,返回结果
  • 注意令牌数限制:七言绝句约 28 字,加上提示词,总令牌数应控制在 500 以内

参考代码框架

package com.example.springai.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.extern.slf4j.Slf4j;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@Service
public class PoemGeneratorService {
    
    @Autowired
    private ChatClient chatClient;
    
    private final PromptTemplate template = PromptTemplate.create(
        "请以 {theme} 为主题,生成一首七言绝句,要求押韵、有意境"
    );
    
    /**
     * 生成古诗(完整版,含异常处理)
     * @param theme 主题
     * @return 生成的古诗
     */
    public String generatePoem(String theme) {
        // 1. 参数校验
        if (!StringUtils.hasText(theme)) {
            throw new IllegalArgumentException("主题不能为空");
        }
        try {
            // 2. 渲染模板
            Map<String, Object> variables = new HashMap<>();
            variables.put("theme", theme);
            String prompt = template.render(variables);
            
            // 3. 调用大模型(链式调用,符合 1.1.x 规范)
            String poem = chatClient.prompt()
                .text(prompt)
                .call()
                .content();
            
            // 4. 响应校验
            if (!StringUtils.hasText(poem)) {
                throw new RuntimeException("大模型返回内容为空");
            }
            return poem;
        } catch (Exception e) {
            // 5. 异常兜底:超时、密钥错误、网络异常等
            log.warn("生成古诗失败,theme: {}, err: {}", theme, e.getMessage());
            return "生成失败:" + e.getMessage();
        }
    }
}

测试代码

@SpringBootTest
class PoemGeneratorServiceTest {
    
    @Autowired
    private PoemGeneratorService service;
    
    @Test
    void testGeneratePoem() {
        String poem = service.generatePoem("摸鱼");
        System.out.println("生成的古诗:");
        System.out.println(poem);
        assertNotNull(poem);
        // 验证:七言绝句应为 4 句,每句 7 字
        String[] lines = poem.split("\n");
        assertEquals(4, lines.length);
    }
}

试试吧:改改模板,换个主题,看看大模型能生成什么好玩的诗!记得加异常处理和日志哦。


文章结束:如果你看完这篇文章,还觉得 Spring AI 是个玄学,那一定是我写得不够接地气。欢迎在评论区吐槽,或者直接来打我(开玩笑的)。

祝你摸鱼愉快! 🎉