基础补课 · 一个「你」,五串字节:字符集管编号,编码管存储
第 23 篇给过这条链子:
文本 → [tokenizer] → 整数序列 → [模型] → 整数 → [decode] → 文本那一篇的词表是 65 个字符,一个字符一个 ID,stoi 直接查。真实模型不这么干 ——
它的词表从语料统计出来,而统计的对象是字节。下一段 BPE 的第一行就是:
tokens = list(text.encode("utf-8"))text.encode("utf-8") 这一步在视频里花了几分钟,这一篇把它讲完。用得上它的地方不止分词:
清洗中文语料、去重、判断一份老文件为什么打开是乱码,都绕不开。
一句话版本:字符集管「编号」(这是第几号字),编码管「存储」(这个号码写成哪几个字节)。 Unicode 只管编号,UTF-8 / 16 / 32 只管存法,ASCII 和 GB 系列两样一起管。
一个字节装不下一个汉字
计算机只存 0 和 1。8 位 = 1 个字节,能表示 0—255 共 256 个数。要存文字,就得先约定 「哪个字符对应哪个数」。ASCII(1963)做的就是这件事:128 个字符,编号 0—127,一个字节装得下。
'A' = 65 'a' = 97 '0' = 48 空格 = 32 换行 = 10第 23 篇那句「词表按 ASCII 排序:0 是换行,1 是空格,然后标点、大写、小写」,
排的就是这张表 —— 换行 10 < 空格 32 < 标点 < 大写 65 起 < 小写 97 起。
128 个只够英文。于是各国各自扩展:中国大陆 GB2312(1980,6763 个常用汉字)→ GBK(1995,扩到两万多)、 台湾 Big5、日本 Shift_JIS。彼此不兼容,而且各自的编号是自己一套。
🔑 乱码:同一串字节,按不同的约定读
这两行是可以直接跑的:
"你好".encode("utf-8").decode("gbk") # '浣犲ソ'bytes.fromhex("efbfbd efbfbd").decode("gbk") # '锟斤拷'第一行:UTF-8 存出来的 6 个字节,被当成 GBK 两两一组去读,读出三个别的字。
第二行更绕 —— EF BF BD 是 UTF-8 里那个替换字符 � 的字节,两个 � 共 6 个字节当 GBK 读,
正好是「锟斤拷」。所以「锟斤拷」是乱码的乱码:先有字节坏掉变成 �,再把 � 用错编码读一遍。
🔑 字节本身不带「我是哪种编码」的信息。乱码从来不是字节坏了,是读的人用错了约定。
两层:字符集管编号,编码管存储
同一个「你」,四行就能把两层讲清楚:
| 「你」 | 由谁决定 | |
|---|---|---|
| 编号(码点) | 20320(十六进制 U+4F60) | Unicode 字符集,全世界统一,只有这一个号码 |
| 存储:UTF-8 | E4 BD A0 → 3 个字节:228、189、160 | UTF-8 的规则 |
| 存储:UTF-16 | 4F 60 → 2 个字节:79、96 | UTF-16 的规则 |
| 存储:UTF-32 | 00 00 4F 60 → 4 个字节:0、0、79、96 | UTF-32 的规则 |
Python 里正好对应两种类型 —— str 是一串码点,bytes 是一串字节:
s = "你"ord(s) # 20320 —— 编号s.encode("utf-8").hex(" ") # 'e4 bd a0' —— 存储(UTF-8)s.encode("utf-16-be").hex(" ") # '4f 60' —— 存储(UTF-16)b"\xe4\xbd\xa0".decode("utf-8") # '你' —— 字节变回码点
encode= 码点 → 字节,decode= 字节 → 码点。方向记反了,报的错会很不直观。
为什么 ASCII 时代不用分
把字节想成只能装 0—255 的小盒子:
- ASCII:编号最大 127,一个盒子就装得下。「A」编号 65,存下来也是一个字节 65 —— 编号就是字节,两层重合成一层,没有分开的必要
- Unicode:编号一直排到 1,114,111(
0x10FFFF),「你」= 20320,一个盒子装不下, 于是「怎么拆进几个盒子」成了另一个问题 —— 这才需要第二层
三种 UTF 就是三种拆法:UTF-32 一律 4 个盒子、前面补 0;UTF-16 一般 2 个; UTF-8 小数字 1 个、大数字 2—4 个,而且每个盒子还要贴一个「我是第几个」的标记位。
❗ 不是平行的:比什么决定答案
ASCII、GBK、UTF-8、UTF-16、UTF-32 这五个名字经常被并排列出来,但它们不在同一层。 关键是看你在比什么:
| 比什么 | 平行吗 | 为什么 |
|---|---|---|
| 最后存成的字节 | ✅ 平行 | 五个都是「字→字节」的完整方案,存文件时五选一(VS Code 右下角显示的就是这个) |
| 怎么变成字节 | ❌ 不平行 | ASCII、GBK 查自己的表;三种 UTF 先查 Unicode 拿到 20320,再各自存 |
所以「Unicode」不是第六种存法,它是三种 UTF 共用的第一步。这也决定了两件不对称的事:
- UTF-8 ↔ UTF-16 ↔ UTF-32 按公式互转,因为三者用的是同一个编号
- GBK ↔ UTF-8 只能查对照表,因为两边的编号根本来自不同的表
GBK 也分两步,只是写在一个标准里
GBK 不是「不分层」,是把两层写进了同一份标准。「你」在 GB 表里是第 36 区第 67 位,
区号、位号各加 160 → 196、227,也就是 C4 E3:
b = "你".encode("gbk") # b'\xc4\xe3'b[0] - 160, b[1] - 160 # (36, 67) —— 区号、位号这个「36 区 67 位」跟 Unicode 的 20320 毫无关系,所以上面那条「只能查表」是必然的,不是实现偷懒。
💡 GB18030(2000)覆盖全部 Unicode 字符,可以看作「兼容 GBK 的另一种 Unicode 存法」——
"你".encode("gb18030")也是C4 E3,但 emoji 这类 GBK 存不进去的字它能存。
UTF-8 的模板与手算
| 码点范围 | 字节数 | 字节模板 | x 的位数 | 例子 |
|---|---|---|---|---|
| 0 — 127 | 1 | 0xxxxxxx | 7 | A = 65 → [65] |
| 128 — 2,047 | 2 | 110xxxxx 10xxxxxx | 11 | é = 233 → [195, 169] |
| 2,048 — 65,535 | 3 | 1110xxxx 10xxxxxx 10xxxxxx | 16 | 你 = 20320 → [228, 189, 160] |
| 65,536 — 1,114,111 | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 21 | 👋 = 128075 → [240, 159, 145, 139] |
每一行的「x 的位数」都正好够装这一档的上界:7 位到 127、11 位到 2047、16 位到 65535,一个不多一个不少。 只有第四行对不上 —— 21 位能装到 2,097,151,Unicode 的上限却停在 1,114,111。 多出来的那一截不是留给以后用的,是被砍掉的:上限是 UTF-16 定死的(后面「代理对」那一节会算出这个数), UTF-8 最早能写到 6 个字节,2003 年的 RFC 3629 把它截到 4 个字节、只编码「UTF-16 够得着的范围」, 超出去的写法一律非法。
手算「你」四步
① ord('你') = 20320,落在 2,048—65,535 → 3 字节模板,一共 16 个 x 位② 20320 写成 16 位二进制 → 0100111101100000③ 按模板切成 4 | 6 | 6 位 → 0100 111101 100000④ 依次填进 x 位 → 11100100 10111101 10100000 228 189 160拿 Python 验一遍,list("你".encode("utf-8")) 就是 [228, 189, 160]。
换「好」(22909)自己算一次,答案是 [229, 165, 189](E5 A5 BD)。
反过来读字节
不需要额外的长度信息,长度就写在第一个字节的开头:
| 开头 | 含义 |
|---|---|
0xxxxxxx | 单字节字符(就是 ASCII) |
110xxxxx | 2 字节字符的开头 |
1110xxxx | 3 字节字符的开头 |
11110xxx | 4 字节字符的开头 |
10xxxxxx | 后续字节,不能当开头 |
[228, 189, 160]:228 = 1110 0100,三个 1 一个 0 → 3 字节字符的开头,
带上后面两个 10 开头的字节,把彩色位拼回 0100 111101 100000 = 20320 → 「你」。
❗ 一个字节装得下的数,UTF-8 也不一定让你直接装
「装得下就直接装」只对 0—127 成立。é = 233,数值上一个字节装得下,UTF-8 却不让:
233 = 1110 1001,开头是 1110 —— 读的时候会被当成 3 字节字符的开头。所以它只能走 2 字节模板:
é = 233 = 00011 101001(11 位) → 110 00011 | 10 101001 = C3 A9 → [195, 169]西欧的老编码 Latin-1 恰恰是把 é 直接存成一个字节 233,两边混用就是另一种常见乱码:
"é".encode("utf-8").decode("latin-1") # 'é' 两个字节被当成了两个字🔑 UTF-8 把 128—255 整段让给了标记位,一个字节只剩 0—127 能原样装 —— 这是它兼容 ASCII 的代价。 同一个设计(每个字节都要拿出几位做标记)也是汉字要占 3 个字节的原因:2 字节模板只剩 11 位装码点,装不下 20320。
❗ 半个汉字为什么是 �
[228, 189]:开头声明有 3 个字节,实际只来了 2 个。声明和事实矛盾,解码器没有别的选择,
只能报一个替换字符 U+FFFD,也就是那个 �:
bytes([228, 189]).decode("utf-8", errors="replace") # '�'字节流被截断(读文件按固定长度切块、网络包切半、日志按字节数截断)都会撞上它。 BPE 里那些「字节碎片」也是同一回事 —— 一个汉字的 3 个字节没被合并成整块, 单独拿出来任何一个都不是合法的 UTF-8。
UTF-8 为什么成了主流
三条都直接来自上面那张模板表:
- 兼容 ASCII:1 字节模板
0xxxxxxx就是 ASCII,纯英文文件在两者下一个字节都不差 - 英文最省:一个字母 1 个字节,UTF-16 要 2 个、UTF-32 要 4 个
- 容错好:
10开头的一律是后续字节,所以从任何位置开始读,往后跳过10开头的就能找到下一个字符的起点 —— 坏一个字节只影响一个字,不会让后面整篇错位
UTF-16、UTF-32 与三种「长度」
名字里的数字是单位的大小,不是「一个字占几个字节」:UTF-8 的「8」指 8 位 = 1 个字节, 一个字按码点大小用 1—4 个单位(「A」1 个、「你」3 个);UTF-16 的单位是 2 个字节,UTF-32 是 4 个字节。
- UTF-32:每个字符固定 4 个字节,直接存码点的数值。最简单,但英文多占三倍
- UTF-16:2 个字节一个单元。码点 ≤ 65,535 的字符(包括常用汉字)用 2 个字节,更大的用 4 个
中文在 UTF-16 里反而更短
「你」= 4F 60,2 个字节,比 UTF-8 的 3 个还少。同一段文字在三种存法下的实测:
| 字符数 | UTF-8 | UTF-16 | UTF-32 | |
|---|---|---|---|---|
hello world | 11 | 11 | 22 | 44 |
你好,世界 | 5 | 15 | 10 | 20 |
안녕하세요 | 5 | 15 | 10 | 20 |
👋 | 1 | 4 | 4 | 4 |
纯中文确实是 UTF-16 更省。但文件、网页、Linux 基本都是 UTF-8 —— 因为真实文本里混着标签、标点、 代码、英文,而这些在 UTF-8 下是 1 个字节、在 UTF-16 下是 2 个。兼容 ASCII 这一条的分量, 比中文省 1 个字节重得多。
字节序与 BOM
一个 2 字节的数,可以先存高位(大端 BE)也可以先存低位(小端 LE)—— 连 UTF-16 自己都有两种字节:
4F 60 和 60 4F。为了标明是哪种,文件开头可以多放两个字节:
list("hi".encode("utf-16"))[:2] # [255, 254] —— BOM,标明小端所以数 UTF-16 的字节数时要注意:encode("utf-16") 比 encode("utf-16-le") 多 2 个字节,
上面那张表用的是 -le 版本,去掉了 BOM。
❗ 同一个字符串,三种「长度」
Java / Kotlin、JavaScript 的字符串内部用 UTF-16,Python 3 的 str 是码点序列,
而存到文件里是 UTF-8 字节。三个数可以都不一样:
| 字符串 | Python len(码点数) | Java / JS length(UTF-16 单元数) | UTF-8 字节数 |
|---|---|---|---|
"A" | 1 | 1 | 1 |
"你" | 1 | 1 | 3 |
"👋" | 1 | 2 | 4 |
emoji 那行是写 Java / JS 时的经典坑:"👋".length 是 2,substring(0, 1) 会切出半个字符。
「一个字符」这个说法本身就不精确,得说清是码点、UTF-16 单元,还是字节。
👋 在 UTF-16 里为什么占 2 个单位:代理对
UTF-16 的单位是 16 位,码点 ≤ 65,535 就原样放、不加任何标记(「你」直接存成 4F 60)。
超过 65,535 的放不下,就拆成两个单位、两个都带标记 —— 这叫代理对。
以「𠮷」(「吉」的异体字,码点 134071)为例:
① 先减 65,536: 134071 − 65536 = 68535,正好 20 位 0001000010 | 1110110111 ← 前 10 位 | 后 10 位② 前 10 位加标记 110110: 110110 0001000010 = D8 42③ 后 10 位加标记 110111: 110111 1110110111 = DF B7→ UTF-16 = D8 42 DF B7(2 个单位,4 个字节)读的时候怎么认出这是一对?Unicode 专门把 D800—DFFF 这段编号留出来、永不分给任何字: 落在 D800—DBFF 的是一对里的前一半,DC00—DFFF 是后一半,其余的值就是一个完整的字。 UTF-8 靠每个字节开头的标记位认边界,UTF-16 靠一段保留不用的编号。
🔑 Unicode 的上限就是从这里算出来的:20 位最多表示 1,048,576 个数,加上直接放的 0—65,535, 最大码点正好是 1,114,111。前面模板表里 UTF-8 第四行 21 位本来能写到 2,097,151 —— 被截掉的就是这一截。
码点是枢纽
不管字节是哪种格式,按对应的规则解码都回到同一个码点;从码点出发,又能存成任何一种格式:
UTF-8 字节 ─┐ ┌─→ UTF-8 字节UTF-16 字节 ─┤ decode encode ├─→ UTF-16 字节UTF-32 字节 ─┼──────────→ 码点 20320 ──┼─→ UTF-32 字节GBK 字节 ─┘ (查表) └─→ GBK 字节(查表)所以转编码永远是两步:先解码成码点,再换一种方式编码。中间那一步不能跳:
text = open("old.txt", encoding="gbk").read() # GBK 字节 → 码点open("new.txt", "w", encoding="utf-8").write(text) # 码点 → UTF-8 字节这条路走得通有三个前提:得知道原来是哪种编码(字节不会告诉你)、
字节必须完整合法([228, 189] 换算不出码点)、GB 系列要查表,
而 GBK 只收了两万多个汉字,emoji 这类它没有的字根本存不进去(GB18030 没这个问题)。
❗ 光看字节,看不出当初用的是哪种编码
"你".encode("utf-16-be").decode("utf-8") # 'O`'4F 60 这两个字节,按 UTF-16 读是「你」,按 UTF-8 读是两个 ASCII 字符 O 和 `(79 和 96)。
两种读法都合法,都不报错,只有一种是对的。
这就是为什么各处都必须显式标明编码:网页写 <meta charset="utf-8">,
Python 读文件写 open(path, encoding="utf-8"),VS Code 右下角显示当前文件的编码。
decode 不报错不等于读对了。
回到分词器:底座是 256 种字节
现在可以把 BPE 的第一行读懂了:
tokens = list(text.encode("utf-8")) # 「你」→ [228, 189, 160]任何文本进分词器之前,先变成一串 0—255 的整数。 BPE 再在这串字节上数「哪一对相邻的出现得最多」, 合并成新条目。
第 23 篇那句「拆到字符」要改成「拆到字节」
第 23 篇写 BPE 的四步时说「初始词表 = 所有单个字符」、罕见的词「一路拆到字符」—— 那是教材口径。真实的字节级 BPE(GPT-2 起)底座是 256 种字节,已经改过来了。
差别不只是措辞。「拆到字符」的话,「永远不会有 OOV」是一句需要兜底表的承诺 —— 遇到一个词表里没有的生僻字怎么办?而「拆到字节」之后它变成机械事实:
🔑 一个字节只有 256 种可能,词表把这 256 种全收进去,任何字节序列都必然可以被表示。 不需要
<UNK>,也不需要兜底规则 —— 不是「设计得好」,是「没有别的可能」。
每个汉字的起点是 3 个字节
这条直接决定中文的 token 账。一个常用汉字是 3 个字节,所以:
- 合并成一整块 → 1 个 token
- 完全没合并 → 3 个 token(三个字节碎片)
分词是大模型许多怪现象的根源。 这句话 15 个字符、45 个 UTF-8 字节(实算)。
一个字都没被合并,它就是 45 个 token;每个字都合成了整块,是 15 个 —— 真实的分词器落在两者之间,
落在哪里取决于它学过多少中文。换个分词器能差多少,下一篇有 OpenAI 公布的数字。
⚠️ 这里只讲机制,不换算成「中文比英文贵几倍」。一个汉字实际花几个 token,取决于分词器训练时 见过多少中文语料、把哪些字合并成了整块 —— 那是 BPE 训练的事,不是 UTF-8 的事。 UTF-8 只定下界和上界。
训练数据要先统一成 UTF-8
如果语料里混着 GBK 和 UTF-16 的文件,同一个「你」就会变成 C4 E3、4F 60、E4 BD A0 三串完全不同的字节。
模型看到的是三个互不相干的符号,会当成三个不同的东西去学。
所以清洗网页语料的第一步就是统一编码 —— 而由上面那条,这一步必须是「先按原编码解码,再按 UTF-8 编码」, 不能在字节上直接替换。
这一篇没有引入任何新机制,只是把「文本 → 字节」这一步拆开: 字符集给字编号,编码把编号存成字节,码点是两者之间唯一的枢纽。
一句话版本:编号只有一个,存法有五种;字节不带自我说明,所以编码必须显式标明。
🔮 下一段就在这 256 个字节上做合并 —— 数哪一对相邻字节出现得最多,合并成新条目, 重复到词表够大。
encode之后的事从那里开始。