Ir al contenido
wannacode

Salida estructurada: JSON según un esquema

En resumen: La salida estructurada es una respuesta del modelo como JSON según un esquema con tipos y valores permitidos, que el programa comprueba y, si hay un error, devuelve al modelo para que lo corrija.

Problema

El código que espera JSON

La bandeja de soporte recibe cientos de correos al día. Un programa los clasifica en colas: entregas, devoluciones, pagos, general. Le pide a un modelo el tema del correo y espera JSON con el campo intent. Aquí hay tres respuestas del modelo para el mismo correo:

List<String> answers = List.of(
"""
¡Claro! Aquí están los datos:
```json
{"intent": "delivery", "order_id": "1042", "urgent": true}
```""",
"""
{"intent": "envío", "order_id": "1042", "urgent": "sí", "summary": "El mensajero no vino."}""",
"""
{"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "El mensajero no vino, el cliente viaja mañana."}""");
for (String answer : answers) {
Map<String, Object> json = parseJson(answer);
if (json == null) {
System.out.println("no es JSON: " + answer.lines().findFirst().get());
continue;
}
String queue = QUEUES.get(json.get("intent"));
System.out.println(queue == null
? "no hay cola para \"" + json.get("intent") + "\""
: "cola: " + queue);
}
Salida
no es JSON: ¡Claro! Aquí están los datos:
no hay cola para "envío"
cola: Entregas

Solo funciona la tercera. La primera es cortés, pero es texto alrededor del JSON y el programa no puede leerlo. La segunda es JSON, pero el tema es una palabra que el programa no conoce y no hay ninguna cola así. En la lección sobre el prompt viste que los ejemplos ayudan. Pero el programa tiene que funcionar la noche en que el modelo decida ser cortés.

La idea

Un esquema, una comprobación, un segundo intento

Cuando la respuesta va a código y no a una persona, tiene un contrato. El contrato es un esquema: qué campos hay, el tipo de cada uno y qué valores se permiten.

  1. El prompt enuncia el esquema. Los campos, los tipos y los valores permitidos están en la instrucción.
  2. El programa lee el JSON. Si no es JSON, es un error, no una caída.
  3. El programa lo comprueba contra el esquema: ¿está cada campo?, ¿es correcto el tipo?, ¿está permitido el valor?, ¿no hay campos de más?
  4. Los errores vuelven al modelo como un mensaje nuevo, y lo intenta otra vez.
  5. Hay como máximo tres intentos. Después el correo pasa a una persona.

Una respuesta según un esquema que el programa comprueba es salida estructurada.

La salida del modelo es entrada no confiable como todo lo que viene de fuera: el programa no confía en que cumpla el contrato, lo comprueba.

Míralo

Leer y comprobar

El esquema del correo: cuatro campos, intent con cuatro valores permitidos:

static final List<Field> TICKET = List.of(
new Field("intent", "string", List.of("delivery", "return", "payment", "other")),
new Field("order_id", "string", List.of()),
new Field("urgent", "boolean", List.of()),
new Field("summary", "string", List.of()));

La lectura devuelve el objeto, o null si el texto no es exactamente un objeto JSON:

static Map<String, Object> parseJson(String text) {
return FlatJson.parse(text);
}
Java no tiene JSON en su biblioteca estándar. Un proyecto usa Jackson o Gson. Aquí parseJson llama al pequeño lector FlatJson de «Más detalle»: entiende un objeto plano con texto, números, true, false y null, lo que la lección necesita.

El tipo de un valor en las palabras del propio JSON:

static String jsonType(Object value) {
return switch (value) {
case null -> "null";
case String s -> "string";
case Boolean b -> "boolean";
default -> "number";
};
}

La comprobación no lanza una excepción. Devuelve una lista de errores, porque el programa tiene algo que hacer con ellos:

static List<String> validate(Map<String, Object> json, List<Field> schema) {
List<String> errors = new ArrayList<>();
for (Field f : schema) {
if (!json.containsKey(f.name())) {
errors.add("falta \"" + f.name() + "\"");
continue;
}
Object value = json.get(f.name());
String type = jsonType(value);
if (!type.equals(f.type())) {
errors.add("\"%s\" es %s, debe ser %s"
.formatted(f.name(), type, f.type()));
} else if (!f.allowed().isEmpty() && !f.allowed().contains(value)) {
errors.add("\"%s\": \"%s\" no es uno de %s".formatted(
f.name(), value, String.join(", ", f.allowed())));
}
}
for (String key : json.keySet()) {
if (schema.stream().noneMatch(f -> f.name().equals(key))) {
errors.add("campo de más \"" + key + "\"");
}
}
return errors;
}

Cuatro respuestas del modelo:

List<String> answers = List.of(
"""
{"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "El mensajero no vino, el cliente viaja mañana."}""",
"""
{"intent": "envío", "order_id": "1042", "urgent": "sí", "summary": "El mensajero no vino."}""",
"""
{"intent": "return", "urgent": false, "summary": "Quiere devolver una chaqueta.", "mood": "tranquilo"}""",
"""
¡Claro! Aquí están los datos:
```json
{"intent": "delivery", "order_id": "1042", "urgent": true}
```""");
for (String answer : answers) {
Map<String, Object> json = parseJson(answer);
List<String> errors = json == null ? List.of("no es JSON") : validate(json, TICKET);
System.out.println(errors.isEmpty() ? "ok" : String.join("\n", errors));
System.out.println("---");
}
Salida
ok
---
"intent": "envío" no es uno de delivery, return, payment, other
"urgent" es string, debe ser boolean
---
falta "order_id"
campo de más "mood"
---
no es JSON
---

Pruébalo

Predice la salida

¿Qué imprime la comprobación para esta respuesta?

List<String> answers = List.of(
"""
{"intent": "refund", "order_id": 1042, "urgent": false, "summary": "Quiere que le devuelvan el dinero."}""");
for (String answer : answers) {
Map<String, Object> json = parseJson(answer);
List<String> errors = json == null ? List.of("no es JSON") : validate(json, TICKET);
System.out.println(errors.isEmpty() ? "ok" : String.join("\n", errors));
System.out.println("---");
}
Respuesta

Pruébalo

Completa el hueco

Una línea de la comprobación: el tipo es correcto, ahora el valor tiene que ser uno de los permitidos. ¿Qué falta?

} else if (!f.allowed().isEmpty() && !f.allowed().(value)) {

La idea

Los errores de vuelta al modelo

¿Qué haces con una respuesta que no pasa? Si repites la pregunta, el modelo suele repetir el mismo error. Es mejor decirle qué está mal.

La respuesta del modelo entra en el historial con el rol assistant, seguida de un mensaje nuevo user con los errores: «La respuesta no cumple el esquema: intent… Devuelve solo JSON según el esquema». El modelo ve su intento y los errores exactos y los corrige. Es un segundo intento con retroalimentación.

Cada intento envía todo el historial. El segundo cuesta más que el primero, y el tercero más que el segundo: por la lección sobre el contexto sabes que la API no tiene memoria y todo empieza de nuevo. Por eso los intentos son pocos, y después el correo pasa a una persona.

Míralo

Tres correos, tres destinos

static Ticket triage(FakeClient client, String email) {
List<Message> messages = new ArrayList<>();
messages.add(new Message("user", email));
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
Reply reply = client.create(SYSTEM, messages);
Map<String, Object> json = parseJson(reply.text());
List<String> errors = json == null
? List.of("no es JSON")
: validate(json, TICKET);
String verdict = errors.isEmpty()
? "ok"
: String.join("; ", errors);
System.out.printf(" intento %d (~%d tokens): %s%n",
attempt, reply.inputTokens(), verdict);
if (errors.isEmpty()) {
return toTicket(json);
}
messages.add(new Message("assistant", reply.text()));
messages.add(new Message("user",
"La respuesta no cumple el esquema: " + verdict + ". Devuelve solo JSON según el esquema."));
}
return null;
}
Paso a paso, en palabras
  1. El correo va al modelo con una instrucción: devuelve solo JSON con cuatro campos.
  2. La respuesta es JSON válido y se lee. Eso todavía no significa que sea correcta.
  3. Cada campo se contrasta con el esquema: si existe, si el tipo es correcto, si el valor es uno de los permitidos.
  4. Dos errores: intent y urgent. Son una lista de textos, no una excepción: el programa decide qué hacer.
  5. Los errores vuelven al modelo como un mensaje nuevo. El intento 2 envía todo el historial: ~103 tokens en vez de ~51.
  6. La segunda respuesta pasa todas las comprobaciones. Solo ahora se convierte en un Ticket.
  7. El correo va a la cola de entregas como urgente. Como máximo 3 intentos; después se encarga una persona.

La instrucción y cuántos intentos tiene el programa:

static final String SYSTEM = "Clasifica el correo del cliente. Devuelve solo JSON con los campos "
+ "intent (delivery, return, payment u other), order_id (texto), "
+ "urgent (true o false) y summary (una frase).";
static final int MAX_ATTEMPTS = 3;

Un correo: preguntar, leer, comprobar, y si hay un error un nuevo intento con los errores. Cuando todo pasa, el mapa se convierte en record Ticket, y a partir de ahí el código trabaja con campos, no con texto:

static Ticket triage(FakeClient client, String email) {
List<Message> messages = new ArrayList<>();
messages.add(new Message("user", email));
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
Reply reply = client.create(SYSTEM, messages);
Map<String, Object> json = parseJson(reply.text());
List<String> errors = json == null
? List.of("no es JSON")
: validate(json, TICKET);
String verdict = errors.isEmpty()
? "ok"
: String.join("; ", errors);
System.out.printf(" intento %d (~%d tokens): %s%n",
attempt, reply.inputTokens(), verdict);
if (errors.isEmpty()) {
return toTicket(json);
}
messages.add(new Message("assistant", reply.text()));
messages.add(new Message("user",
"La respuesta no cumple el esquema: " + verdict + ". Devuelve solo JSON según el esquema."));
}
return null;
}
static Ticket toTicket(Map<String, Object> json) {
return new Ticket((String) json.get("intent"), (String) json.get("order_id"),
(Boolean) json.get("urgent"), (String) json.get("summary"));
}
FakeClient client = new FakeClient();
String[] inbox = {
"El mensajero no vino ayer, pedido 1042. Es urgente, ¡viajo mañana!",
"Quiero devolver el pedido 1187, la chaqueta me queda grande.",
"hola, algo no funciona",
};
for (String email : inbox) {
System.out.println("correo: " + email);
Ticket ticket = triage(client, email);
if (ticket == null) {
System.out.println(" → a una persona");
} else {
String urgent = ticket.urgent() ? ", urgente" : "";
System.out.println(" → " + ticket.intent() + ", " + ticket.orderId() + urgent);
}
}
Salida
correo: El mensajero no vino ayer, pedido 1042. Es urgente, ¡viajo mañana!
intento 1 (~51 tokens): "intent": "envío" no es uno de delivery, return, payment, other; "urgent" es string, debe ser boolean
intento 2 (~103 tokens): ok
→ delivery, 1042, urgente
correo: Quiero devolver el pedido 1187, la chaqueta me queda grande.
intento 1 (~49 tokens): ok
→ return, 1187
correo: hola, algo no funciona
intento 1 (~42 tokens): no es JSON
intento 2 (~70 tokens): no es JSON
intento 3 (~98 tokens): no es JSON
→ a una persona
  • El correo del pedido 1042: la primera respuesta tiene dos errores, la segunda pasa. El segundo intento cuesta ~103 tokens en vez de ~51.
  • El correo del pedido 1187: pasa a la primera.
  • «hola, algo no funciona»: el modelo no encuentra ningún tema y responde con una frase las tres veces. El programa no inventa una categoría; entrega el correo a una persona.

El modelo aquí es un cliente falso, como en la lección sobre el prompt: las respuestas están grabadas y son iguales cada vez. Su regla está en «Más detalle».

Pruébalo

Encuentra el error

Un compañero escribe una comprobación más corta. La respuesta {"intent": "return", "urgent": false, "summary": "…"} pasa como «ok», y un minuto después el programa se cae en algún lugar lejano, cuando busca el pedido. ¿Qué línea tiene el error?

Elige la línea con el error.

La idea

El esquema en la propia API

Las APIs más nuevas aceptan el esquema como parte de la solicitud. Entonces el modelo genera solo texto que lo cumple: cada token siguiente se elige entre los que mantienen el JSON según el esquema. El «¡Claro!» cortés y "urgent": "sí" se vuelven imposibles.

La comprobación en el código se queda, sin embargo:

  • Un rechazo o una respuesta cortada. Si el modelo rechaza (stop_reason es refusal) o llega a max_tokens, el texto puede no cumplir el esquema.
  • Las reglas de la tienda. El esquema dice «texto», pero no sabe si el pedido 1187 existe y si pertenece a este cliente. Eso es una comprobación del programa.
  • Otro modelo u otro proveedor. El código que comprueba no depende de quién generó el JSON.

Pruébalo en tu computadora. Este programa no forma parte de la comprobación de la lección: necesitas una cuenta con un proveedor de modelos, una clave y dinero por cada llamada. La clave está en LLM_API_KEY, nunca en el código. Los proveedores ofrecen SDK oficiales para Java, C#, Python y JavaScript con la misma forma (mensajes, roles, tokens, herramientas); los nombres exactos de clases y métodos están en la documentación del proveedor. La solicitud como JSON: el esquema está en el campo output_config, y el nombre del modelo es un marcador:

{
"model": "<nombre del modelo>",
"max_tokens": 1024,
"system": "Clasifica el correo del cliente. Devuelve solo JSON con los campos intent (delivery, return, payment u other), order_id (texto), urgent (true o false) y summary (una frase).",
"messages": [
{
"role": "user",
"content": "El mensajero no vino ayer, pedido 1042. Es urgente, ¡viajo mañana!"
}
],
"output_config": {
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"intent": {
"type": "string",
"enum": ["delivery", "return", "payment", "other"]
},
"order_id": {"type": "string"},
"urgent": {"type": "boolean"},
"summary": {"type": "string"}
},
"required": ["intent", "order_id", "urgent", "summary"],
"additionalProperties": false
}
}
}
}

Una respuesta de ejemplo. El JSON según el esquema es texto en el bloque de texto, y el programa igualmente lo lee y lo comprueba:

{
"content": [
{
"type": "text",
"text": "{\"intent\": \"delivery\", \"order_id\": \"1042\", \"urgent\": true, \"summary\": \"El mensajero no vino, el cliente viaja mañana.\"}"
}
],
"stop_reason": "end_turn",
"usage": {"input_tokens": 51, "output_tokens": 20}
}

Sin SDK, la solicitud sale con el cliente HTTP estándar del lenguaje. La dirección es un marcador:

// requestJson es el JSON de la solicitud de arriba.
// La cabecera exacta de la clave está en la documentación del proveedor.
HttpClient http = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://<proveedor>/messages"))
.header("Authorization", "Bearer " + System.getenv("LLM_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
HttpResponse<String> response =
http.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());

Cada objeto del esquema tiene additionalProperties: false: así un campo de más como mood no puede aparecer.

La idea

Cuándo duele

  • JSON para todo. La respuesta a un cliente es texto. Un esquema va donde la respuesta entra en código: una cola, una base de datos, otro servicio.
  • Un esquema profundo. Cinco niveles de objetos anidados y veinte campos dan más errores e intentos más caros. Dos solicitudes simples son mejores que una complicada.
  • Parches en vez de comprobación. «Corta desde la primera { hasta la última }» funciona hasta la primera respuesta con dos {. Si la API admite un esquema, úsalo. Si no, comprueba e intenta de nuevo.
  • La forma correcta no es la respuesta correcta. "order_id": "1043" pasa el esquema, pero el modelo pudo equivocarse en un dígito: los modelos inventan de forma verosímil. El número se comprueba contra la base de datos antes de usarlo.
  • Los intentos tienen un precio. Cada intento son más tokens y más segundos para el cliente. Cuánto exactamente y cómo mantener un presupuesto está en la lección sobre costo y velocidad.
  • El esquema y el prompt cambian juntos. Una categoría nueva en el esquema sin una categoría nueva en el prompt da un modelo que nunca la dice. La versión del esquema está en el código, junto al prompt.
Más precisamente, para curiosos

Las palabras de JSON Schema. El esquema en la API es JSON Schema: type (object, string, number, boolean, array, null), properties con los campos, required con los obligatorios, enum con los valores permitidos y additionalProperties: false contra los de más. Nuestra comprobación hace un subconjunto, suficiente para mostrar el mecanismo. Un proyecto usa una biblioteca ya hecha para JSON Schema.

Del texto a un tipo. Después de la comprobación el mapa se convierte en Ticket, y el resto del código ya no comprueba nada: el compilador o los campos dicen qué hay. Es la idea «lee y comprueba en la entrada, y trabaja con tipos adentro». La comprobación está en un solo lugar, no repartida por el programa.

Las herramientas usan lo mismo. En la siguiente lección el modelo le pide al programa que ejecute una función. Los argumentos de la función también llegan como JSON según un esquema (input_schema) y también se comprueban.

El cliente falso por dentro. Sus respuestas están grabadas según el número de pedido del correo: para 1042 la primera es incorrecta y después de la corrección es correcta; para 1187 es correcta de inmediato; para cualquier otra cosa es una frase. Los tokens son palabras × 1,3.

// Ocupa el lugar del modelo real: respuestas grabadas,
// sin red y siempre iguales.
class FakeClient {
static final String WRONG_1042 = """
{"intent": "envío", "order_id": "1042", "urgent": "sí", "summary": "El mensajero no vino."}""";
static final String GOOD_1042 = """
{"intent": "delivery", "order_id": "1042", "urgent": true, "summary": "El mensajero no vino, el cliente viaja mañana."}""";
static final String GOOD_1187 = """
{"intent": "return", "order_id": "1187", "urgent": false, "summary": "Quiere devolver la chaqueta, le queda grande."}""";
static final String PROSE = "No entiendo qué quiere el cliente.";
Reply create(String system, List<Message> messages) {
String email = messages.get(0).content();
boolean corrected = messages.size() > 1;
String text;
if (email.contains("1042")) {
text = corrected ? GOOD_1042 : WRONG_1042;
} else if (email.contains("1187")) {
text = GOOD_1187;
} else {
text = PROSE;
}
int input = tokens(system);
for (Message m : messages) {
input += tokens(m.content());
}
return new Reply(text, "end_turn", input, tokens(text));
}
// A grandes rasgos: palabras × 1,3, redondeado hacia arriba.
static int tokens(String text) {
String t = text.strip();
int words = t.isEmpty() ? 0 : t.split("\\s+").length;
return (words * 13 + 9) / 10;
}
}

El lector FlatJson. Descenso recursivo en una sola pasada: object lee {, pares clave–valor y }, y value reconoce texto, true, false, null y un número. No lee objetos anidados ni arreglos, ni \n dentro del texto. Cada lugar inesperado lanza una excepción, y parse la convierte en null.

// Java no tiene JSON en la biblioteca estándar. Este lector entiende
// un objeto plano: texto, números, true, false y null. En un proyecto: Jackson.
class FlatJson {
private final String s;
private int i;
private FlatJson(String s) {
this.s = s;
}
// El objeto, o null si el texto no es exactamente un objeto JSON.
static Map<String, Object> parse(String text) {
FlatJson p = new FlatJson(text.strip());
try {
Map<String, Object> obj = p.object();
return p.i == p.s.length() ? obj : null;
} catch (RuntimeException e) {
return null;
}
}
private Map<String, Object> object() {
Map<String, Object> obj = new LinkedHashMap<>();
expect('{');
skip();
if (peek() == '}') {
i++;
return obj;
}
while (true) {
skip();
String key = string();
skip();
expect(':');
skip();
obj.put(key, value());
skip();
if (peek() == '}') {
i++;
return obj;
}
expect(',');
}
}
private Object value() {
if (peek() == '"') {
return string();
}
for (String word : new String[] {"true", "false", "null"}) {
if (s.startsWith(word, i)) {
i += word.length();
return word.equals("null") ? null : Boolean.valueOf(word);
}
}
int start = i;
while (i < s.length() && "+-.0123456789eE".indexOf(s.charAt(i)) >= 0) {
i++;
}
return Double.parseDouble(s.substring(start, i));
}
private String string() {
expect('"');
StringBuilder out = new StringBuilder();
while (peek() != '"') {
char c = s.charAt(i++);
out.append(c == '\\' ? s.charAt(i++) : c);
}
i++;
return out.toString();
}
private void skip() {
while (i < s.length() && Character.isWhitespace(s.charAt(i))) {
i++;
}
}
private char peek() {
return s.charAt(i);
}
private void expect(char c) {
if (peek() != c) {
throw new IllegalStateException("esperaba " + c);
}
i++;
}
}

Ejercicio

El número tiene cuatro dígitos

Resuélvelo en tu editor.

Añade a la comprobación una regla: order_id tiene exactamente 4 dígitos. Si no, el error es "order_id": "10420" no tiene 4 dígitos. Ejecuta la comprobación para las tres respuestas que se diferencian solo en el número: 1042, 10420 y #1042.

Salida
ok
---
"order_id": "10420" no tiene 4 dígitos
---
"order_id": "#1042" no tiene 4 dígitos
---

Más tareas

  • Un resumen vacío. summary no puede ser un texto vacío ni solo espacios. Añade la regla y una prueba con "summary": " ".
  • Un número limitado de tokens. Detén los intentos también cuando los tokens totales del correo pasen de 100, incluso antes de tres intentos. ¿Cuántos intentos hace entonces el correo «hola, algo no funciona»?

Test

Ponte a prueba

1. El modelo devolvió JSON válido. ¿Significa que el programa puede usarlo?
2. ¿Por qué en el segundo intento se envían los errores al modelo, en vez de repetir la pregunta?
3. El correo no tiene sentido y el modelo devuelve una frase en vez de JSON tres veces. ¿Qué hace el programa?
4. La API ya garantiza JSON según el esquema. ¿Se queda la comprobación en el código?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·