Streaming Renderer
Streaming Renderer 是前端处理 LLM 流式输出的渲染层,负责逐字渲染文本、处理不完整的 Markdown、代码块高亮和自动滚动。
#type / concept
#status / evergreen
#tech / frontend
#tech / ai
[!info] related notes
- 所属 MOC: AI Agent Application MOC
- 上游: SSE Client, Event Reducer
- 相关: Markdown Renderer, Text Delta
- 实践: SSE 流式 Markdown 渲染
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 中的图片在流式渲染时延迟加载。
常见坑
- 每个 token 都重渲染: 性能差,应该用防抖或增量渲染
- 不处理不完整 Markdown: 代码块没闭合时渲染崩溃
- 自动滚动太灵敏: 用户想看历史消息时被强制拉到底部
- 不做 XSS 防护: 直接用 dangerouslySetInnerHTML 渲染 Markdown
- 语法高亮阻塞主线程: 长代码块的高亮计算阻塞 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 渲染