前端接 LLM 流式输出:EventSource、axios、fetch 怎么选

0 阅读5分钟

最近给业务接了一版大模型「打字机」输出。页面要边收边渲染 Markdown,请求得带 Token,body 还是 POST。

项目里平时接口都走 axios。第一反应是:SSE 不就是 EventSource 吗?搜了一圈 demo,再对一下我们的接口约束,发现这俩基本对不上。最后还是用 fetch + ReadableStream 自己啃了一层。

把选型过程记一下,免得下次又纠结。

先看接口长什么样

我们这边对话接口大致是:

  • 方法:POST
  • Header:Authorization: Bearer xxx
  • Body:业务参数(prompt、上下文之类)
  • 响应:多数时候 text/event-stream,偶发 NDJSON,极端情况还有直接吐文本块的
  • 帧内容偏 OpenAI 风格:choices[0].delta.content

UI 侧只要两件事:来一段就刷一段;用户点停止立刻断掉。

为什么没用 EventSource

EventSource 写法很干净:

const es = new EventSource('/api/stream')
es.onmessage = (e) => {
  console.log(e.data)
}

但它有几个硬限制:

  1. 基本只支持 GET
  2. 自定义 Header 基本没戏(Bearer 挂不上)
  3. 没有 request body
  4. 断线会自动重连——对话场景里,一次请求结束就结束了,自动重连反而容易把状态搞乱

如果后端肯改成「先 POST 拿个 ticket,再 GET 拉流」,EventSource 也能用。我们不想为了前端 API 再加一轮协议,就没走这条路。

所以:不是 EventSource 差,是它适合「公开的、GET 的推送」,不适合「鉴权 POST 的对话流」。

为什么没继续死磕 axios

axios 在项目里已经很成熟了:拦截器、错误码、刷新 Token 都齐。

但浏览器端做流式时,axios 的心智还是「等响应差不多齐了再处理」。你当然可以想办法拿底层能力,但:

  • 按行拆 SSE、处理半包、解析 data:,这些 axios 都不会帮你做
  • 和现有拦截器(默认当 JSON、统一 toast)很容易打架

我们最后的分工很简单:

  • 普通接口继续 axios
  • 对话流单独走 fetch

没必要为了「全项目只有一种请求库」硬拧在一起。

最终方案:fetch + ReadableStream

fetch 能覆盖我们卡住的点:POST、Header、AbortSignalresponse.body.getReader()

剩下的麻烦集中在三块:

  1. TCP 一次给你的不一定是完整一行(半包)
  2. 要识别 data:、跳过 : 注释行、碰到 [DONE] 收手
  3. 帧结构不统一,得抽一层 parser

核心逻辑大概是这样(路径和字段做过简化):

type FrameParser = (value: unknown) => string | null

function defaultFrameParser(value: unknown): string | null {
  if (value == null) return null
  if (typeof value === 'string') {
    const t = value.trim()
    return t === '' || t === '[DONE]' ? null : value
  }
  if (typeof value !== 'object') return null

  const o = value as Record<string, unknown>

  // 有的网关 HTTP 200,业务错误塞在 body.code 里
  if (typeof o.code === 'number' && o.code !== 0 && o.code !== 200) {
    throw new Error(typeof o.message === 'string' ? o.message : '请求失败')
  }

  const choices = o.choices
  if (Array.isArray(choices) && choices[0] && typeof choices[0] === 'object') {
    const delta = (choices[0] as any).delta
    if (delta && typeof delta.content === 'string' && delta.content) {
      return delta.content
    }
  }

  for (const k of ['content', 'text', 'delta', 'result', 'output'] as const) {
    const v = o[k]
    if (typeof v === 'string' && v) return v
  }

  if (o.data && typeof o.data === 'object') {
    return defaultFrameParser(o.data)
  }
  return null
}

function processDataLine(
  payload: string,
  parseFrame: FrameParser,
  append: (s: string) => void
) {
  const data = payload.trim()
  if (!data || data === '[DONE]') return

  try {
    const chunk = parseFrame(JSON.parse(data))
    if (chunk) append(chunk)
  } catch {
    // 不是 JSON 就当纯文本增量
    append(data)
  }
}

export async function streamTextChatRequest(options: {
  url: string
  body?: unknown
  token?: string
  signal?: AbortSignal
  timeoutMs?: number
  onDelta: (delta: string, full: string) => void
}): Promise<string> {
  const {
    url,
    body,
    token,
    signal: outerSignal,
    timeoutMs = 300_000,
    onDelta
  } = options

  const controller = new AbortController()
  const onOuterAbort = () => controller.abort()
  if (outerSignal) {
    if (outerSignal.aborted) controller.abort()
    else outerSignal.addEventListener('abort', onOuterAbort)
  }
  const timer = window.setTimeout(() => controller.abort(), timeoutMs)

  const headers: Record<string, string> = {
    Accept: 'text/event-stream, application/json, text/plain, */*',
    'Content-Type': 'application/json'
  }
  if (token) headers.Authorization = `Bearer ${token}`

  let full = ''
  const append = (s: string) => {
    if (!s) return
    full += s
    onDelta(s, full)
  }

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers,
      body: body == null ? undefined : JSON.stringify(body),
      signal: controller.signal
    })

    if (!response.ok) {
      throw new Error((await response.text()) || `HTTP ${response.status}`)
    }

    const ct = response.headers.get('content-type') ?? ''
    const reader = response.body?.getReader()
    if (!reader) {
      const t = await response.text()
      if (t.trim()) append(t)
      return full
    }

    const decoder = new TextDecoder()
    let lineCarry = ''
    const useSse =
      ct.includes('text/event-stream') || ct.includes('application/x-ndjson')

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

      const chunk = decoder.decode(value, { stream: true })
      if (!useSse) {
        if (chunk) append(chunk)
        continue
      }

      // 半包:上一截尾巴和下一段拼起来再按行切
      lineCarry += chunk
      const lines = lineCarry.split('\n')
      lineCarry = lines.pop() ?? ''

      for (const line of lines) {
        const trimmed = line.replace(/\r$/, '').trim()
        if (!trimmed || trimmed.startsWith(':')) continue
        if (trimmed.startsWith('data:')) {
          processDataLine(trimmed.slice(5).trimStart(), defaultFrameParser, append)
        } else if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
          // 兼容一行一个 JSON 的 NDJSON
          processDataLine(trimmed, defaultFrameParser, append)
        }
      }
    }

    if (lineCarry && useSse) {
      const trimmed = lineCarry.replace(/\r$/, '').trim()
      if (trimmed.startsWith('data:')) {
        processDataLine(trimmed.slice(5).trimStart(), defaultFrameParser, append)
      } else if (trimmed) {
        append(trimmed)
      }
    }

    return full
  } finally {
    window.clearTimeout(timer)
    outerSignal?.removeEventListener('abort', onOuterAbort)
  }
}

页面里用法也很直白:

const ac = new AbortController()

await streamTextChatRequest({
  url: '/api/ai/chat',
  token: getToken(),
  body: { prompt },
  signal: ac.signal,
  onDelta: (_delta, full) => {
    setMarkdown(full)
  }
})

// 停止生成
ac.abort()

流式过程中用 Markdown 组件渲染;结束后再切成可编辑文本。传输层只负责吐字,展示层自己决定怎么画。

线上踩过的几个坑

1. 半包

一行 JSON 经常被拆成两个 chunk。一开始直接 chunk.split('\n'),偶发解析失败、字丢了。加了 lineCarry 之后稳定很多。

2. 心跳行

SSE 里会有以 : 开头的注释/心跳,当正文拼进去会出奇怪字符,记得跳过。

3. HTTP 200 但业务失败

有的网关不报 4xx,错误写在 JSON 的 code 里。parser 里不处理的话,UI 会一直空转,最后才发现其实早就失败了。

4. Content-Type 不老实

同一条业务链路,有时是 text/event-stream,有时是 NDJSON,还有直接吐文本。所以用 Content-Type 分流:能按行解析就按行,否则当普通文本追加。

5. 要不要每帧都 setState

我们目前是每来一段就 setMarkdown(full)。对话体量下没感觉到卡;如果后面接推理模型狂喷 token,再考虑 rAF 合并也行,没必要一上来就优化。

有没有想过用 fetch-event-source

想过。@microsoft/fetch-event-source 把 SSE 协议层包得比较完整,POST、Header、重连策略都省事。

我们没引,主要是:

  • 对话流要的是「一次请求读完」,不需要它那套重连
  • 网关格式不止标准 SSE,自己控 parser 更直接
  • 项目里这类能力希望和现有鉴权、错误处理、超时取消放在同一层

如果你们后端 SSE 很标准、又希望少写解析代码,用现成库完全合理。我们是被兼容性推着走到手写这条路的。

现在怎么选

结合这次接入,我自己的判断是:

  • GET、无自定义鉴权、要自动重连 → EventSource 够用
  • 普通 JSON 业务接口 → 继续 axios
  • POST + Header + 边收边解析 + 可取消fetch + ReadableStream(或 fetch-event-source)

LLM 对话流,绝大多数时候落在第三种。

具体用原生 fetch 还是再包一层库,看团队对依赖和可控性的取舍就行;选型本身不复杂,别被「SSE 就等于 EventSource」带偏就够了。