在 LLM 聊天 UI 中追求 240 FPS

发布: (2025年12月16日 GMT+8 01:52)
6 min read
原文: Dev.to

Source: Dev.to

TL;DR

我构建了一个基准套件,用来测试在 React UI 中流式 LLM 响应的各种优化。关键要点:

  1. 先构建合适的状态,再在后期优化渲染。理想情况下,把状态放在 React 之外(例如使用 Zustand),随后再适配。
  2. 不要把注意力放在 React 的重新渲染或 Hook 上;优先考虑窗口化。相较于窗口化技术,记忆化、useTransitionuseDeferredValue 等带来的收益微乎其微。
  3. 使用 CSS 属性优化关键渲染路径(CRP),如 content-visibility: autocontain: content。如果使用动画,加入 will-change
  4. 尽量避免直接返回原始 markdown。解析和渲染 markdown 开销大。如果必须返回,先把响应拆分为纯文本和 markdown 片段,仅对 markdown 部分进行解析。
  5. 在网络不是瓶颈时对流进行限流。在单词之间加入极小的延迟(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)。

Laggy React state updates

优化方案

基准套件包括:

  • 一个最小化的 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 秒后仍有明显提升,但仍未达到理想水平。

RAF batching performance

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-windowreact-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。

Back to Blog

相关文章

阅读更多 »