治好 AI 长回答的卡、闪、膨胀:渲染开销从 50 万次砍到 1000

13 阅读12分钟

治好 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 的块级语法里,一个块一旦被空行、代码围栏或列表标记结束,渲染结果只由自己的内容决定,后面再来新块改变不了它。这是块级语法的特性,也是增量切块能成立的根基——所以增量是"块级"的,不是字符级的。

┌─────────────────────────────────────────────────────────┐
│ 已完成的块:冻住,永不再变                                │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐                  │
│ │ 块 1     │ │ 块 2     │ │ 块 3     │ ...              │
│ └──────────┘ └──────────┘ └──────────┘                  │
├─────────────────────────────────────────────────────────┤
│ 正在写的尾块:还能增长,每次只扫描这一段                  │
│ ┌──────────────────────────────────────────────┐        │
│ │ "我正在写第 4 段:## 这是标"                │ 当前    │
│ └──────────────────────────────────────────────┘        │
└─────────────────────────────────────────────────────────┘

切块的关键是判断什么时候一段文字算写完了。markdown 的块边界有三种:空行结束普通段落;代码围栏(fence,三个反引号 ``` 标记的代码块边界)开始或结束代码块;列表、标题、引用各有标记。容易踩的坑是代码块内部的空行——代码块里本来就有空行,见到空行就切,一段代码会被腰斩成两块,渲染直接断裂。所以要先判断是不是在围栏里:在围栏里只等闭合,不在才按空行切:

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,一种非加密的快速哈希:

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)
}
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 的纯计算。主线程只保留渲染:

// 主线程
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,同一输入不管执行多少次结果都一致)——已经合法闭合的输入原样返回,不会越补越多:

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
}

有一条必须守住的原则:投机只发生在渲染这一步,保存的内容不能动。组件渲染尾块时投机闭合一下再交给渲染器,但存起来的始终是模型原始输出的文本——用户复制、分享拿到的必须是模型真实说的内容,而不是被偷偷补过符号的:

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 分发回调:

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)来量化。正确做法是块离开视口前记下真实高度,占位就用这个值:

useEffect(() => {
  return observe(el, root, (v) => {
    if (!v) heightRef.current = ref.current.offsetHeight   // 离开前记下真实高度
    setVisible(v)
  })
}, [])

if (!visible) {
  return <div style={{ height: heightRef.current || 32 }} />
}

还有一条硬规矩:正在写的尾块绝对不能被虚拟化,否则用户看不到字还在蹦,整个流式效果就断了。只有已完成的块才虚拟化:

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 到达后跟得上、不掉帧。