BEACHSIDE BLOG

Azure と GitHub と C# が好きなエンジニアの個人メモ ( ・ㅂ・)و ̑̑

プロンプトエンジニアリングの基礎: 1/3 (C# +Azure.OpenAI SDK)

DeepLearning.AIというサイトでは、アカウントを登録するだけで AI に関する学習コンテンツがあります。

その中の無償コンテンツのひとつで OpenAI + Python の「ChatGPT Prompt Engineering for Developers」を受講してみました。

そこで学んだことを Azure OpenAI + C# で Azure.OpenAI の SDK を使って実装しながら自分なりに振り返っていきます。

今回だけだと書ききれそうになかったので3つにわけました。順次公開したらリンクにしていきます。

  • プロンプトエンジニアリングの基礎: 1/3 (C# +Azure.OpenAI SDK) 👈 今ここ
    • プロンプトエンジニアリングにおける2大原則
  • プロンプトエンジニアリングの基礎: 2/3 (C# +Azure.OpenAI SDK)
    • プロンプトの育て方、文章要約、感情分析+α の Tips
  • プロンプトエンジニアリングの基礎: 3/3 (C# +Azure.OpenAI SDK)
    • 概要は後日更新予定

今回は、プロンプトエンジニアリングのガイドラインとなる2つの原則に対して実装例を書いていきます。

準備

.NET7 でコンソールアプリを作って、以下のNuGet パッケージを入れています。

  • Azure.AI.OpenAI: 1.0.0-beta.5

また VS の UserSecrets から Azure OpenAI の情報を取得しています。実装方法は前回のブログに書いているのでリンクをはっておきます。

blog.beachside.dev

Azure OpenAI の準備や細かい実装方法は↑のリンクに書いてますのでそちらをみてもらいつつ、あとはこんなコードで主に try-and-error region の中身を変えながらプロンプトエンジニアリングの Tips をまとめていきます。

using Azure;
using Azure.AI.OpenAI;

namespace Learning.PromptEngineering;

internal class Program
{
    static async Task Main()
    {
        var options = OpenAIOptions.ReadFromUserSecrets();
        var client = new OpenAIClient(new Uri(options.Endpoint), new AzureKeyCredential(options.ApiKey));

        #region try-and-error

        var prompt = """
            架空の映画のタイトルと監督・主演とジャンルのリストを3つ生成してください。
            回答はJSON形式で回答してください。
            """;

        #endregion

        var result = await GetChatCompletionAsync(client, options, prompt);
        Console.WriteLine(result);
    }

    private static async Task<string> GetChatCompletionAsync(OpenAIClient client, OpenAIOptions options, string prompt)
    {
        var chatCompletionsOptions = new ChatCompletionsOptions
        {
            MaxTokens = 1000,
            Messages =
            {
                new ChatMessage(ChatRole.User, prompt)
            }
        };

        var response = await client.GetChatCompletionsAsync(options.DeploymentName, chatCompletionsOptions);
        return response.Value.Choices[0].Message.Content;
    }
}

public class OpenAIOptions
{
    public string Endpoint { get; set; }
    public string ApiKey { get; set; }
    public string DeploymentName { get; set; }

    public static OpenAIOptions ReadFromUserSecrets()
    {
        var builder = new ConfigurationBuilder()
            .AddUserSecrets<Program>();
        return builder.Build().GetSection(nameof(OpenAIOptions)).Get<OpenAIOptions>();
    }
}

では、ここからは本題としてプロンプトエンジニアリングをする上での2つの原則とその原則を実践するために使う Tips を書いていきます。

原則1: 明確で具体的な指示を出す

「明確」は「 短く」って意味ではなく、むしろ長くて具体的な指示である方がよい (長ければいいって話しではないけど)。

明確で具体的な指示をだすために使ういくつかの Tips をあげます。

1-1: 区切り文字を使う

区切り文字として使う代表的なのは、"```" や "---" や "###" や html のタグなど。この実践例はこのあといくつかでてきます。

1-2: 構造化された出力を指示する

箇条書きの文章なのか、HTML や JSON といった出力形式などを名確認指定するのがよいです。JSON などは、出力するプロパティするのがよいです。

ということで試してみましょう。まずはこんなプロンプトにしてみました。

        var prompt = """
            架空の映画のタイトルと監督・主演とジャンルのリストを3つ生成してください。
            回答はJSON形式で回答してください。
            """;

結果はこんな感じ。それっぽいけど後続で API たたくようなことがあるなら JSON がちょっといまいちですね。そしてなんで英語だよってなりますね。

プロンプトを改善してみましょう。

        var prompt = """
            架空の映画のタイトルと監督・主演とジャンルのリストを3つ生成してください。
            日本語で回答してください。
            回答は、JSON形式でJSON フォーマットのキーは以下を使います。
         
            title, director, mainActor, genre
            """;

結果は...うーんそれっぽいけど、JSON はそのまま API の body に入れるにはしんどい。

もうひとがんばりしてみます。回答例の区切り文字にはタグを使ってみました。区切り文字に "```" を使いがちな私ですが、今回色々試してると、タグを使うのが LLM が一番理解してくれやすいのかなぁと感じました。

        var prompt = """
            あなたのタスクは、架空の映画のタイトルとその監督、主演とジャンルを作ることです。
            日本語で回答します。
            3つの映画の回答を生成します。
           
            回答は、"回答例" のタグを参考にJSONフォーマットの配列で出力してください。

            <回答例>
            [
             { "title": "1つめの映画のタイトル", "director": "1つめの映画の監督" "actor", "genre":"1つめの映画のジャンル" },
             { "title": "2つめの映画のタイトル", "director": "2つめの映画の監督" "actor", "genre":"2つめの映画のジャンル" },
             { "title": "3つめの映画のタイトル", "director": "3つめの映画の監督" "actor", "genre":"3つめの映画のジャンル" }
            ]
            </回答例>
            """;

これくらいまで書くと (私が勝手に想定していた) 回答のフォーマットにたどり着きました。ここらへんは想定通りのフォーマットになるように試行錯誤が必要だなと感じます。

JSON のフォーマットは具体的に書かないと結構事故る印象があるので注意だなぁと感じる今日この頃です。

1-3: 条件が満たされているかを確認する

今回は、なんらかの手順が書かれたテキストを箇条書きにする、手順が書かれていないテキストに対しては回答しないという条件を定義しました。出力形式も箇条書きっぽい感じに指定しています。

text って変数を使ってますが、実際チャットするときはこれがユーザーからの INPUT になるイメージです。

        var text = """
            おいしい抹茶のいれかたは、まずふるった抹茶を茶杓2杯分( 約2グラム )茶碗に入れます。
            次に、70から80度に冷ましたお湯を70ミリリットル準備し、そこから少量を茶碗に注ぎ、ダマができないように、茶せんで溶くようにして混ぜます。
            残りのお湯を注いで、手首を前後に動かして最初は小刻みに、次は表面を整えるようにして混ぜます。大きな泡ができたら茶筅の先でつぶし、表面を整えるときれいに見えます。
            泡が細かいほど口当たりがなめらかでおいしく仕上がります。これで完成です。おいしいお菓子と一緒にゆっくり味わいましょう。
            """;

        var prompt = $"""
            ### で区切られたテキストが提供されます。一連の手順が含まれている場合、その手順を次の形式で書き直します。

            Step 1: ...
            Step 2: ...
            ...
            Step N: ...

            もし、テキストに手順がふくまれていない場合は、「手順は含まれていません。」と回答します。

            ### {text} ###
            """;

出力はこんな感じ。

テキストに手順が含まれないのを入れると想定通りに動作もします。

1-4: Few-shot プロンプトを使う

代表的なテクニックのひとつ、Few-shot プロンプトです。タスクの成功例を提示し、その後にタスクを依頼するってやつです。

多少だめそうな成功例を提示しても、ちゃんと答えてくれます。

        var question = "太陽系で一番大きい惑星は何ですか。";

        var prompt = $"""
            あなたのタスクは、一貫したスタイルで回答することです。

            <User>: 北海道は大きいですか
            <Assistant>: でっかいどー

            <User>: 太陽の大きさは
            <Assistant>: でっかいどー

            <User>: {question}
            """;

回答の最後の一行を補完する感じで回答してくれました。

ちなみに、prompt の最後に <Assistant>: とつけると...

            <User>: {question}
            <Assistant>:
            """;

回答は <Assistant>: より後ろの回答をしてくれます。賢い。

原則2: Model に考える時間を与える

実行したらレスポンス遅いから気長に待つっていう意味ではないです。複数のステップを踏んで解決してほしいときは明示的に書いてあげるって感じでしょうか。

(理解が正しいのか我ながら疑念がありますが) では Tips を書いていきます。

2-1: タスクを完了するための指示を与える

今回は桃太郎を題材にしてみます。桃太郎のあらすじの要約は、こちらのサイトのあらすじ②を使ってみます。

テキストとプロンプトはこんな感じにしてみました。

        var text = """
            あるところにおじいさんとおばあさんが住んでいて、おじいさんは山に芝を刈りに行きおばあさんは川で洗濯をしに行っていると、川から大きな桃が流れてきます。早速大きな桃を食べるために持ち帰り中を割ってみると中から赤子が出てきたではありませんか。
            二人は桃から生まれた赤子に桃太郎と名付けます。桃太郎は急速に成長していき、ある時、とあるうわさを聞きつけます。なんでも、悪い鬼が人間から金銀財宝を巻き上げているという噂を。
            桃太郎は鬼の悪事が許せず、鬼を退治しに行くと育ててくれたお爺さんおばあさんに打ち明けました。するとおじいさんとおばあさんは、旅先で食べるようにと黍団子を手渡すのです。
            旅の道中犬に出会った桃太郎は、犬から黍団子が欲しいと打ち明けられ、その代わりに鬼退治で加勢してほしいと頼み犬は加勢に了承します。今度は猿に同じことを言われ猿も加勢に賛同し、最後にキジと出会い記事にも黍団子を私すべての黍団子を使い果たします。
            桃太郎は犬、猿、記事をお供に従えて鬼退治へと鬼が住む島に乗り込むのです。鬼ノ島に乗り込んだ桃太郎一行は鬼と対峙し、双方争いの中、桃太郎側に勝敗が傾くと鬼たちは降参の意向を示し始めますが果たしてどうなるのでしょうか。
            """;

        var prompt = $"""
            あなたのタスクは、次のアクションを実行することです。
            1. 以下にある ### で区切られた TEXT を100文字程度の1文に要約します。
            2. 要約した文章を英語に翻訳します。
            3. 登場人物の名前を出力します。
            4. JSON Object を出力します。JSONのキーには "jp_summary""names" を使います。

            それぞれの回答は、改行で区切ります。

            TEXT:
            ###
            {text}
            ###
            """;

回答はこんな感じで想定通りにでます。

出力のフォーマットをもう少し指定して試してみましょう。

        var prompt = $"""
            あなたのタスクは、次のアクションを実行することです。

            1. 以下にある ### で区切られた TEXT を100文字程度の1文に要約します。
            2. 要約した文章を英語に翻訳します。
            3. 登場人物の名前を出力します。
            4. JSON Object を出力します。JSONのキーには "jp_summary""names" を使います。

            回答は次の形式で出力します。

            概要: <要約>
            翻訳: <要約の翻訳>
            登場人物: <登場人物のリスト>
            テキスト: <要約するテキスト>

            TEXT:
            ###
            {text}
            ###
            """;

想定通りに出力されますね。

2-2: 結論を急ぐ前に独自の解決策を見つけるように指示する

計算問題に対して、生徒が正しい回答かを評価するプロンプトで試していきます。今回は生徒の回答を意図的に間違ってみます。

まずはこんな感じ。

        var prompt = """
            あなたのタスクは、問題に対して生徒の回答が正しいかを判断することです。

            タスクを完了するのに以下の手順で行ないます。

            - 最初に、あなたが正しい回答を作成してください。 
            - 次に、あなたの回答と生徒の回答を比較して、生徒の回答が正しいかを評価してください。

            自身で問題を解くまでは、生徒の回答が正しいか判断してはいけません。
            以下のフォーマットを使って返答してください。

            ## 問題

            <ここに問題が書かれます>

            ### 生徒の回答
           
            <ここに生徒の回答が書かれます>

            ## 評価

            ### 正しい回答

            <解決までの手順を step by step でここに書きます>
     
            ### 生徒の回答は正しいか

            <正解または不正解で回答>

            ## 問題
            
            太陽光発電の建設にあたり、費用は以下です。

            - 土地代は、30000/平方メートル
            - ソーラーパネルのコストは、40000/平方メートル
            - 年間の保守費用は基本料金として1000000円と、追加の保守費用として5000/平方メートルがかかります。
            
            1年間のX平方メートルの合計コストは何円になるでしょうか。
           
            ### 生徒の回答
            
            それぞれのコストは
            - 土地代: 30000X
            - ソーラーパネルのコスト: 40000X
            - 保守費用: 5000X
            
            したがって合計は 30000X + 40000X + 1000000 + 5000X = 72000X + 1000000

            ## 評価
            """;

結果は、LLM の計算がおかしい。何度か試すと LLM が正しい回答を返すけど生徒の評価が間違っていたりと結構不安定。。。。。生徒の回答を正しいのに変えても何度か評価しても不安定。これは単純に日本語の理解が苦手なだけっぽい。

「結論を急ぐ前に独自の解決策を見つけるように指示する」って本質からはちょっとそれるけど、以下のように英語に翻訳して正答を作るよう指示すると品質はかなり安定したので、それもそれでひとつの Tips ですね。

            タスクを完了するのに以下の手順で行ないます。

            - 最初に、問題を英語に翻訳し、翻訳した文章を使ってあなたが正しい回答を作成してください。 
            - 次に、あなたの回答と生徒の回答を比較して、生徒の回答が正しいかを評価してください。

この原則については、「自身で問題を解くまでは、生徒の回答が正しいか判断してはいけません。」って指示がキーになるような雰囲気を感じたけど日本語だといまいち効果は感じなかった。英語だとまた違うんだろうなぁとか思いつつ次に進みます。

2-3: Hallucinations (幻覚)

Hallucinations ... LLM は存在しないものもあたかもあるように回答します。3.5 から 4 になって Hallucinations もかなり減ったらしいですが、完全になくすことは難しいでしょう。

ということで、Model はうそも平気でいうから気を付けようねって話です。人間もよっぽど嘘や間違ったこというから LLM ばっか責めるなよって思ってしまう今日この頃ですが。

Hallucinations を軽減させるには、最初に関連情報を与えてから関連情報に基づいた質問に回答させるとかが基本になるみたいだけど、どうプロンプトを構成すべきかは考慮すべきことたくさんあるから注意しようくらいの話にしておきます。

次回へ続く

長くなったので、今日はこの辺で続編シリーズにします。

blog.beachside.dev