FEATURED · 精选文章

大模型长回复流式渲染提速:从卡顿到流畅的完整方案

发布时间 / 2026/8/29 4:04:43
来源 / 创域科博编辑部
栏目 / 资讯中心
大模型长回复流式渲染提速:从卡顿到流畅的完整方案 在接入 Claude 这类大模型服务的流式接口时网页端和桌面端最容易被低估的问题不是首字延迟而是长回复的渲染速度。用户等了几秒才看到第一段内容却在后面几千字输出时遭遇页面越来越卡、光标跳动、滚动条不听使唤、甚至内存持续上涨。所谓“Claude 网页与桌面端长回复流式渲染提速”正是要解决这个阶段的问题首字已经到达但页面跟不上 token 的产出速度。本文会从流式渲染链路、瓶颈定位、网页端优化、桌面端优化、效果验证和常见排错几个角度把一个长回复渲染提速方案讲清楚并给出可以直接改造的代码思路。长回复场景在 Claude 网页版和桌面端并不少见一段完整的代码评审、一篇数千字的分析报告、一份带 Markdown 表格的实施方案。和短对话不同长回复在输出过程中会反复触发页面更新任何一处低效实现都会被放大。下面先从基础链路开始搞清楚慢在哪里。1. 先理解长回复场景里的流式渲染为什么用户会感到卡顿1.1 流式渲染链路从 token 到像素Claude 网页版或桌面端本质上是一个客户端应用通过 HTTP 流式接口接收模型生成的增量内容。与普通接口等完整响应返回后再渲染不同流式接口会把一段长文本切成若干 chunk例如 SSEServer-Sent Events消息一个接一个到达客户端。一次完整链路大致是服务端把模型输出切分成多个增量事件。客户端网络层收到一个或多个数据块准备交给业务层。业务层解析出文本增量并更新当前内容状态。前端框架根据状态变化重新渲染组件。浏览器把新的节点插入页面触发布局和绘制。用户看到新文字出现同时滚动位置可能被调整。这里有一个很容易忽略的点网络层是一回事渲染层是另一回事。很多开发者以为“接口返回慢”才会造成等待实际上在长回复场景里服务端输出速度往往远大于浏览器渲染速度瓶颈经常发生在第 4 到第 6 步。用一句话概括长回复渲染慢不是网络字节没到而是浏览器“画不过来”。1.2 长回复慢的典型现象和可测量指标在实际页面中长回复慢通常表现为输入几个字后光标或选中区域发生跳动。内容和 Markdown 渲染结果不一致有时先显示纯文本再显示标题、加粗或代码块。滚动条拖动困难页面在持续输出时不断重新布局。输入框到回复内容的切换卡顿点击复制按钮要等一会儿才有反馈。长时间输出后内存明显上升甚至出现白屏。这些现象可以量化为几个关键指标指标含义优化前常见情况首块渲染时间从收到第一个 chunk 到页面上出现第一段文字的时间0.5 秒到 2 秒增量更新耗时每收到一个 chunk 后页面完成渲染的耗时几十毫秒到几百毫秒随内容变长越来越慢长任务数量Performance 面板中的 Long Tasks 次数在长文本中高频出现页面总帧时间输出期间的平均帧间隔超过 100ms明显掉帧内存峰值长回复结束后 Heap Size 大小持续上涨GC 后仍不回落如果优化后长回复场景的首块渲染时间从 1.2 秒降到 0.3 秒增量更新耗时从 200 毫秒降到 50 毫秒以下总输出时间从 20 秒缩短到 5 秒左右用户主观体验就是“提速了大约 4 倍”。这类倍数不是固定的它与内容长度、设备性能、浏览器内核、桌面端封装方式都有关系但在普通中端设备上是一个可复现的目标。2. 定位瓶颈长回复渲染慢通常卡在这四层2.1 网络接收与协议解析层chunk 到达后立刻使用是否合理流式接口的典型事件格式类似下面这样data: {type:content_block_delta,delta:{type:text_delta,text: 这是一段增量文本}} data: {type:content_block_stop,index:0}客户端收到content_block_delta后需要把delta.text拼接到当前内容中。初学者最容易犯的错误是每收到一条消息就立刻把完整内容交给状态管理然后触发重渲染。这种做法在短回复中没问题但在长回复中会导致两个后果状态更新频率过高渲染任务被拆成大量微任务阻塞主线程。每一条消息都携带“当前完整内容”如果内容越来越长序列化和传输成本也会增加加重网络层负担。合理做法是在业务层先做缓冲把短时间内到达的增量合并成批量数据再交给渲染层。后面第 3 章会展示具体实现。2.2 状态更新与组件重渲染层全量 setState 是最大隐患以 React 前端为例错误写法通常是const [content, setContent] useState(); function onDelta(deltaText: string) { setContent((prev) prev deltaText); }这段代码逻辑看起来没有错但问题在于onDelta可能每秒被调用几十次而每次调用都会让content变化组件需要重新执行渲染函数。尤其当content很长时React 还要递归比较前后两个大的字符串节点内存和 CPU 都会被大量消耗。更严重的是如果页面同时展示 Markdown 渲染结果例如在 JSX 中写Markdown{content}/Markdown那么每次setContent都会触发完整 Markdown 解析。无论 Markdown 库本身多快在长文本上重复做全量解析都会成为性能瓶颈。2.3 Markdown 解析与 DOM 构建层全量解析带来的重计算Claude 长回复通常包含标题、列表、代码块、表格、行内代码等 Markdown 元素。在流式输出过程中Markdown 语法往往是不完整的内容刚开始可能只有## 实要等下一块才变成## 实现方案。代码块可能只输出了开头的 还没有闭合。表格可能只有表头还没有数据行。如果每次都把不完整的 Markdown 全文交给解析器解析器每次都会做大量重复工作而且最终生成的 DOM 也不稳定。用户会看到内容“跳一下”再稳定下来这就是全量解析的副作用。优化方向有两个在文本累积到一定长度时再做解析而不是每次增量都解析。把“正在输出的尾部内容”和“已完成且稳定的头部内容”分开处理只对新增部分做增量解析已解析内容保持不变。2.4 滚动体验与事件处理层主线程被低效滚动监听抢占长回复输出时页面往往需要自动滚到最新位置。常见的低效写法是streamContainer.addEventListener(scroll, () { if (isNearBottom) { scrollToBottom(); } });这个监听器在每次滚动时都会被触发而流式输出过程中滚动事件非常频繁。如果在事件回调里做复杂计算比如读取布局、修改 DOM 高度、调用scrollTo主线程会被大量占满。更合理的方法是只在内容追加后判断一次是否需要滚动而不是让滚动事件驱动更新。这里还要注意不要每次强制滚动到底部否则用户想往上翻看前面的内容时会被反复拉回底部。3. 网页端提速方案把“全量追加”改成“分片批量渲染”3.1 错误的写法收到 delta 立刻更新完整内容先看一个典型的低效实现这种实现很容易让长回复变卡// 伪代码每收到一个 delta 就更新完整文本并重新渲染 let fullText ; socket.onmessage (event) { const data JSON.parse(event.data); if (data.type content_block_delta) { fullText data.delta.text; renderMarkdown(fullText); // 全量解析全量渲染 } };问题在于renderMarkdown(fullText)被高频触发而且每次都处理完整文本。当文本超过 3000 字时这个函数单次执行时间可能达到几十毫秒输出 5000 字时已经能明显感到掉帧。3.2 核心优化一缓冲区合并与 requestAnimationFrame 批量提交优化思路是把网络消息和渲染拆成两条链路网络层持续接收增量写入缓冲区渲染层通过requestAnimationFrame或setTimeout定期消费缓冲区合并成一次更新。let buffer ; let isScheduled false; let fullText ; function appendDelta(deltaText: string) { buffer deltaText; if (!isScheduled) { isScheduled true; requestAnimationFrame(flushBuffer); } } function flushBuffer() { if (buffer) { fullText buffer; buffer ; renderMarkdown(fullText); } isScheduled false; }这样做的原因是浏览器每一帧之前会执行requestAnimationFrame回调多个网络消息可以在同一帧内合并处理避免每次网络消息都产生一次独立的渲染任务。这里要注意一个细节renderMarkdown(fullText)仍然在渲染完整内容所以在长回复中还是不够。需要配合第二种优化。3.3 核心优化二长回复 Markdown 分块解析更彻底的做法是把内容分成“稳定区”和“缓冲区”。稳定区已经接收到足够长度并且当前解析结果稳定不再变化的文本。缓冲区最近到达的文本可能是不完整的 Markdown 片段。当缓冲区积累到一定阈值例如 200 个字符时把缓冲区内容追加到稳定区然后只对新增块做解析最后把新解析出的 DOM 片段插入到页面。const STABLE_THRESHOLD 200; let stableText ; let pendingText ; let container: HTMLElement; function appendDelta(deltaText: string) { pendingText deltaText; if (pendingText.length STABLE_THRESHOLD) { commitStableText(); } } function commitStableText() { stableText pendingText; const html renderMarkdownBlock(pendingText); const wrapper document.createElement(div); wrapper.innerHTML html; container.appendChild(wrapper); pendingText ; }这种方案有三个好处避免全量解析单次解析内容长度可控。已解析的 DOM 节点不被反复替换用户看到的文字更稳定。滚动位置更容易保持因为新增节点只是在末尾追加。但它也有一个前提Markdown 的跨块特性必须被处理。比如代码块刚开头的 可能落在上一个块的末尾如果直接切块会导致代码块渲染错乱。常见处理方式是切块时不要把内容切在代码块、列表或表格中间可以先判断当前是否处于未闭合的 Markdown 标记中如果处于则不切分继续等下一块。3.4 核心优化三结合虚拟列表控制 DOM 数量如果一段回复非常长例如 8000 字以上的报告即使分块解析页面 DOM 节点数量也会很大。这时可以结合虚拟列表只渲染可视区域内的内容块。虚拟列表的关键是放弃“一次性渲染全部节点”的思路只保留可视区域前后若干块节点其余节点用占位高度代替。const container document.getElementById(stream-content); const blockHeights new Mapnumber, number(); function renderVirtualList(blocks: string[], startIndex: number, endIndex: number) { container.innerHTML ; // 在容器上方填充高度占位 const topPlaceholder document.createElement(div); topPlaceholder.style.height ${computeHeight(0, startIndex)}px; container.appendChild(topPlaceholder); for (let i startIndex; i endIndex; i) { const block document.createElement(div); block.innerHTML renderMarkdownBlock(blocks[i]); blockHeights.set(i, block.offsetHeight); container.appendChild(block); } const bottomPlaceholder document.createElement(div); bottomPlaceholder.style.height ${computeHeight(endIndex, blocks.length)}px; container.appendChild(bottomPlaceholder); }虚拟列表适合回复内容极长、需要跳转和选择文本的场景但不适合每一块高度都不稳定、并且用户正在实时阅读的情况。对于 Claude 网页版和桌面端最常见的长回复场景其实先做分块批量渲染就够了只有当单条回复超过一定长度比如超过 5000 字时虚拟列表收益才明显。注意虚拟列表会改变页面 DOM 结构可能影响浏览器查找、复制文本和自动滚动逻辑。引入前要充分测试复制全文、查找关键词和屏幕阅读器场景。4. 桌面端Electron 技术栈的额外提速手段4.1 桌面端和网页端差在哪里Claude 桌面端通常基于桌面框架封装加载的还是前端页面但它的运行环境比普通浏览器多出几层渲染进程负责页面绘制。主进程负责窗口管理、菜单、系统能力调用。渲染进程和主进程通过 IPC 通信。如果使用 Node 集成还能直接读取本地文件系统或数据库。在长回复流式渲染场景中桌面端的额外瓶颈往往来自Markdown 解析渲染发生在渲染进程阻塞用户界面。IPC 消息过于频繁导致主进程与渲染进程之间反复切换。长回复内容既需要展示又需要持久化到本地网络、内存、磁盘操作叠加造成卡顿。4.2 用 Web Worker 隔离 Markdown 解析桌面端可以创建 Web Worker 来承担耗时的 Markdown 解析任务。渲染进程只负责接收文本增量和接收解析结果不再自己占用主线程做解析。Worker 侧代码// markdown-worker.ts import { marked } from marked; self.onmessage (event) { const { id, text } event.data; const html marked.parse(text, { async: false }); (self as unknown as Worker).postMessage({ id, html }); };主线程中调用const worker new Worker(new URL(./markdown-worker.ts, import.meta.url)); const pendingCallbacks new Mapnumber, (html: string) void(); let requestId 0; function renderMarkdownAsync(text: string): Promisestring { return new Promise((resolve) { const id requestId; pendingCallbacks.set(id, resolve); worker.postMessage({ id, text }); }); } worker.onmessage (event) { const { id, html } event.data; const resolve pendingCallbacks.get(id); if (resolve) { pendingCallbacks.delete(id); resolve(html); } };Web Worker 有两个注意点不是所有 Markdown 库都能在 Worker 中直接运行依赖浏览器 DOM 的插件需要替换。解析结果是异步的到达顺序可能与文本顺序不同。主线程要按请求 ID 或文本序号排序后再插入 DOM否则会出现内容顺序错乱。另一种选择是使用OffscreenCanvas渲染但 Markdown 文本通常不需要 Canvas优先使用 Worker 解析 DOM 字符串已经足够。4.3 IPC 批量发送避免主进程高频阻塞如果桌面端把“接收流式数据”放在主进程再把内容转发给渲染进程那么每次网络消息都触发一次 IPC会产生很大的通信开销。改进方式是主进程把收到的增量先攒起来按固定时间窗口批量发送// 主进程示例 let pendingText ; let ipcTimer: NodeJS.Timeout | null null; function pushNetworkText(chunk: string) { pendingText chunk; if (!ipcTimer) { ipcTimer setTimeout(() { const text pendingText; pendingText ; win.webContents.send(stream:batch, text); ipcTimer null; }, 16); // 约一帧 } }渲染进程只监听stream:batch事件然后放入渲染缓冲队列。这样主进程和渲染进程之间的消息数量会大幅下降渲染进程也更容易在同一帧内批量更新内容。4.4 本地缓存和恢复时的渲染策略桌面端长回复还有一个常见需求应用重启后恢复之前的对话内容。如果恢复时直接把完整文本一次性渲染会造成启动卡顿表现类似流式输出最后阶段的白屏。建议恢复流程分成两步先渲染“文本轮廓”例如只渲染前 2000 字让用户最快看到内容。再把剩余内容分片补齐每次补充 200 到 500 字确保每一帧不长时间阻塞。如果桌面端使用本地数据库存聊天记录不要把整个 Markdown 原文一次性读入内存再渲染应该按块读取。每条回复可以拆成多个块保存恢复时按块加载渲染一个块后再加载下一块。这样做还有一个好处后续做全文检索或导出时可以按块索引不用解析整篇长文。5. 验证提速效果如何测出“4 倍”并且不骗自己5.1 可复现的长文本样本优化前后对比必须使用同一份长文本生成样本才能排除内容差异。建议准备三类样本样本类型内容特点作用纯文本长回复3000 字以上连续文字无 Markdown测量基础渲染能力综合 Markdown 回复包含标题、列表、表格、引用、代码块测量解析器瓶颈超长代码回复多个大段代码块包含缩进和特殊字符测量 DOM 构建和滚动压力在真实 Claude 页面中可以选取一段已有的长回复作为样本把接口响应录制下来然后回放给优化前后的页面渲染。录制内容只包含content_block_delta事件按原有时间间隔重放。5.2 三个关键性能指标重点观察三个指标首块渲染时间从第一个 delta 到达页面到第一个可见文字出现的时间。总渲染完成时间从第一个 delta 到达页面到所有内容渲染完成的时间。主线程长任务时长Performance 面板中 Long Tasks 的总时长和单次最大时长。在控制台可以手动标记时间window.__renderStart performance.now(); window.__renderEnd null; // 在收到第一个 delta 时记录 // 在最后一块 DOM 插入后记录然后计算差值和占比。例如优化前总渲染完成时间 20 秒优化后 5 秒就可以说“当前场景下渲染提速约 4 倍”。5.3 控制变量和测试步骤测试时要注意控制变量使用同一台设备避免跨机器对比。使用同一份录制好的流式事件避免网络延迟干扰。关闭浏览器扩展和桌面端无关插件。至少测试 3 次取中位数避免垃圾回收和系统调度造成波动。记录内容包括页面尺寸、设备 DPR、浏览器版本、是否开启硬件加速。推荐测试步骤优化前版本先跑一遍记录首块渲染时间、总渲染时间和长任务统计。应用网页端优化方案再跑一遍。应用桌面端额外优化方案再跑一遍。对比两组数据确认瓶颈是否已经转移到网络层或解析库本身。用内存面板记录 GC 前后堆大小确认没有内存泄漏。5.4 如何判断优化是否真正生效不能只看总时间变短。还需要确认以下现象是否改善输出过程中拖动滚动条页面响应是否及时。复制长文本时是否能选中完整内容不会因为虚拟列表导致文本缺失。在快速输出场景下代码块和表格是否仍然能正确闭合。用户向上翻看历史回复后新增内容是否还会强制滚动。桌面端在长回复期间CPU 占用是否明显下降风扇是否不再狂转。如果优化后长任务数量仍然很多但总时间没有下降可能瓶颈并不在渲染层而在 Markdown 解析库或网络序列化上需要继续定位。6. 常见问题排查优化后丢字、乱序、滚动跳动怎么办6.1 丢字或内容截断现象页面底部内容缺失或者最后几个字一直没有出现。可能原因缓冲区还没有 flush应用就关闭或页面切换。在批处理时buffer被错误清空导致部分增量丢失。分块解析时切块位置刚好切在 Markdown 特殊字符中间内容被误判为结束。检查方式在flushBuffer中打印buffer.length确认每次 flush 时 buffer 是否为 0。在最终停止流的时候强制调用一次 flush。对比收到的总字符数和渲染后的文本长度找出差异位置。解决方案function stopStream() { flushBuffer(); commitStableText(); // 再处理 pendingText 剩余内容 }预防建议不要在beforeunload或页面隐藏事件中清空缓冲区至少先把剩余内容同步提交。6.2 顺序错乱现象后出现的文字跑到前面或者代码块标题和正文顺序颠倒。可能原因使用 Web Worker 异步解析时没有按请求顺序插入结果。多个网络请求并发到达业务层直接按到达顺序处理但网络层本身不保证顺序。分块解析后用户看到的是独立 DOM 片段但业务逻辑仍按全局索引维护造成可见顺序错乱。检查方式在 Worker 回传结果时打上请求 ID检查插入顺序。在业务层维护一个lastCommittedIndex每次只接受比它大的序号。如果使用文本追加对比stableText和最终显示的文本是否一致。解决方案主线程维护队列只有序号连续的消息才会被插入否则先缓存等待const pendingBlocks new Mapnumber, string(); let nextExpectedBlock 0; function onBlockReady(index: number, html: string) { pendingBlocks.set(index, html); while (pendingBlocks.has(nextExpectedBlock)) { appendHTML(pendingBlocks.get(nextExpectedBlock)!); pendingBlocks.delete(nextExpectedBlock); nextExpectedBlock; } }6.3 滚动位置跳动现象用户向上翻看前面的内容时页面被自动拉回底部或者新内容出现时滚动条高度瞬间变化。可能原因每次追加内容都调用scrollToBottom()没有判断用户当前是否在底部。虚拟列表没有维护稳定的滚动锚点。图片或表格加载后高度变化挤压了当前阅读位置。检查方式在滚动回调中输出scrollTop和scrollHeight看用户离开底部时是否仍在强制滚动。检查虚拟列表容器的min-height是否稳定。观察是否有元素在渲染后才加载字体或图片导致高度抖动。解决方案function onContentUpdated() { const distanceToBottom container.scrollHeight - container.scrollTop - container.clientHeight; if (distanceToBottom 100) { container.scrollTop container.scrollHeight; } }预防建议只有在用户已经接近底部时才自动滚动并在虚拟列表中为每个块预留最小高度减少高度抖动。6.4 内存持续上涨现象长回复结束后堆内存不断上升GC 后也不回落。可能原因每次全量渲染都保留旧的 DOM 节点没有释放。Worker 或 IPC 回调中持有大量历史文本引用。缓冲区没有清空或者旧的 Promise 回调一直存在 Map 中。检查方式Performance 面板的内存快照查看 Retained Size 大的对象。检查pendingCallbacksMap 是否有未删除的项。在flushBuffer后打印 buffer 长度确认已清空。解决方案使用虚拟列表时确保移出可视区的 DOM 节点被回收。异步解析回调完成后必须从 Map 中删除。长回复结束后主动把缓冲区内容置空并为内容容器创建新节点避免旧节点残留。6.5 桌面端白屏或卡死现象窗口显示一片空白或者持续输出时无响应。可能原因渲染进程长时间执行同步 Markdown 解析导致页面无法响应。IPC 消息积压主进程或渲染进程的事件循环被阻塞。本地数据库写入操作占用主线程长时间锁住界面。检查方式打开桌面端的 DevTools看渲染进程是否存在 Long Tasks。查看主进程控制台是否有大量未处理消息。在任务管理器中观察 CPU 和内存占用确认是哪个进程异常。解决方案把 Markdown 解析迁移到 Worker主线程只操作最终 HTML。IPC 批量发送减少消息频率。数据库写入放到异步队列中不要阻塞渲染循环。注意桌面端性能问题优先看渲染进程的 Long Tasks因为白屏和卡死绝大多数是渲染进程主线程被占满而不是主进程出错。7. 最佳实践与扩展方向7.1 可复用的渲染性能检查清单在接入任何大模型流式接口的长回复场景前可以按这份清单逐项检查网络层是否做了增量合并是否每收到一个 chunk 就更新页面。状态更新是否使用了批量提交避免高频 setState。Markdown 解析是否全量执行是否有分块或缓存策略。内容是否使用虚拟列表DOM 节点数量是否失控。自动滚动是否有用户意图判断是否在用户阅读时强制滚动。桌面端是否把耗时操作放到 WorkerIPC 是否频繁。是否记录首块渲染时间、总渲染时间和长任务持续时间。内存快照是否显示持续上涨Worker 和回调是否被正确释放。停止流式输出后缓冲区是否被正确 flush。超长代码块、表格和引用块是否在分块时保持完整。7.2 生产环境还要做哪些事流式渲染优化只是用户体验的一部分进入生产环境后还需要关注日志埋点在content_block_delta到达、缓冲区 flush、DOM 插入三个节点打点方便后续排查用户反馈。错误恢复网络断开或服务端异常时页面不能只显示一半内容。要有一个明确的“生成中断”状态并提供重新生成或继续生成的入口。内容安全长回复可能包含 HTML 或脚本片段分块渲染时不要直接把渲染结果插入页面。应使用 Markdown 库的转义功能并对代码块做严格的 HTML 转义。缓存策略桌面端可以把已渲染的块存入本地数据库启动时按块恢复避免一次性恢复大量内容。统计看板用真实用户数据验证提速效果例如 P75 的“总渲染时间”和“复制按钮响应时间”比实验室测试更能说明问题。7.3 下一步可以探索的方向如果分块批量渲染已经做到位仍然觉得长回复体验不够顺滑可以考虑以下方向增量解析器有些 Markdown 解析库支持增量解析只更新变化部分的 AST而不是重新解析全文。这个方向适合内容既有大量文本又有复杂结构的场景。Canvas 或 WebGL 渲染当文本量极大且需要频繁滚动时可以尝试 Canvas 渲染文本但这会牺牲文本选择和复制能力需要慎重评估。并发流处理如果同时展示多条回复可以把不同回复放到不同 Worker 中解析避免互相阻塞。渲染降级策略在低端设备上自动降低分块解析频率例如从一帧一次降为两帧一次优先保证主线程不长时间阻塞。长回复流式渲染提速的核心不是某一行代码的魔法而是把网络接收、缓冲合并、解析、DOM 插入和滚动控制拆成独立环节让每一层都只做最小必要工作。把“收到增量就全量渲染”的习惯改成“攒一批、渲染一批、稳定后再解析下一批”再把 Markdown 解析尽量移到后台就能在 Claude 网页端和桌面端的长回复场景中看到非常明显的体验提升。对于刚接触这方面优化的开发者建议先从第 3 章的requestAnimationFrame批量提交改起。这个改动最小收益却最直观。改完之后再用 Performance 面板观察长任务变化再决定是否需要继续引入 Worker 或虚拟列表。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻