LLM 如何工作:token、context window 与采样

1. 概述

调用大型语言模型(LLM)的 API 时,你会遇到像 max_tokens、temperature、top_p 这样的参数,以及“context length exceeded”这类错误。当你理解了模型如何生成文本,这些参数就容易理解了。

本文介绍了三个基础概念:token、context window 和 sampling。对于每个概念,你都会得到一段可以运行的 Java 21 代码。阅读完后,你将能够在调用 API 前计数 token,将聊天历史适配于 context window,并知道如何为不同的问题选择合适的 temperature 或 top_p。

2. 核心循环:预测下一个 token

LLM 不会一次生成整个答案。它运行一个简单的循环:

  1. 接收输入 token 序列。
  2. 为词表中的每个 token 计算一个 logit(原始分数),通常数量从几万到十多万不等。
  3. 将 logit 转换为概率,然后 选择 一个 token。这个选择步骤称为采样。
  4. 将选定的 token 附加到输入中,然后回到步骤 1。

当模型生成结束 token 或达到 max_tokens 限制时,循环停止。从这个循环得出三个结论:

  • 模型将文本看作 token,而不是字符或单词。
  • 模型在一次调用中“记住”的所有内容都必须在输入中。这个输入受 context window 限制。
  • 答案是多次 受控随机选择 的结果。因此相同的提示可能产生不同的结果。

3. Token

3.1. Token 是什么

Tokenizer 将文本分割成小块,然后将每块映射到一个整数。大多数现代模型使用 Byte Pair Encoding (BPE) 的变体。在训练数据中频繁出现的字符序列被合并为一个 token。不常见的序列被分割成多个较小的 token,有时甚至到单个字节。

因此,像 hello 这样的常见英文单词通常只需要一个 token。相比之下,带有音调符号的越南语对于相同的内容通常需要更多 token,因为像“ượ”或“ững”这样的组合在 tokenizer 的训练数据中很少出现。

3.2. 使用 JTokkit 计数 token

要在 Java 中计数 token,你可以使用 JTokkit。这个库实现了 OpenAI 的 BPE 编码,如 cl100k_base 和 o200k_base:

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

以下代码比较英文句子和越南文句子的 token 数量:

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 个 token,%d 个字符%n",
                vietnamese, viTokens, vietnamese.length());
    }
}

解释:

  • getEncoding(EncodingType.CL100K_BASE) 获取早期 GPT-4 模型使用的词表。
  • encode(...) 返回 token id 列表。countTokens(...) 只返回数量,因此在你不需要 id 时速度更快。
  • 对于 "Hello world",结果是 2 个 token:Hello 和 world。空格被合并到后面的 token 中。

结果看起来像:

Hello world -> 2 tokens: [9906, 1917]
Đơn hàng của bạn đã được giao thành công -> ... 个 token,40 个字符

你可以自己运行来看越南文消耗的 token 比单词数多得多。在估算成本时这是一个需要考虑的因素,因为提供商按 token 计费。

注意: 每个模型系列使用自己的 tokenizer。Claude、Gemini、Llama 和 GPT 的 token 分割方式不同。JTokkit 只对 OpenAI 的编码精确。对于其他模型,你应该使用提供商的 tokenizer 或计数 token 的端点,或者将 JTokkit 的数据视为估计值并留下安全余量。

4. Context window

4.1. Context window 包含什么

Context window 是模型在 一次调用 中能处理的最大 token 数。这个数字 既包括输入也包括输出:

system prompt + 聊天历史 + 附加文档 + 问题 + 答案 ≤ context window

有三个常见的误解:

  • “模型记住之前的对话。” 错误。API 不在调用之间保持状态。聊天应用必须每次重新发送整个历史记录,所以每次后续调用都比前一次消耗更多 token。
  • “Context 越长越好。” 不一定。成本和延迟随输入 token 数增加。模型也容易忽略非常长的 context 中间部分的信息,这种现象通常称为“lost in the middle”。
  • “输入刚好适应 context window 就够了。” 不够。你还需要为输出预留空间。如果输入占用了几乎全部空间,答案会被截断。

4.2. 将聊天历史截断以适应 token 预算

一个简单且常见的策略是:始终保留 system prompt,然后从 最新到最早 获取消息,直到用尽预算。

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 {

    // 每条消息还需要额外的几个 token 来编码 role 和分隔符
    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 相对于 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);
    }
}

解释:

  • reservedForOutput 是为答案预留的部分。这个值应该与你发送到 API 的 max_tokens 匹配。
  • 循环从列表末尾向后遍历,因为最新的消息通常对当前问题最重要。
  • 当遇到无法容纳的消息时,用 break 完全停止,不要跳过去获取更早的消息。如果跳过,对话会在中间断裂,模型容易误解上下文。
  • addFirst 在返回的结果中保持正确的时间顺序。

在实践中,你可以结合另外两种技术:摘要 旧消息为简短的段落,或使用 RAG 只引入与问题相关的文档片段而不是整个文档。

5. 采样

5.1. 从 logit 到概率:softmax 和 temperature

假设模型需要完成句子“越南的首都是…“并返回四个候选项,logit 如下:Hà Nội = 4.0, Huế = 2.5, Đà Nẵng = 2.0, Sài Gòn = 1.0。

为了将 logit 转换为概率,模型使用 softmax 函数。在计算 softmax 之前,logit 除以 temperature (T):

  • T < 1 使分布“更尖锐”。高分数的 token 更容易被选择。
  • T > 1 使分布“更平坦”。低分数的 token 有更多机会。

以下类实现了 softmax、temperature、top-k、top-p 和 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 必须 > 0,使用 greedy() 作为 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; // 防止舍入误差
    }

    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;
    }
}

解释主要部分:

  • 在 softmax 中,每个 logit 在调用 Math.exp 前都减去最大值。数学结果不变,但避免了 logit 较大时的数值溢出。
  • topK 和 topP 都在去除候选项后重新规范化概率,使总和为 1。
  • sample 获取 [0, 1) 范围内的随机数,然后累加概率。使累积和超过该数的候选项会被选中。

尝试用三个 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));
}

结果(已四舍五入):

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}

在 T = 2.0 时,大约 1/9 的时间模型会回答“Sài Gòn”。对于事实性问题,这是一个错误的答案。这个例子说明了为什么高 temperature 容易导致“幻觉”。

Temperature = 0 通常理解为 greedy decoding:总是选择 logit 最高的 token,对应于 greedy() 函数。

5.2. Top-k 和 top-p

这两种技术在选择前切断包含低概率 token 的“长尾”部分:

  • Top-k:只保留概率最高的 k 个 token。对于 k = 2,只剩Hà Nội和Huế。
  • Top-p (nucleus sampling):保留最小的 token 组,其总概率 ≥ p。对于 T = 1.0 和 p = 0.9,累积总和为 0.710,然后 0.868,然后 0.964。所以 top-p 保留 3 个 token 并排除Sài Gòn。

Top-p 比 top-k 更灵活。当模型非常确定时,top-p 只保留几个 token。当模型犹豫时,它保留更多 token。Top-k 总是保留恰好 k 个 token,不管分布如何。

注意: 许多提供商建议只调整 temperature 或 top_p 之一,不要同时调整两者。原因是它们的作用相互叠加,很难预测结果。

5.3. 测试采样行为

测试依赖项,对于 Spring Boot 3.x 已包含在 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");
    }
}

最后的测试说明了一个重要问题:相同的种子会给出相同的结果序列。一些 API 有 seed 参数,但大多数只承诺“尽力重现”(best effort)。GPU 上的并行计算和将多个请求分组到同一个 batch 可能会使 logit 偏离一点。因此,即使 temperature = 0,你也不应该编写测试来精确匹配真实 LLM 答案中的每个字。

6. 实践中的参数选择

问题 建议
数据提取、分类、生成 JSON temperature 0–0.2
回答问题、生成代码 temperature 0.2–0.5
创意写作、头脑风暴 temperature 0.7–1.0 或 top_p 0.9–0.95

一些常见的错误:

  • 将 max_tokens 设置过低:JSON 被截断且无法解析。在处理结果前,检查响应中的停止原因字段(通常是 finish_reason 或 stop_reason)。
  • 在发送前不计数 token:当聊天历史随时间变长时,请求会被拒绝。
  • 对需要准确性的任务使用高 temperature:模型会“编造”不存在的数据。
  • 认为 temperature = 0 能保证正确:greedy 只选择概率最高的 token。如果模型从一开始就误解,它会一致地出错。

7. 结论

本文介绍了 LLM 的三个基础概念。Token 是模型读取的单位,也是计费单位。越南语对于相同内容通常比英语消耗更多 token。Context window 限制了一次调用中的输入和输出的总 token 数,因此你需要主动计数和截断历史记录。采样 决定如何从概率分布中选择下一个 token。temperature 改变分布的“尖锐”程度,而 top-k 和 top-p 切掉低概率尾部。

一旦掌握了“预测 token、选择 token、附加到输入”的循环,你就能解释在使用 API 时遇到的大多数 LLM 行为,从答案被截断到不同调用的不同结果。

llmaijavatokenizationsampling