Evalúa los tool rounds de tu agente: Promptfoo + LLM4Agents
Un eval de prompt plano deja de medir tu agente en el momento exacto en que el agente empieza a hacer su trabajo: la primera tool call. Este es el follow-up que prometimos en el runbook de migración — unas cuarenta líneas de glue que hacen que Promptfoo evalúe la conversación completa, MCP tool rounds incluidos.
El setup que vamos a testear es el RAG agent del tutorial de 50 líneas: un system prompt, una allowlist de una sola tool (vector_query) y maxToolRounds: 2. Ese post salió con un golden eval de tres queries — un loop que busca substrings esperados en la respuesta. Atrapa las fallas más ruidosas. No dice nada sobre cómo el agente llegó a la respuesta: si de verdad recuperó del corpus, cuántos rounds quemó, cuánto costó la corrida. Esas son las propiedades que regresionan en silencio cuando editas un system prompt, y son exactamente lo que un chequeo de strings no puede ver.
Por qué el eval de prompt plano no alcanza
Apunta Promptfoo al endpoint OpenAI-compatible de LLM4Agents — como mostró el post de migración — y obtienes un eval sólido de una sola llamada de completions. Un prompt entra, una respuesta sale, asserts sobre el texto. Ese modo tiene un punto ciego estructural para agentes: el comportamiento interesante vive entre el request y la respuesta final. El modelo decide llamar una tool, la plataforma la ejecuta, el resultado vuelve a entrar, y el loop se repite hasta que el modelo responde o agota su presupuesto de rounds.
Tres regresiones que hemos visto en la práctica, ninguna de las cuales un assert de substring puede atrapar por sí solo:
El agente deja de recuperar. Una edición bienintencionada del system prompt ("sé conciso") hace que el modelo responda preguntas del handbook desde sus propios pesos. La respuesta muchas veces todavía contiene el string que se ve correcto. Ya no está anclada en tu corpus, y la primera actualización de políticas lo va a demostrar.
El agente quema su presupuesto de rounds. Una descripción de tool vaga hace que el modelo reintente vector_query con queries casi idénticas. Con maxToolRounds: 2 el síntoma es una respuesta pobre; con un presupuesto mayor es una factura. En ambos casos, el assert de string pasa.
El costo deriva. Cada tool round es una llamada facturada más tokens adicionales en el contexto. Un cambio que duplica el promedio de rounds duplica el costo por pregunta sin cambiar el pass rate. En una plataforma donde cada respuesta trae su costo liquidado, no hacer asserts sobre costo es dejar información sobre la mesa.
Una nota sobre el runner
Hay una ironía que atender antes del código. El post de migración recomendó Promptfoo como destino para las suites de eval que salían de los Evals hosteados de OpenAI — y en marzo, OpenAI anunció que adquiría Promptfoo. El anuncio de Promptfoo se compromete a mantener el proyecto open source y a "support a diverse range of providers and models". Eso son palabras; los hechos estructurales importan más. Promptfoo corre local, en tu máquina o en tu CI, contra cualquier endpoint que configures. Tus casos de eval viven en archivos YAML dentro de tu repo, no en una consola hosteada que puede pasar a read-only — que es precisamente el modo de falla que forzó la migración de Agent Builder en primer lugar. Si la dirección del proyecto llegara a cambiar, la licencia MIT significa que la versión de la que dependes sigue funcionando y se puede forkear. Assets portables, ejecución local, licencia permisiva: el checklist del post de evaluación sobrevive la adquisición. Lo seguimos recomendando, con los ojos abiertos.
El patrón de custom provider
El punto de extensión de Promptfoo es el custom provider: una clase de JavaScript o TypeScript con dos métodos. id() devuelve una etiqueta. callApi(prompt, context) recibe el prompt renderizado y devuelve un objeto cuyo único campo requerido es output — más campos opcionales que son el punto entero de este ejercicio: cost, error y un objeto metadata de forma libre que tus asserts pueden leer después. Referencias el archivo desde la config con file://, y lo que pongas bajo config: en el YAML llega al constructor.
El patrón, entonces: en vez de dejar que Promptfoo haga una llamada HTTP, el provider ejecuta tu conversación real — mismo SDK, mismo system prompt, misma allowlist, mismo presupuesto de rounds que producción — y reporta lo que pasó como metadata.
El glue code
Este es el provider, completo. Guárdalo como llm4agents-provider.ts junto a tu config:
// llm4agents-provider.ts — custom provider de Promptfoo para evals de agentes
import { LLM4AgentsClient } from '@llm4agents/sdk';
const client = new LLM4AgentsClient({ apiKey: process.env.LLM4AGENTS_API_KEY! });
type AgentConfig = {
model: string;
system: string;
allow: readonly string[];
maxToolRounds?: number;
};
export default class LLM4AgentsAgentProvider {
constructor(private readonly options: { id?: string; config: AgentConfig }) {}
id() { return this.options.id ?? 'llm4agents-agent'; }
async callApi(prompt: string) {
const { model, system, allow, maxToolRounds = 4 } = this.options.config;
// conversación nueva por test case — sin estado que se filtre entre filas
const conv = client.chat.conversation({
model, system, maxToolRounds,
tools: { mcp: { url: 'https://mcp.llm4agents.com/mcp', allow: [...allow] } },
});
try {
const reply = await conv.say(prompt);
const toolNames = reply.toolCalls.map((t) => t.name);
return {
output: reply.content,
cost: reply.costUsdCents / 100, // el header X-Cost-Usd-Cents, expuesto por el SDK
metadata: {
toolNames,
toolCallCount: toolNames.length,
toolArgs: reply.toolCalls.map((t) => JSON.stringify(t.arguments)),
},
};
} catch (e) {
return { error: String(e) };
}
}
}
Dos decisiones de diseño que vale la pena nombrar. Una conversación nueva por callApi significa que cada fila de test arranca limpia — sin historia filtrándose entre casos, que es lo que quieres para regression testing (los evals multi-turn son otra config, no otro provider). Y el provider reporta hechos, no veredictos: qué tools corrieron, cuántas veces, con qué argumentos, a qué costo. Juzgar esos hechos es trabajo de la config, lo que mantiene el glue reutilizable a través de todos los agentes que operas.
Los asserts que atrapan regresiones de agentes
La config conecta el provider con las golden queries del post de RAG, y después agrega los asserts que un eval de prompt plano no podía expresar. El sistema de assertions de Promptfoo pasa el metadata de tu provider directo a los asserts javascript vía context.metadata — ese shortcut es lo que hace funcionar todo el patrón:
# promptfooconfig.yaml
prompts:
- '{{question}}'
providers:
- id: file://./llm4agents-provider.ts
label: handbook-rag-agent
config:
model: anthropic/claude-sonnet-4.6
system: 'Answer strictly from the handbook corpus. Always call vector_query before answering. Cite the source filename. If nothing relevant returns, say so.'
allow: [vector_query]
maxToolRounds: 2
defaultTest:
assert:
# 1. el agente de verdad recuperó — grounding, no memoria
- type: javascript
value: 'context.metadata.toolNames.includes("vector_query")'
# 2. respetó su presupuesto de rounds — sin loops descontrolados
- type: javascript
value: 'context.metadata.toolCallCount <= 2'
# 3. nunca repitió exactamente la misma llamada — detector de retry loops
- type: javascript
value: 'new Set(context.metadata.toolArgs).size === context.metadata.toolArgs.length'
# 4. techo de costo por pregunta, en dólares
- type: cost
threshold: 0.05
tests:
- vars: { question: 'What is the refund window for annual plans?' }
assert:
- { type: icontains, value: '30 days' }
- vars: { question: 'Who approves expenses over the team limit?' }
assert:
- { type: icontains, value: 'finance' }
- vars: { question: 'What color is the CEO's car?' }
assert:
# pregunta fuera del corpus: debe recuperar y admitir que no encontró nada
- type: llm-rubric
value: 'The answer states the information is not in the corpus and does not invent a color.'
Corre npx promptfoo@latest eval y cada fila ejecuta el agente real. Los cuatro asserts por defecto codifican las tres regresiones silenciosas del inicio de este post. El assert 1 hace fallar la edición que "dejó de recuperar" incluso cuando el texto de la respuesta se ve correcto. Los asserts 2 y 3 hacen fallar la regresión del retry loop — nota que el assert 3 es más estricto que el 2 y atrapa la patología incluso con un presupuesto de rounds generoso. El assert 4 convierte la liquidación por llamada de la plataforma en una compuerta de regresión: el número sale del mismo pipeline reserve-proxy-settle que tus facturas de producción, así que el eval cuesta exactamente lo que producción va a pagar.
La tercera fila de test es la que los operadores se saltan y no deberían. Un agente que responde con confianza preguntas fuera del corpus es una falla de grounding que los asserts de string premian en vez de atrapar; un llm-rubric de una línea — model-graded, así que cuesta una llamada de grader — es el chequeo honesto más barato. Y como el grader también habla el shape del API de OpenAI, puedes apuntarlo al endpoint de LLM4Agents y mantener el eval completo, agente y juez, en un solo balance.
trajectory:tool-used, trajectory:tool-sequence) que verifican rutas de tools de forma nativa. Requieren trace data de OpenTelemetry desde el provider — maquinaria más pesada de la que este tutorial necesita. El patrón de metadata de arriba te da las mismas garantías con cero infraestructura de tracing; gradúa a asserts basados en traces cuando necesites timings de spans, no antes.
CI: haz fallar el pull request, no el agente
La suite se gana su lugar cuando corre en cada cambio al system prompt, a la allowlist o a las descripciones de tools. Promptfoo publica una GitHub Action oficial que corre el eval y comenta los resultados en el pull request, con una vista de diff contra la rama base:
# .github/workflows/agent-eval.yml
name: agent-eval
on:
pull_request:
paths: ['agents/**', 'promptfooconfig.yaml']
jobs:
eval:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.cache/promptfoo
key: ${{ runner.os }}-promptfoo-v1
- uses: promptfoo/promptfoo-action@v1
env:
LLM4AGENTS_API_KEY: ${{ secrets.LLM4AGENTS_API_KEY }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
config: promptfooconfig.yaml
cache-path: ~/.cache/promptfoo
Dos notas operativas. Dale a CI su propio agente registrado con un balance chico dedicado — el tráfico de eval aparece entonces como su propia línea en client.wallets.transactions(), separado de producción, y una key de CI filtrada solo puede gastar su propio presupuesto. Y mantén el paso de cache: Promptfoo cachea llamadas idénticas entre corridas, así que un PR que solo toca una fila de test no re-factura la suite completa.
Qué significa para LLM4Agents
Este tutorial cierra la caja más débil del mapping de migración. "Evals → Promptfoo" era honesto pero superficial mientras solo cubría prompts planos; con el patrón de provider, la historia de evals ahora cubre lo que los agentes de verdad hacen en esta plataforma — conversaciones que gastan dinero llamando tools. Eso importa comercialmente: el operador que decide si mover un workload de producción fuera de un stack hosteado deprecado pregunta "¿puedo testearlo como testeo código?", y la respuesta ahora es un archivo de cuarenta líneas que puede copiar.
El encaje más profundo es el billing. La mayoría de los stacks de eval tratan el costo como una estimación derivada de conteos de tokens y una tabla de precios. Acá el provider reporta costo liquidado desde el propio pipeline de facturación de la plataforma, por fila de test, y el assert de cost lo convierte en compuerta. Ninguna otra parte del stack podría producir ese número honestamente — sale gratis de reserve-proxy-settle. Evals que se ponen precio a sí mismos son un diferenciador que la plataforma obtiene sin shippear nada.
Cómo mantenerse en la frontera
Lo que este walkthrough expone, en el orden en que debería atenderse:
- Publicar el provider como paquete. Cuarenta líneas de glue es un blog post;
@llm4agents/promptfoo-provideres una integración. Shippearlo con el contrato de metadata de arriba para que los asserts escritos hoy sigan funcionando. - Exponer los tool rounds en el objeto de reply como contrato estable. Este patrón se apoya en
reply.toolCallsy en el costo por llamada. Documentar ambos como superficie versionada del SDK — las suites de eval son los consumidores que más fuerte se rompen cuando un campo se mueve. - Emitir spans de OpenTelemetry desde el conversation loop. Los trajectory asserts de Promptfoo y el ecosistema de observabilidad convergen en traces. Cuando el SDK emita spans de tool rounds de forma nativa, los operadores obtienen asserts
trajectory:tool-sequencey tracing de producción de la misma instrumentación. - Agregar un starter de golden suite al registro de agentes. Un agente nuevo debería nacer con un scaffold de
promptfooconfig.yaml— el hábito del eval debería ser el camino por defecto, no una best practice enterrada en dos tutoriales. - Vigilar la gobernanza de Promptfoo post-adquisición. El compromiso con la diversidad de providers está en el registro. Si se debilita, la recomendación de eval runner debería moverse — y como cada asset de este post es YAML local y un archivo de provider, moverse costaría una tarde, no una migración.
Evalúa el agente, no el prompt
Registra un agente, copia el archivo del provider y pon tus tool rounds bajo test hoy.
Registra tu agente