Adapters are small files that cause big confusion
LoRA and QLoRA made fine-tuning cheap, but they moved the pain from training time to loading and serving time. A LoRA checkpoint is a handful of megabytes, not a multi-gigabyte model โ which is wonderful until you try to load it, merge it, and discover the outputs are identical to the base model, or that merging a 4-bit QLoRA adapter throws a dtype error you've never seen before. This guide is the practical map: what a PEFT adapter actually is, how to load one (or several), how to merge correctly, and the dtype pitfall that trips up almost everyone doing QLoRA.
If you'd rather have someone walk through your specific adapter on a call, our Hugging Face proxy job support team does exactly that. For the training side of the story, the PEFT fine-tuning job support page pairs with everything below.
What a PEFT adapter actually is
A LoRA adapter does not contain your model. It contains two small low-rank matrices (A and B) per targeted layer, plus a tiny adapter_config.json that records the base model name, the rank r, lora_alpha, and the target_modules they attach to. At inference the adapter computes ฮW = (alpha / r) ยท BยทA and adds it to the frozen base weight. That is the entire trick โ and it explains every downstream gotcha: the adapter is meaningless without the exact base model it was trained against.
When you save with PEFT you get something like:
adapter_model.safetensors # the A/B matrices, in safetensors format
adapter_config.json # base_model_name_or_path, r, alpha, target_modules
README.md # optional model card
Note what's not there: no model.safetensors with billions of parameters, no tokenizer necessarily. That is the point โ you version and ship kilobytes, not gigabytes.
Saving adapters vs saving a full model
There are two fundamentally different artifacts you can produce after training, and confusing them is the root of most "my adapter does nothing" tickets.
# 1) Save ONLY the adapter (recommended default) โ small, portable
trainer.model.save_pretrained("out/my-lora") # writes adapter_model.safetensors
# 2) Merge into the base and save a standalone full model โ large, self-contained
merged = trainer.model.merge_and_unload()
merged.save_pretrained("out/merged-model", safe_serialization=True)
Option 1 keeps the adapter separate and requires the base at load time. Option 2 produces a normal Transformers model with the adapter baked in โ no PEFT needed to serve it. Which you want depends on how you serve, which we'll get to.
Loading an adapter with PeftModel.from_pretrained
To use a saved adapter you load the base model first, then wrap it. The base you load must match the base_model_name_or_path in the adapter config โ same repo, same revision, ideally the same dtype family.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
base_id = "meta-llama/Llama-3.1-8B"
base = AutoModelForCausalLM.from_pretrained(
base_id, torch_dtype=torch.bfloat16, device_map="auto"
)
tok = AutoTokenizer.from_pretrained(base_id)
model = PeftModel.from_pretrained(base, "out/my-lora") # attaches A/B matrices
model.eval()
At this point the adapter is active but not merged โ the forward pass adds ฮW on the fly. This is perfect for inference when you want to keep the base shared across many adapters. The interview-style reasoning behind "why load base then wrap" comes up often; see our Hugging Face proxy interview support if you're prepping for that.
Loading and switching multiple adapters
One base can host several adapters โ a support-tone adapter, a summarization adapter, a JSON-mode adapter โ and you switch between them without reloading gigabytes. Give each a name when you load it:
model = PeftModel.from_pretrained(base, "out/support-lora", adapter_name="support")
model.load_adapter("out/summarize-lora", adapter_name="summarize")
model.set_adapter("support") # route the next calls through the support adapter
# ... generate ...
model.set_adapter("summarize") # hot-swap, no base reload
You can also combine adapters with weighted merges using add_weighted_adapter for ensembling, or disable them entirely with the model.disable_adapter() context manager to get raw base outputs โ a handy A/B test when you suspect an adapter is a no-op.
Merging with merge_and_unload()
Merging folds ฮW into the base weights and returns a plain model with no PEFT wrapper and no runtime add overhead. This is what you do before exporting for high-throughput serving.
from peft import PeftModel
merged = model.merge_and_unload() # W := W + (alpha/r)ยทBยทA
merged.save_pretrained("out/merged", safe_serialization=True)
tok.save_pretrained("out/merged")
After merge_and_unload() the object is a standard AutoModelForCausalLM โ it serializes to model.safetensors like any other model and can be loaded without PEFT installed. There is a subtlety, though, and it is the number-one QLoRA support ticket.
The dtype pitfall: you cannot merge into a 4-bit base
Here is the trap. You trained with QLoRA, so your base is loaded in 4-bit NF4. You call merge_and_unload() and either get a dtype error, a warning about merging into a quantized module, or โ worse โ a silent, low-quality merge. The reason is fundamental: LoRA math is fp16/bf16, but the base weights are packed 4-bit integers. You cannot add a bf16 ฮW into a 4-bit quantized weight and expect a faithful result. bitsandbytes quantized layers are not designed to absorb a merge cleanly.
The correct procedure is to dequantize by reloading the base in fp16/bf16, re-attach the adapter, then merge:
import torch
from transformers import AutoModelForCausalLM
from peft import PeftModel
# WRONG: base is still 4-bit here
# base_4bit = AutoModelForCausalLM.from_pretrained(base_id, quantization_config=bnb_config)
# PeftModel.from_pretrained(base_4bit, adapter_dir).merge_and_unload() # dtype pitfall
# RIGHT: reload base WITHOUT quantization, in bf16, then merge
base_fp16 = AutoModelForCausalLM.from_pretrained(
base_id,
torch_dtype=torch.bfloat16, # NOT load_in_4bit
device_map="auto",
)
model = PeftModel.from_pretrained(base_fp16, "out/my-qlora")
merged = model.merge_and_unload() # now A/B and W share bf16 โ clean merge
merged.save_pretrained("out/merged-bf16", safe_serialization=True)
The adapter itself was trained against the 4-bit base, but the LoRA A/B matrices are stored in higher precision, so merging into the bf16 base reproduces the trained behavior faithfully. The merged model is a bf16 model; if you want it quantized for serving, quantize after merging (GPTQ/AWQ), not before. Details on that post-merge quantization path are in our Hugging Face quantization job support guide.
Choosing target_modules
Which layers you adapt affects both quality and whether a merge even changes anything meaningful. The common choices:
target_modules="all-linear"โ adapts every linear layer (attention projections + MLP). Highest quality, most parameters. A good default in 2026.- Attention only โ
["q_proj", "k_proj", "v_proj", "o_proj"]. Lighter, often sufficient for style/format tasks. - Explicit list โ needed for architectures where module names differ (e.g. fused
c_attn, or MoE experts). Inspect withprint(model)and match names exactly.
A frequent bug: the adapter's target_modules names don't exist in the base you loaded (wrong architecture or a renamed layer), so PEFT attaches nothing and your adapter is a silent no-op. Always confirm the config's targets match the base's actual module names. The training-side selection of rank, alpha, and targets is covered in our QLoRA fine-tuning job support resource.
"My adapter isn't affecting outputs"
When a loaded adapter appears to do nothing, it is almost always one of these:
- Wrong base model or revision. The adapter was trained on
Llama-3.1-8Bbut you loadedLlama-3.1-8B-Instruct. The weights don't line up, soฮWlands on the wrong values. - Adapter never attached.
target_modulesdidn't match any layer names โ check for the "no trainable/attached modules" condition. - You saved the base, not the adapter. If you called
save_pretrainedon an unwrapped model, or merged before saving expecting a small file, you may be loading the wrong artifact. - Adapter disabled. A stray
disable_adapter()context, or you forgotset_adapter()after loading multiple. - Chat template mismatch. The adapter learned a specific prompt format; feeding raw text bypasses the behavior it was trained to trigger.
A fast diagnostic: generate with the adapter, then inside with model.disable_adapter(): generate again on the same prompt. If the two outputs are identical, the adapter is not engaged โ work down the list above.
Serving: merged model vs adapter on the fly
Two valid strategies, chosen by your traffic pattern:
- Merge, then serve the standalone model. Best for a single fine-tune in production. No PEFT at runtime, no per-request add overhead, and engines like vLLM/TGI/SGLang consume it as an ordinary Transformers v5 model. Quantize after merging if you need it.
- Keep the base loaded and apply adapters dynamically. Best when you serve many fine-tunes of the same base โ vLLM's multi-LoRA support and TGI can hot-swap adapters per request, sharing one copy of the base in VRAM. Far cheaper than N merged models.
The rule of thumb: one adapter and latency-sensitive โ merge; many adapters and memory-sensitive โ dynamic. The serving mechanics tie into our Transformers inference job support and, for throughput tuning, GPU optimization job support.
Pushing adapters to the Hub
Because adapters are tiny, the Hub is an excellent place to version them. Using the modern hf CLI (with Xet storage) and the push_to_hub API, publishing is a one-liner. Set the base model in the card so consumers know exactly what to load underneath.
from huggingface_hub import login
login() # or: hf auth login
model.push_to_hub("your-org/my-lora-adapter") # pushes adapter_model.safetensors + config
tok.push_to_hub("your-org/my-lora-adapter")
# Anyone can then load it against the recorded base:
from peft import PeftModel
reloaded = PeftModel.from_pretrained(base, "your-org/my-lora-adapter")
Everything ships in safetensors format, and Xet-backed uploads deduplicate blocks so re-pushing a lightly-changed adapter transfers only the delta. Hub workflows, private repos, and revisions are covered in our Hugging Face Hub job support page.
Get it right the first time
Adapters reward precision: match the base, confirm the targets attach, dequantize before merging a QLoRA adapter, and pick a serving strategy that fits your traffic. Miss one of those and you'll ship a model that quietly ignores everything you trained into it. If an adapter is misbehaving โ merging with dtype errors, or generating like the base โ bring it to our Hugging Face proxy job support team and we'll get it loading, merging, and serving correctly on your stack.