HinterBuild logoHinterBuild
AI Systems · 9 min read

Teacher-Student Distillation for LLMs: Practical Tutorial

Teacher-Student Distillation for LLMs guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· Updated · 9 min read

  • LLM
  • LLM Serving
  • Evaluation
  • Cost Optimization

Table of Contents:

What Is Teacher-Student Distillation for LLMs?

Short answer: Teacher-student distillation for LLMs is the process of training a small "student" model to replicate a large "teacher" model's behavior on a specific task — producing a cheaper, faster model that matches the teacher's quality on that task.

The teacher (GPT-4o, Claude Sonnet, a 70B open model) generates high-quality outputs. The student (Llama 3.1 8B, Mistral 7B, Phi-3) learns from those outputs via fine-tuning. The result: a model that costs 10–50x less per token and runs on a single GPU — for your specific task.

We've used teacher-student distillation at HinterBuild to replace frontier API calls with self-hosted 8B models for classification, extraction, and domain-specific Q&A — cutting inference costs by 85%+ while maintaining 95%+ of teacher quality.

Key Takeaways:

  • Teacher-student distillation transfers capability from a large model to a small one for a specific task
  • Data quality from the teacher matters more than dataset size — 5K excellent examples beat 50K noisy ones
  • LoRA fine-tuning on an 8B student takes 2–8 hours on a single A100
  • Always evaluate student vs teacher on held-out data before deploying
  • Distillation + LLM routing gives maximum cost optimization

This is a complete step-by-step tutorial: data generation, training loop, evaluation, and production deployment.


When Distillation Beats Routing and Fine-Tuning

Three approaches to cheaper LLM inference:

ApproachCost reductionQuality retentionEffort
LLM routing40–70%90–98%Low (days)
Fine-tuning (human labels)50–80%85–95%Medium (weeks)
Teacher-student distillation80–95%90–98%Medium (weeks)

Choose Distillation When:

  • You have a high-volume, well-defined task (classification, extraction, formatting)
  • The task requires domain knowledge a base small model lacks
  • You need self-hosted inference for privacy or latency
  • LLM routing to cheap models isn't hitting quality thresholds
  • You have budget for a one-time training effort but want ongoing cost savings

Skip Distillation When:

  • Task is too broad or open-ended (general chatbot)
  • Traffic is too low to justify training (<10K requests/month)
  • Quality requirements demand frontier model reasoning
  • You can hit quality targets with routing alone

Distillation works best as a complement to routing: distill the high-volume task, route edge cases to the teacher.


Step 1: Define the Task and Success Criteria

Before generating a single training example, nail down what "good" means.

Task Definition Template

yaml
task:
  name: "support_ticket_classification"
  description: "Classify customer support tickets into intent categories"
  input: "Raw ticket text (subject + body)"
  output: "JSON with intent, urgency, confidence, suggested_team"

  teacher_model: "gpt-4o"
  student_model: "meta-llama/Llama-3.1-8B-Instruct"

  success_criteria:
    intent_accuracy: ">= 0.95"
    urgency_accuracy: ">= 0.90"
    json_validity: ">= 0.99"
    latency_p99: "<= 500ms"
    cost_per_1k_requests: "<= $0.05"

Output Schema

Define the exact output format. Use constrained JSON decoding when generating teacher data:

python
from pydantic import BaseModel
from typing import Literal

class TicketClassification(BaseModel):
    intent: Literal[
        "billing", "shipping", "returns", "product_question",
        "account", "technical", "other"
    ]
    urgency: Literal["low", "medium", "high", "critical"]
    confidence: float
    suggested_team: Literal["tier1", "tier2", "billing", "engineering"]
    summary: str

The student must produce this exact schema. Define it once, use it for teacher generation, training data, and evaluation.


Step 2: Generate Training Data from the Teacher

Teacher-student distillation quality is bounded by teacher data quality. This is the most important step.

Data Generation Pipeline

python
import asyncio
import json
from openai import OpenAI
from pydantic import BaseModel

client = OpenAI()

TEACHER_MODEL = "gpt-4o"
SYSTEM_PROMPT = """You are an expert support ticket classifier.
Analyze the ticket and classify it according to the schema.
Be precise with intent categories. When uncertain, use 'other' with lower confidence."""

async def generate_teacher_response(ticket: str) -> dict:
    response = client.beta.chat.completions.parse(
        model=TEACHER_MODEL,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"Classify this ticket:\n\n{ticket}"},
        ],
        response_format=TicketClassification,
        temperature=0.1,  # low temperature for consistent labels
    )
    return response.choices[0].message.parsed.model_dump()

async def generate_dataset(tickets: list[str], output_path: str):
    results = []
    semaphore = asyncio.Semaphore(10)  # rate limit

    async def process(ticket: str, idx: int):
        async with semaphore:
            try:
                label = await generate_teacher_response(ticket)
                results.append({
                    "id": idx,
                    "input": ticket,
                    "output": label,
                    "teacher_model": TEACHER_MODEL,
                })
            except Exception as e:
                print(f"Failed on ticket {idx}: {e}")

    tasks = [process(ticket, i) for i, ticket in enumerate(tickets)]
    await asyncio.gather(*tasks)

    with open(output_path, "w") as f:
        json.dump(results, f, indent=2)

    print(f"Generated {len(results)} examples → {output_path}")

Where to Get Input Data

Sources for distillation training inputs:

  1. Production logs — Anonymized real queries (best distribution match)
  2. Historical data — Past tickets, documents, queries from your domain
  3. Synthetic generation — Ask the teacher to generate diverse inputs:
python
SYNTHETIC_PROMPT = """Generate 10 diverse customer support tickets for an e-commerce company.
Vary: intent, urgency, tone (angry, polite, confused), length, language complexity.
Format: one ticket per line, separated by ---"""

async def generate_synthetic_inputs(n_batches: int = 100) -> list[str]:
    all_tickets = []
    for _ in range(n_batches):
        response = await client.chat.completions.create(
            model="gpt-4o-mini",  # cheap for input generation
            messages=[{"role": "user", "content": SYNTHETIC_PROMPT}],
            temperature=0.9,  # high diversity
        )
        tickets = response.choices[0].message.content.split("---")
        all_tickets.extend(t.strip() for t in tickets if t.strip())
    return all_tickets
  1. Augmentation — Paraphrase existing inputs for diversity

How Much Data?

Task complexityMinimum examplesRecommendedDiminishing returns after
Binary classification5002,0005,000
Multi-class (5-10 labels)1,0005,00015,000
Structured extraction2,00010,00030,000
Open-ended generation5,00020,00050,000+

For our support classification task, 5,000 teacher-labeled examples produced a student within 2% of teacher accuracy.


Step 3: Curate and Filter the Dataset

Raw teacher output includes errors. Teacher-student distillation fails when you train on bad labels.

Quality Filtering Pipeline

python
def filter_dataset(examples: list[dict]) -> list[dict]:
    filtered = []

    for ex in examples:
        output = ex["output"]
        if output.get("confidence", 0) < 0.7:
            continue  # teacher wasn't sure, skip

        # Filter 2: Schema validation
        try:
            TicketClassification.model_validate(output)
        except Exception:
            continue

        # Filter 3: Input quality
        if len(ex["input"].strip()) < 10:
            continue  # too short to classify

        # Filter 4: Output quality
        if len(output.get("summary", "")) < 5:
            continue

        filtered.append(ex)

    return filtered

def deduplicate(examples: list[dict], similarity_threshold: float = 0.95) -> list[dict]:
    """Remove near-duplicate inputs using embedding similarity."""
    from openai import OpenAI
    client = OpenAI()

    inputs = [ex["input"] for ex in examples]
    embeddings = client.embeddings.create(
        model="text-embedding-3-small", input=inputs
    ).data

    keep = [True] * len(examples)
    for i in range(len(examples)):
        if not keep[i]:
            continue
        for j in range(i + 1, len(examples)):
            if not keep[j]:
                continue
            sim = cosine_similarity(embeddings[i].embedding, embeddings[j].embedding)
            if sim > similarity_threshold:
                keep[j] = False

    return [ex for ex, k in zip(examples, keep) if k]

Train/Validation/Test Split

python
import random

def split_dataset(examples: list[dict], train=0.8, val=0.1, test=0.1):
    random.shuffle(examples)
    n = len(examples)
    train_end = int(n * train)
    val_end = train_end + int(n * val)

    return (
        examples[:train_end],
        examples[train_end:val_end],
        examples[val_end:],
    )

Critical: Keep test set untouched until final evaluation. Never tune hyperparameters on test data.

Format for Training

Convert to instruction-tuning format:

python
def to_training_format(example: dict) -> dict:
    return {
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"Classify this ticket:\n\n{example['input']}"},
            {"role": "assistant", "content": json.dumps(example["output"])},
        ]
    }

training_data = [to_training_format(ex) for ex in train_set]

Step 4: Choose and Prepare the Student Model

The student model choice affects distillation quality, cost, and deployment complexity.

Student Model Selection

ModelParametersVRAM (fp16)Best for
Phi-3 Mini3.8B8 GBEdge deployment, simple tasks
Llama 3.1 8B8B16 GBGeneral-purpose distillation
Mistral 7B7B14 GBEuropean data residency
Llama 3.1 70B70B140 GBMaximum quality retention

For most teacher-student distillation projects, Llama 3.1 8B is the sweet spot: strong base capabilities, single-GPU deployment, large community.

LoRA vs Full Fine-Tuning

MethodTrainable paramsVRAM neededQualityTraining time
LoRA0.1–1%16–24 GB95–98% of full FT2–8 hours
Full fine-tuning100%40–80 GBBest8–24 hours
QLoRA0.1–1% (4-bit)8–12 GB93–97% of full FT3–10 hours

Recommendation: Start with LoRA. Move to full fine-tuning only if LoRA quality isn't sufficient.

Setup Training Environment

bash
pip install torch transformers datasets peft trl accelerate bitsandbytes
pip install wandb  # experiment tracking
python
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model

MODEL_ID = "meta-llama/Llama-3.1-8B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    torch_dtype="auto",
    device_map="auto",
)

lora_config = LoraConfig(
    r=16,               # rank — higher = more capacity, more VRAM
    lora_alpha=32,      # scaling factor
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: ~42M / 8B = 0.5%

Step 5: Run the Training Loop

This is the core of teacher-student distillation — training the student on teacher-generated data.

Training with TRL SFTTrainer

python
from trl import SFTTrainer, SFTConfig
from datasets import Dataset

def format_chat(example):
    text = tokenizer.apply_chat_template(
        example["messages"],
        tokenize=False,
        add_generation_prompt=False,
    )
    return {"text": text}

train_dataset = Dataset.from_list(training_data).map(format_chat)
val_dataset = Dataset.from_list([to_training_format(ex) for ex in val_set]).map(format_chat)

training_args = SFTConfig(
    output_dir="./distilled-classifier",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,       # effective batch size = 16
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.05,
    max_seq_length=2048,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=50,
    save_strategy="steps",
    save_steps=100,
    load_best_model_at_end=True,
    metric_for_best_model="eval_loss",
    bf16=True,
    report_to="wandb",
    run_name="ticket-classifier-distillation-v1",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=val_dataset,
    processing_class=tokenizer,
)

trainer.train()
trainer.save_model("./distilled-classifier/final")
tokenizer.save_pretrained("./distilled-classifier/final")

Training Hyperparameters Guide

ParameterConservativeAggressiveNotes
learning_rate1e-45e-4Higher LR risks catastrophic forgetting
num_epochs25Watch val loss — stop when it plateaus
lora_r864Higher rank = more capacity for complex tasks
batch_size832Larger batches stabilize training
max_seq_length10244096Match your longest training example

Monitoring Training

Watch for these signals during teacher-student distillation:

  • Train loss decreasing, val loss flat — Good, model is learning
  • Train loss decreasing, val loss increasing — Overfitting; reduce epochs or add data
  • Both losses flat after epoch 1 — Learning rate too low or data too easy
  • Train loss spikes — Learning rate too high; reduce by 2–5x

Log to W&B or TensorBoard. Compare eval loss across runs to find optimal hyperparameters.

Multi-Teacher Distillation (Advanced)

For higher quality, generate data from multiple teachers and train on the ensemble:

python
TEACHERS = ["gpt-4o", "claude-sonnet-4-20250514", "gemini-2.0-flash"]

async def multi_teacher_label(ticket: str) -> dict:
    labels = await asyncio.gather(*[
        generate_teacher_response(ticket, model=m) for m in TEACHERS
    ])
    # Use majority vote or highest-confidence label
    return resolve_disagreement(labels)

Multi-teacher labels reduce individual teacher bias and improve student generalization.


Step 6: Evaluate the Student Model

Never deploy a distilled model without rigorous evaluation against the teacher.

Load and Run the Student

python
from peft import PeftModel

base_model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID, torch_dtype="auto", device_map="auto"
)
student = PeftModel.from_pretrained(base_model, "./distilled-classifier/final")
student.eval()

def student_classify(ticket: str) -> dict:
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": f"Classify this ticket:\n\n{ticket}"},
    ]
    input_ids = tokenizer.apply_chat_template(
        messages, return_tensors="pt", add_generation_prompt=True
    ).to(student.device)

    output_ids = student.generate(
        input_ids,
        max_new_tokens=256,
        temperature=0.1,
        do_sample=False,
    )
    response = tokenizer.decode(output_ids[0][input_ids.shape[1]:], skip_special_tokens=True)
    return json.loads(response)

Evaluation Metrics

python
from sklearn.metrics import classification_report, accuracy_score

def evaluate_student(test_set: list[dict]) -> dict:
    predictions = []
    ground_truth = []

    for ex in test_set:
        try:
            pred = student_classify(ex["input"])
            predictions.append(pred)
            ground_truth.append(ex["output"])
        except (json.JSONDecodeError, Exception) as e:
            predictions.append({"intent": "PARSE_ERROR", "urgency": "PARSE_ERROR",
                              "confidence": 0, "suggested_team": "tier1", "summary": ""})

    # Intent accuracy
    intent_acc = accuracy_score(
        [g["intent"] for g in ground_truth],
        [p["intent"] for p in predictions],
    )

    # JSON validity rate
    valid = sum(1 for p in predictions if p.get("intent") != "PARSE_ERROR")
    json_validity = valid / len(predictions)

    # Full classification report
    report = classification_report(
        [g["intent"] for g in ground_truth],
        [p["intent"] for p in predictions],
        output_dict=True,
    )

    return {
        "intent_accuracy": intent_acc,
        "json_validity": json_validity,
        "classification_report": report,
        "n_samples": len(test_set),
    }

Teacher vs Student Comparison

Run the same test set through both models:

python
async def compare_teacher_student(test_set: list[dict]) -> dict:
    teacher_results = []
    student_results = []

    for ex in test_set:
        teacher_label = await generate_teacher_response(ex["input"])
        student_label = student_classify(ex["input"])
        teacher_results.append(teacher_label)
        student_results.append(student_label)

    teacher_acc = accuracy_score(
        [r["intent"] for r in teacher_results],
        [ex["output"]["intent"] for ex in test_set],
    )
    student_acc = accuracy_score(
        [r["intent"] for r in student_results],
        [ex["output"]["intent"] for ex in test_set],
    )

    agreement = accuracy_score(
        [t["intent"] for t in teacher_results],
        [s["intent"] for s in student_results],
    )

    return {
        "teacher_accuracy": teacher_acc,
        "student_accuracy": student_acc,
        "teacher_student_agreement": agreement,
        "quality_retention": student_acc / teacher_acc if teacher_acc > 0 else 0,
    }

Acceptable Quality Thresholds

MetricMinimum to deployTarget
Quality retention (student/teacher)90%95%+
Teacher-student agreement85%92%+
JSON validity99%99.9%
Latency vs teacher API10x faster20x+ faster

If quality retention is below 90%, try: more training data, higher LoRA rank, multi-teacher labels, or a larger student model.


Step 7: Deploy and Monitor in Production

A distilled model is only valuable if it runs reliably in production.

Serve with vLLM

python
# Start vLLM server with LoRA adapter
# vllm serve meta-llama/Llama-3.1-8B-Instruct \
#   --enable-lora \
#   --lora-modules classifier=./distilled-classifier/final \
#   --max-lora-rank 16

Or integrate into your existing backend API:

python
from fastapi import FastAPI
import httpx

app = FastAPI()
VLLM_URL = "http://localhost:8000/v1"

@app.post("/v1/classify")
async def classify_ticket(ticket: str):
    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{VLLM_URL}/chat/completions",
            json={
                "model": "classifier",
                "messages": [
                    {"role": "system", "content": SYSTEM_PROMPT},
                    {"role": "user", "content": f"Classify this ticket:\n\n{ticket}"},
                ],
                "temperature": 0.1,
                "max_tokens": 256,
            },
        )
    result = response.json()
    content = result["choices"][0]["message"]["content"]
    return json.loads(content)

Hybrid Deployment: Student + Teacher Fallback

Combine teacher-student distillation with cascade routing:

python
async def classify_with_fallback(ticket: str) -> dict:
    student_result = student_classify(ticket)

    # Low confidence → escalate to teacher
    if student_result.get("confidence", 0) < 0.75:
        teacher_result = await generate_teacher_response(ticket)
        logger.info("escalated_to_teacher", ticket_hash=hash(ticket))
        return teacher_result

    return student_result

In production, ~85% of requests stay on the student. The remaining 15% escalate to the teacher API. Net cost savings: ~80%.

Production Monitoring

Track with observability and monitoring:

MetricAlert threshold
student.confidence_p50Drop below 0.80 → data drift
student.escalation_rateAbove 25% → retrain needed
student.json_error_rateAbove 1% → model degradation
student.latency_p99Above 500ms → scaling issue
student.intent_distributionShift >10% → new ticket types emerging

Schedule monthly re-evaluation. Retrain when quality retention drops below 90% or escalation rate exceeds 20%.

Deploy on cloud infrastructure with GPU autoscaling and model versioning.


Advanced Techniques

Knowledge Distillation with Logit Matching

Instead of training only on teacher outputs (hard labels), match the student's output distribution to the teacher's logits (soft labels):

python
import torch
import torch.nn.functional as F

def distillation_loss(student_logits, teacher_logits, labels, temperature=2.0, alpha=0.5):
    # Soft label loss (KL divergence between distributions)
    soft_student = F.log_softmax(student_logits / temperature, dim=-1)
    soft_teacher = F.softmax(teacher_logits / temperature, dim=-1)
    soft_loss = F.kl_div(soft_student, soft_teacher, reduction="batchmean") * (temperature ** 2)

    # Hard label loss (standard cross-entropy)
    hard_loss = F.cross_entropy(student_logits, labels)

    return alpha * soft_loss + (1 - alpha) * hard_loss

Soft labels transfer more knowledge per example — the student learns why the teacher chose an answer, not just what it chose.

Continual Distillation

As production data accumulates, periodically re-distill:

Month 1: Initial distillation (5K examples)
Month 3: Add 2K production examples where student escalated
Month 6: Full re-distillation with 10K examples

This keeps the student current as your data distribution evolves.

Distillation for Tool-Calling Agents

Distill tool calling behavior from a teacher agent:

python
# Teacher generates: (query, tool_call, tool_result, final_answer) trajectories
# Student learns: given query → correct tool_call JSON

class ToolCallExample(BaseModel):
    query: str
    tool_name: str
    tool_arguments: dict
    reasoning: str

This produces a fast, cheap tool-selection model for AI agent systems.


Primary references: official documentation, official documentation, official documentation, official documentation.

Frequently Asked Questions

What is teacher-student distillation for LLMs?

Teacher-student distillation trains a small LLM (student) to replicate a large LLM's (teacher) behavior on a specific task. The teacher generates training data; the student learns via fine-tuning. The result is a cheaper, faster model for that task.

How is distillation different from fine-tuning?

Fine-tuning uses human-labeled data. Teacher-student distillation uses AI-generated labels from a stronger model. Distillation scales data generation cheaply but inherits teacher biases.

How much training data do I need?

For classification tasks: 2,000–5,000 high-quality teacher-labeled examples. For extraction: 5,000–10,000. Quality matters more than quantity — filter aggressively.

Which student model should I use?

Llama 3.1 8B for most tasks. Phi-3 Mini (3.8B) for edge/simple tasks. Llama 3.1 70B when 8B can't retain enough teacher quality. Start small, scale up only if needed.

How long does distillation training take?

LoRA fine-tuning on 5K examples with Llama 8B: 2–8 hours on a single A100. QLoRA on a consumer GPU (RTX 4090): 4–12 hours.

Can I distill from closed API models?

Yes. Use GPT-4o or Claude as the teacher via API. Generate labeled data, then fine-tune an open-weight student. You can't distill the closed model's weights — only its outputs.

How do I know if distillation worked?

Compare student vs teacher on a held-out test set. Target 90%+ quality retention (student accuracy / teacher accuracy). Also measure JSON validity, latency, and cost per request.

Should I use distillation or LLM routing?

Use both. Distillation for high-volume, well-defined tasks. LLM routing for diverse queries and edge cases. Deploy distilled student as the default route, escalate to teacher on low confidence.


Conclusion

Teacher-student distillation for LLMs turns expensive API calls into cheap self-hosted inference — without sacrificing quality on your specific task. The step-by-step process:

  1. Define the task and success criteria with a strict output schema
  2. Generate 2K–10K teacher-labeled examples with quality filtering
  3. Train the student with LoRA fine-tuning (2–8 hours on one GPU)
  4. Evaluate against the teacher on held-out data (target 90%+ retention)
  5. Deploy with cascade fallback to the teacher for low-confidence cases
  6. Monitor and retrain monthly as data distribution shifts

Distillation + routing + constrained JSON decoding is the cost optimization stack we deploy for production AI agent systems.

At HinterBuild:

Contact us to distill your LLM workload.

Free consultation

Book a free consultation call on teacher-student model distillation

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading