渲染管线与 scrollback · 让长会话也跑得动

一个保留全部历史、还要流式刷新的滚动区,怎么不卡?grok 靠零字节空帧、估算-落定的懒布局、增量 virtual_y 补丁,以及一个专门改造过的 ratatui Terminal 分叉。这一章看它渲染快的几个核心手段。

作者 David更新于 第 43 篇(共 60 篇)

渲染管线与 scrollback · 让长会话也跑得动

一个会话可能有几百条历史块,还在流式追加 token。全量重绘会卡死。grok 的渲染分两层:画帧、写终端的底层在 xai-grok-pager-render(render/draw.rs),滚动区的布局与缓存在 xai-grok-pager 的 scrollback/ 里。两层合起来,有一串让它跑得动的手段。

空帧不吐字节

核心在 draw_frame(它绕过了 ratatui 的 try_draw):一帧的序列是「同步更新开始 → 取帧并渲染(顺带收集 OSC 8 链接)→ flush_with_links 做 cell diff,返回有没有 cell 变化 → 判定空帧 → 写 PostFlush 转义(Kitty 图、通知等,仍在同步块内)→ 调光标 → 同步更新结束 → 整帧字节交给写线程」。关键:没变化的空帧直接 early-return、一个字节都不发——这才保住了光标闪烁(每帧都重发光标转义会打断闪烁计时:30fps 下,500ms 的闪烁周期永远走不完)。CursorState 还专门去重光标转义。

为此 grok 维护了一个 ratatui Terminal 的分叉(xai-ratatui-inline)。分叉的本意是 inline 视口:上游的 Terminal 不暴露操纵 inline 视口所需的内部状态。在此之上又加了几刀:让 flush 返回「这一帧有没有 cell 变了」、让 OSC-8 超链接参与 cell diff,还修掉了上游 Buffer::diff 在单元格数超过 65,535 时的 u16 溢出(420×160 的超大终端会把整个界面画进一个小角落)。为了流畅和正确去改上游库——这是产品对体验较真的程度。

scrollback:全保留 + 懒虚拟滚动

滚动区保留全部历史块(不是环形缓冲),滚动是虚拟的(只画窗口内的)。LayoutCache 存一份 virtual_y(各块累计行偏移的前缀和),定位某个滚动位置在哪块用二分。

真正的性能核心是懒测量——「估算,再落定」:

  • 批量加载/改宽时,给每块一个便宜的高度估算(不精确);
  • 只对视口窗口内的块升级成精确高度(要真做 markdown 折行才知道),窗口是可见块再往下多量 8 块;
  • 一个刻意的设计:测量窗口不含上边距——这样向上滚时,测量不会把锚定的顶部推走。作为补偿,恢复会话、钉在底部重建时,会预先精确测量视口上方 3 屏,第一次上滚就不会跳。

流式追加不全重排

流式 token 进来,push_chunk_to_agent 这类方法把 chunk 写进块、清掉它的渲染缓存,再把那块标脏(dirty_heights)。下一帧走快路径:只重测脏块,只要没有折叠/展开/增删这类结构变化、只是高度变了,就增量打补丁 virtual_y——对「尾部追加」是 O(1),不重算整个布局。这就是「模型一直吐字、界面丝滑滚动」的底层。

还有几个配套:

  • ScrollAnchor:改宽/重折行后,用「第几块、第几逻辑行、第几子行」重新锚定,而不是记绝对行号(重折后绝对行号就没意义了)。视口上方插入或删除块时,另有按稳定 EntryId 记录的 StructuralScrollAnchor,保证画面不跳。
  • 渲染缓存:每块的输出按 (宽度, raw 模式, 主题, 是否选中, cwd) 缓存(RefCell 内部可变),内容一变就整条失效;滚出测量窗口 ± 128 块就驱逐重型缓存(每 5 秒扫一次,选中的块除外),但保留高度不影响几何。
  • 折行引擎:基于 textwrap,带 joiner 元数据区分软/硬换行,好让复制时正确重接。

和《从 LLM 到 Coding Agent》对照

教学实现的「界面」不需要考虑这些——print 就完了。grok 的增量是把「保留全部历史 + 流式刷新 + 任意终端尺寸」做到丝滑:零字节空帧、估算-落定懒布局、增量 virtual_y、宽度稳定的锚点。为此不惜维护一个 ratatui 分叉。下一章看这些块里最复杂的内容:流式 markdown。


上一篇:TUI 架构 · 下一篇:终端里实时渲染 markdown

本页目录