Einen BPE-Tokenizer von Hand schreiben
Modul 01 hat gesagt, dass das Modell Tokens sieht. Diese Lektion schreibt selbst einen Byte-basierten BPE-Tokenizer, trainiert ihn auf Tang-Gedichten und zeigt, wie er aus Bytes Schritt für Schritt chinesische Schriftzeichen und häufige Wörter zusammensetzt – und welche Schwächen er bei wenigen Daten hat.
- Etwa 45 Minuten
- Niveau: Fortgeschritten
- Getestet: 2026-09-15 reines Python, Daten aus der „Vollständigen Sammlung der Tang-Gedichte“
Code und Programmausgaben stehen genau so da, wie sie gelaufen sind – Kommentare und Ausgaben sind daher auf Chinesisch.
Modul 01, Lektion 1 hat gezeigt: Das Modell kennt keine Schrift, es sieht eine Folge von Token-Nummern. Dieses Modul baut ein GPT von Grund auf, und der erste Schritt ist zu entscheiden, wie man Text in Tokens zerlegt.
Diese Lektion schreibt von Hand das Verfahren, das die GPT-Reihe, DeepSeek und Qwen (通义千问) alle nutzen: BPE (Byte Pair Encoding). Danach verstehst du zwei Dinge, die man sich in Modul 01 erst einmal nur merken konnte: warum derselbe Text in verschiedenen Modellen unterschiedlich viele Tokens hat und warum ein seltenes Schriftzeichen in mehrere Tokens zerlegt wird.
Die Daten vorbereiten
Das ganze Modul nutzt Tang-Gedichte als Daten. Sie stammen aus dem Open-Source-Projekt chinese-poetry (MIT-Lizenz), das über fünfzigtausend Gedichte der „Vollständigen Sammlung der Tang-Gedichte“ enthält.
uv add torch opencc
python prepare_poems.py
Die Rohdaten sind in Langzeichen; das Skript wandelt sie mit OpenCC in Kurzzeichen um. OpenCC wandelt nach Wortgruppen um, das ist genauer als Zeichen für Zeichen zu ersetzen. Dann bleiben nur metrisch regelmäßige Gedichte übrig: fünf oder sieben Zeichen pro Zeile, vier Zeilen (Jueju) oder acht Zeilen (Lüshi), und nur chinesische Schriftzeichen, Komma und Punkt.
《全唐诗》共 57607 首,留下格律整齐的 35135 首,共 1557776 个字符,6288 个不同的字符
五言绝句:3656 首
七言绝句:10087 首
五言律诗:13865 首
七言律诗:7527 首
前三首:
闲却白云居,行踪出去初。窗中聊取笔,架上独留书。日背林光冷,潭澄岳影虚。长闻得药力,此说复何如。
秋溪南岸菊霏霏,急管烦弦对落晖。红叶树深山径断,碧云江静浦帆稀。不堪孙盛嘲时笑,愿送王弘醉夜归。流落正怜芳意在,砧声徒促授寒衣。
东城晓出静尘埃,紫画神旗向日开。锦袖半攘争捧辔,银鞍不下小传杯。马盘草上朱弓满,鴈落云中白羽回。晚向三通残皷尽,北原千骑卷行来。
Ein Gedicht pro Zeile, insgesamt 35.000 Gedichte und 1,56 Millionen Zeichen. Beim dritten Gedicht sieht man genau hin, dass „鴈“ und „皷“ nicht in „雁“ und „鼓“ umgewandelt wurden: Das sind Zeichenvarianten, keine Lang-Kurz-Entsprechungen, und die Umwandlung von OpenCC behandelt sie nicht. Datenbereinigung ist selten hundertprozentig sauber; beim späteren Training fällt das kaum ins Gewicht, also bleiben sie.
Mit Bytes anfangen
Auf der untersten Ebene besteht Text im Computer aus Bytes. In UTF-8 ist ein lateinischer Buchstabe ein Byte, ein chinesisches Schriftzeichen drei Bytes:
一个汉字在 UTF-8 里是 3 个字节:'月' → e6 9c 88
BPE geht von Bytes aus, deshalb hat sein Anfangsvokabular nur 256 Elemente (die Werte 0 bis 255, die ein Byte darstellen kann). Der Vorteil: Jeder Text lässt sich darstellen, es gibt nie „dieses Zeichen ist nicht im Vokabular“; schlimmstenfalls wird in Bytes zerlegt.
Dann wird immer wieder dasselbe getan: in den Trainingsdaten das häufigste Paar benachbarter Tokens finden und zu einem neuen Token zusammenfügen.
def train(text, n_merges):
ids = list(text.encode("utf-8")) # 从字节开始:词表一开始就是 0~255 这 256 个字节
merges = {} # (a, b) -> 新编号
vocab = {i: bytes([i]) for i in range(256)} # 编号 -> 它代表的字节串
for k in range(n_merges):
pairs = count_pairs(ids)
pair, count = pairs.most_common(1)[0] # 出现最多的一对相邻的词元
new_id = 256 + k
ids = merge(ids, pair, new_id)
merges[pair] = new_id
vocab[new_id] = vocab[pair[0]] + vocab[pair[1]]
count_pairs zählt mit Counter einmal alle benachbarten Paare, merge ersetzt jedes Vorkommen dieses Paars durch die neue Nummer, beides nur wenige Zeilen (siehe code/09-transformer/bpe.py).
Jede Zusammenführung vergrößert das Vokabular um ein Token und verkürzt die Trainingsdaten etwas. Die Zahl der Zusammenführungen bestimmt die endgültige Größe des Vokabulars und muss selbst gewählt werden.
Was er gelernt hat
Mit 2000 Gedichten 1500 Zusammenführungen trainiert (reines Python ist sehr langsam; mit mehr Daten müsste man lange warten):
训练数据:2000 首诗,89863 个字符,265591 个字节
第 1 次合并: [80] + [82] → [80 82] (出现 6396 次),训练数据变成 259195 个词元
第 2 次合并: [ef] + [bc] → [ef bc] (出现 6394 次),训练数据变成 252801 个词元
第 3 次合并: [ef bc] + [8c] → , (出现 6394 次),训练数据变成 246407 个词元
第 4 次合并: [e3] + [80 82] → 。 (出现 6394 次),训练数据变成 240013 个词元
第 5 次合并: [e4] + [b8] → [e4 b8] (出现 3702 次),训练数据变成 236311 个词元
第 6 次合并: 。 + ↵ → 。↵ (出现 1999 次),训练数据变成 234312 个词元
第 7 次合并: [e4] + [ba] → [e4 ba] (出现 1819 次),训练数据变成 232493 个词元
第 8 次合并: [e5] + [a4] → [e5 a4] (出现 1749 次),训练数据变成 230744 个词元
……
第 1001 次合并:[ef bc 8c e8 a1] + [8c] → ,行 (出现 26 次),训练数据变成 106108 个词元
第 1500 次合并: [e7 a5] + [96] → 祖 (出现 15 次),训练数据变成 96124 个词元
训练 1500 次合并,用了 27 秒,词表大小 1756
In eckigen Klammern stehen Bytes, die noch kein vollständiges Zeichen ergeben. Die ersten Zusammenführungen sind interessant:
- Die ersten 4 setzen Komma und Punkt zusammen. Jedes Gedicht enthält sie, sie kommen am häufigsten vor. Die 1. und die 4. gehören zusammen: Zuerst werden die letzten zwei Bytes des Punkts,
80 82, verbunden, dann kommt das führendee3dazu. - Die 6. fügt „Punkt + Zeilenumbruch“ zu einem Token zusammen, weil jedes Gedicht mit einem Punkt endet und danach ein Zeilenumbruch kommt.
- Die 5., 7. und 8. fügen die ersten zwei Bytes der UTF-8-Kodierung chinesischer Schriftzeichen zusammen. Viele häufige Zeichen haben dieselben ersten zwei Bytes; mit
e4 b8beginnen etwa „不“, „与“, „世“, „东“ und andere. Deshalb kommen diese „halben Zeichen“ häufiger vor als jedes einzelne vollständige Zeichen.
Nach 1500 Zusammenführungen sind die Trainingsdaten von 266.000 Bytes auf 96.000 Tokens geschrumpft, etwas mehr als die 89.900 Zeichen.
1500 个新词元里:883 个正好是一个完整的字符,172 个是两个字符以上,其余 445 个是半个汉字、或者跨了字的边界
两个字符以上的,最早学到的 30 个:
。↵ ,不 ,一 。不 ,何 ,山 人。↵ ,风 万里 ,春 ,应 ,天 ,江 。何 ,无 千里 何处 ,清 ,白 。自 。莫 人间 ,秋 ,寒 ,月 。↵一 ,相 。一 ,云 ,日
Er hat einige echte Wörter gelernt: „万里“ (zehntausend Meilen), „千里“ (tausend Meilen), „何处“ (wo), „人间“ (Menschenwelt). Häufiger aber ist „Komma + ein Zeichen“, etwa „,不“, „,何“, „,春“. Das liegt daran, dass vor dem ersten Zeichen jeder Verszeile eines Tang-Gedichts immer ein Satzzeichen steht; diese Kombination kommt sehr oft vor. BPE sieht nur Häufigkeiten und versteht keine Sprache; es weiß nicht, dass das Satzzeichen nichts mit dem folgenden Zeichen zu tun hat.
Tokenizer im Praxiseinsatz zerlegen den Text vor dem Training zunächst grob mit Regeln, etwa Satzzeichen, Leerzeichen und Ziffern jeweils einzeln, und BPE fügt nur innerhalb der so entstandenen Stücke zusammen. GPT-2 nutzt dafür einen regulären Ausdruck. So entstehen keine Tokens wie „,不“, die über ein Satzzeichen hinwegreichen.
Kodieren und dekodieren
Das Training liefert eine Tabelle mit Zusammenführungsregeln. Um einen neuen Text zu kodieren, wandelt man ihn zuerst in Bytes um und fügt dann in der Reihenfolge, in der die Regeln gelernt wurden, zusammen:
def encode(text, merges):
ids = list(text.encode("utf-8"))
while len(ids) >= 2:
# 在所有相邻的对里,找最早学到的那个合并规则先用上:和训练时的顺序一致
pair = min(set(zip(ids, ids[1:])), key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break
ids = merge(ids, pair, merges[pair])
return ids
def decode(ids, vocab):
return b"".join(vocab[i] for i in ids).decode("utf-8", errors="replace")
Warum in dieser Reihenfolge? Weil spätere Regeln auf früheren aufbauen. Das Token „,行“ etwa entsteht aus „den ersten zwei Bytes des Kommas plus den ersten zwei Bytes von ‚行‘“ plus 8c; werden die früheren Zusammenführungen nicht zuerst ausgeführt, gibt es für die späteren nichts zum Zusammenfügen.
Das Dekodieren ist viel einfacher: die Byte-Folgen aller Tokens aneinanderhängen und als UTF-8 dekodieren.
Bei unbekannten Gedichten
Test mit 500 Gedichten, die beim Training nicht vorkamen:
在训练时没见过的 500 首诗上:
68179 个字节,23059 个字符,BPE 分成 25078 个词元,平均每个词元 0.92 个字符
训练用的 2000 首诗里有 3655 个不同的字符;测试诗里的字符,1.0% 在训练数据里一次都没出现过
例子:自君入城市,北邙无新坟。始信壶中药,不落白杨根。如何忽告归,蕣华还笑人。玉笙无遗音,怅望缑岭云。
切成:自 | 君 | 入 | 城 | [e5 b8] | [82] | [ef bc 8c e5] | [8c] | [97] | [e9 82] | [99] | 无 | 新 | [e5 9d] | [9f] | [e3 80 82 e5] | [a7 8b] | 信 | [e5 a3] | [b6] | 中 | 药 | ,不 | 落 | 白 | 杨 | 根 | 。如 | 何 | 忽 | [e5 91] | [8a] | 归 | [ef bc 8c e8] | [95] | [a3] | 华 | 还 | 笑 | 人 | 。玉 | [e7 ac] | [99] | 无 | 遗 | 音 | [ef bc 8c e6 80] | [85] | 望 | [e7 bc] | [91] | 岭 | 云 | 。
解码回去和原文完全一样
Das Ergebnis ist nicht schön: mehr Tokens als Zeichen, im Schnitt nur 0,92 Zeichen pro Token. Zeichen wie „市“, „北“, „邙“, „坟“, „始“ wurden alle in zwei oder drei Stücke zerlegt.
Der Grund sind zu wenige Daten. Die 2000 Gedichte enthalten nur 3655 verschiedene Zeichen, und 1500 Zusammenführungen haben nur 883 davon zu vollständigen Zeichen zusammengesetzt. Kommt ein Zeichen nur ein paar Mal vor, kommt es bei den Zusammenführungen nicht an die Reihe. „市“ ist ein häufiges Zeichen, kommt in diesen 2000 Gedichten aber nicht oft genug vor und wird deshalb weiterhin zerlegt.
Eines macht er aber sehr gut: Das Dekodierte ist mit dem Original völlig identisch. Egal wie kleinteilig zerlegt wird, keine Information geht verloren. Auch Englisch kann er verarbeiten, er fällt nur auf ein Token pro Byte zurück:
一句含英文的:月 | 落 | 乌 | 啼 | 霜 | 满 | 天 | 。 | H | e | l | l | o
Echte Tokenizer
Die Tokenizer echter großer Modelle werden genauso trainiert, nur mit Daten, die um Größenordnungen umfangreicher sind: Dutzende bis Hunderte GB Text in vielen Sprachen und über hunderttausend Zusammenführungen. Stand September 2026 haben die Vokabulare der gängigen Modelle meist zwischen hunderttausend und gut zweihunderttausend Einträge. Bei genügend Daten werden häufige chinesische Zeichen zu vollständigen Tokens, und auch häufige Wörter werden zu einem Token.
Das erklärt auch, was wir in Modul 01 beobachtet haben:
- Die Tokenizer verschiedener Modelle wurden auf verschiedenen Daten trainiert, deshalb hat derselbe Text unterschiedlich viele Tokens. Modelle mit vielen chinesischen Trainingsdaten zerlegen Chinesisch sparsamer.
- Seltene Zeichen kommen in den Trainingsdaten selten vor, werden nicht zusammengeführt und deshalb in mehrere Byte-Tokens zerlegt.
Welche Tokenisierung dieses Modul verwendet
Unser GPT nutzt nicht dieses BPE, sondern die einfachste zeichenbasierte Tokenisierung: Ein Zeichen ist ein Token.
class CharTokenizer:
"""字符级分词器:一个字符就是一个词元。换行符表示一首诗结束。"""
def __init__(self, text):
self.chars = sorted(set(text))
self.index = {c: i for i, c in enumerate(self.chars)}
def encode(self, text):
return [self.index[c] for c in text]
def decode(self, ids):
return "".join(self.chars[i] for i in ids)
Den Grund haben wir oben gesehen: Bei so wenigen Daten zerlegt BPE sogar kleinteiliger als Zeichen. Und die sprachliche Einheit von Tang-Gedichten ist ohnehin vor allem das einzelne Zeichen; ein Token pro Zeichen ist ganz natürlich. Alle 35.000 Gedichte zusammen haben 6288 verschiedene Zeichen, das Vokabular ist klein, und jedes Zeichen kommt beim Training mehrmals vor.
Der Preis der zeichenbasierten Tokenisierung: Mit Zeichen außerhalb des Vokabulars kann sie nichts anfangen. Für unser Experiment spielt das keine Rolle, weil das Vokabular aus allen Gedichten gebildet wird. Beliebigen Text außerhalb der Trainingsdaten kann sie aber nicht verarbeiten, und deshalb nutzen alle echten großen Modelle Byte-basiertes BPE.
Übungen
- Erhöhe die Trainingsdaten von 2000 auf 5000 Gedichte (etwas langsamer). Wie viele Zeichen hat ein Token auf der Testmenge jetzt im Schnitt?
- Trenn vor dem Training Komma, Punkt und Zeilenumbruch einzeln ab und zähle und füge nur innerhalb jeder Verszeile zusammen. Welche Tokens mit mehr als zwei Zeichen werden jetzt gelernt?
- Das Anfangsvokabular von Byte-basiertem BPE sind 256 Bytes. Welche Vor- und Nachteile hätte es, stattdessen mit Zeichen zu beginnen (Anfangsvokabular sind alle verschiedenen Zeichen der Trainingsdaten)?
Selbsttest
1. Was tut BPE bei jedem Trainingsschritt? Wann hört es auf?
Es zählt, wie oft jedes Paar benachbarter Tokens in den Trainingsdaten vorkommt, fügt das häufigste Paar zu einem neuen Token zusammen, nimmt es ins Vokabular auf und ersetzt es in den Daten. Das wiederholt sich, bis die vorab festgelegte Zahl von Zusammenführungen erreicht ist, also das Vokabular die gewünschte Größe hat.
2. Warum muss man beim Kodieren in der Reihenfolge zusammenfügen, in der die Regeln gelernt wurden?
Spätere Regeln wurden auf den Ergebnissen früherer Zusammenführungen gelernt; ihr Gegenstand kann ein neues Token sein, das durch eine frühere Zusammenführung entstanden ist. Hält man die ursprüngliche Reihenfolge nicht ein, finden spätere Regeln nichts zum Zusammenfügen, und das Ergebnis weicht vom Training ab.
3. Warum zerlegt das BPE dieser Lektion die Testmenge in mehr Tokens als Zeichen? Warum haben echte Tokenizer dieses Problem nicht?
Die Trainingsdaten sind nur 2000 Gedichte; viele Zeichen kommen nicht oft genug vor, werden nicht zu vollständigen Zeichen zusammengeführt und nur in zwei oder drei Byte-Stücke zerlegt. Echte Tokenizer werden mit riesigen Datenmengen trainiert und führen über hunderttausend Zusammenführungen aus; häufige Zeichen und gängige Wörter werden zu vollständigen Tokens.
Fragen und Diskussion
Hängst du in dieser Lektion fest? Frag hier. Und wenn du die Frage von jemandem beantworten kannst, tu es gern.
Eine Frage bringt 3 Punkte, eine Antwort 6. Beiträge erscheinen nach der Prüfung.
Diskussion wird geladen…