在 LLM 聊天 UI 中追求 240 FPS
Source: Dev.to
TL;DR
我构建了一个基准套件,用来测试在 React UI 中流式 LLM 响应的各种优化。关键要点:
- 先构建合适的状态,再在后期优化渲染。理想情况下,把状态放在 React 之外(例如使用 Zustand),随后再适配。
- 不要把注意力放在 React 的重新渲染或 Hook 上;优先考虑窗口化。相较于窗口化技术,记忆化、
useTransition、useDeferredValue等带来的收益微乎其微。 - 使用 CSS 属性优化关键渲染路径(CRP),如
content-visibility: auto和contain: content。如果使用动画,加入will-change。 - 尽量避免直接返回原始 markdown。解析和渲染 markdown 开销大。如果必须返回,先把响应拆分为纯文本和 markdown 片段,仅对 markdown 部分进行解析。
- 在网络不是瓶颈时对流进行限流。在单词之间加入极小的延迟(5–10 ms)可以帮助保持更高的 FPS。
LLM 流式工作原理
LLM 通过服务器发送事件(SSE)分块流式返回响应,而不是一次性发送完整答案。
// Basic LLM stream handling with fetch
async function streamLLMResponse(url) {
const response = await fetch(url);
const reader = response.body.getReader();
let done = false;
while (!done) {
const { value, done: streamDone } = await reader.read();
if (streamDone) {
done = true;
break;
}
const chunk = new TextDecoder().decode(value);
// Process the chunk (can be one or multiple words)
console.log(chunk);
}
}
在 React 中处理块——为何会卡顿
最直接的做法是对每个收到的块都更新 React 状态:
import { useState } from "react";
const [response, setResponse] = useState("");
async function streamLLMResponse(url) {
const response = await fetch(url);
const reader = response.body.getReader();
let done = false;
while (!done) {
const { value, done: streamDone } = await reader.read();
if (streamDone) {
done = true;
break;
}
const chunk = new TextDecoder().decode(value);
setResponse(prev => prev + chunk); // Update state with each chunk
}
}
随着聊天记录的增长,每一次状态更新都会触发整个组件树的重新渲染,导致 FPS 急剧下降(往往在几秒内跌至 0 FPS)。

优化方案
基准套件包括:
- 一个最小化的 Node + TypeScript 服务器,能够以可配置的延迟流式发送单词(模拟 LLM 流)。
- 一个使用 React + Vite + TypeScript 的前端,实现了多种优化手段。
- 在流式期间以 FPS 为指标进行性能测量;不同优化方案下的内存占用也会有所差异。
RAF 批处理
将收到的块缓冲起来,并在每个动画帧(requestAnimationFrame)结束时一次性写入状态。
import { useRef, useState } from "react";
const [response, setResponse] = useState("");
const bufferRef = useRef("");
async function streamLLMResponse(url) {
const response = await fetch(url);
const reader = response.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
bufferRef.current += new TextDecoder().decode(value); // Collect chunk
requestAnimationFrame(() => {
if (bufferRef.current) {
setResponse(prev => prev + bufferRef.current); // Flush once per frame
bufferRef.current = "";
}
});
}
}
结果: 最低 FPS 约 15,在 90 秒后仍有明显提升,但仍未达到理想水平。

React 18 startTransition
startTransition 将更新标记为非紧急,使 React 能够把紧急 UI 工作(如用户输入)放在流式文本更新之前。
import { startTransition, useState } from "react";
function handleInputChange(e) {
const value = e.target.value;
setInputValue(value); // Urgent
startTransition(() => {
setFilteredList(filterList(value)); // Non‑urgent
});
}
注意事项: 如果块的到达速度超过了 React 能让出时间的速度,主线程仍会被占满,startTransition 的收益会受限。
其他技巧
-
窗口化 / 虚拟化: 只渲染可见的聊天记录部分(例如使用
react-window或react-virtualized),可大幅降低 DOM 大小和重绘成本。 -
CSS 优化:
.chat-message { content-visibility: auto; contain: content; }对有动画的元素使用
will-change,向浏览器提示即将发生的变化。 -
块限流: 在处理块之间加入少量人工延迟(5–10 ms),可以平滑渲染峰值,而不会明显影响感知的响应速度。
-
在 React 之外管理状态: Zustand 等库允许你维护一个可变的存储,React 只在必要时读取,从而减少触发重新渲染的状态更新次数。
通过结合 RAF 批处理、窗口化、针对 CRP 的 CSS 与 谨慎的状态管理,你可以实现接近 60 FPS 的流畅体验——在硬件足够强大的情况下,甚至可以在 LLM 聊天 UI 中逼近 240 FPS。