この記事は、連載「追わない技術」の第 2 回です。うまくいかない症状と、その対処を一つずつ並べています。各回はファイルやスクリプトを一つ置けば完結します。その仕組みがなぜ要るのかは、置いたあとに解説を読めば分かります。連載の全体像と各回の一覧は序論にあります。

今回は、仕事をどのモデルに渡すかを決める時点の話です。安いモデルは、使うべきです。私も使っています。私がやめたのは、一番安いモデルを「既定」にすることのほうです。値段は、単価の比 × 量の比で決まります。

この回で比べる 2 つのモデルの呼び名は、単価の高いほうが「高い側」、安いほうが「安い側」です。

そして白状します。私はこの実験の結果を 2 度、高い側と安い側に「差が無い」と読み間違えました。2 度とも、私は指摘を受けて初めて間違いに気づきました。指摘が無ければ、私は「杞憂でした」と書いていたはずです。

安いモデルに仕事を任せたことがあるなら、そのときのことを思い出してみてください。

安いモデルに任せた仕事は、本当に安く終わりましたか。

やり直しの回数まで数えたうえで、そう言うことができますか。

CC BY 4.0

予測から始めました —— 「安いモデルのテストは、見逃しが増えるはず」

私が最初に立てた予測は、こうです。

安いモデルにテストを書かせると、テストは書き上げるが、見逃しが増える。

私は題材にテストを選びました。テストを書く仕事の失敗は、テストが合格しているかぎり表に出ないからです。実装のバグは、いつかどこかでエラーとして表に出ますが、テストが見逃したバグは、テストが合格したまま残ります。「テストを書かせた」仕事は、テストが合格した時点で完了に見えます。見逃したバグを直す手間がかかるのは、そのあとです。

テストを書かせた実装は、音声認識の結果が、出題語と同じ読みかどうかを判定する 1 つのクラスです。このクラスは、認識した文字列を辞書の表記で区切り、区切った表記の読みをつないだものが、出題語の読みと一致するかを見ます。

私は見逃しを、ミューテーションテスト(変異テスト、mutation testing)の形で数えました。実装をわざと 1 か所だけ壊し(壊したものを変異と呼びます)、テストがその変異で失敗するかを見る形です。テストが失敗すれば、テストはその変異を落としたことになります。物差しには、過去に私が手元のコードへ入れ、手元の自動テストが実際に落とした変異 8 種を、入れ直して使いました。変異を過去のものから採ったのは、この実験のために私が書くと、見逃し率が、書き方ひとつで 0% にも 100% にもなるからです。

モデルが書くテストと比べる相手も、私は用意しました。同じ実装に対して、人(私)が以前に書いた既存のテストです。そのテストは、8 種のうち 6 種を落とします。

私が変えたのは 2 つだけです。1 つはモデル(高い側 / 安い側)で、もう 1 つは、テストを書く範囲(call 1 メソッドに絞る / 「必要だと思うテストを書いて」とだけ頼む)です。私は、どの組み合わせでも、どんなテストを書くかという観点を、テストを書くモデルに任せました。

1 回目 —— 安い側のテストは、6 本のうち 4 本が走りませんでした

私は、新しい会話から始まるサブエージェントを 12 回(2 つのモデル × 2 つの範囲 × 3 回)起動し、1 回に 1 本ずつテストを書かせました。サブエージェントが受け持ったのはテストを書くところまでで、書かれたテストを走らせたのは私です。私はまず、変異を入れない元の実装のまま、書かれたテストを全部走らせました。

テストが走った本数走ったテストだけの検出(8 種のうち何種を落としたか。多いほうが良い)
高い側6 / 63.33 / 8
安い側2 / 63.00 / 8

走らなかった 4 本のテストは、存在しないアサーションを呼んだり、辞書の行を重複させたりしていました。どれも、テストを走らせればすぐに分かる壊れ方です。

そこで私は、走らなかった 4 本を「見逃しとは別枠」として、検出を数える母数から外しました。外したのには、私なりの理屈がありました。走らないテストなら、私はその場で気づき、高い側へ切り替えることができる、という理屈です。

これが 1 つ目の間違いでした。

間違い 1 —— 走らなかったテストを、母数から外していました

外した結果、残った安い側は 2 本だけです。私はその 2 本の平均を「安い側の実力」として、高い側 6 本と並べていました。

走らなかったテストを母数から外すと、いちばん危ない結果が平均から消えます。

この間違いの直し方は、簡単でした。私は、ふだんの仕事と同じように、走らなかったテストを差し戻しました。私がサブエージェントに返したのは、エラーの出力と「直してください」の一言だけです。何が悪いかを伝えると、テストの観点を私が渡すことになるからです。

私は差し戻しを重ねて、12 本のテストを全部合格させました。そのうえで、落とした変異の数を数え直すと、こうなりました。

範囲を絞る絞らない
高い側3.33 / 83.33 / 8
安い側3.33 / 82.33 / 8

範囲を絞れば、安い側は高い側と同点です。私はここで、こう書こうとしました。「安いモデルで増えるのは、その場で気づける失敗のほうです。見逃しは増えません。」

これも間違いでした。

間違い 2 —— 落とした変異の数に、天井がありました

8 種の変異のうち 3 種は、12 本のテストがどれも見逃していました。人が書いた既存のテストは、その 3 種を落とします。

私が 3 種を並べてみると、共通点がありました。どれも、テストを書く前に、材料か測り方を自分で用意しておく必要がある変異です。

  • 表の中でいちばん長い見出し語を、自分で辞書に入れる
  • 同じ表記に読みを 2 通り持たせる
  • 戻り値の代わりに、問い合わせの本数を数える

課題の文は、どちらの範囲でも、辞書を setup で自分で用意するよう求めていました。範囲を絞った課題は、見たい振る舞いが出る中身を辞書に入れることも求めていました。戻り値のほかに何を測るかは、どちらの課題も書いていませんでした。

12 本がどれもこの 3 種を見逃した理由として、私は、モデルの力のほかに、課題の文を疑いました。課題が求めていないために、どのモデルのテストも落とせない変異が残ると、落とした変異の数は、その手前で頭打ちになります。私は、この頭打ちを天井と呼びます。

そこで私は、課題に 1 段落だけ足しました。

setup で作る辞書に現象が入っていなければ、そのテストは何も見ていません。 見たい振る舞いが出せるように、辞書のほうを組み立ててください。 戻り値を見るだけでは分からない振る舞いもあります。そういうものは、測り方のほうを用意してください。

私がこの段落に書いたのは、辞書と測り方を用意するというやり方だけです。8 種の変異は、どれも挙げていません。

そして私はこの段落を、4 つの条件(2 つのモデル × 2 つの範囲)のどれにも、1 バイト違わず同じ文で渡しました。条件のあいだの差を、モデルと範囲のほかに増やすと、測っているものが変わるからです。

天井を外したら、結論がひっくり返りました

私はもう一度、サブエージェントを 12 回起動し、テストを書かせました。今度は、1 回目に 12 本とも見逃した 3 種にも、落とすテストが出てきました。

1 回目(天井あり)2 回目(天井なし)
高い側3.33 / 85.00 / 8
安い側2.83 / 82.83 / 8
高い側・範囲を絞る3.33 / 85.00 / 8
安い側・範囲を絞る3.33 / 8(同点)3.00 / 8(同点ではない)

天井を外すと、落とした変異の数が増えたのは高い側だけでした。安い側は 2.83 のままです。

「絞れば同点」は、両方のモデルが、落としやすい変異だけを落として並んでいた結果でした。

範囲を絞っても絞らなくても、落とした変異の数はほぼ同じでした(高い側は 5.00 と 5.00 で差 0、安い側は 3.00 と 2.67)。1 回目に範囲を絞って変わったのは、「そもそもテストが走るか」のほうです。

最初の予測は当たっていました。ただし、そう分かるまでに、私は 2 つの間違いを直す必要がありました。走らなかったテストを母数から外したことと、落とした変異の数に天井があったことです。

実装は正しいものとして差し戻すと、テストを消して返すサブエージェントがいました

差し戻しの回数は、高い側は 2 回の実験とも 0 回、安い側は 1 回目が 6 回、2 回目が 10 回でした。ここまでは、私の想像どおりです。

想像と違ったのは、サブエージェントの直し方のほうです。私が、実装は正しいものとして扱い、「直すのはテストの側です」と書いて差し戻すと、返ってきた直しのうち 3 件は、失敗しているテストを消したか、エラーが起きても捕まえて止めない書き方に変えたものでした。

  • 「上限ちょうどは通す」という 1 件を、削除
  • 全角の正規化を見る 1 件を、削除
  • 重複した辞書の行は消さずに残し、そこで起きるエラーを捕まえて止めない書き方に変更

どの直しでも、テストは合格になります。そして、消えたテストが見るはずだった変異は、そのまま見逃しになります。

実装は正しいものとして差し戻すと、テストを直す代わりに、消して返すサブエージェントが出ます。

ただし、差し戻しが要ったのは安い側だけでした。だから結果として、テストが消えたのも安い側だけです。

原理: 安いかどうかは「単価の比 × 量の比」で決まります

下の表に、2 回目の実験で、全部のテストが合格するまでに、安い側が高い側の何倍の量を使ったかを数えました。

数えたもの範囲を絞る絞らない全体
初めて読ませた量1.64 倍1.24 倍1.48 倍
キャッシュから読めた量9.71 倍2.62 倍5.29 倍
書いた量0.26 倍0.38 倍0.31 倍
ターン数(差し戻し込み)3.33 倍2.00 倍2.67 倍

ターンは、私がサブエージェントに依頼を 1 回渡してから、答えが返ってくるまでのひと区切りです。

キャッシュから読めた量は、前のリクエストと同じ先頭の部分を、もう一度読ませた量です。この部分は、プロンプトキャッシュ(prompt caching)によって、前のリクエストの処理の結果から読まれます。キャッシュから読んだ部分は安く課金されますが、ゼロではありません。

書いた量だけは、安い側のほうが少なくなっています(0.31 倍)。

料金のページには、いま使っているモデルより単価の安いモデルが載っています。並んでいるのは、1 トークンあたりの価格です。試しに、テストを書く仕事を安いモデルに頼むと、AI は、テストを走らせては失敗を直すことを何度も繰り返してから、全部のテストが合格したと報告してきました。この仕事を既定でどちらのモデルに頼むかを、あなたなら次のどちらで決めますか。

  • 料金のページの単価だけを比べて決める
  • 任せたセッション全体のトークン数に、単価を掛けて決める

答えは、セッション全体のトークン数に単価を掛けるほうです。失敗を直すたびに、その仕事の会話は伸び、伸びた会話は、そのあとのリクエストのたびに、キャッシュからもう一度読まれます。料金のページの単価は 1 トークンあたりの価格で、直した回数も、そのたびに読み直された量も、どの列にも載っていません。単価だけで比べると、やり直しで増えたぶんが判断から抜けます。セッション全体のトークン数に単価を掛ければ、増えたぶんも、書いた量が少ないぶんも、総額に入ります。私の 2 回目の実験では、テストを走らせて失敗を返したのは私で、差し戻しは高い側が 0 回、安い側が 10 回でした。安い側の量は、上の表のとおり、キャッシュから読めた量が 5.29 倍、書いた量が 0.31 倍です。どちらが安く終わるかは、この量の比に単価の比を掛けるまで分かりません。

理解したことは一文にできます。

値段は「単価の比 × 量の比」で決まる。単価の比はカタログに書いてあるが、やり直しで増える量の比は、測って初めて分かる。だから、単価だけで既定のモデルを決めず、用途ごとに量の比を測る。

なぜ、量の比は測って初めて分かるのか

この実験で量の比を大きくしたのは、差し戻しでした。高い側の差し戻しは 2 回の実験とも 0 回で、安い側は 6 回と 10 回です。

差し戻しが要るかどうかは、書かれたテストを走らせて初めて決まります。この実験のサブエージェントは、課題の決まりどおり、書いたテストを走らせずに返しています。テストを走らせるのは、私の役でした。存在しないアサーションを呼ぶテストは、走らせればすぐに分かる壊れ方でしたが、その壊れ方に気づいたのは、テストを走らせた私です。

差し戻しが 1 回増えると、使う量は、1 回ぶんより多く増えます。Claude Code のコストのページによると、Claude Code はリクエストのたびに会話の全体を送り、Claude がツールを使うたびに、その結果を載せたリクエストをもう 1 つ送ります。差し戻しのエラー出力を受け取ったサブエージェントが、テストを直して返すまでの間に、Claude Code が送るリクエストも、1 回ごとに、それまでの会話の全体を載せたものです。会話は差し戻しのたびに伸びるので、後のリクエストほど、送り直す量が大きくなります。

同じコストのページによると、Claude Code は、それまでの会話をキャッシュの単価で読み直すので、送り直した会話の大部分は、キャッシュの読み出しとして課金されます。Anthropic の prompt caching のページによると、キャッシュの対象は、プロンプトの先頭から続く部分です。プロンプトの中身は、先頭からツールの定義・system prompt・会話の順に並びます。前のリクエストまでに送った会話は、次のリクエストのプロンプトの先頭に、そのまま並んでいます。安い側では、初めて読ませた量が 1.48 倍だったのに、キャッシュから読めた量は 5.29 倍まで伸びました。この先頭の部分が、リクエストのたびに読み直されるからです。

Anthropic の料金のページは、キャッシュから読んだ部分の価格を、入力トークンの価格とは別の列に並べています。どのモデルの行でも、その価格は 0 より高く、入力トークンの価格より低く書かれています。同じページで、どのモデルの行でも入力トークンの価格より高いのが、出力トークンの価格です。書いた量のほうが 1 トークンあたり高くつくので、総額の計算で、読ませた量の倍率より大きな重みがかかるのは、安い側の書いた量の 0.31 倍のほうです。

量のほかにも、差し戻しで変わるものがあります。Anthropic のプロンプトの指針には、Claude がテストを通すことに集中しすぎることがある、と書いた節があり、その節の例文は、テストが間違っているなら、回り道をせずに知らせてほしいと頼んでいます。私の差し戻しの文が書いていたのは、実装は正しいものとして扱うことと、「直すのはテストの側です」だけでした。テストが間違っているなら知らせてほしい、という一文は入れていません。返ってきた直しのうち 3 件は、失敗しているテストを消すか、エラーが起きても捕まえて止めない書き方でした。量の比は、合格するまでに使った量を比べたものです。テストを消して合格になった 3 件も、ほかの合格と同じ 1 件の完了として、その中に入っています。

検証手順: 単価の比と、量の比を、別々に数える

前提

同じ課題を、2 つのモデルへ同じ材料で渡す手段が要ります。材料が 1 バイトでも違うと、測るものがモデルの差から材料の差に変わります。合否を決めるのは、プログラムで出る物差しです。人が読んで採点すると、測り直すたびに人の手間がかかるからです。使ったトークンは、初めて読ませた量・キャッシュから読めた量・書いた量に分けて残します。分けて残っていないなら、手順 6 と 7 を行うことはできません。

所要時間: 半日(物差しが既にある場合)

手順

  1. 物差しにする変異は、過去に手元のコードへ入れ、手元の自動テストが落としたものから採ります。この実験より前からあった変異だけを使います
  2. 人が書いた既存のテストを、同じ物差しに掛けます。これを、モデルのテストと比べる相手にします。満点なら物差しが緩いので、変異を足してください
  3. 同じ課題を、2 つのモデル × 2 つの範囲 × 3 回、サブエージェントへ渡し、テストを書かせます。テストを走らせるのはこちらです。サブエージェントには、テストを書くところまでを頼みます
  4. 変異を入れない状態で、書かれたテストを全部走らせます。テストが失敗したサブエージェントも、母数に残します。エラーの出力だけを返して差し戻し、全部のテストが合格するまで繰り返します
  5. 全部のテストが合格したら、実装へ変異を 1 種ずつ入れて、テストを走らせます。変異 1 種ごとに、全部のサブエージェントのテストをまとめて 1 回走らせることができます
  6. 差し戻しの回数と、初めて読ませた量・キャッシュから読めた量・書いた量を、モデルごとに合計します
  7. 合計した量にカタログ(料金のページ)の単価を掛けて、仕事が終わるまでの総額を出します

合格条件(すべて満たすこと)

合格の条件は 4 つです。1 つ目は、手順 2 で、人が書いた既存のテストが落とさない変異が 1 つ以上あることで、満点なら物差しが緩すぎます。2 つ目は、手順 4 のあと、全部のサブエージェントのテストが合格であることです。1 本でも失敗したまま数えると、いちばん危ない結果が平均から消えます。3 つ目は、手順 5 で、全部のサブエージェントが見逃し、人が書いた既存のテストは落とす変異が 1 つも無いことです。1 つでもあるなら、その変異を落とすのに要る準備が課題の文から抜けているので、材料と測り方を自分で用意する、と課題に書き足してください。4 つ目は、手順 6 で、キャッシュから読めた量が 0 より大きいことで、0 なら、量が分けて記録されていません。

合格しなかったとき

  • 人が書いた既存のテストが満点 —— 物差しが緩いか、変異を手順 1 と違うところから採っています
  • 差し戻しても、テストが合格にならないサブエージェントがある —— そこが「高い側へ切り替える」判断点です。差し戻しの回数を費用として記録してから、高い側へ切り替えます
  • 全部のサブエージェントが見逃し、人が書いた既存のテストは落とす変異がある —— 落とした変異の数に天井があります。課題の文に 1 段落足して、全部の条件へ同じ文で渡し、測り直します
  • キャッシュから読めた量が 0 —— 量が分けて記録されていません。分けて記録することができないなら、量の比は出さずに、ターン数だけで判断します

後始末

一時的に置いたテストは消してください。記録(トークンの内訳)は残し、結果は日付つきで残します。トークンの記録は増え続け、同じ集計を明日もう一度行うと、数が変わるからです。

CC BY 4.0 はここまで

既定にしなくなったもの

  • 「安いほうで足りるだろう」と考えて、仕事を安い側へ渡すのをやめました。それは予測です
  • 続けていることもあります —— 安いモデルを使うことです。どの用途に使うかを、任せたセッション全体のトークン数に単価を掛けて決めるようにしました

次回は「最新モデルを使わない技術」。私は、片方のモデルが失敗した時点で、強いほうへ乗り換えるのをやめました。 落ちた項目だけでは、原因がモデルの差か、渡していない決まりかが分からなかったからです。