Skip to main content

AI 对话的流式输出实现

流式不是为了炫技:大模型首字延迟动辄数秒,不流式就等于让用户盯着空白屏。选 SSE 而不是 WebSocket,是工程上的默认解。

一、单向数据流与部署成本​

对话场景的数据流向是单向的:用户发一条消息,服务端逐段吐回结果,过程中客户端几乎不再往服务端写东西。SSE 就是为这个方向设计的,而 WebSocket 提供的是双向通道——在这个场景里,另一半方向基本闲置。

更实际的差别在部署层面:

  • SSE 是普通 HTTP,代理、网关、CDN 全都认识它,不需要特殊配置就能穿过企业网络;
  • WebSocket 需要一次协议升级(Upgrade),中间设备不配合就会断,这也是它在某些内网环境里表现不稳定的原因;
  • SSE 自带断线重连与事件 ID 机制,WebSocket 的这些都要自己实现;
  • 代价也要说清:SSE 在 HTTP/1.1 下受同域连接数限制(浏览器通常是 6 个),且只能传文本,不能传二进制。对话场景这两条都不构成问题。

所以结论是:只要通信是单向的、数据是文本的,默认选 SSE;需要双向实时(协同编辑、 multiplayer 状态同步)才考虑 WebSocket。

浏览器模型服务一次 POST 发起text/event-stream 持续回推SSE(推荐默认)单向流:客户端发一次,服务端逐段吐回浏览器实时服务双向双向WebSocket(用于双向实时)协同编辑、多人状态同步这类需要客户端持续上行
图:对话是单向数据流,SSE 是默认解;双向实时才需要 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 与背压才好处理断流。