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

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

相关推荐
AlienZHOU7 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc11 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师12 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen13 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒13 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow13 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81813 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007813 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明13 小时前
第 6 章 组件进阶
前端·vue.js
江华森13 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端