Vercel AI SDK:Next.jsでマルチプロバイダーLLM対応AIストリーミングチャットボットを構築する

AI tutorial - IT technology blog
AI tutorial - IT technology blog

午前2時、チャットボットが文章の途中でレスポンスを落とし始めた。ユーザーは中途半端な回答を目にし、ローディングスピナーは永遠に回り続け、Slackは通知で溢れかえっていた。根本原因は、生のfetch()とServer-Sent Eventsを使って自前でストリーミング実装を構築していたこと——想定外の脆さがあった。

全てを正しく作り直した後、一つ確信したことがある:ストリーミングは基盤だ。ここを間違えると、ユーザーはトークンが落ちるたびに痛みを感じる。

自前ストリーミングの問題点

Next.jsアプリにLLMを初めて組み込むとき、安易なやり方はこうなる:

// 安易なアプローチ — 本番環境ではやらないこと
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行以上続く

これは動く——動かなくなるまでは。SSEチャンクのカスタムパーサーを書き、バックプレッシャーを処理し、接続タイムアウトを管理し、そしてOpenAIをAnthropicに切り替えるたびに全てをやり直すことになる。午前2時の事故が起きる前に、私は3日間これに費やした。

アプローチ比較:AIストリーミングの3つの方法

ツールを決める前に、3つのアプローチを全て負荷テストにかけた。以下が気づいた点だ。

オプション1:生のFetch + 手動SSE解析

最大のコントロール、最大の苦痛。ストリームの全バイトを自分で処理する。学習には向いているが、本番リリースには最悪だ。

オプション2:プロバイダーSDK(OpenAI SDK、Anthropic SDK)

各プロバイダーはストリーミングヘルパー付きの独自SDKを提供している。OpenAI SDKのstream()メソッドは実によく設計されている:

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 ?? '');
}

注意点:OpenAI向けにこれを一度書いたら、次はAnthropicのmessages.stream()向けに全て書き直し、さらにGooglegenerateContentStream()向けにも書き直す。チームがプロバイダーを切り替えることになったとき——必ずそうなる——データ層全体をリファクタリングすることになる。

オプション3:Vercel AI SDK

最初から使っておけばよかったと思うもの。あらゆるプロバイダーに対応した一つのAPI。Next.js App Routerとの深い統合。そして重要なのは——ReactのState管理も担ってくれること。まさにそこが自前実装の崩れるところだ。

メリット・デメリット:正直な評価

Vercel AI SDK

  • メリット: OpenAI・Anthropic・Google・Mistralなどに対応した一つのAPI——設定一つでプロバイダーを切り替え可能
  • メリット: useChatフックがローディング状態・エラーハンドリング・ストリーミング更新を自動管理
  • メリット: ツール呼び出し・構造化出力・マルチステップ推論のビルトインサポート
  • メリット: Next.jsのRoute HandlerとServer Actionに直接統合可能
  • デメリット: 追加の抽象化レイヤー——SDKにバグがあれば、Vercelに頼るしかない
  • デメリット: 一部の高度なプロバイダー固有機能は生のSDKに落とす必要がある

プロバイダーSDKを直接使う

  • メリット: リリース当日からプロバイダー固有の全機能にアクセス可能
  • メリット: 抽象化のオーバーヘッドなし
  • デメリット: プロバイダーを切り替える際に全面的な再実装が必要
  • デメリット: ReactのState管理レイヤーを自前で構築する必要がある

生のFetch

  • メリット: 依存関係ゼロ
  • デメリット: 必ず午前2時の事故が起きる。断言する。

推奨セットアップ

事故後に作り直した際のスタックがこれだ——3回の大きなプロバイダーアップデートを経ても、一度も破壊的変更は起きていない:

  • Next.js 14以降、App Router使用
  • コアストリーミング層にVercel AI SDK(aiパッケージ)
  • プロバイダー固有のアダプターパッケージ(@ai-sdk/openai@ai-sdk/anthropic
  • 全てTypeScript——SDKの型安全性がコンパイル時にプロバイダーAPIの変更を検出する

実装ガイド

ステップ1:依存関係のインストール

npm install ai @ai-sdk/openai @ai-sdk/anthropic

Google Geminiのサポートは別パッケージ:

npm install @ai-sdk/google

ステップ2:APIルートハンドラーの作成

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: '役に立つアシスタントです。簡潔に直接的に答えてください。',
    messages,
  });

  return result.toDataStreamResponse();
}

バックエンドはこれだけ。toDataStreamResponse()が以前80行あったSSEパーサーを置き換えてくれる——チャンキング、バックプレッシャー、接続のクリーンアップを自動で処理する。runtimeフラグについて一点:'edge'に設定すること。設定しないと、Vercelがレスポンス全体を送信前にバッファリングしてしまい、ストリーミング効果が完全に失われる。

ステップ3:チャットUIの構築

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('チャットエラー:', 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">プロバイダーをリアルタイムで切替</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">考え中...</div>
        )}
        {error && (
          <div className="text-red-500 text-sm">エラー: {error.message}</div>
        )}
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="何でも聞いてください..."
          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"
        >
          送信
        </button>
      </form>
    </div>
  );
}

ステップ4:環境変数

# .env.local
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...

両プロバイダーのパッケージがこれらの名前を自動的に検出する。追加の設定は不要だ。

ステップ5:本番デプロイチェックリスト

午前2時の呼び出しから救ってくれるいくつかのポイント:

  • ルートハンドラーにexport const runtime = 'edge'を設定——Vercel上でのストリーミングには必須
  • streamText呼び出しの前にレートリミットを追加(SDKにビルトインのレートリミットはない)
  • messagesの入力を検証・サニタイズする——生のユーザーデータをモデルに直接渡さないこと
  • コスト爆発を防ぐためstreamTextmaxTokens制限を設定する
  • 本番環境のシークレットには.envファイルではなくVercelの環境変数UIを使用する
const result = streamText({
  model,
  messages,
  maxTokens: 1000,  // ハードキャップ — コスト管理に重要
  temperature: 0.7,
});

よくある落とし穴

デプロイ後にストリーミングが壊れたように見える——テキストが長い遅延の後に一度に届く。SDKを責める前に、runtimeを確認しよう。標準のNode.jsランタイム(edgeではない)では、プラットフォームのリバースプロキシがレスポンス全体をバッファリングすることが多い。edgeランタイムに切り替えるか、レスポンスヘッダーにX-Accel-Buffering: noを追加して修正する。

SDKそのものは実際の負荷に耐えられる。本番環境でストリーミングが壊れるとき、ほぼ必ずインフラが原因だ——バッファリングするプロキシ、欠けているヘッダー、誤ったruntimeの設定。edgeから始めれば、午前2時の事故になる前に問題の90%を回避できる。

Share: