← Blog

AI agents

LLM-provenance: zo herken je een verkapte GEITje-merge

Een Bredase GovTech-leverancier gaf ons een Nederlandse LLM die ze naar eigen zeggen zelf hadden gebouwd. Twee uur in de tender-review matchte de tokenizer hash een publiek checkpoint.

Jacob Molkenboer· Oprichter · A Brand New Company· 16 jun 2026· 8 min
Koperen notarisstempel op crème certificaat met dubbele afdruk, groene wasdraad, kleirode inktkussen, donkergroene achtergrond.

Donderdagochtend, 9:14, een vergaderruimte op de derde verdieping boven de parkeergarage aan de Stationsweg in Breda. Zes mensen aan tafel: drie inkopers van een Nederlands IT-samenwerkingsverband voor gemeenten, twee engineers van een GovTech-leverancier van 23 man, en wij, als technisch reviewer bij een aanbesteding voor een Nederlandstalige burgerservices-agent. In de pitch van de leverancier stond "eigen Nederlands taalmodel, in eigen huis getraind op Nederlandse infrastructuur". Het contract was een jaaromzet van de leverancier waard. Het besluit moest voor de lunch vallen.

De model card die de leverancier de avond ervoor had gestuurd, noemde een 7B-parameter decoder in LLaMA-stijl, "from scratch getraind op een gecureerd Nederlands corpus". De artefacten kwamen binnen als een safetensors-bundle van 14 GB, een tokenizer.json en een config.json. We hadden negentig minuten voor het inkoopbesluit. Het eerste bestand dat we openden was de tokenizer.

Het signaal dat de vergadering beëindigde

We berekenden een SHA-256 van de gesorteerde vocabulary en de merge-tabel, en vergeleken die met een referentieset van publieke Nederlandse en meertalige checkpoints. De hash matchte byte voor byte met de tokenizer die wordt meegeleverd met mistralai/Mistral-7B-Instruct-v0.2. Hij matchte ook met Rijgersberg/GEITje-7B, de bekendste Nederlandstalige continued-pretraining van Mistral-7B, die de Mistral-tokenizer ongewijzigd overneemt.

Een tokenizer is het ene artefact dat je niet per ongeluk reproduceert. Als je echt een model from scratch traint op Nederlandse tekst, kies je je eigen vocab-grootte, je eigen special tokens, je eigen merge-tabel. De kans dat twee onafhankelijke BPE-trainings op verschillende corpora dezelfde 32000 entries in dezelfde volgorde produceren, is feitelijk nul. De hash besliste de vergadering.

Daar lieten we het niet bij. De tokenizer-match vertelde ons dat de leverancier een ouder model had geërfd. De interessante vraag was welke, en of er meer dan één had bijgedragen. Dus gingen we door met de gewichten.

De tensor embed_tokens.weight in de bundle van de leverancier had een rij-gemiddelde cosine similarity van 0,94 met de embeddingsmatrix van GEITje-7B en 0,91 met Mistral-7B-Instruct. De lm_head zat dichter bij Mistral-Instruct. Laag voor laag volgden de onderste blocks GEITje, de bovenste blocks volgden Mistral-Instruct, met een vloeiende sigmoid-overgang in het midden. Dat is geen finetune. Dat is een SLERP-merge, waarschijnlijk gedaan met mergekit, daarna gequantiseerd, daarna gerelabeld.

Waarschuwing

Wordt het "eigen" 7B Nederlandse model van een leverancier geleverd met een tokenizer van vocab_size 32000 en de Mistral SentencePiece-layout, dan heb je een Mistral-afgeleide voor je. Vraag welke, en met welke merge-tool, voor je iets tekent.

Onhandig, niet kwaadwillig

GEITje-7B was een groot deel van 2024 het meest bruikbare Nederlandstalige open basismodel in de 7B-klasse, uitgebracht onder Apache 2.0. Het mergen met Mistral-7B-Instruct, ook Apache 2.0, om een Nederlands model te krijgen dat instructies volgt, is een verdedigbare engineering-shortcut. Het bespaart zes cijfers aan training-compute en twee maanden kalendertijd. Het probleem is niet de techniek. Het probleem is het etiket.

Een gemeentelijke inkoper die "eigen Nederlands LLM, in eigen huis getraind" leest, maakt drie aannames: de leverancier heeft controle over de trainingsdata, de leverancier kan het model patchen als er iets stuk gaat, en de leverancier bezit het IP integraal. Twee daarvan kloppen niet bij een gerelabelde merge. De trainingsdata zit in twee upstream-corpora die de leverancier nooit heeft gezien. Een merge patchen is broos, want opnieuw mergen verschuift elk gewicht in het netwerk. De IP-positie hangt af van Apache 2.0-attributie, en die had de model card van de leverancier stilletjes weggelaten.

Dit telt, omdat dezelfde leverancier meedong naar een contract waarin via het model AVG-inzageverzoeken beantwoord zouden worden. Hallucineert het model de huursubsidiegeschiedenis van een burger, dan wil het samenwerkingsverband een heldere verantwoordingsketen voor het trainingscorpus. Een gerelabelde merge knipt die keten dubbel door: één keer bij de pretraining-mix van GEITje en één keer bij de instruction-tuning set van Mistral-Instruct.

De zes-stappen provenance-diff die we nu draaien

Sinds die ochtend in Breda draaien we dit op elk model dat een leverancier presenteert in een Nederlandse GovTech-aanbesteding. Het duurt ongeveer veertig minuten per kandidaat, vereist geen speciale tooling buiten Python en de Hugging Face-stack, en gaat ervan uit dat je lokaal toegang hebt tot een kleine kandidatenset van publieke checkpoints. Wij houden de onze in object storage, versie-gepind aan de releases die leveranciers het vaakst mergen: Mistral-7B en 7B-Instruct (v0.1, v0.2, v0.3), GEITje-7B, GEITje-7B-Ultra, Llama-2-7B, Llama-3-8B en Qwen2-7B.

1. Tokenizer-vingerafdruk

Bereken een hash van de gesorteerde vocabulary en de merge-tabel. Komt die overeen met een publiek model, dan is de "from scratch"-claim van de leverancier klaar en ga je door om de ouder te identificeren. Komt die met niets publieks overeen, ontspan dan nog niet: het kan nog steeds een gequantiseerde afgeleide zijn met een opnieuw gegenereerde tokenizer.

from transformers import AutoTokenizer
import hashlib, json

def tokenizer_fingerprint(model_id_or_path):
    tok = AutoTokenizer.from_pretrained(model_id_or_path)
    vocab = sorted(tok.get_vocab().items(), key=lambda kv: kv[1])
    payload = json.dumps(vocab, ensure_ascii=False).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()

for ref in [
    "mistralai/Mistral-7B-Instruct-v0.2",
    "Rijgersberg/GEITje-7B",
    "meta-llama/Llama-2-7b-hf",
    "./vendor-bundle",
]:
    print(ref, tokenizer_fingerprint(ref))

2. Cosine similarity van embeddings

Twee modellen die een tokenizer delen, kunnen nog steeds niet-gerelateerde embeddingsmatrices hebben, als één daarvan echt from scratch is getraind op die vocab. Een rij-gemiddelde cosine similarity boven 0,85 met een publieke ouder is een afleiding, geen toeval. Wij rekenen door tegen elke kandidaat in onze referentieset.

import safetensors.torch as st
import torch

def embed_matrix(safetensors_path):
    weights = st.load_file(safetensors_path)
    return weights["model.embed_tokens.weight"].float()

vendor = embed_matrix("vendor-bundle/model-00001-of-00003.safetensors")
geitje = embed_matrix("geitje-7b/model-00001-of-00002.safetensors")
sim = torch.nn.functional.cosine_similarity(vendor, geitje, dim=1)
print("mean cosine:", sim.mean().item(),
      "p05:", sim.quantile(0.05).item(),
      "p95:", sim.quantile(0.95).item())

3. Laag-voor-laag drift-profiel

Bereken de cosine similarity per laag voor de q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj en down_proj van elk transformer block, tegen elke kandidaat-ouder. Plot het. Een schone continued-pretraining levert een vlakke lijn rond 0,95 op. Een gerichte finetune toont een vlakke lijn met een kleine dip in de bovenste lagen. Een merge produceert een stapfunctie, een sigmoid, of twee verweven banden. De vorm vertelt je de merge-familie: een schone stap is lineaire interpolatie, een sigmoid is SLERP, twee verweven banden duiden op TIES of DARE.

4. Drift-test met identieke prompts

Laat het model van de leverancier en elke kandidaat-ouder lopen tegen dezelfde vijftig Nederlandse prompts op temperature 0, max_new_tokens 256, met dezelfde seed. Bereken de Levenshtein-afstand tussen de outputs. Zitten de outputs van de leverancier voor sommige prompts binnen 8% edit distance van de ene ouder en voor andere prompts van de andere, dan heb je gedragsbevestiging die strookt met het gewichten-profiel uit stap 3.

Deze stap vangt ook het zeldzamere geval: een leverancier die heeft gefinetuned op de output van een publiek model om verder weg te lijken in weight space dan ze werkelijk zijn. De Levenshtein-test trekt zich niets aan van gewichten, alleen van gedrag.

5. Architectuur- en config-diff

De config.json wordt veld voor veld gediff'd tegen de kandidaten. Leveranciers die mergen, vergeten vaak hidden_size, num_attention_heads, num_key_value_heads, rope_theta of sliding_window aan te passen. Mistral-7B-Instruct-v0.2 heeft een kenmerkende rope_theta van 1000000.0 en geen sliding_window. v0.1 heeft sliding_window 4096 en rope_theta 10000.0. Komen die exacte waarden voor in een "from scratch" model, schrijf het versienummer dan in je rapport.

6. Licentie- en attributieketen

Lees de model card, het LICENSE-bestand, het NOTICE-bestand en de README. Is het model afgeleid van Apache 2.0-gewichten, dan moet de leverancier de upstream NOTICE-bestanden reproduceren en de oorspronkelijke auteurs vermelden. Een ontbrekende NOTICE is op zichzelf geen forensisch signaal. In combinatie met stap 1 tot en met 5 vertelt het je dat de leverancier zijn verplichtingen ofwel niet begrijpt, ofwel hoopt dat je niet kijkt. De inkoop-consequentie is identiek.

Wat we het samenwerkingsverband aanbevolen

De leverancier werd niet gediskwalificeerd. We schreven een memo van één A4, voegden de zes diff-outputs als bijlagen toe, en bevolen drie aanpassingen aan de bieding aan. Eén: herformuleer het model in het contract als "Mistral-7B-Instruct, continued-pretrained op Nederlands via GEITje, gemerged via mergekit", met de upstream licenties erbij en de merge-recipe ingecheckt in een repository die het samenwerkingsverband kan inzien. Twee: commit aan een gedocumenteerd re-merge-proces, zodat het samenwerkingsverband het artefact uit upstream kan herbouwen mocht de leverancier verdwijnen. Drie: noem een fallback-model uit de publieke Nederlandse 7B-set, met een geteste swap-procedure en een helder performance-verschil.

De leverancier accepteerde alle drie. Twee weken later gunde het samenwerkingsverband het contract, met een clausule die bij elke modelupdate een attestatie van tokenizer-hash en config voorschrijft. De inkoper vertelde ons achteraf dat het gerelabelde-merge probleem het voorgaande jaar al twee keer was opgekomen, beide keren toevallig opgemerkt, geen van beide keren vastgelegd. De zes-stappen-diff zit nu in het standaard evaluatiepakket van het samenwerkingsverband.

Het gesprek over lokale modellen dat in Nederlandse inkoop ontbreekt

Bovenaan de voorpagina van Hacker News staat deze week een thread met de vraag of iemand Claude of GPT heeft vervangen door een lokaal model voor dagelijks coderen. De antwoorden zijn gemengd, en het gesprek gaat uit van een onderlegde gebruiker die modellen kan wisselen, benchmarks kan lezen en merkt wanneer de outputkwaliteit onder zijn vingers verschuift. Nederlandse gemeentelijke inkopers hebben niets van die infrastructuur. Ze rekenen erop dat het etiket op de bundle klopt, want niemand aan hun kant gaat de diff draaien.

De goedkoopste fix is degene waar dit incident op wijst. Een aanbesteding die een specifiek model noemt, is de inkoper een herkomstdossier verschuldigd: ondertekend, met hashes voor de tokenizer, de config en de eerste en laatste rijen van de embeddings. Een zes-stappen-diff tegen publieke checkpoints kost een competente engineer een halve dag. De kosten van het niet draaien zijn een inkoopbesluit op basis van een etiket dat geen uur inspectie overleeft, op een model dat de komende drie jaar in productie vragen van burgers gaat beantwoorden.

Het kleinste wat je vandaag kunt doen

Beoordeel je dit kwartaal een "eigen" LLM van een leverancier, draai dan stap één voor je volgende meeting. De tokenizer-hash kost negentig seconden en vertelt je of de rest van de audit de moeite waard is. Toen we de herkomst-review-pipeline bouwden voor het Bredase samenwerkingsverband, zat de bottleneck niet in de diff zelf, maar in het schrijven van de attestatie-clausule die een leverancierswissel overleeft. ABN helpt Nederlandse GovTech-inkopers dit soort AI-agents herkomstaudits uit te voeren op shortlisted leveranciers, voordat er getekend wordt.

Kern

Een tokenizer-hash kost negentig seconden en maakt korte metten met de meeste 'we hebben het zelf getraind'-claims; komt die overeen met een publiek checkpoint, dan is de rest van de audit papierwerk.

FAQ

Hoe weet ik of het LLM van een leverancier echt from scratch is getraind?

Hash de gesorteerde tokenizer-vocabulary en merge-tabel. Een echt from scratch getraind model heeft een unieke BPE-tabel, dus een identieke hash met Mistral, Llama of GEITje betekent dat de leverancier dat model heeft geërfd.

Is het mergen van GEITje met Mistral-Instruct technisch legaal?

Ja, mits beide upstreams Apache 2.0 zijn en de leverancier de NOTICE-bestanden reproduceert en de upstream-auteurs credit geeft. Het juridische risico zit in het weglaten van de attributie, niet in de merge zelf.

Hoe lang duurt de zes-stappen herkomstaudit?

Ongeveer veertig minuten per kandidaat zodra publieke referentie-checkpoints lokaal klaarstaan. Stap één alleen kost negentig seconden en maakt korte metten met de meeste zwakke claims.

Wat als de tokenizer-hash uniek is voor de leverancier?

Ga door met cosine similarity van embeddings en het laag-drift-profiel. Een leverancier kan een tokenizer opnieuw genereren om een merge te verbergen, maar de gewichten dragen de vingerafdruk van de ouder nog steeds.

Moeten we leveranciers diskwalificeren die gemergde modellen presenteren als eigen werk?

Niet per se. Eis een herformuleerde modelbeschrijving, de merge-recipe in een leesbare repository, upstream licenties erbij, en een fallback-model met een geteste swap-procedure.

ai agentssecurityarchitecturestrategytooling

Iets bouwen?

Start een project