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

Forma, no calidad: la frontera

Descripción

Esta es la lección más importante de todo el módulo, y una de las más importantes de toda la guía. Construye la pieza que faltaba de check_schema —el diccionario OUTPUT_SCHEMAS completo, con la forma de salida de las cuatro tools de Reservo, y _last_result_for_tool, la función que encuentra qué resultado validar dentro de un history de varios pasos— y, con esa pieza terminada, se detiene a trazar, con toda la precisión que el DISEÑO de esta guía exige, la frontera exacta entre lo que este módulo verifica y lo que nunca verifica.

Esa frontera tiene un nombre: forma contra calidad. Un chequeo de forma pregunta "¿esto tiene la estructura correcta?" — una pregunta que un programa puede contestar sin ninguna ambigüedad. Un juicio de calidad pregunta "¿esto es bueno?" — una pregunta que, casi siempre, necesita algún tipo de criterio interpretativo, humano o de un modelo entrenado para imitar ese criterio. Este módulo, en su totalidad, vive del lado de la forma. La calidad semántica de un agente de LLM —¿la respuesta responde bien lo que se preguntó?, ¿el razonamiento que llevó a esa respuesta fue sólido?— es un territorio real, tratado a fondo en una guía hermana: evaluation-frameworks-guide. Esta lección no solo lo nombra una vez — lo demuestra, con un ejemplo ejecutado que deja ver, con números reales, dónde termina exactamente lo que este módulo puede contestar.

Conexión con el módulo

Esta lección completa check_schema con OUTPUT_SCHEMAS, el diccionario de las cuatro tools, y agrega _last_result_for_tool y check_expected_output — las tres piezas que la lección 07 va a usar, sin cambios, dentro de run_case.


OUTPUT_SCHEMAS: la forma de salida de las cuatro tools

agent-fundamentals ya te dio el input_schema de cada tool —qué forma tiene lo que el modelo le pide—. Este módulo agrega la mitad que faltaba: qué forma tiene lo que cada tool devuelve. El vocabulario es el mismo (type/properties/required), aplicado ahora al resultado en vez de al argumento:

OUTPUT_SCHEMAS = {
    "list_rooms": {"type": "array", "items": {
        "type": "object",
        "properties": {"room": {"type": "string"}, "rate_cents": {"type": "integer"}},
        "required": ["room", "rate_cents"],
    }},
    "get_quote": {
        "type": "object", "properties": {"price_cents": {"type": "integer"}}, "required": ["price_cents"],
    },
    "book_room": {
        "type": "object",
        "properties": {"booking_id": {"type": "integer"}, "confirmed": {"type": "boolean"}},
        "required": ["booking_id", "confirmed"],
    },
    "cancel_booking": {
        "type": "object", "properties": {"cancelled": {"type": "boolean"}}, "required": ["cancelled"],
    },
}

list_rooms es la única de las cuatro con un schema de tipo "array" — devuelve una lista de objetos, no un solo objeto. check_schema (lección 02) ya sabe manejar esto: cuando el tipo raíz es "array", valida cada elemento de la lista contra schema["items"], y acumula los errores con un prefijo item[i] que dice exactamente cuál elemento falló.

def check_schema(result, schema):
    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__}"]
    if schema.get("type") == "array":
        item_schema = schema.get("items", {})
        for i, item in enumerate(result):
            errors.extend(f"item[{i}].{e}" for e in check_schema(item, item_schema))
        return errors
    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

Confirma el caso del array con un resultado de list_rooms roto a propósito —una tarifa guardada como string en vez de entero, en el primer elemento—:

broken = [{"room": "Focus", "rate_cents": "2500"}, {"room": "Studio", "rate_cents": 4000}]
print(check_schema(broken, OUTPUT_SCHEMAS["list_rooms"]))

Qué esperar:

["item[0].'rate_cents' debe ser integer, llegó str"]

El mensaje señala, con precisión, cuál de los tres elementos de la lista tiene el problema — información que un simple "el schema no valida" no daría.


_last_result_for_tool: qué resultado validar, dentro de un run de varios pasos

Un caso como book_focus_pro_3h_ana llama a tres tools en secuencia — list_rooms, get_quote, book_room. Para validar el resultado de la última, hace falta una función que recorra history y encuentre, específicamente, el tool_result exitoso más reciente que corresponda a esa tool:

def _last_result_for_tool(history, tool_name):
    """El content del último tool_result EXITOSO de `tool_name` en este
    run -- el resultado que se valida contra su OUTPUT_SCHEMAS."""
    tool_use_name = {}
    last_content = None
    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"):
                if tool_use_name.get(block["tool_use_id"]) == tool_name:
                    last_content = block["content"]
    return last_content

El patrón —tool_use_name, un diccionario que empareja el id de cada tool_use con su nombre— es el mismo que ya usaste en cost_for_run (Módulo 3) para reconstruir a qué tool pertenece cada tool_result. La única diferencia es el filtro: en vez de acumular todo, esta función se queda solo con el último content que coincide con tool_name, descartando cualquier intento anterior rechazado por is_error.

Corre el caso completo de Ana y valida cada resultado de tool contra su OUTPUT_SCHEMAS, no solo el último:

case = CASE_SET[2]  # book_focus_pro_3h_ana
reset_reservo_state()
with rl.traced_run(case["question"], 1) as trace_id:
    final, history = ra.run_reservo_agent(case["question"], case["model_script"])

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"):
            name = tool_use_name[block["tool_use_id"]]
            result = json.loads(block["content"])
            errors = check_schema(result, OUTPUT_SCHEMAS[name])
            print(f"{name:15} resultado={result}  errores={errors}")

Qué esperar:

list_rooms      resultado=[{'room': 'Focus', 'rate_cents': 2500}, {'room': 'Studio', 'rate_cents': 4000}, {'room': 'Boardroom', 'rate_cents': 8000}]  errores=[]
get_quote       resultado={'price_cents': 6000}  errores=[]
book_room       resultado={'booking_id': 1, 'confirmed': True}  errores=[]

Los tres pasos del run pasan su chequeo de forma: list_rooms devuelve un array de tres objetos, cada uno con room (string) y rate_cents (entero); get_quote devuelve price_cents como entero; book_room devuelve booking_id (entero) y confirmed (booleano). Este es exactamente el gate que la lección 07 corre sobre los cinco casos completos.


🛑 La frontera exacta, con números: check_schema no atrapa un precio equivocado

Aquí está la demostración central de esta lección, y vale la pena leerla con cuidado. Imagina que, por la razón que sea —una decisión de negocio real, o un error de transcripción en reservo_tools.py—, la tarifa base de Focus cambia de 2500 a 2600 centavos por hora:

rt.ROOM_RATE_CENTS["Focus"] = 2600  # cambio real, a propósito, para esta demostración

case = CASE_SET[0]  # quote_focus_pro_3h
reset_reservo_state()
with rl.traced_run(case["question"], 1) as trace_id:
    final, history = ra.run_reservo_agent(case["question"], case["model_script"])

result = json.loads(_last_result_for_tool(history, "get_quote"))
schema_errors = check_schema(result, OUTPUT_SCHEMAS["get_quote"])
output_errors = check_expected_output(result, case["expected_output"])

print("resultado real                       :", result)
print("check_schema (forma)                 :", schema_errors)
print("check_expected_output (ancla literal):", output_errors)

Qué esperar:

resultado real                       : {'price_cents': 6240}
check_schema (forma)                 : []
check_expected_output (ancla literal): ["'price_cents' esperado=6000, obtenido=6240"]

Aquí está la frontera, con evidencia: check_schema devuelve []sin ningún error. {"price_cents": 6240} tiene exactamente la forma correcta: un dict, con la clave price_cents, de tipo entero. Para check_schema, este resultado es tan válido como {"price_cents": 6000} — porque, con toda razón, lo es: la forma no se rompió. El precio cambió, no el contrato.

Lo que sí atrapa este cambio es check_expected_output —una segunda función, deliberadamente distinta de check_schema—, que compara el valor exacto contra el ancla que el caso declara: 6000 esperado, 6240 obtenido, un error preciso. Nota, con cuidado, qué clase de chequeo es este: sigue siendo una comparación literal, determinista, contra un valor fijo — nunca un juicio de "¿es razonable este precio?". check_expected_output no sabe, ni le importa, si 2600 centavos por hora es un precio justo para una sala de coworking — sabe, únicamente, que este caso específico del CASE_SET ancla a 6000, y que 6240 no es 6000.

def check_expected_output(result, expected_output):
    """Comparación LITERAL contra un valor fijo -- cada clave declarada en
    el caso debe coincidir EXACTO con el resultado real. Esto es lo que
    ancla, por ejemplo, get_quote(Focus, pro, 3h) a 6000 centavos: si algo
    en la aritmética de Reservo cambiara, este chequeo lo atrapa."""
    errors = []
    for key, expected_value in expected_output.items():
        actual_value = result.get(key)
        if actual_value != expected_value:
            errors.append(f"'{key}' esperado={expected_value!r}, obtenido={actual_value!r}")
    return errors

Tres capas, tres preguntas distintas — y solo dos de ellas viven en este módulo

Esta demostración deja ver, con toda claridad, que hay al menos tres preguntas posibles sobre el mismo resultado, y que confundirlas es el error más peligroso de todo este módulo:

  1. "¿Tiene la forma correcta?"check_schema. Determinista, sin ningún valor de referencia — solo el tipo y la estructura. Esta es la capa más permisiva: casi cualquier precio "razonable" la pasa.
  2. "¿Coincide, exacto, con el valor que este caso específico anclaba?"check_expected_output. Determinista también, pero contra un valor fijo, conocido de antemano: 6000, no "un precio razonable". Esta capa es estricta, pero sigue sin necesitar ningún criterio de interpretación — es una comparación de ==, nada más.
  3. "¿Es este un precio razonable para una sala de coworking, en este mercado, en esta ciudad?" — esta pregunta no tiene ninguna función en este módulo, y no la va a tener nunca. Contestarla exige un criterio externo —una investigación de mercado, un rango aceptable definido por un humano, quizás un modelo que compare contra datos reales— que no es una comparación de forma ni una comparación literal contra un ancla fija. Esta pregunta, y cualquiera con la misma estructura (¿la respuesta es clara?, ¿el tono es apropiado?, ¿la explicación es correcta?), pertenece por completo a evaluation-frameworks-guide.

La diferencia entre la pregunta 2 y la pregunta 3 es sutil pero decisiva, y vale la pena decirla una vez más, sin rodeos: ambas comparan un valor contra una referencia, pero la referencia de la pregunta 2 es un número fijo, escrito en el CASE_SET, mientras que la referencia de la pregunta 3 no existe como un valor único — depende de contexto, de juicio, de criterio. check_expected_output nunca "decide" si 6000 es un buen precio — alguien, humano, ya decidió eso al escribir el caso; la función solo confirma que el sistema sigue produciendo ese número exacto. El día en que la pregunta se convierta en "¿es razonable el precio que produjo el agente, sin que nadie haya fijado de antemano cuál es el número correcto?", esa pregunta ya no tiene una respuesta determinista, y en ese momento, sin excepción, el trabajo pasa a evaluation-frameworks-guide — su módulo evaluating-agents está diseñado, específicamente, para preguntas con esa estructura.

La declaración completa, para repetir en cada lección que roce esta frontera: este gate verifica que la forma no se rompió —schema, tool correcta, umbral— y, cuando corresponde, que un valor coincide con un ancla fija y conocida. Nunca evalúa si la respuesta es buena en un sentido que dependa de interpretación. Eso es evaluation-frameworks-guide.


Por qué esta frontera importa en la práctica

No es un tecnicismo académico. Un equipo que confunde estas dos capas corre uno de dos riesgos reales, en direcciones opuestas: si intenta resolver preguntas de calidad con herramientas de forma —por ejemplo, tratando de anticipar, con un CASE_SET fijo, cada posible variación de una "buena" respuesta—, termina con un archivo de casos imposible de mantener, que crece sin límite y sigue sin cubrir la pregunta real. Si intenta resolver preguntas de forma con herramientas de calidad —por ejemplo, usando un modelo para "juzgar" si check_schema debería pasar—, introduce una fuente de no-determinismo exactamente donde la guía entera insiste en que no debe haber ninguna: un gate de CI que a veces pasa y a veces falla, sin que nada haya cambiado, es peor que no tener gate. La frontera de esta lección no es una curiosidad — es la que mantiene a cada herramienta haciendo el trabajo para el que está diseñada.


Errores comunes

  1. Pensar que porque check_expected_output compara un valor exacto, "ya es" evaluación de calidad. No lo es — sigue siendo una comparación literal contra un número fijo, escrito de antemano en el CASE_SET, sin ninguna interpretación. La evaluación de calidad empieza cuando ya no hay un único valor "correcto" conocido de antemano contra el cual comparar.

  2. Usar check_schema para intentar atrapar un cambio de precio. El ejemplo trabajado de esta lección lo demuestra: check_schema no puede atrapar esto, por diseño — su trabajo es la forma, no el valor. Si un caso necesita anclar un valor específico, ese es el trabajo de expected_output, nunca de OUTPUT_SCHEMAS.

  3. Ampliar OUTPUT_SCHEMAS con restricciones de rango ("price_cents debe estar entre 1000 y 100000") para intentar atrapar valores "razonables". Esto sería un paso real hacia el territorio de calidad, encubierto como si fuera forma — un rango arbitrario no tiene ningún fundamento determinista, y esta guía lo evita a propósito. Si un valor necesita compararse contra un rango de negocio real, esa decisión de diseño pertenece a una conversación explícita sobre qué tipo de chequeo se está construyendo, no a una extensión silenciosa de check_schema.

  4. Confundir un FAIL de check_expected_output con "el agente hizo algo mal". El ejemplo trabajado usa, a propósito, un cambio de negocio legítimo (ROOM_RATE_CENTS["Focus"] = 2600) para producir el FAIL — el agente respondió exactamente lo que le correspondía responder con los nuevos datos. El FAIL del gate no juzga si el cambio fue bueno o malo — solo confirma que algo cambió respecto al ancla anterior, y deja que un humano decida si esa ancla necesita actualizarse.

  5. Pensar que la frontera con evaluation-frameworks-guide solo aplica al valor de get_quote. Aplica a cualquier pregunta sobre la respuesta del agente que dependa de interpretación: ¿la respuesta final en texto es clara?, ¿el orden en que el agente exploró las opciones fue el más eficiente posible?, ¿la explicación que le dio al usuario tiene sentido? Ninguna de esas preguntas tiene una función en este módulo, sin importar sobre qué tool o qué campo se pregunten.


Ejercicios

Ejercicio 1: Confirma que cancel_booking también distingue forma de valor (Fácil)

Corre el caso book_and_cancel_studio_basic_1h_diego completo. Valida el resultado de cancel_booking con check_schema (debería pasar). Después, construye un resultado hecho a mano, {"cancelled": False}, y compáralo contra el expected_output del caso ({"cancelled": True}) con check_expected_output.

Ver solución
case = CASE_SET[4]  # book_and_cancel_studio_basic_1h_diego
reset_reservo_state()
with rl.traced_run(case["question"], 1) as trace_id:
    final, history = ra.run_reservo_agent(case["question"], case["model_script"])

result = json.loads(_last_result_for_tool(history, "cancel_booking"))
print("resultado real   :", result)
print("check_schema     :", check_schema(result, OUTPUT_SCHEMAS["cancel_booking"]))

resultado_falso = {"cancelled": False}
print("check_expected_output sobre un valor hecho a mano:",
      check_expected_output(resultado_falso, case["expected_output"]))

Salida esperada:

resultado real   : {'cancelled': True}
check_schema     : []
check_expected_output sobre un valor hecho a mano: ["'cancelled' esperado=True, obtenido=False"]

Explicación: el resultado real del run pasa ambos chequeos (forma correcta, valor correcto). El resultado hecho a mano, {"cancelled": False}, tiene la forma perfectamente válida —check_schema no encuentra ningún problema—, pero no coincide con lo que este caso específico espera, y check_expected_output lo señala con precisión.

Ejercicio 2: Diseña una pregunta de cada categoría sobre book_room (Medio)

Para la tool book_room, escribe tres preguntas distintas sobre su resultado —una de forma, una de valor literal, una de calidad semántica—, siguiendo el patrón de las tres capas de esta lección. Para cada una, di qué función (o qué guía, si es la pregunta de calidad) la contestaría.

Ver solución
  1. Forma: "¿booking_id es un entero, y confirmed un booleano?" → check_schema contra OUTPUT_SCHEMAS["book_room"].
  2. Valor literal: "¿Esta reserva específica, con el estado de Reservo reseteado, produce booking_id: 1?" → check_expected_output contra el expected_output del caso.
  3. Calidad semántica: "¿La confirmación en texto que el agente le mostró al usuario ('Reservé Focus pro por 3 horas para Ana...') es clara, completa, y suena natural?" → ninguna función de este módulo. Esa pregunta pertenece a evaluation-frameworks-guide.

Explicación: la pregunta 3 es cualitativamente distinta de las otras dos porque no tiene un único valor "correcto" contra el cual comparar por igualdad — dos respuestas de texto completamente distintas podrían ser, ambas, "claras y naturales". Eso es, con precisión, lo que la aparta del territorio de este módulo.

Ejercicio 3: Provoca un FAIL de forma en book_room y compáralo con el FAIL de valor de esta lección (Difícil)

Construye un resultado hecho a mano donde book_room devuelve booking_id como un string ("1" en vez de 1) mientras que confirmed sigue siendo True. Valídalo con check_schema y con check_expected_output (contra {"booking_id": 1, "confirmed": True}). Compara los dos mensajes de error, y explica en una frase por qué ambos chequeos, en este caso particular, terminan señalando el mismo campo (booking_id) pero por razones distintas.

Ver solución
broken_result = {"booking_id": "1", "confirmed": True}
expected = {"booking_id": 1, "confirmed": True}

print("check_schema          :", check_schema(broken_result, OUTPUT_SCHEMAS["book_room"]))
print("check_expected_output :", check_expected_output(broken_result, expected))

Salida esperada:

check_schema          : ["'booking_id' debe ser integer, llegó str"]
check_expected_output : ["'booking_id' esperado=1, obtenido='1'"]

Explicación: ambos chequeos señalan booking_id, pero por razones completamente distintas: check_schema se queja del tipo (str en vez de int, sin importar el valor); check_expected_output se queja del valor ("1" no es == a 1 en Python, aunque un lector humano los lea como "lo mismo"). En este caso los dos chequeos coinciden en señalar el mismo campo porque el error es, a la vez, un error de tipo y un error de valor — pero el ejemplo trabajado de esta lección (price_cents: 6240 en vez de 6000) muestra el caso contrario: un valor que pasa check_schema sin ningún problema, y que solo check_expected_output puede atrapar. Los dos chequeos son necesarios porque cubren fallas de naturaleza distinta, y ninguno de los dos, por sí solo, es un juicio de calidad semántica.


Resumen y siguiente paso

  • Completamos check_schema con OUTPUT_SCHEMAS, el diccionario de las cuatro tools de Reservo, incluido el caso especial de list_rooms (un array de objetos, validado elemento por elemento).
  • Construimos _last_result_for_tool, la función que encuentra qué resultado validar dentro de un history de varios pasos, y check_expected_output, la comparación literal contra el ancla fija de cada caso.
  • Demostramos, con un cambio de negocio real y números ejecutados, la frontera exacta de este módulo: check_schema no puede, ni debe, atrapar un cambio de valor ({"price_cents": 6240} es perfectamente válido en forma); check_expected_output sí lo atrapa, porque compara contra un ancla fija, no porque juzgue si el valor es razonable.
  • Declaramos, con precisión, la frontera con evaluation-frameworks-guide: este módulo verifica forma (schema, tool correcta, umbral) y, cuando corresponde, coincidencia exacta contra un ancla fija — nunca calidad semántica, nunca un juicio de "¿es razonable?", nunca un dataset con verdad difusa.

Siguiente lección: 05 — Verificando la elección de tool. Con la frontera de forma-vs-calidad ya establecida sobre el resultado de una tool, aplicamos el mismo criterio a la elección de la tool misma: check_tool_choice a fondo, con la primera demostración completa de un FAIL real — una regresión de prompt que hace que el agente elija la tool equivocada.


Recursos adicionales

  1. Anthropic — Tool use (function calling) overview — El mismo vocabulario de schema (type/properties/required) que este módulo reutiliza para describir la forma de una salida, no solo de una entrada.
  2. Python — isinstance — La base de cada chequeo de tipo dentro de check_schema, incluido el caso recursivo de array.
  3. Python — comparación de igualdad (==) — El operador exacto detrás de check_expected_output, y por qué "1" != 1 en Python aunque ambos "signifiquen lo mismo" para un lector humano.
  4. Anthropic — Building effective agents — Sobre la diferencia entre verificar que un sistema agentic se comporta de forma predecible y evaluar si sus decisiones son, en sustancia, las mejores posibles.
  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, incluida la demostración del cambio de precio.