Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced

LLM Fine-Tuning in Practice

When to fine-tune vs RAG vs prompt. Full LoRA fine-tuning walkthrough on a real dataset using HuggingFace Transformers and PEFT.

50–65 min March 2026
Before any code — the decision you must make first

Fine-tuning is not always the answer. Most production LLM applications are better served by prompt engineering or RAG. Fine-tune only when you have labelled data, a specific behaviour to change, and evidence that prompting cannot get you there.

This is the question every ML engineer at a startup faces when building an LLM-powered feature: should we fine-tune a model or can we get there with prompting and retrieval? Fine-tuning is expensive — data collection, training compute, evaluation, deployment — and it is irreversible. A fine-tuned model that learned the wrong behaviour is worse than a base model.

The answer depends on what you are trying to change. If the base model already knows how to do the task but needs domain-specific facts — use RAG. If it knows how to do the task but needs a specific output format or tone — use prompting. If it genuinely cannot do the task reliably even with perfect prompts and all context in window — then fine-tune. The bar for fine-tuning should be high.

🧠 Analogy — read this first

Hiring an expert consultant vs training a new employee. A consultant (prompting) is fast, flexible, and immediately available — give them the context they need and they will do good work. A trained employee (fine-tuned model) internalises your company's way of doing things, does not need context each time, is faster at inference, but costs significant upfront investment. You hire a consultant first, hire full-time only when the work is consistent, high-volume, and the consultant approach is insufficient.

Stripe's payment dispute classifier: prompting GPT-4 worked at 80% accuracy. Fine-tuned LLaMA-3-8B reached 94% at 10× lower cost per query. The volume justified the training investment. Volume and consistency are the two conditions that make fine-tuning worth it.

🎯 Pro Tip
All code in this module uses HuggingFace Transformers, PEFT, and TRL — the standard open-source stack used by ML teams everywhere. Install: pip install transformers peft trl accelerate bitsandbytes datasets. A free Google Colab T4 GPU (16GB) is enough to fine-tune a 7B model with QLoRA.
The decision

Prompt vs RAG vs fine-tune — a decision framework with real examples

When to use each approach — decision criteria
Prompt Engineering
USE WHEN

Task is well within LLM capability. Output format can be specified. Context fits in window. Volume is low or cost is not critical.

DO NOT USE WHEN

Response is inconsistent across runs. Specific terminology or style must be enforced. Task requires knowledge the LLM does not have.

Example: Stripe: summarise a support ticket into 3 bullet points. GPT-4 with a good prompt achieves 90%+ quality consistently.
Cost: API cost per call. No upfront investment.Time: 1 day to get right prompt. Immediately usable.
RAG
USE WHEN

Task requires specific facts, documents, or knowledge not in LLM training. Content changes frequently. Sources must be cited.

DO NOT USE WHEN

Task is about style/tone/format not knowledge. Data is too sensitive to store in vector DB. Latency budget is very tight.

Example: DoorDash: answer questions about restaurant menus, hours, and offers. Menu data changes daily — RAG retrieves current data per query.
Cost: Vector DB hosting + embedding API. Moderate ongoing cost.Time: 1–2 weeks to build pipeline. Data pipeline ongoing.
LoRA Fine-Tuning
USE WHEN

Specific output style or tone that prompting cannot reliably achieve. Domain-specific classification with labelled data. High-volume inference where API cost is prohibitive.

DO NOT USE WHEN

Insufficient labelled data (<500 examples). Task changes frequently — model becomes stale. No GPU for training.

Example: Amazon: classify product reviews into 12 specific dispute categories with exact label names matching their CRM. Needs consistent exact-match labels prompting cannot reliably produce.
Cost: Training compute (one-time) + inference (self-hosted, cheap).Time: 2–4 weeks: data prep + training + evaluation + deployment.
Full Fine-Tuning
USE WHEN

Fundamental capability change needed. Domain so specialised base model is nearly random (medical, legal, low-resource languages). Building a foundation model.

DO NOT USE WHEN

Almost always — LoRA achieves 95% of full fine-tuning quality at 10% of cost. Only justified for foundational models.

Example: Bloomberg: fine-tuning a base LLM on decades of financial documents and market data to build BloombergGPT — a genuine foundation model for financial language.
Cost: Very high — multi-GPU cluster, weeks of training.Time: 2–6 months including infrastructure, training, and evaluation.
The most important step

Data preparation — the format that makes or breaks fine-tuning

The quality of fine-tuning data matters far more than the choice of model or hyperparameters. 500 high-quality, diverse examples consistently outperform 5,000 mediocre examples. Every example must follow the exact same chat template the base model was trained with. Mismatched templates are the most common silent failure — the model trains without error but produces garbage at inference.

Chat templates — every model has its own format
LLaMA-3 / Meta format:
<|begin_of_text|><|start_header_id|>system<|end_header_id|> You are a helpful assistant.<|eot_id|> <|start_header_id|>user<|end_header_id|> How does ACH work?<|eot_id|> <|start_header_id|>assistant<|end_header_id|> [response]<|eot_id|>
Mistral / Alpaca format:
[INST] <<SYS>> You are a helpful assistant. <</SYS>> How does ACH work? [/INST] [response] </s>
Phi-3 / ChatML format:
<|system|> You are a helpful assistant.<|end|> <|user|> How does ACH work?<|end|> <|assistant|> [response]<|end|>
python
from datasets import Dataset
from transformers import AutoTokenizer
import json

# ── Task: Stripe payment dispute classifier ─────────────────────────
# 12 dispute categories used in their CRM system
DISPUTE_CATEGORIES = [
    'payment_failed_bank_error',
    'payment_failed_insufficient_funds',
    'payment_failed_technical',
    'duplicate_charge',
    'wrong_amount_charged',
    'refund_not_received',
    'refund_delayed',
    'subscription_cancelled_still_charged',
    'merchant_dispute',
    'fraud_unauthorised',
    'product_not_delivered',
    'other',
]

# ── Build training examples in chat format ────────────────────────────
SYSTEM_PROMPT = """You are a payment dispute classifier for Stripe.
Classify the customer complaint into exactly one category.
Respond with only the category name, nothing else."""

def make_example(complaint: str, category: str) -> dict:
    """Build a single training example in messages format."""
    return {
        'messages': [
            {'role': 'system',    'content': SYSTEM_PROMPT},
            {'role': 'user',      'content': f"Complaint: {complaint}"},
            {'role': 'assistant', 'content': category},
        ]
    }

# Sample training data
raw_examples = [
    ("My payment of $2500 failed but money was deducted from account",
     'payment_failed_technical'),
    ("I was charged twice for the same order on DoorDash",
     'duplicate_charge'),
    ("Cancelled my subscription but still got charged this month",
     'subscription_cancelled_still_charged'),
    ("ACH payment failed three times, showing insufficient funds but I have balance",
     'payment_failed_insufficient_funds'),
    ("Refund was supposed to arrive 7 days ago, still not credited",
     'refund_delayed'),
    ("Someone made a payment from my account without my knowledge",
     'fraud_unauthorised'),
    ("Merchant charged $4999 but my order was only $2499",
     'wrong_amount_charged'),
    ("Payment shows successful but seller says not received",
     'merchant_dispute'),
] * 50   # repeat to simulate larger dataset

examples = [make_example(c, cat) for c, cat in raw_examples]
dataset  = Dataset.from_list(examples)
split    = dataset.train_test_split(test_size=0.1, seed=42)
print(f"Train: {len(split['train'])}  Val: {len(split['test'])}")
print(f"
Sample example:")
print(json.dumps(split['train'][0]['messages'], indent=2))

# ── Apply chat template using the tokeniser ───────────────────────────
MODEL_ID  = 'meta-llama/Meta-Llama-3-8B-Instruct'
# tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
# For demonstration — show what the formatted text looks like

def format_example_manual(example: dict) -> str:
    """
    Manual LLaMA-3 chat template formatting.
    In production: use tokenizer.apply_chat_template()
    """
    msgs = example['messages']
    text = '<|begin_of_text|>'
    for msg in msgs:
        text += f"<|start_header_id|>{msg['role']}<|end_header_id|>
"
        text += f"{msg['content']}<|eot_id|>
"
    return text

formatted = format_example_manual(split['train'][0])
print(f"
Formatted with LLaMA-3 template:")
print(formatted)
print(f"
Length: {len(formatted)} characters")
The complete fine-tuning recipe

QLoRA fine-tuning — 4-bit quantisation + LoRA on a 7B model

QLoRA (Module 51) combines 4-bit quantisation of the frozen base model with LoRA adapters that train in fp16. This makes fine-tuning a 7B model possible on a single 16GB GPU — a Google Colab T4 or a local RTX 4090. The TRL library (from HuggingFace) wraps SFTTrainer — a Trainer specifically designed for supervised fine-tuning that handles chat template formatting, packing short sequences together, and gradient checkpointing automatically.

python
# pip install transformers peft trl accelerate bitsandbytes datasets

from transformers import (
    AutoTokenizer, AutoModelForCausalLM,
    BitsAndBytesConfig, TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM
from datasets import Dataset
import torch

# ── Step 1: Load tokeniser ────────────────────────────────────────────
MODEL_ID  = 'meta-llama/Meta-Llama-3-8B-Instruct'
# Alternative smaller models (no HuggingFace access required):
# MODEL_ID = 'microsoft/Phi-3-mini-4k-instruct'   # 3.8B, great quality
# MODEL_ID = 'mistralai/Mistral-7B-Instruct-v0.3'  # 7B, permissive license

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
tokenizer.pad_token     = tokenizer.eos_token
tokenizer.padding_side  = 'right'   # pad on right for causal LM

# ── Step 2: Load model in 4-bit (QLoRA) ──────────────────────────────
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type='nf4',            # NormalFloat4 — best 4-bit type
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_use_double_quant=True,       # extra compression
)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    quantization_config=bnb_config,
    device_map='auto',
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)

# ── Step 3: LoRA configuration ────────────────────────────────────────
lora_config = LoraConfig(
    r=16,                          # rank — higher = more capacity
    lora_alpha=32,                 # scaling = alpha/r = 2
    lora_dropout=0.05,
    bias='none',
    task_type='CAUSAL_LM',
    target_modules=[               # LLaMA-3 attention + MLP layers
        'q_proj', 'k_proj', 'v_proj', 'o_proj',
        'gate_proj', 'up_proj', 'down_proj',
    ],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Expected: trainable params: ~40M / 8B (0.5%)

# ── Step 4: Format dataset with chat template ─────────────────────────
def format_chat(example):
    """Apply tokeniser's built-in chat template to messages list."""
    text = tokenizer.apply_chat_template(
        example['messages'],
        tokenize=False,
        add_generation_prompt=False,
    )
    return {'text': text}

# Use dataset from previous section
train_formatted = split['train'].map(format_chat)
val_formatted   = split['test'].map(format_chat)

print(f"
Sample formatted text:")
print(train_formatted[0]['text'][:300])

# ── Step 5: TrainingArguments ─────────────────────────────────────────
training_args = TrainingArguments(
    output_dir='./stripe-dispute-classifier',
    num_train_epochs=3,
    per_device_train_batch_size=4,
    per_device_eval_batch_size=4,
    gradient_accumulation_steps=4,      # effective batch = 4×4 = 16
    gradient_checkpointing=True,        # trade compute for memory
    optim='paged_adamw_32bit',          # memory-efficient Adam for QLoRA
    learning_rate=2e-4,                 # higher than full fine-tuning
    lr_scheduler_type='cosine',
    warmup_ratio=0.05,
    fp16=True,
    logging_steps=25,
    evaluation_strategy='epoch',
    save_strategy='epoch',
    load_best_model_at_end=True,
    metric_for_best_model='eval_loss',
    save_total_limit=2,
    report_to='none',                   # set to 'wandb' for logging
    seed=42,
)

# ── Step 6: SFTTrainer ────────────────────────────────────────────────
# DataCollatorForCompletionOnlyLM: computes loss ONLY on assistant tokens
# Critical: we do not want to train the model to generate system/user text
response_template = '<|start_header_id|>assistant<|end_header_id|>'
collator = DataCollatorForCompletionOnlyLM(
    response_template=response_template,
    tokenizer=tokenizer,
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_formatted,
    eval_dataset=val_formatted,
    tokenizer=tokenizer,
    data_collator=collator,
    dataset_text_field='text',
    max_seq_length=512,
    packing=True,    # pack short examples together for efficiency
)

print("
Starting QLoRA fine-tuning...")
# trainer.train()   # uncomment to actually train

# ── Step 7: Save adapter ──────────────────────────────────────────────
# trainer.model.save_pretrained('./stripe-adapter')
# tokenizer.save_pretrained('./stripe-adapter')
# Adapter is ~80MB — the base model stays frozen at 4GB
Measuring what matters

Evaluating fine-tuned LLMs — beyond perplexity

Training loss and perplexity tell you the model is learning but not whether it will perform well in production. For task-specific fine-tuning, evaluate on task metrics: exact match accuracy for classification, ROUGE for summarisation, code execution rate for code generation. Always hold out a test set that the model never sees during training. Always compare against the base model and a prompting baseline — if fine-tuning does not beat prompting by a meaningful margin, the fine-tuning is not worth the cost.

python
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
from peft import PeftModel
import numpy as np
from sklearn.metrics import (
    accuracy_score, classification_report, confusion_matrix
)

# ── Load fine-tuned model for evaluation ─────────────────────────────
def load_finetuned(base_model_id: str, adapter_path: str,
                    device: str = 'cuda'):
    tokenizer = AutoTokenizer.from_pretrained(adapter_path)
    base      = AutoModelForCausalLM.from_pretrained(
        base_model_id,
        torch_dtype=torch.float16,
        device_map=device,
    )
    model = PeftModel.from_pretrained(base, adapter_path)
    model = model.merge_and_unload()   # merge LoRA → no inference overhead
    model.eval()
    return model, tokenizer

# ── Inference function — extract classification label ─────────────────
def classify_dispute(model, tokenizer, complaint: str,
                      max_new_tokens: int = 20) -> str:
    """
    Run inference and extract the predicted category.
    Since we trained the model to output exactly the category name,
    we take the first line of the response.
    """
    messages = [
        {'role': 'system',    'content': SYSTEM_PROMPT},
        {'role': 'user',      'content': f"Complaint: {complaint}"},
    ]
    formatted = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True,
    )
    inputs = tokenizer(formatted, return_tensors='pt').to(model.device)

    with torch.no_grad():
        output = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            temperature=0.0,       # deterministic for classification
            do_sample=False,
            pad_token_id=tokenizer.eos_token_id,
        )

    # Decode only new tokens
    new_tokens = output[0][inputs['input_ids'].shape[1]:]
    response   = tokenizer.decode(new_tokens, skip_special_tokens=True)
    # Extract first line — should be exactly the category name
    return response.strip().split('
')[0].strip()

# ── Batch evaluation ──────────────────────────────────────────────────
def evaluate_classifier(model, tokenizer, test_examples: list) -> dict:
    """
    Evaluate on held-out test set.
    Returns accuracy, per-class metrics, and error analysis.
    """
    preds, truths, raw_outputs = [], [], []

    for complaint, true_label in test_examples:
        pred = classify_dispute(model, tokenizer, complaint)
        preds.append(pred)
        truths.append(true_label)
        raw_outputs.append(pred)

    # Exact match accuracy
    exact_match = accuracy_score(truths, preds)

    # Handle cases where model predicts an invalid category
    valid_preds = [
        p if p in DISPUTE_CATEGORIES else 'other'
        for p in preds
    ]
    valid_accuracy = accuracy_score(truths, valid_preds)

    return {
        'exact_match':    exact_match,
        'valid_accuracy': valid_accuracy,
        'invalid_rate':   sum(1 for p in preds if p not in DISPUTE_CATEGORIES) / len(preds),
        'predictions':    preds,
        'truths':         truths,
    }

# ── Compare: base model vs prompting vs fine-tuned ────────────────────
print("Evaluation framework:")
print("""
Test set: 200 held-out disputes (never seen during training)
Metric: exact match accuracy (category must be exactly correct)

Baseline comparisons:
  1. Base LLaMA-3-8B with no prompt engineering
  2. Base LLaMA-3-8B with optimised system prompt
  3. GPT-4-turbo with optimised prompt (API baseline)
  4. Fine-tuned LLaMA-3-8B with QLoRA

Simulated results on Stripe dispute classification:
""")

results = {
    'Base LLaMA-3-8B (no prompt)':          0.41,
    'Base LLaMA-3-8B (optimised prompt)':   0.73,
    'GPT-4-turbo (optimised prompt)':        0.88,
    'Fine-tuned LLaMA-3-8B (QLoRA)':         0.94,
}
cost_per_1k = {
    'Base LLaMA-3-8B (no prompt)':          0.00,
    'Base LLaMA-3-8B (optimised prompt)':   0.00,
    'GPT-4-turbo (optimised prompt)':        10.00,
    'Fine-tuned LLaMA-3-8B (QLoRA)':         0.10,
}
print(f"  {'Model':<45} {'Accuracy':>10} {'$/1k calls':>12}")
print("  " + "─" * 70)
for name, acc in results.items():
    cost = cost_per_1k[name]
    bar  = '█' * int(acc * 20)
    print(f"  {name:<45} {acc:>10.1%} {cost:>12.2f}")

# ── Error analysis — what categories fail most ────────────────────────
print("""
Error analysis (fine-tuned model):
  Most confused pairs:
    payment_failed_technical ↔ payment_failed_bank_error  (18% of errors)
    refund_not_received ↔ refund_delayed                  (12% of errors)
    fraud_unauthorised ↔ merchant_dispute                 (8% of errors)

  Fix: add more examples distinguishing confused categories.
  Review: pull all wrong predictions, read them, understand the pattern.
  The most impactful data improvement is always on the failure modes.
""")
Shipping it

Deploying fine-tuned LLMs — serving, versioning, and monitoring

python
# ── Option 1: vLLM — fastest open-source LLM serving ─────────────────
# pip install vllm

print("""
vLLM with merged LoRA weights:

# Merge LoRA into base model first
from peft import PeftModel
from transformers import AutoModelForCausalLM

base  = AutoModelForCausalLM.from_pretrained('meta-llama/Meta-Llama-3-8B-Instruct')
model = PeftModel.from_pretrained(base, './stripe-adapter')
model = model.merge_and_unload()
model.save_pretrained('./stripe-merged')

# Serve with vLLM (OpenAI-compatible API)
# vllm serve ./stripe-merged --port 8000 --dtype float16

# Then call like OpenAI API:
from openai import OpenAI
client = OpenAI(base_url='http://localhost:8000/v1', api_key='unused')
response = client.chat.completions.create(
    model='./stripe-merged',
    messages=[
        {'role': 'system', 'content': SYSTEM_PROMPT},
        {'role': 'user',   'content': 'Complaint: charged twice for DoorDash order'},
    ],
    temperature=0.0,
    max_tokens=20,
)
category = response.choices[0].message.content.strip()
""")

# ── Option 2: FastAPI wrapper ──────────────────────────────────────────
print("""
FastAPI production wrapper:

from fastapi import FastAPI
from pydantic import BaseModel
import torch

app   = FastAPI()
model = None   # loaded at startup

class DisputeRequest(BaseModel):
    complaint: str
    merchant_id: str = None

class DisputeResponse(BaseModel):
    category: str
    confidence: float
    latency_ms: float

@app.on_event('startup')
async def load_model():
    global model, tokenizer
    model, tokenizer = load_finetuned(BASE_MODEL_ID, ADAPTER_PATH)

@app.post('/classify', response_model=DisputeResponse)
async def classify(request: DisputeRequest):
    import time
    start    = time.time()
    category = classify_dispute(model, tokenizer, request.complaint)
    latency  = (time.time() - start) * 1000

    # Validate output
    if category not in DISPUTE_CATEGORIES:
        category = 'other'

    return DisputeResponse(
        category=category,
        confidence=0.0,    # add confidence scoring for production
        latency_ms=latency,
    )
""")

# ── Monitoring in production ───────────────────────────────────────────
print("""
Production monitoring checklist:

1. Latency tracking
   - p50, p95, p99 latency per request
   - Alert if p95 > 2 seconds (SLO violation)

2. Accuracy tracking (requires ground truth labels)
   - Sample 5% of requests for human review
   - Track accuracy weekly — fine-tuned models can degrade
   - Common cause: data distribution shift (new dispute types)

3. Distribution monitoring
   - Track predicted category distribution daily
   - Alert if any category spikes >2x or drops to 0
   - Sudden spike in 'other' = model seeing out-of-distribution inputs

4. Model versioning
   - Tag every model with: training date, data version, eval accuracy
   - Keep last 3 versions in storage for rollback
   - Blue-green deployment: 5% traffic to new model → validate → 100%

5. Retraining triggers
   - Accuracy drops >3% vs baseline on weekly sample
   - New dispute category appears in data (needs training examples)
   - Base model major version update available (retrain on new base)
""")
Errors you will hit

Every common LLM fine-tuning mistake — explained and fixed

Fine-tuned model generates correct category followed by extra explanation text
Why it happens

The DataCollatorForCompletionOnlyLM was not configured correctly — loss was computed on both user and assistant tokens, not just the assistant response. The model learned to imitate the full training format including any trailing text. Or the response_template string does not exactly match the tokenised special tokens in the actual training data.

Fix

Verify DataCollatorForCompletionOnlyLM is using the exact response_template string that appears in your formatted examples. Print one tokenised example and check that only the assistant tokens have loss_ids set (label != -100). At inference: post-process the output to extract only the first line or the first recognised category name. Add a validation function that maps raw model output to valid categories — catch invalid outputs before returning to the caller.

Training loss goes to near-zero in epoch 1 but validation loss increases — severe overfitting
Why it happens

Dataset is too small relative to the number of trainable LoRA parameters. With r=16 and 7 target modules on LLaMA-3-8B, LoRA adds ~40M trainable parameters. If your dataset has fewer than 200 examples, the model memorises training data exactly and fails to generalise. Also caused by too many epochs — 3 epochs on 100 examples is equivalent to training from scratch on 300 examples.

Fix

Collect more data — minimum 500 examples for LoRA fine-tuning, ideally 2,000+. Use a lower rank: r=4 or r=8 — fewer trainable parameters are harder to overfit with small data. Reduce target modules to only q_proj and v_proj. Add LoRA dropout: lora_dropout=0.1. Use only 1–2 epochs and select checkpoint by validation loss. Add label smoothing to SFTTrainer. If data collection is not possible, stay with prompting.

SFTTrainer fails — ValueError: could not find response template in the tokenised sequence
Why it happens

The response_template string passed to DataCollatorForCompletionOnlyLM does not appear in the tokenised training examples. This is a tokenisation mismatch — the template string is tokenised differently when it appears mid-sequence vs standalone. LLaMA-3 adds different token IDs to the same string depending on position in the sequence.

Fix

Use the tokeniser to get the correct token IDs for the response template: response_ids = tokenizer.encode(response_template, add_special_tokens=False). Pass these IDs instead of the string: DataCollatorForCompletionOnlyLM(response_template=response_ids, ...). Alternatively use TRL's SFTTrainer with formatting_func instead of data_collator — it handles the template internally. Print a formatted and tokenised example and visually verify the template appears in the token IDs.

Fine-tuned model works on test set but degrades after deployment — distribution shift
Why it happens

The test set was not representative of production data. Training and test examples were from the same source (e.g. manually written examples) but production receives real customer complaints with different vocabulary, code-switching (Hindi-English mix), typos, and edge cases not present in training data. The model memorised the training distribution and fails on real inputs.

Fix

Collect evaluation data from real production inputs, not the same source as training data. If you are fine-tuning on manually written examples, add real customer complaints to the training set before training — even a small number (50–100 real examples) dramatically improves production performance. Set up a data flywheel: log production inputs → human review wrong predictions → add corrected examples to training set → retrain monthly.

What this looks like at work

How teams actually arrive at "yes, fine-tune it"

In practice, the decision in Section 2 is not made once in a meeting — it is arrived at through a cheap, sequenced experiment. A team ships a prompted feature first because it costs an afternoon, not a sprint. They watch two numbers: accuracy against a hand labelled sample, and dollar cost per thousand calls at current volume. If accuracy is the problem and it is a knowledge gap, they add RAG next — a one-to-two week investment. Only when volume is high enough that per-call API cost is a real budget line item, and the failure mode is clearly about consistency of behaviour rather than missing facts, does fine-tuning enter the conversation at all. Most LLM features at most companies never reach that third stage, and that is the intended outcome of a good decision process, not a failure to be ambitious.

When a team does cross that threshold, they rarely stand up their own GPU training infrastructure first. Hosted fine-tuning products — OpenAI's fine-tuning API, Together AI, Fireworks AI, and Amazon Bedrock's custom model import — let a small team upload a dataset and get back a hosted fine-tuned endpoint without owning a training cluster. Self-hosted, open-weight fine-tuning with QLoRA on a rented GPU only becomes worth the extra engineering once the volume is large enough that inference cost, not training cost, dominates the budget — self-hosting an open model can be an order of magnitude cheaper per call than a hosted proprietary model once volume is high enough to amortise the fixed setup cost.

PathWho owns itSetup timeBest fit
Hosted fine-tuning APIOne ML engineer, no infra teamDaysValidating the idea, moderate volume
Self-hosted QLoRA (open weights)ML engineer + MLOps/platform2–4 weeksHigh volume, cost-sensitive, data-sensitive
Full fine-tuning / foundation modelDedicated research + infra orgMonthsBuilding a genuine domain foundation model
The ticket you would actually get

Slack message from the PM, three months after the prompted classifier shipped: "We're at 40,000 dispute classifications a day now and the GPT-4 bill is becoming a real line item. Support says accuracy is fine but slow at peak load. Can we bring the cost down without hurting quality?" That is the actual trigger for fine-tuning in most organisations — not a technical ceiling on what prompting can do, but a volume and cost inflection point. The response is rarely "train from scratch": it is "export three months of logged predictions and corrections as training data, fine-tune an open-weight model with QLoRA, and put it behind the same API contract so nothing else in the product has to change."

Misconceptions

Five things people get wrong about LLM fine-tuning

Myth: Fine-tuning is a reliable way to teach a model new facts

Fine-tuning is genuinely reliable at teaching behaviour — output format, tone, exact label names, task-specific style — because those are patterns repeated consistently across the training examples. Teaching a model a large number of new declarative facts is a different problem: a fact seen a handful of times during fine-tuning gets blended statistically with everything else the model already believes, the model cannot cite where a fine-tuned fact came from, and there is no way to update or remove a single fact without retraining. This is exactly why RAG, not fine-tuning, is the standard answer whenever the need is specific, current, or citable knowledge — fine-tuning and knowledge injection solve different problems even though both sound like "the model learned something new."

Myth: LoRA is a weaker, watered-down substitute you settle for when full fine-tuning is too expensive

LoRA is not a compromise forced by limited compute — it is built on the empirical observation that the weight update needed to adapt a large pretrained model to a new task is itself low-rank, meaning most of the update can be captured by a small number of trainable parameters without meaningfully sacrificing quality. In practice LoRA reaches quality close to full fine-tuning on the overwhelming majority of real tasks, while producing a small, portable adapter file that is far easier to version, combine with other adapters, and roll back. Reaching for full fine-tuning by default, treating LoRA as the fallback, gets the actual trade-off backwards for almost every production use case.

Myth: Catastrophic forgetting only happens with full fine-tuning — LoRA is immune to it

LoRA freezing the base weights reduces the risk of catastrophic forgetting, it does not eliminate it. A high LoRA rank, too many training epochs on a small dataset, or a learning rate that is too aggressive can still push the adapted model to lose general capabilities it had before fine-tuning, especially on tasks unrelated to the fine-tuning data. The overfitting error covered earlier in this module — training loss collapsing while validation loss rises — is a direct symptom of this same failure mode. Always evaluate a fine-tuned model on general capability benchmarks, not just the target task, to catch this regardless of which fine-tuning method was used.

Myth: Once you decide to fine-tune, RAG and prompt engineering are no longer needed

Production LLM systems almost always combine all three rather than picking one and discarding the others. A fine-tuned model still needs a system prompt to set context for the specific deployment, still benefits from RAG for any information that changes after the fine-tuning snapshot was taken, and the fine-tuning itself is usually targeted narrowly at consistent behaviour — like producing an exact label format — rather than trying to internalise the entire knowledge base. Treating fine-tuning as a replacement for the other two techniques, instead of one more layer alongside them, is a common design mistake that leads to a model that is confidently wrong about anything that changed after training.

Myth: You need thousands of examples before fine-tuning is worth attempting

As this module's data preparation section covers, 500 high-quality, diverse examples routinely outperform 5,000 mediocre ones — the quality and diversity of examples matters far more than raw count. LoRA in particular, with its small number of trainable parameters, can show meaningful task-specific improvement from a few hundred carefully chosen examples, especially for a narrow task like consistent classification labels. Waiting to "collect enough data" by volume alone, without checking whether the existing smaller set is clean, diverse, and correctly labelled, routinely delays a fine-tuning effort that would already have worked.

Interview prep

LLM fine-tuning — 5 questions interviewers actually ask

Q1 — When does fine-tuning genuinely beat RAG for a given use case?

Fine-tuning wins when the problem is about consistent behaviour rather than missing knowledge — enforcing an exact output format or label set that a downstream system depends on, matching a specific tone or style reliably across thousands of calls, or reducing per-call cost at high volume by moving from an expensive hosted model to a smaller self-hosted one. RAG wins when the problem is that the model does not have access to specific, current, or citable information — content that changes often, proprietary documents, or anything where the answer needs a traceable source. If the failure mode is "the model does not know X," that is a RAG problem; if it is "the model knows how to do this but will not do it the exact way we need every time," that is a fine-tuning problem.

Q2 — How much data do you actually need to fine-tune effectively?

There is no fixed universal number, but a reasonable starting point for LoRA on a narrow task is a few hundred to a couple thousand examples, and quality matters more than quantity at any scale. I would rather have 500 examples that are correctly labelled, diverse in phrasing, and cover the edge cases the model actually sees in production, than 5,000 examples generated quickly or scraped from a single source. In practice I would start by manually reading a random sample of the available data before committing to a training run — labelling inconsistencies found there are almost always more valuable to fix first than adding more raw volume.

Q3 — How would you evaluate whether a fine-tuned model is actually better than the alternatives?

I would build a held-out test set the model never saw during training, and compare against at least three baselines on the same set: the base model with no prompt engineering, the base model with an optimised prompt, and a stronger proprietary model with an optimised prompt. The metric has to match the task — exact match accuracy for classification, something like ROUGE for summarisation, execution success rate for code. If the fine-tuned model does not clearly beat the optimised-prompt baseline by a meaningful margin, that is a sign the investment was not worth it, and I would ship the simpler prompting or RAG approach instead rather than defend the fine-tune out of sunk cost.

Q4 — What is the actual difference between full fine-tuning and LoRA, and when would you pick full fine-tuning?

Full fine-tuning updates every parameter in the model, requiring memory and compute proportional to the entire model size and carrying the highest risk of catastrophic forgetting. LoRA freezes the base weights and trains a small pair of low-rank matrices injected into specific layers, updating a tiny fraction of total parameters while reaching comparable quality on most tasks. I would only reach for full fine-tuning when the goal is building a genuine foundation model for a domain so specialised that the base model's existing knowledge is closer to random than useful — something like BloombergGPT trained on decades of financial documents — which is a fundamentally different scale of investment than adapting an existing capable model to a specific application.

Q5 — How do you prevent, and how would you detect, catastrophic forgetting during fine-tuning?

Prevention starts with the training setup: use a lower LoRA rank and fewer target modules when the dataset is small, limit training to one or two epochs, add dropout, and always select the checkpoint by validation loss rather than training loss. Detection requires evaluating on more than just the fine-tuning task — I would run the fine-tuned model against a general capability benchmark or a handful of unrelated prompts it should still answer well, and compare that against the base model's performance on the same prompts before and after fine-tuning. A model that improved on the target task but visibly degraded on unrelated general prompts is showing catastrophic forgetting, even if the target-task metric alone looks like a clean win.

What comes next

You can fine-tune any LLM. Next: models that see and understand both images and text simultaneously.

Fine-tuning adapts a model to a specific task using labelled examples. The next frontier is multimodal models — models that jointly understand images and text. CLIP encodes images and text in a shared embedding space. LLaVA connects a vision encoder to an LLM decoder, enabling visual question answering. Module 66 covers how these architectures work and how to use them for tasks that require understanding both what is written and what is shown.

Next — Module 66 · Generative AI
Multimodal Models — CLIP, LLaVA, and Vision-Language

Models that see and understand images and text together. CLIP for zero-shot image classification, LLaVA for visual question answering.

Start →

🎯 Key Takeaways

  • Fine-tune only when prompting and RAG cannot get you there. The decision hierarchy: prompt engineering first (1 day, flexible, no training cost) → RAG for knowledge gaps (1-2 weeks) → LoRA fine-tuning for consistent behaviour change on high-volume tasks (2-4 weeks) → full fine-tuning almost never for applications. The bar for fine-tuning must be justified by volume and a clear quality gap over prompting.
  • Data quality trumps data quantity. 500 high-quality, diverse, correctly-labelled examples beat 5,000 mediocre ones every time. Evaluate your data before training: read 50 random examples manually. If you find labelling inconsistencies, fix the data first. The most impactful ML work is data cleaning, not model architecture.
  • Chat template format must match the base model exactly. LLaMA-3, Mistral, Phi-3, and Gemma all use different special tokens. Apply the template with tokenizer.apply_chat_template() — never hardcode template strings manually. Template mismatches are the most common silent failure in LLM fine-tuning.
  • Use DataCollatorForCompletionOnlyLM to compute loss on assistant tokens only. Training on prompt tokens wastes compute and teaches the model the wrong thing — it should learn to generate responses, not re-generate inputs. Verify by printing token labels: prompt positions must be -100 (ignored).
  • QLoRA (4-bit quantisation + LoRA, rank 16, all projection layers) on a 7B model fits in 16GB VRAM with batch_size=4 and gradient_accumulation=4. Use paged_adamw_32bit optimiser, gradient_checkpointing=True, and packing=True in SFTTrainer. Training 3 epochs on 2,000 examples takes approximately 30-60 minutes on a T4.
  • Always compare fine-tuned model against: base model with no prompt, base model with optimised prompt, and a stronger model API (GPT-4) with optimised prompt. If GPT-4 with a good prompt beats your fine-tuned model, you have a data or training problem, not a capability gap. Ship the simpler approach until fine-tuning genuinely wins on your evaluation set.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...