Spring AI | ChatClient与 Prompt 实战

0 阅读12分钟

本篇目标:跟着一个完整案例,从零搭出"宠物门诊智能助手",一次吃透 Spring AI 2.0 最核心的两个 API——ChatClient与 Prompt

技术栈:Spring AI 2.0 + Spring Boot 4 + JDK 21,模型用 DeepSeek(OpenAI 兼容接口)

前置知识:LLM 原理、大模型 API 调用、Prompt Engineering(建议先看前面几篇)


0. 案例全景:你要做出的东西

"宠物门诊智能助手"模拟一个宠物医院的在线导诊台,支持连续对话、识别症状、查排班、帮挂号。案例共 6 个能力点,每个能力点对应一块 ChatClient & Prompt 的知识:

能力一句话描述用到的技术点
① 打招呼跑通第一个 ChatClient依赖注入、call().content()
② 导诊台让 AI 以"宠物医生"身份说话System Prompt 角色与规则
③ 问诊模板参数化组装提示词PromptTemplate 与 .param()
④ 症状单让 AI 输出 Java 对象结构化输出 .entity()
⑤ 记性多轮对话不"失忆"ChatMemory + 记忆 Advisor
⑥ 动手让 AI 调用查询/挂号方法@Tool 工具调用

整个系统的调用链长这样:

浏览器 / 前端
   │  GET /chat?sessionId=xxx&message=xxx
   ▼
PetClinicController(表现层:把 HTTP 参数转给 ChatClient)
   │
   ▼
ChatClient(门面:组装 Prompt、解析结果、管理参数)
   │   ▲  Advisor 链(横切逻辑):
   │   │    · MessageChatMemoryAdvisor —— 会话记忆
   │   │    · ToolCallingAdvisor —— 工具调用循环(自动生效)
   │   ▼
ChatModel(统一模型接口,屏蔽厂商差异)
   │
   ▼
DeepSeek / OpenAI / 通义……(HTTP + JSON,即前一篇讲的 API 调用)

一句话理解整个体系:Controller 负责收请求,ChatClient 负责"把话说好 + 把结果接住",模型负责"听懂人话"。


1. 先建立直觉:ChatClient 到底是什么

Spring AI 的设计分两层:

  • ChatModel:最底层的统一接口,各家模型(DeepSeek、OpenAI、通义……)各有一个实现。你上一篇手写的"HTTP 调用"其实就是 ChatModel 干的事。

  • ChatClient:面向业务的门面(Facade)。它把"拼消息 → 调模型 → 解析响应"整套脏活封装成一行行链式 API,还附带模板、记忆、工具、拦截器这些能力。

💡 类比

如果你熟悉 Spring 生态,ChatClient 之于 ChatModel,就像 RestClient 之于底层 HTTP 连接、JdbcTemplate 之于裸 JDBC——框架帮你把重复劳动干了,你只写业务

看一个最小的 ChatClient 调用,拆开看它做了什么:

String reply = chatClient.prompt()        // ① 开始组装一次请求
        .system("你是一个宠物医生")        // ② 系统消息(角色)
        .user("猫咪吐了怎么办?")          // ③ 用户消息(问题)
        .call()                            // ④ 发起调用(同步)
        .content();                        // ⑤ 取出回答文本

这 5 步背后,框架替你完成了:把 system/user 拼成 messages 数组 → 走 HTTP POST 发给模型 → 解析响应 JSON → 取出 choices[0].message.content这些正是上一篇《大模型 API 调用》里手写过的逻辑——现在它们被 ChatClient 收编了。


2. 动手前准备:工程骨架

2.1 依赖

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

<properties>
    <java.version>21</java.version>   <!-- Spring AI 2.0 要求 Java 21+ -->
</properties>

<dependencies>
    <!-- Web:提供 REST 接口 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- Spring AI:OpenAI 兼容模型(含 DeepSeek) -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

版本号由 Spring Boot 4 的 BOM 统一管理;如果 IDE 找不到依赖,在 dependencyManagement 里显式引入 spring-ai-bom(版本 2.0.0)。

2.2 配置

spring:
  application:
    name: pet-assistant
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}          # 你的 Key,放环境变量
      base-url: https://api.deepseek.com   # OpenAI 兼容接口一换就切模型
      chat:
        model: deepseek-v4-flash           # 对话模型
        temperature: 0.7

2.3 关键配置项速查

配置作用说明
spring.ai.openai.api-key鉴权2.0 移除了 .options 段,键名更扁平
spring.ai.openai.base-url模型服务地址换成 DeepSeek/OpenAI 等兼容地址即可切模型
spring.ai.openai.chat.model对话模型名按厂商文档填
spring.ai.openai.chat.temperature随机性02,问答用 0.30.7

3. 能力① Hello 宠物医生:第一个 ChatClient

新建一个 Controller,注入 ChatClient

@RestController
public class ChatController {

    // Spring AI 自动装配了 ChatClient.Builder,用它构建即可
    private final ChatClient chatClient;

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

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

启动后访问:

GET /hello?message=你好
→ 你好!我是宠物门诊助手,有什么可以帮你的吗?

跑通这一步,说明整条链路(Controller → ChatClient → ChatModel → DeepSeek)是通的。这也是后面所有能力的底座。


4. 能力② 会"看病"的导诊台:System Prompt 角色与规则

裸的 ChatClient 只是"能说话",还不是"会看病"。给它加一个 System Prompt,明确角色和行为边界:

@GetMapping("/triage")
public String triage(@RequestParam String question) {
    return chatClient.prompt()
            .system("""
                    你是一位经验丰富的宠物门诊医生助手。

                    行为规则:
                    1. 先了解宠物种类、症状、持续时间,再给出分析;
                    2. 只做初步分析和就医建议,不代替线下诊断;
                    3. 语气温和、用大白话,避免堆砌术语;
                    4. 判断可能是急症(如吐血、抽搐、无法站立)时,
                       明确建议尽快就医并说明紧急性。
                    """)
            .user(question)
            .call()
            .content();
}

看效果对比——同一个问题,有无 System Prompt 差异明显:

GET /triage?question=我家猫咪今天吐了两次
→(无 system)猫咪呕吐可能的原因有很多,包括毛球症、饮食不当、肠胃炎等,
  建议观察……(泛泛而谈)

→(有 system)先别着急。猫咪呕吐常见原因有毛球、吃太快、换粮或肠胃炎。
  请问:① 吐出来的是毛球还是食物/液体?② 吐了多久了?③ 精神食欲怎么样?
  如果它精神萎靡或持续呕吐超过 24 小时,建议尽快来院检查。
  (主动追问关键信息 + 提示就医时机 —— 这才是"会看病"

📌 要点:System Prompt 里"角色 + 规则"是固定不变的,User 是每次变化的提问——这种"固定 / 变化分离"的思路,就是下一节模板化的动机。


5. 能力③ 问诊模板:PromptTemplate 参数化

业务里问诊问题经常是同一套模板换参数。用 PromptTemplate 把模板抽出来,避免到处拼字符串:

@GetMapping("/assess")
public String assess(@RequestParam String species,
                     @RequestParam String symptom,
                     @RequestParam String duration) {
    return chatClient.prompt()
            .user(u -> u.text("我的{species}最近{symptom},已经持续{duration}了,"
                            + "帮我分析可能的原因和是否需要就医。")
                        .param("species", species)
                        .param("symptom", symptom)
                        .param("duration", duration))
            .call()
            .content();
}

请求与效果:

GET /assess?species=狗狗&symptom=拉肚子&duration=两天
→ 狗狗腹泻两天,常见原因有换粮过快、吃了不干净的东西、寄生虫或肠胃炎。
  建议先停食 6-8 小时观察,……如果出现便血或精神变差,请尽快就医。

更规范的做法是把模板抽到资源文件里维护(比如 classpath:/prompts/assess.st),用 @Value 注入 Resource 再交给 PromptTemplate——提示词和 Java 代码分离,运营/兽医同事也能直接改话术:

@Value("classpath:/prompts/assess.st")
private Resource assessTemplate;

@GetMapping("/assess2")
public String assess2(@RequestParam String species, @RequestParam String symptom) {
    PromptTemplate template = new PromptTemplate(assessTemplate);
    Prompt prompt = template.create(Map.of("species", species, "symptom", symptom));
    return chatClient.prompt(prompt).call().content();
}

6. 能力④ 症状单:让 AI 输出 Java 对象

前三个接口拿到的都是自由文本。业务系统要的是结构化数据——比如把用户一句口语描述,自动整理成一张"症状单"存进数据库(正好能对接宠物档案系统)。

Spring AI 用 .entity(Class) 直接拿到 Java 对象:

// 症状单:字段描述越清楚,模型输出越准
public record PetSymptom(
        String species,    // 宠物种类
        String mainIssue,  // 主要症状
        String duration,   // 持续时间
        String urgency) {} // 紧急程度:LOW / MEDIUM / HIGH
@GetMapping("/symptom-form")
public PetSymptom symptomForm(@RequestParam String description) {
    return chatClient.prompt()
            .system("你是宠物门诊导诊助手,负责把用户描述整理成结构化症状单。")
            .user(description)
            .call()
            .entity(PetSymptom.class);   // 直接返回 record,而不是字符串
}

请求与返回:

GET /symptom-form?description=我家八岁的金毛,从昨天开始没精神,今天早上还吐了黄水
→ PetSymptom[
      species    = "狗",
      mainIssue  = "精神萎靡、呕吐黄水",
      duration   = "1天",
      urgency    = "HIGH"
  ]

这段 Java 对象可以直接落到数据库或对接后续流程。底层原理:框架根据 record 结构自动生成 JSON Schema 约束模型输出,再反序列化成对象;Spring AI 2.0 还内置了"输出不合法自动纠错重试"的能力。

⚠️ 字段命名和含义注释越明确,识别越准。给 record 字段写清楚中文注释(如 // 紧急程度:LOW/MEDIUM/HIGH),能显著降低模型"自由发挥"的概率。


7. 能力⑤ 记性:ChatMemory 多轮会话

你现在应该发现了一个问题:/triage 每次都是"单轮"的——用户回答了"吐的是毛球",下一轮 AI 就忘了。

原因上一篇讲过:大模型 API 无状态,历史要靠客户端拼。Spring AI 的解法是记忆 Advisor——挂上之后,框架自动帮你把历史消息带进每次请求,Service 层一行拼历史的代码都不用写。

@Configuration
public class ChatConfig {

    // ① 记忆存储:内存实现(重启即失)。生产可换 Redis / 数据库实现
    @Bean
    public ChatMemory chatMemory() {
        return new InMemoryChatMemory();
    }

    // ② 记忆 Advisor:自动读写会话历史
    @Bean
    public MessageChatMemoryAdvisor messageChatMemoryAdvisor(ChatMemory chatMemory) {
        return MessageChatMemoryAdvisor.builder(chatMemory).build();
    }
}

使用时,每次调用显式传入会话 ID(2.0 的硬性要求,不再有默认会话):

@GetMapping("/chat")
public String chat(@RequestParam String sessionId, @RequestParam String message) {
    return chatClient.prompt()
            .user(message)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))  // 会话隔离的关键
            .call()
            .content();
}

效果——同一个 sessionId 下的连续对话:

第1轮:我家猫吐了,怎么办?                    → 先别急,请问吐的是什么?
第2轮:吐的是毛球                             → 大概率是毛球症,可以喂化毛膏观察……
第3轮:那化毛膏一天喂几次?                   → (还记得上一轮在聊毛球症,直接对症回答)

而换一个 sessionId(新会话),AI 就是"初次见面"的状态——用会话 ID 做隔离,就能同时服务很多用户而互不串台


8. 能力⑥ 动手:Function Calling 工具调用-下篇精讲

到这里,助手只能"说话"。让它真正"干活"——查排班、挂号的临门一脚,是工具调用(Function Calling):把 Java 方法暴露给 AI,AI 判断"该查排班了"就调用它,把结果拿回来组织回答。

先定义两个工具方法:

@Component  // 交给 Spring 管理,方法会被扫描成可调用工具
public class ClinicTools {

    @Tool(description = "查询指定科室的门诊时间与剩余号源。科室示例:内科、外科、皮肤科")
    public String querySchedule(String department) {
        // 生产环境这里查数据库 / 调 HIS 接口,此处用 Map 模拟
        Map<String, String> schedule = Map.of(
                "内科""周一至周五 9:00-17:00,号源充足",
                "外科""周一/三/五 9:00-12:00,号源紧张",
                "皮肤科""周二/四 14:00-17:00,号源充足"
        );
        return schedule.getOrDefault(department, "该科室暂无排班信息");
    }

    @Tool(description = "为指定宠物挂号,返回挂号结果")
    public String makeAppointment(String petName, String department) {
        return "已为「" + petName + "」成功挂上「" + department + "」的号,请提前 15 分钟到院。";
    }
}

📌 @Tool 的 description 是给模型看的"说明书"——写清楚什么时候该调、参数是什么,模型才知道怎么用。这是工具调用效果好坏的最大变量。

调用时把工具挂到这次请求上:

@RestController
public class AssistantController {

    private final ChatClient chatClient;
    private final ClinicTools clinicTools;   // 注入工具组件

    public AssistantController(ChatClient.Builder builder, ClinicTools clinicTools) {
        this.chatClient = builder.build();
        this.clinicTools = clinicTools;
    }

    @GetMapping("/assistant")
    public String assistant(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .tools(clinicTools)          // 暴露给模型:本次对话可调用这些 @Tool 方法
                .call()
                .content();
    }
}

看一次完整的"思考 → 调工具 → 回答":

GET /assistant?message=我想给狗狗做个体检,外科明天有号吗?
→ AI 内部先判断:这需要"查外科排班"→ 调用 querySchedule("外科")
   拿到结果 → 组织回答:
  "外科明天(周三)9:00-12:00 有号,目前号源紧张。
   需要的话告诉我狗狗名字,我可以帮你挂号。"

GET /assistant?message=叫豆豆,帮我挂了
→ AI 判断:需要 makeAppointment → 调用 makeAppointment("豆豆""外科")
  → "已为「豆豆」成功挂上「外科」的号,请提前 15 分钟到院。"

整个过程模型不知道你的排班数据存在哪——它只负责"决定调哪个工具、传什么参数",真正执行的是你的 Java 代码


9. 完整代码清单

把上面的能力拼到一起,工程结构如下:

pet-assistant/
├── pom.xml
├── src/main/resources/
│   ├── application.yml
│   └── prompts/
│       └── assess.st                    # 可选:问诊模板资源文件
└── src/main/java/com/example/petassist/
    ├── PetAssistantApplication.java     # 启动类(@SpringBootApplication)
    ├── config/
    │   └── ChatConfig.java              # ChatMemory + 记忆 Advisor
    ├── controller/
    │   ├── ChatController.java          # 能力① / ④:hello、symptom-form
    │   ├── TriageController.java        # 能力② / ③:triage、assess
    │   ├── SessionChatController.java   # 能力⑤:多轮 /chat(带 sessionId)
    │   └── AssistantController.java     # 能力⑥:/assistant(挂工具)
    ├── tool/
    │   └── ClinicTools.java             # @Tool 工具方法
    └── dto/
        └── PetSymptom.java              # 结构化输出 record

一键跑通后,你可以验证一整条端到端对话(记忆 + 工具同时生效):

① /chat?sessionId=s1&message=我家猫吐了          → 追问细节
② /chat?sessionId=s1&message=吐的毛球            → 判断毛球症,给建议
③ /assistant?message=内科周末上班吗?            → 调 querySchedule
④ /assistant?message=帮我挂豆豆的内科            → 调 makeAppointment

10. 避坑清单

#现象解法
1没配 api-key / 配错 base-url401 或连接失败检查 spring.ai.openai.* 配置,Key 放环境变量
2模型名过时404 / model not found到厂商文档确认最新模型名(如 DeepSeek 用 deepseek-v4-flash
3.entity() 解析偶发失败偶发抛 JSON 解析异常2.0 用结构化输出自纠错 Advisor;业务侧加重试兜底
4忘记传 CONVERSATION_ID2.0 直接抛 IllegalArgumentException每次 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
5System Prompt 又长又冗余每轮都烧 token,成本高固定的放 system,变化的放 user,历史交给记忆 Advisor
6@Tool 描述太糙模型压根不调用工具description 写清"何时调用 + 参数含义 + 示例值"
7工具方法抛异常对话中断工具内部 try-catch,返回友好错误串让模型转述
8temperature 过高问诊回答不稳定、跑偏医疗/事实场景调到 0.3 左右

11. 小结:ChatClient & Prompt 知识地图

能力核心 API底层原理(回扣篇目)
对话ChatClient.prompt().user().call()HTTP + JSON 调用(API 调用篇)
角色与规则.system()Prompt 工程·角色设定(提示词篇)
参数化PromptTemplate + .param()模板化 = 固定/变化分离
结构化输出.entity(Record.class)JSON Schema 约束
多轮记忆ChatMemory + MessageChatMemoryAdvisor无状态 API → 客户端拼历史(API 调用篇)
工具调用@Tool + .tools()ReAct:模型决定 → 框架执行 → 结果回填

下一步建议

  1. 尝试把案例跑通

  2. 继续阶段2 的下一个知识点:Function Calling(这篇已经涉及了,下篇精讲)

想继续的学习的点个**【赞】【推荐】**让主编知道!

顺手点个**【关注】**,感谢各位学习路上的朋友。