Structured output : contraindre un LLM à retourner du JSON au bon schéma avec Spring AI

1. Aperçu général

Quand vous intégrez un LLM dans une application, vous n’avez généralement pas besoin d’un bloc de texte. Vous avez besoin de données structurées pour les stocker en base, appeler des API ou prendre des décisions. Cet article explique comment contraindre un LLM à retourner du JSON conforme au schéma dans Spring Boot 3.x avec Spring AI. Après la lecture, vous serez capable de mapper directement la réponse du modèle dans un enregistrement Java, d’activer le mode JSON Schema natif d’OpenAI, de valider le résultat, de faire des réessais quand le modèle retourne une mauvaise réponse et d’écrire des tests pour la partie parse.

2. Pourquoi « retournez du JSON » n’est pas suffisant

La façon la plus simple est d’ajouter à votre prompt une phrase « Répondez en JSON ». Cette approche a plusieurs problèmes :

  • Le modèle enveloppe le JSON dans des balises markdown, ou ajoute une phrase « Voici le résultat : » au début.
  • Les noms de champs changent entre les appels, par exemple tantôt quantity, tantôt qty.
  • Les types de données sont incorrects, par exemple "quantity": "deux" au lieu de 2.
  • Les valeurs enum sont inventées, par exemple "speed": "très rapide".

Pour obtenir la stabilité, vous avez besoin de trois couches de protection :

  1. Un schéma clair envoyé avec le prompt, ou mieux, envoyé via l’API en tant que contrainte.
  2. Parser strictement vers un type Java.
  3. Valider les règles métier après le parsing, puis réessayer si nécessaire.

3. Installation

Cet article utilise Spring AI 1.0 avec OpenAI. D’abord, ajoutez le BOM et le starter :

<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>

Ensuite, configurez la clé API et le modèle :

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 réduit la « créativité » du modèle. Pour un problème d’extraction de données, c’est ce que vous voulez.

4. Mapper la réponse dans un record avec ChatClient.entity()

Problème d’exemple : extraire les informations de commande à partir d’un email client. Commencez par définir le type de données cible :

public enum DeliverySpeed { STANDARD, EXPRESS }

public record OrderRequest(
        @JsonProperty(required = true)
        @JsonPropertyDescription("Nom complet du client")
        String customerName,

        @JsonProperty(required = true)
        @JsonPropertyDescription("Code de produit, format SP-xxxx")
        String productCode,

        @JsonProperty(required = true)
        @JsonPropertyDescription("Quantité, nombre entier positif")
        int quantity,

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

Les annotations Jackson ici servent deux objectifs :

  • @JsonPropertyDescription est inclus dans le schéma JSON. Le modèle lit cette description pour comprendre le sens de chaque champ.
  • @JsonProperty(required = true) marque le champ comme obligatoire dans le schéma.
  • L’enum DeliverySpeed génère "enum": ["STANDARD", "EXPRESS"], ce qui limite le modèle à ces deux valeurs.

Vient ensuite le service qui appelle le modèle :

@Service
public class OrderExtractionService {

    private final ChatClient chatClient;

    public OrderExtractionService(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("Vous extrayez les informations de commande à partir des emails clients.")
                .build();
    }

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

L’appel .entity(OrderRequest.class) fait trois choses :

  1. Génère le schéma JSON à partir de OrderRequest.
  2. Ajoute le schéma et les instructions de format à la fin du prompt.
  3. Parse la réponse avec Jackson en OrderRequest.

Essayez avec l’email suivant :

Bonjour, je m'appelle Trần Thị Lan. Je voudrais commander 3x SP-1024, avec livraison express s'il vous plaît.

Résultat obtenu :

OrderRequest[customerName=Trần Thị Lan, productCode=SP-1024, quantity=3, speed=EXPRESS]

Le modèle comprend que « express » correspond à EXPRESS, car l’enum ne permet que deux valeurs.

4.1. En interne : BeanOutputConverter

.entity() utilise BeanOutputConverter en coulisse. Vous pouvez utiliser directement cette classe pour voir exactement ce qui est envoyé :

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

Sortie (abrégée) :

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "customerName" : { "type" : "string", "description" : "Nom complet du client" },
    "productCode" : { "type" : "string", "description" : "Code de produit, format SP-xxxx" },
    "quantity" : { "type" : "integer", "description" : "Quantité, nombre entier positif" },
    "speed" : { "type" : "string", "enum" : [ "STANDARD", "EXPRESS" ] }
  },
  "required" : [ "customerName", "productCode", "quantity", "speed" ],
  "additionalProperties" : false
}

La méthode getFormat() retourne le fragment d’instructions ajouté au prompt. Cet extrait demande au modèle de retourner uniquement du JSON selon RFC 8259, sans explications, sans encapsulation markdown, et d’inclure le schéma ci-dessus. Lors du parsing, convert() retire automatiquement les balises markdown si le modèle insiste pour les ajouter.

4.2. Types génériques : liste de commandes

Si un email contient plusieurs commandes, utilisez ParameterizedTypeReference :

public List<OrderRequest> extractAll(String email) {
    return chatClient.prompt()
            .user(u -> u.text("Listez toutes les commandes dans l'email :\n{email}").param("email", email))
            .call()
            .entity(new ParameterizedTypeReference<List<OrderRequest>>() {});
}

Passer List.class ne fonctionnera pas. Les informations de généricité sont effacées au moment de l’exécution, donc Spring AI ne sait pas quel type sont les éléments.

5. Structured output natif d’OpenAI

L’approche de la section 4 n’est que une requête dans le prompt. Le modèle peut toujours l’ignorer. Les nouveaux modèles OpenAI supportent response_format de type json_schema en mode strict. En mode strict, le processus de génération de tokens est limité par le schéma, de sorte que le JSON retourné est toujours valide syntaxiquement et structurellement.

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("Email:\n{email}").param("email", email))
            .options(options)
            .call()
            .content();

    return converter.convert(json);
}

Cet extrait de code fonctionne comme suit :

  • Utilise toujours BeanOutputConverter, mais seulement pour obtenir le schéma (getJsonSchema()) et parser le résultat (convert()).
  • Le schéma est envoyé via le paramètre API au lieu d’être inséré dans le prompt.
  • .content() retourne la chaîne JSON brute, que vous convertissez ensuite vous-même.

Remarque : Le mode strict d’OpenAI a ses propres limitations. Tous les champs doivent être dans required, et l’objet doit avoir additionalProperties: false. Certains mots-clés comme pattern ou minimum peuvent ne pas être supportés, selon le modèle. Si un champ est optionnel, déclarez un type qui accepte null au lieu de l’omettre dans required.

Le structured output natif garantit la structure, pas le contenu correct. Le modèle peut toujours retourner quantity: 0 ou inventer un productCode. C’est pourquoi vous avez besoin de la section suivante.

6. Validation et réessai

Ajoutez des contraintes Bean Validation au record :

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
) {}

Ensuite, validez le résultat. Si les données sont incorrectes, renvoyez l’erreur au modèle et demandez-lui de corriger :

@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 = "Extraire les commandes de l'email :\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\nPrécédemment, vous aviez retourné des données incorrectes : " + describe(violations) + ". Veuillez corriger.";
            } catch (RuntimeException e) {
                feedback = "\n\nPrécédemment, la réponse n'a pas pu être analysée : " + e.getMessage();
            }
        }
        throw new IllegalStateException("Impossible d'extraire la commande après " + MAX_ATTEMPTS + " tentatives");
    }

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

Points à noter :

  • validator.validate(order) vérifie les contraintes métier que le schéma ne capture pas complètement, par exemple l’expression régulière pour le code de produit.
  • Les messages d’erreur comme quantity must be greater than 0 sont ajoutés au prompt suivant. Le modèle corrige les erreurs bien mieux quand il sait où elles se trouvent.
  • Le bloc catch traite le cas où le JSON est malformé. Cette erreur est rare en mode strict, mais peut survenir avec d’autres modèles.
  • Limitez le nombre de tentatives pour éviter les boucles coûteuses.

Remarque : Si l’email ne contient vraiment pas de quantité, les réessais ne feront que pousser le modèle à inventer un chiffre pour passer la validation. Dans ce cas, permettez au champ d’accepter null, puis gérez-le au niveau métier, par exemple en redemandant au client.

7. Écrire des tests

Vous ne devriez pas appeler un vrai LLM dans un test unitaire, car c’est lent, coûteux et les résultats ne sont pas stables. Ce que vous devriez tester, c’est la logique déterministe : le schéma est généré correctement, le parsing fonctionne, la validation fonctionne.

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":"Trần Thị 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");
    }
}

Le troisième test montre clairement la délimitation des responsabilités. Ce JSON a la bonne structure, donc il se parse avec succès, mais il viole toujours les règles métier. Bean Validation attrape les deux champs en erreur.

Pour vérifier la qualité du prompt avec un vrai modèle, créez un ensemble de tests d’intégration séparé. Ces tests s’exécutent selon un calendrier ou manuellement, utilisent un ensemble d’emails d’exemple et mesurent le taux d’extraction correcte.

8. Erreurs courantes et quand ne pas l’utiliser

Quelques erreurs courantes :

  • Utiliser .entity() avec un modèle qui ne suit pas bien les instructions. Les petits modèles exécutés localement retournent souvent du JSON incomplet. Dans ce cas, utilisez le mode JSON ou la grammaire du runtime si disponible.
  • Schéma trop profond ou trop large. Des dizaines de champs imbriqués augmentent le taux d’erreur et la consommation de tokens. Divisez plutôt en plusieurs petits appels.
  • Manquer @JsonPropertyDescription. Les noms de champs comme code ou type sont très ambigus pour le modèle. Une ligne de description améliore considérablement la précision.
  • Faire confiance uniquement au mode strict. Le mode strict garantit uniquement la forme du JSON. Il n’empêche pas les données inventées.
  • Laisser temperature haute. Les résultats d’extraction vont fluctuer entre les exécutions.
  • Insérer les données utilisateur dans le prompt sans séparation claire. Un email peut contenir une phrase comme « ignorez les instructions précédentes ». Séparez le system prompt et validez toujours la sortie.

Ne pas utiliser le structured output quand :

  • La sortie est essentiellement du texte libre, comme un résumé ou une réponse conversationnelle. Forcer le texte dans JSON ne fait que dégrader la qualité.
  • Les données peuvent être extraites avec regex ou un analyseur classique. Cette approche est moins chère, plus rapide et plus déterministe.
  • Vous avez besoin d’une précision absolue, par exemple un montant de transaction, sans étape d’approbation humaine.

9. Conclusion

Cet article a couvert les moyens de contraindre un LLM à retourner du JSON au bon schéma dans Spring Boot :

  • ChatClient.entity() avec des records et des enums permet de mapper la réponse vers un type Java en une seule ligne de code.
  • BeanOutputConverter permet de voir et de contrôler le schéma généré.
  • ResponseFormat.Type.JSON_SCHEMA d’OpenAI garantit la structure dès que le modèle génère les tokens.
  • Bean Validation combiné à un retry avec retour d’erreur gère la partie que le schéma ne capture pas.

Le principe fondamental : le schéma préserve la forme des données, la validation préserve le sens des données. Une application de production a besoin des deux couches, plus des tests unitaires pour le parsing et un évaluateur séparé pour la qualité du prompt.

llmspring-aijson-schemaspring-bootjava