面试知识库
高 进阶

AI对话前端流式渲染#

一句话答案#

前端流式渲染要处理四件事:读流(fetch + ReadableStream 手动切 SSE 帧,残帧留到下一轮)、画(已完成的 Markdown 块缓存不动,只重渲染最后一块,未闭合的代码块和表格先补齐或暂缓,用 requestAnimationFrame 合并更新)、滚(贴底时跟随,用户上滑就停)、停(AbortController 断开请求,服务端顺着取消上游)。消息要建成「按 part 组织的状态机」,而不是一个不断变长的字符串。

核心要点

SSE / WebSocket 的传输选型和服务端要点见 流式输出与实时交互,本篇只讲浏览器这一侧:拿到字节流之后怎么变成稳定、不闪、能停的界面。

1. 三种读流写法#

写法能做什么限制适用
EventSource浏览器自动解析 event:/data:/id:,断线自动重连并带 Last-Event-ID只能 GET,不能带自定义 header(Authorization 只能放 cookie 或 query)订阅已存在的任务事件流(task_id 已知)
fetch + ReadableStreamPOST 请求体、任意 header、AbortController 取消帧解析、重连、续传全要自己写对话发送即返回流(主流做法)
WebSocket双向,同一条连接上发消息、取消、回复确认自己定帧格式、心跳、重连需要中途插话、语音、确认卡往返频繁

fetch 手动解析 SSE 的关键点:网络 chunk 的边界和 SSE 帧的边界没有关系,一个 chunk 可能半帧、也可能三帧;多字节中文可能被切在两个 chunk 之间。

生产里可以直接用现成的 SSE 解析库,但面试要能说清:按空行切帧、残帧缓存、流式解码、忽略注释行这四点。

2. 消息与工具事件的状态建模#

一条助手消息不是一段文字,而是有序的 part 列表:文本、推理、工具调用、确认卡、错误。用判别联合(discriminated union)建模,所有事件经一个纯函数 reducer 合进状态,渲染层只读状态:

要点:

  • 工具 part 按 callId 定位,不按数组下标——并行工具调用的 delta 会交错到达。
  • 前端对 argsText 只做展示(可用容错的 partial JSON 解析显示已到的字段),执行判断永远在服务端。
  • status 的终态集合前后端共用一份定义;「stopped」(用户点停止)和「error」(服务端出错)要分开,UI 文案和是否允许「继续生成」都不同。
  • 高频 delta 下别每个事件都 setState:先把事件累积到 useRef 里的缓冲,每帧 requestAnimationFrame 统一 reduce 一次再提交,React 渲染次数从「每 token 一次」降到「每帧最多一次」。

3. 增量 Markdown:为什么会闪,怎么不闪#

朴素做法是每次把累计文本整段交给 Markdown 渲染器。问题有三个:

现象原因处理
越到后面越卡每帧重新解析全文,长回答总开销随长度平方增长;代码高亮更贵按块切分:已完成的块(遇到空行 / 围栏闭合)memo 缓存,只重渲染最后一个未完成块
代码块闪烁只到了开头的 ```,后面全文被当成代码;闭合那一刻整页排版跳变统计围栏数量,奇数时在渲染副本末尾临时补一个闭合围栏;未完成的代码块先不做语法高亮
表格闪烁表头行到了、分隔行 |---| 还没到时,按段落渲染;分隔行一到突然变表格末行不以换行结尾时先不渲染;识别到表头候选行时等分隔行到达再渲染
链接 / 加粗半截**重点 未闭合时显示成星号同样按「补齐或暂缓最后一行」处理,或接受短暂的原文显示
function splitBlocks(md: string): { done: string[]; tail: string } {
  // 简化版:按空行切块,但围栏代码块内部的空行不算块边界
  const blocks: string[] = []; let cur = ""; let inFence = false;
  for (const line of md.split("\n")) {
    if (/^\s*```/.test(line)) inFence = !inFence;
    if (!inFence && line.trim() === "" && cur) { blocks.push(cur); cur = ""; continue; }
    cur += (cur ? "\n" : "") + line;
  }
  return { done: blocks, tail: cur };
}

function patchTail(tail: string): string {
  const fences = (tail.match(/^\s*```/gm) || []).length;
  return fences % 2 === 1 ? tail + "\n```" : tail;   // 只改渲染副本,不改原文
}
ts

done 里每块用稳定 key(块序号)+ React.memo 渲染,已经画好的块不会再动;只有 tail 每帧重算。另外两个细节:

  • 打字机节奏:token 常常一坨一坨到,直接显示会一顿一顿。可以用一个字符队列按固定速率吐出,但队列积压过多时要加速清空,否则流结束了屏幕还在慢慢打字,用户会以为没结束。
  • XSS:模型输出等同于不可信输入。Markdown 渲染器关掉原始 HTML,确需渲染 HTML 时先过 DOMPurify 这类 sanitizer;链接和图片地址只放行 http(s):、mailto: 等白名单协议,过滤 javascript:;链接加 rel="noopener noreferrer"。

4. 自动滚动与用户上滑暂停#

规则只有一条:用户在底部附近时跟随,离开底部就不跟。常见 bug 是每来一个 token 就 scrollTo(bottom),用户想往上翻看都翻不动。

function useStickToBottom(ref: React.RefObject<HTMLElement>) {
  const stick = React.useRef(true);
  React.useEffect(() => {
    const el = ref.current!;
    const onScroll = () => {
      const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
      stick.current = gap < 40;                        // 阈值容忍亚像素误差和最后一行未画完
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    const ro = new ResizeObserver(() => { if (stick.current) el.scrollTop = el.scrollHeight; });
    ro.observe(el.firstElementChild!);                 // 内容长高时才滚,而不是每个 token 都滚
    return () => { el.removeEventListener("scroll", onScroll); ro.disconnect(); };
  }, [ref]);
  return stick;
}
ts
  • 程序自己设置 scrollTop 也会触发 scroll 事件,但那时 gap 接近 0,stick 仍是 true,不会误判。
  • 离开底部时显示「回到底部 / 有新内容」按钮,点击后恢复跟随。
  • 用户发送新消息时强制回到底部并恢复跟随。
  • 代码块、表格内部横向滚动不影响这个判断;图片、Mermaid 这类异步撑高的内容靠 ResizeObserver 统一处理。

5. 停止与重新生成#

停止要三层都断,前端只负责第一层:

const ctrl = new AbortController();
try {
  const res = await fetch("/api/chat", { method: "POST", body, signal: ctrl.signal });
  for await (const ev of readSSE(res)) dispatch(JSON.parse(ev.data));
} catch (err) {
  if ((err as DOMException).name === "AbortError") markStopped();  // 用户主动停,不是错误
  else markError(err);
}
// 停止按钮:ctrl.abort()
ts
  • abort() 后已渲染的部分保留,消息标 stopped。服务端靠连接断开感知取消,再取消上游模型调用(见 流式输出与实时交互)。
  • 如果用的是「任务入队 + 订阅事件流」的架构,断开订阅不等于取消任务(任务在 worker 里跑)——停止按钮要额外调一个 POST /cancel。断线重连也就不会误杀任务,续传见 实时推送断线续传与多实例事件转发。
  • 重新生成:删掉(或折叠为历史版本)最后一条助手消息,用同样的历史再发一次。要注意上一轮已经执行的写操作工具不会被撤销,重生成前如果那一轮有写操作,应提示用户或禁用重生成。
  • 竞态:连点「重新生成」或停止后立刻发新消息,旧流的晚到事件可能写进新消息。每次请求生成 requestId,reducer 丢弃不属于当前 requestId 的事件。

6. Vercel AI SDK 这类库替你做了什么#

以 Vercel AI SDK 为例(写作时官方文档为 7.x;5.0 起 useChat 改为 transport 架构、不再管理输入框状态,大版本之间 API 名有变化,以官方文档为准),它把上面几件事打包了:

  • 服务端:streamText 调模型,toUIMessageStreamResponse() 把结果转成 UI message stream。这个协议基于 SSE,帧类型包括 text-start/text-delta/text-end、reasoning-*、tool-input-start/tool-input-delta/tool-input-available、tool-output-available、start-step/finish-step、error 等,以 data: [DONE] 结束;自研后端要兼容时响应头带 x-vercel-ai-ui-message-stream: v1。
  • 客户端:useChat 返回 messages(UIMessage,按 parts 组织)、sendMessage、regenerate、stop、status(submitted / streaming / ready / error)等,内部做了流解析、按 part 组装消息、取消、重新生成、错误状态。
  • 工具调用:每个工具对应一种 tool-<工具名> part,带状态(参数流式中、参数就绪、有输出、被拒绝),可以在客户端执行工具(addToolOutput)或渲染成交互组件;6.0 起有工具执行前的审批流程(tool-approval-request 帧 + addToolApprovalResponse)。
  • 续传:useChat({ resume: true }) 在挂载时尝试重连正在进行的流,但服务端要自己提供 GET 恢复接口,并借助 resumable-stream 包和 Redis 保存流;这种模式下 stop() 只断开当前连接、不会取消服务端生成,要另做停止接口。

它不替你做的:增量 Markdown 的块缓存和未闭合处理(交给你选的渲染组件)、滚动策略、续传所需的服务端存储、多实例事件转发、业务确认的幂等。自研后端事件协议(如 AG-UI 或自定义 event: 类型)时,也可以只借鉴它的状态建模思路。

面试回答(2分钟版)

前端流式渲染我分四块讲。第一是读流:原生 EventSource 只能 GET、不能带 Authorization,所以对话接口一般用 fetch 加 ReadableStream,TextDecoderStream 流式解码防止中文被切坏,按空行切 SSE 帧,残帧留到下一个 chunk,注释行当心跳忽略。第二是状态建模:一条助手消息是 part 列表,文本、推理、工具调用、确认卡各是一种 part,工具 part 按 callId 定位,因为并行工具的 delta 会交错;事件先攒进 ref,每帧用 requestAnimationFrame 统一 reduce 一次,避免每个 token 触发一次渲染。第三是 Markdown:整段重解析会越来越卡,我按块切分,已完成的块 memo 住,只重渲染最后一块;代码围栏数量是奇数就在渲染副本末尾补一个闭合,表格等分隔行到了再渲染,避免闪烁。第四是交互:滚动只在贴底时跟随,用 ResizeObserver 在内容长高时滚,用户上滑就停并显示回到底部;停止用 AbortController,已渲染的保留、标成 stopped,和服务端错误分开;如果是任务入队的架构,断开订阅不等于取消,要另调 cancel 接口。重新生成要用 requestId 丢掉旧流的迟到事件。Vercel AI SDK 的 useChat 帮你做了流解析、按 part 组装消息、stop 和 regenerate,续传也有 resume 选项,但滚动、续传的服务端存储、确认幂等还得自己做。结合项目时可以讲:前端用哪种传输和事件协议、连接和提交任务谁先谁后、确认卡怎么作为独立 part 渲染,以及卡顿或闪烁是用什么手段定位的。

追问与易错

追问方向:

  • “为什么不直接用 EventSource?” → EventSource 只支持 GET、不能设置自定义请求头,对话要 POST 消息体、带 Bearer token;如果硬把消息塞进 query 用 GET 发,它的自动重连会把同一条消息再发一遍。只有订阅已存在的任务事件流(按 task_id GET)时才适合用。
  • “一个网络 chunk 里有半个 SSE 帧怎么办?” → 解析器维护字符串缓冲,只处理到最后一个 \n\n 为止,剩下的留到下一次 read;解码用 TextDecoderStream 或 TextDecoder.decode(chunk, { stream: true }),否则多字节字符会被切成乱码。
  • “长回答渲染越来越卡,怎么定位和优化?” → 用 React Profiler 看每帧是不是在全文重解析和重高亮;优化是按块切分 + memo 已完成块 + rAF 合并更新,代码高亮只在围栏闭合后做。
  • “流式时代码块为什么闪?” → 开头的围栏到了、结尾还没到,渲染器把后面所有内容当代码;闭合时排版突变。做法是在渲染副本上补一个闭合围栏,原始文本不改。
  • “用户往上翻历史时新内容一直把页面拽到底,怎么修?” → 用滚动事件算距底部距离决定是否跟随,只在跟随状态下、并且由 ResizeObserver 发现内容变高时才滚动;用户上滑后显示「回到底部」按钮。
  • “点了停止,后端怎么知道?” → fetch 被 abort 后 TCP 连接关闭,服务端写出失败或收到断开事件,再把取消传给上游模型请求;任务队列架构下连接和任务解耦,必须显式调取消接口。
  • “工具调用参数在前端能不能 JSON.parse 后直接用?” → 不能,参数是流式碎片,完整前 parse 会失败;前端只能用容错解析做预览,执行和校验都在服务端,前端展示的参数也不能作为确认依据,确认卡应展示服务端生成的快照。
  • “重新生成时要注意什么?” → 上一轮如果已经执行过写操作(下单、发邮件),重新生成不会撤销它;要么禁用、要么提示。另外用 requestId 过滤旧请求的迟到事件,防止两轮内容串在一起。

易错:

  • ❌ “每个 token 都 setState 最实时” → 渲染次数和 token 数同量级,长回答必卡;按帧合并对肉眼没有区别。
  • ❌ “前端 abort 了就等于停止生成” → 服务端不处理断开事件、或任务在独立 worker 里跑时,模型会继续生成并计费。
  • ❌ “用 dangerouslySetInnerHTML 渲染模型输出的 HTML 最快” → 模型输出可被 prompt injection 控制,等于开了 XSS 口子。