0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

1 回のコールにまとめたら安くなると思っていたら、逆だった — prompt cache は接頭辞が一致しないと効かない

0
Posted at

こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。

このサイトでは、拾ってきたニュースを「小学生向けに扱ってよい話題か」で選別しています。暴力や事件を除く、といった判定です。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 で済みます。回数を減らすより、読み出し側に回すほうが効きます。

そして、読み出し側に回るための条件が厳しいところでした。

条件は「接頭辞のバイト列が完全に一致すること」

キャッシュは接頭辞の一致で判定されます。リクエストは toolssystemmessages の順に並び、その先頭から見ていって、一致しなくなったところから先は全部キャッシュが効きません。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 コールは失敗の単位が小さくなり、対応づけの事故も監査の手間も減る
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?