ブログ111本をAIに読ませる費用を、トークン数の実測で見積もり直した

ブログ111本をAIに読ませる費用を、トークン数の実測で見積もり直した

111本、770,047文字。このブログの記事本文をまるごとAIに読ませて校正させたら、入力だけでいくらかかるのか。

見積もりは、短い日本語を1文数えるところから始めました。日本語の文章を数えます。 は12文字です。tiktokenで数えると、cl100k_baseで11トークン、o200k_baseで9トークンでした。ほぼ1文字1トークンです。これを770,047文字に掛けると、約70.6万と約57.8万トークンになります。

実測は46.4万と37.2万トークンでした。掛け算の見積もりは、5割以上も多かったことになります。

これはAIのAPIを実際に呼んだ記録ではありません。手元の記事ファイルをトークンに分けて数え、公開されている料金表に当てはめた見積もりです。確かめたかったのは、料金の入口にあたるトークン数が、文字数からどれだけずれるかでした。

数えるのに使った道具

用語を3つだけ置きます。

  • トークン: モデルが文章を区切る単位です。API料金は、この数に単価を掛けて決まります。
  • tiktoken: OpenAIが公開しているトークン計測用のPythonライブラリです。
  • cl100k_base と o200k_base: tiktokenに入っている2種類の区切りルールです。Claudeなど他社モデルの区切りとは別物なので、数えた結果は目安として扱います。

一時ディレクトリに仮想環境を作って、tiktokenを入れます。

TMPDIR="${TMPDIR:-/tmp}"
python3 -m venv "$TMPDIR/tkv"
"$TMPDIR/tkv/bin/pip" install -q tiktoken

pipは、依存パッケージのwheelファイル名が正規化されていないという非推奨の警告を出しました。導入自体には影響しませんでした。

"$TMPDIR/tkv/bin/python" -c "import tiktoken; print(tiktoken.__version__)"
0.14.0

計測スクリプトは、記事ファイルの先頭にあるfrontmatterと本文を分け、さらに本文をコードブロックとそれ以外に分けて数えます。全文は下に畳みました。

count_tokens.py の全文
import glob, re
import tiktoken

SAMPLE = "日本語の文章を数えます。"
encs = {n: tiktoken.get_encoding(n) for n in ("cl100k_base", "o200k_base")}

def n_tok(name, text):
    return len(encs[name].encode(text, disallowed_special=()))

print("sample", len(SAMPLE), "chars:", {n: n_tok(n, SAMPLE) for n in encs})

files = sorted(glob.glob("src/content/posts/*.md"))
acc = {"body": [0, 0, 0], "prose": [0, 0, 0], "code": [0, 0, 0], "front": [0, 0, 0]}
def add(key, text):
    acc[key][0] += len(text)
    acc[key][1] += n_tok("cl100k_base", text)
    acc[key][2] += n_tok("o200k_base", text)

per_post_o200k = []
prose_all = ""
for path in files:
    s = open(path, encoding="utf-8").read()
    m = re.match(r"---\n.*?\n---\n", s, re.S)
    front, body = s[:m.end()], s[m.end():]
    prose = re.sub(r"```.*?```", "", body, flags=re.S)
    code = "\n".join(re.findall(r"```.*?```", body, re.S))
    for key, text in (("front", front), ("body", body), ("prose", prose), ("code", code)):
        add(key, text)
    prose_all += prose
    per_post_o200k.append(n_tok("o200k_base", body))

print("files", len(files))
print("prose ascii share: %.3f" % (sum(ch.isascii() for ch in prose_all) / len(prose_all)))
for key, (c, a, b) in acc.items():
    print(f"{key:5s} chars={c:7d} cl100k={a:7d} ({a/c:.3f}/char) o200k={b:7d} ({b/c:.3f}/char)")

c, a, b = acc["body"]
print("naive from sample: cl100k", round(c * 11 / 12), "o200k", round(c * 9 / 12))
print("naive / actual: cl100k %.2f o200k %.2f" % (c * 11 / 12 / a, c * 9 / 12 / b))
print("max single post o200k:", max(per_post_o200k), "avg:", round(sum(per_post_o200k) / len(files)))

PER_M = {"input $2.00 (one prompt)": 2.00, "haiku <=100k $0.10": 0.10, "haiku >100k $0.50": 0.50}
total = b + acc["front"][2]
print("o200k body+front tokens", total)
for k, p in PER_M.items():
    print(f"{k}: ${total / 1e6 * p:.3f}")

上のコードで $TMPDIR/count_tokens.py を新規に作ります。できたファイルを、リポジトリのルート(hibanas-net)で実行します。

cd hibanas-net
"$TMPDIR/tkv/bin/python" "$TMPDIR/count_tokens.py"
sample 12 chars: {'cl100k_base': 11, 'o200k_base': 9}
files 111
prose ascii share: 0.455
body  chars= 770047 cl100k= 463591 (0.602/char) o200k= 371813 (0.483/char)
prose chars= 517620 cl100k= 376657 (0.728/char) o200k= 288788 (0.558/char)
code  chars= 253419 cl100k=  86877 (0.343/char) o200k=  83106 (0.328/char)
front chars=  41135 cl100k=  23209 (0.564/char) o200k=  19153 (0.466/char)
naive from sample: cl100k 705876 o200k 577535
naive / actual: cl100k 1.52 o200k 1.55
max single post o200k: 8652 avg: 3350
o200k body+front tokens 390966
input $2.00 (one prompt): $0.782
haiku <=100k $0.10: $0.039
haiku >100k $0.50: $0.195

1文字あたりの重さは、場所で倍近く違う

本文全体では、1文字あたり0.602トークン(cl100k_base)と0.483トークン(o200k_base)でした。区切りルールを変えるだけで、トークン数は約2割減ります。

区分文字数cl100k_baseo200k_baseo200k_baseの1文字あたり
地の文(コード以外)517,620376,657288,7880.558
コードブロック253,41986,87783,1060.328

コードブロックは文字数の約3分の1を占めるのに、トークンでは約2割強です。英数字の並びは、複数の文字がまとめて1トークンになりやすいからだと見ています。

ただ、地の文でさえ0.558で、短文で測った0.75より軽い値でした。地の文の半分近く(45.5パーセント)はASCII文字です。表の罫線、URL、インラインコード、英単語が混ざっているためで、純粋な日本語の段落は一部に過ぎません。短い日本語1文は、私のブログ全体の平均像ではありませんでした。

最初の集計は、地の文の行が壊れていた

実は、最初に書いた集計スクリプトは地の文の数字を間違えていました。出力はこうです。

prose-only chars 769774 cl100k/char 0.602 o200k/char 0.483

本文全体は770,047文字です。コードが3分の1あるのに、地の文だけの文字数が273文字しか減っていません。コードブロックを抜くところで、複数のブロックを連結した文字列を body.replace(code, "") で消そうとしていました。連結文字列は本文のどこにも存在しないので、何も消えていなかったのです。比率が全体と同じ値になったことで、おかしいと気づきました。

re.sub でブロックごとに消す形へ直したものが、上の最終スクリプトです。もし最初の数字を信じていたら、コードの軽さには気づかなかったはずです。

料金表に当てはめる

トークン数に掛ける単価は、公開されている料金表から取ります。OpenAIの料金表では、gpt-6.1-solの入力が100万トークンあたり2.00ドル(通常のshort context)でした。Claude Sonnet 5.5も入力は2ドルです。Claude Haiku 5.5は、プロンプトが10万トークン以下なら入力0.10ドル、それを超えると0.50ドルと書かれています。

Claudeのトークン数は測れていないので、o200k_baseの値を代用します。frontmatterも含めた390,966トークンで計算しました。

読ませ方単価(入力100万トークン)費用
全記事を1回のプロンプトで(gpt-6.1-sol)2.00ドル0.782ドル
1本ずつ(Claude Haiku 5.5、10万以下)0.10ドル0.039ドル
全記事を1回のプロンプトで(Claude Haiku 5.5、10万超)0.50ドル0.195ドル

安い方に目が行きがちですが、同じHaikuでも読ませ方で5倍違います。1本ずつ送れば、最大の記事でも8,652トークンです。10万の境目には届きません。

毎日の差分だけなら、平均3,350トークンの記事1本で、gpt-6.1-solなら約0.0067ドル、Haiku 5.5なら約0.0003ドルです。全記事を毎回読ませる必要は、今のところ見つかりませんでした。月の請求がこの桁なら、個人事業の経費として悩むほどの額でもありません。

まだ納得していないこと

見積もりの穴は3つあります。

1つ目は、Claudeのトークン数を測っていないことです。Anthropicの料金ページは、Claude 4.7以降が従来より約3割多くトークンを出す新しい区切りを使うと書いています。ただし、これは旧Claudeとの比較で、o200k_baseとの差ではありません。仮に3割増えても、Haikuの1本ずつの費用は0.05ドル前後で、結論は変わらない見込みです。

2つ目は、出力トークンの料金を入れていないことです。校正の指摘をどれだけ返させるかで、入力より高くつく可能性があります。

3つ目は、プロンプトに付ける指示文を数えていないことです。1本ずつ送ると、指示文が毎回乗ります。ここは実際にAPIを呼び、返ってくる使用量の数字と突き合わせて直したいです。

文字数の掛け算ではなく、まず自分の記事を数える。見積もりの出発点は、それで足りました。

出典

この記事をシェア