โ† Back to Blog
AI EngineeringLLMsTools

Distilling a Frontier LLM into a Small Model: A Teacher-Student Playbook for Production

Your classification or extraction feature doesn't need a frontier model on every request. Here's how to use a big model as a teacher, label data cheaply, and train a small student that is 10-50x cheaper and faster.

By Joinloopยท7 November 2026ยท8 min read
ShareLinkedInXReddit
๐Ÿงช

You shipped an LLM feature. It works. Then the bill arrives: a ticket-triage classifier, an intent router, or a field extractor is calling a frontier model on every request, adding a second of latency and a line item finance keeps asking about. The task is narrow. The model is general. You are paying for capability you never use.

Distillation fixes this. You use the large model as a teacher to label your real traffic, then train a small student model on those labels. The student handles the common path cheaply; the teacher stays as a fallback for hard cases.

This post is a practical playbook: when distillation is worth it, how to build the pipeline, how to evaluate the student honestly, and where teams get burned.

When distillation is the right move

Distillation pays off when the task is narrow, high-volume, and stable.

Signal Distill? Why
Classification, routing, tagging, extraction with a fixed schema Yes Small models match large ones on bounded tasks
100k+ calls/month on the same prompt Yes Fixed training cost amortises quickly
Latency budget under ~300 ms Yes Small models run locally or on cheap GPUs
Open-ended generation, multi-step reasoning Usually no Students lose the long tail of capability
Task definition changes weekly No You'll retrain constantly
Fewer than ~1k examples of real traffic Not yet Collect data first

Also check your provider's terms: some restrict using model outputs to train competing models. Read them before you build the pipeline, and prefer open-weight teachers if in doubt.

The pipeline at a glance

  1. Collect real, unlabelled inputs (production logs, with PII redacted).
  2. Label them with the teacher, using your best prompt.
  3. Filter noisy labels (low confidence, disagreement across samples).
  4. Gold set: hand-label a few hundred examples. Never train on this.
  5. Train the student on teacher labels.
  6. Evaluate the student against the gold set, not against the teacher.
  7. Route: student first, teacher fallback on low confidence.
  8. Monitor drift and re-label periodically.

The most common mistake is skipping step 4 and measuring the student by agreement with the teacher. That tells you how well you copied the teacher's mistakes.

Step 1-3: Label with the teacher, then filter

Use structured output and sample the teacher more than once. Disagreement across samples is a cheap, useful noise signal.

import json
from collections import Counter
from concurrent.futures import ThreadPoolExecutor

LABELS = ["billing", "bug", "feature_request", "account", "other"]

SYSTEM = f"""Classify the support ticket into exactly one label from {LABELS}.
Respond with JSON: {{"label": "<label>"}}"""

def teacher_label(client, text: str, n: int = 3) -> dict:
    votes = []
    for _ in range(n):
        resp = client.chat(  # replace with your provider's SDK call
            model="teacher-model",
            temperature=0.7,
            system=SYSTEM,
            user=text,
        )
        try:
            votes.append(json.loads(resp)["label"])
        except (json.JSONDecodeError, KeyError):
            continue
    if not votes:
        return {"label": None, "agreement": 0.0}
    label, count = Counter(votes).most_common(1)[0]
    return {"label": label if label in LABELS else None,
            "agreement": count / n}

def label_dataset(client, texts, workers=8):
    with ThreadPoolExecutor(workers) as pool:
        return list(pool.map(lambda t: teacher_label(client, t), texts))

Then keep only examples where the teacher agreed with itself:

rows = [
    {"text": t, **lab}
    for t, lab in zip(texts, labels)
    if lab["label"] and lab["agreement"] >= 0.67
]
print(f"kept {len(rows)}/{len(texts)}")

Dropped examples are not wasted: they are your hard set. Review a sample by hand. They often reveal ambiguous label definitions, which is a product problem, not a model problem.

Step 4: Build a gold set you never train on

Hand-label 300-500 examples, stratified by class, sampled from real traffic. Two people labelling a subset lets you measure inter-annotator agreement. If humans only agree 80% of the time, no model will be reliably better than that, and you should tighten the label guide before training anything.

Keep the gold set in a separate file and guard it:

import hashlib

def is_gold(text: str, gold_hashes: set[str]) -> bool:
    return hashlib.sha256(text.encode()).hexdigest() in gold_hashes

train_rows = [r for r in rows if not is_gold(r["text"], gold_hashes)]

Deduplicate near-duplicates between train and gold too, otherwise leakage will flatter your numbers.

Step 5: Train the student

For classification, a small encoder (a DistilBERT or MiniLM-class model) fine-tuned with Hugging Face transformers is usually the best cost/quality point. For structured extraction, a 1-3B parameter decoder with LoRA is more typical (see our LoRA/QLoRA guide).

from datasets import Dataset
from transformers import (AutoTokenizer, AutoModelForSequenceClassification,
                          TrainingArguments, Trainer)

model_name = "distilbert-base-uncased"
tok = AutoTokenizer.from_pretrained(model_name)
label2id = {l: i for i, l in enumerate(LABELS)}

ds = Dataset.from_list([
    {"text": r["text"], "label": label2id[r["label"]]} for r in train_rows
]).train_test_split(test_size=0.1, seed=42)

def encode(batch):
    return tok(batch["text"], truncation=True, max_length=256)

ds = ds.map(encode, batched=True)

model = AutoModelForSequenceClassification.from_pretrained(
    model_name, num_labels=len(LABELS),
    id2label={i: l for l, i in label2id.items()}, label2id=label2id,
)

args = TrainingArguments(
    output_dir="student",
    learning_rate=3e-5,
    per_device_train_batch_size=32,
    num_train_epochs=3,
    eval_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
)

Trainer(model=model, args=args, tokenizer=tok,
        train_dataset=ds["train"], eval_dataset=ds["test"]).train()

Argument names change between transformers versions (for example evaluation_strategy became eval_strategy), so check the docs for the version you have installed.

A refinement worth knowing: instead of hard labels, train on the teacher's probability distribution (soft labels) with a KL-divergence loss. This transfers more information per example, but only works when you can get calibrated probabilities from the teacher, which API models often don't expose. Majority-vote hard labels are the pragmatic default.

Step 6: Evaluate against gold, by slice

from sklearn.metrics import classification_report
import numpy as np

preds = trainer.predict(gold_ds).predictions.argmax(-1)
print(classification_report(gold_labels, preds, target_names=LABELS))

Compare three numbers on the same gold set: teacher, student, and a trivial baseline (majority class or keyword rules). Then look at slices: per class, per customer segment, per input length. Students typically fail on rare classes and long inputs first.

A reasonable ship criterion: student within 1-3 points of the teacher on overall F1, and no class dropping more than 5 points.

Step 7: Route with confidence, keep the teacher as fallback

You rarely need to replace the teacher entirely. A cascade captures most of the savings with little quality loss.

import torch

def classify(text: str, threshold: float = 0.85):
    inputs = tok(text, return_tensors="pt", truncation=True, max_length=256)
    with torch.no_grad():
        probs = model(**inputs).logits.softmax(-1)[0]
    conf, idx = probs.max(0)
    if conf >= threshold:
        return {"label": LABELS[idx], "source": "student", "conf": conf.item()}
    return {**teacher_label(client, text, n=1), "source": "teacher"}

Tune the threshold on the gold set: plot accuracy against the fraction of traffic the student handles, and pick the knee. Raw softmax confidence is often over-confident, so calibrate it (temperature scaling is a simple fix) before trusting a threshold.

Log every fallback. Those rows are your next training batch, labelled for free.

Cost and latency: the comparison to run

Do the arithmetic before you start. Fill in your own numbers.

Teacher (API) Student (self-hosted)
Cost per 1M requests tokens x price GPU/CPU hours
p50 latency hundreds of ms to seconds typically tens of ms
Ops burden none hosting, monitoring, retraining
Quality ceiling high bounded by task and data
Fixed cost none labelling + training + eval engineering

If your monthly teacher spend is small, the engineering time may not pay back. Distillation is an optimisation, not a starting point.

Common pitfalls

  • Measuring against the teacher. Agreement is not accuracy. Always use human gold labels.
  • Gold-set leakage. Duplicates and near-duplicates across splits inflate scores silently.
  • Training on unfiltered labels. Noisy teacher outputs cap student quality. Filter by self-agreement.
  • Class imbalance. The student learns the majority class well and ignores the tail. Rebalance or weight the loss.
  • Distribution shift. Production inputs change (new product, new customers). Schedule re-labelling of a recent sample and watch the fallback rate; a rising rate is your drift alarm.
  • Ignoring the label guide. If the categories are fuzzy, both teacher and student will be inconsistent. Fix definitions first.
  • Privacy. Redact PII before sending logs to a teacher or storing training data.
  • Over-distilling. Not every task compresses. If the student plateaus far below the teacher, the task needs the larger model; keep the cascade.

A checklist before you ship

  • Task is narrow, stable, and high-volume
  • Provider terms allow training on outputs
  • 300+ hand-labelled gold examples, held out and deduplicated
  • Teacher labels filtered by self-agreement
  • Student compared to teacher and baseline, sliced by class
  • Confidence calibrated; threshold chosen from the gold set
  • Teacher fallback in place, with logging
  • Drift monitor and a retraining cadence

Next steps

Start small: pick your highest-volume, simplest LLM call, export a week of redacted inputs, and run the labelling script above. Spend an afternoon on the gold set; it will pay for itself in every later decision. Even if you never ship the student, the exercise tells you exactly how much of your traffic really needs a frontier model.

If you want to build this end to end with feedback from practitioners, our AI Engineering cohort walks through evals, fine-tuning, and production routing on real projects.

ShareLinkedInXReddit

Want to build this yourself?

Join a live cohort and go from reading about AI to shipping it, with practitioners who do it daily.