React 驱动的 LLM 响应式流渲染:处理 Token 输出的并发策略

第一章:LLM 的“早恋”与 React 的“草率”

各位好,欢迎来到“前端异步地狱”的特别研讨会。

今天我们不聊 Redux 的状态树有多深,也不聊 Webpack 的打包速度有多慢。我们聊点刺激的——当那个不知疲倦的 LLM(大语言模型)开始说话时,你该如何优雅地在 React 中接住它抛过来的每一个 Token。

想象一下这个场景:你的聊天界面是一个狭窄的走廊,而 LLM 是一个甚至不懂得“插队”的中二病少年。它一边在那儿打字,一边往你的管道里扔数据。有时候它很流畅,有时候它会把 JSON 的闭合大括号 } 扔到单词 Hello 的中间去。

如果你是那种直接在 useEffect 里写 setText(text + chunk) 的“热血青年”,恭喜你,你很快就会走进我今晚要讲的第一座坟墓——竞态条件与渲染风暴

第二章:把水倒进漏水的桶里——逐个字符渲染的灾难

很多新手,甚至有些老鸟,在面对流式响应时,最直观的解决方案是这样的:

// ❌ 灾难现场:不要这样做!
const [content, setContent] = useState("");

useEffect(() => {
  const eventSource = new EventSource("/api/chat");

  eventSource.onmessage = (event) => {
    // 每次收到一个 token 就触发一次重渲染
    setContent(prev => prev + event.data); 
  };
}, []);

看起来挺对吧?数据来了,加进去,React 更新 DOM。但这就像是你试图用一只手指去堵住喷涌的水龙头。每一个 Token 的到达都会触发一次重渲染,而 React 的重渲染又需要调度、执行 Diff 算法、更新 DOM 节点。

如果你的 LLM 朋友是个语速快的,每秒能给你吐 50 个 Token,你的浏览器每秒就要执行 50 次昂贵的 DOM 操作。结果就是:你的文本在闪烁,光标在跳舞,用户的眼睛在怀疑人生。 这种“狗血剧”式的渲染,不仅性能极差,而且用户根本读不清你在说什么,因为文字在还没等你读完的时候就已经被新的文字顶走了。

这就是我们要解决的核心矛盾:高频率、无序、且非原子性的输入,如何与低频、有序、原子性的 DOM 渲染机制相匹配?

第三章:React 18 的救命稻草——useTransition

在 React 18 之前,我们要么忍受卡顿,要么自己写防抖。但现在,React 给了我们一把名为 useTransition 的瑞士军刀。它的核心哲学就两个字:“懒”

如果你把渲染普通文本视为“高优先级任务”,那么把渲染 LLM 的逐字流视为“低优先级任务”,React 就会帮你把那 50 次渲染请求合并、排队,变成每 16ms(一帧)执行一次。这叫批处理

让我们来重构一下上面的代码,戴上 startTransition 的眼镜:

import { useState, useTransition } from 'react';

const ChatStream = () => {
  const [text, setText] = useState("");
  const [isPending, startTransition] = useTransition(); // 获取“懒人”助手

  useEffect(() => {
    const eventSource = new EventSource("/api/chat");

    eventSource.onmessage = (event) => {
      // 1. 定义一个新的状态更新
      const nextText = text + event.data;

      // 2. 把这个更新扔给 startTransition
      // React:嘿,兄弟,这事儿不急,你先去处理 UI 线程上的点击事件,这文本慢慢加。
      startTransition(() => {
        setText(nextText);
      });
    };

    return () => eventSource.close();
  }, [text]);

  return (
    <div>
      {/* 只有 isPending 为 true 时,我们才显示一个加载圈或者禁止用户输入 */}
      {isPending && <div>模型正在思考... (请勿打扰)</div>}
      <p>{text}</p>
    </div>
  );
};

效果如何?
当你开始打字输入新消息时,React 会瞬间响应你的输入(高优先级),因为 isPending 状态锁住了“渲染流”。而在后台,LLM 的文本正在通过 useTransition 悄悄地流进来。

但是!useTransition 只是把“慢”的事情变慢了,它并没有解决“每次更新都触发全量 Diff”的问题。如果文本已经很长了,每增加一个 Token 就要把整个 10000 字的字符串重新 Diff 一遍,内存和 CPU 还是会炸。

第四章:缓冲区与消息队列——打造你的“高速公路”

要真正驾驭 LLM,我们不能只做“快递员”,我们要做“调度中心”。我们需要一个中间层,一个缓冲区。

核心策略:消息队列

我们不应该在数据到达的瞬间就立即更新 UI。我们应该让数据进入一个队列,然后以可控的速率(比如每秒 10-20 次)从队列中取出一批数据,一次性渲染到界面上。

让我们写一个稍微复杂一点的 Hook,专门处理这个缓冲逻辑。

import { useState, useEffect, useRef } from 'react';

const useStreamBuffer = (streamUrl, chunkSize = 5, flushInterval = 16) => {
  const [content, setContent] = useState("");
  const bufferRef = useRef([]); // 这里是我们的消息桶
  const timerRef = useRef(null);
  const abortControllerRef = useRef(null);

  // 启动流
  useEffect(() => {
    abortControllerRef.current = new AbortController();
    bufferRef.current = []; // 清空旧缓冲区

    const eventSource = new EventSource(streamUrl);

    eventSource.onmessage = (event) => {
      // 1. 接收数据 -> 2. 放入缓冲区
      bufferRef.current.push(event.data);

      // 如果缓冲区满了,或者我们在急切模式下,立即触发一次渲染
      if (bufferRef.current.length >= chunkSize) {
        flushBuffer();
      }
    };

    return () => {
      eventSource.close();
      abortControllerRef.current.abort();
      clearTimeout(timerRef.current);
    };
  }, [streamUrl]);

  // 定时刷新:确保即使数据很慢,也不会超过一帧的时间
  useEffect(() => {
    timerRef.current = setInterval(() => {
      if (bufferRef.current.length > 0) {
        flushBuffer();
      }
    }, flushInterval);

    return () => clearInterval(timerRef.current);
  }, [flushInterval]);

  const flushBuffer = () => {
    const chunks = bufferRef.current.splice(0, bufferRef.current.length);
    const newContent = chunks.join('');

    // 这里使用 useTransition 或者直接更新都行,取决于你的复杂度
    setContent(prev => prev + newContent);
  };

  return content;
};

这段代码妙在哪里?

  1. 解耦: API 的响应速度(可能很快,也可能很慢)与 React 的渲染帧率(恒定)被分开了。
  2. 吞吐量控制: chunkSize 决定了我们一次渲染多少个字符。比如设置为 20,那么每秒收到 100 个 Token,我们只渲染 5 次。这不仅减少了重渲染次数,还降低了 GPU 的压力。
  3. 平滑度: 你会发现,flushBuffer 里的文字不是一个个蹦出来的,而是像滚屏一样连贯地滑过去。

第五章:处理“乱序”——JSON 里的终结符

这可是个技术难题。LLM 有时候是个没礼貌的读者。它会先读完一个长句,然后突然冒出一个 JSON 的 { "key": "value" },而这个 JSON 可能被拆成了 {, {"key":, value"}, } 陆续传过来。

如果你只是简单的拼接字符串,界面就会变成这样:

"Hello world {"key": "value"}

这太丑了,甚至会导致 Markdown 解析器崩溃。我们需要一个状态机

我们需要追踪我们的缓冲区,判断它是否是一个“不完整的句子”或“不完整的 JSON 对象”。

策略:状态追踪与完整性校验

我们可以维护一个内部缓冲字符串,并在渲染前检查它。如果字符串以 {, [, ", 或者某些特殊字符结尾,我们就假设它还没结束,继续在后台排队,直到我们看到一个合法的终结符(比如 } 或者 .(句号+空格))。

这听起来很复杂,但其实可以用一个简单的 while 循环配合正则表达式来实现。

// 一个简单的分块渲染逻辑示例
const processStream = (rawBuffer) => {
  // 尝试匹配常见的 Markdown 或 JSON 结束符
  // 这里的正则比较简陋,实际生产中可能需要更复杂的库

  // 1. 先尝试匹配标准的 Markdown 段落结束 (Double newline)
  // 2. 再尝试匹配 JSON 结束
  // 3. 或者单词边界 (Word boundary)

  const regex = /(nn|ns*n|}s*|. s*$)/g;
  let match;
  const chunks = [];
  let lastIndex = 0;

  // 简单的状态机模拟
  while ((match = regex.exec(rawBuffer)) !== null) {
    const chunk = rawBuffer.slice(lastIndex, match.index);
    if (chunk) chunks.push(chunk);
    lastIndex = regex.lastIndex;
  }

  if (lastIndex > 0) {
    // 剩下的缓冲区是不完整的,或者是最后一个完整的块
    const remaining = rawBuffer.slice(lastIndex);
    if (remaining.trim()) {
      chunks.push(remaining);
    }
    // 清空 rawBuffer,保留 remaining 在下一次循环继续处理
    return { chunks, remaining };
  }

  // 如果没找到任何结束符,把整个 buffer 返回为 chunks
  return { chunks: [rawBuffer], remaining: "" };
};

通过这种方式,我们确保了传给 React 渲染的,永远是语法完整的块。这极大地提高了用户体验,因为用户不会看到半截的代码块。

第六章:视觉上的“节流阀”——requestAnimationFrame

有时候,仅仅依靠定时器(setInterval)还不够完美。因为浏览器的刷新率不一定总是 60Hz。如果你的 LLM 说话极快,在两帧之间塞进了 200 个 Token,而你只执行了 2 次渲染,那么剩下的 198 个 Token 就会积压在队列里。

这时候,我们要引入requestAnimationFrame。这是浏览器原生的 API,它保证我们在下一次屏幕刷新之前执行代码。这就像是电影的帧率,无论是 24Hz 还是 120Hz,我们的渲染节奏永远追得上屏幕的刷新。

让我们把之前的定时器逻辑升级一下:

const flushBuffer = () => {
  const chunks = bufferRef.current.splice(0, bufferRef.current.length);
  const newContent = chunks.join('');

  // 使用 requestAnimationFrame 保证在下一帧绘制前执行
  requestAnimationFrame(() => {
    setContent(prev => prev + newContent);
  });
};

为什么这很重要?因为在流式传输中,我们不仅要处理“量”,还要处理“时”。requestAnimationFrame 让我们的渲染节奏与用户的视觉刷新率同步,消除了那种“抽搐”的感觉。

第七章:Markdown 高亮的“陷阱”——Diff 与重新解析

终于,文本流是平滑的了,JSON 也是完整的了。但还有一个大 BOSS:Markdown 语法高亮

你不会想让用户看到一堆纯文本 **bold**,或者 <div>content</div>。你需要 Prism.js 或者 react-syntax-highlighter。

但是,这里有一个巨大的坑。

React 的工作原理是 Diff。如果你把长文本变成了一堆 <span> 标签(用于高亮),每次你追加 10 个字符,React 就会去 Diff 这 10 个字符对应的 10 个 <span> 节点。而且,因为 React 认为文本内容变了,它很可能会直接删除旧的 <span> 树,然后重新解析整个字符串生成新的 <span> 树。

这会导致一个经典问题:光标跳动

每当文本更新时,如果整个组件树被卸载重装,滚动条就会跳到最顶端;或者光标会闪烁。

解决方案:文本差分

不要每次都把整个字符串扔给解析器。我们要做的是增量 Diff

对于简单的场景,我们可以只在追加的 10 个字符范围内,重新进行语法高亮,然后将其作为一个“幽灵块”或者直接替换 DOM 中对应的部分。对于复杂的场景(比如长文章),你需要维护一个“虚拟 DOM”层,只在用户滚动到可视区域时,才去解析高亮那一段。

不过,为了代码演示的简洁性(4000 字限制,咱们不整那么玄乎),我们这里用一种稍微偷懒但有效的策略:只渲染完整的 Markdown 块

// 这是一个简化的思路,实际需要配合上面的 processStream
// 只有当 buffer 处理完一个完整的代码块或段落时,才进行高亮渲染

const StreamComponent = () => {
  const content = useStreamBuffer('/api/chat');

  // 这里使用 dangerouslySetInnerHTML 是最简单的,
  // 但出于安全考虑,生产环境应该用 markdown-it 或 remark
  // 并且要确保 content 不包含 XSS

  return (
    <div 
      dangerouslySetInnerHTML={{ __html: marked.parse(content) }} 
      className="prose" 
    />
  );
};

注意: 上面这行代码虽然简单,但通常不够快。因为 marked.parse 是同步的 CPU 密集型操作。如果你的文本很长,会阻塞主线程,导致输入延迟。

真正的专家级做法: 将文本渲染和 Markdown 解析解耦。

  1. 将原始文本存入 State。
  2. 使用 useMemo 或者更高级的库,在后台异步解析 Markdown。
  3. 在解析结果准备好之前,先展示纯文本,解析完毕后再切换 DOM。

第八章:并发策略的终极形态——deferStream (React 19 预览)

虽然 React 19 还没全面普及,但它的方向非常明确:Defer Streaming

想象一下,现在的策略是:数据来了 -> 进缓冲区 -> 取出来 -> 渲染。
未来的策略(或者你可以手动模拟)是:数据来了 -> 进缓冲区 -> 不渲染 -> 等待组件挂载后,再像电影逐帧播放一样,把缓冲区里的数据“流”出来。

这种策略允许你实现极其丝滑的“打字机效果”,不仅仅是文本出现的动画,还包括光标闪烁、高亮块逐一出现的动画。这完全打破了 React 的传统渲染模式,进入了一种“数据驱动动画”的领域。

// React 19 风格的伪代码概念
const Message = () => {
  const { data } = useDeferredStream('/api/chat');

  return (
    <div>
      <span>{data.text}</span>
      <Cursor blink />
    </div>
  );
};

第九章:实战演练——手写一个健壮的 Chat 组件

好了,理论讲得差不多了,口水也干了。让我们把上面所有的策略揉在一起,写一个完整的、可以拿去面试(或者拿去忽悠客户)的组件。

这个组件将包含:

  1. 自定义 Hook:处理数据流。
  2. 缓冲机制:防止渲染风暴。
  3. Markdown 支持:让代码看起来专业。
  4. 错误处理:万一 LLM 挂了怎么办?
import { useState, useEffect, useTransition } from 'react';
import ReactMarkdown from 'react-markdown';

// 自定义 Hook:处理 LLM 流
const useLLMStream = (url) => {
  const [content, setContent] = useState("");
  const [isPending, startTransition] = useTransition();
  const [error, setError] = useState(null);

  // 缓冲区
  const bufferRef = useRef([]);
  // 定时器引用
  const timerRef = useRef(null);

  useEffect(() => {
    const eventSource = new EventSource(url);
    bufferRef.current = []; // 重置

    // 消息到达事件
    eventSource.onmessage = (event) => {
      // 1. 将数据推入缓冲区
      bufferRef.current.push(event.data);

      // 2. 如果缓冲区有足够的数据(或者你想实时显示一点),立即刷新
      // 这里我们稍微激进一点,只要有数据就尝试解析一个完整块
      // 实际项目中,这里可以加一个 isParsing 状态来防止并发解析冲突
      if (bufferRef.current.length > 0) {
         // 简单的触发刷新,实际逻辑中需要拆分完整的 Markdown 块
         flushBuffer(); 
      }
    };

    // 错误处理
    eventSource.onerror = (err) => {
      setError("Connection lost. The AI ran away.");
      eventSource.close();
      clearTimeout(timerRef.current);
    };

    return () => {
      eventSource.close();
      clearTimeout(timerRef.current);
    };
  }, [url]);

  // 定时器:如果数据太慢,至少每 50ms 刷新一次,防止死等
  useEffect(() => {
    timerRef.current = setInterval(() => {
      if (bufferRef.current.length > 0) {
        flushBuffer();
      }
    }, 50);

    return () => clearInterval(timerRef.current);
  }, []);

  const flushBuffer = () => {
    // 拿出所有数据并清空缓冲区
    const chunks = bufferRef.current.splice(0, bufferRef.current.length);
    const nextContent = chunks.join('');

    // 使用 useTransition 标记为低优先级
    startTransition(() => {
      setContent(prev => prev + nextContent);
    });
  };

  return { content, isPending, error };
};

// UI 组件
const ChatInterface = () => {
  const { content, isPending, error } = useLLMStream('/api/chat-stream');

  if (error) return <div className="error">{error}</div>;

  return (
    <div className="chat-window">
      {isPending && <div className="status">Thinking...</div>}
      <div className="message-content">
        {/* 这里为了性能,不建议用 react-markdown 的同步解析做高亮,
            除非你能确保文本长度可控。这里只做基础渲染 */}
        <ReactMarkdown>{content}</ReactMarkdown>
      </div>
    </div>
  );
};

第十章:防抖、去抖与“你的屏幕死了吗?”

在结束之前,我想再啰嗦一句关于并发中的防抖

有时候,如果你在流式传输过程中让用户点击“停止生成”,然后 API 的流还在源源不断地发数据过来。React 的更新循环可能会因为 useTransition 而变得不可预测。

策略:AbortController

一定要使用 AbortController。当用户点击停止时,不仅要关闭 EventSource,还要清空我们的 bufferRef,并停止任何正在进行的 setInterval

const stopGeneration = () => {
  if (eventSourceRef.current) {
    eventSourceRef.current.close();
  }
  if (timerRef.current) {
    clearInterval(timerRef.current);
  }
  // 清空缓冲区,防止停止后突然弹出一大段文字
  bufferRef.current = []; 
  setContent("");
};

结语:构建流式体验的艺术

处理 LLM 的响应式流渲染,本质上是一场对秩序的争夺战

数据是混乱的(无序的 Token),API 是不可控的(有时快有时慢),而 React 是死板的(必须遵守渲染周期)。作为前端工程师,你的工作就是用 useTransition 这种并发原语,用自定义的缓冲队列,用精心设计的正则分块策略,在混乱中建立秩序。

不要只满足于“文字能出来”。要去追求“文字滑出来的感觉”。要去追求“代码块高亮是完整的”。去追求“当用户疯狂输入时,输入框从不卡顿”。

这就是 React 驱动的 LLM 流渲染的精髓。现在,去写代码吧,让那个中二病的 AI 对象跟你愉快地聊天!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注