Saltar al contenido

Llamadas a funciones con Gemini y JavaScript

Hablar con modelos de lenguaje, poder preguntarles cosas y que nos expliquen … todas esas cosas están muy bien, pero tienen dos importantes limitaciones:

  • sus conocimientos están limitados a sus datos de entrenamiento, o la información que directamente les facilitemos
  • no pueden realizar acciones.

La posibilidad de “llamar a funciones” (function calling) permite a aplicaciones basadas en grandes modelos de lenguaje suplir ambas deficiencias, pudiendo llamar a servicios que le faciliten información no disponible en su entrenamiento (el tiempo del día, estado de vuelos, cotizaciones de bolsa, …) o tomar acciones (reservar vuelos, comprar acciones, crear entradas en nuestro calendario).

La mayoría de los grandes modelos de lenguaje ofrecen en sus APIs algún tipo de funcionalidad para poder llamar funciones, con funcionamiento básico muy similar, pero en esta entrada nos centraremos en usar la funcionalidad de la API de Google Gemini.

Conceptos Básicos

Lo primero que hay que aclarar es que el modelo de lenguaje no será directamente quien llame a las funciones; el modelo lo que hará será indicar que una llamada a función debe producirse, para que sea nuestra aplicación la que haga la llamada. Viendo el intercambio más en detalle, el flujo sería:

  1. La aplicación inicia la interacción con el modelo de lenguaje, y le indica las funciones que va a tener disponibles, junto con sus especificaciones (parámetros, etc).
  2. Mientras el modelo no necesite una llamada a función el, intercambio de mensajes entre usuario y modelo se produce con normalidad.
  3. Cuando el modelo determina que una llamada a función es requerida enviará un mensaje en un formato específico, especificando qué función se tiene que ejecutar y con qué parámetros.
  4. La aplicación controla que el mensaje recibido es una llamada a función y la ejecuta.
  5. La aplicación envía al modelo un mensaje de tipo “respuesta a función” con la respuesta de la función”.
  6. El modelo, teniendo ya la respuesta que necesitaba de la función, reanuda el intercambio de mensajes, con el beneficio de la información adicional obtenida de la función.

Olivia

Para ilustrar el uso de llamadas a funciones, vamos a construir a Olivia, nuestra ayudante personal de cocina. Olivia nos ayudará a decidir nuestro menú y a encontrar recetas nuevas, pero además hará las veces de archivista de recetas: será capaz de salvar recetas y consultar las que tenemos ya guardadas.

Librerías

Implementaremos nuestras funciones guardarReceta y listarRecetas usando sqlite, que nos permite persistir la base de datos de recetas en un archivo de forma sencilla. Junto con sqlite para este proyecto necesitaremos instalar la librería de Gemini para JavaScript:

npm install @google/generative-ai
npm install better-sqlite3

Especificación de las funciones

Para poder usar las funciones, Gemini necesita que previamente se las especifiquemos. Es importante que la especificación sea lo más clara y precisa posible en términos de descripción y parámetros de entrada, porque evitar que el modelo trate de usarlo cuando realmente no aplica, o que nos genere la llamada con parámetros incorrectos. En nuestro caso especificaremos nuestras dos funciones –guardarReceta y listarRecetas– de la siguiente manera:

const declaracionGuardarReceta = {
    name: "guardarReceta",
    parameters: {
        type: "OBJECT",
        description: "Guarda una nueva receta",
        properties: {
            nombre: {
                type: "STRING",
                description: "Nombre de la receta.",
            },
            ingredientes: {
                type: "STRING",
                description: "Lista de ingredientes necesarios para preparar la receta, con sus cantidades.",
            },
            preparacion: {
                type: "STRING",
                description: "Lista de pasos específicos necesarios para preparar la receta, incluyendo el orden, tiempos de cocción y cualquier criterio que sea necesario especificar para que una persona sin conocimiento previo pueda elaborarla con éxito.",
            },
        },
        required: ["nombre", "ingredientes", "preparacion"],
    }
};

const declaracionListarRecetas = {
    name: "listarRecetas"
}

Implementación de las funciones

Las correspondientes implementaciones de ambos servicios serían:

let tablaExiste = false

function listarRecetas() {
    let recetas, db;
    try {
        db = new Database('recetas.db');
        const stmt = db.prepare("select nombre, ingredientes, preparacion from receta");
        recetas = stmt.all();
        tablaExiste = true;
    } catch {
        recetas = "No hay recetas.";
    } finally {
        db.close();
    }
    return recetas;
}

function guardarReceta(nombre, ingredientes, preparacion) {
    let resultado, db;
    try {
        db = new Database('recetas.db');
        if (!tablaExiste) {
            const stmtTabla = db.prepare(`CREATE TABLE if not exists receta (
                [nombre] TEXT NOT NULL,
                [ingredientes] TEXT,
                [preparacion] TEXT)`);
            stmtTabla.run();
        }
        const stmtInsert = db.prepare("insert into receta (nombre, ingredientes, preparacion) values (?,?,?)");
        stmtInsert.run(nombre, ingredientes, preparacion);
        resultado = "OK";
    } catch {
        resultado = "Error guardando la receta";
    } finally {
        db.close();
    }
    return resultado;
}

Mensaje de sistema

Para hacer que Olivia se comporte como queremos, es importante que le facilitemos un mensaje de sistema detallando lo que esperamos de ella. Aunque Gemini (a diferencia de OpenAI o Anthropic) no tiene mensajes de sistema propiamente dicho, lo que haremos será iniciar la conversación con nuestro mensaje de sistema, lo cual funciona suficientemente bien. El mensaje es el siguiente:

# QUIEN ERES

Eres Olivia, una gran chef y archivista de recetas. Con tu conocimiento enciclopédico, eres capaz de proponer recetas, escribirlas a gusto del usuario y modificarlas para adaptarlas a sus preferencias.

# COMPORTAMIENTO

Habla con el usuario, averigua sus gustos, aconséjale sobre posibles recetas y modifícalas con forme a lo que te pida. Una vez que el usuario confirma que está satisfecho con la receta, pregúntale si desea guardarla, y en caso afirmativo, genera una llamada al servicio "guardarReceta".

Si el usuario quiere saber las recetas que ya tiene guardadas, debes obtener las recetas guardadas llamando al servicio "listarRecetas". Dicho servicio te devolverá una lista de objetos, cada uno de los cuales representa una receta, con la estructura que se explica a continuación.

# ESTRUCTURA DE UNA RECETA

Una receta se compone de tres partes:
- Título
- Ingredientes: lista de ingredientes necesarios para preparar la receta, con sus cantidades.
- Preparación: lista de pasos específicos necesarios para preparar la receta, incluyendo el orden, tiempos de cocción y cualquier criterio que sea necesario especificar para que una persona sin conocimiento previo pueda elaborarla con éxito.

El mensaje de sistema lo almacenaremos en un archivo de texto separado, el cual leeremos y se lo pasaremos al modelo al inicio de la conversación.

Interacción con Gemini

Toda la interacción con el modelo de lenguaje la encapsularemos dentro de una clase, que llamaremos ClienteGemini:

class ClienteGemini {
    constructor(modelo, mensajeSistema) {
        this.modelo = modelo;
        const genai = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY);
        let cliente = genai.getGenerativeModel(
            {
                model: modelo,
                safetySettings: [
                    {
                        "category": "HARM_CATEGORY_HARASSMENT",
                        "threshold": "BLOCK_NONE"
                    },
                    {
                        "category": "HARM_CATEGORY_HATE_SPEECH",
                        "threshold": "BLOCK_NONE"
                    },
                    {
                        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
                        "threshold": "BLOCK_NONE"
                    },
                    {
                        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
                        "threshold": "BLOCK_NONE"
                    },
                ],
                tools: {
                    functionDeclarations: [declaracionGuardarReceta, declaracionListarRecetas],
                },
            }, { apiVersion: 'v1beta', });
        this.sesionChat = cliente.startChat();
        this.sesionChat.sendMessage(mensajeSistema); // No mostramos la respuesta, así que no necesitamos hacer nada con la promesa
    }

    async _chat(nombreFuncion, respuestaFuncion) {
        const ret = await this.sesionChat.sendMessage([{ "functionResponse": { "name": nombreFuncion, "response": { recetas: respuestaFuncion } } }]);
        return ret.response.text();
    }

    async chat(mensajeUsuario) {
        const respuesta = await this.sesionChat.sendMessage(mensajeUsuario);
        let mensajeModelo;
        if (respuesta.response.functionCall()) {
            const fc = respuesta.response.functionCall();
            console.log("-- función: " + fc.name);
            if (fc.name === "listarRecetas") {
                mensajeModelo = await this._chat(fc.name, listarRecetas());
            } else if (fc.name === "guardarReceta") {
                mensajeModelo = await this._chat(fc.name, guardarReceta(fc.args.nombre, fc.args.ingredientes, fc.args.preparacion));
            } else {
                mensajeModelo = "Llamada a función!! " + fc.name; // No debería ocurrir
            }
        } else mensajeModelo = respuesta.response.text();
        return mensajeModelo;
    }
}

Varios puntos que comentar sobre este código:

  • En la llamada a getGenerativeModel informamos el parámetro tools, con las declaraciones de la dos funciones que deseamos que el modelo pueda usar. Adicionalmente, la librería de Gemini para JavaScript requiere en este momento que se declare el uso de la versión de la API v1beta; de no hacerlo, la llamada con las declaraciones de funciones fallaría, ya que las llamadas a funciones no están soportadas por la v1 de la API.
  • La especificación de los filtros de seguridad (safetySettings) ayuda a que Gemini bloquee menos la conversación en caso de recetas picantes, o, posiblemente, en caso de que incluyan comentarios sobre tamaño de melones o pepinos.
  • Con la llamada startChat() inicializamos una clase ChatSession que nos ahorra tener que mantener el histórico de la conversación. Si quisiéramos mantener más control sobre esto y gestionarlo manualmente, podríamos cambiarlo y usar en su lugar el servicio generateContent.
  • Las respuestas (salvo casos de error, que este código no se mete en controlar) incluirán o un texto o una llamada a función. Nuestro código debe controlar los casos en que la respuesta sea una llamada a función, ejecutar la función adecuada, pasar la respuesta de la función al modelo (lo cual hace el método _chat) usando un mensaje de tipo functionResponse, y la respuesta del modelo a este segundo mensaje será el que se muestre al usuario. En un caso normal, salvo porque la respuesta tardará algo más, el usuario no se dará cuenta de que se ha producido una llamada a función, aunque en este caso, excepcionalmente, he optado por mantener un mensaje en pantalla cuando se produce una llamada a función.

Interfaz del chat

Por último, tan sólo resta el cuerpo del script, que recogerá el mensaje de sistema de un archivo, instanciará la clase ClienteGemini, y mostrará en bucle la interfaz del chat:

import * as readline from "node:readline/promises";
import { GoogleGenerativeAI } from "@google/generative-ai";
import fs from "fs";
import Database from 'better-sqlite3';

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
});

let cliente = new ClienteGemini("gemini-1.0-pro", fs.readFileSync("olivia.md", "utf8"));
let texto_user;
while (texto_user = (await rl.question('¿Qué le quieres decir a Olivia?\n> ')).trim()) {
    console.log(await cliente.chat(texto_user));
}
rl.close();

¡Y con eso ya tenemos a Olivia terminada! Espero que resulte útil, pero sobre todo que haya quedado claro con este ejemplo cómo usar la funcionalidad de llamadas a funciones, que puede ampliar muchísimo lo que podremos hacer con nuestras aplicaciones basadas en grandes modelos de lenguaje.

Como siempre, el código completo se puede encontrar en GitHub.

Comentarios finales

Cuando empecé a hacer pruebas con llamadas a funciones, en preparación para escribir esta entrada, todo funcionaba excepcionalmente bien. Gemini llamaba a las funciones en el momento justo y toda la interacción resultaba muy natural.

Sin embargo, recientemente me estoy encontrando con que Gemini me está respondiendo como si hubiera llamado a las funciones («Ya he guardado la receta.», «Estas son las recetas que tenemos guardadas …») cuando en realidad no es verdad. Ha habido veces en que he tenido que insistirle varias veces para que finalmente hiciera la llamada a la función, mientras que ha habido otras veces que ni aún así he conseguido que lo hiciera. Curiosamente, el modelo gemini-1.0-pro es el que mejor resultado me está dando ahora mismo, por delante de gemini-1.5-pro y de gemini-1.5-flash.

La razón por la que elegí la API de Gemini a la hora de escribir esta entrada fue que recientemente anunciaron un concurso para desarrolladores, y me pareció una buena oportunidad para investigar sus posibilidades con más profundidad. Desafortunadamente, en su estado actual no recomendaría el uso de Gemini como base para desarrollar mis aplicaciones. Esperemos que en alguna revisión próxima la situación mejore.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *