TL;DR
OpenAIのAPIを使った時の出力フォーマットをJSON形式に固定&指定できるライブラリguardrails
を実際に使ってみたときのコードと一緒に紹介してます。
出力フォーマットを定義&固定したい場合や、その定義からずれたときに自動的に修正させたい場合に使えます。
概要
GPT(LLM)を使ってアプリケーション開発やデータ分析、サービス開発などをしている方もたくさんいらっしゃるかと思います。
GPT(正確に言えば、OpenAIのAPI)を使ったアプリケーションにおいては、おおよそ次のような処理フローとなると思います。
ここで大変なのが、フローの最後から2番目の『使いやすいように成形する』箇所ですよね。
『別の処理』で扱いやすいようにするためにはGPTの出力テキストをうまい具合に成形する必要がありますが、これが面倒くさいです。
もちろん、プロンプトを工夫することで出力のフォーマットを固定化することもできますが、出力されたテキストの中から欲しい部分を抜き取るような処理を自前で用意する必要があります。「最初からJSON形式みたいな扱いやすい形式で出力を出してくれれば良いのに。。。」と思ったことがありますよね。
それを実現してくれるのがguardrails
というPythonパッケージです。
guardrailsでできること
guardrails自体は、Pythonで使えるオープンソースのパッケージです。 pip
で簡単にインストールできます。
guardrailsの公式ページのguardrails紹介文では次の3つのことができると書いてあります。3つ目の『構造とタイプの保証(例えばJSON)を強制します。』という機能があることで、GPTからの出力を自分で定義したJSON形式にguardrailsが整形してくれます。
・LLMの出力のpydanticスタイルのバリデーションを行います。これには、生成されたテキストのバイアスチェック、生成されたコードのバグチェックなどの意味論的なバリデーションが含まれます。
・バリデーションに失敗した場合に、LLMに再問い合わせなどの修正アクションを実行します。
・構造とタイプの保証(例えばJSON)を強制します。
(原文は英語ですが、ChatGPTで翻訳しました。)
guardrailsの仕組みを簡単に紹介
guardrailsの仕組みを簡単に紹介します。
guardrailsでは、rail
というファイルを使います。
rail
ファイルは、Reliable AI markup Languageを略したもので、guardrails独自のフォーマットのファイルです。これをユーザが自分で作ることで、自分が定義したJSONフォーマットの形にGPTの出力を整形してくれます。
railファイル自体はテキストエディタで自分で作れるので、作成すること自体は難しくありません(のちほど書き方を解説します)。
ファイル名は例えば、test.rail
などとしておけばOKです。
実行するタスク
今回はワンピースのあらすじ(イーストブルー編のみ)をWikipediaから拝借して、次の2つのタスクを実行してみます。
- ①主人公の属性情報を抽出
- guardrailsのチュートリアルにも近しい内容ですが、『与えられたテキストから特定の情報を抽出してJSON形式で出力させる』ということをさせます。
- ②あらすじにタグ付け
- guardrailsでは配列も出力させることができるので、それをテストするためにあらすじに10個のタグ付けさせてみます。
テキストファイルの中身は、次のようになっています。wikipediaのあらすじをコピペしただけです。何も下降していないです。
冒険の始まり(1巻)
シャンクスとの別れから10年後、修行を重ね17歳になったルフィは、海賊王を目指してフーシャ村を旅立つ。旅立ち直後にいきなり遭難したルフィは、海軍に入ることを夢見る少年コビーと出会う。ルフィは女海賊アルビダを倒し、コビーを海賊船の雑用係から解き放つ。
ルフィとコビーは海軍基地の町「シェルズタウン」に到着する。「海賊狩り」の異名を持つ悪名高い賞金稼ぎロロノア・ゾロが海軍に捕らえられていることを知ったルフィは、海軍基地に乗り込み、ゾロを救い出す。三刀流の剣士ゾロは、圧政を振るう海軍大佐モーガンをルフィと共に倒し、ルフィの最初の仲間となる。二人はコビーと別れ、世界中の海賊が集まる海「偉大なる航路(グランドライン)」を目指す。
オレンジの町編(1巻 - 3巻)
ゾロを仲間に加えたルフィは、次に訪れた「オレンジの町」で、海賊専門の泥棒ナミと出会う。ナミが優れた航海術を持つと知り、ルフィは彼女を仲間に誘う。ナミは海賊になることを拒絶するが、二人は互いの目的のために手を組むことになる。ルフィとゾロは、町を荒らす海賊〝道化のバギー〟から「偉大なる航路」の海図を奪うため、バギー海賊団に戦いを挑む。ルフィは、バラバラの実の能力者であるバギー相手に勝利を収め、町を後にする。
シロップ村編(3巻 - 5巻)
船を求め立ち寄った「シロップ村」で、ルフィはウソつきの少年ウソップと出会う。ウソップは、シャンクスの部下ヤソップの息子であった。ルフィ達は村の富豪の娘カヤの屋敷を訪れるが、執事クラハドールに追い返される。しかしルフィとウソップはしばらくして、クラハドールがかつて処刑されたはずの海賊〝キャプテン・クロ〟であることを知ってしまう。クロは自分の海賊団に村を襲わせ、カヤを殺して財産を手に入れようと企んでいた。ルフィ達はウソップと共にクロネコ海賊団を迎え撃ち、クロの計画を阻止する。ルフィ達は、新たに狙撃手ウソップを仲間に加え、さらにカヤから海賊船「ゴーイングメリー号」を譲り受ける。
バラティエ編(5巻 - 8巻)
航海に欠かせない海のコックを仲間に加えるため、ルフィ達は海上レストラン「バラティエ」に向かう。そこで副料理長にして凄腕の料理人・サンジと出会い、ルフィは彼を仲間に引きこむことを決意する。その時、東の海の覇者と言われる海賊艦隊提督〝首領・クリーク〟が現われ、バラティエの乗っ取りを宣言する。さらに、ゾロが目標とする世界最強の剣士〝鷹の目のミホーク〟が現れる。ゾロはミホークに戦いを挑むが、全く歯が立たずに敗れ去ってしまう。ミホークが去った後もクリーク海賊団との戦いは続く。ルフィは数々の兵器を繰り出すクリークとの激闘を制し、新たに料理人サンジを仲間に加える。
アーロンパーク編(8巻 - 11巻)
ルフィ達は、クリーク海賊団との戦闘の最中行方をくらましたナミを追い、コノミ諸島「ココヤシ村」に上陸する。そこは、魚人の海賊アーロンが支配する土地であった。さらに、ナミがアーロン一味の幹部であるという事実が判明するが、その裏にはナミの悲壮な決意があった。ナミの想いを知ったルフィ達は、ナミを救うため、アーロン一味の根城「アーロンパーク」に殴り込む。ルフィはアーロンと激戦を繰り広げ、怒りの一撃でアーロンパークもろとも彼を倒す。島はアーロン一味の支配から解放され、航海士ナミが正式に仲間に加わった。
ローグタウン編(11巻 - 12巻)
東の海の大物海賊達を次々に打ち破ったルフィの情報は海軍にも伝わり、ルフィには東の海最高となる3000万ベリーの懸賞金が懸けられる。
「偉大なる航路」入りを目前に控えたルフィ達「麦わらの一味」は、かつて海賊王ロジャーが処刑された町「ローグタウン」に立ち寄る。そこでは、ルフィへの復讐を狙うバギー、そして悪魔の実を食べ生まれ変わったアルビダが待ち構えていた。ルフィは海賊王の処刑台でバギーに処刑されそうになるが、奇跡のような落雷に救われる。ルフィ達は町を治める海軍大佐スモーカーを振り切り、いよいよ「偉大なる航路」に進出すべく、「リヴァース・マウンテン」を駆け登る。
タスク①実行:主人公の属性情報を抽出
パッケージのインストール
インストールするパッケージは2つです。
!pip install guardrails-ai
!pip install openai
ライブラリのインポート
import openai
import os
import guardrails as gd
OpenAIのAPIの設定
OpenAIのAPIを設定します。
GPT_API_KEY
にはご自分のAPIキーを入れてください。
OpenAIのAPIキーの取得方法はいろんな方が紹介しているので、ここでは解説しません。
GPT_API_KEY = "sk-"
os.environ["OPENAI_API_KEY"] = GPT_API_KEY
railファイルの作成
さあここがguardrailsのキモとなる部分です。
先にファイルの中見を全て見せてから、各パートを解説していきます。
英語で書かれていますが、GPT君は賢いので、あらすじを英訳する必要はありません。勝手に英語に翻訳して英語で返してくれます。逆にプロンプトを日本語にしておき、明示的に日本語で出力させることもできます(タスク②では日本語出力させてます)。
<rail version="0.1">
<output>
<object name="main">
<string name = "name" description = "main character's name" />
<string name = "gender" description = "main character's gender" />
<integer name = "age" format = "valid-range: 0 100"/>
</object>
</output>
<prompt>
Given the following notes, please extract a dictionary that contains the characters' information.
{{notes}}
@complete_json_suffix_v2
</prompt>
</rail>
見てみるとピンとくると思いますが、HTMLのような書き方になっています。(rail自体がReliable AI markup Languageの略なので、マークアップ言語ではあるので当然と言えば当然ですが)。
一行目はrailのバージョンを指定しています。
<rail version="0.1">
二行目以降の<output>
は、ここが最終的な出力のフォーマットを定義している個所です。
<object>
は文字通り「オブジェクトの定義をここからするよ」、という意味を表しています。
objectは複数定義することができます(詳細は後述)。
<string>'''は文字列型の出力を指定しており、
nameを使って区別することができます。下の例では、
name```に「name(キャラの名前)」、「gender(キャラの性別)」の2つを文字列型で指定しています。
integer
は、整数型の出力を指定しています。また、format
を0-100歳として、値の範囲を限定しています(指定した範囲外の値が入ってきた場合はguardrailsが自動でGPTに正しい範囲の値が入ってくるように再度プロンプトを投げ返してくれます。ここがguardrailsの大きな特徴でもあります)。
もちろん、型は他にも定義することができintegerではなく、floatを指定することもできます。
guardrailsで指定できる型の一覧は公式ページに掲載されています。
<output>
<object name="main">
<string name = "name" description = "main character's name" />
<string name = "gender" description = "main character's gender" />
<integer name = "age" format = "valid-range: 0 100"/>
</object>
</output>
最後にここが実際にGPTへ投げられるプロンプトを定義しています。
「Given」から始まる箇所がGPTへの指示を表した文章で、今回は、「以下のノートが与えられた場合、キャラクターの情報を含む辞書を抽出してください。」という内容になっています。
{{notes}}
というのは自分で定義する変数名で、今回作成するプロンプトに入力したいテキストを格納させるために定義しているものです。例えば今回ではワンピースのあらすじをプロンプトに格納するので、pythonを使って{{notes}}
の箇所にあらすじを格納します(後述)。
@complete_json_suffix_v2
はguardrailsの開発者側が作成したプロンプトを自動で挿入するためのものです。guardrails開発者側でLLMのアウトプットの質を上げるためのプロンプトテンプレートをいくつか用意してくれていて、それをプロンプトに挿入します。どんなテンプレートがあるかはこちらに記載されているので、そちらを見てみてください。
<prompt>
Given the following notes, please extract a dictionary that contains the characters' information.
{{notes}}
@complete_json_suffix_v2
</prompt>
ちなみに今回使っている@complete_json_suffix_v2
は実際は、次の内容を自動でプロンプトに挿入してくれます。
Given below is XML that describes the information to extract from this document and the tags to extract it into.
{output_schema}
ONLY return a valid JSON object (no other text is necessary), where the key of the field in JSON is the `name` attribute of the corresponding XML, and the value is of the type specified by the corresponding XML's tag. The JSON MUST conform to the XML format, including any types and format requests e.g. requests for lists, objects and specific types. Be correct and concise.
Here are examples of simple (XML, JSON) pairs that show the expected behavior:
- `<![CDATA[<string name='foo' format='two-words lower-case' />`]]> => `{{{{'foo': 'example one'}}}}`
- `<![CDATA[<list name='bar'><string format='upper-case' /></list>]]>` => `{{{{"bar": ['STRING ONE', 'STRING TWO', etc.]}}}}`
- `<![CDATA[<object name='baz'><string name="foo" format="capitalize two-words" /><integer name="index" format="1-indexed" /></object>]]>` => `{{{{'baz': {{{{'foo': 'Some String', 'index': 1}}}}}}}}`
railファイルの読み込みとguardオブジェクトのインスタンス化
上で作成したrailファイルを読み込みます。
(筆者の好みで、railファイルを置いたディレクトリを表す'''DIR'''とrailファイル名を定義したRAIL_FILE
を結合することでrailファイルの場所を指定し、読み込ませる形にしています。)
guard = gd.Guard.from_rail(DIR + RAIL_FILE)
テキストファイルの読み込み
ワンピースのあらすじを記載したテキストファイルを読み込みます。
(railファイルと同様に、テキストファイルを置いた場所を表す'''DIR'''と、テキストファイル名を表す'''TEXT_FILE'''を結合することでテキストファイルを読み込ませてます)
with open(DIR + TEXT_FILE) as f:
text = f.read()
GPTに投入する
先ほど作成したguardオブジェクトに、OpenAIのAPIコールをラップしてあげることで、GPTを呼び出しています。今回は「text-davinci-003」を使っていますが、公式によれば、「gpt-3.5-turbo」も使えます。
# Wrap the OpenAI API call with the `guard` object
raw_llm_output, validated_output = guard(
openai.Completion.create,
prompt_params = {"notes": text}, # railファイル内の'notes'という変数名にあらすじテキストを代入しているtext変数を格納させる
engine = "text-davinci-003",
max_tokens = 1024,
temperature = 0.0,
)
prompt_params = {"notes": text}
箇所が、railファイル内の'''''' 内の{{note}}'''と対応づきます。
text```は読み込んだテキストファイルで今回はワンピースのあらすじデータが入ります。
イメージは下の図のようなイメージです。
ちなみに、これらの定義によって最終的に生成されたプロンプトを確認することができます。
print(guard.base_prompt)
結果。
Given the following notes, please extract a dictionary that contains the characters' information.
{notes}
Given below is XML that describes the information to extract from this document and the tags to extract it into.
<output>
<object name="main">
<string name="name" description="main character's name"/>
<string name="gender" description="main character's gender"/>
<integer name="age" format="valid-range: min=0 max=100"/>
</object>
</output>
ONLY return a valid JSON object (no other text is necessary), where the key of the field in JSON is the `name` attribute of the corresponding XML, and the value is of the type specified by the corresponding XML's tag. The JSON MUST conform to the XML format, including any types and format requests e.g. requests for lists, objects and specific types. Be correct and concise.
Here are examples of simple (XML, JSON) pairs that show the expected behavior:
- `<string name='foo' format='two-words lower-case' />` => `{{'foo': 'example one'}}`
- `<list name='bar'><string format='upper-case' /></list>` => `{{"bar": ['STRING ONE', 'STRING TWO', etc.]}}`
- `<object name='baz'><string name="foo" format="capitalize two-words" /><integer name="index" format="1-indexed" /></object>` => `{{'baz': {{'foo': 'Some String', 'index': 1}}}}`
-
出力を表示させる
実行してみた結果を表示させます。
# Print the validated output from the LLM
print(validated_output)
結果。
{'name': 'Luffy', 'gender': 'Male', 'age': 17}
ちゃんとJSON形式かつ、それぞれの型も定義された通りに出力されてますね。
objectを増やしてみる
今のはメインキャラだけを抽出する形にしていましたが、サブキャラも一緒にJSON形式で出力させることもできます。その場合はrailファイルを次のように書きます。
objectを増やしただけです。
<rail version="0.1">
<output>
<object name="main">
<string name = "name" description = "main character's name" />
<string name = "gender" description = "main character's gender" />
<integer name = "age" format = "valid-range: 0 100"/>
</object>
<object name="sub">
<string name = "name" description = "sub character's name" />
<string name = "gender" description = "sub character's gender" />
<integer name = "age" format = "valid-range: 0 100"/>
</object>
</output>
<prompt>
Given the following notes, please extract a dictionary that contains the characters' information.
{{notes}}
@complete_json_suffix_v2
</prompt>
</rail>
これでコードを実行すると下のような出力が出てきます。
{'main': {'name': 'Luffy', 'gender': 'Male', 'age': 17}, 'sub': {'name': 'Zoro', 'gender': 'Male', 'age': 19}}
object名に「main」と「sub」ができて、mainにはルフィの情報が、サブにはゾロの情報がJSON形式で格納されていることがわかります。
JSON形式になっているので、JSONをパースすれば後段での処理で使いやすいですよね。
タスク②実行:あらすじにタグ付け
guardrailsは同じタイプの情報を複数抽出し、それを配列型としてJSONに格納することもできます。今回はあらすじをインスタっぽくタグ付けさせてみます。
タグは10個生成させてみることにしました。
railファイルの定義以外は基本的に同じなので、railファイルの定義のみ記載します。
railファイルの定義
基本的に書き方はタスク①の属性情報抽出と同じですが、<list>
を使っている点が異なります。
<list>
を使うことで、配列型で情報を抽出することができます。
<list>
内の<object>
の解説は後述。
<rail version="0.1">
<output>
<object name = "list_tags_object">
<list name = "tags" description = "次の文章の特徴を表す10個のタグを日本語でつけるとしたらどんなタグ?">
<object>
<integer name = "index" format = "1-indexed" />
<string name = "tag" format="one-word" on-fail-one-word="reask"/>
</object>
</list>
</object>
</output>
<prompt>
Given the following document, answer the following questions. If the answer doesn't exist in the document, enter 'None'.
{{document}}
@xml_prefix_prompt
{output_schema}
@json_suffix_prompt_v2_wo_none</prompt>
</rail>
<integer>
は、タグのインデックスを表現するために指定してます。今回のように単にタグをつける場合は特に必要ないとは思いつつ、動作を確認するために指定してみました。
<string name = "tag"
の箇所がタグを作ってもらうための場所です。format
という箇所でいろいろとフォーマットの条件や、そのフォーマット定義に合わない出力が返ってきたときの動作を指定しています。
format="one-word"
は、「1単語で」というフォーマットを指定しています。
続く、on-fail-one-word="reask"
は、定義したフォーマットに合致しない時の対応方法を指定してます。今回は「reask」を指定しており、これは「1単語で」というフォーマットに合致しないものが出力されたときは自動で再度LLM(GPT)にプロンプトを導入&出力の生成をさせます。
(on-failが指定した定義からずれたときの対応方法を指定する方法です。詳細は公式ページを見てください。)
<object>
<integer name = "index" format = "1-indexed" />
<string name = "tag" format="one-word" on-fail-one-word="reask"/>
</object>
ちなみに、フォーマットやフォーマット以外の定義からずれてしまった時の対応方法の指定は複数を同時に指定することもできます。
たとえば、
<string name = "tag" format="lower-case; one-word" on-fail-lower-case="fix" on-fail-one-word="reask"/>
であれば、
- フォーマット:小文字かつ1単語で
- 定義からずれた場合の対応方法:
- 小文字ではなかった時:プログラム側で自動修正します(強制的にフォーマットに合わせます)
- 1単語ではなかった場合:自動でLLM(GPT)に再度出力生成させます。
という動作をします。これがかなり強力だと個人的には思っています。そう考えている理由は「まとめ&考察」パートで記載します。
GPTに投入する
# Wrap the LLM API call with Guard
raw_llm_response, validated_response = guard(
openai.Completion.create,
prompt_params = {"document": text}, # 特に意味はないですが、さっきはnotesという変数名でしたが今回はdocumentにしている点に注意。
engine = "text-davinci-003",
max_tokens = 1024,
temperature = 0.0
)
結果を出力
print(validated_response)
結果。
{'list_tags_object': {'tags': [{'index': 1, 'tag': '冒険'},
{'index': 2, 'tag': '別れ'},
{'index': 3, 'tag': '修行'},
{'index': 4, 'tag': '海賊'},
{'index': 5, 'tag': 'コビー'},
{'index': 6, 'tag': 'アルビダ'},
{'index': 7, 'tag': 'ゾロ'},
{'index': 8, 'tag': 'ナミ'},
{'index': 9, 'tag': 'ウソップ'},
{'index': 10, 'tag': 'サンジ'}]}}
ちゃんとJSON形式で配列が出てますね。(なぜかキャラ名ばっかですがw)。
また、descriptionを日本語で書いたため、ちゃんと日本語で返してくれています。10回ほど実行しましたが、10回とも日本語でした。
まとめ&考察
今回はguardrailsというPythonパッケージを使って、GPT(LLM)の出力を強制フォーマッティングすることに成功しました。
ChatGPTやInstructGPTでは、適切なプロンプトを出せばある程度フォーマットを固定して出力させることができますが、次の2点でguardrailsは便利だと感じました。
①外部ファイルを使って出力フォーマットを指定できること
②自動でJSON形式にフォーマッティングしてくれること
③指定した定義とは異なる出力が返ってきたときに、自動で修正してくれること
特に3つ目がうれしいです。1つ目はテンプレートプロンプトファイルを自分で作れば良くて、2つ目は自分で何とかできる部分がありますが、3つ目は自分で作るのはかなり大変だと思っています。
指定したフォーマットではない出力をLLMが返してきたときに対応方法を指定し、自動で修正してくれるのは個人的にはかなりありがたいと考えています。なぜなら、LLMを使う場合、「どんな出力が返ってくるか、そのフォーマットがやってみるまでわからない」ことが多いと感じているからです。プロンプト側でフォーマットを指定したとしても定義通りの出力にはならない時があります。そういう時は自分で修正しないといけないわけですがguardrailsを使えばその面倒くささから解放されます。
上ではguardrailsを使う際の良い点に目を向けていましたが、次は気になる点を記載します。
プロンプトが固定である点。
railファイルに従ってプロンプトを自動生成するため、プロンプトのカスタマイズ性が薄いと考えています。これはプロンプトエンジニアリング力の高い人からしたら「もっと良い書き方があるのに...」という状況を生み出す可能性があります。また、GPTへ投入することのできるトークンサイズに上限があるため、プロンプトを工夫したいことも多々あると思います。しかし、guardrailsではそのあたりへの対応がないっぽい(筆者が見つけられていないだけかもですが)ので、投入したいテキストが膨大な時などはチャンク分割などの工夫が必要になります。