Azure OpenAI APIの正しいトークン数の計算方法

はじめに

OpenAIのAPI利用する際、OpenAI内のGPTなどのLLMはトークンと呼ばれる文字列の単位で処理を行い、APIの利用料金は処理したトークン数にもとづいて課金されます。

そのため、OpenAI APIの利用料金を算出するにあたって、トークン数を試算する必要があります。
この記事では、OpenAI APIにおいてトークン数の算出をする方法について紹介します。

OpenAI APIのレスポンスボディからトークン数を算出

OpenAIのAPIをコールすると通常は、レスポンスボディのusageの項目からトークン数を取得することができます。

// OpenAI APIのレスポンスボディの例
{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1677652288,
  "model": "gpt-4o-mini",
  "system_fingerprint": "fp_44709d6fcb",
  "choices": [{
    "index": 0,
    "message": {
      "role": "assistant",
      "content": "\n\nHello there, how may I assist you today?",
    },
    "logprobs": null,
    "finish_reason": "stop"
  }],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 12,
    "total_tokens": 21,
    "completion_tokens_details": {
      "reasoning_tokens": 0
    }
  }
}

usage配下のパラメータはそれぞれ以下の意味を持ちます。

  • prompt_tokens: 入力されたトークン数
  • completion_tokens: 出力されたトークン数
  • total_tokens: 入力と出力の合計トークン数
  • completion_tokens_details: 出力時に生成されたトークンの内訳
    • reasoning_tokens: 推論で使用されたトークンの内訳

OpenAI公式 APIレスポンス

通常はAPIで応答されるtotal_tokensの値をカウントしていけば、使用したトークン数のカウントができるのですが、APIのリクエスト時のオプションでstreamを有効にして、ストリーミング形式で分割してレスポンスを取得するようにすると、APIのレスポンスからトークン数を取得することができません。

OpenAIデベロッパーフォーラム

そのため、ストリーミング時はトークナイザーとよばれる算出ツールを使って、トークン数を自前で算出する必要があります。

トークナイザーを使ったトークン数試算

トークン試算をする場合、OpenAI公式からWEBブラウザ経由で該当の文字列のトークン数を算出できるトークナイザーが提供されています。

OpenAI公式: トークナイザー

ブラウザ経由ではなく、プログラミング言語でトークン算出をする場合、 OpenAIのAPIを算出できるトークナイザーは実装するプログラミング言語ごとに異なり、以下のサイトで確認できます。

OpenAIクックブック: トークン数試算

Pythonの場合は、以下のtiktokenというトークナイザーを使用することができます。

tiktokenは以下のように使用することができます。

pip install tiktoken
import tiktoken
# モデルに応じたエンコーダーを作成
enc = tiktoken.get_encoding("gpt-4")

# トークンに分割
tokens = enc.encode("Hello World!")

# Hello World!という文字列のトークン数を表示
print(len(tokens))

pip: tiktoken

但し、上記のOpenAIのクックブックに記載されている通り、tiktokenでの算出結果は試算値に過ぎず、APIをコールしたときに実際に消費されるトークンと一致しない可能性があるので、注意が必要です。

Note that the exact way that tokens are counted from messages may change from model to model. Consider the counts from the function below an estimate, not a timeless guarantee.

トークン数試算時の計算式

トークン数を試算する場合、ユーザーが入力したテキストとLLMの生成テキスト以外にも、APIのメッセージに含まれるメタ情報もカウントが必要になり、計算式は以下になります。

トークン数 = M * TPM + N * TPN + ΣV + 3

* M = メッセージの総数
* TPM = メッセージ当たりのトークン(モデルによって3または4)
* TPN = `name`キーをもつメッセージ当たりのトークン(モデルによって1または-1)
* N = `name`キーを持つメッセージの数
* V = メッセージの各バリューのトークン数( `content`キー、`role`キーなどに対するバリューのトークン数)
* 3 = メッセージの最後に追加されるトークン数( APIの全ての応答に対して付与される `<|start|>assistant<|message|>` の分、APIコール前の場合は0 )

例えば、モデルがgpt-35-turbo-16k-0613で、会話履歴のメッセージが以下の形式の場合、計算結果は以下のようになります。

// 会話履歴メッセージリスト
[
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "こんにちは", "name": "John"},
    {"role": "assistant", "content": "こんにちは!今日はどのようにお手伝いできますか?"}
]
M = 3 ※配列の中のメッセージが3つなので、3
TPM = 3, TPN = 1 ※gpt-35-turbo-16k-0613の場合は3と1
N = 1 ※nameキーを含むメッセージが1つなので1
ΣV = 7 + 3 + 21 = 31 ※各メッセージのトークンの合計
  "system" + "You are a helpful assistant." → V = 1 + 6 = 7  
  "user" + "こんにちは" + "John" → V = 1 + 1 + 1 = 3
  "assistant" + "こんにちは!今日はどのようにお手伝いできますか?" → V = 1 + 20 = 21 

トークン数 = M * TPM + N * TPN + ΣV + 3 
          = 3 * 3 + 1 * 1 + 31 + 3
          = 44

上記の計算式をPythonで記述すると以下のコードになります。

import tiktoken
import os
from openai import AzureOpenAI

client = AzureOpenAI(
  api_key = os.getenv("AZURE_OPENAI_API_KEY"),  
  api_version = "2024-02-01",
  azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")  # Your Azure OpenAI resource's endpoint value.
)

system_message = {"role": "system", "content": "You are a helpful assistant."}
max_response_tokens = 250
token_limit = 4096
conversation = []
conversation.append(system_message)

def num_tokens_from_messages(messages, model="gpt-3.5-turbo-0613"):
    """Return the number of tokens used by a list of messages."""
    try:
        encoding = tiktoken.encoding_for_model(model)
    except KeyError:
        print("Warning: model not found. Using cl100k_base encoding.")
        encoding = tiktoken.get_encoding("cl100k_base")
    if model in {
        "gpt-3.5-turbo-0613",
        "gpt-3.5-turbo-16k-0613",
        "gpt-4-0314",
        "gpt-4-32k-0314",
        "gpt-4-0613",
        "gpt-4-32k-0613",
        }:
        tokens_per_message = 3
        tokens_per_name = 1
    elif model == "gpt-3.5-turbo-0301":
        tokens_per_message = 4  # every message follows <|start|>{role/name}\n{content}<|end|>\n
        tokens_per_name = -1  # if there's a name, the role is omitted
    elif "gpt-3.5-turbo" in model:
        print("Warning: gpt-3.5-turbo may update over time. Returning num tokens assuming gpt-3.5-turbo-0613.")
        return num_tokens_from_messages(messages, model="gpt-3.5-turbo-0613")
    elif "gpt-4" in model:
        print("Warning: gpt-4 may update over time. Returning num tokens assuming gpt-4-0613.")
        return num_tokens_from_messages(messages, model="gpt-4-0613")
    else:
        raise NotImplementedError(
            f"""num_tokens_from_messages() is not implemented for model {model}."""
        )
    num_tokens = 0
    for message in messages:
        num_tokens += tokens_per_message
        for key, value in message.items():
            num_tokens += len(encoding.encode(value))
            if key == "name":
                num_tokens += tokens_per_name
    num_tokens += 3  # every reply is primed with <|start|>assistant<|message|>
    return num_tokens
while True:
    user_input = input("Q:")      
    conversation.append({"role": "user", "content": user_input})
    conv_history_tokens = num_tokens_from_messages(conversation)

    while conv_history_tokens + max_response_tokens >= token_limit:
        del conversation[1] 
        conv_history_tokens = num_tokens_from_messages(conversation)

    response = client.chat.completions.create(
        model="gpt-35-turbo", # model = "deployment_name".
        messages=conversation,
        temperature=0.7,
        max_tokens=max_response_tokens
    )


    conversation.append({"role": "assistant", "content": response.choices[0].message.content})
    print("\n" + response.choices[0].message.content + "\n")

Azure公式: トークン計算サンプルコード


マルチモーダル利用時のトークン算出

マルチモーダルで画像を入力情報としてOpenAIにリクエストした場合は、画像使用分のトークンが別に発生します。

以下が、画像入力時のトークン数の計算は以下の記事が参考になります。

Zenn: Microsoft社員投稿記事

高解像度モードがオフなのかオンなのかによってトークン数の計算式が変わってきます。

  • low:高解像度モードがオフの場合、1画像あたり85トークン
  • high:高解像度モードがオンの場合、特別な計算式により計算

高解像度モードがオンの場合の計算の流れ

  1. 入力画像は縦横比を取得
  2. 縦横比を維持したまま、2,048 × 2,048 ピクセルの正方形に収まるように拡大縮小
  3. 縦横比を維持したまま、短い辺の長さが 768 ピクセルになるように縮小
  4. 512 ピクセルの正方形のタイルに分割
  5. トークン数 = タイル数 x 170 トークン + 85
    • タイルの数(部分的なタイルは切り上げ)によって最終的なコストが決まる。

高解像度モードのトークン計算例

2,048 x 4,096 ピクセルの画像入力を行う場合、以下のようになります。

  1. 縦横比 = 1:2
  2. 2,048 × 4,096 -> 1,024 × 2,048 ピクセルにリサイズ
    • 2,048 ピクセルの正方形に収まるように拡大縮小
  3. 1,024 × 2,048 -> 768 x 1,536 ピクセルに縮小
    • 短い辺の長さが 768 ピクセルになるように縮小
  4. 512 ピクセルのタイルに分割 -> タイル6枚
  5. トークン数 170 × 6 + 85 = 1,105 トークン

マルチモーダル対応のPythonコード

Microsoftのサンプルコードをもとに画像入力時のトークン数を算出する非公式のコードを作成しました。

pillow
tiktoken
import requests
import tiktoken
from PIL import Image
from math import ceil

class OpenAiTokenizer:

    @staticmethod
    def calc_tokens_from_messages(messages, model="gpt-35-turbo-0613", response_flag=False):
        """トークン数を計算する
        Note:
            参考
                https://learn.microsoft.com/ja-jp/azure/ai-services/openai/how-to/chatgpt?pivots=programming-language-chat-completions&tabs=python-new#manage-conversations

            計算式
                M = メッセージの総数
                Tpm = メッセージ当たりのトークン(モデルによって3または4)
                Tpn = `name`キーをもつメッセージ当たりのトークン(モデルによって1または-1)
                N = `name`キーを持つメッセージの数
                V = メッセージの各バリューのトークン数( `content`キー、`role`キーのトークン数)
                3 = メッセージの最後に追加されるトークン数( APIの全ての応答に対して付与される `<|start|>assistant<|message|>` の分、APIコール前の場合は0 )
                T = M * Tpm + N * Tpn + ΣV + 3
            ※modelがgpt-4系でサポート外の場合は、gpt-4-0613の計算式を使用する
        Args:
            messages (list[dict[str, str]]): メッセージリスト
            model (str): モデル名
            response_flag (bool): レスポンスフラグ (最後に追加されるトークン数を追加するかどうか: Trueの場合追加する)
        Returns:
            int: トークン数
        """

        try:
            encoding = tiktoken.encoding_for_model(model)
        except KeyError:
            encoding = tiktoken.get_encoding("cl100k_base")
        if model in {
            "gpt-35-turbo-0613",
            "gpt-35-turbo-16k-0613",
            "gpt-4-0314",
            "gpt-4-32k-0314",
            "gpt-4-0613",
            "gpt-4-32k-0613",
        }:
            tokens_per_message = 3
            tokens_per_name = 1
        elif model == "gpt-35-turbo-0301":
            tokens_per_message = 4  # every message follows <|start|>{role/name}\n{content}<|end|>\n
            tokens_per_name = -1  # if there's a name, the role is omitted
        elif model.startswith("gpt-35-turbo"):
            return OpenAiTokenizer.calc_tokens_from_messages(messages, model="gpt-35-turbo-0613", response_flag=response_flag)
        elif model.startswith("gpt-4"):
            return OpenAiTokenizer.calc_tokens_from_messages(messages, model="gpt-4-0613", response_flag=response_flag)
        else:
            raise NotImplementedError(
                f"""num_tokens_from_messages() is not implemented for model {model}."""
            )
        num_tokens = 0
        for message in messages:
            num_tokens += tokens_per_message
            for key, value in message.items():
                if type(value) == list:
                    for item in value:
                        # 画像の場合は、画像のURLと詳細度をトークン数に変換
                        if item.get("type") == "image_url":
                            image_url = item.get("image_url").get("url")
                            detail = item.get("image_url").get("detail")
                            image_text_token = len(encoding.encode(image_url)) + len(encoding.encode(detail))
                            num_tokens += image_text_token

                            # urlからファイルをダウンロードし、ファイルサイズを特定
                            width, height = OpenAiTokenizer.get_image_size(image_url)
                            image_token = OpenAiTokenizer.count_image_tokens(width, height, detail)
                            num_tokens += image_token

                        # テキストの場合は、テキストをエンコードしてトークン数を算出
                        elif item.get("type") == "text":
                            num_tokens += len(encoding.encode(item.get("text")))
                else:
                    num_tokens += len(encoding.encode(value))
                if key == "name":
                    num_tokens += tokens_per_name
        if response_flag:
            num_tokens += 3  # every reply is primed with <|start|>assistant<|message|>
        return num_tokens

    @staticmethod
    def get_image_size(image_url):
        """画像のサイズを取得する
        Args:
            image_url (str): 画像URL
        Returns:
            int: 画像の幅
            int: 画像の高さ
        """
        response = requests.get(image_url)
        image = Image.open(io.BytesIO(response.content))
        width, height = image.size
        image.close()
        return width, height

    @staticmethod
    def count_image_tokens(width: int, height: int, detail: str = "auto"):
        """画像のトークン数を計算する
        Notes:
            以下のステップで計算を行う。
            1. 縦横比を保ちながら2048x2048以内に収まるようにリサイズ (2048 x 4096 -> 縦横比は1:2 -> 1024 x 2048)
            2. 最短辺のサイズを768pxにリサイズ (1024 x 2048 -> 768 x 1536)
            3. リサイズ後の画像を覆うのに必要な512pxの正方形タイルの数をカウント(部分的なタイルは切り上げ) (768 / 512 = 1.5 -> 2,  1536 / 512 = 3, 2 x 3 = 6)
            4. トークンコストを計算 (170 * タイル数 + 85)

            参考
            - https://learn.microsoft.com/ja-jp/azure/ai-services/openai/overview#image-tokens-gpt-4-turbo-with-vision-and-gpt-4o
            - https://zenn.dev/microsoft/articles/cd3060cbcf0303
            - https://community.openai.com/t/how-do-i-calculate-image-tokens-in-gpt4-vision/492318

        Args:
            width (int): 画像の幅
            height (int): 画像の高さ
            detail (str): 画像の詳細度 : default "auto"
        Returns:
            int: 画像のトークン数
        """

        cost = 0

        if detail == "low":
            cost = 85
        else:
            # detailがhighの場合の計算
            # ステップ1: 縦横比取得
            aspect_ratio = width / height

            # ステップ2: 縦横比を保ちながら2048x2048の範囲にリサイズ
            # 横幅の方が大きい場合
            if aspect_ratio > 1:
                new_width = 2048
                new_height = int(2048 / aspect_ratio)
            # 縦幅の方が大きい場合
            else:
                new_height = 2048
                new_width = int(2048 * aspect_ratio)

            # ステップ3: 最短辺を768pxにリサイズ
            if new_width < new_height:
                scale_factor = 768 / new_width
            else:
                scale_factor = 768 / new_height

            final_width = int(new_width * scale_factor)
            final_height = int(new_height * scale_factor)

            # ステップ4: 512pxの正方形タイルの数をカウント
            # math.ceil: 切り上げ
            num_tiles = math.ceil(final_width / 512) * math.ceil(final_height / 512)

            # ステップ5: トークンコストを計算
            cost = 170 * num_tiles + 85

        return cost

おわりに

この記事では、OpenAIのAPI利用時のトークン数の算出方法について紹介しました。

  • OpenAI APIの利用料金は処理したトークン数に基づいて課金される
  • トークン数の算出はレスポンスの usage 項目で確認可能だが、ストリーミング形式では自分で算出が必要
  • トークン数を計算するツール「トークナイザー」が提供されており、Pythonでは tiktoken ライブラリを使用できる
  • トークナイザーでの計算は推定値のため、実際の値と一致しない可能性がある
  • トークン数の試算式が提示され、メッセージ内の各要素ごとにトークン数がカウントされる仕組みが説明されている。
  • 画像を含むリクエストでは、画像の解像度に応じてトークンが追加される

この記事がトークン数の試算を検討されている方の参考になれば幸いです。