Course EN

Capítulo 6 de 21 · intermedio

Function calling

Qué cubre esta sesión

Pregúntale a un modelo cuánto son 26.2 millas en kilómetros y te va a contestar con toda confianza y, una fracción notable de las veces, con el número equivocado. Eso no es un defecto que se arregle con prompts — los pesos no hacen aritmética, la aproximan. Esta hora trata del mecanismo que lo corrige: declaras una función que el modelo puede llamar, el modelo decide cuándo llamarla y con qué argumentos, tu Python la ejecuta, y el modelo redacta la respuesta alrededor del resultado exacto.

Vuelve a leer esa secuencia, porque la división del trabajo es toda la lección. El modelo nunca ejecuta nada. Emite una petición — "llama convert con estos argumentos" — y se detiene. Tu código ejecuta la función, en tu máquina, bajo tu control, y regresa el resultado para que una segunda llamada al modelo lo narre. Todo lo que los capítulos de agentes hacen después es este mismo loop con más herramientas y más turnos. Esta semana es una herramienta y un round trip, bien hechos.

OpenAI

La herramienta se declara como JSON Schema: un nombre, una descripción y los parámetros exactos con sus tipos. Trata la descripción como un prompt — es lo que el modelo lee al decidir si llama y cómo. El ejemplo le da al modelo una sola función, convert, y le hace la pregunta del maratón.

"""Week 5 - Function calling (OpenAI).

The model can't do exact unit math, but your Python can. Declare one
function as a tool, let the model decide to call it, run the real code,
hand the result back, and get a grounded answer. One tool, one round trip.
"""
import json
import os
import sys

from dotenv import find_dotenv, load_dotenv
from openai import OpenAI

load_dotenv(find_dotenv())

if not os.environ.get("OPENAI_API_KEY"):
    sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")

client = OpenAI()


# region: the-function
# The actual work is plain Python — exact, deterministic, testable.
# The model never does this math; it only asks for it.
FACTORS_TO_METERS = {"km": 1000.0, "miles": 1609.344, "meters": 1.0, "feet": 0.3048}


def convert(value: float, from_unit: str, to_unit: str) -> float:
    meters = value * FACTORS_TO_METERS[from_unit]
    return round(meters / FACTORS_TO_METERS[to_unit], 3)
# endregion


# region: tool-schema
# The tool declaration is a JSON Schema: name, what it does, and the
# exact parameters. The description is a prompt — the model reads it to
# decide WHEN to call and WHAT to pass.
TOOLS = [{
    "type": "function",
    "name": "convert",
    "description": "Convert a length between units exactly.",
    "parameters": {
        "type": "object",
        "properties": {
            "value": {"type": "number"},
            "from_unit": {"type": "string", "enum": ["km", "miles", "meters", "feet"]},
            "to_unit": {"type": "string", "enum": ["km", "miles", "meters", "feet"]},
        },
        "required": ["value", "from_unit", "to_unit"],
    },
}]
# endregion

QUESTION = "A marathon is 26.2 miles. How many kilometers is that, exactly?"

# region: round-trip
# Call 1: the model reads the question and, instead of answering, emits a
# function_call item — the function name plus arguments as a JSON string.
response = client.responses.create(
    model="gpt-5.4-nano",
    input=QUESTION,
    tools=TOOLS,
)

call = next(item for item in response.output if item.type == "function_call")
args = json.loads(call.arguments)
print(f"model wants: {call.name}({args})")

# YOUR code runs the function. The model only chose it.
result = convert(**args)

# Call 2: hand the result back, linked by call_id. previous_response_id
# carries the whole conversation state so we only send the new part.
final = client.responses.create(
    model="gpt-5.4-nano",
    previous_response_id=response.id,
    input=[{
        "type": "function_call_output",
        "call_id": call.call_id,
        "output": json.dumps({"result": result}),
    }],
    tools=TOOLS,
)
# endregion

print(final.output_text)

Dos cosas que internalizar. Primero, la respuesta del modelo para llamarla no es texto — es un item function_call que trae el nombre de la función y los argumentos como un string JSON que tú parseas y validas; los argumentos son output del modelo, así que restringe con enums todo lo que puedas y trata el resto como input de usuario. Segundo, el resultado regresa vinculado por call_id, y previous_response_id carga el estado de la conversación para que la segunda petición solo envíe la parte nueva. Olvida el parámetro tools en esa segunda llamada y te va a tocar un modelo confundido que ya no se acuerda de qué es un convert.

Gemini

La misma función, el mismo round trip, distinta ortografía. Gemini declara las herramientas con objetos Schema tipados en lugar de un dict de JSON Schema crudo, y la llamada llega en response.function_calls con los argumentos ya parseados a un dict — sin paso de json.loads.

"""Week 5 - Function calling (Gemini).

Same lesson, same function, so the two SDKs read side by side. Gemini
takes typed FunctionDeclarations on the config; the call comes back as a
structured part with the args already parsed into a dict.
"""
import os
import sys

from dotenv import find_dotenv, load_dotenv
from google import genai
from google.genai import types

load_dotenv(find_dotenv())

if not os.environ.get("GEMINI_API_KEY"):
    sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")

client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])


# region: the-function
FACTORS_TO_METERS = {"km": 1000.0, "miles": 1609.344, "meters": 1.0, "feet": 0.3048}


def convert(value: float, from_unit: str, to_unit: str) -> float:
    meters = value * FACTORS_TO_METERS[from_unit]
    return round(meters / FACTORS_TO_METERS[to_unit], 3)
# endregion


# region: tool-schema
# Gemini declares tools with typed Schema objects instead of raw JSON
# Schema dicts — same information, different spelling.
CONVERT_DECL = types.FunctionDeclaration(
    name="convert",
    description="Convert a length between units exactly.",
    parameters=types.Schema(
        type=types.Type.OBJECT,
        properties={
            "value": types.Schema(type=types.Type.NUMBER),
            "from_unit": types.Schema(type=types.Type.STRING, enum=["km", "miles", "meters", "feet"]),
            "to_unit": types.Schema(type=types.Type.STRING, enum=["km", "miles", "meters", "feet"]),
        },
        required=["value", "from_unit", "to_unit"],
    ),
)

CONFIG = types.GenerateContentConfig(
    tools=[types.Tool(function_declarations=[CONVERT_DECL])],
)
# endregion

QUESTION = "A marathon is 26.2 miles. How many kilometers is that, exactly?"

# region: round-trip
# Call 1: the model answers with a function call part instead of text.
# The SDK surfaces it on response.function_calls with args as a dict —
# no json.loads needed, unlike the OpenAI side.
response = client.models.generate_content(
    model="gemini-3.1-flash-lite",
    contents=QUESTION,
    config=CONFIG,
)

fc = response.function_calls[0]
print(f"model wants: {fc.name}({dict(fc.args)})")

result = convert(**fc.args)

# Call 2: replay the conversation — question, the model's function-call
# turn, then a function_response part carrying your result — and the
# model writes the final answer from it.
contents = [
    types.Content(role="user", parts=[types.Part(text=QUESTION)]),
    response.candidates[0].content,
    types.Content(
        role="user",
        parts=[types.Part.from_function_response(name=fc.name, response={"result": result})],
    ),
]
final = client.models.generate_content(
    model="gemini-3.1-flash-lite",
    contents=contents,
    config=CONFIG,
)
# endregion

print(final.text)

La diferencia de cableado que vale la pena notar es el estado. OpenAI te deja encadenar desde previous_response_id; Gemini te pone a reproducir la conversación — tu pregunta, el turno de function call del modelo, y luego una parte function_response con tu resultado. La misma información, pero la armas a mano. El SDK de Python también tiene un modo automático donde pasas una función de Python de verdad y el SDK corre todo el loop por ti; sáltatelo hasta que el cableado manual sea reflejo, porque cuando los tool calls se porten mal en producción, la versión manual es la que vas a estar depurando en tu cabeza.

Ponlo a trabajar

Tres aplicaciones web con docker-compose bajo code/showcase/<slug>/, la misma rutina de cada semana: bash bootstrap-secrets.sh, docker compose up --build, http://localhost:3000. Cada una le da al modelo exactamente una función y una tarea que el modelo no puede hacer honestamente por sí solo.

Showcase 1 — Convertidor de unidades

Haz una pregunta de conversión en lenguaje natural y el modelo la convierte en una llamada a convert sobre tablas de conversión exactas — longitud, masa, volumen y temperatura, con su matemática de offset resuelta en código. Las instrucciones amarran al modelo a la herramienta para la aritmética, y las unidades de la herramienta están restringidas con enums en el schema, así que un argumento alucinado de "furlongs" falla la validación en lugar de producir un factor inventado.

Showcase 2 — Matemática de calendario

Los días de la semana y los conteos de días son donde los modelos adivinan calladamente — hacen pattern matching sobre calendarios en lugar de contar. Aquí el único trabajo real del modelo es sacar un YYYY-MM-DD de tu oración; el datetime de Python hace el conteo y regresa hechos exactos. Pregunta por dos fechas y el modelo emite dos llamadas en un solo turno, y por eso el código del round trip maneja una lista de llamadas, no una sola.

Showcase 3 — Escalador de recetas

Pega una receta, di "hazla para doce", y el modelo parsea la lista completa de ingredientes a un argumento tipado de arreglo de objetos — la forma de schema que no habías visto en esta hora. Python multiplica cada cantidad con exactitud y muestra 0.75 de vuelta como 3/4. Un modelo escalando una receta de cabeza pierde ingredientes y batalla con las fracciones; una llamada estructurada físicamente no puede.

Los tres corren el mismo backend/main.py, despachando según PROVIDER, así que PROVIDER=gemini docker compose up --build cambia de SDK sin tocar la lección. La decisión de diseño que los mantiene honestos: cada run() tiene una rama sin llamada — si el modelo decide que ninguna función aplica, regresa su respuesta en texto plano en lugar de que el código finja una conversión que nunca ocurrió.

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 para los ejemplos básicos y cada showcase. Las llaves salen del .env no rastreado en la raíz del curso, igual que todas las demás semanas.

Conclusiones

Function calling es una división del trabajo: el modelo elige, tu código ejecuta. Mantén esa línea bien marcada. La descripción de la herramienta es un prompt, así que escríbela como tal; los argumentos son output del modelo, así que valídalos como input de usuario; y la función en sí es Python normal que puedes probar con unit tests sin un modelo a la vista. Una vez que este round trip sea reflejo — llamada que sale, resultado que entra, respuesta que sale — el loop de agente de la próxima semana es solo este loop con un while alrededor y más de una herramienta de dónde elegir.