Módulo 3: Structured Outputs y System Prompts
6. Prompt Templates y Variables
Descripción
En producción, los prompts raramente son estáticos. Los prompts reales necesitan adaptarse al usuario, al contexto, al idioma, al historial de conversación y a los datos específicos de cada request. Un sistema de templates bien diseñado hace que tus prompts sean mantenibles, testables y reutilizables.
En esta cápsula aprenderás: template systems con f-strings y .format(), Jinja2 para lógica condicional, composición y herencia de templates, gestión de templates en archivos, y manejo de errores comunes.
Por qué los Templates son esenciales en producción
Comparación de enfoques:
| Enfoque | Pros | Contras | Cuándo usar |
|---|---|---|---|
| String concatenación | Simple | No mantenible, propenso a bugs | Nunca en producción |
| f-strings | Pythónico, simple | Sin lógica, sin bucles | Templates simples |
.format() | Más flexible que f-strings | Sin lógica | Templates con variables dinámicas |
| Jinja2 | Lógica completa, herencia, filtros | Dependencia extra | Templates complejos |
| Archivos de template | Separación de concerns | Requiere gestión de archivos | Equipos grandes, muchos prompts |
Técnica 1: f-strings y .format() (Python nativo)
f-strings para templates simples
from openai import OpenAI
client = OpenAI()
# Template básico con f-string
def clasificar_texto(texto: str, categorias: list[str], idioma: str = "español") -> str:
categorias_str = ", ".join(categorias)
system = f"Eres un clasificador preciso. Responde solo con una de estas categorías: {categorias_str}."
user = f"Clasifica en {idioma}:\n\n{texto}"
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user}
],
temperature=0
)
return response.choices[0].message.content.strip()
# Test
result = clasificar_texto(
texto="No puedo iniciar sesión, dice error 401",
categorias=["TÉCNICO", "FACTURACIÓN", "CUENTA", "OTRO"]
)
print(result) # → TÉCNICO o CUENTA
.format() para templates reutilizables
# El método .format() permite definir templates como constantes
PROMPT_CLASIFICACION = """
Clasifica el siguiente texto en una de estas categorías: {categorias}.
Reglas:
- Devuelve SOLO el nombre de la categoría
- Si no estás seguro, devuelve la más probable
- Idioma de respuesta: {idioma}
Texto a clasificar:
{texto}
"""
PROMPT_RESUMEN = """
Resume el siguiente {tipo_documento} en máximo {max_palabras} palabras.
Tono: {tono}
Público: {publico}
{tipo_documento}:
{contenido}
"""
def build_prompt(template: str, **kwargs) -> str:
"""
Construye prompt a partir de template y variables.
Args:
template: Template con placeholders {variable}
**kwargs: Variables para reemplazar en el template
Returns:
Prompt completo con variables interpoladas
Raises:
KeyError: Si falta alguna variable requerida
"""
try:
return template.format(**kwargs)
except KeyError as e:
raise KeyError(f"Variable requerida faltante en template: {e}")
# Test básico
prompt = build_prompt(
PROMPT_CLASIFICACION,
categorias="TÉCNICO, FACTURACIÓN, CUENTA, OTRO",
idioma="español",
texto="Me cobraron dos veces este mes"
)
print(prompt)
Template con valores por defecto
class PromptBuilder:
"""Builder para construir prompts con valores por defecto y validación."""
DEFAULTS = {
"idioma": "español",
"max_tokens": 200,
"tono": "profesional",
"nivel_detalle": "medio"
}
def __init__(self, template: str):
self.template = template
self._variables: dict = dict(self.DEFAULTS)
def set(self, **kwargs) -> "PromptBuilder":
"""Establece variables. Retorna self para encadenamiento."""
self._variables.update(kwargs)
return self
def build(self) -> str:
"""Construye el prompt final."""
# Validar que todas las variables requeridas estén definidas
import string
formatter = string.Formatter()
required = {field_name for _, field_name, _, _ in formatter.parse(self.template) if field_name}
missing = required - set(self._variables.keys())
if missing:
raise ValueError(f"Variables requeridas faltantes: {missing}")
return self.template.format(**self._variables)
def reset(self) -> "PromptBuilder":
"""Resetea a valores por defecto."""
self._variables = dict(self.DEFAULTS)
return self
# Uso con encadenamiento
builder = PromptBuilder(PROMPT_RESUMEN)
prompt = (builder
.set(tipo_documento="email",
max_palabras=50,
tono="formal",
publico="directivos",
contenido="[contenido del email aquí]")
.build()
)
print(prompt)
Técnica 2: Jinja2 para Templates Complejos
Instalación y setup básico
# pip install jinja2
from jinja2 import Template, Environment, BaseLoader, FileSystemLoader
from jinja2 import TemplateSyntaxError, UndefinedError
# Template básico con Jinja2
template_str = """
Eres un clasificador para {{ dominio }}.
Categorías disponibles: {{ categorias | join(", ") }}
{% if ejemplos %}
Ejemplos de clasificación:
{% for inp, out in ejemplos %}
- Input: "{{ inp }}" → Categoría: {{ out }}
{% endfor %}
{% endif %}
{% if contexto_adicional %}
Contexto importante: {{ contexto_adicional }}
{% endif %}
Clasifica el siguiente texto:
{{ texto }}
{% if formato_json %}
Responde ÚNICAMENTE en JSON: {"categoria": "...", "confianza": 0.0}
{% else %}
Responde ÚNICAMENTE con el nombre de la categoría.
{% endif %}
"""
template = Template(template_str)
prompt = template.render(
dominio="soporte al cliente SaaS",
categorias=["TÉCNICO", "FACTURACIÓN", "CUENTA", "OTRO"],
ejemplos=[
("No puedo acceder", "TÉCNICO"),
("Me cobraron extra", "FACTURACIÓN"),
("Quiero cambiar mi plan", "CUENTA")
],
contexto_adicional="La empresa maneja pagos en MXN y USD",
texto="El botón de pago no responde al hacer clic",
formato_json=True
)
print(prompt)
Filtros personalizados de Jinja2
from jinja2 import Environment, BaseLoader
def crear_environment() -> Environment:
"""Crea Environment de Jinja2 con filtros personalizados."""
env = Environment(loader=BaseLoader())
# Filtro para truncar texto
def truncate_words(text: str, max_words: int) -> str:
words = text.split()
if len(words) <= max_words:
return text
return " ".join(words[:max_words]) + "..."
# Filtro para formatear lista como bullet points
def as_bullets(items: list, marker: str = "-") -> str:
return "\n".join(f"{marker} {item}" for item in items)
# Filtro para formatear como numerado
def as_numbered(items: list) -> str:
return "\n".join(f"{i+1}. {item}" for i, item in enumerate(items))
# Filtro para capitalizar primera letra
def sentence_case(text: str) -> str:
return text[0].upper() + text[1:] if text else text
env.filters["truncate_words"] = truncate_words
env.filters["as_bullets"] = as_bullets
env.filters["as_numbered"] = as_numbered
env.filters["sentence_case"] = sentence_case
return env
env = crear_environment()
# Usar filtros personalizados
template = env.from_string("""
Analiza este reporte:
Métricas clave:
{{ metricas | as_bullets }}
Hallazgos del periodo:
{{ hallazgos | as_numbered }}
Contexto: {{ contexto | truncate_words(50) }}
""")
prompt = template.render(
metricas=["Usuarios: 10,000", "Churn: 5%", "NPS: 42"],
hallazgos=[
"Crecimiento de 15% en nuevos usuarios",
"Caída de engagement en mobile",
"Pico de tickets de soporte en miércoles"
],
contexto="Este es un reporte largo con mucho contexto que necesitamos truncar para " * 20
)
print(prompt)
Herencia de templates
from jinja2 import Environment, DictLoader
# Templates base y derivados usando herencia
templates = {
"base_system.j2": """
Eres {{ rol }}.
{% block expertise %}
{% endblock %}
{% block reglas_generales %}
Reglas generales:
- Sé preciso y objetivo
- No inventes información
- Si no sabes algo, di "No tengo información suficiente"
{% endblock %}
{% block formato_output %}
Formato de respuesta: Texto libre.
{% endblock %}
""",
"expert_analyst.j2": """
{% extends "base_system.j2" %}
{% block expertise %}
Tienes expertise en {{ dominio }} con {{ anos_experiencia }} años de experiencia.
Tu audiencia: {{ audiencia }}.
{% endblock %}
{% block formato_output %}
Estructura tu respuesta:
1. Resumen ejecutivo (2-3 oraciones)
2. Hallazgos principales (lista)
3. Recomendaciones
{% endblock %}
""",
"json_formatter.j2": """
{% extends "base_system.j2" %}
{% block expertise %}
Especialista en extracción y estructuración de datos.
{% endblock %}
{% block reglas_generales %}
{{ super() }}
- CRÍTICO: Tu output debe ser ÚNICAMENTE JSON válido
- Sin texto antes ni después del JSON
- Usa null para campos no encontrados
{% endblock %}
{% block formato_output %}
Schema JSON requerido:
{{ schema | tojson(indent=2) }}
{% endblock %}
"""
}
env = Environment(loader=DictLoader(templates))
# Renderizar template derivado
expert_template = env.get_template("expert_analyst.j2")
system_prompt = expert_template.render(
rol="un consultor de negocio senior",
dominio="estrategia empresarial para startups latinoamericanas",
anos_experiencia=12,
audiencia="fundadores de startups en etapa temprana"
)
print(system_prompt)
Técnica 3: Templates desde Archivos
Estructura de carpetas recomendada
prompts/
├── base/
│ ├── base_system.j2
│ └── base_user.j2
├── clasificacion/
│ ├── tickets.j2
│ ├── sentimiento.j2
│ └── intención.j2
├── extraccion/
│ ├── invoice.j2
│ ├── email.j2
│ └── contacto.j2
└── analisis/
├── competidores.j2
└── metricas.j2
Cargar templates desde archivos
from jinja2 import Environment, FileSystemLoader, select_autoescape
from pathlib import Path
class PromptTemplateManager:
"""
Gestor de templates de prompts desde sistema de archivos.
Soporta:
- Carga desde directorios
- Caché automático de templates
- Recarga en desarrollo
- Validación de variables requeridas
"""
def __init__(self, templates_dir: str | Path, auto_reload: bool = False):
self.templates_dir = Path(templates_dir)
self.env = Environment(
loader=FileSystemLoader(str(self.templates_dir)),
autoescape=select_autoescape(["html", "xml"]),
auto_reload=auto_reload,
keep_trailing_newline=True
)
# Registrar filtros personalizados
self._register_filters()
def _register_filters(self):
"""Registra filtros de Jinja2 útiles para prompts."""
self.env.filters["as_bullets"] = lambda items: "\n".join(f"- {i}" for i in items)
self.env.filters["as_numbered"] = lambda items: "\n".join(
f"{i+1}. {item}" for i, item in enumerate(items)
)
def render(self, template_name: str, **variables) -> str:
"""
Renderiza un template con las variables dadas.
Args:
template_name: Nombre del template (ej: "clasificacion/tickets.j2")
**variables: Variables para el template
Returns:
Template renderizado como string
Raises:
TemplateNotFound: Si el template no existe
UndefinedError: Si falta una variable requerida
"""
template = self.env.get_template(template_name)
return template.render(**variables)
def list_templates(self, category: str | None = None) -> list[str]:
"""Lista templates disponibles, opcionalmente filtrados por categoría."""
all_templates = self.env.list_templates()
if category:
return [t for t in all_templates if t.startswith(category)]
return all_templates
def validate_variables(self, template_name: str, variables: dict) -> list[str]:
"""
Valida que las variables requeridas estén presentes.
Returns:
Lista de variables faltantes (vacía si todo está correcto)
"""
import re
template = self.env.get_template(template_name)
source = template.module.__loader__.get_source(template_name) if hasattr(template, 'module') else ""
# Parsear variables del template
required = set(re.findall(r"\{\{\s*(\w+)\s*\}\}", source))
present = set(variables.keys())
return list(required - present)
# Crear templates de ejemplo
import os
def setup_templates_demo():
"""Crea estructura de templates de demo."""
os.makedirs("prompts/clasificacion", exist_ok=True)
# Template de clasificación de tickets
ticket_template = """
Eres un clasificador experto de tickets de soporte para {{ empresa }}.
{% if descripcion_empresa %}
Contexto: {{ descripcion_empresa }}
{% endif %}
Categorías disponibles:
{% for cat in categorias %}
- {{ cat.nombre }}: {{ cat.descripcion }}
{% endfor %}
{% if ejemplos %}
Ejemplos:
{% for ejemplo in ejemplos %}
Input: "{{ ejemplo.input }}" → {{ ejemplo.output }}
{% endfor %}
{% endif %}
Clasifica el siguiente ticket y devuelve JSON:
{"categoria": "nombre", "prioridad": "ALTA|MEDIA|BAJA", "confianza": 0.0-1.0}
Sin texto adicional.
"""
with open("prompts/clasificacion/tickets.j2", "w") as f:
f.write(ticket_template)
setup_templates_demo()
# Usar el manager
manager = PromptTemplateManager("prompts")
system_prompt = manager.render(
"clasificacion/tickets.j2",
empresa="TechSaaS",
descripcion_empresa="Plataforma de gestión de proyectos para equipos de software",
categorias=[
{"nombre": "TÉCNICO", "descripcion": "Errores, bugs, problemas de funcionamiento"},
{"nombre": "FACTURACIÓN", "descripcion": "Cobros, planes, facturas"},
{"nombre": "CUENTA", "descripcion": "Acceso, contraseña, perfil"},
{"nombre": "FEATURE_REQUEST", "descripcion": "Solicitudes de nuevas funcionalidades"}
],
ejemplos=[
{"input": "El dashboard no carga", "output": "TÉCNICO"},
{"input": "Quiero cambiar mi plan", "output": "CUENTA"}
]
)
print(system_prompt)
Técnica 4: Composición de Templates
La composición permite construir prompts complejos ensamblando partes reutilizables.
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class PromptPart:
"""Parte de un prompt con nombre y función generadora."""
nombre: str
generar: Callable[..., str]
requerido: bool = True
class PromptComposer:
"""
Composer para construir prompts modulares.
Permite definir partes de prompt reutilizables y combinarlas.
"""
def __init__(self, separador: str = "\n\n"):
self._partes: list[PromptPart] = []
self.separador = separador
def agregar(self, nombre: str, requerido: bool = True):
"""Decorator para registrar una parte del prompt."""
def decorator(func: Callable) -> Callable:
self._partes.append(PromptPart(
nombre=nombre,
generar=func,
requerido=requerido
))
return func
return decorator
def componer(self, **kwargs) -> str:
"""
Compone el prompt final con todas las partes.
Args:
**kwargs: Variables para pasar a cada parte
Returns:
Prompt completo
"""
partes_generadas = []
for parte in self._partes:
try:
contenido = parte.generar(**kwargs)
if contenido and contenido.strip():
partes_generadas.append(contenido.strip())
except TypeError:
# La función no acepta ciertos kwargs, intentar sin ellos
try:
contenido = parte.generar()
if contenido and contenido.strip():
partes_generadas.append(contenido.strip())
except Exception as e:
if parte.requerido:
raise ValueError(f"Error en parte requerida '{parte.nombre}': {e}")
return self.separador.join(partes_generadas)
# Ejemplo de uso del Composer
classifier_composer = PromptComposer()
@classifier_composer.agregar("rol_base", requerido=True)
def generar_rol(empresa: str, dominio: str, **kwargs) -> str:
return f"Eres un clasificador experto de tickets para {empresa} ({dominio})."
@classifier_composer.agregar("categorias", requerido=True)
def generar_categorias(categorias: list[str], **kwargs) -> str:
cats_str = "\n".join(f"- {c}" for c in categorias)
return f"Categorías disponibles:\n{cats_str}"
@classifier_composer.agregar("ejemplos", requerido=False)
def generar_ejemplos(ejemplos: list[tuple] | None = None, **kwargs) -> str:
if not ejemplos:
return ""
lineas = [f'- "{inp}" → {out}' for inp, out in ejemplos]
return "Ejemplos:\n" + "\n".join(lineas)
@classifier_composer.agregar("formato_output", requerido=True)
def generar_formato(formato: str = "texto", **kwargs) -> str:
if formato == "json":
return 'Responde SOLO con JSON: {"categoria": "...", "confianza": 0.0}'
return "Responde SOLO con el nombre de la categoría."
# Componer prompt
system_prompt = classifier_composer.componer(
empresa="Acme SaaS",
dominio="plataforma CRM",
categorias=["TÉCNICO", "FACTURACIÓN", "CUENTA", "OTRO"],
ejemplos=[("No puedo exportar", "TÉCNICO"), ("Me cobraron extra", "FACTURACIÓN")],
formato="json"
)
print(system_prompt)
Técnica 5: Templates con Contexto Dinámico
Para sistemas RAG o con contexto variable, los templates necesitan manejar listas de documentos de tamaño variable.
from jinja2 import Template
import tiktoken
# Estimar tokens para no exceder el context window
def contar_tokens(texto: str, modelo: str = "gpt-4o-mini") -> int:
"""Cuenta tokens de un texto para un modelo específico."""
try:
enc = tiktoken.encoding_for_model(modelo)
return len(enc.encode(texto))
except Exception:
# Estimación aproximada si tiktoken falla
return len(texto.split()) * 1.3
def build_rag_prompt(
pregunta: str,
documentos: list[dict],
max_context_tokens: int = 3000,
modelo: str = "gpt-4o-mini"
) -> str:
"""
Construye prompt para RAG con gestión de context window.
Args:
pregunta: Pregunta del usuario
documentos: Lista de {"contenido": str, "fuente": str, "relevancia": float}
max_context_tokens: Máximo de tokens para el contexto
modelo: Modelo para calcular tokens
Returns:
Prompt optimizado que respeta el límite de tokens
"""
# Ordenar por relevancia (mayor primero)
docs_ordenados = sorted(documentos, key=lambda x: x.get("relevancia", 0), reverse=True)
# Seleccionar documentos que quepan en el context window
docs_seleccionados = []
tokens_usados = 0
for doc in docs_ordenados:
doc_texto = f"Fuente: {doc['fuente']}\n{doc['contenido']}"
doc_tokens = contar_tokens(doc_texto, modelo)
if tokens_usados + doc_tokens > max_context_tokens:
break
docs_seleccionados.append(doc)
tokens_usados += doc_tokens
template = Template("""
Responde la pregunta basándote ÚNICAMENTE en los documentos de contexto proporcionados.
{% if documentos %}
## Contexto relevante
{% for doc in documentos %}
### Documento {{ loop.index }} (Fuente: {{ doc.fuente }})
{{ doc.contenido }}
{% endfor %}
{% else %}
No hay documentos de contexto disponibles.
{% endif %}
## Pregunta
{{ pregunta }}
## Instrucciones
- Si la respuesta está en el contexto, cítalo
- Si no está en el contexto, di: "No encuentro información sobre esto en los documentos disponibles"
- No inventes información que no esté en el contexto
""")
return template.render(
documentos=docs_seleccionados,
pregunta=pregunta,
tokens_info={"total": tokens_usados, "docs_incluidos": len(docs_seleccionados)}
)
# Test
documentos_ejemplo = [
{
"contenido": "FastAPI es un framework moderno para construir APIs con Python 3.7+.",
"fuente": "docs.fastapi.tiangolo.com",
"relevancia": 0.95
},
{
"contenido": "La autenticación JWT en FastAPI se implementa con OAuth2PasswordBearer.",
"fuente": "fastapi.tiangolo.com/tutorial/security",
"relevancia": 0.87
},
{
"contenido": "Pydantic v2 es significativamente más rápido que v1 gracias a Rust.",
"fuente": "docs.pydantic.dev",
"relevancia": 0.60
}
]
prompt = build_rag_prompt(
pregunta="¿Cómo implemento autenticación en FastAPI?",
documentos=documentos_ejemplo,
max_context_tokens=2000
)
print(prompt)
Técnica 6: Versionado de Templates
En producción, es importante versionar los templates para A/B testing y rollbacks.
from datetime import datetime
import json
from pathlib import Path
class VersionedTemplateManager:
"""
Gestor de templates con versionado y A/B testing.
"""
def __init__(self, storage_dir: str = "prompt_versions"):
self.storage_dir = Path(storage_dir)
self.storage_dir.mkdir(exist_ok=True)
self._registry: dict = self._load_registry()
def _load_registry(self) -> dict:
registry_file = self.storage_dir / "registry.json"
if registry_file.exists():
return json.loads(registry_file.read_text())
return {}
def _save_registry(self):
registry_file = self.storage_dir / "registry.json"
registry_file.write_text(json.dumps(self._registry, indent=2))
def guardar_version(
self,
nombre: str,
template: str,
descripcion: str = "",
autor: str = "sistema"
) -> str:
"""Guarda nueva versión de un template."""
version_id = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
version_data = {
"template": template,
"descripcion": descripcion,
"autor": autor,
"created_at": datetime.utcnow().isoformat(),
"version_id": version_id
}
if nombre not in self._registry:
self._registry[nombre] = {"versiones": [], "activa": None}
self._registry[nombre]["versiones"].append(version_id)
# Guardar template en archivo
version_file = self.storage_dir / f"{nombre}_{version_id}.j2"
version_file.write_text(template)
# Guardar metadata
meta_file = self.storage_dir / f"{nombre}_{version_id}.json"
meta_file.write_text(json.dumps(version_data, indent=2))
self._save_registry()
return version_id
def activar_version(self, nombre: str, version_id: str):
"""Activa una versión específica como la versión actual."""
if nombre not in self._registry:
raise ValueError(f"Template '{nombre}' no encontrado")
if version_id not in self._registry[nombre]["versiones"]:
raise ValueError(f"Versión '{version_id}' no encontrada")
self._registry[nombre]["activa"] = version_id
self._save_registry()
def obtener_activo(self, nombre: str, **variables) -> str:
"""Renderiza la versión activa de un template."""
if nombre not in self._registry:
raise ValueError(f"Template '{nombre}' no encontrado")
version_id = self._registry[nombre].get("activa")
if not version_id:
# Usar la más reciente si no hay activa
version_id = self._registry[nombre]["versiones"][-1]
version_file = self.storage_dir / f"{nombre}_{version_id}.j2"
template_str = version_file.read_text()
from jinja2 import Template
return Template(template_str).render(**variables)
def listar_versiones(self, nombre: str) -> list[dict]:
"""Lista todas las versiones de un template con metadata."""
if nombre not in self._registry:
return []
versiones = []
activa = self._registry[nombre].get("activa")
for version_id in self._registry[nombre]["versiones"]:
meta_file = self.storage_dir / f"{nombre}_{version_id}.json"
if meta_file.exists():
meta = json.loads(meta_file.read_text())
meta["es_activa"] = version_id == activa
versiones.append(meta)
return versiones
# Ejemplo de uso
manager = VersionedTemplateManager()
# Versión 1 del template
v1 = manager.guardar_version(
nombre="clasificador_tickets",
template="Clasifica en {{ categorias | join(', ') }}. Texto: {{ texto }}",
descripcion="Versión inicial sin ejemplos",
autor="mike"
)
# Versión 2 mejorada
v2 = manager.guardar_version(
nombre="clasificador_tickets",
template="""
Eres un clasificador experto. Categorías: {{ categorias | join(', ') }}.
{% if ejemplos %}
Ejemplos: {% for e in ejemplos %}{{ e.input }}→{{ e.output }} {% endfor %}
{% endif %}
Clasifica: {{ texto }}. Responde solo con la categoría.
""",
descripcion="Mejorado con ejemplos few-shot",
autor="mike"
)
manager.activar_version("clasificador_tickets", v2)
# Renderizar versión activa
prompt = manager.obtener_activo(
"clasificador_tickets",
categorias=["TÉCNICO", "FACTURACIÓN", "OTRO"],
texto="No puedo descargar mi factura del mes pasado",
ejemplos=[
{"input": "Error al cargar", "output": "TÉCNICO"},
{"input": "Cobro duplicado", "output": "FACTURACIÓN"}
]
)
print(prompt)
Troubleshooting
1. Variable no definida en Jinja2
Síntoma: UndefinedError: 'variable_name' is undefined
from jinja2 import Template, Undefined
# Solución 1: Usar filtro default
template = Template("""
Hola {{ nombre | default('Usuario') }}.
Idioma: {{ idioma | default('español') }}.
""")
# Solución 2: Usar is defined
template = Template("""
{% if contexto is defined and contexto %}
Contexto: {{ contexto }}
{% endif %}
""")
# Solución 3: Environment con undefined silencioso
from jinja2 import ChainableUndefined
env = Environment(
loader=BaseLoader(),
undefined=ChainableUndefined # Variables no definidas retornan '' en lugar de error
)
# Solución 4: Pasar valores por defecto al renderizar
template = Template("{{ nombre }}")
result = template.render({"nombre": None} | {"nombre": "Fallback"})
2. Inyección de código en templates de usuario
from jinja2 import Environment, sandbox
# NUNCA renderizar templates creados por usuarios sin sandboxing
# MAL:
template_usuario = "{% for i in range(10000) %}x{% endfor %}" # DoS
Template(template_usuario).render()
# BIEN: Usar SandboxedEnvironment
from jinja2.sandbox import SandboxedEnvironment
safe_env = SandboxedEnvironment()
try:
result = safe_env.from_string(template_usuario).render()
except Exception as e:
print(f"Template inseguro bloqueado: {e}")
# Mejor práctica: Solo permitir variables, no lógica de control
def render_usuario_seguro(template_str: str, variables: dict) -> str:
"""
Renderiza template de usuario con solo interpolación de variables.
No permite {% %} blocks de control.
"""
import re
# Verificar que no hay bloques de control
if re.search(r"\{%-?\s*(for|if|while|import|from|include|extends)", template_str):
raise ValueError("Templates de usuario no pueden contener lógica de control")
return safe_env.from_string(template_str).render(variables)
3. Templates muy largos que exceden el context window
import tiktoken
def trim_prompt_to_limit(
prompt: str,
max_tokens: int = 3000,
modelo: str = "gpt-4o-mini"
) -> tuple[str, bool]:
"""
Trunca prompt si excede el límite de tokens.
Returns:
Tuple (prompt_posiblemente_truncado, fue_truncado)
"""
enc = tiktoken.encoding_for_model(modelo)
tokens = enc.encode(prompt)
if len(tokens) <= max_tokens:
return prompt, False
# Truncar y decodificar
tokens_truncados = tokens[:max_tokens]
prompt_truncado = enc.decode(tokens_truncados)
# Añadir indicador de truncamiento
prompt_truncado += "\n\n[...contenido truncado por límite de tokens...]"
return prompt_truncado, True
# Uso
prompt_largo = "Texto muy largo..." * 1000
prompt_final, fue_truncado = trim_prompt_to_limit(prompt_largo, max_tokens=2000)
if fue_truncado:
print(f"⚠️ Prompt truncado a 2000 tokens")
4. Templates con caracteres especiales que rompen el formato
from jinja2 import Template
# Problema: Variables que contienen llaves o caracteres de Jinja2
usuario_input = "Usa el formato {{ dato }} en tu respuesta"
# Solución: Escapar la variable antes de usar en template
template = Template("""
El usuario preguntó: {{ pregunta | e }}
Responde a su pregunta.
""")
# El filtro |e (escape) maneja caracteres especiales de HTML
# Para Jinja2 mismo, los {{ }} en variables se tratan como texto, no se ejecutan
# Para incluir llaves literales en el template:
template_con_llaves_literales = Template("""
El formato JSON es: {{ '{{' }}campo{{ '}}' }}
""")
# O usando bloques raw:
template_raw = Template("""
{% raw %}El modelo debe responder en: {"key": "value"}{% endraw %}
""")
Ejercicios
Ejercicio 1: Template con condicional para few-shot opcional
Crea un template Jinja2 que incluya ejemplos only if ejemplos no está vacío, con manejo de lista de tamaño variable.
Ver solución
from jinja2 import Template
TEMPLATE_FEW_SHOT = Template("""
Eres un clasificador para el dominio: {{ dominio }}.
Categorías: {{ categorias | join(", ") }}.
{% if ejemplos and ejemplos | length > 0 %}
## Ejemplos de clasificación:
{% for ejemplo in ejemplos %}
{{ loop.index }}. Input: "{{ ejemplo.input }}"
Categoría: {{ ejemplo.output }}
{% if ejemplo.razon is defined %}
Razón: {{ ejemplo.razon }}
{% endif %}
{% endfor %}
{% endif %}
## Tarea
Clasifica el siguiente texto en una de las categorías listadas.
Texto: {{ texto }}
{% if formato_json %}
Responde ÚNICAMENTE en JSON: {"categoria": "NOMBRE", "confianza": 0.0-1.0}
{% else %}
Responde ÚNICAMENTE con el nombre de la categoría.
{% endif %}
""")
# Test 1: Sin ejemplos
prompt_sin_ejemplos = TEMPLATE_FEW_SHOT.render(
dominio="soporte técnico",
categorias=["TÉCNICO", "FACTURACIÓN", "OTRO"],
ejemplos=[],
texto="No puedo hacer login",
formato_json=True
)
# Test 2: Con ejemplos
prompt_con_ejemplos = TEMPLATE_FEW_SHOT.render(
dominio="soporte técnico",
categorias=["TÉCNICO", "FACTURACIÓN", "OTRO"],
ejemplos=[
{"input": "Error 500", "output": "TÉCNICO", "razon": "Error de servidor"},
{"input": "Me cobraron 2 veces", "output": "FACTURACIÓN"},
],
texto="No puedo hacer login",
formato_json=True
)
print("=== Sin ejemplos ===")
print(prompt_sin_ejemplos)
print("\n=== Con ejemplos ===")
print(prompt_con_ejemplos)
Ejercicio 2: Template desde archivo con FileSystemLoader
Crea el directorio prompts/, guarda un template en prompts/resumen.j2, y cárgalo con FileSystemLoader.
Ver solución
import os
from jinja2 import Environment, FileSystemLoader
# 1. Crear directorio y template
os.makedirs("prompts", exist_ok=True)
template_content = """
Eres un especialista en crear resúmenes {{ tipo_resumen }}.
Genera un resumen del siguiente {{ tipo_documento }}.
Requisitos:
- Longitud: {{ longitud }} palabras aproximadamente
- Tono: {{ tono | default("profesional") }}
- Incluir: {% for item in incluir %}"{{ item }}"{% if not loop.last %}, {% endif %}{% endfor %}
{% if excluir is defined and excluir %}
- Excluir: {% for item in excluir %}"{{ item }}"{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %}
{{ tipo_documento | capitalize }} a resumir:
{{ contenido }}
"""
with open("prompts/resumen.j2", "w", encoding="utf-8") as f:
f.write(template_content)
# 2. Cargar y renderizar desde FileSystemLoader
env = Environment(loader=FileSystemLoader("prompts"))
template = env.get_template("resumen.j2")
prompt = template.render(
tipo_resumen="ejecutivos para directivos",
tipo_documento="artículo",
longitud=150,
tono="formal",
incluir=["puntos clave", "conclusiones", "recomendaciones"],
excluir=["detalles técnicos", "jerga especializada"],
contenido="""
La adopción de IA en empresas latinoamericanas creció un 45% en 2024.
Los principales usos son automatización de procesos y análisis de datos.
Las barreras principales son costos de implementación y falta de talento.
Las startups lideran la adopción, seguidas por grandes corporaciones.
"""
)
print(prompt)
Ejercicio 3: Composer de prompts modular para diferentes tareas
Implementa un PromptComposer con partes: rol, tarea, restricciones, formato. Cada parte debe ser opcional excepto rol y tarea.
Ver solución
from jinja2 import Template
class ModularPromptComposer:
"""Composer modular para prompts con secciones configurables."""
SECTION_TEMPLATES = {
"rol": Template("Eres {{ rol_descripcion }}."),
"tarea": Template("""
## Tarea
{{ descripcion_tarea }}
{% if subtareas %}
Pasos a seguir:
{% for subtarea in subtareas %}
{{ loop.index }}. {{ subtarea }}
{% endfor %}
{% endif %}
"""),
"restricciones": Template("""
## Restricciones
{% for restriccion in restricciones %}
- {{ restriccion }}
{% endfor %}
"""),
"formato": Template("""
## Formato de respuesta
{% if tipo_formato == "json" %}
Responde ÚNICAMENTE con JSON válido: {{ schema_json }}
{% elif tipo_formato == "lista" %}
Responde con una lista numerada.
{% elif tipo_formato == "markdown" %}
Usa formato Markdown con secciones claras.
{% else %}
{{ instrucciones_formato }}
{% endif %}
"""),
"ejemplos": Template("""
## Ejemplos
{% for ejemplo in ejemplos %}
Input: {{ ejemplo.input }}
Output: {{ ejemplo.output }}
{% endfor %}
""")
}
def __init__(self):
self._secciones: dict = {}
self._orden: list[str] = ["rol", "tarea", "ejemplos", "restricciones", "formato"]
def configurar(self, seccion: str, **kwargs) -> "ModularPromptComposer":
"""Configura una sección del prompt."""
self._secciones[seccion] = kwargs
return self
def componer(self) -> str:
"""Compone el prompt final."""
requeridos = ["rol", "tarea"]
for req in requeridos:
if req not in self._secciones:
raise ValueError(f"Sección requerida faltante: '{req}'")
partes = []
for seccion in self._orden:
if seccion in self._secciones:
template = self.SECTION_TEMPLATES[seccion]
try:
contenido = template.render(**self._secciones[seccion]).strip()
if contenido:
partes.append(contenido)
except Exception as e:
print(f"Warning: Error en sección '{seccion}': {e}")
return "\n\n".join(partes)
# Test
composer = ModularPromptComposer()
prompt = (composer
.configurar("rol", rol_descripcion="un analista de seguridad experto en APIs REST")
.configurar("tarea",
descripcion_tarea="Revisa el código de la API y detecta vulnerabilidades de seguridad.",
subtareas=["Identificar endpoints sin autenticación", "Verificar validación de inputs", "Detectar SQL injection potencial"])
.configurar("restricciones", restricciones=[
"No generes exploits o código malicioso",
"Clasifica cada vulnerabilidad como CRÍTICA, ALTA, MEDIA o BAJA",
"Proporciona recomendaciones de remediación"
])
.configurar("formato",
tipo_formato="json",
schema_json='{"vulnerabilidades": [{"descripcion": "", "nivel": "", "remediacion": ""}]}')
.componer()
)
print(prompt)
Ejercicio 4: Template con conteo de tokens y truncamiento automático
Implementa una función que construya un prompt RAG y garantice que no exceda max_tokens truncando documentos de menor relevancia.
Ver solución
from jinja2 import Template
import tiktoken
def build_rag_prompt_limitado(
pregunta: str,
documentos: list[dict],
system_base: str,
max_tokens: int = 4000,
modelo: str = "gpt-4o-mini"
) -> tuple[str, dict]:
"""
Construye prompt RAG respetando límite de tokens.
Returns:
Tuple (prompt, metadata) donde metadata incluye docs incluidos/excluidos
"""
enc = tiktoken.encoding_for_model(modelo)
def contar(texto: str) -> int:
return len(enc.encode(texto))
# Tokens fijos del sistema y pregunta
tokens_base = contar(system_base) + contar(pregunta) + 100 # 100 de overhead
tokens_disponibles = max_tokens - tokens_base
# Ordenar por relevancia
docs_ordenados = sorted(
documentos,
key=lambda x: x.get("relevancia", 0.5),
reverse=True
)
docs_incluidos = []
docs_excluidos = []
tokens_usados = 0
for doc in docs_ordenados:
doc_str = f"[{doc['fuente']}]\n{doc['contenido']}"
doc_tokens = contar(doc_str)
if tokens_usados + doc_tokens <= tokens_disponibles:
docs_incluidos.append(doc)
tokens_usados += doc_tokens
else:
docs_excluidos.append(doc['fuente'])
template = Template("""
{{ system_base }}
## Documentos de referencia ({{ docs | length }} de {{ total_docs }} disponibles)
{% for doc in docs %}
### [{{ loop.index }}] {{ doc.fuente }} (relevancia: {{ "%.0f%%" | format(doc.relevancia * 100) }})
{{ doc.contenido }}
{% endfor %}
{% if excluidos %}
({{ excluidos | length }} documentos adicionales excluidos por límite de tokens)
{% endif %}
## Pregunta del usuario
{{ pregunta }}
Responde basándote únicamente en los documentos de referencia.
""")
prompt = template.render(
system_base=system_base,
docs=docs_incluidos,
total_docs=len(documentos),
excluidos=docs_excluidos,
pregunta=pregunta
)
metadata = {
"total_tokens": contar(prompt),
"docs_incluidos": len(docs_incluidos),
"docs_excluidos": docs_excluidos,
"truncado": len(docs_excluidos) > 0
}
return prompt, metadata
# Test
docs_test = [
{"contenido": "FastAPI es un framework moderno y rápido.", "fuente": "fastapi.io", "relevancia": 0.95},
{"contenido": "Uvicorn es el servidor ASGI recomendado para FastAPI.", "fuente": "uvicorn.org", "relevancia": 0.80},
{"contenido": "Starlette es la base de FastAPI para routing.", "fuente": "starlette.io", "relevancia": 0.70},
{"contenido": "Python 3.11 introduce mejoras de rendimiento del 60%.", "fuente": "python.org", "relevancia": 0.30},
]
prompt, meta = build_rag_prompt_limitado(
pregunta="¿Cómo arranco un servidor FastAPI en producción?",
documentos=docs_test,
system_base="Eres un experto en Python y APIs REST.",
max_tokens=500
)
print(prompt[:300] + "...")
print(f"\nMetadata: {meta}")
Ejercicio 5: Sistema de A/B testing de prompts
Implementa una función que ejecute el mismo query con dos versiones de prompt y compare los resultados.
Ver solución
from openai import OpenAI
import json
from dataclasses import dataclass
client = OpenAI()
@dataclass
class ABTestResult:
version_a_output: str
version_b_output: str
tokens_a: int
tokens_b: int
latencia_a_ms: float
latencia_b_ms: float
def ab_test_prompts(
prompt_a: str,
prompt_b: str,
user_input: str,
model: str = "gpt-4o-mini",
n_trials: int = 3
) -> ABTestResult:
"""
Ejecuta A/B test entre dos versiones de prompt.
Ejecuta múltiples trials y retorna el último resultado.
"""
import time
def llamar(system: str) -> tuple[str, int, float]:
start = time.time()
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user_input}
]
)
elapsed_ms = (time.time() - start) * 1000
return (
response.choices[0].message.content,
response.usage.total_tokens,
elapsed_ms
)
# Ejecutar últimos trials
output_a, tokens_a, lat_a = llamar(prompt_a)
output_b, tokens_b, lat_b = llamar(prompt_b)
result = ABTestResult(
version_a_output=output_a,
version_b_output=output_b,
tokens_a=tokens_a,
tokens_b=tokens_b,
latencia_a_ms=lat_a,
latencia_b_ms=lat_b
)
print(f"\n=== RESULTADO A/B TEST ===")
print(f"Input: '{user_input[:50]}'")
print(f"\nVersionA ({tokens_a} tokens, {lat_a:.0f}ms):")
print(f" {output_a[:100]}...")
print(f"\nVersionB ({tokens_b} tokens, {lat_b:.0f}ms):")
print(f" {output_b[:100]}...")
print(f"\nDiferencia tokens: {tokens_b - tokens_a:+d}")
print(f"Diferencia latencia: {lat_b - lat_a:+.0f}ms")
return result
# Test
PROMPT_V1 = "Clasifica el ticket en TÉCNICO, FACTURACIÓN o CUENTA. Solo la categoría."
PROMPT_V2 = """
Eres un clasificador experto de soporte.
Categorías: TÉCNICO (bugs, errores), FACTURACIÓN (cobros, pagos), CUENTA (acceso, perfil).
Clasifica el ticket y responde SOLO con la categoría.
"""
result = ab_test_prompts(
prompt_a=PROMPT_V1,
prompt_b=PROMPT_V2,
user_input="No puedo descargar mi factura del mes de enero"
)
Resumen
| Técnica | Caso de uso | Complejidad |
|---|---|---|
| f-strings | Prompts simples con variables | Baja |
.format() | Templates como constantes reutilizables | Baja |
| Jinja2 inline | Condicionales y loops en prompts | Media |
| Jinja2 FileSystem | Equipos con prompts en archivos | Media |
| Herencia Jinja2 | Sistema con base + variantes | Alta |
| PromptComposer | Partes modulares y reutilizables | Alta |
| Versionado | Producción con A/B testing | Alta |