AI活用

OpenAI APIをストリーミング表示する方法|JavaScriptで逐次出力を受け取る

OpenAI Responses APIの出力をJavaScriptで逐次受信し、Node.jsのCLI表示、ブラウザへのSSE中継、切断・キャンセル・UI状態・重複防止まで実装します。

この記事の目次
  1. 結論:サーバーでOpenAIのイベントを読み、必要な差分だけ中継する
  2. ストリーミングが必要なケース
  3. 体感速度と総処理時間は別
  4. 一括レスポンスで十分な処理
  5. Node.jsでイベントを順番に読む
  6. CLIデモを準備する
  7. stream: trueでイベントを受け取る
  8. イベントを最小ログへ残す
  9. ブラウザへSSEで中継する
  10. Node.jsサーバーを作る
  11. ブラウザでSSEを読む
  12. 停止ボタンとエラー表示をつなぐ
  13. ストリームの状態をUIへ反映する
  14. 4つの状態を明示する
  15. Markdownは頻繁に再解析しない
  16. 途切れる・重複する時の確認点
  17. イベント種別と完了有無を記録する
  18. 再接続で二重表示させない
  19. 本番前のチェックリスト
  20. まとめ:delta・完了・切断を別々に設計する

OpenAI Responses APIでは、リクエストに stream: true を指定し、response.output_text.delta イベントの delta を順番に連結すると、生成途中の文章を逐次表示できます。ブラウザから直接OpenAIへ接続せず、APIキーを保持するNode.jsサーバーがストリームを受け、SSEで画面へ中継する構成にします。

streaming(ストリーミング)は、回答全体の完成を待たず、届いた差分を少しずつ処理する方式です。最初の文字が早く見えるため待ち時間の体感は短くなりますが、モデルが回答を作り終えるまでの総時間やトークン数が必ず減るわけではありません。

この記事では、Node.jsのCLI版とブラウザ向けSSE版を作り、loading・streaming・done・errorの状態、切断時の中止、Markdownの途中描画、重複防止まで確認します。OpenAI APIの初期設定がまだなら、先にOpenAI APIをJavaScriptから使う方法を確認してください。

情報確認日:2026年7月20日(日本時間)

結論:サーバーでOpenAIのイベントを読み、必要な差分だけ中継する

ブラウザ
  │ POST /api/stream(質問だけ送る)
  ▼
Node.jsサーバー
  │ Responses APIへ stream: true
  ▼
OpenAI API
  │ response.output_text.delta
  ▼
Node.jsサーバー
  │ SSE: delta / done / error
  ▼
ブラウザが同じ回答領域へ差分を追記

安全で壊れにくい実装の要点

  • 標準APIキーはNode.jsサーバーの環境変数だけに置く
  • response.output_text.deltadelta だけを表示用に連結する
  • response.completed を受けて完了状態へ移す
  • 未知のイベントは捨てず、型とrequest IDをログへ残す
  • ブラウザ切断時はOpenAIへの処理も中止する
  • 再実行前に古い接続を止め、回答領域を1回だけ初期化する
スポンサーリンク

ストリーミングが必要なケース

体感速度と総処理時間は別

ストリーミングで改善しやすいのはTTFT(Time To First Token:最初の出力が届くまでの時間)の体感です。長い説明やコードが少しずつ見えるため、利用者は処理が進んでいると分かります。一方、最後のイベントまでの時間、生成されるトークン、料金は別に測定します。

処理 ストリーミング向き 一括応答向き
長い説明・文章 読み始めを早くしたい 完成後に審査してから表示したい
コード生成 進行を見せたい 構文検証後だけ表示したい
JSON処理 途中経過を別UIへ出せる 完全なJSONだけをparseしたい
バッチ・保存処理 進捗イベントが必要 最終結果だけをDBへ保存する
モデレーション 表示と安全確認を両立する設計がある 全文確認後に公開する方が安全

parse(パース)は、文字列をJSONなどの構造へ読み替える処理です。生成途中のJSONやMarkdownは閉じ括弧・コードフェンスが未完成なので、毎回完成文として解釈すると表示が崩れます。

一括レスポンスで十分な処理

検索インデックスへ保存する要約、構造化出力の検証、夜間処理など、利用者が生成中の文章を読む必要がない処理は一括応答が簡単です。ストリームにはイベント処理、切断、部分結果、ログ、再試行という追加の状態が生まれます。

Node.jsでイベントを順番に読む

CLIデモを準備する

mkdir openai-stream-demo
cd openai-stream-demo
npm init -y
npm pkg set type=module
npm install openai
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_MODEL="your_available_model_id"

モデルIDは利用できる現行モデルを環境変数へ設定します。記事へ特定モデル名を固定すると、廃止・権限・料金変更でコードが動かなくなるためです。

stream: trueでイベントを受け取る

import OpenAI from "openai";

const client = new OpenAI();
const model = process.env.OPENAI_MODEL;

if (!model) {
  throw new Error("OPENAI_MODEL is not set");
}

const stream = await client.responses.create({
  model,
  input: "JavaScriptのPromiseを初学者向けに説明してください。",
  stream: true,
});

let answer = "";

for await (const event of stream) {
  if (event.type === "response.output_text.delta") {
    answer += event.delta;
    process.stdout.write(event.delta);
  } else if (event.type === "response.completed") {
    process.stdout.write("\n");
    console.error(`completed: ${event.response.id}`);
  } else if (event.type === "error") {
    console.error(event.error);
  }
}

console.error(`characters: ${answer.length}`);

OpenAIの公式ガイドでは、主要イベントとして response.createdresponse.output_text.deltaresponse.completederror が案内されています。画面表示ではテキスト差分だけを使い、完了・エラーは状態更新とログに使います。

deltaを上書きしない:delta は回答全体ではなく今回届いた差分です。answer = event.delta ではなく answer += event.delta として連結します。

イベントを最小ログへ残す

本文や個人情報を含む入力を丸ごとログへ残すのではなく、イベント型、response ID、処理時間、終了理由、エラーコードを記録します。未知のイベント型を見つけたら、内容を安全にマスクした上で調査できるようにします。

const startedAt = performance.now();

function logEvent(event) {
  console.error(JSON.stringify({
    type: event.type,
    responseId: event.response?.id ?? null,
    elapsedMs: Math.round(performance.now() - startedAt),
  }));
}

ブラウザへSSEで中継する

SSE(Server-Sent Events:サーバーからブラウザへイベントを順次送る仕組み)は、HTTPレスポンスを閉じずに event:data: の行を送ります。今回は質問をPOSTしたいため、ブラウザ標準の EventSource ではなく fetch() のReadableStreamからSSEを読みます。

Node.jsサーバーを作る

server.mjsを作り、OpenAIのイベントを表示に必要な3種類へ絞って中継します。

import http from "node:http";
import OpenAI from "openai";

const client = new OpenAI();
const model = process.env.OPENAI_MODEL;

if (!model) {
  throw new Error("OPENAI_MODEL is not set");
}

function sendSse(response, event, data) {
  response.write(`event: ${event}\n`);
  response.write(`data: ${JSON.stringify(data)}\n\n`);
}

const server = http.createServer(async (request, response) => {
  if (request.method !== "POST" || request.url !== "/api/stream") {
    response.writeHead(404).end();
    return;
  }

  const chunks = [];
  for await (const chunk of request) chunks.push(chunk);

  let prompt;
  try {
    const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
    prompt = String(body.prompt ?? "").trim();
  } catch {
    response.writeHead(400).end("Invalid JSON");
    return;
  }

  if (!prompt || prompt.length > 4000) {
    response.writeHead(422).end("Invalid prompt");
    return;
  }

  response.writeHead(200, {
    "Content-Type": "text/event-stream; charset=utf-8",
    "Cache-Control": "no-cache, no-transform",
    Connection: "keep-alive",
    "X-Content-Type-Options": "nosniff",
  });

  const abortController = new AbortController();
  response.on("close", () => {
    if (!response.writableEnded) abortController.abort();
  });

  try {
    const stream = await client.responses.create(
      { model, input: prompt, stream: true },
      { signal: abortController.signal },
    );

    for await (const event of stream) {
      if (event.type === "response.output_text.delta") {
        sendSse(response, "delta", { text: event.delta });
      } else if (event.type === "response.completed") {
        sendSse(response, "done", {
          responseId: event.response.id,
        });
      } else if (event.type === "error") {
        sendSse(response, "error", {
          message: "OpenAI API error",
        });
      }
    }
  } catch (error) {
    if (!abortController.signal.aborted) {
      sendSse(response, "error", {
        message: "Streaming failed",
      });
      console.error(error);
    }
  } finally {
    response.end();
  }
});

server.listen(3000, () => {
  console.log("http://localhost:3000");
});

入力長の上限は、意図しない高額リクエストや巨大なログを防ぐ最小対策です。本番では利用者認証、レート制限、モデルの許可リスト、タイムアウト、モデレーションも要件に応じて追加します。APIキーはレスポンスやクライアントJavaScriptへ含めません。

ブラウザでSSEを読む

次の関数は、SSEの1イベントを空行で区切り、eventdata を取り出します。説明を短くするためDOM要素は既に存在する前提です。

let activeController = null;

async function startStreaming(prompt, output, status) {
  activeController?.abort();
  activeController = new AbortController();

  output.textContent = "";
  status.textContent = "loading";

  const response = await fetch("/api/stream", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
    signal: activeController.signal,
  });

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

  status.textContent = "streaming";
  const reader = response.body
    .pipeThrough(new TextDecoderStream())
    .getReader();

  let buffer = "";

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

    const frames = buffer.split("\n\n");
    buffer = frames.pop() ?? "";

    for (const frame of frames) {
      const event = frame.match(/^event: (.+)$/m)?.[1];
      const dataText = frame.match(/^data: (.+)$/m)?.[1];
      if (!event || !dataText) continue;

      const data = JSON.parse(dataText);
      if (event === "delta") {
        output.textContent += data.text;
      } else if (event === "done") {
        status.textContent = "done";
      } else if (event === "error") {
        throw new Error(data.message);
      }
    }
  }
}

textContent を使うと、モデル出力にHTMLが含まれてもコードとして実行されません。MarkdownをHTMLへ変換する場合も、生成完了後に信頼できるparserとsanitizer(危険なHTMLを除去する処理)を通します。

停止ボタンとエラー表示をつなぐ

const form = document.querySelector("#prompt-form");
const promptInput = document.querySelector("#prompt");
const output = document.querySelector("#output");
const status = document.querySelector("#status");
const stopButton = document.querySelector("#stop");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  try {
    await startStreaming(promptInput.value, output, status);
  } catch (error) {
    if (error.name === "AbortError") {
      status.textContent = "cancelled";
    } else {
      status.textContent = "error";
      console.error(error);
    }
  }
});

stopButton.addEventListener("click", () => {
  activeController?.abort();
});

ブラウザの AbortController がfetchを止めると接続が閉じ、サーバー側の response.on("close") がOpenAIリクエスト用のcontrollerを中止します。API側ですでに処理された分の課金が消えるとは限らないため、停止は「以後の処理をできるだけ早く止める」機能と考えます。

ストリームの状態をUIへ反映する

4つの状態を明示する

状態 意味 UIの例
loading HTTP接続・API開始を待っている 送信ボタンを無効化、停止は有効
streaming 差分を受信中 カーソル表示、停止を有効
done 完了イベントを受信した 再送・コピーを有効化
error / cancelled 失敗または利用者が中止 部分結果を区別し、再実行を案内

HTTPレスポンスが閉じたことと、response.completed を受けたことは同じではありません。途中で回線が切れてもreaderは終了します。完了イベントを受けていない場合は、部分結果を完成回答として保存しないようにします。

Markdownは頻繁に再解析しない

生成途中は、見出し記号、表、リンク、コードフェンスが未完成です。差分ごとに全文をHTMLへ変換すると表示が点滅し、長文では処理量も増えます。受信中はプレーンテキストで表示し、完了後に1回だけMarkdownへ変換する方法が簡単です。

途中でも装飾したい場合は、50〜100ミリ秒単位で更新をまとめ、コードブロック内では描画を保留するなどの設計が必要です。生成文字列を innerHTML へ直接代入してはいけません。

途切れる・重複する時の確認点

イベント種別と完了有無を記録する

症状 主な原因 確認方法
何も表示されない モデル、認証、イベント名、proxy buffering HTTP statusと最初のイベント時刻を記録
途中で止まる 回線切断、タイムアウト、上限、利用者キャンセル completedの有無とclose理由を分ける
文字が重複する 差分ではなく全文を追記、古い接続が残存 delta長と同時接続数をログ化
まとめて届く WebサーバーやCDNがレスポンスをbuffering ローカル直結とproxy経由を比較
再試行で二重生成 完了不明のまま自動再送 クライアントrequest IDとresponse IDを紐付ける

proxy buffering(プロキシのバッファリング)は、途中の小さなデータをWebサーバーやCDNがまとめてから送る動作です。SSEのヘッダーだけで解決しない環境もあるため、利用中のNginx、CDN、ホスティングの設定を確認します。

再接続で二重表示させない

自動再接続する前に、同じ処理を再開できるのか、新しい回答を作り直すのかを決めます。単純に同じリクエストを送ると別の回答と課金が発生し、古い回答の末尾へ新しい差分を追記すると混在します。

  • 送信ごとにアプリ側のrequest IDを発行する
  • 現在のrequest IDと一致する差分だけを画面へ反映する
  • 再実行前に古いcontrollerをabortする
  • 部分回答を残す場合は「未完了」と明示する
  • 429や一時障害の再試行は回数と待機時間に上限を付ける

レート制限の切り分けはOpenAI APIの429エラー対処法を参照してください。ストリーミング中も、無制限な再試行は同じ障害と費用を増やします。

本番前のチェックリスト

  1. APIキーがサーバーの環境変数だけにある
  2. 利用可能なモデルIDを設定ファイルまたは環境変数で管理している
  3. 入力長、認証、レート制限、タイムアウトがある
  4. delta・completed・error・未知イベントを分けている
  5. HTMLへ直接挿入せず、安全なテキスト表示にしている
  6. 切断時に上流のリクエストも中止する
  7. 部分結果と完了結果を区別して保存する
  8. 同時接続と再試行に上限がある
  9. response ID、所要時間、終了状態を個人情報なしで記録する
  10. ローカル、Webサーバー、CDN経由で逐次表示を確認する

まとめ:delta・完了・切断を別々に設計する

Responses APIのストリーミングは、stream: trueresponse.output_text.delta の連結から始められます。ただし、実用的な画面にするには、完了イベント、エラー、利用者の停止、接続切断、Markdown途中状態、再実行を別の状態として扱う必要があります。

まずCLI版でOpenAIのイベント順を確認し、次にNode.jsサーバーからSSEで必要な情報だけをブラウザへ中継してください。Responses API全体の出力構造や会話状態はOpenAI Responses APIの使い方で確認できます。

スポンサーリンク