Objetivo da Aula
Ao concluir esta aula, você será capaz de:
Implementar backoff exponencial com jitter para retry de rate limits de API
Construir extração robusta de JSON de respostas de LLM usando estratégias em cascata
Criar chains de prompt que passam contexto estruturado entre etapas sequenciais
Aplicar estratégias de anti-alucinação baseadas em restrições de output e grounding explícito
Debugar falhas em pipelines de prompt identificando onde a cadeia quebrou e por quê
Por que isso importa
Prompts de produção são diferentes de prompts de playground. No playground, você tenta uma vez, vê se funcionou, edita e tenta de novo. Em produção, seu código precisa lidar com rate limits, respostas malformadas, contexto que chega em partes, e LLMs que às vezes inventam dados.
O custo de não fazer isso direito é alto. Uma startup de análise de documentos legais implantou um pipeline RAG sem validação de JSON — quando o Claude retornou JSON com comentários (formato inválido para json.loads()), o pipeline inteiro travou em produção por 2 horas, afetando contratos em revisão. O fix eram 5 linhas de código de extração robusta.
Um segundo problema comum: rate limits. APIs de LLM têm limites de requests por minuto. Em carga alta, você os atinge. Sem retry adequado, cada request que falha é perdido. Com retry ingênuo (tenta de novo imediatamente), você piora o problema — 10 workers tentando simultaneamente quando o rate limit é atingido cria uma tempestade que demora muito mais para se resolver.
E o problema mais insidioso: alucinações. LLMs inventam fatos com total confiança. Em aplicações de suporte ao cliente, análise de documentos, ou qualquer domínio onde precisão importa, alucinações são bugs que chegam ao usuário final como verdades.
Esta aula te dá as ferramentas técnicas para lidar com cada um desses problemas sistematicamente.
Conceitos Fundamentais
Por Que LLMs Falham em JSON
Fundamento: Saída do LLM é Texto Probabilístico
APIs REST tradicionais garantem o formato da resposta — o servidor retorna o schema contratado ou um erro. LLMs não têm essa garantia: a saída é texto gerado probabilisticamente, e o modelo pode adicionar texto antes do JSON, usar aspas simples em vez de duplas, incluir comentários inválidos, ou truncar antes de fechar o objeto. Isso não é bug — é a natureza do modelo. A prática de pedir "retorne JSON" no prompt aumenta a probabilidade de JSON válido, mas não garante. Provedores oferecem mecanismos nativos mais fortes: JSON mode (força estrutura de objeto JSON), structured outputs com schema (força campos e tipos específicos) — use esses quando disponíveis antes de recorrer à extração defensiva por regex.
Pedir a um LLM para retornar JSON parece simples, mas há várias formas de falhar:
Falha 1: Texto antes/depois do JSON
Claro! Aqui está a análise:
{"topicos": ["ML", "IA"], "nivel": "intermediario"}
Espero que ajude!
json.loads() falha porque há texto fora do JSON.Falha 2: JSON com comentários
{
"topicos": ["ML"], // análise técnica
"nivel": "avancado"
}JSON não suporta comentários. json.loads() falha.
Falha 3: Aspas simples em vez de duplas
{'topicos': ['ML'], 'nivel': 'avancado'}JSON requer aspas duplas. É Python dict notation, não JSON válido.
Falha 4: Markdown code block
```json
{"topicos": ["ML"]}
O modelo às vezes envolve JSON em blocos de código markdown.
**Falha 5: Truncamento por max_tokens**
```json
{"topicos": ["Machine Learning", "Redes NeO JSON foi cortado no meio porque atingiu o limite de tokens. Resultado inválido.
A solução é uma função de extração em cascata que tenta cada estratégia em ordem:
import json
import re
def extrair_json(texto: str) -> dict:
# Estratégia 1: JSON direto (caso feliz)
try:
return json.loads(texto)
except json.JSONDecodeError:
pass
# Estratégia 2: Remove blocos markdown
# ``` json\n{...}\n``` ou ```\n{...}\n```
match = re.search(r'```(?:json)?\s*(\{[\s\S]+?\})\s*```', texto)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# Estratégia 3: Encontra qualquer { ... } ou [ ... ]
match = re.search(r'(\{[\s\S]+\}|\[[\s\S]+\])', texto)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# Estratégia 4: Tenta reparar aspas simples → duplas
try:
reparado = texto.replace("'", '"')
return json.loads(reparado)
except json.JSONDecodeError:
pass
# Nenhuma estratégia funcionou
return {'erro': 'JSON inválido', 'texto_original': texto}Backoff Exponencial: A Matemática
Rate limits são expressos em “X requests por minuto”. Quando você excede, a API retorna HTTP 429 e indica quando você pode tentar de novo (header Retry-After).
A estratégia correta é backoff exponencial com jitter:
Tentativa 1 falha → aguarda 2^0 + jitter = 1.0 + rand(0, 0.5) ≈ 1.3s
Tentativa 2 falha → aguarda 2^1 + jitter = 2.0 + rand(0, 0.5) ≈ 2.3s
Tentativa 3 falha → aguarda 2^2 + jitter = 4.0 + rand(0, 0.5) ≈ 4.2s
Tentativa 4 falha → desiste e propaga erroO jitter é crítico. Imagine 50 workers que todos atingem o rate limit no mesmo segundo. Sem jitter, todos tentam de novo no mesmo segundo, e o rate limit acontece de novo. Com jitter, as tentativas se espalham no tempo, e a API “respira”.
Cap no delay máximo: Em sistemas de alta disponibilidade, você também coloca um cap (ex: máximo 30s de wait) para não deixar requests presos por minutos:
delay = min(2 ** tentativa + random.uniform(0, 1), 30) # cap em 30sPrompt Chaining: O Padrão
Chaining é quebrar uma tarefa complexa em etapas menores onde o output de uma etapa alimenta o input da próxima. É como um pipeline Unix: cat file | grep pattern | sort | uniq.
Por que usar? - Cada passo tem uma tarefa clara → menos erro e alucinação - Você pode inspecionar e validar outputs intermediários - Você pode usar modelos diferentes para diferentes etapas (Haiku para classificação simples, Sonnet para síntese complexa) - Mais fácil de debugar quando falha (sabe exatamente qual etapa)
Estrutura básica:
# Etapa 1: Extração de fatos
resultado_extracao = chamar_llm(prompt_extracao(texto_bruto))
fatos = extrair_json(resultado_extracao)
# Etapa 2: Usa fatos da etapa 1 para classificar
resultado_classificacao = chamar_llm(
prompt_classificacao(fatos["topicos"], fatos["nivel"])
)
classificacao = extrair_json(resultado_classificacao)
# Etapa 3: Usa resultado de ambas as etapas para gerar saída final
resultado_final = chamar_llm(
prompt_relatorio(fatos, classificacao)
)Estratégias de Anti-Alucinação
LLMs alucinam porque são otimizados para gerar texto plausível, não necessariamente verdadeiro. As estratégias efetivas para reduzir alucinação:
1. Grounding explícito (RAG) Forneça a fonte de verdade no prompt e instrua o modelo a só usar essa fonte:
Responda APENAS com base no texto abaixo. Se a informação não estiver no texto,
diga "não encontrei essa informação no texto fornecido."
TEXTO: {documento}
PERGUNTA: {pergunta}2. Solicitar citação de fonte
Para cada afirmação factual, cite o parágrafo de onde extraiu a informação.
Se não conseguir citar, não inclua a afirmação.3. Output estruturado com campo de confiança
{
"resposta": "...",
"confianca": "alta|media|baixa",
"justificativa": "baseado no parágrafo 3 que diz '...'"
}4. Pedido de cadeia de raciocínio (CoT)
Antes de responder, pense passo a passo:
1. Quais fatos relevantes estão no texto?
2. Quais inferências posso fazer com segurança?
3. O que preciso deduzir (menor confiança)?
Então dê sua resposta final.5. Consistência via múltiplas chamadas Para decisões críticas, chame o LLM 3x com temperatura alta e use a resposta majoritária (self-consistency):
respostas = [chamar_llm(prompt, temperature=0.7) for _ in range(3)]
# Use votação majoritária ou pede ao modelo para escolher a melhorAprofundamento Técnico
O Pattern de Retry Completo
import time
import random
import anthropic
client = anthropic.Anthropic()
def chamar_com_retry(
mensagens: list[dict],
max_tokens: int = 500,
tentativas: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
) -> str:
"""
Backoff exponencial com jitter e cap máximo.
Diferencia entre erros recuperáveis e não-recuperáveis.
"""
ultimo_erro = None
for tentativa in range(tentativas):
try:
response = client.messages.create(
model='claude-haiku-4-5-20251001',
max_tokens=max_tokens,
messages=mensagens,
)
return response.content[0].text
except anthropic.RateLimitError as e:
# Recuperável — rate limit temporário
ultimo_erro = e
if tentativa == tentativas - 1:
raise
delay = min(base_delay * (2 ** tentativa) + random.uniform(0, 0.5), max_delay)
print(f"[rate_limit] Tentativa {tentativa+1}/{tentativas}. Aguardando {delay:.1f}s...")
time.sleep(delay)
except anthropic.APIConnectionError as e:
# Recuperável — problema de rede temporário
ultimo_erro = e
if tentativa == tentativas - 1:
raise
delay = min(base_delay * (2 ** tentativa), max_delay)
print(f"[connection_error] Tentativa {tentativa+1}/{tentativas}. Aguardando {delay:.1f}s...")
time.sleep(delay)
except anthropic.AuthenticationError:
# NÃO recuperável — chave de API inválida. Não adianta tentar de novo.
raise
except anthropic.APIError as e:
# Outros erros da API — recuperável em alguns casos (5xx), não em outros (4xx)
if e.status_code and 400 <= e.status_code < 500:
raise # erros de cliente (bad request, etc.) — não tentar de novo
ultimo_erro = e
if tentativa == tentativas - 1:
raise
delay = min(base_delay * (2 ** tentativa), max_delay)
time.sleep(delay)
raise RuntimeError(f"Falhou após {tentativas} tentativas: {ultimo_erro}")Extração Robusta de JSON com Validação de Schema
Uma função de extração realmente robusta não só extrai o JSON, mas valida que tem a estrutura esperada:
import json
import re
from typing import Any, Optional
def extrair_json(texto: str) -> dict:
"""Extrai JSON de texto que pode ter conteúdo ao redor."""
# Estratégia 1: texto puro é JSON
try:
return json.loads(texto.strip())
except json.JSONDecodeError:
pass
# Estratégia 2: JSON em bloco de código markdown
match = re.search(r'```(?:json)?\s*\n?([\s\S]+?)\n?```', texto)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# Estratégia 3: Primeira ocorrência de { ... } válido
# Usa greedy para pegar o objeto mais externo completo
match = re.search(r'\{[\s\S]+\}', texto)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
# Estratégia 4: Primeira ocorrência de [ ... ] (para arrays)
match = re.search(r'\[[\s\S]+\]', texto)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
return {'erro': 'JSON inválido após todas as estratégias', 'texto_original': texto[:200]}
def validar_schema(dados: dict, campos_obrigatorios: list[str]) -> tuple[bool, str]:
"""Valida que o JSON extraído tem os campos esperados."""
campos_faltando = [c for c in campos_obrigatorios if c not in dados]
if campos_faltando:
return False, f"Campos ausentes: {campos_faltando}"
if 'erro' in dados:
return False, f"Extração falhou: {dados['erro']}"
return True, "ok"
def chamar_com_json_garantido(
prompt: str,
campos_obrigatorios: list[str],
max_tentativas: int = 3,
) -> dict:
"""
Chama o LLM até conseguir um JSON válido com os campos esperados.
Usa o erro da tentativa anterior como feedback no prompt seguinte.
"""
historico = [{'role': 'user', 'content': prompt}]
for tentativa in range(max_tentativas):
resposta_texto = chamar_com_retry(historico, max_tokens=600)
dados = extrair_json(resposta_texto)
valido, motivo = validar_schema(dados, campos_obrigatorios)
if valido:
return dados
# Adiciona ao histórico como se fosse conversa multi-turn
# O modelo vê sua resposta anterior e a instrução de correção
historico.append({'role': 'assistant', 'content': resposta_texto})
historico.append({
'role': 'user',
'content': (
f"Sua resposta anterior não era JSON válido ou faltavam campos. "
f"Motivo: {motivo}. "
f"Campos obrigatórios: {campos_obrigatorios}. "
f"Retorne APENAS o JSON, sem texto adicional."
)
})
raise ValueError(f"Não conseguiu JSON válido após {max_tentativas} tentativas")Prompt Chaining com Contexto Acumulado
O padrão de chain onde cada etapa recebe os resultados de todas as anteriores:
def analisar_artigo_chain(titulo: str, resumo: str) -> dict:
"""
Chain 2 etapas:
1. Extrai metadados do artigo
2. Classifica audiência usando os metadados
"""
# === ETAPA 1: Análise de conteúdo ===
prompt_etapa1 = f"""Analise o artigo científico de IA abaixo e retorne JSON.
ARTIGO:
Título: {titulo}
Resumo: {resumo}
Retorne EXATAMENTE este JSON (sem texto adicional):
{{
"topicos": ["lista de tópicos técnicos"],
"nivel": "iniciante|intermediario|avancado",
"tem_codigo": true|false,
"area_principal": "NLP|CV|RL|teoria|aplicado|outro"
}}"""
texto_etapa1 = chamar_com_retry([{'role': 'user', 'content': prompt_etapa1}])
resultado_etapa1 = extrair_json(texto_etapa1)
# Validação intermediária — falha rápido se etapa 1 quebrou
campos_etapa1 = ['topicos', 'nivel', 'tem_codigo']
for campo in campos_etapa1:
if campo not in resultado_etapa1:
raise ValueError(f"Etapa 1 falhou: campo '{campo}' ausente. Resposta: {texto_etapa1[:200]}")
# === ETAPA 2: Classificação de audiência ===
# Usa os resultados da etapa 1 como contexto estruturado
prompt_etapa2 = f"""Com base na análise de um artigo científico, classifique a audiência ideal.
ANÁLISE DO ARTIGO:
- Tópicos: {', '.join(resultado_etapa1['topicos'])}
- Nível: {resultado_etapa1['nivel']}
- Tem código: {resultado_etapa1['tem_codigo']}
Retorne EXATAMENTE este JSON:
{{
"audiencia": "descrição da audiência ideal",
"prerequisitos": ["conhecimento 1", "conhecimento 2"],
"tempo_leitura_min": número_inteiro
}}"""
texto_etapa2 = chamar_com_retry([{'role': 'user', 'content': prompt_etapa2}])
resultado_etapa2 = extrair_json(texto_etapa2)
return {
'titulo': titulo,
'analise': resultado_etapa1,
'classificacao': resultado_etapa2,
}Por que passar contexto estruturado (JSON) entre etapas, não texto livre?
Compare as duas abordagens para passar dados da etapa 1 para a etapa 2:
# RUIM: passa texto livre da etapa 1
prompt_etapa2 = f"""Com base nesta análise: '{texto_etapa1}', classifique..."""
# O modelo precisa "re-parsear" o texto para entender os dados
# BOM: passa dados estruturados
prompt_etapa2 = f"""Tópicos: {resultado_etapa1['topicos']}
Nível: {resultado_etapa1['nivel']}"""
# O modelo recebe dados precisos, sem ambiguidadeContexto estruturado reduz a chance de erro de interpretação e torna a cadeia mais confiável.
Exemplos Anotados
Exemplo 1: O Problema do Retry Ingênuo vs Correto
import time
import anthropic
client = anthropic.Anthropic()
# ========================================
# VERSÃO INGÊNUA — não faça assim
# ========================================
def retry_ruim(prompt, tentativas=3):
for i in range(tentativas):
try:
return client.messages.create(
model='claude-haiku-4-5-20251001',
max_tokens=200,
messages=[{'role': 'user', 'content': prompt}]
).content[0].text
except Exception:
time.sleep(1) # sleep fixo — piora o thundering herd
raise RuntimeError("Falhou")
# Problema: 50 workers falham ao mesmo tempo → 50 workers tentam de novo
# 1s depois (juntos) → falham de novo → loop até o rate limit scaler
# resolver o problema por conta própria
# ========================================
# VERSÃO CORRETA — exponential backoff + jitter
# ========================================
import random
def retry_correto(prompt, tentativas=3):
for tentativa in range(tentativas):
try:
return client.messages.create(
model='claude-haiku-4-5-20251001',
max_tokens=200,
messages=[{'role': 'user', 'content': prompt}]
).content[0].text
except anthropic.RateLimitError:
if tentativa == tentativas - 1:
raise
# delay = 2^0=1s, 2^1=2s, 2^2=4s + jitter de 0-0.5s
# Workers diferentes terão delays ligeiramente diferentes
delay = (2 ** tentativa) + random.uniform(0, 0.5)
print(f"Rate limit. Aguardando {delay:.2f}s...")
time.sleep(delay)
except anthropic.AuthenticationError:
raise # não tentar de novo — chave inválida
# Resultado: requests se espalham no tempo, rate limit se resolveExemplo 2: Pipeline Completo com Validação em Cada Etapa
import json
import re
import anthropic
client = anthropic.Anthropic()
def extrair_json(texto: str) -> dict:
"""Extração em cascata — tenta 4 estratégias."""
# Estratégia 1: JSON direto
try:
return json.loads(texto.strip())
except json.JSONDecodeError:
pass
# Estratégia 2: Remove markdown code block
match = re.search(r'```(?:json)?\s*\n?([\s\S]+?)\n?```', texto)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# Estratégia 3: Encontra { ... }
match = re.search(r'\{[\s\S]+\}', texto)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
# Falhou tudo
return {'erro': 'JSON inválido', 'raw': texto[:200]}
def analisar_artigo_com_validacao(titulo: str, resumo: str) -> dict:
"""
Pipeline com:
1. Retry para falhas de API
2. Extração robusta de JSON
3. Validação de campos obrigatórios
4. Correção automática se etapa falhar
"""
# ETAPA 1: Extração de metadados
prompt1 = f"""Analise este artigo e retorne APENAS JSON válido, sem texto adicional.
Título: {titulo}
Resumo: {resumo[:500]}
JSON esperado:
{{
"topicos": ["topico1", "topico2"],
"nivel": "iniciante|intermediario|avancado",
"tem_codigo": true,
"area_principal": "NLP|CV|RL|teoria|aplicado|outro"
}}"""
# Retry com backoff
for tentativa in range(3):
try:
resp1 = client.messages.create(
model='claude-haiku-4-5-20251001',
max_tokens=300,
messages=[{'role': 'user', 'content': prompt1}]
)
break
except anthropic.RateLimitError:
if tentativa == 2:
raise
time.sleep(2 ** tentativa + random.uniform(0, 0.5))
analise = extrair_json(resp1.content[0].text)
# Validação de campos obrigatórios
for campo in ['topicos', 'nivel', 'tem_codigo']:
if campo not in analise or 'erro' in analise:
raise ValueError(
f"Etapa 1 produziu JSON inválido. Resposta: {resp1.content[0].text[:200]}"
)
# ETAPA 2: Classificação de audiência
# Usa dados estruturados da etapa 1, não texto bruto
prompt2 = f"""Classifique a audiência ideal para um artigo de IA.
Dados do artigo:
- Tópicos: {analise['topicos']}
- Nível: {analise['nivel']}
- Tem código de exemplo: {analise['tem_codigo']}
- Área: {analise.get('area_principal', 'não especificado')}
Retorne APENAS JSON:
{{
"audiencia": "descrição em 1 frase",
"prerequisitos": ["prerequisito1", "prerequisito2"],
"tempo_leitura_min": 15
}}"""
resp2 = client.messages.create(
model='claude-haiku-4-5-20251001',
max_tokens=300,
messages=[{'role': 'user', 'content': prompt2}]
)
classificacao = extrair_json(resp2.content[0].text)
return {
'titulo': titulo,
'analise': analise, # resultado da etapa 1
'classificacao': classificacao, # resultado da etapa 2
'tokens_usados': (
resp1.usage.input_tokens + resp1.usage.output_tokens +
resp2.usage.input_tokens + resp2.usage.output_tokens
)
}
# Teste
resultado = analisar_artigo_com_validacao(
titulo='Attention is All You Need',
resumo='Apresentamos o Transformer, arquitetura baseada exclusivamente em atenção.'
)
print(f"Análise: {resultado['analise']}")
print(f"Classificação: {resultado['classificacao']}")
print(f"Tokens totais do pipeline: {resultado['tokens_usados']}")Padrões e Armadilhas
Padrões Corretos
Padrão 1: Separar concerns de retry da lógica de negócio
# CORRETO: retry é infraestrutura, fica numa função separada
def com_retry(fn, tentativas=3): ...
def analisar(texto: str) -> dict:
return com_retry(lambda: chamar_api(texto))
# ERRADO: retry misturado com lógica de negócio — difícil de testar e manter
def analisar(texto: str) -> dict:
for i in range(3):
try:
resultado = chamar_api(texto)
# ... mais 50 linhas de parsing ...
except:
time.sleep(2**i)Padrão 2: Falhar rápido em erros não-recuperáveis Chave inválida (AuthenticationError), parâmetros inválidos (BadRequestError), input maior que context window — nenhum desses vai melhorar com retry. Detecte e propague imediatamente.
Padrão 3: Multi-turn para correção de JSON Se o JSON extraído está inválido, em vez de só tentar de novo com o mesmo prompt, adicione ao histórico de mensagens a resposta ruim do modelo e uma instrução de correção:
historico = [
{'role': 'user', 'content': prompt_original},
{'role': 'assistant', 'content': resposta_invalida}, # o que o modelo retornou
{'role': 'user', 'content': 'Sua resposta não era JSON válido. Retorne APENAS JSON.'},
]O modelo vê seu próprio erro e tende a corrigir.
Armadilhas
⚠️ Armadilha 1: Usar except Exception para tudo no retry
# ERRADO: captura erros não-recuperáveis e tenta de novo desnecessariamente
except Exception:
time.sleep(2**tentativa)
# CORRETO: diferencia por tipo de erro
except anthropic.RateLimitError:
# recuperável
except anthropic.AuthenticationError:
raise # não recuperável — imediatamente⚠️ Armadilha 2: max_tokens muito baixo trunca o JSON
# PERIGOSO: se o JSON gerado for maior que 200 tokens, vai truncar
max_tokens=200 # para prompt que pede JSON com 5 campos
# Calcule: cada campo do JSON ≈ 10-20 tokens
# JSON com 5 campos complexos ≈ 100-300 tokens
# Dê margem: max_tokens >= 2x o tamanho esperado do JSON
max_tokens=600⚠️ Armadilha 3: Passar texto livre (não estruturado) entre etapas de chain
# ERRADO: o texto da etapa 1 vai para o prompt da etapa 2 como texto bruto
prompt_etapa2 = f"Com base nesta análise: '{texto_resposta_etapa1}', classifique..."
# O modelo precisa "re-entender" o texto, o que pode gerar erros
# CORRETO: parseia JSON da etapa 1 e passa campos específicos
analise = extrair_json(texto_resposta_etapa1)
prompt_etapa2 = f"Tópicos: {analise['topicos']}\nNível: {analise['nivel']}"⚠️ Armadilha 4: Não validar outputs intermediários do chain
# ERRADO: passa o resultado da etapa 1 para a etapa 2 sem checar
resultado_etapa1 = extrair_json(resposta1)
prompt_etapa2 = f"Topicos: {resultado_etapa1['topicos']}" # KeyError se campo ausente!
# CORRETO: valida antes de usar
if 'topicos' not in resultado_etapa1:
raise ValueError(f"Etapa 1 falhou — campo 'topicos' ausente")⚠️ Armadilha 5: Temperatura alta em prompts que pedem JSON
# PERIGOSO: temperature alta → mais criatividade → mais chance de malformar JSON
temperature=0.9 # para prompt de extração de dados
# CORRETO: temperatura baixa para outputs estruturados
temperature=0.0 # máximo determinismo para JSON
temperature=0.1 # ou muito baixa — permite alguma variação mas mantém estruturaSe não for realizar o laboratório, pule para o próximo capítulo.
Ponte para o Lab
O starter tem 3 TODOs que cobrem os conceitos centrais desta aula:
TODO 1 — Em chamar_com_retry(), a estrutura de retry já existe mas falta o backoff exponencial. O código atual tem time.sleep(2**tentativa) como comentário. A versão completa deve adicionar jitter: espera = 2**tentativa + random.uniform(0, 0.5) e time.sleep(espera). O if tentativa == tentativas - 1: raise já está correto — na última tentativa, propaga o erro. Seção de referência: Conceitos Fundamentais → “Backoff Exponencial”.
TODO 2 — Em extrair_json(), implemente extração em cascata. A função atual só tenta json.loads() diretamente. Adicione: primeiro tente direto, depois regex para markdown code block (```json ... ```), depois regex para { ... } qualquer. Se tudo falhar, retorne {'erro': 'JSON inválido', 'texto_original': texto[:100]}. Seção de referência: Conceitos Fundamentais → “Por Que LLMs Falham em JSON” e Exemplos Anotados → Exemplo 2.
TODO 3 — Em analisar_artigo_chain(), a etapa 2 já tem o placeholder. Construa um segundo prompt que usa resultado_analise['topicos'] e resultado_analise['nivel'] como input estruturado. O prompt deve pedir JSON com campos audiencia, prerequisitos, e tempo_leitura_min. Chame chamar_com_retry() com esse prompt e retorne o JSON extraído. Seção de referência: Aprofundamento Técnico → “Prompt Chaining com Contexto Acumulado”.
Após implementar os três TODOs, o pipeline vai: chamar a API com retry automático → extrair JSON robustamente → passar dados estruturados entre etapas. O main() do starter testa com dois artigos reais e imprime os resultados de cada etapa.
Agora você está pronto para o lab.