Casi todo el "manejo de prompts" es una carpeta de archivos .txt y una revisión de código. Esto es lo opuesto: los prompts viven en la base de datos como filas versionadas, cada agente busca el actual en el momento de la petición, la calidad se puntúa a partir de lo que de verdad pasó (una publicacion fue aceptada, un borrador se publicó, no un LLM calificándose a sí mismo), y un ciclo diario reescribe a los perdedores, les hace A/B testing a las reescrituras, y promueve al ganador de manera automática.
TL;DR
El ciclo, una vez que un prompt está en el registro:
| Etapa | Qué pasa | Dónde |
|---|---|---|
| Resolver | el agente busca el prompt activo en el momento de la petición (intercambiable en caliente) | resolve_prompt |
| Medir | explícito (👍/⭐) + implícito (resultado) → combined_score
|
recolector de feedback + trabajador implícito |
| Optimizar | a diario: reescribe a los de bajo rendimiento con un LLM → una sugerencia PENDING | optimizador de prompts |
| Probar | abre un A/B test 90/10, enruta el tráfico por versión | gestor de A/B |
| Promover | tras ≥50 muestras, el ganador por ≥10 pts → actívalo | gestor de A/B |
La única idea que lo hace funcionar: combined_score = explícito·0.4 + implícito·0.6, donde lo implícito es un resultado de negocio real. El modelo nunca califica su propia tarea.
El punto de partida: prompts como constantes
La línea base que casi todos publican:
SUMMARY_PROMPT = """You are an expert reviewer. Summarise: {text}"""
async def summarise(text: str) -> str:
return await llm(SUMMARY_PROMPT.format(text=text))
Dos problemas, los dos invisibles hasta que muerden:
- Cambiar un prompt es un despliegue. Ajustar una coma en el system prompt significa un PR, un build, un rollout. Así que nadie itera: el prompt se queda sin mantener.
- "¿El nuevo es mejor?" es una corazonada. Cambias la redacción, le echas un ojo a tres salidas, y publicas. No hay medición, así que no hay mejora, solo cambio.
Todo lo de abajo reemplaza "constante + corazonadas" con "fila + puntaje".
Capa 1: los prompts son filas versionadas
El prompt se vuelve dato. Una fila por (agent, prompt_type, version); una está marcada como activa.
class PromptVersion(Base):
__tablename__ = "prompt_versions"
agent_name: Mapped[str]
prompt_type: Mapped[str]
version: Mapped[int]
prompt_text: Mapped[str]
is_active: Mapped[bool] # exactamente 1 TRUE por (agent, type), normalmente
# calidad acumulada (llenada por el puntuador, Capa 3)
usage_count: Mapped[int]
avg_explicit_rating: Mapped[float]
explicit_approval_rate: Mapped[float]
implicit_success_rate: Mapped[float]
combined_score: Mapped[float]
performance_trend: Mapped[str] # improving | stable | declining
Registrar una versión nueva desactiva a la activa anterior en la misma transacción, así que "publicar un prompt nuevo" es una escritura a la base de datos, no un despliegue:
async def register_prompt(self, *, agent_name, prompt_type, prompt_text, activate):
current_max = await self._max_version(agent_name, prompt_type)
if activate:
await self.db.execute(
update(PromptVersion)
.where(PromptVersion.agent_name == agent_name,
PromptVersion.prompt_type == prompt_type,
PromptVersion.is_active.is_(True))
.values(is_active=False)
)
self.db.add(PromptVersion(
agent_name=agent_name, prompt_type=prompt_type,
version=current_max + 1, prompt_text=prompt_text, is_active=activate,
))
Los prompts fijos que ya existían siembran el registro como versión 1, una vez (idempotente). Esa semilla es también el fallback, ve la siguiente capa.
Capa 2: los agentes resuelven el prompt en el momento de la petición
El intercambio que hace gratis la iteración. En lugar de
SUMMARY_PROMPT.format(...), el agente llama a un resolvedor que lee la fila actual:
async def resolve_prompt(db, *, agent_name, prompt_type, template_vars, fallback_text):
"""Reemplazo directo de `FOO_PROMPT.format(**vars)`, pero el texto viene del
registro. Cae al constante fijo si el registro no tiene fila (despliegue
fresco, o las tablas de aprendizaje todavía no están sembradas)."""
try:
text, version_id = await PromptRegistry(db).get_prompt(
agent_name=agent_name, prompt_type=prompt_type, template_vars=template_vars,
)
return text
except LookupError:
return fallback_text.format(**template_vars)
Dos detalles que no son obvios:
- El fallback es el prompt fijo. El registro es un override, nunca una dependencia dura. Un entorno de dev con las tablas de aprendizaje vacías se comporta exactamente como el código viejo basado en constantes. Puedes adoptarlo un agente a la vez.
-
get_promptincrementausage_county regresa elversion_id. Ese id se estampa en la traza de la petición, así que cuando un resultado aterriza después (Capa 3) se atribuye a la versión exacta del prompt que lo produjo. Sin ese enlace, nada de la puntuación significa algo.
Después de esta capa, cambiar un prompt en prod es un UPDATE (o una fila activa nueva), en vivo en la siguiente petición, sin despliegue. Lo cual levanta la pregunta de verdad: ¿cómo sabes que la fila nueva es mejor?
Capa 3: calidad desde los resultados, no desde las opiniones
Este es el meollo, y donde la mayoría de la "evaluación de prompts" se equivoca. Dos tipos de señal, ponderadas a propósito:
Explícita: el usuario te dice. Un 👍/👎 o una estrella del 1 al 5 sobre la salida. Honesta pero escasa y sesgada (la gente califica cuando está molesta). Vale la pena recolectarla, vale la pena ponderarla de menos.
Implícita: el mundo te dice. ¿La cosa que el agente produjo de verdad funcionó? Estos son eventos de negocio reales, atados de regreso a la versión del prompt vía la traza de la Capa 2:
# Las señales implícitas son resultados, no la autoevaluación del modelo:
# blog_accepted — la publicacion que el agente ayudó a proponer fue ACEPTADA
# post_published — el borrador que el agente escribió se publicó
# message_converted — el mensaje que el agente redactó convirtió un lead
# doc_reuploaded — (negativo) el usuario re-subió → la primera pasada falló
Un worker recorre los resultados recientes y registra cada uno como un éxito/fracaso contra la versión que lo generó. Luego el puntuador acumula todo:
# combined_score = explícito·0.4 + implícito·0.6
explicit = (avg_rating / 5 * 100) * 0.75 + approval_rate * 0.25
implicit = implicit_success_rate # 0–100, la verdad de combined = explicit * 0.4 + implicit * 0.6
Lo implícito pesa más que lo explícito (0.6 vs 0.4) a propósito. Un prompt que la gente dice que le gusta pero cuyas propuestas de blog nunca se aceptan es un peor prompt que uno tosco que sí consigue aceptaciones. Los resultados son la verdad de fondo; las calificaciones son una pista.
La misma pasada calcula una tendencia comparando el combined_score de hoy con el puntaje del mismo prompt de hace una semana (improving | stable | declining). La tendencia, no solo el puntaje absoluto, es lo que marca un prompt que se está pudriendo calladito mientras el mundo cambia a su alrededor.
El principio que vale la pena tatuarse en la pared: nunca dejes que el modelo califique su propia salida. "Pídele a GPT que califique esta respuesta del 1 al 10" mide fluidez, no utilidad. Ata el puntaje a algo que pase después de que la respuesta sale del sistema.
Capa 4: enrutamiento A/B por versión
Un prompt candidato nuevo no se activa: se registra como una variante y un A/B test enruta una rebanada del tráfico hacia él:
# start_test: registra la variante (NO activa), abre un test 90/10
variant = await registry.register_prompt(..., activate=False)
db.add(ABTest(agent_name=..., prompt_type=...,
control_version_id=control.id, variant_version_id=variant.id,
control_traffic_pct=90, variant_traffic_pct=10,
min_samples=50, status="RUNNING"))
La resolución (Capa 2) revisa primero si hay un test RUNNING y tira los dados:
test = await running_test_for(agent_name, prompt_type)
if test:
chosen = test.variant_version_id if randint(1, 100) <= test.variant_traffic_pct \
else test.control_version_id
return version_by_id(chosen)
return active_version(agent_name, prompt_type) # sin test → la fila activa
90/10, no 50/50: una variante mala solo grava el 10% del tráfico mientras se prueba a sí misma o muere. El control sigue sirviendo el otro 90%.
Capa 5: declarar un ganador (o no)
La evaluación corre en un calendario. Las reglas son aburridas a propósito: lo aburrido es lo que te evita promover ruido:
if variant.usage_count < test.min_samples: # < 50 usos
return "not_enough_samples" # sigue esperando
diff = variant.combined_score - control.combined_score
if abs(diff) >= 10: # WINNER_DIFF_PTS
winner = variant if diff > 0 else control
activate(winner); test.status = "COMPLETED" # promover — intercambio en caliente
return "variant_win" if diff > 0 else "control_win"
if variant.usage_count >= 500: # HARD_STOP_SAMPLES
activate(control); test.status = "COMPLETED" # sin ventaja clara → quédate con el control
return "inconclusive"
return "still_running" # todavía no hay suficiente separación
Tres umbrales, cada uno se gana su lugar:
-
min_samples = 50: por debajo de esto, un "gane" es puro azar. No mires todavía. -
|diff| ≥ 10 puntos: una ventaja de 2 puntos es ruido. Exige una brecha real. -
hard_stop = 500: si 500 usos no los pueden separar, son equivalentes; quédate con el titular y deja de desperdiciar tráfico. Sin un tope duro, los tests inconclusos corren por siempre.
La promoción es nada más activate(winner): el mismo intercambio en
caliente de una escritura de la Capa 1. El prompt nuevo está en vivo en la siguiente petición.
Capa 6: cerrar el ciclo, reescribir a los perdedores
Hasta ahora un humano todavía tiene que escribir la variante. La última pieza automatiza eso. Un optimizador diario encuentra a los de bajo rendimiento:
# de bajo rendimiento si CUALQUIERA:
# usage_count >= 30 AND combined_score < 40 (suficientes datos, mal puntaje)
# performance_trend == "declining" AND combined_score < 60 (pudriéndose)
…le pasa el prompt débil + sus puntajes a un LLM ("aquí hay un prompt y cómo le está yendo; reescríbelo para mejorar los resultados"), y archiva la reescritura como una sugerencia PENDING. El gestor de A/B (Capa 4) toma las sugerencias PENDING y abre tests. El ciclo ahora corre de punta a punta:
sembrar → resolver → medir (resultados) → optimizar (reescribir) → A/B → promover → resolver …
Un humano todavía aprueba lo que entra a un test: el optimizador sugiere, no auto despliega a prod. Pero la escritura, la puntuación, y la promoción son automáticas.
La trampa: un bug tumbó cada query
Una función de autoservicio empezó a fallar con:
Multiple rows were found when one or none was required
El selector de versión del resolvedor usaba scalar_one_or_none():
test = (await db.execute(
select(ABTest).where(ABTest.status == "RUNNING",
ABTest.agent_name == name,
ABTest.prompt_type == ptype))
).scalar_one_or_none() # ← lanza MultipleResultsFound con 2 filas
Nada en el esquema fuerza un único test RUNNING por prompt, y se habían abierto dos con 13 segundos de diferencia. De ahí en adelante, cada petición para ese prompt pegaba contra scalar_one_or_none(), encontraba dos filas, y lanzaba excepción. Peor, el resolvedor solo atrapaba LookupError, así que la excepción se pasó de largo el fallback y salió hasta el usuario.
Dos arreglos, los dos necesarios:
# 1) Tolera duplicados en lugar de tronar: elige el más nuevo, registra la anomalía.
tests = (await db.execute(
select(ABTest).where(...).order_by(ABTest.created_at.desc())
)).scalars().all()
if len(tests) > 1:
logger.warning("multiple_running_abtests", agent=name, type=ptype, count=len(tests))
test = tests[0] if tests else None
-- 2) De duplica el dato: cancela el test RUNNING más viejo.
UPDATE prompt_ab_tests SET status = 'CANCELLED' WHERE id = '<older>';
La lección se generaliza: scalar_one_or_none() es un crash latente en cualquier lado donde "solo debería haber uno" no es una restricción de la base de datos. Si el invariante no lo fuerza un índice único, la ruta de lectura tiene que tolerar la violación, no darla por descartada.
Lo que NO ayudó
- Calidad autocalificada. Pedirle al modelo que puntúe su propia salida medía fluidez, nunca utilidad. Reemplazado por completo por los resultados de aguas abajo.
- Splits A/B 50/50. Gravar la mitad del tráfico con una variante sin probar. 90/10 prueba una variante igual de bien con un décimo del radio de impacto.
- El puntaje absoluto solo. Un prompt en 70 que va declinando es un problema más grande que uno estable en 60. La tendencia atrapó la pudrición que el puntaje escondía.
-
Suponer un solo test RUNNING. Sin restricción única →
scalar_one_or_noneera una bomba de tiempo.
Lecciones
- Puntúa sobre resultados, no sobre opiniones. Todo el sistema se para sobre las señales implícitas: eventos reales atados a la versión del prompt. Todo lo demás es decoración.
- Haz gratis el cambiar un prompt. Un registro + resolución en el momento de la petición convierte "cambiar un prompt" de un despliegue a una fila. La iteración gratis es la precondición para mejorar.
- El override tiene que degradar al constante. Un fallback fijo significa que adoptas de manera incremental y nunca endureces una dependencia del ciclo.
- Los umbrales aburridos le ganan a los ingeniosos. El mínimo de muestras, un requisito de brecha real, y un tope duro son lo que te evita promover ruido.
- Radio de impacto chico para los cambios sin probar. 90/10, no 50/50.
- Si no es una restricción de la base de datos, no es un invariante. Las rutas de lectura tienen que tolerar la segunda fila "imposible".
Top comments (0)