WithCodeMedia-1-pc
previous arrowprevious arrow
next arrownext arrow

WithCodeMedia-1-sp
previous arrowprevious arrow
next arrownext arrow

Vercel AI SDKでストリーミングUI実装|Next.js×LLMアプリのUX改善【完全ガイド】

この記事でわかること

  • Vercel AI SDKの概要とストリーミングUIが数十行で実装できる理由
  • SSE(Server-Sent Events)の仕組みとノンストリーミングとの違い
  • Next.js App RouterでのAPIルート・useChat Hookの実装全コード
  • ツール呼び出し(Function Calling)・マルチモーダル・構造化データ生成の実装方法
  • Edge Runtime活用・レート制限・トークン管理など本番環境のベストプラクティス

結論から言うと、Vercel AI SDKを使えばChatGPTのようなストリーミングUIを数十行のコードで実装できます。useChat HookにSSEのパース・状態管理・エラーハンドリングがすべて抽象化されており、OpenAI・Anthropic・Amazon Bedrockなど複数プロバイダーにも対応しています。Claude Sonnet 4のような高性能モデルで10秒以上かかる応答も、ストリーミング実装で「最初の文字が0.3〜1秒で表示される」体験に変わります。


目次

Vercel AI SDKとは|ストリーミングUIが数十行で実装できる理由

Vercel AI SDKは、LLMを活用したWebアプリケーションを簡単に構築するためのオープンソースライブラリです。Server-Sent Events(SSE)を使ったストリーミング処理・React Hooksによる状態管理・複数AIプロバイダーへの対応を提供します。Vercel社が2023年に公開し、2024〜2026年にかけて急速に普及し、Next.js公式ドキュメントでも推奨されています。

従来はストリーミングUIの実装に「SSEのレスポンスを手動でパース」「テキストの増分をstateで管理」「エラー時のリトライロジックを自前で実装」など多くのボイラープレートコードが必要でした。Vercel AI SDKはこれらをすべて内部に抽象化し、useChatという一つのHookで完全なチャットUI機能を提供します。

>【Vercel AI SDKの3つの特徴】

✅ 簡単なストリーミング実装
→ streamText() + toDataStreamResponse() の2行でストリーミング対応完了

✅ 抽象化されたフロントエンド
→ useChat() Hook で送信・受信・履歴管理をすべて管理
→ 自前で状態管理を書く必要がほぼない

✅ マルチプロバイダー対応
→ OpenAI, Anthropic, Google AI, Amazon Bedrock, Mistral etc.
→ プロバイダーを変更してもUIコードはそのまま

ストリーミングUIとノンストリーミングUIの比較

観点ノンストリーミングストリーミング
体感速度全テキスト生成まで待機(10〜30秒)生成開始と同時に表示(0.3〜1秒で最初の文字)
ユーザー体験「固まったのか?」という不安感AIが考えていることがリアルタイムでわかる
実装複雑度シンプル(fetch→JSON解析)Vercel AI SDK使用でシンプルに実現
サーバーリソース応答完了までメモリに保持が必要チャンクを順次送信してメモリ効率が高い
エラーハンドリング一度にエラーが判明ストリーム途中でエラーになる場合がある

ストリーミングの仕組み:Server-Sent Events(SSE)

Vercel AI SDKのストリーミングはHTTPの「Server-Sent Events(SSE)」を使っています。SSEはHTTP接続を維持したままサーバーからクライアントへ一方向にデータをプッシュできるWebAPIです。WebSocketと比べてシンプルで、HTTPの標準機能のみで動作するためCDN・プロキシ・ファイアウォール環境でも安定しています。

>【LLMがストリーミングで送るデータの実際の形式】

f:{"messageId":"msg-OuigLUo9Lxefa0EudVrcsnaz"}
0:"Vercel AI"
0:" SDKは、"
0:"AI機能を"
0:"Webアプリ"
0:"ケーションに"
0:"簡単に統"
0:"合するための"
0:"オープンソース"
0:"ライブラリです"
...
e:{"finishReason":"stop","usage":{"promptTokens":468,"completionTokens":570}}
d:{"finishReason":"stop"}

→ テキストが生成されるたびに小さなチャンクがリアルタイムで送信される
→ ユーザーは生成の過程を文字単位でリアルタイムに確認できる
→ HTTP接続を維持しながらサーバーからクライアントへ一方向に送信(SSE)
→ 先頭の数字はデータの種類を示すプレフィックス(0=テキスト、f=メタデータ、e=完了情報)

環境構築と実装コード全文|Next.js App Router版

1. パッケージのインストール

# Next.js プロジェクトを作成
npx create-next-app@latest ai-streaming-app --typescript --tailwind --app

# Vercel AI SDK と使用するプロバイダーのパッケージをインストール
# OpenAI を使う場合
pnpm add ai @ai-sdk/openai

# Amazon Bedrock を使う場合
pnpm add ai @ai-sdk/amazon-bedrock @aws-sdk/credential-providers

# Anthropic を使う場合
pnpm add ai @ai-sdk/anthropic
# .env.local に APIキーを設定(Gitに含めないこと)
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxx

# Amazon Bedrock を使う場合
AWS_ACCESS_KEY_ID=XXXXXXXXXXXXXXXXXX
AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_REGION=ap-northeast-1

2. バックエンド実装:APIルート(OpenAI版)

>// src/app/api/chat/route.ts

import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

// ストリーミング応答を最大30秒まで許可する(Vercel Edge Functionのタイムアウト)
export const maxDuration = 30

export async function POST(req: Request) {
  const { messages } = await req.json()

  // ストリーミングでテキストを生成
  const result = streamText({
    model: openai('gpt-4o-mini'),
    system: 'あなたは親切なAIアシスタントです。日本語で回答してください。',
    messages,
  })

  // データストリームレスポンスとして返却
  // → Server-Sent Events 形式でクライアントに送信される
  return result.toDataStreamResponse()
}

3. フロントエンド実装:チャットUI(useChat Hook)

>// src/app/page.tsx
'use client'

import { useChat } from '@ai-sdk/react'

export default function Chat() {
  // useChat Hook にストリーミング処理が完全に抽象化されている
  const {
    messages,    // チャット履歴({ id, role, content, parts }[])
    input,       // テキスト入力の現在値
    handleInputChange,  // input の onChange ハンドラ
    handleSubmit,       // フォームの onSubmit ハンドラ
    isLoading,   // 応答待ち中は true
    stop,        // ストリーミングを中断する関数
    reload,      // 最後のメッセージを再生成する関数
    error,       // エラーがあれば Error オブジェクト
  } = useChat({
    api: '/api/chat',  // デフォルト値なので省略可
    onFinish: (message) => {
      console.log('生成完了:', message.content.length, '文字')
    },
    onError: (error) => {
      console.error('エラー発生:', error.message)
    }
  })

  return (
    <div className="flex flex-col w-full max-w-2xl mx-auto h-screen py-8">
      {/* チャット履歴エリア */}
      <div className="flex-1 overflow-y-auto space-y-4 mb-4 px-4">
        {messages.length === 0 && (
          <p className="text-center text-gray-400 mt-20">
            質問を入力してください
          </p>
        )}
        {messages.map(message => (
          <div
            key={message.id}
            className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
          >
            <div className={`max-w-[80%] rounded-2xl px-4 py-3 ${
              message.role === 'user'
                ? 'bg-blue-500 text-white'
                : 'bg-gray-100 text-gray-900'
            }`}>
              {/* テキストパーツを表示(ストリーミング中も随時更新される) */}
              {message.parts?.map((part, i) => {
                if (part.type === 'text') {
                  return (
                    <p key={i} className="whitespace-pre-wrap text-sm">
                      {part.text}
                    </p>
                  )
                }
                return null
              })}
            </div>
          </div>
        ))}

        {/* ローディングインジケーター */}
        {isLoading && (
          <div className="flex justify-start">
            <div className="bg-gray-100 rounded-2xl px-4 py-3">
              <span className="inline-block animate-pulse text-gray-500">▍</span>
            </div>
          </div>
        )}
      </div>

      {/* エラー表示 */}
      {error && (
        <div className="mx-4 mb-2 p-3 bg-red-50 border border-red-200 rounded-lg">
          <p className="text-red-600 text-sm">{error.message}</p>
          <button
            onClick={reload}
            className="mt-2 text-xs text-red-500 underline"
          >
            再試行する
          </button>
        </div>
      )}

      {/* 入力フォーム */}
      <form onSubmit={handleSubmit} className="flex gap-2 px-4">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="メッセージを入力..."
          disabled={isLoading}
          className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-sm
                     focus:outline-none focus:ring-2 focus:ring-blue-500
                     disabled:opacity-50"
        />
        {isLoading ? (
          <button
            type="button"
            onClick={stop}
            className="bg-red-500 text-white rounded-xl px-5 py-3 text-sm font-medium
                       hover:bg-red-600"
          >
            停止
          </button>
        ) : (
          <button
            type="submit"
            disabled={!input.trim()}
            className="bg-blue-500 text-white rounded-xl px-5 py-3 text-sm font-medium
                       hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            送信
          </button>
        )}
      </form>
    </div>
  )
}

ツール呼び出し(Function Calling)の実装

Vercel AI SDKのもっとも強力な機能の一つが「ツール呼び出し」です。LLMが自律的に外部APIや関数を呼び出して情報を取得し、その結果をもとに回答を生成できます。ChatGPTのプラグイン機能に相当する仕組みです。

バックエンド:ツールの定義

>// src/app/api/chat/route.ts
// ツール呼び出しを使った例:天気情報を取得して回答する

import { streamText, tool } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'

export const maxDuration = 30

export async function POST(req: Request) {
  const { messages } = await req.json()

  const result = streamText({
    model: openai('gpt-4o-mini'),
    system: 'あなたはアシスタントです。天気について聞かれたら必ず getWeather ツールを使って回答してください。',
    messages,
    tools: {
      // ツール名: tool() で定義
      getWeather: tool({
        description: '指定した都市の現在の天気情報を取得する',
        parameters: z.object({
          city: z.string().describe('都市名(例: Tokyo, Osaka)'),
          unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
        }),
        // execute 関数で実際の処理を実装(非同期OK)
        execute: async ({ city, unit }) => {
          // 実際はAPIを叩く(ここではモックデータ)
          const mockData = {
            tokyo: { temp: 18, condition: '晴れ', humidity: 55 },
            osaka: { temp: 20, condition: '曇り', humidity: 60 },
          }
          const data = mockData[city.toLowerCase()] ?? { temp: 15, condition: '不明', humidity: 50 }

          return {
            city,
            temperature: unit === 'celsius' ? data.temp : data.temp * 9/5 + 32,
            unit,
            condition: data.condition,
            humidity: data.humidity,
          }
        },
      }),

      // 複数のツールを定義できる
      searchWeb: tool({
        description: 'Webを検索して最新情報を取得する',
        parameters: z.object({
          query: z.string().describe('検索クエリ'),
        }),
        execute: async ({ query }) => {
          // 実際はBing Search API等を呼び出す
          return { results: `「${query}」の検索結果...` }
        },
      }),
    },
    maxSteps: 5, // ツール呼び出しの最大ステップ数(連鎖的な呼び出しに対応)
  })

  return result.toDataStreamResponse()
}

フロントエンド:ツール実行結果の表示

>// src/app/page.tsx
// ツール呼び出しの結果を表示する

'use client'
import { useChat } from '@ai-sdk/react'

export default function ChatWithTools() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()

  return (
    <div className="max-w-2xl mx-auto p-4">
      <div className="space-y-4 mb-4">
        {messages.map(message => (
          <div key={message.id} className="space-y-2">
            <div className={`font-semibold text-sm ${
              message.role === 'user' ? 'text-blue-600' : 'text-gray-600'
            }`}>
              {message.role === 'user' ? 'あなた' : 'AI'}
            </div>

            {/* メッセージのパーツを種類ごとに表示 */}
            {message.parts?.map((part, i) => {
              switch (part.type) {
                case 'text':
                  return <p key={i} className="text-gray-800">{part.text}</p>

                case 'tool-invocation':
                  // ツール呼び出し中 / 完了を表示
                  return (
                    <div key={i} className="bg-blue-50 border border-blue-200 rounded-lg p-3">
                      <div className="text-xs font-medium text-blue-700 mb-1">
                        🔧 ツール: {part.toolInvocation.toolName}
                      </div>
                      {part.toolInvocation.state === 'call' && (
                        <p className="text-xs text-blue-500">実行中...</p>
                      )}
                      {part.toolInvocation.state === 'result' && (
                        <pre className="text-xs text-blue-800 overflow-auto">
                          {JSON.stringify(part.toolInvocation.result, null, 2)}
                        </pre>
                      )}
                    </div>
                  )

                default:
                  return null
              }
            })}
          </div>
        ))}
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="「東京の天気は?」など試してみてください"
          className="flex-1 border rounded-lg px-4 py-2"
        />
        <button type="submit" disabled={isLoading} className="bg-blue-500 text-white px-4 py-2 rounded-lg">
          送信
        </button>
      </form>
    </div>
  )
}

マルチモーダル対応|画像入力とVisionモデルの活用

GPT-4o・Claude 3.5 Sonnet・Google Geminiなどのマルチモーダルモデルに画像を渡して分析させることも、Vercel AI SDKを使えば簡単に実装できます。

バックエンド:画像入力の処理

>// src/app/api/vision/route.ts

import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

export const maxDuration = 30

export async function POST(req: Request) {
  const formData = await req.formData()
  const image = formData.get('image') as File
  const question = formData.get('question') as string

  // File を ArrayBuffer に変換
  const imageBytes = await image.arrayBuffer()
  const imageBase64 = Buffer.from(imageBytes).toString('base64')

  const result = streamText({
    model: openai('gpt-4o'),  // Vision対応モデルを使用
    messages: [
      {
        role: 'user',
        content: [
          {
            type: 'image',
            image: imageBase64,  // Base64エンコードされた画像
            mimeType: image.type as 'image/jpeg' | 'image/png' | 'image/webp',
          },
          {
            type: 'text',
            text: question || 'この画像について詳しく説明してください。',
          },
        ],
      },
    ],
  })

  return result.toDataStreamResponse()
}

フロントエンド:画像アップロードUI

>// src/app/vision/page.tsx
'use client'

import { useState, useRef } from 'react'
import { useCompletion } from '@ai-sdk/react'

export default function VisionPage() {
  const [imagePreview, setImagePreview] = useState<string | null>(null)
  const [question, setQuestion] = useState('')
  const fileInputRef = useRef<HTMLInputElement>(null)

  const { completion, isLoading, complete } = useCompletion({
    api: '/api/vision',
  })

  const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    const reader = new FileReader()
    reader.onload = () => setImagePreview(reader.result as string)
    reader.readAsDataURL(file)
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    const file = fileInputRef.current?.files?.[0]
    if (!file) return

    const formData = new FormData()
    formData.append('image', file)
    formData.append('question', question)

    await complete('', {
      body: formData,
      headers: {}, // Content-TypeはブラウザがFormDataを自動設定
    })
  }

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">画像分析AI</h1>

      <form onSubmit={handleSubmit} className="space-y-4">
        <div className="border-2 border-dashed border-gray-300 rounded-xl p-6 text-center">
          <input
            ref={fileInputRef}
            type="file"
            accept="image/*"
            onChange={handleImageChange}
            className="hidden"
            id="image-upload"
          />
          <label htmlFor="image-upload" className="cursor-pointer">
            {imagePreview ? (
              <img src={imagePreview} alt="アップロード画像" className="max-h-48 mx-auto rounded-lg" />
            ) : (
              <div className="text-gray-400">
                <p className="text-4xl mb-2">📷</p>
                <p>クリックして画像を選択</p>
              </div>
            )}
          </label>
        </div>

        <input
          value={question}
          onChange={(e) => setQuestion(e.target.value)}
          placeholder="画像について質問してください(例:この料理のカロリーは?)"
          className="w-full border rounded-xl px-4 py-3"
        />

        <button
          type="submit"
          disabled={!imagePreview || isLoading}
          className="w-full bg-blue-500 text-white rounded-xl py-3 font-medium disabled:opacity-50"
        >
          {isLoading ? '分析中...' : '画像を分析する'}
        </button>
      </form>

      {completion && (
        <div className="mt-6 p-4 bg-gray-50 rounded-xl">
          <h2 className="font-semibold mb-2">分析結果:</h2>
          <p className="whitespace-pre-wrap text-gray-700">{completion}</p>
        </div>
      )}
    </div>
  )
}

Vercel AI SDKの便利機能3選

機能1:構造化データの生成(Zodスキーマ指定)

generateObjectを使うと、LLMの出力をZodスキーマで型定義したJSONとして受け取れます。「記事のメタデータ生成」「フォームの自動入力」「データ抽出」などのユースケースで特に有効です。

>// 出力結果の構造をZodで定義してJSON形式で取得できる
import { generateObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'

const result = await generateObject({
  model: openai('gpt-4o-mini'),
  schema: z.object({
    title: z.string().describe('記事タイトル'),
    summary: z.string().describe('100文字以内の要約'),
    tags: z.array(z.string()).max(5).describe('関連タグ(最大5つ)'),
    difficulty: z.enum(['beginner', 'intermediate', 'advanced']).describe('難易度'),
    readingTime: z.number().describe('読了時間(分)'),
  }),
  prompt: '次の技術記事についてメタデータを生成してください:「TypeScriptの型推論入門」',
})

console.log(result.object)
// → { title: 'TypeScriptの型推論入門', summary: '...', tags: ['TypeScript', ...], difficulty: 'beginner', readingTime: 8 }

機能2:自動リトライ処理

>// LLMの処理は様々な理由で失敗することがある
// maxRetries でリトライ回数を設定できる
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'

const result = await generateText({
  model: openai('gpt-4o-mini'),
  maxRetries: 3,  // 失敗時に最大3回自動リトライ(指数バックオフ)
  prompt: '日本の首都はどこですか?',
})

console.log(result.text)
// → リトライ時のログ: Retrying after 1s / 2s / 4s...
// → 最終的な成功結果を返す

機能3:複数プロバイダーの切り替え

>// プロバイダーを変えてもUIコードはそのまま
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { anthropic } from '@ai-sdk/anthropic'
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'

// 環境変数でプロバイダーを切り替える
const getModel = () => {
  switch (process.env.AI_PROVIDER) {
    case 'anthropic':
      return anthropic('claude-3-5-sonnet-20241022')
    case 'bedrock':
      const bedrock = createAmazonBedrock({ region: 'ap-northeast-1' })
      return bedrock('apac.anthropic.claude-sonnet-4-20250514-v1:0')
    default:
      return openai('gpt-4o-mini')
  }
}

export async function POST(req: Request) {
  const { messages } = await req.json()
  const result = streamText({ model: getModel(), messages })
  return result.toDataStreamResponse()
}

パフォーマンス最適化とベストプラクティス

Edge Runtimeでのレイテンシ削減

Vercel Edge Functionsを使うことで、ユーザーの地理的に最も近いサーバーでAI APIリクエストを処理できます。日本ユーザーが東京リージョンのEdgeから呼び出すことで、米国経由のリクエストと比べてレイテンシを30〜50%削減できる場合があります。

>// src/app/api/chat/route.ts
// Edge Runtimeを有効にする

export const runtime = 'edge'
export const maxDuration = 30

import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

export async function POST(req: Request) {
  const { messages } = await req.json()
  const result = streamText({
    model: openai('gpt-4o-mini'),
    messages,
  })
  return result.toDataStreamResponse()
}

// ⚠️ Edge Runtimeではfetchなど一部のNode.js APIが使えないので注意
// ⚠️ データベースアクセスにはEdge対応のクライアント(Neon HTTP driver等)が必要

システムプロンプトの最適化

システムプロンプトは毎回のリクエストでトークンを消費します。長すぎるシステムプロンプトはコストと遅延の両方に影響します。一般的なベストプラクティスとして、200〜500トークン(日本語で400〜1000文字)程度に収めることを推奨します。

>// システムプロンプトの最適化例

// ❌ 悪い例:長すぎて毎回トークンを無駄遣い
const badSystem = `
あなたはWithCodeというプログラミングスクールのAIアシスタントです。
WithCodeは2018年に設立され、東京を拠点に...(200行続く)
`

// ✅ 良い例:要点を絞った簡潔なプロンプト
const goodSystem = `
あなたはWithCode(プログラミングスクール)のAIアシスタントです。
主な業務:受講相談、カリキュラム説明、技術的なQ&A対応。
回答は200文字以内で簡潔に。専門用語には必ず説明を付ける。
`

会話履歴のトークン管理(スライディングウィンドウ)

チャットの会話が長くなるにつれ、渡すメッセージ履歴のトークン数が増加します。コストと速度の観点から、一定件数以上の古いメッセージを切り捨てる「スライディングウィンドウ」戦略が有効です。

>// メッセージ履歴のトークン数を制限するユーティリティ

function trimMessageHistory(
  messages: Message[],
  maxMessages: number = 20
): Message[] {
  if (messages.length <= maxMessages) return messages

  // 最初のシステムメッセージは常に保持
  const systemMessages = messages.filter(m => m.role === 'system')
  const conversationMessages = messages.filter(m => m.role !== 'system')

  // 最新N件のみ保持
  const trimmed = conversationMessages.slice(-maxMessages)

  return [...systemMessages, ...trimmed]
}

// APIルートで使用
export async function POST(req: Request) {
  const { messages } = await req.json()
  const trimmedMessages = trimMessageHistory(messages, 20)

  const result = streamText({
    model: openai('gpt-4o-mini'),
    messages: trimmedMessages,
  })

  return result.toDataStreamResponse()
}

デプロイと本番環境の設定

Vercelへのデプロイ

# Vercel CLIでデプロイ
npm i -g vercel
vercel

# 環境変数を設定(Vercelダッシュボード or CLIで)
vercel env add OPENAI_API_KEY production

# プレビューデプロイ(develop/featureブランチ)
vercel --env OPENAI_API_KEY=sk-xxx

# 本番デプロイ
vercel --prod

レート制限の実装(本番環境必須)

本番環境では悪意あるユーザーによる大量リクエスト(DoS攻撃・コスト爆発)を防ぐレート制限が必須です。Vercel KV(Redis互換)を使ったシンプルな実装例を示します。

>// src/app/api/chat/route.ts
// レート制限付きAPIルート

import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { kv } from '@vercel/kv'
import { headers } from 'next/headers'

const RATE_LIMIT = 20  // 1分あたりの最大リクエスト数
const RATE_LIMIT_WINDOW = 60  // ウィンドウサイズ(秒)

export async function POST(req: Request) {
  // IPアドレスでレート制限
  const headersList = await headers()
  const ip = headersList.get('x-forwarded-for') ?? 'unknown'
  const key = `rate_limit:${ip}`

  const count = await kv.incr(key)
  if (count === 1) {
    await kv.expire(key, RATE_LIMIT_WINDOW)
  }

  if (count > RATE_LIMIT) {
    return new Response(
      JSON.stringify({ error: 'Too Many Requests' }),
      {
        status: 429,
        headers: {
          'Content-Type': 'application/json',
          'Retry-After': String(RATE_LIMIT_WINDOW),
        },
      }
    )
  }

  const { messages } = await req.json()
  const result = streamText({
    model: openai('gpt-4o-mini'),
    messages,
  })

  return result.toDataStreamResponse()
}

よくある質問

ストリーミング中にエラーが起きた場合どうなりますか?

useChaterrorプロパティにエラーオブジェクトが格納されます。onErrorコールバックを設定することでエラー発生時のログ送信・ユーザーへの通知処理を記述できます。maxRetriesで自動リトライを設定することで一時的なネットワーク障害への耐性を高められます。reload()関数で最後のメッセージを再送信することも可能です。

Vercel以外(AWS・GCP・Renderなど)にデプロイできますか?

できます。Vercel AI SDKはVercelプラットフォームに依存していません。ただしexport const maxDuration = 30はVercel Edge Functionの設定であり、他の環境ではサーバーのタイムアウト設定を別途調整する必要があります。AWSのApp Runner・ECS・LambdaやGCPのCloud Runでも問題なく動作します。

チャット履歴をデータベースに保存するにはどうすればいいですか?

useChatonFinishコールバックで会話終了時にAPIを叩いてDBに保存するのが標準的なパターンです。initialMessagesプロパティでDBから取得した過去の履歴を初期値として渡すことで会話履歴の復元も可能です。

>const { messages } = useChat({
  initialMessages: await fetchMessagesFromDB(sessionId), // 過去の履歴を初期値として渡す
  onFinish: async (message) => {
    // 会話完了時にDBへ保存
    await fetch('/api/save-message', {
      method: 'POST',
      body: JSON.stringify({ sessionId, message }),
    })
  },
})

useChat と useCompletion の違いは何ですか?

useChatはチャット形式(複数ターンのやり取り・会話履歴管理)に特化したHookです。useCompletionは単発のテキスト生成(1つのプロンプトに対して1つの補完)に特化しています。「翻訳ツール」「要約ツール」「コード生成」などシングルターンのユースケースにはuseCompletionが適しています。

ストリーミングを途中で停止できますか?

できます。useChatが返すstop()関数を呼ぶことでストリーミングをいつでも中断できます。中断した場合それまでに受信したテキストはmessagesに残ります。isLoadingに応じて「送信ボタン」と「停止ボタン」を切り替えるUIパターンが一般的です。


まとめ

  • Vercel AI SDK:streamText + toDataStreamResponse でサーバー側のストリーミングが2行で完成。ノンストリーミングと比べてユーザー体験が大幅に向上する
  • useChat Hook:送信・受信・ローディング・エラー・履歴管理・ストリーミング停止(stop)・再試行(reload)をすべて抽象化。状態管理コードがほぼ不要
  • ツール呼び出し:tool()関数とZodスキーマでLLMが自律的に外部APIを呼び出すエージェント機能を実装できる。maxStepsで連鎖的な呼び出しも制御可能
  • マルチモーダル:GPT-4o等のVisionモデルに画像を渡して分析するUIもformDataとArrayBufferを使って簡単に実装できる
  • マルチプロバイダー:OpenAI / Anthropic / Amazon Bedrock等をほぼ同じAPIで切り替え可能。本番環境では環境変数でプロバイダーを制御できる
  • 本番環境対策:Edge Runtimeによるレイテンシ削減・Vercel KVを使ったレート制限・スライディングウィンドウでコスト管理が可能

Vercel AI SDKを使えば、LLMの応答待ち体験を根本から改善するストリーミングUIが数十行のコードで実装できます。ツール呼び出し・マルチモーダル・マルチプロバイダーなど高度な機能も標準で提供されており、Next.jsとTypeScriptの基礎があれば今日から始められます。


関連記事


参考リンク(公式・一次情報)


WithCodeを体験できる初級コース公開中!

WithCodeを体験できる初級コース公開中!

初級コース(¥49,800)が完全無料に!

  • 期間:1週間
  • 学習内容:
    ロードマップ/基礎知識/環境構築/HTML/CSS/LP・ポートフォリオ作成
    正しい学習方法で「確かな成長」を実感できるカリキュラム。

副業・フリーランスが主流になっている今こそ、自らのスキルで稼げる人材を目指してみませんか?

未経験でも心配することはありません。初級コースを受講される方の大多数はプログラミング未経験です。まずは無料カウンセリングで、悩みや不安をお聞かせください!

この記事を書いた人

WithCode(ウィズコード)は「目指すなら稼げる人材」をビジョンに、累計400名以上のフリーランスを輩出してきた超実践型プログラミングスクールです。150社以上の実案件支援を特徴にWeb制作・Webデザインなどの役立つ情報を現場のノウハウに基づいて発信していきます。

– service –WithGroupの運営サービス

  • WithCode
    - ウィズコード -

    スクール

    「未経験」から
    現場で通用する
    スキルを身に付けよう!

    詳細はこちら
  • WithFree
    - ウィズフリ -

    実案件サポート

    制作会社のサポート下で
    実務経験を積んでいこう!

    詳細はこちら

公式サイト より
今すぐ
無料カウンセリング
予約!

目次