午前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()向けに全て書き直し、さらにGoogleのgenerateContentStream()向けにも書き直す。チームがプロバイダーを切り替えることになったとき——必ずそうなる——データ層全体をリファクタリングすることになる。
オプション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の入力を検証・サニタイズする——生のユーザーデータをモデルに直接渡さないこと- コスト爆発を防ぐため
streamTextにmaxTokens制限を設定する - 本番環境のシークレットには
.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%を回避できる。

