资讯动态

SSE流式输出实战:AI对话打字机效果与前后端实现比较

发布时间:2026/9/9 10:30:40 来源:尧图企业网站定制
最近在给团队做 AI 对话功能的技术选型评审有个很有意思的现象聊到“打字机”式流式输出不少同事的第一反应都是“用 WebSocket 吧”但当我把方案拆开对比一轮之后大家最终都倒向了 SSE。这个结论不是我拍脑袋得的而是因为 AI 对话这个场景本身几乎就是为 SSE 量身定做的。这篇文章我就把完整的分析过程、踩坑记录和可复用的前后端代码一起整理出来给正在纠结协议选型的朋友一个参考。1. 先搞清楚一件事流式输出到底在解决什么问题很多人以为“打字机”效果只是个视觉噱头为了让页面看起来更酷。实际上在 AI 对话场景里流式输出直接关系到用户对“快”的感知更关系到交互的可靠性。大模型生成一段 500 字的回答如果采用传统的“等全部生成完再返回”用户可能需要盯着空白的聊天窗口等 5 到 10 秒甚至更久。这个等待时间在心理上会被无限放大用户会怀疑页面是不是卡死了、请求是不是失败了。而流式输出把这段时间拆成了“开始响应—逐字输出—完整结束”三个阶段用户看到第一个字出现可能只需要几百毫秒之后的内容持续跟进整个感知延迟被大幅压缩。从工程角度看流式输出还有一个隐藏优势它能及时释放资源。大模型接口的响应时间普遍较长如果所有请求都等到完全生成后才返回网关层和后端服务长时间占用的连接数会很高。流式返回让 HTTP 连接可以边生成边传输配合流式读取连接池的压力明显更小。那为什么实现流式输出的首选方案是 SSE先记住一个结论AI 对话的数据流是“服务端生成、客户端接收”的单向流业务上几乎不存在客户端向服务端连续发送消息的需求。这种“天然单向”的场景恰好是 SSE 的主场而不是 WebSocket 的。2. 主流方案对比SSE、WebSocket、轮询各自输在了哪里我在内部评审时做了一张对比表贴出来给大家一个直观感受对比维度短轮询PollingWebSocketSSEServer-Sent Events连接模型客户端反复发起 HTTP 请求一次握手全双工长连接一次握手服务端单方向推送通信方向请求-响应双方向自由收发仅服务端到客户端但可配合 GET 参数/独立请求协议复杂度极低较高需处理帧、心跳、重连策略低基于普通 HTTP自动重连需要自己实现需要自己实现浏览器内置自动重连防火墙/NAT 穿透无压力部分环境会拦截升级请求无压力就是普通 HTTP流式体验差受轮询间隔限制好但存在杀鸡用牛刀问题好事件驱动的即时推送服务端成本极高大量无效请求中等需维护长连接状态很低短事务 长响应流短轮询的问题不用多说你设置多短的间隔体验上限就在那里。做 1 秒轮询勉强能模拟“实时”但每秒钟每个用户都要带起一次完整的 HTTP 往返用户量一上来服务端直接被无效请求打爆。WebSocket 在体验上确实没得挑全双工、低延迟、双向推送但它为“双向”这个能力付出了高昂的复杂度代价。你需要在服务端维护连接状态、设计心跳保活机制、处理异常断连、考虑消息帧的粘包拆包还要面对某些企业防火墙对 Upgrade 请求不友好等环境兼容问题。在 AI 对话这个“只需要服务端往客户端推文本流”的场景里这些复杂度和成本都是不必要的。这就像你只是想在墙上挂一幅画却买了一整套电钻工具箱。SSE 走的是普通 HTTP 通道服务端把响应当成一段“永远不关闭的流”持续向下写数据客户端通过浏览器原生的 EventSource API 接收。它不需要额外的协议握手不会有粘包重连之类的心智负担浏览器甚至连断线自动重连都替你做完了。这么多优点集中在一个和 HTTP 完全兼容的机制上在 AI 对话场景里不做首选实在说不过去。3. 从协议细节看本质SSE 是“一行一行地说话”而不是“一次说完再闭嘴”要真正理解 SSE 为什么适合打字机效果得先看它传输的数据到底长什么样。SSE 的 Content-Type 是text/event-stream服务端返回的数据遵循一个非常简单的文本格式。每个事件块之间用空行分隔每个字段是“字段名: 值”的形式。最常见的字段有三个data:表示本次推送的数据内容可以是任意字符串event:表示事件类型默认是messageid:表示事件 ID用于断线重连时的断点续传retry:表示重连间隔单位是毫秒实际传输时大概是这个样子event: message data: {content:你,timestamp:1700000000000} event: message data: {content:好,timestamp:1700000001000} event: message data: {content:,timestamp:1700000002000} event: done data: [DONE]后端生成一个 token就把这个 token 塞进一段data:立刻 flush 给客户端。浏览器侧的 EventSource 一收到数据就触发onmessageJavaScript 把内容追加到页面上。这一个“生成—推送—渲染”的小循环不断滚动用户看到的就是逐字输出的打字机效果。关键点在于SSE 并不要求一定按“字”推送。大模型接口普遍是“按 token词元”生成的一个 token 可能是半个词、一个词也可能是一个标点。服务端收到上游的一个 token 就可以立即透传一次也可以攒两三个字再推一次。推送粒度越细打字效果越顺滑但网络开销也越高。实际项目里我一般会选择“收到 token 直接转发”利用大模型接口自身已经包装好的流式响应不需要额外做攒批。如果用的是国内某些封装好的模型接口它们可能本身就按固定长度分块返回这时候直接透传就好不需要二次加工。还有一个容易被忽略的细节HTTP/1.1 的分块传输编码chunked transfer encoding是 SSE 能落地的基础。服务端在响应头里不加Content-Length而是用Transfer-Encoding: chunked持续写数据这样连接就不会因为“不知道长度”而被迫结束。现代浏览器和服务器框架对 chunked 的支持非常成熟这为 SSE 的普及扫清了最大的障碍。4. 后端实现用 Spring Boot 搭一个 SSE 推送通道后端我用的是 Spring Boot 的SseEmitter这是 Spring 框架对 SSE 服务的原生封装使用成本极低。核心思路是接口收到前端请求后立刻返回一个SseEmitter对象同时把这个对象丢到一个线程池或异步任务里让模型接口的流式响应源源不断地写进 emitter。先看一个极简的、能跑通的代码基于 Spring Boot 3.2Java 17import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; RestController RequestMapping(/api/chat) public class ChatController { private final ExecutorService executor Executors.newCachedThreadPool(); GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(RequestParam String prompt) { // 设置超时时间30分钟-1L表示永不超时视业务情况而定 SseEmitter emitter new SseEmitter(30 * 60 * 1000L); executor.execute(() - { try { // 模拟按字/词逐步推送实际项目中替换为大模型流式接口的返回 String[] words simulateLLMResponse(prompt); for (String word : words) { // 这里构造的数据格式会被前端 EventSource 原样收到 MapString, String payload Map.of( content, word, timestamp, String.valueOf(System.currentTimeMillis()) ); emitter.send(SseEmitter.event() .name(message) .data(payload, MediaType.APPLICATION_JSON)); Thread.sleep(50); // 控制推送节奏方便观察效果 } // 发送结束标记前端可据此判断流结束 emitter.send(SseEmitter.event() .name(done) .data([DONE])); emitter.complete(); } catch (IOException | InterruptedException e) { emitter.completeWithError(e); } }); return emitter; } private String[] simulateLLMResponse(String prompt) { return new String[]{你, 好, , 我, 是, AI, 助, 手, 。}; } }这里有两个关键点需要注意produces MediaType.TEXT_EVENT_STREAM_VALUE这行决定了响应头的 Content-Type 是text/event-stream浏览器才能正确识别。emitter.send()内部会自动帮你把数据按 SSE 格式组装好包括data:前缀和空行分隔符你不需要手动拼接字符串。真实项目中simulateLLMResponse要换成实际的大模型 SDK 调用。以 OpenAI 兼容接口为例Java 里通常这样写// 伪代码具体 SDK 以实际引入的依赖为准 OpenAiClient client OpenAiClient.builder() .apiKey(your-api-key) .build(); client.chatCompletionStreaming(request) .doOnNext(chunk - { String delta chunk.getChoices().get(0).getDelta().getContent(); if (delta ! null) { emitter.send(SseEmitter.event() .name(message) .data(Map.of(content, delta), MediaType.APPLICATION_JSON)); } }) .doOnComplete(() - { emitter.send(SseEmitter.event().name(done).data([DONE])); emitter.complete(); }) .doOnError(emitter::completeWithError) .subscribe();把上游 SDK 的响应式流直接桥接到 SseEmitter 上中间不需要额外缓冲。这样整个链路就是“大模型 Token 生成 → 网络层推送 → 前端展示”延迟只有一次网络抖动的时间打字机效果会非常跟手。还有一个工程细节值得单独拎出来说SseEmitter 的超时设置。默认构造器的 30 秒超时对 AI 对话来说太短了长一点的回答很容易撑爆。我习惯设置成和上游模型接口的最大响应时间一致留出合理的余量。另外complete()和completeWithError()一定要在合适的时机被调用否则前端会一直处于“等待中”的状态连接也无法被释放。5. 前端接入Vue 3 组合式函数实现打字机渲染前端我用 Vue 3 的组合式 API 封装了一个useSSE函数把连接管理、消息分发、状态标记都收拢到一起。这样页面组件里只需要调用这个函数监听消息回调即可。先看最基础的 EventSource 用法const eventSource new EventSource(/api/chat/stream?prompt你好); eventSource.addEventListener(message, (event) { // 解析服务端推送的 JSON 数据 const data JSON.parse(event.data); appendToChat(data.content); }); eventSource.addEventListener(done, () { // 收到结束事件关闭连接 eventSource.close(); }); eventSource.onerror () { // 浏览器会自动重连这里可以处理 UI 上的错误提示 console.log(连接异常正在重连...); };单看这段代码EventSource 的用法其实已经很简单了。但真实项目中远不止这么点逻辑需要处理消息累积、需要区分正常关闭和异常重连、需要避免组件卸载后回调仍在执行。所以在 Vue 3 项目里我推荐把这段逻辑抽成组合式函数。下面这个封装版本是我在实际项目中打磨过的加了几个关键处理// useSSE.js import { ref, onUnmounted } from vue; export function useSSE({ url, onMessage, onDone, onError }) { const connected ref(false); const error ref(null); let eventSource null; let reconnectTimer null; const maxRetries 5; let retryCount 0; function connect() { eventSource new EventSource(url); eventSource.onopen () { connected.value true; error.value null; retryCount 0; }; eventSource.addEventListener(message, (e) { try { const data JSON.parse(e.data); onMessage(data); } catch (err) { console.error(解析 SSE 数据失败:, err, e.data); } }); eventSource.addEventListener(done, () { eventSource.close(); connected.value false; onDone onDone(); }); eventSource.onerror (err) { // 注意EventSource 遇到网络异常会自动重连这里主要做重试上限控制 connected.value false; retryCount 1; if (retryCount maxRetries) { eventSource.close(); error.value err; onError onError(err); } }; } function close() { if (eventSource) { eventSource.close(); eventSource null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer null; } connected.value false; } onUnmounted(close); return { connect, close, connected, error }; }页面组件里这样用script setup import { ref, onMounted } from vue; import { useSSE } from ./useSSE; const chatText ref(); const isStreaming ref(false); const { connect, close, connected, error } useSSE({ url: /api/chat/stream?prompt你好, onMessage: (data) { // 这里注意如果是走 AI 接口返回的 Markdown 文本 // 渲染时要区分“纯文本追加”还是“Markdown 实时渲染” chatText.value data.content; }, onDone: () { isStreaming.value false; }, onError: (err) { console.error(SSE 连接错误:, err); isStreaming.value false; }, }); onMounted(() { isStreaming.value true; connect(); }); /script template div classchat-message span v-textchatText/span span v-ifisStreaming classcursor▍/span /div /template这里有一件非常值得说道的事前端展示 AI 消息时尽量不要用 v-html 直接渲染content字符串。因为大模型输出的内容是动态生成的中间极有可能夹带 HTML 标签或异常内容直接 v-html 等于把 XSS 漏洞的大门敞开。正确做法是用 v-text 或 {{ }} 插值做纯文本渲染。如果你需要渲染 Markdown也应该用一个安全的 Markdown 解析库并且关闭原始的 HTML 渲染开关。再聊一下“打字机光标”的处理。很多刚接触这个需求的人会去搜“打字机效果 js 动画”然后引入一个逐字展示的类库。但在 AI 对话场景里这个做法反而画蛇添足服务端本来就是逐 token 推送的你只需要把收到的内容原样追加到文本节点前端继续显示一个闪烁的“▍”光标提示流式期间的状态就足够了。刻意去做“逐字显示动画”会造成内容堆积反而让消息越来越慢。所以正确的做法就是朴素数据到了就渲染渲染完光标自然就稳定停止。关键在于不要把它当“打字机动画问题”处理而要当作“数据流驱动 UI 更新”来处理。数据流的节奏由服务端控制前端 UI 只负责被动响应。6. 那些坑我是一步步踩过来的SSE 本身机制不复杂但落地到生产环境会有一堆“非协议因素”捣乱。我把自己和团队趟过的坑列在下面每一条都值得你在投产后提前设防。6.1 Nginx 缓冲导致“一整块”输出这是 SSE 接入生产环境后最常见的坑。Nginx 默认开启了proxy_buffering会把上游也就是你的后端服务发来的数据先攒在一个缓冲区里等攒满了或者请求结束后才一次性转发给客户端。后果就是你的后端明明是一 token 一 token 推送的用户却还是等了几秒钟突然看到一整段文字弹出来打字机效果直接没了。解决办法是在 Nginx 配置里对 SSE 接口所在路径单独关闭缓冲并关掉 TCP 的 Nagle 算法location /api/chat/stream { proxy_pass http://your-backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 关闭缓冲让数据实时流向客户端 proxy_buffering off; proxy_cache off; # 关闭 Nagle 算法避免小包被延迟合并 tcp_nodelay on; # 设置更长的超时防止读流过程中断 proxy_read_timeout 3600s; proxy_send_timeout 3600s; # HTTP/1.1 是 SSE 长连接的基础 proxy_http_version 1.1; }这段配置的核心是proxy_buffering off和proxy_http_version 1.1。前者让 Nginx 成为“数据搬运工”而不是“数据囤积者”后者确保和上游的通信走支持 chunked 传输的 HTTP/1.1。忘了写proxy_http_version 1.1的话Nginx 默认用 HTTP/1.0 和上游通信连接复用和 chunked 支持都会有问题。6.2 浏览器 EventSource 的“跨域”限制EventSource 受到了同源策略的限制如果你的前端页面在a.com而 SSE 接口在b.com直接请求会报跨域错误。这不是协议问题是浏览器策略问题。处理方式和普通 Ajax 跨域一样服务端需要加 CORS 响应头并且明确允许Content-Type: text/event-stream因为 SSE 请求的 Accept 类型比较特殊// Spring Boot 中的 CORS 配置示例 registry.addMapping(/api/chat/**) .allowedOrigins(https://a.com) .allowedMethods(GET, POST) .allowedHeaders(*) .allowCredentials(true);有一类特殊的场景需要单独处理如果前端页面和后端接口不在同一个域但又不想引入太多跨域配置可以考虑用 Nginx 做一层反向代理把/api/chat前缀的请求转发到真实后端这样浏览器看到的还是同域请求。这个方案在生产环境里非常实用。6.3 网关层超时与连接中断微服务体系里请求往往要经过 API 网关。很多网关默认的读超时只有 60 秒而一个完整的 AI 回答生成时间很容易超过这个阈值。这就是“为什么前端明明收到了前几个字后面却戛然而止”的常见幕后黑手。排查方法很直接用 curl 直接打后端服务看 SSE 流能持续多久再通过网关打一次做个时间对比。如果是超时类的报错调整网关对应路由的read-timeout参数即可。一般设置成 10 分钟以上或者直接设成 0表示不超时视网关实现而定。另外负载均衡层如果要配健康检查不要对 SSE 接口做频繁的主动探测。因为 SSE 连接本身是长连接探测请求如果被误当成业务请求会持续占用连接不释放容易引发连接池耗尽。6.4 后端框架层面对“流式响应”的干扰Spring MVC 处理响应时有可能经过拦截器Interceptor或过滤器Filter。有些拦截器会在请求处理完成后往响应里写入额外的数据或者对响应体做统一包装比如把对象序列化成统一 JSON 结构。这些东西一旦对text/event-stream类型的响应生效就会破坏 SSE 的数据格式前端解析就会报错。解决办法是拦截器或过滤器的范围要排除 SSE 接口路径或者在过滤器中判断响应类型如果已经是text/event-stream直接放行不做任何包装。这个小细节在项目里排查了很久当时的表现是“前端偶尔报解析错误刷新后又能跑一阵子”非常有迷惑性。6.5 忘记设置合理的出错恢复机制如果前端没有处理好错误恢复一旦 SSE 连接中断用户体验就会卡在“说了一半的话上面”用户不知道是继续等还是刷新页面。我在实践中采用了一个比较稳妥的方案在onerror回调里判断当前是否还在等待内容。如果已经收到过数据保留已展示的内容并在界面显示“连接已断开点此重试”的提示如果还没收到过任何数据直接走自动重连。配合 SSE 自带的断线重连能力加上上面useSSE中的 maxRetries 控制绝大多数临时性网络抖动都可以自愈。7. 进阶玩法SSE 不只是打字机还能做实时进度与日志流把 SSE 的思路再往外延伸一层你会发现它在 AI 应用里还有不少用武之地。热词里出现的“SSE SpringBoot Vue3 通用进度条”就是很典型的场景。思路本质上是一样的服务端把任务的进度状态按时间顺序推给前端前端用一个进度条组件逐条更新。比如你在做一个文档解析类的 AI 工具处理流程是“拆解文档 → 向量化 → 入库 → 生成摘要”每一步都耗时。如果不做流式反馈用户只能看到“处理中”的转圈动画鬼知道要等到什么时候。用 SSE 把这个链条上的每个阶段推下去前端就能展示“正在拆解文档... 45%”、“正在向量化... 60%”这样有真实信息量的进度条用户的耐心和企业系统的透明度都会好很多。实现的时候你只需要在服务端不同的处理阶段调用emitter.send(...)把阶段名称、百分比、附加信息一起推给前端前端基于event字段判断进度条的类型基于data.percent更新进度值。代码上几乎不需要额外的抽象就是把目前打字机效果的思路复制一遍。事件类型这个字段在打字机场景里可能没被充分利用但做进度条时它就是必需品了。你可以定义event: stage-change、event: progress-update、event: task-complete等事件类型前端分别注册对应的监听器逻辑会非常清晰。还有一个值得探索的方向AI Agent 场景里把 Agent 每一步的思考过程比如调用工具、搜索文档、生成回复通过 SSE 推给前端用户能实时看到 Agent 的“工作过程”而不是只能被动等待最终结果。这种“透明化”对建立用户信任非常有帮助而且在技术上比你想的要简单——只是在合适的位置多调用几次emitter.send而已。8. 什么时候你应该果断抛弃 SSE我不想把 SSE 说成银弹。如果你要做的功能是“用户之间实时聊天”“多人协作文档编辑”“在线白板协作”这类需要双向通信的产品SSE 天然不适合WebSocket 才是正道。这类场景客户端和服务端都在高频地产生数据SSE 只能服务端向客户端单向推送客户端消息要通过额外请求补发来回切换的链路复杂且低效。另外如果你的服务端已经全面拥抱了 WebSocket且已经有一套成熟的连接管理机制那为了 AI 对话场景引入 SSE 确实会增加一套技术栈的维护成本。这种“双通道”局面不是不能接受但要衡量团队的实际维护能力。从我的角度看绝大多数网页端 AI 对话、AI 搜索、AI 阅读助手类应用SSE 都是优先级最高的选择。它足够简单、足够可靠、和浏览器生态配合得天衣无缝而且对后端基础设施的改造要求低到近乎为零。对一个“服务端生成、客户端展示”的业务来说这已经是满分答案了。就我个人实践而言第一次做 AI 对话的时候我也是想都没想直接上了 WebSocket结果光心跳维护和断线重连就折腾了两个版本。后来切到 SSE代码量少了一半体验反而提升了一个档次。所以如果你正站在这个选型岔路口不妨先问自己一句我到底需不需要那条“反向通道”如果不需要SSE 就是最舒适的选择。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价