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

⚡ 30 秒速记

  • 流式输出不让总时间变短,解决的是「首字等太久」,让用户一直看到进展
  • fetch + response.body.getReader() 读流,同一个 TextDecoder 带 { stream: true } 解码
  • 一次 read() 不等于一条消息:要自己留 buffer,按 \n 或 SSE 的空行拆帧,处理半包和粘包
  • 协议里要能区分增量文本、错误、结束;用户停止用 AbortController,后端也要跟着停
  • 渲染别一个 token 刷一次,合并到每帧一次,Markdown 解析和代码高亮要节流

前端做流式输出,就是边读响应流边拼字,但要自己处理「一块数据不等于一条消息」这件事。 打个比方,后端是一条一条发快递,网络中途可能把两个包捆一起送,也可能把一个包拆成两半送。所以我会用 getReader() 一直读,同一个 TextDecoder 解码,防止中文字被切成两半变乱码,再在 buffer 里按换行拆出完整的 JSON。页面上把增量攒一下,每帧刷一次,不然回答一长就卡。另外取消、断网、服务端报错要分开提示,别只留半截文字。

const reader = res.body.getReader()
const decoder = new TextDecoder() // 只建一个,跨块保留半个字符
let buffer = ''
while (true) {
  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()) onEvent(JSON.parse(line))
  if (done) break
}
时序图 · 4 个参与者 / 12 步
loop 每生成一段文本alt 正常结束用户点击停止用户用户前端页面前端页面后端接口后端接口大模型大模型输入问题并发送1fetch 发起请求,带上取消信号2以流式模式调用模型3返回一段增量4按行写出一个事件5解码并拼进缓冲区,拆出完整行6每帧合并刷新一次界面7生成完毕8发送 done 事件并关闭流9点击停止10abort 断开连接11监听到断开,取消模型调用12

假设服务端返回按行分隔的 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
公众号
开发者导航
切换夜间模式
折叠侧边栏
收起全部