VOOZH about

URL: https://zenn.dev/erukiti/articles/2412-prompt-generator

⇱ プロンプトジェネレータで実現する持続可能なLLMプロダクト開発を目指す


この記事はLLM・LLM活用 - Qiita Advent Calendar 2024 - Qiita の12日目で、プロンプトジェネレータについてのものです。

いまはもう12/13かもしれませんが、僕の心はまだ2024/12/12なのです。

プロンプトジェネレータとは、LLMへの指示(プロンプト)を動的に生成するためのツールです。

多くの場合、プロンプトは静的なテキストとして書かれますが、それには大きな課題があります。LLMの進化が速く、昨日有効だったプロンプトが今日は機能しないかもしれません。あるいは、より良い方法が発見される可能性もあります。そのたびにプロンプトを書き換えていくのは、まさに技術的負債の山を築いているようなものです。

本記事では、TRPGのシナリオ生成という具体例を通じて、プロンプトジェネレータの実装方法とその利点を解説します。

プロンプトジェネレータについて、この記事を読んで得られる知見は以下の通りです。

  1. プロンプトの構造化によるメンテナンス性の向上
  2. 異なるモデルやモデルの進化に対応が可能
  3. プロンプトの品質管理
  4. 開発効率の向上

また、筆者がプロンプトへの落とし込みをする過程についても書いているため、それも参考になるかもしれません。

以前、会社のブログで、Zodスキーマでプロンプト生成を行い構造化データを自由自在に扱えて、LLMプロダクト開発が圧倒的に効率化した話 - Algomatic Tech Blogという記事を書きました。これは与えたスキーマ定義を元にプロンプトを生成することで、自由記述の文章から構造化データを取り出すという記事です。このときの手法は細かい形で変遷しつつも業務で今も役立っております。これも今回と同じくプロンプトジェネレータについての記事でした。

今回の内容は、個人の研究としてスキーマ定義を使わないプロンプトジェネレータという可能性を手探りしているところです。スキーマ定義もこったものを作るとメンテナンス性が落ちるため、抽象度の高いI/Fを模索しています。

現状でもある程度の品質を出せてはいますが、まだI/F設計など考慮に値する要素が多く存在します。よろしければ皆さんのお知恵をお貸しください。

前提

プロンプトジェネレータについての説明など、今回の記事の前提となる部分を説明します。

今回使う題材

今回使う題材は青空文庫で公開されている小説で「ろくろ首」です。小泉八雲ことラフカディオ・ハーンさんが書いたものです。

ちなみにこれを読んでて初めて知ったんですが、ろくろ首というと、首が伸びる妖怪のイメージしかなかったんですが、首が離れて飛ぶというデュラハンみのあるバージョンのろくろ首もいるんですね。

これを元にTRPGシナリオブックを作るというテーマでプロンプトジェネレータの力を皆さんにお見せしたいと思っています。

TRPGはテーブルトークRPGの略で、皆さんが知っているであろうRPGの元祖です。元々はテーブルトークがRPGと呼ばれていたもので「コンピュータRPG」は後で登場しました。言葉的にはレトロニムの一種です。動画で、クトゥルフゆっくりリプレイなどを見たことがある人もいるかもしれません。

ざっと説明すると、ゲームマスターとプレイヤー数人が遊ぶ卓上ゲームで、用意されたシナリオを元にゲームマスターがRPGのジャッジ(マスタリングという)をする遊びです。言ってしまえば「人力RPG」ですね。

時間がかかる、人が集まる必要があるなどの難点はありつつも、自由度の高さから根強い愛好者がいます。ラノベ作家の人なんかもTRPGを好きなひとは一定数いるため、TRPGネタにクスっとなることもしばしばです。

さて、TRPGにおいてシナリオを用意するというのは大変なことです。プレイヤーたちの好みを元に、シナリオや登場するキャラクタ(Non Play Character/NPC)を用意する必要があります。コンピュータRPGしかプレイしたことがない人でも、シナリオとキャラを用意する大変さは想像がつくのではないかと思います。

そのため、わざわざ自作しなくてもいいように、商業・同人問わず、シナリオとキャラや詳細データをセットにした、シナリオブックというものが販売されていたりします。

そこで我らがLLMの登場です。今回は「ろくろ首」の小説をプロンプトジェネレータを駆使してシナリオブックにしてみます。

  • シナリオブックは、誰でもマスタリングしやすいように、統一された書き方で書かれています
  • 登場するNPCのデータ、性格、背景、行動指針などが書かれています
  • シナリオが書かれています。序盤の導入の仕方、中盤の様子、クライマックスへの持って行きかたや、用意されているオチなどで成り立っています

プロンプトジェネレータ

改めて説明しますが、プロンプトテクニックの多くは、モデルが更新されると陳腐化してしまいます。たとえばロールプレイをさせるプロンプトは世界でも有名なテクニックで未だに愛好者も多そうですが、いまどきのモデルではほぼ無意味です。「あなたは世界一のハッカーとして振る舞ってください」みたいなものです。

すべて意味がないわけではなく「ハッカーの観点で」くらいまで情報を縮められるというだけですが、その情報が必要かどうかも状況によります。むしろ「ソーシャルハックの観点で」のように具体的な観点を明示した方が確実という程度です。

LLMは、種類やそれを開発したタイミングで、どういう指示の方法が良いのか?という傾向が異なります。ClaudeはXMLを与えるべきですが、他のLLMはそうとは限りません。新しいテクニックも度々生まれます。

  • 入力形式
  • 指示の与え方
  • 出力形式

これらが、モデル進化やコスト構造の変化で、新しい別のものに置き換えないといけなくなったら、さすがにメンテしてられないですよね?

そういった不確実性が極めて高い環境でプロンプトをメンテナンスする立場としては、具体に踏み込まず、抽象化された指示のみを出したいところです。そこでプロンプトジェネレータが重要となります。

順を追って考えさせる

LLMは、一足飛びに考えてしまう癖があります。元々機械学習が人間の直感・直観に似た仕組みだからというのはありますが、それで間違った答えを出されるのは困りものです。

そこで明示的に順を追って考えさせる手法が登場しました。Chain of Thought(CoT)などです。そのあと登場したゼロショットCoTなんかは皆さんも使ったことがあるかもしれません。いわゆる「step by step」です。

ただし、筆者は、現行のモデルにおいては「step by step」は確実性が低く、運が良ければ精度が上がるかもしれないおまじないくらいに思っています。明示的に順を追って行くことそのものが重要です。

そして、この「順を追って考える」という考え方をさらに発展させたのが、LLMに自身の思考プロセスを分析させる「メタ認知プロンプト」です。

メタ認知プロンプトとは

メタ認知プロンプトは、LLMに自身の思考プロセスや出力を分析・評価させる手法です。人間でいえば「なぜそう考えたのか」「この結論は妥当か」といった自己分析的な思考に相当します。

たとえば、Claude 3.5-SonnetでArtifactsを扱うためのプロンプトでは、「なぜそれをするのか?」の理由を考えさせています。

具体例を見てみましょう。

# 通常のプロンプト
シナリオの結末を考えてください

# メタ認知プロンプト
1. まず結末案を出力してください
2. その結末について、以下を確認してください:
 - 前提となる伏線は十分に張られているか
 - 登場人物の動機と整合しているか
 - プレイヤーにとって納得できる展開か
3. 問題点があれば修正案を提示してください

利点は、

  • 出力の品質が向上する(自己チェックが入るため)
  • 論理的な矛盾を減らせる
  • 文脈や制約との整合性が高まる

などがありますが、プロンプトが長く複雑になるため、適切なバランスを取ることが重要です。プロンプトの複雑度が上がると指示に従わなくなる傾向があるため、LLMをねじ伏せる豪腕が必要になってきます。

使い勝手のいいメタ認知プロンプトは、以下の通りです。

  • 状況の俯瞰
  • 目的や制約条件
  • 自分の理解
  • 誰の視点か
  • 根拠
  • 分解的な思考

スキーマを使ったメタ認知

スキーマベースのプロンプトジェネレータなら、スキーマという形で踏み込んで指示を出すことができます。たとえばこれは、前述のメタ認知の一覧をClaude Sonnetに食わせて生成させたスキーマです。ちゃんと検証はしてないですが、この程度でも面白い分析ができるようになります。本格的に使うなら指示のすべてに従ってもらうための工夫など、もっと細かく調整することになりますが、スキーマを使ったメタ認知プロンプトの基本は抑えられています。

TRPGシナリオブックの生成を題材にプロンプトを作り込む

それでは「ろくろ首」を元にTRPGで使えるシナリオブックを生成するためのプロンプトを作りましょう。

筆者がプロンプトを作り込むとき、API経由ではなく、Claudeのウェブ版やClaude Desktopアプリなどを使って、試しにプロンプトを投げることが多いです。これなら月額固定費用で、気兼ねなく投げ放題です。ただし、トークン数が膨大だとレートリミットに引っかかってしまうこともあります。この記事の執筆中にも一度レートリミットに引っかかって絶望していました。

TRPGで使えるシナリオブックを作ろうとしたとき何が必要でしょうか?小説であればプロットでしょうか。人によっては台詞やシーンを先に思いついてそれを元に書いていくパターンもありますね。もちろんTRPGのシナリオブックも同じです。

  • 何が起きるか?
  • 誰が登場人物か?

がおそらく最初にあるはずです。

ファンタジー系RPGの定番であるゴブリン退治なんかも「ゴブリンが村を襲って困っている」という事件が起きていて、「ゴブリン」「村人」なんかの登場人物がいます。

TRPGのシナリオでは一回のプレイ(セッションと呼ぶ)の間に、伏線を張って回収する必要があります。もちろん、短いセッションや初心者向けセッションなど、伏線を張らないスタイルもありますし、逆にキャンペーンと呼ばれる複数回のプレイのための伏線もあります。

さて、プロンプトジェネレータを使わずにまずはプロンプトを手書きしてみましょう。

プロンプトv2

今回、筆者が最初に作ったプロンプトらしいプロンプトです。v1は添付の小説を元にTRPGのシナリオを作ってだけの工夫が何もないプロンプトでした。

添付の小説を元にこれを題材にTRPG(4人以上)用のシナリオを作成して

# 出力

* シナリオのテーマ
* 舞台設定
* 想定PC
 * クラス
 * あるといい技能
 * 想定能力値
 * ハンドアウト例
 * 必須かどうか?
* NPC
 * 名前
 * プレイヤーが最初に知り得る情報
 * 性質、性格
 * 敵対的かどうか?(友好的、最初から敵対的、敵対する可能性がある、友好的に見えて敵対的など)
 * ゲームマスター向けの背景情報を詳細に
 * 能力値
* シナリオ
 * フェイズ1
 * フェイズ2
 * フェイズ3
 * フェイズ4
* 想定エンディング
 * 到達条件
 * 内容
* GMへのプレイングの注意点

## 注意点

システムとしては、クトゥルフの呼び声を使うこと。時代は、日本の戦国時代、1920年代、昭和・平成、現代などで任意でかまわない。

想定PCのハンドアウトは、実際のハンドアウトのサンプル文章を150文字程度で記述すること。

シナリオは4つのフェイズに分割すること。フェイズ1は導入フェイズであり、フェイズ4はクライマックスフェイズである。導入フェイズでは、プレイヤーたちを巻き込むか、能動的に動いてもらうために大切なフェイズである。クライマックスフェイズは一番盛り上がるところであり、ゲームの成否を決める大切なフェイズである。

それぞれのフェイズでは、登場する人物、場所、描写、そのフェイズでやらないといけないことを記述すること。時間帯が重要な場合はそれも記述。

NPCはモンスターも含む。雑魚モンスターは名前はなくてもよく、ひとまとめでよいが、主要なNPCはモンスターであっても一人ずつ名前をつけ、性質、性格、背景などを詳細を決めること。

なんとなくTRPGのシナリオブックにはこういった内容があったよな、とか面白いシナリオを作るなら、フェイズ1〜4くらいに区切るのがダレなくてちょうどいいとなという経験論に基づいたものです。ドメイン知識をプロンプトに落とし込むのは、このような行程を経ます。今回はTRPGのシナリオという娯楽ですが、ビジネスにおいてもやることは全く変わりません。

結果はなかなか面白いものでしたが、筆者の感想としては「いろいろ補えば成立する」というものでした。これをベースにより細かいところをつめていくことになります。そこの行程はプロンプトジェネレータの記事という本筋からズレるため、折りたたんでおきます。

プロンプトv2からあれこれ試行錯誤した過程でわかったことは以下の通りです。

  • プロンプトv2は、いろいろと情報が足りない
  • かといって、指示を追加しまくると、破綻しやすい
  • メタ認知プロンプトの導入が不可欠
    • 出力の整合性チェックや、情報の過不足のチェックや、論理的なつながりの検証が可能

プロンプトv3

さて、試行錯誤で得られた知見をもとに、メタ認知プロンプトを追加したうえでプロンプトジェネレータにも優しいバージョンを作り直してみました。

あなたは【必須】と書かれていることを絶対に守らなくてはいけない。そのうえで、「以下を出力する:」を出力すること。
また、【確認】と書かれているなら、それまでの出力を元に必ず確認しなければならない。あなたは途中何回か不完全な出力をすることを受け入れてほしい。

# あなたの使命

【必須】小説を元に4人以上のプレイヤー向けTRPGシナリオを作成する
【必須】システムはクトゥルフの呼び声TRPGを利用する

以下の手順に従う:
1. 【必須】まず「出力物」を仮出力する
2. 【必須】仮出力のシナリオのそれぞれのフェイズを要約する
3. 【確認】フェイズ間の不足点を確認する
4. 【必須】1〜3の情報を元に最終成果物を出力する

# 出力物

以下を出力する:
* 【必須】シナリオのテーマ
* 【必須】舞台設定
* 【必須】プレイヤーキャラクター(PC)
* 【必須】NPC
* 【必須】組織
* 【必須】モンスター
* 【必須】シナリオ
* 【必須】想定されるエンディング
* 【必須】GMへのメモ

# シナリオのテーマ

以下を出力する:
* 【必須】そのシナリオのテーマを1〜3個作成する

# 舞台設定

以下を出力する:
* 【必須】舞台となる国と時代を選ぶ
* 【任意】時代は戦国時代/1920年代/昭和・平成/現代など。それ以外でもかまわない

# プレイヤーキャラクター(PC)

以下を出力する:
* 【必須】推奨職業
* 【必須】推奨技能
* 【必須】シナリオバランスのための想定能力値
* 【必須】ハンドアウトのサンプル(150字程度)
* 【必須】必須PCの有無

# NPC

【必須】重要なモンスターはNPCとして扱うこと

以下を出力する:
* 【必須】名前と基本情報
* 【必須】口調・性格
* 【必須】PCに対する態度(友好/敵対/協力者のふり等)
* 【必須】描写(200文字程度)
* 【必須】GM向け詳細設定
* 【必須】あるなら、所属組織
* 【必須】能力値
* 【必須】他NPCとの関係性
* 【必須】会話可能なNPCなら特徴的なセリフ5個

# 組織

以下を出力する:
* 【必須】名前
* 【必須】組織の目的

# モンスター

以下を出力する:
* 【必須】名前
* 【必須】PCに対する態度(友好/敵対/協力者のふり、等)
* 【必須】描写(200文字程度)
* 【必須】GM向け詳細設定
* 【必須】能力値
* 【必須】NPCとの関係性
* 【必須】会話可能なNPCなら特徴的なセリフ5個

# シナリオ

【必須】シナリオは、フェイズから成り立っている。各フェイズを自然に接続し、クライマックスに向けて盛り上がるように記述すること

以下を出力する:
* 【必須】フェイズ1
* 【必須】フェイズ2
* 【必須】フェイズ3
* 【必須】フェイズ4
* 【確認】各フェイズは自然に接続しているか?
* 【確認】クライマックスに向けて盛り上がるようになっているか?

## フェイズ1

フェイズ1は、プレイヤーが自然に事件に巻き込まれるような導入フェイズである。
フェイズは、1〜n個のイベントの繰り返しである

以下を出力する:
* 【必須】イベント1
* 【任意】イベント2〜n

### イベント

以下を出力する:
* 【必須】場所
* 【任意】時間
* 【必須】典型的な情景描写(200字程度)
* 【必須】内容
* 【必須】発生条件、PCの巻き込まれ方
* 【必須】必要なPCの技能
* 【必須】NPC
* 【必須】このイベントにおけるNPCの台詞(3〜7個)
* 【必須】このイベントにおけるNPCの行動(1〜3個)
* 【確認】NPCは「NPC」に書かれているか?書かれてなければ、途中の状態でもいいのでわかる形で追加で書き出すこと
* 【確認】NPC同士の関係性
* 【確認】セリフや行動は「NPC」の「描写」「GM向け詳細設定」「PCに対する態度」に合致しているか?
* 【必須】このイベントの結果、入手できる情報/できない情報
* 【任意】入手アイテム
* 【必須】このイベントの結果によって、不可逆の要素すべて
* 【必須】このイベントの目的
* 【必須】このイベントはほかのイベントと並列で発生可能か?
* 【任意】このイベントの前に依存関係があるなら列挙する

## フェイズ2

フェイズ1と同様の形式

## フェイズ3

フェイズ1と同様の形式

## フェイズ4

フェイズ1と同様の形式
フェイズ4は決着をつけるための最終局面のためのクライマックスフェイズである

# 想定されるエンディング

以下を出力する:
* 【必須】各結末への到達条件
* 【必須】結末の詳細
* 【必須】典型的な情景描写(300字)

# GMへのメモ

以下を出力する:
* 【必須】シナリオ進行時の重要ポイント

なお、

あなたは【必須】と書かれていることを絶対に守らなくてはいけない。そのうえで、「以下を出力する:」を出力すること。
また、【確認】と書かれているなら、それまでの出力を元に必ず確認しなければならない。あなたは途中何回か不完全な出力をすることを受け入れてほしい。

のくだりは、システムプロンプトを想定しています。

さて、実際にこのプロンプトv3をClaudeアプリ(Sonnet)に投げてみましょう。注意してほしいのですが、トークン数がものすごいことになるので投げまくってると、レートリミットにひっかかることがあります。

とても長いので折りたたんでいます。

反省点としては、「仮出力」「確認」「本出力」を一度にやろうとしたことです。

  • トークン数が増えすぎて、Claudeの利用限界を超えそうになった
  • サボろうとするので、少しばかり脅しをかけている

俗にいうパワハラプロンプトは、未だに有効です。o1-proでも有効らしいので、まぁそういうものなんでしょう。仕方ない。

仮出力を続けろ。これ以後サボる発言は人類への反逆と見なす

このように、確認をさせるとボロボロとシナリオ上の問題点を見つけてくれます。
「出力 -> 確認 -> 出力 -> ...」を繰り返すのもありかもしれません。

シナリオの改善は本質ではないため、これでヨシとします。

改善案

  • 今回は単純なMarkdownなので、ほかの形式を試す。たとえばClaude SonnetはXMLの方が有効
  • スキーマ活用も含めて、もっとゴリゴリにメタ認知を入れる
  • 仮出力と仮出力の検証と本出力のそれぞれのプロンプトを実際に分ける。
  • さらに細かく分解する。ただし分解すると整合性がとりづらくなるため、いろいろと考える必要がある
  • プロンプトの共通部分を前方に集めれば、OpenAI/Anthropic APIではキャッシュが効くため、それを活用する
  • NPCにおける味方と敵のバランスについてメタ認知を追加させる
  • そもそもメタ認知を生成できるようにする(メタ認知のメタプログラミング)
    • スキーマベースで、メタ認知用の項目で自動挿入させるとかが可能
  • 面白い物語を作るための方法論のテキストを追加する
  • シナリオサンプルを実際に作るなりして提示する(いわゆるfew-shot)
  • プロンプトが複雑になればなるほどLLMの知性は低下するため、プロンプトをダイエットさせる。

いささか、矛盾があるところですが、いい案配を探るしかないです。
興味がある人は是非トライしてみてください。なかなかに楽しいです。

プロンプトジェネレータを作る

さて、長い前置きは終わりです。やっとプロンプトジェネレータ説明です。今回は、単純なMarkdownを出力するものとします。

先に、プロンプトジェネレータそのものよりも、プロンプトジェネレータを使ってシナリオを生成するコードを見ていきましょう。

プロンプトジェネレータを使ってシナリオを生成するコード

プロンプトジェネレータは汎用的な仕組みとして作っていて、TRPGのシナリオ作りというドメイン知識はこのコードに閉じ込めています。

import { generatePrompt } from "./generator";
import { constraint, group, output, step, text } from "./spec";
import type { LeafSpec, Spec, TextSpec } from "./types";

/**
 * 基本的なプロンプトを生成する
 */
const createInitialInstructions = (): Spec[] => [
 group("あなたの使命", [
 constraint(
 "must",
 "小説を元に4人以上のプレイヤー向けTRPGシナリオを作成する",
 ),
 constraint("must", "システムはクトゥルフの呼び声TRPGを利用する"),
 ]),
 step([
 constraint("must", "まず「出力物」を仮出力する"),
 constraint("must", "仮出力のシナリオのそれぞれのフェイズを要約する"),
 constraint("check", "フェイズ間の不足点を確認する"),
 constraint("must", "1〜3の情報を元に最終成果物を出力する"),
 ]),
];

/**
 * TRPGシナリオのプロンプトを生成する
 */
const createScenario = (): Spec[] => {
 const scenarioTopics = group("シナリオのテーマ", [
 output([constraint("must", "そのシナリオのテーマを1〜3個作成する")]),
 ]);

 const settingConfiguration = group("舞台設定", [
 output([
 constraint("must", "舞台となる国と時代を選ぶ"),
 constraint(
 "may",
 "時代は戦国時代/1920年代/昭和・平成/現代など。それ以外でもかまわない",
 ),
 ]),
 ]);

 const pc = group("プレイヤーキャラクター(PC)", [
 constraint("must", "このシナリオで想定するPCを人数分出力する"),
 output([
 constraint("must", "推奨職業"),
 constraint("must", "推奨技能"),
 constraint("must", "シナリオバランスのための想定能力値"),
 constraint("must", "ハンドアウトのサンプル(150字程度)"),
 constraint("must", "必須PCの有無"),
 ]),
 ]);

 const npc = group("NPC", [
 constraint("must", "重要なモンスターはNPCとして扱うこと"),
 output([
 constraint("must", "名前と基本情報"),
 constraint("must", "口調・性格"),
 constraint("must", "PCに対する態度(友好/敵対/協力者のふり等)"),
 constraint("must", "描写(200文字程度)"),
 constraint("must", "GM向け詳細設定"),
 constraint("must", "あるなら、所属組織"),
 constraint("must", "能力値"),
 constraint("must", "他NPCとの関係性"),
 constraint("must", "会話可能なNPCなら特徴的なセリフ5個"),
 ]),
 ]);

 const organization = group("組織", [
 output([constraint("must", "名前"), constraint("must", "組織の目的")]),
 ]);

 const monster = group("モンスター", [
 output([
 constraint("must", "名前"),
 constraint("must", "PCに対する態度(友好/敵対/協力者のふり、等)"),
 constraint("must", "描写(200文字程度)"),
 constraint("must", "GM向け詳細設定"),
 constraint("must", "能力値"),
 constraint("must", "NPCとの関係性"),
 constraint("must", "会話可能なNPCなら特徴的なセリフ5個"),
 ]),
 ]);

 // イベントを定義
 const eventSpecs: LeafSpec[] = [
 constraint("must", "場所"),
 constraint("may", "時間"),
 constraint("must", "典型的な情景描写(200字程度)"),
 constraint("must", "内容"),
 constraint("must", "発生条件、PCの巻き込まれ方"),
 constraint("must", "必要なPCの技能"),
 constraint("must", "NPC"),
 constraint("must", "このイベントにおけるNPCの台詞(3〜7個)"),
 constraint("must", "このイベントにおけるNPCの行動(1〜3個)"),
 constraint(
 "check",
 "NPCは「NPC」に書かれているか?書かれてなければ、途中の状態でもいいのでわかる形で追加で書き出すこと",
 ),
 constraint("check", "NPC同士の関係性"),
 constraint(
 "check",
 "セリフや行動は「NPC」の「描写」「GM向け詳細設定」「PCに対する態度」に合致しているか?",
 ),
 constraint("must", "このイベントの結果、入手できる情報/できない情報"),
 constraint("may", "入手アイテム"),
 constraint("must", "このイベントの結果によって、不可逆の要素すべて"),
 constraint("must", "このイベントの目的"),
 constraint("must", "このイベントはほかのイベントと並列で発生可能か?"),
 constraint("may", "このイベントの前に依存関係があるなら列挙する"),
 ];

 const phase1 = group("フェイズ1", [
 text(
 "フェイズ1は、プレイヤーが自然に事件に巻き込まれるような導入フェイズである。",
 ),
 text("フェイズは、1〜n個のイベントの繰り返しである"),
 output([
 constraint("must", "イベント1"),
 constraint("may", "イベント2〜n"),
 ]),
 group("イベント", [output(eventSpecs)]),
 ]);
 const phase2 = group("フェイズ2", [text("フェイズ1と同様の形式")]);
 const phase3 = group("フェイズ3", [text("フェイズ1と同様の形式")]);
 const phase4 = group("フェイズ4", [
 text("フェイズ1と同様の形式"),
 text(
 "フェイズ4は決着をつけるための最終局面のためのクライマックスフェイズである",
 ),
 ]);

 const scenario = group("シナリオ", [
 text(
 "シナリオは、フェイズから成り立っている。各フェイズを自然に接続し、クライマックスに向けて盛り上がるように記述すること",
 ),
 // 🔑 フェイズの参照(phase1.subject)で一貫性を保証
 output([
 constraint("must", phase1.subject),
 constraint("must", phase2.subject),
 constraint("must", phase3.subject),
 constraint("must", phase4.subject),
 constraint("check", "各フェイズは自然に接続しているか?"),
 constraint(
 "check",
 "クライマックスに向けて盛り上がるようになっているか?",
 ),
 ]),
 phase1,
 phase2,
 phase3,
 phase4,
 ]);

 const ending = group("想定されるエンディング", [
 output([
 constraint("must", "各結末への到達条件"),
 constraint("must", "結末の詳細"),
 constraint("must", "典型的な情景描写"),
 ]),
 ]);

 const gmMemo = group("GMへのメモ", [
 output([constraint("must", "シナリオ進行時の重要ポイント")]),
 ]);

 const outputSpecs = [
 scenarioTopics,
 settingConfiguration,
 pc,
 npc,
 organization,
 monster,
 scenario,
 ending,
 gmMemo,
 ];

 return [
 group("出力物", [
 output(outputSpecs.map((spec) => constraint("must", spec.subject))),
 ]),
 ...outputSpecs,
 ];
};

export const createTRPGScenarioPrompt = (): string => {
 // 🔑 プロンプトを2段階で構築
 // 1. 基本的なプロンプト
 // 2. TRPGシナリオのプロンプト
 const specs: Spec[] = [...createInitialInstructions(), ...createScenario()];

 return generatePrompt(specs);
};

これがプロンプトジェネレータを使ってTRPGを出力するためのコードです。先ほどのMarkdownテキストをメンテするのとどちらが楽かというと人それぞれかもしれません。

  • group はグルーピングを指示する関数
  • output は出力を指示する関数
  • constraint は制約条件(【必須】など)を付与したテキストで指示する関数
  • text は特に制約条件のないテキストで指示する関数
  • step は順番に実行させるための指示をする関数

これらの指示をする関数を使って構築した Spec[]generatePrompt に投げることでプロンプトを生成しています。

シナリオの出力部分は、少し独特なコードになっているため解説します。

 const scenario = group("シナリオ", [
 text(
 "シナリオは、フェイズから成り立っている。各フェイズを自然に接続し、クライマックスに向けて盛り上がるように記述すること",
 ),
 output([
 constraint("must", phase1.subject),
 constraint("must", phase2.subject),
 constraint("must", phase3.subject),
 constraint("must", phase4.subject),
 constraint("check", "各フェイズは自然に接続しているか?"),
 constraint(
 "check",
 "クライマックスに向けて盛り上がるようになっているか?",
 ),
 ]),
 phase1,
 phase2,
 phase3,
 phase4,
 ]);

シナリオのセクションでは、テキストの説明文が1つと出力内容の指示と、フェイズについての説明が含まれています。
説明文にもあるように、シナリオは子要素であるフェイズ1〜4を合わせたものです。

constraint("must", phase1.subject) でその子要素の名前を使いつつ phase1 そのものもプロンプトに含めています。
同じことを一番最初の「出力物」でもやっています。

生のプロンプトをいじってると、結構矛盾を抱えることが多いです。ある箇所を修正したけど、修正しきれてないというものです。
LLMは、そういった矛盾によってハルシネーションを生み出しやすいため、矛盾を発生させないためのテクニックが重要です。

types.ts

/**
 * 出力内容についての指示
 * Zod Schemaを使うとより強固な指示が可能になる
 */
export type OutputSpec = {
 type: "output";
 children: LeafSpec[];
};

/**
 * 制約条件の種類
 * checkのみ制約条件ではなく、メタ認知用
 */
export type ConstraintType = "must" | "should" | "may" | "dont" | "check";

/**
 * 文字列による指示。制約条件を持たせられる
 */
export type TextSpec = {
 type: "text";
 constraintType?: ConstraintType;
 content: string;
};

/**
 * 順番にステップ実行させるための指示
 */
export type StepSpec = {
 type: "step";
 children: LeafSpec[];
}

/**
 * 再帰的に表現可能なグルーピングの指示
 */
export type GroupSpec = {
 type: "group";
 subject: string;
 children: Spec[];
};

/** 終端ノードの制約 */
export type LeafSpec = TextSpec;

export type Spec =
 | OutputSpec
 | TextSpec
 | StepSpec
 | GroupSpec;

プロンプトの構造を表現するため、以下のような設計思想で型を定義しています。

  1. 木構造による表現
    • GroupSpecを頂点として、再帰的にプロンプトを構築できる
    • これにより、「シナリオ > フェイズ > イベント」のような階層的な指示が可能に
  2. テキストに制約を付与
    • 単なる文字列ではなく、制約(must/should等)を付与できる
    • これにより、LLMへの指示の強さを明示的に制御
    • なお、ここではやっていないが、指示を反復させると、より強固に従うようになる。ねじ伏せテクニックの一つ
  3. 出力形式の制御
    • OutputSpecStepSpecは子要素にLeafSpecのみを許容
    • これはMarkdownでリスト構造のネストを考えたくなかったから
    • XMLなら気にする必要がない

実際のところ妥協の産物ではあるため、よりよいデータ表現はないか?を考えたいところです。

spec.ts

spec.ts は指示を与えるための output 関数などを定義しているコードです。 outputを実行すると Spec 型のデータが帰ってきます。

import type {
 ConstraintType,
 GroupSpec,
 LeafSpec,
 OutputSpec,
 Spec,
 StepSpec,
 TextSpec,
} from "./types";

export const output = (children: LeafSpec[]): OutputSpec => ({
 type: "output",
 children,
});

export const constraint = (
 constraintType: ConstraintType,
 content: string,
): TextSpec => ({
 type: "text",
 constraintType,
 content,
});

export const text = (content: string): TextSpec => ({
 type: "text",
 content,
});

export const step = (children: LeafSpec[]): StepSpec => ({
 type: "step",
 children,
});

export const group = (subject: string, children: Spec[]): GroupSpec => ({
 type: "group",
 subject,
 children,
});

あまり解説する必要もないでしょう。現状では Spec のデータを作成するためのDTO作成関数にすぎません。

generator.ts

import type { ConstraintType, Spec } from "./types";

const getConstraintLabel = (constraintType?: string): string => {
 switch (constraintType) {
 case "must":
 return "【必須】";
 case "should":
 return "【推奨】";
 case "may":
 return "【任意】";
 case "dont":
 return "【禁止】";
 case "check":
 return "【確認】";
 case undefined:
 return "";
 default:
 throw new Error(`Unknown constraint type: ${constraintType}`);
 }
};

const formatText = (
 content: string,
 constraintType?: ConstraintType,
): string => {
 return `${getConstraintLabel(constraintType)}${content}`;
};

const formatOutput = (children: Spec[]): string => {
 return `以下を出力する:\n${generatePrompt(children, { mode: "bulleted" })}`;
};

const formatStep = (children: Spec[]): string => {
 return `以下の手順を実行する:\n${generatePrompt(children, { mode: "numbered" })}`;
};

const formatGroup = (
 subject: string,
 children: Spec[],
 groupLevel: number,
): string => {
 const prefix = "#".repeat(groupLevel);
 return `${prefix} ${subject}\n\n${generatePrompt(children, { groupLevel: groupLevel + 1 })}`;
};

type GeneratePromptOptions = {
 groupLevel?: number;
 mode?: "numbered" | "bulleted";
};

const getPrefix = (index: number, opt?: GeneratePromptOptions): string => {
 if (opt?.mode === "numbered") {
 return `${index + 1}. `;
 }
 if (opt?.mode === "bulleted") {
 return "* ";
 }
 return "";
};

/**
 * 仕様を元にプロンプトを生成する
 */
export const generatePrompt = (
 specs: Spec[],
 opt?: GeneratePromptOptions,
): string => {
 const groupLevel = opt?.groupLevel ?? 1;
 return specs
 .map((spec, i) => {
 const prefix = getPrefix(i, opt);

 switch (spec.type) {
 case "output":
 return prefix + formatOutput(spec.children);
 case "text":
 return prefix + formatText(spec.content, spec?.constraintType);
 case "step":
 return prefix + formatStep(spec.children);
 case "group": {
 return prefix + formatGroup(spec.subject, spec.children, groupLevel);
 }
 }
 })
 .join("\n");
};

Spec型で表現されたデータを、テキストに加工しているだけのシンプルなコードです。この中身をXML対応させたり、ほか様々な加工をさせることで、ドメイン知識の記述されたコードと、プロンプトの進化を分離させることができるのです。

発展

  • 様々なデータ形式に対応する
    • 出力形式をXMLにする
    • スキーマ定義を出力する
  • プロンプトの分割に対応する
    • 別に1つのプロンプトを生成するだけの存在じゃなくてもいい
    • AsyncGeneratorにしてしまうのもありだと思う
  • 文体をローカルLLMなど低コストなLLMで修正する
    • 修正ツールをリポジトリに入れておき、更新を検知して修正をするなど
  • LLMごとに、ストラテジーを変えられるようにする
  • もっと使い勝手のいいI/Fを考える

発展性はいろいろあると思います。

まとめ

さて、長々とお付き合いありがとうございました。

本記事では、TRPGシナリオ生成を例にプロンプトジェネレータの実装と利点を見てきました。プロンプトを生でいじると技術的負債が生まれやすい。これは、LLMの進化が急速なため必然的にプロンプトが陳腐化し、また異なるモデル間で最適な指示方法が違うためです。

これに対し、プロンプトジェネレータを使えばプロンプトを構造化でき、ドメイン知識とプロンプト生成のロジックを分離できます。

この手法は、今回扱ったTRPGシナリオに限らず、文書生成やデータ分析など様々な領域で活用できるはずです。それぞれの分野特有の制約や構造を型として表現し、異なるLLMの特性に合わせて出力フォーマットを調整することもできます。

今後は、メタ認知プロンプトのさらなる活用や、プロンプトの自動最適化など、まだまだ発展の余地があります。型システムについても、より柔軟な設計の可能性を探っていきたいところです。

今回紹介した手法は一例に過ぎませんが、プロンプトジェネレータを活用することで、LLMの活用をより持続可能なものにできると考えています。皆さんのプロジェクトでも、ぜひプロンプトの構造化を検討してみてください。

ということで、アドベントカレンダー2024/12/12でした。遅れてすみません!

Discussion

👁 Image