大模型对话界面实战:流式输出与 Markdown 渲染的工程落地

一、Token 逐字吐出背后的体验博弈

敲下回车的那一刻,3 秒空白让人想关掉。这事我见过太多团队栽进去。某头部客服 SaaS 去年复盘过一次,灰度时段就因为首字延迟从 800ms 涨到 4 秒,次留直接掉了 12 个百分点。老板盯着用户流失图复盘,研发才知道,体感比模型能力更影响留存。

流式输出正是为了解决这个等待焦虑。它把回答拆成 Token 片段,边生成边展示。首字延迟从数秒降到几百毫秒,体感天差地别。不同框架的差异在于:React 项目通常用 useState 维护 buffer,Vue 项目用 ref,但底层都是同一件事。

但工程上没这么简单。流式场景最大的难点是:Markdown 是上下文相关的语言。一个未闭合的代码块、一个半截表格,都会让解析器崩或渲染出残品。某开源聊天组件曾出过事故:模型生成到一半输出一个反引号,前端整页 HTML 炸了 30 分钟。

更麻烦的是,模型产物的内容不可信。注入、敏感词、循环输出,这些都得在前端做兜底。我们曾经被一个用户提示诱导,模型输出了 </div><script>... 的恶意片段,没消毒直接渲染,半个团队周末在线救火。从此消毒是硬性流程,没得商量。

二、流式渲染的底层链路

浏览器侧最常见的方案是 fetch + ReadableStream。服务端用 SSE 或分块传输,源源不断把字节推到前端。

字节流里有个坑:UTF-8 多字节字符可能被网络分片切断。直接按块 decode 会丢字。正确做法是用 TextDecoderstream: 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 管理,别让陈年流占着连接。

剩下的就是看监控——首字延迟、解析耗时、并发流数。指标上去了,体感才上得去。这条路在千万级对话下能跑通,回报是值得的。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐