Spring AI ChatClient 详解:system/user/assistant 角色、call 与 stream 到底怎么选
系列:Spring AI 深入浅出(第 2 篇)
版本:Spring AI 2.0.1 / Spring Boot 4.1.x / JDK 21
标签:#SpringAI #ChatClient #Java接入大模型 #流式输出
前置阅读:第 1 篇《五分钟跑通第一个应用》
本文将解决 :ChatClient 是 Spring AI 2.0 的"一等公民",但只会 .prompt().call() 只算入门。本文把消息角色、defaultSystem 与单次 .system() 的覆盖关系、同步 call() 与响应式 stream() 的取舍、参数调优、客户端复用一次讲透。
你将学到:
- 2.0 的关键架构区分:
ChatClient(业务主入口)vsChatModel(框架底层) - system / user / assistant 三种消息角色的职责与用法
defaultSystem()与单次.system()的优先级陷阱call()同步 vsstream()响应式的原理、代码与适用场景- temperature / max-tokens / top-p 参数调优对照表
- 多客户端复用与配置隔离的最佳实践
一、版本标注
本文基于 Spring AI 2.0.1 / Boot 4.1 / JDK 21。项目依赖与 BOM 配置沿用第 1 篇的完整 pom,这里不再重复(spring-ai-bom + spring-ai-starter-model-openai)。
二、先分清两个概念:ChatClient 和 ChatModel
这是 2.0 最重要的职责划分,很多从 1.x 过来的人会搞混。
| ChatClient | ChatModel | |
|---|---|---|
| 定位 | 高层业务/Agent 主入口 | 低层框架集成接口 |
| 面向谁 | 业务开发者 | 框架/中间件作者 |
| 能力 | 角色、流式、工具、记忆、Advisor、结构化输出 | 原始的 call(Prompt) / stream(Prompt) |
| 类比 | JdbcTemplate |
DataSource |
一句话:你 99% 的代码应该用 ChatClient ;ChatModel 是模型供应商适配层的抽象,平时不直接碰。
两者可以互相转:ChatModel 上有个 chatClient() 方法能转回 ChatClient,但 2.0 推荐直接注入 ChatClient.Builder 来构建,不要自己 new。
java
// 推荐:注入 Builder
@RestController
class MyController {
private final ChatClient chatClient;
MyController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
}
三、三种消息角色:system / user / assistant
一次对话请求由多条消息组成,三种角色各有分工:
| 角色 | 类 | 含义 | 举例 |
|---|---|---|---|
| system | SystemMessage |
设定 AI 的人设、规则、边界 | "你是严谨的 Java 技术助手" |
| user | UserMessage |
用户说的话、提问 | "什么是 Advisor" |
| assistant | AssistantMessage |
AI 之前的回复(多轮记忆用) | "Advisor 是拦截链..." |
在 ChatClient 的流式 API 里,通过 .system() 和 .user() 来设置:
java
String reply = chatClient.prompt()
.system("你是一个严谨的 Java 技术助手,回答必须给出代码示例。")
.user("什么是 Advisor?")
.call()
.content();
AssistantMessage一般不用手动拼------多轮对话的记忆由后续的 ChatMemory/Advisor 自动回填(本系列第 7 篇会讲)。
四、defaultSystem 与单次 .system 的优先级陷阱
ChatClient 支持在构建时 设一个默认 system 提示,也可以在每次请求时覆盖。它们的关系是:
java
// 构建时设置默认人设
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("你是默认的通用助手。")
.build();
// 请求 1:不写 .system(),用默认人设
chatClient.prompt().user("你好").call().content();
// 请求 2:写了 .system(),会【替换】默认人设
chatClient.prompt().system("你是只会写诗的诗人。").user("写首诗").call().content();
关键结论 :单次 .system() 是替换默认值,不是追加。很多人误以为两者会拼接,结果人设被意外覆盖,导致输出风格突变。
建议的实践:把稳定的、全局的人设放 defaultSystem() ;把本次任务特有的约束放 .system()(如"本次只回答 X 领域"),并意识到后者会临时替换前者。
五、Fluent 链式调用全流程
ChatClient 是典型 Fluent API,构建时和请求时各有一串链式方法:
java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("...") // 默认 system 提示
.defaultOptions(opts) // 默认模型参数
.defaultAdvisors(advisor) // 默认 Advisor(记忆/检索等,后续篇讲)
.build();
chatClient.prompt() // 开始一次请求
.system("...") // 本次 system
.user("...") // 本次 user 输入
.options(opts) // 本次参数(覆盖 defaultOptions)
.advisors(advisor) // 本次 Advisor
.call() // 同步执行
.content(); // 取字符串结果
每一层的作用:构建时的方法定义"默认行为",请求时的方法做"本次覆盖"。这套心智模型贯穿整个 Spring AI。
六、call() 同步 vs stream() 响应式
这是最常被问的"到底怎么选"。
6.1 call():同步,等全部生成完才返回
java
String reply = chatClient.prompt().user(q).call().content();
- 返回类型:
content()是String(完整回答) - 特点:阻塞直到模型生成完毕
- 适用:后台任务、工具型调用、需要完整结果才能继续的场景
6.2 stream():响应式,边生成边返回
java
import reactor.core.publisher.Flux;
Flux<String> replyFlux = chatClient.prompt().user(q).stream().content();
- 返回类型:
content()是Flux<String>(增量 token 流) - 特点:非阻塞,模型生成一个 token 就吐一个
- 适用:聊天界面打字机效果、需要低首字延迟的交互场景
6.3 为什么"选错"会踩坑
- 把
stream()的结果当String用 → 编译都过不了 ,因为返回的是Flux,必须订阅才能消费 - 用
call()做聊天界面 → 用户要等模型全部生成完才看到第一个字,体验差 - 用
stream()做批处理 → 增加无谓的响应式复杂度,还得处理背压
想要"打字机效果"且让前端一行行收到,
stream()+ SSE 是正解------这在本系列第 18 篇《流式输出对接前端》会完整落地。
七、参数调优:temperature / max-tokens / top-p
参数在 2.0 里统一用 ChatOptions 的 Builder 构建,创建后不可变:
java
import org.springframework.ai.chat.prompt.ChatOptions;
ChatOptions options = ChatOptions.builder()
.model("gpt-4o-mini")
.temperature(0.3)
.maxTokens(1024)
.topP(0.9)
.build();
String reply = chatClient.prompt()
.user(q)
.options(options)
.call()
.content();
也可以设成客户端默认值(构建时 defaultOptions),让所有请求共享。
调优对照表:
| 参数 | 含义 | 低值效果 | 高值效果 | 建议 |
|---|---|---|---|---|
| temperature | 随机性/创造性 | 更确定、更保守 | 更发散、更有创意 | 事实问答/代码 0~0.3;写作/头脑风暴 0.7~1.0 |
| max-tokens | 最大输出长度 | 回答可能被截断 | 成本更高、更啰嗦 | 按场景设上限,配合成本控制 |
| top-p | 采样候选概率阈值 | 更聚焦 | 更多样 | 一般 0.9~1.0,少与 temperature 同时大幅调 |
一个常见翻车 :把 temperature 设到 1.0 又让它写代码,结果输出大量幻觉、格式乱。严谨任务用低温,创意任务用高温,这是一条朴素但好用的经验。
八、客户端复用与配置隔离
ChatClient 是线程安全的,可以复用。但实际项目里常常需要"多个不同配置的客户端",比如:
- 一个"代码助手"客户端(低温、特定 system)
- 一个"客服"客户端(温和人设、较高温度)
- 一个"总结"客户端(长上下文、不同模型)
做法:用 @Configuration 建多个 Bean,用 @Qualifier 或自定义名字区分:
java
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AiClientConfig {
@Bean("codeClient")
public ChatClient codeClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultSystem("你是资深 Java 架构师,回答要严谨并附代码。")
.defaultOptions(ChatOptions.builder().temperature(0.2).build())
.build();
}
@Bean("chatClient")
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultSystem("你是友好耐心的客服。")
.defaultOptions(ChatOptions.builder().temperature(0.7).build())
.build();
}
}
使用时注入对应的 Bean:
java
@RestController
public class DualClientController {
private final ChatClient codeClient;
private final ChatClient chatClient;
public DualClientController(
@org.springframework.beans.factory.annotation.Qualifier("codeClient") ChatClient codeClient,
@org.springframework.beans.factory.annotation.Qualifier("chatClient") ChatClient chatClient) {
this.codeClient = codeClient;
this.chatClient = chatClient;
}
@GetMapping("/code")
public String code(@RequestParam String q) {
return codeClient.prompt().user(q).call().content();
}
@GetMapping("/chat")
public String chat(@RequestParam String q) {
return chatClient.prompt().user(q).call().content();
}
}
这样不同场景的 AI 能力配置隔离、互不干扰,是架构师视角下"一个客户端打天下"到"按场景分客户端"的进阶。
九、原理图解:一次请求在 ChatClient 内部怎么走
#mermaid-svg-WRHCi1saSOTvCkkM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WRHCi1saSOTvCkkM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WRHCi1saSOTvCkkM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WRHCi1saSOTvCkkM .error-icon{fill:#552222;}#mermaid-svg-WRHCi1saSOTvCkkM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WRHCi1saSOTvCkkM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WRHCi1saSOTvCkkM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WRHCi1saSOTvCkkM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WRHCi1saSOTvCkkM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WRHCi1saSOTvCkkM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WRHCi1saSOTvCkkM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WRHCi1saSOTvCkkM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WRHCi1saSOTvCkkM .marker.cross{stroke:#333333;}#mermaid-svg-WRHCi1saSOTvCkkM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WRHCi1saSOTvCkkM p{margin:0;}#mermaid-svg-WRHCi1saSOTvCkkM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WRHCi1saSOTvCkkM .cluster-label text{fill:#333;}#mermaid-svg-WRHCi1saSOTvCkkM .cluster-label span{color:#333;}#mermaid-svg-WRHCi1saSOTvCkkM .cluster-label span p{background-color:transparent;}#mermaid-svg-WRHCi1saSOTvCkkM .label text,#mermaid-svg-WRHCi1saSOTvCkkM span{fill:#333;color:#333;}#mermaid-svg-WRHCi1saSOTvCkkM .node rect,#mermaid-svg-WRHCi1saSOTvCkkM .node circle,#mermaid-svg-WRHCi1saSOTvCkkM .node ellipse,#mermaid-svg-WRHCi1saSOTvCkkM .node polygon,#mermaid-svg-WRHCi1saSOTvCkkM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WRHCi1saSOTvCkkM .rough-node .label text,#mermaid-svg-WRHCi1saSOTvCkkM .node .label text,#mermaid-svg-WRHCi1saSOTvCkkM .image-shape .label,#mermaid-svg-WRHCi1saSOTvCkkM .icon-shape .label{text-anchor:middle;}#mermaid-svg-WRHCi1saSOTvCkkM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WRHCi1saSOTvCkkM .rough-node .label,#mermaid-svg-WRHCi1saSOTvCkkM .node .label,#mermaid-svg-WRHCi1saSOTvCkkM .image-shape .label,#mermaid-svg-WRHCi1saSOTvCkkM .icon-shape .label{text-align:center;}#mermaid-svg-WRHCi1saSOTvCkkM .node.clickable{cursor:pointer;}#mermaid-svg-WRHCi1saSOTvCkkM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WRHCi1saSOTvCkkM .arrowheadPath{fill:#333333;}#mermaid-svg-WRHCi1saSOTvCkkM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WRHCi1saSOTvCkkM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WRHCi1saSOTvCkkM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WRHCi1saSOTvCkkM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WRHCi1saSOTvCkkM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WRHCi1saSOTvCkkM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WRHCi1saSOTvCkkM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WRHCi1saSOTvCkkM .cluster text{fill:#333;}#mermaid-svg-WRHCi1saSOTvCkkM .cluster span{color:#333;}#mermaid-svg-WRHCi1saSOTvCkkM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WRHCi1saSOTvCkkM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WRHCi1saSOTvCkkM rect.text{fill:none;stroke-width:0;}#mermaid-svg-WRHCi1saSOTvCkkM .icon-shape,#mermaid-svg-WRHCi1saSOTvCkkM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WRHCi1saSOTvCkkM .icon-shape p,#mermaid-svg-WRHCi1saSOTvCkkM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WRHCi1saSOTvCkkM .icon-shape rect,#mermaid-svg-WRHCi1saSOTvCkkM .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WRHCi1saSOTvCkkM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WRHCi1saSOTvCkkM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WRHCi1saSOTvCkkM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 业务代码
ChatClient.prompt
组装消息
system+user
应用 Options
默认+本次合并
经过 Advisor 链
记忆/工具/检索
ChatModel.call/stream
模型服务
ChatResponse
content 取字符串
Advisor 链是本系列的灵魂,第 6 篇会单独深挖。这里先建立"请求一定会经过它"的心智。
十、踩坑 FAQ
坑 1:把 stream() 的 Flux 当 String 用
现象 :String r = chatClient.prompt().user(q).stream().content(); 编译报错。
原因 :stream().content() 返回 Flux<String>,不是 String。
解决 :需要订阅消费(Flux 是惰性的,不订阅不执行),或用 .block() 阻塞取单值(不推荐在 WebFlux 环境用)。
坑 2:defaultSystem 被单次 .system() 意外覆盖
现象 :加了 .system() 后,AI 人设突然变了。
原因 :单次 .system() 替换默认值,不是追加。
解决 :全局人设放 defaultSystem,任务级约束放 .system(),并明确这是替换关系。
坑 3:temperature 设太高输出乱码/胡编
现象 :代码题输出语法错误、事实题胡说八道。
原因 :temperature 高,采样更随机。
解决:严谨任务降到 0~0.3。
坑 4:在 WebFlux 环境里 stream 没输出
现象 :调了 stream() 但前端收不到任何东西。
原因 :Flux 是惰性的,没人订阅就不会执行;或在 MVC 环境里用错返回类型。
解决 :确保返回的 Flux 被框架订阅(如 @GetMapping 返回 Flux<String> 并配 TEXT_EVENT_STREAM),不要自己中途 .block()。
坑 5:max-tokens 设太小,回答被截断
现象 :回答突然断在半截。
原因 :maxTokens 限制了最大输出,超过就被截断。
解决 :按场景调大 maxTokens,同时留意成本(token 越多越贵)。
十一、小结与合集导航
本篇把 ChatClient 从"会调用"推进到"会用对":
- 分清
ChatClient(业务主入口)与ChatModel(底层接口) - 掌握三种消息角色与
defaultSystem/.system()的替换语义 - 理解
call()同步与stream()响应式的原理与取舍 - 学会用
ChatOptions.builder()做参数调优 - 用多客户端实现配置隔离
下一篇:《同一套代码切换 OpenAI/DeepSeek/通义/Ollama》------把"换模型不改代码"的可移植抽象讲透。
十二、互动钩子
投票:你的 AI 场景主要用哪种?
- call() 同步(后台/工具型)
- stream() 流式(聊天界面)
- 两者都有
评论区聊聊你在 temperature 上调优踩过的坑,或者你的客户端是怎么隔离的;源码见置顶评论。