Small Language Models with Hugging Face transformers Library + smolLM3

Running a 70B model in production is expensive, and for many tasks, unnecessary. If you're building a focused pipeline, a well-trained 3B model will match or beat the 70B on your specific task at a fraction of the cost.



Small Language Models with Hugging Face transformers Library + smolLM3
 

Small But Powerful

 
Running a 70B model in production can be expensive, slow, and, for many tasks, unnecessary. If you're building a focused pipeline like a document classifier or a multilingual support responder, a well-trained 3B model will match or beat the 70B on your specific task at a fraction of the cost. The 3B model fits entirely in a single consumer GPU. It loads in seconds. It costs nothing per token. And on constrained hardware, it's the only option that runs at all.

That's the actual case for small language models (SLMs). This article uses SmolLM3, Hugging Face's flagship 3B model released on July 8, 2025, as the working model throughout. It's the most technically interesting SLM available at the 3B scale right now, trained on 11.2 trillion tokens, supporting a 128k context window, dual-mode reasoning, native tool calling, six languages, and an Apache 2.0 license with the full training blueprint published alongside the weights.

The project thread woven through every section: a multilingual customer support ticket router that classifies incoming tickets by category, detects the ticket language, generates a reply in that same language, and flags low-confidence outputs for human escalation. By the end, you'll have a working pipeline you can adapt to your own domain.

 

Why Small Language Models Deserve More Attention

 
The parameter-count fixation in AI is understandable but misleading. Raw scale matters, up to a point. After that point, data quality, training curriculum, and architectural choices matter more.

Research from the SmolLM2 paper (arxiv, February 2025) showed that at the 1B—3B scale, carefully curated training data consistently outperforms naively scaling parameters. SmolLM3 takes that further: 11.2 trillion training tokens across a staged curriculum — web, code, math, and reasoning data — plus 140 billion reasoning tokens in post-training. The result is a model that, on zero-shot benchmarks, outperforms both Llama-3.2-3B and Qwen2.5-3B and rivals Qwen3-4B on several tasks.

Take the IFEval instruction-following benchmark, where SmolLM3 scores 76.7, higher than Qwen3-4B at 68.9. On BFCL (tool calling), it ties Llama's tool-call fine-tune at 92.3. On Global MMLU (multilingual QA), it scores 53.5 against Llama-3.1-3B's 46.8.

Where SLMs genuinely fall short: tasks requiring deep, broad world knowledge, competitive trivia, complex multi-hop reasoning over vast knowledge graphs, and very long-form creative writing with rich historical context. For those, you want the big model. For everything focused and domain-specific, the SLM with fine-tuning on your data will match it at a tenth of the operating cost.

The Hugging Face SLM collection currently includes SmolLM3-3B (instruction-tuned, what this article uses), SmolLM3-3B-Base (untuned pretrained weights), SmolLM2-1.7B (lighter predecessor), and SmolVLM (the vision-language variant). SmolLM3 is the right choice for most new projects because dual-mode reasoning, tool calling, and the 128k context window are rare at this parameter scale.

 

Understanding SmolLM3's Architecture

 
SmolLM3 is a decoder-only transformer, which is standard. Three architectural decisions inside that standard frame are less common and worth understanding because they directly affect how you deploy and tune the model.

  1. Grouped Query Attention: Standard multi-head attention maintains separate key and value projections for each of the 16 attention heads. SmolLM3 groups those 16 heads into 4 shared query projections, reducing key-value (KV) cache memory by roughly 25% without measurable accuracy loss. This matters at inference time: a smaller KV cache means lower peak VRAM, which means you can process longer contexts or larger batches on the same hardware.
  2. NoPE (No Positional Encoding on select layers): SmolLM3 removes rotary positional encoding (RoPE) from every fourth transformer layer, implementing a 3:1 RoPE-to-NoPE ratio. This approach comes from the 2025 paper "RoPE to NoRoPE and Back Again" and helps the model generalize over long contexts without the positional embedding degradation that affects most other small models at long sequence lengths.
  3. Dual-mode reasoning: A single set of weights handles two modes: think and no_think. In think mode, the model generates a chain-of-thought trace inside <think>...</think> tags before the final answer, equivalent to what separate "reasoning models" do. In no_think mode, it answers directly. You control this per-request via the system prompt or the enable_thinking kwarg in the chat template. No extra model, no extra checkpoint.

 

Setting Up Your Environment

 
Hardware minimums:

 

Feature Minimum Recommended
GPU VRAM 6 GB (bfloat16) 8 GB+ (RTX 3060 or better)
System RAM 16 GB 32 GB
Disk 8 GB free 20 GB+ SSD
Apple Silicon M2 8 GB M2 Pro / M3 16 GB

 

CPU-only works. Expect roughly 3x slower inference for text-to-speech (TTS) synthesis and 5—8 tokens/second on generation tasks depending on your machine. Fine-tuning on CPU is impractical; use Google Colab's free T4 GPU if you don't have a local GPU.

Python and packages:

# Python 3.10 or newer required
python --version

# Create and activate a virtual environment
python -m venv smollm-env
source smollm-env/bin/activate       # macOS / Linux
smollm-env\Scripts\activate          # Windows

# Install all dependencies
pip install \
  "transformers>=4.53.0" \
  "torch>=2.3.0" \
  "accelerate>=0.30.0" \
  "bitsandbytes>=0.43.0" \
  "sentencepiece" \
  "trl>=0.9.0" \
  "peft>=0.11.0" \
  "datasets>=2.19.0"

 

Note: transformers>=4.53.0 is required; SmolLM3's modeling code shipped in that release. Earlier versions will fail with an unrecognized architecture error.

 

Device detection helper (run this first):

# device_check.py
# Run this before anything else to confirm your setup and pick the right dtype.

def detect_device():
    """
    Detect the best available compute device.
    Returns (device_str, dtype_str, load_kwargs) for use with from_pretrained.
    """
    try:
        import torch
    except ImportError:
        raise RuntimeError("PyTorch not found. Install with: pip install torch")

    if torch.cuda.is_available():
        vram_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
        print(f"CUDA GPU detected: {torch.cuda.get_device_name(0)} ({vram_gb:.1f} GB VRAM)")
        # bfloat16 is recommended for SmolLM3 -- it's the training dtype
        return "cuda", torch.bfloat16, {"device_map": "auto", "torch_dtype": torch.bfloat16}

    elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
        print("Apple Silicon MPS detected")
        # MPS supports float16 but not all bfloat16 ops -- use float16 on Apple Silicon
        return "mps", torch.float16, {"device_map": "mps", "torch_dtype": torch.float16}

    else:
        print("No GPU found -- running on CPU (slower but functional)")
        return "cpu", torch.float32, {"device_map": "cpu", "torch_dtype": torch.float32}


if __name__ == "__main__":
    device, dtype, kwargs = detect_device()
    print(f"Device : {device}")
    print(f"Dtype  : {dtype}")
    print(f"Kwargs : {kwargs}")

 

How to run:

python device_check.py

 

Expected output (NVIDIA GPU example):

CUDA GPU detected: NVIDIA GeForce RTX 3060 (12.0 GB VRAM)
Device : cuda
Dtype  : torch.bfloat16
Kwargs : {'device_map': 'auto', 'torch_dtype': torch.bfloat16}

 

Loading SmolLM3 and Running Your First Inference

 
With the environment confirmed, here's the complete load-and-generate pattern. This covers dtype selection, device_map="auto" for multi-GPU or CPU offload, and both thinking modes side by side.

# first_inference.py
# Prerequisites: transformers>=4.53.0, torch, accelerate
# Run: python first_inference.py

import re
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL_ID = "HuggingFaceTB/SmolLM3-3B"

# ── 1. Load tokenizer and model ───────────────────────────────────────────────

print(f"Loading {MODEL_ID}...")

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    torch_dtype=torch.bfloat16,    # Match the training dtype; use float16 on Apple Silicon
    device_map="auto",             # Spreads across all available GPUs, or CPU if none
)
model.eval()

print(f"Model loaded on: {model.device}")

# ── 2. Generation helper ──────────────────────────────────────────────────────

def generate(messages: list[dict], max_new_tokens: int = 512) -> str:
    """
    Apply the SmolLM3 chat template, tokenize, generate, and decode.
    Strips the ... block from the output automatically
    so callers always receive the final answer only.
    """
    # apply_chat_template formats messages using SmolLM3's built-in chat template
    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
    )
    inputs = tokenizer(text, return_tensors="pt").to(model.device)

    with torch.no_grad():
        output_ids = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            temperature=0.6,   # Recommended by the SmolLM3 team for balanced output
            top_p=0.95,        # Nucleus sampling -- keeps output focused without being repetitive
            do_sample=True,
        )

    # Decode only the newly generated tokens, not the input prompt
    new_tokens = output_ids[0][inputs["input_ids"].shape[-1]:]
    raw = tokenizer.decode(new_tokens, skip_special_tokens=True)

    # Strip the chain-of-thought block if present.
    # In think mode the model prefixes its response with ....
    # Callers usually only need the final answer that follows.
    final = re.sub(r".*?", "", raw, flags=re.DOTALL).strip()
    return final


# ── 3. Compare think vs no_think on the same prompt ──────────────────────────

prompt = "A customer is charged twice for the same order. What are three concrete steps support should take?"

# no_think: fast, direct answer -- good for high-throughput classification and replies
no_think_messages = [
    {"role": "system", "content": "/no_think"},
    {"role": "user",   "content": prompt},
]

# think: reasoning trace before answer -- good for complex decisions and edge cases
think_messages = [
    {"role": "system", "content": "/think"},
    {"role": "user",   "content": prompt},
]

print("\n── no_think mode ──")
print(generate(no_think_messages, max_new_tokens=256))

print("\n── think mode ──")
print(generate(think_messages, max_new_tokens=512))

 

How to run:

python first_inference.py

 

The model downloads to ~/.cache/huggingface/hub/ on first run (~6.7 GB). On subsequent runs, it loads from cache in a few seconds.

When you compare the two outputs, think mode produces a noticeably more structured answer; it reasons through the steps before committing. no_think is faster and often sufficient for routine tasks. The right mode depends on your latency budget and task complexity. For the ticket router project coming next, we'll use no_think for classification (latency-sensitive) and think for escalation decisions (accuracy-sensitive).

 

Building a Multilingual Support Ticket Router

 
Now the core project. The TicketRouter class takes a support ticket in any of SmolLM3's six natively supported languages (English, French, Spanish, German, Italian, Portuguese), classifies it into a category, generates a reply in the ticket's own language, and flags low-confidence outputs for human review.

This is a pattern used at scale in real support operations. The SmolLM3 version runs entirely offline, with no API key, no data leaving the server, and no per-ticket cost. That matters for any support system handling personally identifiable information (PII).

# ticket_router.py
# Prerequisites: transformers>=4.53.0, torch, accelerate
# Run: python ticket_router.py

import re
import json
import torch
from dataclasses import dataclass
from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL_ID      = "HuggingFaceTB/SmolLM3-3B"
ESCALATE_AT   = 0.70   # Tickets with confidence below this go to a human agent

# ── Data class for a routing result ──────────────────────────────────────────

@dataclass
class RoutingResult:
    ticket: str
    category: str           # billing | technical | account | general
    confidence: float       # 0.0-1.0 self-reported by the model
    reply: str              # Generated in the same language as the ticket
    escalate: bool          # True when confidence < ESCALATE_AT
    raw_output: str         # Full model output for debugging


# ── System prompt ─────────────────────────────────────────────────────────────

SYSTEM_PROMPT = """You are a multilingual customer support router for a SaaS company.
Your job is to classify support tickets and draft a helpful, professional reply.

Rules:
- Detect the language of the ticket automatically.
- Classify into EXACTLY ONE of: billing, technical, account, general.
- Reply in the SAME language as the ticket.
- Rate your confidence honestly from 0.0 to 1.0. Low confidence means the ticket is ambiguous or outside your knowledge.
- Respond ONLY with a single JSON object -- no preamble, no explanation outside the JSON.

Required format:
{"category": "", "confidence": <0.0-1.0>, "reply": ""}"""


# ── Router class ──────────────────────────────────────────────────────────────

class TicketRouter:
    def __init__(self, model_id: str = MODEL_ID):
        print(f"Loading {model_id}...")
        self.tokenizer = AutoTokenizer.from_pretrained(model_id)
        self.model = AutoModelForCausalLM.from_pretrained(
            model_id,
            torch_dtype=torch.bfloat16,
            device_map="auto",
        )
        self.model.eval()
        print(f"Ready on {self.model.device}")

    def _call_model(self, ticket: str) -> str:
        """
        Format the ticket into a chat message, run inference in no_think mode
        (faster for classification), and return the raw decoded output.
        """
        messages = [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user",   "content": ticket},
        ]
        text = self.tokenizer.apply_chat_template(
            messages,
            tokenize=False,
            add_generation_prompt=True,
            enable_thinking=False,   # Fast path -- no chain-of-thought for routine classification
        )
        inputs = self.tokenizer(text, return_tensors="pt").to(self.model.device)

        with torch.no_grad():
            output_ids = self.model.generate(
                **inputs,
                max_new_tokens=256,
                temperature=0.3,   # Lower temp for classification -- more deterministic output
                top_p=0.9,
                do_sample=True,
            )

        new_tokens = output_ids[0][inputs["input_ids"].shape[-1]:]
        return self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip()

    def _parse_output(self, raw: str) -> dict:
        """
        Extract the JSON object from the model's output.
        Falls back to a default 'general' category with zero confidence if parsing fails.
        This prevents a JSON parse failure from crashing the pipeline.
        """
        # Find any JSON object in the output, even if surrounded by stray text
        match = re.search(r"\{.*?\}", raw, re.DOTALL)
        if not match:
            return {"category": "general", "confidence": 0.0, "reply": raw}
        try:
            return json.loads(match.group())
        except json.JSONDecodeError:
            return {"category": "general", "confidence": 0.0, "reply": raw}

    def route(self, ticket: str) -> RoutingResult:
        """
        Route a single ticket. Returns a RoutingResult with classification,
        confidence, reply, and escalation flag.
        """
        raw = self._call_model(ticket)
        parsed = self._parse_output(raw)

        category   = parsed.get("category", "general")
        confidence = float(parsed.get("confidence", 0.0))
        reply      = parsed.get("reply", "Thank you for reaching out. We will follow up shortly.")

        return RoutingResult(
            ticket=ticket,
            category=category,
            confidence=confidence,
            reply=reply,
            escalate=confidence < ESCALATE_AT,
            raw_output=raw,
        )

    def route_batch(self, tickets: list[str]) -> list[RoutingResult]:
        """Route a list of tickets sequentially. Returns results in input order."""
        return [self.route(t) for t in tickets]


# ── Run it ────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    router = TicketRouter()

    test_tickets = [
        "I was charged twice for my subscription this month. Please refund the duplicate charge.",
        "L'application se bloque chaque fois que j'essaie d'exporter un fichier PDF.",   # French
        "No puedo iniciar sesión en mi cuenta desde hace dos días.",                     # Spanish
        "Die Rechnung für März fehlt in meinem Abrechnungsbereich.",                     # German
        "Il mio abbonamento non si rinnova automaticamente nonostante il pagamento.",    # Italian
    ]

    print("\n" + "=" * 70)
    results = router.route_batch(test_tickets)

    for r in results:
        flag = "🔴 ESCALATE" if r.escalate else "🟢 AUTO"
        print(f"\n{flag}")
        print(f"Ticket     : {r.ticket[:70]}...")
        print(f"Category   : {r.category}")
        print(f"Confidence : {r.confidence:.2f}")
        print(f"Reply      : {r.reply[:100]}...")

    escalated = [r for r in results if r.escalate]
    print(f"\n{'─'*70}")
    print(f"Total tickets : {len(results)}")
    print(f"Auto-routed   : {len(results) - len(escalated)}")
    print(f"Escalated     : {len(escalated)}")

 

How to run:

python ticket_router.py

 

What to look for in the output: tickets where the model returns a confidence below 0.70 will be flagged for escalation. Ambiguous tickets, short messages, mixed-language content, and requests that could fit two categories reliably produce lower confidence scores. That's the signal you want: the model being honest about uncertainty rather than guessing confidently and propagating a wrong classification downstream.

 

Adding Tool Calling to SmolLM3

 
The ticket router works well for classification and reply generation. But what happens when a customer asks about a specific order? The model doesn't have access to your database. Without tool calling, it either hallucinates an answer or deflects with "please contact support" — neither of which is useful.

SmolLM3 supports tool calling natively. You define a tool as a JSON Schema, pass it via xml_tools in the chat template, and the model emits a structured <tool_call> block when it decides the tool is needed. You parse that block, call the real function, inject the result, and let the model generate the final response.

Here's the full round-trip for an order lookup:

# tool_calling.py
# Prerequisites: transformers>=4.53.0, torch, accelerate
# Run: python tool_calling.py

import re
import json
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL_ID  = "HuggingFaceTB/SmolLM3-3B"
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model     = AutoModelForCausalLM.from_pretrained(
    MODEL_ID, torch_dtype=torch.bfloat16, device_map="auto"
)
model.eval()

# ── Tool definition ───────────────────────────────────────────────────────────
# SmolLM3 accepts tool definitions as JSON Schema objects under xml_tools.
# The model uses the name and description to decide when to call the tool.
# The parameters schema tells it what arguments to include in the call.

TOOLS = [
    {
        "name": "lookup_order_status",
        "description": (
            "Look up the current status, estimated delivery date, and carrier "
            "for a specific customer order. Call this when the customer mentions "
            "an order number or asks where their order is."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "The order ID, usually in the format ORD-XXXXXX."
                }
            },
            "required": ["order_id"]
        }
    }
]

# ── Simulated order database ──────────────────────────────────────────────────

def lookup_order_status(order_id: str) -> dict:
    """
    In production, replace this with a real database or API call.
    Returns a dict the model can read and summarize for the customer.
    """
    database = {
        "ORD-4821": {"status": "shipped",    "eta": "June 18, 2026", "carrier": "DHL"},
        "ORD-3307": {"status": "processing", "eta": "June 20, 2026", "carrier": None},
        "ORD-1190": {"status": "delivered",  "eta": None,            "carrier": "FedEx"},
    }
    return database.get(order_id, {"status": "not_found", "eta": None, "carrier": None})

# ── Tool call parser ──────────────────────────────────────────────────────────

def parse_tool_call(output: str):
    """
    Extract a tool call from the model's output.
    SmolLM3 emits: {"name": "...", "arguments": {...}}
    Returns (tool_name, arguments) or (None, None) if no tool call is present.
    """
    match = re.search(r"(.*?)", output, re.DOTALL)
    if not match:
        return None, None
    try:
        payload = json.loads(match.group(1).strip())
        return payload.get("name"), payload.get("arguments", {})
    except json.JSONDecodeError:
        return None, None

# ── Full tool-call round trip ─────────────────────────────────────────────────

def respond_with_tools(user_message: str) -> str:
    """
    Full agentic loop:
    1. Send user message + tool definitions to the model.
    2. If the model emits a tool call, execute it and inject the result.
    3. Generate the final customer-facing response.
    """
    # Turn 1: give the model the user message and available tools
    messages = [{"role": "user", "content": user_message}]

    inputs = tokenizer.apply_chat_template(
        messages,
        xml_tools=TOOLS,           # Pass tool definitions here
        enable_thinking=False,
        add_generation_prompt=True,
        tokenize=True,
        return_tensors="pt",
    ).to(model.device)

    with torch.no_grad():
        output_ids = model.generate(
            inputs, max_new_tokens=256, temperature=0.3, top_p=0.9, do_sample=True
        )
    turn1 = tokenizer.decode(
        output_ids[0][inputs.shape[-1]:], skip_special_tokens=True
    )

    # Check if the model wants to call a tool
    tool_name, tool_args = parse_tool_call(turn1)

    if tool_name == "lookup_order_status":
        # Execute the real function
        tool_result = lookup_order_status(**tool_args)
        print(f"  [Tool called] {tool_name}({tool_args}) → {tool_result}")

        # Turn 2: inject the tool result and ask for the final response
        messages += [
            {"role": "assistant", "content": turn1},
            {"role": "tool",      "content": json.dumps(tool_result), "name": tool_name},
        ]
        inputs2 = tokenizer.apply_chat_template(
            messages,
            xml_tools=TOOLS,
            enable_thinking=False,
            add_generation_prompt=True,
            tokenize=True,
            return_tensors="pt",
        ).to(model.device)

        with torch.no_grad():
            output_ids2 = model.generate(
                inputs2, max_new_tokens=256, temperature=0.3, top_p=0.9, do_sample=True
            )
        return tokenizer.decode(
            output_ids2[0][inputs2.shape[-1]:], skip_special_tokens=True
        ).strip()

    # No tool call -- model answered directly
    return turn1.strip()


# ── Test it ───────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    queries = [
        "Where is my order ORD-4821? It's been a week.",
        "My order ORD-3307 hasn't shipped yet -- what's the status?",
        "I just want to change my email address.",  # No tool needed
    ]

    for query in queries:
        print(f"\nCustomer : {query}")
        response = respond_with_tools(query)
        print(f"Agent    : {response}")

 

How to run:

python tool_calling.py

 

The model routes order-related queries through the lookup_order_status tool and generates the final reply using the real database result. For the email-change query, it answers directly without calling any tool. That selective invocation — calling tools only when they're needed — is what makes the agentic pattern practical.

 

Fine-Tuning SmolLM3 on Domain Data

 
A 3B model is small enough to fine-tune on a single consumer GPU in minutes, not hours. The result is a model that knows your domain vocabulary, your response style, and your escalation logic, instead of relying on prompt engineering to approximate it at every inference call.

This section uses the TRL library's SFTTrainer with LoRA adapters from PEFT, which means we're training only a small fraction of parameters — typically under 1% — and merging the adapter back into the base model at the end.

# finetune.py
# Additional prerequisites: pip install trl>=0.9.0 peft>=0.11.0 datasets>=2.19.0
# Run: python finetune.py
# Time: ~8-12 minutes on an RTX 3060 for 3 epochs over 50 examples

import json
import torch
from datasets import Dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig

MODEL_ID   = "HuggingFaceTB/SmolLM3-3B"
OUTPUT_DIR = "./smollm3-ticket-router"

# ── System prompt (same as the inference router) ──────────────────────────────

SYSTEM_PROMPT = """You are a multilingual customer support router for a SaaS company.
Classify the support ticket and generate a helpful reply in the same language as the ticket.
Respond ONLY with JSON: {"category": "", "confidence": <0.0-1.0>, "reply": ""}"""

# ── Training data ─────────────────────────────────────────────────────────────
# In production you would load hundreds of real labelled tickets.
# This minimal set demonstrates the format -- expand with your real data.

raw_examples = [
    ("I was charged twice for my subscription.", "billing",
     "We're sorry for the duplicate charge. Our billing team will review and issue a refund within 3-5 business days."),
    ("The app crashes every time I try to export a PDF.", "technical",
     "We apologize for the inconvenience. Our engineering team has been notified and will investigate."),
    ("I can't log into my account since yesterday.", "account",
     "We're sorry you're having trouble. Please try resetting your password. If the issue continues, we'll escalate to our account team."),
    ("Die App stürzt beim Exportieren von PDFs ab.", "technical",
     "Wir entschuldigen uns für die Unannehmlichkeiten. Unser Technikteam wurde benachrichtigt und untersucht das Problem."),
    ("L'application se bloque quand j'exporte un fichier.", "technical",
     "Nous nous excusons pour la gêne occasionnée. Notre équipe technique a été informée et travaille sur ce problème."),
    ("My March invoice is missing from the billing section.", "billing",
     "Thank you for flagging this. Our billing team will locate your March invoice and resend it within 24 hours."),
    ("No puedo iniciar sesión desde ayer por la noche.", "account",
     "Lamentamos el problema de acceso. Por favor, restablezca su contraseña. Si el problema persiste, escalaremos su caso."),
    ("How do I upgrade my plan to the Pro tier?", "general",
     "You can upgrade to Pro directly from Settings → Subscription. The new rate applies from your next billing cycle."),
]

def format_example(ticket: str, category: str, reply: str) -> dict:
    """
    Format a single example into the SmolLM3 messages format.
    The assistant turn contains the target JSON the model should learn to produce.
    """
    return {
        "messages": [
            {"role": "system",    "content": SYSTEM_PROMPT},
            {"role": "user",      "content": ticket},
            {"role": "assistant", "content": json.dumps({
                "category": category, "confidence": 0.95, "reply": reply
            })},
        ]
    }

dataset = Dataset.from_list([format_example(*ex) for ex in raw_examples])

# ── Tokenizer ─────────────────────────────────────────────────────────────────

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
tokenizer.pad_token = tokenizer.eos_token   # SmolLM3 has no separate pad token

# ── Model (4-bit quantized base for QLoRA) ────────────────────────────────────

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    quantization_config=bnb_config,
    device_map="auto",
)

# ── LoRA config ───────────────────────────────────────────────────────────────
# We target the attention and MLP projection layers -- these carry the most
# task-specific signal and give the best accuracy/parameter trade-off.

lora_config = LoraConfig(
    r=16,              # Rank of the LoRA update matrices -- higher = more expressive, more memory
    lora_alpha=32,     # Scaling factor; conventionally set to 2*r
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",   # Attention projections
        "gate_proj", "up_proj", "down_proj",        # MLP projections (SwiGLU)
    ],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Expected: trainable params: ~13M (0.4% of 3B total)

# ── Training config ───────────────────────────────────────────────────────────

sft_config = SFTConfig(
    output_dir=OUTPUT_DIR,
    num_train_epochs=3,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,   # Effective batch size = 8
    learning_rate=2e-4,
    warmup_ratio=0.1,
    lr_scheduler_type="cosine",
    bf16=True,
    logging_steps=5,
    save_strategy="epoch",
    max_seq_length=512,              # Tickets are short -- no need for the full context window
)

# ── Train ─────────────────────────────────────────────────────────────────────

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    args=sft_config,
)
trainer.train()

# ── Save and merge ────────────────────────────────────────────────────────────
# Save the LoRA adapter -- small file, easy to share or version.
trainer.save_model(f"{OUTPUT_DIR}/adapter")

# Merge the adapter back into the base model weights for standalone deployment.
# The merged model loads exactly like the base model -- no PEFT dependency at inference.
merged = model.merge_and_unload()
merged.save_pretrained(f"{OUTPUT_DIR}/merged")
tokenizer.save_pretrained(f"{OUTPUT_DIR}/merged")

print(f"\nFine-tuned model saved to {OUTPUT_DIR}/merged")
print("Load it with: AutoModelForCausalLM.from_pretrained('./smollm3-ticket-router/merged')")

 

How to run:

pip install trl>=0.9.0 peft>=0.11.0 datasets>=2.19.0
python finetune.py

 

Expected training output:

trainable params: 13,631,488 || all params: 3,085,123,584 || trainable%: 0.4420
{'loss': 1.842, 'learning_rate': 2e-04, 'epoch': 0.5}
{'loss': 0.923, 'learning_rate': 1.4e-04, 'epoch': 1.0}
{'loss': 0.461, 'learning_rate': 6e-05, 'epoch': 2.0}
{'loss': 0.287, 'learning_rate': 0.0, 'epoch': 3.0}

 

Fine-tuned model saved to ./smollm3-ticket-router/merged.

The loss dropping from 1.8 to 0.3 across three epochs tells you the model is learning the task format. On real data (hundreds of examples across your specific categories), you'll see the classification accuracy and reply quality improve noticeably compared to the base model with prompt engineering alone.

After training, swap MODEL_ID in ticket_router.py for "./smollm3-ticket-router/merged" and you're running your domain-tuned router.

 

Conclusion

 
SmolLM3 makes the case that parameter count is not the primary metric. A 3B model trained on 11.2 trillion tokens with the right architectural choices — grouped query attention (GQA), NoPE, and dual-mode reasoning — delivers production-viable results on focused tasks at a fraction of the latency, cost, and hardware requirements of 70B alternatives.

The ticket router project in this article covers the full production pattern: load once, route many, escalate on low confidence, call tools for live data, fine-tune on domain data, and quantize for constrained hardware. Each of those techniques applies to any focused natural language processing (NLP) task. Swap the ticket examples for your domain, adjust the category labels, and you have a foundation worth deploying.

The SmolLM3 GitHub repo has the full training code, data mixture details, and evaluation configs. The model page has the benchmark tables in full and the quantized model collection. The SmolLM3 blog post covers the training decisions in depth if you want to understand the architectural choices before building on top of them.

Resources:

 
 

Shittu Olumide is a software engineer and technical writer passionate about leveraging cutting-edge technologies to craft compelling narratives, with a keen eye for detail and a knack for simplifying complex concepts. You can also find Shittu on Twitter.


Get the FREE ebook 'KDnuggets Artificial Intelligence Pocket Dictionary' along with the leading newsletter on Data Science, Machine Learning, AI & Analytics straight to your inbox.

By subscribing you accept KDnuggets Privacy Policy


Get the FREE ebook 'KDnuggets Artificial Intelligence Pocket Dictionary' along with the leading newsletter on Data Science, Machine Learning, AI & Analytics straight to your inbox.

By subscribing you accept KDnuggets Privacy Policy

Get the FREE ebook 'KDnuggets Artificial Intelligence Pocket Dictionary' along with the leading newsletter on Data Science, Machine Learning, AI & Analytics straight to your inbox.

By subscribing you accept KDnuggets Privacy Policy

No, thanks!