一条回复流二十分钟,界面得跟着长

写 AI Chat 客户端的人都绕不过这件事:模型的回答不是一次性给你的,它一个 token 一个 token 往外吐,中间还要插工具调用、插代码块、插一段思考。用户盯着屏幕,其实是在看一棵树一边长一边被人重新摆位置。

GetPullRequest 那个团队最近写了篇长文,讲他们怎么在 Flutter 里把这件事做出来。最扎人的一句是说 Flutter 官方的 GenUI 包:

It only drew the UI after the model’s full response had arrived and been parsed.

意思是它非得等整段回答收完、解析完才画界面。普通问答等几秒还行,agent 一轮干二十分钟就不行了:工具卡早就该出来了,你还在看菊花。

我顺着这篇帖子在 macOS 上把几个关键场景复现了一遍,量了数。这篇讲三个坑,每个坑都配一个能跑出来的数字。

坑一:每个 token 都重解析一遍 Markdown

最朴素的写法是:来一片文本,把累积起来的全文丢给 Markdown 解析器,拿到节点树再渲染。看起来天经地义,代价是每次都在解析「已经有过的内容」。

我拿一段 886 字符的 agent 回复做样本,里面有散文、一张三行表格、一个 dart 代码块、两层嵌套列表,按 4 字符一片切成 222 片,模拟流式到达。三种做法对比如下。

做法总耗时说明
每片都全量重解析28 毫秒解析 222 次,单次最慢 0.62 毫秒
只在正在增长的最后一块内重解析4 毫秒已完成的块只解析一次
收完再一次性解析0.148 毫秒就是 GenUI 那种做法的成本

28 毫秒听上去不多,但它不是平均分在 222 片上。开头的 50 片平均 0.046 毫秒,最后的 50 片平均 0.199 毫秒,越往后越贵,因为累积的文本越来越长。这就是个平方级的活:886 字符的回复,朴素做法一共处理了约 98K 字符,是原文的 111 倍。

换成真实长度的回答更明显。同一个做法,回复长度翻倍,耗时涨两三倍:

886 字符 / 222 片:16 ms
1774 字符 / 444 片:61 ms
3550 字符 / 888 片:229 ms
7102 字符 / 1776 片:899 ms

一帧的预算是 16.7 毫秒。等到 3550 字符那一档,光解析一次尾部就要吃掉好几毫秒,再叠加布局和绘制,掉帧是必然的。

同一段回复的三种解析策略

那篇帖子的解法是段落级增量:已完成的块只解析一次,只有正在长的那一块反复解析,同时把「每片都通知 UI 刷新」改成「一个微任务合并通知一次」。

逐行解析会把结构打散

还有个更隐蔽的坑,跟「让用户能按行点选评论」这个产品需求绑在一起:如果你的渲染层是一行一个 widget,每行单独送去解析,Markdown 的结构就散了。

同一段内容,逐行解析出来的标签序列是这样的:

[p, p, p, p, p, pre, p, p, pre, ul, li, pre, pre]

分块解析出来的是这样:

[p, table, thead, tr, th, th, tbody, tr, td, td, tr, td, td, pre, ul, li, li, li]

对一下数:表格逐行解析出 0 个 table(四行表格变成五行带竖线的文字),分块解析出 1 个;代码块逐行解析出 4 个 pre(本该是一个整体),分块解析出 1 个;嵌套列表逐行只剩 1 个 li(本该三层),分块解析出 3 个。

嵌套列表那条最容易漏。Markdown 里行首四个空格的意思是「这是代码」,所以第二层列表一旦被单独拿去解析,它就变成了一段代码块,屏幕上显示成一个灰块,前面还挂着一个裸的减号。

逐行解析与分块解析的结构差异

顺带一个更基础的坑:markdown 这个包的表格要显式开扩展,默认的 Document() 解析出来是 0 个 table,换成 ExtensionSet.gitHubWeb 才是 1 个。

还有个流式特有的:一片文本可能在三个反引号中间断掉。实测把这样半截的文本送去解析,尾部一个或两个反引号会直接变成正文里的字面字符,屏幕上先跳出两个孤零零的 `,下一片到了才变成一个正常代码块。而如果恰好是完整的三个反引号、内容还没来,会立刻出现一个空的代码框。

坑二:你往上翻,它把你拽回底部

聊天列表是反着排的(最新在最下面,offset 0 就是底部)。用户往上翻 300 像素去回看历史,这时候新 token 到了,如果代码里什么都不判断、直接滚到底,画面会被生生拽回去 300 像素。

实测:offset 从 300 跳到 0,视野被拉动 300 像素。正在读的那段话直接消失。

改成「只有距底部 200 像素以内才自动滚」之后,同样是 offset 300 的状态,新内容到达时 offset 保持 300,不打扰用户。

第二个抖动来源是卡片展开。视口上方有一张卡片从 100 像素高变成 400 像素,可见内容整体位移 300 像素,手指下面的东西就漂走了。补偿办法是先量一次高度差,等这一帧排版完再把滚动位置挪回同样的距离:实测按 300 像素补偿后,锚点残余位移 0.0 像素。

那篇帖子还提到一个反向的坑:展开好办,折叠难办,因为 Flutter 在你来得及修之前已经把滚动位置调过了,你得记住变化之前的位置。

坑三:一条很长的回复当成一个 widget

前两个坑都在「文本」这一层,第三个在列表这一层。老写法是一条回复塞进一个 Column,里面是几十上百个块。问题是 Flutter 只要看到这个 widget 里有一部分在屏幕上,就得把整个 Column 建出来。

500 个块的实测对比:

渲染方式首次渲染实际构建的块一次更新
整条回复一个 Column232 毫秒500 个30 毫秒,重建 500 个
一行一块,用 ListView.builder24 毫秒14 个(只建可见的)只重建变化的那行

232 毫秒相当于十四帧,第一次打开就卡一下;一次更新 30 毫秒也要掉两帧。改成分块之后,首次只建 14 个块,快了一个数量级。

500 个块的两种渲染方式

还有通知频率。20 个 chunk 在一个事件循环里到达,如果每个都调一次 notifyListeners(),UI 就被要求刷新 20 次;用一个小标志位把它们合并进一个微任务,通知次数变成 1 次。屏幕上看到的结果一样,白干的活少了十九份。

那篇帖子里我最认同的一句,也是这三节合起来的结论:

the widget that owns a stream should be the smallest widget that shows it.

翻译过来就是,谁的流谁负责,别让一个大家伙替你监听。

这些数字怎么读

先交代口径:上面这些是在 flutter test 的无头环境里量的,量的是解析、构建、布局的 CPU 时间,不含 GPU 光栅化。所以看的是量级和相对差异,不等于真机帧率;真机上还会叠加字体排版、图片解码这些开销,只会更糟。

三个坑其实是同一件事的三个切面:都在为「已经处理过的内容」重复付钱。全量重解析在重复解析,整条回复一个 widget 在重复构建,每 chunk 通知一次在重复刷新。要修的也就不是三处代码,而是一个习惯。

顺带说个绕不开的现状:很多人第一反应是装 flutter_markdown 把 Markdown 交给官方包,但它在 pub.dev 上已经是停维护状态,最后一次发版停在 2025 年 5 月的 0.7.7+1,包页上指向了接手的 flutter_markdown_plus。流式渲染这块,官方那条路目前是断的,社区和各家客户端都在自己写。

你那边是怎么处理流式 Markdown 的,逐块解析还是整段重排?欢迎评论区聊聊。