Teil 5 · Zuverlässiges JSON aus einem lokalen LLM bekommen

Mai 10, 2026·
Ndimofor Aretas
Ndimofor Aretas
· 8 Min Lesezeit
blog AI Engineering

Teil einer Serie über die Entwicklung von Gemma CogniVault. Zuvor: Crash-resumable ingestion with DBOS.

Alle Abkürzungen werden im Anhang unten auf der Seite vollständig erklärt.

Der Study Hub von CogniVault generiert aus deinen Dokumenten vier Arten von strukturierten Artefakten: Quizzes, mehrteilige Workshops, Flashcard-Decks und Mindmaps. Alle vier benötigen ein Modell, das strukturiertes JSON zurückgibt, keinen Fließtext. Alle vier laufen auf Gemma 4, das lokal über Ollama ausgeführt wird. Und alle vier würden viel zu oft scheitern, wenn ich darauf vertrauen würde, dass das Modell “einfach JSON zurückgibt”.

Hier ist das defensive Muster, das diese Ausfallrate auf nahe null drückt – und was man mit den Fällen macht, die trotzdem noch durchrutschen.

Das Muster

1. Retrieve     hybrid search restricted by user-selected scope
2. Prompt       strict schema-by-example with explicit count + shape rules
3. Generate     ollama.chat with format="json"  (grammar-constrained)
4. Parse        json.loads, tolerant of object / array / fenced shapes,
                 with a trailing-comma repair pass
5. Validate     drop malformed items rather than fail the whole batch
6. Retry        the workshop outline retries once with a stronger prompt
7. Persist      SQLite (progress.db) so the user can come back later

Jeder Generator in CogniVault folgt diesem Ablauf. Die interessanten Schritte sind 2, 4 und 5.

Schritt 3: format="json" leistet echte Arbeit

Ollama bietet eine format="json"-Option, die dem Modell während des Samplings einen Grammatik-Constraint (Einschränkung) auferlegt. Der Decoder gibt keine Tokens aus, die die Ausgabe zu ungültigem JSON machen würden. Das ist nicht perfekt – Schemata umfassen mehr als nur “gültiges JSON”, und das Modell kann immer noch wohlgeformten Müll produzieren – aber es eliminiert die gesamte Klasse von “Das Modell hat angefangen, Text vor der schließenden Klammer zu schreiben”-Fehlern.

Wenn dein lokaler LLM-Stack eine Grammatik-Option unterstützt (Ollama, llama.cpp, vLLM usw.), schalte sie ein. Sie ist nicht umsonst (Sampling wird etwas langsamer), aber die Verbesserung bei den Fehlerarten ist enorm. Ohne sie wirst du dein gesamtes Fehlerbudget für abgeschnittene Objekte ausgeben.

Schritt 2: Ein Schema-im-Prompt, an das sich das Modell auch halten kann

format="json" garantiert, dass die Struktur der Ausgabe JSON ist. Es sagt nichts darüber aus, ob das JSON auch zu deinem Domain-Schema passt. Das ist die Aufgabe des Prompts.

Das Muster, das für mich funktioniert: Anstatt ein formales JSON-Schema reinzuwerfen und zu sagen “halte dich daran”, baue ein ausgefülltes Beispiel ein, das dem Modell die exakte Form und explizite Mengenangaben zeigt. Hier ist das Herzstück des echten Quiz-Templates von CogniVault (es liegt als bearbeitbare Markdown-Datei in backend/prompts/quiz.md):

Output ONLY a single JSON object — no prose, no markdown fences,
no text outside the JSON.

NUMBER OF QUESTIONS: EXACTLY $num_questions. This is a hard requirement.

OUTPUT SCHEMA:
{
  "questions": [
    {
      "type": one of [$types_csv],
      "question": the question text (string, no leading numbering),
      "options": array of strings (length 4 for mcq, length 2 for true_false),
      "correct_index": integer index into options (0-based),
      "explanation": 1-2 sentence explanation of the correct answer
    },
    ... exactly $num_questions entries
  ]
}

Ein paar Entscheidungen, die wichtig sind:

  • Zeig die Form, beschreibe sie nicht. “Jedes Item hat ein type-Feld” wird viel öfter ignoriert als ein wörtliches Beispiel.
  • Lege die Anzahl fest. “EXACTLY 10” – wiederholt, in Großbuchstaben, als harte Anforderung – ist viel zuverlässiger als “ungefähr 10”.
  • Verwende Indizes, keine Wiederholungen. Die richtige Antwort ist correct_index, ein Integer, der auf die options verweist – und nicht noch einmal der Antworttext. Wiederholter Text lädt zu Paraphrasierungen ein (“Paris” vs. “Paris, Frankreich”), und dann geht dein Grading-Vergleich kaputt.
  • Ein Artefakt pro Aufruf. Ich habe versucht, einen kompletten Workshop (Outline + jede Lektion) in einem Aufruf zu generieren. Die Qualität des Modells nimmt rapide ab, je länger die Antwort wird. Die Aufteilung in “Outline zuerst, Lektionen nach Bedarf” ist die weiter unten beschriebene Zwei-Phasen-Strategie.

Schritt 4: Tolerant parsen

Selbst mit format="json" überleben in der Praxis zwei Parsing-Probleme.

Die Struktur-Überraschung. Das hier hat mich in der Produktion erwischt: Ich war davon ausgegangen, dass das Modell ein reines JSON-Array von Fragen zurückgeben würde. Mit format="json" gibt Gemma aber beständig ein Objekt zurück – {"questions": [...]} – und eine Zeit lang hat mein Parser nur das Array akzeptiert. Das Ergebnis: Ein 502-Fehler bei jeder Quiz-Generierung, bis ich es gefunden hatte. Die Lösung ist ein Parser, der dem Modell entgegenkommt:

# Simplified from backend/services/quiz_generator.py
def extract_items(raw: str) -> list | None:
    for candidate in (raw, extract_json_object(raw), extract_json_array(raw)):
        if candidate is None:
            continue
        data = load_json_lenient(candidate)
        if isinstance(data, list):
            return data                      # bare array
        if isinstance(data, dict):
            items = data.get("questions")    # the expected object shape
            if isinstance(items, list):
                return items
    return None

Lexikalische Ausrutscher. Manchmal rutscht ein nachgestelltes Komma durch. Die Reparatur ist absichtlich eng gefasst – ein Regex-Durchlauf, danach aufgeben:

def load_json_lenient(text: str):
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        repaired = re.sub(r",(\s*[\]}])", r"\1", text)   # strip trailing commas
        try:
            return json.loads(repaired)
        except json.JSONDecodeError:
            return None

Ich versuche nicht, Klammern auszugleichen, abgeschnittene Strings zu vervollständigen oder fehlende Felder zu erraten. Entweder ist die Ausgabe mit einem Trailing-Comma-Pass und etwas Substring-Extraktion reparierbar, oder eben nicht – und dann gehen wir zu Schritt 5.

Schritt 5: Fehlerhafte Items verwerfen, nicht den ganzen Batch scheitern lassen

Das war die Entscheidung, mit der ich am längsten zu kämpfen hatte, bis ich meinen Frieden damit gemacht habe.

Wenn das Modell 10 Quizfragen zurückgibt, aber bei Nummer 7 das options-Feld fehlt, ist die Versuchung groß, einen Fehler auszuwerfen und den ganzen Batch neu zu generieren. Tu das nicht. Validiere jedes Item einzeln und verwirf diejenigen, die fehlschlagen.

# CogniVault does this with explicit field checks into a dataclass;
# pydantic works just as well.
questions = []
for raw_item in parsed_items:
    q = validate_item(raw_item, allowed_types)   # returns None if malformed
    if q is not None:
        questions.append(q)

Der Nutzer bekommt 9 Fragen statt 10. Das fällt ihm nicht auf. Die gesamte Generierung neu zu starten, um Frage 7 zu reparieren, kostet 30 Sekunden und führt vielleicht zu neuen Fehlern in den Fragen 1-6. Der “Dropped-Item”-Ansatz ist für die UX einfach streng genommen besser. (Das Modell schießt übrigens auch manchmal über das Ziel hinaus – die validierte Liste wird dann einfach auf die angeforderte Menge gekürzt.)

Schritt 6: Die Outline darf es einmal neu versuchen

Workshops sind die Ausnahme, die die Regel bestätigt. Ein Workshop ist eine strukturierte Outline (Titel, Zusammenfassung, Lektionsliste) plus der Inhalt jeder Lektion. Die Outline muss parsbar sein – bei einem Inhaltsverzeichnis gibt es keinen Teilerfolg. Deshalb löst ein Parsing-Fehler hier genau einen Retry aus, bei dem der Prompt noch einmal mit einer strengen Erinnerung geschickt wird: “Your previous response was unparseable. Output ONLY a single valid JSON object.” Wenn der zweite Versuch auch fehlschlägt, bekommt der Nutzer eine klare Fehlermeldung mit dem Vorschlag, den Scope etwas einzuengen.

Ein Retry, nicht drei. Drei Retries, wenn das Modell ohnehin verwirrt ist, verschwenden nur Sekunden und Strom.

Die Lektionen selbst sind interessanterweise gar kein JSON. Ein Lektionstext ist Fließtext – ihn in einen JSON-String zu zwingen, brächte nichts und würde nur Escaping-Kopfschmerzen verursachen. Lektionen werden als reines Markdown generiert und durchlaufen dann einen kleinen Cleanup-Pass, die Chat-Floskeln entfernt, die das Modell trotz gegenteiliger Anweisungen manchmal hinzufügt (“I hope this helps!”, “Let me know if…”). Andere Ausgabe, anderer Vertrag.

Zwei-Phasen-Ansatz: Outline zuerst, Lektionen nach Bedarf

Workshops nutzen ein zweistufiges Generierungsmuster:

Pass 1 — generate outline:    {"title": ..., "lessons": [{"title": ...}, ...]}   (cheap, JSON)
Pass 2 — for each lesson:     a full Markdown lesson body                        (on demand)

Die Outline ist schnell da und lässt den Nutzer sofort die Struktur des Workshops sehen. Jede Lektion wird erst generiert, wenn der Nutzer sie öffnet – was bedeutet, dass der Nutzer gerade Lektion 1 liest, während er noch entscheidet, ob er Lektion 5 überhaupt haben möchte. Die Gesamt-Wartezeit bis zum “ersten nützlichen Inhalt” ist so selbst bei einem Workshop mit 10 Lektionen winzig.

Das ist genau der gleiche architektonische Kniff, den die Chat-Seite mit dem Two-Phase-Streaming anwendet: Teile eine langsame Operation in einen winzigen schnellen Teil und einen größeren langsamen Teil auf, und gib dem Nutzer den schnellen Teil sofort.

Was ich bisher beim Bauen dieser Generatoren gelernt habe

Ein paar destillierte Prinzipien aus den vier Generatoren:

  1. Nutze die Grammatik-Option in deinem Inference-Stack. Versuch erst gar nicht, JSON aus einem frei formulierenden Decoder herauszulocken.
  2. Nagel jeden Quantifikator im Prompt fest. “Exactly 10”, “exactly 4 options”, “one or two sentences”. Vage Mengenangaben = inkonsistenter Output.
  3. Verlass dich nicht auf die oberste Struktur-Ebene. Grammatik-eingeschränktes Gemma mag Objekte; dein Code erwartet vielleicht Arrays. Akzeptiere beides – der Parser ist billiger, als sich darauf zu verlassen, dass das Modell die erwartete Struktur liefert.
  4. Verwerfen, nicht scheitern lassen. Ein verlustbehafteter Erfolg schlägt spröde Perfektion.
  5. Ein Retry, nie mehr. Wenn zwei Versuche kein gültiges Ergebnis liefern, ist der Prompt falsch, nicht das Modell.
  6. Teile große Generierungen auf. Outline + Lektionen. Skelett + Körper. Zwei kleine Aufrufe schlagen einen großen fast jedes Mal. Und wenn ein Teil der Ausgabe natürlicher Fließtext ist, lass ihn auch Fließtext sein.

Lokale LLMs sind im Jahr 2026 gut genug, dass strukturierte Generierung für Features auf Produktionsniveau wirklich nutzbar ist. Sie sind allerdings nicht so gut, dass du auf das defensive Gerüst verzichten könntest. Das obige Gerüst macht insgesamt vielleicht 80 Zeilen Code in allen vier Generatoren aus, und das ist genau der Unterschied zwischen “Demo-Qualität” und “Ich vertraue dem genug, um es zu shippen.”


Anhang: Abkürzungen in diesem Beitrag

AbkürzungVollformBedeutung
JSONJavaScript Object NotationDas strukturierte Textformat, das die Generatoren produzieren müssen
LLMLarge Language ModelEin neuronales Netz, das auf großen Textmengen trainiert wurde, um Sprache zu lesen und zu generieren
AIArtificial IntelligenceSoftware, die Aufgaben ausführt, für die normalerweise menschliche Intelligenz erforderlich ist
MCQMultiple-Choice QuestionEine der zwei Arten von Quizfragen (die andere ist True/False)
UXUser ExperienceWarum 9 gültige Fragen besser sind als ein Neu-Generierungs-Fehler
SQLite(SQL = Structured Query Language)Die Single-File-Datenbank, in der generierte Artefakte gespeichert werden
DBOSDatabase-Oriented Operating SystemDie Bibliothek für dauerhafte Workflows aus dem vorherigen Beitrag
HTTP 502Bad Gateway (HyperText Transfer Protocol status code)Der Fehler, den mein reiner Array-Parser warf, bis ich Gemmas Objektform akzeptierte

Als Nächstes: The mindmap renderer — was mich das händische Bauen eines SVG-Radial-Layouts gelehrt hat und warum Version zwei trotzdem React Flow nutzt.