LLM hoạt động thế nào: token, context window và sampling

1. Tổng quan

Khi gọi API của một Large Language Model (LLM), bạn sẽ gặp các tham số như max_tokens, temperature, top_p và lỗi kiểu “context length exceeded”. Các tham số này dễ hiểu khi bạn nắm được cách model sinh văn bản.

Bài này giải thích ba khái niệm nền tảng: token, context window và sampling. Với mỗi khái niệm, bạn sẽ có một đoạn code Java 21 chạy được. Đọc xong, bạn đếm được token trước khi gọi API, cắt lịch sử chat cho vừa context window, và biết chọn temperature hay top_p phù hợp với từng bài toán.

2. Vòng lặp cốt lõi: dự đoán token tiếp theo

LLM không viết cả câu trả lời trong một lần. Nó chạy một vòng lặp đơn giản:

  1. Nhận một chuỗi token đầu vào.
  2. Tính ra một logit (điểm thô) cho mọi token trong từ vựng, thường từ vài chục nghìn đến hơn một trăm nghìn token.
  3. Chuyển logit thành xác suất rồi chọn một token. Bước chọn này gọi là sampling.
  4. Nối token vừa chọn vào đầu vào và quay lại bước 1.

Vòng lặp dừng khi model sinh ra token kết thúc hoặc chạm giới hạn max_tokens. Từ vòng lặp này rút ra ba hệ quả:

  • Model nhìn văn bản dưới dạng token, không phải ký tự hay từ.
  • Mọi thứ model “nhớ” trong một lượt gọi đều phải nằm trong đầu vào. Đầu vào này bị giới hạn bởi context window.
  • Câu trả lời là kết quả của nhiều lần chọn ngẫu nhiên có kiểm soát. Vì vậy cùng một prompt có thể cho ra kết quả khác nhau.

3. Token

3.1. Token là gì

Tokenizer cắt văn bản thành các mảnh nhỏ rồi ánh xạ mỗi mảnh thành một số nguyên. Phần lớn model hiện nay dùng biến thể của Byte Pair Encoding (BPE). Các chuỗi ký tự xuất hiện thường xuyên trong dữ liệu huấn luyện được gộp thành một token. Chuỗi hiếm gặp bị tách thành nhiều token nhỏ hơn, có khi tới mức từng byte.

Vì vậy một từ tiếng Anh phổ biến như hello thường chỉ tốn một token. Ngược lại, tiếng Việt có dấu thường tốn nhiều token hơn cho cùng một lượng nội dung, vì các tổ hợp như “ượ” hay “ững” ít xuất hiện trong dữ liệu huấn luyện của tokenizer.

3.2. Đếm token bằng JTokkit

Để đếm token trong Java, bạn có thể dùng JTokkit. Thư viện này cài đặt các encoding BPE của OpenAI như cl100k_base và o200k_base:

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

Đoạn code sau so sánh số token của câu tiếng Anh và câu tiếng Việt:

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 ký tự%n",
                vietnamese, viTokens, vietnamese.length());
    }
}

Giải thích:

  • getEncoding(EncodingType.CL100K_BASE) lấy bảng từ vựng mà các model GPT-4 đời đầu sử dụng.
  • encode(...) trả về danh sách token id. countTokens(...) chỉ trả về số lượng nên nhanh hơn khi bạn không cần id.
  • Với "Hello world", kết quả là 2 token: Hello và world. Dấu cách được gộp vào token phía sau.

Kết quả có dạng:

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

Bạn hãy tự chạy để thấy câu tiếng Việt tốn token nhiều hơn hẳn so với số từ của nó. Đây là điểm cần tính tới khi ước lượng chi phí, vì nhà cung cấp tính tiền theo token.

Lưu ý: Mỗi họ model dùng một tokenizer riêng. Claude, Gemini, Llama và GPT không chia token giống nhau. JTokkit chỉ chính xác với encoding của OpenAI. Với model khác, bạn hãy dùng tokenizer hoặc endpoint đếm token của nhà cung cấp, hoặc coi số liệu từ JTokkit là ước lượng và để dư biên an toàn.

4. Context window

4.1. Context window chứa những gì

Context window là số token tối đa model xử lý được trong một lượt gọi. Con số này tính cả đầu vào lẫn đầu ra:

system prompt + lịch sử hội thoại + tài liệu đính kèm + câu hỏi + câu trả lời ≤ context window

Có ba hiểu lầm hay gặp:

  • “Model nhớ cuộc trò chuyện trước.” Sai. API không giữ trạng thái giữa các lượt gọi. Ứng dụng chat phải gửi lại toàn bộ lịch sử mỗi lần, nên mỗi lượt sau tốn nhiều token hơn lượt trước.
  • “Context càng dài càng tốt.” Không hẳn. Chi phí và độ trễ tăng theo số token đầu vào. Model cũng có xu hướng bỏ sót thông tin nằm ở giữa một context rất dài, hiện tượng thường gọi là “lost in the middle”.
  • “Đầu vào vừa context window là đủ.” Chưa đủ. Bạn còn phải chừa chỗ cho đầu ra. Nếu đầu vào chiếm gần hết, câu trả lời sẽ bị cắt ngang.

4.2. Cắt lịch sử chat cho vừa ngân sách token

Chiến lược đơn giản và phổ biến là: luôn giữ system prompt, sau đó lấy tin nhắn từ mới nhất về cũ nhất cho tới khi hết ngân sách.

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 {

    // Mỗi tin nhắn tốn thêm vài token cho role và ký tự phân tách
    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 quá dài so với 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);
    }
}

Giải thích:

  • reservedForOutput là phần chừa cho câu trả lời. Giá trị này nên khớp với max_tokens bạn gửi lên API.
  • Vòng lặp đi ngược từ cuối danh sách, vì tin nhắn gần nhất thường quan trọng nhất với câu hỏi hiện tại.
  • Gặp tin nhắn không còn vừa thì dừng hẳn bằng break, không bỏ qua để lấy tin cũ hơn. Nếu bỏ qua, hội thoại sẽ bị đứt đoạn ở giữa và model dễ hiểu sai ngữ cảnh.
  • addFirst giữ đúng thứ tự thời gian trong kết quả trả về.

Trong thực tế, bạn có thể kết hợp thêm hai kỹ thuật: tóm tắt các tin nhắn cũ thành một đoạn ngắn, hoặc dùng RAG để chỉ đưa vào những đoạn tài liệu liên quan tới câu hỏi thay vì cả tài liệu.

5. Sampling

5.1. Từ logit đến xác suất: softmax và temperature

Giả sử model cần điền tiếp câu “Thủ đô của Việt Nam là…” và trả về bốn ứng viên với logit như sau: Hà Nội = 4.0, Huế = 2.5, Đà Nẵng = 2.0, Sài Gòn = 1.0.

Để đổi logit thành xác suất, model dùng hàm softmax. Trước khi tính softmax, logit được chia cho temperature (T):

  • T < 1 làm phân phối “nhọn” hơn. Token có điểm cao càng dễ được chọn.
  • T > 1 làm phân phối “phẳng” hơn. Token có điểm thấp có nhiều cơ hội hơn.

Lớp sau cài softmax, temperature, top-k, top-p và hàm chọn 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 phải > 0, dùng greedy() cho 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; // phòng sai số làm tròn
    }

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

Giải thích các phần chính:

  • Trong softmax, mỗi logit được trừ đi giá trị lớn nhất trước khi gọi Math.exp. Kết quả toán học không đổi, nhưng tránh được tràn số khi logit lớn.
  • topK và topP đều chuẩn hóa lại xác suất để tổng bằng 1 sau khi loại bớt ứng viên.
  • sample lấy một số ngẫu nhiên trong khoảng [0, 1) rồi cộng dồn xác suất. Ứng viên làm tổng cộng dồn vượt qua số đó sẽ được chọn.

Thử in xác suất với ba mức 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));
}

Kết quả (đã làm tròn):

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, khoảng 1 trên 9 lần model sẽ trả lời “Sài Gòn”. Với câu hỏi dữ kiện, đó là câu trả lời sai. Ví dụ này cho thấy vì sao temperature cao dễ dẫn tới “bịa”.

Temperature = 0 thường được hiểu là greedy decoding: luôn chọn token có logit cao nhất, tương ứng hàm greedy().

5.2. Top-k và top-p

Hai kỹ thuật này cắt bỏ phần “đuôi dài” gồm các token xác suất thấp trước khi chọn:

  • Top-k: chỉ giữ k token có xác suất cao nhất. Với k = 2, chỉ còn Hà Nội và Huế.
  • Top-p (nucleus sampling): giữ nhóm token nhỏ nhất có tổng xác suất ≥ p. Với T = 1.0 và p = 0.9, tổng cộng dồn là 0.710, rồi 0.868, rồi 0.964. Vậy top-p giữ lại 3 token và loại Sài Gòn.

Top-p linh hoạt hơn top-k. Khi model rất chắc chắn, top-p chỉ giữ vài token. Khi model phân vân, nó giữ nhiều token hơn. Top-k thì luôn giữ đúng k token, bất kể phân phối trông như thế nào.

Lưu ý: Nhiều nhà cung cấp khuyến nghị chỉ chỉnh một trong hai tham số temperature hoặc top_p, không chỉnh cả hai cùng lúc. Lý do là tác động của chúng chồng lên nhau và rất khó dự đoán kết quả.

5.3. Kiểm thử hành vi sampling

Dependency cho test, với Spring Boot 3.x thì đã có sẵn trong 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");
    }
}

Test cuối cùng nêu một ý quan trọng: cùng seed thì cho cùng chuỗi kết quả. Một số API có tham số seed, nhưng hầu hết chỉ cam kết “cố gắng tái lập” (best effort). Tính toán song song trên GPU và việc gom nhiều request vào cùng một batch có thể làm logit lệch đi một chút. Vì vậy ngay cả khi temperature = 0, bạn cũng không nên viết test so khớp chính xác từng chữ trong câu trả lời của LLM thật.

6. Chọn tham số trong thực tế

Bài toán Gợi ý
Trích xuất dữ liệu, phân loại, sinh JSON temperature 0–0.2
Trả lời câu hỏi, sinh code temperature 0.2–0.5
Viết sáng tạo, brainstorm temperature 0.7–1.0 hoặc top_p 0.9–0.95

Một số lỗi hay gặp:

  • Đặt max_tokens quá thấp: JSON bị cắt giữa chừng và không parse được. Bạn hãy kiểm tra trường lý do dừng trong response (thường là finish_reason hoặc stop_reason) trước khi xử lý kết quả.
  • Không đếm token trước khi gửi: request bị từ chối khi lịch sử chat dài dần theo thời gian.
  • Dùng temperature cao cho tác vụ cần chính xác: model “sáng tạo” ra dữ kiện không có thật.
  • Coi temperature = 0 là đảm bảo đúng: greedy chỉ chọn token có xác suất cao nhất. Nếu model hiểu sai ngay từ đầu, nó sẽ sai một cách nhất quán.

7. Kết luận

Bài viết đã đi qua ba khái niệm nền tảng của LLM. Token là đơn vị model đọc và cũng là đơn vị tính tiền. Tiếng Việt thường tốn nhiều token hơn tiếng Anh cho cùng một nội dung. Context window giới hạn tổng số token của đầu vào và đầu ra trong một lượt gọi, nên bạn cần chủ động đếm và cắt lịch sử. Sampling quyết định cách chọn token tiếp theo từ phân phối xác suất. temperature thay đổi độ “nhọn” của phân phối, còn top-k và top-p cắt bỏ phần đuôi xác suất thấp.

Khi đã nắm được vòng lặp “dự đoán token, chọn token, nối vào đầu vào”, bạn có thể giải thích hầu hết các hành vi của LLM mà mình gặp khi làm việc với API, từ câu trả lời bị cắt ngang đến kết quả khác nhau giữa các lần gọi.

llmaijavatokenizationsampling