# 第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
}