AI 流式界面提交频率预算:按帧合并、INP 与长动画帧
网络流每到一个 token 就 setState 会放大 React 提交次数。本文只约束事件到 commit 的频率与新鲜度,用按帧合并、Event Timing 和 LoAF 建立门禁。
本文要点
- 主题聚焦:AI、React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
AI 对话的网络吞吐和提交频率不是同一个合同。服务端可能每秒送来 40 个片段,浏览器却没有必要执行 40 次 React commit。LLM 流式前端实现 已给出 token 缓冲与 rAF 入门做法,RAG 流式 UI 也介绍过节流。本篇只把“事件到 commit 的次数与新鲜度”变成可复现合同;每次 commit 内的解析工作由 AI 流式 Markdown 单次解析预算 负责。
固定负载后再定义提交频率预算
性能报告必须记录 React、浏览器完整构建号、操作系统、CPU、屏幕刷新率、生产构建摘要和测试数据摘要。基准夹具固定为 90 秒、6000 个中文字符、每秒 30 个片段,片段大小按保存的序列重放;页面预装 100 条历史消息,其中包含代码、表格和引用。测试每 500 毫秒向输入框追加字符,每 5 秒点击一次工具按钮,末尾执行停止生成。
在批准的中档设备和 Chromium 基线中,可先设以下发布线,再用真实用户分位数校准:按一次交互聚合后的 duration P75 不超过 200 毫秒;Profiler 子树的 actualDuration P95 不超过 12 毫秒、最大值不超过 32 毫秒;每个前台活动分钟的 LoAF 数量、duration 分布和逐条 entry.blockingDuration 求和不超过批准基线;片段从接收到提交的 P95 延迟不超过 100 毫秒。最终文本摘要必须与输入夹具完全一致,不能用丢片段换低延迟。Core Web Vitals 给出指标含义,这里额外保留 stream_queue_to_commit_ms,因为 INP 不会说明某个 token 等了多久。
Event Timing 只统计符合条件的用户交互,短测试不能把尚未结束的页面级 INP 当作实时仪表。Observer 使用 { type: "event", buffered: true, durationThreshold: 16 },丢弃 interactionId === 0 的非交互条目,并以 interactionId 分组取该次交互的最大 duration 后再算分位数;否则默认 104 毫秒阈值会漏掉快交互,同一次指针或键盘操作也会被重复计数。自动化同时采集该聚合值、Long Animation Frames 条目、Profiler 子树 render 时长和自定义片段时间戳;页面级 INP 使用 web-vitals 的生命周期口径并通过 Web 性能 RUM 上报。四类数据不能相互替代。
入站缓冲只在绘制边界提交
网络读取器只负责把已验证片段追加到可变缓冲,并为最早未显示片段保存 receivedAt。前台页面若没有待提交回调,就登记一个 requestAnimationFrame;回调交换缓冲快照,一次性更新当前回答。新片段在 commit 期间到达时进入下一帧,不能修改正在渲染的快照。
function enqueueText(part: string, receivedAt: number) {
pending.text += part;
pending.firstAt ??= receivedAt;
if (frameId === null) frameId = requestAnimationFrame(flushFrame);
}
function flushFrame(frameStartedAt: number) {
frameId = null;
const batch = takePending();
if (!batch.text) return;
setVisibleText((text) => text + batch.text);
markRenderQueued(batch.firstAt, frameStartedAt);
if (pending.text) frameId = requestAnimationFrame(flushFrame);
}
requestAnimationFrame 对齐绘制机会,不保证 React 已经完成提交或像素已经显示,因此这里只登记 queue-to-rAF。组件在 layout effect 登记 commit;双 rAF 或 requestPostAnimationFrame polyfill 只能作为 post-paint 近似,严格的像素可见性还要用浏览器 trace 或视觉夹具验证并记录误差。缓冲还要有 8 KiB 或 50 毫秒的前台上限;超过上限说明主线程无法排空,应降级富文本而不是无限积压。页面隐藏时 rAF 会被节流,逻辑改用低频有界刷新并从前台分母排除;收到 done、错误或取消时必须排空当前快照。连接取消与上游清理仍遵循 Next.js AI 流式响应治理,渲染合并不能伪装成网络背压。
startTransition 可以把非紧急的回答更新标为可中断工作,但不会缩短昂贵的 Markdown 解析,也不会自动合并每次入站事件。输入框的受控状态保持紧急更新,回答区可以在批次边界 transition;若 transition 持续重启导致可见延迟越线,就切回更小的同步 commit,而不是继续提高优先级猜测。
用确定性重放制造最坏帧
测试分别以 60 Hz 和 120 Hz 运行,加入 4 倍 CPU 降速、连续选中文本、滚动、展开引用和停止生成。故意在一帧内注入 200 个小片段,并在下一帧注入一个 64 KiB 片段,检查内存上限、顺序、Unicode 完整性和最终摘要。开发模式的 Strict Mode 与 source map 会改变提交次数,门禁必须跑生产构建;调试 profiling 构建只用于归因。
灰度时按应用版本和设备档位比较 INP、每前台分钟 LoAF 数量及阻塞时长、queue-to-commit、视觉 trace 和最终摘要错误。LoAF 不支持的浏览器上报 unavailable,不能记作零。先放 1%,连续一个完整高峰窗口无越线再扩大。回滚开关恢复上一版固定 50 毫秒批次器,同时保留新旧指标字段;若新实现出现吞字、停止后仍更新、P75 INP 增加 20% 或 queue-to-commit 越过 150 毫秒,立即回滚。可靠的流畅不是“字出现得更碎”,而是在有限帧预算内完整、及时且不阻塞用户操作。
延伸阅读
Related