Agent 时代的开发者界面(七):GUI 上的 Markdown 流式渲染——全量重建与工程折中
系列第七篇。第 3 篇拆解了 TUI 上的 Markdown 流式渲染——降级映射、Stable Prefix Cache、表格 retreat、代码块盲区。这一篇是同一面问题的镜像:在拥有像素、字体引擎和 GPU 合成器的 GUI 世界里,Markdown 流式渲染为什么仍然是一个“打字机效果“就吃掉 60% CPU 的难题。
一、打字机效果 ≠ 增量渲染
用过 ChatGPT、Claude、FlowDown 的人一定熟悉打字机效果——AI 回复逐字出现。但这是视觉错觉。
真实情况:数据层是增量的,渲染层不是。
网络 SSE chunk → BalancedEmitter(增量吐出字符)
→ message.document += newText // 全文拼接
→ MarkdownParser.parse(fullContent) // ← 全量 AST 解析
→ TextBuilder.build() // ← 全量重建 NSAttributedString
→ textView.attributedText = doc // ← CoreText 全量布局
每一步 O(n)。n 随文本增长而变大。以下是 FlowDown 在一组本地实测里的 3000 字回复数据,反映的是特定设备和构建配置下的趋势:
| 文本量 | 解析 | 构建 | 布局 | 刷新频率 | CPU |
|---|---|---|---|---|---|
| ~500 字 | 轻 | 轻 | 轻 | 20 Hz | 低 |
| ~1500 字 | 中 | 中 | 中 | 15 Hz | 中 |
| ~3000 字 | 重 | 重 | 重 | 9 Hz | ~60% |
| 5000+ 字 | 很重 | 很重 | 很重 | 3 Hz | 高 |
应用已经做了降频处理——文本越长刷新越慢。但这只降低了“重做“频率,没降低单次“重做“的成本。3000 字时每秒仍有 9 次全量管线触发。
打字机效果是 BalancedEmitter 增量吐出字符制造的数据层假象。渲染层每次都在重做整个文档。
二、三层管线拆解
从 LLM token 到屏幕像素,经过六个阶段:
LLM token 流
→ ① 传输:SSE chunk 到达
→ ② 拼接:chunk 拼成字符串,节流控制
→ ③ 检测:判断当前文本是否"可解析"(大部分实现没有这层)
→ ④ 解析:Markdown → AST
→ ⑤ 构建:AST → NSAttributedString
→ ⑥ 布局:NSAttributedString → 像素
④⑤⑥ 深度耦合。问题不能靠单独替换某一层解决。
解析层——最根本的限制
以 cmark 为例(Swift 侧最主流的 Markdown 解析库,被 FlowDown/MarkdownView、Down 等使用):
cmark_parser_new() → 创建 parser
cmark_parser_feed() → 喂入整个字符串
cmark_parser_finish() → 结束解析,产出 AST
cmark_parser_free() → 销毁 parser
没有 pause(),没有 resume(),没有 ast_diff(old, new)。每次新 chunk 到达都必须走完整的 create → feed → finish → free 流程。
构建层
FlowDown 的 TextBuilder 源码里有一行:
assert(!previouslyBuilt, "TextBuilder can only be built once.")
整个 builder 假设输入是一份完整的解析结果。一次性产出完整的 NSMutableAttributedString,遍历所有 block node 做属性拼接。NSMutableAttributedString 本身有 append(_:)——但这条 builder 管线的设计假设不支持增量。
布局层
textView.attributedText = doc 之后,整段富文本重新进入文本系统计算。富文本布局比纯文本贵 50-100 倍——同样的 3000 字,纯文本布局 <1ms,富文本 20-60ms。差距来自字形查找(6+ 种字体 face)、CJK 字体回退链、换行断点评估、内存分配风暴。
2.1 O(n) 为什么这么重——常数因子拆解
O(n) 的大 O 记法掩盖了常数因子。以 iPhone 上 3000 字富文本 NSAttributedString(包含代码块、标题、加粗、链接、表格等多种样式组合)为例,一次 CoreText 布局的实际开销拆开看:
字形生成。 纯文本布局是 trivial 的——一个字体 face,逐字符排版。但 Markdown 渲染产出的是高度异构的 NSAttributedString:正文用 .systemFont,加粗/斜体用不同 weight,代码块用等宽字体(Menlo 或 SF Mono),标题用不同 size(H1-H6 至少 2-3 种),链接可能换 foregroundColor,emoji 触发 Apple Color Emoji 字体回退路径。每个字符的属性变更都可能触发一次新的字体 face 查找。3000 字符 × 5-6 种字体 face,加上 CJK 字符的字体回退链(CJK 字形在 systemFont 中不完整时需回退到专用中文字体),字形查找本身就是显著的 CPU 开销。
换行计算。 CoreText 的 line breaker 对每一行都要做宽度测量(候选断点位置的累积字符宽度)、断点选择(在允许的断点中选最优,考虑连字符、标点禁则)、中文特殊规则(行首禁则——。,、不能出现在行首,行末溢出——标点可以略微突出边界)。一个 3000 字的段落,假定平均每行 30 个字符、总共 100 行,断点评估的量级远比“扫描一遍字符串“大得多。
内存分配与 ARC 开销。 这是最容易被忽略的一项。每个 tick 的完整路径:cmark_parser_new + cmark_node_new × N + cmark_parser_free(malloc/free AST 节点树)→ NSMutableAttributedString.append() × M 个 block(每个 append 可能触发属性字典 NSDictionary 分配)→ CTFramesetterCreateWithAttributedString(内部分配 CTTypesetter + CTLine × 100 行 + 字形缓存)。9 次/秒 × 3000 字:大量短暂存活的对象在单次 runloop iteration 中创建和丢弃,ARC 的 retain/release 在这些对象间高频触发,autorelease pool 在 runloop 末尾 drain 时集中释放,产生额外 CPU spike。
量化对比:
| 操作 | 3000 字耗时(数量级) | 占比 |
|---|---|---|
| cmark 解析 | ~1-3ms | ~5% |
| TextBuilder 构建 | ~5-15ms | ~15% |
| CoreText 布局 | ~20-60ms | ~80% |
同一段 3000 字的纯文本布局(单一字体、无属性切换):< 1ms。差距是 50-100 倍。真凶不是文本长度,是属性异构度 × 高频触发。
2.2 CoreText 全量 invalidate 的本质
CTFramesetter 的 API 设计假设输入的 NSAttributedString 是“完整的、不会增量变化的“。当你修改 attributedText 并重新 set 时:必须重新创建 framesetter → 必须重新对整个内容做排布 → 每行 line fragment 的位置都可能因为前面内容的变动而偏移。CoreText 内部没有“脏区域“标记机制——它不知道哪些行变了、哪些没变。
对比 CSS layout engine:浏览器里 element.appendChild(newParagraph) 只标记父节点为 “layout dirty”,然后只重排那个子树,页面大部分内容保持原位。而 CoreText 的 textView.attributedText = newAttrString——一切 invalidate,从头到尾全量重排。
Markdown 的块级结构更是放大了这个问题。列表嵌套深度、代码块边界、角标定义——这些跨段落上下文依赖意味着即使 CoreText 支持 dirty reflow,Markdown 解析层的全局依赖也会让“局部修改“扩散为“全局 dirty“。
三、路线 A:边输出边渲染(FlowDown 模式)
BalancedEmitter:节流发射器
actor BalancedEmitter {
private var buffer: String = ""
private var frequency: Int // 发射次数/秒
private var batchSize: Int // 每次发射字符数
func add(_ chunk: String) {
buffer += chunk
batchSize = max(1, Int(ceil(Double(buffer.count) / Double(frequency)))) // 动态
dispatchLoopIfRequired()
}
}
batchSize 是动态的——缓冲区积压越多,每次发射字符越多,自适应追赶。
emotionalDamage 降频
文本越长 → 渲染越重 → 主动降频:
var emotionalDamage = 0 // 累计字符数
if emotionalDamage >= 5000 {
frequency = 3 // 3次/秒 — 极长文本
} else if emotionalDamage >= 2000 {
frequency = 9 // 9次/秒
} else if emotionalDamage >= 1000 {
frequency = 15 // 15次/秒
}
// 默认 20次/秒
设计思想:“宁可视觉上慢一点,也不能让 UI 掉帧。”
代价:O(n²) 从哪来
每次渲染是 O(n)。但流式场景下 O(n) 被执行了 O(n) 次:
tick 1: parse(50字) + build(50字) + layout(50字)
tick 2: parse(100字) + build(100字) + layout(100字)
...
tick 90: parse(3000字)+ build(3000字)+ layout(3000字)
总工作量 ≈ N²。降频让 t 不再严格正比于 N——但砍的是常数因子,复杂度阶没变。降频的收益集中在中等长度(1000-5000 字)——文本继续变长、频率触底(3Hz)之后,tick 数重新正比于 N,总工作量回归 N²。
四、路线 B:输出完毕再渲染
流式期间不做任何渲染,只把 chunk 拼进 buffer。流式结束后一次性做完整 Markdown 渲染。
流式中:buffer += chunk(字符串拼接,摊还 O(1) per chunk)
流式结束:一次完整 parse + build + layout
CPU 优势:管线只跑一次。代价:没有打字机效果——等了几秒后突然出现完整回复。适用于非实时对话场景(批量处理、后台任务)。
五、过渡方案:流式纯文本 + 结束切 Markdown
这是 ROI 最高的方案。
流式期间跳过④⑤⑥层,用纯文本展示。流式结束那一刻做一次完整 Markdown 渲染,切换成富文本。
流式中:纯文本渲染(无 parse/build 开销)
流式结束:一次性完整 Markdown 渲染
改动全在应用层——渲染入口加一个 if-else,不碰任何外部库。流式期间 Markdown 解析开销清零。
代价:流式过程中看到的是原始 Markdown 语法(**加粗**、# 标题),流式结束有一次 snap 切换。但流式过程中 Markdown 语法通常是未闭合的——**bold 没右半截、代码块没结尾 ```——当前方案下这些残缺语法的渲染结果也是错乱的。纯文本反而是干净的。
六、跨技术栈对比:谁解决了哪一层
解析层(④)所有人都站在同一条起跑线上——没有真正的增量 Markdown 解析。差距全在⑤⑥层。
| 解析层(④) | 构建层(⑤) | 布局层(⑥) | |
|---|---|---|---|
| 原生 CoreText | cmark 全量 | NSAttributedString 全量重建 | 全量 invalidate |
| 原生 TextKit 2 | cmark 全量 | NSTextContentStorage 增量追加 | NSTextLayoutManager 局部 invalidate |
| WebView | JS 全量 parse | React reconciliation → 最小 DOM 变更 | 浏览器增量 reflow |
| React Native | JS 全量 parse | React reconciliation → 最小变更集 | Yoga 增量布局 |
| Lynx | JS/C++ 全量 parse | 后台线程 diff → updateData 增量 | 原生增量 + list 虚拟化 |
下面展开 WebView、RN 和 Lynx 三条路——它们各自的优势、代价和适用边界。
6.1 WebView:增量 reflow 是出厂能力
浏览器渲染引擎(Blink / WebKit)从 90 年代 Netscape 时代就在解决“网页被 JavaScript 不断修改“的问题,增量 reflow 是它的第一性能力,不是后加的特性。
当 JavaScript 向 DOM 追加新节点时(appendChild),渲染引擎只标记受影响子树为 dirty:Style Computation 只对新增节点做 selector matching;Layout 从 dirty node 向上找到最近的 block formatting context root,向下做局部重排,兄弟节点不受影响;Paint 只重绘 dirty region;Composite 直接复用未变动的 GPU texture 图层。
但 Markdown 解析仍在 JS 层——marked.js、markdown-it 每次仍然是全量 parse。所以实际做法分两类:
做法 A:innerHTML 全量替换。 每次新 chunk 到达,把整段 Markdown 重新转成 HTML 字符串,赋给 innerHTML。这条路径没有节点级复用——innerHTML 赋值的标准行为是解析整段 HTML 字符串、销毁旧子树、构建新子树,浏览器不在这条路径上做新旧 DOM diff,不存在“结构一致就复用节点“的优化。它的收益在重建之后:新子树挂上文档时,渲染引擎按正常的 dirty 传播做增量 reflow 和重绘,兄弟容器和页面其余部分不受影响;而且 HTML 解析本身是高度优化的 C++ 路径,比 JS 层的 reconcile 便宜得多。所以“全量“全在 DOM 构建层,并不全在渲染层。
做法 B:React reconciliation。 Vercel AI SDK 的默认做法——useChat 每收到一个新 chunk 就触发一次重渲染(可用 throttle 参数节流)→ React 对使用 messages 的组件(通常是整个消息列表)做 Virtual DOM diff → 找到最小 DOM 变更集 → 只 apply 这些变更到真实 DOM → 浏览器收到最小 DOM 变更 → 最小化 dirty reflow。注意这里出力气的是 React 而非 SDK 本身——SDK 不含 Markdown 渲染逻辑,官方做法是让用户自行接入 react-markdown。React 的 Virtual DOM diff 替代了原生方案中 NSAttributedString 全量重建(⑤层),浏览器增量 reflow 替代了 CoreText 全量重排(⑥层)。
代价:WKWebView 进程隔离,每个 WebContent 进程占独立内存空间——占用随页面内容波动很大(数十 MB 到数百 MB),且 iOS 对单个 WebContent 进程设有系统级内存上限(随设备内存缩放,iPhone 大致从数百 MB 到约 2GB,iPad 更高),超限该进程直接被 jetsam 杀掉,表现为 WebView 白屏;聊天列表中多个 cell 不能都用 WebView;字体渲染和系统不一致;无法复用 Dynamic Type / VoiceOver。
6.2 React Native:新架构去掉了 Bridge 瓶颈
RN 经典架构(Bridge 模式)下,JS Thread 做 React reconciliation → Shadow Thread 用 Yoga(C++)做 flexbox 增量布局 → Main Thread 通过 Bridge 接收 mutation list 做原生视图增量更新。每次新 chunk 产生的微小变更都要经过异步 Bridge 序列化/反序列化。
新架构(Fabric + JSI,0.68+ 引入,0.76 起默认启用)去掉了异步 Bridge:JS 通过 JSI 直接同步调用 C++ 函数;Fabric 的 Shadow Tree 直接映射到原生 View Tree,支持优先级调度(高优更新可以打断低优更新)。对 Markdown 流式渲染的关键改进是:每次 chunk 产生的微小 DOM 变更不再经过异步 Bridge,布局计算可以在渲染帧空闲时执行,不阻塞主线程滚动。
但 RN 也不能做 Markdown 增量解析。它还有一个隐蔽问题:流式输出中,新 chunk 可能改变已有段落的结构(如代码块闭合后,之前被误判为代码块内容的东西需要重排),导致大量 key 失配,Reconciliation 退化为接近全量对比。
6.3 Lynx:引擎内置流式能力
Lynx(字节跳动出品)在两个关键设计上对 AI 流式场景特别友好:双线程架构(Main Thread + Background Thread)将网络流解析和业务逻辑处理放在非 UI 线程——token 到达 → 后台解析 → 生成 UI 更新 → 提交给主线程渲染,正好匹配 LLM SSE 流式数据的处理需求。fetchStream 在引擎层面提供了三端统一的流式资源接口,不像 RN 需要依赖第三方 SSE 库做桥接。updateData 支持从原生侧增量推送数据,不需要每次重刷整个模板。
Lynx 在流式渲染上的优势,本质上和 RN 新架构类似——后台线程处理流式数据和 UI diff,主线程只负责渲染。但 Lynx 把流式网络、模板更新、列表虚拟化做成了引擎内置能力,不需要开发者自己搭 pipe。
6.4 结论
不存在“增量 Markdown 解析“的跨平台魔法。 各技术栈只是在下游渲染架构上有不同程度的增量优化。WebView / RN / Lynx 的渲染架构天然更适应高频增量更新的冲击,原生 CoreText 在这方面是最脆弱的。如果你要做 Agent GUI 客户端,技术栈选型真正要考虑的不是“哪个能增量解析 Markdown“——不存在这样的方案——而是“谁的渲染引擎能更好地吸收高频增量更新的冲击“。
七、为什么移动端比桌面端严重得多
同一段 3000 字流式 Markdown,Mac 上 10-15% CPU,iPhone 飙到 60%。原因不在代码:
散热墙。 iPhone 是被动散热——持续高负载触发 thermal throttling 后 CPU 降频,单次 layout 从 30ms 升到 50ms,CPU 占用率反而更高,恶性循环。
CPU 微架构。 Mac M 系列芯片的共享 L2 与系统级缓存(SLC)更大,内存带宽更高。CoreText 布局阶段大量访问字形缓存——cache miss 时 iPhone 代价远高于 Mac。
WKWebView 成本。 WebView 的进程隔离在移动端是实打实的代价——每个 WebContent 进程占用独立内存空间,超限会被 jetsam 杀掉(直接白屏)。桌面端无所谓,移动端多 cell 场景是真实的稳定性风险。
八、四条优化路线的 ROI 排序
| # | 方案 | 改动面 | 收益 | 副作用 |
|---|---|---|---|---|
| 1 | 继续降频 | 一行参数 | CPU ~50-75% | 打字机变顿 |
| 2 | 流式纯文本 + 结束切 MD | 1-2 个调用点 | 流式 MD 开销清零 | 流式期间看原始语法 |
| 3 | 模块检测 + 分片渲染 | 手写检测器 + 改造渲染管线 | 视觉更好 | 工程量大 |
| 4 | 底层增量解析 | 三个库架构重构 | 理论上最优 | 极高复杂度 |
方案 2 的 ROI 最高。方案 4 留给相关库的长期演进——或者留给 WebView/RN,它们的渲染架构天生更适应这个场景。最稀缺的工程能力不是写出复杂的方案,是判断一个方案不该做。
九、对照第 3 篇:同一个问题,两种介质
第 3 篇讲 TUI 上的 Markdown 流式渲染,困在字符网格和物理行计算里。这一篇讲 GUI 上的同一问题,困在解析器和 CoreText 的全量重建里。核心矛盾完全相同:Markdown 是“写完再解析“的语言,Agent 是“边想边输出“的过程。 两种介质只是在不同的层面与这个矛盾搏斗。
TUI 的策略:降级映射 + Stable Prefix Cache + 防御性 retreat。GUI 的策略:节流降频 + 纯文本 fallback + 模块检测。
没有哪个介质解决了这个问题。都只是在不同的约束下做了不同的折中。
十、下一篇
第 8 篇:权限 UX 与 Diff UX——Agent 真正的“主界面“。前几篇——TUI 三部曲和 GUI 两篇——都在讲“怎么渲染“,下一篇开始讲“渲染什么“——在 Agent 的所有输出中,哪两样东西最值得精心设计界面。
本文分析基于 FlowDown 源码(pin MarkdownView 3.9.1)和 ForgeLoopTUI 项目,性能数据来自 FlowDown 流式卡顿分析笔记。