Teaching MiniCPM5 1B and 2B to be SQL agents on a MacBook

ML & AIProjects

I've spent the last stretch teaching MiniCPM5-1B, and then MiniCPM5-2B, to answer questions about a SQLite database by writing and running SQL. It all runs on my M3 Pro with 36 GB of memory: MLX, LoRA adapters and group-relative policy optimisation (GRPO). A small API model helps write training questions and checks them.

The code, tests and every engineering record are on GitHub.

1B, never-trained question types
44.7%
+41.1 ptsfrom 3.6%
Test set, 33 questions, scored once
2B, never-trained question types
73.1%
+34.8 ptsfrom 38.3%
Same test set, scored once
Check set
70.1%
+57.9 ptsfrom 12.2%
5 never-trained types, 18 questions
Trained types, new data
94.9%
+80.1 ptsfrom 14.8%
Unseen parameters and databases

Each question was attempted 16 times at temperature 1. The baselines are OpenBMB's released models in bf16. The two cards without a model name are the 1B.

The task

The model gets a question like "Which customers have placed more than 10 orders? Return only their names." It has two tools. run_sql runs a read-only query and shows ten preview rows. submit_sql submits the final query and ends the episode.

The full result of the submitted query is the answer. Prose doesn't count. A correct answer scores 1, a wrong one 0, and running out of turns or tokens scores -0.2.

Here's what that looked like before and after. The released model actually got the right number here, then wrote a paragraph instead of submitting:

Released MiniCPM5-1B

How many customers living in Paris have placed at least one order but never had an order with status 'shipped'?
  1. run_sqlSELECT COUNT(*) FROM customers c WHERE c.city = 'Paris' AND (SELECT AVG(status) FROM order_items oi GROUP BY oi.order_id HAVING oi.status <> 'shipped') > 0
    errorno such column: status
  2. run_sqlSELECT COUNT(*) FROM customers c WHERE c.city = 'Paris' AND NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.status = 'shipped')
    result0
  3. replied in prose

    It appears that there are no records in the database to answer your question. Please verify the spelling of the city name and the status for each order.

Never submitted, scored as no answer

I've cut two of its broken queries from the middle. The fine-tuned model makes mistakes too, but it reads the error, fixes the query and submits:

Fine-tuned MiniCPM5-1B, test-set question

Which city in Japan has the most orders from its customers? Return only the city.
  1. run_sqlSELECT COUNT(*) FROM orders o JOIN customers c ON c.id=o.customer_id WHERE c.country='Japan' GROUP BY c.city ORDER BY total_carts DESC LIMIT 1
    errorno such column: total_carts
  2. run_sqlSELECT c.city FROM orders o JOIN customers c ON c.id=o.customer_id WHERE c.country='Japan' GROUP BY c.city ORDER BY COUNT(*) DESC LIMIT 1
    resultTokyo
  3. submit_sqlSELECT c.city FROM orders o JOIN customers c ON c.id=o.customer_id WHERE c.country='Japan' GROUP BY c.city ORDER BY COUNT(*) DESC LIMIT 1
    resultTokyo
Matches the reference on all four databases

A grader that can't be gamed

Most of the early work went into the verifier. If the reward accepts wrong SQL, RL will happily teach wrong SQL.

Each submission runs against four databases and has to match the reference query on all of them. Row order is ignored. Column order, duplicate rows, text and integer precision all count. Results over 10,000 rows return an error instead of being cut off, and queries that read the clock are rejected.

One of the four databases is built to catch lazy SQL: two different customers with the same name in every city, two same-name customers who are both top spenders, prices sitting exactly on the thresholds the questions use, dates outside the usual years, and cancelled orders big enough to change which category wins if you forget the status filter.

I tested it with 21 plausible wrong rewrites of the reference queries. Eleven of them passed the previous version of the verifier for at least one question. All 21 now fail for every parameter, while correct rewrites (NOT EXISTS, LEFT JOIN ... IS NULL, BETWEEN) still pass.

Wrong rewrites the old verifier accepted

parameter values passed, old verifier then new
Question typeReference and rewriteOldNew
Customers in a cityCOUNT(*)COUNT(DISTINCT name)16/160/16
Cheap productsprice < Nprice <= N18/180/18
Signups in a yearsignup_date LIKE 'YYYY-%'signup_date >= 'YYYY-01-01'5/150/15
Average price by categoryAVG(price)AVG(DISTINCT price)1/10/1
Frequent customersSELECT c.nameSELECT DISTINCT c.name4/40/4
Top spendersGROUP BY c.idGROUP BY c.name2/20/2

Regrading the archive from the first training run with the stricter checks rejected 376 of 2,266 answers that had been marked correct. That's 376 times the model was rewarded for something wrong.

Measuring the right thing

An early run looked great: evaluation went from 25% to 82.5%. Then I noticed 23 of the 25 evaluation questions had appeared in training. That number measured memory.

The evaluation now uses separate databases and three kinds of question:

SplitWhat it tests
Reserved parametersTrained question types with values and data the model never trained on
Selection setFive never-trained question types, used to pick checkpoints and stop early
Check setFive other never-trained types, scored only at the start and end of a run

Then, after every design decision was made, I wrote a ten-type test set. It was scored exactly once, on the released model and the final checkpoint. The rule for excluding a test type that overlaps a generated training type was written down before any result existed, and it excluded one.

What the first long run did

The first overnight run started from the released model and ran 300 steps in 5.3 hours. The trained types were solved within 30 steps. The never-trained types peaked at step 160 and then swung all over the place:

First overnight run

29 questions, 4 attempts each
  • Never-trained types
  • Trained types, new values
0%25%50%75%100%050100150200250300best checkpoint, step 160Never-trained types, step 0: 9.1%Never-trained types, step 10: 22.7%Never-trained types, step 20: 34.1%Never-trained types, step 30: 50.0%Never-trained types, step 40: 56.8%Never-trained types, step 50: 45.5%Never-trained types, step 60: 52.3%Never-trained types, step 70: 59.1%Never-trained types, step 80: 36.4%Never-trained types, step 90: 43.2%Never-trained types, step 100: 50.0%Never-trained types, step 110: 45.5%Never-trained types, step 120: 34.1%Never-trained types, step 130: 38.6%Never-trained types, step 140: 40.9%Never-trained types, step 150: 43.2%Never-trained types, step 160: 68.2%Never-trained types, step 170: 52.3%Never-trained types, step 180: 20.5%Never-trained types, step 190: 52.3%Never-trained types, step 200: 36.4%Never-trained types, step 210: 34.1%Never-trained types, step 220: 38.6%Never-trained types, step 230: 45.5%Never-trained types, step 240: 4.5%Never-trained types, step 250: 15.9%Never-trained types, step 260: 34.1%Never-trained types, step 270: 45.5%Never-trained types, step 280: 38.6%Never-trained types, step 290: 43.2%Never-trained types, step 300: 38.6%Trained types, new values, step 0: 14.1%Trained types, new values, step 10: 70.3%Trained types, new values, step 20: 81.2%Trained types, new values, step 30: 93.8%Trained types, new values, step 40: 95.3%Trained types, new values, step 50: 98.4%Trained types, new values, step 60: 96.9%Trained types, new values, step 70: 96.9%Trained types, new values, step 80: 100.0%Trained types, new values, step 90: 90.6%Trained types, new values, step 100: 100.0%Trained types, new values, step 110: 92.2%Trained types, new values, step 120: 81.2%Trained types, new values, step 130: 100.0%Trained types, new values, step 140: 98.4%Trained types, new values, step 150: 98.4%Trained types, new values, step 160: 98.4%Trained types, new values, step 170: 95.3%Trained types, new values, step 180: 98.4%Trained types, new values, step 190: 98.4%Trained types, new values, step 200: 98.4%Trained types, new values, step 210: 100.0%Trained types, new values, step 220: 100.0%Trained types, new values, step 230: 100.0%Trained types, new values, step 240: 98.4%Trained types, new values, step 250: 100.0%Trained types, new values, step 260: 100.0%Trained types, new values, step 270: 100.0%Trained types, new values, step 280: 100.0%Trained types, new values, step 290: 100.0%Trained types, new values, step 300: 100.0%

The reason is in the training logs. GRPO only learns from groups where some attempts succeed and some fail. In the first 50 steps, 9.8 of every 16 question groups had that signal. In the last 100 steps, 1.1 did. The model had run out of things to learn, and the few updates left were mostly noise.

Six ways more training made it worse

This is the part I found most interesting. Each of these made a score go down, and each has its own fix in the code now.

It ran out of things to learn9.8 to 1.1 useful groups of 16

By step 60 the model solved nearly every training question, so most updates carried no signal.

Fix: more question types. An API model proposes new templates whenever the learning signal drops, and every proposal goes through the same data checks as the hand-written ones.

New types crowded out old onestrained types 100% to 58% in 8 steps

Adding over a hundred harder templates made the model forget simple questions. It started inventing tables like books and categories.

Fix: a fixed 25% of each batch replays templates it has already mastered.

A fresh optimiser made big, noisy jumpsfirst few steps

Restarting Adam on an adapter that is already trained moves every weight by roughly the learning rate on the first few steps.

Fix: resume the saved optimiser state. Resumed runs take their learning rate from the command line, not from the saved state.

Wrong SQL was being rewardedup to 21 times per step

In groups where nobody got the answer right, a wrong submission (0) scored above running out of turns (-0.2), so wrong SQL got a positive update. A measure of policy noise rose tenfold in eight steps.

Fix: wrong answers never get positive credit.

Teacher examples from too far awayextra joins on simple questions

Injecting an API model's solutions to hard questions taught the student to bolt extra joins onto easy ones.

Fix: only use teacher solutions the student already finds reasonably likely (mean per-token negative log-likelihood under 1.0), at half weight. That keeps about 80% of them.

The policy swung between SQL styles41% to 18% in 4 steps

With 16 questions per step from a varied pool, a few templates could dominate an update.

Fix: evaluate and save an exponential moving average of the weights, with a lower learning rate and a bigger batch.

With all of that in place, a 20-step validation run moved the check set from 40% to 59% with no loss on the trained types. A longer run with twice the batch size then plateaued at about 45% on the selection set after 30 steps.

Distillation beat more RL

RL only learns from questions the model sometimes gets right, so question types it can't solve yet give it nothing. To get past the plateau, GLM-5.3-flash wrote solutions for every training type, four parameter choices each. Every solution was checked against all four databases, which left 787 verified solutions across 212 types. They cost $0.12.

The model then trained on those directly, mixed with 567 of its own correct answers so it wouldn't forget what it already knew. That's 86 updates at a low learning rate. The selection set went from 46% to 51% and the check set from 65% to 72%, with trained types unchanged at 97%. That checkpoint is the final 1B model.

Results

The biggest change is that the model now plays the game. The released model never submitted SQL on 640 of its 1,472 attempts and ran out of turns or tokens on another 614. The fine-tuned model submits almost every time, so what's left is wrong SQL:

How every attempt ended

1,472 attempts per model, all splits
  • Correct
  • Wrong SQL
  • Never submitted
  • Ran out of turns or tokens
Released 1B
640614
Fine-tuned 1B
865596
Released 2B
773459192
Fine-tuned 2B
1,223212

On the test set, some question types went from nothing to around 90%. The hardest ones are still hard: counting groups that pass a HAVING filter, returned-order ratios and month-of-year aggregation sit between 0% and 19%.

Test set by question type

16 attempts per question
  • Released 1B
  • Fine-tuned 1B
Distinct products bought in a city0% 88%
Products above the average price12% 77%
Latest signup in a country16% 69%
Customers in a city with no shipped order0% 67%
City with the most orders in a country2% 47%
Customers who spent over an amount0% 19%
Customers who bought from several categories0% 10%
Busiest month in a year0% 0%
Share of returned orders in a category0% 0%
Average order value in a country (excluded)0% 0%

The full table, with 95% intervals:

Released 1BFine-tuned 1B
Test set: 9 never-trained types, 33 questions3.6% (1-7%)44.7% (34-56%)
Check set: 5 never-trained types, 18 questions12.2% (2-23%)70.1% (56-83%)
Selection set: 5 never-trained types, 19 questions5.6% (2-11%)50.0% (32-69%)
Trained types, unseen parameters and data14.8% (5-28%)94.9% (91-98%)

Where the training data came from

The question bank grew from 15 hand-written templates to 202 accepted generated ones. Claude Haiku 5.5 and GLM-5.3 propose templates, and a different model has to solve each one without seeing the reference answer. A template is rejected if its answer is the same on every database, if it depends on tie order, if one of its filters never changes the answer, or if its answers overlap a held-out question.

When the model keeps failing a template, the generator writes simpler stepping-stone templates that teach its parts. At 1B parameters, the model learns best from small steps it can almost manage.

Total API spend was about $2, mostly on template generation and review. Everything else ran on the laptop.

The same pipeline on the 2B

Once the 1B was done, the steps became a single script that runs on any model:

scripts/pipeline.sh

  1. Check
    Score the released model
    Every split, including the test set, 16 attempts per question
  2. Train
    Distil from a fresh adapter
    787 verified teacher solutions, LoRA on all layers. This also teaches the tool protocol
  3. Train
    GRPO with early stopping
    Up to six hours, checkpoints picked on the selection set
  4. Train
    Distil again
    Teacher solutions plus the RL run's own correct transcripts
  5. Check
    Keep the better checkpoint and score it once
    Whichever of RL or the second distillation scores higher on the selection set

MiniCPM5-2B went through it unattended in just under 11 hours. It starts much stronger than the 1B, because it already follows the tool protocol, and it finishes well ahead:

Released 1BFine-tuned 1BReleased 2BFine-tuned 2B
Test set: 9 never-trained types3.6%44.7%38.3%73.1%
Check set12.2%70.1%73.6%96.2%
Selection set5.6%50.0%47.4%76.0%
Trained types, unseen parameters and data14.8%94.9%69.5%98.8%
All attempts8.0%58.8%52.5%83.1%

The 1B gained more points on the test set (41 against 35) because it started near zero. The 2B removed more of its mistakes: 56% of its test-set misses, against 43% for the 1B.

Distillation did most of the work on the 2B. The teacher solutions only had to teach SQL patterns, not the protocol, and one pass took the selection set from 52% to 66% and the check set from 75% to 92%. Six hours of RL then added another nine points on the selection set. The second distillation pass came out level with RL.

2B test set by question type

16 attempts per question
  • Released 2B
  • Fine-tuned 2B
City with the most orders in a country25% 98%
Latest signup in a country86% 98%
Products above the average price72% 97%
Distinct products bought in a city94% 95%
Customers in a city with no shipped order17% 80%
Customers who spent over an amount12% 77%
Customers who bought from several categories10% 69%
Busiest month in a year6% 27%
Share of returned orders in a category0% 5%
Average order value in a country (excluded)17% 69%

The same two types sit at the bottom for both models: month-of-year aggregation and the returned-order ratio. Counting groups that pass a HAVING filter, where the fine-tuned 1B managed 10-19%, reached 69-77% on the 2B.

Two things differ between the runs. The 1B's final checkpoint came from the development runs, with far more RL than the 2B's six hours. And the 2B's second distillation included 97 solutions for templates generated from real Spider train questions, which were added after the 1B was finished. Running the 1B through the same script would make the comparison exact.

Running it yourself

sqlrl/scripts/export_gguf.sh fuses a final adapter into its model and writes BF16, Q8_0 and Q4_K_M GGUF files (1.15 GB and 0.69 GB at 8 and 4 bits for the 1B), with no-think mode as the chat template default because that's how it was trained. docs/lmstudio.md has the settings, a system prompt and ten example questions with their measured success rates for testing it in LM Studio.

Everything else is in the repository: the training code, the verifier and its tests, the template generator, the dashboard, and the write-up with the evidence for every number above in sqlrl/engineering/. Model weights and databases stay local.