De predictor de texto a componente de software: esquemas y function calling
Cómo JSON Schema, la salida estructurada y la decodificación restringida convierten el texto libre de un LLM en datos tipados en los que tu código puede confiar, hasta llegar al function calling.
Antes de los agentes, RAG y MCP hay una pregunta más básica: ¿qué es un LLM y qué hace en realidad? Un gran modelo de lenguaje hace exactamente una cosa. Predice el siguiente token. Lee el texto que lleva hasta ahora, elige el siguiente token más probable, lo añade y vuelve a predecir, en bucle, hasta terminar.
Ese bucle es todo el mecanismo. Todo lo demás que se construye alrededor de los LLM va encima. De ahí salen dos consecuencias. La primera, que un LLM no sabe nada de tipos, números, objetos ni bases de datos. Produce texto y solo texto. La segunda, que sus predicciones salen de los datos de entrenamiento, que son una foto del pasado, así que las respuestas pueden estar desfasadas a menos que metas tú mismo información fresca en el prompt.
Ser un simple generador de texto se convierte en un problema real en cuanto construyes software encima. El software es determinista y tipado, con objetos, métodos y números. Un LLM en bruto te entrega un string.
El problema en concreto
Supón que le preguntas el tiempo a un modelo. Podría devolver cualquiera de estas respuestas:
- «Hace 22 °C y sol»
- «Unos 22 grados, cielo despejado»
- «Yo diría que unos 22°»
Las tres son correctas y las tres son inútiles para tu código. Tu código quiere que response.temp_c sea un número siempre, para poder guardarlo, hacer cálculos con él o pintarlo en una gráfica. Con una frase no puedes hacer nada de eso, y no puedes aplicar JSON.parse() a un texto en lenguaje natural. Una expresión regular quizá funcione hoy y falle con la redacción de mañana.
Fíjate en cómo falla esto. No hay nada roto y nadie se ha equivocado; el modelo hizo su trabajo, y ese trabajo resultó ser prosa cuando tu programa quería un valor. Así que el problema no es que el modelo se equivoque. Es que la salida no tiene una forma fija. Para construir encima necesitas una salida predecible, no solo correcta. Resolverlo exigió unas cuantas ideas que se apilan unas sobre otras.
JSON Schema
Empecemos por JSON Schema. Es una receta de cómo debe quedar un objeto JSON una vez relleno. No contiene datos. Solo describe la forma: qué campos existen, de qué tipo es cada uno y cuáles son obligatorios.
{
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name, e.g. Istanbul" },
"temp_c": { "type": "number", "description": "Temperature in Celsius" },
"is_sunny": { "type": "boolean" }
},
"required": ["city", "temp_c"]
}
Cada campo recibe un type como string, number o boolean. Una description da al modelo más contexto sobre el campo, y la lista required indica qué campos deben estar presentes. En este ejemplo is_sunny es opcional.
Es justo lo que necesita una consulta del tiempo: una ciudad como string, una temperatura como número y un indicador que puede estar o no. Un esquema por sí solo no hace nada. Tómalo como un contrato que dice «los datos válidos tienen este aspecto». El siguiente paso es entregarle ese contrato al modelo.
Salida estructurada
En 2024 los LLM incorporaron una función llamada salida estructurada. Le das al modelo un JSON Schema y tiene que rellenarlo, ni más ni menos. (Una versión anterior y más débil solo garantizaba JSON válido, sin ninguna promesa sobre campos o tipos. La salida estructurada cerró ese hueco.)
Funciona gracias a dos cosas a la vez, y ninguna basta por separado.
La primera es el entrenamiento. A partir de 2024, los modelos nuevos se entrenaron para entender esquemas y emitir salidas que encajaran con ellos. Pero un modelo sigue adivinando token a token, así que incluso uno bien entrenado respetaba el esquema en torno al 90 % de las veces. En software real, un 90 % es un bug esperando su momento.
La segunda es código normal en el motor de inferencia, lo que se llama decodificación restringida. El modelo emite tokens uno a uno y, en cada paso, este código consulta el esquema y elimina cualquier token que lo rompería poniendo su probabilidad a cero. El esquema se convierte en una gramática, un conjunto de reglas sobre qué tokens pueden venir a continuación, y el modelo solo puede elegir entre los que quedan.
El modelo entrenado hace la mayor parte del trabajo y la decodificación restringida lo convierte en una garantía cercana al 100 %. Piensa en el reparto así: el entrenamiento hace probable la respuesta correcta y el paso de decodificación hace imposible una forma incorrecta. El modelo sigue devolviendo un string, pero uno que siempre encaja con la receta. Luego lo parseas a un objeto real y puedes fiarte de él.
// the recipe, the same kind of JSON Schema we saw above
const schema = {
type: "object",
properties: {
city: { type: "string" },
temp_c: { type: "number" }
},
required: ["city", "temp_c"]
};
// ask the model, and pass the schema to force the shape
const report = await askModel({
prompt: "Weather in Istanbul, sunny, 22°",
schema: schema,
});
report.temp_c; // 22, always a number. no parsing, no regex.
Ahora el código tipado y el modelo por fin pueden colaborar, porque la salida tiene una forma con la que puedes contar.
Hay una consecuencia mayor. Los parámetros de una función no son más que campos con tipos, que es exactamente lo que es una salida estructurada. Si el modelo puede rellenar cualquier esquema, puede rellenar los argumentos de una función. Y en cuanto puede producir argumentos correctos, puede decirle a tu código, en la práctica, qué función ejecutar y con qué valores. Eso te da más que datos limpios. Te da llamadas a métodos, y eso tiene nombre.
Function calling
Con function calling envías el prompt junto con una lista de funciones con nombre. Cada una tiene un nombre, una description que explica qué hace y un JSON Schema para sus parámetros.
{
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
La description pesa mucho, porque es lo que usa el modelo para decidir que esa función es la adecuada. El modelo lee tu prompt, concluye que necesita el tiempo y responde con la llamada que quiere que se haga:
{ "name": "get_weather", "input": { "city": "Istanbul" } }
Esa respuesta no es más que una salida estructurada. input contiene los argumentos y name identifica la función. El modelo no ha ejecutado nada, ni puede. En tu lado parseas el JSON, buscas la función correspondiente a partir de name, le pasas input como argumentos, la ejecutas y devuelves el resultado al modelo. Dicho de forma sencilla, function calling es una salida estructurada con los argumentos, combinada con la elección del modelo de qué función encaja.
Eso es lo que abre las puertas. Todo lo que puedas envolver en una función pasa a estar al alcance: una API del tiempo, un sistema de pagos, un buscador, una base de datos, tus propios servicios. Cualquier funcionalidad descrita como función con esquema se puede conectar a un LLM. Fíjate en lo poco que necesita el modelo para esto. Nunca toca tus sistemas directamente; solo propone una llamada, y tu código decide si se ejecuta y cómo. Así es como un cuadro de texto se convierte en algo que actúa en el mundo real, a través de tu código.
Lo difícil nunca fue describir lo que quieres. Fue conseguir que un generador de texto siguiera las reglas. Una vez resuelto eso, un LLM pudo convertirse en una pieza más de tu código, como cualquier otra herramienta.