AWS Builder Center
De Script Caótico a App de IA

De Script Caótico a App de IA

Hay un momento muy específico en el ciclo de vida de todo desarrollador que trabaja con LLMs. Es el momento en que el script de cinco líneas que escribiste un martes por la tarde se convierte en algo que nadie entiende, que falla de formas misteriosas, y que un día te manda una factura que no esperabas. No voy a hablar de LangChain, ni llamadaindex, ni agentes autonomos, voy a hablar de Python Basico, de estructura de proyecto, y de tres problemas concretos que van a aprender en cualquier proyecto LLM

El punto de partida: anatomía del caos

Este es el código que todos escribimos la primera vez. Reconocerás los comentarios:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import openai
import os

# 🚨 API key hardcodeada (o peor, commiteada en git)
openai.api_key = "sk-abc123..." # TODO: sacar esto de aquí

def resumir(texto):
response = openai.chat.completions.create(
model="gpt-4o-mini", # 🚨 Modelo hardcodeado
messages=[
{
"role": "system",
"content": "Eres un asistente que resume textos. Hazlo en español, "
"máximo 3 oraciones. Si el texto es técnico, simplifica. "
"No uses bullet points. Sé conciso pero no pierdas información "
"importante. Si hay datos numéricos, inclúyelos."
# 🚨 Prompt gigante inline que nadie va a mantener
},
{"role": "user", "content": texto}
]
)
return response.choices[0].message.content

# 🚨 Sin manejo de errores
# 🚨 Sin control de costos
# 🚨 Sin logging
# 🚨 Sin retry en caso de fallo

resultado = resumir("Python es un lenguaje de programación...")
print(resultado)

# Después agregas otra función y copias todo el patrón...
def traducir(texto):
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Eres un traductor. Traduce al inglés. "
"Mantén el tono original. No agregues explicaciones."
# 🚨 Otro prompt inline, duplicación de patrones
},
{"role": "user", "content": texto}
]
)
return response.choices[0].message.content
El problema no es que este código sea feo. El problema es lo que pasa cuando crece. Cada nueva función duplica el mismo patrón. Cada cambio de modelo requiere buscar y reemplazar en diez lugares. Cada ajuste de prompt requiere tocar código Python. Y cuando algo falla, no tienes ni logging ni manejo de errores, así que te quedas adivinando.
Vamos a arreglarlo en tres pasos.

Paso 1: Los prompts son configuración, no código

Por qué importa

Hay una regla simple en ingeniería de software: las cosas que cambian por razones distintas deben estar separadas. Tu lógica de negocio cambia cuando cambian los requisitos funcionales. Tus prompts cambian cuando iteras sobre el comportamiento del modelo, cuando alguien de producto quiere ajustar el tono, cuando una nueva versión del modelo responde diferente a la misma instrucción.
Si están mezclados, cada cambio de prompt es un cambio de código. Necesita code review. Necesita un desarrollador. Es ilegible en el git diff. No se puede testear de forma aislada.
La solución es tratar los prompts como lo que realmente son: archivos de configuración.

La estructura

1
2
3
4
5
proyecto/
├── prompts/
│ ├── resumir.txt
│ └── traducir.txt
└── app.py
El archivo prompts/resumir.txt:
1
2
3
4
5
6
7
8
9
Eres un asistente que resume textos.

Reglas:
- Responde en español
- Máximo 3 oraciones
- Si el texto es técnico, simplifica el lenguaje
- No uses bullet points
- Sé conciso pero no pierdas información importante
- Si hay datos numéricos, inclúyelos
El archivo prompts/traducir.txt:
1
2
3
4
5
6
7
Eres un traductor profesional.

Reglas:
- Traduce al inglés
- Mantén el tono original del texto
- No agregues explicaciones ni notas
- Si hay términos técnicos sin traducción directa, déjalos en el idioma original

El cargador

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from pathlib import Path
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

client = OpenAI() # Lee OPENAI_API_KEY del entorno automáticamente

PROMPTS_DIR = Path(__file__).parent / "prompts"

def cargar_prompt(nombre: str, **variables) -> str:
"""
Carga un prompt desde archivo y reemplaza variables.

Uso:
cargar_prompt("resumir")
cargar_prompt("analizar", idioma="español", max_palabras=100)
"""

archivo = PROMPTS_DIR / f"{nombre}.txt"

if not archivo.exists():
raise FileNotFoundError(f"Prompt no encontrado: {archivo}")

contenido = archivo.read_text(encoding="utf-8")

if variables:
contenido = contenido.format(**variables)

return contenido

def llamar_llm(prompt_nombre: str, texto_usuario: str, **prompt_vars) -> str:
system_prompt = cargar_prompt(prompt_nombre, **prompt_vars)

response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": texto_usuario},
],
)
return response.choices[0].message.content

# El uso queda limpio
if __name__ == "__main__":
texto = "Python fue creado por Guido van Rossum en 1991..."

resumen = llamar_llm("resumir", texto)
print(resumen)

traduccion = llamar_llm("traducir", resumen)
print(traduccion)

Variables dinámicas en prompts

A veces el prompt necesita adaptarse en tiempo de ejecución. El cargador usa str.format(), así que esto simplemente funciona:
Archivo prompts/analizar.txt:
1
2
3
4
5
Eres un analista de texto.

Analiza el siguiente contenido y responde en {idioma}.
Tu respuesta no debe superar {max_palabras} palabras.
Enfócate en: {enfoque}.
Uso:
1
2
3
4
5
6
7
resultado = llamar_llm(
"analizar",
texto,
idioma="español",
max_palabras=150,
enfoque="aspectos técnicos y rendimiento"
)
Para casos con lógica condicional dentro del prompt (si A muestra X, si B muestra Y), puedes usar Jinja2. Pero para el 90% de los casos, str.format() es más que suficiente y no agrega dependencias.

Qué ganas concretamente

Git diff legible. Cuando cambias un prompt en un archivo .txt, el diff muestra exactamente qué palabras cambiaron. Cuando el prompt es un string inline, el diff muestra cambios de indentación, comillas escapadas y el contexto del código Python alrededor.
Edición sin código. Alguien de producto, diseño o contenido puede abrir resumir.txt, cambiar "máximo 3 oraciones" por "máximo 2 oraciones", y hacer un PR. No necesitan entender Python. No necesitan contexto del código.
Testing aislado. Puedes escribir un script que tome 20 inputs de ejemplo, los corra contra el prompt, y evalúe si los outputs son correctos, todo sin levantar la aplicación completa.
Sin duplicación. Si la misma instrucción aparece en tres funciones distintas, ahora vive en un solo archivo. Cambias una vez, afecta todos los lugares que lo usan.

Paso 2: Estructura que escala

Cuando el proyecto crece más allá de dos o tres funciones, un archivo único empieza a acumular responsabilidades que no le corresponden. La solución es la misma que en cualquier proyecto de software: separación de responsabilidades.
1
2
3
4
5
6
7
8
proyecto/
├── config.py → Variables de entorno y parámetros
├── llm_client.py → Toda la comunicación con la API
├── prompts.py → Registro y carga de prompts
├── prompts/
│ ├── resumir.txt
│ └── traducir.txt
└── app.py → Lógica de negocio, limpia
Cada archivo tiene una razón para cambiar. config.py cambia cuando cambias de modelo o ajustas parámetros. llm_client.py cambia cuando cambias de proveedor o agregas funcionalidades de resiliencia. prompts.py cambia cuando agregas nuevos prompts o versiones. app.py cambia cuando cambia la lógica de tu aplicación.

config.py

1
2
3
4
5
6
7
8
9
10
11
12
import os
from dotenv import load_dotenv

load_dotenv()

class Config:
OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
MODEL_NAME: str = os.getenv("MODEL_NAME", "gpt-4o-mini")
MAX_RETRIES: int = 3
TIMEOUT_SECONDS: int = 30
TEMPERATURE: float = 0.7
MAX_TOKENS: int = 1024
Todo en un lugar. Si mañana quieres cambiar el modelo por defecto, tocas un archivo. Si quieres que sea configurable por entorno (dev usa mini, prod usa 4o), agregas una variable de entorno y listo.

llm_client.py — donde vive la resiliencia

Este es el archivo más importante de la estructura. Encapsula todo lo que tiene que ver con la comunicación con la API: los reintentos, el manejo de errores, el logging, los timeouts. Tu código de negocio no sabe nada de esto.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import logging
import time
from openai import OpenAI, APIError, RateLimitError, APITimeoutError
from config import Config

logger = logging.getLogger(__name__)

class LLMClient:

def __init__(self):
self.client = OpenAI(api_key=Config.OPENAI_API_KEY)
self.model = Config.MODEL_NAME

def completar(
self,
system_prompt: str,
user_message: str,
temperature: float | None = None,
max_tokens: int | None = None,
) -> str:
temperature = temperature or Config.TEMPERATURE
max_tokens = max_tokens or Config.MAX_TOKENS

for intento in range(1, Config.MAX_RETRIES + 1):
try:
logger.info(
"Llamada al LLM (intento %d/%d, modelo=%s)",
intento, Config.MAX_RETRIES, self.model,
)

response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
temperature=temperature,
max_tokens=max_tokens,
timeout=Config.TIMEOUT_SECONDS,
)

resultado = response.choices[0].message.content
logger.info("Respuesta recibida (%d caracteres)", len(resultado))
return resultado

except RateLimitError:
espera = 2 ** intento # 2s, 4s, 8s
logger.warning("Rate limit. Esperando %ds...", espera)
time.sleep(espera)

except APITimeoutError:
logger.warning("Timeout en intento %d", intento)

except APIError as e:
logger.error("Error de API: %s", e)
if intento == Config.MAX_RETRIES:
raise

raise RuntimeError(
f"Llamada fallida tras {Config.MAX_RETRIES} intentos"
)

Entendiendo el backoff exponencial

El patrón espera = 2 ** intento no es arbitrario. Cuando recibes un RateLimitError, la API te está diciendo que estás enviando demasiadas solicitudes. Si reintentaras inmediatamente, solo empeorarías el problema.
Con backoff exponencial:
  • Intento 1 falla → espera 2 segundos
  • Intento 2 falla → espera 4 segundos
  • Intento 3 falla → espera 8 segundos
Cada espera le da tiempo a la API para recuperarse. En sistemas de alta concurrencia se agrega también un componente aleatorio (jitter) para evitar que múltiples instancias reintentan exactamente al mismo tiempo, pero para la mayoría de los casos el backoff simple funciona.

prompts.py — registro con versionado

El registro centralizado te da un lugar único para ver todos los prompts del sistema, con sus versiones:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from pathlib import Path
from dataclasses import dataclass

@dataclass
class Prompt:
nombre: str
version: str
template: str

def render(self, **variables) -> str:
return self.template.format(**variables)

PROMPTS_DIR = Path(__file__).parent / "prompts"

def _cargar(nombre: str) -> str:
return (PROMPTS_DIR / f"{nombre}.txt").read_text(encoding="utf-8")

# Un solo lugar para ver todos los prompts del sistema
REGISTRO: dict[str, Prompt] = {
"resumir": Prompt(
nombre="resumir",
version="1.2",
template=_cargar("resumir"),
),
"traducir": Prompt(
nombre="traducir",
version="1.0",
template=_cargar("traducir"),
),
}

def obtener_prompt(nombre: str, **variables) -> str:
if nombre not in REGISTRO:
disponibles = ", ".join(REGISTRO.keys())
raise KeyError(f"Prompt '{nombre}' no existe. Disponibles: {disponibles}")

return REGISTRO[nombre].render(**variables)
El versionado en el registro es simple pero útil: cuando un prompt cambia de comportamiento, incrementas la versión. Así puedes loguear qué versión del prompt produjo cada respuesta, lo que facilita enormemente el debugging cuando algo falla en producción.

app.py — lógica de negocio sin ruido

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import logging
import sys
from llm_client import LLMClient
from prompts import obtener_prompt

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
stream=sys.stderr,
)

def resumir(client: LLMClient, texto: str) -> str:
return client.completar(
system_prompt=obtener_prompt("resumir"),
user_message=texto,
)

def traducir(client: LLMClient, texto: str) -> str:
return client.completar(
system_prompt=obtener_prompt("traducir"),
user_message=texto,
)

if __name__ == "__main__":
client = LLMClient()
texto = "Python fue creado por Guido van Rossum en 1991..."

resumen = resumir(client, texto)
print(resumen)

traduccion = traducir(client, resumen)
print(traduccion)
Este archivo no sabe que existe openai. No sabe cuántos reintentos se hacen. No sabe qué modelo se usa. Solo describe la lógica de negocio: resumir un texto, traducir un texto. Eso es todo lo que debería hacer.

Pregunta frecuente: ¿cuándo es necesaria esta estructura?

No el día uno. Si estás explorando, prototipando, o construyendo algo para uso personal, el script en un archivo es perfectamente válido. La estructura paga su costo cuando:
  • Hay más de una persona trabajando en el proyecto
  • El proyecto va a estar en producción
  • Tienes más de 3-4 prompts distintos
  • Necesitas poder cambiar de modelo o proveedor
  • Quieres testear la lógica de negocio de forma aislada
La transición tampoco tiene que ser todo o nada. Puedes empezar sacando solo los prompts a archivos. Luego agregar el cliente. Luego la config. Cada paso tiene valor independiente.

Pregunta frecuente: ¿y si quiero cambiar de OpenAI a Anthropic o a un modelo propio?

Con esta estructura, el cambio está contenido en llm_client.py. El resto del código ni se entera. Solo cambias el cliente, ajustas el formato de mensajes si el proveedor lo requiere, y listo. Sin tocar prompts, sin tocar lógica de negocio, sin tocar configuración.

Paso 3: No quedarte sin presupuesto

Esta es la parte que duele cuando la ignoras.
Dejé un script corriendo con GPT-4. Me fui a almorzar. Volví con $47 USD menos en una hora.
No es una historia inventada. Les pasa a desarrolladores que saben perfectamente lo que están haciendo. El problema no es la ignorancia: es la ausencia de guardarraíles.

Los números reales

Antes de hablar de soluciones, los precios actuales por millón de tokens:
ModeloInputOutput
gpt-4o-mini$0.15$0.60
gpt-4o$2.50$10.00
gpt-4.1-mini$0.40$1.60
gpt-4.1-nano$0.10$0.40
Un resumen de un párrafo corto cuesta aproximadamente $0.00005. Mil llamadas con gpt-4o-mini cuestan alrededor de $0.15. El problema no es el costo unitario — es cuando procesas datasets enteros, cuando dejas loops corriendo sin control, o cuando usas gpt-4o para tareas que gpt-4o-mini resuelve igual de bien.
La diferencia entre mini y 4o es de 16x en precio. Para la mayoría de tareas de resumen, traducción, extracción de datos y clasificación, gpt-4o-mini da resultados equivalentes o indistinguibles.

El tracker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from dataclasses import dataclass, field

import tiktoken

logger = logging.getLogger(__name__)

PRECIOS: dict[str, dict[str, float]] = {
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"gpt-4o": {"input": 2.50, "output": 10.00},
"gpt-4.1-mini": {"input": 0.40, "output": 1.60},
"gpt-4.1-nano": {"input": 0.10, "output": 0.40},
}

@dataclass
class LlamadaRegistro:
timestamp: str
modelo: str
tokens_input: int
tokens_output: int
costo_usd: float
prompt_nombre: str = ""

@dataclass
class CostTracker:

presupuesto_mensual_usd: float = 5.0
archivo_log: Path = field(default_factory=lambda: Path("costos_log.json"))
_registros: list[LlamadaRegistro] = field(default_factory=list)

def __post_init__(self):
self._cargar_registros()

def verificar_presupuesto(self):
disponible = self.presupuesto_mensual_usd - self.gasto_mes_actual()
if disponible <= 0:
raise PresupuestoExcedido(
f"Presupuesto mensual agotado. "
f"Gastado: ${self.gasto_mes_actual():.4f} / "
f"${self.presupuesto_mensual_usd:.2f}"
)
logger.info("Presupuesto disponible: $%.4f", disponible)

def registrar_llamada(
self,
modelo: str,
tokens_input: int,
tokens_output: int,
prompt_nombre: str = "",
) -> float:
costo = self._calcular_costo(modelo, tokens_input, tokens_output)

registro = LlamadaRegistro(
timestamp=datetime.now(timezone.utc).isoformat(),
modelo=modelo,
tokens_input=tokens_input,
tokens_output=tokens_output,
costo_usd=costo,
prompt_nombre=prompt_nombre,
)

self._registros.append(registro)
self._guardar_registros()

logger.info(
"Costo: $%.6f | Tokens: %d in / %d out | Total mes: $%.4f",
costo, tokens_input, tokens_output, self.gasto_mes_actual(),
)

return costo

def gasto_mes_actual(self) -> float:
mes = datetime.now(timezone.utc).strftime("%Y-%m")
return sum(r.costo_usd for r in self._registros if r.timestamp.startswith(mes))

def _calcular_costo(self, modelo: str, tokens_input: int, tokens_output: int) -> float:
precios = PRECIOS.get(modelo, PRECIOS["gpt-4o-mini"])
return (
(tokens_input / 1_000_000) * precios["input"] +
(tokens_output / 1_000_000) * precios["output"]
)

def _cargar_registros(self):
if self.archivo_log.exists():
data = json.loads(self.archivo_log.read_text(encoding="utf-8"))
self._registros = [LlamadaRegistro(**r) for r in data]

def _guardar_registros(self):
self.archivo_log.write_text(
json.dumps([r.__dict__ for r in self._registros], indent=2, ensure_ascii=False),
encoding="utf-8",
)

def resumen(self) -> str:
gasto = self.gasto_mes_actual()
total_llamadas = len([
r for r in self._registros
if r.timestamp.startswith(datetime.now(timezone.utc).strftime("%Y-%m"))
])
return (
f"📊 Resumen mes actual\n"
f" Llamadas: {total_llamadas}\n"
f" Gastado: ${gasto:.4f}\n"
f" Disponible: ${self.presupuesto_mensual_usd - gasto:.4f}\n"
f" Presupuesto: ${self.presupuesto_mensual_usd:.2f}"
)

def contar_tokens(texto: str, modelo: str = "gpt-4o-mini") -> int:
"""Cuenta tokens localmente antes de hacer la llamada, con tiktoken."""
try:
encoding = tiktoken.encoding_for_model(modelo)
except KeyError:
encoding = tiktoken.get_encoding("cl100k_base")
return len(encoding.encode(texto))

class PresupuestoExcedido(Exception):
pass

Integrando el tracker en el cliente

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class LLMClientConCostos:

def __init__(self, presupuesto_mensual: float = 5.0):
self.client = OpenAI()
self.model = Config.MODEL_NAME
self.tracker = CostTracker(
presupuesto_mensual_usd=presupuesto_mensual,
archivo_log=Path("costos_log.json"),
)

def completar(
self,
system_prompt: str,
user_message: str,
prompt_nombre: str = "",
) -> str:

# 1. Verificar presupuesto antes de gastar
self.tracker.verificar_presupuesto()

# 2. Estimar tokens de entrada (opcional, para logs tempranos)
tokens_estimados = contar_tokens(system_prompt + user_message, self.model)
logger.info("Tokens estimados de entrada: %d", tokens_estimados)

# 3. Hacer la llamada
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
)

# 4. Registrar el costo real con los tokens que reporta la API
self.tracker.registrar_llamada(
modelo=self.model,
tokens_input=response.usage.prompt_tokens,
tokens_output=response.usage.completion_tokens,
prompt_nombre=prompt_nombre,
)

return response.choices[0].message.content

if __name__ == "__main__":
client = LLMClientConCostos(presupuesto_mensual=5.0)

try:
resultado = client.completar(
system_prompt="Resume en máximo 2 oraciones en español.",
user_message="Python fue creado por Guido van Rossum en 1991...",
prompt_nombre="resumir",
)
print(resultado)
print(f"\n{client.tracker.resumen()}")

except PresupuestoExcedido as e:
print(f"⛔ {e}")

¿Por qué usar los tokens de la respuesta y no los estimados?

La API de OpenAI devuelve en response.usage los tokens exactos que se consumieron: prompt_tokens (input) y completion_tokens (output). Estos son los números que OpenAI usa para cobrar, así que son los que debes usar para calcular el costo real.
tiktoken es útil para estimar antes de hacer la llamada — por ejemplo, para rechazar inputs que sean demasiado largos o costosos antes de gastar. Pero para el registro final, siempre usa los tokens del response.

Pregunta frecuente: ¿esto reemplaza los límites del dashboard de OpenAI?

No, y no debería intentarlo. Los límites del dashboard son tu red de seguridad final: si todo lo demás falla, OpenAI corta el gasto. El tracker en código te da algo diferente: visibilidad granular en tiempo real. Sabes cuánto gastó cada función, qué prompts son los más caros, en qué momento del mes se concentra el gasto. Los dos mecanismos son complementarios.

Pregunta frecuente: ¿JSON en disco escala?

Para un solo proceso, perfectamente. Para múltiples instancias concurrentes, necesitas un almacenamiento que maneje concurrencia: una base de datos SQLite con transacciones, PostgreSQL, o incluso una tabla en DynamoDB si estás en AWS. El diseño del tracker es el mismo — solo cambia el backend de persistencia.
La recomendación: empieza con JSON. Cuando tengas el problema de concurrencia, ya sabrás exactamente qué necesitas cambiar.

Bonus: cómo testear prompts

Una de las ventajas de tener prompts en archivos separados es que puedes testearlos de forma sistemática. Esto no requiere ningún framework especial:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# test_prompts.py
from app import llamar_llm

CASOS_RESUMIR = [
{
"input": "Python es un lenguaje interpretado de alto nivel...",
"verificar": lambda r: len(r.split(".")) <= 4, # máximo 3 oraciones
"descripcion": "no supera 3 oraciones",
},
{
"input": "El PIB de Chile creció un 2.3% en 2024...",
"verificar": lambda r: "2.3" in r, # incluye datos numéricos
"descripcion": "incluye datos numéricos",
},
{
"input": "La función `async/await` en Python...",
"verificar": lambda r: "async" in r.lower() or "asíncrono" in r.lower(),
"descripcion": "mantiene términos técnicos",
},
]

def test_prompt_resumir():
for caso in CASOS_RESUMIR:
resultado = llamar_llm("resumir", caso["input"])
assert caso["verificar"](resultado), (
f"Falló: {caso['descripcion']}\n"
f"Input: {caso['input'][:50]}...\n"
f"Output: {resultado}"
)
print(f"✅ {caso['descripcion']}")

if __name__ == "__main__":
test_prompt_resumir()
Esto no es un test unitario en el sentido clásico — tiene costo, tiene latencia, y los LLMs son no deterministas. Pero es exactamente el tipo de evaluación que necesitas antes de cambiar un prompt en producción. Corres el script, ves qué casos pasan, y si algo falla entiendes exactamente qué aspecto del prompt rompiste.
Para evaluaciones más sofisticadas, puedes usar otro LLM como juez: le pasas el input, el output esperado y el output real, y le pides que evalúe si el resultado es correcto. Esto escala mejor que escribir funciones de verificación a mano.

La arquitectura completa

Juntando todo, la evolución se ve así:
Antes:
1
script_50_lineas.py ← todo mezclado, sin estructura
Después:
1
2
3
4
5
6
7
8
9
10
proyecto/
├── .env ← API keys, nunca en git
├── config.py ← parámetros y configuración
├── llm_client.py ← comunicación con la API, retry, logging
├── prompts.py ← registro de prompts con versiones
├── tracker.py ← control de costos
├── prompts/
│ ├── resumir.txt ← versionable, editable por no-devs
│ └── traducir.txt
└── app.py ← lógica de negocio, limpia
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article