Zurück zum Blog

Zeichen sind keine Token: wie ein Embedding-Stapel an 300.000 Token scheiterte

Ein Embedding-Rückfall riss die Grenze von 300.000 Token pro Anfrage, und der erste Fix teilte nach Zeichen. Bei chinesischem Text und vielen kurzen Abschnitten reichte das nicht. Warum Bytes die bessere Einheit sind und wie Tests das beweisen.

5 Min. Lesezeitvon Daniel AlischRAG, KI-Agenten, Engineering

Ein Fehler, der sich jeden Tag mit exakt derselben Meldung zurückmeldet, ist selten Pech. Meist ist er eine Schleife. In AI Employee, meiner Agenten-Plattform, hat genau so eine Schleife dafür gesorgt, dass ein Teil der Wissensdatenbank tagelang ohne Vektoren blieb, und der erste Fix dagegen war selbst wieder falsch.

Das Symptom: 67 gleiche Fehler in vier Tagen

Die Wissensdatenbank der Plattform zerlegt Markdown-Dateien in Abschnitte und berechnet für jeden Abschnitt ein Embedding. Normalerweise macht das ein lokaler Dienst. Fällt der aus, springt ein Rückfallpfad auf die Embedding-Schnittstelle von OpenAI ein. Im Fehlerprotokoll tauchte an vier aufeinanderfolgenden Tagen dieselbe Meldung auf: HTTP 400, max_tokens_per_request. Zusammen 67 Mal, mit 26, 22, 11 und 11 Treffern pro Tag.

Die Ursache war schnell gefunden. Der Indexer übergibt alle Abschnitte einer Datei in einem Aufruf, und der Rückfallpfad reichte diesen Stapel ungeteilt an /embeddings weiter. Die Schnittstelle erlaubt pro Anfrage höchstens 300.000 Token über alle Eingaben und höchstens 2.048 Eingaben. Beobachtet wurden Anfragen mit bis zu 479.065 Token. Die Schnittstelle lehnt dann nicht den überzähligen Rest ab, sondern die ganze Anfrage. Der Code gab daraufhin für jeden Abschnitt None zurück.

Unangenehm wurde es durch den nächsten Schritt: Der Indexer nimmt beim folgenden Lauf alle Dateien erneut auf, deren Embedding fehlt. Damit war der Fehler sein eigener Arbeitsauftrag. Für die Nutzer sah man davon nichts außer einer Warnung im Protokoll. Die semantische Suche fiel bei den betroffenen Dateien still auf Stichwortsuche zurück. Niemand beschwert sich über eine Suche, die etwas schlechter findet; man merkt es schlicht nicht.

Der erste Fix: ein Budget in Zeichen

Die naheliegende Reparatur war, den Stapel vor dem Senden in Gruppen zu teilen, und zwar an der gemeinsamen Engstelle, durch die jeder Aufrufer muss, nicht beim Indexer. So erbt jeder künftige Aufrufer den Schutz. Das war richtig. Falsch war die Maßeinheit: Die erste Fassung teilte nach Zeichen und rechnete mit etwa zwei Zeichen pro Token. Für deutschen und englischen Text ist das eine vorsichtige Schätzung. Die Tests waren grün.

Die Review hat zwei Gegenbeispiele gebaut, die beide durchrutschten. Erstens chinesischer Text: Dort kommt ungefähr ein Token auf ein Zeichen. Eine Gruppe, die nach der Schätzung bequem unter der Grenze lag, ergab eine Anfrage mit 499.799 Token. Zweitens viele kurze Abschnitte: 2.100 kleine Texte passten locker ins Zeichenbudget und gingen in einer einzigen Anfrage raus, über der Grenze von 2.048 Eingaben. Gedeckelt war nur die Größe, nie die Anzahl.

Warum hatten die Tests das nicht gesehen? Weil sie die Tokenzahl mit derselben Schätzfunktion berechneten wie der Produktionscode. Ein solcher Test prüft, ob der Code mit sich selbst übereinstimmt. Ist die Schätzung falsch, ist sie es in beiden Hälften gleich, und alles bleibt grün.

Bytes statt Zeichen: eine Grenze, die man beweisen kann

Die gemergte Fassung zählt UTF-8-Bytes. Das klingt nach einer weiteren Schätzung, ist aber eine echte Obergrenze. Der Tokenisierer des Embedding-Modells arbeitet als Byte-BPE: Jedes einzelne Byte ist im Vokabular, und jede Verschmelzung ersetzt zwei Token durch eines. Die Tokenzahl eines Textes kann deshalb nie größer sein als seine Bytezahl. Chinesische Zeichen belegen in UTF-8 drei Bytes, werden also eher zu großzügig gezählt, nie zu knapp.

Der Splitter hält beide Grenzen zugleich ein, mit etwas Abstand zur Token-Grenze:

python
MAX_INPUTS = 2_048
MAX_BYTES = 290_000   # Puffer unter 300.000 Token

def split_for_request(texts):
    group, size = [], 0
    for text in texts:
        n = len(text.encode("utf-8"))
        if group and (size + n > MAX_BYTES or len(group) >= MAX_INPUTS):
            yield group
            group, size = [], 0
        group.append(text)
        size += n
    if group:
        yield group

Ein Text, der allein schon zu groß ist, wird einzeln verschickt statt stillschweigend verworfen. Dazu kommt ein Nebeneffekt, der fast wichtiger ist als die Grenze selbst: Lehnt die Schnittstelle eine Gruppe ab, bleibt nur diese Gruppe ohne Vektoren und nicht mehr der ganze Stapel.

Bewusst nicht verwendet wurde eine echte Tokenzählung mit tiktoken im Produktionscode. Die Bibliothek lädt ihre Tokenisierer-Dateien beim ersten Aufruf aus dem Netz. In einem Rückfallpfad, der gerade dann läuft, wenn schon ein Dienst ausgefallen ist, wäre das ein neuer Fehlerweg zur ungünstigsten Zeit. Der Preis sind mehr und kleinere Anfragen im Rückfall. Den zahle ich gern.

Wie die Tests diesmal etwas beweisen

Die Lehre aus dem ersten Versuch war wichtiger als der Splitter. Die neuen Tests zählen Token mit dem echten Tokenisierer des Modells, unabhängig von der Logik im Produktionscode; tiktoken ist dort nur Testabhängigkeit. Der nachgebaute HTTP-Server lehnt selbst nach den dokumentierten Grenzen ab, mit 400 bei zu vielen Token oder zu vielen Eingaben. Ein Splitter, der nicht teilt, fällt also durch, statt still zu bestehen.

Und es gab die Gegenprobe: Mit der alten Zeichenlogik eingesetzt, schlagen sechs der neuen Tests fehl. Erst damit ist belegt, dass sie den Fehler überhaupt sehen können. Die Byte-Schranke wurde zusätzlich an rund 8.000 Stichproben quer durch Unicode gemessen, von Emoji-Sequenzen über kombinierende Akzente bis zu CJK-lastigen Texten. Das schlechteste Verhältnis lag bei genau einem Token je Byte, also exakt an der Grenze und nie darüber. Die beiden Gegenbeispiele aus der Review laufen jetzt durch: Das chinesische wird zu sechs Anfragen, das mit den vielen kurzen Texten zu zwei mit 2.048 und 52 Eingaben.

Offen geblieben ist eine verwandte Grenze: Auch der einzelne Text darf höchstens 8.192 Token haben, und die bestehende Kappung nach 32.000 Zeichen garantiert das ebenfalls nicht. Gemessen riss sie schon auf Deutsch. Das ist ein eigenes Ticket, weil eine Kappung nach Bytes den Inhalt der Vektoren verändert und eigene Prüfung verdient.

Fazit

Ein Budget ist nur so gut wie seine Einheit. Zeichen sind bequem, aber für Token nur eine Hoffnung; Bytes sind bei einem Byte-BPE eine beweisbare Obergrenze. Und ein Test, der mit derselben Schätzung rechnet wie der Code, bestätigt bloß die Schätzung. Wer Grenzen einer Schnittstelle absichern will, braucht einen Testpartner, der genauso streng ablehnt wie das Original, und eine Gegenprobe, die zeigt, dass er es tut.

Weiterlesen