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.