前端流式输出与通信
大模型回答一个字要几十毫秒,一次回答要十几秒。如果等全部生成完再返回,用户会以为页面卡死。 流式的价值就一句话:把首字延迟(TTFT)从「十几秒」压到「几百毫秒」。
一、四种传输方案怎么选
| 方案 | 方向 | 首字延迟 | 实现成本 | 适用 |
|---|---|---|---|---|
| 轮询 | 客户端反复问 | 差(一个轮询周期) | 低 | 任务型(生成图片、跑批) |
| SSE | 服务端 → 客户端单向下推 | 最好 | 低 | AI 对话(主流) |
| WebSocket | 双向 | 好 | 中 | 需要边说边听的场景(语音、协同) |
| fetch + ReadableStream | 单向,但能自定义请求头 | 最好 | 低 | AI 对话(推荐) |
结论:
- 大多数 AI 对话用 SSE 或 fetch 流式;
- 只有需要「客户端边说边听」(实时语音、多人协同编辑)才上 WebSocket;
- 轮询只用于「任务型」生成(提交任务 → 轮询结果),因为它天然支持断线后继续查。
二、SSE 协议长什么样
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]规则很简单:
- 每条消息以
data:开头,两个换行\n\n表示一条消息结束; - 可以带
event:(事件类型)、id:(断线重连用)、retry:(重连间隔); - 结尾常用
data: [DONE]作为结束标记(OpenAI 风格)。
原生 EventSource 的两个硬限制
js
const es = new EventSource('/api/chat?q=hi') // ❌ 只能 GET
// 而且不能自定义 header(无法带 Authorization)const es = new EventSource('/api/chat?q=hi') // ❌ 只能 GET
// 而且不能自定义 header(无法带 Authorization)所以真实项目里几乎都用 fetch 自己读流,见下一节。
三、用 fetch 读流(推荐写法)
js
async function* streamChat(messages, { signal }) {
const res = await fetch('/api/chat', {
method: 'POST', // 可以用 POST,能带 body
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, // 能带自定义头
Accept: 'text/event-stream',
},
body: JSON.stringify({ messages, stream: true }),
signal, // 用于中断
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
// 关键:stream: true —— 多字节字符可能被 chunk 边界切断
buffer += decoder.decode(value, { stream: true })
// 按「空行」切分事件(\r\n\r\n 和 \n\n 都要兼容)
const parts = buffer.split(/\r?\n\r?\n/)
buffer = parts.pop() // 最后一段可能不完整,留到下一轮
for (const part of parts) {
for (const line of part.split(/\r?\n/)) {
if (!line.startsWith('data:')) continue
const data = line.slice(5).trim()
if (data === '[DONE]') return
try {
const json = JSON.parse(data)
yield json.choices?.[0]?.delta?.content ?? ''
} catch {
// 心跳/注释行,忽略
}
}
}
}
}async function* streamChat(messages, { signal }) {
const res = await fetch('/api/chat', {
method: 'POST', // 可以用 POST,能带 body
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, // 能带自定义头
Accept: 'text/event-stream',
},
body: JSON.stringify({ messages, stream: true }),
signal, // 用于中断
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
// 关键:stream: true —— 多字节字符可能被 chunk 边界切断
buffer += decoder.decode(value, { stream: true })
// 按「空行」切分事件(\r\n\r\n 和 \n\n 都要兼容)
const parts = buffer.split(/\r?\n\r?\n/)
buffer = parts.pop() // 最后一段可能不完整,留到下一轮
for (const part of parts) {
for (const line of part.split(/\r?\n/)) {
if (!line.startsWith('data:')) continue
const data = line.slice(5).trim()
if (data === '[DONE]') return
try {
const json = JSON.parse(data)
yield json.choices?.[0]?.delta?.content ?? ''
} catch {
// 心跳/注释行,忽略
}
}
}
}
}四行最容易写错的代码:
| 位置 | 错法 | 后果 |
|---|---|---|
decoder.decode(value) | 不加 { stream: true } | 中文被切断时出乱码(�) |
buffer.split(...) | 每读一次就整体 split | 半条消息被当成完整 JSON,JSON.parse 报错 |
parts.pop() | 忘了把尾巴留回 buffer | 同上,丢数据 |
| 结束判断 | 只认 [DONE] | 有些网关不发 [DONE],要同时判断 done 和 finish_reason |
四、断流与重连
三种断法,处理方式不同:
| 断法 | 表现 | 处理 |
|---|---|---|
| 网络抖动 | reader.read() 抛错 | 指数退避重试同一条消息 |
| 网关超时(Nginx 默认 60s) | 长回答被截断 | 服务端发心跳(: ping 注释行)保活;客户端检测空转超时 |
| 用户切走页面 | 后台标签页被节流 | 用 visibilitychange 暂停/恢复,或让服务端继续生成、前端按 messageId 拉结果 |
续传(比重试更靠谱)
前端记录已渲染的内容长度(或最后一条 id:),重连时带上:
js
GET /api/chat/resume?messageId=xxx&offset=128GET /api/chat/resume?messageId=xxx&offset=128服务端从 offset 继续推 —— 这样不会重复计费、也不会重复显示。这也是为什么服务端应该把生成中的回答落到存储里,而不是纯内存流。
心跳保活
js
// 服务端每 15s 发一行注释,防止代理断开
res.write(': ping\n\n')// 服务端每 15s 发一行注释,防止代理断开
res.write(': ping\n\n')五、中断与重试
js
const controller = new AbortController()
async function send() {
try {
for await (const delta of streamChat(messages, { signal: controller.signal })) {
append(delta)
}
} catch (e) {
if (e.name === 'AbortError') return // 用户主动停止,不算错误
showError(e)
}
}
stopBtn.onclick = () => controller.abort()const controller = new AbortController()
async function send() {
try {
for await (const delta of streamChat(messages, { signal: controller.signal })) {
append(delta)
}
} catch (e) {
if (e.name === 'AbortError') return // 用户主动停止,不算错误
showError(e)
}
}
stopBtn.onclick = () => controller.abort()两个体验细节:
- 「停止生成」要保留已生成的内容,不要清空(用户往往想接着用);
- 重试要么整条重来、要么从断点续,别混着来,否则会出现重复段落。
六、流式 Markdown 渲染的坑
边收边渲染 Markdown,会短暂出现「语法不完整」的状态:
| 现象 | 原因 | 处理 |
|---|---|---|
| 代码块闪烁、样式错乱 | ``` 还没闭合 | 渲染前补齐未闭合的围栏;或代码块内容先纯文本、闭合后再高亮 |
| 表格错位 | 表头有了、分隔行还没到 | 检测到表格起始时空一行占位 |
** 星号裸露 | 粗体没闭合 | 允许短暂裸露(可接受),或延后 50~100ms 渲染 |
| 每帧都重渲染卡顿 | 每个 token 触发一次全量 Markdown 解析 | 按 rAF 节流(见下) |
js
// 用 rAF 批量渲染,避免每个 token 都解析一次
let pending = ''
let raf = 0
function append(delta) {
pending += delta
if (raf) return
raf = requestAnimationFrame(() => {
raf = 0
text = pending
html = renderMarkdown(text)
})
}// 用 rAF 批量渲染,避免每个 token 都解析一次
let pending = ''
let raf = 0
function append(delta) {
pending += delta
if (raf) return
raf = requestAnimationFrame(() => {
raf = 0
text = pending
html = renderMarkdown(text)
})
}性能优先级:节流渲染 > 代码高亮延后 > 虚拟滚动(超长回答)。
七、滚动与光标(体验细节)
js
// ❌ 无条件滚到底:用户上滑看历史时会被强行拽回去
el.scrollTop = el.scrollHeight
// ✅ 只在用户本来就在底部时才自动跟随
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40
if (isAtBottom) el.scrollTop = el.scrollHeight// ❌ 无条件滚到底:用户上滑看历史时会被强行拽回去
el.scrollTop = el.scrollHeight
// ✅ 只在用户本来就在底部时才自动跟随
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40
if (isAtBottom) el.scrollTop = el.scrollHeight其他细节:
- 生成中显示闪烁光标(
▍)或打字动画,让用户知道还在输出; - 生成结束再显示「复制 / 重新生成 / 点赞」按钮,避免按钮跳动;
prefers-reduced-motion下关掉动画。
八、常见坑清单
- [ ] 用
EventSource结果发现带不了Authorization(应改 fetch 流) - [ ]
TextDecoder忘了{ stream: true },中文变乱码 - [ ] 按
\n\n切分前没做缓冲,半条 JSON 解析失败 - [ ] 没有心跳,Nginx 60s 超时把长回答截断
- [ ] 断线重试时不带 offset,重复显示已生成内容
- [ ] 中断后清空了已生成内容
- [ ] 每个 token 都跑一次 Markdown 解析 + 高亮,长回答卡死
- [ ] 无条件自动滚到底,用户无法回看
九、速查
| 问题 | 答案 |
|---|---|
| AI 对话用什么传输 | SSE / fetch 流式;要双向才用 WebSocket |
| SSE 消息怎么分隔 | data: 开头,空行(\n\n)结束 |
| 为什么不用 EventSource | 只能 GET、不能带自定义请求头 |
| 中文乱码怎么解 | TextDecoder 加 { stream: true } |
| 半条 JSON 怎么办 | 按空行切分并保留最后一段 buffer |
| 长回答被截断 | 服务端发心跳,客户端做空转超时 |
| 断流怎么恢复 | 带 messageId + offset 续传,别整条重来 |
| 用户点停止 | AbortController.abort(),保留已生成内容 |
| 流式 Markdown 卡顿 | rAF 节流 + 代码高亮延后 |
| 自动滚动的正确做法 | 只在用户本来在底部时跟随 |
Cao Note