AIに質問してから「最初の一文字」が出るまで、裏側で何が起きているのか
AIチャットで送信ボタンを押すと、少し待ったあとに回答が一文字ずつ現れ始めます。
画面上で見えるのは「質問を送った」「文字が出てきた」という二つの変化だけです。しかし、その間ではWeb APIがリクエストを受け取り、会話履歴を組み立て、推論サーバーがGPUへ処理を割り当て、生成されたデータがもう一度ネットワークを通ってブラウザへ戻っています。
しかも、「最初の文字が出るまで」と「出始めてからの生成速度」は、同じ理由で決まっているわけではありません。
この記事では、一つの質問が次の経路を進む様子を追いかけます。
送信ボタン
↓
ブラウザからAPIへリクエスト
↓
認証・入力検証・レート制限
↓
システム指示・会話履歴・検索結果などを組み立てる
↓
TokenizerでトークンIDへ変換
↓
キューイング・バッチ編成
↓
GPUでPrefill
↓
KV Cacheを作成
↓
Decodeとサンプリング
↓
SSEなどでテキスト差分を送信
↓
ReadableStreamで受信
↓
ブラウザが画面へ描画先に断っておくと、特定の商用AIサービスがどのような内部構成を採用しているかは、通常すべて公開されているわけではありません。
以下は、Transformerの論文、Web標準、vLLM、Hugging Face Transformers、NVIDIA Tritonなどの公開資料を基にした「一般的なLLMサービスの構成例」です。実際のサービスでは、CDN、WAF、ロードバランサー、モデレーション、検索、ツール実行、複数モデル間のルーティングなどが追加されることがあります。
まず、4者の役割を分ける
AIチャットの経路は、次の4者へ分けると理解しやすくなります。
ブラウザ
利用者の入力を受け取り、HTTPリクエストを送ります。返ってきたストリームを読み、停止操作を処理し、回答を画面へ描画します。
Webサーバー/API
認証、レート制限、入力検証、会話履歴の取得、システム指示の追加などを行い、推論サーバーへリクエストを中継します。
LLM推論サーバー
文章のトークン化、リクエストのキュー管理、バッチ編成、GPUへの割り当て、次トークンの選択、トークンIDから文字列への復元を担当します。
GPU
Transformerの大規模な行列演算を実行します。Attention、Prefill、Decode、KV Cacheの読み書きなど、モデル本体の計算を担います。
ここで押さえておきたいのは、GPUがHTTPやSSEを処理しているわけではないことです。
GPUは主に数値計算を行います。その前後をWebサーバーと推論サーバーがつなぎ、最終的にブラウザが文字として表示しています。
ブラウザ
│ POST /api/chat
▼
Webサーバー/API
│ 認証・履歴取得・プロンプト構築
▼
LLM推論サーバー
│ Tokenizer・キュー・バッチ・サンプリング
▼
GPU
│ Prefill・KV Cache・Decode
▼
LLM推論サーバー
│ トークンIDを文字列へ戻す
▼
Webサーバー/API
│ SSEなどで差分を転送
▼
ブラウザ
│ ReadableStream
▼
画面へ逐次描画1.送信ボタンを押すと、ブラウザがAPIを呼び出す
送信ボタンを押すと、ブラウザは`fetch()`などを使ってチャットAPIへリクエストを送ります。
送信するのは、今回の質問だけとは限りません。ステートレスなAPIでは、必要な会話履歴を`messages`配列として毎回送ることがあります。
{
"messages": [
{
"role": "user",
"content": "SSEとは何ですか?"
},
{
"role": "assistant",
"content": "SSEはサーバーから..."
},
{
"role": "user",
"content": "WebSocketとの違いは?"
}
]
}別の設計では、ブラウザは`conversation_id`だけを送り、Webサーバーがデータベースから履歴を取得します。
どちらの場合でも、モデルが過去の会話を自動的に思い出しているわけではありません。一般的には、推論に必要な履歴を今回の入力へ再び含めます。違うのは、履歴をブラウザとサーバーのどちらが管理するかです。
システムプロンプトまでブラウザへ持たせる設計は避けたほうが安全です。クライアントから送られるJSONは利用者が変更できるため、信頼できるシステム指示はサーバー側で挿入し、受け取った`role`やデータ型も検証します。
接続を再利用できない場合は、API処理の前にDNS名前解決、TCP接続、TLSハンドシェイクなども発生します。HTTP/2、HTTP/3、Keep-Aliveによって再利用できる部分はありますが、利用者が送信してから測るなら、これらも待ち時間です。
2.GPUへ渡す前に、認証とレート制限を行う
リクエストがWebサーバーへ届くと、セッションCookieやBearer Tokenなどを使って利用者を確認します。
その後、利用者、IPアドレス、組織、APIキーなどの単位でレート制限を判定します。未認証、上限超過、不正な入力は、GPUのキューへ入れる前に`401`、`403`、`429`などで終了させます。
LLMでは、単純な「1分間に何回」という回数制限だけでは不十分な場合があります。
短い質問と、数万トークンの履歴を含む質問では、必要な計算量やGPUメモリが違います。そのため、実際のAPIでは次のような要素を組み合わせて制限することがあります。
一定時間内のリクエスト数
入力トークン数
出力トークン上限
同時に生成できるリクエスト数
利用モデルや契約プラン
キューへ滞留できる時間
認証やレート制限は単なる入口の処理ではありません。高価なGPU計算へ進ませるリクエストを選別する役割も持っています。
3.システム指示と会話履歴を、一つの入力へまとめる
認証を通過すると、Webサーバーはモデルへ渡す入力を構築します。
一般的には、次のような情報が候補になります。
サーバーが管理するシステム指示
使用できるツールの定義
会話履歴や、その要約
検索やRAGで取得した参考情報
ユーザー設定
今回の質問
生成用に予約する出力トークン数
実際の並び方や特殊トークンは、モデルのチャットテンプレートによって異なります。
コンテキストウィンドウは「モデルが今回見られる範囲」
モデルが一度の推論で扱えるトークン列の上限を、コンテキストウィンドウと呼びます。
大まかには、次の合計を上限内へ収めます。
システム指示
+ ツール定義
+ 会話履歴
+ 検索結果
+ 今回の質問
+ 生成用に確保する出力
<= コンテキストウィンドウ文字数ではなく、後述するトークン数で数える点が重要です。
上限へ近づいた場合は、古い会話を削除する、履歴を要約する、必要な情報だけを検索して差し込む、といった処理が必要になります。
長い履歴は、料金や上限だけの問題ではありません。入力全体を処理するPrefillが重くなるため、最初の文字が出るまでの時間にも影響します。
4.Tokenizerが文章をトークンIDへ変換する
モデルは、文字列をそのまま計算しているわけではありません。
Tokenizerが文章をトークンという単位へ分割し、それぞれを語彙表に対応する整数IDへ変換します。
トークンは「1文字」や「1単語」と同じではありません。
単語全体が一つのトークンになることもあれば、単語の一部、空白、句読点が別々のトークンになることもあります。同じ日本語でもTokenizerが違えば、トークン数も変わります。
そのため、「日本語は1文字あたり必ず何トークン」といった固定倍率では正確に計算できません。利用するモデルに対応したTokenizerで数える必要があります。
Tokenizerは出力側でも登場します。
GPUが計算するのは、次に来るトークン候補のスコアです。選ばれたトークンIDを人が読める文字列へ戻す処理を、デトークナイズと呼びます。
したがって、厳密には「最初のトークン」と「画面に出る最初の一文字」も同じではありません。最初のトークンが空白だったり、一つのトークンが複数文字へ戻ったりすることがあります。
5.リクエストはキューへ入り、GPUの順番を待つ
トークン化が終わっても、すぐにGPUで計算できるとは限りません。
同時に多数の利用者がいる場合、リクエストは推論サーバーのキューへ入ります。スケジューラは到着順だけでなく、入力トークン数、生成上限、優先度、タイムアウト、利用可能なKV Cache容量などを見て、次に処理するリクエストを決めます。
Dynamic Batching
GPUは、一件ずつ小さな処理を実行するより、複数のリクエストをまとめて処理したほうが効率よく使える場合があります。
近い時刻に届いたリクエストをまとめ、一つのバッチとしてGPUへ渡す仕組みがDynamic Batchingです。NVIDIA Tritonの公式資料でも、複数の推論リクエストを動的に結合し、主にスループットを高める機能として説明されています。
ただし、より大きなバッチを作るために待ちすぎると、一件ごとのTTFTは悪化します。
ここには、次のトレードオフがあります。
一人へ最速で返すのか。
少し待ってまとめ、システム全体で多く処理するのか。
Continuous Batching
LLMの回答は、リクエストごとに長さが違います。
固定バッチでは、短い回答が終わっても、長い回答が終わるまでバッチの一部が空いたままになることがあります。
そこでLLM向け推論エンジンでは、生成の反復ごとに終了したリクエストを外し、新しいリクエストを追加するContinuous BatchingやIteration-level Schedulingが使われます。
Dynamic Batchingと一括りにされることもありますが、開始前にリクエストをまとめる処理と、生成途中に入れ替える処理は分けて考えたほうが正確です。
6.GPUでTransformerを実行する
実行対象に選ばれると、トークンID列がGPUへ渡されます。
現在の多くの生成LLMは、Transformerを基礎としています。Transformerの原型は、2017年の論文「Attention Is All You Need」で提案されました。
Transformer内部のAttentionは、現在の位置が過去のどの情報をどの程度参照するかを計算します。
単純化した式は次の形です。
Attention(Q, K, V)
= softmax(QKᵀ / √d + mask)V各トークンの表現からQuery、Key、Valueを作り、QueryとKeyの関係から参照の重みを求め、その重みでValueをまとめます。
実際のTransformer層では、Attentionだけでなく、MLP、正規化、残差接続なども実行されます。大量の行列演算を並列に処理しやすいため、GPUが使われます。
そして、生成時のGPU処理は性質の違う二段階に分かれます。
入力全体を処理するPrefill
一トークンずつ生成するDecode
この違いが、AIチャットの「最初だけ待つ」という感覚を生みます。
7.Prefillで入力全体を読み、KV Cacheを作る
Prefillは、システム指示、ツール定義、会話履歴、今回の質問など、入力トークン列全体を処理する段階です。
入力中の多くの位置をまとめて計算しやすいため、一般的にはGPUの演算能力を使いやすい処理です。一方、入力が長くなれば、そのぶん仕事量も増えます。
Prefillでは、Transformer各層のAttentionで得られたKeyとValueを保存します。これがKV Cacheです。
KV Cacheがない場合、次のトークンを一つ生成するたびに、それまでの入力を何度も計算し直さなければなりません。
KV Cacheへ過去のKeyとValueを保存しておけば、次回以降はそれを再利用し、新しく追加されたトークンを中心に計算できます。Hugging Faceの公式資料でも、KV Cacheは過去の計算結果を再利用し、自己回帰生成の重複計算を避ける仕組みとして説明されています。
ただし、KV Cacheは無料ではありません。
会話が長く、同時生成数も多くなるほど、保持するKeyとValueが増え、GPUメモリを圧迫します。キャッシュを量子化したりCPUへ退避したりすればメモリは節約できますが、データ移動や復元によって遅延が増える場合があります。
同じシステムプロンプトなど、共通する先頭部分をリクエスト間で再利用するPrefix Cachingもあります。ただし、すべてのサービスで使われているとは限りません。
8.Decodeは、一トークンずつ前へ進む
Prefillが終わると、モデルは最初の次トークン候補を計算できます。
その後のDecodeでは、直前に選ばれたトークンとKV Cacheを使い、次の一トークンに対するスコアを計算します。
一トークン決める。
そのトークンを入力へ追加する。
KV Cacheを更新する。
次の一トークンを計算する。
この処理を回答が終了するまで繰り返します。
PrefillとDecodeの違いを単純化すると、次のようになります。
Prefill
入力全体を処理する
長い入力の影響を受けやすい
多数の位置をまとめて計算しやすい
TTFTへ強く影響する
Decode
一回に基本一トークンずつ進む
前の結果がないと次へ進めない
KV Cacheを繰り返し読み出す
出始めてからの生成速度へ強く影響する
vLLMの資料では、Prefillは計算量の影響を受けやすいcompute-bound、DecodeはKV Cacheやモデル重みの読み出し帯域が効きやすいmemory-boundとして説明されています。
この性質の違いに合わせ、PrefillとDecodeを別のノードへ分ける構成や、長いPrefillを小さく分割してDecodeと同じバッチへ入れるChunked Prefillもあります。
9.TemperatureとTop-pで、次のトークンを選ぶ
GPUが出力するのは、語彙に含まれる各トークンの「次に来そうな度合い」です。この正規化前のスコアをlogitsと呼びます。
logitsから実際の次トークンを選ぶ処理がサンプリングです。
Temperature
Temperatureは、候補の確率分布をどの程度鋭くするか調整します。
値を小さくすると上位候補が選ばれやすくなり、出力は比較的安定します。大きくすると低確率の候補も選ばれやすくなります。
Top-p
Top-pは、確率が高い候補から順に並べ、累積確率が指定値へ達する最小の候補集合を残します。
`top_p: 0.9`なら、毎回同じ個数の候補を残すのではなく、その時点で累積90%以上になる候補集合から選びます。
TemperatureやTop-pは、モデルへ新しい知識を与える設定ではありません。モデルが計算した次トークン分布から、どのように選ぶかを変えるものです。
また、すべてのAPIが両方を公開しているとは限りません。適用順序や`temperature: 0`の扱いも実装によって異なるため、実際には利用するAPIの仕様を確認します。
10.TTFTと、その後の生成速度は別の指標
ここまでの流れを時間で分けると、「最初だけ遅い」理由が見えてきます。
TTFTはTime To First Tokenの略で、リクエスト開始から最初の出力トークンまでの時間を表します。
ただし、計測する場所によって定義が変わります。推論サーバー内のTTFTと、利用者が画面で最初の文字を見るまでのEnd-to-end TTFTは同じではありません。
ブラウザから見た待ち時間は、おおむね次の合計です。
上りネットワーク
+ 認証・レート制限・入力検証
+ 会話履歴の取得・プロンプト構築
+ トークナイズ
+ キュー待ち・バッチ待ち
+ Prefill
+ 最初のサンプリング
+ デトークナイズ
+ サーバーのflush
+ プロキシ・下りネットワーク
+ ブラウザの受信・初回描画
= 利用者が見る最初の文字までの時間最初のトークンが出た後は、主に次のループになります。
Decode
↓
サンプリング
↓
デトークナイズ
↓
ストリーム送信
↓
次のDecodeトークン間の待ち時間はITL(Inter-Token Latency)、最初のトークンを除いた平均出力時間はTPOT(Time Per Output Token)などと呼ばれます。ツールごとに定義が少し異なるため、数値を比較するときは計測範囲を確認する必要があります。
vLLMも、TTFTとInter-token latencyを別々のメトリクスとして公開しています。
ここから、遅さの切り分け方が変わります。
最初の文字だけが遅い
入力や会話履歴が長い
推論キューが混雑している
バッチ形成の待ち時間が長い
Prefillが重い
プロキシがレスポンスをためている
初回接続や認証が遅い
出始めてからも遅い
Decodeが遅い
同時生成数が多い
モデルの重みやKV Cacheの読み出しが詰まっている
長い文脈でAttentionの参照範囲が増えている
中継サーバーのキューが詰まっている
ブラウザの描画処理が重い
「AIが遅い」という一つの数値だけでは、どこを直すべきか分かりません。
11.生成した差分をSSEでブラウザへ送る
推論サーバーが文字列の差分を生成すると、Webサーバーはそれをブラウザへ転送します。
チャットで使われる方法の一つが、Server-Sent Events、略してSSEです。
SSEは、HTTPレスポンスを開いたままにし、サーバーからクライアントへイベントを送り続けるテキスト形式です。`Content-Type`には`text/event-stream`を使い、イベントは空行で区切ります。
event: delta
data: {"delta":"こん"}
event: delta
data: {"delta":"にちは"}
event: done
data: {}
SSEはサーバーからクライアントへの一方向通信です。
質問は通常のPOSTリクエストとして送り、そのPOSTレスポンスをSSE形式で受け取れば、AIチャットのストリーミングを実装できます。
ブラウザ標準の`EventSource`は便利ですが、基本的にはURLへ接続する形なので、JSON本文を持つPOSTとは扱い方が合いません。そのため、`fetch()`でPOSTし、`response.body`の`ReadableStream`を読む構成がよく使われます。
ネットワークチャンク、SSEイベント、トークンは別物
実装で混同しやすいのが、データの単位です。
`reader.read()`が返すネットワークチャンク
一件のSSEイベント
モデルが生成した一トークン
一回の画面描画
この四つは一致しません。
一件のSSEイベントが複数のネットワークチャンクへ分割されることもあります。反対に、複数のSSEイベントが一回の`read()`で届くこともあります。
受信したチャンクをそのまま`JSON.parse()`すると、通信状況によって時々失敗するのはこのためです。
12.ReadableStreamとBackpressure
`ReadableStream`を使うと、レスポンスが完了する前から、届いたバイト列を順番に処理できます。
しかし、受信側の処理が遅い場合に、送信側が無制限にデータを作り続けると、途中のメモリが増え続けます。
そこで必要になるのがBackpressureです。
GPUが生成
↓
推論サーバーの送信キュー
↓
Webサーバーの送信バッファ
↓
プロキシ
↓
TCP
↓
ブラウザのReadableStream
↓
DOM描画下流が遅いとき、上流へ「今はこれ以上送らないでほしい」と伝え、キューが無制限に増えるのを防ぎます。
ただし、ブラウザの`ReadableStream`が持つBackpressureが、自動的にGPUのDecode停止まで伝わるわけではありません。
各層で上限付きキューを使い、Node.jsなら`response.write()`が`false`を返したときに`drain`を待つなど、明示的に接続する必要があります。
13.ブラウザでは、受信のたびにDOM全体を更新しない
ストリーミングを受信できても、描画方法が悪いと画面がカクつきます。
トークンのような細かい差分を受信するたびに、Markdown全文を解析し直したり、DOM全体を置き換えたりすると、ネットワークではなくブラウザがボトルネックになります。
実装では、差分を短時間だけバッファし、`requestAnimationFrame()`ごとにまとめて描画すると安定します。
プレーンテキストなら`textContent`を使用します。受信内容をそのまま`innerHTML`へ入れると、生成内容がHTMLとして解釈されるため危険です。
Markdownを表示する場合は、次のような方法があります。
生成中はプレーンテキストで表示し、完了後にMarkdown化する
一定間隔でまとめて再解析する
差分更新に対応したレンダラーを使う
HTMLを許可する場合はサニタイザーを通す
生成途中ではコードフェンスや表がまだ閉じていないこともあるため、毎回完全なMarkdownとして扱えるとは限りません。
小さなSSEストリーミングチャットを作る
ここからは、流れを確認するための最小例です。
サーバーの`generateText()`はデモ用のスタブです。実際には、利用するLLM SDKや推論サーバーが返すストリームへ置き換えます。
サーバー側
Node.jsとExpressを使います。
import express from "express";
import { once } from "node:events";
import { setTimeout as sleep } from "node:timers/promises";
const app = express();
app.use(express.json({ limit: "64kb" }));
async function* generateText(question, signal) {
const text = `質問「${question}」へのデモ回答です。`;
for (const delta of text.match(/.{1,3}/gu) ?? []) {
await sleep(80, undefined, { signal });
yield delta;
}
}
app.post("/api/chat", async (req, res) => {
const question = req.body?.message;
if (typeof question !== "string" || question.length === 0) {
res.status(400).json({ error: "message is required" });
return;
}
const controller = new AbortController();
const cancel = () => {
if (!controller.signal.aborted) {
controller.abort(new Error("client disconnected"));
}
};
req.once("aborted", cancel);
res.once("close", () => {
if (!res.writableEnded) cancel();
});
res.status(200);
res.set({
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no"
});
res.flushHeaders();
try {
for await (const delta of generateText(
question,
controller.signal
)) {
const frame =
`event: delta\n` +
`data: ${JSON.stringify({ delta })}\n\n`;
if (!res.write(frame)) {
await once(res, "drain", {
signal: controller.signal
});
}
}
res.write("event: done\ndata: {}\n\n");
res.end();
} catch (error) {
if (!controller.signal.aborted && !res.writableEnded) {
res.write(
`event: error\n` +
`data: ${JSON.stringify({
message: "generation failed"
})}\n\n`
);
res.end();
}
}
});
app.listen(3000);確認用に実行する場合は、次のように準備します。
npm init -y
npm install express
node server.mjs実際のLLMへ接続するときは、ブラウザ切断用の`AbortController`と、上流のSDKや`fetch()`へ渡す`signal`を同じキャンセル経路へ接続します。
ブラウザとの通信だけ閉じても、推論側へキャンセルが伝わらなければ、利用者には見えない生成が続きます。
ブラウザ側
次のコードは、`fetch()`のレスポンス本文を`ReadableStream`として読み、空行単位でSSEイベントを取り出します。
const controller = new AbortController();
const answer = document.querySelector("#answer");
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream"
},
body: JSON.stringify({
message: "PrefillとDecodeの違いは?"
}),
signal: controller.signal
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let output = "";
let pending = "";
let frameId = 0;
function queuePaint(delta) {
pending += delta;
if (frameId !== 0) return;
frameId = requestAnimationFrame(() => {
output += pending;
pending = "";
answer.textContent = output;
frameId = 0;
});
}
while (true) {
const { value, done } = await reader.read();
if (value) {
buffer += decoder.decode(value, { stream: true });
}
if (done) {
buffer += decoder.decode();
}
buffer = buffer.replace(/\r\n?/g, "\n");
let boundary;
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const eventName =
frame
.split("\n")
.find((line) => line.startsWith("event:"))
?.slice(6)
.trim() ?? "message";
const data = frame
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, ""))
.join("\n");
if (!data) continue;
const payload = JSON.parse(data);
if (eventName === "delta") {
queuePaint(payload.delta);
}
}
if (done) break;
}`TextDecoder`をストリーミングモードで使うのは、UTF-8の一文字を構成するバイト列が複数のネットワークチャンクへ分割されても、途中で文字化けさせないためです。
停止ボタンでは、次のように`abort()`を呼び出します。
document
.querySelector("#stop")
.addEventListener("click", () => {
controller.abort();
});実装で起きやすい問題
サーバーは逐次送信しているのに、最後にまとめて表示される
よくある原因は、Nginx、CDN、圧縮ミドルウェアなどによるレスポンスのバッファリングです。
Nginxなら、SSE用の経路で次のような設定を検討します。
location /api/chat {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
}Nginxの公式資料では、`proxy_buffering off`の場合、上流から受け取ったレスポンスを直ちにクライアントへ渡すこと、`X-Accel-Buffering`レスポンスヘッダーでも制御できることが説明されています。
ローカルのNode.jsへ直接接続したときだけでなく、本番と同じCDN、ロードバランサー、プロキシを通して確認する必要があります。
停止ボタンを押しても、GPUでは生成が続いている
`AbortController.abort()`でブラウザ側の`fetch()`を止めるだけでは、上流の生成まで止まるとは限りません。
キャンセルは次のように伝播させます。
ブラウザのAbortController
↓
HTTP接続の切断
↓
Webサーバーが切断を検知
↓
上流SDK/推論APIをabort
↓
推論スケジューラからrequest_idを除去
↓
KV Cacheを解放タイムアウトやタブ終了も、同じキャンセル経路へ接続します。
会話が長くなるほど、最初の文字が遅くなる
履歴を毎回すべて含めると、入力トークン数が増え、PrefillとKV Cacheの負荷が増えます。
対策としては、次のようなものがあります。
モデル対応Tokenizerで入力トークン数を数える
出力上限とシステム指示のぶんを先に確保する
古い会話を要約する
必要な過去情報だけを検索して挿入する
大きなツール結果やHTMLをそのまま履歴へ残さない
共通部分へPrefix Cachingを利用する
ただし、要約には情報損失があります。ユーザー設定、合意事項、処理済みの注文番号など、失ってはいけない情報は、会話要約だけへ依存せず構造化して保存したほうが安全です。
ReadableStreamを使ったのにメモリが増え続ける
`ReadableStream`を使うだけでは、システム全体のバッファ増加を防げません。
`response.write()`が`false`なら`drain`を待つ
推論サーバーとWebサーバー間のキューへ上限を設ける
遅すぎるクライアントへタイムアウトを設定する
大きなストリームを安易に`tee()`で複製しない
回答全文を同期的にログへ書き続けない
切断後は推論とKV Cacheを解放する
特に「GPUは生成を続けているが、ネットワークは送れない」という状態を放置すると、無駄な計算とメモリ使用量が増えます。
JSON.parseが時々失敗する
ネットワークチャンク単位で`JSON.parse()`している可能性があります。
ネットワークチャンクとSSEイベントの境界は一致しません。空行までバッファして一件のSSEフレームを取り出し、その中の`data:`を連結してからJSONとして解析します。
画面がカクつく
差分ごとにMarkdown全文を再解析したり、DOM全体を作り直したりしている可能性があります。
受信と描画を分け、`requestAnimationFrame()`や短い時間間隔で更新をまとめます。生成中はプレーンテキスト、完了後にMarkdownへ変換する方法も現実的です。
「AIが遅い」を分解して計測する
速度を改善するには、ブラウザからGPUまで同じリクエストを追跡できるようにします。
少なくとも、次の時刻を記録すると原因を切り分けやすくなります。
t_click
利用者が送信ボタンを押した時刻
t_api_received
Web APIがリクエストを受信した時刻
t_auth_done
認証・入力検証が完了した時刻
t_queue_enter / t_scheduled
推論キューへ入った時刻と、GPUへ割り当てられた時刻
t_prefill_done
Prefillが完了した時刻
t_first_content_write
サーバーが最初の回答差分を書いた時刻
t_first_content_read
ブラウザが最初の回答差分を読んだ時刻
t_first_paint
利用者が画面上で最初の文字を見られた時刻
t_done
回答全体が完了した時刻同じ`request_id`をブラウザ、Web API、推論サーバーで共有し、入力トークン数、出力トークン数、キュー時間、Prefill時間、Decode時間、キャンセル理由を関連付けます。
ただし、SSEイベント数を出力トークン数として数えてはいけません。
中継サーバーが複数トークンを一つの差分へまとめることも、一つのトークンが複数のネットワークチャンクに分かれることもあります。トークン数はTokenizerや推論サーバーのメタデータから取得します。
また、HTTPレスポンスの先頭バイトが届くTTFBと、内容のある最初の回答が届くTTFTも分けます。接続維持用のSSEコメントが先に届いても、利用者が読む回答はまだ始まっていません。
AIの応答速度を、一つの数字にしない
送信ボタンを押してから最初の文字が出るまでには、ネットワーク、認証、履歴取得、プロンプト構築、トークナイズ、キュー、バッチ待ち、Prefill、最初のサンプリング、プロキシ、ブラウザ描画が積み重なっています。
その後は、KV Cacheを使ったDecodeの反復が主に生成速度を決めます。
だから、最初だけ遅いなら、長い入力、キュー、Prefill、初回接続、プロキシのバッファリングを疑います。
出始めてから遅いなら、Decode、同時生成数、KV Cache、Backpressure、ブラウザ描画を見ます。
同じ「遅い」でも、調べる場所は別です。
LLM内部の計算だけを見ても、AIチャットの体感速度は説明できません。
HTTPリクエストがGPUへ届き、生成結果が再びHTTPを通り、ブラウザのDOMへ描かれるまでを一つの経路として測る。そこまでつなげて、ようやくWebアプリケーションとしてのAIチャットが見えてきます。
