Streaming Renderer

Streaming Renderer 是前端处理 LLM 流式输出的渲染层,负责逐字渲染文本、处理不完整的 Markdown、代码块高亮和自动滚动。

#type / concept #status / evergreen #tech / frontend #tech / ai

[!info] related notes

Streaming Renderer

一句话定义

Streaming Renderer 是前端处理 LLM 流式输出的渲染层。它不只是”逐字显示文本”,还要处理不完整的 Markdown(比如代码块只到了一半)、语法高亮、增量渲染和自动滚动。

它解决什么问题

LLM 的输出是逐 token 到达的。如果直接把每个 token 拼接后用 Markdown 渲染器渲染,会遇到:

  • 不完整的代码块: python 已经到了,但 还没到,渲染器会报错
  • 不完整的表格: 表格行到了但分隔行还没到
  • 不完整的 HTML 标签: <div> 到了但 </div> 还没到
  • 频繁重渲染: 每个 token 都触发完整 Markdown 解析,性能差
  • 自动滚动: 新内容到达后需要自动滚动,但用户手动滚动时不应打断

核心原理

流式 Markdown 的挑战

Token 到达顺序:
1: "# Hello\n"
2: "```python\n"
3: "def "
4: "foo():\n"
5: "    "
6: "return "
7: "42\n"
8: "```\n"

问题: 在 token 2-7 之间,代码块是不完整的
Markdown 渲染器无法正确解析不完整的代码块

解决策略

1. 延迟渲染(等代码块完整)

检测到 开始后,等到对应的 结束后再渲染代码块。中间的 token 先缓存。

2. 增量渲染(只重新渲染变化的部分)

不是每个 token 都重新解析整个 Markdown,而是只解析新增的部分。

3. 降级渲染(不完整时用纯文本)

代码块不完整时,用 <pre> 纯文本显示,完整后再切换到语法高亮。

4. 分段渲染

把 Markdown 按段落/代码块分段,每段独立渲染。新 token 只影响当前段。

自动滚动逻辑

function useAutoScroll(dependency: any) {
  const scrollRef = useRef<HTMLDivElement>(null);
  const [shouldAutoScroll, setShouldAutoScroll] = useState(true);

  // 用户手动滚动时,检测是否到底部
  const handleScroll = () => {
    const el = scrollRef.current;
    if (!el) return;
    const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
    setShouldAutoScroll(isAtBottom);
  };

  // 新内容到达时,如果之前在底部,自动滚动
  useEffect(() => {
    if (shouldAutoScroll) {
      scrollRef.current?.scrollTo({
        top: scrollRef.current.scrollHeight,
        behavior: 'smooth',
      });
    }
  }, [dependency, shouldAutoScroll]);

  return { scrollRef, handleScroll };
}

典型工程实现

基于 react-markdown 的流式渲染

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';

function StreamingMarkdown({ content }: { content: string }) {
  // 检测不完整的代码块
  const fencedBlocks = content.match(/```/g) || [];
  const isIncompleteCodeBlock = fencedBlocks.length % 2 !== 0;

  // 如果代码块不完整,在末尾添加闭合标记(渲染后移除)
  const safeContent = isIncompleteCodeBlock
    ? content + '\n```'
    : content;

  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      components={{
        code({ node, inline, className, children, ...props }) {
          const match = /language-(\w+)/.exec(className || '');
          return !inline && match ? (
            <SyntaxHighlighter language={match[1]} PreTag="div">
              {String(children).replace(/\n$/, '')}
            </SyntaxHighlighter>
          ) : (
            <code className={className} {...props}>{children}</code>
          );
        },
      }}
    >
      {safeContent}
    </ReactMarkdown>
  );
}

性能优化:防抖渲染

function useDebouncedContent(rawContent: string, delay: number = 16) {
  const [displayContent, setDisplayContent] = useState(rawContent);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDisplayContent(rawContent);
    }, delay);
    return () => clearTimeout(timer);
  }, [rawContent, delay]);

  return displayContent;
}

常见设计模式

1. 打字机效果

逐字显示,每 10-30ms 更新一次,模拟打字机效果。

2. 代码块折叠

长代码块自动折叠,用户点击展开。

3. LaTeX 渲染

支持数学公式的流式渲染(需要特殊处理不完整的公式)。

4. 图片懒加载

Markdown 中的图片在流式渲染时延迟加载。

常见坑

  1. 每个 token 都重渲染: 性能差,应该用防抖或增量渲染
  2. 不处理不完整 Markdown: 代码块没闭合时渲染崩溃
  3. 自动滚动太灵敏: 用户想看历史消息时被强制拉到底部
  4. 不做 XSS 防护: 直接用 dangerouslySetInnerHTML 渲染 Markdown
  5. 语法高亮阻塞主线程: 长代码块的高亮计算阻塞 UI

和其他概念的关系

  • vs Markdown Renderer: Markdown Renderer 是静态渲染,Streaming Renderer 处理流式场景
  • vs Text Delta: Text Delta 是事件格式,Streaming Renderer 是渲染逻辑
  • vs SSE Client: SSE Client 负责接收数据,Streaming Renderer 负责渲染
  • vs Event Reducer: Reducer 负责状态更新,Renderer 负责 UI 渲染

参考资料

创建于 2026/6/30 更新于 2026/7/15