Bài cuối của seri Python, Từng mẩu một, dựng một chatbot trả lời từng token. Trong bài đó, mô hình là một hộp đen: chữ đi ra từ model.generate(), ta chỉ lo chuyền chúng tới trình duyệt. Seri này mở hộp đen ra, và bắt đầu từ chỗ đơn giản nhất mà cũng hay bị hiểu sai nhất: mô hình không đọc chữ. Nó đọc một dãy số nguyên, mỗi số gọi là một token. Câu "Tất nhiên, tôi sẽ giúp bạn" là 8 token với Qwen nhưng 16 token với GPT-4. Cùng một câu, gấp đôi tiền, gấp đôi thời gian.
Lộ trình: mô hình thực sự nhận được gì, chữ tiếng Việt trở thành byte ra sao (và vì sao hai chuỗi trông giống hệt nhau lại khác nhau), ba cách cắt chữ, tự viết một tokenizer BPE khoảng 50 dòng, những gì tokenizer thật làm thêm, đo xem tiếng Việt tốn bao nhiêu token với bốn tokenizer phổ biến, rồi các hệ quả: giá, độ dài ngữ cảnh, con số, đếm chữ cái, và cả chuyện stream ở bài trước.
Mọi con số trong bài đều đo trên máy thật, với Python 3.12, tiktoken 0.14, tokenizers 0.23, transformers 5.20. Tokenizer không cần GPU hay mô hình; mọi thí nghiệm trong bài chạy được trên laptop chỉ với pip install tiktoken tokenizers.
Mô hình thực sự nhận được gì
Khi gửi "Xin chào" cho Qwen2.5 (mô hình của bài chatbot), thứ đi vào mô hình là thế này:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
ids = tokenizer.apply_chat_template([{"role": "user", "content": "Xin chào"}], add_generation_prompt=True)["input_ids"]
print(len(ids), ids)
print([tokenizer.decode([i]) for i in ids])
32 [151644, 8948, 198, 2610, 525, 1207, 16948, 11, 3465, 553, 54364, 14817, 13, 1446, 525, 264, 10950, 17847, 13, 151645, 198, 151644, 872, 198, 55, 258, 139548, 151645, 198, 151644, 77091, 198]
['<|im_start|>', 'system', '\n', 'You', ' are', ' Q', 'wen', ',', ' created', ' by', ' Alibaba', ' Cloud', '.', ' You', ' are', ' a', ' helpful', ' assistant', '.', '<|im_end|>', '\n', '<|im_start|>', 'user', '\n', 'X', 'in', ' chào', '<|im_end|>', '\n', '<|im_start|>', 'assistant', '\n']
Ba điều đáng chú ý:
- Hai chữ "Xin chào" chỉ là 3 trong 32 token. Phần còn lại là "khung" hội thoại mà thư viện tự thêm: một lời dặn hệ thống mặc định, và các token đặc biệt
<|im_start|>,<|im_end|>đánh dấu ai đang nói. Mô hình không có khái niệm "tin nhắn"; nó chỉ thấy một dãy số liền mạch và đã học rằng sau<|im_start|>assistant\nthì đến lượt nó viết. - Token không trùng với từ. "Xin" bị cắt làm
X+in, còn " chào" (có dấu cách ở đầu) là một token nguyên. - Dấu cách thuộc về token đứng sau nó.
' chào',' are',' Cloud': hầu hết tokenizer hiện đại gắn dấu cách vào đầu từ kế tiếp. Đó là lý do câu trả lời ở bài chatbot về thành'Tất ','nhiên, '… (phần dấu cách đứng cuối là do streamer gom chữ, xem ở mục stream bên dưới).
Mỗi số là chỉ mục vào một bảng gọi là vocabulary (bộ từ vựng). Bảng của Qwen2.5 có 151.665 mục, của GPT-4o là 200.019. Bên trong mô hình, mỗi chỉ mục được tra ra một vector, và mọi phép tính sau đó làm trên các vector ấy; bài sau sẽ đi vào phần đó. Bài này lo câu hỏi trước: dãy số ấy được tạo ra thế nào?
Chữ là byte: tiếng Việt trong UTF-8
Trước khi có token, chữ phải thành byte. Gần như mọi tokenizer hiện đại làm việc trên byte UTF-8, nên hiểu UTF-8 của tiếng Việt là hiểu một nửa câu chuyện.
| Chữ | Số byte UTF-8 | Byte |
|---|---|---|
| a, e, o, u | 1 | 61, … |
| ă, â, ê, ô, ơ, ư, đ, á, à, ã, é… | 2 | ê = c3 aa, đ = c4 91, à = c3 a0 |
| ạ, ả, ẽ, ệ, ế, ữ, ỹ… | 3 | ẽ = e1 ba bd, ệ = e1 bb 87 |
Chữ có sẵn từ thời các bảng mã châu Âu cũ (á, à, ã, ê…) chỉ tốn 2 byte. Còn các dấu mà gần như chỉ tiếng Việt dùng (dấu nặng, dấu hỏi, và mọi chữ có hai dấu như ệ, ế, ữ) nằm trong khối Unicode "Latin Extended Additional", từ U+1EA0 tới U+1EF9, và mọi chữ trong đoạn đó đều bắt đầu bằng e1 ba hoặc e1 bb. Hãy nhớ hai cặp byte này; một lát nữa chúng sẽ là thứ đầu tiên mà tokenizer tự học được.
Cả bộ bài viết tiếng Việt trên trang này dài 47.974 ký tự nhưng 60.011 byte; bản tiếng Anh 51.808 ký tự và 51.913 byte. Tiếng Anh gần như mỗi ký tự một byte, tiếng Việt trung bình 1,25 byte.
Hai chuỗi trông giống hệt nhau
Unicode cho phép viết ệ theo hai cách:
- Dựng sẵn (NFC): một ký tự
U+1EC7, 3 byte. - Tổ hợp (NFD): chữ
e+ dấu nặngU+0323+ dấu mũU+0302, ba ký tự, 5 byte.
import unicodedata
s = "Tiếng Việt"
nfd = unicodedata.normalize("NFD", s)
print(len(s), len(s.encode()), "|", len(nfd), len(nfd.encode()), "| equal:", s == nfd)
10 14 | 14 18 | equal: False
Trên màn hình hai chuỗi giống hệt nhau, nhưng với Python (và với tokenizer) chúng là hai chuỗi khác nhau. Văn bản dạng tổ hợp không hiếm: Unikey có chế độ bảng mã "Unicode tổ hợp", một số file PDF và tên file cũng ở dạng này. Mục đo đạc bên dưới sẽ cho thấy cái giá của nó: với phần lớn tokenizer, cùng một bài viết ở dạng NFD tốn gấp đôi token.
Ba cách cắt chữ
Có ba cách hiển nhiên để biến chữ thành dãy số, và cả ba đều có vấn đề:
| Cách cắt | Bộ từ vựng | Độ dài dãy | Vấn đề |
|---|---|---|---|
| Mỗi byte một token | 256 | Dài nhất: "Tiếng Việt" là 14 | Mô hình phải tốn sức ghép lại từng chữ; dãy dài thì chậm và tốn bộ nhớ |
| Mỗi ký tự một token | ~150.000 ký tự Unicode | Vẫn dài: 10 | Hầu hết ký tự hiếm khi xuất hiện |
| Mỗi từ một token | Vô hạn | Ngắn: 2 | Gặp từ lạ, tên riêng, lỗi chính tả là bó tay |
Dãy càng dài, mô hình càng chậm: mỗi token phải đi qua toàn bộ mô hình, và bài trước đã thấy câu trả lời 180 token mất hơn 10 giây trên CPU. Bộ từ vựng càng lớn, bảng tra vector càng nặng và mỗi mục càng ít được học. Lời giải mà gần như mọi mô hình ngày nay dùng nằm ở giữa: subword, mảnh nhỏ hơn từ nhưng lớn hơn byte. Từ phổ biến thành một token, từ hiếm ghép từ vài mảnh, và trường hợp xấu nhất vẫn luôn có byte để lùi về, nên không bao giờ có chữ nào "không viết được".
Thuật toán tìm ra các mảnh đó phổ biến nhất là BPE (Byte Pair Encoding).
Tự viết một tokenizer BPE
Ý tưởng của BPE gói trong một câu: bắt đầu từ byte, rồi lặp lại việc ghép cặp token đứng cạnh nhau xuất hiện nhiều nhất thành một token mới. Sau vài nghìn lần ghép, các chữ, vần và từ hay gặp tự hình thành.
import re
from collections import Counter
SPLIT = re.compile(r" ?\w+| ?[^\w\s]+|\s+") # a word with its leading space, punctuation, whitespace
def train(text: str, num_merges: int):
"""Learn merges: repeatedly glue the most frequent pair of neighbouring tokens into a new token."""
words = Counter(tuple(w.encode()) for w in SPLIT.findall(text)) # each word as a tuple of byte ids
vocab = {i: bytes([i]) for i in range(256)} # start: one token per byte
merges = {}
for new_id in range(256, 256 + num_merges):
pairs = Counter()
for word, freq in words.items():
for pair in zip(word, word[1:]):
pairs[pair] += freq
if not pairs:
break
best = max(pairs, key=pairs.get)
merges[best] = new_id
vocab[new_id] = vocab[best[0]] + vocab[best[1]]
words = Counter({merge_word(w, best, new_id): f for w, f in words.items()})
return merges, vocab
def merge_word(word, pair, new_id):
out, i = [], 0
while i < len(word):
if i + 1 < len(word) and (word[i], word[i + 1]) == pair:
out.append(new_id)
i += 2
else:
out.append(word[i])
i += 1
return tuple(out)
def encode(text: str, merges) -> list[int]:
ids = []
for w in SPLIT.findall(text):
word = tuple(w.encode())
while len(word) > 1:
# apply the earliest-learned merge that is present, like real BPE tokenizers do
pair = min(zip(word, word[1:]), key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break
word = merge_word(word, pair, merges[pair])
ids.extend(word)
return ids
def decode(ids, vocab) -> str:
return b"".join(vocab[i] for i in ids).decode("utf-8", errors="replace")
Vài chi tiết:
- Cắt thành từ trước (
SPLIT). Cặp chỉ được ghép bên trong một từ, nên không bao giờ có token kiểu"n, t"vắt qua dấu phẩy. Đếm theo từ (Countercủa các từ) cũng nhanh hơn nhiều so với đi qua cả văn bản: một từ xuất hiện 500 lần chỉ phải xét một lần, nhân với 500.\wtrongrecủa Python hiểu Unicode, nênệhayđđược tính là chữ. - Khi mã hóa, các lần ghép được áp dụng theo đúng thứ tự đã học. Đó là lý do có
min(..., key=merges.get): lần ghép nào học sớm (phổ biến hơn) thì làm trước. decodechỉ là nối byte lại. Mọi token là một dãy byte, và nối hết các dãy byte lại luôn cho ra đúng văn bản ban đầu.
Huấn luyện trên chính các bài viết này
Dữ liệu huấn luyện là phần chữ (bỏ code) của ba bài tiếng Việt trên trang này, tổng cộng 37.605 byte. Bài thứ tư, Từng mẩu một, để riêng ra làm dữ liệu kiểm tra mà tokenizer chưa từng thấy. 2.000 lần ghép chạy mất 0,9 giây. Hai mươi token đầu tiên học được:
256 '\xe1\xbb' 257 '\xe1\xba' 258 ' t' 259 ' c' 260 'ng'
261 'à' 262 'đ' 263 ' đ' 264 ' l' 265 ' n'
266 ' m' 267 ' th' 268 ' ch' 269 ' v' 270 'ộ'
271 '\n\n' 272 'ư' 273 ' b' 274 ' k' 275 'i\xe1\xbb'
Hai lần ghép đầu tiên chính là e1 bb và e1 ba, hai cặp byte mở đầu các chữ như ạ, ẽ, ệ, ữ. Chúng chưa phải là chữ hoàn chỉnh nào (chỉ là hai phần ba của một chữ), nhưng chúng là cặp byte gặp nhiều nhất trong văn bản tiếng Việt, nên BPE ghép chúng trước tiên. Tiếp theo là các âm đầu t, c, th, ch (với dấu cách đứng trước), vần ng, rồi các chữ à, đ, ộ, ư. Không ai dạy thuật toán về chính tả tiếng Việt; nó chỉ đếm.
Kiểm tra trên bài chưa thấy (22.405 byte):
| Số lần ghép | Bộ từ vựng | Token (bài tiếng Việt) | Byte mỗi token | Token (bản tiếng Anh của cùng bài) |
|---|---|---|---|---|
| 0 | 256 | 22.405 | 1,00 | 20.153 |
| 100 | 356 | 12.575 | 1,78 | 16.057 |
| 500 | 756 | 8.174 | 2,74 | 13.066 |
| 1.000 | 1.256 | 6.884 | 3,25 | 11.668 |
| 2.000 | 2.256 | 6.251 | 3,58 | 11.191 |
Hai điều rút ra:
- Lợi nhanh rồi chậm dần. 100 lần ghép đầu đã cắt gần một nửa số token; 1.000 lần ghép cuối chỉ cắt thêm 9%. Tokenizer thật có hàng trăm nghìn token để vắt nốt phần lợi nhỏ này trên một lượng văn bản khổng lồ.
- Tokenizer giỏi đúng thứ nó được học. Học trên tiếng Việt, nó cần 6.251 token cho bài tiếng Việt nhưng 11.191 token cho bản tiếng Anh của chính bài đó, dù bản tiếng Anh ngắn hơn tính theo byte. Giữ chi tiết này trong đầu: lát nữa ta sẽ thấy đúng hiện tượng đó, theo chiều ngược lại, ở tokenizer của GPT-4.
Với 2.256 token, câu ở đầu bài được cắt thế này:
Tất nhiên, tôi sẽ giúp bạn → 8 token: 'T' 'ất' ' nhiên' ',' ' tôi' ' sẽ' ' giúp' ' bạn'
Giống hệt từng token với cách tokenizer 151.665 mục của Qwen2.5 cắt câu này. Học từ 37 KB chữ, chạy dưới một giây.
Tokenizer thật làm thêm những gì
Tokenizer của GPT-4o hay Qwen vẫn là BPE như trên, nhưng có thêm vài lớp quanh nó. Mở chúng ra:
import tiktoken
from tokenizers import Tokenizer
qwen = Tokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
deepseek = Tokenizer.from_pretrained("deepseek-ai/DeepSeek-V3")
print(qwen.normalizer, "|", deepseek.normalizer)
print(tiktoken.get_encoding("o200k_base")._pat_str)
Chuẩn hóa (normalizer)
NFC() | Sequence(normalizers=[])
Qwen chạy NFC lên mọi văn bản trước khi cắt, nên dạng tổ hợp và dạng dựng sẵn cho ra cùng một dãy token. DeepSeek-V3 (và tiktoken của OpenAI) không chuẩn hóa gì: văn bản đưa vào thế nào thì cắt thế ấy.
Cắt trước bằng regex
Biểu thức SPLIT ở trên là bản đơn giản của bước này. Bản của GPT-4o dài hơn nhiều:
[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?|...|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n/]*|\s*[\r\n]+|\s+(?!\S)|\s+
Đọc từng mảnh: một chữ có thể có một ký tự không phải chữ đứng trước (thường là dấu cách), các đuôi tiếng Anh như 's, 'll được giữ cùng từ, \p{M} (dấu tổ hợp) được tính như một phần của chữ, và số được cắt thành nhóm tối đa 3 chữ số (\p{N}{1,3}). Qwen chọn khác: \p{N}, mỗi chữ số một token. Hai lựa chọn này cho kết quả rất khác nhau với con số, xem ở mục hệ quả.
Byte-level và chữ Ġ kỳ lạ
Nếu xem token của Qwen ở dạng thô (qwen.encode("Xin chào Việt Nam").tokens), ta thấy:
['X', 'in', 'ĠchÃło', 'ĠViá»ĩt', 'ĠNam']
Đây không phải lỗi font. Thư viện của Hugging Face lưu token dưới dạng chuỗi, nên nó ánh xạ mỗi byte sang một ký tự in được: byte 0x20 (dấu cách) thành Ġ, các byte UTF-8 của à và ệ thành Ãł và á»ĩ. ĠViá»ĩt chỉ là cách viết khác của 6 byte Việt. Giải mã bằng decode sẽ thấy lại chữ bình thường.
Token đặc biệt
<|im_start|> và <|im_end|> ở đầu bài không được học bằng BPE; chúng được thêm thẳng vào bộ từ vựng với id riêng (151644 và 151645). Mô hình học được ý nghĩa của chúng trong lúc huấn luyện, và <|im_end|> cũng là token báo "hết câu trả lời": khi mô hình sinh ra nó, generate() dừng.
Chúng có một mặt nguy hiểm. Nếu người dùng gõ đúng chuỗi <|im_start|> vào ô chat thì sao?
tokenizer("<|im_start|>", add_special_tokens=False)["input_ids"] # [151644]
tokenizer("<|im_start|>", add_special_tokens=False, split_special_tokens=True)["input_ids"] # [27, 91, 318, 4906, 91, 29]
Mặc định, thư viện transformers biến chuỗi đó thành đúng token đặc biệt thật, tức là người dùng có thể tự mở một lượt "system" hay "assistant" giả ở giữa tin nhắn của mình. Với split_special_tokens=True nó chỉ là 6 token chữ thường <, |, im, _start, |, >. tiktoken chọn cách an toàn hơn: encode("<|endoftext|>") ném ValueError trừ khi bạn nói rõ muốn xử lý thế nào. Nếu tự ghép prompt từ chữ người dùng gõ, hãy chắc chắn chữ đó không thể trở thành token đặc biệt.
Tiếng Việt tốn bao nhiêu token
Giờ là câu hỏi thực tế. Mọi bài trên trang này có bản tiếng Việt và bản tiếng Anh dịch sát nhau, nên đó là một bộ dữ liệu khá công bằng: cùng nội dung, hai ngôn ngữ. Đếm token của phần chữ (bỏ code) cả bốn bài với bốn tokenizer:
| Tokenizer (dùng bởi) | Bộ từ vựng | Tiếng Việt | Tiếng Anh | Việt / Anh | Ký tự mỗi token (Việt) |
|---|---|---|---|---|---|
cl100k_base (GPT-4, GPT-3.5) |
100.277 | 21.040 | 12.429 | 1,69 | 2,28 |
o200k_base (GPT-4o) |
200.019 | 14.109 | 12.337 | 1,14 | 3,40 |
| Qwen2.5 | 151.665 | 14.165 | 12.675 | 1,12 | 3,39 |
| DeepSeek-V3 | 128.815 | 18.547 | 12.485 | 1,49 | 2,59 |
Với tiếng Anh, cả bốn gần như ngang nhau, khoảng 4,1–4,2 ký tự mỗi token. Với tiếng Việt, chênh lệch rất lớn: cùng một nội dung, tokenizer của GPT-4 cần nhiều hơn 69% token so với tiếng Anh, còn của GPT-4o chỉ hơn 14%. Từ GPT-4 sang GPT-4o, cùng bài tiếng Việt giảm từ 21.040 xuống 14.109 token, ít hơn một phần ba.
Lý do là bài học ở mục BPE: tokenizer giỏi đúng thứ nó được học. OpenAI không công bố dữ liệu dùng để học cl100k_base, nhưng cách nó cắt chữ cho thấy trong đó có rất ít tiếng Việt. Xem nó cắt vài câu:
| Câu | cl100k_base |
o200k_base |
Qwen2.5 |
|---|---|---|---|
| Tất nhiên, tôi sẽ giúp bạn | 16 | 8 | 8 |
| Trường Đại học Bách khoa Hà Nội | 15 | 9 | 10 |
| Tiếng Việt | 6 | 3 | 4 |
| Nguyễn | 5 | 2 | 3 |
| Hello world | 2 | 2 | 2 |
Với cl100k_base, câu "Tất nhiên, tôi sẽ giúp bạn" thành T ất n hi ên , t ô i s \xe1\xba \xbd gi ú p bạn: chữ "tôi" bị cắt làm ba, và chữ ẽ trong "sẽ" bị cắt đôi giữa các byte của nó. Tokenizer không có token nào cho ẽ, nên nó lùi về byte: e1 ba (một trong hai cặp đầu tiên mà BPE của ta học được) cộng bd. Với o200k_base và Qwen, mỗi từ tiếng Việt thường là đúng một token.
Dạng tổ hợp và bỏ dấu
Cùng bài tiếng Việt đó, ở dạng dựng sẵn (NFC, dạng bình thường), dạng tổ hợp (NFD), và bỏ hết dấu:
| Tokenizer | NFC | NFD | Không dấu |
|---|---|---|---|
cl100k_base |
21.040 | 36.823 | 15.873 |
o200k_base |
14.109 | 30.212 | 14.640 |
| Qwen2.5 | 14.165 | 14.165 | 16.000 |
| DeepSeek-V3 | 18.547 | 32.193 | 15.844 |
- NFD tốn gấp đôi với mọi tokenizer không chuẩn hóa. Bộ từ vựng được học trên văn bản dựng sẵn, nên gặp
e+ dấu nặng + dấu mũ rời nhau, nó không có token nào ghép được:o200k_basecắt "Tiếng Việt" ở dạng NFD thành 8 token (Tie, dấu mũ, dấu sắc,ng,Vie, dấu nặng, dấu mũ,t) thay vì 3. Qwen miễn nhiễm nhờ bước NFC. Nếu bạn gửi văn bản tiếng Việt từ nguồn không rõ tới API, một dòngunicodedata.normalize("NFC", text)có thể giảm một nửa hóa đơn. - Bỏ dấu không tiết kiệm được gì với tokenizer mới. Với
cl100k_basenó giảm 25%, nhưng vớio200k_baselại tốn thêm 4%, với Qwen tốn thêm 13%: các tokenizer này đã học tiếng Việt có dấu là dạng phổ biến. Đó là chưa kể "ban" có thể là "bạn", "bán", "bàn" hay "bận", và mô hình phải đoán.
Hệ quả
Giá và độ dài ngữ cảnh
API tính tiền theo token, và giới hạn ngữ cảnh ("128K", "1M") cũng tính bằng token. Cùng một hợp đồng hay tài liệu, bản tiếng Việt qua một mô hình dùng cl100k_base tốn 1,69 lần bản tiếng Anh, tức là đắt hơn 69% và chiếm nhiều hơn 69% cửa sổ ngữ cảnh. Với GPT-4o hay Qwen, khoảng cách đó chỉ còn 12–14%. Khi chọn mô hình cho sản phẩm tiếng Việt, hãy đếm token trên văn bản thật của bạn, đừng tin con số "token" chung chung trong bảng giá.
Ước lượng nhanh cho tiếng Việt với tokenizer hiện đại: khoảng 3,4 ký tự hay 0,7 từ mỗi token (1,39 token mỗi từ). Với tiếng Anh, con số quen thuộc là khoảng 4 ký tự mỗi token.
Tốc độ
Mô hình sinh từng token, nên "50 token/giây" nghĩa là bao nhiêu chữ phụ thuộc vào tokenizer. Với 3,4 ký tự mỗi token, đó là khoảng 170 ký tự tiếng Việt mỗi giây; với 2,28 ký tự mỗi token như cl100k_base, chỉ còn 114. Cùng một mô hình, cùng phần cứng, người dùng tiếng Việt thấy chữ chạy chậm hơn một phần ba.
Con số
| Chuỗi | cl100k_base / o200k_base |
Qwen2.5 |
|---|---|---|
12345678 |
3: 123 456 78 |
8: mỗi chữ số một token |
2026-10-17 |
6: 202 6 - 10 - 17 |
10 |
Với cách cắt nhóm 3, số 12345678 được "nhìn thấy" như 123|456|78, cắt từ trái sang, không khớp với hàng nghìn (12|345|678) mà phép cộng cần. Năm 2026 thành 202 + 6. Mô hình vẫn học được số học, nhưng phải học xuyên qua cách cắt kỳ quặc này. Qwen cắt từng chữ số: dãy dài hơn, nhưng chữ số hàng đơn vị luôn là một token riêng, giống cách ta đặt tính.
Đếm chữ cái
Câu hỏi nổi tiếng "chữ strawberry có mấy chữ r?" từng làm nhiều mô hình trả lời sai. Lý do nằm ngay đây: với cả ba tokenizer, strawberry là một token duy nhất. Mô hình không bao giờ thấy các chữ cái s-t-r-a-w…; nó thấy một con số, và chỉ biết cách đánh vần nếu đã học được điều đó từ dữ liệu. Đếm chữ cái, đảo ngược chuỗi, tìm từ có vần "ương": những việc dễ với người lại khó với mô hình, vì đơn vị nó nhìn thấy không phải là chữ cái.
Stream: token không trùng ranh giới ký tự
Quay lại chatbot ở bài trước. Mô hình sinh ra từng token, và ta muốn gửi chữ đi ngay. Cách ngây thơ là giải mã từng token một:
import codecs
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
ids = enc.encode("Nguyễn Văn A sẽ đến")
print([enc.decode([i]) for i in ids])
decoder = codecs.getincrementaldecoder("utf-8")()
print([decoder.decode(enc.decode_single_token_bytes(i)) for i in ids])
['Ng', 'uy', '�', '�', 'n', ' V', 'ă', 'n', ' A', ' s', '�', '�', ' đ', 'ến']
['Ng', 'uy', '', 'ễ', 'n', ' V', 'ă', 'n', ' A', ' s', '', 'ẽ', ' đ', 'ến']
Giải mã từng token riêng, chữ ễ và ẽ (mỗi chữ bị cắt đôi qua hai token) thành hai ký tự �. Người dùng sẽ thấy "Nguy��n". Bộ giải mã tăng dần (getincrementaldecoder) giữ lại phần byte dở dang cho tới khi đủ một ký tự: token thứ ba cho ra chuỗi rỗng, token thứ tư cho ra ễ hoàn chỉnh. Đây là cùng ý tưởng "giữ mẩu dở dang, chờ thêm" như bộ đệm trong stream ở bài trước, chỉ ở cấp byte.
TextStreamer của transformers (cái mà chatbot ở bài trước kế thừa) giải quyết theo cách thô hơn. Mã nguồn bản 5.20 làm thế này: mỗi token mới, nó giải mã lại cả đoạn từ đầu dòng, rồi chỉ đẩy ra phần chữ tới dấu cách cuối cùng:
# transformers/generation/streamers.py, TextStreamer.put (abridged)
text = self.tokenizer.decode(self.token_cache)
if text.endswith("\n"):
printable_text = text[self.print_len :] # a full line: flush everything
...
else:
printable_text = text[self.print_len : text.rfind(" ") + 1] # up to the last space only
Đó chính là lý do chatbot ở bài trước gửi về 'Tất ', 'nhiên, ', 'tôi ': mỗi mẩu là một từ trọn vẹn kèm dấu cách, chứ không phải một token. Cách này tránh được ký tự vỡ (một chữ dở dang không bao giờ đứng trước dấu cách), đổi lại chữ ra chậm hơn một nhịp: một từ chỉ được gửi khi từ kế tiếp bắt đầu.
Đo đạc
Tokenizer chạy nhanh cỡ nào? Mã hóa khoảng 1 MB văn bản tiếng Việt (bộ bài viết lặp lại 17 lần), lấy lần nhanh nhất:
| Tokenizer | Thời gian cho 1,02 MB | Tốc độ |
|---|---|---|
| BPE tự viết ở trên (Python thuần, 2.256 token) | 521 ms | 2,0 MB/s |
HF tokenizers, Qwen2.5 (Rust) |
175 ms | 5,8 MB/s |
tiktoken o200k_base (Rust) |
22 ms | 46,5 MB/s |
Bản tự viết chậm hơn tiktoken khoảng 24 lần, nhưng 2 MB/s vẫn là quá đủ để thử nghiệm; tokenizer hiếm khi là nút thắt. Để so sánh, chatbot ở bài trước sinh khoảng 17 token mỗi giây trên CPU; ngay cả bản Python thuần cũng mã hóa nhanh hơn thế hàng chục nghìn lần. HF tokenizers chậm hơn tiktoken một phần vì nó trả về nhiều hơn: ngoài id, mỗi lần mã hóa còn tính vị trí (offset) của từng token trong chuỗi gốc.
Huấn luyện mất bao lâu? 2.000 lần ghép trên 37.605 byte mất 0,9 giây. Thuật toán ở trên đếm lại mọi cặp sau mỗi lần ghép, nên với vài GB văn bản và 200.000 lần ghép như tokenizer thật thì không dùng được; thư viện thật chỉ cập nhật các cặp bị ảnh hưởng bởi lần ghép vừa rồi. Nhưng kết quả cuối cùng là cùng một loại bảng: danh sách các lần ghép theo thứ tự.
Tóm lại
| Khái niệm | Thực chất |
|---|---|
| Token | Một số nguyên, chỉ mục vào bộ từ vựng; mô hình chỉ thấy dãy số này |
| Chat template | Tin nhắn được ghép thành một dãy liền, đánh dấu bằng token đặc biệt như <|im_start|> |
| UTF-8 tiếng Việt | 1–3 byte mỗi chữ; các chữ như ạ, ẽ, ệ đều bắt đầu bằng e1 ba/e1 bb |
| NFC và NFD | Hai cách viết cùng một chữ; NFD tốn gấp đôi token với tokenizer không chuẩn hóa |
| BPE | Bắt đầu từ byte, lặp lại việc ghép cặp phổ biến nhất; kết quả là một danh sách các lần ghép |
| Tokenizer giỏi thứ nó được học | cl100k_base: tiếng Việt tốn 1,69 lần tiếng Anh; o200k_base: 1,14 lần |
| Con số | Nhóm 3 chữ số (OpenAI) hay từng chữ số (Qwen) |
| Đếm chữ cái | strawberry là một token: mô hình không thấy chữ cái |
| Stream | Token có thể cắt giữa một ký tự; giải mã tăng dần, hoặc chờ tới dấu cách như TextStreamer |
Ở cuối đường ống này là một dãy số. Bài sau sẽ theo dãy số đó đi vào bên trong mô hình: mỗi id được tra ra một vector, các vector "nhìn" nhau qua attention, và ở đầu ra là một phân phối xác suất cho token kế tiếp. Ta sẽ viết một GPT nhỏ để xem một bước sinh một token thực sự gồm những phép tính gì.