TTFTとは何を測っているのか|内訳を分けて2枚のGPUで実測すると、動く部分と動かない部分が分かれる

TTFTに関する記事のアイキャッチ画像 - TTFTとは何を測っているのか GPU・グラフィックボード

この記事の要点

  • 未ロードの状態から測ると、「最初に返るまで」の 95〜99% はモデルの読み込みだった(7モデル・GPU 2枚とも)
  • モデルが載った状態でも load_duration は残り、「最初に返るまで」に対する比で 65〜95% を占めた
  • 内訳ごとに2枚の GPU を比べると、プロンプト処理は 1.23〜2.00倍、デコード速度は 1.56〜1.98倍で、7モデルすべて RTX 5080 側が速い。モデルの読み込みは 0.94〜1.84倍で向きが揃わず、2モデルでは RTX 5060 Ti のほうが短かった
  • 指標を「最終回答が始まるまで」に替えると、非思考型は 1.0倍のまま、思考型は 10.4倍・21.8倍(RTX 5060 Ti)まで伸びた
  • 思考をオフにする指定は、思考型2モデルのうち1モデルでは思考部分の文字数を変えなかった(この確認は RTX 5060 Ti のみ)

先に結論: TTFTは同じ名前で別のものを指しうる

ローカルLLMの応答の速さを表す数字として TTFT が使われる。ところが同じ「TTFT」という名前の下で、実際に測られているものは一つに定まっていない。値を決めているのは、まず2種類の指標と、そのどちらにも掛かる2種類の条件の組合せである。これに加えて、呼び出したエンドポイントや出力の打ち切り設定でも数字は変わる(前者は実測5、後者は実測2と実測4で扱う)。揃えるべき項目の全体はまとめに置いた。

  • 指標①: 最初に返るまで — 応答として何かが最初に届くまでの時間。思考に対応したモデルでは、最初に届くのが思考部分になることがある
  • 指標②: 最終回答が始まるまで — 最終回答にあたるフィールドが埋まり始めるまでの時間
  • 条件①: 未ロードから測ったか、モデルが載った状態から測ったか — どちらの指標にも掛かる
  • 条件②: クライアント側の壁時計で測った値か、サーバーが返す内訳から組み立てた値か — これもどちらの指標にも掛かる

組合せが違えば、同じモデル・同じGPUでも桁の違う数字が出る。さらにこの記事で2枚の GPU を並べて分かったのは、指標①の大半を占めている部分には2枚の間で一貫した差が出ず(7モデル中2モデルでは RTX 5060 Ti のほうが短い)、指標①の中でGPUの違いが素直に出る部分は時間としては小さい、という非対称である。この非対称があるため、指標①だけで2枚のGPUの速さを比べることはできない。モデルどうしを指標①で並べた場合も、比べているのは読み込みを含む「何かが出るまで」であって、生成の速さでも最終回答までの待ちでもない。後者を比べたい場合に何が起きるかは実測4で扱う。

TTFTという言葉が指しうるもの

TTFT(Time To First Token)は、リクエストを送ってから最初のトークンが返るまでの時間を指す。NVIDIA が公開している NIM のベンチマーク指標一覧にも、リクエストの送信から最初のトークンを受け取るまでの時間として記載がある。ここまでは業界で共有された定義であって、特定の測定者が決めたものではない。

問題はその先にある。「最初のトークン」が何を指すかは、実装と計測方法で変わる。

指標: 何が届いた時点を「最初」と数えるか

思考に対応したモデルは、最終回答より先に思考を生成する。この順序はモデル側の生成の順序であり、Ollama はそれを区別して記録できるように、思考の出力を content とは別のフィールドで返す。つまりフィールドが分かれているから思考が先に届くのではなく、先に生成されるものを別々に受け取れるようにフィールドが分かれている。

したがって、届いたものを素直に「最初のトークン」と数えると、思考型では思考の先頭を指す。最終回答の先頭を数えれば別の値になる。本文では前者を「最初に返るまで」、後者を「最終回答が始まるまで」と呼び分ける。

ただしこの分離はいつも同じ形で効くわけではない。思考をオフに指定して生成用エンドポイントを呼び出した場合には、思考が別フィールドへ分けられず最終回答フィールドに入ることがあり、思考を無効化する指定が効くかどうかもモデルで結果が分かれた。どちらも後段の実測5で扱う。

条件: どこから測り、どこの時計で測るか

条件の一つ目は、モデルがメモリに載っているかどうか。Ollama はモデルを保持しておく時間を keep_alive で制御でき、公式 FAQ には既定でモデルは5分間メモリに保持されてからアンロードされると記載がある。既定値と指定方法は版で変わりうるが、保持が切れた後の最初のリクエストでは読み込みが再び発生する、という関係は変わらない。「起動直後の1回」と「連続して投げている最中の1回」は、同じ指標でも別の条件で測った数字になる。

二つ目は、どこの時計で測った値か。Ollama のレスポンスには load_duration と prompt_eval_duration が含まれ、モデルの読み込み時間とプロンプト処理時間を分けて取得できる。これはサーバーが返す内訳であって、クライアント側から外側で測った時間とは取得元が違う。両者は取得元が違うため、由来を書かずに一方を他方の代わりに使うと、同じ名前で別のものを比べることになる。本記事が出している「最初に返るまでに対する load_duration の比」は、同じ条件で測った実行から、クライアント側で測った待ち時間の内訳としてサーバーの値を当てた概算で、残りの部分にはクライアント側の往復も含まれる。中央値で示した表では、この割合は列ごとの中央値どうしの比になる。本文では、サーバーが返す読み込み時間を「モデルの読み込み」と呼ぶ。

測定の前提と、測っていない軸

測定に使った構成と条件を先に開示する。

項目
実行環境 Ollama 経由(0.32.3。測定時点。同日時点の最新の正式リリースは 0.32.9)
GPU A RTX 5060 Ti 16GB。外付けで、実測のリンクは PCIe 4.0 x4(負荷時に nvidia-smi で確認)
GPU B RTX 5080 16GB。PCIe 5.0 x16(同上)
CPU / RAM Intel Core i7-14700F / 96GB
OS Windows 11 build 26200
GPUドライバ 610.47(測定時点)
量子化 各モデルとも既定タグ(Q4_K_M)
温度 0
プロンプト 英文1件・技術的な説明を3文で求めるもの。全モデル・全条件で同一
初回の作り方 keep_alive を0にして一度アンロードしてから最初の1回を計測
集計 初回は1回、2回目以降は3回の中央値
出力上限 実測1〜3は200トークンで打ち切り(options.num_predict=200)/実測4・5は打ち切りなし
2枚のGPUの扱い それぞれ別のOllamaインスタンスで、1枚だけが見えている状態で実行。同時には使っていない

2枚の GPU は同じ1台の PC に載っており、CPU・RAM・OS・ドライバ・Ollama の版はすべて共通。違うのは GPU と、その GPU がつながっているリンクの幅になる。どちらも VRAM は 16GB で、この容量で動かせるモデルの全体像はVRAM 16GBで動かすローカルLLM完全ガイドで扱っている。

測っていない軸も先に挙げておく。出力の品質、回答の正しさ、体感の良し悪しは対象外で、この記事に速い遅い以外の優劣は出てこない。プロンプトは1件のみ、量子化は各モデルの既定タグのみ。思考オフの指定とフィールド配置を扱う実測5は、RTX 5060 Ti のみで実施している。

再現するときに効いてくる点として、Ollama のモデルタグは中身が更新されることがあり、同じタグ名でも時期によって別のモデルを指す場合がある。この記事の deepseek-r1:8b は DeepSeek-R1-0528-Qwen3-8B を指すもので、latest / 8b / 8b-0528-qwen3-q4_K_M はダイジェストが一致する同じ中身、Llama 系をベースにした版は 8b-llama-distill-q4_K_M という別タグ・別ダイジェストになっている。モデル名を揃えることは必要だが、それだけでは同じものを測っている保証にならない。

本記事の実測値は2026年8月13日時点・Ollama 0.32.3・GPUドライバ610.47での測定に基づく。版が変われば値は変わる。

実測1: 初回の数秒は、どちらのGPUでもほぼモデルの読み込み

まず、モデルを一度アンロードしてから最初の1回を測った値を見る。ストリーミングで受け取り、最初に何かが返ってきた時刻を記録している。

モデル 5060 Ti 最初に返るまで 5060 Ti うち load_duration 5080 最初に返るまで 5080 うち load_duration
deepseek-r1:8b(思考型) 7,294 ms 7,101 ms(97%) 3,744 ms 3,570 ms(95%)
qwen3:14b(思考型) 4,806 ms 4,589 ms(95%) 4,517 ms 4,345 ms(96%)
llama3.1:8b 3,703 ms 3,634 ms(98%) 3,685 ms 3,622 ms(98%)
phi4-mini:3.8b 1,981 ms 1,904 ms(96%) 3,448 ms 3,367 ms(98%)
gemma3:4b 9,234 ms 9,107 ms(99%) 4,622 ms 4,557 ms(99%)
mistral:7b 7,689 ms 7,592 ms(99%) 2,808 ms 2,728 ms(97%)
llama3.2:3b 7,238 ms 7,162 ms(99%) 3,149 ms 3,076 ms(98%)

「最初に返るまで」の 95〜99% がモデルの読み込みで、この比率は7モデル・2枚とも同じ範囲に収まった。数秒という桁を作っているのはほぼ読み込みであり、残りは合計しても数十から二百ミリ秒台にとどまる。

「うち load_duration」の列は Ollama が返す値で、外から測った時間ではない。括弧内の%は、クライアント側の壁時計で測った「最初に返るまで」に対する比であって、同じ時計で測った内訳の分解ではない。この表は初回を1回ずつ測ったもので、中央値ではない。

初回の値はディスクキャッシュなど実行時の状態の影響を受けうるもので、実行のたびに変わる。2枚の間で読み込み時間の大小が入れ替わっている行もあり、phi4-mini:3.8b は 5060 Ti 側が 1,904 ms、5080 側が 3,367 ms と逆になっている。この逆転の原因までは特定していない。何がどれだけ効いているかを切り分ける計測はしていないため、初回の読み込み時間をモデルやGPUの性能として読むことはできない。

実測2: 二回目以降も読み込みは消えない

同じ実行から、モデルが既にメモリに載っている状態の3回を取り、中央値にした。「最初のチャンクの種別」は、ストリーミングで最初に届いた内容が思考部分だったか最終回答だったかを示す。

モデル 最初のチャンクの種別 5060 Ti 最初に返るまで 5060 Ti うち load_duration 5080 最初に返るまで 5080 うち load_duration
deepseek-r1:8b(思考型) 思考部分 320.7 ms 251.4 ms(78%) 281.4 ms 241.8 ms(86%)
qwen3:14b(思考型) 思考部分 346.5 ms 245.9 ms(71%) 309.2 ms 261.3 ms(85%)
llama3.1:8b 最終回答 343.4 ms 314.5 ms(92%) 307.8 ms 292.7 ms(95%)
phi4-mini:3.8b 最終回答 368.3 ms 334.8 ms(91%) 335.2 ms 306.7 ms(91%)
gemma3:4b 最終回答 652.2 ms 611.1 ms(94%) 660.2 ms 617.3 ms(94%)
mistral:7b 最終回答 86.7 ms 63.6 ms(73%) 53.4 ms 34.5 ms(65%)
llama3.2:3b 最終回答 331.5 ms 300.6 ms(91%) 305.1 ms 277.0 ms(91%)

秒単位だった数字はミリ秒の桁に落ちる。それでも読み込みのフィールドは0にならず、「最初に返るまで」の 65〜95% を占め続けている。占有率でいえば、常駐させた後でも読み込みが大半を占める行が残る。

ここに出ている時間が、モデルファイルを読み直す時間なのか、既に載っているモデルを使い始めるまでの準備時間なのかは、この計測では分解できない。分かるのは、常駐状態でもこのフィールドが残り、しかも支配的だということまで。保持が切れれば読み込みからやり直しになるので、keep_alive を延ばす設定は秒単位の待ちを避ける手当てとして効く。そのとき何秒かかるかは実行時の状態で変わり、実測1の値がそのまま出るとは限らない。常駐や並列を前提にした設定の勘所はOllamaを常駐・並列運用する実運用設定にまとめてある。

「最初のチャンクの種別」の列は2枚で一致した。思考型の2モデルでは最初に届くのが思考部分で、それ以外の5モデルでは最終回答が最初に届いている。同じ「最初に返るまで」でも、思考型では思考の先頭までの時間を測っていることになる。

この実行は出力を200トークンで打ち切っている。5060 Ti では思考型の2モデルとも最終回答に到達しておらず、5080 でも qwen3:14b は到達していない。5080 の deepseek-r1:8b だけが、思考が短く済んだために打ち切りの中で最終回答に入っている。4つの条件で揃わないため、この表では最終回答側の値を扱わず、指標を替えた比較は実測4で行う。思考が続いて最終回答まで届かない現象は、別のモデルの実測でも起きている(Muse Glimmer 30BをVRAM 16GBで動かす)。

実測3: GPUを変えたとき、動く部分と動かない部分

実測2と同じ実行を、今度は内訳ごとに並べ替える。倍率は 5060 Ti の値を 5080 の値で割ったもので、1より大きいほど 5080 が速い。デコード速度だけは大きいほうが速いので、5080÷5060 Ti で計算している。

モデル モデルの読み込み プロンプト処理 デコード速度
deepseek-r1:8b(思考型) 251.4 → 241.8 ms(1.04倍) 14.5 → 8.2 ms(1.77倍) 毎秒71.9 → 130.3トークン(1.81倍)
qwen3:14b(思考型) 245.9 → 261.3 ms(0.94倍) 23.4 → 12.2 ms(1.92倍) 毎秒43.4 → 85.8トークン(1.98倍)
llama3.1:8b 314.5 → 292.7 ms(1.07倍) 13.7 → 7.9 ms(1.73倍) 毎秒76.3 → 140.9トークン(1.85倍)
phi4-mini:3.8b 334.8 → 306.7 ms(1.09倍) 9.1 → 5.5 ms(1.65倍) 毎秒118.5 → 198.9トークン(1.68倍)
gemma3:4b 611.1 → 617.3 ms(0.99倍) 35.0 → 28.4 ms(1.23倍) 毎秒102.4 → 159.8トークン(1.56倍)
mistral:7b 63.6 → 34.5 ms(1.84倍) 12.0 → 6.0 ms(2.00倍) 毎秒86.0 → 168.0トークン(1.95倍)
llama3.2:3b 300.6 → 277.0 ms(1.09倍) 7.2 → 4.2 ms(1.71倍) 毎秒148.0 → 284.0トークン(1.92倍)

プロンプト処理は7モデルすべてで 5080 側が速く、幅は 1.23〜2.00倍。デコード速度も7モデルすべてで 5080 側が速く、1.56〜1.98倍。モデルが変わっても向きが揃っている。

モデルの読み込みは揃わない。7モデル中5モデルでは 5080 側が短い。ただしその差は、同じGPU・同じモデルを3回測ったときの振れ幅と同じ程度にとどまる。qwen3:14b は 0.94倍、gemma3:4b は 0.99倍で 5060 Ti のほうが短い。この回数で言えるのは、2枚の差をこの計測の振れ幅から分離できていない、というところまでになる。構成上の違いには前提の表に挙げたリンクの幅も含まれるが、向きが揃っていない以上、この差をリンクの幅に帰することはできない。原因を切り分ける計測もしていないため、この表から読めるのは大小の一貫性の有無までになる。

そのうえで実測2と重ねると、指標の性格が見えてくる。「最初に返るまで」の 65〜95% を占めているのは、まさにこの揃わない部分である。反対に、GPUの違いが素直に出るプロンプト処理は倍率こそ大きいが、絶対値が小さい。deepseek-r1:8b で 14.5 ms と 8.2 ms、llama3.2:3b で 7.2 ms と 4.2 ms という桁で、「最初に返るまで」に乗る量としては誤差に近い。

結果として、「最初に返るまで」で2枚を並べても、7モデル中6モデルは 1.14倍以内にとどまる。llama3.1:8b はデコード速度で 1.85倍の差がついているのに、「最初に返るまで」は 343.4 ms と 307.8 ms。gemma3:4b にいたっては 652.2 ms と 660.2 ms で、デコード速度が 1.56倍速い側のほうが大きい。デコード速度で2倍近い差がある2枚が、この指標ではほぼ同じ数字を返す。例外は mistral:7b で、86.7 ms と 53.4 ms(1.62倍)と開く。ただしこの行は上の表で向きの揃わなかった読み込みの列が最も大きく動いた行(1.84倍)でもあり、この指標が読み込みに連動していることの裏返しになる。

この表の倍率と、実測2の割合は、いずれも列ごとに独立して中央値を取ってから割ったもので、同一実行内の比ではない。1回ごとに計算すると割合はこれより広く散る。実測1は初回を1回ずつ測ったもので、こちらは同一実行内の比になる。

実測4: 最終回答までは思考型だけ大きく伸びる

指標を替えると、同じモデル・同じ状態でも伸び方が分かれる。ここでは生成を打ち切らずに最後まで走らせ、「最初に返るまで」と「最終回答が始まるまで」を同じ1回の実行から別々に記録した。各モデルで捨て回を1回入れたあと、モデルが載っている状態の3回を計測してその中央値を取っている。倍率は「最終回答が始まるまで」を「最初に返るまで」で割ったもの。

モデル 5060 Ti 最初に返るまで 5060 Ti 最終回答が始まるまで 5060 Ti 倍率 5060 Ti 思考部分 5080 最初に返るまで 5080 最終回答が始まるまで 5080 倍率 5080 思考部分
deepseek-r1:8b(思考型) 335.8 ms 3,488 ms 10.4倍 1,349字 296.7 ms 1,471 ms 5.0倍 893字
qwen3:14b(思考型) 368.1 ms 8,020 ms 21.8倍 1,687字 326.3 ms 3,723 ms 11.4倍 1,453字
llama3.1:8b 329.0 ms 329 ms 1.0倍 なし 316.4 ms 316 ms 1.0倍 なし
phi4-mini:3.8b 359.1 ms 359 ms 1.0倍 なし 362.0 ms 362 ms 1.0倍 なし

非思考型の2モデルでは、2つの指標が同じ値になる。最初に届くのが最終回答の先頭だからで、倍率は 1.0倍。思考型では 5060 Ti が 10.4倍と 21.8倍、5080 が 5.0倍と 11.4倍まで開く。どの指標で見るかによって、モデル間の差の大きさが桁の単位で変わるということになる。

温度0で同じプロンプトを送っていても、思考部分の文字数は2枚で違っている。deepseek-r1:8b は 1,349字 と 893字、qwen3:14b は 1,687字 と 1,453字。生成された内容そのものが同じではないため、倍率の2枚比較には思考の分量の違いが混ざっており、速度差だけの結果として読むことはできない。文字数はトークン数ではなく、倍率もこの1つのプロンプトでの値であって、モデルの固有値ではない。

言い換えると、「最終回答が始まるまで」は、処理の速さと、そのモデルがそのプロンプトでどれだけ思考を書いたかの両方を含む指標になる。待ち時間として体験されるのはこちらだが、その分だけ生成内容の揺れも一緒に持ち込む。

実測5: 「思考をオフ」の指定は、必ずしも思考を止めない

ここから先は RTX 5060 Ti のみで実施した。2枚の比較ではなく、指定と呼び出し方が記録のされ方をどう変えるかを見る。

指定が効くかはモデルで分かれた

思考型の2モデルに対し、リクエストで思考をオンにする指定とオフにする指定をそれぞれ渡し、各3回計測して中央値を取った。すべてモデルが既にメモリに載っている状態で、同一プロンプト・温度0、生成は打ち切っていない。呼び出しにはチャット用のエンドポイントを使っている。

モデル 思考の指定 最初に返るまで 最終回答が始まるまで 思考部分の文字数
deepseek-r1:8b(思考型) オン 334.7 ms 3,480 ms 1,349字
deepseek-r1:8b(思考型) オフ 329.2 ms 3,488 ms 1,349字
qwen3:14b(思考型) オン 377.3 ms 8,038 ms 1,687字
qwen3:14b(思考型) オフ 300.6 ms 301 ms 0字

qwen3:14b では指定が効き、思考部分は0字、最終回答が始まるまでは 8,038 ms から 301 ms になった。「最初に返るまで」の 300.6 ms と一致しており、最初に届いたものがそのまま最終回答の先頭だったことを示す。あわせて最終回答フィールドの長さを見ると、オフ側はオン側の回答部分とほぼ同じで、思考の分だけ長くなってはいない。後段で扱う「記録先が移っただけ」の場合はここが思考の分だけ膨らむので、両者はこの点で見分けられる。指定が効いた側では最終回答までが短くなった、というのがここで言える範囲になる。

deepseek-r1:8b では、オフを指定しても思考部分は 1,349字 のままで、最終回答が始まるまでも 3,480 ms と 3,488 ms でほぼ変わらない。オフの指定に対してエラーは返っていない。ただし、指定が受け取られたうえで結果が変わらなかったのか、指定そのものが無視されたのかは、返ってきた値からは区別できない。原因がモデルの側にあるのか実行環境の側にあるのかは、この計測では区別できない。

未確認の点も明示しておく。思考の指定そのものは API リファレンスにも think パラメータとして、チャット用・生成用の両方に記載がある。Ollama の公式説明は、思考の指定をオフにした場合について「モデルは思考せず、内容を直接出力する」と書いている。つまり仕様どおりなら無効になるはずのところ、この deepseek-r1:8b と Ollama 0.32.3 の組合せではその結果にならなかった、というのがここで観測したことになる。原因がモデルの側にあるのか、Ollama の版なのか、その他の実行条件なのかは、この計測では切り分けていない。なお、公式ドキュメントが例示する deepseek-r1 と、この記事で測った deepseek-r1:8b が同じ中身かどうかは、ダイジェストの照合で確認済み。

同じ指定でも、記録先は呼び出し方で変わる

次に deepseek-r1:8b だけを対象に、呼び出すエンドポイントと思考オフの指定の組合せを変えた。「最終回答フィールド」は、チャット用エンドポイントでは message.content、生成用エンドポイントでは response を指す。モデルが載っている状態から各3回計測し、中央値を取っている。

呼び出し方 最初に返るまで 最終回答フィールドが埋まり始めるまで 思考フィールド 最終回答フィールド
/api/chat・思考オフを指定しない 317.7 ms 3,481 ms 1,349字 438字(回答本文から始まる)
/api/chat・思考オフを指定 292.7 ms 3,475 ms 1,349字 438字(回答本文から始まる)
/api/generate・思考オフを指定 332.4 ms 332 ms 0字 1,802字(<think> から始まる)

読むべきは2行目と3行目の対比になる。同じ「思考オフを指定」でも、チャット用では思考が思考フィールドに 1,349字 入り、最終回答フィールドが埋まり始めるのは 3,475 ms。生成用では思考フィールドが0字になり、最終回答フィールドは 332 ms で埋まり始める。

3行目の思考フィールド0字は、思考が生成されなくなったという意味ではない。同じ条件の最終回答フィールドが 1,802字 に膨らみ、その先頭が思考の開始タグになっている。増えた分は、思考 1,349字 に開始・終了タグ分を足した長さと過不足なく一致する。つまり思考は生成されており、記録先が本文側へ移っただけである。前節の qwen3:14b で0字になったケースは、最終回答フィールドが思考の分だけ長くなっていないことまで確認したうえで思考そのものが止まったと判断したもので、この2つは別の現象になる。

1行目は同じ条件で取り直した別の実行の中央値で、2・3行目とは実行が異なる。行をまたいだ「最初に返るまで」の差は、実行ごとのばらつきの範囲にある。エンドポイントの違いについてこの計測から言えるのは、返り方が違うということまでで、どちらが正しいという話ではない。

指標の側から見ると、これは「最終回答が始まるまで」の測り方に直結する。最終回答フィールドが埋まり始めた時刻を機械的に取ると、生成用エンドポイントの3行目では思考の先頭を最終回答の開始として記録してしまう。同じスクリプトで同じモデルを測っても、呼び出し方を変えれば 3,475 ms と 332 ms という別の数字が出る。

指標を混ぜると何が起きるか

ここまでの非対称を踏まえると、誤読はいくつかの型に整理できる。

未ロードの値と載った状態の値を並べる。deepseek-r1:8b では 7,294 ms と 320.7 ms で、同じモデル・同じGPUでも桁が違う。前者を代表値にすれば常用時の応答を大きく見誤り、後者だけを見れば保持が切れた後の待ちが視界から消える。どちらの条件で測ったかを書かない数字は、この2つのどちらとも解釈できてしまう。

「最初に返るまで」でGPUを比べる。この指標の大半を占めるのは、2枚で向きの揃わないモデルの読み込みだった。この指標に乗るプロンプト処理の差はミリ秒単位でしかない。デコード速度にいたっては、最初のトークンより後の生成速度なので、そもそもこの指標には入らない。だからGPUの違いはこの指標にほとんど現れない。逆に言えば、この指標で差が出なかったことを「差が無い」と読むと、この指標が最初から捉えていないデコード速度の 1.56〜1.98倍を見落とすことになる。

思考型と非思考型を一つの指標で並べる。非思考型は2つの指標が一致するのに対し、思考型は最大で 21.8倍(RTX 5060 Ti・qwen3:14b)離れた。どちらの指標で並べたかが書かれていない表は、思考型の行が何を意味するのかを読み手が決められない。

サーバーの内訳と壁時計を混ぜる。load_duration は Ollama が返す値で、外から測った時間ではない。片方が壁時計、もう片方がサーバー内訳という2つの数字を、同じ時計で測った内訳であるかのように足し引きして提示することはできない。別々の実行・別々の出所から取った数字を組み立てて1つの TTFT にした場合も同じで、出てくるのは名前だけが同じ別の数字になる。加えて、思考フィールドが空だからといって思考が止まったとは限らない点も、実測5のとおりである。

公開されている数値を手元の環境の見積もりに使うときの一般的な注意は、ローカルLLMのベンチマーク数値の読み取り方で扱っている。

手元の環境で測るときの手順

同じ分け方を手元の環境で再現する手順は5つになる。

  1. keep_alive を0にして対象モデルを解放する。応答が返っても解放が終わっているとは限らないので、少し待ってから次へ進む
  2. 解放した状態から1回投げ、未ロードからの「最初に返るまで」を記録する
  3. 続けて同じモデルへ3回投げ、モデルが載った状態の値を取って中央値にする
  4. 1回ごとに、最初に届いたチャンクが思考部分か最終回答かを記録し、「最終回答が始まるまで」を別に取る。あわせてレスポンスの load_duration と prompt_eval_duration を控える(いずれもナノ秒で返る。本文の表はミリ秒に直している)
  5. 出力の打ち切り設定の有無を記録に残す。打ち切ると、思考型は最終回答に到達しないことがある

手順1〜4をそのまま実装したものが以下のスクリプトになる。標準ライブラリだけで動く。MODELS を手元で使っているモデル名に書き換えて実行する。打ち切りの設定は渡していないため、手順5にあたる打ち切りの有無はスクリプトの外に記録する。HOST はスキームを含む URL 形式で渡す。Ollama 公式は OLLAMA_HOST をスキーム無しの host:port 形式で例示しており、その値がそのまま入っていると接続先の組み立てに失敗し、failed のあとに unknown url type とだけ表示される。スキーム付きの値に直すか、別名の環境変数で渡す。

"""Ollama の TTFT を3種類に分けて測る。同じ TTFT でも「何か最初に届く」「最終回答の先頭が届く」「サーバー内訳 load/prompt_eval」は別物なので分けて記録する。"""
import json
import os
import statistics
import time
import urllib.request

# Ollama の既定ポートは 11434。変えている場合は環境変数 OLLAMA_HOST で上書きする
HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
# 計測したいモデル名に書き換える(`ollama list` の NAME 列を使う)。
# 思考型と非思考型を1つずつ入れておくと first_kind の違いが出る
MODELS = ["deepseek-r1:8b", "llama3.1:8b"]
PROMPT = "Explain in three sentences why prefix caching speeds up repeated LLM requests."

def _post(model, messages, keep_alive=None):
    body = {"model": model, "messages": messages, "stream": True}
    if keep_alive is not None:
        body["keep_alive"] = keep_alive
    req = urllib.request.Request(f"{HOST}/api/chat", data=json.dumps(body).encode(),
                                  headers={"Content-Type": "application/json"})
    return urllib.request.urlopen(req)

def loaded(model):
    # /api/ps は今ロードされているモデルを返す。解放できたかを時間で推測せず
    # ここで直接確かめる
    with urllib.request.urlopen(f"{HOST}/api/ps") as resp:
        data = json.loads(resp.read())
    return any(m.get("name") == model or m.get("model") == model
               for m in data.get("models", []))

def unload(model, timeout=30):
    # keep_alive=0 で解放してから測る。初回の値は「未ロードから始めた」ことが前提で、
    # 載ったままのモデルに投げると読み込み時間が入らず別の数字になる
    with _post(model, [], keep_alive=0) as resp:
        resp.read()
    # 応答が返っても解放が終わっているとは限らないので、消えるまで待つ。
    # 経過時間や load_duration のしきい値で判定すると、常駐時の読み込みが
    # もともと長いモデルを取りこぼす
    deadline = time.time() + timeout
    while loaded(model) and time.time() < deadline:
        time.sleep(1)
    return not loaded(model)

def run_once(model):
    t0 = time.perf_counter()
    first_any = first_content = first_kind = None
    server = {}
    with _post(model, [{"role": "user", "content": PROMPT}]) as resp:
        for line in resp:
            line = line.strip()
            if not line:
                continue
            data = json.loads(line)
            msg = data.get("message", {})
            thinking, content = msg.get("thinking") or "", msg.get("content") or ""
            if first_any is None and (thinking or content):
                first_any = time.perf_counter()
                # thinking 対応モデルは thinking が先に来る。先着を記録しないと
                # 同じ TTFT という名前で違うものを比較することになる
                first_kind = "thinking" if thinking else "content"
            if first_content is None and content:
                first_content = time.perf_counter()
            if data.get("done"):
                for k in ("load_duration", "prompt_eval_duration", "eval_duration"):
                    if k in data:
                        server[k] = data[k] / 1e6  # ns -> ms
                server["eval_count"] = data.get("eval_count")  # これだけは時間ではなくトークン数
    return {
        "first_any_ms": (first_any - t0) * 1000 if first_any else None,
        "first_content_ms": (first_content - t0) * 1000 if first_content else None,
        "first_kind": first_kind,
        **server,
    }

def median(results, key):
    values = [r[key] for r in results if r.get(key) is not None]
    return statistics.median(values) if values else None

def ms(value):
    # 最終回答が一度も届かない実行では None が入る。そこを 0 と表示すると
    # 「すぐ返った」と読めてしまうため、取れなかったことが分かる形で出す
    return f"{value:.1f}ms" if value is not None else "n/a"

def main():
    for model in MODELS:
        try:
            if not unload(model):
                # 解放を確認できないまま測ると、載ったままの回が初回の値に混ざる
                print(f"  ! {model}: 解放を確認できなかった。初回の値として扱わない")
                continue
            cold = run_once(model)
            warms = [run_once(model) for _ in range(3)]
            print(f"{model}: cold first_any={ms(cold['first_any_ms'])} "
                  f"first_content={ms(cold['first_content_ms'])} "
                  f"first_kind={cold['first_kind']} "
                  f"load={ms(cold.get('load_duration'))} "
                  f"prompt_eval={ms(cold.get('prompt_eval_duration'))} "
                  f"| warm_median first_any={ms(median(warms, 'first_any_ms'))} "
                  f"first_content={ms(median(warms, 'first_content_ms'))}")
        except Exception as e:
            print(f"{model}: failed ({e})")

if __name__ == "__main__":
    main()

このスクリプトは本記事の測定環境で、上の既定値のまま実行して動作を確認している。スクリプトは解放できたかどうかを時間ではなく /api/ps のロード状態で確かめる。読み込み時間のしきい値で判定すると、常駐状態の読み込みがもともと長いモデル(実測2の gemma3:4b は 611.1 ms)を取りこぼす。ここで扱っているのは Ollama 経由の値で、レスポンスに内訳が含まれることを前提にした作りになる。実行環境そのものの選択肢はローカルLLM推論エンジン比較で扱っている。

よくある質問

手元の環境で応答が遅いとき、どこから見ればよいか

最初に切り分けるのは、そのリクエストがモデルの載っていない状態から始まったかどうか。載っていない状態から始まったなら、待ちの大半は読み込みで、対処は常駐させるかどうかの話になる。載った状態でも遅い場合は、レスポンスの内訳を見て、読み込み・プロンプト処理・生成のどれが伸びているかを確かめる。常駐させても読み込みのフィールドは残るため、最初の表示までの待ちではここが効いていることがある。この3つは打つ手が違うため、内訳を見ないまま機材を替える判断には進みにくい。

思考型のモデルは避けたほうがよいか

何を待っているかによる。最初に何かが届くまでで見るなら、思考型でも届くのは思考部分なので、この指標の上では非思考型と同じ土俵に乗る。ただし思考を画面に出さない使い方では、表示が始まるのは最終回答からになり、待ちは思考の分だけ伸びる。先に決めるべきなのは、どちらの時点をもって「応答があった」とみなすか。

思考をオフにする指定を入れれば待ちは縮むか

手元の環境で確かめる方法は、指定の前後で思考フィールドの文字数を比べること。文字数が変わらなければ、その指定はそのモデルでは結果を変えていない。文字数が0になった場合も、最終回答フィールドの先頭を一緒に確認する。思考の開始タグから始まっていれば、止まったのではなく記録先が移っただけになる。

モデルを常駐させれば読み込みの時間はなくなるか

秒単位の読み込みは避けられる一方、読み込みのフィールドそのものは常駐状態でも残る。常駐は「起動直後の1回」を避けるための手当てであって、待ち時間の下限をゼロにする操作ではない。手元の環境でどこまで縮むかは、保持が効いている状態で数回投げ、その中央値を見れば分かる。

まとめ

TTFT という数字を比べる形にするなら、揃えるべきものははっきりしている。

  • どちらの指標か(最初に返るまで / 最終回答が始まるまで)
  • どこから測ったか(未ロード / モデルが載った状態)
  • どこの時計か(クライアント側の壁時計 / サーバーが返す内訳)
  • モデルのタグと、そのタグが指している中身(名前だけでは足りない)
  • 出力の打ち切り設定の有無
  • プロンプト・温度・量子化
  • 呼び出したエンドポイント

これらが揃っていない数字どうしは、同じ名前で別のものを測っている。そして揃えたうえでなお、この記事の測定範囲で言えるのは処理時間の内訳までであり、出力の品質や回答の正しさはここには入っていない。

参考資料

プロンプト処理の速さが機械によってどれだけ違うかは、NVIDIA DGX Sparkは買いかで128GB級と16GBのGPUを並べて見ている。

タイトルとURLをコピーしました