23.09-流式聊天UI对接

要点

  • 流式响应(Server-Sent Events)让 AI 回复逐字显示,提升用户体验
  • 后端使用 streamSSE 中间件发送流式数据
  • 前端使用 EventSourcefetch + ReadableStream 接收流式数据
  • 需要处理流式响应的加载状态和错误处理

内容

1. 后端实现流式响应

1.1 使用 streamSSE

// src/index.ts
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
 
const app = new Hono()
 
app.post('/v1/chat/completions/stream', async (c) => {
  const body = await c.req.json()
 
  return streamSSE(c, async (stream) => {
    // 调用 LLM API(流式)
    const llmStream = await callLLMStream(body)
 
    for await (const chunk of llmStream) {
      // 发送 SSE 事件
      await stream.writeSSE({
        event: 'message',
        data: JSON.stringify({
          choices: [{
            delta: {
              content: chunk.content,
            },
          }],
        }),
      })
    }
 
    // 发送结束事件
    await stream.writeSSE({
      event: 'done',
      data: '[DONE]',
    })
  })
})
 
export default app

1.2 模拟流式响应

// 模拟 LLM 流式响应
async function* mockLLMStream(text: string) {
  const words = text.split(' ')
  for (const word of words) {
    await new Promise(resolve => setTimeout(resolve, 100))
    yield { content: word + ' ' }
  }
}
 
app.post('/v1/chat/completions/stream', async (c) => {
  const body = await c.req.json()
  const message = body.messages[body.messages.length - 1].content
 
  return streamSSE(c, async (stream) => {
    const mockText = `这是一个模拟的 AI 回复,你说了:${message}`
 
    for await (const chunk of mockLLMStream(mockText)) {
      await stream.writeSSE({
        event: 'message',
        data: JSON.stringify({
          choices: [{
            delta: {
              content: chunk.content,
            },
          }],
        }),
      })
    }
 
    await stream.writeSSE({
      event: 'done',
      data: '[DONE]',
    })
  })
})

2. 前端接收流式响应

2.1 使用 fetch + ReadableStream

// src/lib/stream-chat.ts
export async function streamChat(
  message: string,
  onChunk: (content: string) => void,
  onDone: () => void,
  onError: (error: Error) => void
) {
  const response = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      messages: [{ role: 'user', content: message }],
    }),
  })
 
  if (!response.ok) {
    onError(new Error(`Request failed: ${response.status}`))
    return
  }
 
  const reader = response.body!.getReader()
  const decoder = new TextDecoder()
  let buffer = ''
 
  while (true) {
    const { done, value } = await reader.read()
 
    if (done) break
 
    buffer += decoder.decode(value, { stream: true })
 
    // 解析 SSE 数据
    const lines = buffer.split('\n')
    buffer = lines.pop() || ''
 
    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = line.slice(6)
 
        if (data === '[DONE]') {
          onDone()
          return
        }
 
        try {
          const parsed = JSON.parse(data)
          const content = parsed.choices?.[0]?.delta?.content
 
          if (content) {
            onChunk(content)
          }
        } catch (err) {
          console.error('Failed to parse SSE data:', err)
        }
      }
    }
  }
 
  onDone()
}
// src/components/Chat.tsx
import { useState } from 'react'
import { streamChat } from '@/lib/stream-chat'
 
export function Chat() {
  const [input, setInput] = useState('')
  const [messages, setMessages] = useState([])
  const [streaming, setStreaming] = useState(false)
 
  async function handleSend() {
    const userMessage = { role: 'user', content: input }
    setMessages(prev => [...prev, userMessage])
    setInput('')
    setStreaming(true)
 
    let assistantMessage = ''
 
    await streamChat(
      input,
      // onChunk
      (content) => {
        assistantMessage += content
        setMessages(prev => {
          const last = prev[prev.length - 1]
          if (last?.role === 'assistant') {
            return [...prev.slice(0, -1), { ...last, content: assistantMessage }]
          }
          return [...prev, { role: 'assistant', content: assistantMessage }]
        })
      },
      // onDone
      () => {
        setStreaming(false)
      },
      // onError
      (error) => {
        console.error(error)
        setStreaming(false)
      }
    )
  }
 
  return (
    <div>
      <div className="messages">
        {messages.map((msg, i) => (
          <div key={i} className={msg.role}>
            {msg.content}
          </div>
        ))}
      </div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && handleSend()}
        disabled={streaming}
      />
      <button onClick={handleSend} disabled={streaming}>
        {streaming ? '回复中...' : '发送'}
      </button>
    </div>
  )
}

2.2 使用 EventSource

// 注意:EventSource 只支持 GET 请求,POST 请求需要用 fetch
// 如果后端支持 GET 方式的流式响应,可以使用 EventSource
 
const eventSource = new EventSource('/api/chat/stream?message=你好')
 
eventSource.addEventListener('message', (event) => {
  const data = JSON.parse(event.data)
  const content = data.choices?.[0]?.delta?.content
 
  if (content) {
    console.log('Chunk:', content)
  }
})
 
eventSource.addEventListener('done', () => {
  console.log('Stream done')
  eventSource.close()
})
 
eventSource.onerror = (error) => {
  console.error('Stream error:', error)
  eventSource.close()
}

3. React Hook 封装

// src/hooks/useStreamChat.ts
import { useState, useCallback } from 'react'
 
interface Message {
  role: 'user' | 'assistant'
  content: string
}
 
export function useStreamChat() {
  const [messages, setMessages] = useState<Message[]>([])
  const [streaming, setStreaming] = useState(false)
  const [error, setError] = useState<Error | null>(null)
 
  const sendMessage = useCallback(async (content: string) => {
    setMessages(prev => [...prev, { role: 'user', content }])
    setStreaming(true)
    setError(null)
 
    let assistantContent = ''
 
    try {
      const response = await fetch('/api/chat/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          messages: [...messages, { role: 'user', content }],
        }),
      })
 
      if (!response.ok) {
        throw new Error(`Request failed: ${response.status}`)
      }
 
      const reader = response.body!.getReader()
      const decoder = new TextDecoder()
      let buffer = ''
 
      while (true) {
        const { done, value } = await reader.read()
        if (done) break
 
        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() || ''
 
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6)
 
            if (data === '[DONE]') {
              setStreaming(false)
              return
            }
 
            try {
              const parsed = JSON.parse(data)
              const chunk = parsed.choices?.[0]?.delta?.content
 
              if (chunk) {
                assistantContent += chunk
                setMessages(prev => {
                  const last = prev[prev.length - 1]
                  if (last?.role === 'assistant') {
                    return [...prev.slice(0, -1), { role: 'assistant', content: assistantContent }]
                  }
                  return [...prev, { role: 'assistant', content: assistantContent }]
                })
              }
            } catch (err) {
              console.error('Failed to parse SSE:', err)
            }
          }
        }
      }
    } catch (err) {
      setError(err as Error)
      setStreaming(false)
    }
  }, [messages])
 
  return {
    messages,
    streaming,
    error,
    sendMessage,
  }
}
// 使用
import { useStreamChat } from '@/hooks/useStreamChat'
 
function Chat() {
  const { messages, streaming, error, sendMessage } = useStreamChat()
  const [input, setInput] = useState('')
 
  return (
    <div>
      {messages.map((msg, i) => (
        <div key={i}>{msg.role}: {msg.content}</div>
      ))}
      {error && <div>错误:{error.message}</div>}
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        disabled={streaming}
      />
      <button onClick={() => sendMessage(input)} disabled={streaming}>
        {streaming ? '回复中...' : '发送'}
      </button>
    </div>
  )
}

4. 错误处理和重试

export async function streamChatWithRetry(
  message: string,
  maxRetries = 3
): Promise<string> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      let content = ''
 
      await streamChat(
        message,
        (chunk) => { content += chunk },
        () => {},
        (error) => { throw error }
      )
 
      return content
    } catch (error) {
      if (attempt === maxRetries) {
        throw error
      }
 
      console.warn(`Attempt ${attempt} failed, retrying...`)
      await new Promise(resolve => setTimeout(resolve, 1000 * attempt))
    }
  }
 
  throw new Error('Max retries exceeded')
}

5. 取消流式请求

export function useStreamChat() {
  const [streaming, setStreaming] = useState(false)
  const abortControllerRef = useRef<AbortController | null>(null)
 
  const sendMessage = useCallback(async (content: string) => {
    abortControllerRef.current = new AbortController()
 
    try {
      const response = await fetch('/api/chat/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ messages: [{ role: 'user', content }] }),
        signal: abortControllerRef.current.signal,
      })
 
      // ... 处理流式响应
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Request cancelled')
      } else {
        throw error
      }
    } finally {
      setStreaming(false)
    }
  }, [])
 
  const cancel = useCallback(() => {
    abortControllerRef.current?.abort()
    setStreaming(false)
  }, [])
 
  return {
    streaming,
    sendMessage,
    cancel,
  }
}

6. 小结

流式聊天 UI 对接的关键点:

  1. 后端:使用 streamSSE 中间件发送 Server-Sent Events
  2. 前端:使用 fetch + ReadableStream 接收流式数据
  3. 状态管理:实时更新消息内容,处理加载状态
  4. 错误处理:处理网络错误、解析错误
  5. 取消请求:使用 AbortController 取消流式请求
  6. Hook 封装:封装成可复用的自定义 Hook

一句话带走:

流式响应让 AI 回复逐字显示,大幅提升用户体验。后端用 streamSSE,前端用 fetch + ReadableStream,两端配合实现实时流式聊天。