一条回复流二十分钟,界面得跟着长
写 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 个块的实测对比:
| 渲染方式 | 首次渲染 | 实际构建的块 | 一次更新 |
|---|---|---|---|
| 整条回复一个 Column | 232 毫秒 | 500 个 | 30 毫秒,重建 500 个 |
| 一行一块,用 ListView.builder | 24 毫秒 | 14 个(只建可见的) | 只重建变化的那行 |
232 毫秒相当于十四帧,第一次打开就卡一下;一次更新 30 毫秒也要掉两帧。改成分块之后,首次只建 14 个块,快了一个数量级。

还有通知频率。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 的,逐块解析还是整段重排?欢迎评论区聊聊。
