Cómo funcionan los LLM: tokens, ventana de contexto y muestreo
1. Descripción general
Cuando llamas a la API de un Modelo de Lenguaje Grande (LLM), te encontrarás con parámetros como max_tokens, temperature, top_p y errores como “context length exceeded”. Estos parámetros son fáciles de entender cuando comprendes cómo genera texto el modelo.
Este artículo explica tres conceptos fundamentales: token, ventana de contexto y muestreo. Para cada concepto, tendrás un fragmento de código Java 21 ejecutable. Después de leerlo, podrás contar tokens antes de llamar a la API, truncar el historial de chat para que se ajuste a la ventana de contexto, y saber elegir temperature o top_p apropiados para cada problema.
2. El ciclo central: predicción del siguiente token
Un LLM no escribe toda la respuesta de una sola vez. Ejecuta un bucle simple:
- Recibe una secuencia de tokens de entrada.
- Calcula un logit (puntuación sin procesar) para cada token en el vocabulario, generalmente desde decenas de miles hasta más de cien mil tokens.
- Convierte los logits en probabilidades y selecciona un token. Este paso de selección se llama muestreo.
- Concatena el token seleccionado a la entrada y vuelve al paso 1.
El bucle se detiene cuando el modelo genera un token de terminación o alcanza el límite max_tokens. De este bucle se derivan tres consecuencias:
- El modelo ve el texto como tokens, no como caracteres o palabras.
- Todo lo que el modelo “recuerda” en una única llamada debe estar en la entrada. Esta entrada está limitada por la ventana de contexto.
- La respuesta es el resultado de múltiples selecciones aleatorias controladas. Por lo tanto, el mismo prompt puede producir resultados diferentes.
3. Token
3.1. ¿Qué es un token?
Un tokenizador divide el texto en pequeños fragmentos y mapea cada fragmento a un número entero. La mayoría de los modelos actuales utilizan una variante de Byte Pair Encoding (BPE). Las secuencias de caracteres que aparecen frecuentemente en los datos de entrenamiento se combinan en un único token. Las secuencias raras se dividen en varios tokens más pequeños, a veces hasta el nivel de bytes individuales.
Por lo tanto, una palabra común en inglés como hello generalmente solo cuesta un token. Por el contrario, el vietnamita con tonos generalmente cuesta más tokens para la misma cantidad de contenido, porque combinaciones como “ượ” o “ững” aparecen raramente en los datos de entrenamiento del tokenizador.
3.2. Contar tokens con JTokkit
Para contar tokens en Java, puedes usar JTokkit. Esta biblioteca implementa las codificaciones BPE de OpenAI como cl100k_base y o200k_base:
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
El siguiente código compara el número de tokens de una oración en inglés y una en vietnamita:
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 vietnamese = "Đơn hàng của bạn đã được giao thành công";
IntArrayList ids = encoding.encode(english);
System.out.println(english + " -> " + ids.size() + " tokens: " + ids);
int viTokens = encoding.countTokens(vietnamese);
System.out.printf("%s -> %d tokens, %d caracteres%n",
vietnamese, viTokens, vietnamese.length());
}
}
Explicación:
getEncoding(EncodingType.CL100K_BASE)obtiene la tabla de vocabulario que utilizan los primeros modelos GPT-4.encode(...)devuelve una lista de IDs de tokens.countTokens(...)solo devuelve la cantidad, por lo que es más rápido cuando no necesitas los IDs.- Con
"Hello world", el resultado es 2 tokens:Helloyworld. El espacio se combina en el token posterior.
El resultado tiene esta forma:
Hello world -> 2 tokens: [9906, 1917]
Đơn hàng của bạn đã được giao thành công -> ... tokens, 40 caracteres
Ejecuta el código para ver que la oración en vietnamita consume muchos más tokens que el número de palabras que tiene. Este es un punto importante a considerar al estimar costos, ya que los proveedores cobran por tokens.
Nota: Cada familia de modelos usa un tokenizador diferente. Claude, Gemini, Llama y GPT no dividen los tokens de la misma manera. JTokkit solo es preciso con la codificación de OpenAI. Para otros modelos, usa el tokenizador o endpoint de conteo de tokens del proveedor, o trata los números de JTokkit como una estimación y deja un margen de seguridad.
4. Ventana de contexto
4.1. ¿Qué contiene la ventana de contexto?
La ventana de contexto es el número máximo de tokens que el modelo puede procesar en una única llamada. Este número cuenta tanto la entrada como la salida:
system prompt + historial de conversación + documentos adjuntos + pregunta + respuesta ≤ ventana de contexto
Hay tres conceptos erróneos comunes:
-
“El modelo recuerda conversaciones anteriores.” Incorrecto. La API no mantiene estado entre llamadas. La aplicación de chat debe reenviar el historial completo cada vez, por lo que cada llamada posterior consume más tokens que la anterior.
-
“Contexto más largo es siempre mejor.” No necesariamente. El costo y la latencia aumentan con el número de tokens de entrada. El modelo también tiende a pasar por alto información en el medio de un contexto muy largo, un fenómeno comúnmente llamado “lost in the middle” (perdido en el medio).
-
“Si la entrada cabe en la ventana de contexto, es suficiente.” No del todo. Aún necesitas reservar espacio para la salida. Si la entrada ocupa casi todo el espacio, la respuesta se truncará.
4.2. Truncar el historial de chat para que se ajuste al presupuesto de tokens
La estrategia simple y común es: siempre mantener el system prompt, luego tomar mensajes del más reciente al más antiguo hasta que se agote el presupuesto.
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 mensaje cuesta algunos tokens adicionales para el rol y separadores
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("El system prompt es demasiado largo en relación con la ventana de contexto");
}
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);
}
}
Explicación:
reservedForOutputes el espacio reservado para la respuesta. Este valor debe coincidir con elmax_tokensque envías a la API.- El bucle itera hacia atrás desde el final de la lista, porque los mensajes más recientes suelen ser los más relevantes para la pregunta actual.
- Cuando un mensaje no cabe, detente completamente con
break, no lo omitas para tomar uno más antiguo. Si lo omites, la conversación se dividirá en el medio y el modelo puede malinterpretarla. addFirstmantiene el orden temporal correcto en el resultado devuelto.
En la práctica, puedes combinar dos técnicas más: resumir los mensajes antiguos en un párrafo corto, o usar RAG para incluir solo los fragmentos de documentos relevantes para la pregunta en lugar del documento completo.
5. Muestreo
5.1. De logits a probabilidades: softmax y temperature
Supón que el modelo necesita completar la oración “La capital de Vietnam es…” y devuelve cuatro candidatos con logits así: Hà Nội = 4.0, Huế = 2.5, Đà Nẵng = 2.0, Sài Gòn = 1.0.
Para convertir logits en probabilidades, el modelo usa la función softmax. Antes de calcular softmax, el logit se divide por temperature (T):
- T < 1 hace la distribución más “puntiaguda”. Los tokens con puntuaciones altas son más propensos a ser seleccionados.
- T > 1 hace la distribución más “plana”. Los tokens con puntuaciones bajas tienen más oportunidades.
La siguiente clase implementa softmax, temperature, top-k, top-p y la función de selección 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 debe ser > 0, usa 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; // en caso de errores de redondeo
}
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;
}
}
Explicación de las partes principales:
- En
softmax, cada logit se resta del valor máximo antes de llamar aMath.exp. El resultado matemático es el mismo, pero evita el desbordamiento cuando los logits son grandes. - Tanto
topKcomotopPnormalizan nuevamente las probabilidades para que la suma sea 1 después de eliminar candidatos. sampletoma un número aleatorio en el rango [0, 1) y acumula probabilidades. El candidato que hace que la suma acumulada supere ese número es seleccionado.
Intenta imprimir probabilidades con tres niveles de temperature:
var candidates = List.of(
new Candidate("Hà Nội", 4.0), new Candidate("Huế", 2.5),
new Candidate("Đà Nẵng", 2.0), new Candidate("Sài Gòn", 1.0));
for (double t : new double[]{0.5, 1.0, 2.0}) {
System.out.println("T=" + t + " " + TokenSampler.softmax(candidates, t));
}
Resultado (redondeado):
T=0.5 {Hà Nội=0.934, Huế=0.047, Đà Nẵng=0.017, Sài Gòn=0.002}
T=1.0 {Hà Nội=0.710, Huế=0.158, Đà Nẵng=0.096, Sài Gòn=0.035}
T=2.0 {Hà Nội=0.485, Huế=0.229, Đà Nẵng=0.178, Sài Gòn=0.108}
En T = 2.0, aproximadamente 1 de cada 9 veces el modelo responderá “Sài Gòn”. Para una pregunta de hechos, esa es una respuesta incorrecta. Este ejemplo muestra por qué una temperature alta tiende a llevar a “alucinaciones”.
Temperature = 0 se entiende comúnmente como decodificación greedy: siempre selecciona el token con el logit más alto, correspondiente a la función greedy().
5.2. Top-k y top-p
Estas dos técnicas eliminan la “cola larga” de tokens con baja probabilidad antes de seleccionar:
-
Top-k: solo mantiene los k tokens con mayor probabilidad. Con k = 2, solo quedan
Hà NộiyHuế. -
Top-p (nucleus sampling): mantiene el grupo más pequeño de tokens con probabilidad acumulativa ≥ p. Con T = 1.0 y p = 0.9, la suma acumulada es 0.710, luego 0.868, luego 0.964. Así que top-p mantiene 3 tokens y elimina a
Sài Gòn.
Top-p es más flexible que top-k. Cuando el modelo está muy seguro, top-p solo mantiene algunos tokens. Cuando el modelo es indeciso, mantiene más tokens. Top-k siempre mantiene exactamente k tokens, sin importar cómo se vea la distribución.
Nota: Muchos proveedores recomiendan ajustar solo uno de los dos parámetros
temperatureotop_p, no ambos al mismo tiempo. La razón es que sus efectos se superponen y es muy difícil predecir el resultado.
5.3. Prueba del comportamiento del muestreo
Dependencia para las pruebas, con Spring Boot 3.x ya está incluida en 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("Hà Nội", 4.0), new Candidate("Huế", 2.5),
new Candidate("Đà Nẵng", 2.0), new Candidate("Sài Gòn", 1.0));
@Test
void lowerTemperatureMakesTopTokenMoreLikely() {
double cold = TokenSampler.softmax(candidates, 0.5).get("Hà Nội");
double hot = TokenSampler.softmax(candidates, 2.0).get("Hà Nội");
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("Hà Nội", "Huế", "Đà Nẵng");
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("Hà Nội");
}
}
La última prueba señala un punto importante: el mismo seed produce la misma secuencia de resultados. Algunas APIs tienen un parámetro seed, pero la mayoría solo se compromete a “hacer el mejor esfuerzo” (best effort). El cálculo paralelo en GPU y agrupar múltiples solicitudes en un solo lote puede hacer que los logits difieran ligeramente. Por lo tanto, incluso con temperature = 0, no deberías escribir pruebas que comparen exactamente cada carácter con las respuestas reales de los LLM.
6. Elegir parámetros en la práctica
| Problema | Sugerencia |
|---|---|
| Extracción de datos, clasificación, generación de JSON | temperature 0–0.2 |
| Responder preguntas, generar código | temperature 0.2–0.5 |
| Escritura creativa, lluvia de ideas | temperature 0.7–1.0 o top_p 0.9–0.95 |
Algunos errores comunes:
-
Establecer
max_tokensdemasiado bajo: JSON se trunca a mitad de camino y no se puede analizar. Verifica el campo de motivo de parada en la respuesta (generalmentefinish_reasonostop_reason) antes de procesar el resultado. -
No contar tokens antes de enviar: la solicitud se rechaza a medida que el historial de chat crece con el tiempo.
-
Usar temperature alta para tareas que requieren precisión: el modelo “alucina” datos que no son reales.
-
Asumir que temperature = 0 garantiza la corrección: greedy solo selecciona el token con la probabilidad más alta. Si el modelo malinterpreta desde el principio, será incorrecto de manera consistente.
7. Conclusión
Este artículo ha cubierto tres conceptos fundamentales de los LLM. Token es la unidad que lee el modelo y también la unidad para facturación. El vietnamita generalmente cuesta más tokens que el inglés para el mismo contenido. Ventana de contexto limita el número total de tokens de entrada y salida en una única llamada, por lo que necesitas contar y truncar activamente el historial. Muestreo determina cómo se selecciona el siguiente token de una distribución de probabilidad. temperature cambia la “nitidez” de la distribución, mientras que top-k y top-p descartan la cola de baja probabilidad.
Una vez que entiendas el bucle “predecir token, seleccionar token, concatenar a la entrada”, puedes explicar la mayoría de los comportamientos de los LLM que encuentres cuando trabajes con la API, desde respuestas truncadas hasta resultados diferentes entre llamadas.