AI 对话的流式输出实现
流式不是为了炫技:大模型首字延迟动辄数秒,不流式就等于让用户盯着空白屏。选 SSE 而不是 WebSocket,是工程上的默认解。
一、单向数据流与部署成本
对话场景的数据流向是单向的:用户发一条消息,服务端逐段吐回结果,过程中客户端几乎不再往服务端写东西。SSE 就是为这个方向设计的,而 WebSocket 提供的是双向通道——在这个场景里,另一半方向基本闲置。
更实际的差别在部署层面:
- SSE 是普通 HTTP,代理、网关、CDN 全都认识它,不需要特殊配置就能穿过企业网络;
- WebSocket 需要一次协议升级(Upgrade),中间设备不配合就会断,这也是它在某些内网环境里表现不稳定的原因;
- SSE 自带断线重连与事件 ID 机制,WebSocket 的这些都要自己实现;
- 代价也要说清:SSE 在 HTTP/1.1 下受同域连接数限制(浏览器通常是 6 个),且只能传文本,不能传二进制。对话场景这两条都不构成问题。
所以结论是:只要通信是单向的、数据是文本的,默认选 SSE;需要双向实时(协同编辑、 multiplayer 状态同步)才考虑 WebSocket。
二、原生接口的三个限制
EventSource 是浏览器为 SSE 提供的原生接口,用起来非常省事:
const es = new EventSource('/api/chat')
es.onmessage = (e) => appendToUi(JSON.parse(e.data))
es.onerror = () => { /* 浏览器会自动重连 */ }
但它有三个硬限制,正好卡在真实业务最需要的地方:
- 只能发 GET —— 没法带请求体。而对话请求必须把多轮上下文、模型参数、会话标识传给服务端;
- 不能自定义请求头 ——
Authorization都带不了,只能靠 Cookie 或在 URL 上拼参数; - 无法控制重连行为 —— 自动重连是内置的,但重连间隔、最大次数、重连前要不要换个 token,都改不了。
于是真实项目几乎都会改用 fetch + ReadableStream 手动解析:能力一样,可控性高得多。代价是重连逻辑要自己写,但换个角度想——重连策略本来就该由业务决定。
三、fetch 手工解析的写法
一次完整的流式对话在前端要走过五个环节:发起请求 → 读响应体 → 增量解码 → 按事件切帧 → 渲染并识别结束。
async function chat(messages, { onDelta, signal }) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ messages }),
signal,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.body) throw new Error('该环境不支持流式响应')
const reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let sep
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, sep)
buffer = buffer.slice(sep + 2)
const line = raw.split('\n').find((l) => l.startsWith('data:'))
if (!line) continue
const payload = line.slice(5).trim()
if (payload === '[DONE]') return
onDelta(JSON.parse(payload))
}
}
}
几个关键点:
Accept: text/event-stream要显式声明,部分服务端据此决定输出格式;decoder.decode(value, { stream: true })的 stream 模式不能省,否则中文会被切断成乱码(详见《SSE 流式解析与切帧处理》);- 结束标记要与服务端约定好,
[DONE]只是常见写法之一,别硬编码到业务里; - 渲染要做节流。模型可能每几十毫秒就吐一段,逐段
setState会把主线程吃满。常见做法是用requestAnimationFrame把一帧内的多次更新合并,或者直接交给文本渲染层做批处理。
自动滚动有个前提:用户往上翻看历史时不要抢滚动条:
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40
// 只在用户本来就在底部时才跟随,否则会把人正在看的内容顶走
if (nearBottom) el.scrollTop = el.scrollHeight
四、中断、重试与滚动
这一节的每一条都是「功能写完了,但上线不好用」的典型原因。
取消与清理。 请求必须带上 AbortController 的 signal,组件卸载时在 cleanup 里 abort()。不这么做的话,读取循环会一直挂在内存里,用户离开页面后仍在接收数据。同时要单独识别 AbortError——它是预期行为,不该上报到错误监控里污染错误率。
useEffect(() => {
const controller = new AbortController()
chat(messages, { onDelta: setText, signal: controller.signal })
.catch((err) => {
if (err.name === 'AbortError') return
reportError(err)
})
return () => controller.abort()
}, [messages])
网关缓冲。 这是流式上线失败率最高的一条:反向代理默认会缓冲整个响应体再往下发,流式直接退化成「转圈几秒后一次性出现」。Nginx 需要 proxy_buffering off,或在响应上加 X-Accel-Buffering: no 头;CDN 侧通常要求源站返回 Cache-Control: no-cache 并使用分块传输。
凭证不能放前端。 调用模型服务需要密钥,这个密钥绝不能出现在浏览器里。正确做法是前端只请求自己的服务端,由服务端代理并注入鉴权。这不只是安全规范问题——密钥放在前端等于把它发布到了公网。
体验设计。 三件事必须有:首字到达前的等待态(哪怕是一个闪烁的光标)、「停止生成」按钮(背后就是 abort())、失败后的「重新生成」入口(保留已生成内容,不要清空)。弱网与移动端还要有超时兜底,超时后给出明确提示而不是无限转圈。
打字机效果别做过头。 逐字追加动画在短文本上是加分,在长回答上会拖慢阅读节奏。常见做法是「流式到达即渲染,但不对已到达的内容再做人为延迟」。
流式上线失败率最高的一条是网关缓冲,配置漏了就退化成一次性返回:
proxy_buffering off; # 关键:不缓冲整个响应体
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 300s; # 长连接别被默认超时掐断
五、流式不只是打字机动画
「一段段显示文本」只是表面。协议选型、切帧边界、取消清理、网关缓冲,每一项都能让功能直接失效。
EventSource 也不够用——它不支持自定义请求头与 POST,真实项目几乎都用 fetch 手动解析。
关掉页面更不代表不用管请求了:组件卸载时不 abort,会留下悬挂的读取循环与内存占用。
有了 WebSocket 也不必放弃 SSE:单向文本推送用 SSE 更简单、更容易穿过中间网络,双向才需要 WebSocket。
流式的难点全在这些不起眼的细节上——切帧、取消、网关缓冲,缺一个就白做。
浏览器侧逐块渲染依赖 ReadableStream,理解 reader 与背压才好处理断流。