TT Lab
はじめる
学ぶ 学習パス コース

LLMサービング

なぜストリーミングがUXを変えるのか

TT Labで続きを見る

一言でいうと

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つです。

総生成時間は、この2つの結果にすぎないので、目標にしません。バッチサイズを大きくすると、全体のスループットは上がりますが、TTFTが悪くなります。スループットと体感速度はトレードオフの関係であり、どちらを生かすかは、製品が決めます。

現場での姿

ストリーミングをオンにすると、測定方法も変わります。応答の完了時刻だけを測っていたものを、最初のチャンクの時刻と、チャンク間の間隔に分けて測る必要があります。そして、これらの値は、バッチ処理の状況によって大きく揺れるので、p99を見る必要があります。

キャンセル処理を抜かした事故は、静かで高くつきます。ユーザーが長い応答の途中で、別の質問に移るパターンはよくありますが、キャンセルがないと、それらのリクエストがバッチのスロットを占有し続けます。同時スループットが理由なく下がり、GPUの請求書だけが増えます。

次のラボですること

決定的なトークン生成器を作って、SSEで流すサーバーを自分で実装します。TTFTとチャンク間の間隔を測定し、キャンセル時に生成が実際に止まることを、カウンターで証明します。