DEV Community

Alair Joao Tavares
Alair Joao Tavares

Posted on • Originally published at activi.dev

Reduzindo o TTFT em Streaming de IA: Padrões de Arquitetura para Flush por Yield no Django

Reduzindo o TTFT em Streaming de IA: Padrões de Arquitetura para Flush por Yield no Django

A era das aplicações impulsionadas por Inteligência Artificial (IA) trouxe novas expectativas de experiência do usuário (UX). Quando interagimos com um Large Language Model (LLM), como modelos de chat ou assistentes de código, a expectativa é ver a resposta aparecer na tela palavra por palavra, quase instantaneamente. Ninguém quer ficar encarando um ícone de carregamento por 15 segundos enquanto o servidor processa a resposta completa.

É aqui que entra uma métrica crucial na arquitetura de aplicações de IA: o Time-To-First-Token (TTFT), ou o Tempo até o Primeiro Token. O TTFT mede a latência entre a solicitação do usuário e a entrega do primeiro pedaço de texto gerado pelo modelo.

Neste artigo, vamos explorar como fazer a transição do ciclo tradicional de requisição/resposta do Django para uma arquitetura de streaming assíncrono. Vamos entender como utilizar o ASGI, as views assíncronas (AsyncIO) e o padrão de flush por yield contínuo para reduzir drasticamente o TTFT em suas aplicações Django.


1. O Gargalo do Ciclo Padrão de Resposta (WSGI)

Historicamente, o Django foi construído sobre o protocolo WSGI (Web Server Gateway Interface), que é fundamentalmente síncrono. O ciclo de vida de uma requisição típica funciona da seguinte forma:

  1. O servidor recebe a requisição.
  2. A view é chamada e executa sua lógica.
  3. O servidor aguarda a view retornar um objeto HttpResponse completo.
  4. A resposta inteira é enviada de volta ao cliente de uma só vez.

Quando integramos chamadas a APIs de LLMs sob esse paradigma, criamos um enorme gargalo. Veja o exemplo de uma view síncrona tradicional:

import json
from django.http import JsonResponse
from meu_app.services import llm_service

def chat_sincrono_view(request):
    user_prompt = request.POST.get('prompt', '')

    # A execução BLOQUEIA aqui até que todo o texto seja gerado
    full_response = llm_service.generate_full_text(user_prompt)

    return JsonResponse({
        'status': 'success',
        'response': full_response
    })
Enter fullscreen mode Exit fullscreen mode

Neste cenário, se o modelo levar 10 segundos para gerar um parágrafo de 300 palavras, o usuário ficará olhando para uma tela vazia por exatamente 10 segundos. O TTFT é igual ao tempo total de geração. Isso resulta em uma péssima experiência de usuário e pode levar a timeouts no balanceador de carga ou no proxy reverso.


2. A Transição para ASGI e Views Assíncronas

Para resolver esse problema, precisamos adotar o padrão assíncrono. O Django introduziu suporte a views assíncronas a partir da versão 3.1 e vem aprimorando seu ecossistema ASGI (Asynchronous Server Gateway Interface) nas versões mais recentes (4.x e 5.x).

Ao executar o Django sob um servidor ASGI (como Uvicorn ou Daphne), podemos liberar a thread principal para lidar com outras requisições enquanto aguardamos I/O (como a resposta da API do LLM) e, mais importante, podemos transmitir a resposta em pedaços (chunks) assim que eles chegam.

Primeiro, precisamos de um cliente LLM que suporte streaming assíncrono. Abaixo está um exemplo genérico de como esse cliente se pareceria usando aiohttp ou a biblioteca assíncrona oficial de um provedor de IA:

# meu_app/services/llm_client.py
import asyncio

class AsyncLLMClient:
    async def stream_response(self, prompt: str):
        """
        Simula um gerador assíncrono que faz streaming de tokens de um LLM.
        Na prática, isso seria uma chamada a uma API externa.
        """
        tokens_simulados = ["Olá", ", ", "como ", "posso ", "ajudar ", "hoje", "?"]

        for token in tokens_simulados:
            # Simula a latência de geração de cada token
            await asyncio.sleep(0.1)
            yield token
Enter fullscreen mode Exit fullscreen mode

3. Implementando o Flush por Yield com StreamingHttpResponse

Para enviar esses tokens para o cliente assim que são gerados, utilizaremos o StreamingHttpResponse do Django em conjunto com um gerador assíncrono no Python (funções que usam yield em vez de return).

O conceito de per-yield flushing significa que cada vez que o nosso código executa um yield, o servidor ASGI imediatamente "descarrega" (flushes) esse pedaço de dado pela conexão de rede até o cliente frontal, sem esperar o término da função.

Veja a arquitetura da view:

# meu_app/views.py
import asyncio
from django.http import StreamingHttpResponse
from meu_app.services.llm_client import AsyncLLMClient

async def chat_streaming_view(request):
    user_prompt = request.GET.get('prompt', 'Diga um oi')
    llm_client = AsyncLLMClient()

    async def event_stream():
        try:
            # Itera de forma assíncrona sobre o gerador de tokens
            async for chunk in llm_client.stream_response(user_prompt):
                # O yield passa o chunk para o StreamingHttpResponse
                yield chunk

                # Garante que o loop de eventos seja liberado (boa prática)
                await asyncio.sleep(0)

        except asyncio.CancelledError:
            # Trata o cenário onde o cliente fecha a aba/conexão no meio da geração
            print("Conexão com o cliente encerrada prematuramente.")
            raise

    # Retorna o StreamingHttpResponse injetando o gerador assíncrono
    return StreamingHttpResponse(
        event_stream(),
        content_type='text/plain'
    )
Enter fullscreen mode Exit fullscreen mode

O que acontece por baixo dos panos?

  1. O Django não aguarda a conclusão da função event_stream(). Ele retorna os cabeçalhos HTTP imediatamente.
  2. Assim que o llm_client gera o token "Olá", a função faz o yield.
  3. O ASGI intercepta o yield e o envia pela conexão TCP.
  4. O frontend recebe "Olá" quase imediatamente. O TTFT foi reduzido a frações de segundo.
  5. O loop continua até o fim da geração.

4. Estruturando os Dados com Server-Sent Events (SSE)

Embora enviar texto puro (text/plain) funcione para testes, aplicações web reais (como um frontend em React) preferem um padrão mais robusto para consumir streams em tempo real. O padrão da indústria para isso é o Server-Sent Events (SSE).

O SSE exige um formato de texto específico e um content-type próprio. Vamos adaptar nossa view para enviar os dados formatados corretamente.

# meu_app/views.py
import json
import asyncio
from django.http import StreamingHttpResponse
from meu_app.services.llm_client import AsyncLLMClient

async def chat_sse_streaming_view(request):
    user_prompt = request.GET.get('prompt', '')
    llm_client = AsyncLLMClient()

    async def sse_event_stream():
        try:
            async for token in llm_client.stream_response(user_prompt):
                # Formatação padrão SSE: "data: {seu_dado}\n\n"
                payload = json.dumps({"token": token})
                yield f"data: {payload}\n\n"

            # Sinaliza o fim do stream
            yield f"data: [DONE]\n\n"

        except asyncio.CancelledError:
            pass

    # O content-type text/event-stream avisa o navegador para tratar como SSE
    return StreamingHttpResponse(
        sse_event_stream(),
        content_type='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'X-Accel-Buffering': 'no' # Importante para Proxies
        }
    )
Enter fullscreen mode Exit fullscreen mode

No frontend (React, por exemplo), você consumiria essa API usando a API padrão do navegador, ou usando clientes robustos como o fetch com a API ReadableStream:

// Exemplo genérico no Frontend (TypeScript/React)
async function fetchStream() {
  const response = await fetch('/api/chat/stream?prompt=teste');
  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;

    const chunk = decoder.decode(value);
    console.log("Recebido:", chunk);
    // Aqui você processa o 'data: {...}' e atualiza a UI do usuário
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Dicas Práticas e Melhores Práticas

Implementar streaming no Django não envolve apenas código Python; envolve infraestrutura e gerenciamento de banco de dados. Aqui estão as regras de ouro:

Cuidado com Proxies e Buffering (A armadilha do Nginx)

De nada adianta ter um backend perfeitamente configurado com per-yield flushing se o seu proxy reverso armazena a resposta em buffer. O Nginx, por padrão, tenta acumular dados para enviá-los de forma eficiente.

Para que o streaming funcione em produção, você deve desativar o proxy buffering. No Nginx, adicione a seguinte linha na sua configuração do location:

location /api/chat/stream {
    proxy_pass http://seu_backend;
    proxy_buffering off;
    proxy_read_timeout 86400s; 
}
Enter fullscreen mode Exit fullscreen mode

(Dica: O cabeçalho X-Accel-Buffering: no adicionado na nossa view anterior ajuda a sinalizar o Nginx automaticamente em algumas configurações).

Banco de Dados no Ciclo Assíncrono

Se você precisar salvar as mensagens no banco de dados, lembre-se de que o ORM do Django, embora tenha ganhado suporte assíncrono, pode ser complexo dentro de geradores.

Use os métodos assíncronos oficiais (como await Message.objects.acreate(...)) ou envolva as operações síncronas pesadas utilizando o sync_to_async do módulo asgiref.sync:

from asgiref.sync import sync_to_async

@sync_to_async
def save_message(user, text):
    # Operação de I/O síncrona com o DB
    return Message.objects.create(user=user, content=text)
Enter fullscreen mode Exit fullscreen mode

Atenção: Faça o salvamento no banco de dados preferencialmente após o bloco async for para não atrasar a entrega dos tokens ao cliente, mantendo o TTFT baixo.

Escolha o Servidor ASGI Correto

Você não pode rodar isso usando o gunicorn tradicional com workers síncronos. Você precisa de um servidor ASGI como o Uvicorn. Em produção, o padrão da indústria é executar o Uvicorn gerenciado pelo Gunicorn:

# Comando de execução em produção
gunicorn meu_projeto.asgi:application -k uvicorn.workers.UvicornWorker --workers 4
Enter fullscreen mode Exit fullscreen mode

Conclusão

Reduzir o Time-To-First-Token não é apenas uma métrica de vaidade no monitoramento de performance; é um aspecto diretamente ligado à percepção psicológica de velocidade pelo usuário final.

Ao fazer a transição de views WSGI estáticas para views ASGI assíncronas no Django, aliadas ao poder do StreamingHttpResponse e ao padrão Server-Sent Events (SSE), você consegue extrair latências ultra baixas de sistemas que antes pareciam letárgicos.

A arquitetura de flush por yield garante que seu servidor aja como um conduite em tempo real, pegando a magia gerada pela IA e colocando-a nos olhos do usuário no exato milissegundo em que cada palavra nasce. Aplique esses padrões no seu próximo serviço de chat ou integração de LLM e sinta a diferença na experiência de uso imediato.

Top comments (0)