Capítulo 1: Fundamentos dos Transformers

Em 2017, um paper relativamente curto da Google Brain mudou para sempre o curso da inteligência artificial. “Attention Is All You Need” (Vaswani et al. 2017) introduziu a arquitetura Transformer, que na época parecia apenas mais uma melhoria incremental para tradução neural. Poucos imaginavam que essa arquitetura se tornaria a base de praticamente todos os avanços revolucionários em IA da última década.

Hoje, quando você interage com ChatGPT, Claude ou qualquer outro agente inteligente moderno, está usando um sistema fundamentado em Transformers. Quando GitHub Copilot completa seu código, quando DALL-E gera imagens a partir de texto, ou quando AlphaCode resolve problemas de programação competitiva - todos esses sistemas compartilham a mesma arquitetura base.

Mas o que torna os Transformers tão especiais? Por que essa arquitetura específica desbloqueou capacidades que pareciam impossíveis apenas alguns anos atrás? E, mais importante para construir agentes de IA: o que você realmente precisa entender sobre Transformers para usá-los efetivamente em produção?

Este capítulo responde essas questões. Não apenas explicando o que são Transformers, mas revelando por que eles funcionam, quais são suas limitações práticas, e como escolher e otimizar modelos baseados nessa arquitetura para suas aplicações específicas.

Vamos começar entendendo o contexto histórico que tornou os Transformers necessários, depois mergulhar em sua mecânica interna, e finalmente explorar as otimizações modernas que os tornam práticos em escala de produção.

Introdução: A Revolução dos Foundation Models

Os foundation models representam uma mudança de paradigma em como construímos sistemas de IA. Antes deles, cada aplicação de machine learning seguia o mesmo padrão custoso e repetitivo. Para cada nova tarefa - classificação de sentimentos, tradução automática, reconhecimento de entidades - engenheiros precisavam definir requisitos específicos, depois investir meses na coleta e anotação manual de milhares de exemplos. Com o dataset finalmente pronto, restava ainda treinar um modelo do zero ou adaptar um modelo pré-treinado genérico através de transfer learning limitado. Pior ainda, esse processo inteiro precisava ser repetido para cada nova tarefa ou domínio, tornando experimentação rápida praticamente inviável.

Para visualizar concretamente essa complexidade, imagine construir um agente de atendimento ao cliente na era pré-foundation models. Você precisaria de um modelo especializado para entender a intenção do usuário (está reclamando? pedindo reembolso? tirando dúvida técnica?), outro completamente separado para extrair informações estruturadas como nome, número de pedido e data, um terceiro para classificar o tipo de problema dentro da taxonomia da sua empresa, e finalmente um quarto modelo para gerar respostas contextualmente apropriadas. Cada nova funcionalidade demandaria seu próprio modelo adicional. Cada um desses modelos exigia seu próprio dataset meticulosamente anotado, seu próprio pipeline de treinamento, e sua própria infraestrutura de deployment. O custo em tempo de engenharia, recursos computacionais e complexidade operacional era proibitivo para todos exceto as organizações mais bem financiadas.

Foundation models invertem essa equação radical mente. Em vez de treinar modelos especializados para cada tarefa, investimos recursos massivos treinando um único modelo em dados extremamente diversos - texto de livros, artigos científicos, código-fonte, conversas, documentação técnica. Esse treinamento broad desenvolve capacidades gerais de compreensão e geração que podem ser adaptadas para tarefas específicas através de três mecanismos fundamentais: prompting (simplesmente descrever a tarefa em linguagem natural), few-shot learning (fornecer alguns exemplos da tarefa sem retreinamento), e fine-tuning (ajustar pesos do modelo com dados específicos do domínio, tema que aprofundaremos no Capítulo 3). O agente de atendimento que antes exigia quatro modelos especializados pode agora ser implementado com um único foundation model e prompts cuidadosamente elaborados.

O termo “foundation” (fundação) é propositalmente arquitetônico. Assim como fundações de edifícios suportam estruturas diversas construídas sobre elas - de casas residenciais a arranha-céus comerciais - esses modelos servem como base reutilizável sobre a qual construímos aplicações especializadas. A fundação não precisa ser reconstruída para cada edifício; apenas a estrutura acima dela varia.

A hipótese central que explica por que essa abordagem funciona é que linguagem e outras modalidades contêm padrões profundos e transferíveis. Um modelo treinado para prever a próxima palavra em textos diversos não aprende apenas correlações superficiais entre tokens - ele desenvolve, como efeito colateral emergente, representações internas ricas sobre estrutura gramatical e semântica (distinguir sujeito de objeto, entender referências pronominais), conhecimento factual sobre o mundo (capitais de países, princípios científicos, eventos históricos), capacidades de raciocínio lógico e matemático (resolver equações, fazer inferências válidas), compreensão de padrões de código e algoritmos (estruturas de dados, paradigmas de programação), e até mesmo rudimentos de teoria da mente (modelar intenções, crenças e estados mentais de agentes mencionados no texto). Essas representações podem ser aproveitadas para tarefas que o modelo nunca viu explicitamente durante pré-treinamento, uma propriedade notável chamada capacidade emergente que exploraremos em profundidade ao longo deste livro.

Formalmente, o termo “foundation model” foi cunhado por pesquisadores de Stanford em 2021 (Bommasani et al. 2021) para descrever modelos que são: (1) treinados em dados amplos e diversos usando self-supervision, (2) servem como base para uma variedade de tarefas downstream, e (3) demonstram capacidades emergentes que não foram explicitamente programadas. Large Language Models (LLMs) como GPT (Brown et al. 2020), BERT (Devlin et al. 2018), LLaMA (Touvron et al. 2023) e Claude são exemplos proeminentes de foundation models, mas o conceito se estende a outras modalidades como CLIP e DALL-E para imagens ou Codex e StarCoder para código.

E no coração de todos esses foundation models modernos - GPT-4, Claude, LLaMA, Gemini - está a arquitetura Transformer. Vamos entender por que essa arquitetura específica tornou tudo isso possível.

Para engenheiros que construirão agentes inteligentes, compreender foundation models não é opcional, é conhecimento base para todo o resto. Os agentes de IA modernos são, em sua essência, sistemas que orquestram foundation models para perceber o ambiente, raciocinar sobre ele e tomar ações. Sem uma compreensão profunda de como esses modelos funcionam, suas capacidades e limitações, é impossível construir agentes robustos e, acima de tudo, confiáveis.

Neste capítulo, vamos explorar os componentes centrais dos foundation models, entender a arquitetura Transformer que os torna possíveis, e entender como eles aprendem e representam conhecimento. Esta base será essencial para os próximos capítulos, onde aplicaremos esses conceitos na construção de agentes inteligentes capazes de operar em ambientes complexos e dinâmicos. Se você não está familiarizado com conceitos como self-attention, positional encoding ou multi-head attention, não se preocupe, vamos cobrir tudo isso em detalhes.

O Que São Tarefas Downstream?

Antes de seguir para assuntos mais profundos, é importante esclarecer o que são tarefas downstream no contexto de foundation models.

Uma tarefa downstream refere-se a uma tarefa específica que um modelo realiza após ter sido pré-treinado em uma tarefa geral. Por exemplo, um foundation model pode ser pré-treinado para prever o próximo token em uma sequência de texto (uma tarefa geral), e depois ser adaptado para tarefas downstream como classificação de texto, tradução automática ou geração de código.

Para entender bem o que são tarefas downstream, vamos pensar no pré-treinamento como a educação básica e as tarefas downstream como especialização profissional:

  • Pré-Treinamento: Ensino fundamental + médio (base ampla de conhecimento), o modelo é treinado com uma ampla gama de conhecimentos e habilidades gerais, como linguagem, gramática, fatos do mundo.
  • Tarefas Downstream: Profissões específicas (médico, advogado, engenheiro), o modelo aplica esse conhecimento geral para resolver problemas específicos em diferentes domínios.

O termo vem da ideia de um fluxo (stream):

---
config:
  flowchart:
    padding: 20
---
%%| label: fig-pre-training-flow
%%| fig-cap: "Fluxo de conhecimento em foundation models: do pré-treinamento para tarefas downstream."
%%| fig-width: 100%
graph TD
    Fonte["Fonte"] --> PreTreinamento["Pré-treinamento (upstream)"]
    PreTreinamento --> FineTuning["Fine-tuning"]
    FineTuning --> Tarefas["Tarefas Downstream"]
    Conhecimento["Conhecimento geral flui <br> para baixo (down)"] --> Aplicacoes["Aplicações específicas"]
    PreTreinamento --> Conhecimento
    Conhecimento --> Aplicacoes

Por que o termo “Downstream”?

O conhecimento flui para baixo (downstream) do modelo geral para aplicações específicas. O modelo base (upstream) fornece a fundação que alimenta (feeds into) múltiplas tarefas específicas (downstream).

No Contexto de Foundation Models

Upstream (Pré-treinamento)

O modelo aprende representações gerais de linguagem através de predição do próximo token em trilhões de tokens de texto.

Downstream (Tarefas específicas)

O modelo é adaptado para tarefas específicas que variam dramaticamente em natureza e complexidade. Classificação de sentimento determina se uma revisão de produto é positiva ou negativa, capturando nuances sutis de tom e contexto. Sistemas de resposta a perguntas (Question Answering, QA) processam um contexto fornecido e extraem respostas factuais precisas. Tradução automática transforma texto de um idioma para outro preservando significado e estilo. Named Entity Recognition (NER) identifica e categoriza nomes próprios, lugares e organizações mencionados no texto. Geração de código converte descrições em linguagem natural em implementações funcionais em Python, JavaScript ou outras linguagens. Sumarização destila artigos longos em resumos concisos que capturam os pontos essenciais. Todas essas tarefas aproveitam as representações internas aprendidas durante pré-treinamento, adaptando-as para domínios específicos sem reconstruir conhecimento fundamental desde o início.

O Que São Tokens?

Antes de mergulharmos na arquitetura Transformer, precisamos entender um conceito fundamental que permeia todo este capítulo: tokens. Você já deve ter notado que mencionamos “tokens” algumas vezes até agora, mas o que exatamente são eles?

Tokens são as unidades mínimas que os modelos de linguagem processam. Eles representam segmentos do texto, que podem ser palavras inteiras, partes de palavras ou até mesmo caracteres, dependendo do algoritmo de tokenização utilizado. Essa abordagem permite ao modelo lidar com diferentes idiomas, neologismos e variações linguísticas de forma eficiente, sem depender de um vocabulário fixo de palavras completas.

Por Que Não Usar Palavras Diretamente?

Por que não usar palavras inteiras como unidade básica? Embora pareça intuitivo à primeira vista, essa escolha traria desafios práticos intransponíveis. Um vocabulário baseado em palavras completas se tornaria gigantesco - português e inglês juntos têm centenas de milhares de palavras únicas, tornando tanto treinamento quanto inferência computacionalmente inviáveis à medida que o modelo escala. Pior ainda, novos termos, gírias e nomes próprios surgem constantemente na linguagem viva (pense em “bitcoinizar”, “memeificação”, “ChatGPT”), dificultando a atualização contínua do vocabulário sem retreinar modelos massivos. O problema se agrava dramaticamente para modelos multilíngues, que precisariam acomodar vocabulários de dezenas de idiomas simultaneamente, multiplicando complexidade e tamanho do embedding layer. Em contraste, vocabulários baseados em subpalavras (tipicamente 32.000-100.000 tokens) capturam padrões morfológicos comuns entre idiomas, reduzem drasticamente o número de parâmetros na camada de embedding, e aceleram processamento ao mapear texto para sequências de inteiros mais compactas.

Como Funciona a Tokenização?

Para contornar esses desafios, os modelos modernos utilizam algoritmos de tokenização por subpalavras, como Byte-Pair Encoding (BPE), WordPiece ou SentencePiece. Eles seguem etapas como:

  1. Inicialmente, cada caractere é tratado como um token.
  2. O algoritmo identifica sequências frequentes de caracteres ou subpalavras.
  3. Essas sequências são agrupadas em tokens únicos.
  4. O processo se repete até atingir o tamanho de vocabulário desejado (geralmente entre 30.000 e 100.000 tokens).

Exemplo de Tokenização

Vejamos como o texto “inteligência artificial” poderia ser tokenizado:

# Possível tokenização (depende do modelo)
texto = "inteligência artificial"

# Tokenização 1 (mais granular):
tokens = ["intel", "ig", "ência", " ", "art", "ificial"]

# Tokenização 2 (menos granular):
tokens = ["intelig", "ência", " ", "artificial"]

# Tokenização 3 (palavras completas, se estiverem no vocabulário):
tokens = ["inteligência", " ", "artificial"]

A tokenização exata depende de como o modelo foi treinado e qual vocabulário aprendeu durante o pré-treinamento.

Experimentando Tokenização na Prática

Vamos ver como diferentes modelos tokenizam texto usando a biblioteca transformers da Hugging Face:

from transformers import AutoTokenizer

# Carregar tokenizer
tokenizer = AutoTokenizer.from_pretrained("gpt2")

# Tokenizar uma palavra
palavra = "inteligência"
tokens = tokenizer.tokenize(palavra)
print(tokens)  # ['intel', 'ig', 'ê', 'nc', 'ia'] - 5 tokens

# Comparar eficiência entre idiomas
texto_pt = "A inteligência artificial está revolucionando o mundo."
texto_en = "Artificial intelligence is revolutionizing the world."

tokens_pt = tokenizer.tokenize(texto_pt)  # 17 tokens
tokens_en = tokenizer.tokenize(texto_en)  # 9 tokens
# Português usa ~1.89x mais tokens que inglês!

Principais observações:

  • Diferentes algoritmos (BPE, WordPiece, SentencePiece) tokenizam de formas distintas
  • Modelos treinados em inglês tokenizam português menos eficientemente
  • Mais tokens = maior custo em APIs que cobram por token
  • A escolha do tokenizer afeta performance e custos em produção
DicaExemplo Completo Executável

Para um exemplo detalhado que compara GPT-2, BERT e XLM-RoBERTa, demonstra decodificação e analisa eficiência entre idiomas, veja o Exemplo 1: Experimentando Tokenização com Diferentes Modelos na seção de Exemplos de Código Completos.

Tipos de Tokens

Além dos tokens de texto propriamente ditos, os modelos utilizam tokens especiais com funções específicas:

  • <BOS> (Beginning of Sequence): Marca o início de uma sequência
  • <EOS> (End of Sequence): Marca o fim de uma sequência
  • <PAD> (Padding): Usado para preencher sequências até um tamanho fixo
  • <UNK> (Unknown): Representa palavras fora do vocabulário
  • <SEP> (Separator): Separa diferentes segmentos de texto

Por Que Isso Importa na Prática?

Compreender tokens é essencial para trabalhar efetivamente com LLMs. O aspecto mais direto é o custo: APIs como OpenAI, Anthropic e Google cobram por token processado. Se você não entende o que são tokens, pode ter surpresas desagradáveis na fatura do final do mês.

Os limites de contexto também são críticos. Cada modelo tem uma janela máxima de tokens que pode processar de uma vez (a context window). O GPT-3.5 trabalha com 4.096 tokens, enquanto o GPT-4 oferece versões de 8.192 ou 32.768 tokens. Claude 3 estende isso para até 200.000 tokens, e o GPT-4 Turbo alcança 128.000 tokens. Ultrapassar esses limites significa truncar seu contexto ou dividir o processamento em múltiplas requisições.

A performance está diretamente ligada ao número de tokens. Mais tokens significam mais computação, especialmente porque a complexidade do mecanismo de self-attention (que veremos adiante) cresce quadraticamente com o tamanho da sequência, seguindo O(n²). Isso significa que dobrar o número de tokens pode quadruplicar o tempo de processamento.

Finalmente, a qualidade da saída é afetada pela tokenização. O modelo “vê” e processa texto através dos tokens, então uma tokenização inadequada pode impactar as respostas. Modelos treinados primariamente em inglês, por exemplo, frequentemente tokenizam português de forma menos eficiente, o que pode afetar sutilmente a qualidade das respostas.

Regra Prática de Contagem

Para estimar rapidamente quantos tokens seu texto contém, use esta aproximação: em inglês, cada token representa aproximadamente 4 caracteres ou 0,75 palavras. Para português, a eficiência é um pouco menor, conte cerca de 4-5 caracteres por token. Isso significa que um parágrafo de 100 palavras em português consumirá aproximadamente 130-150 tokens.

Por exemplo, este parágrafo de ~100 palavras contém aproximadamente 75-150 tokens em português.

Ferramentas para Visualizar Tokens

Para entender melhor como seu texto é tokenizado, você pode usar ferramentas online como:

DicaDica Prática

Ao trabalhar com LLMs em produção, sempre monitore o número de tokens nas suas requisições. Isso ajuda a controlar custos e garantir que você não exceda os limites de contexto do modelo.

Agora que compreendemos o que são tokens, podemos prosseguir para entender como a arquitetura Transformer processa essas unidades fundamentais para criar representações ricas e contextualizadas do texto.

A Arquitetura Transformer: O Coração dos Foundation Models

A arquitetura Transformer representa uma grande evolução sobre as arquiteturas sequenciais que a precederam. Recurrent neural networks (RNNs) e suas variantes como Long short-term memory (LSTMs) processam sequências token por token, mantendo um estado oculto que carrega informação contextual. Esta abordagem tem duas limitações críticas: (1) o processamento sequencial impede a paralelização durante o treinamento, tornando o processo extremamente lento para sequências longas, e (2) o estado oculto fixo cria um gargalo de informação, dificultando o aprendizado de dependências de longo alcance.

O Transformer elimina completamente a recorrência, substituindo-a por um mecanismo de atenção que permite que cada posição na sequência “olhe” para todas as outras posições simultaneamente. Esta mudança não é apenas uma otimização, ela acaba mudando o que o modelo pode aprender. Enquanto RNNs têm um viés indutivo sequencial (processam informação left-to-right), Transformers têm um viés indutivo relacional (modelam relações entre quaisquer dois elementos da sequência).

Para visualizar como os dados fluem através de um Transformer, considere este diagrama simplificado:

graph TD
    A[Input Tokens] --> B[Token Embedding]
    B --> C[Positional Encoding]
    C --> D[Transformer Block 1]
    D --> E[Transformer Block 2]
    E --> F[...]
    F --> G[Transformer Block N]
    G --> H[Layer Norm]
    H --> I[Output Linear]
    I --> J[Softmax]
    J --> K[Token Probabilities]

    style A fill:#e1f5ff
    style K fill:#e1f5ff
    style D fill:#fff4e1
    style E fill:#fff4e1
    style G fill:#fff4e1
Figura 1: Dados fluindo através de um modelo Transformer típico.

Cada Transformer Block é uma unidade completa de processamento que aplica self-attention seguido de transformações feedforward. Internamente, um bloco Transformer individual tem a seguinte estrutura:

graph TD
    A[Input from Previous Layer] --> B[Multi-Head Self-Attention]
    B --> C[Add & Norm]
    A --> C
    C --> D[Feed-Forward Network]
    D --> E[Add & Norm]
    C --> E
    E --> F[Output to Next Layer]

    style A fill:#e1f5ff
    style F fill:#e1f5ff
    style B fill:#ffe1e1
    style D fill:#e1ffe1
    style C fill:#f0f0f0
    style E fill:#f0f0f0
Figura 2: Fluxo de dados dentro de um bloco Transformer.

Os blocos Add & Norm implementam conexões residuais (que permitem que gradientes fluam mais facilmente durante treinamento) e normalização de camada (que estabiliza o treinamento). A Multi-Head Self-Attention é onde a magia acontece, permitindo que o modelo capture diferentes tipos de relações entre tokens simultaneamente. O Feed-Forward Network aplica transformações não-lineares independentemente a cada posição.

Vamos explorar os componentes centrais do Transformer que o tornam tão poderoso.

Self-Attention: O Mecanismo de Memória Associativa

O mecanismo de self-attention é o núcleo do Transformer. Ele permite que o modelo considere todas as partes da entrada simultaneamente, funcionando como uma forma de memória associativa diferenciável.

Para cada token, o modelo computa três vetores através de projeções aprendidas (matrizes W_Q, W_K e W_V):

  1. Query (Q): “o que este token está procurando”
  2. Key (K): “o que este token oferece”
  3. Value (V): “a informação que este token carrega”

Matematicamente: Attention(Q, K, V) = softmax(QK^T / √d_k) V

Onde QK^T computa similaridade entre tokens, √d_k normaliza para evitar gradientes instáveis, e softmax transforma em probabilidades que ponderam os valores V.

Para entender isso de forma intuitiva, vamos usar uma metáfora!

Metáfora da Biblioteca

Imagine que você está em uma biblioteca gigante procurando livros sobre “inteligência artificial”:

  • Query (Q): Sua pergunta “inteligência artificial” é a query. Ela representa o que você está procurando.
    • “Estou procurando livros sobre inteligência artificial”
    • É o que você QUER encontrar
  • Keys (K): Cada livro na biblioteca tem um título e um resumo, que são as keys. Eles representam o que cada livro oferece. Como etiquetas que ajudam a identificar o conteúdo.
    • Cada livro tem uma etiqueta: “IA”, “História”, “Culinária”, etc.
    • É como os livros se DESCREVEM
  • Values (V): O conteúdo real de cada livro é o value. Ele contém a informação que você realmente quer.
    • O texto, informação dentro de cada livro
    • É a INFORMAÇÃO que você vai levar

O Processo de Atenção (Sem Matemátiqueis)

Quando você faz sua pergunta (query), você compara sua pergunta com as etiquetas dos livros (keys) para ver quais são relevantes. Os livros com etiquetas que correspondem bem à sua pergunta recebem mais atenção. Finalmente, você lê o conteúdo desses livros (values) para obter a informação que procura.

Passo 1: Calcular Similaridade (QK^T)

Sua pergunta: "inteligência artificial"
↓
Comparar com cada etiqueta:
- Livro 1 (IA): MUITO similar ✓✓✓
- Livro 2 (História): pouco similar ✓
- Livro 3 (Culinária): nada similar ✗

Passo 2: Divisão por √d_k (Normalização)

Imagine que você tem uma régua gigantesca que vai de 0 a 1000. Se você dá notas de 0 a 1000, fica difícil comparar. A divisão por √d_k é como reduzir essa escala para 0 a 10, fica mais fácil de trabalhar. E por que isso importa?

  • Sem normalização: Livro de IA recebe nota 950, História recebe 50
    • A diferença é TÃO grande que você ignora completamente História
  • Com normalização: Livro de IA recebe nota 9.5, História recebe 0.5
    • Ainda prefere IA, mas não descarta totalmente História

Passo 3: Softmax (Transformar em Porcentagens)

Transforma as notas em porcentagens que somam 100%:

Notas normalizadas:

- IA: 9.5
- História: 0.5
- Culinária: 0.1

Softmax transforma em:

- IA: 94% de atenção
- História: 5% de atenção
- Culinária: 1% de atenção

Passo 4: Ponderar os Valores V

Você pega o conteúdo de cada livro proporcionalmente à atenção:

Conhecimento final =
  94% do conteúdo do livro de IA +
  5% do conteúdo do livro de História +
  1% do conteúdo do livro de Culinária

Resumo Sem Matemátiqueis

  1. Cada palavra faz uma “pergunta” (Q): “Com quem devo prestar atenção?”
  2. Cada palavra oferece uma “resposta” (K): “Eu sou sobre isso!”
  3. Calculamos quão bem as perguntas combinam com as respostas (similaridade)
  4. Normalizamos para não ter números muito grandes ou muito pequenos (√d_k)
  5. Convertemos em porcentagens (softmax)
  6. Pegamos a informação (V) de cada palavra proporcionalmente à atenção
  7. O modelo aprende a fazer essas conversões (W_Q, W_K, W_V) automaticamente

Esse é o mecanismo de self-attention em ação. Cada token na sequência faz isso simultaneamente, permitindo que o modelo capture relações complexas entre todos os tokens.

Traduzindo para Código: Como Q, K, V são Computados

Agora que entendemos a metáfora, vamos ver como isso funciona na implementação real. Q, K e V não são propriedades mágicas dos tokens, eles são obtidos através de projeções lineares aprendidas:

# Estrutura básica do mecanismo de self-attention
class SelfAttention(nn.Module):
    def __init__(self, d_model, d_k):
        super().__init__()
        self.W_Q = nn.Linear(d_model, d_k)  # Projeta para queries
        self.W_K = nn.Linear(d_model, d_k)  # Projeta para keys
        self.W_V = nn.Linear(d_model, d_k)  # Projeta para values
        self.d_k = d_k

    def forward(self, X):
        Q, K, V = self.W_Q(X), self.W_K(X), self.W_V(X)
        scores = (Q @ K.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_k))
        attention_weights = torch.softmax(scores, dim=-1)
        return attention_weights @ V
NotaImplementação Completa

Para a implementação completa com documentação detalhada de cada passo, veja o Exemplo 2: Implementação de Self-Attention.

O que cada matriz aprende:

  • W_Q: Transforma embeddings em “perguntas” - o que cada token busca
  • W_K: Transforma embeddings em “chaves” - como cada token se descreve
  • W_V: Transforma embeddings em “valores” - que informação cada token carrega

Essas matrizes são aprendidas via backpropagation durante o treinamento. O modelo descobre automaticamente que tipos de perguntas fazer, que características destacar nas chaves, e que informações preservar nos valores.

Backpropagation é o algoritmo que permite que redes neurais aprendam. O seu funcionamento básico é:

  • Forward pass: O modelo faz uma previsão com os pesos atuais
  • Cálculo do erro: Compara a previsão com o resultado correto
  • Backward pass (backpropagation): Propaga o erro de volta através das camadas, calculando quanto cada peso contribuiu para o erro
  • Atualização dos pesos: Ajusta os pesos (matrizes W_Q, W_K, W_V) para reduzir o erro

No contexto das matrizes de atenção (W_Q, W_K, W_V), seria:

O modelo não sabe a priori como transformar embeddings em queries, keys e values. Durante o treinamento, ele experimenta diferentes valores para essas matrizes. Através de backpropagation, descobre automaticamente quais transformações funcionam melhor. Aprende, por exemplo, que uma matriz W_Q deve extrair características que representam “o que buscar”, enquanto W_K deve extrair características que representam “o que oferecer”.

Agora, vamos ver como essa atenção é estendida para capturar múltiplas relações simultaneamente.

Multi-Head Attention: Modelando Múltiplas Relações em Paralelo

Um único mecanismo de atenção é poderoso, mas limitado. Diferentes aspectos da linguagem requerem diferentes tipos de atenção: relações sintáticas, co-referências, relações semânticas, etc. Multi-head attention resolve isso executando múltiplos mecanismos de atenção em paralelo, cada um com suas próprias matrizes de projeção aprendidas.

MultiHead(Q, K, V) = Concat(head_1, ..., head_h) W^O
onde head_i = Attention(QW^Q_i, KW^K_i, VW^V_i)

Cada “head” aprende a focar em diferentes tipos de relações. Por exemplo, um head pode aprender a identificar relações sujeito-verbo, outro pode focar em resolver pronomes, e outro pode capturar relações de longo alcance como correspondências entre abertura e fechamento de parênteses. Esta decomposição é aprendida automaticamente durante o treinamento, não é necessário especificar explicitamente o que cada head deve fazer.

O número de heads é um hiperparâmetro importante. Modelos modernos como GPT-4 usam dezenas de attention heads por camada. Mais heads permitem modelar mais tipos de relações, mas também aumentam o custo computacional e podem levar a overfitting se não houver dados suficientes.

Chamamos de overfitting quando o modelo aprende a memorizar os dados de treinamento em vez de generalizar para novos dados. Isso pode acontecer se o modelo for muito complexo (muitos parâmetros) em relação à quantidade de dados disponíveis.

Otimizações Modernas de Atenção

A atenção padrão tem complexidade O(n²) em relação ao comprimento da sequência, pois cada token precisa calcular atenção com todos os outros tokens. Para sequências de 100 mil tokens, isso se torna inviável. Várias técnicas foram desenvolvidas para tornar a atenção mais eficiente.

Vamos explorar algumas das otimizações mais importantes usadas em modelos modernos.

Local/Sparse Attention

Atenção local/esparsa é uma técnica que reduz a complexidade da atenção ao limitar o número de tokens que cada token pode “ver”. Em vez de cada token olhar para todos os outros tokens, ele olha apenas para seus vizinhos mais próximos.

Imagine que você está numa sala de aula com 100 alunos e o professor pede que CADA aluno cumprimente TODOS os outros alunos. Isso seria extremamente ineficiente, pois cada aluno teria que cumprimentar 99 outros alunos, resultando em 100 * 99 = 9.900 cumprimentos!

Local/Sparse Attention resolve esse problema da seguinte maneira: em vez de cada token olhar para todos os outros tokens, ele olha apenas para seus vizinhos mais próximos. Voltando à metáfora da sala de aula: cada aluno só cumprimenta os 10 alunos sentados ao seu redor, não os 99 ou 999 alunos da sala inteira.

Na prática, isso significa que cada token presta atenção apenas aos K tokens anteriores mais próximos. Se K = 256, um token na posição 1000 só olha para os tokens nas posições 744-999, ignorando os primeiros 743 tokens. Isso reduz drasticamente o número de cálculos de 1.000.000 para apenas 256.000 em uma sequência de 1.000 tokens.

O trade-off desta abordagem é claro: se um token só olha para seus vizinhos próximos, como ele captura dependências de longo alcance? A palavra na posição 1000 pode precisar do contexto da palavra na posição 10.

A solução usada, por exemplo, pelo GPT-3 é uma estratégia híbrida, onde se usam camadas de atenção local/esparsa intercaladas com camadas de atenção completa. Algumas camadas continuam usando atenção completa, permitindo que qualquer token acesse qualquer outro token. Isso preserva a capacidade de modelar dependências de longo alcance. Outras camadas usam atenção local/esparsa para economizar computação.

Alternando entre esses dois tipos de camadas, o modelo consegue o melhor dos dois mundos: mantém a capacidade de capturar relações distantes (através das camadas completas) enquanto reduz significativamente o custo computacional (através das camadas esparsas). É como ter reuniões gerais ocasionais onde todos se encontram, intercaladas com reuniões em pequenos grupos para o trabalho do dia a dia.

Grouped-Query Attention (GQA)

Em Multi-Head Attention tradicional, cada head mantém suas próprias matrizes K (Keys) e V (Values) completas. Para um modelo com 32 heads onde cada matriz K e V ocupa 1GB de memória, isso significa 64GB total apenas para armazenar essas matrizes em uma única camada! Multiplique isso por dezenas de camadas e o consumo de memória se torna proibitivo, especialmente durante inferência quando você precisa manter tudo carregado em memória.

O Problema Visualizado

Imagine uma empresa com 32 departamentos (heads). No Multi-Head Attention padrão, cada departamento mantém sua própria cópia completa de um catálogo de produtos (Keys) e um inventário (Values). Mesmo que os catálogos sejam praticamente idênticos, cada departamento armazena sua própria cópia, desperdiçando memória massivamente.

graph TD
    A["Empresa"] -->|"32 Departamentos"| B["Multi-Head Attention"]
    B -->|"Cada departamento tem seu<br/>próprio catálogo"| C["Keys (K)"]
    B -->|"Cada departamento tem seu<br/>próprio inventário"| D["Values (V)"]
    C -->|"32GB de memória"| E["Memória Excessiva"]
    E -->|"Problema de custo"| F["Consumo Excessivo<br/>de Memória"]
Figura 3: Metáfora da empresa com departamentos e catálogos compartilhados

Para resolver esse problema de consumo excessivo de memória, duas abordagens foram propostas:

Multi-Query Attention (MQA) - “Um Catálogo Central para Todos”

Em Multi-Query Attention, a abordagem vai para o extremo oposto: todos os 32 heads compartilham uma única matriz K e V. Apenas as Queries (Q) são separadas para cada head. Voltando à metáfora: todos os departamentos consultam o mesmo catálogo central.

O benefício desta abordagem é a redução drástica do uso de memória, em vez de 32GB, você precisa apenas de 1GB para K e V.

graph TD
    A["Empresa"] -->|"32 Departamentos"| B["Multi-Query Attention"]
    B -->|"Todos os departamentos<br/>compartilham o mesmo catálogo"| C["Keys (K)"]
    B -->|"Todos os departamentos<br/>compartilham o mesmo inventário"| D["Values (V)"]
    C -->|"1GB de memória"| E["Memória Otimizada"]
    E -->|"Benefício de custo"| F["Redução Significativa<br/>de Memória"]
Figura 4: Metáfora da empresa com catálogo central compartilhado

Porém, ainda existe um problema… Todos os heads olham para as mesmas Keys e Values, limitando a diversidade de perspectivas. É como todos os departamentos terem que usar exatamente o mesmo catálogo sem poder priorizá-lo de formas diferentes. Isso pode prejudicar a qualidade do modelo.

Grouped-Query Attention (GQA) - “Catálogos Compartilhados por Grupos”

Grouped-Query Attention, usada em modelos como LLaMA 2 e 3, encontra o meio-termo ideal: agrupa os heads e compartilha K e V dentro de cada grupo.

Exemplo prático: Se temos 32 heads, podemos criar 8 grupos com 4 heads cada:

  • Grupo 1 (Heads 1-4): Compartilham K₁ e V₁
  • Grupo 2 (Heads 5-8): Compartilham K₂ e V₂
  • Grupo 3 (Heads 9-12): Compartilham K₃ e V₃
  • … e assim por diante

Voltando à metáfora da empresa, em vez de 32 catálogos idênticos (MHA) ou 1 único catálogo para todos (MQA), temos 8 catálogos especializados, cada um compartilhado por 4 departamentos relacionados.

graph TD
    A["Empresa"] -->|"32 Departamentos"| B["Grouped-Query Attention"]
    B -->|"8 Grupos de departamentos<br/>compartilham catálogos"| C["Keys (K)"]
    B -->|"8 Grupos de departamentos<br/>compartilham inventários"| D["Values (V)"]
    C -->|"8GB de memória"| E["Memória Balanceada"]
    E -->|"Benefício de custo e qualidade"| F["Equilíbrio Ideal<br/>de Memória e Qualidade"]
Figura 5: Metáfora da empresa com catálogos compartilhados por grupos

Seguindo essa abordagem, temos alguns benefícios claros:

  • Economia de memória: 8GB em vez de 32GB (redução de 4×)
  • Mantém diversidade: Os 8 grupos ainda podem focar em aspectos diferentes das relações entre tokens
  • Performance próxima ao original: Na prática, consegue qualidade similar ao Multi-Head Attention completo com muito menos memória

Mas, assim como qualquer outra abordagem, há trade-offs. A escolha do número de grupos (e quantos heads por grupo) afeta o equilíbrio entre qualidade e eficiência. Mais grupos significam mais diversidade, mas também mais memória. Ou seja:

  • MHA: 32 K + 32 V = Máxima qualidade, máximo uso de memória
  • GQA (8 grupos): 8 K + 8 V = ~85-95% da qualidade, 25% da memória
  • MQA: 1 K + 1 V = ~75-85% da qualidade, 3% da memória

Para sistemas de produção, GQA oferece o melhor equilíbrio: permite que modelos grandes rodem em hardware mais acessível sem sacrificar significativamente a qualidade das respostas. Isso é importante para engenheiros de software construindo agentes, pois estamos sempre buscando otimizações que permitam escalar modelos sem explodir custos.

Flash Attention

Flash Attention (Dao et al. 2022) é uma implementação otimizada do mecanismo de atenção que reduz significativamente o uso de memória e aumenta a velocidade, especialmente em GPUs. A inovação está em como os dados são movidos entre diferentes níveis da hierarquia de memória da GPU, não em mudar a matemática da atenção.

Entendendo a Hierarquia de Memória da GPU

GPUs modernas têm uma hierarquia de memória com características muito diferentes:

  1. High Bandwidth Memory (HBM): A memória principal da GPU
    • Capacidade: 40-80GB (NVIDIA A100/H100)
    • Bandwidth: ~1.5-2TB/s
    • Latência: Alta (centenas de ciclos)
    • Problema: Lenta para acesso frequente
  2. SRAM (On-Chip Memory/Shared Memory): Memória compartilhada dentro dos streaming multiprocessors
    • Capacidade: ~20MB total (dividida entre SMs)
    • Bandwidth: ~19TB/s (10× mais rápido que HBM)
    • Latência: Muito baixa (poucos ciclos)
    • Vantagem: Extremamente rápida, mas muito limitada

A chave para performance em GPUs não é apenas quantas operações você faz (FLOPs), mas quantas vezes você acessa a memória lenta. Em operações modernas de deep learning, o gargalo raramente é computação, é movimento de dados entre HBM e SRAM.

O Problema da Implementação Tradicional

A implementação tradicional de atenção calcula a matriz completa em passos separados:

# Passo 1: Calcular scores de atenção
S = Q @ K^T / √d_k        # Shape: (n × n)
                          # Materializa na HBM: n² valores

# Passo 2: Aplicar softmax
P = softmax(S)            # Lê S da HBM, escreve P de volta
                          # Shape: (n × n)

# Passo 3: Multiplicar por Values
O = P @ V                 # Lê P da HBM novamente
                          # Shape: (n × d)

Custo de memória: Para sequência de 10.000 tokens com FP16 (d_k=512):

  • Matriz S: 10.000 × 10.000 × 2 bytes = 200MB
  • Matriz P: 10.000 × 10.000 × 2 bytes = 200MB
  • Total: 400MB apenas para uma camada de atenção

Custo de I/O (leituras/escritas de HBM):

  • Escrever S para HBM: n² valores
  • Ler S da HBM para softmax: n² valores
  • Escrever P para HBM: n² valores
  • Ler P da HBM para multiplicação final: n² valores
  • Total: 4n² operações de HBM (lento!)

Para n=10.000, isso significa 400 milhões de acessos à memória HBM lenta. Em GPUs modernas, esse movimento de dados domina completamente o tempo de execução.

A Solução: Tiling e Kernel Fusion

Flash Attention resolve isso através de duas técnicas complementares:

1. Kernel Fusion: Em vez de três kernel calls separados (matmul → softmax → matmul), funde tudo em um único kernel que mantém dados intermediários em SRAM.

2. Tiling (Bloco por Bloco): Divide Q, K, V em blocos menores que cabem em SRAM e processa bloco por bloco.

# Pseudocódigo simplificado do Flash Attention
for bloco_q in blocos(Q):                    # Itera sobre blocos de queries
    # Carrega bloco de Q na SRAM (rápido)
    Q_bloco = carregar_para_SRAM(bloco_q)

    for bloco_kv in blocos(K, V):            # Itera sobre blocos de keys/values
        # Carrega blocos de K e V na SRAM
        K_bloco = carregar_para_SRAM(bloco_kv)
        V_bloco = carregar_para_SRAM(bloco_kv)

        # TUDO acontece na SRAM rápida:
        S_bloco = Q_bloco @ K_bloco^T / √d_k  # Compute scores
        P_bloco = softmax_online(S_bloco)     # Softmax incremental
        O_parcial = P_bloco @ V_bloco         # Accumulate output

        # Descarta S_bloco e P_bloco - nunca vão para HBM!

    # Escreve resultado final para HBM
    escrever_para_HBM(O_parcial)

Ganhos de I/O:

  • Implementação tradicional: 4n² acessos à HBM
  • Flash Attention: ~n² / B acessos à HBM (onde B é o tamanho do bloco)
  • Com B=64: Redução de 256× em acessos à memória lenta!

Softmax Online (Incremental)

Um desafio técnico é que softmax requer normalização global, você precisa saber o máximo e a soma de todos os valores. Flash Attention usa uma técnica chamada “online softmax” que atualiza essas estatísticas incrementalmente à medida que processa blocos, sem precisar ter a matriz completa na memória.

Recomputation Durante Backward Pass

Um aspecto engenhoso: durante o backward pass (cálculo de gradientes), em vez de armazenar as matrizes S e P (que consumiriam memória massiva), Flash Attention simplesmente recalcula esses valores on-the-fly. Como os cálculos são extremamente rápidos (acontecem em SRAM), recomputar é mais barato que armazenar.

Resultados Práticos

Para engenheiros construindo sistemas de produção, os ganhos são substanciais:

  • Velocidade: 2-4× mais rápido que atenção padrão, especialmente para sequências longas
  • Memória: Reduz uso de memória de O(n²) para O(n), permitindo sequências muito mais longas
  • Exatidão: Resultado é numericamente idêntico à atenção padrão (não é uma aproximação)
  • Transparência: Drop-in replacement — não requer mudanças no código do modelo

Frameworks modernos como PyTorch (via torch.nn.functional.scaled_dot_product_attention) e bibliotecas como Hugging Transformers já integram Flash Attention automaticamente quando disponível. Para engenheiros, isso significa que você obtém os ganhos “de graça” apenas usando APIs modernas, mas entender por que funciona ajuda a tomar decisões arquiteturais informadas sobre tamanhos de sequência, batch sizes, e deployment.

KV-Cache: Evitando Recomputação em Geração Autoregressiva

Durante a geração de texto token-por-token em modelos decoder-only, há um desperdício computacional massivo: a cada novo token gerado, o modelo recalcula a atenção para todos os tokens anteriores do zero. KV-Cache é a otimização que resolve esse problema, sendo absolutamente crucial para inferência eficiente em produção.

O Problema: Recomputação Redundante

Considere gerar a frase “The cat sat on”:

Passo 1: Gera "The"
  - Compute Q, K, V para "The"
  - Attention entre: ["The"]

Passo 2: Gera "cat"
  - Compute Q, K, V para "The" + "cat"  ← RECOMPUTA "The"!
  - Attention entre: ["The", "cat"]

Passo 3: Gera "sat"
  - Compute Q, K, V para "The" + "cat" + "sat"  ← RECOMPUTA tudo!
  - Attention entre: ["The", "cat", "sat"]

Passo 4: Gera "on"
  - Compute Q, K, V para "The" + "cat" + "sat" + "on"  ← RECOMPUTA tudo de novo!
  - Attention entre: ["The", "cat", "sat", "on"]

Para uma sequência de 1.000 tokens, você recalcula K e V para o primeiro token 1.000 vezes! Isso é bem ineficiente.

A Solução: Cache de Keys e Values

A observação chave é: em geração autoregressiva com máscara causal, K e V dos tokens anteriores nunca mudam. Apenas o novo token precisa ter seus K e V calculados.

# Conceito de KV-Cache: armazenar K e V calculados previamente
class KVCachedAttention(nn.Module):
    def __init__(self, d_model, d_k, d_v):
        super().__init__()
        self.W_Q = nn.Linear(d_model, d_k)
        self.W_K = nn.Linear(d_model, d_k)
        self.W_V = nn.Linear(d_model, d_v)
        self.k_cache = None  # Armazena keys anteriores
        self.v_cache = None  # Armazena values anteriores

    def forward(self, x, use_cache=True):
        Q_new, K_new, V_new = self.W_Q(x), self.W_K(x), self.W_V(x)

        if use_cache and self.k_cache is not None:
            K = torch.cat([self.k_cache, K_new], dim=1)  # Reutiliza cache!
            V = torch.cat([self.v_cache, V_new], dim=1)
        else:
            K, V = K_new, V_new

        if use_cache:
            self.k_cache, self.v_cache = K.detach(), V.detach()

        scores = (Q_new @ K.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_k))
        return torch.softmax(scores, dim=-1) @ V
NotaImplementação e Exemplo de Uso Completos

Para a implementação completa com documentação e exemplo de uso em geração autoregressiva, veja o Exemplo 3: KV-Cache para Geração Autoregressiva.

Ganhos Computacionais:

Para gerar uma sequência de N tokens:

  • Sem KV-Cache: O(N²) computações
    • Token 1: 1 computação
    • Token 2: 2 computações
    • Token N: N computações
    • Total: 1 + 2 + … + N = N(N+1)/2 ≈ N²/2
  • Com KV-Cache: O(N) computações
    • Cada token: 1 nova computação
    • Total: N computações

Para N=1000: 500.000× → 1.000× = redução de 500×.

Trade-off de Memória:

KV-Cache tem um consumo de memória significativo, pois armazena K e V para todos os tokens gerados até agora. A fórmula para o uso de memória é:

Memória KV-Cache = 2 × n_layers × seq_len × d_model × batch_size × sizeof(dtype)

Exemplo - LLaMA 2 70B:

  • 80 layers
  • d_model = 8192
  • seq_len = 2048
  • batch_size = 1
  • dtype = FP16 (2 bytes)
Memória = 2 × 80 × 2048 × 8192 × 1 × 2 bytes
        = 5.368 GB

Para batch_size=32: ~170GB apenas de KV-cache!

Por que GQA é Importante para KV-Cache:

Lembra da técnica Grouped-Query Attention? Ela reduz drasticamente o tamanho do KV-cache:

  • Multi-Head Attention: n_heads × d_k por camada
    • LLaMA 2 70B: 64 heads × 128 = 8192 dimensões
  • GQA (8 grupos): 8 × d_k por camada
    • LLaMA 2 70B: 8 × 128 = 1024 dimensões
    • Redução de 8× no tamanho do cache!

Com GQA, o mesmo LLaMA 2 70B precisa de apenas ~670MB de KV-cache (vs. 5.3GB), tornando viável rodar em GPUs de consumo.

Frameworks modernos de implementação de modelos de linguagem implementam KV-cache automaticamente:

  • vLLM: PagedAttention (gerenciamento eficiente de cache tipo memória virtual)
  • TensorRT-LLM: Multi-query attention + KV-cache otimizado
  • Hugging Face Text Generation Inference (TGI): Cache automático
  • llama.cpp: Cache em CPU/GPU com quantização

Para engenheiros de software construindo agentes de IA, KV-cache é transparente mas é importante entender:

  1. Memória cresce linearmente com comprimento de contexto: Planeje VRAM adequadamente
  2. Batch size limitado por cache: Grandes batches = mais cache
  3. GQA/MQA são essenciais: Modelos sem eles não escalam bem em produção
  4. Quantização de cache é viável: Em produção, INT8 cache economiza muito

KV-Cache é a diferença entre inferência viável e inviável para LLMs de larga escala. Sem ele, gerar 1000 tokens levaria minutos em vez de segundos.

Embeddings Posicionais: Injetando Ordem em um Modelo Sem Ordem

Um aspecto contra-intuitivo do Transformer é que, por design, ele é permutation-invariant. Isso significa que, se embaralharmos os tokens de uma sequência, a saída da camada de atenção será apenas uma permutação correspondente das saídas originais. Isso é problemático porque a ordem das palavras é fundamental para o significado em linguagem natural.

Considere estas três frases com as mesmas palavras em ordens diferentes:

  1. “O cachorro mordeu o gato” → Significado: O cachorro é o agressor
  2. “O gato mordeu o cachorro” → Significado: O gato é o agressor
  3. “Mordeu o cachorro o gato” → Sem sentido em português

Sem embeddings posicionais, o mecanismo de self-attention trataria essas três frases como equivalentes! Vamos ver por quê:

Passo 1: Embeddings dos tokens (sem posição)

"O"       → [0.2, 0.5, 0.1, ...]
"cachorro" → [0.8, 0.3, 0.6, ...]
"mordeu"   → [0.1, 0.9, 0.4, ...]
"o"       → [0.2, 0.5, 0.1, ...]  # mesma embedding que "O"
"gato"     → [0.7, 0.2, 0.8, ...]

Passo 2: Self-Attention calcula relações

Para cada token, self-attention computa:

  • Query (Q): “O que estou procurando?”
  • Keys (K): “O que eu ofereço?”
  • Values (V): “Minha informação”

A ordem não importa nos cálculos:

Attention("cachorro") olha para:

- "O" (antes ou depois? não sabemos!)
- "mordeu" (antes ou depois? não sabemos!)
- "gato" (antes ou depois? não sabemos!)

O Resultado Problemático

Sem informação posicional, o modelo veria essas frases como conjuntos de palavras sem ordem:

Frase 1: {O, cachorro, mordeu, o, gato}
Frase 2: {O, gato, mordeu, o, cachorro}
Frase 3: {Mordeu, o, cachorro, o, gato}

Para o modelo SEM embeddings posicionais:

Todas são IDÊNTICAS!

É como se você recebesse os ingredientes de uma receita, mas sem as instruções de ordem:

  • “Adicione ovos, farinha, asse, misture”
  • “Asse, adicione ovos, misture, farinha”

Ambas têm os mesmos ingredientes, mas produzem resultados completamente diferentes.

Por que isso acontece matematicamente?

A operação de self-attention é invariante à permutação porque:

  1. O produto QK^T calcula similaridades entre todos os pares de tokens
  2. Não importa a ordem: similarity(A, B) = similarity(B, A)
  3. O softmax normaliza sobre todas as posições
  4. O resultado final é uma soma ponderada que não depende da ordem original

Em código (simplificado):

# Sem embeddings posicionais
embeddings_frase1 = [emb_o, emb_cachorro, emb_mordeu, emb_o, emb_gato]
embeddings_frase2 = [emb_o, emb_gato, emb_mordeu, emb_o, emb_cachorro]

# Self-attention sem posição
output1 = self_attention(embeddings_frase1)
output2 = self_attention(embeddings_frase2)

output1 e output2 são apenas permutações um do outro! O modelo não consegue distinguir qual palavra veio antes.

A Solução: Embeddings Posicionais

Embeddings posicionais adicionam informação sobre a posição de cada token:

"O" (posição 0)       → [0.2, 0.5, 0.1, ...] + [pos_0]
"cachorro" (posição 1) → [0.8, 0.3, 0.6, ...] + [pos_1]
"mordeu" (posição 2)   → [0.1, 0.9, 0.4, ...] + [pos_2]
"o" (posição 3)       → [0.2, 0.5, 0.1, ...] + [pos_3]  # DIFERENTE agora!
"gato" (posição 4)     → [0.7, 0.2, 0.8, ...] + [pos_4]

Agora cada token carrega não apenas seu significado, mas também onde ele está na sequência. Isso permite que o modelo aprenda que:

  • “cachorro” na posição 1 antes de “mordeu” na posição 2 → cachorro é sujeito
  • “gato” na posição 1 antes de “mordeu” na posição 2 → gato é sujeito

Existem várias abordagens para incorporar informação posicional. Vamos explorar as mais comuns usadas em modelos modernos.

Embeddings Posicionais Absolutos (Original Transformer)

O paper original usava funções seno e cosseno de diferentes frequências:

PE(pos, 2i) = sin(pos / 10000^(2i/d))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d))

Esta escolha é elegante porque (1) é determinística (não requer aprendizado), (2) pode generalizar para sequências mais longas do que as vistas no treinamento, e (3) permite que o modelo aprenda facilmente a atender a posições relativas.

Attention with Linear Biases (ALiBi)

ALiBi (Press, Smith, e Lewis 2021) não adiciona embeddings posicionais às entradas. Em vez disso, adiciona um viés aos attention scores proporcionalmente à distância entre tokens:

score_ij = QK^T / √d_k - λ|i-j|

Onde λ é um hiperparâmetro específico para cada head. Isso tem duas vantagens: (1) reduz o número de parâmetros, e (2) permite melhor extrapolação para sequências mais longas. Modelos como BLOOM usam ALiBi.

Rotary Position Embeddings (RoPE)

RoPE (Su et al. 2021), usado em modelos modernos como LLaMA, aplica uma rotação às embeddings Q e K baseada na posição:

RoPE(x, pos) = [x_1, x_2, ..., x_d] rotacionado por ângulos θ_1·pos, θ_2·pos, ..., θ_d/2·pos

A matemática exata é complexa, mas a intuição é que RoPE codifica informação de posição relativa diretamente no produto interno Q·K. Isso combina as vantagens de embeddings absolutos e relativos, e tem mostrado melhor performance em tarefas que requerem raciocínio sobre posições.

Feedforward Networks: Processamento Não-Linear

Até agora, exploramos em profundidade o mecanismo de self-attention, que permite ao modelo capturar relações entre tokens. No entanto, a atenção sozinha não é suficiente para construir um modelo poderoso. Embora a atenção seja excelente para redistribuir e agregar informação entre posições, ela é essencialmente uma operação linear seguida de normalização (softmax). Para que o modelo possa aprender transformações complexas e não-lineares dos dados, precisamos de outro componente crucial: as redes feedforward (FFN).

A arquitetura Transformer intercala camadas de atenção com camadas FFN, criando um padrão de “comunicação” seguido de “processamento”: a atenção permite que tokens se comuniquem e compartilhem informação, enquanto a FFN processa essa informação de forma independente para cada token. Esta alternância é fundamental para o poder expressivo do modelo.

Após a camada de self-attention, cada token passa por uma rede feedforward (FFN) de duas camadas:

FFN(x) = max(0, xW_1 + b_1)W_2 + b_2

Modelos modernos usam variantes mais sofisticadas como SwiGLU:

SwiGLU(x) = (Swish(xW_1) ⊙ xW_2)W_3
onde Swish(x) = x·sigmoid(βx)

A FFN é aplicada independentemente a cada posição, mas com os mesmos pesos. Pode parecer “simples” para os matemáticos, não para mim. Aqui estão algumas razões pelas quais as FFNs são essenciais:

  1. Modelagem de Não-Linearidades: Self-attention é essencialmente linear após o softmax. A FFN adiciona a capacidade de modelar relações não-lineares complexas.
  2. Aumento de Dimensionalidade: A dimensão intermediária da FFN (d_ff) é tipicamente 4× maior que a dimensão do modelo (d_model). Isso dá ao modelo mais “espaço” para processar informação.
  3. Memória Key-Value: Pesquisas recentes sugerem que as FFNs funcionam como memórias key-value aprendidas, onde a primeira camada funciona como “keys” e a segunda como “values”.

A FFN geralmente contém a maior parte dos parâmetros do modelo. Em um Transformer com d_model=1024 e d_ff=4096, uma camada FFN tem aproximadamente 8M parâmetros (1024×4096×2), enquanto a camada de atenção tem apenas ~4M parâmetros.

Mixture of Experts (MoE): Eficiência através de Especialização

Mixture of Experts (MoE) é uma técnica arquitetural que revolucionou a forma como construímos modelos de larga escala eficientes. Em vez de ter uma única FFN grande que processa todos os tokens da mesma forma, MoE usa múltiplas FFNs especializadas (“experts”) e um mecanismo de roteamento que decide quais experts ativar para cada token.

MoE não é uma ideia nova relacionada aos LLMs. Ela foi introduzida em 1991 por Jacobs et al. (Jacobs et al. 1991) e popularizada em deep learning por Shazeer et al. (Shazeer et al. 2017) em 2017 com o paper “Outrageously Large Neural Networks”. Recentemente, MoE tem sido adotada em modelos de linguagem como GLaM (Du et al. 2022) (Google, 1.2T parâmetros), Switch Transformer (Fedus, Zoph, e Shazeer 2022), e Mixtral (Jiang et al. 2024) (Mistral AI).

Arquitetura Básica de MoE:

graph LR
    A["Token"] --> B["Router<br/>(gating network)"]
    B --> C["Top-K Selection"]
    C --> D["Expert 1"]
    C --> E["Expert 2"]
    C --> F["..."]
    C --> G["Expert K"]
    D --> H["Aggregate<br/>(weighted sum)"]
    E --> H
    F --> H
    G --> H
    H --> I["Output"]
    
    style B fill:#ffe1e1
    style C fill:#fff4e1
    style D fill:#e1f5ff
    style E fill:#e1f5ff
    style G fill:#e1f5ff
    style H fill:#e1ffe1
Figura 6: Fluxo de processamento em uma camada Mixture of Experts

Implementação conceitual:

class MoELayer(nn.Module):
    """Mixture of Experts com roteamento dinâmico."""
    def __init__(self, d_model, d_ff, num_experts=8, top_k=2):
        super().__init__()
        self.experts = nn.ModuleList([FFN(d_model, d_ff) for _ in range(num_experts)])
        self.router = nn.Linear(d_model, num_experts)
        self.top_k = top_k

    def forward(self, x):
        # Router decide quais experts usar
        router_probs = torch.softmax(self.router(x), dim=-1)
        top_k_probs, top_k_indices = torch.topk(router_probs, self.top_k, dim=-1)

        # Processa tokens pelos top-k experts selecionados
        output = torch.zeros_like(x)
        for i in range(self.top_k):
            expert_idx = top_k_indices[:, :, i]
            expert_prob = top_k_probs[:, :, i:i+1]
            for expert_id, expert in enumerate(self.experts):
                mask = (expert_idx == expert_id)
                if mask.any():
                    output += expert(x) * expert_prob * mask.unsqueeze(-1)
        return output
NotaImplementação Completa

Para a implementação completa com documentação detalhada de cada passo do roteamento, veja o Exemplo 5: Mixture of Experts (MoE) Layer.

Exemplo Real - Mixtral 8x7B:

Mixtral (Jiang et al. 2024), desenvolvido pela Mistral AI, é um dos modelos MoE mais bem-sucedidos atualmente disponíveis. Ele demonstra como MoE pode alcançar alta performance com eficiência de inferência.

O Mixtral 8x7B possui:

  • 8 experts de 7B parâmetros cada
  • Router seleciona top-2 experts por token
  • Total: 47B parâmetros, mas apenas ~13B ativos por token
  • Resultado: Performance próxima a modelo denso de 47B, custo de inferência de 13B

Vantagens do MoE:

  1. Maior capacidade com menor compute ativo: Modelo de 47B que custa como 13B
  2. Especialização natural: Experts tendem a se especializar automaticamente
    • Expert 1: código Python
    • Expert 2: matemática
    • Expert 3: linguagens não-inglesas
    • Expert 4: conhecimento factual
  3. Escalabilidade: Adicionar experts aumenta capacidade sem aumentar compute proporcionalmente
  4. Eficiência de inferência: Menos FLOPs por token

Mesmo com essas vantagens, e como tudo em engenharia, MoE apresenta alguns desafios:

  1. Balanceamento de carga: Router pode favorecer alguns experts, deixando outros sub-utilizados
    • Solução: Adicionar loss auxiliar que penaliza desbalanceamento
    • load_balancing_loss = aux_loss_weight * (num_experts * mean(router_probs)^2)
  2. Memória total alta: Todos experts precisam estar carregados na memória
    • Mixtral 8x7B: Requer ~94GB VRAM (todos experts na GPU)
    • Solução: Expert parallelism (distribuir experts entre GPUs)
  3. Complexidade de treinamento: Treinamento distribuído mais complexo
    • Necessita de all-to-all communication entre GPUs
    • Framework como Megatron-LM ou DeepSpeed oferecem suporte
  4. Overfitting de routing: Router pode memorizar padrões de treinamento
    • Solução: Dropout no router, noise no gating

Quando usar MoE:

  • Você tem orçamento de treinamento grande (MoE requer mais dados para convergir)
  • Você quer máxima capacidade com custo de inferência controlado
  • Você tem infraestrutura multi-GPU/multi-nó
  • Você tem restrições de memória VRAM totais
  • Você precisa de latência ultra-baixa (routing adiciona overhead)

MoE representa uma das direções mais promissoras para escalar modelos de forma eficiente, e é provável que vejamos cada vez mais modelos adotando essa arquitetura nos próximos anos.

Layer Normalization e Residual Connections

Até agora exploramos os componentes que processam informação no Transformer: atenção e redes feedforward. Mas há um problema: quando empilhamos muitas camadas (GPT-4 tem dezenas, possivelmente centenas), o treinamento se torna extremamente difícil. Duas técnicas simples mas cruciais tornam possível treinar Transformers profundos: residual connections e layer normalization. Vamos entender cada uma delas.

Residual Connections (Skip Connections)

Imagine que você está construindo uma torre de 100 andares, onde cada andar processa alguma informação. O problema é que, ao treinar o modelo, o “sinal de erro” (gradiente) precisa voltar do topo até a base para ajustar cada andar. Em redes muito profundas, esse sinal se torna cada vez mais fraco à medida que desce (conhecido como o problema do vanishing gradient). É como um telefone sem fio onde a mensagem se distorce a cada pessoa.

A Solução: Atalhos Diretos

Residual connections criam “atalhos” que permitem que a informação pule camadas:

output = x + Attention(x)

Isso significa: pegue a entrada original x, processe através da atenção, e então some de volta a entrada original ao resultado.

Por que isso funciona?

Pense assim: em vez de cada camada ter que aprender uma transformação completa, ela só precisa aprender o ajuste (o “residual”) que deve ser adicionado. Se uma camada não precisa fazer nada útil, pode simplesmente aprender a retornar zero, e a entrada original passa direto através do atalho.

Visualizando:

Sem residual connection:
x → [Atenção] → output
(Se a atenção é mal-treinada, output pode ser ruim)

Com residual connection:
x → [Atenção] → + → output
↓_____________↑
(Mesmo se atenção falha, x passa direto!)

Durante o backpropagation, o gradiente também tem um caminho direto de volta através desses atalhos, resolvendo o problema de vanishing gradients. É como ter um elevador expresso em um prédio alto, você não precisa subir/descer escada por escada.

Na Prática no Transformer:

A arquitetura Transformer usa residual connections em cada sub-camada (atenção e FFN):

output = LayerNorm(x + Attention(x))
output = LayerNorm(output + FFN(output))

Note que aplicamos residual connections tanto após a atenção quanto após a FFN. Cada uma dessas operações é seguida por normalização, que vamos entender a seguir.

Layer Normalization

Outro problema ao treinar redes profundas é que os valores (ativações) podem crescer ou diminuir descontroladamente à medida que passam pelas camadas. Imagine que você está cozinhando uma receita complexa: se a temperatura varia muito a cada etapa, fica impossível controlar o resultado final.

O que Layer Normalization faz?

Layer normalization “padroniza” os valores em cada posição para terem média 0 e desvio padrão 1, e então aplica uma transformação aprendida:

LayerNorm(x) = γ · (x - μ) / σ + β

Onde:

  • μ (mu): média dos valores
  • σ (sigma): desvio padrão dos valores
  • γ (gamma) e β (beta): parâmetros aprendidos que permitem ao modelo ajustar a escala e deslocamento

Analogia: Normalizar Notas

Imagine que você tem notas de alunos em diferentes provas:

  • Prova fácil: notas entre 70-100 (média 85)
  • Prova difícil: notas entre 20-50 (média 35)

Para comparar justo, você precisa normalizar as notas. O processo é assim:

Passo 1: Entenda a distribuição de cada prova

  • Prova fácil: a maioria tirou em torno de 85, com variação típica de 10 pontos para mais ou menos
  • Prova difícil: a maioria tirou em torno de 35, também com variação de 10 pontos

Passo 2: Transforme cada nota em “posição relativa”

Em vez de olhar a nota absoluta, perguntamos: “Quanto essa pessoa ficou acima ou abaixo da média?”

  • Aluno A tirou 90 na prova fácil:
    • Ficou 5 pontos acima da média (90 - 85 = 5)
    • Como a variação típica é 10, ele ficou “meio passo” acima (+0.5)
  • Aluno B tirou 40 na prova difícil:
    • Ficou 5 pontos acima da média (40 - 35 = 5)
    • Como a variação típica é 10, ele também ficou “meio passo” acima (+0.5)

Passo 3: Compare de forma justa

Agora podemos dizer que ambos tiveram desempenho equivalente, porque ambos ficaram “meio passo acima da média” de suas respectivas provas.

Layer normalization faz exatamente isso com as ativações do modelo: transforma números que podem estar em escalas muito diferentes (alguns muito grandes, outros muito pequenos) em uma escala padronizada, onde podemos comparar tudo de forma justa. Isso mantém o treinamento estável porque evita que alguns números fiquem grandes demais ou pequenos demais.

Variações Modernas

O Transformer original aplicava layer normalization depois de cada sub-camada (“post-norm”), mas modelos modernos como GPT usam pre-norm (normalizar antes da sub-camada), que é mais estável:

# Pre-norm (moderno)
output = x + Attention(LayerNorm(x))
output = output + FFN(LayerNorm(output))

# Post-norm (original)
output = LayerNorm(x + Attention(x))
output = LayerNorm(output + FFN(output))

RMSNorm: Uma Simplificação Eficiente

Modelos recentes como LLaMA usam uma variante ainda mais simples chamada RMSNorm (Root Mean Square Normalization):

RMSNorm(x) = γ · x / RMS(x)
onde RMS(x) = √(Σx²/n)

A diferença é que RMSNorm não subtrai a média (não usa o termo x - μ), apenas divide pelo RMS (root mean square). Isso é:

  • Mais rápido de calcular (menos operações)
  • Usa menos memória
  • Na prática, funciona tão bem quanto LayerNorm completo

É como simplificar a normalização de notas: em vez de calcular “quantos desvios acima/abaixo da média”, você só ajusta pela “magnitude típica” dos valores.

Por que essas técnicas importam juntas?

Residual connections + Layer normalization trabalham em sinergia:

  1. Residual connections garantem que os gradientes fluam suavemente durante o treinamento
  2. Layer normalization garante que os valores permaneçam em escalas razoáveis em cada camada
  3. Juntas, permitem treinar redes com 100+ camadas de forma estável

Sem essas técnicas, seria praticamente impossível treinar os modelos gigantescos que usamos hoje.

Arquiteturas Encoder-Only, Decoder-Only e Encoder-Decoder

O Transformer original era um modelo encoder-decoder para tradução. Desde então, três variantes principais emergiram:

Encoder-Only (BERT-like)

Modelos encoder-only como BERT usam atenção bidirecional. Cada token pode atender a todos os outros tokens (anteriores e posteriores). Isso é ideal para tarefas de compreensão onde você tem acesso ao contexto completo: classificação, NER, question answering, etc.

O treinamento usa masked language modeling (MLM): aleatoriamente mascara alguns tokens e treina o modelo para prevê-los. Isso força o modelo a usar contexto bidirecional.

Exemplo de MLM:

Input:  "The [MASK] sat on the [MASK]"
Target: "The cat sat on the mat"

O modelo vê a frase com palavras faltando, mascaradas ([MASK]), e deve inferir as palavras corretas com base no contexto completo.

Decoder-Only (GPT-like)

Modelos decoder-only como GPT usam atenção causal (autoregressiva). Cada token pode atender apenas a tokens anteriores, nunca a tokens futuros. Isso é implementado com uma “causal mask” que zera os attention scores para posições futuras.

Como Funciona a Causal Mask Tecnicamente:

A máscara causal é implementada como uma matriz triangular inferior que bloqueia a atenção em tokens futuros. Aqui está como isso funciona:

def create_causal_mask(seq_len):
    """Cria máscara triangular para impedir atenção em tokens futuros."""
    mask = torch.tril(torch.ones(seq_len, seq_len))
    mask = mask.masked_fill(mask == 0, float('-inf'))
    mask = mask.masked_fill(mask == 1, 0.0)
    return mask

def masked_attention(Q, K, V, mask):
    """Aplica atenção com máscara causal."""
    d_k = Q.size(-1)
    scores = (Q @ K.transpose(-2, -1)) / torch.sqrt(torch.tensor(d_k))
    scores = scores + mask  # Adiciona -inf para posições futuras
    return torch.softmax(scores, dim=-1) @ V
NotaImplementação Completa

Para a implementação completa com documentação detalhada e visualizações, veja o Exemplo 4: Máscara Causal para Modelos Decoder-Only.

Por que isso importa:

  1. Previne data leakage durante treinamento: O modelo não pode “trapacear” olhando para tokens futuros que deve prever
  2. Garante consistência entre treino e inferência: Durante geração, tokens futuros não existem ainda, então o modelo deve aprender sem eles
  3. Permite paralelização do treinamento: Todos os tokens podem ser processados simultaneamente, com a máscara garantindo causalidade

O treinamento usa causal language modeling (CLM) que prevê o próximo token dada uma sequência. Esta é a base dos modelos generativos de linguagem mais populares.

Exemplo de CLM:

Input:  "The cat sat"
Target: "on"

Onde o modelo vê “The cat sat” e deve prever o próximo token “on” baseado apenas no contexto anterior.

Decoder-only models têm se tornado dominantes (GPT-3 (Brown et al. 2020), PaLM (Anil et al. 2023), LLaMA (Touvron et al. 2023), Claude) porque:

  1. São mais simples arquiteturalmente
  2. Escalam melhor para tamanhos massivos
  3. Podem ser usados tanto para compreensão quanto geração
  4. O treinamento autoregressive é mais sample-efficient

Encoder-Decoder (T5-like)

O Encoder-Decoder mantém a estrutura original do Transformer: um encoder com atenção bidirecional e um decoder com atenção causal que também atende às saídas do encoder via cross-attention.

Encoder-decoder models como T5 (Raffel et al. 2020) são particularmente bons para tarefas sequence-to-sequence: tradução, sumarização, question answering generativo. Eles separam explicitamente “compreensão” (encoder) de “geração” (decoder).

No entanto, são mais complexos e custosos de treinar e inferir devido à arquitetura dupla.

Comparação entre Arquiteturas Transformer

A tabela abaixo resume as principais diferenças entre as três variantes de arquiteturas Transformer:

Característica Encoder-Only Decoder-Only Encoder-Decoder
Tipo de Atenção Bidirecional (vê passado e futuro) Causal (só vê passado) Encoder: bidirecional
Decoder: causal + cross-attention
Treinamento Masked Language Modeling (MLM) Causal Language Modeling (CLM) Sequence-to-sequence
Objetivo Principal Compreensão de texto Geração de texto Transformação de sequências
Tarefas Típicas Classificação
NER
Question Answering
Análise de sentimento
Geração de texto
Completação
Conversação
Code generation
Tradução
Sumarização
Question Answering generativo
Exemplos de Modelos BERT
RoBERTa
ALBERT
DeBERTa
GPT-3/4
LLaMA
PaLM
Claude
Mistral
T5
BART
mT5
Flan-T5
Complexidade Moderada Simples Alta (arquitetura dupla)
Escalabilidade Boa Excelente Moderada
Custo de Inferência Baixo a moderado Moderado (com KV-cache) Alto
Uso Atual Tarefas de compreensão específicas Dominante para modelos de uso geral Tarefas especializadas de transformação
NotaTendência Atual

Modelos decoder-only têm se tornado a arquitetura dominante para foundation models de uso geral devido à sua simplicidade, escalabilidade e versatilidade. Eles podem ser usados tanto para compreensão quanto para geração, tornando-os ideais para construir agentes.

Conclusão

Neste capítulo, exploramos os fundamentos dos Transformers, a arquitetura que revolucionou o processamento de linguagem natural e tornou possível os foundation models modernos. Compreendemos o mecanismo de self-attention, que permite aos modelos capturar relações complexas entre tokens, e vimos como técnicas como multi-head attention, otimizações modernas (GQA, Flash Attention, KV-Cache) e componentes arquiteturais (feedforward networks, MoE, residual connections, layer normalization) trabalham em conjunto para criar modelos poderosos e eficientes.

Entendemos também como embeddings posicionais resolvem a limitação fundamental de permutation-invariance dos Transformers, e exploramos as três variantes principais de arquiteturas: encoder-only, decoder-only e encoder-decoder, cada uma adequada para diferentes tipos de tarefas.

Este conhecimento é essencial para os próximos capítulos, onde exploraremos como esses modelos são treinados em escala massiva, como são adaptados para tarefas específicas, e como construir agentes que aproveitam suas capacidades de forma eficaz e confiável.

Exemplos de Código Completos

Esta seção contém implementações completas e executáveis dos principais conceitos apresentados neste capítulo. Use estes exemplos como referência para experimentação e aprofundamento.

Exemplo 1: Experimentando Tokenização com Diferentes Modelos

Este exemplo demonstra como diferentes modelos tokenizam texto usando a biblioteca transformers da Hugging Face.

# Instale a biblioteca primeiro:
# uv pip install transformers
#
################################################################################
#
# Rode este comando para verificar sua GPU:
# nvidia-smi
#
################################################################################
#
# Se não tiver GPU, tudo bem, o código ainda funcionará, só será mais lento.
# Para rodar sem GPU, faça o seguinte:
# uv pip install torch --index-url https://download.pytorch.org/whl/cpu
# uv pip install transformers
#
################################################################################
#
# Ref.: https://huggingface.co/docs/transformers/en/installation#installation

from transformers import AutoTokenizer

# Texto de exemplo
texto = """
A inteligência artificial está revolucionando a forma como construímos
sistemas de software. Os foundation models representam uma mudança
fundamental no paradigma de machine learning.
"""

# Vamos testar com diferentes modelos

# 1. GPT-2 (usa BPE)
print("=" * 60)
print("GPT-2 Tokenization (BPE)")
print("=" * 60)
tokenizer_gpt2 = AutoTokenizer.from_pretrained("gpt2")
tokens_gpt2 = tokenizer_gpt2.tokenize(texto)
print(f"Número de tokens: {len(tokens_gpt2)}")
print(f"Primeiros 20 tokens: {tokens_gpt2[:20]}")
print(f"IDs dos tokens: {tokenizer_gpt2.encode(texto)[:20]}")

# 2. BERT (usa WordPiece)
print("\n" + "=" * 60)
print("BERT Tokenization (WordPiece)")
print("=" * 60)
tokenizer_bert = AutoTokenizer.from_pretrained("bert-base-multilingual-cased")
tokens_bert = tokenizer_bert.tokenize(texto)
print(f"Número de tokens: {len(tokens_bert)}")
print(f"Primeiros 20 tokens: {tokens_bert[:20]}")
print(f"IDs dos tokens: {tokenizer_bert.encode(texto)[:20]}")

# 3. XLM-RoBERTa (usa SentencePiece)
print("\n" + "=" * 60)
print("XLM-RoBERTa Tokenization (SentencePiece)")
print("=" * 60)
tokenizer_xlm = AutoTokenizer.from_pretrained("xlm-roberta-base")
tokens_xlm = tokenizer_xlm.tokenize(texto)
print(f"Número de tokens: {len(tokens_xlm)}")
print(f"Primeiros 20 tokens: {tokens_xlm[:20]}")
print(f"IDs dos tokens: {tokenizer_xlm.encode(texto)[:20]}")

# Exemplo detalhado: tokenizar palavra por palavra
print("\n" + "=" * 60)
print("Tokenização Palavra por Palavra (GPT-2)")
print("=" * 60)
palavras = ["inteligência", "artificial", "revolucionando", "software"]
for palavra in palavras:
    tokens = tokenizer_gpt2.tokenize(palavra)
    ids = tokenizer_gpt2.encode(palavra, add_special_tokens=False)
    print(f"\n'{palavra}':")
    print(f"  Tokens: {tokens}")
    print(f"  IDs: {ids}")
    print(f"  Número de tokens: {len(tokens)}")

# Demonstrar decodificação (tokens → texto)
# O processo inverso: converter IDs numéricos de volta para texto
print("\n" + "=" * 60)
print("Decodificação: Tokens → Texto")
print("=" * 60)
# Pega os primeiros 5 tokens de "inteligência artificial" e reconstrói o texto
texto_decodificado = tokenizer_gpt2.decode(
    tokenizer_gpt2.encode("inteligência artificial")[:5]
)
print(f"Texto original: 'inteligência artificial'")
print(f"Tokens: {tokenizer_gpt2.tokenize('inteligência artificial')[:5]}")
print(f"Texto decodificado: '{texto_decodificado}'")

# Comparação de eficiência entre idiomas
# Modelos treinados em inglês geralmente tokenizam português menos eficientemente
print("\n" + "=" * 60)
print("Comparação: Português vs Inglês")
print("=" * 60)
texto_pt = "A inteligência artificial está revolucionando o mundo."
texto_en = "Artificial intelligence is revolutionizing the world."

# Tokeniza a mesma frase em dois idiomas
tokens_pt = tokenizer_gpt2.tokenize(texto_pt)
tokens_en = tokenizer_gpt2.tokenize(texto_en)

print(f"Português: '{texto_pt}'")
print(f"  Tokens: {len(tokens_pt)} - {tokens_pt}")
print(f"\nInglês: '{texto_en}'")
print(f"  Tokens: {len(tokens_en)} - {tokens_en}")
# Note como português requer mais tokens (custo maior em APIs)
print(f"\nEficiência: Inglês usa ~{len(tokens_pt)/len(tokens_en):.2f}x menos tokens")

Exemplo 2: Implementação de Self-Attention

Implementação completa do mecanismo de self-attention com matrizes de projeção aprendidas.

import torch
import torch.nn as nn

class SelfAttention(nn.Module):
    def __init__(self, d_model, d_k):
        super().__init__()
        # Matrizes de projeção aprendidas durante treinamento
        # Essas matrizes transformam embeddings em queries, keys e values
        self.W_Q = nn.Linear(d_model, d_k)  # Projeta para "o que buscar"
        self.W_K = nn.Linear(d_model, d_k)  # Projeta para "o que oferecer"
        self.W_V = nn.Linear(d_model, d_k)  # Projeta para "a informação"
        self.d_k = d_k  # Dimensão das keys (usado para normalização)

    def forward(self, X):
        """
        Calcula self-attention sobre a sequência de entrada.

        Args:
            X: (batch, seq_len, d_model) - embeddings de entrada

        Returns:
            (batch, seq_len, d_k) - representações contextualizadas
        """
        # Passo 1: Projetar X em espaços Q, K, V
        # Cada token é transformado em 3 representações diferentes
        Q = self.W_Q(X)  # (batch, seq_len, d_k) - "Perguntas"
        K = self.W_K(X)  # (batch, seq_len, d_k) - "Chaves"
        V = self.W_V(X)  # (batch, seq_len, d_k) - "Valores"

        # Passo 2: Computar attention scores (similaridade entre Q e K)
        # @ é multiplicação matricial: cada query compara com todas as keys
        scores = (Q @ K.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_k))
        # scores: (batch, seq_len, seq_len) - matriz de similaridades
        # Dividimos por √d_k para evitar valores muito grandes

        # Passo 3: Aplicar softmax para obter pesos de atenção
        # Transforma scores em probabilidades que somam 1
        attention_weights = torch.softmax(scores, dim=-1)
        # attention_weights[i][j] = quanto o token i presta atenção no token j

        # Passo 4: Ponderar valores V pelos pesos de atenção
        # Cada token recebe uma combinação ponderada de todos os valores
        output = attention_weights @ V  # (batch, seq_len, d_k)

        return output

Exemplo 3: KV-Cache para Geração Autoregressiva

Implementação de KV-Cache que evita recomputação durante geração token-por-token.

class KVCachedAttention(nn.Module):
    def __init__(self, d_model, d_k, d_v):
        super().__init__()
        # Matrizes de projeção (mesmas do SelfAttention)
        self.W_Q = nn.Linear(d_model, d_k)
        self.W_K = nn.Linear(d_model, d_k)
        self.W_V = nn.Linear(d_model, d_v)
        self.d_k = d_k

        # Cache para armazenar K e V de tokens já processados
        # Isso evita recomputar K e V toda vez que geramos um novo token
        self.k_cache = None  # Shape: (batch, seq_len, d_k)
        self.v_cache = None  # Shape: (batch, seq_len, d_v)

    def forward(self, x, use_cache=True):
        """
        Processa novo token usando cache de K e V anteriores.

        Args:
            x: (batch, 1 ou seq_len, d_model) - novo token ou sequência completa
            use_cache: se True, usa e atualiza o cache
        """
        batch_size = x.size(0)

        # Passo 1: Compute Q, K, V APENAS para o novo token
        # Economiza computação pois não recalcula tokens anteriores
        Q_new = self.W_Q(x)  # (batch, 1, d_k) - Query do novo token
        K_new = self.W_K(x)  # (batch, 1, d_k) - Key do novo token
        V_new = self.W_V(x)  # (batch, 1, d_v) - Value do novo token

        # Passo 2: Combinar novo K/V com cache existente
        if use_cache and self.k_cache is not None:
            # Concatena K e V novos com os já computados anteriormente
            K = torch.cat([self.k_cache, K_new], dim=1)  # (batch, seq_len+1, d_k)
            V = torch.cat([self.v_cache, V_new], dim=1)  # (batch, seq_len+1, d_v)
        else:
            # Primeira iteração ou cache desabilitado
            K = K_new
            V = V_new

        # Passo 3: Atualiza cache para a próxima iteração
        if use_cache:
            # Detach evita acumular gradientes desnecessários no cache
            self.k_cache = K.detach()
            self.v_cache = V.detach()

        # Passo 4: Compute attention usando K e V completos (incluindo cache)
        scores = (Q_new @ K.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_k))

        # Máscara causal não é necessária aqui pois Q_new é apenas o último token
        # que naturalmente só atende a tokens anteriores + ele mesmo

        attention_weights = torch.softmax(scores, dim=-1)
        output = attention_weights @ V  # (batch, 1, d_v)

        return output

    def clear_cache(self):
        """Limpa cache entre diferentes gerações (importante entre prompts)"""
        self.k_cache = None
        self.v_cache = None

Exemplo de uso do KV-Cache:

# Exemplo de como usar KV-Cache na prática
model = TransformerWithKVCache(...)
prompt_tokens = tokenize("Era uma vez")

# Fase 1: Encode prompt completo (sem cache ainda)
output = model(prompt_tokens, use_cache=False)

# Fase 2: Geração autoregressiva (com cache)
# Aqui é onde KV-Cache realmente economiza computação
generated_tokens = []
for _ in range(max_new_tokens):
    # Processa APENAS o último token gerado
    # K e V dos tokens anteriores vêm do cache
    next_token_logits = model(last_token, use_cache=True)
    next_token = sample(next_token_logits)  # Escolhe próximo token
    generated_tokens.append(next_token)
    last_token = next_token  # Prepara para próxima iteração

Exemplo 4: Máscara Causal para Modelos Decoder-Only

Implementação de máscara causal que impede atenção em tokens futuros.

import torch

def create_causal_mask(seq_len):
    """
    Cria máscara causal para impedir atenção em tokens futuros.
    Essencial para modelos decoder-only (GPT-like) que não podem "trapacear"
    olhando para o futuro durante o treinamento.

    Args:
        seq_len: comprimento da sequência

    Returns:
        mask: matriz (seq_len, seq_len) com 0s onde permitido, -inf onde bloqueado
    """
    # Matriz triangular inferior: 1s abaixo e na diagonal, 0s acima
    # Cada linha representa um token, cada coluna um token que pode ser visto
    mask = torch.tril(torch.ones(seq_len, seq_len))

    # Na prática, usamos -inf para posições bloqueadas antes do softmax
    # Quando somamos -inf ao attention score, softmax transforma em ~0
    # Isso garante que softmax(score + mask) ≈ 0 para posições futuras
    mask = mask.masked_fill(mask == 0, float('-inf'))  # Posições bloqueadas = -inf
    mask = mask.masked_fill(mask == 1, 0.0)            # Posições permitidas = 0

    return mask

# Visualização para seq_len=5 (antes de aplicar -inf):
# [[1, 0, 0, 0, 0],   Token 0 só vê ele mesmo
#  [1, 1, 0, 0, 0],   Token 1 vê tokens 0-1
#  [1, 1, 1, 0, 0],   Token 2 vê tokens 0-2
#  [1, 1, 1, 1, 0],   Token 3 vê tokens 0-3
#  [1, 1, 1, 1, 1]]   Token 4 vê todos tokens 0-4

# Aplicação na atenção:
def masked_attention(Q, K, V, mask):
    """
    Calcula atenção com máscara causal aplicada.

    Args:
        Q, K, V: queries, keys, values
        mask: máscara causal criada por create_causal_mask()

    Returns:
        output com atenção causal aplicada
    """
    d_k = Q.size(-1)
    # Calcula scores de atenção normalizados
    scores = (Q @ K.transpose(-2, -1)) / torch.sqrt(torch.tensor(d_k))

    # Adiciona máscara antes do softmax
    # scores + mask onde mask=-inf para posições bloqueadas
    # Isso faz com que essas posições tenham probabilidade ~0 após softmax
    scores = scores + mask

    # Softmax transforma -inf em ~0, efetivamente bloqueando atenção futura
    attention_weights = torch.softmax(scores, dim=-1)

    return attention_weights @ V

Exemplo 5: Mixture of Experts (MoE) Layer

Implementação conceitual de uma camada MoE com roteamento dinâmico.

class MoELayer(nn.Module):
    def __init__(self, d_model, d_ff, num_experts=8, top_k=2):
        super().__init__()
        # Cria múltiplos experts (cada um é uma FFN independente)
        # Cada expert pode se especializar em diferentes tipos de conteúdo
        self.experts = nn.ModuleList([
            FFN(d_model, d_ff) for _ in range(num_experts)
        ])
        # Router: rede neural que decide quais experts usar para cada token
        self.router = nn.Linear(d_model, num_experts)
        self.top_k = top_k  # Quantos experts ativar por token (geralmente 2)

    def forward(self, x):
        """
        Processa tokens através de experts selecionados dinamicamente.

        Args:
            x: (batch, seq_len, d_model) - tokens de entrada

        Returns:
            (batch, seq_len, d_model) - tokens processados por experts
        """

        # Passo 1: Router computa scores para cada expert
        # Para cada token, determina quão relevante é cada expert
        router_logits = self.router(x)  # (batch, seq_len, num_experts)
        router_probs = torch.softmax(router_logits, dim=-1)

        # Passo 2: Seleciona top-k experts por token
        # Em vez de usar todos os 8 experts, usa apenas os 2 melhores
        top_k_probs, top_k_indices = torch.topk(router_probs, self.top_k, dim=-1)
        # Normaliza probabilidades dos top-k para somarem 1
        top_k_probs = top_k_probs / top_k_probs.sum(dim=-1, keepdim=True)

        # Passo 3: Processa cada token pelos seus top-k experts
        output = torch.zeros_like(x)
        for i in range(self.top_k):
            # Índice do i-ésimo melhor expert para cada token
            expert_idx = top_k_indices[:, :, i]
            # Peso (probabilidade) deste expert
            expert_prob = top_k_probs[:, :, i:i+1]

            # Roteamento dinâmico: diferentes tokens vão para diferentes experts
            # Um token sobre Python pode ir para Expert 1, sobre medicina para Expert 3
            for expert_id, expert in enumerate(self.experts):
                # Máscara: quais tokens devem usar este expert?
                mask = (expert_idx == expert_id)
                if mask.any():
                    # Processa tokens pela FFN do expert
                    expert_output = expert(x)
                    # Adiciona saída ponderada pela probabilidade do router
                    output += expert_output * expert_prob * mask.unsqueeze(-1)

        return output