토크나이저 직접 구현
Lesson 01이 장난감 수준의 구현을 다뤘다면, 이번 lesson은 실전에서 쓸 수 있는 도구를 만듭니다.
유형: Build
언어: Python
선수 지식: Phase 10, Lesson 01 (토크나이저: BPE, WordPiece, SentencePiece)
예상 시간: 약 90분
학습 목표
- 유니코드(Unicode), 공백 정규화(whitespace normalization), 특수 토큰(special tokens)을 처리하는 실무 수준 BPE 토크나이저(BPE tokenizer)를 만듭니다.
- 바이트 단위 폴백(byte-level fallback)을 구현해 이모지(emoji), CJK 문자, 코드(code)를 포함한 어떤 입력도 알 수 없는 토큰(unknown token) 없이 인코딩할 수 있게 합니다.
- BPE 병합(BPE merge)을 적용하기 전에 단어 경계(word boundary)에서 텍스트를 나누는 사전 토큰화 정규식(pre-tokenization regex pattern)을 추가합니다.
- 직접 만든 말뭉치(corpus)로 사용자 정의 토크나이저(custom tokenizer)를 학습하고, 다국어 텍스트에서 tiktoken 대비 압축 비율(compression ratio)을 평가합니다.
문제
Lesson 01에서 만든 BPE 토크나이저는 영어 텍스트에서는 동작합니다. 이제 여기에 일본어를 넣어봅니다. 또는 이모지를 넣어봅니다. 탭과 공백이 섞인 Python 코드를 넣어봅니다.
깨집니다.
BPE가 잘못된 알고리즘이라서가 아닙니다. 구현이 불완전하기 때문입니다. 실무용 토크나이저는 어떤 인코딩의 원시 바이트(raw bytes)든 처리하고, 분리 전에 유니코드(Unicode)를 정규화하며, 절대 병합되면 안 되는 특수 토큰(special tokens)을 관리하고, 사전 토큰화(pre-tokenization)와 서브워드 분리(subword splitting)를 연결하고, 15조 개 토큰을 처리하는 학습 파이프라인(training pipeline)의 병목이 되지 않을 만큼 빠르게 이 모든 일을 수행합니다.
GPT-2의 토크나이저에는 50,257개 토큰이 있습니다. Llama 3에는 128,256개가 있습니다. GPT-4에는 대략 100,000개가 있습니다. 장난감 숫자가 아닙니다. 이 어휘(vocabulary) 뒤에 있는 병합 테이블(merge table)은 수백 기가바이트의 텍스트로 학습되었고, 그 주변 장치인 정규화(normalization), 사전 토큰화(pre-tokenization), 특수 토큰 주입(special token injection), 채팅 템플릿 포매팅(chat template formatting)이 "hello world"만 처리하는 토크나이저와 인터넷 전체를 처리하는 토크나이저를 가릅니다.
이번 lesson에서는 그 장치를 만듭니다.
개념
전체 파이프라인(Full Pipeline)
실무용 토크나이저는 하나의 알고리즘이 아닙니다. 서로 다른 문제를 해결하는 다섯 단계의 파이프라인입니다.
graph LR
A["원시 텍스트(Raw Text)"] --> B["정규화(Normalize)"]
B --> C["사전 토큰화(Pre-Tokenize)"]
C --> D["BPE 병합(BPE Merge)"]
D --> E["특수 토큰(Special Tokens)"]
E --> F["토큰 ID(Token IDs)"]
style A fill:#1a1a2e,stroke:#e94560,color:#fff
style B fill:#1a1a2e,stroke:#e94560,color:#fff
style C fill:#1a1a2e,stroke:#e94560,color:#fff
style D fill:#1a1a2e,stroke:#e94560,color:#fff
style E fill:#1a1a2e,stroke:#e94560,color:#fff
style F fill:#1a1a2e,stroke:#e94560,color:#fff
각 단계에는 구체적인 역할이 있습니다.
| 단계(Stage) | 하는 일 | 중요한 이유 |
|---|
| 정규화(Normalize) | NFKC 유니코드, 선택적 소문자화(lowercase), 선택적 악센트 제거(strip accents)를 적용합니다. | "fi" 합자(ligature, U+FB01)가 두 문자 "fi"가 됩니다. 이 단계가 없으면 같은 단어가 서로 다른 토큰을 받습니다. |
| 사전 토큰화(Pre-Tokenize) | BPE 전에 텍스트를 청크(chunk)로 나눕니다. | BPE가 단어 경계를 넘어 병합하지 않게 합니다. "the cat"이 "e c" 같은 토큰을 만들어서는 안 됩니다. |
| BPE 병합(BPE Merge) | 학습된 병합 규칙(merge rules)을 바이트 시퀀스(byte sequences)에 적용합니다. | 핵심 압축 단계입니다. 원시 바이트를 서브워드 토큰(subword tokens)으로 바꿉니다. |
| 특수 토큰(Special Tokens) | [BOS], [EOS], [PAD], 채팅 템플릿 마커(chat template markers)를 주입합니다. | 이 토큰들은 고정 ID를 가집니다. BPE 병합에 참여하지 않으며, 모델은 구조를 이해하기 위해 이 토큰들을 필요로 합니다. |
| ID 매핑(ID Mapping) | 토큰 문자열을 정수 ID(integer IDs)로 변환합니다. | 모델은 문자열이 아니라 정수를 봅니다. |
바이트 단위 BPE(Byte-Level BPE)
Lesson 01의 토크나이저는 UTF-8 바이트 위에서 동작했습니다. 올바른 선택이었습니다. 하지만 중요한 부분을 건너뛰었습니다. 그 바이트들이 유효한 UTF-8이 아니면 어떻게 될까요?
바이트 단위 BPE(Byte-level BPE)는 가능한 모든 바이트 값(0-255)을 유효한 토큰으로 취급해 이 문제를 해결합니다. 기본 어휘(base vocabulary)는 정확히 256개 항목입니다. 어떤 파일이든 텍스트, 바이너리(binary), 손상된 파일(corrupted file) 모두 알 수 없는 토큰을 만들지 않고 토큰화할 수 있습니다.
GPT-2는 한 가지 요령을 더했습니다. 어휘를 사람이 읽기 쉽게 유지하려고 각 바이트를 출력 가능한 유니코드 문자(printable Unicode character)에 매핑했습니다. 바이트 0x20(space)은 이 매핑에서 "G" 문자처럼 보이는 문자로 바뀝니다. 이것은 순전히 보기 편하게 만드는 장치입니다. 알고리즘 자체는 신경 쓰지 않습니다.
진짜 힘은 여기에 있습니다. 바이트 단위 BPE는 지구상의 모든 언어를 처리합니다. 중국어 문자는 각각 3개의 UTF-8 바이트입니다. 일본어는 3-4바이트일 수 있습니다. 아랍어(Arabic), 데바나가리(Devanagari), 이모지도 모두 바이트 시퀀스일 뿐입니다. BPE 알고리즘은 영어 ASCII 바이트에서 패턴을 찾는 것과 정확히 같은 방식으로 이 바이트 시퀀스에서 패턴을 찾습니다.
사전 토큰화(Pre-Tokenization)
BPE가 텍스트를 다루기 전에 텍스트를 청크(chunk)로 나누어야 합니다. 이렇게 하면 병합 알고리즘(merge algorithm)이 단어 경계를 가로지르는 토큰을 만들지 못합니다.
GPT-2는 텍스트를 나누기 위해 정규식 패턴(regex pattern)을 사용합니다.
'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+
이 패턴은 축약형(contractions, 예: "don't" -> "don" + "'t"), 선택적 선행 공백(optional leading spaces)이 붙은 단어, 숫자, 구두점(punctuation), 공백(whitespace)을 나눕니다. 선행 공백은 단어에 붙은 채 유지됩니다. 따라서 "the cat"은 ["the", " ", "cat"]이 아니라 [" the", " cat"]이 됩니다.
Llama는 SentencePiece를 사용하며, 정규식을 완전히 건너뜁니다. 원시 바이트 스트림(raw byte stream)을 하나의 긴 시퀀스로 취급하고 BPE 알고리즘이 경계를 알아내게 둡니다. 이 방식은 더 단순하지만, BPE가 단어 경계를 넘는 토큰을 만들 자유를 더 많이 갖습니다.
이 선택은 중요합니다. GPT-2의 정규식은 한 단어 끝의 "the"와 다음 단어 시작의 "the"가 병합되는 일을 막습니다. SentencePiece는 이를 허용하며, 때로는 더 효율적인 압축을 만들지만 해석하기 덜 쉬운 토큰을 만들 수 있습니다.
특수 토큰(Special Tokens)
모든 실무용 토크나이저는 구조적 마커(structural markers)를 위해 토큰 ID를 예약합니다.
| 토큰(Token) | 목적(Purpose) | 사용하는 모델/계열 |
|---|
[BOS] / <s> | 시퀀스 시작(Beginning of sequence) | Llama 3, GPT |
[EOS] / </s> | 시퀀스 끝(End of sequence) | 모든 모델 |
[PAD] | 배치 정렬(batch alignment)을 위한 패딩(padding) | BERT, T5 |
[UNK] | 알 수 없는 토큰(바이트 단위 BPE는 이를 제거함) | BERT, WordPiece |
<|im_start|> | 채팅 메시지 경계 시작 | ChatGPT, Qwen |
<|im_end|> | 채팅 메시지 경계 끝 | ChatGPT, Qwen |
<|user|> | 사용자 턴(user turn) 마커 | Llama 3 |
<|assistant|> | 어시스턴트 턴(assistant turn) 마커 | Llama 3 |
특수 토큰은 BPE로 절대 쪼개지지 않습니다. 병합 알고리즘이 실행되기 전에 정확히 매칭되어 고정 ID로 바뀌고, 주변 텍스트는 일반적인 방식으로 토큰화됩니다.
채팅 템플릿(Chat Templates)
여기에서 많은 사람이 헷갈리고 많은 구현이 깨집니다.
채팅 모델에 메시지를 보낼 때 API는 메시지 목록을 받습니다.
[
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"}
]
모델은 JSON을 보지 않습니다. 모델은 납작한 토큰 시퀀스(flat token sequence)를 봅니다. 채팅 템플릿은 특수 토큰을 사용해 메시지를 그 납작한 시퀀스로 바꿉니다. 모델마다 이 방식은 다릅니다.
Llama 3:
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are helpful.<|eot_id|><|start_header_id|>user<|end_header_id|>
Hello<|eot_id|><|start_header_id|>assistant<|end_header_id|>
Hi there!<|eot_id|>
ChatGPT:
<|im_start|>system
You are helpful.<|im_end|>
<|im_start|>user
Hello<|im_end|>
<|im_start|>assistant
Hi there!<|im_end|>
템플릿을 잘못 쓰면 모델은 엉뚱한 출력을 만듭니다. 모델은 정확히 하나의 포맷으로 학습되었습니다. 줄바꿈 하나가 빠지거나, 토큰 순서가 바뀌거나, 공백 하나가 추가되는 등 어떤 차이든 입력을 학습 분포(training distribution) 밖으로 밀어냅니다.
속도(Speed)
Python은 실무용 토큰화에는 너무 느립니다.
tiktoken(OpenAI)은 Rust로 작성되어 있고 Python 바인딩(Python bindings)을 제공합니다. Hugging Face tokenizers도 Rust입니다. SentencePiece는 C++입니다. 이들은 순수 Python보다 10-100배 빠릅니다.
감각을 잡아봅시다. Llama 3 사전학습(pre-training)을 위해 15조 개 토큰을 토큰화한다고 합시다. 초당 100만 토큰(빠른 Python)으로 처리하면 174일이 걸립니다. 초당 1억 토큰(Rust)으로 처리하면 1.7일이 걸립니다.
여기서는 알고리즘을 이해하기 위해 Python으로 만듭니다. 실무에서는 컴파일된 구현(compiled implementation)을 사용하고 Python 래퍼(wrapper)만 만지는 편이 맞습니다.
직접 만들기
Step 1: 바이트 단위 인코딩(Byte-Level Encoding)
기초 단계입니다. 어떤 문자열이든 바이트 시퀀스로 변환하고, 표시를 위해 각 바이트를 출력 가능한 문자로 매핑한 뒤, 다시 되돌립니다.
def bytes_to_tokens(text):
return list(text.encode("utf-8"))
def tokens_to_text(token_bytes):
return bytes(token_bytes).decode("utf-8", errors="replace")
다국어 텍스트로 테스트해 바이트 수를 확인합니다.
texts = [
("English", "hello"),
("Chinese", "你好"),
("Emoji", "🔥"),
("Mixed", "hello你好🔥"),
]
for label, text in texts:
b = bytes_to_tokens(text)
print(f"{label}: {len(text)} chars -> {len(b)} bytes -> {b}")
hello는 5바이트입니다. 你好는 6바이트입니다(글자당 3바이트). 불 이모지는 4바이트입니다. 바이트 단위 토크나이저는 언어가 무엇인지 신경 쓰지 않습니다. 바이트는 바이트입니다.
Step 2: 정규식 기반 사전 토크나이저(Pre-Tokenizer with Regex)
GPT-2 정규식 패턴을 사용해 텍스트를 청크로 나눕니다. 각 청크는 BPE로 독립적으로 토큰화됩니다.
import re
try:
import regex
GPT2_PATTERN = regex.compile(
r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
)
except ImportError:
GPT2_PATTERN = re.compile(
r"""'(?:[sdmt]|ll|ve|re)| ?[a-zA-Z]+| ?[0-9]+| ?[^\s\w]+|\s+(?!\S)|\s+"""
)
def pre_tokenize(text):
return [match.group() for match in GPT2_PATTERN.finditer(text)]
regex 모듈은 유니코드 속성 이스케이프(Unicode property escapes)를 지원합니다. 예를 들어 \p{L}은 문자(letter), \p{N}은 숫자(number)를 뜻합니다. 표준 라이브러리 re 모듈은 이를 지원하지 않으므로 ASCII 문자 클래스(ASCII character classes)로 폴백합니다. 실무용 다국어 토크나이저를 만들 때는 regex를 설치합니다.
실행해봅니다.
print(pre_tokenize("Hello, world! Don't stop."))
선행 공백은 단어에 붙은 채 남습니다. 축약형은 아포스트로피(apostrophe)에서 갈라집니다. 구두점은 자체 청크가 됩니다. BPE는 이 경계를 절대 넘어 병합하지 않습니다.
Step 3: 바이트 시퀀스 위의 BPE(BPE on Byte Sequences)
Lesson 01의 핵심 알고리즘을 가져오되, 이제 사전 토큰화된 청크마다 독립적으로 적용합니다.
from collections import Counter
def get_byte_pairs(chunks):
pairs = Counter()
for chunk in chunks:
byte_seq = list(chunk.encode("utf-8"))
for i in range(len(byte_seq) - 1):
pairs[(byte_seq[i], byte_seq[i + 1])] += 1
return pairs
def apply_merge(byte_seq, pair, new_id):
merged = []
i = 0
while i < len(byte_seq):
if i < len(byte_seq) - 1 and byte_seq[i] == pair[0] and byte_seq[i + 1] == pair[1]:
merged.append(new_id)
i += 2
else:
merged.append(byte_seq[i])
i += 1
return merged
Step 4: 특수 토큰 처리(Special Token Handling)
특수 토큰은 정확한 매칭(exact matching)과 고정 ID가 필요합니다. 이 토큰들은 BPE를 완전히 우회합니다.
class SpecialTokenHandler:
def __init__(self):
self.special_tokens = {}
self.pattern = None
def add_token(self, token_str, token_id):
self.special_tokens[token_str] = token_id
escaped = [re.escape(t) for t in sorted(self.special_tokens.keys(), key=len, reverse=True)]
self.pattern = re.compile("|".join(escaped))
def split_with_specials(self, text):
if not self.pattern:
return [(text, False)]
parts = []
last_end = 0
for match in self.pattern.finditer(text):
if match.start() > last_end:
parts.append((text[last_end:match.start()], False))
parts.append((match.group(), True))
last_end = match.end()
if last_end < len(text):
parts.append((text[last_end:], False))
return parts
Step 5: 전체 토크나이저 클래스(Full Tokenizer Class)
모든 단계를 연결합니다. 정규화(normalize), 특수 토큰 기준 분리, 사전 토큰화, BPE 병합, ID 매핑을 하나로 묶습니다.
import unicodedata
class ProductionTokenizer:
def __init__(self):
self.merges = {}
self.vocab = {i: bytes([i]) for i in range(256)}
self.special_handler = SpecialTokenHandler()
self.next_id = 256
def normalize(self, text):
return unicodedata.normalize("NFKC", text)
def train(self, text, num_merges):
text = self.normalize(text)
chunks = pre_tokenize(text)
chunk_bytes = [list(chunk.encode("utf-8")) for chunk in chunks]
for i in range(num_merges):
pairs = Counter()
for seq in chunk_bytes:
for j in range(len(seq) - 1):
pairs[(seq[j], seq[j + 1])] += 1
if not pairs:
break
best = max(pairs, key=pairs.get)
new_id = self.next_id
self.next_id += 1
self.merges[best] = new_id
self.vocab[new_id] = self.vocab[best[0]] + self.vocab[best[1]]
chunk_bytes = [apply_merge(seq, best, new_id) for seq in chunk_bytes]
def add_special_token(self, token_str):
token_id = self.next_id
self.next_id += 1
self.special_handler.add_token(token_str, token_id)
self.vocab[token_id] = token_str.encode("utf-8")
return token_id
def encode(self, text):
text = self.normalize(text)
parts = self.special_handler.split_with_specials(text)
all_ids = []
for part_text, is_special in parts:
if is_special:
all_ids.append(self.special_handler.special_tokens[part_text])
else:
for chunk in pre_tokenize(part_text):
byte_seq = list(chunk.encode("utf-8"))
for pair, new_id in self.merges.items():
byte_seq = apply_merge(byte_seq, pair, new_id)
all_ids.extend(byte_seq)
return all_ids
def decode(self, ids):
byte_parts = []
for token_id in ids:
if token_id in self.vocab:
byte_parts.append(self.vocab[token_id])
return b"".join(byte_parts).decode("utf-8", errors="replace")
def vocab_size(self):
return len(self.vocab)
Step 6: 다국어 테스트(Multilingual Test)
진짜 테스트입니다. 영어, 중국어, 이모지, 코드를 모두 넣어봅니다.
corpus = (
"The quick brown fox jumps over the lazy dog. "
"The quick brown fox runs through the forest. "
"Machine learning models process natural language. "
"Deep learning transforms how we build software. "
"def train(model, data): return model.fit(data) "
"def predict(model, x): return model(x) "
)
tok = ProductionTokenizer()
tok.train(corpus, num_merges=50)
bos = tok.add_special_token("<|begin|>")
eos = tok.add_special_token("<|end|>")
test_texts = [
"The quick brown fox.",
"你好世界",
"Hello 🌍 World",
"def foo(x): return x + 1",
f"<|begin|>Hello<|end|>",
]
for text in test_texts:
ids = tok.encode(text)
decoded = tok.decode(ids)
print(f"입력(Input): {text}")
print(f"토큰(Tokens): {len(ids)} ids")
print(f"디코딩(Decoded): {decoded}")
print()
중국어 문자는 각각 3바이트를 만듭니다. 이모지는 4바이트를 만듭니다. 어느 것도 토크나이저를 깨뜨리지 않습니다. 어느 것도 알 수 없는 토큰을 만들지 않습니다. 이것이 바이트 단위 BPE의 힘입니다.
사용해보기
실제 토크나이저 비교하기(Comparing Real Tokenizers)
Llama 3, GPT-4, Mistral의 실제 토크나이저를 불러옵니다. 같은 다국어 문단을 각 토크나이저가 어떻게 처리하는지 봅니다.
import tiktoken
gpt4_enc = tiktoken.get_encoding("cl100k_base")
test_paragraph = "Machine learning is powerful. 机器学习很强大。 L'apprentissage automatique est puissant. 🤖💪"
tokens = gpt4_enc.encode(test_paragraph)
pieces = [gpt4_enc.decode([t]) for t in tokens]
print(f"GPT-4 ({len(tokens)} tokens): {pieces}")
from transformers import AutoTokenizer
llama_tok = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B")
mistral_tok = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1")
for name, tok in [("Llama 3", llama_tok), ("Mistral", mistral_tok)]:
tokens = tok.encode(test_paragraph)
pieces = tok.convert_ids_to_tokens(tokens)
print(f"{name} ({len(tokens)} tokens): {pieces[:20]}...")
같은 텍스트라도 서로 다른 토큰 수가 나옵니다. 128K 어휘를 가진 Llama 3는 흔한 패턴을 더 공격적으로 병합합니다. 100K 어휘를 가진 GPT-4는 중간쯤에 있습니다. 32K 어휘를 가진 Mistral은 더 많은 토큰을 만들지만 임베딩 층(embedding layer)은 더 작습니다.
트레이드오프는 항상 같습니다. 더 큰 어휘는 더 짧은 시퀀스를 만들지만 더 많은 파라미터를 요구합니다.
산출물 만들기
이 lesson은 실무용 토크나이저를 만들고 디버깅하기 위한 프롬프트를 산출합니다. outputs/prompt-tokenizer-builder.md를 확인합니다.
연습문제
- 쉬움: 임의의 토큰 ID에 대한 원시 바이트(raw bytes)를 보여주는
get_token_bytes(id) 메서드를 추가합니다. 이 메서드로 가장 흔한 병합 토큰이 실제로 무엇을 나타내는지 살펴봅니다.
- 중간: 공백과 숫자를 기준으로 나누되 선행 공백을 유지하는 Llama 스타일 사전 토크나이저(pre-tokenizer)를 구현합니다. 같은 말뭉치에서 GPT-2 정규식 접근과 어휘를 비교합니다.
- 어려움:
{"role": ..., "content": ...} 메시지 목록을 받아 Llama 3 채팅 포맷에 맞는 토큰 시퀀스를 만드는 채팅 템플릿 메서드를 추가합니다. Hugging Face 구현과 비교해 테스트합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 바이트 단위 BPE(Byte-level BPE) | "바이트 위에서 동작하는 토크나이저" | 256개 바이트 값을 기본 어휘로 쓰는 BPE입니다. 알 수 없는 토큰 없이 어떤 입력도 처리합니다. |
| 사전 토큰화(Pre-tokenization) | "BPE 전에 나누기" | BPE가 단어 경계를 넘어 병합하지 못하게 정규식 또는 규칙 기반으로 텍스트를 나누는 단계입니다. |
| NFKC 정규화(NFKC normalization) | "유니코드 정리" | 정규 분해(canonical decomposition) 뒤 호환 합성(compatibility composition)을 적용합니다. "fi" 합자는 "fi"가 되고, 전각 "A"는 "A"가 됩니다. |
| 채팅 템플릿(Chat template) | "메시지가 토큰이 되는 방식" | 역할(role)과 내용(content)으로 된 메시지 목록을 납작한 토큰 시퀀스로 바꾸는 정확한 포맷입니다. 모델별로 다르며 학습 포맷과 맞아야 합니다. |
| 특수 토큰(Special tokens) | "제어 토큰(control tokens)" | BPE를 우회하는 예약 토큰 ID입니다. [BOS], [EOS], [PAD], 채팅 마커 등이 있으며 병합 전에 정확히 매칭됩니다. |
| 토큰 비옥도(Fertility) | "단어당 토큰 수" | 입력 단어 대비 출력 토큰의 비율입니다. GPT-4의 영어는 약 1.3, 한국어는 2-3 정도일 수 있으며, 높을수록 컨텍스트가 낭비됩니다. |
| tiktoken | "OpenAI 토크나이저" | Python 바인딩을 제공하는 Rust BPE 구현입니다. 순수 Python보다 10-100배 빠릅니다. |
| 병합 테이블(Merge table) | "어휘" | 학습 중 얻은 바이트 쌍 병합(byte-pair merges)의 순서 있는 목록입니다. 이것이 토크나이저가 학습한 지식입니다. |
더 읽을거리