Capítulo 3: Fine-Tuning e Otimização

Nos capítulos anteriores, exploramos a arquitetura dos Transformers e entendemos como foundation models são treinados em escala massiva usando trilhões de tokens. Porém, esses modelos pré-treinados possuem capacidades gerais impressionantes, mas frequentemente precisam ser adaptados para domínios específicos ou tarefas particulares onde seu desempenho genérico não é suficiente.

Imagine que você precisa construir um agente de IA especializado em análise de contratos jurídicos. Um modelo como GPT-4 ou LLaMA possui conhecimento geral sobre linguagem natural e alguns conceitos legais, mas não foi otimizado para entender a terminologia específica, precedentes jurídicos brasileiros ou formatos contratuais particulares da sua aplicação. Ainda mais se tratando de modelos treinados por empresas estrangeiras, que podem não ter acesso a dados locais relevantes ou nem mesmo tem interesse em se especializar nos casos específicos de cada país.

Para suprir essa necessidade, poderíamos treinar um modelo do zero, claro, mas sabemos que isso é extremamente caro e demorado. Portanto, outra abordagem se torna viável, o fine-tuning, o processo de ajustar um modelo pré-treinado usando dados específicos de algum domínio ou tarefa. Mas fine-tuning não é apenas “treinar mais um pouco”. Envolve decisões importantes sobre quais parâmetros atualizar (todos os 70 bilhões de parâmetros de um LLaMA ou apenas uma fração pequena?), como balancear custo e performance (fine-tuning completo pode custar dezenas de milhares de dólares em GPUs), como evitar catastrophic forgetting (o modelo pode “esquecer” capacidades gerais aprendidas anteriormente), e como avaliar se realmente melhorou (métricas tradicionais nem sempre capturam qualidade em tarefas complexas).

Para endereçar essas questões, neste capítulo, exploraremos as técnicas modernas que tornam fine-tuning prático e econômico, desde abordagens tradicionais até métodos revolucionários como LoRA e QLoRA que democratizaram o acesso a modelos grandes. Também entenderemos quantização, RLHF (o segredo por trás de assistentes como ChatGPT) e frameworks para selecionar o modelo certo para cada situação.

Fine-Tuning: Adaptando Foundation Models

Existem várias estratégias de fine-tuning, cada uma adequada para diferentes cenários. Vamos explorar as principais abordagens, começando pelas mais diretas até técnicas avançadas de parameter-efficient fine-tuning.

Supervised Fine-Tuning (SFT)

Supervised Fine-Tuning (SFT) é a forma mais direta de adaptar um modelo pré-treinado para uma tarefa específica. Envolve treinar o modelo em um dataset rotulado onde cada input tem um output desejado. Portanto, continuamos o treinamento com next-token prediction, mas usando um dataset curado de exemplos de alta qualidade para a tarefa alvo.

Exemplo de Dataset para SFT:

Dataset: [
  {"input": "Qual a capital da França?", "output": "A capital da França é Paris."},
  {"input": "Resuma este artigo: ...", "output": "Este artigo discute..."},
  {"input": "Qual é a fórmula da água?", "output": "A fórmula da água é H2O."},
  {"input": "Quem escreveu 'Dom Casmurro'?", "output": "Machado de Assis escreveu 'Dom Casmurro'."},
  {"input": "Qual é a capital do Brasil?", "output": "A capital do Brasil é Brasília."},
  ...
]

O modelo aprende a seguir o padrão de input-output dos exemplos. Com dados suficientes e de qualidade (previamente selecionados, limpos e balanceados), isso pode melhorar bastante a performance em tarefas específicas.

Uma forma particular de SFT é o Instruction Tuning, onde os exemplos são formatados como instruções seguidas de respostas. Isso treina o modelo a seguir instruções arbitrárias, não apenas responder perguntas específicas. Datasets como FLAN, Alpaca e Dolly contêm dezenas ou centenas de milhares de exemplos instruction-response.

Eu recomendo que você acesse os links desses datasets para entender melhor como eles são estruturados. Isso pode ajudar bastante na hora de criar seus próprios datasets para fine-tuning.

Continual Pre-Training (Domain Adaptation)

Se você tem um domínio específico (ex: legal, médico, financeiro), pode continuar o pré-treinamento não-supervisionado usando corpus desse domínio utilizando a técnica de Continual Pre-Training (também chamado de Domain Adaptation).

Por exemplo, podemos pegar um modelo geral como LLaMA-2 e continuar o pré-treinamento em um grande corpus de textos específicos:

Existem várias fontes públicas de dados especializados:

Domínio Recurso Descrição
Legal FreeLaw Project Milhões de casos jurídicos dos EUA
LexML Brasil Legislação e jurisprudência brasileira
EUR-Lex Direito da União Europeia
Pile of Law 256GB de textos legais (papers, casos, contratos)
Médico PubMed Central 8+ milhões de artigos médicos open access
MIMIC-III Registros clínicos anonimizados (requer credenciamento)
Medical Meadow Datasets médicos curados
Financeiro SEC EDGAR Relatórios financeiros de empresas públicas dos EUA
Financial Phrasebank Sentenças financeiras anotadas
FiQA Question-answering financeiro

Isso expõe o modelo a terminologia, padrões e conhecimento domain-specific. Mas é crucial usar uma mixture do corpus original (~80%) e corpus de domínio (~20%) para evitar catastrophic forgetting, o fenômeno onde o modelo “esquece” capacidades gerais ao se especializar demais.

Construindo Seu Próprio Corpus

Para domínios específicos da sua organização, talvez você precise construir seu próprio corpus. Algumas estratégias incluem web scraping usando ferramentas como Scrapy ou BeautifulSoup para coletar dados públicos, uso de APIs de plataformas como PubMed API ou arXiv API, aproveitamento de dados internos como documentação, relatórios e emails (com aprovação de privacidade), e parcerias com universidades ou instituições do setor. Sempre verifique licenças e conformidade com LGPD/GDPR antes de usar dados.

Parameter-Efficient Fine-Tuning (PEFT)

Até aqui, discutimos fine-tuning como se atualizar todos os parâmetros fosse trivial. Mas na prática, fine-tuning completo de modelos grandes é proibitivamente caro para a maioria das organizações, pesquisadores, estudantes e entusiastas.

O fine-tuning completo de um modelo de 70B parâmetros requer carregar todos os 70B parâmetros na memória (280GB em FP32, 140GB em FP16), armazenar gradientes para cada parâmetro (mais 70B = 140GB em FP16), manter optimizer states do Adam com momentum e variance para cada parâmetro em FP32 (mais 140B × 2 × 4 bytes = 560GB), totalizando aproximadamente 840GB de memória sem otimizações, ou cerca de 560GB com técnicas como ZeRO e gradient checkpointing.

Para contexto, a GPU NVIDIA A100 tem 80GB de memória, então você precisa de 7-8 GPUs A100 só para carregar tudo, com custo de aproximadamente $25,000/mês em cloud para treinar, levando entre dias a semanas dependendo do dataset.

Isso acaba sendo viável somente para grandes empresas com orçamentos massivos ou aquelas que estão transferindo dinheiro de investidores umas para as outras sem dar um retorno real. Para o resto de nós, há o PEFT, Parameter-Efficient Fine-Tuning.

A técnica PEFT mantêm a maioria dos parâmetros congelados e atualizam apenas um subset pequeno (0.1-1% dos parâmetros). Isso reduz drasticamente os requisitos de memória e computação. Para modelos de 70B, isso significa treinar apenas ~70M a 700M parâmetros.

A intuição é que adaptar um modelo pré-treinado para uma nova tarefa não requer re-aprender tudo, apenas ajustar um pequeno conjunto de parâmetros.

Uma das técnicas para realizar essa adaptação é a LoRA (Low-Rank Adaptation) (Hu et al. 2021), que consiste em injetar matrizes de baixo rank adaptáveis nas camadas de atenção:

W' = W + BA
onde B ∈ R^(d×r), A ∈ R^(r×d), r << d

Apenas A e B são treinados. Com r=8 e d=4096, isso adiciona apenas 8×4096×2 = 65K parâmetros por matriz, comparado a 16M para a matriz completa. Redução de ~250×.

LoRA é muito eficaz, por vezes alcança performance comparável a fine-tuning completo com <<1% dos parâmetros treináveis. A hipótese é que adaptação de task requer apenas modificações low-rank porque os “intrinsic dimensions” das tarefas são baixas.

Seguindo a evolução natural das técnicas e estudos da área, surge a técnica QLoRA (Quantized LoRA) (Dettmers et al. 2023) que combina LoRA com quantização de 4-bit do modelo base (veremos sobre quantização logo mais), permitindo fine-tuning de modelos 70B em uma única GPU de 48GB. A inovação está em manter o modelo base congelado e quantizado enquanto treina adapters LoRA em precisão total.

Modelo Base (4-bit, congelado)  →  LoRA Adapters (FP16, treináveis)
         ↓                                    ↓
    W_quantized              +           W_lora = BA
         ↓                                    ↓
    Dequantize temporariamente para computação
         ↓
    W_dequant + BA  →  Saída

Economia de Memória Prática (Modelo 70B):

Componente Full Fine-Tuning (FP16) LoRA (FP16) QLoRA (4-bit)
Modelo Base 140 GB 140 GB 35 GB
Gradientes 140 GB 0.14 GB (apenas LoRA) 0.14 GB
Optimizer States 560 GB 0.56 GB 0.56 GB
Total ~840 GB ~280 GB ~48 GB

Com QLoRA, fine-tuning de modelos 70B cabe em uma NVIDIA A6000 (48GB) ou RTX 4090 (24GB) com gradient checkpointing.

Performance vs. Full Fine-Tuning:

Em benchmarks como MMLU e HumanEval, QLoRA alcança 99-99.5% da performance de full fine-tuning (Dettmers et al. 2023), tornando-se o método de escolha para fine-tuning com recursos limitados.

Quantização: Reduzindo Requisitos de Memória

Quantização é uma técnica usada para tornar LLMs mais acessíveis, reduzindo os requisitos de memória e computação ao representar pesos e ativações do modelo com menor precisão numérica. É graças a quantização que conseguimos rodar modelos grandes em hardware modesto, como GPUs consumidoras ou até mesmo CPUs.

Por padrão, modelos são treinados em FP32 (float de 32 bits) ou BF16/FP16 (16 bits), porque a precisão numérica é crítica para o desempenho do modelo. Se não temos precisão numérica suficiente, o modelo pode falhar em aprender ou generalizar. Um modelo de 70B parâmetros em FP16 requer:

70B parâmetros × 2 bytes = 140GB de memória

Isso exclui a maioria das GPUs consumidoras (uma RTX 4090 tem 24GB). Quantização resolve isso reduzindo bits por parâmetro. Ou seja representamos os pesos do modelo com menos bits, como 8-bit, 4-bit ou até menos.

Vamos explorar os principais tipos de quantização e seus trade-offs.

Tipos de Quantização

1. Post-Training Quantization (PTQ)

Quantiza um modelo já treinado sem retreinamento. Rápido e simples, mas pode ter perda de qualidade em quantizações agressivas.

Exemplo com Transformers + bitsandbytes:

# Carregar modelo quantizado em INT4 (4-bit) via PTQ
model_int4 = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    load_in_4bit=True,  # Quantização automática 4-bit
    device_map="auto"
)
# Memória: ~3.5GB (75% de redução vs. FP16)
NotaImplementação Completa

Para o exemplo completo comparando FP16, INT8 e INT4, veja o Exemplo 2: Benchmark de Quantização.

2. Quantization-Aware Training (QAT)

Treina o modelo sabendo que será quantizado, incluindo efeitos de quantização no gradiente. Melhor qualidade, mas requer retreinamento completo. QAT simula quantização durante o treinamento, permitindo que o modelo aprenda a compensar erros de quantização.

DicaQAT vs PTQ

Quando usar QAT: precisão crítica (modelos para produção), quantizações agressivas (INT4, INT3), ou quando há tempo disponível para retreinamento.

Quando usar PTQ: prototipagem rápida, quantização moderada (INT8), ou quando o modelo já está treinado e não pode ser retreinado.

Ganhos Práticos de Quantização

A tabela abaixo mostra o trade-off memória vs. qualidade para um modelo de 70B parâmetros. INT8 é o sweet spot para produção (50% menos memória, perda imperceptível), enquanto INT4 democratiza acesso permitindo rodar modelos grandes em GPUs consumidoras com qualidade aceitável.

Precisão Memória (70B) Qualidade Caso de Uso
FP16 140GB 100% (baseline) Treinamento, inferência premium
INT8 70GB ~99% Inferência produção
INT4 35GB ~95-97% Consumer GPUs, edge devices
INT3 26GB ~90-93% Experimental, edge extremo

Reinforcement Learning from Human Feedback (RLHF)

Reinforcement Learning from Human Feedback (RLHF) (Ouyang et al. 2022), é uma técnica avançada para alinhar LLMs com preferências humanas, indo além do que o Supervised Fine-Tuning (SFT) pode alcançar. Enquanto o SFT pode criar modelos que seguem instruções, mas não necessariamente produzem outputs que humanos preferem, RLHF utiliza feedback humano direto para treinar modelos que geram respostas mais úteis, seguras e alinhadas com valores humanos.

Isso significa que em vez de apenas aprender a prever o próximo token com base em dados rotulados, o modelo aprende a maximizar uma função de recompensa que reflete as preferências humanas reais.

O que acontece por meio de algumas etapas, que podem ser resumidas assim:

  1. Coletar Comparações: Humanos classificam pares de respostas para um mesmo prompt
  2. Treinar Reward Model: Executamos Supervised Learning para prever qual resposta os humanos preferirão
  3. Policy Optimization: Usamos o reward model como função de recompensa para fazer o fine-tune via Reinforcement Learning

Matemáticamente, o objetivo é otimizar a seguinte função:

Objective: maximize E[R(output)] - β·KL(π || π_ref)

Onde R é o reward model, π é a política sendo otimizada, π_ref é a política de referência (SFT model), e β controla quanto a política pode divergir.

Algoritmos populares incluem PPO (Proximal Policy Optimization) e DPO (Direct Preference Optimization). DPO é mais recente e mais simples, evita treinar um reward model separado, enquanto o PPO trabalha diretamente com recompensas.

RLHF é crucial para modelos comerciais como ChatGPT e Claude. Transforma modelos tecnicamente competentes em assistentes realmente úteis que humanos gostam de usar.

Avaliando os Modelos

Como Sabemos se Funcionou?

Avaliar LLMs é bem difícil. Métricas tradicionais como perplexidade ou BLEU são imperfeitas. Assim como tudo em engenharia, temos pontos bons, pontos medianos e pontos ruins em todas as técnicas que usamos. Perplexidade não correlaciona bem com qualidade percebida, BLEU/ROUGE funcionam para tradução/sumarização, mas não para geração open-ended. Por isso, abordagens modernas incluem: benchmarks padronizados, avaliação humana e LLMs como juízes.

Vamos explorar isso a partir de agora.

Benchmarks

Benchmarks são conjuntos padronizados de tarefas usadas para medir e comparar o desempenho de modelos em várias capacidades. Eles fornecem uma maneira objetiva de avaliar melhorias e comparar diferentes arquiteturas ou técnicas de treinamento.

Existem algumas suites de tarefas padronizadas validadas pelo mercado que podemos utilizar como benchmarks para avaliar LLMs. MMLU (Hendrycks et al. 2021) cobre 57 tarefas em conhecimento STEM, humanidades e outras áreas. Big-Bench possui mais de 200 tarefas diversas incluindo raciocínio, compreensão e criatividade. HumanEval (Chen et al. 2021) mede correção em geração de código (usado para avaliar GitHub Copilot). GSM8K (Cobbe et al. 2021) testa capacidade em problemas matemáticos de nível escolar.

Porém, benchmarks têm suas limitações. Os modelos podem ter visto os dados de teste durante o pré-treinamento (contaminação), e muitos benchmarks já estão saturados por modelos top-tier. Além disso, benchmarks podem não refletir tarefas do mundo real que você quer que o modelo faça.

Avaliação Humana

A avaliação humana envolve pedir a pessoas reais para julgarem a qualidade, utilidade, correção e preferência entre outputs de modelos. É considerado o gold standard das avaliações porque captura aspectos subjetivos que métricas automatizadas falham, como fluência e naturalidade da linguagem, relevância prática, segurança e criatividade.

Exemplo: Chatbot Arena

Chatbot Arena (Zheng et al. 2023) (LMSYS) usa uma abordagem elegante chamada avaliação cega. O sistema funciona mostrando respostas de dois modelos anônimos para uma pergunta real do usuário, que então escolhe qual resposta é melhor (ou declara empate). Com milhares de comparações, o sistema calcula rankings estilo Elo de xadrez. O resultado são rankings que refletem preferências humanas reais em tarefas diversas, disponíveis em lmarena.ai/leaderboard, divididos por categorias úteis como geração de texto, código, visão e busca.

As vantagens da avaliação humana neste processo incluem captura de preferências reais, detecção de nuances sutis como toxicidade e viés, e avaliação de capacidades emergentes difíceis de medir automaticamente. Como desvantagens, temos o fato de que a avaliação humana é cara, pode demorar semanas para coletar dados suficientes, não é escalável para iteração rápida e pode ter variabilidade entre avaliadores. Use para avaliações finais de modelos prontos para produção, decisões de release e validação de melhorias críticas.

LLM-as-Judge

Uma alternativa moderna à avaliação humana é usar LLMs fortes como juízes automáticos para avaliar outputs de outros modelos. Isso é chamado de LLM-as-Judge. A ideia é simples: usamos um modelo grande e capaz (ex: GPT-4, Claude, Gemini) para ler a pergunta do usuário e a resposta gerada por outro modelo, e pedir que o LLM avalie a qualidade da resposta com base em critérios específicos.

Vamos seguir o exemplo de avaliação de resposta a uma pergunta para entender o processo:

prompt_judge = f"""
Você é um avaliador imparcial. Avalie a qualidade da resposta abaixo na pergunta dada.

Pergunta: {user_question}
Resposta: {model_output}

Critérios:
1. Correção factual (0-5)
2. Completude (0-5)
3. Clareza (0-5)
4. Utilidade prática (0-5)

Forneça scores e justificativa.
"""

evaluation = gpt4.complete(prompt_judge)

O processo de LLM-as-judge correlaciona bem com julgamento humano em muitas tarefas. Aqui estão alguns pontos chave:

  • Correlação ~0.7-0.85 com preferências humanas
  • Muito mais consistente que humanos individuais
  • 1000× mais rápido e 100× mais barato

Casos de uso práticos:

  1. Comparação de modelos: “Modelo A ou B é melhor para esta tarefa?”
  2. Avaliação de fine-tuning: “O fine-tuning melhorou a performance?”
  3. Detecção de regressões: “Mudança X degradou qualidade?”
  4. Ranking de respostas: Ordenar múltiplas respostas por qualidade

Limitações:

LLM-as-judge possui alguns vieses conhecidos. Primeiro, há viés de comprimento, onde judges tendem a preferir respostas mais longas mesmo quando concisão é melhor. Segundo, há viés de estilo, onde preferem outputs que parecem com seu próprio estilo de geração (GPT-4 judge prefere outputs que “soam como GPT-4”, Claude judge prefere outputs estruturados como Claude estrutura). Terceiro, modelos apresentam auto-preferência, tendendo a dar scores mais altos para seus próprios outputs (aproximadamente 10-15% bias). Quarto, há limitação de capacidade, o judge precisa ser significativamente mais capaz que os modelos avaliados (GPT-4 avaliando Llama-3-8B funciona bem, mas Llama-3-8B avaliando GPT-4 não é confiável). Por fim, judges podem não detectar erros factuais sutis em domínios técnicos especializados.

Melhores Práticas

Para mitigar limitações e melhorar a confiabilidade, use múltiplos judges para reduzir viés calculando a mediana dos scores, faça blind evaluation sem revelar qual modelo gerou o output, e calibre com avaliação humana comparando um subset de 100-500 exemplos até atingir correlação acima de 0.8.

Com base no que sabemos até aqui sobre LLM-as-Judge, use LLM como juiz para iteração rápida durante desenvolvimento, monitoramento contínuo de qualidade em produção, A/B testing automatizado de mudanças, e pre-screening antes de avaliação humana custosa. Não use para decisões críticas de release (sempre validar com humanos), avaliação de modelos state-of-the-art (judge pode não ser superior), ou domínios altamente especializados sem judges treinados. Lembre-se que não substitui completamente avaliação humana e o judge precisa ser significativamente mais capaz que os modelos sendo avaliados.

NotaImplementação Completa

Para implementação completa de LLM-as-judge com Claude, veja o Exemplo 3: LLM-as-Judge para Avaliação.

Seleção do Modelo Certo para Cada Tarefa

A paisagem de LLMs disponíveis é vasta e vem crescendo. Pra mim é impossível acompanhar tudo. Neste cenário, como escolher o modelo certo para sua aplicação de agente de IA?

Vamos explorar os principais trade-offs e considerações para selecionar o modelo certo. Com este racional, você pode tomar decisões informadas baseadas em requisitos específicos.

Considerações Fundamentais

1. Tamanho do Modelo e Capacidade

Modelos maiores são mais capazes, porém mais custosos:

Tamanho Parâmetros Capacidades Latência Custo
Nano <1B Tarefas simples, seguir comandos básicos <50ms $0.001/1K tokens
Small 1-10B Raciocínio moderado, tasks bem-definidas 50-200ms $0.01/1K tokens
Medium 10-70B Raciocínio complexo, multitask 200-1000ms $0.10/1K tokens
Large 70-500B+ Raciocínio avançado, few-shot generalization 1-5s $1-10/1K tokens
NotaSobre os Valores de Latência e Custo

Os valores de latência são aproximações típicas para inferência em GPUs modernas (A100/H100) com batching otimizado. Latência real varia significativamente com: comprimento de contexto, otimizações de servidor, quantização, e infraestrutura.

Os custos são estimativas baseadas em preços de APIs comerciais (OpenAI, Anthropic, Google). Custos de self-hosting podem ser 5-10× menores para alto volume, mas requerem investimento em infraestrutura. Preços variam por provedor e tendem a cair ao longo do tempo (~50% por ano historicamente).

Para custos atualizados: OpenAI Pricing, Anthropic Pricing, Google AI Pricing

Para agentes, você raramente precisa do modelo mais poderoso para todas as tarefas. Uma arquitetura comum é:

  • Modelo pequeno para tasks frequentes e simples (routing, formatting)
  • Modelo médio para tasks core (analysis, generation)
  • Modelo grande para tasks críticas e complexas (strategic planning, edge cases)

2. Encoder vs. Decoder

Modelos encoder-only (estilo BERT) são melhores para tarefas de compreensão com inputs fixos como classificação, NER e semantic search, mas não geram texto. Modelos decoder-only (estilo GPT) são versáteis, podem fazer compreensão E geração, e são dominantes para agentes. Modelos encoder-decoder (estilo T5) são úteis para tasks sequence-to-sequence explícitas como tradução e sumarização. Para agentes autônomos, decoder-only é quase sempre a escolha certa.

3. Context Window

O tamanho do context window determina quanto input/output o modelo pode processar de uma vez, impactando diretamente a capacidade de lidar com tarefas longas ou mais complexas. Modelos legacy como GPT-3.5 possuem 4K tokens (aproximadamente 3K palavras), muitos modelos small-medium possuem 8K tokens, modelos modernos como GPT-4 e Claude 2 possuem 32K-128K tokens, e modelos como Claude 3 e Gemini 1.5 Pro possuem 200K+ tokens.

Context windows maiores são cruciais para RAG (incluir múltiplos documentos retrieved), conversas longas (manter histórico extenso), análise de código (processar codebases inteiras), e raciocínio complexo (incluir chain-of-thought extenso). Porém, modelos com context windows muito grandes são mais lentos e mais caros. Procure usar o mínimo necessário.

4. Velocidade e Latência

Para sistemas interativos, latência importa. Modelos locais oferecem menos de 100ms mas com capacidade limitada. API models pequenos levam 200-500ms. API models grandes levam 1-5s ou mais.

Técnicas para reduzir latência incluem speculative decoding (usa modelo pequeno para gerar drafts, modelo grande para verificar), streaming (retorna tokens incrementalmente em vez de esperar resposta completa), caching (cacheia prompts comuns e prefixos), e batching (processa múltiplas requests em paralelo).

5. Custo

Custo de inferência varia em ordens de magnitude:

Modelo Input Output
GPT-3.5 Turbo $0.0005/1K $0.0015/1K
GPT-4 Turbo $0.01/1K $0.03/1K
Claude 3 Opus $0.015/1K $0.075/1K
Claude 3 Haiku $0.00025/1K $0.00125/1K
Llama 3 70B (self-hosted) ~$0.001/1K ~$0.002/1K

Nota: Valores podem mudar frequentemente. Consulte fontes oficiais.

Para aplicações de alto volume, custos se acumulam rapidamente. Estratégias incluem cascade (use modelo mais barato primeiro, escale para modelo mais caro apenas se necessário), caching (cachear agressivamente resultados de queries similares), quantização (self-host modelos quantizados 4-bit para reduzir computação), e prompt compression (comprimir prompts longos sem perder informação).

6. Privacidade e Segurança

Para aplicações empresariais, considerações de privacidade e segurança são determinantes. API models (OpenAI, Anthropic) enviam dados para servidores third-party. Existem opções de não treinar nos dados do usuário, mas dados ainda atravessam fronteiras e você não tem controle total, dependendo da confiança no provedor. Self-hosted models oferecem controle completo sobre dados, mas requerem infraestrutura massiva e expertise na gestão de modelos. Para setores regulados (healthcare, finance), self-hosting muitas vezes é até mesmo obrigatório.

Framework de Decisão

Para ajudar na seleção do modelo certo, aqui está um framework de decisão simplificado baseado em requisitos comuns que você pode adaptar para seus casos específicos:

INÍCIO
  |
  ├─ Complexidade da tarefa?
  |    ├─ Simples (classificação, extração)
  |    |    └─> Modelo encoder (BERT) ou decoder pequeno (7B)
  |    ├─ Moderada (análise, Q&A)
  |    |    └─> Decoder médio (70B), GPT-3.5, Claude 2, Gemini 1
  |    └─ Complexa (raciocínio, criatividade)
  |         └─> Decoder grande (GPT-4, Claude Opus)
  |
  ├─ Requisitos de latência?
  |    ├─ Tempo real (<100ms)
  |    |    └─> Modelo pequeno local
  |    ├─ Interativo (<1s)
  |    |    └─> API rápida ou modelo médio self-hosted
  |    └─ Batch
  |         └─> Qualquer modelo
  |
  ├─ Volume/Orçamento?
  |    ├─ Baixo volume
  |    |    └─> API proprietária (OpenAI GPT, Claude, Gemini)
  |    ├─ Médio volume
  |    |    └─> Mix de modelos (cascade) ou proprietário médio
  |    └─ Alto volume
  |         └─> Self-hosted open-source
  |
  └─ Requisitos de privacidade?
       ├─ Rigoroso (HIPAA, LGPD/GDPR estrito)
       |    └─> Apenas self-hosted
       └─ Moderado
            └─> API com acordo de não-treinamento

As empresas possuem APIs com conformidade regulatória e acordos de privacidade que podem ser suficientes para muitos casos. Mas para dados sensíveis, self-hosting é a única opção segura.

Pratique o que Aprendeu

Este capítulo apresentou técnicas essenciais de fine-tuning e otimização, incluindo LoRA, QLoRA, quantização, RLHF e estratégias de avaliação de modelos.

Para consolidar seu entendimento através de implementação prática, consulte os Exercícios Práticos do Capítulo 3, que incluem:

  1. Fine-Tuning com LoRA: Adapte um modelo para tradução de termos técnicos usando PEFT
  2. Benchmark de Quantização: Compare performance de modelos em INT8, INT4 e INT3
  3. Sistema LLM-as-Judge: Implemente avaliação automática de qualidade de respostas
  4. Projeto Final: Sistema completo de fine-tuning, quantização e avaliação

Os exercícios fornecem código executável e guias passo a passo para aplicação prática dos conceitos apresentados.

Conclusão

Neste capítulo, exploramos as técnicas essenciais para adaptar e otimizar foundation models para casos de uso específicos. Compreendemos diferentes abordagens de fine-tuning, desde supervised fine-tuning completo até técnicas parameter-efficient como LoRA e QLoRA que democratizam o acesso ao fine-tuning de modelos grandes.

Mergulhamos profundamente em quantização, entendendo como reduzir drasticamente os requisitos de memória com perdas mínimas de qualidade, tornando possível rodar modelos de 70B parâmetros em hardware consumer. Vimos exemplos práticos com llama.cpp e discutimos os trade-offs entre diferentes níveis de quantização.

Exploramos RLHF, a técnica crucial que transforma modelos tecnicamente competentes em assistentes verdadeiramente úteis e alinhados com preferências humanas. Entendemos também as diferentes abordagens de avaliação, desde benchmarks padronizados até avaliação humana e o uso de LLMs como juízes.

Por fim, desenvolvemos um framework prático para selecionar o modelo certo para cada tarefa, considerando fatores como tamanho, velocidade, custo, context window e requisitos de privacidade.

Este conhecimento sobre fine-tuning e otimização é essencial para construir agentes de IA eficientes e econômicos em produção. Com essas técnicas, você pode adaptar modelos para suas necessidades específicas, balanceando custo, performance e requisitos de infraestrutura.

Nos próximos capítulos, aplicaremos essas técnicas para construir agentes autônomos capazes de realizar tarefas complexas de forma independente. Prepare-se para colocar em prática tudo o que aprendeu até aqui!

Exemplos Completos de Código

Esta seção apresenta implementações completas e executáveis dos conceitos principais discutidos no capítulo. Cada exemplo é standalone e pode ser executado diretamente.

Exemplo 1: Fine-Tuning com LoRA

Implementação completa de fine-tuning usando LoRA para adaptar um modelo para tradução de termos técnicos PT→EN.

# uv pip install transformers peft datasets accelerate bitsandbytes torch

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from peft import LoraConfig, get_peft_model
import json
import os

# Configuração
MODEL_NAME = "gpt2"  # Use modelos maiores se tiver recursos
OUTPUT_DIR = "./gpt2-tech-translator-lora"

print("🔄 Carregando modelo base...")

# Carregar modelo e tokenizer
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    device_map="auto",
    torch_dtype=torch.float16,
)

print(f"✅ Modelo carregado: {MODEL_NAME}")
print(f"📊 Parâmetros totais: {model.num_parameters():,}")

# Configurar LoRA
print("\n🔧 Configurando LoRA...")

lora_config = LoraConfig(
    r=16,                        # Rank dos adapters
    lora_alpha=32,               # Scaling factor
    target_modules=["c_attn"],   # Para GPT-2
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)

model = get_peft_model(model, lora_config)

# Mostrar redução de parâmetros
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
total_params = sum(p.numel() for p in model.parameters())
trainable_percentage = (trainable_params / total_params) * 100

print(f"📊 Parâmetros treináveis: {trainable_params:,} ({trainable_percentage:.2f}%)")
print(f"💾 Redução: ~{100 - trainable_percentage:.1f}%")

# Preparar dados de exemplo
train_data = [
    {"input": "Traduza: aprendizado de máquina", "output": "machine learning"},
    {"input": "Traduza: rede neural", "output": "neural network"},
    {"input": "Traduza: inteligência artificial", "output": "artificial intelligence"},
    {"input": "Traduza: processamento de linguagem natural", "output": "natural language processing"},
    {"input": "Traduza: aprendizado profundo", "output": "deep learning"},
]

def format_prompt(example):
    """Formata exemplo como prompt de instrução"""
    return f"{example['input']}\nResposta: {example['output']}"

# Tokenizar dataset
def tokenize_function(examples):
    """Tokeniza exemplos para treinamento"""
    prompts = [format_prompt(ex) for ex in examples]
    tokenized = tokenizer(
        prompts,
        truncation=True,
        padding="max_length",
        max_length=128,
        return_tensors="pt"
    )
    tokenized["labels"] = tokenized["input_ids"].clone()
    return tokenized

print("\n🔄 Tokenizando dataset...")
from torch.utils.data import Dataset

class SimpleDataset(Dataset):
    def __init__(self, data):
        self.data = tokenize_function(data)
    
    def __len__(self):
        return len(self.data['input_ids'])
    
    def __getitem__(self, idx):
        return {
            'input_ids': self.data['input_ids'][idx],
            'attention_mask': self.data['attention_mask'][idx],
            'labels': self.data['labels'][idx]
        }

train_dataset = SimpleDataset(train_data)

# Configurar treinamento
training_args = TrainingArguments(
    output_dir=OUTPUT_DIR,
    num_train_epochs=3,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    fp16=True,
    logging_steps=10,
    save_steps=100,
    save_total_limit=2,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
)

# Treinar
print("\n🚀 Iniciando fine-tuning...")
trainer.train()

print("\n✅ Fine-tuning completo!")

# Salvar modelo
print(f"\n💾 Salvando adapters LoRA em {OUTPUT_DIR}...")
model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)

# Testar
print("\n🧪 Testando modelo...")
test_prompt = "Traduza: computação em nuvem"
inputs = tokenizer(test_prompt, return_tensors="pt").to(model.device)

with torch.no_grad():
    outputs = model.generate(**inputs, max_new_tokens=10, do_sample=False)

result = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(f"Prompt: {test_prompt}")
print(f"Output: {result}")

print("\n💡 Adapters LoRA salvos e prontos para deploy!")

Resultados esperados:

  • Parâmetros treináveis: ~0.1-0.5% do total
  • Tempo de treinamento: minutos (vs. horas/dias para full fine-tuning)
  • Tamanho dos adapters: ~10-50MB (vs. GB para modelo completo)
  • Performance: comparável a fine-tuning completo para tarefas específicas

Exemplo 2: Benchmark de Quantização

Script completo para comparar diferentes níveis de quantização medindo memória, latência e throughput.

# uv pip install llama-cpp-python psutil

import time
import psutil
import os
from typing import Dict

try:
    from llama_cpp import Llama
except ImportError:
    print("❌ Instale: uv pip install llama-cpp-python")
    raise

def get_memory_usage() -> float:
    """Retorna uso de memória em MB"""
    process = psutil.Process(os.getpid())
    return process.memory_info().rss / 1024 / 1024

def benchmark_model(model_path: str, test_prompt: str) -> Dict:
    """
    Realiza benchmark de um modelo quantizado.
    
    Returns:
        Dict com métricas: load_time, memory_mb, gen_time, tokens_per_sec
    """
    if not os.path.exists(model_path):
        raise FileNotFoundError(f"Modelo não encontrado: {model_path}")
    
    mem_before = get_memory_usage()
    
    # Carregar modelo
    start_load = time.time()
    llm = Llama(model_path=model_path, n_ctx=512, n_threads=4, verbose=False)
    load_time = time.time() - start_load
    
    mem_after = get_memory_usage()
    mem_used = mem_after - mem_before
    
    # Inferência
    start_gen = time.time()
    output = llm(test_prompt, max_tokens=100, temperature=0.7, top_p=0.9, echo=False)
    gen_time = time.time() - start_gen
    
    tokens_generated = output['usage']['completion_tokens']
    tokens_per_sec = tokens_generated / gen_time if gen_time > 0 else 0
    
    del llm
    
    return {
        "load_time": load_time,
        "memory_mb": mem_used,
        "gen_time": gen_time,
        "tokens_per_sec": tokens_per_sec,
        "output": output['choices'][0]['text']
    }

# Configuração de modelos
# Baixe modelos GGUF de: https://huggingface.co/TheBloke/Llama-2-7B-GGUF
model_paths = {
    "Q8_0": "./models/llama-2-7b.Q8_0.gguf",
    "Q5_K_M": "./models/llama-2-7b.Q5_K_M.gguf",
    "Q4_K_M": "./models/llama-2-7b.Q4_K_M.gguf",
}

test_prompt = "Explain quantum computing in simple terms:"

print("="*80)
print("🔬 BENCHMARK: Quantização de Modelos")
print("="*80)

results = {}

for quant, path in model_paths.items():
    if not os.path.exists(path):
        print(f"\n⚠️  {quant}: Modelo não encontrado em {path}")
        continue
    
    print(f"\n{'='*80}")
    print(f"Testando: {quant}")
    print(f"{'='*80}\n")
    
    try:
        metrics = benchmark_model(path, test_prompt)
        results[quant] = metrics
        
        print(f"✅ Carregado em {metrics['load_time']:.2f}s")
        print(f"💾 Memória: {metrics['memory_mb']:.0f} MB")
        print(f"⚡ Geração: {metrics['gen_time']:.2f}s")
        print(f"📊 Throughput: {metrics['tokens_per_sec']:.1f} tokens/s")
        print(f"📝 Output: {metrics['output'][:100]}...")
    except Exception as e:
        print(f"❌ Erro: {e}")

# Comparação
if results:
    print(f"\n{'='*80}")
    print("📊 COMPARAÇÃO DE PERFORMANCE")
    print(f"{'='*80}\n")
    
    print(f"{'Quant':<10} {'Memória (MB)':<15} {'Load (s)':<12} {'Tokens/s':<12}")
    print("-"*80)
    
    for quant, m in results.items():
        print(f"{quant:<10} {m['memory_mb']:<15.0f} {m['load_time']:<12.2f} {m['tokens_per_sec']:<12.1f}")

print("\n💡 Recomendações:")
print("  - Q8_0: Melhor qualidade, maior memória")
print("  - Q5_K_M: Ótimo equilíbrio qualidade/memória")
print("  - Q4_K_M: Máxima eficiência, qualidade aceitável")

Exemplo 3: LLM-as-Judge para Avaliação

Sistema de avaliação automática usando LLM como juiz para comparar respostas de modelos.

# uv pip install anthropic

import anthropic
import json
import os
from typing import Dict

# Configurar API key
# export ANTHROPIC_API_KEY="sua-chave"
api_key = os.environ.get("ANTHROPIC_API_KEY")

if not api_key:
    raise ValueError(
        "❌ Defina ANTHROPIC_API_KEY como variável de ambiente\n"
        "   export ANTHROPIC_API_KEY='sua-chave'\n"
        "   Obtenha em: https://console.anthropic.com/"
    )

client = anthropic.Anthropic(api_key=api_key)

def evaluate_with_judge(question: str, answer: str, expected: str) -> Dict:
    """
    Usa Claude como judge para avaliar resposta.
    
    Returns:
        Dict com scores de 0-5 e justificativa
    """
    judge_prompt = f"""Você é um avaliador imparcial de respostas de modelos de IA.

Pergunta: {question}
Resposta esperada: {expected}
Resposta do modelo: {answer}

Avalie a resposta nos critérios abaixo (0-5):
1. Correção factual
2. Completude
3. Clareza

Retorne JSON:
{{
    "factual": <0-5>,
    "completeness": <0-5>,
    "clarity": <0-5>,
    "overall": <0-5>,
    "justification": "<explicação breve>"
}}
"""
    
    response = client.messages.create(
        model="claude-3-haiku-20240307",
        max_tokens=500,
        messages=[{"role": "user", "content": judge_prompt}]
    )
    
    try:
        return json.loads(response.content[0].text)
    except json.JSONDecodeError:
        return {"error": "Failed to parse judge response"}

# Exemplo de uso
print("="*60)
print("🧪 AVALIAÇÃO COM LLM-AS-JUDGE")
print("="*60 + "\n")

# Simular respostas de dois modelos
test_cases = [
    {
        "question": "Qual é a capital da França?",
        "expected": "Paris",
        "model_a": "Paris",
        "model_b": "A capital francesa é Paris, localizada no norte do país."
    },
    {
        "question": "O que é machine learning?",
        "expected": "Aprendizado de máquina é um subcampo da IA",
        "model_a": "É quando computadores aprendem",
        "model_b": "Machine learning é um subcampo da inteligência artificial que permite sistemas aprenderem com dados."
    }
]

results_a = []
results_b = []

for case in test_cases:
    print(f"\n📝 Avaliando: {case['question']}")
    
    eval_a = evaluate_with_judge(case['question'], case['model_a'], case['expected'])
    eval_b = evaluate_with_judge(case['question'], case['model_b'], case['expected'])
    
    results_a.append(eval_a)
    results_b.append(eval_b)
    
    print(f"\nModelo A: {case['model_a']}")
    print(f"  Score: {eval_a.get('overall', 'N/A')}/5")
    
    print(f"\nModelo B: {case['model_b']}")
    print(f"  Score: {eval_b.get('overall', 'N/A')}/5")

# Resumo
print("\n" + "="*60)
print("📊 RESUMO COMPARATIVO")
print("="*60)

avg_a = sum(r.get('overall', 0) for r in results_a) / len(results_a)
avg_b = sum(r.get('overall', 0) for r in results_b) / len(results_b)

print(f"\nModelo A - Score médio: {avg_a:.2f}/5")
print(f"Modelo B - Score médio: {avg_b:.2f}/5")

if avg_b > avg_a:
    winner = "Modelo B"
elif avg_a > avg_b:
    winner = "Modelo A"
else:
    winner = "Empate"

print(f"\n🏆 Vencedor: {winner}")

Insights sobre LLM-as-Judge:

  • Velocidade: 1000× mais rápido que avaliação humana
  • Custo: 100× mais barato que crowdsourcing
  • Consistência: Mais consistente que humanos individuais
  • Limitações: Viés de comprimento, auto-preferência, requer judge superior aos modelos avaliados