手順書の整理を終えた夜、変更記録の末尾に「約 150.9KB(約 45,700 トークン)から約 101KB(約 30,600 トークン)へ、33% 減」と書き込みました。保存した直後に、手が止まりました。この 33% は、いったい何の割合なのでしょうか。
私が数えていたのは、定期タスクが起動のたびに読み込む文書の大きさ、つまり「一度読まれるバイト数」だけでした。その文書が実行の途中で何回読み直されるのかは、数字のどこにも入っておりませんでした。見積もりを済ませたつもりでいた、というのが正直なところだったのだと思います。
最初にお伝えしたいのは、数え直した結果です。前読みの 12,960 バイトを削る変更は、再読込の累計で見ると約 5.9%、平均的なツール呼び出し約 1.3 回ぶんにとどまりました。ところが、読み込みをほとんど増やさずに呼び出しを 3 回まとめるだけで、約 14.7%、呼び出し約 3.2 回ぶんが減ります。文書を削る作業より、呼び出しの数を減らす作業のほうが効いていたのです。
個人開発で Lab 4 サイトの運用を定期タスクに任せている私は、この数字を見て、手順書を削る夜の過ごし方を変えました。以下、その物差しの作り方を順にお伝えします。
物差しの前提 — 呼び出しのたびに文脈は読み直されます
エージェントがツールを呼ぶたびに、それまでの会話と読み込み済みの文書は、あらためて入力として読み直されます。最初の呼び出しで 20KB の文書を読むと、その 20KB は二回目以降のすべての呼び出しに同乗する、ということです。
プランごとの利用上限が内部でどう数えられているのかを、私は外から確かめられません。API の課金ではキャッシュ済みの入力が割引になりますが、割引の効き方が構成しだいで大きく変わることは、キャッシュ読み取りが 75% 安くなっても、請求の下がり方は構成しだいで 10 倍違いますでも触れました。
ですから、これから作る台帳は請求額の予測ではありません。「どの変更が、どの変更より効きそうか」を比べるための、相対的な物差しです。次の二つは、実測ではなく置いた値です。
- 基礎トークン(システムプロンプト・ツール定義・自動で読まれる設定ファイル)は 30,000 と置きます。本当の値は環境ごとに違うので、後で 20,000 と 40,000 でも計算し、結論が動かないことを確かめます。
- 日本語まじりの文書は 1 トークンあたり 3.3 バイトと置きます。09-25 の見積もりに使った値で、トークナイザの実値ではありません。
再読込の台帳を数える
呼び出し番号・ラベル・出力バイト数の TSV を読み、呼び出しごとに「読み直した文脈」を足し上げる小さなスクリプトです。
#!/usr/bin/env python3
"""定期タスク1回ぶんの「再読込の累計」を数える台帳。
各ツール呼び出しは、それまでに積み上がった文脈を読み直す。
入力 TSV: 呼び出し番号<TAB>ラベル<TAB>出力バイト数(同じ番号は合算)
使い方: reread_ledger.py run.tsv [基礎トークン=30000] [バイト/トークン=3.3]
"""
import csv, sys
from collections import OrderedDict
def load(path):
calls = OrderedDict()
with open(path, newline="", encoding="utf-8") as f:
for r in csv.reader(f, delimiter="\t"):
if len(r) < 3:
continue
no = int(r[0])
label, nbytes = calls.get(no, ("", 0))
calls[no] = ((label + "+" if label else "") + r[1], nbytes + int(r[2]))
return [(no, lab, b) for no, (lab, b) in sorted(calls.items())]
def ledger(rows, base_tokens=30000, bytes_per_token=3.3):
ctx, total, out = float(base_tokens), 0.0, []
for no, label, nbytes in rows:
total += ctx # この呼び出しが読み直す文脈
out.append((no, label, nbytes, round(ctx), round(total)))
ctx += nbytes / bytes_per_token # 出力は次の呼び出しから文脈に乗る
return total, out
def as_calls(rows, edited, base_tokens=30000, bpt=3.3):
"""edited の削減分が、平均的な呼び出し何回ぶんの再読込に当たるか"""
t0, _ = ledger(rows, base_tokens, bpt)
t1, _ = ledger(edited, base_tokens, bpt)
return (t0 - t1), (t0 - t1) / (t0 / len(rows))
if __name__ == "__main__":
rows = load(sys.argv[1])
base = float(sys.argv[2]) if len(sys.argv) > 2 else 30000
bpt = float(sys.argv[3]) if len(sys.argv) > 3 else 3.3
total, out = ledger(rows, base, bpt)
for no, label, nbytes, ctx, cum in out:
print(f"{no:>3} {label:<24} +{nbytes:>6}B 文脈 {ctx:>7,} 累計 {cum:>10,}")
print(f"再読込の累計: {round(total):,} トークン({len(rows)} 回)")足す順序が肝です。呼び出し i が読み直すのは、i の出力が乗る前の文脈ですから、累計に加えてから文脈を増やします。順序を逆にすると、毎回自分の出力ぶんだけ多く数えてしまいます。
同じ呼び出し番号を合算するのは、一回の bash の中で複数の文書を cat する運用に合わせるためです。文脈に乗る単位は、ファイルではなく呼び出しだからです。