web-llmは、サーバーを使わずブラウザ内でLLM(大規模言語モデル)を動かすためのライブラリです。モデルの重みファイルやトークナイザ(文章を数値IDの列に変換する仕組み)をブラウザのキャッシュに保存・削除・読込するコードがsrc/cache_util.tsに集まっています。今回はその中の`asyncLoadTokenizer`関数を選びました。「ファイルが2種類ある時、優先したい方を試し、無ければ次善の策にフォールバックしてユーザーに警告を出す」という、実務で毎日使うパターンがコンパクトに詰まっているためです。

コード

/**
 *
 * @param baseUrl The link to which we can find tokenizer files, usually is a `ModelRecord.model`.
 * @param config A ChatConfig, usually loaded from `mlc-chat-config.json` in `baseUrl`.
 * @param appConfig An AppConfig, usually `webllm.prebuiltAppConfig` if not defined by user.
 * @param logger Logging function, console.log by default.
 * @param integrity Optional integrity configuration for verifying tokenizer files.
 * @returns
 */
export async function asyncLoadTokenizer(
  baseUrl: string,
  config: ChatConfig,
  appConfig: AppConfig,
  logger: (msg: string) => void = console.log,
  integrity?: ModelIntegrity,
): Promise<Tokenizer> {
  const modelCache = createScopedArtifactCache("webllm/model", appConfig);

  if (config.tokenizer_files.includes("tokenizer.json")) {
    const url = new URL("tokenizer.json", baseUrl).href;
    const model = await modelCache.fetchWithCache(url, "arraybuffer");
    await maybeVerifyTokenizerIntegrity(
      model,
      "tokenizer.json",
      url,
      integrity,
    );
    return Tokenizer.fromJSON(model);
  } else if (config.tokenizer_files.includes("tokenizer.model")) {
    logger(
      "Using `tokenizer.model` since we cannot locate `tokenizer.json`.\n" +
        "It is recommended to use `tokenizer.json` to ensure all token mappings are included, " +
        "since currently, files like `added_tokens.json`, `tokenizer_config.json` are ignored.\n" +
        "Consider converting `tokenizer.model` to `tokenizer.json` by compiling the model " +
        "with MLC again, or see if MLC's huggingface provides this file.",
    );
    const url = new URL("tokenizer.model", baseUrl).href;
    const model = await modelCache.fetchWithCache(url, "arraybuffer");
    await maybeVerifyTokenizerIntegrity(
      model,
      "tokenizer.model",
      url,
      integrity,
    );
    return Tokenizer.fromSentencePiece(model);
  }
  throw new UnsupportedTokenizerFilesError(config.tokenizer_files);
}

引用はリポジトリの実物と機械で照合しています。「// …略…」は省略した行です。

上から順に読む

/**
 *
 * @param baseUrl The link to which we can find tokenizer files...

`/** ... */`はJSDocというコメント記法です。関数の直前に書くと、エディタが関数名にカーソルを合わせたときに説明をポップアップ表示してくれます。`@param 引数名 説明`という行が、各パラメータが何なのかを説明する決まった書き方です。人間向けの説明であり、コードの動作には影響しません。

logger: (msg: string) => void = console.log,

これは「関数を受け取る引数」の書き方です。`(msg: string) => void`という型注釈は、「文字列を1つ受け取り、何も返さない関数」を意味します(`=>`の左が引数、右が戻り値の型、`void`は戻り値なしを表す型)。さらに`= console.log`があるので、呼び出し側がlogger引数を省略した場合は自動的に`console.log`(ブラウザのコンソールに出力する標準関数)が使われます。これがTypeScriptの「デフォルト引数」です。

integrity?: ModelIntegrity,

引数名の直後についた`?`は「オプショナル(省略可能)」という意味です。呼び出し側はこの引数を渡しても渡さなくてもよく、渡さなかった場合の値は`undefined`になります。デフォルト値を持たせるほどではないが、必須にもしたくない引数によく使われる書き方です。

): Promise<Tokenizer> {

`Promise<Tokenizer>`は「いずれTokenizer型の値を返す約束(Promise)」という戻り値の型です。`<...>`はジェネリクスと呼ばれる書き方で、「何のPromiseか」を`<>`の中に指定します。この関数は`async function`として宣言されているので、中で`return Tokenizer.fromJSON(model)`のようにTokenizerを返せば、自動的にPromise<Tokenizer>としてラップされます。

if (config.tokenizer_files.includes("tokenizer.json")) {

`config.tokenizer_files`は文字列の配列(例: ["tokenizer.json", "tokenizer.model"])です。配列の`.includes(値)`メソッドは、その値が配列の中に含まれているかを`true`/`false`で返します。ここでは「tokenizer.jsonというファイルが利用可能なリストに入っているか」を判定しています。

const url = new URL("tokenizer.json", baseUrl).href;

`URL`はJavaScript/ブラウザ標準のクラスで、`new URL(相対パス, 基準URL)`と書くと、基準URLに相対パスを結合した完全なURLオブジェクトを作ってくれます。例えばbaseUrlが`https://example.com/models/llama/`なら、結果は`https://example.com/models/llama/tokenizer.json`になります。`.href`はそのURLオブジェクトから文字列を取り出すプロパティです。パス結合を自分で文字列連結せず、専用クラスに任せているのがポイントです。

const model = await modelCache.fetchWithCache(url, "arraybuffer");

`await`は「この右側のPromiseが完了するまで、この行で処理を一時停止して待つ」という意味のキーワードです。async関数の中でしか使えません。`fetchWithCache`はURLからファイルをダウンロードしつつキャッシュも扱ってくれる関数で、`"arraybuffer"`はダウンロード結果をバイナリデータ(ArrayBuffer)として受け取りたいという指定です。

await maybeVerifyTokenizerIntegrity(
      model,
      "tokenizer.json",
      url,
      integrity,
    );

関数呼び出しの引数が多いとき、TypeScript/JavaScriptでは1つの引数ごとに改行してカンマで区切ることができます(動作は1行で書いた場合と全く同じ)。ここでは`maybeVerifyTokenizerIntegrity`という別の関数に「今取得したデータ、ファイル名、URL、整合性チェック用の情報」を渡し、ダウンロードしたファイルが改ざんされていないか検証しています。

} else if (config.tokenizer_files.includes("tokenizer.model")) {

1つ目の`if`が偽だった場合の分岐です。ここでは代わりに`tokenizer.model`という古い形式のファイルを探しています。

"Using `tokenizer.model` since we cannot locate `tokenizer.json`.\n" +
        "It is recommended to use `tokenizer.json` to ensure all token mappings are included, " +

文字列の途中にある`+`は文字列同士の連結演算子で、長い1つのメッセージを複数行に分けて書くためのものです。`\n`は改行文字を表す特殊記法です。そしてこの文字列を`logger(...)`に渡すことで、「本来はtokenizer.jsonを使ってほしいが、無かったので古い形式にフォールバックする」という警告をユーザーに見せています。黙って代替処理をするのではなく、なぜそうなったかを伝えているのが実務的な設計判断です。

throw new UnsupportedTokenizerFilesError(config.tokenizer_files);

`if`にも`else if`にも一致しなかった、つまりtokenizer.jsonもtokenizer.modelも無い場合にここに到達します。`throw new クラス名(...)`は、指定したエラーオブジェクトを投げて処理を中断する構文です。`UnsupportedTokenizerFilesError`はこのプロジェクト独自に定義されたErrorのサブクラスで、`Error`をそのまま投げるより「何のエラーか」が名前だけで分かり、呼び出し元でエラーの種類ごとに対応を分けやすくなります。

← 2026.09.05 の号を読む