vLLM で Structured Output を試す
試す・・・というか、適用にやってみたけど、どうもうまくいかないので、改めてイチから。
事前準備
前提
- Ubuntu-22.04 + RTX4090
- Python-3.12
- vLLM の OpenAI互換APIサーバ を使用
インストール
Quickstartどおりに進める
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
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. 文化:日本の競馬文化は非常に発展しており、その歴史と伝統を理解する機会も提供します。
Structured Outputs
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
ところでちょっとおさらい。
OpenAI の公式 API で Structured Outputs を使う場合、以下で試しているように Pydantic モデル or JSON スキーマで出力を定義して、response_format
パラメータで渡していた。
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に戻る。
で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なのね。
というかStructured Outputについてはドキュメントの内容が全体的に古い気がする(更新はされてるみたいだけど、古い内容を完全に更新したり消したりしてない感じ)
Reasoningモデルの場合はReasoningの出力を普通の出力を分けることができる。
これを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データが完成します。
ブランド: トヨタ
モデル: カローラ
タイプ: セダン