前端如何优雅地处理多模型 SSE 流式响应

一套代码接入 40+ AI 模型,统一处理不同厂商的 SSE 流式输出格式。


为什么需要统一 SSE 处理?

调用过多个 AI 模型 API 的前端开发者都会遇到一个头疼的问题:各家厂商的 SSE(Server-Sent Events)返回格式不一样。

同样是流式对话,不同厂商的数据块长得完全不同:

OpenAI 格式(ChatGPT / DeepSeek):

json 复制代码
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"你好"}}]}

Anthropic 格式(Claude):

复制代码
event: message_start
data: {"type":"message_start","message":{"content":[]}}

event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"你好"}}

通义千问格式:

json 复制代码
data: {"output":{"choices":[{"message":{"content":"你好"}}]}}

三种格式,三种字段路径。如果你的应用需要同时支持多个模型,按传统方式每个模型写一套解析逻辑,代码会迅速膨胀成灾难。

更棘手的是,SSE 协议本身还有一些坑:

  • TCP 分片 :一个完整的 data: {...}\n\n 可能被 TCP 拆成两次传输,前端收到的 chunk 可能是一个 JSON 的一半
  • 多行 data :部分厂商的一条消息中有多个 data:
  • 空行和注释\n: heartbeat\n 需要过滤

架构设计:统一的 SSE 管道

核心思路:将不同厂商的 SSE 响应,通过适配器模式统一转换为前端组件可消费的标准流。

复制代码
┌──────────────────────────────────────────────────┐
│                   Vue 组件                        │
│         streamingText, isStreaming, error         │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│              统一流式响应处理器                     │
│    文本累积 / 错误处理 / 中断控制 / 重连机制        │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│                 SSE 解析器                         │
│    字节流 → 行缓冲 → 事件解析 → 标准事件对象        │
└────────────────────┬─────────────────────────────┘
                     │
        ┌────────────┼────────────┐
        ▼            ▼            ▼
   OpenAI适配器  Anthropic适配器  通义千问适配器
   (choices[0]   (content_block  (output.choices
    .delta        _delta.text)   [0].message
    .content)                    .content)

第一步:底层 SSE 解析器

先从最底层的字节流解析开始。fetch API 的 ReadableStream 是处理 SSE 的最佳选择,相比 EventSource 它可以:

  • 支持 POST 请求(携带请求体)
  • 自定义请求头(Authorization 等)
  • 手动控制取消和超时
typescript 复制代码
/**
 * SSE 事件解析器:将原始字节流解析为标准事件对象
 * 处理 TCP 分片、多行 data、注释行等边界情况
 */
class SSEParser {
  private buffer = ''
  private decoder = new TextDecoder()

  /** 从 ReadableStream 读取并逐行解析,返回异步迭代器 */
  async *parse(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
    const reader = body.getReader()

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

        if (done) {
          // 流结束,处理缓冲区剩余内容
          if (this.buffer.trim()) {
            const event = this.parseBuffer()
            if (event) yield event
          }
          break
        }

        // 解码并追加到行缓冲区
        this.buffer += this.decoder.decode(value, { stream: true })

        // 按行分割处理
        yield* this.processLines()
      }
    } finally {
      reader.releaseLock()
    }
  }

  /** 处理缓冲区中的完整行 */
  private *processLines(): Generator<SSEEvent> {
    while (true) {
      const idx = this.buffer.indexOf('\n')
      if (idx === -1) break // 没有完整行,等待更多数据

      const line = this.buffer.slice(0, idx).trimEnd()
      this.buffer = this.buffer.slice(idx + 1)

      // 跳过空行和注释
      if (!line || line.startsWith(':')) continue

      // 解析 "field: value" 格式
      const colonIdx = line.indexOf(':')
      if (colonIdx === -1) continue

      const field = line.slice(0, colonIdx)
      let value = line.slice(colonIdx + 1)
      if (value.startsWith(' ')) value = value.slice(1)

      if (field === 'data') {
        // 累积 data 行,直到遇到空行
        let data = value
        while (true) {
          const nextIdx = this.buffer.indexOf('\n')
          if (nextIdx === -1) break

          const nextLine = this.buffer.slice(0, nextIdx).trimEnd()
          if (nextLine === '' || nextLine.startsWith(':')) {
            // 空行或注释,表示事件结束
            this.buffer = this.buffer.slice(nextIdx + 1)
            yield { data }
            break
          }

          this.buffer = this.buffer.slice(nextIdx + 1)
          if (nextLine.startsWith('data:')) {
            data += '\n' + nextLine.slice(5).replace(/^ /, '')
          }
        }
      }
    }
  }

  /** 处理缓冲区中不完整的最终内容 */
  private parseBuffer(): SSEEvent | null {
    const line = this.buffer.trim()
    if (line.startsWith('data:')) {
      return { data: line.slice(5).replace(/^ /, '') }
    }
    return null
  }
}

interface SSEEvent {
  data: string
}

第二步:模型适配器

每种模型只需要一个轻量级适配器,负责从原始 JSON 提取文本内容:

typescript 复制代码
/** 适配器接口:从原始 JSON 提取文本增量 */
interface ModelAdapter {
  /** 从 SSE 事件 data 中提取文本片段,返回 null 表示非内容事件(如心跳) */
  extractDelta(rawData: string): string | null
}

/** OpenAI 兼容格式(DeepSeek、豆包、ChatGLM 等均兼容) */
const openaiAdapter: ModelAdapter = {
  extractDelta(raw: string) {
    try {
      const parsed = JSON.parse(raw)
      return parsed?.choices?.[0]?.delta?.content ?? null
    } catch {
      return null
    }
  }
}

/** Anthropic 格式(Claude 系列) */
const anthropicAdapter: ModelAdapter = {
  extractDelta(raw: string) {
    try {
      const parsed = JSON.parse(raw)
      if (parsed?.type === 'content_block_delta') {
        return parsed?.delta?.text ?? null
      }
      return null
    } catch {
      return null
    }
  }
}

/** 通义千问格式 */
const qwenAdapter: ModelAdapter = {
  extractDelta(raw: string) {
    try {
      const parsed = JSON.parse(raw)
      return parsed?.output?.choices?.[0]?.message?.content ?? null
    } catch {
      return null
    }
  }
}

/** 适配器注册表 */
const adapterRegistry: Record<string, ModelAdapter> = {
  openai: openaiAdapter,
  anthropic: anthropicAdapter,
  qwen: qwenAdapter,
}

第三步:统一流式响应处理器

将解析器和适配器组合,提供上层组件直接使用的 API:

typescript 复制代码
interface StreamOptions {
  url: string
  body: Record<string, any>
  adapter: keyof typeof adapterRegistry
  signal?: AbortSignal
  onToken: (text: string) => void
  onDone: (fullText: string) => void
  onError: (error: Error) => void
}

/**
 * 发起 SSE 流式请求,自动解析并回调文本增量
 * 
 * 使用示例:
 *   streamChat({
 *     url: '/ai/v1/chat/completions',
 *     body: { model: 'deepseek-v3', messages: [...] },
 *     adapter: 'openai',
 *     onToken: (text) => { fullText.value += text },
 *     onDone:  (text) => { loading.value = false },
 *     onError: (err) => { error.value = err.message },
 *   })
 */
async function streamChat(options: StreamOptions) {
  const { url, body, adapter: adapterName, signal, onToken, onDone, onError } = options

  const modelAdapter = adapterRegistry[adapterName]
  if (!modelAdapter) {
    onError(new Error(`未知的模型适配器:${adapterName}`))
    return
  }

  const controller = new AbortController()
  const mergedSignal = signal
    ? combineSignals(signal, controller.signal)
    : controller.signal

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ...body, stream: true }),
      signal: mergedSignal,
      // 携带 HttpOnly Cookie 用于鉴权,防御 XSS 窃取 token
      credentials: 'include',
    })

    if (!response.ok) {
      const errorBody = await response.text().catch(() => '')
      throw new Error(`请求失败 (${response.status}): ${errorBody}`)
    }

    const parser = new SSEParser()
    let fullText = ''

    for await (const event of parser.parse(response.body!)) {
      if (event.data === '[DONE]') break

      const delta = modelAdapter.extractDelta(event.data)
      if (delta !== null) {
        fullText += delta
        onToken(delta)
      }
    }

    onDone(fullText)
  } catch (err: any) {
    if (err.name === 'AbortError') return
    onError(err instanceof Error ? err : new Error(String(err)))
  }
}

/** 合并两个 AbortSignal */
function combineSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
  const controller = new AbortController()
  const onAbort = () => controller.abort()
  a.addEventListener('abort', onAbort)
  b.addEventListener('abort', onAbort)
  if (a.aborted || b.aborted) controller.abort()
  return controller.signal
}

第四步:Vue 3 Composable

封装为 Vue 组合式函数,组件中使用起来极其简洁:

typescript 复制代码
// composables/useAiChat.ts
import { ref } from 'vue'

export function useAiChat() {
  const streamingText = ref('')
  const isStreaming = ref(false)
  const error = ref<string | null>(null)

  let abortController: AbortController | null = null

  async function send(
    model: string,
    adapter: keyof typeof adapterRegistry,
    messages: Array<{ role: string; content: string }>
  ) {
    // 中断上一个请求
    abortController?.abort()
    abortController = new AbortController()

    streamingText.value = ''
    isStreaming.value = true
    error.value = null

    await streamChat({
      url: '/ai/v1/chat/completions',
      body: { model, messages },
      adapter,
      signal: abortController.signal,
      onToken: (text) => {
        streamingText.value += text
      },
      onDone: () => {
        isStreaming.value = false
      },
      onError: (err) => {
        error.value = err.message
        isStreaming.value = false
      },
    })
  }

  function stop() {
    abortController?.abort()
    isStreaming.value = false
  }

  return { streamingText, isStreaming, error, send, stop }
}

组件中使用:

vue 复制代码
<script setup lang="ts">
import { useAiChat } from '@/composables/useAiChat'

const { streamingText, isStreaming, error, send, stop } = useAiChat()

const handleSend = (message: string) => {
  // 切换模型只需改 adapter 参数,无需修改任何解析逻辑
  send('deepseek-v3', 'openai', [{ role: 'user', content: message }])
}
</script>

<template>
  <div class="chat-output" v-text="streamingText" />
  <button v-if="isStreaming" @click="stop">停止生成</button>
  <div v-if="error" class="error">{{ error }}</div>
</template>

工程化要点

1. 错误重试策略

网络抖动导致 SSE 连接断开时,自动重试需要配合后端 API 的幂等性。建议在前端做指数退避:

typescript 复制代码
async function streamWithRetry(options: StreamOptions, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      await streamChat(options)
      return // 成功
    } catch (err) {
      if (i === maxRetries - 1) {
        options.onError(err as Error)
        return
      }
      // 指数退避: 1s, 2s, 4s
      await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000))
    }
  }
}

2. 内存控制

流式响应可能产生大量文本(如生成一篇长文章),fullText 字符串持续拼接会产生 GC 压力。如果只需要实时展示且不保留完整历史,可以考虑只保留 onToken 回调,不在内部累积 fullText

3. 组件卸载时的清理

typescript 复制代码
import { onUnmounted } from 'vue'

onUnmounted(() => {
  stop() // 组件销毁时自动中断正在进行的 SSE 连接
})

4. 多模型并排对比

有了统一的 SSE 管道,实现"同一问题同时发送给多个模型,并排展示结果"变得非常简单:

typescript 复制代码
// 同时向 DeepSeek、通义千问、ChatGLM 发起请求
Promise.all([
  send('deepseek-v3',  'openai', messages),
  send('qwen3-max',     'qwen',   messages),
  send('chatglm-4',     'openai', messages), // ChatGLM 兼容 OpenAI 格式
])

每个模型的流式输出独立走各自管道,互不干扰。


为什么选择统一的 API 网关?

上述架构虽然优雅,但前提是------你使用的 API 平台已经处理好了模型协议的差异

如果用星枢无极这样的统一 AI API 网关,前端开发者完全不需要关心底层模型是 OpenAI 格式还是 Anthropic 格式。平台在后端完成协议转换,所有模型统一输出 OpenAI 兼容的 SSE 流式格式。

这意味着你的前端代码可以简化到极致:

typescript 复制代码
// 所有 40+ 模型统一使用 OpenAI 兼容格式
await streamChat({
  url: '/ai/v1/chat/completions',
  body: {
    model: 'qwen3-max',     // 通义千问
    // model: 'deepseek-v3',  // DeepSeek
    // model: 'claude-opus',  // Claude
    messages: [...],
  },
  adapter: 'openai', // ← 永远只用这一个适配器
})

切换模型只需改一个 model 字段,无需修改任何解析代码,也无需关心各厂商 SDK 的版本兼容性。


总结

方案 适配器数量 切换模型成本 维护负担
逐模型接入 N 个 改代码 + 改解析逻辑 极高
统一 SSE 管道 N 个适配器 改 adapter 参数
API 网关 + 统一管道 1 个(openai) 改 model 字段 极低

核心结论 :流式响应的本质是字节流 → 结构化事件 → 文本增量的转换链。通过 SSEParser(字节流解析)+ ModelAdapter(模型格式适配)+ streamChat(统一 API),你可以将这个链路抽象为一次配置、处处复用的前端基础设施。

配合 API 网关后端的协议统一,前端代码可以做到模型无关------这是规模化接入多模型最优雅的方式。