Een agent gebouwd rond het niet aanroepen van de LLM

De standaard van de meeste agent frameworks is roep het model nog een keer aan. Wil je elk uur een webpagina checken? Draai de agent elk uur. Wil je in de gaten houden of er iets verandert? Vraag de LLM of er iets is veranderd. Wil je een field uit een tool result halen? Nog een round trip. De doorlopende rekening voor een agent die zo gebouwd is, is ruwweg de kosten van het model maal het aantal keer dat de agent wakker werd, en voor elk nuttig systeem is dat veel.
Het persoonlijke agent-project waar deze post over gaat is gebouwd om een andere standaard te beantwoorden — moeten we het model deze keer eigenlijk wel aanroepen? — en om elk onderdeel van het systeem zo te structureren dat het antwoord meestal nee is. Het doel dat ik vanaf dag één had was een agent die extreem kosteneffectief is om te draaien, en elke architecturele keuze die uit dat doel volgt is waar de rest van deze post over gaat.
De algehele vorm
Het project is een agent runtime rond vier onderdelen die op elkaar leunen:
-
Een main loop die met een pluggable model provider praat en een kleine registry aan tools aanstuurt die het model kan aanroepen.
-
Een scheduler die gewone Python jobs op een timer draait, hun output alleen met een LLM evalueert zodra er iets verandert, en je een mail stuurt als dat gebeurt.
-
Een provider layer die de loop ofwel met een goedkope remote endpoint ofwel met een lokaal model laat praten — uitwisselbaar.
-
Een zelf te hosten setup flow — installatiewizard, authenticatie, mail- en runtime-configuratie — zodat het pad van “clone deze repo” naar “er draait iets voor me” niet via een README en een
.env-file loopt.
Er zijn drie runtime profiles waar de gebruiker uit kan kiezen: goedkope remote, lokaal, of goedkope remote met een lokaal model dat alleen voor preprocessing wordt gebruikt. Dat zijn drie punten op de cost/capability curve, en de hele reden dat de provider layer bestaat is zodat de keuze ertussen een dropdown is en geen herschrijving.
Waarom de goedkope remote het hoofdmodel is
Het hoofdmodel is een goedkoop remote model om een saaie reden: op het moment dat ik dit bouwde, was het de goedkoopste remote optie die zich daadwerkelijk staande hield in een agent loop. Een frontier model had hetzelfde werk betrouwbaarder gedaan en zou me genoeg per run hebben gefactureerd om de “kosteneffectieve agent”-premisse binnen een week om zeep te helpen. Een agent loop is geen one-shot — het is een langlopend ding waarin elke aanroep er één van vele is, en de kosten schalen lineair met het aantal aanroepen.
De interessantere tradeoff is waarom niet lokaal. Daar ben ik wel begonnen — de eerste versie van de agent laadde direct een klein lokaal model, met grammar constraints die boolean-shaped outputs afdwongen zodat tool calls te parsen waren, en viel terug op een download URL als er geen weights op disk stonden. Wat ik bleef tegenkomen was dat kleine lokale modellen zowel te traag waren als niet accuraat genoeg voor de hot path. Inference was traag genoeg om de debug loop pijnlijk te maken, en de outputs waren inconsistent genoeg dat ik meer prompt scaffolding aan het schrijven was dan daadwerkelijke agent-logica. Het moment waarop ik tegen mijn eigen eerlijkheid aanliep was een note die ik uiteindelijk heb verwijderd — het model krijgt een aparte prompt die zegt: gebruik alleen tool results als bewijs, negeer je eigen eerdere redeneerwerk en wereldkennis. Dat is het punt waarop ik het al had opgegeven dat het lokale model zich naar het antwoord toe zou redeneren. De overstap naar een goedkope remote was alleen nog de erkenning.
Ik denk nog steeds dat lokaal draaiende modellen de toekomst zijn. De rekenkosten van frontier LLMs zijn geen houdbare basis om software op te bouwen, en het vermogen van kleine modellen verbetert snel genoeg dat het plaatje over een jaar er waarschijnlijk anders uitziet dan vandaag. De architectuur waar ik op uitkwam is gebouwd om die verschuiving op te vangen wanneer hij komt — de provider layer bestaat juist zodat de volgende keer dat een lokaal model bruikbaar is voor de hot path, hem inswappen goedkoop is. Vandaag was hij niet bruikbaar.
Tool design is het deel waar ik het meeste van genoot
De houding waar ik op dit project steeds op terugkwam, en die ik in vrijwel alles wat ik verder bouw met een LLM in de loop zou meenemen, is dat de tools het design surface zijn, niet het model. Zodra je accepteert dat het model vaststaat — je hebt je provider gekozen, je hebt je size gekozen, je gaat hem niet in een weekend fine-tunen — is alles wat er nog te ontwerpen valt de set affordances die het model ziet wanneer het aan een taak begint. Wat kan het fetchen. In welke vorm. Wat kan het produceren. Waar kijkt het zelf naar versus wat geeft het door.
Dat andere perspectief bleek nuttiger dan welke scaffolding-trick ik ook eerst probeerde. Je stelt niet meer de vraag “hoe krijg ik het model zover dat het zich naar X toe redeneert” en begint te vragen “wat zou ik een redelijk slimme intern in handen geven die X moest produceren, en welke tools zouden er op zijn bureau liggen?” Als het model worstelt, is de eerlijke diagnose meestal niet “het model is slecht” maar “ik heb hem geen tool gegeven die past bij de vorm van de taak.” De prompt herschrijven is de verleidelijke shortcut; een betere tool schrijven is meestal de juiste zet.
Een concreet voorbeeld. Een generieke “fetch een webpagina”-tool returnt de volledige HTML van de pagina, wat zowel duur in tokens is als rommelig genoeg dat het model uiteindelijk in zijn hoofd DOM parsing zit te doen. De fix is niet om het model te zeggen “negeer alsjeblieft de nav en footer.” De fix is een tweede tool — een targeted extractor die een CSS selector of een semantische omschrijving meekrijgt en alleen het deel van de pagina returnt waar het model daadwerkelijk om vroeg. Zelfde web, radicaal andere vorm, en het model verzuipt niet meer.
Het andere stuk tool design dat ik pas waardeerde toen ik er middenin zat, is dat de tool description zelf onderdeel is van de prompt. De description van elke tool komt mee in de context van het model zodra tools worden aangeboden, en die description is waar je niet alleen kunt communiceren wat de tool doet maar ook wanneer het model er wel of juist niet naar moet grijpen. Degene waar ik het meest trots op ben is de escalation tool:
De tool die hierin wordt beschreven stuurt een enkele zelfstandige vraag naar een capabeler (en duurder) model en returnt het antwoord. Het patroon waar ik op mikte is goedkoop model voor het grootste deel van de loop, slimmer model alleen wanneer het goedkope model echt vastloopt — een escape hatch die de gemiddelde kosten per aanroep laag houdt maar een vangnet overlaat voor de zeldzame vraag die daadwerkelijk meer rekenkracht nodig heeft. Het idee is geboren toen het hoofdmodel een klein lokaal model was — kleine lokale modellen missen het soort complexer redeneerwerk dat je nodig hebt voor zwaardere taken, en hen op aanvraag een escape hatch geven naar een capabeler model is goedkoper dan ofwel het lokale model permanent upgraden ofwel alles routen door een frontier API.
Eerlijke kanttekening: ik ben overgestapt vanaf het lokale hoofdmodel voordat ik kon bevestigen dat een lokaal model daadwerkelijk correct naar deze escape hatch grijpt op het moment dat het zou moeten. De tool zit er nog steeds in en het patroon klopt in principe nog steeds, maar zijn beoogde pairing is met het lokale runtime profile, en die pairing is ongetest. Met een goedkope remote als hoofdmodel is de escalation minder load-bearing, omdat de remote het meeste afhandelt wat anders de escalation zou triggeren. Wanneer ik het project weer opensla en een nieuwer klein lokaal model in de hot path zet, is kijken of dit patroon in de praktijk werkt een van de eerste dingen waar ik naar wil kijken.
Andere tools die ik om dezelfde design-surface reden ben gaan waarderen: een persistent key-value memory zodat state niet elke turn opnieuw gefabriceerd wordt; een structured page-change checker die de agent direct kan aanroepen in plaats van een pagina opnieuw de loop in te lezen; en een tool waarmee de agent een nieuwe scheduled job in het project kan schrijven die op de volgende scheduler tick wordt opgepakt — wat het onderwerp is van de volgende sectie.
De scheduler is de thesis in uitvoerbare vorm
De architecturele keuze waar ik op dit project het meest aan gehecht ben is het scheduled background-job systeem. De realisatie erachter is simpel: een hoop nuttig agent-werk — check of deze pagina is veranderd, laat het me weten wanneer deze release uitkomt, kijk naar deze feed voor een nieuw item — heeft duidelijk geen metered LLM-aanroep per uur nodig. Een LLM is meer dan capabel om de check zelf te schrijven, één keer. Een cron kan die check vervolgens eindeloos draaien zonder het model nog een keer aan te raken. De LLM hoeft pas weer terug te komen in de loop wanneer de check iets meldt dat evalueren waard is.
Zo werkt het systeem. De agent heeft een tool die een nieuwe Python module in een jobs directory laat vallen. De scheduler ontdekt de module automatisch op de volgende tick, draait hem op zijn eigen interval, houdt een persistent state file bij zodat hij tussen runs weet wat “changed” betekent, en roept de LLM alleen aan om een gedetecteerde verandering te evalueren voordat hij de gebruiker een email callback stuurt. De eerste job die ik ermee schreef houdt de homepage van een model provider in de gaten voor release-gerelateerde taal, omdat ik hem handmatig zat te checken en dat precies het soort taak is waarbij elk uur voor een LLM betalen absurd zou zijn.
Het bredere patroon is echter niet “bouw een website watcher.” Het patroon is de meeste herhaalbare automatiseringen hebben de LLM alleen nodig op het moment van bouwen, niet bij elke tick, en de taak van het systeem is om die vorm tot standaard te maken in plaats van tot workaround. Als er één feature is die dit project onderscheidt van elke andere “hier is mijn local LLM wrapper”-weekendbuild, dan is het deze. De meeste agent frameworks beantwoorden “hoe draai ik deze automatisering elk uur?” met “draai de agent elk uur.” Het antwoord van dit project is “laat de agent een cron voor je schrijven.”
Lokale modellen in een nauwere rol
Naast de scheduler zit een webpage-compression preprocessor. Hij draait een klein lokaal model op gefetchte HTML met een strikte structured-output prompt die precies deze vorm produceert:
TITLE: ...
SUMMARY: - ...
SECTIONS: [Heading] - ...
ENTITIES: - ...
KEY FACTS: - ...
OPEN QUESTIONS: - ... or 'None'
De web-fetch tool stuurt HTML optioneel door deze preprocessor voordat hij überhaupt het hoofdmodel bereikt. De oorspronkelijke reden was local-on-local: toen de main loop een klein lokaal model was, hoefde hij eigenlijk niet de HTML te lezen — hij had de content van de pagina nodig, en specifiek de content in een vorm die compact genoeg was om in zijn krappe context window te passen en gestructureerd genoeg om over te kunnen redeneren. Ruwe HTML faalde op beide punten. Een tweede motivatie kwam boven zodra een ander klein lokaal model dat ik bij de hand had, oprecht goed bleek in het schrijven van deze samenvattingen: hem voor de pipeline plaatsen verlaagde de token count op elke downstream call die naar het resultaat moest kijken. Voor een project waarvan het hele punt is om metered model-aanroepen te minimaliseren is een tokenbesparing op elke web fetch geen kleine winst.
Beide motivaties zijn vandaag nog steeds van toepassing, en ze passen netjes samen met het derde runtime profile: goedkope remote main loop, met een lokale preprocessor ervoor. Dezelfde preprocessor-rol, andere begunstigde. Het remote model ziet de ruwe pagina nooit. Zijn context window blijft schoon, zijn per-call token count blijft begrensd, en het kleine lokale model verdient zijn brood in een rol waar zijn plafond er niet toe doet — hij probeert niet te redeneren, hij probeert een compacte, voorspelbare samenvatting van iets ondoorzichtigs te produceren. Hij faalt netjes. En hij is bovendien ongeveer gratis om op dezelfde box als al het andere te draaien.
De generalisatie voorbij web scraping is wat ik in elk toekomstig project ook daadwerkelijk zou meenemen. Vrijwel elke tool die ondoorzichtige, in size onbegrensde output teruggeeft — lange file reads, verbose API responses, transcripts, scraped content — is een kandidaat voor een small-local-model compressiestap voor de main loop. De besparingen zijn groter dan ze lijken omdat ze stapelen over elke tool call in een lang gesprek, niet alleen de ergste. Ik heb dit systeem niet gebouwd als een “local vs. remote”-verhaal, en ik denk niet dat dat kader standhoudt. Ik heb hem gebouwd als een goedkoop model dat de saaie preprocessing job opvangt zodat het dure model minder ziet en er meer mee doet, en dat is een verhaal dat overleeft welke verschuiving dan ook in welk model op welke dag duur is.
De provider layer is de wegbereider
Alles hierboven — de runtime profiles, de preprocessor-rol, de uiteindelijke local-main gok — hangt ervan af dat het de agent loop niet uitmaakt met welk model hij praat. Die ontkoppeling is het stuk plumbing waar ik het minst opgewonden over ben om over te praten en het stuk dat de rest van de architectuur mogelijk maakt. Een kleine interface zit tussen de loop en welke client er ook actief is, de loop praat alleen tegen die interface, en de interface doet de rest. De payoff is dat een toekomstig klein lokaal model dat zich staande houdt in een agent loop slechts een one-file swap verwijderd is van bruikbaar zijn voor de hot path. Geen ander deel van het systeem hoeft het te weten.
Het bredere punt voorbij dit project: elk agent-project waar ik aan gewerkt heb wil uiteindelijk zo’n abstractie, en hoe eerder hij er is hoe minder pijnlijk de uiteindelijke providerwissel is. Er is altijd een uiteindelijke providerwissel.
De setup flow is een first-class surface
De beoogde gebruiker van dit project is iedereen die hem wil draaien — niet “ik en misschien een collaborator.” Dat is de reden dat de web layer zo substantieel is als hij is. De installatiewizard, de authenticatie, de mail-configuratie, de runtime-profile picker, het gebruikersbeheer — alles bestaat zodat het pad van “clone de repo” naar “je background job draait en mailt je” niet via het met de hand bewerken van config files loopt. Als de waardepropositie is “deploy dit en het stopt jou LLM tokens te kosten op routinewerk,” dan moet de setup-ervaring daadwerkelijk werken, anders valt de hele propositie om bij eerste contact met de gebruiker.
Dat argument zou ik breder trekken voor elke zelf te hosten tool. Setup is een first-class surface die het waard is om als feature te behandelen, niet als een klusje waarover je proza schrijft in een README.
Wat ik heb meegenomen uit het bouwen hiervan
Hoe meer tijd ik besteedde aan het ontwerpen van tools waar het model naar kan grijpen in plaats van te proberen het model zelf slimmer te maken, hoe meer het werk begon te lijken op de vorm van de gok die ik op LLMs in het algemeen wil maken.
Mijn lezing, nu ik dit gedaan heb: de as die er vooruitkijkend toe doet is niet hoeveel informatie een model in zijn weights heeft gepropt, maar hoe goed hij de juiste informatie kan ophalen uit een betrouwbare bron en een mening kan vormen over wat hij net gelezen heeft. Een model dat een beetje minder kennis heeft maar weet hoe het naar de juiste bron moet grijpen, hem zorgvuldig moet lezen, en je moet vertellen wat hij daadwerkelijk betekent is nuttiger dan een groter model dat met overtuiging iets herhaalt wat het zich half herinnert uit pretraining. Het eerste soort model is een probleem dat je oplost met tools. Het tweede soort is een probleem dat je oplost met parameter count, en parameter count is wat inference duur maakt.
Dit is geen nieuw patroon. Het is hetzelfde wat al lang voor LLMs bestonden gold voor mijn eigen werk. Het lastige deel van vrijwel elke taak waar ik daadwerkelijk voor ben betaald is niet handelen op basis van de informatie — het is om te beginnen de juiste informatie vinden om naar te handelen. Handelen op slechte of onjuiste informatie is erger dan niet handelen, omdat alles wat erop volgt al gecompromitteerd is; je shipt uiteindelijk iets dat met overtuiging fout is, en de kosten van met overtuiging fout zitten zijn vrijwel altijd groter dan de kosten van trager zijn. Als de input slecht is, kan de output niet goed zijn, en geen enkele hoeveelheid afwerking verderop in de pipeline fixt het probleem stroomopwaarts.
De LLM-versie is hetzelfde patroon. Pretraining data is statisch en uiteindelijk stale, en het eerlijke antwoord van het model op “wat weet je over X” is een gewogen gemiddelde van wat er over X waar was op een vast punt in het verleden. Een tool die de huidige versie van X gaat lezen, gestructureerd zodat het model hem kan consumeren, geeft het model een kans om vandaag gelijk te hebben. De ontwerpvraag is niet langer hoe kies ik een slimmer model maar wordt wat moet het model lezen, vanwaar, en in welke vorm — wat precies de ontwerpvraag is waar de tools layer van dit project omheen is gebouwd.
Dat is de gok waar de architectuur voor gebouwd is, en het is de gok die ik in de volgende versie wil blijven maken.
Waar het nu staat, en wat er hierna komt
Het project staat momenteel op de plank. Niet dood — ik ben van plan ernaar terug te keren, en de reden dat ik van plan ben terug te keren is de reden dat ik eraan begonnen ben. LLM-gebruik optimaliseren is de weg vooruit, niet hem negeren, en rekenefficiënte automatisering waarbij de LLM op het moment van bouwen wordt geraadpleegd en daarna uit de loop blijft is een betere standaard dan het “draai de agent elk uur”-patroon dat de meeste frameworks aanmoedigen. Kleine lokaal draaiende modellen gaan goed genoeg worden om op de hot path te zitten, en een architectuur die al rond die gok gebouwd is is een veel korter pad naar een werkend systeem dan het achteraf aanpassen van een remote-first stack.
Wanneer ik hem weer opensla, zijn de dingen die de volgende iteratie overleven de scheduler, het preprocessor-patroon, en de provider-abstractie. Het ding dat ik het liefst daadwerkelijk wil testen is het escalation pattern in zijn beoogde pairing — een klein lokaal model als de main loop, dat alleen escaleert naar een grotere remote als het echt vastzit. Die test is de eerste keer nooit gedraaid omdat de lokale main loop nooit ver genoeg kwam om hem nodig te hebben. Een deel van de omliggende machinerie — de installatie-flow in het bijzonder — is waarschijnlijk te zwaar opgetuigd voor waar het project op dit moment staat, en is een kandidaat voor agressieve vereenvoudiging voordat er iets nieuws bovenop komt.
Als iets hiervan overeenkomt met een probleem dat jij ook probeert te vermijden door er tokens op te verbranden, dan zou ik graag notities vergelijken.
Lees ook
-
Agentic coding vergroot de kloof voordat hij krimpt
Agentic coding loops haalden me uit de scepsis — maar de tool versterkt welke achtergrond je ook meebrengt. Die kloof gaat groter worden voordat hij krimpt.
-
Spec-driven development wordt steeds trager
De volledige spec-and-review-ceremonie bij elke taak vertraagde de build tot een slakkengang, dus heb ik superpowers geforkt in twee snelheidsbanen. De audit plakte er een concreet getal op: 27 subagent-runs voor 11 taken, waarbij één fix-loop 22 procent daarvan opslokte. Upstream bracht een dag na mijn routing-aanpassing een eigen router uit, dus het probleem was reëel.
-
Een autoresearch-lab bouwen waar ik ook echt naar kan kijken
De afgelopen zes weken doet een machine die ik heb gebouwd een deel van mijn quant-onderzoek voor me. Het stelt een hypothese voor, schrijft zelf de strategie, backtest deze over twaalf out-of-sample-vensters en vertelt me welke ideeën dood zijn. Het heeft ongeveer 700 experimenten gedraaid en niets heeft de holdout overleefd. Dat leest als een oordeel over autoresearch, maar dat is het niet: het is een oordeel over mijn harness.