本文从通用 AI 应用架构出发,讨论模型输出如何逐步转换为 Markdown 内容和生成式 UI,以及如何在低延迟、正确性、安全性与渲染成本之间取得平衡。文中伪代码用于表达设计思路,不依赖特定项目或框架。
一、流式渲染不只是逐字显示
AI 应用接收到的回复,可能同时包含普通文本、Markdown、代码块、结构化数据和界面描述协议。如果每收到一段文本就直接插入 DOM,会遇到一些典型问题:
- 结构化数据残缺期,如 Markdown 语法还未闭合,JSON 或 UI 协议对象尚不完整等都会导致解析失败;
- 每个小片段都重新处理全文,回答越长,重复工作越多;
- 结构化界面尚未完成时就展示,可能闪烁、误导用户或触发不完整交互。
因此,流式渲染的关键不是"尽可能快地显示每个字符",而是区分可以渐进呈现的内容 和需要等待结构完整才能提交的内容。
二、从模型事件到界面的分层架构
一个便于扩展的渲染链路可以拆为六层:
- 传输层:接收 WebSocket、SSE 或其他流事件;
- 消息状态层:按会话、请求 ID 和事件序号更新当前回复;
- 内容分段层:识别普通文本、Markdown、代码块及结构化协议块;
- 语义解析层:将协议文本校验并转换为内部数据模型;
- 渲染层:选择 Markdown renderer、卡片 renderer 或生成式 UI renderer;
- 交互与安全层:处理用户操作、权限校验、HTML 净化和错误隔离。
三、消息状态机:明确区分快照和增量
流协议经常有两种 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 缓存的边界问题
流式系统容易忽略事件顺序和内容修订。建议至少考虑以下情况:
- 重复事件:用 sequence 或事件 ID 去重;
- 乱序事件:只接受当前请求、当前 run 的有效版本;
- 快照修订:若新文本不再以前一快照为前缀,应重置相关 typewriter 和 block cache;
- 迟到 final:完成事件必须强制以最终文本重渲染;
- 断线重连:区分恢复快照与新增片段,避免重复显示;
- 取消和错误:取消定时器、结束 loading 状态,并保留已输出内容;
- 引用定义变化:使受影响的 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 应用的流式渲染可以用一句话概括:持续接收,明确建模,按语义分流,只提交可信且足够稳定的结构。
一个务实的优化顺序是:
- 明确 delta 是追加还是累计快照,并建立可靠的请求/序号过滤;
- 将 Markdown 渲染从模板副作用中分离,增加节流与完成时 flush;
- 流式阶段延迟代码高亮;
- 缓存已经确认稳定的 Markdown block,保留末尾 pending block;
- 生成式 UI 采用 schema 校验、白名单和原子提交;
- 通过性能数据决定是否需要增量 scanner、增量 UI patch 和历史列表虚拟化。
流式体验的目标不是让所有东西都逐字出现,而是让普通文本及时可读,让结构化界面正确可交互,并让稳定内容不必一遍遍重算。