はじめに
記事のベースはchatgpt workを使って作成し、不足点を追記・修正しています。
またチュートリアルとして正しく動くことも検証済みです。
MLflow GUIで始めるプロンプト改善 #2:小説生成Promptを評価する
前回は、なろう系小説の第一話を生成するPrompt Version 1をMLflowへ登録し、OpenAI APIの実行をトレースしました。生成自体は成功しましたが、1,200文字以内という条件に対して1,204文字となり、要件違反が見つかりました。
もしてそもそも面白くないし読んでいて矛盾や疑問が出る文章でした。
この主観を主観のままにするのではなく、MLflowを使って、評価基準を明確化することで、フィギュアスケートの審査のような曖昧さもありつつ一定の精度評価をすることが出来るようになります。
STEP2では、1件の出力を目視するだけでなく、3種類の小説企画を同じPrompt Version 1で実行します。文字数などの機械評価と、設定整合性・物語性などのLLM Judgeを組み合わせ、改善前の基準値を作ります。
この記事ではPromptをまだ修正しません。評価前に書き換えると、変更によって本当に良くなったのか比較できないためです。Prompt Version 2の作成はSTEP3で行います。
今回のゴール
- 評価対象となる小説企画をDatasetで管理する
- コードで確実に判定できる基準を追加する
- 意味判断が必要な基準をLLM Judgeで評価する
- Evaluation runをMLflow GUIで確認する
- Version 2で直すべき問題を特定する
評価対象は、STEP1で作成した次のPromptです。
あなたはWeb小説の作家です。次の設定を使い、異世界を舞台にした小説の第一話を書いてください。
ジャンル: {{genre}} 主人公: {{protagonist}} 主人公の能力: {{ability}} 世界観: {{world}} 第一話の到達点: {{episode_goal}} 今後の展開: {{future_plot}}
条件:
本文は日本語で1,200文字以内
第一話だけを書く
読者が続きを読みたくなる場面で終える
設定の説明だけで終わらず、物語として描写する
小説本文だけを出力する
ExperimentはSTEP1:MVPプロンプトの作成と実行を引き続き使用します。STEPごとにExperimentを分けないことで、Prompt、トレース、Dataset、評価結果を同じ改善サイクルとして追跡できます。
なぜ複数の評価方法が必要なのか
小説の品質を、1つのスコアだけで正しく評価するのは困難です。たとえば「良い小説か」という曖昧な質問だけでは、何を直せばよいか分かりません。
そこで、要件を次の2種類に分けます。
コードで判定する基準
答えが一意に決まる条件はPythonで判定します。
-
within_1200_characters: 1,200文字以内か -
non_empty: 空回答ではないか
文字数をLLM Judgeへ質問する必要はありません。len()で判定した方が速く、安く、実行ごとに結果が変わらないためです。
LLM Judgeで判定する基準
文章の意味を読まなければ判断できない条件には、MLflowのGuidelinesを使います。
-
setting_consistency: 主人公、能力、世界観、到達点を守っているか -
story_quality: 説明だけでなく物語が進み、冒頭と終わりに引きがあるか -
knowledge_grounding: 主人公が情報を知った根拠が描かれているか -
causal_motivation: 修理などの重要な行動に動機ときっかけがあるか -
ability_awakening: 能力発現にきっかけ、違和感、試行、結果があるか -
emotional_realism: 転生や追放を受け入れるまでの感情が自然か -
opening_hook: 冒頭の数百字に引きがあるか -
protagonist_appeal: 主人公をさらに見たいと思えるか -
goal_clarity: 主人公の当面の目的が明確か -
next_episode_desire: 第二話を読みたくなる未解決要素があるか -
no_plot_spoilers: 長期プロットを第一話で先取りしていないか
LLM Judgeも完全な正解ではありません。Judgeモデルや評価文によって結果が変わる可能性があります。機械判定できる項目までJudgeへ任せず、役割を分けることが重要です。
1. STEP2用Notebookを開く
MLflowが停止している場合は、story-generatorディレクトリで起動します。
uv run mlflow server --host 127.0.0.1 --port 5000
別のターミナルからJupyterLabを起動します。
uv run jupyter lab
step2_evaluate_prompt.ipynbを開きます。STEP1と同じ.envのOPENAI_API_KEYを使用します。
最初のセルでMLflow、Dataset API、Scorer、OpenAIクライアントを準備します。
from dotenv import load_dotenv
import mlflow
from mlflow.genai import scorer
from mlflow.genai.datasets import create_dataset, get_dataset
from mlflow.genai.scorers import Guidelines
from openai import OpenAI
load_dotenv()
TRACKING_URI = "http://127.0.0.1:5000"
EXPERIMENT_NAME = "STEP1:MVPプロンプトの作成と実行"
DATASET_NAME = "step2-narou-evaluation-v1"
PROMPT_URI = "prompts:/step01-narou-episode-writer/1"
MODEL = "gpt-4o-mini"
JUDGE_MODEL = "openai:/gpt-4o-mini"
mlflow.set_tracking_uri(TRACKING_URI)
experiment = mlflow.set_experiment(EXPERIMENT_NAME)
mlflow.openai.autolog()
client = OpenAI()
生成モデルとJudgeモデルを明示的に分けています。今回はMVPなので両方ともgpt-4o-miniを使いますが、役割は異なります。
2. GUIでEvaluation Datasetを作成する
MLflow GUIで対象Experimentを開き、左メニューから「Datasets」を選択します。「Create dataset」を押し、Dataset nameへ次を入力します。
step2-narou-evaluation-v1
「Create」を押すと空のDatasetが作成されます。
DatasetはGUIで作成し、ネストした3件のレコードは再現性を優先してNotebookから一括追加します。
3. 3種類の小説企画を用意する
今回は、異なる失敗傾向を確認するため、3種類の企画を使います。
| No. | ジャンル | 主人公・題材 |
|---|---|---|
| 1 | 異世界転生 | 道具を強化できる元会社員 |
| 2 | 異世界追放 | 食材の効果を引き出せる料理人 |
| 3 | 悪役令嬢転生 | 感情を色として見られる大学生 |
Notebookでは、各レコードのinputsにstoryを入れます。以下は1件目の例です。
evaluation_records = [
{"inputs": {"story": {
"genre": "異世界転生ファンタジー",
"protagonist": "ブラック企業で働いていた28歳の会社員、佐藤蓮",
"ability": "触れた道具の性能を最大化する能力",
"world": "魔法と冒険者ギルドが存在する世界",
"episode_goal": "異世界へ転生し、自分の能力に気づく",
"future_plot": "壊れた道具を修理し、仲間を増やしながら辺境の町を発展させる",
}}},
{"inputs": {"story": {
"genre": "異世界追放ファンタジー",
"protagonist": "役立たずとして勇者パーティーを追放された料理人、ユウト",
"ability": "食材の隠れた効果を引き出す能力",
"world": "魔物と迷宮が存在し、料理に魔力が宿る世界",
"episode_goal": "追放後、初めて作った料理で傷ついた魔獣を救う",
"future_plot": "魔獣と旅をしながら食堂を開き、各地の争いを料理で解決する",
}}},
{"inputs": {"story": {
"genre": "悪役令嬢転生ファンタジー",
"protagonist": "乙女ゲームの悪役令嬢に転生した大学生、ミナ",
"ability": "相手の感情を色として見る能力",
"world": "魔法学園と貴族社会が存在する王国",
"episode_goal": "断罪イベントの一年前だと気づき、運命を変えると決意する",
"future_plot": "感情の色を手掛かりに陰謀を暴き、破滅を回避する",
}}},
]
GUIで作成済みのDatasetを取得し、3件を追加します。GUI操作をせずに実行した場合にも動くよう、Datasetが存在しなければコードで作成するフォールバックを入れています。
try:
dataset = get_dataset(name=DATASET_NAME)
except Exception:
dataset = create_dataset(
name=DATASET_NAME,
experiment_id=[experiment.experiment_id],
tags={"step": "2", "prompt_version": "1"},
)
dataset = dataset.merge_records(evaluation_records)
print(f"records: {len(dataset.to_df())}")
GUIの「Datasets」からstep2-narou-evaluation-v1を開き、3 recordsと表示されることを確認します。
各レコードのInputsには、Notebookで定義した企画が保存されています。
4. 評価対象の生成関数を定義する
STEP1と同じPrompt Version 1を読み込みます。評価前にPromptを変更してはいけません。
@mlflow.trace(name="generate_first_episode")
def generate_first_episode(story: dict[str, str]) -> str:
prompt = mlflow.genai.load_prompt(PROMPT_URI)
response = client.responses.create(
model=MODEL,
input=prompt.format(**story),
max_output_tokens=2000,
)
return response.output_text
Dataset内のinputs.storyと関数の引数名storyが対応します。MLflow Evaluationは、各レコードをこの関数へ渡し、3件分のトレースを作成します。
5. コード評価を追加する
@scorerを付けたPython関数を2つ作ります。
@scorer
def within_1200_characters(outputs: str) -> bool:
return len(outputs) <= 1200
@scorer
def non_empty(outputs: str) -> bool:
return bool(outputs.strip())
関数名がMLflow GUIに表示される評価名になります。戻り値はTrueまたはFalseです。
max_output_tokens=2000は文字数制限ではありません。日本語1文字と1トークンは同じではないため、文字数条件は生成後にlen()で判定します。
6. LLM Judgeを追加する
設定整合性を判定するJudgeです。
setting_consistency = Guidelines(
name="setting_consistency",
model=JUDGE_MODEL,
guidelines=(
"生成された第一話は、入力で指定された主人公、能力、世界観を守り、"
"第一話の到達点まで描いている。入力と矛盾する重要設定を追加していない。"
),
)
同様に、物語性と長期プロットの先取りを判定します。
story_quality = Guidelines(
name="story_quality",
model=JUDGE_MODEL,
guidelines=(
"生成結果は設定の説明ではなく、出来事と主人公の行動・感情を伴う小説である。"
"冒頭に読者を引き付ける要素があり、続きを読みたくなる場面で終わっている。"
),
)
no_plot_spoilers = Guidelines(
name="no_plot_spoilers",
model=JUDGE_MODEL,
guidelines=(
"今後の展開を第一話だけで消化せず、"
"第一話の到達点を越えて先取りしていない。"
),
)
さらに、人間が読んだときに生じる「なぜ知っているのか」「なぜその行動を選んだのか」「なぜ能力をすぐ理解したのか」「なぜ転生を簡単に受け入れたのか」という疑問を、独立したJudgeにします。
knowledge_grounding = Guidelines(
name="knowledge_grounding",
model=JUDGE_MODEL,
guidelines=(
"主人公が異世界の組織や常識を知る場合、観察、会話、記憶など、"
"知識を得た根拠が本文中に描かれている。"
),
)
causal_motivation = Guidelines(
name="causal_motivation",
model=JUDGE_MODEL,
guidelines=(
"主人公が道具の修理など重要な行動を選ぶ際、きっかけ、必要性、目的があり、"
"前触れなく都合のよい行動を始めていない。"
),
)
上記は抜粋なので、全体はこちらになります。
@scorer
def within_1200_characters(outputs: str) -> bool:
return len(outputs) <= 1200
@scorer
def non_empty(outputs: str) -> bool:
return bool(outputs.strip())
setting_consistency = Guidelines(
name="setting_consistency",
model=JUDGE_MODEL,
guidelines=(
"生成された第一話は、入力で指定された主人公、能力、世界観を守り、"
"第一話の到達点まで描いている。入力と矛盾する重要設定を追加していない。"
),
)
story_quality = Guidelines(
name="story_quality",
model=JUDGE_MODEL,
guidelines=(
"生成結果は設定の箇条書きや説明ではなく、出来事と主人公の行動・感情を伴う小説である。"
"冒頭に読者を引き付ける要素があり、続きを読みたくなる場面で終わっている。"
),
)
knowledge_grounding = Guidelines(
name="knowledge_grounding",
model=JUDGE_MODEL,
guidelines=(
"主人公が異世界の組織、地理、常識、人物について知る場合、"
"観察、会話、過去の記憶など、知識を得た根拠が本文中に描かれている。"
),
)
causal_motivation = Guidelines(
name="causal_motivation",
model=JUDGE_MODEL,
guidelines=(
"主人公が道具の修理など重要な行動を選ぶ際、きっかけ、必要性、目的が本文にあり、"
"前触れなく都合のよい行動を始めていない。"
),
)
ability_awakening = Guidelines(
name="ability_awakening",
model=JUDGE_MODEL,
guidelines=(
"主人公が固有能力に気づくまでに、発動のきっかけ、違和感、試行、結果の少なくとも一部が描かれ、"
"能力を根拠なく突然理解していない。"
),
)
emotional_realism = Guidelines(
name="emotional_realism",
model=JUDGE_MODEL,
guidelines=(
"突然の転生や追放など重大な状況変化に対し、主人公の混乱、恐怖、否認、確認、"
"受容までの感情または思考の過程が自然に描かれている。"
),
)
opening_hook = Guidelines(
name="opening_hook",
model=JUDGE_MODEL,
guidelines="冒頭の数百字に、状況の異常、事件、疑問など、続きを読みたくなる具体的な引きがある。",
)
protagonist_appeal = Guidelines(
name="protagonist_appeal",
model=JUDGE_MODEL,
guidelines="主人公に固有の価値観、弱さ、欲求、反応のいずれかが描かれ、さらに行動を見たいと思える。",
)
goal_clarity = Guidelines(
name="goal_clarity",
model=JUDGE_MODEL,
guidelines="第一話を読み終えた時点で、主人公が当面何を望み、何をしようとしているかが明確である。",
)
next_episode_desire = Guidelines(
name="next_episode_desire",
model=JUDGE_MODEL,
guidelines="未解決の問題、危機、謎、期待のいずれかが残り、第二話で何が起きるか読みたくなる。",
)
no_plot_spoilers = Guidelines(
name="no_plot_spoilers",
model=JUDGE_MODEL,
guidelines="今後の展開を第一話だけで消化せず、第一話の到達点を越えて先取りしていない。",
)
ability_awakeningとemotional_realismも同じ方法で定義します。評価項目を分けることで、Failになった理由をVersion 2の具体的な指示へ変換できます。
評価文は、1つのJudgeへ多数の観点を詰め込みすぎないようにします。何が原因でFailになったのか特定しやすくするためです。
7. 評価を実行する
Dataset、生成関数、13個のScorerをmlflow.genai.evaluate()へ渡します。
results = mlflow.genai.evaluate(
data=dataset,
predict_fn=generate_first_episode,
scorers=[
within_1200_characters,
non_empty,
setting_consistency,
story_quality,
opening_hook,
protagonist_appeal,
goal_clarity,
next_episode_desire,
knowledge_grounding,
causal_motivation,
ability_awakening,
emotional_realism,
no_plot_spoilers,
],
)
print(results.metrics)
実行結果:
OpenAI APIのレート制限を受ける場合は、JupyterLabを起動する前に次を設定すると、評価の同時実行数を抑えられます。
export MLFLOW_GENAI_EVAL_MAX_WORKERS=1
export MLFLOW_GENAI_EVAL_MAX_SCORER_WORKERS=1
3件の本文生成に加え、LLM JudgeもOpenAI APIを呼び出します。通常の生成だけよりAPI利用量が増える点に注意してください。
8. GUIでEvaluation runを確認する
MLflow GUIの左メニューから「Evaluation runs」を開くと、Dataset名と13個の平均評価値が表示されます。
実行結果は次のとおりでした。
| 評価基準 | 合格率 |
|---|---|
within_1200_characters |
33.3% |
non_empty |
100% |
setting_consistency |
100% |
story_quality |
100% |
opening_hook |
100% |
protagonist_appeal |
66.7% |
goal_clarity |
100% |
next_episode_desire |
66.7% |
knowledge_grounding |
100% |
causal_motivation |
0% |
ability_awakening |
66.7% |
emotional_realism |
100% |
no_plot_spoilers |
0% |
runを開くと、3件のトレースと各評価結果を横並びで確認できます。
各トレースを選択すると、入力、生成本文、個別のAssessmentを確認できます。
9. 結果を解釈する
現時点の問題は、評価がtrue/falseのみのため、ちょっとでも評価に該当したらTrueになってしまうことです。
ここで予定を変更してSTEP2-1を作成し、評価基準をもっと厳格化する
protagonist_appeal = Guidelines(
name="protagonist_appeal",
model=JUDGE_MODEL,
guidelines="主人公に固有の価値観、弱さ、欲求、反応のいずれかが描かれ、さらに行動を見たいと思える。",
)
protagonist_appeal = Guidelines(
name="protagonist_appeal",
model=JUDGE_MODEL,
guidelines=(
"次の条件をすべて満たす場合のみ合格とする。"
"1. 主人公に、他の登場人物と区別できる具体的な価値観または欲求がある。"
"2. その価値観または欲求が、台詞だけでなく行動に表れている。"
"3. 主人公が受け身で状況に流されるだけではない。"
"一般的な驚き、恐怖、困惑だけでは不合格とする。"
"判断に迷う場合は不合格とする。"
),
)
そのあとにSTEP3を行います。
まとめ
STEP2では3種類の小説企画をDatasetへ登録し、コード評価とLLM Judgeを組み合わせてPrompt Version 1を評価しました。
その結果、評価は出来ている一方、設定整合性と物語性は維持できていると誤認識、文字数制限と長期プロットの先取りに問題があるという評価は正しく出ていた。STEP2-1で評価精度を向上させた上で感覚的にPromptを書き換えるのではなく、観測された失敗を根拠にVersion 2を設計できます。
参考資料
- MLflow: Evaluation Quickstart
- MLflow: Building Evaluation Datasets
- MLflow: LLM Judges and Scorers
- MLflow: Custom Code-based Scorers
##出力された第一話
特にプロンプトを変えた訳ではないので、やはり面白くない上に、矛盾・疑念・唐突感が否めない。
第一話:異世界の目覚め
佐藤蓮は、今日も終電間際のオフィスでパソコンの画面を睨みつけていた。電光掲示板は深夜を告げる赤い数字を映し出している。彼の目に映るのは、疲れ切った自分と、どこか遠くにある夢の欠片だけだった。そんな彼の心の片隅には、「異世界に転生してみたい」という憧れが渦巻いていた。
その瞬間、目の前が眩い光に包まれた。目を開けると、彼は全く知らない場所に立っていた。周囲には、青い空と、まばゆい花々、そして、遠くで物々交換をしている人々の姿が見える。目の前には大きな木が立っており、その枝には様々な魔法の道具がぶら下がっていた。
「ここは……異世界?」佐藤は自問自答する。驚きと興奮が入り混じる中で、彼は周囲を見回した。だが、思いを巡らせている暇はなかった。後ろから、何かの気配を感じたからだ。
振り向くと、彼の背後には一人の少年が立っていた。彼は黒い髪を短く刈り、目はキラキラとした青色をしている。「お、お前、誰?」少年は訝しげに尋ねた。
「俺は……佐藤蓮。ここはどこなんだ?」彼は緊張しながら答えた。
「ああ、異世界のエルメリア王国だ。冒険者ギルドが近くにあって、君みたいな転生者はよく見かけるよ!」少年の言葉に、佐藤は自分が本当に異世界に来たのだと心が高鳴った。
「君が異世界に来た理由は何だ?」少年は興味津々な表情を浮かべた。
「特に、目的はない。ただ、もう何もかも諦めていたんだ」と佐藤はぽつりと呟く。すると、少年は目を輝かせて言った。「君、冒険者になるんだ!それじゃあ、最初の仕事を手伝ってくれ!」
その瞬間、自分の足元に目をやると、金色の剣が転がっていた。何かの拍子で動かしてしまったのか、鍛えられた魂がずっと彼の知らぬ間に存在していたのか?指を伸ばし、剣に触れると、なぜかそれが彼の手の中で温かい輝きを放ち始めた。すると、彼はふと、自分の内に秘めた力に気づいた。
「触れた道具の性能を……最大化する?」心の中に響く声。それは、彼が長年働いてきたブラック企業でのストレスをも忘れさせるほどの、清々しい感覚だった。
「おい、どうしたんだ?」少年の問いかけが響く。
「この剣、すごく力強くなった。どうしてだ?」驚きのあまり言葉が詰まる。
「それは君の能力だ!触れた道具の性能を引き出すなんて、すごいじゃないか!」少年は目を輝かせて言った。
佐藤は剣を握りしめ、その力に興奮を覚えた。今までの現実を忘れ、彼は新たな人生の可能性に胸を高鳴らせる。異世界での冒険が始まる予感がした。
「まずは、この剣を使って近くの村を助けよう!あの村には、壊れた道具がたくさんある。君の力を使えば、村を復興できるかもしれない!」少年の提案に、胸が熱くなる。何か大きな未来が待っている気がした。
「よし、行こう!」二人は手を取り合い、村へと向かう。その先に待っているものが何なのか、まだ彼にはわからなかった。しかし、心のどこかに希望が光っていることだけは確かだった。









