🙆‍♀️

OpenAI API(Node.js)で トークン数の送信前チェックについて検証(1)

2023/08/20に公開

はじめに

プロンプトの長さにはmodel毎に制限があります。
ユーザーからの入力をOpenAI APIに送る際に、実際に送ってトークン数に関するエラーが出たら対処する実装もあるかとは思いますが、送信前にチェックできればAPIのコストが削減でき、ユーザーへのレスポンスも改善するかと思います。

あくまで実際に作るサービス次第にはなりますが、選択肢の1つとしてトークン数の送信前チェックについて調査しました。

前提

実装はNode.jsになります。
実装に使用するmodelはgpt-3.5-turbo-0613を想定しています。

model毎の上限

リンク先の公式資料から抜粋・引用します。

GPT-4
GPT-3.5

MODEL MAX TOKENS
gpt-4-0613 8,192
gpt-4-32k-0613 32,768
gpt-3.5-turbo-0613
gpt-3.5-turbo-16k-0613

Embedding models

MODEL GENERATION TOKENIZER MAX INPUT TOKENS
V2 cl100k_base 8191
V1 GPT-2/GPT-3 2046

APIレスポンスに記載されるトークン数

APIのレスポンスから(つまりリクエスト後)
以下のようにトークン数を取得することができます。

const response = await openai.createChatCompletion({
  model: "gpt-3.5-turbo-0613",
  messages: messages,
});
console.log(response.data.usage);

実行結果例

{ prompt_tokens: 24, completion_tokens: 252, total_tokens: 276 }

今回はリクエスト前の段階でトークン数を算出し、各モデルのトークン上限を超えないように制御することが目標です。

OpenAI Tokenizer

まずはOpenAIが提供しているTokenizerを使ってAPI(gpt-3.5-turbo-0613)と比較してみたのですが、APIの結果と大きな差が出てしまいました。
エンコーディングが異なるのが原因のようです。

てっきり最新のmodelと同じエンコーディングかと思い込んでいたので、気がつくのに時間がかかってしまいました。。。

エンコーディング

今回主にするmodelはgpt-3.5-turbo-0613、今後利用予定があるのはgpt-4系ですが、いずれもエンコーディングはcl100k_baseです。

詳細は、参考にさせていただいた以下のサイトを参考にしてください。
OpenAI 言語モデルごとのエンコーディング一覧

そのため、cl100k_baseでトークン化し、それをカウントする必要があります。

例のごとくLangChain(JS)を調べる

トークン化のためにパッケージを探したのですが、公式で直接提供しているものはないようです。
それ以外でいくつか検索してみたのですが、評価が難しく、LangChain(JS)と同じものを選ぶことにしました。

LangChainと揃えるメリットは、LangChainで採用されている実績と、関連した情報が得やすいことです。
(個別のパッケージのチェックは厳しいですが、LangChain経由であればチェックが楽になります。)

次にLangChainでの用例を見てみます。
手始めに package.jsonを見たところjs-tiktokenと言うそれらしき名前のパッケージを見つけたので、後はimportしている処理を検索・参照します。

例えば下記
calculateMaxTokens() langchain/src/base_language/count_tokens.ts
では

  • model名からエンコーディングを選択
  • プロンプトをエンコードし、長さを計算
  • modelの上限トークン数から、プロンプトのトークン数を減算した値を返す
  • エンコーディングに失敗した際は、プロンプトの長さの1/4をトークン数として用いる
    といった処理が実装されています。

js-tiktokenのコードはOpenAIのtiktokenからフォークされた
tiktokenのリポジトリ内の
js-tiktoken
にあります。

js-tiktokenはJSで使える複数の実装の1つで、どういったものかは以下の記述が参考になります。

This repository contains the following packages:

tiktoken (formally hosted at @dqbd/tiktoken): WASM bindings for the original Python library, providing full 1-to-1 feature parity.
js-tiktoken: Pure JavaScript port of the original library with the core functionality, suitable for environments where WASM is not well supported or not desired (such as edge runtimes).

実装例としては
example
があり、dynamicとsimpleの2パターン用意されています。

dynamicは実行時に直接エンコードに使用するファイルをfetchする例で(cacheはする)、simpleはinstall時に取得したファイルを用いる実装例です。

LangChainは前者を使っていますが、私は今回後者を使うことにします。
トークン数の計算という限定した用途なため、例えばAPIの値と、事前チェックの差分が大きくなった場合は、手動でパッケージorファイル更新で対応して様子を見たいと思います。
(あまりに頻繁に更新が必要な場合はdynamicに切り替えます)

実装

すでに書いたようにsimple版で以下のように実装しました。

const messages = [
  {
    role: "user",
    content: content, // プロンプト本文
  },
];

const enc = getEncoding("cl100k_base");
const after = enc.encode(messages[0].content);
console.log(after.length);

検証1

通常のプロンプト、メッセージのみで検証します。

以下の夏目漱石の文章、「現代日本の開化 ――明治四十四年八月和歌山において述――」を要約してください。

上記のプロンプトに下記のテキストの一部を添付して、4096トークン前後になるように調整しながら検証しました。

現代日本の開化――明治四十四年八月和歌山において述――夏目漱石

1. js-tiktokenで4089、API結果4096

{ prompt_tokens: 4096, completion_tokens: 1, total_tokens: 4097 }

API側のプロンプトトークン数は4096丁度だが、1トークンだけ回答を出力し停止。一応正常終了。
差は7トークン。

[
  {
    index: 0,
    message: { role: 'assistant', content: '今' },
    finish_reason: 'length'
  }
]

2. [再]js-tiktokenで4089、API結果4096

少し文章を書き換えてAPI側が4096になるように実行。

プロンプト冒頭の

以下の夏目漱石

次の夏目漱石

と変えています。

{ prompt_tokens: 4096, completion_tokens: 0, total_tokens: 4096 }

プロンプトは4096丁度で、回答は空で正常終了。
差は7トークン。

上限を超えるトークンを返しうる挙動は記憶にとどめて置いて損はなさそうですが、
送信前チェックを除いて、トークン数で分岐をかけるような処理は現状想定いないので、今回この問題は深追いしないことにします。
(トークン数の計算は端数切り捨てだが、プロンプトと回答を合算した時端数を超えたのか?など色々想像してしまいますが)

[
  {
    index: 0,
    message: { role: 'assistant', content: '' },
    finish_reason: 'length'
  }
]

3. 上記に1文字追加。js-tiktokenで4090、API結果

明確に超えたのでエラー(例外)になりました。
差は7トークン。

error: {
  message: "This model's maximum context length is 4097 tokens. However, your messages resulted in 4097 tokens. Please reduce the length of the messages.",
  type: 'invalid_request_error',
  param: 'messages',
  code: 'context_length_exceeded'
}

4. 回答用トークンに余裕を持たせて実行

js-tiktokenで2328、API側が2335。差は7トークン。

{ prompt_tokens: 2335, completion_tokens: 283, total_tokens: 2618 }

余裕を持たせると最後まで回答を出力して正常終了。

[
  {
    index: 0,
    message: {
      role: 'assistant',
      content: '(略)'
    },
    finish_reason: 'stop'
  }
]

5. 回答用トークンに余裕を持たせて実行(短文パターン)

テスト4の結果で気がついたのですが、文章の長短で、API側で計算するプロンプトの長さと事前チェック(js-tikitoken)との差に変化がありませんでした。
そこで、短いプロンプトで何回か比較してみました。

短文で夏目漱石について質問。
js-tiktokenで 202、 API側は209。

{ prompt_tokens: 209, completion_tokens: 423, total_tokens: 632 }

さらに短く「こんにちは」
js-tiktokenで1、API側は8。

{ prompt_tokens: 8, completion_tokens: 14, total_tokens: 22 }

さらに短くならないか、「あ」のみ。
js-tiktokenで1、API側は8。

{ prompt_tokens: 8, completion_tokens: 61, total_tokens: 69 }

[こんにちは、こんばんは]
js-tiktokenで6、API側は13。

{ prompt_tokens: 13, completion_tokens: 8, total_tokens: 21 }

「こんにちはこんにちは」
js-tiktokenで2、API側は9。

{ prompt_tokens: 9, completion_tokens: 18, total_tokens: 27 }

英語を試してみます。
「A」
js-tiktokenで1、API側が8。

{ prompt_tokens: 8, completion_tokens: 10, total_tokens: 18 }

「AB」
js-tiktokenで1、API側が8。

{ prompt_tokens: 8, completion_tokens: 29, total_tokens: 37 }

「ABC」
js-tiktokenで1、API側が8。

{ prompt_tokens: 8, completion_tokens: 200, total_tokens: 208 }

「DOG」
js-tiktokenで1、API側が8。

{ prompt_tokens: 8, completion_tokens: 122, total_tokens: 130 }

「DOG CAT」
js-tiktokenで2、API側が9。

{ prompt_tokens: 9, completion_tokens: 200, total_tokens: 209 }

「DOG CAT MONKEY」
js-tiktokenで4、API側が11。

{ prompt_tokens: 11, completion_tokens: 177, total_tokens: 188 }

「DOG CAT APE」
js-tiktokenで4、API側が11。

{ prompt_tokens: 11, completion_tokens: 20, total_tokens: 31 }

犬、猫が1トークン増加で、猿は文字列の長さ関係なく2トークンというのは、少し気になりますが。。。
差分は安定して7になっています。

差分について

参考までに以下

const messages = [
  {
    role: "user",
    content: content, // プロンプト本文
  },
];

const enc = getEncoding("cl100k_base");
const after = enc.encode(JSON.stringify(messages[0]));
console.log(after.length);

のように、プロンプトだけでなく、関連パラメタをJSON.stringifyして計算してみました。
具体的な数値は煩雑になるので、掲載しませんが、JSON.stringify(messages[0])で計算すると、API側のトークン数をわずかに上回るものの、値は安定してAPI側の値に近い結果が得られました。(他のパラメタを含むと、その分差分が大きくなる)

ただし、完全一致することはありませんでしたし、プロンプト本文のみをjs-tiktokenで計算した時と違い、差分にばらつき(数トークン程度ですが)が発生していました。(特に与えた文字列に変化がないにもかかわらず)

そのため、プロンプト本文をjs-tiktokenで計算し、固定の補正値を入れるのが一番良さそうです。
ちなみに、LangChainでも、プロンプト本文で計算しています。

まとめ

上記の結果を踏まえ、一旦自分の中では以下の結論を出してみました。

  • トークン数の計算はOpenAI APIを利用しないため料金はかからない。エンコードファイルを静的に利用、もしくはキャッシュ利用できていれば、外部通信も発生しない。そのため都度送信前チェックしても無駄なコストや遅延は発生しない。
  • トークンの上限は回答が途切れるか途切れないか、必要な結果が得られるかという観点で、プロンプトと回答の長さの合算になる。ただし、例外が出るのはプロンプトの長さが超えた時で、回答が収まらない場合は内容が省略される。
    ※ レスポンスのパラメタfinish_reason: 'length'で判定できる。
  • js-tiktokenによる事前チェックとOpenAI API側からのレスポンス確かに差分はあるものの、ほぼ7トークンで安定しているため、固定値で補正すれば良さそう。
  • エンコードファイルの変更や、OpenAI側の仕様変更を想定して、事前チェックと実績値のログ取得し、差分が大きくなったら都度メンテナンスする必要はありそう。

続き

ここまでチェックして、まだ解消してない疑問と、新たに発生した疑問があります。
次回は以下の内容のいずれかについて引き続き調べたいと思います。
(最後のは難易度が高そうなことに加え、得るものが少なそうなのでやらないと思います)

  • Function callingを利用した際のトークン数計算方法
  • 会話履歴を利用した際のトークン数計算方法
  • トーク数が不足し、途中で途切れた回答の続きを取得する方法
  • max_tokensを指定した場合、返信の長さを調整できるか(省略を回避できるか)
  • finish_reason: 'length'の場合、続きを取得する方法
  • プロンプトが上限丁度のケースで、合計トークンが上限を超過するケースがある理由

Discussion