← Zurück zum Blog

Ein API-Schlüssel, viele Mieter: Wie wir DeepL Übersetzungen kundenübergreifend isolieren

Rasepi verwendet einen einzigen DeepL API-Schlüssel für alle Tenants. So handhaben wir Glossare pro Kunde, Stilregeln, zwischengespeicherte Übersetzungen und Isolierung auf Blockebene, ohne dass etwas durchsickert.

Unter der Haube
Ein API-Schlüssel, viele Mieter: Wie wir DeepL Übersetzungen kundenübergreifend isolieren

Jedes Mal, wenn ich einem anderen Entwickler die Übersetzungsarchitektur von Rasepi erkläre, taucht eine Frage auf: "Moment mal, alle deine Mieter teilen sich einen DeepL API-Schlüssel? Wie verhinderst du, dass ihre Glossare und Stilregeln ineinander übergehen?"

Das ist eine berechtigte Frage. Und die Antwort erfordert mehr Designarbeit, als du erwarten würdest.

Wir haben die vollständige Übersetzungspipeline in einem früheren Beitrag behandelt, das Hashing auf Blockebene, den Orchestrator, den gesamten Fluss vom Speichern des Dokuments bis zur übersetzten Ausgabe. Dieser Beitrag befasst sich mit dem speziellen Problem der Mandantenfähigkeit. Wie man eine API eines Drittanbieters nimmt, die kein Konzept für Mandanten hat, und darauf eine Mandantenisolierung aufbaut.

Das Problem: DeepL weiß nichts über deine Kunden

Die API von DeepL authentifiziert sich mit einem einzigen API-Schlüssel. Alles, was unter diesem Schlüssel erstellt wird - Glossare, Stilregel-Listen, Übersetzungshistorie - gehört zu demselben Konto. Auf DeepL gibt es kein Konzept, das besagt: "Dieses Glossar gehört zu Mieter A".

Wenn du GET /v2/glossaries aufrufst, erhältst du alle Glossare von allen Mietern. Wenn du eine Stilregel-Liste erstellst, befindet sie sich im gleichen Namensraum wie die Stilregeln aller anderen Tenants. Die API ist flach.

Für ein selbst gehostetes Produkt, bei dem jeder Kunde seine eigene Instanz mit seinem eigenen DeepL Schlüssel betreibt, ist das in Ordnung. Für ein SaaS mit mehreren Mandanten, bei dem wir die Infrastruktur verwalten? Du brauchst eine Isolationsschicht.

Die Datenbank ist die Quelle der Wahrheit

Unsere zentrale Designentscheidung: Die Datenbank ist Eigentümerin aller Glossarinhalte und der Konfiguration der Stilregeln. DeepL ist ein Ausführungsziel für die Laufzeit, nichts weiter.

Jede TenantGlossary und TenantStyleRuleList Entität implementiert ITenantScoped, was bedeutet, dass die globalen Abfragefilter von EF Core alle Lesevorgänge automatisch auf den aktuellen Tenant beschränken. Eine Abfrage nach Glossaren im Anfragekontext von Tenant A wird niemals die Einträge von Tenant B zurückgeben. Dies ist das gleiche Isolationsmuster, das wir überall in Rasepi verwenden und das auf ORM-Ebene durchgesetzt wird.

Das macht die Sache interessant. Wenn ein Tenant einen Glossarbegriff bearbeitet, rufen wir nicht sofort DeepL auf. Wir aktualisieren die Datenbankzeile und setzen IsDirty = true. Das war's. Das eigentliche DeepL Glossar wird in aller Ruhe erstellt (oder neu erstellt), bevor die nächste Übersetzung es braucht.

public async Task<string?> GetOrSyncDeepLGlossaryIdAsync(
    string sourceLanguage, string targetLanguage)
{
    var glossary = await _db.TenantGlossaries
        .Include(g => g.Entries)
        .FirstOrDefaultAsync(g =>
            g.SourceLanguage == sourceLanguage &&
            g.TargetLanguage == targetLanguage);

    if (glossary?.Entries.Count == 0) return null;

    if (!glossary.IsDirty && glossary.DeepLGlossaryId is not null)
        return glossary.DeepLGlossaryId;

    // Dirty: delete old, create new
    if (glossary.DeepLGlossaryId is not null)
        await _deepL.DeleteGlossaryAsync(glossary.DeepLGlossaryId);

    var entries = glossary.Entries
        .ToDictionary(e => e.SourceTerm, e => e.TargetTerm);

    var created = await _deepL.CreateGlossaryAsync(
        $"rasepi-{glossary.Id}",
        glossary.SourceLanguage,
        glossary.TargetLanguage,
        entries);

    glossary.DeepLGlossaryId = created.GlossaryId;
    glossary.IsDirty = false;
    glossary.LastSyncedAt = DateTime.UtcNow;
    await _db.SaveChangesAsync();

    return glossary.DeepLGlossaryId;
}

Der Abfragefilter auf TenantGlossaries sorgt für die Isolierung. Das IsDirty Flag sorgt für den lazy sync. Und die Namenskonvention (rasepi-{glossary.Id}) dient nur zum Debuggen im DeepL Dashboard, sie hat keinen funktionalen Zweck.

Warum lazy? Weil DeepL v2 Glossare unveränderlich sind. Du kannst sie nicht bearbeiten. Jede Änderung bedeutet löschen und neu erstellen. Wenn ein Team eine CSV-Datei mit 200 Begriffen importiert und dann einen Tippfehler in einem Eintrag korrigiert, wollen wir das Glossar DeepL nicht zweimal löschen und neu erstellen. Wir setzen einfach beide Male IsDirty und die einmalige Neuerstellung erfolgt, wenn die nächste Übersetzung läuft.

Stilregeln: gleiches Muster, andere API

Die [DeepL's style rules] (https://developers.deepl.com/docs/api-reference/translate/openapi-spec-for-text-translation) sind neuer (v3 API) und tatsächlich veränderbar, was schöner ist. Du kannst konfigurierte Regeln mit PUT /v3/style_rules/{style_id}/configured_rules aktualisieren, und benutzerdefinierte Anweisungen können individuell hinzugefügt oder entfernt werden.

Wir verwenden aber immer noch das gleiche IsDirty Muster. Ein TenantStyleRuleList hat einen DeepLStyleId, der dem Laufzeitbezeichner von DeepL entspricht, sowie ConfiguredRulesJson für die Formatierungsregeln und eine Sammlung von TenantCustomInstruction Einträgen für Freitext-Übersetzungsanweisungen.

Die eigentliche Stärke liegt in diesen benutzerdefinierten Anweisungen. Jede dieser Anweisungen ist eine Klartextanweisung mit bis zu 300 Zeichen, die bestimmt, wie DeepL übersetzt. Echte Beispiele von unseren Mietern:

  • "Verwende immer die Form 'Sie', niemals 'du'" für eine deutsche Anwaltskanzlei
  • "Übersetze 'deployment' als 'Bereitstellung', niemals 'Deployment'"_ für kontextabhängige Begriffe, die über einfache Glossarzuordnungen hinausgehen
  • "Verwende die britische Schreibweise (Farbe, Organisation, Lizenz)"_ für ein britisches Unternehmen, das zwischen verschiedenen englischen Varianten übersetzt
  • "Setze Währungssymbole hinter den numerischen Betrag"_ für europäische Konventionen

Jeder Tenant kann völlig unterschiedliche Anweisungen pro Zielsprache haben, die alle hinter demselben API-Schlüssel stehen. Die Isolierung ergibt sich aus der Tatsache, dass jeder Übersetzungsaufruf nur die glossary_id und style_id des anfragenden Tenants enthält. Die DeepL Ressourcen anderer Tenants werden nie referenziert.

Der Übersetzungsaufruf: alles setzt sich zusammen

Wenn der Orchestrator einen Block übersetzt, fügt er alle Tenant-spezifischen Einstellungen in einer einzigen Anfrage zusammen:

var glossaryId = await _glossaryService
    .GetOrSyncDeepLGlossaryIdAsync(sourceLang, targetLang);
var styleId = await _styleRuleService
    .GetOrSyncStyleIdAsync(targetLang);
var formality = langConfig.Formality ?? "default";

var options = new TranslationOptions
{
    GlossaryId = glossaryId,
    StyleId = styleId,
    Formality = formality,
    Context = documentContext,
    ModelType = styleId != null ? "quality_optimized" : null
};

Jeder Parameter hier ist mandantenspezifisch. Der glossaryId wurde durch eine Tenant-gefilterte Abfrage aufgelöst. Der styleId wurde auf die gleiche Weise aufgelöst. Die formality stammt von der TenantLanguageConfig, die ebenfalls tenant-scoped ist. Auch die context (umgebende Absätze, die zur Verbesserung der Übersetzungsqualität gesendet und nicht in Rechnung gestellt wurden) stammt aus dem eigenen Dokument des Mieters.

Eine Sache, die ich hervorheben möchte: Wenn style_id gesetzt ist, verwendet DeepL automatisch ihr quality_optimized Modell. Du kannst keine Stilregeln mit latency_optimized kombinieren. Das ist eine DeepL Einschränkung, aber ehrlich gesagt eine vernünftige Einschränkung. Wenn du in benutzerdefinierte Stilregeln investierst, willst du wahrscheinlich die beste Qualität erreichen.

Caching auf Blockebene: die Datenbank als Translation Memory

Wir rufen DeepL nicht für Blöcke auf, die sich nicht geändert haben. Der Caching-Mechanismus ist die TranslationBlock Tabelle selbst.

Jeder EntryBlock hat einen ContentHash, einen SHA256 seines semantischen Inhalts (wobei Metadatenattribute wie blockId und deleted entfernt werden). Jeder TranslationBlock speichert den SourceContentHash, der zum Zeitpunkt der Übersetzung aktuell war. Wenn sich der Quellblock ändert, ändert sich auch sein Hash. Der Orchestrator vergleicht die Hashes und stellt nur Blöcke in die Warteschlange, die nicht übereinstimmen.

Der Entscheidungsbaum für jeden Block sieht wie folgt aus:

  1. Hash stimmt überein, Übersetzung existiert = überspringen (gecached, aktuell)
  2. Hash geändert, maschinell übersetzt, nicht gesperrt = automatisch neu übersetzen
  3. Hash geändert, von Menschen bearbeitet oder gesperrt = als veraltet markieren, nicht überschreiben

Der dritte Fall ist entscheidend. Wenn dein deutscher Übersetzer einen Absatz manuell überarbeitet hat, löschen wir ihn nicht, nur weil sich die englische Quelle geändert hat. Wir kennzeichnen ihn als veraltet, damit er weiß, dass er überarbeitet werden muss, aber der übersetzte Text bleibt intakt.

Das praktische Ergebnis: Die Bearbeitung eines Absatzes in einem Dokument mit 30 Absätzen löst genau einen DeepL API-Aufruf aus (na ja, einen Stapel, der einen Block enthält). Die anderen 29 Absätze in allen Sprachen sind bereits zwischengespeichert und kosten nichts.

Warum nicht einen eigenen Schlüssel pro Tenant verwenden?

Ich habe das ernsthaft in Erwägung gezogen. Gib jedem Tenant seinen eigenen DeepL API-Schlüssel und das Problem der Isolierung ist gelöst.

Drei Gründe, warum wir es nicht getan haben:

  1. Komplexität der Abrechnung Jeder Mieter bräuchte sein eigenes DeepL Abonnement oder eine Möglichkeit, Unterkonten einzurichten. DeepL bietet von Haus aus keine mandantenfähige Schlüsselverwaltung.
  2. Kosteneffizienz. Gemeinsame Infrastruktur bedeutet gemeinsame Tarifgrenzen und Mengenrabatte. Unsere Gesamtnutzung führt zu besseren Preisen.
  3. Einfacher Betrieb. Ein Schlüssel zum Drehen, ein Kontingent zum Überwachen, eine Integration zum Pflegen.

Der Nachteil ist, dass wir die beschriebene Isolationsebene brauchen. Da wir aber bereits für alles andere im System mieterspezifische EF Core-Abfragen haben, war es ein Leichtes, diese in Glossare und Stilregeln einzubauen. Das Muster war bereits vorhanden.

Was dich wirklich schützt

Um die Isolationsgarantien zusammenzufassen:

  • Glossareinträge werden in TenantGlossary (implementiert ITenantScoped) gespeichert, gefiltert durch globale EF Core Abfragefilter. DeepL Glossar-IDs sind undurchsichtige Referenzen, die nur im Kontext des Tenants aufgelöst werden.
  • Stilregeln und benutzerdefinierte Anweisungen folgen dem gleichen Muster durch TenantStyleRuleList.
  • Übersetzte Inhalte befinden sich im TranslationBlock, der über seine übergeordnete EntryHub-Kette skaliert wird, die ebenfalls tenant-skaliert ist.
  • Der SaveChanges guard setzt TenantId automatisch bei neuen Entitäten und wird bei mandantenübergreifenden Schreibvorgängen geworfen.
  • Kein IgnoreQueryFilters() im Produktionscode. Immer.

Das Designprinzip ist einfach: DeepL sieht Ressourcen-IDs. Rasepi sieht tenant-scoped Entities. Die Zuordnung zwischen ihnen überschreitet niemals Tenant-Grenzen, da die Abfrage, die die Zuordnung auflöst, physisch nicht in der Lage ist, die Daten eines anderen Tenants zurückzugeben.

Wenn du ein mandantenfähiges SaaS aufbaust, das mit APIs von Drittanbietern ohne native Tenant-Unterstützung integriert wird, funktioniert dieses Muster gut. Behandle die externe API als zustandslose Ausführungsmaschine, speichere alle Konfigurationen in deiner eigenen Tenant-Datenbank, synchronisiere sie nachlässig und verlasse dich bei der Isolierung niemals auf externe Ressourcenauflistungen.

Halte deine Doku aktuell. Automatisch.

Rasepi erzwingt Überprüfungstermine, verfolgt die Inhaltsqualität und veröffentlicht in über 40 Sprachen.

Kostenlos starten →