El cambio de una palabra que nadie recuerda
Alguien retoca una frase del prompt de sistema para que el tono sea «más cercano». Funciona en los tres ejemplos que prueba, hace merge y se va a comer. Dos semanas después, el porcentaje de respuestas que cumplen el formato JSON ha bajado del 98 % al 91 % y nadie sabe desde cuándo. El historial de Git dice que el archivo cambió, pero el prompt estaba incrustado en una cadena de 200 líneas dentro de un módulo de Python, y el diff es ilegible.
El problema no es el prompt. Es que lo tratas como configuración informal cuando se comporta como código con efectos de producción: una línea mal puesta cambia el resultado de todas las peticiones. Y, a diferencia del código, no tiene tests que fallen cuando lo rompes.
Un prompt es un artefacto, no una cadena
El primer paso es sacarlo del código. Un prompt versionado necesita tres cosas: vivir en su propio archivo, llevar metadatos y tener un identificador estable que viaje con cada respuesta.
# prompts/clasificar_ticket/v3.yaml
id: clasificar_ticket
version: 3
modelo: claude-sonnet-5 # el prompt está calibrado para este modelo
temperatura: 0.0
variables: [asunto, cuerpo]
plantilla: |
Eres un clasificador de tickets de soporte.
Devuelve SOLO un JSON con las claves "categoria" y "urgencia".
Categorías válidas: facturacion, acceso, bug, otro.
Asunto: {asunto}
Cuerpo: {cuerpo}
Fíjate en dos decisiones. El modelo va dentro del archivo, porque un prompt calibrado para un modelo no es el mismo artefacto en otro. Y las variables están declaradas, de modo que un cargador puede fallar al arrancar si falta una, en lugar de enviar {cuerpo} literal al modelo.
Qué versionar y cómo numerarlo
No todo cambio merece una versión nueva. Una regla útil es separar por efecto en el comportamiento:
| Tipo de cambio | Ejemplo | Qué hacer |
|---|---|---|
| Cosmético | Corregir una errata | Mismo número, nuevo commit |
| De comportamiento | Reformular una instrucción | Versión nueva y evaluación completa |
| De contrato | Cambiar el formato de salida | Versión mayor y revisar a los consumidores |
| De modelo | Pasar a otro modelo | Versión nueva y evaluación completa |
La cuarta fila es la que más se olvida. Cambiar de modelo sin tocar una sola letra del prompt es un cambio de comportamiento, y debe pasar por el mismo filtro.
El conjunto de regresión
Versionar sin medir solo te dice qué cambió. Para saber si cambió a peor necesitas un conjunto de casos fijo contra el que comparar cada versión. No hace falta que sea grande: entre 50 y 200 casos reales, con la respuesta esperada o un criterio verificable, es suficiente para detectar regresiones gruesas.
import json
from pathlib import Path
def evaluar(version: str, casos: list[dict], llamar) -> dict:
"""Ejecuta un prompt contra el conjunto fijo y devuelve métricas."""
ok_formato = ok_categoria = 0
for caso in casos:
salida = llamar(version, **caso["entrada"])
try:
datos = json.loads(salida) # ¿es JSON válido?
ok_formato += 1
ok_categoria += datos["categoria"] == caso["esperado"]["categoria"]
except (json.JSONDecodeError, KeyError):
pass # un fallo de formato cuenta como fallo, sin excepciones
n = len(casos)
return {"version": version,
"formato": ok_formato / n,
"categoria": ok_categoria / n}
# Comparar la candidata con la que está en producción
casos = [json.loads(l) for l in Path("evals/tickets.jsonl").read_text().splitlines()]
base = evaluar("clasificar_ticket@v3", casos, llamar)
nueva = evaluar("clasificar_ticket@v4", casos, llamar)
La métrica de formato y la de acierto van por separado a propósito. Si una versión mejora el acierto y empeora el formato, quieres verlo, no que se compensen en una media.
La puerta en la integración continua
Con el conjunto en el repositorio, la comprobación se automatiza. Cada pull request que toque prompts/ ejecuta la evaluación y compara con la versión desplegada. Un umbral razonable es bloquear si cualquier métrica cae más de un margen acordado, por ejemplo dos puntos, o si la nueva versión empeora un caso que antes acertaba y que el equipo marcó como crítico.
Dos precauciones, ambas aprendidas por las malas:
- Fija la temperatura a cero en la evaluación y repite los casos dudosos. Un solo fallo aislado en un modelo no determinista es ruido, no regresión.
- Revisa los casos que cambian de signo, no solo la media. Una versión que arregla diez casos y rompe otros ocho tiene una media mejor y un riesgo distinto.
Desplegar con identificador, no con esperanza
El último paso es que producción sepa qué prompt está ejecutando. Registra el identificador y la versión en cada llamada, junto al modelo y la latencia:
log.info("llm_call", extra={
"prompt": "clasificar_ticket", "version": 4,
"modelo": "claude-sonnet-5", "latencia_ms": ms,
})
Con eso, la pregunta «¿desde cuándo baja el formato?» se responde con un filtro, no con arqueología. Y el rollback deja de ser un commit de emergencia: es cambiar un puntero de v4 a v3. Si además despliegas la versión nueva al 5 % del tráfico antes de generalizarla, el último filtro lo pone la realidad en lugar de tu conjunto de pruebas.
Qué hacer el lunes
No hace falta montar una plataforma. Empieza por lo barato, en este orden: saca los prompts a archivos, añade un identificador de versión a tus logs, recopila 50 casos reales y escribe la función de evaluación de arriba. Con eso ya detectas el 80 % de las regresiones. La sofisticación (despliegue gradual, paneles, evaluación con modelo como juez) se añade cuando el dolor lo justifique, no antes.
Un prompt sin historial es una deuda que se paga siempre en el peor momento. Versionarlo cuesta una tarde.