Blog/Web-Engineering
Eine LLM-Übersetzungspipeline für i18n: Glossar, Platzhalter und Review
Produkt- und Website-Texte mit LLMs übersetzen, ohne Platzhalter, Pluralformen oder Tonalität zu beschädigen: Glossar, ICU-Nachrichten, QA-Checks, Review.
Balázs Csorba··9 Min. Lesezeit
- Translation
- i18n
- LLM
- Vue
- Localization

Das Wichtigste in Kürze
- Platzhalter, Tags und ICU-Syntax vor dem Modell schützen und danach prüfen; dieser Check findet fast alle echten Fehler.
- Werte übersetzen, nie Schlüssel, und die Dateistruktur unverändert lassen, damit der Build diffbar und prüfbar bleibt.
- Ein Glossar plus eine Nicht-übersetzen-Liste bringt mehr Konsistenz als jede Prompt-Formulierung, weil es maschinell erzwungen wird.
- Deutsch und Ungarisch brauchen frühe Entscheidungen: formelle oder informelle Anrede, Zusammensetzungen und Anführungszeichen.
- Das Modell übersetzt, Skripte prüfen, und Menschen sehen nur die Texte an, bei denen ein Fehler teuer wird.
Jede Produktwebsite bekommt früher oder später eine Übersetzungspipeline, ob geplant oder nicht. Die übliche Version ist eine Tabelle, ein Skript, das jemand einmal geschrieben hat, und die Regel, dass die deutschen Texte vor dem Release-Branch fertig sein müssen. Das funktioniert, bis ein Platzhalter umbenannt wird, eine Pluralform verschwindet oder derselbe Button-Text auf drei Arten übersetzt ist.
Dieser Beitrag beschreibt die Pipeline, die ich für Vue- und Nuxt-Seiten verwende: Texte liegen in JSON, ein Modell übersetzt Werte mit Glossar und explizitem Kontext, Skripte prüfen das Ergebnis maschinell, und Menschen sehen nur die Texte an, bei denen ein Fehler teuer wird. Das ist bewusst langweilig, und genau darum geht es: Übersetzen soll ein Build-Schritt sein, kein Ereignis.
Was tatsächlich kaputtgeht
Maschinelle Übersetzung von reinem Fließtext ist für europäische Sprachen gelöst. Kaputt geht in Software alles, was der Satz nicht trägt: das {count} mitten im String, der HTML-Tag um einen Link, die Pluralform, die Deutsch und Ungarisch beide brauchen, der Produktname, der englisch bleiben muss.
- Ein verlorener oder umbenannter Platzhalter, der eine funktionierende Seite zum Laufzeitfehler oder zu einer leeren Stelle macht.
- Eine kaputte ICU-Nachricht, weil Pluralkategorien je Sprache verschieden sind und Konkatenation sie nicht ausdrücken kann.
- Übersetzte HTML- oder Markdown-Tags, die entweder das Rendering brechen oder im sichtbaren Text landen.
- Inkonsistente Terminologie: dasselbe Feature heißt auf einer Seite drei verschiedene Dinge.
- Tonalitätsdrift, bei der eine formelle deutsche Oberfläche den Leser plötzlich duzt.
Die Pipeline
Der Extraktionsschritt ist der einzige Ort, der Quelltexte erzeugt. Ein hartcodierter String in einer Komponente wird nie übersetzt; die erste nützliche Prüfung ist deshalb ein Linter, der sichtbare Textliterale in Templates als Fehler meldet. Der Übersetzungsschritt ist zustandslos: er liest Quelldatei, Glossar und die vorherige Übersetzung desselben Schlüssels und schreibt neue Werte.
Platzhalter und Tags zuerst schützen
Ein Platzhalter ist ein Vertrag zwischen dem String und dem Code, der ihn rendert. Das Modell darf ihn weder übersetzen noch umstellen oder weglassen, und der Code darf nicht annehmen, dass er überlebt hat. Das zuverlässige Muster: alle Platzhalter vor der Anfrage extrahieren und die Liste nach der Antwort vergleichen; eine Abweichung ist ein harter Fehler, keine Warnung.
import re
PLACEHOLDER = re.compile(r"\{[A-Za-z_][\w]*(?:,[^}]*)?\}|\{[0-9]+\}|%[sd]|</?[a-z][^>]*>")
def slots(text: str) -> list[str]:
"Every placeholder and tag in the string, in order of appearance."
return PLACEHOLDER.findall(text)
def check(source: str, target: str) -> list[str]:
"Returns the differences; empty list means the translation is safe to ship."
a, b = slots(source), slots(target)
return [f"missing {s}" for s in a if s not in b] + [f"added {s}" for s in b if s not in a]
Tags verdienen dieselbe Behandlung. Enthält der Quelltext <strong>{name}</strong>, darf die Übersetzung den Tag verschieben, muss aber beide Hälften und den Platzhalter behalten. Ein einfacher geordneter Vergleich fängt die häufigen Fälle, ein HTML-Parser den Rest.
Plural und Select gehören zu ICU
Deutsch und Ungarisch unterscheiden Singular und Plural, deshalb wirkt eine rein englische Pluralform harmlos bis zum ersten Zählerstand null. ICU MessageFormat löst das sauber, und Vue I18n spricht ICU, sodass die Nachricht in der Locale-Datei liegen und als Einheit übersetzbar bleiben kann.
| String | en | de | hu |
|---|---|---|---|
| Warenkorb-Zähler | {count, plural, one {# item} other {# items}} | {count, plural, one {# Artikel} other {# Artikel}} | {count, plural, one {# termék} other {# termék}} |
| Speichergröße | {size} GB of {total} GB used | {size} GB von {total} GB belegt | {size} GB a {total} GB-ból |
<p>{{ $t('cart.items', { count: items.length }, items.length) }}</p>
// en.json
"cart": { "items": "{count, plural, one {# item} other {# items}}" }
// de.json
"cart": { "items": "{count, plural, one {# Artikel} other {# Artikel}}" }
// hu.json
"cart": { "items": "{count, plural, one {# termék} other {# termék}}" }
Zwei Gewohnheiten halten das am Laufen. Baue nie einen Satz, indem du ein übersetztes Fragment mit einer Zahl oder einem Namen verkettest, denn die Wortstellung unterscheidet sich; halte die Zahl innerhalb der Nachricht. Und lass die Pipeline nie eine halb übersetzte Pluralform sehen: übersetze die ganze ICU-Nachricht als einen String, mit denselben Kategorienamen, und lass einen Check bestätigen, dass jede Kategorie der Quelle im Ziel existiert.
Werte übersetzen, niemals Schlüssel
Der Übersetzungsschritt bekommt ein JSON-Objekt und liefert ein Objekt mit derselben Form zurück. Schlüssel sind Bezeichner und bleiben unangetastet, nur Werte ändern sich. Mit Structured Outputs ist das auf API-Ebene erzwingbar, und auch ohne Schema ist es der einfachste prüfbare Vertrag: die Menge der Schlüssel muss vorher und nachher identisch sein.
{
"page": "checkout", // context for the model, not translated
"glossary": [["cart", "Warenkorb"], ["checkout", "Kassa"]],
"strings": {
"cart.title": "Your cart",
"cart.empty": "Your cart is empty",
"cart.items": "{count, plural, one {# item} other {# items}}"
}
}Die Gruppierung ist wichtiger als die Größe. Strings, die sich eine Seite, eine Komponente oder eine Tonalität teilen, gehören in eine Anfrage, weil das Modell die Nachbarn als Kontext nutzt. Zwanzig bis sechzig Strings pro Anfrage sind ein guter Bereich: ein Retry ist günstig, und der Kontext bleibt spezifisch. Übersetze nur neu, was sich geändert hat, per Hash-Vergleich des Quellwerts, dann bleiben die laufenden Kosten nach dem ersten Durchlauf nahe null.
Glossar und Translation Memory
Ein Glossar ist das günstigste Konsistenzwerkzeug der Pipeline: eine Liste von Quellbegriffen mit freigegebenen Zielbegriffen und einer Regel zu Groß- und Kleinschreibung sowie Flexion. Das Modell bekommt es als Eingabe, ein Check erzwingt es danach. Erscheint eine verbotene Übersetzung, wird der String einmal mit dem zitierten Verstoß wiederholt; danach geht er ins Review statt zu loopen.
| Quelle | Deutsch | Ungarisch | Regel |
|---|---|---|---|
| cart | Warenkorb | kosár | Substantiv, im Deutschen groß |
| checkout | Kassa | fizetés | nicht übersetzen, nicht beugen |
| AI agent | AI-Agent | AI-ügynök | englischen Begriff behalten, beim ersten Vorkommen erklären |
Translation Memory ist die andere Hälfte: exakte Treffer aus früheren Releases werden ohne Modellaufruf wiederverwendet, unscharfe Treffer als Vorschlag übergeben. In einem JSON-pro-Sprache-Aufbau ist die Memory nur eine Tabelle aus Quell-Strings und akzeptierten Zielen, der Effekt ist aber derselbe: das Modell sieht nur, was wirklich neu ist, und Reviewer sehen nur, was sich seit dem letzten Release geändert hat.
Besonderheiten bei Deutsch und Ungarisch
Deutsch bestraft Inkonsistenz stärker als Grammatikfehler. Wähle eine Anrede pro Produkt, dokumentiere sie und lege sie in den Styleguide, der mit dem Glossar ausgeliefert wird; ein Shop, der im Footer siezt und im Onboarding duzt, wirkt zusammengesetzt. Substantive bleiben groß, Komposita werden zusammengeschrieben, und Anführungszeichen sind die deutschen: „so“, nicht "so".
Ungarisch ist agglutinierend und kennt kein grammatisches Geschlecht, was die Pipeline auf zwei praktische Arten verändert. Erstens lassen sich "user" oder "customer" nicht per Pronomen weiterführen: die Übersetzung muss das Substantiv wiederholen oder ohne es formuliert werden. Zweitens hängen Suffixe an Substantiven, also muss ein Platzhalter innerhalb eines Kasussuffixes vermieden oder die Nachricht umgebaut werden, bis das Suffix neben dem Wort steht.
- Schreibe pro Sprache eine einseitige Tonalitätsnotiz und halte sie neben dem Glossar im Repository.
- Jeder wiederkehrende Begriff wird ein Glossareintrag, statt im Prompt wiederholt zu werden.
- Lass einen Muttersprachler die Tonalitätsnotiz einmal korrigieren und behandle die Korrekturen danach als Lint-Regeln.
- Prüfe das Ergebnis im Layout, nicht im JSON: Deutsch und Ungarisch sind meist länger als Englisch.
Checks, die fast alles finden
Jeder Fehlermodus aus dem ersten Abschnitt hat einen maschinellen Check. Führe sie im selben Commit wie die Übersetzung aus, nicht später in einem Aufräum-Sprint; eine Pipeline, die nicht laut scheitern kann, liefert kaputte Strings leise aus.
// verify-locales.mjs - runs in CI, exits non-zero on the first real problem
const checks = [
['placeholder parity', (src, tgt) => sameSlots(src, tgt)],
['icu category parity', (src, tgt) => sameIcuCategories(src, tgt)],
['no empty values', (src, tgt) => tgt.trim().length > 0],
['glossary respected', (src, tgt) => glossaryViolations(src, tgt).length === 0],
['length sanity', (src, tgt) => tgt.length < src.length * 2.2],
]
- Platzhalter- und Tag-Parität, pro String und pro ICU-Nachricht.
- Identische Schlüsselmengen zwischen Quell- und Ziel-Locale-Datei.
- Keine leeren oder unveränderten Werte, wo sich die Quelle geändert hat.
- Glossarverstöße mit dem betroffenen Begriff, damit der Retry-Prompt ihn zitieren kann.
- Längenverhältnis-Grenzen, die Kürzungen und ausufernde Erweiterungen melden, bevor ein Designer sie sieht.
Kosten und Durchsatz
Übersetzen ist ein kleiner Teil der LLM-Rechnung, weil die Strings kurz sind. Als Faustregel kosten tausend Strings mit je etwa zwanzig Wörtern rund 20k Eingabe- und 25k Ausgabe-Tokens; bei einem Mittelklasse-Modell mit drei Dollar pro Million Eingabe- und fünfzehn Dollar pro Million Ausgabe-Tokens sind das etwa fünfundvierzig Cent pro tausend Strings. Frontier-Modelle kosten ein Vielfaches und lohnen sich hier selten.
| Durchlauf | Was es sieht | Kostentreiber |
|---|---|---|
| Erster Volllauf | Jeder Quell-String plus Glossar | Quellmenge, einmalig |
| Release-Delta | Nur Schlüssel mit geändertem Quell-Hash | Änderungsrate |
| Exakter Memory-Treffer | Nichts, aus der Memory kopiert | Null |
| Review | Diffs, markierte Verstöße, Stichproben | Menschenminuten |
Die Durchsatzfrage ist wichtiger als der Preis. Ein voller Durchlauf über ein paar tausend Strings dauert Minuten und kann in der CI laufen; der Engpass ist das Review. Deshalb sollte die Pipeline Reviewern einen Diff der geänderten Schlüssel und eine Liste markierter Strings zeigen, statt einer Datei, die von oben gelesen werden muss.
Wo Menschen weiterhin zählen
Ein Review-Gate lohnt seine Kosten, wenn ein Fehler teuer ist: Onboarding, Preisseiten, Rechtstexte, alles, was Kunden dir zurückspiegeln. Für den langen Schwanz aus Fehlermeldungen und Einstellungslabels schlagen automatische Checks plus Glossar einen hastigen menschlichen Durchgang; das Gegenteil zu behaupten verschiebt die Arbeit nur ins nächste Release.
- Die Pipeline erzeugt einen Diff pro Sprache und eine Liste markierter Strings.
- Ein Reviewer gibt nur die markierten und gut sichtbaren Einträge frei oder bearbeitet sie.
- Freigegebene Werte werden zurückgeschrieben, was die Translation Memory aktualisiert.
- Der Build veröffentlicht die Seite, und die Checks laufen erneut auf dem veröffentlichten Artefakt.
Nichts davon ist exotisch. Quelltexte in JSON, daneben ein Glossar, ein Modell, das Werte übersetzt, ein Skript, das Verträge prüft, und ein Mensch, der den Diff ansieht. Die Pipeline ist bewusst langweilig, weil die interessanten Teile eines Produkts niemals die Übersetzungen sein sollten.
Quellen
- Unicode ICU User Guide: Formatting Messages
- Vue I18n: Message Format Syntax
- Nuxt i18n: Getting started
- Unicode CLDR: Plural rules
- OpenAI: Structured outputs
- DeepL API: Glossaries
- W3C: Localization vs. internationalization
- GNU gettext manual
- RFC 5646: Tags for Identifying Languages
- Rat für deutsche Rechtschreibung
- Microsoft Writing Style Guide
Häufige Fragen
Brauche ich noch ein Translation-Management-System?
Nicht zwingend. Eine JSON-Datei pro Sprache mit Review-Gate im Repository reicht für die meisten Produktseiten. Ein TMS lohnt sich, sobald externe Übersetzer, viele Sprachen oder Release-Branches dazukommen.
Wie groß sollte ein Übersetzungs-Batch sein?
So klein, dass ein Fehlschlag günstig zu wiederholen ist, so groß, dass Kontext vorhanden ist. In der Praxis funktionieren 20 bis 60 Strings pro Anfrage, gruppiert nach Seite oder Komponente.
Darf das Modell die ganze Datei als Kontext sehen?
Ja, als reine Lesehilfe. Schicke die Quelldatei mit den zu übersetzenden Strings markiert und lass die Antwort nur die übersetzten Werte enthalten. Das hält Prompt und Antwortschema klein.
Was ist mit Sprachen ohne Pluralform wie im Englischen?
ICU löst das, aber nur wenn auch der Quelltext ICU nutzt. Zähler und Pluralformen zuerst in ICU-Nachrichten überführen und dann die Nachricht übersetzen, nicht den zusammengesetzten Satz.
Braucht es noch Post-Editing?
Bei sichtbaren Texten ja. Bei selten gelesenen Fehlermeldungen und Einstellungslabels schlagen ein gutes Glossar und automatische Checks einen hastigen menschlichen Durchgang meist.