大模型这个领域,最让人头疼的从来不是"要不要学",而是"从哪下手"。我见过太多人抱着《Attention Is All You Need》啃了三天,最后卡在多头注意力的维度变换上;也见过有人环境配了一周,连第一个token都没生成出来。上海交大那套《动手学大模型》教程能在GitHub上霸榜,本质上就是因为它把这条"从懵到能跑"的路给铺平了——不是那种只讲概念的PPT教程,而是每一章都让你真的动手敲代码、真的把模型跑起来。这篇我就结合这套教程的脉络,加上我自己踩过的坑,把大模型从入门到微调实战的完整路径拆开讲一遍。不管你是刚接触Transformer的学生,还是想把手头业务模型做领域适配的工程师,下面这些内容应该都能让你少走几天弯路。
1. 为什么这套教程值得花时间啃
1.1 它解决的是"知识断层"问题
大部分大模型学习资料有个通病:要么纯讲原理,公式推到天上去,但你看完不知道代码怎么写;要么纯讲调包,from transformers import AutoModel一贴,跑是跑通了,但模型里面发生了什么你完全没概念。《动手学大模型》这套教程的定位恰好卡在中间——它假设你有基本的Python和深度学习基础,但不假设你懂大模型。从Transformer的编码器结构开始,一层一层用手写代码的方式拆给你看,然后再过渡到用HuggingFace生态做实际任务。
这个顺序很关键。我自己的经验是,如果你跳过手写Transformer直接去调包,后面遇到微调不收敛、显存爆炸、推理结果诡异这些问题时,你根本没有排查能力。因为你不知道模型内部在干什么,就只能瞎试参数。而手写一遍之后,你至少知道hidden_size、num_heads、num_layers这些参数各自控制什么,出了问题能定位到具体环节。
1.2 教程的章节脉络与学习节奏
这套教程的内容编排大致遵循这样一条线:Transformer原理与手写实现 → 预训练语言模型基础 → 大模型微调技术 → 模型部署与推理优化 → 实战项目。每个阶段都有配套的代码和练习,不是光看就完事。
我建议的学习节奏是这样的:Transformer手写部分不要赶,花三到五天,确保你能不看参考代码从零写出一个能跑通前向传播的Transformer。微调部分重点吃透LoRA,因为这是目前性价比最高的微调方式,单卡就能玩。部署部分至少跑通一次vLLM或Ollama的本地部署,感受一下推理框架对性能的影响。
注意:不要试图一次性把所有章节都看完再动手。这套教程的正确用法是"看一章、敲一章、跑一章",每章的代码都要自己复现一遍,哪怕照着抄也要抄一遍。
1.3 适合什么样的人学
这套教程不是零基础入门。如果你连Python的类和继承都用不熟,或者不知道什么是梯度下降,建议先去补基础。但如果你满足以下任意一条,这套教程会非常适合你:
- 有深度学习基础,用过PyTorch写过简单的神经网络
- 做过后端或算法开发,想转型到大模型方向
- 手头有业务数据,想把开源大模型微调成领域专用模型
- 已经会调OpenAI的API,但想理解底层原理并做本地部署
2. Transformer手写:绕不过去的第一道坎
2.1 从零实现一个Transformer编码器
Transformer的编码器部分,核心就是三个东西:多头自注意力、前馈网络、残差连接加层归一化。听起来简单,但手写的时候细节特别多。我当初写第一版的时候,光是维度对齐就调了两个小时。
先说输入的处理。假设你的输入是一个batch的token序列,经过embedding层之后得到形状为(batch_size, seq_len, d_model)的张量。这个d_model就是整个模型的隐藏维度,比如512或768。然后你需要加上位置编码,因为Transformer本身没有序列顺序的概念,位置信息全靠位置编码注入。
位置编码的实现有两种常见方式:一种是固定的正弦余弦编码,一种是可学习的位置嵌入。教程里两种都讲了,我建议先用正弦余弦版本,因为它不需要训练参数,而且能泛化到比训练时更长的序列。
import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=5000): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) pe = pe.unsqueeze(0) self.register_buffer('pe', pe) def forward(self, x): return x + self.pe[:, :x.size(1), :]这段代码里有个容易忽略的点:register_buffer。用这个方法注册的pe不会被当作模型参数更新,但会跟着模型一起保存和加载,而且会自动移到GPU上。如果你直接用普通属性存,模型搬到GPU的时候这个张量还在CPU上,就会报设备不匹配的错。
2.2 多头注意力:维度变换的坑最多
多头注意力是整个Transformer里最容易写错的部分。核心逻辑是:把输入投影到Q、K、V三个空间,然后分成多个头分别计算注意力,最后拼接起来再投影一次。
维度变换的流程是这样的:输入(batch, seq_len, d_model)经过线性层得到Q、K、V,形状都是(batch, seq_len, d_model)。然后reshape成(batch, seq_len, num_heads, head_dim),再transpose成(batch, num_heads, seq_len, head_dim)。这里head_dim = d_model // num_heads。
class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() assert d_model % num_heads == 0 self.d_model = d_model self.num_heads = num_heads self.head_dim = d_model // num_heads self.q_linear = nn.Linear(d_model, d_model) self.k_linear = nn.Linear(d_model, d_model) self.v_linear = nn.Linear(d_model, d_model) self.out_linear = nn.Linear(d_model, d_model) def forward(self, x, mask=None): batch_size, seq_len, _ = x.shape q = self.q_linear(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) k = self.k_linear(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) v = self.v_linear(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) attn = torch.softmax(scores, dim=-1) out = torch.matmul(attn, v) out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model) return self.out_linear(out)这里有几个实战中总结的要点。第一,math.sqrt(self.head_dim)这个缩放因子不能省,否则softmax之后的梯度会非常小,训练不动。第二,mask的处理要注意形状,通常是(batch, 1, 1, seq_len)或者(batch, 1, seq_len, seq_len),要能广播到scores的形状上。第三,transpose之后一定要.contiguous()再view,否则会报内存不连续的错。
2.3 残差连接与层归一化的顺序问题
原始Transformer论文里用的是Post-LN,也就是先做注意力或前馈,再加残差,最后层归一化。但后来的实践发现Pre-LN(先归一化再进子层)训练更稳定,尤其是模型深了之后。现在大部分开源大模型用的都是Pre-LN或者RMSNorm。
如果你只是学习原理,按论文的Post-LN写就行。但如果要实际训练,建议直接上Pre-LN。这个细节在教程里可能不会特别强调,但你跑通之后做对比实验就能感受到差异。
3. 微调实战:LoRA为什么成了标配
3.1 全量微调 vs LoRA:显存账要算清楚
全量微调一个7B参数的模型,光是模型权重就要占14GB显存(FP16),加上优化器状态、梯度、激活值,没有个五六张A100根本跑不动。这对个人开发者和小团队来说基本不可行。
LoRA的思路很巧妙:我不动你原来的权重,我在旁边加两个小矩阵,训练的时候只更新这两个小矩阵。假设原始权重是W,形状(d, k),LoRA加上B @ A,其中A的形状是(r, k),B的形状是(d, r),r远小于d和k。这样可训练参数就从d*k降到了r*(d+k)。
举个例子,一个d=4096, k=4096的权重矩阵,全量微调要训练1600多万个参数。如果r=8,LoRA只需要训练8*(4096+4096)=65536个参数,差了240多倍。显存占用和训练时间都大幅下降。
3.2 LoRA微调Qwen模型的完整流程
以Qwen系列模型为例,用LoRA做指令微调的流程大致如下。首先安装依赖:
pip install transformers peft datasets accelerate bitsandbytes然后加载模型和tokenizer。这里用4bit量化加载可以进一步省显存:
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig from peft import LoraConfig, get_peft_model, TaskType bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.float16 ) model = AutoModelForCausalLM.from_pretrained( "Qwen/Qwen2.5-7B-Instruct", quantization_config=bnb_config, device_map="auto" ) tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")配置LoRA参数:
lora_config = LoraConfig( task_type=TaskType.CAUSAL_LM, r=8, lora_alpha=32, lora_dropout=0.1, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"] ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()target_modules的选择很关键。对于Qwen这类模型,注意力层的q_proj、k_proj、v_proj、o_proj是标配,有些实践还会加上前馈层的gate_proj、up_proj、down_proj。加得越多,可训练参数越多,效果可能更好但显存也更高。我一般先用四个注意力投影层跑一版,效果不够再加。
lora_alpha和r的关系是alpha/r作为缩放系数。经验值是alpha设为r的两倍或四倍。r=8, alpha=32是比较稳的组合。
3.3 数据准备:格式比数量重要
LoRA微调的数据格式,最常见的是指令-回答对。每条数据包含instruction、input(可选)、output三部分。关键是你要把数据转成模型能理解的prompt格式。
以Qwen的chat template为例:
def format_data(sample): messages = [ {"role": "system", "content": "你是一个专业的助手。"}, {"role": "user", "content": sample["instruction"] + sample.get("input", "")}, {"role": "assistant", "content": sample["output"]} ] return tokenizer.apply_chat_template(messages, tokenize=False)数据量方面,LoRA微调通常几百到几千条就能看到明显效果。但质量比数量重要得多。我试过用500条精心构造的数据,效果比5000条爬来的脏数据好很多。重点检查:回答是否准确、格式是否统一、有没有重复样本、有没有明显的错误标注。
3.4 训练参数设置与常见报错
训练用Trainer或者SFTTrainer都可以。关键参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| learning_rate | 1e-4 ~ 2e-4 | LoRA的学习率比全量微调大 |
| batch_size | 1 ~ 4 | 受显存限制,配合梯度累积 |
| gradient_accumulation_steps | 4 ~ 16 | 等效增大batch |
| num_epochs | 2 ~ 3 | LoRA容易过拟合,不宜多 |
| warmup_ratio | 0.03 ~ 0.1 | 预热比例 |
| lr_scheduler | cosine | 余弦衰减比较稳 |
| max_seq_length | 512 ~ 2048 | 根据数据长度定 |
常见报错里,CUDA out of memory最常见。解决办法:减小batch_size、降低max_seq_length、开启gradient_checkpointing、用4bit量化加载。另一个常见问题是loss不下降,通常是学习率太小或者数据格式不对,先检查数据有没有正确tokenize。
4. 本地部署:把模型跑起来才算数
4.1 vLLM vs Ollama:场景决定选择
模型微调完之后,你得把它部署起来才能用。目前主流的本地部署方案有两个方向:vLLM和Ollama。
vLLM的核心优势是吞吐量。它用了PagedAttention技术,把KV Cache分成固定大小的块来管理,显存利用率比朴素实现高很多。如果你要做一个多人使用的API服务,vLLM基本是首选。启动命令很简单:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/your/model \ --dtype float16 \ --max-model-len 4096 \ --gpu-memory-utilization 0.9启动之后就是一个兼容OpenAI API格式的服务,可以直接用openai的Python库调用。
Ollama的定位更偏向个人使用和快速体验。它把模型下载、量化、运行都封装好了,一条命令就能跑:
ollama run qwen2.5:7bOllama适合快速验证和本地开发,但并发能力弱,不适合生产环境。vLLM适合做服务,但配置稍微复杂一些。我的建议是:开发阶段用Ollama快速迭代,上线用vLLM。
4.2 显存不够怎么办:量化方案对比
本地部署最大的门槛是显存。一个7B模型FP16需要约14GB显存,加上KV Cache,实际需要16GB以上。如果你的显卡只有8GB或12GB,就需要量化。
常见的量化方案:
- GPTQ:训练后量化,4bit下7B模型约需4GB显存,质量损失较小
- AWQ:激活感知量化,比GPTQ略好,但支持的模型少一些
- GGUF:llama.cpp用的格式,CPU也能跑,但速度慢
- bitsandbytes:在线量化,加载时量化,方便但速度稍慢
我实测下来,4bit的GPTQ或AWQ量化模型,在大多数任务上跟FP16的差距很小,但显存占用只有三分之一左右。对于个人开发者来说,量化基本是必选项。
4.3 推理参数调优:temperature和top_p怎么设
模型跑起来之后,生成质量很大程度上取决于推理参数。几个关键参数:
- temperature:控制随机性。0.1以下适合确定性任务(如代码生成、数学题),0.7-1.0适合创意写作
- top_p:核采样,只从累积概率前p的token中采样。通常设0.9-0.95
- top_k:只从概率最高的k个token中采样。一般设40-50
- repetition_penalty:重复惩罚,1.0表示不惩罚,1.1-1.2可以抑制重复
实际使用中,我一般temperature和top_p二选一调,不要同时大改。做知识问答时temperature=0.1, top_p=0.9比较稳;做文案生成时temperature=0.8, top_p=0.95更有创意。
5. 那些教程不会告诉你的踩坑经验
5.1 环境配置的隐形陷阱
大模型开发的环境配置,坑比想象中多。CUDA版本、PyTorch版本、transformers版本、peft版本之间的兼容性是个矩阵,稍不注意就冲突。
我的建议是用conda建独立环境,然后按这个顺序装:先确定CUDA版本(用nvidia-smi看),然后装对应CUDA版本的PyTorch,再装transformers和peft。不要用pip install一把梭,版本冲突了很难排查。
另一个坑是bitsandbytes在Windows上的支持问题。如果你用Windows,建议直接上WSL2,原生Windows下bitsandbytes的安装经常出问题。
5.2 微调效果不好的排查思路
LoRA微调之后效果不理想,按这个顺序排查:
第一,看loss曲线。如果loss完全不降,大概率是数据格式问题或者学习率太小。如果loss降了但验证集效果差,是过拟合,减少epoch或增大dropout。
第二,检查数据质量。随机抽几条训练数据,用tokenizer编码后decode出来看看,确认格式正确、没有截断。
第三,确认target_modules是否正确。不同模型的层命名不一样,用model.named_modules()打印出来确认。
第四,试试调大r和lora_alpha。有时候模型容量不够,增大秩能改善。
5.3 模型合并与导出
LoRA训练完之后,推理时有两种方式:一种是加载基座模型再加载LoRA适配器,另一种是把LoRA权重合并到基座模型里导出成一个完整模型。
合并的代码:
from peft import PeftModel base_model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct") model = PeftModel.from_pretrained(base_model, "./lora_output") merged_model = model.merge_and_unload() merged_model.save_pretrained("./merged_model")合并的好处是推理时不需要额外加载适配器,部署更简单。但合并后的模型文件跟基座一样大,而且不能再单独调整LoRA权重。如果要做多套LoRA切换,就不要合并。
5.4 从学习到产出的最短路径
最后说一个我自己的体会。大模型这个领域,看再多教程不如跑通一个完整流程。我的建议是给自己定一个具体目标,比如"用LoRA微调一个模型,让它能回答我所在领域的专业问题",然后围绕这个目标去学。遇到什么不会就查什么,比按部就班从头学到尾效率高得多。
具体路径可以这样:第一周手写Transformer并跑通;第二周跑通一个LoRA微调的demo;第三周准备自己的数据做一次真实微调;第四周部署成API服务。四周下来,你对大模型的理解会超过看三个月视频的人。
这套上海交大的教程最大的价值,就是给了你一条可以跟着走的路径。但路径归路径,路还是得自己走。代码要自己敲,bug要自己调,模型要自己跑。这个过程里积累的经验,才是真正属于你的东西。