前端如何实现 Chat 流式输出:从 0 到可上线
在 AI 对话场景里,用户最敏感的不是“总耗时”,而是“多久看到第一个字”。
流式输出(Streaming)最大的价值,就是让回复边生成边展示,显著提升体感速度。
这篇文章结合一个 Vue + Pinia 项目的真实代码,讲清楚前端如何实现稳定的流式聊天输出。
1. 先明确目标:我们到底要实现什么
一个可用的流式 Chat 前端,至少要满足下面 5 点:
- 请求发出后,能持续接收后端增量数据(不是一次性返回全文)。
- 每收到一个 chunk,就把它追加到页面上。
- 支持结束事件(done)和错误事件(error)。
- 支持会话 ID 回传(新会话 -> 后端创建 -> 前端接管)。
- UI 不“卡帧”,用户确实能看到“逐段输出”。
2. 协议约定:前后端先说好 SSE 格式
这套实现走的是 text/event-stream,后端按行推送:
data: {"content": "你"}
data: {"content": "好"}
data: {"done": true, "conversation_id": "conv_abc123", "message_id": "msg_001"}
前端只需要抓住一个核心:逐行解析 data: ,拿到 JSON 后按字段分发逻辑。
3. API 层:用 fetch + ReadableStream 读取流
下面是核心实现思路:
export async function streamChat(message, history = [], { conversationId, onChunk, onDone, onError, signal } = {}) {
const res = await fetch("/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, history, conversation_id: conversationId || null }),
signal,
})
if (!res.ok) throw new Error(res.statusText || "请求失败")
const reader = res.body.getReader()
const decoder = new TextDecoder("utf-8")
let buffer = ""
let fullText = ""
while (true) {
const { done, value } = await reader.read()
if (value) buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const raw = line.slice(6).trim()
if (!raw) continue
const data = JSON.parse(raw)
if (data.error) {
onError?.(data.error)
return fullText
}
if (data.content !== undefined) {
fullText += data.content
onChunk?.(data.content, fullText)
}
if (data.done) {
onDone?.(fullText, data)
return fullText
}
}
if (done) break
}
onDone?.(fullText, {})
return fullText
}
这段代码的 4 个关键点
ReadableStream + getReader():让你拿到“增量字节流”,而不是等待完整响应。TextDecoder(..., { stream: true }):避免中文被拆包时出现乱码。buffer + split("\n"):处理半包/粘包,保证每次只按完整行解析。fullText累积:UI 展示时直接用完整文本,避免拼接顺序错乱。
4. Store 层:如何把 chunk 丝滑渲染到页面
很多项目“看起来是流式”,但其实 UI 一次性更新。问题常出在响应式更新策略。
const assistantIdx = messages.value.length - 1
await streamChat(content, history, {
conversationId: activeConversationId.value,
onChunk: (chunk, fullText) => {
const msg = messages.value[assistantIdx]
if (msg) {
// 用“替换对象”强制触发响应式,而不是只改 msg.content
messages.value[assistantIdx] = { ...msg, content: fullText }
}
},
onDone: (fullText, metadata = {}) => {
streaming.value = false
const msg = messages.value[assistantIdx]
if (msg) {
messages.value[assistantIdx] = { ...msg, streaming: false, content: fullText }
}
// 新会话场景:接住后端返回的 conversation_id
if (!activeConversationId.value && metadata.conversation_id) {
activeConversationId.value = metadata.conversation_id
}
}
})
为什么要“替换对象”而不是直接赋值字段
在高频 chunk 更新下,某些场景里直接 msg.content = fullText 可能让视图更新不稳定。
通过 messages[idx] = { ...msg, content: fullText },可以更稳定地触发 Vue 响应式刷新。
5. 体验优化:让“流式”真的看起来在流
项目里还有一个很关键的细节:每次 chunk 回调后,把主线程还给浏览器一帧。(实现打字机动画效果最关键的地方)
const yieldToUI = () => new Promise((r) => requestAnimationFrame(r))
if (data.content !== undefined) {
fullText += data.content
onChunk?.(data.content, fullText)
await yieldToUI()
}
这个技巧可以缓解“后端虽然分片返回,但前端因为连续计算导致批量渲染”的问题,肉眼观感会更像真实打字流。
6. 错误处理与边界条件
流式场景最容易忽略的是“非 happy path”。建议至少覆盖这些分支:
res.ok === false:HTTP 失败直接走错误提示。- SSE 数据里带
error字段:显示业务错误并结束流。 done到达时补齐元数据:conversation_id、message_id、tokens_used(后续的对话引用历史内容要用到)。- 最后一行没有换行符:在
done后补解析buffer。 - 用户主动停止(AbortController):中断请求并更新 UI 状态。
7. 一份可复用的最小实现模板
如果你要在新项目快速落地,可以按这个结构拆分:
src/api/streamChat.js:只管网络流读取和 SSE 解析。src/stores/chat.js:只管消息状态、占位消息、逐段更新、结束落盘。ChatInput.vue:发消息/停止生成按钮。ChatMessageList.vue:渲染消息列表和streaming状态。
你会得到一个职责清晰、可维护的流式聊天前端架构。
8. 常见坑位清单(实战版)
- 只判断了 HTTP 成功,没处理 SSE 内的业务错误。
- 按 chunk 直接渲染,没做 buffer 行解析,导致 JSON 解析随机失败。
- 使用了流式接口,但没有逐帧让出 UI,用户仍看到“整段跳出”。
- 新会话拿到
conversation_id后没回写,下一轮请求上下文断裂。 - 切换会话时历史消息和当前流混在一起,状态污染。
为什么很多人“写了流式却看不到流式”
1) 没做 buffer,直接 JSON.parse(chunk)
这几乎必炸。
因为网络层会拆包/粘包,chunk 和一条完整 data: 事件不是一一对应关系。
2) 没处理最后尾包
split("\n") 后最后一段会留在 buffer,如果末尾没有换行,最后一条事件会丢。
上面代码里 if (done && buffer.trim()) 就是专门兜这个坑。
3) UI 更新策略不对
后端在推,但前端不让主线程喘气,就会“看起来一次性渲染”。
requestAnimationFrame 让出一帧,观感差距非常明显。
4) 我在项目里踩过的 7 个坑
- 只判断
res.ok,没处理 SSE 里的error字段。 - 直接 parse chunk,导致随机 JSON 报错。
- 忘了处理 done 时 buffer 尾包,最后一句话丢失。
- 新会话没回写
conversation_id,下一轮上下文断裂。 - 高频更新直接改对象字段,某些情况下视图刷新不连贯。
- 生成中切会话,旧流写进新会话列表,状态串台。
- 没做取消逻辑,用户点“停止”后 UI 停了但请求还在跑。
9. 总结
前端实现流式输出的核心,不是“用了 SSE”这么简单,而是:
- 读得对(ReadableStream + 行级解析)
- 渲得稳(响应式策略 + 逐帧更新)
- 状态闭环(占位、完成、异常、会话关联)
把这三件事做好,你的 Chat 页面就不只是“能用”,而是“体验接近成熟产品”。 完整代码:
export async function streamChat(message, history = [], { conversationId, onChunk, onDone, onError, signal } = {}) {
const res = await fetch("/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message,
history,
conversation_id: conversationId || null,
}),
signal,
})
if (!res.ok) {
const errMsg = res.statusText || "请求失败"
onError?.(errMsg)
throw new Error(errMsg)
}
const reader = res.body.getReader()
const decoder = new TextDecoder("utf-8")
let buffer = ""
let fullText = ""
const parseSSELine = (line) => {
if (!line.startsWith("data: ")) return {}
const raw = line.slice(6).trim()
if (!raw) return {}
if (raw === "[DONE]") {
onDone?.(fullText, {})
return { shouldReturn: true }
}
try {
const data = JSON.parse(raw)
if (data.error) {
onError?.(data.error)
return { shouldReturn: true }
}
if (data.content !== undefined) {
fullText += data.content
onChunk?.(data.content, fullText)
return { needsYield: true }
}
if (data.done) {
onDone?.(fullText, {
conversation_id: data.conversation_id || null,
message_id: data.message_id || null,
tokens_used: data.tokens_used || null,
})
return { shouldReturn: true }
}
return {}
} catch {
return {}
}
}
while (true) {
const { done, value } = await reader.read()
if (value) buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
const result = parseSSELine(line)
if (result.shouldReturn) return fullText
if (result.needsYield) await new Promise((r) => requestAnimationFrame(r))
}
if (done && buffer.trim()) {
const result = parseSSELine(buffer)
if (result.shouldReturn) return fullText
buffer = ""
}
if (done) break
}
onDone?.(fullText, {})
return fullText
}