从Markdown到生成式UI:AI流式渲染的工程实践与避坑指南
去年年中我们团队在给一个内部AI数据问答助手做体验改造。最初方案听起来特别简单——把大模型的回答流式接到聊天窗口用 Markdown 渲染出来大家都觉得两三天能搞定。结果真正动手才发现从“页面上能看到字”到“用户觉得流畅”中间隔着一整条流式渲染的工程量再往后我们想把回答从“一大段文字”升级成“带表格、带图表、带可点击按钮的界面”时Markdown 彻底不够用了。这件事让我意识到一个很实际的问题大模型本质上是一个文本生成器它吐出来的是字符流但用户真正想要的是一个能读、能看、能操作的界面。从 Markdown 到生成式 UI 这条路上涉及流式协议、增量解析、渲染性能、安全边界一系列问题每个点都有值得记录的坑。这也就是今天这篇想聊透的内容。1. 为什么流式渲染是AI产品的“及格线”而不是“加分项”很多团队做第一个AI功能时第一版往往是“用户点按钮转圈等模型完整输出后一次性把答案贴到页面上”。这个方案在 demo 阶段没问题一旦放到真实用户手里体验会迅速崩盘。1.1 慢响应心理用户等不了几十秒但能等一两秒主流对话模型生成一个长回答通常需要几十秒虽然首字返回往往只要几百毫秒到两秒左右。这里存在一个明显的矛盾完整响应时间很长但首块响应很快。用户对“点击后多久能看到反馈”的耐心阈值大约是两三秒超过这个时间就会焦虑甚至重复点击。而首块内容只要在阈值内出现用户就会认为系统“已经在干活了”剩下的等待会变得可接受。所以流式渲染的意义并不是制造“打字机效果”而是把用户的等待感知从“几十秒的空白”拆分成“逐字加载的连续反馈”。它是 AI 产品的体验基线不是加分项。我们后面做的所有优化本质上都是围绕“尽可能早地展示可信内容”展开的。这里要注意区分两个概念打字机效果是人为匀速逐字输出播放的是一段已经完整生成的文本而流式渲染是模型真的在边生成边推送内容本身是逐块到达的。后者要处理的东西比前者复杂得多。1.2 SSE通道流式渲染的技术基石实现流式渲染的前提是能拿到流式响应。目前主流大模型API基本都支持流式返回底层协议通常是 SSE也就是 Server-Sent Events。SSE 本质上是一条基于 HTTP 的长连接服务端可以持续向客户端推送文本事件。它和 WebSocket 的一大区别是单向的——服务端推给客户端客户端不需要维护一条双向长连接。对大模型这种“用户发一条指令模型持续返回文本”的场景SSE 刚好够用而且天然基于 HTTP在某些复杂网络环境下可比 WebSocket 更容易穿透。一个典型的SSE接收流程大致如下const controller new AbortController(); async function streamCompletion(prompt: string) { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt }), signal: controller.signal, }); if (!response.body) return; const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { value, done } await reader.read(); if (done) break; const text decoder.decode(value, { stream: true }); // 这里拿到的是连续的文本增量不是完整报文 handleDelta(text); } }拿到增量文本之后前端就要考虑怎么把这些片段拼成一个可展示的界面了。这恰恰是大多数团队翻车的地方。2. Markdown流式渲染的第一个坑光标跳动与语法重排我们的第一个版本直接用 react-markdown 渲染模型每吐一个 token 就 setState 一次。结果页面惨不忍睹输入框里的光标疯狂跳动用户刚打一半的字被顶走已经显示的 Markdown 内容像抽搐一样反复重排列表、代码块区域尤其明显整个界面像在跳机械舞。2.1 逐token setState为什么是灾难问题出在 Markdown 的块级结构特性上。以列表为例模型输出- 第一项时这一行还没写完渲染器只能先把它当作普通段落当下一行又是- 第二项时渲染器才发现这应该是一个列表于是把整个块从段落重排成列表。每次重排都会造成 DOM 结构重建滚动位置和输入框焦点自然保不住。代码块更麻烦。一个 Markdown 代码块必须以 开头如果模型还没输出结尾的围栏面对已经到达的“半个代码块”很多渲染器只能当成普通文本处理。等围栏闭合后再整体切换成代码块样式用户看到的就是内容先以正文展示、随后突然变成带背景色的代码区域视觉上非常跳跃。逐 token setState 是最直接的实现思路但恰恰是最不可行的方案。它的本质问题是渲染频率远远超过了用户视觉能感知的范围却远远低于 Markdown 语法结构对内容完整性要求的门槛。2.2 增量解析与未闭合区块的暂存策略我们后来用的方案是“分块缓冲 定时增量刷新”。核心思路很简单不放弃流式但也不让每个 token 都触发重渲染。把若干 token 合并成一个“帧”再用浏览器的心跳节奏去刷帧。function createMarkdownBuffer() { let buffer ; let frameTimer null; let onFrame () {}; function append(chunk: string) { buffer chunk; if (!frameTimer) { frameTimer setTimeout(() { onFrame(buffer); frameTimer null; }, 80); } } return { append, onFrame }; }这段代码的关键是控制 render 时机——每 80ms 刷一次将一段时间内的增量批量渲染大幅减少重排次数。但光有节流还不够还要解决“内容不完整导致的重排”问题。我的处理是增加一个“未闭合区块暂存层”对输入文本做块级扫描如果发现代码围栏还没闭合或者表格的|还没有形成完整行就把这些内容暂存到 buffer 里不急着交给渲染器等后续内容补齐了再将缓存尾部合并解析。function splitIncompleteBlock(markdown: string) { // 判断最后一个 是否闭合 const fences markdown.split(); const fenceUnclosed fences.length % 2 0; // 简单判断表格行是否有不完整的行尾 const lines markdown.split(\n); const lastLine lines[lines.length - 1]; const tablePending lastLine.includes(|) !lastLine.trim().endsWith(|); if (fenceUnclosed || tablePending) { // 把最后一个块切出来暂存到 pending等待下一批增量 const index markdown.lastIndexOf(\n\n); return { ready: markdown.slice(0, index), pending: markdown.slice(index), }; } return { ready: markdown, pending: }; }这个策略解决了大部分闪烁问题但它也让我意识到一件事Markdown 始终是“文本优先”的表达协议。哪怕渲染得再顺它仍然无法描述按钮、图表、卡片这类真正的 UI 组件。于是在 Markdown 流式稳定之后我们开始往生成式 UI 方向走。3. 从Markdown到生成式UI文本模型与界面需求的鸿沟AI 数据分析助手最典型的问题是用户问“近三个月销售额趋势如何”模型能返回一大段准确的分析文字但用户真正想要的是折线图、几个关键指标卡片、一张可以排序的明细表。3.1 Markdown的上限只能表达文本无法表达交互Markdown 能覆盖的范围非常有限标题、粗体、列表、表格、代码块、引用。它没有组件、没有事件、没有数据类型、没有状态。你没法让 AI 在 Markdown 里输出一个筛选器、一个下拉框、一个带 tooltip 的图表更没法告诉前端这些元素之间的数据关系。当然可以走另一条路让 AI 直接生成 React 代码字符串前端拿到后 eval 执行。这条路在我们项目碰到的第一天就被否了——直接执行模型生成的字符串代码等于把整个前端的安全边界完全打开同时也会让页面样式和交互失控AI 可能生成一个布局混乱、样式炸裂的组件嵌套。3.2 生成式UI是“协议渲染引擎”不是“文本转代码”生成式 UI 的正确理解方式是把大模型当作一个“源数据提供者”模型按照预先定义的结构化协议输出 JSON 描述前端有一层渲染引擎把这些描述映射成白名单组件树。组件类型、样式、交互逻辑全部由前端预先定义模型只能描述“用什么组件、填什么数据、按什么顺序排列”无法定义“组件长什么样、怎么做交互”。打个比方后端给前端返回 JSON 数据前端按接口文档渲染列表。生成式 UI 只是把“后端返回数据”换成了“大模型返回 JSON”但协议约束比传统接口更严格因为模型的输出是不稳定的需要前端做更多的边界校验和理解容错。确定这个方向后我们面临三个核心问题需要逐一击破协议怎么定、流式 JSON 怎么解析、安全性怎么保证。4. 结构化输出的落地UI Schema设计与流式解析方案我们最终采用的协议是“UI Schema”本质上是一棵由type、props、children组成的组件描述树。模型输出的不是 UI 组件的像素或代码而是类似这样的结构化描述。{ type: stack, props: { direction: vertical, gap: 12 }, children: [ { type: heading, props: { level: 3, text: 月度销售趋势 } }, { type: chart, props: { chartType: line, data: [ { month: 1月, sales: 1200 }, { month: 2月, sales: 1800 }, { month: 3月, sales: 1500 } ] } }, { type: table, props: { columns: [月份, 销售额], rows: [[1月, 1200], [2月, 1800], [3月, 1500]] } } ] }这个 Schema 的设计参考了传统组件树但做了严格约束。type必须是预注册的组件白名单props必须有类型定义children只能是数组且受限深度。模型不需要理解前端组件的实现细节只需要学会按这份协议“组织信息”。4.1 UI Schema协议设计从卡片到图表的统一描述我们最开始只支持text、table、chart三种组件后来发现很多场景需要卡片布局、列表、折叠面板、步骤条于是逐步扩展。每次扩展都要回答一个问题这个组件的信息密度和交互复杂度是不是必要的协议设计时有几个原则比较重要。第一组件类型尽量少而通用避免模型在表达同一类信息时出现多种写法。第二文本内容可以允许 Markdown因为我们已经有 Markdown 渲染管线复用于 UI 组件内的文本能减少模型输出成本。第三数值型 props如宽度、数量、时间点要声明范围模型超出范围时前端应回退到默认值而不是报错。4.2 增量JSON解析器不等待完整报文也能渲染流式场景下最大的技术难点是模型吐出来的是一个字符一个字符的 JSON 文本你不可能等整段 JSON 全部到达再JSON.parse。完整 JSON 到达可能需要十几秒这期间页面必须已经能显示一部分 UI 了。我在实践中有两条路可以走。一条是引入增量 JSON 解析器把接收到的字节流按状态机一层层解开解析出“当前已完整”的对象、数组、字段另一条是折中方案——维护一个缓冲区每次有新的 chunk 到达时尝试去掉尾部未闭合的内容后做一次JSON.parse解析成功就渲染。function extractCompleteJson(text: string): string | null { // 从后往前找到第一个看起来像JSON边界的结尾 const start text.indexOf({); if (start 0) return null; let depth 0; let inString false; let escaped false; for (let i start; i text.length; i) { const c text[i]; if (inString) { if (escaped) { escaped false; } else if (c \\) { escaped true; } else if (c ) { inString false; } continue; } if (c ) { inString true; } else if (c { || c [) { depth; } else if (c } || c ]) { depth--; if (depth 0) { return text.slice(start, i 1); } } } return null; }这个函数本质上是在做“提取第一个完整闭合的 JSON 对象”。模型输出如果是{type:stack,...这样的字符串每批新 chunk 到达时我们运行一次提取只要返回了非空 JSON就解析并渲染对应的组件树。已经渲染的部分保持不动后续到达的新字段或新 children 再以补丁形式合并。实际表现是一个包含标题、图表、表格的卡片式回答大约 1.5 秒时页面上已经出现标题和文本3 秒后图表出现5 秒后完整表格渲染完毕。用户在看到最终内容之前已经看到了陆续出现的信息体验远好于一次性白屏后整体跳出。4.3 安全边界白名单组件、深度限制与类型校验我特别要强调安全设计。生成式 UI 的数据源是一个天然不稳定的“不可信输入”你永远无法保证模型这一步输出的组件类型在不在白名单里props 类型对不对children 会不会无限嵌套。我们的安全策略有四个维度组件白名单未注册的type一律不渲染默认降级为纯文本块。props 类型校验每个组件注册时同时声明props的类型规范前端拿 JSON 里的值做运行时校验不符合就回退默认值。深度限制组件树嵌套深度超过限制我们设为4层后超出部分折叠成一个“更多内容”容器。数量限制列表长度限制为最大 50 项图表数据点限制为最大 200 个点防止模型一次输出超大数组把页面卡死。另一个容易被忽略的安全点是绝对不要在text组件里用dangerouslySetInnerHTML直接渲染模型输出的字符串。所有文本必须经过 Markdown 渲染器或转义处理否则模型输出里夹带的 HTML 片段会成为 XSS 入口。5. 渲染引擎的工程实现从SSE流到组件树的完整链路协议和安全边界定好后剩下的就是工程化接入。我们的前端是 React 技术栈所以下文以 React 为例。5.1 数据管线SSE接收、前置分类、增量解析、状态合并整体链路拆成四段第一段是 SSE 接收层负责把网络字节流转成文本增量。第二段是前置分类器根据内容判断这段输出是普通 Markdown 文本还是 UI Schema。我们约定模型在输出 UI 时先输出一行标记前端看到这个标记就进入 UI Schema 解析模式。const UI_MARKER !--ui-schema--; function handleDelta(text: string) { if (mode unknown) { if (text.includes(UI_MARKER)) { mode ui; return; } mode markdown; } if (mode markdown) { markdownBuffer.append(text); return; } if (mode ui) { jsonBuffer.append(text); const jsonText extractCompleteJson(jsonBuffer.fullText); if (jsonText) { const schema safeParseSchema(jsonText); if (schema) dispatch({ type: INCREMENTAL_SCHEMA, payload: schema }); } } }第三段是增量状态合并。这里比较容易犯的错是把整棵树整体替换进 React state导致所有子组件重渲染。正确做法是按节点id做增量合并每棵树上的节点在生成时顺便给一个稳定的id新 batch 到达时只更新新增或变化的部分。第四段是 React 渲染层。叶子组件全部用React.memo包裹非叶子布局组件如stack、grid保持轻量不承载业务数据。5.2 节流合并与渲染控制让 React 别那么激动流式场景下最怕 React 频繁渲染。我们的经验是数据结构变更统一进入一个useReducer但 reducer 的 dispatch 不在每个 chunk 都触发而是用requestAnimationFrame合并。function useSchemaReducer() { const [state, dispatch] useReducer(schemaReducer, { nodes: {}, order: [] }); const pending useRefany[]([]); const raf useRefnumber(); const queueDispatch (action: any) { pending.current.push(action); if (raf.current null) { raf.current requestAnimationFrame(() { dispatch({ type: FLUSH, actions: pending.current }); pending.current []; raf.current null; }); } }; return { state, queueDispatch }; }这样无论模型输出速度多快渲染频率都被限制在帧率以内。5.3 中断、撤销与错误恢复的处理策略用户随时可能中断生成。我们的做法是AbortController取消 SSE 连接并把已经渲染出来的半成品 UI 标记为“未完成”底部出现一个“继续生成”按钮。这个按钮触发一次新的模型调用但前端不会清空现有组件树而是继续往同一个 Schema 里追加数据。模型输出可能中途报错或违反协议导致 JSON 解析失败。针对这种场景我们在错误恢复时保留已成功渲染的节点丢掉解析失败的尾部并在 UI 底部展示一个轻量提示。6. 实测与踩坑几个真实案例的复盘最后分享几个我们踩过的真实问题都是网上很难查到的细节。6.1 图表数据被前置文字污染模型偶尔会在 JSON 前面输出“好的以下是分析结果”这类自然语言extractCompleteJson虽然能定位到{起始位置但前置文本如果出现在 UI Schema 标记之后会被当作 JSON 的一部分导致提取失败。解决思路是提取到完整的 JSON 对象后还要检查它是否符合最小 Schema 结构比如必须包含type字段如果不符合就认为这一轮输出不可渲染整体降级为纯文本展示。6.2 组件嵌套深度失控导致卡顿有一次模型为了表达层级关系生成了七层嵌套的stack和grid。渲染本身不算太重但布局计算和滚动区域嵌套让交互明显卡顿。后续我们把深度限制从“超出后折叠”提前到了协议层面模型输出的 Schema 超过三层时前端自动拍平部分嵌套只保留前两层结构更深层级的内容合并为可展开的折叠区域。6.3 “AI生成的交互控件”边界到底在哪我们曾经尝试让模型直接生成“筛选器”“排序按钮”这类带交互逻辑的组件最后发现方向有问题。模型可以描述“这里应该有一个下拉框”但它无法描述交互后数据流向哪里、组件之间状态怎么同步。这些逻辑只能由前端代码实现模型只能选择“使用这个组件”或“不使用”。这也成了我们筛选组件白名单的准则只纳入“能通过 props 描述数据就能工作”的组件比如文本、表格、图表、折叠面板、指标卡片。任何要求点击行为、状态联动、请求发起的组件都不进入生成式 UI 的候选范围。6.4 小技巧给每个UI节点一个稳定ID最后分享一个可以立刻用上的小技巧。生成式 UI 在流式过程中同一个数组里的第二个元素在第一次 batch 和第三次 batch 到达时很可能会被解析成不同的位置。如果渲染列表的 key 用了数组下标已渲染的 DOM 会被重新挂载造成闪烁和状态丢失。我们为每个 Schema 节点生成一个稳定的key规则是层级路径 组件类型 内容摘要这样同一个位置的内容在多次增量合并中始终对应同一个 DOM 节点渲染稳定度提升非常明显。做了这个项目之后我最大的体会是流式渲染不是“小优化”而是 AI 产品体验的地基生成式 UI 也不是让 AI 画页面而是用一套安全、可控的协议把模型的文本输出转成用户真正能消费的信息界面。如果团队刚起步我建议先做好 Markdown 流式渲染再加一层结构化卡片协议不要一上来就做全组件生成式 UI否则容易在协议设计、流式解析、安全边界这三个泥潭里消耗太多时间。先把地基打稳后续的体验升级才有支点。