# 第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
}
假设服务端返回按行分隔的 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
}