从一条直线到大模型输出一个token(二):分词与token表

建议先看:从一条直线到大模型输出一个token(一):从一条直线到高维空间

上一篇,我们把 230B 参数压缩成了一条过原点的直线,又通过加截距、升维、引入矩阵,搭好了数学地基。但留了个尾巴: y = w ⋅ x + b y = w \cdot x + b y=wx+b 这种公式,怎么处理"今天西安的天气怎么样?"这样一句话?

这一篇就解决两件事:怎么把一句话拆开(分词),以及怎么把拆开的东西变成数字(token 表)。本篇开始出现少量代码——它们只是用来展示大模型的实现细节,跳过不影响理解。

1. 一句话进入大模型的第一步

如果问大模型:“今天西安的天气怎么样?”,它的第一步是什么?

不是"思考",也不是"查资料"。它的第一步非常机械:把这句话切成一个个小块,再把每个小块换成一个整数

为什么必须这么干?上一篇讲过,大模型的本质是矩阵运算。矩阵里的元素是数字,而"今天"两个字是字符——字符进不了矩阵。所以必须有一座桥:字符串在桥这头,整数在桥那头。

分词器(Tokenizer)就是这座桥。它干的活可以拆成两步:

  1. 切:把连续的文本切成一个个单元(token)
  2. 换:查 token 表,把每个单元换成一个整数(token id)

顺序不能乱:先切,后换。那怎么切?这就是分词的核心问题。

2. 三种传统分词方式

分词的定义很简单:把连续的文本序列切分成有限个单元的过程。难点在于:在哪里下刀。

最直观的三种传统方案,如图 2-1 所示。

三种传统分词方式对比

图2-1 同一句话的三种传统拆法

2.1 按字分词

把每个字(或字母)当成一个单元。"今天西安的天气怎么样?"变成 11 个单元。

优点:简单粗暴,永远不会遇到"词表里没有"的问题(生僻字再生僻,也是个字)。

缺点:单元太碎。两个字组合出来的语义,单个字表达不了——“西”+“安"和"西安”(城市)完全是两码事。而且序列太长,11 个字的句子有 11 个单元,一篇 1000 字的文章就有 1000 个单元,模型的计算量扛不住。

2.2 按字典分词

维护一部词典(像新华词典),拿句子去词典里匹配。"今天西安的天气怎么样?"匹配出:今天|西安|的|天气|怎么样|?,6 个单元。

优点:单元有完整语义,序列短。

缺点:遇到词典外的词(OOV,Out-Of-Vocabulary)就歇菜。比如"犇"“𰻝”(biáng,陕西名吃 biángbiáng 面的那个字)不在词典里,切词直接失败。而且词典是人工维护的,新词(“绝绝子”“citywalk”)天天冒出来,词典永远追不上。

2.3 字词混合分词

工程上的折中:先查词典,词典里有的切成词,查不到的退化成单字。

优点:兼顾语义和兜底,传统 NLP 时代的主流方案。

缺点:本质还是依赖人工词典,切分质量取决于词典质量,而且规则写死,没法从海量语料里自动进化。

三种方式的对比汇总如表 2-1 所示。

表2-1 三种传统分词方式对比

方式优点缺点适合场景
按字分词无 OOV 问题序列长,单字无语义字符级模型
按字典分词单元有语义,序列短OOV 致命,词典人工维护封闭领域
字词混合工程折中,有兜底仍依赖人工词典,无法自动进化传统 NLP

可以看到,三种方案各有硬伤。大模型需要的是:单元要有语义、序列不能太长、永远不 OOV、还要能从语料里自动学出来。这就是 BPE 登场的原因。

3. BPE:数据驱动的分词

3.1 核心思想

BPE(Byte Pair Encoding,字节对编码)的核心思想一句话就能说清:高频出现的相邻组合,应该合并成一个单元

"今天"这两个字总是挨在一起出现?那就把它们粘成一个 token。"天气"也总是连着?也粘上。频率就是唯一的裁判——完全数据驱动,不需要任何人工词典。

3.2 手算一遍 BPE

拿一个小语料手动跑两轮,胜过看十遍定义。语料就 4 句话:

今天天气好、今天去西安、今天怎么样、西安天气差

初始状态,所有文本按单字切开(词表 = 出现过的所有单字 = 11 个字):

今|天|天|气|好 今|天|去|西|安 今|天|怎|么|样 西|安|天|气|差

第一轮:统计所有相邻单元对的频率,取最高频的合并。过程如图 2-2 所示。

BPE手算第一轮

图2-2 BPE第一步:统计相邻对频率,合并最高频的(今, 天)

统计结果: freq ( 今 , 天 ) = 3 \text{freq}(\text{今},\text{天}) = 3 freq(,)=3 是唯一最高频对。合并!词表从 11 变成 12,多了一个新 token “今天”。

第二轮:语料更新为:

今天|天|气|好 今天|去|西|安 今天|怎|么|样 西|安|天|气|差

再次统计: freq ( 天 , 气 ) = 2 \text{freq}(\text{天},\text{气}) = 2 freq(,)=2 是唯一最高频对。合并!词表变成 13,多了"天气"。

第三轮:所有相邻对的频率都是 1,没有明显高频对了。真实场景下语料是数万亿 token 的规模,会继续合并几万轮,直到词表达到目标大小。我们的小演示到此为止,完整过程汇总如表 2-2 所示。

表2-2 BPE 两轮合并过程(语料:4句话)

轮次最高频相邻对频率合并结果词表大小
初始11 个单字11
第 1 轮(今, 天)3今天12
第 2 轮(天, 气)2天气13
第 3 轮所有对均为 1 次停止(真实语料会继续几万轮)13

注意一个精妙之处:合并"今天"之后,第二轮里 (今天, 天) 这样的新组合也会参与统计——每次合并都会产生新的相邻关系,雪球越滚越大。整个训练过程就是一个循环,如图 2-3 所示。

BPE迭代构建流程

图2-3 BPE词表构建:一个循环往复的统计游戏

3.3 伪代码:BPE 到底在干什么

核心逻辑用 Python 写出来只有十几行(真实实现要考虑工程优化,但原理就是这样):

from collections import Counter

def train_bpe(corpus, vocab_size):
    # 第 1 步:语料全部切成最小单元(这里用单字演示,真实 BBPE 用字节)
    words = [list(w) for w in corpus]
    vocab = {c for w in words for c in w}   # 初始词表 = 所有单字

    # 第 2 步:循环合并,直到词表达到目标大小
    while len(vocab) < vocab_size:
        # 统计所有相邻单元对的频率
        freq = Counter()
        for w in words:
            for a, b in zip(w, w[1:]):
                freq[(a, b)] += 1

        # 取频率最高的一对,合并成新 token
        (a, b), _ = freq.most_common(1)[0]
        new_token = a + b
        vocab.add(new_token)

        # 语料里所有 (a, b) 相邻对都替换成新 token
        for w in words:
            i = 0
            while i < len(w) - 1:
                if w[i] == a and w[i + 1] == b:
                    w[i:i + 2] = [new_token]
                i += 1
    return vocab

看到没有,没有一行硬编码的规则,全靠频率统计。这就是"数据驱动"的含义。

3.4 BPE 的优缺点

优点:高频组合自动合并(有"词"的语义)、词表大小可控、纯统计无需人工。

缺点:地基是"字符"。如果训练时没见过某个字符(生僻字、emoji、小语种文字),它连进场的资格都没有——这就是上一节按字典分词的老毛病 OOV,只是从"词表里没有这个词"变成了"词表里没有这个字"。

4. BBPE:字节级的事实标准

4.1 换个地基:从字符到字节

BPE 的 OOV 问题,解法简单得让人拍大腿:把地基从"字符"换成"字节"

任何文本在计算机里最终都是字节。UTF-8 编码下:

  • 一个英文字母 = 1 字节
  • 一个常用汉字 = 3 字节
  • 一个 emoji = 4 字节
  • 再生僻的字符,也不过是 4 个字节

而字节一共只有 256 种。把初始词表设成 256 个字节,任何字符都能被表示——OOV 从物理上被消灭了。这就是 BBPE(Byte-level BPE),对比如图 2-4 所示。

BBPE字节级

图2-4 从BPE到BBPE:把地基从「字符」换成「字节」

4.2 真实证据:GPT-4 词表里的字节碎片

这不是纸上谈兵。GPT-4 的 cl100k 词表里,"气"和"怎"就没被整字收录,被拆成了字节碎片:

  • “气”(UTF-8 三字节 e6 b0 94)→ 两个 token:[e6 b0] + [94]
  • “怎”(UTF-8 三字节 e6 80 8e)→ 两个 token:[e6 80] + [8e]

而高频的"么""样"都有整字 token。同一个词表,高频字是整块,低频字是碎片——这就是 BPE 频率原则在字节层面的直接体现。

4.3 为什么 BBPE 成了事实标准

GPT 系列(cl100k、o200k)、LLaMA、Qwen、DeepSeek、GLM……当前所有主流大模型,分词器全部是 BBPE 或其变体。原因就三条:

  1. 永不 OOV:256 个字节能拼出一切文本,包括代码、emoji、多种语言混排
  2. 压缩率由数据决定:高频组合自动合并成短 token,低频的自然退化成字节,词表大小完全可控
  3. 一套词表通吃所有语言:不用为中文、英文、日文分别建词表

5. token 表:字符串和整数的对照表

5.1 3000 个汉字的启发

一个有意思的现象:掌握 3000 多个常用汉字,就能覆盖日常阅读 99% 以上的内容。少量的"最小单元",通过组合就能表达无限的内容——汉字如此,token 也如此。

token 表(也叫词表,Vocabulary)就是把这些最小单元编号的对照表:

  • 左边一列:token 的字符串内容(“今天”、“西”、“e6 b0”……)
  • 右边一列:对应的整数 id(5237、23872……)

它是在模型训练之前单独训练好的(用 3.3 节的 BBPE 循环跑海量语料),训练好之后就固定不动,编码(文本→id)和解码(id→文本)都用它。

5.2 词表多大合适

各主流大模型的词表大小如图 2-5 所示。

各厂商词表大小

图2-5 主流大模型词表大小:3万~25万

汇总如表 2-3 所示。

表2-3 主流大模型词表大小(截至 2026)

模型词表大小说明
LLaMA-2 (2023)32,000偏小,中文效率低
Mistral-7B32,000与 LLaMA-2 同款
GPT-3.5/4 (cl100k)100,277约 10 万
LLaMA-3 (2024)128,256比 2 代扩了 4 倍
DeepSeek-V3 (2024)129,280约 13 万
DeepSeek-V4-Pro/Flash (2026)129,280沿用 V3 词表
GLM-4-9B151,552约 15 万,中文优化
Qwen2.5152,064约 15 万,中文优化
GPT-4o (o200k)200,019约 20 万
Claude 3.5/4.x~240,000Anthropic 未官方公开,社区估算
Gemma-2256,000当前最大档之一

词表大小是个权衡:太小,常见文本要拆得很碎(token 数多,计算贵);太大,嵌入层和输出层的参数会变多(第 3 篇和第 9 篇会讲到这两层,它们的参数量直接正比于词表大小)。目前主流平衡点在 13 万~25 万。

5.3 语言差异:词表里的"贫富差距"

词表收录了什么,直接决定了各种语言的"生存状况"。这正是 6.1 节要展开讲的实验,先埋个引子:同一句中文,国产 3 款大模型都只拆 6 个 token(比 GPT-4 少一半还多),而这一切的根源都在词表的中文语料占比。详细对比留到 6.1 节,看图 2-7 那张 5 模型对比柱状图。

这也是国产模型(Qwen、GLM、DeepSeek)普遍用 15 万左右大词表的原因——中文场景的 token 效率,是真金白银的计算成本。

6. 贯穿案例:拆解"今天西安的天气怎么样?"

现在,用真实工具把我们的贯穿案例完整拆一遍。

6.1 真实 tokenizer 的拆分结果

只测一家不放心,我们同时用 OpenAI 的 tiktoken 和 HuggingFace 的 transformers 库,把 4 款主流模型的 tokenizer 都跑一遍,代码如下:

import tiktoken
from transformers import AutoTokenizer

text = "今天西安的天气怎么样?"

# OpenAI 家族
for name, enc in [("GPT-4 (cl100k)", tiktoken.get_encoding("cl100k_base")),
                  ("GPT-4o (o200k)", tiktoken.get_encoding("o200k_base"))]:
    ids = enc.encode(text)
    print(f"{name}{len(ids)} token: {[enc.decode([i]) for i in ids]}")

# 开源大模型(HuggingFace 需联网)
for model_id in ["deepseek-ai/DeepSeek-V3", "Qwen/Qwen2.5-7B", "THUDM/glm-4-9b-chat"]:
    tok = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
    ids = tok.encode(text, add_special_tokens=False)
    print(f"{model_id:30s}{len(ids)} token: {[tok.decode([i]) for i in ids]}")

实际跑出来的结果,5 款模型的拆分对比如表 2-4 所示。

表2-4 同一句话,5款主流模型的真实 token 拆分

模型token 数拆分结果([token] id)
GPT-4 (cl100k)13[今][天][西][安][的][天][e6b0][94][e680][8e][么][样][?]
DeepSeek-V36[今天 5237][西安 23872][的 301][天气 16652][怎么样 19602][? 1148]
GLM-4-9B6[今天 99637][西安 102257][的 98314][天气 101791][怎么样 103753][? 11314]
Qwen2.5-7B6[今天 100644][西安 102178][的 9370][天气 104307][怎么样 104472][? 11319]
GPT-4o (o200k)7[今天 47256][西 13781][安 7837][的 1616][天气 167823][怎么样 34633][? 4802]

国产 3 款都只拆 6 个 token,比 GPT-4 少了一半还多——同样的中文文本,训练语料以中文为主的模型,压缩率就是高。最直观的对比是图 2-6。

同一句话各模型拆分对比

图2-6 同一句话,5款主流模型的 token 数对比

再看两代 GPT 词表的字节级细节对比(cl100k vs o200k),如图 2-7 所示。

真实拆分对比

图2-7 cl100k vs o200k 字节碎片对比

GPT-4 的 cl100k 把"气"和"怎"拆成字节碎片(红框行),13 个 token;GPT-4o 的 o200k 整词收录,7 个 token。

系列后续所有矩阵计算,都基于 DeepSeek-V3 拆出的这 6 个 token(今天/西安/的/天气/怎么样/?)——国产词表对中文的压缩率最好,6 个 token 每个都有完整语义,矩阵画出来也最干净。

6.2 查表:得到 token 索引数组

最后一步,查 token 表,把 6 个 token 换成 6 个整数(以 DeepSeek-V3 词表为例),如图 2-8 所示。

token表查表

图2-8 token表 = 字符串和整数的一一映射表

到这里,"今天西安的天气怎么样?"这 11 个字符,变成了一个长度为 6 的一维整数数组:

X = [ 5237 ,   23872 ,   301 ,   16652 ,   19602 ,   1148 ] \mathbf{X} = [5237,\ 23872,\ 301,\ 16652,\ 19602,\ 1148] X=[5237, 23872, 301, 16652, 19602, 1148]

这就是大模型真正"看到"的输入。不是文字,不是拼音,是这么一个干巴巴的整数数组。

小结

这一篇,我们打通了"字符串世界"到"数字世界"的桥:

  • 分词三传统方案(按字/按字典/字词混合)各有硬伤,大模型需要的是数据驱动的方案
  • BPE 的本质:高频相邻对合并,一个循环往复的统计游戏
  • BBPE 把地基换成字节(256 种),从物理上消灭 OOV,成为事实标准
  • token 表是字符串和整数的对照表,各厂商词表在 3 万~25 万之间
  • 词表的语言构成决定 token 成本:同一句话,GPT-4 词表要 13 个 token,GPT-4o 只要 7 个
  • 贯穿案例定格:[5237, 23872, 301, 16652, 19602, 1148](DeepSeek-V3,6 个 token)

下一篇预告

拿到 token 数组之后,问题马上来了:5237 只是一个编号,它没有任何语义。模型怎么知道"今天"和"天气"有关系,"西安"是个城市?

下一篇讲嵌入层(Embedding):把每个整数 id 变成一个 4096 维的向量,让语义相近的词,在几何空间里也彼此靠近。届时会看到那个著名的例子:国王 − 男人 + 女人 ≈ 女王。

顺便剧透:那个 4096 维的向量,就是第一篇里"升维"思想真正的用武之地。

系列目录:

  1. 从一条直线到高维空间
  2. 分词与 token 表(当前篇)
  3. 隐藏层与嵌入
  4. 位置编码 RoPE
  5. Transformer Block 全景与层归一化
  6. QKV 三剑客
  7. 注意力权重与多头机制
  8. 残差连接与前馈网络
  9. 输出矩阵与多层堆叠
  10. 首 token 诞生与 KV-Cache

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

更多推荐