TungDaDev's Blog

Chuyện gì xảy ra bên dưới khi bạn prompt xong và nhấn Enter?

Circuit board with brain
Published on
/15 mins read/

Bạn gõ một câu, nhấn Enter, chưa tới một giây sau chữ đã bắt đầu chạy ra. Nhanh tới mức ít ai để ý trong khoảng thời gian ngắn ngủi đó, câu hỏi của mình đã đi qua cả chục hệ thống và hàng tỷ phép nhân.

Ở bài How LLMs work mình đã mổ xẻ bên trong model: token, embedding, attention. Lần này đổi góc nhìn sang phía hệ thống: một request thật sự đi qua những đâu, bước nào tốn thời gian, và tại sao lại tốn.

Ai từng đọc bài chuyện gì xảy ra khi gọi một REST API sẽ thấy nửa đầu khá quen: vẫn DNS, vẫn HTTPS, vẫn load balancer. Chỉ khác ở cuối đường không phải một câu SQL, mà là một con GPU đang cặm cụi nhân ma trận.


# nhìn từ trên cao

Giờ đi từng chặng một.


# prompt của bạn không đi một mình

Bạn chỉ gõ mỗi câu "tóm tắt giúp mình file này", nhưng thứ được gửi lên server thì dài hơn nhiều.

Đứng đầu là system prompt, mấy lời dặn của người làm app: đóng vai gì, theo quy tắc nào, giọng văn ra sao. Cái này có khi dài vài nghìn token. Tiếp theo là toàn bộ lịch sử chat, vì model không nhớ gì cả, muốn nó "nhớ" thì cứ mỗi lượt phải gửi lại hết từ đầu. Nếu app cho model gọi tool (search, chạy code...) thì mô tả từng tool cũng đi kèm. Rồi tới nội dung file đính kèm, mấy đoạn tài liệu RAG tìm được. Câu bạn vừa gõ thường lại là phần ngắn nhất.

Request gửi đi trông đại khái thế này (theo kiểu API chat phổ biến):

{
  "model": "some-model",
  "stream": true,
  "max_tokens": 1024,
  "temperature": 0.7,
  "messages": [
    { "role": "system", "content": "Bạn là trợ lý lập trình, trả lời ngắn gọn bằng tiếng Việt." },
    { "role": "user", "content": "Giải thích HashMap trong Java" },
    { "role": "assistant", "content": "HashMap lưu dữ liệu dạng key-value..." },
    { "role": "user", "content": "Vậy còn ConcurrentHashMap?" }
  ]
}

Thế nên chat càng dài thì mỗi lượt càng chậm, càng tốn: hỏi thêm một câu là kéo theo cả cái đuôi lịch sử phía trước.


# qua cổng

Đoạn này thì hệ thống web nào cũng có. Phân giải DNS, bắt tay TLS, rồi load balancer hay API gateway đẩy request về đúng region, đúng cụm máy. Gateway kiểm tra API key hoặc phiên đăng nhập, đếm xem tài khoản của bạn còn được gửi bao nhiêu request, bao nhiêu token mỗi phút (lỗi 429 quen thuộc sinh ra ở đây). Tuỳ nhà cung cấp, có thể có thêm vài bộ lọc nội dung chạy trước hoặc chạy song song.

Mấy bước này nhanh, tính bằng mili giây thôi. Chỗ tốn kém nằm ở phía sau.


# ghép template và tokenize

Model không đọc JSON. Cái danh sách messages kia phải được ghép lại thành một chuỗi văn bản duy nhất theo chat template của model, có các token đặc biệt đánh dấu ai đang nói. Ví dụ với định dạng kiểu ChatML mà Qwen và nhiều model khác dùng:

<|im_start|>system
Bạn là trợ lý lập trình, trả lời ngắn gọn bằng tiếng Việt.<|im_end|>
<|im_start|>user
Giải thích HashMap trong Java<|im_end|>
<|im_start|>assistant
HashMap lưu dữ liệu dạng key-value...<|im_end|>
<|im_start|>user
Vậy còn ConcurrentHashMap?<|im_end|>
<|im_start|>assistant

Để ý dòng cuối, chuỗi dừng lơ lửng ngay sau assistant. Model được "mời" viết tiếp từ chỗ đó. Với nó, trả lời bạn cũng chỉ là đoán phần chữ tiếp theo của đoạn văn bản này mà thôi.

Sau đó tokenizer cắt chuỗi thành token ID. Một cuộc chat vài lượt cộng system prompt là dễ dàng lên tới vài nghìn token.

Mỗi model một template riêng. Chạy model local mà thấy nó nói lan man, tự đóng vai người dùng hoặc lặp mãi không dừng thì nghi ngay tới chat template trước tiên.


# xếp hàng chờ GPU

Một con GPU đâu có phục vụ riêng mình bạn, nó đang gánh cùng lúc hàng chục, hàng trăm request khác. Ai được vào lúc nào là do scheduler sắp xếp.

continuous batching

GPU chạy hiệu quả nhất khi xử lý nhiều request một lúc (gọi là batch). Ngày trước người ta gom đủ một nhóm rồi mới chạy, đứa nào xong trước cũng phải ngồi chờ cả nhóm, giống xe khách đợi đủ người mới lăn bánh. Các inference engine bây giờ như vLLM, SGLang, TensorRT-LLM dùng continuous batching, giống xe buýt hơn: sau mỗi bước sinh token, request nào xong thì xuống, request mới thì lên luôn. GPU lúc nào cũng có việc, chẳng ai phải đợi ai.

prefix cache

Rất nhiều request có phần đầu giống hệt nhau: cùng system prompt, cùng bộ tool, cùng lịch sử chat của lượt trước. Kết quả đã tính cho phần đầu đó (cụ thể là KV cache, nói ngay bên dưới) có thể giữ lại để dùng tiếp.

Tính năng prompt caching mà các nhà cung cấp API hay quảng cáo dựa trên đúng cơ chế này. Phần input trúng cache được tính giá rẻ hơn nhiều và chạy nhanh hơn hẳn.

Nếu bạn làm app thì nên để phần cố định ở đầu prompt (system prompt, tool, tài liệu dùng chung), phần thay đổi như câu hỏi người dùng thì để cuối. Nhét timestamp hay ID ngẫu nhiên vào đầu system prompt là tự tay đạp đổ cache của mình.


# prefill: đọc cả prompt một lượt

Tới GPU rồi. Từ đây việc sinh chữ chia làm hai pha khác hẳn nhau, và hiệu năng của LLM xoay quanh chính hai pha này.

Pha đầu là prefill. Model xử lý toàn bộ prompt cùng lúc. Vì mọi token của prompt đã có sẵn, GPU tính attention cho tất cả song song, tha hồ dùng hết sức nhân ma trận. Trong lúc đó, ở từng tầng, model cất lại vector Key và Value của mọi token. Cái kho đó gọi là KV cache.

Prefill xong thì có token đầu tiên của câu trả lời. Khoảng thời gian từ lúc gửi request tới lúc nhận token đầu gọi là TTFT (Time To First Token). Prompt càng dài thì prefill càng lâu, TTFT càng cao, nên dán nguyên file 100 trang vào thì phải ngồi đợi một lúc mới thấy chữ đầu tiên là vậy.

Pha này bị chặn bởi sức tính toán (compute-bound): toàn phép nhân ma trận to, GPU chạy gần kịch công suất.


# decode: nhả từng token một

Pha thứ hai là decode. Có token đầu rồi, model sinh token thứ hai, thứ ba..., mỗi lần đúng một token, vì token sau phụ thuộc vào token trước.

KV cache

Thử tưởng tượng không có cache. Muốn sinh token thứ 500, model phải tính lại attention cho cả 499 token trước đó. Sang token 501 lại tính lại từ đầu lần nữa. Phí kinh khủng.

Có KV cache thì Key và Value của các token cũ nằm sẵn đó. Mỗi bước decode chỉ phải tính cho đúng một token mới, cho nó attention vào cache, rồi nối K, V của nó vào cache cho bước sau.

Cái giá phải trả là KV cache ngốn bộ nhớ dữ dội. Công thức ước lượng:

KV cache mỗi token = 2 (K và V) × số tầng × số KV head × head_dim × số byte mỗi giá trị

Lấy một model cỡ 8B điển hình: 32 tầng, 8 KV head (nhờ GQA), head_dim 128, lưu FP16 (2 byte):

2 × 32 × 8 × 128 × 2 = 131.072 byte ≈ 128 KB cho mỗi token
 
Context 8K token    ≈ 1 GB
Context 128K token  ≈ 16 GB    (lớn hơn cả trọng số model khi đã quantize)

Nhân thêm với vài trăm request chạy song song là hiểu vì sao bộ nhớ GPU quý như vàng khi phục vụ LLM. PagedAttention ra đời cũng vì vậy: chia KV cache thành từng trang giống bộ nhớ ảo của hệ điều hành để khỏi phí chỗ trống. Mình viết kỹ ở bài vLLM và PagedAttention.

nút thắt là băng thông bộ nhớ

Decode khác prefill ở chỗ mỗi bước chỉ tính cho một token, nhưng lại phải đọc toàn bộ trọng số model từ bộ nhớ GPU ra. Tính thì ít mà đọc thì nhiều, nên decode bị chặn bởi băng thông bộ nhớ (memory-bandwidth-bound).

Có một cách ước lượng nhanh trần tốc độ decode cho một request với model dense:

token/giây tối đa ≈ băng thông bộ nhớ / dung lượng trọng số
 
Model 8B quantize 4-bit ≈ 5 GB, GPU băng thông ~1000 GB/s
→ tối đa khoảng 200 token/giây

Thực tế thấp hơn vì còn overhead và KV cache, nhưng phép tính này giải thích được khối chuyện. Model MoE chạy nhanh là vì mỗi token chỉ phải đọc phần tham số active. Mac với unified memory băng thông cao chạy model local khá ổn dù GPU thua card rời về sức tính. Còn gom nhiều request vào một batch thì đọc trọng số một lần mà dùng được cho cả đám, nên hiệu quả hơn hẳn.

speculative decoding

Có một mẹo để decode nhanh hơn khá phổ biến. Cho một model nhỏ (hoặc một phần dự đoán gắn thêm vào model) đoán trước vài token, rồi model lớn kiểm tra cả loạt trong một lần chạy. Đoán trúng bao nhiêu nhận bấy nhiêu, trật ở đâu thì bỏ từ chỗ đó. Kết quả cuối cùng giống hệt model lớn tự sinh, chỉ là nhanh hơn.


# chọn token: chỗ "sáng tạo" nằm ở đây

Sau mỗi bước, model trả về logit cho từng token trong từ điển. Từ đống điểm số đó ra được một token cụ thể là việc của sampler, và các tham số bạn truyền trong request phát huy tác dụng ở chính khâu này.

temperature chia logit cho một con số trước khi qua softmax. Để thấp (0–0.3) thì phân bố nhọn, model gần như chắc chắn chọn token cao điểm nhất, hợp với code hay trích xuất dữ liệu. Để cao (0.8–1.2) thì phân bố bẹt ra, câu chữ đa dạng hơn, hợp với viết lách. top-k chỉ giữ k token điểm cao nhất, còn top-p giữ nhóm token nhỏ nhất có tổng xác suất đạt p (chẳng hạn 0.9) và cắt bỏ phần đuôi. Các loại penalty trừ điểm token đã xuất hiện để model bớt lặp. Structured output hay JSON mode thì chặn luôn những token làm hỏng cấu trúc ngay tại bước này, nên output chắc chắn parse được.

logits ──▶ chia temperature ──▶ softmax ──▶ lọc top-k / top-p ──▶ bốc thăm ──▶ token

Reasoning model còn có thêm một đoạn "suy nghĩ" ẩn: hàng trăm tới hàng nghìn token suy luận trước khi viết câu trả lời thật. Mấy token này vẫn đi qua đúng vòng decode ở trên, vẫn tốn thời gian và thường vẫn bị tính tiền như token output.


# dừng lại và trả về

Vòng decode dừng khi model sinh ra token kết thúc (end-of-sequence, hay <|im_end|> như template ở trên), khi chạm max_tokens bạn đặt, khi gặp một stop sequence bạn khai báo, hoặc khi model quyết định gọi tool, tức là thay vì trả lời thì nó sinh ra một lời gọi hàm có cấu trúc.

Trong lúc decode, cứ mỗi token (hoặc vài token gộp lại) được đổi ngược thành chữ rồi đẩy về client qua SSE (Server-Sent Events) trên chính kết nối HTTP đó. Thế nên chữ mới hiện ra dần dần chứ không phải đợi xong cả đoạn.

nếu model gọi tool

Với agent thì vòng lặp dài thêm một chút:

Mỗi vòng tool là một request mới, kèm toàn bộ hội thoại tính tới lúc đó. Một agent làm việc vài phút có thể đốt cả trăm nghìn token cũng vì thế, và prefix cache với agent quan trọng cực kỳ.


# thử tận tay với model local

Chạy model trên máy bằng Ollama thì bạn nhìn được số liệu của từng pha:

ollama pull qwen3.5:4b
 
curl -s http://localhost:11434/api/chat -d '{
  "model": "qwen3.5:4b",
  "stream": false,
  "messages": [{ "role": "user", "content": "Giải thích KV cache trong 3 câu" }]
}' | jq '{prompt_eval_count, prompt_eval_duration, eval_count, eval_duration, load_duration, total_duration}'

Kết quả có dạng như dưới, thời gian tính bằng nano giây:

{
  "prompt_eval_count": 19,
  "prompt_eval_duration": 45000000,
  "eval_count": 120,
  "eval_duration": 2400000000,
  "load_duration": 900000000,
  "total_duration": 3400000000
}

load_duration là lúc nạp model vào bộ nhớ, chỉ tốn ở lần gọi đầu. prompt_eval_count là số token prompt sau khi đã ghép template, còn prompt_eval_duration chính là thời gian prefill. eval_count là số token sinh ra và eval_duration là thời gian decode. Tốc độ decode tính bằng eval_count / eval_duration × 10⁹ token/giây.

Giờ thử gửi một prompt dài vài nghìn token xem. prompt_eval_duration sẽ tăng vọt, trong khi tốc độ decode gần như đứng yên: hai pha, hai giới hạn khác nhau, khớp với lý thuyết bên trên. Lười gõ curl thì ollama run qwen3.5:4b --verbose cũng in các chỉ số này ra sau mỗi câu trả lời.


# vậy tối ưu ở đâu

Từ cái luồng ở trên, mấy lời khuyên quen thuộc tự nhiên có lý do của nó:

quan sátnên làm
Output đắt hơn input (thường gấp vài lần)Decode tuần tự, chiếm GPU lâu. Yêu cầu trả lời ngắn gọn, đặt max_tokens hợp lý
Prompt dài làm TTFT caoBớt nhồi tài liệu thừa, chỉ đưa đoạn liên quan (RAG chọn lọc)
Prefix trùng nhau được cachePhần cố định ở đầu, phần thay đổi ở cuối. Đừng chèn giờ hay ID vào system prompt
Chat dài thì mỗi lượt càng tốnTóm tắt lịch sử cũ, mở cuộc chat mới khi đổi chủ đề
Reasoning model nghĩ lâu và tốn tokenChỉ bật mức suy luận cao cho việc thật sự khó
Cần output ổn định để parsetemperature thấp và structured output thay vì dặn "trả JSON nhé"

# gom lại

Một lượt nhấn Enter đi qua chừng này chặng. App ghép system prompt, lịch sử, tool và câu hỏi của bạn thành một request. Gateway xác thực và đếm quota. Request được ghép theo chat template, cắt thành token, rồi scheduler nhét nó vào batch đang chạy, tiện thể tìm xem có prefix nào cache sẵn không. GPU prefill cả prompt một lượt để dựng KV cache, thời gian này tăng theo độ dài prompt. Sau đó decode nhả từng token, mỗi bước đọc lại toàn bộ trọng số, thời gian tăng theo độ dài câu trả lời. Sampler biến logit thành token theo temperature và top-p, token được đổi lại thành chữ rồi stream về màn hình của bạn qua SSE.

Muốn tự dựng toàn bộ luồng này trên máy mình thì đọc tiếp bài các model LLM local miễn phí đáng chạy nhất 2026.

Tài liệu tham khảo:


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.