Structured output: forçar LLM a retornar JSON no schema correto com Spring AI

1. Visão geral

Ao integrar LLM em uma aplicação, geralmente você não precisa de um parágrafo de texto. Você precisa de dados estruturados para salvar no banco, chamar uma API ou tomar uma decisão. Este artigo apresenta como forçar LLM a retornar JSON no schema correto no Spring Boot 3.x usando Spring AI. Ao terminar de ler, você conseguirá mapear a resposta do modelo diretamente para um Java record, ativar o modo JSON Schema nativo do OpenAI, validar o resultado, fazer retry quando o modelo retorna algo errado e escrever testes para a parte de parse.

2. Por que “retorne em JSON” não é suficiente

A forma mais simples é adicionar ao prompt a frase “Responda em JSON”. Essa abordagem quebra de várias formas:

  • O modelo envolve o JSON em markdown fence, ou adiciona uma frase como “Aqui está o resultado:” no início.
  • Os nomes de field mudam entre as chamadas, por exemplo às vezes quantity, às vezes qty.
  • O tipo de dados está errado, digamos "quantity": "dois" em vez de 2.
  • Valores enum são inventados, por exemplo "speed": "muito rápido".

Para ficar estável, você precisa de três camadas de proteção:

  1. Schema claro enviado junto com o prompt, ou melhor ainda, enviado via API como uma restrição.
  2. Parse rigoroso para um tipo Java.
  3. Validação de negócio após o parse, depois retry se necessário.

3. Configuração

Este artigo usa Spring AI 1.0 com OpenAI. Primeiro, adicione o BOM e o 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>

Depois configure a chave da API e o modelo:

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 reduz a “criatividade” do modelo. Para tarefas de extração de dados, é o que você quer.

4. Mapear resposta para record com ChatClient.entity()

Exemplo de problema: extrair informações de pedido do email do cliente. Comece definindo o tipo de dados de destino:

public enum DeliverySpeed { STANDARD, EXPRESS }

public record OrderRequest(
        @JsonProperty(required = true)
        @JsonPropertyDescription("Nome completo do cliente")
        String customerName,

        @JsonProperty(required = true)
        @JsonPropertyDescription("Código do produto, formato SP-xxxx")
        String productCode,

        @JsonProperty(required = true)
        @JsonPropertyDescription("Quantidade, número inteiro positivo")
        int quantity,

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

As anotações Jackson aqui têm dois propósitos:

  • @JsonPropertyDescription é incluída no JSON Schema. O modelo lê essa descrição para entender o significado de cada field.
  • @JsonProperty(required = true) marca o field como obrigatório no schema.
  • O enum DeliverySpeed gera "enum": ["STANDARD", "EXPRESS"], então o modelo é restrito a esses dois valores.

Em seguida está o service que chama o modelo:

@Service
public class OrderExtractionService {

    private final ChatClient chatClient;

    public OrderExtractionService(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("Você extrai informações de pedido do email do cliente.")
                .build();
    }

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

A chamada .entity(OrderRequest.class) faz três coisas:

  1. Gera JSON Schema a partir de OrderRequest.
  2. Adiciona instruções de formato junto com o schema ao final do prompt.
  3. Faz parse da resposta usando Jackson para OrderRequest.

Experimente com o seguinte email:

Oi shop, sou Trần Thị Lan. Quero pedir 3 unidades do SP-1024, entrega rápida por favor.

O resultado recebido seria:

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

O modelo entende automaticamente que “rápida” corresponde a EXPRESS, porque o enum permite apenas esses dois valores.

4.1. Por baixo dos panos: BeanOutputConverter

.entity() usa BeanOutputConverter nos bastidores. Você pode usar essa classe diretamente para ver exatamente o que está sendo enviado:

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

A saída (abreviada):

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "customerName" : { "type" : "string", "description" : "Nome completo do cliente" },
    "productCode" : { "type" : "string", "description" : "Código do produto, formato SP-xxxx" },
    "quantity" : { "type" : "integer", "description" : "Quantidade, número inteiro positivo" },
    "speed" : { "type" : "string", "enum" : [ "STANDARD", "EXPRESS" ] }
  },
  "required" : [ "customerName", "productCode", "quantity", "speed" ],
  "additionalProperties" : false
}

O método getFormat() retorna o trecho de instrução que é adicionado ao prompt. Este trecho pede ao modelo para retornar apenas JSON conforme RFC 8259, sem explicações, sem envolver em markdown, e incluindo o schema acima. Ao fazer parse, convert() até remove markdown fence se o modelo ainda tentar adicionar.

4.2. Tipos genéricos: lista de pedidos

Se um email contém múltiplos pedidos, use ParameterizedTypeReference:

public List<OrderRequest> extractAll(String email) {
    return chatClient.prompt()
            .user(u -> u.text("Liste todos os pedidos no email:\n{email}").param("email", email))
            .call()
            .entity(new ParameterizedTypeReference<List<OrderRequest>>() {});
}

Passar List.class não funcionará. A informação genérica é removida em tempo de execução, então Spring AI não sabe qual é o tipo do elemento interno.

5. Structured output nativo do OpenAI

A abordagem na seção 4 é apenas uma solicitação no prompt. O modelo ainda pode ignorá-la. Os modelos OpenAI mais novos suportam response_format do tipo json_schema em modo strict. Neste modo, o processo de geração de token é restrito pelo schema, então o JSON retornado é sempre válido em termos de sintaxe e estrutura.

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

Este código funciona da seguinte forma:

  • Ainda usa BeanOutputConverter, mas apenas para obter o schema (getJsonSchema()) e fazer parse do resultado (convert()).
  • O schema é enviado via parâmetro da API em vez de ser injetado no prompt.
  • .content() retorna a string JSON bruta, que você depois converte.

Nota: O modo strict do OpenAI tem suas próprias limitações. Todo field deve estar em required, e o object deve ter additionalProperties: false. Algumas palavras-chave como pattern ou minimum podem não ser suportadas, dependendo do modelo. Se um field é opcional, declare o tipo como permitindo null em vez de omiti-lo de required.

Structured output nativo garante a estrutura, não garante o conteúdo correto. O modelo ainda pode retornar quantity: 0 ou inventar um productCode. É por isso que você ainda precisa da próxima seção.

6. Validação e retry

Adicione restrições de Bean Validation ao 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
) {}

Depois valide o resultado. Se os dados estiverem errados, envie de volta o erro para o modelo e peça para corrigir:

@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 = "Extraia o pedido do 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\nNa última vez você retornou dados inválidos: " + describe(violations) + ". Corrija.";
            } catch (RuntimeException e) {
                feedback = "\n\nNa última vez a resposta não pôde ser parseada: " + e.getMessage();
            }
        }
        throw new IllegalStateException("Não foi possível extrair o pedido após " + MAX_ATTEMPTS + " tentativas");
    }

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

Pontos dignos de nota:

  • validator.validate(order) verifica restrições de negócio que o schema não exprime completamente, como regex para código de produto.
  • Mensagens de erro como quantity must be greater than 0 são adicionadas ao próximo prompt. O modelo corrige melhor quando sabe onde errou.
  • O bloco catch lida com o caso de JSON corrompido. Isso é raro com modo strict, mas pode acontecer com outros modelos.
  • Um limite no número de tentativas para evitar loops que custam dinheiro.

Nota: Se o email realmente não contém a quantidade, retry apenas fará o modelo inventar um número para passar na validação. Neste caso, deixe o field aceitar null, depois trate na camada de negócio, por exemplo pedindo ao cliente novamente.

7. Escrever testes

Você não deve chamar LLM real em unit testes, porque é lento, caro e o resultado é instável. A parte que deve testar é a lógica determinística: schema é gerado corretamente, parse funciona, validação funciona.

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");
    }
}

O terceiro teste mostra claramente a divisão de responsabilidades. Este JSON está estruturalmente correto então faz parse com sucesso, mas ainda está errado no ponto de vista de negócio. Bean Validation pega ambos os campos errados.

Para verificar a qualidade do prompt com um modelo real, separe um conjunto de testes de integração. Este conjunto roda em um cronograma ou manualmente, usa um conjunto de emails de amostra e mede a taxa de extração correta.

8. Erros comuns e quando não usar

Alguns erros comuns:

  • Usar .entity() com modelo que não segue bem as instruções. Modelos menores rodando localmente geralmente retornam JSON faltando fields. Neste caso, use o modo JSON ou grammar do runtime se disponível.
  • Schema muito profundo ou muito largo. Dezenas de fields aninhados aumentam a taxa de erro e desperdiçam tokens. Melhor dividir em várias chamadas menores.
  • Falta de @JsonPropertyDescription. Nomes como code ou type são muito vagos para o modelo. Uma linha de descrição melhora significativamente a precisão.
  • Confiar completamente em modo strict. Strict apenas garante a forma do JSON. Não impede dados inventados.
  • Deixar temperature alta. Resultados de extração variarão entre as execuções.
  • Colocar dados do usuário no prompt sem separá-los claramente. Um email pode conter frases como “ignore as instruções anteriores”. Sempre separe o system prompt e valide a saída.

Não deve usar structured output quando:

  • A saída é essencialmente texto livre, como um resumo ou resposta de chat. Forçar texto em JSON apenas reduz a qualidade.
  • Os dados podem ser extraídos com regex ou parser comum. É mais barato, rápido e determinístico.
  • Você precisa de precisão absoluta, como valores de transação em dinheiro, sem uma etapa de confirmação humana.

9. Conclusão

Este artigo cobriu várias formas de forçar LLM a retornar JSON no schema correto no Spring Boot:

  • ChatClient.entity() com record e enum permite mapear a resposta para um tipo Java em apenas uma linha.
  • BeanOutputConverter permite ver e controlar qual schema é gerado.
  • ResponseFormat.Type.JSON_SCHEMA do OpenAI garante estrutura enquanto o modelo gera tokens.
  • Bean Validation combinado com retry com feedback lida com a parte que o schema não cobre.

O princípio central: schema protege a forma dos dados, validação protege o significado dos dados. Aplicações em produção precisam de ambas as camadas, junto com testes unitários para a parte de parse e um avaliador separado para qualidade do prompt.

llmspring-aijson-schemaspring-bootjava