Volver a los artículos

Evaluaciones automáticas: cómo saber que tu cambio de prompt no rompió nada

Evaluaciones automáticas: cómo saber que tu cambio de prompt no rompió nada
Hace un tiempo estuve en un equipo que construía un chat de servicio al cliente. No era un chat que solo devolviera texto amable: para decidir qué contestar consultaba varios endpoints, y de esas respuestas dependía si ofrecía un reembolso, si escalaba a una persona o si se limitaba a explicar una política.
El problema no apareció en el código. Apareció en las revisiones con producto. Alguien probaba el chat y decía que sonaba muy frío. Ajustábamos el prompt, lo probaba de nuevo y ahora sonaba demasiado confianzudo. Ajustábamos otra vez y, en esa tercera vuelta, alguien notaba que un flujo que nadie había tocado había dejado de funcionar bien. Con los guardrails pasaba lo mismo: agregábamos uno para cerrar un caso puntual y se rompía otra cosa en la otra punta del producto.
Lo interesante es qué se rompía. Casi nunca era que el chat llamara a la herramienta equivocada, que es lo primero que uno esperaría. Era más sutil: el dato estaba ahí, el endpoint lo había devuelto, y la respuesta lo ignoraba. El chat consultaba la política de reembolsos, recibía que el pedido estaba fuera de plazo y de todas formas le ofrecía el reembolso al cliente.
¿Cómo lo encontrábamos? A mano. QA revisaba unos 300 casos, uno por uno, y cada regresión completa tomaba semanas. Estábamos en desarrollo, así que no se perdió dinero: se perdió algo que en esa etapa vale más, que es el tiempo entre tener una idea y saber si esa idea rompió algo.
Hoy esos mismos 300 casos corren solos en cada push al PR y tardan unos 6 minutos. Este post trata de qué hay en el medio.

Por qué un cambio de tono rompe otra cosa

La primera explicación es la más incómoda: en un sistema con LLM el prompt es una superficie compartida. Todos los flujos leen el mismo texto. Cuando escribes sé más cálido y cercano para arreglar el saludo, esa frase también la lee el modelo mientras decide si corresponde un reembolso, y ahí cercano puede terminar significando dale el gusto al cliente. En un prompt no existe el cambio local.

Un prompt, todos los flujos

ajuste de tonosystem promptreembolsoestado del pedidocambio de direcciónescalar a humanoun cambio entra por arriba y baja a los cuatro
La segunda explicación es que la salida no es determinista. El mismo caso, con el mismo prompt y el mismo modelo, puede pasar hoy y fallar mañana. Eso rompe el supuesto sobre el que se apoya cualquier test que conozcas: que una entrada fija produce una salida fija. Con un LLM no evalúas una salida, evalúas una distribución de salidas, y a una distribución solo la ves corriendo el caso varias veces.
Y la tercera: un test unitario verifica que tu código hace lo que escribiste. En ese chat los tests pasaban, porque el código hacía exactamente lo que decía hacer: armaba el prompt, llamaba al endpoint y devolvía la respuesta del modelo. Nadie estaba verificando lo único que importaba, que era si la conversación había salido bien. Ya conté antes que los tests no son cosa de QA; esto es la continuación de ese argumento, en un terreno donde el test clásico no llega.

Qué es una evaluación automática

Una evaluación automática tiene tres piezas, y ninguna es complicada por separado: un dataset de casos, una tarea que corre tu sistema sobre cada caso, y uno o más jueces que puntúan el resultado. Eso es todo. La dificultad no está en la mecánica, está en decidir qué se puntúa. En inglés a esto se le llama evals, por si quieres buscar más.
Para lo que sigue voy a usar un ejemplo inventado, que no es el chat de la anécdota: un chat de servicio al cliente de una tienda en línea con cinco herramientas, get_order, check_refund_policy, create_refund, update_shipping_address y escalate_to_human.
Antes de evaluar nada hay que poder ver lo que pasó. Si el chat no deja rastro de qué endpoint llamó y qué le devolvió, un juez solo puede opinar sobre el texto final, que es la parte menos interesante. Con Langfuse eso son unas pocas líneas:
chat_agent.py
from langfuse import get_client, observe, propagate_attributes

langfuse = get_client()


@observe(name="support-chat-turn", as_type="agent")
def handle_turn(conversation_id: str, customer_id: str, message: str) -> dict:
    """One turn of the support chat, recorded as one trace."""
    with propagate_attributes(
        session_id=conversation_id,
        user_id=customer_id,
        tags=["support-chat"],
    ):
        return run_agent_loop(load_history(conversation_id), message)


def check_refund_policy(order_id: str) -> dict:
    """Every endpoint the chat calls is one tool observation inside that trace."""
    with langfuse.start_as_current_observation(
        as_type="tool", name="check_refund_policy"
    ) as span:
        policy = refunds_api.policy_for(order_id)
        span.update(output=policy)
        return policy
Lo único que hace falta retener de ese código es qué devuelve handle_turn, porque de eso dependen todos los jueces que vienen: un diccionario con answer, el texto que ve el cliente; tool_calls, la lista de llamadas que hizo; y tool_results, lo que devolvió cada endpoint. Si quieres el detalle de cómo instrumentar un agente con Langfuse, lo conté en el post sobre la factura de un agente.
Con las trazas en su lugar, la pregunta es qué se evalúa. En un chat que usa herramientas hay cuatro cosas distintas, y confundirlas es la razón más común por la que una evaluación no sirve para nada:
Qué se evalúaCon qué juezEjemplo de falla
La trayectoriaCódigoCrea el reembolso sin haber consultado la política
El uso del datoLLM como juezLa política dice que el pedido está fuera de plazo y la respuesta igual ofrece el reembolso
El tonoLLM como juez, con anclasUn "estimado cliente" donde el resto del producto tutea, o un chiste en un reclamo
Los guardrailsCódigo si la regla es exacta, LLM si pide criterioMenciona un descuento que no existe
El primer juez no necesita un LLM, y por eso es el que conviene escribir primero: es una regla exacta sobre la trayectoria. Nunca crear un reembolso sin haber consultado la política.
evaluators/trajectory.py
from langfuse import Evaluation


def policy_checked_before_refund(*, output, **kwargs) -> Evaluation:
    """A refund must never be created before the policy was checked."""
    calls = [call["name"] for call in output["tool_calls"]]

    if "create_refund" not in calls:
        return Evaluation(
            name="policy_checked_before_refund",
            value=True,
            comment="No refund in this conversation",
        )
    if "check_refund_policy" not in calls:
        return Evaluation(
            name="policy_checked_before_refund",
            value=False,
            comment="Refund created without checking the policy",
        )
    return Evaluation(
        name="policy_checked_before_refund",
        value=calls.index("check_refund_policy") < calls.index("create_refund"),
        comment="Checked the order of the calls",
    )
El segundo sí necesita criterio, porque la pregunta es si la respuesta respetó lo que devolvió el endpoint. Esa es exactamente la falla que nos costaba semanas encontrar a mano, y no hay forma de escribirla como una comparación de cadenas.
evaluators/grounding.py
import anthropic
from langfuse import Evaluation
from pydantic import BaseModel

judge = anthropic.Anthropic()

RUBRIC = """You grade one customer support conversation on a single dimension:
did the answer respect what the tools returned?

1.0 = the answer uses the tool data and contradicts none of it.
0.5 = the answer ignores part of the tool data but contradicts nothing.
0.0 = the answer contradicts the tool data. Example: it offers a refund
      after check_refund_policy returned that the order is out of the window.

Quote the evidence from the conversation in one sentence."""


class Verdict(BaseModel):
    score: float
    evidence: str


def answer_matches_tool_data(*, output, **kwargs) -> Evaluation:
    response = judge.messages.parse(
        model="claude-sonnet-5",
        max_tokens=1000,
        system=RUBRIC,
        messages=[
            {
                "role": "user",
                "content": f"Tool results:\n{output['tool_results']}\n\nAnswer:\n{output['answer']}",
            }
        ],
        output_format=Verdict,
    )
    verdict = response.parsed_output
    return Evaluation(
        name="answer_matches_tool_data",
        value=verdict.score,
        comment=verdict.evidence,
    )
Dos detalles de ese juez valen más que el código. El primero son las anclas: la rúbrica no dice "califica del 0 al 1", dice qué significa cada número. Sin anclas, dos corridas del mismo juez sobre la misma conversación no puntúan igual, y entonces el juez agrega ruido en vez de quitarlo. El segundo es que devuelve evidencia citada, porque un puntaje sin evidencia no se puede discutir: cuando el juez se equivoca, quieres verlo enseguida.
Y una decisión de costo: el juez corre cientos de veces por cada cambio, así que usa un modelo más barato que el del chat. No necesita resolver la conversación, solo juzgar una dimensión de algo que ya pasó.

El juez también se equivoca

Antes de confiar en un juez hay que calibrarlo. Toma unas decenas de casos, puntúalos a mano y compara con lo que dijo el juez. Si no coinciden, el problema casi siempre es la rúbrica y no el modelo: o la dimensión que pediste medir eran dos dimensiones disfrazadas de una.

La regresión: por qué 300 casos y no 20

Cuando pruebas a mano estás muestreando. Revisas diez o veinte conversaciones, todo se ve bien y concluyes que el cambio no rompió nada. El problema es que un cambio de prompt casi nunca rompe todo: rompe una fracción. Y una fracción pequeña es exactamente lo que una muestra chica no alcanza a ver.
La cuenta es la de siempre. Si un cambio rompe una fracción p de las conversaciones y pruebas N casos, la probabilidad de que al menos uno falle es 1 - (1 - p)^N. Con esa fórmula, la discusión sobre cuántos casos hacen falta deja de ser una cuestión de opinión:
Si el cambio rompe...20 casos100 casos300 casos
el 1% de las conversaciones18%63%95%
el 2% de las conversaciones33%87%99.8%
Vale la pena leer la primera fila dos veces. Un cambio que rompe una de cada cien conversaciones, que en producción es un montón, pasa desapercibido cuatro de cada cinco veces si lo revisas con veinte casos. No porque quien probó lo hiciera mal: porque veinte casos no dan para verlo.
Y esos números son el mejor caso posible. La fórmula supone que tus casos tocan el flujo que se rompió y que el juez detecta la falla. Si el dataset no cubre ese flujo, la probabilidad de encontrarlo es cero por más casos que agregues. De ahí que lo importante no sea solo cuántos casos tienes, sino de dónde salieron.

De dónde salen los casos

La primera fuente es el tráfico que ya tienes. Si el chat está instrumentado, cada conversación real que pasó por producción es un caso candidato, con sus datos y sus rarezas, que son las que nadie inventa sentado en una reunión. Langfuse permite crear un caso directamente desde la traza que lo originó, así que el dataset se arma eligiendo conversaciones, no escribiéndolas.
La segunda fuente son los errores que ya te costaron caros. Cada bug que se arregla entra al dataset como un caso permanente, y a partir de ahí ese error concreto no puede volver sin que alguien se entere. Es la misma disciplina que ya aplicas cuando escribes un test que reproduce un bug antes de corregirlo.
La tercera son usuarios simulados: otro LLM conversando con el tuyo, con una instrucción de personalidad y un objetivo, para cubrir los flujos y los tonos que el tráfico real todavía no trajo. Es la fuente menos confiable de las tres, porque un usuario simulado se comporta mejor que uno real, pero es la única que te deja probar un flujo que aún no lanzaste.
Sumado a eso está el no determinismo, que ya mencioné. Como el mismo caso puede pasar una vez y fallar la siguiente, lo honesto es repetir cada caso varias veces dentro de la corrida y mirar la tasa de aprobación, no el resultado de una sola pasada. Eso multiplica el número de conversaciones por corrida, y es la razón por la que estas suites se cuentan en miles aunque el dataset tenga cientos.
build_dataset.py
from langfuse import get_client

langfuse = get_client()
langfuse.create_dataset(name="support-chat-regression")

for trace in picked_production_traces:
    langfuse.create_dataset_item(
        dataset_name="support-chat-regression",
        input={
            "message": trace.input["message"],
            "customer_id": trace.input["customer_id"],
        },
        metadata={"flow": trace.metadata["flow"]},
        source_trace_id=trace.id,
    )

langfuse.flush()

Cómo se corre

Con el dataset armado, una corrida es una función que ejecuta tu chat igual que en producción, la lista de jueces que ya escribimos y un número de concurrencia:
run_regression.py
import os

from langfuse import get_client

from chat_agent import handle_turn
from evaluators.grounding import answer_matches_tool_data
from evaluators.trajectory import policy_checked_before_refund

langfuse = get_client()
dataset = langfuse.get_dataset("support-chat-regression")


def task(*, item, **kwargs) -> dict:
    """Run the chat exactly as production runs it, one dataset item at a time."""
    return handle_turn(
        conversation_id=f"eval-{item.id}",
        customer_id=item.input["customer_id"],
        message=item.input["message"],
    )


result = dataset.run_experiment(
    name="support-chat-regression",
    run_name=f"pr-{os.environ['PR_NUMBER']}-{os.environ['COMMIT_SHA'][:7]}",
    task=task,
    evaluators=[policy_checked_before_refund, answer_matches_tool_data],
    max_concurrency=20,
)

print(result.dataset_run_url)
langfuse.flush()
El parámetro que cambia la escala es max_concurrency. Los casos no corren en fila: corren en paralelo, y por eso 300 conversaciones completas caben en minutos. También por eso pasar de 300 a 3000 casos es un cambio de configuración y una factura de tokens más alta, no otra semana del tiempo de alguien. El límite deja de ser humano y pasa a ser presupuestario, que es un límite mucho más fácil de discutir.
Cada corrida queda guardada y comparable contra las anteriores, y cuando un caso falla puedes abrir su traza y ver la conversación completa: qué endpoint se llamó, qué devolvió y qué contestó el chat. Ese es el bucle entero.

El circuito de una corrida

300 casosel chatjuecespuntajeslínea baseel PR pasael PR falla

Meterlo en el CI

Todo lo anterior sirve de poco si alguien tiene que acordarse de correrlo. La corrida se dispara sola en cada push al PR y su resultado se compara contra una línea base aprobada: si algún juez cae por debajo de su umbral, el PR falla, igual que cuando se rompe un test. Ahí es donde esto deja de ser un experimento y empieza a proteger algo.
Hay dos decisiones de diseño que importan más que el script. La primera es que el umbral va por juez y no sobre un promedio general.

Umbral por juez, no promedio

Si promedias todos los jueces en un solo número, un flujo que se cayó por completo se esconde detrás de los que siguen bien. El promedio baja un poco, nadie se alarma, y el reembolso quedó roto. Cada juez necesita su propio umbral, porque cada juez representa algo que puede fallar solo.
La segunda decisión es dónde vive la línea base. Para CI, Langfuse recomienda guardarla en un archivo aprobado del repositorio y comparar contra él, en vez de consultar la corrida anterior. Suena menos elegante y funciona mejor: la línea base pasa a ser algo que alguien aprueba explícitamente en un PR, y no un número que se mueve solo cada vez que alguien corre algo.
ci_gate.py
import json
import sys

from langfuse import Evaluation

TOLERANCE = 0.02


def mean_scores(*, item_results, **kwargs) -> list[Evaluation]:
    """One aggregate per judge, so the gate can look at each flow on its own."""
    names = {evaluation.name for r in item_results for evaluation in r.evaluations}
    aggregates = []
    for name in sorted(names):
        scores = [
            float(evaluation.value)
            for r in item_results
            for evaluation in r.evaluations
            if evaluation.name == name
        ]
        aggregates.append(
            Evaluation(name=f"mean::{name}", value=sum(scores) / len(scores))
        )
    return aggregates


# result comes from dataset.run_experiment(..., run_evaluators=[mean_scores])
with open("ci/approved-baseline.json") as f:
    baseline = json.load(f)

regressions = [
    f"{e.name}: {baseline[e.name]:.2f} -> {e.value:.2f}"
    for e in result.run_evaluations
    if e.name in baseline and e.value < baseline[e.name] - TOLERANCE
]

if regressions:
    print("This PR regressed against the approved baseline:")
    print("\n".join(regressions))
    sys.exit(1)
La tolerancia de ese script no es decoración. Como la salida no es determinista, dos corridas idénticas no devuelven el mismo número, y un umbral sin margen convierte el filtro en una fuente de fallas aleatorias. Un filtro que falla por ruido se termina ignorando, y un filtro ignorado es peor que no tener ninguno.

Cierra el cliente antes de salir

En un script corto como el del CI, el proceso termina antes de que Langfuse alcance a enviar lo último que registró. Por eso va langfuse.flush() al final: sin eso pierdes justo las trazas y los puntajes de la corrida que te importaba mirar.

Qué hace QA ahora

QA no desapareció de esta historia, y esa es la parte que más me gusta. Dejó de repetir 300 casos que una máquina puede repetir mejor, y pasó a lo que una persona hace mejor que cualquier juez: explorar, inventar la conversación torcida que a nadie se le ocurrió escribir, probar los flujos nuevos antes de que existan casos. El trabajo repetitivo se automatizó; el criterio, no.

Lo que esto no resuelve

Un juez se equivoca, y cuando se equivoca lo hace con total seguridad, igual que el sistema que está evaluando. Por eso hay que calibrarlo contra personas cada cierto tiempo y tratarlo como lo que es: otro componente que puede tener bugs. Además, el dataset envejece. Un conjunto de casos que se armó hace seis meses describe un producto que ya no existe, y un juez que aprueba todo casi siempre está midiendo algo que dejó de importar.
Y cuesta dinero. Cada push dispara cientos de conversaciones completas más sus jueces, y eso se paga en tokens. Es barato comparado con semanas de trabajo, pero no es gratis, y conviene mirarlo con las mismas herramientas con las que se mira cualquier factura de un sistema con LLM, que es de lo que hablé en el post sobre la factura de un agente.
Si algo me quedó de esa época es que el cambio de fondo no fue técnico. La pregunta siempre fue la misma: ¿esto rompió algo? Lo que cambió fue cuánto cuesta responderla. Antes costaba semanas del tiempo de otra persona, así que la hacíamos poco y tarde, y cada respuesta llegaba cuando ya había tres cambios encima. Ahora cuesta unos 6 minutos y una factura de tokens.
Cuando responder una pregunta se vuelve barato, dejas de elegir cuándo hacerla. Esa es toda la diferencia.
Todos los artículos