Đó là 2 giờ sáng khi chatbot của chúng tôi bắt đầu mất phản hồi giữa chừng. Người dùng nhìn thấy những câu trả lời nửa vời, vòng xoay loading cứ quay mãi không thôi, và Slack thì nổ tung. Nguyên nhân gốc rễ? Chúng tôi đã tự xây dựng luồng streaming bằng fetch() thô và Server-Sent Events — và nó dễ vỡ theo những cách mà chúng tôi chưa bao giờ lường trước.
Sau khi xây dựng lại mọi thứ đúng cách, tôi có thể khẳng định: streaming là nền tảng. Làm sai là người dùng cảm nhận được từng token bị mất.
Vấn Đề Khi Tự Xây Dựng Streaming
Khi bạn lần đầu tích hợp LLM vào ứng dụng Next.js, con đường ngây thơ trông như sau:
// Cách tiếp cận ngây thơ — đừng dùng trong production
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
body: JSON.stringify({ model: 'gpt-4o', messages, stream: true }),
});
const reader = res.body?.getReader();
// ... 80 dòng code parse stream dễ vỡ
Cách này hoạt động — cho đến khi nó không còn hoạt động nữa. Bạn sẽ phải viết parser tùy chỉnh cho các SSE chunk, xử lý backpressure, quản lý timeout kết nối, rồi làm lại từ đầu khi đổi từ OpenAI sang Anthropic. Tôi đã mất ba ngày cho việc này trước khi sự cố lúc 2 giờ sáng buộc tôi phải bỏ tất cả.
So Sánh Cách Tiếp Cận: Ba Cách Xử Lý AI Streaming
Trước khi chọn công cụ, tôi đã thử cả ba cách dưới tải thực. Đây là những điểm nổi bật.
Tùy Chọn 1: Raw Fetch + Tự Parse SSE
Kiểm soát tối đa, đau đầu tối đa. Bạn tự xử lý từng byte của stream. Tốt để học, tệ để đưa vào production.
Tùy Chọn 2: Provider SDK (OpenAI SDK, Anthropic SDK)
Mỗi provider đều có SDK riêng với các helper streaming. Phương thức stream() của OpenAI SDK thực sự được thiết kế tốt:
import OpenAI from 'openai';
const stream = await openai.chat.completions.stream({
model: 'gpt-4o',
messages,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}
Nhưng có một vấn đề: bạn viết code này cho OpenAI, rồi lại viết lại hoàn toàn cho messages.stream() của Anthropic, rồi lại một lần nữa cho generateContentStream() của Google. Khi team quyết định đổi provider — và điều đó chắc chắn xảy ra — bạn sẽ phải refactor toàn bộ data layer.
Tùy Chọn 3: Vercel AI SDK
Đây là thứ tôi ước mình đã dùng từ đầu. Một API cho mọi provider. Tích hợp sâu với Next.js App Router. Và quan trọng nhất — nó xử lý luôn phần React state management, đúng chỗ mà các giải pháp tự xây thường sụp đổ.
Ưu và Nhược Điểm: Đánh Giá Thực Tế
Vercel AI SDK
- Ưu điểm: Một API cho OpenAI, Anthropic, Google, Mistral và nhiều hơn nữa — đổi provider chỉ bằng một thay đổi config
- Ưu điểm: Hook
useChattự động quản lý trạng thái loading, xử lý lỗi và cập nhật streaming - Ưu điểm: Hỗ trợ sẵn tool calls, structured output và multi-step reasoning
- Ưu điểm: Tích hợp trực tiếp vào Next.js Route Handlers và Server Actions
- Nhược điểm: Thêm một lớp abstraction — nếu SDK có bug, bạn phụ thuộc vào Vercel
- Nhược điểm: Một số tính năng nâng cao của từng provider yêu cầu dùng SDK gốc
Dùng Provider SDK Trực Tiếp
- Ưu điểm: Truy cập mọi tính năng của từng provider ngay ngày phát hành
- Ưu điểm: Không có overhead của abstraction
- Nhược điểm: Phải implement lại hoàn toàn khi đổi provider
- Nhược điểm: Bạn phải tự xây dựng React state management
Raw Fetch
- Ưu điểm: Không phụ thuộc thư viện nào
- Nhược điểm: Bạn sẽ gặp sự cố lúc 2 giờ sáng. Tôi đảm bảo.
Stack Được Khuyên Dùng
Đây là stack chính xác tôi xây lại sau sự cố — nó đã đứng vững qua ba lần cập nhật provider lớn mà không có một breaking change nào:
- Next.js 14+ với App Router
- Vercel AI SDK (gói
ai) cho lớp streaming cốt lõi - Các gói adapter theo từng provider (
@ai-sdk/openai,@ai-sdk/anthropic) - TypeScript xuyên suốt — type safety của SDK giúp phát hiện thay đổi API của provider ngay lúc compile
Hướng Dẫn Triển Khai
Bước 1: Cài Đặt Dependencies
npm install ai @ai-sdk/openai @ai-sdk/anthropic
Hỗ trợ Google Gemini là một gói riêng biệt:
npm install @ai-sdk/google
Bước 2: Tạo API Route Handler
Tạo file app/api/chat/route.ts:
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { streamText } from 'ai';
export const runtime = 'edge';
export async function POST(req: Request) {
const { messages, provider = 'openai' } = await req.json();
const model = provider === 'anthropic'
? anthropic('claude-sonnet-4-6')
: openai('gpt-4o');
const result = streamText({
model,
system: 'Bạn là một trợ lý hữu ích. Hãy ngắn gọn và trực tiếp.',
messages,
});
return result.toDataStreamResponse();
}
Vậy là xong phần backend. toDataStreamResponse() thay thế parser SSE 80 dòng trước đây — nó tự động xử lý chunking, backpressure và dọn dẹp kết nối. Một lưu ý về runtime flag: đặt thành 'edge'. Nếu không, Vercel sẽ buffer toàn bộ response trước khi gửi, làm mất hoàn toàn hiệu ứng streaming.
Bước 3: Xây Dựng Chat UI
Tạo file app/chat/page.tsx:
'use client';
import { useChat } from 'ai/react';
import { useState } from 'react';
export default function ChatPage() {
const [provider, setProvider] = useState('openai');
const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
useChat({
api: '/api/chat',
body: { provider },
onError: (err) => {
console.error('Lỗi chat:', err);
},
});
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
<div className="mb-4 flex gap-2">
<select
value={provider}
onChange={(e) => setProvider(e.target.value)}
className="border rounded px-2 py-1"
>
<option value="openai">GPT-4o</option>
<option value="anthropic">Claude Sonnet</option>
</select>
<span className="text-sm text-gray-500 self-center">Đổi provider trực tiếp</span>
</div>
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((m) => (
<div
key={m.id}
className={`p-3 rounded-lg ${
m.role === 'user' ? 'bg-blue-100 ml-8' : 'bg-gray-100 mr-8'
}`}
>
<span className="font-semibold text-xs uppercase text-gray-500">
{m.role}
</span>
<p className="mt-1 whitespace-pre-wrap">{m.content}</p>
</div>
))}
{isLoading && (
<div className="text-gray-400 text-sm">Đang suy nghĩ...</div>
)}
{error && (
<div className="text-red-500 text-sm">Lỗi: {error.message}</div>
)}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Hỏi bất cứ điều gì..."
className="flex-1 border rounded px-3 py-2"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
>
Gửi
</button>
</form>
</div>
);
}
Bước 4: Biến Môi Trường
# .env.local
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
Cả hai gói provider đều tự động nhận diện các tên biến này. Không cần cấu hình thêm gì.
Bước 5: Checklist Triển Khai Production
Một vài điều sẽ giúp bạn tránh được cuộc gọi lúc 2 giờ sáng:
- Đặt
export const runtime = 'edge'trong route handler — bắt buộc để streaming hoạt động trên Vercel - Thêm rate limiting trước lời gọi
streamText(SDK không có rate limiting tích hợp sẵn) - Validate và sanitize input
messages— không bao giờ truyền dữ liệu người dùng thô trực tiếp vào model - Đặt giới hạn
maxTokenstrongstreamTextđể tránh chi phí vượt kiểm soát - Dùng UI quản lý biến môi trường của Vercel, không dùng file
.envcho các secret production
const result = streamText({
model,
messages,
maxTokens: 1000, // Giới hạn cứng — quan trọng để kiểm soát chi phí
temperature: 0.7,
});
Một Điều Vẫn Làm Nhiều Người Vấp Ngã
Streaming có vẻ bị lỗi sau khi deploy — văn bản xuất hiện cùng một lúc sau một khoảng delay dài. Trước khi đổ lỗi cho SDK, hãy kiểm tra runtime của bạn. Trên runtime Node.js tiêu chuẩn (không phải edge), reverse proxy của platform thường buffer toàn bộ response. Khắc phục bằng cách chuyển sang edge runtime, hoặc thêm X-Accel-Buffering: no vào response headers.
Bản thân SDK hoạt động ổn định dưới tải thực tế. Khi streaming bị lỗi trong production, hầu như luôn là do hạ tầng — proxy buffer, thiếu header, sai cấu hình runtime. Bắt đầu với edge runtime và bạn tránh được 90% những vấn đề đó trước khi chúng trở thành sự cố lúc 2 giờ sáng.

