Vercel AI SDK完全ガイド:Next.jsでプロダクションレディなAIチャットアプリを構築する
AIを活用したアプリケーション構築がこれほど簡単になったことはありません。Vercel AI SDKを使えば、Next.jsアプリで洗練されたストリーミングチャットインターフェース、テキスト補完システム、AI支援機能を驚くほど少ないボイラープレートで作成できます。
しかし課題があります:SDKは急速に進化しており、ドキュメントはバージョンごとに散在し、ほとんどのチュートリアルは表面をなぞるだけです。OpenAI、Anthropic、その他のLLMプロバイダーをウェブアプリに統合しようとして、ストリーミングの複雑さ、トークン管理、状態同期に溺れた経験があるなら—このガイドが役立ちます。
この包括的なディープダイブでは、ゼロからプロダクションレディなAIチャットアプリを構築します。基本的なフックからツール呼び出し、マルチモデルルーティング、レート制限といった高度なパターンまですべてカバーします。最後まで読めば、どんなAI機能でも構築できる確固たる基盤が手に入ります。
📌 バージョン情報: このガイドは Vercel AI SDK v6.0+(2025年末リリース)を対象としています。以前のバージョンでは一部のAPIが異なる場合があります。
npm info ai versionでインストール済みバージョンを確認してください。
なぜVercel AI SDKなのか?
コードに入る前に、Vercel AI SDKがReactアプリでのAI統合の標準になった理由を理解しましょう。
ストリーミング問題
LLM APIを直接呼び出すと、生成全体が完了するまで応答を受け取れません。500トークンの応答なら5-10秒の待機です。ユーザーはこれを嫌います。
// ナイーブなアプローチ - 最悪のUX const response = await openai.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: '量子コンピューティングを説明して' }], }); // ユーザーがローディングスピナーを8秒見つめる... console.log(response.choices[0].message.content);
ストリーミングは生成されるたびにトークンを送信することでこれを解決します。しかしReactで適切なストリーミングを実装するのは意外と複雑です:
- APIからの
ReadableStream管理 - SSE(Server-Sent Events)や改行区切りJSONのパース
- 再レンダーの連鎖なしにReact状態を更新
- キャンセル用のabortシグナル処理
- ローディング、エラー、完了状態の管理
- クライアント・サーバー状態の同期
Vercel AI SDKはこれらすべてをシンプルで宣言的なフックに抽象化します。
プロバイダー非依存アーキテクチャ
SDKのキラー機能の1つは、プロバイダー間の統一されたインターフェースです:
import { openai } from '@ai-sdk/openai'; import { anthropic } from '@ai-sdk/anthropic'; import { google } from '@ai-sdk/google'; // 同じ関数シグネチャ、異なるプロバイダー const result = await generateText({ model: openai('gpt-4-turbo'), // または: model: anthropic('claude-3-opus'), // または: model: google('gemini-pro'), prompt: '量子コンピューティングを説明して', });
1行変更するだけでプロバイダーを切り替え可能。リファクタリング不要です。
コアコンセプト:AI SDKアーキテクチャ
Vercel AI SDKは3つの主要パッケージに分かれています:
1. AI SDK Core (ai)
基盤パッケージで以下を提供:
generateText()- 完全な結果でテキスト生成streamText()- テキスト生成のストリーミングgenerateObject()- 構造化されたJSON生成streamObject()- 構造化されたJSON生成のストリーミングembed()- エンベディング生成embedMany()- バッチエンベディング
2. AI SDK UI (@ai-sdk/react)
UI構築用のReactフック:
useChat()- 完全なチャットインターフェース管理useCompletion()- シングルターンテキスト補完useObject()- 構造化データのストリーミングuseAssistant()- OpenAI Assistants API統合
3. プロバイダーパッケージ
モデル実装:
@ai-sdk/openai- OpenAI、Azure OpenAI@ai-sdk/anthropic- Claudeモデル@ai-sdk/google- Geminiモデル@ai-sdk/mistral- Mistral AI@ai-sdk/amazon-bedrock- AWS Bedrock- その他コミュニティプロバイダー...
セットアップ:プロジェクトブートストラップ
プロダクションレディなプロジェクト構造を作りましょう:
npx create-next-app@latest ai-chat-app --typescript --tailwind --app cd ai-chat-app # AI SDKパッケージをインストール npm install ai @ai-sdk/openai @ai-sdk/anthropic # オプション:UIコンポーネント npm install @radix-ui/react-scroll-area lucide-react
環境設定
# .env.local OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-...
プロジェクト構造
src/
├── app/
│ ├── api/
│ │ └── chat/
│ │ └── route.ts # チャットAPIエンドポイント
│ ├── page.tsx # メインチャットUI
│ └── layout.tsx
├── components/
│ ├── chat/
│ │ ├── ChatContainer.tsx
│ │ ├── MessageList.tsx
│ │ ├── MessageBubble.tsx
│ │ └── ChatInput.tsx
│ └── ui/
│ └── Button.tsx
├── lib/
│ ├── ai/
│ │ ├── models.ts # モデル設定
│ │ └── prompts.ts # システムプロンプト
│ └── utils.ts
└── types/
└── chat.ts
チャットAPI構築:サーバーサイド実装
APIルートが魔法が起こる場所です。堅牢なチャットエンドポイントを構築しましょう:
基本的なチャットルート
// src/app/api/chat/route.ts import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; export const runtime = 'edge'; // 低レイテンシのためエッジランタイムを有効化 export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ model: openai('gpt-4-turbo'), messages, system: `あなたは役立つAIアシスタントです。簡潔で明確に回答してください。`, }); return result.toDataStreamResponse(); }
基本的なチャットはこれだけです!しかし本番アプリにはもっと必要です...
プロダクションレディなチャットルート
// src/app/api/chat/route.ts import { openai } from '@ai-sdk/openai'; import { anthropic } from '@ai-sdk/anthropic'; import { streamText, convertToModelMessages } from 'ai'; import { z } from 'zod'; export const runtime = 'edge'; export const maxDuration = 30; // 最大実行時間 // リクエスト検証スキーマ const chatRequestSchema = z.object({ messages: z.array(z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string(), })), model: z.enum(['gpt-4-turbo', 'claude-3-opus']).default('gpt-4-turbo'), temperature: z.number().min(0).max(2).default(0.7), }); // モデルレジストリ const models = { 'gpt-4-turbo': openai('gpt-4-turbo'), 'claude-3-opus': anthropic('claude-3-opus-20240229'), }; const SYSTEM_PROMPT = `あなたはソフトウェア開発を専門とするエキスパートAIアシスタントです。 ガイドライン: - 正確で構造化された回答を提供 - 関連する場合はコード例を含める - 事実を主張する際は出典を引用 - 推測より不確実性を認める - 簡潔ながら包括的に`; export async function POST(req: Request) { try { const body = await req.json(); const { messages, model, temperature } = chatRequestSchema.parse(body); // レート制限チェック(独自ロジックを実装) const clientIP = req.headers.get('x-forwarded-for') || 'unknown'; // await checkRateLimit(clientIP); const result = streamText({ model: models[model], messages: await convertToModelMessages(messages), system: SYSTEM_PROMPT, temperature, maxTokens: 4096, // クライアント切断用のabortシグナル abortSignal: req.signal, // ロギング/分析用コールバック onFinish: async ({ text, usage }) => { console.log(`完了: ${usage.totalTokens}トークン`); // await logUsage(usage); }, }); return result.toDataStreamResponse(); } catch (error) { if (error instanceof z.ZodError) { return new Response(JSON.stringify({ error: '無効なリクエスト', details: error.errors }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } console.error('チャットAPIエラー:', error); return new Response(JSON.stringify({ error: '内部サーバーエラー' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } }
チャットUI構築:useChatフックディープダイブ
useChatフックがクライアントサイド実装の心臓部です:
基本的な使い方
// src/app/page.tsx 'use client'; import { useChat } from '@ai-sdk/react'; export default function ChatPage() { const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat(); return ( <div className="flex flex-col h-screen max-w-2xl mx-auto p-4"> <div className="flex-1 overflow-y-auto space-y-4"> {messages.map((message) => ( <div key={message.id} className={`p-4 rounded-lg ${ message.role === 'user' ? 'bg-blue-100 ml-auto max-w-[80%]' : 'bg-gray-100 mr-auto max-w-[80%]' }`} > <p className="whitespace-pre-wrap">{message.content}</p> </div> ))} </div> <form onSubmit={handleSubmit} className="flex gap-2 pt-4"> <input value={input} onChange={handleInputChange} placeholder="メッセージを入力..." className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={isLoading} /> <button type="submit" disabled={isLoading || !input.trim()} className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50" > {isLoading ? '送信中...' : '送信'} </button> </form> </div> ); }
高度なuseChat設定
'use client'; import { useChat, Message } from '@ai-sdk/react'; import { useState, useCallback, useRef, useEffect } from 'react'; export default function AdvancedChatPage() { const [selectedModel, setSelectedModel] = useState('gpt-4-turbo'); const scrollRef = useRef<HTMLDivElement>(null); const { messages, input, setInput, handleInputChange, handleSubmit, isLoading, error, reload, stop, append, setMessages, } = useChat({ api: '/api/chat', // 各リクエストに追加データを送信 body: { model: selectedModel, temperature: 0.7, }, // 初期メッセージ(例:DBから) initialMessages: [], // ユニークIDを生成 generateId: () => crypto.randomUUID(), // レスポンスストリーミング開始時に呼び出し onResponse: (response) => { if (!response.ok) { console.error('レスポンスエラー:', response.status); } }, // ストリーミング完了時に呼び出し onFinish: (message) => { console.log('メッセージ完了:', message.id); // DB保存、分析など }, // エラー発生時に呼び出し onError: (error) => { console.error('チャットエラー:', error); }, }); // 自動スクロール useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages]); // プログラムでメッセージを追加 const sendQuickMessage = useCallback((content: string) => { append({ role: 'user', content }); }, [append]); // チャット履歴をクリア const clearChat = useCallback(() => { setMessages([]); }, [setMessages]); // 最後のメッセージを再試行 const retryLast = useCallback(() => { if (messages.length >= 2) { reload(); } }, [messages, reload]); return ( <div className="flex flex-col h-screen max-w-3xl mx-auto"> {/* コントロール付きヘッダー */} <header className="flex items-center justify-between p-4 border-b"> <h1 className="text-xl font-bold">AIチャット</h1> <div className="flex items-center gap-2"> <select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} className="p-2 border rounded" > <option value="gpt-4-turbo">GPT-4 Turbo</option> <option value="claude-3-opus">Claude 3 Opus</option> </select> <button onClick={clearChat} className="p-2 text-gray-500 hover:text-gray-700"> クリア </button> </div> </header> {/* メッセージ */} <div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4"> {messages.length === 0 && ( <div className="text-center text-gray-500 mt-8"> <p className="text-lg mb-4">今日は何をお手伝いしましょうか?</p> <div className="flex flex-wrap justify-center gap-2"> {['React Server Componentsを説明', 'コードをデバッグ', '関数を作成'].map((prompt) => ( <button key={prompt} onClick={() => sendQuickMessage(prompt)} className="px-4 py-2 bg-gray-100 rounded-full hover:bg-gray-200 text-sm" > {prompt} </button> ))} </div> </div> )} {messages.map((message) => ( <MessageBubble key={message.id} message={message} /> ))} {isLoading && ( <div className="flex items-center gap-2 text-gray-500"> <div className="animate-pulse">●</div> <span>AIが考え中...</span> <button onClick={stop} className="text-red-500 text-sm"> 停止 </button> </div> )} {error && ( <div className="p-4 bg-red-50 text-red-600 rounded-lg"> <p>エラー: {error.message}</p> <button onClick={retryLast} className="text-sm underline"> 再試行 </button> </div> )} </div> {/* 入力 */} <form onSubmit={handleSubmit} className="p-4 border-t"> <div className="flex gap-2"> <input value={input} onChange={handleInputChange} placeholder="メッセージを入力..." className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={isLoading} /> <button type="submit" disabled={isLoading || !input.trim()} className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors" > 送信 </button> </div> </form> </div> ); } function MessageBubble({ message }: { message: Message }) { const isUser = message.role === 'user'; return ( <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}> <div className={`max-w-[80%] p-4 rounded-2xl ${ isUser ? 'bg-blue-500 text-white rounded-br-md' : 'bg-gray-100 text-gray-900 rounded-bl-md' }`} > <p className="whitespace-pre-wrap">{message.content}</p> </div> </div> ); }
ツール呼び出し:AI機能の拡張
現代のLLMの最も強力な機能の1つがツール呼び出し(関数呼び出し)です。AI SDKはこれをシームレスにします:
ツールの定義
// src/lib/ai/tools.ts import { z } from 'zod'; import { tool } from 'ai'; export const weatherTool = tool({ description: '場所の現在の天気を取得します', parameters: z.object({ location: z.string().describe('都市名、例:「東京」'), unit: z.enum(['celsius', 'fahrenheit']).default('celsius'), }), execute: async ({ location, unit }) => { // 本番では実際の天気APIを呼び出す const temp = Math.floor(Math.random() * 30) + 5; return { location, temperature: temp, unit, condition: '曇り時々晴れ', }; }, }); export const searchTool = tool({ description: 'ウェブで最新情報を検索します', parameters: z.object({ query: z.string().describe('検索クエリ'), }), execute: async ({ query }) => { // 本番では検索APIを使用 return { results: [ { title: `${query}の検索結果`, url: 'https://example.com' }, ], }; }, }); export const calculatorTool = tool({ description: '数学計算を実行します', parameters: z.object({ expression: z.string().describe('数式、例:「2 + 2 * 3」'), }), execute: async ({ expression }) => { try { // 警告:evalは本番では危険 - 適切な数式パーサーを使用 const result = Function(`"use strict"; return (${expression})`)(); return { expression, result }; } catch { return { expression, error: '無効な式' }; } }, });
APIルートでツールを使用
// src/app/api/chat/route.ts import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; import { weatherTool, searchTool, calculatorTool } from '@/lib/ai/tools'; export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ model: openai('gpt-4-turbo'), messages, tools: { weather: weatherTool, search: searchTool, calculator: calculatorTool, }, // 連続した複数のツール呼び出しを許可 maxSteps: 5, }); return result.toDataStreamResponse(); }
構造化出力:generateObjectとuseObject
AIがテキストではなく構造化されたデータを返す必要がある場合があります:
サーバーサイド構造化生成
// src/app/api/analyze/route.ts import { openai } from '@ai-sdk/openai'; import { generateObject } from 'ai'; import { z } from 'zod'; const sentimentSchema = z.object({ sentiment: z.enum(['positive', 'negative', 'neutral']), confidence: z.number().min(0).max(1), keywords: z.array(z.string()), summary: z.string(), }); export async function POST(req: Request) { const { text } = await req.json(); const { object } = await generateObject({ model: openai('gpt-4-turbo'), schema: sentimentSchema, prompt: `このテキストの感情を分析してください:「${text}」`, }); return Response.json(object); }
プロダクションパターン:レート制限、キャッシュ、エラーハンドリング
Upstash Redisでレート制限
// src/lib/rate-limit.ts import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, }); export const rateLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, '1 m'), // 1分間に10リクエスト analytics: true, }); // APIルートでの使用 export async function POST(req: Request) { const ip = req.headers.get('x-forwarded-for') ?? 'anonymous'; const { success, limit, reset, remaining } = await rateLimiter.limit(ip); if (!success) { return new Response('レート制限を超過しました', { status: 429, headers: { 'X-RateLimit-Limit': limit.toString(), 'X-RateLimit-Remaining': remaining.toString(), 'X-RateLimit-Reset': reset.toString(), }, }); } // ... チャットロジックを続行 }
マルチモデルルーティング:スマートプロバイダー選択
本番アプリでは、タスクに応じて異なるモデルにリクエストをルーティングしたい場合があります:
// src/lib/ai/router.ts import { openai } from '@ai-sdk/openai'; import { anthropic } from '@ai-sdk/anthropic'; import { google } from '@ai-sdk/google'; import { streamText } from 'ai'; type TaskType = 'code' | 'creative' | 'analysis' | 'general'; function detectTaskType(message: string): TaskType { const codeKeywords = ['コード', '関数', 'デバッグ', '実装', 'typescript', 'python']; const creativeKeywords = ['書く', 'ストーリー', '創作', '想像', '詩']; const analysisKeywords = ['分析', '比較', '評価', '研究', '説明']; const lowerMessage = message.toLowerCase(); if (codeKeywords.some(k => lowerMessage.includes(k))) return 'code'; if (creativeKeywords.some(k => lowerMessage.includes(k))) return 'creative'; if (analysisKeywords.some(k => lowerMessage.includes(k))) return 'analysis'; return 'general'; } function selectModel(taskType: TaskType) { switch (taskType) { case 'code': return anthropic('claude-3-opus-20240229'); // コーディングに最適 case 'creative': return openai('gpt-4-turbo'); // 創作ライティングに優れる case 'analysis': return google('gemini-1.5-pro'); // 長文コンテキスト分析に良い default: return openai('gpt-4-turbo'); // 汎用 } } export async function routedChat(messages: any[]) { const lastUserMessage = messages.filter(m => m.role === 'user').pop(); const taskType = detectTaskType(lastUserMessage?.content ?? ''); const model = selectModel(taskType); console.log(`${taskType}モデルにルーティング`); return streamText({ model, messages, }); }
デプロイ時の考慮事項
Vercelデプロイ
// next.config.js /** @type {import('next').NextConfig} */ const nextConfig = { experimental: { serverComponentsExternalPackages: ['@ai-sdk/openai'], }, }; module.exports = nextConfig;
エッジランタイムの考慮事項
- エッジ関数には25MBのサイズ制限あり
- Node.js APIなし(Web APIを使用)
- 限られた実行時間(Vercelで30秒)
- 低レイテンシAI応答に最適
まとめ
Vercel AI SDKはAIを活用したアプリケーション構築を劇的に簡素化します。カバーした内容:
- コアコンセプト:ストリーミング、プロバイダーアーキテクチャ、フック
- useChatディープダイブ:完全な設定、状態管理、UIパターン
- ツール呼び出し:カスタム関数でAI機能を拡張
- 構造化出力:スキーマで型安全なAI応答
- プロダクションパターン:レート制限、キャッシュ、エラーハンドリング
- マルチモデルルーティング:スマートプロバイダー選択
- テストとデプロイ:本番のベストプラクティス
成功するAI統合の鍵は、正しいツールを使うだけでなく—アプリを信頼性高く、パフォーマンス良く、ユーザーフレンドリーにするパターンを理解することです。
クイックリファレンス
// 基本的なチャット const { messages, input, handleSubmit, isLoading } = useChat(); // オプション付き const chat = useChat({ api: '/api/chat', body: { model: 'gpt-4' }, onFinish: (message) => saveToDb(message), onError: (error) => toast.error(error.message), }); // プログラム制御 chat.append({ role: 'user', content: 'こんにちは' }); chat.reload(); chat.stop(); chat.setMessages([]);
シンプルに始めて、必要に応じて反復し、トークン使用量のモニタリングを忘れずに—お財布が感謝しますよ。
Vercel AI SDKで何かクールなものを作っていますか?エコシステムは急速に成長しており、新しいプロバイダーや機能が定期的にリリースされています。最新の機能に追いつくには、公式ドキュメントとGitHubリリースをチェックしてください。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう