Capítulo 20 de 22 · avanzado
Un agente de un solo ciclo con herramientas
Qué cubre esta sesión
Allá en la semana 6 escribiste un tool loop y te dije que tenía un nombre por el que la gente cobra dinero. Esta semana cumplimos: un agent. Quítale el marketing y un agent son tres cosas — una meta (el system prompt que dice para qué sirve y cómo comportarse), un conjunto de tools que puede llamar, y un loop acotado que corre lo que sea que el modelo pida hasta que el modelo deja de pedir y produce una respuesta. Eso es todo. Cada framework que evalúes — LangGraph, el OpenAI Agents SDK, CrewAI, lo que salga el próximo trimestre — es un conjunto de comodidades encima de estas tres piezas. Construye las piezas una vez y los frameworks dejan de ser magia.
La razón para construirlo tú mismo no es pureza, es criterio. Cuando un agent se porta mal — hace loop para siempre, llama la tool equivocada, ignora un resultado — necesitas saber exactamente dónde está la falla, y solo puedes saberlo si entiendes el loop. Esta hora es sobre ese entendimiento, y sobre las decisiones de diseño que separan a un agent de demo de uno en el que confiarías: seguridad de las tools, un tope duro de pasos, y resultados que el modelo no puede ignorar.
OpenAI
Las tools primero, porque un agent es tan capaz como sus tools y tan seguro como
la peor de ellas. La calculadora de aquí mete a whitelist los operadores a través
del AST de Python en lugar de llamar eval, así que una tool que el modelo
maneja no puede ser convencida de correr código arbitrario.
# An agent is only as capable as its tools — and only as safe as its worst one.
# The calculator whitelists operators through the AST instead of calling eval(),
# so a tool the model controls can't run arbitrary code.
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}
def _eval(node):
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.BinOp):
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp):
return _OPS[type(node.op)](_eval(node.operand))
raise ValueError("unsupported expression")
def calculate(expression: str) -> dict:
try:
return {"result": _eval(ast.parse(expression, mode="eval").body)}
except Exception as e:
return {"error": str(e)}
_FACTS = {"speed_of_light_m_per_s": 299792458, "earth_radius_km": 6371,
"seconds_per_day": 86400, "moon_distance_km": 384400}
def lookup(key: str) -> dict:
return {"value": _FACTS.get(key), "known_keys": list(_FACTS)}
TOOLS_IMPL = {"calculate": calculate, "lookup": lookup}
Luego el agent en sí: una meta en el system prompt, el conjunto de tools, y el loop acotado. El tope de pasos no es opcional — es la diferencia entre "el agent se confundió" y "el agent se confundió y acumuló una cuenta toda la noche".
# Goal + tools + bounded loop. The system prompt is the agent's identity and
# instructions; the loop executes tool calls and feeds results back until the
# model answers. The step cap is the safety rail — an agent without one is a way
# to spend money in an infinite loop.
GOAL = ("You are a research agent. Use `lookup` to fetch physical constants and "
"`calculate` to do arithmetic. Never do math in your head — use the tool. "
"When you have the answer, state it plainly with units.")
def agent(task: str, max_steps: int = 8) -> str:
input_list = [{"role": "user", "content": task}]
response = None
for _ in range(max_steps):
response = client.responses.create(model=_MODEL, instructions=GOAL, input=input_list, tools=TOOLS)
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
break
input_list += response.output
for call in calls:
result = TOOLS_IMPL[call.name](**json.loads(call.arguments))
print(f" tool: {call.name}({call.arguments}) -> {result}")
input_list.append({"type": "function_call_output", "call_id": call.call_id, "output": json.dumps(result)})
return response.output_text if response else ""
El string de la meta hace más trabajo del que parece. "Never do math in your head — use the tool" está ahí porque los modelos calculan mal con toda confianza; forzar la tool hace la respuesta confiable. Un buen diseño de agent es en gran medida un buen diseño de instrucciones: dile al agent para qué sirve, qué tools preferir, y cuándo parar.
Gemini
La abstracción es agnóstica al proveedor — meta, tools, loop — así que la versión de Gemini solo cambia la plomería de las llamadas a tools. Esa portabilidad es el punto de entender el patrón en lugar de memorizar un SDK.
def agent(task: str, max_steps: int = 8) -> str:
contents = [types.Content(role="user", parts=[types.Part(text=task)])]
response = None
for _ in range(max_steps):
response = client.models.generate_content(model=_MODEL, contents=contents, config=_CONFIG)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for fc in response.function_calls:
result = TOOLS_IMPL[fc.name](**dict(fc.args))
print(f" tool: {fc.name}({dict(fc.args)}) -> {result}")
parts.append(types.Part.from_function_response(name=fc.name, response=result))
contents.append(types.Content(role="user", parts=parts))
return (response.text or "") if response else ""
Dos modos de falla que vigilar cuando construyas agents de verdad. Primero, los loops de tool-call: un agent que sigue llamando la misma tool con los mismos argumentos está atorado, y el tope de pasos es lo que te salva — pero también fíjate en por qué (normalmente una tool devolvió algo que el modelo no pudo usar). Segundo, las tools equivocadas silenciosas: un agent a veces elegirá una tool plausible-pero-equivocada, así que loguea cada llamada en desarrollo. El loop hace poderosos a los agents; la observabilidad los hace debuggeables.
Ponlo a trabajar
Tres apps de docker-compose bajo code/showcase/<slug>/, cada una el mismo loop
con un conjunto de tools y una meta distintos. Misma rutina:
bash bootstrap-secrets.sh, docker compose up --build, http://localhost:3000.
Showcase 1 — Agente de cálculo
Una calculadora y un conversor de unidades, encadenados para resolver problemas de enunciado. Es la demostración más limpia del patrón: el modelo planea la secuencia, las tools aportan los números exactos, y el agent narra el resultado.
Showcase 2 — Agente de KB
Una sola tool de search sobre una base de conocimiento de productos — la
versión agent de RAG. Donde la semana 9 cableó a la fuerza el
retrieve-then-generate, aquí el modelo decide cuándo y qué buscar, a veces más
de una vez. Mismo grounding, más autonomía.
Showcase 3 — Agente planeador
Un agent con memoria: mantiene una lista de tareas, descompone una meta con
add_step, la trabaja con mark_done, y reporta. La lista es estado real por
petición que las tools mutan — la semilla de todo lo que los agents más
sofisticados hacen con scratchpads y memoria de trabajo.
Los tres son meta + tools + loop, acomodados de tres formas. Una vez que lo ves, puedes construir un agent para cualquier cosa para la que tengas tools.
Córrelo
El README de esta carpeta tiene la versión de Python, la línea de instalación,
las dos variables de entorno, y los comandos exactos. Las keys vienen del .env
sin trackear en la raíz del curso. Los scripts básicos imprimen cada llamada a
tool para que veas al agent pensar.
Lo que te llevas
Un agent es una meta, un conjunto de tools, y un loop acotado — y una vez que has
escrito esas tres piezas a mano, cada framework de agents se lee como comodidad,
no como magia. La ingeniería que hace confiable a uno no es el loop, es la
disciplina alrededor: tools que son seguras por construcción (whitelist, no hagas
eval), un tope duro de pasos para que un agent confundido falle barato en lugar
de caro, instrucciones que fuerzan al modelo hacia sus tools en lugar de sus
adivinanzas, y un log de cada llamada para que veas qué hizo realmente. La memoria
es solo estado que tus tools leen y escriben. La próxima semana dejamos que los
agents hablen entre sí — orquestación multiagente.