流式渲染提速:前端性能優(yōu)化的關(guān)鍵路徑)
Claude 這次在網(wǎng)頁端和桌面端做了一件看起來不大、實際很關(guān)鍵的事把長回復(fù)的流式渲染速度提升了大約 4 倍。這里的“提速”不在模型側(cè)而在前端渲染側(cè)。模型生成的 token 數(shù)量沒有變變快的是頁面把 token 流變成可讀文本、Markdown、代碼塊、可滾動長文這一整條處理鏈路。長回復(fù)是 LLM 產(chǎn)品里最容易暴露體驗短板的地方。寫長文、生成完整代碼、做長文檔對話時如果渲染線程跟不上 token 到達速度就會出現(xiàn)“字已經(jīng)輸出完了但頁面還要卡幾秒才能滾動”“輸入框打字掉幀”“代碼塊高亮出得很慢”這類現(xiàn)象。Claude 這次優(yōu)化的目標就是把這些卡頓壓下去。這篇文章會做三件事先拆解長回復(fù)流式渲染的性能瓶頸到底在哪再給一條可落地的前端流式渲染優(yōu)化路徑包含增量解析、任務(wù)切片、Web Worker 處理和 fetch 流式讀取示例最后給出一套不依賴 Claude 內(nèi)部實現(xiàn)的性能驗證方法你可以直接用在手頭的 AI 產(chǎn)品上。適合閱讀的讀者正在寫 AI Chat 產(chǎn)品的前端工程師、負責(zé) LLM 應(yīng)用性能優(yōu)化的開發(fā)者以及想搞清楚“流式渲染提速”到底在優(yōu)化什么的技術(shù)負責(zé)人。1. 核心能力速覽先說清楚一個前提Claude 網(wǎng)頁端與桌面端長回復(fù)流式渲染提速 4 倍這個口徑來自外部公開信息。官方?jīng)]有放出足夠詳細的基準測試原始數(shù)據(jù)所以本文不會聲稱“復(fù)現(xiàn)了 4 倍提升”而是把這個事件當(dāng)作引子重點講流式渲染優(yōu)化的通用工程方法。具體能提升多少必須在自己的項目里做前后對比。能力項說明優(yōu)化產(chǎn)品Claude 網(wǎng)頁端、桌面客戶端優(yōu)化場景長回復(fù)、長文本輸出的流式渲染優(yōu)化方向前端渲染鏈路不是模型推理速度提升幅度官方口徑約 4 倍具體以實際測試為準常見技術(shù)方向token 增量處理、Markdown 流式解析、渲染任務(wù)調(diào)度可復(fù)用范圍所有帶 LLM 流式輸出的 Web 應(yīng)用、桌面端應(yīng)用驗證方式模擬流式接口 Chrome DevTools Performance主要收益減少滾動卡頓、降低主線程占用、提升打字響應(yīng)結(jié)論放在前面這類優(yōu)化的核心不是“把模型輸出變快”而是“讓頁面在輸出過程中不卡”。下面按瓶頸、優(yōu)化路徑、驗證方法展開。2. 長回復(fù)流式渲染為什么會卡頓2.1 渲染速度跟不上 token 到達速度大模型流式輸出的速度并不慢。一個正常的大模型接口平均每秒能返回幾十到上百個 token。放到界面上這就是每秒幾十次網(wǎng)絡(luò)回調(diào)。如果前端每收到一個 chunk 就立刻做一次完整 DOM 更新渲染任務(wù)就會以每秒鐘二三十次的頻率壓到主線程上??D的根本原因是節(jié)奏不匹配網(wǎng)絡(luò)層是高頻小包渲染層是低吞吐大任務(wù)。每次更新如果還要觸發(fā) Markdown 重解析、代碼高亮、布局計算主線程很快就會被占滿。2.2 全量重解析是最大消耗很多 AI 聊天頁面最初實現(xiàn)時是把已收到的全部文本當(dāng)作一個整體字符串每次有新的 token 到達就用這個字符串重新走一遍 Markdown 解析和 HTML 渲染。文本短的時候問題不大一旦累計到幾千字、幾萬字每次重解析的成本會跟著全文長度一起上漲。這種實現(xiàn)的復(fù)雜度是 O(n)n 是當(dāng)前已生成的文本長度。token 越多每次更新越慢到長回復(fù)后期單次解析可能就要幾百毫秒。用戶感知就是生成到一半頁面越來越卡。2.3 高頻 DOM 更新占滿主線程LLM 聊天界面里常見的 DOM 更新動作包括替換正文容器、更新代碼高亮、調(diào)整滾動位置、更新 token 計數(shù)。這些動作如果高頻觸發(fā)會在 Performance 面板里形成一連串 Long Task。瀏覽器主線程被這些任務(wù)占據(jù)后輸入框的 keydown 事件排隊等待用戶打字就會出現(xiàn)明顯延遲。長回復(fù)場景里還有一個隱藏問題全文一直掛在 DOM 上沒有分頁或虛擬滾動。當(dāng)頁面節(jié)點數(shù)量從幾百漲到幾千、幾萬瀏覽器在樣式重算和重繪上的時間會顯著增加。2.4 長文本滾動區(qū)域破壞滾動手勢用戶看長回復(fù)時通常希望邊生成邊往上讀同時又能隨時拖動滾動條。如果每次新 token 到達都強制滾動到底部會打斷用戶的閱讀節(jié)奏如果完全不滾動用戶又看不到最新內(nèi)容。這種“滾動策略”處理不好體驗上比渲染性能問題更明顯。更隱蔽的是布局抖動。每次新內(nèi)容插入到滾動容器頂部或底部瀏覽器都要重新計算滾動高度一旦插入位置和滾動位置互相影響就可能出現(xiàn)滾動條跳變。3. 提速 4 倍的常見技術(shù)路徑下面這些優(yōu)化路徑是流式渲染場景下通用的工程做法。Claude 的具體實現(xiàn)細節(jié)沒有公開但同類產(chǎn)品要在這里提效通常會沿著這幾個方向做。3.1 全量重渲染改為增量渲染核心改動是維護一個“已經(jīng)渲染到哪了”的游標。每次收到新 chunk只處理新增部分而不是把整個歷史文本重新解析一遍。已渲染的歷史內(nèi)容保持不動新內(nèi)容追加到容器尾部。這樣單次更新時間不隨全文長度增長整體復(fù)雜度從 O(n) 降為 O(增量)。3.2 把穩(wěn)定內(nèi)容和實時內(nèi)容分開管理長回復(fù)里Markdown 段落一旦閉合就不會再變化。代碼塊、列表、標題都屬于穩(wěn)定結(jié)構(gòu)。可以先把這些穩(wěn)定內(nèi)容渲染成正式 DOM 節(jié)點中間的臨時 token 流放到一個輕量的“游標區(qū)間”里。等到游標區(qū)間積累到成段內(nèi)容再提升為正式節(jié)點。這樣避免了每來一個 token 都操作整棵 DOM 樹。3.3 代碼塊和列表專項渲染代碼塊是長回復(fù)里最重的渲染對象。一次生成幾百行代碼時語法高亮可能比 Token 流本身更耗時。常見做法是代碼渲染先降級為普通文本展示高亮任務(wù)通過異步分片或 Worker 完成。列表和表格同理不急著實時渲染復(fù)雜樣式先保證文本可見。3.4 請求調(diào)度而不是每包必渲染網(wǎng)絡(luò)回調(diào)和渲染任務(wù)不必一一對應(yīng)??梢韵劝盐谋緦懭刖彌_區(qū)再通過 requestAnimationFrame 或 requestIdleCallback 統(tǒng)一提交。比如每 50 到 100 毫秒刷新一次界面把期間累積的增量一次渲染。犧牲一點“逐字輸出”的觀感換來主線程空閑和滾動流暢。3.5 Web 端與桌面端共用渲染核心桌面端通常基于 WebView 或 Electron渲染核心如果能和 Web 端復(fù)用同一套邏輯維護成本會低很多。優(yōu)化一次兩端同時生效。這也是 Claude 網(wǎng)頁和桌面端能同步提速的原因之一。4. 環(huán)境準備與流式渲染驗證方案要驗證流式渲染性能不一定要接真實大模型。準備一個能模擬長回復(fù)輸出的流式接口再配合瀏覽器性能工具就能完成大部分實驗。4.1 準備一個可控的流式輸出源推薦使用 FastAPI 或 Node.js 啟動一個本地服務(wù)用 SSE 格式按固定間隔推送文本。這樣能控制 token 的到達速度方便前后對比。# requirements: fastapi uvicorn from fastapi import FastAPI from fastapi.responses import StreamingResponse import asyncio app FastAPI() async def generate(): chunk 這是用于測試流式渲染性能的長文本內(nèi)容。 for i in range(200): yield fdata: {chunk} 第{i 1}段\n\n await asyncio.sleep(0.05) app.post(/api/stream) async def stream(): return StreamingResponse(generate(), media_typetext/event-stream)啟動命令uvicorn main:app --host 127.0.0.1 --port 80004.2 確定核心指標驗證流式渲染性能建議先固定幾項指標指標名稱定義觀察方式TTFT從發(fā)起請求到第一段內(nèi)容可見DevTools Network字符渲染速率每秒實際出現(xiàn)在屏幕上的字符數(shù)視頻錄制或腳本統(tǒng)計長任務(wù)耗時主線程上超過 50ms 的任務(wù)Performance 面板交互延遲輸入框敲字到字符出現(xiàn)的時間Input 事件分析滾動幀率滾動長文本時的 FPSPerformance 面板 FPS 圖4.3 用 DevTools Performance 記錄現(xiàn)場打開 Chrome DevTools切到 Performance 面板點擊錄制然后在頁面上發(fā)起一次長回復(fù)生成。等輸出結(jié)束后停止錄制重點看 Long Task、Recalculate Style 和 Layout 三條記錄。如果 Long Task 出現(xiàn)在每次網(wǎng)絡(luò)回調(diào)之后說明當(dāng)前渲染實現(xiàn)確實在和 token 流搶主線程。這個現(xiàn)場記錄就是優(yōu)化的基線數(shù)據(jù)。5. 流式渲染優(yōu)化落地示例下面給三組代碼。第一組是常見但性能較差的基線條案第二組改成游標增量更新第三組把 Markdown 解析放到 Worker 中。它們不是完整生產(chǎn)實現(xiàn)但能直接跑通并體現(xiàn)優(yōu)化思路。5.1 基線版本每個 chunk 都全量更新// 基線版本每收到一個 chunk 就全量替換 innerHTML const container document.getElementById(output); const response await fetch(/api/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt: 寫一篇長文 }) }); const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const text decoder.decode(value, { stream: true }); container.innerHTML text; // 每次全量重解析長文后越來越卡 }這個版本的問題很直觀每次網(wǎng)絡(luò)包到達都會走一次全量解析和 DOM 替換。文本從 1000 字漲到 10000 字時單次處理時間會顯著上升。5.2 改進版本游標增量提交const container document.getElementById(output); let fullText ; let renderedLength 0; function flushIncremental() { const newText fullText.slice(renderedLength); if (!newText) return; // 先把純文本放進去避免每次解析 Markdown const span document.createElement(span); span.textContent newText; container.appendChild(span); renderedLength fullText.length; } const response await fetch(/api/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt: 寫一篇長文 }) }); const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; fullText decoder.decode(value, { stream: true }); // 使用 requestIdleCallback 降低渲染優(yōu)先級 if (typeof requestIdleCallback function) { requestIdleCallback(flushIncremental, { timeout: 200 }); } else { setTimeout(flushIncremental, 50); } }這個版本把“全量替換”改成了“只追加新增部分”同時通過 requestIdleCallback 合并渲染任務(wù)避免每個網(wǎng)絡(luò)包都強制觸發(fā)一次布局。5.3 進一步改進在 Web Worker 里解析 Markdown主線程只負責(zé) DOM 提交把 Markdown 解析這種計算型工作交給 Worker。// markdown.worker.js // 這里用簡化邏輯演示實際項目可換成 markdown-it 或 marked self.onmessage (event) { const { fullText, renderedLength } event.data; const newText fullText.slice(renderedLength); // 模擬 Markdown 解析只把換行轉(zhuǎn)成 p 塊 const html newText .split(\n\n) .map((block) p${block}/p) .join(); self.postMessage({ html, length: newText.length }); };// 主線程中創(chuàng)建 Worker 并接收解析結(jié)果 const container document.getElementById(output); const worker new Worker(./markdown.worker.js); worker.onmessage (event) { const { html } event.data; const temp document.createElement(div); temp.innerHTML html; container.appendChild(...temp.children); }; worker.postMessage({ fullText, renderedLength });注意Worker 版本適合把“解析字符串”從主線程移走但 DOM 替換本身仍要發(fā)生在主線程。如果想徹底避免主線程頻繁操作 DOM還需要對長文本做分頁或虛擬滾動這部分屬于下游工程。6. 長回復(fù)場景下的 API 與流式鏈路設(shè)計6.1 流式響應(yīng)的數(shù)據(jù)格式主流 LLM 接口都支持 SSE 或 fetch stream。服務(wù)端按事件流推送增量內(nèi)容前端通過 ReadableStream 讀取。data: {id:1,delta:你} data: {id:1,delta:好} data: {id:1,delta:今天繼續(xù)寫這篇長文}前端讀取流時注意用 TextDecoder 的{ stream: true }參數(shù)處理多字節(jié)字符被分包的情況否則中文可能亂碼。6.2 超時、中斷與重連長回復(fù)接口不適合短超時。普通 REST 接口可能 10 到 30 秒超時但流式接口在生成一個幾千字回復(fù)時整體耗時可能達到幾十秒甚至幾分鐘。超時策略應(yīng)該基于“兩個 chunk 之間的間隔”而不是“整個請求的總時長”。前端還要處理中斷場景用戶點擊停止生成、頁面切換、桌面端 WebView 被系統(tǒng)回收。建議做好“流中斷時保留已生成文本”的快照邏輯。6.3 長回復(fù)與批量任務(wù)流式渲染解決的是“用戶在頁面上看著內(nèi)容生成”的場景。一次生成即一個流式響應(yīng)。批量任務(wù)則不同把大量長文本生成任務(wù)放進隊列通過任務(wù) ID 輪詢或回調(diào)獲取結(jié)果。兩種模式對應(yīng)不同的接口設(shè)計。如果要做批量長文生成可以這樣設(shè)計任務(wù)狀態(tài){ task_id: task_123456, status: running, total_chunks: 200, completed_chunks: 87, output: }批量任務(wù)不需要前端實時渲染每一段重點是任務(wù)進度的可追蹤性和失敗重試。7. 性能觀察與對比方法7.1 前后對比的實驗設(shè)計如果你想在項目里復(fù)現(xiàn)類似優(yōu)化實驗流程分四步。第一步固定測試文本。為了避免隨機性建議準備一個 3000 到 5000 字的固定長文本按固定間隔推送。第二步固定測試環(huán)境。同一瀏覽器、同一設(shè)備、關(guān)閉不必要的插件必要時用瀏覽器無痕模式。第三步記錄基線。用未優(yōu)化的版本跑一遍記錄 Long Task 數(shù)量、累計主線程阻塞時間和滾動幀率。第四步切換優(yōu)化版本重復(fù)同樣流程對比數(shù)據(jù)。7.2 不要只盯著“倍數(shù)”官方說 4 倍提升這個數(shù)字是從特定測試環(huán)境和指標下得出的。你在自己的頁面里優(yōu)化后可能只提升了 30%也可能提升了 10 倍。這都很正常。關(guān)鍵是看主線程占用是否降下來了、長任務(wù)是否變少、用戶能否在生成過程中流暢滾動手動閱讀。7.3 性能觀察的常見誤區(qū)第一個誤區(qū)是只看 TTFT。TTFT 只反映第一段內(nèi)容的到達時間長回復(fù)的卡頓主要集中在后半程。第二個誤區(qū)是忽略滾動場景。很多測試只盯著自動輸出沒有在輸出過程中模擬手動滾動。第三個誤區(qū)是忘記檢查頁面內(nèi)存。長期掛著一個超大 DOM 樹即使渲染不卡內(nèi)存也會持續(xù)增長桌面端尤其明顯。8. 常見問題與排查方法問題現(xiàn)象可能原因排查方式解決方案長文本滾動卡頓高頻 DOM 更新占滿主線程Performance 面板查看 Long Task 頻率批量提交增量用 requestIdleCallback 調(diào)度代碼塊出現(xiàn)時頁面掉幀語法高亮在主線程同步執(zhí)行觀察代碼塊首次渲染時的耗時高亮任務(wù)放到 Worker 或延遲到空閑期打字輸入延遲明顯渲染任務(wù)和 input 事件搶占主線程輸入框事件響應(yīng)延遲分析降低渲染優(yōu)先級預(yù)留輸入事件處理間隙桌面端 WebView 內(nèi)存暴漲歷史節(jié)點持續(xù)增加沒有淘汰觀察 DOM 節(jié)點數(shù)和堆內(nèi)存曲線引入虛擬滾動限制容器內(nèi)節(jié)點數(shù)流式輸出中斷代理或網(wǎng)關(guān)斷開空閑連接查看網(wǎng)絡(luò)請求斷開時間點配置更長超時時間前端加自動重連中文輸出亂碼TextDecoder 未用流式模式解碼檢查解碼參數(shù)使用 new TextDecoder() 并傳 { stream: true }頁面強制滾到底部打斷閱讀滾動策略沒有區(qū)分用戶意圖觀察滾動事件發(fā)生時機只在用戶位于底部時自動滾動這些問題的共同點是不要讓一次性渲染任務(wù)長時間占據(jù)瀏覽器主線程。9. 最佳實踐與使用建議9.1 工程落地建議第一次接入流式渲染時先用模擬接口跑通鏈路不要直接接真實模型。把“網(wǎng)絡(luò)接收、文本緩存、渲染提交、滾動控制”四層拆開分別測試。這樣出了問題能快速定位。項目目錄建議分開管理模型輸出文本、渲染后 HTML、高亮后的代碼塊、滾動位置緩存。避免在狀態(tài)對象里存一整套扁平字符串然后每次全量重新生成。9.2 合規(guī)與安全邊界長回復(fù)內(nèi)容可能包含代碼、文檔、個人信息。涉及業(yè)務(wù)數(shù)據(jù)時要確保流式接口有權(quán)限校驗避免中間人截獲敏感內(nèi)容。接口服務(wù)最好限制訪問范圍不要暴露到公網(wǎng)。如果生成結(jié)果涉及人臉、聲音、他人作品或未公開文檔發(fā)布前必須確認授權(quán)。流式渲染只是技術(shù)展示不能繞過內(nèi)容合規(guī)審核。10. 總結(jié)與下一步這次 Claude 網(wǎng)頁端與桌面端的長回復(fù)流式渲染提速本質(zhì)是前端渲染鏈路的優(yōu)化不是模型輸出變快了。它提醒所有做 LLM 產(chǎn)品的人模型把文字“說”出來只是完整鏈路的前半段后半段“讓用戶舒服地看到文字”是否高效直接決定產(chǎn)品體驗。最值得先做的驗證是在你自己的 Chat 頁面上用固定長文本做一次基線測試看 Performance 面板里是否出現(xiàn)大量長任務(wù)。如果長任務(wù)密集就按本文的增量渲染、批量提交、Worker 解析三個方向逐步改造。最容易踩的坑是“只優(yōu)化了第一屏”。長回復(fù)的卡頓通常出現(xiàn)在文本累計到一定長度之后驗證時一定要跑到長文本后段手動滾動畫一畫輸入框敲幾個字整套體驗順了才算真正完成。后續(xù)可以繼續(xù)擴展的方向包括長文本虛擬滾動、代碼高亮按需加載、桌面端離線緩存、批量生成任務(wù)隊列。每一步都可以用同一套性能觀察流程驗證效果。建議收藏備用。