CH 01Spring AI 入门
这一章只有一个目标:建立正确的心智模型,并跑通第一个能用的 AI 接口。一句话概括本章核心结论——你以后写的 90% 的代码,都是在跟 ChatClient 打交道,而 ChatModel 才是真正连接大模型的那根线。
教学设计说明(教师参考):本章按「先画分层图 → 再写最底层 → 再写门面 → 最后串接口」的顺序推进,课堂采用「教师写一步、学生跟一步、随机点人读一行注释」的节奏,2 学时内务必让每个学生亲眼看到 /ask 与 /stream 两个接口返回结果。易掉队点有两个:① 把 ChatModel 和 ChatClient 当成同一层的同学,会在第 3 章结构化输出时彻底卡住,所以 1.1 的分层图要反复强调;② 流式接口的 produces 最容易漏写。检查方式:下课用 1.11 节的验收清单逐项打勾,重点抽查 /stream 是否真的逐字返回。
本章新增易掉队点(按发生频率,补充进原清单):
- ③ 最隐蔽:密钥为空时
ChatModelbean 根本不注入,报错是No qualifying bean of type 'ChatModel'而不是 401——1.2 节验证依赖、1.8 节启动失败时都要先查环境变量。 - ④ 文件建错包(
ChatClientConfig没放在com.example.springai.common扫描路径下)导致全局ChatClient注入失败——每个「在哪个包右键」步骤都要强调。 - ⑤ 跨域用错 WebFlux 写法(
CorsWebFilter)在 WebMVC 工程里不生效——1.6 节用 WebMVC vs WebFlux 对照表讲清。 - ⑥ 从 Initializr 默认选 Spring Web 得到
starter-web而非starter-webmvc——1.2 节明说「Web 依赖是 webmvc」。
1.1先建立心智模型:Spring AI 的三层抽象
新手最容易犯的错,是把 Spring AI 当成「一个 HTTP 客户端」。它不是。它是一套把不同厂商大模型的能力抹平成统一接口的抽象层。理解下面这张分层图,你后面 10 章都不会迷路。
为什么必须建立分层心智模型?因为大模型应用的 bug,90% 出在「没搞清楚自己调用的是哪一层」。比如你想换模型,改错了层(去改业务代码)就会事倍功半;你想统一加日志,加错了层(在每处调用里写)就会到处重复。分层图就是用来告诉你:哪些东西该写一次、哪些东西该随场景变、哪些东西永远不用碰。
| 层次 | 代表类型 | 它负责什么(一句话) | 你要不要写 |
|---|---|---|---|
| 应用层 你写的代码 |
Controller / Service / Agent / RAG 链路 | 业务逻辑:什么场景、什么提示词、什么工具、怎么组装结果 | ✅ 主要工作 |
| 门面层 ChatClient |
ChatClient / Advisor |
提供流式(fluent)API、默认系统提示、默认 Advisor 链,是官方推荐的入口 | ✅ 天天用 |
| 模型抽象层 Model |
ChatModel / EmbeddingModel / ImageModel |
定义「对话」「向量化」「生图」的能力契约,屏蔽厂商差异 | 🔸 需要精细控制时用 |
| 厂商实现层 Provider |
OpenAiChatModel / OllamaChatModel / AnthropicChatModel |
真正发 HTTP 请求、解析厂商特有协议、映射参数 | ❌ 不写,自动装配 |
Ctrl+N(macOS 为 Cmd+O)调出「Go to Class」搜索框,依次输入下面三个类名,观察它们各自落在哪个包——你会发现「分层」不是抽象概念,而是真实的目录结构:
ChatModel→ 落在org.springframework.ai.chat.model(模型抽象层)ChatClient→ 落在org.springframework.ai.chat.client(门面层)OpenAiChatModel→ 落在org.springframework.ai.openai(厂商实现层)
易混概念对照:ChatModel vs ChatClient
| 维度 | ChatModel | ChatClient |
|---|---|---|
| 定位 | 模型能力的抽象接口(插座) | 建立在 ChatModel 之上的门面(窗口) |
| 提供方 | Spring AI 核心,需 starter 自动装配 | Spring AI 核心,需 ChatClient.Builder 构建 |
| 典型调用 | chatModel.call(prompt) / .stream(prompt) | chatClient.prompt().user(x).call().content() |
| 默认系统提示 | ❌ 每次要自己拼 SystemMessage | ✅ defaultSystem(...) 自动带上 |
| Advisor 链 | ❌ 要自己串 | ✅ defaultAdvisors(...) |
| 流式 / 结构化 | stream 返回 Flux<ChatResponse> | .stream().content() 返回 Flux<String>;.entity(X.class) 直接映射对象 |
| 何时直接用 | 要拿最原始 ChatResponse 元数据、写框架/公共组件、绕过 Advisor 时 | 95% 的业务代码都面向它写 |
ChatModel 是「后厨的灶」,你很少直接对着它点单;ChatClient 是「你点单的窗口」,日常 90% 的活都通过它。灶只有一根,窗口可以有无数个(第 2 章你会建第二个专属窗口)。
四个必记的核心抽象
① ChatModel —— 连接模型的插座
接口只定义了「我能接收一个 Prompt,返回一个 ChatResponse」。换厂商时,这个接口名一个字都不用改,改的是 yml 配置和依赖坐标。
关键方法call(Prompt) / stream(Prompt)
② ChatClient —— 对开发者友好的门面
在 ChatModel 之上封装了流式 API、默认系统提示、默认 Advisor、结构化输出等便利能力。业务代码优先用它。
最小用法chatClient.prompt().user("...").call().content()
③ Prompt —— 一次请求的完整输入
它不是字符串,而是「消息列表 + 模型参数」的组合。第 2 章会讲透消息角色,第 3 章会加 format 占位符约束输出。
结构Prompt(List<Message>, ChatOptions)
④ Advisor —— 横切关注点的插槽
类似 Spring MVC 的 Interceptor / Servlet Filter。可以在请求前后插入逻辑:打日志、算 Token、加记忆、做 RAG 检索、做内容安全。
典型MessageChatMemoryAdvisor(第 6 章)、QuestionAnswerAdvisor(第 5 章)
ChatClient 是你点单的窗口,Prompt 是你写的订单,Advisor 是中间传菜的厨师(可以加料、改单、记账),ChatModel 是后厨的灶,厂商实现是具体哪家的燃气。
完整调用时序(建议对照代码看三遍)
ChatClient 调用一行都不用动。这就是抽象层的价值。
1.2创建项目与依赖
开始写代码前,要先有一个 Maven 工程。课程提供两种拿到工程的方式:直接用配套 springai-lab,或从零在 Initializr 创建。无论哪种,最终工程里的依赖必须和下面对齐。
方式一:IDEA / start.spring.io 生成(推荐新手)
3.x」—— 那是 Spring Boot 4 发布之前的写法,
已经过时。Spring Boot 没有 LTS 概念,每个小版本只有约 12 个月 OSS 支持期:
3.5.x 已于 2026-06-30 停止 OSS 支持,并被移出了 Initializr。
实测官方 start.spring.io 现在只提供 4.x
(4.1.1 为默认,另有 4.0.8 及若干 SNAPSHOT),下拉列表里根本没有 3.x。
选 4.1.1。
-
打开
https://start.spring.io,或 IDEA 的 New Project → Spring Initializr。 -
Project 选 Maven,Language Java,Spring Boot 选 4.1.1(当前默认版),Java 选 17。
-
Group
com.example,Artifactspringai-lab,Package namecom.example.springai。 -
依赖勾 Spring Web、Lombok(可选,本课程不用),以及 AI 分组里的 OpenAI。 注意:勾「Spring Web」生成出来的坐标是
spring-boot-starter-webmvc—— Boot 4 已把spring-boot-starter-web改名(旧名废弃)。 -
生成后解压到工作目录,用 IDEA 打开
pom.xml。
lab/ 已经是最终工程(含全部依赖与逐行注释)。操作:IDEA → File → Open → 选中 lab 目录(认准有 pom.xml 的那一层)→ OK → 等右下角 Maven 同步进度条跑完(首次约 1~3 分钟,配好国内镜像会快很多)。同步完没有红色波浪线,就可以往下走。
动手:核对依赖清单(方式二,与配套 lab 一致)
- 在 IDEA 左侧 Project 面板打开
lab/pom.xml,Ctrl+F搜索下面四个坐标,确认它们都在文件里:spring-boot-starter-webmvc、spring-boot-starter-validation、spring-ai-starter-model-openai、spring-ai-bom。 - 确认
spring-ai-bom在<dependencyManagement>段里、与<dependencies>平级(不是嵌套在里面)。 - 确认 没有
spring-boot-starter-web(旧名)——若搜到,说明 Initializr 选成了 Boot 3.x 的写法,要改成webmvc。
实测 Initializr 有一个完整的 AI 分组:OpenAI / Anthropic / DeepSeek /
Ollama / Mistral / Google GenAI / MCP Client & Server / pgvector / Milvus / Qdrant /
Redis / JDBC 记忆库……该有的都有。而且生成出来的项目会自动带上
spring-ai.version = 2.0.1,本身就是最新版。
真正需要手改 pom 的场景是:课程后面章节要用到的其他 Spring AI 模块 (向量库、记忆库、MCP Server 等),按需追加即可。
方式二:直接改 pom(本课程采用,与配套 lab 完全一致)
配套 lab/pom.xml 已经是最终形态,下面把最关键的三段抽出来讲。注意第 1 章至少要保证这三个依赖存在:
<!-- Web:提供 HTTP 接口 -->
<!-- ★ 注意是 starter-**webmvc**,不是 3.x 教程里的 starter-web:-->
<!-- Spring Boot 4 已废弃旧名。写成旧名不会报错,但迟早被移除。-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- ★ 参数校验:第 2 章的 @NotBlank 依赖它(Boot 2.3 后 web 不再自带校验)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- ★ Spring AI 的核心模型接入:OpenAI 协议及其所有兼容实现 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- ★ BOM:统一管理所有 spring-ai-* 的版本,缺了它就要逐个写版本号 -->
<!-- dependencyManagement 是 <project> 直接子元素,和 <dependencies> 平级 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependency> 节点直接放在 <project> 下(缺了 <dependencies> 包裹)、同一个 starter 声明了两三次、<dependencyManagement> 段复制了两份——这些会让构建直接失败,或「不报错但埋雷」(多份声明迟早改漏一处)。
自检方法:改完 pom 必跑一遍 mvn -q dependency:tree(或 1.2 节末尾的过滤版),命令能跑通且依赖齐全,结构就没问题。
/v1/chat/completions、同样的 JSON 结构)。
Spring AI 的 OpenAI starter 本质上只是按 OpenAI 协议发请求,所以换个 base-url 就能指到别家。
只有当某家完全不兼容(比如 Anthropic Claude 的原生协议)时,才需要换成对应的 starter。
验证依赖是否装配成功
工程打开后,先别急着写代码,先确认依赖真的拉到了。在项目根目录执行:
# 命令行验证 Spring AI 相关依赖树
mvn -q dependency:tree -Dincludes=org.springframework.ai
# 期望能看到类似:
# org.springframework.ai:spring-ai-starter-model-openai:jar:2.0.1
# org.springframework.ai:spring-ai-model:jar:2.0.1
# org.springframework.ai:spring-ai-client-chat:jar:2.0.1
# org.springframework.ai:spring-ai-openai:jar:2.0.1
预期输出说明:能看到上面四行、且版本都是 2.0.1,说明 starter 与 BOM 配合正确。如果只看到 spring-ai-bom 而看不到 spring-ai-starter-model-openai,说明 starter 没加进去,回去检查 <dependencies>。
spring-ai-bom 却没加具体 starter,启动时不会报错(因为根本没触发自动配置),
但你在 Service 里注入 ChatModel 时会得到
NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.ai.chat.model.ChatModel'。
排查顺序:
- 确认
pom.xml的<dependencies>里真的有spring-ai-starter-model-openai(不是只在 BOM 里); - 确认
echo $DEEPSEEK_API_KEY(Windows 为echo $env:DEEPSEEK_API_KEY)不为空——Spring AI 在密钥为空时可能不注册 ChatModel bean,这是最隐蔽的一种; - 确认 IDEA 右下角 Maven 面板没有红线,必要时点刷新按钮重新拉依赖。
运行验证:先编译一次,确认依赖真的齐了
依赖就位后,先别急着写业务代码——每写完一块就编译一次是本章的纪律。在 lab 工程根目录(pom.xml 所在目录)执行:
mvn -q compile
预期输出:
[INFO] BUILD SUCCESS
[INFO] Total time: 3.456 s
看到什么算通过:最后一行是 BUILD SUCCESS,且没有任何 [ERROR] 或 BUILD FAILURE。如果报 package org.springframework.ai.chat.model does not exist,说明 starter 或 BOM 没配对,回到本节的依赖清单重查。编译通过才继续下一节。
'dependencies.dependency.version' for org.springframework.ai:spring-ai-starter-model-openai is missing。
根因:BOM 没生效——Maven 不知道 spring-ai-* 该用哪个版本,于是报「缺版本号」。最常见是 <dependencyManagement> 段被误放进了 <dependencies> 里面,或漏写了 <type>pom</type> / <scope>import</scope>。
修复:确认 spring-ai-bom 的声明在 <dependencyManagement> 下、与 <dependencies> 平级,四个子标签(groupId / artifactId / version / type / scope)一字不差。改完重跑 mvn -q compile。
知识卡:starter 与 BOM 到底是什么
① starter(起步依赖)—— 一键打包的「功能套餐」
是什么:一个空的 Maven 坐标,背后聚合了一组常用依赖。比如 spring-ai-starter-model-openai 一引入,就自动带上了 openai 实现、chat 模型、client 门面、自动配置等。
为什么需要它:你不用去记「调 DeepSeek 到底要哪几个 jar」,一个坐标搞定。
没有它会怎样:你自己手写一长串依赖,版本对不齐就会 NoClassDefFoundError。
类比:像点「套餐」而不是单点每一道菜。
② BOM(物料清单)—— 版本仲裁中心
是什么:spring-ai-bom 是一张「版本对照表」,用 import 方式被你的 pom.xml 继承。
为什么需要它:所有 spring-ai-* 必须用同一版本,BOM 保证你写 <artifactId> 时不必再写 <version>。
没有它会怎样:每个依赖都要手写版本号;一旦前后不一致,运行期出现诡异的 MethodNotFound。
类比:像公司采购目录——目录里定了价格(版本),你只管下单(写 artifactId)不用每次谈价。
1.3模型连接配置(换厂商对照表)
动手:把配置写进 application.yml
- 在 IDEA 左侧 Project 面板展开
src → main → resources,找到application.yml(若不存在,在resources目录上右键 → New → File → 输入application.yml)。 - 双击打开,
Ctrl+A全选已有内容 →Delete清空。 - 把下面这段完整粘贴进去(
Ctrl+V),然后Ctrl+S保存。 - 确认 IDEA 没有在
yml上画红色波浪线——红色说明缩进或层级写错(YAML 对缩进极度敏感,必须用空格、不能用 Tab)。
先写一份第 1 章用的配置:
spring:
application:
name: springai-lab
ai:
openai:
# 从环境变量读;冒号后为空表示"没有也要启动",避免本地没配就起不来
# ★ 本课程统一约定用 DEEPSEEK_API_KEY 这个环境变量名
api-key: ${DEEPSEEK_API_KEY:}
# ★ 换厂商的核心开关:只改这一行 + model,Java 代码一行不动
base-url: https://api.deepseek.com
chat:
options:
model: deepseek-chat
temperature: 0.7
max-tokens: 2048
# 超时设置:大模型首字延迟经常 2~10 秒,读超时要给足
connect-timeout: 30s
read-timeout: 120s
server:
port: 8080
logging:
level:
com.example.springai: debug
# 打开后能看到发给模型的完整请求体,调提示词时必开
org.springframework.ai.chat.client: debug
运行验证:配置能编译、层级正确
光写对还不够,先确认它不报错。在 lab 根目录执行:
mvn -q compile
预期输出:
[INFO] BUILD SUCCESS
看到什么算通过:BUILD SUCCESS 即说明 YAML 层级、缩进、属性名都没问题(真正的连通性要到 1.8 节启动后调接口才验证)。如果报 Failed to bind properties under 'spring.ai.openai' ... 之类,通常是缩进错位,回到上一步检查 ai: 下 openai: 的缩进是否对齐。
401 Unauthorized,但 echo $DEEPSEEK_API_KEY 明明有值。
根因:进程没继承到环境变量(详见第 0 章「进程继承」)。常见两种:① IDEA 是在你 setx 之前启动的;② 你在 IDEA 的 Run Configuration 里覆盖了环境变量、或没有勾选「Include system environment variables」。
修复:完全退出并重启 IDEA,或在该接口的 Run Configuration → Environment variables 里手动加 DEEPSEEK_API_KEY=sk-...。验证:临时在代码里 System.out.println(System.getenv("DEEPSEEK_API_KEY")),输出为空就说明没继承到。
知识卡:${DEEPSEEK_API_KEY:} 占位符
环境变量占位符——密钥绝不进源码
是什么:yml 里的 ${DEEPSEEK_API_KEY:} 表示「从名为 DEEPSEEK_API_KEY 的环境变量读值;冒号后是默认值,这里为空,即『读不到也照常启动』」。
为什么需要它:密钥不能硬编码进仓库(一旦进 Git 历史就删不干净,泄露风险极高);用占位符把密钥留在运行环境里。
没有它会怎样:要么把明文 key 写死在 yml(泄露),要么本地没配 key 就启动失败(缺默认值)。
类比:像保险箱的密码盘——箱体(代码)公开,密码(密钥)只在你手里的便签(环境变量)上。
${DEEPSEEK_API_KEY:},真正的密钥住在环境变量里,永远不进 Git。
DEEPSEEK_API_KEY 读取(见第 0 章)。
配套 lab 工程当前的 application.yml 写的是 ${OPENAI_API_KEY:},
请手动改成 ${DEEPSEEK_API_KEY:},并相应把环境变量名也统一,否则启动后注入不到密钥会报 401。
${...:} 这种写法叫「占位符带默认值」,冒号后为空表示「即使环境变量没设,应用也照常启动」——避免了本地没配密钥就直接启动失败。
常见厂商配置对照表(可直接抄)
| 厂商 | base-url | model 示例 | 备注 |
|---|---|---|---|
| DeepSeek | https://api.deepseek.com | deepseek-chat / deepseek-reasoner | 性价比高,本课程默认;无 embedding 接口 |
| 阿里通义 | https://dashscope.aliyuncs.com/compatible-mode | qwen-plus / qwen-max | 有 text-embedding-v3,第 4 章可直接用 |
| 智谱 AI | https://open.bigmodel.cn/api/paas/v4 | glm-4-plus | 有 embedding-3。走 OpenAI 兼容协议仍然可用——就用 spring-ai-starter-model-openai,只改 base-url |
| 月之暗面 | https://api.moonshot.cn | moonshot-v1-8k | 长上下文有优势 |
| OpenAI | https://api.openai.com | gpt-4o-mini | 需要网络可达;embedding 用 text-embedding-3-small |
| Ollama 本地 | http://localhost:11434(换 starter) | qwen2.5:7b | 需把依赖换成 spring-ai-starter-model-ollama |
/v1(starter 会自己拼),
写了反而变成 /v1/v1/chat/completions 而 404。遇到 404 第一件事就是把 base-url 简化到域名。
1.4最底层调用:直接使用 ChatModel
先看「不加任何糖」的写法,这样你才知道 ChatClient 帮你做了什么。为什么先讲底层?因为底层最容易出错也最容易理解——它能让你看清一次对话请求到底由「消息列表」组成。
动手:在 ch01 包创建 ChatModelBasics.java
- 在 IDEA 左侧 Project 面板,展开
java → com → example → springai。确认能看到ch01包(配套 lab 已建好;若没有,在springai目录上右键 → New → Package → 输入com.example.springai.ch01)。 - 在
ch01包上右键 → New → Java Class。 - 在弹出的输入框里精确输入
ChatModelBasics(注意大小写:首字母大写、其余驼峰;不要带.java后缀)。回车。 - IDEA 会自动生成
package com.example.springai.ch01;和空的类骨架。把下面整段代码全选粘贴覆盖进去(Ctrl+A再Ctrl+V),然后Ctrl+S保存。
下面这份代码与配套 lab 逐字一致,是本章第一个要落盘的文件:
package com.example.springai.ch01;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 第 1 章 · 最底层的调用方式:只用 ChatModel 接口。
* 目的:看清一次对话请求到底由哪些部分组成。
*/
@Component
public class ChatModelBasics {
// 构造器注入。这个 bean 由 spring-ai-starter-model-openai 自动装配,
// 类型实际是 OpenAiChatModel,但我们只依赖抽象接口,方便日后替换/测试打桩。
private final ChatModel chatModel;
public ChatModelBasics(ChatModel chatModel) {
this.chatModel = chatModel;
}
/**
* 最简单的调用:只传一句用户话。
* 内部会自动包成一个只含 UserMessage 的 Prompt。
*/
public String simple(String userText) {
// call(String) 是 ChatModel 的便捷方法,等价于 new Prompt(userText)
return chatModel.call(userText);
}
/**
* 带系统提示 + 用户消息的标准写法。
* 这是理解"消息列表"最直观的例子。
*/
public String withSystemPrompt(String userText) {
// SystemMessage:设定角色与规则,模型会当作最高优先级的背景指令
SystemMessage system = new SystemMessage(
"你是一名严谨的企业知识库助手,回答必须简洁,不确定时明确说不知道,禁止编造。");
// UserMessage:用户这一轮真正说的话
UserMessage user = new UserMessage(userText);
// Prompt 的本质 = 有序的消息列表(+ 可选的模型参数)
Prompt prompt = new Prompt(List.of(system, user));
// call 返回的是 ChatResponse,里面不止有文本,还有元数据
ChatResponse response = chatModel.call(prompt);
// 逐层取值:response -> getResult() -> getOutput()(AssistantMessage) -> getText()
return response.getResult().getOutput().getText();
}
/**
* 取出本次调用的元数据:用了哪个模型、消耗多少 Token。
* 第 10 章做成本统计时就用这里的数字。
*/
public String inspectMetadata(String userText) {
ChatResponse response = chatModel.call(new Prompt(userText));
String text = response.getResult().getOutput().getText();
// Usage 里含 promptTokens / completionTokens / totalTokens
var usage = response.getMetadata().getUsage();
// 模型名(如 deepseek-chat / gpt-4o-mini)
String model = response.getMetadata().getModel();
return """
模型:%s
输入Token:%s 输出Token:%s 合计:%s
回答:%s
""".formatted(model,
usage.getPromptTokens(), usage.getCompletionTokens(),
usage.getTotalTokens(), text);
}
}
运行验证:写完 ChatModelBasics 先编译一次
养成「每写完一个文件就编译」的习惯,别等全部写完才发现前面拼错了。在 lab 根目录执行:
mvn -q compile
预期输出:
[INFO] BUILD SUCCESS
看到什么算通过:BUILD SUCCESS。此时 ChatModelBasics 只是个 @Component,还没被任何 Controller 调用,编译通过只证明「类、导入、语法都对」,不代表能连模型——真正的调用要到 1.8 节启动应用后验证。
error: package org.springframework.ai.chat.model does not exist。
根因:这个包来自 spring-ai-model,由 spring-ai-starter-model-openai 间接引入。报错说明 starter 没拉到(1.2 节依赖没配对,或 Maven 同步还没跑完)。
修复:① 回 1.2 节确认 pom.xml 的 <dependencies> 里确有 spring-ai-starter-model-openai;② IDEA 右侧 Maven 面板点刷新按钮重新拉依赖;③ 重跑 mvn -q compile。
知识卡:call() 与 stream() 怎么选
| 维度 | call(Prompt) | stream(Prompt) |
|---|---|---|
| 返回类型 | ChatResponse(一次性完整结果) | Flux<ChatResponse>(逐个片段吐出) |
| 执行方式 | 阻塞,等模型生成完才返回 | 非阻塞,首字到了就推给订阅者 |
| 适用场景 | 后台任务、脚本、要拿完整结果再处理 | 聊天框、长文生成、要尽快看到第一个字 |
| 取值 | response.getResult().getOutput().getText() | 对每个 ChatResponse 取 getOutput().getText() 再拼接 |
ChatResponse 取值为什么是三层嵌套
是什么:response.getResult().getOutput().getText() —— Result 是「一个候选回答」,Output 是「消息对象」(可能含文本/工具调用/图片),Text 才是纯文本。
为什么需要它:一次调用可能返回多个候选(getResults() 是列表),每个候选的输出可能是多模态,所以必须「先选结果 → 再取消息 → 最后取文本」。
没有它会怎样:若直接假设只有一段文本,未来遇到多候选或工具调用结果就会取错对象。
类比:像快递——getResults() 是一摞包裹,getOutput() 是其中一个包裹里的盒子,getText() 才是盒里那张纸条。
call() 一次给全,stream() 一点一点给;取值永远走 getResult()→getOutput()→getText() 这三层。
ChatResponse → getResult() → getOutput() → getText()。
为什么这么绕?因为未来会有「一次返回多个候选结果」和「多模态输出」的场景,
getResults() 是列表、getOutput() 是消息对象(可能含图片)。记住这条路径,第 7 章工具调用时你会频繁用到 getOutput().getToolCalls()。
1.5推荐用法:ChatClient 门面
实际项目里几乎不会直接调 ChatModel,而是用 ChatClient。它把「默认系统提示、默认 Advisor、参数覆盖、结构化输出」都收进了一套流式 API 里。为什么推荐门面?因为它把「每次都要重复写的系统提示、日志 Advisor、异常处理」统一收口——你只关心业务逻辑,重复的脚手架交给它。
动手:在 common 包创建 ChatClientConfig.java
- 在 Project 面板找到
com.example.springai.common包(配套 lab 已建好;若没有,在springai上右键 → New → Package → 输入com.example.springai.common)。 - 在
common包上右键 → New → Java Class,精确输入ChatClientConfig,回车。 - 把下面整段配置代码粘贴覆盖,
Ctrl+S保存。
为什么放 common 包?它是全应用共享的全局配置,必须放在 com.example.springai.common(在主启动类的组件扫描范围内),才能被 Spring 自动发现并注册为 bean。若放在 ch01 之外的未扫描路径,全局 ChatClient 就注入不进来。
第一步:把 ChatClient 做成一等公民(配置类)
配套 common/ChatClientConfig.java 与下面逐字一致。注意它比 1.x 旧写法多了一个 @Primary——1.x 那种不区分默认人选的写法已经过时;因为第 2 章会再建一个客服专用 ChatClient,容器里就有两个同类型的 bean,必须有一个被标记为「默认人选」。
package com.example.springai.common;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* 全局 ChatClient 配置。
* 为什么不直接在 Service 里 new?—— 因为默认系统提示、默认 Advisor 应该全应用统一,
* 散落在各个 Service 里迟早改不齐。
* 注意:容器里已经有一个 ChatClient.Builder(Spring AI 自动装配),
* 我们注入它再 build,比自己 new Builder 更省事。
*/
@Configuration
public class ChatClientConfig {
/**
* ★ @Primary:容器里有多个 ChatClient 时的"默认人选"(第 2 章会加客服专用 ChatClient)。
* 规则是:按类型注入拿这个全局的;要拿专属的必须 @Qualifier 指定 bean 名。
*/
@Bean
@Primary
public ChatClient chatClient(ChatClient.Builder builder) {
return builder
// 全局默认角色设定:所有请求都会自动带上(可在单次调用时覆盖)
.defaultSystem("""
你是"智汇企业助手",服务于公司内部员工。
规则:
1. 用简体中文回答,语气专业但不啰嗦。
2. 不确定的信息必须说明"我需要更多信息",禁止编造。
3. 涉及金额、日期、人名时,严格使用原文给出的数据。
""")
// 默认 Advisor:打印每次请求/响应,调试期必开(第 3 章会写自定义的)
.defaultAdvisors(new SimpleLoggerAdvisor())
// 不在这里写 defaultOptions,留给各场景按需覆盖
.build();
}
}
动手:在 ch01 包创建 HelloAiService.java
- 在
ch01包上右键 → New → Java Class,精确输入HelloAiService,回车。 - 把下面整段 Service 代码粘贴覆盖,
Ctrl+S保存。
运行验证:写完两个文件再编译一次
此时项目里有 ChatModelBasics、ChatClientConfig、HelloAiService 三个类。再编译一次,确认它们能一起通过:
mvn -q compile
预期输出:
[INFO] BUILD SUCCESS
看到什么算通过:BUILD SUCCESS。注意此刻还没写 Controller,应用还不能对外提供接口,但三个类已经能互相注入(编译期就检查了类型匹配)。
No qualifying bean of type 'org.springframework.ai.chat.client.ChatClient'。
根因:两种最常见——① ChatClientConfig 放错了包,不在 com.example.springai 的扫描路径下,于是 @Configuration 根本没被加载;② 忘了写 @Bean 或方法没声明 public,bean 没注册。
修复:确认文件首行是 package com.example.springai.common;,且类上有 @Configuration、方法上有 @Bean。改完重跑 mvn -q compile(编译能过不代表 bean 一定注册,但最终启动会暴露)。
第二步:在 Service 里使用 fluent API
配套 ch01/HelloAiService.java。注意构造器注入的是 ChatClient(拿到的就是上面 @Primary 那个全局 bean)。
package com.example.springai.ch01;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
/**
* 第 1 章 · 推荐的日常写法:通过 ChatClient 调用。
* ChatClient 是高层封装:链式 API + 自动带上全局系统提示 + Advisor 链。
* 业务代码 95% 的情况只面向 ChatClient 写。
*/
@Service
public class HelloAiService {
private final ChatClient chatClient;
public HelloAiService(ChatClient chatClient) {
this.chatClient = chatClient;
}
/** 最小可用:一句话进,一句话出 */
public String ask(String question) {
return chatClient.prompt() // ① 开启一次请求构建
.user(question) // ② 设置用户消息(也可 .system(...) 覆盖默认)
.call() // ③ 同步执行:阻塞等待完整结果
.content(); // ④ 取出纯文本内容
}
/** 流式:一个字一个字往回推,前端体验好,适合聊天框 */
public Flux<String> askStream(String question) {
return chatClient.prompt()
.user(question)
.stream() // ③' 换成 stream(),返回 Flux<String>
.content();
}
/** 想看完整响应对象(含 Token 用量),用 chatResponse() 而不是 content() */
public String askWithUsage(String question) {
var response = chatClient.prompt()
.user(question)
.call()
.chatResponse(); // ④' 拿到 ChatResponse
var usage = response.getMetadata().getUsage();
return "用量:" + usage.getTotalTokens() + " tokens\n"
+ response.getResult().getOutput().getText();
}
}
知识卡:@Primary 与「多个同类型 bean」
为什么容器里会有两个 ChatClient,还要 @Primary
是什么:@Primary 告诉 Spring:「按类型注入时,如果容器里有多个同类型的 bean,默认选我这个」。
为什么需要它:第 2 章会再建一个「客服专用 ChatClient」(不同的系统提示),于是容器里有两个 ChatClient 类型的 bean。业务代码里 @Autowired ChatClient 按类型注入时,Spring 不知道选哪个就会报错。
没有它会怎样:报 NoUniqueBeanDefinitionException: expected single matching bean but found 2。
类比:公司有两个同名「张伟」,HR 规定带 @Primary 的那位是「默认张伟」,点名不指定时就找他;要找另一位得喊全名(@Qualifier("customerChatClient"))。
SimpleLoggerAdvisor —— 调试期的「黑匣子」
是什么:Spring AI 内置的一个 Advisor,每次请求/响应时把内容打印到日志。
为什么需要它:你调提示词时,必须看见「到底发给模型的是什么、模型回了什么」才能调。配合 1.3 节 yml 里的 logging.level.org.springframework.ai.chat.client=debug 一起开。
没有它会怎样:模型答非所问时,你完全不知道是提示词写错还是模型抽风,只能盲猜。
类比:像给电话装了录音——出问题了回放就知道哪句说错了。
知识卡:ChatClient fluent API 的「链式契约」
为什么是 .prompt().user().call().content() 这一串
是什么:fluent(流式)API——每个方法返回「下一步该调什么」的对象,所以能一路点下去。prompt() 返回请求构建器,user()/system() 返回同一个构建器,call()/stream() 返回响应提取器,content() 才真正取出文本。
为什么需要它:把「设系统提示、设用户消息、设参数、选同步/流式、取结果」收成一条可读的链,避免层层嵌套的样板代码。
没有它会怎样:回到 ChatModel 那种「手动 new 一堆 Message、手动包 Prompt」的写法(见 1.4 节),重复又易错。
类比:像点奶茶——prompt() 拿杯子,.user() 加料,.call() 封口,.content() 拿到喝的那口;漏了任一步链条就断(编译期就报错)。
@Primary 解决「同类型多个 bean 选哪个」;ChatClient 的链是「拿杯→加料→封口→喝」,断了就编译不过。
ChatClient fluent API 速查
| 写法 | 含义 | 返回 |
|---|---|---|
.prompt() | 开始构建一次请求 | ChatClientRequestSpec |
.system("…") / .user("…") | 设置对应角色的消息,可传字符串、Resource、模板 | 本对象(可链式) |
.options(ChatOptions) | 本次请求覆盖模型参数(温度、模型名等) | 本对象 |
.advisors(a -> a.param("k","v")) | 本次请求附加 Advisor 或传参 | 本对象 |
.tools(Object…) | 第 7 章:注册可调用的工具对象 | 本对象 |
.call() | 同步执行 | CallResponseSpec |
.stream() | 流式执行 | StreamResponseSpec |
.content() | 取纯文本 | String / Flux<String> |
.chatResponse() | 取完整响应(含元数据) | ChatResponse |
.entity(Class) | 第 3 章:直接映射成 Java 对象 | T |
call() 与流式 stream() 怎么选?后台任务、脚本、一次要拿到完整结果再处理的场景用 call();聊天框、长文生成、任何「用户要尽快看到第一个字」的场景用 stream()。二者业务代码几乎一样,只差一个方法名和一个返回类型(String vs Flux<String>)。
1.6流式输出(SSE)—— 让前端"打字机"效果
非流式的问题:模型生成 500 字要 8 秒,用户盯着白屏 8 秒,体验极差。流式的价值是把首字延迟从 8 秒降到 0.5 秒。为什么用 SSE(Server-Sent Events)?因为它基于 HTTP、天然单向推送、浏览器原生支持 EventSource,比 WebSocket 轻得多——而 AI 回答恰恰只需要「服务端推给前端」,不需要前端回推。
动手:在 ch01 包创建 ChatController.java
- 在
ch01包上右键 → New → Java Class,精确输入ChatController,回车。 - 把下面整段 Controller 代码粘贴覆盖,
Ctrl+S保存。 - 重点确认:
/stream那个方法上有produces = MediaType.TEXT_EVENT_STREAM_VALUE——这是流式能否生效的关键,漏写前端就看不到打字机效果(见下方坑 ①)。
下面是完整代码:
package com.example.springai.ch01;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
/**
* 第 1 章 · 对外 HTTP 接口。
* Controller 只做三件事:接参数 → 调 Service → 返回结果。
* 不要在 Controller 里写业务逻辑,也不要直接注入 ChatClient。
*/
@RestController
@RequestMapping("/api/v1/chat")
public class ChatController {
private final HelloAiService service;
// 构造器注入(推荐):字段少一个 @Autowired,还便于单元测试时手工构造
public ChatController(HelloAiService service) {
this.service = service;
}
/** 同步:适合后台任务、脚本调用 */
@PostMapping("/ask")
public String ask(@RequestBody AskRequest req) {
return service.ask(req.question());
}
/**
* 流式:produces 必须是 text/event-stream(SSE 协议),
* 前端用 EventSource 或 fetch + ReadableStream 接收。
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestBody AskRequest req) {
return service.askStream(req.question());
}
/** 用 record 接收请求体,比 Map 更安全(字段名编译期就能检查) */
public record AskRequest(String question) {}
}
运行验证:写完 Controller 再编译一次
现在 ch01 包已经有三个类、common 包有一个配置类。编译确认它们整体能通过:
mvn -q compile
预期输出:
[INFO] BUILD SUCCESS
看到什么算通过:BUILD SUCCESS,且 IDEA 里 ChatController 的 @PostMapping 没有红色波浪线。至此全部代码已就位,下一步(1.8 节)就是真正启动应用、调接口验证。
知识卡:SSE 与 produces 的关系
为什么流式接口必须写 produces = TEXT_EVENT_STREAM_VALUE
是什么:MediaType.TEXT_EVENT_STREAM_VALUE 即 HTTP 头 Content-Type: text/event-stream,是 SSE 协议的标识。
为什么需要它:Spring MVC 靠它选择正确的消息转换器,并按 SSE 格式(每行 data:xxx\n\n)逐块输出;不写就走 JSON 转换器,把 Flux<String> 聚合成一个数组一次性返回,流式效果消失。
没有它会怎样:前端收到一整坨 JSON 而非逐字推送,用户又回到「盯白屏 8 秒」。
类比:像水龙头开关——produces 是「打开龙头让水流动」的阀门,没它水就积在桶里一次性倒出来。
易混概念对照:WebMVC vs WebFlux(跨域写法)
| 维度 | WebMVC(本课程) | WebFlux |
|---|---|---|
| 底层模型 | Servlet(一个请求一个线程) | Reactor 响应式(少量线程扛高并发) |
| 依赖坐标 | spring-boot-starter-webmvc | spring-boot-starter-webflux |
| 跨域配置类 | 实现 WebMvcConfigurer 的 addCorsMappings | CorsWebFilter Bean |
| 流式返回 | 返回 Flux<String> + SSE | 原生响应式,天然流式 |
CorsWebFilter / ServerHttpResponse,在本课程 WebMVC 工程里加了也不会生效——因为那套是 WebFlux 的。必须用 1.6 节给出的 WebMvcConfigurer 写法(实现 addCorsMappings)。判断方法:看你 pom.xml 里是 webmvc 还是 webflux 坐标。
produces = text/event-stream 开阀门;跨域在 WebMVC 用 WebMvcConfigurer,别抄 WebFlux 的 CorsWebFilter。
前端怎么接(可以直接贴进浏览器控制台验证):
// 浏览器端:fetch + 流式读取(比 EventSource 更灵活,因为要 POST)
const res = await fetch('http://localhost:8080/api/v1/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: '用三句话介绍 Spring AI' })
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// SSE 每行形如 "data:xxx\n\n",这里做最简解析
const chunk = decoder.decode(value, { stream: true });
chunk.split('\n').forEach(line => {
if (line.startsWith('data:')) {
full += line.slice(5);
document.title = full.slice(-30); // 快速验证:看标签页标题在变
}
});
}
console.log(full);
为什么在浏览器里测 /stream 会报 CORS 错误?
从浏览器控制台或前端页面(比如跑在 5173 端口的 Vite 项目)向 8080 发请求属于跨域:浏览器会先自动发一个 OPTIONS 预检请求,后端不放行就直接拦截,报错形如 ...has been blocked by CORS policy。这不是代码写错了,是浏览器的同源安全策略——curl 和 Apifox 不受它限制,所以「命令行能通、浏览器不通」时先想到 CORS。
后端放行只需一个配置类。注意本课程是 WebMVC(Servlet)架构,要实现的是 WebMvcConfigurer;从网上抄来的 WebFlux 写法(CorsWebFilter / ServerHttpResponse)加进来不会生效——这是 WebFlux/WebMVC 混淆的高频事故:
package com.example.springai.common;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 全局跨域配置(WebMVC 写法)。
* 原理:addCorsMappings 注册的规则会在浏览器发 OPTIONS 预检时
* 自动返回 Access-Control-Allow-Origin 等响应头,预检通过后真正请求才被放行。
*/
@Configuration
public class WebCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") // 对哪些接口生效
.allowedOriginPatterns("*") // 允许哪些来源(课程演示放开,生产要收紧为具体域名)
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("*");
}
}
配套 lab 未内置此文件;需要与前端联调时,把它加进 common 包即可,重启后 OPTIONS /api/v1/chat/stream 的响应头里应出现 Access-Control-Allow-Origin。
- 没加
produces:Spring 会按application/json返回,前端收到一坨,没有流式效果。排查:看响应头Content-Type是不是text/event-stream。 - 中间有网关/反向代理:Nginx 默认开启缓冲(
proxy_buffering on),会把流式结果攒起来一次性下发。排查:本地直连 8080 能流式、走域名就不能,八成是它;location 里加proxy_buffering off;。 - Reactor 线程被阻塞:在
.stream()之后又调用了.block()或阻塞式 JDBC,会破坏背压。排查:流式链路上所有 IO 都要是响应式的,不要在map/flatMap里写Thread.sleep或同步查询。
1.7完整可运行示例:Hello AI 服务
把前面三块拼起来,第 1 章的包结构如下。这三份文件与 1.4、1.5、1.6 节逐个对应,是本章唯一需要落盘的代码:
运行验证:确认文件都落在正确的包
- 在 IDEA 的 Project 面板展开
com.example.springai,确认结构如下(包名、文件名一个字都不能差):common/ChatClientConfig.javach01/ChatModelBasics.javach01/HelloAiService.javach01/ChatController.java
- 再编译一次兜底:
mvn -q compile→BUILD SUCCESS。
看到什么算通过:四个文件都在对应包下、编译通过。文件若建错包(比如 ChatClientConfig 放在 ch01 而非 common),启动时全局 ChatClient 注入会失败——所以这一步的「包名核对」不是走过场。
@RestControllerAdvice 统一兜底,避免把 500 堆栈直接返回前端。它的代码会在后续章节(错误处理专题)统一给出,本章先聚焦跑通主链路,因此 lab 的 ch01 包只保留上面三份文件。等学到对应章节,再把异常处理补进来即可——这也是课程「先跑通、再加固」的一贯节奏。
1.8运行与验证
代码写完,现在让它在本地真正跑起来。第 1 章不需要任何数据库(向量库、记忆库都是后面章节才用),所以直接启动应用即可。
-
确认环境变量已设好(重开终端后执行):
# Windows PowerShell echo $env:DEEPSEEK_API_KEY # macOS / Linux echo $DEEPSEEK_API_KEY # 期望:打印出 sk- 开头的 key,而不是空 -
启动应用(任选其一):
# 方式 A:Maven 插件 mvn spring-boot:run # 方式 B:IDEA 直接运行 SpringaiLabApplication 的 main 方法预期输出:控制台出现
Started SpringaiLabApplication in X.XXX seconds,且没有任何APPLICATION FAILED TO START。 -
测试同步接口:
curl -X POST http://localhost:8080/api/v1/chat/ask \ -H "Content-Type: application/json" \ -d '{"question":"用一句话解释 Spring AI 是什么"}'预期输出:一段中文回答,例如「Spring AI 是 Spring 官方提供的大模型应用开发抽象层……」。
-
测试流式接口(
-N关闭 curl 缓冲,才能看到逐字输出):curl -N -X POST http://localhost:8080/api/v1/chat/stream \ -H "Content-Type: application/json" \ -d '{"question":"从 1 数到 10,每个数字换一行"}'预期输出:数字一个一个蹦出来,而不是等 8 秒后一次性出现。
APPLICATION FAILED TO START:Parameter 0 of constructor ... ChatModel ... required a bean of type 'org.springframework.ai.chat.model.ChatModel' that could not be found。
根因:容器里找不到 ChatModel 这个 bean。最隐蔽的原因是密钥环境变量为空——Spring AI 在 api-key 为空时不会注册 ChatModel 自动配置,而不是报错说「密钥空」。
修复:① 重开终端,echo $DEEPSEEK_API_KEY(Windows 为 echo $env:DEEPSEEK_API_KEY)确认非空;② 若用 IDEA 运行,确认是在 setx 之后启动的,或直接在 Run Configuration 的 Environment variables 里加 DEEPSEEK_API_KEY=sk-...;③ 确认 pom 有 spring-ai-starter-model-openai。
-N;
Windows 的 Git Bash 里有时即使加了 -N 也会缓冲,改用 curl --no-buffer
或在 IDEA 的 HTTP Client(.http 文件)里测试,那边对 SSE 支持更好。
{question:hello}——内嵌的双引号全被剥离了。根因是 PowerShell 5.1 向原生命令传参时不会自动转义内嵌双引号(它还会把 curl 这个名字解析成自己的别名 Invoke-WebRequest,行为又不一致)。
三选一绕开:
① 换 Apifox(图形界面,最省心,推荐初学者);
② 改用 PowerShell 原生命令 Invoke-RestMethod:
Invoke-RestMethod -Uri http://localhost:8080/api/v1/chat/ask `
-Method Post -ContentType "application/json" `
-Body '{"question":"用一句话解释 Spring AI"}'
③ 在 Git Bash 或 CMD 里执行 curl(引号规则正常,本文的 curl 示例可直接照抄)。
1.9常见坑排错对照表
| 报错 / 现象 | 根本原因 | 解决 |
|---|---|---|
No qualifying bean of type 'ChatModel' |
没加 starter,或 api-key 为空导致自动配置没生效 | 确认 pom 里有 spring-ai-starter-model-openai;确认 echo $DEEPSEEK_API_KEY 不为空 |
401 Unauthorized |
密钥错误、过期,或环境变量没被读到 | 用 curl 直连厂商 API 验证密钥;检查 IDEA 启动配置里是否勾了 Include system environment variables |
404 Not Found |
base-url 多写了 /v1,或路径重复 |
把 base-url 简化成纯域名,让 starter 自己拼路径 |
429 Too Many Requests |
触发厂商限流 | 降低并发;课堂环境建议每人独立密钥;生产环境加重试(第 6 章 Resilience4j) |
SocketTimeoutException: Read timed out |
默认读超时太短,长回答没读完就断了 | 在 yml 里加 read-timeout: 120s;流式接口不受此限 |
| 中文输出乱码 / 问号 | 控制台或 IDE 编码非 UTF-8 | IDEA → Settings → Editor → File Encodings 全设 UTF-8;启动加 -Dfile.encoding=UTF-8 |
回答里带着 ```json 代码块标记 |
提示词没约束格式 | 第 3 章会系统解决,临时可以先在提示词里加「只输出 JSON,不要任何解释和代码块标记」 |
IDEA 里 ChatClient.Builder 注入失败 |
容器里只有一个 Builder 时才能按类型注入;自定义了多个会歧义 | 用 @Qualifier 指定,或直接在方法参数里注入 ChatClient.Builder(Spring AI 会提供默认的) |
1.10自测题(先自己想,再点开)
Q1. ChatModel 和 ChatClient 的区别是什么?什么时候必须直接用 ChatModel?
ChatModel 是模型能力的抽象接口,只提供 call(Prompt) / stream(Prompt),返回结构化对象;
ChatClient 是建立在其上的门面,提供流式 API、默认系统提示、默认 Advisor、entity() 结构化输出等便利能力。
必须直接用 ChatModel 的场景:需要拿到最原始的 ChatResponse 元数据做精细处理、需要绕过 Advisor 链、
或者在写框架/公共组件(比如自定义 Advisor、路由组件)时。
Q2. 把 DeepSeek 换成通义千问,需要改哪几处?
因为两者都兼容 OpenAI 协议:只改 yml 三行——api-key、base-url、chat.options.model。
业务代码零改动。若不兼容(如 Anthropic 原生),则还需换 starter 依赖。
Q3. 为什么 ChatResponse 取值要写成三层 getResult().getOutput().getText()?
因为一次调用可能返回多个候选结果(getResults() 是列表),
每个结果里的 output 是消息对象(可能包含文本、工具调用、多模态内容),
所以要先选结果、再取消息、最后取文本。这样设计是为了支持多候选、工具调用和多模态。
Q4. 流式接口为什么一定要写 produces = MediaType.TEXT_EVENT_STREAM_VALUE?
Spring MVC 需要靠它选择正确的 HttpMessageConverter 并按 SSE 协议(text/event-stream)输出。
不写的话会走 JSON 序列化,Flux 会被聚合成一个数组一次性返回,前端就看不到逐字效果了。
Q5.【动手题】改造本章代码:让 /ask 接口支持前端传入 temperature,并限幅在 0~1 之间。
提示:用 OpenAiChatOptions.builder().temperature(t).build(),通过
chatClient.prompt().options(options) 覆盖本次请求参数;限幅用 Math.max(0, Math.min(1, t)) 或
@Min/@Max 注解 + @Validated。
1.11实操作业与本章验收
课堂练习(课上完成,约 30 分钟):
- 跟着 1.4→1.5→1.6 把三份文件敲进
lab工程的ch01包,启动后用 curl 验证/ask与/stream都返回正常结果,并把两个返回截图贴进实验报告。 - 故意把
application.yml的api-key改成空字符串,重启观察报什么错(401还是注入失败),再改回来——知道错误长什么样,比记住正确答案更重要。 - 在
HelloAiService里临时去掉.call()前的.user(question),看编译期/运行期提示,体会 fluent API 的「链式契约」。
课后作业(提交到个人 Git 仓库,提交信息 ch01: 完成对话接口):
- 在
ChatModelBasics.inspectMetadata的基础上,写一个main或用单元测试打印一次真实调用的 Token 用量,并在报告里说明promptTokens与completionTokens分别代表什么。 - 把 1.5 节的全局系统提示词改成「你是一名 Python 技术面试官」,重启后用
/ask提问「说说装饰器」,截图验证角色切换生效。 - 用 300 字回答:「如果有一天我们弃用 DeepSeek、改用公司私有大模型(也兼容 OpenAI 协议),我需要改哪些文件、哪些不用改?」
- 能口头说出 Spring AI 三层抽象(应用/门面/模型/厂商)各自的职责,并在白板上画出 1.1 节的调用时序。
/ask与/stream两个接口都通过 curl 验证:同步返回完整文本、流式可见逐字输出。- 能独立把模型从 DeepSeek 切换到通义千问或本地 Ollama,且业务代码零改动。
- 能读懂
getMetadata().getUsage()里的 Token 数字,说清promptTokens/completionTokens/totalTokens含义。
1.12动手清单与自查表(照着核对)
这一节只回答三个问题:本章到底要建哪些文件、怎么一步步确认做对了、卡住时去哪一节的哪张表查。建议每完成一步就回来打个勾——全部过完,第 1 章才算真的跑通,而不是"看着像跑通了"。
① 你要建的文件(建一个,勾一个)
| 文件(相对工程根) | 作用 | 怎么确认建对了 |
|---|---|---|
src/main/resources/application.yml | 模型连接配置 | 缩进用空格不用 Tab;api-key 写的是 ${DEEPSEEK_API_KEY:} 占位符,文件里没有明文 key |
src/main/java/com/example/springai/ch01/ChatModelBasics.java | 1.4 底层调用 | 能构造 Prompt 并拿到 ChatResponse;三层取值 getResult().getOutput().getText() 不报错 |
src/main/java/com/example/springai/common/ChatClientConfig.java | 1.5 全局 ChatClient | 类上有 @Configuration,方法上有 @Bean 与 @Primary;IDEA 左侧能看到豆图标 |
src/main/java/com/example/springai/ch01/HelloAiService.java | 1.5 门面用法 | 用构造器注入 ChatClient,不是 new |
src/main/java/com/example/springai/ch01/ChatController.java | 1.6 两个 HTTP 接口 | 流式方法上有 produces = MediaType.TEXT_EVENT_STREAM_VALUE,返回 Flux<String> |
SpringaiLabApplication.java(Initializr 自动生成) | 启动类 | 带 @SpringBootApplication,且位于 com.example.springai 根包——放错包会扫不到子包的 bean |
package 声明一致。写了 package com.example.springai.ch01; 的文件,就必须落在 .../java/com/example/springai/ch01/ 下。IDEA 里最快的做法:在目标包上右键 → New → Java Class → 输入类名 → 回车,不要手写路径。
② 分步验收:从零到跑通(每步都有预期输出)
-
先确认密钥在当前终端可见(不通过就别往下走):
# Git Bash / macOS / Linux echo $DEEPSEEK_API_KEY # PowerShell echo $env:DEEPSEEK_API_KEY预期:打印出
sk-开头的一长串。打印为空 → 停下,回第 0 章 0.6 节重设环境变量并重开终端,否则后面必然报"找不到 ChatModel"。 -
确保 8080 没被占用(不先做这步,第 4 步一定失败):
# 推荐:用第 0 章的一键脚本 powershell -ExecutionPolicy Bypass -File .\stop-app.ps1 # 或手工查一下有没有 LISTENING netstat -ano | findstr :8080预期:提示「未被占用」,或列出被清掉的 PID。看到什么算通过:再执行一次
netstat,8080 上已无LISTENING。 -
编译(在
pom.xml所在目录执行):mvn -q compile预期:
BUILD SUCCESS,且没有任何[ERROR]。报package org.springframework.ai... does not exist→ 回 1.2 节查依赖,不是代码问题。 -
启动应用:
mvn spring-boot:run,或 IDEA 里直接运行启动类。预期:控制台出现
Tomcat started on port 8080与Started SpringaiLabApplication in X.XXX seconds,没有APPLICATION FAILED TO START。 -
打同步接口(新开一个终端,别把启动的那个关掉):
curl -i -X POST http://localhost:8080/api/v1/chat/ask \ -H "Content-Type: application/json" \ -d '{"question":"用一句话解释 Spring AI 是什么"}'预期:首行
HTTP/1.1 200,随后是一段中文回答。看到什么算通过:状态码是
200且回答与问题相关(出现「Spring」「AI」等字样)。若返回401/404/ 400 → 见 1.9 排错表与第 0 章终端差异表。PowerShell 用户注意:这句会踩引号坑,先读第 0 章 0.2 节,改用 Apifox 或
Invoke-RestMethod。 -
打流式接口(验证 SSE 真的通了):
curl -N -X POST http://localhost:8080/api/v1/chat/stream \ -H "Content-Type: application/json" \ -d '{"question":"从 1 数到 10,每个数字换一行"}'预期:数字一个一个蹦出来。
看到什么算通过:盯着屏幕能看到中间态;若"唰"地一次性全出来,说明少了
-N,或中间有缓冲层。 -
做一次反例实验(最能检验你是否真懂这一章):把
application.yml里api-key临时改成空串,重启。预期:启动失败,报
Parameter 0 of constructor ... required a bean of type 'ChatModel' that could not be found。为什么值得做:这条报错看起来像"依赖没配好",实际是"密钥为空导致自动配置整体没生效"。亲手制造一次,下次见到就不会往错方向查。实验完记得改回去并重启。
③ 卡住了去哪查(症状 → 定位)
| 症状 | 先去哪一节 | 一句话提示 |
|---|---|---|
启动报 No qualifying bean of type 'ChatModel' | 1.8 节第一个 pit | 优先怀疑密钥为空,而不是依赖没加 |
接口 401 Unauthorized | 0.6 节「进程继承」+ 1.9 | IDEA 是在设环境变量之前启动的吗?重启 IDEA 再试 |
接口 404 Not Found | 1.9 排错表 | base-url 多写了 /v1,或 Controller 路径写错 |
Port 8080 was already in use | 0.2 节一键脚本 | 上一个实例没停干净,用 stop-app.ps1 |
curl 报 400,日志里请求体变成 {question:hi} | 0.2 节终端差异表 | PowerShell 引号坑,换 Apifox 或 Invoke-RestMethod |
| 流式看不到逐字效果 | 1.8 节 tip | 加 -N;或换 IDEA 的 HTTP Client 测 |
IDEA 里 ChatClient.Builder 注入歧义 | 1.5 节知识卡 + 1.9 | 容器里多于一个 Builder 时要显式 @Qualifier |
| 中文输出乱码 / 问号 | 1.9 排错表 | IDEA File Encodings 全设 UTF-8;启动加 -Dfile.encoding=UTF-8 |