大模型推理与微调:关键机制深度解析与常见误区总结

174 阅读13分钟

引言

随着大语言模型(LLM)在各类场景中不断落地,理解 它们是如何推理、如何生成、如何被训练出来的 变得日益重要。
许多工程师在使用或微调大模型时,会遇到以下疑惑:

  • 推理为什么“前面慢、后面快”?
  • KV Cache 到底缓存了什么?
  • 梯度累积究竟在解决什么问题?
  • 为什么评估策略与保存策略不一致会导致“最佳模型不最佳”?
  • Tokenization 阶段与模型实际计算到底有什么区别?

本文将从推理机理、训练机制、代码细节三个维度,系统梳理 LLM 的核心工作流程,并揭示微调实践中最常见的误区。


01 推理机制:Prefill 与 Decode 的本质区别

大模型推理由两个根本不同的阶段组成:

  1. 预填充阶段(Prefill) —— “一次性阅读 Prompt”
  2. 解码阶段(Decode) —— “逐字写作、逐字生成”

如果不了解二者的区别,很容易误判性能瓶颈,也会错估模型推理速度。


1.1 Prefill:模型的完整阅读阶段

Prefill 的目标很单纯:

一次性处理所有输入 Prompt,并构建后续生成所需的 KV Cache。

它的一次性特点决定了其计算复杂度与 Prompt 长度强烈相关。

Prefill 的关键步骤

对 Prompt 中 每一个 token 执行以下流程:

  1. Tokenization
    字符串 → token id,不涉及模型计算。

  2. Embedding Lookup
    查表得到 embedding,仍不涉及注意力。

  3. 穿过全部 Transformer 层(核心)
    对每个 token 执行:

    • Self-Attention(全量 Attention,复杂度 O(N²))
    • Feed-Forward(FFN)
  4. 生成 KV Cache
    每层都会生成整段 Prompt 的 K 和 V 向量并缓存。

  5. 得到下一 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)

  1. 查找 token id

  2. Embedding lookup(转换成语义向量,仍不涉及注意力计算)

  3. 通过每层 Transformer:

    • Self-Attention(增量 Attention:Q × K_cache)
    • FFN
    • 将新 token 的 K/V 追加进 KV Cache
  4. 输出 logits

  5. 按策略采样下一个 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"]
Seq2SeqLMT5["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~1616~32保持语义能力,同时学习复杂行为
文本生成(续写类)4~816不希望破坏语言分布,更新要轻
情感/分类任务2~48~16分类任务很小,不需要强表达能力
领域微调(法律/金融)16~3232~64需要学习大量新知识
小数据任务(几千条)2~44~8降低过拟合风险
大数据 LoRA(百万级)32~6464~128r 需要更大容量,否则拟合不够

经验法则:
让 α 大约是 r 的 1~2 倍即可。
让 α≫r 会过拟合,让 α≪r 会学不到东西。


误区 5:忽略 lora_dropout,以为 LoRA 不需要正则化

很多人配置 LoRA 时只关注 rlora_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) →  BA

这个 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 原理、训练稳定性机制,你才能真正调优模型,而不是“把参数调来调去”。