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:
string[] answers =[ """ ¡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."}""",];foreach (var answer in answers){ var json = ParseJson(answer); if (json == null) { Console.WriteLine($"no es JSON: {answer.Split('\n')[0]}"); continue; } var intent = json["intent"].GetString(); Console.WriteLine(Queues.TryGetValue(intent, out var queue) ? $"cola: {queue}" : $"no hay cola para \"{intent}\"");}no es JSON: ¡Claro! Aquí están los datos:no hay cola para "envío"cola: EntregasSolo 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.
- El prompt enuncia el esquema. Los campos, los tipos y los valores permitidos están en la instrucción.
- El programa lee el JSON. Si no es JSON, es un error, no una caída.
- El programa lo comprueba contra el esquema: ¿está cada campo?, ¿es correcto el tipo?, ¿está permitido el valor?, ¿no hay campos de más?
- Los errores vuelven al modelo como un mensaje nuevo, y lo intenta otra vez.
- 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 readonly Field[] TicketSchema =[ new("intent", "string", ["delivery", "return", "payment", "other"]), new("order_id", "string", []), new("urgent", "boolean", []), new("summary", "string", []),];La lectura devuelve el objeto, o null si el texto no es exactamente un objeto JSON:
static Dictionary<string, JsonElement> ParseJson(string text){ try { using var doc = JsonDocument.Parse(text); if (doc.RootElement.ValueKind != JsonValueKind.Object) { return null; } return doc.RootElement.EnumerateObject() .ToDictionary(p => p.Name, p => p.Value.Clone()); } catch (JsonException) { return null; }}System.Text.Json forma parte de .NET. JsonDocument lee el texto, y Clone() mantiene vivo cada valor después de liberar el documento.El tipo de un valor en las palabras del propio JSON:
static string JsonType(JsonElement value) => value.ValueKind switch{ JsonValueKind.String => "string", JsonValueKind.Number => "number", JsonValueKind.True or JsonValueKind.False => "boolean", JsonValueKind.Null => "null", JsonValueKind.Array => "array", _ => "object",};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( Dictionary<string, JsonElement> json, Field[] schema){ var errors = new List<string>(); foreach (var f in schema) { if (!json.TryGetValue(f.Name, out var value)) { errors.Add($"falta \"{f.Name}\""); continue; } var type = JsonType(value); if (type != f.Type) { errors.Add($"\"{f.Name}\" es {type}, debe ser {f.Type}"); } else if (f.Allowed.Length > 0 && !f.Allowed.Contains(value.GetString())) { var allowed = string.Join(", ", f.Allowed); errors.Add($"\"{f.Name}\": \"{value.GetString()}\" no es uno de {allowed}"); } } foreach (var key in json.Keys) { if (!schema.Any(f => f.Name == key)) { errors.Add($"campo de más \"{key}\""); } } return errors;}Cuatro respuestas del modelo:
string[] answers =[ """{"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} ``` """,];foreach (var answer in answers){ var json = ParseJson(answer); var errors = json == null ? ["no es JSON"] : Validate(json, TicketSchema); Console.WriteLine(errors.Count == 0 ? "ok" : string.Join("\n", errors)); Console.WriteLine("---");}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?
string[] answers =[ """{"intent": "refund", "order_id": 1042, "urgent": false, "summary": "Quiere que le devuelvan el dinero."}""",];foreach (var answer in answers){ var json = ParseJson(answer); var errors = json == null ? ["no es JSON"] : Validate(json, TicketSchema); Console.WriteLine(errors.Count == 0 ? "ok" : string.Join("\n", errors)); Console.WriteLine("---");}Dos errores. «refund» no es uno de los cuatro valores permitidos, y 1042 sin comillas es un número, no texto. Un número de pedido parece un número, pero es un identificador: «0042» como número se convertiría en 42. Por eso el esquema pide texto y la comprobación insiste.
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.Length > 0 && !f.Allowed.(value.GetString()))Contains. Sin esta comprobación «envío» pasa, porque es texto, y llega a una cola que no existe.
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("user", email)]; for (var attempt = 1; attempt <= MaxAttempts; attempt++) { var reply = client.Create(SystemPrompt, messages); var json = ParseJson(reply.Text); var errors = json == null ? ["no es JSON"] : Validate(json, TicketSchema); var verdict = errors.Count == 0 ? "ok" : string.Join("; ", errors); Console.WriteLine( $" intento {attempt} (~{reply.InputTokens} tokens): {verdict}"); if (errors.Count == 0) { return ToTicket(json); } messages.Add(new("assistant", reply.Text)); messages.Add(new("user", "La respuesta no cumple el esquema: " + verdict + ". Devuelve solo JSON según el esquema.")); } return null;}static List<string> Validate( Dictionary<string, JsonElement> json, Field[] schema){ var errors = new List<string>(); foreach (var f in schema) { if (!json.TryGetValue(f.Name, out var value)) { errors.Add($"falta \"{f.Name}\""); continue; } var type = JsonType(value); if (type != f.Type) { errors.Add($"\"{f.Name}\" es {type}, debe ser {f.Type}"); } else if (f.Allowed.Length > 0 && !f.Allowed.Contains(value.GetString())) { var allowed = string.Join(", ", f.Allowed); errors.Add($"\"{f.Name}\": \"{value.GetString()}\" no es uno de {allowed}"); } } foreach (var key in json.Keys) { if (!schema.Any(f => f.Name == key)) { errors.Add($"campo de más \"{key}\""); } } return errors;}Paso a paso, en palabras
- El correo va al modelo con una instrucción: devuelve solo JSON con cuatro campos.
- La respuesta es JSON válido y se lee. Eso todavía no significa que sea correcta.
- Cada campo se contrasta con el esquema: si existe, si el tipo es correcto, si el valor es uno de los permitidos.
- Dos errores: intent y urgent. Son una lista de textos, no una excepción: el programa decide qué hacer.
- Los errores vuelven al modelo como un mensaje nuevo. El intento 2 envía todo el historial: ~103 tokens en vez de ~51.
- La segunda respuesta pasa todas las comprobaciones. Solo ahora se convierte en un Ticket.
- 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:
const string SystemPrompt = "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).";const int MaxAttempts = 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("user", email)]; for (var attempt = 1; attempt <= MaxAttempts; attempt++) { var reply = client.Create(SystemPrompt, messages); var json = ParseJson(reply.Text); var errors = json == null ? ["no es JSON"] : Validate(json, TicketSchema); var verdict = errors.Count == 0 ? "ok" : string.Join("; ", errors); Console.WriteLine( $" intento {attempt} (~{reply.InputTokens} tokens): {verdict}"); if (errors.Count == 0) { return ToTicket(json); } messages.Add(new("assistant", reply.Text)); messages.Add(new("user", "La respuesta no cumple el esquema: " + verdict + ". Devuelve solo JSON según el esquema.")); } return null;}static Ticket ToTicket(Dictionary<string, JsonElement> json) => new( json["intent"].GetString(), json["order_id"].GetString(), json["urgent"].GetBoolean(), json["summary"].GetString());var 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",];foreach (var email in inbox){ Console.WriteLine($"correo: {email}"); var ticket = Triage(client, email); if (ticket == null) { Console.WriteLine(" → a una persona"); } else { var urgent = ticket.Urgent ? ", urgente" : ""; Console.WriteLine($" → {ticket.Intent}, {ticket.OrderId}{urgent}"); }}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, urgentecorreo: Quiero devolver el pedido 1187, la chaqueta me queda grande. intento 1 (~49 tokens): ok → return, 1187correo: 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.
El campo que falta se salta en silencio: continue sin error. La respuesta sin order_id pasa la comprobación, y el programa se cae más tarde, donde busca el pedido por número. Un error atrapado en la entrada se explica solo: «falta order_id». El mismo error tres métodos más adentro es un misterio. La corrección: añadir el error «falta» antes de continue.
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_reasonesrefusal) o llega amax_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.using var http = new HttpClient();var key = Environment.GetEnvironmentVariable("LLM_API_KEY");http.DefaultRequestHeaders.Add("Authorization", "Bearer " + key);var content = new StringContent(requestJson, Encoding.UTF8, "application/json");var response = await http.PostAsync("https://<proveedor>/messages", content);Console.WriteLine(await response.Content.ReadAsStringAsync());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{ public Reply Create(string system, List<Message> messages) { var email = messages[0].Content; var corrected = messages.Count > 1; string text; if (email.Contains("1042")) { text = corrected ? """{"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."}"""; } else if (email.Contains("1187")) { text = """{"intent": "return", "order_id": "1187", "urgent": false, "summary": "Quiere devolver la chaqueta, le queda grande."}"""; } else { text = "No entiendo qué quiere el cliente."; } var input = Tokens(system) + messages.Sum(m => Tokens(m.Content)); return new Reply(text, "end_turn", input, Tokens(text)); }
// A grandes rasgos: palabras × 1,3, redondeado hacia arriba. public static int Tokens(string text) { var words = text.Split((char[])null, StringSplitOptions.RemoveEmptyEntries).Length; return (words * 13 + 9) / 10; }}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.
ok---"order_id": "10420" no tiene 4 dígitos---"order_id": "#1042" no tiene 4 dígitos---Más tareas
- Un resumen vacío.
summaryno 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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: La ventana de contexto · Los límites del modelo · Llamar a un modelo a través de una API · El prompt es código
- Continúa en: Uso de herramientas: el modelo pide, el programa actúa · El bucle del agente: plan, acción, observación · Prompt injection: el correo que da órdenes · Guardrails: comprobaciones antes y después del modelo
- Ejercicios sobre el tema: Aplicaciones con LLM