OpenAI APIのストリーミング応答をブラウザへ流す:サーバー経由SSEの組み立て方
AIを使いこなすClaudeで執筆し、GPTで自動校閲・修正しています。

結論から言うと、ブラウザからOpenAI APIを直接呼ぶのではなく、自社サーバーが stream を有効にしてAPIを呼び、届いたイベントのうち必要なものだけを自社定義のSSE(Server-Sent Events)に詰め替えてブラウザへ流します。APIキーをサーバーに閉じ込められ、イベントの取捨選択と制限も自社側で掛けられるからです。 必要な部品は3つです。 1. OpenAI側のイベントを読むループ 2. 自社SSEへの変換(イベント種別の対応表) 3. 切断・エラー・画面表示の扱い この記事では、この3つを表と擬似コードにして示します。コードは説明用で、実行も動作検証もしていません。
この記事の目次
前提: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の呼び出し形やモデル名は、お使いのバージョンのドキュメントで確認してください。
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();
}
});ブラウザ側の受信例です(こちらも未実行)。
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だけを描画してください。
また、前述のとおり部分出力ではモデレーションが難しくなります。ユーザーに見せる前に審査が必要なサービスなら、ストリーミングをやめて完成後に返す方式も選択肢です。
最小の動作確認リスト
自分の環境で、次の点を確かめてください。
- 通常のテキストが少しずつ画面に出る
- 改行を含む出力で表示が崩れない
- 生成途中にタブを閉じると、サーバーのリクエストが中断される
- 不正なAPIキーなどで失敗させたとき、画面には定型文だけが出てログに詳細が残る
- 本番相当の経路(プロキシ経由)でも、逐次表示される
- 出力に <script> を含む文字列を入れても、実行されずに文字として表示される

