news 2026/10/19 0:16:42

从Markdown到生成式UI:AI流式渲染的工程实践与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从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 = useRef<any[]>([]); const raf = useRef<number>(); 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,否则容易在协议设计、流式解析、安全边界这三个泥潭里消耗太多时间。先把地基打稳,后续的体验升级才有支点。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 0:15:19

Python量化实盘迁移:从可编辑Word文档到可信赖交易系统

简介&#xff1a;本资源是一份面向量化投资初学者与金融编程入门者的Python量化分析实践文档&#xff0c;聚焦股票策略回测核心流程&#xff0c;解决从数据获取、清洗、可视化到关键指标&#xff08;年化收益率、最大回撤、平均涨幅&#xff09;计算的完整链路问题。文件为1个可…

作者头像 李华
网站建设 2026/10/18 23:57:31

Shell一键安装脚本实战:从设计到落地

前阵子帮朋友在他的测试服务器上部署一套内部工具&#xff0c;前后折腾了一个下午。东西本身不复杂&#xff0c;一个二进制程序加一份配置文件&#xff0c;但环境检测、依赖安装、目录初始化、服务注册这几步反复敲命令&#xff0c;敲到第三台机器的时候我就受不了了。于是顺手…

作者头像 李华
网站建设 2026/10/18 23:56:06

YOLOv10n+P2检测层实现垃圾分类实时识别与边缘部署实践

垃圾分类这件事&#xff0c;做起来比想象中复杂得多。我一直觉得回收物里的“瓶瓶罐罐”应该是最容易分的&#xff0c;真上手做了一个自动分类识别项目之后才意识到&#xff0c;玻璃瓶和陶瓷片在镜头下有多像&#xff0c;亚克力板和塑料盖混在一起时&#xff0c;连人都容易看走…

作者头像 李华
网站建设 2026/10/18 23:54:57

CIS-CAT合规扫描实战:OVAL+XCCDF配置核查与等保落地

简介&#xff1a;本资源为CIS-CAT轻量级安全合规扫描工具安装包&#xff0c;面向系统管理员、安全运维工程师及网络安全初学者&#xff0c;用于自动化检测Windows、Linux等主流操作系统的配置偏差与基础安全漏洞&#xff0c;助力快速对标CIS基准完成合规性自评。压缩包共152个文…

作者头像 李华
网站建设 2026/10/18 23:54:19

三芯微电子AI 望诊仪:不止是检测,更是大健康机构的 “增长引擎”

对中医馆、药店、养生馆而言&#xff0c;选对一款智能设备&#xff0c;往往能撬动服务与营收的双重升级。深圳市三芯微电子深耕医疗检测领域 20 载&#xff0c;打造的三芯为电子 AI 望诊仪&#xff0c;早已超越 “健康检测工具” 的单一属性&#xff0c;成为众多大健康机构的核…

作者头像 李华