Azure AI Studio Prompt flow によるプロンプトのテストと評価ハンズオン④ - Function calling

Azure AI Studio の Prompt flow によるテストと評価のハンズオン第四回目では Function calling の評価をしていきたいと思います。

まずはテンプレートチャットフローから始めましょう。Azure AI Studio の「プロンプトフロー」メニューからプロンプトフローの「＋作成」ボタンをクリックして、ギャラリーから「Use GPT Function Calling」を探して「クローン」ボタンをクリックして名前を入力して複製します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/c5337fcc-ba24-8c35-09ae-cdc4d25cafd1.png)

デフォルトの Function calling フローは以下のような処理フローとなっています。

1. `use_functions_with_chat_models`: ユーザーの質問から呼ぶべき関数を選んでパラメータを抽出する
1. `run_function`: 選択した関数を実行して結果を返す

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/c5bc0e41-296e-ae6d-4fff-e9426fac7065.png" width="300" />

# 1. カスタマイズ
今回は以前紹介した[出張申請アシスタント](https://qiita.com/nohanaga/items/fa9f5260cf85a7fddc52#%E3%82%A2%E3%82%B7%E3%82%B9%E3%82%BF%E3%83%B3%E3%83%88%E3%81%AE%E5%BD%B9%E5%89%B2)の例を使用して、レストランとホテルを検索する関数を実装していきたいと思います。

## 1.1. プロンプトエンジニアリング
`use_functions_with_chat_models` ノードのシステムプロンプトを以下のように書き換えます。その下のチャット履歴の部分はそのままにしておきます。

```
# system:
あなたは Contoso 社の社員の出張を支援するためのアシスタントです。あなたは以下の業務を遂行します。
 - 旅程を作成します
 - ホテルを検索したり予約します
 - 交通機関を検索します
 - 出張で行くべきレストランや居酒屋を提案します
 - 出張にかかる概算費用を計算します

#制約事項
 - ユーザーからのメッセージは日本語で入力されます
 - ユーザーからのメッセージから忠実に情報を抽出し、それに基づいて応答を生成します。抽出できない場合は必要な項目を聞き返します。
 - ユーザーからのメッセージに勝手に情報を追加したり、不要な改行文字を追加してはいけません
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/2999a540-043e-f460-49d5-31a1373b4e7a.png)

## 1.2. 関数呼び出しの追加
プロンプトボックスの上にある「関数呼び出し」部分をクリックして開き、`functions` 項目のテキストボックス右側の編集ボタンをクリックして以下の JSON を貼り付けて保存します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/410a3a24-48f8-d79d-bad2-aa3989b19e4c.png)

### スキーマ定義
このスキーマに沿うようにパラメータを抽出せよというモデルへの指示です。

```json
[
    {
        "name": "search_hotpepper_shops",
        "description": "ホットペッパーグルメAPIを利用し、キーワードや個室の有無などのオプションフィルターで飲食店を検索できます。",
        "parameters": {
            "type": "object",
            "properties": {
                "keyword": {
                    "type": "string",
                    "description": "飲食店を検索するためのキーワード。店名、住所、駅名、お店ジャンルなどを指定できる。ユーザーメッセージから検索キーワードとなる文字を抽出して検索クエリーにしてください。例: ###大阪駅 和食###"
                },
                "private_room": {
                    "type": "integer",
                    "description": "個室ありの店舗のみを検索, 0:絞り込まない, 1:絞り込む。オプション",
                    "enum": [
                        0,
                        1
                    ]
                }
            },
            "required": [
                "keyword"
            ]
        }
    },
    {
        "name": "search_vacant_hotels",
        "description": "楽天トラベルのAPIを使って、場所、チェックイン日、チェックアウト日、予算、大人の人数など、さまざまなフィルターで空室ホテルを検索できます。",
        "parameters": {
            "type": "object",
            "properties": {
                "latitude": {
                    "type": "number",
                    "description": "ホテル検索場所の緯度(WGS84), ex:35.6065914"
                },
                "longitude": {
                    "type": "number",
                    "description": "ホテル検索場所の経度(WGS84), ex:139.7513225"
                },
                "searchRadius": {
                    "type": "number",
                    "description": "緯度経度検索時の検索半径(単位km), 0.1 to 3.0"
                },
                "checkinDate": {
                    "type": "string",
                    "description": "yyyy-MM-dd 形式のチェックイン日。年の指定がない場合は2024年を指定する。"
                },
                "checkoutDate": {
                    "type": "string",
                    "description": "yyyy-MM-dd 形式のチェックアウト日。年の指定がない場合は2024年を指定する。"
                },
                "maxCharge": {
                    "type": "integer",
                    "description": "上限金額, int 0 to 999999999"
                },
                "adultNum": {
                    "type": "integer",
                    "description": "宿泊者数, int 1 to 99"
                }
            },
            "required": [
                "latitude",
                "longitude",
                "searchRadius",
                "checkinDate",
                "checkoutDate"
            ]
        }
    }
]
```

あと、現状の Function calling ノード、バージョン古いですね。Azure OpenAI API の `2023-12-01-preview` バージョンのリリースに伴い、`functions` および `function_call` パラメーターは非推奨になりました。`functions` に置き換わるのは `tools` パラメーターで、`function_call` に置き換わるのは `tool_choice` パラメーターですが、まだ反映されていません。今後のアップデートに期待です。


:::note warn
注意
今後 [Structured Outputs](https://qiita.com/nohanaga/items/8f66bb759639aca88a6f) の機能も載ってくる可能性がありますので、この記事はすぐ古くなるかもしれません。
:::

これで `use_functions_with_chat_models` ノードを実行すると、必要な関数の名前とパラメーターが返るようになります。ここでフローを保存して、チャットボックスからテストしてみましょう。

`有楽町近辺でおすすめのイタリアンを探しています`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/3613b383-e64a-e69c-d071-a9b3d3a9fe87.png)

今は実行すべき関数が無いので `run_function` ノードで KeyError が発生しますが、`use_functions_with_chat_models` ノードの出力を見ると、ちゃんと `function_call` に呼ぶべき関数の名前と引数が出力されていることが分かりますね。事前に `functions` で定義したスキーマ通りの結果が返ってきています。

```json
{
    "arguments":"{\"keyword\":\"有楽町 イタリアン\"}",
    "name":"search_hotpepper_shops"
}
```

## 1.3. 関数の追加
呼ぶべき関数とパラメーターの取得ができるようになったので、次の処理へ移ります。`run_function` ノードの中に呼ばれる関数を実装します。デフォルトでは天気を返す関数が入っているのでこれを以下のサンプルデータを返すスタブ関数で置き換えます。`run_function` 関数はそのままにしておきます。

実際はここで Web API をコールする処理を実装します。

```python
def search_hotpepper_shops(keyword=None, private_room=0, start=1, count=3):
    """ホットペッパーグルメAPIを利用し、キーワードや個室の有無などのオプションフィルターで飲食店を検索できます。"""
    params = {
        "keyword": keyword,
        "start": start,
        "count": count,
        "private_room": private_room,  # 個室ありの店舗のみを検索, 0:絞り込まない, 1:絞り込む
    }
    shops = [
    {
        "name": "トラットリア・AI 有楽町店",
        "address": "東京都千代田区有楽町２－４－１ 有楽町プラザ３Ｆ",
        "station_name": "有楽町",
        "access": "JR有楽町駅から徒歩2分／地下鉄日比谷駅A3出口から徒歩3分",
        "genre": {
            "name": "イタリアン・レストラン",
            "catch": "本格イタリアンとワインが楽しめるトラットリア",
            "code": "G008"
        },
        "budgetAverage": "2500円（通常平均）／1200円（ランチ平均）",
        "open": "月～日、祝日、祝前日: 11:00～23:00 （料理L.O. 22:00 ドリンクL.O. 22:30）",
        "close": "無"
    },
    {
        "name": "グリル&バー サンシャイン 有楽町",
        "address": "東京都千代田区有楽町２－８－４ サンシャインビル５Ｆ",
        "station_name": "有楽町",
        "access": "JR有楽町駅徒歩3分／地下鉄銀座駅徒歩5分",
        "genre": {
            "name": "ステーキ・グリル",
            "catch": "ステーキとワインが楽しめる",
            "code": "G008"
        },
        "budgetAverage": "4500円（通常平均）／1000円（ランチ平均）",
        "open": "月～日、祝日、祝前日: 11:00～23:00 （料理L.O. 22:00 ドリンクL.O. 22:30）",
        "close": "不定休"
    },
    {
        "name": "スシバー 銀座 有楽町店",
        "address": "東京都千代田区有楽町２－２－３ 銀座ビル１Ｆ",
        "station_name": "有楽町",
        "access": "JR有楽町駅徒歩1分／地下鉄銀座駅徒歩2分",
        "genre": {
            "name": "寿司",
            "catch": "新鮮なネタを使った寿司",
            "code": "G009"
        },
        "budgetAverage": "3000円（通常平均）／1200円（ランチ平均）",
        "open": "月～日、祝日、祝前日: 11:00～22:00 （料理L.O. 21:30 ドリンクL.O. 21:45）",
        "close": "無"
    }]
    return {"shops": shops, "params": params}

def search_vacant_hotels(latitude, longitude, searchRadius, checkinDate, checkoutDate, maxCharge=50000, adultNum=1, page=1, hits=2):
    """楽天トラベルのAPIを使って、場所、チェックイン日、チェックアウト日、予算、大人の人数など、さまざまなフィルターで空室ホテルを検索できます。"""
    params = {
        "page": page,
        "hits": hits,
        "latitude": latitude, # ex:35.6065914
        "longitude": longitude, # ex:139.7513225
        "searchRadius": searchRadius,  #緯度経度検索時の検索半径(単位km), 0.1 to 3.0
        "datumType": 1, # WGS84
        "checkinDate": checkinDate, # yyyy-MM-dd
        "checkoutDate": checkoutDate, # yyyy-MM-dd
        "maxCharge": maxCharge, # 上限金額, int 0 to 999999999
        "adultNum": adultNum # 宿泊者数, int 1 to 99
    }
    hotels = [
    {
        "hotelName": "シティホテル 東京ベイ",
        "address": "東京都江東区豊洲2-3-12",
        "checkInDate": checkinDate,
        "checkOutDate": checkoutDate,
        "price": maxCharge,
        "numberOfAdults": adultNum,
        "roomType": "ダブルルーム",
        "rating": "4.5",
        "amenities": [
            "無料Wi-Fi",
            "朝食バイキング",
            "フィットネスジム"
        ],
        "access": "地下鉄有楽町線 豊洲駅から徒歩5分"
    },
    {
        "hotelName": "新宿グランデホテル",
        "address": "東京都新宿区西新宿7-10-1",
        "checkInDate": checkinDate,
        "checkOutDate": checkoutDate,
        "price": maxCharge,
        "numberOfAdults": adultNum,
        "roomType": "ツインルーム",
        "rating": "4.2",
        "amenities": [
            "無料Wi-Fi",
            "大浴場",
            "レストラン"
        ],
        "access": "JR新宿駅から徒歩8分"
    },
    {
        "hotelName": "横浜ベイフロントホテル",
        "address": "神奈川県横浜市中区海岸通2-15-4",
        "checkInDate": checkinDate,
        "checkOutDate": checkoutDate,
        "price": maxCharge,
        "numberOfAdults": adultNum,
        "roomType": "スイートルーム",
        "rating": "4.8",
        "amenities": [
            "無料Wi-Fi",
            "朝食サービス",
            "プール",
            "スパ"
        ],
        "access": "みなとみらい線 元町・中華街駅から徒歩3分"
    }]
    return {"hotels": hotels, "params": params}
```
### `run_function` 関数（コメント付き）
パット見何をやっているか分かりづらいのでコメントつけておきましたが、`use_functions_with_chat_models` からの入力値から呼ぶべき関数名を取り出して、以下のコードで実行まで行います。ナルホド、データでの関数のやり取りはこーやってやってるんだ。

```python
# Text to 関数実行
globals()[function_name](**function_args)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/84715ab0-87b2-79bc-1a9a-8dca1de951ee.png)

ではこの状態でフローを保存し、チャットのテストを実施します。

`有楽町近辺でおすすめのイタリアンを探しています`


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/da8c37b4-1292-b443-a5e6-b1af699c22c6.png)

`run_function` ノードからの出力がレストランの配列であることが確認できます。これは実際に `search_hotpepper_shops` 関数が実行された結果です。ただし最終出力が JSON になってしまっているので、以下のように整形処理を追加します。

## 1.4. 整形処理の追加
デフォルトのフローだと関数の実行結果（JSON）を受けてチャットアシスタントの回答として整形する処理が入っていないので、これを追加します。整形処理には `gpt-4o` を使用したいと思います。

ツールバーから「＋LLM」ボタンをクリックして「MakeResponse」と名付けて追加します。以下のような与えられたリストに基づいて質問に回答する指示を含むプロンプトを貼り付けます。JSON を `#list` の下に直接貼り付けて整形は `gpt-4o` に任せてしまおうという考えです。

```jsx
# system:
与えられたお店やホテルのリストを使用してユーザーからの質問に回答します。
#list が空の場合やデータが含まれない場合は、足りない部分を聞き返します。

# list:
{{list}}
 
# user:
{{question}}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/d19b0637-002d-8c00-f482-9b1ba7022ca0.png)

その後、「入力の検証と解析」をクリックすると、`list` と `question` 変数が抽出されます。これに対して以下のように紐づけを行うと、右のようなフローが完成します。

- list: `${run_function.output}`
- question: `${inputs.question}`

### 出力の設定変更
チャットの回答に `MakeResponse` ノードからの出力を使用するには、一番上の「出力」項目を編集します。`llm_output` は使用しないので消しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/cfddfb51-fb88-0d35-4f59-987c41e97d92.png)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/a2c0299d-32af-9cc8-4156-4f8c7ead4747.png" width="400" />

OK ですね。

### 足りない部分を聞き返す
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/7f854919-4753-bbbf-13a3-b1e08b5189be.png" width="400" />

COOL😎

# 2. 専用 LLM の追加と条件分岐の実装
もしも旅行プランを作成する能力に特化した LLM があった場合、あえて `gpt-4o` に答えさせずに Function calling でそちらへ振り分けることもできます。ここでは独自にトレーニングしたモデルやファインチューニングしたカスタマイズモデルを呼ぶことを想定しています。また、このタイミングで条件分岐も試してしまいましょう。

## 2.1. スキーマ定義を追加
`use_functions_with_chat_models` に以下のスキーマを追加します。

```json
    {
        "name": "make_travel_plan",
        "description": "ユーザーのキーワードから旅行プランを作成します。",
        "parameters": {
            "type": "object",
            "properties": {
                "keyword": {
                    "type": "string",
                    "description": "旅行プランを作成するためのキーワード。ユーザーメッセージから検索キーワードとなる文字を抽出して検索クエリーにしてください。例: ###大阪旅行###"
                }
            },
            "required": [
                "keyword"
            ]
        }
    }
```

## 2.2. 関数の追加
`run_function` に以下の関数を追加します。Python コード内で LLM をコールしてもよいのですが、今回はあえて LLM ツールを利用したいと思います。ですので、関数内では処理をせずに後段処理に旅行プラン作成用のキーワードとフラグを引き渡します。

```python
def make_travel_plan(keyword):
    return {"keyword": keyword, "plan": 1}
```

## 2.3. LLM ツールの追加
ツールバーから「＋LLM」をクリックして「MakeTravelPlan」と名付けます。接続設定には MaaS でデプロイしたでサーバーレス API をセットします。ここでは、`Phi-3-medium-4k-instruct` を使用しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/e2cdf9f4-829d-5ec0-0ec3-091d5f7ead60.png)

例えば以下のようなプロンプトを記述します。

```jsx
# system:
ユーザーのニーズに合わせた旅行プランを作成します
# user:
{{question.keyword}}
```

そして、`question` パラメーターには `${run_function.output}` をマッピングします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/8975408e-8804-e370-c6b6-10153a32b94d.png)

### when ステートメントの利用
「構成のアクティブ化」項目で指定した条件を満たす時に `MakeTravelPlan` ノードが実行されるように設定することができます。`run_function` ノードから渡された値`${run_function.output.plan}` が `1` の時、つまり `make_travel_plan` 関数が実行された時に `MakeTravelPlan` ノードが動きます。それ以外の場合はバイパスされます。

この when を実装するために、`run_function` ノードの他の関数の戻り値にも `plan` を入れておかないと KeyError になっちゃうのがちょっとカッコ悪いです。もっとスマートなやり方ある気がする。。。

## 2.4. 最終回答の作成
`MakeResponse` のプロンプトを以下のように修正します。プロンプトテンプレートに用いられている [Jinja](https://jinja.palletsprojects.com/en/3.1.x/) テンプレートで [if 文](https://jinja.palletsprojects.com/en/3.1.x/templates/#if-expression)を記述しています。`plan` 項目がある場合のみプロンプトに挿入するようにしてみました。

```jsx
# system:
与えられたお店やホテルのリストを使用してユーザーからの質問に回答します。
旅行プランの場合はリストは使いません。
"#list" が空の場合やデータが含まれない場合は、足りない部分を聞き返します。

# list:
{{list}}

{% if plan %}
# plan:
{{plan}}}}
{% endif %}

# user:
{{question}}
```

## 2.5. トレースの確認
プロンプトフローのノードが増えてくると各処理ごとのデバッグが面倒臭くなりますよね。その場合はトレース機能が便利です。フローにおける各関数の実行結果や LLM ツールの詳細なリクエスト/レスポンスを確認できます。

### MakeTravelPlan
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/15976fce-eff3-f509-cd9d-8bed9ce8db60.png)

### use_functions_with_chat_models
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/a9d8f44d-5769-798d-7a77-8a88703b1612.png)


# 3. フローの評価
評価方法については RAG の評価を参照ください。

https://qiita.com/nohanaga/items/9cda65aee671b0b2ef74#4-rag-%E8%A9%95%E4%BE%A1

# シリーズ
https://github.com/nohanaga/aistudio-handson