做大模型应用开发,绕不开的第一个问题就是模型从哪里来。答案是HuggingFace,它相当于模型界的GitHub,上面有大量开源模型、数据集和配套工具,后面的很多工作都会围绕它展开。
本文以一个中文GPT2模型为例,用HuggingFace的transformers库把它下载到本地,看清它的文件和内部结构,再脱离网络把它调用起来。
安装只需要transformers这一个库。分词器所在的tokenizers库会在安装transformers时自动装上,不用单独安装。
bash
pip install transformers
01 下载模型与分词器
下载模型用AutoModelForCausalLM,下载分词器用AutoTokenizer,两者都通过cache_dir参数指定保存路径。本文使用的模型是uer/gpt2-chinese-cluecorpussmall,一个在中文语料上训练的GPT2。
python
from transformers import AutoModelForCausalLM, AutoTokenizer
# 模型名称和本地保存路径
model_name = "uer/gpt2-chinese-cluecorpussmall"
cache_dir = "model/uer/gpt2-chinese-cluecorpussmall"
# 下载模型
AutoModelForCausalLM.from_pretrained(model_name, cache_dir=cache_dir)
# 下载分词器
AutoTokenizer.from_pretrained(model_name, cache_dir=cache_dir)
print(f"模型分词器已下载到:{cache_dir}")
下载完成后,模型文件会保存在cache_dir下的snapshots目录里,其中包含config.json的那一层就是真正的模型目录。
02 分词器:文本如何变成模型能读的数字
模型只认数字,不认文字,所以文本要先经过分词器切成一个个token,再到词表里查出每个token的编号,转换成数字之后才能输入模型。这里的词表就是模型目录里的vocab.txt,共21128行,编号就是token在vocab.txt中的编号。
这个模型的分词器类型是BertTokenizer,按字切分。以"今天天气不错,我们去旅游吧!"为例,会被切成今 / 天......。"今"在vocab.txt中的编号是791,"天"的编号是1921,所以模型实际接收到的输入就是791 / 1921......。

除了vocab.txt,分词器还靠两个文件决定自己的行为。special_tokens_map.json的作用很简单,就是告诉分词器哪个字符串扮演哪个角色。
unk_token是Unknown的缩写,词表里没有的字(生僻字、部分emoji)统一替换成UNK。
sep_token是Separator,也就是分隔符,在BERT里用来分隔两个句子,也标记句子结尾。
pad_token是Padding,也就是填充符,一个batch里的句子长短不一,短的要补到同样长度,补的就是PAD。
cls_token是Classification,放在序列开头。
mask_token用于BERT预训练,训练时会随机遮住一些字让模型去猜,遮住的位置用MASK表示。
json
{
"unk_token": "[UNK]",
"sep_token": "[SEP]",
"pad_token": "[PAD]",
"cls_token": "[CLS]",
"mask_token": "[MASK]"
}
tokenizer_config.json 则是分词器的行为配置,前五项特殊符号与上面相同,后面四项决定了切分时的具体处理。do_lower_case决定是否把英文统一转成小写,false表示不转。
tokenize_chinese_chars决定是否在每个汉字两侧加空格,让每个汉字单独成为一个片段。BERT分词器的第一步是按空格切词,中文没有空格,整句话会被当成一个"词"。这个开关会在每个汉字前后插入空格,于是每个汉字都成为独立片段,再去词表里查编号。
strip_accents决定是否去掉重音符号(如 é → e),null表示跟随do_lower_case,也就是如果把英文统一转成小写,则去掉重音符号,反之亦然。
model_max_length决定分词器认为的最大长度,和模型的n_positions对应。
json
{
"do_lower_case": false,
"unk_token": "[UNK]",
"sep_token": "[SEP]",
"pad_token": "[PAD]",
"cls_token": "[CLS]",
"mask_token": "[MASK]",
"tokenize_chinese_chars": true,
"strip_accents": null,
"model_max_length": 1024
}
03 模型结构:编号如何变成向量,再穿过12层
模型的结构由config.json决定,它记录了向量维度、层数、上下文长度、词表大小等关键参数。
n_embd是隐藏层维度,表示每个token在模型内部被表示成768维向量。n_head是注意力头数,表示模型有12个注意力头,每个头768/12 = 64 维,各自独立做一次注意力计算,最后再把12个结果拼回768维。
n_layer是Transformer解码器的层数,对应下面模型结构中的12个GPT2Block。n_positions是最大上下文长度,提示词和生成内容加起来不能超过1024个 token,超出会报错或效果崩坏。
do_sample和max_length是文本生成时的配置,do_sample表示是否开启采样生成,max_length表示生成的总长度(含提示词),提示词写得越长,实际生成的内容就越少。
vocab_size是词表大小,对应vocab.txt中的21128行。
json
{
"activation_function": "gelu_new",
"architectures": [
"GPT2LMHeadModel"
],
"attn_pdrop": 0.1,
"embd_pdrop": 0.1,
"gradient_checkpointing": false,
"initializer_range": 0.02,
"layer_norm_epsilon": 1e-05,
"model_type": "gpt2",
"n_ctx": 1024,
"n_embd": 768,
"n_head": 12,
"n_inner": null,
"n_layer": 12,
"n_positions": 1024,
"output_past": true,
"resid_pdrop": 0.1,
"task_specific_params": {
"text-generation": {
"do_sample": true,
"max_length": 320
}
},
"tokenizer_class": "BertTokenizer",
"vocab_size": 21128
}
加载模型之后,用print(model) 就能打印出完整的结构。下面是代码和打印结果,其中的注释是后加的,用来对应config.json里的参数。
python
from transformers import AutoModelForCausalLM
# 包含 config.json 的目录(snapshots 下的哈希目录),只支持绝对路径
model_dir = r"D:\models--uer--gpt2-chinese-cluecorpussmall\snapshots\c2c0249d8a2731f269414cc3b22dff021f8e07a3"
model = AutoModelForCausalLM.from_pretrained(model_dir)
# 打印模型结构
print(model)
text
GPT2LMHeadModel(
(transformer): GPT2Model(
// 词嵌入,对应vocab_size和n_embd,把token编号查表变成768维向量。每个token的向量已经提前训练好,存在wte的权重矩阵里,所以这一步只是查表,不需要计算
(wte): Embedding(21128, 768)
// 位置嵌入,对应 n_positions和n_embd,把位置编号(0~1023)查表变成768维向量
(wpe): Embedding(1024, 768)
(drop): Dropout(p=0.1, inplace=False)
(h): ModuleList(
// 12个块,对应n_layer
(0-11): 12 x GPT2Block(
(ln_1): LayerNorm((768,), eps=1e-05, elementwise_affine=True, bias=True)
(attn): GPT2Attention(
(c_attn): Conv1D(nf=2304, nx=768)
(c_proj): Conv1D(nf=768, nx=768)
(attn_dropout): Dropout(p=0.1, inplace=False)
(resid_dropout): Dropout(p=0.1, inplace=False)
)
(ln_2): LayerNorm((768,), eps=1e-05, elementwise_affine=True, bias=True)
(mlp): GPT2MLP(
(c_fc): Conv1D(nf=3072, nx=768)
(c_proj): Conv1D(nf=768, nx=3072)
(act): NewGELUActivation()
(dropout): Dropout(p=0.1, inplace=False)
)
)
)
(ln_f): LayerNorm((768,), eps=1e-05, elementwise_affine=True, bias=True)
)
// 输出层,输出维度21128,对应vocab_size
(lm_head): Linear(in_features=768, out_features=21128, bias=False)
)

这个结构里最先发生的是wte的查表,也就是向量化。分词器给出的编号只是一个序号,本身没有含义,模型要理解文本,必须把编号映射到词向量空间里,让它在其中成为一个点。

可以用男女分类来理解特征空间,以身高为横轴、头发长度为纵轴,身高高、头发短的点更可能是男性,身高矮、头发长的点更可能是女性,这两个点只有放在这个坐标系里才有了男和女的含义,换成一个没有意义的空间,同样的点就什么也表达不了。

词也是这样,可以把"今"对应的编号791想象成特征空间里的一个点,被映射到词向量空间之后,它才能代表"今"这个字,这一步在模型内部完成,作用是提取并传递语言的含义。向量化之后,向量再加上位置信息,依次穿过12个GPT2Block,最后由lm_head输出结果。
04 输出层:生成下一个字本质是分类
模型的最后一层是lm_head,它把768维的向量映射成21128个输出,数量正好等于词表大小,每个输出对应词表里一个字被选中的概率。所以文本生成模型的本质依然是分类任务,生成下一个字,就是在21128个类别里选一个。
举个例子,输入"白( )",候选字"色、天、大、下"的概率分别是 0.4、0.4、0.1、0.1,模型会按照这个概率分布去选下一个字。选出的字接在原来的输入后面,再送进模型生成下一个字,如此循环,就得到了一整段文字。

05 采样参数与离线调用
既然模型是按概率选字,就有了怎么选的问题,这正是采样参数要控制的事情。top_k=50 表示只保留概率最大的前50个候选token,top_p=0.9 表示从累积概率达到0.9的候选集合里采样,temperature=0.7控制随机性,值越低越保守,越高越多样。三者配合使用时,是在筛出来的候选里带着随机性选出一个字。

如果temperature=0,则直接取概率最大的token,也就是说提示词不变时,模型每次的输出都是相同的。不过HuggingFace目前不允许直接设置 temperature=0,需要固定输出时,应改用do_sample=False。
有了这些,就可以把模型离线调用起来了。
python
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
# 包含 config.json 的目录(snapshots 下的哈希目录),只支持绝对路径
model_dir = r"D:\models--uer--gpt2-chinese-cluecorpussmall\snapshots\c2c0249d8a2731f269414cc3b22dff021f8e07a3"
# 加载模型和分词器
model = AutoModelForCausalLM.from_pretrained(model_dir)
tokenizer = AutoTokenizer.from_pretrained(model_dir)
# 创建文本生成 pipeline,device="cpu" 表示用 CPU 运行
generator = pipeline("text-generation", model=model, tokenizer=tokenizer, device="cpu")
output = generator(
"你好,我是一款语言模型,", # 提示词,模型根据它生成后续文本
max_length=50, # 生成文本的最大长度(含输入的token),受n_positions=1024限制;如果只想限制新生成的长度,可以改用max_new_tokens
num_return_sequences=1, # 返回几段独立生成的文本
truncation=True, # 输入超过模型最大长度时是否截断
temperature=0.7, # 随机性,越低越保守
top_k=50, # 只从概率最高的前50个词里选
top_p=0.9, # 核采样,从累积概率达到0.9的词里选
clean_up_tokenization_spaces=True, # 是否清理分词带来的多余空格
)
print(output)
小结
回头看,GPT2生成一句话的过程是一条连贯的链路。分词器按字把文本切开,到vocab.txt里查出每个字的编号,编号进入模型后通过wte查表变成768维向量,再加上wpe提供的位置信息,依次穿过12个GPT2Block,最后由lm_head输出 21128 个概率,每个概率对应词表里的一个字。
生成下一个字就是在这21128个类别里选一个,选的方式由 temperature、top_k、top_p控制,选出的字接在输入后面,再进入下一轮,直到达到设定的长度。
