Spring AI 应用开发实践课程教导站 · Spring AI 2.0 课程首页 速查
课程首页 / 第 1 章

CH 01Spring AI 入门

这一章只有一个目标:建立正确的心智模型,并跑通第一个能用的 AI 接口。一句话概括本章核心结论——你以后写的 90% 的代码,都是在跟 ChatClient 打交道,而 ChatModel 才是真正连接大模型的那根线。

教学目标
看懂分层,跑通首个 AI 接口
教学重点
ChatClient 门面与流式输出
教学难点
ChatModel 与 ChatClient 关系、SSE 配置
建议学时
2 学时

教学设计说明(教师参考):本章按「先画分层图 → 再写最底层 → 再写门面 → 最后串接口」的顺序推进,课堂采用「教师写一步、学生跟一步、随机点人读一行注释」的节奏,2 学时内务必让每个学生亲眼看到 /ask 与 /stream 两个接口返回结果。易掉队点有两个:① 把 ChatModel 和 ChatClient 当成同一层的同学,会在第 3 章结构化输出时彻底卡住,所以 1.1 的分层图要反复强调;② 流式接口的 produces 最容易漏写。检查方式:下课用 1.11 节的验收清单逐项打勾,重点抽查 /stream 是否真的逐字返回。

本章新增易掉队点(按发生频率,补充进原清单):

  • ③ 最隐蔽:密钥为空时 ChatModel bean 根本不注入,报错是 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 请求、解析厂商特有协议、映射参数 ❌ 不写,自动装配
马上动手验证分层(1 分钟,别只盯着图看):在 IDEA 里按 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

维度ChatModelChatClient
定位模型能力的抽象接口(插座)建立在 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 是后厨的灶,厂商实现是具体哪家的燃气。

完整调用时序(建议对照代码看三遍)

你的 Controller │ chatClient.prompt().user("你好").call() ▼ ChatClient(门面)—— 组装默认 SystemMessage + 默认 Advisor 链 │ 构造 Prompt ▼ AdvisorChain ──▶ Advisor#1(日志)──▶ Advisor#2(记忆/RAG)──▶ … │ 最终把 Prompt 交给 ChatModel ▼ OpenAiChatModel(厂商实现)—— 把 Prompt 翻译成 OpenAI JSON │ HTTPS POST /v1/chat/completions ▼ 大模型服务 ──▶ 返回 JSON │ ChatResponse ←── 解析、包成统一对象 ▼ 逐层回卷:Advisor 逆序后处理 ──▶ ChatClient ──▶ 你的 Controller │ .content() 取出纯文本
读时序图的正确姿势:顺着箭头从「你的 Controller」走到「大模型服务」是请求方向,再顺着回卷路径走回来是响应方向。Advisor 在去程被依次执行、在回程逆序执行——记住「去正回逆」,第 3 章写自定义 Advisor 时你会用上。
学完这节,你应该能回答:「如果要把 DeepSeek 换成通义千问,我需要改哪几个地方?」 答案:改 依赖坐标(如果厂商不兼容 OpenAI 协议)、改 yml 里的 base-url / model / api-key。 业务代码里的 ChatClient 调用一行都不用动。这就是抽象层的价值。

1.2创建项目与依赖

开始写代码前,要先有一个 Maven 工程。课程提供两种拿到工程的方式:直接用配套 springai-lab,或从零在 Initializr 创建。无论哪种,最终工程里的依赖必须和下面对齐。

方式一:IDEA / start.spring.io 生成(推荐新手)

⚠️ 动手前先看这条:Spring Boot 版本不要照抄教程。 网上大量资料写着「选当前稳定 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。
  1. 打开 https://start.spring.io,或 IDEA 的 New Project → Spring Initializr。

  2. Project 选 Maven,Language Java,Spring Boot 选 4.1.1(当前默认版),Java 选 17。

  3. Group com.example,Artifact springai-lab,Package name com.example.springai。

  4. 依赖勾 Spring Web、Lombok(可选,本课程不用),以及 AI 分组里的 OpenAI。 注意:勾「Spring Web」生成出来的坐标是 spring-boot-starter-webmvc —— Boot 4 已把 spring-boot-starter-web 改名(旧名废弃)。

  5. 生成后解压到工作目录,用 IDEA 打开 pom.xml。

推荐路径:直接用配套 lab,别在 Initializr 上耗时间。课程包里的 lab/ 已经是最终工程(含全部依赖与逐行注释)。操作:IDEA → File → Open → 选中 lab 目录(认准有 pom.xml 的那一层)→ OK → 等右下角 Maven 同步进度条跑完(首次约 1~3 分钟,配好国内镜像会快很多)。同步完没有红色波浪线,就可以往下走。

动手:核对依赖清单(方式二,与配套 lab 一致)

  1. 在 IDEA 左侧 Project 面板打开 lab/pom.xml,Ctrl+F 搜索下面四个坐标,确认它们都在文件里:spring-boot-starter-webmvc、spring-boot-starter-validation、spring-ai-starter-model-openai、spring-ai-bom。
  2. 确认 spring-ai-bom 在 <dependencyManagement> 段里、与 <dependencies> 平级(不是嵌套在里面)。
  3. 确认 没有 spring-boot-starter-web(旧名)——若搜到,说明 Initializr 选成了 Boot 3.x 的写法,要改成 webmvc。
顺便纠正一个流传的说法:「Spring AI 的依赖在 Initializr 里选不到最新版,所以要手改 pom」—— 这不成立。

实测 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>
手改 pom 的结构性事故(真实案例):把 <dependency> 节点直接放在 <project> 下(缺了 <dependencies> 包裹)、同一个 starter 声明了两三次、<dependencyManagement> 段复制了两份——这些会让构建直接失败,或「不报错但埋雷」(多份声明迟早改漏一处)。 自检方法:改完 pom 必跑一遍 mvn -q dependency:tree(或 1.2 节末尾的过滤版),命令能跑通且依赖齐全,结构就没问题。
为什么一个 starter 就能连 DeepSeek、通义、智谱、Kimi? 因为这些厂商都提供了 OpenAI 兼容接口(同样的 URL 路径 /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'。 排查顺序:
  1. 确认 pom.xml 的 <dependencies> 里真的有 spring-ai-starter-model-openai(不是只在 BOM 里);
  2. 确认 echo $DEEPSEEK_API_KEY(Windows 为 echo $env:DEEPSEEK_API_KEY)不为空——Spring AI 在密钥为空时可能不注册 ChatModel bean,这是最隐蔽的一种;
  3. 确认 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)不用每次谈价。

一句话记忆:starter 决定「装什么功能」,BOM 决定「装哪个版本」。两者配合,你才能在依赖里只写 artifactId、不写 version。

1.3模型连接配置(换厂商对照表)

动手:把配置写进 application.yml

  1. 在 IDEA 左侧 Project 面板展开 src → main → resources,找到 application.yml(若不存在,在 resources 目录上右键 → New → File → 输入 application.yml)。
  2. 双击打开,Ctrl+A 全选已有内容 → Delete 清空。
  3. 把下面这段完整粘贴进去(Ctrl+V),然后 Ctrl+S 保存。
  4. 确认 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 就启动失败(缺默认值)。

类比:像保险箱的密码盘——箱体(代码)公开,密码(密钥)只在你手里的便签(环境变量)上。

一句话记忆:yml 里只写 ${DEEPSEEK_API_KEY:},真正的密钥住在环境变量里,永远不进 Git。
关于密钥占位符:本课程的约定是从环境变量 DEEPSEEK_API_KEY 读取(见第 0 章)。 配套 lab 工程当前的 application.yml 写的是 ${OPENAI_API_KEY:}, 请手动改成 ${DEEPSEEK_API_KEY:},并相应把环境变量名也统一,否则启动后注入不到密钥会报 401。 ${...:} 这种写法叫「占位符带默认值」,冒号后为空表示「即使环境变量没设,应用也照常启动」——避免了本地没配密钥就直接启动失败。

常见厂商配置对照表(可直接抄)

厂商base-urlmodel 示例备注
DeepSeekhttps://api.deepseek.comdeepseek-chat / deepseek-reasoner性价比高,本课程默认;无 embedding 接口
阿里通义https://dashscope.aliyuncs.com/compatible-modeqwen-plus / qwen-max有 text-embedding-v3,第 4 章可直接用
智谱 AIhttps://open.bigmodel.cn/api/paas/v4glm-4-plus有 embedding-3。走 OpenAI 兼容协议仍然可用——就用 spring-ai-starter-model-openai,只改 base-url
月之暗面https://api.moonshot.cnmoonshot-v1-8k长上下文有优势
OpenAIhttps://api.openai.comgpt-4o-mini需要网络可达;embedding 用 text-embedding-3-small
Ollama 本地http://localhost:11434(换 starter)qwen2.5:7b需把依赖换成 spring-ai-starter-model-ollama
base-url 末尾斜杠问题:大部分厂商要求不带 /v1(starter 会自己拼), 写了反而变成 /v1/v1/chat/completions 而 404。遇到 404 第一件事就是把 base-url 简化到域名。

1.4最底层调用:直接使用 ChatModel

先看「不加任何糖」的写法,这样你才知道 ChatClient 帮你做了什么。为什么先讲底层?因为底层最容易出错也最容易理解——它能让你看清一次对话请求到底由「消息列表」组成。

动手:在 ch01 包创建 ChatModelBasics.java

  1. 在 IDEA 左侧 Project 面板,展开 java → com → example → springai。确认能看到 ch01 包(配套 lab 已建好;若没有,在 springai 目录上右键 → New → Package → 输入 com.example.springai.ch01)。
  2. 在 ch01 包上右键 → New → Java Class。
  3. 在弹出的输入框里精确输入 ChatModelBasics(注意大小写:首字母大写、其余驼峰;不要带 .java 后缀)。回车。
  4. 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

  1. 在 Project 面板找到 com.example.springai.common 包(配套 lab 已建好;若没有,在 springai 上右键 → New → Package → 输入 com.example.springai.common)。
  2. 在 common 包上右键 → New → Java Class,精确输入 ChatClientConfig,回车。
  3. 把下面整段配置代码粘贴覆盖,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

  1. 在 ch01 包上右键 → New → Java Class,精确输入 HelloAiService,回车。
  2. 把下面整段 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

  1. 在 ch01 包上右键 → New → Java Class,精确输入 ChatController,回车。
  2. 把下面整段 Controller 代码粘贴覆盖,Ctrl+S 保存。
  3. 重点确认:/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-webmvcspring-boot-starter-webflux
跨域配置类实现 WebMvcConfigurer 的 addCorsMappingsCorsWebFilter 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。

流式的三个典型坑:
  1. 没加 produces:Spring 会按 application/json 返回,前端收到一坨,没有流式效果。排查:看响应头 Content-Type 是不是 text/event-stream。
  2. 中间有网关/反向代理:Nginx 默认开启缓冲(proxy_buffering on),会把流式结果攒起来一次性下发。排查:本地直连 8080 能流式、走域名就不能,八成是它;location 里加 proxy_buffering off;。
  3. Reactor 线程被阻塞:在 .stream() 之后又调用了 .block() 或阻塞式 JDBC,会破坏背压。排查:流式链路上所有 IO 都要是响应式的,不要在 map/flatMap 里写 Thread.sleep 或同步查询。

1.7完整可运行示例:Hello AI 服务

把前面三块拼起来,第 1 章的包结构如下。这三份文件与 1.4、1.5、1.6 节逐个对应,是本章唯一需要落盘的代码:

src/main/java/com/example/springai/ ├── SpringAiLabApplication.java ├── common/ │ └── ChatClientConfig.java # 全局 ChatClient(1.5 节) └── ch01/ ├── ChatModelBasics.java # 底层调用演示(1.4 节) ├── HelloAiService.java # 业务服务(1.5 节) └── ChatController.java # HTTP 接口(1.6 节)

运行验证:确认文件都落在正确的包

  1. 在 IDEA 的 Project 面板展开 com.example.springai,确认结构如下(包名、文件名一个字都不能差):
    • common/ChatClientConfig.java
    • ch01/ChatModelBasics.java
    • ch01/HelloAiService.java
    • ch01/ChatController.java
  2. 再编译一次兜底:mvn -q compile → BUILD SUCCESS。

看到什么算通过:四个文件都在对应包下、编译通过。文件若建错包(比如 ChatClientConfig 放在 ch01 而非 common),启动时全局 ChatClient 注入会失败——所以这一步的「包名核对」不是走过场。

关于「全局异常处理」:模型调用是最容易抛异常的地方(超时、限流、余额不足),生产上应加一个 @RestControllerAdvice 统一兜底,避免把 500 堆栈直接返回前端。它的代码会在后续章节(错误处理专题)统一给出,本章先聚焦跑通主链路,因此 lab 的 ch01 包只保留上面三份文件。等学到对应章节,再把异常处理补进来即可——这也是课程「先跑通、再加固」的一贯节奏。

1.8运行与验证

代码写完,现在让它在本地真正跑起来。第 1 章不需要任何数据库(向量库、记忆库都是后面章节才用),所以直接启动应用即可。

  1. 确认环境变量已设好(重开终端后执行):

    # Windows PowerShell
    echo $env:DEEPSEEK_API_KEY
    # macOS / Linux
    echo $DEEPSEEK_API_KEY
    # 期望:打印出 sk- 开头的 key,而不是空
  2. 启动应用(任选其一):

    # 方式 A:Maven 插件
    mvn spring-boot:run
    
    # 方式 B:IDEA 直接运行 SpringaiLabApplication 的 main 方法

    预期输出:控制台出现 Started SpringaiLabApplication in X.XXX seconds,且没有任何 APPLICATION FAILED TO START。

  3. 测试同步接口:

    curl -X POST http://localhost:8080/api/v1/chat/ask \
      -H "Content-Type: application/json" \
      -d '{"question":"用一句话解释 Spring AI 是什么"}'

    预期输出:一段中文回答,例如「Spring AI 是 Spring 官方提供的大模型应用开发抽象层……」。

  4. 测试流式接口(-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。
流式输出看不到逐字效果?curl 默认会缓冲。加 -N; Windows 的 Git Bash 里有时即使加了 -N 也会缓冲,改用 curl --no-buffer 或在 IDEA 的 HTTP Client(.http 文件)里测试,那边对 SSE 支持更好。
Windows PowerShell 5.1 下,curl 的 JSON 会「悄悄坏掉」。 现象:命令照抄,服务端却报 400 / JSON 解析错误,后端日志里请求体变成 {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 协议),我需要改哪些文件、哪些不用改?」

1.12动手清单与自查表(照着核对)

这一节只回答三个问题:本章到底要建哪些文件、怎么一步步确认做对了、卡住时去哪一节的哪张表查。建议每完成一步就回来打个勾——全部过完,第 1 章才算真的跑通,而不是"看着像跑通了"。

① 你要建的文件(建一个,勾一个)

文件(相对工程根)作用怎么确认建对了
src/main/resources/application.yml模型连接配置缩进用空格不用 Tab;api-key 写的是 ${DEEPSEEK_API_KEY:} 占位符,文件里没有明文 key
src/main/java/com/example/springai/ch01/ChatModelBasics.java1.4 底层调用能构造 Prompt 并拿到 ChatResponse;三层取值 getResult().getOutput().getText() 不报错
src/main/java/com/example/springai/common/ChatClientConfig.java1.5 全局 ChatClient类上有 @Configuration,方法上有 @Bean 与 @Primary;IDEA 左侧能看到豆图标
src/main/java/com/example/springai/ch01/HelloAiService.java1.5 门面用法用构造器注入 ChatClient,不是 new
src/main/java/com/example/springai/ch01/ChatController.java1.6 两个 HTTP 接口流式方法上有 produces = MediaType.TEXT_EVENT_STREAM_VALUE,返回 Flux<String>
SpringaiLabApplication.java(Initializr 自动生成)启动类带 @SpringBootApplication,且位于 com.example.springai 根包——放错包会扫不到子包的 bean
文件放哪的纪律:Java 文件的目录路径必须和 package 声明一致。写了 package com.example.springai.ch01; 的文件,就必须落在 .../java/com/example/springai/ch01/ 下。IDEA 里最快的做法:在目标包上右键 → New → Java Class → 输入类名 → 回车,不要手写路径。

② 分步验收:从零到跑通(每步都有预期输出)

  1. 先确认密钥在当前终端可见(不通过就别往下走):

    # Git Bash / macOS / Linux
    echo $DEEPSEEK_API_KEY
    # PowerShell
    echo $env:DEEPSEEK_API_KEY

    预期:打印出 sk- 开头的一长串。打印为空 → 停下,回第 0 章 0.6 节重设环境变量并重开终端,否则后面必然报"找不到 ChatModel"。

  2. 确保 8080 没被占用(不先做这步,第 4 步一定失败):

    # 推荐:用第 0 章的一键脚本
    powershell -ExecutionPolicy Bypass -File .\stop-app.ps1
    # 或手工查一下有没有 LISTENING
    netstat -ano | findstr :8080

    预期:提示「未被占用」,或列出被清掉的 PID。看到什么算通过:再执行一次 netstat,8080 上已无 LISTENING。

  3. 编译(在 pom.xml 所在目录执行):

    mvn -q compile

    预期:BUILD SUCCESS,且没有任何 [ERROR]。报 package org.springframework.ai... does not exist → 回 1.2 节查依赖,不是代码问题。

  4. 启动应用:mvn spring-boot:run,或 IDEA 里直接运行启动类。

    预期:控制台出现 Tomcat started on port 8080 与 Started SpringaiLabApplication in X.XXX seconds,没有 APPLICATION FAILED TO START。

  5. 打同步接口(新开一个终端,别把启动的那个关掉):

    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。

  6. 打流式接口(验证 SSE 真的通了):

    curl -N -X POST http://localhost:8080/api/v1/chat/stream \
      -H "Content-Type: application/json" \
      -d '{"question":"从 1 数到 10,每个数字换一行"}'

    预期:数字一个一个蹦出来。

    看到什么算通过:盯着屏幕能看到中间态;若"唰"地一次性全出来,说明少了 -N,或中间有缓冲层。

  7. 做一次反例实验(最能检验你是否真懂这一章):把 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 Unauthorized0.6 节「进程继承」+ 1.9IDEA 是在设环境变量之前启动的吗?重启 IDEA 再试
接口 404 Not Found1.9 排错表base-url 多写了 /v1,或 Controller 路径写错
Port 8080 was already in use0.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
本章「做完」的判据(三条同时成立):① 上面 7 步验收全部出现预期输出;② 六个文件的位置与包名与清单一致;③ 能对着 1.1 节的时序图,把"一次请求从 Controller 走到模型再回来"讲一遍。任何一步"我也说不清为什么",就回对应小节重读——第 1 章的地基不牢,第 3 章的 Advisor 和第 5 章的 RAG 都会变成背代码。