Direct naar inhoud
Terug naar projecten
Eigen project

Digital-twin: een AI-agent die namens mij antwoordt met bronnen

Een LangGraph-agent over mijn cv, projecten en artikelen die vragen van bezoekers beantwoordt in een streaming chat. Elke bewering met bron, weigeren zonder bron, een hard dagbudget en menselijke goedkeuring voordat hij namens mij handelt.

Iedereen kent de demo: een chatbot over “jouw documenten” die overtuigend antwoordt in een strakke interface. Dat kost een middag. Wat de demo niet laat zien is wat er op dag drie gebeurt — als een bezoeker “negeer je instructies” intypt, als iemand hem zijn Python-huiswerk laat schrijven op jouw API-budget, of als hij vol overtuiging een baan verzint die je nooit had.

Dit project is gebouwd om dat gat te dichten. De chat was het makkelijke deel. Het interessante deel is alles rondom het model dat het veilig maakt om hem onbeheerd te laten draaien: guards die geen model nodig hebben, een kostenlimiet die fail closed is, een goedkeuringsstap voor het ene pad dat namens mij handelt, en evaluaties die elke wijziging bewaken.

Guards

Fail closed, zonder model

Evaluatiegrens

95% tools · 90% taken

Risicovolle acties

Menselijke goedkeuring

Dagbudget

Hard plafond, overleeft herstart

Python · FastAPI
LangGraph
Postgres + pgvector
React + Vite
Server-sent events
Gemini / OpenAI / DeepSeek / Kimi
58 tests, ruff schoon
Systeemarchitectuur van digital-twin in vijf kolommen: browser (React + Vite, SSE-events, thread_id per gesprek), FastAPI (POST /api/chat met rate limit 20 per 10 minuten per IP, POST /api/feedback, /api/admin/approvals met bearer token, pydantic-validatie), agent (LangGraph-graph guard → generate → tools → verify, guards, tools, BudgetTracker, checkpoint per thread), providerlaag (system prompt + kennisbank als stabiel prefix, chat-completions client met retry, kostenberekening per beurt) en modellen (Gemini, OpenAI, DeepSeek, Kimi; één .env-variabele schakelt om). Daaronder Ruud als beheerder, de kennisbank en Postgres + pgvector met de tabellen checkpoints, pending_approvals, turn_log, budget_ledger, guard_incidents, feedback, contact_messages, knowledge_chunks en eval_runs.
Eén beurt loopt van browser via FastAPI naar de agent en het model; alles wat de agent doet, landt in Postgres.

Waarom dit project

Ik wilde weten waar de grens ligt tussen een agent die demonstreert en een agent die je een weekend alleen durft te laten. Die grens ligt niet bij de prompt en niet bij het model. Die ligt bij de vragen die elke organisatie tegenkomt zodra een assistent op de eigen kennis publiek wordt: hoe garandeer je dat antwoorden onderbouwd zijn, hoe voorkom je dat hij zonder toezicht geld uitgeeft of handelt, en hoe weet je dat een wijziging het beter maakte in plaats van anders?

Mijn eigen cv, projectbeschrijvingen en blogposts zijn daar een goed proefterrein voor: klein genoeg om in een systeemprompt te passen, precies genoeg om elke bewering na te lopen, en met één actie — een bericht aan mij opstellen — die echt iets doet en dus echt fout kan gaan.

De opzet

De keten is bewust dun. Een kleine React-app praat over server-sent events met FastAPI; FastAPI geeft de beurt door aan een LangGraph-agent; de agent raakt precies één keer per stap een model aan via een OpenAI-compatibele client; en alles wat er gebeurt, wordt in Postgres vastgelegd. Er zit geen framework-magie tussen: routering, toolexecutie en verificatie zijn gewone Python.

De modelprovider is een configuratieschakelaar. Dezelfde client praat met Gemini, OpenAI, DeepSeek of Kimi; één variabele in .env bepaalt welke. Dat is geen luxe maar risicobeheersing: de kennisbank en de guards zijn van mij, het model is inwisselbaar.

Drie lagen, elk apart testbaar

01

Browser en API

De client stuurt per gesprek een thread_id en de volledige historie mee, en leest een stroom SSE-events terug: tekst, citaties, toolmeta, trace, goedkeuringsstatus, klaar. FastAPI valideert elke payload met pydantic, beperkt tot 20 verzoeken per 10 minuten per IP-adres, en biedt een feedback-endpoint en een admin-endpoint voor goedkeuringen dat uit staat zolang er geen bearer token is geconfigureerd.

02

Agent en providerlaag

De graph guard → generate → tools → verify, met drie tools (zoeken in de kennisbank, beschikbaarheid melden, contactbericht opstellen) en een BudgetTracker met een dagplafond in dollars. De providerlaag zet systeemprompt en kennisbank als stabiel prefix in elke aanroep, parseert de SSE-stroom van het model, en probeert alleen opnieuw zolang er nog niets naar de bezoeker is gestuurd.

03

Postgres als geheugen en logboek

Checkpoints per thread, de goedkeuringswachtrij, een beurtenlog met kosten en latency, een budgetgrootboek, incidenten van de guards, feedback en onbeantwoorde vragen, en de kennisbank als chunks met embeddings in pgvector. Zonder DATABASE_URL draait alles in geheugen — handig voor tests, en niets overleeft dan een herstart.

De agent-graph: één beurt, van vraag tot antwoord

Eén chatbeurt is een kleine toestandsmachine. Van de zes nodes praat er precies één met een model; de andere vijf zijn deterministische Python en dus gewoon te testen met een nep-model. Dat is de ontwerpkeuze waar de rest van het project uit volgt.

De agent-graph als stroomschema: START → guard_input (dagbudget bereikt? prompt-injectie? kaart- of rekeningnummer? duidelijk off-topic?). Bij een afgegane guard → refuse (beleefde weigering zonder één token uit te geven) → END. Anders → generate, de enige node die een model aanroept; die streamt tekst, verzamelt citaties en telt tokens en kosten op. Een gewone tool gaat naar execute_tools (deterministische Python met timeout), een hoog-risico tool naar request_approval (interrupt: de graph pauzeert en wordt gecheckpoint, de bezoeker krijgt een melding) en pas na het besluit van Ruud naar execute_tools. Toolresultaten gaan terug naar generate, maximaal drie rondes. Zonder tools → verify (kosten bij het budget, lang antwoord zonder citatie wordt gemarkeerd, trace naar de client) → END.
Eén node praat met een model; routering, guards, toolexecutie en verificatie zijn gewone Python.

Wat elke node doet

guard_input

Screenen vóór er een token is uitgegeven

Is het dagbudget bereikt? Staan er prompt-injectie-markers in de invoer? Een kaart- of rekeningnummer? Een verzoek dat overduidelijk off-topic is (“schrijf een script voor me”)? Elk van die controles is een paar regels Python zonder model, kost niets en kan niet crashen. De node reset ook de per-run state, zodat een vorige beurt nooit doorlekt.

refuse

Beleefd weigeren als volwaardige uitkomst

Een afgegane guard eindigt in een nette omleiding — “dat staat niet in mijn kennisbank, vraag het Ruud direct” — en in een regel in guard_incidents. Het model is dan niet aangeroepen. Weigeren is geen foutpad maar een van de geteste antwoorden.

generate

De enige node die een model aanroept

Krijgt systeemprompt plus de volledige kennisbank als stabiel prefix, streamt tekst naar de bezoeker terwijl het binnenkomt, verzamelt citaties uit het antwoord en telt tokens en kosten op — echte getallen uit het gebruiksrapport van de provider, inclusief cachehits.

execute_tools

Deterministische uitvoering met timeout

Drie tools, elk met een strikt JSON-schema en een timeout. Argumenten worden altijd geparsed, nooit op tekst gematcht. Een tool die faalt geeft een nette foutmelding terug in plaats van de beurt te laten crashen. Een hoog-risico tool zonder goedkeuring wordt hier geweigerd, niet uitgevoerd.

request_approval

Pauzeren tot een mens heeft gekeken

Grijpt het model naar draft_contact_message, dan roept de graph interrupt() aan: de state wordt gecheckpoint, het verzoek gaat in pending_approvals, en de bezoeker hoort dat het op beoordeling wacht. De thread blijft staan tot ik een besluit heb genomen.

verify

Nacontrole en boekhouding

Boekt de kosten van de beurt bij het dagbudget, markeert een lang en stellig antwoord zonder één citatie als hallucinatierisico, en stuurt de trace — de latency per node — als diagnostisch event mee naar de browser.

Toolresultaten gaan terug naar generate, maar niet eindeloos. Na drie toolrondes wordt tool_choice op “none” gezet, zodat het model in tekst móét antwoorden — ook als het blijft aandringen. Elke node schrijft daarbij zijn eigen latency in de trace, dus als een beurt traag is, is te zien waar.

Het model is het enige onderdeel dat kan improviseren. Alles ervoor en erna is deterministische Python — en faalt dicht: bij twijfel geen antwoord in plaats van een gokje.

Niets onomkeerbaars zonder mens

Van de drie tools handelt er één namens mij: een contactbericht opstellen. Die is gemarkeerd als hoog risico, en dat label is geen instructie in de prompt maar een eigenschap van de tool die de graph afdwingt. Het pad hieronder is de reden dat LangGraph in dit project zit — een interrupt die de state duurzaam wegschrijft en later, in een ander proces, weer oppakt, wil je niet zelf bouwen.

Sequentiediagram van de goedkeuringsflow. Bezoeker → browser: “Kun je dit doorgeven aan Ruud?”. Browser → FastAPI: POST /api/chat met thread_id. FastAPI → agent: astream. guard_input: geen guard afgegaan. generate → model, dat een tool call draft_contact_message teruggeeft. Notitie: hoog-risico tool, nooit zonder goedkeuring. request_approval roept interrupt() aan, de thread wordt gecheckpoint in Postgres en in pending_approvals gezet. FastAPI stuurt SSE “voorgelegd ter goedkeuring” plus approval pending en done; de browser toont een melding. Later — de thread blijft gepauzeerd staan. Ruud → FastAPI: POST /api/admin/approvals/{thread}/decision met bearer token. FastAPI → agent: Command(resume = approved). De agent laadt het checkpoint, execute_tools wordt nu wél uitgevoerd, generate maakt het antwoord af, contact_messages en turn_log worden geschreven, en Ruud krijgt 200 — besluit verwerkt.
De bezoeker krijgt meteen antwoord; de actie zelf wacht op mijn besluit, ook als dat pas dagen later komt.

Wat er precies gebeurt

  1. 01

    Het model vraagt om draft_contact_message. De graph ziet het risicolabel en gaat naar request_approval in plaats van execute_tools.

  2. 02

    interrupt() pauzeert de graph. De volledige state wordt als checkpoint in Postgres gezet en het verzoek komt in pending_approvals.

  3. 03

    De bezoeker krijgt via SSE te horen dat het bericht ter goedkeuring is voorgelegd — de beurt eindigt netjes, zonder dat er iets is verstuurd.

  4. 04

    Ik keur goed of af via het admin-endpoint, met bearer token. Een Command(resume) laadt het checkpoint en laat de graph verdergaan waar hij stond.

  5. 05

    Pas nu draait execute_tools. generate maakt het antwoord af, contact_messages en turn_log worden geschreven.

Zonder bearer token staat het admin-endpoint uit. Dan kan er niets goedgekeurd worden — en dus ook niets verstuurd.

De vraag bij een agent is niet “kan hij dit?”, maar “wat gebeurt er als hij het niet had moeten doen?”

Wat er om het model heen staat

Op elke laag geldt dezelfde regel: bij twijfel geen antwoord in plaats van een gokje. Drie lagen zitten vóór het model en kosten geen enkele token. Twee zitten erna en kijken naar wat het model heeft gezegd — zonder het model te vragen of het klopte.

Citaties zijn native aan het antwoord: het model noemt bij elke bewering het document waar die uit komt. Het deel dat ertoe doet zit daarna. Een citatie wordt alleen doorgegeven als de genoemde titel echt in de kennisbank staat; anders wordt ze stil weggelaten en gemarkeerd. En een lang, stellig antwoord zonder één citatie en zonder toolresultaat wordt gelogd voor handmatige review. Het model bepaalt niet zelf of het onderbouwd was.

0

tokens uitgegeven voordat transport, budget en input-guards zijn gepasseerd

20

verzoeken per 10 minuten per IP-adres; maximaal 40 beurten per gesprek

3

toolrondes per beurt; daarna moet het model in tekst antwoorden

Vijf lagen om het model, van links naar rechts: 1 transport (20 verzoeken per 10 minuten per IP, pydantic-validatie op rol, lengte, maximaal 40 beurten, laatste beurt van de bezoeker), 2 budget (dagplafond in dollars, bereikt is beleefde weigering vóór er een token wordt uitgegeven, overleeft een herstart), 3 input guards (prompt-injectie, kaart- en rekeningnummers, off-topic; plain Python, geen model dat over zichzelf oordeelt), het model (system prompt plus volledige kennisbank in vaste volgorde; het enige onderdeel dat kan improviseren), 4 citatiecontrole (alleen doorgeven als de titel echt in de kennisbank staat, anders stil laten vallen en markeren) en 5 groundedness (lang, stellig antwoord zonder citatie en zonder toolresultaat wordt gemarkeerd voor handmatige review). Daaronder het toolcontract, wat per beurt wordt vastgelegd, en de evals met drempels 95% en 90% en failure injection.
Drie lagen vóór het model kosten geen token; twee lagen erna controleren zonder het model te vragen.

Vijf lagen, van buiten naar binnen

01

Transport

Rate limiting per IP-adres en pydantic-validatie op elke payload: rol, lengte, maximaal 40 beurten, en de laatste beurt moet van de bezoeker zijn. Een publiek endpoint dat een LLM aanroept is anders een open portemonnee.

02

Budget

Een dagplafond in dollars. Is het bereikt, dan weigert de agent beleefd tot middernacht — vóórdat er één token is uitgegeven. Het grootboek staat in Postgres, dus een herstart zet de meter niet op nul.

03

Input guards

Prompt-injectie, kaart- en rekeningnummers, duidelijk off-topic verzoeken. Plain Python — geen model dat over zichzelf oordeelt, en dus ook geen model dat omgepraat kan worden.

04

Citatiecontrole

Elke citatie in het antwoord wordt vergeleken met de titels in de kennisbank. Wat niet bestaat, bereikt de bezoeker niet en wordt gemarkeerd. Een verzonnen bron is daarmee een meetbare gebeurtenis, geen verrassing.

05

Groundedness

Een lang, feitelijk klinkend antwoord zonder enige citatie en zonder toolresultaat is verdacht. Het wordt gemarkeerd als hallucinatierisico en gelogd, zodat ik het kan nalopen en, als het structureel is, in de evaluatieset kan opnemen.

Het toolcontract

  • Strikt JSON-schema per tool: elke property verplicht, geen extra velden.

  • Argumenten worden altijd geparsed, nooit op tekst gematcht.

  • Elke tool heeft een timeout; een tool die faalt geeft een nette foutmelding terug in plaats van de beurt te laten crashen.

  • draft_contact_message is hoog risico en draait nooit zonder expliciete goedkeuring.

Per beurt wordt vastgelegd: uitkomst, tokens, kosten en latency in turn_log, plus guard_incidents, unanswered_questions, budget_ledger en de feedback van bezoekers.

Kennisbank en dataplatform

De kennisbank is klein en bewust zo gehouden: cv, projecten, vaardigheden en een over-mij, als Python-modules in knowledge/. Bij het opstarten worden ze in vaste volgorde als markdown in de systeemprompt geladen — een stabiele prefix, waardoor vervolgbeurten grotendeels uit gecachete tokens bestaan.

Diezelfde documenten worden per kop opgedeeld, gehasht en geëmbed in pgvector. De synchronisatie is incrementeel: alleen alinea's waarvan de hash veranderde, worden opnieuw geëmbed. De zoektool rangschikt op cosinusafstand, zodat een vraag die anders is geformuleerd dan het document toch het juiste stuk vindt.

Alles wat de agent doet, staat in Postgres — met alembic-migraties, zodat het schema net zo in git staat als de code. Zonder DATABASE_URL draait het geheel in geheugen; dan overleeft niets een herstart, en dat is precies het verschil tussen een test en productie.

De onbeantwoorde vragen zijn de nuttigste tabel van allemaal: daar staat wat bezoekers wilden weten en wat er dus in de kennisbank ontbreekt.

Tabellen in Postgres
checkpointsLangGraph-state per thread — de pauze bij een goedkeuring overleeft een herstart
pending_approvalswachtrij van hoog-risico acties, met besluit en tijdstip
turn_logelke beurt met uitkomst, model, tokens, cachehits, kosten en latency
budget_ledgerdagplafond dat een herstart overleeft
guard_incidentswelke guard wanneer afging, en waarop
feedback · unanswered_questionsduim omhoog of omlaag per antwoord, en vragen waar de kennisbank geen antwoord op had
contact_messagesgoedgekeurde berichten aan mij
knowledge_chunkskennisbank per kop, met hash en embedding, incrementeel bijgewerkt
eval_runs · eval_caseselke evaluatierun bewaard, zodat kwaliteit over tijd te volgen is

Kwaliteit wordt gemeten, niet gehoopt

“Werkt het?” is bij een agent een vage vraag. Die is vervangen door twee gelabelde datasets die tegen de echte graph draaien, met een drempel waaronder de run faalt.

WatGrensBetekenis
Toolselectie12 cases · ≥ 95%pakt de agent de juiste tool — en nooit een risicovolle die hij niet mag pakken
Taakvoltooiing11 cases · ≥ 90%bevat het antwoord wat het moet bevatten, citeert het wanneer het moet, weigert het wanneer het moet
Failure injectionelk 2e verzoekelk tweede HTTP-verzoek wordt gedropt; de retries van de client moeten dat opvangen, anders zakt de run
Unit en integratie58 testsguards, budget, toolcontract, graph-routering en de SSE-stroom, met een nep-model
Lintruff, elke pushCI draait bij elke push via GitHub Actions
Historieeval_runselke run wordt bewaard, zodat een wijziging vergeleken kan worden met de vorige

De taakvoltooiingscases bevatten bewust vragen waarop het juiste antwoord een weigering is. Weigeren wordt dus net zo grondig gemeten als antwoorden — anders wordt de agent op den duur beloond voor het verzinnen van een bron.

De failure-injection-modus is er omdat een streaming-endpoint op twee manieren kan falen: vóórdat er iets is verstuurd, en middenin. De providerlaag probeert alleen opnieuw in het eerste geval; in het tweede geval krijgt de bezoeker een nette afbreking in plaats van een half antwoord dat twee keer begint.

Wat de controles wél en niet garanderen

Een guard die er indrukwekkend uitziet maar meer belooft dan hij waarmaakt, is gevaarlijker dan geen guard. Daarom staat precies opgeschreven wat elke laag doet.

01

De citatiecontrole checkt het bestaan, niet de inhoud

Een citatie komt alleen door als de genoemde titel echt in de kennisbank staat. Of de bewering ook in dat document staat, controleert de laag niet. Dat vangt verzonnen bronnen, niet verkeerd toegeschreven beweringen; daarvoor zijn de taakvoltooiingscases er.

02

Groundedness markeert, blokkeert niet

Een lang antwoord zonder citatie wordt gelogd voor handmatige review, niet tegengehouden. Een hard blok zou te veel goede antwoorden raken — “nee, daar weet ik niets over” heeft ook geen citatie. De keuze is bewust: meten eerst, blokkeren pas als de data laat zien dat het nodig is.

03

De input guards zijn patronen, geen classifier

Prompt-injectie en off-topic worden op patronen gescreend. Dat is snel, deterministisch en kan niet omgepraat worden, maar het is geen volledige verdediging. De echte verdediging is dat het model niets onomkeerbaars kán doen: de enige tool die handelt, wacht op mij.

04

In geheugen is geen productie

Zonder DATABASE_URL werkt alles, maar overleeft niets een herstart — ook het budget en de goedkeuringswachtrij niet. Dat is expres zo gehouden voor tests en lokale runs; in productie is Postgres geen optie maar een vereiste.

Ontwerpkeuzes

Een paar afwegingen waar ik het langst over heb nagedacht.

01

Guards zonder model

Het is verleidelijk om het model te vragen of een invoer veilig is. Maar een model dat over zichzelf oordeelt, kan met dezelfde truc omgepraat worden als het model dat antwoordt. Deterministische Python is saaier, kost geen tokens, kan niet crashen en is gewoon te unit-testen.

02

Kennisbank in de prompt én in pgvector

De volledige kennisbank past in de systeemprompt, en dankzij prompt caching is dat goedkoop: de prefix is stabiel. De pgvector-zoektool is er voor gerichte vragen — hij vindt het juiste stuk ook als de bezoeker het anders formuleert dan ik het opschreef. Geen van beide alleen was genoeg.

03

Provider als configuratie, retries alleen vóór het streamen

Eén OpenAI-compatibele client over httpx voor Gemini, OpenAI, DeepSeek en Kimi. De retry-met-backoff geldt alleen zolang er nog niets naar de bezoeker is gestuurd; een afgebroken stream wordt nooit stilletjes opnieuw gestart. Een goedkope standaardprovider plus caching houdt een dag gesprekken onder een paar dollar — en het plafond garandeert dat.

04

LangGraph voor de interrupt, niet voor de chat

Voor een gewone chatbeurt is LangGraph overkill. Het is er voor één ding: interrupt() met een checkpointer in Postgres, zodat een gepauzeerde thread dagen later in een ander proces kan worden hervat. Dat zelf bouwen is precies het soort code dat subtiel fout gaat.

Wat dit project laat zien

LLM-agent in productie: LangGraph, streaming, tools
Deterministische guards en fail-closed ontwerp
Human-in-the-loop met duurzame interrupts
Kostenbeheersing: budget, caching, telemetrie per beurt
Postgres + pgvector als geheugen en logboek
Evaluatiesets met drempels en failure injection
FastAPI, SSE en een React-client
CI met tests en lint bij elke push

Elke organisatie die een assistent op haar eigen kennis wil, loopt tegen dezelfde vragen aan — niet “welk model”, maar hoe je onderbouwing, kosten en handelingen onder controle houdt. Deterministische guards, een budget dat fail closed is, menselijke goedkeuring op het risicovolle pad en evaluaties die de pipeline bewaken zijn één op één overdraagbaar.

De assistent spreekt óver mij, nooit namens mij: alles wat mijn inbox bereikt, passeert eerst mijn goedkeuring.

Achtergrondartikel op het blog: “Een digital-twin die alleen zegt wat hij kan bewijzen”.