Como LLM funciona: tokens, context window e sampling

1. Visão geral

Quando você chama a API de um Large Language Model (LLM), encontra parâmetros como max_tokens, temperature, top_p e erros do tipo “context length exceeded”. Esses parâmetros são fáceis de entender quando você domina a forma como o modelo gera texto.

Este artigo explica três conceitos fundamentais: tokens, context window e sampling. Para cada conceito, você terá um trecho de código Java 21 funcional. Após ler, você conseguirá contar tokens antes de chamar a API, encaixar o histórico de chat no context window, e saberá escolher temperature ou top_p apropriados para cada problema.

2. Loop principal: prevendo o próximo token

Um LLM não escreve toda a resposta de uma vez. Ele executa um loop simples:

  1. Recebe uma sequência de tokens de entrada.
  2. Calcula um logit (pontuação bruta) para cada token no vocabulário, geralmente de dezenas de milhares a mais de centenas de milhares de tokens.
  3. Converte logits em probabilidades e depois escolhe um token. Este passo de escolha é chamado sampling.
  4. Concatena o token recém-escolhido à entrada e volta ao passo 1.

O loop para quando o modelo gera um token de fim ou atinge o limite de max_tokens. Deste loop, três consequências emergem:

  • O modelo vê o texto como tokens, não como caracteres ou palavras.
  • Tudo que o modelo “lembra” em uma chamada deve estar na entrada. Esta entrada é limitada pelo context window.
  • A resposta é o resultado de muitas escolhas aleatórias controladas. Portanto, o mesmo prompt pode produzir resultados diferentes.

3. Tokens

3.1. O que é um token

O tokenizer divide o texto em pequenos pedaços e mapeia cada pedaço para um número inteiro. A maioria dos modelos atuais usa uma variante de Byte Pair Encoding (BPE). Sequências de caracteres que aparecem frequentemente nos dados de treinamento são agrupadas em um único token. Sequências raras são divididas em múltiplos tokens menores, às vezes até em nível de byte.

Portanto, uma palavra inglesa comum como hello geralmente custa apenas um token. Por outro lado, uma frase com diacríticos geralmente custa mais tokens para a mesma quantidade de conteúdo, porque combinações específicas aparecem raramente nos dados de treinamento do tokenizer.

3.2. Contando tokens com JTokkit

Para contar tokens em Java, você pode usar JTokkit. Esta biblioteca implementa encodings BPE do OpenAI como cl100k_base e o200k_base:

<dependency>
    <groupId>com.knuddels</groupId>
    <artifactId>jtokkit</artifactId>
    <version>1.1.0</version>
</dependency>

O código a seguir compara o número de tokens de uma frase em inglês e uma frase em português:

import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingType;
import com.knuddels.jtokkit.api.IntArrayList;

public class TokenCounterDemo {

    public static void main(String[] args) {
        Encoding encoding = Encodings.newDefaultEncodingRegistry()
                .getEncoding(EncodingType.CL100K_BASE);

        String english = "Hello world";
        String portuguese = "Seu pedido foi entregue com sucesso";

        IntArrayList ids = encoding.encode(english);
        System.out.println(english + " -> " + ids.size() + " tokens: " + ids);

        int ptTokens = encoding.countTokens(portuguese);
        System.out.printf("%s -> %d tokens, %d caracteres%n",
                portuguese, ptTokens, portuguese.length());
    }
}

Explicação:

  • getEncoding(EncodingType.CL100K_BASE) obtém a tabela de vocabulário que os primeiros modelos GPT-4 usam.
  • encode(...) retorna uma lista de IDs de tokens. countTokens(...) retorna apenas o número, sendo mais rápido quando você não precisa dos IDs.
  • Com "Hello world", o resultado é 2 tokens: Hello e world. O espaço é agrupado com o token seguinte.

O resultado fica assim:

Hello world -> 2 tokens: [9906, 1917]
Seu pedido foi entregue com sucesso -> 10 tokens, 39 caracteres

Tente executar e verá que a frase em português custa mais tokens em relação ao número de caracteres. Este é um ponto a considerar ao estimar custos, pois os provedores cobram por token.

Nota: Cada família de modelo usa seu próprio tokenizer. Claude, Gemini, Llama e GPT não dividem tokens da mesma forma. JTokkit é preciso apenas com a codificação do OpenAI. Para outros modelos, use o tokenizer ou endpoint de contagem de tokens do provedor, ou trate os números do JTokkit como uma estimativa e deixe uma margem de segurança.

4. Context window

4.1. O que o context window contém

O context window é o número máximo de tokens que o modelo pode processar em uma única chamada. Este número conta tanto entrada quanto saída:

system prompt + histórico de conversa + documentos anexados + pergunta + resposta ≤ context window

Existem três equívocos comuns:

  • “O modelo lembra da conversa anterior.” Errado. A API não mantém estado entre chamadas. Um aplicativo de chat deve reenviar todo o histórico a cada vez, portanto cada chamada subsequente custa mais tokens que a anterior.
  • “Contexto mais longo é sempre melhor.” Nem sempre. Custo e latência aumentam com o número de tokens de entrada. O modelo também tende a perder informações no meio de um contexto muito longo, um fenômeno frequentemente chamado de “lost in the middle”.
  • “Se a entrada cabe no context window, está tudo bem.” Não o suficiente. Você também precisa reservar espaço para a saída. Se a entrada ocupar quase tudo, a resposta será cortada no meio.

4.2. Encaixando o histórico de chat no orçamento de tokens

A estratégia simples e comum é: sempre manter o system prompt, depois pegar mensagens do mais recente para o mais antigo até acabar o orçamento.

import com.knuddels.jtokkit.api.Encoding;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;

public record ChatMessage(String role, String content) {}

public class ContextWindowManager {

    // Cada mensagem custa alguns tokens adicionais para role e caracteres de separação
    private static final int PER_MESSAGE_OVERHEAD = 4;

    private final Encoding encoding;
    private final int contextWindow;
    private final int reservedForOutput;

    public ContextWindowManager(Encoding encoding, int contextWindow, int reservedForOutput) {
        this.encoding = encoding;
        this.contextWindow = contextWindow;
        this.reservedForOutput = reservedForOutput;
    }

    public int countTokens(ChatMessage message) {
        return encoding.countTokens(message.content()) + PER_MESSAGE_OVERHEAD;
    }

    public List<ChatMessage> fit(ChatMessage systemPrompt, List<ChatMessage> history) {
        int budget = contextWindow - reservedForOutput - countTokens(systemPrompt);
        if (budget < 0) {
            throw new IllegalStateException("System prompt muito longo comparado ao context window");
        }

        Deque<ChatMessage> kept = new ArrayDeque<>();
        for (int i = history.size() - 1; i >= 0; i--) {
            int cost = countTokens(history.get(i));
            if (cost > budget) {
                break;
            }
            kept.addFirst(history.get(i));
            budget -= cost;
        }
        kept.addFirst(systemPrompt);
        return List.copyOf(kept);
    }
}

Explicação:

  • reservedForOutput é o espaço reservado para a resposta. Este valor deve corresponder ao max_tokens que você envia para a API.
  • O loop vai de trás para frente na lista, porque mensagens mais recentes geralmente são mais importantes para a pergunta atual.
  • Quando encontra uma mensagem que não cabe mais, para completamente com break, não pula para pegar mensagens mais antigas. Se pular, a conversa será interrompida no meio e o modelo pode entender mal o contexto.
  • addFirst mantém a ordem cronológica correta no resultado retornado.

Na prática, você pode combinar duas técnicas adicionais: resumir mensagens antigas em um parágrafo curto, ou usar RAG para trazer apenas as partes do documento relevantes à pergunta em vez do documento inteiro.

5. Sampling

5.1. De logits a probabilidade: softmax e temperature

Suponha que o modelo precise completar a frase “A capital do Brasil é…” e retorna quatro candidatos com logits: Brasília = 4.0, Rio de Janeiro = 2.5, Salvador = 2.0, São Paulo = 1.0.

Para converter logits em probabilidades, o modelo usa a função softmax. Antes de calcular softmax, os logits são divididos por temperature (T):

  • T < 1 torna a distribuição mais “afiada”. O token com pontuação alta é mais fácil de ser escolhido.
  • T > 1 torna a distribuição mais “plana”. O token com pontuação baixa tem mais chance.

A classe a seguir implementa softmax, temperature, top-k, top-p e a função de escolha de token:

import java.util.*;

public record Candidate(String token, double logit) {}

public class TokenSampler {

    private final Random random;

    public TokenSampler(long seed) {
        this.random = new Random(seed);
    }

    public static Map<String, Double> softmax(List<Candidate> candidates, double temperature) {
        if (temperature <= 0) {
            throw new IllegalArgumentException("temperature deve ser > 0, use greedy() para T = 0");
        }
        double max = candidates.stream().mapToDouble(Candidate::logit).max().orElseThrow();
        Map<String, Double> exps = new LinkedHashMap<>();
        double sum = 0;
        for (Candidate c : candidates) {
            double e = Math.exp((c.logit() - max) / temperature);
            exps.put(c.token(), e);
            sum += e;
        }
        Map<String, Double> probs = new LinkedHashMap<>();
        for (var entry : exps.entrySet()) {
            probs.put(entry.getKey(), entry.getValue() / sum);
        }
        return sortDesc(probs);
    }

    public static Map<String, Double> topK(Map<String, Double> probs, int k) {
        Map<String, Double> kept = new LinkedHashMap<>();
        sortDesc(probs).entrySet().stream().limit(k)
                .forEach(e -> kept.put(e.getKey(), e.getValue()));
        return normalize(kept);
    }

    public static Map<String, Double> topP(Map<String, Double> probs, double p) {
        Map<String, Double> kept = new LinkedHashMap<>();
        double cumulative = 0;
        for (var entry : sortDesc(probs).entrySet()) {
            kept.put(entry.getKey(), entry.getValue());
            cumulative += entry.getValue();
            if (cumulative >= p) {
                break;
            }
        }
        return normalize(kept);
    }

    public static String greedy(List<Candidate> candidates) {
        return candidates.stream()
                .max(Comparator.comparingDouble(Candidate::logit))
                .orElseThrow().token();
    }

    public String sample(Map<String, Double> probs) {
        double r = random.nextDouble();
        double cumulative = 0;
        String last = null;
        for (var entry : probs.entrySet()) {
            cumulative += entry.getValue();
            last = entry.getKey();
            if (r < cumulative) {
                return last;
            }
        }
        return last; // proteção contra erro de arredondamento
    }

    private static Map<String, Double> normalize(Map<String, Double> probs) {
        double sum = probs.values().stream().mapToDouble(Double::doubleValue).sum();
        Map<String, Double> result = new LinkedHashMap<>();
        probs.forEach((token, prob) -> result.put(token, prob / sum));
        return result;
    }

    private static Map<String, Double> sortDesc(Map<String, Double> probs) {
        Map<String, Double> sorted = new LinkedHashMap<>();
        probs.entrySet().stream()
                .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
                .forEach(e -> sorted.put(e.getKey(), e.getValue()));
        return sorted;
    }
}

Explicação das partes principais:

  • Em softmax, cada logit é subtraído do valor máximo antes de chamar Math.exp. O resultado matemático não muda, mas evita overflow quando os logits são grandes.
  • topK e topP normalizam novamente as probabilidades para somar 1 depois de eliminar candidatos.
  • sample pega um número aleatório entre [0, 1) e acumula probabilidades. O candidato que fazer a soma acumulada ultrapassar esse número é escolhido.

Tente imprimir as probabilidades com três níveis de temperature:

var candidates = List.of(
        new Candidate("Brasília", 4.0), new Candidate("Rio de Janeiro", 2.5),
        new Candidate("Salvador", 2.0), new Candidate("São Paulo", 1.0));

for (double t : new double[]{0.5, 1.0, 2.0}) {
    System.out.println("T=" + t + " " + TokenSampler.softmax(candidates, t));
}

O resultado (arredondado):

T=0.5 {Brasília=0.934, Rio de Janeiro=0.047, Salvador=0.017, São Paulo=0.002}
T=1.0 {Brasília=0.710, Rio de Janeiro=0.158, Salvador=0.096, São Paulo=0.035}
T=2.0 {Brasília=0.485, Rio de Janeiro=0.229, Salvador=0.178, São Paulo=0.108}

Em T = 2.0, cerca de 1 em 9 vezes o modelo retornará “São Paulo”. Para uma pergunta de fato, essa é uma resposta errada. Este exemplo mostra por que temperature alta tende a levar a “alucinações”.

Temperature = 0 é geralmente entendido como greedy decoding: sempre escolhe o token com o logit mais alto, correspondente à função greedy().

5.2. Top-k e top-p

Ambas as técnicas cortam a “cauda longa” de tokens com baixa probabilidade antes de escolher:

  • Top-k: mantém apenas os k tokens com maior probabilidade. Com k = 2, restam apenas Brasília e Rio de Janeiro.
  • Top-p (nucleus sampling): mantém o menor grupo de tokens cuja probabilidade acumulada é ≥ p. Com T = 1.0 e p = 0.9, a soma acumulada é 0.710, depois 0.868, depois 0.964. Então top-p mantém 3 tokens e exclui São Paulo.

Top-p é mais flexível que top-k. Quando o modelo tem certeza, top-p mantém poucos tokens. Quando o modelo está em dúvida, mantém mais tokens. Top-k sempre mantém exatamente k tokens, independentemente da distribuição.

Nota: Muitos provedores recomendam ajustar apenas um dos parâmetros temperature ou top_p, não ambos ao mesmo tempo. A razão é que seus efeitos se sobrepõem e é muito difícil prever o resultado.

5.3. Testando o comportamento do sampling

Dependência para teste. Com Spring Boot 3.x já está incluído em spring-boot-starter-test:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.stream.IntStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;

class TokenSamplerTest {

    private final List<Candidate> candidates = List.of(
            new Candidate("Brasília", 4.0), new Candidate("Rio de Janeiro", 2.5),
            new Candidate("Salvador", 2.0), new Candidate("São Paulo", 1.0));

    @Test
    void lowerTemperatureMakesTopTokenMoreLikely() {
        double cold = TokenSampler.softmax(candidates, 0.5).get("Brasília");
        double hot = TokenSampler.softmax(candidates, 2.0).get("Brasília");

        assertThat(cold).isCloseTo(0.934, within(0.001));
        assertThat(cold).isGreaterThan(hot);
    }

    @Test
    void topPKeepsSmallestNucleus() {
        var nucleus = TokenSampler.topP(TokenSampler.softmax(candidates, 1.0), 0.9);

        assertThat(nucleus).containsOnlyKeys("Brasília", "Rio de Janeiro", "Salvador");
        assertThat(nucleus.values().stream().mapToDouble(Double::doubleValue).sum())
                .isCloseTo(1.0, within(1e-9));
    }

    @Test
    void sameSeedGivesSameSequence() {
        var probs = TokenSampler.softmax(candidates, 1.0);
        var first = new TokenSampler(42L);
        var second = new TokenSampler(42L);

        List<String> a = IntStream.range(0, 20).mapToObj(i -> first.sample(probs)).toList();
        List<String> b = IntStream.range(0, 20).mapToObj(i -> second.sample(probs)).toList();

        assertThat(a).isEqualTo(b);
    }

    @Test
    void greedyAlwaysPicksHighestLogit() {
        assertThat(TokenSampler.greedy(candidates)).isEqualTo("Brasília");
    }
}

O último teste ressalta um ponto importante: a mesma seed produz a mesma sequência de resultados. Algumas APIs têm um parâmetro seed, mas a maioria apenas se compromete a “tentar reproduzir” (best effort). A computação paralela em GPU e a agregação de múltiplas requisições no mesmo batch podem fazer os logits variar um pouco. Portanto, mesmo com temperature = 0, você não deve escrever testes que comparem exatamente cada palavra na resposta de um LLM real.

6. Escolhendo parâmetros na prática

Problema Sugestão
Extração de dados, classificação, gerar JSON temperature 0–0.2
Responder perguntas, gerar código temperature 0.2–0.5
Escrita criativa, brainstorm temperature 0.7–1.0 ou top_p 0.9–0.95

Alguns erros comuns:

  • Defini max_tokens muito baixo: JSON fica cortado no meio e não faz parse. Verifique o campo de razão de parada na resposta (geralmente finish_reason ou stop_reason) antes de processar o resultado.
  • Não contar tokens antes de enviar: requisição é rejeitada quando o histórico de chat fica longo com o tempo.
  • Usar temperature alta em tarefas que precisam de precisão: o modelo “inventa” fatos que não existem.
  • Achar que temperature = 0 garante correção: greedy apenas escolhe o token com maior probabilidade. Se o modelo entender errado desde o início, será errado de forma consistente.

7. Conclusão

O artigo cobriu três conceitos fundamentais de LLM. Tokens são a unidade que o modelo lê e também a unidade de cobrança. Português geralmente custa mais tokens que inglês para o mesmo conteúdo. Context window limita o total de tokens de entrada e saída em uma única chamada, então você precisa contar ativamente e encaixar o histórico. Sampling determina como escolher o próximo token da distribuição de probabilidade. temperature muda a “nitidez” da distribuição, enquanto top-k e top-p cortam a cauda de baixa probabilidade.

Quando você domina o loop “prever token, escolher token, concatenar na entrada”, consegue explicar a maioria dos comportamentos de LLM que encontra ao trabalhar com APIs, desde respostas cortadas a resultados diferentes entre chamadas.

llmaijavatokenizationsampling