A chamada de ferramentas (tool calling) e o streaming querem coisas opostas de você.
O streaming quer repassar os tokens ao navegador no instante em que chegam. A chamada de ferramentas quer examinar o que o modelo devolveu, decidir se ele pediu uma ferramenta, executá-la e repetir. O incômodo é que você só descobre que tipo de turno tem nas mãos gerando esse turno — nenhum campo da requisição diz “esta vai ser uma resposta”.
Por isso existe uma forma óbvia de escrever o loop, e quase todo mundo a escreve pelo menos uma vez.
O formato
loop:
completion = create(messages, tools) // sem streaming
if completion has tool calls:
run them, append results, continue
else:
break
// agora que sabemos que é uma resposta, gera de novo, com streaming
stream = create(messages, tools) // com streaming
forward every delta to the browser
Lendo, parece razoável. A chamada sem streaming é uma sondagem: fácil de entender, fácil de usar num if, e mantém simples o caminho do streaming, porque quando ele começa você já sabe que nada mais pode acontecer.
E esse loop também gera cada resposta comum duas vezes.
A primeira chamada produz uma resposta completa — o modelo não sabe que é uma sondagem. Você joga essa resposta fora, pergunta de novo e faz streaming da segunda. O usuário espera pelas duas.
Eu lancei exatamente isso, e o código sobreviveu a várias versões, porque nada nele parece errado. Não há erro. A latência é “meio lenta, é um LLM”. A conta é “mais ou menos o que se espera de IA”. Os testes passam, porque verificam o texto final, e o texto final está correto.
Quanto isso custa de verdade
Duas coisas, e a segunda é pior que a primeira.
Tokens. Um turno sem chamadas de ferramentas paga a resposta inteira duas vezes, saída e entrada — e a entrada é a metade cara quando você envia um prompt de sistema, uma visão geral da central de ajuda e o histórico da conversa. Respostas são o caso comum: saudações, perguntas complementares, esclarecimentos, tudo o que o modelo resolve com o que já tem.
Latência até o primeiro caractere. É esse custo que o usuário sente. A sondagem precisa terminar por completo antes de a chamada com streaming começar, então o visitante espera uma geração inteira olhando para um balão vazio, e só depois os caracteres começam a aparecer. Você implementou streaming justamente para acabar com essa espera e depois a colocou de volta na frente dele.
A correção é parar de sondar. Faça streaming de cada rodada e trate “chegou texto” e “uma ferramenta foi pedida” como duas coisas que podem sair da mesma chamada — porque podem.
Streaming de uma rodada que talvez você abandone
O bloqueio mental é que uma rodada com streaming parece arriscada: e se ela virar uma chamada de ferramenta depois que você já começou a repassar texto ao navegador?
Não faz diferença. Uma rodada que chama uma ferramenta em geral não emite texto, e quando emite, é uma narração que o usuário deveria ver de qualquer jeito — “Vou conferir a documentação sobre isso” — seguida da chamada. Repasse os deltas conforme chegam. Se aparecerem chamadas de ferramentas na mesma rodada, execute-as e continue o loop; o texto da rodada seguinte simplesmente continua a mesma mensagem.
O que muda é que o loop agora remonta a rodada a partir de fragmentos, em vez de ler um objeto pronto. Quatro detalhes fazem isso funcionar:
As chamadas de ferramentas chegam em pedaços, identificadas pelo índice. O primeiro chunk de uma chamada traz index, id e o nome da função; cada chunk seguinte traz só index e a próxima fatia do JSON de argumentos. Você acumula tudo num mapa indexado por index e concatena as strings dos argumentos. Nada mais é estável — nem a ordem, nem a posição no array.
O motivo de parada chega num chunk próprio. finish_reason vem num chunk com delta vazio; se você só olha os chunks que trazem conteúdo, nunca vai vê-lo e nunca vai saber que a resposta foi cortada no limite de tokens.
O uso de tokens chega depois de tudo. Peça com stream_options: { include_usage: true }, e ele vem num chunk final com o array choices vazio. Um código que faz chunk.choices[0].delta sem checar antes vai lançar uma exceção justamente no chunk que diz quanto o turno custou.
Uma rodada pode narrar e depois chamar. Se chegou texto nesta rodada e a anterior também produziu texto, você precisa de uma quebra de parágrafo entre os dois; senão, duas frases geradas com um segundo de diferença se juntam numa linha só.
Nenhum desses detalhes é difícil. Todos falham em silêncio quando você erra, e é por isso que a versão com sondagem parece mais segura do que é.
O que vem de graça: o botão de parar
Além do dinheiro, foi este argumento que me convenceu.
Enquanto você sonda, não dá para oferecer um botão de parar. O pedido do usuário não está ligado a nada que se possa cancelar — a geração em andamento é uma que você vai jogar fora de qualquer jeito, e a que importa ainda não começou. Cancelar só passa a ser possível quando há exatamente uma geração viva por turno e é ela que está sendo repassada.
Com o formato certo, parar são poucas linhas: um AbortSignal do botão até o fetch e uma verificação no servidor que para de escrever quando o cliente vai embora. Sair do loop também encerra a requisição ao provedor do modelo, então o modelo realmente para de gerar, em vez de terminar uma resposta que ninguém vai ler.
Mas faça a cobrança direito, porque é aqui que uma implementação ingênua perde dinheiro em silêncio, na direção oposta. Se você registra o uso de tokens no fim de um turno bem-sucedido, um turno interrompido não registra nada — e você acaba de dar de presente cada token que o modelo gastou antes de o usuário apertar parar. Registre o uso num finally, para que o mesmo caminho rode quer o turno tenha terminado, falhado ou sido abandonado no meio da frase:
try {
// ... streaming das rodadas, yield dos deltas
} finally {
billOnce(tokensSpentSoFar);
}
Em JavaScript, sair de um for await sobre um gerador assíncrono chama return() nele, o que retoma o gerador no último yield e executa o finally. O cancelamento não é um caminho especial que você precisa lembrar — é o caminho normal de encerramento, desde que a cobrança fique por onde esse encerramento passa.
Depois, salve o que o usuário viu. Uma resposta interrompida continua sendo uma resposta: meio parágrafo na tela dele e, se você guarda as transcrições, meio parágrafo no seu painel, marcado como interrompido. Não guardar nada faz seus próprios logs divergirem da conversa que a pessoa de fato teve.
Como saber se você tem esse bug
Conte as gerações, não os tokens. Registre uma linha de log por chamada ao modelo com o id do turno, faça ao seu assistente uma pergunta que não precise de nenhuma ferramenta — “oi” ou “o que você sabe fazer?” — e veja quantas gerações aquele único turno produziu.
Se forem duas, você está pagando por uma delas à toa.
O chat com IA do Kookee agora faz streaming de uma única geração por rodada, e o botão de enviar vira um botão de parar enquanto a resposta chega. Veja Instale o widget de chat com IA no seu site para o widget, ou Por que a lista de “Fontes” do seu chatbot de IA provavelmente está errada para a outra armadilha desta parte do stack.