Trust, but Trace
EN ES

Metodología de evaluación

Determinista donde puedas, juez donde haga falta

Las llamadas a herramientas piden comparación determinista; saber si el agente cumple su objetivo exige un LLM. Unas herramientas trazan esa línea y otras no.

¿Acabas de llegar a la serie? Empieza aquí: qué da por sabido y qué no.

En la parte 5 vimos el anclaje por rúbrica, con una herramienta que le dice al juez lo que NO debe evaluar. La siguiente estrategia no afina más la pregunta del juez y plantea otra previa: si tiene sentido usar un juez y dónde.

Estoy construyendo AgentAnvil, un framework de pruebas guiado por contratos para agentes basados en LLM, y leer otros evaluadores me sirve para averiguar qué deberían restringir esos contratos. La hibridación determinista es la más sencilla de las estrategias de esta serie y también la más fácil de aplicar medio bien. Algunas preguntas sobre un agente tienen una única respuesta correcta (si ha llamado a la herramienta adecuada, si el JSON se puede parsear, si ha elegido la opción B), y basta una comparación de cadenas, una regex o un parser para resolverlas. Otras no la tienen (si la respuesta ha sido útil de verdad), y ahí una regex no sirve. La estrategia consiste en usar cada método donde encaje y en dejar el reparto a la vista. Hay una herramienta, Inspect AI, que acierta con la arquitectura.

Aquí la leo en su código fuente, en el commit ff29dc9 (abril de 2026), al que apuntan las referencias a ficheros y líneas y los fragmentos de código.

La intuición

Cuando tienes un LLM en el stack, lo cómodo es usarlo para todo y pedirle que puntúe esta salida, que compare estas dos, que valore esto de 0 a 3. Cada evaluación se convierte así en un prompt, y cada prompt cuesta entre una fracción de céntimo y unos pocos céntimos, además de unos segundos. Con mil muestras y cuatro evaluadores, la evaluación acaba costando más que la propia ejecución.

En realidad, muchas de las preguntas que haces en una evaluación no necesitan un modelo de lenguaje. Para «¿Ha llamado el agente a get_weather con la ciudad correcta?» basta una comparación de cadenas; para «¿Incluye la respuesta el nombre del usuario?», una búsqueda de subcadena, y para «¿Es la salida un objeto JSON válido que cumple este esquema?», un parser. Son comprobaciones más baratas, más rápidas y deterministas (la misma entrada da siempre la misma respuesta), y puedes ejecutarlas en local, en bucle y gratis.

El reflejo de meter un LLM en todo viene de que las herramientas actuales te empujan a ello. La mayoría de los frameworks de evaluación con LLM ponen en primer plano sus evaluadores con juez porque es lo que los distingue, la capacidad nueva que los convierte en frameworks de evaluación con LLM y los separa de un ejecutor de tests corriente. Los evaluadores por coincidencia, regex o patrón están ahí, pero parecen un paso atrás. El usuario se pregunta por qué iba a usar otra cosa teniendo un juez potente.

Porque el juez es la herramienta adecuada para unas preguntas y la equivocada para otras. Hay que usar cada método donde encaje. Lo más difícil es hacer explícito el reparto para que un revisor pueda auditarlo.

Inspect AI, paso a paso

Inspect AI define Scorer como un Protocol estructural, sin clase base de la que heredar (scorer/_scorer.py:33-61).

@runtime_checkable
class Scorer(Protocol):
async def __call__(
self,
state: TaskState,
target: Target,
) -> Score | None:
...

Ese es todo el contrato: un evaluador es un callable asíncrono que recibe el estado de la tarea (entrada, salida del modelo, llamadas a herramientas, metadatos) y el target (la respuesta que espera el dataset) y devuelve un Score o None. Tanto los evaluadores deterministas como los que usan juez cumplen este protocolo, y el framework no los distingue en el sistema de tipos, donde todos son Scorers. El reparto lo decide el usuario, sin que el framework imponga ninguno.

La familia determinista está repartida entre _match.py, _pattern.py, _answer.py, _classification.py y _choice.py (más _math.py). Los evaluadores que trae de serie son match() (comparación de cadenas, con location ∈ begin/end/any/exact), includes() (prueba de subcadena), pattern() (regex sobre la salida), exact() (en ese commit, igualdad del conjunto de tokens normalizado; las versiones actuales comparan la secuencia de tokens normalizada, así que importan el orden y las repeticiones), f1() (F1 al estilo SQuAD, que devuelve un valor continuo de 0 a 1), answer() (un wrapper de regex para LETTER/WORD/LINE) y choice() (opción múltiple mediante state.choices[i].correct). Ninguno importa el tipo Model; son funciones puras de la salida y el target, sin llamada al LLM, sin coste y sin varianza. Tampoco leen las llamadas a herramientas, y para comprobar una basta un evaluador propio de pocas líneas.

La familia con juez está en _model.py y tiene dos puntos de entrada, model_graded_qa() para calificar respuestas abiertas y model_graded_fact() para comparar la salida con una referencia. model_graded_fact es, literalmente, un wrapper de una línea que llama a model_graded_qa con otra plantilla (_model.py:75-83). En los dos casos, la implementación real está en _model_graded_qa_single() (_model.py:154-237). La llamada que importa es esta:

result = await model.generate([scoring_prompt])
match = re.search(grade_pattern or DEFAULT_GRADE_PATTERN, result.completion)

En ese commit, el patrón por defecto para la nota es r"(?i)GRADE\s*:\s*([CPI])(.*)$" (_model.py:293), que falla en cuanto hay algo detrás de la línea de la nota. Si falta la nota, la puntuación es INCORRECT. Las versiones actuales, en cambio, se quedan con el último GRADE: X de la salida ((?is).*(?<!\w)GRADE(?!\w)\s*:\s*([CPI]), simplificado) y, por defecto, dejan sin puntuar un veredicto que no se puede parsear o que no está entre las opciones. El juez tiene que escribir GRADE: C o GRADE: I (o GRADE: P si está activada la puntuación parcial). Como la firma trae partial_credit: bool = False, la calificación es binaria salvo que pidas la parcial.

Conviene reconocer de dónde sale todo esto. Un comentario en _model.py:240-241 dice «these templates are based on the openai closedqa templates» y enlaza a github.com/openai/evals/blob/main/evals/registry/modelgraded/closedqa.yaml. Como las plantillas de OpenAI evals son de 2023, la forma de calificar de Inspect AI es anterior a la mayoría de los frameworks de evaluación con LLM que he leído. Funciona porque quienes la escribieron no tenían prisa por ser originales.

Seis tareas de evaluación: elige para cada una la familia de evaluador. La fila de abajo da el coste del reparto actual con 100 muestras (a unos 0,005 $ por llamada al LLM).
  • ¿Ha llamado el agente a get_weather con los argumentos correctos?
    @scorer propio: compara con el target el ToolCall de get_weather (function y arguments) que hay en state.messages (sin llamada al LLM)
  • ¿La respuesta contiene el número de teléfono esperado?
    pattern(r"(\d{3}[-.]\d{3}[-.]\d{4})"): extrae el número y lo compara con el target
  • ¿La respuesta es la letra de opción múltiple que esperaba el dataset?
    choice(): comprueba state.choices[i].correct
  • ¿La respuesta al estilo SQuAD se solapa con la de referencia?
    f1(): F1 de solapamiento de tokens al estilo SQuAD, con un valor continuo de 0 a 1
  • ¿El resumen es fiel al párrafo de origen?
    model_graded_qa() con un criterio como «cada afirmación del resumen se apoya en la fuente»
  • ¿La respuesta ha sido útil de verdad para el usuario?
    model_graded_qa(): calificación de respuestas abiertas, protocolo GRADE: C/I
tareas con LLM2 de 6
llamadas al LLM / 100 muestras200
~ coste a 0,005 $1,00 $

Hay dos maneras de componer evaluadores. La primera es multi_scorer(scorers, reducer) (_multi.py:13-30), que ejecuta N evaluadores por muestra y los reduce a un único Score con un reductor (máximo, media, moda o uno propio). La segunda es pasar una lista directamente al constructor de Task (Task(scorer=[exact(), model_graded_qa()])), y entonces cada evaluador aporta al informe su propia columna de métrica, sin necesidad de reductor.

Lo que Inspect AI no trae es la elección de evaluador muestra a muestra. «En esta fila usa exact, en esa usa el juez» no se puede declarar directamente, así que el usuario escribe una función propia decorada con @scorer que decide según state.metadata o target, o divide el dataset en dos tareas. El framework te da las primitivas y deja en tus manos la decisión del reparto.

El detalle más revelador está en _model.py:341-354,372-414. Los marcadores [BEGIN DATA] ... [END DATA], que delimitan la salida del agente dentro del prompt de calificación, se sanean en todos los campos no fiables (el answer del modelo y, del dataset, la question, el criterion y los metadatos). Si esos marcadores aparecen en un texto que controla el usuario, sus espacios se sustituyen por guiones. Un comentario lo deja claro: «Literal space (not \s) is intentional — \s also matches U+00A0 (NBSP), which would let a model pre-neutralize its own output and bypass the mitigation». Los mantenedores han tenido en cuenta la inyección de prompts dirigida al propio juez. En las demás herramientas de evaluación de código abierto es raro encontrar una mitigación comparable.

Y hay que reconocerle el mérito. Entre las herramientas de evaluación de código abierto de esta serie, el protocolo Scorer de Inspect AI es el que mejor separa los evaluadores deterministas de los que usan juez. Hay una sola interfaz, las dos familias la cumplen y el usuario elige en cada tarea. Los mantenedores lo dicen sin rodeos (docs/scorers.qmd:55) y describen model_graded_fact() como el evaluador «que se usa cuando la salida del modelo es demasiado compleja para valorarla con un simple evaluador match() o pattern()». Calificar con un juez es el plan B.

Lo que pierdes

Te toca pensar. La mayoría de las veces está claro si conviene match() o model_graded_qa(). Las respuestas exactas piden una coincidencia exacta y las abiertas, un evaluador con juez. Los casos difíciles están en medio, como una respuesta estructurada que el modelo puede formular de otra manera, un nombre de función que puede venir con otras mayúsculas o una respuesta numérica en la que el modelo puede poner la unidad o no. Ahí eliges tú, sin ayuda del framework. Eso es una ventaja cuando quien elige sabe lo que hace, y un coste cuando no lo sabe.

El reparto se decide tarea a tarea. En Inspect AI, una Task asocia un evaluador (o una lista fija de evaluadores) a todo el dataset y no se puede cambiar de una muestra a otra. Si 800 de tus 1000 muestras pueden usar coincidencia exacta y 200 necesitan un juez, no hay forma de declararlo sin más. Tienes que escribir un evaluador propio que elija según los metadatos o partir el dataset en dos tareas. Las dos soluciones funcionan, pero ninguna cabe en una línea. Lo que falta es una primitiva para mezclar tipos de evaluador muestra a muestra, justo donde un framework más ambicioso automatizaría la decisión.

Además, el prompt del juez es un valor por defecto que se puede cambiar. model_graded_qa(template=..., instructions=..., grade_pattern=...) lo adapta a cada tarea, las claves de los metadatos de la muestra hacen de variables de la plantilla y el criterio sale del target de cada muestra. Para la mayoría de las preguntas abiertas basta con la plantilla por defecto, porque una calificación genérica sí generaliza. Cuando necesitas otro protocolo de calificación (llamadas encadenadas al juez, salida estructurada, un veredicto que una sola regex no puede extraer), vuelves a tener que escribir un @scorer propio y llamar tú mismo a model.generate(). Es la misma vía de escape que te da cualquier otro framework y cumple su función, pero ya no cabe en una línea.

Cuándo encaja este enfoque

Para saber si te conviene esta estrategia basta una pregunta.

¿Alguna de tus preguntas de evaluación se puede responder con una regex? La mayoría de las evaluaciones tienen al menos una: las llamadas a herramientas tienen una respuesta correcta, las salidas estructuradas siguen un esquema, las respuestas numéricas tienen un valor esperado y la respuesta de una tarea de opción múltiple es una letra. Si pagas una llamada al LLM para comprobar si el JSON se puede parsear, gastas dinero en lo que un parser hace gratis y le añades la varianza del juez a una pregunta que no tiene ninguna.

Puntuar con un juez compensa cuando la respuesta es subjetiva de verdad, por ejemplo al decidir si la contestación ha sido útil, si el resumen es fiel o si el tono es el adecuado. Son preguntas cuya respuesta no sale de ninguna comparación de cadenas, y para ellas el juez es la herramienta adecuada. Eso sí, recurrir a él tiene que ser siempre una decisión explícita.

Lo que la hibridación determinista no resuelve es el problema de calibración de los propios evaluadores con juez. Al repartir el trabajo, la varianza queda localizada: todo lo determinista sale impecable y todo lo que pasa por el juez arrastra el ruido que la serie viene documentando. El evaluador con juez es un LLM, y es justo en el LLM donde está la cuestión de la calibración. Algunas herramientas te dan, pese a ese ruido, un intervalo estadísticamente válido sobre la puntuación agregada (el ARES de la parte 4), pero la mayoría no. La última parte de la serie se ocupa de la brecha que nadie cierra.

Como regla práctica, cada regex que puedas escribir es una llamada al LLM menos que depurar. Usa el juez donde haga falta y en ningún otro sitio.

LLM como juezMetodología de evaluación