
大模型对话界面实战流式输出与 Markdown 渲染的工程落地一、Token 逐字吐出背后的体验博弈敲下回车的那一刻3 秒空白让人想关掉。这事我见过太多团队栽进去。某头部客服 SaaS 去年复盘过一次灰度时段就因为首字延迟从 800ms 涨到 4 秒次留直接掉了 12 个百分点。老板盯着用户流失图复盘研发才知道体感比模型能力更影响留存。流式输出正是为了解决这个等待焦虑。它把回答拆成 Token 片段边生成边展示。首字延迟从数秒降到几百毫秒体感天差地别。不同框架的差异在于React 项目通常用useState维护 bufferVue 项目用ref但底层都是同一件事。但工程上没这么简单。流式场景最大的难点是Markdown 是上下文相关的语言。一个未闭合的代码块、一个半截表格都会让解析器崩或渲染出残品。某开源聊天组件曾出过事故模型生成到一半输出一个反引号前端整页 HTML 炸了 30 分钟。更麻烦的是模型产物的内容不可信。注入、敏感词、循环输出这些都得在前端做兜底。我们曾经被一个用户提示诱导模型输出了/divscript...的恶意片段没消毒直接渲染半个团队周末在线救火。从此消毒是硬性流程没得商量。二、流式渲染的底层链路浏览器侧最常见的方案是fetchReadableStream。服务端用 SSE 或分块传输源源不断把字节推到前端。字节流里有个坑UTF-8 多字节字符可能被网络分片切断。直接按块decode会丢字。正确做法是用TextDecoder的stream: true模式让它跨块拼接。我们项目上线后第一周有用户反馈中文突然变成乱码就是这个原因。补丁打上后问题消失。文本攒到一定量交给 Markdown 解析器。解析器输出抽象语法树渲染层再映射成组件。每来一段就重渲染一次。完整数据流sequenceDiagram participant U as 浏览器 participant G as 接入网关 participant L as 大模型服务 participant P as Markdown 解析器 participant V as 渲染层 U-G: 发起对话请求fetch stream G-L: 转发请求并携带会话上下文 L--G: 流式返回 Token 片段chunk G--U: SSE 分块推送字节流 U-P: 解码片段并追加到缓冲区 P-V: 增量解析生成抽象语法树 V-U: 更新虚拟 DOM 并局部重绘三、生产级流式渲染 Hook下面这段代码是我从一个真实项目里抽出来的。它消费流式响应、增量解析、带中断和超时控制。可以直接做基础组件。import { useEffect, useRef, useState } from react; import { marked } from marked; import DOMPurify from dompurify; // 调用方传入已建立好的流式请求工厂 export function useStreamMarkdown( requestFactory: () PromiseResponse, options: { timeoutMs?: number } {} ) { const [html, setHtml] useState(); const [done, setDone] useState(false); const controllerRef useRefAbortController | null(null); useEffect(() { const controller new AbortController(); controllerRef.current controller; let buffer ; let timeout false; const timer setTimeout( () { timeout true; controller.abort(); }, options.timeoutMs ?? 30000 ); (async () { try { const res await requestFactory(); if (!res.body) throw new Error(响应体为空); const reader res.body.getReader(); const decoder new TextDecoder(); while (true) { const { value, done } await reader.read(); if (done || timeout) break; buffer decoder.decode(value, { stream: true }); // 每次增量都重新解析并消毒阻断 XSS 注入 const raw marked.parse(buffer) as string; setHtml(DOMPurify.sanitize(raw)); } setDone(true); } catch (err) { if ((err as Error).name ! AbortError) { setHtml(DOMPurify.sanitize(p内容加载失败请重试/p)); } } finally { clearTimeout(timer); } })(); return () controller.abort(); }, []); return { html, done }; }三个不能省的点每次解析完都过DOMPurify因为模型不可信。AbortController必须挂载到useEffect的清理函数里组件卸载就释放。超时阈值别超过 30 秒否则用户早就切到别的会话了。四、流式不是万能解全量重解析的成本是 O(n²)。回答 500 字时看不出来到 2000 字就开始掉帧。这是结构性问题没有银弹。折中做法是只解析已闭合的区块。但流式文本天然处于半闭合状态强行切会生成残缺结构。需要在稳定性与流畅度之间取舍。安全这事千万别偷懒。Markdown 支持原始标签模型又可能被注入攻击诱导输出script。消毒是强制项不是可选项。少一行DOMPurify.sanitize明天就是 P0 工单。状态隔离也要重视。用户同时开三个会话旧的流没中断就会跟新流抢同一块视图。组件卸载时记得调abort否则内存会偷偷涨。适用边界大概是这样短对话、代码片段展示场景收益最大超长文档建议分块虚拟滚动单次渲染别超过 200 个节点。五、总结流式渲染的工程要点其实就三件事数据链路用流式读取不要轮询。渲染必须配合消毒库模型不可信。生命周期交给AbortController管理别让陈年流占着连接。剩下的就是看监控——首字延迟、解析耗时、并发流数。指标上去了体感才上得去。这条路在千万级对话下能跑通回报是值得的。