Prompt caching giúp token rẻ hơn 10 lần, nhưng cơ chế thật sự là gì?
Ngay lúc bài viết này được viết, token đầu vào được cache (cached input token) rẻ hơn 10 lần so với token đầu vào thông thường, tính theo đơn giá mỗi token, trên cả API của OpenAI và Anthropic.
Anthropic thậm chí còn khẳng định rằng prompt caching có thể giảm độ trễ “tới 85% với các prompt dài”, và qua thử nghiệm của riêng tác giả bài viết, điều này là đúng khi prompt đủ dài. Tác giả đã gửi hàng trăm request tới cả Anthropic và OpenAI, và nhận thấy thời gian chờ token đầu tiên (time-to-first-token) giảm đáng kể với các prompt mà toàn bộ token đầu vào đã được cache.

Sau khi đã “câu” bạn bằng vài con số ấn tượng, đã bao giờ bạn tự hỏi: cached token thực chất là gì?
Điều gì đang diễn ra trong những trung tâm dữ liệu đầy GPU đó để các nhà cung cấp có thể giảm giá 10 lần cho token đầu vào? Họ đang lưu lại cái gì giữa các request? Chắc chắn không phải là lưu sẵn câu trả lời rồi trả lại y nguyên khi bạn gửi cùng một prompt - điều này rất dễ kiểm chứng qua API: viết một prompt, gửi nó chục lần, bạn sẽ thấy câu trả lời khác nhau mỗi lần, dù phần usage trong response vẫn báo có token được cache.
Không hài lòng với lời giải thích trong tài liệu của các nhà cung cấp - vốn giải thích khá tốt cách dùng prompt caching nhưng lại tránh trả lời câu hỏi thứ gì thực sự được cache - tác giả quyết định tìm hiểu sâu hơn. Anh đã lần theo cách LLM hoạt động cho đến khi hiểu chính xác dữ liệu nào được các nhà cung cấp lưu lại, nó dùng để làm gì, và vì sao nó giúp mọi thứ nhanh hơn, rẻ hơn cho tất cả mọi người.
Sau bài viết này, bạn sẽ…
Phần tiêu đề “Sau bài viết này, bạn sẽ…”- Hiểu sâu hơn về cách LLM hoạt động
- Có thêm trực giác về lý do LLM được thiết kế như vậy
- Hiểu chính xác những con số nào được cache, và vì sao chúng giúp giảm chi phí request
Kiến trúc của LLM
Phần tiêu đề “Kiến trúc của LLM”Về bản chất, LLM là một hàm toán học khổng lồ. Nó nhận vào một dãy số, và cho ra một số ở đầu ra. Bên trong LLM là một đồ thị (graph) khổng lồ gồm hàng tỷ phép toán được sắp xếp cẩn thận, biến đổi các số đầu vào thành một số đầu ra.
Đồ thị phép toán khổng lồ này có thể chia thành 4 phần chính: tokenizer (bộ mã hóa văn bản thành số), embedding (bộ gắn ý nghĩa cho các số đó), transformer (phần xử lý, xáo trộn thông tin giữa các token) và output (phần chọn ra token tiếp theo).

Mỗi phần trong đồ thị đó có thể coi là một hàm nhận đầu vào và trả ra đầu ra. Input được đưa vào LLM theo một vòng lặp, cho tới khi một giá trị đầu ra đặc biệt báo cho nó dừng lại. Viết dưới dạng giả mã (pseudocode), quy trình đó trông như sau:
prompt = "Ý nghĩa của cuộc sống là gì?";
tokens = tokenizer(prompt);while (true) { embeddings = embed(tokens); for ([attention, feedforward] of transformers) { embeddings = attention(embeddings); embeddings = feedforward(embeddings); } output_token = output(embeddings); if (output_token === END_TOKEN) { break; } tokens.push(output_token);}
print(decode(tokens));LLM nhỏ hơn bạn nghĩ
Phần tiêu đề “LLM nhỏ hơn bạn nghĩ”Đoạn giả mã trên tuy đã rất đơn giản hóa, nhưng điều khiến tác giả ngạc nhiên là các LLM hiện đại thực chất được viết bằng rất ít dòng code.
Sebastian Raschka, người chuyên tái hiện lại các mô hình mã nguồn mở bằng PyTorch (cùng rất nhiều tài liệu giáo dục chất lượng khác), đã tái hiện lại Olmo 3 - một trong những mô hình mở hàng đầu hiện nay - chỉ trong vài trăm dòng code.
Phần mà prompt caching diễn ra chính là cơ chế “attention” (chú ý) trong transformer. Bài viết này sẽ đi qua từng bước cách LLM hoạt động, theo đúng trình tự, cho tới khi chạm tới phần đó. Và điều đó có nghĩa là phải bắt đầu từ token.
Tokenizer
Phần tiêu đề “Tokenizer”Trước khi LLM có thể xử lý được gì, nó cần chuyển prompt của bạn thành một dạng biểu diễn mà nó có thể làm việc với. Đây là quy trình 2 bước, được chia sẻ giữa giai đoạn tokenizer và giai đoạn embedding. Lý do vì sao cần làm vậy sẽ rõ hơn khi tới phần embedding, nên tạm thời hãy theo dõi cách tokenizer hoạt động trước.
Tokenizer nhận prompt của bạn, chặt nó ra thành các đoạn nhỏ, và gán cho mỗi đoạn duy nhất một số nguyên gọi là “token”. Ví dụ, đây là cách tokenizer của GPT-5 tách prompt “Check out ngrok.ai”:
| Đoạn văn bản | Token ID |
|---|---|
Check |
4383 |
out |
842 |
ng |
1657 |
rok |
17690 |
.ai |
75584 |
Prompt đã được tách thành mảng ["Check", " out", " ng", "rok", ".ai"], và chuyển thành các token [4383, 842, 1657, 17690, 75584]. Cùng một prompt luôn cho ra cùng một dãy token. Token cũng phân biệt chữ hoa/chữ thường, vì cách viết hoa mang thông tin về từ đó. “Will” viết hoa chữ W nhiều khả năng là tên riêng hơn là “will” viết thường, chẳng hạn.
Tại sao không tách theo khoảng trắng, hoặc theo từng ký tự?
Phần tiêu đề “Tại sao không tách theo khoảng trắng, hoặc theo từng ký tự?”Đây là một câu hỏi lớn hơn bạn nghĩ, và trả lời đầy đủ có thể khiến bài viết này dài gấp đôi. Câu trả lời ngắn (và không hoàn toàn thỏa đáng) là: đây là một sự đánh đổi (trade-off). Nếu muốn tìm hiểu sâu, Andrej Karpathy có một video tuyệt vời nơi anh xây dựng một tokenizer từ đầu. Với mục đích hiểu prompt caching, chỉ cần biết rằng tokenization biến văn bản thành số là đủ.
Token là đơn vị cơ bản cho cả đầu vào và đầu ra của LLM. Khi bạn hỏi ChatGPT một câu, câu trả lời được stream về cho bạn từng token một, sau mỗi lượt lặp của LLM. Các nhà cung cấp làm vậy vì tạo ra toàn bộ câu trả lời có thể tốn tới hàng chục giây, nhưng gửi từng token ngay khi nó sẵn sàng giúp trải nghiệm cảm giác tương tác hơn.
Ví dụ với câu hỏi kinh điển “Có bao nhiêu chữ r trong từ ‘strawberry’?”, prompt này được tách thành 12 token: How, many, r, 's, in, the, word, ', st, raw, berry, '?, cộng thêm token đặc biệt <end> (199999) đánh dấu kết thúc prompt.

Token đầu vào (prompt) được đưa vào, “✨ AI xử lý ✨”, token đầu ra xuất hiện, rồi lặp lại. Quá trình này gọi là “inference” (suy luận). Lưu ý rằng mỗi token đầu ra sẽ được gắn thêm vào cuối prompt đầu vào trước lượt lặp tiếp theo. LLM cần toàn bộ ngữ cảnh để tạo ra câu trả lời tốt. Nếu chỉ đưa prompt vào mà không có phần đã sinh ra, nó sẽ liên tục cố sinh lại token đầu tiên của câu trả lời. Nếu chỉ đưa phần trả lời vào mà bỏ prompt, nó sẽ quên ngay câu hỏi ban đầu. Toàn bộ prompt + phần đã trả lời phải được đưa vào LLM ở mọi lượt lặp.
Token <END> 199999 là gì vậy?
Phần tiêu đề “Token <END> 199999 là gì vậy?”Quá trình inference phải dừng ở một điểm nào đó. LLM có một số token “đặc biệt” mà nó có thể sinh ra, trong đó có một token báo hiệu kết thúc câu trả lời. Với tokenizer của GPT-5, đó là token 199999. Đây chỉ là một trong nhiều cách LLM có thể kết thúc: bạn cũng có thể chỉ định số token tối đa cần sinh qua API, và nhà cung cấp cũng có thể có các quy tắc an toàn khác quyết định khi nào nên dừng.
Cũng có những token đặc biệt để đánh dấu điểm bắt đầu và kết thúc của từng lượt tin nhắn trong hội thoại - đây là cách các mô hình chat như ChatGPT và Claude biết khi nào một tin nhắn kết thúc và tin nhắn khác bắt đầu.
Điều cuối cùng cần biết về tokenizer: có rất nhiều loại tokenizer khác nhau! Tokenizer mà ChatGPT dùng khác với tokenizer của Claude. Thậm chí các mô hình khác nhau của OpenAI cũng dùng tokenizer khác nhau. Mỗi tokenizer có bộ quy tắc riêng để tách văn bản thành token. Nếu muốn xem các tokenizer khác nhau tách văn bản ra sao, hãy thử tiktokenizer.
Sau khi đã làm quen với token, hãy nói tiếp về embedding.
Embedding
Phần tiêu đề “Embedding”Các token từ tokenizer giờ được đưa vào giai đoạn embedding. Để hiểu embedding, cần hiểu mục tiêu thực sự của một mô hình.
Khi con người giải quyết một bài toán bằng code, ta viết các hàm nhận đầu vào và trả ra đầu ra - ví dụ, chuyển đổi độ Fahrenheit sang độ Celsius:
function fahrenheitToCelsius(fahrenheit) { return ((fahrenheit - 32) * 5) / 9;}Ta có thể đưa bất kỳ số nào vào fahrenheitToCelsius và nhận được kết quả đúng. Nhưng nếu ta gặp một bài toán mà ta không biết công thức thì sao? Giả sử ta chỉ có một bảng bí ẩn gồm các cặp đầu vào - đầu ra:
| Input | Output |
|---|---|
| 2 | 17 |
| 3 | 23 |
| 10 | 29 |
| 20 | 61 |
| 27 | 7 |
Tác giả không kỳ vọng bạn nhận ra ngay công thức ở đây (dù ChatGPT có thể đoán ra ngay nếu bạn dán ảnh chụp bảng này vào). Khi ta biết đầu ra mong muốn cho mỗi đầu vào, nhưng không biết hàm tạo ra nó, ta có thể “huấn luyện” (train) một mô hình để học ra hàm đó. Cách làm là đưa cho mô hình một “khung vẽ” - chính là đồ thị phép toán khổng lồ nói ở trên - rồi chỉnh sửa đồ thị đó liên tục cho tới khi mô hình hội tụ về đúng hàm cần tìm. Mỗi lần đồ thị được cập nhật, ta chạy các đầu vào qua nó để xem nó đã gần đúng chưa, và lặp lại quá trình này đến khi kết quả đủ tốt. Đó chính là bản chất của việc huấn luyện.
Khi huấn luyện một mô hình để sinh ra văn bản đúng, hóa ra việc nhận biết hai câu có “giống nhau” hay không lại rất có ích. Nhưng giống nhau theo nghĩa nào? Chúng có thể giống nhau về mức độ buồn, hài hước, hay gợi suy nghĩ. Chúng có thể giống về độ dài, nhịp điệu, giọng văn, ngôn ngữ, từ vựng, cấu trúc. Có rất nhiều chiều (dimension) khác nhau để mô tả độ giống nhau giữa hai câu, và hai câu có thể giống ở chiều này nhưng khác ở chiều khác.
Token không có chiều - chúng chỉ là những số nguyên đơn thuần. Nhưng embedding thì có, và có rất nhiều chiều.
Một embedding là một mảng gồm n số, biểu diễn một vị trí trong không gian n chiều. Nếu n bằng 3, một embedding có thể là [10, 4, 2], tương đương vị trí x=10, y=4, z=2 trong không gian 3 chiều. Khi LLM được huấn luyện, mỗi token được gán một vị trí ngẫu nhiên ban đầu trong không gian này, và quá trình huấn luyện dần dần dịch chuyển tất cả các token đó tới khi tìm ra một cách sắp xếp cho ra kết quả tốt nhất.
Giai đoạn embedding bắt đầu bằng việc tra ra embedding của từng token. Viết dưới dạng giả mã:
// Được tạo ra trong quá trình huấn luyện, không đổi khi inference.const EMBEDDINGS = [...];
function embed(tokens) { return tokens.map(token => { return EMBEDDINGS[token]; });}Vậy là ta lấy tokens - một mảng số nguyên - và chuyển nó thành một mảng embedding: một mảng của các mảng, hay còn gọi là “ma trận” (matrix). Ví dụ, các token [75, 305, 284, 887] sẽ được chuyển thành một ma trận các embedding 3 chiều.
Số chiều của embedding càng lớn, nó càng có nhiều “trục” để so sánh các câu với nhau. Trong ví dụ trên ta dùng embedding 3 chiều cho dễ hình dung, nhưng các mô hình hiện nay dùng embedding với hàng nghìn chiều - những mô hình lớn nhất có tới hơn 10.000 chiều.
Để minh họa giá trị của việc có nhiều chiều hơn, hãy tưởng tượng 8 nhóm hình khối màu khác nhau, ban đầu nằm trên một đường thẳng (không gian 1 chiều) - chúng trông như một mớ lộn xộn, khó phân biệt.

Nhưng khi thêm chiều thứ 2, rồi thứ 3, các nhóm này dần tách ra rõ ràng thành 8 cụm riêng biệt, có liên hệ với nhau.

3 chiều là giới hạn tối đa có thể minh họa trực quan được - bạn sẽ phải tự tưởng tượng những gì có thể làm được với hàng nghìn chiều.
Giai đoạn embedding còn làm thêm một việc cuối cùng: sau khi lấy embedding của token, nó mã hóa thêm vị trí của token đó trong prompt vào embedding. Tác giả không đi sâu vào cơ chế này, chỉ biết rằng nó không ảnh hưởng nhiều tới cách prompt caching hoạt động - nhưng thiếu bước này, LLM sẽ không thể biết được thứ tự của các token trong prompt.
Cập nhật lại giả mã, giả sử có một hàm encodePosition nhận vào embedding và một vị trí, rồi trả về embedding mới đã mã hóa vị trí đó:
const EMBEDDINGS = [...];
// Input: mảng các token (số nguyên)function embed(tokens) { // Output: mảng các embedding n chiều return tokens.map((token, i) => { const embeddings = EMBEDDINGS[token]; return encodePosition(embeddings, i); });}Tóm lại, embedding là các điểm trong không gian n chiều, có thể coi là ý nghĩa ngữ nghĩa (semantic meaning) của phần văn bản mà chúng biểu diễn. Trong quá trình huấn luyện, mỗi token được di chuyển trong không gian này để nằm gần các token tương tự khác. Càng nhiều chiều, mô hình càng có thể biểu diễn mỗi token một cách phức tạp và tinh tế hơn.
Toàn bộ công việc ở giai đoạn tokenizer và embedding đều nhằm chuyển văn bản thành thứ mà LLM có thể xử lý được. Hãy xem tiếp công việc đó diễn ra thế nào ở giai đoạn transformer.
Transformer
Phần tiêu đề “Transformer”Giai đoạn transformer làm nhiệm vụ nhận embedding làm đầu vào và di chuyển chúng trong không gian nhiều chiều đó. Nó làm điều này qua 2 cơ chế, nhưng bài viết chỉ tập trung vào cơ chế thứ nhất: attention (chú ý). Phần “feedforward” hay giai đoạn output sẽ không được đề cập ở đây.
Nhiệm vụ của cơ chế attention là giúp LLM hiểu quan hệ giữa các token trong prompt, bằng cách cho phép các token ảnh hưởng đến vị trí của nhau trong không gian nhiều chiều. Nó làm điều này bằng cách kết hợp các embedding của các token trong prompt theo một tỷ lệ trọng số nào đó. Đầu vào là embedding của toàn bộ prompt, đầu ra là một embedding mới, là tổ hợp có trọng số của tất cả embedding đầu vào.
Ví dụ, với prompt “Mary had a little” (4 token: Mary, had, a, little), cơ chế attention có thể quyết định rằng để sinh token tiếp theo, nó nên dùng:
- 63% embedding của
Mary - 16% embedding của
had - 12% embedding của
a - 9% embedding của
little
Rồi nó kết hợp tất cả bằng cách nhân từng embedding với trọng số tương ứng và cộng lại. Đây là cách LLM biết cần quan tâm - hay “chú ý” (attend) - bao nhiêu tới từng token trong prompt.
Đây là phần trừu tượng và phức tạp nhất trong toàn bộ quy trình. Phần lớn các phép tính trong attention là phép nhân ma trận (matrix multiplication). Điều duy nhất bạn cần biết về phép nhân ma trận cho bài viết này là: hình dạng (số dòng, số cột) của ma trận kết quả được quyết định bởi hình dạng của hai ma trận đầu vào. Ma trận kết quả luôn có số dòng bằng ma trận đầu vào thứ nhất và số cột bằng ma trận đầu vào thứ hai. Ví dụ, một ma trận 2 dòng x 3 cột nhân với một ma trận 3 dòng x 1 cột sẽ cho ra một ma trận 2 dòng x 1 cột.
Với nguyên tắc đó, đây là cách một cơ chế attention (được đơn giản hóa) tính trọng số cho từng token, viết dưới dạng giả mã (dùng * để biểu thị phép nhân ma trận):
// Tương tự EMBEDDINGS ở trên, WQ và WK được học// trong quá trình huấn luyện và không đổi khi inference.//// Đây là các ma trận n*n, với n là số chiều embedding.// Trong ví dụ dưới, n = 3.const WQ = [[...], [...], [...]];const WK = [[...], [...], [...]];
// Embedding đầu vào trông như sau:// [// [-0.1, 0.1, -0.3], // Mary// [1.0, -0.5, -0.6], // had// [0.0, 0.8, 0.6], // a// [0.5, -0.7, 1.0] // little// ]function attentionWeights(embeddings) { const Q = embeddings * WQ; const K = embeddings * WK; const scores = Q * transpose(K); const masked = mask(scores); return softmax(masked);}Khoan đã, WQ và WK là gì?
Phần tiêu đề “Khoan đã, WQ và WK là gì?”Nhớ lại rằng mỗi token được gán một vị trí ngẫu nhiên ban đầu, rồi quá trình huấn luyện dần dần điều chỉnh vị trí đó cho tới khi mô hình hội tụ về một cách sắp xếp tốt? WQ và WK cũng tương tự: chúng là các ma trận n-x-n (với n là số chiều embedding), được gán giá trị ngẫu nhiên khi bắt đầu huấn luyện, rồi cũng được điều chỉnh dần trong quá trình huấn luyện để giúp mô hình hội tụ về một giải pháp tốt.
Bất cứ thứ gì được điều chỉnh trong quá trình huấn luyện được gọi là một “tham số mô hình” (model parameter). Mỗi số thực (floating point) trong các vector embedding và trong các ma trận WQ, WK này là một tham số. Khi bạn nghe nói một mô hình có “175 tỷ tham số,” đó chính là những con số này.
Còn về việc WQ và WK thực chất là gì - thành thật thì không ai biết chính xác. Khi mô hình hội tụ, chúng biểu diễn một dạng biến đổi nào đó của embedding giúp mô hình sinh ra kết quả tốt. Chúng có thể đang làm bất cứ điều gì, và việc lý giải (interpret) những gì thực sự diễn ra bên trong vẫn là một hướng nghiên cứu mở và đang rất tích cực.
Để tính Q và K, ta lấy embeddings nhân với WQ và WK tương ứng. WQ và WK luôn có số dòng và số cột bằng số chiều embedding (3 trong ví dụ này). Ma trận embeddings (4 dòng, ứng với 4 token) nhân với WQ (3x3) cho ra ma trận Q có 4 dòng, 3 cột: 4 dòng vì embeddings có 4 dòng (mỗi token một dòng), và 3 cột vì WQ có 3 cột (mỗi chiều embedding một cột). Phép tính cho K hoàn toàn tương tự, chỉ thay WQ bằng WK.
Q và K đều là các “phép chiếu” (projection) của embedding đầu vào vào một không gian n chiều mới - chúng không phải là embedding gốc, nhưng được suy ra từ chúng.
Sau đó, ta lấy Q nhân với K đã “chuyển vị” (transpose - lật ma trận theo đường chéo), để kết quả là một ma trận vuông có số dòng và số cột bằng số token trong prompt đầu vào. Đây chính là ma trận scores, thể hiện mức độ quan trọng của mỗi token đối với token tiếp theo được sinh ra. Ví dụ, số ở dòng 1 cột 1 của ma trận này thể hiện mức độ quan trọng của “Mary” đối với “had”; số ở dòng 2 cột 1 thể hiện mức độ quan trọng của “Mary” đối với “a”, và cứ thế. Mọi bước tiếp theo đều nhằm biến các scores này thành các trọng số dùng để trộn embedding lại với nhau.
Vấn đề đầu tiên với ma trận scores là nó cho phép các token ở tương lai ảnh hưởng tới quá khứ. Ở dòng đầu tiên, ta chỉ biết đến “Mary”, vậy nó phải là từ duy nhất ảnh hưởng tới việc sinh ra “had”. Tương tự, ở dòng thứ hai, ta biết “Mary” và “had”, nên chỉ 2 từ đó được phép ảnh hưởng tới việc sinh ra “a”, và tiếp tục như vậy.
Để khắc phục, ta áp một “mặt nạ” (mask) hình tam giác lên ma trận, để “xóa” các token ở tương lai. Nhưng thay vì đặt chúng bằng 0, ta đặt chúng bằng âm vô cực (-∞) - lý do sẽ được giải thích ngay sau đây.
Vấn đề thứ hai là các con số trong scores chỉ là những số bất kỳ. Sẽ hữu ích hơn nhiều nếu chúng là một phân phối (distribution) có tổng bằng 1 trên mỗi dòng. Đây chính xác là điều hàm softmax làm. Chi tiết cách softmax hoạt động không quan trọng - nó phức tạp hơn một chút so với việc chia mỗi số cho tổng của dòng đó, nhưng kết quả tương tự: mỗi dòng có tổng bằng 1, và mỗi số nằm trong khoảng 0 đến 1.
Lý do dùng âm vô cực thay vì 0: hàm softmax không chia trực tiếp, mà lấy e^x (hàm mũ) của mỗi số trước, rồi mới chia cho tổng:
function softmax(matrix) { return matrix.map(row => { const exps = row.map(x => Math.exp(x)); const sumExps = exps.reduce((a, b) => a + b, 0); return exps.map(exp => exp / sumExps); });}Nếu dùng số 0 thay cho âm vô cực, vì Math.exp(0) === 1, các ô đã bị “xóa” vẫn sẽ góp phần vào trọng số cuối cùng - điều ta không muốn. Trong khi đó Math.exp(-Infinity) cho ra 0, đúng như mong đợi.
Với dữ liệu thực tế lấy từ phiên bản GPT-2 chạy trên trang Transformer Explainer, trọng số attention cho prompt “Mary had a little” trông như sau: ở dòng đầu, “Mary” chiếm 100% (vì chỉ có 1 từ). Ở dòng 2, “Mary” góp 79%, “had” góp 21% cho việc sinh ra “a”.

Không khó hiểu khi từ mà mô hình cho là quan trọng nhất trong câu này là “Mary” - nó luôn có trọng số cao nhất ở mọi dòng. Nếu được yêu cầu hoàn thành câu “Jessica had a little”, chắc bạn cũng không chọn “lamb” (con cừu).
Bước cuối cùng là trộn các embedding theo trọng số đã tính - phần này đơn giản hơn nhiều so với việc tính ra trọng số. Tương tự WQ, WK, ta có một ma trận WV được xác định khi huấn luyện, dùng để tính ra ma trận V từ embedding của các token:
// Được học trong quá trình huấn luyện, không đổi khi// inference. Đây cũng là một ma trận n*n, với n là số// chiều embedding.const WV = [[...], [...], ...];
function attention(embeddings) { const V = embeddings * WV; // Đây là hàm attentionWeights ở phần trên, // được gọi lại trong hàm attention này. const weights = attentionWeights(embeddings); return weights * V;}Vì sao không trộn trực tiếp embedding?
Phần tiêu đề “Vì sao không trộn trực tiếp embedding?”Khi tính Q và K rồi nhân chúng lại để ra trọng số attention, ta đang tính toán hoàn toàn dựa trên mức độ liên quan (relevance) giữa các token. Embedding mã hóa mọi loại ý nghĩa ngữ nghĩa của token - một chiều có thể biểu diễn “màu sắc,” một chiều khác “kích thước,” chiều khác nữa “sự thô lỗ,” v.v. Các trọng số dùng độ tương tự để xác định mức độ liên quan.
WV cho phép mô hình quyết định chiều nào nên được giữ lại khi trộn embedding. Trong câu “Mary had a little”, điều quan trọng về “Mary” chính là đó là một cái tên. Mô hình cũng có thể đã học được nhiều thứ về cocktail Bloody Mary, hay về Mary Nữ hoàng Scotland - những điều này không liên quan tới bài đồng dao này, và việc giữ lại chúng chỉ gây thêm nhiễu. Vậy nên WV cho phép mô hình lọc bỏ các đặc trưng không liên quan trước khi trộn embedding lại với nhau.
Sau khi có V, ta nhân nó với các weights đã tính ở trên để ra embedding đầu ra mới. Đầu ra cuối cùng của cơ chế attention chính là dòng cuối cùng của ma trận kết quả này. Toàn bộ thông tin ngữ cảnh từ các token trước đó đã được trộn vào dòng cuối này, dù tất cả các dòng trước cũng phải được tính để làm được điều đó.
Nói ngắn gọn, embedding đi vào và một embedding mới đi ra. Cơ chế attention đã thực hiện rất nhiều phép toán phức tạp để trộn các token với nhau, theo tỷ lệ tương ứng với mức độ quan trọng của chúng, dựa trên các ma trận WQ, WK, WV đã học được trong quá trình huấn luyện. Đây chính là cơ chế cho phép LLM biết điều gì quan trọng trong context window của nó, và vì sao.
Giờ ta đã có đủ mọi thứ cần biết để nói về caching.
Còn nhiều điều về attention hơn thế
Phần tiêu đề “Còn nhiều điều về attention hơn thế”Những gì trình bày ở đây là một phiên bản attention đã được đơn giản hóa (dù đã “đơn giản” như vậy!), chỉ nhằm làm rõ những gì liên quan tới prompt caching. Trong thực tế cơ chế này còn phức tạp hơn nhiều - nếu muốn tìm hiểu sâu hơn, hãy xem video của 3blue1brown về attention.
Prompt caching
Phần tiêu đề “Prompt caching”Hãy xem lại lưới trọng số attention ở trên, nhưng lần này theo dõi nó được lấp đầy dần khi mỗi token mới được sinh ra trong vòng lặp inference, với prompt được mở rộng dần: “Mary”, “had”, “a”, “little”, “lamb”, “whose”, “fleece”…

Mỗi token mới được nối thêm vào đầu vào và toàn bộ được xử lý lại từ đầu. Nhưng nếu để ý kỹ, bạn sẽ thấy: các trọng số của những dòng trước không hề thay đổi. Dòng thứ 2 luôn là 0.79 và 0.21. Dòng thứ 3 luôn là 0.81, 0.13, 0.06. Ta đang lặp lại rất nhiều phép tính không cần thiết. Phần lớn các phép nhân ma trận cho “Mary had a little” là không cần thiết nếu ta vừa xử lý xong “Mary had a” - đó lại chính xác là cách vòng lặp inference của LLM hoạt động.
Bạn có thể tránh những phép tính lặp lại này bằng 2 thay đổi trong vòng lặp inference:
- Cache lại ma trận
KvàVsau mỗi lượt lặp. - Chỉ đưa token mới nhất vào mô hình, thay vì đưa lại toàn bộ prompt.
Hãy đi qua lại các phép nhân ma trận, nhưng lần này với K và V của 4 token đầu tiên đã được cache sẵn, và ta chỉ đưa vào embedding của 1 token duy nhất.
Tính Q mới chỉ cho ra 1 dòng duy nhất (WQ không đổi). Tính K mới cũng chỉ cho ra 1 dòng (WK không đổi). Sau đó, ta nối dòng K mới này vào 4 dòng K đã cache từ lượt trước, được ma trận K đầy đủ cho toàn bộ token trong prompt - nhưng ta chỉ vừa phải tính dòng cuối cùng của nó.
Ta tiếp tục theo cách này để tính scores mới (chỉ 1 dòng), rồi weights mới qua softmax (cũng chỉ 1 dòng). Suốt quá trình, ta chỉ tính những gì cần thiết - không tính lại bất kỳ giá trị cũ nào. Tương tự, ta tính dòng V mới và nối vào V đã cache. Cuối cùng, nhân weights mới với V (đầy đủ) để ra embedding mới - chỉ 1 dòng duy nhất, nhưng đã chứa đựng toàn bộ thông tin ngữ cảnh từ các token trước đó, nhờ vào K và V đã được cache.
Dữ liệu được cache chính là kết quả của embeddings * WK và embeddings * WV - nói cách khác, là K và V. Vì vậy, prompt caching thường được gọi là “KV caching” (viết tắt của Key-Value caching).
Đó chính là nó: các ma trận K và V này chính là những con số 1 và 0 mà các nhà cung cấp lưu lại trong các trung tâm dữ liệu khổng lồ của họ, để mang lại cho ta mức giá rẻ hơn 10 lần và phản hồi nhanh hơn rất nhiều.
Cache
Phần tiêu đề “Cache”Các nhà cung cấp giữ lại những ma trận này cho mỗi prompt trong khoảng 5-10 phút sau khi request được gửi. Nếu bạn gửi một request mới bắt đầu bằng đúng prompt cũ, họ sẽ dùng lại K và V đã cache thay vì tính lại từ đầu. Điều thú vị là bạn có thể khớp một phần với một cache entry và vẫn dùng được phần khớp đó, không cần khớp toàn bộ.
Ví dụ, nếu bạn gửi liên tiếp vài prompt có cùng phần tiền tố (prefix) nhưng khác phần cuối, phần tiền tố chung sẽ được tái sử dụng từ cache, chỉ phần khác biệt mới cần tính lại.

Cache cũng không tồn tại vĩnh viễn - sau một khoảng thời gian không được dùng tới, nó sẽ bị xóa và phải xây dựng lại từ đầu ở request tiếp theo.
OpenAI và Anthropic triển khai caching theo hai cách rất khác nhau. OpenAI làm điều này hoàn toàn tự động, cố gắng định tuyến (route) request tới các cache entry sẵn có khi có thể. Trong các thử nghiệm của tác giả, gửi một request rồi gửi lại ngay sau đó cho tỷ lệ trúng cache (hit rate) khoảng 50%. Với các context window dài, thời gian chờ byte đầu tiên (time-to-first-byte) có thể chênh lệch khá lớn, dẫn tới hiệu năng không ổn định.
Anthropic cho bạn nhiều quyền kiểm soát hơn, để tự quyết định khi nào cache và cache trong bao lâu. Bạn phải trả thêm cho đặc quyền này, nhưng qua thử nghiệm, Anthropic định tuyến bạn tới cache entry 100% số lần khi bạn yêu cầu cache một prompt. Điều này có thể khiến Anthropic là lựa chọn phù hợp hơn cho các ứng dụng làm việc với context window dài và cần độ trễ ổn định, dự đoán được.
Còn temperature thì sao?
Phần tiêu đề “Còn temperature thì sao?”LLM cung cấp một số tham số để kiểm soát mức độ ngẫu nhiên của kết quả sinh ra, phổ biến nhất là temperature, top_p, và top_k. Các tham số này chỉ ảnh hưởng tới bước cuối cùng của vòng lặp inference - nơi mô hình chọn ra một token dựa trên xác suất mà nó đã gán cho từng token trong bộ từ vựng. Bước này diễn ra sau khi cơ chế attention đã tạo ra embedding cuối cùng, nên prompt caching hoàn toàn không bị ảnh hưởng bởi các tham số này. Bạn có thể thay đổi chúng tự do mà không lo làm mất hiệu lực cache đã có.
Kết luận
Phần tiêu đề “Kết luận”Đây là một quá trình học đầy thú vị. LLM là một công nghệ hấp dẫn, và với tư cách một ngành, chúng ta mới chỉ chạm tới bề mặt của những gì công nghệ này có thể làm được.
Lời cảm ơn
Phần tiêu đề “Lời cảm ơn”Tác giả đã tham khảo nhiều nguồn để viết bài này, trong đó hữu ích nhất là:
- Build a Large Language Model (From Scratch) của Sebastian Raschka
- Neural Networks: Zero to Hero của Andrej Karpathy
- Neural Networks, chuỗi video của 3blue1brown
- Transformer Explainer của Aeree Cho và các đồng tác giả
Nếu bạn thích bài viết này, chắc chắn bạn cũng sẽ thích những nguồn tài liệu trên.