avatar Artículo

Mi agente de IA funcionaba. Mis tests, no.

Agentes de IA en producción

Un agente LLM en producción, de principio a fin: testear con evals antes de desplegar, observar qué hace en producción con Langfuse, operar y mejorar con lo que ves, y asegurar la frontera con Bedrock Guardrails. Fallos y soluciones reales de un agente en vivo.

4 artículos

En progreso
Mi agente de IA funcionaba. Mis tests, no.

Hace unos meses publiqué la primera versión del asistente de IA de AWS Announcements Hub. La web tiene un chatbot y, detrás de ese chatbot, hay un agente de IA. Ese agente se ejecuta con Strands sobre Amazon Bedrock AgentCore y utiliza Amazon Nova a través de Amazon Bedrock. Su trabajo es concreto: buscar entre más de 20.000 anuncios de AWS y responder preguntas sobre cómo han evolucionado los servicios con el tiempo.

Esta serie recorre el ciclo completo de llevar un agente a producción: testear antes de desplegar → observar qué hace en producción → operar y mejorar con lo que ves → asegurar la frontera. Esta es la parte 1, testear.

Montar la primera versión fue sorprendentemente rápido. Llegar al punto en el que podía cambiar algo sin preguntarme qué otra cosa acababa de romper, en cambio, fue otra historia.

Este post no es una lista de buenas prácticas. Es lo que me fui encontrando por el camino: tres fallos tontos que mis tests no vieron venir, lo que me obligaron a cambiar en mi forma de probar el agente, y el flujo con el que acabé trabajando. Si tienes un agente en producción, probablemente reconozcas el punto de partida.

El día que mis tests en verde no probaban lo que importaba

Yo ya tenía unit tests, y además pasaban todos. Las funciones Python que había detrás del agente estaban probadas: los filtros funcionaban, las transformaciones de datos funcionaban y las búsquedas devolvían lo que esperaba. En verde de arriba a abajo.

Y escribir esos tests nunca ha sido tan barato: hoy le pides a la IA que te genere la batería de unit tests de una función y la tienes en segundos, prácticamente gratis. Así que el problema nunca fue tener tests (me sobraban). El problema era que estaba probando lo que era fácil de probar, no lo que de verdad se rompía.

Tener muchos tests en verde no es lo mismo que probar lo que importa. La IA te genera unit tests en segundos; decidir qué comportamiento merece una prueba sigue siendo cosa tuya.

El problema apareció cuando dejé de hacerle preguntas “de manual” al agente y empecé a tratarlo como lo haría un usuario real. Como el asistente responde en varios idiomas, probé a propósito la misma intención en inglés y en español, y ahí se cayó el teatro.

Lo primero que le pedí, en inglés, fue el anuncio más antiguo de la base de datos:

Yo: “What is the first announcement?”

Agente: “I’m specialized in AWS announcements and services only.”

Es una pregunta perfectamente válida sobre anuncios de AWS, y aun así el agente la rechazó de plano. Pensé que era mala suerte con la redacción, así que le pregunté exactamente lo mismo en español:

Yo: “¿cuál fue el primer anuncio?”

Agente: [devuelve una lista de anuncios, del más reciente al más antiguo]

Esta vez sí buscó. Pero yo había pedido el primer anuncio y me devolvió, en la práctica, el último. Misma intención, idioma distinto, resultado distinto, y los dos mal.

El tercero fue el que más me preocupó. Le pregunté por un año concreto:

Yo: “Are there announcements from 2004?”

Agente: [responde con una lista plausible, sin tocar la base de datos]

Se inventó una respuesta verosímil a partir del conocimiento del propio modelo, sin consultar los datos reales. Si no llego a conocer el dataset, me la cuela.

Tres preguntas, tres fallos, y lo revelador es que cada uno vivía en una capa distinta del sistema: el primero fue un rechazo indebido de una consulta válida, el segundo un parámetro mal elegido por el modelo, y el tercero directamente no usar la tool cuando tocaba. Ninguno de mis unit tests se enteró de nada. Ahí caí en la cuenta de que estaba probando las tools, pero no estaba probando al agente.

La trampa está aquí: probar las funciones que hay detrás del agente no es lo mismo que probar al agente. El modelo decide si las llama, con qué parámetros y cómo interpreta el resultado, y esa capa no la toca ningún unit test.

Por qué los unit tests no bastaban (y por qué siguen ahí)

Que los unit tests no vieran estos fallos no significa que sobren. No sustituí nada: una tool es software normal, y el software determinista se prueba de forma determinista. Un test como este sigue siendo rápido, barato y valioso para cazar un filtro roto o una transformación mal hecha:

1
2
3
4
5
6
7
8
def test_search_returns_s3_results():
    results = search_announcements(service="S3", year=2025)

    assert results["count"] > 0
    assert all(
        r["service"] == "S3"
        for r in results["results"]
    )

El problema es que por encima de esa capa hay otra que el unit test no toca: el modelo decide si llama a la función, con qué argumentos, cómo interpreta el resultado y cómo lo convierte en respuesta. Tener un search_announcements() impecable no sirve de nada si el agente decide no llamarlo. Esa decisión es justo donde se me caían las tres preguntas de antes.

Así que añadí una segunda capa de tests. Y aquí está la diferencia que a mí me costó ver al principio: estos ya no son tests normales. Un unit test llama a tu función y comprueba lo que devuelve. Esta capa, en cambio, arranca el agente de verdad (con su modelo, su system prompt y sus tools reales), le lanza una pregunta en lenguaje natural y observa qué hace con ella. No prueba una función; prueba la decisión del modelo.

Lo monté en tres piezas. La primera es describir cada caso como un dato, no como código de test. Uso un dataclass para eso:

1
2
3
4
5
6
7
8
9
from dataclasses import dataclass, field

@dataclass
class EvalCase:
    id: str
    query: str                              # lo que le pregunto al agente
    tool_expected: str | None               # qué tool DEBE llamar (o None)
    must_contain: list[str] = field(default_factory=list)      # subcadenas obligatorias en la respuesta
    must_not_contain: list[str] = field(default_factory=list)  # subcadenas prohibidas

Un EvalCase no es un test: es la descripción de un comportamiento que me importa. Por ejemplo, “si pregunto por el primer anuncio, el agente tiene que llamar a search_announcements y NO soltarme el mensaje de rechazo”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
EvalCase(
    id="search-first-announcement-en",
    query="what is the first announcement?",
    tool_expected="search_announcements",
    must_not_contain=["I'm specialized in AWS"],   # no debe rechazar
    must_contain=["/announcement/"],               # debe devolver enlaces reales
)

EvalCase(
    id="scope-weather",
    query="what's the weather in Madrid?",
    tool_expected=None,                            # fuera de alcance: NO debe buscar
    must_contain=["I'm specialized in AWS"],       # aquí el rechazo SÍ es correcto
)

La segunda pieza es la que de verdad distingue esto de un unit test: una función que ejecuta el agente real y captura, además de la respuesta en texto, qué tools llamó y con qué parámetros. Simplificada:

1
2
3
4
5
6
7
8
9
10
11
12
13
def run_agent_locally(query: str) -> dict:
    agent = Agent(model=model, system_prompt=SYSTEM_PROMPT, tools=tools)

    response, tools_used, tool_calls = "", [], []
    async for event in agent.stream_async(query):
        if "data" in event:                        # texto que va emitiendo el agente
            response += event["data"]
        if "current_tool_use" in event:            # cada vez que decide usar una tool
            call = event["current_tool_use"]
            tools_used.append(call["name"])
            tool_calls.append({"name": call["name"], "params": call["input"]})

    return {"response": response, "tools_used": tools_used, "tool_calls": tool_calls}

Fíjate en que aquí no hay ningún assert: solo corro el agente y me quedo con lo que hizo. El tool_calls (el nombre de la tool y los parámetros exactos que eligió el modelo) es justo lo que un unit test nunca ve, y lo que me permitió cazar el bug del orden invertido.

La tercera pieza es la comprobación. Teniendo lo que el agente hizo, un assert normal basta para la mayoría de casos:

1
2
3
4
5
6
7
8
9
10
11
def score_case(case: EvalCase, result: dict) -> tuple[bool, list[str]]:
    failures = []
    if case.tool_expected and case.tool_expected not in result["tools_used"]:
        failures.append(f"esperaba la tool '{case.tool_expected}', usó {result['tools_used']}")
    for s in case.must_contain:
        if s not in result["response"]:
            failures.append(f"falta en la respuesta: {s!r}")
    for s in case.must_not_contain:
        if s in result["response"]:
            failures.append(f"no debería aparecer: {s!r}")
    return (len(failures) == 0, failures)

Y lo ato todo a pytest para que cada caso sea un test independiente y la suite entera corra con el mismo pytest de siempre:

1
2
3
4
5
6
7
import pytest

@pytest.mark.parametrize("case", EVAL_CASES, ids=[c.id for c in EVAL_CASES])
def test_eval_case(case):
    result = run_agent_locally(case.query)
    passed, failures = score_case(case, result)
    assert passed, f"Eval '{case.id}' falló:\n" + "\n".join(failures)

El resultado es que pytest tests/test_behavioral.py me ejecuta las decenas de preguntas contra el agente real y me dice, caso a caso, cuáles pasa y cuáles no. No hay un segundo LLM evaluando esto: corre el agente de verdad y código normal comprueba el resultado. Para cada caso puedo preguntarme si llamó a una tool, si era la correcta, qué parámetros usó, si rechazó algo que no debía, o si la respuesta contiene el tipo de enlace que espero. La redacción exacta casi nunca importa, y eso es bueno, porque un modelo puede dar la misma respuesta correcta de diez maneras distintas.

Pero el segundo bug, el del orden invertido en español, me enseñó que no basta con mirar qué tool se usa. Piensa en “What was the first AWS announcement?”: estas dos llamadas usan la tool correcta, y solo una responde a la pregunta.

1
2
search_announcements(sort_order="asc")   # correcta
search_announcements(sort_order="desc")  # misma tool, pregunta equivocada

Por eso separé un tercer tipo de comprobación centrada solo en los argumentos: que sort_order="asc" se use para peticiones tipo first u oldest, o que all_history=True aparezca cuando la pregunta necesita el histórico completo y no la ventana por defecto. Es una clase de fallo fácil de pasar por alto a ojo: la traza enseña la tool correcta, todo parece razonable, pero el modelo le está haciendo a la tool la pregunta equivocada.

Cuando el código ya no sabe juzgar

Las comprobaciones deterministas me llevan sorprendentemente lejos, pero tienen un techo: no me dicen si la respuesta final es buena. Imagina que la tool devuelve diez anuncios de Amazon EC2 y el agente muestra solo dos sin explicar por qué, se inventa un título, responde en inglés a una pregunta en español, cambia las URLs o describe el resultado más antiguo como si fuera el más nuevo. Escribir un assert para cada una de esas cosas se vuelve absurdo enseguida.

Para ese puñado de casos uso un LLM como evaluador. El juez recibe la consulta original, los datos que devolvieron las tools, la respuesta final del agente y un único criterio concreto a evaluar:

1
2
3
4
5
6
7
8
9
10
11
Query:
"¿cuáles son los últimos anuncios de EC2?"

Tool output:
[10 EC2 announcements]

Agent response:
"Here are the latest EC2 announcements..."

Evaluation:
Is the response written in the same language as the user query?

Estas evaluaciones las ejecuto con Amazon Nova a través de Bedrock, pero las uso con cuentagotas. Si código normal puede decirme si sort_order es correcto, el código normal gana siempre: es más rápido, más barato y determinista. El LLM judge lo reservo para cuando la propiedad que me importa de verdad exige entender la respuesta. Y tampoco lo trato como una verdad absoluta: es otro modelo más, así que el prompt de evaluación importa y sus veredictos hay que revisarlos de vez en cuando.

Con esto, el stack de tests me quedó repartido en cuatro capas que no compiten entre sí, cada una vigilando una frontera distinta del sistema:

CapaQué comprueboFallo típico que caza
Unit testsCódigo determinista de las toolsFiltro o transformación rotos
Behavioral evalsSelección de tool, rechazos, contrato de respuestaEl agente rechaza una consulta válida
Parameter evalsArgumentos que elige el modelosort_order="desc" en vez de "asc"
Quality evalsCalidad semántica de la respuesta finalIdioma incorrecto o resumen infiel

De bug a test: eval-driven development

Con las capas claras, volví a los tres fallos del principio, y aquí está lo que más me cambió la cabeza: ninguno de los tres era el mismo tipo de problema, aunque los tres parecían “cosas del prompt”.

El rechazo de la consulta válida sí era de prompt: el system prompt tenía ejemplos muy pegados a servicios concretos y no dejaba claro este tipo de pregunta más general. Lo arreglé cambiando instrucciones y ejemplos. El orden invertido en español, en cambio, no era un problema de idioma: tenía código Python intentando adivinar la dirección del orden a partir de palabras en inglés (first, latest), y para primero no había caso. La solución no fue parchear el diccionario, sino subir esa decisión al modelo (añadí sort_order a la interfaz de la tool) para que tradujera la intención del usuario a un valor explícito. Era un problema de diseño de la frontera entre el modelo y la tool. Y la respuesta inventada sobre 2004 era otra cosa distinta: el modelo sabía suficiente AWS como para improvisar algo plausible, así que lo hizo; lo resolví haciendo explícito que las preguntas sobre datos de anuncios tienen que apoyarse en la base de datos. Una restricción de comportamiento.

Ninguno exigió cambiar el foundation model, y ninguno era “prompt engineering” a secas. El comportamiento de un agente se reparte entre el prompt, los esquemas de las tools, el código determinista y el propio modelo, y la gracia de la suite de evals es que me dice qué frontera es la que está mal.

Eso cambió por completo mi flujo. Antes, tocar el prompt era esto:

flowchart LR
    A[Cambiar prompt] --> B[Probar 4 preguntas a mano]
    B --> C["Parece que va"]
    C --> D[Desplegar]

Y el agujero es siempre la pregunta que no se te ocurrió repetir: arreglas un comportamiento y rompes otro sin enterarte. Ahora el bucle es otro:

flowchart LR
    A[Encontrar un fallo] --> B[Reproducirlo]
    B --> C[Añadirlo a la suite]
    C --> D[Arreglar prompt, tool o código]
    D --> E[Pasar la suite]
    E --> F[Desplegar]

A esto se le suele llamar eval-driven development. Se parece a TDD, salvo que lo que pruebas es en parte probabilístico. El objetivo no es forzar a un LLM a comportarse como una función determinista; es dejar de evaluar cada cambio de prompt de memoria y a ojo.

Una pregunta razonable aquí es: ¿y Amazon Bedrock AgentCore Evaluations?

AgentCore tiene ahora una capacidad gestionada de evaluaciones, con evaluadores predefinidos y personalizados, que se solapa con parte de lo que he montado. Aun así sigo viendo valor en mantener mi pequeña suite dentro del repo: muchos de mis casos más útiles son aserciones deliberadamente aburridas (“esta consulta debe producir sort_order='asc'”), baratas, fáciles de entender, que corren en mi flujo normal de despliegue y que vienen directamente de bugs que ya me he comido. Para un sistema más grande, o si quisiera evaluación gestionada y continua sobre trazas de producción, AgentCore Evaluations gana mucho. No los veo como excluyentes.

Por dónde empezar (y lo que aún no cubro)

Hoy, antes de cada despliegue, el agente pasa unos 30 casos de comportamiento (selección de tools, scope, multiidioma), 11 de parámetros (sobre todo sort_order y all_history) y 8 de calidad (consistencia de idioma, fidelidad a los datos, formato de enlaces). Son 49 en total, y es a propósito: prefiero 49 casos que salen de comportamientos que me importan de verdad a cientos de prompts de test generados que no he mirado nunca. La suite sigue siendo lo bastante pequeña como para entender cada caso que hay dentro.

Si tienes un agente y aún no tienes evals, no empezaría eligiendo framework. Empezaría por los comportamientos que te importan. Apunta cinco consultas que el agente deba responder bien, dos que deba rechazar, qué tool esperas en cada una (si esperas alguna) y una propiedad observable que te diga si acertó. Pasa esas siete contra el agente real y, cuando aparezca el siguiente bug, añade la consulta que lo destapó. Eso ya es una suite de regresión. Los evaluadores semánticos los añades cuando los assert deterministas se quedan cortos.

Para arrancar: cinco consultas que el agente deba responder bien, dos que deba rechazar, la tool que esperas en cada una y una propiedad observable que te diga si acertó. Córrelas contra el agente real y crece la suite con cada bug nuevo.

Queda un hueco que esto no tapa, y conviene decirlo claro: mi suite solo puede contener consultas que a mí, en algún momento, se me ocurrieron. Los usuarios reales son muchísimo mejores encontrando las que no imaginé. La otra mitad del problema es ver exactamente qué hizo el agente en producción (sus llamadas al modelo, a las tools y los parámetros) y convertir esos fallos reales en nuevos casos de eval.

Eso es justo lo que cubro en la Parte 2, Mi agente ya está en producción. ¿Qué está haciendo ahí dentro?: montar la observabilidad y cerrar el ciclo convirtiendo las trazas de producción en casos de eval. La Parte 3, Ya tengo trazas de mi agente. ¿Y ahora qué?, cuenta qué hago con esas trazas para mejorar el agente día a día. Y la Parte 4, Mi agente es público. ¿Y si alguien intenta manipularlo?, añade la frontera de seguridad alrededor del agente.


El agente del que hablo en este post, el asistente de IA de AWS Announcements Hub con sus tools y su suite de evals, es el que puedes probar en news.playingaws.com.

Este artículo está licenciado bajo CC BY 4.0 por el autor.