LLMのレイテンシ削減は、応答性の高いAIアプリケーションを構築するエンジニアにとって最も重要な課題の一つです。大規模言語モデル(LLM)は能力を高め続けていますが、そのトークンごとの生成はエンドユーザーにとって煩わしいボトルネックを生み出しかねず、長い待ち時間は直接エンゲージメントの低下やアプリケーションの離脱につながります。したがって、推論パイプラインを速度重視で最適化することは、開発者にとって中核的な要件です。本ガイドでは、プロンプトキャッシュの設定方法、レスポンスストリーミングの実装、エッジネットワークルーティングの構築、そして処理遅延を削減するためのサーバーレス構成の活用について解説します。
[!TIP] パフォーマンス指標のヒント: APIの遅延を測定する際は、Time to First Token(TTFT)を全体の生成速度から切り離して考えましょう。TTFTが低ければ、たとえ出力全体の生成に数秒かかっても、テキストがすぐに描画され始めるため、ユーザーにはアプリケーションが瞬時に反応しているように感じられます。
重要なポイント:
- プロンプトキャッシュ: 静的なプレフィックスブロックを再利用してパース処理を回避し、TTFTを80%削減します。
- レスポンスストリーミング: Server-Sent Events(SSE)でトークンを送信し、ユーザーに即時のテキスト生成を見せます。
- エッジWorker: 認可とリクエストルーティングを、ユーザーに近い地域のエッジ拠点で実行します。
- モデルルーティング: 単純なユーザーリクエストを軽量モデルに振り分けて速度を最適化します。
LLM APIレイテンシの構成要素
応答時間を短縮するには、まず全体的なAPI遅延を左右する要素を理解する必要があります。全体のレイテンシは、3つの異なる変数の累積合計です。
第一に、ネットワーク転送時間は、リクエストがクライアントからサーバーへ、さらにモデルプロバイダーのAPIへと移動するのにかかる時間を測定します。これにより、転送距離は大きなボトルネックとなります。
第二に、Time to First Token(TTFT)は、モデルがリクエストを受信してから最初の出力トークンを生成するまでの時間を表します。これこそがプロンプトキャッシュが非常に重要である理由です。
最後に、トークン生成速度は、ハードウェアが後続のトークンを出力する速度を測定します。生成速度はハードウェアの制約によって決まりますが、開発者は転送時間とTTFTを完全にコントロールできるため、賢いルーティングとキャッシュによってLLMのレイテンシを大幅に削減できます。
LLM統合を高速化しましょう前提条件
以下の最適化に取りかかる前に、次のものが揃っていることを確認してください。いずれも特殊なものではありませんが、一つでも欠けると後で分かりにくい不具合を引き起こしがちです。
- 自分で制御できるミドルウェアまたはエッジランタイム。 例ではCloudflare Workersを使用しますが、リクエストをプロキシできるサーバーレスプラットフォームであれば何でも構いません。ブラウザとモデルプロバイダーの間に位置する場所が必要です。
- ストリーミングとキャッシュに対応したプロバイダーのAPI認証情報。 OpenAIとAnthropicはどちらも対応しています。キーはシークレット(Wranglerのシークレットまたは環境変数)として保存し、クライアント側のコードには決して入れないでください。
- Node.js 18以降(
wrangler devでWorkerをローカルにテストしたい場合)。本ガイド全体で使用するグローバルのfetchおよびReadableStreamAPIは、そのランタイムとモダンブラウザで利用できます。 - ベースライン測定。 何かを変更する前に、現在のTime to First Tokenと総応答時間を記録しておき、各最適化が実際に効果を発揮したことを証明できるようにします。以下のベンチマークのセクションでその方法を示します。
- Server-Sent Events(SSE)への習熟。 ストリーミングレスポンスは
data:行の連続として届き、それをクライアント側でパースします。
プロンプトキャッシュの実装
プロンプトキャッシュは、大きなシステムプロンプトを中心に構築されたアプリケーションでTTFTを最適化する最も効果的な方法です。リクエストに長い静的な命令ブロック(エージェントのシステムプロンプトやRAGの参照ドキュメントなど)が含まれる場合、モデルプロバイダーは実行のたびにそれらのトークンをパースしてエンコードしなければなりません。AnthropicとOpenAIはどちらもプロンプトキャッシュに対応しており、パース済みのトークン状態をメモリに保存します。同じプレフィックスを共有する後続のリクエストは、その後パース段階を回避し、TTFTを最大80%削減します。
キャッシュの有効期間はプロバイダーによって異なります。Anthropicは非アクティブ状態が約5分続くまでキャッシュを保持しますが、OpenAIは動的な減衰モデルを使用します。したがって、定期的なバックグラウンドのfetch pingをスケジュールすることで、重要なシステム命令をサーバーメモリ内でアクティブに保つことができます。
メカニズムは2つのプロバイダー間でわずかに異なり、リクエストの構造を正しく整えることが、キャッシュが実際に機能するかどうかを決定づけます。Anthropicでは、cache_controlを使ってキャッシュのブレークポイントを明示的にマークします。ブレークポイントより前のすべてが保存されるため、安定した静的なコンテンツを最初に、リクエストごとに変動するコンテンツを最後に配置しなければなりません。
1import Anthropic from "@anthropic-ai/sdk";
2
3const anthropic = new Anthropic();
4
5const response = await anthropic.messages.create({
6 model: "claude-opus-4-8",
7 max_tokens: 1024,
8 system: [
9 {
10 type: "text",
11 text: SYSTEM_INSTRUCTIONS // small, sent on every request
12 },
13 {
14 type: "text",
15 text: KNOWLEDGE_BASE, // large, static reference block
16 cache_control: { type: "ephemeral" }
17 }
18 ],
19 messages: [
20 { role: "user", content: userQuestion } // volatile — after the breakpoint
21 ]
22});
23
24// Confirm the cache is working
25console.log(response.usage.cache_read_input_tokens);
最もよくある間違いは、タイムスタンプ、リクエストID、あるいはリクエストごとに変わる文字列をキャッシュされたブロックの前に置くことです。キャッシュはプレフィックスの一致に基づくため、ブレークポイントより前のどこかで1バイトでも変わると、その後のすべてが無効になり、キャッシュは静かにまったくヒットしなくなります。レスポンスからusage.cache_read_input_tokensを読み取って動作を確認しましょう。同一のリクエストでこの値がゼロのままなら、何か動的なものがプレフィックスに紛れ込んでいます。また、キャッシュが機能し始めるには、キャッシュされたプレフィックスが最小の長さ(モデルによりおよそ1,024〜4,096トークン)を超えている必要がある点にも注意してください。
OpenAIはよりシンプルなアプローチを取ります。キャッシュはおよそ1,024トークンを超えるプロンプトに対して自動的に行われ、設定すべきcache_controlフラグはありません。ただし、同じ規律は依然として当てはまります。静的な命令ブロックをmessages配列の先頭に保ち、変化するユーザー入力を末尾に追加して、再利用可能なプレフィックスがリクエスト間でバイト単位で同一に保たれるようにします。
プロンプトキャッシュの料金体系とパラメーター構造については、Anthropicのプロンプトキャッシュガイド を参照してください。
エッジコンピュートとサーバーレスルーティング
LLMのリクエストを単一の集中型サーバーで処理すると、世界中のユーザーにとって膨大なネットワークホップが発生します。APIミドルウェアをサーバーレスのエッジネットワーク(Cloudflare Workersなど)にデプロイすると、それらの経路が劇的に短縮されます。
エッジWorkerはクライアントのリクエストを受け取り、セッションを認可し、最も近いモデルプロバイダーのデータセンターへルーティングします。このサーバーレス構造は、トークンが計算されたその瞬間にユーザーの画面へ届けるため、インターフェースは非常に応答性が高く感じられます。以下のJavaScriptミドルウェアは、エッジランタイムから直接ストリーミングレスポンスを構成する方法を示しています。
1export default {
2 async fetch(request, env) {
3 const payload = await request.json();
4
5 // Call the streaming LLM endpoint
6 const response = await fetch("https://api.openai.com/v1/chat/completions", {
7 method: "POST",
8 headers: {
9 "Authorization": `Bearer ${env.OPENAI_API_KEY}`,
10 "Content-Type": "application/json"
11 },
12 body: JSON.stringify({
13 model: "gpt-4o-mini",
14 messages: payload.messages,
15 stream: true
16 })
17 });
18
19 // Forward the stream directly to the client browser
20 return new Response(response.body, {
21 headers: { "Content-Type": "text/event-stream" }
22 });
23 }
24};
このサーバーレス構造は、計算されるそばからトークンを即座にユーザーの画面へ届けます。エッジ最適化されたバックエンドの構築方法については、Cloudflare WorkersでサーバーレスAPIを構築する ガイドをお読みください。
クライアントでのストリームのパース
エッジからストリームを転送するのは、作業の半分にすぎません。ブラウザは依然として、届いたチャンクを到着次第読み取り、各トークンを描画する必要があります。そうしなければ、レスポンスはバッファに蓄積されて一度にまとめて表示され、本来の目的が損なわれてしまいます。これはほとんどのチュートリアルが飛ばす手順であり、体感パフォーマンスが実際に得られるか失われるかが決まる箇所です。
レスポンスボディは生のバイトのReadableStreamです。SSEフレームはdata:行として届きますが、1つのネットワークチャンクに複数のフレームが含まれることもあれば、1つのフレームが2つのチャンクに分割されることもあります。そのため、各チャンクが完全なメッセージであると仮定するのではなく、部分的な行をバッファリングしなければなりません。
1async function streamCompletion(messages, onToken) {
2 const response = await fetch("/api/chat", {
3 method: "POST",
4 headers: { "Content-Type": "application/json" },
5 body: JSON.stringify({ messages })
6 });
7
8 const reader = response.body.getReader();
9 const decoder = new TextDecoder();
10 let buffer = "";
11
12 while (true) {
13 const { value, done } = await reader.read();
14 if (done) break;
15
16 buffer += decoder.decode(value, { stream: true });
17 const lines = buffer.split("\n");
18 buffer = lines.pop(); // keep the trailing partial line
19
20 for (const line of lines) {
21 if (!line.startsWith("data: ")) continue;
22 const payload = line.slice(6).trim();
23 if (payload === "[DONE]") return;
24
25 try {
26 const json = JSON.parse(payload);
27 const token = json.choices?.[0]?.delta?.content;
28 if (token) onToken(token);
29 } catch {
30 // ignore keep-alive comments and malformed partial frames
31 }
32 }
33 }
34}
buffer.split("\n")に続くlines.pop()が重要な点です。次のチャンクが完成させるまで、不完全な行を保持します。JSON.parseをtry/catchで囲むことで、keep-aliveコメントや途中まで受信したフレームが届いてもループを生かし続けられます。その後、onTokenコールバックが各フラグメントをDOMに追加するため、モデルが生成した瞬間にテキストが表示されます。
レイテンシの測定とベンチマーク
測定していないものを最適化することはできません。各変更の前後でTime to First Tokenと総生成時間を記録し、改善を正しい原因に帰属できるようにします。TTFTをサンプリングする最も手早い方法はcurlを使うことで、time_starttransferを最初のバイトがクライアントに届くタイミングの近似として利用します。
1curl -w "TTFT: %{time_starttransfer}s\nTotal: %{time_total}s\n" \
2 -X POST https://your-worker.example.com/chat \
3 -H "Content-Type: application/json" \
4 -d '{"messages":[{"role":"user","content":"Hello"}]}' \
5 -o /dev/null -s
アプリケーションレベルの数値については、クライアント側のパーサーを直接計測します。リクエストが送出されたときにクロックを刻み、最初のトークンが到着したときに再度刻みます。
1const start = performance.now();
2let firstTokenAt = null;
3
4await streamCompletion(messages, (token) => {
5 if (firstTokenAt === null) firstTokenAt = performance.now();
6 render(token);
7});
8
9console.log(`TTFT: ${Math.round(firstTokenAt - start)}ms`);
各測定は複数回実行し、1回のサンプルではなく中央値を取ってください。ネットワークのばらつきやコールドスタートが一度きりの測定値をゆがめる可能性があるためです。以下の表は、行儀のよいグローバルリクエストで時間が通常どこに費やされるかについての例示的な範囲を示しています。これらは固定値ではなく比較のための目安として扱ってください。実際の数値は、リージョン、モデル、プロンプトサイズによって変わるためです。
| 段階 | 典型的な割合 | 制御可能か? |
|---|---|---|
| ネットワーク転送(クライアントからエッジ) | 10〜60 ms | はい — エッジルーティングで短縮 |
| エッジでのミドルウェア処理 | 1〜15 ms | はい — Workerを軽量に保つ |
| Time to First Token(コールドプロンプト) | 400〜1,200 ms | 一部 — キャッシュで大きく削減 |
| Time to First Token(キャッシュ済みプレフィックス) | 100〜400 ms | はい — プロンプトキャッシュで |
| トークンごとの生成 | 10〜50 ms/トークン | いいえ — モデルとハードウェアで決定 |
じっくり見る価値がある2つの行は、キャッシュ済みとコールドのTTFTの数値です。この差は、ほとんどのアプリケーションで得られる最大の単一の効果であり、プロンプトキャッシュが最適化リストの筆頭に位置する理由です。一方、生成速度はプロバイダーによって固定されているため、より単純なリクエストを小さなモデルにルーティングすることがそこでの唯一の手立てです。
ステップバイステップの最適化ワークフロー
ソフトウェアアプリケーションの速度を最適化するには、まず静的なシステム命令を動的なユーザー入力から分離することから始めます。この分割により、キャッシュのエントリーポイントをきれいに狙えます。
次に、API ペイロード内でプロンプトキャッシュのフラグを有効にして、モデルプロバイダーがテキストトークンをメモリに保存するようにします。
レスポンスストリーミングは、常に標準的なSSEエンドポイントを使って構成しましょう。チャンクを到着次第処理する軽量なフロントエンドパーサーを書くことで、ユーザーが体感するパフォーマンスを向上させます。続いて、モデルのフォールバック経路を確立します。基本的な顧客入力を小さなモデルへルーティングし、大きな推論モデルは高度なタスクのために確保します。最後に、ネットワークホップをプロファイルして、サーバーレスWorkerが転送遅延を削減していることを確認します。データベース最適化の詳細については、WordPressとカスタムWeb開発の比較 ガイドをご覧ください。
よくある落とし穴とトラブルシューティング
チームがストリーミングとキャッシュを初めて導入する際には、いくつかの不具合が繰り返し発生します。症状を認識できれば、何時間もの当て推量を省けます。
| 症状 | 考えられる原因 | 対処 |
|---|---|---|
cache_read_input_tokensがゼロのまま | タイムスタンプ、UUID、またはセッションIDがキャッシュのブレークポイントより前にあり、リクエストごとにプレフィックスが変わっている | すべての動的コンテンツを静的ブロックの後ろに移動する。JSONは決定論的にシリアライズする |
| トークンが段階的にではなく一度に届く | 中間のプロキシまたはCDNがレスポンスをバッファリングしている | Cache-Control: no-transformとX-Accel-Buffering: noを送信し、text/event-streamのcontent typeが設定されていることを確認する |
| ストリームが途中で切れる | upstreamのボディが終わる前にWorkerが返した、またはmax_tokensに達した | 全文を待たずにresponse.bodyを直接返す。max_tokensを引き上げる |
| キャッシュしているのに最初のトークンが遅い | 静的プレフィックスがプロバイダーの最小キャッシュ可能長を下回っている | 命令を統合し、キャッシュされるブロックが約1,024トークンの下限を超えるようにする |
| クライアントのパーサーが一部のチャンクで例外を投げる | フレームが2つのネットワークチャンクに分割された | 上記のように部分的な行をバッファリングし、JSON.parseをtry/catchで囲む |
より巧妙な罠は、エッジそのものでのバッファリングです。Worker内で返す前にawait response.text()を呼ぶと、ストリーミングレスポンスを知らないうちにブロッキングなものに戻してしまっています。ストリームのボディは常にそのまま通してください。同様に、WorkerのCPU制限にも注意しましょう。ミドルウェアでのリクエストごとの重い処理はTTFTに直接加算されるため、認可とルーティングのロジックは最小限に保ち、コストの高い処理はすべて後回しにします。
本番運用での考慮事項
ブラウザでデモのストリーミングを動かすのは簡単ですが、実際のトラフィック下で確実に運用するには、いくつかの追加の防御策が必要です。
upstreamの呼び出しに適切なリクエストタイムアウトを設定し、停滞したプロバイダー接続がWorkerを無期限に開いたままにできないようにします。そして、プライマリがタイムアウトしたときに第2のプロバイダーや小さなモデルにフォールバックするリトライと組み合わせます。プロンプトキャッシュはキャッシュ書き込みにわずかなプレミアムを、読み取りに大きな割引を課すため、プレフィックスが再利用されるときにのみ元が取れます。数分ごとのバックグラウンドpingは、ユーザーリクエストごとに書き換える費用を払わずに、稼働中のシステムプロンプトをメモリに常駐させます。
計測はローンチ時だけでなく継続的に行いましょう。リクエストごとのTTFTと毎秒トークン数をログに記録し、中央値がずれたときにアラートを出します。プロバイダー側の劣化やプロンプトサイズの変化は、まずそこに現れるためです。最後に、プロバイダーのレート制限を尊重してください。同時ストリームが一気に増えると制限に引っかかる可能性があるため、リクエストを静かに失敗させるのではなく、キューに入れるか、負荷を適切に間引きます。これらの対策が、速いプロトタイプを、肝心なときに速さを保つアプリケーションへと変えます。
重要なポイント
- LLMのレイテンシを削減するために、Time to First Token(TTFT)と転送時間を狙います。
- モデルAPIでプロンプトキャッシュを活用し、システム的な命令パースのオーバーヘッドを回避します。
- レスポンスストリーミングを使ってトークンをリアルタイムに届け、体感速度を向上させます。
- APIミドルウェアをサーバーレスのエッジランタイムにデプロイし、グローバルなネットワーク経路を短縮します。
- より単純なユーザークエリを軽量モデルにルーティングし、実行速度を最適化します。
- パフォーマンス監視ツールを設定し、実際のユーザー条件下でレイテンシを継続的に分析・削減します。
よくある質問(FAQ)
本番環境でLLMのレイテンシを削減するには? 本番環境でLLMのレイテンシを削減するには、静的な命令に対してプロンプトキャッシュを実装し、トークンストリーミングを有効にし、エッジWorkerをデプロイしてリクエストルーティングを最適化する必要があります。サーバーレスのオーケストレーションランタイムを世界中のクライアントの近くにデプロイすることで、開発者は複数のネットワークルーティングホップを回避し、最初のレスポンストークンをリアルタイムで届けられます。
プロンプトキャッシュとは? プロンプトキャッシュは、パース済みのテキスト状態をサーバーメモリに保存するAPI機能で、同じプレフィックスを使う後続のリクエストをはるかに高速に実行できるようにします。大きな命令データセットに対するシステム的なパースサイクルを回避することで、この最適化はTime to First Token(TTFT)を最大80パーセント削減します。
モデルサイズはレイテンシに影響しますか? はい。小さなモデルはトークン生成速度がはるかに速いため、レイテンシが主な関心事となる単純なタスクに理想的です。単純な分類や抽出のリクエストを専用のエッジモデルにルーティングすることで、迅速なターンアラウンドを確保しつつ、密なモデルは推論タスクのために確保できます。
Server-Sent Events(SSE)ストリーミングは、どのように体感レイテンシの削減に役立ちますか? SSEストリーミングは、テキスト出力トークンをモデルホストからクライアント画面へ、コンパイルされるそばからリアルタイムに送信します。これは総実行時間を短縮するわけではありませんが、Time to First Token(TTFT)を最小化し、ユーザーに応答性の高い、能動的なアプリケーションインターフェースを提供します。
動的なLLMレスポンスをエッジでキャッシュするには? 動的なレスポンスは、短いTTL(Time to Live)制限を設けたKVデータベースやRedisインスタンスを使ってエッジでキャッシュできます。動的なレスポンスのキャッシュは、繰り返されるユーザークエリや一般的なカスタマーサービスの意図に対して効果的で、モデルプロバイダーへのネットワーク呼び出しを完全に防ぎます。
コメント