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

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

最初のトークンはいつ来るか — SSE を手で読み、prefill・キャッシュ・キャンセルを測る

LabHubで続きを見る

目標

Pod内の小さなLLMサーバーでストリーミング応答を自分で読み、最初のトークンまでの遅延・prefill・KVキャッシュ・最初の文の時刻を測り、接続を切って生成を止めます。

なぜ重要なのか

音声アシスタントは、最初の文が終わった瞬間に話し始められます。その時刻を遅らせる最大の要因がプロンプトの計算(prefill)で、それを減らす最も安い方法がKVキャッシュです。どちらも数字で見ないと、プロンプトをどう組むべきか判断できません。採点ツールはLLMサーバーに問い合わせず、保存した生のSSE・測定JSON・サーバーログだけを読み、マシンごとに違う絶対時間の代わりに、同じ実行内の関係(最初のトークン < 全体、長いプロンプト > 短いプロンプト、キャッシュヒット < キャッシュなし)を見ます。

ステップ

  1. voice-llm upでサーバーを起動し、/healthを/root/voice/llm/health.jsonに、/propsを/root/voice/llm/props.jsonに保存してください。
  2. SSEを1行ずつ読む/root/voice/llm/sse.pyを作り、受け取った行をそのまま/root/voice/llm/sse_raw.txtに、内容の断片を時刻とともに/root/voice/llm/chunks.jsonlに保存してください。
  3. cache_prompt: falseで同じ質問を5回送り、最初のトークン・全体時間・トークン数を/root/voice/llm/ttft.jsonに書いてください。
  4. KBドキュメントを0・4・12個、システムプロンプトに付けて(キャッシュ無効)、prompt_nと最初のトークンの時刻を/root/voice/llm/prefill.jsonに書いてください。
  5. キャッシュを有効にして同じ長いプロンプトを2回、先頭に時刻の1行を付けてもう1回送り、/root/voice/llm/cache.jsonに書いてください。
  6. 3文の答えをストリーミングで受け取り、最初の文が終わった時刻を/root/voice/llm/sentence.jsonに書いてください。
  7. 長い答えをリクエストしたあと0.5秒で接続を切り、サーバーが止まるまでの時間を/root/voice/llm/cancel.jsonに書いてください。
  8. 測定結果をまとめた/root/voice/llm/report.jsonを作ってください。

参考

サーバーを起動して、何が立ち上がったかを見る

voice-llm upでLLMサーバーを起動したあと、curl -s localhost:8080/healthを/root/voice/llm/health.jsonに、curl -s localhost:8080/propsを/root/voice/llm/props.jsonに保存してください。

/healthが{"status":"ok"}なら、モデルの読み込みが完了しています。/propsには、モデルのパス、コンテキスト長(default_generation_settings.n_ctx)、スロット数(total_slots)があります。

SSEを1行ずつ読む

/root/voice/llm/sse.pyにstream(messages, max_tokens=64, cache_prompt=True, raw_out=None)を作り、/v1/chat/completionsにstream: trueで送って、受け取った行をraw_outにそのまま書き、内容がある断片ごとに{"t_ms": 요청 직전부터의 ms, "text": 조각}を集めて(プレースホルダーはリクエスト直前からのmsと断片です)、(断片のリスト、全体のms、timings)を返すようにしてください。システムプロンプト「You are a clinic phone assistant. Answer in one short sentence.」と質問「What should a new patient bring?」で1回動かして、/root/voice/llm/sse_raw.txtと/root/voice/llm/chunks.jsonlを作ってください。

行が「data: 」で始まっていたら、その後ろをJSONとして解釈します。「data: [DONE]」が終わりです。choices[0].delta.contentが空の断片(最初の断片のrole)は飛ばします。時刻はtime.perf_counter()で測ります。

最初のトークンまでの遅延を5回測る

ステップ2の質問をcache_prompt=Falseで5回送り、回ごとにttft_ms(最初の内容の断片の時刻)・total_ms・tokens(timingsのpredicted_n)をrunsに、2つの時間の中央値をttft_p50・total_p50に書いた/root/voice/llm/ttft.jsonを作ってください。

キャッシュを切ると、毎回プロンプト全体を再計算します。中央値は、並べ替えた5つの値の3番目です。最初の回は、サーバーが起動したばかりで少し遅いことがあります。そのため、1回ではなく複数回測ります。

プロンプトが長いと最初のトークンが遅くなる

/opt/lab/fixtures/voice/kb/*.mdを名前順に読み、先頭から0・4・12個をシステムプロンプトに付けて、質問「When is the clinic open on Saturday?」をcache_prompt=False、max_tokens=32で送り、{"docs": n, "prompt_n": …, "prompt_ms": …, "ttft_ms": …}の3つを/root/voice/llm/prefill.jsonの配列に書いてください。

prompt_nとprompt_msは、最後の断片のtimingsにあります。トークンが増えるほど、最初のトークンがほぼ比例して遅くなることを確認してください。キャッシュが有効だと、前のリクエストと重なる先頭部分が省かれて、数字がぼやけます。

KVキャッシュ: 同じ先頭部分は飛ばされる

KBドキュメントをすべて付けた長いシステムプロンプトで、まず別の短いリクエスト(「hi」)を1回送ってスロットを空にしてから、cache_prompt=Trueで2回(first・second)送り、システムプロンプトの先頭に「Current time: 14:05. 」を付けてもう1回(changed_prefix)送って、それぞれのprompt_n・ttft_msを/root/voice/llm/cache.jsonに書いてください。

サーバーは、スロットに残っている直前のリクエストのK・Vと新しいリクエストを先頭から比較して、同じ分だけ飛ばします。先頭の1行が違えば、最初のトークンから違うので、何も再利用できません。変わる行を一番後ろに付けるとどうなるかも考えてみてください。

最初の文はいつ終わるか

システムプロンプト「You are a clinic phone assistant. Answer in exactly three short sentences.」と質問「How do I prepare for a fasting blood test?」をmax_tokens=120でストリーミングし、つなげた文で初めて[.!?]の後ろに空白が来た断片の時刻をfirst_sentence_msに、その文をfirst_sentenceに書いた/root/voice/llm/sentence.json(ttft_ms・first_sentence_ms・total_ms・first_sentence・text)を作ってください。最後まで空白が来なければ、全体が1文です。

ピリオドだけを見て切ると、「3.5」や「a.m.」で間違えます。記号の後ろに空白が付いて来た瞬間が、「文が終わった」というより安全なサインです。小さなモデルは、「3文」という指示をよく破ります。そのため、長さをコードで扱います。

割り込まれたら生成を止める

「List the numbers from 1 to 300, separated by commas.」をmax_tokens=256でストリーミングし、0.5秒で接続を閉じて、それまでに受け取った内容の断片の数をreceived_tokensに、閉じたあと/slotsのis_processingがfalseになるまでのmsをslot_idle_after_msとして、/root/voice/llm/cancel.json(max_tokens・received_tokens・closed_at_ms・slot_idle_after_ms)に書いてください。

http.client接続のclose()が、そのまま「やめて」です。サーバーログ(voice-llm log)に「cancel task」が出力されれば、サーバーが理解したということです。長く話すことが確実なリクエストを選んでください。切る前に生成が終わると、試験になりません。

レポート

/root/voice/llm/report.jsonに、ttft_p50_ms・total_p50_ms(ttft.json)、ttft_12docs_ms・prompt_n_12docs(prefill.jsonの最後)、cache_ttft_ms(cache.jsonのsecond)、first_sentence_ms(sentence.json)、cancel_idle_ms(cancel.jsonのslot_idle_after_ms)を書き写してください。

前のステップのファイルを読んで書き写します。数字を並べると、どこを減らせば口を早く開けるかが見えます。