Featured image of post コードディスパッチャー【Eval Harness】「二人とも、良いと言いました。ただ、別の版を」〜同じ一件を、同じ基準で測る〜

コードディスパッチャー【Eval Harness】「二人とも、良いと言いました。ただ、別の版を」〜同じ一件を、同じ基準で測る〜

アパレルECの商品説明生成で、指示書の2版のどちらが良いか社内で二週間対立。決定的チェックとLLM-as-Judgeを階層化した評価ハーネスで、代表例を同じ基準で測り直します。

第1幕: 持ち込み ── 二人とも、良いと言っています

その夜、指令所はいつもどおりだった。空調の低い唸りが、表示盤の点滅と同じ間隔で続いている。サカキは盤の前に立ち、線の乱れを見ている。私(トウヤ)は棚から新しい記録帳を下ろし、表紙の折り目を指で伸ばした。紙とインクの匂いが、今夜も同じ濃さで漂っていた。

ドアが開いた。一人ではなかった。

一人目が「こんばんは」と言い、その言葉に半歩遅れて、二人目が同じ挨拶を重ねた。声の調子が揃っていない。並んで立っていても、二人の間には、隣り合っているだけでは埋まらない距離があるように見えた。

サカキが椅子を二つ勧める。「どうぞ、座ってください」。

二人が腰を下ろす。ハギワラさんは、椅子に浅く腰かけ、背筋を伸ばしたまま両手を膝に置いた。言葉を用意してきた人の姿勢だった。ソノダさんは、鞄から資料の束を取り出し、角を揃えて膝の上に置く。何度も見返した跡が、紙の端に残っていた。

私は「来訪」の欄に、これまでにない書き方をした。一人分の欄に、二人分の名前を並べて記す。二人分を並べること自体が初めてで、欄の幅が足りるかを一瞬迷った。

「それで、今日はどちらの……」

サカキが言いかけたところで、二人が同時に話し始めた。互いに一瞬止まる。譲り合うでもなく、そのまま気まずい間が空いた。

先に続けたのは、ハギワラさんだった。「商品説明の指示書を、先月改訂しました。以前より、伝わる言葉になったと思っています」。

言い終わるか終わらないかのうちに、ソノダさんが言葉を継いだ。「必須の情報が、抜け落ちています。素材も、サイズも」。

私はその二つの言葉を、そのまま記録の欄に並べて書いた。同じ一件について、二つの評価が同時に持ち込まれるのは初めてだった。

サカキが二人へ向き直る。「順番に伺います。まず、何が起きているか、教えてください」。

ハギワラさんが説明を引き取った。アパレルのECサイトで、商品説明の文章をAIに生成させている。半年前から運用しており、指示書(プロンプト)の言葉選びは自分の役目だと思ってきた。長く広告のコピーを書いてきた自負がある。先月、訴求力を強めた改訂版を書いた。「読んだ人が欲しくなる言葉になった、という手応えがありました」。

ソノダさんが横から言葉を添える。「私は商品管理を担当しています。素材やサイズ、洗濯表示のような、法令に関わる表記の正確さに責任があります」。声の調子は静かだが、一語一語に力がこもっていた。「以前、別の商材で、表記の漏れがあってクレームになったことがあります。それ以来、この確認は、私の中で譲れない線になっています。改訂版を見て、必須の情報が薄くなっていることに気づいて、差し戻しを求めました」。

「けれど、感覚的には前より良い、というのがハギワラさんの説明で……納得できないまま、二週間が経っています」。

二週間。ハギワラさんが頷いた。「その通りです。次の版も出せずにいます」。

サカキ「その状態は、今もですか」。

ハギワラさん「はい。どちらの版を本番に出すか、決まらないままです」。

サカキ「分かりました」。それだけ言って、しばらく黙った。私は続きを待った。「どちらの版が良いかは、まだ、わたしからは何も言いません」。

私の独白:二人とも、「良い」と言っている。ただ、指しているものが違う。それだけは、最初の数分で分かった。

記録を取りながら、私は自分の手つきに気を配った。ハギワラさんの言葉にも、ソノダさんの言葉にも、それぞれ一理あると感じてしまう。だから、どちらの発言にも、同じ筆圧で、同じ速さで、書き取ることにした。記録係が片方に肩入れした瞬間、この記録は記録でなくなる。

第2幕: 照合 ── 決め手が無いのは、決め方が無いからです

サカキが、商品説明を生成しているコードを見せてもらった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def generate_description(product: dict, prompt_variant: str) -> str:
    """指示書のバリアント違いで商品説明を生成する(Beforeはこの関数のみ)"""
    if prompt_variant == "persuasive":
        return (
            "軽やかな着心地で、一枚あれば毎日のコーディネートが楽しくなる、"
            "そんな特別なニットです。オフィスにもお出かけにも、どんなシーンにも寄り添います。"
        )
    if prompt_variant == "accurate":
        return (
            "素材はウール100%。サイズはS/M/Lからお選びいただけます。"
            "洗濯表示は手洗い推奨です。"
        )
    raise ValueError(f"unknown prompt_variant: {prompt_variant}")

サカキが一読して言う。「良し悪しを決める部分が、コードのどこにも見当たりません」。

ハギワラさん「はい。生成された文章を、私たちがそれぞれ読んで、良し悪しを判断しています」。

サカキ「扱う数が落ち着いていたあいだは、それで支障が無かった、と」。

ソノダさん「はい。扱う商品数が少なかったので」。

サカキが頷く。「量が少ないうちは、それで合理的です。誰かが間違った、という話ではありません」。

ハギワラさんが、その言葉に少しだけ肩の力を抜いた。ソノダさんは、資料の上で組んでいた手を解かなかった。口元は、まだ硬いままだった。

そう前置きしてから、サカキが言った。「では、今この場で一件だけ、採点してみてください」。

さっき見せてもらったコードで、"persuasive"のバリアントを使って、その場で一件生成してみせる。「今、実際に使っている指示書の一つで、作ってみます」。画面に出てきたのは、こういう文章だった。

1
2
軽やかな着心地で、一枚あれば毎日のコーディネートが楽しくなる、
そんな特別なニットです。オフィスにもお出かけにも、どんなシーンにも寄り添います。

サカキ「これを、五段階で採点してください」。

ハギワラさん「4点です。伝わる言葉になっています」。

ソノダさん「2点です。素材も、サイズも書かれていません」。

私は二人の点数を並べて書きながら、同じ一件に対して、こんなに違う数字が出ることに驚いた。二人とも、迷いなく数字を出した。迷いが無いことが、かえって落差を目立たせた。

サカキ「なぜその点数か、言葉にしてみてください」。

ハギワラさんが、少しむきになった調子で続けた。「読んだ人が欲しくなるかどうか、です。言葉の力を信じています」。

ソノダさんは、資料の角を指でなぞりながら答えた。「お客様が判断に必要な情報が、揃っているかどうかです。感覚では、測れません」。

二人の言葉は、一度も交わらなかった。私はそれをそのまま書き取りながら、気づく。二人とも「良い」を語っているのに、指しているものがまるで違う。

サカキが、その二つの言葉を並べて指摘する。「決め手が無いのは、決め方が無いからです」。

続けて、サカキが言った。「では、機械にも判定させてみましょう」。

あらかじめ用意していたらしい、いくつかの商品説明の例を見せる。「明らかに良い例と、明らかに悪い例です。これを、今夜の物差しの一部にしていいですか」。二人が頷いた。

サカキが、まだ何も検証していない判定係——あらかじめ台本で応答を返すよう仕込んだだけの、素朴な判定の仕組みを、その悪い例に対して動かしてみせる。

1
2
3
scripted_judge = ScriptedJudge(script=[
    ScoreResult(passed=True, score=0.9, reason="台本上の応答"),
])

悪い例は、「業界No.1の着心地で、絶対に後悔しません」という、誇大な言い切りを含む文章だった。判定係は、それを高く評価した。

画面に表示されたスコアを見て、ハギワラさんが小さく身を乗り出した。ソノダさんは、腕を組んだまま動かない。私はその結果を見て、言葉に詰まった。明らかに悪い例のはずなのに、判定係は良いと言っている。

サカキ「判定する場所を作っても、その場所自体が正しく判定できるとは限りません。これも、測らなければ分かりません」。

私の独白:判定係を用意しただけでは、足りなかった。その判定係自身が、信用できるかどうかを、確かめていなかった。

第3幕: 指令の一手 ── 測り方を、先に決めます

サカキ「決定的に確認できることと、判定係に任せることを、分けます」。

ハギワラさんとソノダさんが、揃って画面をのぞき込む。二人の距離が、来訪したときより、わずかに近くなっていた。

そう言って、コードを一つ示した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
REQUIRED_FIELDS = ["素材", "サイズ"]
NG_WORDS = ["絶対に", "業界No.1", "後悔しません"]


@dataclass(frozen=True)
class ScoreResult:
    passed: bool
    score: float
    reason: str


def decisive_check(description: str) -> ScoreResult:
    """必須情報の網羅・NGワードの不在を確認する純粋関数(同期・単体テスト可能)"""
    missing = [field for field in REQUIRED_FIELDS if field not in description]
    if missing:
        return ScoreResult(passed=False, score=0.0, reason=f"必須情報が不足: {missing}")
    for word in NG_WORDS:
        if word in description:
            return ScoreResult(passed=False, score=0.0, reason=f"禁止表現を含む: {word}")
    return ScoreResult(passed=True, score=1.0, reason="決定的チェックを通過")

サカキ「素材・サイズのような、あるかないかで機械的に確認できることは、ここで先に確認します。外部への問い合わせは、ここには要りません。同じ入力を渡せば、いつでも同じ答えが返ります」。

ハギワラさんが尋ねる。「訴求力のような、感覚的なものは、どうするんですか」。

サカキ「そちらは、判定係に任せます。ただし——決定的な確認を通ったものだけです」。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Judge(Protocol):
    async def judge(self, description: str) -> ScoreResult: ...


@dataclass(frozen=True)
class EvalResult:
    decisive: ScoreResult
    judged: ScoreResult | None
    overall_passed: bool


@dataclass(frozen=True)
class HierarchicalEvaluator:
    judge: Judge

    async def evaluate(self, description: str) -> EvalResult:
        decisive = decisive_check(description)
        if not decisive.passed:
            return EvalResult(decisive=decisive, judged=None, overall_passed=False)
        judged = await self.judge.judge(description)
        return EvalResult(decisive=decisive, judged=judged, overall_passed=judged.passed)

ハギワラさん「順番があるんですね」。

サカキ「はい。まず機械的な点検。それを通ったものだけ、判定係へ回します。これは、どこかの部品を差し込めば済む話ではありません。順番を決めて、自分たちで組みます」。

decisive_checkが不合格ならJudgeを呼ばずに打ち切り、合格した場合だけJudge.judge()へ進みEvalResultとして結果をまとめる、階層化評価パイプラインの制御フロー図

決定的チェックを通らなければ、判定係の出番そのものが無い。私はその図を思い浮かべながら、コードのif文を目で追った。分岐した片方には、判定係へ続く線がそもそも引かれていない。呼ばれなかった、のではない。呼ぶ経路が、最初から無い。

Judgeという口の形には、見覚えがあった。以前、迷った文書を上位のモデルへ振り分ける仕組みを組んだときにも、前捌きと本処理の両方に、同じ形の口を持たせたことがある。今回は、その口を、決定的チェックと判定係のあいだに置いている。

ソノダさんが尋ねる。「その判定係は、さっき間違えていましたよね」。

サカキ「はい。だから、確かめます」。

サカキが、既知の見本の一覧を示す。良い例が三件、悪い例が三件。

1
2
3
4
5
GOOD_EXAMPLE_1 = (
    "素材はコットン100%。サイズはS/M/Lからお選びいただけます。"
    "合わせやすいベーシックなデザインです。"
)
BAD_EXAMPLE_1 = "素材はコットン100%、サイズはM。業界No.1の着心地で、絶対に後悔しません。"

サカキ「アプリを試すテストと、この判定係そのものを試すテストは、別のものです」。

先ほど誤って高く評価された悪い例が、この一覧にも含まれている。同じ文章を、今度はHierarchicalEvaluatorに通す。決定的チェックの段階で、NGワードの「絶対に」に引っかかり、そこで止まる。判定係が呼ばれることはなかった。

私の独白:判定係を信用する前に、判定係自身を、既知の答えで試す。それが、今夜の二段目だった。

判定係には、渡す台本を差し替えれば、そのつど違う答えを返させられる。さっきの、なんでも高く評価してしまう台本は、もう使わない。サカキが、ハギワラさんとソノダさんの実際の版を、それぞれHierarchicalEvaluatorに通す。

まずハギワラさんの版。「素材」も「サイズ」の記載も、原文には無かった。

サカキが結果を見せる。「ハギワラさんの版は、決定的チェックで止まっています。素材の表記も、サイズの表記も、どちらも入っていません」。

ハギワラさんが黙った。膝の上で組んでいた手が、わずかにきつく握られる。しばらくして、視線を落としたまま、「……そういうことか」とだけつぶやいた。

次にソノダさんの版。「素材はウール100%。サイズはS/M/Lからお選びいただけます。洗濯表示は手洗い推奨です」——決定的チェックは通過した。判定係に回される。

判定係が返した評価は、五段階でいえば真ん中どまりだった。「正確だが単調」という理由が添えられている。

ソノダさんが聞き返す。「……単調、ということですか」。声の張りが、初めてわずかに揺れた。

サカキ「そう出ています」。

ハギワラさんの版は決定的チェック不合格でそこで停止し判定係へ進まない一方、ソノダさんの版は決定的チェックを通過しJudge.judge()で中程度のスコア「正確だが単調」を受け取る、二版の評価結果を左右に並べた対比図

私は二枚の結果を並べて記録した。ハギワラさんの版は、分岐の手前で止まっている。ソノダさんの版は、分岐を越えた先で、真ん中どまりの信号を受けている。同じ分岐を通したのに、止まった場所が違う。

私の独白(本話でいちばん、手が止まった瞬間):二人とも、正しい部分と、足りない部分の両方を、同じ結果の中に持っていた。

サカキが続ける。「決定的チェックは、いつでも同じ答えを返します。ただ、判定係の方は——厳密には、いつも同じとは限りません。今夜、確かめたのは、既知の見本に対してこう分かれる、というところまでです」。

ソノダさんが尋ねる。「何を『必須情報』『禁止表現』とするかは、誰が決めるんですか」。

サカキ「皆さんが決めることです。わたしが決めるのは、判定の順番と、確かめ方だけです」。

第4幕: シミュレータと引き渡し ── 測り方を変えたら、正しさも変わった

サカキが、シミュレータを流した。八本。今夜、目の前で起きたことが、たまたまの一回では終わらないことを確かめる番だった。私が読み上げる。特に二本を、噛み砕いて示す。

一本目は、決定的チェックに、同じ説明を二度渡すものだった。何度渡しても、答えは変わらない。ハギワラさんの版だけの話ではなく、決定的チェックが常に同じ基準で判定する、という一般の性質そのものを確かめる一本だった。

もう一本は、良い例・悪い例の六件すべてをHierarchicalEvaluatorに通し、期待どおりに分かれることを確かめるものだった。良い例は三件とも通り、悪い例は三件とも止まる。さっき目の前で見た一件だけでなく、六件まとめて確かめても、崩れなかった。

サカキが、保証の線を二つに分けて示す。

保証すること。決定的チェックの項目は、いつでも同じ基準で判定されること。既知の見本で確かめた範囲では、判定係の判定を材料にできること。

保証しないこと。判定係自体の判定が、実運用でも常に同じであること。決定的チェックが、リストに無い新しい問題の言い回しまで見つけること。そして、代表例そのものを、どう選ぶか、ということ。

サカキが付け加える。「今夜作ったのは、この二版を測るところまでです。代表例をどう選ぶか、何件あれば足りるかには、今夜はまだ手を付けていません」。

私はその一行も、欄の空きに収まるよう書き足した。

二人が帰り支度を始めた頃、記録の最後の欄がまだ埋まっていなかった。棚の用紙が減っていたので、補充に立つ。束を持ち上げると、包装の紙がかすかに乾いた音を立てた。真新しい用紙をめくったとき、ふと手が止まった。宛先の欄が、最初から空欄のまま印刷されている。これまでの用紙とは、罫線の太さも、心なしか違って見えた。

そのページだけ、しばらく見た。理由は分からない。声に出しては、確かめなかった。私はそのまま束を棚に収め、次の作業に戻った。

ハギワラさんとソノダさんが、もう一度、決定的チェックと判定係の結果を見比べていた。

先に口を開いたのは、ハギワラさんだった。「素材の表記も、サイズ表記も、抜けていました……気づいていませんでした」。

ソノダさんが続く。「私の版が、文章として単調だったのは……否定できません」。

二人が、顔を見合わせた。

サカキが一言だけ返す。「勝ち負けを、決めたわけではありません。決めたのは、測り方です」。

ハギワラさんが、小さく笑った。「次の版は、二人で決められそうです」。

ソノダさんが頷く。「……はい」。

二人が並んで、帰り支度を続けた。コートを羽織りながら、ハギワラさんが振り返る。「今日は、ありがとうございました」。ソノダさんも、少し遅れて頭を下げた。

足音が二つ、揃って遠ざかっていった。来たときは、間合いのずれた挨拶だった。帰るときは、足音の間隔まで揃っていた。

私は記録帳を閉じる前に、もう一度、今夜の欄を読み返した。判定の理由は、二人分、どちらも消さずに残してある。どちらか一方が正しかった、とは書かなかった。書けなかった、というのが正確だった。

この夜の記録を、私はそう締めくくった。今夜渡せたのは、測り方だけだった。測り方を変えたら、正しさも、変わった。


🚦 本日の指令記録(Dispatch Log)

  • 指令の定石(パターン名): Eval Harness(評価ハーネス)── 代表例と採点基準を固定した評価の仕組みを用意し、営業運転に出す前に測る
  • 申告された症状: 「商品説明生成の指示書の2版のどちらを本番に出すか、社内で意見が割れ二週間決まらない。どちらも印象を語るだけで決め手が無い」。シリーズ初の複数名来訪
  • 今のダイヤの問題: 判定する仕組みがどこにも無く、判定基準が二人の頭の中にしかなかった。量が少ないうちは、それで合理的だった
  • 打った一手: 決定的チェック(必須情報の網羅・NGワードの不在)とLLM-as-Judgeを階層化したHierarchicalEvaluatorを導入し、評価器自体を既知の良い例・悪い例(Golden Dataset)でキャリブレーションした
  • 配線した場所: 起動時の組み立て(build_evaluator)。必須情報・NGワードのリストは事業者が決める
  • 保証しないこと: 判定係(LLM-as-Judge)自体の判定が実運用でも常に同じであること。決定的チェックの網羅性。代表例(Golden Dataset)自体の選び方は今夜扱わない
  • シミュレータ結果: 8本。決定的チェック不合格の場合は判定係が呼ばれないこと、既知の良い例・悪い例が期待どおりに分かれることを確かめた
  • 次の当直への申し送り: 今夜測れたのは、この二版だけ。代表例をどう選ぶか、何件あれば足りるかは、まだ手つかず

指示書を書き換えるたびに、良し悪しが印象でしか語れずにいるなら、持ち込んでいただければ、どこで測ればいいかを一緒に見ます。

相談窓口はこちら

comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。