第四章 · 前台掌柜:ChatClient 流式 API
下一章@Override
Flux<ChatResponse> stream(Prompt prompt); //唯⼀抽象⽅法
}( chat/model/StreamingChatModel.java:29-49 )
@FunctionalInterface ⼀⾏,把整个流式契约压缩成唯⼀的抽象⽅法 stream(Prompt) ——返回 Reactor 的 Flux<ChatResponse> ,每个元素是⼀个流式块,块本身仍是⼀个完整形状的 ChatResponse (只是内容是增量⽚段)。两个 default 重载只是糖:把字符串/消息数组包成 Prompt ,再把每个块映射成纯⽂本Flux<String> ,取⾸个 generation 的⽂本、空兜底为 "" ( :32-46 )。简单场景⼀⾏流式,复杂场景⾛完整 ChatResponse ——⼜是那套"便捷默认降⻔槛"的⽼配⽅。
⽽真正的精妙在 ChatModel 这⾥。上⼀章我们提过, ChatModel 同时继承了同步 Model 和流式 StreamingChatModel ( chat/model/ChatModel.java:30 ),天然是"同步+流式"⼆合⼀。可万⼀某个番邦压根不⽀持流式呢?看它给 stream 的默认实现:
default Flux<ChatResponse> stream(Prompt prompt) {
throw new UnsupportedOperationException("streaming is not supported");
}( chat/model/ChatModel.java:64-66 )
这是⼀句优雅的认怂。 StreamingChatModel 的唯⼀抽象⽅法 stream(Prompt) ,被 ChatModel ⽤⼀个抛 UnsupportedOperationException 的 default实现"顶"掉了。结果是:你实现 ChatModel 时,只须实现同步的 call(Prompt) 即可编译通过——不想做流式,就让它继续抛异常;想做,就覆盖它。⼀个不强迫、可降级的契约。
🦴 ⻣灰级细节:这⾥有个容易看⾛眼的接⼝设计博弈。 StreamingChatModel ⾃⼰是 @FunctionalInterface ,意味着 stream(Prompt) 在它那⾥是抽象的、必
须实现的;可⼀旦被 ChatModel 继承, ChatModel ⼜给同⼀个⽅法补了个抛异常的 default ,把"必须实现"软化成了"可选覆盖"。同⼀个⽅法签名,在⽗接⼝是硬契约、在⼦接⼝是软默认——Spring AI ⽤"⼦接⼝补 default 实现"这⼀⼿,既保住了 StreamingChatModel 单独使⽤时的函数式纯粹性,⼜让 ChatModel 的实现者获得了"流式可选"的⾃由。代价是: ChatModel 因此不再是函数式接⼝(它有两个待实现⽅法 call 和……其实只剩 call ,因为 stream 已被 default 兜底)。这种"⽤ default 改写继承来的抽象⽅法"的技巧,是 Java 8 之后接⼝设计的⼀记巧劲,但也让"这个⽅法到底要不要我实现"变得要看你站在哪个接⼝往下看。
顺带点破⼀个隐喻边界:别把"流式"想象成番邦在"⼀个字⼀个字往外蹦"。 Flux<ChatResponse> 是 Reactor 的响应式流,每个 ChatResponse 块是⽹络层切⽚后的产物,这些碎⽚化的密信最终还得有⼈拼回完整的⼀封——那是上⼀章提过的书记官 MessageAggregator 的活,本章不展开,记住流式块是"半成品"即可。
🎯 三句带⾛
1. ChatOptions 是 8 个全 @Nullable 的可移植参数(model/temperature/maxTokens/topK/topP/frequency-presence-penalty/stopSequences); null表示"弃权"⽽⾮"清零";⼚商/能⼒扩展⾛⼦接⼝(如 ToolCallingChatOptions 的 toolCallbacks/toolContext),不旁路。
2. 合并语义有四套且分散:标量"⾮ null 才覆盖"、stopSequences"追加"、toolCallbacks"整体替换"、toolContext"逐键覆盖";且这些 merge* 是静态 helper ⽽⾮接⼝契约,合并规则缺单⼀权威⼊⼝,靠各 provider 约定。
3. StreamingChatModel 是 @FunctionalInterface ,唯⼀抽象⽅法 stream(Prompt) 返回 Flux<ChatResponse> ; ChatModel ⽤⼀个抛UnsupportedOperationException 的 default 把流式改成"可选覆盖",实现者只需写同步 call 即可。
承上启下到这⾥,馆⼼(core)那层冰冷的契约——⽂牒、信函、回执、通⽤条款、流式约定——我们已经逐张验明正身。但有件事⼀直被我们刻意推后:这些 Prompt 、ChatOptions 、 Flux 真要由客官亲⼿ new 出来、亲⼿拼装、亲⼿订阅吗?
当然不。没有哪个商旅愿意每次进馆都⾃⼰写 new Prompt(new UserMessage(...)) 、⾃⼰ combineWith 默认 options、⾃⼰ subscribe 那个 Flux 。译馆的前台掌柜早就候在那⼉了——他点菜式地接你的话( .user() 、 .system() 、 .options() ),把这些底层零件在幕后悄悄拼好,还能直接给你⼀个流式的话筒。下⼀章,我们⾛到台前,看这位掌柜怎么把本章这堆"原料"端成⼀道菜——「第四章 · 前台掌柜:ChatClient 流式 API」。
卷⼆ · 接待(ChatClient + Advisor)
第四章 · 前台掌柜:ChatClient 流式 API
"我只管把客官领到位、把座次排⻬整,真正办事的,是我身后那⼀排⼈。" ——若 ChatClient 会开⼝,它⼤概会这么撇清⾃⼰。
卷⼀⾥我们摸清了通⽤⽂牒的家底: ChatModel 怎样把⼀纸契约递给番邦, ChatResponse 怎样带着耗墨账回来。那是后厨的事。可客官进了译馆,第⼀个照⾯的从来不是后厨——是前台。
推开春芽译馆的⻔,阿芽迎上来,身后⽴着⼀座漆得发亮的柜台。柜台后坐着位笑眯眯的掌柜:你想点什么菜(system 训谕、user 客⾔、要不要带⼏个差役、⾛同步还是流式),跟他说就⾏。他不亲⾃下厨,只负责接待 + 把座次排⻬整,然后扭头朝身后那⼀排⼈喊⼀声"上"。
这位掌柜,就是 ChatClient 。本章只讲他怎么接待、怎么排队形;⾄于他身后那⼀排到底是谁、怎么层层盘查——按下不表,留到下⼀章。
⼀、掌柜从不记仇:⽆状态的 fluent ⼊⼝先看怎么把掌柜请出来。 ChatClient 是个接⼝,⻔⾯是两组静态⼯⼚:
static ChatClient create(ChatModel chatModel) {
return create(chatModel, ObservationRegistry.NOOP);
}// ...
static Builder builder(ChatModel chatModel) {
return builder(chatModel, ObservationRegistry.NOOP, null, null);
}ChatClient.java:63-82create 系列是"懒⼈通道",内部其实也是 builder(...).build() ( ChatClient.java:76-77 ); builder 系列才是正路,能顺⼿塞进观测注册表、⾃定义的ToolCallingAdvisor.Builder 。两条路殊途同归,最后都落到 DefaultChatClientBuilder :
public ChatClient build() {
return new DefaultChatClient(this.defaultRequest);
}DefaultChatClientBuilder.java:114-116注意 build() 把⼀个 defaultRequest (⼀份默认请求规格)交给了 DefaultChatClient 。这意味着:你在 builder 阶段配的那些 defaultSystem /defaultAdvisors / defaultTools ,会被烙进这份默认规格,成为往后每⼀次接待的"底单"。
掌柜到位了,开始点菜。 prompt() ⼀开⼝,返回的是 ChatClientRequestSpec ——⼀张当次的"点菜单":
ChatClientRequestSpec prompt();
ChatClientRequestSpec prompt(String content);ChatClientRequestSpec prompt(Prompt prompt);ChatClient.java:128-132接下来就是那串你早已眼熟的链式调⽤。挑两个看实现:
@Override
public ChatClientRequestSpec system(String text) {
Assert.hasText(text, "text cannot be null or empty");
this.systemText = text;
return this;
}// ...@Override
public ChatClientRequestSpec advisors(Advisor... advisors) {
Assert.noNullElements(advisors, "advisors cannot contain null elements");
this.advisors.addAll(Arrays.asList(advisors));
return this;
}DefaultChatClient.java:1089-1094 、 978-984
system() 、 user() 、 messages() 、 tools() 、 advisors() 、 options() ——清⼀⾊把值往⾃⼰字段上⼀塞,然后 return this 。这是 fluent API 的标准⻓相:每⼀步都把同⼀张点菜单递回你⼿⾥,你接着划下⼀道菜。
✨ 爽点时刻:这种设计读起来像写句⼦。 chatClient.prompt().system("你是诗⼈").user("写⾸春天").call().content() ⼀⽓呵成,IDE 的⾃动补全顺着
this ⼀路点到底,⼏乎不⽤查⽂档。语义即代码,代码即语义。
但真正值得拍桌⼦的,不是链式好看,⽽是掌柜从不记仇。 ChatClient 本身是⽆状态的——它持有的只是那份只读的默认规格;每次 prompt() 都新开⼀张点菜单,互不串味。所以同⼀个 ChatClient 实例可以被全局单例、被多线程并发复⽤,你不必担⼼ A 客官的 system 训谕漏给了 B 客官。这跟"前台掌柜"的隐喻严丝合缝:
掌柜接待完⼀桌,转头就忘,绝不把上⼀桌的⼝味带到下⼀桌。
⚠ 暗礁(隐喻边界):别把"⽆状态"误读成"对话⽆记忆"。掌柜不记仇,是说框架对象⽆状态;可⼀旦你挂上记忆驿丞(后续章节的 MessageChatMemoryAdvisor ),对话历
史照样能跨轮留存——那是另⼀个⼈在记账,不是掌柜。隐喻到此为⽌,别越界。
⼆、两个终端:call() 与 stream() 各⾃排队形点完菜,客官只有两种⾛法:要么坐下等⼀整份端上来( call() ),要么要求边做边上、⼀道⼀道流出来( stream() )。看这对孪⽣终端:
@Override
public CallResponseSpec call() {
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultCallResponseSpec(DefaultChatClientUtils.toChatClientRequest(this), advisorChain,
this.observationRegistry, this.chatClientObservationConvention);
}@Override
public StreamResponseSpec stream() {
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultStreamResponseSpec(DefaultChatClientUtils.toChatClientRequest(this), advisorChain,
this.observationRegistry, this.chatClientObservationConvention);
}DefaultChatClient.java:1175-1187两个⽅法⻓得像双胞胎,各⾃⼲了恰好两件事:
1. buildAdvisorChain() ——把身后那⼀排⼈(驿丞)按规矩排好队形;
2. toChatClientRequest(this) ——把点菜单翻成⼀份正式的请求。
然后各⾃包成⼀个 ResponseSpec (响应规格)交还给你。注意:点完菜的那⼀刻,模型还没被惊动。 call() 返回的只是个 CallResponseSpec ,真正的盘问与下厨,要等你向它要结果—— content() / chatResponse() / entity() ——才会被触发。这是惰性的:点菜单是声明,索取结果才是执⾏。
buildAdvisorChain() 是本章和下⼀章的接缝,这⾥只揭⼀⻆:
private BaseAdvisorChain buildAdvisorChain() {
autoRegisterToolCallingAdvisor();
validateSingleToolAdvisor();// At the stack bottom add the model call advisors.// They play the role of the last advisors in the advisor chain.
List<Advisor> chain = new ArrayList<>(this.advisors);
chain.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build());
chain.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build());
return DefaultAroundAdvisorChain.builder(this.observationRegistry)
.observationConvention(this.advisorObservationConvention)
.pushAll(chain)
.build();
}DefaultChatClient.java:1189-1203
看最后这两个 chain.add :⽆论你⾛的是 call() 还是 stream() ,它都把 call 和 stream 两个"终结者"⼀起塞进同⼀份链。乍看冗余——同步路径怎么也要那个stream 终结者?谜底在下⼀章:这俩各⾃只实现 CallAdvisor / StreamAdvisor 接⼝,⼊队时被分到两个不同的队列⾥,井⽔不犯河⽔。掌柜⼀句"上",喊的其实是两套⼈⻢,各听各的号令。
🦴 ⻣灰级细节:这条链是⼀次性、消费式的——内部⽤ Deque.pop() 弹栈推进,跑完即耗尽。那为什么 ChatClient 还能反复⽤?就因为 call() / stream()
每次进来都重新 buildAdvisorChain() 现搭⼀条全新的链。链是耗材,掌柜是常驻;每接待⼀桌就现排⼀次队形,排完就拆。这正是"⽆状态可复⽤"在底层兑现的⽅式——不是没有状态,⽽是把状态全压进了每次现造、⽤完即弃的链⾥。理解这⼀点,你才算真的看懂为什么这套 API 敢让你拿单例满世界跑。
三、座次是死规矩:System → messages → User掌柜接待最⻅功夫的⼀步,是排座次。客官七嘴⼋⾆地报了 system、塞了⼀串历史 messages、⼜补了句 user,这些碎⽚到了模型⾯前,必须排成模型认得的队形。这活⼉交给 DefaultChatClientUtils.toChatClientRequest :
// System Text => First in the list
String processedSystemText = inputRequest.getSystemText();
if (StringUtils.hasText(processedSystemText)) {
if (!CollectionUtils.isEmpty(inputRequest.getSystemParams())) {
processedSystemText = PromptTemplate.builder()
.template(processedSystemText)
.variables(inputRequest.getSystemParams())
.renderer(inputRequest.getTemplateRenderer())
.build()
.render();
}
processedMessages.add(SystemMessage.builder()...build());
}
// Messages => In the middle of the list
if (!CollectionUtils.isEmpty(inputRequest.getMessages())) {
processedMessages.addAll(inputRequest.getMessages());
}
// User Text => Last in the list
String processedUserText = inputRequest.getUserText();// ...(同样的模板渲染分⽀)...
processedMessages.add(UserMessage.builder()...build());DefaultChatClientUtils.java:57-97顺序是写死的:System 第⼀ → 你的 messages 居中 → User 垫底。注意源码⾥那三段注释 => First 、 => In the middle 、 => Last ,作者⽣怕后⼈改坏,把座次刻进了注释。这不是随意——绝⼤多数番邦都默认 system 训谕居⾸、最新⼀句客⾔垫底,框架替你把这条潜规则固化下来,你就不必每次⼿摆消息数组。
夹在中间的 PromptTemplate 分⽀也别放过:只要你给 systemParams / userParams 喂了变量,⽂本就会过⼀遍 TemplateRenderer 渲染。这是把"公⽂模板"嵌进接待流程——你写 user("帮我翻译成{lang}") 再传 lang=法语 ,渲染就在这⼀步发⽣( DefaultChatClientUtils.java:63-68 、 85-90 )。
排完座次,顺⼿把 options 也并好:
ChatOptions.Builder<?> builder = inputRequest.getChatModel().getOptions().mutate();
if (inputRequest.getOptionsCustomizer() != null) {
builder = builder.combineWith(inputRequest.getOptionsCustomizer());
}DefaultChatClientUtils.java:103-106起点是 chatModel.getOptions().mutate() ——以模型⾃带的默认条款为底,再叠加你当次的定制。若这 builder 恰好是 ToolCallingChatOptions.Builder ,
还会把差役名册(toolCallbacks)和差役⾏囊(toolContext)注进去( :108-124 )。最后打包成⼀份 ChatClientRequest ,context ⽤⼀个 ConcurrentHashMap 兜底:
return ChatClientRequest.builder()
.prompt(promptBuilder.build())
.context(new ConcurrentHashMap<>(inputRequest.getAdvisorParams()))
.build();DefaultChatClientUtils.java:133-136
⚠ 暗礁(这是真批评,不是夸):看⻅那个 context 没有?它是个裸 Map<String, Object> ,贯穿整条驿丞链,谁都能往⾥塞、谁都能往外掏。RAG 驿丞约定⽤
qa_retrieved_documents 这个字符串 key 存检索到的典籍,记忆驿丞⽤另⼀个 key 存会话 id——全靠⼝头约定的魔法字符串串场,编译器⼀句话都帮不上你。key敲错⼀个字⺟,不报错,只是悄⽆声息地拿到 null ,然后你对着⼀条空荡荡的回函百思不得其解。这是 Spring AI 这套优雅设计⾥⼀处实打实的类型安全洼地:fluent ⻔⾯光鲜,context 后院却是个谁都能进的杂物间。隐喻⾥那本"通⽤⽂牒"是有封印有格式的;可这只跨链传递的⼝袋,偏偏没上锁。
四、取菜的姿势:同步要整盘,流式要逐⼝座次排好、链也搭好,最后看客官怎么把菜端⾛。
同步路⾛ CallResponseSpec ,菜单很全: content() 取纯⽂本、 chatResponse() 取整张回函(含耗墨账与收笔缘由)、 entity(...) 直接反序列化成你的 Java对象。 entity 这⼀⽀尤其讨喜——内部⽤ BeanOutputConverter 把模型吐的 JSON 喂回你的 POJO:
@Override
public <T> ResponseEntity<ChatResponse, T> responseEntity(Class<T> type) {
Assert.notNull(type, "type cannot be null");
return doResponseEntity(new BeanOutputConverter<>(type));
}DefaultChatClient.java:463-467
流式路⾛ StreamResponseSpec ,返回的是 reactor 的 Flux 。看 content() 这道最常点的菜:@Override
public Flux<String> content() {
return chatResponse()
.map(r -> Optional.ofNullable(r.getResult())
.map(Generation::getOutput)
.map(AbstractMessage::getText)
.orElse(""))
.filter(StringUtils::hasLength);
}DefaultChatClient.java:747-757⼲净利落:从每⼀帧 ChatResponse ⾥逐层 Optional 摸出⽂本,摸不到就给空串,再 filter 掉空帧。于是你拿到的是⼀条 Flux<String> ——番邦每吐⼀个字符⽚段,这⾥就推⼀截给你,前端那种"打字机效果"就是这么来的。
两条路的对称很美:同步要的是⼀整盘,流式要的是⼀⼝⼀⼝;但喂给它们的是同⼀条点菜单、同⼀套座次规矩、同⼀份请求装配。差别只在终端怎么收⼝。
🦴 ⻣灰级细节:别被 content() 的简洁骗了。它上游的 doGetObservableFluxChatResponse ⽤了
Flux.deferContextual ( DefaultChatClient.java:699-700 ),意味着整条流是惰性的——你不 subscribe ,模型⼀个字都不会吐。更妙的是它在contextWrite ⾥⼿动接⼒了观测的⽗⼦ span( :712-729 ),源码注释还专⻔解释:这样做是为了让 Micrometer 的 span ⽗⼦关系挂在"逻辑⽗观测"上,⽽不是误挂到当前线程恰好开着的 servlet HTTP span 上。流式 + 可观测 + reactor 上下⽂传播,这三者要对⻬,框架在这⾥替你蹚了不少坑——你只看⻅⼀⾏ content() ,底下是这⼀整套精⼼的上下⽂编织。
五、爽在哪、糙在哪:换模型只换链尾把本章的设计收个⼝。
最⼤的爽点,是换模型只换链尾。掌柜接待的姿势、座次规矩、请求装配,全程跟"番邦是谁"⽆关;真正跟某个具体模型绑定的,只有链最末端那个
ChatModelCallAdvisor / ChatModelStreamAdvisor ( DefaultChatClient.java:1196-1197 ),⽽它俩拿的也只是⼀个 ChatModel 接⼝。今天接 OpenAI、明天换 Anthropic,你换掉注⼊的 ChatModel Bean,前台这套 prompt().system().user().call() 的写法⼀个字都不⽤动。可移植抽象的红利,在 ChatClient这⼀层兑现得最直观。
糙的地⽅也得说清:除了上⾯那处 context 缺类型安全的暗礁,还有⼀处理解成本——惰性 + ⼀次性链这套机制对调试不友好。你在 prompt() 后打断点,什么都没发⽣;真正的执⾏散在 subscribe 、散在 Deque.pop 的递归⾥。新⼿常被"我代码明明跑了怎么没调模型"绊⼀跤——答案永远是:你还没向 ResponseSpec 索取结果。优雅与可调试性,这⾥做了⼀笔不算亏但也不⽩送的交易。
🎯 三句带⾛1. ChatClient ⽆状态可全局复⽤;每次 call() / stream() 都 buildAdvisorChain() 现搭⼀条⼀次性的链,跑完即弃。
2. 请求装配 DefaultChatClientUtils.toChatClientRequest 把消息写死成 System → messages → User 三段,带变量的 system/user ⽂本经TemplateRenderer 渲染。
3. 同步⽤ CallResponseSpec ( content()/chatResponse()/entity() ),流式⽤ StreamResponseSpec 返回惰性 Flux ;换模型只换链尾的两个终结advisor,前台写法零改动。
承上启下掌柜笑眯眯地把你的点菜单翻成了正式请求,排好了 System→messages→User 的座次,然后扭头朝身后喊了那⼀声"上"。本章我们只看了他怎么接待、怎么排队形——可那⼀声"上"喊给谁听? buildAdvisorChain() ⾥那条被现搭、被 pushAll 、被按 order 排序的链,到底是怎么⼀层层把请求往⾥递、⼜⼀层层把回函往外捧的?
掌柜从不办事,办事的是他身后那⼀排垂⼿⽽⽴、层层盘查的⼈。下⼀章,我们就绕到柜台后⾯,挨个盯住他们的脸——「第五章 · ⼀排驿丞:Advisor 责任链」