大模型到底是怎么看懂一句话的——为什么在写prompt时会有一些规范?
目录
在3000米高空看完大语言模型(LLM)的基本结构后,略懂了模型会先在海量文本上进行预训练,并通过“预测下一个 token”这一看似简单的任务,逐步学习语言规律、语义关系和上下文信息。如今许多主流 LLM 都采用基于 Transformer 的纯解码器架构,预训练完成后,还可以继续通过微调,让模型更好地遵循指令或适应特定任务。
当你向 ChatGPT 提问时,它真的能“读懂”文字吗?其实,大模型既不认识汉字,也不理解单词,它看到的只是一个个数字。本文将用通俗易懂的方式,带你了解文本如何一步步变成向量,并最终成为大模型能够理解的信息,你懂了这些原理,就明白为什么在写与LLM的prompt 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 等 |
语义关系、上下文理解 |
| 图像 | 二维像素网格,具有空间特征 | CNN(ResNet、VGG)、ViT |
形状、纹理、颜色等视觉特征 |
| 音频 | 一维时序信号 | CNN+频谱图、RNN、Transformer |
频率、音调、时序依赖 |
| 视频 | 时空序列数据 | 3D CNN、RNN+CNN、Video Transformer |
时空特征、动作捕捉 |
其中,生成词嵌入的方法和框架很多,Word2Vec 是其中的经典方案。它通过学习目标词与上下文之间的关系,让经常出现在相似语境中的词获得相近的向量表示。因此,将词向量降维并可视化后,语义相近的词通常会聚集在一起,如下图所示。

词嵌入的维度可以从几维扩展到数千维。二维嵌入便于展示,而高维表示通常具有更大的语义表达容量,但也会增加参数量和计算成本。与 Word2Vec 等预训练静态词向量不同,LLM 通常在预训练过程中学习自己的 token 嵌入矩阵,使其更适配模型的训练目标和数据。token 进入模型时会获得一个初始嵌入,经过 Transformer各层处理后,再形成与当前上下文相关的隐藏表示。实际LLM 的模型维度通常很高。例如,GPT-2 Small 的隐藏维度为 768,而 GPT-3 175B 为 12,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 Token和Token ID处理
完成了将短篇小说分词为单独的token,但token本身并不是方便计算的数学值。为了方便计算,需要将token先转成整型的token ID,这就是所谓的编码;随后再将这些token ID组织成张量形式,作为嵌入层的索引输入。真正可供模型进行连续数值计算的向量,要经过Embedding层查表后才得到。当然,token ID和token之间的转换通常是可逆的,毕竟计算机偏好整型索引,而人偏好对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
输出的token和token 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化处理
上一节用到了一种大力出奇迹的方式去尽可能的穷举所有出现过的token的token 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前的文本处理本质;这么通用的方案,就没有现成的包吗?还真有,接下来就介绍一种现成的基于字节对编码(BPE,Byte Pair Encoding)概念的更复杂的分词方案。BPE及其变体曾被用于GPT-2、GPT-3等模型的分词体系,OpenAI后续模型也使用了类似的子词或字节级编码思想。
2. 现成的字节对编码包
下面开始从0到1实现字节对编码(BPE,Byte Pair Encoding)……开玩笑的,重复造轮子一直是技术的大忌,除非迫不得已;直接使用一个名为tiktoken的现有Python开源库
GitHub:(https://github.com/openai/tiktoken)- 简介:看到名字,这个库是不是
TikTok的?嘿嘿,还真不是,tiktokenis a fastBPEtokeniserfor use withOpenAI’s models.,该库基于Rust中的源代码非常高效地实现了BPE算法。与其他Python库类似,可以直接通过Python的pip安装程序从终端安装tiktoken库。
此次不必细究BPE的全部功能,如果对BPE有兴趣的同学可以参考GitHub及官方更多资料,这里主要实现:
- 用 educational 版
SimpleBytePairEncoding可视化BPE合并过程 - 加载
GPT-4o官方编码器(仅用于对比生成的token ID,本质其实类似) - 基于
cl100k_base扩展自定义特殊token,构建一个GPT-4风格的编码器 - 输出
token ID->token文本的映射条目 - 对样例文本做 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-116 的 bpe_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” 为例拆解,假设词表里已有 ll、ell、ello、Hello 这些合并规则,过程大致是:
| 步骤 | 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 训练数据构造的核心流程——滑动窗口切分法。
-
文本 →
Token ID:用BPE编码器(GPT-2的gpt2编码)将整篇文本编码为一长串整数 ID。 -
滑动窗口切分 (输入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”。
-
-
Dataset→DataLoader,将切好的(输入x,目标y)对存入PyTorchDataset,再通过DataLoader,实现批量加载(batch)、随机打乱(shuffle)、丢弃残余 batch(drop_last)。 -
两个关键参数:
-
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把文本转换成离散的token,token再编码成为token ID。token 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=6、output_dim=3,但在真实模型中:
GPT-2 Small:vocab_size=50257,embedding_dim=768GPT-3:embedding_dim=12288,词表规模与所用tokenizer相关LLaMA-7B:vocab_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 Embedding与Position 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行作为输入,每行4个token ID,每个token ID只有1个元素;好,现在关键的来了,就像《三体》"智子"的展开一般,你把这个1通过嵌入层embedding展开映射到一个256维的向量上,于是就变成了 torch.Size([8, 4, 256]),这样理解了吗?是不是很魔幻和艺术?因为只有在细节的256维(实际生产可能更高)上,你才可以对某个token ID精雕细琢。
以上就是大模型到底是怎么看懂一句话的,从 Token 到 Embedding 全过程,总结如下;
- 神经网络不能直接对原始文本进行计算,因此需要先将文本转换为数值表示。文本经过分词后会得到一系列
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-2、GPT-3等经典GPT模型使用可学习的绝对位置嵌入,并将其与Token嵌入逐元素相加,形成最终送入Transformer的输入。经过多层Transformer处理后,同一个Token在不同上下文中会得到不同的上下文化表示。
现在,你知道大模型到底是怎么看懂一句话的了吗?是否也更好地了解了为什么prompt工程会有那么多的建议,比如prompt要设定角色,尽量详细等等……
关于本篇文章用到的相应的学习代码,博主也放到了https://github.com/lauliu/LLM_tutorial,欢迎一起学习总结,采用Python的uv包管理,下载同步项目前,记得先uv sync同步相关的依赖包。
更多推荐


所有评论(0)