Saltar al contenido

Tokens y Stream

Terminamos la entrada anterior con un chatbot funcional pero básico. Ahora vamos a hacerle dos mejoras: activaremos la opción stream para poder recibir y visualizar las respuestas poco a poco, y controlaremos el tamaño de la ventana de contexto para evitar exceder el límite del modelo en conversaciones largas.

Ventana de contexto y tokens

A medida que conversamos con el chatbot, vamos acumulando preguntas y respuestas que volvemos a reenviar al modelo en las siguientes llamadas. La información que enviamos va por tanto creciendo según avanza la conversación. Podemos intuir que la cantidad de información que el modelo puede manejar no es infinita, y que en algún momento se debe alcanzar un límite, pero ¿cuál es este límite, y qué lo determina?

El límite viene determinado por lo que se conoce como la ventana de contexto que representa la cantidad de información que el modelo puede manejar en una llamada, incluyendo tanto la entrada como la salida. El tamaño de la ventana de contexto se mide en tokens, y depende del modelo que estemos usando; por ejemplo, GPT-3.5 Turbo tiene versiones con ventanas de contexto de 4.096 y 16.385 tokens, mientras que el más reciente GPT-4 Turbo llega a 128.000 tokens.

Los tokens son los segmentos de texto que maneja el modelo para su procesamiento y generación. Su tamaño es variable, y puede ir desde una carácter hasta una palabra completa. En inglés, su tamaño medio es de en torno a 4 caracteres, pero cambia también según el idioma, y también del modelo (la tokenización de GPT-3 era distinta de la utilizada por modelos posteriores). OpenAI tiene una herramienta de tokenización que muestra como se realiza esta división del texto en tokens.

OpenAI Tokenizer
Tokenizer nos permite ver cómo se separa un texto en tokens.

Otra razón para prestar atención a los tokens es que éstos determinan el coste de las llamadas a la API. Se factura por número de tokens, tanto de entrada como de salida, aunque los de salida tienden a ser más caros.

La respuesta del servicio chat completions por defecto devuelve información sobre el uso de los tokens en la llamada que se acaba de realizar, la cual se puede encontrar dentro del campo usage. Por ejemplo:

"usage": {
    "prompt_tokens": 9,
    "completion_tokens": 31,
    "total_tokens": 40
  }

Está información nos identifica de forma separada los tokens consumidos por nuestra llamada (prompt_tokens), por la respuesta (completion_tokens), así como la suma de los dos (total_tokens).

La información de usage, en principio, nos podría valer para mantener el control del tamaño de la conversación, pero desafortunadamente no siempre se devuelve. Concretamente, si se usa la opción de streaming (que veremos en detalle más abajo) la respuesta no incluirá la información de usage. Por eso, y para permitirnos un control más granular, se hace necesario tener una manera programática de obtener el número de tokens de un mensaje.

Cómo contar tokens

Para entender cómo se cuentan los tokens, hagamos una llamada sencilla a chat completions usando curl y analicemos la respuesta:

curl https://api.openai.com/v1/chat/completions -H "Authorization: Bearer clave-de-api" -H "Content-Type: application/json" -d "{\"model\":\"gpt-3.5-turbo\",\"messages\":[{\"role\":\"user\",\"content\":\"Identificate\"}]}"
{
  "id": "chatcmpl-8m5KZ7orrXJHBGSaVFWAh4Dq3tEvW",
  "object": "chat.completion",
  "created": 1706470927,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "No puedo identificar sin más información. ¿A qué te refieres con \"identificar\"? ¿Te refieres a identificar a una persona, identificar una canción, o algo más específico? Por favor, provee más detalles."
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 50,
    "total_tokens": 59
  },
  "system_fingerprint": null
}

Podemos ver en usage que nuestra llamada ha consumido 9 tokens, y la respuesta, 50. Si cogemos el contenido de la respuesta (choices[0].message.content) y lo pasamos por Tokenizer (quitando los caracteres de escape de las comillas) veremos que nos calcula exactamente los 50 tokens que esperamos.

Hacemos ahora lo mismo con el mensaje de llamada. Metemos en Tokenizer por separado el rol (user) y el contenido (Identificate), y nos calcula 1 + 2 = 3 tokens. Sin embargo prompt_tokens es 9. ¿De dónde viene esta diferencia?

Para calcular los tokens del mensaje de entrada debemos sumar lo siguiente:

  1. Tokens de cada mensaje, incluyendo tanto el rol como el contenido. En nuestro ejemplo, la entrada tiene un único mensaje de 3 tokens (rol + contenido).
  2. Coste fijo adicional de cada mensaje, que depende del modelo. En nuestro caso hemos usado gpt-3.5-turbo-0613 (gpt-3.5-turbo, es solo un alias que apunta al modelo más reciente de la familia), para el cual este coste fijo adicional es de 3 tokens.
  3. 3 tokens adicionales que se añaden a la llamada, independientemente del tipo de mensaje y que (según he podido ver en la documentación) son independientes del modelo.

Sumando todo, llegamos a los 9 tokens que nos dice usage.

Lo que nos falta ahora es saber cómo calcular los tokens de cada mensaje (punto 1 de la lista) desde nuestro programa. Para eso, OpenAI facilita tiktoken, una librería en Python, pero que se encuentra también portada a otros lenguajes, incluido Node.js. Empezaremos por instalarla:

pip install tiktoken
o
npm install tiktoken

Describiré la forma de trabajar con tiktoken usando Python, pero su uso en Node.js es casi idéntico. Para empezar, importaremos la librería:

import tiktoken

Para convertir mensajes a tokens necesitaremos generar un objecto encoding. Hay varias maneras de obtenerlo; podemos hacerlo especificando la forma de codificación correspondiente a nuestro modelo (cl100k_base para todos los modelos más modernos), pero también podemos dejar que tiktoken nos lo elija especificándole el modelo. Haremos esto último de la siguiente manera:

encoding = tiktoken.encoding_for_model('gpt-3.5-turbo-0613')

El objeto encoding tiene un método decode que recibe una cadena de texto y devuelve una lista de identificadores numéricos, uno por cada token en que se descompone el texto. La longitud de esta lista es el número que buscamos: cuántos tokens consume el texto:

num_tokens = len( encoding.encode('Contando tokens') )  # num_tokens == 3

Un último apunte sobre el cálculo de tokens: el número de tokens fijo a añadir por mensaje (punto 2 en la lista anterior) es distinto en función del modelo, pero no he llegado a encontrar formalmente documentado en ningún sitio el valor a usar para cada uno. Para identificarlo me he hecho el script busca_fijo_por_mensaje, que puedes encontrar en GitHub para Python o Node.js. Se basa en usar una lista de mensajes para comparar el número obtenido de usage y el calculado con tiktoken, para cada modelo de texto, para determinar el valor que el modelo usa. El resultado obtenido es que todos los modelos de la familia GPT-4 y GPT-3.5-Turbo añaden 3 tokens por mensaje, con excepción de gpt-3.5-turbo-0301, que añade 4.

Controlando el tamaño de la ventana

Controlar el número de tokens nos permitirá saber cuándo nos acercamos al límite del tamaño de la ventana, pero ¿qué podemos hacer cuando esto sucede? Hay múltiples estrategias que se pueden adoptar, entre ellas:

  • Truncado: Acortar el historial de la conversación, eliminando los mensajes más antiguos, asumiendo que según avanza la conversación serán los menos relevantes.
  • Resumen: Resumir la conversación, y usarlo en subsiguientes intercambios, en lugar de la historia completa.
  • Selección por relevancia: Analizar la conversación y mantener una historia sólo con los mensajes relevantes, eliminando los otros.

Para la nueva evolución de nuestro chatbot implementaremos el truncado, por ser la estrategia más sencilla, y porque con el tamaño de las nuevas ventanas de contextos, en un uso estrictamente de chat, es más que probable que los mensajes más antiguos no tengan ya nada que ver con la conversación. Para otros usos, por ejemplo, traducción de textos extensos, esta estrategia no sería válida y sería necesario optar por otras.

Para empezar, además de la lista de mensajes que ya tenemos, crearemos una segunda lista (tokens_mensaje) donde guardaremos el tamaño en tokens que calculamos para cada uno de ellos. Así, para el mensaje que introduce el usuario:

tokens_mensaje.append(len(enc.encode('user')) + len(enc.encode(texto_user)) + fijo_por_mensaje)

Donde fijo_por_mensaje representa el valor de 3 tokens (para este modelo) que debemos añadir a cada mensaje.

Lo mismo haremos con cada uno de los mensajes devueltos por el modelo. De forma que en la lista tokens_mensaje siempre tendremos actualizada la cantidad de tokens que enviaremos en la siguiente llamada. Usaremos esto para hacer un cálculo antes de cada llamada a la API y determinar si tenemos aún suficiente espacio en la ventana, y de lo contrario borraremos mensajes del principio de la lista hasta tener espacio suficiente.

    suma_tokens = sum(tokens_mensaje)
    if suma_tokens + 3 + max_respuesta > ventana:
        i = 2
        suma = tokens_mensaje[1]
        while suma_tokens + max_respuesta + 3 - suma > ventana:
            suma += tokens_mensaje[i]
            i += 1
        del mensajes[1:i]
        del tokens_mensaje[1:i]

Una cosa a tener en cuenta es que la ventana de contexto establece el máximo para la suma de la entrada y salida de la llamada. Es decir, no es suficiente con controlar que nuestro historial de mensajes no supera el tamaño de la ventana, hay que asegurar también que queda suficiente espacio para la respuesta. ¿Y cómo de grande debe ser este espacio que dejemos? Dependerá de nuestro uso. En este caso, yo he decidido dejar al menos espacio suficiente para poder recibir la respuesta más larga que se haya recibido en lo que llevamos de conversación. Ese valor de la respuesta más larga recibida se guarda en la variable max_respuesta, y lo tendremos en cuenta en el cálculo de mensajes a borrar.

El código completo de la versión evolucionada del chatbot cascarrabias está en GitHub para Python y Node.js.

La opción stream

Si has usado el chatbot cascarrabias y lo has comparado con ChatGPT sin duda habrás notado que, además de tener malas pulgas … es más lento. De hecho, no es más lento, porque en mostrar el mensaje completo (de un tamaño comparable) vienen a tardar algo parecido, pero ChatGPT va escribiendo el mensaje poco a poco, mientras que nuestro chatbot espera has recibir el mensaje completo y lo muestra de una vez.

Afortunadamente, la API de OpenAI nos da la oportunidad de incorporar esta funcionalidad fácilmente, llamando al servicio de chat completions con la opción stream, y las librerías ya lo hacen todo por nosotros. En Python, para mostrar el mensaje paulatinamente bastaría con hacer lo siguiente:

respuesta = client.chat.completions.create(
    model = modelo,
    messages = mensajes,
    stream = True
)

texto_respuesta = ''
for trozo in respuesta:
    trozo_txt = trozo.choices[0].delta.content or '\n'
    texto_respuesta += trozo_txt 
    print(trozo_txt, end='', flush=True)

El único cambio en la llamada al servicio es añadir el parámetro stream = True. Después de eso, bastará con iterar por la respuesta, la cual vamos mostrando pero también guardaremos para poder almacenarla finalmente en el historial de mensajes de la conversación. Una cosa a tener en cuenta es que el último trozo del mensaje que recibimos tendrá None en el valor de content, lo cual será necesario controlar para evitar errores (en este caso lo hemos simplemente sustituido por un retorno de carro).

En Node.js haremos algo muy similar:

const completion_stream = await client.chat.completions.create({
    model: modelo,
    messages: mensajes,
    stream: true
});

let lista_trozos = [];
for await (const trozo of completion_stream) {
    const trozo_txt = trozo.choices[0]?.delta?.content || '\n';
    process.stdout.write(trozo_txt);
    lista_trozos.push(trozo_txt);
}
let texto_respuesta = lista_trozos.join('');

Un detalle a la hora de usar la opción stream es que el mensaje de respuesta varía, y en lugar de recoger choices[0].message deberemos recoger choices[0].delta. Adicionalmente, cuando se usa la opción stream no se incluye la información usage como parte de la respuesta, con lo que si necesitamos controlar el uso de tokens tendremos necesariamente que usar tiktoken (u otra librería equivalente).

El código completo del chatbot cascarrabias con stream lo puedes encontrar en GitHub para Python y Node.js.

Deja una respuesta

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