Gabriel Caiana

Construindo um pipeline de IA assíncrono com Bedrock + SQS na AWS


Sumário
  1. A arquitetura real: por que tudo é assíncrono
  2. O problema de dois mundos: LocalStack + Bedrock real
  3. Titan Embeddings: busca semântica sem OpenAI
  4. Streaming: quando a IA precisa parecer uma conversa
  5. Tratamento de erros que ninguém fala
  6. O que eu mudaria se começasse hoje
  7. Resumo das decisões

No artigo anterior eu expliquei por que escolhi Amazon Bedrock em vez de OpenAI pra construir o Sovereign Architect minha plataforma de carreira pra devs. Custo, controle via IAM, consistência dentro da AWS.

Esse artigo é sobre o como.

Porque “chamar uma API de IA” é a parte fácil. Difícil é fazer isso dentro de um produto real, com tarefas que demoram 30+ segundos, sem travar o usuário, com retry, com fallback, com dev local funcionando, e com custo previsível. Aqui vai o que aprendi construindo isso.

A arquitetura real: por que tudo é assíncrono

A primeira decisão importante: nenhuma chamada pra IA é síncrona.

O produto recebe um currículo em PDF, extrai dados, gera gap analysis comparando com vagas, e devolve um roadmap personalizado. Cada etapa tem latência diferente:

  • Extrair currículo: ~30 segundos
  • Gap analysis: ~25 segundos
  • Gerar roadmap: até 45 segundos

Se você tentar fazer isso dentro de um request HTTP, o usuário vai ver um loading infinito, o ALB vai dar timeout, e a experiência vira lixo. A solução foi assincronia via SQS.

O fluxo real é esse:

  1. Usuário faz upload do currículo → API salva no S3, cria um import_job no RDS, publica ai.process_profile no SNS
  2. SNS distribui pra fila SQS ai-processing-worker-queue
  3. Worker consome, baixa o PDF do S3, extrai texto, chama Bedrock (Haiku), valida JSON com Zod, persiste no RDS
  4. Worker publica profile.processed no SNS
  5. API recebe via WebSocket/SSE e atualiza o frontend em tempo real

O usuário vê uma tela de “processando…” e em até 30 segundos aparece o perfil completo. Nenhum HTTP timeout, nenhum loading infinito, nenhum request preso.

E tem um bônus: o SQS absorve naturalmente picos de carga e retries de erro. Mais sobre isso adiante.

O problema de dois mundos: LocalStack + Bedrock real

Aqui tem uma armadilha que eu caí antes de entender o que estava acontecendo.

No desenvolvimento local, eu uso LocalStack pra emular SQS, SNS e S3. Funciona perfeito sem custo, sem precisar de conta AWS, sem risco de chamar infra real por acidente. Mas o Bedrock não tem emulação no LocalStack. Não existe um “Bedrock fake local”. Você precisa chamar o Bedrock real, com credenciais reais.

O problema surgiu quando eu coloquei AWS_ENDPOINT_URL=http://localhost:4566 nas variáveis de ambiente pra apontar pro LocalStack. O SDK da AWS intercepta essa variável em todos os clientes inclusive BedrockRuntimeClient. De repente, o worker estava tentando chamar o Bedrock no LocalStack, que não existe lá, e falhando silenciosamente.

A solução foi separar as credenciais e nunca usar AWS_ENDPOINT_URL global:

# Credenciais pra SQS/SNS/S3 → LocalStack em dev
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
LOCALSTACK_ENDPOINT=http://localhost:4566   # variável customizada, NÃO interceptada pelo SDK

# Credenciais pra Bedrock → AWS real (STS temporárias)
BEDROCK_AWS_ACCESS_KEY_ID=ASIA...
BEDROCK_AWS_SECRET_ACCESS_KEY=...
BEDROCK_AWS_SESSION_TOKEN=...

O worker cria dois conjuntos de clientes: um com endpoint do LocalStack explicitamente passado no construtor, outro com credenciais STS pra Bedrock. Os dois convivem no mesmo processo sem interferência.

Toda vez que as credenciais STS expiram (máximo 12 horas), eu renovo:

aws sts get-session-token --duration-seconds 43200

É um atrito de dev local que em produção desaparece o ECS Task usa IAM Role automático, sem token pra renovar.

Titan Embeddings: busca semântica sem OpenAI

Uma parte menos óbvia do pipeline: o match inicial entre perfil e vaga não usa Claude. Usa Titan Text Embeddings v2.

O motivo é custo e velocidade. Antes de rodar o gap analysis (Sonnet, caro), eu faço um score rápido baseado em similaridade semântica via embeddings. Converto o perfil do usuário em um vetor de 1536 dimensões, converto a vaga em outro vetor, calculo cosine similarity via pgvector no PostgreSQL.

Isso é barato, rápido, e me dá um score base de 0 a 100 antes de qualquer chamada de LLM.

SELECT 1 - (profile_embedding <=> $1::vector) AS similarity
FROM user_profiles WHERE user_id = $2

Esse score base vai como input pro prompt do Sonnet no gap analysis ele ajusta, não ignora. Algo como: “O score de embedding é 72. Analise o perfil e a vaga e me dê um score final considerando contexto que embeddings não capturam.”

Embeddings populares (React, Node.js, TypeScript) são cacheados no Redis por 24 horas. Nenhuma razão pra recalcular o embedding de “React” dez vezes por dia.

A regra geral: use embedding pra triar, LLM pra raciocinar. Embedding é centavos, LLM é dólares.

Streaming: quando a IA precisa parecer uma conversa

Tem uma feature no produto que usa um padrão diferente: entrevistas simuladas. O usuário entra num modo de entrevista, responde perguntas, e o sistema dá feedback em tempo real.

Aqui latência importa de forma diferente. Não é “espera 30 segundos e recebe o resultado”. É “começa a ver a resposta em menos de 1 segundo e ela vai aparecendo como se alguém estivesse digitando”.

O Bedrock tem InvokeModelWithResponseStream. Em vez de esperar o modelo terminar de gerar, você recebe chunks de texto conforme são produzidos. O worker faz stream desses chunks via SSE pro frontend.

O Claude 3.5 Sonnet via Bedrock tem Time-to-First-Token de ~500–800ms. Pra uma entrevista simulada onde o usuário acabou de terminar de digitar uma resposta esse delay é imperceptível.

Se eu precisasse de latência ainda menor, o Haiku chega a ~200–400ms. Mas a qualidade do feedback de entrevista com Haiku é inferior. É um tradeoff consciente: experiência mais fluída ou qualidade de feedback? Fui de Sonnet.

Lição: streaming não é só “aparece mais rápido”. É um padrão diferente de UX que muda a percepção de latência mesmo quando a latência total é maior.

Tratamento de erros que ninguém fala

Quando o Bedrock devolve JSON inválido e isso acontece, mais raro com Claude 3.5 mas acontece você precisa de uma estratégia.

A minha: até 2 re-tentativas com um prompt mais rígido. Algo como: “Você retornou algo que não é JSON válido. Tente novamente e retorne EXCLUSIVAMENTE JSON, sem texto adicional, sem markdown.” Na terceira falha, marco o job como failed no RDS, publico evento de falha no SNS, notifico o usuário pra preencher manualmente.

Pra ThrottlingException (429 do Bedrock): backoff exponencial. 1s, 4s, 16s. A mensagem SQS volta pra fila com delay aumentado. Isso é uma das vantagens do pipeline assíncrono o SQS absorve o retry de forma natural, sem precisar de lógica de retry síncrono no request.

Fallback de modelo: se Sonnet estiver indisponível, a extração estruturada cai pro Haiku automaticamente. Gap analysis não tem fallback se Sonnet falhar, o job fica pendente e tenta de novo mais tarde. Não vale degradar a qualidade do core do produto.

A regra geral: retry assíncrono > retry síncrono. SQS + DLQ resolve 80% dos casos sem você escrever uma linha de retry.

O que eu mudaria se começasse hoje

Duas coisas.

Primeiro: Bedrock Prompt Caching desde o dia zero. Os system prompts do roadmap e do gap analysis são longos. Em cada chamada, esses tokens são cobrados na entrada. Prompt Caching do Bedrock reduz o custo de tokens de entrada em até 90% pra chamadas que reutilizam o mesmo system prompt. Deixei pra depois, mas deveria ser configuração padrão desde o início.

Segundo: métricas de tokens desde o primeiro deploy. Eu comecei a instrumentar tokens_in, tokens_out, latency_ms no CloudWatch com o sistema em produção. Deveria ter sido desde o dia um. Sem isso, você está voando cego no custo. Você só descobre que um modelo está consumindo mais tokens do que esperado quando a fatura chega.

Se você está começando agora, faça os dois antes de qualquer feature. É 30 minutos de trabalho que evita meses de cegueira.

Resumo das decisões

As escolhas que mais impactaram o resultado:

DecisãoMotivo
Tudo assíncrono via SQSNenhum HTTP segura tarefa de IA
Credenciais separadas Bedrock / LocalStackLOCALSTACK_ENDPOINT customizado, nunca AWS_ENDPOINT_URL global
Embedding triagem, LLM raciocínioTitan + pgvector antes de Sonnet
Streaming pra interação, batch pra processamentoPadrões diferentes pra UX diferente
Retry no SQS, não no códigoBackoff exponencial via DLQ + visibility timeout
Métricas de token + Prompt Caching desde o inícioNão voar cego no custo

Nenhuma dessas escolhas é óbvia até você estar com o sistema rodando. Mas todas elas exceto as duas últimas, que eu deixei pra depois eu tomaria de novo.


Esse é o segundo de uma série sobre a construção do Sovereign Architect. No primeiro, falei sobre por que escolhi Bedrock em vez de OpenAI. Os próximos vão entrar em prompt engineering pra extração estruturada e o sistema de matching com embeddings.