327 lines
7.2 KiB
Markdown
327 lines
7.2 KiB
Markdown
# 流式输出(SSE)接入
|
||
|
||
> 使用 Server-Sent Events 实现 AI 流式响应,提升用户体验。
|
||
|
||
## 🌊 什么是流式输出?
|
||
|
||
流式输出(Streaming)是一种数据传输方式,服务器将数据「分块」发送给客户端,而不是等待全部完成后再返回。
|
||
|
||
### 对比
|
||
|
||
| 方式 | 等待时间 | 体验 | 适用场景 |
|
||
|------|----------|------|----------|
|
||
| 普通请求 | 等待完整响应 | 一般 | 简单、快速的请求 |
|
||
| 流式输出 | 实时看到生成过程 | ⭐ 优秀 | AI 生成、长文本 |
|
||
|
||
## 🚀 开始使用
|
||
|
||
### 前端:接收流式响应
|
||
|
||
```typescript
|
||
import { WebsopyClient } from '@websopy/sdk'
|
||
|
||
const client = new WebsopyClient({
|
||
apiKey: process.env.WEBSOPY_API_KEY
|
||
})
|
||
|
||
async function streamChat() {
|
||
const stream = await client.ai.chatStream({
|
||
messages: [
|
||
{ role: 'user', content: '写一篇关于 AI 的文章' }
|
||
]
|
||
})
|
||
|
||
// 方法 1:遍历数据块
|
||
for await (const chunk of stream) {
|
||
if (chunk.type === 'content') {
|
||
process.stdout.write(chunk.content)
|
||
}
|
||
}
|
||
|
||
// 或方法 2:事件监听
|
||
// stream.on('data', (chunk) => { ... })
|
||
// stream.on('end', () => { ... })
|
||
}
|
||
```
|
||
|
||
### 响应数据结构
|
||
|
||
```typescript
|
||
interface StreamChunk {
|
||
type: 'content' | 'tool_call' | 'done' | 'error'
|
||
content?: string
|
||
tool?: {
|
||
name: string
|
||
arguments: string
|
||
}
|
||
usage?: {
|
||
promptTokens: number
|
||
completionTokens: number
|
||
}
|
||
}
|
||
|
||
// 示例数据块
|
||
{ type: 'content', content: 'AI' }
|
||
{ type: 'content', content: ' 正在' }
|
||
{ type: 'content', content: '改变' }
|
||
{ type: 'content', content: '世界...' }
|
||
{ type: 'done', usage: { promptTokens: 20, completionTokens: 150 } }
|
||
```
|
||
|
||
## 🌐 原生 SSE 实现
|
||
|
||
如果不用 SDK,直接使用 Fetch API:
|
||
|
||
### GET 请求
|
||
|
||
```typescript
|
||
async function nativeStream() {
|
||
const response = await fetch(
|
||
'https://api.websopy.com/v1/ai/chat?message=Hello',
|
||
{
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
'Accept': 'text/event-stream'
|
||
}
|
||
}
|
||
)
|
||
|
||
const reader = response.body.getReader()
|
||
const decoder = new TextDecoder()
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
|
||
const chunk = decoder.decode(value)
|
||
// 解析 SSE 数据
|
||
const lines = chunk.split('\n')
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
const data = JSON.parse(line.slice(6))
|
||
console.log('收到:', data)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### POST 请求
|
||
|
||
```typescript
|
||
async function streamPost() {
|
||
const response = await fetch(
|
||
'https://api.websopy.com/v1/ai/chat/stream',
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'text/event-stream'
|
||
},
|
||
body: JSON.stringify({
|
||
messages: [
|
||
{ role: 'user', content: '解释量子计算' }
|
||
],
|
||
model: 'gpt-4',
|
||
stream: true
|
||
})
|
||
}
|
||
)
|
||
|
||
// 处理流...
|
||
}
|
||
```
|
||
|
||
## 🎨 前端组件示例
|
||
|
||
### React Hook
|
||
|
||
```typescript
|
||
import { useState } from 'react'
|
||
|
||
function useStreamingChat() {
|
||
const [messages, setMessages] = useState([])
|
||
const [streaming, setStreaming] = useState(false)
|
||
const [currentContent, setCurrentContent] = useState('')
|
||
|
||
const sendMessage = async (content) => {
|
||
setStreaming(true)
|
||
setCurrentContent('')
|
||
|
||
// 添加用户消息
|
||
setMessages(prev => [...prev, { role: 'user', content }])
|
||
|
||
const stream = await client.ai.chatStream({
|
||
messages: [...messages, { role: 'user', content }]
|
||
})
|
||
|
||
for await (const chunk of stream) {
|
||
if (chunk.type === 'content') {
|
||
setCurrentContent(prev => prev + chunk.content)
|
||
}
|
||
}
|
||
|
||
// 保存完整消息
|
||
setMessages(prev => [...prev, { role: 'assistant', content: currentContent }])
|
||
setStreaming(false)
|
||
}
|
||
|
||
return { messages, streaming, currentContent, sendMessage }
|
||
}
|
||
```
|
||
|
||
### Vue Composition API
|
||
|
||
```typescript
|
||
import { ref } from 'vue'
|
||
|
||
export function useStreamingChat() {
|
||
const messages = ref([])
|
||
const streaming = ref(false)
|
||
const currentContent = ref('')
|
||
|
||
const sendMessage = async (content) => {
|
||
streaming.value = true
|
||
currentContent.value = ''
|
||
|
||
messages.value.push({ role: 'user', content })
|
||
|
||
const stream = await client.ai.chatStream({
|
||
messages: messages.value
|
||
})
|
||
|
||
for await (const chunk of stream) {
|
||
if (chunk.type === 'content') {
|
||
currentContent.value += chunk.content
|
||
}
|
||
}
|
||
|
||
messages.value.push({ role: 'assistant', content: currentContent.value })
|
||
streaming.value = false
|
||
}
|
||
|
||
return { messages, streaming, currentContent, sendMessage }
|
||
}
|
||
```
|
||
|
||
## ⚙️ 服务端 SSE 配置
|
||
|
||
### Node.js Express
|
||
|
||
```typescript
|
||
import express from 'express'
|
||
|
||
app.post('/api/chat/stream', async (req, res) => {
|
||
// 设置 SSE 响应头
|
||
res.setHeader('Content-Type', 'text/event-stream')
|
||
res.setHeader('Cache-Control', 'no-cache')
|
||
res.setHeader('Connection', 'keep-alive')
|
||
|
||
// 保持连接
|
||
res.flushHeaders()
|
||
|
||
try {
|
||
const stream = await client.ai.chatStream(req.body)
|
||
|
||
for await (const chunk of stream) {
|
||
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||
}
|
||
|
||
res.write('data: [DONE]\n\n')
|
||
} catch (error) {
|
||
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`)
|
||
}
|
||
|
||
res.end()
|
||
})
|
||
```
|
||
|
||
### Next.js API Route
|
||
|
||
```typescript
|
||
// app/api/chat/route.ts
|
||
export async function POST(req: Request) {
|
||
const encoder = new TextEncoder()
|
||
const stream = new ReadableStream({
|
||
async start(controller) {
|
||
try {
|
||
const stream = await client.ai.chatStream(await req.json())
|
||
|
||
for await (const chunk of stream) {
|
||
controller.enqueue(
|
||
encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)
|
||
)
|
||
}
|
||
|
||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||
} catch (error) {
|
||
controller.enqueue(
|
||
encoder.encode(`data: ${JSON.stringify({ error: error.message })}\n\n`)
|
||
)
|
||
}
|
||
controller.close()
|
||
}
|
||
})
|
||
|
||
return new Response(stream, {
|
||
headers: {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
'Connection': 'keep-alive'
|
||
}
|
||
})
|
||
}
|
||
```
|
||
|
||
## ❓ 常见问题
|
||
|
||
### Q: 流式中断怎么办?
|
||
|
||
```typescript
|
||
async function streamWithReconnect() {
|
||
const maxRetries = 3
|
||
let retries = 0
|
||
|
||
while (retries < maxRetries) {
|
||
try {
|
||
const stream = await client.ai.chatStream({...})
|
||
return stream
|
||
} catch (error) {
|
||
retries++
|
||
await sleep(1000 * retries) // 指数退避
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Q: 如何在流式过程中取消请求?
|
||
|
||
```typescript
|
||
const controller = new AbortController()
|
||
|
||
// 发送请求
|
||
const stream = await client.ai.chatStream({
|
||
messages: [...],
|
||
signal: controller.signal
|
||
})
|
||
|
||
// 取消请求
|
||
controller.abort()
|
||
```
|
||
|
||
### Q: SSE 和 WebSocket 区别?
|
||
|
||
| 特性 | SSE | WebSocket |
|
||
|------|-----|-----------|
|
||
| 方向 | 单向(服务端→客户端) | 双向 |
|
||
| 协议 | HTTP | ws:// |
|
||
| 重连 | 自动 | 需手动处理 |
|
||
| 复杂度 | 简单 | 复杂 |
|
||
| 适用 | AI 流式输出 | 实时聊天 |
|
||
|
||
---
|
||
|
||
**上一步:** [API Key 创建与管理](./apikey.md)
|
||
**下一步:** [Webhook 事件接入](./webhook.md)
|