Merging, Exporting and Quantising a Fine-Tuned Model
By the end of this lesson you will be able to turn an adapter directory into something every engine in Level 2 can load, choose between merging and attaching at serving time, quantise the result without corrupting it, prove that the exported model still behaves like the one you trained, and put it somewhere that is not public.
This is the step that separates an experiment from a tool. The training run leaves you tens of
megabytes of adapter_model.safetensors, which llama.cpp cannot open, MLX does not recognise and
Ollama has never heard of. Everything on this page is about closing that gap.
The four destinations
Section titled “The four destinations”From an adapter to something that serves
- Adapter directoryadapter_config.json naming the base model, adapter_model.safetensors, and the tokeniser you remembered to save.
- Attach, or merge?Attach to serve several fine-tunes of one base, or to fall back instantly. Merge to get one artefact you can convert and quantise.
- Merge into the base at BF16On the CPU, one copy of the weights, merge_and_unload, save. Never into a 4-bit base.
- Convert for your engineGGUF for llama.cpp and everything built on it, MLX for the Mac, safetensors or an AWQ or FP8 checkpoint for vLLM.
- Prove itSame prompts, same settings, unmerged model versus exported model. A conversion that changed the behaviour is a conversion that failed.
- Publish privately, and record itA private Hub repository with a card naming the base model, its licence, the dataset and the run id.
Merging, and when not to
Section titled “Merging, and when not to”PEFT’s conceptual guide states why merging works: “during training, the smaller weight matrices … are separate. But once training is complete, the weights can actually be merged into a new weight matrix that is identical.” The scaled product of the two adapter matrices is added to the frozen weight, and the result is an ordinary model of exactly the original shape and size, with no adapter in it and no inference overhead.
Two functions do it. merge_adapter() merges but keeps the adapter weights, so unmerge_adapter()
can undo it, which is useful when you are comparing several adapters in one process.
merge_and_unload() is the one you want for export: it “doesn’t keep the adapter weights in memory”
and produces a standalone model. PEFT adds a warning that has cost many people an hour: “It is
important to assign the returned model to a variable and use it, merge_and_unload() is not an
in-place operation.”
Fragment — not complete on its own
base = AutoModelForCausalLM.from_pretrained(base_id, dtype=torch.bfloat16, device_map="cpu")model = PeftModel.from_pretrained(base, adapter_dir)model = model.merge_and_unload() # not in place: the return value is the merged modelmodel.save_pretrained(merged_dir)AutoTokenizer.from_pretrained(adapter_dir).save_pretrained(merged_dir)Three rules about that snippet, and each one is a fault in this part’s challenge.
Merge into the same base you trained against. The adapter records base_model_name_or_path in
adapter_config.json for exactly this reason. An adapter trained on an instruct checkpoint and
merged into the base checkpoint of the same family produces a model that loads, runs and is subtly
wrong everywhere.
Merge at BF16, not at 4 bits. Adding a low-rank update to weights that have been rounded to four
bits applies your update to the wrong numbers and then rounds again. PEFT is explicit about the
general hazard for the quantised backends it supports, warning for torchao that “merging only works
correctly with LoRA and with quant_type = 'int8_weight_only'” and that other combinations “will
likely result in an error, and even it doesn’t, the results will still be incorrect.” The safe order
is always merge first, quantise second.
Merge on the CPU. It is weight arithmetic rather than a forward pass, so it needs no accelerator and only one copy of the weights in memory. On a machine that could barely hold the training run, this is what lets the merge happen at all.
GGUF, for llama.cpp and everything built on it
Section titled “GGUF, for llama.cpp and everything built on it”Two scripts in a llama.cpp checkout do the conversion, and which one you want depends on whether you merged.
A merged model goes through convert_hf_to_gguf.py, which takes the model directory as a
positional argument. --outfile is documented as “path to write to; default: based on input”, and
--outtype chooses the precision with the documented choices f32, f16, bf16, q8_0, tq1_0,
tq2_0 and auto, where auto is “the highest-fidelity 16-bit float type”. Convert at bf16 and
quantise afterwards; converting straight to a small type throws away information the quantiser could
have used.
RunnableAll tracks
python3 ~/llama.cpp/convert_hf_to_gguf.py ~/models/my-finetune-merged \ --outfile ~/models/my-finetune-bf16.gguf \ --outtype bf16
llama-quantize \ ~/models/my-finetune-bf16.gguf \ ~/models/my-finetune-Q4_K_M.gguf \ Q4_K_MAn unmerged adapter goes through convert_lora_to_gguf.py, whose docstring describes it as
converting “a Hugging Face PEFT LoRA adapter to a GGUF file”. Its positional argument is the
directory “containing Hugging Face PEFT LoRA config (adapter_model.json) and weights”, and it takes
--base, documented as the “directory containing Hugging Face model config files (config.json,
tokenizer.json) for the base model that the adapter is based on — only config is needed, actual
model weights are not required.” If you cannot supply the base locally, --base-model-id takes the
Hub identifier instead. The result is a small GGUF that llama-server --lora attaches to a base
GGUF at load time.
RunnableAll tracks
python3 ~/llama.cpp/convert_lora_to_gguf.py ~/runs/my-finetune \ --base ~/models/Qwen3-4B \ --outfile ~/models/my-finetune-lora.gguf \ --outtype f16
llama-server \ --model ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf \ --lora ~/models/my-finetune-lora.gguf \ --alias local/finetune \ --jinja \ --ctx-size 8192 \ --host 127.0.0.1 \ --port 8080MLX, for the Mac
Section titled “MLX, for the Mac”Track M does not merge with PEFT; the equivalent is mlx_lm.fuse, whose arguments are --model
(“The path to the local model directory or Hugging Face repo”), --adapter-path (“Path to the
trained adapter weights and config”, defaulting to adapters) and --save-path (“The path to save
the fused model”, defaulting to fused_model).
RunnableTrack M · Apple silicon
mlx_lm.fuse \ --model mlx-community/Qwen3-4B-bf16 \ --adapter-path ~/runs/my-finetune-adapters \ --save-path ~/models/my-finetune-fusedThree more options matter. --upload-repo pushes the fused model to a Hub repository.
--dequantize produces a dequantised model, which is what you want when the base you trained against
was itself quantised and you now need full precision for a conversion. And --export-gguf with
--gguf-path writes GGUF directly, which the LoRA documentation describes as supporting a limited
set of architectures in fp16. Because that support is narrow, the dependable Mac route to llama.cpp
is to fuse into safetensors and then run llama.cpp’s own convert_hf_to_gguf.py over the result,
which is the path this part’s lab takes.
vLLM: adapters, or a quantised checkpoint
Section titled “vLLM: adapters, or a quantised checkpoint”vLLM can serve your adapter without merging anything. Its LoRA page documents starting the server
with --enable-lora and registering adapters with --lora-modules name=path, in either the simple
name=path form or a JSON form that also names the base model. Two limits are set at start-up:
--max-lora-rank, which the documentation says to “set … to the maximum rank among all LoRA adapters
you plan to use”, and --max-loras, which caps how many are resident at once. There is also a
runtime path, enabled by the environment variable VLLM_ALLOW_RUNTIME_LORA_UPDATING, which exposes
/v1/load_lora_adapter and /v1/unload_lora_adapter; the documentation attaches a warning to it
that is worth repeating in full: “This feature comes with security risks. It should not be used in
production unless it is an isolated, fully trusted environment.”
For a merged model, vLLM will serve the BF16 safetensors directly, and two quantised formats are documented if you want the memory back.
FP8 is the simpler one and needs recent silicon: “FP8 computation is supported on NVIDIA GPUs with compute capability >= 8.9 (Ada Lovelace, Hopper, Blackwell)”, with older cards landing on a weight-only path instead. The recipe uses llm-compressor, which its own documentation describes as “an easy-to-use library for optimizing large language models for deployment with vLLM”:
Fragment — not complete on its own
recipe = QuantizationModifier( targets="Linear", scheme="FP8_DYNAMIC", ignore=["lm_head"],)vLLM documents this as “Static, per-channel quantization on the weights” with “Dynamic, per-token
quantization on the activations”, needing no calibration data, and reports “a 2x reduction in model
memory requirements and up to a 1.6x improvement in throughput with minimal impact on accuracy”.
Those are figures published by the project, not measured here. There is also an online form,
--quantization fp8 at start-up, which quantises “down to FP8_E4M3 precision with a per-tensor
scale” without a preprocessing step, at the cost of doing the scaling during inference.
AWQ is the four-bit option, and the tooling has moved. vLLM’s AWQ page now states that “The
AutoAWQ library is deprecated” and points at llm-compressor for the same job; a produced checkpoint
is then served with --quantization auto_awq. If you meet a tutorial using AutoAWQ directly, that
is the sentence to remember.
Proving the export
Section titled “Proving the export”An export that changed the model is a bug that will reach a user as a mysteriously worse answer three weeks later. Check it while the training run is still fresh.
The test is a comparison, not an inspection. Take ten prompts, half from your training distribution and half from your Part 10 task set. Run them against the unmerged model in Python, with temperature zero and a fixed seed. Run the same ten against the exported model through its engine, at the same settings. Compare.
What you should see depends on what you did. A merge at the same precision should be essentially identical, allowing for arithmetic that is not bit-exact across implementations; large differences mean the wrong base or the wrong precision. A quantisation will differ on some outputs, because rounding the weights changes some token choices, and the honest question is not whether any answer changed but whether the score changed: run the Part 10 harness against both and compare, exactly as you compared the fine-tune against the base.
RunnableAll tracks
llama-cli \ --model ~/models/my-finetune-Q4_K_M.gguf \ --prompt "Summarise this ticket in the house format." \ --predict 128 \ --temp 0 \ --seed 0Publishing, privately
Section titled “Publishing, privately”A fine-tune is a derivative work of a base model and a dataset, and the dataset may contain things
that should not leave the machine. The Hub’s default for a new repository is a choice you make, and
its documentation is clear about what private means: setting visibility to private will “Ensure your
repo does not show up in other users’ search results”, make other users who visit the URL receive a
404 - Repo not found error, and stop them cloning it. Visibility is set at creation and can be
changed later “in the Settings tab”.
The workflow is: create the repository as private in the web interface, log in from the terminal, and upload.
RunnableAll tracks
hf auth login
hf upload your-namespace/my-finetune-adapter ~/runs/my-finetune . \ --commit-message "LoRA adapter, Qwen3-4B base, run 20260909T101500Z"The CLI guide documents the shape as hf upload [repo_id] [local_path] [path_in_repo] and notes
that “If the repo doesn’t exist yet, it will be created automatically” — which is exactly why you
create it in the web interface first, with the visibility you intended, rather than letting the
upload choose.
Write a short model card in the repository. Four facts make it useful a year later: the base model
and its licence, the training method and the settings, the dataset with its checksum and its
provenance, and the run identifier from labbook.md. Publishing an adapter without naming its base
model produces an artefact nobody can use, including you.
Validate each transformation independently
Section titled “Validate each transformation independently”Treat export as a chain: exact base plus adapter, merged weights, converted format, quantised format, served alias. Preserve an identity record at each boundary. If the final service regresses, this lets you locate the first transformation that changed behaviour.
Compare adapter-attached and merged models using the same tokeniser, template, evaluation mode and dtype. For numeric comparisons, use a justified tolerance; floating-point operation order can change values without making a merge invalid. Exact sampled text alone is a brittle equality test. Then evaluate the converted and quantised variants with the same task suite, reporting any quality change separately from memory and latency.
Check that special-token settings and any added vocabulary survived. The adapter may carry tokeniser or configuration files that must accompany the weights. Do not merge into a convenient sibling checkpoint merely because its architecture matches. Keep the original adapter, exact base identity, conversion command and raw evaluation outputs even after deleting a large intermediate file. Those are the inputs required to reproduce or repair the deployed artefact.
Merging adds the scaled product of the two adapter matrices back into the frozen weight and produces
an ordinary model; merge_and_unload() does it and is not an in-place operation, so its return value
matters. Merge into the same base you trained against, at BF16, on the CPU, and quantise afterwards
rather than merging into something already quantised. You do not have to merge: llama.cpp attaches a
converted adapter with --lora, and vLLM serves one with --enable-lora and --lora-modules,
sized by --max-lora-rank. A merged model becomes GGUF through convert_hf_to_gguf.py at bf16
followed by llama-quantize, or an adapter becomes a GGUF adapter through convert_lora_to_gguf.py
with --base. On the Mac, mlx_lm.fuse produces the fused model, and the dependable route onward to
llama.cpp is llama.cpp’s own converter rather than the narrow --export-gguf path. For vLLM, FP8
through llm-compressor needs compute capability 8.9 or newer, and AWQ’s old tooling is deprecated in
favour of the same library. Prove the export with the same prompts at the same settings, expect a
merge to match and a quantisation to differ a little, and settle the question with the Part 10
harness. Then publish to a repository you made private first, with a card naming the base, the
licence, the dataset and the run.
Check your understanding
Sources for this lesson
13 verified · checked 2026-09-09
- 01PEFT — LoRA developer guide§ Merging adapters; merge_adapter; merge_and_unloadhuggingface.co/docs/peft/developer_guides/lora2026-09-09
- 02PEFT — Quantization§ torchao caveats; LoftQhuggingface.co/docs/peft/main/en/developer_guides/quantization2026-09-09
- 03llama.cpp — convert_hf_to_gguf.py§ Command-line argumentsraw.githubusercontent.com/ggml-org/llama.cpp/master/convert_hf_to_gguf.py2026-09-09
- 04llama.cpp — convert_lora_to_gguf.py§ Command-line argumentsraw.githubusercontent.com/ggml-org/llama.cpp/master/convert_lora_to_gguf.py2026-09-09
- 05llama.cpp — llama-server README§ LoRA options; chat template optionsgithub.com/ggml-org/llama.cpp/blob/master/tools/server/README.md2026-09-09
- 06mlx-lm — fuse§ Command-line argumentsraw.githubusercontent.com/ml-explore/mlx-lm/main/mlx_lm/fuse.py2026-09-09
- 07mlx-lm — LoRA documentation§ Fusegithub.com/ml-explore/mlx-lm/blob/main/mlx_lm/LORA.md2026-09-09
- 08vLLM — LoRA adapters§ enable-lora; lora-modules; max-lora-rank; runtime updatingdocs.vllm.ai/en/latest/features/lora.html2026-09-09
- 09vLLM — AutoAWQ quantization§ Deprecation; quant_config; servingdocs.vllm.ai/en/latest/features/quantization/auto_awq.html2026-09-09
- 10vLLM — FP8 quantization with llm-compressor§ Hardware requirements; recipe; online dynamic quantisationdocs.vllm.ai/en/latest/features/quantization/llm_compressor/fp82026-09-09
- 11llm-compressor documentation§ Overview; supported formatsdocs.vllm.ai/projects/llm-compressor/en/latest2026-09-09
- 12Hugging Face Hub — Repository settings§ Repository visibilityhuggingface.co/docs/hub/repositories-settings2026-09-09
- 13huggingface_hub — Command Line Interface§ hf auth login; hf uploadhuggingface.co/docs/huggingface_hub/guides/cli2026-09-09
Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.