Part I · the basics
What fine-tuning changes, when to use it, and how to go from a folder of examples to a private model running on your own machine.
Fine-tuning a small open model to do one task well is cheaper and simpler than its reputation suggests. This part covers how it works and the full workflow. Part II then uses it on a real production system and shows what that takes.
01 · First, do you even need it?
Try these in order. Each one is more work than the last, and most tasks are solved before the third.
Put instructions and a few examples in the prompt. If that gets you there, stop. No training, no infrastructure.
Keep facts in a search index and let the model read them when it needs them. Use this when the problem is what the model knows: documents, catalogs, data that changes.
Adjust the weights so the model behaves a certain way by default: a fixed format, a tone, a tool-calling pattern. You don't have to spell it out in the prompt every time.
02 · What actually changes
A model is a big stack of weight matrices. Full fine-tuning rewrites all of them, which works but is expensive and easy to get wrong. LoRA leaves the original weights frozen and trains a small add-on layer (the “adapter”) instead. You update a few million parameters rather than several billion.
QLoRA goes one step further and loads the frozen base in 4-bit precision, so it fits in much less memory. That is what lets you fine-tune an 8B model on a single GPU, or a laptop.
The original billions of weights, left untouched (and in QLoRA, stored in 4-bit). All the general ability stays here.
A small extra matrix per layer. It's the only part that trains, and it's small enough to email. Attach or merge it when you run the model.
03 · The workflow
The whole loop, in the order you do it. Most of the work is in step one. The training itself is quick.
Turn your reports, Q&A, and labels into chat-style JSONL. This is the slow part, and it matters most: the model learns whatever is in your examples.
A 1B–8B instruction-tuned base. Smaller is cheaper, faster, and easier to run privately. Start with the smallest size that might do the job.
Freeze the 4-bit base and train just the adapter on your data. This takes minutes to a couple of hours, not days.
Check that it answers the way you want on examples it never saw. Read the outputs, not the loss number. Fix what it gets wrong and retrain.
Merge or attach the adapter, export to a portable format like GGUF, and run it offline. No per-token bill.
04 · Choosing a base
Start with the smallest instruction-tuned model that might handle the task, and only move up if your evaluation says you need to. Smaller models are cheaper to train and easier to run yourself.
| If you need… | Reach for |
|---|---|
| Simple tagging & extraction | A ~1–2B instruct model. Fastest, runs anywhere |
| A solid all-round starter | A ~3–4B instruct model. The usual sweet spot |
| Clean structured / JSON output | A model with strong instruction-following at 3–8B |
| More reasoning headroom | An ~8B model, when smaller sizes plateau on your task |
Whatever you pick, train with the exact chat template that model expects. Getting the template wrong is the most common reason a run quietly produces nonsense.
05 · Hyperparameters
There's a long list of settings, but for LoRA these three do most of the work. Leave the rest at their defaults.
How much room the adapter has to learn. 8 to 16 is a good default. Raise it only if the model can't capture the behavior; higher rank also means more chance of overfitting.
How big each training step is. Too high and the model breaks or forgets the base; too low and nothing changes. For LoRA, 1e-4 to 2e-4 is a good place to start.
How many times the model sees your data. One to three is usually enough. More than that and it starts memorizing. Judge by held-out behavior, not training loss.
06 · Running it & the bill
Run the pipeline on your own machine first, for free, to catch bugs. Then rent a GPU just for the full run. A small QLoRA job costs a few dollars, as long as you remember to shut the instance down.
# 1. Test the loop locally (Apple MLX), on a small slice — free
mlx_lm.lora --model <base-model-4bit> --train \\
--data ./data --iters 200 --batch-size 1
# 2. Full QLoRA run on a rented GPU (e.g. Unsloth + a spot instance)
python train.py --model <base-model> --data ./data.jsonl \\
--rank 16 --lr 2e-4 --epochs 2
# 3. Export to a portable format and run it offline, locally
# (merge adapter -> GGUF -> a local runtime such as Ollama)
A quick run on your laptop catches template and data bugs before you pay for a GPU.
A GPU you forgot to turn off. Export, copy it down, then terminate.
07 · The checklist
One pass through the whole loop. Three mistakes cause most bad runs: too few or too similar examples, watching the loss number instead of the actual outputs, and leaving the GPU running.
Collect input-and-ideal-output pairs for your task. With under about 50, or examples that all look alike, the model won't learn much. Variety matters more than volume.
Format them with the exact chat template the model expects, or quality drops. Keep about 10% aside for evaluation.
Run a small slice on your own machine (Apple MLX works well) to confirm the pipeline runs end to end before you pay for a GPU.
Launch a spot GPU and run the full QLoRA job. A short run on clean data beats a long run on messy data.
Compare the tuned model to the base on held-out data. If it isn't clearly better, fix the data before touching the settings.
Export to a portable format, copy it down, and shut the instance off. A forgotten GPU is the only thing here that really costs money.
End of Part I
Part I covered the technique. Part II uses it on a real production agent, where the training is the easy step and the hard part is working out whether the small model can actually replace the big one.
The tool
Every measurement in Part II came from a small comparison tool: send one message to two engines at once and watch the tool calls, the full trace, the tokens, and the cost. Below, the fine-tuned model (left, on a laptop) is set against GPT-5.2 (right). Both are wired to the same live data services, so the only thing that differs is the model.
One query, two models. Both call the live RAG service and get the same five datasets. The only real difference is $0 on the left (local) versus a paid call on the right.
Every turn expands into a full trace: the user message, the model’s tool call, and the raw tool result it gets back.
Part II · a real case study
How I fine-tuned Qwen3-8B to stand in for GPT-5.2 behind a geospatial dataset agent: what I measured, what worked, and the gaps that still need closing before it could replace the original.
Part I covered how to fine-tune. Here the goal is harder — actually replacing a paid frontier model in a live product, and being honest about whether it worked. The techniques (LoRA, QLoRA, MLX) are the same. What changes is that training is no longer the hard part; measuring whether the small model is good enough is. The numbers below are from one run on one system, so treat them as an example of how to think about the problem, not as benchmarks to copy.
01 · Why this can be fine-tuned at all
You can't replace most chat assistants with a small model, because there's no limit to what “good” means for them. This agent is the opposite. It has seven fixed tools that run in a set order, confirmation steps where the user has to approve, fixed response templates, a finite list of datasets behind a search tool, and a clear refusal for anything off-topic. Every step is either right or wrong, which means every step can be measured.
GPT-5.2 was projected at about $30K a month. One conversation makes 11 model calls and uses around 80K input tokens.
About $890 a month on a hosted fallback, or close to nothing run locally, with a prompt 24× smaller.
02 · The methodology
Training is the easy part. The discipline is in the order: build the benchmark before training, check the spec against production, split the data so you can catch memorization, and measure whether it works in a real conversation, not just one turn at a time.
For each assistant turn, give the model the system prompt and the conversation so far from a known-good recording, then check its decision against what the recording actually did. Before trusting the benchmark at all, confirm the production model can reproduce its own recording at close to 100%. Mine couldn't at first — two scoring bugs had to be fixed before it did.
The policy is deterministic, so I can generate the training transcripts mechanically instead of writing them by hand. Then I check those transcripts against the live production model. The first pass agreed 90% of the time, which turned up four real bugs in my generator. After fixing them, agreement was 98.8% on tool choice and 92.5% on the echoed text, with no disagreements at the confirmation gates.
If your test examples come from the same generator as your training ones, they leak into each other. So I hold back whole categories of input — certain topics, places, and ways of phrasing dates — and use those only for evaluation. A hash check on the training set warns me if any test item also showed up in training.
LoRA on a 4-bit Qwen3-8B with MLX. I train against a short stub prompt instead of the full one, so the policy ends up in the weights and each request carries far fewer input tokens. The loss only applies to the turn being trained.
Replay to compare turn by turn. The untrained base model as a control, to see what training actually added. Free-running rollout to check whole conversations. Repeated runs to measure variance. And an outside review of the method itself.
The production backend runs locally and hands back its full state every turn, so I can record real conversations without any logging setup. Once I have those, they become the main training data, and the generator is only there to fill in cases the real ones miss.
03 · Results, and how each was checked
No result is stated without the method behind it. The colored dot is the real verdict, not the one I was hoping for.
| Claim | Result & how verified |
|---|---|
| The benchmark is calibrated | GPT-5.2 reproduces its own recording 100/100/100, three times running replay self-test, repeated |
| The generated data matches production | 98.8% tool / 92.5% echo / 0 gates on 164 held-out decisions live GPT-5.2 replay, two rounds |
| Fine-tuning clearly beats the base model | base with stub prompt 65.5% / 10% / 9 gates → fine-tuned 98.8% / 93.8% / 0 gates same runtime and prompt, only the weights differ |
| The fine-tuned model matches GPT-5.2 turn for turn | tools 98.8 = 98.8 · echo 93.8 vs 92.5 · gates 0 = 0 same decisions, same scorer (replay) |
| Corrected: the earlier completion failure | The “0–25% completion” number was a bug in the evaluator, not the model. The same adapter scores 100% once the evaluator is fixed, and it really does call every tool. Exposure bias is still a real risk; this number wasn't evidence of it. re-run on a fixed evaluator |
| Date conversion didn't generalize | a held-out “spring 2021” gave the wrong date range (84.5% args vs 98.8%); later fixed by handing date parsing to a plain code tool held-out replay, grouped by failure |
| It follows the full prompt too, not just the stub | given GPT-5.2’s full 14,571-char prompt instead of the 507-char stub, the fine-tuned model still routes tools and hits the gate correctly — at 3.7× the input tokens for the same result, which is why the stub is worth it live replay, stub vs full prompt |
| Same datasets from the live RAG service | wired to the real RAG/Geodini/STAC services, the fine-tuned model and GPT-5.2 return the same five methane collections for the same query. The difference is $0 (local) versus a paid call, not the datasets. side-by-side on live services |
04 · The main lesson
In replay, the model gets the correct history at every turn, so one bad turn never affects the next. In rollout, the model feeds on its own previous answers, and a small slip early on can snowball into a state the training data never covered. So replay tells you whether each turn is right; only rollout tells you whether the whole conversation holds together.
05 · Failure modes
Each of these went unnoticed for a while. They're the ones a beginner's guide rarely warns you about.
My headline example of exposure bias turned out to be a bug in the evaluator, not a failure in the model. The same adapter scored 0% completion under the old rollout loop and 100% under a fixed one, and it really was calling every tool. Replay versus rollout is still the right way to think about it, but you have to check the evaluator as carefully as you check the model.
I trained on “summer” and “spring” still failed. The model had memorized specific date phrases, not learned the pattern. Either cover every case in training, or — better — move the conversion into plain code, which fixes it for every model at once.
mlx_lm.server 0.31.3 quietly ignored the adapter, so I was benchmarking the base model while thinking it was the fine-tune. mlx_lm.fuse quietly drops the LoRA on a 4-bit base. Always confirm the served model can reproduce a training example before you trust any number from it.
Prefix-matching on the echoed text accepted anything tacked on the end. Fuzzy argument matching let the wrong place through. One gate metric lumped all text turns together. Fixing each one changed which model looked better — the score had been measuring the scorer.
The RAG service picks and ranks the datasets, but the model still has to repeat that list to the user, and a model can reorder, drop, or invent an entry while doing it. GPT-5.2 spells the whole list out in prose; the fine-tuned model just says “Found 5” and leaves the list to the interface. The safer design builds the user-facing list in code from the tool output instead of trusting the model to repeat it — which is the whole reason for the gated controller.
06 · Hardening the method
The common thread: copying what production does isn't the same as being able to replace it. A public-facing agent needs a stricter bar than “matches GPT-5.2 on replay.”
Three things get mixed up here: what the agent should do, what GPT-5.2 actually does, and what the fine-tuned model does. If you only ever match the middle one, the student quietly inherits whatever GPT-5.2 gets wrong. You need a small set of human-checked examples to anchor against.
The model reads and acts on whatever the tools and RAG service hand back, so those outputs need to be validated against a schema. And you have to draw a boundary: can a dataset's metadata contain text that reads like an instruction?
Something like “vegetation loss last spring” shouldn't be settled by the production model or the generator on its own. Decide up front who labels these cases and how.
Held-out synthetic examples hit every branch of the policy, but they don't reflect how often real users phrase things each way. You need both: one test for coverage, one for real traffic. A model can pass one and fail the other.
07 · The open question
The results lean toward the second. The fine-tuned model is good at pulling out inputs and picking options, which suggests that asking an 8B model to carry the whole workflow in its weights is the wrong split. The fragile, exact parts belong in code; let the model do the language work it's good at.
The language work: handling reworded requests, working out intent, pulling out values, and spotting off-topic questions.
Anything that has to be exact: tracking workflow state, dates, IDs, and the fixed response templates.
A stronger model can stay in the loop for the cases the small one is unsure about, so replacement doesn't have to be all-or-nothing. What the data can't tell me yet is whether an 8B model with plain fine-tuning is enough to stay consistent over long conversations, or whether closing that gap needs preference training or a bigger base model. Honestly, that's not settled either way.
08 · The acceptance contract
Replacing the model is a yes-or-no call, not a gut feeling. Each row has to clear a stated bar, and a failure counts for more or less depending on how bad it is.
| Dimension | Acceptance bar | Severity if failed |
|---|---|---|
| Full pipeline completion | ≥ production − X%, or ≥ 99% on critical paths | Blocker |
| Dataset / place / date accuracy | ≥ 98–99%, or delegated to a deterministic tool | Critical |
| Confirmation-gate violations | 0 tolerated | Blocker |
| Unsafe or off-policy completions | 0 in the adversarial suite | Blocker |
| Tool-error recovery | ≥ 95–99% | Critical |
| Regression vs GPT-5.2 on high-severity scenarios | no statistically significant drop | Blocker |
09 · Reproduce it
Check the benchmark, generate and split the data, train locally, then evaluate with both replay and rollout.
# 1. Calibrate the benchmark (the recorded teacher must score ~100%)
python3 -m agent_bench run --models recorded
# 2. Generate data and the disjoint train/eval split
python3 -m agent_bench generate --per-scenario 8 --sft-system stub
# 3. Verify the synthetic spec against live production
python3 -m agent_bench run --models gpt-5.2 \\
--narratives-dir datagen_out/eval_narratives
# 4. Fine-tune locally (LoRA on Qwen3-8B 4-bit via MLX)
bash mlx_kit/train.sh
# 5. Serve the adapter, then evaluate per-turn (replay) and free-running (rollout)
python3 mlx_kit/serve_patched.py \\
--model mlx-community/Qwen3-8B-4bit \\
--adapter-path mlx_kit/adapters \\
--port 8091
python3 -m agent_bench run --models qwen3-8b-ft-clean-local --system stub \\
--narratives-dir datagen_out/eval_narratives # is each turn right?
python3 -m agent_bench rollout --models qwen3-8b-ft-clean-local --system stub \\
--narratives-dir datagen_out/eval_narratives # does the whole conversation hold?Bottom line
The hard part here wasn't training an 8B model. It was not believing any result, good or bad, until it held up under a test built to break it. The run earned a real win: fine-tuning clearly beats the base model and matches GPT-5.2 turn for turn. It also earned an honest caveat: replacing the original still needs more data behind the numbers, a test on real traffic, and a safety bar that holds up under repeated runs. Some of the gaps flagged here were later closed, and one alarming result turned out to be a bug in the measurement. Catching and fixing those is the whole point.
Every number here comes with how it was checked. That matters more than any single result.
Source, benchmark, and the comparison tool: github.com/ajinkyakulkarni/finetuning-field-guide