Módulo 1: Fundamentos de Prompt Engineering

7. Proveedores y Diferencias de Comportamiento

Descripción de la cápsula

OpenAI, Anthropic y Google responden de forma distinta al mismo prompt. El system prompt que produce resultados perfectos con GPT-4o puede necesitar ajustes en Claude 3.5. En esta cápsula verás cómo cada proveedor procesa prompts, sus diferencias de comportamiento medibles, y cómo construir prompts portables que funcionen en los tres.

También aprenderás adapter patterns: abstracciones que normalizan las diferencias entre proveedores sin tener que duplicar tu lógica de prompting. Esto es especialmente relevante en producción, donde la estrategia de fallback (si OpenAI tiene incidentes, redirigir a Anthropic) requiere que tus prompts sean portables.

Por qué importa: En producción puedes necesitar fallback entre proveedores por disponibilidad o costos, o trabajar con clientes que exigen un proveedor específico. Si tus prompts asumen solo OpenAI, una migración es un rediseño completo. Si tus prompts son portables desde el diseño, la migración es configuración.


Arquitectura de APIs por Proveedor

Antes de comparar comportamiento, entiende cómo cada API estructura los mensajes:

OpenAI (GPT-4o, GPT-4o-mini)

from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI()  # Lee OPENAI_API_KEY del .env

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        # System prompt como mensaje de rol "system"
        {"role": "system", "content": "Eres un clasificador de sentimiento."},
        # Historia de conversación intercalada
        {"role": "user", "content": "¿Qué opinas de este texto?"},
        {"role": "assistant", "content": "Necesito ver el texto primero."},
        # Mensaje actual del usuario
        {"role": "user", "content": "El servicio fue excelente."}
    ],
    temperature=0,
    max_tokens=10,
    # Forzar JSON válido (nativo en OpenAI)
    # response_format={"type": "json_object"}
)

# Acceso al output
texto = response.choices[0].message.content
tokens_usados = response.usage.total_tokens
print(f"Output: {texto}")
print(f"Tokens: {tokens_usados}")

Características:

  • Roles: system, user, assistant
  • System prompt: mensaje de tipo "role": "system" — bien respetado
  • JSON mode: nativo con response_format={"type": "json_object"} o {"type": "json_schema", ...}
  • Temperatura: 0.0-2.0 (default 1.0 en la API, muchos clientes usan 0.7)
  • Respeto de "solo X": Muy consistente, especialmente con temperature=0

Anthropic (Claude 3.5 Sonnet, Claude 3 Haiku)

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()
client = Anthropic()  # Lee ANTHROPIC_API_KEY del .env

response = client.messages.create(
    model="claude-3-5-sonnet-20241022",
    max_tokens=10,          # OBLIGATORIO en Anthropic (no tiene default)
    # System prompt como parámetro separado, NO como mensaje
    system="Eres un clasificador de sentimiento. Responde solo con POSITIVO o NEGATIVO.",
    messages=[
        # Solo "user" y "assistant" — no hay "system" en messages[]
        {"role": "user", "content": "El servicio fue excelente."}
    ],
    temperature=0
)

# Acceso al output — estructura diferente a OpenAI
texto = response.content[0].text
tokens_input = response.usage.input_tokens
tokens_output = response.usage.output_tokens
print(f"Output: {texto}")
print(f"Tokens input/output: {tokens_input}/{tokens_output}")

Características:

  • Roles en messages[]: solo user y assistant (no "system")
  • System prompt: parámetro separado system= en el request
  • JSON mode: mediante instrucciones en el prompt (no nativo como OpenAI)
  • max_tokens: obligatorio (sin default)
  • XML tags: Claude los sigue especialmente bien (<output>...</output>)
  • Comportamiento: excelente siguiendo instrucciones largas; a veces más "conversacional" en respuestas cortas

Google (Gemini 1.5 Flash, Gemini 1.5 Pro)

import google.generativeai as genai
import os
from dotenv import load_dotenv

load_dotenv()
genai.configure(api_key=os.environ["GOOGLE_API_KEY"])

# Configurar modelo con system instruction
model = genai.GenerativeModel(
    model_name="gemini-1.5-flash",
    system_instruction="Eres un clasificador de sentimiento. Responde solo con POSITIVO o NEGATIVO."
)

response = model.generate_content(
    "El servicio fue excelente.",
    generation_config=genai.types.GenerationConfig(
        temperature=0,
        max_output_tokens=10,
        # JSON mode disponible:
        # response_mime_type="application/json"
    )
)

texto = response.text
print(f"Output: {texto}")

Características:

  • Roles: user y model (equivalente a "assistant")
  • System instruction: parámetro separado en la inicialización del modelo
  • JSON mode: response_mime_type="application/json" en generation_config
  • Comportamiento: bueno en tareas técnicas; puede ser más verbose por defecto en respuestas abiertas

Diferencias de Comportamiento: Experimento Controlado

El mismo prompt, los tres proveedores:

from openai import OpenAI
from anthropic import Anthropic
import google.generativeai as genai
import os
from dotenv import load_dotenv

load_dotenv()

# Inicializar clientes
oai_client = OpenAI()
ant_client = Anthropic()
genai.configure(api_key=os.environ["GOOGLE_API_KEY"])
gem_model = genai.GenerativeModel(
    "gemini-1.5-flash",
    system_instruction="Clasifica sentimiento. Responde ÚNICAMENTE con POSITIVO, NEGATIVO, o NEUTRO. Una sola palabra."
)

SYSTEM = "Clasifica sentimiento. Responde ÚNICAMENTE con POSITIVO, NEGATIVO, o NEUTRO. Una sola palabra."

test_inputs = [
    "Me encantó el producto, excelente calidad",
    "Pésimo servicio, nunca más compraré aquí",
    "El producto llegó, funciona bien",
    "Estoy bastante decepcionado con el resultado",
    "Regular, nada especial"
]

def clasificar_openai(texto: str) -> str:
    r = oai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": texto}
        ],
        temperature=0,
        max_tokens=10
    )
    return r.choices[0].message.content.strip()

def clasificar_anthropic(texto: str) -> str:
    r = ant_client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=10,
        system=SYSTEM,
        messages=[{"role": "user", "content": texto}]
    )
    return r.content[0].text.strip()

def clasificar_google(texto: str) -> str:
    r = gem_model.generate_content(
        texto,
        generation_config=genai.types.GenerationConfig(temperature=0, max_output_tokens=10)
    )
    return r.text.strip()

print(f"{'Input':<45} {'OpenAI':<12} {'Anthropic':<12} {'Google':<12}")
print("-" * 85)
for texto in test_inputs:
    oai = clasificar_openai(texto)
    ant = clasificar_anthropic(texto)
    goo = clasificar_google(texto)
    print(f"{texto[:44]:<45} {oai:<12} {ant:<12} {goo:<12}")

Output típico:

Input                                         OpenAI       Anthropic    Google      
---------------------------------------------------------------------------------------
Me encantó el producto, excelente calidad     POSITIVO     POSITIVO     POSITIVO    
Pésimo servicio, nunca más compraré aquí      NEGATIVO     NEGATIVO     NEGATIVO    
El producto llegó, funciona bien              POSITIVO     POSITIVO     POSITIVO    
Estoy bastante decepcionado con el resultado  NEGATIVO     NEGATIVO     NEGATIVO    
Regular, nada especial                        NEUTRO       NEUTRO       NEUTRO

Para clasificación simple con instrucciones claras, los tres son consistentes. Las diferencias aparecen en casos de comportamiento más sutil:


Tabla de Diferencias Clave

AspectoOpenAIAnthropicGoogle
System prompt ubicaciónmessages[0] con role: systemParámetro system= separadosystem_instruction= en model init
max_tokens obligatorioNo (tiene default) (sin default, lanza error)No (tiene default)
JSON mode nativoresponse_formatParcial (con instrucciones)response_mime_type
XML tags para estructuraFuncionaExcelente (optimizado para ello)Funciona
Respeto a "solo X palabras"AltoAltoMedio-Alto
System prompt muy largo (5k+ tokens)Bien soportadoExcelente (200k context)Bien
Temperatura range0.0-2.00.0-1.00.0-2.0
Roles en messagessystem/user/assistantuser/assistantuser/model

Diferencias de comportamiento en práctica:

Claude es más "conversacional" por defecto:

# Si el sistema no impone restricciones estrictas:
# OpenAI → "NEGATIVO"
# Claude  → "NEGATIVO" o a veces "Negativo. El texto muestra frustración."

# Solución para Claude: refuerza en Personality
SYSTEM_CLAUDE = """
Eres un clasificador de sentimiento.
Responde ÚNICAMENTE con la categoría. Sin puntuación. Sin texto adicional.
Categoría correcta: POSITIVO
Categoría incorrecta: "El sentimiento es POSITIVO porque..."
"""

Google puede ser más verbose en respuestas abiertas:

# Para tareas de generación (no clasificación), Google tiende a dar más contexto
# Solución: añadir instrucción de brevedad explícita
SYSTEM_GOOGLE = """
Clasifica el sentimiento. Una palabra. Sin justificación.
"""

Adapter Patterns para Multi-Proveedor

Patrón 1: Wrapper unificado con interfaz común

from openai import OpenAI
from anthropic import Anthropic
from typing import Literal

ProviderType = Literal["openai", "anthropic"]

class LLMClient:
    """Wrapper que normaliza la interfaz entre proveedores."""
    
    def __init__(self):
        self.openai = OpenAI()
        self.anthropic = Anthropic()
    
    def complete(
        self,
        system: str,
        user: str,
        provider: ProviderType = "openai",
        temperature: float = 0,
        max_tokens: int = 100
    ) -> str:
        """Interfaz unificada para completion de texto."""
        if provider == "openai":
            r = self.openai.chat.completions.create(
                model="gpt-4o-mini",
                messages=[
                    {"role": "system", "content": system},
                    {"role": "user", "content": user}
                ],
                temperature=temperature,
                max_tokens=max_tokens
            )
            return r.choices[0].message.content.strip()
        
        elif provider == "anthropic":
            r = self.anthropic.messages.create(
                model="claude-3-5-sonnet-20241022",
                max_tokens=max_tokens,  # Obligatorio en Anthropic
                system=system,
                messages=[{"role": "user", "content": user}],
                temperature=temperature
            )
            return r.content[0].text.strip()
        
        raise ValueError(f"Provider desconocido: {provider}")

# Uso
llm = LLMClient()

system = "Clasifica sentimiento. Solo: POSITIVO, NEGATIVO, o NEUTRO."
user = "El servicio fue excelente"

# Mismo código, diferentes proveedores
for provider in ["openai", "anthropic"]:
    resultado = llm.complete(system=system, user=user, provider=provider)
    print(f"{provider}: {resultado}")

Patrón 2: Normalización de output por proveedor

Los proveedores pueden devolver pequeñas variaciones en formato (espacios, puntuación). Normaliza antes de procesar:

def normalizar_clasificacion(raw: str, categorias_validas: list[str]) -> str:
    """
    Normaliza output de cualquier proveedor a categoría válida.
    Maneja variaciones: espacios, puntuación, capitalización inconsistente.
    """
    # Limpiar whitespace y puntuación trailing
    cleaned = raw.strip().rstrip(".,!?").upper()
    
    # Match exacto primero
    if cleaned in categorias_validas:
        return cleaned
    
    # Match parcial (para outputs como "POSITIVO." o "El texto es NEGATIVO")
    for cat in categorias_validas:
        if cat in cleaned:
            return cat
    
    return "DESCONOCIDO"

# Test con outputs reales de diferentes proveedores
outputs_raw = [
    "POSITIVO",              # OpenAI ideal
    "Positivo.",             # Claude ocasional
    "El sentimiento es NEGATIVO",  # Claude conversacional
    "NEUTRO\n",              # Con newline
    " POSITIVO ",            # Con espacios
]
categorias = ["POSITIVO", "NEGATIVO", "NEUTRO"]

for raw in outputs_raw:
    normalizado = normalizar_clasificacion(raw, categorias)
    print(f"'{raw}' → '{normalizado}'")

Output:

'POSITIVO' → 'POSITIVO'
'Positivo.' → 'POSITIVO'
'El sentimiento es NEGATIVO' → 'NEGATIVO'
'NEUTRO\n' → 'NEUTRO'
' POSITIVO ' → 'POSITIVO'

Patrón 3: Fallback automático entre proveedores

import time

def complete_with_fallback(
    system: str,
    user: str,
    primary: ProviderType = "openai",
    fallback: ProviderType = "anthropic",
    max_retries: int = 2
) -> tuple[str, str]:
    """
    Intenta con primary, cae a fallback si falla.
    Returns: (resultado, proveedor_usado)
    """
    llm = LLMClient()
    
    # Intentar con primary
    for attempt in range(max_retries):
        try:
            resultado = llm.complete(system=system, user=user, provider=primary)
            return resultado, primary
        except Exception as e:
            print(f"Primary ({primary}) falló (intento {attempt + 1}): {e}")
            if attempt < max_retries - 1:
                time.sleep(1)
    
    # Fallback
    print(f"Usando fallback: {fallback}")
    resultado = llm.complete(system=system, user=user, provider=fallback)
    return resultado, fallback

# Uso
resultado, proveedor = complete_with_fallback(
    system="Clasifica sentimiento. Solo: POSITIVO o NEGATIVO.",
    user="Me encantó el producto"
)
print(f"Resultado: {resultado} (usando: {proveedor})")

System Prompt Compatibility Matrix

Para hacer un prompt portable, usa estas prácticas:

PrácticaOpenAIAnthropicGooglePortable?
System prompt claro y al inicio✅ Sí
response_format para JSON✅ Nativo❌ No existe✅ Diferente syntax❌ No
XML tags para estructura✅ Funciona✅ Excelente✅ Funciona✅ Sí
Ejemplos en Experiment✅ Sí
"Responde SOLO con X"✅ Sí
Instrucciones en español✅ Sí

Regla de oro para portabilidad: Usa instrucciones explícitas en texto en lugar de features específicas de proveedor. Si dependes de response_format, implementa parsing con retry para proveedores que no lo soporten.

# Portable: Instrucción de formato en texto
SYSTEM_PORTABLE = """
Clasifica sentimiento. 
Responde en JSON: {"sentimiento": "POSITIVO|NEGATIVO|NEUTRO"}
Ejemplo: {"sentimiento": "POSITIVO"}
Sin texto adicional. Solo el JSON.
"""

# No portable: Depende de feature de OpenAI
# response_format={"type": "json_object"}  # Solo OpenAI

Diferencias en JSON Output

Esta es la diferencia práctica más importante para sistemas que necesitan output estructurado:

import json

# OpenAI: JSON mode nativo garantiza JSON válido
def extract_json_openai(texto: str, schema_description: str) -> dict:
    r = oai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": f"Extrae: {schema_description}. Responde en JSON."},
            {"role": "user", "content": texto}
        ],
        temperature=0,
        response_format={"type": "json_object"}  # Garantiza JSON válido
    )
    return json.loads(r.choices[0].message.content)  # Siempre parseable

# Anthropic: Sin JSON mode nativo — necesitas instrucciones explícitas + retry
def extract_json_anthropic(texto: str, schema_description: str, max_retries: int = 2) -> dict:
    system = f"""
    Extrae: {schema_description}
    
    IMPORTANTE: Responde ÚNICAMENTE con JSON válido.
    Sin texto antes ni después del JSON.
    Ejemplo de formato correcto: {{"key": "value"}}
    """
    
    for attempt in range(max_retries + 1):
        r = ant_client.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=200,
            system=system,
            messages=[{"role": "user", "content": texto}],
            temperature=0
        )
        raw = r.content[0].text.strip()
        
        # Limpiar markdown si viene envuelto en ```json ... ```
        if raw.startswith("```"):
            lines = raw.split("\n")
            raw = "\n".join(lines[1:-1] if lines[-1] == "```" else lines[1:])
        
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            if attempt < max_retries:
                # Feedback específico en el retry
                system += f"\nEl output anterior no fue JSON válido: '{raw[:100]}'. Corrige el formato."
    
    raise ValueError("No se pudo obtener JSON válido de Anthropic")

# Test comparativo
texto = "Contáctar a Ana Martínez en ana.martinez@empresa.com"

try:
    resultado_oai = extract_json_openai(texto, "nombre y email como {nombre: str, email: str}")
    print(f"OpenAI: {resultado_oai}")
except Exception as e:
    print(f"OpenAI error: {e}")

try:
    resultado_ant = extract_json_anthropic(texto, "nombre y email como {nombre: str, email: str}")
    print(f"Anthropic: {resultado_ant}")
except Exception as e:
    print(f"Anthropic error: {e}")

Recomendaciones por Proveedor

Para OpenAI (GPT-4o-mini / GPT-4o):

  • Usa response_format={"type": "json_object"} para JSON garantizado
  • System prompt en el primer mensaje con role: "system"
  • Temperature=0 para determinismo
  • max_tokens opcional pero recomendado para controlar costo

Para Anthropic (Claude 3.5 Sonnet / Claude 3 Haiku):

  • System prompt como parámetro separado system=
  • max_tokens es obligatorio (sin él lanza error)
  • Para JSON: usa instrucciones explícitas + retry logic
  • XML tags funcionan excelentemente para estructurar respuestas complejas
  • Claude 3 Haiku para tareas simples (más barato), Claude 3.5 Sonnet para reasoning complejo

Para Google (Gemini 1.5 Flash / Pro):

  • System instruction en la inicialización del modelo
  • Para JSON: response_mime_type="application/json" en generation_config
  • Añade instrucciones de brevedad explícitas si el output es más largo del esperado

Conexión con el Proyecto

En el Prompt Analyzer (cápsula 08) puedes añadir detección de portabilidad:

  • Detectar si el prompt usa features específicas de un proveedor (ej: menciona response_format)
  • Sugerir versión portable de las mismas instrucciones
  • Indicar qué cambios necesitaría para funcionar en cada proveedor

En el Módulo 3 (Structured Outputs), profundizarás en JSON mode de OpenAI y las alternativas de Anthropic — exactamente los adapters que viste aquí, pero con schemas Pydantic y validación.


Troubleshooting

Problema 1: Claude devuelve respuestas más largas de lo pedido

Causa: Claude tiende a ser más explicativo por defecto, especialmente en respuestas cortas donde el format constraint no es 100% estricto.

Solución:

# Refuerza en Personality con lenguaje firme
system = """
Clasifica el sentimiento. 

RESPONDE ÚNICAMENTE con una sola palabra: POSITIVO, NEGATIVO, o NEUTRO.
Sin puntuación. Sin explicación. Sin texto adicional.
Respuesta incorrecta: "El sentimiento es POSITIVO porque..."
Respuesta correcta: "POSITIVO"
"""

Problema 2: Error "max_tokens is required" en Anthropic

Causa: Anthropic no tiene valor default para max_tokens — es obligatorio.

Solución: Siempre incluir max_tokens en llamadas a Anthropic:

# ❌ Lanza error
r = ant_client.messages.create(model="...", messages=[...])

# ✅ Correcto
r = ant_client.messages.create(model="...", max_tokens=100, messages=[...])

Regla general: max_tokens = (tokens esperados de output) × 2 como buffer de seguridad.

Problema 3: JSON de Anthropic viene con texto antes o después

Causa: Sin JSON mode nativo, Claude a veces añade "Aquí está el JSON:" o usa json ... .

Solución: Implementa un parser robusto:

import re

def extraer_json(raw: str) -> dict:
    """Extrae JSON de respuesta que puede tener texto rodeándolo."""
    # Intentar parse directo
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        pass
    
    # Buscar bloque JSON entre ```
    match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', raw, re.DOTALL)
    if match:
        return json.loads(match.group(1))
    
    # Buscar el primer { ... } del texto
    match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', raw, re.DOTALL)
    if match:
        return json.loads(match.group())
    
    raise ValueError(f"No se encontró JSON válido en: {raw[:100]}")

Problema 4: Migrar de OpenAI a Anthropic y el JSON falla

Causa: Dependías de response_format={"type": "json_object"} de OpenAI, que Anthropic no soporta.

Solución: Adoptar el patrón portable: instrucción de JSON en texto + parser robusto + retry. Esto funciona en todos los proveedores y es más resiliente incluso en OpenAI.

Problema 5: Variables de entorno no cargadas

Causa: Cada proveedor busca su propia variable de entorno.

Solución:

# .env
OPENAI_API_KEY=sk-proj-...
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_API_KEY=AI...
# En tu código
from dotenv import load_dotenv
load_dotenv()  # Carga .env antes de inicializar clientes

from openai import OpenAI
from anthropic import Anthropic
# Los clientes leen automáticamente del entorno
oai = OpenAI()
ant = Anthropic()

Ejercicios

Ejercicio 1: Wrapper unificado básico (Fácil)

Implementa una función clasificar_sentimiento(texto, provider) que devuelva siempre "POSITIVO", "NEGATIVO", o "NEUTRO" independientemente del proveedor usado. Prueba con al menos OpenAI y Anthropic.

Ver solución
from openai import OpenAI
from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()
oai = OpenAI()
ant = Anthropic()

SYSTEM = "Clasifica sentimiento. Responde ÚNICAMENTE con POSITIVO, NEGATIVO, o NEUTRO. Una sola palabra."

def normalizar(raw: str) -> str:
    cleaned = raw.strip().rstrip(".,!?").upper()
    for cat in ["POSITIVO", "NEGATIVO", "NEUTRO"]:
        if cat in cleaned:
            return cat
    return "DESCONOCIDO"

def clasificar_sentimiento(texto: str, provider: str = "openai") -> str:
    if provider == "openai":
        r = oai.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "system", "content": SYSTEM},
                {"role": "user", "content": texto}
            ],
            temperature=0,
            max_tokens=10
        )
        raw = r.choices[0].message.content
    elif provider == "anthropic":
        r = ant.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=10,
            system=SYSTEM,
            messages=[{"role": "user", "content": texto}]
        )
        raw = r.content[0].text
    else:
        raise ValueError(f"Provider desconocido: {provider}")
    
    return normalizar(raw)

# Test
textos = ["Me encantó el producto", "Pésimo servicio", "Normal, sin destacar"]
for t in textos:
    for provider in ["openai", "anthropic"]:
        print(f"[{provider}] {t}: {clasificar_sentimiento(t, provider)}")

Verificar: ¿Ambos proveedores devuelven la misma categoría para los mismos inputs?


Ejercicio 2: Comparar outputs en tareas de razonamiento (Medio)

Elige una tarea más compleja (ej: resumir un texto en 3 puntos) y ejecuta el mismo prompt en OpenAI y Anthropic. Compara: longitud de respuesta, formato, y si siguen las instrucciones de brevedad.

Ver solución
SYSTEM_RESUMEN = """
Resume el siguiente texto en exactamente 3 puntos.

Formato:
1. [Primer punto — máximo 15 palabras]
2. [Segundo punto — máximo 15 palabras]
3. [Tercer punto — máximo 15 palabras]

Solo los 3 puntos numerados. Sin introducción ni conclusión.
"""

texto = """
El machine learning ha transformado cómo las empresas procesan datos.
Los algoritmos supervisados aprenden de ejemplos etiquetados para hacer predicciones.
Las redes neuronales profundas han superado a los métodos clásicos en visión por computador.
El procesamiento del lenguaje natural permite a las máquinas entender texto humano.
La ética en IA es un campo creciente que busca mitigar sesgos y garantizar equidad.
"""

# OpenAI
r_oai = oai.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": SYSTEM_RESUMEN},
        {"role": "user", "content": texto}
    ],
    temperature=0
)
out_oai = r_oai.choices[0].message.content

# Anthropic
r_ant = ant.messages.create(
    model="claude-3-5-sonnet-20241022",
    max_tokens=200,
    system=SYSTEM_RESUMEN,
    messages=[{"role": "user", "content": texto}]
)
out_ant = r_ant.content[0].text

print("=== OpenAI ===")
print(out_oai)
print(f"(Palabras: {len(out_oai.split())})\n")

print("=== Anthropic ===")
print(out_ant)
print(f"(Palabras: {len(out_ant.split())})")

Observa: ¿Cuál sigue más estrictamente el límite de 15 palabras por punto? ¿Cuál añade más contexto no pedido?


Ejercicio 3: Parser robusto de JSON multi-proveedor (Difícil)

Implementa una función parse_json_universal(raw: str) -> dict que maneje todos los formatos de output de JSON que los distintos proveedores pueden devolver: JSON puro, json..., con texto introductorio, con trailing comma, etc.

Ver solución
import json
import re

def parse_json_universal(raw: str) -> dict:
    """
    Parsea JSON de output de cualquier proveedor LLM.
    Maneja: JSON puro, ```json...```, texto+JSON, trailing commas.
    """
    # 1. Intentar parse directo
    try:
        return json.loads(raw.strip())
    except json.JSONDecodeError:
        pass
    
    # 2. Extraer de bloque markdown ```json ... ```
    match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', raw, re.DOTALL)
    if match:
        try:
            return json.loads(match.group(1).strip())
        except json.JSONDecodeError:
            pass
    
    # 3. Encontrar el JSON en el texto (primer { ... } balanceado)
    # Busca desde el primer { hasta el } balanceado correspondiente
    start = raw.find('{')
    if start == -1:
        raise ValueError("No se encontró JSON en el output")
    
    depth = 0
    for i, char in enumerate(raw[start:], start):
        if char == '{':
            depth += 1
        elif char == '}':
            depth -= 1
            if depth == 0:
                candidate = raw[start:i+1]
                # 4. Limpiar trailing commas (error común)
                candidate = re.sub(r',\s*}', '}', candidate)
                candidate = re.sub(r',\s*]', ']', candidate)
                try:
                    return json.loads(candidate)
                except json.JSONDecodeError:
                    break
    
    raise ValueError(f"No se pudo parsear JSON de: {raw[:200]}")

# Test con formatos problemáticos reales
test_cases = [
    '{"key": "value"}',                                    # JSON puro
    '```json\n{"key": "value"}\n```',                     # Markdown
    'Aquí está el JSON:\n{"key": "value"}',               # Con texto
    '{"key": "value",}',                                   # Trailing comma
    'El resultado es: ```\n{"key": "value"}\n```\n¡Listo!',  # Todo mezclado
]

for case in test_cases:
    try:
        result = parse_json_universal(case)
        print(f"✅ '{case[:40]}...' → {result}")
    except ValueError as e:
        print(f"❌ Error: {e}")

Resumen

En esta cápsula aprendiste:

  • OpenAI: role: "system" en messages[], JSON mode nativo, max_tokens opcional
  • Anthropic: system prompt como parámetro separado, max_tokens obligatorio, sin JSON mode nativo, XML tags funcionan excelentemente
  • Google: system_instruction en model init, JSON via response_mime_type, puede ser más verbose
  • Adapter pattern: Wrapper unificado normaliza diferencias de API; normalización de output maneja variaciones de formato
  • Portabilidad: Usa instrucciones en texto en lugar de features específicas; implementa retry para JSON sin modo nativo
  • Fallback: Diseña el flujo para que, si el proveedor principal falla, el fallback funcione con el mismo prompt

Próxima cápsula: Proyecto del módulo — construirás el Prompt Analyzer que integra anatomía, CRISPE, comparación casual vs engineered, y detección de portabilidad multi-proveedor.


Recursos adicionales

  1. OpenAI API Reference — Messages — Documentación completa de parámetros, incluyendo response_format y temperature
  2. Anthropic Messages API — Diferencias de diseño respecto a OpenAI, con énfasis en system y max_tokens
  3. Google Gemini API — generativeai — Documentación Python para Gemini con system_instruction y generation config
  4. LiteLLM — Librería open-source que unifica la interfaz de 100+ LLMs; alternativa a implementar adapters manualmente
  5. OpenAI Structured Outputs — JSON mode avanzado con schemas JSON Schema (preview del Módulo 3)
  6. Anthropic Model Comparison — Tabla actualizada de modelos Claude con capacidades, precios, y casos de uso recomendados