大模型对话界面实战:流式输出与 Markdown 渲染的工程落地
大模型对话界面实战:流式输出与 Markdown 渲染的工程落地
一、Token 逐字吐出背后的体验博弈
敲下回车的那一刻,3 秒空白让人想关掉。这事我见过太多团队栽进去。某头部客服 SaaS 去年复盘过一次,灰度时段就因为首字延迟从 800ms 涨到 4 秒,次留直接掉了 12 个百分点。老板盯着用户流失图复盘,研发才知道,体感比模型能力更影响留存。
流式输出正是为了解决这个等待焦虑。它把回答拆成 Token 片段,边生成边展示。首字延迟从数秒降到几百毫秒,体感天差地别。不同框架的差异在于:React 项目通常用 useState 维护 buffer,Vue 项目用 ref,但底层都是同一件事。
但工程上没这么简单。流式场景最大的难点是:Markdown 是上下文相关的语言。一个未闭合的代码块、一个半截表格,都会让解析器崩或渲染出残品。某开源聊天组件曾出过事故:模型生成到一半输出一个反引号,前端整页 HTML 炸了 30 分钟。
更麻烦的是,模型产物的内容不可信。注入、敏感词、循环输出,这些都得在前端做兜底。我们曾经被一个用户提示诱导,模型输出了 </div><script>... 的恶意片段,没消毒直接渲染,半个团队周末在线救火。从此消毒是硬性流程,没得商量。
二、流式渲染的底层链路
浏览器侧最常见的方案是 fetch + ReadableStream。服务端用 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: () => Promise<Response>,
options: { timeoutMs?: number } = {}
) {
const [html, setHtml] = useState('');
const [done, setDone] = useState(false);
const controllerRef = useRef<AbortController | 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 管理,别让陈年流占着连接。
剩下的就是看监控——首字延迟、解析耗时、并发流数。指标上去了,体感才上得去。这条路在千万级对话下能跑通,回报是值得的。
更多推荐




所有评论(0)