Streaming UI pra LLM: o que a tela do agente faz quando a conexão cai no oitavo token
Montei uma demo em que a resposta do modelo cai no meio, de propósito. Cancelar, retomar e mostrar o erro são decisões de produto, e quando a tela aprova uma ação do agente elas viram contrato da plataforma de IA, com idempotência no backend.
Toda demo de chat com LLM funciona no Wi-Fi do escritório. A interface de um agente de IA mostra quem pensou nela no dia em que a conexão cai no meio da resposta, e esse dia chega rápido em produção.
Por isso eu montei uma demo que derruba a conexão de propósito. Ela é o primeiro item da série Frontend com IA: 01-streaming-ui, em TypeScript estrito, sem framework de UI. Abra este link e aperte Enviar: a resposta cai no oitavo token. A tela mostra “tentando de novo”, espera e continua do ponto onde parou.
Três decisões que eu tomaria em qualquer time
Cancelar precisa cancelar. Esconder o texto e deixar o pedido rodando ainda gasta token e ainda pode disparar efeito colateral. Na demo, o botão chama abort() num AbortController ligado ao fetch, e a sessão vai para o estado cancelled.
Retry tem limite e aparece na tela. Se a conexão cai sozinha, a sessão tenta de novo com espera crescente e desiste na quarta tentativa, com uma mensagem que diz em qual token caiu:
export function backoffMs(attempt: number, base = 250, cap = 2_000): number {
return Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
} Com quatro tentativas no total, as esperas ficam em 250 ms, 500 ms e 1 s, e o teto de 2 s fica de reserva caso alguém aumente o limite. O estado de erro existe no tipo (idle, connecting, streaming, stalled, retrying, done, cancelled, error), então a tela precisa ter o que mostrar em cada um.
O DOM não acompanha o ritmo dos tokens. Os pedaços entram num buffer e a escrita acontece uma vez por requestAnimationFrame. A própria demo compara “a cada token” com “uma vez por frame”, e o contador de tokens recebidos sobe mais rápido que o de escritas.
Onde a demo é honesta comigo
Retomar do último token funciona porque o servidor de mentira aceita um fromToken. Quando você liga um provedor real pela API de chat completions, o código faz outra coisa:
if (mode === 'provider') {
fromToken = 0;
hooks.onReset();
batcher.reset();
} Começa do zero, porque esse endpoint não sabe continuar uma geração pela metade. A tela só retoma o que o backend sabe retomar. Esse é o ponto em que a conversa sai do frontend e vai para a arquitetura.
Quando a tela aprova uma ação
Em agentes de IA, o stream carrega texto e também pedidos de ação: “posso mandar essa mensagem?”, “posso abrir esse PR?“. Imagina a pessoa clicando em Aprovar e a conexão caindo logo depois. O retry com backoff, que é ótimo para texto, manda a aprovação de novo. Se o backend executa duas vezes, a mensagem sai duas vezes.
Como Tech Lead, eu trataria a UI do agente como parte do contrato da plataforma, com três exigências:
- Cada pedido de aprovação tem um id estável, e a aprovação viaja com uma chave de idempotência. Receber a mesma chave duas vezes devolve o resultado da primeira execução.
- O estado da ação mora no servidor (pendente, aprovado, executando, concluído, falhou). Depois de reconectar, a tela pergunta esse estado em vez de supor.
- A geração tem um id e um offset, para o cliente pedir “continua do token N” ou, no mínimo, saber que precisa recomeçar.
Em TypeScript, o pedaço que o frontend manda fica pequeno:
type Approval = {
actionId: string; // vem do stream, igual em todo retry
idempotencyKey: string; // gerada uma vez no clique
decision: "approve" | "reject";
}; O resto é backend: guardar a chave, devolver a resposta já registrada e não executar de novo. Aprovação humana (HITL) que não sobrevive a uma queda de rede vira um botão que às vezes funciona.
O que eu levaria para a mesa
Antes de discutir qual modelo usar, eu perguntaria ao time: se o stream cair agora, o que a pessoa vê, o que o servidor sabe e o que roda duas vezes? A resposta mostra se a plataforma de IA foi desenhada para rede de verdade.
A demo está aberta. Dá para mexer em latência, rajada, chance de pausa e no token do corte pela URL. As outras três demos da série estão em eliseu.dev/projects.
Fontes
- Código da demo 01-streaming-ui, repositório frontend-ia-demos
- Demo no ar, cenário com corte no token 8
- AbortController, MDN
- Window.requestAnimationFrame(), MDN
- Idempotent requests, documentação da Stripe