2026年7月26日
promptfoo

Notebook

Promptfooで同一provider idを使った評価結果の衝突バグを修正した

Promptfooで同一provider idを利用した評価をした際に、結果が衝突し適切に表示されないバグがあったため修正しました。

promptfooLLMEvaluation
English

Guide

目次

  1. 背景
  2. 起きていた問題
  3. 修正内容
  4. メンテナの修正
  5. まとめ

Promptfoo を使っていて見つけたバグを修正したので、その内容をまとめます。

PRはこちらです。
promptfoo PR #10208

背景

以下のように、json で出力する場合と通常のテキストで出力する場合でどちらが品質がよくなりそうかを評価していました。

prompts:
  - |
    次の季節におすすめのB'zの曲を100文字以内で教えてください。
    季節: {{season}}
 
providers:
  - id: openai:chat:gpt-5-mini
    config:
      max_tokens: 256
 
  - id: openai:chat:gpt-5-mini
    config:
      max_tokens: 256
      response_format:
        type: json_schema
        json_schema:
          name: custom_name
          strict: true
          schema:
            type: object
            additionalProperties: false
            required:
              - message
            properties:
              message:
                type: string
                maxLength: 100
                description: message
 
tests:
  - vars:
      season: 
    assert:
      - type: latency
        threshold: 5000
 
  - vars:
      season: 
    assert:
      - type: latency
        threshold: 5000

結果を確認すると、以下のように本来はテキストで出力するパターンとjsonで出力するパターンが出てほしいのに、片方の結果しか出ないという問題がありました。

同一 provider id の評価結果が片方の列に衝突している

今回のケースのように LLM 評価で、同じモデルを設定違いで複数回並べて比較することがあります。たとえば temperature などを変えて比較するケースもありえると思います。promptfooのドキュメントにもこれは記載があります。
Promptfoo evaluate-llm-temperature

ただしこれには回避する方法があり、 label を指定することで適切に表示されました。

providers:
  - id: openai:chat:gpt-5-mini
    label: "テキストで出力"
    config:
      max_tokens: 128
 
  - id: openai:chat:gpt-5-mini
    label: "jsonで出力"
    config:
      max_tokens: 128
      response_format:
      # response_format の詳細は省略

provider に label を指定して評価結果を分けて表示できた

しかしこれに気付くまで時間がかかってしまったので、同一provider idを指定している場合は ワーニングを出すか、表示出来るようにする必要があると感じました。

そこでこれを直したのが今回のPRです。

起きていた問題

Promptfoo では、評価結果をテーブル形式で保持するために、各 provider/prompt の組み合わせに prompt index を割り当てています。

例えば、同じ provider id を返す provider を2つ指定し、prompt が1つだけあるケースを考えます。

providerの出現順provider idprompt本来の列index
1つ目duplicate-providerTest prompt0
2つ目duplicate-providerTest prompt1

本来は、同じ provider id と同じ prompt であっても、provider が2つあるので評価結果の列は2つ必要です。

しかし以前の実装では、評価結果をどの prompt/provider 列に入れるかを探すために、次のような key で index を管理していました。

`${provider}:${promptId}` -> index

このケースでは、2つの provider が同じ provider id を返し、prompt も同じなので、Map key が完全に同じになります。

providerの出現順Map key保存されるindex
1つ目duplicate-provider:<promptId>0
2つ目duplicate-provider:<promptId>1

JavaScript の Map は同じ key を複数持てないため、2つ目の値が1つ目を上書きします。

結果として、Map の中身は次のようになります。

Map keyindex
duplicate-provider:<promptId>1

該当箇所は evaluator.ts#L2044 になります。

本来必要だった index = 0 の情報が消えてしまい、1つ目の provider の評価結果も2つ目の列に紐づいてしまう問題がありました。

修正内容

修正後は、同じ key に対して単一の index ではなく、index の配列を持つようにしました。commit 4043eca

`${provider}:${promptId}` -> [index1, index2, ...]

先ほどの例では、Map の中身は次のようになります。

Map keyindices
duplicate-provider:<promptId>[0, 1]

これで、同じ provider id と同じ prompt の組み合わせが複数あっても、対応する列 index をすべて保持できます。

ただし、配列にしただけでは「今実行している provider は [0, 1] のどちらを使うべきか」がまだ分かりません。

そこで evaluation 実行時に、同じ provider key が何回目に出てきたかを数えるようにしました。

providerの出現順provider idoccurrence index使用するprompt index
1つ目duplicate-provider0[0, 1][0] = 0
2つ目duplicate-provider1[0, 1][1] = 1

実装としては、provider ごとの出現回数を Map で管理し、その occurrence index に対応する prompt index を使います。

const promptIndices = promptIndexMap.get(`${providerKey}:${promptId}`);
const promptIdx = promptIndices?.[providerOccurrenceIndex];

これにより、同じ provider id と同じ prompt の組み合わせでも、それぞれの評価結果を別々の列に保存できるようになりました。

メンテナの修正

上記でPRを出したのですが、promptfooメンテナのMichaelさんに追加で修正していただきました。

最初の修正では、providerKey:promptId という key は残したまま、値を単一の index から index の配列に変えていました。

`${provider}:${promptId}` -> [index1, index2, ...]

一方で修正いただいたのは、評価結果の列を providerKey:promptId から検索すること自体をやめています。

代わりに、prompt column を作るタイミングで「この provider が持つ列」をそのまま保持するようになりました。

概念的には次のような構造です。

providerの出現順providercolumns
1つ目firstProvider[{ promptIdx: 0, prompt: Test prompt }]
2つ目secondProvider[{ promptIdx: 1, prompt: Test prompt }]

つまり、列をあとから key で探すのではなく、列を作った時点で provider ごとに promptIdx を持たせています。

type ProviderColumns = {
  provider: ApiProvider;
  columns: { promptIdx: number; prompt: Prompt }[];
};

この形にすると、実行時には columnsByProvider を順番に処理すればよくなります。

for (const { provider, columns } of columnsByProvider) {
  for (const { promptIdx, prompt } of columns) {
    // この provider/prompt の結果は promptIdx の列に保存する
  }
}

この修正のポイントは、provider idprompt id のような「表示・識別用の文字列」ではなく、実際に作成した列そのものを実行計画に渡していることです。

そのため、次のようなケースにも強くなっています。

ケースなぜ問題になりうるかメンテナ修正での扱い
同じ provider id の provider が複数あるproviderKey が衝突するprovider ごとに別の columns を持つ
異なる provider が同じ label を持つlabel を provider key として使うと衝突するprovider オブジェクト単位で列を保持する
異なる prompt が同じ prompt id になるprompt label 由来の id が衝突するprompt ごとの列を作成時点で保持する
test 側で provider/prompt を filter するfilter 後に列 index がずれる可能性がある作成済みの promptIdx をそのまま使う

また、resume 時の metrics 共有にも手が入っています。

既存の評価結果から metrics を復元する際、重複 provider が同じ stored prompt に解決されることがあります。その metrics object をそのまま複数列で共有すると、片方の結果更新がもう片方にも影響してしまいます。

そこで、既存 metrics を使う場合は structuredClone で複製し、各 column が独立した metrics を持つようにしています。

metrics: existingPrompt?.metrics
  ? structuredClone(existingPrompt.metrics)
  : createDefaultPromptMetrics()

最初の修正は「同じ key に複数 index を持たせる」ことで衝突を避けるものでした。Michaelさんの修正はさらに一歩進んで、「そもそも重複しうる文字列 key から列を復元しない」設計に変えています。

まとめ

以下のように正しく表示されるようになりました。

修正後に同一 provider id の評価結果が別々の列に表示されている

こちらの記事の趣旨とは異なりますが、あわせてこの修正中にドキュメントの誤りも見つけたので修正しています。 promptfoo PR #10209

Promptfoo はプロンプトの評価でとても便利なので最近はかなり使っています。
そのため今回のような小さい修正ではありますが、少しでも貢献出来たので嬉しく思います。