Skip to content

Structured outputs en Bedrock: la forma está garantizada, el contenido no

· 6 min de lectura

tags: llms, architecture, ai-agents

El 20 de octubre, AWS anunció structured outputs para los modelos Claude en Amazon Bedrock: pasás un JSON schema y la respuesta tiene garantizado cumplirlo. La promesa es que ya no hace falta parseo propio, reintentos ni post-procesamiento.

Corremos muchas llamadas estructuradas por Bedrock, todas con el mismo patrón: pedir JSON en el prompt, validarlo con Zod, y reparar o volver a pedir cuando no encaja. Así que el anuncio se leía como una invitación a borrar una capa de código. Antes de borrar nada, lo medimos.

Versión corta: funciona, no es un reemplazo directo, y por ahora mantenemos el camino viejo como default. Este post cuenta lo que encontramos, sin los detalles de nuestra carga.

Antes del GA: "soportado", hasta que el schema crecía

Antes del anuncio, el structured output nativo en Bedrock con los modelos Claude nuevos era irregular. Los schemas chicos pasaban. Un schema de extracción realista fallaba con un error así:

Schemas contains too many parameters with union types (76 ...) limit: 16

Cada campo opcional o nullable cuenta como una unión (string | null, que es lo que genera .nullable() en Zod). Un schema de extracción donde la mayoría de los campos pueden faltar llega muy rápido al tope de 16. En la práctica, "no soportado" quería decir "rechazado en cuanto el schema es de verdad". Por eso habíamos pasado a prompt más validación.

El SDK tiene su propia opinión

Llamamos a Bedrock con el AI SDK de Vercel (@ai-sdk/amazon-bedrock). Tiene una opción structuredOutputMode con tres valores: outputFormat (nativo), jsonTool (una tool forzada) y auto. Hay tres detalles que importan más que la opción en sí:

  • auto no elige el modo nativo para los Claude más nuevos. A la versión 5.0.121, el provider tiene una lista de los modelos Claude con los que confía en el modo nativo. Las generaciones nuevas quedan afuera, y un comentario en el código todavía dice que Bedrock las rechaza. El GA salió, pero el SDK no se actualizó.
  • No sabe que un application inference profile es Claude. Si llamás a los modelos con ARNs de perfiles, que son IDs opacos, auto no sabe que está hablando con Anthropic. Tenés que pasar modelFamily: 'anthropic' o forzar el modo.
  • Le saca restricciones a tu schema. Antes de mandarlo, el SDK convierte maxLength, maximum y maxItems en sugerencias dentro de la descripción. El modelo lee "que sea corto" en vez de un límite duro, y se pasa. Un schema con .max() entonces falla tu propia validación con una respuesta que Bedrock consideró válida.

Forzar el modo nativo se ve así:

const { output } = await generateText({
  model: bedrock(profileArn),
  output: Output.object({ schema }),
  providerOptions: {
    bedrock: { structuredOutputMode: 'outputFormat' },
  },
});

Y por el tercer punto, igual necesitás un paso de reparación: algo que recorte los campos demasiado largos antes de que tu validador rechace el objeto entero. El modo nativo no nos dejó borrar ese código. Lo movió a otro hook.

Cómo medimos

Tomamos inputs reales de varias operaciones estructuradas, desde clasificaciones cortas hasta reportes largos y anidados, y corrimos cada una de tres formas:

  1. el camino actual (JSON en el prompt más validación),
  2. el mismo camino actual otra vez,
  3. structured outputs nativo.

La segunda corrida es la importante. La salida de un LLM varía entre corridas aunque no cambie nada, así que una diferencia entre dos modelos no dice nada hasta que la comparás con la diferencia entre un modelo y sí mismo. A eso le decimos el piso de ruido. Todas las comparaciones de abajo son contra él.

Mismo modelo y mismo reasoning effort en los tres brazos. Lo único que cambiaba era cómo le llegaba el schema al modelo.

Hallazgo 1: la forma está garantizada, el contenido no

Este es el que importa. Con un schema anidado, un resumen de texto más un array de evaluaciones por ítem, el modo nativo muchas veces devolvió JSON válido pero hueco: el resumen vacío, el array vacío, todas las claves obligatorias presentes. Pasó en aproximadamente dos de cada tres corridas. Pasó también con y sin reasoning effort, así que no era un problema del presupuesto de thinking.

Nada falló. El schema decía summary: string, y "" es un string. Zod lo aceptó, y la llamada parecía más rápida y más barata. El ahorro de tokens y de latencia era el contenido que faltaba.

Esa es la trampa de los schemas garantizados: mueven la falla de "tiró error" a "parecía estar bien". Si vas por el modo nativo, ponele un mínimo a cada campo que tiene que traer contenido (.min(1) en los strings y en los arrays que nunca deberían venir vacíos), para que una respuesta hueca falle la validación y se vuelva a pedir en vez de llegarle a un usuario.

Hallazgo 2: donde claramente ayudó

Un schema fue para el otro lado. Por el camino actual, una parte notable de las respuestas fallaba la validación (alrededor de un cuarto), casi siempre por problemas de estructura que la reparación no podía arreglar. Con el modo nativo, todas las respuestas fueron válidas, y la latencia mediana bajó a más o menos la mitad.

Así que no es que el nativo sea peor. Garantizar la forma vale mucho en los schemas donde el modelo se equivoca seguido con la forma. Vale muy poco en los que el modelo ya resuelve bien, y puede hacer daño donde los valores vacíos pasan como válidos.

Hallazgo 3: en todo lo demás, empate

En clasificaciones cortas, comparaciones y reportes medianos, el nativo y el de texto se diferenciaron más o menos lo mismo que el de texto consigo mismo. Una operación fue un poco más lenta en nativo y tuvo un par de fallas que nunca vimos por texto. Otra salió levemente peor en coincidencia. Las muestras eran lo bastante chicas como para no llamar tendencia a ninguna de las dos.

Dos costos menores: el camino nativo sumó unos cien tokens de input por llamada, y todavía no medimos cómo afecta al prompt caching sacar el schema del prompt.

Notas al margen sobre los Claude nuevos

Nada de esto es propio de structured outputs, pero apareció en todas las corridas:

  • Piensan por defecto, dentro de tu presupuesto de salida. Los tokens de thinking cuentan para maxOutputTokens. Un presupuesto calibrado para un modelo anterior se puede gastar en thinking y volver con el JSON cortado a la mitad, o vacío. Fijá el reasoning effort explícitamente y dale margen al presupuesto.
  • Escriben más. Para la misma tarea vimos varias veces los tokens de salida de la generación anterior. Revisá todos los límites hardcodeados.
  • Rechazan parámetros de sampling que no son los default, como temperature, cuando el thinking está activo. Si tu código manda temperature: 0 para ser determinístico, ese request falla o se reintenta sin él. El determinismo tiene que salir de otro lado.

Qué estamos haciendo

Por ahora, prompt más validación sigue siendo el default. Structured outputs nativo pasa a ser algo que se activa por schema. Un schema califica cuando el camino de texto tiene una tasa real de fallas de validación, los campos que tienen que traer contenido tienen mínimos, y una corrida contra el piso de ruido muestra que ayuda.

La capa de reparación se queda en cualquier caso, porque el SDK sigue sacando las restricciones que se supone que debería hacer cumplir.

Lo vamos a volver a revisar a medida que el SDK aprenda sobre los modelos nuevos y el camino nativo madure. La funcionalidad es real y el GA es una buena noticia. Lo que todavía no significa es lo que suena "sin post-procesamiento".

Si estás por borrar tu capa de validación por este anuncio, medí primero: el mismo modelo dos veces, después el nativo, con tus propios inputs. Y mirá qué traen las respuestas válidas, no solo si parsean.