Seu chatbot com streaming provavelmente gera cada resposta duas vezes

ai-agents

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

Plain Text
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:

TypeScript
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.