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 vecesqty. - Tipos de datos incorrectos, como
"quantity": "dos"en lugar de2. - Valores de enumeraciones inventados, como
"speed": "muy rápido".
Para hacerlo estable, necesitas tres capas de protección:
- Un schema explícito incluido en el prompt, o mejor aún, enviado a través de la API como una restricción.
- Parsing estricto a un tipo de dato Java.
- 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:
@JsonPropertyDescriptionse 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
DeliverySpeedgenera"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:
- Genera el JSON Schema a partir de
OrderRequest. - Agrega las instrucciones de formato junto con el schema al final del prompt.
- 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 teneradditionalProperties: false. Algunas palabras clave comopatternominimumpueden no ser soportadas, dependiendo del modelo. Si un campo es opcional, declara su tipo permitiendonullen lugar de sacarlo derequired.
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 0se añaden al siguiente prompt. El modelo corrige los errores mucho mejor cuando sabe dónde está el problema. - El bloque
catchmaneja 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 comocodeotypeson 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
temperaturealta. 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.BeanOutputConverterte permite ver y controlar el schema que se genera.ResponseFormat.Type.JSON_SCHEMAde 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.