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 + ReadableStream | POST 请求体、任意 header、AbortController 取消 | 帧解析、重连、续传全要自己写 | 对话发送即返回流(主流做法) |
| WebSocket | 双向,同一条连接上发消息、取消、回复确认 | 自己定帧格式、心跳、重连 | 需要中途插话、语音、确认卡往返频繁 |
fetch 手动解析 SSE 的关键点:网络 chunk 的边界和 SSE 帧的边界没有关系,一个 chunk 可能半帧、也可能三帧;多字节中文可能被切在两个 chunk 之间。
export async function* readSSE(res: Response): AsyncGenerator<{ event: string; data: string; id?: string }> {
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader(); // 流式解码,不会切坏多字节字符
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += value.replace(/\r\n/g, "\n");
let idx: number;
while ((idx = buf.indexOf("\n\n")) >= 0) { // 空行 = 一帧结束
const raw = buf.slice(0, idx);
buf = buf.slice(idx + 2); // 残帧留在 buf 里等下一个 chunk
let event = "message", id: string | undefined;
const data: string[] = [];
for (const line of raw.split("\n")) {
if (line.startsWith(":")) continue; // 注释帧,心跳用
const [k, ...rest] = line.split(":");
const v = rest.join(":").replace(/^ /, "");
if (k === "event") event = v;
else if (k === "data") data.push(v); // 多行 data 按规范用 \n 拼接
else if (k === "id") id = v;
}
if (data.length) yield { event, data: data.join("\n"), id };
}
}
}ts生产里可以直接用现成的 SSE 解析库,但面试要能说清:按空行切帧、残帧缓存、流式解码、忽略注释行这四点。
2. 消息与工具事件的状态建模#
一条助手消息不是一段文字,而是有序的 part 列表:文本、推理、工具调用、确认卡、错误。用判别联合(discriminated union)建模,所有事件经一个纯函数 reducer 合进状态,渲染层只读状态:
type ToolPart = {
type: "tool"; callId: string; name: string;
argsText: string; // 参数 JSON 碎片,只做预览
state: "streaming-args" | "running" | "done" | "error";
summary?: string;
};
type Part =
| { type: "text"; text: string }
| { type: "reasoning"; text: string }
| ToolPart
| { type: "confirm"; cardId: string; status: "pending" | "approved" | "rejected" | "expired" };
type AssistantMsg = {
id: string; role: "assistant";
status: "streaming" | "done" | "stopped" | "error" | "interrupted";
parts: Part[];
};
type Ev =
| { type: "text_delta"; text: string }
| { type: "tool_call_start"; call_id: string; name: string }
| { type: "tool_call_delta"; call_id: string; args_delta: string }
| { type: "tool_result"; call_id: string; ok: boolean; summary: string }
| { type: "run_end"; finish_reason: string }
| { type: "error"; message: string };
function reduce(m: AssistantMsg, e: Ev): AssistantMsg {
const parts = [...m.parts];
const last = parts[parts.length - 1];
switch (e.type) {
case "text_delta": // 连续文本并入最后一个 text part;
if (last?.type === "text") parts[parts.length - 1] = { ...last, text: last.text + e.text };
else parts.push({ type: "text", text: e.text }); // 工具调用之后的文本另起一段,保持时间顺序
return { ...m, parts };
case "tool_call_start":
parts.push({ type: "tool", callId: e.call_id, name: e.name, argsText: "", state: "streaming-args" });
return { ...m, parts };
case "tool_call_delta":
case "tool_result":
return { ...m, parts: parts.map(p => p.type === "tool" && p.callId === e.call_id
? (e.type === "tool_call_delta"
? { ...p, argsText: p.argsText + e.args_delta }
: { ...p, state: e.ok ? "done" : "error", summary: e.summary })
: p) };
case "run_end": return { ...m, status: "done" };
case "error": return { ...m, status: "error" };
}
}ts要点:
- 工具 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; // 只改渲染副本,不改原文
}tsdone 里每块用稳定 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()tsabort()后已渲染的部分保留,消息标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 口子。