资讯动态

从Markdown到生成式UI:流式渲染增量解析与容错实战

发布时间:2026/10/22 11:25:31 来源:尧图企业网站定制
1. 从 Markdown 到生成式 UI 的流式渲染全景拆解1.1 为什么流式渲染成了 AI 应用的标配能力做过对话类 AI 应用的人都有一个共同体会用户最焦虑的时刻不是答案不对而是屏幕半天没反应。传统请求-响应模式下模型要完整生成完一大段内容才一次性返回用户盯着转圈图标等十几秒体验直接崩掉。流式渲染解决的正是这个等待焦虑——模型每吐出一个 token前端就立刻渲染出来用户看到文字像打字机一样逐字浮现主观等待感大幅降低。但流式渲染真正难的地方不在于把字一个个显示出来而在于内容格式的实时解析。模型输出的往往是 Markdown 语法标题、列表、代码块、表格、加粗。如果只是把原始字符串塞进innerText用户会看到一堆##、**、|符号乱飞体验比不流式还差。所以核心命题是如何在数据不完整的情况下边接收边解析边渲染且不出现闪烁、错位、重复渲染。再往前一步现在很多应用已经不满足于渲染 Markdown 了而是走向生成式 UI——模型输出的不再只是文本而是结构化的组件描述前端根据描述动态渲染出按钮、卡片、表单、图表。这就把流式渲染的难度又抬高了一个量级文本流是线性的而 UI 是树形的一个组件描述可能跨多个 chunk 才拼完整中途渲染就会报错。这篇文章我会把从 Markdown 流式渲染到生成式 UI 的完整链路拆开讲包括解析策略、增量渲染、状态管理、性能优化和踩过的坑。适合正在做 AI 对话产品的前端、全栈开发者也适合想理解流式渲染底层逻辑的技术负责人。1.2 三种渲染路线的取舍逻辑在动手之前先要明确你走哪条路线。我把常见方案归为三类各自的适用场景和代价差别很大。路线核心做法优点代价适用场景全量重渲染每次收到 chunk 就重新解析整段文本并整体替换 DOM实现最简单逻辑清晰内容越长性能越差光标位置易丢失短文本、Demo 验证增量解析渲染维护解析状态机只处理新增片段追加 DOM 节点性能好体验顺滑状态机复杂边界情况多主流对话应用生成式 UI模型输出结构化描述前端按 schema 渲染组件交互能力强可动态生成界面需要协议设计、校验、容错智能助手、Agent 应用我个人的建议是先跑通增量解析渲染再考虑生成式 UI。因为生成式 UI 的底层依然依赖流式文本解析能力如果连 Markdown 的增量解析都没搞明白直接上结构化组件只会让 bug 更难定位。很多团队一上来就想做AI 生成界面结果卡在 JSON 流式解析上最后退回纯文本反而浪费了时间。选型时还要考虑一个关键因素你的模型输出是否可控。如果用的是通用大模型输出格式可能不稳定Markdown 语法可能残缺比如代码块围栏只出现一半这时候解析器必须极度健壮。如果能在 prompt 层面约束输出格式比如强制用特定标记包裹结构化内容解析难度会下降很多。2. 流式 Markdown 解析的核心细节与实操要点2.1 增量解析的状态机设计思路流式 Markdown 解析的本质是维护一个未完成缓冲区加一个已确认结构栈。每收到一个新 chunk先拼到缓冲区末尾然后尝试从缓冲区里切出确定完整的块进行渲染剩下的留在缓冲区等下一个 chunk。为什么不能直接对整段文本调用 Markdown 解析库因为大多数 Markdown 解析器如 marked、markdown-it是设计来处理完整文档的对不完整输入要么抛错要么产生错误的 AST。比如**加粗这种未闭合的语法解析器可能把它当成普通文本等闭合符号到了又要重新解析导致前面已经渲染的内容被推翻重来用户就会看到文字闪烁。我的做法是写一个轻量状态机核心状态包括inCodeBlock是否在代码块内遇到 切换inList是否在列表中用于决定列表项如何闭合buffer未确认的尾部文本pendingInline未闭合的行内标记如**、状态机的关键判断逻辑是只有当某个语法结构确定闭合时才把它从缓冲区移出并渲染。代码块是最典型的例子——只有遇到结束的 才认为代码块完整否则一直留在缓冲区界面上可以先显示一个代码生成中的占位。// 简化版状态机核心逻辑 function feed(chunk) { buffer chunk; let output ; // 逐行扫描寻找可确认的完整块 const lines buffer.split(\n); // 最后一行可能不完整保留在缓冲区 const safeLines lines.slice(0, -1); buffer lines[lines.length - 1]; for (const line of safeLines) { if (line.startsWith()) { inCodeBlock !inCodeBlock; output renderFence(line); } else if (inCodeBlock) { output renderCodeLine(line); } else { output renderMarkdownLine(line); } } return output; }这段代码有个重要细节按行切分时最后一行永远保留在缓冲区。因为网络 chunk 的边界和行的边界几乎不可能对齐一个 chunk 很可能在句子中间断开。如果直接渲染最后一行下次 chunk 到来时这一行会被重复渲染。保留最后一行是成本最低的防重复策略。注意按行切分对大多数 Markdown 语法有效但对表格和嵌套列表不够。表格需要连续多行才能确定结构嵌套列表需要跟踪缩进层级。如果你的应用大量使用表格建议把表格也纳入状态机遇到表格起始行后进入表格模式直到遇到空行才确认整个表格完整。2.2 行内语法的未闭合处理技巧块级语法标题、代码块、列表相对好处理真正恶心的是行内语法加粗、斜体、行内代码、链接。这些标记可能出现在任意位置且经常跨 chunk 断开。举个真实场景模型输出这是一段**重要然后停顿下一个 chunk 是提示**内容。如果第一个 chunk 就渲染用户看到的是这是一段重要星号裸露等第二个 chunk 到了又要重新渲染成这是一段重要提示**内容。这种反复横跳在视觉上非常刺眼。我的处理策略是对未闭合的行内标记做延迟渲染 视觉降级。具体来说扫描当前行时如果发现奇数个**说明加粗未闭合就把从最后一个**开始的内容标记为待定先不渲染成加粗而是原样输出但去掉标记符号等闭合后再补上样式。这样用户看到的是这是一段重要虽然暂时没有加粗但至少没有裸露的星号等闭合后加粗样式平滑出现。行内代码同理。未闭合时可以先渲染成普通文本闭合后再套上代码样式。链接[文字](url)更麻烦因为 url 可能跨 chunk我的做法是链接整体延迟——只有检测到完整的](...)才渲染成链接否则先显示文字部分。这里有个经验不要追求 100% 实时。用户对 100 毫秒级的延迟无感但对闪烁极其敏感。宁可让某个标记晚 200 毫秒出现也不要让它先出现再变化。这个取舍原则贯穿整个流式渲染设计。2.3 代码块高亮的流式适配代码块是流式渲染里最特殊的存在因为它有两个额外需求语法高亮和横向滚动。语法高亮库如 highlight.js、Prism通常需要完整代码才能正确高亮流式场景下代码是逐行到达的怎么办我的方案是按行高亮 增量追加。每确认一行代码就单独对这一行做高亮然后追加到代码块容器里。这样虽然可能丢失跨行的语法上下文比如多行字符串、多行注释但视觉上已经足够好而且性能极佳。等代码块完全结束后可以选择性地对整块重新高亮一次修正跨行语法。function renderCodeLine(line) { // 单行高亮指定语言 const highlighted hljs.highlight(line, { language: currentLang }).value; return span classcode-line${highlighted}/span; }性能上有个坑不要每行都触发一次 DOM 重排。如果代码块很长逐行 append 会导致大量 reflow。我的做法是用DocumentFragment批量收集或者用一个requestAnimationFrame节流把一帧内的多行合并成一次 DOM 操作。另外代码块的横向滚动条在流式过程中会不断出现又消失因为内容宽度在变。可以在代码块容器上固定overflow-x: auto并给一个min-height避免高度抖动。语言标签右上角显示javascript之类也要等语言确定后再显示因为 后面的语言标识可能和围栏分开到达。3. 从文本流到生成式 UI 的完整实操链路3.1 结构化输出的协议设计生成式 UI 的核心是让模型输出可被程序理解的结构而不是纯文本。常见做法是让模型输出 JSON前端解析后渲染组件。但直接让模型输出裸 JSON 有两个问题一是 JSON 必须完整才能解析流式场景下没法边收边渲染二是模型可能输出非法 JSON解析直接崩。我的解决方案是用标记包裹 分块协议。让模型按特定格式输出比如每个组件用component开头、end结尾中间是 JSON。前端按标记切分遇到完整的一个组件块就解析渲染不完整的留在缓冲区。这样既保留了流式的渐进感又保证了每个组件解析时 JSON 是完整的。协议设计要遵循几个原则标记要足够独特不能和正常内容冲突。我一般用三尖括号加关键词普通文本几乎不会出现。每个块要自包含不依赖其他块的信息方便独立解析和容错。要有类型字段前端根据 type 决定渲染成什么组件。要有降级字段当组件渲染失败时能回退成纯文本显示。一个典型的组件描述长这样{ type: card, title: 订单摘要, fields: [ { label: 商品, value: 无线耳机 }, { label: 金额, value: 299 元 } ], actions: [ { label: 确认支付, action: confirm } ] }前端拿到这个结构就能渲染出一张带按钮的卡片。如果解析失败就把原始文本显示出来至少不丢信息。3.2 流式 JSON 的容错解析即使有标记包裹JSON 本身在流式过程中也是不完整的。比如{type: card, title: 订这种半截 JSON直接JSON.parse必然报错。这时候需要容错解析器它能在 JSON 不完整时尽量提取已有信息。我常用的策略是括号配平 补全。扫描缓冲区统计{、[、的开闭情况如果发现未闭合就尝试补上对应的闭合符号再解析。比如{type: card, title: 订补全成{type: card, title: 订}虽然 title 不完整但至少能拿到 type 字段知道这是个 card 组件可以先渲染一个骨架。function tryParsePartial(jsonStr) { try { return JSON.parse(jsonStr); } catch (e) { // 尝试补全括号 let fixed jsonStr; const stack []; let inString false; for (let i 0; i fixed.length; i) { const c fixed[i]; if (c fixed[i-1] ! \\) inString !inString; if (inString) continue; if (c { || c [) stack.push(c); if (c } || c ]) stack.pop(); } if (inString) fixed ; while (stack.length) { const open stack.pop(); fixed open { ? } : ]; } try { return JSON.parse(fixed); } catch (e2) { return null; } } }这个函数的关键在于字符串状态跟踪。JSON 里的字符串可能包含{、}等字符如果不在字符串状态下判断括号会误判。所以要先跟踪inString状态只在非字符串状态下统计括号。转义字符\也要处理否则字符串里的引号会打乱状态。提示容错解析只用于预览渲染最终渲染一定要等标记完整后再用严格解析。否则可能出现字段缺失、类型错误等问题导致组件行为异常。3.3 组件渲染与状态同步生成式 UI 的渲染不是一次性的而是随着流式数据不断更新。比如一个卡片组件先收到 title渲染出标题再收到 fields追加字段最后收到 actions加上按钮。这就要求组件支持增量更新而不是每次重新创建。我的做法是给每个组件实例分配一个稳定 ID可以用组件在流中的序号维护一个Mapid, componentState。每次解析出新的部分数据就按 ID 找到对应实例合并数据并触发局部更新。这样避免了整棵树重建性能好也不会丢失用户已经产生的交互状态比如输入框里已经输入的内容。状态同步还有个容易忽略的点顺序问题。流式数据到达顺序和最终渲染顺序可能不一致尤其是模型并行生成多个组件时。我的处理是给每个组件块带一个index字段前端按 index 排序后再渲染保证视觉顺序稳定。如果 index 缺失就按到达顺序但要做好后到的组件插入到中间的准备用绝对定位或 flex order 处理。4. 常见问题与排查技巧实录4.1 流式渲染典型问题速查表实际开发中遇到的问题五花八门我把高频问题整理成表方便对照排查。问题现象可能原因排查方向解决方案文字闪烁、重复出现缓冲区切分不当最后一行被重复渲染检查按行切分逻辑保留最后一行在缓冲区代码块高亮错乱跨行语法上下文丢失检查是否按行高亮块结束后整块重新高亮列表项缩进跳动嵌套层级未跟踪检查状态机是否记录缩进增加缩进栈组件渲染报错JSON 不完整或非法打印原始缓冲区容错解析 降级显示滚动条自动跳顶内容高度变化触发滚动重置检查是否整体替换 DOM增量追加保持滚动位置长文本卡顿每次 chunk 全量重渲染用 Performance 面板分析增量渲染 节流光标位置丢失输入框和渲染区未分离检查是否复用 DOM输入区独立渲染区只读4.2 滚动位置保持的实战技巧流式渲染时内容不断增长如果用户没有主动滚动应该让视图自动跟随最新内容如果用户手动往上滚了就不应该强制拉回底部。这个跟随/不跟随的判断是体验的关键。我的实现是监听滚动事件记录用户是否在底部附近比如距底部 50px 以内。如果在底部新内容到达时自动滚到底如果用户滚上去了就停止自动滚动并在底部显示一个回到最新的按钮。这个逻辑看似简单但有个坑程序触发的滚动也会触发滚动事件导致状态判断混乱。解决办法是用一个标志位区分程序滚动和用户滚动程序滚动时忽略事件。let isProgrammaticScroll false; function scrollToBottom() { isProgrammaticScroll true; container.scrollTop container.scrollHeight; requestAnimationFrame(() { isProgrammaticScroll false; }); } container.addEventListener(scroll, () { if (isProgrammaticScroll) return; const atBottom container.scrollHeight - container.scrollTop - container.clientHeight 50; autoFollow atBottom; });另一个技巧是用 CSS 的overflow-anchor。现代浏览器支持overflow-anchor: auto能在内容变化时自动保持滚动锚点减少手动干预。但它在某些场景下会失效比如内容整体替换所以还是要配合 JS 逻辑。4.3 性能优化的几个关键点流式渲染的性能瓶颈通常不在解析而在 DOM 操作。每收到一个 chunk 就操作一次 DOM一秒几十次浏览器渲染压力很大。我的优化手段主要有三个第一是批量更新。用一个pendingOutput数组收集待渲染内容通过requestAnimationFrame每帧统一 flush 一次。这样无论一秒收到多少 chunkDOM 操作最多每秒 60 次而且和浏览器刷新节奏对齐。第二是虚拟化长列表。如果对话历史很长只渲染可视区域内的消息其他用占位符。这个对生成式 UI 尤其重要因为组件比纯文本重得多。第三是避免布局抖动。流式内容高度不断变化如果容器用了height: auto每次变化都会触发父级重排。可以给容器一个预估高度或者用contain: layout隔离重排范围。.message-container { contain: layout style; will-change: contents; }contain: layout告诉浏览器这个元素的内部布局不影响外部能显著减少重排范围。will-change: contents提示浏览器内容会变化提前优化。这两个属性在长列表流式渲染里效果很明显我实测下来滚动帧率能提升不少。4.4 我踩过的几个坑说几个文档里不会写、但实际一定会遇到的坑。坑一chunk 边界切断多字节字符。网络传输的 chunk 是按字节切的一个中文字符占 3 个字节很可能被切在两个 chunk 里。如果直接对每个 chunk 做TextDecoder会出现乱码。正确做法是用TextDecoder的stream: true选项它会自动处理跨 chunk 的多字节字符。const decoder new TextDecoder(utf-8); // 流式解码自动处理跨 chunk 字符 const text decoder.decode(chunk, { stream: true });坑二SSE 的换行处理。如果用 Server-Sent Events 传输每条消息以\n\n分隔但消息内容里的换行会被转义。解析时要先按\n\n切分事件再处理data:前缀最后把\n还原。我见过有人直接按\n切分结果 Markdown 的换行全乱了。坑三模型输出带 BOM 或零宽字符。有些模型输出会在开头带 BOM\uFEFF或零宽空格\u200B这些字符肉眼看不见但会破坏 Markdown 解析比如#前面有个零宽字符标题就识别不了。我的做法是在解析前统一过滤这些字符。坑四并发流的状态隔离。如果页面同时有多个流比如多个对话窗口每个流必须有独立的解析状态机和缓冲区不能共用。我一开始图省事用了全局状态结果两个窗口的内容串在一起排查了半天。5. 生成式 UI 的进阶玩法与扩展方向5.1 组件注册与动态加载生成式 UI 要真正好用组件库必须可扩展。我的做法是维护一个组件注册表把 type 映射到渲染函数。模型输出新的 type 时如果注册表里有就渲染没有就降级成文本。这样新增组件只需要注册不用改解析逻辑。const componentRegistry { card: renderCard, table: renderTable, chart: renderChart, form: renderForm, }; function renderComponent(spec) { const renderer componentRegistry[spec.type]; if (!renderer) { return renderFallback(spec); } return renderer(spec); }更进一步可以支持按需加载。组件库很大时不必一次性打包进主 bundle而是根据模型输出的 type 动态 import 对应组件。这样首屏加载快只有真正用到的组件才加载。用React.lazy或动态import()都能实现。5.2 交互回传与多轮生成生成式 UI 的价值不只是显示更在于交互。用户点击卡片上的按钮应该能把动作回传给后端触发下一轮生成。这就形成了一个闭环模型生成 UI → 用户操作 UI → 操作结果回传 → 模型生成新 UI。实现上每个交互组件要带一个action标识点击时把 action 和当前组件的数据一起发给后端。后端根据 action 决定下一步可能是调用工具也可能是继续生成。这里要注意幂等性用户可能连点多次后端要能处理重复请求。多轮生成时UI 的状态管理会更复杂。我的建议是把每次生成的 UI 当作一个独立快照用消息 ID 关联不要试图在旧 UI 上原地修改。这样历史记录清晰回滚也容易。5.3 安全与降级策略生成式 UI 有个绕不开的问题模型输出的内容不可信。如果模型输出了一个恶意组件描述比如带javascript:协议的链接或者超大的 base64 图片可能造成安全问题或性能问题。所以渲染前必须做白名单校验type 必须在注册表里字段类型必须匹配URL 必须过滤协议数值必须有范围限制。降级策略也要提前设计。解析失败、组件不存在、字段缺失都要有对应的降级显示最差也要把原始文本显示出来。我一般会保留一份原始输出任何时候都能回退到纯文本模式。这个安全网在调试阶段特别有用能快速定位是解析问题还是渲染问题。注意生成式 UI 的校验一定要在前端做不能只依赖后端。因为流式数据是前端直接消费的后端可能来不及校验每个 chunk。前端校验是最后一道防线。6. 写在最后的一点个人体会这套流式渲染方案我在几个项目里迭代过从最早的全量重渲染到现在的状态机 生成式 UI最大的感受是流式渲染的难点从来不是技术本身而是对不完整状态的处理哲学。数据永远是不完整的你要决定哪些可以立即渲染、哪些必须等待、哪些可以降级显示。这个决策直接影响用户体验。我的经验是把确定性作为渲染的第一原则。确定完整的块立即渲染不确定的留在缓冲区宁可晚一点也不要反复横跳。用户能接受内容慢慢出现但接受不了已经出现的内容变来变去。另外别过度设计。如果你的应用只是展示 Markdown 文本老老实实做增量解析就够了不用上生成式 UI。生成式 UI 适合那些需要动态交互、需要模型生成界面的场景比如智能助手、数据查询、流程编排。用错场景复杂度上去了收益却不成正比。最后分享一个小技巧在开发阶段加一个流式调试面板实时显示缓冲区内容、状态机状态、已渲染块数量。这个面板能帮你快速定位 90% 的流式 bug比打断点高效得多。等上线时把它隐藏或移除即可。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑