WorldTUI: Ik trainde een model van 360M parameters om zich voor te doen als een terminal
De demo is ontworpen voor een desktopbrowser met WebGPU en een fysiek toetsenbord. Chrome of Edge zijn de beste browsers om dit te proberen. Het zou op een moderne telefoon moeten werken, maar dan pijnlijk langzaam.
Toen ik vorig jaar voor het eerst Genie 3 zag, begon ik me een toekomst voor te stellen waarin applicaties niet in code worden geschreven, maar worden aangeleerd. In plaats van dat een game-engine de scène rendert, voorspelt een model het volgende meest waarschijnlijke frame op basis van de input van de gebruiker.
Natuurlijk wilde ik mijn eigen 'world model' trainen. Maar aangezien ik geen massale GPU-cluster of een enorme hoeveelheid videodata heb, heb ik mijn ambitie geschaald: in plaats van een gamewereld, een terminal.
Een terminal bleek een goede match. Het is simpelweg een raster van tekens in plaats van pixels, en ik kan onbeperkt perfect gelabelde trainingsdata genereren door echte shells te draaien. Een vooraf getraind taalmodel heeft bovendien al een redelijk goed idee van wat ls of cat zou moeten printen.
Wat ik echt wilde weten, was of het een consistente wereld kon handhaven, of alleen overtuigende terminal-output kon genereren. Het uiteindelijke model is gebaseerd op Qwen2.5-Coder-0.5B, gefine-tuned op terminalsessies die zijn opgenomen van echte programma's die in Docker draaien.
Hoe het een terminal ziet
Standaard LLM's nemen een prompt en voegen daar vervolgens tokens aan toe, één voor één. Dat sluit niet goed aan bij een terminal. Een enkele toetsaanslag kan op zichzelf iets doen, zoals esc in vim, en output gaat niet altijd naar de onderkant: clear wist het scherm en stuurt de cursor terug naar de bovenkant. In plaats van een stroom tekst te voorspellen, moet ik dus veranderingen aan het scherm voorspellen.
Ik behandel de terminal als een raster van tekens, één per cel. Het model ziet het raster niet direct, maar een reeks tokens die de inhoud beschrijven en de wijzigingen die daarin zijn aangebracht.
Een sessie
Elke sessie begint met een snapshot: de schermgrootte, de inhoud en de positie van de cursor. De grootte is onderdeel van de input en is niet vastgesteld op 32×96. In de demo kun je de rijen en kolommen instellen bij het maken van een aangepast startscherm.
BOS SCREEN_START 32 96
ROW_0 0 'simedw@laptop:~$ '
CURSOR 0 17 1
SCREEN_END
Dit stelt me ook in staat om halverwege een applicatie te beginnen. Geef het model een bash-prompt, een vim-buffer of een spelletje Snake, en het model probeert vanaf dat punt verder te gaan (hoewel het beter presteert vanaf bash).
Daarna wordt elke toetsaanslag ingevoerd als de ruwe bytes die een echte terminal zou verzenden. Letters blijven letters, Enter is \r, Escape is 0x1b en ← is ESC [ D, enzovoort.
INPUT_START 'l' INPUT_END
Het model antwoordt met welke rijen herschreven moeten worden en waar de cursor eindigt:
OUTPUT_START
ROW_0 17 'l'
CURSOR 0 18 1
OUTPUT_END
In woorden: op rij 0, beginnend bij kolom 17, schrijf 'l', en verplaats de cursor vervolgens naar rij 0, kolom 18. De laatste '1' betekent dat de cursor zichtbaar is, aangezien full-screen apps deze soms verbergen.
Er wordt niets eerst gewist; nieuwe tekens worden simpelweg over de oude heen getekend. Ik had geen nieuwe tokens nodig voor tekens, aangezien Qwen al tokens heeft voor alle 256 byte-waarden, die ik hergebruik voor tekens, coördinaten en andere kleine getallen. Hierdoor neemt een coördinaat zoals 17 slechts één token in beslag, in plaats van twee.
Tijd is ook een input. Als er niets wordt ingedrukt, krijgt het model een WAIT-token met een duur in plaats van een toets, wat ervoor zorgt dat Snake uit zichzelf blijft bewegen.
Rijen kopiëren
Wanneer je de onderkant van het terminalvenster bereikt en op enter drukt, moet elke regel één plek omhoog verschuiven. Ik zou het model kunnen vragen om elke rij opnieuw uit te voeren op zijn nieuwe plek, maar dat kost ongeveer 3.000 tokens voor één enkele toetsaanslag.
Daarom heb ik een COPY-operatie toegevoegd:
ROW_0 COPY 1
ROW_1 COPY 2
ROW_2 COPY 3
...
Elke operatie kopieert een rij van het vorige scherm. In dit voorbeeld wordt de oude rij 1 de nieuwe rij 0, rij 2 wordt rij 1, enzovoort. Een scroll-actie kost nu drie tokens per rij in plaats van ongeveer 96.
Deze truc werkt ook voor het wissen van het scherm, het invoegen en verwijderen van regels in vim, en het navigeren door man-pagina's. Je kunt ook een rij kopiëren en vervolgens een deel ervan overschrijven.
In totaal gebruikt het model een zeer kleine woordenschat van 333 tokens: 256 byte-tokens, 64 rij-tokens en een handvol speciale operaties. De decodering is grammaticaal beperkt, waardoor elke voorspelling een geldige schermupdate is.
Het genereren van de trainingsdata
Ik zocht aanvankelijk naar bestaande datasets van terminalopnames, waaronder asciinema, maar kon geen dataset vinden met de precieze uitlijning tussen toetsaanslag en scherm die ik nodig had. Gelukkig was het niet te moeilijk om mijn eigen data te creëren.
Het basisidee was om geloofwaardige terminalsessies te genereren door echte commando's uit te voeren in Docker-containers, waarbij elke toetsaanslag en het scherm daarna werden vastgelegd.
De synthetische methode was als volgt:
- Randomiseer gebruikersnamen, hostnamen, prompt-stijlen en terminalgroottes.
- Randomiseer directories en bestanden op basis van een fictief persona (ontwikkelaar, sysadmin, etc.).
- Kies 6–24 gewogen activiteiten (bestanden wijzigen, vim, python, tmux, etc.).
- Houd de staat bij, zodat sessies meestal bestanden openen die daadwerkelijk bestaan.
- Typ als een mens: één toetsaanslag per keer, inclusief tab-completion. Ik heb het foutpercentage op 1,2% gezet.
- 35% van de sessies begint halverwege, om het model te dwingen te leren werken vanuit elke willekeurige staat.
- De terminalgrootte was meestal 32×96, met enkele variaties van 24×80 en 40×120, zodat het model niet overfit op één maat.
Ik bleef de dataset uitbreiden naarmate het model capabeler werd, en eindigde uiteindelijk op 336.000 episodes, wat overeenkomt met ongeveer 2 miljard tokens. Ter vergelijking: het basismodel zag ongeveer 18 biljoen tokens tijdens de training.
Training
Ik gebruikte Qwen2.5-Coder-0.5B als startpunt. Ik had een klein model nodig om dicht bij real-time prestaties te komen. Omdat het een coding-model is, had het bovendien al veel CLI-gebruik gezien.
Ik begon met eenvoudige supervised fine-tuning: geef een schermgeschiedenis en een toetsaanslag, en voorspel de schermwijziging (delta) die door de echte terminal wordt geproduceerd. Gewoon typen werd snel aangeleerd: een letter verschijnt, backspace verwijdert deze, de cursor beweegt.
In langere sessies begonnen fouten zich echter op te stapelen: een fout in een bestandsnaam plantte voort in de rest van de sessie.
Leren van eigen fouten
Om dit op te lossen, maakte ik gebruik van een methode vergelijkbaar met DAgger, waarbij een model leert van de staten die het zelf bezoekt in plaats van alleen van ideale demonstraties.
Voor elk trainingsvoorbeeld:
- Kies een punt in een opgenomen sessie, meestal vlak voor het commando dat de output produceert.
- Laat het model de volgende 1-4 gebeurtenissen voorspellen.
- Vergelijk het resulterende scherm met het echt opgenomen scherm.
- Als het verschil klein genoeg is, wordt er een correctie opgesteld die het scherm van het model bij de volgende toetsaanslag herstelt.
- Ga verder met trainen op het gecorrigeerde frame en een paar daaropvolgende echte gebeurtenissen.
De delta brengt het incorrecte scherm van het model naar het correcte volgende scherm. In essentie leert het zijn eigen fouten te "repareren".
Er is één belangrijke beperking: het model moet over voldoende informatie beschikken om de correctie af te leiden. Elk woord dat door een correctie wordt geïntroduceerd, moet eerder op een echt scherm zijn verschenen of door de gebruiker zijn getypt, en niet alleen in de voorspellingen van het model zelf. Als het model bijvoorbeeld een bestandsnaam verkeerd spelt die eerder is verschenen, kan ik het leren dit te herstellen. Maar als het een bestandsnaam noemt die het nooit heeft gezien, kan ik niet verwachten dat het dit correct doet. Training op dergelijke voorbeelden zou hallucinaties belonen.
Ongeveer een derde van de gesamplede sessies leverde nuttige correctievoorbeelden op. Ik herhaalde dit in drie rondes, waarbij ik telkens fouten verzamelde van het nieuwste model. Zelfcorrectie was een van de meest effectieve wijzigingen; samen met extra trainingsdata verbeterde het de score op PresetBench van 24/47 naar 38/47 bij int4.
In totaal kostte het trainen van het model en de latere verfijningen ongeveer 75 GPU-uren op een RTX 4090.
Testen
Ik gebruik drie tests:
- ShellBench: 106 commando's over 24 handgeschreven bash-sessies, gescoord op exacte output.
- PresetBench: 47 gescripte gevallen gebaseerd op fouten die ik in de demo tegenkwam.
- Geheugentest: 32 werelden waarin twee willekeurige waarden naar twee bestanden worden geschreven en later in aparte sessies worden teruggelezen. Een wereld is geslaagd als beide read-backs exact zijn (64 in totaal). De namen en waarden komen nooit voor in de training.
Hoeveel helpt fine-tuning?
| Model | ShellBench /106 | File readbacks /13 |
|---|---|---|
| Qwen2.5-Coder-0.5B, prompted | 52 | 2 |
| Qwen3-0.6B, prompted | 60 | 3 |
| WorldTUI | 80 | 9 |
Het grootste verschil zat in het geheugen: WorldTUI kreeg 9/13 readbacks correct, tegenover 2/13 en 3/13 voor de prompted modellen. Het is nog steeds ver verwijderd van een betrouwbaar bestandssysteem. Het gaat redelijk om met bekende commando's, maar het combineren ervan op nieuwe manieren kan snel fout gaan. Zo toont cat colors na echo green >> colors vaak niet de toegevoegde regel.
Snelheid optimaliseren
Een tijd lang vermeed ik autoregressieve modellen volledig. Een volledige redraw kost ongeveer 3.000 tokens, en het genereren daarvan één voor één leek hopeloos voor iets dat als een terminal moet aanvoelen. Maar omdat ze zo veel beter presteerden dan de alternatieven, gaf ik toe. Bij één token per keer voelde typen prima (~38 ms per toets), maar alles wat het hele scherm hertekende (vim test.txt, man, clear) duurde tot 12 seconden op mijn RTX 4090.
Rijen in parallel
Wat ik over het hoofd had gezien, was dat de meeste rijen in een frame niet van elkaar afhankelijk zijn. Ik voer één batch-element per rij uit. Elk element krijgt dezelfde vorige geschiedenis (inclusief KV-cache), maar genereert alleen zijn eigen update.
Dit verandert de decoderingskosten van de som van alle rijen (tot 3.000 stappen) naar de lengte van de langste rij (ongeveer 100 stappen). De cursor wordt vervolgens een eigen stap aan het einde die de volledige set rij-updates kan zien.
Dit vereiste een bijpassend attention-mask tijdens de training, maar de snelheidswinst was enorm. De p95-latentie voor grote frames daalde van 6,7 s naar 0,87 s op een RTX 4090.
Rijen die niet met elkaar kunnen communiceren
Het probleem was dat rijen, omdat ze onafhankelijk zijn, niet kunnen coördineren. Dit leidde ertoe dat vim bijvoorbeeld op rij 3 opende in plaats van rij 0, of dat er meerdere rijen tegelijk voedsel voor Snake toevoegden.
De oplossing was speculatieve decodering. Normaal gebruikt speculatieve decodering een klein model om tokens te schetsen (drafting) en een groter model om deze te verifiëren. Hier gebruik ik hetzelfde model voor beide: de parallelle rijen produceren een draft, die het model vervolgens sequentieel verifieert. De verifieerder behoudt tokens tot het eerste meningsverschil, genereert vanaf daar zijn eigen correctie en stelt de resterende draft opnieuw voor.
Op mijn M4 Mac in de browser duurt het typen van één karakter ongeveer 60ms. Een grotere update zoals man test duurt 2,5s sequentieel, of 0,9s met parallelle drafting en verificatie.
Het model verkleinen
Ik begon met Qwen2.5-Coder-0.5B (~494M parameters). Voor een webdemo moest ik dit verkleinen. De embedding-tabel voor de woordenschat van 151.936 tokens beslaat 136M parameters. Omdat ik slechts 333 tokens nodig heb, kon ik bijna alles weggooien, wat het model bracht van 494M naar ongeveer 360M parameters (990MB → 716MB). Daarna heb ik het gekwantiseerd naar int4, wat het terugbracht naar 208 MB.
De browserdemo gebruikt een custom JavaScript-implementatie van de Qwen-decoder, met compute shaders geschreven in WGSL en uitgevoerd via WebGPU. Alle inferentie draait lokaal in het tabblad.
Distillatie
Door het model te distilleren naar minder lagen, leerde ik welke lagen cruciaal zijn voor het geheugen. Bij het verwijderen van slechts laag 7 zonder opnieuw te trainen, veranderde de next-token accuratesse nauwelijks (van 99,3% naar 98,9%), maar de exacte geheugen-readbacks zakten van 64/64 naar 0/64.
Een student-model van 16 lagen dat specifieke lagen (waaronder laag 7) behield, slaagde voor alle 32 geheugentests (64/64 readbacks), zelfs bij 4-bit kwantisatie (~138MB). Er was echter een prijs: het haalde slechts 65 ShellBench-commando's correct, tegenover 78 van de 'teacher'. Een student-model kan er dus prima uitzien, terwijl het stilletjes zijn geheugen is verloren. Uiteindelijk heb ik distillatie opgegeven.
Wat het kan
Geheugen
Dit was het deel waar ik het meest in geïnteresseerd was. Een overtuigende directory-listing genereren is makkelijk. Wat ik wilde, was een bestand maken, het bewerken, wegnavigeren en terugkomen om te zien dat het ongewijzigd was gebleven. De eerste keer dat ik tmux startte, een bestand in vim opende, wijzigde, opsloeg en vervolgens in een ander tmux-venster de bijgewerkte inhoud met cat las, voelde ik me als Dr. Frankenstein: het leeft!
Andere applicaties
Het was belangrijk dat dit verder ging dan de bash-prompt, dus ik trainde op vim, python, man-pagina's, Snake en zelfs tmux.
- Vim: Het handelt de modus-switch via
escaf en laat me opslaan, afsluiten of teruggaan naar insert-modus. - Snake: Waarschijnlijk de meest rekenintensieve versie van Snake ter wereld.
- Tmux: Ik kan meerdere panes maken, verschillende dingen in elk paneel doen en ertussen springen. Het is niet perfect, maar indrukwekkend voor een klein model. Het beheert zelfs omgevingsvariabelen en variabele-expansie.
Python
Omdat het basismodel een coding-model is, was ik benieuwd naar de Python REPL. Het doet een redelijke poging: kleine toevoegingen zijn meestal correct en list comprehensions retourneren een lijst, hoewel vaak met de verkeerde inhoud. Het is ook enigszins tolerant voor typefouten: pythoon start Python, maar ython niet.
Wat niet werkt
- Internet: Ik heb de Docker-containers geen internettoegang gegeven, dus tools zoals
lynxzijn vrij saai. - Complexiteit: Python kan eenvoudige operaties aan, maar alles wat complexer is, produceert hallucinaties die doen denken aan het GPT-3 tijdperk.
- Consistentie: Soms, wanneer ik geneste directories maak, raakt het model de weg kwijt en bevind ik me plotseling in een ander pad.
- Browser-compatibiliteit: In mijn tests draaide de demo ongeveer 20× langzamer in Firefox dan in Chrome, ondanks dat beide WebGPU gebruiken.
Gerelateerd werk
In 2022 probeerden mensen ChatGPT al te prompten om als Linux-terminal te fungeren. Recentelijk simuleerde GameNGen de game Doom met behulp van diffusie, en NeuralOS pakte de desktop-GUI aan. WorldTUI ligt dichter bij de originele ChatGPT-terminaltruc, behalve dat het specifiek is getraind op toetsaanslagen en schermupdates, inclusief full-screen applicaties zoals vim en tmux.
Samenvatting
Het bouwen hiervan was een geweldige ervaring. Is het nuttig? Niet echt. Maar voor een model van 360M parameters dat in een browsertabblad draait, is het veel beter dan ik had verwacht. Wat me het meest interesseert, is dat het erin slaagt een redelijk consistente wereld te handhaven zonder dat er enige onderliggende regels zijn geïmplementeerd.
Het is overduidelijk een zwak world model, en ik ben benieuwd hoe ver deze aanpak kan gaan met betere consistentie en een andere methode voor het genereren van schermupdates, wellicht via diffusie in plaats van autoregressieve decodering.
Een volgend experiment is om te trainen op duizenden willekeurig gegenereerde applicaties, elk met eigen controls en regels. Het doel is om te zien of het model het gedrag van een applicatie kan voorspellen die het nooit eerder is tegengekomen, mits er genoeg contextuele hints zijn.
Groetjes,