AI agents
Douane-classificatie agent: LangGraph, CrewAI of zelfbouw
Drie orchestrators, één douane-classificatie agent, 3.200 lookups per week. We scoorden LangGraph, CrewAI en een zelfgebouwde Claude-loop op kosten, replay en zondagavond-patches.

Zondag, 21:14. Het kantoor van een 19-koppig Dordts expeditiebureau ligt donker. Twee declaranten zijn thuis, een derde rondt in Antwerpen een feeder af. De EU heeft net een TARIC-kwartaalwijziging in het Publicatieblad gepubliceerd — 312 codes gewijzigd, zes daarvan in hoofdstukken die deze klant elke dag raakt. Tegen maandag 06:30 hebben de eerste 47 aangiftes een HS-code nodig die niet wordt geweigerd op het Douane-portaal. Wie de douane-classificatie agent beheert, beheert deze zondagavond.
Die zin — wie beheert de zondagavond — bleek de hele framework-vergelijking te zijn.
De aangifte die we wilden automatiseren
Onze klant importeert polymeer-compounds, technische folies en een lange staart aan afgewerkte kunststofproducten. Een declarant pakt een leveranciersfactuur, zoekt de juiste 10-cijferige TARIC-code, controleert preferentiële oorsprong tegen het handelsverdrag, berekent invoerrechten en BTW, en dient de aangifte in via AGS. Dat gebeurt voor ruwweg 3.200 regels per week. Zo'n 70% zijn herhalingen van dezelfde materialen; de overige 30% is waar fouten duur worden.
De agent die we moesten bouwen had een kleine, vieze taak: parse de factuur-PDF, stel een HS-code voor met een betrouwbaarheidsband, toon twee of drie plausibele alternatieven met motivatie, en schrijf alles weg op een plek waar een Douane-inspecteur in 2033 nog bij kan. De orchestratielaag is waar de rest van deze post over gaat.
LangGraph: de graph die te veel wist
We hebben eerst een prototype gebouwd op LangGraph. Het mentale model van een gerichte graph past netjes op het werk: parse → propose → cross-check chapter → confirm origin → draft aangifte. LangGraph heeft een Postgres-checkpointer, replays zijn een gedocumenteerde feature, en het ecosysteem eromheen is volwassen. Binnen vier dagen hadden we een werkend prototype. De graph compileerde, de checkpointer schreef rijen weg, replays werkten op het happy path. Op papier was dit het antwoord.
Drie dingen beten ons.
Ten eerste: het state-object is van het framework. De checkpoint-tabel bewaart geserialiseerde state — vandaag leesbaar, in 2033 alleen te decoderen als de deserializer dan nog bestaat in de vorm die we nu hebben. Als een Douane-inspecteur vraagt “wat zag de agent, en waarom koos hij 3920.10.81 boven 3921.19.00 op aangifte 2026-04-17-0231”, willen we één SQL-query, geen Python-virtualenv vastgezet op de versie die we toen gebruikten. We hebben dit concreet getest: we laadden een checkpoint geschreven door een graph-definitie van twee minor versies terug. Hij las in, maar een node die we sindsdien hadden hernoemd liet stilletjes zijn tool-resultaat vallen bij replay. In development goed op te lossen. Niet het soort voetnoot dat in een douanedossier hoort.
Ten tweede: de mensen die de graph moeten kunnen aanpassen, kunnen dat niet. Een node bewerken is een Python StateGraph bewerken. Onze klant heeft twee declaranten die overweg kunnen met een YAML-bestand en een kleine admin-UI. Geen van beiden gaat op zondagavond een service opnieuw deployen. We hadden er een dunne admin-laag overheen kunnen bouwen die de live graph muteerde, maar op dat punt waren we het mentale model van LangGraph aan het tegenwerken in plaats van het te gebruiken.
Ten derde: retries op rate limits van tool-calls gaven ons niet-deterministische stap-aantallen in de trace. Op te lossen, maar meer code dan we wilden schrijven om een eigenschap te verdedigen die we vanaf de eerste regel al wilden. Determinisme is goedkoop om in te ontwerpen en duur om er achteraf aan vast te schroeven.
CrewAI: rollen op zoek naar een workflow
Vervolgens probeerden we CrewAI met drie rollen — classifier, auditor, scribe — en lieten ze samenwerken. Voor een open onderzoekstaak is deze stijl echt elegant. Voor een douaneaangifte met een papieren spoor naar de regelgever is de autonomie een belasting die we niet wilden betalen.
In de eerste drie runs konden we niet één agent aanwijzen en zeggen “deze heeft besloten.” De trace was een gesprek, geen beslisboom. In één vroege run onderhandelden de classifier en de auditor drie beurten over dezelfde tariefpost voordat één toegaf; het antwoord klopte, maar het pad las als de notulen van een kleine commissie. We trokken de taken en tools zo strak aan dat de “crew” in feite een sequentiële pipeline was — en op dat moment was het framework overhead. Persistentie hadden we sowieso op Postgres moeten bouwen. CrewAI is een scherp gereedschap. Niet de vorm waarvoor het geslepen is.
De zelfgebouwde loop met Postgres als bron van waarheid
We hebben een zelfgebouwde Claude tool-use loop uitgerold. Ongeveer 600 regels Python. De state machine is één tabel:
create table agent_run (
run_id uuid primary key,
aangifte_ref text not null,
step_no int not null,
step_kind text not null, -- 'plan' | 'tool_call' | 'tool_result' | 'final'
model text not null, -- e.g. 'claude-sonnet-4-5-20250929'
prompt_hash text not null,
prompt jsonb not null,
tool_name text,
tool_args jsonb,
tool_result jsonb,
output jsonb,
taric_snapshot text not null, -- e.g. 'TARIC-2026-Q2-v3'
created_at timestamptz not null default now(),
unique (run_id, step_no)
);
create index on agent_run (aangifte_ref);
create index on agent_run (taric_snapshot);
Elke turn van de loop schrijft één rij weg voordat de volgende call vertrekt. De TARIC-snapshot-ID staat per run vast, en de hoofdstukregels die de agent leest komen uit een geversioneerd YAML-bestand dat de declaranten beheren. Niets van de run leeft in framework-geheugen; als het proces crasht, pakt de run de draad weer op vanaf de laatste rij.
Kosten per aangifte bij 3.200 lookups per week
Cijfers uit productieverkeer, geen benchmark-deck. Gemiddeld 4,1 turns per classificatie, ~6,5k input-tokens en ~900 output-tokens per turn op Claude Sonnet 4.5, met prompt caching op de stabiele system-prefix. Marginale kosten per lookup kwamen uit op €0,018. Bij 3.200 lookups per week is dat €57,60 per week, ~€3.000 per jaar aan inference.
LangGraph en CrewAI draaiden dezelfde inference-rekening binnen de ruis — de orchestrator verandert de prompt niet wezenlijk. Waar de kosten uiteenliepen, was opslag en operations.
- LangGraph checkpoint-tabel bij deze load: ~80 MB/jaar aan geserialiseerde state.
- Onze
agent_run-tabel bij deze load: ~420 MB/jaar, omdat we prompts en tool-args verbatim injsonbopslaan.
Die extra 340 MB/jaar is het audit-spoor. Op een managed Postgres-tier die we al hadden, zijn de marginale kosten een afrondingsfout. Die betalen we.
Methodologische noot: het cijfer van 80 MB komt uit het referentie-Postgres-schema van LangGraph met default-instellingen op ons verkeerspatroon; met periodieke checkpoint-pruning kun je lager uitkomen, maar pruning is precies wat we niet wilden. De 420 MB is ongesnoeid, ongecomprimeerd JSONB. We overwogen Postgres-kolomcompressie op de prompt-kolom aan te zetten en besloten dat de operationele eenvoud van “wat je in de rij ziet is wat de agent zag” de schijfruimte waard was.
Stapgeschiedenis die de Douane accepteert
Nederlandse expeditiebureaus bewaren douanedossiers doorgaans zeven jaar om aan te sluiten bij de fiscale bewaarplicht, terwijl het Douanewetboek van de Unie drie jaar als baseline hanteert. Wij ontwerpen voor tien, vanuit het principe dat de volgende toezichthouder die een lastige vraag stelt over een AI-geclassificeerde aangifte niet degene zal zijn waar we voor planden.
Replay-verdedigbaar betekent voor ons vijf concrete eigenschappen:
- Eén rij per agent-stap, te joinen op
aangifte_ref. - Prompt en tool-argumenten verbatim opgeslagen, niet samengevat.
- TARIC-snapshot vastgezet per run, met een aparte tabel van snapshot-manifesten.
- Modelnaam en exacte versie vastgezet per rij.
- De definitieve aangifte te joinen met de run die hem produceerde.
LangGraph kan vier daarvan vandaag invullen, met moeite. De vijfde — de gok “werkt de deserializer in 2033 nog” — was de inzet die we niet wilden plaatsen. Een platte Postgres-rij met JSON-kolommen is het langstlevende dataformaat dat we kennen.
Als je audit-spoor afhangt van of je orchestratie-framework over zeven jaar zijn eigen checkpoint-formaat nog kan parsen, bewaar je geen dossier. Je houdt een gijzelaar vast.
Twee bugs die het audit-spoor ving
In de eerste maand na go-live vonden we twee bugs die we niet hadden gevangen zonder verbatim opgeslagen prompt en tool-args.
De eerste was een regressie in een hoofdstukregel. Een declarant bewerkte de YAML voor hoofdstuk 39, liet een komma vallen, en de loader parseerde het bestand maar kapte een guidance-string halverwege een zin af. De agent classificeerde door. Zijn betrouwbaarheidsband op post 3920 zakte stilletjes met zo'n acht punten. We merkten het toen het nachtelijke gemiddelde van de betrouwbaarheid daalde, en bewezen binnen een uur dat het niets met het model te maken had door run-rijen te joinen op de YAML-versie waaraan ze waren vastgezet.
De tweede was stiller. Een leverancier stapte over op een nieuw factuur-template dat de productbeschrijving in een zijbalk duwde. Onze PDF-parser gaf nog steeds een waarde terug: de eerste tekstrun op de pagina, die bleek een logo-bijschrift te zijn. De agent classificeerde plichtsgetrouw “BrandCo Solutions” richting iets dat dicht genoeg lag om een code op te leveren. Het spoor toonde de slechte parse verbatim in tool_args; we voegden een sanity check toe (lengte en gedetecteerde taal), en speelden vervolgens drie weken aan getroffen aangiftes opnieuw af om de zeven aan te wijzen die opnieuw ingediend moesten worden.
Geen van beide bugs is exotisch. Het punt is dat we ze allebei vonden door vier tabellen te joinen en tekst te lezen. Geen serializer, geen framework-versie-pin, geen tijd kwijt aan reconstrueren wat het model “wel gezien moet hebben”.
De graph patchen op een zondagavond
Dit is de beslissing waar de vergelijking eigenlijk om draaide. TARIC publiceert kwartaaldelta-updates, soms in het weekend. De klant wilde geen leverancier — wij — in het kritieke pad voor elke kwartaalwijziging. Ze wilden dat de beslissing in het pand bleef.
Met LangGraph betekent een node bewerken: de graph-definitie bewerken, opnieuw deployen, tests terugspelen. Dat doen declaranten niet. Met CrewAI betekende de autonomie dat een config-wijziging niet-vanzelfsprekende runtime-effecten had; niet wat je wilt om 22:00 op een zondag. Met de zelfgebouwde loop staat een hoofdstukregel in YAML:
chapter: "39"
label: "Plastics and articles thereof"
taric_snapshot_min: "TARIC-2026-Q2-v1"
guidance: |
Always require preferential-origin evidence (ATR or EUR.1) when
the supplier country is TR, MA or any EU FTA partner.
Reject confidences below 0.78 for headings 3920 and 3921;
surface 3 alternatives instead.
fallback_human_review: true
Als de nieuwe TARIC verschijnt, markeert een diff-script welke hoofdstukken verschoven zijn. De declaranten keuren de nieuwe regels goed via een kleine admin-pagina. Wij krijgen alleen een Slack-pingetje als de diff een regel raakt die de declaranten niet herkennen.
In de achttien maanden dat de agent live staat, zijn we één keer gepaged. De declaranten hebben zeven kwartaalwijzigingen op eigen houtje goedgekeurd. De update van Q3 2025 was de drukke: 41 codes in hoofdstuk 39 verschoven, waaronder drie die de klant meerdere keren per week gebruikt. Het diff-script markeerde het zondag om 18:00; om 20:30 had de senior declarant een nieuwe ruleset goedgekeurd, was de YAML-versie opgehoogd, en gebruikten de eerste 47 aangiftes van maandag de nieuwe guidance. Wij hoorden het op de stand-up.
Kies de orchestrator die je klant in 2030 op een zondagavond nog kan bedienen.
Wat we hebben opgeleverd
LangGraph en CrewAI zijn allebei goed in waar ze voor bedoeld zijn. Geen van beide was bedoeld voor een 19-koppig expeditiebureau dat om 21:30 op een zondag agent-gedrag moet kunnen patchen zonder engineer in de lus, en die beslissing zeven jaar later moet kunnen verdedigen tegenover een Douane-inspecteur. De zelfgebouwde Claude-loop met een Postgres-stap-tabel kostte ongeveer hetzelfde aan operations, gaf ons een audit-spoor dat we met één SQL-query lezen, en zette de patch-autoriteit terug waar die thuishoort.
Toen we deze douane-classificatie-stack bij ABN bouwden, was de verrassing hoezeer de architectuurkeuze eigenlijk een HR-keuze was — wie bewerkt wat, op welke avond, met welke deploy-key. We hebben het uiteindelijk opgelost met een YAML-regelbestand en een admin-pagina zodat de declaranten de bewegende delen beheren. Sta je voor dezelfde afweging? Onze aantekeningen over het uitrollen van AI-agents zijn waar we de langere playbook bewaren.
Eén ding dat je vandaag kunt doen: open de audit-tabel waar je huidige agent naartoe schrijft en vraag jezelf af of je een enkele classificatie uit 2024 opnieuw kunt draaien — zelfde prompt, zelfde modelversie, zelfde rule-snapshot — en op een token na hetzelfde antwoord krijgt. Zo niet, dan heb je een beslissing te nemen voordat de volgende toezichthouder dat doet.
Kern
De juiste agent-orchestrator is degene die je klant over zeven jaar nog kan bedienen, auditen en patchen op een zondagavond — niet die met de mooiste graph.
FAQ
Waarom niet gewoon de Postgres-checkpointer van LangGraph gebruiken?
Hij werkt vandaag. Onze zorg was 2033: het checkpoint is geserialiseerde state die van het framework is. Een platte rij met JSON-kolommen is het langstlevende audit-formaat dat we kennen, en de Douane wil één SQL-query, geen bevroren virtualenv.
Is CrewAI ooit de juiste keuze voor compliance-werk?
Zelden. Zijn kracht is autonome rolcollaboratie, en dat is een belasting wanneer elke stap een papieren spoor naar de regelgever nodig heeft. Voor open onderzoek en concept-taken is het een scherp gereedschap. Voor douaneaangiftes was het overhead.
Op welke inference-kosten per aangifte kwamen jullie uit?
Ongeveer €0,018 per HS-code-lookup op Claude Sonnet 4.5 met prompt caching, gemiddeld 4,1 turns per classificatie. Bij 3.200 lookups per week is dat ruwweg €3.000 per jaar aan inference, exclusief opslag en operations.
Hoe handelen jullie TARIC-kwartaalupdates af zonder opnieuw te deployen?
Hoofdstukregels staan in een geversioneerd YAML-bestand dat de declaranten beheren. Een diff-script markeert hoofdstukken die in de nieuwe TARIC verschoven zijn, declaranten keuren wijzigingen goed via een admin-pagina, en wij worden alleen gepaged als de diff een onbekende regel raakt.