「このプロンプト、細かく書いた方がいいのか、ゴールだけ渡した方がいいのか」
議論はよく見かけますが、自分の用途で実際に測った話はあまり見ません。そこで測る側の仕組みを作りました。この記事では、その検証ハーネスの実装を共有します。
実際にこれで測った結果(手順を削っても正答率100%のまま、仕様を削ると0%まで落ちた)は本家記事にまとめています。
推論モデルへの指示は簡潔でいい。ただし「仕様」を削ると精度は0%まで落ちた
この記事では結果ではなく、測り方を扱います。コードはすべて実際に動かしたものです。
何が難しいのか
プロンプトのA/B比較は、一見すると「2つ投げて見比べるだけ」です。実際にやると3つ詰まります。
| 詰まりどころ | 中身 |
|---|---|
| 評価が主観になる | 出力の良し悪しを人間が判断すると、比較にならない |
| 交絡が入る | 推論モデルと通常モデルを比べると、差が「思考の有無」か「モデルの地力」か分からない |
| 課金が読めない | 特に推論系は思考トークンで膨らむ。上限を決めないと怖くて回せない |
順に潰していきます。
1. 評価を機械化する:正解が1通りに定まる問題を使う
主観を排除するには、正解が機械判定できるタスクを選ぶ必要があります。今回は「6人の並び順を条件から特定する論理パズル」にしました。答えが BEAFDC のような文字列1つに定まるので、完全一致で採点できます。
ただしここに落とし穴があります。手で問題を作ると、解が複数できます。
私が最初に手作りした問題は、条件を6個も付けたのに解が3通りありました。これに気づかず採点すると、正しい答えを「不正解」と判定してしまいます。
そこで、問題を自動生成し、解が1通りであることを総当たりで確認するようにしました。
考え方:答えを先に決めて、条件を逆算する
普通は「条件を考える → 解ける問題になっているか確かめる」の順ですが、それだと解が1通りになる保証がありません。そこで逆向きにします。
先に答えを BEAFDC と決めてしまえば、少なくともこの答えは必ず成立します。あとは「他に解がないか」だけ確認すればよくなります。
準備:位置を引く辞書
条件の判定には、人物名から「左から何番目か」を引く辞書を使います。
answer = "BEAFDC"
pos = {c: i + 1 for i, c in enumerate(answer)}
# → {'B': 1, 'E': 2, 'A': 3, 'F': 4, 'D': 5, 'C': 6}
# Bは1番目、Eは2番目、Aは3番目 …
これがあると、条件を「位置の比較」として書けます。
pos['B'] < pos['E'] # 1 < 2 → True 「BはEより左にいる」
abs(pos['A'] - pos['F']) == 1 # |3-4|=1 → True 「AとFは隣り合っている」
条件を1種類だけ作ってみる
いきなり全部を作らず、まず「AとBの左右関係」だけを考えます。
# 正解 BEAFDC では B(1番目) が A(3番目) より左
if pos["B"] < pos["A"]:
説明文 = "BはAより左にいる"
判定 = lambda p: p["B"] < p["A"] # 任意の並び p に対して真偽を返す
説明文(人間が読む)と判定関数(プログラムが使う)をセットで持つのがポイントです。説明文はそのまま問題文になり、判定関数は「他に解がないか」を数えるのに使います。
これを全ペア(A-B, A-C, … E-F の15組)について、左右関係と距離の2種類ずつ作ります。
全体のコード
from itertools import permutations, combinations
import random
PEOPLE = "ABCDEF"
ALL = list(permutations(PEOPLE)) # 720通りの並び
def build_conditions(answer):
"""正解から、成立する条件を「説明文 + 判定関数」の形で列挙する"""
pos = {c: i + 1 for i, c in enumerate(answer)}
conds = []
for x, y in combinations(PEOPLE, 2):
if pos[x] < pos[y]:
conds.append((f"{x}は{y}より左にいる", lambda p, x=x, y=y: p[x] < p[y]))
else:
conds.append((f"{x}は{y}より右にいる", lambda p, x=x, y=y: p[x] > p[y]))
gap = abs(pos[x] - pos[y])
if gap == 1:
conds.append((f"{x}と{y}は隣り合っている",
lambda p, x=x, y=y: abs(p[x] - p[y]) == 1))
else:
conds.append((f"{x}と{y}の間にはちょうど{gap - 1}人いる",
lambda p, x=x, y=y, g=gap: abs(p[x] - p[y]) == g))
return conds
def count_solutions(conds):
"""条件を満たす並びが何通りあるか(720通りを総当たり)"""
n = 0
for perm in ALL:
p = {c: i + 1 for i, c in enumerate(perm)}
if all(f(p) for _, f in conds):
n += 1
return n
lambda p, x=x, y=y: のデフォルト引数は必須です。これを書かないとクロージャが最後の値を参照して、15組すべてが同じ条件になります。ループ内で lambda を作るときの定番の罠です。
count_solutions は、720通りすべてを実際に並べてみて、条件を全部満たすものが何個あるか数えるだけです。1個なら問題として成立、2個以上なら条件が足りていない、0個なら条件が矛盾している、と判断できます。
正解から条件を作り、解が1通りになるまで足してから、余分な条件を削るという流れにします。
def make_problem(seed):
"""解が1通りになる最小の条件セットを作る"""
rnd = random.Random(seed)
answer = "".join(rnd.sample(PEOPLE, len(PEOPLE)))
pool = build_conditions(answer)
rnd.shuffle(pool)
# 解が1通りになるまで条件を足す
chosen = []
for cond in pool:
chosen.append(cond)
if count_solutions(chosen) == 1:
break
# 余分な条件を削って最小セットにする(削るほど難しくなる)
i = 0
while i < len(chosen):
trial = chosen[:i] + chosen[i + 1:]
if trial and count_solutions(trial) == 1:
chosen = trial
else:
i += 1
text = ("A〜Fの6人が横一列に並んでいます。次の条件をすべて満たす並び順を求めてください。\n"
+ "\n".join(f"- {desc}" for desc, _ in chosen))
return {"prompt": text, "answer": answer, "conds": chosen}
後段の「余分な条件を削る」処理を入れているのは、条件が少ないほど難しくなるからです。最初は条件を多めに付けた問題で試したところ全条件100%正解になり、差が出ませんでした。難易度が足りないと比較になりません。
実行するとこうなります。
A〜Fの6人が横一列に並んでいます。次の条件をすべて満たす並び順を求めてください。
- EとFの間にはちょうど1人いる
- CとDは隣り合っている
- DはFより右にいる
- CとFの間にはちょうど1人いる
- BとFの間にはちょうど2人いる
- BはFより左にいる
正解: BEAFDC
解の個数: 1
条件を2つ削ると: 6通り
最後の行が効いてきます。条件(=仕様)を削ると解が一意でなくなるので、「仕様を削ったらどうなるか」の検証条件がそのまま作れます。
2. 交絡を消す:同じモデルで思考をON/OFFする
「推論モデルは細かい指示が要らない」を検証したいとき、推論モデルと通常モデルを比べてはいけません。差が出ても、それが「思考の有無」によるものか「モデルの地力の違い」によるものか分けられないからです。
Gemini には thinkingBudget があり、同一モデルのまま思考をON/OFFできます。
{
"contents": [{ "parts": [{ "text": "(プロンプト)" }] }],
"generationConfig": {
"thinkingConfig": { "thinkingBudget": 0 }
}
}
0 で思考なし、正の値(今回は 8192)で思考ありです。モデルの地力が固定されるので、思考の有無だけを取り出せます。
落とし穴:最新モデルでは思考を切れない
gemini-3.6-flash で thinkingBudget: 0 を投げると 400エラーになります。思考が常時ONの設計に変わっているためです。
HTTP 400: Request contains an invalid argument.
使う前に、対象モデルがON/OFF両方を受け付けるか確認してください。数リクエストで済みます。
for model in ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-2.5-flash"]:
for budget in [0, 4096]:
# 実際に投げて 200 が返るか、thoughtsTokenCount が変化するかを見る
...
今回は ON/OFF 両対応の gemini-3.5-flash を使いました。
3. コストを抑える:使用量を測りながら上限で打ち切る
推論系は思考トークンが出力レートで課金されるため、油断すると膨らみます。使用量はレスポンスの usageMetadata に入っているので、累積を測りながら上限で止める構造にします。
import json
import os
import re
import urllib.request
API_KEY = os.environ["GEMINI_API_KEY"]
MODEL = "gemini-3.5-flash"
# 料金($/1Mトークン)。思考トークンは出力レートで課金される
IN_RATE, OUT_RATE = 0.30 / 1e6, 2.50 / 1e6
def ask(prompt, thinking_budget):
"""thinking_budget=0 で思考なし、正の値で思考あり"""
body = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"thinkingConfig": {"thinkingBudget": thinking_budget}},
}
req = urllib.request.Request(
f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent?key={API_KEY}",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=240) as res:
data = json.load(res)
text = "".join(
part.get("text", "")
for cand in data.get("candidates", [])
for part in cand.get("content", {}).get("parts", [])
)
usage = data.get("usageMetadata", {})
return {
"text": text,
"thinking_tokens": usage.get("thoughtsTokenCount", 0),
"output_tokens": usage.get("candidatesTokenCount", 0),
"input_tokens": usage.get("promptTokenCount", 0),
}
def cost_of(result):
return (result["input_tokens"] * IN_RATE
+ (result["output_tokens"] + result["thinking_tokens"]) * OUT_RATE)
採点は正規表現で拾います。出力形式を指定しておくと安定します。
def extract_answer(text):
"""「答え: BEAFDC」の形式を優先し、無ければ6文字の英大文字を拾う"""
found = re.findall(r"答え[::]\s*([A-F]{6})", text)
if found:
return found[-1]
found = re.findall(r"\b([A-F]{6})\b", text)
return found[-1] if found else None
実行するとこうなります。
回答: BEAFDC / 正解: BEAFDC / ○
思考 1439tok / 出力 601tok
このリクエストの費用: $0.00513
1リクエストあたり0.8円ほどです。ループ側で累積し、上限に達したら打ち切ります。
LIMIT_USD = 1.2
cost = 0.0
for cond_name, budget in [("思考OFF", 0), ("思考ON", 8192)]:
for prompt_name, template in [("A案", PROMPT_A), ("B案", PROMPT_B)]:
for problem in problems:
for _ in range(TRIALS):
if cost >= LIMIT_USD:
print("上限到達。打ち切り")
break
r = ask(template.format(q=problem["prompt"]), budget)
cost += cost_of(r)
...
「上限に達したら止まる」を先に入れておくと、安心して回数を増やせます。今回は3回の検証を合わせて累計170円ほどで済みました。
比較するプロンプトの設計
ここが検証の肝です。変える要素を1つに絞らないと、何が効いたのか分かりません。
A案(手順を細かく指定)
以下の手順に厳密に従って解いてください。
1. まず全ての条件を箇条書きで整理する
2. 次に考えられる配置をすべて列挙する
3. 各条件を1つずつ適用して候補を絞り込む
4. 残った候補が条件をすべて満たすか検算する
5. 最後に答えを出力する
最終行に必ず「答え: XXXXXX」の形式(6文字の英大文字)で記載してください。
(問題文)
B案(ゴールと制約だけ)
(問題文)
最終行に必ず「答え: XXXXXX」の形式(6文字の英大文字)で記載してください。
違いは5ステップの手順を書くかどうかだけです。問題の条件と出力形式の指定は、両案とも同じだけ渡しています。ここを揃えないと、比較にならなくなります。
さらに C案 として、B案から条件を2つ削ったものを用意しました。手順ではなく仕様が欠けたときに何が起きるかを見るためです。前述のとおり、この状態では解が一意に定まりません。
ハマった点
実装中に踏んだものを共有します。
シェル経由でJSONを渡すと失敗する
curl -d '{"generationConfig": ...}' の形でシェルから投げると、空レスポンスが返ることがありました。原因の切り分けに時間を取られたので、最初からPythonスクリプト単体で完結させるのが早いです。ファイル経由(-d @req.json)なら通ります。
問題が簡単すぎると差が出ない
最初は5人・条件6個で試したところ、全条件が3/3で正解して比較になりませんでした。6人にして条件を最小セットまで削ったところ、ようやく条件間で差が出ました。差が出ないときは、まず難易度を疑うのが早いです。
思考OFFでは出力が途中で尽きることがある
手順を細かく指定した条件で、答えの形式まで到達せずに終わるケースがありました。指定した5ステップを順に書き出すことに出力を使い切るためです。採点側で None を許容し、原因を追えるよう応答テキストも保存しておくと後で分析できます。
まとめ
プロンプトのA/B比較を実測するために必要だったのは、次の3つでした。
- 正解が機械判定できるタスクを選び、解が一意であることを総当たりで検証する
-
同一モデルで条件を切り替える(Geminiの
thinkingBudget)ことで、モデル差の交絡を消す - 使用量を測りながら上限で打ち切る構造にして、コストの不安を消す
これができると、新しいモデルが出るたびに同じ比較を回せます。テクニックの正解を追いかけ続けるより、測る仕組みを持っておく方が長持ちします。
実際にこれで測った結果は本家に書いています。あわせてどうぞ。



