vLLM là engine mã nguồn mở để phục vụ mô hình ngôn ngữ lớn (LLM) với thông lượng cao, ra đời từ nhóm nghiên cứu ở UC Berkeley và công bố qua bài báo PagedAttention tại SOSP 2023. Hai ý tưởng làm nên nó là continuous batching (gộp yêu cầu ở mức từng bước sinh token) và quản lý KV cache theo khối như bộ nhớ ảo của hệ điều hành. Bài này không chỉ nói "vLLM nhanh" mà tự dựng lại từng cơ chế ở quy mô nhỏ: đo kích thước KV cache thật, đo vì sao batch lớn rẻ hơn theo từng token, mô phỏng lập lịch và viết một bộ cấp phát khối kèm prefix caching.
Bài nối tiếp Tối ưu suy luận: export ONNX, quantization và chạy CPU và Phục vụ mô hình AI bằng FastAPI. Những bài đó nói về mô hình nhỏ chạy trên CPU; ở đây ta chuyển sang mô hình sinh văn bản, nơi bộ nhớ chứ không phải phép tính quyết định bạn phục vụ được bao nhiêu người cùng lúc. Token và ngữ cảnh đã giải thích ở LLM hoạt động thế nào.
Giới hạn trung thực của bài: vLLM chủ yếu nhắm GPU. Máy thử nghiệm là Apple M4, 16 GB, không có GPU NVIDIA; theo tài liệu vLLM, Apple Silicon chỉ ở mức thử nghiệm và phải build từ mã nguồn. Vì vậy bài không chạy vLLM thật. Mọi con số dưới đây là output thật của mã trong code/s7-49 (PyTorch 2.8.0, Transformers 4.56.0, mô hình Qwen2-0.5B, CPU 2 luồng) và của Ollama qua endpoint tương thích OpenAI. Phần lệnh vllm serve trên GPU là cấu hình tham khảo từ tài liệu, ghi rõ "cần GPU, chưa chạy trong bài".
Vì sao phục vụ LLM khó hơn phục vụ mô hình thường
LLM sinh từng token một: mỗi token mới cần một lượt chạy qua toàn bộ mô hình, và lượt đó phải đọc lại khoá (key) và giá trị (value) của mọi token trước đó. Để không tính lại, ta lưu chúng lại; vùng nhớ này gọi là KV cache. Hệ quả có ba mặt:
- Số bước không biết trước. Một yêu cầu có thể dừng sau 5 token, yêu cầu khác sinh 500 token.
- KV cache lớn dần theo từng bước và chiếm phần lớn VRAM còn lại sau khi nạp trọng số.
- Bước decode bị giới hạn bởi băng thông bộ nhớ, không phải phép nhân ma trận: mỗi bước phải đọc toàn bộ trọng số chỉ để sinh một token cho mỗi yêu cầu trong batch. Gộp nhiều yêu cầu vào một bước gần như "miễn phí".
Muốn thông lượng cao phải gộp nhiều yêu cầu vào một bước, và muốn gộp nhiều thì phải quản lý KV cache cho khéo; vLLM giải cả hai.
KV cache lớn cỡ nào: công thức và phép đo thật
Với mỗi token, mỗi lớp lưu một vector key và một vector value cho từng KV head. Kích thước theo byte là 2 x số lớp x số KV head x head_dim x byte mỗi phần tử. Đoạn mã đọc cấu hình thật của Qwen2-0.5B trong cache Hugging Face và so với hai cấu hình Llama công bố:
def kv_bytes_per_token(layers, kv_heads, head_dim, dtype_bytes):
# 2 = key và value
return 2 * layers * kv_heads * head_dim * dtype_bytesOutput của step1_kv_size.py:
Qwen2-0.5B: layers 24 q_heads 14 kv_heads 2 head_dim 64
Qwen2-0.5B (config trong cache): 12288 byte/token fp16 = 12 KiB; 4096 token = 48 MiB; 32 yêu cầu x 4096 = 1.5 GiB
Llama-3-8B (config công bố: 32 lớp, 8 KV head, 128): 131072 byte/token fp16 = 128 KiB; 4096 token = 512 MiB; 32 yêu cầu x 4096 = 16.0 GiB
Llama-2-7B (config công bố: 32 lớp, 32 KV head, 128, không GQA): 524288 byte/token fp16 = 512 KiB; 4096 token = 2048 MiB; 32 yêu cầu x 4096 = 64.0 GiBVới mô hình cỡ 7–8 tỉ tham số, 32 yêu cầu dài 4.096 token đã cần 16 GiB KV cache (Llama-3-8B) tới 64 GiB (Llama-2-7B), vượt xa một GPU 24 GB sau khi trừ trọng số khoảng 16 GB. Nhờ grouped-query attention (GQA, ít KV head hơn query head), KV cache của Llama-3-8B nhỏ hơn bốn lần so với Llama-2-7B cùng cỡ: chọn kiến trúc mô hình ảnh hưởng trực tiếp tới chi phí phục vụ.
Công thức có đúng với thực tế không? step2_real_kv.py chạy Qwen2-0.5B bf16 trên CPU, lấy past_key_values và cộng kích thước các tensor:
16 token: đo được 196608 byte, công thức 196608 byte, khớp=True
64 token: đo được 786432 byte, công thức 786432 byte, khớp=True
256 token: đo được 3145728 byte, công thức 3145728 byte, khớp=TrueKhớp tuyệt đối, và tăng tuyến tính theo độ dài. Để lập kế hoạch, lấy VRAM trừ trọng số rồi chia cho KV byte mỗi token.
Batching: vì sao gộp yêu cầu làm giảm chi phí mỗi token
step3_decode_batch.py đo thời gian một bước decode (sinh 1 token cho mỗi yêu cầu) với ngữ cảnh 128 token, batch từ 1 đến 16, mô hình fp32, 2 luồng CPU, lấy trung vị 5 lần chạy:
batch= 1: 39.9 ms/bước, 39.9 ms/token, 25.0 token/s
batch= 2: 69.2 ms/bước, 34.6 ms/token, 28.9 token/s
batch= 4: 71.4 ms/bước, 17.9 ms/token, 56.0 token/s
batch= 8: 121.4 ms/bước, 15.2 ms/token, 65.9 token/s
batch=16: 135.6 ms/bước, 8.5 ms/token, 118.0 token/sBatch 16 chỉ chậm hơn batch 1 khoảng 3,4 lần mỗi bước nhưng sinh nhiều hơn 16 lần token, nên thông lượng tăng gần 5 lần. Số liệu CPU có nhiễu (batch 2 chậm bất thường vì máy dùng chung), nhưng xu hướng rõ: chi phí cố định mỗi bước được chia đều cho cả batch.
Lưu ý tài nguyên: lần chạy này dùng fp32 và batch tối đa 16 nên RSS đỉnh đo bằng /usr/bin/time -l khoảng 3,11 GB (3111976960 byte), ở sát mức trần khoảng 3 GB cho một tiến trình; nếu máy ít RAM hơn, hãy dùng bf16 hoặc giới hạn batch tối đa 8.
Từ 5 điểm đo, ta khớp một mô hình chi phí tuyến tính t(B) = a + b x B; kết quả là t(B) = 50,3 + 6,0 x B ms. Mô hình này đủ để mô phỏng lập lịch ở phần sau.
Static batching và continuous batching
Cách ngây thơ nhất là static batching (còn gọi request-level): gom tối đa B yêu cầu, chạy cả nhóm cho tới khi yêu cầu dài nhất sinh xong, rồi mới nhận nhóm mới. Yêu cầu ngắn xong sớm vẫn phải đợi, ô trống trong batch bị bỏ phí và yêu cầu mới phải chờ cả nhóm cũ.
Continuous batching (hay iteration-level scheduling) lập lịch ở mức từng bước. Sau mỗi bước, yêu cầu đã xong rời batch, yêu cầu đang chờ chen vào chỗ trống ngay. Ý tưởng này do bài báo Orca (Yu và cộng sự, OSDI 2022) đưa ra, vLLM kế thừa và kết hợp với PagedAttention.
step4_scheduler_sim.py mô phỏng cả hai chính sách với 64 yêu cầu đến theo phân phối Poisson (trung bình 120 ms một yêu cầu), độ dài sinh theo phân phối lognormal có đuôi dài, batch tối đa 8, chi phí bước lấy từ mô hình đã khớp ở trên:
chi phí bước: t(B) = 50.3 + 6.0*B ms (khớp từ 5 điểm đo)
64 yêu cầu, token sinh: trung vị 34, lớn nhất 205, tổng 2926
static batching : xong sau 79.0 s, thông lượng 37.0 token/s, độ trễ p50 29.4 s, p95 71.9 s
continuous batching: xong sau 40.0 s, thông lượng 73.2 token/s, độ trễ p50 13.0 s, p95 29.4 sĐây là mô phỏng, không phải đo vLLM, và nó bỏ qua giai đoạn prefill. Nhưng nó cho thấy cơ chế: cùng phần cứng, cùng giới hạn batch, continuous batching gần gấp đôi thông lượng và giảm một nửa độ trễ vì không còn cảnh yêu cầu 20 token đợi yêu cầu 200 token.
Hai pha: prefill và decode
Mỗi yêu cầu có hai pha khác bản chất. Prefill xử lý toàn bộ prompt trong một lượt song song, tốn nhiều phép tính và quyết định thời gian tới token đầu tiên (TTFT). Decode sinh từng token, tốn băng thông bộ nhớ và quyết định tốc độ chữ chạy ra. Prompt dài chen vào giữa lúc nhiều người đang decode sẽ làm mọi người khựng lại. Giải pháp là chunked prefill: cắt prompt thành từng đoạn và trộn với các bước decode. Tài liệu vLLM mô tả: khi bật, các yêu cầu prefill có thể bị chia nhỏ theo phần còn lại của max_num_batched_tokens.
Theo bài giới thiệu kiến trúc V1 của vLLM, bộ lập lịch V1 xem token của prompt và token sinh ra như nhau, biểu diễn mỗi quyết định bằng một ánh xạ {request_id: số token}, nên chunked prefill, prefix caching và speculative decoding cùng chạy trên một cơ chế; chunked prefill được bật mặc định khi có thể.
PagedAttention: KV cache như bộ nhớ ảo
Hệ thống trước vLLM thường cấp cho mỗi yêu cầu một vùng nhớ liền khối đủ cho độ dài tối đa, vì không biết trước yêu cầu sẽ sinh bao nhiêu. Bài báo PagedAttention (Kwon và cộng sự, arXiv:2309.06180) chỉ ra hệ quả: phân mảnh trong và ngoài, cộng với sao chép dư thừa, làm lãng phí bộ nhớ và giới hạn kích thước batch. vLLM mượn ý tưởng phân trang: chia KV cache thành các khối kích thước cố định (mỗi khối chứa KV của một số token, ví dụ 16), cấp khối theo nhu cầu và dùng bảng khối (block table) ánh xạ vị trí logic của yêu cầu sang khối vật lý. Kernel attention đọc qua bảng này nên các khối không cần nằm cạnh nhau.
Tác giả báo cáo thông lượng cao hơn 2–4 lần so với FasterTransformer và Orca ở cùng độ trễ, và mức tăng lớn hơn với chuỗi dài, mô hình lớn.
step5_paged_blocks.py dựng một mô hình thu nhỏ: bộ nhớ KV đủ cho 8 yêu cầu nếu cấp liền khối theo max_len = 2048, độ dài thực tế của 500 yêu cầu sinh ngẫu nhiên theo lognormal, khối 16 token:
độ dài thực tế: trung vị 260, p95 735, max_len cấu hình 2048
liền khối : chạy cùng lúc 8 yêu cầu, dùng thật 17% chỗ đã giữ, lãng phí 83%
phân trang: nhét được 45 yêu cầu cùng lúc vào cùng bộ nhớ, lãng phí chỉ ở khối cuối: 2.2%Phân phối độ dài là giả định của mình nên đừng đọc "45 so với 8" như số liệu của vLLM; điều có giá trị là cơ chế. Cấp liền khối giữ chỗ cho trường hợp xấu nhất nên phần lớn bị bỏ trống; cấp theo khối chỉ lãng phí tối đa một khối chưa đầy ở cuối mỗi yêu cầu. Bộ nhớ tiết kiệm được biến thành batch lớn hơn, và batch lớn hơn là thông lượng cao hơn như đã đo ở trên.
Prefix caching: tái dùng KV của phần mở đầu giống nhau
Trong ứng dụng doanh nghiệp, phần lớn prompt bắt đầu bằng cùng một system prompt, chính sách hoặc ví dụ mẫu. Tính lại KV cho phần đó ở mỗi yêu cầu là phí. Theo tài liệu thiết kế của vLLM, automatic prefix caching chỉ cache các khối đầy; hash của mỗi khối được tính từ hash khối trước, token ID của khối, và các giá trị bổ sung (LoRA, hash đầu vào đa phương tiện, cache_salt). Vì hash chứa hash khối cha nên nó phản ánh cả tiền tố. Khối không còn được tham chiếu nằm trong hàng đợi trống và bị đẩy ra theo LRU.
Thuật toán băm mặc định là sha256 (từ v0.11); cache_salt cho phép chỉ các yêu cầu cùng salt dùng chung khối, một chốt chặn cần thiết trong hệ thống đa khách hàng vì thời gian phản hồi lệch giữa hit và miss có thể làm lộ prompt của người khác.
Đoạn mã dưới tự cài cơ chế băm nối chuỗi trên token thật của tokenizer Qwen2:
def block_hashes(ids):
hs, parent = [], b""
for i in range(0, len(ids) - len(ids) % BLOCK, BLOCK): # chỉ khối đầy mới được cache
parent = hashlib.sha256(parent + str(ids[i:i + BLOCK]).encode()).digest()
hs.append(parent.hex()[:8])
return hsBa câu hỏi khách hàng cùng một system prompt tiếng Việt, câu hỏi khác nhau ở cuối:
prompt 183 token = 11 khối đầy, trùng tiền tố 0 khối (0 token tính lại được bỏ qua)
prompt 182 token = 11 khối đầy, trùng tiền tố 10 khối (160 token tính lại được bỏ qua)
prompt 177 token = 11 khối đầy, trùng tiền tố 10 khối (160 token tính lại được bỏ qua)Câu đầu phải tính hết, hai câu sau tái dùng 10 trên 11 khối. Khối thứ 11 chứa phần đầu câu hỏi riêng nên không trùng. Bài học thực hành: đặt phần cố định lên trước, phần thay đổi (câu hỏi, dữ liệu người dùng) xuống sau. Chèn timestamp hoặc ID yêu cầu ở đầu prompt sẽ phá toàn bộ cache.
Dựng vLLM trên GPU: cấu hình tham khảo
Phần vLLM này cần GPU, chưa chạy trong bài; các tên tham số lấy từ tài liệu vLLM. Server dựng bằng vllm serve <mô hình> và mở các endpoint tương thích OpenAI như /v1/chat/completions, /v1/completions, /v1/embeddings và /v1/models. Ví dụ khung (đổi tên mô hình, giá trị theo GPU của bạn):
vllm serve Qwen/Qwen2.5-7B-Instruct \
--max-model-len 8192 \
--gpu-memory-utilization 0.90 \
--max-num-seqs 64 \
--enable-prefix-cachingÝ nghĩa các tham số theo tài liệu: --max-model-len là độ dài ngữ cảnh gồm cả prompt và output (hạ xuống sẽ giải phóng KV cache cho nhiều yêu cầu hơn); --gpu-memory-utilization là tỉ lệ bộ nhớ GPU mà một instance được dùng; --max-num-seqs và --max-num-batched-tokens giới hạn số yêu cầu và số token trong một bước; --kv-cache-dtype mặc định auto (theo kiểu dữ liệu mô hình). Hãy kiểm tra giá trị mặc định ở phiên bản bạn cài, vì chúng thay đổi giữa các bản.
Với CPU, vLLM có wheel dựng sẵn cho Linux x86 và ARM, Docker image, và build từ mã nguồn trên macOS Apple Silicon ở mức thử nghiệm. Nếu mục tiêu là chạy thử trên máy cá nhân, Ollama và llama.cpp vẫn là đường ngắn hơn.
Client tải: đo TTFT và thông lượng qua API tương thích OpenAI
Dù backend là vLLM, Ollama hay dịch vụ thương mại, client đo tải viết giống nhau vì cùng giao thức. step6_client_load.py mở luồng streaming tới /v1/chat/completions, đo thời gian tới chunk đầu và tổng thông lượng. Mặc định trỏ vào Ollama ở localhost:11434/v1, không cần API key thật; với vLLM chỉ đổi BASE_URL và MODEL:
đồng thời=1: TTFT trung vị 568 ms, xong sau 1.58 s, 60 chunk, 38.0 chunk/s (trung vị 5 lần)
đồng thời=4: TTFT trung vị 2411 ms, xong sau 4.79 s, 240 chunk, 50.1 chunk/s (trung vị 5 lần)Script có một lượt khởi động (nạp mô hình) rồi đo 5 lần và lấy trung vị. Kết quả trên máy dùng chung này (load trung bình hơn 20 vì nhiều tiến trình khác chạy cùng lúc) cho thấy điều ngược với kỳ vọng ngây thơ: bốn yêu cầu đồng thời xong sau 4,79 giây so với 1,58 giây của một yêu cầu, thông lượng tổng chỉ tăng từ 38 lên 50 chunk/s (các lần chạy lại cho 38 đến 67 chunk/s ở mức 1 luồng và 50 đến 58 chunk/s ở mức 4 luồng, tức khác biệt giữa hai mức nằm trong vùng nhiễu), còn TTFT của 4 luồng tăng lên hơn 2 giây vì các yêu cầu phải xếp hàng.
Với cấu hình mặc định, Ollama không gộp các yêu cầu thành một batch lớn như continuous batching của vLLM; mức song song phụ thuộc cài đặt của nó và tài nguyên máy.
Đây chính là lý do cần đo thay vì suy đoán: số liệu một luồng và nhiều luồng trên backend khác nhau cho kết luận khác nhau. (Đơn vị là chunk streaming, xấp xỉ token.) Khi đánh giá vLLM thật, hãy đo đúng ba chỉ số: TTFT, thời gian giữa hai token và thông lượng tổng ở nhiều mức đồng thời, trên tải có phân phối độ dài giống sản phẩm của bạn, và lặp nhiều lần lấy trung vị.
Khi nào nên và không nên tự host bằng vLLM
Nên tự host khi lưu lượng đều, nhiều người dùng đồng thời hoặc dữ liệu không được rời hạ tầng. Nên chọn API thương mại hoặc Ollama khi chỉ có vài request mỗi phút (GPU chạy không tải vẫn tốn tiền), khi không có GPU, hoặc khi tác vụ khó đòi chất lượng mà mô hình mở cỡ nhỏ chưa đạt: hãy đo trên bộ test của bạn.
Tự host vLLM kéo theo việc vận hành: cập nhật phiên bản (thay đổi nhanh), giám sát GPU, bảo mật endpoint. Đừng mở cổng server ra internet mà không có xác thực; hãy đặt sau reverse proxy giống cách phục vụ mô hình bằng FastAPI.
Lỗi thường gặp
- Đặt
max-model-lenquá lớn "cho chắc". Server phải dành chỗ cho ít nhất một yêu cầu dài tối đa; GPU nhỏ sẽ không khởi động hoặc còn rất ít KV cache cho batch. - Đặt
gpu-memory-utilizationsát 1,0 khi chạy nhiều tiến trình trên một GPU. Tham số này tính theo từng instance, hai instance cùng chiếm 0,9 sẽ tranh nhau bộ nhớ. - Benchmark vLLM bằng prompt cố định, độ dài đều hoặc client gửi tuần tự: kết quả đẹp giả tạo vì không có đuôi dài.
Checklist triển khai
- Tính KV byte mỗi token từ cấu hình mô hình, suy ra số token đồng thời tối đa theo VRAM.
- Chọn mô hình có GQA nếu quan tâm chi phí phục vụ.
- Đặt
max-model-lencủa vLLM theo nhu cầu thật, không theo giới hạn lý thuyết của mô hình. - Đưa phần cố định của prompt lên đầu để tận dụng prefix caching; dùng
cache_saltkhi nhiều khách hàng dùng chung server. - Đo vLLM bằng TTFT, độ trễ giữa token và thông lượng ở nhiều mức đồng thời, với tải gần thực tế.
Chạy lại: cài requirements.txt vào venv rồi chạy step1 đến step6 trong code/s7-49; các file step*_output.txt là output đã dán. Tài liệu gốc: bài báo PagedAttention, bài báo Orca (OSDI 2022), thiết kế prefix caching của vLLM, tham số engine và cài đặt vLLM trên CPU.
Bài tiếp theo
MLOps với MLflow: theo dõi thí nghiệm, registry và triển khai mô hình. Khi mô hình đã có chỗ chạy, câu hỏi tiếp theo là quản lý vòng đời: phiên bản nào đang chạy, được huấn luyện từ dữ liệu nào và đo ra sao.



