Server-Sent Events による LLM 応答のストリーミング
LLM のトークンストリーミングが SSE 上でどう動くか:text/event-stream 形式、data 行と [DONE] 終端の解析、なぜ fetch が EventSource に勝るのか、そして AbortController でのキャンセル方法を解説します。
LLM API は Server-Sent Events(SSE) を使い、出力をトークン単位でストリーミングします。SSE は一方向のテキストプロトコルで、サーバーが text/event-stream データを送信し、クライアントはそれを到着した順に読み取ります。stream: true を設定すると、応答は単一の JSON 本体ではなくなり、小さなイベントの連なりになります。各イベントは JSON の断片を運ぶ 1 行の data: で、イベント同士は空行で区切られ、(Chat Completions では)リテラルな data: [DONE] で終わります。トークンを生成されたそばから描画するには、応答全体を待つのではなく、このストリームを逐次解析します。本記事では、そのワイヤーフォーマット、LLM 呼び出しでブラウザ組み込みの EventSource ではなく fetch を使うべき理由、そして AbortController でストリームをきれいにキャンセルする方法を説明します。
ストリーミングは追加の解析コストに見合う価値があります。体感レイテンシを大きく縮められるからです。ユーザーが感じるのは最初のトークンまでの時間(time-to-first-token)であり、ストリーミング応答は生成完了を待つのではなく数百ミリ秒で現れ始めます。その引き換えに、クライアントは今やパーサーとキャンセル経路を自ら抱えることになります。
SSE ワイヤーフォーマットを正確に
SSE は小さな規則のセットで定義されています。サーバーは Content-Type: text/event-stream で応答し、本体は UTF-8 テキストで、メッセージは 改行のペア(\n\n)で区切られます。各メッセージは 1 行以上の field: value から成ります。定義されているフィールドは 4 つだけです。
| フィールド | 意味 |
|---|---|
data | ペイロード。1 つのメッセージ内で連続する data: 行は、間に改行を挟んで連結されます。 |
event | イベント型の名前。対応するリスナーへディスパッチされます。省略時は既定の message です。 |
id | クライアントの「最後のイベント ID」を設定し、再接続時に Last-Event-ID ヘッダーで送り返されます。 |
retry | 再接続の遅延(ミリ秒)。 |
コロンで始まる行は コメント であり無視されます。プロバイダーはこれを、アイドル状態のプロキシが接続を閉じないようにするためのキープアライブ ping として使います。生の Chat Completions ストリームはワイヤー上でこのように見えます。
data: {"id":"chatcmpl-x","choices":[{"delta":{"role":"assistant"}}]}
data: {"id":"chatcmpl-x","choices":[{"delta":{"content":"Hello"}}]}
data: {"id":"chatcmpl-x","choices":[{"delta":{"content":" world"}}]}
data: [DONE]
各チャンクは chat.completion.chunk オブジェクトで、テキストは choices[0].delta.content にあり、delta を積み上げてメッセージを構築します。ストリームは終端 data: [DONE] で終わりますが、これは JSON ではありません。解析を試みる前にリテラル文字列としてチェックしなければ、JSON デコーダーが最後の行で例外を投げます。
2 つのイベント語彙:Chat Completions と Responses
トランスポートは同一ですが、イベントはエンドポイントによって異なります。Chat Completions は chat.completion.chunk の差分と [DONE] 終端を発します。Responses API は明示的な名前を持つ セマンティックイベント を発します——response.created、response.output_text.delta、response.completed など——各イベントが自分が何であるかを宣言します。セマンティックイベントはルーティングが容易です。チャンクの形を調べる代わりに、イベント名で分岐すればよいのです。エンドポイントを選ぶ段階なら Responses API と Chat Completions の比較 を、既存のストリーミングコードを移行するなら Chat Completions から Responses API への移行 をご覧ください。
Chat Completions ストリームで最終的な利用量の集計を得るには、stream_options: {"include_usage": true} を送ります。プロバイダーは最後のチャンクを追加し、その usage オブジェクトにトークン数が入るので、課金のためにゲートウェイの使用ログへ転送できます。
なぜ EventSource ではないのか?
ブラウザには組み込みの SSE クライアント EventSource がありますが、2 つの具体的な理由から、LLM 呼び出しには不適切なツールです。第一に、EventSource は GET リクエストしか発行できませんが、LLM 呼び出しは JSON 本体を伴う POST です。第二に、EventSource はカスタムヘッダーを設定できないため、Authorization: Bearer ... を送れません。どちらの制約も構造的なもので、設定で切り替えられるものではありません。
正しいアプローチは、読み取り可能なストリームを伴う fetch を使い、SSE を自分で解析することです。これにより POST、ヘッダー、そしてキャンセルの完全な制御が手に入ります。
const res = await fetch("https://your-gateway.example.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer AVE_GATEWAY_API_KEY",
},
body: JSON.stringify({ model: "gpt-5.6", stream: true,
messages: [{ role: "user", content: "Stream a haiku about latency." }] }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
// Normalize CRLF so the blank-line separator matches regardless of line endings.
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n"); // messages end at a blank line
buffer = events.pop(); // keep the incomplete trailing chunk
for (const evt of events) {
// One event can hold several data: lines; join them per the SSE spec.
const data = evt
.split("\n")
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).replace(/^ /, ""))
.join("\n");
if (!data) continue;
if (data === "[DONE]") return; // sentinel is not JSON
const json = JSON.parse(data);
const token = json.choices?.[0]?.delta?.content;
if (token) appendToken(token); // browser: write to a DOM text node
}
}
appendToken は描画先を表します。ブラウザではトークンをテキストノードまたは React の state に追加し、Node なら代わりに process.stdout へ書き込みます。解析上、見落としやすい重要な点が二つあります。まず \r\n を \n に正規化してください。一部の中継装置は CRLF を出力するため、単純に \n\n で分割すると \r\n\r\n の境界を見逃します。次に、イベントの最初の行だけを読むのではなく、すべての data: 行を結合してください。SSE 仕様では連続する data: 行を改行で連結するため、一行だけ残すと複数行のペイロードが壊れます。
バッファリングは重要です。TCP はメッセージ境界を尊重しないため、1 回の read() がイベントの半分だけ、あるいは複数のイベントをまとめて届けることがあります。空行の区切りで分割し、不完全な末尾を次の読み取りへ繰り越してください。これを省くのは最も一般的なストリーミングのバグで、テストでは動くのに負荷がかかると壊れます。
AbortController でストリームをキャンセルする
ユーザーはリクエストを放棄します。ページを離れたり、プロンプトを編集したり、暴走した生成を止めたりします。そうした瞬間に、トークンの課金を止めるべきです。AbortController がその仕組みです。コントローラーを作り、その signal を fetch に渡し、abort() を呼んで接続を破棄します。
const controller = new AbortController();
const res = await fetch(url, { method: "POST", signal: controller.signal, /* ... */ });
// stopButton.onclick:
controller.abort(); // fetch promise rejects with a DOMException named "AbortError"
abort() を呼ぶと、進行中の fetch は name が AbortError の DOMException で reject されるので、err.name === "AbortError" をチェックすることで、意図的なキャンセルと本当の失敗を区別できます。自前のコントローラーを配線せずにハードなタイムアウトを設けたい場合、AbortSignal.timeout(ms) は指定ミリ秒後に自動で中断するシグナルを返します——発火したタイムアウトは AbortError ではなく TimeoutError で reject します。速やかに中断することは直接的なコスト管理であり、LLM API コストの削減 につながります。
再接続と、LLM ストリームにおけるその限界
SSE には再接続が組み込まれています。id フィールドと retry フィールドにより、ブラウザは接続が切れた後 Last-Event-ID を再送して再開できます。これはイベントフィードには強力ですが、LLM 生成には部分的にしか当てはまりません。再開した接続は既に生成されたトークンの課金を取り消せず、ほとんどのモデルストリームは任意のオフセットからの再生を想定して設計されていないからです。LLM トラフィックでは、中断したストリームは不完全な結果として扱い、再生成してください——ただし二重課金されないよう冪等性と協調させることが必要で、これは LLM API のリトライと冪等性 で扱っています。インフラ面での注意点を 1 つ:HTTP/1.1 上の SSE はドメインあたり同時 6 接続に制限されるため、1 つのブラウザオリジンから多数の並列生成をストリーミングするには HTTP/2 が必要で、通常はゲートウェイがそれを終端してくれます。
サーバー経由で SSE ストリームをプロキシする
ほとんどの本番構成は、ブラウザからプロバイダーへ直接ストリーミングしません。プロバイダーのキーを保持し、認証・レート制限・ロギングを適用する自前のバックエンドやゲートウェイを経由してストリーミングします。SSE のプロキシには、間違えやすく、応答全体が揃うまでストリームが「ハングする」原因になるいくつかの要件があります。
- 応答のバッファリングを無効にする。 リバースプロキシやフレームワークは既定で応答をバッファリングすることが多く、これはストリーミングを完全に台無しにします。チャンクをすぐに送り出し、必要な箇所ではプロキシのバッファリングを無効にして、転送前に本体を溜め込むものが何もないようにしてください。
- 正しいヘッダーを設定する。
Content-Type: text/event-streamとCache-Control: no-cacheで応答し、中間装置がストリームをキャッシュしたり作り変えたりしないようにします。 - 転送し、再シリアライズしない。 できる限りプロバイダーのチャンクをそのまま通してください。トークンごとに再解析して再送出すると、レイテンシが増え、境界バグを招きます。
- キャンセルを伝播する。 クライアントが切断したら、上流のリクエストも中断してください。さもないと、誰も読まないトークンに対して支払い続けることになります。これは
AbortControllerのサーバー側の鏡像です。
ゲートウェイはこれらの自然な置き場所です。すでにクライアント接続を終端し、資格情報を保持しているからです。各プロバイダーをその背後のチャネルとして登録すれば、ゲートウェイは健全なアップストリームからストリーミングしつつ、すべてのクライアントに一貫した SSE の形を提示でき、ストリーミングされたリクエストのトークン使用量も非ストリーミングのリクエストと同じように記録できます。1 つのエンドポイントの背後で多数のプロバイダーを運用する場合、それぞれのイベント形式間のストリーミング翻訳は、OpenAI 互換 API ゲートウェイ が行うことの一部です。
FAQ
なぜ JSON パーサーがストリームの最後の行で例外を投げるのですか?
Chat Completions ストリームの最終行が data: [DONE] であり、[DONE] は JSON ではなくリテラルの終端だからです。ペイロードを JSON.parse に渡す前に [DONE] 文字列をチェックして止めてください。これはストリーミング解析で最も多いエラーです。
OpenAI 形式のストリーミングにブラウザの EventSource を使えますか?
いいえ。EventSource は GET 専用で Authorization ヘッダーを設定できませんが、LLM 呼び出しは認証付きの POST リクエストです。上の例で示したように、fetch と res.body.getReader() を使い、text/event-stream を自分で解析してください。
ストリームを止めてトークンの支払いを止めるにはどうしますか?
AbortController のシグナルを fetch に付け、controller.abort() を呼びます。接続が破棄され、fetch の promise は AbortError で reject します。中断によって課金が止まるのは、キャンセルがプロバイダーまで伝播した場合に限ります。プロキシ経由の構成では、サーバーも自分のアップストリーム呼び出しを中断しなければなりません。UI の「停止」コントロールとクライアント切断の両方に配線し、端から端まで伝播させてください。
Chat Completions と Responses のストリームはワイヤー上で同じに見えますか?
SSE トランスポートは共有しますが、イベントは異なります。Chat Completions は [DONE] で終わる chat.completion.chunk の delta を送り、Responses API は response.output_text.delta や response.completed のような名前付きセマンティックイベントを送ります。一方向けに書いたパーサーは、もう一方向けには調整が必要です。
キャンセル可能なストリームを組み込む
上の fetch + reader のスニペットを取り、stream: true で自分のゲートウェイに向け、トークンが届くたびに choices[0].delta.content をテキストノードへ描画してください。次に AbortController と停止ボタンを加えます。中断はベストエフォートです。キャンセルされたリクエストではローカルに記録される出力トークン数が減るはずですが、それは証拠ではなくシグナルとして扱ってください。切断後もアップストリームがしばらく生成または課金を続ける可能性があり、最終的なアップストリーム使用量が届かなければローカルのトークン数は推定値になることがあります。キャンセルが本当にプロバイダーまで届いたか確認するには、ゲートウェイの stream_status(client_gone マーカー)を、プロバイダー自身の使用量または請求書と突き合わせてください。ローカルのトークン数だけを根拠にしないでください。