从 Markdown 到生成式 UI:AI 应用中的流式渲染实践

本文从通用 AI 应用架构出发,讨论模型输出如何逐步转换为 Markdown 内容和生成式 UI,以及如何在低延迟、正确性、安全性与渲染成本之间取得平衡。文中伪代码用于表达设计思路,不依赖特定项目或框架。

一、流式渲染不只是逐字显示

AI 应用接收到的回复,可能同时包含普通文本、Markdown、代码块、结构化数据和界面描述协议。如果每收到一段文本就直接插入 DOM,会遇到一些典型问题:

  • 结构化数据残缺期,如 Markdown 语法还未闭合,JSON 或 UI 协议对象尚不完整等都会导致解析失败;
  • 每个小片段都重新处理全文,回答越长,重复工作越多;
  • 结构化界面尚未完成时就展示,可能闪烁、误导用户或触发不完整交互。

因此,流式渲染的关键不是"尽可能快地显示每个字符",而是区分可以渐进呈现的内容 和需要等待结构完整才能提交的内容。

二、从模型事件到界面的分层架构

一个便于扩展的渲染链路可以拆为六层:

  1. 传输层:接收 WebSocket、SSE 或其他流事件;
  2. 消息状态层:按会话、请求 ID 和事件序号更新当前回复;
  3. 内容分段层:识别普通文本、Markdown、代码块及结构化协议块;
  4. 语义解析层:将协议文本校验并转换为内部数据模型;
  5. 渲染层:选择 Markdown renderer、卡片 renderer 或生成式 UI renderer;
  6. 交互与安全层:处理用户操作、权限校验、HTML 净化和错误隔离。
sequenceDiagram participant Model as 模型服务 participant Stream as 流传输层 participant State as 消息状态管理 participant Split as 内容分段器 participant Parser as Markdown / UI 解析器 participant View as 界面组件 Model->>Stream: 增量事件 Stream->>State: 事件归一化与校验 State->>Split: 当前内容快照 Split->>Parser: 完整块与待完成块 Parser->>View: 安全 HTML / 结构化 UI 模型 View->>View: 增量更新、交互与滚动

三、消息状态机:明确区分快照和增量

流协议经常有两种 delta 语义:

  • 增量片段:本次事件只含新增文本,需要追加;
  • 累计快照:本次事件含从开始到当前的完整文本,需要替换。

前端必须与服务端协议保持一致,不能凭事件名猜测。建议在协议适配层统一成内部的累计快照,UI 层只消费完整的当前文本。

text 复制代码
state = {
  requestId: null,
  status: "idle",          // idle | streaming | complete | error | aborted
  sourceText: "",          // 当前累计的原始回答
  sequence: -1
}

function handleEvent(event):
  if event.sequence <= state.sequence:
    return                         // 忽略重复或过期事件
  state.sequence = event.sequence

  if event.requestId != state.requestId:
    return                         // 忽略其他请求的事件

  switch event.type:
    case "delta":
      if event.mode == "append":
        state.sourceText += event.text
      else if event.mode == "snapshot":
        state.sourceText = event.text
      state.status = "streaming"
      scheduleRender(state.sourceText, complete = false)

    case "final":
      state.sourceText = event.finalText ?? state.sourceText
      state.status = "complete"
      renderImmediately(state.sourceText, complete = true)

    case "error":
      state.status = "error"
      publishError(event.message)

    case "aborted":
      state.status = "aborted"
      finalizeVisiblePartialAnswer()

完成事件应作为强制刷新点:即使平时对 Markdown 渲染做了节流,最终文本也要立即刷新,避免停留在旧快照。超时、断线和中止也应作为状态机的明确分支处理,而不是只依赖一个无限期等待中的 loading 动画。

四、先分段,再选择 renderer

一种实用的内容模型是把模型输出拆成有序片段:

text 复制代码
Segment =
    TextSegment(markdownSource, complete)
  | UiSegment(protocolSource, complete)
  | ActionSegment(payloadSource, complete)
  | CodeSegment(language, source, complete)

例如,回复中普通文字可以先展示;一个尚未闭合的 UI JSON 块则留在 pending 状态,等闭合后再校验和渲染。

text 复制代码
function splitAnswer(source, isMessageComplete): Segment[]:
  segments = []
  cursor = 0

  while cursor < source.length:
    opener = findNextSupportedBlock(source, cursor)

    if opener does not exist:
      segments.push(TextSegment(source[cursor..], complete = isMessageComplete))
      break

    if opener.start > cursor:
      segments.push(TextSegment(source[cursor..opener.start], complete = true))

    closer = findMatchingCloser(source, opener)
    if closer exists:
      body = source[opener.bodyStart..closer.start]
      segments.push(classifyAndBuildSegment(opener.kind, body, complete = true))
      cursor = closer.end
    else:
      body = source[opener.bodyStart..]
      segments.push(classifyAndBuildSegment(opener.kind, body, complete = false))
      break

  return segments

实际解析需要比示意伪代码更严谨:支持的 fence 语言、转义规则、嵌套边界和协议版本都应显式定义。尤其不要用简单正则去解析任意嵌套 JSON;应使用可靠的边界检测器或 JSON parser,并对不完整输入保持 pending,而不是把错误内容当作完整界面。

五、Markdown 流式渲染

5.1 目标文本与显示文本分离

打字机动画可以将"最新收到的目标文本"和"当前显示到的位置"分开维护:

text 复制代码
targetText = 最新累计快照
visibleText = 当前动画已显示的前缀

onTargetChanged(nextText, complete):
  targetText = nextText

  if complete:
    cancelTypingTimer()
    visibleText = targetText
    scheduleMarkdownRender(immediate = true)
    return

  if not targetText.startsWith(visibleText):
    // 内容被纠正、重置或收到非前缀快照
    visibleText = targetText
  else:
    startOrUpdateTypewriter(targetText)

function typewriterTick():
  remaining = targetText.length - visibleText.length
  visibleText += nextChunk(remaining)
  scheduleMarkdownRender(immediate = false)
  if visibleText.length < targetText.length:
    requestNextTick()

若网络端 delta 很快、打字机显示较慢,定时器不应捕获旧的 targetText 并一直追赶过期内容;每个 tick 都应读取最新目标。收到非前缀修订时,也要取消旧动画并按新快照重置。

5.2 节流 Markdown 渲染

Markdown 转 HTML 通常包括解析、代码高亮、链接规范化、表格处理和 HTML 净化。把这项工作直接放在响应式模板表达式中,会使每次依赖变化都触发全量渲染。

可将渲染结果保存为单独状态,并在短时间窗口内合并多次更新:

text 复制代码
RENDER_INTERVAL = 64ms
lastRenderTime = 0
pendingTimer = none
renderedHtml = ""

targetChanged(text, complete):
  latestText = text

  if complete:
    cancel(pendingTimer)
    renderedHtml = sanitizeAndRenderMarkdown(latestText, final = true)
    lastRenderTime = now()
    return

  if pendingTimer exists:
    return                         // 已有一次待执行刷新,保留最新文本即可

  delay = max(0, RENDER_INTERVAL - (now() - lastRenderTime))
  pendingTimer = setTimeout(() =>:
    pendingTimer = none
    renderedHtml = sanitizeAndRenderMarkdown(latestText, final = false)
    lastRenderTime = now()
  , delay)

onComponentUnmount():
  cancel(pendingTimer)

节流主要减少渲染调用频次,并不会自动避免单次全文解析。间隔需要基于设备与内容测试,通常可从 50--100ms 试起;答案完成时立即 flush。注意计时器回调应读取最新文本,而不是闭包中的旧文本。

5.3 将代码高亮延后

代码块在流式阶段不断增长,每次高亮的源文本都不同,按完整代码内容做缓存往往命中很低。一个简单策略是:流式时对代码块按纯文本转义显示,闭合或回答完成后再高亮。

text 复制代码
function renderCodeBlock(token, messageComplete):
  safeCode = escapeHtml(token.code)

  if not token.closed and not messageComplete:
    return renderPlainCode(safeCode)

  highlighted = highlightWithCache(token.language, token.code)
  return renderHighlightedCode(highlighted)

这样可把高亮成本从频繁变化的阶段移到结构稳定的时点。无论是否高亮,代码源都必须正确转义;不能将未经校验的模型文本作为 HTML 直接插入页面。

六、缓存已完成的 Markdown 块

比全文节流更进一步的办法,是缓存已经确认稳定的 block token,只重新渲染末尾尚未结束的块。

text 复制代码
cache = []                  // 已完成块:{ key, html }
pendingSource = ""
lastCommittedSource = ""

function updateMarkdown(source, messageComplete):
  tokens = lexMarkdown(source)

  if messageComplete:
    stableTokens = tokens
    pendingTokens = []
  else:
    stableTokens = tokens except last token
    pendingTokens = last token if present

  stableKeys = stableTokens.map(tokenKey)
  commonPrefixLength = longestEqualPrefix(cache.keys, stableKeys)

  // 语法修订或外部引用变化导致前缀不一致时,从首个变化块开始失效
  cache.truncate(commonPrefixLength)

  for token in stableTokens[commonPrefixLength..]:
    rawHtml = parseMarkdownTokens([token])
    safeHtml = sanitizeAndPostProcess(rawHtml)
    cache.append({ key: tokenKey(token), html: safeHtml })

  pendingSource = serialize(pendingTokens)
  pendingHtml = pendingSource
    ? sanitizeAndRenderMarkdown(pendingSource, final = messageComplete)
    : ""

  publish(cache.htmlBlocks, pendingHtml)

缓存键可由 token 类型和原始 raw 内容构成;如果块内容相同而全局上下文可能影响解析,还需把解析上下文或版本加入键中。缓存应限定在单条消息或单个 renderer 实例内,并设置内存上限。

不要只按空行切分 Markdown。 列表、引用、表格和代码围栏可能跨行;引用式链接定义也可能出现在后文并影响前文。检测到全局引用定义、语法边界修订等情形时,要让相关缓存块失效重算,或者回退到整段解析。

还有一个性能细节:若每次更新仍调用 lexMarkdown() 扫描全文,即便 parser、高亮和净化只作用于新增稳定块,lexer 仍是全文成本。只有性能数据证明确有必要后,再实现增量 scanner;这类 scanner 必须维护 fence、列表、引用、表格等上下文状态。

七、生成式 UI:把协议数据转成受控组件树

生成式 UI 不应被视为 Markdown 的一个样式分支。它通常包含协议解析、schema 校验、组件映射、数据绑定和用户事件。模型文本只是候选输入,经过验证后才能进入 UI 状态。

7.1 Markdown 与 A2UI 如何共同构成一条回答

一种实际落地方式,是让模型在同一条回答中按顺序输出普通 Markdown 和 A2UI JSONL 代码块。Markdown 承担解释、步骤说明和结论;A2UI 则承载表单、选择器、按钮等可交互界面。前端按原始输出顺序分段,再把不同片段交给不同 renderer,因此用户看到的不是"聊天内容区 + 一个孤立的表单",而是一条文本与界面交织的连续回答。

例如模型可以生成如下内容(协议字段仅作示意):

text 复制代码
## 先确认部署配置

请检查以下配置是否正确。确认后可以继续下一步。

```jsonl
{"surfaceUpdate":{"surfaceId":"deploy-form","components":[{"id":"region","component":{"Select":{"label":"部署区域","value":"华东"}}} ]}}
{"dataModelUpdate":{"surfaceId":"deploy-form","contents":[{"key":"confirmed","value":false}]}}
```

提交后,我会根据所选区域继续说明部署步骤。

渲染后,前后两段说明仍然是 Markdown,中间的协议块则变成真实的交互 Surface。用户可以先读上下文,再操作控件,最后继续读模型生成的后续说明。这个顺序本身构成了体验的一部分:Markdown 负责表达,A2UI 负责行动,两者共享同一个回答上下文。

内容分段器识别 jsonl 与特定动作代码围栏,并尝试判断 JSONL 内容是否为 A2UI 消息;未被识别为 A2UI 的 JSONL 内容可作为普通文本显示。对于尚未闭合的受支持围栏,会保留为未完成片段。A2UI Surface 只在片段完整后解析并交给组件渲染,避免半截 JSON 造成卡片闪烁或不完整控件。

text 复制代码
function renderAssistantAnswer(source, isComplete): Node[]:
  segments = splitBySupportedFences(source, isComplete)
  nodes = []

  for segment in segments in original order:
    if segment.kind == "text":
      nodes.push(MarkdownRenderer(segment.source))
      continue

    if segment.kind == "jsonl":
      messages = parseCompleteJsonLines(segment.source)
      if messages contain A2UI messages:
        normalized = normalizeA2UIValuesAndComponents(messages)
        surfaces = processIntoSurfaces(normalized)
        nodes.push(A2UIRenderer(surfaces))
      else:
        nodes.push(MarkdownRenderer(segment.source))
      continue

    nodes.push(renderOtherStructuredSegment(segment))

  return nodes

在一套实际实现中,A2UI 内容以 jsonl fenced block 为主要入口:先提取代码围栏中的内容,再逐个识别 JSON 对象;对象扫描会跟踪大括号深度、字符串状态和转义符,避免字符串里的 {、} 被误当成对象边界。如果传输内容把换行编码成字面量 \\n,解析前还需要恢复为真实换行。

解析后会做一次协议归一化:例如将数组式 valueList 转为键值形式的数据结构,并将文本、选择器、输入框等组件的简单字符串、数字或布尔值包装成协议要求的 literal value。完成归一化后,再把消息交给 A2UI message processor,读取生成的 surfaces,最后由对应的 Surface renderer 绘制。普通文本仍保留在原序列中的位置并交给 Markdown renderer,而非被 A2UI 内容吞并。

Surface 只在片段完整后才进入解析缓存;未闭合的 jsonl 块会被标记为未完成,不提前显示可交互控件。当前实现的缓存键由片段索引和片段内容构成,适合确保同一内容对应确定的 Surface;但消息增长时会重新拆分片段并重建缓存对象,已完成的 A2UI 块也可能再次解析。进一步优化可以按稳定的片段内容摘要或协议 revision 复用处理结果,并只处理新增或失效的 Surface。缓存复用前仍需确认 processor 对 surface 更新的状态语义,避免不同消息间共享可变状态。

协议适配还可以负责把简写数据转换成 renderer 预期的规范形式。这样模型输出格式与 UI 组件内部数据模型之间有清晰边界,组件不必理解多种不一致的 JSON 形状。

应用启动时还需要向 A2UI 渲染运行时提供组件 catalog 和主题配置;回答中的协议消息经处理器应用后,生成的 surface 数据再交由 surface 组件绘制。协议 processor、组件 catalog 与主题因此构成运行时基础设施,而不是由模型随意生成的代码。

这种协同模式带来几个直接的用户体验收益:

  • 解释与操作相邻:用户不必在说明文字和独立表单之间来回切换;
  • 普通文字更快出现:不必等待整个回答结束,前面的 Markdown 可按自己的节奏呈现;
  • 交互结构完整后再出现:A2UI 块完整并通过解析后再提交,减少半成品 UI;
  • 一条回答支持多种表达:后续 Markdown 可以继续解释刚刚展示的界面或操作结果。

相应地,当前"等待整个 A2UI 片段完整再绘制"的策略是完整性优先,而不是逐字段的生成式 UI 流式更新。若产品要求控件随模型输出逐步出现,应设计有明确 revision 和操作语义的增量 UI 协议,而不是尝试渲染不完整 JSON。

7.2 结构完整后原子提交

入门且稳妥的策略是等待协议块闭合,再解析并提交整个 UI 描述:

text 复制代码
function processUiSegment(segment):
  if not segment.complete:
    showPendingIndicator()
    return

  result = parseJson(segment.source)
  if result is invalid:
    showBlockError("界面描述格式错误")
    return

  validated = validateSchema(result)
  if validated is invalid:
    showBlockError("界面描述不符合协议")
    return

  viewModel = mapToAllowedComponents(validated)
  commitSurface(viewModel)

优点是解析失败不会反复闪烁,交互控件也不会在属性未齐全时短暂出现。缺点是较大的 UI 块需要等闭合后才能展示。

7.3 增量生成式 UI

若希望更早看到界面,可以设计明确的增量事件,而不是尝试把半截 JSON 猜成完整结构:

text 复制代码
UiEvent = {
  surfaceId,
  revision,
  operation: "create" | "setProperty" | "appendChild" | "remove",
  targetId,
  payload
}

function applyUiEvent(event):
  if event.revision <= surface.lastRevision:
    return                         // 幂等:忽略重复或过期版本

  if not isAllowedOperation(event.operation):
    reject(event)
    return

  patch = validateOperationSchema(event)
  if patch is invalid:
    reject(event)
    return

  nextTree = applyPatchTransaction(surface.tree, patch)
  if validateWholeTree(nextTree):
    surface.tree = nextTree       // 事务提交,避免半更新状态
    surface.lastRevision = event.revision

这一方案需要服务端和前端约定稳定的组件 ID、revision、操作集合和失败恢复方式。协议应版本化,断线重连后还应能够通过快照恢复最终状态。

7.4 生成式 UI 的安全边界

至少应具备以下约束:

  • 组件类型白名单;
  • 对每类组件的属性、长度、数据类型做 schema 校验;
  • 动作名称白名单与服务端授权,不接受模型任意指定执行函数;
  • 限制树深度、组件数量、文本长度和数据体积;
  • 未知组件安全降级,单个节点错误不影响整个回答;
  • 模型输出不作为脚本、模板表达式或未经净化 HTML 执行;
  • 对 revision、surface ID 和请求 ID 做校验,防止跨请求污染。

安全校验应发生在生成式 UI 状态提交之前,而不是等组件渲染后再补救。

八、事件与 Markdown 缓存的边界问题

流式系统容易忽略事件顺序和内容修订。建议至少考虑以下情况:

  1. 重复事件:用 sequence 或事件 ID 去重;
  2. 乱序事件:只接受当前请求、当前 run 的有效版本;
  3. 快照修订:若新文本不再以前一快照为前缀,应重置相关 typewriter 和 block cache;
  4. 迟到 final:完成事件必须强制以最终文本重渲染;
  5. 断线重连:区分恢复快照与新增片段,避免重复显示;
  6. 取消和错误:取消定时器、结束 loading 状态,并保留已输出内容;
  7. 引用定义变化:使受影响的 Markdown 块失效,而非继续展示旧解析结果。

伪代码中的"提交稳定块"也应被视为一种可撤销缓存策略:若后续协议或语法上下文表明块并不稳定,系统必须能够从最早受影响的位置回滚。

九、滚动、DOM 与渲染预算

自动滚动不应无条件在每次输出后吸到底部。更好的交互是:用户原本靠近底部时跟随新内容;用户主动向上查看历史时暂停吸底,并提供"回到底部"提示。

text 复制代码
onContentLayoutChanged():
  if userWasNearBottom:
    scheduleAtMostOncePerFrame(scrollToBottom)
  else:
    showNewContentIndicator()

对超长对话还应考虑消息列表虚拟化。虚拟化、Markdown 节流和 block 缓存解决不同问题:

  • 节流:减少渲染调用频率;
  • block 缓存:减少稳定内容重复解析、高亮和净化;
  • 增量 scanner:减少全文词法扫描;
  • 虚拟化:减少同时挂载的历史 DOM;
  • 帧合并:减少频繁布局与滚动操作。

十、安全与 HTML 净化

使用 innerHTML 或 Vue 的 v-html 时,模型输出必须视为不可信。推荐流程是:

text 复制代码
source = modelOutput
html = markdownParser(source)
html = applicationPostProcess(html)
safeHtml = sanitizer(html, strictPolicy)
render(safeHtml)

更推荐让 renderer 产生安全结构,再由框架绑定事件,而不是允许 Markdown HTML 带 onclick 等内联事件属性。复制按钮、表格操作可通过容器上的事件委托和 data-action 标记实现。链接协议应限制在允许集合中;代码、语言名称、属性值也要经过正确转义。

生成式 UI 的策略类似:解析、校验、授权、构造受控组件树,绝不执行模型提供的任意 HTML 或 JavaScript。

十一、如何衡量优化效果

建立一组有代表性的基准输入:长文本、多段代码、表格、引用式链接、未闭合代码围栏、大型 UI 协议块、无效 JSON、重复/乱序事件。记录:

  • Markdown lexer/parser 调用次数和耗时;
  • 高亮与 HTML 净化耗时;
  • 稳定块缓存命中率、失效次数和内存占用;
  • 生成式 UI 解析时延、失败率、重复 revision 数;
  • DOM patch、滚动和布局测量耗时;
  • 长回答期间的帧率、输入响应和最终内容到达延迟。

优化应由测量驱动。比如 lexer 扫描占主导时,仅缓存高亮没有多少帮助;DOM 节点过多时,减少 parser 调用也不能替代虚拟化。

十二、总结

AI 应用的流式渲染可以用一句话概括:持续接收,明确建模,按语义分流,只提交可信且足够稳定的结构。

一个务实的优化顺序是:

  1. 明确 delta 是追加还是累计快照,并建立可靠的请求/序号过滤;
  2. 将 Markdown 渲染从模板副作用中分离,增加节流与完成时 flush;
  3. 流式阶段延迟代码高亮;
  4. 缓存已经确认稳定的 Markdown block,保留末尾 pending block;
  5. 生成式 UI 采用 schema 校验、白名单和原子提交;
  6. 通过性能数据决定是否需要增量 scanner、增量 UI patch 和历史列表虚拟化。

流式体验的目标不是让所有东西都逐字出现,而是让普通文本及时可读,让结构化界面正确可交互,并让稳定内容不必一遍遍重算。

相关推荐
迅猛龙办公室1 小时前
python实现简单进度条
java·前端·python
Rosanci1 小时前
Codex 下载与本地部署实战:从安装到运行全流程指南
开发语言·前端·算法·chatgpt·codex
zhangzeyuaaa1 小时前
深入 Ruby:Block、Proc、Lambda 核心区别与最佳实践
开发语言·前端·ruby
liangshanbo12152 小时前
前端高级面试题:WebSocket 双向流式通信怎么设计?
前端·websocket·网络协议
可乐鸡翅yeah_2 小时前
新手梳理:HLS 线上问题,哪些该提给 CDN,哪些该找后端切片服务
开发语言·前端·javascript·vue.js·网络协议·http·m3u8在线
zwd20052 小时前
Manim arrange 和 arrange_in_grid 用法详解:buff、aligned_edge、rows/cols(0.21.0 实测)
前端·python·edge
小小龙学IT2 小时前
Go 语言 reflect 反射包深度解析:从 Type/Value 到三大定律与工业实践
前端·golang
小溪学编程2 小时前
C语言篇:枚举类型
java·c语言·前端
xingpanvip2 小时前
星盘接口开发文档:推算星座接口指南
前端·php