AI 对话内容的可视化呈现:流式 Markdown 渲染与代码高亮架构
AI 对话内容的可视化呈现流式 Markdown 渲染与代码高亮架构一、模型吐出半个代码块前端整页 HTML 炸了去年某开源聊天组件出过这么个事故模型生成到一半吐了一个孤零零的反引号前端整页 HTML 解析炸了 30 分钟。查日志才发现Markdown 解析器在处理未闭合的代码围栏时把缓冲里全部内容都按 HTML 实体尝试解码结果一段普通文本被截断成畸形标签触发了v-html的 XSS 防护连带报错。这事我见过太多团队栽进去。更早之前团队以为等整段输出再渲染是最稳的结果用户每次提问要等 8 秒才看到第一行字。次日留率掉了 18%。后来才转向流式渲染但随之而来的就是上面这种半截语法的工程难题。Markdown 是上下文相关的语言。一个未闭合的代码块、一个未结束的表格行都会让解析器报错或渲染半成品。代码高亮本身又是昂贵操作每收到一个 token 就全量重解析长文后期会严重掉帧。这种不确定性是流式渲染与生俱来的必须有专门设计来应对。本文聚焦流式场景下的增量解析、安全边界保护与高亮调度给出可投产的前端架构。二、流式渲染的增量处理管道流式 Markdown 渲染可以拆成四个阶段原始片段累积成缓冲解析器尝试切出已闭合的安全块安全块交给渲染层高危的未闭合块如进行中的代码块先做降级展示如纯文本或占位待后续片段补全再升级渲染。flowchart TD A[模型片段流入] -- B[追加到文本缓冲] B -- C{能否切出安全块?} C -- 是 -- D[渲染为富文本] C -- 否 -- E[识别未闭合结构] E -- F[降级:纯文本/骨架] F -- B D -- G[已渲染区固定] G -- H[用户看到渐进排版]这里的关键决策是何时认为一个块安全。对于段落、标题、列表项这类行级结构遇到换行即可视为完成对于围栏代码块必须等到配对的三反引号出现对于表格需等表头与分隔行齐全。把判定逻辑前置能有效避免渲染崩溃。三、生产级流式渲染与高亮调度下面给出一套可投产实现用双缓冲区分已稳定区与流式尾部只对尾部做轻量解析代码高亮通过 Web Worker 异步完成避免阻塞主线程并用requestAnimationFrame节流重渲染。// 流式 Markdown 渲染器安全切分 Worker 高亮 // 为什么双缓冲避免每 token 全量重解析导致长文掉帧 import MarkdownIt from markdown-it; export class StreamRenderer { private md new MarkdownIt({ html: false, linkify: true }); // 禁 html 防 XSS private stable ; // 已闭合、可安全渲染的前缀 private tail ; // 正在流入、可能未闭合的尾部 // 每收到一个片段调用一次 push(chunk: string): { html: string } { this.tail chunk; const cut this.findSafeCut(this.tail); if (cut 0) { this.stable this.tail.slice(0, cut); this.tail this.tail.slice(cut); } // 已稳定区全量渲染尾部仅做文本降级待闭合再升级 const html this.md.render(this.stable) this.escapeTail(this.tail); return { html }; } // 定位最后一个安全切分点围栏代码块需配对 private findSafeCut(buf: string): number { const fence buf.lastIndexOf(\x60\x60\x60); if (fence -1) return buf.length; // 无代码块整段安全 const after buf.slice(fence 3); // 没有第二个围栏说明代码块未闭合切到它之前 if (after.indexOf(\x60\x60\x60) -1) return fence; return buf.length; } // 未闭合尾部转义为纯文本杜绝注入与半截高亮错乱 private escapeTail(t: string): string { return t.replace(//g, amp;).replace(//g, lt;); } // 高亮交给 Worker避免主线程被语法树解析卡住 async highlight(code: string, lang: string): Promisestring { return new Promise((resolve, reject) { const worker new Worker(/hl.worker.js); const timer setTimeout(() { worker.terminate(); reject(new Error(高亮超时)); }, 2000); worker.onmessage (e) { clearTimeout(timer); resolve(e.data); worker.terminate(); }; worker.postMessage({ code, lang }); }); } }节流方面渲染层应把push的结果合并到下一帧再setInnerHTML而不是每片段立即更新。Worker 高亮还需做去重同一个代码块若被多次重入只高亮最新版本避免重复计算。异常时降级为无高亮的pre保证内容永远可见。四、实时性、安全性与开销的三角权衡流式渲染最大的诱惑是越快越好但安全边界不能被牺牲。禁用html: true、对所有未闭合尾部做转义是防 XSS 的底线——因为模型产物不可信提示词注入可能试图让页面渲染恶意脚本。某次复盘发现竞品的对话界面被攻击者利用提示词注入读出了页面的 localStorage根因正是没禁原始 HTML。其次是高亮成本的取舍。同步高亮在短回答里无感长代码块则会卡顿。Worker 异步化解决了卡顿却引入先无高亮、后补高亮的视觉跳变。折中做法是对小于 200 字符的片段同步高亮保流畅超长再异步。最后是完整度与体验的矛盾。等待整段闭合再渲染最稳却失去流式意义过于激进的增量又易渲染半成品。经验上以行级结构即时、围栏块配对后升级为最佳平衡点。graph TD A[片段] -- B{在围栏代码块内?} B -- 否 -- C[行级即时渲染] B -- 是 -- D{配对的围栏已到?} D -- 否 -- E[降级纯文本] D -- 是 -- F[Worker异步高亮] F -- G[升级为彩色代码]还有一个常被低估的细节是占位与骨架。在流式初期模型尚未输出任何内容界面不应该是空白而应该给出正在思考的骨架或打字指示器。这既是体验问题也是心理预期管理用户看到明确的进行中状态容忍度会提高。骨架本身也要轻量避免它成为额外的渲染负担。适用边界对话式 AI、文档助手等富文本输出场景必须做流式渲染而仅返回纯文本或固定结构的接口则无需引入这套复杂度。五、总结流式 Markdown 渲染的主要挑战是在实时输出与解析稳定之间取得平衡。工程上应采用双缓冲策略已闭合前缀全量渲染未闭合尾部先做转义降级待围栏配对后再升级。代码高亮须放入 Web Worker 异步执行并用requestAnimationFrame节流避免长文掉帧。安全上必须禁用原始 HTML、转义未闭合内容防范模型产物中的 XSS 注入。权衡上需接受高亮的短暂视觉跳变并以行级即时、代码块配对升级作为最佳平衡。对于富文本 AI 输出场景流式渲染是必备能力。这条路的回报是值得的。
