こんにちは~ 浮田です。
プロンプトのチューニング、毎回「ちょっと言い回しを変えては試す」の繰り返しで、正直しんどいなと思っていました。
プロンプトは LLM に読ませるための文章なんだから、どう書けば伝わるかは LLM 自身が一番よく知っているのでは?
つまり自分のことは自分でやれと。
人間は採点と環境づくりだけ引き受けて、改良は本人にやらせる。
この「生成→評価→選抜→反復」のプロンプト自動育成ループを Step Functions で回してみたのが今回の記事です。
Claude や Nova のような LLM は、プロンプトの書き方ひとつで精度が大きく変わります。良いプロンプトを人力で試行錯誤するのは大変なので、これを自動化したい。
学術的には OPRO / PromptBreeder / DSPy などの自動プロンプト最適化と同じ発想で、流れはこうです。
初期プロンプト → 変種を生成 → 評価データで採点 → 上位を選抜 → 改善案を生成 → …(収束 or 規定回数まで繰り返す)→ 最良プロンプトを保存
この「生成 → 評価 → 選抜 → 反復」のループを Step Functions のステートマシンで回すのが本記事の主題です。
■ 2. なぜ Step Functions なのか
このループに必要な要素は、そのまま Step Functions の得意分野でした。
|
やりたいこと |
Step Functions の機能 |
|
複数の変種を並列評価 |
Map ステート(同時実行数も制御可) |
|
目標正解率まで繰り返す |
Choice ステート + ループ |
|
LLM 呼び出しの一時失敗に耐える |
Retry / Catch(指数バックオフ) |
|
数十分単位の長時間実行 |
Standard ワークフロー |
|
世代ごとの進捗を可視化 |
実行グラフ・実行履歴 |
Step Functions (Standard / JSONata)
① Init データセット(S3)を読み込み、育成用(dev+fewshot)と検証用(test)に分割
② Generate Claude Sonnet で判定プロンプトを N 個生成(誤分類を弱点フィードバック)
③ Evaluate Map(並列) 各変種で育成セットの画像を分類し、正解率を算出
④ Select 正解率が最良の変種を選抜し DynamoDB に履歴・混同行列を保存
⑤ CheckDone 規定世代 or 目標正解率に到達まで ② へループ
⑥ TestEval 育成済みベストを「未使用の test セット」で採点(過学習チェック)
⑦ Finalize 最良プロンプトと dev/test スコアを S3 / DynamoDB に保存
インフラは CDK 1 スタックに集約。
ステートマシン定義(ASL/JSONata)は外部 JSON にし、CDK の definitionSubstitutions で Lambda ARN を注入しています。
書類タイプ分類を選んだ理由は、正解ラベルがあり「正解率」で客観採点できるから。
しかも請求書・領収書・見積書は見た目がそっくり(品目表+合計金額)で、判定プロンプトの書き方で精度が変わるので育成のしがいがあるのでは?と考えたからです。
手元に書類がなかったので、Pillow の合成ジェネレータで各クラスの画像と正解ラベルを生成しました。
タイトル文言のゆらぎ(請求書 / ご請求書 / INVOICE)、決め手キーワードのランダム表示、スキャン風劣化(傾き・影・低解像度・ノイズ)を入れ、後半では「難化版」として英語タイトル多用や引っかけ書類も追加しました。
各変種プロンプトで画像を分類し、出力ラベルが正解と一致するか数えるだけ。
採点にLLMを使わないので採点基準のブレがありません(ただし判定モデル自身の出力ゆらぎは別問題で、後述の temperature: 0 が必要でした)。
さらに dataset.json の split(fewshot/dev/test)で育成用と検証用を分割。育成ループは dev だけを使い、test は最後の 1 回だけ採点します。devScore と testScore の差(overfitGap)で過学習を可視化できます。
判定モデルは Converse API でモデル非依存に。
Bedrock の Converse API は Claude でも Nova でも同じコードで呼べるので、判定モデルを環境変数ひとつで差し替えられます。
採点は決定的に、生成は創造的に。
当初 temperature を指定し忘れており、同じプロンプト・同じ画像でも判定結果が毎回揺れていました。採点側は temperature: 0 で決定的にし、多様な変種が欲しい改良側(Sonnet)はデフォルトのまま——という書き分けが重要です。
誤分類を次世代へフィードバック。
評価で集めた混同行列(どのクラスをどう間違えたか)を Generate に渡し、「ここの見分けを強化せよ」と改良方向を誘導します。
ループ状態は JSONata の変数($generation / $history / $best / $testset)で持ち回し、収束判定は Choice ステートで実装しています。
■ 7. 試行錯誤の記録
ここからが実際に動かして分かったことです。
最初、判定も改良も Claude Sonnet 4.6 で回したところ、第1世代でいきなり dev/test 100%。
目標スコア到達で即終了しました。技術的には成功ですが、「世代ごとに伸びる」絵が出ない。モデルが強すぎると育成のドラマが生まれないという最初の学びです。
実務だと、すぐに正解を出してくれるのはいいことなんですが、今回は成長が見たかったので、判定役を非力なモデルに下げ、改良役は賢いモデルのままという役割分担にしました。
判定 = Amazon Nova Lite(安い・小型)、改良 = Claude Sonnet 4.6。
Nova Lite に切り替えて回すと、正解率が 49→47→44→51% と横ばいで揺れるだけ。最終プロンプトを見ると、なぜか「この画像が何の書類か答えてください。」という雑な初期プロンプトのままでした。
DynamoDB を調べると、各世代の変種が 1個だけ・全部シード。generate(改良役)が毎回フォールバックに落ちていたのです。
原因は:
Sonnet が 3 つの長大なプロンプトを生成 → max_tokens=3000 で 途中で切れて JSON が壊れる → パース失敗 → variants = [seed] にフォールバック
つまり改良案が一度も採用されず、正解率の揺れは Nova の非決定性だけでした。
エラーも出ず静かに握りつぶされていたので発見が遅れました。
対策:
教訓: LLM 出力のパースは「静かに失敗」しやすい。フォールバックは必ずログを吐かせる。
バグを直すと、ちゃんと育成が機能しました。
雑なシード「この画像が何の書類か答えてください。」から始まり、Sonnet が自力で 優先順位付きの決定木を編み出しました。
シード → [クラス定義+出力形式を導入] → 65% → [決定木化] → 70%
育った最終プロンプトの一部:
書類画像を7クラスに分類し、ラベル名1語のみ出力せよ。
### 各クラスの識別ロジック(上から順に評価し、最初に一致したクラスで確定)
① 契約書: 「第○条」などの条文番号 AND(「甲」または「乙」)が存在する → 契約書
② 領収書: タイトルに「領収」がある、または「確かに受領」「但し」がある → 領収書
※金額が書いてあっても受領証明があれば請求書でなく領収書
...
判定順序が戦略的(紛らわしい契約書・領収書を先に、汎用的な請求書を後に)で、「Xに見えてもYがあればY」という打ち消しルールまで含む。
これは前世代の混同行列をフィードバックした効果がそのまま反映されたものです。
人間のプロンプトエンジニアが書くような内容に、自動で到達しました。
同じシード vs 同じ育成済みプロンプトを各モデルで比較しました。
|
モデル |
素→育成後 |
コメント |
|
Haiku 4.5 |
84→100 (+16) |
伸びる&満点。伸びしろ少 |
|
Sonnet 4.6 |
88→100 (+12) |
元から高く伸びしろ少 |
|
Nova Lite |
39→54 (+14) |
伸びるが頭打ち低い |
|
Nova Pro |
25→41 (+16) |
伸び幅大だが到達点が低い |
|
Nova 2 Lite |
52→54 (+2) |
ほぼ伸びない |
注意点: このプロンプトは Nova Lite 用に育てたもの。各モデルで個別に育成すれば結果は変わります。また素スコアの低さは「能力」より「出力形式」の影響も大きい(自由回答だとラベル抽出に失敗する)。
Nova Lite の 70% の壁を、プロンプトの工夫で破れるか試しました。結果はすべて逆効果。
|
試み |
結果 |
|
情報を盛る(詳細な注意書き追加) |
70% → 58% ↓ |
|
CoT(考えてからラベル出力) |
62.5% → 46.4% ↓ |
|
few-shot(正解例画像を同梱) |
60.7% → 41.1% ↓ |
|
短くしすぎ(キーワード表だけ) |
58% → 44% ↓ |
共通する法則: 小型モデルに「処理する材料」を増やすほど崩れる。
Nova Lite は複数画像や長い推論を捌けず、追加情報がノイズになる。制約してシンプルに使うのが最善で、勝ったのは「キーワード+優先順位+打ち消しルールの簡潔な決定木」でした。
つまり 7-3 で育成が自力で見つけたスタイルが、実は最適だったわけです。
※この表の数値は temperature 未指定(既定値)時代の測定で、±数ポイントのノイズを含みます。ただし全レバーが2桁劣化しており、結論は覆りません。
もっと大きな成長を見たくて、データを難化(英語タイトル多用・低解像度・引っかけ書類)し、Nova Lite で 6 世代回しました。
雑なシード(素の正解率は5割前後)から 80% 前後まで育ち、これまでで最大の成長です。
ただ、1回の実行で「成長した」と言い切るのは危険です。
実際、最初の1回は「dev = test = 80.4%(過学習ゼロ)」という出来すぎた結果で、危うくそのまま成果として書くところでした。
そこで判定を temperature=0 で決定化した上で、同一条件でフル育成を10回実行して分布を見ました。
|
指標 |
10回試行の結果 |
|
第1世代ベスト |
平均 76.2%(67.0〜81.2% とばらつく) |
|
最終 devScore |
平均 80.4%(全回 77.7〜82.1% に収束、標準偏差 1.4) |
|
testScore |
平均 74.7%(62.5〜80.4%) |
|
overfitGap |
平均 5.7pt(0.0〜15.2) |
ここから見えたことが3つあります。
なお「過学習ゼロ」の回は10回中1回だけで、gap は平均 5.7pt、最悪 15.2pt。dev がほぼ同じでも test は大きく揺れるため、「dev で選んだベストが汎化でも最良」とは限らない
というのが次の改善余地です。
ちなみに判定役を Haiku 4.5 に替えて同じ難化データで5回試行すると、5回すべて第1世代で dev/test 100%(目標到達で早期終了)。強いモデルには育成の出番すらないので、成長曲線を見せたいなら「ちょうど良い能力のモデル × 難しめのタスク」が要る、という結論です。
「生成 → 評価 → 選抜 → 反復」というプロンプト最適化のループは、Step Functions の Map / Choice / Retry / 変数にきれいに対応しました。ワークフロー基盤としての相性は非常に良いです。
ただ、実際に回して面白かったのは技術構成そのものより、「どのモデルをどう使うか」で結果が激変するという運用のリアルでした。
プロンプト自動化は「賢いプロンプトを書く技術」であると同時に、「最適化が効くモデル・タスク・評価設計を選ぶ技術」でもあるというのが、一連の試行錯誤から得た実感です。
「自分のことは自分でやれ」とプロンプト改良を LLM に丸投げした結果、たしかに改良はやってくれました。ただし、誰に(モデル選定)・何を(タスク)・どう採点するか(評価設計)を決めるのは、最後まで人間の仕事でした。