Module 17 — Small & Local LLM Models¶
Time: 5–7 days · Depends on: 01, 05, 10 · Pairs with: tracks using Phi / Ollama · Next: Agent design patterns · Agents on SLMs: 24 Local-first
Learning objectives¶
- Match small language models (SLMs) to tasks they can actually own
- Run local inference with Ollama, llama.cpp, and/or vLLM
- Apply quantization deliberately and re-eval quality after every compress step
- Size a model to limited hardware (RAM/VRAM, KV cache, one resident model) so the laptop stays out of swap
- Build a router that sends easy work to SLMs and hard work to larger models
What you can build¶
- Offline / private assistant over local files
- Cheap classifier or router in front of a large model
- Quantized deployment on a laptop or small GPU with measured quality
Why this matters (CS engineer)¶
Finance wants the bill cut in half. The team swaps every call to a 3B local model “because demos looked fine,” then quantizes to Q4 so it fits on a laptop GPU. Schema pass rate on extraction collapses; the agent loops on tools the small model cannot plan. There was no router, no re-eval after quant, and no list of tasks the SLM actually owns. Cost went down; product quality and on-call load went up. The fix was not “bigger GPU” — it was specialist first-line + escalate, with golden metrics as the gate.
Not every token deserves a frontier model. Most production traffic is classification, routing, extraction, short rewrite, and retrieval-augmented lookup — tasks where a 1B–8B-class model (or a “mini” cloud tier) wins on latency, cost, and privacy. CS engineers who only know one cloud chat API overspend and cannot ship air-gapped or VPC-only features.
SLMs are not “GPT but free.” They need tighter prompts, harder validation, and honest evals. Treated as specialized workers in a system (Module 10 routing, Module 16 hybrid), they are one of the highest-ROI tools in the stack.
Mental model¶
flowchart TB
U[User task] --> R[Router SLM or rules]
R -->|easy / high confidence| S[Local or mini SLM]
R -->|hard / low confidence / schema fail| L[Large model]
S --> V[Validate schema / policy]
L --> V
V -->|fail| L
V -->|pass| Out[Response]
Q[Quantize weights] --> S
E[Golden eval] --> Q
E --> R
Invariant: choose SLMs by task fit + measured quality, not by parameter count marketing. Always re-run golden evals after quantization or prompt changes.
Intuition lock
Sticky picture: an SLM is a specialist intern — fast, cheap, great on narrow labeled work. A frontier model is the senior consultant you escalate to when confidence is low or the schema fails. Quantize, then re-eval (never blog-trust a quant level). Privacy and latency economics often justify local run even when raw accuracy is a few points lower on the right tasks.
Kill this idea: “Small model = free GPT for everything.” Parameter count marketing is not a task assignment. Without routing and validation you only move failure modes around.
1. Strengths and limits¶
| Advantages | Limits |
|---|---|
| Lower latency and $ per call | Weaker multi-step / long-horizon reasoning |
| Privacy and air-gap options | Smaller context (model-dependent) |
| Fine-tune / LoRA friendly on modest hardware | Brittle to sloppy or huge prompts |
| Edge and on-prem control | Gaps in world knowledge and some multilingual settings |
| Great as routers and extractors | Tool-heavy agents may thrash without strong validation |
Modern families to evaluate (verify latest model cards): Phi-class, Llama 3.x compact sizes, Gemma, Qwen2.5 smaller sizes, Mistral small models, and cloud “mini/haiku/flash” tiers when local is not required.
Explainer · small ≠ weak at everything
A 3B model that only outputs one of five labels with a strict schema can beat a frontier model on cost-adjusted reliability for that task. A 70B model asked to “handle the ticket” with no structure can still fail product SLOs. Task design and validation often matter more than raw size.
2. Local runtimes¶
| Runtime | Fit | Notes |
|---|---|---|
| Ollama | Dev laptop ergonomics | Pull/run UX; good default for learning |
| llama.cpp | CPU / Apple Metal, GGUF | Fine control of quant and threads |
| vLLM / TGI | Throughput serving on GPU | Batching, continuous batching for multi-user |
| LM Studio | GUI exploration | Fast qualitative checks |
# Ollama quickstart (install from ollama.com first)
ollama pull llama3.2
ollama run llama3.2 "Summarize RAG in 3 bullets."
# OpenAI-compatible local endpoint (typical pattern)
# curl http://localhost:11434/v1/chat/completions ...
# Conceptual OpenAI-compatible client pointed at local server
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
def local_chat(prompt: str, model: str = "llama3.2") -> str:
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
)
return resp.choices[0].message.content or ""
Serving note: for multi-user prod on GPU, prefer vLLM/TGI-style servers; Ollama is excellent for dev and small deployments but measure concurrency limits before betting the company on it.
3. Prompting SLMs well¶
- Shorter instructions; explicit output formats
- More few-shot when structure is fragile
- Decompose → many small calls beat one giant reasoning soup
- Validate hard (JSON schema, enum labels, regex)
- Prefer temperature 0–0.2 for extract/classify
def classify_short(text: str, labels: list[str], llm) -> str:
label_line = ", ".join(labels)
prompt = (
f"Classify into exactly one label: {label_line}.\n"
f"Reply with the label only.\n\nText: {text}"
)
out = llm(prompt).strip()
if out not in labels:
raise ValueError(f"invalid_label:{out!r}")
return out
On schema failure: retry once with a repair prompt, then escalate to a larger model (router pattern below).
Think · when not to use an SLM
Reveal: red flags that you need a larger model (or a non-LLM system)
- Multi-hop reasoning over long, conflicting documents without a strong RAG scaffold - Open-ended strategy / novel coding on large repos - Safety-critical nuance where small models fail your golden **risk** cases - Tasks already solved better by rules, SQL, or classical ML classifiers If a deterministic extractor works, do not pay for tokens — small or large.4. Quantization¶
Quantization reduces weight precision so models fit in RAM/VRAM and run faster — at a quality cost you must measure.
A weight that was stored as 16-bit floating point (many possible values, smooth math) is stored as 8- or 4-bit integers (a short menu of values plus a scale). Matmul still works; the numbers are just coarser. That coarseness shows up first on brittle tasks: multi-digit arithmetic, strict JSON, long-horizon tool plans. Sentiment and short classify often look fine until a cliff. That is why “Q4 is fine” is not a product statement until your golden set says so.
| Approach | Notes |
|---|---|
| 8-bit / 4-bit weights | Big VRAM wins; measure task metrics |
| GGUF Q4 / Q5 / Q6 | Common for llama.cpp / Ollama |
| Speculative decoding | Draft small model + verify large (advanced speed) |
| Distillation | Teacher large → student small (training pipeline) |
FP16 model → quantize Q4 → golden eval
│
├─ pass SLO → ship Q4
└─ fail → try Q5/Q8, different model, or keep FP16 for hard path only
Rules
- Never ship a quant level because a blog said “Q4 is fine.”
- Re-run your golden set (Module 04) after every quant change.
- Watch refuse / schema failure rate, not only average “vibe.”
- You may run Q4 for router and higher precision for final answer on the same host.
Explainer · quality cliffs
Quantization error is uneven: some tasks (sentiment, short classify) stay flat until aggressive quants; others (multi-digit reasoning, brittle JSON) fall off a cliff. Plot metric vs quant level. The right product answer is often mixed precision routing, not one global quant for every call.
5. Router pattern (highest ROI)¶
User → cheap SLM router / rules
├─ easy → SLM answer (+ validate)
└─ hard / low confidence / validation fail → large model
from dataclasses import dataclass
@dataclass
class RouteDecision:
model: str
reason: str
class ModelRouter:
def __init__(self, cheap: str, strong: str):
self.cheap = cheap
self.strong = strong
def pick(self, task: str, prompt: str, *, confidence: float | None = None) -> RouteDecision:
if task in {"classify", "route", "extract_fields"}:
return RouteDecision(self.cheap, "narrow_task")
if task == "complex_reason" or len(prompt) > 8000:
return RouteDecision(self.strong, "hard_or_long")
if confidence is not None and confidence < 0.6:
return RouteDecision(self.strong, "low_confidence")
return RouteDecision(self.cheap, "default_cheap")
def generate_with_escalation(task: str, prompt: str, llms: dict, router: ModelRouter) -> str:
decision = router.pick(task, prompt)
text = llms[decision.model](prompt)
# example: escalate if JSON parse fails
if task == "extract_fields":
try:
import json
json.loads(text)
except Exception:
text = llms[router.strong](prompt)
return text
Combine with Module 10 cost ledgers: track % escalated, $ per successful task, and quality on a fixed eval set.
Think · economics of escalate
Reveal: when is a high escalate rate still a win?
If 80% of traffic is cheap classify/extract that the SLM nails, and 20% escalates to a large model, blended **$ per success** and p95 can still beat “always large” — *if* escalate catches the hard tail and quality SLOs hold. A 90% escalate rate means your router is noise: fix task labels, prompts, or stop pretending the SLM owns the hard path. Track escalate rate next to quality; optimize the blend, not “never call large.”6. SLMs + RAG + privacy¶
Local models shine when documents must not leave the device or VPC:
- Embed and retrieve on-prem (or on-laptop).
- Generate with local SLM grounded on retrieved chunks.
- Keep audit logs local (Module 14).
Still apply injection hygiene (Module 02): retrieved text is data, not instructions. Small models can be more suggestible — validation and allowlisted tools matter more, not less.
7. Working effectively on limited hardware¶
This course assumes a laptop, often no discrete GPU. That is a product constraint, not an apology. A 3B model that stays in RAM and answers in 200 ms will beat an 8B that thrashes swap and fans for 40 seconds — on quality and on whether you actually use it.
Intuition lock
Sticky picture: RAM is a loading dock, not a warehouse. Weights + KV cache + OS have to stand on it at once. Swap is shipping the dock to another city between tokens. Context length is rent on the dock (KV cache grows with every token). One resident model is one truck; two 7Bs is a traffic jam.
Kill this idea: “Pull the biggest GGUF that Ollama will download; RAM will figure it out.” → Replace with: Fit weights + KV + ~5 GB OS headroom. Cap num_ctx. Keep one model loaded. Prefer a smaller model that is hot in RAM over a larger one that is cold on disk.
RAM is the limiter¶
working set ≈ weights + KV cache + runtime + OS
weights ≈ params_B × (bits / 8) GB (Q4 ≈ 0.5 byte/param)
KV cache grows with context × layers × batch (often the surprise)
from src.local_agents import HardwareBudget, recommend_local_setup, weight_gb
assert weight_gb(8.0, bits=4) == 4.0 # 8B Q4 ≈ 4 GB of weights
fit = recommend_local_setup(HardwareBudget(ram_gb=16))
# LocalFit(params_b=8.0, quant="Q4", max_ctx=4096, ...)
recommend_local_setup is a teaching table, not a profiler. It reserves ~5 GB for macOS/Windows, the browser, and Python. Re-eval after you pick a real GGUF — cards lie about “fits in 8 GB” because they forgot KV cache and Chrome.
| Machine | Honest local default | Do not |
|---|---|---|
| 8 GB RAM, CPU/Metal | 1–3B Q4, num_ctx 2k, one model |
7B Q4 (will swap) + a second model |
| 16 GB | 7–8B Q4, num_ctx 4k |
13B Q4 + 32k context “for RAG” |
| 32 GB | 8B Q8 or 13–14B Q4, ctx 8k if evals hold | Two 13Bs resident |
| 6–8 GB VRAM dGPU | Offload 7B Q4 layers to GPU; short ctx | Full 13B FP16 |
| No GPU, old CPU | 1B–3B Q4; fewer threads than you think | Benchmarking while compiling Chromium |
Apple Silicon: Metal is the reason 7–8B Q4 is pleasant. x86 laptop CPU: expect single-digit to low tens of tok/s — still enough for classify/extract, painful for long chat. Measure tok/s after the second prompt (first prompt pays load + compile).
Explainer · why context eats RAM
Weights are mostly fixed. The KV cache is per token of context (keys and values for every layer). Doubling num_ctx can add more RAM than dropping one quant level saves. A “32k context” 7B on 16 GB often loses to a 4k context 7B that actually stays resident. Module 05 packing is a hardware feature here: retrieve 3 chunks, not 30.
Knobs that matter on a laptop¶
| Knob | What to do | Why |
|---|---|---|
| One resident model | ollama stop extras; don’t keep 8B + 3B + embedder if RAM is tight |
Each model’s weights sit in RAM/VRAM |
num_ctx / n_ctx |
Set to what you use (2k–4k for SLM tasks) | KV cache |
num_predict / max tokens |
Cap completions (64–256 for extract/classify) | Latency and RAM |
| Threads | Physical cores, not “all logical” | Oversubscription thrashes |
| mmap | Keep on (llama.cpp default) | OS pages weights; don’t force a full copy |
| Keep-alive | Keep the one model loaded while you work; unload overnight | Avoid reload tax vs RAM hog |
| Batch = 1 | Interactive laptop | Throughput servers (vLLM) are a different machine |
| Embedder | Tiny (e.g. <100M) or hash/keyword until RAM allows | A 7B plus a large embedder is two models |
Ollama-shaped example (names vary; check ollama help):
# Prefer a tag that matches your RAM (see table), then pin context
ollama run llama3.2 # 3B-class; good 8–16 GB default
# In a Modelfile / API options:
# num_ctx: 2048
# num_predict: 128
# num_thread: 4 # set to physical cores
Swap is a stop-the-line signal. If Activity Monitor / htop shows swap climbing while you generate, the model is too big. Shrink params, quant, or context — do not “give it a few more minutes.” Swap-backed inference is slower than a cloud mini and wrecks the SSD.
Thermals: laptop CPU/GPU will throttle. Do not publish tok/s from the first 10 seconds on a cold chassis. Steady state after a minute is the number that matters.
Prompt and system design that small hardware can survive¶
Hardware limits and prompt limits are the same list:
- Short system prompt. A 2k sermon leaves no room for the user task in a 2k window.
- JSON / enums, temperature 0–0.2, few-shot of one compact example — not five essays (already §3).
- Decompose. Five 200-token SLM calls beat one 4k “think hard” call that OOMs the KV cache.
- RAG: top-k 2–4, chunk small, citations required (Modules 07/09). Unbounded retrieve is a RAM attack.
- Agents:
max_steps4–8, truncate tool dumps (Module 11/24). A small model with a god-tool will loop until the fan is the loudest component. - Escalate (this module’s router + Module 24) when JSON fails — that is cheaper than stuffing a 70B into 16 GB.
Think about it
Question: 16 GB Mac, Ollama, you want “repo Q&A.” You can load 8B Q4 at num_ctx=4096 at ~15 tok/s, or 3B Q4 at num_ctx=2048 at ~40 tok/s. Retrieval already returns the right 3 chunks. Which do you ship for classify + short answer, and what do you measure?
Reveal a strong answer
Ship the **smallest model that clears the golden set** at the context you actually pack. If 3B + 3 chunks matches 8B on schema-pass and citation hit, take 3B: more headroom, less swap risk, snappier UI. Measure schema-pass, Hit@k of cites, p95 latency **after warmup**, and whether swap is zero. If 3B fails JSON, try 8B Q4 at 4k *or* escalate that 10% of calls (Module 10/24) — don’t jump to 32k context as a quality fix. Context is RAM.What not to do on this hardware¶
| Temptation | What happens |
|---|---|
| 32k / 128k context “because the card says so” | KV cache evicts the OS; fans; silence |
| LoRA-train 8B overnight on 16 GB | OOM or multi-hour swap; use PEFT on a smaller base or a rented GPU (Module 06) |
| vLLM on a 16 GB laptop | Wrong runtime; that’s a throughput GPU server |
| Three Ollama models idle | 12 GB of weights before the first token |
| Fine-tune instead of retrieve | RAM + time; try RAG first (Module 07) |
Fine-tunes and 13B+ still belong in this course — on comfortable hardware (setup table) or a rented box. The laptop path is specialize, cap context, validate, escalate.
Failure modes¶
| Failure | Symptom | Fix |
|---|---|---|
| One giant prompt to 3B | Gibberish / truncate | Decompose + retrieve less junk |
| Quant without eval | Silent quality drop | Golden suite gate |
| SLM as sole agent brain | Looping tools, bad plans | Router + max steps + large-model escalate |
| Trusting “label only” without check | Free-form prose labels | Enum validate / repair / escalate |
| Undersized context | Lost instructions | Shorter system prompts; external memory |
| Oversized context on 16 GB | Swap, thermal throttle, “model hung” | Cap num_ctx; pack (Module 05); smaller top-k |
| Two models resident | Mystery OOM / 2 tok/s | One hot model; unload the rest |
| Local server open to LAN | Data exposure | Bind localhost / auth / firewall |
Lab¶
Lab · measure before you commit
- Run a 3B–8B-class model locally (Ollama is fine) on 20 golden tasks from your project.
- Score accuracy / schema-pass vs a cloud mini model on the same set.
- Implement confidence or validation routing (schema fail → escalate).
- If you quantize (e.g. compare two GGUF levels), re-run the same 20 and record the delta.
- Write a short decision: which tasks SLM owns, which escalate, and why.
- Run
recommend_local_setup(HardwareBudget(ram_gb=<yours>)). Confirm the model you used in step 1 fits that row (weights + headroom). If Activity Monitor showed swap, drop a quant level or a billion parameters and re-run the 20.
Quizzes¶
Quiz · 25 XP
You quantize a local model from Q8 to Q4 to fit on a laptop. What must you do before shipping?
Quiz · 25 XP
What is usually the highest-ROI production pattern involving SLMs?
Quiz · 25 XP
Your 16 GB laptop starts paging (swap climbing) while a local 13B Q4 generates. What is the right first move?
OSS & further materials¶
| Resource | Why |
|---|---|
| Ollama | Fast local dev loop |
| llama.cpp | GGUF + CPU/Metal control; n_ctx, threads, mmap |
| vLLM | High-throughput GPU serving (not a 16 GB laptop default) |
Course src.local_agents.recommend_local_setup |
Teaching RAM fit table |
| Hugging Face model cards | License, context length, intended use |
| Module 10 Cost optimization | Routing and unit economics |
| Module 04 Testing & evals | Golden sets for quant gates |
Checkpoint¶
- You ran at least one local model end-to-end
- You know which of your tasks SLMs can own
- Quantization (if any) is eval-backed
- You can size a model to your RAM (weights + KV + OS) and name the knobs (
num_ctx, one resident model) - A router or validation-escalation path exists on paper or in code
Mark Module 17 complete when local run + eval comparison are done honestly.
Exercise¶
- Catalog: EX-17 — Local SLM vs mini
- Prove: Local vs mini is scored; the local model fits
recommend_local_setupfor your RAM (no swap). - Test:
pytest tests/test_local_agents.py -v
Next: Module 18 — Agent design patterns · or jump to a specialization track