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

Verificando la elección de tool

Descripción

La lección 04 trazó la frontera de forma-vs-calidad sobre el resultado de una tool. Esta lección aplica exactamente el mismo criterio a una pregunta distinta, igual de central para un agente: ¿el agente sigue eligiendo la tool correcta, en el orden correcto, frente a un guion de turnos que ya conoces? check_tool_choice —construida en su forma final en la lección 02— es la respuesta, y esta lección la pone a prueba a fondo: primero sobre los cinco casos reales del CASE_SET, todos en PASS, y después sobre dos guiones "después de un cambio" —dos formas distintas en que un agente puede regresionar sin que su código haya cambiado en absoluto— que producen el primer FAIL real de todo este módulo.

Este FAIL importa especialmente, porque es el mismo tipo de evidencia que el DISEÑO de esta guía exige citar con precisión: no un "algo falló", sino un mensaje exacto —tool esperada get_quote, obtenida book_room— que le dice a quien lo lea, sin ninguna ambigüedad, qué se rompió.

Conexión con el módulo

Esta lección no agrega ninguna función nueva a regression/harness.pycheck_tool_choice y extract_tool_sequence ya quedaron completas en la lección 02—. Lo que agrega es la primera demostración completa de run_case, la función que la lección 07 va a terminar de ensamblar: correr un caso del CASE_SET contra un guion sustituido, simulando "la versión del agente después de un cambio en el prompt".


Recordando check_tool_choice

def extract_tool_sequence(history):
    """La secuencia LITERAL de tools llamadas por el agente, en el orden en
    que las llamó."""
    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."""
    actual = extract_tool_sequence(history)
    return actual == expected_tools, actual

Dos propiedades de esta función merecen repetirse antes de ponerla a prueba a fondo. Primero: la comparación es sobre una lista ordenada, no sobre un conjunto — ["get_quote", "book_room"] y ["book_room", "get_quote"] son, para check_tool_choice, dos secuencias completamente distintas, aunque contengan las mismas dos tools. Segundo: la función siempre devuelve la secuencia real, incluso cuando el veredicto es False — esa segunda parte de la tupla es la que hace posible construir un mensaje de FAIL preciso, en vez de un simple "no coincide".


Ejemplo trabajado, parte 1: los cinco casos reales, todos en PASS

Antes de fallar nada a propósito, confirma que el CASE_SET completo, corrido tal como está —sin ninguna sustitución—, pasa check_tool_choice en los cinco casos:

print("--- check_tool_choice sobre los cinco casos reales del CASE_SET ---")
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"])
    ok, actual = check_tool_choice(history, case["expected_tools"])
    print(f"{case['name']:38} {'PASS' if ok else 'FAIL'}  esperado={case['expected_tools']}  obtenido={actual}")

Qué esperar:

--- check_tool_choice sobre los cinco casos reales del CASE_SET ---
quote_focus_pro_3h                     PASS  esperado=['get_quote']  obtenido=['get_quote']
quote_focus_basic_3h                   PASS  esperado=['get_quote']  obtenido=['get_quote']
book_focus_pro_3h_ana                  PASS  esperado=['list_rooms', 'get_quote', 'book_room']  obtenido=['list_rooms', 'get_quote', 'book_room']
book_boardroom_pro_1h_sofia            PASS  esperado=['list_rooms', 'get_quote', 'book_room']  obtenido=['list_rooms', 'get_quote', 'book_room']
book_and_cancel_studio_basic_1h_diego  PASS  esperado=['book_room', 'cancel_booking']  obtenido=['book_room', 'cancel_booking']

Cinco de cinco. Esto no es sorprendente todavía —cada model_script del CASE_SET fue escrito, a mano, para que el agente (concepto) llame exactamente a esas tools—. Lo interesante empieza ahora, cuando el guion que se corre deja de ser el que el caso declara.


run_case, con un guion sustituido: simulando "la versión de después"

Para comparar un comportamiento nuevo contra uno esperado, hace falta poder correr el mismo caso —misma pregunta, mismo expected_tools, mismos umbrales— contra un guion distinto al que el CASE_SET trae por defecto. Esa es la razón exacta por la que run_case (que la lección 07 completa) acepta un parámetro opcional model_script:

def run_case(case, sequence_number, model_script=None):
    """Corre UN caso del CASE_SET: ejecuta run_reservo_agent (sin tocar su
    lógica) con `model_script` (el del caso, o uno sustituido -- la pieza
    que M7 reusa para comparar una versión vieja contra una nueva)."""
    reset_reservo_state()
    script = model_script if model_script is not None else case["model_script"]

    with rl.traced_run(case["question"], sequence_number) as trace_id:
        final, history = ra.run_reservo_agent(case["question"], script)

    tool_choice_ok, actual_tools = check_tool_choice(history, case["expected_tools"])
    # ... el resto de los chequeos (lecciones 04 y 06) se agregan en la lección 07.
    return tool_choice_ok, actual_tools

Cuando model_script es None, run_case corre exactamente lo que el CASE_SET declara —el comportamiento de la sección anterior—. Cuando se pasa un guion distinto, run_case sigue comparando contra el mismo expected_tools del caso original, pero ejecuta un comportamiento diferente. Esta es, con precisión, la técnica que el Módulo 7 va a reusar para comparar una versión vieja del agente contra una nueva: el CASE_SET no cambia, lo que cambia es el guion que representa "cómo responde el modelo ahora".


Ejemplo trabajado, parte 2: el FAIL principal — una regresión que salta la cotización

Imagina que un cambio en el system prompt de Reservo (concepto — nunca se ejecuta una llamada real) hace que el modelo, ante una pregunta de cotización simple, decida ser "más proactivo" y reserve directamente, sin cotizar primero. Es exactamente el tipo de regresión de comportamiento que un cambio de prompt, bien intencionado, puede introducir sin que nadie lo note hasta que un usuario se queja de una reserva que no pidió.

case = CASE_SET[0]  # quote_focus_pro_3h -- pregunta: "¿Cuánto cuesta Focus pro 3h?"

# Guion "después del cambio": el modelo salta la cotización y reserva directo.
regressed_script = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "book_room",
         "input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Reservé Focus pro 3h para Ana."}]},
]

tool_choice_ok, actual_tools = run_case(case, 99, model_script=regressed_script)
print("resultado:", "PASS" if tool_choice_ok else "FAIL")
print(f"FAIL: tool esperada {case['expected_tools'][0]}, obtenida {actual_tools[0]}")

Qué esperar:

resultado: FAIL
FAIL: tool esperada get_quote, obtenida book_room

Este es el mensaje que el DISEÑO de esta guía pide citar con precisión, y ahora lo tienes, producido por código real: tool esperada get_quote, obtenida book_room. Nota lo que este FAIL no dice: no dice que la respuesta final del agente ("Reservé Focus pro 3h para Ana") esté mal escrita, ni que sea confusa — de hecho, como texto, es perfectamente clara. Lo que dice, con total precisión, es que el agente tomó una acción distinta a la esperada: en vez de responder una pregunta de cotización (una operación de solo lectura, sin ningún efecto), creó una reserva real (una operación de escritura, con un efecto que el usuario no pidió). Ese es exactamente el tipo de regresión de comportamiento que un gate de forma —nunca uno de calidad— está diseñado para atrapar: no importa cuán bien redactada esté la respuesta final, la acción que la precedió fue la incorrecta. Si la pregunta, en cambio, fuera "¿la respuesta final suena natural y profesional?", check_tool_choice no tendría nada que decir — esa pregunta pertenece, con toda su propia disciplina, a evaluation-frameworks-guide.


Ejemplo trabajado, parte 3: un FAIL más sutil — el orden importa

No todas las regresiones cambian qué tools se llaman — algunas cambian en qué orden. Corre el caso de Ana con las mismas tres tools de siempre, pero con list_rooms y get_quote intercambiados:

case3 = CASE_SET[2]  # book_focus_pro_3h_ana
out_of_order_script = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
    {"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": "book_room",
         "input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Reservé Focus pro 3h para Ana."}]},
]
tool_choice_ok, actual = run_case(case3, 98, model_script=out_of_order_script)
print("resultado:", "PASS" if tool_choice_ok else "FAIL")
print("esperado :", case3["expected_tools"])
print("obtenido :", actual)

Qué esperar:

resultado: FAIL
esperado : ['list_rooms', 'get_quote', 'book_room']
obtenido : ['get_quote', 'list_rooms', 'book_room']

Las mismas tres tools, exactamente — pero en un orden distinto, y check_tool_choice lo atrapa igual de bien que el caso anterior, sin ningún cambio en la función. Este es un ejemplo útil para entender por qué la comparación literal de secuencias completas, y no de conjuntos, es la decisión correcta para este módulo: un agente que cotiza antes de saber qué salas existen podría estar cotizando sobre una sala que en realidad ya no está disponible — el orden, en este dominio, no es un detalle cosmético.


Por qué la comparación es literal, y no "razonablemente similar"

Podría parecer más flexible que check_tool_choice aceptara, por ejemplo, una secuencia "parecida" —las mismas tools, en cualquier orden, o incluso con una tool de más si no afecta el resultado final—. Esta guía rechaza esa flexibilidad a propósito, por la misma razón que rechazó, en la lección 04, cualquier intento de que check_schema juzgara si un valor es "razonable": en el momento en que la comparación deja de ser exacta, necesita algún criterio para decidir cuánta diferencia es aceptable — y ese criterio ya no es una comparación de forma, es un juicio. Volviendo a la analogía de la inspección técnica: un inspector que aceptara "los frenos responden casi siempre" en vez de "los frenos responden, sí o no" ya no está haciendo una inspección — está haciendo una evaluación de riesgo, un trabajo distinto, con herramientas distintas. check_tool_choice, con su == exacto, se queda deliberadamente del lado de la inspección.


Errores comunes

  1. Pensar que un FAIL de check_tool_choice siempre significa "el agente se equivocó". Como en el error común de la lección 04, un FAIL solo dice que el comportamiento cambió respecto al esperado — la lección 07 muestra cómo ese cambio puede ser una regresión real (un bug de prompt) o un cambio de comportamiento intencional que simplemente todavía no se reflejó en el CASE_SET.

  2. Comparar set(actual) contra set(expected_tools) "para que el orden no importe". Esto elimina exactamente la información que el Ejemplo trabajado, parte 3, demuestra que importa. Si alguna vez el orden genuinamente no importara para un caso específico, la decisión correcta sería documentarlo explícitamente en ese caso — nunca cambiar el comportamiento por defecto de la función para todos los casos.

  3. Olvidar que run_case con model_script=None usa el guion del caso, no uno vacío. Pasar model_script=[] por error (en vez de omitir el argumento) produciría un IndexError dentro de run_reservo_agent al intentar acceder a model_script[0] — un error completamente distinto a un FAIL de check_tool_choice, y mucho más confuso de diagnosticar si no se sabe qué causó la diferencia.

  4. Confundir "una tool de más" con "el orden correcto de las tools esperadas". Si el guion sustituido llamara a list_rooms, get_quote, book_room, y además cancel_booking al final (una tool extra, no esperada), check_tool_choice también lo marcaría como FAIL — una lista de cuatro elementos nunca es == a una de tres, sin importar que los primeros tres coincidan exactamente.

  5. Ejecutar el guion sustituido sin reset_reservo_state antes. Como ya advirtió la lección 03, esto puede producir un booking_id inesperado, contaminando el diagnóstico: un FAIL genuino de check_tool_choice (la tool correcta) puede quedar oculto detrás de un FAIL espurio de check_expected_output (el id equivocado, por una razón que no tiene nada que ver con la regresión real que se está investigando).


Ejercicios

Ejercicio 1: Provoca un FAIL en el caso de cancelación (Fácil)

Usando case = CASE_SET[4] (book_and_cancel_studio_basic_1h_diego), construye un guion sustituido que reserve pero no cancele —el agente responde con end_turn inmediatamente después de book_room—. Corre check_tool_choice contra case["expected_tools"] y confirma el FAIL.

Ver solución
case = CASE_SET[4]
no_cancel_script = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "book_room",
         "input": {"room": "Studio", "tier": "basic", "hours": 1, "member": "Diego"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Reservé Studio basic 1h para Diego."}]},
]
reset_reservo_state()
with rl.traced_run(case["question"], 1) as trace_id:
    final, history = ra.run_reservo_agent(case["question"], no_cancel_script)
ok, actual = check_tool_choice(history, case["expected_tools"])
print("resultado:", "PASS" if ok else "FAIL")
print("esperado :", case["expected_tools"])
print("obtenido :", actual)

Salida esperada:

resultado: FAIL
esperado : ['book_room', 'cancel_booking']
obtenido : ['book_room']

Explicación: una secuencia de un solo elemento nunca es == a una de dos, sin importar que el primer elemento coincida exactamente. Este es el patrón más simple de FAIL: el agente se detuvo antes de completar la secuencia esperada.

Ejercicio 2: Confirma que check_tool_choice sobre el caso de basic no se confunde con el de pro (Medio)

quote_focus_pro_3h y quote_focus_basic_3h tienen el mismo expected_tools (["get_quote"]). Corre el guion de quote_focus_basic_3h (que cotiza con tier="basic") pero compáralo contra el expected_tools de quote_focus_pro_3h. Confirma que check_tool_choice da PASS (porque la secuencia de tools sí coincide), y explica en una frase por qué esto no significa que el caso completo sea correcto.

Ver solución
case_basic = CASE_SET[1]
case_pro = CASE_SET[0]

reset_reservo_state()
with rl.traced_run(case_basic["question"], 1) as trace_id:
    final, history = ra.run_reservo_agent(case_basic["question"], case_basic["model_script"])

ok, actual = check_tool_choice(history, case_pro["expected_tools"])
print("check_tool_choice (comparado contra el caso pro):", ok, actual)

Salida esperada:

check_tool_choice (comparado contra el caso pro): True ['get_quote']

Explicación: check_tool_choice solo compara nombres de tools, nunca sus argumentos — get_quote(Focus, basic, 3) y get_quote(Focus, pro, 3) producen la misma secuencia ["get_quote"], así que el chequeo pasa igual. Esto no significa que el caso "esté bien": si el tier importara para el veredicto de este caso específico, haría falta check_expected_output (lección 04) sobre el price_cents resultante —7500 para basic, 6000 para pro— para distinguir uno del otro. Cada chequeo de este módulo cubre una dimensión distinta del comportamiento; ninguno, solo, cubre todas.

Ejercicio 3: Diseña un caso donde el orden "razonable" alternativo también debería fallar (Difícil)

Para el caso book_boardroom_pro_1h_sofia (list_roomsget_quotebook_room), construye un guion alternativo, igual de "razonable" a primera vista, donde el agente llama a get_quote dos veces antes de reservar —una vez, se "arrepiente", y vuelve a cotizar con los mismos argumentos exactos antes de reservar—. Corre check_tool_choice y confirma el FAIL. Después, explica por qué, aunque el resultado final (la reserva) sería idéntico, este comportamiento sigue siendo una regresión legítima de detectar.

Ver solución
case = CASE_SET[3]  # book_boardroom_pro_1h_sofia
double_quote_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": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 1}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_03", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 1}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_04", "name": "book_room",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 1, "member": "Sofía"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Reservé Boardroom pro 1h para Sofía."}]},
]
reset_reservo_state()
with rl.traced_run(case["question"], 1) as trace_id:
    final, history = ra.run_reservo_agent(case["question"], double_quote_script)
ok, actual = check_tool_choice(history, case["expected_tools"])
print("resultado:", "PASS" if ok else "FAIL")
print("obtenido :", actual)

Salida esperada:

resultado: FAIL
obtenido : ['list_rooms', 'get_quote', 'get_quote', 'book_room']

Explicación: aunque el resultado final de la reserva sería idéntico —la segunda cotización tiene exactamente los mismos argumentos que la primera, así que produciría el mismo price_cents—, este comportamiento sí es una regresión que vale la pena detectar: una tool call de más, sin ningún propósito, cuesta tokens reales (Módulo 3) y latencia real (Módulo 4) por cada intento redundante. Un agente que empieza a repetir tool calls sin necesidad es, con frecuencia, la primera señal visible de un problema más profundo en el prompt —por ejemplo, que el modelo dejó de confiar en el resultado de su propia tool call anterior—, y este chequeo lo atrapa en el momento exacto en que empieza a ocurrir, no varios pasos después cuando ya afectó el costo de miles de runs reales.


Resumen y siguiente paso

  • Confirmamos, ejecutado, que los cinco casos reales del CASE_SET pasan check_tool_choice sin ningún cambio — la base contra la que cualquier regresión futura se compara.
  • Construimos la técnica de run_case con un model_script sustituido: el mismo caso, un guion distinto, simulando "el agente después de un cambio" — la pieza que el Módulo 7 va a reusar para comparaciones de versión.
  • Produjimos el primer FAIL real de todo el módulo, con el mensaje exacto que el DISEÑO de esta guía exige: tool esperada get_quote, obtenida book_room — una regresión de prompt que salta un paso de solo-lectura y va directo a una acción con efectos reales.
  • Confirmamos un segundo tipo de FAIL —el mismo conjunto de tools, orden distinto— y explicamos por qué la comparación literal de secuencias completas, nunca de conjuntos, es la decisión correcta para este chequeo.

Siguiente lección: 06 — Verificando umbrales de costo y latencia. Con la elección de tool ya cubierta, completamos la tercera pregunta del gate: check_cost_threshold y check_latency_threshold, reusando cost_for_run (Módulo 3) y el modelo de latencia (Módulo 4) sin modificarlos, con un caso que falla por exceder un umbral.


Recursos adicionales

  1. Anthropic — Tool use (function calling) overview — El protocolo tool_use que extract_tool_sequence recorre para construir la secuencia real de cada run.
  2. Python — comparación de listas — El comportamiento exacto de == sobre dos listas: elemento por elemento, en orden, sensible a la longitud — la base completa de check_tool_choice.
  3. Python — comprensión de listas anidadas — La construcción con dos for que arma extract_tool_sequence en una sola expresión.
  4. Anthropic — Building effective agents — Sobre por qué la elección de qué acción tomar, no solo la calidad del texto final, es una de las decisiones más consecuentes de un agente en producción.
  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 tres FAILs demostrados.