第7章:Spring AI 2.0流式输出与消息聚合
上章思考题答案以你接入的 Spring AI 2.0 实现为准单次system()通常覆盖这一次的系统文本不应再叠加一条建造期系统消息。不要靠猜。把chatResponse之前的Prompt打出来或在测试里记录请求消息条数。条数不是 1 条系统加 1 条用户时先改调用方式再谈 token。账单在同一次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 与 Usage2. 项目设计小胖说「这不就是食堂窗口边炒边递吗炒好一根菜就递出去客人不用等整盘。」小白问「递到一半客人走了后厨这盘算不算做完小票上的金额按整盘还是按已经递出去的你的日志打算记哪一种」小胖又说「页面自己把字拼起来不就行了服务器不用再拼一次。」小白追问「页面拼出来的字符串没有finishReason也没有 token。审计要的是服务器看到的那一份。两份拼接规则如果不一样纠纷时听谁的」大师把两条出口分开「ChatClient.stream().content()是FluxString每一段是增量正文给页面。stream().chatResponse()是FluxChatResponse每一段是协议里的一个块往往只带当前 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(){}publicstaticFluxStringchunks(ChatClientchatClient,StringorderId,StringuserText){StringsystemAfterSalePrompts.renderSystem(orderId,App,简短,policy-2026.10)\n回答中必须出现未发货可退;returnchatClient.prompt().system(system).user(userText).stream().content().doOnNext(piece-System.out.println(chunkpiece));}}运行结果文字描述控制台多行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){StringsystemAfterSalePrompts.renderSystem(orderId,App,简短,policy-2026.10)\n回答中必须出现未发货可退;varfluxchatClient.prompt().system(system).user(userText).stream().chatResponse();AtomicReferenceChatResponsedonenewAtomicReference();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){StringsystemAfterSalePrompts.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) 模拟中途断开步骤目标残句不写进对外审计。varfluxchatClient.prompt().system(system).user(userText).stream().chatResponse().take(1);AtomicReferenceChatResponsepartialnewAtomicReference();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.javaspring-ai-model/.../chat/model/MessageAggregator.javaChatClient.StreamResponseSpec3.4 测试验证SpringBootTestEnabledIfEnvironmentVariable(namedDEEPSEEK_API_KEY,matches.)classRefundStreamTest{AutowiredprivateChatClientchatClient;TestvoidstreamAndSyncShareThePhrase(){StringorderIdLC202610060018;Stringquestion这单未发货能不能退;StringsyncRefundStreamSync.sync(this.chatClient,orderId,question);ChatResponseaggregatedRefundAudit.collect(this.chatClient,orderId,question);Stringstreamedaggregated.getResult().getOutput().getText();System.out.println(syncsync);System.out.println(streamstreamed);assertThat(sync).contains(未发货可退);assertThat(streamed).contains(未发货可退);assertThat(aggregated.getMetadata().getUsage().getPromptTokens()).isPositive();}}把步骤 3 的sync方法放到RefundStreamSync避免测试直接依赖未整理的片段。预期两条正文都含短语token 大于 0。无密钥则跳过。mvn -DtestRefundStreamTest test4. 项目总结4.1 优点与缺点对比项只同步 callstream 加 MessageAggregator首字时间等整段第一块即可显示审计天然一条必须等聚合完成费用一次调用仍是一次调用中断少见残句需要丢弃策略测试一个字符串要订阅否则没有请求优点页面可以打字式展示退款说明。日志仍是一条完整ChatResponse不是几十行碎片。聚合和展示来自同一次流不会双倍计费。关键短语让同步和流式可以对照而不要求全文相同。缺点过早读取Usage会得到 0。取消订阅后的残句看起来像答案。在 Web 线程blockLast会占住线程。错误结束时聚合结果可能是 null。4.2 适用场景适用App 退款说明需要边出字边显示。审计要完整正文和 token。需要对比同步与流式是否守住同一事实短语。要演练中途断开时不把残句当最终答案。运维要区分「用户取消」和「模型正常结束」。不适用回答必须是一次性 JSON。结构化输出的校验顾问不支持流见第 8 章。只有几个字的问候。同步更简单流式收益很小。4.3 注意事项生产接口返回Flux不要在 Servlet 线程里阻塞等待整段除非你明确使用 WebFlux 或单独的弹性线程。对外审计只接受finishReason为STOP且包含关键短语的聚合结果。日志不要打印每一个 chunk 的全文到永久存储调试日志即可。聚合器不会替你重试。流中的错误要有自己的降级文案。4.4 常见踩坑审计库里一条回答变成四十行。根因是把content()的每一块都 insert 了。处理只在聚合回调里写一行。计费是页面展示的两倍。根因是流式给用户看结束后又call()一次写日志。处理日志读聚合结果。用户取消后仍展示半句作为最终结论。根因是没看finishReason。处理非STOP的聚合结果标记为不可发布。4.5 思考题页面已经拼出了完整句子服务器聚合结果却少了最后一个字。你先怀疑取消订阅还是怀疑页面把重复的 delta 又拼了一次要加哪条日志区分流式接口的网关超时如果仍是同步接口的 8 秒长退款说明会被掐断。超时应该从第一块开始重新计算还是从请求开始一直算到结束答案在第 8 章开头。延伸阅读与资源10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析