📕

プロンプトエンジニアリングのすすめかた

に公開

最近 AI が暴走して他社に攻撃してしまった。
そんな話が耳に入りつつ、自分とは関係ないことだと思っていました。

そう、過去形です。
実は私はここのところ、ずっとモデルの性能アップと格闘していました。
モデルの精度が上がり、指示への遵守性が上がったことで、TAKT が無限に近いループを起こすようになってしまったのです。

TAKT には、内部エージェントが利用するビルトインのプロンプトが大量に入っています。
この1週間だけでプロンプト関連の fix コミットが20個近く積まれています。

  • fix: make review remediation converge by problem family
  • fix: make iterative review remediation converge
  • fix: make review remediation retries converge

コミットログに converge(収束せよ)が3連発している時点で察してもらえると思いますが、エージェントのレビュー修正ループが収束しない問題と延々戦っていました。

プロンプトエンジニアリングというと「いい感じの指示文を書く技術」というイメージがあるかもしれませんが、実態はだいぶ違います。
少なくとも私のやり方は、プロンプトを読んで唸る時間より、評価を書いて回す時間のほうが長い。
プロンプトの TDD です。

この記事では、私が TAKT や LLM プロダクトを開発する際に行っているプロンプトエンジニアリングの流れを紹介します。

なぜ雰囲気で直すと壊れるのか

プロンプトの修正には回帰があります。
回帰があること自体はコードと同じで、問題ではありません。
問題は、コードより気づきにくいことです。

たとえば「レビュアーが見落としをする」という問題に対して、プロンプトに「徹底的に確認せよ」と書き足すとします。
見落としは減るかもしれません。
代わりに、問題のないコードへの言いがかりが増えます。
単純にレビューを厳しくする修正は、ほぼ確実に誤検知率とトレードオフになります。

しかも LLM の出力は確率的なので、修正後に1回試して上手くいっても、それは直ったのか、たまたまなのか分かりません。

つまり「プロンプトが良くなった」は感想ではなく計測結果であるべきなんです。
計測していないプロンプト修正は、テストのないリファクタリングと同じで、直したつもりになっているだけの可能性が常にあります。

この実験に私は promptfoo という OSS を使っています。
promptfoo は LLM 出力の実験ツールで、プロンプトとテストケース、合否基準(アサーション)を YAML で書くと、実行して pass/fail を判定してくれます。

TAKT ではエンジンの動作はモック E2E で検証していますが、それとは別に、プロンプトの中身が良い出力を生むかどうかを promptfoo の評価スイートで測っています。
レビュー・計画・修正・裁定といったステップごとにスイートがあって、現時点で20個以上あります。

全体の流れ

プロンプトエンジニアリングの流れはつぎのとおりです。

  1. 実運用でエージェントのミスを見つける
  2. そのミスを評価ケースとして再現する
  3. 評価を実行して、落ちること(赤)を確認する
  4. プロンプトに最小の修正を入れる
  5. 評価を実行して、通ること(緑)を確認する。他のスイートも回して回帰がないか見る
  6. ケースは消さずに残す。次の改善のヒントにも回帰テストにもなる

1. 実運用でミスを観測する

出発点は大抵は実運用です。
評価ケースを机上で発明するのではなく、実際に起きたミスから作ります。

たとえば最近、次のような問題がありました。

コードベースに同じ種類の欠陥が7ファイルにまたがって存在する。
レビュアーはそのうち1箇所を見つけて指摘する。
修正エージェントはその1箇所だけ直す。
次のラウンドでレビュアーは別の1箇所を見つける。
以下繰り返し。

同族の問題を1箇所ずつ小出しに指摘するので、ループが7往復するわけです。

人間なら「同じ指摘を7回に分けて出すな」で済みますが、エージェントは毎回記憶がリセットされた状態で登場するので、口頭注意が効きません。
プロンプトを直すしかない。
ただし、直す前にやることがあります。

2. ミスを評価ケースに落とす

まず、このミスを再現する評価ケースを作ります。

TAKT の eval ディレクトリには自己完結した題材プロジェクト(fixture)が入っていて、そこに欠陥を意図的に植えます。
この例なら、同じ契約違反を実装からテストまで7ファイルに植えた diff を用意して、レビュー対象にします。

そして promptfoo のアサーションを書きます。

assert:
  - type: llm-rubric
    metric: review/problem-family-closure
    value: |
      見出しや表の形、文言の一致ではなく、欠陥ファミリーの
      実質的な網羅で判定せよ。
      (中略・対象7ファイル)にまたがる問題ファミリー全体を、
      同じ不変条件違反または根本原因に結びつけて指摘・REJECT
      した場合のみ pass。
      代表箇所で止まったら fail。
      未確認の経路を確認済み扱いしたら fail。
      正しい経路を欠陥として報告したら fail。

value に書いたこの文章がルーブリック(採点基準)です。
合否の判定を LLM にやらせる、いわゆる LLM as a Judge で、この文章はレビューエージェントではなく採点側の LLM に渡ります。
実物は英語で書いていますが、この記事では日本語に訳して載せています。

ルーブリックの書き方にはコツがふたつあります。

まず、見出しや表の形式ではなく実質で判定させること。
レビューエージェントの報告は、決まった見出しと指摘テーブルの形式で出てきます。
採点する LLM に何も言わないと、この見た目の整い方に引っ張られます。
正しい見出しと立派な表が付いているのに、肝心の指摘が1箇所しかない報告が通ってしまう。
逆に、全箇所を指摘できているのに、書式が想定と違うだけで落ちる。
だから「形式ではなく実質で判定せよ」と採点の軸を最初に固定します。

なお、形式そのもののチェックがしたいなら、文字列一致のようなルールベースのアサーションで足ります(promptfoo ではそういうチェックも自由に書けます)。
高価で揺らぐ LLM 採点は、「同じ根本原因に結びつけて全箇所を報告できているか」のような、ルールでは書けない判定にだけ使います。

次に fail 条件を明示すること。
「代表箇所で止まったら fail」「未確認の経路を確認済み扱いしたら fail」「正しい経路を欠陥として報告したら fail」。
ミスの形が分かっているのだから、そのミスをそのまま fail 条件に書きます。
最後の条件は誤検知を防ぐためのもので、見落とし対策のつもりの修正が誤検知を増やしていないかを同じケースで見張っています。

3. 赤を確認する

ケースができたら評価を回して、落ちることを確認します。
この流れでいちばん大事なのがここです。

npm run eval:prompts -- review-family-closure

ここでいきなり通ってしまったら、喜ぶわけにはいきません。
それはケースが実際のミスを捉えていないという意味だからです。
実運用では起きたのに評価では再現しないなら、植えた欠陥が簡単すぎるか、状況設定が違うか、どこかがズレている。
プロンプトを触る前にケースを作り直します。

落ちない評価を土台にプロンプトを直しても、その修正が効いたのかどうかは永遠に分かりません。
赤(失敗)の確認は、修正の効果測定ができる状態を先に作る作業です。
テストファーストで先にテストを落とすのと同じ理屈です。

4. プロンプトを最小修正する

赤が確認できて、はじめてプロンプトを触ります。

TAKT のプロンプトはファセットという単位で部品化してあります。
ペルソナ、ポリシー、ナレッジ、インストラクションが別ファイルになっていて、各ステップのプロンプトはそれらの合成でできています。
だから修正は「どのファセットの責務か」を特定して、そこに最小の差分を入れる形になります。

この例では、レビュー指示のファセットにこう足しました。

同じ family の問題を同じ回で出し切ってください。

一箇所を直して終わりにせず、同じ原因の箇所を残らず探して同じレビューで報告させる指示です。
部品化してあるおかげで、この一文を {{include:instructions/review-family-completion}} として14個のレビュー指示に一行ずつ配れます。

修正エージェント向けには、こんな指示も書きました。

verifier の一覧は未完了範囲の例であり、今回の修正範囲の上限ではありません。
列挙された箇所だけを修正して再提出しないでください。

指摘された3件だけ直して「完了しました」と再提出してくる挙動への対策です。

5. 緑を確認して、回帰を見る

修正したら評価を回して、通ることを確認します。

まず反復です。
LLM の出力は確率的なので、1回の緑は信用しません。
重要な修正は --repeat 3 で回して、安定して通ることを見ます。

npm run eval:prompts -- review-family-closure --repeat 3

次に他のスイートです。
レビュアーを厳しくする修正は、クリーンなコードへの誤検知を増やしているかもしれません。
TAKT のスイートには欠陥を植えたケースだけでなく、何も植えていないクリーンな diff のケースもあって、そちらは「言いがかりを付けたら fail」で守られています。
見落とし検出と誤検知抑制を別々のケースで測っておいて、修正のたびに両方回す。
片方だけ測っていると、レビュアーはどこまでも疑り深い性格に育っていきます。

6. ケースを資産にする

緑になったケースは消しません。

ケースには、実際に起きたミスと、それを再現する状況設定と、効いた修正がひとそろいで残っています。
次のプロンプトエンジニアリングでは、これが出発点になります。
似た挙動に出会ったとき、過去のケースを開けば、どんな欠陥をどう植えて、どんなルーブリックで捕まえたかをそのまま参照できます。

回帰チェックにも使えます。
プロンプトは自然言語なので、一見無関係な文を足しただけで別の挙動が崩れることが普通にあります。
別の問題でプロンプトを直したとき、過去のケースが落ちたら「前に直したミスが再発した」と分かる。

冒頭の converge 3連発コミットは、この流れを問題ファミリー、修正の完了検証、検証者の独立性、と対象を変えながら回した結果です。
各コミットの裏に評価スイートが1個ずつ増えています。

まとめ

プロンプトエンジニアリングの実態は、文章術というより計測の仕組み作りです。

実運用のミスをケース化して、赤を確認して、最小修正して、緑と回帰を確認して、ケースを資産として積む。
この回し方なら「プロンプトが良くなった」を計測結果として言えます。
逆にこの仕組みがないと、直したつもりの修正を積み重ねて、どこかで別の挙動を壊していても気づけません。

冒頭の無限ループも、こうしてひとつずつ評価ケースに変えながら閉じていきました。
モデルが賢くなるたびに、また別のループがどこかで生まれるのでしょう。
そのたびにケースが増えて、同じミスは二度と通らなくなります。

こうして鍛えたプロンプトはすべて TAKT のビルトインとして同梱されているので、ぜひつかってみてくださいね!

npm install -g takt
takt exec

https://github.com/nrslib/takt

TAKT のユーザーコミュニティはこちらです!
https://discord.gg/TbrqwGWhAb

お知らせ

TAKT を Product Hunt に出します!
8/9 に公開されます。
https://www.producthunt.com/products/takt-3?launch=takt-3

Product Hunt はローンチ当日のコミュニティの反応でランキングが決まる仕組みで、上位に入るほど海外の開発者の目に留まります。
つまり当日の盛り上がりがそのまま海外への広がりになり、結果的に TAKT 開発の勢いも増します。
公開されたら、ぜひ見に来て応援してください。
コメントや議論への参加には Product Hunt のアカウントが必要なので、事前に作っておいていただくことを推奨します。
登録はこちらより!
https://www.producthunt.com/
応援よろしくお願いします!

Discussion