Spring AI ChatClient 详解:system/user/assistant 角色、call 与 stream 到底怎么选

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(业务主入口)vs ChatModel(框架底层)
  • system / user / assistant 三种消息角色的职责与用法
  • defaultSystem() 与单次 .system() 的优先级陷阱
  • call() 同步 vs stream() 响应式的原理、代码与适用场景
  • 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 上调优踩过的坑,或者你的客户端是怎么隔离的;源码见置顶评论。

相关推荐
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 喜欢与取消喜欢
java·开发语言·交友
萧瑟余晖1 小时前
Spring Data Redis详解
spring·面试
Ivanqhz1 小时前
MoE 原理、TC/EC 路由、LSH-MoE 与负载均衡浅析
运维·网络·人工智能·深度学习·算法·机器学习·负载均衡
码农小白AI1 小时前
无损检测 NDT 批量报告审核效率如何提升?IACheck AI 报告文档审核 Agent 优化批量报告审核
大数据·人工智能
hrrrrxeeeee1 小时前
不会编程适合考什么证书?AI应用认证与技术型认证的门槛区别
人工智能
摸鱼仙人~1 小时前
校招帮简历字段匹配架构分析报告
java·前端·架构
shxjnpl1 小时前
粤语客服录音如何实现自动质检?从 ASR 语音识别到大模型分析
人工智能·语音识别·实时音视频
钛生智能1 小时前
OA 流程定制全参数详解(七):流程的生命周期——启用、停用、导出、导入
java·办公自动化·oa·协同办公·oa办公系统
2601_955662462 小时前
短视频配音怎么做?从文案到成品音频的完整流程
人工智能·音视频·语音识别·媒体·视频