LLMはどのように動作するか: トークン、コンテキストウィンドウとサンプリング

1. 概要

Large Language Model (LLM) のAPIを呼び出すとき、max_tokens、temperature、top_p などのパラメータや「context length exceeded」というようなエラーに遭遇します。これらのパラメータは、モデルがテキストを生成する方法を理解すれば、容易に理解できます。

この記事では、3つの基本概念:トークン、コンテキストウィンドウ、サンプリングについて説明します。各概念について、実行可能なJava 21コードが付属しています。読んだ後、APIを呼び出す前にトークンをカウントでき、チャット履歴をコンテキストウィンドウに合わせてカット、各問題に適切なtemperatureまたはtop_pを選択できるようになります。

2. 核となるループ: 次のトークンの予測

LLMは1回で答え全体を書き出しません。それは単純なループを実行します:

  1. 入力トークンのシーケンスを受け取ります。
  2. 語彙内のすべてのトークン(通常は数万から100,000以上)のlogit(スコア)を計算します。
  3. logitを確率に変換して、トークンを選択します。この選択ステップは、サンプリングと呼ばれます。
  4. 選択したトークンを入力に追加して、ステップ1に戻ります。

ループは、モデルが終了トークンを生成するか、max_tokensの制限に達したときに停止します。このループから、3つの意味が生まれます:

  • モデルはテキストをトークンとして見えます。文字や単語ではなく。
  • モデルが1回の呼び出しで「覚えている」すべては入力に含まれている必要があります。この入力はコンテキストウィンドウによって制限されます。
  • 回答は、多くの制御されたランダム選択の結果です。したがって、同じプロンプトでも異なる結果を出す可能性があります。

3. トークン

3.1. トークンとは何か

トークナイザーはテキストを小さな部分に分割し、各部分を整数にマップします。ほとんどの現在のモデルは**Byte Pair Encoding (BPE)**の変形を使用しています。トレーニングデータに頻繁に出現するキャラクターシーケンスは1つのトークンに結合されます。稀なシーケンスは、複数の小さなトークンに分割され、時にはバイト単位まで分割されます。

したがって、helloのような一般的な英語の単語は通常1つのトークンしかかかりません。一方、ベトナム語の音声記号は同じ量のコンテンツに対してより多くのトークンがかかる傾向があります。なぜなら、「ượ」や「ững」のような組み合わせはトークナイザーのトレーニングデータにほとんど出現しないからです。

3.2. JTokkitでトークンをカウント

Javaでトークンをカウントするには、JTokkitを使用できます。このライブラリは、cl100k_baseやo200k_baseなどのOpenAIのBPE encodingを実装しています:

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

次のコードは、英文とベトナム語文のトークン数を比較します:

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

説明:

  • getEncoding(EncodingType.CL100K_BASE)は、初期のGPT-4モデルが使用する語彙表を取得します。
  • encode(...)はトークンIDのリストを返します。countTokens(...)は数だけを返すので、IDが不要な場合はより高速です。
  • "Hello world"の場合、結果は2つのトークン:Helloと worldです。スペースは後続のトークンに結合されます。

結果は次のような形式です:

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

自分で実行して、ベトナム語の文が単語数と比較してはるかに多くのトークンを使用することを確認してください。これはコスト見積もりを計算する際に考慮する必要があります。提供者はトークンごとに課金します。

注意: モデルファミリーごとに異なるトークナイザーを使用します。Claude、Gemini、Llama、GPTはトークンを同じ方法で分割しません。JTokkitはOpenAIのencodingに対してのみ正確です。他のモデルの場合、プロバイダのトークナイザーまたはトークンカウント用エンドポイントを使用するか、JTokkitからのデータを推定値と見なして安全な余裕を持たせてください。

4. コンテキストウィンドウ

4.1. コンテキストウィンドウに含まれるもの

コンテキストウィンドウは、1回の呼び出しでモデルが処理できる最大トークン数です。この数は入力と出力の両方を計算します:

システムプロンプト + チャット履歴 + 添付ドキュメント + 質問 + 回答 ≤ コンテキストウィンドウ

よくある3つの誤解があります:

  • 「モデルは以前の会話を覚えている」 間違い。APIは呼び出し間で状態を保持しません。チャットアプリケーションは毎回履歴全体を再送信する必要があります。したがって、後の呼び出しほど前の呼び出しよりも多くのトークンを消費します。
  • 「コンテキストが長いほど良い」 そうとは限りません。コストと遅延は入力トークン数に応じて増加します。モデルは、非常に長いコンテキストの真ん中にある情報を見落とす傾向があり、これは通常「lost in the middle」と呼ばれる現象です。
  • 「入力がコンテキストウィンドウに収まれば十分」 そうではありません。出力用にスペースを確保する必要があります。入力がほとんど全てを占めている場合、回答が途中で切れてしまいます。

4.2. チャット履歴をトークン予算に合わせてカット

シンプルで一般的な戦略は:常にシステムプロンプトを保持し、次に最新から最古へメッセージを取得して、予算が尽きるまで続けます。

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 {

    // 各メッセージはロールと区切り文字に追加トークンを消費します
    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("システムプロンプトがコンテキストウィンドウに対して長すぎます");
        }

        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は、返された結果に正しい時系列順序を保ちます。

実際には、追加の2つのテクニックを組み合わせることができます:古いメッセージを短いセクションに要約するか、RAGを使用して、ドキュメント全体ではなく質問に関連するドキュメントセクションのみを含めることができます。

5. サンプリング

5.1. logitから確率へ: softmaxとtemperature

モデルが「ベトナムの首都は…」という文を完成させる必要があり、次のlogitを持つ4つの候補を返すと仮定します: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では、分布を「シャープ」にします。高いスコアのトークンが選択されやすくなります。
  • T > 1では、分布を「フラット」にします。低いスコアのトークンがより多くの機会を得ます。

以下は、softmax、temperature、top-k、top-p、およびトークン選択関数を実装するクラスです:

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である必要があります。T = 0の場合はgreedy()を使用します");
        }
        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では、Math.expを呼び出す前に、各logitから最大値を引きます。数学的な結果は変わりませんが、logitが大きい場合のオーバーフローを防ぎます。
  • topKとtopPは、候補を除外した後、確率を正規化して合計が1になるようにします。
  • sampleは[0、1)の範囲内のランダムな数を取得し、確率を累積します。その数を超える累積合計を作成する候補が選択されます。

3つの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を持つトークンを選択します。これはgreedy()関数に対応します。

5.2. Top-kとtop-p

これら2つのテクニックは、選択前に低確率トークンの「長い尾」部分を切り取ります:

  • Top-k:最高確率のkトークンのみを保持します。k = 2の場合、Hà NộiとHuếのみが残ります。
  • Top-p (nucleus sampling):合計確率≥pの最小トークングループを保持します。T = 1.0およびp = 0.9の場合、累積合計は0.710、次に0.868、次に0.964です。したがって、top-pは3つのトークンを保持し、Sài Gònを除外します。

Top-pはtop-kより柔軟です。モデルが非常に確実な場合、top-pはほんの数トークンのみを保持します。モデルが不確かな場合、より多くのトークンを保持します。Top-kは、分布がどのように見えるかに関わらず、常に正確にkトークンを保持します。

注意: 多くのプロバイダは、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パラメータがありますが、ほとんどは「ベストエフォート」の再現性のみを保証しています。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が途中で切れてパースできません。結果を処理する前に、response内のストップ理由フィールド(通常finish_reasonまたはstop_reason)を確認してください。
  • 送信前にトークンをカウントしない:チャット履歴が時間とともに長くなると、リクエストが拒否されます。
  • 正確性が必要なタスクに高いtemperatureを使用:モデルが実在しないデータを「作成」します。
  • temperature = 0を正確性の保証と考える:貪欲デコーディングは最も確率の高いトークンのみを選択します。モデルが最初から誤解すると、一貫して間違ったままになります。

7. 結論

この記事では、LLMの3つの基本概念を検討しました。トークンはモデルが読むユニットであり、課金単位でもあります。ベトナム語は通常、同じコンテンツの英語より多くのトークンを消費します。コンテキストウィンドウは、1回の呼び出しでの入力と出力の総トークン数を制限するため、主動的にカウントして履歴をカットする必要があります。サンプリングは、確率分布から次のトークンを選択する方法を決定します。temperatureは分布の「シャープさ」を変更し、top-kとtop-pは低確率の「尾」部分を切り取ります。

「トークンを予測、トークンを選択、入力に追加」というループを理解すれば、APIを操作するときに遭遇するLLMのほとんどの動作を説明できます。回答が途中で切れるものから、呼び出し間で異なる結果が得られるものまで。

llmaijavaトークン化サンプリング