掘金文章归档 · 第 1 篇 AI 对话 SSE / Markdown / Nginx

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

七层拆解:SSE 流式 → 问答协议 → 流式拼接 → MD 渲染 → 自定义节点 → 断线重连 → Nginx 防粘连

原文作者:随意_(掘金) | juejin.cn/post/7684814322542395455 | 发布于 2026-09-14 | 约 12 分钟阅读

0先看整体:拆成哪七层

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

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

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

① SSE 流式传输     —— 数据是怎么一波波到前端的
② 问答协议规范     —— 前后端怎么约定"增量/结束/错误"
③ 流式接收与拼接   —— 收到碎片后怎么拼成完整文本
④ Markdown 渲染    —— 怎么把文本变成带格式的界面
⑤ 自定义节点       —— 怎么插入表格、卡片、图表等富组件
⑥ 断线重连         —— 连接断了怎么处理、怎么重连
⑦ Nginx 防粘连     —— 为什么经过代理会"蹦字",怎么修
📌 分工记忆:①②负责"传得对",③负责"接得住",④⑤负责"显示得好看",⑥负责"断了能回来",⑦负责"传得不卡"。

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

1.1 为什么要流式?

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

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

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

SSEWebSocket
方向服务器 → 浏览器(单向)双向
数据格式纯文本任意二进制 / 文本
特点简单、天然支持断线重连复杂、全双工
适合AI 流式输出、实时通知聊天、游戏、双方互发
✅ AI 对话是"服务器单方面吐数据",用 SSE 就够了,不需要 WebSocket 那么重。

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

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

2.1 SSE 报文长什么样

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

event: 事件名(可省略)
data: 数据内容
(每条消息以空行 \n\n 结尾,服务器可以连续推送很多条)

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

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

// 正在生成: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 强在哪

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

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

3.2 核心用法

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 30003 秒后自动重连(返回值 = 重连间隔,单位毫秒)
return undefined(不 return)按默认间隔重连(默认 1000ms,或报文 retry: 字段改过的值)
throw err(抛异常)不重连,整个请求以失败结束
📌 核心记忆:"返回数字 = 重连","抛异常 = 放弃"

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

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

4.3 两个容易误解的点

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

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

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 知道"现在在干嘛"

// 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 后,需要维护一份"累加文本",同时用节流控制渲染频率(否则每个字符都重渲染,卡):

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 开头组件。核心思想只有一条,但很关键:

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

6.1 一个 MD 组件的基本骨架

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 节点:

| 名称 | 状态 |
| ---- | ---- |
| 退款 | 待处理 |
function TableNode({ children }) {
  return <table className="md-table">{children}</table>;
  // 也可以解析成二维数组,用 antd/自研 Table 渲染
}
⚠️ 流式时表格有个坑:AI 的表格还没写完就渲染,会"蹦"。解法:等表格的 | 行和表头闭合后再渲染,或中途先用占位。

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

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

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

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

代码层面,CodeNode 就是一层 switch:

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);在 CodeNode 的 switch 里新增一个 case,把这段 JSON 渲染成你的组件。你不需要改任何解析逻辑——Markdown 的 AST 解析帮你把代码块完整剥离,CodeNode 只需负责"看标记、做分发"。

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

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

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

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

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 为什么会粘连(两层延迟)

7.2 三件套配置

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 "";
}
✅ 改完这几行,再回前端看,就是逐字流畅、不再蹦字了。

8完整链路 + 踩坑汇总

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总结 & 术语表 & 附录

10.1 总结

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

术语表

术语定义
SSEServer-Sent Events,服务器推送事件:基于 HTTP 的单向文本流推送协议
流式输出(Streaming)数据边生成边传输、前端边收边显示,形成打字机效果
delta / full增量包(每包只带新内容)vs 全量包(每包带全部内容),AI 流式用 delta
@microsoft/fetch-event-source微软官方 SSE 库:基于 fetch,支持 POST / headers / AbortController / 可控重连
onerror 返回值返回数字 = 该毫秒数后重连;不返回 = 默认间隔重连;throw = 放弃重连
指数退避重连间隔按 1s→2s→4s 递增的策略,需在 onerror 里手动实现
AST抽象语法树:Markdown 解析后的结构化树,用于精准接管各类节点
CodeNode 分发器按代码块 language 标记把渲染分派给不同组件(card/chart/button/js…)
proxy_buffering offNginx 关闭缓冲,SSE 数据逐字节实时透传,解决"蹦字"核心配置

附录 A:完整链路可运行骨架(fetch-event-source + 重连 + 状态机)

// ========== 可运行骨架:AI 对话流式客户端 ==========
import { fetchEventSource } from '@microsoft/fetch-event-source';
import { useState } from 'react';

const MAX_RETRIES = 3;
const MAX_INTERVAL = 30000;

export function useChat() {
  const [phase, setPhase] = useState('idle');   // idle|connecting|streaming|reconnecting|closed
  const [streamText, setStreamText] = useState('');

  const textRef = useRef('');
  const timerRef = useRef(null);
  let retryCount = 0;
  let interval = 1000;
  const ctrl = new AbortController();

  const pushDelta = (delta) => {
    textRef.current += delta;
    if (timerRef.current) return;
    timerRef.current = setTimeout(() => {
      setStreamText(textRef.current);   // 60ms 节流批量更新
      timerRef.current = null;
    }, 60);
  };

  async function ask(question) {
    setPhase('connecting');
    await fetchEventSource('/api/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      },
      body: JSON.stringify({ question, stream: true }),
      signal: ctrl.signal,
      openWhenHidden: true,

      onopen: async (res) => {
        if (res.status === 401) throw new Error('UNAUTHORIZED');
        if (!res.ok) throw new Error(`HTTP_${res.status}`);
      },

      onmessage: (ev) => {
        retryCount = 0; interval = 1000;
        setPhase('streaming');
        const data = JSON.parse(ev.data);
        if (data.type === 'delta') pushDelta(data.content);
        if (data.type === 'done')  { setPhase('closed'); ctrl.abort(); }
      },

      onerror: (err) => {
        if (err.message === 'UNAUTHORIZED' || retryCount >= MAX_RETRIES) {
          setPhase('closed'); throw err;
        }
        retryCount += 1;
        interval = Math.min(interval * 2, MAX_INTERVAL);
        setPhase('reconnecting');
        return interval;
      },
    });
  }

  const stop = () => ctrl.abort();   // 主动停止,不触发重连

  return { phase, streamText, ask, stop };
}

附录 B:Nginx 防粘连完整配置

# ========== Nginx SSE 防粘连配置 ==========
server {
    listen 80;

    location /api/chat {
        proxy_pass http://127.0.0.1:8080;

        proxy_http_version 1.1;   # 长连接 + chunked
        proxy_cache off;          # 禁缓存
        proxy_buffering off;      # 【核心】逐字节透传
        proxy_read_timeout 3600s; # 长连接不超时
        gzip off;                 # 禁压缩
        proxy_set_header Connection "";
    }
}