Skip to content

前端流式输出与通信 ​

大模型回答一个字要几十毫秒,一次回答要十几秒。如果等全部生成完再返回,用户会以为页面卡死。 流式的价值就一句话:把首字延迟(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=128
GET /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()

两个体验细节:

  1. 「停止生成」要保留已生成的内容,不要清空(用户往往想接着用);
  2. 重试要么整条重来、要么从断点续,别混着来,否则会出现重复段落。

六、流式 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 节流 + 代码高亮延后
自动滚动的正确做法只在用户本来在底部时跟随