在3000米高空看完大语言模型(LLM)的基本结构后,略懂了模型会先在海量文本上进行预训练,并通过“预测下一个 token”这一看似简单的任务,逐步学习语言规律、语义关系和上下文信息。如今许多主流 LLM 都采用基于 Transformer 的纯解码器架构,预训练完成后,还可以继续通过微调,让模型更好地遵循指令或适应特定任务。

当你向 ChatGPT 提问时,它真的能“读懂”文字吗?其实,大模型既不认识汉字,也不理解单词,它看到的只是一个个数字。本文将用通俗易懂的方式,带你了解文本如何一步步变成向量,并最终成为大模型能够理解的信息,你懂了这些原理,就明白为什么在写与LLMprompt engineering(提示词工程)时会有一些规范,也帮你更好地驾驭和掌控LLM

1. 什么是词嵌入和文本分词

1.1 词嵌入(Word Embedding

LLM以及其他深度神经网络无法直接对原始文本进行数学计算,因为文本由离散(不连续的)的字符、词语和符号组成,而神经网络真正能够处理的是数值张量(先理解为向量或矩阵,后面会解释)。因此,在文本进入模型之前,必须先将其转换为数值化的张量表示。可以把这个过程理解为:当我们想描述一位博主时,不需要每次都把真人“搬到”模型面前,而是提取与他相关的一系列特征,例如外貌、性格、表达风格和兴趣偏好等,再用一组数值来表示这些特征。这样,原本难以直接计算的对象,就被映射成了神经网络可以处理的向量

不过,Embedding 并不是简单地规定“帅气等于 1、阳光等于 2、幽默等于 3”。这种做法只是给不同类别分配编号,数字之间并不具备真实的语义关系。真正的嵌入会把一个对象表示为高维向量,例如 [0.21, -0.47, 0.83, …]。向量中的单个数值通常没有独立、明确的含义,但整个向量能够共同编码对象的语义特征。语义越相近的对象,它们在向量空间中的位置通常也越接近。

这种将文本、图像、音频或视频等数据映射为连续向量表示的过程,通常称为嵌入(Embedding。实际应用中,可以通过专门的嵌入层,也可以借助预训练神经网络模型,对不同模态的数据进行编码,从而得到可供后续模型计算、比较和学习的向量表示,如下图所示。

不同类型的数据,如文本、图像、音频和视频,具有不同的数据结构,因此需要使用相应的模型,将其转换为神经网络能够处理的向量表示。

嵌入的核心,就是把单词、图像、文档等对象映射到连续的向量空间。文本嵌入既可以表示单词,也可以表示句子、段落或整篇文档。其中,句子和段落嵌入常用于 RAG 等检索任务;而 GPT 类模型按 token 逐步生成文本,因此这里主要讨论 token 嵌入。

数据类型 数据特征 嵌入模型 主要特征
文本 离散的、序列化的符号数据 Word2Vec, GloVe, BERT, GPT 语义关系、上下文理解
图像 二维像素网格,具有空间特征 CNNResNetVGG)、ViT 形状、纹理、颜色等视觉特征
音频 一维时序信号 CNN+频谱图RNNTransformer 频率、音调、时序依赖
视频 时空序列数据 3D CNNRNN+CNNVideo Transformer 时空特征、动作捕捉

其中,生成词嵌入的方法和框架很多,Word2Vec 是其中的经典方案。它通过学习目标词与上下文之间的关系,让经常出现在相似语境中的词获得相近的向量表示。因此,将词向量降维并可视化后,语义相近的词通常会聚集在一起,如下图所示。

词嵌入的维度可以从几维扩展到数千维。二维嵌入便于展示,而高维表示通常具有更大的语义表达容量,但也会增加参数量和计算成本。与 Word2Vec 等预训练静态词向量不同,LLM 通常在预训练过程中学习自己的 token 嵌入矩阵,使其更适配模型的训练目标和数据。token 进入模型时会获得一个初始嵌入,经过 Transformer各层处理后,再形成与当前上下文相关的隐藏表示。实际LLM 的模型维度通常很高。例如,GPT-2 Small 的隐藏维度为 768,而 GPT-3 175B12,288,这体现了模型容量与计算效率之间的权衡,接下来将介绍文本进入 LLM 的基本流程:使用分词器将文本切分为 token,将 token 转换为 token ID,再通过嵌入层得到对应的向量表示。

1.2 文本分词

文本分词,顾名思义就是将输入的文本拆分成为单个token(词元),这些token可以是单个单词或特殊字符,包括标点符号,此处就像一个刚刚学说话的娃娃,娃娃是说不出完整句子的,你得拆成一个个简短的词语,如下图。

接下来,将用于LLM 训练的文本数据集是一部由 Edith Wharton 创作的短篇小说《判决》,该作品已在网上公开,因此允许用于LLM 训练任务。该文本可在 Wikisource上找到,网址是 https://en.wikisource.org/wiki/The_Verdict,您可以将其复制并粘贴到文本文件中。将其复制到名为 the-verdict.txt 的文本文件中,以便使用 Python 的标准文件读取工具进行加载,接下来干就完事了。

把小说加载进来,并完成分词操作,为了展示方便,此次分词就先展示前30个token

from pathlib import Path
import re

if __name__ == '__main__':
    # 定位到项目根目录下的 text/the-verdict.txt,避免受运行时工作目录影响
    current_dir = Path(__file__).parent
    text_file = current_dir.parent / "text" / "the-verdict.txt"
    with open(text_file, "r", encoding="utf-8") as f:
        raw_text = f.read()
    # 打印原始文本的字符总数及前 99 个字符,用于快速检查读取是否正确
    print("Total number of character:", len(raw_text))
    print(raw_text[:99])

    # 用正则切分原文:保留标点和双连字符作为独立 token
    preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
    # 清理空白项,得到最终的 token 列表
    preprocessed = [item.strip() for item in preprocessed if item.strip()]
    # 查看 token 总数及前 30 个 token
    print(len(preprocessed))
    print(preprocessed[:30])

    # 对所有 token 去重并排序,构建词表的基础集合
    all_words = sorted(set(preprocessed))
    vocab_size = len(all_words)
    print(vocab_size)

便于演示,一些数据就节选了局部,总体输入效果如下,包括预览小说,打印小说的总token数,完成分词预览等操作,此时的操作是不是非常简单,其实LLM的原理确实没有大家想象的那么复杂,耐心学习,相信普通人也会有所心得的。

Total number of character: 20480
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no 
4690
['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']
1130

1.3 TokenToken ID处理

完成了将短篇小说分词为单独的token,但token本身并不是方便计算的数学值。为了方便计算,需要将token先转成整型的token ID,这就是所谓的编码;随后再将这些token ID组织成张量形式,作为嵌入层的索引输入。真正可供模型进行连续数值计算的向量,要经过Embedding层查表后才得到。当然,token IDtoken之间的转换通常是可逆的,毕竟计算机偏好整型索引,而人偏好对token实际内容的解读。

接下来先将小说的token转成token ID,考虑到小说篇幅,也先展示局部。

from pathlib import Path
import re

if __name__ == '__main__':
    # 定位到项目根目录下的 text/the-verdict.txt,避免受运行时工作目录影响
    current_dir = Path(__file__).parent
    text_file = current_dir.parent / "text" / "the-verdict.txt"
    with open(text_file, "r", encoding="utf-8") as f:
        raw_text = f.read()
    # 打印原始文本的字符总数及前 99 个字符,用于快速检查读取是否正确
    print("Total number of character:", len(raw_text))
    print(raw_text[:99])

    # 用正则切分原文:保留标点和双连字符作为独立 token
    preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
    # 清理空白项,得到最终的 token 列表
    preprocessed = [item.strip() for item in preprocessed if item.strip()]
    # 查看 token 总数及前 30 个 token
    print(len(preprocessed))
    print(preprocessed[:30])

    # 对所有 token 去重并排序,构建词表的基础集合
    all_words = sorted(set(preprocessed))
    vocab_size = len(all_words)
    print(vocab_size)

    # 构建词表:token -> 整数 ID(从 0 开始递增)
    vocab = {token: integer for integer, token in enumerate(all_words)}
    # 预览词表前 50 项,检查映射是否正确
    for i, item in enumerate(vocab.items()):
        print(item)
        if i > 50:
            break

输出的tokentoken ID效果如下,可以理解为每个token都是个学生,而token ID就是你为这些学生编上的一个个学号。

Total number of character: 20480
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no 
4690
['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']
1130
('!', 0)
('"', 1)
("'", 2)
('(', 3)
(')', 4)
(',', 5)
('--', 6)
('.', 7)
(':', 8)
(';', 9)
('?', 10)
('A', 11)
('Ah', 12)
('Among', 13)
('And', 14)
('Are', 15)
('Arrt', 16)
('As', 17)
('At', 18)
('Be', 19)
('Begin', 20)
('Burlington', 21)
('But', 22)
('By', 23)
('Carlo', 24)
('Chicago', 25)
('Claude', 26)
('Come', 27)
('Croft', 28)
('Destroyed', 29)
('Devonshire', 30)
('Don', 31)
('Dubarry', 32)
('Emperors', 33)
('Florence', 34)
('For', 35)
('Gallery', 36)
('Gideon', 37)
('Gisburn', 38)
('Gisburns', 39)
('Grafton', 40)
('Greek', 41)
('Grindle', 42)
('Grindles', 43)
('HAD', 44)
('Had', 45)
('Hang', 46)
('Has', 47)
('He', 48)
('Her', 49)
('Hermia', 50)
('His', 51)

为了方便程序的使用,可以先手搓一个完整的通用分类器SimpleTokenizerV1,该分类器包含:

  • 1个 encode 方法,该方法负责将文本拆分为token,并通过词汇表进行token字符串到整数(token ID)的映射,以通过词汇表生成token ID
  • 1个 decode 方法,该方法则负责进行整数到字符串的反向映射,将token ID 转换回文本。

为了方便理解原理,手搓一个实现和应用的代码如下,注意,此处更多的是像当年老师让你手搓几个JDK的通用方法,目的是为了让你了解JDK的本质,并不是说让你手搓整个JDK,分类器也是类似,其实现在的Python包,自然是有现成的,但是为了方便理解,还是先手搓一个吧,现成的Python包后续也会讲到,先卖个关子。

from pathlib import Path
import re

class SimpleTokenizerV1:
    def __init__(self, vocab):
        self.str_to_int = vocab                                                   #A
        self.int_to_str = {i:s for s,i in vocab.items()}                          #B

    def encode(self, text):                                                       #C
        # 按标点、双连字符或空白符切分文本,括号中的分组会保留分隔符本身
        preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
        # 去除每个片段两端空白,并过滤掉空字符串
        preprocessed = [item.strip() for item in preprocessed if item.strip()]
        # 将每个 token 通过词汇表映射为对应的整数 ID
        ids = [self.str_to_int[s] for s in preprocessed]
        return ids

    def decode(self, ids):                                                        #D
        # 将 ID 序列先还原为 token 字符串,再用空格拼接成文本
        text = " ".join([self.int_to_str[i] for i in ids])

        # 去掉标点符号前面多余的空格,使输出更接近自然书写形式
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)                           #E
        return text


#A 将词汇表作为类属性存储,以方便在 encode 和 decode 方法中访问
#B 创建一个反向词汇表,将token ID 映射回原始的文本token
#C 将输入文本转换为token ID
#D 将token ID 还原为文本
#E 在指定的标点符号前去掉空格

if __name__ == '__main__':
    # 定位到项目根目录下的 text/the-verdict.txt,避免受运行时工作目录影响
    current_dir = Path(__file__).parent
    text_file = current_dir.parent / "text" / "the-verdict.txt"
    with open(text_file, "r", encoding="utf-8") as f:
        raw_text = f.read()
    # 打印原始文本的字符总数及前 99 个字符,用于快速检查读取是否正确
    print("Total number of character:", len(raw_text))
    print(raw_text[:99])

    # 用正则切分原文:保留标点和双连字符作为独立 token
    preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
    # 清理空白项,得到最终的 token 列表
    preprocessed = [item.strip() for item in preprocessed if item.strip()]
    # 查看 token 总数及前 30 个 token
    print(len(preprocessed))
    print(preprocessed[:30])

    # 对所有 token 去重并排序,构建词表的基础集合
    all_words = sorted(set(preprocessed))
    vocab_size = len(all_words)
    print(vocab_size)

    # 构建词表:token -> 整数 ID(从 0 开始递增)
    vocab = {token: integer for integer, token in enumerate(all_words)}
    # 预览词表前 50 项,检查映射是否正确
    for i, item in enumerate(vocab.items()):
        print(item)
        if i > 50:
            break

    # 用构建好的词表实例化 tokenizer,并进行编码-解码测试
    tokenizer = SimpleTokenizerV1(vocab)
    text = """"It's the last he painted, you know," Mrs. Gisburn said with pardonable pride."""
    ids = tokenizer.encode(text)
    print(ids)

    # 解码回文本,验证 encode/decode 是否互逆
    print(tokenizer.decode(ids))

    # 用一句新文本测试:会因 "Hello" 不在词表中而抛出 KeyError,提示需要 <|unk|> 等未知词处理
    text = "Hello, do you like tea?"
    print(tokenizer.encode(text))

效果如下:

Total number of character: 20480
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no 
4690
['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']
1130
('!', 0)
('"', 1)
("'", 2)
('(', 3)
(')', 4)
(',', 5)
('--', 6)
('.', 7)
(':', 8)
(';', 9)
('?', 10)
('A', 11)
('Ah', 12)
('Among', 13)
('And', 14)
('Are', 15)
('Arrt', 16)
('As', 17)
('At', 18)
('Be', 19)
('Begin', 20)
('Burlington', 21)
('But', 22)
('By', 23)
('Carlo', 24)
('Chicago', 25)
('Claude', 26)
('Come', 27)
('Croft', 28)
('Destroyed', 29)
('Devonshire', 30)
('Don', 31)
('Dubarry', 32)
('Emperors', 33)
('Florence', 34)
('For', 35)
('Gallery', 36)
('Gideon', 37)
('Gisburn', 38)
('Gisburns', 39)
('Grafton', 40)
('Greek', 41)
('Grindle', 42)
('Grindles', 43)
('HAD', 44)
('Had', 45)
('Hang', 46)
('Has', 47)
('He', 48)
('Her', 49)
('Hermia', 50)
('His', 51)
[1, 56, 2, 850, 988, 602, 533, 746, 5, 1126, 596, 5, 1, 67, 7, 38, 851, 1108, 754, 793, 7]
" It' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.
Traceback (most recent call last):
  File "/text_process/src/text_split.py", line 75, in <module>
    print(tokenizer.encode(text))
          ^^^^^^^^^^^^^^^^^^^^^^
  File "/text_process/src/text_split.py", line 15, in encode
    ids = [self.str_to_int[s] for s in preprocessed]
           ~~~~~~~~~~~~~~~^^^
KeyError: 'Hello'

Process finished with exit code 1

通过手搓的这个通用分类器,可以看到很顺利的实现了对训练集的分词和编码,同时也是可逆的,至于出现的KeyError: 'Hello',这个原因是短篇小说《判决》中并没有出现过Hello这个词。因此在分词后的编码操作中发生了异常,那怎么考虑多样化的训练集以扩展词汇的呢?接着往下走。

1.4 特殊词汇的Token化处理

上一节用到了一种大力出奇迹的方式去尽可能的穷举所有出现过的tokentoken ID,但穷举的方式在拆token的计算中,肯定是会遇到未知的token的,毕竟用有限对抗无限,历来都是失败的;那怎么办呢?解法其实很简单,定义一个<|unk|>token,跟黑洞一样吞噬一切,把一切未知的token都放入<|unk|>中,当然原则上肯定是放进去的token数越少越好,然后给<|unk|>定义一个唯一的编号token ID

同时,训练LLM往往会用到很多篇不同的文档,因此也经常会运用<|endoftext|>这个token来标识和间隔一篇文档的结束,另一篇文档的开端,方便LLM理解,尽管这些文本源在训练中是连接在一起的,但它们实际上是无关的,因此也会为<|endoftext|>加一个token ID,因此你会发现,大体上就分为两类token,一类是根据分词规则分出来的token,另一类则是人类为了处理特殊问题,自定义的一些通用token,那自定义的是不是只能有这两个token呢?从原则上来看当然是不止的,这些细节,就是各大LLM训练的一些独到的理解之一了,合理即可,这里就先用<|endoftext|><|unk|>说明问题。

  • [BOS](序列开始):这个token指示 LLM 内容的开始。
  • [EOS](序列结束):这个token位于文本的末尾,在连接多个无关文本时特别有用,类似于 <|endoftext|>
  • [PAD](填充):在使用大于 1 的批量大小数据集训练 LLM 时,批量可能包含不同长度的文本。为了确保所有文本长度一致,较短的文本会用 [PAD] token进行扩展或填充,直到达到批量中最长文本的长度。
  • ……

接下来,将包含特殊token的理念重新写入新的分类器,姑且就叫SimpleTokenizerV2吧,以下是调整后的代码实现和效果。

from pathlib import Path
import re

class SimpleTokenizerV2:
    def __init__(self, vocab):
        # str_to_int: token -> token ID 的正向映射
        self.str_to_int = vocab
        # int_to_str: token ID -> token 的反向映射,用于解码
        self.int_to_str = { i:s for s,i in vocab.items()}

    def encode(self, text):
        # 按标点符号、双连字符、空白字符切分,保留分隔符
        preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
        # 清理空白项
        preprocessed = [item.strip() for item in preprocessed if item.strip()]
        # 将词表中不存在的 token 替换为 <|unk|>
        preprocessed = [item if item in self.str_to_int
                        else "<|unk|>" for item in preprocessed]

        # 将每个 token 映射为对应的整数 ID
        ids = [self.str_to_int[s] for s in preprocessed]
        return ids

    def decode(self, ids):
        # 将整数 ID 序列还原为 token 文本,用空格拼接
        text = " ".join([self.int_to_str[i] for i in ids])
        # 去除标点符号前的多余空格,还原自然语言格式
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
        return text

if __name__ == '__main__':
    # 定位到项目根目录下的 text/the-verdict.txt,避免受运行时工作目录影响
    current_dir = Path(__file__).parent
    text_file = current_dir.parent / "text" / "the-verdict.txt"
    with open(text_file, "r", encoding="utf-8") as f:
        raw_text = f.read()
    # 打印原始文本的字符总数及前 99 个字符,用于快速检查读取是否正确
    print("Total number of character:", len(raw_text))
    print(raw_text[:99])

    # 用正则切分原文:保留标点和双连字符作为独立 token
    preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
    # 清理空白项,得到最终的 token 列表
    preprocessed = [item.strip() for item in preprocessed if item.strip()]
    # 查看 token 总数及前 30 个 token
    print(len(preprocessed))
    print(preprocessed[:30])

    # 对所有 token 去重并排序,构建词表的基础集合
    all_words = sorted(set(preprocessed))
    # 添加特殊未知词 token 到词表中
    all_words.extend(["<|endoftext|>", "<|unk|>"])
    vocab_size = len(all_words)
    print(vocab_size)

    # 构建词表:token -> 整数 ID(从 0 开始递增)
    vocab = {token: integer for integer, token in enumerate(all_words)}
    # 预览词表最后5项,检查新增的 <|endoftext|> 和 <|unk|> 特殊 token 是否映射正确
    for i, item in enumerate(list(vocab.items())[-5:]):
        print(item)


    # 用构建好的词表实例化 tokenizer,测试编码-解码流程
    tokenizer = SimpleTokenizerV2(vocab)
    text = """"It's the last he painted, you know," Mrs. Gisburn said with pardonable pride."""
    ids = tokenizer.encode(text)
    print(ids)

    # 解码回文本,验证 encode / decode 是否互为逆操作
    print(tokenizer.decode(ids))

    # 拼接两段文本,中间用 <|endoftext|> 分隔,模拟多段文本输入场景
    # <|unk|> 会替换 Hello、palace 等不在词表中的词,<|endoftext|> 视为普通 token
    text1 = "Hello, do you like tea?"
    text2 = "In the sunlit terraces of the palace."
    text = " <|endoftext|> ".join((text1, text2))
    print(tokenizer.encode(text))
    print(tokenizer.decode(tokenizer.encode(text)))

生成的效果打印如下;

Total number of character: 20480
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no 
4690
['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']
1132
('younger', 1127)
('your', 1128)
('yourself', 1129)
('<|endoftext|>', 1130)
('<|unk|>', 1131)
[1, 56, 2, 850, 988, 602, 533, 746, 5, 1126, 596, 5, 1, 67, 7, 38, 851, 1108, 754, 793, 7]
" It' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.
[1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, 956, 984, 722, 988, 1131, 7]
<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.

Process finished with exit code 0

以上是一个简单手搓的分词方案,重点是从本质理解训练LLM前的文本处理本质;这么通用的方案,就没有现成的包吗?还真有,接下来就介绍一种现成的基于字节对编码(BPEByte Pair Encoding)概念的更复杂的分词方案。BPE及其变体曾被用于GPT-2GPT-3等模型的分词体系,OpenAI后续模型也使用了类似的子词或字节级编码思想。

2. 现成的字节对编码包

下面开始从0到1实现字节对编码(BPEByte Pair Encoding)……开玩笑的,重复造轮子一直是技术的大忌,除非迫不得已;直接使用一个名为tiktoken的现有Python开源库

  • GitHub:(https://github.com/openai/tiktoken
  • 简介:看到名字,这个库是不是TikTok的?嘿嘿,还真不是,tiktoken is a fast BPE tokeniser for use with OpenAI’s models.,该库基于Rust中的源代码非常高效地实现了BPE算法。与其他Python库类似,可以直接通过Pythonpip安装程序从终端安装tiktoken库。

此次不必细究BPE的全部功能,如果对BPE有兴趣的同学可以参考GitHub及官方更多资料,这里主要实现:

  1. 用 educational 版 SimpleBytePairEncoding 可视化 BPE 合并过程
  2. 加载 GPT-4o 官方编码器(仅用于对比生成的token ID,本质其实类似)
  3. 基于 cl100k_base 扩展自定义特殊 token,构建一个 GPT-4 风格的编码器
  4. 输出 token ID -> token 文本的映射条目
  5. 对样例文本做 encode / decode 验证
from importlib.metadata import version
from tiktoken._educational import *

# 本文件演示 BPE(Byte Pair Encoding)分词器:
#   1. 用 educational 版 SimpleBytePairEncoding 可视化 BPE 合并过程
#   2. 基于 cl100k_base 扩展自定义特殊 token,构建一个 GPT-4 风格的编码器
#   3. 输出 token ID -> token 文本的词表映射
#   4. 对样例文本做 encode / decode 验证
#   5. 加载 GPT-4o 官方编码器(仅用于对比)

if __name__ == '__main__':
    print("tiktoken version:", version("tiktoken"))


    # 用 educational 版 BPE 包装 cl100k_base,可视化 GPT-4 的编码过程
    # SimpleBytePairEncoding.encode 默认 visualise="colour",会在终端打印彩色输出:
    #   - 每一行代表 BPE 的一次合并步骤的快照
    #   - 每个颜色块 = 一个当前 token,颜色仅用于区分相邻 token 边界,无语义
    #   - 随着合并推进,颜色块从多变少,最终无法再合并时停止
    # 关闭可视化可传 visualise=None 或 visualise="simple"
    enc1 = SimpleBytePairEncoding.from_tiktoken("cl100k_base")

    print("\n=== cl100k_base 编码过程可视化 ===")
    print(enc1.encode("hello world aaaaaaaaaaaa"))
    print("\n=== GPT-4 编码对比 ===")
    # 加载 GPT-4o 官方编码器(仅用于对比生成的 token ID,本质其实类似)
    tokenizer = tiktoken.encoding_for_model("gpt-4o")
    print(tokenizer.encode("hello world aaaaaaaaaaaa"))

    # 基于 cl100k_base 扩展自定义特殊 token:
    # <|im_start|> / <|im_end|> 是 ChatML 格式用于标记对话轮次起止的特殊 token
    # 修改特殊 token 集合时,name 必须改名以避免与官方编码器混淆
    cl100k_base = tiktoken.get_encoding("cl100k_base")
    enc = tiktoken.Encoding(
        name="cl100k_im",
        pat_str=cl100k_base._pat_str,
        mergeable_ranks=cl100k_base._mergeable_ranks,
        special_tokens={
            **cl100k_base._special_tokens,
            "<|im_start|>": 100264,
            "<|im_end|>": 100265
        }
    )

    # 输出 BPE 映射中的 token ID -> token 文本键值对
    print("\n=== BPE 映射:Token ID -> Token 键值对 ===")

    # 词表由两部分组成:
    #   - _mergeable_ranks: dict[bytes, int],BPE 学到的可合并 token(普通子词)
    #   - _special_tokens:  dict[str, int],  人为定义的特殊标记(如 <|endoftext|>)
    id_to_text = {}
    for token_bytes, token_id in enc._mergeable_ranks.items():
        # bytes 解码为字符串,无效 UTF-8 用替换字符代替
        id_to_text[token_id] = token_bytes.decode("utf-8", errors="replace")
    for token_str, token_id in enc._special_tokens.items():
        id_to_text[token_id] = token_str

    sorted_ids = sorted(id_to_text.keys())
    print(f"当前映射条目数: {len(sorted_ids)} 个 token\n")

    # 低 ID 区(前 30 个):BPE 词表的基础层,主要是单字节字符(ASCII 标点、数字、字母)
    # BPE 训练时先为每个字节值(0-255)分配 ID,再按合并优先级递增分配后续 ID
    print("--- 低 ID 区(前 30 个)---")
    for tid in sorted_ids[:30]:
        print(f"  {tid}: {repr(id_to_text[tid])}")

    # 高 ID 区(后 10 个):训练后期才合并出的长 token,以及人为追加的特殊 token
    print("\n--- 高 ID 区(后 10 个)---")
    for tid in sorted_ids[-10:]:
        print(f"  {tid}: {repr(id_to_text[tid])}")


    # 编码-解码验证:
    # allowed_special="all" 表示允许文本中的 <|endoftext|> 等特殊 token 被识别为单个 token
    # 否则默认会把特殊 token 当普通文本拆成多个子词
    text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace."
    integers = enc.encode(text, allowed_special="all")
    print(integers)
    print(enc.decode(integers))
    
    # 编码-解码验证:中文文本
    text = "你好,你喜欢茶吗? <|endoftext|> 在某个不知名地方的阳光照耀下的梯田上。"
    integers = enc.encode(text, allowed_special="all")
    print(integers)
    print(enc.decode(integers))


效果如下:

tiktoken version: 0.13.0

=== cl100k_base 编码过程可视化 ===
hello
hello
hello
hello
hello

 world
 world
 world
 world
 world
 world

 aaaaaaaaaaaa
 aaaaaaaaaaaa
 aaaaaaaaaaaa
 aaaaaaaaaaaa
 aaaaaaaaaaaa
 aaaaaaaaaaaa
 aaaaaaaaaaaa
 aaaaaaaaaaaa
 aaaaaaaaaaaa
 aaaaaaaaaaaa
 aaaaaaaaaaaa

[15339, 1917, 264, 70540, 33746]

=== GPT-4 编码对比 ===
[24912, 2375, 261, 117525, 55894]

=== BPE 映射:Token ID -> Token 键值对 ===
当前映射条目数: 100263 个 token

--- 低 ID 区(前 30 个)---
  0: '!'
  1: '"'
  2: '#'
  3: '$'
  4: '%'
  5: '&'
  6: "'"
  7: '('
  8: ')'
  9: '*'
  10: '+'
  11: ','
  12: '-'
  13: '.'
  14: '/'
  15: '0'
  16: '1'
  17: '2'
  18: '3'
  19: '4'
  20: '5'
  21: '6'
  22: '7'
  23: '8'
  24: '9'
  25: ':'
  26: ';'
  27: '<'
  28: '='
  29: '>'

--- 高 ID 区(后 10 个)---
  100253: '.WaitFor'
  100254: ' daycare'
  100255: ' Conveyor'
  100257: '<|endoftext|>'
  100258: '<|fim_prefix|>'
  100259: '<|fim_middle|>'
  100260: '<|fim_suffix|>'
  100264: '<|im_start|>'
  100265: '<|im_end|>'
  100276: '<|endofprompt|>'
[9906, 11, 656, 499, 1093, 15600, 30, 220, 100257, 763, 279, 7160, 32735, 7317, 2492, 315, 1063, 16476, 17826, 13]
Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.
[57668, 53901, 3922, 57668, 83601, 250, 25340, 95, 91994, 114, 7305, 245, 11571, 220, 100257, 74662, 76475, 238, 19483, 16937, 53283, 13372, 30590, 24273, 9554, 83175, 6708, 231, 163, 92871, 20551, 222, 17297, 9554, 162, 95, 107, 7518, 108, 17905, 1811]
你好,你喜欢茶吗? <|endoftext|> 在某个不知名地方的阳光照耀下的梯田上。



Process finished with exit code 0

其中=== cl100k_base 编码过程可视化 ===是有颜色的,这正是 BPE 逐步合并的可视化过程——每一行代表一次合并步骤,颜色块数量的减少就是 token 数量的减少。其原理就是每打印一行等于跑一轮合并,可以参考 _educational.py:83-116bpe_encode 函数:

def bpe_encode(mergeable_ranks, input, visualise="colour"):
    parts = [bytes([b]) for b in input]   # 先拆成单字节
    while True:
        if visualise:
            visualise_tokens(parts)        # ← 每轮合并前先打印一次当前状态
        # 找到 rank 最小(优先级最高)的相邻 pair
        ...
        if min_rank is None:
            break
        # 合并这个 pair
        parts = parts[:min_idx] + [parts[min_idx] + parts[min_idx + 1]] + parts[min_idx + 2:]

关键点:while 循环每跑一轮,就打印一行彩色输出,然后合并一对相邻字节。所以你看到的不是一张图,而是一段动画的逐帧截图,以 “Hello” 为例拆解,假设词表里已有 llellelloHello 这些合并规则,过程大致是:

步骤 parts 状态 颜色数 你看到的
0(初始) [H][e][l][l][o] 5 红橙黄绿青(5 块)
1 [H][e][ll][o] 4 红橙黄绿(ll 合成一块)
2 [H][e][llo] 3 红橙黄
3 [H][ello] 2 红橙
4 [Hello] 1 (只剩一块)
5 无法再合并,退出

所以你看到的现象是:颜色块从多变少,最后只剩一块红色,为什么最后一定是红色,看 visualise_tokens 的着色逻辑:

for token in unicode_token_values:
    color = background[running_length % len(background)]
    ...
    running_length += len(token)

颜色是按 running_length % 7 选的。当只剩一个 token 时,running_length = 0,所以 background[0] = 色号 167 = 红色

只要最终合并成单个 token,必定显示红色(因为起始位置永远是 0)。这跟 token 内容无关,纯粹是着色算法的副作用。在 GPT 系列常用的字节级 BPE 中,可以理解为:从字节级单元开始,按照训练得到的合并优先级,反复合并相邻片段,直到词表里没有可合并的 pair 为止

需要注意的是,上面打印的“当前映射条目数”指当前 id_to_text 字典中收集到的 token 条目数量,并不表示 token ID 一定是连续编号,也不等同于最大 token ID + 1

有了BPE这种好东西,你拆token的工作自然就是事半功倍的,通过以上章节,恭喜大家即掌握了手搓的分词解决方案,又知晓了主流的一些现成解决方案。

3. 滑动窗口(输入x和目标y配对)数据采样

还记得LLM的核心任务是什么吗?永远预测下一个token,所以整个LLM训练的数据集,其实本质就是准备输入x和目标y的配对的数据集。这里的目标y不是token ID数值加1,而是把输入序列整体向后错开一位:每个位置的目标都是原序列中的下一个token

还是以the-verdict.txt为例,结合BPE的现成算法包,下面进行滑动窗口输入x和目标y配对数据集的实现,先来个纯手搓版本的,目的还是一样,先方便理解本质,以下是手搓版本的实现代码;

from pathlib import Path
from tiktoken._educational import *

# 本文件演示 LLM 训练中“滑动窗口”数据构造方式:
#   - 给定一段 token 序列,把前 N 个 token 作为输入 x,后移一位的 N 个 token 作为目标 y
#   - 这样模型学到的是“根据上下文预测下一个 token”的任务(next-token prediction)
#   - 同一个 context_size 窗口内会拆出多个 (context, target) 训练对,最大化数据利用率


if __name__ == '__main__':
    # 定位到项目根目录下的 text/the-verdict.txt,避免受运行时工作目录影响
    current_dir = Path(__file__).parent
    text_file = current_dir.parent / "text" / "the-verdict.txt"
    with open(text_file, "r", encoding="utf-8") as f:
        raw_text = f.read()

    # 用 GPT-4o 的 BPE 编码器把整篇文本编码为 token ID 序列
    tokenizer = tiktoken.encoding_for_model("gpt-4o")
    enc_text = tokenizer.encode(raw_text)
    # print(len(enc_text))

    # 丢掉前 50 个 token(通常是开头的版权/标题等噪声),让训练样本更干净
    enc_sample = enc_text[50:]
    print(len(enc_sample))
    # 解码回文本前 99 个字符,验证切片后内容是否符合预期
    dec_text = tokenizer.decode(enc_sample)
    print(dec_text[:99])

    # 构造一个 context_size=4 的训练样本:
    #   x = 前 4 个 token(模型输入)
    #   y = 后移一位的 4 个 token(预测目标)
    # 例如 enc_sample = [a, b, c, d, e, ...]
    #      x = [a, b, c, d]
    #      y = [b, c, d, e]   ← 每个位置上的目标就是 x 同位置的下一个 token
    context_size = 4
    x = enc_sample[:context_size]
    y = enc_sample[1:context_size + 1]
    print(f"x: {x}")
    print(f"y:    {y}")

    # 把上面的 (x, y) 拆成“逐步增长”的子样本,更直观地看到 next-token 预测任务:
    #   [a]            -> b
    #   [a, b]         -> c
    #   [a, b, c]      -> d
    #   [a, b, c, d]   -> e
    # 每一行就是一次“给定 context 预测下一个 token”的训练实例
    for i in range(1, context_size + 1):
        context = enc_sample[:i]
        desired = enc_sample[i]
        print(context, "---->", desired)

    # 同上,但把 token ID 解码回文本,便于人眼直观验证滑动窗口的语义对应关系
    for i in range(1, context_size + 1):
        context = enc_sample[:i]
        desired = enc_sample[i]
        print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))

实现的效果如下;

4786
 himself in a villa on the Riviera. (Though I rather thought it would have been Rome or Florence.)

x: [11166, 306, 261, 38350]
y:    [306, 261, 38350, 402]
[11166] ----> 306
[11166, 306] ----> 261
[11166, 306, 261] ----> 38350
[11166, 306, 261, 38350] ----> 402
 himself ---->  in
 himself in ---->  a
 himself in a ---->  villa
 himself in a villa ---->  on

Process finished with exit code 0

通过以上的代码方式,已经成功的创建了输入x和目标y的配对训练集,原则上即将可以应用于 LLM 的训练了。但在往往很好的完成一件事后,我们大多数情况下还期望这件事又好又快。这里的重点不是改变数据的离散属性,而是不要一个样本一个样本地手动处理;我们可以实现一个高效的数据加载器,把样本批量组织成 PyTorch 张量,方便后续训练循环高效读取。

张量(Tensor 是任意维度的数字容器多维数组,是深度学习中最基本的数据单元。

向量(Vector一维的数字列表(即只有长和宽中的一个维度)。

说人话就是你得找到一个名词来描述1阶,2阶,3阶,……,n阶数组的玩意,这个玩意在深度学习上就叫张量。

阶数 数学名称 形态 通俗理解
0阶 标量 单个数字 点(无方向)
1阶 向量 一维数组 [a, b, c] 线(列表)
2阶 矩阵 二维数组 [[a,b],[c,d]] 表格(行与列)
3阶及以上 高阶张量 多维数组 立体/超立方体(多个矩阵叠加)

简而言之,目标是返回两个张量:一个输入x张量,包括 LLM 看到的文本,另一个目标y张量,包含 LLM 需要预测的目标,如下图。

接下来,就用一段代码实现了LLM 训练数据构造的核心流程——滑动窗口切分法。

  1. 文本 → Token ID:用 BPE 编码器(GPT-2gpt2 编码)将整篇文本编码为一长串整数 ID。

  2. 滑动窗口切分 (输入x,目标y)对,以 max_length 为窗口大小、stride 为步长,在 token ID 序列上滑动:

    • input_chunk = tokens[i : i+max_length]

    • target_chunk = tokens[i+1 : i+max_length+1]
      也就是说 target 是 input 整体后移一位,模型在每个位置学到的是
      “看到前面的 token,预测下一个 token”。

  3. DatasetDataLoader,将切好的(输入x,目标y)对存入 PyTorch Dataset,再通过 DataLoader,实现批量加载(batch)、随机打乱(shuffle)、丢弃残余 batch(drop_last)。

  4. 两个关键参数:

    • max_length(上下文窗口):决定了模型一次能“看到”多长的上文。
      GPT-2 常用 1024,GPT-3 用 2048,更大的模型用 4096 甚至更高。

    • stride(步长):控制窗口重叠程度。
      stride=1 时窗口几乎完全重叠,每个 token 会出现在多个样本中;
      stride=max_length 时窗口无重叠,每个 token 只属于一个样本。
      重叠可以充分利用数据,但会增加训练样本数;无重叠处理更简洁。

实现的Python代码如下;

import torch
from torch.utils.data import Dataset, DataLoader
from pathlib import Path
from tiktoken._educational import *

# 本文件演示 LLM 训练数据的构造流程:
#   1. 将文本用 BPE 编码为 token ID 序列
#   2. 用滑动窗口切分为 (input, target) 训练对(target 是 input 后移一位)
#   3. 封装为 PyTorch Dataset → DataLoader,实现批量加载


class GPTDatasetV1(Dataset):
    """滑动窗口数据集:将文本按 stride 步长滑动,切分为 (input_chunk, target_chunk) 训练对。
    
    target_chunk 是 input_chunk 整体后移一位的 token 序列,
    模型在每个位置上都要预测“下一个 token”,这正是 GPT 类模型的训练目标。
    """
    def __init__(self, txt, tokenizer, max_length, stride):
        self.input_ids = []
        self.target_ids = []

        # 将整篇文本用 BPE 编码成 token ID 序列
        token_ids = tokenizer.encode(txt)

        # 滑动窗口切分:
        #   - max_length: 每个样本包含的 token 数(窗口大小)
        #   - stride:     窗口每次滑动的步长(stride < max_length 时窗口重叠)
        #   - 结束条件: i + max_length 不能越界,末尾不足一个窗口的数据丢弃
        for i in range(0, len(token_ids) - max_length, stride):
            # input_chunk:  [t_i, t_{i+1}, ..., t_{i+max_length-1}]
            input_chunk = token_ids[i:i + max_length]
            # target_chunk: [t_{i+1}, t_{i+2}, ..., t_{i+max_length}]
            target_chunk = token_ids[i + 1: i + max_length + 1]
            self.input_ids.append(torch.tensor(input_chunk))
            self.target_ids.append(torch.tensor(target_chunk))

    def __len__(self):
        """返回数据集中样本的总数"""
        return len(self.input_ids)

    def __getitem__(self, idx):
        """返回第 idx 个样本的 (input_ids, target_ids) 对"""
        return self.input_ids[idx], self.target_ids[idx]


def create_dataloader_v1(txt, batch_size=4, max_length=256,
                         stride=128, shuffle=True, drop_last=True, num_workers=0):
    """创建 DataLoader 的工厂函数。
    
    参数说明:
        max_length: 每个训练样本的 token 数(上下文窗口大小),GPT-2 常用 1024
        stride:     滑动窗口步长;设为 max_length 则不重叠,小于 max_length 则重叠
        drop_last:  丢弃不足 batch_size 的最后一个 batch,防止训练时 loss 异常波动
    """
    # 使用 GPT-2 的 BPE 编码器(vocab_size=50257)
    tokenizer = tiktoken.get_encoding("gpt2")
    # 构造滑动窗口数据集
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
    # 包装为 PyTorch DataLoader,支持批量加载、随机打乱等
    dataloader = DataLoader(
      	dataset,
      	batch_size=batch_size,
      	shuffle=shuffle,        # 每个 epoch 随机打乱数据顺序
      	drop_last=drop_last,    # 丢弃最后不足一个 batch 的残余数据
      	num_workers=num_workers # 并行加载数据的 worker 进程数
    )
    return dataloader


if __name__ == '__main__':
    # 加载文本
    current_dir = Path(__file__).parent
    text_file = current_dir.parent / "text" / "the-verdict.txt"
    with open(text_file, "r", encoding="utf-8") as f:
        raw_text = f.read()

    # 示例 1:batch_size=1, stride=1 —— 窗口每次只滑动 1 个 token,高度重叠
    # 适合观察滑动窗口的逐 token 滑动的效果
    dataloader = create_dataloader_v1(
        raw_text, batch_size=1, max_length=4, stride=1, shuffle=False)
    data_iter = iter(dataloader)
    first_batch = next(data_iter)
    print(first_batch)

    second_batch = next(data_iter)
    print(second_batch)

    # 示例 2:batch_size=8, stride=4 —— stride 等于 max_length,窗口无重叠
    # 每个 token 只出现在一个样本中,适合实际训练
    dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=4, stride=4)
    data_iter = iter(dataloader)
    inputs, targets = next(data_iter)
    print("Inputs:\n", inputs)
    print("\nTargets:\n", targets)


输出效果;

[tensor([[  40,  367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]
Inputs:
 tensor([[    1,  2504,   373,   465],
        [  438,   270,   373, 15830],
        [  314, 16896,    13,   198],
        [  550,   257,  3200,  2861],
        [ 9449,  2540,   284,  2689],
        [  991,  2045,   546,   329],
        [  286, 16153,   312,   328],
        [ 4376,   607, 26928,   351]])

Targets:
 tensor([[ 2504,   373,   465,  2106],
        [  270,   373, 15830,   326],
        [16896,    13,   198,   198],
        [  257,  3200,  2861,  8208],
        [ 2540,   284,  2689,   502],
        [ 2045,   546,   329,   257],
        [16153,   312,   328,  3780],
        [  607, 26928,   351,   257]])

Process finished with exit code 0

以上代码将步幅一会儿是1,一会儿增加到了 4。这个有什么区别呢?其实更多的是这是为了如何全面利用数据集(不跳过任何单词还是批次之间允许的重叠),那是不是越重叠越好?其实也不然,因为更多的重叠可能会导致过拟合。

4. 开始真正的文本处理

以上章节中,分词得到token、编码生成token ID,其实只是处理文本数据的中间过程。将token ID 转换为连续的向量表示后,这些向量化后的表示才是真正给到 LLM 的输入数据格式,为什么呢?这里会涉及到一些专业知识;

大语言模型首先用 tokenizer 把文本转换成离散的tokentoken再编码成为token IDtoken ID 只是索引,本身没有连续的数学意义,可以作为 Embedding 层的索引输入,但不能直接作为 Transformer block 的向量输入。因此,模型使用一个可训练的 Embedding 矩阵,把每个 token ID 映射成一个连续向量。

Transformer 随后对这些向量进行矩阵乘法、加法、注意力计算和非线性变换。由于这些运算大多是可微的,损失函数产生的梯度可以通过反向传播传回各层,并更新 Transformer 参数以及 Embedding 矩阵中的向量。

Embedding 的核心意义,不只是把文字“数字化”,而是为每个离散 token 提供一个可学习的连续表示,使模型能够通过训练发现 token 的用法、关系和上下文规律。

听糊涂了是吗?这个后续博主做一些这些内容的专门章节再介绍,现在方便理解,先拿个生活中的例子简单来说,想象在桌面上有一堆东西。为了统计你给这些东西编写了号码牌:

小猫:17号
小狗:18号
苹果:19号

但人是不能直接根据号码理解它们的,因为 18 号不代表小狗比小猫多一个,也不代表 19 号苹果比小狗更大。这些号码只是用来“找到这个东西”的。

4.1 处理词嵌入层(Embedding

本质就像每个东西搞一张特征卡,比如:

小猫:
毛茸茸程度 9
宠物程度 9
会飞程度 0

小狗:
毛茸茸程度 8
宠物程度 10
会飞程度 0

飞机:
毛茸茸程度 0
宠物程度 0
会飞程度 10

真实的 Embedding 不是只有3项,而是可能有几千项,并且每一项不一定有这么清楚的人类含义。但核心思想是一样的:

  • 号码牌告诉模型“它是谁”
  • 特征卡告诉模型“可以怎样表示它”
  • 原则上表示的向量项越多,就描述该东西越清晰,但是计算量也更大

现在能理解为什么不能直接把文字丢给模型?因为对计算机来说,“猫”这个汉字本身不是一个可以做乘法的东西。

不能计算:

猫 × 0.7 + 狗 × 0.3

但是向量可以:

[0.2, 0.8, -0.1] × 0.7
+
[0.3, 0.7, 0.0] × 0.3

神经网络的全部工作,本质上都是大量类似的数字运算,所以必须先把符号转换成数字向量。

接下来,为了说明问题,就自定义一个简单的Embedding 层作为一个”巨大“的查找表——一个 vocab_size × output_dim 的权重矩阵。然后输入一个 token ID,等价于取出权重矩阵的第 token_id 行。

当然这里只是为了说明问题,本节为了演示用了 vocab_size=6output_dim=3,但在真实模型中:

  • GPT-2 Smallvocab_size=50257, embedding_dim=768
  • GPT-3embedding_dim=12288,词表规模与所用 tokenizer 相关
  • LLaMA-7Bvocab_size=32000, embedding_dim=4096

这些权重矩阵从随机初始化开始,随着训练逐步收敛到有语义含义的向量空间。

训练过程:
1. 初始状态(本章重点)embedding_layer.weight 是随机值,没有任何语义信息

2. **前向传播(本章重点)**:`token ID` → 查表取出向量 → 送入 `Transformer` → 输出预测
3. 反向传播(后续再说,仅仅为了连贯):预测结果 vs 真实标签的 loss 通过链式法则梯度回传到 `Embedding` 权重
4. 权重更新(后续再说,仅仅为了连贯):监督信号推动语义相似的 `token` 向量彼此靠近

LLM 整体流程中的位置:

Raw Text → BPE Tokenizer → Token IDs→ nn.Embedding → Dense Vectors → Transformer Blocks → ...

用于演示的 Python 代码;

import torch

# 本文件演示 PyTorch 的 nn.Embedding 层——将离散的 token ID 映射为连续的稠密向量。
# 这是 LLM 的第一步:把上一节得到的 token ID 序列,转换为模型能处理的浮点数向量。

if __name__ == '__main__':
    # 假设一份已编码好的 token ID 序列(来自 BPE 编码器的输出)
    input_ids = torch.tensor([2, 3, 5, 1])

    # 词表大小:模型能识别的不同 token 总数
    vocab_size = 6
    # 输出维度:每个 token 会被映射到多少维的向量空间
    # 实际 GPT-2 的嵌入维度是 768,GPT-3 是 12288
    output_dim = 3

    # 固定随机种子,保证每次运行得到的嵌入矩阵相同
    torch.manual_seed(123)
    # 创建一个嵌入层:本质是一个 vocab_size × output_dim 的权重矩阵(查找表)
    # 每一行对应一个 token ID 的向量表示
    embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
    # 打印这个权重矩阵,6 行 3 列,行号 = token ID
    print(embedding_layer.weight)

    # 查单个 token:取出 ID=3 那一行的向量
    print(embedding_layer(torch.tensor([3])))

    # 批量查询:传入整个 input_ids 序列,一次取出所有 token 对应的嵌入向量
    # 输出形状: (4, 3) → 4 个 token,每个 token 一个 3 维向量
    print(embedding_layer(input_ids))



输出效果;

arameter containing:
tensor([[ 0.3374, -0.1778, -0.1690],
        [ 0.9178,  1.5810,  1.3010],
        [ 1.2753, -0.2010, -0.1606],
        [-0.4015,  0.9666, -1.1481],
        [-1.1589,  0.3255, -0.6315],
        [-2.8400, -0.7849, -1.4096]], requires_grad=True)
tensor([[-0.4015,  0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)
tensor([[ 1.2753, -0.2010, -0.1606],
        [-0.4015,  0.9666, -1.1481],
        [-2.8400, -0.7849, -1.4096],
        [ 0.9178,  1.5810,  1.3010]], grad_fn=<EmbeddingBackward0>)

通过以上代码,就简单地实现了从token ID到词嵌入(embedding)层的转化,才真正从文本变成了Transformer能处理的输入。但是,同一个 Token 无论出现在句首还是句尾,都会从嵌入矩阵中查到相同的初始向量,但仅靠这种限制仍不足以准确表示 Token 的具体位置和彼此距离,因此还需要向模型注入位置表示。

4.2 更加严谨的位置编码处理

Transformer又是怎么利用这个position的呢?很多书都会说一句:

Token Embedding + Position Embedding = 输入Transformer

但是几乎没有解释:**Transformer 到底是怎么"利用"Position Embedding 的?**其实答案没有想象中那么神秘,记住本质:

Transformer 并不会专门去解析一个单独的“位置字段”,而是在 Token EmbeddingPosition Embedding 相加后的混合向量中学习利用位置信号。

例如一句话:

我 爱 AI

Token Embedding 可能是(用3维举例)

[0.3, 0.8, 0.2][0.5, 0.1, 0.7]

AI
[0.9, 0.4, 0.6]

词出现的Position Embedding可能是(用3维举例)

第1个位置
[0.2, 0.1, 0.4]

第2个位置
[-0.3,0.7,0.5]

第3个位置
[0.8,-0.1,0.2]

怎么处理位置呢?其实没有想象中的那么复杂,大道至简,只是单纯相加而已:

=[0.3,0.8,0.2]+[0.2,0.1,0.4]=[0.5,0.9,0.6]

同理:
爱=[0.2,0.8,1.2]
AI=[1.7,0.3,0.8]

于是进入 Transformer 的其实已经不是

我
爱
AI

而是可以理解为,位置已经融合进原始的Token ID嵌入的向量里面了。

(我是第1个)

(爱是第2个)

(AI是第3个)

那么Transformer怎么看出来这是位置?嘿嘿,其实它不会读取一个单独的“位置字段”。其实不必惊讶,因为作为Transformer,从来没人告诉它

这一部分是Token

这一部分是Position

模型只看到相加之后的混合向量,例如

0.5
0.9
0.6

这些数字。剩下的,全靠训练自己学。通俗理解是,Transformer会在大量训练中逐渐学会利用这些混合向量里的位置信号。

好比,假设你训练一个AI识别人。输入给AI:

年龄
身高
体重

值为:

25
180
70

AI不会知道

第一个数字叫年龄

第二个叫身高
……

它只是不断训练:

看到这种组合

预测正确

参数更新

慢慢地,它自己学出来:

哦

第一列数字好像决定年龄

第二列决定身高

……

Transformer学习Position也是一样,那位置信息是不是就完全没实际意义呢?其实不是。位置信息会通过输入向量影响后续各层的注意力计算和非线性变换,这才是关键。这里先简短举例说明问题,不必深究Attention,后续会有章节重点说Attention

于是得到了预训练前的文本处理全流程;

下面进入Python的实操环节,代码如下,也是整篇文章原理的所有代码。

import torch
from torch.utils.data import Dataset, DataLoader
from pathlib import Path
from tiktoken._educational import *


# 本文件演示 LLM 中“位置嵌入”的概念和实现:
#   Transformer 的 Self-Attention 本质上是位置无关的——它看到的只是一堆 token,
#   不知道谁在前谁在后。Position Embedding 的作用就是给每个 token 注入它在序列中的
#   位置信息。
#
#   流程:
#     1. Token Embedding:把 token ID 映射为稠密向量
#     2. Position Embedding:为每个位置索引生成一个位置向量
#     3. 逐元素相加:token 向量 + position 向量 = 带有位置信息的最终输入


class GPTDatasetV1(Dataset):
    """滑动窗口数据集:将文本按 stride 步长滑动,切分为 (input_chunk, target_chunk) 训练对。

    target_chunk 是 input_chunk 整体后移一位的 token 序列,
    模型在每个位置上都要预测“下一个 token”,这正是 GPT 类模型的训练目标。
    """

    def __init__(self, txt, tokenizer, max_length, stride):
        self.input_ids = []
        self.target_ids = []

        # 将整篇文本用 BPE 编码成 token ID 序列
        token_ids = tokenizer.encode(txt)

        # 滑动窗口切分:
        #   - max_length: 每个样本包含的 token 数(窗口大小)
        #   - stride:     窗口每次滑动的步长(stride < max_length 时窗口重叠)
        #   - 结束条件: i + max_length 不能越界,末尾不足一个窗口的数据丢弃
        for i in range(0, len(token_ids) - max_length, stride):
            # input_chunk:  [t_i, t_{i+1}, ..., t_{i+max_length-1}]
            input_chunk = token_ids[i:i + max_length]
            # target_chunk: [t_{i+1}, t_{i+2}, ..., t_{i+max_length}]
            target_chunk = token_ids[i + 1: i + max_length + 1]
            self.input_ids.append(torch.tensor(input_chunk))
            self.target_ids.append(torch.tensor(target_chunk))

    def __len__(self):
        """返回数据集中样本的总数"""
        return len(self.input_ids)

    def __getitem__(self, idx):
        """返回第 idx 个样本的 (input_ids, target_ids) 对"""
        return self.input_ids[idx], self.target_ids[idx]


def create_dataloader_v1(txt, batch_size=4, max_length=256,
                         stride=128, shuffle=True, drop_last=True, num_workers=0):
    """创建 DataLoader 的工厂函数。

    参数说明:
        max_length: 每个训练样本的 token 数(上下文窗口大小),GPT-2 常用 1024
        stride:     滑动窗口步长;设为 max_length 则不重叠,小于 max_length 则重叠
        drop_last:  丢弃不足 batch_size 的最后一个 batch,防止训练时 loss 异常波动
    """
    # 使用 GPT-2 的 BPE 编码器(vocab_size=50257)
    tokenizer = tiktoken.get_encoding("gpt2")
    # 构造滑动窗口数据集
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
    # 包装为 PyTorch DataLoader,支持批量加载、随机打乱等
    dataloader = DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=shuffle,  # 每个 epoch 随机打乱数据顺序
        drop_last=drop_last,  # 丢弃最后不足一个 batch 的残余数据
        num_workers=num_workers  # 并行加载数据的 worker 进程数
    )
    return dataloader


if __name__ == '__main__':
    # 词表大小和嵌入维度(与 GPT-2 Small 一致)
    vocab_size = 50257
    output_dim = 256
    torch.manual_seed(0)

    # Token Embedding:将 token ID 映射为稠密向量
    # 权重矩阵形状: (50257, 256),每一行是一个 token 的向量表示
    token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)

    # 加载文本并构造 DataLoader
    current_dir = Path(__file__).parent
    text_file = current_dir.parent / "text" / "the-verdict.txt"
    with open(text_file, "r", encoding="utf-8") as f:
        raw_text = f.read()

    # max_length=4, stride=4:每个样本恰好 4 个 token,窗口无重叠
    dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=4, stride=4)
    data_iter = iter(dataloader)
    inputs, targets = next(data_iter)
    print(f"Inputs shape (batch_size, max_length): {inputs.shape}")
    print("Token IDs (前 8 个样本):\n", inputs)
    print(f"Targets shape (batch_size, max_length): {targets.shape}")
    print("Target Token IDs:\n", targets)

    # 查询 Token Embedding,得到 (8, 4, 256) 的张量
    # 含义:8 个样本 × 每个样本 4 个 token × 每个 token 256 维向量
    token_embeddings = token_embedding_layer(inputs)
    print(f"\nToken Embeddings 形状 (batch, seq_len, embed_dim): {token_embeddings.shape}")
    print("Token Embeddings(第 1 个样本):\n", token_embeddings[0])

    # --- Position Embedding:为每个位置索引注入位置信息 ---
    #
    # 问题:如果不加入位置编码,Transformer 的 Self-Attention 本身缺少显式顺序信息——
    #       它对序列位置是置换等变的:输入顺序调换,输出也会相应调换。
    #       因此仅凭注意力计算本身,模型无法知道谁在前谁在后。
    # 解决:给每个位置学习一个独立的位置向量,加到 token 向量上。
    #
    # context_length=4:最大序列长度,即最多支持 4 个位置 (0,1,2,3)
    # 实际 GPT-2 的 max_position=1024,GPT-3 是 2048
    context_length = 4
    pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)

    # torch.arange(context_length) → tensor([0, 1, 2, 3]),即 4 个位置索引
    # 查询后得到 (4, 256):位置 0 的向量、位置 1 的向量、...
    pos_embeddings = pos_embedding_layer(torch.arange(context_length))
    print(f"\nPosition Embeddings 形状 (context_length, embed_dim): {pos_embeddings.shape}")
    print("Position Embeddings(4 个位置各自 256 维向量):\n", pos_embeddings)

    # 关键一步:Token Embedding + Position Embedding(逐元素相加)
    # token_embeddings: (8, 4, 256)
    # pos_embeddings:        (4, 256) —— PyTorch 自动广播到 (8, 4, 256)
    # 加法后,同一个位置上的不同样本共享同一个位置向量
    input_embeddings = token_embeddings + pos_embeddings
    print(f"\n最终输入嵌入形状 (batch, seq_len, embed_dim): {input_embeddings.shape}")
    print("最终输入嵌入(第 1 个样本) = Token + Position:\n", input_embeddings[0])



代码运行效果如下,看到这些代码和效果,你想到了什么?如果你读过《三体》,一定对"智子"印象深刻。三体文明先把一个质子展开,在巨大的二维平面上雕刻复杂电路,再重新折叠成一个看似普通的质子。Embedding 虽然没有这么科幻,但也有几分相似:一个看似毫不起眼的 Token ID,本身只是一个简单编号;经过嵌入层映射后,却对应着一个包含丰富语义信息的高维向量。不同的是,智子依靠的是物理空间的展开,而 Embedding 本质上只是从一个庞大的向量表中查出对应的那一行数据。两者机制完全不同,但都体现了一个共同的思想:简单的外表背后,可以蕴含远超想象的丰富信息。

Inputs shape (batch_size, max_length): torch.Size([8, 4])
Token IDs (8 个样本):
 tensor([[   13,  8759,  2763,   438],
        [   11,   290,  1816,   866],
        [  284,  1064,   503,  1521],
        [   11,  2087,   329,   616],
        [  286, 32315, 12606,  9074],
        [  568,   340,   373,   645],
        [ 1522,   502,    13,  1320],
        [ 2376, 26927,   616,   670]])
Targets shape (batch_size, max_length): torch.Size([8, 4])
Target Token IDs:
 tensor([[ 8759,  2763,   438,  1169],
        [  290,  1816,   866,   290],
        [ 1064,   503,  1521,    13],
        [ 2087,   329,   616, 35957],
        [32315, 12606,  9074,    13],
        [  340,   373,   645,  1049],
        [  502,    13,  1320,   338],
        [26927,   616,   670,   526]])

Token Embeddings 形状 (batch, seq_len, embed_dim): torch.Size([8, 4, 256])
Token Embeddings(第 1 个样本):
 tensor([[ 0.0657,  0.2796, -1.0797,  ..., -1.1799,  0.4073,  0.3755],
        [-1.3436,  0.2447,  1.8961,  ...,  0.6354, -0.4566,  1.1165],
        [ 0.6949, -1.9462, -0.4282,  ...,  1.0817,  0.1718, -0.1362],
        [ 0.1888, -0.1743, -1.5887,  ...,  0.0404, -0.2062,  1.0268]],
       grad_fn=<SelectBackward0>)

Position Embeddings 形状 (context_length, embed_dim): torch.Size([4, 256])
Position Embeddings(4 个位置各自 256 维向量):
 tensor([[ 0.2622,  0.3008, -0.9968,  ...,  0.4018, -0.9568, -0.4577],
        [ 0.0143,  2.0074,  0.7900,  ..., -0.8851,  0.4023,  0.6870],
        [-1.0412, -0.3501,  1.0060,  ..., -0.7935, -0.1178,  0.1919],
        [ 0.5343,  0.7589, -0.2145,  ..., -0.0861, -0.4651,  2.1439]],
       grad_fn=<EmbeddingBackward0>)

最终输入嵌入形状 (batch, seq_len, embed_dim): torch.Size([8, 4, 256])
最终输入嵌入(第 1 个样本) = Token + Position:
 tensor([[ 0.3279,  0.5804, -2.0765,  ..., -0.7781, -0.5494, -0.0822],
        [-1.3293,  2.2521,  2.6861,  ..., -0.2497, -0.0544,  1.8034],
        [-0.3463, -2.2963,  0.5778,  ...,  0.2882,  0.0540,  0.0557],
        [ 0.7230,  0.5846, -1.8032,  ..., -0.0457, -0.6712,  3.1707]],
       grad_fn=<SelectBackward0>)

Process finished with exit code 0

针对这个嵌入层embedding映射的原理,是不是还是有点模糊?其实没有这么复杂,想得简单一点,比如一开始的输入x矩阵,也就是8乘4的张量(这里也是矩阵);长度是 torch.Size([8, 4]),为什么embedding后就变成了 torch.Size([8, 4, 256]) 呢?原理其实很简单,原长度是 torch.Size([8, 4]),其实你是不是也可以理解为 torch.Size([8, 4, 1])?为了方便理解其实完全是可以的,只不过是说这个1很特殊,整个input第一批次的token ID矩阵就变成了取了8行作为输入,每行4token ID,每个token ID只有1个元素;好,现在关键的来了,就像《三体》"智子"的展开一般,你把这个1通过嵌入层embedding展开映射到一个256维的向量上,于是就变成了 torch.Size([8, 4, 256]),这样理解了吗?是不是很魔幻和艺术?因为只有在细节的256维(实际生产可能更高)上,你才可以对某个token ID精雕细琢。

以上就是大模型到底是怎么看懂一句话的,从 TokenEmbedding 全过程,总结如下;

  • 神经网络不能直接对原始文本进行计算,因此需要先将文本转换为数值表示。文本经过分词后会得到一系列 Token,再将每个 Token 映射为对应的 Token ID。随后,模型通过嵌入层把这些离散编号转换为连续向量。
  • Token 是分词器处理文本时使用的基本单位,既可能是完整单词,也可能是子词、标点、字符片段或字节组合。Token ID 只是词表中的索引,本身不包含可直接利用的语义关系。
  • 特殊 Token 用于表达普通文本之外的结构信息。例如,<|endoftext|> 可以标记一段文本的结束或不同文档之间的边界;某些分词器还会使用 <unk> 表示无法识别的内容。不过,GPT-2 所采用的字节级 BPE 能将任意文本拆解为字节级单元,因此通常不需要依赖未知词标记。
  • BPE 会从训练语料中学习常见字符或字节组合,并逐步将它们合并为更长的 Token。常见词可能被表示为一个 Token,少见词则会被拆成多个子词或字节片段,从而在控制词表规模的同时覆盖几乎所有输入文本。
  • 在构造训练数据时,可以使用滑动窗口从连续 Token 序列中截取多个样本。输入序列通常作为模型的上下文,目标序列则整体向后移动一个位置,使模型学习根据已有 Token 预测下一个 Token
  • PyTorch 的嵌入层本质上是一个可训练的查找表。每个 Token ID 对应嵌入矩阵中的一行,查找后即可得到该 Token 的连续向量。嵌入矩阵会随着模型训练不断更新,使向量逐渐适应训练目标和数据特征。
  • 同一个 Token ID 在输入嵌入层中会查到相同的初始向量,但这个向量本身不包含位置信息。为了让模型理解词序,还需要额外加入位置表示。位置机制可以描述 Token 的绝对位置,也可以表达 Token 之间的相对距离。GPT-2GPT-3 等经典 GPT 模型使用可学习的绝对位置嵌入,并将其与 Token 嵌入逐元素相加,形成最终送入 Transformer 的输入。经过多层 Transformer 处理后,同一个 Token 在不同上下文中会得到不同的上下文化表示。

现在,你知道大模型到底是怎么看懂一句话的了吗?是否也更好地了解了为什么prompt工程会有那么多的建议,比如prompt要设定角色,尽量详细等等……

关于本篇文章用到的相应的学习代码,博主也放到了https://github.com/lauliu/LLM_tutorial,欢迎一起学习总结,采用Pythonuv包管理,下载同步项目前,记得先uv sync同步相关的依赖包。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐