
本文是「Spring Boot AI 全栈后端」系列第 09 篇。前几篇我们解决了模型能不能对话、能不能省钱、能不能按格式输出、能不能查实时数据、能不能读私有文档、能不能自己串任务、能不能统一接入内部系统。这一篇回到用户体验本身点发送之后那几秒的空窗怎么变成一句一句往外蹦的打字机。示例基于 Spring AI 2.0 / Boot 4.1。一个等了六秒用户关掉了页面的场景给客户做第一期 AI 助手上线后产品经理甩过来一条用户反馈用户问帮我写一份下周的营销活动方案页面上的按钮转圈转了六秒用户以为卡死了直接关掉。六秒其实不算慢——模型要生成一份几百字的方案本来就是这么久。问题不在速度在感知用户盯着一个转圈没有任何反馈六秒里他的耐心就耗完了。如果把生成过程换成一句一句往外蹦用户第一秒就看到字在动剩下五秒他是在看内容不是在等结果。这个体验差别背后是两种完全不同的接口形态维度一次性返回call流式返回stream首字延迟要等整段都生成完模型吐出第一段就发出去用户感知转圈 → 突然一大段打字机 → 跟着读断线恢复整段重来已收到的部分还在服务端内存攒整段答案边生成边发送压力平摊实现复杂度一行.content()多几行还要处理取消和异常一句话流式不改变生成要多快它改变用户感觉有多快。对长文生成、代码补全、报告写作这类场景流式几乎是标配。一、ChatClient 切到流式其实只差一个方法第 02 篇里拿一次完整答案是这样写的StringanswerchatClient.prompt().system(你是智能写作助手).user(question).call()// 一次性等整段.content();切成流式把.call()换成.stream()返回类型从String变成FluxStringFluxStringanswerStreamchatClient.prompt().system(你是智能写作助手).user(question).stream()// 流式一段一段来.content();FluxString是 Reactor 的响应式类型代表未来会一个接一个到来的字符串序列。Spring AI 把stream()之后的三个出口都给你了content()只要文本片段chatResponse()要带元数据的完整响应chatClientResponse()要包括工具调用在内的全过程。做打字机content()就够。这层抽象的好处是业务代码不关心底层模型是 OpenAI 还是别的什么。只要是 Spring AI 支持的模型stream()的行为一致你换模型不用改这行代码。二、光吐字不够流式接口有四个必须处理的边角很多人以为流式就是把.call()换成.stream()就完了。V哥 告诉你真上线你会撞上四件事每一件都能让这个功能翻车第一件完整答案从哪来。打字机是一段一段吐给前端的但审计、计费、质量抽检要的是整段。你不能指望前端把碎片拼好了再回传给你——那不可靠。正确做法是服务端在吐的同时自己把碎片拼一份流结束的时候归档。第二件迟迟不来第一段怎么办。模型那边限流了、网络抖了Flux可能一直没元素。前端转圈事小连接占着不释放事大。要给它加一个静默超时。第三件半路断了怎么收场。模型生成到一半报错如果你不做处理SSE 连接会直接断用户看到一半字停在那里比没字更难受。要把异常翻译成一句人话塞进流里再正常收尾。第四件用户关页面了后端知道吗。这是最容易被忽略、也最费钱的一点。用户看到一半关掉页面如果后端收不到取消信号模型还在为一份没人看的答案继续吐 token钱照花。Flux的取消信号必须被接住。把这四件事写进一个 Service长这样ServicepublicclassStreamingChatService{privatestaticfinalStringSYSTEM_PROMPT 你是智能写作助手回答要口语化一段话控制在三句以内方便在打字机效果里阅读。 ;privatestaticfinalDurationSILENCE_TIMEOUTDuration.ofSeconds(20);privatefinalChatClientchatClient;privatefinalAnswerArchivearchive;privatefinalStreamMetricsmetrics;publicStreamingChatService(ChatModelchatModel,AnswerArchivearchive,StreamMetricsmetrics){this.chatClientChatClient.builder(chatModel).build();this.archivearchive;this.metricsmetrics;}publicFluxStringstreamAnswer(Stringquestion){StringBuilderfullnewStringBuilder();returnchatClient.prompt().system(SYSTEM_PROMPT).user(question).stream().content().doOnNext(chunk-{metrics.onChunk();full.append(chunk);}).timeout(SILENCE_TIMEOUT).doOnCancel(metrics::onCancel).doOnComplete(()-archive.save(question,full.toString())).doOnError(e-metrics.onError()).onErrorResume(ex-Flux.just([生成中断friendlyMessage(ex)]));}publicStringanswerAll(Stringquestion){returnstreamAnswer(question).reduce(String::concat).block();}privatestaticStringfriendlyMessage(Throwableex){Stringnameex.getClass().getSimpleName();returnswitch(name){caseTimeoutException-等待模型响应超时请重试;caseResourceAccessException-连接模型服务失败;default-后端生成异常;};}}几个 Reactor 算子逐个说清楚它们为什么必须在这里doOnNext每来一段就拼进StringBuilder。这个变量是闭包捕获的每个订阅者一份流结束的时候就是完整答案timeout(20s)两段之间的静默超过 20 秒就往下游发TimeoutException。注意它测的是间隔不是总时长——打字机本来就是慢慢吐的只要一直在动就不该超时doOnComplete正常吐完把完整答案归档。这是完整答案从哪来的答案doOnCancel前端断开时触发这里只做了计数。真实项目里这个信号应该再往上游传让模型真正停下来——很多模型 SDK 的stream()都支持把取消传到网络层那才是真省钱onErrorResume把任何异常都翻译成一句[生成中断...]塞回流里正常收尾。前端拿到这句就知道不是网络断了是后端出了问题展示体验完全不一样。AnswerArchive和StreamMetrics是配套的两个小类。归档用内存Map示意生产换 MySQL指标就是三个计数器吐了多少段、取消几次、失败几次。这三个数是你判断流式接口健不健康的直接依据——取消率居高不下说明要么太慢要么用户根本不需要那么长的答案。三、前端怎么接两种姿势各有用武之地前端侧最简单的是用浏览器原生的EventSourceconstesnewEventSource(/api/stream/ask-get?questionencodeURIComponent(question));es.onmessage(e){textArea.valuee.data;// 一段一段往上拼};es.onerror()es.close();不过EventSource只支持 GET参数只能放 URL 里长问题不合适。生产里 V哥 更推荐用fetch读流能带 POST body也能精确控制取消constcontrollernewAbortController();constrespawaitfetch(/api/stream/ask,{method:POST,headers:{Content-Type:application/json},body:JSON.stringify({question}),signal:controller.signal,// 用户点停止就触发后端的 doOnCancel});constreaderresp.body.getReader();constdecodernewTextDecoder();while(true){const{done,value}awaitreader.read();if(done)break;textArea.valuedecoder.decode(value,{stream:true});}AbortController这条线要重点画出来前端停止按钮调controller.abort()请求断开后端Flux收到取消doOnCancel被触发——这才是取消链路真正闭环的地方。少了这一步你做的取消只是一个前端动画后端该怎么烧钱还怎么烧。四、后端怎么返回Flux 直返 vs SseEmitterController 层Spring Boot 给了两条路RestControllerRequestMapping(/api/stream)publicclassStreamAskController{privatefinalStreamingChatServiceservice;publicStreamAskController(StreamingChatServiceservice){this.serviceservice;}// 路线一直接把 Flux 返回出去框架自动按 text/event-stream 处理推荐PostMapping(value/ask,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicFluxStringask(ValidRequestBodyStreamAskRequestrequest){returnservice.streamAnswer(request.question());}// 路线二SseEmitter适合还要手工塞心跳、自定义事件名的场景PostMapping(/ask-emitter)publicSseEmitteraskEmitter(ValidRequestBodyStreamAskRequestrequest){varemitternewSseEmitter(30_000L);service.streamAnswer(request.question()).subscribe(chunk-{try{emitter.send(chunk);}catch(IOExceptionex){emitter.completeWithError(ex);}},emitter::completeWithError,emitter::complete);returnemitter;}}两条路的取舍很清晰Flux 直返代码最省框架帮你把响应式流桥接到 HTTP取消信号天然透传。日常做打字机V哥 默认用这条SseEmitter传统的 Servlet 异步模型好处是你可以完全掌控发什么。比如每隔 15 秒手工emitter.send(SseEmitter.event().comment())发个心跳防止网关把空闲连接掐断或者用event().name(meta)发自定义事件把预计还要 10 秒这种提示单独推给前端。入参还是老规矩空问题直接 400publicrecordStreamAskRequest(NotBlank(message问题不能为空)Stringquestion){}五、离线环境怎么验证这条链流式接口的难点在时间和信号这两样用桩都能模拟。桩只要覆写stream(Prompt)把一段固定答案切成几片往外吐publicclassStreamingStubChatModelextendsStubChatModel{privatefinalListStringchunks;publicStreamingStubChatModel(ListStringchunks){super(String.join(,chunks));this.chunkschunks;}OverridepublicFluxChatResponsestream(Promptprompt){returnFlux.fromIterable(chunks).map(text-ChatResponse.builder().generations(List.of(newGeneration(newAssistantMessage(text)))).build());}}再配一个故障桩stream()直接Flux.error(...)用来验证异常翻译publicclassFailingStreamStubChatModelextendsStubChatModel{privatefinalRuntimeExceptionfailure;publicFailingStreamStubChatModel(RuntimeExceptionfailure){super(不可用);this.failurefailure;}OverridepublicFluxChatResponsestream(Promptprompt){returnFlux.error(failure);}}有了这两个桩就能离线断言下面这些关键点片段按顺序到达、拼起来是完整答案、正常结束后归档完整答案、取消时归档保持旧值半截答案不该落库、异常变成[生成中断...]而不是断流、answerAll()返回整段、HTTP 端点返回text/event-stream且内容里带生成的文本、空问题返回 400。这些覆盖的是流式接口的工程正确性跟模型聪明不聪明无关而前者才是上线前必须锁死的部分。尤其取消时归档保持旧值这一条V哥 每次都会单独写一个测试流式答案必须在完整到达后才落库中途取消绝不能把半截答案存进去否则下一次质检、审计拿到的就是一份残废数据。六、几个上线必踩的坑现象原因处理前端收到的是一整坨不是打字机中间有代理/网关Nginx开了缓冲关掉proxy_buffering或给响应头加X-Accel-Buffering: no用户关页面后 token 还在烧取消信号没传到模型层从doOnCancel一路把取消传进 SDK 的流并加取消计数监控半路断了用户看到半截异常没翻译就直接断流onErrorResume塞一句人话正常收尾首字等了 8 秒模型冷启动 / 首 token 慢监控首字延迟必要时上缓存或更快的档位呼应第 03 篇路由完整答案归档重复业务层又 collect 了一遍归档只做一次统一在 Service 里doOnComplete完成关于第一条单独多说一句流式上线后第一件事不是看正确率是看首字延迟和取消率两个指标。首字延迟决定了用户会不会关页面取消率决定了你在为一个没人看的答案烧多少钱。这两条曲线稳了再谈生成质量。最后一句流式输出的本质不是让模型吐得更快而是让用户每一秒都拿到反馈——把.call()换成.stream()只是第一行代码真正值钱的是那四件边角事完整答案要落库、静默要超时、异常要说人话、取消要能收到把这四件做扎实打字机才不是一个花架子。下一篇10带你把看字升级成看图让模型同时读图片和文字做商品图审核和发票识别。