想象你是快递分拣中心的理货员。远方的智能仓库(大模型)每“想好”一个字符,就立刻打包成小包裹,沿着传送带(网络流)源源不断送过来。你的任务是第一时间把包裹摆上货架(页面 DOM),让顾客(用户)看到最新内容。麻烦的是:传送带永不停歇,货架越摆越长,而你每次上架必须在 16.7 毫秒内完成——否则画面卡顿、掉帧,顾客体验直线下降。这篇文章要讲的,就是让理货员在滔滔不绝的包裹洪流中依然从容的几门手艺:虚拟滚动(只照看眼前货架)、增量更新(来一件理一件)、Markdown 增量解析(碎包裹边到边分拣)、代码高亮(给易碎品贴标签),以及如何用性能监控和工程实践把整套系统打磨顺畅。
掌握流式渲染优化,是 AI 应用前端同学的硬核本领:它决定大模型产出的“最后一公里”能否丝滑抵达用户,直接关系产品的流畅口碑与留存。
目录
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
SSE/Token流 DOM节点 (仅渲染可见区域)rendered增量DOM更新LLM流式输出前端渲染可视化:展示性能优化技术栈DOM节点 (仅渲染可见区域)rendered增量DOM更新LLM流式输出前端渲染可视化:展示性能优化技术栈 DOM节点 (仅渲染可见区域)rendered增量DOM更新LLM流式输出前端渲染可视化:展示性能优化技术栈DOM节点 (仅渲染可见区域)rendered增量DOM更新LLM流式输出前端渲染可视化:展示性能优化技术栈
流式渲染原理:传送带与 16.7 毫秒
你是一名快递分拣中心的理货员,面前是一条永不停歇的传送带。远方的智能仓库(大模型)每生成一个字,就打包成一个小包裹立刻投递,绝不攒着。而主管给你定下规矩:每 16.7 毫秒必须把眼前这批货摆上新货架——因为摄像机(显示器)每秒要拍 60 张照片,错过一拍,顾客看到的画面就会卡一下。
浏览器里的情况一模一样。大模型通过 SSE 或 WebSocket 把 token 一个块一个块推送到前端,每次到达都要走完一条流水线:把文本拼进容器、解析成 DOM、计算布局、绘制像素。这四个环节的总时间就是渲染一帧的耗时,只要超过 16.7 毫秒,帧率就会从 60fps 掉下去。
流式场景的典型陷阱是“每个 token 都全量重写”。很多第一版代码在 onmessage 里直接 container.innerHTML = 全部文本,等于每次包裹到达,都把整面货架拆了重搭。文本短时看不出问题,等对话积累到几千行,一次重写就是几百毫秒,于是出现“打字机一顿一顿”的经典卡顿。
理解了这 16.7 毫秒预算,就理解了流式渲染的一切优化方向:要么减少每次要干的活(增量更新),要么减少货架上同时摆的货(虚拟滚动),要么把最重的活往后挪(防抖、异步)。理货员不是变快了,而是学会不再重复劳动。
- T_frame(帧总耗时)$T_{frame}$ 表示渲染一整帧画面所需的时间,它是解析、布局、绘制三步耗时之和,也是判断卡顿的总账本。
- T_parse(解析耗时)$T_{parse}$ 是浏览器把新到文本解析成 DOM 结构的时间,全量重写 innerHTML 会让它急剧膨胀。
- T_layout(布局耗时)$T_{layout}$ 是计算元素尺寸位置的回流(reflow)时间,DOM 节点越多、改动越深,它越贵。
- T_paint(绘制耗时)$T_{paint}$ 是把最终样式画到屏幕像素上的时间,涉及合成与光栅化。
- 帧预算 16.7ms$\frac{1000}{60}$ 是 60fps 下每帧的额定预算,约等于 16.7 毫秒,超支即掉帧。
虚拟滚动:只照看眼前的货架
货架越摆越长——聊了 100 轮的天,文本行数轻松过万。理货员不可能同时照看一万层货架,但顾客的眼睛一次只能看到那么几层。聪明的理货员干脆只摆眼前 8 层,其余的行只记在台账(内存数组)里,滚动条一拉,再快速换上对应那几层。
这就是虚拟滚动的全部秘密:DOM 里永远只存在可视区(加一小圈缓冲)的行,数据则完整保存在 JavaScript 数组里。外层滚动容器高度不变,靠一个透明的占位元素把滚动条撑到“所有行加起来”的高度,让用户感觉自己在滚一个超长列表。
实现时最关键的是“行号 ⇄ 滚动位置”的换算。假设每行固定高度 itemH,监听 scroll 事件拿到 scrollTop,就能算出当前应该渲染第几行到第几行。UI 库如 react-window、vue-virtual-scroller 都是这个思路的成熟封装。
虚拟滚动本质上是“用滚动位置的数学计算,换掉成千上万个 DOM 节点”。台账上的货一件不少,货架上永远只有十来件——理货员的手终于闲下来了。
- scrollTop 与 itemH 的商scrollTop 是容器已滚过的像素高度,itemH 是每行固定高度,二者相除得到“已滚过了多少行”。
- 向下取整$\lfloor \cdot \rfloor$ 把小数行号取整,得到第一个完整可见的行号。
- 视口底边行号scrollTop + viewH 是视口底边的像素位置,除以行高得到最后可见行号。
- buffer 缓冲修正上下各多渲染 buffer 行,防止快速滚动时露出白屏,最终区间的起点要取非负。
增量更新:来一件,理一件
光有虚拟滚动还不够。货架上已经摆好的货,如果每次新包裹到了都要全部挪一遍位置,照看 8 层和照看 1000 层一样累。很多聊天界面卡顿的第二大元凶,就是“每次更新整棵树”。
增量更新的思路朴素极了:来一个包裹,就在货架末尾加一件,前面的货动都不动。放到前端,就是每次 chunk 到达只追加新的 DOM 节点或文本片段,绝不重建已有内容。LLM 输出天然是“块”状的——每次 SSE 消息就是一个 chunk,正好一块一块地接。
具体手法:纯文本场景用 textContent 追加或 insertAdjacentText;结构化场景维护“已渲染行数”指针,新数据只创建从指针到末尾的节点;多个 chunk 在同一帧内到达时,用 requestAnimationFrame 合并成一次渲染,避免一帧里重复布局。
增量更新的成本只和“新到的货”成正比,和“货架总长”无关。这就是它和全量重绘的本质区别:一个干 k 件活,一个干 N 件活。只要 k 远小于 N,界面就能保持丝滑。
- c_0(单块渲染成本)$c_0$ 是渲染一个数据块(一行文本、一个 DOM 节点)的固定开销,包括节点创建与插入。
- C_inc(增量成本)$C_{inc}$ 是增量更新的总成本,只与本次新增块数 $k$ 有关,与存量无关。
- C_full(全量成本)$C_{full}$ 是每来一个块就把全部 $N$ 块重画一遍的成本。
- k ≪ N 的意义$k \ll N$ 表示新增的远少于已有的,因此增量更新的成本优势成立——这正是流式输出的天然特征。
Markdown解析:碎包裹边到边分拣
大模型特别喜欢写 Markdown:标题、列表、表格、代码块。这些是“包装规格说明书”——理货员得按规格把散装字符分拣进不同的货箱。可麻烦在于,包裹是碎着到的:“## 标题”可能分三次才到齐,理货员不能等全部到齐才开始分拣,那样顾客早就等急了。
流式 Markdown 解析的关键词是“边到边”:边到达边解析。做法是维护一个增量解析器——每次新 chunk 到达,只重新解析“上一个不完整块 + 新 chunk”,已经完整的块绝不重扫。以空行为分隔符可以把文档切成块级单元,每个块有“未完成/已完成”状态:例如代码块出现 ``` 但没闭合,就把它标记为未完成,留在缓冲区,等下一个 chunk 到达时继续吞并。
成熟的库(marked、remark、micromark)都提供了底层解析接口,可以在其上叠加这个增量策略。另外,大模型生成的内容属于“用户可控输入”,必须过一遍 DOMPurify 之类的消毒器,防止 Markdown 里的原始 HTML 变成 XSS 攻击。
增量解析相当于给每个货箱装上“未满”指示灯:满箱入库,半箱继续接货。顾客看到的是标题、列表、代码块一层层长出来,而不是最后突然从纯文本跳变成富文本。
- k_i(第 i 次到达的块长)$k_i$ 表示第 $i$ 个 chunk 带来的新字符数,流式场景下它通常是个小常数。
- 增量总成本 O(n)每次只解析新块,所有块累加起来正好覆盖全文一次。
- 全量重解析 O(n²)如果每次都重扫全文,第 $i$ 次要扫前 $i$ 个块,累计是等差求和。
- 差距随增长拉大$n$ 越大,$O(n)$ 与 $O(n^2)$ 的差距越悬殊。
代码高亮:给易碎品贴标签
Markdown 里最重的货箱是代码块。它不仅要显示,还得“上色”:关键字一种颜色、字符串一种颜色、注释又一种颜色。理货员每收到一截代码就从头到尾重新涂一遍色,很快就会手抽筋。
代码高亮 = 词法分析(把代码切成 token)+ 着色(按 token 类型映射颜色)。highlight.js、Prism、Shiki 都是干这个的,但它们的全量高亮对长代码块很昂贵——尤其流式场景下代码还在不断变长,每来一个字符都全量跑一遍分词,开销完全不可接受。
三个优化招数:第一,防抖——停止输入 200 毫秒后再高亮,避免每个 token 都触发一次;第二,缓存——同一段代码的高亮结果存起来,内容没变直接复用,滚动回来不再重复计算;第三,只高亮可视行——与虚拟滚动结合,屏幕外的代码行先不上色,等滚到再补。
给易碎品贴标签是必须的,但不必每来一个碎渣就重贴一遍全套标签。停顿一下、看准了、一次性贴好,理货员的效率反而更高。
- t_last(最后输入时刻)$t_{last}$ 是最后一个新 token 到达的时间点,每次到达都会刷新它。
- Δ_wait(防抖等待窗)$\Delta_{wait}$ 是防抖等待窗口,常用值 200 毫秒,表示“停止输入这么久才动手”。
- 防抖触发条件只有当前时刻已经越过 $t_{hl}$ 且期间没有新 token 到达,才真正执行高亮。
- 高亮频率下降高亮从“每 token 一次”降为“每停顿一次”,触发次数大幅减少并受等待窗控制。
性能监控:分拣中心的监视屏
分拣中心的天花板上装着监视屏,显示理货员每时每刻的繁忙程度。哪个环节堵了、哪段时间超过额定负荷,管理者一目了然。前端优化也一样,凭感觉说“好像不卡了”不算数,得用数字说话。
两个最核心的指标:FPS(每秒帧数)和 Long Task(长任务)。浏览器规定,任何主线程任务只要超过 50 毫秒,就记一次 Long Task——这就是理货员被一个大包裹卡住的瞬间。FPS 低于 30 说明画面已经开始卡顿,低于 20 就是肉眼可见的幻灯片。
实操中用 PerformanceObserver 监听 longtask 事件,用 requestAnimationFrame 的时间戳自己算 FPS,再把数据打进日志或监控平台。React 项目还可以用 Profiler API 定位具体哪个组件重渲染最贵。优化前后各跑一轮,对比 Long Task 次数和平均帧耗时,效果一目了然。
监视屏的作用不是好看,而是让优化有方向、有效果。理货员是否从容,不看嘴上说,看监视屏上的曲线。
- T_task(任务耗时)$T_{task}$ 是浏览器主线程上单个任务的执行时长,从任务开始时间到结束时间。
- 50ms 阈值超过 50 毫秒的任务被浏览器标记为 Long Task,同时会阻塞渲染。
- 对帧率的传导长任务占用了本该属于渲染的预算,导致帧率下降、用户可感知卡顿。
工程实践:组装一条流水线
单靠一招半式撑不起一个流畅的分拣中心。理货员再快,如果包裹标签贴错、台账混乱,一样会乱套。真正的工程化,是把前面所有技巧组装成一套分层协作的系统。
推荐三层架构。数据层:接收 token、拼接缓冲、维护完整文本和行数组;解析层:Markdown 增量解析 + 代码高亮防抖;渲染层:虚拟滚动 + 增量 DOM 追加。层与层之间解耦——数据层不知道渲染层怎么画,渲染层只管消费解析层输出的块。配套策略包括:缓存已解析的 Markdown 块和已高亮的代码段,避免滚动回来重复计算;控制并发——高亮和解析任务用 requestIdleCallback 调度,别抢占渲染;还要有降级方案——检测到设备性能差时关闭高亮、加大虚拟滚动缓冲、降低动效,保证基本可用。
就像分拣中心把“接货—贴标—上架”分成不同工位,每个工位只专注一件事,整条流水线才能扛住高峰。你的聊天界面,也应该是一条分工明确的流水线,而不是一个手忙脚乱的万能理货员。
- 视口可容纳行数viewH 是视口像素高度,itemH 是行高,二者相除向上取整得到“一屏几行”。
- 双端缓冲上下各加 buffer 行,防止快速滚动的白屏闪烁,得到实际渲染行数。
- N_total(数据层总行数)$N_{total}$ 是完整文本的行数,虚拟滚动下它只存在于数组台账,不进入 DOM。
- 数量级对比一屏约 10 行 vs 上万行总量,渲染成本与总量解耦。
AI 应用场景
场景 1:AI 对话助手(类 ChatGPT)前端
阶段:应用层前端渲染
解决的问题:传统聊天界面把整段对话丢进虚拟 DOM diff,流式回复越长越卡。虚拟滚动将 DOM 节点数从上万压到几十,增量更新让每次 chunk 只追加一行,Markdown 增量解析让表格和代码块即时成形。
具体案例:智能客服对话界面,用户提问后机器人流式输出长回答;回答中途用户滚动查看上方历史消息,界面依然保持 60fps,代码块结束后 200ms 内完成高亮,无白屏、无闪烁。
场景 2:AI 代码生成编辑器(类 Copilot)
阶段:应用层交互渲染
解决的问题:代码补全以流式输出,逐字渲染 + 实时高亮开销极大。防抖高亮避免每个 token 重绘,缓存复用已高亮行,滚动大文件时虚拟滚动保证编辑器不卡。
具体案例:IDE 插件里 AI 正在生成一段 200 行的排序算法,用户同时滚动查看文件其他部分;高亮延迟 200ms 后一次性完成,光标无闪烁,滚动无白屏。
场景 3:实时会议纪要生成
阶段:应用层内容渲染
解决的问题:会议转写摘要以流式输出长文档,全量渲染会随文档变长越来越卡。增量 Markdown 解析让标题、列表、待办事项实时结构化呈现。
具体案例:线上会议进行到一半,右侧纪要面板已滚动出 3000 行 Markdown 文本,包含实时更新的待办列表和代码片段;渲染层始终只绘制可视区,CPU 占用稳定,设备风扇不再狂转。
Python 演示
"""模拟 LLM 流式输出的前端渲染器:增量追加 + 虚拟视口裁剪""" class StreamRenderer: """把 token 块流式喂入,只处理新增行,只渲染可视区""" def __init__(self, item_h=28, view_h=224): self.item_h = item_h # 每行像素高度 self.view_h = view_h # 视口像素高度 self.lines = [] # 数据台账:全部完整行 self.nodes = [] # 已渲染的节点(模拟 DOM) self._pending = '' # 尚未成行的半截文本 def feed(self, chunk): """到达一个新 chunk,增量处理:只追加新行""" self._pending += chunk parts = self._pending.split('\n') new_lines = parts[:-1] if len(parts) > 1 else [] for line in new_lines: self.lines.append(line) self.nodes.append(line) # 增量创建节点,老节点不动 self._pending = parts[-1] # 半截行留到下一个 chunk return list(new_lines) def visible_range(self, scroll_top): """虚拟滚动:由滚动位置算出可视行区间(含缓冲)""" buffer = 1 start = max(0, scroll_top // self.item_h - buffer) end = min(len(self.nodes), (scroll_top + self.view_h) // self.item_h + buffer) return start, end def render(self, scroll_top): """只渲染可视区内的行,返回 [(行号, 文本), ...]""" s, e = self.visible_range(scroll_top) return [(i, self.nodes[i]) for i in range(s, e)] if __name__ == '__main__': r = StreamRenderer() chunks = ['# LLM', ' 流式输出\n\n', '这是第一段,', '包含列表:\n- 项目一\n', '- 项目二\n\n', '完毕。'] for c in chunks: added = r.feed(c) print('chunk=%r -> 新增 %d 行 | 节点总数=%d' % (c, len(added), len(r.nodes))) print('视口(顶部) :', r.render(0)) print('视口(滚3行):', r.render(3 * r.item_h))
self._pending += chunk半截文本拼入等待区,避免换行符把一行拆碎new_lines = parts[:-1] if len(parts) > 1 else []只取已成完整行的部分,末尾半截保留self.nodes.append(line)增量创建节点,已有节点完全不动scroll_top // self.item_h滚动像素换算成行号,虚拟滚动的核心换算min((scroll_top + self.view_h) // self.item_h + buffer, len(self.nodes))视口底边行号加缓冲,且不越界
常见误区
误区输出“一顿一顿”肯定是网络不好,token 到达慢,等网络好了自然就流畅。
正解网络慢是“字出得慢”,画面卡是“帧率掉”。前者是传送带送得慢,后者是理货员被卡住了。真正的卡顿通常来自渲染层:每个 token 全量重写容器、布局抖动、高亮刷屏,导致主线程超过 16.7ms 预算。
为何常见:流式输出的直观感受是“字慢慢蹦出来”,容易把渲染瓶颈误归因于网络延迟。
误区虚拟滚动不就是滚动到哪才加载到哪吗?和图片懒加载一回事。
正解懒加载是内容加载一次后常驻 DOM,节点数随滚动越来越多;虚拟滚动则数据全量在内存,DOM 只保留可视区那几十行,节点数恒定。一个是“货到了就摆上架”,一个是“货都在台账里,只摆眼前几层”。
为何常见:两者都涉及“可见才处理”,初学者容易混淆触发时机与数据归属。
误区反正最终要完整渲染,不如等 LLM 全部输出完,一次解析渲染,省事。
正解等全文到齐,用户会长时间盯着纯文本,最后突然跳变成富文本,体验极差;且结尾一次性解析几千行同样会触发长任务卡顿。增量解析让文本边到边结构化,总成本 O(n),而全量重扫是 O(n²)。
为何常见:后端批量思维惯性强,没意识到浏览器渲染是“持续交付”而非“一次性交付”。
误区虚拟 DOM 那么快,每次 setState 全量更新列表,框架 diff 一下就好了。
正解框架只优化 diff 算法本身,但上万节点的 diff 依然昂贵;而且 diff 之后还是要真实操作 DOM。流式场景的正确做法是从源头减少节点和更新范围——虚拟滚动 + 增量更新,而不是把压力全丢给框架。
为何常见:框架宣传“高性能”给人万能错觉,忽略了复杂度随数据规模增长的物理定律。
误区高亮不就是改个颜色吗?直接每个 token 同步高亮一遍,没多少开销。
正解高亮需要词法分析把代码切成成百上千个 token,长代码块全量高亮一次可达几十毫秒,流式场景下每来一个字符跑一遍,必然卡顿。需要防抖 200ms、缓存结果、只高亮可视行三招齐下。
为何常见:看到的只是“换颜色”的结果,看不到背后的分词计算量。
面试题
Q1. LLM 流式输出时,浏览器每帧的渲染时间预算约为多少?
- A.16.7ms← 正确答案
- B.50ms
- C.100ms
- D.1000ms
答案:A。浏览器以 60fps 刷新时,每帧预算为 1000/60≈16.7ms,超过即掉帧。就像分拣主管给理货员定的上架时限;50ms 是长任务阈值而非帧预算。
Q2. 虚拟滚动的核心思想是什么?
- A.把数据分批加载到内存
- B.仅渲染视口内的节点← 正确答案
- C.用图片代替文本
- D.压缩数据体积
答案:B。虚拟滚动让 DOM 里只存在可视区(加缓冲)的节点,其余数据保存在数组台账中。分批加载是懒加载,与虚拟滚动不同。
Q3. 增量更新相比全量重绘的最大优势是?
- A.网络传输更快
- B.只增量处理新增数据← 正确答案
- C.减少 token 数量
- D.让 LLM 生成更快
答案:B。增量更新只处理新增块,成本从 O(N) 降到 O(k),类似理货员来一件理一件。网络速度和 token 生成是上游的事,前端渲染管不到。
Q4. 流式 Markdown 增量解析需要处理哪些问题?
- A.块级元素跨 chunk 截断← 正确答案
- B.代码块未闭合← 正确答案
- C.列表缩进不完整← 正确答案
- D.必须等完整文本才能解析
答案:ABC。chunk 可能把一个标题、代码块或列表切成几段,所以要有“未完成块”状态并保持挂起。增量解析恰恰不需要完整文本,D 是要打破的错误假设。
Q5. 代码高亮性能优化中的“防抖”是指?
- A.降低字体抖动
- B.停顿后再高亮← 正确答案
- C.高亮前清空缓存
- D.仅高亮第一行
答案:B。防抖是停止输入(新 token 到达)一段时间(如 200ms)后再执行高亮,把连续到达的 token 合并成一次操作,避免每来一个字符都触发高亮。
Q6. 哪些属于浏览器“长任务”(Long Task)的特征?
- A.任务耗时超过 50ms← 正确答案
- B.会阻塞主线程← 正确答案
- C.导致帧率下降← 正确答案
- D.只在移动端出现
答案:ABC。Long Task 的判定是主线程任务超过 50ms,它会阻塞渲染导致卡顿。它和移动端无关,桌面端同样普遍存在。
Q7. 虚拟滚动中,滚动条的高度由什么撑起?
- A.实际渲染的 DOM 节点
- B.容器的占位元素与内边距← 正确答案
- C.浏览器原生滚动条
- D.图片的高度
答案:B。真实渲染的节点只有可视区几十个,撑不起整根滚动条,必须用占位元素/内边距模拟全量高度。就像货架上只摆几层货,但台账标着整面货架的高度。
Q8. 下列哪项是流式输出前端渲染的常见性能瓶颈?
- A.网络带宽不足
- B.token 到达即全量重写← 正确答案
- C.GPU 性能差
- D.显示器刷新率太高
答案:B。最大瓶颈是每个 token 到达就全量重写容器,文本越长单次重写越贵。纯文本场景下 GPU 和刷新率很少成为瓶颈。
Q9. 下列哪些属于流式渲染常用的性能指标?
- A.FPS 帧率← 正确答案
- B.Long Task 长任务耗时← 正确答案
- C.内存占用← 正确答案
- D.模型参数量
答案:ABC。前端渲染指标关注帧率、长任务耗时和内存占用。模型参数量是大模型侧属性,与前端渲染性能无关。
Q10. 在虚拟滚动 + 增量更新的架构中,以下哪项做法是错误的?
- A.维护数据层与渲染层分离
- B.缓存已解析的 Markdown 块
- C.新 chunk 全量重新高亮← 正确答案
- D.rAF 合并同帧内更新
答案:C。正确架构应数据/渲染分离、缓存解析结果、用 rAF 合并更新;“每来一个新 chunk 就全量重新高亮所有代码块”会让高亮成本随文本增长,与增量原则背道而驰。