AI 对话 "打字机" 是怎么实现的?从 SSE 流式、Markdown 组件到 Nginx 防粘连

我们在做AI应用的时候,免不了实现对话问答,甚至之更复杂的 代码高亮、表格、甚至图表,做了这么多的问答应用,整理下问答流程,以及详细的渲染

我们在做AI问答应用时,往往卡在几个点上:流式数据怎么收?协议怎么定?Markdown 怎么渲染?AI 输出的表格 / 图表怎么变成好看的组件?还有 ------ 为什么有时候字是一卡一卡 "蹦" 出来的?连接断了又该怎么处理?


0. 先看整体:拆成哪几层

一个 AI 对话界面,前端要做的事可以拆成几层:

arduino 复制代码
① SSE 流式传输     ------ 数据是怎么一波波到前端的
② 问答协议规范     ------ 前后端怎么约定"增量/结束/错误"
③ 流式接收与拼接   ------ 收到碎片后怎么拼成完整文本
④ Markdown 渲染    ------ 怎么把文本变成带格式的界面
⑤ 自定义节点       ------ 怎么插入表格、卡片、图表等富组件
⑥ 断线重连         ------ 连接断了怎么处理、怎么重连
⑦ Nginx 防粘连     ------ 为什么经过代理会"蹦字",怎么修

①②负责 "传得对",③负责 "接得住",④⑤负责 "显示得好看",⑥负责 "断了能回来",⑦负责 "传得不卡"。


1. 第一层:SSE ------ 让数据像打字机一样 "流" 过来

1.1 为什么要流式?

传统请求是 "等全部结果返回再展示",但 AI 生成一句话要几秒到几十秒,让用户干等很难受。所以有了流式(Streaming) :数据边生成边传输,前端边收边显示,形成 "打字机" 效果 ------既让用户觉得快,又能提前看到内容

1.2 SSE 是什么?和 WebSocket 有啥区别?

SSE(Server-Sent Events,服务器推送事件) :一种让服务器主动、持续向浏览器推送文本数据的协议,非常适合 "AI 一个字一个字往外吐"。

表格

SSE WebSocket
方向 服务器 → 浏览器(单向) 双向
数据格式 纯文本 任意二进制 / 文本
特点 简单、天然支持断线重连 复杂、全双工
适合 AI 流式输出、实时通知 聊天、游戏、双方互发

AI 对话是 "服务器单方面吐数据",用 SSE 就够了,不需要 WebSocket 那么重


2. 第二层:SSE 问答协议规范 ------ 前后端怎么约定一次问答

流式问答的难点不在传输本身,而在前后端对 "消息格式" 的约定。约定不清楚,前端就不知道该拼哪段、什么时候结束、出错怎么处理。

2.1 SSE 报文长什么样

SSE 的本质是:服务器把一个文本流以特定格式 "喂" 给浏览器。一条消息的规范格式是:

makefile 复制代码
event: 事件名(可省略)
data: 数据内容

每条消息用 data: 开头,最后以一个空行 \n\n 结尾。 服务器可以连续推送很多条。

2.2 推荐的一种问答规范(实践常用)

为了让前端能区分 "正在输出 / 结束 / 出错",业界常用统一在 data 里放 JSON,再用 type 字段区分事件类型

vbnet 复制代码
// 正在生成:delta,携带增量内容
event: message
data: {"type":"delta","content":"你"}

// 生成结束:done
event: message
data: {"type":"done","messageId":"msg_123"}

// 出错:error
event: error
data: {"code":"5001","message":"服务繁忙,请稍后重试"}

这个规范的好处:前端只需要在 onmessage 里 switch 一下 type ,就知道该 "追加文本" 还是 "收尾" 还是 "报错"。你要做的就是在项目里定好这套 type 枚举,前后端对齐。

2.3 为什么用 "增量 delta" 而不是 "整段全量"?

  • 增量(delta) :每个包只带新加的几个字,前端累加,延迟最低、最接近打字机;
  • 全量(full) :每个包都带当前全部内容,容错强但浪费带宽、易抖动。

实践里 AI 流式用 delta ,前端只做 fullText += delta 即可(delta 可能被后端按词 / 按句切,不一定是单字)。


3. 第三层:传输层实战 ------ 为什么用 @microsoft/fetch-event-source

原生 EventSource 太简单,遇到真实项目(要带 token、要 POST、要取消、要控制重连)就抓瞎。所以很多人选 @microsoft/fetch-event-source

3.1 它比原生 EventSource 强在哪

表格

能力 原生 EventSource fetch-event-source
请求方式 仅 GET POST / 任意,底层是 fetch
自定义请求头(带 token)
取消 / 中断 仅 close () ✅ AbortController
自定义重连逻辑 自动但不可控 ✅ onerror 里可控制
后台保持连接 浏览器可能挂起 ✅ openWhenHidden

它是 "能自定义 headers + 能取消 + 能控制重连的 EventSource" ,是生产级流式问答的标配。

3.2 核心用法

javascript 复制代码
import { fetchEventSource } from '@microsoft/fetch-event-source';

const ctrl = new AbortController(); // 用于取消本次问答

async function chat(question, { onDelta, onDone }) {
  await fetchEventSource('/api/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`   // 能带 token,这是关键
    },
    body: JSON.stringify({ question, stream: true }),
    signal: ctrl.signal,          // 支持取消
    openWhenHidden: true,         // 切后台也保持连接

    onopen: async (res) => {
      if (!res.ok) throw new Error(`连接失败: ${res.status}`);
    },

    onmessage: (ev) => {
      const data = JSON.parse(ev.data);
      if (data.type === 'delta') onDelta(data.content);  // 追加增量
      if (data.type === 'done')  onDone(data.messageId); // 收尾
    },

    onclose: () => console.log('连接关闭'),
  });
}

4. 第四层:断线重连 ------ onerror 到底怎么控制 "断了重连"

这是最容易翻车、也最容易被讲错的一块。先记住结论:onerror 的返回值就决定一件事 ------"多久后重连"。

4.1 onerror 返回值的正确语义

表格

onerror 的写法 结果
return 3000 3 秒后自动重连(返回值 = 重连间隔,单位毫秒)
return undefined(不 return) 按默认间隔重连 (默认 1000ms,或报文 retry: 字段改过的值)
throw err(抛异常) 不重连,整个请求以失败结束

核心记忆: "返回数字 = 重连","抛异常 = 放弃"。

4.2 什么时候会触发 onerror?(哪些情况算 "断")

触发场景 举例 一般要不要重连
网络错误 断网、请求超时、DNS 失败 ✅ 通常要重连
onopen 抛错 后端返回非 2xx、Content-Type 不是 text/event-stream ⚠️ 看状态码
流中途断开 读流到一半连接被掐断 ✅ 通常要重连

4.3 两个容易误解的点

  1. 主动取消不会重连 :用户点 "停止" 调 ctrl.abort() 走的是正常结束分支,不会 触发 onerror 重连,放心用;
  2. 库没有内置指数退避 :默认固定 1000ms 重试。想要 "1s、2s、4s" 的退避效果,得自己在 onerror 里做(下面示例)。

4.4 实战:设计一套健壮的重连策略

工程上要解决四件事:限次数、做退避、区分可重试 / 不可重试错误、收到数据就重置

javascript 复制代码
import { fetchEventSource } from '@microsoft/fetch-event-source';

const MAX_RETRIES = 3;      // 最多重试 3 次
const MAX_INTERVAL = 30000; // 退避上限 30s

function createChat(onEvent) {
  let retryCount = 0;
  let interval = 1000;
  const ctrl = new AbortController();

  async function connect(question) {
    await fetchEventSource('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question, stream: true }),
      signal: ctrl.signal,
      openWhenHidden: true,

      onopen: async (res) => {
        // 在 onopen 里主动判定"是否该继续"
        if (res.status === 401) throw new Error('UNAUTHORIZED'); // 不可重试
        if (res.status !== 200) throw new Error(`HTTP_${res.status}`);
      },

      onmessage: (ev) => {
        // 收到数据 → 连接健康 → 重置重试状态
        retryCount = 0;
        interval = 1000;
        const data = JSON.parse(ev.data);
        onEvent(data);
      },

      onerror: (err) => {
        // ① 不可重试的错误:throw,放弃
        if (err.message === 'UNAUTHORIZED') throw err;
        // ② 超过重试上限:throw,放弃
        if (retryCount >= MAX_RETRIES) {
          console.error('重试次数已用尽', err);
          throw err;
        }
        // ③ 否则:手动指数退避,返回间隔 → 触发重连
        retryCount += 1;
        interval = Math.min(interval * 2, MAX_INTERVAL);
        console.log(`第 ${retryCount} 次重连,${interval}ms 后重试`);
        return interval;   // 返回数字 = 按这个间隔重连
      },
    });
  }

  return {
    start: (q) => connect(q),
    stop: () => ctrl.abort(), // 主动取消,不触发重连
  };
}

4.5 配一个状态机,让 UI 知道 "现在在干嘛"

css 复制代码
// state: 'idle' | 'connecting' | 'streaming' | 'reconnecting' | 'closed'
const [phase, setPhase] = useState('idle');

onopen:    () => setPhase('connecting'),
onmessage: () => setPhase('streaming'),
onerror:   () => setPhase('reconnecting'),
onclose:   () => setPhase('closed'),

UI 上在 reconnecting 时显示 "连接已断开,正在重连...",体验立刻专业起来。


5. 第五层:数据处理 ------ 把增量拼成完整 Markdown

收到 delta 后,需要维护一份 "累加文本",同时用节流控制渲染频率(否则每个字符都重渲染,卡):

ini 复制代码
const [streamText, setStreamText] = useState('');

// 用 ref 存最新文本,配合节流更新视图
const textRef = useRef('');
const timerRef = useRef(null);

const onDelta = (delta) => {
  textRef.current += delta;
  if (timerRef.current) return;            // 节流:已有定时器就不再触发
  timerRef.current = setTimeout(() => {
    setStreamText(textRef.current);        // 批量更新一次界面
    timerRef.current = null;
  }, 60);                                  // 每 60ms 刷新一次
};

这一步的目标:把 "高频增量" 稀释成 "低频可渲染的完整 Markdown 串" ,为下一层做准备。


6. 第六层:MD 组件封装 ------ Md 展示 / 自定义节点 / 表格 / 图表

这里就是很多团队自己封装的那些 MD 开头组件。核心思想只有一条,但很关键:

先把 Markdown 解析成 AST(语法树),再用一套 "组件映射" 把每种 AST 节点渲染成 React 组件。所谓 "自定义节点",就是往这套映射里塞你自己的组件。

6.1 一个 MD 组件的基本骨架

javascript 复制代码
function Markdown({ content, components }) {
  return (
    <ReactMarkdown
      components={{
        h1: H1Node,
        table: TableNode,   // 表格节点
        code: CodeNode,     // 代码/自定义块节点
        a: LinkNode,
        ...components,      // 外部可继续扩展
      }}
    >
      {content}
    </ReactMarkdown>
  );
}

6.2 表格节点:把 Markdown 表格变 "组件表格"

Markdown 的表格长这样,react-markdown 默认能渲染,但要自定义样式 / 交互,就接管 table 节点:

lua 复制代码
| 名称 | 状态 |
| ---- | ---- |
| 退款 | 待处理 |
javascript 复制代码
function TableNode({ children }) {
  return <table className="md-table">{children}</table>;
  // 也可以解析成二维数组,用 antd/自研 Table 渲染
}

流式时表格有个坑:AI 的表格还没写完就渲染,会 "蹦" 。解法:等表格的 | 行和表头闭合后再渲染,或中途先用占位。

6.3 自定义节点:CodeNode 就是一个 "分发器"

这是 MD 组件最灵活、也最核心的一环。要理解它,先记住一个关键点:

CodeNode 不是一个 "代码渲染器",而是一个 "分发器(dispatcher)" ------ 它拿到一段代码块,先看它的 language 标记,然后把渲染这件事 "分派" 给对应的组件。分发给谁,由你来定,这就是自定义能力的来源。

工作流程:

ini 复制代码
```xxx { ... } ```
    ↓ 进入 CodeNode(分发器)
    ↓ 读取 language 标记
    ├─ language = "card"   → 分发给 <Card> 组件
    ├─ language = "chart"  → 分发给 <Chart> 组件
    ├─ language = "js/py"  → 分发给 <SyntaxHighlighter> 代码高亮
    └─ 其他语言            → 走默认高亮 / 朴素显示

代码层面,CodeNode 就是一层 switch

javascript 复制代码
function CodeNode({ className, children }) {
  // 从 className 里取出语言标记,比如 language-chart → "chart"
  const lang = (className || '').replace('language-', '');

  // 分发器:按 language 决定"这段代码怎么展示"
  switch (lang) {
    case 'card':                       // 自定义卡片
      return <Card {...JSON.parse(String(children))} />;
    case 'chart':                      // 自定义图表
      return <Chart option={JSON.parse(String(children))} />;
    case 'button':                     // 自定义按钮
      return <ActionButton {...JSON.parse(String(children))} />;
    default:                           // 其余当普通代码,走代码高亮
      return <SyntaxHighlighter language={lang}>{String(children)}</SyntaxHighlighter>;
  }
}

关键理解: 因为 CodeNode 是个分发器,所以 "让 AI 输出一种新内容" 变得极其简单 ------你只需要做两件事 :和 AI 约定一个特殊语言标记(比如 card);在 CodeNodeswitch 里新增一个 case,把这段 JSON 渲染成你的组件。你不需要改任何解析逻辑 ------Markdown 的 AST 解析帮你把代码块完整剥离,CodeNode 只需负责 "看标记、做分发"。

6.4 图表展示:把 AI 的数据画成 ECharts

AI 对话里常出现 "分析数据" 的场景,纯文字表述不清楚,图表一眼看懂。思路和自定义节点一样:约定一种结构化格式,比如让 AI 输出一个特殊的 ECharts JSON 代码块:

json 复制代码
```chart
{"type":"bar","data":{"x":["1月","2月","3月"],"y":[100,120,90]}}
```

前端识别 chart 代码块 → 解析 JSON → 动态渲染 <ECharts> 组件:

javascript 复制代码
import ReactECharts from 'echarts-for-react';

function ChartNode({ config }) {
  const option = {
    xAxis: { data: config.data.x },
    yAxis: {},
    series: [{ type: config.type, data: config.data.y }]
  };
  return <ReactECharts option={option} />;
}

核心原则:流式阶段用文本 / 骨架占位,等这段数据完整了再画图,避免中途 "闪"。

6.5 为什么用 AST 而不是正则?

正则只能处理 "样子" 匹配,遇到嵌套、转义、流式半截内容就崩;AST 是结构化解析,能准确区分 "这是表格标题还是正文",也方便精准接管某个节点。生产级渲染,选 AST。


7. 第七层:Nginx 配置 ------ 让流式 "不粘连"

很多人会遇到:直连后端逐字流畅,一经过 Nginx 就 "2-3 秒蹦 20 个字" 。这不是前端 bug,是 Nginx 默认行为跟 SSE 打架。

7.1 为什么会粘连(两层延迟)

  1. Nginx 缓冲:默认会把后端输出 "攒到 4k/8k 才转发",小数据一直被扣着;
  2. TCP 小包合并:即便转发,TCP 也会等约 200ms 看有没有后续小包一起发,形成 "10 字 + 200ms" 的卡顿节奏。

7.2 三件套配置

bash 复制代码
location /api/chat {
    proxy_pass http://127.0.0.1:8080;   # 你的后端 SSE 服务

    # 核心三件套
    proxy_http_version 1.1;   # 强制 HTTP/1.1,支持长连接 + 分块传输
    proxy_cache off;          # 禁用代理缓存,防旧数据污染
    proxy_buffering off;      # 【核心】关闭缓冲,逐字节实时透传

    # 补充配置
    proxy_read_timeout 3600s; # 长连接超时拉长
    gzip off;                 # 禁用压缩(压缩会攒包)
    proxy_set_header Connection "";
}
  • proxy_http_version 1.1:解决 "协议支持",SSE 依赖 keep-alive 和 chunked;
  • proxy_buffering off解决粘连的核心,数据不攒、实时透传;
  • proxy_cache off:解决 "缓存污染",保证每次都是实时数据。

改完这几行,再回前端看,就是逐字流畅、不再蹦字了。


8. 完整链路 + 踩坑汇总

bash 复制代码
POST /api/chat(fetch-event-source,带 token)
  → Nginx 不缓冲、实时透传
  → 后端按 SSE 协议吐 {type:delta}
  → 前端 onmessage 拼文本 + 节流
  → MD 组件 AST 渲染:普通/表格/代码/自定义卡片/图表
  → 断了?onerror 按退避策略重连,超限则放弃

高频坑清单

正确做法
Nginx 下流式蹦字 proxy_buffering off + proxy_http_version 1.1 + proxy_cache off
原生 EventSource 带不了 token @microsoft/fetch-event-source,用 POST + headers
以为 "throw 会重连" 记反了:throw 是放弃,返回数字才是重连
无限重连,后端挂了空转 MAX_RETRIES 限次数
401 也在傻傻重试 onopen/onerror 里识别不可重试错误直接 throw
每个字符都重渲染,卡 节流 / 防抖,60ms 批量刷一次
代码高亮乱闪 等代码块闭合再高亮,半截先朴素显示
表格 / 图表中途 "蹦" 数据完整后再渲染富组件,流式阶段用占位
用户想停止生成 AbortController + ctrl.abort()(不会触发重连)

9. 选型建议:这几层能不能一起用?

需求 要上哪几层
只要 "打字机" 效果 SSE 接收 + 节流拼接即可
内容有代码 / 表格 加 MD 组件渲染 + 代码高亮
需要富交互(卡片 / 按钮) 用 AST 自定义节点接管特殊渲染
需要数据可视化 约定 chart 代码块 + 动态渲染 ECharts
需要稳定不蹦、断线能恢复 上面全部 + 重连策略 + Nginx 三件套

成熟的 AI 对话产品,基本是这些层一起用的:SSE 是地基,Markdown 是基础渲染,自定义节点和图表是 "在 Markdown 之上扩展的富能力",重连策略 + Nginx 配置是保障 "稳定流畅" 的最后一公里。


10. 总结

SSE 协议定好 "增量 / 结束 / 错误" 的规范,fetch-event-source 负责带 token、可取消地把流接过来,onerror 的返回值控制 "断了多久重连、要不要放弃",MD 组件用 AST 把 Markdown 渲染成 "普通文本 + 表格 + 自定义节点 + 图表",而 Nginx 的 proxy_buffering off 三件套,是让这一切 "逐字流畅、不粘连" 。

相关推荐
雪芽蓝域zzs1 小时前
第三十七节:防抖、节流封装(composables 通用函数,搜索框防重复请求)
前端·javascript·vue.js
海浪仙人掌1 小时前
速动比率怎么分析?速动比率分析有哪些注意事项?
大数据·数据库·人工智能
武子康1 小时前
SGLang 一加并发就出问题,先查显存还是队列
人工智能·llm·agent
Jared_devin1 小时前
单头、多头 Self-Attention可视化
人工智能·pytorch·深度学习·算法·机器学习·pycharm
七爷数码AI1 小时前
通知播报与作业配音:4款文字转语音工具怎么选?
人工智能·语音识别
天国梦1 小时前
同步课本单词APP怎么选?实测3款才知道真实差距
人工智能·机器学习
昇腾CANN1 小时前
9月14日直播丨人芯对话:昇腾950算力落地算子的编程范式探索
人工智能·昇腾·cann·cann开源
by组态1 小时前
Ricon组态系统通信配置指南
前端·后端·物联网
lucas_AI1 小时前
TableParseMap:榜单 93 分的表格解析,真实复杂表格只有 85 分
人工智能