AI agents
Tokenizer drift: anatomie van een AI-agent storing van 7 uur
Een leverancier pushte op dinsdag een nieuwe tokenizer. Om 23:11 citeerde onze contract-agent zelfverzekerd clausules die niet bestonden. Zeven uur later: de postmortem.

De Slack-ping van 23:11
Een senior partner bij een Haags handelsadvocatenkantoor liep de nachtelijke output van de agent door toen ze het opmerkte. De agent had een opzegclausule in een leveranciersovereenkomst samengevat als "Artikel 12.4(b) vereist een opzegtermijn van 60 dagen." Ze pakte het contract erbij. Artikel 12.4(b) ging over vrijwaring. De opzegclausule stond in 14.1. De agent had het niet verkeerd gelezen. Hij had het verzonnen.
De Slack-ping kwam binnen om 23:11 Amsterdamse tijd op een dinsdag. Om 06:30 de volgende ochtend hadden we de agent offline gehaald, de bug getraceerd, 4.127 contracten opnieuw geëmbed en een fix uitgerold. Zeven uur. De oorzaak was niet het model. Het was een tokenizer.
Wat de agent moest doen
De klant is een juridische SaaS die mid-cap Nederlandse bedrijven helpt leveranciersovereenkomsten te beoordelen voor ondertekening. De agent leest een PDF, vindt de clausules die ertoe doen (opzegging, IP, aansprakelijkheidsplafond, toepasselijk recht, gegevensverwerking) en produceert een korte risicosamenvatting met verwijzingen naar artikelnummers. Een medewerker scant de samenvatting; een partner tekent af; een hoop declarabele uren verschuiven naar echt juridisch werk.
Het kantoor draait zo'n 320 leveranciersovereenkomsten per maand door de pipeline. In normale omstandigheden tagt de agent risicotaal die een junior zou missen en toont hij een aantal verwijzingen die een partner in minder dan drie minuten kan steekproeven. De economische logica is simpel: elk contract dat de agent correct beoordeelt, is grofweg twee uur associate-tijd die het kantoor terugkrijgt. Die logica staat of valt bij de juistheid van de verwijzing.
De pipeline:
- OCR plus layout-parsing met een op Nederlands afgestemde parser.
- Chunks van ongeveer 500 tokens met 60 tokens overlap.
- Embedden met het text-embedding-model van de leverancier.
- Opslaan in een Postgres + pgvector index.
- Per clausulecategorie de top-k chunks ophalen en doorgeven aan Claude met een strikte citation prompt.
De citation prompt was duidelijk: "Citeer het artikelnummer exact zoals het in de brontekst staat. Als het artikelnummer onvolledig of onduidelijk is, geef UNCLEAR terug." Die regel had zes maanden lang in evals gewerkt. Toen schoof een vendor-release op dinsdagochtend de bodem eronder weg.
Waar het chunken stuk ging
Nederlandse contracten nummeren clausules dicht op elkaar: Artikel 7.2.3 (a), sub (i), sub (B). De tokenizer die we gebruikten voor chunken behandelde 7.2.3 als vier tokens: 7, ., 2, .3. Dat was stabiel sinds de livegang.
Op die bewuste dinsdag pushte de leverancier een tokenizer-revisie. Lange cijfer-puntreeksen in Latijns schrift werden vanaf dat moment behandeld als één subword. 7.2.3 werd één token. Twee gevolgen:
- Elke chunk verschoof met een paar tokens. De grens die voorheen tussen alinea's viel, viel nu midden in een clausule.
- Artikelverwijzingen aan het begin van een alinea bleven soms als wees achter aan de staart van de vorige chunk:
... met inachtneming van Artikelop chunk 47, daarna7.2.3 verklaart de Leverancier ...op chunk 48.
Retrieval gaf chunk 48 terug. Het model zag een zin die begon met 7.2.3 verklaart, zonder voorafgaande artikelcontext. Het keek naar de omringende tekst, herkende een discussie over opzegging en noteerde wat een plausibel Nederlands leverancierscontract meestal zegt: artikel 12.4(b), 60 dagen opzegtermijn. De UNCLEAR-escape uit de prompt ging niet af, want het artikelnummer was niet onvolledig in de input die hij zag. Het was fout op een zelfverzekerd ogende manier.
Als je RAG-pipeline genummerde verwijzingen leest (juridische artikelen, RFC-secties, ICD-codes, SKU-strings), kan een verschuiving van 2% in tokengrenzen elke verwijzing in je corpus over een chunkgrens duwen. Het model weigert niet. Het gokt.
Drie monitoren die het misten
We hadden drie lagen monitoring en de bug liep er dwars doorheen.
Laag één was een vaste eval-set van 240 contracten met handmatig geannoteerde, correcte verwijzingen. Die draaide bij elke model-upgrade. Hij draaide niet bij wijzigingen die alleen de tokenizer raakten, want we hadden de tokenizer nooit als aparte afhankelijkheid behandeld. Hij zat binnenin de embedding-library die we op majorversie hadden gepind.
Laag twee was driftdetectie in productie. Die keek naar de verdeling van outputlengtes, het refusal-rate, de dekking per clausulecategorie en een citatievorm-check die controleerde of elk geciteerd artikel overeenkwam met het lokale patroon (cijfer, punt, cijfer, optionele letter, optionele subletter). De output zag er normaal uit. Citatievormen kwamen perfect door de regex: 12.4(b) is exact hoe een echt Nederlands artikelnummer eruitziet. Het refusal-rate bewoog niet. De agent had zelfverzekerd ongelijk op een manier die geen enkele geaggregeerde metric kon zien, want de metric controleerde syntax en de bug ging over referentie.
Laag drie was de menselijke review-queue. Het kantoor reviewde 5% van de output als steekproef. De dinsdagqueue liep uit. Tegen de tijd dat de partner het om 23:11 zag, waren elf contracten al de deur uit met foute verwijzingen. Drie ervan waren al doorgestuurd naar klanten.
De zeven uur
Globale tijdlijn, gereconstrueerd vanuit het incident-kanaal:
- 23:11. Partner pingt het on-call kanaal.
- 23:25. We halen de agent offline en zetten een onderhoudsbanner aan. De samenvattingen van de afgelopen 48 uur worden gemarkeerd voor herbeoordeling.
- 00:40. We reproduceren de foute verwijzing in staging met hetzelfde contract. Prompt, opgehaalde chunks en modeloutput komen overeen met productie.
- 01:50. We zien dat de opgehaalde chunk het artikelnummer helemaal niet bevat. We checken de chunk-store. De helft van de chunks voor dat contract heeft subtiel verkeerde grenzen. De chunks waren die ochtend herbouwd door een geautomatiseerde nachtelijke job.
- 02:30. We diffen de chunkoutput tegen de snapshot van de vorige week. Grenzen zijn vrijwel overal verschoven met 1 tot 4 tokens.
- 03:15. We vinden een tokenizer-changelog-aantekening in de release feed van de leverancier van dinsdagochtend. Eén regel. Geen deprecation-window.
- 04:10. We pinnen de eerdere tokenizer-revisie en draaien de chunker tegen een canary van 50 contracten. Grenzen komen overeen met de staat van vóór dinsdag.
- 05:30. We embedden het corpus opnieuw, alle 4.127 contracten, met de gepinde tokenizer. We draaien de eval-set. 240 van 240 slagen.
- 06:30. Agent weer online, onderhoudsbanner weg. We beginnen aan de concept-disclosuremail voor de klanten van het kantoor.
De fix in code
De vorm van de fix is simpel. Pin alles wat de vorm van tekst raakt. Behandel de tokenizer als een versioned dependency, niet als een implementatiedetail.
from tokenizers import Tokenizer
# Before: implicit version, loaded by name.
# tokenizer = Tokenizer.from_pretrained("vendor/text-embed-v3")
# After: explicit revision, pinned in source, mirrored to an internal store.
TOKENIZER_REPO = "vendor/text-embed-v3"
TOKENIZER_REVISION = "a14f0c2b9d1e" # full commit hash, not a tag
tokenizer = Tokenizer.from_pretrained(
TOKENIZER_REPO,
revision=TOKENIZER_REVISION,
)
CHUNK_TARGET = 500
CHUNK_OVERLAP = 120 # doubled from 60; index grows ~12%, references survive
def chunk(text: str) -> list[str]:
ids = tokenizer.encode(text).ids
out, i = [], 0
while i < len(ids):
out.append(tokenizer.decode(ids[i:i + CHUNK_TARGET]))
i += CHUNK_TARGET - CHUNK_OVERLAP
return out
Twee details die de moeite waard zijn om eruit te trekken. Ten eerste: de revision wordt naast elke chunk-rij in pgvector opgeslagen. Als we ooit opnieuw embedden met een nieuwe tokenizer, zijn de oude chunks zichtbaar verouderd, niet stilletjes vermengd. Ten tweede: de overlap verdubbelde van 60 naar 120 tokens. Dat is niet gratis, de index groeit met ongeveer 12%, maar elke redelijke artikelverwijzing komt nu in minstens twee chunks voor.
Anthropic publiceert een token-counting endpoint die we nu als sanity check in CI aanroepen: als de tokencount van een vast referentiecontract tussen runs meer dan een halve procent drift, faalt de build. Hun token-counting docs stonden allang in onze bookmarks. We hadden ze alleen nog niet aangesloten op het faalpad.
Drie permanente wijzigingen
We maakten drie wijzigingen die we nu op elke agent toepassen die we uitleveren.
Pin elke text-shape afhankelijkheid op volledige revision. Tokenizers, embedding-modellen, OCR-engines, alles dat tekst in andere tekst omzet. Pinnen op majorversie is niet genoeg; semantic versioning dekt niet "dezelfde string produceert nu een andere reeks integers". Als de leverancier je geen revision hash wil geven, mirror je het artefact zelf en pin je tegen je mirror.
Draai een chunk-integrity canary. Elk uur wordt een vast referentiecontract opnieuw gechunked en worden de grenzen vergeleken met een vastgezette baseline. Als één tokengrens verschuift, paged de canary on-call. Het referentiecontract heeft 47 artikelverwijzingen; de canary checkt dat alle 47 nog binnen een chunk vallen, niet op een chunkgrens. De vastgezette baseline staat in dezelfde repo als de chunker, dus een bewuste grensverandering is één PR en één review, geen stille vendor-push.
Communiceer drift vroeg naar de klant. We meldden het binnen vier uur na bevestiging van de oorzaak bij de compliance lead van het kantoor, en het kantoor meldde het binnen 24 uur bij zijn klanten. De reflex is wachten tot je een nette postmortem hebt. De juiste keuze is de vorm van het probleem flaggen zodra je hem begrijpt, en de details later opvolgen. De incident response-richtlijnen van NCSC verwoorden het anders maar komen op hetzelfde neer: wacht niet op een keurig verhaal voor je begint met de disclosure.
Het kantoor inlichten
Het disclosuregesprek is het deel van een incident dat de meeste engineering-teams onderschatten. Onze default zou zijn geweest om te wachten op een schone root cause, een nette postmortem van twee pagina's te schrijven en die dan te sturen. De compliance lead van het kantoor wilde iets anders: één alinea om 04:00 die vertelt wat we wisten, wat we niet wisten en welke contracten geraakt waren. De uitgebreide write-up kon later.
Ze had gelijk. Om 09:00 zaten de partners van het kantoor aan de telefoon met de drie klanten waarvan de contracten met foute verwijzingen waren uitgegaan. De gesprekken waren kort. De klanten wilden twee dingen weten: was het opgelost, en zou het kantoor het ze de volgende keer melden voordat ze het zelf zouden moeten vragen. Het eerlijke antwoord op beide was ja. Geen van de drie contracten was getekend puur op basis van de foute verwijzing van de agent; de partners hadden de onderliggende PDF's gelezen.
Dat laatste punt is het waard om even bij stil te staan. De agent is een herverdeler van declarabele uren, geen vervanging voor het oog van een partner. De dag dat een kantoor begint te vertrouwen op een LLM-citaat zonder de clausule te herlezen, is de dag dat de volgende vendor-patch een beroepsfoutclaim wordt. We herschreven die week de in-product copy zodat de herleesstap moeilijker te skippen is: elke samenvatting bevat nu inline de bronalinea, niet een link ernaartoe.
Hoe dit eruitziet voor jouw stack
Als je een RAG-achtige agent draait tegen welk vendor-model dan ook, drie checks van vijftien minuten elk, vandaag:
grepje codebase op tokenizer- en embedding-model-namen. Vind voor elk de versiestring. Als die eindigt op-latest, een kale majorversie of helemaal geen suffix, ben je blootgesteld aan stille drift.- Open de changelog feed van je leverancier en zoek de meest recente tokenizer- of model-patch die je niet via een deploy hebt meegemaakt. Als je er geen kunt aanwijzen, heb je hem waarschijnlijk gemist.
- Kies één referentiedocument uit je corpus. Chunk het vandaag opnieuw, sla de grensoffsets op, chunk het volgende maandag nog eens. Als zelfs één offset is verschoven, bouwt je pipeline zichzelf onder je opnieuw.
Toen we de contract-review agent voor het Haagse kantoor bouwden, liepen we ertegenaan dat een tokenizer-revisie van een leverancier elke chunkgrens met een paar tokens verschoof, zonder dat één outputmetric die we bijhielden meebewoog. We losten het op door de revision in source te pinnen, chunk-overlap te verdubbelen en een chunk-integrity canary toe te voegen. Dat soort loodgieterswerk is het grootste deel van wat we doen als we AI-agents bouwen voor klanten met echte workflows.
Kleinste ding om vandaag te doen: draai grep -RIn "from_pretrained\|encoding_for_model\|tiktoken.get_encoding" . op je agent-repo. Schrijf bij elke match de volledige revision hash erbij. Als je er geen kunt vinden, dat is de bug.
Kern
Als je RAG-agent genummerde verwijzingen citeert, ligt een ongepinde tokenizer één vendor-patch verwijderd van zelfverzekerd ogende hallucinaties.
FAQ
Wat is tokenizer drift in een RAG-pipeline?
Wanneer een leverancier een tokenizer update, splitst dezelfde tekst in andere tokens. Dat verschuift chunkgrenzen, kan belangrijke verwijzingen als wees achterlaten en verandert wat de retriever teruggeeft aan het model.
Hoe pin ik een tokenizer-versie?
Verwijs ernaar in je model loader met de volledige revision hash, niet op naam of majorversie. Mirror het artefact naar een interne store zodat verwijderingen of renames aan de leverancierskant je build niet breken.
Geldt dit ook voor OpenAI- of Anthropic-embeddings?
Ja. Elke leverancier kan tussen releases een tokenizer patchen. Het token-counting endpoint van Anthropic komt het dichtst bij een stabiel contract dat je als drift-check in CI kunt aansluiten.
Hoe vaak moet een chunk-integrity canary draaien?
Elk uur is genoeg voor de meeste RAG-agents. Het punt is om drift te vangen voordat de volgende geplande re-embed-job je index herbouwt tegen een verschoven tokenizer.