Módulo 5: Evals de regresión como gate de producción
Qué verifica un eval de regresión
Descripción
La lección 01 nombró tres preguntas y prometió que cada una se puede contestar con una comparación exacta, sin ninguna interpretación de por medio. Esta lección cumple esa promesa: antes de construir el harness completo —el trabajo de las lecciones 03 a 07—, vas a ver, ejecutadas y en aislamiento, las tres funciones que van a terminar siendo el corazón de regression/harness.py: check_schema, check_tool_choice, y el par check_cost_threshold/check_latency_threshold. Cada una es, deliberadamente, más simple de lo que su nombre sugiere — y esa simplicidad es exactamente el punto de este módulo.
Al final de la lección, vas a juntar las tres sobre un caso real de Reservo —sin todavía el CASE_SET fijo ni la infraestructura completa del gate, eso es trabajo de las lecciones que siguen— y vas a ver, con tus propios ojos, cómo un run entero produce un veredicto PASS con solo cuatro comparaciones deterministas.
Conexión con el módulo
Esta lección entrega la primera versión, completa y funcional, de las cuatro funciones que regression/harness.py va a exponer: check_schema, check_tool_choice, check_cost_threshold, check_latency_threshold. Las lecciones 04, 05 y 06 no las reescriben — las integran con el CASE_SET fijo (lección 03) y las prueban a fondo, con más casos y con las fallas que cada una está diseñada para atrapar.
La analogía, con los tres instrumentos de la inspección
La lección 01 comparó este módulo con una inspección técnica: una lista fija de chequeos, cada uno con un criterio binario, medido con un instrumento. Esta lección le pone nombre a cada instrumento:
- El chequeo de las luces es
check_schema: un sensor simple que confirma que la señal tiene la forma correcta —enciende, o no enciende—, sin ninguna opinión sobre si el diseño de la luz es bonito. - El chequeo de los frenos es
check_tool_choice: pisas el pedal, y el auto frena exactamente donde se espera, o no. No hay un "frenó más o menos bien" — o se detuvo en la distancia esperada, o no. - El chequeo del velocímetro y del cuentakilómetros es
check_cost_threshold/check_latency_threshold: un número, leído de un instrumento, comparado contra un límite fijo. El velocímetro no "opina" sobre si vas rápido — simplemente reporta un número, y el límite decide.
Ningún inspector técnico necesita manejar el auto para hacer estos tres chequeos. Del mismo modo, ninguna de las tres funciones que construyes en esta lección necesita entender de qué trata la pregunta que el usuario le hizo al agente de Reservo — cada una compara una forma, una secuencia, o un número, contra un valor fijo.
Pregunta 1: ¿el resultado de la tool tiene la forma correcta?
check_schema recibe un resultado —el dict (o list) que una tool de Reservo devolvió— y un schema de salida —una descripción, con el mismo vocabulario de type/properties/required que ya usaste para los input_schema de agent-fundamentals, pero aplicado ahora a lo que la tool devuelve, no a lo que recibe—. La función recorre el schema, campo por campo, y junta una lista de errores; una lista vacía significa que el resultado pasó.
_PY_TYPE = {"string": str, "integer": int, "number": (int, float), "boolean": bool, "object": dict, "array": list}
def check_schema(result, schema):
"""Valida un resultado de tool contra su schema de SALIDA: ¿el tipo
raíz coincide?, ¿los campos requeridos están?, ¿cada tipo coincide?
FORMA, nunca contenido -- no evalúa si el valor es 'bueno'."""
errors = []
root_type = _PY_TYPE.get(schema.get("type"))
if root_type and not isinstance(result, root_type):
return [f"tipo raíz debe ser {schema['type']}, llegó {type(result).__name__}"]
props = schema.get("properties", {})
for name in schema.get("required", []):
if name not in result:
errors.append(f"falta el campo requerido '{name}'")
for name, value in result.items():
if name in props:
expected = _PY_TYPE.get(props[name].get("type"))
if expected and not isinstance(value, expected):
errors.append(f"'{name}' debe ser {props[name]['type']}, llegó {type(value).__name__}")
return errors
Con el schema de salida de get_quote —un dict con un único campo requerido, price_cents, de tipo entero—, tres casos: uno válido, uno sin el campo, uno con el tipo equivocado.
get_quote_schema = {"type": "object", "properties": {"price_cents": {"type": "integer"}}, "required": ["price_cents"]}
print("resultado válido :", check_schema({"price_cents": 6000}, get_quote_schema))
print("resultado sin campo :", check_schema({}, get_quote_schema))
print("resultado tipo roto :", check_schema({"price_cents": "6000.00"}, get_quote_schema))
Qué esperar:
resultado válido : []
resultado sin campo : ["falta el campo requerido 'price_cents'"]
resultado tipo roto : ["'price_cents' debe ser integer, llegó str"]
Fíjate en algo importante en el tercer caso: "6000.00" es, en cualquier lectura humana, "el mismo precio" que 6000 — pero check_schema no lo sabe, ni le importa. Su trabajo es confirmar que el tipo es el que el contrato de la tool promete (int, no str), no que el valor "tenga sentido" para un lector humano. Esa distinción —tipo correcto contra valor razonable— es exactamente la línea entre FORMA y CALIDAD que la lección 04 desarrolla a fondo.
Pregunta 2: ¿el agente eligió la tool correcta?
check_tool_choice recibe el history que devuelve run_reservo_agent (sin tocarlo) y una lista de nombres de tools esperados, en orden. Extrae la secuencia real de tools llamadas, y la compara contra la esperada con un único operador: ==.
def extract_tool_sequence(history):
"""La secuencia LITERAL de tools llamadas por el agente, en el orden en
que las llamó -- sin juzgar si la elección fue 'razonable', solo
registrarla."""
return [
block["name"]
for turn in history if not isinstance(turn["content"], str)
for block in turn["content"] if block["type"] == "tool_use"
]
def check_tool_choice(history, expected_tools):
"""Comparación LITERAL: la secuencia de tools obtenida == la secuencia
esperada, exactamente, elemento por elemento. Nunca un score parcial."""
actual = extract_tool_sequence(history)
return actual == expected_tools, actual
Con dos historiales de un solo paso —uno que llama a get_quote, otro que llama a book_room en su lugar—:
history_a = [
{"role": "user", "content": "x"},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "get_quote", "input": {}}]},
]
history_b = [
{"role": "user", "content": "x"},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "book_room", "input": {}}]},
]
print("tool esperada get_quote, agente llamó get_quote:", check_tool_choice(history_a, ["get_quote"]))
print("tool esperada get_quote, agente llamó book_room:", check_tool_choice(history_b, ["get_quote"]))
Qué esperar:
tool esperada get_quote, agente llamó get_quote: (True, ['get_quote'])
tool esperada get_quote, agente llamó book_room: (False, ['book_room'])
check_tool_choice devuelve una tupla: el veredicto booleano, y la secuencia real, siempre — incluso cuando el veredicto es False. Esa segunda parte no es un detalle menor: es lo que permite, en la lección 05, construir un mensaje de FAIL que diga exactamente qué tool se esperaba y cuál se obtuvo, en vez de un simple "algo cambió" sin ninguna pista de qué.
Pregunta 3: ¿el costo y la latencia se mantuvieron bajo el umbral?
Estas dos funciones son, a propósito, las más simples de las cuatro — una sola comparación numérica cada una, sin ningún estado ni ninguna lógica adicional:
def check_cost_threshold(cost_cents, threshold_cents):
return cost_cents <= threshold_cents
def check_latency_threshold(latency_ms, threshold_ms):
return latency_ms <= threshold_ms
print("costo 3 <= umbral 5 :", check_cost_threshold(3, 5))
print("costo 12 <= umbral 5 :", check_cost_threshold(12, 5))
print("latencia 185 <= umbral 250:", check_latency_threshold(185, 250))
print("latencia 185 <= umbral 100:", check_latency_threshold(185, 100))
Qué esperar:
costo 3 <= umbral 5 : True
costo 12 <= umbral 5 : False
latencia 185 <= umbral 250: True
latencia 185 <= umbral 100: False
No hay ningún misterio en estas dos funciones — y ese es, con precisión, el argumento de esta lección: no todo lo que hace falta para un gate confiable tiene que ser complicado. cost_cents viene de cost_for_run (Módulo 3, sin tocar); latency_ms viene del modelo de latencia por tool ya establecido desde el Módulo 1 y desarrollado a fondo en el Módulo 4. La única pieza nueva aquí es el <= que decide si ese número, ya calculado por otro módulo, está dentro del presupuesto.
Ejemplo trabajado: las tres preguntas, sobre un run real
Con las cuatro funciones ya confirmadas por separado, júntalas sobre un run de verdad —"¿Cuánto cuesta Focus pro 3h?", una sola llamada a get_quote, envuelta en traced_run como cualquier run de esta guía—:
import json
import reservo_agent as ra
import run_logger as rl
from cost_calculator import cost_for_run
question = "¿Cuánto cuesta Focus pro 3h?"
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."}]},
]
with rl.traced_run(question, 1) as trace_id:
final, history = ra.run_reservo_agent(question, script)
# Pregunta 2 primero: ¿llamó a la tool correcta?
tool_ok, actual_tools = check_tool_choice(history, ["get_quote"])
# Pregunta 1: el resultado de esa tool, ¿tiene la forma correcta?
result = json.loads(_last_result_for_tool(history, "get_quote")) # helper de la lección 04
schema_errors = check_schema(result, get_quote_schema)
# Pregunta 3: costo y latencia, bajo umbral
report = cost_for_run(trace_id, question, history)
latency_ms = latency_for_run(history) # helper de la lección 06
cost_ok = check_cost_threshold(report.cost_cents, 5)
latency_ok = check_latency_threshold(latency_ms, 100)
print("pregunta 1 (schema) :", schema_errors == [], schema_errors)
print("pregunta 2 (tool choice) :", tool_ok, actual_tools)
print("pregunta 3a (costo) :", cost_ok, f"{report.cost_cents} <= 5 centavos")
print("pregunta 3b (latencia) :", latency_ok, f"{latency_ms} <= 100 ms")
print("PASS del caso :", schema_errors == [] and tool_ok and cost_ok and latency_ok)
Qué esperar:
pregunta 1 (schema) : True []
pregunta 2 (tool choice) : True ['get_quote']
pregunta 3a (costo) : True 0 <= 5 centavos
pregunta 3b (latencia) : True 25 <= 100 ms
PASS del caso : True
Cuatro comparaciones deterministas, ninguna llamada a un modelo, y un veredicto claro. _last_result_for_tool y latency_for_run son dos funciones auxiliares —una para encontrar el último resultado exitoso de una tool específica dentro de history, otra para sumar la latencia modelada de cada paso— que las lecciones 04 y 06 construyen y explican en detalle; aquí se usan por nombre para que veas el flujo completo antes de entrar en cada pieza. El resto de este módulo, lecciones 03 a 07, es la construcción disciplinada de exactamente este mismo patrón, aplicado a un CASE_SET fijo de cinco casos en vez de a uno solo escrito a mano.
Por qué estas tres preguntas, y no otras
Vale la pena preguntarse por qué el gate de este módulo se detiene exactamente en estas tres preguntas, y no en, por ejemplo, "¿la respuesta final suena natural?" o "¿el agente resolvió la tarea de la forma más eficiente posible?". La respuesta tiene que ver con una propiedad que las tres preguntas comparten, y que ninguna pregunta sobre calidad tiene: cada una se puede contestar con una función pura, sin estado, sin aleatoriedad, y sin ningún componente que pueda variar entre dos ejecuciones idénticas. check_schema({"price_cents": 6000}, schema) va a devolver exactamente [] hoy, mañana, y dentro de un año, sin importar quién lo ejecute. Esa propiedad —determinismo total— es la que hace posible que un gate de regresión se pueda correr miles de veces, en un pipeline de CI, sin que nadie tenga que revisar manualmente ningún resultado. Una pregunta como "¿suena natural?" no tiene esa propiedad: dos lecturas humanas de la misma respuesta pueden discrepar, y ningún criterio puramente sintáctico puede resolver esa discrepancia. Por eso esa pregunta pertenece a otra disciplina —evaluation-frameworks-guide—, con otras herramientas, diseñadas específicamente para lidiar con esa clase de ambigüedad.
Errores comunes
-
Pensar que
check_schema"sabe" cuál es el schema correcto de cada tool. No lo sabe — recibe el schema como un argumento, siempre. Quién decide cuál schema aplicarle a qué resultado es responsabilidad del código que llama acheck_schema, no de la función misma. Esta lección lo pasa a mano (get_quote_schema); la lección 04 construye el diccionarioOUTPUT_SCHEMASque centraliza esa decisión para las cuatro tools de Reservo. -
Confundir el orden de los argumentos de
check_tool_choice. La función espera(history, expected_tools)— el resultado real primero, la expectativa después. Invertirlos no produce un error de Python (ambos son listas/estructuras compatibles con la comparación), pero sí produce un mensaje de diagnóstico confuso si, más adelante, alguien intenta imprimir "tool esperada X, obtenida Y" con los valores cambiados de lugar. -
Usar
<en vez de<=en los chequeos de umbral. Un umbral que se toca exactamente (cost_cents == threshold_cents) es, por diseño de esta guía, un PASS — el umbral es el límite máximo aceptable, no un valor prohibido.check_cost_threshold(5, 5)devuelveTrue, noFalse. -
Olvidar que
check_schemasobre un tipo raíz equivocado devuelve inmediatamente, sin seguir revisando campos. Siresultno es del tipo queschema["type"]espera (por ejemplo, una lista en vez de undict), no tiene sentido seguir buscando campos dentro de una estructura que ni siquiera es del tipo correcto — la función corta ahí, con un solo mensaje de error, en vez de intentar iterar sobre algo que podría no ser iterable de la forma esperada. -
Pensar que estas cuatro funciones "ya son" el gate completo. Son las piezas — el gate completo necesita, además, un
CASE_SETfijo que declare qué se espera de cada caso (lección 03), y una función que las junte y agregue un veredicto por caso y por lote (lección 07). Confundir las piezas sueltas con el sistema completo es adelantarse al trabajo de las lecciones que siguen.
Ejercicios
Ejercicio 1: Valida el schema de book_room (Fácil)
El schema de salida de book_room es {"type": "object", "properties": {"booking_id": {"type": "integer"}, "confirmed": {"type": "boolean"}}, "required": ["booking_id", "confirmed"]}. Usa check_schema para validar tres resultados: uno válido ({"booking_id": 1, "confirmed": True}), uno con confirmed como string ("true" en vez de True), y uno sin booking_id.
Ver solución
book_room_schema = {
"type": "object",
"properties": {"booking_id": {"type": "integer"}, "confirmed": {"type": "boolean"}},
"required": ["booking_id", "confirmed"],
}
print(check_schema({"booking_id": 1, "confirmed": True}, book_room_schema))
print(check_schema({"booking_id": 1, "confirmed": "true"}, book_room_schema))
print(check_schema({"confirmed": True}, book_room_schema))
Salida esperada:
[]
["'confirmed' debe ser boolean, llegó str"]
["falta el campo requerido 'booking_id'"]
Explicación: el segundo caso es el más fácil de pasar por alto en una revisión manual — "true" (string) y True (booleano) se leen igual para un humano, pero son tipos distintos en Python, y check_schema los distingue con precisión, exactamente como lo haría cualquier consumidor real de ese JSON que espere un booleano de verdad.
Ejercicio 2: Construye una secuencia de tres tools y verifica dos expectativas distintas (Medio)
Construye un history (a mano, sin correr el agente) que represente la secuencia list_rooms → get_quote → book_room. Usa check_tool_choice dos veces: una con la secuencia correcta como expectativa, otra con ["get_quote", "book_room"] (sin list_rooms) como expectativa incorrecta.
Ver solución
history_three = [
{"role": "user", "content": "x"},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "list_rooms", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "[]"}]},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t2", "name": "get_quote", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t2", "content": "{}"}]},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t3", "name": "book_room", "input": {}}]},
]
print(check_tool_choice(history_three, ["list_rooms", "get_quote", "book_room"]))
print(check_tool_choice(history_three, ["get_quote", "book_room"]))
Salida esperada:
(True, ['list_rooms', 'get_quote', 'book_room'])
(False, ['list_rooms', 'get_quote', 'book_room'])
Explicación: extract_tool_sequence siempre devuelve la secuencia REAL, sin importar contra qué se compare — por eso la segunda línea, aunque el veredicto es False, sigue mostrando los tres nombres reales. La comparación falla porque una lista de tres elementos nunca es == a una lista de dos, sin importar que los dos elementos de la segunda estén, en el mismo orden, contenidos dentro de la primera — check_tool_choice no busca subsecuencias, exige coincidencia exacta de principio a fin.
Ejercicio 3: Diseña el schema de salida de cancel_booking y prueba los tres casos límite (Difícil)
cancel_booking devuelve {"cancelled": bool}. (a) Escribe su schema de salida. (b) Prueba check_schema contra: un resultado válido, un resultado con una clave extra no declarada en el schema ({"cancelled": True, "note": "ok"}), y un resultado con cancelled ausente. (c) Para el caso de la clave extra: explica, en una frase, por qué check_schema —tal como está escrita en esta lección— no reporta ningún error para esa clave, y si eso es una decisión de diseño correcta para un chequeo de FORMA.
Ver solución
(a)
cancel_booking_schema = {"type": "object", "properties": {"cancelled": {"type": "boolean"}}, "required": ["cancelled"]}
(b)
print(check_schema({"cancelled": True}, cancel_booking_schema))
print(check_schema({"cancelled": True, "note": "ok"}, cancel_booking_schema))
print(check_schema({}, cancel_booking_schema))
Salida esperada:
[]
[]
["falta el campo requerido 'cancelled'"]
(c) check_schema solo recorre las claves de result que sí están declaradas en props (for name, value in result.items(): if name in props:) — una clave adicional, no declarada, se ignora en silencio, nunca produce un error. Esta es una decisión de diseño correcta para un chequeo de FORMA orientado a regresión: el objetivo es detectar cuando algo que se esperaba dejó de estar, o cambió de tipo — no impedir que la tool devuelva información adicional en el futuro. Un schema que rechazara cualquier campo no anticipado sería frágil ante evoluciones legítimas del sistema (por ejemplo, si cancel_booking empezara a devolver también un refund_cents), y ese tipo de fragilidad no es lo que este módulo busca — busca detectar rupturas, no impedir crecimiento.
Resumen y siguiente paso
- Construimos las cuatro funciones que van a ser el corazón de
regression/harness.py:check_schema(¿la forma del resultado es correcta?),check_tool_choice(¿la secuencia de tools coincide, literalmente?),check_cost_thresholdycheck_latency_threshold(¿el número está bajo el límite?). - Confirmamos, ejecutado, que las cuatro son funciones puras y deterministas: mismo input, siempre el mismo output, sin ningún componente probabilístico.
- Juntamos las cuatro sobre un run real de Reservo, y produjimos el primer veredicto PASS de todo el módulo, con las cuatro comparaciones citadas.
- Explicamos por qué estas tres preguntas —y no "¿suena natural?" o "¿fue eficiente?"— son las que este gate puede contestar: porque, y solo porque, cada una admite una comparación exacta contra un valor fijo.
Siguiente lección: 03 — El set fijo de casos. Con las cuatro funciones ya confirmadas en aislamiento, construimos regression/golden_cases.json: el conjunto fijo de cinco casos de Reservo que este módulo va a correr, una y otra vez, cada vez que algo cambie.
Recursos adicionales
- Anthropic — Tool use (function calling) overview — La forma de
input_schemaque este módulo reutiliza, con el mismo vocabulario, para describir la forma de un resultado de salida. - Python —
isinstance— La función central decheck_schema, y por québooles, técnicamente, una subclase deinten Python (algo que vale la pena verificar antes de confiar ciegamente en un chequeo de tipos). - Python — comparación de secuencias — Cómo Python compara dos listas con
==: elemento por elemento, en orden, sin ninguna noción de "subsecuencia" — la base exacta decheck_tool_choice. - Anthropic — Building effective agents — Sobre por qué un sistema agentic confiable necesita chequeos deterministas y repetibles, no solo revisión manual.
- Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de esta lección.