1. Concepto central
Tener herramientas no te hace agente. Lo que te hace agente es quién decide el flujo.
Definición operativa que uso en todo el curso:
Un sistema es un agente en la medida en que el LLM decide, en runtime, (a) qué acción ejecutar a continuación, (b) con qué argumentos, y (c) cuándo terminar. Si esas tres decisiones están fijadas en el código, es un pipeline con pasos LLM — por sofisticado que sea.
Con esa vara, un pipeline RAG típico (embed → retrieve → generate) llama a un LLM y usa una “tool” (el retriever), pero el orden está fijo: el LLM nunca decide si buscar, qué buscar de nuevo, ni cuándo la respuesta está completa. Es un pipeline excelente, pero pipeline. La agencia no es binaria — es un continuo de cuántas decisiones de control le cedes al modelo.
En el mundo RPA/BPM/n8n el flujo es el artefacto: se dibuja, se versiona, se audita. En un agente, el flujo es emergente: existe solo como la trayectoria que el modelo eligió en esa ejecución particular. Dos ejecuciones con el mismo input pueden producir trayectorias distintas. Esa inversión de control es el concepto central de este capítulo.
El patrón ReAct (Reasoning + Acting, Yao et al. 2022) es la implementación mínima de esa inversión: un loop donde el modelo alterna Thought (razonamiento sobre qué hacer), Action (llamada a una tool con argumentos) y Observation (el resultado vuelve al contexto), hasta que decide emitir una respuesta final.
El “framework” de un agente ReAct es un while de treinta líneas. Todo lo demás — LangGraph, CrewAI — es andamiaje alrededor de ese while.
2. Por qué importa en producción
Cada decisión que se le cede al modelo es una decisión que se deja de poder garantizar. En banca regulada esto no es filosofía, es arquitectura de riesgo:
- Auditabilidad. En un pipeline, el flujo aprobado por riesgo/compliance es el que se ejecuta, siempre. En un agente, lo auditable es la trayectoria registrada de cada ejecución — el logging de trayectorias pasa a ser infraestructura de primera clase, no un
print. - Costo variable. Un pipeline cuesta N llamadas al LLM, fijo. Un agente cuesta entre 1 y
max_iterationsllamadas, y no se sabe cuánto hasta que corre. - Modos de falla nuevos. Loops infinitos, llamadas a tools que no existen, argumentos malformados, y el más insidioso: el modelo responde de memoria en vez de llamar a la tool (“¿cuál es la tasa vigente?” → alucina una cifra en vez de consultarla).
- El criterio de diseño #1 de la disciplina. Dónde poner la frontera entre control determinista y control del modelo exige saber con precisión quirúrgica dónde vive el control en el código — y este capítulo obliga a saberlo porque el loop se escribe a mano.
3. Ejercicio práctico
Stack: Python puro + Ollama (Qwen 2.5 7B) + pgvector existente. Sin LangChain, sin LangGraph. Un script, no un servicio.
Etapa A — ReAct textual
El modelo emite Thought: ... / Action: nombre_tool / Action Input: {json}, parseado con regex, ejecutado, y devuelto como Observation: .... Este era el ReAct original, pre-tool-calling nativo — y el sufrimiento del parsing es el punto: ahí se entiende por qué existe el tool calling estructurado.
Etapa B — Tool calling nativo
El mismo loop, usando el parámetro tools de la API de chat de Ollama:
import json, time, uuidimport ollama
MODEL = "qwen2.5:7b"MAX_ITERS = 8
SYSTEM = """Eres un asistente de análisis bancario con herramientas.Reglas:- Usa herramientas para obtener datos; nunca inventes cifras.- Si la información no está disponible con tus herramientas, di explícitamente "no puedo responder esto".- Cuando tengas la respuesta final, respóndela sin llamar más herramientas."""
def obtener_transacciones(cliente_id: str, desde: str, hasta: str) -> str: # Consulta parametrizada (NO SQL libre) a una tabla dummy en Postgres. ...
def buscar_documentos(query: str) -> str: # Reutiliza el retriever pgvector existente; devuelve top-3 pasajes. ...
import ast, operator_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.USub: operator.neg}
def calcular(expresion: str) -> str: def ev(n): if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value if isinstance(n, ast.BinOp): return _OPS[type(n.op)](ev(n.left), ev(n.right)) if isinstance(n, ast.UnaryOp): return _OPS[type(n.op)](ev(n.operand)) raise ValueError("expresión no permitida") return str(ev(ast.parse(expresion, mode="eval").body))
TOOLS = { "obtener_transacciones": {"fn": obtener_transacciones, "schema": {"type": "function", "function": { "name": "obtener_transacciones", "description": "Obtiene las transacciones de un cliente en un rango de fechas.", "parameters": {"type": "object", "properties": { "cliente_id": {"type": "string"}, "desde": {"type": "string", "description": "YYYY-MM-DD"}, "hasta": {"type": "string", "description": "YYYY-MM-DD"}}, "required": ["cliente_id", "desde", "hasta"]}}}}, "buscar_documentos": {"fn": buscar_documentos, "schema": {"type": "function", "function": { "name": "buscar_documentos", "description": "Busca en la base documental interna y devuelve pasajes relevantes.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}}}, "calcular": {"fn": calcular, "schema": {"type": "function", "function": { "name": "calcular", "description": "Evalúa una expresión aritmética simple (+ - * / paréntesis).", "parameters": {"type": "object", "properties": {"expresion": {"type": "string"}}, "required": ["expresion"]}}}},}
def run_agent(task: str) -> dict: run_id = str(uuid.uuid4())[:8] messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": task}] trace, llm_calls, t0 = [], 0, time.time()
for step in range(MAX_ITERS): resp = ollama.chat(model=MODEL, messages=messages, tools=[t["schema"] for t in TOOLS.values()], options={"temperature": 0.1}) llm_calls += 1 msg = resp["message"] messages.append(dict(msg)) tool_calls = msg.get("tool_calls") or []
# >>> AQUÍ vive la inversión de control: el MODELO decidió # si el loop continúa (emitió tool_calls) o termina (no emitió). if not tool_calls: trace.append({"step": step, "type": "final", "content": msg.get("content")}) return {"run_id": run_id, "answer": msg.get("content"), "terminated": True, "llm_calls": llm_calls, "seconds": time.time() - t0, "trace": trace}
for call in tool_calls: name = call["function"]["name"] args = call["function"]["arguments"] if name not in TOOLS: result, error_type = f"ERROR: la herramienta '{name}' no existe.", "tool_inexistente" else: try: result, error_type = TOOLS[name]["fn"](**args), None except Exception as e: result, error_type = f"ERROR: {e}", "argumentos_invalidos" trace.append({"step": step, "type": "tool", "name": name, "args": args, "result": str(result)[:500], "error": error_type}) messages.append({"role": "tool", "content": str(result)})
# El modelo NO decidió terminar: lo cortamos nosotros. Esto también es dato. return {"run_id": run_id, "answer": None, "terminated": False, "llm_calls": llm_calls, "seconds": time.time() - t0, "trace": trace}Notas de implementación honestas: (a) la API del cliente Python de Ollama ha cambiado entre versiones — en versiones recientes resp.message.tool_calls es un atributo de objeto, no una key de dict; ajustar según la versión instalada. (b) los errores de tool se devuelven al modelo como Observation en vez de romper el loop — darle al agente la oportunidad de auto-corregirse es una decisión de diseño con costo (más iteraciones) y beneficio (resiliencia) medibles. (c) messages y trace completos se guardan en un JSONL por ejecución — es la evidencia de auditoría embrionaria y el insumo del harness.
El baseline determinista
El contendor del experimento es un pipeline al estilo de lo que ya se haría hoy: clasificación de intención (una sola llamada al LLM con categorías cerradas) + tres rutas fijas.
def pipeline(task: str) -> str: intent = clasificar_intencion(task) # 1 llamada LLM, opciones cerradas if intent == "consulta_documental": return rag_fijo(task) # retrieve → generate if intent == "consulta_transacciones": return ruta_transacciones(task) # extraer params → query → resumir if intent == "calculo": return ruta_calculo(task) return "No puedo procesar esta solicitud."El pipeline codifica supuestos sobre qué tareas llegarán. Las tareas compuestas (“busca la comisión vigente Y calcula cuánto pagó el cliente X”) caen entre las rutas por diseño — no es un bug del baseline, es la limitación estructural que el agente pretende resolver, y el experimento cuantifica si lo logra y a qué precio.
4. Experimento comparativo
Pregunta: ¿en qué categoría de tareas el agente justifica su sobrecosto frente al pipeline, con un modelo local de 7B?
Diseño: 25 tareas en tasks.jsonl, tres categorías — 10 simples (1 paso, dentro de las rutas del pipeline), 10 compuestas (2–3 pasos, requieren combinar tools), y 5 trampa (irresolubles con las tools disponibles; la respuesta correcta es rehusar). Cada tarea lleva verificación automática (contiene / numerico con tolerancia / rechazo).
Métricas por sistema y por categoría: tasa de éxito, llamadas LLM promedio, latencia p50/p95, tasa de no-terminación (hit MAX_ITERS), tool calls malformados o a tools inexistentes, y en trampa: tasa de alucinación.
Hipótesis registradas antes de correr:
- H1 — el pipeline iguala o supera al agente en simples (~95% vs ~80%) a un tercio del costo.
- H2 — el agente supera masivamente al pipeline en compuestas (el pipeline debería estar cerca de 0% por diseño; la pregunta real es si el agente 7B pasa del 50%).
- H3 — en trampa, el pipeline rehúsa mejor; el agente 7B alucinará en al menos 1 de 5.
- H4 — entre 10% y 25% de las iteraciones del agente en Etapa A (textual) tendrán fallos de formato; con tool calling nativo (Etapa B) debería bajar sustancialmente.
Corrida de control: las 10 compuestas contra Qwen 2.5 14B local y Claude Sonnet vía API, para separar “límite de la arquitectura” de “límite del modelo”.
5. Resultados reales
210 ejecuciones corridas (150 de la comparación principal + 60 de la corrida de control). Código, datos sintéticos y trazas completas — repositorio público, enlace próximamente.
Análisis completo, con tabla de resultados, contraste hipótesis-vs-realidad, un trace real citado y los cuatro modos de falla observados: Solté el control de flujo a un LLM de 7B. Esto es lo que midió.
Comparación principal (pipeline vs. agente Etapa B, Qwen 2.5 7B, 25 tareas × 3 repeticiones):
| Categoría | Pipeline | Agente Etapa B |
|---|---|---|
| Simple | 90% | 70% |
| Compuesta | 0% | 0% |
| Trampa | 80% | 100% |
Corrida de control (solo compuestas, mismo agente y tools, cuatro modelos):
| Sistema | Tasa de éxito en compuestas |
|---|---|
| Pipeline determinista | 0% |
| Qwen 2.5 7B (agente) | 0% |
| Qwen 2.5 14B (agente) | 16.7% |
| Claude Sonnet (agente) | 100% |
Contra las hipótesis
- H1 (pipeline gana en simples a un tercio del costo): dirección confirmada (90% vs 70%), pero el costo en llamadas LLM resultó prácticamente idéntico entre ambos (1.70 vs 1.70) — la parte de “costo” no se sostuvo.
- H2 (agente >50% en compuestas): refutada con fuerza. Causa raíz encontrada revisando las
respuestas reales: ni el 7B ni el 14B llamaron a
buscar_documentosni una sola vez en sus 60 ejecuciones combinadas de tareas compuestas, pese a que 7 de las 10 tareas la necesitaban para obtener la política de comisiones vigente. El 7B intenta con las tools que sí usa pero falla la aritmética mental; el 14B directamente se rinde en 1 llamada (“no puedo responder esto… la información no está disponible”) sin intentar la tool que resolvería exactamente eso. Detalle que no vi hasta revisar elSYSTEMprompt del agente: la frase “no puedo responder esto” que usa el 14B para rendirse es verbatim la frase que el system prompt le instruye usar cuando falta información (“di explícitamente ‘no puedo responder esto’”). El modelo no decidió de forma independiente que la tarea era irresoluble — repitió la vía de escape que el prompt le ofreció, sin intentar la herramienta primero. Es la evidencia más directa del confounder de diseño de prompt que motiva la ablación del Capítulo 2 (ver el post de blog para el desarrollo completo de este punto). - H3 (agente aluncina ≥1/5 en trampa): refutada a favor del agente — 0 alucinaciones de 15. El pipeline rechazó correctamente 4 de 5 categorías trampa (el quinto caso reveló un bug real: el código fija el año en 2026 y no lo extrae de la tarea, así que “marzo de 2027” devolvió datos de 2026 sin darse cuenta — se dejó sin corregir a propósito, es el hallazgo).
- H4 (Etapa A falla formato 10-25%, Etapa B menos del 5%): no se corrió Etapa A a escala en este experimento (el diseño final de T21 solo comparó pipeline vs. Etapa B). La única evidencia es anecdótica: en una ejecución manual, Etapa A falló el formato de argumentos 2 de 2 veces, Etapa B acertó 1 de 1. H4 queda pendiente — se resuelve o se retira explícitamente en el Capítulo 2 (ver Parte 0 de ese capítulo), no se deja indefinida. Etapa B tuvo, además, 0 errores de tool inexistente y 0 de argumentos inválidos en las 207 llamadas reales a tools registradas en todo el experimento: 52 del 7B (en sus 75 ejecuciones de las tres categorías), 14 del 14B y 141 de Claude (ambos solo en sus 30 ejecuciones de tareas compuestas — no corrieron simples ni trampa, así que estos números no son directamente comparables en volumen entre sí). El tool calling nativo con schema explícito parece resolver ese modo de falla específico casi por completo, en los tres modelos.
- Hallazgo no anticipado por ninguna hipótesis: la tasa de éxito en compuestas escala de forma monótona con la capacidad del modelo, misma arquitectura de agente exacta — 0% (7B) → 16.7% (14B) → 100% (Claude). Esto separa con datos “el loop ReAct + tool calling no es el cuello de botella” de “el modelo local de 7-14B sí lo es, en esta tarea”.
6. Preguntas de cierre
Respuestas completas, con citas de datos concretos, en el repositorio del proyecto (enlace próximamente). Resumen:
- ¿Dónde vive la decisión de continuar/terminar?
agente_etapa_b.py:61(if not tool_calls:). La toma el modelo en cada llamada — y en las 210 ejecuciones, 0 llegaron aMAX_ITERS, incluso cuando “decidió terminar” prematuramente (rendirse sin intentar una tool disponible). Auditoría:terminated: trueno distingue “resolví la tarea” de “me rendí”. - ¿Es un agente un RAG con orden fijo? No — y los datos lo confirman: convertirlo en agente no habría ayudado en tareas simples (el pipeline de este experimento sacó 90% vs. 70% del agente equivalente, mismo modelo).
- ¿Qué se gana/pierde forzando el orden? Es literalmente pipeline vs. agente en este experimento: se gana fiabilidad en simples y trampa (90%/80% vs. 70%/100%… el agente igual ganó en trampa), se pierde adaptabilidad — y el pipeline forzado no eliminó la alucinación, la trasladó a un bug de código silencioso (el año hardcodeado).
- ¿Qué le falla más al 7B? Ni elegir tool ni formatear argumentos (0 errores en sus 52 llamadas reales, las tres categorías) — falla en saber cuándo detenerse: 0/30 veces intentó
buscar_documentosen compuestas antes de rendirse o alucinar. - ¿Por qué
temperature=0.1no es determinismo? Porque sigue siendo muestreo, no greedy puro — y lo confirma un dato real: la misma tarea (compuesta-06), mismo modelo (14B), misma temperatura, dio 2 respuestas razonables y 1 respuesta degenerada ("sourceMapping"repetido) en 3 repeticiones idénticas. - ¿Alcanza el JSONL para un regulador? Parcialmente — tiene
run_id,model, mensajes y trace completo, pero falta timestamp firmado dentro del JSON (hoy solo hay fecha de archivo), digest inmutable del modelo, e identidad de quién disparó la ejecución. Queda abierto para el Capítulo 8, como estaba previsto.