Transformer 实现机制 · 第 3 课 · 补充课

中文怎么办 —— 没有空格的语言,怎么过 tokenizer 这一关

⏱ 约 20 分钟 · 前置:第 2 课(分词与 BPE) · 应同学提问而加 · 主干:Vizuara 1.3–1.4 的中文延伸 · 本课所有 tokenizer 数字均为本机实测(2026-09-17,tiktoken + Qwen 官方词表),不是转抄

好问题值得加一课。第 2 课的三种刀法和 BPE 演算,例子全是英文。中文怎么切?这不是小注脚——中文把第 2 课的所有前提都掀了:没有空格(没有现成的词边界)、"词"要靠猜、常用字几千个但组合近乎无限。看看真实模型怎么接招。

一、中文的特殊性:词边界要靠猜

南京市长江大桥

英文里空格是免费的边界标记;中文的"词"边界是隐式的,要猜:是「南京市 / 长江 / 大桥」,还是「南京 / 市长 / 江大桥」?歧义句一抓一把。

所以老路线(BERT 之前)分两步:先用分词器把句子切成词——jieba 就是干这个的,靠词典 + 统计猜边界——然后再对词做嵌入。痛点很明显:分词器切错,后面的模型全盘照收,错误向后传染;新词、人名、网络语永远追着词典跑。

于是 Google 的中文 BERT 做了一个影响深远的选择:按字切。bert-base-chinese 词表 21,128,每个汉字 = 1 个词元,不做任何词级合并。

为什么"字符级"这把刀,在英文是灾难,在中文却可行? 第 2 课批评字符级切法的两条罪名:序列暴长、单个字符无语义。第二条在中文不成立——汉字不是字母!「学」自带语义,粒度约等于英文的整词。所以字级切法在中文几乎没有语义损失,序列也不算失控(一句话几十字),还顺手消灭了分词器这个错误源。字级是中文 BERT 时代的标准答案,直到今天很多中文 NLP 任务还在用。

二、现代 LLM 的统一答案:字节级 BPE

GPT-2 起给出了另一条路:不管什么语言,先全部变成 UTF-8 字节流,再跑 BPE。这就是第 2 课说的"基础层是字节,永不 OOV"的完整含义——任何文字、错拼、表情,最坏也能退回字节零件。

代价立刻来了,先看数学。UTF-8 里:英文字母和数字 = 1 字节;常用汉字 = 3 字节。亲手验证:

# ============================================================ # 【演示目标】看清"字符"在计算机里的真面目——UTF-8 字节。 # 这是字节级 BPE 的物理基础: 英文字母 1 字节,常用汉字 3 字节。 # 正文说"中文在 GPT-2 里吃亏",根子就在这 3 倍的字节差。 # 【思路】两小步: # 1. 单字符视角: encode("utf-8") 把字符变成字节,打印十六进制, # 亲眼看到"学"= e5 ad a6 三个字节(而 A 只是 41 一个字节) # 2. 整句视角: 数一数同一句中文的"字符数"vs"字节数",对比出 3 倍差 # 读法: U+XXXX 是字符的 Unicode 编号;hex() 显示的是编码后的字节 # ============================================================ # ---- 第 1 步: 逐个字符看字节构成 ---- for ch in ["A", "9", "学", "习", "!"]: b = ch.encode("utf-8") # 字符 → 字节串(这就是计算机里存的东西) print(f" {ch} U+{ord(ch):04X} → {len(b)} 字节: {b.hex(' ')}") # ord(ch) 取 Unicode 编号;b.hex() 把字节显示成十六进制。 # 注意:3 个汉字和全角叹号都是 3 字节——它们才是中文的"常态" s = "深度学习改变了很多行业。" print() # ---- 第 2 步: 整句对比 字符数 vs 字节数 ---- print(f"「{s}」: {len(s)} 个字符 → {len(s.encode('utf-8'))} 个字节(每个汉字 3 字节,句号也是 3 字节)") # len(s) 数字符,len(s.encode()) 数字节——两者差 3 倍。 # 同样一句话,中文占的字节是英文的好几倍,这就是"字节级分词"里中文的起点劣势

那 GPT-2 为什么把中文切得稀碎?回忆第 2 课的核心机制:词表由语料投票产生。GPT-2 的训练语料几乎全是英文,汉字的 3 个字节从来没得到过票数——于是「学」(U+5B66 = e5 ad a6)在 GPT-2 里被切成 2 个词元:一个 2 字节块 e5 ad + 一个孤零零的 a6(本机 tiktoken 实测,ID 27764 和 99)。

这两个碎片,单独都"不是字" e5 ad 不是合法的 UTF-8 字符——把它单独解码只会得到乱码符号 �。你如果在 ChatGPT 的流式输出里见过突然蹦出的 �,根源就是这个:第 1 课讲过,生成是一个词元一个词元往外吐的,某个词元恰好是半个汉字时,它解不成任何字,只能显示占位符。词表的碎片化,一路反映到了你屏幕上。

三、词表里的"中文票数",决定你的使用体验

同一个中文句子,在不同词表下切几刀?下面是本机实测(tiktoken 0.14 + Qwen 官方 tokenizer 文件):

词表(规模)代表模型「你好,世界!」(6 字符)「深度学习改变了很多行业。模型越大越聪明吗?」(21 字符)
gpt2(50,257)GPT-2(2019)14 个词元48 个词元
cl100k_base(100,277)GPT-3.5 / GPT-47 个词元28 个词元
o200k_base(200,019)GPT-4o4 个词元16 个词元
Qwen(约 151,665)Qwen 系列4 个词元12 个词元

同一句话:GPT-2 切 48 刀,Qwen 切 12 刀,差 4 倍。而英文句子 The cat sat on the mat. 在四家词表下都是 7 个词元——英文对谁都是"整词直接命中"。看 Qwen 的切法就知道差距在哪:我在 | 学习 | Transformer | 的 | 实现 | 机制 | 。——常用二字词整个进了词表,一字一词元只是下限。

为什么你在乎?因为现代 LLM 的计费、上下文窗口、生成速度,全都按词元算:同一份中文内容,GPT-2 词表下贵 4 倍、"装"不下、出字慢 4 倍。Qwen 技术报告(Qwen2, arXiv 2407.10671)明确把"多语言压缩率优于 GPT-4 / Llama 3"当作卖点,151,643 个词元里给中文留了大量票数。中国团队(Qwen、DeepSeek 等)重训词表,买的就是这个公平。

自己复核 OpenAI 三列可在此复算:选 gpt2 / cl100k / o200k 分别粘贴两句中文即可。Qwen 列需要本地跑 tokenizers 库加载官方词表文件,感兴趣我可以带你跑一遍。

四、亲手看词表"学会"中文

中文词元的诞生过程和第 2 课的 old/finest 一模一样——还是投票、焊接,只是开局零件从单字母变成了单字节。下面这个真实的字节级 BPE:语料 4 个中文词,做 26 刀合并,然后去切没见过的句子:

# ============================================================ # 【演示目标】亲眼看见"汉字词元"怎么从字节零件里长出来。 # 算法和第 2 课的 old/finest 一模一样,只是开局零件从"字母"换成"字节"—— # 由此你会同时看懂两件事: 汉字能被焊出来,GPT-2 只是因为没票才没焊出来。 # 【思路】三段: # 1. 开局: 每个中文词拆成 UTF-8 字节(每个汉字 = 3 个字节零件) # 2. 训练 26 刀: 数相邻对 → 焊接票王 → 重复。 # 盯着看三个瞬间(正文 win 卡片详解): # 刀 1-2 「学」的 3 个字节焊成整块 —— GPT-2 缺的正是这一步 # 刀 3 合并跨过汉字边界 —— BPE 不知道"字"在哪结束,"词"就这么诞生 # 刀 16 「深度学习」整个成语块 # 3. 切新句: 学过的字/词保留,生字裂回字节碎片(GPT-2 面对中文的处境) # 读输出的技巧: show() 把词元尽量解成汉字,解不出的半截字显示十六进制 # ============================================================ corpus = {"深度学习": 9, "学习模型": 7, "训练模型": 5, "好模型": 3} # 改我! num_merges = 26 # 改我!加大看更多词块长出来 def show(tok): # 词元 = 一段字节。能组成合法 UTF-8 就解成汉字给人看; # 解不出(半个汉字,如 e5 ad)就显示十六进制——那就是"碎片"的真身, # 也是 GPT-2 单独解码中文词元时输出 � 的原因 try: s = tok.decode("utf-8") return s if s else "''" except UnicodeDecodeError: return " ".join(f"{b:02x}" for b in tok) # ---- 开局: 每个词 → 一串"单字节词元" ---- # w.encode("utf-8") 得到字节串;bytes([b]) 把其中 1 个字节包成最小词元 words = {tuple(bytes([b]) for b in w.encode("utf-8")): f for w, f in corpus.items()} merges = [] # 合并规则清单(和第 2 课同一个角色) # ---- 训练循环: 数票 → 焊接票王(每轮叫"一刀") ---- for step in range(num_merges): # ① 数相邻对(乘词频)。"对"的两边可以是 字节、半个字、整字、词—— # BPE 不关心语义,谁相邻就数谁,这正是"词"能跨字长出来的原因 pairs = {} for toks, f in words.items(): for a, b in zip(toks, toks[1:]): pairs[(a, b)] = pairs.get((a, b), 0) + f if not pairs: break best = max(pairs, key=lambda p: pairs[p]) # ② 票王(并列取先扫到) merges.append(best) # ③ 全语料执行焊接: 相邻两词元命中 best 就拼接(bytes 相加 = 字节串连接) nxt = {} for toks, f in words.items(): out, i = [], 0 while i < len(toks): if i < len(toks) - 1 and toks[i] == best[0] and toks[i + 1] == best[1]: out.append(toks[i] + toks[i + 1]); i += 2 # 焊接,吃掉两个位置 else: out.append(toks[i]); i += 1 # 无关,照抄 nxt[tuple(out)] = f words = nxt print(f"第 {step+1:2d} 刀: {show(best[0]):<8} + {show(best[1]):<8} → {show(best[0]+best[1]):<10} (×{pairs[best]})") # ---- encode: 拿规则清单切全新句子 ---- # 先把新句变字节流,再按训练顺序重放规则——与第 2 课同一个逻辑 def encode(text): toks = [bytes([b]) for b in text.encode("utf-8")] for a, b in merges: out, i = [], 0 while i < len(toks): if i < len(toks) - 1 and toks[i] == a and toks[i + 1] == b: out.append(a + b); i += 2 else: out.append(toks[i]); i += 1 toks = out return toks print("\n切没见过的句子:") for s in ["深度好模型", "大模型行业", "强化学习"]: toks = encode(s) print(f" {s}({len(s)}字/{len(s.encode())}字节) → {len(toks)} 词元: {' | '.join(show(t) for t in toks)}") # 对照正文 win 卡片读: 深度好模型→2 词元(全是学过的块); # 大模型行业→生字「大/行/业」裂回字节碎片;强化学习→「学习」复用、「强/化」裂开
看懂输出里的三个关键瞬间 ① 刀 1–2:e5 ad 先焊出来,再吃进 a6——「学」从 3 个字节零件变成 1 个词元,这正是 GPT-2 没学到的那一步(没有票)。② 刀 3:学 + e4——合并跨过了汉字的边界!BPE 根本不知道"字"在哪结束,它只看相邻频次;而"词"恰恰就是这样长出来的(刀 5 出「学习」,刀 16 出「深度学习」)。③ 切新句:「深度好模型」只切 2 刀(15 字节 → 2 词元);但「大模型行业」里没学过的「大」「行」「业」裂回了字节碎片(那些十六进制块)——和 GPT-2 面对中文时的处境一模一样。算法从来不分语言,票数决定一切。

小结:中文分词的三个时代

时代方案一句话评价
词级时代jieba 先分词 → 词嵌入分词错误向后传染,新词追着词典跑
字级时代(BERT)每汉字 = 1 词元,词表 21,128汉字自带语义,字级在中文天然可行
字节级 BPE 时代(GPT-2 起)UTF-8 字节 + 投票合并,词表 5 万~20 万统一方案永不怕生词;票数决定中文体验

检索练习

本课主读材料

去读/看(约 15 分钟)

Let's build the GPT Tokenizer — Karpathy(fast.ai 全文整理):字节级 BPE 的权威讲解,含中文/多语言例子和流式乱码演示。配视频版(YouTube 搜 "Karpathy Let's build the GPT Tokenizer")。

中文视角补充:The Pitfalls of Chinese Tokenization in General-Purpose LLMs——专门讨论通用 LLM 词表对中文的坑,含 bert-base-chinese 对比。

动手复核:本课表格里 OpenAI 三列可用 Tiktokenizer 直接复算;想复算 Qwen 列,随时喊我带你本地跑。

我是你的老师,别客气 比如"为什么汉字不统一用 4 字节编码省事"或者"DeepSeek 的词表对中文也这么友好吗",贴回来问。下一个问题也欢迎——这门课的补充课就是这么来的。