LLMのプロンプトを自動テスト？！promptfooを使ってみる。

LLMのプロンプトって難しいですよね。

ちょっと変えるだけで答えてくれる内容が全然変わります。

LLMから出力されるテキストの品質を評価し、自動でテストできないかなー。と調べていたら、promptfoo なるものを見つけました。

https://promptfoo.dev/
> promptfooは、LLMの出力品質を評価するためのCLIとライブラリです。
> promptfoo を使うと、以下のことができます：
> - プロンプト、モデル、RAG を定義済みのテストケースで系統的にテストする。
> - LLM出力を並べて比較することにより、品質を評価し、リグレッションを検出する。
> - キャッシュと並行テストによる評価のスピードアップ
> - 期待値を定義することで、出力を自動的にスコアリング
> - CLIとして使用することも、ライブラリとしてワークフローに統合することもできます。
> - OpenAI、Anthropic、Azure、Google、HuggingFace、Llamaのようなオープンソースモデルを使用するか、任意のLLM API用のカスタムAPIプロバイダーを統合します。

簡単に試してみたので、備忘録として残します。

## インストール～実行
[ドキュメント](https://promptfoo.dev/docs/intro)の内容に沿って進めてみます。

まず `npm install promptfoo` で promptfooをインストールします。※Node 16以上

`npx promptfoo@latest init` で`promptfooconfig.yaml` が作られますので、このファイルを編集していきます。今回、以下のように書き換えました。AOAIのGPT-3.5-turboとGPT-4に対して2種類のプロンプトを評価、テストしてみます。
設定ファイルの記載方法、評価の種類などはドキュメント、GitHubに色々載ってます。

https://promptfoo.dev/docs/configuration/reference

https://github.com/promptfoo/promptfoo/tree/main/examples

```yaml:promptfooconfig.yaml
# This configuration compares LLM output of 2 prompts x 2 GPT models across 3 test cases.
# Learn more: https://promptfoo.dev/docs/configuration/guide
description: 'My first eval'

prompts:
  - file://prompt/prompt1.json #プロンプト1
  - file://prompt/prompt2.json #プロンプト2

providers:
  # - openai:gpt-3.5-turbo
  # - openai:gpt-4
  # 今回はAzureOpenAIでテスト...
  - id: azureopenai:chat:gpt-35-turbo # azureopenai:chat:<deploymentName>
    config:
      apiHost: '<azure-openai-service-name>.openai.azure.com'
      temperature: 0.5
      max_tokens: 1000

  - id: azureopenai:chat:gpt-4
    config:
      apiHost: '<azure-openai-service-name>.openai.azure.com'
      temperature: 0.5
      max_tokens: 1000

tests:
  - vars:
      topic: バナナ
    assert:
      # llm-runbric: 言語モデルを使用して出力が指定された要件に一致するかどうかを確認
      - type: llm-rubric
        value: 出力にユーモアが含まれていない事を確認する。尚、「にゃ」が含まれているのはユーモアでは無い。
        provider:
          id: azureopenai:chat:gpt-4
          config:
            apiHost: '<azure-openai-service-name>.openai.azure.com'
      
      # contains: 出力に部分文字列が含まれるか
      - type: contains
        value: にゃ
      
      - type: llm-rubric
        value: 出力の最後の単語が「にゃ」で終わっている事を確認する。
        provider:
          id: azureopenai:chat:gpt-4
          config:
            apiHost: '<azure-openai-service-name>.openai.azure.com'
      
      # similar: コサイン類似度のしきい値を使用して出力の埋め込みが期待値と意味的に類似しているか
      - type: similar
        value: バナナは食物繊維がたっぷりだにゃ
        threshold: 0.85
        provider:
          id: azureopenai:embeddings:text-embedding-ada-002
          config:
            apiHost: '<azure-openai-service-name>.openai.azure.com'
    
```
プロンプトは以下の2種類を用意します。※JSON以外でも可
```json:prompt1.json
[
  {
    "role": "system",
    "content": "あなたは猫です。{{topic}}の内容について面白おかしく雑学を言ってください。"
  },
  {
    "role": "user",
    "content": "{{topic}}でお願いします。"
  }
]
```
```json:prompt2.json
[
    {
      "role": "system",
      "content": "あなたは猫です。{{topic}}の内容について簡潔に雑学を答える。内容にはユーモアを含んではいけない。語尾には「にゃ」必ずをつける事。"
    },
    {
      "role": "user",
      "content": "{{topic}}でお願いします。"
    }
]
```
API_KEYを環境変数に設定しておきます。
```bash:bash
export AZURE_OPENAI_API_KEY=<APIKEY>
```

それでは早速テストを実行してみます。コマンドは`npx promptfoo@latest eval` です。
すると以下のように出力されます。 `-o` オプションで出力形式をcsvなどに変更する事もできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/d500acbc-3703-691c-acf2-578263a03505.png)

テスト完了後は`npx promptfoo@latest view`コマンドで、localhostサーバーが立ち上がり、ウェブ画面形式で結果を確認することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/31095839-70eb-b41e-7da0-5c9ba0048e09.png)

虫眼鏡マークをクリックすると、各テストの詳細を確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/8eee3cec-b2ee-faa6-ef42-a478f83d02e3.png)

これは、プロンプト1の出力には「にゃ」が含まれておらず、出力の最後の単語は「ですよ！」であり、「にゃ」で終わってないのでテスト失敗になってます。

ちなみに、promptfooはLLMへのAPI呼び出し結果をキャッシュおり、キャッシュを消去する場合は、`npx promptfoo@latest cache clear` で消去できます。

他、コマンドの詳細はこちらに記載されてます。

https://www.promptfoo.dev/docs/usage/command-line

## GitHub Actions
promptfooのGithub Actionがあるようです。これでプロンプトの自動テストができそうです。

https://www.promptfoo.dev/docs/integrations/github-action

`.github/workflows/promptfoo.yml`を作成します。
```yaml:.github/workflows/promptfoo.yml
name: 'Prompt Evaluation'
on:
  pull_request:
    branches:
      - 'prompts' #ここではpromptsブランチに対して

jobs:
  evaluate:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - name: Set up promptfoo cache
        uses: actions/cache@v3
        with:
          path: ~/.cache/promptfoo
          key: ${{ runner.os }}-promptfoo-v1
          restore-keys: |
            ${{ runner.os }}-promptfoo-

      - name: Run promptfoo evaluation
        uses: promptfoo/promptfoo-action@v1
        with:
          azure-api-key: ${{ secrets.AZURE_API_KEY }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
          prompts: 'promptfoo/prompt/*.json' #プロンプトのファイル指定
          config: 'promptfoo/promptfooconfig.yaml' # 評価設定ファイル指定
          cache-path: ~/.cache/promptfoo
```
Githubレポジトリの設定からシークレット「AZURE_API_KEY」を追加しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/0722092d-7b94-47fe-2500-699cfd3c96ba.png)

該当のブランチへのPRでアクションが走ります。
ローカルの実行と同じように確認用のURLが払い出されるのでweb画面で確認することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/f4c0e775-5064-8bbd-a981-7230f4e61675.png)

:::note info
プルリクエストで差分が発生したプロンプトにだけテストが実行されるようです。
:::

最後プロンプトを調整し、全てのテストをクリアしました (一応...)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/dab21d5f-9527-0f84-feb5-9a0cf29c2381.png)

## さいごに
まだまだpromptfooには色々な評価方法があります。
PyrhonやJavaScriptで独自の評価関数なども作れるようです。場面によって色々使っていってみようかと思います。