治好 AI 长回答的卡、闪、膨胀:渲染开销从 50 万次砍到 1000
AI 对话产品的长回答通常有三个毛病:卡(滚动一卡一卡)、闪(文字反复抖动)、膨胀(文章越长越卡)。最直白的渲染方式------每来一个 token 就把整篇 markdown 重新解析、重新渲染------三种毛病全占:一篇 1000 个 token 的回答累计要渲染 1+2+3+...+1000 ≈ 50 万次块;token 停在 **加粗 中间时整块会抖;一万字的文章有约 70 个块全挂 DOM。
这篇把三个症状各拆到根因,各给解法。一个关键的发现是:"卡"其实不是一个问题,而是三个互相独立的问题叠加在一起。全文要讲的五个优化彼此正交,工程上可以任意组合、任意顺序启用,下面按症状归类,只是为了讲清楚每个优化治的是什么。
先看清三个症状各从哪来
| 症状 | 根因 | 解法 |
|---|---|---|
| 卡 | 每个 token 重新解析整篇,O(n²) | 增量切块:只解析尾块 |
| 卡 | 内容没变的块 render 也重跑 | 记忆化:哈希 + memo |
| 卡 | 主线程被解析挤占 | Worker:解析搬出主线程 |
| 闪 | 半截语法让整块反复拆重建 | 投机闭合:渲染前临时补全 |
| 膨胀 | 长文 DOM 节点太多 | 视口虚拟化:只留视口 |
"卡"占了三行,因为它有三个独立根因------这也是它最难治、最容易被误当成单一问题的原因。下面挨个治。
症状一:卡
卡是读者感受最深的症状,但拆开看,它是三件独立的事叠在一起。
根因一:重算------每个 token 重新解析整篇
最直白的实现里,每个 token 到来都把整篇 markdown 重新解析一遍。算笔账:第 1 个 token 渲染 1 个块,第 2 个渲染前 2 个,第 100 个渲染前 100 个,一篇 100 个块的回答累计 1+2+...+100 = 5050 次解析;1000 个块就是 50 万次。每个 token 的开销和文章长度成正比,整篇累加是 O(n²)。
解法是增量切块:已经渲染完成的块内容不会再变,没必要每个 token 都解析它们。把已完成的块冻住,只跟踪正在写的最后一段(下文叫尾块),每个 token 只解析尾块。
为什么"已完成块不会再变"成立?因为 markdown 的块级语法里,一个块一旦被空行、代码围栏或列表标记结束,渲染结果只由自己的内容决定,后面再来新块改变不了它。这是块级语法的特性,也是增量切块能成立的根基------所以增量是"块级"的,不是字符级的。
arduino
┌─────────────────────────────────────────────────────────┐
│ 已完成的块:冻住,永不再变 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 块 1 │ │ 块 2 │ │ 块 3 │ ... │
│ └──────────┘ └──────────┘ └──────────┘ │
├─────────────────────────────────────────────────────────┤
│ 正在写的尾块:还能增长,每次只扫描这一段 │
│ ┌──────────────────────────────────────────────┐ │
│ │ "我正在写第 4 段:## 这是标" │ 当前 │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
切块的关键是判断什么时候一段文字算写完了。markdown 的块边界有三种:空行结束普通段落;代码围栏(fence,三个反引号 ``````````` 标记的代码块边界)开始或结束代码块;列表、标题、引用各有标记。容易踩的坑是代码块内部的空行------代码块里本来就有空行,见到空行就切,一段代码会被腰斩成两块,渲染直接断裂。所以要先判断是不是在围栏里:在围栏里只等闭合,不在才按空行切:
ts
let inFence = false
for (const line of lines) {
if (!inFence) {
if (line === '') finishBlock() // 围栏外:空行结束当前块
else if (line.startsWith('```')) inFence = true
} else {
if (line.startsWith('```')) inFence = false // 围栏内:只等闭合
}
}
前后对比:增量做法每个 token 只解析尾块,单次开销只和尾块长度有关,绝大多数 token 只是往尾块追加一个字符,尾块一完成就清空。n 个 token 累加从 O(n²) 降到 O(n),1000 个 token 的解析次数从 50 万降到 1000------标题里那个数字。
这一刀砍掉了 O(n²),但卡还没治完。
根因二:重渲------内容没变的块 render 也重跑
解析降下来之后,重新 profile 会发现一个新浪费:每个 token 到来时,React 仍会把所有块的 render 函数全部重跑一遍,再逐个比对。已完成块的内容明明没变,render 跑完的结果和上次一模一样,这次比对纯属浪费。这在 O(n²) 解析时被掩盖,解析快了就冒头。
记忆化(memoization,把函数上次的结果缓存起来,同样的输入直接复用)就是治这个。顺带一提,它之所以能生效,靠的是上一节增量切块建立的稳定块对象------已完成块在 token 之间不被重新创建,引用不变,记忆化才有意义。所以这两个优化虽然正交,但配合起来才完整。
给每个块算一个内容哈希,套上 React.memo,让它在哈希没变时直接跳过 render。为什么用哈希不用整段文本比较?一段 1000 字的块逐字符要比 1000 次,比哈希只比一次。这里用 cyrb53,一种非加密的快速哈希:
ts
export function cyrb53(str: string, seed = 0): string {
let h1 = 0xdeadbeef ^ seed
let h2 = 0x41c6ce57 ^ seed
for (const ch of str) {
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)
}
tsx
const Block = memo(
function Block({ seg, streaming }: Props) {
const text = seg.status === 'active' ? speculativeClose(seg.text) : seg.text
return <div>{renderMarkdown(text)}</div>
},
(a, b) =>
a.seg.id === b.seg.id &&
a.seg.hash === b.seg.hash &&
a.seg.status === b.seg.status &&
a.streaming === b.streaming,
)
比较函数里要比 id、哈希、状态这三样:id 是块的身份(换了块一定要重渲),哈希是内容指纹(内容变了要重渲),状态区分它是已完成还是正在写(影响要不要补全语法)。三者合起来才完整刻画了"这个块需不需要重新渲染"。
前后对比:页面上 100 个完成的块再来一个新 token,记忆化前 React 要跑 101 次 render,记忆化后只跑 1 次(那个正在写的块),render 调用从 O(n) 降到 O(1)。
根因三:主线程被解析挤占
前两个根因都治了,CPU 总开销已经很低。但模型吐得快的时候(实测每秒 30 多个 token),偶尔还是会顿一下。原因是主线程同时要解析 markdown、维护状态、渲染 React、响应交互,而浏览器主线程上一个任务超过 50ms 就会让人感觉到掉帧(这种长任务叫 long task,可以用 PerformanceObserver 采集)。解析和渲染抢同一个主线程,解析一忙,渲染和交互就得排队等。
解法是把切块和算哈希这两件重活搬到 Web Worker。为什么搬这两件、不把渲染也搬过去?因为 DOM 和 React 只能在主线程跑,Worker 没有 DOM,搬不过去;能搬走的只有不碰 DOM 的纯计算。主线程只保留渲染:
ts
// 主线程
const seg = new RemoteSegmenter(worker)
seg.push(delta)
const segments = await seg.getSegments()
// Worker 线程
self.onmessage = (e) => {
const seg = getOrCreate(e.data.id)
seg.push(e.data.delta)
self.postMessage({ segments: seg.getSegments() })
}
前后对比:主线程不再被解析占用,long task 从"解析加渲染"压缩到"只渲染",滚动和输入响应不再被切块挤占。
卡的三个根因都治完了。接下来是闪。
症状二:闪
闪来自半截语法。token 停在 **加粗 中间时,解析器并不知道它将来会闭合,要么把 ** 当字面字符,要么干脆不渲染;等真正的闭合符号到了,这段从普通文本变成 <strong>,DOM 结构变了,React 把整块拆掉重建,肉眼就是一抖。半截状态反复出现,抖动也就反复出现。
要消灭它,就得让闭合前后结构一样------办法是渲染前先按最可能的方式把没闭合的符号补上,让半截语法在闭合前就呈现出和闭合后相同的结构。这手法借鉴 CPU 的投机执行(speculative execution,不等条件确定就先按最可能的结果往下做,事后再核对),叫投机闭合(speculative closing)。
举个能推演的例子。模型吐到 **加 就停了,这是半截加粗。不补的话,**加 被当成字面文本;等真符号到了变成 **加粗**,又渲染成加粗的"加粗",结构从文本节点变成 <strong>,React 拆重建,闪一下。投机闭合的做法是渲染前先补成 **加**,渲染出加粗的"加",也就是 <strong>加</strong>;等真符号到 **加粗**,渲染出 <strong>加粗</strong>。两次结构都是 <strong>,只是文本从"加"变成"加粗",React 只更新文本节点,不拆重建,不闪。补的那一个 ** 在过程中完全看不出来。
实现是一个二十几行的函数,用奇偶计数找出没闭合的符号,而且它是幂等的(idempotent,同一输入不管执行多少次结果都一致)------已经合法闭合的输入原样返回,不会越补越多:
ts
function speculativeClose(text: string): string {
if (fenceCount(text) % 2 === 1) text += '\n```' // 代码块没闭合
if (backtickCount(text) % 2 === 1) text += '`' // 行内代码没闭合
if (boldCount(text) % 2 === 1) text += '**' // 加粗没闭合
if (text.endsWith('](')) text += ')' // 链接没闭合
if (bracketDiff(text) > 0) text += ']' // 方括号没闭合
return text
}
有一条必须守住的原则:投机只发生在渲染这一步,保存的内容不能动。组件渲染尾块时投机闭合一下再交给渲染器,但存起来的始终是模型原始输出的文本------用户复制、分享拿到的必须是模型真实说的内容,而不是被偷偷补过符号的:
tsx
function Block({ seg }: { seg: Segment }) {
const text = seg.status === 'active' ? speculativeClose(seg.text) : seg.text
return <div>{renderMarkdown(text)}</div>
}
前后对比:原来每个停在未闭合位置的 token 都会触发一次整块拆重建;投机闭合后,闭合前后 DOM 结构一致,拆重建次数降到 0,只剩下廉价的文本节点更新。
症状三:膨胀
膨胀来自 DOM 数量。一万字的文章大约 70 个块,全部挂在 DOM 上,滚动时浏览器要处理几万个节点,越滚越卡。视口虚拟化(viewport virtualization,只渲染屏幕可见范围加少量缓冲的元素,其余用占位 div 撑开高度)让常驻 DOM 只剩视口附近那十几个,跟文章总长脱钩。
第一个细节是滚动监听为什么用 IntersectionObserver 而不是 scroll 事件。scroll 每秒能触发几十上百次,回调里为了知道元素位置往往要读 getBoundingClientRect,这一读会强制浏览器同步重算布局,性能很差。IntersectionObserver 是浏览器原生的异步观察 API,只在元素进出视口时回调。再用一个 WeakMap 把同一个滚动容器里所有块的监听合并成一个,用 Map 分发回调:
ts
const REGISTRY = new WeakMap<Element, { io: IntersectionObserver; cbs: Map<Element, Cb> }>()
function observe(el: Element, root: Element, cb: Cb) {
let entry = REGISTRY.get(root)
if (!entry) {
const io = new IntersectionObserver(dispatch, { rootMargin: '600px 0px' })
entry = { io, cbs: new Map() }
REGISTRY.set(root, entry)
}
entry.cbs.set(el, cb)
entry.io.observe(el)
}
第二个细节是占位高度必须用实测值,不能估。假设一个块实际高 200px,离开视口时估成 150px 占位,它后面的所有块位置都会往上偏 50px;等用户滚回来,块按真实 200px 挂回去,位置又跳下去,整页抖一下。这种抖动用 CLS(累积布局偏移,Cumulative Layout Shift)来量化。正确做法是块离开视口前记下真实高度,占位就用这个值:
tsx
useEffect(() => {
return observe(el, root, (v) => {
if (!v) heightRef.current = ref.current.offsetHeight // 离开前记下真实高度
setVisible(v)
})
}, [])
if (!visible) {
return <div style={{ height: heightRef.current || 32 }} />
}
还有一条硬规矩:正在写的尾块绝对不能被虚拟化,否则用户看不到字还在蹦,整个流式效果就断了。只有已完成的块才虚拟化:
tsx
return seg.status === 'final' ? (
<VirtualBlock key={seg.id} enabled={virtualize}>{block}</VirtualBlock>
) : (
block
)
前后对比:常驻 DOM 块从 70 降到约 15(视口加缓冲)。滚动时浏览器要处理的节点数量从此和文章长度无关,一万字和十万字滚起来一样顺。
五个优化是正交的,各治一症
回头看,这五个优化没有先后依赖,是五个正交的技术选择:增量切块决定解析多少,记忆化决定渲染多少,投机闭合决定尾块稳不稳,虚拟化决定 DOM 多少,Worker 决定计算在哪个线程。它们各管一层,任意组合都成立------只做其中一两个也能拿到对应那部分收益,全做才把卡、闪、膨胀一次治干净。
项目里实现了四种可切换的渲染模式做对比:naive(暴力全量重渲)、memoized(只加记忆化)、incremental(完整增量)、worker-incremental(增量搬到 Worker)。切换后实时采集 React 提交次数、单帧最长耗时、帧率、掉帧数、常驻 DOM 块数、主线程 long task:
mode 提交次数 单帧耗时 帧率 掉帧 常驻DOM块
naive 多 高 低 多 ~70
memoized 少一些 中 中 中 ~70
incremental 少 低 高 少 ~70
worker-incremental 最少 很低 稳60 几乎0 ~15(加虚拟化后)
表里的"提交次数、单帧耗时、帧率、掉帧、DOM 块数"都是 PerfStore 真实采集的指标,趋势确定------越往右每一项都更好;但具体数值会随文章长度和机器不同变化,所以只标趋势不写死数字。真正要上线,建议在目标机型和典型文章长度上跑一遍采集面板,拿实测值再定性能预算。
模型每秒吐 30 多个 token,前端每 30 毫秒就要处理一次新输入,而一帧只有 16 毫秒。只有把每个环节的开销都压到最小------解析从 O(n²) 到 O(n)、render 从 O(n) 到 O(1)、DOM 从全量到视口、主线程从混合到隔离------才能在每个 token 到达后跟得上、不掉帧。