FastAPI: inferência de modelos em streaming com asyncio, chunking e SSE
Por John Pires · 23 de março de 2026

FastAPI: inferência de modelos em streaming com asyncio, chunking e SSE
1) Problema real que resolve
Contexto técnico
Aplicações que fazem inferência com modelos grandes (LLMs, transformadores, modelos de voz/texto) precisam devolver respostas com latência baixa e experiência progressiva. Usuários esperam ver tokens incrementais ou progresso antes do resultado final — especialmente em interfaces conversacionais, aplicativos de assistente e pipelines de ETL que fazem streaming de saídas.
Quando a inferência é feita num endpoint HTTP tradicional que retorna um JSON só no final, o cliente fica bloqueado até o processamento terminar. Isso aumenta latência percebida, degrada UX e dificulta streaming de grandes volumes de texto.
Impacto em produção
Implementações ingênuas causam consumo alto de memória (buffering completo do output), picos de latência e dificuldade para cancelar tarefas quando o cliente desiste. Em cenários com muitos usuários simultâneos, a escalabilidade é prejudicada: threads/loops ocupados, filas longas e spikes em custo de infraestrutura.
Uma solução baseada em streaming com SSE (Server-Sent Events) e asyncio permite transmitir tokens progressivamente, aplicar chunking para reduzir overhead de mensagens e controlar concorrência para evitar sobrecarregar o modelo.
2) Abordagem ingênua vs abordagem otimizada
Abordagem ingênua
A abordagem ingênua executa a inferência de forma síncrona ou bloqueante, coleta todo o resultado em memória e só então retorna um JSON com o texto completo. É simples e funciona para protótipos, mas tem vários problemas:
- Latência percebida alta: usuário só vê saída depois de tudo pronto.
- Uso de memória: buffers crescentes para respostas grandes.
- Sem cancelamento fácil: difícil parar a inferência se o cliente desconectar.
Exemplo típico: endpoint que chama model.generate(...) e retorna texto. Funciona, mas não escala nem dá boa UX.
Abordagem otimizada
A abordagem otimizada usa um pipeline baseado em asyncio: o modelo produz tokens incrementalmente (token stream), cada token entra numa fila (asyncio.Queue), o endpoint consome a fila e faz chunking — agrupando tokens antes de enviar pelo SSE — para reduzir mensagens pequenas e overhead. Além disso, usamos um semáforo (asyncio.Semaphore) para limitar concorrência de inferência, timeouts e checagem de desconexão para cancelar trabalho.
Vantagens:
- Baixa latência perceptível ao usuário.
- Menos overhead de mensagens via chunking.
- Controle de concorrência e cancelamento em caso de desconexão.
3) Implementação com código real
Simulador de modelo (async token generator)
Antes de integrar um modelo real, crie um simulador assíncrono que gera tokens com delay. Isso facilita testes e demonstra o padrão sem dependências externas.
import asyncio
async def fake_model_stream(prompt: str):
"""Simula um gerador de tokens do modelo.
Cada yield representa um token gerado incrementalmente.
"""
words = (prompt + "").split()
for i, w in enumerate(words):
await asyncio.sleep(0.05) # latência simulada por token
yield w + (" " if i < len(words) - 1 else "")
O código acima gera tokens com pause para simular latência do modelo. Em produção, substitua por streaming do SDK do modelo (ex: generate_stream em libs que suportam callback async).
Servidor FastAPI: SSE + asyncio.Queue + chunking
Este trecho é o núcleo: cria um endpoint de streaming que inicia uma tarefa de inferência, coloca tokens numa fila e envia eventos SSE agrupados por chunk_size.
from fastapi import FastAPI, Request, HTTPException
from starlette.responses import StreamingResponse
import asyncio
import json
app = FastAPI()
# Limita quantas inferências rodarão simultaneamente
INFER_SEMAPHORE = asyncio.Semaphore(4)
async def stream_generator(prompt: str, request: Request, chunk_size: int = 4):
"""Gera dados formato SSE com chunking.
- Lança uma tarefa de inferência que coloca tokens em uma queue.
- Consome a queue, agrupa tokens em chunks e envia como eventos SSE.
- Verifica request.is_disconnected() para cancelar.
"""
queue = asyncio.Queue()
stop_event = asyncio.Event()
async def producer():
# Protege o modelo com um semáforo
async with INFER_SEMAPHORE:
try:
async for token in fake_model_stream(prompt):
await queue.put(token)
if stop_event.is_set():
break
finally:
await queue.put(None) # sentinel
prod_task = asyncio.create_task(producer())
buffer = []
sent_any = False
while True:
# se o cliente desconectar, sinaliza produtor e cancela
if await request.is_disconnected():
stop_event.set()
prod_task.cancel()
break
try:
token = await asyncio.wait_for(queue.get(), timeout=5.0)
except asyncio.TimeoutError:
# envia keep-alive ocasionalmente para manter conexão
yield "data: {}\n\n".format(json.dumps({"keepalive": True}))
continue
if token is None:
# fim do stream
if buffer:
data = ''.join(buffer)
yield "data: {}\n\n".format(json.dumps({"text": data}))
break
buffer.append(token)
# envia quando buffer atingir chunk_size
if len(buffer) >= chunk_size:
data = ''.join(buffer)
yield "data: {}\n\n".format(json.dumps({"text": data}))
buffer = []
sent_any = True
# cleanup
if not prod_task.done():
prod_task.cancel()
return
@app.post('/stream')
async def stream_endpoint(request: Request):
body = await request.json()
prompt = body.get('prompt', '')
if not prompt:
raise HTTPException(status_code=400, detail='prompt required')
headers = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }
return StreamingResponse(stream_generator(prompt, request, chunk_size=8), headers=headers)
O código acima provê um endpoint POST /stream que aceita JSON {"prompt": "..."} e responde com SSE. Cada evento tem campo data: com JSON que contém {"text": "..."}.
Cliente simples em Python que consome SSE
Um consumidor simples usando httpx em modo streaming lê linhas e reconstrói eventos SSE. Útil para testes ou scripts automação.
import httpx
def read_sse_stream(url: str, prompt: str):
with httpx.stream('POST', url, json={'prompt': prompt}, timeout=None) as r:
for line in r.iter_lines():
if not line:
continue
s = line.decode('utf-8') if isinstance(line, bytes) else line
if s.startswith('data: '):
payload = s[len('data: '):]
try:
obj = json.loads(payload)
if 'text' in obj:
print(obj['text'], end='', flush=True)
except Exception:
pass
# Uso: read_sse_stream('http://localhost:8000/stream', 'hello world this is a test')
O cliente lê linhas, filtra as que começam com 'data: ' e decodifica payload JSON. Em produção, prefira bibliotecas SSE ou web frameworks no frontend que já tratam eventos.
4) Testes e validacao
Testes unitários e integração (pytest + httpx AsyncClient)
Automatize validação do streaming com testes assíncronos: verifique que o stream produz eventos parciais e final.
import pytest
import asyncio
from fastapi.testclient import TestClient
client = TestClient(app)
def test_stream_basic():
resp = client.post('/stream', json={'prompt': 'a b c d e f g h'})
assert resp.status_code == 200
# o corpo é streaming; TestClient consome tudo automaticamente
text = resp.text
assert '"text"' in text
Para testes assíncronos mais realistas (sem bloquear), use httpx.AsyncClient e verifique fragmentos conforme chegam. Mocking do model também é recomendado para controlar latências.
Benchmark e validação de performance
Para validar throughput e latência, rode benchs que simulem concorrência usando asyncio.gather para disparar várias requisições simultâneas. Medir tempo até o primeiro evento (TTFP) é essencial para UX.
Exemplo rápido (load test mínimo):
import time
import asyncio
import httpx
async def single_call(client, url):
start = time.perf_counter()
async with client.stream('POST', url, json={'prompt': 'x ' * 100}, timeout=None) as r:
# time until first chunk
async for line in r.aiter_lines():
if line and line.startswith('data: '):
return time.perf_counter() - start
return None
async def run_load(n):
async with httpx.AsyncClient() as client:
tasks = [single_call(client, 'http://localhost:8000/stream') for _ in range(n)]
return await asyncio.gather(*tasks)
# asyncio.run(run_load(10))
Esse benchmark simples retorna tempo até o primeiro chunk para N clientes paralelos. Ajuste INFER_SEMAPHORE para equilibrar latência e throughput.
5) Consideracoes de producao
Escalabilidade e recursos
Em produção, considere:
- Semáforos para limitar inferências simultâneas por instância (evita OOM e saturação GPU/CPU).
- Pool de workers ou filas externas (RabbitMQ, Redis streams, Celery) quando inferências são pesadas e podem ser enfileiradas.
- Autoscaling baseado em métricas de latência e fila – prefira HPA configurado por custom metrics (GPU utilization, queue length).
O padrão apresentado é ótimo para latência e UX, mas em cargas muito altas combine com arquitetura assíncrona de filas para smoothing.
Observabilidade, timeouts e retries
Instrumente com métricas e logs estruturados:
- TTFP (time to first partial output)
- Tempo total por inferência
- Taxa de cancelamento (cliente desconectou)
Implemente timeouts para produtores e consumidores. Em deploys com GPU, monitore memória e introduza circuit-breakers para evitar que falhas em modelos degradem serviço.
Erros comuns, armadilhas e dicas práticas
Listo os problemas que vejo com frequência:
- Não verificar desconexão: se você não checar request.is_disconnected(), continuará produzindo tokens inutilmente.
- Chunk muito pequeno: envia muitos eventos pequenos, aumentando overhead e latência de rede.
- Chunk muito grande: perde a vantagem de streaming; o usuário espera muito.
- Sem controle de concorrência: modelos saturam memória/CPU/GPU em picos de tráfego.
- Não limpar tasks: esqueça de cancelar tarefas produtoras e vazamentos acontecem.
Use chunk_size adaptativo: inicie com um valor conservador (4-16 tokens) e ajuste com base no tamanho médio do token e latência de rede. Combine com keep-alive SSE para conexões longas.
Dica prática: implemente um mecanismo de backpressure simples — se a queue ficar maior que X, reduza a taxa de aceitação de novas requisições ou faça enfileiramento externo.
Conclusao prática
Resumo das decisões
Streaming SSE + asyncio.Queue + chunking oferece uma solução balanceada entre UX e eficiência. O padrão:
- Cria uma tarefa produtora que injeta tokens em uma fila.
- Endpoint consome a fila, agrupa tokens e emite SSE com JSON.
- Semáforos controlam concorrência; request.is_disconnected() permite cancelamento.
Esse modelo reduz latência percebida, controla uso de recursos e melhora experiência em interfaces interativas.
Próximos passos recomendados
Para evoluir em produção, considere:
- Substituir simulador por SDK do modelo com streaming nativo.
- Adotar filas externas para smoothing de picos.
- Implementar métricas TTFP e alertas baseados em percentis.
Com essas práticas você terá um serviço mais resiliente, previsível e com boa UX para inferência em tempo real.
Recap rápido
Implementar inferência em streaming com FastAPI exige atenção a concorrência, chunking e cancelamento. Use asyncio primitives (Queue, Semaphore, Event), StreamingResponse com SSE e valide com testes de integração e benchmarks. Evite buffering completo e garanta limpeza de tasks para prevenir leaks.
Pronto para testar: copie os exemplos, substitua fake_model_stream pelo seu gerador real e ajuste semáforo/chunk_size conforme capacidade do host. Em seguida, rode benchmarks e monitore TTFP para calibrar parâmetros.
Assuntos: Python, asyncio, sse, inferencia, chunking
Leia também
Feature flags dinâmicos em FastAPI: toggles, rollout e cache com Redis
Padrao prático para feature flags em FastAPI usando Redis: evite clientes sincronos, use cache local com TTL, rollout deterministico por hashing e invalidacao…
Instrumentando FastAPI com OpenTelemetry: traces, métricas e logs correlacionados
Guia prático para instrumentar FastAPI com OpenTelemetry: configure Tracer/Meter, exporte OTLP, adicione spans manuais, métricas e logs correlacionados…
Pydantic v2: migrando modelos, validators custom e padrões de performance
Guia prático para migrar modelos do Pydantic v1 para v2: reescrever validators com @field_validator, usar model_construct e slots para performance, patterns…
Background tasks no FastAPI: TaskGroup, lifespan, cancelamento e retries
Guia técnico prático sobre background tasks no FastAPI: quando usar, diferenças entre BackgroundTasks e TaskGroup, como integrar no lifespan, estratégias de…
Servidor de embeddings com FastAPI, Transformers e FAISS: batching e quantização
Implementação prática de um servidor de embeddings com FastAPI, transformers e FAISS: mostro anti-patterns e correções, batching assíncrono, execução em…
FastAPI: endpoints idempotentes com Idempotency-Key e Redis (retries seguros)
Guia prático para implementar Idempotency-Key em FastAPI com Redis: locks atomicos, persistencia de respostas, scripts Lua, decorator para endpoints e boas…