Eerder schreef ik een artikel over het meertalig maken van een Astro SSG-site met de Google Translate API. Hierbij werd Google Cloud Translation API gebruikt om de statische HTML na de build te vertalen en zo versies in elke taal te genereren. Als eenvoudige en goedkope manier om meertalig te worden, werkte dit prima in de praktijk.
Na enige tijd in productie kwamen er echter punten naar voren die me zowel qua SEO als qua vertaalkwaliteit zorgden. Waarom we Google Translate hebben stopgezet, lees je in een afzonderlijk artikel.
Artikel: Machinevertaling migreren van Google Translate naar Claude API
Dit artikel gaat over de implementatie: hoe we het opnieuw hebben opgebouwd. We hebben de vertaalengine vervangen door een LLM (Claude API) en tegelijkertijd de handmatige operationele stappen weggewerkt die we vorig keer nog open hadden laten staan.
Vertaling tijdens de build met LLM, differentiële cache in R2
Het uitgangspunt is hetzelfde als vorige keer: de vertaling wordt volledig aan de serverkant tijdens de build uitgevoerd. We roepen de Claude API niet vanuit de browser aan. Wat wel anders is, is de vertaalengine en de plek waar de cache wordt opgeslagen.
De buildflow ziet er als volgt uit.
npm run build
├── fetch-microcms (microCMSから記事データ取得)
├── astro build (日本語HTMLを生成 → dist/)
├── translate (各ロケールのHTMLを生成 → dist/{locale}/)
└── update-xml (sitemap更新)
Wat translate (in translate-html-llm.mjs) in grote lijnen doet, verloopt in deze volgorde.
- Het vertaalcachebestand (een enkel JSON-bestand) downloaden van Cloudflare R2
- Lees elk HTML-bestand onder
dist/in met cheerio en haal de teksten op die vertaald moeten worden - Gebruik de cache als die bestaat en stuur alleen de ontbrekende teksten naar de Claude API
- Vervang de tekst door de vertaling en schrijf het resultaat weg naar
dist/{locale}/ - Voeg de nieuw vertaalde teksten samen met de cache en upload die naar R2
Het gaat om twee punten: alleen vertalen wat is veranderd (differentiële vertaling) en de cache opslaan in R2.
① Hoe je vertalingen natuurlijk maakt wanneer ze inline tags overspannen
Dit is precies wat we het liefst wilden verbeteren.
Stel dat de hoofdtekst de volgende HTML bevat.
<p>私たちは<strong>ウェブアクセシビリティ</strong>を重視しています</p>
Als je de vertaling gewoon zo verwerkt, worden 私たちは / ウェブアクセシビリティ / を重視しています als drie losse fragmenten apart vertaald. Omdat de woordvolgorde in het Japans en het Engels verschilt, komt de tag <strong> bij het terugplaatsen van de vertaalde fragmenten op de verkeerde plek te staan, of klinkt de zin gewoon onnatuurlijk. Hoe meer inline tags een zin heeft, hoe erger het misgaat. Dit was het grootste probleem van het oude systeem.
De verbetering bestaat uit twee stappen.
1e: chunk per blokelement. De kleinste vertaaleenheid is niet de 'ruimte tussen tags', maar de volledige inhoud (innerHTML) van blokelementen zoals p, h1 t/m h6, li, td en blockquote. Zinnen worden niet gesplitst.
2e: vervang inline tags door markers, zodat de volledige zin als één vertaaleenheid wordt aangeboden. Inline <strong> en <a> binnen de chunk worden eerst vervangen door markers zoals ....
私たちは[[T1]]ウェブアクセシビリティ[[/T1]]を重視しています
Het LLM krijgt de instructie: 'Dit is één zin. Vertaal deze natuurlijk en plaats dezelfde markers opnieuw op de woorden die nadruk verdienen. De positie van de markers mag worden aangepast aan de woordvolgorde van de vertaling.' Wanneer de vertaling terugkomt, worden de markers hersteld naar de oorspronkelijke <strong> en <a href="...">. Attributen (zoals href of class) blijven behouden.
Zo wordt <strong> nu ook aan de Engelse kant correct aan het juiste woord gekoppeld, en leest de zin natuurlijk.
② Ontwerp van differentiële vertaling en R2-cache
Als alles telkens opnieuw vertaald werd, zouden de kosten snel uit de hand lopen. Vorige keer gebruikten we ook al een cache, translate-cache.json, maar dit keer hebben we de manier waarop sleutels worden gemaakt en waar ze worden opgeslagen herzien.
Een mechanisme dat vertalingen opnieuw maakt wanneer de prompt wordt aangepast
De cachesleutel wordt opgebouwd uit het oorspronkelijke Japanse tekst, de locale en de inhoud van de vertaalprompt. Wanneer de Japanse tekst verandert, wordt alleen dat item opnieuw vertaald. Wanneer de vertaalinstructies of terminologiebeleid (prompt) veranderen, worden automatisch alle items als 'niet in cache' behandeld en opnieuw vertaald.
Het doel is te voorkomen dat een verbeterde prompt wordt genegeerd omdat oude vertalingen in de cache blijven staan. In de praktijk wordt de combinatie gehasht met SHA-256 en die hash wordt als sleutel gebruikt.
De inhoud van de cache ziet er als volgt uit.
{"<sha256のキー>":{"value":"翻訳結果(マーカー込み)","locale":"en","model":"claude-haiku-4-5-20251001","translatedAt":"2026-06-12T..."}}
Cache opgeslagen in R2 en handmatig werk afgeschaft
Hier pakken we de openstaande punten van vorige keer op.
Vorige keer werd de cache beheerd in een JSON-bestand in de repository. Wanneer je in de CMS een artikel toevoegde en een build via de deploy hook startte, kon de server dus alleen de oude cache in Git zien. Om dat op te lossen hadden we een werkafspraak: na het toevoegen van een artikel bouwen we lokaal en pushen we het bijgewerkte cachebestand naar Git.
Nu staat de cache als één JSON-blob in Cloudflare R2. Bij elke build wordt deze uit R2 opgehaald en na afloop teruggeschreven. Hierdoor blijft de cache ook bewaard bij builds via webhook, en is de handmatige stap van lokaal bouwen en pushen volledig verdwenen. Voeg een artikel toe en push: alleen de nieuwe content wordt vertaald en de cache wordt automatisch bijgewerkt.
Prioriteit van vertalingen
Inconsistente vertalingen, zoals wisselende schrijfwijzes van de bedrijfsnaam Liberogic, waren ook vorige keer al een probleem. Nu verwerken we vertalingen in vier stappen.
- Handmatige overschrijving (
data-i18n-key) ― Elementen in de HTML die eendata-i18n-keykrijgen, worden vastgezet op een vooraf geschreven vertaling. Zo hoeft er niet naar de LLM of het woordenboek te worden gekeken, en kun je exact bepalen welke vertaling op die plek moet staan. - Woordenlijst (glossary) ― Vaste labels die vaak voorkomen, zoals navigatie-items en paginatitels, krijgen een vaste vertaling in het JSON-woordenboek. Pas het woordenboek aan en bouw opnieuw, dan is de wijziging direct zichtbaar.
- R2-cache ― Als er in geen van de bovenstaande bronnen een vertaling staat, wordt de cache geraadpleegd.
- Claude API ― Alleen voor teksten die nergens anders gevonden worden, wordt als laatste stap de LLM gebruikt.
Consistent terminology across the body text can't be fully handled by a simple dictionary lookup (partial matches aren't supported), so we loosely align it through terminology hints in the system prompt.
Working with the quirks of LLMs
Machine translation producing odd translations was also an issue last time, but LLMs have their own quirks. Here are a few things we've noticed in practical use.
- Entire technical sentences are left in English. By over-applying the list of "do not translate" terms such as API, React, and Vue, the model sometimes returns whole sentences in English. We addressed this by strongly reinforcing in the user prompt that output must be in the target locale's language.
- Marker positions get semantically reversed. The model misjudges which words should be emphasized, causing
<strong>to wrap the wrong span. Deleting the affected entry and retranslating it fixes the problem. - Responses get cut off mid-way for CJK languages. Kanji consume more tokens per character, so when sent in bulk, the output hits the limit and the JSON breaks. We raised
max_tokensand reduced the number of items per batch to handle this. - Literal
and broken CJK dates. Line breaks sometimes leak in as the literal string, or extra spaces appear in dates like2026 年 05 月 22 日. We clean these up in bulk with a post-processing script.
One operational lesson that worked well: fixing problematic entries one at a time and retranslating them has the highest success rate. When trying to correct several at once, the LLM tends to misjudge the same structure in the same way across the batch. Slow and steady won the race.
About costs
We've standardized on Claude Haiku 4.5 as the engine. Since cost is a priority, our approach is to improve quality issues first through the prompt and dictionary. The system prompt uses prompt caching so the fixed portion repeated on every request costs less.
Dit zijn de ongeveer verwachte kosten op basis van metingen.
Inhoud | Kosten |
|---|---|
Eerste volledige vertaling van één locale | ca. $2,5-3 |
Eerste volledige vertaling voor alle locales | ca. $20 |
Reguliere deployment (alleen cache-hits) | bijna $0 |
Eén artikel toegevoegd (enkele vertalingen) | ca. $0,01 |
Day-to-day deployments are nearly free, and adding an article costs only a few yen. The initial rebuild requires a sizable investment, but once that hurdle is passed, running costs have actually become lighter than before.
Conclusie
Overstap van Google Translate naar LLM-vertaling heeft geleid tot lagere operationele kosten en een aanzienlijk betere vertaalkwaliteit.
Omdat het een LLM is, wordt niet alles automatisch perfect. Het blijft nodig om steeds opnieuw eigenaardigheden op te sporen en te corrigeren. Maar doordat de aanpassingen zijn samengebracht op de duidelijke plekken, namelijk de prompt en het woordenboek, verloopt de verbetercyclus een stuk soepeler.
Van DTP naar het web: al snel werkte hij aan markup, frontend, projectregie en accessibility, en beheerst hij alles. Actief bij Liberogic sinds de beginjaren en nu een levende encyclopedie binnen het bedrijf. Recentelijk verdiept hij zich, met prompts als hulpmiddel, in de vraag: "Kunnen we voor accessibility niet meer op AI leunen?" Technologie en denkwijze blijven zich ontwikkelen.
Ayumu Futamata
IAAP-gecertificeerd webaccessibility-specialist (WAS) / Markup-engineer / Frontend-engineer / Webdirecteur