Transmitir uma resposta de ajuda

JavaScriptBeginner
Pratique Agora

Introdução

Quando um modelo de IA prepara uma resposta mais longa, esperar pelo resultado completo pode fazer o aplicativo parecer travado. O streaming permite que o aplicativo receba pequenos trechos assim que ficam prontos. É parecido com ler uma mensagem enquanto a outra pessoa ainda está digitando: a resposta completa exige praticamente o mesmo trabalho, mas o texto útil aparece mais cedo.

Este laboratório usa Server-Sent Events (SSE), um formato de texto para enviar uma sequência de eventos em uma única resposta HTTP. Cada evento do Workers AI começa com data:. Os eventos de texto carregam partes da resposta gerada, e um evento final data: [DONE] indica que o stream terminou normalmente. Uma pausa entre eventos significa apenas que o modelo ainda está trabalhando; sem um sinal de conclusão, o aplicativo não consegue distinguir uma resposta lenta de uma conexão que nunca terminará.

Você criará POST /help, transmitirá uma resposta do Llama hospedado na Cloudflare para um cliente de linha de comando fornecido e testará dois finais: conclusão normal e cancelamento deliberado após o primeiro trecho útil. Cancelamento significa que o cliente não precisa mais do restante da resposta e encerra o trabalho, em vez de deixar uma conexão não utilizada aberta. Você também usará testes determinísticos para provar que uma falha na inicialização do modelo se transforma em um erro limitado e que um stream interrompido termina em vez de travar.

Este é o segundo laboratório do curso. Presume-se que você saiba que um Cloudflare Worker é um código de aplicativo executado na rede da Cloudflare e que um binding AI disponibiliza o Workers AI como env.AI. Se você entrou diretamente no curso, conclua primeiro Conectar o LabEx à sua conta da Cloudflare para aprender a usar o terminal da VM, autorizar o Wrangler, confirmar sua conta de aprendizado e salvar o ID da conta.

O laboratório usa @cf/meta/llama-3.3-70b-instruct-fp8-fast e perguntas sintéticas pequenas. Atualmente, as contas Workers Free recebem uma alocação diária compartilhada de 10.000 Neurons. A inferência local também acessa a Cloudflare e consome essa alocação. Se a alocação se esgotar ou o modelo estiver sem capacidade, pare em vez de enviar solicitações repetidas; o aplicativo deve informar um erro terminal em vez de travar.

A configuração instala o Node.js 22.22.0 e o Wrangler 4.132.0 local do projeto em /home/labex/project/help-stream. Ela fornece o cliente SSE, fixtures determinísticas e verificações independentes. A configuração não faz login, não invoca um modelo, não implanta um Worker nem cria um recurso na nuvem. Mantenha esta VM aberta até excluir o Worker descartável e confirmar o logout.

Autorizar a VM e configurar o Worker de streaming

Nesta etapa, você autorizará esta VM nova e configurará o Worker descartável de streaming. O login existente no Dashboard não concede automaticamente às instruções do terminal permissão para gerenciar a conta de aprendizado.

Entre no projeto preparado e confirme a versão fixada do Wrangler:

cd /home/labex/project/help-stream
npx wrangler --version

Espere 4.132.0. Solicite apenas as permissões necessárias aqui. O acesso de gravação a Workers Scripts gerencia o Worker descartável, o acesso de gravação ao Workers AI permite que o binding chame o modelo e os dois escopos de leitura identificam a conta selecionada. O Wrangler 4.132.0 também verifica dependências de bindings KV durante a exclusão do Worker; por isso, o escopo limitado de gravação em KV permite que o comando de limpeza seja concluído, embora este laboratório não crie dados KV.

npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write ai:write

Abra o link exibido, informe o código atual do dispositivo, verifique a conta e as permissões e autorize a conta de aprendizado. O Background Access também pode aparecer porque o Wrangler continua depois que o navegador é fechado. Volte ao terminal e aguarde a mensagem de sucesso. Em seguida, consulte os dados estruturados de identidade:

npx wrangler whoami --json

Confirme loggedIn: true e leia name e id da conta pretendida. Gere um nome exclusivo para o Worker descartável:

RUN="labex-c07-a02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

Substitua YOUR_ACCOUNT_ID pelo ID real dessa conta:

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "YOUR_ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-16",
  "compatibility_flags": ["enable_request_signal"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  },
  "ai": {
    "binding": "AI",
    "remote": true
  }
}
JSON

O binding AI ficará disponível como env.AI. remote: true significa que o desenvolvimento local continuará usando o modelo real na nuvem. enable_request_signal faz com que request.signal indique quando o cliente se desconecta, permitindo que o Worker registre e trate um sinal de cancelamento. A observabilidade salva os eventos do ciclo de vida que você examinará mais tarde. Nenhum Worker foi implantado e nenhuma inferência foi executada ainda.

Inspecionar o binding de IA e o cliente SSE

Nesta etapa, você conectará o binding de IA ao cliente SSE fornecido antes de escrever o Worker. O modelo é o produtor, o Worker encaminha os bytes e client.mjs é o consumidor. Separar essas funções deixa claro qual componente deve encerrar o trabalho.

Gere os tipos do ambiente do Worker:

npx wrangler types
grep -A4 'interface __BaseEnv_Env' worker-configuration.d.ts

Procure AI: Ai. Isso significa que o binding configurado estará disponível para o handler como env.AI; ele não é uma chave de API armazenada no código-fonte.

Agora inspecione os resultados terminais do cliente fornecido:

grep -nE 'chunk:|complete chunks=|cancelled after|stream_error:' client.mjs

O cliente lê a resposta pouco a pouco. Uma linha chunk: mostra o texto recém-gerado. complete aparece somente depois de data: [DONE]. O modo de cancelamento fecha o reader após o primeiro trecho não vazio. stream_error indica uma falha terminal, incluindo um timeout de 45 segundos; o timeout é um limite de segurança, não uma previsão de que toda resposta do modelo levará esse tempo.

Um evento SSE é texto simples separado por uma linha em branco. Um stream bem-sucedido típico é semelhante a este:

data: {"response":"First piece"}

data: {"response":" and another piece."}

data: [DONE]

Os limites dos chunks são detalhes do transporte: um evento pode conter uma palavra, uma pontuação ou um trecho maior. A lógica do aplicativo deve combinar as strings response e aguardar [DONE], sem presumir uma quantidade ou um tamanho fixo de chunks.

Criar um endpoint de streaming monitorado

Nesta etapa, você criará o endpoint de streaming e o monitoramento do ciclo de vida. O Worker solicitará um stream ao modelo usando stream: true e exporá esse mesmo protocolo SSE ao cliente. Ele não armazenará primeiro a resposta inteira na memória. Um pequeno wrapper acompanha o ciclo de vida do stream: a conclusão fecha normalmente, o cancelamento cancela o reader upstream e um erro no stream encerra a resposta.

Crie o entrypoint do Worker:

cat > src/index.js <<'JS'
const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
const MAX_QUESTION = 800;

function json(data, status = 200) {
  return Response.json(data, { status });
}

async function readQuestion(request) {
  const contentType = request.headers.get("content-type") || "";
  if (!contentType.toLowerCase().includes("application/json")) {
    return { error: json({ error: "json_required" }, 415) };
  }

  const raw = await request.text();
  if (raw.length > 2048) {
    return { error: json({ error: "question_too_large" }, 413) };
  }

  let body;
  try {
    body = JSON.parse(raw);
  } catch {
    return { error: json({ error: "invalid_json" }, 400) };
  }

  const question = typeof body?.question === "string" ? body.question.trim() : "";
  if (!question) {
    return { error: json({ error: "invalid_question" }, 400) };
  }
  if (question.length > MAX_QUESTION) {
    return { error: json({ error: "question_too_large" }, 413) };
  }
  return { question };
}

function monitor(upstream, details) {
  const reader = upstream.getReader();
  let terminal = false;

  return new ReadableStream({
    async pull(controller) {
      try {
        const { done, value } = await reader.read();
        if (done) {
          terminal = true;
          console.log(JSON.stringify({ event: "help_stream_completed", ...details }));
          controller.close();
          return;
        }
        controller.enqueue(value);
      } catch {
        terminal = true;
        console.error(JSON.stringify({ event: "help_stream_failed", ...details }));
        controller.error(new Error("model stream interrupted"));
      }
    },
    async cancel(reason) {
      if (!terminal) {
        terminal = true;
        console.log(JSON.stringify({ event: "help_stream_cancelled", ...details }));
      }
      await reader.cancel(reason);
    }
  });
}

async function streamHelp(request, env) {
  const parsed = await readQuestion(request);
  if (parsed.error) return parsed.error;

  const requestId = crypto.randomUUID();
  const details = { requestId, model: MODEL };
  request.signal.addEventListener("abort", () => {
    console.log(JSON.stringify({ event: "help_client_disconnected", ...details }));
  }, { once: true });

  try {
    const upstream = await env.AI.run(MODEL, {
      messages: [
        {
          role: "system",
          content: "Answer the support question in at most four short sentences. Give safe, practical steps and do not invent account details."
        },
        { role: "user", content: parsed.question }
      ],
      stream: true,
      max_tokens: 160,
      temperature: 0.2
    });

    if (!(upstream instanceof ReadableStream)) {
      throw new Error("stream unavailable");
    }

    console.log(JSON.stringify({ event: "help_stream_started", ...details }));
    return new Response(monitor(upstream, details), {
      headers: {
        "content-type": "text/event-stream; charset=utf-8",
        "cache-control": "no-store",
        "x-request-id": requestId
      }
    });
  } catch {
    console.error(JSON.stringify({ event: "help_stream_start_failed", ...details }));
    return json({ error: "model_unavailable", requestId }, 502);
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (request.method === "GET" && url.pathname === "/health") {
      return json({ status: "ok" });
    }
    if (request.method === "POST" && url.pathname === "/help") {
      return streamHelp(request, env);
    }
    return json({ error: "not_found" }, 404);
  }
};
JS

O código registra IDs de solicitação e eventos do ciclo de vida, mas nunca registra a pergunta nem a resposta gerada. Um ID de solicitação relaciona uma resposta do cliente a uma entrada de log sem copiar o conteúdo de suporte para os dados de observabilidade. A resposta de erro também oculta detalhes internos do provedor; os operadores podem usar o log do ciclo de vida, enquanto os clientes recebem o contrato estável model_unavailable.

Execute os testes determinísticos. O binding de IA falso emite eventos controlados, falha antes do streaming, permite cancelamento e interrompe um stream sem consumir Neurons:

node --test test/worker.test.mjs

Espere seis testes aprovados. Em seguida, gere o bundle do Worker real sem implantá-lo:

npx wrangler deploy --dry-run

Os testes comprovam o comportamento do aplicativo sob condições de tempo controladas. O dry run comprova que o código-fonte e a configuração formam um bundle válido. Nenhum dos dois comprova que o modelo na nuvem está disponível neste momento; a próxima etapa usará um stream real.

Observar um stream local real

Nesta etapa, você observará um stream real por meio de um processo do Worker executado na VM. “Local” descreve o handler da solicitação; a inferência do modelo ainda acontece na conta selecionada e conta para a alocação diária.

Inicie o Wrangler em segundo plano e salve o ID do processo:

npx wrangler dev --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid

Aguarde a rota de integridade que não usa IA:

for attempt in $(seq 1 30); do
  if curl --silent --fail http://127.0.0.1:8787/health; then
    break
  fi
  sleep 1
done

Agora use o cliente fornecido para fazer uma pergunta sintética pequena:

node client.mjs http://127.0.0.1:8787 \
  "How can I safely retry an invoice upload without creating a duplicate ticket?"

Você deverá ver uma ou mais linhas chunk: seguidas por uma linha terminal semelhante a:

complete chunks=18 chars=238

O texto, a quantidade de chunks e a quantidade de caracteres serão diferentes. A evidência importante é receber conteúdo incremental não vazio seguido por [DONE], que o cliente converte em complete. Se aparecer stream_error, inspecione .labex/dev.log. Uma falha de cota, autorização ou capacidade é uma inferência malsucedida, não um motivo para esperar indefinidamente.

Por fim, confirme que a validação do aplicativo ainda acontece antes da inferência:

curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \
  http://127.0.0.1:8787/help \
  --header 'Content-Type: application/json' \
  --data '{"question":""}'

Espere {"error":"invalid_question"} e HTTP 400. Um stream só é útil depois que uma solicitação comum passa pelas verificações de entrada.

Implantar, concluir e cancelar um stream

Nesta etapa, você implantará o Worker, concluirá um stream e cancelará outro após o primeiro trecho útil. Primeiro, pare somente o processo de desenvolvimento salvo e aguarde seu encerramento:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true

Implante o mesmo código do Worker:

npx wrangler deploy

Salve a URL exata de workers.dev exibida pelo Wrangler:

WORKER_URL="https://YOUR_WORKER_URL"

Primeiro, observe a conclusão normal:

node client.mjs "$WORKER_URL" \
  "How can I safely retry an invoice upload without creating a duplicate ticket?"

A linha final complete significa que o modelo enviou [DONE]; receber apenas o primeiro trecho não comprovaria uma resposta completa.

Agora inicie um segundo stream e interrompa-o deliberadamente após o primeiro trecho não vazio:

node client.mjs "$WORKER_URL" \
  "Explain four checks to make before retrying a failed file upload." \
  --cancel-after-first

Espere uma linha chunk: seguida por cancelled after 1 chunk. O cancelamento não é um erro do modelo: o cliente decidiu intencionalmente que não precisava mais do restante. Fechar o reader propaga o cancelamento para o stream upstream, enquanto o sinal da solicitação recebida permite que o Worker registre a desconexão.

Abra o Cloudflare Dashboard e acesse Workers & Pages → Overview → seu Worker labex-c07-a02-... → Observability → Logs. Encontre as solicitações recentes. A visão geral abaixo vem de uma execução descartável de aceitação: 14 Success e 0 Errors mostram que o Worker tratou suas verificações de integridade, streams concluídos e desconexões deliberadas sem uma invocação com falha. Seus totais e horários serão diferentes.

Invocações bem-sucedidas do Worker com streaming e sem erros

Pesquise help_stream_completed, expanda um resultado e confirme model, requestId e event. O ID de solicitação é um valor seguro para correlação: ele ajuda o operador a relacionar registros do ciclo de vida sem armazenar a pergunta do aluno nem a resposta gerada.

Registro do ciclo de vida de um stream concluído com modelo e ID de solicitação

Para a solicitação pública cancelada deliberadamente, pesquise help_client_disconnected. Com enable_request_signal, esse evento é a evidência direta de que o cliente recebido se desconectou. O teste determinístico da Etapa 4 comprova separadamente que o cancel() downstream chega ao fixture do modelo e registra help_stream_cancelled; o tempo real da rede pode fazer com que o evento de sinal da solicitação seja o registro visível na nuvem. Os logs salvos podem chegar depois da resposta; aguarde brevemente e faça no máximo mais uma solicitação de cancelamento limitada, se necessário.

Registro do ciclo de vida da desconexão do cliente para o stream cancelado

Depois, abra Workers AI e examine o uso do modelo hoje. Encontre o modelo Llama 3.3 e confirme que os exercícios pequenos continuam dentro da alocação Free de 10.000 Neurons. No exemplo abaixo, todo o trabalho na conta de aprendizado usou 158.03/10k Neurons; isso inclui outros exercícios nessa conta, portanto seu número será diferente. Um plano Workers Paid não é necessário para este laboratório enquanto a conta permanecer dentro da alocação Free. O uso pode demorar para aparecer; não repita a inferência apenas para forçar a atualização de um gráfico.

Uso diário de Neurons do Workers AI para o modelo Llama 3.3

O nome do Worker, os IDs de solicitação, os horários e o uso mostrados nessas capturas são exemplos de uma execução descartável. Os nomes dos eventos e a relação entre os eventos do ciclo de vida — não os valores exatos — são os objetivos de aprendizagem.

Remover o Worker e fazer logout

Nesta etapa, você removerá o Worker descartável e depois fará logout desta VM. Os registros de uso do Workers AI são históricos no nível da conta; portanto, excluir o Worker remove seu endpoint público, mas não apaga esses registros históricos nem altera o plano da conta.

Exclua exatamente o Worker indicado em wrangler.jsonc:

npx wrangler delete

Confirme somente quando o Wrangler mostrar o nome exclusivo deste laboratório, labex-c07-a02-.... O comando deve terminar com Successfully deleted. No Dashboard, atualize Workers & Pages → Overview e confirme que esse nome exato não está mais presente.

Enquanto a VM ainda estiver autorizada, execute a verificação independente de gerenciamento:

python3 .labex/verify.py deleted

Somente depois que o comando informar PASS: deleted, remova a autorização armazenada desta VM:

npx wrangler logout
npx wrangler whoami --json

Exija loggedIn: false. Um arquivo local ausente, uma aba do navegador fechada ou um erro de rede não comprovaria a exclusão na nuvem nem o logout.

Resumo

Você criou um endpoint do Workers AI que encaminha a saída SSE incremental em vez de armazenar uma resposta completa em buffer. Aprendeu por que o cliente precisa de um sinal explícito [DONE], como um timeout impede uma espera indefinida, como o cancelamento deliberado se diferencia de uma falha e como os erros do stream terminam corretamente. Você verificou a inferência real local e implantada, relacionou os eventos do ciclo de vida à observabilidade do Dashboard, removeu o Worker descartável e fez logout da VM nova.