🍉

Pydanticを使ったLLMのプロンプトテクニック

テラーノベルで機械学習を担当している川尻です。最近、LLMを使った社内業務の自動化などを行っていて、Pydanticを使ったテクニックが良かったので紹介します。

概要

OpenAIの最新のモデルの gpt-4-1106-previewgpt-3.5-turbo-1106 でJSON Modeがサポートされました(2023/12/27現在)[1]。 これにより出力結果を後段タスクのプログラムで扱いやすくなります。Pydantic [2] というのは、Pythonのデータバリデーションライブラリで、型情報を強力に扱うことができて高速に動きます。最初はPydanticを出力結果のパースにだけ使うつもりでしたが、Pydanticを使うとJSON Schema[3] を出力できることを思い出し、そのまま出力形式を指定するためにプロンプトに入れるといいのではと気が付きました。その結果、OpenAIが公開しているプロンプトエンジニアリング [4] のうち、以下の項目も満たせる、もしくは満たしやすくなります。

Tactic: Use delimiters to clearly indicate distinct parts of the input
Tactic: Provide examples
Tactic: Specify the desired length of the output

利点・欠点は以下のようになります。

利点

  • JSON Schemaをプロンプトを渡すことで出力の形式を明示できる。
  • Pydanticによる型ヒントを活用したプロンプトを作成できる。
  • LLMによるJSONの出力結果を人間が読むやすい形式に変換するときにも便利(後述)。

欠点

  • プロンプトが少し長くなる
  • プロンプトの編集にプログラミングの知識が必要 (エンジニアじゃない人に修正を任せづらい)

続く章では、具体的な方法を紹介します。

事前準備

以下のようにモジュールはインポート済みで、OpenAIのAPI Keyも環境変数に設定済みとします。

import json

import tiktoken
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel, Field
from rich import print

load_dotenv()
client = OpenAI()

プロンプトにJSON Schema

例として、小説についての情報抽出する状況を考えます。Pydanticを使わずに普通に書く場合は以下のようになるかと思います。

元のプロンプト
original_prompt = """夏目漱石の小説「坊っちゃん」について、以下のjson 形式に従って、要約と主要な登場人物を返してください。
* charactersには、主な登場人物。最低1人、最大7人までのキャラクターのリストです。
{
    "genre": "ジャンル。1個以上。例: 推理, 青春, ホラー, SF, ファンタジー, 恋愛, 歴史, ノンフィクション",
    "summary": "文章の要約。面白さが伝わるように、小説の要約を書いてください。",
    "characters": [
        {
            "name": "キャラクターの名前",
            "desc": "キャラクターの説明"
        }
    ]
}
"""

response = client.chat.completions.create(
    model="gpt-4-1106-preview",
    response_format={"type": "json_object"},
    temperature=0.0,
    messages=[
        {"role": "system", "content": "あなたは日本の文学に詳しい国語の先生です。"},
        {"role": "user", "content": original_prompt},
    ]
)
print(response.choices[0].message.content)
print(response.usage)
出力結果
{
    "genre": "青春, コメディ",
    "summary": 
"「坊っちゃん」とは、東京の裕福な家庭に生まれた主人公の愛称で、彼の四国の小さな町での教師生活を描いています。正義感
が強く、素直だが少々無鉄砲な坊っちゃんは、新任教師として赴任した学校で、生徒や同僚教師たちとの間に様々なトラブルを
経験します。特に、狡猾な数学教師・赤シャツや卑怯な教頭との対立は、物語の中心的なエピソードです。坊っちゃんの直情径
行な性格が引き起こす騒動と、彼の純粋な心が周囲に与える影響をユーモアを交えて描き、日本の教育界や社会の矛盾を風刺し
ています。",
    "characters": [
        {
            "name": "坊っちゃん",
            "desc": 
"主人公。東京の裕福な家庭に生まれ、正義感が強く素直だが無鉄砲な性格の若者。四国の学校に教師として赴任する。"
        },
        {
            "name": "赤シャツ",
            "desc": "数学教師。狡猾で自己中心的な性格をしており、坊っちゃんと対立する。"
        },
        {
            "name": "教頭",
            "desc": "坊っちゃんが赴任した学校の教頭。卑怯な性格で、赤シャツと結託している。"
        },
        {
            "name": "野だいこ",
            "desc": "体育教師。坊っちゃんの数少ない味方の一人で、豪快な性格。"
        },
        {
            "name": "うらなり",
            "desc": "英語教師。裏表のある性格で、坊っちゃんを陥れようとする。"
        },
        {
            "name": "清",
            "desc": "坊っちゃんの使用人。坊っちゃんを慕い、彼を支える。"
        },
        {
            "name": "美代子",
            "desc": "坊っちゃんの初恋の人。物語の中で彼女の存在は坊っちゃんに影響を与える。"
        }
    ]
}
CompletionUsage(completion_tokens=757, prompt_tokens=268, total_tokens=1025)

PydanticをつかうとJSON Schema以下のように、各フィールドに titledescriptionをつけることができます。また、examplesmin/max_length なども、自然言語ではなくPythonのプログラムやJSON Schemaとして明示的に表現できます。

PydanticでJSON Schemaを出力
class Character(BaseModel):
    name: str = Field(..., title="キャラクターの名前")
    desc: str = Field(..., title="キャラクターの説明")

class Output(BaseModel):
    genre: list[str] = Field(
        ...,
        title="ジャンル",
        min_length=1,
        examples=["推理", "青春", "ホラー", "SF", "ファンタジー", "恋愛", "歴史", "ノンフィクション"],
    )
    summary: str = Field(
        ...,
        title="小説の要約",
        description="面白さが伝わるように、小説の要約を書いてください。",
    )
    characters: list[Character] = Field(
        ...,
        title="主要な登場人物",
        min_length=1,
        max_length=7,
    )
print(json.dumps(Output.model_json_schema(), ensure_ascii=False, indent=2))
JSON Schema
{
    "$defs": {
        "Character": {
            "properties": {
                "name": {
                    "title": "キャラクターの名前",
                    "type": "string"
                },
                "desc": {
                    "title": "キャラクターの説明",
                    "type": "string"
                }
            },
            "required": [
                "name",
                "desc"
            ],
            "title": "Character",
            "type": "object"
        }
    },
    "properties": {
        "genre": {
            "examples": [
                "推理",
                "青春",
                "ホラー",
                "SF",
                "ファンタジー",
                "恋愛",
                "歴史",
                "ノンフィクション"
            ],
            "items": {
                "type": "string"
            },
            "minItems": 1,
            "title": "ジャンル",
            "type": "array"
        },
        "summary": {
            "description": "面白さが伝わるように、小説の要約を書いてください。",
            "title": "小説の要約",
            "type": "string"
        },
        "characters": {
            "items": {
                "$ref": "#/$defs/Character"
            },
            "maxItems": 7,
            "minItems": 1,
            "title": "主要な登場人物",
            "type": "array"
        }
    },
    "required": [
        "genre",
        "summary",
        "characters"
    ],
    "title": "Output",
    "type": "object"
}

これを使うとJSON Schemaをプロンプトに埋め込めて、出力も簡単にパースすることができます。

JSON Schemaを使って結果取得
prompt = "夏目漱石の小説「坊っちゃん」について、以下のJSON Schemaに従って、要約と主要な登場人物を返してください。\n"
json_schema = json.dumps(Output.model_json_schema(), ensure_ascii=False, separators=(",", ":"))
json_schema_prompt = prompt + json_schema
response = client.chat.completions.create(
    model="gpt-4-1106-preview",
    response_format={"type": "json_object"},
    temperature=0.0,
    messages=[
        {"role": "system", "content": "あなたは日本の文学に詳しい国語の先生です。"},
        {"role": "user", "content": json_schema_prompt},
    ],
)
print(response)
output = Output.model_validate_json(response.choices[0].message.content, strict=False)
print(output)
出力
Output(
    genre=['青春'],
    summary='「坊っちゃん」は、東京の生まれ育った主人公が四国の田舎町にある学校に赴任し、そこで起こる様々なトラブル
や人間関係のもつれを通じて成長していく様子を描いた小説です。主人公は正義感が強く、素直だが少々無鉄砲な性格で、周囲
の大人たちの狡猾さや不正に対して立ち向かっていきます。彼の率直さと行動力が、地元の生徒や一部の教師からは支持を受け
る一方で、保守的な教師たちとの間で衝突を引き起こします。物語は、主人公が自分の信念を貫き、最終的には田舎町を去るこ
とになるが、その過程で人間性の深みを学び、成長していく姿をユーモラスに描いています。',
    characters=[
        Character(
            name='坊っちゃん',
            desc='物語の主人公で、東京の裕福な家庭に生まれた若者。正義感が強く、素直だが行動的で少々無鉄砲。四国の
学校に赴任し、多くのトラブルに巻き込まれながらも成長していく。'
        ),
        Character(
            name='赤シャツ',
            desc='主人公が赴任した学校の数学教師。坊っちゃんとは対照的に狡猾で、生徒たちには人気があるが、裏では様
々な策略を巡らせている。'
        ),
        Character(
            name='ニセ君',
            desc='学校の英語教師で、赤シャツと組んで坊っちゃんを陥れようとする。表面上は紳士的だが、その実態は狡猾
で自己中心的。'
        ),
        Character(
            name='ウラン',
            desc='学校の理科教師。坊っちゃんとは友人関係にあり、彼を支持し助ける。正直でまっすぐな性格。'
        ),
        Character(
            name='野だいこ',
            desc='学校の体育教師。坊っちゃんとは良い関係を築き、彼の正義感を理解し支える。'
        ),
        Character(name='清', desc='坊っちゃんの下宿の女中。坊っちゃんに好意を持ち、彼を慕っている。'),
        Character(
            name='山嵐',
            desc='坊っちゃんと同じ下宿に住む数学教師。坊っちゃんの良き理解者であり、彼の行動を支持する。'
        )
    ]
)
CompletionUsage(completion_tokens=908, prompt_tokens=315, total_tokens=1223)

プロンプトのトークン数比較

プロンプトのトークン数を比較すると以下のようになりました。

enc = tiktoken.get_encoding('cl100k_base')
print("オリジナル: ", len(enc.encode(original_prompt)))
print("Pydantic JSON Schema: ", len(enc.encode(json_schema_prompt)))
オリジナル:  235
Pydantic JSON Schema:  282

20%ほど長くなってしまいました。しかし、実際にはJSON Schema以外に渡すコンテキストのほうが圧倒的に長くなることが多いと思うので、JSON Schema部分についてだけであれば、利便性と比べれば許容範囲かと考えています。

出力結果を見やすく表示

おまけ的な利点として、JSONで受け取った出力結果をMarkdownやHTMLなどの人間が見やすい形式に変換するときに、JSON Schemaのためにつけた情報が便利に使えるというのが挙げられます。 model_fieldsに各フィールドの情報が入っていて、例えば以下のようにMarkdownの表示に使えます。

Pydanticの情報を使って出力
txt = f"""
## {output.model_fields["genre"].title} (例:{", ".join(output.model_fields["genre"].examples)})
{", ".join(output.genre)}

## {output.model_fields["summary"].title} ({output.model_fields["summary"].description})
{output.summary}

## {output.model_fields["characters"].title} ({output.model_fields["characters"].metadata[0].min_length}{output.model_fields["characters"].metadata[1].max_length}人)
"""

for character in output.characters:
    txt += f"""
  {character.model_fields["name"].title}:{character.name}
  {character.model_fields["desc"].title}:{character.desc}
"""

print(txt)
出力結果
## ジャンル (例:推理, 青春, ホラー, SF, ファンタジー, 恋愛, 歴史, ノンフィクション)
青春

## 小説の要約 (面白さが伝わるように、小説の要約を書いてください。)
「坊っちゃん」は、東京の生まれ育った主人公が四国の田舎町にある学校に赴任し、生徒や地元の教師たちとの間で起こる様々なトラブルや対立を描いた物語です。主人公は正義感が強く、素直だが少々無鉄砲な性格で、周囲と衝突しながらも自分の信念を貫きます。彼の行動は次第に生徒たちからの信頼を得て、最終的には不正を働く教師たちを退けることに成功します。この小説は、夏目漱石の自伝的要素を含みつつ、教育界の不正や人間の愚かさを風刺した作品として知られています。

## 主要な登場人物 (1〜7人)

  キャラクターの名前:坊っちゃん
  キャラクターの説明:物語の主人公で、東京の裕福な家庭に生まれた若者。正義感が強く、素直だが行動が無鉄砲で、新任教師として四国の学校に赴任します。

  キャラクターの名前:赤シャツ
  キャラクターの説明:主人公が赴任した学校の数学教師。坊っちゃんと意気投合し、彼の良き理解者となる。

  キャラクターの名前:ウラン
  キャラクターの説明:主人公が赴任した学校の理科教師。坊っちゃんとは対立するが、後に和解します。

  キャラクターの名前:ニセ君
  キャラクターの説明:主人公が赴任した学校の英語教師。二枚舌を使い、坊っちゃんを陥れようとするが、最終的にはその策略が露見します。

  キャラクターの名前:野だいこ
  キャラクターの説明:主人公が赴任した学校の体操教師。粗野で乱暴な性格。

  キャラクターの名前:清
  キャラクターの説明:坊っちゃんの家の使用人で、彼を「坊っちゃん」と呼び慕う。坊っちゃんが学校に赴任する際にも、彼を支え続けます。

まとめ

以上、最近LLMを使っていて見つけたテクニックを紹介しました。プロンプトが長くなってしまう問題は、プロンプト圧縮などの技術で解決できないかなと考えていました。まだ試せていないですが、うまく動いたらまた報告します。
https://github.com/microsoft/LLMLingua

脚注
  1. Json Mode: https://platform.openai.com/docs/guides/text-generation/json-mode ↩︎

  2. Pydantic: https://docs.pydantic.dev/ ↩︎

  3. JSON Schema: https://json-schema.org/ ↩︎

  4. Prompt engineering: https://platform.openai.com/docs/guides/prompt-engineering ↩︎

テラーノベル テックブログ

Discussion