Cách stream phản hồi LLM trong Next.js với Vercel AI SDK
Stream LLM token-by-token trong Next.js với 50 dòng code dùng AI SDK v6. Hướng dẫn route handler, client hook, và hai bẫy timeout Vercel mà hầu hết tutorial bỏ qua.
Bởi Ethan
1.481 từ · 8 phút đọc
Chờ bốn giây nhìn vào ô nhập liệu trống rồi đột nhiên nó hiện ra một đống text — đó là một trong những trải nghiệm người dùng tệ nhất trong các ứng dụng AI. Streaming giải quyết vấn đề này. Bạn có thể xây dựng một ứng dụng chat Next.js với output hiển thị từng token một chỉ trong khoảng 50 dòng code dùng Vercel AI SDK v6 — và phần khó không phải là code, mà là hai giới hạn timeout của Vercel function mà hầu hết tutorial đều bỏ qua.
Bài này dành cho ai
Các lập trình viên Next.js muốn thêm giao diện chat LLM vào ứng dụng đang có, hoặc bắt đầu dự án mới từ đầu. Bạn cần biết cơ bản về TypeScript. Bạn không cần kinh nghiệm trước với streams, ReadableStream, hay EventSource.
Tại sao dùng SDK thay vì tự viết
Con đường thủ công — ReadableStream ở server, EventSource ở client — không quá khó, nhưng khá lằng nhằng. Bạn sẽ phải tự viết một framing protocol (line-delimited JSON hoặc SSE), một parser ở client, logic reconnect, báo lỗi qua stream, và quản lý state trong React. AI SDK xử lý tất cả những thứ đó. Ngoài ra nó còn cho phép đổi provider dễ dàng: chuyển từ OpenAI sang Groq hay Anthropic chỉ cần thay một dòng import.
API v5/v6 (ra mắt tháng 7/2025, còn được dùng tính đến tháng 6/2026) đã bỏ tương thích ngược với v4 ở ba chỗ quan trọng:
| v4 | v5/v6 |
|---|---|
useChat từ ai | useChat từ @ai-sdk/react |
handleSubmit + input | sendMessage({ text }) |
message.content (string) | message.parts[].text (array) |
Hầu hết các tutorial được đánh index trước giữa năm 2025 đều dùng cấu trúc của v4. Nếu bạn làm theo một trong số đó, TypeScript compiler sẽ không báo lỗi tại message.content — nó âm thầm không render gì cả.
Cài đặt để streaming
npm install ai @ai-sdk/openai @ai-sdk/react
Thêm API key vào .env.local:
OPENAI_API_KEY=sk-...
Chỉ vậy thôi. Không cần thư viện SSE riêng, không cần config thêm webpack.
Bước 1: Route handler
Tạo file app/api/chat/route.ts:
import { openai } from '@ai-sdk/openai';
import { convertToModelMessages, streamText, UIMessage } from 'ai';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai('gpt-4o-mini'),
system: 'You are a helpful assistant.',
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
Ba điểm cần nắm rõ ở đây.
UIMessage[] là wire format của SDK cho messages — nó chứa parts, metadata, và role theo cấu trúc mà client hook đã biết cách tạo ra và tiêu thụ. Bạn truyền vào, truyền cho convertToModelMessages, xong.
convertToModelMessages dịch UIMessage[] sang định dạng native của từng model (OpenAI chat messages, Anthropic messages, v.v.). Việc chuyển đổi này nhận biết provider, nên khi đổi provider bạn không cần sửa chỗ này.
toUIMessageStreamResponse() bọc stream trong giao thức SSE của SDK và set đúng các header Content-Type và Cache-Control. Client hook ở đầu kia biết cách parse giao thức này — bạn không cần viết parser.
Export maxDuration = 30 là route segment config của Next.js, giới hạn thời gian thực thi tối đa cho function này. Mặc định fluid-compute của Vercel là 300 giây — chi tiết về giới hạn theo từng plan trong phần “Những lỗi hay gặp” bên dưới.
Bước 2: Client component
Tạo hoặc thay thế app/page.tsx:
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
export default function ChatPage() {
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = useState('');
return (
<div>
{messages.map(msg => (
<div key={msg.id}>
<strong>{msg.role === 'user' ? 'You' : 'AI'}:</strong>{' '}
{msg.parts.map((p, i) =>
p.type === 'text' ? <span key={i}>{p.text}</span> : null
)}
</div>
))}
<form
onSubmit={e => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input
value={input}
onChange={e => setInput(e.target.value)}
disabled={status !== 'ready'}
/>
<button type="submit" disabled={status !== 'ready'}>
Send
</button>
</form>
</div>
);
}
Ba điểm khác biệt v5/v6 đề cập ở trên đều xuất hiện ở đây.
Đường import: useChat lấy từ @ai-sdk/react, không phải ai. Nếu dùng sai import, hook vẫn tồn tại (có một re-export stub) nhưng sendMessage sẽ không được định nghĩa và bạn sẽ gặp lỗi runtime.
sendMessage: thay thế pattern handleSubmit + input của v4. Bạn gọi nó với { text: input } và nó xử lý HTTP request, parse stream, và cập nhật state. Không cần wire thêm controlled input qua hook.
message.parts: mỗi message có một mảng parts với discriminator type. Nội dung text nằm ở p.type === 'text', với text trong p.text. Nếu bạn render message.content, bạn sẽ nhận được undefined cho AI messages — không có lỗi TypeScript, chỉ là output trống trông như lỗi streaming.
Field status chạy qua các trạng thái 'ready', 'submitted', 'streaming', rồi về lại 'ready'. Disable input khi không ở trạng thái 'ready' giúp tránh gửi trùng.
TTFT: Groq vs OpenAI
TTFT (time to first token) — thời gian từ lúc gửi đến khi ký tự đầu tiên xuất hiện — quyết định streaming của bạn cảm giác nhanh hay ì ạch.
Groq chạy trên phần cứng LPU được tối ưu riêng cho inference throughput. Trong thực tế, điều này cho TTFT thấp hơn đáng kể so với hạ tầng GPU phổ thông mà GPT-4o đang dùng. Đánh đổi là năng lực model: GPT-4o vượt trội hơn Llama 3.1 8B về lý luận và làm theo hướng dẫn. Với giao diện chat mà tốc độ phản hồi quan trọng hơn chiều sâu lý luận, Groq đáng để thử nghiệm trên workload của bạn. TTFT thay đổi theo độ dài prompt, tải của model và khu vực, nên hãy đo trong môi trường thực của bạn thay vì dựa vào số liệu trung bình được công bố.
Để chuyển sang Groq, thay import provider và tên model — phần còn lại của route handler giữ nguyên:
import { groq } from '@ai-sdk/groq';
// ...
model: groq('llama-3.1-8b-instant'),
Những lỗi hay gặp
Timeout Vercel Hobby (300 giây)
Từ khi Vercel triển khai fluid-compute năm 2025, serverless functions trên plan Hobby bị giới hạn tối đa 300 giây — tăng so với giới hạn cũ 10 giây. Con số này đủ để xử lý hầu hết phản hồi LLM. Export maxDuration trong route của bạn được áp dụng đến giới hạn đó.
Một lưu ý: fluid compute phải được bật theo từng project trong Vercel dashboard, không tự động áp dụng cho các project cũ. Kiểm tra Settings → Functions → Fluid Compute. Nếu chưa bật, giới hạn cũ vẫn có hiệu lực — và cách fix cũ (Edge runtime, cũng có giới hạn 300 giây riêng) vẫn dùng được cho những project đó. Với một route đơn thuần là API forwarding như chat handler này, Edge runtime hoạt động ổn; chỉ gặp vấn đề nếu bạn dùng Node.js built-ins (fs, crypto, http, v.v.).
Timeout Vercel Pro (800 giây)
Các tài khoản Pro có thể set maxDuration lên đến 800 giây khi fluid compute được bật (từ đợt triển khai năm 2025). Con số này đủ cho mọi workload LLM thực tế. Nếu bạn vẫn bị timeout trên Pro, bottleneck gần như chắc chắn nằm ở model hoặc upstream API, không phải Vercel.
Giới hạn bundle size trên Cloudflare Workers
Nếu bạn deploy lên Cloudflare Workers thay vì Vercel, hãy để ý bundle size. Plan miễn phí giới hạn 3 MB sau compress; package ai thêm khoảng 300 KB min+gzip sau tree-shaking, vẫn còn chỗ cho code ứng dụng nhưng đáng chú ý nếu bạn stack nhiều dependency nặng. Nếu chạm giới hạn:
- Nâng lên Cloudflare Workers Paid để có giới hạn 10 MB, hoặc
- Dùng server-only import của AI SDK (
import { streamText } from 'ai/server') để loại React client code ra khỏi bundle Workers.
Lỗi bundle từ wrangler sẽ xuất hiện lúc deploy, không phải lúc chạy.
Tổng kết
Toàn bộ implementation chỉ có hai file và khoảng 50 dòng. AI SDK v6 xử lý streaming protocol, SSE framing, và React state management — việc của bạn là kết nối streamText ở server với useChat ở client và tránh ba cấu trúc API của v4 đã không còn tồn tại. Giới hạn fluid-compute 300 giây của Vercel Hobby là đủ cho hầu hết chat LLM. Nếu bạn cần đến 800 giây, hoặc fluid compute chưa được bật cho project của bạn, plan Pro sẽ nâng giới hạn lên.