LLM은 어떻게 작동하는가: token, context window, sampling

1. 개요

대규모 언어 모델(LLM) API를 호출할 때, max_tokens, temperature, top_p 같은 파라미터와 “context length exceeded” 같은 오류를 접하게 됩니다. 이러한 파라미터들은 모델이 어떻게 텍스트를 생성하는지 이해하면 쉬워집니다.

이 글에서는 세 가지 핵심 개념을 설명합니다: token, context window, sampling. 각 개념마다 실행 가능한 Java 21 코드가 제공됩니다. 읽고 나면, API 호출 전에 토큰을 세고, 채팅 이력을 context window에 맞게 자르고, 각 문제에 맞는 temperature나 top_p를 선택할 수 있게 됩니다.

2. 핵심 루프: 다음 token 예측하기

LLM은 한 번에 전체 답변을 작성하지 않습니다. 단순한 루프를 실행합니다:

  1. 입력 token 시퀀스를 받습니다.
  2. 어휘의 모든 token에 대한 logit(원점수)을 계산합니다. 보통 수만 개에서 10만 개가 넘는 token입니다.
  3. logit을 확률로 변환한 다음 token을 선택합니다. 이 선택 단계를 sampling이라고 합니다.
  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을 사용할 수 있습니다. 이 라이브러리는 cl100k_base와 o200k_base 같은 OpenAI의 BPE 인코딩을 구현합니다:

<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() + " 토큰: " + ids);

        int viTokens = encoding.countTokens(vietnamese);
        System.out.printf("%s -> %d 토큰, %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 토큰: [9906, 1917]
Đơn hàng của bạn đã được giao thành công -> ... 토큰, 40 문자

직접 실행하면 베트남어 문장이 단어 수에 비해 훨씬 많은 token을 소비하는 것을 볼 수 있습니다. 이는 비용을 추정할 때 고려해야 할 사항입니다. 제공자는 token 기준으로 요금을 청구하기 때문입니다.

참고: 각 모델 계열은 자신만의 tokenizer를 사용합니다. Claude, Gemini, Llama, GPT는 token을 같은 방식으로 나누지 않습니다. JTokkit은 OpenAI의 인코딩에만 정확합니다. 다른 모델의 경우 제공자의 tokenizer나 token 계산 endpoint를 사용하거나, JTokkit의 데이터를 추정으로 보고 안전한 여유를 두세요.

4. Context window

4.1. Context window에는 무엇이 있는가

Context window는 모델이 한 번의 호출에서 처리할 수 있는 최대 token 수입니다. 이 숫자는 입력과 출력을 모두 포함합니다:

시스템 프롬프트 + 대화 이력 + 첨부 문서 + 질문 + 답변 ≤ context window

세 가지 일반적인 오해가 있습니다:

  • “모델이 이전 대화를 기억한다.” 틀렸습니다. API는 호출 간에 상태를 유지하지 않습니다. 채팅 애플리케이션은 매번 전체 이력을 다시 전송해야 하므로 각 후속 호출마다 더 많은 token을 사용합니다.
  • “Context가 길수록 좋다.” 반드시 그렇지는 않습니다. 비용과 지연 시간이 입력 token 수에 따라 증가합니다. 모델은 매우 긴 context의 중간에 있는 정보를 놓칠 수도 있습니다. 이것을 ‘lost in the middle’ 현상이라고 부릅니다.
  • “입력이 context window에 맞으면 충분하다.” 충분하지 않습니다. 출력을 위해 공간을 남겨두어야 합니다. 입력이 거의 전부를 차지하면 답변이 잘립니다.

4.2. 채팅 이력을 token 예산에 맞게 자르기

간단하고 일반적인 전략은 다음과 같습니다: 항상 시스템 프롬프트를 유지한 다음, 가장 최근부터 가장 오래된 것까지 메시지를 가져옵니다. 예산이 모두 소진될 때까지.

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 {

    // 각 메시지는 role과 구분자 문자로 추가 token을 소비합니다
    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("시스템 프롬프트가 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. Sampling

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에서 모델은 약 9번 중 1번 “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): 누적 확률이 ≥ p인 가장 작은 token 그룹을 유지합니다. 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. Sampling 동작 테스트하기

테스트용 종속성, 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");
    }
}

마지막 테스트는 중요한 점을 지적합니다: 같은 seed는 같은 결과 시퀀스를 제공합니다. 일부 API는 seed 파라미터가 있지만, 대부분은 ‘최선의 노력’ 재현만 약속합니다. GPU에서의 병렬 계산과 여러 요청을 하나의 배치로 묶는 것이 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을 세고 이력을 잘라야 합니다. Sampling은 확률 분포에서 다음 token을 선택하는 방법을 결정합니다. temperature는 분포의 ’뾰족함’을 변경하고, top-k와 top-p는 낮은 확률의 꼬리 부분을 제거합니다.

‘token 예측, token 선택, 입력에 연결’ 루프를 이해하면 API를 사용할 때 마주치는 대부분의 LLM 동작을 설명할 수 있습니다. 응답이 잘리는 것부터 호출마다 다른 결과가 나오는 것까지 말입니다.

llmaijavatokenizationsampling