Las llamadas a herramientas y el streaming te piden cosas opuestas.
El streaming quiere reenviar los tokens al navegador en cuanto llegan. Las llamadas a herramientas quieren examinar lo que devolvió el modelo, decidir si pidió una herramienta, ejecutarla y repetir el ciclo. Lo incómodo es que solo sabes qué tipo de turno tienes al generarlo: ningún campo de la petición dice «esta será una respuesta».
Así que hay una forma obvia de escribir el bucle, y casi todo el mundo la escribe al menos una vez.
El patrón
loop:
completion = create(messages, tools) // sin streaming
if completion has tool calls:
run them, append results, continue
else:
break
// ya sabemos que es una respuesta: otra vez, ahora con streaming
stream = create(messages, tools) // con streaming
forward every delta to the browser
Al leerlo parece razonable. La llamada sin streaming es un sondeo: es fácil de entender, es fácil decidir qué hacer con su resultado y deja simple la parte del streaming, porque cuando empiezas a transmitir ya sabes que no puede pasar nada más.
Y además genera dos veces cada respuesta simple.
La primera llamada produce una respuesta completa: el modelo no sabe que es un sondeo. Tiras esa respuesta, vuelves a preguntar y transmites la segunda. El usuario espera las dos.
Lancé exactamente esto y sobrevivió a varias versiones, porque nada en él parece incorrecto. No hay ningún error. La latencia se despacha con un «va algo lento, es un LLM». La factura, con un «lo normal tratándose de IA». Los tests pasan, porque comprueban el texto final, y el texto final es correcto.
Lo que cuesta
Dos cosas, y la segunda es peor que la primera.
Tokens. Un turno sin llamadas a herramientas paga la respuesta entera dos veces, tanto la salida como la entrada, y la entrada es la mitad cara en cuanto envías un prompt de sistema, un resumen del centro de ayuda y el historial de la conversación. Las respuestas son el caso más común: saludos, preguntas de seguimiento, aclaraciones, todo lo que el modelo puede resolver con lo que ya tiene.
Latencia hasta el primer carácter. Esta es la que notan los usuarios. El sondeo tiene que terminar del todo antes de que empiece la llamada con streaming, así que el visitante espera una generación completa mirando una burbuja vacía, y solo entonces empiezan a aparecer caracteres. Pusiste el streaming justo para eliminar esa espera, y luego la volviste a colocar delante.
La solución es dejar de sondear. Haz streaming de cada ronda y trata «llegó texto» y «se pidió una herramienta» como dos cosas que pueden salir de la misma llamada, porque pueden.
Transmitir una ronda que quizá tengas que abandonar
El bloqueo mental es que una ronda con streaming parece insegura: ¿y si resulta ser una llamada a una herramienta cuando ya empezaste a reenviar texto al navegador?
Da igual. Una ronda que llama a una herramienta normalmente no emite texto, y cuando lo emite, es una narración que el usuario debería ver de todos modos («Déjame revisar la documentación sobre eso») seguida de la llamada. Reenvía los deltas según llegan. Si en la misma ronda aparecen llamadas a herramientas, ejecútalas y sigue con el bucle: el texto de la siguiente ronda continúa el mismo mensaje.
Lo que cambia es que tu bucle ahora reconstruye cada ronda a partir de fragmentos en lugar de leer un objeto terminado. Todo depende de cuatro detalles:
Las llamadas a herramientas llegan en trozos, identificadas por índice. El primer fragmento de una llamada trae index, id y el nombre de la función; cada fragmento posterior trae solo index y el siguiente trozo del JSON de argumentos. Acumulas todo en un mapa por índice y concatenas las cadenas de argumentos. Nada más es estable: ni el orden ni la posición en el array.
El motivo de parada llega en su propio fragmento. finish_reason viene en un fragmento con el delta vacío, así que si solo miras los fragmentos con contenido nunca lo verás, y nunca sabrás que la respuesta se cortó al llegar al límite de tokens.
El consumo llega después de todo lo demás. Pídelo con stream_options: { include_usage: true } y llega en un último fragmento con el array de choices vacío. El código que hace chunk.choices[0].delta sin comprobarlo antes lanzará una excepción justo en el fragmento que te dice cuánto costó el turno.
Una ronda puede narrar y después llamar. Si en esta ronda llegó texto y la anterior también produjo texto, necesitas un salto de párrafo entre ambos; si no, dos frases generadas con un segundo de diferencia acaban pegadas en una sola línea.
Ninguno es difícil. Todos fallan en silencio cuando te equivocas, y por eso la versión con sondeo parece más segura de lo que es.
Lo que obtienes gratis: poder detener la respuesta
Este es el argumento que me convenció, más allá del dinero.
Mientras sondeas, no puedes ofrecer un botón para detener. La petición del usuario no está ligada a nada que se pueda cancelar: la generación en curso es una que vas a tirar de todos modos, y la que importa todavía no ha empezado. Cancelar solo es posible cuando hay exactamente una generación activa por turno y es la que se está reenviando.
Con el patrón correcto, detener la respuesta cuesta unas pocas líneas: un AbortSignal del botón al fetch y una comprobación en el servidor que deja de escribir cuando el cliente se ha ido. Salir del bucle también termina la petición al modelo, así que el modelo deja de generar de verdad en lugar de terminar una respuesta que nadie va a leer.
Pero factura bien, porque aquí una implementación ingenua pierde dinero sin hacer ruido, en la dirección contraria. Si registras el consumo de tokens al final de un turno que termina bien, un turno detenido no registra nada, y acabas de regalar todos los tokens que el modelo gastó antes de que el usuario pulsara detener. Registra el consumo en un finally, para que se ejecute el mismo camino tanto si el turno terminó como si falló o se abandonó a mitad de frase:
try {
// ... transmitir las rondas, hacer yield de los deltas
} finally {
billOnce(tokensSpentSoFar);
}
En JavaScript, salir de un for await sobre un generador asíncrono llama a su return(), que reanuda el generador en su último yield y ejecuta su finally. Así la cancelación pasa por el camino normal de salida y no hace falta recordar ningún caso especial, siempre que la facturación esté en ese camino.
Después guarda lo que vio el usuario. Una respuesta detenida sigue siendo una respuesta: medio párrafo en su pantalla y, si guardas las transcripciones, medio párrafo en tu panel, marcado como detenido. Si no guardas nada, tus propios registros no coinciden con la conversación que esa persona tuvo de verdad.
Cómo saber si tienes este error
Cuenta las llamadas al modelo, no los tokens. Registra una línea por cada llamada con el id del turno, hazle a tu asistente una pregunta que no necesite ninguna herramienta («hola» o «¿qué puedes hacer?») y mira cuántas llamadas produjo ese único turno.
Si son dos, una de ellas la pagas a cambio de nada.
El chat con IA de Kookee ahora hace streaming de una sola llamada por ronda, y el botón de enviar se convierte en un botón de detener mientras llega la respuesta. Consulta Instalar el widget de chat con IA en tu sitio web para el widget, o Por qué la lista de «Fuentes» de tu chatbot de IA probablemente está mal para la otra trampa de esta parte del stack.