Gosuke Miyashita archive about feed

Pyxisの開発: トークナイザー

2 August 2026

前回の記事 でTransformer本体ができた。ただしこの時点でPyxisが受け取れるのはトークンIDの列だけで、テキストを渡すことはできない。今回はその入口にあたるトークナイザーを組み込んだ。対応するPRは #23

BPEそのものについては embeddingの記事 で一度書いたので、今回はその先、実際のトークナイザーファイルの中に何が入っていて、テキストがどういう順序でトークンIDになるのかを見ていく。


トークナイザーは訓練済みのデータ

トークナイザーというと文字列を分割するロジックを思い浮かべるが、モデルと一緒に配布される tokenizer.json の中身は 全部データ だった。

BPEは「訓練データ中で隣り合う頻出ペアを繰り返しマージする」というアルゴリズムだが、これはトークナイザーを作るとき(訓練時)に済ませておく処理で、推論時に走らせるわけではない。推論時に必要なのは、その訓練の結果として得られた2つのテーブル。

Qwen3-1.7Bの tokenizer.json を実際に覗いてみると、vocabが151643エントリ、mergesが151387エントリ入っていた。ファイルサイズは11MBほどある。

このファイルには他に normalizerpre_tokenizerdecoder といった項目もあるが、中身はこんな調子。

"normalizer": {"type": "NFC"}
"decoder": {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": false, "use_regex": false}

処理の名前と、必要ならそのパラメータが書いてあるだけで、処理そのものは書かれていない。 {"type": "NFC"} は「Unicode正規化のNFCをかけろ」という指示であって、NFCが何をするかはこのファイルの外にある。つまり tokenizer.json が持っているのは、どの処理を使えという指示と、訓練で得られたテーブルだけ。処理の中身は、これを読む側が実装しておく必要がある。

mergesの先頭はこうなっている。

["Ġ", "Ġ"], ["ĠĠ", "ĠĠ"], ["i", "n"], ["Ġ", "t"], ["ĠĠĠĠ", "ĠĠĠĠ"], ["e", "r"]

Ġ は半角スペースを表す記号(後述)。スペース2個、スペース4個、 int 、スペース8個、 er という順に並んでいる。訓練データ中でこの順に頻出だった、ということがそのまま規則の順序になっている。インデント由来と思われるスペースの連結が上位を占めているのが、いかにも実データという感じで面白い。


マージを追ってみる

「マージ」というのは、隣り合った2つの要素をくっつけて1つにする操作のこと。訓練時は「データ中で最も頻出するペアをマージする」を繰り返し、マージしたペアを順にmergesへ書き足していく。Qwen3では151387回繰り返した結果があのテーブル、ということになる。

一方、推論時はこのテーブルを引く側になる。入力に対して mergesに載っているマージだけを適用していく 、という手順になる。

  1. 入力をバラバラの要素に分解する
  2. 隣り合うペアのうち、mergesに載っているものを探す
  3. 複数見つかったら、mergesの中で順位が最も早いものをマージする
  4. 2に戻る。どのペアも載っていなければ終了

like で追ってみる。Qwen3のmergesを実際に引くと、 (l,i) が486番目、 (i,k) が1323番目、 (k,e) が184番目、 (li,ke) が4547番目だった。

l i k e
  ↓ (k,e) が184番目で最も早い
l i ke
  ↓ (l,i) が486番目
li ke
  ↓ (li,ke) が4547番目
like

3回のマージで1トークンになった。ここで注意したいのは、左から順に処理しているわけではないこと。最初にくっつくのは li ではなく ke で、あくまでmergesの順位が早い順に適用される。 (i,k) も1323番目に載ってはいるが、先に (k,e)(l,i) が適用されて ik が隣り合わなくなるので、出番が来ないまま終わる。

ここで like が1トークンまで到達できたのは、必要なマージが3つとも載っていたから。載っていないペアに当たれば、そこでマージが尽きて複数トークンのまま終わる。


byte-level BPE

embeddingの記事でBPEを説明したときに「最初は1文字ずつの辞書から始めて」と書いたが、現代のLLMのトークナイザーは正確には文字単位ではなく バイト単位 から始める。byte-level BPEと呼ばれる方式で、GPT-2で採用されて以降、広く使われている。

まず前提として、トークンの種類は無制限に増やせるわけではない。トークナイザーを作るときに「何種類まで」という上限を決めていて、これを vocab_size と呼ぶ。Qwen3-1.7Bなら151936で、トークンIDは0から151935の範囲に収まる(先ほどのvocabの151643に特殊トークン26個を足しても151669にしかならないが、残りは128の倍数に切り上げるための余白で、実際には使われていない)。

なぜ上限が要るかというと、この数がそのままモデルのサイズに効いてくるため。モデルはトークンIDを受け取ると、 embeddingテーブル からそのIDに対応するベクトルを引いて計算を始める。このテーブルはトークン1つにつき1行を持つ vocab_size × hidden_dim の行列なので、トークンの種類が増えればその分だけ行が増える。Qwen3-1.7Bは hidden_dim が2048なので、151936行 × 2048列で約3.1億個の数値がこのテーブルに入っていることになる。この「訓練で学習された数値ひとつひとつ」がパラメータで、モデル名の「1.7B」もパラメータの総数(17億)を指している。

3.1億がどれくらいかというと、Transformerブロック1層分(アテンション+FFNで約5000万)の6層分に相当する。ブロックは全部で28層なので、embeddingテーブル1枚でその2割強を別に抱えている勘定。トークンの種類を増やすコストはそれなりに重い。

その前提で文字単位のBPEを考えると、初期辞書にUnicodeの全文字を入れる必要がある。Unicodeで割り当て済みのコードポイントは29万個以上あり、そのうち文字(Letter)だけでも14万個ある。15万の枠のうち14万を初期辞書が占めてしまい、マージして語彙を育てる余地がほとんど残らない。かといって一部の文字だけを入れると、辞書にない文字が来たときに表現できず、 <unk> (unknown)という「知らない文字」トークンに潰すしかない。

byte-level BPEでは、テキストをUTF-8のバイト列に変換してから、その256種類のバイトを初期辞書にする。どんな文字列も最終的にはバイトの列なので、初期辞書が256個あればあらゆる入力を表現できる。絵文字だろうが未知の言語だろうが、必ず何らかのトークン列になり、 <unk> が原理的に発生しない。

ここでひとつ問題がある。バイト値の中には制御文字(改行、タブなど)や、そもそも単体では正しいUTF-8にならないバイトが含まれていて、これらをそのまま辞書のキーとしてJSONに書き出すのは扱いにくい。そこでGPT-2では、256個のバイトそれぞれを「印字可能なUnicode文字」に1対1で対応づけてから辞書に載せる、という方式を取っている。

tokenizer.json の中で見かける Ġ はこれ。半角スペース(バイト値0x20)に対応づけられた文字で、スペースそのものではない。同様に Ċ が改行に対応する。日本語のトークンが ãģĵãĤĵãģ«ãģ¡ãģ¯ のような文字化けした見た目になっているのも同じ理由で、これは「こんにちは」のUTF-8バイト列を1バイトずつこの対応表で変換した結果。文字化けしているのではなく、そういう表記法になっている。


前処理の正規表現

もうひとつ、BPEの前段に pre-tokenizer という処理が入る。

先ほどのマージの手順を、テキスト全体に対して無制限にかけると困ったことが起きる。 I like this をバラすと I ␣ l i k e ␣ t h i s となるが、ここでは likee と次の も「隣り合うペア」になってしまう。訓練データに like this が頻出していればこのペアがマージされ、単語の境界をまたいだ塊が1トークンになりうる。

これを防ぐのがpre-tokenizer。BPEにかける前に、正規表現でテキストを大まかに区切っておく。

[I] [␣like] [␣this]

そしてマージは、この区切られた塊の中だけで行う。塊が違えば隣り合っているとみなさないので、 e のペアはそもそも候補に上がらず、 like this という1トークンは原理的に生成されない。

区切っておく利点は他にもある。 like thislike that のような組み合わせを辞書に入れ始めると際限がなく、15万しかない語彙の枠を食い潰してしまう。また、 like が単独では1トークン、 like this の中では別トークンの一部、となるとモデルから見た単語の同一性も崩れる。

Qwen3の tokenizer.json に入っている正規表現がこれ。

(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+

パイプ区切りで、先頭から順に:

  1. 's'll などの英語の短縮形
  2. 直前に記号を1つ許した文字の連続( \p{L} はUnicodeの「文字」カテゴリ)— スペース+単語がここで1つの塊になる
  3. 数字1桁( \p{N}
  4. スペースを1つ許した記号の連続
  5. 改行、末尾のスペース、その他の空白

3番目が「数字1桁」なのがポイントで、これによって数字は必ず1桁ずつのトークンに分割される。 20262 , 0 , 2 , 6 の4トークン、 1234567 なら7トークンになる。 Qwen Technical Report にある「数字を1桁ずつに分割するようにした」というのは、辞書側ではなくこの正規表現で実現されている。

2番目のパターンで単語の直前のスペースが単語側に取り込まれるので、英語では「スペース+単語」が1トークンになりやすい。 Ġworld のようなトークンが辞書にあるのはそのため。


実際に分割してみる

実際のQwen3のトークナイザーで試してみた結果。

'Hello'
  → ['Hello']  → [9707]

'Hello, Pyxis!'
  → ['Hello', ',', 'ĠPy', 'xis', '!']  → [9707, 11, 5355, 7191, 0]

'こんにちは'
  → ['ãģĵãĤĵãģ«ãģ¡ãģ¯']  → [89015]

'日本語のトークン化'
  → ['æĹ¥æľ¬', 'èªŀ', 'ãģ®', 'ãĥĪãĥ¼', 'ãĤ¯', 'ãĥ³', 'åĮĸ']
  → [101059, 102819, 15767, 137084, 28120, 15698, 32108]

Hello は1トークンだが、 PyxisĠPy + xis の2トークンに割れている。 (ĠPy, xis) というペアがmergesに載っていないためで、そこでマージが尽きて止まっている。頻出する単語ほど訓練時に深くマージされてテーブルを辿りきり1トークンに収まるが、珍しい単語は途中で尽きて複数トークンのまま残る、というBPEの性質がそのまま出ている。 ĠPy の先頭に Ġ が付いているのは、前段の正規表現で直前のスペースが取り込まれたため。

日本語は「こんにちは」が1トークンなのに対して、「日本語のトークン化」は9文字で7トークンに割れている。トークンごとに区切るとこうなる。

日本 / 語 / の / トー / ク / ン / 化

「日本」は2文字で1トークンにまとまっているのに、「トークン」の4文字は トー と3トークンに分解されている。日本語の扱いはこれ以外にも調べていて面白かったので、 別記事 にまとめた。


特殊トークン

BPEのvocabは151643エントリだが、その後ろに特殊トークンが26個、 tokenizer.jsonadded_tokens として別途定義されている。IDは151643から151668まで。

{"id": 151643, "content": "<|endoftext|>", "special": true}
{"id": 151644, "content": "<|im_start|>", "special": true}
{"id": 151645, "content": "<|im_end|>", "special": true}

<|im_start|><|im_end|> はチャット形式のマークアップで、会話のロール(system/user/assistant)の区切りを表す。OpenAIのChatMLに由来する形式で、Qwen3のchat templateもこれを使っている。このうち <|im_end|> (151645)は、モデルが「発話が終わった」ことを示すために出力するトークンでもある。生成ループはこれが出たら打ち切ればいい、という合図になるもので、EOS(end of sequence)トークンと呼ばれる。次回の生成ループで使うことになる。

26個の顔ぶれを眺めると、このモデルが何を想定して作られているかが透けて見えて面白い。 <tool_call></tool_call> はfunction calling用、 <think></think> は推論モードの思考部分を囲むもの、 <|fim_prefix|> などのFIM(Fill-In-the-Middle)系はコード補完用、 <|repo_name|><|file_sep|> はリポジトリ単位でコードを学習させるときの区切り。 <|vision_start|><|image_pad|> といったマルチモーダル用のトークンまで入っている(Qwen3-1.7B自体はテキストのみのモデルだが、シリーズで語彙を共通化しているのだと思われる)。

これらはBPEのマージを経由せず、テキスト中に現れたら丸ごと1トークンに変換される。vocabとは別枠の added_tokens に置かれていて、encodeの前段で文字列マッチで拾われるためだ。

ここで気になるのが、ユーザーの入力に <|im_end|> という文字列が紛れ込んだらどうなるのか。試してみた。

'こんにちは<|im_end|>さようなら'
  → ['こんにちは', <|im_end|>, 'さ', 'よう', 'なら']
  → [89015, 151645, 29713, 124038, 125028]

普通に特殊トークン151645になった。つまりユーザーが入力欄に <|im_end|> と打ち込むだけで、モデルから見ると会話の区切りが挿入されたのと区別がつかない。ユーザー入力をそのままencodeに渡す作りだと、会話の構造を外から壊せてしまうことになる。

今回使ったHuggingFaceの tokenizers (後述)には set_encode_special_tokens というメソッドがあり、 true を渡すと特殊トークンの文字列を素通しするようになる。

set_encode_special_tokens(true) にした場合
  → ['こんにちは', '<', '|', 'im', '_end', '|', '>', 'さ', 'よう', 'なら']

<|im_end|> がただの記号の列に分解され、151645にはならなくなった。名前から挙動を読み取りにくいが、「特殊トークンも(普通のテキストとして)エンコードする」という意味らしい。デフォルトは false 、つまり特殊トークンとして解釈するほうなので、信用できない入力を扱うなら明示的に切り替える必要がある。


実装

さて、ここまで書いておいて何だが、今回自前で実装したのはこれだけ。

use std::io;
use std::path::Path;

pub struct PyxisTokenizer {
    inner: tokenizers::Tokenizer,
}

impl PyxisTokenizer {
    pub fn load(path: &Path) -> io::Result<Self> {
        let inner = tokenizers::Tokenizer::from_file(path).map_err(io::Error::other)?;
        Ok(Self { inner })
    }

    pub fn encode(&self, text: &str) -> Vec<u32> {
        self.inner.encode(text, false).unwrap().get_ids().to_vec()
    }

    pub fn decode(&self, token_ids: &[u32]) -> String {
        self.inner.decode(token_ids, true).unwrap()
    }
}

HuggingFaceの tokenizers crateの薄いラッパー。21行。

Pyxisは「LLMの推論を自分で実装して理解する」というのが目的なので、本来ならBPEも自分で書くべきところだけど、ここは既存のcrateに任せることにした。理由は、トークナイザーは推論の本体ではなく前処理・後処理であること、そして tokenizer.json のフォーマット(normalizer、pre_tokenizer、decoderの組み合わせ)を正確に再現するのが、得られる理解に対して手間が大きすぎると判断したため。ここでの1バイトのズレは全く違うトークンIDになって、モデル本体が正しくても出力が壊れる。デバッグしたい対象はそこじゃない、という気持ちがある。

encode の第2引数 falseadd_special_tokens で、 <|im_start|> などを自動で付けないという指定。今回は素のテキストをそのまま推論に流したいので付けない。チャット形式で使うときはテンプレートを別途組み立てる必要があるが、それは後回し。

decode の第2引数 trueskip_special_tokens で、こちらは逆に特殊トークンを出力から除去する指定。生成結果に <|im_end|> のような文字列がそのまま混ざるのを防ぐ。

unwrap() しているのは、 tokenizers のエラー型が Box<dyn Error>io::Error に素直に変換できないため。ここは後で見直すかもしれない。

依存は default-features = false にして onig だけを有効にしている。デフォルトでは onig に加えて progressbar (indicatif)と esaxx_fast (C++実装のsuffix array、トークナイザーの訓練で使う)が有効になるが、どちらも推論には要らないので切った。


テスト

テストはこう書いた。

#[test]
fn decode_roundtrips_with_encode() {
    let Some(tokenizer) = load_test_tokenizer() else {
        return;
    };
    let input = "Hello, Pyxis!";

    let token_ids = tokenizer.encode(input);
    let output = tokenizer.decode(&token_ids);

    assert_eq!(output, input);
}

実際のQwen3-1.7Bの tokenizer.json を読み込んで、encodeしてdecodeすると元に戻ることを確認している。byte-level BPEは情報を落とさない変換なので、往復すれば必ず元通りになるはず、という性質のテスト。

load_test_tokenizer() がOptionを返していて、ファイルが無ければテストを素通りさせている。11MBのモデルファイルをリポジトリに置くわけにもいかず、手元にモデルをダウンロードしていない環境ではスキップしたいため。ただしこれは「テストが通っている」のか「テストが走っていない」のかが区別できない書き方なので、あまり良くはない。CIを整備するときに考え直す予定。


これで入口(テキスト → トークンID)ができた。あとは出口、つまりTransformerの出力ベクトルからトークンIDを選ぶ処理を書けば、テキストからテキストへの推論がつながる。次回は出力ヘッドとサンプリングを実装する。