AI Chat实现第一步,流式输出,教你如何实现打字流

0 阅读6分钟

打字.gif

前端如何实现 Chat 流式输出:从 0 到可上线

在 AI 对话场景里,用户最敏感的不是“总耗时”,而是“多久看到第一个字”。
流式输出(Streaming)最大的价值,就是让回复边生成边展示,显著提升体感速度。

这篇文章结合一个 Vue + Pinia 项目的真实代码,讲清楚前端如何实现稳定的流式聊天输出。


1. 先明确目标:我们到底要实现什么

一个可用的流式 Chat 前端,至少要满足下面 5 点:

  1. 请求发出后,能持续接收后端增量数据(不是一次性返回全文)。
  2. 每收到一个 chunk,就把它追加到页面上。
  3. 支持结束事件(done)和错误事件(error)。
  4. 支持会话 ID 回传(新会话 -> 后端创建 -> 前端接管)。
  5. 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”。建议至少覆盖这些分支:

  1. res.ok === false:HTTP 失败直接走错误提示。
  2. SSE 数据里带 error 字段:显示业务错误并结束流。
  3. done 到达时补齐元数据:conversation_idmessage_idtokens_used(后续的对话引用历史内容要用到)。
  4. 最后一行没有换行符:在 done 后补解析 buffer
  5. 用户主动停止(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 个坑

  1. 只判断 res.ok,没处理 SSE 里的 error 字段。
  2. 直接 parse chunk,导致随机 JSON 报错。
  3. 忘了处理 done 时 buffer 尾包,最后一句话丢失。
  4. 新会话没回写 conversation_id,下一轮上下文断裂。
  5. 高频更新直接改对象字段,某些情况下视图刷新不连贯。
  6. 生成中切会话,旧流写进新会话列表,状态串台。
  7. 没做取消逻辑,用户点“停止”后 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
}