マガジン

OpenAI APIのストリーミング応答をブラウザへ流す:サーバー経由SSEの組み立て方

AIを使いこなす

Claudeで執筆し、GPTで自動校閲・修正しています。

ノートにサーバーからブラウザへのイベントの流れを手描きしている
記事の内容をもとに制作したAI生成イメージです。実際の製品画面や検証結果の写真ではありません。

結論から言うと、ブラウザからOpenAI APIを直接呼ぶのではなく、自社サーバーが stream を有効にしてAPIを呼び、届いたイベントのうち必要なものだけを自社定義のSSE(Server-Sent Events)に詰め替えてブラウザへ流します。APIキーをサーバーに閉じ込められ、イベントの取捨選択と制限も自社側で掛けられるからです。 必要な部品は3つです。 1. OpenAI側のイベントを読むループ 2. 自社SSEへの変換(イベント種別の対応表) 3. 切断・エラー・画面表示の扱い この記事では、この3つを表と擬似コードにして示します。コードは説明用で、実行も動作検証もしていません。

この記事の目次
  1. 前提:OpenAI側で確認できたこと
  2. 手順1:全体の流れを決める
  3. 手順2:イベント種別の整理表
  4. 手順3:中継サーバーの擬似コード(未実行)
  5. 手順4:つまずきやすい3点を確認する
  6. 手順5:画面に出すときの安全面
  7. 最小の動作確認リスト

前提:OpenAI側で確認できたこと

公式のストリーミングガイドによると、Responses APIでは stream を有効にしてリクエストします。返るのは型付きのセマンティックイベントで、各イベントには type が付きます。代表的なライフサイクルとして response.created、response.output_text.delta、response.completed、error が挙げられています。関数呼び出しやファイル検索などに対応するイベントもあり、全一覧はAPIリファレンスにあります。詳細はStreaming API responses(OpenAI)を参照してください。

同じガイドは、ストリーミングだと部分的な出力の評価が難しく、本番ではモデレーションがやりにくくなると注意しています。後述の中継設計では、この点も判断材料にします。

ストリーミングイベントのリファレンスには、response.output_text.delta の差分テキストが delta フィールドに入ること、失敗時の response.failed と未完了時の response.incomplete も記載されています。

手順1:全体の流れを決める

流れは次のとおりです。

  • ブラウザ → 自社サーバー:POST /api/chat(本文は短いJSON)
  • 自社サーバー → OpenAI:stream 有効でリクエスト
  • OpenAI → 自社サーバー:型付きイベントが順に届く
  • 自社サーバー → ブラウザ:text/event-stream で delta / done / error を送る

ブラウザ側は EventSource ではなく fetch と ReadableStream で読む構成にしています。EventSource はGETしか使えないため、ユーザー入力をPOST本文で渡したいこの構成には合いません。

手順2:イベント種別の整理表

下の表は、テキストだけを返すチャットを想定した、編集上の設計案です。自社SSEのイベント名(delta など)は説明用の例で、OpenAIの仕様ではありません。

  • response.created、公式資料での確認:ガイド・リファレンスで確認、自社サーバーの扱い:応答IDをログ用に控える程度。送らない、ブラウザへ送るSSE:なし
  • response.output_text.delta、公式資料での確認:ガイド・リファレンスで確認。差分は delta、自社サーバーの扱い:差分テキストを取り出して転送、ブラウザへ送るSSE:delta
  • response.output_item.added / done、公式資料での確認:リファレンスで名称を確認、自社サーバーの扱い:テキストのみなら無視、ブラウザへ送るSSE:なし
  • response.completed、公式資料での確認:ガイドで確認、自社サーバーの扱い:正常終了として通知し、接続を閉じる、ブラウザへ送るSSE:done
  • error、公式資料での確認:ガイドで確認、自社サーバーの扱い:詳細は自社ログへ。画面には定型文、ブラウザへ送るSSE:error
  • response.failed / response.incomplete、公式資料での確認:リファレンスで確認、自社サーバーの扱い:error と同様に扱う、ブラウザへ送るSSE:error
  • 関数呼び出し・検索系など、公式資料での確認:ガイドに存在の記載あり、自社サーバーの扱い:許可リスト方式で既定は破棄、ブラウザへ送るSSE:なし

ポイントは「許可リスト方式」です。知らないイベントを素通しにせず、使うものだけ明示的に変換します。モデルや機能を足したときに、想定外の内部情報がブラウザへ漏れるのを避けられます。

手順3:中継サーバーの擬似コード(未実行)

Node.js + Express + OpenAI公式SDKを想定した例です。SDKの呼び出し形やモデル名は、お使いのバージョンのドキュメントで確認してください。

js
import express from "express";
import OpenAI from "openai";

const app = express();
app.use(express.json({ limit: "20kb" }));
const client = new OpenAI(); // キーは環境変数から読む想定

const send = (res, event, data) =>
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);

app.post("/api/chat", async (req, res) => {
  const text = String(req.body?.message ?? "").slice(0, 2000);
  if (!text) return res.status(400).json({ error: "empty" });

  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });
  const ac = new AbortController();
  res.on("close", () => ac.abort()); // ブラウザ切断で上流も止める

  try {
    const stream = await client.responses.create(
      { model: process.env.MODEL_NAME, input: text, stream: true },
      { signal: ac.signal }
    );
    for await (const ev of stream) {
      if (ev.type === "response.output_text.delta") {
        send(res, "delta", { text: ev.delta });
      } else if (ev.type === "response.completed") {
        send(res, "done", {});
      } else if (
        ev.type === "error" ||
        ev.type === "response.failed" ||
        ev.type === "response.incomplete"
      ) {
        console.error(ev); // 詳細はログだけ
        send(res, "error", { message: "生成に失敗しました" });
      } // それ以外は破棄
    }
  } catch (e) {
    if (!ac.signal.aborted) send(res, "error", { message: "接続に失敗しました" });
  } finally {
    res.end();
  }
});

ブラウザ側の受信例です(こちらも未実行)。

js
const r = await fetch("/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ message }),
});
if (!r.ok) return;
const reader = r.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += value;
  const blocks = buf.split("\n\n");
  buf = blocks.pop(); // 途中までのブロックは次回に持ち越す
  for (const b of blocks) {
    const ev = /^event: (.*)$/m.exec(b)?.[1];
    const data = JSON.parse(/^data: (.*)$/m.exec(b)?.[1] ?? "{}");
    if (ev === "delta") out.textContent += data.text;
    if (ev === "error") out.append("\n[エラーが発生しました]");
  }
}

手順4:つまずきやすい3点を確認する

データはJSONにして1行で送る

SSEは空行でイベントを区切ります。モデルの出力には改行が含まれるので、生の文字列をそのまま data: に入れると区切りが壊れます。上のコードのように JSON.stringify を通せば、改行はエスケープされます。

切断とエラーの扱い

  • ブラウザが閉じられたら上流の生成も止めます(例では AbortController)。止めないと、誰も見ない出力を生成し続けるおそれがあります。
  • ヘッダー送信後はHTTPステータスを変えられません。途中の失敗は error イベントで伝えます。
  • 入力の長さ制限と、自社エンドポイントの認証・回数制限は、中継を作る時点で入れておきます。キーを隠しても、無制限に呼べる入口を作っては意味がありません。

中間のバッファリング

リバースプロキシやCDNが応答をためてしまうと、ストリームが一括表示になります。手元で動いても本番で止まる典型例なので、構成に応じて、バッファリングの設定を確認してください。確認方法は使用する製品の資料によります。

手順5:画面に出すときの安全面

差分を innerHTML に足すのは避け、上の例のように textContent で出します。OWASPのLLM05: Improper Output Handlingは、モデル出力を検証・エンコードせずに表示するとXSSなどにつながると整理しています。対策としては、表示先に合ったエンコード、厳格なCSP(Content Security Policy)などが挙げられています。Markdownを整形表示したい場合は、サニタイズ済みのHTMLだけを描画してください。

また、前述のとおり部分出力ではモデレーションが難しくなります。ユーザーに見せる前に審査が必要なサービスなら、ストリーミングをやめて完成後に返す方式も選択肢です。

最小の動作確認リスト

自分の環境で、次の点を確かめてください。

  1. 通常のテキストが少しずつ画面に出る
  2. 改行を含む出力で表示が崩れない
  3. 生成途中にタブを閉じると、サーバーのリクエストが中断される
  4. 不正なAPIキーなどで失敗させたとき、画面には定型文だけが出てログに詳細が残る
  5. 本番相当の経路(プロキシ経由)でも、逐次表示される
  6. 出力に <script> を含む文字列を入れても、実行されずに文字として表示される

出典・参考資料