Ringkasan Utama
Bi-directional KV transfer di vLLM membuat prefill node menarik KV cache yang disimpan decode node dari giliran sebelumnya, sehingga agen multi-turn hanya melakukan prefill untuk suffix yang baru. Tim vLLM melaporkan time to first token turun hingga 1,6x pada Qwen3-32B dan 3,3x pada GLM-5-FP8 di setup satu prefill dan satu decode dengan EFA, dengan prefix caching dimatikan di prefill.
Setiap agent loop yang saya baca berakhir sama: giliran demi giliran, client mengirim ulang seluruh percakapan, termasuk tool call dan hasilnya, dan server melakukan prefill untuk semuanya sebelum mengeluarkan satu token pun. Pada 12 Oktober 2026 tim vLLM menerbitkan tulisan tentang cara mengambil kembali pekerjaan berulang itu, dan detail yang membuat saya membacanya dua kali adalah lokasi cache-nya. Decode node sudah menyimpan KV block untuk giliran sebelumnya, termasuk token yang ia hasilkan sendiri. Prefill node, yang mengerjakan bagian paling mahal, tidak bisa melihatnya.
Ini adalah hasil membaca sumber primer, bukan benchmark yang saya jalankan sendiri. Saya membaca tulisan vLLM, halaman NixlConnector dan disaggregated prefill di dokumentasi vLLM, pull request yang sudah di-merge beserta RFC di baliknya, pull request yang masih terbuka yang menjadi dependensi reproduksi di tulisan itu, dan dokumentasi EFA dari AWS. Setiap angka di bawah milik tim vLLM atau kontributornya dan ditulis dengan atribusi. Apa pun yang tidak saya temukan di sumber, saya buang.
Mengapa giliran agen kesepuluh membayar sembilan giliran pertama lagi
Pada agen multi-turn, giliran N mengirim ulang giliran 1 sampai N-1 ditambah apa pun yang baru. Pada satu node dengan prefix caching ini murah, karena riwayatnya sudah ada. Pada disaggregation prefill-decode sering tidak demikian: tulisan itu menjelaskan prefill node menghitung ulang konteks setiap kali ia miss pada cache-nya sendiri, dan biayanya tumbuh seiring percakapan. Baseline di tulisan itu menunjukkannya. Dengan fitur mati, time to first token (TTFT) Qwen3-32B naik dari 158 ms pada prompt sekitar 2k token menjadi 778 ms pada sekitar 24k, dan GLM-5-FP8 naik dari 500 ms pada sekitar 2k menjadi 2.334 ms pada sekitar 20k.
Bagi angka ujung itu dan polanya jelas. Prompt tumbuh sekitar 12 kali untuk Qwen3-32B dan 10 kali untuk GLM-5-FP8, sedangkan TTFT tumbuh sekitar 4,9 dan 4,7 kali. Itu hitungan saya atas angka ujung di tulisan tersebut, bukan angka yang dinyatakan tulisannya, dan campuran antara recompute dan overhead tetap, jadi bukan hukum yang bersih. Poinnya lebih sempit: agen di giliran keempat puluh membayar pajak latensi yang terlihat di setiap giliran untuk teks yang sudah pernah dihitung oleh cluster.
Disaggregation saja tidak menyelesaikan ini. Dokumentasi vLLM menyebut disaggregated prefill ada untuk menyetel TTFT dan inter-token latency secara terpisah, dengan memberi tiap instance pengaturan parallel sendiri, dan menyatakan bahwa fitur itu tidak meningkatkan throughput. Halaman itu juga menandai fiturnya sebagai experimental. Jadi pertanyaan pertama bukan cara mengaktifkan bi-directional transfer, melainkan apakah traffic Anda cukup multi-turn dan berat di prefill sehingga prefix yang berulang itulah yang Anda bayar.
Apa yang ditinggalkan disaggregation prefill-decode satu arah
Pada alur standar, KV bergerak satu arah. Prefill node menghitung KV dari prompt dan mengirimnya ke decode node, yang menghasilkan jawaban. Karena itu decode node akhirnya memegang KV untuk prompt dan untuk setiap token yang ia hasilkan. Pada giliran berikutnya client mengirim ulang semuanya sebagai input, dan prefill node, yang tidak pernah melihat token hasil generate, tidak punya apa pun untuk dicocokkan. RFC di balik fitur ini menyebut dua kasus yang timbul: percakapan multi-turn, di mana respons model sebelumnya hanya ada di decode, dan prefill cache yang tergusur (evicted), di mana state sesi ada di decode sementara prefill miss pada cache-nya sendiri.
| Hal yang dibandingkan | Satu arah P ke D | Bi-directional |
|---|---|---|
| Arah transfer KV | Hanya prefill ke decode | Dua arah; decode mengembalikan block yang disimpan ke prefill pada giliran berikutnya, memakai NIXL READ di kedua arah |
| KV dari token hasil generate | Hanya di decode; prefill tidak bisa memakainya lagi | Disimpan di decode selama TTL; prefill bisa menariknya |
| Prefix yang berulang | Prefill menghitung ulang apa pun yang miss di cache-nya sendiri | Prefill menarik block-nya dan hanya menghitung suffix yang tersisa |
| Komponen tambahan | Proxy yang merutekan request | Router stateful yang sadar KV cache, dikunci dengan conversation_id, hanya menyimpan metadata |
| Saat state cache sudah basi | Tidak berlaku; prefill selalu menghitung ulang | Pembacaan block yang kedaluwarsa ditolak dan giliran itu kembali ke prefill normal |
Penulis RFC mempertimbangkan alternatif yang jelas, yaitu incremental prefill di decode node, dan menolaknya karena itu menggagalkan tujuan disaggregation dengan membuat decode node menjalankan kedua jalur. Menarik block kembali menjaga pembagian peran dan hanya mengubah apa yang berpindah lewat jaringan.
Enam langkah penarikan dari decode ke prefill
Tulisan vLLM menjelaskan alurnya dalam enam langkah. Jika dibaca bersama RFC, keputusan desain yang penting adalah tidak ada yang melakukan query ke decode node di jalur utama.
- Decode menyimpan KV untuk prompt dan untuk responsnya sendiri setelah request selesai.
- Decode mengembalikan metadata block pada chunk terakhir dari streaming response.
- Router menyimpan metadata itu di cache dengan kunci conversation_id.
- Pada giliran berikutnya, prefill melakukan lookup prefix cache lokal seperti biasa, lalu memeriksa apakah reuse dari remote memungkinkan.
- Jika jumlah yang bisa dipakai ulang mencapai threshold, ia mengantrekan receive yang asynchronous.
- Ia memuat KV dengan NIXL READ dari decode ke prefill dan hanya menghitung suffix yang tersisa secara lokal.
RFC menjelaskan mengapa metadata dibawa di response stream. Penulisnya awalnya mengusulkan query API terpisah untuk menanyakan parameter KV ke decode, mem-benchmark-nya, dan menemukan round trip tambahan menambah latensi, sehingga kini decode mengembalikan kv_transfer_params di streaming response-nya. Query API hanya tersisa untuk pairing node awal. Router menyimpan lokasi block, tidak pernah tensor KV.
# My sketch of the router-side state the post describes. NOT vLLM's code.
import time
DECODER_TTL_S = 480 # decoder_kv_blocks_ttl on the decode node
ROUTER_TTL_S = 450 # shorter on purpose: stop asking before decode drops blocks
_cache: dict[str, tuple[dict, float]] = {} # conversation_id -> (kv_params, stored_at)
def remember(conversation_id: str, kv_params: dict) -> None:
# kv_params comes from the terminal chunk of decode's streaming response.
# Block LOCATIONS only. The router never touches KV tensors.
_cache[conversation_id] = (kv_params, time.monotonic())
def lookup(conversation_id: str) -> dict | None:
hit = _cache.get(conversation_id)
if hit is None:
return None # first turn, or router restarted: full prefill
kv_params, stored_at = hit
if time.monotonic() - stored_at > ROUTER_TTL_S:
del _cache[conversation_id]
return None # decode may have dropped them: full prefill
return kv_params # handed to prefill, which pulls via NIXL READSketsa itu buatan saya dan sengaja dibuat naif. Implementasi sebenarnya adalah disagg_proxy_multiturn.py di repositori vLLM, yang belum saya baca baris demi baris. Dokumentasi menambahkan satu detail penting: conversation_id adalah ekstensi proxy dan tidak diteruskan ke vLLM sendiri, jadi gateway Anda harus membuatnya dan membawanya di setiap giliran, dan pull request yang menambahkan kv_transfer_params ke streaming response adalah dependensi wajib.
Dua pengaturan yang menentukan apakah penarikan terjadi: TTL dan threshold
Dua pengaturan menentukan apakah penarikan terjadi dan apakah itu sepadan. Keduanya ada di kv_connector_extra_config, dan tulisan serta dokumentasinya memberi nilai default berikut.
| Setting | Default | Fungsinya | Yang bisa salah |
|---|---|---|---|
| bidirectional_kv_xfer | Tidak disebutkan; set true di kedua instance | Mengaktifkan transfer dari decode ke prefill | Hanya di satu sisi, atau false di decode, dan proxy terus mencatat cache MISS |
| decoder_kv_blocks_ttl | 480 detik | Berapa lama decode menyimpan block untuk dipakai ulang; tetap, dan tidak diperpanjang oleh heartbeat | Giliran yang datang lebih lambat mencatat Declining expired remote read dan kembali ke prefill normal |
| kv_recompute_threshold | 64 token | Jumlah minimum token remote yang bisa dipakai ulang sebelum prefill menarik, bukan menghitung ulang | Terlalu rendah membuang transfer untuk prefix yang lebih murah dihitung ulang; terlalu tinggi melepas reuse yang nyata |
| kv_lease_duration | 30 detik | Berapa lama prefiller menahan block untuk decoder pada alur P ke D biasa; heartbeat memperpanjangnya | Bukan TTL bidirectional; mengubahnya tidak membuat block di decode bertahan lebih lama |
Jebakan penamaan: contoh config di pull request yang sudah di-merge menyebut threshold itu remote_pull_threshold, sedangkan dokumentasi terbaru dan tulisan Oktober menyebutnya kv_recompute_threshold. Periksa nama key terhadap versi yang Anda jalankan. Saya tidak bisa memastikan dari sumber apakah key yang tidak dikenal menimbulkan error atau diabaikan, jadi uji sendiri, jangan berasumsi.
Diskusi pull request menyebut retensi block di decode mengikuti timeout yang sama dengan transfer biasa, VLLM_NIXL_ABORT_REQUEST_TIMEOUT sebesar 480 detik, dan proxy contohnya mengadaluarsakan entry-nya sendiri pada 450 detik agar berhenti meminta block sebelum decode membuangnya. Saat PR itu ditulis, connector belum bisa menolak pembacaan block yang kedaluwarsa, jadi tiru kebiasaannya: jaga TTL router lebih pendek dari decoder_kv_blocks_ttl.
Membaca angkanya: 1,6x dan 3,3x, dan di kondisi apa diukur
Berikut angka-angka seperti yang diberikan tulisan itu. Ia mencetak angka ujung dalam prosa, bukan tabel per panjang prompt, jadi saya hanya menyalin apa yang dinyatakan.
| Model | TTFT, fitur mati | TTFT, fitur hidup | Tambahan waktu transfer KV | Pengurangan maksimum yang dilaporkan |
|---|---|---|---|---|
| Qwen3-32B (TP 8) | 158 ms pada sekitar 2k sampai 778 ms pada sekitar 24k | 122 ms sampai 460 ms | Sekitar 30 sampai 130 ms | Sekitar 1,6x |
| GLM-5-FP8 | 500 ms pada sekitar 2k sampai 2.334 ms pada sekitar 20k | 358 ms sampai 702 ms | Sekitar 29 sampai 110 ms | Hingga 3,3x |
Baca kondisinya sebelum rasionya. Satu prefill node dan satu decode node, masing-masing p5en.48xlarge dengan delapan GPU H200, di availability zone yang sama, lewat EFA, dengan prefix caching sengaja dimatikan di prefill node untuk meniru node yang sudah mengevict cache lokalnya. Benchmark client, jumlah percakapan, dan concurrency tidak disebutkan. Membagi angka ujung Qwen3-32B memberi sekitar 1,7x, bukan 1,6x seperti headline, jadi anggap headline sudah dibulatkan. Tulisan itu mengaitkan gain GLM-5-FP8 yang lebih besar dengan biaya recompute per token yang lebih tinggi; waktu transfer berada di rentang puluhan milidetik yang sama untuk kedua model, sehingga model yang lebih lambat dihitung ulang mendapat gain lebih besar. Tulisan itu sendiri menyatakan hasil bergantung pada workload dan konfigurasi deployment.
Pull request lebih terus terang soal kasus yang dialami sebagian besar cluster produksi. Dengan prefix caching aktif di prefill, penulisnya melaporkan median TTFT turun dari 307,86 ms menjadi 219,61 ms, sekitar 29 persen, pada Llama-3.3-70B di p5en. Itu hasil yang bagus dan sangat berbeda dari 3,3x. Rasio besar menggambarkan prefill node yang miss pada cache-nya sendiri; jika milik Anda jarang miss, harapkan angka yang lebih kecil.
Memasangnya: config yang dicetak tulisan vLLM, dan yang tidak dicetaknya
Tulisan itu mencetak sisi prefill secara lengkap. Ini adalah command dan config tersebut apa adanya, dengan komentar saya tentang fungsi tiap bagian.
# Prefill node, as printed in the vLLM post (Qwen3-32B, 8x H200, EFA).
# The post prints bidirectional_kv_xfer as false for the OFF leg; true is the ON leg.
export FI_PROVIDER=efa # EFA via libfabric; without it NIXL may use TCP
export VLLM_NIXL_SIDE_CHANNEL_HOST=<P_IP> VLLM_NIXL_SIDE_CHANNEL_PORT=5600
# --no-enable-prefix-caching goes on the PREFILL node only. It emulates a prefill
# node that has evicted its cache, which is the post's best case. Decode keeps it on.
vllm serve Qwen/Qwen3-32B --served-model-name Qwen --dtype bfloat16 \
--tensor-parallel-size 8 --max-model-len 32768 --max-num-seqs 8 \
--no-enable-prefix-caching --port 8100 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_buffer_device":"cuda",
"kv_role":"kv_producer","engine_id":"prefill-engine-001","kv_rank":0,
"kv_parallel_size":2,"kv_connector_extra_config":{"backends":["LIBFABRIC"],
"kv_recompute_threshold":64,"decoder_kv_blocks_ttl":480,
"bidirectional_kv_xfer":true}}'Sisi decode tidak dicetak. Tulisan itu meminta menjalankan serve_pd_multiturn.sh dengan ROLE=decode dan menjaga BIDIR sama di kedua node. Dokumentasi memberi perannya: prefill adalah kv_producer, decode adalah kv_consumer, dan kv_both yang lama sudah deprecated untuk NixlConnector. Salin nilai decode dari repro script, bukan dari tebakan saya.
# Wrong: flag set on one side only. Nothing errors, the proxy logs "cache MISS"
# on every turn, and the run looks healthy while showing no speed-up.
# prefill: "bidirectional_kv_xfer": true
# decode: "bidirectional_kv_xfer": false
# Right: identical on both nodes (the post's scripts take BIDIR=true on each),
# and restart the proxy between the OFF and ON legs: its cache is in-process.
# The multi-turn proxy takes hosts and ports, not URLs.
python3 $VLLM_SRC/examples/disaggregated/disaggregated_serving/disagg_proxy_multiturn.py \
--port 8000 --prefiller-host <P_IP> --prefiller-ports 8100 \
--decoder-hosts <D_IP> --decoder-ports 8200
# Before reading a single TTFT number, prove the transport on BOTH nodes:
fi_info -p efa | head -n 5 # empty output = no EFA provider = invalid run
# Then watch for the two log lines that explain most bad runs:
# "Declining expired remote read" -> TTL lapsed before the next turn
# "KV Transfer metrics:" -> transfer timings (prefill node log)Transport adalah bagian dari hasil. AWS mendokumentasikan bahwa EFA terintegrasi dengan libfabric dan mendukung NIXL, bahwa NIXL membutuhkan libfabric 1.21.0 atau lebih baru, bahwa RDMA read didukung pada instance dengan Nitro versi 4 ke atas, dan bahwa traffic EFA tidak bisa melintasi availability zone atau VPC. Batas terakhir itulah alasan dua node di tulisan tersebut berada di zone yang sama. Tanpa EFA, tulisan itu memperingatkan, NIXL gagal atau jatuh ke TCP socket dan angka transfernya tidak valid. Dokumentasi vLLM menambahkan bahwa backend NIXL default adalah UCX, dan mode bi-directional saat ini hanya untuk CUDA dengan KV cache di device buffer.
Di mana ia rusak: kedaluwarsa, state router, reasoning trace
Lima mode kegagalan muncul di sumber-sumber itu, dan tidak ada yang eksotis.
- TTL kedaluwarsa. Tulisan itu menyebut TTL tetap dan tidak diperpanjang, dan giliran yang datang lebih lambat kembali ke prefill normal. Agen yang menunggu tool lambat atau persetujuan manusia lebih dari 480 detik kehilangan cache-nya. Tulisan itu mengusulkan lease yang diperpanjang heartbeat sebagai pekerjaan berikutnya.
- State router ada di memori. Tulisan itu mencatat conversation cache proxy ada di dalam proses, itulah sebabnya ia harus di-restart antara run off dan on. Inferensi saya, bukan tulisan itu: restart router, atau replica router kedua, mulai dengan map kosong dan setiap percakapan membayar satu kali prefill penuh.
- Reasoning trace. Dokumentasi memperingatkan bahwa model reasoning yang membuang thinking trace di antara giliran bisa menghasilkan hasil yang salah, dan menyebut router harus mendeteksi ketidakcocokan itu.
- Kepercayaan. Di thread RFC, seorang maintainer menandai bahwa meminta client mengembalikan kv_transfer_params pada giliran berikutnya berisiko karena client berniat jahat bisa memanfaatkannya, dan menyarankan pelacakan sesi di router atau load balancer.
- Dependensi yang masih direview. Reproduksi di tulisan itu menerapkan pull request 40186 ke kedua node. Saat saya membaca halamannya pada 17 Oktober 2026, PR itu tampil open, dengan permintaan approval ulang setelah rebase pada 1 Oktober.
Cache yang membuat Anda lebih lambat hanya mengganggu. Cache yang membuat output salah adalah insiden. Jika Anda melayani model yang membuang reasoning di antara giliran, uji apakah output identik dengan fitur hidup dan mati sebelum melihat grafik latensi apa pun.
Soal kematangan: pull request yang di-merge masuk pada 30 April 2026, RFC ditutup sebagai selesai pada 15 Juni 2026, dokumentasi kini menjelaskan fiturnya, dan tulisan itu menyebut fitur ini awalnya dibangun dan divalidasi di AWS Trainium sebelum dikontribusikan ke vLLM. Itu jejak yang nyata, tetapi benchmark yang jadi dasarnya hanya mencakup satu workload per model dan satu transport. Saya menyebutnya masih awal.
Yang dikerjakan hari Senin
Jangan mulai dengan mengaktifkan flag. Mulailah dengan mencari tahu apakah traffic Anda memang punya masalah ini.
- Catat conversation_id, timestamp, dan jumlah prompt token untuk setiap request, lalu jalankan script di bawah untuk melihat berapa bagian tiap prompt yang sudah ada di giliran sebelumnya dan berapa giliran yang datang setelah TTL.
- Jika bagian yang berulang kecil, atau sebagian besar jeda melebihi 480 detik, berhenti. Prefix caching pada node yang menyatu adalah jawaban yang lebih sederhana, dan itu penilaian saya, bukan klaim vLLM.
- Jika Anda memang melakukan disaggregation, jalankan A/B dengan prefix caching aktif di prefill, karena itu kondisi produksi Anda, dan simpan run dengan caching mati sebagai skenario terbaik.
- Verifikasi transport sebelum membaca TTFT apa pun: fi_info -p efa harus menampilkan provider EFA di kedua node.
- Atur TTL router di bawah decoder_kv_blocks_ttl, dan beri alert jika tingkat cache MISS bertahan di 100 persen, yang oleh tulisan itu disebut sebagai gejala run yang salah konfigurasi.
#!/usr/bin/env python3
# usage: python3 reprefill.py < requests.jsonl
# one JSON object per line: {"conversation_id": "c1", "ts": 1760000000.0, "prompt_tokens": 18234}
import collections
import json
import sys
TTL_S = 480 # match decoder_kv_blocks_ttl
convs = collections.defaultdict(list)
for line in sys.stdin:
r = json.loads(line)
convs[r["conversation_id"]].append((r["ts"], r["prompt_tokens"]))
prompt_total = repeated = turns = late = 0
for rows in convs.values():
rows.sort()
for (prev_ts, prev_tok), (ts, tok) in zip(rows, rows[1:]):
turns += 1
prompt_total += tok
# Lower bound: the previous PROMPT is certainly part of this prompt.
# The previous response is too, but we do not log it, so we ignore it.
repeated += min(prev_tok, tok)
if ts - prev_ts > TTL_S:
late += 1
print(f"follow-up turns: {turns}")
print(f"repeated prompt share (>=): {repeated / prompt_total:.1%}")
print(f"turns after the TTL: {late / turns:.1%}")Script itu melaporkan batas bawah, karena hanya menghitung prompt token giliran sebelumnya sebagai berulang dan mengabaikan token respons yang juga dipegang decode. Saya lebih suka meremehkan peluangnya daripada menentukan perubahan cluster berdasarkan estimasi yang optimistis.
Aturan yang saya ambil: pada serving yang di-disaggregate, cache yang Anda butuhkan mungkin ada di node lain, dan pertanyaannya adalah apakah menariknya kembali lebih baik daripada menghitung ulang. Menarik menang ketika prompt panjang, giliran datang berdekatan, dan transport-nya kelas RDMA. Angka 3,3x dari tim vLLM nyata untuk kasus yang diukurnya, sekitar 29 persen adalah angka yang diharapkan saat cache prefill sendiri sehat, dan fitur ini cukup muda sehingga Anda sebaiknya membaca pull request yang masih terbuka sebelum membangun di atasnya.
Sumber dan bacaan lanjutan
- Blog vLLM: Bi-Directional KV Transfer untuk workload agentic multi-turn (12 Oktober 2026)
- Dokumentasi vLLM: penggunaan NixlConnector, termasuk transfer bidirectional
- Dokumentasi vLLM: disaggregated prefilling
- Pull request vLLM 32553: transfer KV cache bi-directional antara node P dan D (merged)
- Pull request vLLM 40186: kv params pada streaming API response (open saat dibaca)
- RFC vLLM 32733: optimasi komputasi prefill dengan transfer KV bi-directional
- Dokumentasi AWS: Elastic Fabric Adapter untuk workload AI/ML dan HPC
- NIXL di GitHub: NVIDIA Inference Xfer Library

