
1. 为什么流式渲染不是“锦上添花”而是 AI 应用的体验底线最近在折腾 AI 应用的输出链路时发现大多数人把“流式渲染”想简单了。很多人以为只要后端把 token 一个接一个吐出来前端用innerHTML拼一下就行。但真实情况是模型输出的是一大段 Markdown 文本流里面有标题、列表、表格、代码块、嵌套引用甚至还有半截的 JSON 结构。如果前端不做任何处理直接把半成品字符串塞进页面用户看到的就是格式错乱、闪烁抖动、代码块高亮失效的灾难现场。流式渲染的第一个价值是抢时间。同样的内容等全部生成完再一次性渲染用户可能盯着空白屏幕等 3 到 5 秒而流式渲染可以把首屏可读内容的时间压缩到几百毫秒。尤其在企业级应用里AI 回答动辄上千字等待时间越长用户流失率越高。更重要的是流式渲染给用户提供了一种“过程可控感”——看到内容在逐步生成、逐步完整用户会更容易理解 AI 的推理路径也更愿意接受错误和修正。这不是玄学是交互心理学里“进度可视化”的实际应用。第二个价值藏在技术层面。流式渲染要求前端具备“增量解析 局部更新”的能力这套能力天然适合生成式 UI 的落地。AI 不只是输出纯文本它还能输出结构化组件、操作按钮、数据面板。当你的渲染管线已经支持“边流边渲染”把 Markdown 里的某个扩展块映射成 React/Vue 组件就成了顺理成章的事。所谓“生成式 UI”本质上就是把 AI 的输出从“一段文字”升级为“一组可交互的界面元素”而流式渲染是它的地基。这篇文章适合谁看如果你在做 AI 对话产品、智能助手、内容生成工具或者正在研究 LLM 输出的前端呈现方案这篇文章可以直接抄作业。我会从数据协议、增量解析、渲染管线、组件映射、故障排查五个维度把整个链路拆开讲清楚。2. 数据协议与流式传输先解决“怎么收”的问题2.1 为什么选 SSE 而不是 WebSocket流式渲染的第一步不是渲染而是“怎么把数据从服务端接到前端”。目前主流方案有两个SSEServer-Sent Events和 WebSocket。我一向优先选 SSE原因很简单——AI 生成场景是单向的、文本密集的、且需要自动重连。SSE 基于 HTTP 长连接天然支持断线重连和事件 ID 追踪而 WebSocket 是双向全双工协议在这个场景里属于杀鸡用牛刀还平白增加心跳维护和消息序列化的复杂度。实际项目里模型服务返回的数据流通常长这样event: token data: {content:你好} event: token data: {content:今天} event: done data: {}SSE 的消息格式很短解析成本几乎为零。前端用原生的EventSource或者自己封装一个 fetch ReadableStream就能逐条读取。关键细节是每条data里尽可能减少嵌套层级不要让 token 在 JSON 里包三层再出来。我见过有人把整个大对象序列化后塞进 data前端拿到还要做一次深解析白白浪费主线程时间。好的设计是服务端先把 token 或片段内容抽出用{type, delta}这样的扁平结构下发。前端要解码的东西越少流式渲染的帧率就越稳。2.2 处理 UTF-8 截断中文乱码的根源流式传输的一个经典坑是网络包在传输过程中会被拆成任意大小的 chunk而 UTF-8 编码下一个中文字符占用 3 个字节。如果服务端在字节边界处把内容切断前端拿到的那个 chunk 就会以“半个字符”结尾直接解析就会出现乱码或者替换字符 。这个问题的解法在服务端不在前端。服务端不要把字符串原样切割而是用Buffer或者TextEncoder先把字符串编码成字节序列等到积累了完整字符后再发送。实操中更简单的方法是不按字节切而是按“增量 token”切。模型每输出一个 token服务端直接把它追加到发送缓冲区并且判断缓冲区结尾是不是一个完整的 UTF-8 字符序列。如果结尾字节处于不完整状态就暂时留到下一条再发。前端也不能完全被动接收。稳妥的做法是用TextDecoder的stream: true模式来解码字节流而不是积累字符串后JSON.parse。TextDecoder专门处理跨 chunk 的字符边界实测下来基本不会出现乱码。哪怕服务端偶尔切断了字节前端也能正确拼接。2.3 内容缓冲策略按 token 收还是按时间收流式传输还有一个调优参数多久把缓冲区里的内容推送给渲染层。推得太频繁比如每个 token 都更新一次 DOM会造成浏览器频繁重排CPU 占用飙升用户体验反而变差。推得太慢又丧失流式渲染的意义。我的经验值是 120 到 200 毫秒批量推送一次。用requestAnimationFrame兜底确保每次刷新都跟浏览器的渲染帧对齐。具体逻辑是let buffer []; let timer null; function onChunk(chunk) { buffer.push(chunk); if (timer null) { timer setTimeout(flush, 150); } } function flush() { render(buffer.join()); buffer []; timer null; }这个基于时间窗口的合并策略比“每个 token 都刷新”的 CPU 占用能降低一半以上而用户感知到的延迟几乎没有差别。别小看这个细节当并发会话多、页面里还有编辑器和高亮组件时性能差距会非常明显。3. 从字节流到结构化文档增量解析的设计思路3.1 增量解析器的核心逻辑状态机驱动拿到文本流之后最忌讳的做法是“每次收到新片段就把全文重新解析一遍”。假设用户输入了 1000 个字第 999 个字到达时你还要重新解析前面 998 个字复杂度是 O(n²)页面迟早卡死。真正的解法是增量解析把解析器设计成状态机每次只消费新增的那段文本然后更新解析状态和输出结构。Markdown 的语法天然适合状态机建模。整篇文档可以抽象成一个“块级元素栈”栈顶是当前正在处理的块段落、列表、引用、代码块、表格等。每来一个新片段就把片段逐字符/逐行送给状态机状态机根据当前栈顶状态决定是继续累积内容还是切换到新块。这里有一个核心的数据结构class ParserState { stack []; // 块级元素栈 buffer ; // 当前未完成的文本片段 mode normal; // normal | code | table | list codeFence null; // 代码块围栏语言 }比如遇到javascript时状态机把 mode 切换成 code然后接下来的所有内容都进入代码块分支直到遇见结束围栏。遇到| 列1 | 列2 |时状态机进入 table 模式开始解析表头和分隔符行。这种栈式状态机的思路跟编译器做语法分析是一个套路只是 Markdown 的语法约束比编程语言宽松得多。3.2 处理未闭合元素半截代码块和表格增量解析最头疼的问题是语法结构不完整。模型生成| 名称 | 数 | 量 |之后可能停顿 500 毫秒才继续输出下一行生成python之后可能要等一秒钟才会出现第一行代码。如果渲染层拿到半截语法就按完整结构渲染轻则内容错位重则代码块直接盖住后续所有内容。我的处理策略是“分层渲染 占位保留”。解析器遇到未闭合的块比如没有结束围栏的代码块先把当前内容渲染成代码块样式但给一个底部过渡区表格只解析出表头和前几行后续行到达时再逐行追加。渲染层绝不因为“结构不完全”就放弃渲染已拿到的内容——相反流式渲染的美妙之处就在于“先给你看到的一部分再补全剩余部分”。关键细节是记录每个块的“打开位置”。当后续片段到达时如果发现某个块已经闭合就直接将新内容作为相邻块追加如果块未闭合就把新内容追加到这个块内部。比如表格块的打开位置在第 5 行后续来了第 6、7 行也属于表格块追加进去即可。这个按位置归属的逻辑简单可靠不用每次重建整棵文档树。3.3 容错与降级解析出错时怎么办增量解析不可能 100% 完美。模型可能输出不规范的 Markdown比如#和文字之间没加空格或者嵌套列表缩进混乱。解析器要设计容错机制我的标准是“不崩溃、不卡死、不吞内容”。遇到解析不了的结构就把当前行当作普通段落处理等到上下文明确后再纠正。具体做法是给解析器设置最大回溯深度。默认 20 层超过这个深度就强制将当前块弹出作为独立块结束。这可以防止极度复杂的嵌套列表把栈撑爆。同时解析器维护一个“宽容开关”当遇到同一个块重入超过 5 次比如 这样无限嵌套引用直接降级为普通文本不再做嵌套结构。这里有个经验别再等解析器彻底稳定了才渲染。流式渲染的本质是“带着毛边往前跑”你要做的是让毛边尽量不影响用户观感而不是追求解析器完美无缺。很多人在这一步过度设计导致代码复杂度爆炸最后反而因为 bug 太多而更不稳定。4. 渲染管线实战从解析结果到 UI 增量更新4.1 构建文档树与节点 ID 分配解析器输出的不能是纯 Markdown 字符串而是一棵“文档树”。每个块级元素占一个节点节点上有稳定的唯一 ID。这个 ID 是后续增量更新的锚点。比如一个二级标题的节点 ID 是h2-3它对应文档里的第三个二级标题一个代码块的节点 ID 是code-2。为什么需要节点 ID因为流式渲染时前端要在“旧节点”的基础上做局部更新而不是整树重建。没有 ID 的话diff 算法无法判断某个节点是新增的还是被修改的只能靠粗暴的“删除全部重建全部”。有了 ID就可以做到“这个表格只更新最后一行”“那个代码块只追加两行代码”更新范围精确到节点级。节点 ID 的生成规则要保证在同一棵文档树下唯一且不受后续内容插入影响。常见做法是给每个块打一个递增序号再结合类型前缀。注意不能只依赖行号因为行号会随着前面内容变多而偏移。我踩过这个坑行号作为 ID前面插入一段文字后后面所有节点的 ID 全变了diff 直接失效页面就整体闪烁。4.2 增量 diff 与 patch不是虚拟 DOM 的专利很多人一听到“增量更新”就想到虚拟 DOM。实际上Markdown 文档树的增量更新比组件树的 diff 简单得多完全不需要跑完整的前端框架 diff。因为文档树的结构变化是有规律可循的绝大多数情况下是“追加”少数情况是“在中间插入”几乎不会出现“删除”和“移动”。回想一下模型输出过程——它只会在流式过程中追加内容很少回头修改前面已经生成的文本。基于这个规律我设计了一套精简的 patch 协议。服务端或前端解析器每产出一批新节点就生成一组 patch 指令[ { op: append, parentId: doc, node: {...} }, { op: updateText, nodeId: h2-3, text: 新增的标题内容 }, { op: appendChild, parentId: table-1, node: {...} } ]渲染层拿到 patch 后直接按指令操作 DOM。整个过程不需要对比新旧两棵树的差异省掉了最耗时的遍历环节。实测在 3000 字的长文档持续流式输出时一次 patch 的平均计算时间可以控制在 1ms 以内。虚拟 DOM 在这种场景下完全没必要反而增加内存开销。4.3 代码块与表格渲染的专项优化代码块和表格是流式渲染里最容易翻车的两个元素。代码块的难点在于高亮。高亮算法对“半成品代码”极不友好——一行写了一半的const foo bar语法分析器大概率会挂掉或产生奇怪的结果。我的解法是“延迟高亮 按行增量”。先把代码块内容按行累积到内存里当行数超过 3 行或者代码块闭合时才对整块代码做一次完整高亮。如果是 200 行的大代码块则按每 30 行一个分块进行增量高亮避免一次性处理太多文本导致卡顿。表格的难点在于列宽和结构对齐。流式过程中表格可能只有一半列或者表头还没闭合。我的策略是先按“当前完整行”渲染列宽根据已到达的行动态计算但限制最大列数超过 6 列就改用横向滚动容器。当后续行到达时如果新行的列数和当前 DOM 结构不一致就做一个平滑的列宽过渡动画至少让用户能感知到“表格正在被补全”。4.4 布局抖动抑制与滚动锚定流式渲染最容易被吐槽的问题是“页面乱跳”。用户正在读前面的内容后面新内容不断插进来把视口往下顶阅读进度一下就丢了。这个问题的缓解手段业内标准做法是“滚动锚定”scroll anchoring浏览器原生支持overflow-anchor属性但实际效果并不完全可控。我通常再叠加一个“局部锚定”策略。渲染层记录当前用户可见区域的第一个块节点 ID当新 patch 到达时只更新该节点之前的内容不改变页面滚动位置。只有用户主动滚动到文档尾部时才把新内容“接到”当前视口的后面。这个策略的代码量不大但用户体验的提升非常明显。没有它用户可能每 3 秒就被迫重新找一次阅读位置再好的内容也会被交互问题毁掉。滚动锚定之外还要抑制布局抖动。主要方法是用 CSScontain属性给每个块设置独立的布局上下文避免一个块的高度变化引起整页重排。代码块容器用contain: layout style表格容器用contain: content实测能大幅降低 diff 更新时的 reflow 面积。5. 从 Markdown 文本到生成式 UI组件映射与动态渲染5.1 生成式 UI 的本质从陈述到操作流式渲染解决的是“文本如何平滑呈现”生成式 UI 解决的是“如何让 AI 直接生成可交互界面”。两者的关系是递进的先把文本流稳定渲染出来才有能力在文本流中嵌入结构化的组件声明。生成式 UI 的核心思路是AI 的响应不再只是一段 Markdown而是包含特殊扩展块的“文档”。比如模型输出下面是本月的数据报表 component weather-widget { city: 北京, days: 5, units: metric }前端渲染时遇到component weather-widget这个代码块不按普通代码渲染而是找到对应的weather-widget组件把 JSON 作为 props 传进去挂载。这样 AI 输出的就不只是“一段关于天气的文字”而是“一个可以交互的天气面板”。5.2 结构化输出协议让模型按格式生成要让模型输出这种组件声明不能靠提示词里写一句“请输出组件”。你需要一套严格的结构化输出协议。实际项目中我通常用 JSON Schema 来约束模型的输出格式。系统提示词里明确声明当需要生成某个类型的组件时必须输出指定格式的代码块且 JSON 字段必须符合 Schema 定义。这套协议的落地离不开工具调用function calling或结构化推理能力。简单说让模型在规划阶段就决定哪些地方生成组件、哪些地方生成普通文本。中间状态可以用特殊标记包裹比如用COMPONENT:weather-widget和END_COMPONENT这样的自定义标记。前端解析器只认这些扩展标记解析到标记时从普通 Markdown 模式切换到组件模式把标记之间的 JSON 传给组件管理器。需要注意的是组件声明和渲染的先后顺序问题。流式输出时模型可能先输出组件的开头部分比如COMPONENT:weather-widget然后停顿一下再输出 JSON 内容。前端在解析到开标记时不能立刻挂载组件——数据还没到齐。稳妥的做法是先渲染一个“组件占位符”等闭合标记到达、JSON 完整后再真正挂载组件。如果需要更早的反馈可以在占位符上展示骨架屏或者“组件生成中”的提示。5.3 组件沙箱与安全边界AI 生成的组件意味着前端会执行任意组件的渲染逻辑。如果这个组件内部有用户输入或者来自模型的动态内容必须考虑安全问题尤其是 XSS 风险。Markdown 里允许富文本组件 props 里也可能包含 HTML 片段在渲染前必须过滤脚本标签和事件属性。我的安全策略分三层。第一层是组件类型白名单只有注册过的组件类型才允许挂载未注册的类型一律渲染为普通代码块或警告框。第二层是 props 校验组件挂载前对 JSON props 做递归校验确保不存在函数、对象原型链污染等危险类型。第三层是渲染隔离组件默认放在 Shadow DOM 或独立的 iframe 中样式和脚本都不与主文档共享这样即使组件内部有异常也不会波及整个页面。组件渲染的临时态和最终态也要分开管理。流式过程中组件可能反复收到更新。比如天气面板的 days 参数从 3 变成 5前端不应每次重建整个组件而是调用组件暴露的updateProps方法。这就要求自定义组件必须遵循统一的生命周期接口。我在实践中定义了三件套create(props)、update(props)、destroy()所有可生成式 UI 组件必须按这个接口实现。6. 常见问题排查与实操教训6.1 乱码、截断与半截字符串症状页面上出现替换字符或者 JSON 解析报错。排查路径先在服务端打印原始字节流确认是不是服务端切割了 UTF-8 序列。用Buffer.isEncoding(utf8)检查或者直接看末尾字节。再看前端解码方式。确认使用的是TextDecoder(utf-8, {stream: true})而不是直接拼接后Buffer.from。最后看数据传输层有没有额外编码转换。某些 HTTP 中间件会把text/event-stream强制转成 ISO-8859-1这种坑非常隐蔽。经验总结这个问题的根因九成在服务端切割逻辑剩下的可能是网关或代理层动了编码。前端用TextDecoder已经是兜底方案真正的修复必须发生在数据源头。6.2 布局抖动和渲染闪烁症状页面内容渲染后突然跳位或者元素先显示为纯文本再变成格式化样式。排查路径先检查是否每次接收新 token 都触发了整树重建。在 patch 链路上做一次 console.count确认对同一节点是否执行了多次 replace。再检查节点 ID 是否稳定。打印节点 ID 列表对比两次更新之间的变化如果大量 ID 变更说明 ID 分配规则有问题。最后检查 CSS contain 属性是否生效。用浏览器 Performance 面板看渲染的 reflow 面积如果过大说明布局隔离失败。经验总结我发现 90% 的闪烁问题不是渲染性能不够而是 diff 逻辑出了偏差导致节点被误删重建。先修 diff再调性能顺序不能反。6.3 高亮失效和代码块抖动症状代码块流式过程中高亮颜色突然丢失或者整块代码闪白。排查路径高亮是否被重复触发。同一段代码如果解析器在未闭合时触发了一次高亮闭合后又触发一次两次的结果可能不一致引起视觉抖动。高亮库是否支持增量输入。很多高亮库会内部缓存状态如果你传入半截代码它会返回一个不稳定的结果。代码块是否被多次重建。检查代码块节点的 key 是否稳定。经验总结代码块的高亮延迟到完全闭合之后再触发是性价比最高的方案。如果必须流式高亮就用独立的 worker 线程运行高亮逻辑避免阻塞主线程。6.4 内存泄漏与长会话卡顿症状AI 对话持续 20 分钟以上后页面越来越卡。排查路径用 Chrome 的 Memory 面板做两次快照对比节点数量和监听器数量有没有明显增长。组件挂载/销毁时确认事件监听器有没有解绑。尤其是自定义组件的update方法里注册的监听器特别容易被遗漏。检查文档树是否在无限增长。旧 token 的字符串如果一直被保留在文本节点里即使不可见也不回收内存会越积越高。经验总结长会话场景下建议对超出视口范围的旧内容做“虚拟化”处理把已经离开视口的文本节点移出 DOM但保留节点 ID 和元数据。用户回滚查看时再用占位节点替换回来。这套方案在做 AI 知识库对话产品时非常重要否则一个小时的会话就能让你页面崩掉。6.5 断线重连与消息续传症状网络抖动导致连接断开恢复后前端渲染的内容出现缺口。排查路径SSE 的lastEventId有没有正确下发。断线重连时服务端应该根据客户端上传的事件 ID从断点处继续下发。前端解析器能否接受“只补增量”的数据。如果解析器没有设计成可重入状态机重连后的增量内容可能和已有内容冲突。经验总结服务端发送任务 ID前端保存当前已解析位置。重连时前端把receivedOffset传给服务端服务端从偏移位置继续发送。这个实现复杂度比前端全量重放低得多而且能保证渲染的连续性。7. 性能调优和实测经验流式渲染的性能调优我在多个项目里反复打磨总结出一套可复用的调优路径。当页面出现卡顿、CPU 持续高占用、移动端烫手等问题时按这个顺序逐一排查比盲目优化有效得多。先明确性能瓶颈的三个维度解析开销、渲染开销、浏览器 GC 压力。解析开销来自 Markdown 状态机对文本的逐行处理渲染开销来自 DOM 更新和布局计算GC 压力来自频繁创建和销毁临时对象。三者的优化策略完全不同不能混为一谈。解析层面的优化空间其实不大因为 Markdown 解析本身是轻量操作。真正的大头在渲染。我的实测数据5000 字文档加上 30 行代码块全量渲染一次约耗时 80ms而用增量 patch 方案每次 token 到达只做追加操作平均耗时 2ms 左右。这个差距在移动端更大低端机型上可能从两百毫秒退化到卡顿。GC 压力容易被忽略。我遇到过一个诡异的问题内存曲线呈阶梯状上升每到达一批 token 就涨一点偶尔下降一点但整体趋势在涨。排查后发现是解析器每次都会创建一个新的ParserState对象旧的没有被及时回收。解决方式很简单复用解析器实例每次解析前重置内部状态字段而不是新建对象。代码块高亮是另一个 GC 大户。高亮库会把代码切分成 token 数组每个 token 占一个对象几百行代码会产生上万个对象。我的优化方案是限制单次高亮的最大代码长度超过 8000 字符的代码块改在requestIdleCallback里分批高亮避免阻塞主线程。还有一个小技巧CSS 选择器避免过深嵌套。渲染层在 patch 时如果使用querySelector(.container .content .markdown-body .table-wrapper tr)这种深路径每次 DOM 查询都会遍历大量节点。我改成给每个块节点分配一个>// Markdown 增量解析器骨架 export class IncrementalMarkdownParser { constructor() { this.reset(); } reset() { this.stack []; this.buffer ; } append(chunk) { this.buffer chunk; const patches []; // 按行扫描维持状态机 let newlineIndex; while ((newlineIndex this.buffer.indexOf(\n)) 0) { const line this.buffer.slice(0, newlineIndex 1); this.buffer this.buffer.slice(newlineIndex 1); this.feedLine(line, patches); } return patches; } feedLine(line, patches) { const top this.stack[this.stack.length - 1]; // 简化示例只处理标题和普通段落 if (line.startsWith(### )) { patches.push({ op: append, node: { type: h3, text: line.slice(4) } }); return; } // 其他块级元素类似列表、引用、表格、代码块 } }补丁生成器与渲染器// 渲染器根据 patch 操作 DOM export class PatchRenderer { constructor(root) { this.root root; this.nodeMap new Map(); } apply(patches) { for (const patch of patches) { if (patch.op append) { const el this.createElement(patch.node); this.root.appendChild(el); this.nodeMap.set(patch.node.id, el); } else if (patch.op updateText) { const el this.nodeMap.get(patch.nodeId); el.textContent patch.text; } // 更多 op 分支update, appendChild, destroy... } } }组件注册表// 生成式 UI 组件注册表 export class ComponentRegistry { constructor() { this.components new Map(); } register(name, factory) { this.components.set(name, factory); } create(name, props, mountPoint) { const factory this.components.get(name); if (!factory) { const fallback document.createElement(div); fallback.textContent [未知组件: name ]; return fallback; } const instance factory.create(props); mountPoint.appendChild(instance.root); return instance; } }这个骨架在实际项目中跑起来后核心的工作量会转移到两块完善解析器的 Markdown 块类型覆盖以及扩展组件注册表的业务组件。解析器的每个分支都可以单独写单元测试尤其是表格和代码块的状态切换最好用固定样例数据反复验证。生产环境中我建议把解析器放到 Web Worker 里运行。主线程只负责 DOM 操作解析和补丁生成都丢给 Worker这样即使文档很大也不会阻塞用户交互。主线程和 Worker 之间用postMessage传递 patch 数组数据结构保持可序列化。9. 从个人经验出发的几点忠告踩过不少坑之后我越来越觉得流式渲染和生成式 UI 的工程化核心不在于某一种框架或某一个库而是一套稳健的架构分层。如果你打算把这套能力落地到自己的项目里我给你几个经验层面的建议。解析器和渲染器一定要解耦。很多人喜欢把 Markdown 解析逻辑直接写在 React 组件里图省事。但组件是易变层解析器是核心逻辑层混在一起会让单元测试变得极其困难。我见过一个团队把所有逻辑写在一个 2000 行的组件里最后改一个表格样式都要提心吊胆。正确做法是解析器做成纯函数或者纯类不依赖任何 UI 框架这样可以用 Node 环境直接跑测试。增量解析的测试用例要尽早沉淀。流式场景的特殊之处在于“任何阶段都可能中断又恢复”。准备一份包含标题、列表、代码块、表格、嵌套引用、组件声明共六种类型的长文档样本在测试环境里按 1 个字符、5 个字符、100 个字符三种粒度切分分别验证解析结果是否一致。这套测试能帮你拦住至少 30% 的边界 bug。生成式 UI 一定要做能力边界的版本管理。AI 输出的组件类型不是固定的今天新增一种明天可能废弃一种。组件注册表要带版本字段组件声明里也要带版本号前后端约定好兼容策略。不要让旧数据流里突然冒出一种前端不认识的新组件类型否则整个页面渲染都会被拖垮。性能优化不要一上来就上重型方案。可以先跑一个最简版本量出主要瓶颈再针对性地引入 Web Worker、虚拟滚动、增量高亮。上来就堆全套优化会导致代码复杂度飙升排查问题时反而更加痛苦。我见过很多人把最简单的流式聊天页面做成了微前端架构最后项目跑不动了还找不到问题纯粹是过度设计害的。写这篇文章时我脑子里一直有一个朴素的标准用户打开一个 AI 应用他希望第一眼看到的是顺滑的“打字机”效果而不是白屏等待更不是格式错乱的半成品。流式渲染就是为这个目标服务的它做得好用户感受不到它的存在做得不好它就是灾难现场。生成式 UI 是下一步的自然演化但要走到那一步先把文本流的渲染体验做到极致是最值得投入的一件事。