Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 解析。差距全在⑤⑥层。

解析层(④)构建层(⑤)布局层(⑥)
原生 CoreTextcmark 全量NSAttributedString 全量重建全量 invalidate
原生 TextKit 2cmark 全量NSTextContentStorage 增量追加NSTextLayoutManager 局部 invalidate
WebViewJS 全量 parseReact reconciliation → 最小 DOM 变更浏览器增量 reflow
React NativeJS 全量 parseReact reconciliation → 最小变更集Yoga 增量布局
LynxJS/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流式纯文本 + 结束切 MD1-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 流式卡顿分析笔记。