把黑箱拆开:手搓一个大语言模型 书架

第 02 章 / 共 16 章

第二章 · 文字怎么变成数字:切词与编号

你现在正读着这行字。你的眼睛扫过一个个方块汉字,毫不费力地知道"苹果"是一个东西、"跑"是一个动作。可计算机不认识字。它只认识数字——准确说,只认识由 0 和 1 堆出来的数字。所以在上一章我们说"LLM 只干一件事:猜下一个词"之前,有个更土的问题必须先解决:那个"词",到底是怎么变成一串它能算的数字的?

这一章我们把这个"词"——也就是 token(词元)——彻底拆开看。它是整本书里最不起眼、却最无处不在的东西:模型的输入是它,输出是它,计费按它算,上下文长度按它量,训练数据按它数。把它看清楚了,后面所有章节的单位才算统一了。

先把句子剪成积木块

假设你要把一句话交给一台只认识数字的机器。最笨的办法是给每个字一个编号:中文常用字几千个,那就 0 到几千号。英文更省,二十六个字母加标点,几十个号搞定。听起来行得通,但很快会出问题——后面会讲为什么。先记住这个动作本身有个名字。

分词(Tokenization),就是把一段连续的文字,切成一小块一小块的过程。切出来的每一块,叫一个 token(词元)——你可以把它想象成语言的"积木块"。一块可能是一个完整的词,也可能只是半个词、一个字、甚至一个空格、一个标点。

token 不是"词"。别被翻译骗了。它是模型处理文字的最小单位,是人为切出来的一块砖,不一定对应你直觉里的"一个词"。

切完之后,第二步简单得可笑:给每一种不同的积木块,发一个固定的编号。所有编号凑成一张大表,叫 词表(vocabulary)——本质就是一本字典,左边是积木块,右边是它的编号。于是"苹果"可能变成 15043,"跑"变成 892。模型自始至终看到的都不是字,而是这一串编号。

一句 "我爱苹果",经过分词器(tokenizer)先被切成 ["我", "爱", "苹果"],再查词表换成编号 [2769, 4263, 15043]。喂给模型的,就是最后这串数字。模型吐出来的,也是数字,比如 892,再反查词表变回 "跑"给你看。整个"理解语言"的过程,两头都是查表翻译,中间全是数字运算。

顺便说个直观的换算:在主流模型里,一个 token 大约对应四分之三个英文单词,或者一个左右的汉字。所以"100 个 token"大概是一段七八十字的中文,或七十五个单词的英文——也就两三句话。这个比例先放心里,待会儿要用。

为什么不整词一个编号,也不单字一个编号

现在回到刚才那个"很快会出问题"的坑。切词有两个极端方案,两个都糟。

第一个极端:一个完整的词发一个编号(英文里叫 word-level)。听起来最自然,但英语里光是常见词就有几十万,还不算 running/runs/ran 这种同一个词的各种变形,每个都得单独占一个号。词表会膨胀到几十上百万。更致命的是未登录词(OOV, out-of-vocabulary)——就是"表里没有的词"。用户打一个词表里没收录的新词、拼错的词、生造的词,机器直接傻眼:这块积木不在我字典里,没编号,处理不了。而语言天天在造新词,这个窟窿永远堵不完。

第二个极端:一个字符发一个编号(character-level)。英文就 26 个字母,词表小到可怜,也永远不会遇到"表里没有"的字符。听着完美?代价是把语言切得太碎了。apple 五个字母,模型得自己从 a-p-p-l-e 这五块毫无意义的碎片里,重新拼出"苹果"这个概念。一句话本来十几个词,现在变成几十上百个字符,模型要处理的积木块数量暴涨,还得花大力气去学"哪些字母凑一起才算个意思"。太累,太浪费。

整词太胖:词表爆炸,还怕遇到没见过的词。单字太瘦:永远不怕生词,但把话剁成了肉末,模型累死。理想方案得在胖和瘦之间。

折中的聪明活:切成"子词"

真正被广泛采用的,是介于两者之间的 子词分词(subword tokenization)。核心思想一句话:常见的词整块保留,罕见的词拆成更小的常见零件。

怎么决定哪些该整块、哪些该拆?不靠人拍脑袋定规则,而是让算法在海量文本里数数。有一套经典办法叫 BPE(Byte Pair Encoding,字节对编码),它的逻辑朴素得像小孩搭积木。我们拿一个迷你语料现场演示一遍:

假设全部训练文本只有四个词,出现次数是:low 5 次、lower 2 次、newest 6 次、widest 3 次。第一步,把每个词拆成单个字符。然后反复做同一件事:统计哪两个相邻块一起出现得最频繁,就把它们合并。数一遍,es 挨着出现 9 次(newest 里 6 次、widest 里 3 次),全场最频繁——合并成 es;再数,est 挨着 9 次——合并成 est;接着 lo 合并成 lolo 再和 w 合并成 low……几轮下来,estlow 都成了独立的积木块。于是再来个没见过的新词 lowest,不用慌,直接切成 low + est 两块现成的零件。

真实的 BPE 干的就是这件事,只不过语料是几百亿词、合并几万次。那些高频组合——常见的词根、词缀、整词——自然而然长成了独立的积木块,而没长成的稀有组合,就还是一堆小碎片。

结果就是:apple 这种天天见的词,会作为一整块留在词表里,一个编号搞定。而一个生僻词,比如 tokenization,可能被切成 token + ization 两块;再冷门些的、模型从没整块见过的词,会被拆得更碎,比如 [" un", "believ", "ability"] 这样。这就是为什么生僻词会被拆碎——不是机器歧视它,而是它没常见到"配得上"一个自己的编号。

这套机制有个漂亮的副作用:OOV(表里没有的词)问题被彻底干掉了。因为无论你打出多离谱的词,最坏情况也能一路拆到单个字符(甚至单个字节),而字符和字节的数量是有限且固定的,全都在表里。再没有"这块积木我不认识"的时刻了。任何词都能被表达,代价只是拆得碎一点。代价也体现在价格上:越生僻的表达,拆出的 token 越多,而模型的一切开销都按 token 计。

一个 token 里,到底装了多少信息

现在到了本章最值得停下来想一想的地方。一个 token,看起来不过是词表里的一个编号,能有多大信息量?惊人地大——只要你算一笔账。

主流大模型的词表大约是十万个 token。也就是说,模型每生成一个字块,本质上是在做一道十万选一的选择题:从十万种可能的下一块里,挑出"对的"那一块。而信息论告诉我们,从 N 个等可能的选项里确定一个,所携带的信息量是 log₂N 个 比特(bit)——计算机信息的最小单位,一次"是或否"的回答。十万个选项,log₂(100000) ≈ 16.6 比特

16.6 比特是什么概念?抛硬币,猜正反,猜对一次得到 1 比特信息。模型每吐出一个 token,相当于连续猜对十六七次硬币的正反面。它回答你一个问题,轻轻松松写了 2000 个 token——那就是连续猜对三万三千多次硬币,次次都中。换成另一杆秤:一副洗匀的扑克牌有 52! 种排列,从中猜中你手里那一把的顺序,约等于 226 比特——也只值模型说上十四个 token。

当然,严格说这十万种选择不是等可能的——"今天天气很"后面接"好"的概率远高于接"氢弹",所以平均每个 token 实际携带的信息没有 16.6 比特那么满。但反过来说,正因为模型面对的是真正的十万选一,而不是从两三个候选里挑顺眼的,它每一次选择都可能出乎你的意料——这正是它"会说人话"的数学地基:它的表达空间,大得近乎自由。

别把"一个编号"看扁了。编号本身是死的,但"从十万个编号里选中这一个"这个动作,一次就顶你连猜对十六次硬币。一段话几千个 token,就是几万个这样的抉择连成串——信息密度就是这么堆出来的。

再往上叠一层,尺度会更吓人。训练一个大模型要"读"多少 token?GPT-3 是 3000 亿个,后来几代模型是十几万亿个。十几万亿 token 是多少文字?一本书大约 15 万个 token,十几万亿 token 相当于一亿本书——而人类有史以来出版过的全部书籍,估计也就一亿三千万种上下。换句话说,这些模型在几个月的训练里"读"掉的文字,和整个人类文明几千年来写下的书的总量,是同一个量级。而你自己呢?一个人从识字到八十岁,听、说、读、写全算上,一辈子经手的文字折成 token 大约是十亿量级——不到训练集的万分之一。它为什么显得什么都懂一点?因为它"见过"的话,比你多出四五个数量级,而这"见过"的每一次,都是一次十万选一的练习。

这笔账还有一个务实的结果:token 成了这个行业通用的度量衡。你在 API 账单上看到的费用,按 token 计;模型"一次能看多长",按 token 量;生成速度的快慢,按"每秒多少 token"算。下次看到"上下文 128k tokens",别再脑补成"12.8 万个字"——它是 12.8 万块积木,英文大约是一本两百页的书,中文大约是十几万字、一部长篇小说的篇幅。整个行业的成本、速度、能力的标尺,都钉在这块小小的积木上。

这解释了几件你可能纳闷过的事

为什么模型对某些字符拼写会犯迷糊?比如数 strawberry 里有几个 r 会出错。因为它压根没逐个字母地看这个词——它看到的是两三块子词积木,字母藏在积木内部,它不一定拆得开。

为什么中文和英文"计费"感觉不一样?主流大模型按 token 数收费和计算。英文一个常见词往往就一个 token,而中文因为字符集庞大、组合多,常常是一个汉字占一个 token,生僻字还会被拆成多个字节 token。所以同样一段话,中文消耗的 token 数往往比你以为的多。这不是玄学,就是分词器切出来的块数不同。

所以本质上,它就是……

所谓"让机器读懂文字",第一步毫无浪漫可言:把连绵的语言剪成一堆预先编好号的积木块,再把号码串成一列数字递进去。机器从头到尾没见过一个"字",它见到的只是 [2769, 4263, 15043] 这样的整数序列,然后对这串整数做运算,再吐出一个整数,我们再翻译回字给自己看。

可这里有个新问题:编号 15043(苹果)和编号 892(跑)之间,除了大小不同,没有任何关系。15043 不比 892 更"甜",也不更"红"。注意,这跟前面说的"信息量惊人"并不矛盾——惊人的是"从十万里选中这一个"这个动作,而不是门牌号本身。门牌号是死的、任意的,纯粹是查表查出来的:它完全不携带"意思"。可我们明明希望机器知道"苹果"和"香蕉"沾点边、和"跑"八竿子打不着。光有门牌号不够,还得给每个词一个能表达"含义"的坐标。这,就是下一章的事。

把黑箱拆开:手搓一个大语言模型 · 王建硕
面向对世界有好奇心的人 · 费曼式写法 · 由多个 AI 代理撰写与互相审校