บทนำ: เมื่อ Timeout ทำให้ UX พังทลาย
เช้าวันศุกร์ที่ผมนั่งทำโปรเจกต์ Chatbot สำหรับลูกค้าองค์กรใหญ่แห่งหนึ่ง ทุกอย่างดูราบรื่นจนกระทั่งทีม QA ส่ง Bug Report มาว่า "ผู้ใช้บ่นว่าข้อความตอบกลับจาก AI ใช้เวลานานเกินไป บางครั้งรอแล้วไม่มาสักที" พอเปิด Console ดูเจอ Error ที่ชัดเจน:
ConnectionError: timeout of 30000ms exceeded และ
Error: Network connection lost หลังจากนั่งแก้ทั้งวัน ผมค้นพบว่าปัญหาคือการใช้ Polling แทน Streaming ทำให้ User Experience แย่มาก
บทความนี้จะสอนวิธีใช้ Server-Sent Events (SSE) เพื่อส่งข้อความจาก AI แบบ Real-time Streaming ที่ทำให้ผู้ใช้เห็นตัวอักษรปรากฏทีละตัว เหมือนกับ ChatGPT ที่เราใช้กันทุกวัน พร้อมโค้ดตัวอย่างที่พร้อมใช้งานจริงสำหรับทั้ง Vue 3 และ React
Server-Sent Events คืออะไร และทำไมต้องใช้กับ AI
Server-Sent Events หรือ SSE เป็นเทคโนโลยีที่ทำให้ Server สามารถส่งข้อมูลไปยัง Browser ได้อย่างต่อเนื่องผ่าน HTTP Connection เดียว โดยไม่ต้องให้ Client ส่ง Request ซ้ำๆ ต่างจาก REST API ทั่วไปที่ต้องรอ Response แล้วค่อยส่ง Request ใหม่ ซึ่งเหมาะมากกับการใช้งาน AI Streaming เพราะ AI Model ต้องประมวลผลทีละ Token และส่งออกมาทีละส่วน
ข้อดีหลักของ SSE คือ:
-
Low Latency — ผู้ใช้เห็นผลลัพธ์ทันทีที่ AI ประมวลผลเสร็จ
-
Persistent Connection — เปิด Connection ครั้งเดียวส่งข้อมูลได้ต่อเนื่อง
-
Auto-reconnect — Browser มี built-in รองรับการเชื่อมต่อใหม่อัตโนมัติ
-
Simple Implementation — ใช้ EventSource API ง่ายกว่า WebSocket
สำหรับโปรเจกต์ AI Chatbot ที่ต้องการประสบการณ์เหมือน ChatGPT หรือ Claude การใช้ SSE เป็นทางเลือกที่ดีกว่า WebSocket เพราะโค้ดฝั่ง Server ง่ายกว่าและเพียงพอต่อความต้องการ
การใช้งาน SSE กับ HolySheep AI API
ก่อนเข้าสู่โค้ดตัวอย่าง มาดูว่า HolySheep AI มีข้อดีอย่างไร HolySheep AI เป็น AI API Gateway ที่รวม Model ยอดนิยมไว้ในที่เดียว ให้บริการด้วยโครงสร้างราคาที่ประหยัดมาก — อัตราแลกเปลี่ยน ¥1=$1 ทำให้ค่าใช้จ่ายต่ำกว่า Official API ถึง 85% โดยสามารถ
สมัครที่นี่ และรับเครดิตฟรีเมื่อลงทะเบียน รองรับการชำระเงินผ่าน WeChat และ Alipay พร้อม Latency เฉลี่ยต่ำกว่า 50ms ซึ่งเร็วมากสำหรับ Streaming Application
ราคาเฉพาะสำหรับปี 2026 มีดังนี้:
- GPT-4.1: $8/MTok
- Claude Sonnet 4.5: $15/MTok
- Gemini 2.5 Flash: $2.50/MTok
- DeepSeek V3.2: $0.42/MTok (ประหยัดที่สุดสำหรับงานทั่วไป)
โค้ดตัวอย่าง: Vue 3 Composition API
สำหรับ Vue 3 เราจะสร้าง composable function ที่จัดการ SSE connection ทั้งหมด เพื่อให้สามารถ reuse ได้ง่ายในหลาย Component
// composables/useStreamingAI.ts
import { ref, onUnmounted } from 'vue'
interface StreamMessage {
content: string
done: boolean
error?: string
}
export function useStreamingAI() {
const messages = ref<string[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
let eventSource: EventSource | null = null
let currentContent = ''
const startStream = async (userMessage: string) => {
// ล้างค่าก่อนหน้า
messages.value = []
currentContent = ''
error.value = null
isLoading.value = true
try {
// ส่ง POST request เพื่อเริ่ม stream
const response = await fetch('https://api.holysheep.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_HOLYSHEEP_API_KEY'
},
body: JSON.stringify({
model: 'gpt-4.1',
messages: [
{ role: 'user', content: userMessage }
],
stream: true // สำคัญ: เปิด streaming mode
})
})
if (!response.ok) {
throw new Error(HTTP ${response.status}: ${response.statusText})
}
// อ่าน response เป็น ReadableStream
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader) {
throw new Error('Response body is null')
}
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
// Parse SSE data ที่มี format: data: {...}\n\n
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
// ตรวจสอบว่าเป็น [DONE] message หรือไม่
if (data === '[DONE]') {
isLoading.value = false
return
}
try {
const parsed = JSON.parse(data)
const content = parsed.choices?.[0]?.delta?.content
if (content) {
currentContent += content
messages.value = [currentContent]
}
} catch (parseError) {
// ในกรณีที่ JSON parse ไม่ได้ ให้ข้ามไป
console.warn('Parse error:', parseError)
}
}
}
}
isLoading.value = false
} catch (err) {
isLoading.value = false
error.value = err instanceof Error ? err.message : 'Unknown error occurred'
console.error('Streaming error:', err)
}
}
const stopStream = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
isLoading.value = false
}
// Cleanup เมื่อ component ถูก unmount
onUnmounted(() => {
stopStream()
})
return {
messages,
isLoading,
error,
startStream,
stopStream
}
}
โค้ดตัวอย่าง: React Hook
สำหรับ React เราจะสร้าง custom hook ที่ใช้งานได้ทั้งกับ React และ Next.js
// hooks/useStreamingChat.ts
import { useState, useCallback, useRef } from 'react'
interface UseStreamingChatOptions {
apiKey: string
model?: string
baseUrl?: string
}
interface Message {
id: string
role: 'user' | 'assistant'
content: string
}
export function useStreamingChat({
apiKey,
model = 'gpt-4.1',
baseUrl = 'https://api.holysheep.ai/v1'
}: UseStreamingChatOptions) {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const sendMessage = useCallback(async (content: string) => {
// สร้าง AbortController สำหรับ cancel request
abortControllerRef.current = new AbortController()
// เพิ่ม user message
const userMessage: Message = {
id: user-${Date.now()},
role: 'user',
content
}
setMessages(prev => [...prev, userMessage])
setInput('')
setIsLoading(true)
setError(null)
// สร้าง placeholder สำหรับ assistant message
const assistantMessageId = assistant-${Date.now()}
let fullResponse = ''
try {
const response = await fetch(${baseUrl}/chat/completions, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${apiKey}
},
body: JSON.stringify({
model,
messages: [
...messages.map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content }
],
stream: true
}),
signal: abortControllerRef.current.signal
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error?.message || Request failed: ${response.status})
}
// เพิ่ม empty assistant message
setMessages(prev => [...prev, {
id: assistantMessageId,
role: 'assistant',
content: ''
}])
// อ่าน streaming response
const reader = response.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) {
setIsLoading(false)
break
}
const chunk = decoder.decode(value, { stream: true })
// Parse SSE format
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
setIsLoading(false)
break
}
try {
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) {
fullResponse += delta
// Update assistant message
setMessages(prev => prev.map(msg =>
msg.id === assistantMessageId
? { ...msg, content: fullResponse }
: msg
))
}
} catch {
// Skip invalid JSON
}
}
}
}
} catch (err) {
setIsLoading(false)
if (err instanceof Error) {
if (err.name === 'AbortError') {
setError('Request was cancelled')
} else {
setError(err.message)
}
} else {
setError('An unexpected error occurred')
}
// ลบ assistant message ที่ถูกสร้างไว้
setMessages(prev => prev.filter(m => m.id !== assistantMessageId))
}
}, [apiKey, baseUrl, model, messages])
const stopGeneration = useCallback(() => {
abortControllerRef.current?.abort()
setIsLoading(false)
}, [])
const clearMessages = useCallback(() => {
setMessages([])
setError(null)
}, [])
return {
messages,
input,
setInput,
isLoading,
error,
sendMessage,
stopGeneration,
clearMessages
}
}
ตัวอย่างการใช้งานใน Component
Vue Component
<template>
<div class="chat-container">
<div class="messages">
<div
v-for="(msg, index) in messages"
:key="index"
class="message"
:class="msg.role"
>
{{ msg.content }}
</div>
<div v-if="isLoading" class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
<div v-if="error" class="error-message">
⚠️ {{ error }}
</div>
<div class="input-area">
<input
v-model="userInput"
@keyup.enter="handleSend"
placeholder="พิมพ์ข้อความของคุณ..."
:disabled="isLoading"
/>
<button
@click="handleSend"
:disabled="isLoading || !userInput.trim()"
>
{{ isLoading ? 'กำลังส่ง...' : 'ส่ง' }}
</button>
<button
v-if="isLoading"
@click="stopStream"
class="stop-btn"
>
หยุด
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useStreamingAI } from './composables/useStreamingAI'
const userInput = ref('')
const { messages, isLoading, error, startStream, stopStream } = useStreamingAI()
// แปลง messages เป็น format ที่เหมาะกับ UI
const displayMessages = computed(() =>
messages.value.map((content, index) => ({
id: index,
role: index % 2 === 0 ? 'user' : 'assistant',
content
}))
)
const handleSend = async () => {
if (!userInput.value.trim() || isLoading.value) return
await startStream(userInput.value)
userInput.value = ''
}
</script>
React Component
// ChatComponent.jsx
import React from 'react'
import { useStreamingChat } from './hooks/useStreamingChat'
const API_KEY = 'YOUR_HOLYSHEEP_API_KEY'
export default function ChatComponent() {
const {
messages,
input,
setInput,
isLoading,
error,
sendMessage,
stopGeneration,
clearMessages
} = useStreamingChat({
apiKey: API_KEY,
model: 'gpt-4.1'
})
const handleSubmit = (e) => {
e.preventDefault()
if (input.trim() && !isLoading) {
sendMessage(input)
}
}
return (
<div className="chat-container">
<div className="messages-area">
{messages.map((msg) => (
<div
key={msg.id}
className={message message-${msg.role}}
>
<div className="message-content"&
แหล่งข้อมูลที่เกี่ยวข้อง
บทความที่เกี่ยวข้อง