AIチャットの回答遅延を減らすストリーミング最適化|SSE実装とTTFT高速化の手順

AI LLM SSE Web開発 JavaScript
結論

AIチャットの遅延対策は、JSONを一括返却する従来型APIから Server-Sent Events(SSE)ストリーミング への変更が最優先です。最初の1文字目が出るまでの時間(TTFT)を数秒から数百ミリ秒へ短縮できます。

AIチャット構築でユーザー満足度を左右する最大の要因は、レスポンスの生成完了時間ではなく 「最初の1文字目(体感応答)が何秒で表示されるか」 です。LLMが全文章を生成し終えるまで数秒〜数十秒待たせる一括レスポンス設計は、体感速度を著しく悪化させます。


ストリーミング有無による体感速度の比較

一括レスポンス(JSON返却)とストリーミング配信(SSE)のパフォーマンスおよび動作特性の違いは以下の通りです。

評価軸一括レスポンス (JSON)SSE ストリーミング配信
TTFT (Time To First Token)3.0s 〜 15.0s(生成完了まで画面変化なし)0.2s 〜 0.8s(1文字目から順次表示)
HTTP ヘッダーContent-Type: application/jsonContent-Type: text/event-stream
接続プロトコル通常の HTTP POST / GETHTTP/1.1 (Keep-Alive) または HTTP/2
サーバーリソース全文生成までメモリに保持Chunk ごとに即時送信・破棄
途中切断時の挙動生成結果が全破棄される切断直前までの受信テキストが残る

SSEストリーミングの導入手順

SSEストリーミングを導入するためのステップです。

  1. サーバー側で Content-Type: text/event-stream ヘッダーを返すレスポンスを生成します。
  2. クライアント側で Fetch API と ReadableStreamReader を使って Chunk 単位でレスポンスを受信します。
  3. デコードしたテキストをリアルタイムに UI 状態へ追加し、スクロール位置を調整します。

1. サーバー側のストリーミングレスポンス構築

Cloudflare Pages Functions や Node.js のバックエンドで、Content-Type: text/event-stream を指定してレスポンスヘッダーを返します。

// サーバー側 (Cloudflare Worker / Pages Function 例)
export async function onRequestPost(context) {
  const { readable, writable } = new TransformStream();
  const writer = writable.getWriter();
  const encoder = new TextEncoder();

  // LLM API へストリーミングリクエストを発行(バックグラウンド処理)
  (async () => {
    try {
      const llmStream = await callLlmStreamApi(context.env);
      for await (const chunk of llmStream) {
        const text = chunk.text || "";
        // SSE 形式に整形して書き込み
        await writer.write(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`));
      }
      await writer.write(encoder.encode("data: [DONE]\n\n"));
    } catch (err) {
      await writer.write(encoder.encode(`data: ${JSON.stringify({ error: "Stream error" })}\n\n`));
    } finally {
      await writer.close();
    }
  })();

  return new Response(readable, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "Connection": "keep-alive",
    },
  });
}

2. フロントエンド側の ReadableStream 受信処理

EventSource API は POST リクエストやカスタムヘッダーに対応していないため、fetch API と ReadableStreamReader を組み合わせて受信します。

async function streamChatResponse(message: string, onChunk: (text: string) => void) {
  const response = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message }),
  });

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

  const reader = response.body.getReader();
  const decoder = new TextDecoder("utf-8");
  let buffer = "";

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

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n\n");
    buffer = lines.pop() || ""; // 不完全な最終行をバッファに残す

    for (const line of lines) {
      if (line.startsWith("data: ")) {
        const dataStr = line.slice(6).trim();
        if (dataStr === "[DONE]") return;
        
        try {
          const parsed = JSON.parse(dataStr);
          if (parsed.text) {
            onChunk(parsed.text);
          }
        } catch (e) {
          // JSONパースエラーのハンドリング
        }
      }
    }
  }
}

3. UI への即時描画とスクロール制御

受信したテキストをReactやAstroコンポーネントの状態(State)へ追記し、最下部への自動スクロールを連携させます。

// クライアント側呼び出し例
let currentResponse = "";

await streamChatResponse("こんにちは", (chunkText) => {
  currentResponse += chunkText;
  updateChatUi(currentResponse); // 画面上のメッセージを更新
  scrollToBottom();              // スクロール追従
});

現場で発生する失敗境界(Failure Boundary)

ストリーミング運用時に直面する代表的な障害パターンと対処法です。

[障害ログ例]: TypeError: Failed to execute 'read' on 'ReadableStreamDefaultReader': ReadableStream is locked
[原因]: reader の解放処理を行わずに再リクエストを発行した、または複数ループで同一ストリームを読み取ろうとした。

バッファリングによるストリーミング遅延

Cloudflare / Nginx や Reverse Proxy のバッファリング機能が有効化されていると、サーバー側が Chunk を送信しても Proxy 側で一定サイズ溜まるまでクライアントへ転送されず、結果として一括レスポンスと同じ遅延が発生します。

  • 対策: レスポンスヘッダーに X-Accel-Buffering: no(Nginx)および Cache-Control: no-cache を必ず付与します。

ブラウザの文字化け(マルチバイト文字の途中切れ)

日本語などのUTF-8マルチバイト文字は1文字あたり3〜4バイト消費します。LLMから送られるバイトストリームの区切りがマルチバイト文字の途中に落ちると、デコード時に文字化けが発生します。

  • 対策: TextDecoder("utf-8") の初期化および decoder.decode(value, { stream: true }) オプションを有効にし、マルチバイト境界のバイト列を内部バッファで適切に保持させます。

関連リンク