Structured Output: LLM-JSON mit korrektem Schema in Spring AI

1. Überblick

Bei der Integration von LLMs in deine Anwendung brauchst du selten nur einen Text. Du brauchst strukturierte Daten, um sie in der DB zu speichern, APIs aufzurufen oder Entscheidungen zu treffen. In diesem Artikel zeige ich dir, wie du LLMs zwingst, JSON mit dem richtigen Schema in Spring Boot 3.x zurückzugeben, indem du Spring AI nutzt. Nach dem Lesen wirst du LLM-Antworten direkt in Java Records abbilden können, das native JSON Schema von OpenAI aktivieren, Ergebnisse validieren, bei falschen Ausgaben neu versuchen und Tests für den Parsing-Teil schreiben.

2. Warum “gib JSON zurück” nicht ausreicht

Die einfachste Methode ist, im Prompt zu sagen “Antworte mit JSON”. Das funktioniert auf vielfältige Weise nicht:

  • Das Modell wickelt JSON in Markdown-Blöcke ein oder fügt Sätze wie “Hier ist das Ergebnis:” am Anfang ein.
  • Feldnamen ändern sich zwischen Aufrufen, z.B. mal quantity, mal qty.
  • Datentypen sind falsch, z.B. "quantity": "zwei" statt 2.
  • Enum-Werte werden erfunden, z.B. "speed": "sehr schnell".

Um zuverlässige Ergebnisse zu erzielen, brauchst du drei Schutzschichten:

  1. Klares Schema, entweder als Teil des Prompts oder besser über die API als Zwang.
  2. Striktes Parsing in einen Java-Typ.
  3. Geschäftslogik-Validierung nach dem Parsing, dann Retry bei Bedarf.

3. Installation

Dieser Artikel verwendet Spring AI 1.0 mit OpenAI. Zuerst füge das BOM und den Starter hinzu:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

Konfiguriere dann den API-Schlüssel und das Modell:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0

temperature=0 reduziert die “Kreativität” des Modells. Für Datenextraktionsaufgaben ist das genau das, was du willst.

4. Antworten mit ChatClient.entity() in Records abbilden

Beispielaufgabe: Extrahiere Bestellinformationen aus Kunden-E-Mails. Beginne damit, den Zieldatentyp zu definieren:

public enum DeliverySpeed { STANDARD, EXPRESS }

public record OrderRequest(
        @JsonProperty(required = true)
        @JsonPropertyDescription("Name des Kunden")
        String customerName,

        @JsonProperty(required = true)
        @JsonPropertyDescription("Produktcode, Format SP-xxxx")
        String productCode,

        @JsonProperty(required = true)
        @JsonPropertyDescription("Menge, positive Ganzzahl")
        int quantity,

        @JsonProperty(required = true)
        DeliverySpeed speed
) {}

Die Jackson-Annotationen hier haben zwei Zwecke:

  • @JsonPropertyDescription wird in das JSON Schema aufgenommen. Das Modell liest diese Beschreibung, um die Bedeutung jedes Feldes zu verstehen.
  • @JsonProperty(required = true) markiert erforderliche Felder im Schema.
  • Das Enum DeliverySpeed erzeugt "enum": ["STANDARD", "EXPRESS"], sodass das Modell auf diese zwei Werte beschränkt ist.

Hier ist der Service, der das Modell aufruft:

@Service
public class OrderExtractionService {

    private final ChatClient chatClient;

    public OrderExtractionService(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("Du extrahierst Bestellinformationen aus Kunden-E-Mails.")
                .build();
    }

    public OrderRequest extract(String email) {
        return chatClient.prompt()
                .user(u -> u.text("E-Mail:\n{email}").param("email", email))
                .call()
                .entity(OrderRequest.class);
    }
}

Der Aufruf von .entity(OrderRequest.class) macht drei Dinge:

  1. Generiert das JSON Schema aus OrderRequest.
  2. Hängt eine Formatierungsanweisung zusammen mit dem Schema am Ende des Prompts an.
  3. Parsed die Antwort mit Jackson in OrderRequest.

Versuche es mit dieser E-Mail:

Hallo Shop, ich bin Tran Thi Lan. Ich möchte 3x SP-1024 bestellen, bitte express Versand.

Erhaltenes Ergebnis:

OrderRequest[customerName=Tran Thi Lan, productCode=SP-1024, quantity=3, speed=EXPRESS]

Das Modell verstand von selbst, dass “express” dem EXPRESS entspricht, da das Enum nur zwei Werte zulässt.

4.1. Intern: BeanOutputConverter

.entity() nutzt BeanOutputConverter im Hintergrund. Du kannst diese Klasse direkt verwenden, um genau zu sehen, was gesendet wird:

var converter = new BeanOutputConverter<>(OrderRequest.class);
System.out.println(converter.getJsonSchema());

Ausgabe (gekürzt):

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "customerName" : { "type" : "string", "description" : "Name des Kunden" },
    "productCode" : { "type" : "string", "description" : "Produktcode, Format SP-xxxx" },
    "quantity" : { "type" : "integer", "description" : "Menge, positive Ganzzahl" },
    "speed" : { "type" : "string", "enum" : [ "STANDARD", "EXPRESS" ] }
  },
  "required" : [ "customerName", "productCode", "quantity", "speed" ],
  "additionalProperties" : false
}

Die Methode getFormat() gibt den Text zurück, der an den Prompt angehängt wird. Dieser Text fordert das Modell auf, nur JSON nach RFC 8259 zurückzugeben, ohne Erklärungen, ohne Markdown-Blöcke und mit dem Schema oben. Beim Parsing entfernt convert() auch Markdown-Blöcke, falls das Modell sie trotzdem hinzufügt.

4.2. Generische Typen: Liste von Bestellungen

Wenn eine E-Mail mehrere Bestellungen enthält, nutze ParameterizedTypeReference:

public List<OrderRequest> extractAll(String email) {
    return chatClient.prompt()
            .user(u -> u.text("Liste alle Bestellungen in der E-Mail auf:\n{email}").param("email", email))
            .call()
            .entity(new ParameterizedTypeReference<List<OrderRequest>>() {});
}

Das Übergeben von List.class funktioniert nicht. Generische Informationen werden zur Runtime gelöscht, sodass Spring AI nicht weiß, welchen Typ die Elemente haben.

5. Natives Structured Output von OpenAI

Die Methode aus Abschnitt 4 ist nur eine Aufforderung im Prompt. Das Modell kann es ignorieren. Neue OpenAI-Modelle unterstützen response_format vom Typ json_schema im strikten Modus. In diesem Modus ist die Token-Generierung an das Schema gebunden, sodass das zurückgegebene JSON immer syntaktisch und strukturell gültig ist.

public OrderRequest extractStrict(String email) {
    var converter = new BeanOutputConverter<>(OrderRequest.class);

    var options = OpenAiChatOptions.builder()
            .responseFormat(new ResponseFormat(
                    ResponseFormat.Type.JSON_SCHEMA, converter.getJsonSchema()))
            .build();

    String json = chatClient.prompt()
            .user(u -> u.text("E-Mail:\n{email}").param("email", email))
            .options(options)
            .call()
            .content();

    return converter.convert(json);
}

Dieser Code funktioniert wie folgt:

  • Es wird weiterhin BeanOutputConverter verwendet, aber nur um das Schema zu holen (getJsonSchema()) und das Ergebnis zu parsen (convert()).
  • Das Schema wird über den API-Parameter statt in den Prompt eingefügt.
  • .content() gibt den rohen JSON-String zurück, den du dann selbst konvertierst.

Hinweis: Der strikte Modus von OpenAI hat eigene Einschränkungen. Alle Felder müssen in required enthalten sein, und das Objekt muss additionalProperties: false haben. Einige Schlüsselwörter wie pattern oder minimum werden je nach Modell möglicherweise nicht unterstützt. Wenn ein Feld optional ist, deklariere seinen Typ so, dass er null zulässt, statt es aus required auszulassen.

Natives Structured Output garantiert die Struktur, nicht den korrekten Inhalt. Das Modell könnte immer noch quantity: 0 zurückgeben oder einen erfundenen productCode. Deshalb brauchst du den nächsten Abschnitt.

6. Validierung und Retry

Füge Bean-Validierungsannotationen zum Record hinzu:

public record OrderRequest(
        @JsonProperty(required = true) @NotBlank
        String customerName,

        @JsonProperty(required = true) @Pattern(regexp = "SP-\\d{4}")
        String productCode,

        @JsonProperty(required = true) @Positive
        int quantity,

        @JsonProperty(required = true) @NotNull
        DeliverySpeed speed
) {}

Validiere dann das Ergebnis. Wenn die Daten fehlerhaft sind, sende die Fehler an das Modell zurück und fordere es auf zu korrigieren:

@Service
public class ValidatedOrderExtractor {

    private static final int MAX_ATTEMPTS = 3;

    private final ChatClient chatClient;
    private final Validator validator;

    public ValidatedOrderExtractor(ChatClient.Builder builder, Validator validator) {
        this.chatClient = builder.build();
        this.validator = validator;
    }

    public OrderRequest extract(String email) {
        String feedback = "";
        for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
            String prompt = "Extrahiere die Bestellung aus der E-Mail:\n" + email + feedback;
            try {
                OrderRequest order = chatClient.prompt().user(prompt).call().entity(OrderRequest.class);
                Set<ConstraintViolation<OrderRequest>> violations = validator.validate(order);
                if (violations.isEmpty()) {
                    return order;
                }
                feedback = "\n\nDein letztes Ergebnis war fehlerhaft: " + describe(violations) + ". Bitte korrigiere.";
            } catch (RuntimeException e) {
                feedback = "\n\nDie letzte Antwort konnte nicht geparst werden: " + e.getMessage();
            }
        }
        throw new IllegalStateException("Konnte Bestellung nach " + MAX_ATTEMPTS + " Versuchen nicht extrahieren");
    }

    private String describe(Set<ConstraintViolation<OrderRequest>> violations) {
        return violations.stream()
                .map(v -> v.getPropertyPath() + " " + v.getMessage())
                .collect(Collectors.joining("; "));
    }
}

Beachte folgende Punkte:

  • validator.validate(order) prüft die geschäftslogischen Constraints, die das Schema nicht vollständig ausdrückt, z.B. die Regex für den Produktcode.
  • Fehlermeldungen wie quantity must be greater than 0 werden in den nächsten Prompt eingefügt. Das Modell korrigiert Fehler besser, wenn es weiß, was falsch war.
  • Der catch-Block behandelt den Fall von ungültigem JSON. Bei striktem Modus ist das selten, kann aber bei anderen Modellen vorkommen.
  • Das Limit auf Versuche verhindert Endlosschleifen und spart Kosten.

Hinweis: Wenn die E-Mail wirklich keine Menge enthält, wird Retry nur dazu führen, dass das Modell einen Wert erfindet, um die Validierung zu bestehen. In solchen Fällen solltest du das Feld null erlauben und die Situation auf der Geschäftslogik-Ebene behandeln, z.B. den Kunden fragen.

7. Tests schreiben

Du solltest LLMs nicht in Unit Tests aufrufen, weil das langsam ist, Kosten verursacht und die Ergebnisse unstabil sind. Das zu testende sollte die deterministische Logik sein: Schema wird korrekt generiert, Parsing funktioniert richtig, Validierung funktioniert richtig.

class OrderRequestConversionTest {

    private final BeanOutputConverter<OrderRequest> converter =
            new BeanOutputConverter<>(OrderRequest.class);

    private final Validator validator =
            Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void schemaContainsAllFieldsAndEnumValues() {
        String schema = converter.getJsonSchema();

        assertThat(schema)
                .contains("\"customerName\"", "\"productCode\"", "\"quantity\"")
                .contains("\"STANDARD\"", "\"EXPRESS\"");
    }

    @Test
    void convertsValidJsonToRecord() {
        String json = """
                {"customerName":"Tran Thi Lan","productCode":"SP-1024","quantity":3,"speed":"EXPRESS"}
                """;

        OrderRequest order = converter.convert(json);

        assertThat(order.quantity()).isEqualTo(3);
        assertThat(order.speed()).isEqualTo(DeliverySpeed.EXPRESS);
        assertThat(validator.validate(order)).isEmpty();
    }

    @Test
    void flagsInvalidBusinessValues() {
        String json = """
                {"customerName":"Lan","productCode":"ABC","quantity":0,"speed":"STANDARD"}
                """;

        OrderRequest order = converter.convert(json);

        assertThat(validator.validate(order))
                .extracting(v -> v.getPropertyPath().toString())
                .containsExactlyInAnyOrder("productCode", "quantity");
    }
}

Der dritte Test zeigt klar die Verantwortungstrennung. Dieses JSON ist strukturell korrekt und wird erfolgreich geparst, aber es ist geschäftslogisch fehlerhaft. Bean Validation erkennt beide fehlerhaften Felder.

Um die Prompt-Qualität mit echten Modellen zu überprüfen, erstelle einen separaten Integration-Test-Satz. Dieser Test-Satz läuft nach Zeitplan oder manuell, verwendet einen Satz von Beispiel-E-Mails und misst die Quote korrekter Extraktionen.

8. Häufige Fehler und wann man Structured Output nicht verwenden sollte

Einige häufig auftretende Fehler:

  • .entity() mit Modellen verwenden, die nicht gut Anweisungen folgen. Kleine lokal laufende Modelle geben oft JSON mit fehlenden Feldern zurück. In solchen Fällen nutze JSON Mode oder die Grammar-Funktion der Runtime, falls vorhanden.
  • Zu tiefes oder zu breites Schema. Dutzende verschachtelter Felder erhöhen die Fehlerquote und kosten mehr Tokens. Es ist besser, mehrere kleine Aufrufe zu machen.
  • @JsonPropertyDescription fehlt. Feldnamen wie code oder type sind für das Modell unklar. Eine Zeile Beschreibung verbessert die Genauigkeit erheblich.
  • Vollständig auf strikten Modus verlassen. Strict garantiert nur die Form des JSON, nicht die korrekten Daten.
  • .convert()-Fehler ignorieren. Sie sind selten, aber wenn sie auftreten, sind sie oft Zeichen für ein zu komplexes Schema.
  • temperature zu hoch halten. Extraktionsergebnisse werden zwischen Aufrufen schwanken.
  • Benutzerdaten direkt in den Prompt einbinden, ohne klare Trennung. E-Mails können Sätze wie “ignoriere die Anweisungen davor” enthalten. Nutze immer einen System-Prompt und validiere die Ausgabe.

Nutze Structured Output nicht, wenn:

  • Die Ausgabe von Natur aus freier Text ist, z.B. Zusammenfassungen oder Chat-Antworten. Erzwungenes JSON verschlechtert nur die Qualität.
  • Daten mit Regex oder Standard-Parsern extrahiert werden können. Das ist billiger, schneller und zuverlässiger.
  • Du absolute Genauigkeit brauchst, z.B. bei Transaktionsbeträgen, ohne menschliche Überprüfung.

9. Fazit

Dieser Artikel hat die Methoden durchlaufen, um LLMs zu zwingen, JSON mit korrektem Schema in Spring Boot zurückzugeben:

  • ChatClient.entity() mit Records und Enums ermöglicht es dir, Antworten mit einer Zeile Code in Java-Typen abzubilden.
  • BeanOutputConverter lässt dich das generierte Schema sehen und kontrollieren.
  • ResponseFormat.Type.JSON_SCHEMA von OpenAI garantiert Struktur ab dem Token-Generierungsprozess.
  • Bean Validation kombiniert mit Retry und Fehler-Feedback behandelt das, was das Schema nicht abdeckt.

Das Kernprinzip: Schema erhält die Form der Daten, Validierung erhält die Bedeutung der Daten. Production-Anwendungen brauchen beide Schichten, zusammen mit Unit Tests für die Parse-Logik und einer separaten Evaluations-Suite für die Prompt-Qualität.

llmspring-aijson-schemaspring-bootjava