Structured Output con Spring AI: de texto a objetos Java fiables

Una aplicación clasifica tickets de soporte con un LLM. En la demo, el modelo devuelve un JSON impecable. Una semana después llega una respuesta que empieza por «La prioridad sería alta porque…», luego un JSON con la prioridad URGENT, que no existe en el enum, y después uno al que le falta un campo.
Ahí empieza el código defensivo: expresiones regulares, try/catch alrededor de Jackson y prompts cada vez más largos. El problema no es conseguir JSON: el modelo produce texto y la aplicación necesita un contrato.
En una demo puede bastar con recibir texto. En producción, en cuanto otra pieza de software usa la respuesta, deja de serlo. Structured Output convierte la respuesta probabilística del modelo en un contrato que el código Java puede tratar de forma fiable.
Este artículo explica cómo funciona en Spring AI 2.0 (la versión estable es la 2.0.1), qué garantiza cada mecanismo y qué no garantiza ninguno. La idea que lo recorre entero: estructura válida no es lo mismo que dato correcto.
Structured Output en Spring AI, en 30 segundos
- entity() convierte la respuesta del modelo en un tipo Java, normalmente un record.
- Por defecto es best effort: el modelo recibe el schema como instrucciones y puede incumplirlo.
- validateSchema() comprueba la salida contra el JSON Schema y, si falla, repite la llamada indicando al modelo el error.
- useProviderStructuredOutput() usa el mecanismo nativo del proveedor cuando el modelo lo soporta.
- Forma, no verdad: Structured Output refuerza la forma de la respuesta, pero no garantiza que los datos sean verdaderos.
Qué es Structured Output en Spring AI
Structured Output en Spring AI permite convertir la respuesta textual de un modelo de lenguaje en un tipo Java definido por la aplicación. Spring AI genera un JSON Schema a partir del tipo esperado, guía o restringe al modelo para que produzca una respuesta compatible y después la convierte en el objeto Java correspondiente.
Con entity() por defecto, esa conformidad es best effort; validateSchema() y useProviderStructuredOutput() la refuerzan de maneras distintas, como recoge la documentación oficial de Structured Output. La llamada básica en cada framework aparece en la comparativa entre Spring AI y LangChain4j; aquí nos centramos en lo que la rodea en producción.
- LLM
genera texto
- JSON
forma de objeto, sin garantías
- JSON Schema
campos, tipos y valores permitidos
- record Java
el tipo de la aplicación
- Aplicación
valida, aplica reglas y actúa
El caso básico: entity() convierte la respuesta de ChatClient en un record
El método entity() de ChatClient es la forma directa de obtener un objeto Java en lugar de un String: se le pasa la clase del tipo esperado y devuelve una instancia de ese tipo construida a partir de la respuesta del modelo.
public enum Priority { LOW, MEDIUM, HIGH }
public record SupportTicketClassification(
String category,
Priority priority,
String summary,
boolean requiresHumanReview
) {}@Service
public class TicketClassifier {
private final ChatClient chatClient;
public TicketClassifier(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("Clasificas tickets de soporte de clientes B2B.")
.build();
}
public SupportTicketClassification classify(String ticketText) {
return chatClient.prompt()
.user(ticketText)
.call()
.entity(SupportTicketClassification.class);
}
}Qué hace entity() por dentro
Con una clase como argumento, entity() crea un BeanOutputConverter y sigue estos pasos:
- Schema: genera un JSON Schema (Draft 2020-12) a partir del record. En 2.0 los campos son obligatorios salvo que se marquen como opcionales, y los enums se traducen en listas de valores permitidos.
- Instrucciones: añade el schema al prompt como instrucciones de formato.
- Generación: el modelo responde, todavía como texto.
- Conversión: deserializa la respuesta con Jackson 3. entity() está anotado como @Nullable, así que el resultado puede ser null.
content(), entity() y responseEntity(): cuándo usar cada uno
content() devuelve texto; entity(), un objeto Java tipado; responseEntity(), ese objeto junto con la respuesta completa y su metadata. content() no es la opción pobre: si la respuesta la lee una persona, Structured Output no aporta nada.
| Necesidad | Método de ChatClient | Qué obtienes |
|---|---|---|
| Mostrar texto a una persona | content() | String |
| Consumir la respuesta desde código Java | entity(Clase.class) | Instancia del tipo (puede ser null) |
| Objeto tipado más tokens y metadata | responseEntity(Clase.class) | ResponseEntity con la entidad y el ChatResponse |
| Listas y tipos genéricos | entity(new ParameterizedTypeReference<…>() {}) | List<T> u otro tipo genérico |
Por qué los records de Java encajan como contrato de salida
Los records son la forma natural de expresar un contrato de salida: inmutables, sin boilerplate y claramente un resultado, no una entidad con comportamiento.
Las anotaciones de Jackson @JsonPropertyDescription, @JsonProperty(required = …) y @JsonPropertyOrder se reflejan en el schema, así que la descripción de cada campo llega al modelo como parte del contrato.
public enum RiskLevel { LOW, MEDIUM, HIGH }
public record InvoiceAnalysis(
@JsonPropertyDescription("Número de factura tal como aparece en el documento")
String invoiceNumber,
@JsonPropertyDescription("Importe total con impuestos incluidos")
BigDecimal total,
@JsonPropertyDescription("Código de moneda ISO 4217, por ejemplo EUR")
String currency,
RiskLevel risk,
List<String> anomalies,
boolean requiresReview
) {}Listas y tipos genéricos con ParameterizedTypeReference
Para listas y tipos genéricos, entity() acepta un ParameterizedTypeReference, porque el borrado de tipos impide pasar List<T>.class.
Con la salida nativa de OpenAI el schema no puede tener un array como raíz; la documentación recomienda envolver la lista en un record.
List<SupportTicketClassification> results = chatClient.prompt()
.user(ticketBatch)
.call()
.entity(new ParameterizedTypeReference<List<SupportTicketClassification>>() {});public record TicketBatchClassification(
List<SupportTicketClassification> tickets
) {}Por qué entity() por defecto sigue siendo best effort
Por defecto, entity() pide al modelo que respete el schema, pero no le obliga: según la documentación, el modelo es instruido, no forzado. El schema viaja como instrucciones en el prompt.
Una respuesta puede añadir texto antes del JSON, omitir campos o inventar un valor de enum. Con modelos pequeños o schemas extensos ocurre lo suficiente para romper un pipeline. Spring AI 2.0 ofrece dos mecanismos para endurecer el contrato.
validateSchema(): validar la respuesta y pedir una corrección
validateSchema() valida la respuesta contra el JSON Schema y, si no lo cumple, vuelve a llamar al modelo con el error de validación para que corrija su salida: un bucle de autocorrección con reintentos limitados.
Lo implementa StructuredOutputValidationAdvisor, que se registra solo al activar el interruptor. Según su API, maxRepeatAttempts es el número máximo de reintentos después de un fallo de validación y vale 3: una llamada inicial y hasta tres llamadas adicionales si la salida sigue sin cumplir el schema. Con 0 no hay reintentos. Para cambiar el valor se registra el advisor de forma explícita, como describe Schema Validation & Self-Correction.
SupportTicketClassification result = chatClient.prompt()
.user(ticketText)
.call()
.entity(SupportTicketClassification.class, spec -> spec.validateSchema());ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(StructuredOutputValidationAdvisor.builder()
.outputType(SupportTicketClassification.class)
.maxRepeatAttempts(2)
.build())
.build();Qué pasa cuando se agotan los reintentos
Si la salida sigue sin poder convertirse al tipo esperado después de los intentos de corrección, la aplicación debe tratar explícitamente el fallo y no asumir que dispondrá de una entidad válida. En la práctica: capturar el error de conversión, comprobar que el resultado no es null y decidir qué ocurre entonces, ya sea revisión humana, reintento diferido o un error controlado. Además, validateSchema() no admite streaming, porque necesita la respuesta completa para validarla.
useProviderStructuredOutput(): el schema como restricción nativa del proveedor
useProviderStructuredOutput() envía el JSON Schema al proveedor como restricción de su API, en lugar de como instrucciones en el prompt: el schema deja de ser una petición y actúa durante la generación. La referencia es Provider-Native Structured Output.
La documentación de Spring AI 2.0 enumera como soportados OpenAI, Anthropic, Google GenAI, Mistral AI y Ollama, este último según el modelo. Que el proveedor lo soporte no significa que cualquiera de sus modelos acepte todas las construcciones de JSON Schema.
SupportTicketClassification result = chatClient.prompt()
.user(ticketText)
.call()
.entity(SupportTicketClassification.class, spec -> spec.useProviderStructuredOutput());- Fallo silencioso: si las opciones del modelo no implementan StructuredOutputChatOptions, Spring AI ignora la opción sin avisar y vuelve al modo basado en prompt.
- Soporte parcial: $ref, arrays muy anidados, allOf/anyOf/oneOf, regex y tipos recursivos son limitaciones habituales incluso en proveedores compatibles.
- Particularidades: OpenAI no admite un array como raíz del schema; en Ollama, los modelos con modo de razonamiento pueden devolver texto plano.
- Activación global: AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT lo activa de forma general. Por compatibilidad, está desactivado por defecto.
Combinar salida nativa y validación: cuándo compensa
La combinación más robusta es activar ambos: la salida nativa restringe la respuesta antes de que exista y validateSchema() la comprueba después, incluso si el modo nativo se desactiva sin avisar.
SupportTicketClassification result = chatClient.prompt()
.user(ticketText)
.call()
.entity(SupportTicketClassification.class, spec -> spec
.useProviderStructuredOutput()
.validateSchema());| Compensa combinar ambos | Probablemente sobra |
|---|---|
| Pipelines automatizados sin persona en medio | Prototipos y pruebas de concepto |
| Resultados que se persisten en base de datos | Respuestas conversacionales |
| Decisiones posteriores: routing, prioridades, alertas | Contenido que solo lee una persona |
| Datos enviados a otra API o a un workflow | Salidas sin procesamiento posterior |
responseEntity(): el objeto tipado y además tokens y metadata
responseEntity() devuelve la entidad Java y el ChatResponse completo, con los tokens consumidos y la metadata para trazas y métricas. Admite las mismas sobrecargas que entity(), según la API de ChatClient.
Con validateSchema() refleja lo que ha costado de verdad obtener un objeto válido, reintentos incluidos.
ResponseEntity<ChatResponse, SupportTicketClassification> response = chatClient.prompt()
.user(ticketText)
.call()
.responseEntity(SupportTicketClassification.class, spec -> spec.validateSchema());
SupportTicketClassification classification = response.entity();
long totalTokens = response.response().getMetadata().getUsage().getTotalTokens();StructuredOutputConverter y BeanOutputConverter: cuándo bajar de nivel
StructuredOutputConverter<T> es la abstracción bajo entity(): un FormatProvider con las instrucciones de formato y un Converter<String, T>. BeanOutputConverter<T> genera el schema desde una clase y deserializa con Jackson; también existen MapOutputConverter y ListOutputConverter, descritos en Output Converters.
Compensa bajar a este nivel con formatos que no son JSON, respuestas que el converter estándar rechaza (por ejemplo, JSON envuelto en un bloque de Markdown), uso directo de ChatModel o streaming. Un converter propio debe implementar getJsonSchema() para participar en validateSchema() y useProviderStructuredOutput().
entity() solo funciona con call(). En streaming, la alternativa documentada es incluir el formato del converter en el prompt, acumular el stream y convertir al final, sin validación ni reintentos.
BeanOutputConverter<SupportTicketClassification> converter =
new BeanOutputConverter<>(SupportTicketClassification.class);
String json = chatClient.prompt()
.user(ticketText + "\n\n" + converter.getFormat())
.stream()
.content()
.collectList()
.block()
.stream()
.collect(Collectors.joining());
SupportTicketClassification result = converter.convert(json);Structured Output no es Bean Validation
La validación de JSON Schema comprueba la estructura: campos, tipos y valores permitidos. Jakarta Bean Validation comprueba reglas sobre los valores: rangos y formatos del negocio. Structured Output no invoca un Validator sobre el resultado de entity().
Un campo entero acepta un descuento de 95 aunque la política no permita pasar del 30 %. @Min y @Max no se aplican solas: hay que invocar el Validator, como con cualquier DTO externo.
public record DiscountProposal(
@NotBlank String customerId,
@Min(0) @Max(30) int discountPercent,
@NotBlank String justification
) {}public DiscountProposal propose(String context) {
DiscountProposal proposal = chatClient.prompt()
.user(context)
.call()
.entity(DiscountProposal.class, spec -> spec.validateSchema());
if (proposal == null) {
throw new IllegalStateException("El modelo no devolvió ninguna propuesta");
}
Set<ConstraintViolation<DiscountProposal>> violations = validator.validate(proposal);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
return proposal;
}Estructura válida no significa respuesta correcta
Structured Output puede, como mucho, asegurar que la respuesta tiene la forma del contrato, no que los valores sean ciertos. Esta factura cumple el schema y puede tener el importe equivocado:
{
"invoiceNumber": "F-2026-9182",
"total": 3840.00,
"currency": "EUR",
"risk": "LOW",
"anomalies": [],
"requiresReview": false
}- Parsing
¿puedo leerlo? La respuesta es JSON parseable
- Schema
¿cumple el contrato? Campos, tipos y valores permitidos
- Semántica
¿el dato es correcto? Respecto al documento y al negocio
Parsing y schema son terreno de Structured Output; la semántica exige reglas deterministas (que las líneas sumen el total), comprobaciones contra sistemas reales (que la factura exista en el ERP), revisión humana cuando proceda y evals que midan si la aplicación resuelve bien la tarea.
Structured Output, JSON mode y Tool Calling: diferencias
JSON mode garantiza JSON sintácticamente válido; Structured Output, una estructura definida por un schema; Tool Calling permite al modelo pedir que se ejecute una función.
En Tool Calling el schema describe los argumentos de una herramienta, y la documentación de Spring AI aclara que StructuredOutputConverter no se usa ahí. Structured Output se aplica a la respuesta final. En 2.0 ambos usan el mismo generador de schemas, con papeles distintos.
| Concepto | Qué resuelve | Qué no resuelve |
|---|---|---|
| JSON mode del proveedor | Que la respuesta sea JSON válido | Que tenga los campos y tipos que espera tu código |
| Structured Output | Que la respuesta final siga un JSON Schema y se convierta en un tipo Java | Que los valores sean correctos |
| Tool Calling | Que el modelo pida ejecutar una función con argumentos estructurados | Convertir la respuesta final en un contrato de datos |
| Validación en Java | Que los valores cumplan las reglas del negocio | Que el dato sea cierto respecto a la fuente |
Caso completo: clasificación y enrutado de tickets de soporte
Un ticket llega con este texto: «Desde ayer no podemos cerrar pedidos porque SAP devuelve un error al contabilizar». La aplicación debe decidir departamento, prioridad y si hace falta revisión humana.
El LLM interpreta, Structured Output normaliza y las decisiones las toma código Java testeable. Ticket y TicketRoute son tipos propios de la aplicación.
public enum Department { FINANCE, OPERATIONS, IT, SALES }
public record TicketAnalysis(
Department department,
Priority priority,
@JsonPropertyDescription("Resumen de una frase, sin datos personales")
String summary,
List<String> detectedSystems,
boolean humanReview
) {}public TicketRoute analyzeAndRoute(Ticket ticket) {
TicketAnalysis analysis = chatClient.prompt()
.system("""
Analizas tickets de soporte interno.
Marca humanReview si el ticket menciona importes, datos de clientes
o sistemas de facturación.
""")
.user(ticket.body())
.call()
.entity(TicketAnalysis.class, spec -> spec
.useProviderStructuredOutput()
.validateSchema());
return routingPolicy.decide(ticket, analysis);
}@Component
class RoutingPolicy {
TicketRoute decide(Ticket ticket, TicketAnalysis analysis) {
if (analysis == null || analysis.department() == null || analysis.priority() == null) {
return TicketRoute.manualTriage(ticket, "Clasificación incompleta");
}
boolean touchesErp = analysis.detectedSystems() != null
&& analysis.detectedSystems().stream().anyMatch("SAP"::equalsIgnoreCase);
Priority priority = touchesErp && ticket.blocksOperations()
? Priority.HIGH
: analysis.priority();
return analysis.humanReview() || priority == Priority.HIGH
? TicketRoute.withReview(analysis.department(), priority, analysis.summary())
: TicketRoute.automatic(analysis.department(), priority, analysis.summary());
}
}- Ticket
texto libre del usuario
- Spring AI
prompt con contexto y ChatClient
- Structured Output
salida nativa y validación del schema
- TicketAnalysis
record tipado
- Reglas Java
política de enrutado testeable
- Destino
ServiceNow, Jira o CRM
Arquitectura recomendada: una frontera entre lo probabilístico y lo determinista
Structured Output no convierte al LLM en determinista. Define una frontera verificable para que el resto de la aplicación sí pueda seguir comportándose como software determinista. A un lado, el prompt, el modelo y su variabilidad; al otro, un record, validaciones y reglas de dominio cubiertas por tests. El JSON Schema es el contrato entre ambos.
Esa frontera debe ser estrecha, con un DTO específico para el LLM, y explícita, con validación y reglas antes de persistir o actuar.
Si una empresa ya trabaja con Java y Spring Boot, incorporar IA no debería exigir un stack paralelo. Es el enfoque con el que trabajamos en desarrollo de soluciones con Spring AI.

Errores habituales al usar Structured Output en producción
La mayoría de los problemas vienen del diseño del contrato, no de la API:
- Schema demasiado grande: exponer el dominio completo multiplica los errores posibles. Mejor un DTO pequeño.
- Campos ambiguos: un String status admite cualquier cosa; un enum hace detectable el error.
- Confiar solo en el prompt: «Devuelve exactamente este JSON» no es un contrato; el schema sí.
- Persistir lo que devuelve el modelo: antes de guardar o actuar, comprobar nulls, Bean Validation y reglas de dominio.
- Ignorar el coste de los reintentos: medirlo con responseEntity() y fijar un límite.
- Schemas complejos: tipos recursivos y uniones funcionan peor, sobre todo con salida nativa.
- Confundir schema correcto con dato correcto: el error de fondo, y el más caro.
Cuándo usar Structured Output y cuándo no
Structured Output tiene sentido cuando la respuesta alimenta a otra pieza de software.
| Situación | ¿Structured Output? | Motivo |
|---|---|---|
| El resultado alimenta otra función o servicio | Sí | El consumidor necesita tipos, no frases |
| Se persiste en base de datos | Sí | Hay que validar antes de guardar |
| Se usa para routing o para activar reglas | Sí | Una decisión no puede depender de interpretar texto |
| Respuesta conversacional en un chat | No suele hacer falta | content() es suficiente |
| Contenido para lectura humana | No suele hacer falta | El formato libre es el objetivo |
Structured Output dentro de una aplicación con RAG y agentes
Structured Output no sustituye a RAG ni a Tool Calling: normaliza lo que el modelo concluye para que el código pueda actuar. La arquitectura completa la desarrollamos en Spring AI, RAG y agentes.
Si tu equipo necesita reforzar la recuperación de información, tenemos formación en bases de datos vectoriales y búsqueda semántica.
- RAG
recupera la información relevante
- LLM
interpreta la información en contexto
- Structured Output
normaliza el resultado en un tipo
- Java
decide con reglas deterministas
- Tool
actúa sobre otros sistemas
Qué abstrae Spring AI frente a hacerlo manualmente
Spring AI resuelve la parte repetitiva; diseñar el contrato y decidir qué hacer con el resultado sigue siendo tarea tuya.
| Tarea | Haciéndolo a mano | Con Spring AI 2.0 |
|---|---|---|
| Describir el formato esperado | Instrucciones escritas en el prompt | JSON Schema generado a partir del record |
| Obtener el objeto | Extraer el JSON y deserializar con Jackson | entity() |
| Detectar respuestas mal formadas | Parser propio y try/catch | validateSchema() |
| Restringir la generación | Código específico para cada proveedor | useProviderStructuredOutput() |
| Reglas de negocio y corrección del dato | Responsabilidad de tu código | Sigue siendo responsabilidad de tu código |
Conclusión
Una aplicación Java no debería tratar la salida de un LLM como texto cuando el resto del sistema espera datos. entity() da el caso básico, validateSchema() y useProviderStructuredOutput() lo endurecen y responseEntity() lo hace medible.
Pero tipar una respuesta no convierte al modelo en determinista. Structured Output reduce la incertidumbre sobre la forma; la del contenido sigue necesitando validación, reglas de dominio, tests y evals.
El objetivo no es conseguir que el modelo devuelva JSON. El objetivo es que el resto de la aplicación pueda confiar en el contrato sin fingir que el modelo ha dejado de ser probabilístico.
Fuentes técnicas
Preguntas frecuentes
¿Qué es Structured Output en Spring AI?
Es el mecanismo de Spring AI para convertir la respuesta textual de un LLM en un tipo Java, normalmente un record. Genera un JSON Schema a partir del tipo, lo comunica al modelo (como instrucciones en el prompt o como restricción nativa del proveedor) y convierte la respuesta en un objeto. Por defecto, la conformidad con el schema es best effort.
¿Qué diferencia hay entre content() y entity() en Spring AI?
content() devuelve un String, adecuado cuando el texto lo lee una persona. entity() devuelve un objeto Java tipado, adecuado cuando otra pieza de software consume el resultado.
¿Qué hace validateSchema() en Spring AI?
Valida la respuesta contra el JSON Schema y, si no lo cumple, repite la llamada indicando el error para que el modelo corrija. maxRepeatAttempts vale 3 por defecto: una llamada inicial y hasta tres reintentos. Cada reintento suma tokens, latencia y coste, y la aplicación debe tratar el caso en que tampoco así se obtenga una entidad válida.
¿Qué es useProviderStructuredOutput()?
Envía el JSON Schema al proveedor como restricción de su API en lugar de como instrucciones en el prompt. La documentación de Spring AI 2.0 la da como soportada en OpenAI, Anthropic, Google GenAI, Mistral AI y Ollama (según el modelo). Si las opciones del modelo no la soportan, Spring AI la ignora sin avisar.
¿Structured Output y Tool Calling son lo mismo?
No. Tool Calling usa un JSON Schema para describir los argumentos de una herramienta que el modelo pide ejecutar. Structured Output convierte la respuesta final del modelo en un contrato de datos.
¿Funciona Structured Output con streaming en Spring AI?
No con entity(): solo funciona con call(), y validateSchema() tampoco admite streaming. La alternativa es añadir al prompt el formato de un BeanOutputConverter, acumular el stream y convertir el texto completo al final.
¿Structured Output garantiza que la respuesta sea correcta?
No. Garantizar la estructura y garantizar la verdad son problemas distintos: una respuesta puede cumplir el JSON Schema y contener un importe o una clasificación errónea. La corrección del contenido necesita reglas de dominio, comprobaciones contra sistemas reales, tests y evals.
Sigue leyendo
Ver todos los artículos- Leer artículo
Desarrollo con IA · JavaDesarrollo con IA18 minSpring AI vs LangChain4j: qué framework elegir para IA en Java
- Leer artículo
Spring AI · RAG · AgentesDesarrollo con IA13 minSpring AI, RAG y agentes: cómo construir aplicaciones Java con inteligencia artificial real
- Leer artículo
Java · Spring AIDesarrollo con IA11 minJava + Spring AI: cuándo y por qué elegirlo para IA en empresa
