ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

大模型对话前端第一版:先跑通流式响应和取消

大模型对话前端第一版:先跑通流式响应和取消 大模型对话前端第一版先跑通流式响应和取消第一版大模型对话界面先验证一条主链路接收流式响应、正确解码、可取消地渲染并在异常时给出可恢复的提示。本文以流式 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: Recordstring, string; body: object; onChunk: (text: string) void; onError: (err: Error) void; signal?: AbortSignal; } export async function fetchSSEResponse(options: SSEOptions): Promisevoid { 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 useRefstring[]([]); const frameRef useRefnumber | 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。复杂的多会话并行 WorkerMVP 阶段单次窗口仅支持单条活跃 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给异常加降级保护。三者具备第一版就立住了。
返回列表