引言
随着大语言模型(LLM)在各类场景中不断落地,理解 它们是如何推理、如何生成、如何被训练出来的 变得日益重要。
许多工程师在使用或微调大模型时,会遇到以下疑惑:
- 推理为什么“前面慢、后面快”?
- KV Cache 到底缓存了什么?
- 梯度累积究竟在解决什么问题?
- 为什么评估策略与保存策略不一致会导致“最佳模型不最佳”?
- Tokenization 阶段与模型实际计算到底有什么区别?
本文将从推理机理、训练机制、代码细节三个维度,系统梳理 LLM 的核心工作流程,并揭示微调实践中最常见的误区。
01 推理机制:Prefill 与 Decode 的本质区别
大模型推理由两个根本不同的阶段组成:
- 预填充阶段(Prefill) —— “一次性阅读 Prompt”
- 解码阶段(Decode) —— “逐字写作、逐字生成”
如果不了解二者的区别,很容易误判性能瓶颈,也会错估模型推理速度。
1.1 Prefill:模型的完整阅读阶段
Prefill 的目标很单纯:
一次性处理所有输入 Prompt,并构建后续生成所需的 KV Cache。
它的一次性特点决定了其计算复杂度与 Prompt 长度强烈相关。
Prefill 的关键步骤
对 Prompt 中 每一个 token 执行以下流程:
-
Tokenization
字符串 → token id,不涉及模型计算。 -
Embedding Lookup
查表得到 embedding,仍不涉及注意力。 -
穿过全部 Transformer 层(核心)
对每个 token 执行:- Self-Attention(全量 Attention,复杂度 O(N²))
- Feed-Forward(FFN)
-
生成 KV Cache
每层都会生成整段 Prompt 的 K 和 V 向量并缓存。 -
得到下一 token 的 logits
Prefill 的特点
- 计算量大
- Attention 是全量运算,复杂度为 O(N²)
- KV Cache 在此阶段一次性构建
- Prompt 越长,Prefill 越慢
- 大多数推理延迟都耗在这里
Prefill 就像阅读文章:
越长的文章,要读的越久。
1.2 Decode:基于 KV Cache 的增量生成阶段
Decode 是 LLM 生成阶段的全部精华:
每次只生成 1 个 token,且只计算它自己的 Query。
其他历史上下文全部来自 KV Cache。
这让解码速度相比 Prefill 快得多。
Decode 的流程(每个 token)
-
查找 token id
-
Embedding lookup(转换成语义向量,仍不涉及注意力计算)
-
通过每层 Transformer:
- Self-Attention(增量 Attention:Q × K_cache)
- FFN
- 将新 token 的 K/V 追加进 KV Cache
-
输出 logits
-
按策略采样下一个 token
Decode 的特点
- 计算复杂度 O(N)
- KV Cache 让 Self-Attention 不需要重新算全序列
- 一次生成一个 token
- 模型越大但 seq_len 不变时,解码速度下降不明显
02 微调机制:从理论到代码的全流程理解
这部分我们结合示例代码,讲透工程中最容易误解与误用的一些机制。
2.1 全参数微调(Full Fine-Tuning)
全参数微调是最传统、也最彻底的微调方式:
- 更新模型中所有可训练参数(100%)
- 能最大限度适应新任务
- 但成本高、显存需求大、训练耗时长
- 在大模型时代,由于成本极高,全参数微调的应用正在快速减少,但仍是部分场景的最优方案(如医学/法律等专业领域)
适合的场景包括:
- 有强算力预算(A100/H100)
- 精度要求极高(如评测榜优化)
- 基础模型与目标任务分布差异很大
全参微调代码示例
import numpy as np
import evaluate
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForSequenceClassification, DataCollatorWithPadding
from transformers import TrainingArguments, Trainer
raw_datasets = load_dataset("glue", "mrpc")
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
#将文本序列转成数字序列
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
#整理函数负责模型处理批次划分,并通过Padding让批次内的样本语义张量维度一致
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-uncased",
num_labels=2
)
training_args = TrainingArguments(
output_dir="test-trainer",
evaluation_strategy="epoch",
save_strategy="epoch",
learning_rate=2e-5,
per_device_train_batch_size=4,
per_device_eval_batch_size=8,
gradient_accumulation_steps=4,
fp16=True,
num_train_epochs=3,
logging_steps=100,
load_best_model_at_end=True,
metric_for_best_model="accuracy"
)
# Define evaluation metric
def compute_metrics(eval_preds):
metric = evaluate.load("glue", "mrpc")
logits, labels = eval_preds
predictions = np.argmax(logits, axis=-1)
return metric.compute(predictions=predictions, references=labels)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
tokenizer=tokenizer,
compute_metrics=compute_metrics,
)
trainer.train()
trainer.save_model()
为了更好理解代码背后的机制,我们重点解析三类容易误解的关键点。
全参数微调中最常见的误区与关键机制
误区 1:把per_device_train_batch_size调小就能解决显存不足
这是非常常见、但影响训练稳定性的误操作。
正确思路:梯度累积
当显存不足时,不能简单缩小per_device_train_batch_size,否则:
- 梯度估计方差变大
- Loss 曲线抖动
- 训练变得难以收敛
正确做法:
使用梯度累积,使有效 batch size = per_device_train_batch_size * gradient_accumulation_steps 不变
例如:
per_device_train_batch_size = 4
gradient_accumulation_steps = 4
→ 有效 batch size = 16
这才是工程实践中广泛使用的策略。
误区 2:评估策略与保存策略不一致
许多人把:
- evaluation_strategy = epoch
- save_strategy = steps
混着用,导致“最佳模型不是最佳模型”的经典问题。
为什么这会导致混乱?
因为:
- 模型在某次保存时 没有被评估
- 或者评估结果对应的是之前的 checkpoint
- Trainer 会引用 不对应 的指标来决定 best model
最佳实践:
让 evaluation_strategy 与 save_strategy 对齐。
通常都用 "epoch"。
误区 3:将 Tokenization 当成“模型的计算”
许多初学者认为:
map(tokenize_function) → embedding → attention
其实完全不是!
Tokenization 只是 CPU 字符串处理:
- 将字符串映射为 id
- 不涉及 embedding
- 不涉及模型 forward
- batch_size 调整的是“文本预处理批次”,不是训练批次
模型真正的计算发生在:
- embedding lookup
- attention
- FFN
- classifier head
这一点理解清楚后,就知道为什么调 tokenizer 的 batch_size 不会影响显存占用,它只影响CPU内存占用。
2.2 LoRA 微调(Low-Rank Adaptation)
LoRA 的核心理念可以一句话概括:
不给原模型动刀,只在关键线性层外挂一个“小模块”。只训练小模块,冻结原模型。
收益巨大:
- 可训练参数 < 1%(有时甚至 0.1%)
- 显存需求大幅下降(30%~70%)
- 下游性能接近全参数微调(90%+)
- 多 LoRA 可组合、热插拔,非常灵活
适用场景:
- 算力资源预算有限
- 企业内部多个小微调场景
- 想快速迭代多个版本(如特定口吻、行业指令、情感风格)
这让微调成本大幅下降,越来越成为大模型微调的首选方案。
LoRA 的代码示例(与全参数微调结构平行)
import numpy as np
import evaluate
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForSequenceClassification, DataCollatorWithPadding
from peft import LoraConfig, get_peft_model, TaskType
from transformers import TrainingArguments, Trainer
# 加载数据与 tokenizer
raw_datasets = load_dataset("glue", "mrpc")
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
# 加载基础模型(冻结权重)
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-uncased",
num_labels=2
)
# 配置 LoRA
lora_config = LoraConfig(
task_type=TaskType.SEQ_CLS,
r=8,
lora_alpha=16,
lora_dropout=0.05,
target_modules=["query", "value"] # 对注意力投影层插 LoRA
)
# 将模型转为 LoRA 版本
model = get_peft_model(model, lora_config)
training_args = TrainingArguments(
output_dir="lora-output",
evaluation_strategy="epoch",
save_strategy="epoch",
learning_rate=2e-4, # LoRA 可使用更大学习率
per_device_train_batch_size=16,
per_device_eval_batch_size=16,
num_train_epochs=3,
fp16=True,
logging_steps=100,
)
# Define evaluation metric
def compute_metrics(eval_preds):
metric = evaluate.load("glue", "mrpc")
logits, labels = eval_preds
predictions = np.argmax(logits, axis=-1)
return metric.compute(predictions=predictions, references=labels)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
tokenizer=tokenizer
)
trainer.train()
trainer.save_model()
特点:
- LoRA 的学习率可以比全参数微调大一个量级(如 2e-4 对比 2e-5)
- 训练显存、时间显著减少
- target_modules 的选择影响效果
- 生成类任务中一般对 q_proj / v_proj 加 LoRA
LoRA 微调中的常见误区与关键机制
误区 1:忽略 task_type,不同模型的 target_modules 配置完全不同
LoRA 的 target_modules 并不是“随便填都差不多”。
不同类别模型(CausalLM、MaskedLM、Seq2SeqLM)有完全不同的模块命名。
如果配置错误,LoRA 会完全不工作。
正确示例(按模型类型选择):
| 模型类型 | 常见模型 | 推荐 target_modules |
|---|---|---|
| CausalLM(生成类) | LLaMA、Qwen、GLM、Baichuan | ["q_proj", "v_proj"] |
| MaskedLM(BERT 类) | BERT、RoBERTa、MacBERT | ["query", "value"] |
| Seq2SeqLM | T5 | ["q", "v"] |
⚠️ 例如在 LLaMA 中写 ["query", "value"] 是无效的,因为模型中没有这些模块名。
自动验证 target_modules 是否命中模型:
preview_lora_modules(model, target_modules)
如果没有任何输出 → 你当前配置不生效。
误区 2:学习率仍沿用全参微调的数值
全参微调通常用:
2e-5 ~ 5e-5
但 LoRA 的训练参数量很小,反而需要更大学习率:
2e-4 ~ 5e-4
如果学习率太小:
- 训练非常慢
- 很难学到新任务
误区 3:忽略 LoRA rank 对效果与显存的影响
LoRA 的核心超参是 r(rank) 。
经验公式:
- r = 8 → 性能最均衡
- r = 16 → 更好性能,显存更高
- r = 4 → 性能下降明显,但推理更轻量
关键点:
rank 决定 LoRA 的“表达能力”
参数越大,越能拟合复杂任务。
误区 4:只调 lora_alpha,而忽略与 r 的联动关系
很多人只调 lora_alpha,但忽略了 LoRA 的真实影响强度是 α/r,而不是 α 本身。
影响强度 = (α / r) · B·A
也就是说:
- r 变大 → LoRA 更容易影响原模型
- α 变大 → LoRA 更新幅度更强
- 但真正决定强度的是二者的比例 α/r
🔧 不同任务下的推荐参数(高质量最佳实践表)
| 任务类型 | 推荐 r | 推荐 α | 解释 |
|---|---|---|---|
| 指令微调(ChatGPT 类) | 8~16 | 16~32 | 保持语义能力,同时学习复杂行为 |
| 文本生成(续写类) | 4~8 | 16 | 不希望破坏语言分布,更新要轻 |
| 情感/分类任务 | 2~4 | 8~16 | 分类任务很小,不需要强表达能力 |
| 领域微调(法律/金融) | 16~32 | 32~64 | 需要学习大量新知识 |
| 小数据任务(几千条) | 2~4 | 4~8 | 降低过拟合风险 |
| 大数据 LoRA(百万级) | 32~64 | 64~128 | r 需要更大容量,否则拟合不够 |
经验法则:
让 α 大约是 r 的 1~2 倍即可。
让 α≫r 会过拟合,让 α≪r 会学不到东西。
误区 5:忽略 lora_dropout,以为 LoRA 不需要正则化
很多人配置 LoRA 时只关注 r 和 lora_alpha,却把 lora_dropout 放在默认值,甚至干脆设为 0,认为:
- LoRA 只训练少量参数,不会过拟合
- dropout 不重要
- 数据量够大,正则化无所谓
这是错误的。
🚨 为什么忽略 lora_dropout 是严重误区?
原因有三点:
(1)LoRA 的 ΔW 是“额外添加”的,高度易过拟合
LoRA 本质是在原模型 W 上加一个:
ΔW = (α/r) · B·A
ΔW 是“新增参数”,没有预训练先验。
只要数据不够大,LoRA 层就会很容易直接“死记硬背”,出现典型的:
- 训练集表现很好
- 测试集效果极差
- 文本生成出现过度贴合训练集
(2)r 控制“容量”,但不控制“泛化能力”
r 控制 LoRA 能修改多少维度,但不控制是否在训练中过拟合。
即使 r 很小,例如 r=4,也完全可能记住训练集。
(3)Dropout 是 LoRA 的唯一正则手段
因为你冻结了 W,不能对主模型做正则,只能在 LoRA 的 B→A 路径上加 dropout:
X → Dropout(p) → B → A
这个 dropout 的作用是:
- 迫使 LoRA 不依赖特定通道
- 增强泛化能力
- 减少“死记硬背式”的拟合
- 特别适合小数据任务
🔧 适用场景与推荐配置(最佳实践)
下面是可直接放入文章的配置表:
| 任务规模 / 场景 | 推荐 dropout | 原因说明 |
|---|---|---|
| 超大规模微调(10 万条以上) | 0 ~ 0.05 | 数据足够,过拟合风险低 |
| 中等规模(1~10 万条) | 0.05 ~ 0.1 | 轻度正则化,最常见选择 |
| 小数据集(数千条) | 0.1 ~ 0.2 | 强正则,避免模型死记硬背 |
| 情感分析 / 分类任务 | 0.1 ~ 0.3 | 任务简单,非常容易过拟合 |
| 知识注入(让模型记新知识) | 0 ~ 0.05 | 不希望丢失细节,较少正则化 |
| 对话、指令微调(ChatGPT 类) | 0.05(默认即可) | 一般任务的最佳平衡点 |
| 极小数据(几百条) | 0.2 ~ 0.3 | 不加强正则会完全过拟合 |
默认最安全值是:lora_dropout = 0.05
但这只是中庸配置,不适合极小数据或极易过拟合任务。
误区 6:以为 LoRA 推理自动生效,忘记 merge 权重
LoRA 推理有三种方式:
- 实时加载(对原模型叠加 LoRA)
- merge-and-unload(合并成一个模型)
- 多 LoRA 动态切换(非常方便)
如果在部署时忘记加载或 merge LoRA,会:
- 模型表现退化
- 输出彻底不符合微调结果
2.3 全参数微调 vs LoRA 微调:对比分析
以下是工程团队最关心的关键差异:
| 项目 | 全参数微调 | LoRA 微调 |
|---|---|---|
| 训练的参数量 | 100% | <1%(低至 0.1%) |
| 显存占用 | 极高 | 极低(低 30%~70%) |
| 训练速度 | 慢 | 快 |
| 部署复杂度 | 简单(一个模型) | 需要合并/加载 LoRA 权重 |
| 灵活性 | 较低 | 多 LoRA 可热插拔、组合 |
| 学习率要求 | 较小(2e-5) | 较大(2e-4) |
| 优点 | 可获得最强性能 | 性能接近全参但成本极低 |
| 缺点 | 成本巨大 | 在极高难度任务下性能略逊 |
一句话总结:
全参数微调追求性能上限;LoRA 微调追求成本效率。
90% 的场景,LoRA 性价比远高于全参微调。
03 全文总结:理解机制比记住 API 更重要
(1)推理阶段:LLM 是“先读后写”的系统
- Prefill → 全量计算:O(N²)
- Decode → 增量计算:O(N)
- KV Cache 是性能加速的本质
(2)微调阶段:稳定训练依赖关键机制的正确配置
- 有效 batch size = batch × accumulation_steps
- strategy 对齐才能选出真正的 best model
- LoRA rank 决定表达能力,但越大越容易过拟合
- LoRA 的有效影响强度依赖 α/r,而不仅仅是α
- lora_dropout 是 LoRA 唯一的泛化手段,小数据任务必须加足
(3)数据预处理与模型计算必须区分
- Tokenization 不等于 embedding
- embedding 不等于 attention
- attention 不等于推理速度
(4)理解机制的工程师,比懂 API 的工程师更稀缺
- 只有掌握 Prefill/Decode 的运行机理、KV Cache 原理、训练稳定性机制,你才能真正调优模型,而不是“把参数调来调去”。