AI应用流式渲染实战:从Markdown增量解析到生成式UI
我直接说结论流式渲染不是锦上添花的加分项而是AI应用能不能被人正常使用的基本盘。最近我们在做某智能问答产品的前端重构从最初“转圈等全文”到如今的“边生成边渲染”用户体验差异比想象中大得多——用户不再对着空白页面干等而是能看着AI“打字”出来。这个过程中踩了不少坑也沉淀了一些方法论从Markdown如何增量解析到生成式UI如何流式驱动再到性能抖动、安全风险怎么处理。这篇文章把这段实操记录完整梳理一遍重点讲原理、取舍和可复现的细节。1. AI应用为什么离不开流式渲染1.1 从“等字”到“读字”用户体感的天壤之别先讲一个很简单但容易被忽视的事实大模型接口返回完整回答通常要几秒甚至几十秒而人类阅读同样文字只需要这几秒的大约三分之一。如果产品等模型生成完再一次性展示用户会在白屏或静止页面上耗费大量时间心理上会产生“这东西是不是挂了”的怀疑。早期我们在这个问答产品里做过A/B测试完整渲染方案的跳出率比流式方案明显高出一个量级这个数据直接说服了团队把流式渲染列为P0需求。流式渲染想解决的不只是“假装在动”的视觉效果。它让用户可以在生成过程中开始阅读对长答案来说整体等待感知被摊平了。更关键的是当内容边生成边出现用户能提前判断回答方向如果不对就及时停止或重新提问这种交互模型跟传统请求-响应有本质区别。换句话说流式渲染改变了用户与AI之间的对话节奏让产品更像“对话工具”而非“查询工具”。但这背后有一个硬条件前端必须有能力处理“不完整的内容”。模型返回的是连续的token流经常出现半句话、半个代码块、未闭合的表格怎么让这些碎片不吓到用户、还能稳定地落到DOM上就是我们接下来要聊的核心。流式渲染不是把字符串append到innerHTML里那么简单它是在时间轴上做增量解析与增量构建每一步都需要设计。1.2 Markdown大模型与浏览器之间的通用语言为什么几乎所有AI应用最终都选择Markdown作为首选的输出格式首先是成本原因主流大模型的训练语料中天然包含大量Markdown格式文本模型输出Markdown的概率非常高其次Markdown足够轻量没有HTML那么重的标签噪声token开销低模型更容易在有限上下文里生成结构完整的文档。更深一层的原因是Markdown恰好处于“人类可读”和“机器可解析”的中间地带。它比纯文本有结构标题、列表、代码块又比HTML简单得多前端解析Markdown的成本非常可控。我们在实践中把它当作AI与UI之间的“交换协议”模型按照约定输出带结构的文本前端协议解析器把文本映射成组件树。这个协议越稳定整个流式渲染管道的边界就越清晰。不过Markdown也有它的问题语法规则松散不同解析器对同一个输入可能给出不同结构而且流式场景下语法往往是“进行中”的。一个代码块标记可能这一秒还没闭合下一秒才闭合一个表格首行来了但分隔行还没来。处理这些半成品状态需要的是比常规Markdown解析器更聪明的增量解析方案而不是简单套一个现成的库就完事。1.3 生成式UI流式渲染的下一站Markdown能覆盖80%的文本交互场景但AI应用越来越不满足于只输出文字。我们希望模型直接输出可交互的图表、表单、卡片甚至嵌套组件这就进入了生成式UI的领域。生成式UI的本质是让模型输出结构化的UI描述前端按照这份描述动态渲染组件而不是预先写死所有界面。典型做法是让模型输出JSON字段里包含组件类型、属性、子节点前端拿到JSON后递归映射成React或Vue组件。但这里立刻遇到新问题JSON本身是流式返回的一个完整的JSON对象可能跨多个网络包前端不能在JSON解析完成前阻塞渲染。于是“流式JSON解析”成了生成式UI的前置技术。解决思路不算玄妙本质上跟增量Markdown解析同构维护一个解析状态机边接收字符边尝试推进解析遇到不完整的部分就暂存等待后续数据。在目前的架构里我们采用Markdown与生成式UI混合的路线通用文本走Markdown流式渲染特定场景数据展示、操作面板、复杂交互走结构化JSON渲染。Markdown像万能兜底JSON像精确工具两者通过同一个流式通道衔接。这也是我在这篇文章中想重点说的内容先掌握Markdown流式渲染的地基再往生成式UI延伸会遇到的问题和解决路径是连续的。2. 流式渲染的技术底座数据通道与增量解析2.1 数据传输链路SSE与WebSocket怎么选流式渲染的第一步是保证数据能稳定、低延迟地到达浏览器。目前主流方案是Server-Sent EventsSSE和WebSocket两者经常被放在一起比较但它们在AI场景下的定位其实很不相同。SSE是单向通道服务端往客户端推数据实现简单、基于HTTP、自带断线重连机制天然适合“服务端生成文本流”的模型输出场景。我们最初就是用SSE实现的第一版流式渲染接入成本非常低框架层的EventSource开箱即用也不需要处理复杂的握手和心跳逻辑。它的限制是只能服务端发往客户端且并发连接的浏览器数量有上限HTTP/1.1下通常为6个但对于单会话的AI问答场景这些限制几乎不构成问题。WebSocket是双向通道适合需要客户端与服务端频繁互动的场景比如实时协同编辑、在线游戏。如果AI应用里有“用户发送中断指令、服务端立即响应”之类的双向需求WebSocket会更合理。但它需要自己管理连接状态、重连、心跳、消息分帧工程复杂度明显更高。我的建议是大多数AI问答产品选SSE就够用别为了一个并不高频的双向需求引入额外复杂度。除非产品形态决定了必须双向实时交互。下面是两者的核心对比实测中我们关注的关键点都列在里面特性SSEWebSocket通信方向服务端到客户端单向双向基于协议HTTPTCP独立协议断线重连内置需自行实现实现复杂度低高适用场景AI流式输出、通知推送实时协同、高频交互浏览器并发连接数受限HTTP/1.1约6个不受同源并发限制消息格式文本为主文本/二进制均可2.2 增量解析的核心矛盾不完整语法怎么渲染常规Markdown渲染流程是“整段文本 - 解析 - 生成HTML”但流式场景下前端拿到的永远是下一个分片而不是完整文本。这带来一个关键矛盾解析器必须在语法尚未闭合时就要给出合理的渲染结果否则用户看到的页面会不断“重排”甚至闪灭。举例来说模型输出了三个反引号开始一个代码块但代码内容还没生成此时解析器有两个选择一是等待三反引号之后的内容全部到齐再渲染这个代码块代价是用户会看到文本内容悬空代码块的位置迟迟不出来二是尝试猜测当前处于代码块内部先把代码块容器渲染出来后续内容直接往容器里填代价是如果最终代码块没有闭合或者中间出现了其他语法标记可能会出现渲染偏移。我们采用的基本准则是“乐观解析”对常见的块级语法代码块、引用、列表、表格分隔行在标记出现后就立即创建对应的块级容器后续内容填充其中对于可能跨行的语法尤其是表格则做临时渲染等完整结构出现后再做一次修正。这个策略让用户感知到的是“结构先出现、内容后填充”而不是“内容先出现、结构再重组”。还有一个极其容易踩的坑文本行可能被网络包切成两半。如果解析器按“收到完整行再处理”来设计一半行会被卡在缓冲区里造成内容长时间不显示。正确做法是解析器维护一个内部缓冲区接到的内容先入缓冲只有在遇到换行符时才算处理完一行处理完立即把该行从缓冲中移除。这个缓冲区机制是所有增量解析器的基础。2.3 一条可行的增量解析策略我们最终采用的增量Markdown解析策略可以概括为四个步骤分块接收、行缓冲、逐行状态迁移、块级渲染。分块接收是指网络层拿到数据后不直接交给渲染层而是交给解析器的输入接口。行缓冲的作用是确保每次解析的粒度为一行避免半行问题。逐行状态迁移是核心——解析器维护一个状态对象记录当前处于“普通文本”“代码块”“引用块”“表格”等哪种模式每处理一行都更新状态并生成对应的输出块。块级渲染则是把已经解析完成的块挂载到DOM树已经挂载的块不再修改只修改其内容节点。这个策略的优点是解耦了“文本流入”和“DOM更新”两个节奏。文本流入是高频事件DOM更新则可以通过requestAnimationFrame或批量任务合并避免每收到几个token就操作一次DOM造成性能抖动。基于这个策略我自己总结了一个遵守原则**宁可用稍微复杂的解析器也要保持渲染输出的稳定性。**稳定意味着同一段文字无论怎么分片到达最终呈现的HTML结构都一致。如果做不到这一点用户会时不时看到内容跳来跳去那种感觉比“慢一点”更加糟糕。3. 工程落地搭一套流式Markdown渲染管道3.1 整体架构与模块划分流式Markdown渲染管道可以拆成四个独立模块连接层、增量解析器、渲染器、缓冲队列。连接层负责建立SSE连接、接收分片并传给解析器增量解析器负责把分片变成一个个“已完成块”和“进行中的块”渲染器负责把块转换成DOM并挂载缓冲队列则承担节流作用控制DOM更新的频率。模块划分的原则是通过数据接口隔离彼此这样连接层即使从SSE换到WebSocket解析器和渲染器也不需要改动。我们在项目里就是在上层定义了一个StreamProcessor接口连接层只负责调用processor.push(chunk)解析器通过事件回调把渲染数据发给渲染器。这样测试的时候还能直接把一段文本按固定间隔模拟喂给处理器不需要真的连模型接口。这种分层带来的好处在后期排查问题时特别明显。比如用户反馈“输出卡住”我们可以分别检查连接层是不是断开了、解析器是不是有状态卡死、渲染器是不是被重排拖慢而不是对着一个几百行的回调函数一头雾水。3.2 核心代码缓冲区、解析器与渲染器下面给出一个简化版本的增量Markdown解析器核心结构它只做三件事缓冲输入、按行解析、识别代码块状态。class IncrementalMarkdownParser { constructor() { this.buffer ; this.blocks []; // 已完成的块 this.state { inCodeBlock: false, codeLang: , codeContent: }; } push(chunk) { this.buffer chunk; const lines this.buffer.split(\n); // 保留最后一段不完整的行等待后续数据 this.buffer lines.pop(); for (const line of lines) { this.processLine(line \n); } } processLine(line) { const trimmed line.trim(); if (this.state.inCodeBlock) { if (trimmed.startsWith()) { // 代码块闭合 this.state.inCodeBlock false; this.blocks.push({ type: code, lang: this.state.codeLang, content: this.state.codeContent }); this.state.codeContent ; } else { this.state.codeContent line; } return; } if (trimmed.startsWith()) { // 代码块开始 this.state.inCodeBlock true; this.state.codeLang trimmed.replace(//g, ).trim(); return; } // 普通行按简单规则处理 this.blocks.push({ type: text, content: line }); } }这段代码虽然简化但体现了几个关键设计。第一buffer里始终保留最后一个不完整行这样就避免了半行内容被误判。第二代码块的识别是基于状态而不是基于“是否遇到三反引号”的线性扫描因为代码块内部的一切都不应该被当作Markdown语法解析。第三每当一个块完成就立即推入blocks数组渲染层可以监听数组变化完成DOM更新。渲染层在真实项目中可以这样设计class StreamingRenderer { constructor(container) { this.container container; this.mountedBlocks new Map(); // blockId - DOM节点 } sync(blocks) { // 逐块对比新增的挂载已存在的更新内容 blocks.forEach((block, index) { const domNode this.mountedBlocks.get(index); if (!domNode) { const node this.createNode(block); this.container.appendChild(node); this.mountedBlocks.set(index, node); } else { this.updateNode(domNode, block); } }); } createNode(block) { if (block.type code) { const pre document.createElement(pre); pre.textContent block.content; return pre; } const p document.createElement(p); p.textContent block.content; return p; } updateNode(node, block) { if (block.type code) { node.textContent block.content; } } }这里用textContent而不是innerHTML来写内容我特意标注一下因为这在流式渲染里是性命攸关的细节。AI生成的内容不可信如果直接把文本拼成HTML塞进去等于给恶意提示词注入打开了大门。所有来自模型的内容一律按纯文本处理需要加粗、标题、链接这些样式时用DOM API或Markdown解析器生成节点而不是拼接HTML字符串。3.3 特殊语法场景的流式处理代码块、表格和嵌套列表是流式渲染最容易出问题的三个场景必须单独说。代码块的流式处理关键在于“乐观开启”。前面代码里已经体现遇到三反引号就认为进入代码块立即创建pre元素并展示。这样用户在AI还没写完代码时就能看到代码容器框等后续内容一行行填充进去视觉上是平滑生长的。同时要注意代码块内部的语言高亮必须延迟进行——不能每收到一行就高亮一遍那样会反复重绘。我们的做法是代码块未闭合时不高亮闭合后做一次高亮。表格的流式处理要难一些。Markdown表格的结构是“分隔行| --- | --- |前是表头、后面是内容行”但流式场景下表头这一行刚到达时后端还没生成分隔行前端不知道这里是不是表格。我们在实践中做一个两步判断先检查当前行是否包含竖线符和单元格分隔再检查前后行的形态。如果暂时不能确定就先按普通文本渲染一旦后续行确认是表格就把已渲染的普通文本段落替换为表格结构并隐藏一个滚动条。嵌套列表同样麻烦因为缩进的层级关系需要跨行才能确定。我们的策略是维护一个“当前列表缩进栈”每遇到一个列表项就与栈顶缩进比较缩进更深则创建子列表缩进更浅则弹出父列表。流式场景下缩进栈的更新必须放在一个事务里否则可能因为半行数据导致栈状态错乱。3.4 实操中的几个关键细节再补充几个在实操中很容易被忽略的细节。第一代码块中的反引号可能会被网络包切断。比如这一秒收到的是半个“”下一秒才收到剩余部分如果解析器单独判断“是否等于”就会漏掉代码块开始标记。我们的处理是对反引号做累积匹配记录连续反引号的数量达到3个才触发代码块状态。第二Markdown里的URL自动链接在增量解析中很容易渲染出奇怪的结果。比如“https://”这七个字符还没全部到达时如果解析器已经尝试识别链接就会把它当普通文本。一个稳妥做法是先识别完整的“https://”或“http://”前缀再识别后续文本宁可让链接晚一步高亮也不能让片段式链接渲染出错。第三DOM更新一定要合并批量。我们实测中如果每收到一个chunk就立即更新DOM一次一个较长回答会触发几百次DOM操作页面滚动会明显卡顿。后来改成维护一个任务队列通过requestAnimationFrame批量更新流畅度提升明显。原理很简单浏览器的帧率有限每帧只要做一次DOM变更就够了不需要更频繁。4. 生成式UI当AI直接输出组件4.1 什么是生成式UI生成式UI是让模型直接输出UI描述并用前端渲染成组件的模式。这里说的UI描述通常不是HTML而是一份结构化JSON里面写清楚组件的名称、属性、层级关系。前端拿到JSON后通过一个注册表把组件名映射成真正的组件函数再递归渲染。举个例子模型可能输出这样的JSON{ component: Card, title: 今日天气, children: [ { component: LineChart, data: [12, 18, 14, 22] }, { component: Button, text: 刷新 } ] }前端渲染器遍历这个JSON找到Card组件渲染外层再递归渲染LineChart和Button。这个模式相比Markdown输出的优势是组件能交互、能动态更新、能绑事件能力边界远超过文本和静态图表。但代价是需要设计协议、约束模型输出、处理流式JSON工程复杂度上了一个台阶。为什么现在很多AI应用都在探索生成式UI因为纯Markdown表达力有限一个数据表格、一个可视化图表、一个需要点击操作的任务面板用Markdown写出来要么很别扭、要么根本做不到。生成式UI把“对话生成结果”升级成“对话生成应用界面”产品形态一下子丰富很多。4.2 结构化输出协议的设计要点设计结构化输出协议时我总结出几个原则极简、扩展性好、类型安全、对生成友好。极简是指协议字段越少越好组件类型用字符串标识属性用扁平对象子节点用children数组。别一上来就引入复杂的类型系统和强约束模型生成的稳定性会下降。扩展性好是指每个组件都可以带extra字段预留自定义能力这样以后增加新组件时不需要改协议。类型安全是指前端需要对每个组件定义对应的TS类型或运行时校验否则模型手滑输出一个错误字段前端可能直接白屏。对生成友好则指提示词中要把协议示例写得足够具体确保模型能照着输出。我推荐在系统提示词中明确给出JSON Schema级别的约束外加几个示例这对模型输出结构化内容的稳定性帮助非常大。实测表明给出示例后模型输出非法JSON的概率明显下降但不给出示例时即使写了规范也可能频繁出错。4.3 流式JSON解析的实现思路生成式UI的第一个技术门槛就是流式JSON解析。因为模型的JSON输出也是一个token一个token返回的如果把完整JSON字符串攒齐再解析用户会等到整个组件树生成完毕才看到界面跟流式的初衷相悖。好消息是JSON的语法结构是非常规整的可以设计一个增量解析器来逐步识别。核心思路是维护一个解析状态机状态包括“等待键”“读取键”“等待冒号”“读取值”“等待逗号或右括号”等每读入一个字符根据状态转移尽量提取出已经完整的片段。举个简化的例子解析下面的流式JSON{component:Card,title:天气,data:[12,18]当读到“data”这个键后可以预判它后面是一个数组然后逐个解析数组元素。前两个数值“12”“18”已经到达虽然数组还没闭合但前端已经可以把这两个数值渲染进图表组件了。等最后的数据到达再补齐剩余部分。实现时不需要从零写业界已经有成熟的开源工具核心思路也跟通用JSON解析器一致只是把“一次性完成解析”拆成“分阶段推进解析”。关键点在于解析器要能随时返回“当前已解析出来的部分结果”和“剩余未解析的原始字符串”这样渲染层才能按部分结果先渲染一部分组件。4.4 混合渲染Markdown兜底 组件增强生成式UI虽好但不可控的地方太多了。模型可能在复杂任务下输出非法JSON可能组件层级过深导致解析超时可能生成了一个前端根本没有注册的组件名。这时候如果把宝全部押在结构化JSON上整个产品稳定性都会被拖垮。我们的解决方案是混合渲染架构。核心原则是默认走Markdown流式渲染当且仅当模型明确输出了结构化的UI标记时才切换到生成式UI渲染通道。实现方式是在协议中定义一个特殊的标记出现在回答开头时前端进入组件模式否则按普通Markdown处理。如果组件模式在解析中途发生错误则降级回Markdown渲染把原始文本直接展示给用户。这个兜底策略在真实产品中极其重要。用户可能不关心你用的是什么炫酷的生成式UI但如果界面突然空白或报错用户一定会给你差评。先在Markdown这个“安全区”把体验做稳再去探索更高级的生成式UI是路径最稳妥的演进路线。5. 常见问题排查与性能优化实录5.1 渲染卡顿与DOM更新优化流式渲染最常见的性能问题是“边输出边卡”。我们当时排查过一个案例用户反馈在手机浏览器上回答很长时前几行很流畅越往后越卡。打开性能面板一看问题是每次收到分片都在更新整个消息容器导致整个文档重排。解决方案是“动静分离”把正在流式生成的内容区单独用一个容器包装给它设置独立的渲染上下文尽量减少对父级布局的影响。同时把DOM更新频率降到每帧最多一次并且优先更新当前可见区域的内容也就是说对已经在屏幕外的内容可以延迟更新。实测在长篇回答场景下这两种优化组合起来可以把卡顿感明显降低。另外如果使用了前端框架要注意不要在渲染器的re-render过程中做高开销操作。比如每次stream更新都触发一次大范围的组件树diff哪怕内容只变了末尾一行也会白白消耗大量计算。在实践中我们给流式消息组件设置了memo化和局部更新策略只对变化的部分做更新。5.2 滚动跳变与闪烁问题流式渲染中一个很容易被用户感知的问题就是滚动跳变。当内容不断增长时用户如果在查看前面的内容新内容的出现不应该把视口顶走。实现Automatic Scroll管理是必要的当用户没有主动滚动时持续把滚动条保持在底部一旦用户上滑查看历史内容就停止自动滚动直到用户再次滑到底部才恢复。闪烁问题则更多出现在代码块和表格的“结构重排”上。尤其表格前面提到过如果一开始按普通文本渲染后来才发现是表格就会闪一下。解决方法是“延迟断言的表格识别”在连续五行内容中检测表格特征再决定是否转换成表格虽然会牺牲一点即时性但换来的是不闪烁的稳定体验。还有一个值得说的细节流式渲染时如果文字和背景色对比度不够会感觉文字在“闪”。这其实是字体加载造成的——流式渲染时文字逐个出现字体文件如果还没加载完浏览器会用兜底字体渲染等字体加载完又切换一次造成视觉闪烁。解决方案是提前用Font Face Observer类似逻辑加载好目标字体或在流式输出期间先用系统字体渲染结束后再切换加载好的字体。这个坑很隐蔽但影响非常大。5.3 安全风险流式场景下的XSS防护流式渲染引入了新的攻击面。因为内容是逐步到达的任何基于“最终内容”的清洗逻辑都不能直接应用于流式片段——一个脚本标签可能在第一个分片里只有一半清洗器可能识别不出它是危险内容等到它完整到达时已经在DOM里了。我们总结的防护策略有几个层次。第一默认所有模型输出按纯文本渲染不主动启用HTML渲染。第二需要支持链接、加粗这些Markdown语法时也不拼接HTML字符串而是用解析器生成对应的DOM节点并把href、src等属性做协议白名单校验只允许http、https、mailto等安全协议。第三对可能出现的SVG、iframe、事件处理器属性一律拒绝渲染。这三层叠加基本可以堵住大多数已知的注入路径。还有一个容易被忽略的是“Markdown中的图片引用”。模型可能输出一个指向外部图片的Markdown图片语法如果前端自动加载该图片可能会触发外部请求带来隐私和安全性问题。我们的做法是所有流式内容中的图片默认不自动加载需要用户点击确认才加载这在部分场景下会牺牲体验但在安全性上值得。5.4 排查问题的方法论流式渲染的问题排查比传统渲染难不少因为同样的错误在“完整文本渲染”下不会出现只在流式特定分片下出现。我们的排查经验是先稳定复现再逐层拆解。稳定复现的手段很关键。我们写了一个回放工具把一次会话的所有分片按顺序记录成日志出现问题时可以按同样的时间序列回放整个过程。这样能精确知道“第N个分片到达时解析器做了什么”。如果没有回放信息仅凭用户口头描述“有时候会卡一下”几乎无法定位问题。逐层拆解则是指按连接层、解析层、渲染层分别加日志。连接层记录分片的到达时间与长度解析层记录状态转移和输出块的变化渲染层记录DOM操作的类型和时间点。当出现性能问题时对比三层的时间线可以迅速判断是网络慢、解析慢还是渲染慢。我强烈建议所有做流式渲染的业务都保留一个“debug模式”开启后能在页面上看到每个分片的接收时间、解析状态和渲染耗时。这个功能平时不开启排查问题时开启一次往往能节省大量瞎猜的时间。6. 写在最后的几个心得流式渲染这件事看起来简单——AI输出什么就展示什么嘛——真正做深了才发现里面全是细节。编码字符边界、行缓冲策略、代码块状态管理、DOM批量更新、安全防注入哪一环没做好用户都会直观地感受到卡顿、闪烁或错乱。我个人最大的体会是不要贪多先把Markdown流式渲染做扎实再做生成式UI。生成式UI很酷但它依赖一套大量交互的协议与校验逻辑如果Markdown这座地基不稳上层做得再花哨也会产生糟糕的体验。我们在项目里演进路线就是从纯文本、到Markdown流式、再到混合渲染每一步都稳定运行了相当一段时间后才往前推进。最后分享一个小实用技巧流式渲染时给内容容器预留最小高度并且正确设置overflow-anchor属性。这个属性在流式内容增长时可以稳定视口锚点防止页面因为顶部内容变化而跳来跳去。它是浏览器原生支持的属性实现成本几乎为零但很多开发者根本不知道。用了它之后我们再也没收到过“页面自己乱滚”的反馈。如果这个方向你也在研究建议把上面几个常见问题当成checklist逐项过一遍滚动是否稳定、是否有闪烁、代码块是否正常、安全是否兜住、性能是否达标。逐项过关之后你的流式渲染体验一定不会差到哪里去。