---
title: "Streaming UI pra LLM: o que a tela do agente faz quando a conexão cai no oitavo token"
slug: streaming-ui-llm-queda-de-rede
date: 2026-10-28
category: "Arquitetura"
tags: ["ia", "agentes", "llm", "streaming", "typescript", "frontend"]
readTime: "4 min"
excerpt: "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."
url: https://eliseu.dev/blog/streaming-ui-llm-queda-de-rede
project: streaming-ui
---

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](https://github.com/EliseuSantos/frontend-ia-demos/tree/main/demos/01-streaming-ui), em TypeScript estrito, sem framework de UI. Abra este link e aperte Enviar: [a resposta cai no oitavo token](https://eliseusantos.github.io/frontend-ia-demos/01-streaming-ui/?latencia=40&rajada=8&chance=0&pausa=500&corte=8). 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:

```typescript
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:

```typescript
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:

1. 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.
2. 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.
3. 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:

```typescript
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](https://eliseu.dev/projects).

## Fontes

- [Código da demo 01-streaming-ui](https://github.com/EliseuSantos/frontend-ia-demos/tree/main/demos/01-streaming-ui), repositório [frontend-ia-demos](https://github.com/EliseuSantos/frontend-ia-demos)
- [Demo no ar, cenário com corte no token 8](https://eliseusantos.github.io/frontend-ia-demos/01-streaming-ui/?latencia=40&rajada=8&chance=0&pausa=500&corte=8)
- [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), MDN
- [Window.requestAnimationFrame()](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame), MDN
- [Idempotent requests](https://docs.stripe.com/api/idempotent_requests), documentação da Stripe