“AI 前端落地实战”这几个字,我盯了整整三个晚上才敢动笔。当时接手公司那个大模型对话项目,需求听起来很简单:页面上一个聊天框,用户发消息,AI 像人一样一个字一个字往外吐。结果真做起来才发现,这里面的水比想象深得多——SSE 流式输出怎么接、断线之后怎么续、Markdown 标签还没闭合就渲染到页面上怎么办、Nginx 报stream disconnected before completion: idle timeout waiting for sse又是谁干的,每一个问题都能让你从下午排查到凌晨。
这篇文章就把我踩过的坑、最后落地的方案和一些面试里能拿出来讲的细节,一次性整理出来。如果你也在做 AI 聊天对话前端,或者准备前端面试时被问到流式输出、断点续传、打字机渲染这块内容,照着往下看,应该能省掉不少弯路。
1. 项目概述:一个AI对话前端到底在忙什么
1.1 核心需求拆解
先说清楚项目是什么。这是一个面向内部业务方的大模型对话应用,用户在网页端输入问题,后端把问题转发给大模型服务,然后模型生成的答案需要实时展示在页面上。传统的一次性请求响应(post 之后等完整 JSON 返回)体验太差,尤其模型生成一段几百字的回复可能要十几秒,让用户盯着 loading 转圈,基本等于劝退。
所以需求拆出来是三个核心链路:
- 流式输出:后端通过 SSE(Server-Sent Events)把模型生成的增量文本一段一段推给前端,前端收到后立刻渲染。
- 断点续传:网络抖动、代理超时、后端重启都会导致连接中断。中断后不能让用户重发问题,而是要在恢复连接后从断掉的地方把剩下的内容补回来。
- 打字机渲染:收到增量内容后不能一次性怼到页面上,要让文本像打字机一样逐步显示,同时还要兼容 Markdown 渲染。
这三个模块表面上是独立的,实际上互相影响。比如流式中断后,断点续传要恢复的不仅是“剩余文本”,还要让“打字机效果”从正确的进度继续走,渲染层如果对不齐,就会出现内容跳变或者重复。
1.2 为什么我用 SSE 而不是 WebSocket
这是项目一开始就遇到的技术选型问题。AI 对话场景确实常见两种方案:WebSocket 和 SSE。我最终选了 SSE,核心原因有三个。
第一,这个场景是单向服务端推送。用户发一条消息,服务端持续返回内容,前端在这个过程中除了“停止生成”之外不需要再向服务端发其他指令。SSE 天生就是干这个的,而 WebSocket 是双向通信,等于给一个单向管道配了个双向的闸门,复杂了。
第二,SSE 基于 HTTP,天然支持断线重连。EventSource 内置了reconnect机制,连接断开后浏览器会自动重连,还可以通过Last-Event-ID告诉服务端上次收到的消息 ID。WebSocket 断了就要自己处理重连逻辑,心跳、退避、状态同步全部自己写。想省事,SSE 赢一大截。
第三,穿透性和兼容性好。SSE 走普通 HTTP/HTTPS,不涉及升级协议这一步,经过 Nginx、负载均衡、CDN 这些中间层时比 WebSocket 省心得多。WebSocket 在代理层经常要单独配 upgrade 头,稍微配置不对就握手失败。
顺便把EventSource和 WebSocket 的对比整理成表格,面试时也经常用到:
| 对比项 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端单向推送 | 全双工双向通信 |
| 协议基础 | 普通 HTTP | 独立的 WebSocket 协议,需要握手升级 |
| 断线重连 | 浏览器内置,支持 Last-Event-ID | 需要自己实现 |
| 二进制数据 | 原生不支持,需编码 | 原生支持 |
| 实现复杂度 | 低 | 较高 |
| 典型场景 | 实时通知、AI 流式输出、行情推送 | 在线聊天、协同编辑、游戏 |
当然,如果需求是“用户和 AI 对话过程中还要支持随时打断、上传文件进度上报、多人协作同时编辑”,那 WebSocket 更合适。但纯粹做 AI 对话流式返回,SSE 是最省力的路径。
2. SSE 流式输出:从协议到前端落地
2.1 SSE 的协议格式和 EventSource 的边界
SSE 说白了一种基于纯文本的协议,服务端返回的 Content-Type 是text/event-stream,内容长这样:
id: 1 data: {"content":"你好"} id: 2 data: {"content":",我是AI助手"}每一条消息用空行分隔,data:是数据内容,id:是事件 ID,浏览器会自动记录这个 ID。如果有多行data:,浏览器会把它们用换行符拼起来。还可以用event:字段指定自定义事件类型,默认是message。
原生 EventSource 用起来特别简单:
const es = new EventSource('/api/chat?message=你好'); es.onopen = () => console.log('连接已建立'); es.onmessage = (e) => { // 这里拿到的 data 是字符串 const data = JSON.parse(e.data); render(data.content); }; es.onerror = () => console.log('连接异常,浏览器会自动重连');但项目做到一半我就发现,EventSource 有两个硬伤。
硬伤一:只支持 GET 请求。EventSource 用new EventSource(url)创建连接,这个请求只能是 GET。但 AI 对话场景里,用户的问题可能很长,而且往往需要带上会话 ID、用户 ID 等业务参数。把所有参数拼在 query 上,既容易撞 URL 长度限制,也不方便传 token 鉴权(虽然也能放 header,但 EventSource 无法自定义 header,这是个更麻烦的点)。
硬伤二:自定义事件处理比较笨。服务端如果发不同event:类型,比如event: delta表示增量、event: done表示结束,前端要一个个addEventListener去监听。如果后端协议不标准,EventSource 的自动解析反而帮倒忙。
所以项目里最终放弃了 EventSource,改成用fetch自己读流。这个方案现在在 AI 应用前端里非常主流。
2.2 用 fetch 流式读取,摆脱 EventSource 的限制
核心思路是:用fetch发 POST 请求,拿到response.body这个ReadableStream,然后通过getReader()不断读取数据块,再按 SSE 的格式手动解析。
我封装了一个简单的读取函数,代码如下:
async function fetchSSE( url: string, body: Record<string, unknown>, onMessage: (data: any, event: string) => void, signal?: AbortSignal ) { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', // 这里可以自定义任何业务 header,比如 Authorization }, body: JSON.stringify(body), signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`); } if (!response.body) { throw new Error('当前浏览器不支持 ReadableStream'); } 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 }); // SSE 消息以空行分隔,这里按 \n\n 切分 const parts = buffer.split('\n\n'); buffer = parts.pop() ?? ''; for (const part of parts) { const parsed = parseSSE(part); if (parsed.event) { onMessage(parsed.data, parsed.event); } } } } function parseSSE(block: string) { const lines = block.split('\n'); let event = ''; const dataLines: string[] = []; for (const line of lines) { if (line.startsWith(':')) { // 注释行,一般是心跳 continue; } const colonIndex = line.indexOf(':'); const field = line.slice(0, colonIndex).trim(); const value = line.slice(colonIndex + 1).trim(); if (field === 'event') { event = value; } else if (field === 'data') { dataLines.push(value); } // id、retry 字段按需处理 } if (dataLines.length === 0) return { event: '', data: null }; const raw = dataLines.join('\n'); let data: any = null; try { data = JSON.parse(raw); } catch { data = raw; } return { event: event || 'message', data }; }这里有一个非常关键的细节:buffer += decoder.decode(value, { stream: true })。如果不加{ stream: true },当一段 UTF-8 字节流正好把一个中文字符编码拆成两截的时候,TextDecoder会直接把后半截解码成乱码字符�。加上stream: true后,会把不完整的字节序列缓存在内部,等下一段字节到达时再一起解码。这个坑我刚开始没注意,AI 输出速度快的时候,页面偶尔出现一个黑点乱码,排查了好久才发现是解码方式不对。
另一个细节是心跳处理。服务端可能会定期发送: ping这样的注释行,防止中间代理层在空闲时掐断连接。SSE 协议规定,以冒号开头的行是注释,客户端要忽略。上面parseSSE里已经做了处理。
2.3 请求中断与用户取消
AI 对话还有一个绕不开的功能:用户点了“停止生成”,前端要能真正中止请求。用 fetch 实现时,要用AbortController:
const controller = new AbortController(); async function startChat() { try { await fetchSSE('/api/chat', { message: input.value }, (data) => { rawText.value += data.content; }, controller.signal); } catch (error) { if (error.name === 'AbortError') { console.log('用户主动取消'); } else { // 其他异常 } } } function stopChat() { controller.abort(); }这里有个容易被忽略的点:controller.abort()会抛出AbortError,但读取流内部可能还在进行中。更稳妥的做法是reader.cancel(),这样会直接关闭流。不过实际上如果用同一个signal传给 fetch,abort 后 fetch 内部会自动取消流的读取,reader.read()会 reject。实际开发中,我在AbortError分支里做了标记,确保停止后不再触发后续的重连或续传逻辑,否则用户明明点了停止,几秒后又自动重连把内容补上了,体验很诡异。
3. 断线重连与断点续传:数据不丢、画面不乱
3.1 断点续传到底续什么:连接和内容要分开看
“断点续传”这个词在 AI 对话场景里,比传统文件上传那套复杂一点。文件上传断点续传续的是“第几个分片”,而 AI 流式输出要续的是“模型回复生成到第几个字了”。
我把它拆成两个层面:
- 连接层续传:HTTP 连接断了,浏览器能不能重连。SSE 的 EventSource 自带这个能力,但用 fetch 实现后,重连逻辑就得自己写。好在这个不难,无非是捕获异常后按指数退避重新发起请求。
- 内容层续传:重连之后,AI 回复不会被从头生成一遍(那样太浪费也不行),而是从上次发送的位置继续。也就是说,前端要知道“我已经收到多少内容了”,把进度传给后端,后端从断点继续推。
内容层续传还有一个隐藏问题:渲染状态也要对齐。即使文本内容续上了,打字机渲染如果还停留在旧的进度,或者把已经渲染过的文本重复渲染一遍,用户看到的画面就乱了。所以断点续传不仅仅是网络层的事,还牵扯到状态管理。
3.2 服务端配合:session、messageId 与 offset
前端做断点续传,前提是后端要有对应的能力。当时我们和后端对了一套简单的协议,核心是三个字段:sessionId(会话 ID)、messageId(消息 ID)、offset(偏移量,单位是 UTF-16 代码单元数量)。
流程是这样的:
- 前端发起对话请求,带上
sessionId,服务端创建一个新的messageId。 - 服务端在模型生成过程中,每生成一段内容,就把这段内容追加到缓存区,并通过 SSE 推送给前端。
- 如果连接中断,前端重连时带上
sessionId、messageId和本地已经收到的文本长度offset。 - 服务端检查缓存里这条消息的完整内容,从
offset位置开始继续推送剩余内容。
服务端返回的数据结构我建议统一为:
{ "sessionId": "xxx", "messageId": "msg_123", "offset": 256, "content": "剩余的增量内容", "finished": false }offset表示这条消息已经推送到第 256 个字符,content是本次新增的增量。前端拿到后要做的不是覆盖,而是追加,同时更新自己的本地 offset。这样即使网络抖动导致前端重复收到同一段内容,只要前端记录好“已经处理到哪个 offset”,就能把重复内容过滤掉。
3.3 前端恢复策略:本地缓存拼接与服务端重放
前端的断点续传状态管理,我当时用了一个StreamState对象来维护:
interface StreamState { sessionId: string; messageId: string; receivedLength: number; // 已收到的文本长度 chunks: string[]; // 本地增量缓存 connected: boolean; finished: boolean; retryCount: number; }断线时,核心原则是:先保证已渲染的内容不回滚,再保证未渲染的内容能续上。
具体策略是这样:
- 前端每收到一个 chunk,先写进
chunks缓存,再更新receivedLength,再触发渲染。这个顺序很重要。如果先渲染再更新缓存,断线后本地缓存和页面显示就不一致了。 - 断线时,页面保留当前已渲染文本,不删除、不清空。同时显示一个“网络波动,正在重连…”的状态提示。
- 重连成功后,带
receivedLength请求服务端。服务端从断点返回剩余内容。前端先检查新内容里有没有重复的偏移量,按 offset 过滤后,再追加到rawText,并让打字机渲染从正确的进度继续走。
这里有个细节:重连后收到的新内容的offset等于断线前的receivedLength,说明没有丢失内容。如果新内容的 offset 小于当前 receivedLength,说明流的开始位置更早,这时候需要跳过前面重复部分,只保留 offset 之后的部分。如果不做这个过滤,用户会看到一段已读文本被重复渲染出来,直播翻车现场。
服务端重放还有一个好处:前端本地缓存即使因为浏览器刷新丢失了,只要 session 还在,就可以重新从服务端拉取整条消息的残段。所以“断点续传”最稳妥的兜底,永远在服务端。前端缓存只是用来保存渲染状态,服务端缓存才是恢复数据的源头。
4. 打字机渲染:流畅、不闪烁、不抽搐
4.1 增量渲染的核心数据结构
打字机渲染是用户感知最直观的部分。理想的打字机效果是:文字从左到右出现,节奏稳定,不跳字,不闪烁。
我见过很多同学是这么实现的:用一个interval定时器,每隔 50ms 从完整文本里截取前 N 个字符显示。如果fullText是个逐步增长的变量,那问题还不大;如果fullText是等流全部结束之后再一次性赋值的,那打字机效果就变成了“等很久没反应,然后突然全屏文本刷出来”,非常吓人。
正确的思路是用增量驱动渲染,而不是“全量文本 + 定时截取”。数据结构很简单,两个核心变量:
const streamingText = ref(''); // 已经流式收到的完整文本 let renderCount = 0; // 已经渲染到第几个字符每次收到增量 chunk,执行两个动作:
streamingText.value += chunk.content,把新文本追加进完整内容。- 启动或者继续一个“打字机消费器”,让
renderCount逐步逼近streamingText.length,把streamingText中renderCount到当前位置的新增字符逐步显示出来。
打个比方,streamingText是水桶里攒着的水,renderCount是水龙头,每次流入新水后,水龙头慢慢放水,而不是等整个桶满了再一次性倒光。
核心代码(Vue3 组合式函数风格):
import { ref } from 'vue'; export function useTypewriter(interval = 30) { const displayedText = ref(''); const fullText = ref(''); let timer: number | null = null; let renderIndex = 0; function appendChunk(chunk: string) { fullText.value += chunk; if (timer !== null) return; timer = window.setInterval(() => { if (renderIndex >= fullText.value.length) { clearInterval(timer!); timer = null; return; } renderIndex++; displayedText.value = fullText.value.slice(0, renderIndex); }, interval); } function reset() { fullText.value = ''; displayedText.value = ''; renderIndex = 0; if (timer !== null) clearInterval(timer); timer = null; } return { displayedText, appendChunk, reset }; }这个方案的优点是把“流式接收”和“打字机显示”解耦了。网络快的时候,fullText可能已经攒了很长,但展示还是按固定节奏走,用户看着舒服。网络慢的时候,fullText增长慢,打字机就跟随接收速度走,不会因为定时器空转造成闪烁。
4.2 Markdown 标签没闭合:流式渲染最容易被问倒的细节
如果说打字机节奏是第一关,那 Markdown 渲染就是第二关,也是面试官最爱追问的细节:“AI 输出的是 Markdown,但流式输出时内容是不完整的,比如代码块的三反引号还没出现,表格的竖线还没闭合,你怎么处理?”
这个问题我真实踩过坑。最初直接把streamingText丢给 marked 或者 markdown-it 渲染,结果用户看到的内容是:代码块开始标签出现后,页面先是渲染成了一段文字,等后面的反引号补齐了,又突然跳成代码块样式。这种“渲染结果反复横跳”的体验,用户会以为是 bug。
后来我总结了一套组合拳:
方案一:延迟渲染尾部不完整片段。把文本分成“已稳定区”和“缓冲区”。比如只把renderIndex往前 200 个字符以内的内容当作“已稳定区”,最后 200 个字符一定不渲染,等它过了 200 个字符的阈值后再渲染。这样 Markdown 标签大概率已经闭合,闪烁问题基本消失。代价是尾部始终有“余量”,不是所有文本都立即展示,不过对用户几乎无感知。
方案二:对未闭合标签做临时修补。如果产品要求必须全量实时渲染,那就得写一个轻量的“闭合修复函数”,针对常见的 Markdown 语法做兜底:
- 统计
```的出现次数,如果是奇数,就在文本末尾补一个```,避免代码块一直处于未闭合状态。 - 检测到
**未配对时,在末尾补**。 - 检测到
[text](url这种未闭合的链接写法时,补上)。 - 渲染前把末尾几个不完整的单词暂时去掉,避免出现“半个 token”导致排版错乱。
这个修复函数不追求完美,只求“渲染不崩、样式不跳”。我当时的实现是每渲染前做一次patchMarkdown,核心逻辑大概是:
function patchMarkdown(text: string): string { let patched = text; const fenceCount = (patched.match(/```/g) || []).length; if (fenceCount % 2 === 1) { patched += '\n```'; } const boldCount = (patched.match(/\*\*/g) || []).length; if (boldCount % 2 === 1) { patched += '**'; } if ((patched.match(/\[/g) || []).length > (patched.match(/\]/g) || []).length) { patched += '](javascript:void(0))'; } return patched; }方案三:代码块和普通文本分开渲染。这是一个更彻底的思路。解析流式内容时,检测当前是否处于代码块内部,如果是,代码块部分用<pre><code>包裹,普通文本部分用 Markdown 渲染,两者互不干扰。这样代码块中间的半行代码不会触发 Markdown 解析器报错。
4.3 性能与体验优化
打字机渲染看着简单,内容一长就会暴露性能问题。主要有三个优化点。
第一,渲染节流。displayedText是一个响应式变量,如果频繁更新,整个组件都会跟着渲染。打字机每 30ms 更新一次其实还好,但如果文本特别长,还需要配合v-memo或者在计算属性里做缓存,避免每次更新都全量 diff 大段 DOM。
第二,中文和 emoji 的处理。JavaScript 的slice(0, index)是按 UTF-16 码元切的,emoji 占两个码元,如果刚好切在中间,会出现半个乱码字符。我当时写了一个简单的按码点切割函数:
function sliceByCodePoint(text: string, end: number) { return Array.from(text).slice(0, end).join(''); }Array.from会把字符串转成码点数组,这样 emoji 和生僻字都不会被切坏。对于中文,按码点切比按词切更自然,AI 输出也不存在严格的中文分词,所以这里不需要引入分词库。
第三,超长文本的降级策略。如果一条回复特别长(比如几千字),打字机一直逐字渲染会显得拖沓。我的经验是:接收速度大于每 30ms 一个字符时,可以动态加大步长,比如从+1变成+2,让整体节奏保持稳定。用户体感是“输出流畅”,而不是“一个字一个字数着蹦”。
5. 常见问题与排查实录
5.1 idle timeout waiting for SSE:是谁掐断了连接
先看一条我在项目中真实遇到过的报错,也是搜索热词里的老朋友:
stream disconnected before completion: idle timeout waiting for sse这句话的意思是:流还没结束,连接就被关闭了,原因是“idle timeout”——空闲超时。也就是说,连接建立了,但一段时间内没有数据流动,被中间层判定为“闲置连接”然后主动断开了。
谁干的?大多数情况下是 Nginx 或者负载均衡器。Nginx 默认的proxy_read_timeout是 60 秒,如果 60 秒内后端没有向客户端写入任何数据,Nginx 就会主动断开这条连接。
但 AI 场景恰恰容易踩中这个超时:比如用户问了一个复杂问题,模型需要“思考”几十秒,而测试环境后端在思考阶段没有输出任何内容;又比如服务端犯懒,没发心跳;再比如后端在做函数调用、工具选择,迟迟没有生成文字。前端看起来就是“转圈 60 秒后突然报错”。
排查思路有两层。
第一层是让连接不空闲。服务端在生成阶段也要定期发送心跳。SSE 的注释行就是为这种情况设计的,比如每 15 秒发一行:
: ping前端解析时要跳过注释行,这个我前面已经提到了。
第二层是调整代理层超时。如果是 Nginx,需要在/api/chat对应的 location 里加:
location /api/chat { proxy_pass http://backend; proxy_set_header Connection ''; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_buffering off也很关键。如果开着缓冲,Nginx 会把后端吐出来的数据攒一大块再发给客户端,流式体验会被破坏,用户看到的是“憋了半天,突然蹦出来一大段”。这个不关掉,前面费劲做的打字机效果会大打折扣。
5.2 其他高频问题速查表
除了 idle timeout,还有几个高频问题我整理成一个表,方便你排查时对照:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
页面出现�乱码字符 | TextDecoder 未开启 stream 模式,UTF-8 被截断 | decoder.decode(value, { stream: true }) |
| 内容突然从中间重复显示 | 断线重连后未按 offset 去除重复段,直接追加 | 按 offset 过滤重复部分,只追加偏移量之后的内容 |
| Markdown 代码块样式来回跳 | 未闭合标签直接渲染 | 延迟渲染尾部内容,或修补未闭合标签 |
| 点击停止生成后仍在渲染 | 未处理 AbortError 后才到达的 chunk | abort 后立即置finished标志,后续数据全部忽略 |
| 打字机速度快慢不一 | 步长固定,网络快慢影响体验 | 动态步长,或让渲染节奏独立于接收节奏 |
| 流式请求一直 pending 不返回 | 代理缓冲未关闭,或后端未刷新响应 | Nginx 关闭 buffering,后端每生成一小段就 flush |
| 断线后浏览器不再重连 | 用 fetch 实现后没写重连逻辑 | 单独封装重连函数,指数退避重试 |
5.3 实测下来的一些建议
最后分享几条实操建议,都是被项目验证过的。
第一,前端收到数据后,先在内存里做状态更新,再触发渲染。不要直接依赖组件的响应式系统去“每收到一个 chunk 就重新渲染整棵树”。有一次我把 chunk 直接 push 到数组里,每 push 一次整个聊天列表重新渲染一次,模型输出快的时候页面明显卡顿。后来改成“接收缓存”和“渲染队列”分离,接收进内存缓存,渲染只读队列中的数据,问题立刻消失。
第二,重连要控制频率,别让服务端被打爆。我的重试策略是指数退避:第一次 1 秒、第二次 2 秒、第三次 4 秒,最多重试 5 次。超过 5 次就提示用户“连接不稳定,请点击重试”,而不是无限重试。无限重试在用户无感知的情况下会把服务端拖垮。
第三,调试 SSE 时,先看 Network 面板再写代码。你可以直接打开浏览器的开发者工具,刷新页面后发起一次对话,看请求响应体。如果是text/event-stream,响应体会像流水一样逐段增长,每一段之间有空行。把这个原始数据一眼看明白了,前端代码只是把这种格式翻译成 JavaScript 对象而已。调试的时候最容易犯的错误,是后端返回的根本不是标准 SSE 格式,而是把整段 JSON 一次性返回,前端解析半天全是空。
第四,尽量把“连接管理”和“UI 渲染”拆成两个模块。连接层只管建立连接、解析消息、抛数据;渲染层只管把数据消费成打字机效果。不要写一个巨型组件把所有事都干了,不然断点续传的状态和渲染状态纠缠在一起,出一轮问题改一轮,越改越乱。
我个人的体会是,AI 前端这块并不需要什么高深的魔法,核心就是把 HTTP、流、状态管理、渲染几个基础功打扎实。SSE 流式输出考的是你对协议和浏览器 API 的熟悉程度;断点续传考的是对连接状态和数据一致性的控制能力;打字机渲染考的是对交互细节的拿捏。这三个能力合在一起,基本就是现阶段 AI 应用前端最值钱的那部分技能了。面试的时候如果能把idle timeout、Markdown 标签未闭合、TextDecoder stream 模式这几个点讲清楚,面试官基本就能确认你是真上手做过,而不是只会背题。