A year ago I wrote that small fine-tuned models were beating frontier on my workloads. They still are. What I did not write enough about then was the boring half: everything that has to exist around the model before you let it touch traffic. The training run is an afternoon. The pipeline that makes the training run safe to repeat is the actual job, and it is the part that decides whether you have a system or a science project.
So this is the grown-up version. Same bet, more scar tissue. The thing I have learned since is that fine-tuning a small model is not a modeling problem you solve once. It is 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, it is something you kick off and go get coffee. And the tooling around it grew up: adapter training, eval running, and canary serving are now things you wire together instead of build.
Here is what 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. And the moment you fine-tune, you own a model, with everything that ownership drags behind it. Cheaper training made it easier to start. It 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
Here is the shape I run now. Six stages and a loop, and the loop is the whole point.
Six stages left to right, but the arrow that matters is the dashed one 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, and it 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 one that decides whether you ship. The 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
Here is the part nobody tells you, and the part I did not understand a year ago. Passing the eval gate does not mean the model is good. It means the model is good on the few hundred examples you happened to freeze. Production is wider, weirder, and moving. So the model that cleared the gate goes out behind a canary, not 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, quietly, 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.
So you monitor 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. That is the thing. Every stage here is a small, dull piece of plumbing, and the small dull plumbing is exactly what separates a fine-tuned model you can trust in production from a benchmark you posted once and quietly stopped looking at. The model was never going to be the hard part. Owning it is.
Which of your workloads is shaped like this, and which one are you about to adopt without budgeting for the feeding?