はじめに
RAG や Agent を運用していると、ほぼ毎リクエスト同じ長文を先頭に積んでいます。system prompt、参照ドキュメント、tool 定義——これらは質問が変わっても変わりません。にもかかわらず、素朴に動かすとエンジンは毎回その長文をゼロから prefill します。RAG のコードを書きながら自分の実装を見返したら、見事にやっていました。
このブログではこれまで、量子化・投機デコード・KV キャッシュ・エンジン選択と、生成を速くする手段を測ってきました。どれも要は「生成という計算をいかに速くするか」の話です。ですが、もう 1 つ方向の違う手段があります——「同じ計算を二度しない」。一度計算した prefix の KV cache を使い回す、prefix cache (vLLM) / RadixAttention (SGLang) です。
この記事はそれを RTX 4070 12GB で実測します。先に結論を言うと、同じ prefix の 2 回目以降、最初の 1 文字までの待ち時間 (TTFT) が最大 88% 落ちました。そして prefix が長いほど効果が大きい——つまり RAG の長文ほど効きます。
仕組み — 何がキャッシュされ、何が再利用されるか
prefix cache が効くのは prefill 側です(prefill は入力を一括処理して最初の応答までの時間を決める段階、decode は 1 token ずつ生成する段階)。
- vLLM (automatic prefix caching): KV cache を 16 token の block 単位で管理し、先頭から一致する block の KV を再利用します。次のリクエストが同じ prefix で始まれば、その分の prefill をまるごとスキップできます。デフォルトで有効。
- SGLang (RadixAttention): リクエスト群の prefix を radix tree(基数木)で共有管理し、共通する接頭辞の KV を木のノードとして使い回す仕組みです。こちらもデフォルトで有効。
どちらも「先頭が一致する部分の prefill を省く」点は同じです。末尾だけ違う質問を投げれば、共通の長い prefix はキャッシュにヒットし、差分の質問文だけを新たに prefill すればよくなります。RAG の「同じ文書 + 違う質問」はこの形そのものです。
測定設計 — cold と warm を分離する
prefix cache の効果を測る難しさは、「キャッシュに乗る前 (cold)」と「乗った後 (warm)」をきれいに分けることにあります。普通に同じプロンプトを 2 回投げると、2 回目は当然速くなりますが、それが本当に prefix cache のおかげなのか、他の暖機(JIT/autotune)のおかげなのか切り分けられません。
そこで cold/warm を分離する計測クライアントを書きました。中心はこのループです。試行 r ごとに先頭マーカを変えて prefix を毎回 cold に戻し、同じ prefix へ末尾の質問だけ変えて 2 回投げます。
# 暖機: JIT/Triton を温めるため、別の捨て prefix で 1 回だけ投げて捨てる
warmup = "(warmup throwaway)\n" + prefix[:512] + "\n\n" + question_a
request_stream_once(warmup) # 結果は統計に含めない
# 計測本体: R 回の cold/warm ペアを逐次送信
for r in range(1, runs + 1):
marker = f"Session ID: {r}\n" # 試行ごとに変わる先頭マーカ
p = marker + prefix # → サーバから見てこの prefix は毎回「新規」
# cold: この prefix を初めて prefill する回
cold = request_stream_once(p + "\n\n" + question_a)
# warm: 同じ prefix は既にキャッシュ済み。末尾の質問だけ違う
warm = request_stream_once(p + "\n\n" + question_b)
cold_ttft.append(cold["ttft_ms"])
warm_ttft.append(warm["ttft_ms"])
TTFT そのものは streaming(SSE)で測ります。stream=True でリクエストし、最初に中身のある chunk が届いた時刻と送信時刻の差を TTFT、以降の chunk 間隔を ITL として記録するだけです。
def request_stream_once(full_prompt) -> dict:
payload = {
"model": model,
"messages": [{"role": "user", "content": full_prompt}],
"max_tokens": 128, "temperature": 0,
"stream": True,
"stream_options": {"include_usage": True}, # prompt_tokens を取得
"chat_template_kwargs": {"enable_thinking": False}, # Qwen の thinking を無効化
}
req = urllib.request.Request(
url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST",
)
start = time.time()
first_content_at = 0.0
previous_at = 0.0
itl_ms = []
with urllib.request.urlopen(req, timeout=timeout) as resp:
for raw_line in resp: # SSE を 1 行ずつ読む
line = raw_line.decode("utf-8").strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
chunk = json.loads(data)
content = chunk["choices"][0].get("delta", {}).get("content", "")
if not content:
continue
now = time.time()
if first_content_at == 0.0:
first_content_at = now # ← 最初のトークン到達 = TTFT
if previous_at:
itl_ms.append((now - previous_at) * 1000) # 直前トークンとの間隔
previous_at = now
return {"ttft_ms": (first_content_at - start) * 1000,
"p50_itl_ms": statistics.median(itl_ms) if itl_ms else 0.0}
- 各試行の先頭にユニークなマーカを付け、試行ごとに prefix を新規(cold)に戻します。
- 同じ試行内で末尾の質問だけ変えて 2 回送り、1 回目を cold、2 回目を warm として TTFT を測ります。prefix は完全共有なので、warm は prefix にヒットするはずです。
- JIT/autotune の暖機は別の捨て prefix で 1 回だけ行い、統計から除外します。これだけやらないと「2 回目が速い」を素直に喜べません——ベンチは疑ってかかるものです。
- 各条件 R=5 試行の median を取り、p50 / p95 を併記します。
比較は 4 構成 × 4 ワークロード:
| config | framework | cache |
|---|---|---|
vllm_prefix_on |
vLLM | on(--enable-prefix-caching) |
vllm_prefix_off |
vLLM | off(--no-enable-prefix-caching) |
sglang_radix_on |
SGLang | on(RadixAttention 既定) |
sglang_radix_off |
SGLang | off(--disable-radix-cache) |
| workload | prefix 長 (実測 tokens) | 想定 |
|---|---|---|
short_512 |
555 | 短い system prompt |
sys_2k |
2,206 | 標準的な system prompt |
rag_4k |
4,340 | RAG 文書 1 本 |
rag_8k |
7,412 | 長文 RAG(本命) |
共通条件: 同一文書を文字数で切り詰めて prefix 長だけを振る/モデル RedHatAI/Qwen3.5-4B-FP8-dynamic/flashinfer/vLLM は --enforce-eager/max-model-len 16384/gpu-memory-utilization 0.90/temperature 0/concurrency 1 逐次。off 構成は「再利用しない世界」のベースラインとして、warm ≈ cold になることを確認に使います。
結果① — 削減率は prefix が長いほど上がる
まず本命、cache on での TTFT 削減率(warm が cold からどれだけ落ちたか):
| prefix 長 | vLLM on | SGLang on |
|---|---|---|
| ~555 tok | 55.6% | 0.9% |
| ~2,206 tok | 78.7% | 85.3% |
| ~4,340 tok | 83.4% | 90.8% |
| ~7,412 tok | 88.5% | 80.2% |
prefix が長いほど削減率が上がるのが両エンジン共通の傾向です。これは直感どおりで、prefix が長いほど「省ける prefill」が大きいからです。RAG の長文ほど効く、というメッセージがそのまま数字に出ています。vLLM は 8k で 88.5%、SGLang は 4k で 90.8% がピークでした。
結果② — warm TTFT は prefix 長にほぼ依存しなくなる
削減「率」だけでなく、実際の TTFT (ms) の絶対値を見ると、もっと本質的なことが起きています。
| prefix 長 | vLLM cold | vLLM warm | SGLang cold | SGLang warm |
|---|---|---|---|---|
| ~555 tok | 117 | 52 | 71 | 71 |
| ~2,206 tok | 254 | 54 | 256 | 38 |
| ~4,340 tok | 337 | 56 | 493 | 45 |
| ~7,412 tok | 510 | 59 | 862 | 170 |
cold TTFT は prefix 長に比例して伸びます(vLLM は 117→510ms、SGLang は 71→862ms)。当然で、長い文書をゼロから prefill するからです。
ところが warm TTFT はほぼフラットです。vLLM は prefix が 555 でも 7,412 tokens でも 52〜59ms にほぼ張り付き、SGLang も(512 を除けば)38〜170ms に収まります。
これが prefix cache の本質です。2 回目以降の TTFT を「prefix の長さに比例する量」から「ほぼ定数」に変える。RAG で参照文書をどれだけ長くしても、2 回目以降の応答開始の速さは(ほぼ)変わらない——運用上とても嬉しい性質です。
結果③ — off は削減ゼロ。効果はキャッシュ由来である
「2 回目が速いのは本当に prefix cache のおかげか?」を確かめるのが off 構成です。
| prefix 長 | vLLM off 削減% | SGLang off 削減% |
|---|---|---|
| ~555 tok | 6.1% | 0.0% |
| ~2,206 tok | 1.6% | -0.0% |
| ~4,340 tok | 0.8% | 0.1% |
| ~7,412 tok | 0.3% | -0.1% |
cache を切ると、両エンジンとも削減率はほぼ 0%(cold ≈ warm)。vLLM off の 8k で warm も 1,012ms と、cold の 1,016ms とほぼ同じです。つまり on で見えた最大 88% の削減は、暖機やノイズではなく間違いなく prefix cache / RadixAttention の効果だと確認できました。off は「同じ長文を毎回 prefill し直す世界」そのもので、TTFT は prefix 長に比例し続けます。
結果④ — vLLM と SGLang の分岐点
同じ「prefix 再利用」でも、2 エンジンには明確な違いが出ました。
短い prefix (512 tok) では vLLM だけが効きます。 vLLM は 555 tokens でも 55.6% 削減しますが、SGLang RadixAttention は 0.9%(ほぼ非ヒット)。SGLang は ~2k 以上で初めて強く効き始めます。短い system prompt 程度の再利用なら vLLM が拾ってくれます——実運用で効く差です。
長い prefix では vLLM の warm がよりフラットです。 8k で vLLM の warm TTFT は 59ms に対し、SGLang は 170ms まで上がります。一方で SGLang は cold が遅め(8k で 862ms vs vLLM 510ms)なので、初回の重さと 2 回目以降の安定性のトレードオフがあります。
なお decode 側の token 間隔 (ITL) は SGLang ~14ms < vLLM ~17〜18ms で、SGLang がわずかに速い傾向です。これは並列・scheduler を扱った前シリーズの所見とも整合します。
結論
RTX 4070 12GB・Qwen3.5-4B FP8 での prefix cache / RadixAttention 実測から言えることです:
- 2 回目以降の TTFT は最大 88% 落ちます(vLLM rag_8k 88.5%、SGLang rag_4k 90.8%)。prefix が長いほど削減率は上がります。RAG の長文ほど効きます。
- warm TTFT はほぼ定数になります(vLLM ~55ms、SGLang 38〜170ms)。prefix 長に比例していた応答開始の遅さが、再利用後はほぼ一定になります。
- off は削減 ~0%。効果は確かにキャッシュ由来で、暖機やノイズではありません。
- 短い prefix なら vLLM(512 で 55.6% 効くが SGLang は非ヒット)、長文 RAG はどちらも強い(vLLM は warm がフラット、SGLang は cold が重いが warm は十分速い)。
| 観点 | 結論 |
|---|---|
| 効果の大きさ | 2 回目以降の TTFT を最大 88% 削減 |
| prefix 長との関係 | 長いほど削減率↑、warm はほぼ定数化 |
| 効果の出る下限 | vLLM は ~512 tok から、SGLang は ~2k から |
| off との対照 | off は両者 ~0%。効果はキャッシュ由来 |
| エンジン選択 | 短 prefix は vLLM、長文 RAG はどちらも有効 |
①〜④の手段で「生成を速くする」ことを突き詰めてきましたが、RAG / Agent のように同じ長文を繰り返し投げる用途では、prefix を再利用するだけで応答開始が桁違いに速くなります。しかも多くの場合デフォルトで有効です。 「毎回同じ長文を prefill していないか」——まずそこを疑う価値があります。それが今回の実測の結論です。
実験環境: RTX 4070 12GB / Ubuntu / vLLM nightly (v0.21.1rc1.dev243) / SGLang lmsysorg/sglang:latest / モデル: RedHatAI/Qwen3.5-4B-FP8-dynamic / flashinfer / max-model-len 16384 / temperature 0 / concurrency 1 逐次 / 暖機別 prefix 1 + 計測 R=5 median