The small model serving my traffic right now came out of a training run that finished in a single afternoon. The pipeline wrapped around that run has been going for more than a year, and it does not get to stop. That ratio is the whole story. A year ago I wrote that small fine-tuned models were beating frontier on my workloads; they still are. What I undersold then was the boring half: everything that has to exist around the model before you let it touch traffic. The plumbing that makes a training run safe to repeat is the actual job, and it decides whether you have a system or a science project.
This is the grown-up version of that post. Same bet, more scar tissue. What I have learned since is that fine-tuning a small model is not a modeling problem you solve once but a release pipeline you operate forever, and the model is the smallest piece of it.
What changed, and what stubbornly did not
Three things genuinely got better. The base models are stronger, so a small open-weight model in 2026 starts from a higher floor than the one I was fine-tuning eighteen months ago. Training got cheaper and faster, to the point where a parameter-efficient run is no longer something you schedule but something you kick off before you go get coffee. Tooling grew up too: adapter training, eval running, and canary serving are now things you wire together instead of build.
Some things did not change, and will not. Data quality is still the whole game. A stronger base model does not save a dirty training set, it just learns the dirt faster. The moment you fine-tune, you own a model, with everything that ownership drags behind it. Cheaper training made it easier to start and did nothing to make it easier to keep.
(The cheaper training is a trap, actually. When a run cost real money you thought hard before kicking one off. Now the cost is so low that teams retrain on reflex, ship whatever clears yesterday’s eval, and never notice the eval set itself has gone stale.)
The pipeline, end to end
I run it as six stages and a loop now, and the loop matters more than any single stage.
Six stages left to right, but the one to watch is the dashed arrow coming back: production monitoring feeds drift detection, which feeds the next training set. A fine-tuned model that does not close that loop is decaying, you just cannot see it yet.
Stage one is data curation, which is where the value is. You distill from a stronger model: log a capable model doing the task, then filter its output hard before you ever call it training data. The trap I fell into the first time, and watched two other teams fall into since, is trusting the teacher. A strong model is right most of the time, which means it is wrong some of the time, and if you do not filter, you are faithfully teaching your small model to reproduce the teacher’s mistakes on exactly the cases it gets wrong.
# Curate a fine-tune set by distilling our prod teacher model.
# Hard rule: the teacher is a labeler, not an oracle. Filter it like
# you'd filter any noisy annotator, because that's what it is.
def curate(logged_calls, min_conf=0.88):
rows, dropped = [], Counter()
for call in logged_calls:
out = call.teacher_output
if out.label not in VALID_LABELS:
dropped["off_schema"] += 1 # teacher invented a category
continue
if out.confidence < min_conf:
dropped["low_conf"] += 1 # route these to a human, not the trainset
continue
if call.input_hash in seen:
dropped["near_dupe"] += 1 # dupes inflate eval lift and lie to you
continue
seen.add(call.input_hash)
rows.append(to_example(call.input_text, out.label))
# I look at `dropped` every single run. The day off_schema spikes is the
# day the real-world input distribution moved and nobody told me.
log.info("curated %d, dropped %s", len(rows), dict(dropped))
return rows
That dropped counter earns its keep twice. Once as a data-quality gate, and once as the cheapest drift alarm you will ever build. When the teacher suddenly starts going off-schema or unsure on inputs it used to handle, your input distribution has shifted under you. I would rather find out from a counter in a curation job than from a downstream team telling me the numbers look wrong.
Stage two is training, and in 2026 there is almost nothing to say about it, which is the good news. Parameter-efficient fine-tune, adapters on a small open-weight base, a few thousand to a few tens of thousands of clean examples. If your run is slow or expensive, you are either using too large a base or trying to teach the model knowledge it should be retrieving at inference time, not memorizing into its weights. That mistake has not changed in two years either.
The eval bar is the release gate
Stage three is the one people skip, and it is the only stage that decides whether you ship. An eval is not a number you report; it is a gate that says no.
# The gate. A candidate ships ONLY if it clears the frozen baseline on
# OUR gold set, and does not regress any single slice. Aggregate accuracy
# is a liar: it hides a rare-but-critical bucket the fine-tune forgot.
def gate(candidate, baseline, gold):
cand = score_by_slice(candidate, gold)
base = score_by_slice(baseline, gold)
if cand.overall < base.overall:
return Reject(f"below baseline: {cand.overall:.3f} < {base.overall:.3f}")
# The slice check is the part that's saved me. A model can gain on the
# common case and quietly go blind on a small high-value one (for us,
# the fraud-adjacent buckets) and still beat the average. That's a
# regression wearing a win's clothes.
for slice_name in gold.slices:
if cand.by_slice[slice_name] < base.by_slice[slice_name] - TOLERANCE:
return Reject(f"slice regressed: {slice_name}")
return Approve(cand)
The gold set is a few hundred examples, hand-checked, frozen, and never touched by training. The baseline (the model currently in production, or the teacher) is scored against it once and pinned. Every candidate runs the same gold set, and a candidate that does not beat the line does not ship. No leaderboard, no public benchmark, no vibes. You are beating one specific model on one specific job, measured on your own data.
The slice check is the line I defend hardest in review. Aggregate accuracy will tell you everything is fine right up until someone notices your rarest category, which is usually your most valuable one, has gone to zero. Score every slice, and refuse any candidate that regresses one, even if the average went up.
Canary, because the eval set is not production
Passing the eval gate does not mean the model is good, only that it is good on the few hundred examples you happened to freeze. I did not really understand that a year ago. Production is wider, weirder, and moving, so the model that clears the gate goes out behind a canary rather than straight to all traffic.
A small slice of live requests goes to the new model. Both the new model and the incumbent answer, the incumbent’s answer is what actually serves, and you compare. Where they disagree, you have found either a real improvement or a fresh failure mode the gold set never had, and either way you learned it on one percent of traffic instead of all of it.
# Canary: route a slice to the candidate, but the incumbent still serves.
# We're not A/B testing for metrics here. We're hunting disagreements,
# because a disagreement on live traffic is a gold-set example we didn't have.
def serve(request):
incumbent_ans = incumbent.run(request)
if in_canary(request, pct=CANARY_PCT):
candidate_ans = candidate.run(request)
if candidate_ans != incumbent_ans:
# this is the valuable signal. queue it for human review;
# tomorrow's gold set is built from today's disagreements.
disagreements.put(request, incumbent_ans, candidate_ans)
return incumbent_ans # candidate does not touch the user yet
The disagreement queue is the best source of new eval examples I have. The cases where two reasonable models split are exactly the hard cases your frozen gold set was too clean to contain. Feed the reviewed disagreements back into the gold set and the next candidate gets graded on a harder, more honest test. That is the loop tightening on itself, which is what you want.
Only after the canary stays quiet for long enough do you promote the candidate to full traffic. Promotion is a config change, not a deploy, and it is reversible in seconds, because the incumbent is still loaded and you will want it back the first time the new model surprises you.
Drift is the bill, and it never stops arriving
The frontier API you replaced was somebody else’s problem to keep current. They retrained it, they kept up with the world, you got the upgrades for free. The day you fine-tuned your own, you took that job. Your specialist model knows precisely the distribution you trained it on and nothing past it. The world keeps moving: new input types appear, phrasings shift, a whole category of cases shows up that did not exist when you froze the training set. The model does not crash; it guesses, confidently, wrong, and accuracy bleeds out one basis point at a time until someone downstream notices the totals are off.
The defense is monitoring the live distribution, not just the model’s outputs. The dropped counter from curation, the canary disagreement rate, the teacher’s agreement with your model on a sampled stream: those are your drift alarms. When they trip, you curate a fresh set, retrain, gate, canary, promote. Same pipeline, on a cadence, forever. That forever is the real cost of fine-tuning, and it is the line I now write into every proposal next to the inference savings, in the same font. The teams that get burned are the ones who watched cost-per-call drop, declared victory, and walked away from a model that needed feeding.
None of this is hard, and that is the trap in it. Every stage here is a small, dull piece of plumbing, and that plumbing is exactly what separates a fine-tuned model you can trust in production from a benchmark you posted once and stopped looking at. The model was never the hard part; owning it is the whole job.
My bet for the next stretch is that this only tilts further. Base models keep getting stronger, which makes the training run more trivial and leaves the pipeline as the entire source of value. I am moving my time to match: the disagreement queue, the drift alarms, and that dropped counter now get more of my attention than the training script, and I expect the next real gain in accuracy to come from feeding the loop, not a better run.