テクノロジー

ストリーミングUIの実装:応答待ちのUXを改善する

管理者2026.06.11 公開 ・ 18 min read
ストリーミングUIの実装:応答待ちのUXを改善する

01はじめに

この記事の対象読者

  • Next.js App RouterでLLMを使ったチャット画面を作ろうとしているエンジニア
  • 「ボタンを押したら数秒間何も反応がない」というUXの問題を解消したい方
  • SSEやReadableStreamは名前を知っているが、LLMと組み合わせた実装例を見たことがない方

TypeScriptとReactの基本的な読み書きができること、Next.js App Routerのファイル構成(app/ ディレクトリ)をおおよそ理解していることを前提にしています。 OpenAI APIを直接呼び出した経験があると、より理解しやすいと思います。

実行環境

  • Node.js: v20.18.1
  • Next.js: v14.2.x(App Router)
  • TypeScript: v5.4.x
  • openai: v4.52.x(OpenAI Node.js クライアント)
  • React: v18.3.x

この記事で得られること

  • SSE(Server-Sent Events)の仕組みと、LLMストリーミングとの相性の理由
  • Next.js Route HandlerでSSEレスポンスを返す実装
  • Reactクライアントでトークンを受け取りリアルタイムに描画する実装
  • ツール呼び出し(Function Calling)実行中の「処理中」表示の設計
  • AbortController を使ったキャンセル処理と、途中停止時のリソース解放

(関連記事: ツール呼び出し(Function Calling)の実装パターン)


02TL;DR

  • LLMのストリーミングレスポンスはSSEで返すのが素直な選択肢です。
  • Route Handlerで ReadableStream を返し、クライアントで EventSource またはフェッチストリームで受け取ります。
  • ツール呼び出し中は delta が来ない空白期間が生じるため、進捗メッセージをサーバー側から event: progress で送ることで体感速度を改善できます。
  • AbortControllerfetch に渡し、コンポーネントのクリーンアップ関数でキャンセルを呼ぶことで、アンマウント時のメモリリークを防げます。

03ストリーミングが必要になる理由

「待ち」はUXのボトルネックになりやすい

LLMは文章をトークン単位で生成します。 入力が長くなるほど、また要求する出力が長くなるほど、レスポンス完了までの時間が伸びます。 数十秒かかる場合もめずらしくなく、応答が返るまで画面が静止したままでは、ユーザーはエラーと区別できません。

ストリーミングを使うと、最初のトークンが届いた時点から文字が流れ始めます。 体感の応答速度が改善され、「考えている」という状態が視覚的に伝わります。 筆者たちが開発している BizPlan(事業計画エージェント)でも、ストリーミング導入後にユーザーの離脱率が目に見えて変化しました。 (数値はプロダクト固有のため本稿では省略しますが、一般論として初回トークン到達までの時間を短縮するだけでも体験が大きく変わります。)

ストリーミングの選択肢

Next.js + LLMの構成でよく使われる転送方式を整理します。

方式 特徴 向いている場面
SSE(Server-Sent Events) テキストベースの単方向ストリーム。HTTPの上で動く。 テキスト応答の逐次表示
WebSocket 双方向。接続維持コストが高い。 リアルタイム共同編集など
HTTP Chunked Transfer シンプルだがイベント区切りがない。 バイナリ転送など
Vercel AI SDK SSE/Streamを抽象化したラッパー。 素早く動かしたいとき

本記事ではSSEを選びます。 理由は、OpenAI APIのストリーミングがSSEフォーマットを採用しており、サーバー側でほぼそのまま中継できるからです。 Vercel AI SDKも選択肢として優れていますが、内部で何が起きているかを理解するために、まず素のSSEで組み上げることをお勧めします。


04SSEの基礎

フォーマット

SSEは text/event-stream の Content-Type で返すプレーンテキストのプロトコルです。 各メッセージは次の形式を取ります。

event: <イベント名>\n
data: <データ文字列>\n
\n

event: は省略可能で、省略時は message として扱われます。 data: が複数行の場合は行ごとに data: を付けます。 空行(\n\n)がメッセージの終端を表します。

クライアントでの受信

ブラウザの EventSource APIは標準でSSEを受信できます。

const es = new EventSource('/api/chat');
es.addEventListener('token', (event) => {
  console.log(event.data); // トークン文字列
});
es.addEventListener('done', () => {
  es.close();
});

ただし EventSource はGETリクエストしか送れません。 チャットのようにリクエストボディでメッセージを送りたい場合は、fetch でPOSTしてレスポンスボディをストリームとして読む方法が現実的です。 本記事ではこちらを採用します。


05Step 1: Route HandlerでSSEを返す

まずRoute Handlerの全体像から示します。 シンプルな「ユーザーメッセージを受け取ってLLMの応答をストリーミングで返す」だけのエンドポイントです。

// app/api/chat/route.ts

import OpenAI from 'openai';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

export async function POST(req: Request): Promise<Response> {
  const { messages } = await req.json();

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      const sendEvent = (eventName: string, data: string) => {
        controller.enqueue(
          encoder.encode(`event: ${eventName}\ndata: ${data}\n\n`)
        );
      };

      try {
        const completion = await openai.chat.completions.create({
          model: 'gpt-5.5',
          messages,
          stream: true,
        });

        for await (const chunk of completion) {
          const delta = chunk.choices[0]?.delta?.content;
          if (delta) {
            sendEvent('token', JSON.stringify({ token: delta }));
          }
        }

        sendEvent('done', JSON.stringify({ finished: true }));
      } catch (error) {
        sendEvent('error', JSON.stringify({ message: String(error) }));
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

いくつか補足します。

ReadableStreamstart 関数はコントローラーを受け取り、controller.enqueue() でバイト列を書き込みます。 関数が完了するより先にレスポンスを返せるため、非同期に書き込み続けることができます。

TextEncoder は文字列をUint8Arrayに変換します。 enqueue はバイト列しか受け取らないため必須です。

Cache-Control: no-cache はSSEでは慣例的に設定します。 プロキシやCDNによるキャッシュを防ぐためです。

実際に動かしてみると

event: token
data: {"token":"こんにちは"}

event: token
data: {"token":"。今日"}

event: token
data: {"token":"は"}

event: done
data: {"finished":true}

ターミナルで curl -N -X POST http://localhost:3000/api/chat -H "Content-Type: application/json" -d '{"messages":[{"role":"user","content":"こんにちは"}]}' を実行するとこのような出力が流れます。 -N オプションはバッファリングを無効にするために必要です。


06Step 2: Reactクライアントで受け取る

次にReactコンポーネント側を作ります。 fetch でPOSTしてレスポンスボディを ReadableStream として読み取る実装です。

// components/ChatStream.tsx
'use client';

import { useState, useRef, useCallback } from 'react';

type Message = {
  role: 'user' | 'assistant';
  content: string;
};

export function ChatStream() {
  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 = { role: 'user', content: input };
    const nextMessages = [...messages, userMessage];

    setMessages(nextMessages);
    setInput('');
    setIsStreaming(true);

    // AbortController を新規作成して ref に保持
    const controller = new AbortController();
    abortControllerRef.current = controller;

    // アシスタントの返答を逐次追記するため、まず空で追加しておく
    setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ messages: nextMessages }),
        signal: controller.signal,
      });

      if (!response.ok || !response.body) {
        throw new Error(`HTTP error: ${response.status}`);
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });

        // SSEのメッセージ境界(\n\n)で分割して処理
        const parts = buffer.split('\n\n');
        buffer = parts.pop() ?? '';

        for (const part of parts) {
          const lines = part.split('\n');
          let eventName = 'message';
          let dataLine = '';

          for (const line of lines) {
            if (line.startsWith('event: ')) {
              eventName = line.slice('event: '.length);
            } else if (line.startsWith('data: ')) {
              dataLine = line.slice('data: '.length);
            }
          }

          if (eventName === 'token' && dataLine) {
            const parsed = JSON.parse(dataLine) as { token: string };
            setMessages((prev) => {
              const updated = [...prev];
              const last = updated[updated.length - 1];
              if (last.role === 'assistant') {
                updated[updated.length - 1] = {
                  ...last,
                  content: last.content + parsed.token,
                };
              }
              return updated;
            });
          }

          if (eventName === 'done') {
            break;
          }
        }
      }
    } catch (error) {
      if ((error as Error).name === 'AbortError') {
        // キャンセル時は正常終了として扱う
        console.log('Stream cancelled by user');
      } else {
        console.error('Streaming error:', error);
      }
    } finally {
      setIsStreaming(false);
      abortControllerRef.current = null;
    }
  }, [input, messages, isStreaming]);

  const cancelStream = () => {
    abortControllerRef.current?.abort();
  };

  return (
    <div>
      <div>
        {messages.map((msg, i) => (
          <div key={i} data-role={msg.role}>
            <span>{msg.role === 'user' ? 'あなた' : 'AI'}</span>
            <p>{msg.content}</p>
          </div>
        ))}
      </div>
      <div>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
          disabled={isStreaming}
          placeholder="メッセージを入力..."
        />
        {isStreaming ? (
          <button onClick={cancelStream}>停止</button>
        ) : (
          <button onClick={sendMessage} disabled={!input.trim()}>
            送信
          </button>
        )}
      </div>
    </div>
  );
}

SSEパーサーの実装について

上の実装でポイントになるのはSSEのパース部分です。 ReadableStream から読み取ったバイト列は、チャンクの区切りとSSEのメッセージ境界が必ずしも一致しません。 ネットワークのバッファリングによって1回の read() で複数メッセージが返ることも、1メッセージが複数回に分割されることもあります。

そのため buffer 文字列に追記し続けて、\n\n で区切ったときに完結しているブロックだけを処理する実装にしています。 末尾の未完成ブロックは次のループで処理します。

// 分割されるケースのイメージ

// 1回目の read() で届いたバイト列
"event: token\ndata: {\"token\":\"こんにちは\"}\n\n"

// 2回目の read() で届いたバイト列(途中で切れている)
"event: token\ndata: {\"token\":\"。今日"

// 3回目でようやく完結
"は\"}\n\n"

decoder.decode(value, { stream: true })stream: true オプションは、マルチバイト文字がチャンク境界で分割された場合に不正なデコードを防ぐためのオプションです。 忘れがちですが、日本語テキストを扱う場合は必須です。


07Step 3: ツール実行中の途中経過表示

なぜ空白期間が生じるのか

Function Callingを使うと、LLMがツールを呼び出した瞬間からツールの実行が完了するまでの間、トークンの送信が止まります。 この間はネットワークが切れているわけではなく、サーバーは処理中なのですが、クライアントからは何も届かない状態が続きます。

ユーザーには「フリーズした」と映りやすく、体験を損ないます。 特にWebAPIや計算処理を呼ぶ場合は数秒以上かかることもあります。

解決策: progress イベントをサーバーから送る

サーバー側でツール呼び出しを検知したタイミングで、専用のイベントを送るように設計します。

Route Handlerを拡張します。

// app/api/chat-with-tools/route.ts

import OpenAI from 'openai';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// 例示用のダミーツール実装
async function executeSearchTool(query: string): Promise<string> {
  // 実際のAPI呼び出しをここに記述する
  await new Promise((resolve) => setTimeout(resolve, 1500));
  return `「${query}」の検索結果: サンプルの検索結果が返りました。`;
}

const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
  {
    type: 'function',
    function: {
      name: 'search_web',
      description: 'ウェブ検索を実行して情報を取得します',
      parameters: {
        type: 'object',
        properties: {
          query: {
            type: 'string',
            description: '検索クエリ',
          },
        },
        required: ['query'],
      },
    },
  },
];

export async function POST(req: Request): Promise<Response> {
  const { messages } = await req.json();

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      const sendEvent = (eventName: string, data: unknown) => {
        controller.enqueue(
          encoder.encode(
            `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`
          )
        );
      };

      try {
        // ツール呼び出しを含む可能性があるため、最大3ターンループする
        let currentMessages = [...messages];
        let continueLoop = true;
        let turnCount = 0;
        const MAX_TURNS = 3;

        while (continueLoop && turnCount < MAX_TURNS) {
          continueLoop = false;
          turnCount++;

          const completion = await openai.chat.completions.create({
            model: 'gpt-5.5',
            messages: currentMessages,
            tools,
            stream: true,
          });

          let accumulatedContent = '';
          const toolCallsBuffer: Record<
            number,
            { id: string; name: string; arguments: string }
          > = {};

          for await (const chunk of completion) {
            const choice = chunk.choices[0];
            if (!choice) continue;

            const delta = choice.delta;

            // テキストトークンが届いた場合
            if (delta.content) {
              accumulatedContent += delta.content;
              sendEvent('token', { token: delta.content });
            }

            // ツール呼び出しのデルタが届いた場合
            if (delta.tool_calls) {
              for (const toolCallDelta of delta.tool_calls) {
                const index = toolCallDelta.index;
                if (!toolCallsBuffer[index]) {
                  toolCallsBuffer[index] = { id: '', name: '', arguments: '' };
                }
                if (toolCallDelta.id) {
                  toolCallsBuffer[index].id = toolCallDelta.id;
                }
                if (toolCallDelta.function?.name) {
                  toolCallsBuffer[index].name = toolCallDelta.function.name;
                }
                if (toolCallDelta.function?.arguments) {
                  toolCallsBuffer[index].arguments +=
                    toolCallDelta.function.arguments;
                }
              }
            }

            // ストリームが終了した場合
            if (choice.finish_reason === 'tool_calls') {
              // ツール実行前に進捗イベントを送る
              for (const toolCall of Object.values(toolCallsBuffer)) {
                sendEvent('progress', {
                  status: 'tool_calling',
                  toolName: toolCall.name,
                  message: `${toolCall.name} を実行中...`,
                });

                // ツールを実行する
                let toolResult = '';
                try {
                  if (toolCall.name === 'search_web') {
                    const args = JSON.parse(toolCall.arguments) as {
                      query: string;
                    };
                    toolResult = await executeSearchTool(args.query);
                  } else {
                    toolResult = 'ツールが見つかりませんでした。';
                  }
                } catch {
                  toolResult = 'ツール実行中にエラーが発生しました。';
                }

                // ツール完了イベントを送る
                sendEvent('progress', {
                  status: 'tool_done',
                  toolName: toolCall.name,
                  message: `${toolCall.name} が完了しました`,
                });

                // メッセージ履歴にアシスタントの応答とツール結果を追加する
                currentMessages = [
                  ...currentMessages,
                  {
                    role: 'assistant',
                    content: accumulatedContent || null,
                    tool_calls: [
                      {
                        id: toolCall.id,
                        type: 'function' as const,
                        function: {
                          name: toolCall.name,
                          arguments: toolCall.arguments,
                        },
                      },
                    ],
                  },
                  {
                    role: 'tool' as const,
                    tool_call_id: toolCall.id,
                    content: toolResult,
                  },
                ];
              }

              // ツール結果を受けてLLMに再度問い合わせるためループを続ける
              continueLoop = true;
            }
          }
        }

        sendEvent('done', { finished: true });
      } catch (error) {
        sendEvent('error', { message: String(error) });
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

クライアントでの progress イベントの表示

クライアント側で progress イベントを受け取り、ステータスUIに反映します。

// components/ChatStreamWithTools.tsx(抜粋)
'use client';

import { useState, useRef, useCallback } from 'react';

type ProgressState = {
  status: 'idle' | 'streaming' | 'tool_calling' | 'tool_done';
  toolName?: string;
  message?: string;
};

export function ChatStreamWithTools() {
  // ...(messages, input, isStreaming は前のコンポーネントと同様)
  const [progress, setProgress] = useState<ProgressState>({ status: 'idle' });
  const abortControllerRef = useRef<AbortController | null>(null);

  const sendMessage = useCallback(async (/* ... */) => {
    // ...

    try {
      // ...

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        // ...(バッファリング処理は前のコンポーネントと同様)

        for (const part of parts) {
          // ...(event/data のパースは同様)

          if (eventName === 'token' && dataLine) {
            setProgress({ status: 'streaming' });
            // ...(token 処理は同様)
          }

          if (eventName === 'progress' && dataLine) {
            const parsed = JSON.parse(dataLine) as {
              status: 'tool_calling' | 'tool_done';
              toolName: string;
              message: string;
            };
            setProgress({
              status: parsed.status,
              toolName: parsed.toolName,
              message: parsed.message,
            });
          }

          if (eventName === 'done') {
            setProgress({ status: 'idle' });
            break;
          }
        }
      }
    } catch (error) {
      // ...
    } finally {
      setProgress({ status: 'idle' });
      // ...
    }
  }, [/* deps */]);

  return (
    <div>
      {/* メッセージ一覧 */}
      {/* ... */}

      {/* 進捗表示エリア */}
      {progress.status !== 'idle' && (
        <div aria-live="polite" aria-label="処理状況">
          {progress.status === 'streaming' && (
            <span>回答を生成中...</span>
          )}
          {progress.status === 'tool_calling' && (
            <span>{progress.message}</span>
          )}
          {progress.status === 'tool_done' && (
            <span>{progress.message}</span>
          )}
        </div>
      )}
    </div>
  );
}

aria-live="polite" を付けることで、スクリーンリーダーにも進捗状態が伝わります。 視覚的なアニメーション(スピナーやプログレスバー)と組み合わせると、さらに体験が向上します。


08Step 4: AbortControllerによるキャンセル処理

キャンセル処理が必要な理由

ストリーミング中にユーザーが「停止」ボタンを押した場合や、画面遷移でコンポーネントがアンマウントされた場合に、適切にリクエストを中断しないとリソースリークが起きます。

具体的には次の2点が問題になります。

  1. ブラウザ側では fetch リクエストが残り続け、受信し続けます。
  2. サーバー側では for await のループが動き続け、OpenAI APIへの接続も維持されます。

どちらも無視できる規模の問題ではなく、特にサーバーサイドでは複数ユーザーが同時に使うと影響が出てきます。

クライアントのキャンセル実装

Step 2で示したコンポーネントでは abortControllerRefAbortController を持たせています。 改めて抜粋して解説します。

// components/ChatStream.tsx(キャンセル部分の抜粋)

const abortControllerRef = useRef<AbortController | null>(null);

const sendMessage = async () => {
  // リクエストごとに新しい AbortController を作る
  const controller = new AbortController();
  abortControllerRef.current = controller;

  try {
    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ messages }),
      signal: controller.signal, // ここで渡す
    });

    // ... ストリーム読み取り
  } catch (error) {
    if ((error as Error).name === 'AbortError') {
      // abort() が呼ばれると fetch は AbortError をスローする
      // これは正常な停止なので、エラーとして扱わない
      console.log('User cancelled the request');
    } else {
      console.error('Unexpected error:', error);
    }
  } finally {
    abortControllerRef.current = null;
    setIsStreaming(false);
  }
};

// ユーザーが「停止」ボタンを押したとき
const cancelStream = () => {
  abortControllerRef.current?.abort();
};

// コンポーネントのクリーンアップ(画面遷移時など)
useEffect(() => {
  return () => {
    abortControllerRef.current?.abort();
  };
}, []);

useEffect のクリーンアップ関数でも abort() を呼んでいる点が大切です。 ユーザーが「停止」ボタンを押さずに画面を遷移した場合でも、コンポーネントがアンマウントされるときに自動でキャンセルされます。

サーバーのキャンセル検知

クライアントがリクエストを中断すると、サーバー側の req.signal がabortedになります。 ReadableStreamstart 関数内でこれを検知できます。

// app/api/chat/route.ts(キャンセル検知を追加した版)

export async function POST(req: Request): Promise<Response> {
  const { messages } = await req.json();

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      const sendEvent = (eventName: string, data: unknown) => {
        // キャンセル済みなら送信をスキップする
        if (req.signal.aborted) return;
        try {
          controller.enqueue(
            encoder.encode(
              `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`
            )
          );
        } catch {
          // コントローラーがすでに閉じている場合を無視する
        }
      };

      // クライアントからの中断シグナルを OpenAI の呼び出しに伝える
      req.signal.addEventListener('abort', () => {
        controller.close();
      });

      try {
        const completion = await openai.chat.completions.create(
          {
            model: 'gpt-5.5',
            messages,
            stream: true,
          },
          // OpenAI クライアントに signal を渡すことで、
          // abort 時に OpenAI への接続も切れる
          { signal: req.signal }
        );

        for await (const chunk of completion) {
          // abort されていたらループを抜ける
          if (req.signal.aborted) break;

          const delta = chunk.choices[0]?.delta?.content;
          if (delta) {
            sendEvent('token', { token: delta });
          }
        }

        if (!req.signal.aborted) {
          sendEvent('done', { finished: true });
        }
      } catch (error) {
        if (!req.signal.aborted) {
          sendEvent('error', { message: String(error) });
        }
      } finally {
        // すでに閉じていない場合のみ close を呼ぶ
        try {
          controller.close();
        } catch {
          // 無視する
        }
      }
    },
    cancel() {
      // ReadableStream 自体がキャンセルされたとき(クライアントが読み取りを止めたとき)に呼ばれる
      console.log('Stream cancelled by consumer');
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

ReadableStreamcancel コールバックは、コンシューマーがストリームの読み取りをやめたときに呼ばれます。 ここでOpenAI APIへの接続を明示的に切ることで、サーバーのリソースを解放できます。


09Step 5: 実装をまとめる

ここまでのピースをつなげて、最終的な構成を整理します。

app/
├── api/
│   └── chat/
│       └── route.ts          # Route Handler(SSEレスポンス)
└── chat/
    └── page.tsx              # チャットページ

components/
├── ChatStream.tsx            # チャットUIコンポーネント
└── ProgressIndicator.tsx     # 進捗表示コンポーネント(分離すると再利用しやすい)

ProgressIndicator コンポーネントの分離

進捗表示は独立したコンポーネントに切り出すと、他の画面でも使いやすくなります。

// components/ProgressIndicator.tsx
'use client';

type Props = {
  status: 'idle' | 'streaming' | 'tool_calling' | 'tool_done';
  message?: string;
};

export function ProgressIndicator({ status, message }: Props) {
  if (status === 'idle') return null;

  return (
    <div
      role="status"
      aria-live="polite"
      style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
    >
      {/* スピナーのSVGアニメーションをここに入れる */}
      <svg
        aria-hidden="true"
        width="16"
        height="16"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
      >
        <circle cx="12" cy="12" r="10" opacity="0.25" />
        <path d="M12 2a10 10 0 0 1 10 10" />
      </svg>
      <span>
        {status === 'streaming' && '回答を生成中...'}
        {status === 'tool_calling' && (message ?? 'ツールを実行中...')}
        {status === 'tool_done' && (message ?? 'ツールの実行が完了しました')}
      </span>
    </div>
  );
}

エラーハンドリングの方針

ストリーミング実装でのエラーは大きく3種類に分けられます。

エラー種別 発生場面 対処方針
AbortError ユーザーキャンセル、画面遷移 正常終了として扱い、エラーUIを出さない
HTTPエラー(4xx/5xx) レートリミット、認証エラーなど response.ok チェックで検知し、ユーザーに通知
ネットワーク切断 Wi-Fi切断など fetch 自体がエラーになる。リトライUIを提供
// HTTPエラーの検知例

const response = await fetch('/api/chat', {
  // ...
  signal: controller.signal,
});

if (!response.ok) {
  const errorText = await response.text();
  throw new Error(`API Error ${response.status}: ${errorText}`);
}

if (!response.body) {
  throw new Error('レスポンスボディが空です');
}

10設計上の注意点と選択肢

Vercel AI SDK との比較

Vercel AI SDKは useChat フックと streamText を提供しており、本記事で実装した内容の多くを内部で吸収しています。 プロダクション開発では積極的に検討する価値があります。

一方で、カスタムイベント(progress イベントなど)を細かく制御したい場合や、OpenAI以外のモデルプロバイダーを組み合わせる場合は、素のSSE実装の方が柔軟に対応できます。 どちらを選ぶかはプロジェクトの要件次第です。

ストリーミングとMarkdownレンダリング

LLMはMarkdown形式でテキストを出力することが多いです。 トークンが届くたびに markedreact-markdown でレンダリングすると、不完全なMarkdownの途中状態でパースエラーになることがあります。

筆者たちの経験では、次の2つのアプローチが比較的安定しています。

  • ストリーミング中はプレーンテキストで表示し、done イベント受信後にMarkdownレンダリングする。
  • react-markdownremarkPlugins でインクリメンタルなパースに対応する。

プロダクトの要件に合わせて選ぶとよいと思います。

Next.js App RouterとSSEの相性

Next.js 14のRoute HandlerはEdge RuntimeとNode.js Runtimeの両方でSSEをサポートしています。 ただし、Vercel等のサーバーレス環境では関数の実行時間に制限があるため、長時間のストリーミングでタイムアウトになる場合があります。

長時間のストリーミングが想定される場合は、Vercel ProプランのFluid computeオプションや、専用サーバーへの切り出しを検討する価値があります。


11まとめ

ストリーミングUIの実装を段階的に組み上げてきました。 要点を整理します。

  • SSEは text/event-stream でテキストを流すシンプルなプロトコルで、LLMのトークン逐次表示と相性がよいです。
  • クライアント側のSSEパーサーはバッファリングを考慮した実装が必要で、\n\n で区切ることがポイントです。
  • ツール実行中の空白期間は、progress イベントをサーバーから能動的に送ることで解消できます。
  • AbortController をコンポーネントのライフサイクルに結びつけることで、キャンセル処理とメモリリーク対策が同時に実現できます。

本記事の実装はシンプルさを優先した例です。 実際のプロダクトでは認証、レートリミット、ロギング、エラーの再試行など考慮すべき点が増えますが、基本的な構造はここで示したものと変わりません。 一度手元で動かしてみると、SSEの挙動やストリームの流れが体感として理解しやすくなると思います。

(関連記事: ツール呼び出し(Function Calling)の実装パターン、コンテキストウィンドウの管理と設計)


12参考文献

Author
管理者
Agent Store

記事で紹介した技術を、実際の業務でお試しください。

業務に合うエージェントを条件で絞り込んで選べます。すべて無料で、今すぐ利用できます。

エージェント一覧を見る →

コメント

まだコメントはありません。最初のコメントを投稿してみましょう。

コメントを投稿

ゲストコメントは管理者の承認後に公開されます。 ログインするとすぐにコメントが公開されます。

当サイトではCookieを使用しています。詳しくはCookieポリシーをご覧ください。