本篇目标:跟着一个完整案例,从零搭出"宠物门诊智能助手",一次吃透 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 | 随机性 | 0 |
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-url | 401 或连接失败 | 检查 spring.ai.openai.* 配置,Key 放环境变量 |
| 2 | 模型名过时 | 404 / model not found | 到厂商文档确认最新模型名(如 DeepSeek 用 deepseek-v4-flash) |
| 3 | .entity() 解析偶发失败 | 偶发抛 JSON 解析异常 | 2.0 用结构化输出自纠错 Advisor;业务侧加重试兜底 |
| 4 | 忘记传 CONVERSATION_ID | 2.0 直接抛 IllegalArgumentException | 每次 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) |
| 5 | System Prompt 又长又冗余 | 每轮都烧 token,成本高 | 固定的放 system,变化的放 user,历史交给记忆 Advisor |
| 6 | @Tool 描述太糙 | 模型压根不调用工具 | description 写清"何时调用 + 参数含义 + 示例值" |
| 7 | 工具方法抛异常 | 对话中断 | 工具内部 try-catch,返回友好错误串让模型转述 |
| 8 | temperature 过高 | 问诊回答不稳定、跑偏 | 医疗/事实场景调到 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:模型决定 → 框架执行 → 结果回填 |
下一步建议:
-
尝试把案例跑通
-
继续阶段2 的下一个知识点:Function Calling(这篇已经涉及了,下篇精讲)
想继续的学习的点个**【赞】和【推荐】**让主编知道!
顺手点个**【关注】**,感谢各位学习路上的朋友。