ローカルLLM(Gemma 2 2B)で「Structured Outputs(構造化出力)」っぽいことGuardrails AIで実現する

## 概要
Guardrails AI[^guardrails]を用いてローカルLLMの出力を検査&Parseする方法をまとめた記事.

### Structured Outputs(構造化出力)[^structured-outputs]とは
モデルが常に指定されたJSONスキーマに従った応答を生成することを保証する機能でありOpenAIのAPIで提供されている機能

:::note alert
本記事はStructured OutputsっぽいことをローカルLLMで実現する手法を紹介しています. 出力を100%保証するものではありません
:::

:::note warn
Guardrails AIを用いることで期待しない出力の検出や出力のParseが簡単に行えます. 
:::
## sample
[Hikakkun/local-llm-structured-output(Github)](https://github.com/Hikakkun/local-llm-structured-output)
## Guardrails AI[^guardrails]とは
ローカルのLLMから信頼性の高い出力を得るために設計されたPythonフレームワーク
1. LLMの入力と出力に対してGuardを適用し、期待しない出力やリスクを検出
1. LLMからJSONなどの構造化データを生成し, 信頼性の高いデータ処理を支援
* 以下のコードだけでLLMの出力を検査&Parseすることが可能
    * `pip install guardrails-ai` でインストール可能
    ```python
    from pydantic import BaseModel
    from guardrails import Guard
    
    class Human(BaseModel):
        name : str
        age : int
        
    guard = Guard.for_pydantic(Human)
    validated_output = guard.parse("LLMからの出力")
    if validated_output.validation_passed:
        #validated_output.validated_outputはHumanクラスのコンストラクタに使用できる
        human = Human(**validated_output.validated_output)
    else:
        #エラー処理やLLMで再度処理を実行する etc.
    ```

## 使用したLLMとタスク
* 使用モデル `gemma-2-2b-jpn-it-IQ4_XS.gguf`[^gemma-2-2b-gguf]
    * 日本語版 Gemma 2 2B[^gemma-2-2b]をgguf化し4bit量子化を行ったもの
* 実行するタスクは以下で定義されるキャラクターの生成
    ```python
    from enum import Enum, auto
    from pydantic import BaseModel, Field
    
    
    class Weapon(Enum):
        def _generate_next_value_(name, start, count, last_values):
            # これを定義しておくと Staff = "Staff"をauto()で行ってくれる
            return name
    
        Staff = auto()
        Sword = auto()
        Bow = auto()
        FryingPan = auto()
        DualBlades = auto()
        Gun = auto()
    
    
    class Character(BaseModel):
        name: str = Field(
            description="キャラクターの名前",
        )
        weapon: Weapon = Field(
            description="キャラクターが使用する武器の種類",
        )
        description: str = Field(
            description="キャラクター得意技, 生い立ちなどの説明",
        )
    
    ```
* キャラクターを生成するためのprompt
    * Code blocks内のJSON Schemaは`class Character`から`Character.model_json_schema()`で取得可能
    * 詳しくは[GitHub](https://github.com/Hikakkun/local-llm-structured-output)の`main.py`を参考にしてください
    ``````
    <start_of_turn>user
    出力は次のJSON Schemaに準拠し、Json形式で行うこと。
    ```json
    {
      "$defs": {
        "Weapon": {
          "enum": ["Staff", "Sword", "Bow", "FryingPan", "DualBlades", "Gun"],
          "title": "Weapon",
          "type": "string"
        }
      },
      "properties": {
        "name": {
          "description": "キャラクターの名前",
          "title": "Name",
          "type": "string"
        },
        "weapon": {
          "$ref": "#/$defs/Weapon",
          "description": "キャラクターが使用する武器の種類"
        },
        "description": {
          "description": "キャラクター得意技などのキャラクターを体現するような説明",
          "title": "Description",
          "type": "string"
        }
      },
      "required": ["name", "weapon", "description"],
      "title": "Character",
      "type": "object"
    }
    ```
     キャラクターを生成して<end_of_turn>
    <start_of_turn>model
    <end_of_turn>
    ``````

## 上記のpromptをLLMで処理する上の問題点
* JSON形式で出力される保証はない
    * 700回ほど生成して4回エラー
        * Gemma 自体が結構性能いい
        * `max_tokens`の数を減らせば解決される?
        * この程度であれば正規表現でなんとかなりそうだが複雑なJSONを出力する場合は少し面倒
    ### 面倒な出力例
    1. 解説が書かれている
        ``````txt
        ```json
        {
          "name": "Aoi Sakura",
          "weapon": "Bow",
          "description": "優雅で落ち着いた性格だが、... (省略) ...一発で敵を倒すことができる。" 
        }
        ``` 
        
        
        
        **解説**
        
        * **name:** キャラクターの名前
        * **weapon:** キャラクターが使用する武器の種類（Bow）
        * **description:** キャラクターの性格や特徴、得意技などを記述
        ``````
    1. JSONとしてエラー
        * descriptionのvalueの締めの`"`がない
        ```
        {
          "name": "アリス",
          "weapon": "Staff",
          "description": "静かな力を...(省略) ...周囲を揺さぶる。」
        }
        ``` 
## Guardrails AI[^guardrails]を用いた解決法
* 出力が`Character`に則しているかの判定とParseが可能
    ```python
    from guardrails import Guard
    guard = Guard.for_pydantic(Character)
    validated_output = guard.parse("LLMからの出力")
    if validated_output.validation_passed:
        #これ以下は出力がCharacterであることが保証されている
        character_json = validated_output.validated_output
        character = Character(**character_json)
    else:
        # エラーだったvalidated_output.errorにエラー内容が格納されている
        error = validated_output.error
    ```
    * `出力例 1`も`guard.parse`を用いれば問題なくJSON部分を抽出してくれる
    * `出力例 2`は`validated_output.error="Unterminated string starting at: line 4 column 22 (char 69)"`
* `Guard.for_pydantic`の他にもHTMLのような定義の仕方も可能
    * [Generate structured data](https://www.guardrailsai.com/docs/how_to_guides/structured_data_with_guardrails)
        ```python
        from guardrails import Guard
        
        guard = Guard.for_rail_string("""
          <rail version="0.1">
            <output>
              <string name="name" />
              <integer name="age" />
              <boolean name="is_employed" />
            </output>
          </rail>
        """)    
        ```

[^structured-outputs]: [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs) 
[^guardrails]: [Guardrails AI](https://www.guardrailsai.com/)
[^gemma-2-2b-gguf]: [alfredplpl/gemma-2-2b-jpn-it-gguf](https://huggingface.co/alfredplpl/gemma-2-2b-jpn-it-gguf)
[^gemma-2-2b]: [日本語版 Gemma 2 2B を公開](https://blog.google/intl/ja-jp/company-news/technology/gemma-2-2b/)