LabHub
はじめる
学ぶ 学習パス コース

音声 AI エージェント — 聞いて、調べて、話すパイプライン

口を開く瞬間 — SSE ストリーミング・最初のトークンの遅延・KV キャッシュ

LabHubで続きを見る

一言でいうと

音声アシスタントが口を開く時刻は、LLMが答えをすべて書き終えた時刻ではなく、最初の文を終えた時刻です。そのため、応答を1つの塊として受け取らず、トークンが出てくるたびに受け取ります(SSEストリーミング)。最初のトークンまでの時間(TTFT)の大部分はプロンプトを計算する時間(prefill)で、プロンプトの長さに比例します。同じ先頭部分を再送すると、サーバーがKVキャッシュを再利用して、その時間をほぼなくします。ただし、先頭の1文字だけが変わっても、それ以降をすべて再計算します。

なぜ必要なのか

LabHubの会話練習の1ターンは、ASR 0.4秒 + モデル0.6秒 + TTS 2.3秒 ≈ 3.3秒です(2026-09-23に実測、backend/app/lang_talk.py)。モデルの答えをすべて受け取ってからTTSに渡す直列構造なので、答えが長くなればそのまま増えます。そのため、そのファイルは答えを文の数で縛り、超えたらコードで切ります(「モデルが調子に乗って5文書くと12秒」)。ストリーミングで受け取れば、最初の文が終わった瞬間にTTSを始められます。このモジュールは、その「最初の文」がいつ来るのか、何がそれを遅らせるのかを測ります。

どう動くのか

llama-serverとSSE。llama.cppのllama-serverは、OpenAIと同じ形の/v1/chat/completionsを開きます。"stream": trueで送ると、応答がServer-Sent Eventsで返ってきます。data: {JSON}の行と空行の繰り返しで、断片ごとにchoices[0].delta.contentに新しい文字があり、最後はdata: [DONE]です。最初の断片はたいてい役割(role)だけで、内容が空です。それを最初のトークンとして数えると、TTFTが実際より短く計上されます。最後の断片には、サーバーが測ったtimings(prompt_n・prompt_ms・predicted_n …)が付きます。

TTFT = 待機 + prefill + 最初のトークン1つ。モデルはまずプロンプト全体を一度に計算して、各層のK・Vを作り(prefill)、そのあと1トークンずつ書きます(decode)。prefillはプロンプトのトークン数に比例します。このイメージのQwen2.5-0.5B-Instruct Q4_0をCPU 2コアのPodに載せると(nuc1で実測)、35トークンのプロンプトでは最初のトークンが127 ms、258トークンなら801 msで、その後の生成は毎秒約50トークンでした。RAGでドキュメントを何個か付けた途端に、最初のトークンが何倍も遅くなるということです。

KVキャッシュと先頭部分の一致。サーバーは、スロット(会話の席)ごとに直前のリクエストのK・Vを保持しています。新しいリクエストの先頭部分がそれと同じなら、同じ分だけ飛ばして、新しいトークンだけを計算します(cache_prompt)。同じ258トークンのプロンプトを2回目に送ると、prompt_nが1になり、最初のトークンが24 msに縮まりました(nuc1で実測)。ところが比較は先頭からです。システムプロンプトの先頭に「現在時刻: 14:05」のように変わる行を置くと、毎回のリクエストが最初から再計算されます。変わるものは後ろに、固定のものは前に置きます。

このイメージのサーバーは--cache-ram 0で起動します。最新のllama-serverは、過去のプロンプトのKVをホストメモリにためておき、似たリクエストが来ると復活させますが、デフォルトの上限が8,192 MiBでPodの上限(2 GiB)を超える可能性があり、有効にしておくとスロットを空にしたあとでも以前のプロンプトが復活して、キャッシュの実験がぼやけます(実測: 最初に送ったプロンプトでもprompt_nが1)。本番では、会話が複数あるときに役立つ機能です。

最初の文とmax_tokens。音声では、長い答えがそのまま長い待ちです。最初の文が終わる時刻、つまりピリオドや疑問符の後ろに空白が来る瞬間を別に測り、max_tokensで答えの上限を設けます。空白まで待つ理由は、「3.5」や「a.m.」で切らないためです(モジュール7でさらに扱います)。

割り込み = 接続切断。ユーザーが割り込んだら、残りの生成は捨てるものです。ストリームを読んでいたHTTP接続を閉じると、llama-serverはその作業をキャンセルします(ログにcancel task)。読むのをやめるだけで接続を握ったままにすると、サーバーは最後まで生成してCPUを使い続けます。2コアをTTSと分け合うPodでは、次のターンがその分遅くなります。

現場での姿

小さなモデルは、指示をあまり守りません。このイメージの0.5Bモデルに「ちょうど3文で」と言っても、1文や番号付きリストで答えることがよくありました。そのため、運用する側はモデルの言葉だけを信じずに、長さをコードで縛ります。max_tokens、文の数での切り捨て、最初の文を短く切って先に読み上げること(モジュール7)です。LabHubの会話練習が、文の数を指示文に明記し、超えたらコードで切るのも同じ理由です。

次のラボですること

voice-llm upでサーバーを起動して、/healthと/propsを保存します。SSEを1行ずつ読むsse.pyを作って、断片の時刻を記録し、キャッシュを切った状態で最初のトークンまでの遅延を5回測ります。ドキュメントを0・4・12個付けてprefillを測り、キャッシュが先頭部分を再利用する様子と、先頭の1行がキャッシュを壊す様子を見ます。最初の文が終わる時刻を測り、0.5秒で接続を切って、サーバーが止まるかを/slotsとログで確認します。