All posts

Fine-Tuning & Serving LLMs: LoRA, quantization, and vLLM

  • LLM
  • Fine-Tuning
  • LoRA
  • Quantization

So far in this series we’ve changed what the model can seeretrieval gave it documents, agents gave it tools. This post changes the model itself. When you need it to reliably follow a format, speak in a particular voice, or know a domain cold, you fine-tune: keep training a pretrained model on your own examples.

The catch is cost. A modern model has billions of parameters; updating all of them needs a cluster and a small fortune. The techniques that made fine-tuning accessible to everyone — LoRA and quantization — are clever ways around exactly that cost, and both are simple enough to understand from the inside. Let’s do that, then serve the result.

First: do you even need to fine-tune?

Reach for the cheapest tool that works, in this order:

  1. Prompting — just ask well. Zero training.
  2. RAG — inject the right knowledge at query time (the first post). Best when the problem is missing facts.
  3. Fine-tuning — change the model’s behavior. Best when the problem is form, style, or a skill the model won’t reliably follow no matter how you prompt.

Fine-tuning teaches behavior, not facts — a common trap is trying to fine-tune knowledge in when you should have retrieved it. For “answer in my voice, always with citations,” fine-tuning earns its keep. For “know what my latest post said,” use RAG.

LoRA: don’t retrain the matrix, learn a small correction

Fine-tuning updates weight matrices. A single one might be d×dd \times d with d=4096d = 4096 — that’s ~16.8M numbers, and there are hundreds of them. Updating all of it (full fine-tuning) is the expensive part.

LoRA (Low-Rank Adaptation) makes a bet that pays off: the change you need to make during fine-tuning is simple — it has low “rank” — even though the original matrix is huge. So instead of learning a full d×dd \times d update, freeze the original weight WW and learn the update as a product of two skinny matrices, BB (d×rd \times r) and AA (r×dr \times d), with the rank rr tiny (often 8 or 16):

Weffective=Wfrozen+αrBAW_{\text{effective}} = W_{\text{frozen}} + \frac{\alpha}{r}\, B A

Only BB and AA are trained — 2dr2 d r numbers instead of d2d^2. Slide the rank and matrix size and watch the trainable-parameter count collapse:

The hatched square is W, frozen. Only the two slim matrices B and A are trained, and their product has the same d×d shape as W so it slots right in. Rank r is the whole knob: bigger r = more capacity but more parameters.

This isn’t hand-waving — I built it in NumPy to be sure. Freeze a random matrix, initialize the adapter as a no-op (B=0B = 0), and train only BB and AA by gradient descent to match a target correction. The loss drops to ~103210^{-32}: a rank-rr adapter perfectly recovers a rank-rr update, while training a fraction of the parameters.

# the entire LoRA training step, from scratch (src/lora_demo.py)
err = B @ A - target        # current adapter output vs the correction we want
gB  = err @ A.T             # gradient wrt B
gA  = B.T @ err             # gradient wrt A
B  -= lr * gB               # W stays frozen; only the adapter moves
A  -= lr * gA

The framework version is the same idea on a real model: attach adapters to the attention and MLP projection matrices and train with Unsloth + TRL. The r and alpha in that config are exactly the knobs above.

model = FastLanguageModel.get_peft_model(
    model, r=16, lora_alpha=16,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
)

Because only the adapters change, the trained artifact is a few megabytes, not gigabytes — you can keep dozens of task-specific adapters and swap them over one frozen base model.

Quantization: store the weights in fewer bits

LoRA cuts what you train. Quantization cuts what you store. A weight kept as a 32-bit float takes 4 bytes; store it in 8 bits (1 byte) or 4 bits (½ byte) and the model’s memory footprint shrinks proportionally — at the cost of a little numerical precision. Since inference is mostly memory-bound, this is often the difference between “fits on your GPU” and “doesn’t.”

Pick a model size and a precision and see what fits:

Memory ≈ parameters × bytes-per-parameter. A 7B model is ~28 GB at FP32 but only ~3.5 GB at INT4 — which is how a 7B model runs on a laptop. Note this counts weights only; the KV-cache and activations need more at run time.

QLoRA combines both tricks: load the frozen base model in 4-bit (quantized) and train small LoRA adapters on top in higher precision. You get low memory and low trainable-parameter count at once — which is why fine-tuning a 7B model on a single free-tier GPU is now routine. That’s the load_in_4bit=True in the training script.

Serving: vLLM

A fine-tuned model that only runs in a notebook isn’t useful. vLLM is the standard high-throughput server. Two ideas make it fast, and both should feel familiar from the GPT series:

  • PagedAttention — it manages the KV-cache like an operating system manages memory (in pages), so it wastes far less and serves more requests at once.
  • Continuous batching — new requests slot into a running batch instead of waiting for it to finish, keeping the GPU busy.

vLLM can serve the base model and apply your LoRA adapter per request, and exposes an OpenAI-compatible API — so you point the Phase-1 RAG generator at it and the whole pipeline now answers in your fine-tuned voice:

vllm serve unsloth/Llama-3.2-1B-Instruct \
    --enable-lora --lora-modules blog=outputs/blog-lora

From scratch → framework

LayerWe built (from scratch)Framework / tool
Low-rank adapterNumPy B@A + gradient stepPEFT / Unsloth LoRA
Training datatemplated Q&A from the blogyour labeled set, or LLM-generated pairs
Training loopmanual gradient descentTRL SFTTrainer
Quantizationround a weight to fewer bitsbitsandbytes (4-bit), GGUF
ServingvLLM (PagedAttention, batching)

Key takeaways

  • Fine-tune for behavior, retrieve for facts. Try prompting → RAG → fine-tuning, in that order.
  • LoRA freezes the big matrix and learns a low-rank correction2dr2dr params instead of d2d^2, often ~250× fewer. The adapter is a few MB and swappable.
  • Quantization stores weights in fewer bits; memory ≈ params × bytes, so INT4 shrinks a 7B model from ~28 GB to ~3.5 GB. QLoRA stacks both.
  • vLLM serves it efficiently with paged KV-cache and continuous batching, and can apply your adapter over a shared base model.

Go deeper

Last in the series: knowing whether any of this actually works — evaluation and observability — and shipping the whole thing as a live demo.


Comments