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
-
Sumar latencia también para un
tool_resultconis_error: True. El código delatency_for_runfiltra explícitamentenot 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. -
Olvidar que el costo de un run pequeño es, casi siempre,
0centavos. No es un error del cálculo —es la escala honesta que esta guía repite desde el Módulo 1—. Un umbral decost_threshold_cents: 5en un caso que siempre cuesta0no 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 ser0sin ninguna razón aparente. -
Usar
{**case, "campo": valor}sobre undictanidado esperando que también copie las estructuras internas.{**case3, "latency_threshold_ms": 100}crea una copia superficial: el nuevodicttiene su propiolatency_threshold_ms, peromodel_scriptsigue apuntando a la misma lista que el caso original —está bien para este uso, porque no se modificamodel_script, pero sería un error real si se intentara mutar ese guion in situ esperando que el caso original no cambiara. -
Confundir un umbral estricto "para probar el mecanismo" con un umbral real de producción. El umbral de
100ms 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 delCASE_SET(100/250ms) son los que se usan en la lección 07 en adelante. -
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 encost_for_runylatency_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 decost_for_run(Módulo 3) para tiempo en vez de dinero, reusando sin cambios el modeloTOOL_LATENCY_MSya establecido desde el Módulo 1 y desarrollado a fondo en el Módulo 4. - Confirmamos, ejecutado, que los cinco casos reales del
CASE_SETpasan sus umbrales de costo y latencia con margen —costo0centavos, latencia entre25y210ms, 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
0centavos) — ambos con elCostReport/latencia reales, no con números hechos a mano. - Confirmamos por qué este módulo reusa
cost_for_runyTOOL_LATENCY_MSsin 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
- 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. - Anthropic — Token counting — El conteo real de tokens que la estimación
len(texto)//4(Módulo 3) aproxima, la base de cadacost_centscitado en esta lección. - Python — desempaquetado de diccionarios (
**) — La técnica{**case, "campo": valor}usada para experimentar con umbrales sin modificargolden_cases.json. - 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.
- 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.