本地跑通一个真实 PEFT LoRA:从环境、代码到 base/adapter/merged 输出对比

完整记录一次生产常见 LoRA 推理链路:Apple Silicon 本地环境、uv 隔离环境、Transformers + PEFT 加载 Qwen2.5-0.5B 基座和 commit message LoRA adapter,比较 base、active adapter、merged model 的输出。

前面几篇已经把 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,确实可以把通用小模型稳定地拉到一个具体任务行为上。

参考