Morikatron Engineer Blog

モリカトロン開発者ブログ

OpenAI APIの出力を安定してJSONフォーマットで受け取るTips

こんにちは。モリカトロンAIエンジニアの山田です。

先日京都で開催されたインディーゲームの祭典BitSummitにて、マーダーミステリーゲームRed Ramを発表しました。
今回はRed Ram開発時に得た、OpenAI APIを使う際にできるだけ安定してJSONフォーマットで出力を受け取りたいときのTipsについて紹介します。

Red Ram紹介記事: https://morikatron.com/news/1310/

今回は主に

  • 成功例: プロンプトでの出力フォーマットの指定
  • 失敗例: Function Callingを使ったJSONの出力

について書いていこうと思います。

注意点として

  • 基本的に5~7月上旬に使用したGPT-3.5-turboに関して書きます。GPT-4だと条件が変わる可能性もありますし、GPT-3.5-turboでもTipsの内容が現在は通用しない可能性もあります。
  • あくまで筆者がいろいろと試した中での経験則です。もちろんもっと良い方法はあり得ると思います。

JSONフォーマット出力の魅力

自動化システムを構築する際、出力をそのまま使用する(つまり人間が目を通さずに処理する)ケースでは、JSONフォーマットでの出力が最も便利となります。

スクリプトによる抜け漏れのチェック

JSONフォーマットを用いることで、出力項目の抜け漏れをプログラムで検知し、再度生成リクエストを行うことができます。

不要な冗長性の排除

「あなたの要求に対する答えは〜」のような前置きや、「〜。他にも方法として○○があります」のような後置きは、JSONフォーマットでは省略してくれることが多いです。

形式の柔軟性

文字列だけでなく、数値やリストなど、指定した形式で情報を提供することも可能です。 例えば、"num": 3 や clothes:["めがね", "帽子", "靴", …] のような形です。

JSONフォーマット出力の成功例: プロンプトでマークダウンと出力例を書く

では具体的に、どのようにJSONフォーマットでの出力を実装すれば良いのでしょうか? 筆者が試した中では下記の1と2を組み合わせた方法が有効でした。

  1. プロンプトにおいてマークダウン形式で出力項目を指定
  2. プロンプトでJSONの例を出して出力形式を指定

ネストされたJSONを出力させたい場合も、この方法が有効です。

例えば、ある人物のプロフィールを生成させたいとしたとき、次のような形のプロンプトを書くことで、出力形式を指定することができます。
(※)の部分は注釈です。実際のプロンプトには書いていません。

(※作成して欲しい全体の説明です)  
# 作成項目
ある人物のプロフィールを作成します。以下の項目について具体的な数字、名称、文章を作成してください。
  
(※マークダウン形式で出力項目を指定する部分です)
## プロフィール
- 年齢
人物の年齢
- 性別
性別。男性なら1, 女性なら2, どちらでもない場合は3
- 職業
人物の現在ついている職業
...(※省略)
  
(※JSONの例を出して出力形式を指定する部分です)
# 出力形式
データは次の形式のJSON文字列で返してください。
{
"プロフィール":{
"年齢": integer, "性別": integer, "職業":string, ...(※省略)
                    },
...(※省略)
}

JSONフォーマット出力の失敗例: Function Calling

一方で、注意が必要な点もあります。例えば、GPT APIのFunction Callingを用いてJSONフォーマット出力を試みたことがある方も多いのではないでしょうか。
一見うまくいきそうに見えますし、インターネット上の記事でも「安定して出力できる」という意見が見られますが、結果は必ずしも安定しないのが実情です。

公式ドキュメントでも、「Note: the JSON response may not always be valid(必ずしも適切な形のJSONフォーマットで出力してくれるとは限らない)」と書かれています。
筆者の経験では、同じ例をそれぞれ100回実験したところ、上記のプロンプトでの形式指定の方法で1/100の失敗率で済みましたが、Function Callingを使うと3/100の失敗率に上昇しました。
とくにJSONのネストが深いなど、複雑なJSONを出力をさせたいときほどFunction Callingでは失敗する確率が高くなるように感じました。

Function Calling 公式ドキュメント
https://platform.openai.com/docs/guides/gpt/function-calling

JSONフォーマット出力が失敗したときの出力例

失敗例としてはいろいろとあるのですが、比較的よく起こる例を紹介します。

1. JSONの記述形式になっていない

例えば以下のように中括弧({})が二重になっていることがあります。
(下記の例では"医者"の後の中括弧が二重になっています)

{"プロフィール":{
"年齢": 21, "性別": 1, "職業": "医者"}}
}

またそれ以外にも、keyやvalueでダブルクオーテーションで括るべきところをシングルクオーテーションで括ってあったり、カンマが二重になっているというのはよく起こります。
(推奨はされないものの)プログラム上でJSON形式に修正するという方法はありますが、期待するJSONの出力が複雑になっていくほど修正は難しくなっていきます。

2. 期待したkey, valueが含まれていない

例えば以下のようにvalueが空白になっている場所が含まれていることがあります。

{"プロフィール":{
"年齢": 21, "性別": 1, "職業": ""}
}

また、そもそも期待したkey, valueがない場合もあります。
(下記の例では"職業"が存在していてほしいのですがありません)

{"プロフィール":{
"年齢": 21, "性別": 1}
}

3. 期待した型ではない

例えば以下のように、本来は職業のvalueはstringであって欲しいところがlistになっています。

{"プロフィール":{
"年齢": 21, "性別": 1, "職業": ["医者", "弁護士"]}
}

解決方法

以上3例がよく起こる失敗例です。
1の例であればPythonならjson.loads()でデコードしようとする際に、JSONの構文になっていないことでエラーが返ってくるのでチェックが出来ます。
また2, 3はJSONの構文としては間違ってないですが、json.loads()で辞書にしたもののkeyの有無のチェックをしたり、型チェックなどが出来ると思います。
期待した出力がない場合はAPIへのリクエストをリトライしたり、他の代替手段(後述)を試すことが出来ます。

リクエスト回数は適切に行う

最後に、生成に関しては必要な場合に限りリトライするようにすることが大切です。

リクエストごとに料金が発生する問題

1度のリクエストで期待した出力が得られずリトライする場合、金銭的コストがどんどん高まっていきます。リトライ回数は十分に吟味する必要があります。

ルールベースや自前の機械学習モデルでの代替

生成タスクの場合、OpenAI APIを利用すれば精度の高い出力が得られる可能性はたしかに高いです。ただ何度もリトライして失敗するよりは、上限を決めておいてその回数分失敗した場合は、OpenAI APIに再度リクエストを出すのではなく、ルールベースや自前の機械学習モデルで代替するというのも選択肢の1つだと思います。

例えば、ある文章を特定の文字数以下に要約したいとします。OpenAI APIを使えばたしかに全体の文脈を適切に要約する可能性が高いです。しかし、出力に失敗した場合(期待した文字数以下になっていない場合など)、文中の句読点などで区切るルールベースの要約方法なども考慮すべきというのが私の考えです。

要約タスクでの成功例と失敗例

Red Ramでも文字数削減を行うための要約タスクを行っていました。ゲームという性質上、画面で「〜文字以内」という制限がよくあるものの、生成用のプロンプトで指示するだけでは「〜文字以内」にしてくれない場合が起こるためです。

例えば入力した文を「120文字以内」に要約したいとします。 (こちらはRed Ramのエピローグ用に生成された文であり、刑事のその後を生成したものです)

成功例

  • 入力
    あなたは前田りゅうどを逮捕し、その後大手刑事ドラマ「罪深き血」に主演することとなった。被害者の佐藤ひかりや容疑者たちもファンになる程度の演技力で視聴率も好調だった。また、凶器である鉄製の矢が話題になり、「刑事役」として有名になったあなただけではありません。「罪深き血」シリーズ第2弾以降も期待されています。

  • GPTによる出力
    前田りゅうどが逮捕された後、彼は「罪深き血」という大手刑事ドラマで主演することになりました。彼の演技力は被害者や容疑者たちからも評価され、視聴率も好調でした。

入力は153文字ですが、出力は79文字となっており、「120文字以内に要約してほしい」という要件を満たしています。

失敗例

  • 入力
    あなたは前田りゅうどを取り逃がしてしまいました。その後、「凶器」として使用された鉄製の矢が伝説化し、「失われた証拠」と呼ばれるようになったことから、「凶器探索家」という肩書きで孤高の探偵として生活することになりました。被害者の遺族からは失望され、犯人によって嘲笑される日々です。

  • GPTによる出力
    あなたは前田りゅうどを取り逃がしてしまいました。その後、「凶器」として使用された鉄製の矢が伝説化し、「失われた証拠」と呼ばれるようになったことから、「凶器探索家」という肩書きで孤高の探偵として生活することになりました。あなたは犯人によって嘲笑される日々です。

入力が139文字、出力は129文字となっており、「120文字以内に要約してほしい」という要件を満たしていません。

この場合は以下のようにルールベースで句読点で切ると109文字となり「120文字以内に要約してほしい」という要件を満たすことが出来ます。

  • ルールベースでの出力
    あなたは前田りゅうどを取り逃がしてしまいました。その後、「凶器」として使用された鉄製の矢が伝説化し、「失われた証拠」と呼ばれるようになったことから、「凶器探索家」という肩書きで孤高の探偵として生活することになりました。

最後に

今回は筆者が数ヶ月OpenAI APIを仕事現場で扱ったことで得た、JSONフォーマットの出力に関するTipsについて書かせていただきました。 もちろん、これより良い方法はあると思いますので、コメントなど頂けるとありがたいです。