Teaching MiniCPM5 1B and 2B to be SQL agents on a MacBook
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.
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
- run_sql
SELECT 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') > 0errorno such column: status - run_sql
SELECT 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 - 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.
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
- run_sql
SELECT 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 1errorno such column: total_carts - run_sql
SELECT 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 1resultTokyo - submit_sql
SELECT 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 1resultTokyo
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 type | Reference and rewrite | Old | New |
|---|---|---|---|
| Customers in a city | COUNT(*)COUNT(DISTINCT name) | 16/16 | 0/16 |
| Cheap products | price < Nprice <= N | 18/18 | 0/18 |
| Signups in a year | signup_date LIKE 'YYYY-%'signup_date >= 'YYYY-01-01' | 5/15 | 0/15 |
| Average price by category | AVG(price)AVG(DISTINCT price) | 1/1 | 0/1 |
| Frequent customers | SELECT c.nameSELECT DISTINCT c.name | 4/4 | 0/4 |
| Top spenders | GROUP BY c.idGROUP BY c.name | 2/2 | 0/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:
| Split | What it tests |
|---|---|
| Reserved parameters | Trained question types with values and data the model never trained on |
| Selection set | Five never-trained question types, used to pick checkpoints and stop early |
| Check set | Five 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
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
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
The full table, with 95% intervals:
| Released 1B | Fine-tuned 1B | |
|---|---|---|
| Test set: 9 never-trained types, 33 questions | 3.6% (1-7%) | 44.7% (34-56%) |
| Check set: 5 never-trained types, 18 questions | 12.2% (2-23%) | 70.1% (56-83%) |
| Selection set: 5 never-trained types, 19 questions | 5.6% (2-11%) | 50.0% (32-69%) |
| Trained types, unseen parameters and data | 14.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
- CheckScore the released modelEvery split, including the test set, 16 attempts per question
- TrainDistil from a fresh adapter787 verified teacher solutions, LoRA on all layers. This also teaches the tool protocol
- TrainGRPO with early stoppingUp to six hours, checkpoints picked on the selection set
- TrainDistil againTeacher solutions plus the RL run's own correct transcripts
- CheckKeep the better checkpoint and score it onceWhichever 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 1B | Fine-tuned 1B | Released 2B | Fine-tuned 2B | |
|---|---|---|---|---|
| Test set: 9 never-trained types | 3.6% | 44.7% | 38.3% | 73.1% |
| Check set | 12.2% | 70.1% | 73.6% | 96.2% |
| Selection set | 5.6% | 50.0% | 47.4% | 76.0% |
| Trained types, unseen parameters and data | 14.8% | 94.9% | 69.5% | 98.8% |
| All attempts | 8.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
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.