首页 / 资讯中心 / 文章详情

Spring AI流式输出实践:Flux与SSE让大模型问答告别等待

Spring AI流式输出实践:Flux与SSE让大模型问答告别等待 ★ FEATURED ARTICLE
前阵子我把一个基于大模型做问答的服务从同步返回整段答案改成了Spring AI 流式输出用户发来问题之后页面不再干等三秒转圈而是几百毫秒内就开始一个字一个字往外蹦答案整个交互体验完全不一样。这篇文章把改造背后的 Flux 与 SSE 原理、后端接口怎么写、前端打印字机效果怎么做、以及我在实际项目中踩过的坑一并整理出来供同样做接入的朋友参考。1. 为什么要做流式输出从干等三秒说起1.1 用户看到的不只是一段文字而是一种反馈做任何对话类产品最先要解决的其实不是模型回答得准不准而是用户愿不愿意等。我最早那版接口是最简单的同步调用前端把问题发到后端后端完整调用一次大模型等模型把整段内容生成完再包装成 JSON 一次返回。这个过程的耗时通常在 2 到 5 秒如果模型比较大或者参数调高了10 秒以上也不是没遇到过。问题是用户在这段时间里看不到任何进展只能盯着一个转圈的 loading 图标心里完全没底。后来我把答案改成流式返回前端在模型刚吐出第一个字的时候就能显示出来后面的内容陆续追加整体交互变成看着它在回答你。这个变化看起来只是技术实现方式的不同但用户的感知差异非常大。同样的总耗时流式输出的主观等待感比同步输出低得多因为它把等待变成了阅读用户的注意力被不断出现的文字持续占据。这个道理和视频加载一样线性播放的缓冲总是比干等一个进度条让人舒服得多。1.2 同步接口的问题不只是慢这么简单同步接口的问题不能简单概括为慢它还有一个隐藏瓶颈中间态被完全丢弃。大模型的生成过程本身是逐字逐词进行的服务端在生成第一个 token 的时候就已经有内容可以返回了但同步接口必须等全部生成完毕。这意味着有两段可以节省的时间被白白浪费了。第一段是首字延迟到完整生成的时间第二段是服务端把完整 JSON 序列化并通过网络传输到前端的时间。模型生成几百上千字可能需要几秒但生成第一个字往往只需要几百毫秒。同步接口把这几秒全部压在用户面前而流式接口让用户在第一秒内就看到内容开始出现。另外一个实际问题出现在代理层同步接口一次性返回一个很大的 JSON 包如果网络波动客户端要等整个包传输完毕才能解析中间一旦断开前面传输的数据全部作废。流式输出把一个大包拆成无数小片段每个片段互相独立就算中间某个片段断了之前显示的内容也已经留在用户屏幕上了。1.3 流式输出改变的是整个交互范式接入流式输出之后产品层面还能做很多额外的事情。比如阅读进度跟踪系统可以知道用户看到第几个字时跳出页面从而判断回答的吸引力又比如中途取消用户发现模型在往错误方向回答时可以立即停止输出避免浪费时间和 token 开销。这些能力在同步接口下都很难实现。所以回答不再干等三秒这句话背后不只是体验优化而是对话类应用从请求-响应模式转向边生成-边展示模式的必经一步。理解了这一点再去看 Flux 和 SSE 的选型就有明确的目标感了。2. 核心原理拆解SSE、Flux 与 Spring AI 的流式 API2.1 SSE 为什么适合 AI 对话SSE 全称 Server-Sent Events中文一般叫服务端推送事件。它是在 HTTP 协议之上实现的一种单向推送方案服务端可以持续向客户端发送文本数据客户端只要保持连接就能不断收到新消息。你可能第一反应会问WebSocket 不是更强吗确实WebSocket 是双向全双工通信但对话场景下数据流向其实很单一客户端发起一个请求服务端持续返回内容中途一般不需要客户端再主动发消息。SSE 相比 WebSocket 有几个非常贴合这个场景的优势。首先它是基于普通 HTTP 的不需要额外握手和升级协议更不用引入单独的 WebSocket 服务基础设施和现有网关都能直接透传调试也更简单。其次SSE 本身内置了断线重连机制EventSource 在连接断开之后会自动重连这个能力如果自己用 WebSocket 实现要处理心跳、重连、消息顺序恢复等一系列问题。再次SSE 的消息格式是文本流用起来直观服务端往响应流里写一段段data: 内容\n\n前端就能按事件逐个收。WebSocket 当然也有它的价值比如你要做的是双向实时交互用户端也在持续推消息给服务端那它更合适。但就大模型回答这个典型单向数据流场景而言SSE 是性价比最高的方案。2.2 Flux响应式流不是高级玩具Flux 是响应式编程框架中代表0 到 N 个异步元素序列的核心类型。你可以把 Flux 理解成一条管道数据不是一个一个装进 JSON 返回而是源源不断地从管道一头流进去订阅者在另一头按顺序接收到。它天然支持背压意思是生产速度远大于消费速度时订阅者能主动控制流速避免把下游冲垮。这一点在 AI 流式输出中很关键模型输出的速度是不可控的有时候一秒蹦出几十个字有时候卡住几秒没动静如果中间环节没有背压机制很容易导致内存缓冲堆积。Spring WebFlux 的控制器方法直接返回 Flux 时框架会把它当成响应式流来处理自动适配底层网络层不再需要传统 Servlet 那种阻塞式一个请求占一个线程的模式。配合 SSE具体做法就是返回 Flux 或 Flux Spring 会自动把每个元素编码成 SSE 格式写入响应。这也是为什么我用 WebFlux 来做这件事而不是传统 Spring MVC 加异步线程池手动写 SSE。手动方案不是不行但会有很多边界问题比如连接断开怎么通知线程停止生成、心跳谁负责发、大流量下线程池怎么设计而响应式流在框架层面已经把这些基础能力都做好了。2.3 Spring AI 的 stream() 到底做了什么Spring AI 在流式输出上提供的核心方法就是 ChatModel 接口里的 stream()它接收一个 Prompt返回的是一个 Flux 对象。当订阅发生时框架内部会发起对模型服务商 API 的流式 HTTP 请求服务商每生成一个 token框架就把它封装成 AiResponse通过 Flux 的 next 信号推送给订阅者生成结束整个 Flux 的 onComplete 信号被触发。如果你是第一次接触这套 API可以看成一个四层结构模型服务商底层返回 token 片段Spring AI 的适配器把它们包装成统一的对象Flux 负责承载这些对象的异步序列你的服务层负责转换成前端需要的字符串流。这种分层带来的好处是你在应用层完全不用关心底层用的是什么模型服务商只要 ChatModel 的实现支持 stream 能力你写的 Flux 处理逻辑就完全复用。有一个细节需要注意模型返回的 AiResponse 中不是每个元素都包含内容。最后一个 token 往往只表示生成结束并没有实际文字内容某些情况下中间段也可能因为内容过滤策略返回空文本。因此我在做转换时第一步就是过滤掉空内容避免前端收到一堆空事件。3. 后端实现从零搭一个可用的流式接口3.1 依赖引入与基础配置整个实现基于 Spring Boot WebFlux。项目里最核心的依赖有两个一个是 WebFlux 的 starter另一个是 Spring AI 的核心依赖。在 Maven 里Spring AI 的依赖一般要配合它的 BOM 来管理版本你可以按自己的需求在依赖管理里引入。dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version你的版本号/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-core/artifactId /dependency /dependencies基础配置上比较关键的几个参数是模型服务商地址、密钥、模型名称、采样温度和最大 token 数。这里建议全部通过环境变量注入不要写死在配置文件里。我给一个示范性配置spring: application: name: ai-streaming-demo ai: chat: base-url: ${MODEL_BASE_URL} api-key: ${MODEL_API_KEY} options: model: ${MODEL_NAME} temperature: 0.7 max-tokens: 2048你可能发现你的模型服务商有自己的配置前缀比如某些提供商会要求 base-url 写在特定节点下实际以官方文档为准。我这里要重点提醒的是base-url 不要以斜杠结尾很多适配器拼接 URL 时不会做额外处理多一个斜杠可能导致第一次请求就 404。3.2 服务层把模型输出变成连续的 Flux服务层要做的事情非常单一接收用户消息构造 Prompt调用 stream()然后把响应流里每个元素中的文本内容提取出来过滤掉空值变成 Flux 。下面是我项目里的一个简化版写法Service public class StreamChatService { private final ChatModel chatModel; public StreamChatService(ChatModel chatModel) { this.chatModel chatModel; } public FluxString streamChat(String userMessage) { Prompt prompt new Prompt(new UserMessage(userMessage)); return chatModel.stream(prompt) .flatMap(response - { Generation generation response.getResult(); if (generation null) { return Flux.empty(); } String content generation.getOutput().getContent(); if (content null || content.isBlank()) { return Flux.empty(); } return Flux.just(content); }); } }这里有个取舍直接调用 ChatModel 的 stream() 是最基础的方式但如果你用的是 Spring AI 提供的更高级的 ChatClient 工具类写法会更优雅。ChatClient 本身就支持 stream 方法同时还提供了调整参数、拼接历史上下文之类的链式 API适合业务逻辑比较复杂的项目。我的项目早期用底层的 ChatModel后来业务上需要在同一个请求里带系统提示词和上下文记忆就换成了 ChatClient代码量减少了不少。实际开发中你还要考虑一个问题模型生成的中间内容有时会包含格式标记比如 Markdown 标记。这些标记在流式输出时会被拆成多段传输前端直接拼接通常没问题但如果前端要做格式化渲染就需要等流结束后统一解析。这个属于前端层面的处理后面会详细说。3.3 控制层以 SSE 格式暴露给前端控制层是整个方案的核心枢纽。我让它接收 HttpServletRequest 参数这样前端可以选择 GET 或 POST 方式调用。返回类型是 FluxServerSentEvent Spring WebFlux 看到这个返回类型会自动按 SSE 协议编码。代码非常简单RestController RequestMapping(/api/ai) public class StreamChatController { private final StreamChatService chatService; public StreamChatController(StreamChatService chatService) { this.chatService chatService; } GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString stream(RequestParam(message) String message) { return chatService.streamChat(message) .map(content - ServerSentEvent.builder(content).build()); } }如果你希望前端用 POST 方式传递更复杂的请求体比如带历史消息数组可以把 RequestParam 换成 RequestBody返回类型保持不变。但一定记得在 PostMapping 里同样指定 produces。这是很容易漏掉的细节如果不指定 producesSpring 可能不会按 SSE 来处理流式响应前端拿到的是普通响应。另外有一点非常重要控制器里不要对 Flux 做阻塞式操作不要在 map 里调用 Thread.sleep 之类的阻塞方法因为 WebFlux 的线程模型是事件循环的阻塞会影响整个服务的吞吐。如果你确实需要控制输出速度可以使用 delayElements 这样的非阻塞方法return chatService.streamChat(message) .delayElements(Duration.ofMillis(30)) .map(content - ServerSentEvent.builder(content).build());这种做法实测下来有类似打字机的节流效果但要注意delayElements 相当于强制拉长了整个回答时间模型本来生成快的话会被人为变慢所以要谨慎使用。3.4 超时、背压和并发参数怎么调整流式接口长期挂着一个连接所以超时配置和普通接口不完全一样。如果整体响应时间设得太短模型生成稍慢就被掐断用户体验更差。我一般给流式接口设置一个较长的空闲超时同时配合全局的超时兜底。在配置里spring: webflux: static-path-pattern: /** server: netty: connection-timeout: 10000 reactive: session: timeout: 600s这里我把 Session 超时设为 600 秒是为了应对模型偶发长时间思考的场景。当然不是所有场景都适合这么长如果你的模型通常 30 秒内就能生成完成超时设置为 60 秒更合理避免异常连接占用资源过久。Flux 天然支持背压但使用不当反而会产生问题。比如前端消费速度跟不上模型生产速度时如果订阅端不做处理底层的 HttpClient 可能会继续拉取数据到内存造成一定量的堆积。针对这种情况可以在服务层加一个缓冲上限return chatModel.stream(prompt) .onBackpressureBuffer(200) .flatMap(...);onBackpressureBuffer 的第二个参数可以传入溢出时的报错策略。如果连接断开订阅被取消理论上会向上游传递取消信号但某些底层 HTTP 实现并不会立刻中断请求需要配合超时机制做兜底。我在实际调试中还看到过一种情况前端正常断开后后端还在继续调用模型生成了好一会儿才感知到原因就是底层连接缓冲没有被立即刷新。为了快速感知取消可以在流上挂一个 doOnCancel 打日志return chatService.streamChat(message) .doOnCancel(() - log.info(client disconnected, cancel stream)) .map(content - ServerSentEvent.builder(content).build());这行日志能帮你在排查为什么 token 消耗这么多时定位到问题。4. 前端对接打字机效果的三种实现方式4.1 五分钟用 EventSource 做出打字机前端最简单的做法是使用浏览器的 EventSource 接口。它专门用于接收 SSE 推送会自动处理连接、重连、事件解析等逻辑。后端 GET 接口返回 Flux 后EventSource 就能直接接上。我贴一个最基础的使用代码你在浏览器里把这段代码放在 HTML 页面里就能看到效果。script const output document.getElementById(output); const eventSource new EventSource(/api/ai/stream?message${encodeURIComponent(你好介绍一下你自己)}); eventSource.onmessage (event) { output.textContent event.data; }; eventSource.onerror () { eventSource.close(); }; /script这段代码的问题是打字机的颗粒度比较粗。每个 SSE 事件里可能包含一整段文本比如模型一口气生成了十几个 token后端打包成一个事件发过来前端一次性 append视觉上还是有蹦出来一截的感觉。不过我实测下来模型 API 的流式返回在小模型上确实可能一次带回几十个 token而大模型则更像逐 token 推送不同服务商差异很大。如果你需要更平滑的逐字效果可以自己加一个前端打字队列每次从接收缓冲区里取一个字符显示剩余字符延后处理。let buffer ; let timer null; function onData(chunk) { buffer chunk; if (!timer) { startTyping(); } } function startTyping() { timer setInterval(() { if (buffer.length 0) { output.textContent buffer.charAt(0); buffer buffer.slice(1); } else { clearInterval(timer); timer null; } }, 30); }这样视觉上的确形成了逐个字符跳出来的效果但也带来了新问题如果模型输出速度本来就快30 毫秒一个字的速度会让用户觉得输出节奏偏慢。所以更好的做法是让前端打字机速度自适应收到数据时先全量显示但在不同段落之间做短暂延迟。我后面会再展开讲这个优化思路。4.2 用 fetch 流式读取解决 POST 与鉴权问题EventSource 虽然简单但它有一个很明显的局限它只能用 GET 请求发起连接而且不支持自定义请求头。如果你的接口需要 POST 传递复杂参数或者需要在请求头里带上鉴权 tokenEventSource 就尴尬了。解决方案是改用 fetch 配合 ReadableStream 手写 SSE 解析。浏览器 fetch 拿到响应后response.body 是一个 ReadableStream可以使用 Web Stream API 逐步读取。后端发来的内容是标准 SSE 格式每行以 data: 开头一个事件以空行结束。手动解析的方式大致如下async function fetchStream(url, body) { const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer your-token }, body: JSON.stringify(body) }); if (!response.ok) { throw new Error(HTTP ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); let sepIndex; while ((sepIndex buffer.indexOf(\n)) ! -1) { const line buffer.slice(0, sepIndex).trim(); buffer buffer.slice(sepIndex 1); if (line.startsWith(data:)) { const data line.slice(5).trim(); if (data) { handleData(data); } } } } }这段代码里最容易踩的坑是 TextDecoder 的 stream 参数不能漏。如果不加 { stream: true }一次 decode 调用会默认按完整串处理遇到多字节字符被拆到两个 chunk 里的情况就会产生乱码。加上这个参数后解码器内部会缓存不完整的字节序列等到下一个 chunk 来再补全。这个问题在英文环境下几乎不会暴露但中文和表情符号环境下非常明显。4.3 中断、重连与状态管理流式输出一旦接上前端就要面对中断重连、状态清理、请求取消等一系列问题。EventSource 内置了重连机制但手动用 fetch 时全得自己写。中断处理的关键动作是用户手动停止回答时调用 AbortController 来取消请求。这种取消能力在 UI 上很重要因为模型经常会把答案越写越长用户如果觉得方向不对就不想再等了。实现方式是在发起请求前创建一个 AbortController把它的 signal 传给 fetch用户点击停止生成时调用 controller.abort()。服务端 WebFlux 会检测到客户端连接断开触发我们之前在服务端挂的 doOnCancel上下文就释放了。let controller null; function stopGeneration() { if (controller) { controller.abort(); controller null; } } function startGeneration() { controller new AbortController(); fetch(/api/ai/stream, { method: POST, signal: controller.signal, // ... }); }状态管理上我的经验是把已接收内容放在一个变量里追加显示和最终提交分开。不要在每次 onData 时直接读 DOM 的 textContent 来追加因为 DOM 读写比内存变量读写慢得多而且如果过程中有格式化处理会产生累计偏移。正确做法是维护一个 replyText 变量每次 onData 时它 data然后把 replyText 一次性写入界面渲染。这样也方便在流结束后对整段文本做 Markdown 渲染或者字数统计。5. 常见问题与排查实录一站式排雷手册5.1 流式响应被网关截胡了我在某次内部项目联调时遇到过一个问题后端接口用 curl 测的时候一切正常SSE 数据一段一段往外冒结果前端页面里始终不显示等一分钟之后整段文字突然全部出现。排查到最后发现是前端和网关之间的一层代理做了响应缓冲代理把后端推来的 SSE 片段攒在缓冲区里直到攒够一定字节数或者连接关闭才一次性转发给浏览器。这个问题非常经典如果你的系统里有 Nginx 之类的反向代理并且代理配置了缓冲SSE 基本都会被截胡。解决方案是关闭该路径的 proxy_buffering同时设置 X-Accel-Buffering 响应头让代理明确不缓冲。更通用的做法是在后端给每个 SSE 响应都加一个响应头return chatService.streamChat(message) .map(content - ServerSentEvent.builder(content) .comment(no buffer) .build());但 X-Accel-Buffering 头在响应元数据中设置更标准一些GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString stream(RequestParam(message) String message) { return chatService.streamChat(message) .map(content - ServerSentEvent.builder(content).build()); }如果你用的是 Spring WebFlux可以写一个 WebFilter 给所有 SSE 响应统一加头Component public class SseHeaderFilter implements WebFilter { Override public MonoVoid filter(ServerWebExchange exchange, WebFilterChain chain) { ServerHttpResponse response exchange.getResponse(); response.getHeaders().add(X-Accel-Buffering, no); response.getHeaders().add(Cache-Control, no-cache); return chain.filter(exchange); } }这个 Filter 是我后来加入的它同时解决另一个问题某些浏览器和中间层会对 SSE 做缓存导致第一个事件反复出现或者到达时间异常。加上 Cache-Control: no-cache 之后就稳定了。5.2 中文乱码与半字问题中文乱码是最让我头疼的一个问题花了大半天才定位。现象是这样的前端接收到的内容中大概有 5% 的概率在文字中间夹杂一个奇怪的字符比如你好变成了好更严重的时候整段文字从某个字开始全是乱码。排查后确认是字符编码在多字节边界上的拆包问题。SSE 是文本流协议但它底层传输的始终是字节。中文字符在 UTF-8 编码下通常是 3 个字节网络数据包划分不一定恰好按字符边界切分。后端在某个 chunk 里把一个中文字符的后两个字节和下一个中文字符的第一个字节拼接在一起如果解析时没有正确缓存不完整字节序列就会把中间的半截字符强行解码成乱码。后端多数框架会正确处理这个问题但如果你在网关层重新打包了响应流或者前端像我一样手动解析 ReadableStream就容易踩中。前面 fetch 那段代码里我特意在 TextDecoder 加了 { stream: true }就是干这个用的。此外还要保证整个链路从上到下都是 UTF-8 编码。Nginx 默认不会改编码但如果你在 Nginx 层用了一些第三方模块处理响应体比如 sub_filter 之类的一定要确认它有办法正确处理流式内容否则可能会破坏字节完整性。5.3 前端一次性出整段没有流式效果还有一种更隐蔽的情况后端确实以流式方式推送数据前端也收到了分片但因为某些处理让渲染变成了一次性。最常见的两个原因一个是前端把每个分片存进了数组等整个 Flux 完成后才一次性 setState另一个是 HTML 里有大段内容在等待格式化之后再显示。关于第二个原因多说两句。很多业务场景里模型返回的是 Markdown前端需要调用 Markdown 渲染器来生成 HTML。但 Markdown 解析通常要求完整的字符串流式过程中不断解析会产生大量重复计算而且表格、代码块这类结构在只解析了一半时会出现布局抖动。我的做法是流式过程中用纯文本模式追加显示保证用户实时看到内容流结束之后再把完整文本交给 Markdown 渲染器重新渲染整个内容区。这个方案兼顾了实时性和最终格式唯一需要注意的是从纯文本切到渲染结果时可能有一闪而过的样式变化可以通过在同一个容器上用 fade 过渡来弱化。5.4 流中断、超时与空响应流式连接长时间挂起、突然断开或返回空内容是我在生产环境观察到的另外三类高频问题。先说超时。模型 API 的调用链可能有多层超时Spring AI 的 RestClient 有自己的连接和读取超时操作系统 TCP 层也有超时前端也有自己设置的 timeout。如果某一层的超时时间设得太短模型偶尔思考时间长一点就会被掐断。我的建议是把所有断链类的超时都拉到比模型可接受延迟更长的水平宁可等待时间过长也不要让用户看到答案生成到一半戛然而止。再说空响应。有时前端一直等后端却什么数据都没返回直到事件流直接完成。这种情况多半是模型服务商因为某些原因直接返回了空内容或者 Spring AI 适配器在流式模式下没把部分无内容事件透传出来。排查方向就是先看服务端日志看有没有调用记录再看模型返回的原始响应是不是触发了内容过滤或参数问题。我在服务里加了一个空事件过滤之后前端对这种异常基本免疫了用户看到的是模型没有返回内容的友好提示而不是一片空白。中断和重连是另一件事。EventSource 会自动重连但 fetch 方案需要你自己管理。我建议在前端设置一个重试上限避免连接在短时间内无限重试导致服务端压力过大。登录态的请求如果中途断线再重连要重新获取 token这个逻辑也要一起考虑进去。6. 几点实践心得6.1 适合直接复用的项目结构这套方案跑过几个项目后我总结出一个比较顺手的分层方式。外层是 Controller只负责接收请求和返回 Flux中间是 Service负责调用 Spring AI并做文本过滤、历史上下文拼装、敏感词拦截这些业务逻辑最底层可以再抽一层 Adapter专门对接不同的模型服务商。这样做的好处是如果某一天你想从模型 A 切换到模型 B只需要改最底层的 Adapter 配置Controller 和 Service 完全不动。上下文管理这块如果只是一个单轮问题直接构造 UserMessage 就可以。但如果要支持多轮对话就得把历史消息按顺序拼进 Prompt。Spring AI 的 Message 体系里提供了 SystemMessage、UserMessage、AssistantMessage 三种类型按系统提示-用户-助手-用户的顺序构造消息列表即可。流式场景里助手的历史消息在你把最终文本拿到手之后拼接因为中间过程用户不会把未完成的回答当作上下文依据。6.2 几个让打字机体验更高级的小技巧最后分享几个从使用角度打磨出来的小技巧。第一个是速率自适应。直接逐字输出时如果模型生成很快用户会觉得像在快进如果生成很慢用户又会觉得卡顿。我采用的做法是维护目标展示速度和实际接收速度之间的差值当接收速度快时前端每 10 毫秒刷一个字当接收速度慢时前端就立刻把所有缓冲的字符刷出来。实现上就是不断比较当前时间和上次刷新时间按差值动态调整每次刷出的字符数。第二个技巧是回答开始前展示一个小的光标闪烁效果。当连接建立成功但模型还在准备第一个 token 时前端可以先显示一个光标提示正在输入避免用户以为是白屏。等第一个字符到达后光标转为普通输入光标。这个细节虽然简单但非常有效地降低了用户在第一秒内的焦虑。第三个技巧是对流式接口做限流和用户维度并发控制。大模型 API 按 token 计费流式接口如果被恶意刷连接数会导致费用飙升。我加了一个简单的拦截器每个用户同时最多只有一个流式连接如果重复发起就取消上一个。这个规则还要跟前端配合用户切换问题时停掉旧请求否则多个流会叠加输出。第四个技巧是注意模型参数对流式效果的影响。temperature 太高时模型生成内容的随机性大流式输出会有更多停顿和回退感top_p 等参数也会影响输出的稳定节奏。我在实际调参中发现对话机器人类场景下 temperature 设置在 0.5 到 0.7 之间流式输出的流畅感和内容稳定性比较平衡。当然具体值还是得根据你的业务场景反复试。这套 Spring AI 流式输出的改造方案目前在我这边已经稳定运行了快半年没再出现过用户干等三秒的抱怨。如果你正在做类似的大模型问答服务按着 Flux 加 SSE 这条路子走基本不会错。踩过编码的坑、网关的坑、超时的坑之后你就会发现真正的流式体验不只是技术上能通更要让用户在每一毫秒的界面反馈里都感觉到系统是活着的。
阅读完成 · 觉得有帮助?
咨询建站