Lab práctico · Semana 9: Coste, rendimiento y monitorización de aplicaciones de IA generativa

Comparar el coste real de varias estrategias de optimización

⏱ 60-90 minDificultad: mediaTask statements: 4.14.2

Qué vas a construir

Un script de Python que ejecuta las mismas preguntas sobre un manual de producto con cinco estrategias distintas y mide, con los datos reales que devuelve Bedrock (usage y metrics), el coste y la latencia de cada una:

  1. Base: Nova Lite, manual completo en el prompt, respuesta sin límite estricto.
  2. Modelo más pequeño: Nova Micro con el mismo prompt.
  3. Prompt caching: Nova Lite con un cachePoint después del manual.
  4. Response limiting: Nova Lite con maxTokens bajo e instrucción de brevedad.
  5. Context pruning: Nova Lite con solo la sección relevante del manual.

Al final, el script estima además el coste de la estrategia base si se ejecutara con batch inference (50 % del precio on-demand). Así verás con números propios lo que el módulo explica en teoría.

Antes de empezar

  • Cuenta en el plan de pago o con créditos, y un usuario con permisos para bedrock:InvokeModel, bedrock:Converse y bedrock:CountTokens.
  • Región: eu-central-1 (Fráncfort). Nova Micro y Nova Lite se usan allí mediante el perfil de inferencia geográfico de la UE (eu.amazon.nova-micro-v1:0, eu.amazon.nova-lite-v1:0).
  • Abre AWS CloudShell en eu-central-1. CloudShell ya trae Python 3 y boto3 y usa tus credenciales de la consola: no hay claves en el código.

Precios usados en el script (eu-central-1, on-demand Standard, USD por millón de tokens, consultados el 01/10/2026 en la API de precios de AWS): Nova Micro 0,046 entrada / 0,184 salida; Nova Lite 0,078 / 0,312. La lectura de caché de Nova cuesta el 25 % de la entrada y la escritura no tiene recargo. Compruébalos en la página de precios de Bedrock y actualiza el diccionario PRECIOS si han cambiado.

Paso 1: comprueba que tienes acceso a los modelos

aws bedrock list-inference-profiles --region eu-central-1 \
  --query "inferenceProfileSummaries[?contains(inferenceProfileId, 'nova-micro') || contains(inferenceProfileId, 'nova-lite')].inferenceProfileId"

Debes ver eu.amazon.nova-micro-v1:0 y eu.amazon.nova-lite-v1:0. Haz una prueba rápida:

aws bedrock-runtime converse --region eu-central-1 \
  --model-id eu.amazon.nova-micro-v1:0 \
  --messages '[{"role":"user","content":[{"text":"Di hola en una palabra"}]}]' \
  --query "usage"

Paso 2: crea el script

Crea el fichero coste.py en CloudShell (con nano coste.py) y pega este contenido:

"""Compara coste y latencia de varias estrategias con Amazon Bedrock (Converse)."""
import time
import boto3
from botocore.config import Config

REGION = "eu-central-1"
MICRO = "eu.amazon.nova-micro-v1:0"
LITE = "eu.amazon.nova-lite-v1:0"

# USD por millón de tokens (eu-central-1, 01/10/2026). Verifica en la página de precios.
PRECIOS = {
    MICRO: {"in": 0.046, "out": 0.184},
    LITE: {"in": 0.078, "out": 0.312},
}
FACTOR_LECTURA_CACHE = 0.25  # Nova: lectura de caché = 25 % del precio de entrada
FACTOR_BATCH = 0.5           # batch inference = 50 % del precio on-demand

brt = boto3.client(
    "bedrock-runtime",
    region_name=REGION,
    config=Config(retries={"max_attempts": 5, "mode": "adaptive"}),
)

# Manual ficticio: 20 secciones para superar holgadamente el mínimo de tokens de caché.
SECCIONES = {}
for n in range(1, 21):
    SECCIONES[n] = (
        f"Sección {n}. Código de error E{n:02d}. "
        f"El error E{n:02d} aparece cuando el módulo {n} del router pierde la sincronización. "
        f"Para resolverlo, apaga el equipo, espera {n + 5} segundos, pulsa el botón de reinicio "
        f"durante {n % 7 + 3} segundos y comprueba que el LED {['verde', 'azul', 'ámbar'][n % 3]} "
        f"parpadea. Si persiste, actualiza el firmware a la versión 4.{n}.2 desde el panel web, "
        f"apartado Mantenimiento, y registra la incidencia con la referencia KB-{1000 + n}. "
        "No desconectes la alimentación durante la actualización porque el equipo podría quedar inservible. "
    ) * 2
MANUAL = "\n".join(SECCIONES.values())

PREGUNTAS = [
    (3, "¿Qué hago si aparece el error E03?"),
    (7, "Tengo el error E07, ¿cuánto tiempo pulso el botón de reinicio?"),
    (12, "¿A qué versión de firmware actualizo con el error E12?"),
    (15, "¿Qué referencia de incidencia uso para el error E15?"),
    (18, "¿Qué LED debe parpadear tras resolver el error E18?"),
]
SISTEMA = "Eres el asistente técnico. Responde solo con la información del manual."


def invocar(modelo, system, pregunta, max_tokens):
    t0 = time.time()
    r = brt.converse(
        modelId=modelo,
        system=system,
        messages=[{"role": "user", "content": [{"text": pregunta}]}],
        inferenceConfig={"maxTokens": max_tokens, "temperature": 0},
    )
    u = r["usage"]
    lectura = u.get("cacheReadInputTokens", 0)
    escritura = u.get("cacheWriteInputTokens", 0)
    p = PRECIOS[modelo]
    coste = (
        (u["inputTokens"] + escritura) * p["in"]
        + lectura * p["in"] * FACTOR_LECTURA_CACHE
        + u["outputTokens"] * p["out"]
    ) / 1_000_000
    return {
        "entrada": u["inputTokens"] + lectura + escritura,
        "cache_lectura": lectura,
        "salida": u["outputTokens"],
        "latencia_ms": r["metrics"]["latencyMs"],
        "reloj_ms": int((time.time() - t0) * 1000),
        "coste": coste,
        "stop": r["stopReason"],
    }


def estrategia(nombre, modelo, construir_system, max_tokens, sufijo=""):
    filas = [invocar(modelo, construir_system(sec), preg + sufijo, max_tokens) for sec, preg in PREGUNTAS]
    total = {k: sum(f[k] for f in filas) for k in ("entrada", "cache_lectura", "salida", "coste")}
    total["latencia_media"] = sum(f["latencia_ms"] for f in filas) / len(filas)
    total["truncadas"] = sum(1 for f in filas if f["stop"] == "max_tokens")
    return nombre, total


completo = lambda _sec: [{"text": SISTEMA}, {"text": MANUAL}]
con_cache = lambda _sec: [{"text": SISTEMA}, {"text": MANUAL}, {"cachePoint": {"type": "default"}}]
recortado = lambda sec: [{"text": SISTEMA}, {"text": SECCIONES[sec]}]

# Estimación previa con CountTokens (gratis). No todos los modelos la admiten.
try:
    ct = brt.count_tokens(
        modelId=LITE,
        input={"converse": {"system": completo(0), "messages": [{"role": "user", "content": [{"text": "hola"}]}]}},
    )
    print("CountTokens del prompt completo:", ct["inputTokens"])
except Exception as e:  # el modelo puede no admitir CountTokens
    print("CountTokens no disponible para este modelo:", type(e).__name__)

resultados = [
    estrategia("1 Base (Lite)", LITE, completo, 800),
    estrategia("2 Modelo pequeño (Micro)", MICRO, completo, 800),
    estrategia("3 Prompt caching (Lite)", LITE, con_cache, 800),
    estrategia("4 Response limiting (Lite)", LITE, completo, 120, " Responde en una sola frase."),
    estrategia("5 Context pruning (Lite)", LITE, recortado, 800),
]

print(f"\n{'Estrategia':32} {'Entrada':>8} {'Caché':>7} {'Salida':>7} {'Lat. ms':>8} {'Trunc.':>6} {'USD':>11}")
for nombre, t in resultados:
    print(f"{nombre:32} {t['entrada']:>8} {t['cache_lectura']:>7} {t['salida']:>7} "
          f"{t['latencia_media']:>8.0f} {t['truncadas']:>6} {t['coste']:>11.7f}")

base = resultados[0][1]["coste"]
print(f"\nEstimación de la estrategia base con batch inference: {base * FACTOR_BATCH:.7f} USD")
for nombre, t in resultados[1:]:
    print(f"{nombre:32} ahorro frente a la base: {100 * (1 - t['coste'] / base):5.1f} %")
print("\nProyección a 1 millón de preguntas al mes (USD):")
for nombre, t in resultados:
    print(f"  {nombre:32} {t['coste'] / len(PREGUNTAS) * 1_000_000:10.2f}")

Paso 3: ejecuta y analiza

python3 coste.py

Fíjate en:

  • Columna Entrada: la estrategia 5 envía una fracción de los tokens; la 3 envía los mismos, pero parte se lee de caché.
  • Columna Caché: en la estrategia 3, la primera pregunta escribe la caché y las siguientes deberían leerla. Nova también aplica implicit caching, así que es posible que veas lecturas de caché incluso en otras estrategias: es best effort y no está garantizado.
  • Columna Trunc.: si la estrategia 4 muestra respuestas con stopReason = max_tokens, el límite es demasiado agresivo. Ahorrar cortando respuestas útiles no es ahorrar.
  • Latencia: el modelo pequeño y el contexto recortado deberían ser los más rápidos. Ejecuta el script dos veces: la latencia varía.
  • Proyección: multiplica por un millón de preguntas para ver las diferencias en dinero real.

Paso 4 (opcional): revisa las métricas en CloudWatch

Pasados unos minutos, abre CloudWatch → Metrics → AWS/Bedrock → ModelId y consulta InputTokenCount, OutputTokenCount, CacheReadInputTokenCount e InvocationLatency de los dos perfiles. Son las mismas cifras que has calculado, agregadas por modelo.

Comprueba que funciona

  • El script imprime la tabla con cinco filas y costes distintos de cero.
  • La estrategia 2 (Micro) cuesta aproximadamente la mitad que la 1 por token (0,046 frente a 0,078 de entrada).
  • La estrategia 5 envía muchos menos tokens de entrada que la 1.
  • En la estrategia 3, Caché es mayor que cero en al menos alguna pregunta. Si es cero, revisa que el cachePoint va después del manual y que el manual supera el mínimo de tokens del modelo.

Limpieza

Este lab no crea recursos con coste fijo. Borra el script si quieres:

rm -f coste.py

Preguntas para pensar como arquitecto

  1. La estrategia 5 (context pruning) ha sido la más barata. ¿Qué riesgo introduce en una aplicación real y cómo lo mitigarías?
Respuesta

Si la selección de la sección falla, el modelo no tiene la información y puede alucinar o decir que no sabe. En producción la selección la hace la recuperación (búsqueda vectorial con reranking y filtros de metadatos) y se vigila con métricas de relevancia del contexto (módulo 10). Conviene combinarla con instrucciones de «si no está en el contexto, dilo».

  1. ¿Cuándo no compensaría el prompt caching aunque el manual sea largo?
Respuesta

Cuando las peticiones con el mismo prefijo llegan con más de 5 minutos de separación (el TTL caduca y cada petición paga la escritura; en Claude la escritura cuesta 1,25 veces la entrada), cuando el prefijo cambia en cada petición o cuando la carga va por batch inference, que no admite caché.

  1. El equipo quiere procesar por la noche 500.000 tickets con la estrategia base. ¿Qué cambiarías?
Respuesta

Batch inference: ficheros JSONL en S3 y CreateModelInvocationJob, con un 50 % de descuento. Además, recortar el contexto (solo la sección relevante) y usar Nova Micro si la evaluación de calidad lo permite. Batch no admite tool calling ni prompt caching; sobre structured outputs, la documentación de Bedrock se contradice a 01/10/2026 (la página de structured outputs lo marca como admitido y la de batch inference dice que no admite response_format), así que valida siempre el JSON de salida.

  1. ¿Por qué el script usa temperature = 0 para comparar?
Respuesta

Para reducir la variabilidad de las respuestas entre estrategias y que la longitud de salida (y, por tanto, el coste) dependa sobre todo de la estrategia y no del azar del muestreo. Aun así, los resultados no son totalmente deterministas.


Volver al módulo