前面几篇已经把 LoRA 的机制和数学直觉讲了一遍:
- LoRA 机制学习笔记:LoRA 如何插进 Transformer。
- LoRA rank=16 数学直觉:rank=16 为什么不是“只更新 16 个维度”。
- Program-as-Weights 学习笔记:函数如何被编译成权重程序。
- Parametric Skills 学习笔记:Agent 技能如何被参数化成 LoRA。
但只看概念还不够。真正理解 LoRA,最好本地跑一条标准链路:
base model
→ 加载 LoRA adapter
→ active adapter 推理
→ merge_and_unload 合并
→ merged model 推理
这篇记录我在本机跑通的一个真实 PEFT LoRA 例子。它不是手写 toy matrix,而是用 Hugging Face 上公开的 LoRA adapter,按生产里常见的 transformers + peft 方式加载和推理。
1. 这次跑的是什么
目标任务:给一个 git diff 生成 Conventional Commit message。
使用模型:
adapter: Elib27/qwen2.5-0.5b-commit-msg-lora
base model from adapter_config: unsloth/Qwen2.5-0.5B-Instruct
这个 adapter 是一个标准 PEFT LoRA。它不是完整模型仓库,而是只保存 adapter 配置和 LoRA 权重。使用时需要先加载 base model,再把 adapter 挂上去。
这就是工业里很常见的交付方式:
一个大 base model
+ 很多个轻量 adapter
不同任务可以挂不同 adapter:
commit-message adapter
json-repair adapter
tool-routing adapter
company-style adapter
2. 本地环境
机器环境:
macOS 26.4.1
Apple Silicon arm64
内存:24GB
设备后端:MPS
Python 和库版本:
Python 3.12.13
torch 2.12.1
transformers 5.13.0
peft 0.19.1
accelerate 1.14.0
safetensors 0.8.0
huggingface_hub 1.22.0
为什么不用系统 Python?
我本机的系统 Python 是 3.14,而主流 PyTorch / Transformers 对最新 Python 的支持经常滞后。为了避免污染系统环境,也避免兼容性问题,我用 uv 创建了一个 Python 3.12 的隔离环境。
3. 环境准备
创建目录:
mkdir -p ~/lora-peft-demo
cd ~/lora-peft-demo
创建虚拟环境:
uv venv --python 3.12 .venv
安装依赖:
uv pip install --python .venv/bin/python \
'torch>=2.6' \
'transformers>=4.56' \
'peft>=0.17' \
'accelerate>=1.8' \
safetensors \
huggingface_hub
如果当前网络走 SOCKS 代理,huggingface_hub 可能会报:
Using SOCKS proxy, but the 'socksio' package is not installed.
补装:
uv pip install --python .venv/bin/python socksio
检查环境:
.venv/bin/python - <<'PY'
import torch, transformers, peft, accelerate, safetensors, huggingface_hub
print('torch', torch.__version__)
print('transformers', transformers.__version__)
print('peft', peft.__version__)
print('accelerate', accelerate.__version__)
print('safetensors', safetensors.__version__)
print('huggingface_hub', huggingface_hub.__version__)
print('mps_available', torch.backends.mps.is_available())
PY
输出:
torch 2.12.1
transformers 5.13.0
peft 0.19.1
accelerate 1.14.0
safetensors 0.8.0
huggingface_hub 1.22.0
mps_available True
4. 先看 adapter_config
PEFT adapter 最重要的是 adapter_config.json。它告诉我们:
这个 adapter 属于哪种 PEFT 方法
绑定哪个 base model
rank 是多少
alpha 是多少
LoRA 插到哪些模块上
本次 adapter 的关键配置:
{
"peft_type": "LORA",
"task_type": "CAUSAL_LM",
"base_model_name_or_path": "unsloth/Qwen2.5-0.5B-Instruct",
"r": 16,
"lora_alpha": 32,
"lora_dropout": 0,
"target_modules": [
"down_proj",
"gate_proj",
"k_proj",
"o_proj",
"q_proj",
"up_proj",
"v_proj"
]
}
这里能看到几个重点。
第一,它是标准 LoRA:
peft_type = LORA
第二,它是 causal language model 任务:
task_type = CAUSAL_LM
第三,它的 rank 是 16:
r = 16
第四,它插的位置很完整:
attention: q_proj / k_proj / v_proj / o_proj
MLP: gate_proj / up_proj / down_proj
也就是说,这不是只插一个小地方的演示 adapter,而是典型的“attention + MLP 全投影层 LoRA”配置。
5. 完整脚本
脚本路径可以放在实验目录下:
~/lora-peft-demo/run_peft_lora_demo.py
完整代码如下:
import gc
import json
import time
import torch
import transformers
from peft import PeftConfig, PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
ADAPTER_ID = "Elib27/qwen2.5-0.5b-commit-msg-lora"
PROMPT = """Generate one Conventional Commit message for this git diff.
Return only the commit message.
diff --git a/src/api/users.ts b/src/api/users.ts
index 91a2b31..b7fd402 100644
--- a/src/api/users.ts
+++ b/src/api/users.ts
@@ -12,7 +12,12 @@ export async function createUser(req, res) {
- const user = await db.user.create({ data: req.body })
+ const payload = userCreateSchema.parse(req.body)
+ const user = await db.user.create({ data: payload })
res.json(user)
}
diff --git a/src/api/__tests__/users.test.ts b/src/api/__tests__/users.test.ts
new file mode 100644
index 0000000..7a91f21
--- /dev/null
+++ b/src/api/__tests__/users.test.ts
@@ -0,0 +1,8 @@
+it("rejects invalid user payloads", async () => {
+ const res = await request(app).post("/users").send({ email: "bad" })
+ expect(res.status).toBe(400)
+})
"""
def pick_device() -> tuple[str, torch.dtype]:
if torch.backends.mps.is_available():
return "mps", torch.float16
return "cpu", torch.float32
def count_params(model: torch.nn.Module) -> tuple[int, int]:
total = 0
trainable = 0
for p in model.parameters():
n = p.numel()
total += n
if p.requires_grad:
trainable += n
return total, trainable
def freeze(model: torch.nn.Module) -> None:
for p in model.parameters():
p.requires_grad_(False)
def fmt_params(n: int) -> str:
if n >= 1_000_000_000:
return f"{n / 1_000_000_000:.2f}B"
if n >= 1_000_000:
return f"{n / 1_000_000:.2f}M"
if n >= 1_000:
return f"{n / 1_000:.2f}K"
return str(n)
def generate(model, tokenizer, device: str, title: str) -> str:
messages = [{"role": "user", "content": PROMPT}]
text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
inputs = tokenizer(text, return_tensors="pt").to(device)
t0 = time.perf_counter()
with torch.inference_mode():
output_ids = model.generate(
**inputs,
max_new_tokens=40,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
elapsed = time.perf_counter() - t0
new_tokens = output_ids[0, inputs["input_ids"].shape[-1] :]
output = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
print(f"\n=== {title} ===")
print(output)
print(f"[generated {new_tokens.numel()} tokens in {elapsed:.2f}s]")
return output
def inspect_adapter(model: PeftModel) -> None:
print("\n=== LoRA module samples ===")
shown = 0
lora_param_count = 0
for name, param in model.named_parameters():
if ".lora_A." in name or ".lora_B." in name:
lora_param_count += param.numel()
if shown < 14:
print(f"{name}: shape={tuple(param.shape)}, params={fmt_params(param.numel())}")
shown += 1
print(f"LoRA A/B params counted from model: {fmt_params(lora_param_count)}")
def main() -> None:
device, dtype = pick_device()
print("=== Runtime ===")
print(f"torch={torch.__version__}")
print(f"transformers={transformers.__version__}")
print(f"device={device}, dtype={dtype}")
print(f"adapter={ADAPTER_ID}")
print("\n=== Adapter config from Hugging Face ===")
peft_config = PeftConfig.from_pretrained(ADAPTER_ID)
base_model_id = peft_config.base_model_name_or_path
print(f"base_model_from_adapter_config={base_model_id}")
config_dict = peft_config.to_dict()
keep = {
"peft_type": config_dict.get("peft_type"),
"task_type": config_dict.get("task_type"),
"base_model_name_or_path": config_dict.get("base_model_name_or_path"),
"r": config_dict.get("r"),
"lora_alpha": config_dict.get("lora_alpha"),
"lora_dropout": config_dict.get("lora_dropout"),
"target_modules": sorted(list(config_dict.get("target_modules") or [])),
}
print(json.dumps(keep, indent=2, ensure_ascii=False))
print("\n=== Loading tokenizer and base model ===")
tokenizer = AutoTokenizer.from_pretrained(base_model_id)
base = AutoModelForCausalLM.from_pretrained(
base_model_id,
dtype=dtype,
low_cpu_mem_usage=True,
).to(device)
base.eval()
freeze(base)
total, trainable = count_params(base)
print(f"base params={fmt_params(total)}, trainable={fmt_params(trainable)}")
base_output = generate(base, tokenizer, device, "Base model only")
print("\n=== Loading PEFT LoRA adapter on top of the base model ===")
lora_model = PeftModel.from_pretrained(base, ADAPTER_ID)
lora_model.eval()
total, trainable = count_params(lora_model)
print(f"base+adapter params visible={fmt_params(total)}, trainable={fmt_params(trainable)}")
inspect_adapter(lora_model)
adapter_output = generate(lora_model, tokenizer, device, "Base model + active LoRA adapter")
print("\n=== Merging LoRA into base weights for deployment-style inference ===")
merged = lora_model.merge_and_unload()
merged.eval()
total, trainable = count_params(merged)
print(f"merged model params={fmt_params(total)}, trainable={fmt_params(trainable)}")
merged_output = generate(merged, tokenizer, device, "Merged LoRA model")
print("\n=== Summary ===")
print("Base output:")
print(base_output)
print("\nAdapter output:")
print(adapter_output)
print("\nMerged output:")
print(merged_output)
print("\nInterpretation:")
print("- Base model is the frozen general model.")
print("- PEFT model keeps LoRA A/B matrices as separate adapter modules.")
print("- merge_and_unload folds delta W = B @ A into the base weights for deployment.")
del merged
gc.collect()
if device == "mps":
torch.mps.empty_cache()
if __name__ == "__main__":
main()
运行:
cd ~/lora-peft-demo
.venv/bin/python run_peft_lora_demo.py
6. 输入是什么
这次输入是一个 git diff。
语义上它做了两件事:
1. createUser 不再直接使用 req.body,而是先用 userCreateSchema.parse(req.body) 做校验。
2. 新增测试:无效 email 的用户创建请求应该返回 400。
输入 prompt 要求:
Generate one Conventional Commit message for this git diff.
Return only the commit message.
也就是说,理想输出应该像:
fix(api): validate user payload before create
test(api): reject invalid user payloads
chore(api): add test for bad user input
关键不是哪一个 commit type 最完美,而是模型是否学会:
只输出一条 Conventional Commit message
不要解释
不要复述 diff
不要输出 Markdown
7. 实际运行输出
运行时环境:
=== Runtime ===
torch=2.12.1
transformers=5.13.0
device=mps, dtype=torch.float16
adapter=Elib27/qwen2.5-0.5b-commit-msg-lora
adapter 配置:
base_model_from_adapter_config=unsloth/Qwen2.5-0.5B-Instruct
{
"peft_type": "LORA",
"task_type": "CAUSAL_LM",
"base_model_name_or_path": "unsloth/Qwen2.5-0.5B-Instruct",
"r": 16,
"lora_alpha": 32,
"lora_dropout": 0,
"target_modules": [
"down_proj",
"gate_proj",
"k_proj",
"o_proj",
"q_proj",
"up_proj",
"v_proj"
]
}
基座模型参数:
base params=494.03M, trainable=0
base model 输出:
=== Base model only ===
The conventional commit message for this Git diff would be:
```plaintext
- const user = await db.user.create({ data: req.body })
+ const payload = userCreateSchema.parse(req.body
[generated 40 tokens in 0.79s]
这里 base model 明显没有遵守“只输出 commit message”。它开始解释,还把 diff 复述出来了。
加载 LoRA 后:
base+adapter params visible=502.83M, trainable=0
LoRA 模块样例:
base_model.model.model.layers.0.self_attn.q_proj.lora_A.default.weight: shape=(16, 896), params=14.34K
base_model.model.model.layers.0.self_attn.q_proj.lora_B.default.weight: shape=(896, 16), params=14.34K
base_model.model.model.layers.0.self_attn.k_proj.lora_A.default.weight: shape=(16, 896), params=14.34K
base_model.model.model.layers.0.self_attn.k_proj.lora_B.default.weight: shape=(128, 16), params=2.05K
base_model.model.model.layers.0.self_attn.v_proj.lora_A.default.weight: shape=(16, 896), params=14.34K
base_model.model.model.layers.0.self_attn.v_proj.lora_B.default.weight: shape=(128, 16), params=2.05K
base_model.model.model.layers.0.self_attn.o_proj.lora_A.default.weight: shape=(16, 896), params=14.34K
base_model.model.model.layers.0.self_attn.o_proj.lora_B.default.weight: shape=(896, 16), params=14.34K
base_model.model.model.layers.0.mlp.gate_proj.lora_A.default.weight: shape=(16, 896), params=14.34K
base_model.model.model.layers.0.mlp.gate_proj.lora_B.default.weight: shape=(4864, 16), params=77.82K
base_model.model.model.layers.0.mlp.up_proj.lora_A.default.weight: shape=(16, 896), params=14.34K
base_model.model.model.layers.0.mlp.up_proj.lora_B.default.weight: shape=(4864, 16), params=77.82K
base_model.model.model.layers.0.mlp.down_proj.lora_A.default.weight: shape=(16, 4864), params=77.82K
base_model.model.model.layers.0.mlp.down_proj.lora_B.default.weight: shape=(896, 16), params=14.34K
LoRA A/B params counted from model: 8.80M
active LoRA 输出:
=== Base model + active LoRA adapter ===
chore(api): add test for bad user input
[generated 11 tokens in 0.30s]
merge 后:
=== Merging LoRA into base weights for deployment-style inference ===
merged model params=494.03M, trainable=0
=== Merged LoRA model ===
chore(api): add test for bad user input
[generated 11 tokens in 0.19s]
总结输出:
Base output:
The conventional commit message for this Git diff would be:
```plaintext
- const user = await db.user.create({ data: req.body })
+ const payload = userCreateSchema.parse(req.body
Adapter output:
chore(api): add test for bad user input
Merged output:
chore(api): add test for bad user input
8. 这说明了什么
这次最明显的现象是:
base model:知道 Conventional Commit 是什么,但没有稳定遵守任务格式
base + LoRA:直接输出一条 commit message
merged LoRA:输出和 active adapter 一致
这就是 LoRA 的作用方式。
它不是换了一个模型,也不是往 prompt 里多塞了说明。它是在 base model 的若干线性层旁边挂上了低秩增量:
y = xW + xAB
本次 adapter 的 r=16,所以每个目标模块上都有一组低秩 A/B 矩阵。比如:
q_proj:
A: 16 × 896
B: 896 × 16
这表示:它不是只更新 16 个输出维度,而是通过 16 个低秩方向生成完整输出增量。
9. 为什么参数量显示是 502.83M?
base model 是:
494.03M
加载 adapter 后:
502.83M
差值约:
8.80M
这正好对应脚本统计出来的 LoRA A/B 参数量:
LoRA A/B params counted from model: 8.80M
也就是说:
base model 权重仍然是 494M
LoRA adapter 额外提供 8.8M 参数
8.8M 相对 494M,大约是 1.78%。这个比例已经能明显改变任务行为。
10. active adapter 和 merged model 有什么区别
active adapter 模式:
base model 保持不动
LoRA A/B 作为额外模块挂在旁边
forward 时算 xW + xAB
优点:
可以动态切换 adapter
可以同一个 base 配多个任务
可以热加载、卸载
适合开发和多租户服务
merged model 模式:
提前把 LoRA 增量合并进 W
W' = W + ΔW
推理时只跑普通模型
优点:
部署简单
推理路径更直接
不需要 PEFT wrapper
适合固定任务模型发布
这次结果里:
active adapter output = chore(api): add test for bad user input
merged output = chore(api): add test for bad user input
说明合并没有改变任务行为。
11. 这和“生产级”有什么关系
这条链路就是很多生产环境会采用的标准形态:
训练阶段:
base model 冻结
只训练 LoRA adapter
保存 adapter_config.json + adapter weights
推理阶段:
加载 base model
加载 adapter
或者 merge adapter 后部署
它的工程好处是:
模型基座可以复用
adapter 文件小
任务能力可以单独版本化
上线/回滚成本低
多个 adapter 可以共享同一个 base
这和前面 PAW / Parametric Skills 文章里的观点正好接上:
PAW:把函数编译成 adapter
Parametric Skills:把技能编译成 adapter
这次实跑:展示 adapter 在标准 PEFT 体系里如何加载、推理、合并
12. 本次运行里的几个细节
12.1 adapter 绑定 base model
脚本不是手写 base model,而是从 adapter config 读:
peft_config = PeftConfig.from_pretrained(ADAPTER_ID)
base_model_id = peft_config.base_model_name_or_path
这是好习惯。LoRA adapter 不是通用插件,它和 base model 的结构、层名、hidden size、projection shape 强绑定。
12.2 推理时 trainable=0
输出里:
base params=494.03M, trainable=0
base+adapter params visible=502.83M, trainable=0
merged model params=494.03M, trainable=0
这是因为我们在做 inference,不是在训练。adapter 参数虽然存在,但不需要梯度。
12.3 rank=16 体现在矩阵形状里
比如:
q_proj.lora_A: shape=(16, 896)
q_proj.lora_B: shape=(896, 16)
这里的 16 就是 rank。
它表示:
896 维输入
→ 压到 16 个调节信号
→ 再展开回 896 维输出增量
而不是只更新 16 个输出维度。
12.4 merged 后参数量回到 base size
active adapter 时:
base + LoRA 参数都在模型对象里
所以显示:
502.83M
merge 后:
LoRA 增量被折进 W
adapter 模块被卸载
所以又显示:
494.03M
这就是 merge_and_unload() 的含义。
13. 我对这次实跑的理解
这次 demo 很小,但把 LoRA 的生产链路串起来了:
adapter_config.json 说明 adapter 如何挂载
PeftModel.from_pretrained 负责把 LoRA 接到 base 上
lora_A / lora_B 是真实存在的低秩矩阵
active adapter 会改变输出行为
merge_and_unload 可以把增量折进 base 权重
最重要的是,效果不是抽象的。
同一个输入:
base model 复述 diff
LoRA model 输出 commit message
merged model 保持 LoRA 行为
这就是“可插拔能力补丁”的实际样子。
14. 最后一句话
LoRA 在论文里看起来是一个公式:
W' = W + AB
在生产里,它就是一套非常具体的工程流程:
下载 base
读取 adapter_config
加载 A/B 低秩矩阵
挂到 q/k/v/o 和 MLP projection
推理
必要时 merge 成部署模型
这次本地实跑说明:一个不到 base 2% 参数量的 adapter,确实可以把通用小模型稳定地拉到一个具体任务行为上。