Portulino 350M — a model that uses tools, trained on a free T4

Portulino 350M — a model that uses tools, trained on a free T4

Shell, search and RAG in a one-person model. Plus the hilarious defects along the way.


Fabio 21 Sep 2026 en e IA colab, transformers, dataset, sft, dpo, tool-use, e rag

In the Portulino article I told the beginning: Markov chains, RNNs, Karpathy’s nanoGPT and the first Portulino, trained religiously on free Colab T4s, 100 to 150 iterations a day — and how I had already stopped training it.

Time to build another model.

Still, I almost gave up training models on Colab T4s because after Portulino I built another one and spent 3 months training it until it stalled at 3.0 loss with no val improvement. I thought I had hit the limit. And 3.0 looks good on paper — but in my chat tests it never got past a glorified n-gram, that Markov-style chatter that repeats patterns without understanding a thing.

I decided to try again. I threw that model at Claude to check for bugs, and sure enough there was one: RoPE was wrong. What rage! Months wasted! I asked for another model, and by then Claude and the other AIs had gotten smarter, and to my surprise when I put this current model to train something incredible happened. Since I didn’t know how much batch fits in the T4’s 15G, I kept increasing it until hitting the memory limit. Well … while previous models only held batch 11, this Claude-remodeled one held batch size 21!!! 21?? Something had to be wrong. No way! I threw the model at every AI to audit it and they all said it was correct. OK, then let’s train and see if it’s really right. I figured 21 batch would take forever per step, but no — same speed as the old 11-batch model. Skeptical of this model, I let it train until 3.5 loss, which is when you can actually perceive some intelligibility. And it reached 3.5 in about 3k iterations … That much efficiency gain can’t be possible! Thrilled, I ran validator.py to check val, perplexity and top-1, and they were indeed dropping, and at every checkpoint I tested in chat I could tell it was training really well. This post is the current state — numbers, defects and all.

The new model

In this model I threw away almost everything and restarted from zero: new 32k vocabulary (fits in 16 bits, saves Drive space) with several special tokens like <|think|>, <|RAG|>, <|json|> and <|ai|>, rewritten architecture (GQA with 16 query heads and 4 key-value heads, RoPE, RMSNorm, SwiGLU, no bias) and around 350M parameters (Portulino had only 280M). Context 1024, AdamW, batch_size 21 on T4 and 75 on A100 with 8 accumulation steps.

Same routine: a bit of free T4 every day, and occasionally some credits for A100 or L4 jumps. Each A100 round swaps the raw text block — I’m on the second block, the third will be CulturaX mixed pt-BR and English.

Today’s numbers — and note training is still running, this is a moving picture, not a final portrait: 12,705 iterations, about 7.8 billion tokens seen, val_loss from 4.22 down to 2.0 and top-1 from 0.32 to 0.60. Monotonic curve, no plateau.

val_loss per iteration perplexity per iteration top-1 per iteration

Notice in the chart the two jumps that two A100 quotas made. The small jumps were dataset and format swaps. I used to emit a verbose JSON that ate tokens and made the model struggle to write it correctly, so I slimmed it down. Another change was executed Python code: it used to run when the model emitted a shell JSON that ran python3 with the program contents — extremely bureaucratic — and since I had the special <|code|> token sitting unused, I used it as a python3 code container for the model to emit and the backend to execute. For a model this size trained this way, that’s within expectations — and here’s the first lesson: it’s still in pure pre-training, with about 1% instruct mixed in. It was never taught to be an assistant. Demanding good answers from it now is like testing a student who only read the library and never took an exam.

The bet: tools instead of decoration

Instead of plain running text, the dataset has almost 400 thousand dialogue blocks in a custom format, with markers for each role: <|user|> for you, <|ai|> for what the model already said, <|answer|> for the current answer, <|think|> for reasoning, <|json|> for calling tools (shell, search, create_file) and <|RAG|> for the result coming back from the real world.

Plus a local Flask app where the model actually executes: runs terminal commands, searches the web, creates files. When I ask “which USB devices are connected?”, it really runs lsusb and really reads the output. When the directory is empty, ls returns total 0 — and I already learned the bug is almost never in the tool, but in how the model interprets it.

Since the blog promises occasional catastrophic failures, here’s this week’s collection:

  • I asked the difference between a dog and a cat. The model ran cat ~/Users ten times in a row, failing every time, never answering the question. Discussing these logs with AI assistants, the conclusion was simple and a bit embarrassing: a dumb guardrail was missing — stop at the third repeated error. Not a training problem, a runtime one, fixed in ten lines.
  • I asked a game price. It searched nothing and invented R$ 299 with some rambling about “head-dead”.
  • The classic: the model continues the RAG by hallucinating. The search result ends and it appends invented facts in the same format, as if quoted. I sensed it was structural, but the explanation came from talking to AIs about the logs: in unmasked pre-training, loss applies to every token, including RAG. It was trained to predict search, not to use search. Penny dropped instantly.
  • It emitted JSON with quotes inside quotes and broke its own parser. Investigating with an assistant turned into a curation rule: shell commands in training JSON are single-line with no inner double quotes. Heredoc is impossible by construction — a detail that only shows up when you train the format together with the model, and I only noticed because someone (non-human) pointed it out.

None of this discourages me. Every defect became either a hand-curated example (real RAG preserved, answer rewritten) or a pipeline gate: <|json|> validator, broken-tool quarantine, content filter on SFT. The dataset defends itself today.

Programmatic datasets: real RAG, not fake RAG

There was a phase when I noticed an embarrassing problem: my model learned the format of RAG with invented content, because part of the training data had simulated search. So I decided tool data only gets in if the RAG is real — actually executed when generating the example. The programmatic datasets were born, each with its own generator:

  • HotpotQA (7k): multi-hop benchmark questions with the real supporting facts as evidence.
  • Pira (4k, pt+en): questions and answers with the original abstract as proof.
  • Store catalog (2k): scraped a real utility-store catalog — 914 products via page-embedded JSON, past anti-bot protection — and generated questions whose answers quote real specs.
  • Salaries (2k + 1.5k reports): careful scraper on a salary-floor site (stats, FAQ, by state), including deliberately dirty data so the model learns to ignore noise.
  • FIPE (2k), IMDB (2k, from a 33k-movie CSV), APIs (2k, from a catalog of 837 real APIs), BR phones (1k, with Brazilian phone regex and area-code validation).
  • CodeTrace (1k): code execution tracing, so the model learns to follow a program step by step.
  • Shell and Python (5k): commands and scripts generated with a truly executed answer key — list averages, strings, basic programming. The <|code|> runs, the output becomes <|RAG|>, and the answer quotes the number that came out. Without that bridge, the model learns to pretend it computed.

Every example ships in the format the model will see live: question, <|think|>, <|json|> call, <|RAG|> with the real result, short think citing the datum, answer. No fake search. When the data doesn’t exist (missing file, empty directory), the example teaches saying “not found” instead of inventing — the hardest sentence for a small model.

What’s next: SFT and DPO for real

The next step is already drawn in the notebook — drawn with four hands, mine and the AIs I debate these problems with: percentage mixing — each micro-step draws from pre-training, SFT or DPO. SFT uses -1 masking on everything observational (user, RAG, system) and trains only assistant behavior. DPO uses chosen/rejected pairs with a frozen reference model. Data already leaves the pipeline as parquet with schema metadata: tokens, masked labels and the mask spec stamped together, so nobody trains on the wrong file six months from now.

The lesson from this phase, and I split the credit: a small model learns format long before it learns judgment. With 1% instruct it already emits <|think|>, <|json|>, <|RAG|> in the right places — and gets the content badly wrong. I entered this phase believing dataset is everything (still do), and leave understanding that format comes from pre-training but judgment will have to come from SFT. Half of that I figured out alone watering the plants; the other half was explained to me — including by artificial intelligences, which has its irony: I’m using AIs to learn how to make an AI.

What the dream costs

One free T4 a day, R$ 58 now and then for A100, a Colab, and years watering these plants. I’m one person, the model is proof of concept — and getting here, with a complete pipeline from tokenizer to tool-using app, is already the victory. I’ll keep watering these plants to see where I get.