なぜストリーミングがUXを変えるのか
一言でいうと
300トークンの応答を、すべて作り終えてから一度に送ると、ユーザーは6秒間、空の画面で待ちます。ストリーミングすれば、200ms後から文字が出てきます。合計の時間は同じです。
なぜ必要なのか
体感性能と実際の性能が、これほど大きく分かれる領域は、まれです。総生成時間が同じ6秒でも、6秒後に一度に出てくるものと、200ms後から流れ出てくるものは、まったく別の製品です。後者は、ユーザーが読み始めることができ、方向が間違っていたら、途中で止めることもできます。
そのため、LLMサービスでストリーミングは、選択ではなく、基本です。
どう動くのか
転送方式は、3つのうちの1つです。WebSocketは双方向ですが重く、ロングポーリングは古く、SSE(Server-Sent Events)が、この用途に最も合っています。単方向で、HTTPの上で動作し、ブラウザーに標準のサポートがあります。
SSEの形式は、単純です。Content-Type: text/event-streamで応答し、各イベントを、data: <내용>(プレースホルダーは内容です)の1行と空行で区切って送ります。
data: {"token":"안녕"}
data: {"token":"하세요"}
data: [DONE]
空行がイベントの区切りだという点が重要です。これを抜かすと、クライアントがイベントを認識できず、バッファーに溜めておきます。
実装で必ず押さえるべきことが、4つあります。
第1に、バッファリングの解除です。サーバーフレームワークや前段のプロキシがレスポンスをバッファリングすると、ストリーミングが無意味になります。NGINXの前に置くなら、X-Accel-Buffering: noヘッダーが必要です。
第2に、終了シグナルです。ストリームがいつ終わったかを知らせる必要があります。[DONE]のような、慣例的なマーカーを使います。
第3に、キャンセルの処理です。ユーザーがブラウザーのタブを閉じたら、サーバーは生成を止める必要があります。これをしないと、誰も見ていないトークンを、GPUが作り続けます。コストがそのまま出ていきます。
第4に、エラーの伝達です。ストリームの途中でエラーが起きても、HTTPステータスコードを変えられません。すでに200でヘッダーを送ったからです。そのため、エラーもイベントとして送る必要があります。
間にあるものがストリームを妨げる
SSEを正しく実装したのに、ブラウザーには一度にまとめて届くことがよくあります。原因は、アプリケーションではなく、間にあるものです。
| 妨げるもの | 症状 | 直し方 |
|---|---|---|
| nginxのプロキシバッファー | 4KBずつまとまって届く | proxy_buffering off、またはレスポンスにX-Accel-Buffering: no |
| 圧縮ミドルウェア | 何も出てこなくて、最後に一度に出る | このパスだけgzipの対象から外す |
| フレームワークのバッファー | 最初の断片が遅い | フレームワークのストリーミング応答の型を使う |
| CDN | キャッシュするために全体を受け取る | このパスをキャッシュの対象から外す |
最も多いのは、1行目です。nginxは、デフォルトでアップストリームのレスポンスをバッファリングするので、何も設定せずに前に立てると、ストリーミングが丸ごと無力化されます。レスポンスヘッダー1行(X-Accel-Buffering: no)で、そのリクエストだけをオフにできるので、この方法が安全です。
切れた接続に気づいてこそ、GPUを節約できる
ユーザーがタブを閉じても、サーバーはトークンを作り続けます。誰も見ていない答えを、GPUが6秒間生成することになります。同時リクエストが多いサービスでは、この無駄が大きくなります。
そのため、クライアントの接続切断を検知して、生成を中止します。FastAPIなら、await request.is_disconnected()をトークンごとに確認し、vLLMは、リクエストをキャンセルすると、そのシーケンスをバッチから外します。
async def stream(request, prompt):
async for chunk in llm.generate(prompt):
if await request.is_disconnected():
await llm.abort(chunk.request_id) # GPU 를 놓아준다
return
yield f"data: {json.dumps(chunk.model_dump())}
"
yield "data: [DONE]
"
エラーをストリームの中で伝える
ストリーミングの厄介な点です。200 OKとヘッダーをすでに送ったあとで失敗すると、ステータスコードを変えられません。そのため、エラーもイベントとして送ります。
data: {"token":"안녕하"}
event: error
data: {"code":"context_length","message":"입력이 너무 깁니다"}
クライアントは、[DONE]なしでストリームが終わる場合も処理する必要があります。それはたいてい、ネットワークが切れたということで、すでに受け取った部分までは残しておいて、「応答が途中で切れました」と見せるほうが、何も見せないよりよいでしょう。
何を測るのか
ストリーミングサービスの指標は、2つです。
- TTFT(Time To First Token): ユーザーが体感する応答速度。200–500msを目標にします。
- TPOT(Time Per Output Token): 流れ出てくる速度。人が読む速度(毎秒5–10トークン)より速ければ十分です。
総生成時間は、この2つの結果にすぎないので、目標にしません。バッチサイズを大きくすると、全体のスループットは上がりますが、TTFTが悪くなります。スループットと体感速度はトレードオフの関係であり、どちらを生かすかは、製品が決めます。
現場での姿
ストリーミングをオンにすると、測定方法も変わります。応答の完了時刻だけを測っていたものを、最初のチャンクの時刻と、チャンク間の間隔に分けて測る必要があります。そして、これらの値は、バッチ処理の状況によって大きく揺れるので、p99を見る必要があります。
キャンセル処理を抜かした事故は、静かで高くつきます。ユーザーが長い応答の途中で、別の質問に移るパターンはよくありますが、キャンセルがないと、それらのリクエストがバッチのスロットを占有し続けます。同時スループットが理由なく下がり、GPUの請求書だけが増えます。
次のラボですること
決定的なトークン生成器を作って、SSEで流すサーバーを自分で実装します。TTFTとチャンク間の間隔を測定し、キャンセル時に生成が実際に止まることを、カウンターで証明します。