一套代码接入 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 网关后端的协议统一,前端代码可以做到模型无关------这是规模化接入多模型最优雅的方式。