こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
このサイトでは、拾ってきたニュースを「小学生向けに扱ってよい話題か」で選別しています。暴力や事件を除く、といった判定です。AI に任せていて、毎日数十件を処理します。
最初は 1 回のコールに何件かまとめて渡していました。コール数が減れば安いだろう、という素朴な理由です。今は 1 件 1 コールに割っていて、そのほうが安くなりました。
なぜ逆になるのかを、順番に書きます。
外れていた前提: 課金はコール数に比例する
まとめて投げていたときの前提はこれでした。判定の指示(禁止する話題の定義がひととおり書いてある長い文章)を毎回送るのがもったいないので、1 回にまとめれば送る回数が減って安くなるはずだ、と考えていました。
この前提は、prompt cache のある API では成り立ちませんでした。
prompt cache の課金は 3 種類ある
Anthropic の Messages API には prompt cache があります。同じ接頭辞で始まるリクエストが続いたとき、2 回目以降はその部分の処理を省く仕組みです。
入力トークンの課金は 3 つに分かれます。
- 素の入力トークン: 通常価格
- キャッシュに書き込んだトークン(
cache_creation_input_tokens): 通常の約 1.25 倍 - キャッシュから読んだトークン(
cache_read_input_tokens): 通常の約 1/10
つまり、同じ長い前置きを何度も送っても、2 回目以降が読み出し側に回れば 1/10 で済みます。回数を減らすより、読み出し側に回すほうが効きます。
そして、読み出し側に回るための条件が厳しいところでした。
条件は「接頭辞のバイト列が完全に一致すること」
キャッシュは接頭辞の一致で判定されます。リクエストは tools → system → messages の順に並び、その先頭から見ていって、一致しなくなったところから先は全部キャッシュが効きません。1 文字違えば別物です。
ここで、まとめて渡す設計が引っかかります。
まとめる方式では、system プロンプトに件数が入ります。「以下の 5 件について判定してください」のような文です。5 件のときと 3 件のときで system が変わるので、コール間で共通の接頭辞ができません。日によって拾える件数が変わる以上、system は毎回違う文字列になります。
その結果、どのコールもキャッシュを作る側(1.25 倍)にしかならず、読み出し側(1/10)が一度も発生しませんでした。回数は減っていたのに、単価はいちばん高いところに張り付いていたわけです。
対処 1: system を件数に依存しない定数にする
まず、1 件 1 コールに割って、system プロンプトを定数にしました。
const payload = await llm.call(CHECK_SYSTEM, buildCheckUserText(art, ctx.useBody), FILTER_SCHEMA, {
label: `checker ${label}`,
onMeta: m => {
callMeta = m;
},
});
CHECK_SYSTEM は定数で、記事ごとに変わりません。変わるのは user 側だけです。これで全コールの接頭辞が同一になります。
対処 2: キャッシュに載せる位置を明示する
接頭辞を揃えただけでは載りません。どこまでをキャッシュするかは、cache_control を置いた位置で決まります。ここでは system のブロックに置いています。
system: [{ type: 'text', text: systemText, cache_control: { type: 'ephemeral' } }],
置き場所の決め方は「変わらないものを前に、変わるものを後ろに」です。この印より後ろ(つまり user のテキスト)は毎回違ってよく、印より前が一致していれば読み出しになります。
なお、載る条件がもう 1 つあります。接頭辞が短いと(おおよそ 1,000 トークンに満たないと)キャッシュは黙って作られません。エラーにはならず、ただ効かないだけです。判定の指示は長いので、この条件はもともと満たしていました。
対処 3: 先頭 1 件だけ先に流す
ここまでやっても、そのまま並列に投げると効きませんでした。
実行の開始時点ではキャッシュがまだ存在しないので、同時に飛んだコールが全部「キャッシュを作る側」になります。同じ内容を何度も書き込むだけで、読み込みが起きません。
必要なのは、最初の 1 件だけ先に流して、キャッシュができてから残りを並列にすることです。並列プールの手前に 1 件だけ待つ処理を足しました。
export async function mapPoolWarm<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (items.length <= 1) return mapPool(items, limit, fn);
const first = await withLogContext({ worker: 0 }, () => fn(items[0], 0));
const rest = await mapPool(items.slice(1), limit, (item, i) => fn(item, i + 1));
return [first, ...rest];
}
items.length <= 1 のときに素通しするのは、1 件しかないなら温める意味が無いからです。戻り値が入力順であることも保っています。並列処理のヘルパで順序が崩れると、呼び出し側が「N 件目の結果」を取り違えます。
キャッシュには寿命があります。既定では数分で消えるので、温めた効果が続くのは 1 回の実行の中だけです。実行と実行のあいだをまたいで効かせようとはしていません。
効いているかは usage で見る
この手の最適化は、効いていなくても何も起きないので、必ず数字で確認します。応答の usage にある cache_read_input_tokens がゼロのまま並ぶなら、どこかに接頭辞を壊しているものがあります。
このプロジェクトでは AI のコール 1 回につき監査ログを 1 行残しているので、そこに入力トークンの内訳も入れてあります。調べた時点で、直近 1 日ぶんの判定 59 コールは、キャッシュに書き込んだトークンが約 16 万、キャッシュから読んだトークンが約 109 万でした。共通部分のうち 9 割近くが読み出し側に回っています。
書き込みが完全にゼロにならないのは、日をまたぐと寿命が切れて作り直しになるからです。1 回の実行ごとに 1 件ぶんの書き込みが発生する、という理解でだいたい合っています。
副産物のほうが大きかった
コストは狙ってやったことですが、実際に効いたのは失敗の扱いでした。
1 件 1 コールなら、失敗した 1 件だけが未判定のまま残ります。次回の実行で自動的に拾われます。まとめて投げていた頃は、1 件の失敗が同じバッチの他の件を巻き込んでいました。5 件まとめたコールが失敗すると 5 件とも未判定に戻ります。1 件だけおかしな入力があってパースに失敗しても、道連れになっていました。
もう 1 つ、出力の対応づけも消えました。まとめる方式では「入力の N 件目に対する判定はこれ」を AI に保たせる必要があり、件数が増えるほど、返ってくる配列の長さが合わない、順番が入れ替わる、といった事故が起きていました。1 件 1 コールなら、そもそも対応づけがありません。
監査の面でも素直になりました。コールと対象が 1 対 1 だと、「この記事の判定にいくらかかったか」がログからそのまま引けます。
まとめ
- 1 コールにまとめると安くなるとは限らない。prompt cache のある API では、回数より接頭辞が一致するかで決まる
- 入力トークンの課金は 3 種類ある。読み出しは通常の約 1/10、書き込みは約 1.25 倍
- 接頭辞はバイト列の完全一致で見る。system に件数を入れると、件数が変わるたびに別物になる
- system を定数にして、
cache_controlを「変わらないものと変わるものの境目」に置く - 実行の頭から並列に投げると全部が書き込み側になる。先頭 1 件だけ先に流して温める
- 効いているかは
cache_read_input_tokensで確認する。効いていなくてもエラーは出ない - 1 件 1 コールは失敗の単位が小さくなり、対応づけの事故も監査の手間も減る