Next.js 15ストリーミングAIチャット:SSEからReact Server Actionsまで6つのプロダクションパターン
AIチャットがいつも遅く感じる理由
AIチャットページを開き、質問を入力し、空白の応答ボックスを8秒間見つめる——LLMがようやくテキストの塊を出力するが、ユーザーは既にページがクラッシュしたと思っている。従来のリクエスト・レスポンスモデルでは、体感遅延 = ネットワーク遅延 + LLM初回トークン時間 + 全生成時間となり、「フリーズ」のように感じる。ストリーミングは待ち時間を個々のトークンに分割して段階的にプッシュする——ユーザーは0.5秒以内に最初の文字を見ることができ、体感遅延を80%削減する。
Next.js 15は、低レベルSSEから高レベルReact Server Actionsまで、ストリーミングAIチャットのための完全なツールチェーンを提供する。本記事ではSSEストリーミングレスポンス→React Server Actions + AI→Vercel AI SDK統合→マルチモデルルーティングとフォールバック→会話状態とコンテキスト管理→プロダクションデプロイとパフォーマンス最適化の6つのプロダクションパターンを解説する。
主要ポイント
- SSEはLLMストリーミング出力に最適な転送プロトコル、Next.js Route Handlerがネイティブサポート
- React Server ActionsでAPIエンドポイントの手書き不要、型安全・ゼロボイラープレート
- Vercel AI SDKがOpenAI/Anthropic/Googleなど複数LLMのストリーミングインターフェースを統一
- マルチモデルルーティングでコスト最適化と障害フォールバックを実現——GPT-4oがダウンしたら自動でClaudeに切り替え
- 会話状態管理は短期コンテキストと長期記憶を区別し、トークン爆発を防ぐ必要がある
- プロダクションデプロイでは同時接続数、タイムアウト、バックプレッシャー、エラー回復を処理
目次
- ストリーミングAIチャットアーキテクチャ概要
- パターン1: SSEストリーミングレスポンス実装
- パターン2: React Server Actions + AI
- パターン3: Vercel AI SDK統合
- パターン4: マルチモデルルーティングとフォールバック
- パターン5: 会話状態とコンテキスト管理
- パターン6: プロダクションデプロイとパフォーマンス最適化
- 5つのよくある落とし穴と解決策
- 10のよくあるエラートラブルシューティング
- 高度な最適化テクニック
- 比較分析:SSE vs WebSocket vs ロングポーリング
- おすすめオンラインツール
- まとめ
ストリーミングAIチャットアーキテクチャ概要
┌─────────────────────────────────────────────────────────────┐
│ Next.js 15 AI Chat アーキテクチャ │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ SSE/Stream ┌──────────────────────┐ │
│ │ Client │ ◄────────────── │ Route Handler / │ │
│ │ Chat UI │ │ Server Action │ │
│ │ │ ──────────────► │ │ │
│ └──────────┘ POSTリクエスト └──────────┬───────────┘ │
│ │ │
│ ┌─────────────┼──────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────┐ │
│ │ OpenAI │ │ Anthropic│ │ ローカル│ │
│ │ GPT-4o │ │ Claude │ │ Ollama│ │
│ └──────────┘ └──────────┘ └──────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 会話状態レイヤー (Conversation State) │ │
│ │ ┌─────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ Context │ │ History │ │ Long-term Memory │ │ │
│ │ │ Window │ │ Store │ │ (Vector DB) │ │ │
│ │ └─────────┘ └──────────┘ └──────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
技術選定デシジョンツリー
ストリーミングAIチャットが必要?
├── クイックプロトタイプ → Vercel AI SDK (パターン3)
├── 完全制御 → SSE + Route Handler (パターン1)
├── 型安全優先 → React Server Actions (パターン2)
├── マルチモデル要件 → マルチモデルルーティング (パターン4)
└── エンタープライズ本番 → 全組合せ + 状態管理 (パターン5+6)
パターン1: SSEストリーミングレスポンス実装
SSE(Server-Sent Events)はLLMストリーミング出力の標準転送プロトコル。Next.js 15のRoute HandlerはReadableStreamをネイティブサポートし、SSE形式のストリーミングレスポンスを直接返せる。
基本的なSSE Route Handler
// app/api/chat/sse/route.ts
import { NextRequest } from 'next/server';
export const runtime = 'edge';
export const maxDuration = 60;
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export async function POST(request: NextRequest) {
const { messages }: { messages: ChatMessage[] } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o',
messages,
stream: true,
}),
});
if (!response.ok) {
const errorData = await response.text();
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ error: errorData })}\n\n`)
);
controller.close();
return;
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter((line) => line.startsWith('data: '));
for (const line of lines) {
const data = line.slice(6);
if (data === '[DONE]') {
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
continue;
}
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ content })}\n\n`)
);
}
} catch {
// skip malformed chunks
}
}
}
controller.close();
} catch (error) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ error: String(error) })}\n\n`)
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
クライアント側SSEコンシューマー
// components/ChatSSE.tsx
'use client';
import { useState, useRef, useCallback } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
}
export default function ChatSSE() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async () => {
if (!input.trim() || isStreaming) return;
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: input.trim(),
};
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setInput('');
setIsStreaming(true);
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const response = await fetch('/api/chat/sse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [...messages, userMessage].map((m) => ({
role: m.role,
content: m.content,
})),
}),
signal: abortController.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter((line) => line.startsWith('data: '));
for (const line of lines) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
console.error('SSE error:', parsed.error);
continue;
}
if (parsed.content) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: m.content + parsed.content }
: m
)
);
}
} catch {
// skip malformed data
}
}
}
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('Stream error:', error);
}
} finally {
setIsStreaming(false);
abortControllerRef.current = null;
}
}, [input, messages, isStreaming]);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
}, []);
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto p-4">
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((msg) => (
<div
key={msg.id}
className={`p-3 rounded-lg ${
msg.role === 'user'
? 'bg-blue-600 text-white ml-auto max-w-[80%]'
: 'bg-gray-100 text-gray-900 mr-auto max-w-[80%]'
}`}
>
<pre className="whitespace-pre-wrap font-sans text-sm">{msg.content}</pre>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
placeholder="メッセージを入力..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isStreaming}
/>
<button
onClick={isStreaming ? stopStreaming : sendMessage}
className={`px-4 py-2 rounded-lg font-medium ${
isStreaming
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{isStreaming ? '停止' : '送信'}
</button>
</div>
</div>
);
}
パターン2: React Server Actions + AI
React Server ActionsでAPIエンドポイントの手書きが不要になる。Server Component内で非同期関数を定義し、クライアントからuseActionStateで呼び出す——型安全、ゼロボイラープレート。
ストリーミングServer Action
// app/actions/streaming-chat-action.ts
'use server';
import { createStreamableValue } from 'ai/rsc';
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function streamingChatAction(messages: Array<{ role: 'user' | 'assistant'; content: string }>) {
const streamableValue = createStreamableValue('');
(async () => {
try {
const result = await streamText({
model: openai('gpt-4o'),
messages,
});
for await (const chunk of result.textStream) {
streamableValue.update(chunk);
}
streamableValue.done();
} catch (error) {
streamableValue.error(String(error));
}
})();
return streamableValue.value;
}
クライアント側ストリーミングServer Actionコンシューマー
// components/ChatServerAction.tsx
'use client';
import { readStreamableValue } from 'ai/rsc';
import { streamingChatAction } from '@/app/actions/streaming-chat-action';
import { useState, useCallback } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
}
export default function ChatServerAction() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const handleSubmit = useCallback(async () => {
if (!input.trim() || isStreaming) return;
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: input.trim(),
};
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
};
const updatedMessages = [...messages, userMessage];
setMessages([...updatedMessages, assistantMessage]);
setInput('');
setIsStreaming(true);
try {
const streamValue = await streamingChatAction(
updatedMessages.map((m) => ({ role: m.role, content: m.content }))
);
for await (const chunk of readStreamableValue(streamValue)) {
if (chunk) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: m.content + chunk }
: m
)
);
}
}
} catch (error) {
console.error('Server Action error:', error);
} finally {
setIsStreaming(false);
}
}, [input, messages, isStreaming]);
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto p-4">
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((msg) => (
<div
key={msg.id}
className={`p-3 rounded-lg ${
msg.role === 'user'
? 'bg-blue-600 text-white ml-auto max-w-[80%]'
: 'bg-gray-100 text-gray-900 mr-auto max-w-[80%]'
}`}
>
<pre className="whitespace-pre-wrap font-sans text-sm">{msg.content}</pre>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSubmit()}
placeholder="メッセージを入力..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isStreaming}
/>
<button
onClick={handleSubmit}
disabled={isStreaming}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium disabled:opacity-50"
>
{isStreaming ? '生成中...' : '送信'}
</button>
</div>
</div>
);
}
パターン3: Vercel AI SDK統合
Vercel AI SDK(aiパッケージ)はNext.jsストリーミングAIチャットの公式推奨アプローチ。複数LLMプロバイダーのストリーミングインターフェースを統一し、useChatなどのすぐ使えるフックを提供する。
インストールと設定
npm install ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google
Route Handler(AI SDK版)
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
export const runtime = 'edge';
export const maxDuration = 60;
export async function POST(request: Request) {
const { messages } = await request.json();
const result = streamText({
model: openai('gpt-4o'),
system: 'あなたは役立つAIアシスタントです。簡潔で正確に回答してください。',
messages,
maxTokens: 4096,
temperature: 0.7,
});
return result.toDataStreamResponse();
}
useChatフック(最小実装)
// components/ChatAISDK.tsx
'use client';
import { useChat } from '@ai-sdk/react';
export default function ChatAISDK() {
const { messages, input, handleInputChange, handleSubmit, isLoading, stop } =
useChat({
api: '/api/chat',
onError: (error) => {
console.error('Chat error:', error);
},
onFinish: (message) => {
console.log('Finished:', message.content.length, 'chars');
},
});
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto p-4">
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((msg) => (
<div
key={msg.id}
className={`p-3 rounded-lg ${
msg.role === 'user'
? 'bg-blue-600 text-white ml-auto max-w-[80%]'
: 'bg-gray-100 text-gray-900 mr-auto max-w-[80%]'
}`}
>
<div className="whitespace-pre-wrap text-sm">{msg.content}</div>
</div>
))}
{isLoading && messages[messages.length - 1]?.role === 'user' && (
<div className="bg-gray-100 text-gray-900 mr-auto max-w-[80%] p-3 rounded-lg">
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-pulse">●</div>
<div className="animate-pulse delay-75">●</div>
<div className="animate-pulse delay-150">●</div>
</div>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="メッセージを入力..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type={isLoading ? 'button' : 'submit'}
onClick={isLoading ? stop : undefined}
className={`px-4 py-2 rounded-lg font-medium ${
isLoading
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{isLoading ? '停止' : '送信'}
</button>
</form>
</div>
);
}
パターン4: マルチモデルルーティングとフォールバック
本番環境では単一のLLMプロバイダーに依存できない。マルチモデルルーティングでコスト最適化と障害フォールバックを実現——GPT-4oがダウンしたら自動でClaudeに切り替え、簡単な質問にはGPT-4o-miniでコスト削減。
モデルルーター
// lib/ai/model-router.ts
import { LanguageModelV1 } from 'ai';
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
type ModelTier = 'fast' | 'standard' | 'premium';
interface ModelConfig {
model: LanguageModelV1;
name: string;
tier: ModelTier;
maxRetries: number;
timeoutMs: number;
costPer1kTokens: number;
}
const MODEL_REGISTRY: Record<ModelTier, ModelConfig[]> = {
fast: [
{
model: openai('gpt-4o-mini'),
name: 'gpt-4o-mini',
tier: 'fast',
maxRetries: 2,
timeoutMs: 15000,
costPer1kTokens: 0.00015,
},
{
model: anthropic('claude-3-5-haiku-2024-10-22'),
name: 'claude-3.5-haiku',
tier: 'fast',
maxRetries: 2,
timeoutMs: 15000,
costPer1kTokens: 0.00025,
},
],
standard: [
{
model: openai('gpt-4o'),
name: 'gpt-4o',
tier: 'standard',
maxRetries: 2,
timeoutMs: 30000,
costPer1kTokens: 0.005,
},
{
model: anthropic('claude-sonnet-4-20250514'),
name: 'claude-sonnet-4',
tier: 'standard',
maxRetries: 2,
timeoutMs: 30000,
costPer1kTokens: 0.003,
},
],
premium: [
{
model: openai('o3'),
name: 'o3',
tier: 'premium',
maxRetries: 1,
timeoutMs: 60000,
costPer1kTokens: 0.03,
},
{
model: anthropic('claude-opus-4-20250514'),
name: 'claude-opus-4',
tier: 'premium',
maxRetries: 1,
timeoutMs: 60000,
costPer1kTokens: 0.015,
},
],
};
interface RouteDecision {
model: LanguageModelV1;
modelName: string;
tier: ModelTier;
fallbackChain: string[];
}
export function routeModel(
complexity: 'simple' | 'medium' | 'complex',
preferredProvider?: 'openai' | 'anthropic'
): RouteDecision {
const tierMap: Record<string, ModelTier> = {
simple: 'fast',
medium: 'standard',
complex: 'premium',
};
const tier = tierMap[complexity];
const models = MODEL_REGISTRY[tier];
const preferred = preferredProvider
? models.find((m) => m.name.startsWith(preferredProvider))
: models[0];
const selected = preferred || models[0];
const fallbackChain = models
.filter((m) => m.name !== selected.name)
.map((m) => m.name);
return {
model: selected.model,
modelName: selected.name,
tier,
fallbackChain,
};
}
export { MODEL_REGISTRY };
export type { ModelConfig, ModelTier };
フォールバック付きストリーミングRoute Handler
// app/api/chat/routed/route.ts
import { streamText } from 'ai';
import { routeModel, MODEL_REGISTRY } from '@/lib/ai/model-router';
import { NextRequest } from 'next/server';
export const runtime = 'edge';
export const maxDuration = 60;
export async function POST(request: NextRequest) {
const body = await request.json();
const { messages, complexity = 'medium', provider } = body;
const route = routeModel(complexity, provider);
try {
const result = streamText({
model: route.model,
system: 'あなたは役立つAIアシスタントです。',
messages,
maxTokens: 4096,
abortSignal: request.signal,
});
return result.toDataStreamResponse({
headers: {
'X-Model-Name': route.modelName,
'X-Model-Tier': route.tier,
'X-Fallback-Chain': route.fallbackChain.join(','),
},
});
} catch (error) {
const fallbackModelName = route.fallbackChain[0];
const allModels = Object.values(MODEL_REGISTRY).flat();
const fallback = allModels.find((m) => m.name === fallbackModelName);
if (!fallback) {
return new Response(
JSON.stringify({ error: '全モデルが利用不可' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
const result = streamText({
model: fallback.model,
system: 'あなたは役立つAIアシスタントです。',
messages,
maxTokens: 4096,
});
return result.toDataStreamResponse({
headers: {
'X-Model-Name': fallback.name,
'X-Model-Tier': fallback.tier,
'X-Fallback-Used': 'true',
},
});
}
}
パターン5: 会話状態とコンテキスト管理
AIチャットの中核的な課題の一つはコンテキスト管理——会話履歴が長くなるほどトークン消費が増大し、コストが指数関数的に増加する。短期コンテキストウィンドウと長期記憶を区別する必要がある。
会話状態管理
// lib/ai/conversation-state.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.REDIS_URL!,
token: process.env.REDIS_TOKEN!,
});
interface ConversationMessage {
id: string;
role: 'system' | 'user' | 'assistant';
content: string;
timestamp: number;
tokenCount: number;
}
interface ConversationState {
id: string;
userId: string;
title: string;
messages: ConversationMessage[];
summary?: string;
totalTokens: number;
createdAt: number;
updatedAt: number;
}
const MAX_CONTEXT_TOKENS = 8000;
const SUMMARY_THRESHOLD = 6000;
function estimateTokenCount(text: string): number {
return Math.ceil(text.length / 3.5);
}
export async function getConversation(conversationId: string): Promise<ConversationState | null> {
const data = await redis.get<ConversationState>(
`conversation:${conversationId}`
);
return data;
}
export async function saveConversation(state: ConversationState): Promise<void> {
state.updatedAt = Date.now();
await redis.set(`conversation:${state.id}`, JSON.stringify(state), {
ex: 86400 * 30,
});
}
export async function createContextWindow(
conversationId: string
): Promise<ConversationMessage[]> {
const conversation = await getConversation(conversationId);
if (!conversation) return [];
const messages = conversation.messages;
const totalTokens = messages.reduce((sum, m) => sum + m.tokenCount, 0);
if (totalTokens <= MAX_CONTEXT_TOKENS) {
return messages;
}
if (conversation.summary) {
const summaryMessage: ConversationMessage = {
id: 'summary',
role: 'system',
content: `以前の会話の要約:\n${conversation.summary}`,
timestamp: Date.now(),
tokenCount: estimateTokenCount(conversation.summary),
};
const recentMessages: ConversationMessage[] = [];
let currentTokens = summaryMessage.tokenCount;
for (let i = messages.length - 1; i >= 0; i--) {
if (currentTokens + messages[i].tokenCount > MAX_CONTEXT_TOKENS) break;
recentMessages.unshift(messages[i]);
currentTokens += messages[i].tokenCount;
}
return [summaryMessage, ...recentMessages];
}
const result: ConversationMessage[] = [];
let currentTokens = 0;
for (let i = messages.length - 1; i >= 0; i--) {
if (currentTokens + messages[i].tokenCount > MAX_CONTEXT_TOKENS) break;
result.unshift(messages[i]);
currentTokens += messages[i].tokenCount;
}
return result;
}
export async function generateSummary(
conversationId: string,
messages: ConversationMessage[]
): Promise<string> {
const conversationText = messages
.map((m) => `${m.role}: ${m.content}`)
.join('\n');
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: '以下の会話の重要なポイントと結論を2-3文で要約してください。',
},
{ role: 'user', content: conversationText },
],
max_tokens: 200,
}),
});
const data = await response.json();
const summary = data.choices?.[0]?.message?.content || '';
const conversation = await getConversation(conversationId);
if (conversation) {
conversation.summary = summary;
await saveConversation(conversation);
}
return summary;
}
export { estimateTokenCount, MAX_CONTEXT_TOKENS, SUMMARY_THRESHOLD };
export type { ConversationMessage, ConversationState };
パターン6: プロダクションデプロイとパフォーマンス最適化
同時接続管理
// lib/ai/connection-pool.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.REDIS_URL!,
token: process.env.REDIS_TOKEN!,
});
const MAX_CONCURRENT_CONNECTIONS = 100;
const CONNECTION_TTL_SECONDS = 120;
export async function acquireConnection(userId: string): Promise<boolean> {
const key = `conn:${userId}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, CONNECTION_TTL_SECONDS);
}
if (current > MAX_CONCURRENT_CONNECTIONS) {
await redis.decr(key);
return false;
}
return true;
}
export async function releaseConnection(userId: string): Promise<void> {
const key = `conn:${userId}`;
const current = await redis.decr(key);
if (current <= 0) {
await redis.del(key);
}
}
レート制限ミドルウェア
// lib/ai/rate-limiter.ts
import { Redis } from '@upstash/redis';
import { NextRequest, NextResponse } from 'next/server';
const redis = new Redis({
url: process.env.REDIS_URL!,
token: process.env.REDIS_TOKEN!,
});
interface RateLimitConfig {
windowMs: number;
maxRequests: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
free: { windowMs: 60000, maxRequests: 10 },
pro: { windowMs: 60000, maxRequests: 60 },
enterprise: { windowMs: 60000, maxRequests: 300 },
};
export async function rateLimitMiddleware(
request: NextRequest,
userId: string,
tier: string = 'free'
): Promise<NextResponse | null> {
const config = RATE_LIMITS[tier] || RATE_LIMITS.free;
const key = `rate:${userId}:${Math.floor(Date.now() / config.windowMs)}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, Math.ceil(config.windowMs / 1000));
}
if (current > config.maxRequests) {
return NextResponse.json(
{
error: 'リクエストが多すぎます。しばらくしてからお試しください。',
retryAfter: config.windowMs / 1000,
},
{
status: 429,
headers: {
'Retry-After': String(Math.ceil(config.windowMs / 1000)),
'X-RateLimit-Limit': String(config.maxRequests),
'X-RateLimit-Remaining': '0',
},
}
);
}
return null;
}
ヘルスチェックとモニタリング
// app/api/health/ai/route.ts
import { NextResponse } from 'next/server';
interface HealthCheck {
service: string;
status: 'healthy' | 'degraded' | 'down';
latencyMs: number;
error?: string;
}
async function checkOpenAI(): Promise<HealthCheck> {
const start = Date.now();
try {
const res = await fetch('https://api.openai.com/v1/models', {
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
signal: AbortSignal.timeout(5000),
});
return {
service: 'openai',
status: res.ok ? 'healthy' : 'degraded',
latencyMs: Date.now() - start,
};
} catch (error) {
return {
service: 'openai',
status: 'down',
latencyMs: Date.now() - start,
error: String(error),
};
}
}
export async function GET() {
const checks = await Promise.all([checkOpenAI()]);
const overallStatus = checks.every((c) => c.status === 'healthy')
? 'healthy'
: checks.some((c) => c.status === 'healthy')
? 'degraded'
: 'down';
return NextResponse.json({
status: overallStatus,
timestamp: new Date().toISOString(),
checks,
});
}
5つのよくある落とし穴と解決策
落とし穴1: Nginx/CDNでSSE接続がバッファリングされる
現象: ストリーミングレスポンスが一括で返され、ユーザーが長時間待った後にテキスト全体が表示される。
原因: Nginxがデフォルトでproxy_bufferingを有効にしている。CDNもSSEレスポンスをバッファリングする。
解決策:
location /api/chat {
proxy_pass http://nextjs_backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
proxy_read_timeout 300s;
}
落とし穴2: Edge RuntimeでNode.jsネイティブモジュールが使えない
現象: Vercel Edge FunctionsへのデプロイでModule not foundエラー。
原因: Edge Runtimeはnet、fs、child_processなどのNode.jsモジュールをサポートしない。
解決策: Edge互換の代替ライブラリを使用——@upstash/redisの代わりにioredis、fetchの代わりにhttp。
落とし穴3: useChatのmessages状態と外部状態の同期ズレ
現象: useChat外でmessagesのコピーを維持しているが、両者が乖離する。
解決策: onFinishコールバックで状態を同期するか、setMessagesメソッドを使用。
落とし穴4: ストリーミングレスポンスの中断後に復元できない
現象: ネットワークジッターでSSE接続が切断され、受信済みのコンテンツが失われる。
解決策: クライアント側で受信済みコンテンツをキャッシュし、再接続時に既存コンテンツをコンテキストとして再リクエスト。
落とし穴5: 大量の同時SSE接続によるメモリリーク
現象: サーバーのメモリが継続的に増加し、最終的にOOM。
解決策: すべてのストリームにタイムアウトとクリーンアップメカニズムを確保。
10のよくあるエラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決策 |
|---|---|---|---|
| 1 | TypeError: response.body is null |
Route Handlerがストリーミングレスポンスを返していない | ReadableStreamの返却またはtoDataStreamResponse()の使用を確認 |
| 2 | AI_APICallError: 429 Too Many Requests |
LLM APIレート制限 | 指数バックオフリトライの実装、またはフォールバックモデルへの切り替え |
| 3 | AI_APICallError: context_length_exceeded |
会話履歴がモデルのコンテキストウィンドウを超過 | コンテキストウィンドウのトリミングまたは要約圧縮の実装 |
| 4 | Error: Invalid SSE data |
SSEデータ形式が不正 | data:プレフィックスと\n\nデリミタの確認 |
| 5 | AbortError: The operation was aborted |
ユーザーキャンセルまたはリクエストタイムアウト | AbortSignalの適切な処理、リソースのクリーンアップ |
| 6 | Error: Cannot read properties of undefined (reading 'delta') |
LLMが非標準形式のチャンクを返した | 防御的パーシングの追加、不正形式チャンクのスキップ |
| 7 | RuntimeError: Edge Runtime does not support Node.js API |
Edge RuntimeでNode.js APIを使用 | Node.js Runtimeへの切り替えまたはEdge互換ライブラリの使用 |
| 8 | Error: Maximum call stack size exceeded |
再帰的ストリーム処理によるスタックオーバーフロー | チャンク処理に反復を使用 |
| 9 | TypeError: Failed to execute 'fetch' on 'Window' |
ブラウザCORS制限 | APIルートとフロントエンドが同じオリジンであることを確認、またはCORSヘッダーの設定 |
| 10 | Error: Stream ended unexpectedly |
サーバー側ストリームが途中で閉じた | ハートビートメカニズムによる接続健全性の検出、自動再接続の実装 |
高度な最適化テクニック
1. ストリーミングMarkdownレンダリング
// components/StreamMarkdown.tsx
'use client';
import { memo, useMemo } from 'react';
interface StreamMarkdownProps {
content: string;
isStreaming: boolean;
}
const StreamMarkdown = memo(function StreamMarkdown({
content,
isStreaming,
}: StreamMarkdownProps) {
const html = useMemo(() => {
return content
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\n/g, '<br/>');
}, [content]);
return (
<div className="prose prose-sm max-w-none">
<div dangerouslySetInnerHTML={{ __html: html }} />
{isStreaming && (
<span className="inline-block w-2 h-4 bg-blue-600 animate-pulse ml-0.5" />
)}
</div>
);
});
export default StreamMarkdown;
2. ストリーミングレスポンスキャッシュ
// lib/ai/stream-cache.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.REDIS_URL!,
token: process.env.REDIS_TOKEN!,
});
export async function getCachedStream(queryHash: string): Promise<string | null> {
return redis.get<string>(`stream-cache:${queryHash}`);
}
export async function cacheStreamResponse(
queryHash: string,
response: string,
ttlSeconds: number = 3600
): Promise<void> {
await redis.set(`stream-cache:${queryHash}`, response, { ex: ttlSeconds });
}
export function computeQueryHash(
messages: Array<{ role: string; content: string }>,
model: string
): string {
const raw = JSON.stringify({ messages, model });
let hash = 0;
for (let i = 0; i < raw.length; i++) {
const char = raw.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return hash.toString(36);
}
比較分析:SSE vs WebSocket vs ロングポーリング
| 次元 | SSE | WebSocket | ロングポーリング |
|---|---|---|---|
| 転送方向 | サーバー→クライアントのみ | 双方向 | サーバー→クライアントのみ |
| プロトコル | HTTP/1.1+ | ws/wss | HTTP |
| 自動再接続 | ブラウザネイティブサポート | 手動実装が必要 | 各リクエストが新規 |
| プロキシ/CDN | フレンドリー(標準HTTP) | ブロックされる可能性 | 完全互換 |
| バイナリデータ | 非サポート | サポート | 非サポート |
| フレームオーバーヘッド | 高い(テキスト形式) | 低い(2バイト) | 高い(毎回HTTPヘッダー) |
| ブラウザサポート | IE非対応 | 広範なサポート | 広範なサポート |
| 接続数制限 | ドメインあたり6(HTTP/1.1) | 無制限 | 実質的な制限なし |
| LLM適合性 | ★★★★★ | ★★★ | ★★ |
| 実装複雑度 | ★★ | ★★★★ | ★ |
結論: LLMストリーミング出力は典型的な一方向プッシュシナリオであり、SSEが最適な選択。双方向リアルタイムインタラクション(音声会話、リアルタイム共同編集など)が必要な場合のみWebSocketを検討。
おすすめオンラインツール
- JSONフォーマッター - LLM APIレスポンスのJSONデータをデバッグ
- Base64エンコード/デコード - APIキーなどの機密情報のエンコード処理
- コードフォーマッター - TypeScript/Reactコードのフォーマット
関連記事
- Next.jsストリーミングSSR実践:Suspenseからプログレッシブレンダリングまで5つのプロダクションパターン - Next.js Streaming SSRの原理を深く理解
- Next.js App Routerパフォーマンス最適化ガイド - App Routerパフォーマンス最適化の完全ガイド
- Python SSEストリーミングLLM - バックエンドSSE実装の参考
外部リソース
- Vercel AI SDK公式ドキュメント - AI SDK完全APIリファレンス
- MDN: Server-Sent Events - SSEプロトコル仕様
まとめ
Next.js 15ストリーミングAIチャットの6つのプロダクションパターンには、それぞれ適したユースケースがある:
| パターン | ユースケース | 複雑度 | 推奨度 |
|---|---|---|---|
| SSEストリーミングレスポンス | プロトコルの完全制御が必要 | ★★★ | ★★★★ |
| React Server Actions | 型安全優先、迅速な開発 | ★★ | ★★★★ |
| Vercel AI SDK | クイックプロトタイピング、マルチLLMサポート | ★ | ★★★★★ |
| マルチモデルルーティング | プロダクション級レジリエンス、コスト最適化 | ★★★★ | ★★★★ |
| 会話状態管理 | 長い会話、コンテキストセンシティブ | ★★★★ | ★★★★ |
| プロダクションデプロイ最適化 | エンタープライズローンチ | ★★★★★ | ★★★★★ |
核心的なアドバイス: Vercel AI SDKで迅速にバリデーションし、段階的にマルチモデルルーティングと状態管理を導入し、最後にプロダクションデプロイを完成させる。Next.js 15のストリーミングAIチャットはもはや技術的な難題ではない——鍵は適切なパターンを選び、よくある落とし穴を避けることだ。
ブラウザローカルツールを無料で試す →