Evals deterministas para agentes de IA: testea datos, no frases
Un developer me enseñó su suite de tests para un agente de soporte. Tenía esta línea:
expect(result.text).toBe("Tu suscripción ha sido cancelada con éxito.");
En local pasó tres veces. Hizo push. En la cuarta ejecución en CI, el modelo contestó: "Hemos procesado la cancelación de tu suscripción correctamente."
Pipeline en rojo. La suscripción se canceló. La tool correcta se llamó con el userId correcto. El agente hizo su trabajo y el test falló porque el modelo cambió tres palabras.
Ese test no medía al agente. Medía la redacción de un modelo probabilístico, justo la parte que no controlas. La salida son los evals deterministas para agentes de IA: en vez de relajar la aserción hasta que ya no garantice nada, cambias lo que el agente devuelve.
¿Qué son los evals deterministas para agentes de IA?
Un eval determinista es una comprobación cuyo resultado no depende de cómo redacte el modelo. El agente no devuelve una frase: devuelve un objeto tipado —un veredicto— y el test asierta de forma exacta sobre sus campos. Un decision que es un enum cerrado, un array de códigos de motivo, un identificador. Datos, no prosa. La misma clase de aserción que harías contra un endpoint REST.
La diferencia con lo que la mayoría llama "eval" es el punto de aplicación. No estás puntuando una respuesta a posteriori con una rúbrica: estás rediseñando la interfaz del agente para que su decisión sea inspeccionable.
Conviene marcar la frontera con dos cosas que ya conté por separado. El test harness para agentes de IA es el entorno: las tools falsas, el presupuesto de tokens que corta, el timeout real, la traza reproducible. Es el paso previo y es obligatorio. Este post va de lo otro: qué afirmas dentro de ese entorno.
Y el function calling tipado con TypeScript valida la ENTRADA: los argumentos que el modelo manda a una tool, que en ai@7 viajan en inputSchema. Aquí hablamos de la SALIDA: el veredicto que emite el agente. Es la otra punta del mismo cable, y casi nadie tipa esa punta.
Los dos callejones sin salida antes de llegar aquí
Cuando el test de arriba se pone rojo hay dos salidas habituales, y las dos son peores que el problema: relajar la aserción hasta que deje de garantizar nada, o delegar el juicio en otro modelo.
El primero es relajar la aserción. Un toContain, una expresión regular, un .toLowerCase().includes(). Queda así:
expect(result.text.toLowerCase()).toContain("cancel");
Verde. Y ahora ese test pasa también si el agente respondió "No puedo cancelar tu suscripción, contacta con soporte". Acabas de escribir una aserción que da verde cuando el agente hace exactamente lo contrario de lo que le pediste. Un test que no puede fallar en el caso que importa no es un test: es decoración en el pipeline.
El segundo es montar un LLM-as-a-Judge para todo. Otro modelo lee la respuesta y decide si es correcta. Funciona, pero paga tres precios: es lento (una llamada extra por caso), es caro (y los evals se ejecutan por lotes, así que multiplica), y sobre todo hereda el no-determinismo que intentabas eliminar. Tu suite pasa a depender de que el juez opine igual el martes que el jueves. Y entonces tienes un segundo problema: quién calibra al juez.
El juez tiene su sitio. Pero es el último recurso, no el primero. Antes de delegar una decisión en otro modelo, pregúntate si esa decisión se puede tipar. La mayoría de las veces se puede.
| Superficie de aserción | Determinista | Coste | Cuándo usarla |
|---|---|---|---|
Texto libre con toBe o regex |
No | Cero | Nunca sobre la salida del modelo: o revienta con sinónimos o da verde con cualquier cosa |
Objeto tipado (generateObject + Zod) |
Sí en la aserción | Cero extra | Siempre que la salida sea una decisión, una clasificación, una extracción o un enrutado |
| LLM-as-a-Judge | No | Alto, una llamada por caso | Cuando la calidad es irreductiblemente textual: resúmenes, tono, redacción, código |
El giro: que la decisión sea un dato, no una frase
Si quieres afirmar sobre la decisión del agente, haz que la decisión sea un campo.
Con el AI SDK de Vercel eso es generateObject más un schema de Zod. Los ejemplos de este post corren con ai@7, zod@4 y Vitest 4, versiones de septiembre de 2026. El modelo deja de tener libertad de formato: o devuelve algo que valida contra el schema, o falla ruidosamente, que también es información útil.
Un agente que revisa solicitudes de reembolso:
// refund-agent.ts
import { generateObject } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
import type { RefundTicket } from "./types";
import { REFUND_POLICY_PROMPT } from "./prompts";
const model = anthropic("claude-haiku-4-5-20251001");
export const RefundVerdictSchema = z.object({
decision: z.enum(["APPROVED", "REJECTED", "MANUAL_REVIEW"]),
reasonCodes: z
.array(
z.enum([
"OUTSIDE_RETURN_WINDOW",
"ITEM_DAMAGED_BY_CUSTOMER",
"DUPLICATE_REQUEST",
"OPEN_CHARGEBACK",
"HIGH_VALUE_ORDER",
"TRUSTED_CUSTOMER",
]),
)
.min(1),
riskSignals: z.object({
priorRefunds12m: z.number().int().min(0),
daysSincePurchase: z.number().int().min(0),
}),
summary: z.string(),
});
export type RefundVerdict = z.infer<typeof RefundVerdictSchema>;
export async function reviewRefund(ticket: RefundTicket): Promise<RefundVerdict> {
const { object } = await generateObject({
model,
schema: RefundVerdictSchema,
temperature: 0,
instructions: REFUND_POLICY_PROMPT,
prompt: JSON.stringify(ticket),
});
return object;
}
Fíjate en lo que acaba de pasar. reviewRefund ya no devuelve texto: devuelve RefundVerdict. Un tipo. Tu test vuelve a ser un test normal.
Si ese veredicto es el paso final de un bucle con varias herramientas por medio, el schema es el punto de salida del bucle. Cómo montarlo con estado y reintentos lo desarrollé en el agentic loop en producción con TypeScript.
Hasta aquí es lo que cuenta todo el mundo. Lo que casi nadie cuenta es que el schema puede estar bien tipado y ser una superficie de test pésima.
Cómo diseñar el schema del veredicto: 5 reglas
Esta es la parte que decide si tu suite aguanta seis meses o se convierte en ruido. Cinco reglas.
1. Enums cerrados, nunca strings libres
decision: z.string() valida perfectamente y no te sirve de nada. El modelo devolverá "rechazado", luego "Rechazado por política", luego "REJECT". Has movido el problema del texto de la respuesta al texto de un campo.
// Mal: sigues asertando sobre prosa
decision: z.string(),
// Bien: el espacio de valores es finito y conocido
decision: z.enum(["APPROVED", "REJECTED", "MANUAL_REVIEW"]),
Un enum cerrado tiene una propiedad que ningún string tiene: si el modelo quiere decir algo, solo puede decirlo de una manera. Ahí es donde toBe recupera el sentido.
2. Códigos de motivo, no explicaciones
Un veredicto que solo dice REJECTED te deja testear el qué, pero no el porqué. Y el porqué es donde viven las regresiones interesantes: el agente sigue rechazando el caso correcto, pero por el motivo equivocado. Eso es un bug que un test binario no ve.
Por eso reasonCodes es un z.array(z.enum([...])) y no un z.array(z.string()). Con códigos puedes asertar la causa exacta. Con texto libre, vuelves al principio del post.
Diseñar bien esa lista de códigos es trabajo de verdad: enums demasiado finos y el modelo elige mal entre opciones casi idénticas; demasiado gruesos y no distinguen nada. Empieza por los motivos que ya aparecen escritos en tu política de negocio.
3. Los scores numéricos son la aserción más frágil que existe
confidenceScore: z.number() es tentador. Y es una trampa.
El modelo devuelve 0.82 hoy y 0.79 mañana con la misma entrada. Cualquier test que compare el valor exacto es un test que parpadea. Y cualquier umbral que escribas dentro del prompt —"si la confianza supera 0.8, aprueba"— es lógica de negocio metida en la parte no determinista del sistema.
Dos reglas:
- Si el score se queda, asierta rangos o umbrales, nunca el valor:
expect(v.confidenceScore).toBeGreaterThan(0.7). - Mejor aún: saca el umbral del modelo y ponlo en tu código. Que el agente devuelva señales en bruto (
priorRefunds12m,daysSincePurchase) y que la regla la aplique una función TypeScript pura.
// route-verdict.ts — 100% determinista, testeable sin llamar al modelo
export function routeVerdict(v: RefundVerdict): "AUTO" | "MANUAL_REVIEW" {
const { priorRefunds12m, daysSincePurchase } = v.riskSignals;
// El veredicto del agente manda: si pidió revisión humana, no la saltamos
if (v.decision === "MANUAL_REVIEW") return "MANUAL_REVIEW";
if (priorRefunds12m >= 3) return "MANUAL_REVIEW";
if (daysSincePurchase > 30 && v.decision === "APPROVED") return "MANUAL_REVIEW";
return "AUTO";
}
Cada umbral que mueves del prompt a una función es un test que pasa de probabilístico a exacto.
4. Separa lo que se asierta de lo que se lee
El schema puede —y suele— tener campos en texto libre. summary está ahí para que un humano entienda la decisión en el panel de revisión, y hace falta.
La regla es que ese campo no se asierta jamás. Ni con toContain, ni con regex, ni "solo para comprobar que no viene vacío". Déjalo escrito en un comentario del propio schema, para que el siguiente developer no caiga en la tentación. Un schema tiene dos zonas: la contractual, sobre la que testeas, y la informativa, que solo se lee.
5. Los campos opcionales fabrican tests frágiles
En cuanto un campo permite undefined, tu test tiene que decidir qué significa eso. Y normalmente no lo decide: lo esquiva con un ?. y se queda verde por accidente.
// Ambiguo: ¿no había motivos, o el modelo no los rellenó?
reasonCodes: z.array(ReasonCode).optional(),
// Explícito: el array siempre viene, y siempre con al menos un motivo
reasonCodes: z.array(ReasonCode).min(1),
Prefiere valores por defecto, arrays vacíos y uniones discriminadas antes que opcionalidad. Un undefined que atraviesa la suite entera sin que nadie lo asierte es un agujero con forma de test.
Este tipo de diseño —enums, refinamientos, uniones discriminadas, z.infer para no duplicar tipos— es lo que trabajo paso a paso en el curso de Zod para TypeScript, porque aquí el schema no es validación defensiva: es la superficie de test de todo el sistema.
El test que resulta
Con el schema anterior, el eval en Vitest es aburrido. Ese es el objetivo: un test de agente de IA que se lee igual que cualquier otro test de tu suite.
// refund-agent.eval.test.ts
import { describe, it, expect } from "vitest";
import { reviewRefund, type RefundVerdict } from "./refund-agent";
import { routeVerdict } from "./route-verdict";
import { lateRequestWithChargeback } from "./fixtures";
describe("refund agent · casos obvios", () => {
it("rechaza una solicitud fuera de plazo con chargeback abierto", async () => {
const verdict = await reviewRefund(lateRequestWithChargeback);
expect(verdict.decision).toBe("REJECTED");
expect(verdict.reasonCodes).toContain("OPEN_CHARGEBACK");
expect(verdict.reasonCodes).toContain("OUTSIDE_RETURN_WINDOW");
expect(verdict.reasonCodes).not.toContain("TRUSTED_CUSTOMER");
});
});
describe("routeVerdict · sin modelo", () => {
it("escala a revisión manual con 3 reembolsos previos", () => {
const verdict: RefundVerdict = {
decision: "APPROVED",
reasonCodes: ["TRUSTED_CUSTOMER"],
riskSignals: { priorRefunds12m: 3, daysSincePurchase: 5 },
summary: "",
};
expect(routeVerdict(verdict)).toBe("MANUAL_REVIEW");
});
});
Dos detalles que importan.
El toContain de aquí no es el toContain del callejón sin salida. Sobre un string comprueba subcadenas y da verde con cualquier ruido alrededor; sobre un array de enums comprueba pertenencia exacta a un conjunto cerrado. Misma función, garantías opuestas.
Y el not.toContain vale tanto como el positivo. Un agente que rechaza el caso correcto pero marca al cliente como fiable está acertando por la razón equivocada, y ese es el fallo que se cuela a producción sin que nadie lo vea.
Este test no se rompe si el modelo cambia la redacción del summary. Ni si cambia el orden de los motivos. Ni si actualizas a la siguiente versión del modelo y escribe más bonito. Solo se pone rojo cuando el agente decide distinto, que es exactamente lo que querías vigilar. Si quieres afinar el diseño de suites, fixtures y aislamiento de dependencias, ese músculo lo trabajo a fondo en el curso de Testing en Angular con Jest y Testing Library: los ejemplos son de Angular, pero el diseño de suites y fixtures se traslada tal cual.
Los límites de los evals deterministas en agentes de IA
Toca ser honesto: el schema hace determinista la aserción, no el modelo.
temperature: 0 reduce muchísimo la varianza, pero no la elimina. Entre el batching en el servidor, la aritmética en coma flotante y el enrutado interno de los modelos grandes, la misma entrada puede darte una decisión distinta. Menos que antes. No cero.
La forma de convivir con eso es partir la suite en dos, y esta distinción es la que casi nadie hace.
Casos obvios. El cliente pide el reembolso de un pedido de hace dos años con un chargeback abierto. Solo hay una respuesta razonable. Estos casos son tests binarios, corren siempre y bloquean el merge. Si uno falla, hay un bug: en el prompt, en el schema o en el modelo que acabas de actualizar.
Casos de frontera. El pedido tiene 31 días y la política dice 30, pero el cliente lleva cinco años contigo. Aquí ni tú tienes una respuesta única. Estos casos no se testean como binarios: se miden como tasa de acierto. Ejecutas N veces y exiges un umbral de consistencia. Cinco ejecuciones es el mínimo que justifica el coste, no una muestra seria: si el caso importa de verdad, sube a veinte antes de fiarte de la tasa. Por qué N no es un número arbitrario lo desarrollé en evaluaciones automatizadas para agentes.
// refund-agent.borderline.test.ts
import { borderlineTicket } from "./fixtures";
async function decisionCounts(runs: number, ticket: RefundTicket) {
const results = await Promise.all(
Array.from({ length: runs }, () => reviewRefund(ticket)),
);
return results.reduce<Record<string, number>>((acc, r) => {
acc[r.decision] = (acc[r.decision] ?? 0) + 1;
return acc;
}, {});
}
it(
"mantiene el caso frontera en revisión manual (4 de 5)",
async () => {
const counts = await decisionCounts(5, borderlineTicket);
expect(counts.MANUAL_REVIEW ?? 0).toBeGreaterThanOrEqual(4);
},
60_000,
);
Meter los casos de frontera en la suite que bloquea el merge es la receta perfecta para que el equipo empiece a relanzar pipelines hasta que pasen. Y a partir de ese día los tests dejan de significar nada. Van en un job programado, con su propio umbral y su propia alerta cuando la tasa cae.
Sí, esta suite cuesta dinero, porque llama al modelo de verdad. Por eso corre por lotes y no en cada push, mientras el test harness con tools falsas sigue corriendo en cada commit.
Cuándo sí necesitas un LLM-as-a-Judge
Cuando la calidad de la salida es irreductiblemente textual.
Si tu agente escribe un resumen, redacta un email a un cliente o genera un módulo entero de código, no hay enum que capture "esto está bien". Ahí el juez —con rúbrica explícita, golden dataset versionado y calibración humana— es la herramienta correcta, y lo desarrollé entero en evals para código generado por IA.
La regla de reparto es simple: si la decisión se puede tipar, típala; el juez es para lo que sobra después. En la mayoría de agentes de negocio, lo que sobra es mucho menos de lo que parece antes de sentarse a diseñar el schema.
Por dónde empezar mañana
Coge un agente. El que más te preocupe.
Mira qué devuelve hoy. Si devuelve texto, escribe el schema del veredicto: un enum de decisión, un array de códigos de motivo, las señales numéricas en bruto y un summary que no vas a asertar nunca. Cambia la llamada a generateObject. Y mueve al menos un umbral del prompt a una función TypeScript.
Después escribe cinco casos obvios. Cinco. Con eso ya tienes una red que detecta el día en que cambies de modelo y el agente empiece a aprobar lo que antes rechazaba, que es la regresión que de verdad cuesta dinero.
Este tipo de decisión de diseño es lo que separa una demo de un producto que aguanta usuarios reales, y es el hilo que sigo en el curso Construye con IA: de la idea al producto con Claude Code. En Dominicode Labs están los schemas y las suites completas de los agentes que corremos en producción, con sus casos de frontera y sus umbrales reales.
Deja de testear lo que el agente dice. Testea lo que el agente decide.
Preguntas frecuentes
¿Qué es exactamente un eval determinista?
Es una comprobación automática cuyo resultado no depende de cómo redacte el modelo. Se consigue haciendo que el agente devuelva un objeto tipado en lugar de texto y asertando sobre campos de valores cerrados, como enums o arrays de códigos. La aserción vuelve a ser exacta y repetible, igual que si testearas la respuesta de una API REST.
¿Con temperature 0 ya tengo determinismo garantizado?
No. Reduce mucho la varianza, pero no la elimina, porque hay factores del lado del proveedor que no controlas, como el batching de peticiones o la aritmética en coma flotante. Lo que sí es determinista es tu aserción, y por eso los casos de frontera se miden como tasa de acierto sobre varias ejecuciones en lugar de como un test binario.
¿Puedo asertar sobre un campo de confianza numérico?
Puedes, pero solo por rangos o umbrales, nunca por el valor exacto, porque el mismo caso te dará valores ligeramente distintos entre ejecuciones. La mejor opción es que el modelo devuelva las señales en bruto y que el umbral lo aplique una función de tu código, que sí puedes testear al cien por cien sin llamar al modelo.
¿En qué se diferencia esto de un test harness?
El harness es el entorno de ejecución: las herramientas falsas, el presupuesto de tokens, el timeout y la traza. Responde a si el agente se salió de sus límites. Los evals deterministas son las aserciones que escribes dentro de ese entorno y responden a si el agente decidió lo correcto. Se montan en ese orden: primero el entorno, después las aserciones.
¿Estos tests corren en cada push?
Los que no llaman al modelo, sí: el enrutado, los umbrales y toda la lógica pura alrededor del veredicto. Los que llaman al modelo de verdad cuestan dinero y tardan, así que van en un job programado sobre un conjunto reducido de casos, separando los obvios, que bloquean el merge, de los de frontera, que solo alertan cuando la tasa de acierto cae.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
