最近Python x OpenAI APIで遊んでいます。
日本語は1文字1トークン、ということでなるべく英語に翻訳して使うようにしているのですが、
トークン数の計算が謎だったので色々試してみました。

下準備

トークン数の計算にはOpenAIのトークナイザーであるtiktokenを使います。

pip install tiktoken

このモジュールを使い、与えられた文字列のトークン数をprintする関数を作成します。
GPTのモデルはgpt-3.5-turbo-1106です。

import tiktoken
def count_token(string: str):
  enc = tiktoken.encoding_for_model("gpt-3.5-turbo-1106")
  tokens_count = len(enc.encode(string))
  print(tokens_count)

スペース、タブ、改行、ピリオド

スペース

単語のスペース区切りでは、スペースは無視されます。

count_token("A B")
# Result: 2

スペースを2つ入れると、トークン数が増えました。

count_token("A B")
# Result: 3

下の例では10回スペースを入れていますが、相変わらず3トークンです。

count_token("A          B")
# Result: 3

いくつ入れても変わらないのではと思い、スペース1から1000個まで入れてカウントしてみました。
以下はスペースの数と、トークン数です。徐々に増えています。

 100: 4
 200: 4
 300: 5
 400: 6
 500: 7
 600: 7
 700: 8
 800: 9
 900: 10
1000: 11

必ずしもトークン数が線形に増えるわけではなく、若干の揺らぎもあるようです。

82: 3
83: 4
84: 3
85: 4
86: 4
87: 4
88: 3
89: 4
90: 4

タブ

タブも、少量であればスペースと同じ扱いなようです。

count_token("A\tB")
2
# タブ10個
count_token("A\t\t\t\t\t\t\t\t\t\tB")
3

ただし大量に連続した場合のトークン数は、スペースよりも多いです。

 100: 8
 200: 15
 300: 21
 400: 27
 500: 33
 600: 40
 700: 46
 800: 52
 900: 58
1000: 65

スペースとタブを組み合わせてみました。この場合でも"A B"同様に3トークンな模様。

count_token("A \t\t B")
# Result: 3

スペースだろうとタブだろうと同じ空白の連続扱いなのかと思いきや、
A、タブ、タブ、Bのスペース区切りだと4トークンになりました。

count_token("A \t \t B")
# Result: 4

改行

改行はスペースやタブと違い、1つでも1トークンとしてカウントされました。

count_token("A\nB")
# Result: 3

こちらも、スペースやタブと同様、2連続3つ連続してもトークン数は増えませんが、

count_token("A\n\n\n\n\n\n\n\n\n\nB")
# Result: 3

大量に連続すると徐々に増えていきます。
増え方は、スペース < 改行 < タブ、と全て異なる模様。ややこしい...

 100: 6
 200: 9
 300: 12
 400: 15
 500: 19
 600: 22
 700: 25
 800: 27
 900: 31
1000: 34

スペースやタブとの組み合わせは以下の通りでした。
スペースのインデントはタブに変換した方が良さそう。

count_token("A \n B")
# Result: 3

count_token("A\n    B")
# Result: 4

count_token("A\t\n\tB")
# Result: 3

大文字の改行区切りと小文字の改行区切りで違う結果に。沼ってきました。。。

count_token("AAA\nBBB")
# Result: 4

count_token("AAA \n BBB")
# Result: 3

count_token("aaa\nbbb")
# Result: 3

count_token("aaa \n bbb")
# Result: 4

ピリオド

ピリオドも1トークンとしてカウントされるようです。
ピリオドの必要ない1文とかであれば、末尾のピリオドを抜いた方が節約できそう。

count_token("test.")
# Result: 2

ただしこちらも、後に続く文字列によって扱いが変わります。

count_token("test.test")
# Result: 2

count_token("test.1")
# Result: 3

count_token("test. test")
# Result: 3

ドキュメント系

ここからはいろんなフォーマットで文章を扱う際のトークン数について見ていきます。

ヒアドキュメント

テンプレートなどで利用したくなるヒアドキュメントですが、
以下のような書き方をすると、冒頭と末尾の改行でトークン数が増えてしまいます。

document = """
A
B
C
"""
count_token(document)
# Result: 7

また、実際の利用時には関数内で使うなどしてインデントすることも多いかと思いますが、
ここでもまたトークン数がかさみます。

# 自然にインデントするため冗長なif文
if (1==1):
    document = """
        A
        B
        C
    """
    count_token(document)
# Result: 11

ヒアドキュメントを使う際には、APIへ渡す直前に不要な改行・インデントを削るなどした方が良さそうですね。

HTML

HTMLは開始タグと終了タグでそれぞれ2トークン消費していそうです。

count_token( "<html>a</html>")
# Result: 5

count_token( "<div>a</div>")
# Result: 5

count_token( "<p>a</p>")
# Result: 5

フォーマットしたHTMLをヒアドキュメントで記載するとかだとトークン浪費してしまいますね。。
こういう時にはhtmlminなどのライブラリを使ってminifyしてから使うのが良さそうです。

# 自然にインデントするため冗長なif文
if (1==1):
    document = """
    <body>
        <div>
            <p>a</p>
            <p>b</p>
        </div>
    </body>
    """
    count_token(document)
    # Result: 32

    import htmlmin
    count_token(htmlmin.minify(document, remove_empty_space=True))
    # Result: 18

Markdown

マークダウンでよく使う「見出し」「リスト」「画像」「リンク」について調べてみました。
見出しは深くしてもトークン数に影響なさそうです。画像とリンクは最低4トークンからの模様。

count_token("# aaa")
# Result: 2

count_token("## aaa")
# Result: 2

count_token("### aaa")
# Result: 2

count_token("- li")
# Result: 2

count_token("![](path)")
# Result: 4

count_token("[text](url)")
# Result: 4

上記でHTMLのトークン数について書きましたが、Markdownへ変換して問題ないものであれば、
html2textなどのライブラリを利用してMarkdownへ変換してから使うのが良さそうです。

# 自然にインデントするため冗長なif文
if (1==1):
    document = """
    <body>
        <div>
            <p>a</p>
            <p>b</p>
        </div>
    </body>
    """
    count_token(document)
    # Result: 32

    import htmlmin
    count_token(htmlmin.minify(document, remove_empty_space=True))
    # Result: 18

    import html2text
    count_token(html2text.html2text(document, bodywidth=0))
    # Result:  4

結論、細かい改行やスペースなどは気にせず、
ソースやドキュメントを圧縮するくらいの工夫に留めておくのが快適な気がします。