[大模型实战 03] 拆解 Transformers:从原理图解到 HuggingFace Transformers实战

[大模型实战 03] 拆解 Transformers:从原理图解到 HuggingFace Transformers 实战

核心摘要 (TL;DR)

  • 原理:图解 Transformer 是如何通过“注意力机制”和“位置编码”来理解人类语言的。
  • 实战:在 Kaggle (双 T4 GPU) 环境下,拆解 HuggingFace 代码的“铁三角”(Config, Tokenizer, Model)。
  • 技巧:掌握 TemperatureTop_p,学会控制 AI 的“创造力”。

前言

各位友人们,大家好,这里是阿尔。在上一节的“炼丹”环境搭建中,咱们成功地将Qwen2.5模型运行了起来,跑通了。 但是相信大家对运行的时候的那些参数都代表着什么,都还是懵的。 这篇博客就是为此准备的,我们打算先快速大概地了解一下当前的大模型的底层原理,再结合在一起介绍通用的transformers库, 去看看代码和如何对应这些理论的。

1. Transformer 极简原理:大模型是怎么思考的?

大模型和普通的机器学习模型一样,本质也是一个函数,不同的是,传统机器学习可能输入的是一些整理好的数据,比如房子的尺寸,地段,购买时长,和市中心的距离等等数据,输出一个预测的放假,而大模型输入的是我们的问题,拿到的是大模型给出的回答。但,咱们究其根本,它们都可以看作是一个函数,我们输入一些东西,经过运算之后,输出给我们一些东西。

对于大模型,它的本质就是一个下一个字符预测器,我们输入一些文字,它只负责根据它所训练的海量数据,输出最符合,最有可能的下一个字符,就像一个文字接龙机器,其核心任务只有一个:根据上文,猜下一个字是什么。

为了实现这个目标,Transformer 架构经历了一个精密的流水线:

下面,我们会稍微详细一点地介绍一下各个步骤。

1.1 第一步:Token 化 (Tokenization) —— 查字典

大模型既然是一个函数,那么肯定是针对数字进行处理的,所以,我们就需要一个法子,去将我们的文字字符(甚至是图片)变成大模型认识的数字(或者说张量,向量)。 这就是Token化,去做这一步操作的函数,或者模块就是分词器(Tokenizer)

那么Tokenizer具体是如何工作的?它实际上就是将一个个的字符,对应成一个个数字,或者说ID,就像ASCII码一样,不过它做得更高级。

把每一个字都找一个数字对应,有点奢侈,特别是对于英语,act, acting,action,actor其实都有相同的词根,主要的语义来源于其词根act,所以分词器是按照词元(token)去拆分的,能把有些词拆成词根、前缀和后缀等等,当然具体如何拆,取决于字典如何定义,字典有多大。

这里我们给一个简单的例子

  • 输入:“我爱 AI”
  • 动作:切分 -> ["我", "爱", "AI"] -> 查表 -> [2301, 452, 1083]

1.2 第二步:Embedding & 位置编码 —— 赋予含义与顺序

有了token之后,我们想知道词和词的关系,我们想要通过一个可以量化的量去判断两个词是否是有关系的,关系多大。这就引入了我们的下一个模块,词嵌入模块(Embedding)。

  • Embedding (词向量):把每个 ID 变成一个长长的向量(比如 4096 维的数组)。这个向量在模型训练之前是随机的,其后随着海量的训练数据洗礼,越相关的词向量越靠近,越不相关的词越远离。这个向量代表了词的含义。比如“猫”和“狗”的向量在空间里距离很近,“苹果”和“手机”在某种语境下也更近。

光知道词和词的关系还不够,“我爱你”和“你爱我”的每一个词都是相同的,但是它们确实可以不相关的两个句子,“小狗咬了我”和“我咬了小狗”,也会被人视作“可以正常理解”和“这人好像不对劲”两种完全不同的理解。显而易见,词语在句中的位置是一个非常重要的信息,我们不能弄丢它,也需要将这一部分信息传递给模型训练时候去学习。

  • Positional Encoding (位置编码):Transformer 是并行计算的(它一眼看完所有词),这导致它不知道“我爱你”和“你爱我”的区别。所以,我们需要给每个词贴上一个“座位号”,告诉模型谁在前面,谁在后面。

1.3 第三步:Self-Attention (自注意力) —— 寻找关系

接下来就是大模型的灵魂,我们想知道一句话里的每一个词元和其他词元的关联度,其实就是上下文的联系。当模型处理“苹果”这个词时,如果上下文里有“手机”、“发布会”,注意力机制会告诉模型:“嘿,这里的‘苹果’指的是科技公司,不是水果!”,自注意力机制,让模型能理解上下文。

1.4 第四步:MLP (前馈神经网络) —— 消化吸收

如果说注意力机制是“看”,那么 MLP 就是“想”。它包含多层神经元,负责对提取到的信息进行复杂的非线性变换和逻辑推理,也就是传统的深度学习。

1.5 第五步:Decoder & Softmax —— 输出概率

经过层层计算,模型最终会输出一个包含了所有词汇(比如 15 万个词)的概率列表

  • AI (80%)
  • (10%)
  • (5%)

    最后,我们根据这个概率列表,选择下一个词。

这就是大模型词语接龙的原理了。

2. 拆解模型文件夹:下载下来的到底是什么?

理论讲完了,我们来看看在 Kaggle 的文件系统里,这些理论变成了什么文件。

当你下载一个模型(以 Qwen2.5-7B 为例)时,文件夹结构如下:

2.1 核心架构:config.json

config.json: 是大模型的身份证,也可以说是体检表,它其中就是真正的我们的模型,具体由哪些层构成,是什么架构类型,隐藏层有多深,注意力头的数量有几个,词表的大小是多少。对于大模型而言,它就是模型本身,也是骨架,因为大模型重要的是训练完的参数,模型本身是很小的。

2.2 行为预设:generation_config.json

generation_config.json:是模型的出厂默认设置。

2.3 大脑本身:*.safetensors和*.index.json

有了config.json中的躯体,我们再从*.safetensors和*.index.json载入灵魂,这才是我们能说会道的大模型。

  • model-xxxxx.safetensors:这里面存的是实打实的张量(Tensor)数据,即数十亿个参数的浮点数。为了方便存储和加载,通常会被切分成多个 2GB-5GB 的小文件(Shard)。
  • model.safetensors.index.json:这是一张藏宝图。因为权重被切分了,模型需要知道“第 5 层的权重”到底藏在哪个文件里。
    • 内部长这样
      1
      2
      3
      4
      5
      6
      7
      {
      "metadata": { "total_size": 15423653888 },
      "weight_map": {
      "model.layers.0.self_attn.q_proj.weight": "model-00001-of-00004.safetensors",
      "model.layers.20.mlp.gate_proj.weight": "model-00003-of-00004.safetensors"
      }
      }

数字和文字的翻译官:tokenizer相关文件

  • vocab.json / merges.txt:这是最原始的生词表。记录了所有字、词根对应的 ID。
  • tokenizer.json:这是一个编译后的高效字典文件,包含了分词的所有逻辑(Pre-tokenization, Normalization 等),加载速度比读原始文本快得多。
  • tokenizer_config.json (至关重要):这是分词器的配置文件
    • 它定义了特殊符号(Special Tokens):比如哪个 ID 代表“开始”,哪个代表“结束”。
    • 它包含了 Chat Template (聊天模板):这是一段 Jinja2 代码,决定了 apply_chat_template 如何工作。
    • 内部长这样
      1
      2
      3
      4
      5
      {
      "chat_template": "{% for message in messages %}...<|im_start|>...",
      "eos_token": "<|im_end|>",
      "pad_token": "<|endoftext|>"
      }

3. Transformers库实战:代码中的“铁三角”

在Transforms库中,我们永远绕不开三个核心类。

环境准备:
在 Kaggle 右侧设置中,确保 Internet: OnAccelerator: GPU T4 x2

1
!pip install -U transformers accelerate bitsandbytes

3.1 AutoTokenizer (翻译官)

对应理论中的 Token 化 步骤,和模型文件中的tokenizer相关文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from transformers import AutoTokenizer

model_path = "/kaggle/input/qwen2.5/transformers/7b-instruct/1/"

# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)

text = "Transformer is amazing"
# 1. 编码 (Encode): 文本 -> 数字 ID
input_ids = tokenizer.encode(text)
print(f"原文: {text}")
print(f"数字 ID: {input_ids}")

# 2. 解码 (Decode): 数字 ID -> 文本
decoded_text = tokenizer.decode(input_ids)
print(f"还原: {decoded_text}")

得到的结果将会是这样

1
2
3
原文: Transformer is amazing
数字 ID: [46358, 374, 7897]
还原: Transformer is amazing

对话格式:Chat Template
大模型需要特定的对话格式(Prompt)。来将模型的回答,和用户的问题做区分, 我们一般都可以通过载入语言模型对应模板(不同家的模型,可能模板会有不同),甚至去拼装历史记录。

1
2
3
4
5
6
7
8
messages = [
{"role": "system", "content": "你是一个物理学家。"},
{"role": "user", "content": "用一句话解释相对论。"}
]

# 自动应用聊天模板
formatted_prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
print("模型实际看到的输入:\n", formatted_prompt)

运行结果如下

1
2
3
4
5
6
模型实际看到的输入:
<|im_start|>system
你是一个物理学家。<|im_end|>
<|im_start|>user
用一句话解释相对论。<|im_end|>
<|im_start|>assistant

看到这里,我们也能更好地理解,为什么一个本质是词语接龙的模型,能够区分问题,然后做出回答。因为在训练的过程中,我们会让他知道<|im_start>表示一个message的开始,其中会告诉模型,这句话是谁说的,这句话在什么位置结束,它接龙的时候也会带上开始和结束符号,在推理模型中,甚至会带上思考的标签。

3.2 AutoModel (大脑本体)

对应理论中的 Embedding -> Attention -> MLP 计算过程,通过从config.json中加载模型躯体,在加载上模型的safetensor灵魂数据以及generation_config.json的默认初始化,
在 Kaggle 上,我们拥有 双 T4 (15GB x 2),一定要利用 device_map="auto" 让库自动分配显存。

1
2
3
4
5
6
7
8
9
10
11
12
from transformers import AutoModelForCausalLM
import torch

# 加载模型
model = AutoModelForCausalLM.from_pretrained(
model_path,
device_map="auto", # 关键!自动将模型切分到两张 T4 显卡上
torch_dtype=torch.float16, # 使用半精度,节省显存
trust_remote_code=True
)

print(f"模型加载成功!显存分布: {model.hf_device_map}")

输出结果为

1
模型加载成功!显存分布: {'model.embed_tokens': 0, 'model.layers.0': 0, 'model.layers.1': 0, 'model.layers.2': 0, 'model.layers.3': 0, 'model.layers.4': 0, 'model.layers.5': 0, 'model.layers.6': 0, 'model.layers.7': 0, 'model.layers.8': 0, 'model.layers.9': 0, 'model.layers.10': 0, 'model.layers.11': 0, 'model.layers.12': 1, 'model.layers.13': 1, 'model.layers.14': 1, 'model.layers.15': 1, 'model.layers.16': 1, 'model.layers.17': 1, 'model.layers.18': 1, 'model.layers.19': 1, 'model.layers.20': 1, 'model.layers.21': 1, 'model.layers.22': 1, 'model.layers.23': 1, 'model.layers.24': 1, 'model.layers.25': 1, 'model.layers.26': 1, 'model.layers.27': 1, 'model.norm': 1, 'model.rotary_emb': 1, 'lm_head': 1}

4. 掌控生成的“调节旋钮”

在模型输出的过程中,我们有一些参数可以对输出结果进行调节,对应于我们讲理论部分的中的 第五步 (Softmax 概率输出), 我们有一堆下一个词元的概率分布了,但是我们应该如何去选择呢?

4.1 Temperature (温度)

我们可以设定的Temperature参数,我们在generation_config.json中也看见过它

值越大,更热,更具创造性,更容易输出各种天马行空的词, 会缩小所有词元的差距,雨露均沾,以达到创造性,让低概率的词,也有机会被选中,当然,也更容易胡说八道,出现幻觉.
值越小,更冷,更严肃,在温度为0的时候甚至会固定输出最高的那个词,它会拉大高概率和低概率的差距,赢家通吃,让模型的回答更稳定,严谨。

4.2 Top_p (核采样)

和温度不同,我们还有另一种方式,这种方式更类似于拉网,我们只要可能性前80%的词,在那些词里进行挑选,这个可能性就是P,这个选词(采样)方法又叫top_p(核采样).
简而言之:其只在累积概率达到 P (e.g., 0.9) 的前几个词里选。直接切掉尾部那些极低概率的离谱词。

4.3 实验一下

我们这里先定义一个函数,以温度和top_p为参数去测试不同的参数对回答的影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 定义一个测试函数
def test_generation(temp, top_p, prompt_text):
messages = [
{"role": "system", "content": "你是一个前卫的科幻小说家。"},
{"role": "user", "content": prompt_text}
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer([text], return_tensors="pt").to(model.device)

print(f"\n======== 设置: Temperature={temp}, Top_p={top_p} ========")

try:
generated_ids = model.generate(
**inputs,
max_new_tokens=100, # 限制长度,方便快速看结果
temperature=temp,
top_p=top_p,
do_sample=True, # 必须开启采样
pad_token_id=tokenizer.eos_token_id
)
response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
# 只打印回答部分
print(response.split('assistant')[-1].strip())
except Exception as e:
print(f"生成出错: {e}")

温度实验

然后再其下新建code block去测试,看看效果

1
2
3
4
5
6
7
8
9
10
11
prompt = "请用这三个词写一个微故事:量子、失恋、炒饭。"

# 1. 低温模式 (0.1):严谨、死板
test_generation(temp=0.1, top_p=0.9, prompt_text=prompt)

# 2. 适中模式 (0.7):正常、流畅
test_generation(temp=0.7, top_p=0.9, prompt_text=prompt)

# 3. 高温模式 (1.5):疯狂、混乱
# 注意:可能会输出乱码或完全不通顺的句子
test_generation(temp=1.5, top_p=0.9, prompt_text=prompt)

这是我的运行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
======== 设置: Temperature=0.1, Top_p=0.9 ========
在量子世界里,时间与空间的概念变得模糊不清,而李明的世界也因为一段失败的恋情而变得一片混沌。他尝试着用量子纠缠理论来修复自己破碎的心灵,却意外地将自己从现实世界送入了一个平行宇宙。

在这个平行宇宙中,李明发现了一家特别的餐馆,这里的厨师是一位曾经的恋人,她正在为一位顾客准备一道特别的炒饭。这道炒饭不仅色香味俱全,还

======== 设置: Temperature=0.7, Top_p=0.9 ========
在量子世界的边缘,李明独自一人坐在一家不起眼的小餐馆里,面前是一碗普通的炒饭。他和女朋友分手的原因,是因为她沉迷于虚拟现实中的量子世界,而他却对现实世界充满了留恋。

就在几天前,他们最后一次争吵后,她告诉他:“我找到了真正的自我——一个穿梭在量子世界的探索者。”她留下了一盘未吃完的炒饭,然后消失在了虚拟现实中。

李明望着那盘炒饭,

======== 设置: Temperature=1.5, Top_p=0.9 ========
标题:时空泡饭

李明最近失恋了,每天只能煮一大锅泡饭来消磨时间。

这天李明正在做饭,他忽然接收到一条量子信号。他惊奇地发现那是自己失恋前女友的坐标位置。想到能与自己相爱过的人共度时光是多么美妙的事情,他便将自己煮了一锅泡饭送进了坐标传送器。

结果却是一锅炒饭。

李明百思不解。

top_p实验

接下来是top_p

1
2
3
4
5
6
7
prompt_2 = "请给一种不存在的颜色起个名字,并描述它的样子。"

# 1. 极窄采样 (0.01):只选概率最高的那个词(近似贪婪搜索)
test_generation(temp=0.8, top_p=0.01, prompt_text=prompt_2)

# 2. 宽广采样 (0.95):允许罕见词出现
test_generation(temp=0.8, top_p=0.95, prompt_text=prompt_2)

输出结果为

1
2
3
4
5
6
7
======== 设置: Temperature=0.8, Top_p=0.01 ========
这种不存在的颜色我命名为“星尘紫”。它是一种梦幻般的颜色,介于紫色和银色之间,仿佛是宇宙中无数微小的星辰碎片在闪烁时所散发出的光芒。在不同的光线下,星尘紫会呈现出不同的色调,有时偏紫,有时偏银,有时又像是掺杂了点点星光的淡蓝色。它既神秘又优雅,仿佛能让人感受到宇宙的浩瀚与深邃。

======== 设置: Temperature=0.8, Top_p=0.95 ========
这种不存在的颜色我称之为“星际幻彩”(Stellar Mirage)。在视觉上,它并非单一色调,而是一种动态变化的色彩组合,像是无数微小的光点在眼前闪烁变幻,这些光点包含了所有可见光谱的颜色,同时又带着一种神秘的、不可名状的色彩。

当观察者注视着“星际幻彩”的时候,他可以看到蓝、绿、紫等颜色快速地在眼前切换和混合,它们以一种几

5. 完整 Transformers 代码实战

把所有积木搭在一起,这就是一段标准的模型推理代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

# 1. 设置模型 ID
model_id = "/kaggle/input/qwen2.5/transformers/7b-instruct/1/"

# 2. 加载翻译官
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)

# 3. 加载大脑 (双卡模式)
model = AutoModelForCausalLM.from_pretrained(
model_id,
device_map="auto",
torch_dtype=torch.float16,
trust_remote_code=True
)

# 4. 准备输入
prompt = "请用这三个词写一个微小说:Kaggle、深夜、爆显存"
messages = [
{"role": "system", "content": "你是一个幽默的程序员。"},
{"role": "user", "content": prompt}
]

text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
model_inputs = tokenizer([text], return_tensors="pt").to(model.device)

# 5. 生成 (调节参数!)
print(" 正在生成...")
generated_ids = model.generate(
**model_inputs,
max_new_tokens=512,
temperature=0.8, # 稍微有点创意
top_p=0.9, # 剔除离谱词
do_sample=True # 必须开启采样,温度才生效
)

# 6. 解码并输出
generated_ids = [
output_ids[len(input_ids):] for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
]
response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]

print("-" * 20)
print(f" 回答:\n{response}")
print("-" * 20)

这是输出结果

1
2
3
4
5
6
7
8
9
10
11
Loading checkpoint shards: 100%
 4/4 [00:13<00:00,  3.27s/it]
The module name (originally ) is not a valid Python identifier. Please rename the original module to avoid import issues.
WARNING:accelerate.big_modeling:Some parameters are on the meta device because they were offloaded to the cpu.
正在生成...
--------------------
回答:
在一个寒冷的深夜,李雷坐在他那间堆满咖啡罐和代码文件的房间里,屏幕上是Kaggle竞赛的数据集。他正尝试训练一个复杂的深度学习模型。然而,就在他认为胜利在望的时候,“嘶~”的一声,显示器瞬间变成了一片漆黑,伴随着一声悲壮的“爆显存了”。

李雷揉了揉眼睛,看着眼前一片空白的屏幕,心中充满了挫败感,但他转念一想:“还好不是‘爆内存了’,否则我这台老旧电脑可能就要彻底退休了。”于是,他又开始调整参数,希望能在这个深夜里找到那个隐藏在数据海洋中的宝藏。
--------------------

Hint:

所有的代码,都可以在 这个笔记本中直接获取运行哦

6. 常见问题 (Q&A)

Q: 在 Kaggle 上 device_map="auto" 是必须的吗?
A: 如果你使用单卡 T4 (15GB) 跑 7B 模型(约 14GB),勉强能塞进一张卡。但如果你开启了 Kaggle 的 T4 x2,为了利用全部 30GB 显存,必须加这个参数,否则模型只会塞进第一张卡,导致第一张爆满,第二张围观。

Q: 为什么生成的每一句话都不一样?
A: 因为我们开启了 do_sample=True 并且设置了 temperature > 0。模型在选择下一个词时是按概率随机抽取的。如果你想让结果每次都一样(比如做数学题),请设置 do_sample=False(此时温度失效,变为贪婪解码)。

Q: 什么是 Logits?
A: 在代码深处,模型输出的那个“概率表”在变成百分比之前,叫 Logits(未归一化的数值)。Softmax 函数的作用就是把 Logits 变成概率。你可以把 Logits 理解为模型对每个词的“原始打分”。

Q: Token 和字是一一对应的吗?
A: 不一定。

  • 英文:通常一个单词是一个 Token,长单词可能被切分。
  • 中文:通常一个汉字是一个 Token,但常见词(如“你好”)可能会合并为一个 Token。
  • 平均来说,0.75 个英文单词 ≈ 1 Token1 个汉字 ≈ 1.5 - 2 Token(取决于分词器效率,Qwen 的中文压缩率很高)。