☰
第7章:Spring AI 2.0流式输出与消息聚合
2026/10/14 0:03:06 网站建设 项目流程

上章思考题答案

  1. 以你接入的 Spring AI 2.0 实现为准,单次system()通常覆盖这一次的系统文本,不应再叠加一条建造期系统消息。不要靠猜。把chatResponse之前的Prompt打出来,或在测试里记录请求消息条数。条数不是 1 条系统加 1 条用户时,先改调用方式再谈 token。
  2. 账单在同一次call().chatResponse()的Usage上采集。不要先content()再为了计费重放一次call()。那是两次模型调用。

1. 项目背景

1.1 业务场景

App 上的退款说明有时要写四五句。同步接口把整段生成完才返回,用户盯着转圈。产品要求边生成边显示,像客服正在打字。与此同时,审计日志必须留下完整答案和Usage,不能只留下页面上最后看见的半句。

本章用stream()把增量推给页面,用MessageAggregator在流结束时拼成一条ChatResponse写入日志。页面要的是「正在打字」,审计要的是「这一次最终说了什么、花了多少 token」。两份材料必须来自同一次订阅。若页面看流、日志再同步调一次,用户看到的句子和入库的句子会分叉,费用也会算两次。邻里仓的退款说明通常四五句,同步等待时转圈明显,所以这一章把展示和记账拆成两条出口,而不是拆成两次模型调用。

1.2 痛点

若只把流式片段直接写进操作日志,一次回答会变成几十行残句,无法检索。若等同步接口再调一次模型来补日志,用户看到的和日志里的不是同一次生成,token 也算了两遍。中途断开更麻烦:页面停了,供应商仍可能记了已生成的 token,而你的日志是空的。

没有聚合器时的路径:同步等待整段 → 页面转圈;或只存碎片 → 审计对不上;或再调一次 → 双倍费用。碎片日志还有一个检索问题。客服按订单号搜索时,命中的是半句「可以」,后半句「未发货才行」在下一行,值班的人会抄错。聚合完成后再写一行,订单号、短语和Usage在同一条记录里。中途断开时这一行可以不写,或写成不可发布。这样账单、页面和审计各自有明确的完成条件,不会把残句当成最终结论。

ChatClient.stream

逐块 content

页面打字

chatResponse 流

MessageAggregator

完整 ChatResponse 与 Usage


2. 项目设计

小胖说:「这不就是食堂窗口边炒边递吗?炒好一根菜就递出去,客人不用等整盘。」

小白问:「递到一半客人走了,后厨这盘算不算做完?小票上的金额按整盘还是按已经递出去的?你的日志打算记哪一种?」

小胖又说:「页面自己把字拼起来不就行了,服务器不用再拼一次。」

小白追问:「页面拼出来的字符串没有finishReason,也没有 token。审计要的是服务器看到的那一份。两份拼接规则如果不一样,纠纷时听谁的?」

大师把两条出口分开:「ChatClient.stream().content()是Flux<String>,每一段是增量正文,给页面。stream().chatResponse()是Flux<ChatResponse>,每一段是协议里的一个块,往往只带当前 delta。MessageAggregator.aggregate订阅这条流,在结束时交给你一条拼好的ChatResponse。页面和日志看的是同一次订阅,不是两次模型调用。」

技术映射:边炒边递是content()流,整盘小票是MessageAggregator结束时的那条ChatResponse。

小白问结束标记:「哪一块才算结束?我如果在第一块就去读Usage,是不是经常是 0?」

小胖说:「第一块有字就算成功,后面断了用户也看见字了。」

小白反对:「用户看见半句『可以退』,后半句『但未发货才行』没到。这不是成功。finishReason要等最后一块。」

大师说明聚合时机:「增量块里的Usage经常还是空的或 0。聚合器在流完成时把文本接起来,并把最后拿到的用量填进那条完整响应。所以计费和审计只读聚合完成后的对象,不读第一块。关键短语「未发货可退」必须出现在聚合后的全文里。同步call()的全文也要包含它。措辞其余部分允许不同,因为那是两次生成。」

技术映射:第一块不是账单。账单在聚合完成回调里。关键短语用来对齐两次生成,而不是要求全文相同。

小白又问订阅:「Flux造出来却没有人订阅,DeepSeek 会不会已经在生成?测试若只断言对象不是 null,会不会把『还没请求』当成『已经成功』?」

小胖说:「对象在就说明连上了。」

小白说:「对象只是一张还没交给后厨的单子。不订阅就不会发出去。测试空等超时,和模型拒绝,看起来都像失败,但前者根本没有账单。」

大师把订阅说清楚:「stream()返回的是冷流。doOnNext和聚合回调都要等订阅才执行。示例里用blockLast是为了在 JUnit 里看完整结果。生产控制器把Flux交给 WebFlux,由框架订阅。不要在普通 Servlet 线程里阻塞整段,否则一个慢回答会占住一条请求线程。计费只读聚合完成后的Usage。第一块上的用量经常仍是 0,把它写成成功会少计费,也会把没结束的回答标成STOP。订阅次数也要数。页面订阅一次、审计再订阅一次,若两条订阅各自触发模型,就又变成两次调用。本章把chatResponse()这条流交给聚合器,页面所需的文字从聚合并行的内容增量来,或者只订阅一次再在本地拆开。不要对同一个stream()结果重复订阅却以为仍是一次生成。」

小胖问断开:「客人走了,窗口还炒不炒?」

小白问:「取消订阅之后,聚合回调还会不会来?来了的话Usage完整吗?测试怎么造这个断开,而不必真的拔网线?页面已经拼出的字,要不要清掉?」

小胖说:「页面上的字留着,用户已经看见了。服务器别记就行。」

小白说:「留着却不标未完成,用户会截图当成客服承诺。截图上的半句和审计库对不上,纠纷时没有 finishReason 可查。」

大师把边界说清:「取消订阅后,聚合器不应再把残句当成正常STOP。本章用take(1)只取第一块然后取消,看聚合结果要么没有,要么finishReason不是STOP,并且不要把这段残句写入对外审计库。页面可以留下已显示的字,但要标成未完成,不能当成最终退款结论。真实网络中断的表现以供应商为准,测试里用操作符模拟就够了。不要为了制造断开去掐掉生产连接。本地没有完整 Usage 时不要补 0,未知不是免费。」

技术映射:中途取消是订阅结束,不是一次成功的call。残句不得进入对外审计,页面上的残句要标成未完成。


3. 项目实战

3.1 环境准备

沿用第 6 章的ChatClient和店规。依赖里已有 Reactor。测试使用blockLast只适合示例和 JUnit,生产控制器应返回Flux,不要在 Web 线程里阻塞。关键短语仍是「未发货可退」。把maxTokens留在默认或 256,避免流还没出现短语就被截断。

网关超时不要沿用同步接口的 8 秒。退款说明要等流结束,超时从请求开始算到最后一块,预算按整段来留。第一块到达后把计时清零,后半段仍会被掐断,聚合结果就变成残句。本地先用默认超时跑通逐块打印,确认短语出现在聚合全文里,再单独给流式路径加长超时。密钥和模型与第 6 章相同,没有密钥则跳过连网测试。take(1)那条用例即使有密钥也不依赖完整回答,它只验证残句不会被标成可发布。

3.2 分步实现

步骤 1:逐块打印 content

步骤目标:看见增量,而不是等整段。

packagecom.linlicang.aftersale;importorg.springframework.ai.chat.client.ChatClient;importreactor.core.publisher.Flux;publicfinalclassRefundStream{privateRefundStream(){}publicstaticFlux<String>chunks(ChatClientchatClient,StringorderId,StringuserText){Stringsystem=AfterSalePrompts.renderSystem(orderId,"App","简短","policy-2026.10")+"\n回答中必须出现:未发货可退";returnchatClient.prompt().system(system).user(userText).stream().content().doOnNext(piece->System.out.println("chunk="+piece));}}

运行结果(文字描述):控制台多行chunk=,每行是几个字或一个标点,而不是一整段。最后拼接能读成完整回答。行数随模型和网络变化,不要断言等于某一个整数。

可能的坑:在doOnNext里做阻塞数据库写入,会拖住流。打印可以,写库放到聚合完成之后。另一个坑是忘记订阅。Flux不订阅就不会请求 DeepSeek,测试会空等后超时。

这一步只证明增量看得见。控制台出现多行短句,而不是等了几秒突然刷出一整段。行数不要写进断言。模型、网络和分块策略都会改变行数。断言放在聚合之后的全文上,看关键短语在不在。若第一行就已经是完整回答,多半是客户端仍走了同步接口,方法名是call而不是stream。先看调用的是哪一个方法,再查供应商是否把短回答合并成了一块。短回答合成一块仍然算流式,只是收益变小,不必为此改断言。

步骤 2:用 MessageAggregator 收成一条 ChatResponse

步骤目标:日志里只有一条完整记录。

packagecom.linlicang.aftersale;importjava.util.concurrent.atomic.AtomicReference;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.chat.model.ChatResponse;importorg.springframework.ai.chat.model.MessageAggregator;publicfinalclassRefundAudit{privateRefundAudit(){}publicstaticChatResponsecollect(ChatClientchatClient,StringorderId,StringuserText){Stringsystem=AfterSalePrompts.renderSystem(orderId,"App","简短","policy-2026.10")+"\n回答中必须出现:未发货可退";varflux=chatClient.prompt().system(system).user(userText).stream().chatResponse();AtomicReference<ChatResponse>done=newAtomicReference<>();newMessageAggregator().aggregate(flux,done::set).blockLast();returndone.get();}}

运行结果(文字描述):done里的正文包含「未发货可退」。getMetadata().getUsage().getPromptTokens()大于 0。finishReason多为STOP。这是一次流式调用,不是先流式再同步。

可能的坑:只blockLast()了content()流,再指望Usage出现。content()没有元数据。聚合必须订阅chatResponse()。另一个坑是聚合回调里的响应为 null,因为流以错误结束。先看错误,不要对 null 取getResult()。

聚合回调是审计的唯一写入点。回调拿到的正文应能独立阅读,包含订单号和「未发货可退」。promptTokens大于 0 才把这条记录标成可计费。等于 0 时按未知处理,不要当成免费成功。流若以错误结束,done可能仍是 null,接口应返回「说明生成失败,请重试」,不要把已经打印到页面的残句补进审计库。页面可以保留残句并标成未完成,审计库只接受完整记录。这样客服检索到的每一行都能对上一次正常结束的生成。

步骤 3:和同步调用对照关键短语

步骤目标:两种交互都守住同一事实短语,其余措辞允许不同。

publicstaticStringsync(ChatClientchatClient,StringorderId,StringuserText){Stringsystem=AfterSalePrompts.renderSystem(orderId,"App","简短","policy-2026.10")+"\n回答中必须出现:未发货可退";returnchatClient.prompt().system(system).user(userText).call().content();}

运行结果(文字描述):同步正文和聚合正文都包含「未发货可退」,两段文字不必相等。两边都不包含「优惠券」。

可能的坑:断言sync.equals(aggregated)。两次采样几乎不会全等,测试会偶发失败。只锁短语和禁止项。

同步和流式是两次独立生成。它们共享店规和关键短语,不共享用词。测试写成全文相等,会在温度或分块差异下偶发失败,开发会去调低温度来迁就断言,退款说明变得生硬,问题却还在。短语「未发货可退」是店规要求模型必须写出的事实,两边都有,就说明两种交互都守住了规则。禁止项「优惠券」用来挡住跑题。其余句子允许不同,日志里把两段都打印出来供人工看即可。打印时标上sync和stream,避免两段连在一起分不清哪次是聚合结果。聚合结果还要带上finishReason,不是STOP的不参与短语对照。

步骤 4:用 take(1) 模拟中途断开

步骤目标:残句不写进对外审计。

varflux=chatClient.prompt().system(system).user(userText).stream().chatResponse().take(1);AtomicReference<ChatResponse>partial=newAtomicReference<>();newMessageAggregator().aggregate(flux,partial::set).blockLast();

运行结果(文字描述):聚合对象要么缺失,要么正文明显短于完整回答,且不应被标记成可展示的最终答案。测试断言:若对象非空,其文本长度小于完整聚合文本,并且业务标志publishable为 false。

可能的坑:take(1)之后仍把结果写入审计表。根因是回调不区分正常完成和提前取消。处理:只有finishReason为STOP且短语齐全才写入对外库。

take(1)是测试里的断开,不是生产里的拔网线。它让订阅在第一块之后结束,用来确认业务标志publishable不会被标成 true。真实用户划走页面时,框架取消订阅,表现应和这个用例一致:可以停止继续读,不可以把残句写成最终退款结论。若供应商已经计了已生成的 token,对账时按供应商账单为准,本地没有完整Usage就不要自己补一个 0。补 0 会把未知记成免费。

3.3 完整代码清单

RefundStream、RefundAudit和测试。源码对照:

  • spring-ai-model/.../chat/model/StreamingChatModel.java
  • spring-ai-model/.../chat/model/MessageAggregator.java
  • ChatClient.StreamResponseSpec

3.4 测试验证

@SpringBootTest@EnabledIfEnvironmentVariable(named="DEEPSEEK_API_KEY",matches=".+")classRefundStreamTest{@AutowiredprivateChatClientchatClient;@TestvoidstreamAndSyncShareThePhrase(){StringorderId="LC202610060018";Stringquestion="这单未发货,能不能退?";Stringsync=RefundStreamSync.sync(this.chatClient,orderId,question);ChatResponseaggregated=RefundAudit.collect(this.chatClient,orderId,question);Stringstreamed=aggregated.getResult().getOutput().getText();System.out.println("sync="+sync);System.out.println("stream="+streamed);assertThat(sync).contains("未发货可退");assertThat(streamed).contains("未发货可退");assertThat(aggregated.getMetadata().getUsage().getPromptTokens()).isPositive();}}

把步骤 3 的sync方法放到RefundStreamSync,避免测试直接依赖未整理的片段。预期:两条正文都含短语,token 大于 0。无密钥则跳过。

mvn -Dtest=RefundStreamTest test

4. 项目总结

4.1 优点与缺点

对比项只同步 callstream 加 MessageAggregator
首字时间等整段第一块即可显示
审计天然一条必须等聚合完成
费用一次调用仍是一次调用
中断少见残句需要丢弃策略
测试一个字符串要订阅,否则没有请求

优点:

  1. 页面可以打字式展示退款说明。
  2. 日志仍是一条完整ChatResponse,不是几十行碎片。
  3. 聚合和展示来自同一次流,不会双倍计费。
  4. 关键短语让同步和流式可以对照,而不要求全文相同。

缺点:

  1. 过早读取Usage会得到 0。
  2. 取消订阅后的残句看起来像答案。
  3. 在 Web 线程blockLast会占住线程。
  4. 错误结束时聚合结果可能是 null。

4.2 适用场景

适用:

  1. App 退款说明需要边出字边显示。
  2. 审计要完整正文和 token。
  3. 需要对比同步与流式是否守住同一事实短语。
  4. 要演练中途断开时不把残句当最终答案。
  5. 运维要区分「用户取消」和「模型正常结束」。

不适用:

  1. 回答必须是一次性 JSON。结构化输出的校验顾问不支持流,见第 8 章。
  2. 只有几个字的问候。同步更简单,流式收益很小。

4.3 注意事项

  • 生产接口返回Flux,不要在 Servlet 线程里阻塞等待整段,除非你明确使用 WebFlux 或单独的弹性线程。
  • 对外审计只接受finishReason为STOP且包含关键短语的聚合结果。
  • 日志不要打印每一个 chunk 的全文到永久存储,调试日志即可。
  • 聚合器不会替你重试。流中的错误要有自己的降级文案。

4.4 常见踩坑

  1. 审计库里一条回答变成四十行。根因是把content()的每一块都 insert 了。处理:只在聚合回调里写一行。
  2. 计费是页面展示的两倍。根因是流式给用户看,结束后又call()一次写日志。处理:日志读聚合结果。
  3. 用户取消后仍展示半句作为最终结论。根因是没看finishReason。处理:非STOP的聚合结果标记为不可发布。

4.5 思考题

  1. 页面已经拼出了完整句子,服务器聚合结果却少了最后一个字。你先怀疑取消订阅,还是怀疑页面把重复的 delta 又拼了一次?要加哪条日志区分?
  2. 流式接口的网关超时如果仍是同步接口的 8 秒,长退款说明会被掐断。超时应该从第一块开始重新计算,还是从请求开始一直算到结束?

答案在第 8 章开头。

延伸阅读与资源

10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询