Google ADK: FunctionTool, la interfaz entre el LLM y el mundo real

Google ADK: FunctionTool, la interfaz entre el LLM y el mundo real

Este es el tercer post de la serie sobre Google ADK. Los anteriores: introducción al framework y setup del proyecto.

Los agentes son tan poderosos como las herramientas que tienen disponibles. Un LLM sin herramientas es solo texto; con herramientas bien diseñadas se convierte en un sistema que puede calcular, consultar datos, ejecutar lógica de negocio, y actuar en el mundo.

En este post vamos a fondo con FunctionTool: su anatomía, cómo Zod se traduce a lo que el LLM ve, cómo diseñar retornos que el modelo procese bien, y los patrones de error handling que funcionan en la práctica.

La anatomía completa de un FunctionTool

const myTool = new FunctionTool({
  name: string, // Identificador único, snake_case
  description: string, // Qué hace la herramienta — lo lee el LLM
  parameters: ZodSchema, // Input tipado — se convierte a JSON Schema
  execute: params => any, // La función que se ejecuta
});

Cuatro campos, cuatro responsabilidades distintas.

El schema Zod → JSON Schema → LLM

Este es el mecanismo central. ADK toma tu schema de Zod y lo convierte automáticamente a JSON Schema, que es lo que los LLMs entienden para el function calling.

Para dar una mejor idea, veamos el siguiente ejemplo. Si nosotros escribimos lo siguiente:

parameters: z.object({
  monthlyIncome: z.number().describe("Ingresos mensuales en USD"),
  expenses: z.array(
    z.object({
      category: z.string().describe("Categoría (ej. Vivienda, Comida, Transporte)"),
      amount: z.number().describe("Monto mensual en USD"),
    })
  ).describe("Lista de gastos mensuales"),
}),

El LLM recibe:

{
  "name": "analyze_budget",
  "description": "Analiza ingresos y gastos mensuales...",
  "parameters": {
    "type": "object",
    "properties": {
      "monthlyIncome": {
        "type": "number",
        "description": "Ingresos mensuales en USD"
      },
      "expenses": {
        "type": "array",
        "description": "Lista de gastos mensuales",
        "items": {
          "type": "object",
          "properties": {
            "category": {
              "type": "string",
              "description": "Categoría (ej. Vivienda, Comida, Transporte)"
            },
            "amount": {
              "type": "number",
              "description": "Monto mensual en USD"
            }
          }
        }
      }
    }
  }
}

La consecuencia práctica: cada .describe() que omitís, es contexto que el LLM pierde. Si el usuario dice "mis gastos de comida son $400", el modelo tiene que inferir qué campo corresponde a cada dato. Con buenos describe() la tasa de errores de tool-calling cae significativamente. Esto hace que sea realmente importante armar buenos esquemas de zod incluyendo las descripciones de cada campo.

Si, por ejemplo, tenemos tres numeros enteros como parámetros y no aclaramos que hace cada uno, el modelo no tiene forma de adivinar y decidir que pasar en cada uno, llevandonos a resultados erroneos.

Diseño de retornos: lo que el LLM necesita para razonar

Este es el aspecto más subestimado del diseño de herramientas. El retorno de tu herramienta no es para un usuario humano — es para un LLM que lo va a usar para formular su respuesta, hay que diseñarlo en consecuencia a esto.

Patrón que funciona bien: retornos estructurados con contexto suficiente para que el LLM no tenga que inferir.

Mirá este retorno de analyzeBudgetTool:

execute: ({ monthlyIncome, expenses }) => {
  const totalExpenses = expenses.reduce((sum, e) => sum + e.amount, 0);
  const savings = monthlyIncome - totalExpenses;
  const savingsRate = (savings / monthlyIncome) * 100;

  return {
    status: "success",
    summary: {
      monthlyIncome,
      totalExpenses,
      currentMonthlySavings: savings,
      savingsRate: savingsRate.toFixed(1) + "%",  // String con unidad incluida
      annualSavingsPotential: savings * 12,
    },
    budgetRule503020: {
      needs: {
        recommended: monthlyIncome * 0.5,
        actual: needsTotal,
        status: needsTotal <= monthlyIncome * 0.5 ? "✅ En línea" : "⚠️ Excedido",
      },
      // ...
    },
    savingsHealthRating: savingsRate >= 20 ? "Excelente (≥20%)" : "Necesita mejora (<10%)",
  };
},

Tres cosas que facilitan el trabajo del LLM:

  1. Status explícito ("success", "error") — el modelo sabe inmediatamente si la operación funcionó
  2. Valores pre-formateados"18.5%" en lugar de 0.185; el LLM puede citarlo directamente
  3. Evaluaciones incluidas"✅ En línea" en lugar de dejar que el LLM calcule si 1800 <= 3000

Esto no es spoon-feeding al LLM; es que cada transformación que le ahorrás reduce la probabilidad de errores en la respuesta final.

Es importante entender que esta salida, pasa a ser entrada en el modelo para el siguiente paso y hay que pensarlo de la misma forma que pensamos los promps que hacemos habitualmente.

Por mas que los modelos cada vez son mas potentes si la respuesta es un array de numeros y sin mucha mas explicación, es muy dificil que el modelo pueda interpretar esto y hacer algo valioso gracias a esta nueva información.

Cuando estructuramos la salida y damos mas información aunque parezca anti intuitivo para la forma normal de armar funciones, le damos mucha mas información al modelo para procesar dicha respuesta.

Manejo de errores en herramientas

Algo que siempre hay que tener en cuenta es que como en todos los sistemas, las herramientas fallan. Tener un contrato de error claro hace que el agente pueda recuperarse:

execute: ({ ticker }) => {
  const stock = getStock(ticker);

  if (!stock) {
    return {
      status: "error",
      message: `Ticker '${ticker.toUpperCase()}' no encontrado. Tickers disponibles: AAPL, MSFT, NVDA, SPY, VTI...`,
    };
  }

  return {
    status: "success",
    investment: { ...stock },
  };
},

El status: "error" con un mensaje descriptivo le da al LLM la información necesaria para reformular la pregunta o pedirle al usuario que corrija el input. Sin esto, el LLM recibe un undefined o un objeto vacío y puede inventarse una respuesta.

Nunca tires una excepción desde execute si podés evitarlo. Una excepción no manejada corta el ciclo del agente abruptamente. Siempre retorná un objeto con status: "error" — así el LLM puede razonar sobre el fallo y decidir qué hacer.

// ❌ Evitar
execute: ({ ticker }) => {
  const stock = getStock(ticker);
  if (!stock) throw new Error("Ticker not found"); // corta el ciclo
  return stock;
},

// ✅ Preferir
execute: ({ ticker }) => {
  const stock = getStock(ticker);
  if (!stock) return { status: "error", message: "Ticker no encontrado" };
  return { status: "success", data: stock };
},

Herramientas con lógica de negocio compleja

Las herramientas no son solo wrappers de APIs. En nuestro proyecto algunas tienen lógica financiera significativa. Mirá retirementPlannerTool:

execute: ({ currentAge, retirementAge, currentSavings, monthlyContribution, desiredAnnualRetirementIncome }) => {
  const yearsToRetirement = retirementAge - currentAge;
  const monthlyRate = 0.07 / 12; // 7% anual → mensual
  const totalMonths = yearsToRetirement * 12;

  // Valor futuro de ahorros actuales
  const fvCurrentSavings = currentSavings * Math.pow(1 + 0.07, yearsToRetirement);

  // Valor futuro de contribuciones mensuales (fórmula de anualidad)
  const fvContributions =
    monthlyContribution *
    ((Math.pow(1 + monthlyRate, totalMonths) - 1) / monthlyRate) *
    (1 + monthlyRate);

  const projected = fvCurrentSavings + fvContributions;
  const required = (desiredAnnualRetirementIncome / 0.04); // regla del 4%
  const onTrack = projected >= required;

  return {
    status: "success",
    projections: {
      projectedRetirementBalance: Math.round(projected),
      requiredNestEgg: Math.round(required),
      onTrack,
      verdict: onTrack
        ? `✅ En camino — proyectás $${Math.round(projected - required).toLocaleString()} de superávit`
        : `⚠️ Déficit de $${Math.round(required - projected).toLocaleString()}`,
    },
    // ...
  };
},

La lógica de negocio vive en la herramienta, no en las instrucciones del agente. El agente sabe cuándo usar la herramienta; la herramienta sabe cómo calcular. Separación de responsabilidades básica, pero fácil de romper cuando empezás a poner cálculos en las instrucciones del sistema.

Principios de diseño que aplican bien en la práctica

Single Responsibility: cada herramienta hace una cosa bien, asi como en la programación en general. En lugar de analyzeFinances que hace todo, lo ideal es tener una para cada cosa, entonces tenemos: analyzeBudget, findSavingsOpportunities, calculateEmergencyFund. El LLM puede invocarlas selectivamente según lo que necesita.

Descripciones como documentación de API: la descripción de una herramienta es el contrato entre la función y el LLM. Tiene que responder: ¿qué hace?, ¿cuándo usarla?, ¿qué retorna? Asi como cuando armamos una API nos ocupamos de hacer documentación como open API/Swagger pensando en quien la va a consumir, es importante que cuando armemos estas funciones las documentemos pensando en el modelo que la va a consumir.

// ❌ Descripción insuficiente
description: "Analiza el portfolio",

// ✅ Descripción que da contexto de uso
description:
  "Analiza la cartera de inversiones actual. Retorna valor total, ganancia/pérdida, " +
  "distribución por tipo de activo y sector, riesgo de concentración, y beta ponderado. " +
  "Usá esta herramienta cuando el usuario quiera entender la composición de sus inversiones.",

Retornos ricos pero coherentes: más información es mejor, pero tiene que tener estructura consistente. Si a veces retornás { data: {...} } y otras veces { result: {...} } sin un patrón, el LLM va a tener más dificultades para razonar sobre los resultados.

Validaciones en el input, no en las instrucciones: si un parámetro tiene restricciones, expresalas en el schema de Zod:

parameters: z.object({
  tickers: z.array(z.string()).min(2).max(5)
    .describe("Array de 2 a 5 símbolos ticker para comparar"),
  targetPercents: z.array(z.number().min(0).max(100))
    .describe("Porcentajes objetivo — deben sumar 100"),
}),

Zod valida antes de que llegue a execute. Si el LLM intenta pasar un array de 10 tickers, el error de validación lo intercepta antes.

Herramientas con múltiples escenarios de retorno

Algunas herramientas tienen lógica que lleva a retornos estructuralmente distintos. El patrón discriminado funciona bien:

execute: ({ ticker, sharesOwned }) => {
  const stock = getStock(ticker);
  const fund = getStockFundamentals(ticker);

  // Caso 1: ticker no existe
  if (!stock || !fund) {
    return { status: "error", message: `No hay datos para ${ticker}` };
  }

  // Caso 2: acción sin dividendo
  if (!stock.dividendYield || stock.dividendYield === 0) {
    return {
      status: "info",
      message: `${stock.name} no paga dividendo.`,
      alternatives: ["SCHD — 3.5% yield", "JEPI — 7.5% yield"],
    };
  }

  // Caso 3: análisis completo
  return {
    status: "success",
    dividend: { /* análisis completo */ },
  };
},

El LLM entiende los tres casos y genera respuestas distintas para cada uno, sin necesitar instrucciones especiales para manejarlos.

La capa de datos desacoplada

Asi como cuando trabajamos con APIs intentamos tener patrones como services y repositories, un patrón que vale la pena destacar del proyecto, las herramientas no acceden a datos directamente. Usan funciones de una capa de datos separada:

// src/data/marketData.ts
export function getStock(ticker: string): StockQuote | undefined {
  return STOCK_DATABASE[ticker.toUpperCase()];
}

export function getStockFundamentals(
  ticker: string
): StockFundamentals | undefined {
  return FUNDAMENTALS_DATABASE[ticker.toUpperCase()];
}

// src/tools/stockResearchTools.ts
import { getStock, getStockFundamentals } from '../data/marketData.js';

execute: ({ ticker }) => {
  const quote = getStock(ticker); // abstracción de la fuente de datos
  const fund = getStockFundamentals(ticker);
  // ...
};

Cuando reemplaces los datos mock por una API real (Alpha Vantage, Polygon.io, etc.), solo cambia marketData.ts. Las herramientas, agentes, e instrucciones no se tocan.

Checklist de diseño de herramientas

Antes de dar una herramienta por terminada:

  • El name es descriptivo y único dentro del agente
  • La description explica qué hace, cuándo usarla, y qué retorna
  • Todos los campos del schema tienen .describe()
  • Los errores retornan { status: "error", message: "..." } en lugar de lanzar excepciones
  • Los valores numéricos importantes tienen unidades en el string (e.g. "18.5%", "$3,200")
  • El retorno tiene un campo status consistente
  • La lógica de negocio está en la herramienta, no en las instrucciones del agente

En el próximo post subimos un nivel: cómo componer múltiples agentes en un sistema orquestado donde cada uno tiene su dominio de expertise y el LLM hace el routing automáticamente.

El código de este post corresponde al commit implement budget agent and tools: src/tools/budgetTools.ts y src/agents/budgetAgent.ts.

Comments

Share your thoughts and join the discussion


Related Posts