做AI对话类的前端,绕不开流式输出。用户敲完问题,大模型少说要算好几秒,长一点几十秒都有,你要是等全部生成完再把结果怼到页面上,用户早就跑了。所以现在几乎都是SSE(Server-Sent Events)来接大模型吐出来的token,一有增量就推到前端,配合打字机渲染,让内容像真人打字一样一句句蹦出来。这套东西看着简单,真落地的时候一堆细节:断线怎么续传、idle timeout怎么破、markdown标签被截断怎么处理……这篇就用实际项目里的做法,把SSE流式输出、断点续传、打字机渲染这条链路完整捋一遍。
1. 为什么AI对话场景要选SSE而不是WebSocket或轮询
1.1 需求本质:从“请求响应”到“边算边写”
AI对话的耗时不像普通接口:不是查询数据库几十毫秒,而是模型推理按token生成,可能几秒到几十秒。如果前端发起请求后干等,用户只看到一个loading转圈,完全不知道后台在干嘛。流式输出的核心诉求,是让用户看到“正在生成”的过程,AI回答是逐渐变长的,页面就得跟着逐渐变长。
有的同学会问,那我把整个答案拆成好几段,前端轮询接口行不行?可以,但轮询意味着前端要定时去问“好了没”,没好了你要继续等,请求之间还有间隔,实时性天然差。用WebSocket行不行?也行,但AI回答的场景是“单向为主,少量控制”,WebSocket是全双工,服务端想主动发就发、客户端想主动发就发,七嘴八舌的,协议复杂度和维护成本都比SSE高不少。
我现在的选择标准很简单:如果只是“服务端持续把内容推给前端”这种单向流,SSE就是最贴合HTTP语义的方案。它建立在HTTP之上,不需要额外握手,前端还有一个原生EventSource对象可以直接用,连第三方库都不用装。如果说WebSocket像一条双向隧道,SSE更像一扇单向窗口,大模型生成内容从窗口递出来,前端只负责接。
1.2 SSE和WebSocket的选型对比
选型不能只看“能不能用”。我之前也纠结过,后来把两者放在一起比了一下。
| 对比项 | SSE | WebSocket |
|---|---|---|
| 连接方式 | 普通HTTP长连接 | 先HTTP握手,再升级为WebSocket协议 |
| 数据方向 | 服务端单向推送到客户端 | 全双工 |
| 协议复杂度 | 低,text/event-stream格式 | 高,需要处理帧、掩码、心跳、关闭 |
| 断线重连 | 原生支持,浏览器自动重连 | 需要自己实现 |
| 自定义事件 | 支持event字段,可以区分不同类型 | 需要自定义消息协议 |
| 二进制数据 | 不支持(只能文本) | 支持 |
| 服务端资源 | 普通HTTP连接,Nginx层好处理 | 需要长连接管理,多一层代理配置 |
对于AI对话场景,二进制数据基本用不到,方向又是服务端单向推送,所以SSE在成本上比WebSocket低一大截。如果你只是想实现一个“流式打字机”,千万别一上来就上WebSocket,后面维护够你喝一壶的。
1.3 前端原生的EventSource到底能用多少
原生EventSource用法很简单:
const es = new EventSource('/api/chat/stream'); es.onmessage = (event) => { console.log('收到消息:', event.data); }; es.addEventListener('delta', (event) => { const data = JSON.parse(event.data); console.log('增量内容:', data.content); }); es.onerror = (event) => { console.log('连接出错,EventSource会自动重连'); };这里有个点:EventSource默认只支持GET请求。AI对话场景里,我们通常需要把用户的prompt、历史记录、参数传给后端,如果这些内容很长,塞在URL query里既丑又容易超限。所以你会看到很多实现不用EventSource,而是用fetch读ReadableStream。这个后面讲前端实操时我会重点展开,先记住结论:原生EventSource适合简单Demo,生产环境我更推荐fetch流式读取,因为能发POST、能自己控制重连逻辑、能拿到更细粒度的状态。
2. 从零搭建一条能用的SSE流式链路
2.1 后端接口设计:Content-Type、响应头与心跳
后端只要设置正确的响应头和输出格式,就能让浏览器或fetch把它当SSE流解析。下面这几个响应头是必须的:
Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-aliveX-Accel-Buffering: no:如果用了Nginx,这个头告诉Nginx别缓冲响应,否则SSE会被卡住
SSE的数据格式是“事件块”。每个事件块用空行分隔,常见的字段是data、id、event、retry。例如:
id: 1 event: delta data: {"content": "你"} id: 2 event: delta data: {"content": "好"}后端代码我用Node.js写过一版,大概是这样的:
const express = require('express'); const app = express(); app.get('/api/chat/stream', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); let index = 0; const timer = setInterval(() => { // 心跳:防止代理层空闲超时断开 res.write(': ping\n\n'); if (index >= answer.length) { res.write(`id: ${index}\nevent: done\ndata: {"finished": true}\n\n`); clearInterval(timer); res.end(); return; } const chunk = answer.slice(index, index + 2); res.write(`id: ${index}\nevent: delta\ndata: ${JSON.stringify({ content: chunk })}\n\n`); index += 2; }, 50); });注意心跳行: ping\n\n。不是所有网关都一样,有的反向代理如果一分钟没收到响应体就会报idle timeout,你的连接就被切了。之前我在日志里看到stream disconnected before completion: idle timeout waiting for sse,排查到最后就是网关空闲超时。加心跳是成本最低的解决办法。
另外,如果AI回答里需要查询数据库,后端也要注意别一把把ResultSet全捞到内存里,尤其数据量大的时候。可以使用JDBC的流式查询,设置fetchSize然后逐行处理,再把每行结果转成SSE事件推出去。流式不是只是前端的事,后端从数据源到响应出口,整条链路都要“流式”起来,否则前面再快也被内存拖死。
如果你用的是Java Spring Boot,可以用SseEmitter或者WebFlux的Flux<ServerSentEvent>,原理一样,响应头同理。Node、Java、Go都能做SSE,不存在语言层面的限制,核心就是把数据按text/event-stream格式一点点写出去。
2.2 前端怎么收消息、断线重连和idle timeout处理
生产环境我推荐用fetch + ReadableStream,原因前面说了:EventSource不能POST,而且重连策略太“自动”,有时候反而不灵活。用fetch读流大概是这样的:
async function fetchSSE(prompt, callbacks) { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }), }); 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 { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 按空行分割SSE事件 const frames = buffer.split('\n\n'); buffer = frames.pop(); for (const frame of frames) { const event = parseSSEFrame(frame); callbacks.onEvent?.(event); } } } function parseSSEFrame(frame) { const lines = frame.split('\n'); const event = { id: '', data: '', event: 'message' }; for (const line of lines) { if (line.startsWith('id:')) event.id = line.slice(3).trim(); else if (line.startsWith('data:')) event.data += line.slice(5); else if (line.startsWith('event:')) event.event = line.slice(6).trim(); } return event; }这里有个很容易踩的坑:TextDecoder.decode要用{ stream: true }。因为网络包不一定按SSE事件边界切分,可能半个字符、半个事件混在一次read里。这个参数会保留未解码完的字节,等下次再处理,不然中文多字节字符容易乱码。
断线重连时,手动实现一个带退避的版本:
let lastEventId = 0; let retryDelay = 1000; function connectStream() { fetchSSE('/api/chat/stream', { onEvent(event) { if (event.event === 'done') { cleanup(); return; } if (event.id) lastEventId = Number(event.id); renderChunk(event.data); }, }).catch(() => { setTimeout(() => { retryDelay = Math.min(retryDelay * 2, 10000); connectStream(); }, retryDelay); }); }这里lastEventId就是断点续传的锚点,后面第4节专门讲。
2.3 开发调试:用curl验证SSE流
写前端之前,先确认后端到底是不是真的在“流式”推数据。直接用curl最方便:
curl -N http://localhost:8080/api/chat/stream-N是--no-buffer,让curl不缓冲,每收到一点就打印一点。如果这条命令能看到内容一行一行蹦出来,说明后端逻辑没问题,前端报错就是前端的事。如果加了-N还是一下子全部打印,十有八九是响应头少了Content-Type或者被代理层缓冲了,先去看Nginx配置。
curl还能模拟带Last-Event-ID重连:
curl -N -H "Last-Event-ID: 120" http://localhost:8080/api/chat/stream这能帮我们验证后端“从第120个事件继续推”的逻辑是否正常,不用等前端把所有流程跑一遍再调试,效率高很多。
3. 打字机渲染:不只是“拿到就显示”
3.1 渲染层要做的事:缓冲、解析、防抖、光标
SSE推到前端的内容不是完整段落,而是一小块一小块文本。如果直接把这些小块扔给innerHTML拼接,会遇到两个问题:一是DOM频繁更新,页面卡顿;二是AI返回的是markdown,代码块、表格、列表都是跨多个chunk的,一截断就会渲染出残缺标签。
所以前端要建立一条“渲染流水线”:
- 收到原始块后先放到缓冲区;
- 对缓冲区做“只渲染完整部分”的处理;
- 用节流或requestAnimationFrame控制DOM更新频率;
- 维护一个光标状态,让用户知道还在生成。
打字机效果本质上不是“逐个字打印”,而是“控制UI刷新节奏”。大模型生成速度不稳定,有时候一次推一大段,有时候一次一个字。如果你完全跟着网络节奏走,页面会一会儿跳一下一会儿卡一下。我的做法是维护一个pendingText,每次收到chunk就往里追加,然后用requestAnimationFrame去刷新显示区,把“网络到达频率”平滑成“人眼舒适频率”。
我做过Vue版的聊天对话AI流式输出,思路完全一样,只要确保把流式buffer和渲染函数拆开,不要在watch里直接频繁赋值给响应式字段,否则长文本会卡到你怀疑人生。
3.2 “标签返回未完整怎么处理”:流式片段解析的坑
这是AI流式渲染里最经典的坑。举个例子,模型返回一个markdown代码块,以三个反引号加个语言名开头,中间是代码,最后是三个反引号闭合。但SSE推送可能分好几次到,第一次只到了三个反引号,第二次才到语言名,最后一次才是闭合反引号。如果你每次收到消息就直接把全部内容交给markdown解析器渲染,解析器会在某一刻看到开头的三个反引号没有闭合,输出一个残缺的代码块,或者直接把你后面所有内容都吞进去。
我踩过几次坑之后,总结出来的处理方式是:不要把不完整的内容交给渲染器,而是等它“看起来完整”了再渲染。
具体做法是维护一个缓冲区,每来一段内容先追加进去,然后尝试找到“最后一个完整块”。对markdown来说,常见策略是检测最后一条分割线、代码块标记、表格分隔符等。更通用的做法是利用解析器的能力,只取完整块渲染。一个简化示例:
function getRenderableContent(buffer) { // 简单做法:统计三个反引号出现次数,奇数说明代码块还没闭合 const codeBlockOpen = buffer.split('```').length - 1; if (codeBlockOpen % 2 !== 0) { const lastIndex = buffer.lastIndexOf('```'); return buffer.slice(0, lastIndex); } return buffer; }这段代码只是个示例,真实场景还会遇到markdown表格的行没齐、列表符号开头等。我建议把“渲染”和“流式追加”拆开:流式追加维护原始buffer,渲染时只处理完整片段,残缺部分留在buffer里,等下一个chunk到了再拼起来重试。
另一个更省心的方案是:先用纯文本展示,等整段生成完再统一渲染markdown。缺点是你失去了“实时看到格式”的体验,折中的办法是在流式过程中只渲染纯文本,同时用很浅的高亮来撑住观感。具体取舍看你的产品要求,不是所有场景都必须实时渲染完整markdown。
3.3 性能优化:减少频繁DOM更新
如果一条回答有几千字,每个token都触发一次渲染,哪怕只是innerHTML赋值,也会让页面明显卡顿。我的性能优化三板斧:
- 合并渲染。用
requestAnimationFrame或者setTimeout,把100ms内的所有chunk合并成一次DOM更新。 - 减少重排。渲染目标区域固定宽高,图片、代码块出现时预留空间,避免每个字符都引起布局抖动。
- 尾部光标用CSS动画。不要用JS每秒加一个字符或减一个字符,用CSS的
::after配合blink动画,零开销。
Vue、React里还要注意状态更新的粒度。别把完整回答塞进一个大的响应式对象里,然后每次更新都触发整颗组件树渲染。拆成“纯文本buffer + 渲染版本号”这种结构,只有版本号变化时组件才重渲染,渲染函数内部再把buffer渲染出来。这样状态更新频率降下来了,UI也不容易掉帧。
4. 断点续传:让一次中断的AI回答能接着读
4.1 为什么需要断点续传:网络抖动、超时、刷新
SSE虽然基于HTTP长连接,但连接不可能永远稳定。移动端切网络、代理空闲超时、服务端发布,都可能导致连接断开。如果不做任何处理,用户看到的就是一句话说到一半没了,刷新后又得重新生成一遍,既费钱又费时间。
这里的“断点续传”和文件下载里的断点续传不太一样。文件下载是按字节偏移量(HTTP Range)续传,像MinIO分片上传那种,属于文件字节级;AI流式续传是按“事件序号”或“文本位置”续传。本质都是“记住上次读到哪了”,但粒度不同。
我做AI对话的续传,至少会记录两个东西:
- 事件ID:SSE协议里每个事件都可以带
id字段,表示这是第几个事件; - 已展示文本长度:记录用户已经看到哪了,重连后从断点继续渲染。
只记事件ID还不够,因为同一批事件里可能包含多个chunk,而用户看到的文本长度和事件ID不一定完全对应。所以我一般用一个递增的cursor,这个cursor同时作为事件的id,也作为文本输出的字符计数值。
4.2 前端如何记录消费位置:lastEventId与Message ID
SSE协议本身支持Last-Event-ID。浏览器断线重连时,如果之前的流里带过id字段,EventSource会自动在重连请求头里带上Last-Event-ID: <lastId>,后端看到这个头就知道“接着发”。
但如果用fetch + ReadableStream,这个头是不可能自动带的,因为fetch完全是手动重连。所以我在前端自己存lastEventId,存到sessionStorage或localStorage,每次收到事件就更新:
function saveCursor(cursor) { localStorage.setItem('chat_cursor', String(cursor)); } function loadCursor() { return Number(localStorage.getItem('chat_cursor') || 0); }为什么存在localStorage而不是纯内存?因为用户可能手动刷新页面。刷新后JS变量全丢了,但localStorage还在,至少能恢复一部分。
另外要强调一下:记录的位置必须是“用户实际看到的内容位置”,而不是“服务端发了多少内容”。有时候服务端发了100个事件,但前端因为缓冲、渲染策略,用户只看到了前80个事件对应的文本。如果你用事件ID续传,前端要能把缺失部分补回来;如果直接用文本长度,又要避免“补齐内容覆盖掉用户已经看到的文本”的问题。最稳妥的协议是:服务端返回的是“从cursor开始的增量文本”,前端收到的每一条都带起始位置,然后前端维护一个renderedLength,只取renderedLength之后的内容渲染。
4.3 后端如何实现续传:把流式输出变成“可回放事件流”
要让续传真正可用,后端就不能只把AI返回的内容“实时转发”,而要把它当作一个“可回放的事件流”来存储。也就是说:AI生成的每个chunk都落库或写进缓存,生成完还要保留一段时间,这样用户重连时后端能根据游标把后面的内容重新推一遍。
以Node为例,我可以先把chunk写到Redis列表里:
async function pushChunk(cursor, content) { await redis.rpush(`chat:${sessionId}`, JSON.stringify({ cursor, content })); }等到断线重连,后端从请求里拿到Last-Event-ID或query参数里的cursor,直接遍历Redis列表,从大于这个cursor的位置开始重新推:
app.get('/api/chat/stream', async (req, res) => { let cursor = Number(req.get('Last-Event-ID') || req.query.cursor || 0); const events = await redis.lrange(`chat:${sessionId}`, cursor, -1); for (const event of events) { res.write(`id: ${cursor}\nevent: delta\ndata: ${event}\n\n`); cursor++; } // 然后继续订阅AI产生的新chunk });这里的关键是:数据生成要“先存后推”或“边存边推”。只推不存的话,断线后历史没了,续传无从谈起。落库的另一个好处是,前端刷新后可以直接从最近一次游标开始,不用重新调用大模型,节省成本。
4.4 重连时的UI策略:不闪烁、不重复、不丢字
断点续传不只是后端的事。前后端都做好了,前端UI策略没做对,一样白搭。
重连瞬间最容易出现三种毛病:
- 闪一下空白再填充;
- 已经显示过的内容重复显示;
- 最后几个字被吞掉。
我处理这几个毛病的经验是:
- 重连期间不清空现有内容,界面保持“正在重连”的提示;
- 新数据到达后,先和本地
renderedLength比较,只渲染比它长的部分; - 如果服务端返回的第一条是“从某个cursor开始的完整补发”,前端不要直接替换内容,而是追加缺失的部分;
- 给重连状态设置一个最大超时,比如15秒,超了就用“重试”按钮让用户手动操作,不要无限重试浪费用户流量。
UI上我还会做一个“拖尾光标”效果:内容中断时在末尾显示一个闪烁光标,一旦重连成功、新内容接上,光标继续往下走。这个细节对体验提升很明显,用户能感觉到系统还在干活,而不是死掉了。
5. 实战中常见的坑和排查技巧
5.1 用AI辅助前端开发时,任务怎么拆才不翻车
既然标题是“AI前端落地实战”,这里顺带聊聊我们前端怎么用AI写代码。很多人让AI直接生成一个大功能,结果经常前后矛盾,原因是任务太大、上下文太模糊。我现在的做法是把一套SSE流式链路拆成几个独立可交付的步骤:
- 先让AI生成一个最简后端接口,返回固定字符串的SSE流;
- 前端实现一个只能读流并console.log的调试页面;
- 再把AI生成的markdown内容接到打字机渲染组件里;
- 最后做断线重连和续传,给它补充具体场景。
每个步骤都是一个小任务,AI能专注在一个窄主题上,代码质量明显更高。这其实和多人协作是一样的:明确输入、明确输出、明确验收条件,任务才不容易跑偏。这种思路也可以理解为以时间流的方式来开发代码,每个时间片只解决一个明确问题,从“能跑通”到“健壮”再到“体验好”,层层递进。
5.2 问题速查表:SSE流式链路常见错误
把这段时间碰到的典型问题整理成一张表,方便你排查。
| 现象 | 可能原因 | 排查顺序 |
|---|---|---|
| 页面收到不了SSE流,只等在最后 | 响应被缓冲 | 先看Content-Type和X-Accel-Buffering,再用curl-N验证 |
日志出现stream disconnected before completion: idle timeout waiting for sse | 代理空闲超时 | 服务端加心跳,调大网关idle超时 |
| 中文乱码 | TextDecoder没加{stream: true} | 检查decode参数 |
| Markdown标签显示残缺 | 截断块直接交给解析器 | 做缓冲区,只渲染完整块 |
| 断线重连后内容重复 | 游标记录不准确 | 前后端统一事件ID和renderedLength |
| 打字机卡顿 | 每个chunk都更新DOM | 合并渲染,使用requestAnimationFrame |
| fetch流在Nginx后面不生效 | Nginx缓冲响应 | 设置proxy_buffering off;或者后端返回X-Accel-Buffering: no |
5.3 我的几点工程心得
最后分享几个我在项目里坚持的小习惯。
第一个,SSE格式一定要和后端定成契约。事件类型、data的JSON结构、id的递增规则、结束事件长什么样,都要提前定义好,最好出一个简单的协议文档。不然前后端联调的时候,今天用delta明天用chunk,你改我也改,全是无效沟通。
第二个,所有流式接口都必须有超时和取消机制。用户可能随时不想等了,组件卸载时要能中断fetch,后端也要能感知到连接断开,停止继续生成。别让用户刷新了页面,服务端还在傻傻跑模型,消耗算力。
第三个,生产环境多留日志。SSE连接很容易出现“偶发断线”,但问题复现不了。我在服务端会把每个session的开始时间、事件数、结束原因都打日志,前端也会记录lastEventId和renderedLength。两边日志一对照,很多诡异问题能很快定位。这里面的第二个和第三个习惯,是我被线上事故教育出来的。后来我们把协议文档、超时取消、日志三件套补上之后,SSE这条链路的线上故障率明显降了下来,希望你不用再踩一遍。