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に切り替え
  • 会話状態管理は短期コンテキストと長期記憶を区別し、トークン爆発を防ぐ必要がある
  • プロダクションデプロイでは同時接続数、タイムアウト、バックプレッシャー、エラー回復を処理

目次

  1. ストリーミングAIチャットアーキテクチャ概要
  2. パターン1: SSEストリーミングレスポンス実装
  3. パターン2: React Server Actions + AI
  4. パターン3: Vercel AI SDK統合
  5. パターン4: マルチモデルルーティングとフォールバック
  6. パターン5: 会話状態とコンテキスト管理
  7. パターン6: プロダクションデプロイとパフォーマンス最適化
  8. 5つのよくある落とし穴と解決策
  9. 10のよくあるエラートラブルシューティング
  10. 高度な最適化テクニック
  11. 比較分析:SSE vs WebSocket vs ロングポーリング
  12. おすすめオンラインツール
  13. まとめ

ストリーミング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はnetfschild_processなどのNode.jsモジュールをサポートしない。

解決策: Edge互換の代替ライブラリを使用——@upstash/redisの代わりにioredisfetchの代わりに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を検討。


おすすめオンラインツール

関連記事

外部リソース


まとめ

Next.js 15ストリーミングAIチャットの6つのプロダクションパターンには、それぞれ適したユースケースがある:

パターン ユースケース 複雑度 推奨度
SSEストリーミングレスポンス プロトコルの完全制御が必要 ★★★ ★★★★
React Server Actions 型安全優先、迅速な開発 ★★ ★★★★
Vercel AI SDK クイックプロトタイピング、マルチLLMサポート ★★★★★
マルチモデルルーティング プロダクション級レジリエンス、コスト最適化 ★★★★ ★★★★
会話状態管理 長い会話、コンテキストセンシティブ ★★★★ ★★★★
プロダクションデプロイ最適化 エンタープライズローンチ ★★★★★ ★★★★★

核心的なアドバイス: Vercel AI SDKで迅速にバリデーションし、段階的にマルチモデルルーティングと状態管理を導入し、最後にプロダクションデプロイを完成させる。Next.js 15のストリーミングAIチャットはもはや技術的な難題ではない——鍵は適切なパターンを選び、よくある落とし穴を避けることだ。

ブラウザローカルツールを無料で試す →

#Next.js#AI聊天#流式响应#SSE#React Server Actions#LLM#2026#前端工程