楼层: 首页/ 软件技术/ Spring AI/ ChatClient:对话 API 详解
三

ChatClient:对话 API 详解

ChatClient Deep Dive

上一章那一行 chatClient.prompt(q).call().content() 只是冰山一角。ChatClient 是流式 API(Builder 风格),能配系统提示词、管对话历史、流式输出。这一章把它掰开。

论call() vs stream():同步等 vs 流式吐

call():等大模型把整段回答生成完,一次性返回。像查字典——翻到那一页整页给你。适合短问题、后台任务。

stream():一边生成一边吐,返回 Flux<String>(响应式流)。像 ChatGPT 那种打字机效果,一个字一个字蹦出来。适合前端聊天界面,用户不用干等 5 秒。

流式输出:返回 Flux,前端用 SSE 接收打字机效果

@GetMapping(value = "/stream", produces = "text/event-stream") public Flux<String> stream(@RequestParam String q) { // stream() 返回 Flux,大模型每吐一个词就推一次 return chatClient.prompt(q).stream().content(); } // 前端用 EventSource 接,逐字显示,体验跟 ChatGPT 一样

系统提示词:给 AI 定人设

同样一个问题,让 AI 当"老师"和当"段子手",回答完全不同。系统提示词(System Prompt)就是开工前跟 AI 说"你是谁、你怎么说话"。

用 system() 设人设,用户问题用 user()

String answer = chatClient.prompt() .system("你是一个幽默的编程老师,用大白话讲技术,爱打比方,别用术语。") .user("什么是递归?") .call() .content(); // AI 会用段子手的方式解释递归,而不是念教科书定义

多轮对话:ChatMemory 管历史

大模型本身没有记忆——每次请求都是独立的,它不知道你上一句说了啥。想让它记得上下文,你得自己把历史消息每次都带上。Spring AI 提供 ChatMemory 帮你存对话历史。

用 ChatMemory 实现多轮对话(按 sessionId 区分用户)

// 配一个 ChatMemory Bean,这里用内存版,生产换 Redis @Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder().build(); } // 接口里:带上 conversationId,Spring AI 自动把这个用户的历史拼进去 @GetMapping("/chat/{sessionId}") public String chat(@PathVariable String sessionId, @RequestParam String q) { return chatClient.prompt() .user(q) .advisors(a -> a.advisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .param(ChatMemory.CONVERSATION_ID, sessionId)) .call() .content(); // 第二次请求同一个 sessionId,AI 记得第一次聊了啥 }

Advisors:横切所有对话的"顾问"

Spring AI 用 Advisor 在每次调用前后插逻辑。MessageChatMemoryAdvisor 自动拼历史,QuestionAnswerAdvisor 自动做 RAG,还可以自定义 Advisor 做日志、审计。你在 .advisors(...) 里配的东西,每次请求都会生效,不用在每个接口里重复写。

内置 Advisor干什么
MessageChatMemoryAdvisor自动存取对话历史,实现多轮。
QuestionAnswerAdvisor自动从向量库检索拼上下文,实现 RAG。
SimpleLoggerAdvisor记录每次 Prompt 和返回,调试用。
对话历史越长越贵

每次请求都会把整段历史发给大模型,历史消息占 token,token 就是钱。聊了 100 轮,每次请求都带 100 轮历史,费用指数上涨。生产上要做截断:只保留最近 N 轮,或者对老消息做摘要。MessageWindowChatMemory(内存版)重启就丢,生产换 Redis / JDBC 持久化(如 JdbcChatMemoryRepository)。

完整案例:流式对话 + 系统人设 + 多轮记忆 + Advisor 链

把这章三件套(system 人设、ChatMemory 多轮、stream 打字机)合成一个能跑的完整接口,带真实输出。

第一步:配 ChatMemory Bean(生产换 RedisChatMemory)

@Bean public ChatMemory chatMemory() { // 只留最近 20 条消息,防 token 爆炸 return MessageWindowChatMemory.builder().maxMessages(20).build(); }

第二步:流式聊天接口,人设 + 记忆 + 逐字吐

@GetMapping(value = "/chat/stream", produces = "text/event-stream") public Flux<String> streamChat(String q, String sessionId) { return chatClient.prompt() // 人设:固定角色 + 边界 .system("你是幽默的编程老师,大白话爱打比方,不知道就说不知道。") .user(q) // Advisor 链:先按 sessionId 拼历史,再逐字流式吐 .advisors(a -> a.advisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .param(ChatMemory.CONVERSATION_ID, sessionId)) .stream() .content(); }

第三步:两次请求看效果(同一个 sessionId)

# 第一轮 GET /chat/stream?q=我叫王小明&sessionId=u1 # SSE 逐字返回:你好小明!我是你的编程老师~ 有啥想聊的? # 第二轮:问个需要上下文的问题 GET /chat/stream?q=我叫啥?&sessionId=u1 # 逐字返回:你叫王小明呀,刚才你自己告诉我的~ # ↑ 因为 ChatMemory 把上一轮"我叫王小明"带进了 prompt # 换个 sessionId 就失忆了 GET /chat/stream?q=我叫啥?&sessionId=u2 # 返回:你还没告诉我你叫啥呢,说来听听?

论一次流式调用内部发生了啥

① MessageChatMemoryAdvisor 按 sessionId 把历史消息拼进 prompt;

② ChatClient 把"系统人设 + 历史 + 新问题"发给 DashScope;

③ 大模型一边生成一边通过 SSE 推 token,Spring 包成 Flux<String>;

④ 前端 EventSource 收到一个词渲染一个词,打字机效果;

⑤ 回答完,Advisor 把这轮问答存回 ChatMemory。

本章面试题

面试快答 · ChatClient

Q1. call() 和 stream() 区别?什么场景用哪个?

参考答案

call() 同步等整段回答一次性返回,适合后台任务、短问题;stream() 返回 Flux<String> 逐字吐,适合前端聊天打字机。前端接 stream 必须用 SSE(text/event-stream + EventSource)。

Q2. 大模型本身有记忆吗?怎么实现多轮对话?

参考答案

没有,每次请求无状态。多轮靠 ChatMemory:按 conversationId 把历史消息每次拼进 prompt。代价是历史占 token、就是钱,所以要截断/摘要,生产用 Redis 持久化。

Q3. Advisor 是什么?举两个内置 Advisor。

参考答案

Advisor 是横切每次调用的"顾问",在请求前后插逻辑。MessageChatMemoryAdvisor 自动拼历史;QuestionAnswerAdvisor 自动做 RAG 检索;SimpleLoggerAdvisor 记日志。好处是不用每个接口重复写。