Tabla de Contenidos
Tenía un sistema funcionando. Un agente que procesaba el RSS feed de AWS varias veces por día, filtraba las noticias relevantes con Claude y generaba posts para LinkedIn y X. Lo había construido, desplegado, y monitoreaba sus logs con cierta satisfacción.
Y sin embargo, había algo que no me gustaba cada vez que abría el código: tres métodos cuyo único propósito era desconfiar del LLM.
_extract_json_from_text. _validate_analysis_structure. _create_fallback_analysis.
Juntos sumaban más de 130 líneas. Todo ese código existía para manejar una sola posibilidad: que el modelo respondiera algo distinto a lo que le había pedido. Que incluyera una disculpa antes del JSON. Que olvidara un campo. Que formateara mal el output.
Cuando Amazon anunció Bedrock Structured Outputs, entendí inmediatamente qué era lo que había estado haciendo mal. No era un problema de prompting. Era un problema de arquitectura: había estado pidiéndole al modelo que fuera consistente, cuando lo que necesitaba era garantizarlo.
El Sistema: Agente de Noticias AWS 🗞️
Antes de entrar en materia, contexto del sistema. El agente procesa el RSS de AWS varias veces por día con tres responsabilidades:
- Analizar relevancia de cada noticia (score 0-10 y metadatos para la audiencia técnica)
- Generar contenido social — un post de LinkedIn y uno de X por noticia relevante
- Componer el newsletter semanal, incluyendo subject y preview text del email
Todo corre en Lambda, usa DynamoDB para estado, y Bedrock con el API converse para las interacciones con Claude.
El sistema funcionaba. El problema era la cantidad de código defensivo necesario para confiar en sus outputs.
El Problema: Pedir vs. Garantizar
El content_analyzer.py tenía este system prompt:
# Antes — instrucciones de formato en lenguaje natural
system_prompts = [{
"text": "Eres un experto analista de noticias de AWS...\n\n"
"FORMATO DE RESPUESTA OBLIGATORIO:\n"
"Debes responder ÚNICAMENTE con un objeto JSON válido. "
"No incluyas explicaciones, comentarios o texto adicional.\n\n"
"ESTRUCTURA JSON REQUERIDA:\n"
"{\n"
" \"relevance\": 7,\n"
" \"analysis\": {\n"
" \"article\": true,\n"
" \"keyPoints\": [\"Punto clave 1\", \"Punto clave 2\"],\n"
" \"emojis\": [\"🚀\", \"☁️\"],\n"
" \"relevance\": 7\n"
" }\n"
"}\n\n"
"IMPORTANTE: Responde SOLO con el JSON. "
"No agregues texto antes o después."
}]
Ese bloque es una oración en lenguaje natural que le ruega al modelo que sea consistente. El modelo generalmente lo era. Pero “generalmente” no es suficiente para producción.
La consecuencia directa era este código:
# Antes — extracción defensiva de JSON
output_message = response['output']['message']['content'][0]['text']
# ¿El modelo puso texto antes del JSON? A buscar manualmente.
if not cleaned_output.startswith('{'):
start_idx = cleaned_output.find('{')
end_idx = cleaned_output.rfind('}')
if start_idx != -1 and end_idx != -1:
cleaned_output = cleaned_output[start_idx:end_idx+1]
else:
# Sin JSON → reintento
continue
# ¿El JSON es parseable?
try:
analysis = json.loads(cleaned_output)
# ¿Tiene todos los campos?
if self._validate_analysis_structure(analysis, news['news_id']):
return analysis
else:
continue # reintento
except json.JSONDecodeError:
continue # reintento
# Todos los intentos fallaron → fallback por palabras clave
return self._create_fallback_analysis(news)
Y encima de eso, _validate_analysis_structure (45 líneas) verificando tipos y campos, y _create_fallback_analysis (65 líneas) haciendo análisis por palabras clave cuando el modelo fallaba.
En total: ~130 líneas de código cuya única función era manejar la inconsistencia del modelo.
Bedrock Structured Outputs: Qué Es y Cómo Funciona 🔧
Bedrock Structured Outputs es una feature que garantiza que la respuesta del modelo sea un JSON válido que cumple exactamente con un schema que tú defines (JSON Schema Draft 2020-12).
La palabra importante es garantiza. No “el modelo intentará”. No “usualmente produce”. Garantiza.
La implementación es un parámetro adicional en el request al API converse:
response = self.bedrock.converse(
modelId=Config.BEDROCK_MODEL_ID,
messages=messages,
system=system_prompts,
inferenceConfig=inference_config,
outputConfig={ # ← este es el cambio
'textFormat': {
'type': 'json_schema',
'structure': {
'jsonSchema': {
'schema': json.dumps(MY_SCHEMA), # schema serializado
'name': 'schema_name',
'description': 'Descripción del schema'
}
}
}
}
)
Bedrock compila el schema en una gramática y garantiza que la respuesta cumpla el contrato — no es validación post-generación, sino cumplimiento durante la generación.
🧠 Cómo funciona internamente: Bedrock valida el schema contra JSON Schema Draft 2020-12, compila una gramática (puede tomar unos minutos la primera vez), y la cachea por 24 horas cifrada con claves AWS-managed. Requests subsiguientes con el mismo schema tienen latencia comparable a llamadas estándar.
Modelos Soportados
Un punto importante que me costó un rato en descubrir: Amazon Nova no soporta Structured Outputs.
Los modelos compatibles a marzo 2026 incluyen:
- Anthropic: Claude Haiku 4.5, Sonnet 4.5, Opus 4.5, Opus 4.6
- Qwen: Qwen3 series (235B, 32B, Coder)
- DeepSeek: DeepSeek-V3.1
- Google: Gemma 3 (12B, 27B)
- Mistral AI: Mistral Large 3, Magistral Small
- NVIDIA: Nemotron Nano series
No soportados: Amazon Nova (todas las versiones), Amazon Titan.
Mi sistema usaba amazon.nova-2-lite para el análisis de relevancia — elegido originalmente por costo. Tuve que migrar a Claude Haiku 4.5 para usar la feature. En la práctica, el costo de Haiku 4.5 es comparable, y la calidad del análisis mejoró.
La Migración: Tres Transformaciones
1. Análisis de Relevancia — El Cambio Más Dramático
El schema define exactamente qué estructura debe devolver el modelo:
# Schema de análisis — definido una vez, a nivel de módulo
_ANALYSIS_SCHEMA = {
"type": "object",
"properties": {
"relevance": {"type": "integer"},
"analysis": {
"type": "object",
"properties": {
"article": {"type": "boolean"},
"keyPoints": {"type": "array", "items": {"type": "string"}},
"emojis": {"type": "array", "items": {"type": "string"}},
"relevance": {"type": "integer"}
},
"required": ["article", "keyPoints", "emojis", "relevance"],
"additionalProperties": False # ← ningún campo extra posible
}
},
"required": ["relevance", "analysis"],
"additionalProperties": False
}
Con el schema definido, el método de análisis se simplifica radicalmente:
# Después — sin parsing defensivo, sin fallbacks, sin validación manual
def _analyze_single_news_with_retry(self, news, system_prompts, inference_config, max_retries=3):
for attempt in range(max_retries):
try:
messages = [{
"role": "user",
"content": [{"text": f"Título: {news['title']}\nDescripción: {news['description']}"}]
}]
response = self.bedrock.converse(
modelId=Config.BEDROCK_MODEL_ID,
messages=messages,
system=system_prompts,
inferenceConfig=inference_config,
outputConfig={
'textFormat': {
'type': 'json_schema',
'structure': {
'jsonSchema': {
'schema': json.dumps(_ANALYSIS_SCHEMA),
'name': 'news_analysis',
'description': 'Análisis de relevancia de noticia AWS'
}
}
}
}
)
output_message = response['output']['message']['content'][0]['text']
if not output_message or not output_message.strip():
continue
# json.loads nunca lanza JSONDecodeError aquí — el schema lo garantiza
return json.loads(output_message)
except Exception as e:
# Solo errores de red o servicio, no de parsing
logger.error(f"Error en intento {attempt + 1}: {str(e)}")
if attempt < max_retries - 1:
continue
return None # Ya no hay fallback por palabras clave — si Bedrock falla, la noticia se omite
El resultado: de ~90 líneas a ~30. Y el system prompt también cambia — ya no necesita instrucciones de formato:
# Después — solo criterios de negocio, sin instrucciones de formato JSON
system_prompts = [{
"text": "Eres un experto analista de noticias de AWS...\n\n"
"CRITERIOS DE RELEVANCIA (escala 0-10):\n"
"• 9-10: Bedrock, GenAI, servicios de IA, serverless core\n"
"• 7-8: RDS, Aurora, bases de datos, servicios de datos\n"
"...\n\n"
"CAMPOS A COMPLETAR:\n"
"• relevance: número entero del 0-10\n"
"• analysis.keyPoints: array de 2-3 strings con puntos clave\n"
"• analysis.emojis: array de 2-3 emojis relevantes"
# ← Sin mencionar JSON. Sin ejemplos de estructura.
# El schema en outputConfig ya define el contrato.
}]
Este cambio me pareció elegante: el prompt habla de negocio, el schema habla de estructura. Cada cosa en su lugar.
2. Generación de Posts Sociales — De 2 Llamadas a 1
Antes, el sistema generaba el post de LinkedIn y el de X en llamadas separadas. La razón: sin structured outputs, mezclar dos outputs en un solo request aumentaba la probabilidad de que el modelo “se perdiera” en el formato.
Con structured outputs, eso desaparece:
# Schema para generación simultánea de ambos posts
_SOCIAL_CONTENT_SCHEMA = {
"type": "object",
"properties": {
"linkedin_post": {"type": "string"},
"X_post": {"type": "string"}
},
"required": ["linkedin_post", "X_post"],
"additionalProperties": False
}
def _generate_social_posts(self, news: Dict) -> Dict:
"""Una sola llamada genera LinkedIn + X garantizados."""
# ... construcción del prompt con contexto de la noticia ...
response_text = self._invoke_bedrock(prompt, output_schema=_SOCIAL_CONTENT_SCHEMA)
return json.loads(response_text)
# → {"linkedin_post": "...", "X_post": "..."}
El patrón que hace esto funcionar limpiamente es un _invoke_bedrock con schema opcional:
def _invoke_bedrock(self, prompt: str, output_schema: dict = None) -> str:
"""Invoca Bedrock. Con output_schema activa Structured Outputs."""
converse_kwargs = {
'modelId': Config.SOCIAL_BEDROCK_MODEL_ID,
'messages': [{"role": "user", "content": [{"text": prompt}]}],
'inferenceConfig': {"temperature": 0.7, "maxTokens": 2000}
}
if output_schema:
converse_kwargs['outputConfig'] = {
'textFormat': {
'type': 'json_schema',
'structure': {
'jsonSchema': {
'schema': json.dumps(output_schema),
'name': 'structured_output',
'description': 'Salida estructurada garantizada por Bedrock'
}
}
}
}
response = self.bedrock.converse(**converse_kwargs)
return response['output']['message']['content'][0]['text']
Cuando output_schema=None, el comportamiento es idéntico al anterior — útil para los casos donde el output es texto libre (como la generación de HTML para el newsletter).
Impacto en costos: con ~90 ejecuciones/mes y ~10 noticias relevantes por ejecución, pasé de ~900 a ~450 llamadas mensuales para la generación de contenido social. La mitad.
3. Newsletter — Funcionalidad Nueva sin Código Extra
El newsletter_generator.py tenía un método _generate_subject que devolvía el asunto del email como string. Bien.
Pero había un campo que nunca había implementado: el preview text, esos 80-100 caracteres que Gmail, Outlook y Apple Mail muestran bajo el asunto antes de abrir el correo. Una oportunidad de engagement desperdiciada.
Agregar preview text antes hubiera requerido: una segunda llamada a Bedrock, o instrucciones más complejas en el prompt con el riesgo de que el modelo mezclara los dos campos.
Con structured outputs fue directo:
_SUBJECT_SCHEMA = {
"type": "object",
"properties": {
"subject": {"type": "string"}, # max 60 chars
"preview_text": {"type": "string"} # 80-100 chars, complementa el subject
},
"required": ["subject", "preview_text"],
"additionalProperties": False
}
Una llamada, dos campos garantizados. El newsletter ahora incluye preview_text automáticamente — y el próximo paso es pasárselo a Mailchimp al crear la campaña para que aparezca en los clientes de email de los suscriptores.
Resultados: El Antes y El Después
| Antes | Después | |
|---|---|---|
| Líneas de parsing defensivo | ~130 | 1 (json.loads) |
| Llamadas Bedrock por noticia | 2 | 1 |
JSONDecodeError posible |
Sí | Imposible |
| Métodos eliminados | — | _extract_json_from_text, _validate_analysis_structure, _create_fallback_analysis |
| Preview text en newsletter | No existía | Generado automáticamente |
| Modelo de análisis | Nova 2 Lite | Claude Haiku 4.5 |
El cambio más importante no aparece en esa tabla: el modelo mental con el que escribo prompts cambió. Ya no necesito pensar en cómo darle instrucciones al modelo para que sea consistente. Defino el contrato en código — JSON Schema — y el prompt puede enfocarse exclusivamente en el comportamiento de negocio.
Consideraciones Prácticas
El schema no reemplaza el prompt, lo complementa. El schema garantiza estructura; el prompt define comportamiento. Si el schema tiene "relevance": {"type": "integer"} pero el prompt no explica qué escala usar, el modelo inventará una. Ambas piezas son necesarias.
additionalProperties: False es importante. Sin él, el modelo puede agregar campos extra que no esperabas. Con él, el contrato es exacto en ambas direcciones.
Incompatibilidad con Citations de Anthropic. Si usas la feature de citations de Anthropic (para referenciar fragmentos de documentos), no puedes combinarla con Structured Outputs en el mismo request. Elige uno u otro según el caso de uso.
Schema inválido → HTTP 400 inmediato. Si el schema tiene errores de sintaxis, Bedrock retorna error en la llamada, no durante la generación. Útil para detectar problemas temprano.
Cache de 24 horas. Bedrock guarda en cache la gramática compilada de cada schema por 24 horas (cifrado con claves AWS-managed). La primera vez que usas un schema puede tardar unos segundos más. Los requests siguientes son inmediatos.
Conclusión
Hay una diferencia fundamental entre pedirle a un LLM que sea consistente y garantizar que lo sea. Durante meses escribí prompts cada vez más detallados, con ejemplos de estructura, con advertencias en mayúsculas. Y construí código defensivo para manejar los casos donde el modelo decidía no seguirlos.
Bedrock Structured Outputs resuelve ese problema en la capa correcta. El schema vive en código, se versiona con el código, y se valida como código. El prompt puede hablar de negocio. Y el parsing defensivo desaparece porque ya no tiene razón de existir.
La próxima vez que abras el archivo de un sistema que llama a Bedrock, pregúntate: ¿cuántas líneas de este código existen únicamente para desconfiar del modelo? Si la respuesta es más de diez, ya sabes qué hacer.
Recursos 📚
- Documentación oficial: Bedrock Structured Outputs
- Lista de modelos soportados (actualizada)
- JSON Schema Draft 2020-12
¿Ya tienes sistemas con parsing defensivo de JSON que podrían beneficiarse de esta migración? ¿O encontraste algún caso donde Structured Outputs no fue suficiente? Los comentarios están abiertos.
Inicia la conversación