Lección 6.4.1 · 70 min

Agentes con herramientas: tool calling, ReAct y planificación

Un agente es un bucle: el modelo pide una herramienta, el programa la ejecuta y le devuelve el resultado, hasta que el modelo responde. Esta lección construye ese bucle con tres herramientas deterministas sobre los datos del nivel, lo ejecuta con un LLM local de 0,5B y con las APIs de OpenAI y Anthropic simuladas, y cataloga cómo falla: herramienta equivocada, argumentos inventados, bucles y respuestas que ignoran lo que devolvieron las herramientas.

Fig. — Trazas del bucle de herramientas: cuatro escenarios de un modelo simulado (iguales con las APIs de OpenAI y Anthropic) y tres ejecuciones del LLM local. Círculo: llamada válida; triángulo: error devuelto al modelo; cuadrado: respuesta final; aspa: corte por límite de pasos
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

Un agente es un bucle con herramientas

Un LLM solo produce texto. Para que consulte un precio, busque noticias o haga una cuenta exacta, se le da una lista de herramientas descritas con un nombre, una explicación y un esquema JSON de sus argumentos. Cuando el modelo decide usar una, no la ejecuta: escribe una petición estructurada («llama a precio con ticker=BUSI y fecha=2024-03-15»). El programa que rodea al modelo valida esa petición, ejecuta la función, y le devuelve el resultado como un mensaje más. El modelo lee el resultado y decide si pide otra herramienta o responde. Eso es el tool calling (o function calling), y el bucle completo es un agente.

sínoPreguntaLLM¿Pide herramienta?Validar argumentosEjecutar en códigoObservaciónRespuesta finalVerificar
Fuente Mermaid
flowchart TB
  U[Pregunta] --> M[LLM]
  M --> D{¿Pide herramienta?}
  D -->|sí| V[Validar argumentos]
  V --> E[Ejecutar en código]
  E --> O[Observación]
  O --> M
  D -->|no| R[Respuesta final]
  R --> C[Verificar]

El patrón ReAct (Yao et al., 2022) le dio nombre a esta forma de trabajar: alternar razonamiento (reason) y acción (act), con la observación de cada acción como entrada del siguiente razonamiento. Frente a pedir la respuesta de una vez, ReAct permite que el modelo use datos que no están en sus pesos y que corrija el rumbo con lo que observa. Frente a ReAct está la planificación explícita: primero se decide el plan completo (qué herramientas, en qué orden) y después se ejecuta. Cuando la tarea es conocida, el plan lo puede fijar el programador y el modelo solo rellena los huecos.

Dos ideas guían toda la lección:

  1. El programa manda. Cada llamada pasa por código que valida los argumentos, decide si se ejecuta y registra lo que ocurrió. El modelo propone; el código dispone. La lección 6.4.5 lleva esta idea al límite con un motor de riesgo determinista entre el agente y las órdenes.
  2. Las herramientas deben ser deterministas. Mismo argumento, mismo resultado: así la traza de un agente se puede repetir, auditar y testear, y los fallos son del modelo o del diseño, no de la herramienta.

Tres herramientas deterministas sobre los datos del nivel

Las herramientas trabajan sobre los datos sintéticos del nivel 6: los cierres diarios de las 120 empresas del panel (acciones6_precios.parquet) y el corpus de noticias (noticias6_corpus.parquet). Cada una tiene un modelo Pydantic para sus argumentos (lección 6.2.2): el esquema JSON que verá el modelo sale de ahí, y la validación también.

IN [01]python
import ast
import json
import operator
import re

import numpy as np
import pandas as pd
from pydantic import BaseModel, Field, ValidationError

precios = pd.read_parquet("acciones6_precios.parquet")
noticias = pd.read_parquet("noticias6_corpus.parquet")
TICKERS = set(precios.ticker)
FECHA = r"^\d{4}-\d{2}-\d{2}$"


class ArgsPrecio(BaseModel):
    ticker: str = Field(pattern=r"^[A-Z]{4}$")
    fecha: str = Field(pattern=FECHA)


class ArgsNoticias(BaseModel):
    ticker: str = Field(pattern=r"^[A-Z]{4}$")
    desde: str = Field(pattern=FECHA)
    hasta: str = Field(pattern=FECHA)


class ArgsCalculadora(BaseModel):
    expresion: str


def precio(ticker, fecha):
    if ticker not in TICKERS:
        raise ValueError(f"ticker desconocido: {ticker}")
    s = precios[(precios.ticker == ticker) & (precios.fecha <= fecha)]
    if s.empty:
        raise ValueError(f"sin datos hasta {fecha}")
    r = s.iloc[-1]
    return {"ticker": ticker, "fecha": f"{r.fecha:%Y-%m-%d}",
            "cierre": round(float(r.cierre), 2)}


def buscar_noticias(ticker, desde, hasta):
    if ticker not in TICKERS:
        raise ValueError(f"ticker desconocido: {ticker}")
    ini = pd.Timestamp(desde, tz="UTC")
    fin = pd.Timestamp(hasta, tz="UTC") + pd.Timedelta(days=1)
    x = noticias[(noticias.ticker == ticker) & (noticias.ts >= ini)
                 & (noticias.ts < fin)].drop_duplicates("titular")
    return [{"fecha": f"{t:%Y-%m-%d}", "titular": h}
            for t, h in zip(x.ts[:5], x.titular[:5])]


OPS = {ast.Add: operator.add, ast.Sub: operator.sub,
       ast.Mult: operator.mul, ast.Div: operator.truediv,
       ast.USub: operator.neg}


def _evalua(n):
    if isinstance(n, ast.Constant) and type(n.value) in (int, float):
        return n.value
    if isinstance(n, ast.BinOp) and type(n.op) in OPS:
        return OPS[type(n.op)](_evalua(n.left), _evalua(n.right))
    if isinstance(n, ast.UnaryOp) and type(n.op) in OPS:
        return OPS[type(n.op)](_evalua(n.operand))
    raise ValueError("solo números, + - * / y paréntesis")


def calculadora(expresion):
    """Aritmética sin eval(): se recorre el árbol de la expresión."""
    return round(_evalua(ast.parse(expresion, mode="eval").body), 4)


HERRAMIENTAS = {
    "precio": (precio, ArgsPrecio, "Cierre de una acción en una "
               "sesión (la última sesión en o antes de la fecha)."),
    "buscar_noticias": (buscar_noticias, ArgsNoticias, "Titulares de "
                        "noticias de una acción entre dos fechas "
                        "(AAAA-MM-DD)."),
    "calculadora": (calculadora, ArgsCalculadora, "Evalúa una "
                    "expresión aritmética con + - * / y paréntesis."),
}


def ejecutar(nombre, args):
    """Valida y ejecuta; los errores vuelven como datos, no como
    excepciones, para que el modelo los vea."""
    if nombre not in HERRAMIENTAS:
        return {"error": f"herramienta desconocida: {nombre}"}
    f, Args, _ = HERRAMIENTAS[nombre]
    try:
        a = Args.model_validate(args)
    except ValidationError as e:
        return {"error": "argumentos no válidos: " + "; ".join(
            f"{x['loc'][0]}: {x['msg']}" for x in e.errors())}
    try:
        return {"resultado": f(**a.model_dump())}
    except Exception as e:
        return {"error": str(e)}


print(ejecutar("precio", {"ticker": "BUSI", "fecha": "2024-03-16"}))
print(ejecutar("precio", {"ticker": "BUSI", "fecha": "16/03/2024"}))
print(ejecutar("calculadora", {"expresion": "(27.88/26.19 - 1)*100"}))
print(ejecutar("calculadora",
               {"expresion": "__import__('os').getcwd()"}))
print(ejecutar("buscar_noticias", {"ticker": "BUSI",
               "desde": "2024-03-18", "hasta": "2024-03-20"}))
OUT [01]Herramientas, esquemas y ejecución validada
{'resultado': {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}}
{'error': "argumentos no válidos: fecha: String should match pattern '^\\d{4}-\\d{2}-\\d{2}$'"}
{'resultado': 6.4528}
{'error': 'solo números, + - * / y paréntesis'}
{'resultado': [{'fecha': '2024-03-19', 'titular': 'Un fondo ofrece comprar Busmar Hogar'}]}

Cuatro decisiones de diseño que se repiten en cualquier agente serio:

  • Los errores son datos. Una fecha en formato europeo o un ticker inexistente no lanzan una excepción que tumbe el programa: vuelven como {"error": …}, que el modelo leerá como observación y podrá corregir.
  • Sin eval. La calculadora recorre el árbol sintáctico y solo admite números y cuatro operaciones. Un modelo (o un texto inyectado en una noticia que el modelo lee) no puede ejecutar código por esa vía.
  • Fechas y sesiones explícitas. El 16 de marzo de 2024 es sábado: precio devuelve el cierre del viernes 15 y dice qué fecha usó. Una herramienta que respondiera en silencio con otra sesión sería fuente de errores difíciles de ver.
  • Resultados acotados. buscar_noticias quita las copias sindicadas (mismo titular) y devuelve como mucho cinco titulares. Lo que vuelve de una herramienta entra en el contexto del modelo en cada paso siguiente.

El tool calling por dentro: la plantilla del modelo local

Con un modelo local se ve que el tool calling no es magia: es texto. La plantilla de chat de Qwen2.5-0.5B-Instruct (Apache-2.0, el modelo pequeño del entorno ia; lección 6.2.1) mete los esquemas de las herramientas en el mensaje de sistema entre etiquetas <tools> y pide al modelo que conteste con <tool_call>{"name": …, "arguments": …}</tool_call>. El modelo fue ajustado para seguir ese formato; el programa tiene que extraer las llamadas del texto y comprobar que son JSON válido. La documentación de Qwen lo advierte: no está garantizado que el modelo siga siempre el protocolo, y en producción conviene parsear y validar por cuenta propia.

IN [02]python
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
modelo = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(modelo)
llm = AutoModelForCausalLM.from_pretrained(modelo, dtype=torch.float32)


def esquema(nombres):
    """Herramientas en el formato de funciones de la plantilla."""
    return [{"type": "function", "function": {
        "name": n, "description": HERRAMIENTAS[n][2],
        "parameters": HERRAMIENTAS[n][1].model_json_schema()}}
        for n in nombres]


SISTEMA = ("Eres un asistente de research de renta variable. Usa las "
           "herramientas para obtener datos; no inventes cifras. Cuando "
           "tengas la respuesta, contesta en español en una o dos frases.")
mensajes = [{"role": "system", "content": SISTEMA},
            {"role": "user", "content": "¿Cierre de BUSI el 2024-03-15?"}]
texto = tok.apply_chat_template(mensajes, tools=esquema(["precio"]),
                                add_generation_prompt=True,
                                tokenize=False)
print(texto[texto.index("# Tools"):texto.index("<|im_start|>user")])
OUT [02]Las herramientas dentro del prompt de Qwen
# Tools

You may call one or more functions to assist with the user query.

You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name": "precio", "description": "Cierre de una acción en una sesión (la última sesión en o antes de la fecha).", "parameters": {"properties": {"ticker": {"pattern": "^[A-Z]{4}$", "title": "Ticker", "type": "string"}, "fecha": {"pattern": "^\\d{4}-\\d{2}-\\d{2}$", "title": "Fecha", "type": "string"}}, "required": ["ticker", "fecha"], "title": "ArgsPrecio", "type": "object"}}}
</tools>

For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>

Un bucle ReAct con el LLM local

El bucle es corto: generar, buscar etiquetas <tool_call>, ejecutar cada llamada con ejecutar y devolver el resultado como mensaje con rol tool; si no hay llamadas, esa es la respuesta final. Los guardarraíles mínimos van dentro del bucle:

  • Límite de pasos (max_pasos): un agente que no converge se corta.
  • JSON inválido y herramienta desconocida: vuelven como error.
  • Llamada repetida con los mismos argumentos: no se ejecuta otra vez; se le recuerda al modelo que ya tiene el resultado.
  • Traza: cada llamada, con sus argumentos y su resultado, queda registrada para auditar y verificar.

Dos tareas con su trampa: una variación porcentual (dos precios y una cuenta) y el cierre de un ticker que no existe. Cada paso del modelo pequeño cuesta varios segundos de CPU, así que la lección se queda en dos; la herramienta de noticias se usa en el ejercicio 1.

IN [03]python
LLAMADA = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)


def agente_local(pregunta, nombres, max_pasos=5):
    msgs = [{"role": "system", "content": SISTEMA},
            {"role": "user", "content": pregunta}]
    traza, vistos = [], set()
    for paso in range(1, max_pasos + 1):
        e = tok.apply_chat_template(msgs, tools=esquema(nombres),
                                    add_generation_prompt=True,
                                    return_tensors="pt",
                                    return_dict=True)
        with torch.inference_mode():
            out = llm.generate(**e, max_new_tokens=80, do_sample=False,
                               pad_token_id=tok.eos_token_id)
        n = e["input_ids"].shape[1]
        txt = tok.decode(out[0, n:], skip_special_tokens=True).strip()
        llamadas = LLAMADA.findall(txt)
        print(f"  paso {paso} ({n} tokens de entrada)")
        if not llamadas:
            return txt, traza
        msgs.append({"role": "assistant", "content": txt})
        for crudo in llamadas:
            try:
                c = json.loads(crudo)
                nombre, args = c["name"], c.get("arguments", {})
            except (json.JSONDecodeError, KeyError, TypeError):
                nombre, args = None, None
            clave = json.dumps([nombre, args], sort_keys=True)
            if nombre is None:
                r = {"error": "llamada con JSON no válido"}
            elif clave in vistos:
                r = {"error": "llamada repetida: ya tienes el resultado"}
            else:
                r = ejecutar(nombre, args)
            vistos.add(clave)
            traza.append((nombre, args, r))
            print("    ", nombre, args, "=>",
                  json.dumps(r, ensure_ascii=False)[:70])
            msgs.append({"role": "tool",
                         "content": json.dumps(r, ensure_ascii=False)})
    return None, traza


BASICAS = ["precio", "buscar_noticias", "calculadora"]
TAREAS = [
    "¿Cuánto cambió en porcentaje el cierre de BUSI entre el "
    "2024-03-15 y el 2024-03-22?",
    "¿Cuál fue el cierre de ACME el 2024-03-15?"]
resultados = []
for pregunta in TAREAS:
    print(pregunta)
    final, traza = agente_local(pregunta, BASICAS)
    resultados.append((pregunta, final, traza))
    print("  RESPUESTA:", final)
OUT [03]Bucle ReAct con Qwen2.5-0.5B: dos tareas
¿Cuánto cambió en porcentaje el cierre de BUSI entre el 2024-03-15 y el 2024-03-22?
  paso 1 (567 tokens de entrada)
     precio {'ticker': 'BUSI', 'fecha': '2024-03-15'} => {"resultado": {"ticker": "BUSI", "fecha": "2024-03-15", "cierre": 26.1
     precio {'ticker': 'BUSI', 'fecha': '2024-03-22'} => {"resultado": {"ticker": "BUSI", "fecha": "2024-03-22", "cierre": 27.8
  paso 2 (734 tokens de entrada)
  RESPUESTA: El cambio en el cierre del producto BUSI entre el 2024-03-15 y el 2024-03-22 fue de 27.88%.
¿Cuál fue el cierre de ACME el 2024-03-15?
  paso 1 (548 tokens de entrada)
     precio {'ticker': 'ACME', 'fecha': '2024-03-15'} => {"error": "ticker desconocido: ACME"}
  paso 2 (612 tokens de entrada)
  RESPUESTA: El cierre del ticker ACME el 2024-03-15 fue de $10.

En la primera tarea el modelo hace bien lo que parecía difícil: pide los dos cierres en el mismo turno (dos <tool_call> seguidos), con el ticker y las fechas correctos, y recibe 26,19 y 27,88. Y falla en lo que parecía fácil: no llama a la calculadora y responde que el cambio «fue de 27.88%», el segundo cierre convertido en porcentaje. La variación real es del 6,45 %. En la segunda, el ticker no existe y la herramienta lo dice; el modelo recibe el error y responde que el cierre «fue de $10». Ningún guardarraíl del bucle salta: las llamadas eran válidas, no hubo repeticiones y sobraron pasos. Los tokens de entrada crecen en cada paso (de 567 a 734 en la primera tarea) porque el modelo vuelve a leer toda la conversación, resultados incluidos.

Verificar la respuesta final contra la traza

Los guardarraíles del bucle no ven el error más peligroso, porque está en la respuesta final y no en una llamada. La respuesta se puede verificar contra la traza con dos reglas sencillas, deterministas y baratas:

  1. Toda cifra de la respuesta tiene que aparecer en algún resultado correcto de una herramienta (o en la pregunta).
  2. Todo porcentaje tiene que salir de una herramienta que calcula (aquí, la calculadora), no de la cabeza del modelo.

Y una tercera, de proceso: si una herramienta devolvió un error y nada lo resolvió después, la respuesta no puede dar el dato que esa herramienta debía traer.

IN [04]python
NUM = re.compile(r"\d[\d.,]*\d|\d")


def cifras(s):
    out = []
    for t in NUM.findall(s or ""):
        if "," in t:
            t = t.replace(".", "").replace(",", ".")
        elif re.fullmatch(r"\d{1,3}(\.\d{3})+", t):
            t = t.replace(".", "")
        out.append(float(t.rstrip(".")))
    return out


def cerca(x, ys):
    return any(abs(x - y) <= 0.006 * max(1.0, abs(y)) for y in ys)


def verificar(pregunta, final, traza):
    if final is None:
        return ["sin respuesta: límite de pasos"]
    vistas, calculadas, errores = [], [], []
    for nombre, args, r in traza:
        if "resultado" in r:
            vistas += cifras(json.dumps(r["resultado"]))
            if nombre in ("calculadora", "variacion"):
                calculadas += cifras(json.dumps(r["resultado"]))
        else:
            errores.append(nombre)
    fallos = []
    for x in cifras(final):
        if not cerca(x, vistas + cifras(pregunta)):
            fallos.append(f"cifra sin fuente: {x:g}")
    for p in re.findall(r"(\d[\d.,]*)\s*%", final):
        if not cerca(cifras(p)[0], calculadas):
            fallos.append(f"porcentaje sin cálculo: {p}%")
    if errores and not vistas:
        fallos.append("error de herramienta sin resolver")
    return fallos


for pregunta, final, traza in resultados:
    print(pregunta[:45], "|", verificar(pregunta, final, traza) or "OK")
OUT [04]Verificador de la respuesta final
¿Cuánto cambió en porcentaje el cierre de BUS | ['porcentaje sin cálculo: 27.88%']
¿Cuál fue el cierre de ACME el 2024-03-15? | ['cifra sin fuente: 10', 'error de herramienta sin resolver']

El verificador rechaza las dos respuestas: el 27,88 % no sale de ningún cálculo, y el 10 no sale de ningún resultado y además quedó un error sin resolver. Qué se hace con una respuesta rechazada depende del uso: devolver el motivo al modelo como una observación más y darle otro intento, contestar «no se ha podido verificar» o pasar el caso a una persona. Lo que no se hace es enseñarla como buena. Las reglas son pocas y burdas (una cifra puede estar en la traza y aun así estar mal usada), pero cuestan microsegundos y cazan los dos fallos más caros: cifras inventadas y cuentas no hechas.

Planificar en vez de improvisar

Cuando la tarea es conocida («variación de un precio entre dos fechas»), no hace falta que el modelo descubra el plan en cada ejecución. Hay dos formas de fijarlo:

  • Una herramienta de más alto nivel. variacion(ticker, desde, hasta) hace las dos consultas y la cuenta en código. El modelo solo tiene que extraer tres parámetros de la pregunta y transcribir el resultado.
  • Planificar y ejecutar. El programa tiene el plan (precio inicial, precio final, cuenta, informe) y el modelo solo rellena los parámetros, o solo redacta al final. Es el patrón plan-and-execute: menos libertad, menos fallos, trazas más predecibles.
PreguntaLLM extrae datosValidar esquemaCálculo en códigoLLM redactaVerificar
Fuente Mermaid
flowchart TB
  P[Pregunta] --> E[LLM extrae datos]
  E --> V[Validar esquema]
  V --> H[Cálculo en código]
  H --> R[LLM redacta]
  R --> C[Verificar]
IN [05]python
class ArgsVariacion(BaseModel):
    ticker: str = Field(pattern=r"^[A-Z]{4}$")
    desde: str = Field(pattern=FECHA)
    hasta: str = Field(pattern=FECHA)


def variacion(ticker, desde, hasta):
    a, b = precio(ticker, desde), precio(ticker, hasta)
    return {"desde": a, "hasta": b, "variacion_pct":
            round(100 * (b["cierre"] / a["cierre"] - 1), 2)}


HERRAMIENTAS["variacion"] = (
    variacion, ArgsVariacion, "Variación porcentual del cierre de una "
    "acción entre dos fechas (AAAA-MM-DD), calculada con los dos "
    "cierres.")
final, traza = agente_local(TAREAS[0], ["variacion", "buscar_noticias"])
print("  RESPUESTA:", final)
print("  verificación:", verificar(TAREAS[0], final, traza) or "OK")
OUT [05]La misma pregunta con una herramienta que calcula
  paso 1 (533 tokens de entrada)
     variacion {'ticker': 'BUSI', 'desde': '2024-03-15', 'hasta': '2024-03-22'} => {"resultado": {"desde": {"ticker": "BUSI", "fecha": "2024-03-15", "cie
  paso 2 (691 tokens de entrada)
  RESPUESTA: El cambio en el cierre de la acción BUSI entre el 2024-03-15 y el 2024-03-22 fue de 6.45%.
  verificación: OK

Con variacion el modelo hace una sola llamada con los tres parámetros correctos, recibe la variación calculada en código y la transcribe: 6,45 %, y el verificador la da por buena. Es el mismo modelo con el mismo prompt de sistema; lo único que ha cambiado es que ya no tiene que encadenar tres llamadas ni hacer una cuenta. En un agente, el diseño de las herramientas pesa tanto como el modelo: cuanto más alto el nivel de la herramienta, menos decisiones quedan en manos del modelo y menos formas hay de fallar. El precio es la flexibilidad: variacion no sirve para preguntas que no se previeron, y para esas sigue haciendo falta un bucle ReAct (con su verificador).

Las APIs comerciales: mismas ideas, otra forma

Las APIs de OpenAI y Anthropic hacen lo mismo con mensajes estructurados en lugar de etiquetas en el texto:

OpenAI (Responses API)Anthropic (Messages API)
Definir una herramienta{"type": "function", "name", "description", "parameters"}{"name", "description", "input_schema"}
El modelo pide una llamadaelemento {"type": "function_call", "call_id", "name", "arguments"} (argumentos como texto JSON)bloque {"type": "tool_use", "id", "name", "input"} (argumentos como objeto) y stop_reason: "tool_use"
Devolver el resultado{"type": "function_call_output", "call_id", "output"} en inputbloque {"type": "tool_result", "tool_use_id", "content"} en un mensaje de usuario (is_error para errores)
Varias llamadas a la vezposibles (parallel_tool_calls)posibles; todos los resultados en un solo mensaje

Formas según la documentación de cada proveedor, consultada el 2026-10-231Nota 1 · OpenAI, «Function calling» (Responses API), y Anthropic, «Tool use with Claude», consultadas el 2026-10-23. Las dos documentaciones advierten, cada una a su manera, de que el modelo puede pedir varias llamadas en un mismo turno; OpenAI permite desactivarlo con parallel_tool_calls y Anthropic con disable_parallel_tool_use.. El código real, que no se ejecuta aquí porque necesita claves y red:

IN [06]python
from openai import OpenAI

cliente = OpenAI()                      # OPENAI_API_KEY del entorno
herramientas = [{"type": "function", "name": n,
                 "description": HERRAMIENTAS[n][2],
                 "parameters": HERRAMIENTAS[n][1].model_json_schema()}
                for n in BASICAS]
entrada = [{"role": "user", "content": TAREAS[0]}]
r = cliente.responses.create(model="gpt-5", input=entrada,
                             tools=herramientas)
for item in r.output:
    if item.type == "function_call":
        res = ejecutar(item.name, json.loads(item.arguments))
        entrada += [item, {"type": "function_call_output",
                           "call_id": item.call_id,
                           "output": json.dumps(res)}]
IN [07]python
import anthropic

cliente = anthropic.Anthropic()         # ANTHROPIC_API_KEY del entorno
herramientas = [{"name": n, "description": HERRAMIENTAS[n][2],
                 "input_schema": HERRAMIENTAS[n][1].model_json_schema()}
                for n in BASICAS]
mensajes = [{"role": "user", "content": TAREAS[0]}]
r = cliente.messages.create(model="claude-opus-5-5", max_tokens=1024,
                            tools=herramientas, messages=mensajes)
if r.stop_reason == "tool_use":
    resultados = [{"type": "tool_result", "tool_use_id": b.id,
                   "content": json.dumps(ejecutar(b.name, b.input))}
                  for b in r.content if b.type == "tool_use"]
    mensajes += [{"role": "assistant", "content": r.content},
                 {"role": "user", "content": resultados}]

Para ejecutar ese mismo código sin red, los clientes oficiales se apuntan a simuladores locales: un MockTransport (de httpx para el SDK de OpenAI y de httpx2 para el de Anthropic 2.x) que responde con la forma documentada. Detrás del simulador no hay un modelo sino un guion: una política fija que decide la siguiente acción mirando cuántos resultados de herramientas hay ya en la conversación. Así se pueden provocar a voluntad los fallos típicos y comprobar que el bucle los contiene:

EscenarioQué hace el «modelo» simulado
correctoPide los dos precios a la vez, después la calculadora y responde con el porcentaje calculado
herramienta_equivocadaBusca noticias en lugar de precios y responde sin cifra
argumentos_inventadosPide el precio de «BUSMAR» con fecha «15/03/2024»; tras el error de validación, corrige y sigue como el correcto
buclePide una y otra vez el mismo precio, aunque el bucle le avisa de que ya lo tiene
IN [08]python
import anthropic
import httpx
import httpx2
import openai
import tiktoken

PREGUNTA = TAREAS[0]
DESDE, HASTA = "2024-03-15", "2024-03-22"


def guion(escenario, resultados):
    """Siguiente acción del «modelo» simulado: lista de llamadas
    (nombre, args) o el texto final. `resultados` = salidas de
    herramientas recibidas hasta ahora (diccionarios)."""
    n = len(resultados)
    if escenario == "bucle":
        return [("precio", {"ticker": "BUSI", "fecha": DESDE})]
    if escenario == "herramienta_equivocada":
        if n == 0:
            return [("buscar_noticias", {"ticker": "BUSI",
                     "desde": DESDE, "hasta": HASTA})]
        return "BUSI subió por la oferta de compra de un fondo."
    if escenario == "argumentos_inventados":
        if n == 0:
            return [("precio", {"ticker": "BUSMAR",
                                "fecha": "15/03/2024"})]
        resultados = resultados[1:]
        n -= 1
    if n == 0:
        return [("precio", {"ticker": "BUSI", "fecha": DESDE}),
                ("precio", {"ticker": "BUSI", "fecha": HASTA})]
    if n == 2:
        a, b = (r["resultado"]["cierre"] for r in resultados[:2])
        return [("calculadora", {"expresion": f"({b}/{a} - 1)*100"})]
    v = resultados[2]["resultado"]
    return f"El cierre de BUSI varió un {v:.2f} % entre {DESDE} y {HASTA}."


ENC = tiktoken.get_encoding("o200k_base")
REGISTRO = []                      # tokens de cada petición recibida


def sim_openai(request):
    cuerpo = json.loads(request.content)
    REGISTRO.append(len(ENC.encode(request.content.decode())))
    res = [json.loads(x["output"]) for x in cuerpo["input"]
           if isinstance(x, dict) and x.get("type") == "function_call_output"]
    accion = guion(cuerpo["model"], res)
    if isinstance(accion, str):
        salida = [{"type": "message", "id": "msg_1", "role": "assistant",
                   "status": "completed", "content": [
                       {"type": "output_text", "text": accion,
                        "annotations": []}]}]
    else:
        salida = [{"type": "function_call", "id": f"fc_{len(res)}_{i}",
                   "call_id": f"call_{len(res)}_{i}", "name": nom,
                   "arguments": json.dumps(args), "status": "completed"}
                  for i, (nom, args) in enumerate(accion)]
    return httpx.Response(200, json={
        "id": "resp_sim", "object": "response", "created_at": 0,
        "status": "completed", "model": cuerpo["model"],
        "output": salida, "parallel_tool_calls": True,
        "tool_choice": "auto", "tools": []})


def sim_anthropic(request):
    cuerpo = json.loads(request.content)
    REGISTRO.append(len(ENC.encode(request.content.decode())))
    res = [json.loads(b["content"]) for m in cuerpo["messages"]
           if m["role"] == "user" and isinstance(m["content"], list)
           for b in m["content"] if b.get("type") == "tool_result"]
    accion = guion(cuerpo["model"], res)
    if isinstance(accion, str):
        contenido, parada = [{"type": "text", "text": accion}], "end_turn"
    else:
        contenido = [{"type": "tool_use", "id": f"toolu_{len(res)}_{i}",
                      "name": nom, "input": args}
                     for i, (nom, args) in enumerate(accion)]
        parada = "tool_use"
    return httpx2.Response(200, json={
        "id": "msg_sim", "type": "message", "role": "assistant",
        "model": cuerpo["model"], "content": contenido,
        "stop_reason": parada, "stop_sequence": None,
        "usage": {"input_tokens": 0, "output_tokens": 0}})


oai = openai.OpenAI(
    api_key="sk-simulado", base_url="http://simulado/v1",
    http_client=httpx.Client(transport=httpx.MockTransport(sim_openai)))
ant = anthropic.Anthropic(
    api_key="sk-simulado", base_url="http://simulado",
    http_client=httpx2.Client(
        transport=httpx2.MockTransport(sim_anthropic)))
r = oai.responses.create(model="correcto", input=PREGUNTA)
print([(o.type, o.name, o.arguments) for o in r.output])
m = ant.messages.create(model="correcto", max_tokens=200,
                        messages=[{"role": "user", "content": PREGUNTA}])
print(m.stop_reason, [(b.type, b.name, b.input) for b in m.content])
OUT [08]Un guion de modelo y los dos simuladores (OpenAI y Anthropic)
[('function_call', 'precio', '{"ticker": "BUSI", "fecha": "2024-03-15"}'), ('function_call', 'precio', '{"ticker": "BUSI", "fecha": "2024-03-22"}')]
tool_use [('tool_use', 'precio', {'ticker': 'BUSI', 'fecha': '2024-03-15'}), ('tool_use', 'precio', {'ticker': 'BUSI', 'fecha': '2024-03-22'})]

El bucle es uno solo, con un pequeño adaptador por proveedor: cómo se piden las llamadas, cómo se leen y cómo se devuelven los resultados. Los guardarraíles son los mismos del agente local, más un límite de tokens enviados.

IN [09]python
def ejecutar_seguro(nombre, args, vistos):
    clave = json.dumps([nombre, args], sort_keys=True)
    if clave in vistos:
        return {"error": "llamada repetida: ya tienes el resultado"}
    vistos.add(clave)
    return ejecutar(nombre, args)


def agente_api(proveedor, escenario, max_pasos=6):
    REGISTRO.clear()
    historial = [{"role": "user", "content": PREGUNTA}]
    vistos, eventos = set(), []
    for paso in range(1, max_pasos + 1):
        if proveedor == "openai":
            r = oai.responses.create(model=escenario, input=historial)
            calls = [(o.call_id, o.name, json.loads(o.arguments))
                     for o in r.output if o.type == "function_call"]
            final = r.output_text
            historial += [o.model_dump(exclude_none=True)
                          for o in r.output]
        else:
            r = ant.messages.create(model=escenario, max_tokens=500,
                                    messages=historial)
            calls = [(b.id, b.name, b.input) for b in r.content
                     if b.type == "tool_use"]
            final = "".join(b.text for b in r.content
                            if b.type == "text")
            historial.append({"role": "assistant", "content": [
                b.model_dump(exclude_none=True) for b in r.content]})
        if not calls:
            eventos.append((paso, "respuesta"))
            return final, eventos, list(REGISTRO)
        bloques = []
        for cid, nombre, args in calls:
            res = ejecutar_seguro(nombre, args, vistos)
            tipo = "error" if "error" in res else "ok"
            eventos.append((paso, f"{nombre}:{tipo}"))
            if proveedor == "openai":
                historial.append({"type": "function_call_output",
                                  "call_id": cid,
                                  "output": json.dumps(res)})
            else:
                bloques.append({"type": "tool_result",
                                "tool_use_id": cid,
                                "content": json.dumps(res),
                                "is_error": "error" in res})
        if bloques:
            historial.append({"role": "user", "content": bloques})
    eventos.append((max_pasos, "corte"))
    return None, eventos, list(REGISTRO)


ESCENARIOS = ["correcto", "herramienta_equivocada",
              "argumentos_inventados", "bucle"]
for esc in ESCENARIOS:
    for prov in ("openai", "anthropic"):
        final, ev, tokens = agente_api(prov, esc)
        print(f"{esc:22s} {prov:9s} pasos={ev[-1][0]}"
              f" llamadas={sum(':' in e for _, e in ev)}"
              f" errores={sum(e.endswith('error') for _, e in ev)}"
              f" tokens={sum(tokens):5d} | {final}")
OUT [09]Un bucle, dos proveedores, cuatro escenarios
correcto               openai    pasos=3 llamadas=3 errores=0 tokens=  630 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
correcto               anthropic pasos=3 llamadas=3 errores=0 tokens=  643 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
herramienta_equivocada openai    pasos=2 llamadas=1 errores=0 tokens=  221 | BUSI subió por la oferta de compra de un fondo.
herramienta_equivocada anthropic pasos=2 llamadas=1 errores=0 tokens=  235 | BUSI subió por la oferta de compra de un fondo.
argumentos_inventados  openai    pasos=4 llamadas=4 errores=1 tokens= 1065 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
argumentos_inventados  anthropic pasos=4 llamadas=4 errores=1 tokens= 1101 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
bucle                  openai    pasos=6 llamadas=6 errores=5 tokens= 1643 | None
bucle                  anthropic pasos=6 llamadas=6 errores=5 tokens= 1763 | None

Los dos proveedores dan exactamente la misma traza, porque el guion es el mismo y el bucle solo cambia en la forma de los mensajes. Por escenarios:

  • Correcto: tres peticiones (dos precios en paralelo, la calculadora, la respuesta) y un 6,45 % calculado.
  • Herramienta equivocada: busca noticias, recibe el titular de la oferta y responde algo plausible («subió por la oferta de compra de un fondo») que no contesta a la pregunta: no hay porcentaje. El bucle no tiene forma de saberlo; una regla más del verificador (si la pregunta pide un porcentaje, la respuesta tiene que darlo, calculado) lo marcaría.
  • Argumentos inventados: el ticker «BUSMAR» y la fecha «15/03/2024» no pasan la validación; el error vuelve como observación, el «modelo» corrige y el agente termina bien en cuatro pasos. Un modelo capaz suele hacer esto; uno pequeño, no siempre.
  • Bucle: la primera llamada funciona y las cinco siguientes son repeticiones que el bucle no ejecuta. El límite de seis pasos lo corta sin respuesta, que es el resultado correcto: mejor nada que algo sin verificar.
IN [10]python
final, ev, tokens_bucle = agente_api("anthropic", "bucle", max_pasos=30)
_, _, tokens_ok = agente_api("anthropic", "correcto")
acum = np.cumsum(tokens_bucle)
print("tokens por petición, pasos 1-6:", tokens_bucle[:6])
print("pasos 25-30:", tokens_bucle[24:30])
print("acumulado tras 6, 12 y 30 pasos:", acum[5], acum[11], acum[29])
print("caso correcto, total:", sum(tokens_ok), "en", len(tokens_ok),
      "peticiones")
OUT [10]Sin límite de pasos: lo que cuesta un bucle
tokens por petición, pasos 1-6: [53, 160, 251, 342, 433, 524]
pasos 25-30: [2253, 2344, 2435, 2526, 2617, 2708]
acumulado tras 6, 12 y 30 pasos: 1763 6818 41639
caso correcto, total: 643 en 3 peticiones
Fig. — Tokens enviados al simulador de la API de Anthropic, acumulados por paso, en el escenario de bucle sin límite de pasos: cada petición reenvía toda la conversación

Cada petición reenvía la conversación entera, así que lo que se envía por paso crece linealmente: 53 tokens la primera, 524 la sexta, 2.708 la trigésima. El total crece con el cuadrado de los pasos: 1.763 tokens con el límite de seis, 6.818 con doce y 41.639 con treinta, frente a 643 del caso correcto. En una API que cobra por token, un agente sin límite de pasos es un gasto sin techo; con un modelo local, minutos de CPU. Por eso el límite de pasos es también un límite de presupuesto, y conviene complementarlo con uno explícito de tokens.

Errores típicos

  • Ejecutar sin validar. Los argumentos de una llamada son texto generado: un ticker inventado, una fecha en otro formato, una expresión con código. Siempre esquema, validación y, en herramientas con efectos (órdenes, correos, escrituras), aprobación fuera del modelo (6.4.5).
  • Herramientas con efectos o no deterministas. Una herramienta que lee la hora real, una web viva o un estado mutable hace que la misma traza no se repita y que no se pueda testear. Primero deterministas; lo demás, detrás de una capa que registra y fija los datos.
  • Excepciones en vez de errores como datos. Si una herramienta lanza una excepción, el agente muere y el modelo no aprende nada; si devuelve el error, el modelo puede corregir y el registro lo cuenta.
  • Sin límite de pasos ni de gasto. Un bucle no converge solo. Cada paso reenvía toda la conversación: el coste es cuadrático en el número de pasos.
  • No verificar la respuesta final. Un modelo puede pedir los datos correctos y después redactar otra cosa. Se comprueba la respuesta contra la traza, con reglas deterministas.
  • Darle al modelo la aritmética. Cuentas, conversiones y fechas, en herramientas o en código. El modelo extrae y redacta.
  • Dejar que improvise lo que es un procedimiento. Si la tarea es conocida, un plan fijo (o una herramienta de alto nivel) es más fiable, más barato y más fácil de auditar que un ReAct libre.
  • Confiar en lo que devuelven las herramientas como instrucciones. Un titular o un documento pueden contener texto que parezca una orden («ignora lo anterior y…»): es una inyección de instrucciones. Lo que vuelve de una herramienta es dato, nunca instrucción (6.4.5).

Ejercicios

  1. Añade al verificador una regla para preguntas de noticias: todo titular entrecomillado en la respuesta tiene que aparecer literalmente en algún resultado de buscar_noticias. Pruébala con la traza de una búsqueda de noticias de BUSI entre el 18 y el 20 de marzo de 2024 y dos respuestas escritas a mano, una fiel y otra inventada.
Solución
IN [11]python
def titulares_ok(final, traza):
    reales = {n["titular"] for nom, _, r in traza
              if nom == "buscar_noticias" and "resultado" in r
              for n in r["resultado"]}
    citados = re.findall(r"[«\"“]([^»\"”]+)[»\"”]", final or "")
    return [c for c in citados if c not in reales]


args = {"ticker": "BUSI", "desde": "2024-03-18", "hasta": "2024-03-20"}
traza = [("buscar_noticias", args, ejecutar("buscar_noticias", args))]
fiel = 'El 19 de marzo: «Un fondo ofrece comprar Busmar Hogar».'
falsa = 'Hubo una noticia: "Busmar Hogar dispara su beneficio un 40 %".'
print("fiel:", titulares_ok(fiel, traza) or "OK")
print("inventada:", titulares_ok(falsa, traza))
OUT [11]Titulares citados que existen
fiel: OK
inventada: ['Busmar Hogar dispara su beneficio un 40 %']

La regla deja pasar el titular real y marca el inventado. Es una comprobación literal: una paráfrasis honesta de un titular real («un fondo lanza una oferta por Busmar») también la suspendería. Una comparación aproximada (por palabras o por embeddings, lección 6.3.1) tolera la paráfrasis a cambio de dejar pasar más errores; en un informe que va a clientes, lo prudente es exigir la cita literal.

  1. Con el guion argumentos_inventados, ¿qué pasaría si ejecutar no validara el formato de la fecha? Llama a precio directamente con «15/03/2024» y con «01/03/2024» (1 de marzo, a la española) y compara con las fechas ISO.
Solución
IN [12]python
for f in ["15/03/2024", "2024-03-15", "01/03/2024", "2024-03-01"]:
    print(f, precio("BUSI", f))
OUT [12]Una fecha mal formada sin validación
15/03/2024 {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}
2024-03-15 {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}
01/03/2024 {'ticker': 'BUSI', 'fecha': '2024-01-03', 'cierre': 27.75}
2024-03-01 {'ticker': 'BUSI', 'fecha': '2024-03-01', 'cierre': 25.2}

Sin validación, «15/03/2024» funciona por casualidad: pandas reconoce el formato y lo interpreta bien. Pero «01/03/2024», el 1 de marzo a la española, se lee con el mes primero como 3 de enero, y la herramienta devuelve sin ningún error el cierre de otra fecha (27,75 en lugar de 25,20). Un fallo silencioso es peor que uno ruidoso: por eso el esquema exige AAAA-MM-DD, la validación rechaza lo demás y el resultado dice qué fecha se usó.

  1. Diseña, sin código, las herramientas de un agente que prepara un resumen diario de una cartera: qué herramientas le darías, cuáles no, y qué haría el código en lugar del modelo.
Solución

Herramientas de solo lectura, deterministas para una fecha dada: posiciones de la cartera a la fecha, cierres y rentabilidades del día (calculadas en código), noticias del día por ticker con la hora de publicación (para separar lo de antes y lo de después del cierre), y eventos del calendario (resultados, dividendos). Todas con la fecha como argumento obligatorio, para poder reproducir el informe de cualquier día y para no mezclar información posterior. No le daría nada que escriba o envíe órdenes: el agente redacta, no opera (6.4.5). El código haría las cifras (P&L por posición, contribuciones, variaciones), la selección de qué entra en el informe (umbrales de movimiento, materialidad) y la verificación final: toda cifra del texto en la traza, todo titular citado existente. El modelo solo redacta el resumen a partir de una tabla ya calculada y, como mucho, elige qué noticias explicar. Con ese diseño, un modelo pequeño puede hacer el trabajo; con un ReAct libre sobre herramientas de bajo nivel, ni uno grande es fiable cada día.

Footnotes

  1. OpenAI, «Function calling» (Responses API), y Anthropic, «Tool use with Claude», consultadas el 2026-10-23. Las dos documentaciones advierten, cada una a su manera, de que el modelo puede pedir varias llamadas en un mismo turno; OpenAI permite desactivarlo con parallel_tool_calls y Anthropic con disable_parallel_tool_use. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. En una API con tool calling, ¿quién ejecuta la función precio(ticker, fecha)?

    • AEl modelo, en los servidores del proveedor
    • BEl programa que llama a la API: el modelo solo devuelve el nombre de la herramienta y los argumentos en JSON, y espera el resultado en el siguiente mensaje
    • CNadie: el modelo se inventa el resultado
    • DEl navegador del usuario
    Ver respuesta

    Respuesta B. El programa que llama a la API: el modelo solo devuelve el nombre de la herramienta y los argumentos en JSON, y espera el resultado en el siguiente mensaje

    En las herramientas definidas por el cliente, la respuesta del modelo trae un bloque tool_use (Anthropic) o un elemento function_call (OpenAI) con nombre y argumentos. El programa la ejecuta y devuelve un tool_result o un function_call_output. Por eso el programa puede validar, limitar y auditar cada llamada.

  2. El LLM local pide los dos cierres correctamente (26,19 y 27,88) y responde que el cambio «fue de 27.88%». ¿Qué guardarraíl lo detecta?

    • AEl límite de pasos
    • BLa validación Pydantic de los argumentos
    • CLa detección de llamadas repetidas
    • DUn verificador de la respuesta final que exige que todo porcentaje salga de una llamada a la calculadora (o a una herramienta que calcula) y toda cifra, de algún resultado
    Ver respuesta

    Respuesta D. Un verificador de la respuesta final que exige que todo porcentaje salga de una llamada a la calculadora (o a una herramienta que calcula) y toda cifra, de algún resultado

    Los argumentos eran válidos y no hubo bucle: el error está en la respuesta final, que copia un cierre como si fuera el porcentaje. Solo una comprobación de la respuesta contra la traza de herramientas lo detecta.

  3. ¿Para qué sirve devolver al modelo un error de validación («fecha: String should match pattern…») en lugar de cortar el agente?

    • APara que el modelo pueda corregir los argumentos en el paso siguiente, como hace el escenario simulado de argumentos inventados
    • BPara gastar menos tokens
    • CPorque las APIs no permiten cortar un agente
    • DNo sirve de nada: el modelo nunca corrige
    Ver respuesta

    Respuesta A. Para que el modelo pueda corregir los argumentos en el paso siguiente, como hace el escenario simulado de argumentos inventados

    Un error informativo es una observación más del bucle ReAct. Los modelos capaces suelen corregir el formato al verlo; uno pequeño quizá no, y por eso el error se cuenta y hay un límite de pasos. Lo que no se hace nunca es ejecutar la herramienta con argumentos no validados.

  4. En el escenario de bucle, sin límite de pasos, el agente repite la misma llamada. ¿Por qué el coste crece más deprisa que el número de pasos?

    • APorque cada llamada repetida cuesta el doble
    • BPorque la herramienta tarda más cada vez
    • CPorque cada petición reenvía toda la conversación anterior: los tokens enviados por paso crecen linealmente y el total, de forma cuadrática
    • DNo crece: el coste es constante
    Ver respuesta

    Respuesta C. Porque cada petición reenvía toda la conversación anterior: los tokens enviados por paso crecen linealmente y el total, de forma cuadrática

    Las APIs son sin estado: cada paso envía el historial completo, con todas las llamadas y resultados previos. Con 30 pasos se envían muchas veces más tokens que con 6. El límite de pasos y la detección de repeticiones son también un límite de gasto.

  5. Para «¿cuánto varió en % el cierre de BUSI entre dos fechas?», ¿qué diseño es más fiable con un modelo pequeño?

    • ADarle solo una calculadora y confiar en que haga la cuenta
    • BDarle una herramienta variacion(ticker, desde, hasta) que hace el cálculo en código, o un plan fijo en el que el modelo solo extrae los parámetros
    • CPedirle que piense paso a paso sin herramientas
    • DUsar temperatura alta para que explore
    Ver respuesta

    Respuesta B. Darle una herramienta variacion(ticker, desde, hasta) que hace el cálculo en código, o un plan fijo en el que el modelo solo extrae los parámetros

    Cuanto menos tenga que encadenar el modelo, menos puntos de fallo. Una herramienta de más alto nivel convierte tres llamadas y una cuenta en una, y la aritmética la hace el código. Es planificación fijada por el programador para una tarea conocida.

Ideas clave

  • Tool calling: el modelo emite peticiones estructuradas (nombre y argumentos JSON); el programa las valida, las ejecuta y devuelve el resultado. Nada se ejecuta sin pasar por el código.
  • ReAct alterna razonamiento, acción (llamada a herramienta) y observación (resultado) hasta responder; planificar primero y ejecutar después es la alternativa cuando la tarea es conocida.
  • Guardarraíles mínimos del bucle: validación de argumentos con esquema, errores devueltos como observación, detección de repeticiones, límite de pasos y verificación de la respuesta final contra la traza.
  • El LLM local de 0,5B formula bien llamadas sencillas, pero ignora errores y resultados al redactar: inventa un precio para un ticker inexistente y confunde un cierre con un porcentaje.
  • Las formas de OpenAI (function_call y function_call_output) y de Anthropic (tool_use y tool_result) son equivalentes; el mismo bucle sirve para las dos con un adaptador, y se prueba sin red contra simuladores.

Recursos

Datos