大语言模型入门:从训练到推理

  • 2025-03-18
  • 76
  • 2

随着大语言模型能力的越来越强,以及各种结合 LLM 的工具横空出世,在赞叹其惊人效果的同时也会去思考他是如何做到这一点的。在学习了相关的网络结构之后,比如 Transformer,MoE 等,还是难以理解他的工作过程;几年前通过 RNN 训练可以写藏头诗的模型,同样是处理序列数据,工作过程和 RNN 又有什么差异呢?

大模型动辄数百亿参数,个人设备很难复现训练过程,只有了解一个模型是如何训练的,训练集的输入是什么,输出是什么,才能更好的理解这个大黑盒子。直到在逛 GitHub 的时候发现一个开源项目:MiniMind,他是一个参数只有 26M 的“小语言”模型,个人 GPU 设备也可以训练,虽然参数少,但是包含了大模型的主要网络结构,并且提供了各类的训练集和完整训练过程,训练好后也可以实现简单的对话,作为初学者,是一个非常好的学习 LLM 的入门教程,涉及模型网络设计、预训练、sft微调等等。

这是一篇 LLM 的入门学习文章,记录了自己学习 MiniMind 的过程,跟着开源项目教程复现了从0训练一个大语言模型的基本过程,可以完成基础的对话:

通过阅读学习源码,了解了:1.包括大模型是如何训练的:训练集输入和输出分别是什么,有哪些必要的训练步骤;2. 基本网络结构是什么;3. 最后模型又是如何推理的。

模型训练复现

首先需要有一台拥有 GPU 的电脑,我使用的设备是 Nvidia 2070s(有点老了) 带 8G 显存,实际测试下来预训练过程中显存占用为 6.4G,一个 Epoch 大约耗时 2.5 小时,如果是 4090 的话大概 1小时就能搞定一个 Epoch,开源作者推荐预训练阶段至少 2 个 Epoch,所以我的设备预训练阶段需要5个小时,另外还要进行至少一次的监督微调训练,我的设备耗时 2 个小时,总耗时 7 个小时

对训练时长有了个预期之后那就开始吧,首先需要安装 Pytorch GPU 版本,安装之后使用 torch.cuda.is_available()​ 测试一下确保能正常调用 GPU CUDA,否则使用 CPU 训练的话时间就太久了。这里需要注意如果直接使用 pip install -r requirements.txt​ 安装依赖可能会出现一些版本冲突的问题,所以推荐先手动安装 Pytorch 然后手动按需安装需要的依赖,完整的测试 cuda 是否可用的代码:

import torch
if __name__ == "__main__":
    # 检查CUDA是否可用
    print(f"PyTorch版本: {torch.__version__}")
    print(f"CUDA可用: {torch.cuda.is_available()}")
    print(f"GPU数量: {torch.cuda.device_count()}")
    print(f"当前GPU: {torch.cuda.current_device()}")
    print(f"设备名称: {torch.cuda.get_device_name(0)}")
    # 实际运算测试
    if torch.cuda.is_available():
        device = torch.device("cuda")
        x = torch.randn(100, 100).to(device)
        y = torch.ones_like(x, device=device)
        z = x + y
        print(f"张量设备: {z.device}")
        print("GPU计算测试通过!")
    else:
        print("警告:未检测到GPU加速!")

另外 mac 电脑也可以使用 mps 进行加速,但是项目代码需要做一些适配;

大模型训练主要有以下几个步骤:1. 预训练(学知识),2. 监督微调 sft(学对话方式),3. RLHF 强化训练(优化回复质量,可选),本次使用了下图中的第二种方式以最快的完成模型的训练复现。

预训练

首先开始第一步预训练,需要先下载开源作者提供的训练集:pretrain_hq.jsonl,这个训练集总共 1.62 G, 内容如下:

{"text": "<s>鉴别一组中文文章的风格和特点,例如官方、口语、文言等。需要...</s>"}
{"text": "<s>根据输入的内容,编写一个类别标签。\n这是一篇介绍如何阅...</s>"}
...

把训练集放在 dataset 目录下之后就可以运行 python train_pretrain.py​ 进行模型预训练了,为了防止训练中断重启后能在原来的训练权重下继续训练,而不是重新开始,推荐修改一下训练代码,在初始化模型的时候读取一下已经存在的权重(其实学习率也需要调整,训练代码中每一步的学习率动态技术的,随训练次数减小):

def init_model(lm_config):
    tokenizer = AutoTokenizer.from_pretrained('./model/minimind_tokenizer')
    model = MiniMindLM(lm_config)
    moe_path = '_moe' if lm_config.use_moe else ''
    ckp = f'./out/pretrain_{lm_config.dim}{moe_path}.pth'
    # 如果权重存在则加载权重
    if os.path.exists(ckp):
        Logger(f'加载模型...')
        state_dict = torch.load(ckp, map_location=args.device)
        model.load_state_dict(state_dict, strict=False)
    model = model.to(args.device)
    Logger(f'LLM总参数量:{sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.3f} 百万')
    return model, tokenizer

运行之后就是漫长的等待,期间可以查看一下 GPU 占用状态,防止爆显存或者显卡温度过高:

历时 5 个多小时,经过 2 轮 Epoch 之后 loss 会逐步稳定到 2.0 左右,有时间的话可以还可以继续训练:

Epoch:[1/1](42300/44160)loss:1.956 lr:0.000052185464 epoch_Time:6.0min:
Epoch:[1/1](42400/44160)loss:1.841 lr:0.000051957084 epoch_Time:6.0min:
Epoch:[1/1](42500/44160)loss:2.194 lr:0.000051741258 epoch_Time:6.0min:
Epoch:[1/1](42600/44160)loss:1.794 lr:0.000051537995 epoch_Time:5.0min:
...
Epoch:[1/1](43700/44160)loss:2.015 r:0.000050133853 epoch_Time:2.0min:
Epoch:[1/1](43800/44160)loss:1.760 r:0.000050081985 epoch_Time:1.0min:
Epoch:[1/1](43900/44160)loss:2.013 lr:0.000050042765 epoch_Time:1.0min:
Epoch:[1/1](44000/44160)loss:1.993 lr:0.000050016195 epoch_Time:1.@min.
Epoch:[1/1](44100/44160)loss:2.160 lr:0.000050002277 epoch_Time:0.0min:

这一阶段结束之后可以执行:python eval_model.py --model_mode 0​ 来使用一下这个耗时 5 个小时训练的模型,看看有什么效果,比如输入:你是谁?模型会输出:

你是如何适应环境的?你如何适应环境的?
我是经验丰富的专家,我学习如何适应环境的。我掌握了许多基础知识,包括外部环境、网络环境、网络环境等。我学习了如何在不破
环境的情况下适应环境。我通过学习了各种资源和技巧,如快速解决问题、良好的网络环境、恢复健康的饮食和营养。我在学习环境境
网络环境、网络环境和网络环境等方面都非常有用。我学会了如何适应环境的各种环境,包括外部环境、网络环境、网络环境以及恢恢
健康的饮食习惯。我还学习了如何适应环境的最佳方法。我学会了如何在不破坏环境的情况下适应环境的学习方法。我学习了许多基基
知识,包括外部环境、网络环境、网络环境、恢复健康的饮食和营养。我通过学习各种资源和技巧,如快速解决问题、良好的网络环环
、网络环境以及恢复健康的饮食和营养。我在学习环境、网络环境、网络环境、网络环境以及恢复健康的饮食和营养。我学习了如何何
应环境的各种环境,包括快速解决问题、恢复健康的饮食和保持健康的饮食习惯。我在学习环境、网络环境、网络环境以及恢复健康康饮食习惯。

可以看到此时模型并没法直接执行人类的指令,现在是一个文本续写机器,会在输入文本之后,模型会计算并选择一个概率最高的词,以此类推一直续写直到出现终止符号。由于预训练次数太过少,所以上述续写效果也不好,经常会出现很多重复的内容。

为什么此时模型是个续写机器呢?这需要从预训练过程说起,因为预训练就是一直让模型预测一句话的最后一个字,后面会通过代码一步步分析预训练。通过预训练,此时模型学习到了基本的语法知识与模式,有了一定的泛化能力以适应多样文本和场景,能提取文本特征、积累了一定的知识等;

监督微调

预训练之后,只有经过监督微调(sft)训练,模型才能真正的回答人类的问题。需先下载监督微调训练集:sft_mini_512.jsonl(1.23G),格式如下:

{
	"conversations": [
		{"role": "user", "content": "请用一段话描述阿里巴巴集团的企业文化。"}, 
		{"role": "assistant", "content": "阿里巴巴集团的企业文化以“客户第一、员工第..."}
	]
}
...

和预训练集中提供的语料不同,监督微调的训练集有了一定的格式,包含一对对的问题和答案,通过这样的训练让模型知道如何回答人类的问题,接下来就可以执行:python train_full_sft.py​ 开始监督微调了。这里只进行了一轮的 Epoch 的训练,又是漫长等待的 2 个小时,最后 loss 稳定在 1.8 附近:

Epoch:[1/1](0/37961) loss:2.472 lr:0.000055000000 epoch_Time:10711.0min:
Epoch:[1/1](100/37961) loss:2.099 lr:0.000054999144 epoch_Time:237.0min:
Epoch:[1/1](200/37961) loss:2.041 lr:0.000054996576 epoch_Time:185.0min:
Epoch:[1/1](300/37961) loss:2.137 lr:0.000054992295 epoch_Time:167.0min:
...
Epoch:[1/1](37600/37961) loss:1.791 lr:0.000005011156 epoch_Time:2.0min:
Epoch:[1/1](37700/37961) loss:1.797 lr:0.000005005832 epoch_Time:1.0min:
Epoch:[1/1](37800/37961) loss:1.833 lr:0.000005002219 epoch_Time:1.0min:
Epoch:[1/1](37900/37961) loss:1.881 lr:0.000005000319 epoch_Time:1.0min:

那么,接下来就是见证奇迹的时刻,启动:python eval_model.py --model_mode 1​ 下面截取了一些模型的回答:

user assistant
请介绍一下自己。 我是一款基于自然语言处理技术的语言模型,可以回答各种问题和提供帮助。我可以根据用户的输入生成答案,提供不同的服务和回答。
你更擅长哪一个学科? 我更擅长的是语言和语法。
我咳嗽已经持续了两周,需要去医院检查吗? 您的情况比较严重,可能需要住院手术。您可以通过检查确诊手术来确定是否需要去医院进行手术。
推荐一些杭州的特色美食吧。 杭州有很多著名的特色美食,比如:豆腐脑、豆浆油条、鸭血粉丝汤、青笋炒饭等等。这些美食不仅味道鲜美,而且非常适合作
为正宗的中餐。
请为我讲解“大语言模型”这个概念。 大语言模型是一种自然语言处理技术,它是基于人工神经网络的自然语言处理方法。它可以帮助人们处理各种类型的语言数据,
例如文本、图像、音频和视频等。通过大规模数据训练,我们可以提高模型的准确性和泛化能力,以便更好地理解和处理自然语言。
如何理解ChatGPT? ChatGPT是一种基于预训练的预训练语言模型,它使用大量的语料库来模仿语言模型。它可以用来回答问题,提供有用的答案,以提供有用的答案。它可以使用机器学习算法来处理这些数据,然后用这些数据来生成有用的回答。

可以看到此时模型不再像之前那样只会续写了,现在会理解用户的问题,有点大模型的样子了,到此就完成了大语言模型从0开始训练到推理最基础过程的复现。虽然经常会出现胡说八道的情况,但是对他要求也不能太高,毕竟是一个参数只有 26M 的模型,并且预训练集只有 1.6G ,只预训练了 2 轮,监督训练了 1 轮,有条件的同学可以使用更大的训练集,训练更长的时间试试。开源作者也提供了他训练了更多轮的模型,体验下来会好很多。

除此之外,作者还提供了其他的微调训练代码,例如 RL-HF 强化训练,通过强化训练模型,可以优化模型的回复质量,使其更加符合人类偏好,训练集如下:

{
	"chosen": [
		{"content": "How many moles of HBr are required to react with 2 moles of C2H6 to form 2 moles of C2H5Br along with 2 moles of H2?", "role": "user"}, 
		{"content": "To determine the number of moles of HBr required to react with 2 moles of C2H6 (ethane) to form 2 moles of C2H5Br (ethyl bromide) ...", "role": "assistant"}], 
	"rejected": [
		{"content": "How many moles of HBr are required to react with 2 moles of C2H6 to form 2 moles of C2H5Br along with 2 moles of H2?", "role": "user"}, 
		{"content": "To answer this question, we need to write down the chemical equation representing the reaction between hydrogen bromide (HBr) and...", "role": "assistant"}]
}

还有深度思考微调,通过 Reason 数据集与训练,可以训练出像 deepseek 一样的深度思考能力,训练集如下:

{"conversations": [
	{"role": "user", "content": "请用一段话描述阿里巴巴集团的企业文化。"}, 
	{"role": "assistant", "content": "<think>\n嗯,用户让我用一段话描述阿里巴巴集团的企业文化。首先,我需要明确阿里巴巴集团的平均文化是什么。...。\n</think>\n<answer>\n阿里巴巴集团的企业文化以战略协作为核心,倡导员工之间的资源整合与创新思维的碰撞,...前发展。\n</answer>"}
]}

其实不管是强化训练,还是深度思考训练,模型结构都是一样的,只是在训练的过程中对 loss 进行了处理, 对于特定的回答加上了一定的奖励,通过反向传播,让模型输出向某种偏好移动。另外开源作者还提供了 LoRA 微调训练集,在微调过程中,LoRA会冻结预训练语言模型的所有参数,引入可训练的低秩矩阵,只训练低秩矩阵,这样大大减少了需要训练的参数数量,将通用的大语言模型微调为适用于特定领域(如医疗、金融等)的模型,提高模型在该领域的性能。

篇幅关系以上微调就没一个个尝试了,主要还是学习大模型训练与推理的主要过程,后面有时间再分析这些特定的微调是如何实现的。那么接下来就分析一下大模型训练中的网络结构,真正了解大模型的训练与推理过程。

训练过程与网络结构分析

与主流的大语言模型一样,MiniMind 是一个基于 Transformer 架构的深度学习模型,自从 BERT 和 GPT 模型取得重大成功之后, Transformer 结构已经替代了循环神经网络 (RNN) ,成为了当前 NLP 模型的标配。在学习 Transformer 的过程中肯定看到过这张图:

这是 Transformer 的完整结构,同时拥有 Encoder 和 Decoder 两个部分 ,其中 Decoder 完成序列生成任务的过程中依赖 Encoder 的输出,这种 Encoder-Decoder 模型可以用于翻译任务,在翻译任务训练过程中,将等待翻译的文字输入 Encoder,逐字翻译之后的文字输入 Decoder, 这样在逐字翻译过程中可以关注到完整的待翻译内容。与 GPT 模型一样,MiniMind 是一个 Decoder-Only Transformer 结构的模型,也称自回归 Transformer 模型,顾名思义 Transformer 结构中只有 Decoder 部分,下面是 MiniMind 项目中用于描述网络结构的图:

单独看上面的网络结构图可能有点不太好理解真正的训练过程,下面以“我来自地球”这句话作为训练预料,结合代码分析一下整个训练过程,首先在 dataset 目录下新建一个 pretrain_test.jsonl​ 文件,内容如下:

{"text": "我来自地球"}

开启训练:python .\train_pretrain.py --data_path ./dataset/pretrain_test.jsonl --out_dir test_out记得重新指定模型的保存路径 out_dir, 不然原来训练好的模型权重就被覆盖了。开启 Debug 就可以跟踪每一步的过程了,首先训练脚本会通过 PretrainDataset​ 加载训练集,加载训练集的过程中会使用预训练好的分词器将文本切割成 Token,在分词器内部定义了文本的开始和结束符,项目中分别使用<s></s>​ 表示,对应上图中的 Tokenizer Encoder 部分, 处理过程如下:

Tokenizer 会维护一个词汇表(Vocabulary),这个词汇表包含了所有可能的 Token。每个 Token 都会被映射到一个唯一的整数 ID,模型在处理时实际上操作的就是这些整数 ID 序列。词汇表的大小与训练的分词器模型有关,作者提供的分词器词汇表长度是 6,400,也就是最多能表示 6,400 个词汇;在自然语言处理中,输入的文本长度是不确定的,训练过程中为了充分利用 GPU 资源,会将多句话合成一个 batch 输入,此时模型要求输入的序列具有固定的长度。因此,在将文本转换为 Token 序列后,通常会对序列进行截断或填充操作,使其长度固定为一个预设的值,这个预设值就是输出矩阵的长度,在这个模型中最大长度控制在了 512,所以分词器最终输出的结果是:

{
	input_ids: [1, 397, 6219, 2261, 2, 0, 0, ..., 0] // 总长度为512
	attention_mask: [1, 1, 1, 1, 1, 0, 0, ..., 0] // 总长度为512
}

其中 attention_mask 用于表示哪些位数是有效的,最后通过以下代码来生成训练的输入 X 和 标签 Y:

X = torch.tensor(input_ids[:-1], dtype=torch.long)
Y = torch.tensor(input_ids[1:], dtype=torch.long)

到这一步训练数据的处理其实和之前 RNN 网络训练写诗的模型基本一致,通过将一句完整的句子分词生成 Token 之后,前后错位来产生模型训练的 Input 和 Label,这样每个input对应的输出恰好是下一个字,快速的构造了训练所需的 Input 和 Label:

由于 X、Y 在原来的基础上减少了一位,所以现在 X、Y 的 Shape 都为 (1, 511)​, 然后 X 就作为参数传入到模型网络中,第一步会进过一个 Embedding 层,他会将一维的 Token,转化成二维的向量,上一步生成的 Token 虽然已经是计算机认识的数字了,但是相同词语,在不同上下文中的语义是不同的,比如“苹果”,在有些环境中指的是水果,有些环境中指的是手机,Embedding 层在训练过程中学习到的向量表示能够反映符号之间的语义相似性,例如,“苹果”和“香蕉”作为水果类的词汇时,它们的嵌入向量在空间中会相对靠近;而“苹果”和“汽车”的嵌入向量则会相距较远。Embedding 层初始化需要两个参数分别是词汇表大小和向量维度,词汇表大小和 Tokenizer 有关,上面提到过,模型使用分词器词汇表大小为 6400,在这个模型中向量维度设置的是 512。所以经过 Embedding 层之后,之前输入的每一个 Token 字符都会变成一个长度为 512 的向量,所以此时输出变成了:(1, 511, 512)

然后就是进入到 Transformer Layers,本模型总共设置了 8 层 Transformer,每一层的结构是完全一样的,上一层的输出会作为下一层的输入, Transformer 层的代码如下:

def forward(self, x, pos_cis, past_key_value=None, use_cache=False):
	h_attn, past_kv = self.attention(
		self.attention_norm(x),
		pos_cis,
		past_key_value=past_key_value,
		use_cache=use_cache
	)
	h = x + h_attn
	out = h + self.feed_forward(self.ffn_norm(h))
	return out, past_kv

再回过头看之前的架构图就比较清楚了,最后的 feed_forward 默认情况下是一个前馈神经网络,也可以切换成 MoE(混合专家),训练过程中不需要关注 past_key_value 和 use_cache,在两个参数用于缓存注意力机制中的 Key 和 Value,在推理过程中会被启用,在后面推理的章节会再次提到。在 Transformer 架构中最需要关注的就是注意力机制了,下面是最基础的注意力机制示意图:

上图对应的公式是:$Attention(Q, K, V) = softmax(QK^T/\sqrt{d_k})V$ ,以上是基本的注意力单元,在本模型中使用的注意力机制是分组查询注意力机制(Grouped-Query Attention,下面简称 GQA),另外还有多头注意力机制(Multi-head Attention),在 GQA 中会将 Query 分成多个组,每个组内头有单独的 Query,但是组内的 Key 和 Value 是共享的,多个头有助于提升注意力的效果,不同的头可以关注不同的部分,但是头越多计算量也就越大,所以 GQA 就是一个折中方案。本模型默认参数下, Query 的头数是 8 个,Key,Value 的头数均为 2 个,那么这样 Query 就必须共享 Key 和 Value 了,每 4 个 Query 共享一对 Key 和 Value, 本例中的 GQA 如下图所示:如果 Key、Value 的头数和 Query 一样其实就是多头注意力了,:

其中 Query、Key、Value 就是将输入 X 进行三次仿射变换(线性层)所得到,此时 input 的形状是 (1, 511, 512)​,经过线性层后,Query 的形状是:(1, 511, 8, 64)​, Key 的形状是:(1, 511, 2, 64)​, Value 的形状是:(1, 511, 2, 64)​, 其实就是 (batch_size, seq_len, n_heads, head_dim)​, 其中 Query 的 n_heads​ * head_dim​正好等于 input 的 dim。由于 Key 和 Query 的第三个维度和 Query 不匹配,所以需要将 Value 和 Key 复制以匹配 Query 的形状:

xq, xk, xv = (
	xq.transpose(1, 2),
	repeat_kv(xk, self.n_rep).transpose(1, 2),
	repeat_kv(xv, self.n_rep).transpose(1, 2)
)

处理之后,Query、Key、 Query的形状都变成了:(1, 8, 511, 64)​, 接着就可以通过注意力公式计算了,在回顾一下公式:$Attention(Q, K, V) = softmax(QK^T/\sqrt{d_k})V$,代码如下:

scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(self.head_dim)
scores += self.mask[:, :, :seq_len, :seq_len]
scores = F.softmax(scores.float(), dim=-1).type_as(xq)
scores = self.attn_dropout(scores)
output = scores @ xv

项目中会走到性能优化过的 F.scaled_dot_product_attention​ 这个分支,但是上面的代码更直观,值得注意的是在计算 softmax 前加了一个 mask, 这个 mask 是事先生成好的三角矩阵,模型生成下一个词时只能依赖于已经生成的词,使用 mask 屏蔽了未来的词,示意图如下(此图来源于项目的image目录下,非常直观的表示了 Transformer 的并行化特点):

使用这个 Mask 三角矩阵非常巧妙的实现了 Transformer 的并行化推理,最后输出的形状不变还是 (1, 8, 511, 64)​,然后经过转置,reshape 和一个仿射变换之后变成 (1, 511, 512)​ 和输出的形状一致,Transformer 层的最后再对 Attention 的计算结果进行一些处理:

h = x + h_attn
out = h + self.feed_forward(self.ffn_norm(h))

上面提到,这里的 feed_forward 还可以替换层混合专家层,默认使用的是一个前馈神经网络,最后输出的形状还是(1, 511, 512)​, 这样的 Transformer 层需要经过 8 次,最后再进过一个线性层,将最后一个维度提升到 6400, 也就是词汇表的长度,此时模型输出的形状是(1, 511, 6400)​, 其中每一行的数字表示模型根据输入的字推测下一个字的在词汇表中的概率,也就是 6400 个数字中概率最大的即为推测的下一个字。接着就是使用 CrossEntropyLoss​ 计算损失,label 就是之前生成的 Y , 形状是 (1, 511)​, 因为生成的 X, Y 有 padding 所以还需要处理一下 loss_mask, 相关代码如下:

with ctx:
	res = model(X)
    loss = loss_fct(
        res.logits.view(-1, res.logits.size(-1)),
        Y.view(-1)
     ).view(Y.size())
     loss = (loss * loss_mask).sum() / loss_mask.sum()
     loss += res.aux_loss
     loss = loss / args.accumulation_steps

通过反向传播与梯度下降使得 loss 越来越小,表示该模型已经能更准确的预测下一个字了,以上就是模型预训练的全部过程分析,监督微调训练只是在训练集获取上有些差异其他都是一样的,还有几种微调在 loss 计算上有些改动,以后有时间再总结,模型训练好了之后,接下来再分析一下模型推理的过程。

推理过程分析

通过以上的分析,我们已经基本清楚模型是如何训练的,对模型的网络结构也有了一定的了解,那么接下来就分析一下推理过程,了解模型在输入一个人类问题的时候,是如何连续的生成序列的,直接执行 eval_model.py​ 就可以加载模型进行问答推理了,比如输入:你是谁, 模型会输出:我是AI语言模型,我被训练用于回答问题、生成文本、进行对话和进行对话。那么就以输入:你是谁 为例逐步分析一下模型是如何一步步输出回答序列的。

首先将输入处理成与 sft 微调阶段一样的格式:

[{'content': '你是谁', 'role': 'user'}]

因为经过微调之后,模型已经学习到了以上的交流格式,后续就可以通过这种格式与模型交互了,接着使用之前出现过的 Tokenizer​ 处理层模型更便于理解的格式,sft 微调阶段也同样有这一步,预训练的时候 add_generation_prompt 为 False,为 True 时会添加预置的提示词:

new_prompt = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True
 )

上述代码将输入处理成了以下格式:

'<s>system
你是 MiniMind,是一个有用的人工智能助手。</s>
<s>user
你是谁</s>
<s>assistant
'

接下来再将上述文本内容使用 Tokenizer​ 转换成 Token 值,通过分词,上述文本被分成了 38 个 Token,此时输入的形状是 (1, 38)​,然后就可以输入模型进行推理了:

while input_ids.shape[1] < max_new_tokens - 1: # 判断是否已经达到最大序列长度
   if first_seq or not use_cache:
      out, first_seq = self(input_ids, past_key_values=past_kvs, use_cache=use_cache, **args), False
   else:
      out = self(input_ids[:, -1:], past_key_values=past_kvs, use_cache=use_cache,
      start_pos=input_ids.shape[1] - 1, **args)
   ...
   ...
   input_ids = torch.cat((input_ids, input_ids_next), dim=1) # 生成的下一个 Token 拼接到输入最后
   yield input_ids[:, start:]
       if input_ids_next.item() == eos_token_id: # 生成了句尾结束符,本项目中是 </s>
          break

输出序列的生成过程是一个循环,直到输出了结束符或者已经达到了输出的最大序列了才会停止。值得注意的是第一次循环是全量输入,第二次开始使用上一次生成的最后一个字作为输入,另外前面提到过,推理阶段 use_cache 的值是 True,在后续的生成循环中会复用上一次生成的 Key、Value,这样就不需要每次都从新计算了,并且第二次循环开始只需要输入上次生成的最后一个字,那么基本的一个序列生成流程如下所示:

网络结构在之前训练阶段已经分析过了,唯一有区别的就在 Key、Value 的复用,在 Attention 结构中,代码如下:

if past_key_value is not None:
   xk = torch.cat([past_key_value[0], xk], dim=1)
   xv = torch.cat([past_key_value[1], xv], dim=1)

假设第一次循环之后缓存的 Key、Value 大小为:(1, 38, 2, 64)​ , 其中 38 即序列长度,是在第一次循环中缓存下来的,第二次循环输入的序列长度为 1,所以本次 Key、Value 的序列长度也为 1,shape 为(1, 1, 2, 64)​ 执行 cat 将两次循环的向量相连变成:(1, 39, 2, 64)​, 这样通过 KV 缓存,极大减少了计算量,序列中已经生成过 KV 的 Token 就不需要重复生成了。

我们知道,大语言模型其实就是在计算一句话之后出现概率最大的字,我们再看一下模型输出的向量是如何转变成我们所认识的字符。已知模型最后输出的内容是根据前文预测的最后一个字,实际上模型输出的是一个向量,在本例中输出的向量形状是:(1, 6400)​ ,其中这里的向量长度 6400 即为词汇表大小,表示每个词汇出现的概率,选取最大的数字所在的位置,然后去词汇表中选取同位置的词汇即可。

实际应用中,如果直接选取最大的概率可能会让每次生成的内容比较单一,甚至出现一直重复一个词的情况,为了让模型回复更加多变,会引入一个 temperature​ 的参数,这个参数在主流的大模型 API 上都可以设置,可以控制模型输出的多样性,具体实现方式就是在模型输出之后除以这个参数:

logits /= (temperature + 1e-9) # 这里加上这个极小数是为了防止除 0

除了引入temperature​参数外,例子中还使用了 Top-P 算法进一步提升模型生成的多样性,具体就不详细展开了,见注释:

if top_p is not None and top_p < 1.0:
    sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1) # 从大到小排序
    sorted_probs = F.softmax(sorted_logits, dim=-1) 
    cumulative_probs = torch.cumsum(sorted_probs, dim=-1)  # 按位置累加,例如:[0.6, 0.3, 0.1] -> [0.6, 0.9, 1]
    sorted_indices_to_remove = cumulative_probs > top_p # 找出值大于 top_p 的
    sorted_indices_to_remove[:, 1:] = sorted_indices_to_remove[:, :-1].clone() # 右移一位,确保不会全部被删除
    sorted_indices_to_remove[:, 0] = False # 同上
    indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove) # 找出哪些 index 会被删除
    logits[indices_to_remove] = -float('Inf') # 删除的位置设置成负无穷大,下面 softmax 会忽略该值
input_ids_next = torch.multinomial(F.softmax(logits, dim=-1), num_samples=1) # 随机采样 1(num_samples)个,输出是 index

以上就是一个大语言模型,从0开始预训练,到 sft 微调,再到使用推理的全部过程,中间也学习了网络层 Transformer 架构,注意力机制,以及了解了以上过程中工程上的一些实现细节。在此非常感谢这个开源项目,作为初学者,花了几个周末的时间,对大语言模型这个黑盒子有了一定的了解,限于篇幅缘故,还有一些比较重要的知识点还没总结,比如 MoE 混合专家,LoRA 微调训练,RLHF强化学习训练,推理模型 (Reasoning Model)的训练过程等,这些在这个项目中也都有代码示例,以后再慢慢补充,与此同时,一些第三方的大模型框架和工具库,如transformers,微调训练可能就十几行代码就实现了,更适用与实际应用,有了学习基础,再使用这些框架,就更容易理解各个参数要如何设置了。

>> 转载请注明来源:大语言模型入门:从训练到推理

免费分享,随意打赏

感谢打赏!
微信
支付宝

评论

  • callee回复

    SmartCropper有升级版可以用吗?

    • pqpo回复

      后面花了比较多的时间重新训练了模型、调整了算法,但是不再开源了

发表评论