七层拆解:SSE 流式 → 问答协议 → 流式拼接 → MD 渲染 → 自定义节点 → 断线重连 → Nginx 防粘连
我们在做 AI 应用的时候,免不了实现对话问答,甚至更复杂的代码高亮、表格、图表。做了这么多的问答应用,整理下问答流程以及详细的渲染。
做 AI 问答应用,往往卡在几个点上:流式数据怎么收?协议怎么定?Markdown 怎么渲染?AI 输出的表格 / 图表怎么变成好看的组件?还有——为什么有时候字是一卡一卡"蹦"出来的?连接断了又该怎么处理?
一个 AI 对话界面,前端要做的事可以拆成七层:
① SSE 流式传输 —— 数据是怎么一波波到前端的
② 问答协议规范 —— 前后端怎么约定"增量/结束/错误"
③ 流式接收与拼接 —— 收到碎片后怎么拼成完整文本
④ Markdown 渲染 —— 怎么把文本变成带格式的界面
⑤ 自定义节点 —— 怎么插入表格、卡片、图表等富组件
⑥ 断线重连 —— 连接断了怎么处理、怎么重连
⑦ Nginx 防粘连 —— 为什么经过代理会"蹦字",怎么修
传统请求是"等全部结果返回再展示",但 AI 生成一句话要几秒到几十秒,让用户干等很难受。所以有了流式(Streaming):数据边生成边传输,前端边收边显示,形成"打字机"效果——既让用户觉得快,又能提前看到内容。
SSE(Server-Sent Events,服务器推送事件):一种让服务器主动、持续向浏览器推送文本数据的协议,非常适合"AI 一个字一个字往外吐"。
| SSE | WebSocket | |
|---|---|---|
| 方向 | 服务器 → 浏览器(单向) | 双向 |
| 数据格式 | 纯文本 | 任意二进制 / 文本 |
| 特点 | 简单、天然支持断线重连 | 复杂、全双工 |
| 适合 | AI 流式输出、实时通知 | 聊天、游戏、双方互发 |
流式问答的难点不在传输本身,而在前后端对"消息格式"的约定。约定不清楚,前端就不知道该拼哪段、什么时候结束、出错怎么处理。
SSE 的本质是:服务器把一个文本流以特定格式"喂"给浏览器。一条消息的规范格式是:
event: 事件名(可省略)
data: 数据内容
(每条消息以空行 \n\n 结尾,服务器可以连续推送很多条)
为了让前端能区分"正在输出 / 结束 / 出错",业界常用统一在 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 枚举,前后端对齐。
| 增量(delta) | 全量(full) | |
|---|---|---|
| 含义 | 每个包只带新加的几个字 | 每个包都带当前全部内容 |
| 优点 | 延迟最低、最接近打字机 | 容错强 |
| 缺点 | — | 浪费带宽、易抖动 |
fullText += delta 即可(delta 可能被后端按词 / 按句切,不一定是单字)。原生 EventSource 太简单,遇到真实项目(要带 token、要 POST、要取消、要控制重连)就抓瞎。所以很多人选 @microsoft/fetch-event-source。
| 能力 | 原生 EventSource | fetch-event-source |
|---|---|---|
| 请求方式 | 仅 GET | ✅ POST / 任意,底层是 fetch |
| 自定义请求头(带 token) | ❌ | ✅ |
| 取消 / 中断 | 仅 close() | ✅ AbortController |
| 自定义重连逻辑 | 自动但不可控 | ✅ onerror 里可控制 |
| 后台保持连接 | 浏览器可能挂起 | ✅ openWhenHidden |
它是"能自定义 headers + 能取消 + 能控制重连的 EventSource",是生产级流式问答的标配。
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('连接关闭'),
});
}
这是最容易翻车、也最容易被讲错的一块。先记住结论:onerror 的返回值就决定一件事——"多久后重连"。
| onerror 的写法 | 结果 |
|---|---|
return 3000 | 3 秒后自动重连(返回值 = 重连间隔,单位毫秒) |
return undefined(不 return) | 按默认间隔重连(默认 1000ms,或报文 retry: 字段改过的值) |
throw err(抛异常) | 不重连,整个请求以失败结束 |
| 触发场景 | 举例 | 一般要不要重连 |
|---|---|---|
| 网络错误 | 断网、请求超时、DNS 失败 | ✅ 通常要重连 |
| onopen 抛错 | 后端返回非 2xx、Content-Type 不是 text/event-stream | ⚠️ 看状态码 |
| 流中途断开 | 读流到一半连接被掐断 | ✅ 通常要重连 |
ctrl.abort() 走的是正常结束分支,不会触发 onerror 重连,放心用;工程上要解决四件事:限次数、做退避、区分可重试 / 不可重试错误、收到数据就重置。
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(), // 主动取消,不触发重连
};
}
// state: 'idle' | 'connecting' | 'streaming' | 'reconnecting' | 'closed'
const [phase, setPhase] = useState('idle');
onopen: () => setPhase('connecting'),
onmessage: () => setPhase('streaming'),
onerror: () => setPhase('reconnecting'),
onclose: () => setPhase('closed'),
收到 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 刷新一次
};
这里就是很多团队自己封装的那些 MD 开头组件。核心思想只有一条,但很关键:
先把 Markdown 解析成 AST(语法树),再用一套"组件映射"把每种 AST 节点渲染成 React 组件。所谓"自定义节点",就是往这套映射里塞你自己的组件。
function Markdown({ content, components }) {
return (
<ReactMarkdown
components={{
h1: H1Node,
table: TableNode, // 表格节点
code: CodeNode, // 代码/自定义块节点
a: LinkNode,
...components, // 外部可继续扩展
}}
>
{content}
</ReactMarkdown>
);
}
Markdown 的表格长这样,react-markdown 默认能渲染,但要自定义样式 / 交互,就接管 table 节点:
| 名称 | 状态 |
| ---- | ---- |
| 退款 | 待处理 |
function TableNode({ children }) {
return <table className="md-table">{children}</table>;
// 也可以解析成二维数组,用 antd/自研 Table 渲染
}
这是 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>;
}
}
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} />;
}
正则只能处理"样子"匹配,遇到嵌套、转义、流式半截内容就崩;AST 是结构化解析,能准确区分"这是表格标题还是正文",也方便精准接管某个节点。生产级渲染,选 AST。
很多人会遇到:直连后端逐字流畅,一经过 Nginx 就"2-3 秒蹦 20 个字"。这不是前端 bug,是 Nginx 默认行为跟 SSE 打架。
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:解决"缓存污染",保证每次都是实时数据。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()(不会触发重连) |
| 需求 | 要上哪几层 |
|---|---|
| 只要"打字机"效果 | SSE 接收 + 节流拼接即可 |
| 内容有代码 / 表格 | 加 MD 组件渲染 + 代码高亮 |
| 需要富交互(卡片 / 按钮) | 用 AST 自定义节点接管特殊渲染 |
| 需要数据可视化 | 约定 chart 代码块 + 动态渲染 ECharts |
| 需要稳定不蹦、断线能恢复 | 上面全部 + 重连策略 + Nginx 三件套 |
SSE 协议定好"增量 / 结束 / 错误"的规范,fetch-event-source 负责带 token、可取消地把流接过来,onerror 的返回值控制"断了多久重连、要不要放弃",MD 组件用 AST 把 Markdown 渲染成"普通文本 + 表格 + 自定义节点 + 图表",而 Nginx 的 proxy_buffering off 三件套,是让这一切"逐字流畅、不粘连"。
| 术语 | 定义 |
|---|---|
| SSE | Server-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 off | Nginx 关闭缓冲,SSE 数据逐字节实时透传,解决"蹦字"核心配置 |
// ========== 可运行骨架: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 };
}
# ========== 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 "";
}
}