LLM応答のストリーミング:UXとコストにとって重要な理由
チャットUIが「壊れている」と感じさせた900ミリ秒
数か月前、私はカスタマーサポート用アシスタントをデバッグしていました。数字上のエンドツーエンドのレイテンシは十分に許容できるもので、ほとんどの応答は4〜7秒で完了していました。モデルはツールを選択し、アカウントのコンテキストを取得し、有用な回答を生成していました。
しかし、ユーザーには不評でした。
理由は単純でした。最初の3.8秒間、UIが空白のままだったのです。
スピナーに添える文言も、途中までの応答も、モデルが質問を理解したことを示す表示もありませんでした。あるのは空のアシスタントの吹き出しだけです。同じエンドポイントをServer-Sent Eventsによるトークンストリーミングに切り替えても、平均の総完了時間はほとんど変わりませんでした。しかし、多くのリクエストで最初の出力が表示されるまでの時間が約700〜900ミリ秒に短縮され、製品はすぐに速く感じられるようになりました。
これこそが、ストリーミングが重要な本当の理由です。
LLM応答のストリーミングは魔法ではありません。モデルの思考を速くするわけでも、総トークン数を減らすわけでも、料金を自動的に下げるわけでもありません。しかし、ユーザーが実感できる形でやり取りの流れを変え、キャンセル、描画、モデレーション、使用量の集計、バックプレッシャーをアプリケーション側でより細かく制御できるようにします。
この記事では、SSEストリーミングの実際の仕組み、測定すべき項目、課金で注意が必要な点、そして私が本番システムで使っているクライアント側の実装パターンを解説します。
ストリーミング応答を使う主なメリットは何ですか?
ストリーミング応答の主なメリットは、体感レイテンシです。ユーザーは生成が終わるまでスピナーを見続ける代わりに、数百ミリ秒で最初のトークンを目にできます。総生成時間はほとんど変わりません。変わるのは最初のトークンまでの時間であり、ユーザーが実際に体感するのはその数値です。完了まで9秒かかっても、400ミリ秒後に表示が始まる返信は速く感じられます。同じ返信でも、9秒後に一度に表示されると、壊れているように感じられます。
副次的な利点もあります。ストリーミングでは、途中までの出力を順次表示でき、ユーザーが十分な情報を得た時点で生成を早期にキャンセルして出力トークンを節約でき、長い応答でプロキシの読み取りタイムアウトを回避できます。ただし、一つだけ覚えておくなら、ストリーミングはモデルを速くするのではなく、ユーザー体験から待ち時間を感じさせなくするものだということです。
ストリーミングの実際の意味
ストリーミングを使わずにLLMへリクエストすると、通常、サーバーはモデルが応答の生成を終えるまで待ち、その後、単一のJSONペイロードを返します。
{
"id": "resp_123",
"model": "some-model",
"output_text": "Sure — here is the answer...",
"usage": {
"input_tokens": 812,
"output_tokens": 436
}
}
ストリーミングでは、出力が利用可能になるたびにサーバーがイベントを順次送信します。最も一般的な転送方式は Server-Sent Eventsで、通常は次のように略されます: SSE.
SSEは、接続を長時間維持し、特別なコンテンツタイプを持つHTTP応答にすぎません。
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
サーバーは単一のJSONオブジェクトではなく、小さなイベントを連続して送信します。
event: message.delta
data: {"delta":"Sure"}
event: message.delta
data: {"delta":" — here"}
event: message.delta
data: {"delta":" is"}
event: message.delta
data: {"delta":" the answer..."}
event: message.done
data: {"finish_reason":"stop","usage":{"input_tokens":812,"output_tokens":436}}
APIによってイベント名やスキーマは異なりますが、通常、流れは同じです。
- HTTP接続を開きます。
- リクエストのメタデータとプロンプトを送信します。
- チャンクを順次受信します。
- 現在のアシスタントメッセージにチャンクを追加します。
- 停止理由と、多くの場合は使用量のメタデータを含む最後のイベントを受信します。
- ストリームを閉じます。
実際には、ほとんどのLLMの「ストリーミング」は、イベントごとにトークンを一つ送る方式ではありません。プロバイダーは効率のために、複数のトークンをチャンクにまとめることがあります。受信するのは、単一の文字、単語の一部、複数の単語、あるいは構造化されたJSONの差分かもしれません。各チャンクが完全な単語や文であると想定したクライアントを作らないでください。
レイテンシ:総時間と最初のトークンまでの時間
最も重要なのは、次の二つを区別することです: 総レイテンシ と 最初のトークンまでの時間.
| 指標 | 測定対象 | 重要な理由 |
|---|---|---|
| 最初のバイトまでの時間、TTFB | サーバーから最初のバイトを受信するまで | ルーティング、認証、キュー待ち、モデルのスケジューリングを含む |
| 最初のトークンまでの時間、TTFT | 最初に生成されたトークンまたはチャンクがユーザーに表示されるまで | 応答性の体感を左右する主な要因 |
| 毎秒のトークン数 | 最初のトークン以降の出力生成速度 | 長い回答が完了する速さを決める |
| 総完了時間 | 応答全体を受信するまで | 自動化、ツール呼び出し、バッチ処理で重要 |
| 利用可能な回答が得られるまでの時間 | ユーザーが行動に移せるだけの情報を得るまで | 多くの場合、応答全体の完了より早い |
チャットUXでは、通常、TTFTのほうが総時間より重要です。800ミリ秒で表示が始まり、8秒で完了する応答は、4秒後に一度に表示される応答より快適に感じられることがよくあります。
マシン間のワークフローでは、ストリーミングの重要性は低いことが多くあります。JSONオブジェクト全体が完成するまで次の処理を実行できないなら、ストリーミングは実装を複雑にするだけかもしれません。
PythonでTTFTを測定する
最初のチャンクまでのレイテンシと、ストリーム全体の所要時間を測定するシンプルなパターンを紹介します。APIの形式は意図的に汎用的にしてあります。URLとペイロードは利用するプロバイダーに合わせて変更してください。
import json
import time
import requests
url = "https://api.example.com/v1/chat/completions"
payload = {
"model": "sonnet-4.6",
"stream": True,
"messages": [
{"role": "user", "content": "Explain SSE streaming in 5 bullet points."}
]
}
headers = {
"Authorization": "Bearer $API_KEY",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
start = time.perf_counter()
first_chunk_at = None
chars = 0
with requests.post(url, headers=headers, json=payload, stream=True) as r:
r.raise_for_status()
for raw_line in r.iter_lines(decode_unicode=True):
if not raw_line:
continue
if not raw_line.startswith("data: "):
continue
data = raw_line[len("data: "):]
if data == "[DONE]":
break
event = json.loads(data)
# Provider-specific: this may be event["delta"], event["choices"][0]["delta"], etc.
delta = event.get("delta", "")
if delta and first_chunk_at is None:
first_chunk_at = time.perf_counter()
chars += len(delta)
print(delta, end="", flush=True)
end = time.perf_counter()
print("\n\n--- metrics ---")
print(f"ttft_ms={((first_chunk_at or end) - start) * 1000:.0f}")
print(f"total_ms={(end - start) * 1000:.0f}")
print(f"chars={chars}")
よくある落とし穴として、応答をバッファリングするローカルプロキシ、サーバーレス関数、リバースプロキシを使ってストリーミングをテストすると、モデルがストリーミングしていないと思い込むことがあります。モデルは正しくストリーミングしていても、インフラが応答の完了までチャンクを保持している可能性があります。
私は次のようなケースでこれを経験しました。
- Nginxのバッファリングが有効になっている。
- クラウド関数がストリーミング応答をサポートしていない。
- 圧縮ミドルウェアが、より大きなバッファにデータがたまるのを待っている。
- Webフレームワークが、応答全体を構築してから返している。
- 企業のプロキシが、小さなチャンクをまとめている。
Nginxでは、次の設定が重要になることがよくあります。
location /api/llm/stream {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
add_header X-Accel-Buffering no;
}
SSEとWebSocketsの比較
ストリーミングの方法はSSEだけではありません。WebSocketsも一般的で、特に双方向のアプリケーションで使われます。ただし、ほとんどのLLMチャットインターフェースでは、SSEのほうがシンプルな標準の選択肢です。
| 機能 | SSE | WebSocket |
|---|---|---|
| 方向 | サーバーからクライアント | 双方向 |
| プロトコル | 通常のHTTP応答 | WebSocketへのアップグレード |
| ブラウザーのサポート | ネイティブ EventSource;次の方式でも利用可能: fetch ストリーム | ネイティブ WebSocket |
| 再接続の動作 | 仕組みが組み込まれているが、アプリ側のロジックも必要 | 自分で実装する |
| ロードバランサーとの相性 | 通常は扱いやすい | 追加の設定が必要な場合がある |
| 適した用途 | トークンストリーミング、進捗イベント、シンプルなチャット | リアルタイム共同作業、音声、複数ターンのライブセッション |
| リクエストボディのサポート | EventSource はGETのみ対応;次を使用: fetch POSTのストリーミング用 | 完全な双方向メッセージ通信 |
この EventSource ブラウザーAPIは便利ですが、GETリクエストにしか対応していません。ほとんどのLLM呼び出しにはPOSTの本文と認証ヘッダーが必要なので、私は通常、ストリーミングに次を使います。 fetch() と ReadableStream.
ブラウザークライアントのパターン:Fetch + ReadableStream
最小限のブラウザー実装は次のようになります。
async function streamChat({ messages, signal, onDelta, onDone }) {
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "gpt-5.5",
stream: true,
messages
}),
signal
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") {
onDone?.();
return;
}
const event = JSON.parse(data);
if (event.delta) {
onDelta(event.delta);
}
}
}
onDone?.();
}
UIでは次のようになります。
const controller = new AbortController();
let assistantText = "";
streamChat({
messages,
signal: controller.signal,
onDelta(delta) {
assistantText += delta;
renderAssistantMessage(assistantText);
},
onDone() {
markMessageComplete();
}
});
// Cancel button
document.querySelector("#stop").onclick = () => {
controller.abort();
};
チャンクごとに単純に再描画しない
小さなチャンクを受け取るたびにReactの状態を更新すると、意図せずクライアント側のパフォーマンス問題を引き起こすことがあります。ストリームによっては、毎秒数十個のチャンクが生成されます。そのたびに大きなチャットのツリーを再描画するのは無駄です。
実際には、私は通常、アニメーションフレームに合わせてUI更新をまとめます。
let pending = "";
let scheduled = false;
function onDelta(delta) {
pending += delta;
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
assistantText += pending;
pending = "";
scheduled = false;
setAssistantText(assistantText);
});
}
}
これにより、リアルタイムで出力されている感覚を保ちながら、インターフェースを滑らかに動作させられます。
未完成の出力を正しく扱う
ストリーミングでは、アプリケーションが未完成のテキストを受け取ります。当たり前に聞こえますが、未完成の出力を最終出力として扱うことが、多くのバグの原因になります。
Markdownは一時的に不正な形式になる
ストリーミング中は、Markdownの形式が不正になることがあります。
Here is the query:
```sql
SELECT *
FROM users
WHERE
コードフェンスがまだ閉じられていないかもしれません。表にはヘッダー行しかないかもしれません。リンクが途中までしか出力されていないかもしれません。レンダラーはこうした状態に対応する必要があります。
チャットUIでは、私は次の方針を好みます。
- 不完全な入力に備えながら、Markdownを段階的に描画します。
- 危険なHTMLを無効にします。
- メッセージが完成するまで、負荷の高いシンタックスハイライトを行いません。
- 控えめな「生成中」の表示を出します。
- ストリーム終了後に、最終的な書式処理を再実行します。
JSONのストリーミングはさらに難しい
モデルにJSONを要求してストリーミングすると、その途中の状態は通常、不正なJSONになります。これは、構造化されたUIをリアルタイムで更新したいアプリにとって問題です。
たとえば、次のように考えます。
{
"title": "Incident Summary",
"severity": "high",
"steps": [
"Restart
この段階ではまだ解析できません。
一般的な方法は次のとおりです。
- 完了まで待つその後、一度だけ解析します。
- 行区切りのJSONを使うこの形式では、完成した各行を個別に解析できます。
- 意味を持つイベントをストリーミングする生のJSONをそのまま流すのではなく、イベントとして送ります。
構造化されたストリーミングでは、私はイベント形式の出力を好みます。
event: section
data: {"title":"Summary"}
event: bullet
data: {"text":"Database failover started at 09:42 UTC."}
event: bullet
data: {"text":"Read latency remained elevated for 6 minutes."}
このパターンでは処理の調整が増えますが、確実に描画することがはるかに容易になります。
ユーザーが「停止」を押すと実際に何が起こるか
キャンセルできることは、ストリーミングの利点の中でも過小評価されがちです。
非ストリーミングのリクエストでは、サーバー上でモデルが生成を続けている間に、ユーザーがページを離れることがあります。プロバイダーとバックエンドによっては、その生成で引き続き出力トークンが消費される場合があります。
ストリーミングでは、クライアントがHTTPリクエストを中止できます。
const controller = new AbortController();
fetch("/api/chat/stream", {
method: "POST",
body: JSON.stringify(payload),
signal: controller.signal
});
// Later
controller.abort();
ただし、キャンセルがすべての処理層に即座に反映されるとは限りません。
次のような複数の層があります。
- ブラウザーがfetchを中止します。
- バックエンドが接続の切断を検知します。
- バックエンドが上流のプロバイダーへのリクエストをキャンセルします。
- プロバイダーが生成を停止します。
- すでに処理または生成されたトークンの使用量が計算されます。
よくある落とし穴として、バックエンドがストリームを中継していてもキャンセルを上流に伝播しない場合、ユーザーのブラウザーが切断されても、サーバーは完了までモデルからの読み取りを続けます。
Node形式の疑似コードでは次のようになります。
app.post("/api/chat/stream", async (req, res) => {
const upstreamController = new AbortController();
req.on("close", () => {
upstreamController.abort();
});
const upstream = await fetch(PROVIDER_URL, {
method: "POST",
headers: providerHeaders,
body: JSON.stringify({
...req.body,
stream: true
}),
signal: upstreamController.signal
});
res.setHeader("Content-Type", "text/event-stream");
for await (const chunk of upstream.body) {
res.write(chunk);
}
res.end();
});
これが「UIが停止した」と「モデルが停止した」の違いです。
ストリーミングとコスト:変わること、変わらないこと
ストリーミングによって、使用量を確認できるタイミングは変わります。通常、課金の基本単位は変わりません。
ほとんどのLLM APIは、次のような項目に基づいて課金します。
- 入力トークン。
- 出力トークン数。
- プロンプトキャッシュが適用される場合は、キャッシュされた入力トークン。
- APIによっては、ツール呼び出しや特殊なモダリティ。
- 特定のモデル系列では、推論トークンや、内部で計算予算が割り当てられた処理が課金対象になることもあります。
通常、ストリーミング自体によって料金が割り引かれるわけではありません。モデルが700の出力トークンを生成する場合、それが少しずつ届くか、最後にまとめて届くかにかかわらず、700の出力トークンとして計上されると考えるべきです。
ストリーミングがコストに影響するのは、運用面です。
| シナリオ | コストへの影響 |
|---|---|
| ユーザーが80トークンの後にキャンセルする | キャンセルが伝播すれば、残りの出力の生成を回避できる場合があります |
| UIが十分な回答を早い段階で検知する | アプリが意図的に生成を停止できます |
| バックエンドが上流の処理を中止できない | クライアントが切断されても、生成全体の料金が発生する場合があります |
| 使用量が最後のイベントでのみ届く | 中断されたストリームでは、プロバイダー側のログや推定値が必要になる場合があります |
| 長い回答をデフォルトでストリーミングする | 操作感が滑らかになるため、ユーザーの利用量が増える場合があります |
| テキストのストリーミング前にツールを呼び出す | TTFTは増える場合がありますが、出力トークンのコストは変わらない場合があります |
使用量の集計における落とし穴
多くのストリーミングAPIは、ストリームの最後に使用量のメタデータを送信します。
{
"type": "message.done",
"usage": {
"input_tokens": 1240,
"output_tokens": 389
},
"finish_reason": "stop"
}
最後のイベントより前にストリームが中断されると、アプリケーションが使用量を受け取れない場合があります。それは必ずしも使用量がゼロであることを意味しません。
内部集計のために、私は通常、次のフィールドを保存します。
{
"request_id": "chat_01j...",
"status": "client_aborted",
"usage": null,
"estimated_output_chars": 1842,
"final_usage_received": false
}
その後、プロバイダーがリクエストログや使用量イベントを公開していれば、後から照合します。公開していなくても、少なくともプロダクト分析用の概算はでき、課金時に実際以上の精度があるように扱うことを避けられます。
利用規約で明示的に認めていない限り、文字数からの推定値でエンドユーザーに課金しないでください。トークン化の方法はモデルと言語によって異なります。
バックエンドのアーキテクチャパターン
本番環境のストリーミング経路は、通常、次のようになります。
Browser
|
| POST /api/chat/stream
v
App Backend
|
| provider streaming request
v
LLM API
私はプロバイダーのキーをブラウザーに直接公開することはほとんどありません。バックエンドを設けると、次の機能を実現できます。
- 認証と認可。
- ユーザーまたはワークスペースごとのレート制限。
- プロンプトの組み立てと検索。
- ツールの実行。
- 機密情報のマスキングとログの制御。
- プロバイダーのフェイルオーバー。
- キャンセルの伝播。
- 使用量の記録。
PythonのFastAPIバックエンドから返す最小限のストリーミングレスポンスは次のとおりです。
import json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx
app = FastAPI()
PROVIDER_URL = "https://api.example.com/v1/chat/completions"
@app.post("/api/chat/stream")
async def chat_stream(request: Request):
body = await request.json()
async def event_generator():
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
PROVIDER_URL,
headers={
"Authorization": "Bearer YOUR_PROVIDER_KEY",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
json={
**body,
"stream": True,
},
) as upstream:
async for line in upstream.aiter_lines():
if await request.is_disconnected():
break
if not line:
continue
# Optionally transform provider-specific events here.
yield line + "\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
実際には、私は通常、プロバイダーのイベントを共通の内部スキーマに正規化します。
{
"type": "delta",
"text": "partial text here"
}
続いて、次のようにします。
{
"type": "done",
"finish_reason": "stop",
"usage": {
"input_tokens": 812,
"output_tokens": 436
}
}
これは、共通のインターフェースでClaude、GPT、Gemini、Llama、Qwen、Kimiなどの複数のモデルを提供する場合に重要です。ストリームの意味や挙動は、プロバイダーごとに少しずつ異なります。早い段階で正規化すれば、フロントエンドを単純に保てます。それこそが望ましい状態です。
TTFTはモデルだけで決まるわけではない
TTFTが悪いと、人はまずモデルを原因と考えがちです。それが正しい場合もあります。モデルが大きいほど、コンテキストが長いほど、推論モードの処理が重いほど、最初のトークンが届くまでの遅延は増える可能性があります。しかし、最初のトークンが届くまでには、多くの仕組みが関わっています。
影響する要因には次のものがあります。
- TLS接続の確立。
- バックエンドのコールドスタート。
- プロンプトの構築と検索。
- ベクトルデータベースの遅延。
- 生成前のツール呼び出し。
- プロバイダー側のキュー待ち。
- 長い入力コンテキストの取り込み。
- 安全性チェックやポリシー層。
- プロキシのバッファリング。
- クライアント側の描画遅延。
実用的なトレースでは、これらを分けて記録する必要があります。
{
"trace_id": "req_abc",
"timings_ms": {
"auth": 12,
"retrieval": 84,
"prompt_build": 9,
"provider_connect": 141,
"ttfb": 612,
"first_delta": 804,
"stream_complete": 5820
}
}
検索に 600 ミリ秒かかり、その 250 ミリ秒後にモデルの最初の差分が届くなら、モデルを切り替えても UX は改善しないかもしれません。プロバイダーへの接続確立が大半を占めているなら、接続の再利用が役立つ可能性があります。フロントエンドの描画が段落全体の到着を待っているなら、ストリームは機能していますが、UI の表示方針がそれを隠しています。
ストリーミングを使わないほうがよい場合
ストリーミングが常に割に合うとは限りません。
私は次の場合、ストリーミングを避けます。
- 分類やルーティングなど、応答が非常に短い場合。
- 呼び出し元が、完全な JSON を必要とする別のサービスである場合。
- 表示前に出力全体のモデレーションが必要な場合。
- リクエストがジョブキューやバッチパイプラインで実行される場合。
- フレームワークやホスティング層の影響でストリーミングの信頼性が低くなる場合。
- プロダクトが、段階的なテキスト表示ではなく、まとまった回答を必要とする場合。
UX 上の欠点もあります。ストリーミングでは、モデルが回答の途中で方向性を変える様子が表に出ることがあります。チャットではある程度許容されますが、重大な判断を伴うワークフローでは、洗練されていない印象を与えかねません。法律、医療、金融、または厳格なコンプライアンスが求められるインターフェースでは、ガードレールによる確認後にのみストリーミングするか、内部バッファにストリーミングしてから安全なチャンクを表示する方法を選べます。
実践的なクライアント側のパターン
ほとんどのチャット製品に推奨するパターンは次のとおりです。
1. テキストが届く前に状態を表示する
アシスタントの吹き出しを空のままにしないでください。明確な状態を表示します。
- 「コンテキストを読み取り中…」
- 「ドキュメントを検索中…」
- 「回答を作成中…」
- 「引き続き処理中…」
これは、テキスト生成の前にツール呼び出しが行われる場合に特に役立ちます。
2. 実際に処理を止める停止ボタンを用意する
停止ボタンは、単なる UX 上の飾りではありません。正しく実装すれば、出力トークンを節約できます。
次の点を確認してください。
- ブラウザーがリクエストを中止する。
- バックエンドが切断を検出する。
- バックエンドがプロバイダーへのリクエストを中止する。
- リクエストをキャンセル済みとして記録する。
3. 段階的に描画し、最後に整形する
ストリーミング中は、次の処理を行います。
- テキストを追加する。
- Markdown を軽量に描画する。
- 負荷の高いシンタックスハイライトを避ける。
- スクロールの挙動を予測しやすくする。
完了後は、次の処理を行います。
- Markdown を再解析する。
- コードをハイライトする。
- コピーボタンを有効にする。
- システムが対応していれば、引用や参照を付ける。
4. 未完のメッセージを慎重に保存する
ストリームが途中で失敗した場合、会話履歴上でどう扱うかを決めてください。
選択肢は次のとおりです。
- 未完のアシスタントメッセージを「中断」のマーク付きで残す。
- 完全に削除する。
- ユーザーが同じプロンプトで再試行できるようにする。
- 部分的なコンテキストから続きを生成するようモデルに依頼する。
私は通常、未完であることが明確にわかるマークを付けてメッセージを残します。何も知らせずに削除するとユーザーは混乱しますし、完了したように見せるのはさらによくありません。
5. イベントを正規化する
フロントエンドが、プロバイダーごとに異なる五つのストリーミング形式を知る必要はありません。バックエンドで正規化してください。
{"type":"delta","text":"Hello"}
{"type":"tool_start","name":"search_docs"}
{"type":"tool_result","name":"search_docs","status":"ok"}
{"type":"done","finish_reason":"stop","usage":{"input_tokens":100,"output_tokens":50}}
{"type":"error","message":"upstream timeout"}
これにより、モデルファミリー間の切り替えや、リクエストの動的なルーティングも容易になります。複数モデルに対応する API レイヤーを使っているなら、この正規化はすでにゲートウェイに組み込まれているかもしれません。そうでなければ、自分で実装する価値があります。
実践上のポイント
- ストリーミングは、最初の出力が画面に表示されるまでの時間を短縮することで体感遅延を改善します。生成全体が無料になったり、瞬時に完了したりするわけではありません。
- TTFT、完了までの総時間、毎秒のトークン数をそれぞれ別に測定してください。
- LLM のテキストストリーミングでは、通常 SSE が最もシンプルな通信方式です。使用するのは
fetch()POST の本文が必要な場合のストリームです。 - プロキシやフレームワークのバッファリングに注意してください。「ストリーミングのバグ」の多くは、インフラのバッファリングのバグです。
- 部分的な出力は無効なものとして扱ってください。Markdown が未完成だったり、JSON を解析できなかったり、コードフェンスがまだ閉じられていなかったりする可能性があります。
- キャンセルでコストを節約できるのは、中止がブラウザーからバックエンド、さらにプロバイダーまで伝わる場合だけです。
- 使用量のメタデータはストリームの最後に届くことが多いため、中断されたストリームでは慎重な集計が必要です。
- 小さなチャンクが届くたびに UI 全体を再描画しないでください。アニメーションフレームで更新をまとめて処理します。
- プロバイダー固有のストリームイベントは、バックエンド内で正規化してください。
- 短い分類、バッチジョブ、厳密な構造化出力、または表示前に出力全体のモデレーションが必要なワークフローでは、ストリーミングを使わないでください。
Claude Opus 5.5、Sonnet 5、Haiku 4.5、Fable 5.1に加え、GPT-6のモデルを、単一のAPIキーで利用できます。従量課金制で、サブスクリプションは不要です。
APIキーを取得 →