見出し画像

Ollama で structured outputs (構造化出力)を試す

tl;dr

  • JSON Schema で指定したフォーマットで出力を制御可能になったよ

  • cURL / Python / JavaScript のそれぞれで試してみたよ

  • 具体的な実用例があったのでそれも動かしてみたよ

  • 使う上での tips や今後どんな機能が追加されるかまとめたよ


公開されたブログの流れに準拠しつつ、意図がズレない範囲で翻訳、解説、コードの実行をしていきます。チュートリアルになっているので、よかったら手を動かして試してみてください。

Ollama が structured outputs をサポート。JSON Schema で定義したフォーマットに LLM の出力を制御するすることが可能になりました。Ollama の Python と JavaScript のそれぞれのライブラリにおいてもサポートするよう更新。

ブログでは structured outputs のユースケースとして下記の 4 つがあげられています。

  1. ドキュメントからのデータのパース

  2. 画像からのデータ抽出

  3. LLM のレスポンスの構造化

  4. JSON mode よりも高い信頼性と一貫性(※JSON mode は前から対応済み)

Ollama を最新版にアップデート

利用には最新版にアップデートしておく必要があります。

Ollama アプリをご利用の方はこちらから。

Python でお使いの方は下記のコマンドで。

pip install -U ollama

JavaScript でお使いの方は下記のコマンドで。

npm i ollama

ブログに記載はないですが、macOS にて Homebrew 経由で Ollama を入れていらっしゃる場合は下記のコマンドを実行してください。アップデート後に再起動することができます。

brew upgrade --cask ollama
pkill ollama
open -a Ollama
ollama --version

Ollama の structured outputs を試す

では早速リクエストを投げていきましょう。

まずライブラリによらない cURL から。下記のコマンドで Ollama のローカルサーバが立ち上がっていることをご確認ください。

ollama serve

> ollama serve
Error: listen tcp 127.0.0.1:11434: bind: address already in use

上記のような表示が得られたら OK です。では cURL コマンドを実行します。ブログとの差分は jq 部分です。

curl -X POST http://localhost:11434/api/chat -H "Content-Type: application/json" -d '{
  "model": "llama3.1",
  "messages": [{"role": "user", "content": "Tell me about Canada."}],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "name": {
        "type": "string"
      },
      "capital": {
        "type": "string"
      },
      "languages": {
        "type": "array",
        "items": {
          "type": "string"
        }
      }
    },
    "required": [
      "name",
      "capital", 
      "languages"
    ]
  }
}' | jq

下記のような出力が得られます。message の中の content(レスポンス部分)が JSON Schema 通りに返ってきています。

{
  "model": "llama3.1",
  "created_at": "2024-12-06T22:57:08.113971Z",
  "message": {
    "role": "assistant",
    "content": "{ \"capital\": \"Ottawa\", \"languages\": [\"English\", \"French\"], \"name\": \"Canada\" }"
  },
  "done_reason": "stop",
  "done": true,
  "total_duration": 2631078333,
  "load_duration": 30229458,
  "prompt_eval_count": 15,
  "prompt_eval_duration": 828000000,
  "eval_count": 30,
  "eval_duration": 1770000000
}

レスポンス部分だけを抜き出して整形すると下記のようになります。わかりやすい。

{
  "capital": "Ottawa",
  "languages": [
    "English",
    "French"
  ],
  "name": "Canada"
}

次に Python と JavaScript で試してみましょう。作業ディレクトリを作り、その中でファイルを実行します。

mkdir playground-ollama-structured-outputs
cd playground-ollama-structured-outputs

touch run.py
touch run.js

Python から実行します。ファイルの中身を下記のように書いてください。簡単に解説をすると、Country クラスにて、名前(ここでは国名)、首都名、言語をスキーマで定義しています。ですので、プロンプトを「カナダについて教えてください。」としていても、カナダについて解説してくれる訳ではなく、カナダのスキーマに従った情報が得られます(このすぐ後の出力を見ると一目でどういうことかわかるかと思います)。

# run.py
from ollama import chat
from pydantic import BaseModel

class Country(BaseModel):
  name: str
  capital: str
  languages: list[str]

response = chat(
  messages=[
    {
      'role': 'user',
      'content': 'カナダについて教えてください。',
    }
  ],
  model='llama3.1',
  format=Country.model_json_schema(),
)

country = Country.model_validate_json(response.message.content)
print(country)

下記のコマンドで実行します。

uv run --with ollama run.py

もし uv を入れていなければ仮想環境を作成し、下記のコマンドを実行してください。

pip install ollama
python run.py

出力として下記の出力が得られます。想定通りになっていますね。

name='カナダ' capital='オタワ' languages=['英語', 'フランス語']

次に JavaScript からも実行します。同様にファイルの中身を下記のように書いてください。

// run.js
import ollama from 'ollama';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

const Country = z.object({
    name: z.string(),
    capital: z.string(), 
    languages: z.array(z.string()),
});

const response = await ollama.chat({
    model: 'llama3.1',
    messages: [{ role: 'user', content: 'Tell me about Canada.' }],
    format: zodToJsonSchema(Country),
});

const country = Country.parse(JSON.parse(response.message.content));
console.log(country);

環境構築をします。node がもし入っていなければ Volta 経由で入れるのがおすすめです。

npm init -y
npm install ollama zod zod-to-json-schema
npm pkg set type=module

下記のコマンドで実行してください。

node run.js

下記のような出力が得られていれば成功です。

{ name: 'カナダ', capital: 'オタワ', languages: [ '英語', 'フランス語' ] }

具体的な使い方

具体例として下記の三つのタスクの実行例があげられています。それぞれ動かしてみます。

  1. データ抽出タスク

  2. 画像の説明タスク

  3. OpenAI 互換(これはタスクというより実例のひとつ)

まずはデータ抽出タスクを動かしていきましょう。ファイルを作成します。

touch pet-info-extraction.py

作成したファイルに下記のコードを入力してください。

# pet-info-extraction.py
from ollama import chat
from pydantic import BaseModel

class Pet(BaseModel):
  name: str
  animal: str
  age: int
  color: str | None
  favorite_toy: str | None

class PetList(BaseModel):
  pets: list[Pet]

response = chat(
  messages=[
    {
      'role': 'user',
      'content': '''
        二匹のペットを飼っています。
        一匹は 5 歳の猫で、名前はルナ。毛糸で遊ぶのが大好きです。毛色はグレーです。
        もう一匹は 2 歳の黒猫で、名前はロキ。テニスボールが大好きです。
      ''',
    }
  ],
  model='llama3.1',
  format=PetList.model_json_schema(),
)

pets = PetList.model_validate_json(response.message.content)
print(pets)

実行します。

uv run --with ollama pet-info-extraction.py

下記の出力が得られました。

pets=[Pet(name='Luna', animal='cat', age=5, color='gray', favorite_toy='fuzz ball'), Pet(name='Roku', animal='cat', age=2, color='black', favorite_toy='tennis ball')]

せっかくプロンプトを日本語訳したにもかかわらず、名前が違ったりして失敗しました。Llama 3.1 だとやはり日本語は弱いですね。model を llama3.1 から llama3.2 に変えると成功しました。最新モデルを使うに限りますね。

pets=[Pet(name='ルナ', animal='猫', age=5, color='グレー', favorite_toy='毛糸'), Pet(name='ロキ', animal='猫', age=2, color='黒', favorite_toy='テニスボール')]

次に画像の説明タスクを動かしていきましょう。

以前 Ollama で Llama 3.2 Vision を動かしたので、もしつまずくようでしたら下記の記事をご参照ください。

まずは時間のかかる Llama 3.2 Vision 11B を下記のコマンドでプルしましょう。

llama pull llama3.2-vision:11b

ファイルを作成し、必要な画像を揃えましょう。

touch image-description.py
curl -OL https://ollama.com/public/blog/beach.jpg

サンプルとして用意されていた下記のヤシの木のある海辺の写真を使います。

作成したファイルに下記のコードを書き込みましょう。

# image-description.py
from ollama import chat
from pydantic import BaseModel
from typing import List, Literal, Optional

class Object(BaseModel):
  name: str
  confidence: float
  attributes: str 

class ImageDescription(BaseModel):
  summary: str
  objects: List[Object]
  scene: str
  colors: List[str]
  time_of_day: Literal['Morning', 'Afternoon', 'Evening', 'Night']
  setting: Literal['Indoor', 'Outdoor', 'Unknown']
  text_content: Optional[str] = None

path = 'beach.jpg'

response = chat(
  model='llama3.2-vision:11b',
  format=ImageDescription.model_json_schema(),  # Pass in the schema for the response
  messages=[
    {
      'role': 'user',
      'content': 'この画像を分析し、オブジェクトやシーン、色、テキストなどの検出できる情報について説明してください。',
      'images': [path],
    },
  ],
  options={'temperature': 0},  # Set temperature to 0 for more deterministic output
)

image_description = ImageDescription.model_validate_json(response.message.content)
print(image_description)

下記のコマンドを実行してください。

uv run --with ollama --with pydantic image-description.py

下記の出力が得られていれば成功です。

summary='A palm tree on a beach.' objects=[Object(name='tree', confidence=0.9, attributes='palm tree'), Object(name='beach', confidence=1.0, attributes='sand')] scene='beach' colors=['blue', 'green', 'white'] time_of_day='Afternoon' setting='Outdoor' text_content=None

ちなみに今回の実行環境として Macbook Air メモリ 16GB で試したのですが、このレスポンスを得るためにおおよそ 3 分くらい時間を要しました。

Llama 3.2 は公式では日本語対応していないため、英語で出力されています。ただ、内容としては適切に構造化できています。

最後にサンプルコードとして記載されている OpenAI 互換のコードを動かして終わりにしましょう。こちらは先ほどの Ollama Python ライブラリを使っていた二匹のペットの例を OpenAI ライブラリから呼び出した例です。

touch openai-compatibility.py

下記のコードを流し込んであげましょう。

# openai-compatibility.py
from openai import OpenAI
import openai
from pydantic import BaseModel

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

class Pet(BaseModel):
    name: str
    animal: str
    age: int
    color: str | None
    favorite_toy: str | None

class PetList(BaseModel):
    pets: list[Pet]

try:
    completion = client.beta.chat.completions.parse(
        temperature=0,
        model="llama3.1:8b",
        messages=[
            {"role": "user", "content": '''
                二匹のペットを飼っています。
                一匹は 5 歳の猫で、名前はルナ。毛糸で遊ぶのが大好きです。毛色はグレーです。
                もう一匹は 2 歳の黒猫で、名前はロキ。テニスボールが大好きです。
            '''}
        ],
        response_format=PetList,
    )

    pet_response = completion.choices[0].message
    if pet_response.parsed:
        print(pet_response.parsed)
    elif pet_response.refusal:
        print(pet_response.refusal)
except Exception as e:
    if type(e) == openai.LengthFinishReasonError:
        print("Too many tokens: ", e)
        pass
    else:
        print(e)
        pass

では、下記のコマンドで実行してみましょう。

uv run --with openai openai-compatibility.py

先ほどと同様出力が英語になってしまっていますが、構造化はされています。

pets=[Pet(name='Luna', animal='cat', age=5, color='grey', favorite_toy='wool'), Pet(name='Roki', animal='cat', age=2, color='black', favorite_toy='tennis ball')]

参考までに Llama 3.2 で試した出力も合わせて置いておきます。

pets=[Pet(name='ルナ', animal='猫', age=5, color='グレー', favorite_toy='毛糸'), Pet(name='ロキ', animal='猫', age=2, color='黒', favorite_toy='テニスボール')]

ブログの最後に Tips と今後の開発について言及されています。

structured outputs をうまく使うコツとして下記の三つがあげられています。

  1. Python なら Pydantic、JavaScript なら Zod を利用してレスポンスのスキーマを定義する

  2. プロンプトに「return as JSON(JSON として返して)」などと入れる

  3. 出力のばらつきを減らすために temperature を 0 に指定する

今後の開発については下記の四つがあげられています。

  1. 出力制御のための logits の表示

  2. structured outputs の性能&精度の向上

  3. サンプリング時の GPU アクセラレーション

  4. JSON Schema 以外のフォーマットのサポート

個人的には JSON に揃えてから他のフォーマットに変換する手間も省けるので 4 に期待しています!


以上となります。以前 Gemini が OpenAI ライブラリに対応し、OpenAI ライブラリの形式が一般的になったいま、リクエスト形式に大きな変更を加えず、Gemini やオープンモデルを試せるのはかなりうれしいです。

この記事を読むだけではなく、ご自身の手元で実行してみて、プロダクトなどに使う際にどんな実装のイメージになるかよかったら試してみてください。


先月 AILBREAK という LLM を用いたゲームを開発しました。もしよろしければ遊んでください!パソコンからでもスマホからでも遊べます!(課金要素はありませんのでご安心ください)


いいなと思ったら応援しよう!