Pruebas de regresión
La ejecución que se rompió el martes pasa a ser el caso que toda versión posterior tiene que aprobar.
El ciclo que esto existe para cerrar
Un agente falla en una pregunta real. Alguien lo reproduce a mano, cambia un prompt, y parece que mejora. Tres semanas después vuelve a fallar en la misma pregunta, porque nadie guardó nada.
Un dataset sí lo guarda. Captura la ejecución fallida y deja de ser una anécdota: pasa a ser un caso con una entrada, una respuesta esperada cuando la tienes, y un puntero a la traza de la que salió. Toda versión posterior se ejecuta contra él.
Capturar una ejecución
Busca la ejecución en la interfaz o mediante la API, y luego entrega su id de traza al dataset.
capturar una ejecución fallida como caso
curl -X POST localhost:4317/v1/api/datasets/$DATASET/capture \
-H 'content-type: application/json' \
-d '{"trace_ids": ["b3d9e71a4c0f428eb6a5d2317f90c684"]}'
El caso capturado conserva source_trace_id, así que meses
después puedes abrir la ejecución que lo motivó y leer lo que ocurrió de
verdad en vez de deducirlo de la entrada.
- agent support_agent fail
- model chat
- tool returns_policy
- model chat
- tool returns_policy
- model chat
- tool returns_policy
The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.
La respuesta de la propia ejecución no es la respuesta esperada
use_output_as_expected está desactivado por defecto, y ese valor
por defecto es lo importante. Las ejecuciones que vale la pena capturar
suelen ser las que salieron mal, y guardar una respuesta equivocada como
referencia consagraría el fallo: a partir de ahí, cada versión posterior se
mediría por su capacidad de reproducirlo.
Actívalo cuando captures ejecuciones que salieron bien, para fijar un comportamiento que ya te gusta. Déjalo desactivado, y escribe tú la respuesta esperada, cuando captures un fallo.
Los evaluadores integrados
mcpd trae trece evaluadores deterministas: código corriente que lee una ejecución guardada y devuelve un número. No cuestan nada, nunca llaman a un modelo, y dan la misma respuesta dos veces, que es la propiedad que necesita una prueba de regresión y que un modelo juez no tiene.
| Evaluador | Pasa cuando |
|---|---|
answered | La ejecución produjo una respuesta no vacía. |
no_errors | Ningún span de la ejecución falló. |
exact_match | La respuesta es igual a la esperada. |
contains | La respuesta contiene una cadena obligatoria. |
json_valid | La respuesta se analiza como JSON. |
latency_budget | La ejecución terminó dentro de un presupuesto en milisegundos. |
cost_budget | La ejecución no costó más de una cantidad fijada. |
no_tool_loop | Ninguna herramienta se llamó dos veces con la misma entrada. |
tool_efficiency | La ejecución se mantuvo dentro de un presupuesto de llamadas a herramientas. |
not_a_refusal | La respuesta no es una negativa ni una disculpa. |
no_leaked_secret | No aparece ninguna clave, token ni credencial en la respuesta. |
span_present | La ejecución registró un span del tipo o el nombre exigido. |
tool_succeeded | La herramienta exigida se ejecutó y al menos una llamada terminó bien. |
GET /v1/api/scorers devuelve esa misma lista con una marca
needs_config. Un evaluador que necesita una respuesta esperada y
no la recibe se abstiene en lugar de adivinar, y esa abstención se informa.
Cuando quien califica es un modelo
Hay preguntas que ningún código determinista responde. Si una respuesta atendió de verdad a la pregunta, si un resumen conservó el sentido, si el tono era el correcto: eso necesita un lector, y un modelo es el lector barato.
La puntuación con juez está apagada hasta que la configuras, y un evaluador con juez no se ejecuta nunca si no lo nombras. Los dos valores por defecto existen por la misma razón: calificar gasta dinero en cada ejecución que lee, y una función que hace eso en silencio es una función de la que te enteras por una factura.
{
"judge": {
"endpoint": "http://localhost:11434/v1",
"model": "qwen2.5:14b",
"api_key": "",
"timeout_seconds": 30,
"criteria": {
"answer_relevance": "Does the answer address the question that was asked?",
"no_hedging": "Does the answer commit, rather than hedge?"
}
}
}
La solicitud tiene la forma de chat-completions de OpenAI, y eso no es una
preferencia por un proveedor: Ollama, vLLM, llama.cpp, LM Studio, OpenRouter
y los proveedores alojados la aceptan todos, así que la misma configuración
llega a un modelo en tu portátil y a una pasarela en producción. Deja
api_key vacío para un modelo local que no la pida. La
temperatura es cero, porque un juez que responde distinto cada vez no se
puede comparar consigo mismo, que es lo único para lo que sirven las
puntuaciones.
Los criterios se configuran en vez de pasarse en cada solicitud. El identificador de un evaluador queda guardado en cada puntuación que produce y es aquello por lo que agrupa una comparación posterior, así que dejar que cada llamada inventara a la vez el identificador y la pregunta construiría una columna en la que nadie puede confiar.
Los criterios configurados no aparecen en GET /v1/api/scorers.
Ese endpoint lista los trece evaluadores integrados y nada más, así que un
cliente que arme su menú de evaluadores a partir de ahí no ofrecerá tus
criterios. El único sitio donde el daemon te los nombra de vuelta es el error
que recibes por un identificador de evaluador desconocido, que lista juntos
los integrados y los criterios configurados.
ejecutar un evaluador con juez sobre la ejecución más reciente
curl -X POST localhost:4317/v1/api/score \
-H 'content-type: application/json' \
-d '{"limit": 1, "scorers": ["answer_relevance"]}' timeout_seconds es cuánto puede tardar una llamada de
evaluación, desde que conecta hasta el último byte de la respuesta: 30 por
defecto, de 1 a 300. Si una llamada se pasa de ese tiempo, el evaluador se
abstiene y nombra la clave, y mcpd lee como mucho 1 MiB de una respuesta.
Cada evaluador con juez es una llamada al modelo por ejecución, y la
solicitud tiene que terminar dentro de
--request-timeout-seconds. Así que una solicitud evalúa como
mucho las ejecuciones que caben en ese plazo si cada llamada tarda todo su
timeout_seconds. Con los valores por defecto, 30 y 30, y un
evaluador con juez, eso es una ejecución. Una solicitud que pide más, en
trace_ids o en limit, se rechaza con un 400 que
nombra el tope, antes de llamar a ningún modelo. Si omites
limit, la solicitud evalúa tantas ejecuciones recientes como quepan. Sube
--request-timeout-seconds para evaluar más en una solicitud.
Cada puntuación con juez se guarda en cuanto el modelo responde, así que una
solicitud cortada a medias conserva las que terminó.
Se abstiene en lugar de adivinar
Un juez que devuelve un número que no dedujo es peor que ningún juez, porque
parece evidencia. Por eso cada uno de estos casos se informa en
skipped con su motivo, y ninguno produce una puntuación.
| Qué pasó | Qué hace mcpd |
|---|---|
| La ejecución no registró ninguna respuesta | Se abstiene. Una ejecución sin respuesta no ha puntuado mal en relevancia: no se ha medido. |
| El modelo respondió en prosa | Se abstiene, y cita la respuesta. Un JSON envuelto en un bloque de código o en una frase sí se lee. |
| El modelo respondió fuera de la escala | Se abstiene. Un siete en una escala de uno no es un nueve décimos, y recortarlo sería inventar un número. |
| No se pudo contactar con el modelo | Se abstiene, con el estado y el cuerpo. Un límite de tasa es un motivo, no un cero. |
| La respuesta pasaba de 1 MiB | Se abstiene sin leer el resto. Un veredicto ocupa unos cientos de bytes, así que una respuesta de ese tamaño no lo es. |
| El plazo de la solicitud no dejó tiempo para la llamada | Se niega sin llamar al modelo. La pasada responde con las puntuaciones que terminó, dentro del plazo, en vez de con un 503. |
Toda puntuación con juez guarda el modelo como su
scorer_version. Dos puntuaciones de dos jueces distintos no son
comparables, y ese campo es lo que lo dice en el registro.
Puntuar ocurre a posteriori
POST /v1/api/score pasa los evaluadores sobre ejecuciones que ya
están guardadas. Nada se evalúa en línea. Una evaluación en la ruta de la
solicitud suma su latencia a aquello que juzga, y una evaluación que lanza un
error nunca debería tumbar la solicitud de un usuario.
puntuar las últimas cien ejecuciones
curl -X POST localhost:4317/v1/api/score \
-H 'content-type: application/json' \
-d '{"project": "support-desk", "limit": 100, "config": {"max_ms": 4000}}'
La respuesta trae las puntuaciones que escribió y, por separado, cada
evaluador que se abstuvo con el motivo por el que lo hizo. Un evaluador que
no pudo responder es más interesante que uno que devolvió un número, así que
mcpd nunca se lo traga. El techo de ingesta del proyecto también llega hasta
esta puerta: una puntuación calculada por la pasada que llevaría al proyecto
por encima de --quota-per-minute no se guarda, y aparece en
skipped diciéndolo, en vez de escribirse pasando del techo o
tirarse en silencio.
Envía "dry_run": true para ver qué produciría una pasada sin
guardar nada.
Qué ejecuciones lee una pasada lo deciden trace_ids,
from y limit. Nombra las ejecuciones en
trace_ids para puntuar exactamente esas. Déjalo vacío y la
pasada toma las limit ejecuciones más recientes del proyecto, de
más nueva a más antigua, y from acota esa página: una pasada
enviada con from lee solo las ejecuciones que empezaron en ese
momento o después, que es como puntuas todo lo ocurrido desde un despliegue
sin listar identificadores.
Comparar dos versiones
Registra un experimento por cada versión que quieras comparar, etiqueta sus puntuaciones con el identificador de ese experimento, y pídele a mcpd la diferencia.
¿ayudó el prompt nuevo?
curl 'localhost:4317/v1/api/experiments/compare?baseline=v3&candidate=v4'
La respuesta es una diferencia por evaluador con el tamaño de muestra de cada
lado, y una marca decidable. Por debajo de veinte observaciones
mcpd dice que la comparación no se puede decidir, en vez de informar de un
movimiento.
Es una regla deliberadamente tosca, no una prueba de significación. El fallo que evita es el habitual: una media que se movió sobre cuatro ejecuciones, impresa sin su recuento, leída como un resultado y llevada a producción. Imprimir los recuentos junto a la diferencia es lo que impide confundir el ruido con un hallazgo.
Dónde se ejecuta esto
Todo esto es la API HTTP, así que el ciclo pertenece a CI: captura los fallos
según ocurren, puntúa las ejecuciones que produce tu suite de pruebas,
registra la compilación como un experimento, y haz fallar el trabajo cuando
regressed vuelva como verdadero en una comparación decidible.
A continuación
- Las dos señales junto a las que vive una puntuación: Puntuaciones y feedback.
- Vigilar la media de un evaluador en producción: Monitores y alertas.
- Todos los endpoints y campos: API HTTP.