资讯动态

流式Markdown与生成式UI:AI输出渲染的增量解析与性能优化实践

发布时间:2026/10/22 16:24:40 来源:尧图企业网站定制
1. 从 Markdown 到生成式 UI 的核心思路拆解1.1 为什么 Markdown 是 AI 输出的第一站做过 AI 应用的人都有一个共识大语言模型的原始输出本质上就是一串纯文本。你让它写代码、写文章、写分析报告它吐出来的东西没有格式、没有层级、没有视觉结构。如果直接把这段文本渲染到界面上用户体验会非常糟糕——满屏密密麻麻的文字找不到重点更谈不上交互。Markdown 之所以成为 AI 应用输出的“中间语言”原因很直接它的语法足够简单模型训练数据里大量存在生成质量稳定同时它又能表达标题、列表、代码块、表格、引用等结构化信息。你可以把它理解成一张“施工图纸”——模型负责画图纸前端负责按图纸盖房子。但这里有个容易被忽略的问题Markdown 本身只是一种标记语言它描述的是“内容结构”而不是“视觉呈现”。传统做法是用 marked、markdown-it 这类解析库把 Markdown 转成 HTML再套一层 CSS 样式。这套方案在静态博客场景下跑了十几年没什么毛病。可一旦放到 AI 对话场景里问题就暴露了。1.2 流式渲染带来的核心挑战AI 应用的输出方式和传统 Web 请求完全不同。传统请求是“请求-等待-完整响应”的模式前端拿到完整数据后再一次性渲染。而 AI 应用是流式的——模型一个 token 一个 token 地往外吐前端需要边接收边渲染让用户看到文字像打字机一样逐字出现。这个“边收边渲染”的需求直接把 Markdown 解析的复杂度拉高了一个量级。原因在于Markdown 语法是上下文相关的。比如一个代码块的开始标记是三个反引号结束标记也是三个反引号。当你只收到前三个反引号时你无法确定后面还有没有结束标记当你收到一个不完整的表格行时你不知道这一行会不会在下一个 token 里被补全。我踩过最典型的坑就是流式输出过程中代码块的高亮会不断闪烁。因为每次收到新 token整个 Markdown 都被重新解析一遍代码块的 DOM 被销毁重建高亮库重新执行一次。输出快的时候用户看到的就是一片闪烁的“灯光秀”。1.3 生成式 UI 的野心让模型直接“画界面”Markdown 解决了“结构化文本”的问题但它的表达能力有天花板。你没法用 Markdown 描述一个可交互的按钮、一个带筛选功能的表格、一个实时更新的图表。于是“生成式 UI”的概念被提了出来——让模型输出的不只是文本而是可以直接渲染成 UI 组件的结构化数据。实现路径通常有两种。第一种是模型输出 JSON前端根据 JSON 的 schema 映射到对应的组件库。比如模型输出{type: chart, data: [...]}前端渲染成折线图。第二种是模型输出一种受限的 DSL领域特定语言前端解析 DSL 后生成 UI。第一种方案更可控但灵活性差第二种方案更灵活但解析器写起来复杂。不管走哪条路流式渲染的问题都绕不开JSON 在流式传输过程中是不完整的你不能等 JSON 全部收完再解析那样就失去了流式的意义。所以需要一种“增量解析”的策略能在数据不完整的情况下尽可能多地渲染出已有内容。这里有个关键决策点是选择“先缓冲再渲染”还是“增量渲染”。缓冲方案实现简单但用户等待时间长增量方案体验好但解析器复杂度高。我的建议是如果输出内容以文本为主用增量 Markdown 渲染如果输出内容以结构化数据为主用增量 JSON 解析配合占位符策略。2. 流式 Markdown 渲染的实操要点2.1 增量解析的核心逻辑增量解析的核心思想是每次收到新数据时不重新解析整个文档而是只解析新增的部分并尽可能复用已有的 DOM 结构。听起来简单做起来需要解决几个具体问题。第一个问题是“不完整语法块的识别”。比如你收到py你知道这是一个代码块的开始但代码内容还没来。这时候你应该先渲染一个空的代码块容器等后续内容到达时再往里填充。而不是等整个代码块结束再渲染那样用户会看到一大段空白。第二个问题是“已渲染内容的稳定性”。假设你已经渲染了一个二级标题后面又来了新内容这个标题不应该被重新创建。你需要一个机制来追踪每个语法块的状态是“正在接收中”还是“已完成”。已完成的不再变动正在接收的只更新内容不重建 DOM。第三个问题是“回退处理”。有些 Markdown 语法在流式场景下会出现“误判”。比如你收到-你以为这是一个无序列表项渲染了一个圆点。但下一个 token 是-合起来变成了--这其实是一个分隔线。这时候你需要能回退之前的渲染结果。2.2 代码高亮的流式处理技巧代码块是流式渲染里最棘手的部分。常规做法是等代码块完全结束后把整段代码交给 highlight.js 或 Prism.js 做高亮。但这样用户在代码块输出过程中看到的是没有高亮的纯文本体验割裂。我的做法是“分段高亮 增量更新”。具体来说把代码块的内容按行拆分每收到完整的一行就对该行做高亮然后追加到代码块容器里。这样用户能看到代码逐行出现而且每一行出现时就是高亮状态。但这里有个细节有些语言的语法高亮是跨行的。比如 Python 的三引号字符串、JavaScript 的模板字符串它们可能跨越多行。如果按行独立高亮这些跨行结构会被错误解析。解决方案是维护一个“高亮上下文”记录当前是否处于跨行结构中。每次高亮新行时把上下文传进去高亮库返回新的上下文。// 简化的增量高亮逻辑 let highlightContext null; function highlightLine(line, language) { const result highlightWithContext(line, language, highlightContext); highlightContext result.context; return result.html; }实测下来这套方案在大多数场景下表现稳定。唯一需要注意的是当代码块内容非常长时比如超过 500 行增量高亮的性能会下降。这时候可以降级为“每 10 行高亮一次”或者直接关闭高亮只显示纯文本。2.3 表格和列表的流式渲染策略表格在流式场景下是最难处理的 Markdown 元素之一。因为表格的列数由表头决定而表头可能在流式过程中还没完全到达。你收到第一行| 姓名 |你不知道这个表格有几列没法确定列宽。我的策略是“延迟渲染 占位”。当检测到表格开始时先不渲染而是把内容缓冲起来。等到表头行完整接收即收到换行符再根据表头列数创建表格结构。后续的数据行逐行追加。如果表头行本身就很长用户会感觉到一个明显的延迟这时候可以在表格位置显示一个“正在生成表格”的占位提示。列表相对简单一些但有个坑嵌套列表。当你收到-时你渲染了一个一级列表项。下一个 token 是-这是二级列表项。如果你没有正确处理缩进会把二级列表项渲染成一级。解决方案是维护一个缩进栈每次遇到列表项时比较当前缩进和栈顶缩进决定是同级、升级还是降级。实操心得在流式渲染中不要追求“完美还原最终 Markdown”。用户对“逐字出现”的容忍度很高但对“内容跳动”的容忍度很低。宁可先渲染一个粗略的版本也不要频繁重排布局。3. 从 Markdown 到生成式 UI 的进阶实现3.1 结构化输出的 Schema 设计当你的 AI 应用需要输出不只是文本而是可交互的 UI 时就需要定义一套 Schema。这套 Schema 要解决两个问题一是模型能稳定生成二是前端能可靠解析。我推荐的做法是“Markdown 为主自定义块为辅”。也就是说大部分内容仍然用 Markdown 表达但在需要 UI 组件的地方嵌入自定义的代码块。比如这是一段普通文本。 ui:chart { type: line, data: [1, 2, 3, 4, 5] } 这是图表下方的说明文字。前端在解析 Markdown 时遇到ui:chart这样的语言标记就不走代码高亮逻辑而是交给对应的 UI 组件渲染器。这样既保留了 Markdown 的文本表达能力又扩展了 UI 组件的可能性。Schema 设计的关键是“扁平化”。不要设计深层嵌套的结构因为模型在生成深层嵌套 JSON 时容易出错。尽量用一层对象描述一个组件组件之间的关系通过数组来表达。3.2 增量 JSON 解析的实现流式场景下的 JSON 解析核心难点在于“不完整 JSON 的容错”。标准的JSON.parse遇到不完整 JSON 会直接抛错。你需要一个“宽容解析器”能在 JSON 不完整时尽可能多地提取已有信息。实现思路是维护一个解析栈逐字符扫描输入。遇到{入栈遇到}出栈。在扫描过程中记录每个键值对的完成状态。当输入中断时返回当前已完成的键值对集合。function incrementalParse(buffer) { const stack []; const result {}; let currentKey null; let currentValue ; for (const char of buffer) { if (char {) { stack.push({}); } else if (char }) { const completed stack.pop(); if (stack.length 0) { Object.assign(result, completed); } } else if (char :) { // 键值分隔 } else if (char ,) { // 完成一个键值对 } else { // 累积键或值 } } return result; }实际使用时你不需要自己写这么底层的解析器。可以用json-stream或clarinet这类流式 JSON 解析库。但理解原理很重要因为你需要知道在什么时机触发 UI 更新。3.3 UI 组件的流式渲染与状态管理当解析出完整的 UI 组件描述后渲染本身不难难的是状态管理。因为流式输出意味着组件可能会“更新”。比如模型先输出{type: chart, data: [1, 2]}你渲染了一个只有两个点的图表。然后模型继续输出, 3, 4, 5]你需要更新图表数据而不是重新创建一个图表。这就要求你的 UI 组件支持“增量更新”。对于图表类组件这意味着要支持appendData方法对于表格类组件要支持addRow方法对于文本类组件要支持appendText方法。我的经验是给每个 UI 组件定义一个“更新协议”。协议里规定哪些属性可以增量更新哪些属性只能全量替换。比如图表的data属性支持增量追加但type属性只能全量替换。前端根据协议决定是调用update还是recreate。注意事项增量更新虽然体验好但会带来内存泄漏风险。因为组件实例被复用了之前绑定的定时器、事件监听器如果没有正确清理会越积越多。建议在组件的update方法里显式清理旧资源。4. 常见问题与排查技巧实录4.1 流式渲染中的性能瓶颈与优化流式渲染最常见的性能问题是“渲染频率过高”。模型输出速度可能是每秒几十个 token如果每个 token 都触发一次渲染浏览器的主线程会被大量的小任务占满导致页面卡顿。解决方案是“批量渲染 节流”。维护一个待渲染队列每次收到新 token 时把内容推入队列然后用requestAnimationFrame或setTimeout以固定频率比如 60fps 或 30fps从队列中取出内容渲染。这样既保证了视觉上的流畅性又避免了过度渲染。另一个性能问题是“DOM 节点过多”。当输出内容很长时页面上的 DOM 节点数量会急剧增加。浏览器对 DOM 节点的处理是有性能上限的超过一定数量后布局和重绘的开销会变得不可接受。我的做法是“虚拟滚动 分片渲染”。只渲染视口内的内容视口外的内容用占位符代替。当用户滚动时动态替换占位符为真实内容。这套方案实现起来有一定复杂度但对于长文本输出场景是必须的。4.2 流式中断与错误恢复流式输出最怕的就是“中途断掉”。网络抖动、模型超时、用户主动取消都会导致流中断。如果处理不好用户会看到半截内容卡在那里既不能继续输出也不能重新生成。我的处理策略是“状态标记 恢复入口”。在流式渲染的每个阶段记录当前的状态已接收的原始文本、已解析的语法块、已渲染的 DOM 节点。当流中断时把这些状态保存下来。用户可以选择“继续生成”这时候把已接收的文本作为上下文发给模型让模型接着写。但这里有个细节如果中断发生在代码块中间恢复时模型可能不知道前面已经写了一个代码块。解决方案是在恢复请求里显式告诉模型“你之前输出了一个未完成的代码块请继续完成它。”这需要在 prompt 里做特殊处理。4.3 常见问题速查表问题现象可能原因排查方法解决方案代码块高亮闪烁每次 token 都重新解析整个 Markdown在解析函数里打日志看调用频率改用增量解析只解析新增内容表格渲染错位表头未完整接收就开始渲染检查表格开始的判断逻辑延迟到表头行完整接收后再渲染列表层级混乱缩进处理未考虑流式场景打印每次列表项的缩进值维护缩进栈动态判断层级页面卡顿渲染频率过高或 DOM 节点过多用 Performance 面板录制批量渲染 虚拟滚动流中断后无法恢复未保存中间状态检查是否有状态持久化逻辑记录已接收文本和解析状态UI 组件重复创建增量更新逻辑未生效检查组件的 update 方法实现组件的增量更新协议4.4 几个容易忽略的细节第一个细节是“空格和换行的处理”。Markdown 对空格和换行敏感但在流式传输中空格和换行可能单独作为一个 token 到达。如果你在渲染时忽略了这些空白字符最终渲染结果会和预期不一致。建议在解析前先做一次“空白规范化”把连续的空白符合并。第二个细节是“特殊字符的转义”。模型输出的内容里可能包含、、这些 HTML 特殊字符。如果你直接插入 DOM会导致 XSS 风险或渲染错误。必须在渲染前做转义处理。但转义又会影响 Markdown 语法的解析所以要在解析之后、插入 DOM 之前做转义。第三个细节是“滚动位置的保持”。流式输出时内容不断追加页面高度不断变化。如果用户正在往上滚动查看历史内容新内容的追加会导致滚动位置跳动。解决方案是检测用户的滚动行为如果用户主动往上滚就暂停自动滚动到底部如果用户滚回底部再恢复自动滚动。实操心得在开发流式渲染功能时建议先用一个“模拟流”工具来测试。这个工具可以按指定速度逐字输出一段预设文本方便你调试各种边界情况。比直接对接模型 API 效率高得多。5. 生成式 UI 的扩展方向5.1 从静态组件到交互式组件目前大多数生成式 UI 的实现还停留在“静态展示”层面——模型输出数据前端渲染成图表或表格用户只能看不能操作。但生成式 UI 的潜力远不止于此。下一步的扩展方向是“交互式组件”。比如模型输出一个带筛选功能的表格用户可以在表格里输入筛选条件表格实时过滤。或者模型输出一个可编辑的表单用户修改后可以把数据回传给模型做进一步处理。实现交互式组件的关键是把“用户操作”也纳入流式协议。当用户操作组件时前端生成一个“操作事件”通过 WebSocket 或 SSE 发送回后端后端把事件转换成 prompt 发给模型模型返回新的 UI 描述前端再更新组件。这样就形成了一个闭环。5.2 多模态输出的流式渲染文本和 UI 组件的混合输出已经够复杂了但如果再加上图片、音频、视频呢多模态输出的流式渲染是下一个技术难点。以图片为例模型可能输出一个图片 URL前端需要等图片加载完成后再显示。在图片加载期间应该显示一个占位符。如果图片加载失败要有降级方案比如显示一个默认图标。这些逻辑如果和 Markdown 渲染混在一起代码会变得非常复杂。我的建议是把多模态渲染抽象成独立的“渲染器”。每个渲染器负责一种媒体类型Markdown 解析器只负责识别媒体标记并调用对应的渲染器。这样各模块职责清晰也方便单独测试和优化。5.3 性能与体验的平衡生成式 UI 的终极目标是“像本地应用一样流畅”。但流式渲染的本质决定了它永远会有“逐步呈现”的过程。如何在性能和体验之间找到平衡是一个需要持续调优的问题。我的经验是对于文本内容追求“即时呈现”让用户感觉不到延迟对于 UI 组件追求“稳定呈现”宁可多等 100 毫秒也不要让组件闪烁或跳动。因为用户对文本的容忍度高对 UI 的容忍度低。一个跳动的图表比一段慢慢出现的文字更让人烦躁。具体到实现上可以给不同类型的渲染任务设置不同的优先级。文本渲染用高优先级UI 组件渲染用低优先级。当两者同时到达时先渲染文本再渲染 UI。这样用户能先看到内容再看到交互元素心理感受上会更顺畅。这个方向后续还可以继续深挖比如结合 Web Worker 把解析逻辑放到后台线程避免阻塞主线程或者用 WebAssembly 加速 Markdown 解析进一步提升性能。但那是另一个话题了有机会再展开聊。

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

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

免费获取报价 →
↑