Módulo 5: Evals de regresión como gate de producción

Verificando umbrales de costo y latencia

Descripción

Las lecciones 04 y 05 cubrieron dos de las tres preguntas del gate: la forma del resultado, y la elección de la tool. Esta lección cierra la tercera: ¿el costo y la latencia de este run se mantuvieron dentro de un presupuesto conocido de antemano? A diferencia de las dos preguntas anteriores, esta no verifica qué hizo el agente — verifica cuánto costó que lo hiciera, reusando, sin cambiar una sola línea, la ingeniería que ya construiste en los Módulos 3 y 4: cost_for_run para el costo, y el modelo TOOL_LATENCY_MS para la latencia.

Esta lección construye la pieza que faltaba —latency_for_run, que suma la latencia modelada de cada paso de un run, el equivalente exacto de cost_for_run pero para tiempo en vez de dinero— y pone a prueba los dos umbrales sobre los cinco casos reales del CASE_SET, y sobre dos escenarios diseñados a propósito para fallar: uno por latencia, uno por costo.

Conexión con el módulo

Esta lección entrega latency_for_run, la última pieza nueva de regression/harness.py antes de que la lección 07 ensamble todo en run_case y run_regression_gate. check_cost_threshold y check_latency_threshold ya quedaron completas en la lección 02 — aquí se prueban a fondo, integradas con el resto del harness.


latency_for_run: la misma idea de cost_for_run, aplicada a tiempo

cost_for_run (Módulo 3) recorre history y suma texto estimado por dirección (entrada, salida). latency_for_run recorre el mismo history con una lógica casi idéntica, pero suma un número distinto: la latencia modelada de cada tool que respondió con éxito.

TOOL_LATENCY_MS = {
    "list_rooms": 40,
    "get_quote": 25,
    "book_room": 120,
    "cancel_booking": 90,
}


def latency_for_run(history):
    """Latencia modelada de un run: suma de TOOL_LATENCY_MS por cada
    tool_result exitoso. NUNCA time.time()/time.perf_counter() -- la misma
    honestidad del Módulo 4: en producción de verdad esto se mide con el
    reloj real; aquí se modela para que el ejemplo sea reproducible."""
    total_ms = 0
    tool_use_name = {}
    for turn in history:
        content = turn["content"]
        if isinstance(content, str):
            continue
        for block in content:
            if block["type"] == "tool_use":
                tool_use_name[block["id"]] = block["name"]
            elif block["type"] == "tool_result" and not block.get("is_error"):
                total_ms += TOOL_LATENCY_MS.get(tool_use_name.get(block["tool_use_id"]), 0)
    return total_ms

TOOL_LATENCY_MS es el mismo diccionario, con los mismos cuatro valores, que ya viste ejecutado desde el Módulo 1 y que el Módulo 4 desarrolla a fondo con percentiles sobre lotes grandes — este módulo no inventa un modelo de latencia nuevo, reusa el que ya existe. La única tool que no suma nada a un tool_result con is_error: True: un intento rechazado por validación, como ya viste en el Módulo 3 con el costo, sí consume tokens (y por lo tanto costo), pero en el modelo de latencia de esta guía, un rechazo de validación se resuelve antes de que la tool real se ejecute —dispatch_robust corta ahí, sin ejecutar nada—, así que no hay ninguna latencia de tool que sumar para ese paso.


Ejemplo trabajado, parte 1: costo y latencia sobre los cinco casos reales

print("--- costo y latencia sobre los cinco casos reales ---")
for i, case in enumerate(CASE_SET, start=1):
    reset_reservo_state()
    with rl.traced_run(case["question"], i) as trace_id:
        final, history = ra.run_reservo_agent(case["question"], case["model_script"])
    report = cost_for_run(trace_id, case["question"], history)
    latency_ms = latency_for_run(history)
    cost_ok = check_cost_threshold(report.cost_cents, case["cost_threshold_cents"])
    latency_ok = check_latency_threshold(latency_ms, case["latency_threshold_ms"])
    print(f"{case['name']:38} cost={report.cost_cents:>2}c (<= {case['cost_threshold_cents']}) {cost_ok}   "
          f"latency={latency_ms:>3}ms (<= {case['latency_threshold_ms']}) {latency_ok}")

Qué esperar:

--- costo y latencia sobre los cinco casos reales ---
quote_focus_pro_3h                     cost= 0c (<= 5) True   latency= 25ms (<= 100) True
quote_focus_basic_3h                   cost= 0c (<= 5) True   latency= 25ms (<= 100) True
book_focus_pro_3h_ana                  cost= 0c (<= 5) True   latency=185ms (<= 250) True
book_boardroom_pro_1h_sofia            cost= 0c (<= 5) True   latency=185ms (<= 250) True
book_and_cancel_studio_basic_1h_diego  cost= 0c (<= 5) True   latency=210ms (<= 250) True

Cinco de cinco, en ambos umbrales. El costo real de cada uno de estos runs pequeños es 0 centavos —la misma respuesta honesta que acompaña a esta guía desde el Módulo 1—, y cada umbral (5 centavos, generoso a propósito) lo confirma con margen. La latencia varía según cuántas tools llama cada caso: 25 ms para una cotización sola (get_quote), 185 ms para una reserva completa de tres pasos, 210 ms para reservar y cancelar.


Ejemplo trabajado, parte 2: un FAIL por latencia — un umbral demasiado estricto

Los umbrales del CASE_SET real son generosos, y pasan con margen. Para ver el mecanismo de FAIL en acción, sustituye el umbral de latencia de book_focus_pro_3h_ana —normalmente 250 ms— por uno mucho más estricto, 100 ms, muy por debajo de los 185 ms reales que ese caso necesita:

case3 = CASE_SET[2]  # book_focus_pro_3h_ana
strict_case = {**case3, "latency_threshold_ms": 100}
result = run_case(strict_case, 50)
print("resultado:", "PASS" if result.passed else "FAIL")
print(f"latency_ms={result.latency_ms}  umbral={strict_case['latency_threshold_ms']}  latency_ok={result.latency_ok}")

Qué esperar:

resultado: FAIL
latency_ms=185  umbral=100  latency_ok=False

{**case3, "latency_threshold_ms": 100} construye una copia del caso con un único campo cambiado — una técnica útil para experimentar con un umbral sin tocar golden_cases.json. El agente se comportó exactamente igual que siempre: mismas tres tools, mismo orden, mismo resultado. Lo único que cambió fue el presupuesto que este caso específico le exige — y el gate lo marca como FAIL con la misma seriedad que si hubiera elegido la tool equivocada. Un umbral de latencia no es un detalle secundario del gate: para un sistema real, una tool que empieza a tardar mucho más de lo esperado —aunque siga devolviendo el resultado correcto— es una señal operacional legítima de que algo, en la infraestructura detrás de esa tool, se está degradando.


Ejemplo trabajado, parte 3: un FAIL por costo — un run genuinamente verboso

Los cinco casos del CASE_SET real cuestan 0 centavos porque son pequeños — la misma escala honesta que acompaña a toda esta guía. Para ver un FAIL de costo con un CostReport real (no solo con números hechos a mano, como en la lección 02), construye un guion con una respuesta final deliberadamente larga —simulando un agente verboso, el mismo perfil que el Módulo 3 identificó como el que más costo genera, por la asimetría 5x entre tokens de salida y de entrada—:

verbose_script = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Focus pro 3h cuesta $60.00. " + (
            "Este es un texto de relleno para inflar el costo de salida de este run de ejemplo. " * 40
        )}]},
]
case_verbose = {**CASE_SET[0], "model_script": verbose_script, "cost_threshold_cents": 0}
result_v = run_case(case_verbose, 51)
print("resultado:", "PASS" if result_v.passed else "FAIL")
print(f"cost_cents={result_v.cost_cents}  umbral={case_verbose['cost_threshold_cents']}  cost_ok={result_v.cost_ok}")

Qué esperar:

resultado: FAIL
cost_cents=1  umbral=0  cost_ok=False

Con un umbral de 0 centavos —un presupuesto deliberadamente ajustado, para esta demostración—, el texto de relleno (una respuesta cuarenta veces repetida, simulando un agente que se volvió mucho más verboso de lo necesario) empuja el costo estimado a 1 centavo, y el gate lo marca como FAIL. Con el umbral real del CASE_SET (5 centavos), este mismo run habría pasado sin problema — el umbral de cada caso es una decisión de diseño, no un valor universal: un caso que se espera barato (una cotización simple) puede llevar un umbral ajustado; un caso que involucra varios pasos puede llevar uno más generoso, con la disciplina de que, sea cual sea, quede declarado explícitamente en el CASE_SET, nunca improvisado al momento de correr el gate.


Por qué reusar cost_for_run y TOOL_LATENCY_MS, en vez de reconstruirlos aquí

Vale la pena notar, con precisión, lo que esta lección no hizo: no reconstruyó la fórmula de estimate_cost_cents, no volvió a declarar el pricing de claude-sonnet-5, no inventó un modelo de latencia nuevo. Cada una de esas piezas ya existe, ya está probada, y ya se citó con su fuente en los módulos correspondientes. Reconstruirlas aquí —aunque fuera con el mismo código, copiado y pegado— introduciría exactamente el riesgo que esta guía evita en cada módulo: dos copias de la misma lógica que, con el tiempo, alguien actualiza en un lugar y olvida actualizar en el otro. El gate de regresión de este módulo mide con las mismas herramientas que el resto de la guía — nunca las reinventa.

Y, como en cada lección de este módulo, vale la pena repetir la frontera una vez más, aplicada ahora a un número: check_cost_threshold/check_latency_threshold contestan "¿este número está bajo el límite?" — una comparación de FORMA, tan mecánica como <=. Nunca contestan "¿este costo es razonable para el valor que el agente entregó?" ni "¿esta latencia es aceptable para la experiencia del usuario?" — esas son preguntas de calidad, que dependen de contexto de negocio y de criterio humano, y pertenecen a la disciplina de evaluation-frameworks-guide, no a este gate.


Errores comunes

  1. Sumar latencia también para un tool_result con is_error: True. El código de latency_for_run filtra explícitamente not block.get("is_error") — un intento rechazado por validación nunca llega a ejecutar la tool real, así que no tiene ningún tiempo de tool que sumar. Sumarlo de todas formas inflaría la latencia de un run con errores de validación de forma artificial.

  2. Olvidar que el costo de un run pequeño es, casi siempre, 0 centavos. No es un error del cálculo —es la escala honesta que esta guía repite desde el Módulo 1—. Un umbral de cost_threshold_cents: 5 en un caso que siempre cuesta 0 no es "un umbral inútil que nunca falla" — es un umbral correcto para el tamaño real de ese caso, listo para detectar el día en que ese costo deje de ser 0 sin ninguna razón aparente.

  3. Usar {**case, "campo": valor} sobre un dict anidado esperando que también copie las estructuras internas. {**case3, "latency_threshold_ms": 100} crea una copia superficial: el nuevo dict tiene su propio latency_threshold_ms, pero model_script sigue apuntando a la misma lista que el caso original —está bien para este uso, porque no se modifica model_script, pero sería un error real si se intentara mutar ese guion in situ esperando que el caso original no cambiara.

  4. Confundir un umbral estricto "para probar el mecanismo" con un umbral real de producción. El umbral de 100 ms del Ejemplo trabajado, parte 2, es artificialmente bajo, elegido a propósito para producir un FAIL didáctico — no refleja ningún límite real de latencia aceptable para una reserva de tres pasos. Los umbrales reales del CASE_SET (100/250 ms) son los que se usan en la lección 07 en adelante.

  5. Pensar que check_cost_threshold/check_latency_threshold "saben" de dónde viene el número que reciben. No lo saben, y no deberían — reciben un entero ya calculado (cost_cents, latency_ms) y un entero de umbral, y hacen una sola comparación. Toda la responsabilidad de calcular esos números correctamente vive en cost_for_run y latency_for_run, no en las funciones de umbral.


Ejercicios

Ejercicio 1: Encuentra el umbral de latencia mínimo que sigue dando PASS para cada caso (Fácil)

Para los cinco casos del CASE_SET, calcula latency_for_run sobre cada uno (ya lo hiciste en el ejemplo trabajado) y, para cada caso, di cuál es el umbral de latencia más bajo posible que seguiría dando PASS (es decir, exactamente igual a la latencia real de ese caso).

Ver solución
for i, case in enumerate(CASE_SET, start=1):
    reset_reservo_state()
    with rl.traced_run(case["question"], i) as trace_id:
        final, history = ra.run_reservo_agent(case["question"], case["model_script"])
    latency_ms = latency_for_run(history)
    print(f"{case['name']:38} umbral mínimo para PASS: {latency_ms} ms")

Salida esperada:

quote_focus_pro_3h                     umbral mínimo para PASS: 25 ms
quote_focus_basic_3h                   umbral mínimo para PASS: 25 ms
book_focus_pro_3h_ana                  umbral mínimo para PASS: 185 ms
book_boardroom_pro_1h_sofia            umbral mínimo para PASS: 185 ms
book_and_cancel_studio_basic_1h_diego  umbral mínimo para PASS: 210 ms

Explicación: como check_latency_threshold usa <=, el umbral mínimo que sigue dando PASS es exactamente igual a la latencia real —cualquier valor por debajo, aunque sea por 1 ms, produciría FAIL. Esto confirma por qué los umbrales reales del CASE_SET (100/250 ms) llevan margen: un umbral igual a la latencia exacta sería frágil ante cualquier variación mínima y legítima del sistema.

Ejercicio 2: Calcula cuántas repeticiones del texto de relleno hacen falta para cruzar distintos umbrales (Medio)

Usando el patrón del Ejemplo trabajado, parte 3, calcula el cost_cents resultante con 10, 40, y 100 repeticiones del texto de relleno. Encuentra, probando valores, cuántas repeticiones hacen falta para que el costo cruce 1 centavo.

Ver solución
def cost_with_repeats(n):
    script = [
        {"stop_reason": "tool_use", "content": [
            {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
             "input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
        {"stop_reason": "end_turn", "content": [
            {"type": "text", "text": "Focus pro 3h cuesta $60.00. " + (
                "Este es un texto de relleno para inflar el costo de salida de este run de ejemplo. " * n
            )}]},
    ]
    case = {**CASE_SET[0], "model_script": script, "cost_threshold_cents": 999}
    result = run_case(case, 60)
    return result.cost_cents

for n in (10, 40, 100):
    print(f"repeticiones={n:>3}  cost_cents={cost_with_repeats(n)}")

Salida esperada:

repeticiones= 10  cost_cents=0
repeticiones= 40  cost_cents=1
repeticiones=100  cost_cents=2

Explicación: con 10 repeticiones, el texto sigue siendo demasiado corto para cruzar el umbral de redondeo hacia abajo de la aritmética entera (la misma disciplina del Módulo 3); con 40, ya cuesta 1 centavo —el mismo valor confirmado en el ejemplo trabajado—; con 100, sube a 2. El crecimiento no es lineal centavo por centavo porque cada centavo representa una cantidad relativamente grande de tokens de salida (recordando la asimetría 5x del Módulo 3: los tokens de salida son los que más pesan en la factura).

Ejercicio 3: Diseña un caso que falle por costo Y por latencia a la vez (Difícil)

Construye un guion que combine texto de relleno largo en la respuesta final y una secuencia de tools más larga de lo normal (por ejemplo, list_rooms repetido tres veces seguidas antes de get_quote, algo que un agente real nunca haría a propósito, pero que sirve para esta demostración). Ajusta los umbrales de costo y latencia del caso para que ambos fallen. Confirma, con el CaseResult completo, que cost_ok y latency_ok son ambos False, y que passed también lo es.

Ver solución
double_fail_script = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_02", "name": "list_rooms", "input": {}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_03", "name": "list_rooms", "input": {}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_04", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Focus pro 3h cuesta $60.00. " + (
            "Relleno para inflar el costo de este run de ejemplo hasta cruzar el umbral. " * 40
        )}]},
]
double_fail_case = {
    **CASE_SET[0], "model_script": double_fail_script,
    "expected_tools": ["list_rooms", "list_rooms", "list_rooms", "get_quote"],
    "cost_threshold_cents": 0, "latency_threshold_ms": 50,
}
result = run_case(double_fail_case, 70)
print("passed    :", result.passed)
print("cost_ok   :", result.cost_ok, "cost_cents:", result.cost_cents)
print("latency_ok:", result.latency_ok, "latency_ms:", result.latency_ms)

Salida esperada:

passed    : False
cost_ok   : False cost_cents: 1
latency_ok: False latency_ms: 145

Explicación: latency_ms=145 es, exactamente, 40 (list_rooms) * 3 + 25 (get_quote) = 145 — las tres llamadas redundantes a list_rooms sí acumulan latencia real, una por una, porque cada una devuelve un tool_result exitoso. Con el umbral ajustado a 50 ms, ese total cruza el límite; y el texto de relleno de la respuesta final vuelve a producir cost_cents=1, por encima del umbral de 0 de este caso. El punto pedagógico central es que CaseResult permite ver, de un vistazo, cuáles de los cuatro chequeos fallaron —no un solo booleano opaco—, exactamente el nivel de detalle que hace que un GateReport sea útil para diagnosticar, no solo para alarmar.


Resumen y siguiente paso

  • Construimos latency_for_run, la contraparte exacta de cost_for_run (Módulo 3) para tiempo en vez de dinero, reusando sin cambios el modelo TOOL_LATENCY_MS ya establecido desde el Módulo 1 y desarrollado a fondo en el Módulo 4.
  • Confirmamos, ejecutado, que los cinco casos reales del CASE_SET pasan sus umbrales de costo y latencia con margen —costo 0 centavos, latencia entre 25 y 210 ms, según cuántas tools involucra cada caso.
  • Produjimos un FAIL de latencia (un umbral artificialmente estricto sobre un caso real) y un FAIL de costo (un run genuinamente verboso, con texto de relleno que cruza un presupuesto de 0 centavos) — ambos con el CostReport/latencia reales, no con números hechos a mano.
  • Confirmamos por qué este módulo reusa cost_for_run y TOOL_LATENCY_MS sin reconstruirlos: una sola fuente de verdad para cada cálculo, citada donde se definió, nunca duplicada.

Siguiente lección: 07 — El gate: PASS o FAIL el build. Con las tres preguntas del gate ya completas —forma, elección de tool, umbrales—, las juntamos en run_case y run_regression_gate: el veredicto PASS del CASE_SET completo, y el veredicto FAIL cuando se simula una regresión real.


Recursos adicionales

  1. Python — statistics — La librería que el Módulo 4 usa para percentiles sobre lotes grandes de latencia; este módulo usa solo la suma simple por run, la pieza más básica de ese mismo modelo.
  2. Anthropic — Token counting — El conteo real de tokens que la estimación len(texto)//4 (Módulo 3) aproxima, la base de cada cost_cents citado en esta lección.
  3. Python — desempaquetado de diccionarios (**) — La técnica {**case, "campo": valor} usada para experimentar con umbrales sin modificar golden_cases.json.
  4. Anthropic — Building effective agents — Sobre por qué el costo y la latencia de un agente son señales operacionales de primera clase, con la misma seriedad que su corrección funcional.
  5. Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de esta lección, incluidos los dos FAILs demostrados.