浮世絵壁紙の新しい素材に主題のタグを付け終えた夜、私は念のため Gemini に確認を頼んでおりました。「この作品は名所絵で合っていますか」と画像を添えて尋ねると、返ってくるのは、ほとんどが丁寧な同意と、もっともらしい理由です。
安心しかけたところで、手が止まりました。役者の大首絵に、うっかり名所絵のタグが付いたままの一枚があったのです。その一枚にも、背景の書き割りを根拠にした「名所絵として妥当です」という答えが付いておりました。
確認してもらったつもりが、私の答えをなぞってもらっていただけでした——この記事は、そこから聞き方を組み替えた記録です。
確認を頼む聞き方は、答えを先に渡していました
振り返ると、原因はモデルより先に、私の質問文にありました。「名所絵で合っていますか」という一文には、すでに「名所絵」という答えが入っています。モデルはその語を起点に画面を読み、合う根拠を探しにいきます。
これは Gemini だけの癖ではないのだと思います。gemini-cli のリポジトリには、応答が同意に寄りすぎることを扱った Issue(#4556)が長く開いたままになっており、コメントの中でも「指示で抑えられるのか、聞き方を変えるべきか」で意見が割れています。
私が引いた線は一つです。
判断を頼むときは、自分の答えを相手の目に入れない。
確認という作業を「私の答えを見せて是非を問う」から「何も見せずにもう一度解いてもらい、二つの答えを私が突き合わせる」へ移す、という発想です。
三つの聞き方を並べて、どれを残すか決めました
組み替える前に、思いつく聞き方を三つ並べて、同じ素材の束で試しました。個人開発の小さな運用ですので、評価は私自身の目視です。
聞き方 プロンプトの形 起きたこと 向いている場面
確認型 「◯◯で合っていますか」 ほぼ同意。外れたタグにも理由を添えて頷く 答えの候補が決まっていない文章の校正(後述)
反論指示型 「このタグの誤りを探してください」 正しいタグにも異論を作る。今度は反対側に寄る 見落としの洗い出しだけが目的のとき
伏せ型 候補だけ渡して分類させ、私のタグとは手元で比べる 一致・不一致が素直に分かれる。一回あたりの出力はやや長い 候補が有限で、正解が一つに決まる分類
意外だったのは反論指示型です。「誤りを探して」と頼むと、探すこと自体が課題になるため、正しいタグにも「背景を重く見れば名所絵とも言えます」といった異論が付きます。同意の偏りを、逆向きの偏りに付け替えただけでした。
伏せ型は、モデルに一から分類をやり直してもらうぶん出力が長くなります。それでも、人が見るべき作品が「食い違った分」に絞られるので、全体の手間はむしろ軽くなりました。私は分類には伏せ型を採り、確認型は別の用途に残すことにしました。
答えを伏せたまま分類してもらうコード
ここからは、実際に置き換えたコードの骨格です。Python と google-genai SDK を使います。目的は一つで、私のタグを一文字も渡さずに、候補の中から選んでもらうこと です。
import hashlib
import io
import json
import random
from pathlib import Path
from google import genai
from google.genai import types
from PIL import Image
# 新規プロジェクトで選べるモデルは人によって違うため、models.list で先に確かめる
MODEL = "gemini-3.8-flash"
CATEGORIES = [ "名所絵" , "美人画" , "役者絵" , "花鳥画" , "武者絵" , "戯画" , "その他" ]
client = genai.Client() # GEMINI_API_KEY を環境変数から読む
def build_schema (categories: list[ str ]) -> types.Schema:
"""観察 → 分類 → 次点 → 自信 の順に書かせる。順番そのものが設計の一部"""
return types.Schema(
type = types.Type. OBJECT ,
properties = {
"observations" : types.Schema(
type = types.Type. STRING ,
description = "画面に見えるもの(人物・背景・持ち物・文字)。分類名は書かない" ,
),
"category" : types.Schema( type = types.Type. STRING , enum = categories),
"runner_up" : types.Schema( type = types.Type. STRING , enum = categories),
"confidence" : types.Schema( type = types.Type. NUMBER ),
},
required = [ "observations" , "category" , "runner_up" , "confidence" ],
property_ordering = [ "observations" , "category" , "runner_up" , "confidence" ],
)
def blind_bytes (path: Path) -> tuple[ str , bytes ]:
"""再エンコードでメタデータを落とし、ファイル名の代わりにハッシュで追跡する"""
img = Image.open(path).convert( "RGB" )
buf = io.BytesIO()
img.save(buf, format = "JPEG" , quality = 90 ) # EXIF や IPTC のキャプションはここで消える
data = buf.getvalue()
return hashlib.sha256(data).hexdigest()[: 12 ], data
def classify_blind (path: Path, seed: int ) -> dict :
token, data = blind_bytes(path)
cats = CATEGORIES [:]
random.Random(seed).shuffle(cats) # 候補の先頭に寄る癖を、呼び出しごとに散らす
prompt = (
"この浮世絵の主題を、次の候補から一つだけ選んでください。"
f "候補: { '、' .join(cats) } 。"
"先に画面に見えるものを書き、そのあとで選んでください。"
)
resp = client.models.generate_content(
model = MODEL ,
contents = [types.Part.from_bytes( data = data, mime_type = "image/jpeg" ), prompt],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = build_schema(cats),
temperature = 0.2 ,
),
)
result = json.loads(resp.text)
result[ "token" ] = token
result[ "model_version" ] = resp.model_version # 返ってきた実体も残しておく
return result
なぜこう書くのかを、三点だけ補います。
一つめは property_ordering です。Gemini の構造化出力は、スキーマで指定した順にプロパティを書いていきます。observations を category より前に置くと、見えたものを書き出してから選ぶ流れになり、選んだあとに理由を後付けする形を避けられます。
二つめは候補のシャッフルです。候補の並びは毎回同じだと、迷ったときに先頭寄りを選ぶ傾向がそのまま結果に混ざります。シードを変えて並びを散らすと、その傾向がどの候補にも均等に薄まります。
三つめは blind_bytes です。これは次の節の話につながります。
伏せたつもりでも、答えは三つの経路から漏れていました
伏せ型に替えた最初の晩、一致率が妙に高く出ました。喜ぶ前に入力を読み返すと、私のタグが三つの経路から相手に届いておりました。
ファイル名 。追跡のためにプロンプトへ ファイル: meisho_0412.jpg と書き添えていました。接頭辞がそのまま答えです
画像のメタデータ 。修復作業の途中で、画像のキャプション欄に主題のメモを残していた素材がありました。SDK に渡す前に再エンコードして落とすまで、私はそれに気づいていませんでした
前置きの一文 。「今回の束は名所絵が多めです」と親切心で書いた一文が、迷った作品をまとめて名所絵へ寄せていました
どれも、確認型のときには問題にならなかった経路です。伏せ型を運用に入れる前の落とし穴として、この三つだけは先に回避しておくことをお勧めします。答えを伏せる設計に変えると、伏せたはずの答えがどこから入り込むか を、入力の側で点検する責任が生まれるのだと思います。
私はその点検を、送る直前の関数に一本化しました。
import re
def assert_no_leak (prompt: str , my_label: str , categories: list[ str ]) -> None :
"""候補一覧の外で、自分のタグやファイル名の手がかりが出ていないかを確かめる"""
head = prompt.split( "候補:" )[ 0 ]
if my_label in head:
raise ValueError ( f "候補一覧の前に自分のタグが出ています: { my_label } " )
if re.search( r " [ A-Za-z_ ] + _ \d + \. ( jpe ? g | png ) " , prompt):
raise ValueError ( "ファイル名らしき文字列が混ざっています" )
for word in ( "多め" , "ほとんど" , "大半" ):
if word in head:
raise ValueError ( f "束の傾向を伝える前置きが残っています: { word } " )
if sorted (categories) != sorted ( CATEGORIES ):
raise ValueError ( "候補の集合が正本と一致しません" )
classify_blind の中で prompt を組み立てた直後に、この関数を一度呼ぶだけです。例外で止まる方が、黙って高い一致率を出されるよりずっと扱いやすいと感じております。
食い違った作品だけを、人が見る
伏せたまま出してもらった答えと、私のタグを手元で突き合わせます。ここで大事にしたのは、モデルの自己申告の confidence をあまり信じないことです。数値はそれらしく並びますが、外れた作品でも高めの値が付くことがありました。
代わりに頼りにしたのは二つの手がかりです。次点に私のタグが入っているかどうか、そして候補の並びを変えて二回聞いたときに答えが揺れるかどうか、です。
import csv
from dataclasses import asdict, dataclass
@dataclass
class Verdict :
token: str
mine: str
blind: str
runner_up: str
stable: bool
route: str
def decide_route (mine: str , a: dict , b: dict ) -> Verdict:
stable = a[ "category" ] == b[ "category" ]
if a[ "category" ] != mine and a[ "runner_up" ] != mine:
route = "human" # 私のタグが上位二つに入っていない。まず私の側を疑う
elif a[ "category" ] != mine:
route = "boundary" # 次点には入っている。主題が二つにまたがる作品
elif not stable:
route = "sample" # 一致はしたが、並びを変えると答えが揺れる
else :
route = "pass"
return Verdict(a[ "token" ], mine, a[ "category" ], a[ "runner_up" ], stable, route)
def review_batch (items: list[tuple[Path, str ]], out_tsv: Path) -> None :
with out_tsv.open( "w" , newline = "" , encoding = "utf-8" ) as f:
writer = csv.DictWriter(f, fieldnames = list (Verdict.__dataclass_fields__), delimiter = " \t " )
writer.writeheader()
for path, mine in items:
first = classify_blind(path, seed = 1 )
# 一回目で食い違えば、揺れを確かめるまでもなく人が見る。二回目は一致したときだけ
second = classify_blind(path, seed = 2 ) if first[ "category" ] == mine else first
writer.writerow(asdict(decide_route(mine, first, second)))
二回目の呼び出しを「一回目が一致したときだけ」に絞っているのは、本番運用で呼び出し回数がそのまま二倍になるのを避けるためです。食い違った作品はどのみち人が見ますので、揺れを確かめる意味があるのは一致した作品だけなのです。
route が pass の行は、私はもう開きません。human の行は画像を横に並べて見直し、boundary の行は主題タグを二つ付けてよいかを考えます。sample は束ごとに数枚だけ抜き取って目を通します。
見る枚数が減ったこと以上に助かったのは、見る理由が行ごとに書いてあることでした。「なぜこの一枚を開いているのか」が分かっていると、判断が速くなります。
この振り分けで気づいた、ドキュメントからは読み取れなかったことが一つあります。human に落ちた作品の多くは、モデルではなく私の付け間違いでした。確認型で聞いていたあいだは、その付け間違いまで一緒に頷いてもらっていたことになります。
それでも、確認型を残した場面があります
伏せ型は、候補が有限で正解が一つに決まる作業に向いています。一方で、答えの形が決まっていない作業には使えません。たとえばストアの説明文を英語に直したとき、「正しい訳」は一つではありませんので、伏せて訳し直してもらっても突き合わせる基準がありません。
そこで説明文の見直しには、確認型を少しだけ変えて残しました。
REVIEW_PROMPT = """次の英語の説明文を、アプリを初めて見る読者の立場で読んでください。
誤解されそうな箇所を最大三つ挙げてください。
該当がなければ「なし」とだけ答えてください。
良い点や全体の感想は書かないでください。
---
{text}
"""
def review_copy (text: str ) -> str :
resp = client.models.generate_content(
model = MODEL ,
contents = REVIEW_PROMPT .format( text = text),
config = types.GenerateContentConfig( temperature = 0.2 ),
)
return resp.text.strip()
「合っていますか」ではなく「誤解されそうな箇所を最大三つ」と聞き、同時に「なし」と答える許可をはっきり渡します。許可がないと三つを埋めようとして、反論指示型と同じ偏りに戻るのです。上限と逃げ道を両方置くことで、同意にも反論にも寄りすぎない位置に落ち着きました。
伏せ型と確認型は、どちらかを捨てる関係ではないのだと思います。答えが一つに決まる作業では答えを伏せ、決まらない作業では上限と「なし」の逃げ道を渡す ——私はいま、この二本立てで使い分けております。
同じ分類パイプラインでモデルを並行運用したときの記録は、Gemini 3 Pro と 2.5 Pro を壁紙カテゴリ分類に並行投入した 3週間の実装メモ に残しています。
最初の一歩
手元で試すなら、次の三つの手順で足ります。
すでにラベルを付け終えたデータを、二十件ほど選びます
そのラベルを一切渡さずに review_batch を流し、送る前に assert_no_leak が一度も止まらないことを確かめます
出てきた TSV のうち、route が human の行だけを開きます
そこに並ぶのがモデルの誤りなのか、ご自身の付け間違いなのか——その比率を一度見ておくと、これから確認を頼むときの聞き方が、自然と変わってくるはずです。私もその二十件から始めました。