从 0 实现 ChatGPT 风格的流式对话 UI

流式对话 UI 表面上是「后端推 chunk、前端拼进 state」,实际上线后通常会碰到这几类问题:Markdown 边输出边闪、每个 chunk 触发重渲染导致掉帧、自动滚动打断用户翻历史、点了停止但 loading 没收干净。下面按实现顺序拆开讲。

传输层:SSE + fetch

写 UI 之前,先搞清楚数据怎么从服务端流到浏览器。常见三种:

方案 方向 自动重连 协议 适用场景
SSE 服务端 → 客户端 浏览器自带 HTTP LLM 流式输出,首选
WebSocket 双向 需自己实现 ws/wss 协作编辑、实时对战
Fetch + ReadableStream 服务端 → 客户端 需自己实现 HTTP POST + 自定义 Header 的流式

LLM 对话是单向的:问一次,答一长串,生成过程中客户端不需要往回发数据。SSE 语义匹配,走标准 HTTP,鉴权、网关、CDN 都能复用。OpenAI、Claude 官方接口也是这个路子。

格式简单:Content-Type: text/event-stream,每条事件 data: {...}\n\n

注意:原生 `EventSource` 只支持 GET,不能自定义请求头。调 LLM 要 POST messages 并带 `Authorization`,所以实际项目里几乎都用 `fetch` 读 body,自己按 SSE 格式解析,而不是 `EventSource`。

`streamChat()`:async generator 封装

response.body.getReader() 逐块读,按 \n\n 切事件,从 data: 提取 delta,yield 出去:

TypeScript 复制代码
// streamChat.ts
export interface StreamChatOptions {
  messages: { role: 'user' | 'assistant' | 'system'; content: string }[]
  signal?: AbortSignal // 用于中断
}

export async function* streamChat({ messages, signal }: StreamChatOptions) {
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages, stream: true }),
    signal,
  })

  if (!res.ok || !res.body) {
    throw new Error(`请求失败: ${res.status}`)
  }

  const reader = res.body.getReader()
  const decoder = new TextDecoder('utf-8')
  let buffer = ''

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    // chunk 可能在任意字节处被切断,必须 buffer 拼接
    buffer += decoder.decode(value, { stream: true })

    // SSE 以 \n\n 分隔每条事件
    const lines = buffer.split('\n\n')
    buffer = lines.pop() ?? '' // 最后一段可能不完整,留到下次

    for (const block of lines) {
      const line = block.trim()
      if (!line.startsWith('data:')) continue

      const data = line.slice(5).trim()
      if (data === '[DONE]') return // OpenAI 风格的结束标记

      try {
        const json = JSON.parse(data)
        const delta = json.choices?.[0]?.delta?.content
        if (delta) yield delta as string
      } catch {
        // 半条 JSON,忽略;正常不会走到这,因为按 \n\n 切过了
      }
    }
  }
}

消费:

TypeScript 复制代码
for await (const chunk of streamChat({ messages, signal })) {
  console.log(chunk)
}

解析层最容易踩坑的地方:`reader.read()` 返回的字节块不保证落在字符边界,也不保证落在 SSE 事件边界。汉字 UTF-8 占三个字节,可能被切成两半;一条 `data:` 也可能拦腰截断。必须同时做两件事:

  1. decoder.decode(value, { stream: true }) ------ 让 decoder 缓存不完整的多字节字符。

  2. buffer 累积,按 \n\n 切分,末尾不完整的一段留到下一轮。

缺任何一个,中文乱码或 JSON.parse 失败会随机出现,慢网下更容易复现。

打字机:收流和显示要解耦

流接好后,常见写法是每个 chunk 直接追加到 state:

TypeScript 复制代码
for await (const chunk of streamChat({ messages })) {
  setText(prev => prev + chunk)
}

问题在于 LLM 吐 token 节奏不均匀------有时长时间无输出,有时一口气推半段。直接拼接会出现「顿一下、蹦一大段」,和 ChatGPT 的匀速感差很远;chunk 密集时高频 setState 还会拖慢 reconcile,Markdown 重渲染时更明显。

逐 token 显示(来一个显示一个)实现最简单,但粒度不均,视觉上忽快忽慢。更稳的做法是把接收和显示拆开:**网络层往缓冲区写,显示层用 `requestAnimationFrame` 匀速吐字**

TypeScript 复制代码
// typewriter.ts ------ 框架无关,React/Vue 共用
export function createTypewriter(onUpdate: (text: string) => void) {
  let displayed = ''
  let pending = ''
  let rafId: number | null = null

  // 手感旋钮:中文 2~3,英文 4~6
  const CHARS_PER_FRAME = 2

  function tick() {
    if (pending.length > 0) {
      const take = Math.min(CHARS_PER_FRAME, pending.length)
      displayed += pending.slice(0, take)
      pending = pending.slice(take)
      onUpdate(displayed)
    }
    if (pending.length > 0) {
      rafId = requestAnimationFrame(tick)
    } else {
      rafId = null
    }
  }

  return {
    push(chunk: string) {
      pending += chunk
      if (rafId === null) rafId = requestAnimationFrame(tick)
    },
    // 流结束时调用,避免网络已结束 UI 还在逐字蹦
    flush() {
      displayed += pending
      pending = ''
      onUpdate(displayed)
      if (rafId !== null) {
        cancelAnimationFrame(rafId)
        rafId = null
      }
    },
    get text() {
      return displayed
    },
  }
}

React / Vue 接入

React:

TypeScript 复制代码
import { useState, useRef, useCallback } from 'react'
import { streamChat } from './streamChat'
import { createTypewriter } from './typewriter'

export function useStreamingChat() {
  const [text, setText] = useState('')
  const [loading, setLoading] = useState(false)
  const abortRef = useRef<AbortController | null>(null)

  const send = useCallback(async (messages) => {
    setText('')
    setLoading(true)
    const controller = new AbortController()
    abortRef.current = controller
    const typer = createTypewriter(setText)

    try {
      for await (const chunk of streamChat({ messages, signal: controller.signal })) {
        typer.push(chunk)
      }
      typer.flush()
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        setText(t => t + '\n\n⚠️ 出错了,请重试')
      }
    } finally {
      setLoading(false)
      abortRef.current = null
    }
  }, [])

  const stop = useCallback(() => abortRef.current?.abort(), [])

  return { text, loading, send, stop }
}

Vue 只是把 setText 换成 text.value =

TypeScript 复制代码
// useStreamingChat.ts
import { ref } from 'vue'
import { streamChat } from './streamChat'
import { createTypewriter } from './typewriter'

export function useStreamingChat() {
  const text = ref('')
  const loading = ref(false)
  let controller: AbortController | null = null

  async function send(messages) {
    text.value = ''
    loading.value = true
    controller = new AbortController()
    const typer = createTypewriter(t => (text.value = t))

    try {
      for await (const chunk of streamChat({ messages, signal: controller.signal })) {
        typer.push(chunk)
      }
      typer.flush()
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        text.value += '\n\n⚠️ 出错了,请重试'
      }
    } finally {
      loading.value = false
      controller = null
    }
  }

  function stop() {
    controller?.abort()
  }

  return { text, loading, send, stop }
}

typewriter.ts 与框架无关,React、Vue 共用同一份。

流式 Markdown:未闭合语法导致闪烁

回答里一旦带 Markdown(代码块、列表、表格),边输出边解析就会出问题。比如模型正在输出:

Plain 复制代码
这是一段代码:
```js
const a = 1

结尾的 ` ``` ` 还没到时,解析器面对的是未闭合的代码块。不同库行为不一致:有的把后续内容全当 code,有的当普通文本------每来一个 chunk,DOM 结构就变一轮,屏幕闪烁。列表(`-`)、表格(`|`)在半截状态同样麻烦。

两种方案

**方案 A:`react-markdown` + 节流 + 补全语法。** 接入成本最低,适合大多数项目。Markdown 不必每个 rAF 帧都 parse,节流约 60ms;渲染前对文本做闭合补全。

TypeScript 复制代码
import ReactMarkdown from 'react-markdown'
import { useMemo } from 'react'

// text 先 throttle(useThrottle / lodash.throttle)
function MarkdownMessage({ text }: { text: string }) {
  const safe = useMemo(() => completeUnclosed(text), [text])
  return <ReactMarkdown>{safe}</ReactMarkdown>
}

方案 B:增量解析(如 `streaming-markdown`)或只 parse 变化尾部的自研方案。长代码块场景 CPU 占用更低,但接入成本高,对话量特别大、流畅度要求极高时再考虑。

`completeUnclosed`

渲染前检查围栏和行内反引号,奇数个就临时补闭合符:

TypeScript 复制代码
export function completeUnclosed(text: string): string {
  let result = text

  const fenceCount = (result.match(/```/g) || []).length
  if (fenceCount % 2 === 1) {
    result += '\n```'
  }

  const inlineTicks = (result.replace(/```/g, '').match(/`/g) || []).length
  if (inlineTicks % 2 === 1) {
    result += '`'
  }

  return result
}

代码块输出到一半时,解析器看到的是合法闭合块,布局稳定;真正的 ````` 到达后,临时补的全会被正常替换,用户无感知。

代码高亮:Shiki vs Prism

Shiki 质量高,但是整块 re-highlight。流式过程中每帧对增长中的代码块跑 Shiki,主线程开销很大。更合理的策略:流式期间用纯文本或轻量样式,块闭合后再 Shiki 一次性高亮。Prism 更轻,适合需要实时高亮的场景,画质略逊于 Shiki。

交互细节

中断:`AbortController`

hook 里传入 signalabort() 后 fetch 抛 AbortError,应静默处理,不当成业务错误。finally 里复位 loading、清除 controller。已生成内容保留,允许继续追问。

TypeScript 复制代码
function StopButton({ loading, onStop }) {
  if (!loading) return null
  return <button onClick={onStop}>■ 停止生成</button>
}

跟随滚动

text 一变就 scrollTop = scrollHeight,用户往上翻历史时会被新 chunk 拽回底部。改法:维护 stick 标志,只有用户距底部 40px 以内才自动跟随;主动上滑则停,滑回底部再恢复。

TypeScript 复制代码
function useAutoScroll(deps: unknown[]) {
  const ref = useRef<HTMLDivElement>(null)
  const stick = useRef(true)

  function onScroll() {
    const el = ref.current
    if (!el) return
    const distance = el.scrollHeight - el.scrollTop - el.clientHeight
    stick.current = distance < 40
  }

  useEffect(() => {
    const el = ref.current
    if (el && stick.current) {
      el.scrollTop = el.scrollHeight
    }
  }, deps)

  return { ref, onScroll }
}

refonScroll 绑到滚动容器,deps[text]

超时、重试、空响应

  • 超时:`AbortSignal.timeout(60_000)` 或定时器 abort,避免一直停在「思考中」。

  • 重试:网络瞬断可对整次请求退避重试。LLM 流式没有断点续传,重试等于重发请求。

  • 空响应:流正常结束但 `text` 为空,显示「未生成内容,请重试」。

TypeScript 复制代码
async function withTimeout<T>(p: Promise<T>, ms: number, onTimeout: () => void) {
  const timer = setTimeout(onTimeout, ms)
  try {
    return await p
  } finally {
    clearTimeout(timer)
  }
}

Loading 三态

  • 首个 chunk 未到:骨架或跳动省略号。

  • 正在吐字:光标 ,发送按钮换成停止。

  • 出错:气泡内错误信息 + 重试。

TypeScript 复制代码
{loading && text === '' && <ThinkingDots />}
{text && <MarkdownMessage text={text} />}
{loading && text && <span className="cursor">▋</span>}

性能与边界

虚拟列表不必过早引入。聊天消息高度不固定(代码块、图片、表格),高度测量成本高。消息数到几百条、Performance 明显吃紧时再考虑 react-window / @tanstack/virtual / vue-virtual-scroller

几个低成本优化:

  • 消息 key 用稳定 id,不用数组下标。

  • 历史消息 React.memo / v-memo,只让正在生成的那条高频更新。

  • Markdown parse + 高亮配合 ~60ms 节流,与吐字引擎对齐。

移动端:键盘弹起时监听 visualViewport.resize 调整底部 padding;textarea 限高内部滚动。

TypeScript 复制代码
function autoGrow(el: HTMLTextAreaElement, maxPx = 160) {
  el.style.height = 'auto'
  el.style.height = Math.min(el.scrollHeight, maxPx) + 'px'
}

小结

流式 UI 的难点不在「接住流」------fetch + getReader 几十行能搞定。难在边流边渲染:token 节奏与网络解耦、Markdown 半截语法补全、重型高亮延后、滚动让位给用户操作、停止与错误状态区分清楚。排查问题时,优先看 buffer 解码和 Markdown 闭合这两处。

**项目地址: **https://github\\.com/Mr\\-liu6/streaming\\-chat

相关推荐
EAI-Robotics2 小时前
机器人操作鲁棒性:当机械手遇上真实世界的不确定性
人工智能·算法·机器人
搭贝2 小时前
低代码平台私有化部署架构与信创全栈适配实战
低代码·架构·rxjava
学渣超3 小时前
记一次分布式事务数据不一致的排查之旅:从超时到索引,层层剥茧
java·后端·架构
小短腿的代码世界3 小时前
Qt Bluetooth源码深度解析:从HCI协议到跨平台API的完整架构
开发语言·qt·架构
Tairitsu_H3 小时前
[LC优选算法#17] 链表 | 合并 K 个升序链表 | K个⼀组翻转链表
数据结构·算法·链表
_olone3 小时前
Luogu P2704 [NOI2001] 炮兵阵地
c++·算法·状压dp
kebidaixu4 小时前
两轮BMS 防打火策略详解
算法
小短腿的代码世界4 小时前
Qt WebEngine多进程架构与IPC通信:从Chromium多进程到Qt信号槽融合
qt·架构·系统架构
谷雪_6584 小时前
Linux 网络命名空间:从内核原理到企业级容器网络架构全景实战
linux·网络·架构