Phase 10 - Lesson 02
Construindo um Tokenizador do Zero
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
A Lição 01 deu a você um brinquedo. Esta lição lhe dá uma arma.
Tipo: Construção Linguagens: Python Pré-requisitos: Fase 10, Lição 01 (Tokenizadores: BPE, WordPiece, SentencePiece) Tempo: ~90 minutos
Objetivos de Aprendizado
- Construir um tokenizador BPE de nível de produção que lide com Unicode, normalização de espaços em branco e tokens especiais
- Implementar fallback em nível de byte para que o tokenizador possa codificar qualquer entrada (incluindo emoji, CJK e código) sem tokens desconhecidos
- Adicionar padrões regex de pré-tokenização que dividem o texto nos limites das palavras antes de aplicar as fusões de BPE
- Treinar um tokenizador personalizado em um corpus e avaliar sua taxa de compressão em relação ao tiktoken em texto multilíngue
O Problema
Seu tokenizador BPE da Lição 01 funciona em texto em inglês. Agora tente usar japonês nele. Ou emoji. Ou código Python com abas e espaços misturados.
Ele quebra.
Não porque o BPE esteja errado, mas porque a implementação está incompleta. Um tokenizador de produção lida com bytes brutos em qualquer codificação, normaliza o Unicode antes da divisão, gerencia tokens especiais que nunca são fundidos, encadeia a pré-tokenização com a divisão de subpalavras e faz tudo isso rápido o suficiente para não gargalar um pipeline de treinamento que processa 15 trilhões de tokens.
O tokenizador do GPT-2 tem 50.257 tokens. O Llama 3 tem 128.256. O GPT-4 tem aproximadamente 100.000. Esses não são números de brinquedo. As tabelas de fusão por trás desses vocabulários foram treinadas em centenas de gigabytes de texto, e o mecanismo circundante — normalização, pré-tokenização, injeção de tokens especiais, formatação de templates de chat — é o que separa um tokenizador que lida com "hello world" de um que lida com a internet inteira.
Você vai construir esse mecanismo.
O Conceito
O Pipeline Completo
Um tokenizador de produção não é um único algoritmo. É um pipeline de cinco estágios, cada um resolvendo um problema diferente.
graph LR
A[Texto Bruto] --> B[Normalizar]
B --> C[Pré-Tokenizar]
C --> D[Fusão BPE]
D --> E[Tokens Especiais]
E --> F[IDs de Tokens]
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
Cada estágio tem uma função específica:
| Estágio | O Que Faz | Por Que Importa |
|---|---|---|
| Normalizar | Unicode NFKC, caixa baixa opcional, remoção de acentos opcional | A ligadura "fi" (U+FB01) torna-se "fi" (dois caracteres). Sem isso, a mesma palavra recebe tokens diferentes. |
| Pré-Tokenizar | Divide o texto em blocos antes do BPE | Impede que o BPE faça fusões através dos limites das palavras. "the cat" nunca deve produzir o token "e c". |
| Fusão BPE | Aplica regras de fusão aprendidas a sequências de bytes | A compressão principal. Transforma bytes brutos em tokens de subpalavras. |
| Tokens Especiais | Injeta marcadores de [BOS], [EOS], [PAD] e do template de chat | Esses tokens possuem IDs fixos. Eles nunca participam das fusões de BPE. O modelo precisa deles para a estrutura. |
| Mapeamento de IDs | Converte strings de tokens em IDs inteiros | O modelo vê inteiros, não strings. |
BPE em Nível de Byte
O tokenizador da Lição 01 operava em bytes UTF-8. Essa foi a decisão correta. Mas pulamos algo importante: o que acontece quando esses bytes não são UTF-8 válidos?
O BPE em nível de byte resolve isso tratando cada valor de byte possível (0-255) como um token válido. Seu vocabulário base tem exatamente 256 entradas. Qualquer arquivo — texto, binário, corrompido — pode ser tokenizado sem produzir um token desconhecido.
O GPT-2 adicionou um truque: mapear cada byte para um caractere Unicode imprimível para que o vocabulário permaneça legível por humanos. O byte 0x20 (espaço) torna-se o caractere "G" em seu mapeamento. Isso é puramente cosmético. O algoritmo não se importa.
O verdadeiro poder: o BPE em nível de byte lida com todas as línguas da Terra. Os caracteres chineses têm 3 bytes UTF-8 cada. O japonês pode ter de 3 a 4 bytes. Árabe, devanágari, emoji — todos são apenas sequências de bytes. O algoritmo BPE encontra padrões nessas sequências de bytes exatamente da mesma forma que encontra padrões em bytes ASCII do inglês.
Pré-Tokenização
Antes que o BPE toque no seu texto, você precisa dividi-lo em blocos. Isso impede que o algoritmo de fusão crie tokens que ultrapassem os limites das palavras.
O GPT-2 usa um padrão regex para dividir o texto:
'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+
Esse padrão divide em contrações ("don't" torna-se "don" + "'t"), palavras com espaços iniciais opcionais, números, pontuação e espaços em branco. O espaço inicial é mantido anexado à palavra — assim, "the cat" torna-se [" the", " cat"], não ["the", " ", "cat"].
O Llama usa o SentencePiece, que ignora completamente a regex. Ele trata o fluxo de bytes brutos como uma única sequência longa e deixa o algoritmo BPE descobrir os limites. Isso é mais simples, mas dá ao BPE mais liberdade para criar tokens que cruzam palavras.
A escolha importa. A regex do GPT-2 impede que o tokenizador aprenda que o "the" no final de uma palavra e o "the" no início da próxima devem se fundir. O SentencePiece permite isso, o que às vezes produz uma compressão mais eficiente, mas tokens menos interpretáveis.
Tokens Especiais
Todo tokenizador de produção reserva IDs de tokens para marcadores estruturais:
| Token | Objetivo | Usado Por |
|---|---|---|
[BOS] / <s> |
Início da sequência | Llama 3, GPT |
[EOS] / </s> |
Fim da sequência | Todos os modelos |
[PAD] |
Preenchimento para alinhamento de lote | BERT, T5 |
[UNK] |
Token desconhecido (o BPE em nível de byte elimina isso) | BERT, WordPiece |
<|im_start|> |
Início do limite da mensagem de chat | ChatGPT, Qwen |
<|im_end|> |
Fim do limite da mensagem de chat | ChatGPT, Qwen |
<|user|> |
Marcador de turno do usuário | Llama 3 |
<|assistant|> |
Marcador de turno do assistente | Llama 3 |
Tokens especiais nunca são divididos pelo BPE. Eles são correspondidos exatamente antes que o algoritmo de fusão seja executado, substituídos por seu ID fixo, e o texto restante é tokenizado normalmente.
Templates de Chat
É aqui que a maioria das pessoas se confunde e a maioria das implementações quebra.
Quando você envia mensagens para um modelo de chat, a API aceita uma lista de mensagens:
[
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"}
]
O modelo não vê JSON. Ele vê uma sequência plana de tokens. O template de chat converte as mensagens nessa sequência plana usando tokens especiais. Cada modelo faz isso de forma diferente:
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|>
Se você errar o template, o modelo produzirá lixo. Ele foi treinado em um formato exato. Qualquer desvio — uma nova linha ausente, um token trocado, um espaço extra — coloca a entrada fora da distribuição de treinamento.
Velocidade
O Python é muito lento para a tokenização em produção.
O tiktoken (OpenAI) é escrito em Rust com bindings Python. O HuggingFace tokenizers também é em Rust. O SentencePiece é em C++. Eles alcançam acelerações de 10 a 100 vezes em relação ao Python puro.
Para perspectiva: tokenizar 15 trilhões de tokens para o pré-treinamento do Llama 3 a 1 milhão de tokens por segundo (Python rápido) levaria 174 dias. A 100 milhões de tokens por segundo (Rust), leva 1,7 dias.
Você está construindo em Python para entender o algoritmo. Em produção, você usaria uma implementação compilada e apenas tocaria no wrapper Python.
Construa
Passo 1: Codificação em Nível de Byte
A base. Converta qualquer string em uma sequência de bytes, mapeie cada byte para um caractere imprimível para exibição e reverta o processo.
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")
Teste em texto multilíngue para ver a contagem de bytes:
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" tem 5 bytes. "你好" tem 6 bytes (3 por caractere). O emoji de fogo tem 4 bytes. O tokenizador em nível de byte não se importa com a língua. Bytes são bytes.
Passo 2: Pré-Tokenizador com Regex
Divida o texto em blocos usando o padrão regex do GPT-2. Cada bloco é tokenizado de forma independente pelo 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)]
O módulo regex suporta escapes de propriedade Unicode (\p{L} para letras, \p{N} para números). O módulo re da biblioteca padrão não suporta, então recorremos a classes de caracteres ASCII. Para tokenizadores multilíngues de produção, instale regex.
Experimente:
print(pre_tokenize("Hello, world! Don't stop."))
# [' Hello', ',', ' world', '!', " Don", "'t", ' stop', '.']
O espaço inicial permanece anexado à palavra. As contrações dividem-se no apóstrofo. A pontuação torna-se seu próprio bloco. O BPE nunca fundirá tokens através desses limites.
Passo 3: BPE em Sequências de Bytes
O algoritmo principal da Lição 01, mas agora operando em blocos pré-tokenizados de forma independente.
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
Passo 4: Tratamento de Tokens Especiais
Tokens especiais precisam de correspondência exata e IDs fixos. Eles ignoram completamente o 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
Passo 5: Classe Completa do Tokenizador
Encadeie tudo: normalize, divida em tokens especiais, pré-tokenize, faça a fusão de BPE e mapeie para IDs.
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)
Passo 6: Teste Multilíngue
O teste real. Forneça inglês, chinês, emoji e código para ele.
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()
Os caracteres chineses produzem 3 bytes cada. O emoji produz 4 bytes. Nenhum deles trava o tokenizador. Nenhum produz tokens desconhecidos. Esse é o poder do BPE em nível de byte.
Use-o
Comparando Tokenizadores Reais
Carregue os tokenizadores reais do Llama 3, GPT-4 e Mistral. Veja como cada um lida com o mesmo parágrafo multilíngue.
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]}...")
Você verá contagens de tokens diferentes para o mesmo texto. O Llama 3 com vocabulário de 128K é mais agressivo na fusão de padrões comuns. O GPT-4 com 100K fica no meio. O Mistral com 32K produz mais tokens, mas possui uma camada de embedding menor.
O tradeoff é sempre o mesmo: um vocabulário maior significa sequências mais curtas, mas mais parâmetros.
Envie para Produção
Esta lição produz um prompt para construir e depurar tokenizadores de produção. Veja outputs/prompt-tokenizer-builder.md.
Exercícios
- Fácil: Adicione um método
get_token_bytes(id)que mostre os bytes brutos para qualquer ID de token. Use-o para inspecionar o que os seus tokens fundidos mais comuns realmente representam. - Médio: Implemente o pré-tokenizador no estilo Llama que divide em espaços em branco e dígitos, mas mantém os espaços iniciais. Compare seu vocabulário com a abordagem regex do GPT-2 no mesmo corpus.
- Difícil: Adicione um método de template de chat que receba uma lista de mensagens
{"role": ..., "content": ...}e produza a sequência correta de tokens para o formato de chat do Llama 3. Teste-o em relação à implementação do HuggingFace.
Termos-Chave
| Termo | O que as pessoas dizem | O que realmente significa |
|---|---|---|
| BPE em nível de byte | "Tokenizador que funciona em bytes" | BPE com um vocabulário base de 256 valores de byte — lida com qualquer entrada sem tokens desconhecidos |
| Pré-tokenização | "Divisão antes do BPE" | Divisão baseada em regex ou regras que impede o BPE de fundir através dos limites de palavras |
| Normalização NFKC | "Limpeza de Unicode" | Decomposição canônica seguida de composição de compatibilidade — a ligatura "fi" torna-se "fi", o "A" de largura total (fullwidth) torna-se "A" |
| Template de chat | "Como mensagens se tornam tokens" | O formato exato para converter uma lista de mensagens de papel/conteúdo em uma sequência plana de tokens — específico do modelo e deve corresponder ao formato de treinamento |
| Tokens especiais | "Tokens de controle" | IDs de tokens reservados que ignoram o BPE — [BOS], [EOS], [PAD], marcadores de chat — correspondidos exatamente antes da fusão |
| Fertilidade | "Tokens por palavra" | Proporção de tokens de saída para palavras de entrada — 1,3 para inglês no GPT-4, 2-3 para coreano; quanto maior, mais contexto é desperdiçado |
| tiktoken | "Tokenizador da OpenAI" | Implementação em Rust do BPE com bindings Python — 10 a 100 vezes mais rápida que o Python puro |
| Tabela de fusão | "O vocabulário" | Lista ordenada de fusões de pares de bytes aprendidas durante o treinamento — este É o conhecimento aprendido do tokenizador |
Leituras Adicionais
- OpenAI tiktoken source — Implementação em Rust do BPE usada pelo GPT-3.5/4
- HuggingFace tokenizers — Biblioteca de tokenização em Rust que suporta BPE, WordPiece, Unigram
- Llama 3 paper (Meta, 2024) — Detalhes sobre o vocabulário de 128K e o treinamento do tokenizador
- SentencePiece (Kudo & Richardson, 2018) — Tokenização independente de idioma
- GPT-2 tokenizer source — O mapeamento original de byte para Unicode