
用大白话讲QLoRA微调代码来讲解 pytorch让你快速对 pytorch 有个整体把握。全局总览训练模型到底在干嘛先给你建立一个最核心的认知用「教AI学生刷题」类比所有深度学习训练包括QLoRA本质都是5步循环准备好一本题库数据集一次抽一沓题给学生做按批次送数据学生做完老师算错了多少损失函数老师告诉学生哪道题错了、该往哪个方向改反向传播学生根据反馈修改自己脑子里的知识点优化器更新参数重复以上步骤直到学生错题越来越少你写的所有PyTorch代码都是在实现上面这6步。QLoRA只是额外加了一条规则学生脑子里的课本基座模型不许改只允许改随身带的小抄LoRA权重其他流程和普通训练完全一样。一、张量TensorPyTorch世界的「通用数据格式」人话解释你可以把它理解成专门给GPU用的数组/表格。Python里的列表、普通数字可以直接给 CPU 运算 但是 都不能直接扔给GPU计算必须先转成Tensor格式。你代码里所有的input_ids输入token编号、labels标签、模型输出结果全都是Tensor。你可以按维度直观理解0维一个数字比如「考试分数 85」1维一排数字比如「一个学生的3科成绩 [85, 90, 78]」2维一张表格比如「2个学生每人3科成绩」→ 形状就是(2, 3)3维一摞表格比如「2个班级每班30人每人3科」→ 形状(2, 30, 3)对应到大模型里你天天见的input_ids形状通常是[批次大小, 句子长度]2维一次送多少个句子每个句子有多少个token模型中间的特征是[批次大小, 句子长度, 特征维度]3维每个token对应一串数字特征你99%场景只会用到的5个操作importtorch# 1. 创建张量把普通Python列表转成GPU能认的格式# 你在数据集里肯定见过把token列表转成torch.tensordata[1,2,3,4]tensortorch.tensor(data,dtypetorch.long)# dtypetorch.long 是整数类型token编号都用这个小数用 torch.float32# 2. 看形状最最常用写代码必看数据对不对# 就像你拿到表格先看几行几列print(tensor.shape)# 输出 torch.Size([4]) → 1维4个数字# 3. 改形状把数据摆成你想要的维度# 比如把4个数字摆成2行2列tensor_2dtensor.reshape(2,2)print(tensor_2d.shape)# torch.Size([2, 2])# 4. 拼接把两个张量拼在一起# 比如把两个句子的token拼起来atorch.tensor([1,2])btorch.tensor([3,4])ctorch.cat([a,b],dim0)# dim0 按行拼print(c)# tensor([1, 2, 3, 4])# 5. 转类型/转设备后面讲GPU会详细说tensor_floattensor.to(torch.float32)# 整数转小数torch.cat 的 dim 参数就是指定沿着哪个维度 “变长”dim0沿着第 0 维行方向拼 → 行数变多列数不变 → 上下叠dim1沿着第 1 维列方向拼 → 列数变多行数不变 → 左右拼一句话总结只要你要给模型喂数据、拿模型输出就一定在和Tensor打交道。你不用学几十种创建方式记住torch.tensor()创建、.shape看形状、.reshape()改形状足够应付90%的场景。二、GPU设备切换把数据搬到显卡上算人话解释CPU 你的办公桌能放很多东西但算得慢GPU 你的超级计算器算矩阵特别快但空间显存有限所有数据和模型必须手动搬到GPU上才能加速不然就会用CPU慢死甚至直接报错。这就是你代码里到处都是.to(cuda)的原因。标准写法你抄代码天天见importtorch# 1. 自动判断有没有显卡有就用显卡没有就用CPU# 几乎所有训练代码第一行都会写这个devicetorch.device(cudaiftorch.cuda.is_available()elsecpu)print(f当前用的设备{device})# 2. 把张量搬到GPU上xtorch.tensor([1,2,3])x_gpux.to(device)# 生成一个新的、在GPU上的张量原来的还在CPU# 3. 把模型搬到GPU上后面讲模型会详细说# model model.to(device)补充显存清理训练中删了大变量后显存不会自动释放手动清一下能省空间# 删除变量 清空空闲显存delx_gpu torch.cuda.empty_cache()一句话总结训练前记住两件事模型.to(device)每一批数据也要.to(device)两个都在GPU上才能正常计算。三、数据集与数据加载器给模型「发卷子」的工具这部分就是你代码里「加载训练数据」的那段核心就两个类Dataset和DataLoader。3.1 Dataset整理题库人话解释就是把你的所有训练数据整理成「按题号抽题」的格式。你告诉它要第几条它就返回第几条数据。写自定义Dataset必须写两个函数__len__返回题库总共有多少道题__getitem__给我一个索引返回对应那道题最简示例模拟你的微调数据train_inputs是「题目」train_labels是「标准答案」。只给题目不给答案模型做完题不知道自己对不对就没法学习、没法改错。做 QLoRA 微调的本质就是让模型看着「上文题目」学习预测「下文答案」。fromtorch.utils.dataimportDataset# 自定义一个文本数据集classMyTextDataset(Dataset):def__init__(self,all_input_ids,all_labels):# 初始化把所有数据存进来self.input_idsall_input_ids# 所有句子的token编号self.labelsall_labels# 对应的标签下一个词是什么def__len__(self):# 返回数据集总大小有多少条训练数据returnlen(self.input_ids)def__getitem__(self,idx):# 返回第idx条数据输入 标签return{input_ids:torch.tensor(self.input_ids[idx],dtypetorch.long),labels:torch.tensor(self.labels[idx],dtypetorch.long)}# 测试一下 # 模拟3条训练数据train_inputs[[1,2,3],[4,5,6,7],[8,9]]train_labels[[2,3,4],[5,6,7,8],[9,10]]# 实例化数据集datasetMyTextDataset(train_inputs,train_labels)print(f总数据量{len(dataset)})# 3print(f第0条数据{dataset[0]})# 取出第一条看看3.2 DataLoader按批次发卷子人话解释一道一道给模型发题太慢了DataLoader负责一次发一沓题一个batch让GPU批量计算效率高很多。同时还能打乱题目顺序防止模型死记硬背。核心参数你只需要记住这4个batch_size一沓多少道题显存够就设大不够就设小shuffle训练集设True打乱顺序验证集设Falsedrop_last最后一沓不够数量要不要扔掉collate_fn长短不一的句子统一补成一样长重点为什么要有 collate_fn句子有长有短长度不一样没法摞成一沓一起算。所以要给短句子后面补0叫padding统一成当前批次里最长的长度。同时标签里补-100告诉后面的判卷老师「这些是我补的空格不算分」。最简可运行示例fromtorch.utils.dataimportDataLoaderdefcollate_fn(batch):# 输入一沓单条数据# 输出补完长度、可以直接扔进模型的批次数据# 1. 把这一沓的输入和标签分别拿出来input_ids[item[input_ids]foriteminbatch]labels[item[labels]foriteminbatch]# 2. 算这一沓里最长的句子有多长max_lenmax([len(ids)foridsininput_ids])# 3. 给短句子补 0标签补 -100padded_inputs[]padded_labels[]forids,labinzip(input_ids,labels):pad_lenmax_len-len(ids)# 需要补多少个# 输入补0padded_inputs.append(torch.cat([ids,torch.zeros(pad_len,dtypetorch.long)]))# 标签补-100告诉损失函数这里不算分padded_labels.append(torch.cat([lab,torch.full((pad_len,),-100,dtypetorch.long)]))# 4. 摞成一整沓2维张量返回return{input_ids:torch.stack(padded_inputs),labels:torch.stack(padded_labels)}# 创建数据加载器 dataloaderDataLoader(dataset,batch_size2,# 一次发2道题shuffleTrue,# 打乱顺序collate_fncollate_fn# 用我们自定义的补长函数)# 遍历一沓一沓的数据forbatchindataloader:print(批次输入形状,batch[input_ids].shape)print(批次标签形状,batch[labels].shape)print(标签内容补的地方是-100\n,batch[labels])break# 只看第一批一句话总结Dataset管单条数据怎么取DataLoader管怎么打成批次、怎么补长你代码里for batch in train_dataloader:这段循环就是在一沓一沓拿题四、模型nn.ModuleAI学生本身1、人话解释模型就是一个装了很多「参数」你可以理解成知识点的盒子。__init__函数给模型装零件比如词嵌入层、全连接层forward函数规定模型「拿到题目后按什么步骤算出答案」你用AutoModelForCausalLM.from_pretrained(Qwen/...)加载的基座模型本质就是别人写好的、超大号的nn.Module里面装了几十亿个参数。2、最简模型示例不用管里面具体是什么层先记住这个固定写法importtorchimporttorch.nnasnnimporttorch.nn.functionalasFclassSimpleModel(nn.Module):def__init__(self):super().__init__()# 固定写法必须写# 在这里定义模型有哪些零件self.linearnn.Linear(4,2)# 一个全连接层输入4个数字输出2个数字defforward(self,x):# 在这里定义计算流程输入x一步步算出输出outself.linear(x)returnout# 实例化模型modelSimpleModel()# 测试输入一个2行4列的张量2个样本每个4个特征test_inputtorch.randn(2,4)# 本身为 torch.Size([2, 2])outputmodel(test_input)print(模型输出形状,output.shape)# 此时为 torch.Size([2, 2])nn是neural network的缩写中文就是「神经网络」。torch.nn 是 PyTorch 官方提供的、专门用来搭建神经网络的核心工具包我们写模型用到的几乎所有 “标准零件” 都装在这个包里。3、两个必用开关训练模式 /评估模式这也是你代码里天天见的# 训练模式上课状态# 会开启Dropout随机忘一点知识点防止死记硬背model.train()# 评估模式考试状态# 关闭Dropout全力发挥结果更稳定model.eval()记住训练循环里开头写model.train()验证/推理开头写model.eval()写反了结果会出问题。4、大白话说——梯度我用你最熟悉的「调收音机」类比结合QLoRA微调场景给你讲透全程不用数学公式看完就能对应上你写过的代码。1、梯度到底是什么你可以把大模型想象成一台有 几十亿个旋钮的收音机每个旋钮 模型里的一个参数权重声音越清晰、杂音越少 损失值loss越小模型效果越好梯度 一张实时的「旋钮转动说明书」它会明确告诉你每个旋钮往左拧还是往右拧、拧多大角度能让杂音最快变小简单说梯度不是参数本身而是「参数该怎么改的方向和幅度」。它告诉模型“往这个方向改错题会变少往反方向改错题会变多。”2、在完整训练流程里梯度是哪一环对应你天天写的训练循环5步梯度的位置非常明确前向传播算loss当前旋钮位置下听一听杂音有多大模型错了多少loss.backward()反向传播根据当前的杂音大小算出每个旋钮的调台说明书计算所有可训练参数的梯度梯度裁剪防止说明书写错、让你一下拧太猛把旋钮拧坏限制梯度大小稳定训练optimizer.step()优化器更新照着说明书把每个旋钮都拧一点点用梯度去修改参数值optimizer.zero_grad()清零梯度把这一轮的说明书擦掉下一轮重新算防止梯度累加出错核心逻辑没有梯度模型就完全不知道该往哪个方向改参数只能瞎蒙根本没法学习进步。反向传播算梯度是整个深度学习训练的核心环节。3、requires_grad开关到底在控制什么这个参数就是「要不要给这个旋钮写调台说明书」的总开关requires_grad True给这个旋钮算梯度、写说明书优化器会照着说明书拧它requires_grad False不给这个旋钮算梯度、不写说明书优化器直接跳过它旋钮永远保持原样对应到 QLoRA 微调就是你天天在做的事基座模型有几十亿个参数全算梯度、全拧一遍显存不够、也没必要。所以我们做两件事基座模型全部设为requires_grad False几十亿个主旋钮全部焊死不算梯度、永远不动省大量显存和计算LoRA 适配器设为requires_grad True额外加的几百万个小旋钮才是我们真正要拧的只给这部分算梯度、更新参数这就是为什么 QLoRA 能单卡跑大模型的核心原因之一大部分参数都关闭了梯度计算既省了存梯度的显存又省了算梯度的时间。4、一个最容易搞混的点梯度 ≠ 参数很多初学者会把两者搞混这里明确区分参数旋钮当前的位置一个固定数值是模型本身的一部分保存权重文件存的就是它梯度旋钮该往哪拧的“修改指令”是临时计算出来的每一轮训练都会重新算训练完就可以扔掉你可以这么记参数是「学生脑子里的知识点」梯度是「错题本上的订正方向」每次做完题前向传播写好订正方向算梯度然后照着改知识点更新参数最后把错题本擦掉清零梯度下一套题重新来。五、对应到你见过的代码一眼看懂# 1. 冻住所有参数所有旋钮都不许拧不算梯度forparaminmodel.parameters():param.requires_gradFalse# 2. 只放开LoRA层只有这几个旋钮允许拧才算梯度forparaminmodel.lora.parameters():param.requires_gradTrue# 3. 反向传播给所有 requires_gradTrue 的参数算梯度loss.backward()# 4. 优化器更新只更新那些有梯度的参数optimizer.step()一句话总结梯度是模型学习的“导航”没有它参数就不知道往哪优化requires_grad就是导航的开关QLoRA 的本质就是只给极小部分参数开导航。5、QLoRA核心操作冻结参数人话解释基座模型有几十亿参数全改显存不够、也没必要。QLoRA的思路就是把课本基座模型参数冻住不许改只改随身的小抄LoRA适配器参数。代码就是这么写的# 1. 把模型所有参数都冻住不许改forparaminmodel.parameters():param.requires_gradFalse# 2. 只放开某几层比如LoRA层、最后的分类头允许改forparaminmodel.linear.parameters():param.requires_gradTrue# 看看现在有多少参数需要训练trainablesum(p.numel()forpinmodel.parameters()ifp.requires_grad)totalsum(p.numel()forpinmodel.parameters())print(f可训练参数{trainable}/ 总参数{total})requires_grad True这个参数需要算梯度、可以被修改requires_grad False这个参数冻住了不改一句话总结所有模型都继承nn.Module分__init__装零件和forward算结果两部分train()和eval()切换模式训练和验证必须分开QLoRA的本质冻住大部分参数只训极小一部分五、判卷与改错损失函数 优化器5.1 损失函数Loss阅卷老师人话解释模型做完题损失函数负责算「错得有多离谱」。数字越大错得越厉害。训练的目标就是让这个数字越来越小。大模型微调99%的场景都用交叉熵损失CrossEntropyLoss专门用来考「下一个词该选什么」。它有个非常重要的特性自动忽略标签为-100的位置——正好对应我们前面padding补的-100补的地方不扣分。代码示例importtorch.nnasnn# 定义判卷老师交叉熵损失忽略-100的标签criterionnn.CrossEntropyLoss(ignore_index-100)# 模拟模型输出logits2个样本每个位置有10个词可选logitstorch.randn(2,3,10)# 形状 [批次, 序列长度, 词表大小]# 模拟正确答案labelstorch.tensor([[1,2,-100],[3,-100,-100]])# 计算损失固定写法把形状展平再算losscriterion(logits.view(-1,logits.size(-1)),labels.view(-1))print(f损失值{loss.item():.4f})5.2 优化器Optimizer改错老师人话解释知道错题后优化器负责告诉模型「每个知识点该往哪个方向改、改多少」让下次错题更少。现在大模型训练标配是AdamW改错又稳又好几乎不用换。代码示例importtorch.optimasoptim# 定义优化器只更新「允许训练」的参数optimizeroptim.AdamW(model.parameters(),# 要更新哪些参数lr2e-4,# 学习率每次改多大太大容易学歪太小学得慢weight_decay0.01# 权重衰减防止死记硬背)一句话总结损失函数算错了多少优化器负责改参数大模型标配CrossEntropyLossAdamW几乎不用换5.3 关系总结梯度的 创建者是「反向传播 loss.backward()」优化器 是梯度的「使用者」不是生产者。三个角色的明确分工损失函数nn.CrossEntropyLoss 阅卷老师它只做一件事拿着模型的预测结果和标准答案逐题比对最后算出一个总分——「这张卷子一共扣了多少分」也就是loss这个单一的数字。它只输出分数不分析“哪道题错了、该怎么订正”。loss.backward()反向传播 教研分析组拿到总分之后它从最后一层往回倒推沿着模型的计算链路用链式求导法则给每一个requires_gradTrue的参数算出这个参数调大一点总分会变高还是变低调多少分对应变化多少算出来的这个「方向幅度」就是梯度。算完之后它会把梯度存到每个参数的.grad属性里。这一步才是梯度的 真正创造者。优化器AdamW 执行修正 改错老师它只读取参数身上已经存好的.grad梯度按照学习率等规则动手去修改参数的数值。对应到代码顺序和作用一目了然# 1. 损失函数只算分此时还没有任何梯度losscriterion(logits,labels)# 到这一步只得到了一个数字loss参数的 .grad 全是空的# 2. 反向传播真正计算梯度把梯度填到每个参数身上loss.backward()# 执行完这行所有 requires_gradTrue 的参数.grad 里就有梯度值了# 3. 优化器读取梯度更新参数optimizer.step()# 执行完这行参数数值才真正被修改六、完整训练循环把所有东西串起来现在把上面讲的全部拼起来就是你天天见的训练循环。每一轮训练固定5步一个字都别乱梯度清零 → 2. 前向传播算损失 → 3. 反向传播算梯度 → 4. 裁剪梯度 → 5. 优化器更新参数我给你写一个最标准、最精简的训练循环每一行都注释人话deftrain_one_epoch(model,dataloader,optimizer,criterion,device):model.train()# 切换到上课模式total_loss0forbatchindataloader:# 第0步把数据搬到GPU input_idsbatch[input_ids].to(device)labelsbatch[labels].to(device)# 第1步清零上一轮的错题笔记 # 不清零的话新错题会和旧错题叠在一起就改乱了optimizer.zero_grad()# 第2步前向传播做题判分 logitsmodel(input_ids)# 模型做题输出结果losscriterion(logits.view(-1,logits.size(-1)),labels.view(-1))# 算错多少# 第3步反向传播算出哪里错了 # 告诉模型每个参数该往哪个方向改loss.backward()# 第4步梯度裁剪大模型必加 # 防止某道题错太离谱一下子把模型脑子改坏了torch.nn.utils.clip_grad_norm_(model.parameters(),max_norm1.0)# 第5步优化器更新参数 # 模型根据错题修改自己的知识点optimizer.step()# 累计损失后面算平均total_lossloss.item()# 返回这一轮的平均损失avg_losstotal_loss/len(dataloader)returnavg_loss你回头去看自己跑过的QLoRA代码训练循环里翻来覆去就是这5步顶多中间加了混合精度、梯度累积这些省显存的操作核心骨架永远不变。七、QLoRA专属4个「显存省钱技巧」你用QLoRA就是为了单卡跑大模型下面这几个操作就是代码里那些「花里胡哨配置」的本质。1. 梯度累积人话一沓题太多显存装不下就分成几次做攒够了再一起改错题。效果和做大batch一样但省显存。核心做几道题就loss.backward()但不更新参数攒够指定步数再optimizer.step()。2. 混合精度训练AMP人话原来用32位小数记数据现在用16位字写小一半显存直接减半计算速度还更快。核心用autocast自动把运算转成半精度用GradScaler防止梯度太小消失。3. 梯度检查点人话做题的时候不打草稿算到哪想到哪反向传播的时候再重新算一遍。省了草稿纸显存但速度慢20%左右。核心牺牲速度换显存7B以上模型单卡微调必开。4. 参数冻结 量化人话基座模型不仅冻住不改还把它压缩成4位/8位存着进一步省显存。这就是QLoRA的QQuantization量化。最后给你吃个定心丸你现在回头去翻自己跑过的QLoRA代码会发现前面加载数据 DatasetDataLoader 补长加载模型 加载基座 冻住参数 加LoRA适配器训练循环 移数据 → 算loss → 反向 → 更新 → 清零那些高级配置 混合精度、梯度累积、梯度检查点几个开关PyTorch 80%的API你日常微调基本一辈子都碰不到把上面这些核心概念搞懂就能看懂90%的微调代码也能自己改参数、排查简单报错。