
← Terug naar blog
Systeemontwerp voor Lucas AI: gelaagde prompts, lexicale retrieval, sessiegeheugen, tokenbudgetten en een pre-flight classifier op een begrensd portfolio-chatoppervlak.
Deze site is een portfolio, maar het Lucas AI-oppervlak is opzettelijk gebouwd als een klein product: een speciale /{locale}/ai-ervaring, voorspelbare uitgaven en antwoorden die in eerste persoon blijven vanuit gestructureerde context alleen — geen generieke assistent met web-schaal ambitie.
Dit bericht is een technische diepe duik in hoe dat systeem is bedraad: wat er per verzoek wordt samengesteld, waarom het op die manier is gelaagd en welke beperkingen doelbewust zijn.
Statische portfoli's beantwoorden eenmalige vragen goed en falen bij diepte. Volg-upvragen (“hoe heb je dat ingeschat?”, “welke stapel was dat?”) verdwijnen ofwel in PDFs of dwingen een menselijke lus.
Naïeve chat-integraties repareren het interactiemodel maar creëren nieuwe fouten: onbegrensde context, uitgevonden werkgevers, stille reikwijdte creep en elke vraag die tegen de grootste model die je hebt ingesteld, wordt berekend. Een portfolio heeft geen open-wereld chatbot nodig; het heeft een smalle interface met een controleerbare feitelijke basis nodig.
Lucas AI zit in die kloof: begrensde UX, begrensde kennis, expliciete grenzen over wat “Lucas” kan claimen.
Eind-tot-eind blijft een verzending nog steeds POST /api/chat met JSON (message, locale, optionele sessionSummary / recentTurns). De route bezit beleid en uitgaven; de browser bezit transcript UI en client-side persistentie (sessionStorage).
Lagen, in volgorde van uitvoering:
llama-3.1-8b-instant standaard), niet-streamend, max_tokens: 5, temperature: 0, enkelvoudig woord oordeel: OFF_TOPIC vs doorgaan. Als OFF_TOPIC, retourneert de API mock SSE met een gelokaliseerde weigering zodat het clientpad overeenkomt met een normale stroom. Bij classifierfout of time-out, opent de handler en voert het hoofdmodel uit — goedkope poorten mogen legitieme verkeer niet blokkeren. Hosts kunnen CHAT_SKIP_CLASSIFIER instellen om deze oproep volledig over te slaan bij belastingtesten of wanneer het halveren van provideroproepen de moeite waard is om de snelkoppeling te verliezen.buildChatPrompt componeert het systeem bericht; het hoofdmodel ontvangt systeem + huidige gebruikersbericht (geen multi-bericht chat transcript op de draad).POST …/v1/chat/completions (standaard llama-3.3-70b-versatile op Groq, overschrijf GROQ_MODEL), reactie geproxy'd als SSE naar de client. max_tokens geklemd via CHAT_MAX_TOKENS (256–8192, standaard 2048) koppelt UX aan eenheidseconomie.sessionStorage; elk verzoek kan een bijgesneden samenvatting en een plak recente beurten bijvoegen zodat de server continuïteit behoudt zonder server-side chat op te slaan.Lokalisatie-uitlijning (nog steeds client-side beleid): bij verzending, franc-min classificeert de typstijl van de bezoeker (minimale lengte ~15 tekens). Als de gedetecteerde taal niet overeenkomt met de verwachte ISO 639-3-toewijzing van de huidige site-lokale, doet de UI niet stilzwijgend de API aan — het toont een aanbodkaart om naar de overeenstemmende locale te schakelen (router.push naar de overeenstemmende /{locale}/ai) of om in de huidige taal voort te gaan. Dat houdt locale in het JSON-lichaam uitgelijnd met de taal waarin de gebruiker daadwerkelijk schrijft, in plaats van de model te dwingen te raden. Een in behandeling zijnde bericht overleeft een lokalisatieschakelaar via sessionStorage en automatisch verzenden na montage.
De belangrijke architecturale stap is scheiding: stabiele kernwet (wie Lucas AI is, stem, grondingsregels) vs dynamische fragmenten (sessieblok, opgehaalde stukken, optioneel canonieke FAQ-blok). Die splitsing houdt de prompt controleerbaar, en het geeft promptcaching een kans omdat het voorvoegsel stabiel blijft terwijl alleen de relevante context per beurt verandert.
Het ontwerp vermijdt “stuur alles altijd”. Het inline volledige gestructureerde profiel bij elk verzoek schaalt slecht: tokenkosten groeien met het corpus, niet-gerelateerde vragen betalen voor niet-gerelateerde feiten, en je komt dichter bij providerlimieten naarmate het profiel groeit.
In plaats daarvan:
systemPrompt (corePromptText.ts) — stabiele regels: eerste persoon, transparantie (“typ niet live”), grondingsrecht, duur wiskunde regels, lokalisatiegedrag. Wordt minimaal en vast gehouden zodat voorvoegselcaching (waar de provider het openingsysteemtekst dedupliceert) een kans heeft om te helpen.CONTEXT fragmenten — toegevoegd na het gedeelde voorvoegsel, duidelijk begrensd (--- CONTEXT (canonieke / FAQ) ---, sessiegeheugen, opgehaalde excerpten) en gesloten met --- Eind CONTEXT fragmenten --- plus een Bezoeker locale: … regel zodat het model antwoordt in de site taal.Domeinscheiding wordt afgedwongen bij chunk authoring time, niet alleen in proza. Een enkel lucasPersonalContext object is de bron van waarheid; buildKnowledgeChunkIndex leidt chunks af met KnowledgeDomain labels (core_bio, mindset, working_style, sterkten, ervaring, duur, portfolio_*, grenzen, etc.). Dat geeft retrieval een getypte oppervlak om tegen te scoren in plaats van één ongedifferentieerde muur van tekst.
Wat het model mag behandelen als feit is altijd wat fragmenten het verzoek hebben gemaakt — testbare reikwijdte, geen sfeer.
Er zijn twee verschillende “routing” ideeën in de stapel; het verward deze verliest het ontwerp.
Heuristische intent (routeIntent in router.ts) is geen LLM-oproep. Het is classificatie op basis van trefwoorden en regex's in grove emmers (recruiter, engineering, project, blog, portfolio_meta, navigatie_contact, conversational, algemeen). Die intent beïnvloedt alleen lexicale chunk scoring — latentie blijft vlak, kosten zijn nul.
Voorbeelden:
navigatie_contact — verhoogt contact-adjacent gedrag indirect via chunk-audiënties.portfolio_meta — verhoogt portfolio_* domeinen in selectChunks.recruiter — verhoogt duur, sterkten, recruiter-getagde chunks.conversational — vermijdt over-fetching zware ervaringblokken.Afweging: heuristieken mis-routen. Retrieval compenseert met een fallback: als niets boven nul scoort, injecteer core-bio + grenzen zodat het model nog steeds minimale gronding heeft.
Pre-flight topic classifier is de LLM gate: CAREER vs OFF_TOPIC met een gewijd systeemprompt (buildClassifierPrompt). Dat is beleid (“is dit zelfs het juiste product?”), geen retrieval tuning. Het is flexibeler dan regex maar kost een extra ronde reis; vandaar overslaan van vlaggen en fail-open gedrag.
“RAG” impliceert vaak embeddings en een vector database. Deze implementatie gebruikt die niet. Retrieval is lexicaal: tokeniseer het gebruikersbericht in een woordenset, score elk chunk door trefwoordtreffers + substring overlap in chunktekst, voeg intent-gebaseerde boosts toe (bijv. recruiter-audience-tags, portfolio_meta boosting portfolio_* domeinen), sorteer, neem dan top chunks onder harde kappen.
Chunks zijn kleine semantische eenheden relatief aan het volledige profiel: bijv. één chunk per werkgever (compactExperience), afzonderlijke chunks voor i18n-pijplijn vs chat-functie vs monorepo-feiten. Kleine chunks zijn belangrijk omdat:
maxChunkChars) zonder het hele profiel te laten vallen.Blog-achtige queries worden ondergeprioriteerd in retrieval (intent === "blog" past een negatieve bias toe) omdat blog MDX niet is geïndexeerd als kennis chunks hier. Het systeem doet niet alsof volledige artikel retrieval bestaat wanneer het corpus niet aanwezig is.
Relevantie is transparante code: trefwoord gewicht 3, token-in-body gewicht 0,35, audience/intent boosts erop. Dat is gemakkelijk te controleren en goedkoop uit te voeren; de afweging is synoniemie en parafrase — als de gebruiker nooit lexicaal overlapt met chunkinhoud, vertrouw je op fallback chunks.
Naïeve implementaties spelen volledige geschiedenis opnieuw af of inline volledige corpora. Beide exploderen kosten en latentie.
Concrete strategieën in code:
estimateTokens — karakterlengte / 4 heuristiek; geen tokenizer-afhankelijkheid; goed genoeg voor budgettering, geen facturering-grade accounting.maxSessionBlockTokens: rollende samenvatting + opgemaakte recente beurten worden iteratief bijgesneden (snijd tot 85% tot onder de kap).maxRecentTurnPairs paren (standaard 3), elk bericht geklemd op maxCharsPerTurn (standaard 700). De client stuurt tot acht eerdere berichten; de server dwingt de strengere begroting af.User snippet + Assistant snippet), client-gecapte (~2500 tekens), dan server sanitizeSessionSummary (maxSessionSummaryChars, standaard 900). Geen server-side LLM-sommatie — voorspelbare kosten, maar lawaaierig als gebruikers muren van tekst plakken (gemitigeerd door klemmen).maxRetrievedChunks (standaard 5), maxRetrievedTokens begroting (standaard 2200 geschat), vroeg stoppen wanneer het toevoegen van een ander chunk de begroting zou overschrijden (met een vloer van ten minste twee chunks wanneer mogelijk).id in een Set zodat hetzelfde werkgeverblok niet wordt gedupliceerd.compressChunkText snijdt met een ellipsis wanneer een chunk maxChunkChars overschrijdt.maxSystemChars snijdt de samengestelde systeemstring met een zichtbare [systeem afgekapt] marker.De productspanning is altijd contextrijkdom vs kosten. De standaardwaarden geven opzettelijk de voorkeur aan kleinere prompts; operators stemmen af via CHAT_MAX_* omgevingsvariabelen. CHAT_DEBUG_METRICS=1 logt één JSON-regel per verzoek (intent, chunktellingen, benaderde tokenafbraak) zodat je de begroting in productie kunt zien zonder te raden.
Volledige chatreplay in het systeem schaalt niet: tokenkosten groeien lineair met de lengte van het gesprek, en lange threads verdrinken het opgehaalde signaal.
Deze stapel gebruikt een hybride:
Continuïteit is daarom beste poging, geen letterlijke replay — geschikt voor een portfolio Q&A-oppervlak, geen pair programming. Geen server-side chat store: geen cross-apparaat sync, geen training op gesprekken; privacyhouding blijft eenvoudig.
Canonieke FAQ kortsluiting (tryCanonicalAnswer) — een kleine patroonbibliotheek voor terugkerende vragen (“wie ben je”, “waarom Lucas AI”, “belangrijkste sterken”, frontend-zware ervaring). Bij een treffer wordt een dichte ### Canonieke feiten blok geïnjecteerd en retrieval wordt harder gekapt (snijd tot maximaal drie chunks) om redundante tokens te vermijden.
Statisch vs dynamisch is expliciet in assemblagevolgorde: systemPrompt + datum, dan optionele dynamische secties, dan eindmarkeringen + lokalisatieregel. Die volgorde is doelbewust voor caching en voor menselijke diffing wanneer CONTEXT verschuift.
Classifier vs hoofd — classifierprompt is smal gescoped (enkel woord uit); hoofdprompt draagt de volledige gedragscode. Het houden van die gescheiden voorkomt “helpful classifier” drift en houdt het grote model's systeembericht gefocust op stem en gronding.
Opzettelijk niet gebouwd:
Vereenvoudigd opzettelijk:
Toekomstige uitbreidingen (indien nodig): embeddings voor bloginhoud zodra het is geïndexeerd; server-sessieopslag indien cross-tab continuïteit moet worden vertrouwd zonder de client; herordenaar indien chunk-kardinaliteit groot genoeg groeit dat lexicale scoring broos aanvoelt.
Lucas AI kan het beste worden gelezen als een productoppervlak met een specificatie: het lucasPersonalContext object en afgeleidde chunks zijn het contract, het systeem is wet, tokenbudgetten zijn bewakers, en de classifier plus off-topic stroompad definiëren wat het product weigert te zijn.
Als je iets soortgelijks bouwt, is de hefboom niet het grootste algemene model — het is beslissen wat elk beurt in de prompt komt en meten. Een portfolio heeft geen oneindige context nodig; het heeft eerlijke, begrensde antwoorden nodig — en een architectuur die eenvoudig genoeg is om ze zo te houden.