# 第401题 前端如何实现大模型的流式输出?

⚡ 30 秒速记

  • 流式输出的目标不是让总生成时间变短,而是降低首字等待,让用户持续看到进展
  • 浏览器通常用 fetch 读取 ReadableStream,用 TextDecoder 按增量解码,不能假设一个数据块就是一条完整消息
  • 服务端常用 SSE 帧或按行分隔的 NDJSON;协议必须能表示文本、状态、错误和结束
  • 前端要处理半包、粘包、UTF-8 跨块字符、取消、重连及未完成回答
  • 每到一个 token 就全量重渲染会卡页面,应合并刷新,并对 Markdown 做增量或节流渲染

前端做大模型流式输出,核心是持续读取响应流,再按应用层协议拼出完整事件。 我会用 fetch 获取 response.body,用 TextDecoder 保留跨数据块的字节状态,然后在缓冲区中按换行符拆帧。页面上不会每收到一个字就立刻重渲染,而是按帧或很短的时间窗合并更新。同时要把用户取消、网络中断和服务端错误分开处理,否则界面只会留下一段不知道是否完成的半截文字。

假设服务端返回按行分隔的 NDJSON,每行都是一个独立的 JSON 事件。一次网络读取可能只拿到半行,也可能一次拿到三行,所以必须保留未完整的尾部:

async function* readNdjson(response, signal) {
  if (!response.ok || !response.body) {
    throw new Error(`HTTP ${response.status}`)
  }

  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''

  try {
    while (true) {
      if (signal?.aborted) throw signal.reason
      const { value, done } = await reader.read()
      buffer += decoder.decode(value, { stream: !done })

      const lines = buffer.split('\n')
      buffer = lines.pop() ?? ''
      for (const line of lines) {
        if (line.trim()) yield JSON.parse(line)
      }

      if (done) break
    }
    if (buffer.trim()) yield JSON.parse(buffer)
  } finally {
    reader.releaseLock()
  }
}

const controller = new AbortController()
const response = await fetch('/api/chat', {
  method: 'POST',
  signal: controller.signal,
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ prompt: '解释事件循环' })
})

let answer = ''
for await (const event of readNdjson(response, controller.signal)) {
  if (event.type === 'delta') answer += event.text
  if (event.type === 'error') throw new Error(event.message)
  if (event.type === 'done') break
}
webapp
公众号
开发者导航
切换夜间模式
折叠侧边栏
收起全部