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
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:0procesa 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:InvokeModelsolo sobre el ARN del perfil de inferenciaeu.amazon.nova-micro-v1:0y sobre el modelo base en cualquier región (el perfil geográfico puede procesar en varias regiones de la UE). Nada debedrock:*. La API Converse se autoriza con la acciónbedrock:InvokeModel.ResumenModel+BodyValidator: API Gateway valida el cuerpo contra un JSON Schema (draft-04):textoobligatorio de 20 a 8.000 caracteres,estiloen 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.MethodSettingsdel stage: throttling global del método.TracingEnabledyTracingConfig: Active: trazas de X-Ray en API Gateway y Lambda.Code: src/: una ruta local queaws cloudformation packagesube 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
estiloinvá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.