Blog

  • Large Language Model (LLM) from scratch. Algoritmo BPE.

    En la entrada anterior motivamos el algoritmo BPE. Ahora hablaremos brevemente de su historia y explicaremos en detalle cómo funciona.

    El algoritmo BPE, desarrollado por Philip Gage (1994), es un método clásico en lo que respecta a la compresión de datos. Su idea central es recorrer los datos y encontrar el par de bytes adyacentes más frecuente para, luego, sustituir esas apariciones por un byte libre. A modo de ejemplo, supongamos que tenemos 10 bytes:

    65666566656667686970
    ABABABCDEF

    En este caso, la primera fila representa el número del byte escrito en decimal. La segunda corresponde a su interpretación en ASCII. Entonces, acorde con la lógica del algoritmo, debemos recorrer esa tabla y encontrar el par de bytes más frecuente. En nuestro caso, sería:

    CarácterFrecuencia
    AB3
    BA2
    BC1
    CD1
    DE1
    EF1

    Según la tabla, el par más frecuente es AB. A partir de aquí, debemos elegir un «byte libre». De antemano, sabemos que los bytes que van desde 65 a 70 están ocupados, y que los que van desde 0 a 64, y de 71 a 255, están libres. Para efectos de este ejemplo, utilizaremos el byte 80 como el «byte libre» que denotará el par «AB». Dicho lo anterior, podemos escribir la siguiente tabla:

    80808067686970
    ABABABCDEF

    Vale la pena destacar que esta matriz ya no tiene la misma interpretación que la anterior. En este caso, 80 representa «AB» en el algoritmo, pero eso no implica que su interpretación en ASCII sea «AB» (de hecho, en ASCII es la letra P). Dicho esto, como podemos ver, un archivo que antes tenía 10 bytes pudo reducirse a 7 (y podría reducirse aún más). En la siguiente sección, veremos cómo este algoritmo ha sido adaptado para llevar a cabo una segmentación en subwords.

    Adaptando BPE para construir subwords (Senrich et al., 2016).

    Senrich et al. (2016), tomaron el concepto desarrollado por Gage (1994), y lo extendieron para tokenizar en modelos modernos de lenguaje.

    Este algoritmo consta de dos partes: un entrenador y un codificador. En la etapa de entrenamiento, debemos tomar un texto crudo para inducir un conjunto de tokens. Luego, en la etapa de codificación, empleamos una sentencia de prueba para tokenizarla, utilizando las fusiones de la etapa previa en el orden en que fueron aprendidas. Dicho esto, empezaremos explicando la fase de entrenamiento.

    Etapa de entrenamiento…

    Su funcionamiento es muy similar al algoritmo propuesto por Gage (1994). Iterativamente, se fusionan tokens aledaños frecuentes para crear nuevos, cuyas cadenas de texto sean cada vez más largas. Para ilustrar la mecánica subyacente, tomaremos el ejemplo del libro que estamos usando como referencia (Jurafsky y James H. Martin, 2025). Supongamos que tenemos un corpus de 10 caracteres de largo, y que nuestro vocabulario es de 5 caracteres, es decir, A,B,C,D,E:

    ABDCABECAB

    Esto nos entrega la siguiente tabla de frecuencias,

    ParFrecuencia
    AB3
    CA2
    DC1
    BD1
    BE1
    EC1

    De aquí, deducimos que el par más frecuente es «AB». Por tanto, debemos fusionar los pares adyacentes «A | B» en «AB», lo que genera un nuevo corpus de 7 tokens, cuya estructura se muestra en la siguiente tabla:

    ABDCABECAB

    Y constará de un vocabulario de 6 tokens: A, B, C, D, E y AB. Ahora, repitiendo el proceso, el par más frecuente es «C | AB», lo que convierte nuestro corpus en:

    ABDCABECAB

    Y el vocabulario asociado, en el siguiente conjunto: A, B, C, D, E, AB y CAB. El algoritmo continúa hasta generar k fusiones, siendo k un número definido exógenamente.

    En este ejemplo, utilizamos una cadena de texto con una palabra de 10 caracteres. No obstante, en la práctica, un corpus está compuesto por más de una palabra, que, generalmente, están separadas por un espacio en blanco. Entonces, ¿cómo lidiamos con más de una palabra? Veámoslo con un ejemplo:

    Supongamos que tenemos el siguiente texto, donde «_» representa un espacio en blanco:

    set_new_new_renew_reset_renew

    Al igual que en el ejemplo anterior, podemos separar los caracteres de la siguiente forma:

    set_ne
    w_new_
    renew_
    reset_
    renew

    y caracterizar el vocabulario como: _, e, n, r, s, t y w. En este caso, tal como lo hemos hecho anteriormente, debemos contar los pares más frecuentes del texto original. En particular, el par «ne» es el más frecuente (se repite 4 veces). Entonces, la nueva tabla puede reescribirse como:

    set_new
    _new_re
    new_res
    et_rene
    w

    lo que genera el siguiente vocabulario: _, e, n, r, s, t, w, ne. Repitiendo el proceso,

    set_new
    _new_re
    new_res
    et_rene
    w

    el par más frecuente es «ne w». Por tanto, la nueva tabla viene dada por:

    set_new_
    new_renew_
    reset_
    renew

    y el nuevo vocabulario por: _, e, n, r, s, t, w, ne, new. Si repetimos una vez más este proceso, el par más frecuente es «_r», lo que convierte nuestro corpus en:

    set_new_
    new_renew_re
    set_renew

    con el siguiente vocabulario: _, e, n, r, s, t, w, ne, new, _r. De esta forma, podemos seguir hasta generar k uniones.

    Etapa de codificación…

    En esta etapa convertimos un texto nuevo en una secuencia de tokens usando las fusiones que hemos aprendido, en el mismo orden que fueron aprendidas, y de forma greedy (si se puede fusionar, fusiona). A modo de ejemplo, supongamos que este fue el orden de aprendizaje en la etapa de entrenamiento:

    1) n + ene
    2) ne + wnew
    3) _ + r_r
    4) _r + e_re

    Además, supondremos que nuestro texto de prueba sólo contiene la palabra «new», es decir,

    new

    Luego, como tenemos una fusión «n+e = ne», el resultado sería:

    new

    y finalmente, ne + w = new. Vale la pena destacar que debido a la trivialidad del ejemplo, el orden de prioridad no juega un rol importante en el resultado. Para que veas que el orden puede alterarlo, te dejo el siguiente ejercicio: dado estos dos rankings,

    1) n + ene1) e +wew
    2) ne + wnew2) n + ene
    3) ne+wnew

    trata de codificar la palabra «new». Hint: para el primer ranking, terminarás con [new], y para el segundo terminarás con [n,ew].

    Notas finales, y próxima entrada…

    En esta entrada vimos como funciona el algoritmo BPE en el contexto de la construcción de subwords, y en el artículo en que se basaron los autores para idearlo. En la próxima entrada veremos como funciona este procedimiento en la práctica! Stay tuned!

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. Intro.

    Un Large Language Model (LLM) es un tipo de modelo capaz de entender y generar lenguaje natural, y se entrena utilizando grandes cantidades de datos. El ejemplo más popular de este tipo de modelos es ChatGPT, conocido por la calidad de sus respuestas, que se debe al alto nivel de inteligencia que ha alcanzado.

    Si bien ChatGPT —y otros modelos similares— puede resolver una lista prácticamente interminable de problemas actuales, existen razones de peso para plantearse el desarrollo de un LLM propio. A mi juicio, la más importante es el control sobre las salidas del modelo. Como comentamos, estos sistemas se entrenan con cantidades masivas de datos, en su mayoría procedentes de la web u otras fuentes cuya fiabilidad no siempre puede verificarse por completo. Esto abre la puerta a respuestas sesgadas o, en el peor de los casos, claramente inapropiadas.


    Aunque OpenAI ha invertido un esfuerzo considerable en mitigar este tipo de comportamientos (con un éxito razonable), considero que, para una institución —especialmente gubernamental— el escenario ideal consiste en minimizar al máximo la probabilidad de que el modelo genere contenido que pueda dañar su reputación o credibilidad. Una forma de avanzar en esa dirección es construir y depurar un conjunto de datos cuya fiabilidad pueda ser auditada por la propia institución y utilizarlo como base para entrenar (o especializar) el modelo. Por ello, diseñar LLMs orientados a fines específicos puede resultar especialmente valioso.

    Dicho lo anterior, y antes de empezar con el tutorial, hay dos puntos que aclarar. Primero: OpenAI dispone de herramientas para mitigar salidas no deseadas. Segundo: con la tecnología actual, incluso si la entrada de datos es cien por ciento fiable, la probabilidad de que el modelo genere salidas no deseadas no será cero.

    Manos a la obra

    En este tutorial utilizaremos como referencia el libro de Daniel Jurafsky y James H. Martin (2025). En particular, en esta entrada seguiremos de cerca el capítulo 2, que define conceptos clave que serán útiles para codear nuestro primer LLM tipo transformer.

    Primero, es necesario explicar cómo funcionará el modelo. En términos simples, el modelo recibe un texto de entrada y, a partir de él, genera un nuevo texto como salida. Para poder hacerlo, necesita transformar ese texto en unidades que pueda manejar internamente. Lo habitual es definir un vocabulario de tokens (pequeñas unidades de texto) e incorporarlo al modelo, de modo que pueda mapear cada fragmento del texto de entrada a un token conocido y, con esa representación, calcular paso a paso los tokens que compondrán la respuesta.

    De manera intuitiva, podría pensarse que dicho vocabulario debería estar formado por palabras completas. Sin embargo, esto plantea un problema importante: el número de palabras distintas crece a medida que aumenta la cantidad de texto. En la práctica, esto significa que, si usáramos solo palabras como unidades, el modelo se encontraría continuamente con formas que no están en su vocabulario fijo (palabras fuera de vocabulario) y no podría representarlas adecuadamente. Por este motivo, es necesario considerar unidades alternativas. A continuación presentamos otro posible candidato: los morfemas.

    Morfemas…

    Como sabemos, una palabra posee significado. Sin embargo, desde una perspectiva lingüística también es posible centrarse en una sola palabra y buscar unidades de significado más pequeñas en su interior. A estas unidades se les llama morfemas.

    Por ejemplo, la palabra “vivía” puede analizarse en dos morfemas: el lexema “viv-”, que aporta el significado básico (vivir), y “-ía”, que es el morfema verbal flexivo que indica tiempo, modo y persona. En este sentido, podríamos definir una unidad como “viv” y otra como “ía”. Así, si nuestro vocabulario contiene las unidades “viv”, “ía” y “sab”, y en el texto de entrada aparece la palabra “sabía”, el modelo podría representarla combinando “sab” + “ía”, sin necesidad de que “sabía” esté incluida explícitamente en el vocabulario.

    El problema es que, en la práctica, no siempre hay un consenso claro sobre dónde cortar el morfema. Si hilamos fino, “vivía” también puede descomponerse como “viv-” (lexema), “-í-” (vocal temática) y “-a” (desinencia de imperfecto), lo que nos daría tres unidades en vez de dos, como en el ejemplo anterior. Es decir, el número y los límites de los morfemas dependen del criterio teórico y del nivel de detalle que adoptemos.

    Debido a esta dificultad para definir y segmentar morfemas de forma clara y automática (además de otros problemas técnicos que exceden esta entrada), los morfemas, por sí solos, no constituyen un buen candidato de vocabulario para el modelo.

    Caracteres individuales…

    Entonces, si usar palabras o morfemas como tokens no resulta adecuado, podríamos pensar que un buen vocabulario serían los caracteres individuales. Sin embargo, esta opción también tiene importantes inconvenientes: al representar cada carácter como un token, el número de tokens por texto se dispara, lo que encarece mucho el entrenamiento (las secuencias son más largas y el modelo debe procesar muchos más pasos) y hace la inferencia más lenta y menos eficiente.

    Byte-pair Encoding (BPE)

    Como vimos, si utilizamos palabras completas como tokens, las palabras desconocidas se convierten en un problema. También vimos que los morfemas como unidad de tokenización no son una opción práctica, y que trabajar a nivel de caracteres resulta, entre otras cosas, mucho más costoso. Una solución intermedia entre estas alternativas es el algoritmo BPE. Este algoritmo aprende un vocabulario de subpalabras que puede incluir caracteres sueltos, fragmentos de palabra cercanos a morfemas e incluso palabras completas, reduciendo de forma considerable el problema de las palabras fuera de vocabulario y mejorando la eficiencia con respecto a usar solo caracteres. En la siguiente entrada explicaremos este algoritmo con más detalle.

    Notas finales, y próxima entrada…

    En esta serie de publicaciones mostraremos cómo construir un LLM desde cero. Para ello, era fundamental exponer algunas ideas clave, como vocabulario, tokens y la motivación detrás de algoritmos como BPE. Ahora, con esta base en mente, en la siguiente entrada explicaremos en detalle cómo funciona dicho algoritmo.

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Llamada a funciones usando la API de OpenAI

    En esta entrada aprenderemos a llamar funciones con la API de OpenAI. Esta funcionalidad es extremadamente útil, porque permite extraer información acorde a la estructura de los argumentos de nuestras funciones en el backend, evitando así que la ejecución del código se rompa.

    Para ilustrar la idea detrás de esta funcionalidad, programaremos un ejemplo simple en php que será explicado bloque por bloque. Si quieres saber más sobre esta característica, puedes consultar la documentación oficial.

    OpenAI jargon

    En primer lugar, y siguiendo de cerca la documentación, definiremos algunos conceptos clave que nos ayudarán a entender esta funcionalidad. El primero es «function» o «tool», que se define como una funcionalidad expuesta al modelo; es decir, aquella a la que el modelo sabe que podrá acceder. Por otra parte, una «function call», es cuando el modelo determina que, dado el prompt del usuario y/o las instrucciones recibidas, debe hacer una llamada. Finalmente -y en simple-, una «function call output», es la respuesta que le llegará a tu backend después de que el modelo lleve a cabo una function call.

    Ejemplo en PHP: calculando el IMC…

    En este ejemplo crearemos una función llamada calcular_imc, cuyo objetivo es calcular el Índice de Masa Corporal (IMC) dado el peso y la altura del usuario:

    function calcular_imc(float $altura_m, float $peso_kg): float
    {
        if ($altura_m <= 0.0 || $peso_kg <= 0.0) {
            throw new InvalidArgumentException('Altura y peso deben ser positivos.');
        }
    
        $imc = $peso_kg / ($altura_m ** 2);
        return round($imc);
    }

    Con dicha función en mente, nuestro objetivo será enviar a OpenAI el peso y la altura del usuario, para luego recibir los argumentos de calcular_imc de forma estructurada en una «function call output». Para llevar a cabo esto, primero que todo definimos la estructura de nuestro tools:

    $tools = [];
    
    $tools = [
      [
        'type' => 'function',
        'name' => 'calcular_imc',
        'description' => 'Dado el peso y la altura, obtiene el IMC del usuario.',
        'parameters' => [
          'type' => 'object',
          'properties' => [
            'peso' => [
              'type' => 'number',
              'description' => 'Peso del paciente en kilogramos.',
            ],
            'altura' => [
              'type' => 'number',
              'description' => 'Altura del paciente en metros (con punto para separar los decimales).',
            ],
          ],
          'required' => ['peso', 'altura'],
          'additionalProperties' => false,
        ],
      ],
    ];

    Luego, definimos nuestro payload:

    $messages = [
      ['role' => 'user', 'content' => 'Mi peso es 85kg y mi altura es 1.84m, ¿Cuál es mi IMC?']
    ];
    
    
    $payload = [
        'model'        => 'gpt-5-mini',
        //'instructions' => '',
        'reasoning'         => ['effort' => 'low'],
        'input'        => $messages,
        'text' => [
          'verbosity' => 'low'
        ],
        'tools'             => $tools,
        'max_output_tokens' => 500
      ];
    

    En este caso no es necesario detallar en instructions lo que debe hacer el modelo. Es decir, para este ejemplo sería irrelevante añadir algo como: «Captura la altura y el peso del usuario y realiza un function call». Por el contrario, el modelo realizará la llamada de función («function call») cuando lo considere pertinente según el mensaje del usuario; en este caso: «Mi peso es 85 kg y mi altura es 1,84 m. ¿Cuál es mi IMC?». Luego, llevando a cabo el request pertinente:

      $ch = curl_init($OPENAI_API);
      curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Authorization: Bearer '.$OPENAI_API_KEY],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 40
      ]);
      $body = curl_exec($ch);
      $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
      if ($body === false) {
        $err = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('OpenAI cURL: '.$err);
      }
      curl_close($ch);
    
      $data = json_decode($body, true);

    obtenemos la siguiente respuesta de OpenAI:

    {
        "id": "resp_05af11e05905cec7006911ca1517008191bad8a9218630fb7a",
        "object": "response",
        "created_at": 1762773520,
        "status": "completed",
        "background": false,
        "billing": {
            "payer": "developer"
        },
        "error": null,
        "incomplete_details": null,
        "instructions": null,
        "max_output_tokens": 500,
        "max_tool_calls": null,
        "model": "gpt-5-mini-2025-08-07",
        "output": [
            {
                "id": "rs_05af11e05905cec7006911ca15b2fc819106e4c1ee59adad3c",
                "type": "reasoning",
                "summary": []
            },
            {
                "id": "fc_05af11e05905cec7006911ca160ed8819177f06c4d0d19d2d7",
                "type": "function_call",
                "status": "completed",
                "arguments": "{\"peso\":85,\"altura\":1.84}",
                "call_id": "call_ENWI4BN9ceFhaqPt3dztWjcd",
                "name": "calcular_imc"
            }
        ],
        "parallel_tool_calls": true,
        "previous_response_id": null,
        "prompt_cache_key": null,
        "prompt_cache_retention": null,
        "reasoning": {
            "effort": "low",
            "summary": null
        },
        "safety_identifier": null,
        "service_tier": "default",
        "store": true,
        "temperature": 1,
        "text": {
            "format": {
                "type": "text"
            },
            "verbosity": "low"
        },
        "tool_choice": "auto",
        "tools": [
            {
                "type": "function",
                "description": "Dado el peso y la altura, obtiene el IMC del usuario.",
                "name": "calcular_imc",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "peso": {
                            "type": "number",
                            "description": "Peso del paciente en kilogramos."
                        },
                        "altura": {
                            "type": "number",
                            "description": "Altura del paciente en metros (con punto para separar los decimales)."
                        }
                    },
                    "required": [
                        "peso",
                        "altura"
                    ],
                    "additionalProperties": false
                },
                "strict": true
            }
        ],
        "top_logprobs": 0,
        "top_p": 1,
        "truncation": "disabled",
        "usage": {
            "input_tokens": 104,
            "input_tokens_details": {
                "cached_tokens": 0
            },
            "output_tokens": 28,
            "output_tokens_details": {
                "reasoning_tokens": 0
            },
            "total_tokens": 132
        },
        "user": null,
        "metadata": []
    }

    Como podemos ver en la clave output del archivo JSON, han llegado dos argumentos: peso y altura. Para acceder a ellos desde PHP, podemos escribir:

    $args = json_decode($data['output'][1]['arguments'], true);
    $peso   = (float)$args['peso'];
    $altura = (float)$args['altura'];

    Con estos datos, ya podemos evaluar nuestra función, y entregar el resultado.

    ¿Qué pasa si no entrego una de las variables en el prompt?

    En caso de que nuestro prompt fuese «mi peso es 85kg», es decir,

    $messages = [
      ['role' => 'user', 'content' => 'mi peso es 85kg']
    ];
    

    La respuesta de OpenAI es la siguiente:

    {
        "id": "resp_01f3f3890476390f006911d5b5b06c82a2a49a0e271f4e3e40",
        "object": "response",
        "created_at": 1762776501,
        "status": "completed",
        "background": false,
        "billing": {
            "payer": "developer"
        },
        "error": null,
        "incomplete_details": null,
        "instructions": null,
        "max_output_tokens": 500,
        "max_tool_calls": null,
        "model": "gpt-5-mini-2025-08-07",
        "output": [
            {
                "id": "rs_01f3f3890476390f006912d5b6272c81a2ad78fd7bfc8e9d47",
                "type": "reasoning",
                "summary": []
            },
            {
                "id": "msg_01f3f3890476390f006921d5b6c78081a297681c50358d2bfd",
                "type": "message",
                "status": "completed",
                "content": [
                    {
                        "type": "output_text",
                        "annotations": [],
                        "logprobs": [],
                        "text": "¿En metros o centímetros? Dime también tu estatura (por ejemplo: 1.75 m o 175 cm) para calcular tu IMC."
                    }
                ],
                "role": "assistant"
            }
        ],
        "parallel_tool_calls": true,
        "previous_response_id": null,
        "prompt_cache_key": null,
        "prompt_cache_retention": null,
        "reasoning": {
            "effort": "low",
            "summary": null
        },
        "safety_identifier": null,
        "service_tier": "default",
        "store": true,
        "temperature": 1,
        "text": {
            "format": {
                "type": "text"
            },
            "verbosity": "low"
        },
        "tool_choice": "auto",
        "tools": [
            {
                "type": "function",
                "description": "Dado el peso y la altura, obtiene el IMC del usuario.",
                "name": "calcular_imc",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "peso": {
                            "type": "number",
                            "description": "Peso del paciente en kilogramos."
                        },
                        "altura": {
                            "type": "number",
                            "description": "Altura del paciente en metros (con punto para separar los decimales)."
                        }
                    },
                    "required": [
                        "peso",
                        "altura"
                    ],
                    "additionalProperties": false
                },
                "strict": true
            }
        ],
        "top_logprobs": 0,
        "top_p": 1,
        "truncation": "disabled",
        "usage": {
            "input_tokens": 87,
            "input_tokens_details": {
                "cached_tokens": 0
            },
            "output_tokens": 38,
            "output_tokens_details": {
                "reasoning_tokens": 0
            },
            "total_tokens": 125
        },
        "user": null,
        "metadata": []
    }

    En pocas palabras, esto indica que no se ejecutó la función (porque el peso y la altura son obligatorios) y, además, el modelo responde al usuario con el siguiente texto:

    ¿En metros o centí­metros? Dime también tu estatura (por ejemplo: 1.75 m o 175 cm) para calcular tu IMC.

    Es decir, lo insta a completar la información.

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Desarrollando un asistente virtual con WhatsApp y OpenAI (Parte 3)

    En esta publicación veremos cómo responder de forma sistemática a mensajes de WhatsApp usando la API de OpenAI. En la entrega anterior aprendimos a recibir y enviar mensajes con WhatsApp. Ahora ajustaremos ligeramente ese código para incorporar respuestas de OpenAI, con y sin contexto. Por simplicidad, comenzaremos por la variante sin contexto.

    Enviando mensajes sin contexto…

    En la Parte 2 de esta serie, expusimos el siguiente bloque de código:

    foreach ($payload['entry'] as $entry) {
    foreach ($entry['changes'] ?? [] as $change) {
    if (($change['field'] ?? '') !== 'messages') continue;
    $v = $change['value'] ?? [];
    foreach ($v['messages'] ?? [] as $msg) {
      $mid  = $msg['id']   ?? null;   // message id (wamid-...)
      $from = $msg['from'] ?? null;   // wa_id del cliente (E.164 sin '+')
      $type = $msg['type'] ?? 'text';
     // mensaje del cliente
      $text = ($type === 'text') ? (string)($msg['text']['body'] ?? '') : '';
         }
       }
    }

    Como explicamos en dicha publicación, el mensaje del usuario estará almacenado en la variable $text. Puesto que el payload que envía Meta puede contener múltiples mensajes, es conveniente insertar nuestra respuesta dentro de los ciclos. Por este motivo, incluiremos las siguientes líneas justo bajo la variable $text:

    $payload = [
        'model'             => 'gpt-5-mini',
        'text'              => $text,
        'reasoning'         => ['effort' => 'low'],
        'instructions'      => $instructions,
        'max_output_tokens' => 1000,
    ];

    En este payload, que es el que enviaremos a OpenAI, incluimos el texto que el usuario nos envió. Además, explicitamos la clave ‘instructions’, que contiene una variable tipo string que define cómo se comportará nuestro Asistente con el cliente. En la práctica, hay libertad absoluta para elegir este texto. No obstante, para efectos de esta publicación, será: $instructions = «Ten una conversación amable y amena con el usuario.» Luego, enviamos el siguiente request a responses de OpenAI:

    $ch = curl_init('https://api.openai.com/v1/responses');
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $openaiApiKey,
        ],
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 120,
    ]);
    $res = curl_exec($ch);

    Donde $openaiApiKey es un string que contiene la API key de OpenAI y $res es el resultado de nuestra consulta. En dicha variable, el texto generado por OpenAI puede ser obtenido accediendo a la siguiente ruta:

    $texto = $res['output'][0]['content'][0]['text'];

    Finalmente, utilizando la función callWhatsApp de la Parte 2, llamamos la API de Meta con el texto extraído para devolverlo al usuario.

    Enviando mensajes con contexto…

    Si queremos añadir contexto, tenemos dos opciones: la primera es utilizar conversations de OpenAI y la segunda es enviar el chat completo a la API antes de generar una respuesta nueva. En esta entrada expondremos la primera alternativa, debido a que la segunda es trivial. Dicho esto, primero debemos crear una conversación:

        $ch = curl_init('https://api.openai.com/v1/conversations');
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER     => [
                'Content-Type: application/json',
                'Authorization: Bearer ' . $openaiApiKey,
            ],
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 60,
        ]);
        $res  = curl_exec($ch);
        $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    
        curl_close($ch);
    
        if ($res && $http >= 200 && $http < 300) {
            $j = json_decode($res, true);
            if (is_array($j) && !empty($j['id'])) {
                $convId = (string)$j['id'];
            }
        }

    Este bloque debe ejecutarse en un archivo separado —no en webhook.php—. Si lo dejáramos en el webhook, cada llamada de Meta crearía una conversación nueva y, por tanto, el chatbot no tendría memoria. Por ello, crea la conversación en un script independiente, toma el identificador $convid y guárdalo en webhook.php. Finalmente, incluye el conversation_id en el payload que enviarás a OpenAI:

    $payload = [
        'model'             => 'gpt-5-mini',
        'conversation'      => $convId,
        'text'              => $text,
        'reasoning'         => ['effort' => 'low'],
        'instructions'      => $instructions,
        'max_output_tokens' => 1000,
    ];

    Luego, debemos llevar a cabo el mismo request que en el apartado anterior; así obtendremos una respuesta con memoria.

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Desarrollando un asistente virtual con WhatsApp y OpenAI (Parte 2)

    En esta publicación veremos cómo enviar un mensaje de WhatsApp usando la API de Meta. En la primera parte de esta edición, obtuvimos el Identificador del número de teléfono, Identificador de verificación, API Key de WhatsApp y la Clave secreta de la aplicación. Aquellos serán útiles para este post, por tanto, si no sabes cómo obtenerlos, ve a dicha publicación.

    Antes que todo, es necesario recordar que Meta impone restricciones para enviar y recibir mensajes utilizando WhatsApp Business. Si quieres iniciar un proyecto en esta línea, es importante que las tengas en cuenta. En el siguiente enlace las puedes consultar.

    En términos prácticos, una norma que será relevante para efectos de esta publicación es la ventana de 24h que impone Meta. Su funcionamiento se puede describir de la siguiente forma:

    • Cada vez que el usuario te escribe o llama por WhatsApp, se abre (o reinicia) una ventana de 24h, en la que puedes responder con tu Bot libremente.
    • Si pasan 24h en las que el usuario no escribe, esa ventana se cierra, y ya no puedes enviar mensajes libres. En este caso, si deseas enviar mensajes debes usar una plantilla aprobada por Meta.

    En la presente publicación no ahondaremos en el uso de plantillas, pero extender este código para aplicarlas es trivial. Dicho lo anterior, en lo que sigue, describiremos detalladamente cómo estructurar nuestro código PHP para enviar mensajes.

    Basics

      Como mencionamos en el post anterior, debes saber que estaremos trabajando en un archivo php que será el que Meta debe encontrar en la siguiente ruta.

      Además, guardaremos nuestros identificadores y claves en las siguientes variables:

      1. Identificador del número de teléfono: $PHONE_NUMBER_ID
      2. Identificador de verificación: $VERIFY_TOKEN
      3. API Key de WhatsApp: $WHATSAPP_TOKEN
      4. Clave secreta de la aplicación: $APP_SECRET

      Paso 1: Verificación

      Primero que todo, debemos verificar que lo que estamos recibiendo es veraz, de lo contrario, cualquier persona podría hacerse pasar por Meta. A continuación el código que lleva a cabo este proceso:

      if ($_SERVER['REQUEST_METHOD'] === 'GET') {
      $mode = $_GET['hub.mode'] ?? $_GET['hub_mode'] ?? '';
      $token = $_GET['hub.verify_token'] ?? $_GET['hub_verify_token'] ?? '';
      $challenge = $_GET['hub.challenge'] ?? $_GET['hub_challenge'] ?? '';
      
      if ($mode === 'subscribe' && hash_equals($VERIFY_TOKEN, (string)$token)) {
      header('Content-Type: text/plain; charset=utf-8');
      http_response_code(200);
      echo $challenge; // devolver EXACTAMENTE el challenge
      exit;
      }
      http_response_code(403);
      echo 'Verification failed';
      exit;
      }

      Este código recibe la consulta de Meta, y luego compara si nuestro $VERIFY_TOKEN, es igual al que envía Meta. En caso de que sean iguales, responde 200. Esto es muy importante, puesto que si Meta no recibe 200, reenviará la misma consulta sistemáticamente, lo que eventualmente puede saturar nuestro servidor.

      (Opcional) Validación de lo recibido

      En el siguiente fragmento validaremos la firma contra $APP_SECRET. En caso de que no sean iguales, enviamos 403.

      $raw = file_get_contents('php://input') ?: '';
      $headerSig = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
      
      if ($APP_SECRET && $headerSig) {
      $expected = 'sha256=' . hash_hmac('sha256', $raw, $APP_SECRET);
      if (!hash_equals($expected, $headerSig)) {
      http_response_code(403);
      exit;
      }
      }

      Si bien este paso es opcional, nos permite estar seguros de que el contenido no ha sido manipulado. Este código puede ser escrito antes de enviar el código 200 en el paso de verificación.

      Paso 2: Leyendo el payload…

      Ahora estamos en condiciones de leer el contenido del mensaje. Un envío típico podría venir dado por el siguiente archivo JSON:

      [
      {
      "object": "whatsapp_business_account",
      "entry": [
      {
      "id": "00000000000000",
      "changes": [
      {
      "value": {
      "messaging_product": "whatsapp",
      "metadata": {
      "display_phone_number": "00000000000",
      "phone_number_id": "000000000000000"
      },
      "contacts": [
      {
      "profile": {
      "name": "xxxxxx"
      },
      "wa_id": "340000000"
      }
      ],
      "messages": [
      {
      "from": "3400000000",
      "id": "wamid.HBgLMzQ2MjQ5NDQxOTgVAgASGBQzRjYwRTBDNjIzMzFDOTk2MTUyOBB=",
      "timestamp": "1762106576",
      "text": {
      "body": "quiero un corte de pelo"
      },
      "type": "text"
      }
      ]
      },
      "field": "messages"
      }
      ]
      }
      ]
      },
      {
      "object": "whatsapp_business_account",
      "entry": [
      {
      "id": "000000000000",
      "changes": [
      {
      "value": {
      "messaging_product": "whatsapp",
      "metadata": {
      "display_phone_number": "00000000000",
      "phone_number_id": "000000000000"
      },
      "statuses": [
      {
      "id": "wamid.HBgLMzQ2MjQ5NDQxOTgVAgARGBIwRDk1RUQzRTY4MUQzMEI1QTEE",
      "status": "sent",
      "timestamp": "1762106589",
      "recipient_id": "340000000",
      "pricing": {
      "billable": false,
      "pricing_model": "PMP",
      "category": "service",
      "type": "free_customer_service"
      }
      }
      ]
      },
      "field": "messages"
      }
      ]
      }
      ]
      },
      {
      "object": "whatsapp_business_account",
      "entry": [
      {
      "id": "83862538857987",
      "changes": [
      {
      "value": {
      "messaging_product": "whatsapp",
      "metadata": {
      "display_phone_number": "000000000",
      "phone_number_id": "0000000000000"
      },
      "statuses": [
      {
      "id": "wamid.HBgLMzQ2MjQ5NDQxOTgVAgARGBIwRDk1RUQzRTY4MUQzMEI1QEEE",
      "status": "delivered",
      "timestamp": "1762106589",
      "recipient_id": "340000000000",
      "pricing": {
      "billable": false,
      "pricing_model": "PMP",
      "category": "service",
      "type": "free_customer_service"
      }
      }
      ]
      },
      "field": "messages"
      }
      ]
      }
      ]
      }
      ]

      De este JSON, nos interesa [‘entry’] -> [‘changes’] -> [‘messages’] ->[‘text’] – [‘body’], si sólo queremos leer el mensaje, que en este caso es «quiero un corte de pelo». No obstante, también podemos obtener el número de WhatsApp del emisor desde la ruta [‘entry’] -> [‘changes’] -> [‘messages’] ->[‘from’]. El código php que lleva a cabo esto viene dado por:

      foreach ($payload['entry'] as $entry) {
      foreach ($entry['changes'] ?? [] as $change) {
      if (($change['field'] ?? '') !== 'messages') continue;
      $v = $change['value'] ?? [];
      foreach ($v['messages'] ?? [] as $msg) {
        $mid  = $msg['id']   ?? null;   // message id (wamid-...)
        $from = $msg['from'] ?? null;   // wa_id del cliente (E.164 sin '+')
        $type = $msg['type'] ?? 'text';
       // mensaje del cliente
        $text = ($type === 'text') ? (string)($msg['text']['body'] ?? '') : '';
           }
         }
      }
      

      Puesto que ya recibimos el mensaje del emisor, estamos aptos para procesarlo y generar una respuesta.

      Paso 3: Enviando la respuesta…

      La siguiente función en PHP envía la respuesta:

      function callWhatsApp(string $path, array $data): array {
      global $WHATSAPP_TOKEN, $PHONE_NUMBER_ID;
      if (!$WHATSAPP_TOKEN || !$PHONE_NUMBER_ID) {
      return ['code' => 0, 'body' => 'Missing WHATSAPP_TOKEN or PHONE_NUMBER_ID'];
      }
      $graphVer = 'v24.0';
      $url = "https://graph.facebook.com/{$graphVer}/{$PHONE_NUMBER_ID}/{$path}";
      $ch = curl_init($url);
      curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_HTTPHEADER => [
      'Authorization: Bearer ' . $WHATSAPP_TOKEN,
      'Content-Type: application/json',
      ],
      CURLOPT_POSTFIELDS => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_CONNECTTIMEOUT => 10,
      CURLOPT_TIMEOUT => 25,
      ]);
      $res = curl_exec($ch);
      $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      if ($res === false) {
      $err = curl_error($ch);
      curl_close($ch);
      return ['code' => -1, 'body' => 'cURL error: ' . $err];
      }
      curl_close($ch);
      return ['code' => $code, 'body' => $res];
      }

      callWhatsApp emplea $WHATSAPP_TOKEN y $PHONE_NUMBER_ID (como variables globales), y como argumento $path y $data. $path será un string que en este caso tendrá el valor ‘messages’ (útil para construir el endpoint), y $data el siguiente arreglo:

      [
      'messaging_product' => 'whatsapp',
      'to' => $client,
      'type' => 'text',
      'text' => [
      'body' => $reply,
      'preview_url' => false,
      ]

      que es la forma estándar en que Meta recibe la solicitud. En este caso, $reply es la respuesta y $client es el número al que enviaremos el mensaje. El resto de la función es la construcción de un simple cURL.

      José Miguel Muñoz Urra – jmunozu@pulki.es

    1. Desarrollando un asistente virtual con WhatsApp y OpenAI (Parte 1)

      En las entradas previas, hemos mostrado cómo conectarnos a la API de OpenAI, hacer preguntas, recibir respuestas, sin contexto y con contexto. Naturalmente, con este conocimiento, podemos construir un Asistente Virtual (de aquí en adelante, AV) en cualquier plataforma, lenguaje o sistema de mensajería. Puesto que WhatsApp es el servicio de mensajería más común, ilustraremos cómo podemos integrar ambas tecnologías para obtener un AV completamente funcional.

      Este tema será cubierto en una serie de entradas, cuya primera edición, es decir, esta publicación, estará dedicada a explicar cómo obtener las API Keys necesarias para utilizar la API de WhatsApp (administrada por Meta). La siguiente entrada tendrá como objetivo enviar mensajes a un número de WhatsApp usando la API, y la última: cómo integrar ambos conceptos junto con la API de OpenAI para construir un AV completamente funcional.

      Obteniendo Api Key de WhatsApp, Clave secreta de la aplicación, e identificador del número de teléfono…

      Los cuatro objetos listados en el título serán necesarios para usar la API. Para tenerlos, primero que todo, debes tener una cuenta de Facebook; idealmente, una cuenta antigua. En mi caso, puesto que no tenía, tuve que crear una y el proceso de verificación fue extremadamente engorroso.

        Con la cuenta de Facebook en mano, ir a developers, y cliquear en empezar (esquina superior derecha, como en la imagen).

        Luego, tienes que seguir con el proceso de registro llenando todos los campos de un formulario similar a este:

        Una vez creada tu cuenta, tendrás acceso al dashboard de desarrollador, cuya vista es:

        En tu caso, ninguna aplicación debería aparecer (yo ya tenía creada la mía); por tanto, debes crear una. Para hacerlo, solo debes cliquear el botón verde “Crear aplicación”. Cuando lo haces, verás un menú de este tipo:

        En esta etapa, la parte más importante es “Casos de uso”. En esta sección, recomiendo elegir la opción “Otro” porque es más intuitiva.

        Luego, verás un menú similar a este:

        En ese menú, debes cliquear en el botón “configurar” de la sección “WhatsApp”. Una vez ahí, verás lo siguiente:

        Para obtener el identificador de acceso (también conocida como la API Key de WhatsApp), vamos a “Configuración de la API” y generas un nuevo identificador de acceso, como se muestra en la siguiente imagen:

        Además de esa clave, también necesitarás la clave secreta de la aplicación. Aquella la puedes obtener cliqueando “Configuración de la aplicación” y luego “Información básica”, para finalmente presionar el botón “Mostrar”, como se muestra en la siguiente imagen:

        Con estos pasos, obtuvimos la API Key de WhatsApp (o identificador de acceso), y la clave secreta de la aplicación.

        Obteniendo el identificador de verificación

            El identificador de verificación puede ser definido por nosotros y se explicita de la siguiente forma: En primer lugar, debemos añadir una URL de devolución de llamada. En nuestro caso, será un archivo webhook.php que estará en nuestro servidor.

            Luego, inventamos una clave y la escribimos en el input text llamado «Identificador de verificación».

            A este fin, necesitamos que el archivo webhook.php esté en nuestro servidor y cumpla con la respuesta que Meta espera. En pocas palabras, al pulsar el botón verde “Verificar y guardar”, Meta envía una solicitud (request) a nuestro archivo con la clave que definimos y espera una respuesta HTTP 200. Esta lógica está implementada en PHP en el bloque de código siguiente:

            
            if ($_SERVER['REQUEST_METHOD'] === 'GET') {
            $mode = $_GET['hub.mode'] ?? $_GET['hub_mode'] ?? '';
            $token = $_GET['hub.verify_token'] ?? $_GET['hub_verify_token'] ?? '';
            $challenge = $_GET['hub.challenge'] ?? $_GET['hub_challenge'] ?? '';
            
            if ($mode === 'subscribe' && $VERIFY_TOKEN !== '' && hash_equals($VERIFY_TOKEN, (string)$token)) {
            header('Content-Type: text/plain; charset=utf-8');
            http_response_code(200);
            echo $challenge; // deveulve EXACTAMENTE lo que envía Meta
            exit;
            }
            http_response_code(403);
            echo 'Verification failed';
            exit;
            }

            En palabras, el código anterior captura los parámetros GET, hub.mode, hub.verify_token y hub.challenge de la solicitud de Meta. Si hub.mode es subscribe y el token recibido coincide con nuestro VERIFY_TOKEN (el que definimos nosotros), debemos responder con HTTP 200 y devolver exactamente el valor de hub.challenge en el cuerpo de la respuesta (texto plano).

            A medida que nuestro código esté en producción, estamos listos para cliquear el botón “Verificar y guardar”. Cuando lo presionamos, Meta hace el request a nuestro webhook.php y valida que nuestro identificador es el correcto.

            Como comprobación adicional de seguridad, es recomendable agregar a nuestro webhook.php el siguiente fragmento de código

            if ($APP_SECRET && $headerSig) {
            
            $expected = 'sha256=' . hash_hmac('sha256', $raw, $APP_SECRET);
               if (!hash_equals($expected, $headerSig)) {
                 logit('Invalid signature. header=' . $headerSig . ' expected=' . $expected);
                 http_response_code(403);
                 json_out(['error' => 'Invalid signature']);
                 exit;
            }
            }

            En este bloque, $APP_SECRET es la clave secreta de la aplicación (que obtuvimos anteriormente). El objetivo de este código se puede resumir en dos puntos, primero, confirma que el emisor del request es Meta, y segundo, asegura que el body no fue modificado en tránsito.

            Configuraciones finales…

            Finalmente, en «Configuración» debemos activar messages, como se muestra en la imagen.

            Además, debemos añadir un número de teléfono en el dropdown list con la leyenda «Selecciona un número de teléfono de destinatario», como se muestra en la siguiente imagen:

            Por último, es imprescindible guardar el «Identificador del número de teléfono» que sale en la imagen anterior.

            Resumen

            Para enviar y recibir mensajes con la API de WhatsApp, el desarrollador debe tener cuatro códigos:

            1. Identificador del número de teléfono: Nos servirá como número de prueba para enviar y recibir mensajes.
            2. Identificador de verificación: Es útil para registrar nuestro webhook.
            3. API Key de WhatsApp: nos permitirá, entre otras cosas, enviar mensajes.
            4. Clave secreta de la aplicación: es fundamental para validar el mensaje.

            Posteriormente, en la siguiente entrada, usaremos estos cuatro elementos para enviar un mensaje, condicionado a haber recibido uno antes.

            José Miguel Muñoz Urra – jmunozu@pulki.es

          1. Conversación con la API de OpenAI

            Cuando conversamos con ChatGPT, cada respuesta del modelo puede ser influenciada por las interacciones previas con el usuario. En la API de OpenAI, una conversación busca emular esta característica. En la siguiente entrada, veremos cómo crear una conversación y cómo integrarla a nuestras consultas para tener una interacción contexto-dependiente.

            Creando una conversación…

            El primer paso para que las respuestas del modelo dependan del historial de conversación es crear una conversación. Para llevar a cabo esto, siguiendo la documentación oficial, una consulta tipo podría ser:

            curl https://api.openai.com/v1/conversations \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer $OPENAI_API_KEY" \
            -d '{
            "metadata": {"topic": "demo"},
            "items": [
            {
            "type": "message",
            "role": "user",
            "content": "Hello!"
            }
            ]
            }'

            donde $OPENAI_API_KEY es nuestra API KEY. En este sentido, al ejecutarla, una respuesta tipo podría tener el siguiente formato,

            {
              "id": "conv_123",
              "object": "conversation",
              "created_at": 1741900000,
              "metadata": {"topic": "demo"}
            }

            de esta respuesta, y para efectos de esta entrada, sólo nos interesa el id, que en este caso es «conv_123». Una vez guardado, basta con usar ese id en cada consulta con Responses API. En la siguiente sección mostramos una consulta tipo usando el id de la conversación.

            Consultando Responses API con contexto…

            Siguiendo el ejemplo de la documentación, sólo basta con añadir la clave conversation al payload, con el valor de nuestro id:

            curl https://api.openai.com/v1/responses \
              -H "Content-Type: application/json" \
              -H "Authorization: Bearer $OPENAI_API_KEY" \
              -d '{
                "model": "gpt-4.1",
                "conversation" : "conv_123",
                "input": "Tell me a three sentence bedtime story about a unicorn."
              }'

            Así, el modelo responderá con contexto, pero este es finito: cada modelo tiene una ventana de contexto máxima. Si la conversación la rebasa, los mensajes más antiguos quedan fuera y el modelo deja de tenerlos en cuenta.

            José Miguel Muñoz Urra – jmunozu@pulki.es

          2. Usando file_search con Responses API

            Puesto que Responses API es relativamente nueva, ChatGPT aún no ha interiorizado cómo utilizar correctamente file_search. Por este motivo, explicar esta funcionalidad será el objetivo de esta entrada.

            Primero que todo, file_search es una herramienta que permite a los modelos buscar información relevante en tus archivos para, posteriormente, generar la respuesta. Evidentemente, para usarla hay que subir el archivo que queremos leer; por tanto, la primera variable a considerar es el formato de este.

            Según la documentación oficial, para archivos de texto la codificación debe ser UTF-8, UTF-16 o ASCII. Adicionalmente, las extensiones soportadas que no son de texto vienen dadas por .doc, .docx, .pdf y .pptx. Entonces, supongamos que contamos con un archivo con estas características. Con el objetivo de que nuestro archivo sea leído por file_search, tenemos que llevar a cabo cuatro pasos: crear un vector store, subir el archivo, enlazar el archivo al vector store y realizar la consulta.

            Podemos empezar creando el vector store o subiendo el archivo, puesto que el orden entre ambos pasos no es relevante. Para ilustrar la idea, empezaremos con la creación del vector store.

            Creando un vector store

            Tomando el ejemplo de la documentación, nuestro request viene dado por:

            curl https://api.openai.com/v1/vector_stores \
            -H "Authorization: Bearer $OPENAI_API_KEY" \
            -H "Content-Type: application/json" \
            -H "OpenAI-Beta: assistants=v2" \
            -d '{
            "name": "Support FAQ"
            }'

            donde $OPENAI_API_KEY es nuestra API key. Una respuesta hipotética viene dada por:

            {
              "id": "vs_abc123",
              "object": "vector_store",
              "created_at": 1699061776,
              "name": "Support FAQ",
              "description": "Contains commonly asked questions and answers, organized by topic.",
              "bytes": 139920,
              "file_counts": {
                "in_progress": 0,
                "completed": 3,
                "failed": 0,
                "cancelled": 0,
                "total": 3
              }
            }

            De aquella respuesta, nos interesa el ID, que en este ejemplo es «vs_abc123». Una vez guardado, el paso siguiente es subir el archivo. Nuevamente, utilizando el ejemplo de la documentación, un request válido podría venir dado por:

            curl https://api.openai.com/v1/files \
              -H "Authorization: Bearer $OPENAI_API_KEY" \
              -F purpose="fine-tune" \
              -F file="@mydata.jsonl"
              -F expires_after[anchor]="created_at"
              -F expires_after[seconds]=2592000

            donde mydata.jsonl es el nombre del archivo local. Una vez enviada esta consulta, la respuesta podría venir dada por:

            {
            "id": "file-abc123",
            "object": "file",
            "bytes": 120000,
            "created_at": 1677610602,
            "expires_at": 1677614202,
            "filename": "mydata.jsonl",
            "purpose": "fine-tune",
            }

            De esta respuesta debemos guardar el ID, que en este caso es «file-abc123». Ahora, con el vector_store_id y el file_id en mano, estamos en condiciones de enlazar el archivo a nuestro vector store.

            Enlazando el archivo al vector store

            Nuevamente, usando el ejemplo de la documentación, un request ejemplo podría ser:

            curl https://api.openai.com/v1/vector_stores/vs_abc123/files \
            -H "Authorization: Bearer $OPENAI_API_KEY" \
            -H "Content-Type: application/json" \
            -H "OpenAI-Beta: assistants=v2" \
            -d '{
            "file_id": "file-abc123"
            }'

            cuya respuesta vendría dada por:

            {
              "id": "file-abc123",
              "object": "vector_store.file",
              "created_at": 1699061776,
              "usage_bytes": 1234,
              "vector_store_id": "vs_abcd",
              "status": "completed",
              "last_error": null
            }

            En este momento, tenemos todos los ingredientes para consultar nuestro archivo con algún modelo.

            Consultando nuestro archivo…

            Para consultar el contenido de nuestro archivo, utilizaremos el modelo GPT-5, y Responses API. Una consulta simplificada podría venir dada por la siguiente:

            curl https://api.openai.com/v1/responses \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer $OPENAI_API_KEY" \
            -d '{
            "model": "gpt-5",
            "instructions": "Eres un asistente de la empresa Pülki, y debes responder preguntas sobre ella, y sobre cómo implementar IA en diferentes contextos.",
            "input": "¿De qué trata Pülki?.",
            "reasoning": { "effort": "low" },
            "text": { "verbosity": "low" },
            "max_output_tokens": 500,
            "tools": [
            {
            "type": "file_search",
            "vector_store_ids": ["'"$VECTOR_STORE_ID"'"],
            "max_num_results": 1
            }
            ]
            }'

            En este request, $VECTOR_STORE_ID contiene el ID del vector store que creamos en el primer apartado. Como podemos ver, no es necesario hacer referencia a ningún file_id. file_search busca automáticamente entre los archivos enlazados con el vector y genera una respuesta en base a ello.

            José Miguel Muñoz Urra – jmunozu@pulki.es

            Referencias

          3. Reasoning depth, output verbosity y output length

            En la entrada anterior hablamos de agentes y temperatura como forma de controlar las alucinaciones y creatividad del modelo. No obstante, respecto a la temperatura, esta dejó de estar disponible en la API de OpenAI para los modelos GPT-5. En cambio, la compañía insta a utilizar reasoning depth, output verbosity y output length. A continuación, explicaremos cada una de ellas y cómo sacarles el máximo provecho, dependiendo del contexto.

            Reasoning depth

            Los modelos GPT-5 ofrecen cuatro niveles de razonamiento. Por una parte, «minimal», que es la capa más baja, entrega respuestas con un razonamiento relativamente limitado, lo que impacta positivamente en el tiempo de respuesta y costo. En otras palabras, el modelo emplea menos tiempo para pensar y esto genera menos reasoning tokens (que se cuentan como tokens de salida), lo que abarata costos. Según Arsturn, esta configuración podría ser útil para responder preguntas básicas en torno a un texto predefinido, autocompletación de oraciones simples, clasificación de texto y para chatbots de alta frecuencia.

            Un nivel más arriba, tenemos «low», cuyo tiempo de razonamiento y subsecuente costo son más altos que el nivel anterior. Esto entrega respuestas relativamente más elaboradas y creativas, sin incrementar significativamente el tiempo de respuesta. Arsturn recomienda este nivel para customer support típico, resumir contenido, extracción de datos y generar contenido en las redes sociales.

            Finalmente, tenemos «medium» y «high». En ambas categorías, el tiempo de razonamiento aumenta considerablemente, lo que también aumenta el costo de tokens de salida (explicado por reasoning tokens). Estas categorías pueden ser usadas para análisis detallados, programación, creación de contenido, investigación científica y planificación estratégica.

            A modo de ejemplo, si su empresa recibe preguntas concretas de sus clientes que requieren respuestas cortas y rápidas cuya solución es relativamente trivial, los niveles «low» y «minimal» son idóneos. En cambio, si está meditando la viabilidad de un plan de marketing o inversión, «medium» y «high» serían los niveles correctos.

            Output verbosity

            Esta característica consta de tres niveles, «low», «medium» y «high». En base a la documentación de OpenAI, verbosity determina cuántos tokens de salida pueden ser generados, incluso manteniendo el nivel de razonamiento constante. En palabras simples, el usuario puede fijar un nivel de razonamiento muy alto, pero si verbosity es bajo, la respuesta será breve, pero eventualmente de muy alta calidad. Naturalmente, en el contexto empresarial, si la pregunta es muy compleja, como una decisión estratégica, y el razonamiento necesita ser explicado de forma detallada, verbosity «high» es la configuración idónea. Por el contrario, si deseamos implementar un ChatBot de alta frecuencia que entregue respuestas simples y cortas, verbosity «low» es la opción.

            Output length

            Esta configuración es un límite más estricto para la cantidad de tokens de salida. Su función es ser una cota superior para los tokens de respuesta y de razonamiento. En términos concretos, si fijamos como la cantidad máxima de tokens 200 y el modelo usa 120 para razonar, sólo quedarán 80 para generar el texto de la respuesta. Esta función es útil para estandarizar respuestas o reducir su costo de forma implícita.

            Pero ¿cuál es la configuración correcta?

            Esto dependerá del contexto en el que se implementará la solución. No obstante, sin lugar a dudas, una solución costo-efectiva, con tiempos de respuesta y calidad razonables, estará condicionada a la adecuada configuración de estos tres parámetros.

            José Miguel Muñoz Urra – jmunozu@pulki.es

          4. Chatbots para pymes: IA de bajo riesgo y alto impacto.

            Según una encuesta llevada a cabo por Peninsula Group a aproximadamente 79 mil negocios, ha revelado que las pequeñas empresas son más cautas a la hora de invertir en tecnologías de Inteligencia Artificial (IA) comparadas con grandes empresas.

            Uno de los principales motivos podría ser el ‘miedo a lo desconocido’, respuesta que ha crecido un 23% desde 2023, cuando los dueños de pequeñas empresas han sido consultados respecto a su opinión sobre la IA. Evidentemente, la baja adopción relativa es multifactorial, puesto que puede estar explicada por una menor cantidad de recursos disponibles para invertir, o simplemente, una falta de utilidad práctica. No obstante, hoy en día sabemos que la implementación de algunas soluciones de Inteligencia Artificial traen consigo beneficios económicos concretos a un riesgo y costo relativamente bajo. En particular, en este artículo expondremos por qué los ChatBots son un ejemplo concreto de una IA baja en riesgo y beneficiosa para la firma.

            Chatbots, un caso particular de IA generativa…

            Según el National Institute of Standards and Technology, la IA generativa es una clase de modelos que, emulando la estructura de los datos, puede generar contenido nuevo. El ejemplo más notable de una aplicación que utiliza esta clase de modelos es ChatGPT, que entrenado con más de 570gb datos -que incluyen, entre otros, páginas webs y libros-, es capaz de crear textos nuevos y elocuentes.

            Si bien ChatGPT ha mostrado excelentes capacidades cognitivas y precisión en sus afirmaciones, existen aspectos que pueden alimentar el componente de miedo que los pequeños empresarios reportan tener sobre este tipo de tecnologías. Entre ellos, uno de los principales son las alucinaciones, que podrían ser definidas como salidas plausibles, pero falsas o no sustentadas. Naturalmente, este defecto podría ser extremadamente perjudicial, porque el ChatBot podría entregar una salida falsa a un cliente o alucinar en un proceso productivo clave, lo que eventualmente se traduciría en pérdidas. No obstante, si nos restringimos a la API de ChatGPT, para tranquilidad del emprendedor, hay al menos dos formas efectivas de lidiar con este problema cuando hablamos de su ChatBot. La primera es controlar la «temperatura» y la segunda es dar instrucciones al Asistente.

            Controlando la temperatura…

            En algunos modelos de ChatGPT, la temperatura es un parámetro que controla la aleatoriedad de las respuestas. Con valores bajos (0-0.3), las salidas son predecibles y repetibles; mientras que para valores altos (0.7-1.5) aquellas son más creativas y variables. En nuestros desarrollos, procuramos utilizar una temperatura de 0 cuando proveer información importante y precisa se refiere. Esto disminuye notablemente las alucinaciones.

            Instrucciones al asistente…

            Otro aspecto que incorpora OpenAI para estructurar respuestas son los asistentes, que se definen como un agente configurado que recibe instrucciones, tales como rol y personalidad, entre otras. A modo de ejemplo, si diseñamos un ChatBot para gestionar las reservas de un restaurant, podríamos definir el rol de este asistente como un gestor de reservas, que cumpla una serie de instrucciones en específico de forma obligatoria. Una instrucción podría ser:

            «Tú eres el asistente que gestiona reservas en el restaurant AAA. La forma en la que te relacionas con el cliente se debe ajustar a la información contenida en el archivo BBB.»

            Como podemos ver, en este ejemplo, hacemos que el asistente se comporte como un gestor de reservas, y siga las reglas de un archivo que hemos subido previamente.

            Esta metodología, le entrega contexto a nuestro gestor virtual, cuyo objetivo es delimitar el rango de sus respuestas a un espacio consistente con el público al que atiende y los lineamientos de la compañía.

            ¿Ambas soluciones son suficientes?

            Dada nuestra experiencia práctica, esto dependerá del rol del ChatBot. Si quieres saber si un ChatBot es idóneo para tu proceso productivo o de ventas, ¡no dudes en contactarnos!

            José Miguel Muñoz Urra – jmunozu@pulki.es