上个星期有个做对话产品的朋友来问我,为什么他们家的 AI 助手明明模型响应很快,用户还是在反馈里写"卡"。我打开他们的页面看了一次请求,答案挺直接:后端早就把第一个字推过来了,前端却在等整段响应结束才一次性渲染。用户盯着转圈图标的那三秒,和看着文字一个一个蹦出来的三秒,是完全两种体感。
这篇就把前端接收流数据(Stream Data)并处理数据这条链路,从通道选型、字节切分、增量渲染一直到中断重试,完整讲一遍。它要解决的问题是:在 AI Chat 这种"服务端持续吐碎片、前端要实时显示"的场景下,怎么让数据稳、延迟低、滚动不卡。适合已经能调通接口、但被分包、乱码、掉帧、断流这些具体问题卡住的同学,也适合刚开始做对话界面的新手。我会尽量把每个"为什么这么做"说透,而不是只丢一段代码让你抄。
1. 通道选型:SSE、fetch 流、WebSocket 到底怎么选
1.1 三种通道在对话场景下的真实差异
EventSource(浏览器原生的 SSE 接口)看着最省事,new EventSource(url)之后监听 message,浏览器帮你分帧、帮你自动重连。但它在对话场景里有三个硬伤,基本上每个项目都会撞上其中一个。
第一个是只能发 GET。你的 messages 数组可能有几十轮对话,塞进 query string 不仅难看,还会撞上长度限制——不同浏览器、不同网关对 URL 长度的容忍度不一样,常见上限在 2KB 到 8KB 之间,几轮长对话就超了。
第二个是不能自定义请求头。现在多数团队的鉴权走Authorization: Bearer xxx,EventSource 给不了这个头。你只能退而求其次用 cookie,或者把 token 塞进 URL 里,前者受跨域和 SameSite 策略牵制,后者会把凭证写进访问日志,都不太干净。
第三个是重连语义不匹配。浏览器内置的重连是"断了就按 retry 间隔重发同一个 GET",而对话场景下你希望的往往是"重新发一次这个提问",两者的副作用完全不同——前者可能在后端生成到一半时又来一次,产生重复计费。
WebSocket 是双向通道,能力最强,但 AI Chat 本质是"一问一答 + 服务端单向推送",双向能力基本用不上。为了在一条长连接上跑多轮对话,你得自己设计 requestId、维护路由表、处理乱序和超时。链路越长出错面越大,上线后还要面对网关握手升级被拦、心跳保活、单机连接数上限这些运维问题,投入产出比不划算。
fetch+ReadableStream是我现在的默认选择。它保留了 POST、自定义 header、AbortController 这三样在对话场景里的刚需能力,代价是分帧和重连得自己写——而这部分代码,写得糙一点也就一百行出头。
| 维度 | EventSource | WebSocket | fetch + ReadableStream |
|---|---|---|---|
| 请求方法 | 仅 GET | 握手后自定义 | 任意,通常 POST |
| 自定义请求头 | 不支持 | 握手阶段支持 | 完全支持 |
| 请求体 | 不支持 | 支持 | 支持 |
| 中断控制 | close() | close() | AbortController,更细 |
| 自动重连 | 有,但策略固定 | 无 | 无,需自己写 |
| 协议复杂度 | 低 | 高 | 中 |
| 适合对话场景 | 勉强 | 过剩 | 合适 |
1.2 为什么最后落在 fetch + ReadableStream
除了上面那三条能力,还有一个常被忽略的点:fetch的response.body是一个标准的ReadableStream,意味着你能拿到最原始的字节块,自己决定怎么切、怎么缓冲、什么时候渲染。这在需要做"先渲染纯文本、后补 Markdown 格式"这类优化时特别重要——如果你用的是别人封装好的客户端,中间那层可能已经帮你把整段读完再返回了,优化空间直接被吃掉。
最小可跑通的骨架长这样:
async function startStream(payload, { signal, onEvent, onDone, onError }) { let response; try { response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', }, body: JSON.stringify(payload), signal, }); } catch (err) { // 网络层失败,或者被 abort onError({ layer: 'network', err }); return; } if (!response.ok) { // 注意:错误响应体通常是普通 JSON,不是流 const detail = await response.json().catch(() => ({})); onError({ layer: 'http', status: response.status, detail }); return; } if (!response.body) { // 极少数环境不支持流式 body,降级为一次性读取 const text = await response.text(); onEvent({ data: text }); onDone(); return; } await consume(response, { onEvent }); onDone(); }这段代码里有两个地方值得单独说。一是response.ok的判断必须放在读 body 之前。如果接口返回 401 或者 429,响应体一般是几十字节的 JSON,你把它当事件流去解析,会得到一个莫名其妙的解析错误,把真正的 401 盖掉,排查时非常费劲。二是fetch的 promise 在响应头到达时就 resolve 了,body 还在后面慢慢流。所以await fetch(...)成功返回,不代表流结束,判断流结束只能看reader.read()返回的done。
1.3 前端写对了,链路中间还可能被憋住
第一次做流式接口的人经常会遇到一个诡异现象:本地开发一切正常,上了预发环境就变成"等五秒一次性出结果"。代码一行没改,问题出在中间层。
最常见的原因是反向代理缓冲。很多网关默认会把上游响应攒够一定大小再转发,表现就是"你在流式返回,但前端要等好几秒才看到整段"。常见处理是在响应头里加X-Accel-Buffering: no,同时在网关配置里针对该路径单独关闭响应缓冲。
第二个是压缩。gzip 这类算法需要攒够数据块才能出效率,对小包流式响应反而是拖累,会把几个字节的小片段攒成大块再发。流式接口一般建议对这条路径关闭压缩。
第三个是Content-Type写错。写成text/event-stream会让一部分中间件按事件流处理;如果写成application/json,某些客户端库会尝试先读到结尾再解析,流式效果直接消失。
第四个是跨域。前后端不同源时,fetch默认不带 cookie,需要显式写credentials: 'include',同时服务端的Access-Control-Allow-Origin不能用*,必须回显具体来源,否则浏览器会直接拦掉。
我踩过一次很典型的坑:本地一路顺畅,预发环境等了五秒。排查两个小时,最后发现是预发比本地多了一层网关,那层默认开了缓冲。所以流式接口联调时,一定把"本地 / 预发 / 生产"三段链路分别验证,别只在本地跑通就以为万事大吉。
2. 把字节流切成消息:解码和分帧的两个坑
2.1 TextDecoder 的 stream 选项:中文乱码的根源
网络分片是按字节切的,不按字符。一个汉字在 UTF-8 里占 3 个字节,一个 emoji 占 4 个。假设服务端要发"你好"这 6 个字节,完全可能在中间被切成 3+3 两片,第二片的开头正好是"好"的第一个字节。如果你每次拿到 chunk 都执行一次new TextDecoder().decode(chunk),这个被切开的字符就会被解码成替换字符,界面上表现为偶尔蹦出一个乱码方块。
正确写法是复用一个 decoder 实例,每次调用带上{ stream: true }:
const decoder = new TextDecoder('utf-8'); const text = decoder.decode(chunk, { stream: true });stream: true的含义是"这段字节可能不是一个完整字符,先别急着把它变成替换字符,留在内部缓冲里,等下一段来了接着拼"。流结束后再调一次decoder.decode(),把内部残留冲出来。
这个 bug 有个非常典型的特征:本地测试全对,线上偶发乱码,刷新一下又好了。因为是否正好切在字符中间取决于网络分包,是概率事件,测试环境包小、切得少,线上包大、切得勤,才会暴露出来。
2.2 SSE 的分帧规则:别只认 \n\n
很多人第一次写分帧,直接buffer.indexOf('\n\n')就开工了。这能跑通大部分情况,但 SSE 规范里帧分隔符其实有\n\n、\r\n\r\n、\r\r\n\r\n几种,只认一种的话,遇到\r\n\r\n就永远分不出帧,表现为"数据收到了但界面不动"。
稳妥做法是先做一次换行归一化,把\r\n和单个\r都替换成\n,再按\n\n切。
一帧内部可能有多行内容:
event: delta id: 42 data: {"content":"你"} data: {"content":"好"}解析规则是:data:开头的行提取值,冒号后如果有且仅有一个空格要去掉;多个 data 行用\n拼起来;以:开头的行是注释行(很多服务端拿它发心跳),直接忽略;event决定事件类型;id用于断线续传时定位。
一个最小但正确的帧解析函数:
function parseEvent(raw) { const lines = raw.split('\n'); const dataLines = []; let event = 'message'; let id; for (const line of lines) { if (!line || line.startsWith(':')) continue; const colon = line.indexOf(':'); const field = colon === -1 ? line : line.slice(0, colon); let value = colon === -1 ? '' : line.slice(colon + 1); if (value.startsWith(' ')) value = value.slice(1); if (field === 'data') dataLines.push(value); else if (field === 'event') event = value; else if (field === 'id') id = value; } if (!dataLines.length) return null; return { event, id, data: dataLines.join('\n') }; }2.3 把解码和分帧拼成一个完整循环
单独看两块都简单,拼起来时有两个容易写错的细节:一是缓冲区里可能同时存在多个完整帧加一个半帧;二是结尾那帧可能没有以空行收尾。
async function consume(response, { onEvent }) { const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; try { while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); buffer = buffer.replace(/\r\n|\r/g, '\n'); let sep; while ((sep = buffer.indexOf('\n\n')) !== -1) { const raw = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); const evt = parseEvent(raw); if (evt) onEvent(evt); } } // 冲掉 decoder 内部残留的半个字符 buffer += decoder.decode(); buffer = buffer.replace(/\r\n|\r/g, '\n'); const tail = parseEvent(buffer.trim()); if (tail) onEvent(tail); } finally { reader.releaseLock(); } }两个提示。第一,replace那一行不要放进切帧的内层循环里执行,字符串越长正则越贵,在刚拼接完的位置做一次就够了。第二,如果你对接的后端发的不是 SSE 而是 NDJSON(每行一个完整 JSON,用单个\n分隔),分帧符就变成\n而不是\n\n。协议这件事一定要开工前问清楚,问一句省两小时。
3. 半截 JSON 与增量解析:先分清后端发的是什么
3.1 四种协议形态,处理难度完全不同
这是最容易白费时间的一个点。动手写解析之前,先抓一次真实响应——浏览器 Network 面板里把请求点开,看原始响应文本。你大概率会遇到下面四种之一:
| 协议形态 | 特征 | 前端处理难度 |
|---|---|---|
| 完整 JSON 对象 | 每个事件的 data 都能 JSON.parse 成功 | 低 |
| 纯文本增量 | data 里就是几个字,没有 JSON 结构 | 低 |
| 纯文本累积 | 每帧发的是"到目前为止的全部文本" | 中,要小心复杂度 |
| 被切断的 JSON | parse 随机失败,报错位置每次不同 | 高,建议推动后端改 |
大多数正规实现落在前两种。真的遇到第四种,我的建议是先跟后端把协议对齐,而不是在前端写一个容错 JSON 补全器。补全器要处理字符串里的引号转义、嵌套层级、数字被截断、Unicode 转义序列被切开,边界情况无穷多,长期维护成本远高于改一次后端。
3.2 累积型文本的复杂度陷阱
如果后端每帧发的是全量文本,前端一个很常见的写法是直接整串替换:
// 反面示例:每帧替换整段文本 let text = ''; onEvent((evt) => { text = JSON.parse(evt.data).content; setState(text); });看起来没问题,但假设回答有 500 个字、发了 500 帧,字符总复制量大约是 1+2+…+500,也就是 12.5 万次字符操作,还没算上每帧触发一次渲染和 Markdown 解析。这个量级在桌面端勉强能撑住,到了低端移动设备就开始明显掉帧。
更合理的协议是让后端只发增量,前端做追加。如果后端短期内改不了,前端至少可以做两件事:一是每帧只取"新的尾部"追加到已有字符串上,而不是整串替换;二是渲染层做节流,把多个帧合并成一次渲染。
判断新的尾部其实很简单,先看一眼新内容是不是以当前文本开头:
if (next.startsWith(current)) { appendChunk(next.slice(current.length)); } else { // 不是简单追加,说明后端改了历史内容,走全量替换 replaceAll(next); }顺带说一句,这套"能增量就绝不全量重算"的思路,和数据处理领域里做流式聚合是同一个逻辑。无论处理的是前端每秒几十个文字块,还是几亿行级别的数据管道,瓶颈往往都不在第一次计算,而在你没意识到的那次重复计算——全量重算的代价会随着数据量线性甚至平方增长,规模一大就原形毕露。
3.3 送给渲染器之前要做一次"安全闭合"
即使 data 是完整 JSON,交给 Markdown 渲染器的文本永远是"写了一半"的。典型表现有这么几种:
- 代码块围栏 ``` 只出现了开头,后面的内容被当成普通段落渲染,缩进和样式全乱。
- 加粗符号
**只写了一半,整段后面的文字都被渲染成加粗。 - 表格只出了表头,渲染器画出半张空表,然后每一帧都重排一次。
我的处理方式是在送进渲染器之前做一次临时补全:统计 ``` 出现的次数,是奇数就补一个;统计未闭合的**,是奇数就补一个;表格最后一行如果还没换行,就先缓存住,等出现换行再渲染。
有一点必须强调:补全只作用于渲染层的临时视图,原始文本始终以完整版本为准,千万别把补全后的字符串写回 state。否则用户点"复制"复制出来的内容会多出一堆 ``` 和**,这个 bug 用户一定会发现,而且会觉得产品很糙。
4. 从数据到界面:渲染节奏怎么控制
4.1 每来一个字就 setState 的代价
模型输出速度典型是每秒 20 到 80 个 token。如果每个 token 都触发一次 React 的 setState,那就是每秒几十次重渲染,而每次重渲染都要重新跑一遍 Markdown 解析——这是整条链路里最贵的一步,几百行的文档解析一次可能要几毫秒。乘起来就是明显的卡顿。
解决办法是把"接收"和"渲染"解耦:接收层只管往一个 ref 里累加字符串,渲染层按固定节奏把最新值同步到 state。
class StreamBuffer { constructor(flush) { this.text = ''; this.dirty = false; this.flush = flush; this.raf = null; } append(chunk) { this.text += chunk; if (this.dirty) return; this.dirty = true; this.raf = requestAnimationFrame(() => { this.dirty = false; this.flush(this.text); }); } finish() { if (this.raf) cancelAnimationFrame(this.raf); this.dirty = false; this.flush(this.text); } }为什么用requestAnimationFrame而不是setTimeout固定间隔?rAF 天然跟屏幕刷新对齐,一秒最多 60 次,而且标签页切到后台时会自动降频甚至暂停,省电也省 CPU。固定定时器在用户切走之后照跑不误,白烧资源。
经验值:flush 间隔落在 40ms 到 80ms 之间时,人眼基本感觉不出"分批"。我个人默认用 60ms 左右,配合 rAF 一起用,效果比较平衡。
4.2 Markdown 增量渲染怎么做到不闪
除了前面说的补齐围栏,还有几个具体做法值得试。
第一个是分级渲染。流式过程中用一套轻量规则(只处理换行、加粗、行内代码、代码块包裹),等流结束后再用完整的 Markdown 渲染器跑一次最终结果。用户对 200 毫秒后的格式微调基本无感,但对滚动掉帧非常敏感,把最贵的解析放到最后一次性做,性价比最高。
第二个是代码高亮延后。语法高亮通常比 Markdown 解析还贵,流式过程中每个字都触发一次是灾难。做法是检测到代码块还没闭合时先不高亮,只等闭合后再跑一次高亮。
第三个是别给流式文本加 transition 或动画。文字每帧都在变,任何过渡效果都会让浏览器反复重排重绘,纯属自找麻烦。
我见过一个团队干脆在流式期间只用等宽字体渲染纯文本,结束后再替换成 Markdown 结果。看着有点"糙",但实测滚动帧率最稳,用户反馈里也没人抱怨——因为大部分人根本注意不到那 200 毫秒后的格式变化。
4.3 自动滚动:什么时候不该跟着滚
最容易犯的错是每次新内容来了就el.scrollTop = el.scrollHeight。用户往上翻看前面的内容时会被硬生生拽回底部,体验极差,这是差评里出现频率很高的一条。
判定条件其实很简单:只有用户本来就贴着底部,才跟着滚。
function isNearBottom(el, threshold = 40) { return el.scrollHeight - el.scrollTop - el.clientHeight < threshold; }做法是渲染前先记录一次isNearBottom的结果,渲染完再根据这个记录决定要不要滚。阈值别设太大,40px 左右比较合适,给"手滑了一下"留点余量。
还有两个细节。一是如果容器高度是动态的(比如输入框多行自动增高),别在渲染回调里直接读scrollHeight,那里可能读到中间态的高度,用ResizeObserver监听容器尺寸变化更可靠。二是用户手动往上滚的时候,可以顺手显示一个"回到底部"的按钮,比强行跟随友好得多。
5. 中断、重试和状态机:异常路径才是主战场
5.1 AbortController 的几个使用细节
const controller = new AbortController(); fetch(url, { signal: controller.signal }); // 用户点"停止生成" controller.abort();第一,abort 之后,pending 的 fetch promise 会 reject 一个name为AbortError的错误。你必须把它和真正的网络错误区分开,否则用户主动点停止会弹一个"网络异常,请重试",看起来像是产品出了问题。
try { await startStream(payload, opts); } catch (err) { if (err.name === 'AbortError') return; // 主动取消,静默处理 showError('网络异常,请重试'); }第二,abort 发生时,reader.read()可能还有一个已经 resolve 的 chunk 在路上。所以在处理 delta 的回调里要判一次signal.aborted,避免把取消之后的残留内容追加进去,出现"明明点了停止却又蹦出几个字"的诡异现象。
第三,超时保护要用"空闲超时",不是"总时长超时"。模型思考慢的时候,中间可能十几秒没有输出,但链路是活的。设一个 30 秒的空闲计时器,每收到一帧就重置,超时才 abort 并提示。
第四,别忘了reader.releaseLock()。放在finally里,不然 reader 一直占着流,在某些场景下会导致后续读取报错。
5.2 断线了到底要不要做续传
这是我最想给建议的一段:默认不要做续传,直接重试整条。
理由有三个。第一,续传需要服务端配合。你得把"已经收到的文本"或"最后收到的事件 id"带回去,让服务端接着往下生成。绝大多数模型接口没有这个能力,你传回去的文本只能当上下文重新请求一遍,结果是重新计费,而且生成的内容和你已经展示的那半段可能风格不连贯。
第二,语义会乱。用户看到的是"前半段来自旧请求,后半段来自新请求",中间可能重复几个字,也可能接不上茬。
第三,收益不明显。断线本身是低频事件,而实现一套可靠的续传逻辑(去重、幂等、状态对齐、事件 id 回放)成本很高,属于典型的投入产出不划算。
我的做法是:断线时保留已生成的内容,在消息下方给一个"继续生成"按钮,用户点了就带着上下文重新发一次,新内容接着旧内容往下写。把决定权交给用户,比自动续传更可控,用户也不会觉得内容被莫名其妙地改掉。
如果确实需要自动重试,那限定在一个场景:首次连接就失败、一个字都没收到。这时候可以做退避重试,比如第一次等 500ms、第二次 1500ms、第三次 4000ms,最多三次。已经开始输出的一律不自动重试,否则用户会看到文字重复。
5.3 错误分层与状态机
流式链路上的错误会从四个地方冒出来,处理方式完全不同,混在一起处理就会互相污染。
| 层级 | 典型表现 | 判断方式 | 建议处理 |
|---|---|---|---|
| 网络层 | 连不上、连接被重置 | fetch reject | 提示网络问题,允许重试 |
| HTTP 层 | 401、429、500 | !response.ok | 401 跳登录,429 提示稍后再试 |
| 协议层 | 分帧失败、JSON 解析失败 | 解析过程抛异常 | 记录原始片段,提示响应异常 |
| 业务层 | 模型拒答、内容拦截 | 服务端主动下发 error 事件 | 展示具体原因,不提供重试 |
有个容易踩的坑:HTTP 层出错时,响应体是普通 JSON 而不是流。如果你无条件把response.body当流来读,会得到一个解析失败的怪错误,把真正的 429 掩盖掉。顺序一定是先判response.ok,再把 body 当流处理。
状态机我一般只用五个状态:idle(空闲)、connecting(已发请求、未收到首字节)、streaming(正在输出)、done(正常结束)、error(异常结束)。UI 上停止按钮只在 connecting 和 streaming 出现;输入框在 streaming 期间禁用发送但允许继续输入,这样用户不用等生成完才能打下一句话。
6. 上线之后才暴露的问题
6.1 长会话的内存增长
一个很典型的现象:一个会话聊了几十轮之后,页面开始明显变慢。原因通常有三个。
一是把每一帧的 SSE 原始事件都存下来用于排查。单条不值钱,几十轮乘几百帧就是几万个对象常驻内存。做法是只在调试开关打开时保留原始日志,默认不存。
二是消息列表没有虚拟滚动。几百条消息全部渲染,DOM 节点数量直接决定滚动性能。这个在开发阶段不明显,因为测试数据只有几轮对话,实际上线后用户一天能聊上百轮。加一层虚拟列表,只渲染视口内的几条,是性价比最高的一次优化。
三是已经结束的消息还挂着流式的中间态。流结束后应该只保留最终文本,把所有中间缓冲区、时间戳数组、分帧临时变量全部置空。
还有两个隐蔽的泄漏点:abort 之后没有releaseLock的 reader;注册了全局监听但没在组件卸载时移除。这两个在开发环境基本发现不了,跑久了才会暴露。
6.2 多会话并发时的状态串台
用户可能同时开几个会话,或者在 A 会话生成到一半时切到 B 会话。这时候如果只有一个全局的 controller 和一个全局的 buffer,内容就会串台——B 会话里出现 A 的回答。
做法是给每个会话维护独立的实例:
const streams = new Map(); // sessionId -> { controller, buffer } function stop(sessionId) { const s = streams.get(sessionId); if (!s) return; s.controller.abort(); streams.delete(sessionId); }更关键的一点是回调里必须带上 sessionId,并且在写入前校验"这个流实例是不是还是当前最新的"。有个好用的小技巧:每次新建流时生成一个自增 token 或者随机 id,回调里比对 token,不一致就直接丢弃。这个校验能挡住绝大部分异步竞态导致的串台。
切换会话时我的建议是不 abort,让它在后台继续跑,切回来时内容已经生成完了。这比"切走就停"更符合直觉——用户通常只是想看一眼别的对话,不是想取消当前这条。
6.3 首字延迟该埋在哪几个点
别只上报一个"总耗时",那个数字没有定位价值,出了问题只能靠猜。我一般埋四个时间点:
- t0:用户点发送,请求即将发出。
- t1:fetch resolve,响应头到达(这时能算出 TTFB)。
- t2:第一个非空 delta 落地,这才是真正的"首字"。
- t3:首字完成一次渲染,在 rAF 回调里记。
然后分段看:t1-t0 是网络往返加服务端排队,t2-t1 是模型首 token 时间,t3-t2 是前端渲染开销。如果 t3-t2 经常超过 100ms,说明前端渲染有问题,去查 Markdown 解析和高亮;如果 t2-t1 是主要耗时,那是模型侧的事,前端再怎么优化也挤不出多少空间。
这个分段在和后端、算法团队对齐责任边界时特别好用。以前讨论性能问题经常变成"我觉得是你慢",有了这三个分段数字,讨论就能落到具体环节上。
7. 我自己踩过的几个小坑
最后分享几个不值钱但很费时间的细节。
第一个是冒烟测试的方法。我现在的习惯是每写一个流式功能,先列出所有可能的中断点,然后手动把 Network 面板调到 Slow 3G,一边看着数据一边疯狂点按钮:点发送立刻点停止、发送后切标签页、发送后断网再恢复。这个土办法帮我提前发现的问题,比任何静态检查都多。它不需要写测试代码,只需要一点耐心。
第二个是关于"看起来没问题"的错觉。流式代码在网速好的时候几乎不会出错,所有 bug 都在弱网下才现形。所以验收阶段一定要在真实的弱网环境下点一遍,不能只在办公网里跑通就上线。
第三个是别急着封装。流式这块的抽象很容易封过头,一开始就想做一个"通用流式 SDK",结果发现每个业务对中断、重试、渲染的要求都不一样,抽象层反而成了绊脚石。我的做法是先在具体场景里写三遍,找出真正重复的部分再抽象。这三遍写下来大概两三天,但抽象出来的东西能撑住后面所有的需求变化。