Lab práctico · Semana 6: Despliegue de modelos, integración empresarial y herramientas de desarrollo

App de IA generativa serverless con API Gateway, Lambda y Bedrock

⏱ 75-90 minDificultad: mediaTask statements: 2.22.42.5

Qué vas a construir

Una API de resúmenes lista para integrarse en cualquier sistema de la empresa: POST /resumen recibe un texto y devuelve un resumen generado por Amazon Nova Micro en Bedrock. La montarás con AWS CloudFormation y aplicarás los patrones del módulo 06:

  • Validación de peticiones en API Gateway con un esquema JSON (la petición mala no llega a Lambda ni gasta tokens).
  • API key y plan de uso con throttling y cuota diaria por cliente.
  • Lambda como capa de invocación on-demand: construye el prompt, limita maxTokens, reintenta con backoff y traduce el throttling del modelo a HTTP 429.
  • Mínimo privilegio: la función solo puede invocar un modelo.
  • Observabilidad: logs estructurados con tokens y latencia, y trazas de AWS X-Ray.
flowchart LR
    C["Cliente (curl)"] -->|"x-api-key"| APIGW["API Gateway REST: validación, plan de uso"]
    APIGW -->|"AWS_PROXY"| L["Lambda resumen (Python 3.13)"]
    L -->|"Converse, maxTokens 400"| BR["Bedrock: eu.amazon.nova-micro-v1:0"]
    L --> CW["CloudWatch Logs: tokens y latencia"]
    APIGW -.-> XR["AWS X-Ray"]
    L -.-> XR

Antes de empezar

  • Cuenta de AWS (sirve el Free plan) y una identidad con permisos para CloudFormation, IAM, Lambda, API Gateway, S3 y Bedrock.
  • Región eu-central-1 (Fráncfort). El perfil de inferencia eu.amazon.nova-micro-v1:0 procesa las peticiones en regiones de la UE.
  • Coste esperado: cada resumen usa unos cientos de tokens; con Nova Micro en Fráncfort (estimación a 1/10/2026: 0,046 USD por millón de tokens de entrada y 0,184 USD por millón de salida) cien peticiones cuestan menos de 0,01 USD. API Gateway, Lambda y X-Ray, con este volumen, céntimos como mucho. Máximo razonable: 0,05 USD.
  • Recuerda del SAA-C03: en una integración Lambda proxy (AWS_PROXY), API Gateway pasa la petición entera a la función y devuelve lo que la función responda.

Paso 1: crea la carpeta y el código de la función

En CloudShell (Fráncfort):

mkdir -p ~/lab11/src ~/lab11/tests/unit && cd ~/lab11

Crea la función. Lee los comentarios: cada decisión responde a una skill del temario.

cat > src/app.py <<'EOF'
import json
import logging
import os

import boto3
from botocore.config import Config
from botocore.exceptions import ClientError

MODEL_ID = os.environ.get("MODEL_ID", "eu.amazon.nova-micro-v1:0")
MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "400"))
SYSTEM_PROMPT = (
    "Eres un asistente que resume textos en español de España. "
    "Responde solo con el resumen y no añadas datos que no estén en el texto."
)
ESTILOS = {
    "breve": "Resume el texto en dos o tres frases.",
    "vinetas": "Resume el texto en tres a cinco viñetas cortas.",
}

# Reintentos con backoff exponencial y modo adaptativo del SDK
_bedrock = boto3.client(
    "bedrock-runtime",
    config=Config(retries={"max_attempts": 4, "mode": "adaptive"}, read_timeout=25),
)
log = logging.getLogger()
log.setLevel(logging.INFO)


def construir_peticion(texto: str, estilo: str) -> dict:
    return {
        "modelId": MODEL_ID,
        "system": [{"text": SYSTEM_PROMPT}],
        "messages": [
            {
                "role": "user",
                "content": [{"text": f"{ESTILOS[estilo]}\n\n<texto>\n{texto}\n</texto>"}],
            }
        ],
        "inferenceConfig": {"maxTokens": MAX_TOKENS, "temperature": 0.2},
    }


def _respuesta(status: int, cuerpo: dict) -> dict:
    return {
        "statusCode": status,
        "headers": {"Content-Type": "application/json; charset=utf-8"},
        "body": json.dumps(cuerpo, ensure_ascii=False),
    }


def handler(event, context):
    try:
        datos = json.loads(event.get("body") or "{}")
    except json.JSONDecodeError:
        return _respuesta(400, {"error": "JSON no válido"})

    texto = datos.get("texto", "")
    estilo = datos.get("estilo", "breve")
    # Defensa en profundidad: API Gateway ya valida, pero la función no se fía
    if estilo not in ESTILOS or not 20 <= len(texto) <= 8000:
        return _respuesta(400, {"error": "Parámetros no válidos"})

    try:
        r = _bedrock.converse(**construir_peticion(texto, estilo))
    except ClientError as e:
        codigo = e.response["Error"]["Code"]
        log.warning(json.dumps({"evento": "error_bedrock", "codigo": codigo}))
        if codigo == "ThrottlingException":
            return _respuesta(429, {"error": "Demasiadas peticiones; reintenta más tarde"})
        return _respuesta(502, {"error": "El modelo no está disponible"})

    resumen = r["output"]["message"]["content"][0]["text"]
    uso = r["usage"]
    log.info(json.dumps({
        "evento": "resumen",
        "modelo": MODEL_ID,
        "inputTokens": uso["inputTokens"],
        "outputTokens": uso["outputTokens"],
        "latencyMs": r["metrics"]["latencyMs"],
        "stopReason": r["stopReason"],
    }))
    return _respuesta(200, {"resumen": resumen, "stopReason": r["stopReason"], "uso": uso})
EOF

Decisiones de diseño:

Decisión Por qué
Cliente creado fuera del handler Se reutiliza entre invocaciones (menos latencia)
retries con mode: adaptive y max_attempts: 4 Backoff exponencial y control de tasa del SDK ante ThrottlingException (skill 2.4.3)
read_timeout=25 con la función a 29 s El SDK corta antes que Lambda y API Gateway: devuelves un error controlado en vez de un timeout opaco
maxTokens desde variable de entorno Control del tamaño de respuesta y del coste (skill 2.5.1)
El texto va entre etiquetas texto Separa instrucciones de datos del usuario (ayuda contra prompt injection, módulo 07)
429 si hay throttling, 502 si falla el modelo El cliente sabe si debe reintentar
Log JSON con tokens, latencia y stopReason Consultable con CloudWatch Logs Insights (skill 2.5.6)

Paso 2: pruebas unitarias sin llamar al modelo

Las pruebas usan Stubber de botocore para simular la respuesta de Bedrock: rápidas, gratis y deterministas. Las reutilizarás en el pipeline del lab 12.

cat > tests/unit/test_app.py <<'EOF'
import json
import os
import sys

os.environ.setdefault("AWS_DEFAULT_REGION", "eu-central-1")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))

import app  # noqa: E402
from botocore.stub import ANY, Stubber  # noqa: E402

TEXTO = "Amazon Bedrock es un servicio gestionado que ofrece modelos fundacionales mediante una API."


def _evento(cuerpo):
    return {"body": json.dumps(cuerpo)}


def test_rechaza_estilo_desconocido():
    r = app.handler(_evento({"texto": TEXTO, "estilo": "poema"}), None)
    assert r["statusCode"] == 400


def test_construye_peticion_con_limite_de_tokens():
    p = app.construir_peticion(TEXTO, "breve")
    assert p["inferenceConfig"]["maxTokens"] == app.MAX_TOKENS
    assert "<texto>" in p["messages"][0]["content"][0]["text"]


def test_devuelve_resumen():
    respuesta = {
        "output": {"message": {"role": "assistant", "content": [{"text": "Bedrock da acceso a modelos."}]}},
        "stopReason": "end_turn",
        "usage": {"inputTokens": 50, "outputTokens": 8, "totalTokens": 58},
        "metrics": {"latencyMs": 300},
    }
    with Stubber(app._bedrock) as stub:
        stub.add_response("converse", respuesta, {
            "modelId": ANY, "system": ANY, "messages": ANY, "inferenceConfig": ANY,
        })
        r = app.handler(_evento({"texto": TEXTO}), None)
    assert r["statusCode"] == 200
    assert json.loads(r["body"])["resumen"] == "Bedrock da acceso a modelos."


def test_traduce_throttling_a_429():
    with Stubber(app._bedrock) as stub:
        stub.add_client_error("converse", service_error_code="ThrottlingException", http_status_code=429)
        r = app.handler(_evento({"texto": TEXTO}), None)
    assert r["statusCode"] == 429
EOF

python3 -m pip install --user --quiet pytest
AWS_DEFAULT_REGION=eu-central-1 python3 -m pytest -q tests/unit

Deben pasar las 4 pruebas. Fíjate en test_traduce_throttling_a_429: simula que Bedrock devuelve ThrottlingException y comprueba que tu API responde 429.

Paso 3: la plantilla de CloudFormation

cat > app.yaml <<'EOF'
AWSTemplateFormatVersion: '2010-09-09'
Description: Lab 11 - API de resumen con API Gateway, Lambda y Amazon Bedrock

Parameters:
  ModelId:
    Type: String
    Default: eu.amazon.nova-micro-v1:0
    Description: Perfil de inferencia (o modelo) que usa la función
  BaseModelId:
    Type: String
    Default: amazon.nova-micro-v1:0
    Description: Modelo base al que apunta el perfil de inferencia
  StageName:
    Type: String
    Default: dev

Resources:
  FunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess
      Policies:
        - PolicyName: invocar-solo-un-modelo
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: bedrock:InvokeModel
                Resource:
                  - !Sub arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/${ModelId}
                  - !Sub arn:aws:bedrock:*::foundation-model/${BaseModelId}

  SummaryFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.13
      Handler: app.handler
      Code: src/
      MemorySize: 256
      Timeout: 29
      Role: !GetAtt FunctionRole.Arn
      TracingConfig:
        Mode: Active
      Environment:
        Variables:
          MODEL_ID: !Ref ModelId
          MAX_TOKENS: '400'

  Api:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub lab11-resumen-${StageName}
      EndpointConfiguration:
        Types:
          - REGIONAL

  ResumenResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref Api
      ParentId: !GetAtt Api.RootResourceId
      PathPart: resumen

  ResumenModel:
    Type: AWS::ApiGateway::Model
    Properties:
      RestApiId: !Ref Api
      ContentType: application/json
      Name: ResumenRequest
      Schema:
        $schema: http://json-schema.org/draft-04/schema#
        type: object
        required:
          - texto
        properties:
          texto:
            type: string
            minLength: 20
            maxLength: 8000
          estilo:
            type: string
            enum:
              - breve
              - vinetas
        additionalProperties: false

  BodyValidator:
    Type: AWS::ApiGateway::RequestValidator
    Properties:
      RestApiId: !Ref Api
      Name: validar-cuerpo
      ValidateRequestBody: true
      ValidateRequestParameters: false

  ResumenPost:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref Api
      ResourceId: !Ref ResumenResource
      HttpMethod: POST
      AuthorizationType: NONE
      ApiKeyRequired: true
      RequestValidatorId: !Ref BodyValidator
      RequestModels:
        application/json: !Ref ResumenModel
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        TimeoutInMillis: 29000
        Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SummaryFunction.Arn}/invocations

  InvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref SummaryFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${Api}/*/POST/resumen

  Deployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn: ResumenPost
    Properties:
      RestApiId: !Ref Api

  ApiStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      RestApiId: !Ref Api
      DeploymentId: !Ref Deployment
      StageName: !Ref StageName
      TracingEnabled: true
      MethodSettings:
        - ResourcePath: /*
          HttpMethod: '*'
          ThrottlingRateLimit: 5
          ThrottlingBurstLimit: 10

  UsagePlan:
    Type: AWS::ApiGateway::UsagePlan
    DependsOn: ApiStage
    Properties:
      UsagePlanName: !Sub lab11-plan-${StageName}
      ApiStages:
        - ApiId: !Ref Api
          Stage: !Ref StageName
      Throttle:
        RateLimit: 2
        BurstLimit: 5
      Quota:
        Limit: 500
        Period: DAY

  ClientKey:
    Type: AWS::ApiGateway::ApiKey
    DependsOn: ApiStage
    Properties:
      Name: !Sub lab11-cliente-${StageName}
      Enabled: true

  UsagePlanKey:
    Type: AWS::ApiGateway::UsagePlanKey
    Properties:
      KeyId: !Ref ClientKey
      KeyType: API_KEY
      UsagePlanId: !Ref UsagePlan

Outputs:
  ApiUrl:
    Value: !Sub https://${Api}.execute-api.${AWS::Region}.amazonaws.com/${StageName}/resumen
  ApiKeyId:
    Value: !Ref ClientKey
  FunctionName:
    Value: !Ref SummaryFunction
EOF

Lo más importante de la plantilla:

  • FunctionRole: bedrock:InvokeModel solo sobre el ARN del perfil de inferencia eu.amazon.nova-micro-v1:0 y sobre el modelo base en cualquier región (el perfil geográfico puede procesar en varias regiones de la UE). Nada de bedrock:*. La API Converse se autoriza con la acción bedrock:InvokeModel.
  • ResumenModel + BodyValidator: API Gateway valida el cuerpo contra un JSON Schema (draft-04): texto obligatorio de 20 a 8.000 caracteres, estilo en una lista cerrada, sin campos extra. Una petición inválida recibe 400 sin invocar Lambda ni gastar tokens (skill 2.4.1: «API Gateway to provide custom API clients with request validation»).
  • ApiKeyRequired + UsagePlan: cada cliente usa su clave; el plan limita a 2 peticiones por segundo (ráfaga 5) y 500 al día. Las API keys identifican y limitan clientes; no son un mecanismo de autenticación fuerte: en producción añade IAM, un autorizador Lambda o Cognito.
  • MethodSettings del stage: throttling global del método.
  • TracingEnabled y TracingConfig: Active: trazas de X-Ray en API Gateway y Lambda.
  • Code: src/: una ruta local que aws cloudformation package sube a S3 y sustituye por la URI.

Paso 4: empaqueta y despliega

REGION=eu-central-1
CUENTA=$(aws sts get-caller-identity --query Account --output text)
BUCKET="lab11-artefactos-$CUENTA-$REGION"
aws s3 mb "s3://$BUCKET" --region $REGION

aws cloudformation package \
  --template-file app.yaml \
  --s3-bucket "$BUCKET" \
  --output-template-file packaged.yaml

aws cloudformation deploy \
  --region $REGION \
  --template-file packaged.yaml \
  --stack-name lab11 \
  --capabilities CAPABILITY_IAM

aws cloudformation describe-stacks --region $REGION --stack-name lab11 \
  --query 'Stacks[0].Outputs' --output table

Guarda la URL y la clave en variables:

URL=$(aws cloudformation describe-stacks --region $REGION --stack-name lab11 \
  --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" --output text)
KEY_ID=$(aws cloudformation describe-stacks --region $REGION --stack-name lab11 \
  --query "Stacks[0].Outputs[?OutputKey=='ApiKeyId'].OutputValue" --output text)
API_KEY=$(aws apigateway get-api-key --region $REGION --api-key "$KEY_ID" \
  --include-value --query value --output text)
echo "$URL"

Paso 5: prueba la API

Petición correcta:

curl -s -X POST "$URL" -H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"texto": "Amazon Bedrock es un servicio totalmente gestionado que ofrece modelos fundacionales de varios proveedores mediante una única API. Permite personalizarlos con datos propios y crear agentes y aplicaciones RAG sin gestionar infraestructura.", "estilo": "vinetas"}' | python3 -m json.tool

Verás el resumen, el stopReason (end_turn) y el uso de tokens.

Ahora comprueba cada control:

# 1) Sin API key: 403 de API Gateway (Lambda ni se entera)
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$URL" -H "Content-Type: application/json" \
  -d '{"texto": "Un texto de prueba suficientemente largo."}'

# 2) Cuerpo inválido (estilo desconocido): 400 del validador, sin gastar tokens
curl -s -X POST "$URL" -H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"texto": "Un texto de prueba suficientemente largo.", "estilo": "poema"}'; echo

# 3) Ráfaga de peticiones: a partir de cierto punto verás 429 del plan de uso
for i in $(seq 1 12); do
  curl -s -o /dev/null -w "%{http_code} " -X POST "$URL" -H "x-api-key: $API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"texto": "El soporte atiende de lunes a viernes de 9:00 a 18:00 por teléfono y correo."}' &
done; wait; echo

En la ráfaga, los 429 vienen de API Gateway (plan de uso), antes de llegar al modelo: así se protege la cuota de Bedrock y el presupuesto.

Paso 6: observa tokens, latencia y trazas

En CloudWatch → Logs Insights, elige el grupo /aws/lambda/ de la función (su nombre sale en la salida FunctionName) y ejecuta:

fields @timestamp, modelo, inputTokens, outputTokens, latencyMs, stopReason
| filter evento = "resumen"
| stats count(*) as peticiones, avg(inputTokens) as entrada_media,
        avg(outputTokens) as salida_media, avg(latencyMs) as latencia_media

Después abre X-Ray (en CloudWatch, Trace Map o Traces): verás el recorrido API Gateway → Lambda → llamada a Bedrock y cuánto tiempo aporta cada tramo. Es la herramienta para responder «¿dónde está la latencia?» (skills 2.4.3 y 2.5.6).

Comprueba que funciona

  • Una petición válida devuelve 200 con resumen y tokens.
  • Sin API key: 403. Con estilo inválido: 400. En la ráfaga aparecen 429.
  • Logs Insights muestra peticiones, tokens medios y latencia media.
  • X-Ray muestra la traza de extremo a extremo.

Limpieza

Borra el stack y el bucket de artefactos. Conserva la carpeta ~/lab11: el lab 12 reutiliza el código.

REGION=eu-central-1
CUENTA=$(aws sts get-caller-identity --query Account --output text)
FUNCION=$(aws cloudformation describe-stacks --region $REGION --stack-name lab11 \
  --query "Stacks[0].Outputs[?OutputKey=='FunctionName'].OutputValue" --output text)

aws cloudformation delete-stack --region $REGION --stack-name lab11
aws cloudformation wait stack-delete-complete --region $REGION --stack-name lab11

aws logs delete-log-group --region $REGION --log-group-name "/aws/lambda/$FUNCION"
aws s3 rb "s3://lab11-artefactos-$CUENTA-$REGION" --force

El grupo de logs de Lambda no lo crea CloudFormation (lo crea Lambda al escribir el primer log), por eso se borra a mano.

Preguntas para pensar como arquitecto

1. Los usuarios piden resúmenes de documentos largos que tardan más de 29 segundos. ¿Qué opciones tienes?

Tres, según la experiencia que quieras: response streaming de API Gateway (modo STREAM en la integración proxy, hasta 15 minutos, con la Lambda emitiendo fragmentos de ConverseStream; en Python necesitas Lambda Web Adapter o un runtime personalizado, en Node.js es nativo); una WebSocket API que empuje los fragmentos; o un patrón asíncrono: la API responde 202 con un id, la petición va a SQS, una Lambda la procesa y el cliente consulta el resultado o recibe un aviso. Para lotes grandes sin prisa, Bedrock batch inference.

2. Marketing quiere probar Nova Lite en lugar de Nova Micro sin que desarrollo despliegue código. ¿Cómo?

Saca el identificador del modelo (y maxTokens, temperatura, versión del prompt) a AWS AppConfig y léelo con la extensión del AppConfig Agent en Lambda (con caché). El cambio se despliega con una estrategia gradual y rollback automático si salta una alarma de CloudWatch, y con un perfil de feature flags puedes activarlo solo para un porcentaje. No olvides ampliar la política IAM al nuevo modelo: el mínimo privilegio también se gobierna.

3. Un pico de tráfico provoca ThrottlingException de Bedrock aunque API Gateway limita cada cliente. ¿Qué harías?

El plan de uso limita por cliente, no el total. Opciones: bajar el throttling global del stage por debajo de la cuota de tokens por minuto del modelo; poner SQS delante con concurrencia máxima de Lambda para suavizar; usar un perfil de inferencia que reparta entre regiones (geográfico, si hay requisitos de residencia); pedir aumento de cuota; y, si el tráfico es alto y constante, evaluar Provisioned Throughput. Los reintentos con backoff del SDK ayudan con picos breves, no con saturación sostenida.

4. ¿Por qué validar la petición en API Gateway si la Lambda ya valida?

Defensa en profundidad y coste. El validador de API Gateway rechaza lo mal formado antes de ejecutar la función (no pagas Lambda ni tokens y reduces superficie de ataque). La validación en Lambda protege si alguien invoca la función por otra vía o si el esquema de la API cambia sin querer.

5. Otro equipo quiere la misma capacidad de resumen para su aplicación. ¿Les das acceso directo a Bedrock?

Mejor exponerla como microservicio o dentro de un gateway de IA central: una clave y un plan de uso por equipo (cuotas y throttling propios), mismas plantillas de prompt, mismos guardrails, métricas de coste por consumidor y un solo punto para cambiar de modelo. Dar bedrock:InvokeModel a cada equipo dispersa el control, el coste y la auditoría.


Volver al módulo