Structured output: Fuerza tu LLM a devolver JSON con schema correcto en Spring AI

1. Introducción

Cuando integras un LLM en tu aplicación, normalmente no necesitas un párrafo de texto. Necesitas datos estructurados para guardar en la BD, llamar a APIs o tomar decisiones. Este artículo muestra cómo forzar que el LLM devuelva JSON con schema correcto en Spring Boot 3.x usando Spring AI. Después de leerlo, podrás mapear la respuesta del modelo directamente a un Java record, habilitar el modo JSON Schema nativo de OpenAI, validar resultados, reintentar cuando el modelo devuelve datos incorrectos y escribir tests para la parte de parsing.

2. Por qué “devuelve JSON” no es suficiente

La forma más simple es agregar a tu prompt la frase “Responde en JSON”. Pero esto falla de muchas maneras:

  • El modelo envuelve el JSON en bloques de código markdown, o agrega frases como “Aquí está el resultado:” al inicio.
  • Los nombres de campos cambian entre llamadas, por ejemplo a veces quantity, a veces qty.
  • Tipos de datos incorrectos, como "quantity": "dos" en lugar de 2.
  • Valores de enumeraciones inventados, como "speed": "muy rápido".

Para hacerlo estable, necesitas tres capas de protección:

  1. Un schema explícito incluido en el prompt, o mejor aún, enviado a través de la API como una restricción.
  2. Parsing estricto a un tipo de dato Java.
  3. Validación de negocio después del parsing, seguido de reintentos si es necesario.

3. Instalación

Este artículo usa Spring AI 1.0 con OpenAI. Primero, añade el BOM y el 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>

Luego, configura la clave API y el 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 reduce la “creatividad” del modelo. Para tareas de extracción de datos, es lo que deseas.

4. Mapea la respuesta al record con ChatClient.entity()

Caso de ejemplo: extraer información del pedido desde el email del cliente. Comienza definiendo el tipo de dato destino:

public enum DeliverySpeed { STANDARD, EXPRESS }

public record OrderRequest(
        @JsonProperty(required = true)
        @JsonPropertyDescription("Nombre del cliente")
        String customerName,

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

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

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

Las anotaciones Jackson aquí tienen dos propósitos:

  • @JsonPropertyDescription se incluye en el JSON Schema. El modelo lee esta descripción para entender el significado de cada campo.
  • @JsonProperty(required = true) marca un campo como obligatorio en el schema.
  • El enum DeliverySpeed genera "enum": ["STANDARD", "EXPRESS"], por lo que el modelo se ve limitado a estos dos valores.

Luego viene el servicio que llama al modelo:

@Service
public class OrderExtractionService {

    private final ChatClient chatClient;

    public OrderExtractionService(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("Extraes información de pedidos desde emails de clientes.")
                .build();
    }

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

La llamada .entity(OrderRequest.class) hace tres cosas:

  1. Genera el JSON Schema a partir de OrderRequest.
  2. Agrega las instrucciones de formato junto con el schema al final del prompt.
  3. Parsea la respuesta usando Jackson en una instancia de OrderRequest.

Prueba con este email:

Hola tienda, soy Trần Thị Lan. Quiero hacer un pedido de 3 unidades de SP-1024, entrega urgente por favor.

El resultado obtenido:

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

El modelo entiende automáticamente que “urgente” corresponde a EXPRESS, porque el enum limita a solo dos valores.

4.1. Por dentro: BeanOutputConverter

.entity() usa BeanOutputConverter detrás de escenas. Puedes usar esta clase directamente para ver exactamente qué se envía:

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

La salida (abreviada):

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "customerName" : { "type" : "string", "description" : "Nombre del cliente" },
    "productCode" : { "type" : "string", "description" : "Código de producto, formato SP-xxxx" },
    "quantity" : { "type" : "integer", "description" : "Cantidad, número entero positivo" },
    "speed" : { "type" : "string", "enum" : [ "STANDARD", "EXPRESS" ] }
  },
  "required" : [ "customerName", "productCode", "quantity", "speed" ],
  "additionalProperties" : false
}

El método getFormat() devuelve la instrucción que se añade al prompt. Esta instrucción le pide al modelo que devuelva solo JSON válido según RFC 8259, sin explicaciones, sin envolturas markdown, e incluye el schema anterior. Al parsear, convert() también elimina automáticamente los bloques markdown si el modelo insiste en añadirlos.

4.2. Tipos genéricos: lista de pedidos

Si un email contiene múltiples pedidos, usa ParameterizedTypeReference:

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

Pasar List.class no funcionará. La información genérica se pierde en tiempo de ejecución, por lo que Spring AI no sabe qué tipo tienen los elementos dentro.

5. Structured output nativo de OpenAI

El método en la sección 4 es solo una solicitud en el prompt. El modelo aún puede ignorarla. Los modelos OpenAI más recientes soportan response_format de tipo json_schema en modo strict. En este modo, el proceso de generación de tokens está vinculado al schema, por lo que el JSON devuelto siempre es válido tanto sintácticamente como estructuralmente.

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 así:

  • Seguimos usando BeanOutputConverter, pero solo para obtener el schema (getJsonSchema()) y parsear el resultado (convert()).
  • El schema se envía a través de un parámetro API en lugar de incrustarse en el prompt.
  • .content() devuelve la cadena JSON cruda, que luego convertimos manualmente.

Nota: El modo strict de OpenAI tiene sus propias limitaciones. Todo campo debe estar en required, y el objeto debe tener additionalProperties: false. Algunas palabras clave como pattern o minimum pueden no ser soportadas, dependiendo del modelo. Si un campo es opcional, declara su tipo permitiendo null en lugar de sacarlo de required.

El structured output nativo garantiza la estructura, pero no el contenido correcto. El modelo aún puede devolver quantity: 0 o inventarse un productCode. Por eso necesitas la siguiente sección.

6. Validación y reintentos

Agrega restricciones de Bean Validation al 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
) {}

Luego valida el resultado. Si los datos son incorrectos, devuelve los errores al modelo y pide que lo corrija:

@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 = "Extrae información del pedido desde el 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\nLa última vez devolviste datos incorrectos: " + describe(violations) + ". Corrígelo.";
            } catch (RuntimeException e) {
                feedback = "\n\nLa última respuesta no se pudo parsear: " + e.getMessage();
            }
        }
        throw new IllegalStateException("No se pudo extraer el pedido después de " + MAX_ATTEMPTS + " intentos");
    }

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

Puntos a destacar:

  • validator.validate(order) verifica las restricciones de negocio que el schema no cubre completamente, como expresiones regulares para códigos de producto.
  • Los mensajes de error como quantity must be greater than 0 se añaden al siguiente prompt. El modelo corrige los errores mucho mejor cuando sabe dónde está el problema.
  • El bloque catch maneja el caso de JSON malformado. Este error es raro en modo strict, pero puede ocurrir con otros modelos.
  • Limita el número de intentos para evitar bucles infinitos que gasten dinero.

Nota: Si el email realmente no contiene una cantidad, los reintentos solo harán que el modelo invente un número para pasar la validación. En este caso, debes permitir que el campo sea null, y manejar esto en la capa de negocio, por ejemplo preguntando al cliente.

7. Escribe tests

No deberías llamar el LLM real en tests unitarios, ya que es lento, costoso e impredecible. Lo que debes probar es la lógica determinista: que el schema se genere correctamente, el parsing sea correcto, y la validación funcione.

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

El tercer test muestra claramente la división de responsabilidades. Este JSON tiene la estructura correcta así que se parsea exitosamente, pero es incorrecto desde el punto de vista del negocio. Bean Validation captura ambos campos incorrectos.

Para evaluar la calidad del prompt con el modelo real, separa un conjunto de tests de integración. Estos tests se ejecutan en horario fijo o manualmente, usan un conjunto de emails de muestra y miden la tasa de extracción correcta.

8. Errores comunes y cuándo no usar esto

Algunos errores comunes:

  • Usar .entity() con un modelo que no soporta bien las instrucciones. Los modelos pequeños que se ejecutan localmente a menudo devuelven JSON con campos faltantes. En este caso, usa el modo JSON o la gramática del runtime si está disponible.
  • Un schema muy profundo o muy amplio. Docenas de campos anidados aumentan la tasa de errores y consumen tokens. Es mejor dividir en múltiples llamadas más pequeñas.
  • Falta @JsonPropertyDescription. Nombres de campos como code o type son muy ambiguos para el modelo. Una línea de descripción mejora significativamente la precisión.
  • Confiar completamente en el modo strict. El modo strict solo garantiza la forma del JSON. No previene datos inventados.
  • Dejar temperature alta. Los resultados de extracción fluctuarán entre ejecuciones.
  • Incrustar datos de usuario en el prompt sin separarlos claramente. El email podría contener frases como “ignora las instrucciones anteriores”. Separa el system prompt y siempre valida la salida.

No debes usar structured output cuando:

  • La salida es fundamentalmente texto libre, como un resumen o una respuesta de chat. Forzar texto en JSON solo reduce la calidad.
  • Los datos se pueden extraer con regex o un parser convencional. Es más barato, más rápido y más determinista.
  • Necesitas precisión absoluta, como montos de transacciones, sin un paso de verificación humana.

9. Conclusión

Este artículo ha recorrido las formas de hacer que un LLM devuelva JSON con schema correcto en Spring Boot:

  • ChatClient.entity() con record y enum permite mapear la respuesta a un tipo Java con solo una línea de código.
  • BeanOutputConverter te permite ver y controlar el schema que se genera.
  • ResponseFormat.Type.JSON_SCHEMA de OpenAI garantiza la estructura mientras el modelo genera tokens.
  • Bean Validation combinado con reintentos que incluyen mensajes de error ayuda a manejar lo que el schema no cubre.

El principio central: el schema mantiene la forma de los datos, la validación mantiene el significado de los datos. Las aplicaciones de producción necesitan ambas capas, junto con tests unitarios para el parsing y una suite de evaluación separada para la calidad del prompt.

llmspring-aijson-schemaspring-bootjava