サーバーはまだ書き終えていないのに、なぜ ChatGPT の返信は一文字ずつ流れてくるのか?
Claude Code や ChatGPT に質問すると、答えが一気に表示されるのではなく、文字が少しずつ流れてくる。だが HTTP のレスポンスは「サーバーが本文を作り終えてから、まとめて返す」ものだと思っていなかっただろうか。本文の長さが確定していないのにどうやって送り始めているのか、説明できるだろうか。
- 普通の HTTP レスポンスは本文の総バイト数を
Content-Lengthとして先に伝えるが、LLM の出力は生成が終わるまで長さが決まらないため、この方式では最初の 1 文字も送れない。 - HTTP/1.1 は
Transfer-Encoding: chunkedで長さ不明のまま小分け送信を可能にしたが、HTTP/2 以降はこのヘッダー自体を禁止し、代わりにストリームのDATAフレームという転送層の仕組みで同じことをネイティブに行う。 - LLM の API はその上に Server-Sent Events(SSE) という意味のある区切りを重ね、
content_block_deltaのようなイベント単位でテキスト断片を送る。途中の reverse proxy が応答をまるごと溜め込んでいると、この仕組みは働かず一括表示に戻る。
普通のレスポンスは「全部書き終えてから」返ってくる
ブラウザが JSON や HTML を受け取るとき、サーバーは本文を丸ごと組み立ててから Content-Length ヘッダーに総バイト数を書き、その数だけ本体を送る。クライアントは「あと何バイト読めば終わりか」をヘッダーの時点で知っているので、受信が終わったタイミングも一意に決まる。
LLM の出力はこの前提が崩れる。モデルはトークンを1つずつ生成しており、止め時(ストップシーケンスへの到達や最大トークン数への到達)は生成しながらでないと分からない。サーバーが「全部生成してから Content-Length を確定してレスポンスを送る」方式を守ると、長い回答ほどユーザーは白い画面を長時間見つめることになる。
HTTP/1.1 の chunked は「長さは聞かないで、区切りは自分で言う」仕組み
この問題への HTTP/1.1 の答えが Transfer-Encoding: chunked だ。Content-Length を省略する代わりに、本文を「16進数のサイズ + データ」の組を繰り返す形に変え、サイズ 0 のチャンクで終端を示す。
HTTP/1.1 200 OK Transfer-Encoding: chunked 7\r\n Welcome\r\n 1c\r\n to Mozilla Developer Network\r\n 0\r\n \r\n
サーバーは総サイズを知らなくても、データが用意できた端からチャンクとして送り出せる。クライアントは「チャンクサイズを読む→その分だけ読む→繰り返す→サイズ 0 で終わり」という手順だけで、長さ不明の本文を受信できる。
ところが HTTP/2 は Transfer-Encoding ヘッダーの使用そのものを禁止しており、違反するとプロトコルエラーになる。理由は HTTP/2 の転送方式にある。HTTP/2 はリクエスト・レスポンスをバイナリの DATA フレームに分割してストリーム上に流し、最後のフレームに END_STREAM フラグを立てることで終端を示す。長さ不明のまま小分けに送るという chunked の役割は、この転送層のフレーム機構にもともと組み込まれているため、アプリケーション層で chunked を名乗る必要がなくなった。
マラソンの実況中継に近い。新聞記者はレースが終わってから記事1本にまとめて配達するが、ラジオの実況は「まだレースの結末は分からない」段階から「今この選手が先頭に立った」という断片をそのつど読み上げる。聞き手は放送が終わるのを待たずに、届いた断片から状況を組み立てていく。LLM のストリーミングも同じで、まだ生成が終わっていない(=総文字数が確定していない)段階から、確定した断片だけを先に送っている。
LLM の API はさらに SSE で「意味のある区切り」を重ねる
chunked や DATA フレームが保証するのは「バイト列を小分けに送れる」ことだけで、そのバイト列のどこが1つの意味的なまとまりかはアプリケーション側の約束事になる。LLM の API の多くはこの約束事として Server-Sent Events(SSE) を使う。レスポンスの Content-Type を text/event-stream にし、本文を event: と data: から始まる行の並びとして送り、空行区切りで1つのイベントとする形式だ。
Claude の Messages API を例にすると、ストリームはまず空の本文を持つ message_start イベントで始まり、コンテンツブロックごとに content_block_start → 複数の content_block_delta(delta.type が text_delta で、生成済みテキストの断片を運ぶ) → content_block_stop と続き、最後に message_stop で終わる。画面側の JavaScript は content_block_delta が届くたびに delta.text を末尾に追記しているだけで、これが「一文字ずつ流れる」表示の正体になる。
サーバー(生成中) クライアント │ content_block_delta │ │─────────────────────▶│→「サ」を表示 │ content_block_delta │ │─────────────────────▶│→「ー」を追記 │ content_block_delta │ │─────────────────────▶│→「バ」を追記 │ message_stop │ │─────────────────────▶│→ 表示完了
前回扱った WebSocket と比べると、SSE は Upgrade ヘッダーや 101 Switching Protocols によるプロトコル切り替えを必要としない、普通の HTTP レスポンスのままだ。LLM の応答はサーバーからクライアントへの一方向で足りるため、双方向通信のコストを払う理由がない。加えてブラウザの EventSource は接続が途中で切れても最後に受け取った id を使って自動的に再接続する仕組みを内蔵しており、生のストリーム受信を自前で書くより堅牢になる。
それでも画面が固まって見えるとき ― バッファリングの罠
サーバーが正しくチャンクごとに送信していても、途中に挟まる reverse proxy やロードバランサが応答本文を丸ごと溜め込んでからクライアントへ転送する設定になっていると、ストリーミングの効果は打ち消される。nginx はデフォルトで proxy_buffering が有効になっており、バックエンドからの応答を一旦バッファに貯めてからクライアントへ返す。これは低速なクライアントにバックエンドの接続を長時間握らせないための挙動だが、そのままでは SSE のイベントも全部貯まってから一括で届くことになる。
ストリーミング配信するエンドポイントに限って proxy_buffering off や X-Accel-Buffering: no ヘッダーを設定し、バッファリングを無効化するのはこのためだ。その代わり、接続を長く開けたままにする責任はアプリケーション側に移るため、前回の WebSocket の回で触れたロードバランサのアイドルタイムアウトと同じ種類の考慮が必要になる。
チャット UI に「AI がタイプしているような」表示を実装するとき、フロントエンドは fetch の ReadableStream や EventSource でレスポンスを少しずつ読み取り、届いた断片から DOM を更新する。バックエンドを nginx や ALB の背後に置く場合は、そのエンドポイントだけバッファリングを無効化しないと、ローカルでは動くのに本番ではまとめて表示されるという食い違いに遭遇する。
同じストリーミング用エンドポイントに対して、HTTP/2(デフォルト)と強制 HTTP/1.1 でレスポンスヘッダーを見比べ、Transfer-Encoding の有無を確かめる。
curl -sD - -o /dev/null "https://httpbin.org/stream/3" echo "---" curl -sD - -o /dev/null --http1.1 "https://httpbin.org/stream/3"
1 つ目(HTTP/2)には Transfer-Encoding ヘッダーが出てこない。ストリーミングは DATA フレームの機構で行われ、chunked というヘッダーでの表明が不要だからだ。2 つ目(強制 HTTP/1.1)には Transfer-Encoding: chunked が現れる。同じ「長さ不明のまま小分けに送る」という目的を、プロトコルのバージョンによって別の仕組みで実現していることが確認できる。
- ストリーミング表示は WebSocket を使っている — SSE も chunked も普通の HTTP レスポンスの範囲で完結する片方向の仕組みで、WebSocket のような
Upgradeによるプロトコル切り替えは発生しない。 - HTTP/2 ではチャンクごとの送信ができない — 禁止されているのは
Transfer-Encodingヘッダーという表明の仕方だけで、長さ不明のまま小分けに送る能力自体はDATAフレームの機構としてむしろネイティブに組み込まれている。 - サーバーが逐次送信していれば画面にも必ず逐次表示される — 経路上の reverse proxy がバッファリングしていると、サーバー側の送信タイミングに関わらずクライアントには一括で届く。
- Transfer-Encoding: chunked
- HTTP/1.1 で、本文の総サイズを事前に決めずに「サイズ + データ」の組を繰り返して小分け送信する仕組み。
- DATA フレーム
- HTTP/2 以降で本文を運ぶ最小単位。ストリームの終端は最後のフレームの
END_STREAMフラグで示す。 - Server-Sent Events(SSE)
text/event-stream形式で、サーバーからクライアントへ一方向にテキストイベントを流し続ける HTTP ベースの仕組み。- content_block_delta
- Claude の Messages API がストリーミング中に送る、生成済みテキストの断片を運ぶイベント種別。
- レスポンスバッファリング
- プロキシやロードバランサが応答本文を溜め込んでからまとめて転送する挙動。ストリーミングの効果を打ち消す。
- MDN: Transfer-Encoding — chunked のフォーマットと、HTTP/2 でこのヘッダーが禁止される理由を一次情報で確認できる。
- MDN: Using server-sent events —
event/data/id/retryフィールドの仕様と再接続の挙動を解説。 - Claude Docs: Streaming Messages —
message_start/content_block_delta/message_stopといった実際のイベント種別の一次ソース。