
你有没有试过打开一个AI对话页面输入问题后盯着那枚转圈的光标干等十几秒然后屏幕突然啪地一下弹出一整段密密麻麻的答案我在最早做AI聊天应用的时候就是这么干的——前端发个请求后端去调大模型等整段回复生成完再一起返回。结果用户一句话就把我点醒了为什么它不能一边想一边说这就是流式输出Streaming Output要解决的问题让AI像水管里持续流水一样把生成结果从小的出口不断吐出来。用户从第一个字、第一个词开始就能看到内容而不是憋到最后一次性倒完。标题里AI的嘴巴装了水管说的就是这件事。这篇文章是系列的第一篇上我会从底层原理讲到Spring AI后端接入再到Vue前端的边说边听体验最后还会分享一个流式输出里特别容易踩的坑——markdown标签返回未完整。如果你正准备做AI应用开发、聊天机器人或者已经在接大模型API但每次都要等到天荒地老这篇应该能帮你少走不少弯路。1. 为什么宁可当水管工也不愿等AI把话说完1.1 等待的十几秒里用户已经跑了先算一笔账。普通的大模型接口调用模型把完整回复生成完再一次性返回通常需要5到15秒甚至更久。在Web场景里超过3秒的等待就会让用户明显感知到卡超过5秒就开始怀疑页面是不是挂了。我在实际项目里观察过用户行为一旦请求超过5秒没有反馈相当比例的用户会刷新页面或者重复点击发送按钮导致重复请求。这不是用户没耐心而是整个互联网产品的交互习惯已经把用户训练成了这样——任何没有反馈的状态都会被视为异常。流式输出把这个问题直接绕开了用户输入完问题一秒钟内就看到第一个字开始蹦等待焦虑一下子就被它在动的反馈覆盖掉了。哪怕最终总耗时还是那么长用户的心理体验也完全不同。这就是流式输出最直接的价值它不改变模型的速度但改变了用户对速度的感知。1.2 流式输出和普通输出的本质区别用一个生活化的类比你就懂了。普通输出像去餐厅点了一桌菜后厨把所有菜全部做完一次性端上来。菜多的时候你坐在那干等第一道菜可能早就好了但你就是吃不到。流式输出像边做边上菜后厨每做出一道菜就端一道你马上就能动筷子边吃边等后面的菜。AI场景里模型生成第一句话用了2秒生成完整回复用了15秒。普通输出模式下用户得等15秒才能看到那第一句话流式输出模式下用户2秒就看到开头剩下13秒是看着内容一点点变多。技术上普通输出是一个完整的HTTP响应响应体里是拼好的全文。流式输出则是服务器把HTTP响应体拆成很多个数据块一个接一个推给客户端。每个数据块可能是一个词、一句话、一段代码由模型生成到哪就发到哪。1.3 流式输出解决的不只是快慢问题除了感知上的快流式输出还打开了一些以前做不到的能力。第一可以打断。AI写代码写跑偏了用户在它生成到一半时就能看出来点一个停止生成立刻让它闭嘴。这在普通输出模式下做不到你只能等它全部写完或者干脆掐断整个连接但已经消耗的算力就浪费了。第二可以做过程展示。现在很多AI Agent应用比如研究助手、深度分析工具会把正在搜索某网页正在阅读某篇文档正在总结某段内容这类过程信息流式推给用户。用户看到的是一个正在工作的实况而不是一个空洞的转圈。这对复杂任务的信任度提升非常明显。第三为长输出场景兜底。如果模型要生成几千字的报告普通输出很可能触发网关超时或者用户等不到就离开了。流式输出只要有内容在持续流动连接就一直活着用户也不会放弃。可以说流式输出是AI应用从能用走向好用的关键一步。理解了它为什么重要接下来就看它到底是怎么实现的了。2. 流式输出不是打字机特效底层原理比你想的简单2.1 SSE、WebSocket、轮询三兄弟怎么选实现流式输出绕不开三个名词SSE、WebSocket、轮询。很多人一听就头大其实它们的区别非常清楚。SSEServer-Sent Events服务器向客户端单向推送消息的技术。基于普通的HTTP协议客户端连接上之后服务器可以持续把一个一个事件推过来。浏览器原生支持不需要额外库。WebSocket客户端和服务器之间的全双工连接两边都能随时给对方发消息。它需要先通过HTTP完成握手然后升级到ws/wss协议和HTTP不是一个路子。轮询Polling客户端每隔一段时间主动发一次请求问服务器有新数据了吗。有就是有没有就是没有简单粗暴但浪费大量无效请求。我用一张表给它们做个对比维度SSEWebSocket轮询通信方向服务端到客户端单向双向客户端拉取底层协议HTTP/HTTPSws/wssHTTP/HTTPS浏览器原生支持有EventSource有WebSocket有fetch自动重连内置需要自己实现没有服务端实现难度低中最低典型场景消息推送、AI流式回复在线协同、实时游戏兼容老旧系统2.2 SSE的数据长什么样很多人觉得SSE很神秘其实它就是一个格式有要求的HTTP流。响应头的Content-Type是text/event-stream响应体的内容按固定格式组织每条消息以data:开头消息之间用空行隔开。一个典型的SSE响应长这样data: {choices: [{delta: {content: 你好}}]} data: {choices: [{delta: {content: 欢迎}}]} data: {choices: [{delta: {content: 使用AI助手}}]} data: [DONE]大模型厂商的API之所以能和流式输出无缝衔接就是因为他们普遍采用了这种协议。模型每生成一小段内容就封装成一条data消息推出来最后推一个[DONE]表示结束。在流式输出这件事上业界已经事实上形成了一个统一标准OpenAI兼容的流式接口格式。不管你用的是哪家模型只要走这个协议代码都长得差不多。2.3 为什么大模型聊天场景大多选SSE答案不复杂聊天场景里数据的流向是单向的。用户发完问题之后接下来的十几秒里都是模型在单向输出用户不需要往服务器推送什么。这正好是SSE的舒适区。如果强行用WebSocket等于为了送一桶水专门修了一条双向高速公路技术上可行但完全没有必要。SSE还有两个非常实用的小优势。一是它跑在HTTP上企业网关、负载均衡、防火墙对它都很友好不用专门为ws协议开洞。二是SSE自带重连机制连接意外断开后EventSource会自动尝试重连省了前端不少事。因此在实现AI聊天流式输出时SSE是性价比最高的方案。下面我就按SSE这条路把后端和前端分别讲透。3. 后端从憋大招到边想边说Spring AI 流式接口落地3.1 先确认你接的大模型API支持流式吗动手写代码之前建议先确认一件事你对接的模型服务到底支不支持流式输出。目前市面上主流的大模型服务OpenAI兼容接口几乎都支持stream参数。你把请求体里的stream设为true接口就会把响应体从完整的JSON改成SSE流。各家云厂商的托管模型、开源模型部署框架也基本都兼容这套协议。如果你用的是Spring AI框架它已经把这些差异封装掉了。spring-ai-openai-spring-boot-starter这类依赖引入之后流式调用只是换一个方法的问题。如果用的是Spring AI Alibaba则可以更方便地接上百炼等国内模型服务底层同样是SSE。这里我要多说一句流式输出的思想不只是AI接口在用。很多人搜索jdbc查询流式输出本质上是同一个问题——JDBC默认会把查询结果全部加载到内存再返回而流式查询用fetchSize控制每次从数据库取一批边查边用避免几十万行数据把内存撑爆。理解了大模型流式输出你再看数据库流式查询会发现它们是同一个套路把一次性拿全量结果改成边生产边消费。3.2 Spring AI 的流式调用从返回String到返回FluxSpring AI最核心的改变是返回值类型。非流式调用返回的是String流式调用返回的是FluxString。Flux是响应式流里的概念你可以把它理解成一个异步的、可以持续吐出多个元素的管道。调用者把它当作一个Observable订阅每来一个元素就处理一个元素。具体到代码Controller层大概长这样RestController RequestMapping(/api/chat) public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder.build(); } PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestBody ChatRequest request) { return chatClient.prompt() .user(request.prompt()) .stream() .content(); } }关键点就两个一是接口的produces要声明成TEXT_EVENT_STREAM_VALUE让Spring知道你要返回一个SSE流二是返回值类型是FluxStringSpring WebFlux会帮你把Flux里的每个元素序列化成SSE消息推给前端。如果你之前用的是Spring MVC而不是WebFlux也别担心。Controller返回Flux并不要求整个项目都切到WebFlux只要引入了webflux相关依赖Spring Boot能够处理返回Flux的接口。3.3 不用Spring AI时自己用WebClient拉流有的项目没有引入Spring AI只靠HTTP Client调大模型接口也能实现流式输出。思路是用WebClient请求大模型的SSE接口然后把响应体当成ServerSentEvent流处理WebClient webClient WebClient.builder() .baseUrl(https://api.example.com/v1) .defaultHeader(Authorization, Bearer apiKey) .build(); FluxString contentFlux webClient.post() .uri(/chat/completions) .bodyValue(Map.of( model, gpt-4o-mini, messages, List.of(Map.of(role, user, content, prompt)), stream, true )) .retrieve() .bodyToFlux(ServerSentEvent.class) .map(event - { Object data event.data(); // 解析data提取delta.content return extractContent(data); }) .takeUntil(data - [DONE].equals(data));bodyToFlux(ServerSentEvent.class)是WebClient专门用来解析SSE流的方法。每来一条data消息就封装成一个ServerSentEvent对象我们再把里面的内容字段取出来转发给上层。这里有个坑要注意takeUntil只能根据元素内容决定是否结束如果你在解析[DONE]之前就把数据转换成了纯文本一定要在转换逻辑里保留一个结束标记否则流不会自动停。3.4 返回给前端的接口设计裸Flux还是自定义SSE后端返回FluxString最省事每个字符串就是一段增量内容。但在生产环境里我建议定义一个统一的消息结构比如一个StreamEvent对象里面带上type字段delta表示增量内容status表示状态变化error表示错误信息done表示流结束。这样设计的好处是前端不用靠猜。比如模型在中途触发了内容审核或者服务端超时了你可以推一个error事件过去而不是让前端收到一个不完整的流然后傻等。如果你只需要能跑通的Demo返回Flux 就够了如果你要做一个能上线的产品建议在协议层面多花半小时设计一下。后端这块儿搞定了前端怎么把水管接到用户眼前是下一个关键问题。4. 前端Vue聊天框里边说边听SSE接入与状态管理4.1 新手最容易踩的坑EventSource不支持POST打开浏览器查SSE所有文档都会告诉你用EventSource。代码就三行const es new EventSource(/api/chat/stream?promptxxx); es.onmessage (event) console.log(event.data);是真的简单但它有一个致命限制EventSource只支持GET请求。AI聊天场景的请求几乎都是POST因为要携带很长的prompt、历史消息、各种参数。把prompt塞进URL的query里一方面长度容易超限另一方面内容会留在访问日志里既不安全也不优雅。所以我实际做项目时从来不用EventSource接AI聊天而是用fetchreadable stream手动读取SSE流。这多写一点代码但换来了完整的请求控制能力而且可以通过AbortController随时中断。4.2 用fetch处理POST式SSE前端接入的核心逻辑是这样的发起POST请求拿到响应体之后把它当做一个流来逐段读取再按SSE格式切分成一条条消息。async function streamChat(prompt: string, onChunk: (text: string) void) { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt }), }); if (!response.ok || !response.body) { throw new Error(请求失败); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop()!; for (const line of lines) { const trimmed line.trim(); if (!trimmed.startsWith(data:)) continue; const data trimmed.replace(/^data:\s*/, ).trim(); if (data [DONE]) return; try { const json JSON.parse(data); const content json.choices?.[0]?.delta?.content ?? ; if (content) onChunk(content); } catch (e) { // 某些情况下data可能不是完整JSON先忽略 } } } }这里有两个细节非常关键。第一TextDecoder必须加{ stream: true }。不加的话一个中文字符被拆在两个数据块里解码出来就是乱码。流式场景里UTF-8的多字节字符随时可能被切断不加这个参数几乎必现乱码。第二必须用一个buffer变量做拼接。因为SSE的消息是按空行分隔的而网络传输的数据块和消息边界并不对齐。这份代码把收到的内容先拼到buffer里再按换行符切分最后剩下一段不完整的内容留在buffer里等下一批数据。这种缓冲切分的模式是解析流式数据的基本功。4.3 Vue里让对话内容真正增量更新接口通了之后Vue这边处理起来比较简单但有个性能细节容易忽略。假设你用一个messages数组保存所有聊天记录。收到一个chunk时不要去给整个数组重新赋值只需要在当前最后一条消息的content字段上追加文本const currentMessage computed(() { const len messages.value.length; return len 0 ? messages.value[len - 1] : null; }); function handleChunk(text: string) { if (currentMessage.value) { currentMessage.value.content text; } }如果你把整个messages数组替换成新数组Vue会触发整棵列表的重新渲染消息一长就会明显卡顿。直接修改最后一条消息的content字段只更新一个文本节点性能要好得多。还有一个体验细节聊天窗口要跟随输入自动滚动。如果只是把内容往上堆用户看到一半屏幕就定格了根本感受不到边说边听。我一般会在每次content更新后判断一下如果用户当前视口接近底部就自动滚动到底部如果用户正在往上翻历史消息就不打扰他。watch(() currentMessage.value?.content, () { if (isNearBottom()) { scrollToBottom(); } });4.4 聊天状态机idle、streaming、done、error接入流式输出之后前端不能再用一个简单的loading布尔值来管理状态了。我建议用一个明确的状态机idle初始状态发送按钮可用streaming正在流式接收发送按钮禁用显示停止生成done本次流式接收正常结束恢复发送按钮error出现网络错误或服务端错误显示错误提示恢复发送按钮停止生成按钮对应的就是前面提到的AbortControllerconst controller new AbortController(); async function startChat() { controller.abort(); // 如果还在流式先取消上一次 ... } function stopChat() { controller.abort(); state.value done; }用户在AI回答到一半时点停止生成前端的fetch连接会断开后端收到连接断开信号后也会取消大模型调用。这个链条我在下一节会再展开讲因为后端这一侧的处理也很重要。5. 流式输出最坑的细节标签返回未完整怎么处理5.1 现象AI还没说完页面已经渲染乱了我猜不少人搜过标签返回未完整怎么处理这个词。我最初看到这个问题是在一次联调的时候AI回答里含有一个markdown代码块内容是一个完整的函数。非流式模式下最终文本是完整的markdown渲染器很容易处理。但流式模式下前端每收到几个字就把当前的文本扔给渲染器。生成到一半时文本长这样一个代码块被从中间切开了。渲染器拿到这个不完整的文本要么把剩下的内容当成普通段落显示要么整个布局都错乱。等流结束了内容倒是变正常了但用户在整个生成过程中看到的都是一堆乱糟糟的界面。5.2 根因流的切分边界是任意的为什么会出现这种问题因为流式输出的数据块不是按照完整的语法单元来切的。大模型是按Token逐字生成内容的API推送数据块的时候可能一次推半个词、半个标签。某个数据块结束的位置恰好落在markdown代码块的三个反引号中间或者落在**加粗**的中间这种概率其实非常高。也就是说前端收到每一个增量数据块时手里的文本都是一个随时可能被拦腰截断的不完整文本。如果每收到一块就立刻去做markdown渲染必然会渲染出半截标签。另外还要注意不只是markdown标签JSON增量也会有同样的问题。前置的流式解析里如果每收到一个数据块就尝试JSON.parse很容易得到unexpected end of input。两个问题的本质是一样的数据块边界不是内容边界。5.3 方案一渲染前做一次未闭合标签补全解决思路不是等流结束再渲染而是让渲染器每次拿到的都是语法上完整的文本。具体做法是原样保留收到的全部文本但传给markdown渲染器之前先做一次临时补全。最简单的情况是处理代码块function safeRender(text: string): string { const fenceCount (text.match(//g) || []).length; // 如果反引号数量是奇数说明代码块没闭合 if (fenceCount % 2 1) { return text \n; } return text; }原理很简单markdown里代码块的开启和闭合各需要一个所以正常文本里反引号对的数量应该是偶数。如果是奇数说明渲染到当前时刻时代码块还开着那就临时补一个闭合标记。这只是给渲染器看的补全不影响原始文本流结束后最终展示的还是真实内容。加粗、列表、引用这些也同理不过补全逻辑会复杂一些。实际项目里如果只是想让展示不崩优先把代码块这个最常见的场景处理好收益最大。5.4 方案二延迟渲染窗口另一个思路是晚渲染一小会儿收到的内容先进入缓冲区等积累了足够长的一段比如最近200个字符都没再出现不完整标签的迹象再把它合并进渲染文本。这种方案牺牲了一点点实时性但换来了界面稳定。如果你要在流式输出里渲染比较复杂的富文本比如带表格、图片的markdown我建议采用延迟渲染否则各种半截语法会轮番轰炸你。我个人的经验是把原始数据流rawContent和渲染数据流displayContent分开displayContent永远是由rawContent经过补全处理之后再生成的。这样即使补全逻辑出了问题也不会污染原始数据。5.5 不要忽略的另一个未完整连接层面的分块最后再提醒一个和标签未完整看似无关、实则同源的问题SSE解析时的分块完整性。我之前有个同事排查了半天发现前端收到的第一条JSON永远缺最后一个字符。后来发现是解析代码里没有做buffer拼接直接对每一次reader.read()的返回值单独解析了。一个JSON跨了两个网络数据块自然就断了。所以不管你处理的是SSE数据还是JSON增量一定要记住传输层的块边界不等于消息边界。先用buffer把没处理完的尾巴留住再按分隔符切分这是所有流式解析的通行做法。6. 别忘了兜底中断、重试、超时与并发控制6.1 用户点了停止生成之后后端要做什么前面提到了前端用AbortController取消fetch但后端的配合才是关键。当客户端连接断开时Spring WebFlux会感知到并触发对Flux的取消。如果你用的是Spring AI的流式调用取消信号会传递到大模型调用的底层WebClient会断开和模型服务之间的连接模型端的生成也会被终止。这一整套链路是自动的前提是你没有在代码里阻塞或者绕开响应式执行。如果你在流式处理链路上加了额外的资源比如数据库连接、远程服务调用一定要清理干净。用doFinally是个好习惯return chatClient.prompt() .user(prompt) .stream() .content() .doFinally(signal - { // 客户端断开或流结束时释放资源、打日志 log.info(stream finished, signal: {}, signal); });这样既能知道流是怎么结束的也能确保资源被释放。生产环境里宁可多打点日志也别让一个断开的连接背后的调用链白跑几十秒。6.2 断线、重连与超时本地部署尤其要注意流式连接更容易受网络波动影响。前端如果长时间没有收到任何数据块需要判断是模型在思考还是连接已经死了。标准做法是设置一个空闲超时比如10秒内没有收到任何内容就认为连接异常提示用户重试或自动重连。重连策略用指数退避1秒、2秒、4秒这样递增不要每秒钟无脑重试。如果你在跑本地部署的AI模型这个问题会更突出。本地模型受显存和推理速度限制首Token时间可能长达十几秒甚至更久前端如果没有足够的空闲容忍时间会把正常思考误判成超时中断结果就是模型还没开口连接已经被用户或前端掐了。处理办法是区分两种状态已收到首个Token之前等待时间可以放宽一些已开始输出之后相邻Token之间的间隔再单独设置超时。这样既不会误杀慢思考也不会让断掉的连接一直挂着。6.3 并发控制别让水管爆了流式输出看起来很省但底层调用大模型一样要按Token计费一样占GPU资源。如果每个对话请求都放开跑几个用户同时问复杂问题本地部署的模型显存马上就爆了。后端要做并发控制。最简单的方式是信号量限流private final Semaphore semaphore new Semaphore(4); public FluxString stream(String prompt) { if (!semaphore.tryAcquire()) { return Flux.error(new RuntimeException(当前请求过多请稍后再试)); } return chatClient.prompt() .user(prompt) .stream() .content() .doFinally(s - semaphore.release()); }限制到多少个并发要看模型规模和硬件本地量化模型可能只跑得动2到4个并发云端API可以把上限调高但也要防止被限流。另一个实用技巧是缓存短回复。如果用户问的是什么是SSE这类固定问题完全可以把结果缓存起来下次直接返回完整内容或者返回一个瞬时完成的流避免重复消耗模型算力。这个优化在高峰期非常管用。6.4 生产环境里建议关注的监控指标流式输出接入之后单纯看接口平均耗时就有一点误导了因为总耗时掩盖了很多信息。我更关注两个指标一是首Token时间Time to First Token简称TTFT用户看到第一个字需要多久。这个指标直接决定用户会不会觉得卡。二是Token间延迟Inter-Token Latency模型输出稳定后每两个Token之间的平均间隔。这个指标决定流式展示的流畅度如果一卡一卡的体验也会打折扣。日志里把这两个指标打出来如果TTFT突然变高多半是模型排队或推理卡顿如果Token间延迟忽高忽低则要检查网络和上游服务。有了这两个指标排查问题的时候心里就有谱了。从等它说完到边说边听核心不是某一段代码而是一整套思维方式的转变后端要把一次性响应拆成持续推送前端要把整段渲染改成增量更新协议层要处理好各种未完整的边界情况。我在真机环境里把整个流程跑通之后第一个感觉是界面终于活了。有一点必须提醒你流式输出不是简单把返回值从String改成Flux就完事前端、协议、异常处理都要配合调整。建议你从一个小接口开始先打通Spring AI的Flux再用fetch逐行读最后再加上中断和重连。等整套流程跑顺了你就再也不想回到那种憋大招式的接口了。