はじめに
公式のMLFlowのチュートリアルがちょっとイケてなかったので、chatgpt workを使ってもっと簡単なチュートリアルを作成出来るか検証してみました。
結果からいうと、この段階なら動作確認しても、普通に再現出来ました。
workの良いところは、実際の画面を操作して画面キャプチャーもしてくれるので、かなり楽です。
ただし一部の画像はより適切なものを追加したり差し替えています。
生成AIのテーマとしては、せっかくなら、生成AIで面白いなろう系小説第一話を書けるのか?
という実験も兼ねています。
ここからchatgpt workで作成した記事
MLflow GUIで始めるプロンプト改善 #1:なろう系小説の第一話を生成してトレースする
生成AIのプロンプトは、一度書いて終わりではありません。実際に動かし、出力を評価し、問題の原因を考え、次のVersionへ改善する必要があります。
本連載では、MLflowのGUIを中心に「作成 → 実行 → 評価 → 改善 → 比較 → 運用」のサイクルを段階的に作ります。STEP1では、1,200文字以内の「なろう系小説・第一話」を生成するPrompt Version 1を登録し、OpenAI APIで実行して、結果をMLflowへトレースします。
Experiment作成、Prompt登録、トレース確認など、GUIでできる操作はGUIで行います。OpenAI APIの呼び出しにはコードが必要なので、その部分だけを最小のJupyter Notebookにします。
MLflowを何のために使うのか
Notebookへ生成結果を表示するだけでも、LLMを1回試すことはできます。しかし、試行を重ねると、次の情報を追うのが難しくなります。
- どのPrompt Versionを使ったか
- どの企画や設定を入力したか
- どのモデルから、どの文章が返ったか
- 変更前より品質が良くなったか
- トークン数、コスト、応答時間が増えていないか
MLflowの役割は、生成AI開発の試行錯誤を記録し、後から比較・評価できる状態にすることです。本連載では、主に次の機能を使います。
| MLflowの機能 | 本連載での役割 |
|---|---|
| Experiment | 同じ目的の試行とトレースをまとめる |
| Prompt Registry | プロンプトをVersion付きで保存する |
| Tracing | 入出力、処理時間、トークン数、コストを記録する |
| Datasets | 複数の小説企画を評価用データとして管理する |
| Judges / Evaluation | 共通の基準で生成結果を評価する |
STEP1ではExperiment、Prompt Registry、Tracingを接続します。STEP2以降でDatasetsとEvaluationを加え、「なんとなく面白い」ではなく、定義した基準と実行結果を根拠に改善します。
なぜ「なろう系小説の第一話」を題材にするのか
単純な要約プロンプトでは、文字数や情報保持以外の改善点が少なく、Prompt Versionを重ねる意味が見えにくくなります。小説生成なら、次のような複数の観点で失敗を発見できます。
- 1,200文字以内か
- 指定した主人公、能力、世界観を守っているか
- 第一話の到達点まで描かれているか
- 設定説明だけでなく、物語として進んでいるか
- 冒頭に読者を引き付ける要素があるか
- 続きを読みたくなる終わり方か
- 長期プロットを第一話で先取りしていないか
文字数はPythonで機械的に判定できます。一方、物語性や設定整合性には意味の判断が必要です。この違いをSTEP2の評価設計に利用します。
「なろう系」は厳密な文章形式ではありません。本連載では、異世界転生、主人公固有の能力、読みやすいWeb小説、次話への引きを持つ作品という作業上の定義で扱います。
全体プロットはPromptとRAGのどちらで渡すか
今回はRAGを使いません。第一話だけを作る段階では、参照する世界設定や過去話が少なく、検索する必要がないためです。
STEP1からRAGを入れると、出力が悪かった原因が「プロンプト」「企画」「検索結果」「チャンク分割」のどれなのか分かりにくくなります。まずPromptの効果を観測できる状態にし、設定資料や既出エピソードが増えた段階でRAGを導入します。
ただし、全体プロットをPrompt本文へ固定するのも避けます。Prompt Registryには執筆指示と変数を登録し、今回使用する企画をNotebookから変数として渡します。
Prompt Registry
├── 小説を書くための共通指示
├── 出力条件
└── {{genre}}や{{future_plot}}などの変数
Jupyter Notebook
└── 今回生成する作品の企画と全体プロット
この分離により、同じPrompt Versionへ複数の企画を渡して評価できます。特定作品だけに効くプロンプトではなく、異なる企画でも機能するプロンプトを目指せます。
今回作るもの
| 項目 | 値 |
|---|---|
| Experiment | STEP01:MVPプロンプトの作成と実行 |
| Prompt | step01-narou-episode-writer |
| Prompt Version | 1 |
| モデル | gpt-4o-mini |
| 出力 | 日本語の小説本文、1,200文字以内 |
| 題材 | 異世界転生ファンタジーの第一話 |
動作確認環境
| 項目 | バージョン |
|---|---|
| Python | 3.14.6 |
| MLflow | 3.15.1 |
| OpenAI Python SDK | 3.1.0 |
| パッケージ管理 | uv |
| Backend Store | SQLite (sqlite:///mlflow.db) |
LLMの出力は非決定的です。同じ設定を渡しても、記事と完全に同じ小説になるとは限りません。
1. uvでプロジェクトを作成する
ここでは、まだプロジェクトが存在しない状態から始めます。最初にPython 3.14.6をuvで用意します。
uv python install 3.14.6
続いてstory-generatorプロジェクトを作成し、作成されたディレクトリへ移動します。
uv init --python 3.14.6 story-generator
cd story-generator
uv initにより、pyproject.toml、.python-version、README.md、サンプルのmain.pyなどが生成されます。次に、今回コードから直接利用するパッケージを追加します。
uv add "mlflow==3.15.1" "openai==3.1.0" python-dotenv ipykernel jupyterlab ipywidgets
uv addは依存関係をpyproject.tomlへ記録し、解決結果をuv.lockへ保存したうえで、仮想環境.venvへインストールします。今回の最小構成は次のようになります。
story-generator/
├── .env
├── .env.example
├── .gitignore
├── .python-version
├── .venv/
├── pyproject.toml
├── uv.lock
└── step1_mvp_prompt.ipynb
pyproject.tomlには、少なくとも次の依存関係が記録されます。
[project]
requires-python = ">=3.14"
dependencies = [
"ipykernel>=7.3.0",
"jupyterlab>=4.6.3",
"mlflow==3.15.1",
"openai==3.1.0",
"python-dotenv>=1.2.1",
]
チームメンバーや別環境でuv.lockと同じ依存関係を再現する場合は、次を実行します。
uv sync --frozen
2. OpenAI APIキーを設定する
配布用の.env.exampleを作成します。
OPENAI_API_KEY=your-api-key
.env.exampleをコピーし、実際のAPIキーを設定します。
cp .env.example .env
.gitignoreへ、秘密情報とローカル実行データを追加します。
.env
.venv/
.ipynb_checkpoints/
mlflow.db
mlartifacts/
APIキーをNotebookへ直接書かないでください。また、.envをGitへコミットしないでください。
3. MLflowを起動する
プロジェクトのルートで実行します。
uv run mlflow server --host 127.0.0.1 --port 5000
今回の環境では、Backend Store URIを省略するとsqlite:///mlflow.dbが使われました。起動ログで次を確認します。
Backend store URI not provided. Using sqlite:///mlflow.db
Uvicorn running on http://127.0.0.1:5000
ブラウザでhttp://127.0.0.1:5000を開きます。
4. GUIでExperimentを作成する
左メニューから「Experiments」を開き、右上の「Create」を押します。「Experiment Name」へ次を入力します。
STEP01:MVPプロンプトの作成と実行
Artifact Locationは空欄のまま「Create」を押します。
Experimentは単なるフォルダーではありません。このあと生成するトレース、評価データ、評価結果を同じ改善サイクルとしてまとめる単位です。
5. GUIでPrompt Version 1を登録する
作成したExperimentを開き、左メニューの「Prompts」を選択します。「Create prompt」を押し、次の内容を入力します。
| 項目 | 値 |
|---|---|
| Name | step01-narou-episode-writer |
| Prompt type | Text |
| Commit message | STEP1の小説生成MVP |
Prompt本文は次のとおりです。
あなたはWeb小説の作家です。次の設定を使い、異世界を舞台にした小説の第一話を書いてください。
ジャンル: {{genre}}
主人公: {{protagonist}}
主人公の能力: {{ability}}
世界観: {{world}}
第一話の到達点: {{episode_goal}}
今後の展開: {{future_plot}}
条件:
- 本文は日本語で1,200文字以内
- 第一話だけを書く
- 読者が続きを読みたくなる場面で終える
- 設定の説明だけで終わらず、物語として描写する
- 小説本文だけを出力する
「Create」を押すとVersion 1が作成されます。登録後に、Prompt本文とCommit messageを確認します。
Version 1は、あえて完成度を上げすぎていません。冒頭の作り方、会話と地の文の割合、主人公の感情変化、場面構成などは曖昧なままです。実行結果を評価してから、必要な指示だけをVersion 2へ追加します。
6. Jupyter Notebookから実行する
別のターミナルでJupyterLabを起動します。
source .venv/bin/activate
uv run jupyter lab
step1_mvp_prompt.ipynbを開きます。最初のコードセルで接続先とトレースを設定します。
from dotenv import load_dotenv
import mlflow
from openai import OpenAI
load_dotenv()
TRACKING_URI = "http://127.0.0.1:5000"
EXPERIMENT_NAME = "STEP01:MVPプロンプトの作成と実行"
PROMPT_URI = "prompts:/step01-narou-episode-writer/1"
MODEL = "gpt-4o-mini"
mlflow.set_tracking_uri(TRACKING_URI)
mlflow.set_experiment(EXPERIMENT_NAME)
mlflow.openai.autolog()
client = OpenAI()
mlflow.openai.autolog()により、OpenAI API呼び出しの入出力、レイテンシ、トークン使用量などが自動でトレースされます。
次に、第一話へ渡す企画を定義します。
story_input = {
"genre": "異世界転生ファンタジー",
"protagonist": "ブラック企業で働いていた28歳の会社員、佐藤蓮",
"ability": "触れた道具の性能を最大化する能力",
"world": "魔法と冒険者ギルドが存在する世界",
"episode_goal": "異世界へ転生し、自分の能力に気づく",
"future_plot": "壊れた道具を修理し、仲間を増やしながら辺境の町を発展させる",
}
future_plotには全体の方向性だけを入れます。詳細な章構成を渡すと、第一話で先の出来事まで書く可能性があるためです。
GUIに登録したPrompt Version 1を読み込み、変数を埋めてOpenAI APIへ渡します。
@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
最後に1回実行し、Pythonのlen()で出力文字数も表示します。
result = generate_first_episode(story_input)
print(result)
print(f"\n文字数: {len(result)}")
7. 実行結果を確認する
ありきたりではありますが、一応第一話としては出来ました。
しかし、出力末尾の文字数は次の結果でした。
文字数: 1359
条件:
- 本文は日本語で1,200文字以内
- 第一話だけを書く
- 読者が続きを読みたくなる場面で終える
- 設定の説明だけで終わらず、物語として描写する
- 小説本文だけを出力する
指定した1,200文字を超えています。API呼び出し自体は成功しているため、MLflowのStateはOKになりますが、作品要件としては失敗です。
max_output_tokensはトークン数の上限であり、日本語の文字数上限ではありません。1,200文字以内かどうかは別途評価する必要があります。
この失敗を都合のよい成功例へ差し替えず、STEP2の評価対象として残します。「実行できた」と「要件を満たした」は別の問題だからです。
8. GUIでトレースを確認する
MLflowへ戻り、Experimentの左メニューから「Traces」を開きます。画面を開いたままだった場合は更新します。
一覧から、入力した企画、トークン数、実行時間、Stateを確認できます。今回の検証では1,242トークン、約13.90秒でした。
対象のトレースを選択すると、Summaryに企画の各項目、OpenAI Responses APIの呼び出し、生成された小説本文が表示されます。今回の推定コストは$0.00064でした。値は出力内容や実行時点によって変わります。
「Linked prompts」タブでは、このトレースがstep01-narou-episode-writerのVersion 1を使用したことを確認できます。
今後の改善サイクル
STEP1で、Prompt Version 1の実行結果と、最初の失敗例を記録できました。以降は同じ題材を使って次の順番で進めます。
STEP2:評価基準と評価データを追加する
複数の小説企画をDatasetへ登録し、Version 1を共通条件で評価します。
- 1,200文字以内か:Pythonで判定
- 空回答でないか:Pythonで判定
- 指定設定との整合性:LLM Judgeで判定
- 第一話の到達点を満たすか:LLM Judgeで判定
- 冒頭のフックと次話への引きがあるか:LLM Judgeで判定
- 長期プロットを先取りしていないか:LLM Judgeで判定
今回見つかった1,204文字という結果は、文字数評価が正しく違反を検出できるかを確認するテストケースになります。
STEP3:評価結果からPrompt Version 2を作る
評価の低かった項目だけを改善します。たとえば文字数違反には「1,100文字を目安に書き、必ず1,200文字以下に収める」といった余裕を持たせる指示を検討します。物語性が低ければ、冒頭、事件、能力発見、次話への引きという構成を追加します。
Version 1は上書きせず、MLflow GUIからVersion 2を作成します。
STEP4:Version 1と2を比較評価する
同じDataset、モデル、評価基準で両Versionを実行します。品質スコアだけでなく、トークン数、コスト、レイテンシも比較し、改善による副作用を確認します。
STEP5:Aliasで採用版を管理する
採用したVersionへchampionなどのAliasを設定し、Notebookは固定Versionではなく次のURIを参照します。
prompts:/step01-narou-episode-writer@champion
STEP6:設定資料と過去話をRAGで参照する
複数話を書き、人物設定、地名、既出の出来事が増えた段階でRAGを導入します。今回の短い企画入力と、検索した設定資料を使う方式を比較し、設定整合性が向上するかを評価します。
STEP7:継続監視から次の改善へ戻る
運用中のトレースから問題のある作品をReviewやDatasetへ追加します。新しい失敗例が集まったらSTEP3へ戻り、次のPrompt Versionを作ります。
トラブルシューティング
Promptが見つからない
次のエラーは、指定した名前またはVersionがPrompt Registryに存在しない場合に発生します。
RESOURCE_DOES_NOT_EXIST: Prompt (...) not found
GUIでPrompt名とVersionを確認してください。今回のURIは次のとおりです。
prompts:/step01-narou-episode-writer/1
トレースが表示されない
次を順番に確認します。
- MLflowが
127.0.0.1:5000で起動している -
mlflow.set_tracking_uri()を実行した - Experiment名が完全一致している
-
mlflow.openai.autolog()をAPI呼び出しより前に実行した - Traces画面を更新した
まとめ
STEP1では、MLflow GUIでExperimentと小説生成Prompt Version 1を作成し、Jupyter NotebookからOpenAI APIを実行しました。トレースでは、企画、生成本文、Prompt Version、トークン数、コスト、レイテンシを確認できました。
実行結果は1,204文字で、1,200文字以内という要件には失敗しました。しかし、この失敗を記録できたことで、次に評価すべき項目が明確になりました。次回は複数の企画と評価基準を追加し、Prompt Version 1の品質を測定します。
参考資料
出力された第一話
この段階だと、全然面白くないし、描写・設定が甘く、矛盾・疑念が出る文章です。
ここからアウトプットを評価して、プロンプトを改善することで、どんな風になるのか期待しています。
大失敗の可能性もあり。
第一章:転生の始まり
東京の喧騒から逃れるように、佐藤蓮はその小さなデスクに向かっていた。ブラック企業の闇に飲まれ、仕事のストレスに押しつぶされそうな日々。28歳という年齢は、彼にとってもう一度青春を味わうことは叶わないだろうと、淡い希望を抱きながらも諦めていた。
ある晩、残業の真っ只中。彼は膨大な書類に埋もれていた。疲れ切った目で、モニターに向かい歌が流れるのを聞いていたが、その瞬間、音が高く響いて彼の意識が曖昧になった。
「どこだ、ここは?」
気がつくと、目の前には青い空が広がっていた。街道を行き交う人々、色とりどりの服を纏い、肩に武器や魔法の道具を背負っている。夢の中の一場面かと思ったが、彼はそのリアルさに驚愕した。
「異世界、転生? まさか、こんなことがあるとは…」 蓮は空を見上げながら呟いた。
すると、ふと目の前に小さな看板が立っていることに気づいた。「辺境の町・エルドワン」と書かれている。その町の近くには、古びた冒険者ギルドの建物が見えた。運命を感じた彼は、何か新しい人生が待っているのではないかと心躍らせた。
町に足を踏み入れると、そこは活気に満ちていた。町の中心広場では、様々な冒険者が集まり情報を交換したり、募集しているクエストを吟味していた。彼は心の底から、こういう場所に憧れていた。自分がこの世界で何かを成し遂げることができるのか、期待と不安が入り混じる。
「この町の冒険者ギルド、行ってみよう…」
蓮はゆっくりとギルドの扉を開いた。内部には賑やかな会話や笑い声が響いていた。掲示板にはクエストがびっしりと貼られており、その中から「壊れた道具の修理」を見つけた。どうやら町の人々は、壊れた道具を使えないまま困っているらしい。
「これ、やってみようかな。」
彼は掲示板から依頼を受け取り、さらに周囲の人々に声をかけた。「壊れた道具って、具体的にはどんなものがありますか?」
一人の若い女性が振り向き、「私の剣が壊れちゃって、何とかしてほしいの」と言った。彼女の目には期待が宿っていた。
その瞬間、蓮の中で何かが目覚めた。触れた道具の性能を最大化する能力が、どこからともなく湧き出てくる感覚。自分が何か特別な力を持っていることに気づいたのだ。彼は女性の剣を手に取り、優しく触れた。
「直してみせる。」
すると、彼の手の中で剣が微かに輝き始めた。温かいエネルギーが流れ込み、壊れた部分が修復される。この力は、彼の今までの人生では感じたことのない感覚だった。
「本当に直ったの!?」女性の驚きの声が響く。
蓮は微笑み、「これからは、もっと多くの道具を助けてあげる。君たちのために、あの町を発展させたい。」と告げた。彼の決意はすでに固まっていた。
次の瞬間、彼の目の前には剣がキラリと輝き、その美しさを際立たせていた。町の人々が彼の力に気付き始め、期待と興奮の声が大きくなっていく。
だが、蓮は心の奥で感じ始めていた。自分の力がどこまで通用するのか、どんな冒険が待っているのか。そして、この町に新たな仲間を増やすために、どれだけの試練が立ちはだかるのか…。
この瞬間、彼の冒険が始まったことを、何も知らずに夢を抱いていた数年前の自分に告げてやりたかった。






