大模型对话前端第一版:先跑通流式响应和取消
第一版大模型对话界面先验证一条主链路:接收流式响应、正确解码、可取消地渲染,并在异常时给出可恢复的提示。本文以流式 Markdown 的前端实现为例,讨论这条链路的最小边界。
fetch与TextDecoder足以搭起原型,但还要处理不完整的 UTF-8 字节、被截断的事件以及高频更新带来的重复渲染。状态机、Worker 等机制应由测得的瓶颈决定,而不是一开始全部引入。
MVP 核心链路拆解
在第一版交付中,前端最核心的链路只有一条:接收服务端 Server-Sent Events (SSE) 字节流,逐块解码后通过平滑打字机队列推入状态,同时控制 Markdown DOM 树的渲染频率。
flowchart TD A[SSEResponse 字节流] --> B(ReadableStream DefaultReader) B --> C{TextDecoder Chunk 解码} C --> D[打字机缓冲区队列 Queue] D --> E{requestAnimationFrame 调度器} E --> F[更新 React/Vue State] F --> G[DOM 增量渲染与自动滚动] G --> H{用户触发 AbortController?} H -- 是 --> I[CancelToken 中断底层 HTTP 链接] H -- 否 --> J[流传输结束释放 Worker/Timer]整个链路涉及三个主要瓶颈点:
- 网络层:
EventSourceAPI 不支持 POST 请求与自定义 Header,必须改用原生fetch结合ReadableStream。 - 缓冲层:后端返回的 Chunk 尺寸不均,单次推送可能包含半个 UTF-8 字符或 incomplete JSON,直接赋值给 UI 会导致乱码闪烁。
- 渲染层:高频
setState触发 React Re-render,导致 CPU 占用率飙升至 100%。
关键代码取舍与实现
为了在最小代码量下解决高频渲染卡顿,第一版不需要引入复杂的 Web Worker,而是采用基于requestAnimationFrame的缓冲队列(Buffer Queue)。
// sse_client.ts export interface SSEOptions { url: string; headers: Record<string, string>; body: object; onChunk: (text: string) => void; onError: (err: Error) => void; signal?: AbortSignal; } export async function fetchSSEResponse(options: SSEOptions): Promise<void> { const { url, headers, body, onChunk, onError, signal } = options; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers, }, body: JSON.stringify(body), signal, }); if (!response.ok || !response.body) { throw new Error(`HTTP 异常: ${response.status} ${response.statusText}`); } 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 }); const lines = buffer.split('\n'); // 保留未完成的最后一行 buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith(':')) continue; if (trimmed === 'data: [DONE]') return; if (trimmed.startsWith('data: ')) { try { const parsed = JSON.parse(trimmed.slice(6)); const delta = parsed.choices?.[0]?.delta?.content || ''; if (delta) onChunk(delta); } catch (e) { // 避免单个解析失败阻断后续流处理 console.warn('Chunk 解析不完整:', trimmed); } } } } } catch (err: any) { if (err.name === 'AbortError') { console.log('请求已被用户主动中断'); } else { onError(err); } } }在 UI 层,直接将onChunk的回调关联到组件 State 是灾难性的。按照 50ms 一次 Chunk 的频率,一秒钟触发 20 次组件树重绘。我们可以通过平滑渲染器截断频率。
// useSmoothStream.ts import { useState, useRef, useEffect } from 'react'; export function useSmoothStream() { const [displayText, setDisplayText] = useState(''); const queueRef = useRef<string[]>([]); const frameRef = useRef<number | null>(null); const pushChunk = (chunk: string) => { queueRef.current.push(...chunk.split('')); if (!frameRef.current) { scheduleFlush(); } }; const scheduleFlush = () => { frameRef.current = requestAnimationFrame(() => { if (queueRef.current.length > 0) { // 每次渲染取出最多 3 个字符,保持流畅打字感 const batch = queueRef.current.splice(0, 3).join(''); setDisplayText((prev) => prev + batch); scheduleFlush(); } else { frameRef.current = null; } }); }; const reset = () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); frameRef.current = null; queueRef.current = []; setDisplayText(''); }; return { displayText, pushChunk, reset }; }第一版中果断放弃的技术点:
- 离线 VectorDB 本地检索:完全在服务端处理,前端只负责呈现。
- 自定义 Markdown AST 增量解析引擎:直接选用轻量
marked或markdown-it加防抖,不写自定义 Parser。 - 复杂的多会话并行 Worker:MVP 阶段单次窗口仅支持单条活跃 Request。
现场诊断与优化验证
线上验证时,不能凭感觉判断“卡不卡”。用 Chrome DevTools 的 Performance 面板录制流式输出过程中的 CPU Profile 才是标准动作。
打开 Chrome DevTools,切到Performance标签页,点击 Record,开始一次 1000 字的大模型文本生成,录制 15 秒后停止。
在没有优化前,Profile 显示大量的Recalculate Style和Layout频次高达 45 次/秒,Task列表中长任务(Long Task > 50ms)占比超过 35%。使用requestAnimationFrame批处理后:
Layout频次降至 16 次/秒以内。- JS 执行线程的火焰图中,
setDisplayText导致的重绘耗时从 320ms 降至 42ms。 - 内存占用在整个流式传输期间稳定在 38MB 左右,没有任何内存泄漏曲线。
做 MVP 的核心思维是:给网络层加终止器(AbortController),给渲染层加缓冲闸门(rAF),给异常加降级保护。三者具备,第一版就立住了。