Closed6

vLLM で Structured Output を試す

kun432kun432

事前準備

前提

  • Ubuntu-22.04 + RTX4090
  • Python-3.12
  • vLLM の OpenAI互換APIサーバ を使用

インストール

Quickstartどおりに進める

https://docs.vllm.ai/en/stable/getting_started/quickstart.html

uv init -p 3.12 vllm-structured-output-work && cd $_
uv venv --seed
source .venv/bin/activate
uv pip install vllm --torch-backend=auto
出力
(snip)
 + vllm==0.11.0
(snip)

サーバを起動。どのモデルを選ぶか?によって結果が変わってきそうだけども、とりあえずQuickstart通りに Qwen2.5-1.5B-Instruct で。

vllm serve Qwen/Qwen2.5-1.5B-Instruct
出力
(APIServer pid=3494376) INFO 10-11 17:38:51 [api_server.py:1912] Starting vLLM API server 0 on http://0.0.0.0:8000

動作確認

curl http://localhost:8000/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d '{
    "model": "Qwen/Qwen2.5-1.5B-Instruct",
    "messages": [
      {
        "role": "user",
        "content": "競馬の魅力について5つリストアップして"
      }
    ]
  }' | jq -r .choices[0].message.content
出力
1. 美味しい食べ物:競馬は特別な日を祝うために、多くの人々が豪華な食事を楽しむ機会です。

2. 遊び心:競馬は多くの人々にとって大いに楽しみで、多くの人々がその世界に入り込むことができます。

3. 感動的な瞬間:勝利や敗北など、競馬は人間の情熱と努力の結果として、素晴らしい感動を生み出します。

4. 観戦への好奇心:競馬は、誰もが興味を持つ可能性のあるものであり、それを観察することによって新しい視点を得ることができます。

5. 社交性:競馬は社交的で、多くの人々が集まり、競馬の知識を共有し合うことができます。

別のホストからOpenAI SDKでも確認しておく。

uv init -p 3.12 vllm-test && cd $_
uv add openai
出力
 + openai==2.3.0
main.py
from openai import OpenAI

client = OpenAI(
    base_url="http://<サーバのIPアドレス>:8000/v1",
    api_key="dummy",
)

response = client.chat.completions.create(
    model="Qwen/Qwen2.5-1.5B-Instruct",
    messages=[
        {
            "role": "user",
            "content": "競馬の魅力について5つリストアップして"
        }
    ]
)

print(response.choices[0].message.content)
出力
1. 競技性:競馬は多くの人が興味を持ち、その競技性と刺激性を楽しむことができます。

2. 人間関係:競馬には多くのファンがいて、それらの人々との交流もまた楽しいものです。

3. 健康:馬術や競馬場での体験を通じて、健康に良い影響を与えます。

4. 販売:競馬は様々な商品(馬券、レース記念品など)を販売することで、多くの収益源があります。

5. 文化:日本の競馬文化は非常に発展しており、その歴史と伝統を理解する機会も提供します。
kun432kun432

Structured Outputs

https://docs.vllm.ai/en/stable/features/structured_outputs.html

vLLM は Structured Output をサポートしており、xgrammar または guidance がそのバックエンドとして使用されている。

vLLM の OpenAI 互換 API サーバは、デフォルトで Structured Output がサポートされており、リクエストに応じて、適切なバックエンドが選択されるモード(auto)になっているが、これはサーバ起動時のオプション --structured-outputs-config.backend で任意のバックエンドを指定することもできる。

出力を指定するパラメータには以下がある。

  • choice: 出力は指定された選択肢のいずれか1つに完全に一致する。
  • regex: 出力は正規表現パターンに従う。
  • json: 出力はJSONスキーマに準拠する。
  • grammar: 出力は文脈自由文法に従う形式になる。
  • structural_tag: 生成されたテキスト内の指定されたタグセットに従ってJSONスキーマに準拠する。

シンプルな例としてchoiceを使った場合。

from openai import OpenAI

client = OpenAI(
    base_url="http://<サーバのIPアドレス>:8000/v1",
    api_key="dummy",
)
model = client.models.list().data[0].id

response = client.chat.completions.create(
    model=model,
    messages=[
        {
            "role": "user",
            "content": "この文章の感情を分類して: 競馬は最高だ!"
        }
    ],
    extra_body={"structured_outputs": {"choice": ["positive", "negative"]}},
)

print(response.choices[0].message.content)

OpenAI SDKに extra_bodyみたいなパラメータがあって、どうやらOpenAI APIにない独自のパラメータをここで指定するというものみたい。

出力
positive

ちなみにこのパラメータを指定しない場合はこうなる。

この文は、喜びや興奮といった積極的な感情が含まれています。"競馬は最高だ!" という表現は、人間の喜びや感動を表す言葉で、具体的な行動や出来事に対しての満足感や喜びを示しています。

次にregexの例

from openai import OpenAI

client = OpenAI(
    base_url="http://<サーバのIPアドレス>:8000/v1",
    api_key="dummy",
)
model = client.models.list().data[0].id

response = client.chat.completions.create(
    model=model,
    messages=[
        {
            "role": "user",
            "content": (
                "エニグマ社で働いているアラン・チューリングさんの"
                "サンプルのメールアドレスを生成してください。"
                "行末は、.com と それに続く改行で終わるようにしてください。"
                "例: john.doe@example.com\n"
            )
        }
    ],
    extra_body={"structured_outputs": {"regex": r"\w+@\w+\.com\n"}, "stop": ["\n"]},
)

print(response.choices[0].message.content)
出力
alan_turing@eniigm.com
kun432kun432

ところでちょっとおさらい。

OpenAI の公式 API で Structured Outputs を使う場合、以下で試しているように Pydantic モデル or JSON スキーマで出力を定義して、response_format パラメータで渡していた。

https://zenn.dev/kun432/scraps/91db0da07b4a24

from openai import OpenAI
from pydantic import BaseModel

client = OpenAI()

class CalendarEvent(BaseModel):
    name: str
    date: str
    participants: list[str]

completion = client.chat.completions.parse(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "イベント情報を抽出して。日本語で。"},
        {"role": "user", "content": "アリスとボブは金曜日に科学フェアに行きます。"},
    ],
    response_format=CalendarEvent,
)

print(completion.choices[0].message)
出力(見やすさのために改行を入れている)
ParsedChatCompletionMessage[CalendarEvent](
    content='{"name":"科学フェア","date":"金曜日","participants":["アリス","ボブ"]}',
    refusal=None,
    role='assistant',
    annotations=[],
    audio=None,
    function_call=None,
    tool_calls=None,
    parsed=CalendarEvent(name='科学フェア', date='金曜日', participants=['アリス', 'ボブ'])
)

なので、以下のようにすれば個々の値を取り出せる。

event = completion.choices[0].message.parsed
print("イベント名:", event.name)
print("イベント開催日:", event.date)
print("イベント参加者:", event.participants)
出力
イベント名: 科学フェア
イベント開催日: 金曜日
イベント参加者: ['アリス', 'ボブ']

で、Pydanticモデルを使わずにJSON スキーマで渡すやり方もあるみたい。自分はこちらを試したことがなく、APIリファレンス によると JSON スキーマの場合は以下のように指定するみたい。

from openai import OpenAI
from pydantic import BaseModel

client = OpenAI()

class CalendarEvent(BaseModel):
    name: str
    date: str
    participants: list[str]

completion = client.chat.completions.parse(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "イベント情報を抽出して。日本語で。"},
        {"role": "user", "content": "アリスとボブは金曜日に科学フェアに行きます。"},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": CalendarEvent.__name__,
            "schema": CalendarEvent.model_json_schema()
        }
    }
)

print(completion.choices[0].message)
出力(見やすさのために改行を入れている)
ParsedChatCompletionMessage[NoneType](
    content='{"name":"科学フェア","date":"金曜日","participants":["アリス","ボブ"]}',
    refusal=None,
    role='assistant',
    annotations=[],
    audio=None,
    function_call=None,
    tool_calls=None,
    parsed=None
)

んー、どうもパースされた状態では返されないみたい。contentにはJSON文字列として入ってはいるので取り出せなくはないけども。そういうものなのかな?

とりあえずこの辺りを意識しつつ、vLLMに戻る。

kun432kun432

でvLLMに戻るが、OpenAI互換APIサーバで使うならば、クライアントは当然OpenAI SDKで使いたいし、モデル定義もPydantic or JSONスキーマでやりたい。

で、vLLMのドキュメントにあるサンプルコードを見ると、extra_bodyではなく、response_formatにJSONスキーマを指定している様子。

from pydantic import BaseModel
from enum import Enum
from openai import OpenAI

client = OpenAI(
    base_url="http://<サーバのIPアドレス>:8000/v1",
    api_key="dummy",
)
model = client.models.list().data[0].id

class CarType(str, Enum):
    sedan = "セダン"
    suv = "SUV"
    truck = "トラック"
    coupe = "クーペ"

class CarDescription(BaseModel):
    brand: str
    model: str
    car_type: CarType

completion = client.chat.completions.create(
    model=model,
    messages=[
        {
            "role": "user",
            "content": "1990年代を代表する最も象徴的な車について、ブランド、モデル、車種別分類を含むJSONデータを生成してください。日本語で出力すること。",
        }
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": CarDescription.__name__,
            "schema": CarDescription.model_json_schema()
        },
    },
)
print(completion.choices[0].message)
出力(見やすさのために改行を入れている)
ChatCompletionMessage(
    content='{\n  "brand": "トヨタ",\n  "model": "プリウス",\n  "car_type": "セダン"\n}',
    refusal=None,
    role='assistant',
    annotations=None,
    audio=None,
    function_call=None,
    tool_calls=[],
    reasoning_content=None
)

chat.completions.create()メソッドを使っているので、まあこれは当然と言えば当然。chat.completions.parse()メソッドを使う&Pydanticモデルをそのまま渡すとどうなるか?

from pydantic import BaseModel
from enum import Enum
from openai import OpenAI

client = OpenAI(
    base_url="http://<サーバのIPアドレス>:8000/v1",
    api_key="dummy",
)
model = client.models.list().data[0].id

class CarType(str, Enum):
    sedan = "セダン"
    suv = "SUV"
    truck = "トラック"
    coupe = "クーペ"

class CarDescription(BaseModel):
    brand: str
    model: str
    car_type: CarType

completion = client.chat.completions.parse(
    model=model,
    messages=[
        {
            "role": "user",
            "content": "1990年代を代表する最も象徴的な車について、ブランド、モデル、車種別分類を含むJSONデータを生成してください。日本語で出力すること。",
        }
    ],
    response_format=CarDescription
)
print(completion.choices[0].message)
出力(見やすさのために改行を入れている)
ParsedChatCompletionMessage[CarDescription](
    content='{\n  "brand": "トヨタ",\n  "model": "プリウス",\n  "car_type": "SUV"\n}',
    refusal=None,
    role='assistant',
    annotations=None,
    audio=None,
    function_call=None,
    tool_calls=None,
    parsed=CarDescription(brand='トヨタ', model='プリウス', car_type=<CarType.suv: 'SUV'>),
    reasoning_content=None
)

OpenAI APIを使う場合と同じような書き方で行けそう(プリウスはSUVではないと思うけども)。


ってのが下の方に "Experimental Automatic Parsing (OpenAI API)" として書いてあった。一応Experimentalなのね。

https://docs.vllm.ai/en/stable/features/structured_outputs.html#reasoning-outputs

というかStructured Outputについてはドキュメントの内容が全体的に古い気がする(更新はされてるみたいだけど、古い内容を完全に更新したり消したりしてない感じ)

kun432kun432

Reasoningモデルの場合はReasoningの出力を普通の出力を分けることができる。

https://zenn.dev/link/comments/06ab86197c392b

これをStructured Outputsと組み合わせてみる。

Reasoningモデルとして、cyberagent/DeepSeek-R1-Distill-Qwen-14B-Japanese を使う。モデルによって、Reasoning出力のサポート状況が異なるので注意(このモデルはDeepSeek-R1ベースで、DeepSeek-R1はReasoning出力でサポートされているので)nvidia-smi

uv pip install bitsandbytes
vllm serve cyberagent/DeepSeek-R1-Distill-Qwen-14B-Japanese \
    --host 0.0.0.0 \
    --port 8000 \
    --quantization bitsandbytes \
    --max-model-len 16384 \
    --max-num-seqs 1 \
    --kv-cache-dtype fp8 \
    --reasoning-parser deepseek_r1

1つ前で試したサンプルを使う。

from pydantic import BaseModel
from enum import Enum
from openai import OpenAI

client = OpenAI(
    base_url="http://<サーバのIPアドレス>:8000/v1",
    api_key="dummy",
)
model = client.models.list().data[0].id

class CarType(str, Enum):
    sedan = "セダン"
    suv = "SUV"
    truck = "トラック"
    coupe = "クーペ"

class CarDescription(BaseModel):
    brand: str
    model: str
    car_type: CarType

completion = client.chat.completions.parse(
    model=model,
    messages=[
        {
            "role": "user",
            "content": "1990年代を代表する最も象徴的な車について、ブランド、モデル、車種別分類を含むJSONデータを生成してください。日本語で出力すること。",
        }
    ],
    response_format=CarDescription,
)
print(completion.choices[0].message)
出力(見やすさのために改行を入れている)
ParsedChatCompletionMessage[CarDescription](
    content='{  \n  "brand": "トヨタ",  \n  "model": "カローラ",  \n  "car_type": "セダン"  \n}',
    refusal=None,
    role='assistant',
    annotations=None,
    audio=None,
    function_call=None,
    tool_calls=None,
    parsed=CarDescription(brand='トヨタ', model='カローラ', car_type=<CarType.sedan: 'セダン'>),
    reasoning_content='\nまず、1990年代の代表的な車を選ぶ必要があります。その時代の車を象徴するモデルを特定します。日本ではトヨタのカローラやセリカ、日産のシルフィやスカイライン、ホンダのアコードなどが有名です。海外ではフォード・マスタングやメルセデス・ベンツのSクラスなども候補に上がります。\n\n次に、各車のブランド、モデル名、車種分類を正確に調べます。例えば、トヨタのカローラはセダンやハッチバックなど、複数の車種があります。1990年代のモデルを特定するために、発売年やモデルチェンジの時期を確認します。\n\nJSON形式にデータを整形します。キーは「ブランド」「モデル」「車種分類」を設定し、各モデルに該当する情報を入れます。日本語で出力するため、文字化けしないように注意します。また、複数の車種を持つモデルがあれば、配列としてまとめます。\n\n最後に、生成したJSONが正確で読みやすいか確認します。必要に応じて、車種分類をより詳細に分類したり、モデル名を正確に記載したりします。これで、1990年代の象徴的な車を代表するJSONデータが完成します。\n')

以下のように出力すればそれぞれ取得できる。

print("回答:", completion.choices[0].message.content)
print("推論:", completion.choices[0].message.reasoning_content)
car = completion.choices[0].message.parsed
print("ブランド:", car.brand)
print("モデル:", car.model)
print("タイプ:", car.car_type.value)
回答: {
  "brand": "トヨタ",
  "model": "カローラ",
  "car_type": "セダン"
}
推論:
まず、1990年代の代表的な車を選ぶ必要があります。その時代の車を象徴するモデルを特定します。日本ではトヨタのカローラやセリカ、日産のシルフィやスカイライン、ホンダのアコードなどが有名です。海外ではフォード・マスタングやメルセデス・ベンツのSクラスなども候補に上がります。

次に、各車のブランド、モデル名、車種分類を正確に調べます。例えば、トヨタのカローラはセダンやハッチバックなど、複数の車種があります。1990年代のモデルを特定するために、発売年やモデルチェンジの時期を確認します。

JSON形式にデータを整形します。キーは「ブランド」「モデル」「車種分類」を設定し、各モデルに該当する情報を入れます。日本語で出力するため、文字化けしないように注意します。また、複数の車種を持つモデルがあれば、配列としてまとめます。

最後に、生成したJSONが正確で読みやすいか確認します。必要に応じて、車種分類をより詳細に分類したり、モデル名を正確に記載したりします。これで、1990年代の象徴的な車を代表するJSONデータが完成します。

ブランド: トヨタ
モデル: カローラ
タイプ: セダン
このスクラップは4日前にクローズされました