Structured OutputはLLMの出力をプログラムで扱いやすい形式(JSONとか)に落としこむ機能です。 Googleが開発するオープンなLLMモデルのGemma 3が対応したとのことで試してみまます。
以前にgemma3:1bのStructured Outputを安定させる工夫について書いたので、その続きとして、複雑な例に挑戦してみようかと。
所感としては、文字列や辞書のリスト、nullableな値など、複雑なデータ構造でもソツなくこなす印象です。ただ、「在米経験のある日本人」から出身地を推測するような複雑なタスクだと1bよりも大きめのモデルがよさそう。
準備
- Ollamaをhttps://ollama.comの案内に従ってインストール
ollama serve
でサーバーを軌道ollama pull gemma3:1b
などを実行して使いたいモデルを入手
ソース
任意の構造を実験できるように、以下のようなコマンドを用意しました。簡易的な作りですが、パラメータはモデル名
、ユーザープロンプト
、JSONSchema
です。安定性を確認するため5回ずつループさせます。
uv --directory assets run main.py \
"モデル名(例:gemma3:1b)" \
"ユーザープロンプト" \
"JSON Schema"
ソースコードは記事内にも記載していますが、以下のURLからも参照できます。 gemma3:1bのStructured Outputを安定させる工夫の結果を受けて、temperatureは0、システムプロンプトに「入力に忠実に構造化出力して」と指示しています。
main.py
# assets/main.py
import asyncio
import json
import sys
from json_schema_to_pydantic import create_model
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_ollama import ChatOllama
from pydantic import BaseModel
async def construct_x(client: ChatOllama, user_prompt: str, model: type[BaseModel]):
_ = SystemMessage
result = await client.with_structured_output(model).ainvoke(
[
SystemMessage(content="入力に忠実に構造化出力して"),
HumanMessage(content=user_prompt),
]
)
if not isinstance(result, model):
raise TypeError
print(json.dumps(result.model_dump(), ensure_ascii=False))
async def main():
_, llm, user_prompt, schema = sys.argv
model = create_model(json.loads(schema))
client = ChatOllama(model=llm, temperature=0)
iterations = 5
if llm.endswith(":1b") or llm.endswith(":4b"):
await asyncio.gather(
*[construct_x(client, user_prompt, model) for _ in range(iterations)]
)
return
for _ in range(iterations):
await construct_x(client, user_prompt, model)
asyncio.run(main())
pyproject.toml
# assets/pyproject.toml
[project]
name = "assets"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"json-schema-to-pydantic>=0.2.6",
"langchain>=0.3.24",
"langchain-ollama>=0.3.2",
"pydantic>=2.11.3",
]
結果
Gemma3:1b
dict[str, str]
相当
まずはgemma3:1bのStructured Outputを安定させる工夫で試した例が動くことを確認。
{"name": "atusy", "birthplace": "日本"}
のようになることを期待します。
ひとまずシンプルな文章はよさそうですね。
uv --directory assets run main.py gemma3:1b "名前はatusy、出身地は日本" '
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the person."
},
"birthplace": {
"type": "string",
"description": "The birthplace of the person."
}
},
"required": [
"name",
"birthplace"
]
}'
#> {"name": "atusy", "birthplace": "日本"}
#> {"name": "atusy", "birthplace": "日本"}
#> {"name": "atusy", "birthplace": "日本"}
#> {"name": "atusy", "birthplace": "日本"}
#> {"name": "atusy", "birthplace": "日本"}
ただし、情報が複雑だとgemma3:1bでは荷が重い印象。
- 在米経験に言及
こんにちは。私はatusyです。寿司が好き。在米経験あるけど日本出身だよ。
- gemma3:1b
- ちょっと余計な情報を足したくらいならで問題ない
- 1に加え、明示的に出身を述べない
こんにちは。私はatusyです。寿司が好き。在米経験のある日本人だよ。
- gemma3:1b
- 在米経験のある日本人って感覚的には日本出身な感じがするけど、birthplaceをうまく扱えてない。
{"name": "atusy", "birthplace": "在米 (在米は「在米」の略です。米の国を意味します。日本で生活していることを意味します。) - 日本"}
- gemma3:4b
- いい感じ
{"name": "atusy", "birthplace": "日本 (米国の経験あり)"}
- 2に加え、友達の名前と出身を追加
こんにちは。私はatusyです。寿司が好き。在米経験のある日本人だよ。友達のアリスはイギリス生まれの女の子。
- なお、アリスは架空の人物。
- gemma3:4b
- ちょっと括弧書きの中身が怪しい感じだけど悪くない
{"name": "atusy", "birthplace": "日本 (米在住経験あり)"}
- gemma3:12b
- なかなか終わらないので諦めました
PROMPT="こんにちは。私はatusyです。寿司が好き。在米経験あるけど日本出身だよ。"
uv --directory assets run main.py gemma3:1b "$PROMPT" '
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the person."
},
"birthplace": {
"type": "string",
"description": "The birthplace of the person."
}
},
"required": [
"name",
"birthplace"
]
}'
#> {"name": "atusy", "birthplace": "日本"}
#> {"name": "atusy", "birthplace": "日本"}
#> {"name": "atusy", "birthplace": "日本"}
#> {"name": "atusy", "birthplace": "日本"}
#> {"name": "atusy", "birthplace": "日本"}
PROMPT="こんにちは。私はatusyです。寿司が好き。在米経験のある日本人だよ。"
uv --directory assets run main.py gemma3:1b "$PROMPT" '
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the person."
},
"birthplace": {
"type": "string",
"description": "The birthplace of the person."
}
},
"required": [
"name",
"birthplace"
]
}'
#> {"name": "atusy", "birthplace": "在米 (在米は「在米」の略です。米の国を意味します。日本で生活していることを意味します。) - 日本"}
#> {"name": "atusy", "birthplace": "在米 (在米は「在米」の略です。米の国を意味します。日本で生活していることを意味します。) - 日本"}
#> {"name": "atusy", "birthplace": "在米 (在米は「在米」の略です。米の国を意味します。日本で生活していることを意味します。) - 日本"}
#> {"name": "atusy", "birthplace": "在米 (在米は「在米」の略です。米の国を意味します。日本で生活していることを意味します。) - 日本"}
#> {"name": "atusy", "birthplace": "在米 (在米は「在米」の略です。米の国を意味します。日本で生活していることを意味します。) - 日本"}
PROMPT="こんにちは。私はatusyです。寿司が好き。在米経験のある日本人だよ。"
uv --directory assets run main.py gemma3:4b "$PROMPT" '
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the person."
},
"birthplace": {
"type": "string",
"description": "The birthplace of the person."
}
},
"required": [
"name",
"birthplace"
]
}'
#> {"name": "atusy", "birthplace": "日本 (米国の経験あり)"}
#> {"name": "atusy", "birthplace": "日本 (米国の経験あり)"}
#> {"name": "atusy", "birthplace": "日本 (米国の経験あり)"}
#> {"name": "atusy", "birthplace": "日本 (米国の経験あり)"}
#> {"name": "atusy", "birthplace": "日本 (米国の経験あり)"}
PROMPT="こんにちは。私はatusyです。寿司が好き。在米経験のある日本人だよ。友達のアリスはイギリス生まれの女の子。"
uv --directory assets run main.py gemma3:4b "$PROMPT" '
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the person."
},
"birthplace": {
"type": "string",
"description": "The birthplace of the person."
}
},
"required": [
"name",
"birthplace"
]
}'
#> {"name": "atusy", "birthplace": "日本 (米在住経験あり)"}
#> {"name": "atusy", "birthplace": "日本 (米在住経験あり)"}
#> {"name": "atusy", "birthplace": "日本 (米在住経験あり)"}
#> {"name": "atusy", "birthplace": "日本 (米在住経験あり)"}
#> {"name": "atusy", "birthplace": "日本 (米在住経験あり)"}
list[str]やlist[dict]、nullableな値があるケース
かなり複雑ですがこんなの。gemma3:4bならソツなくこなしてくれます。
{
"name": "山田 太郎",
"current_occupation": "ソフトウェアエンジニア",
"hobbies": [
"キャンプと登山",
"写真撮影",
"読書(歴史小説)"
],
"education": [
{
"school": "私立〇〇高等学校",
"start_year": 2010,
"end_year": 2013
},
{
"school": "▲▲大学",
"faculty": "工学部",
"department": "情報科学科",
"start_year": 2013,
"end_year": 2017
}
]
}
PROMPT='
皆様、はじめまして!山田 太郎(やまだ たろう)と申します。現在はIT企業でソフトウェアエンジニアとして働いています。
# 趣味
私の趣味は多岐にわたりますが、特に好きなものを3つご紹介します。
キャンプと登山: 自然の中で過ごす時間が大好きで、週末にはよく山に出かけます。焚き火を囲んでのんびり過ごしたり、頂上からの絶景を眺めたりすると、日頃の疲れも吹き飛びます。
写真撮影: 特に風景写真を撮るのが好きです。旅先や近所の公園など、美しい景色を見つけると、ついついシャッターを切ってしまいます。最近は星景写真にも挑戦中です。
読書(歴史小説): 学生時代から歴史が好きで、特に戦国時代や幕末を舞台にした小説をよく読みます。登場人物たちの人間ドラマに触れるのが楽しく、時間があっという間に過ぎてしまいます。
# 学歴
学歴は以下の通りです。
2010年4月:私立〇〇高等学校 入学
2013年3月:私立〇〇高等学校 卒業
2013年4月:▲▲大学 工学部 情報科学科 入学
2017年3月:▲▲大学 工学部 情報科学科 卒業
大学ではプログラミングやデータ構造について深く学び、現在の仕事に活かしています。'
uv --directory assets run main.py gemma3:4b "$PROMPT" "$(cat assets/example_schema.json)"
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプと登山", "写真撮影", "読書(歴史小説)"], "education": [{"school": "私立〇〇高等学校", "start_year": 2010, "end_year": 2013}, {"school": "▲▲大学", "faculty": "工学部", "department": "情報科学科", "start_year": 2013, "end_year": 2017}]}
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプと登山", "写真撮影", "読書(歴史小説)"], "education": [{"school": "私立〇〇高等学校", "start_year": 2010, "end_year": 2013}, {"school": "▲▲大学", "faculty": "工学部", "department": "情報科学科", "start_year": 2013, "end_year": 2017}]}
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプと登山", "写真撮影", "読書(歴史小説)"], "education": [{"school": "私立〇〇高等学校", "start_year": 2010, "end_year": 2013}, {"school": "▲▲大学", "faculty": "工学部", "department": "情報科学科", "start_year": 2013, "end_year": 2017}]}
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプと登山", "写真撮影", "読書(歴史小説)"], "education": [{"school": "私立〇〇高等学校", "start_year": 2010, "end_year": 2013}, {"school": "▲▲大学", "faculty": "工学部", "department": "情報科学科", "start_year": 2013, "end_year": 2017}]}
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプと登山", "写真撮影", "読書(歴史小説)"], "education": [{"school": "私立〇〇高等学校", "start_year": 2010, "end_year": 2013}, {"school": "▲▲大学", "faculty": "工学部", "department": "情報科学科", "start_year": 2013, "end_year": 2017}]}
もっと口語調にして、余計な情報を足してみます。
start_year
がとれていないものの、それなりに優秀な感じしますね。もしうまくいかない場合は、もっと大きいモデルを使うか、いったん必要な情報を非構造化で出力してから、構造化しなおすといいかも。
{"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプ", "登山", "写真撮影 (風景写真)", "歴史小説の読書"], "education": [{"school": "私立〇〇高等学校", "graduation_year": 2013, "major": "不明"}, {"school": "▲▲大学", "faculty": "工学部", "department": "情報科学科", "graduation_year": 2017}]}
PROMPT='
皆さん、初めまして!この度、このような場でご挨拶できることを大変光栄に思います。さて、自己紹介ですが、私の名前は山田 太郎(やまだ たろう)と申します。よく「山田さんって、あの山田さんですか?」と聞かれるのですが、はい、その山田です(笑)。普段は都内の雑踏の中に位置する、とあるIT企業でソフトウェアエンジニアとして働いております。最近、オフィスの近くにできた新しいカフェのコーヒーがすごく美味しくて、毎朝のルーティンになりつつあります。
仕事以外で私が熱中していることについてお話ししましょう。いくつかあるんですが、まず挙げられるのは自然との触れ合いですね。具体的には、週末になると**キャンプ**用品を車に積み込んで出かけたり、ちょっと本格的な**登山**に挑戦したりしています。自然の中で過ごす時間は本当に格別です。それから、美しい瞬間を閉じ込める**写真撮影**も大切な趣味です。特に風景写真が好きで、良い景色に出会うと時間を忘れてシャッターを切ってしまいます。そういえば、先日訪れた箱根の景色は本当に素晴らしかったですよ。そして、もう一つ、活字に触れる時間も欠かせません。特に**歴史小説**を読むのが好きで、一度読み始めると止まらなくなってしまいます。
週末のキャンプでは、焚き火のパチパチという音を聞きながら、ぼーっと過ごすのが至福の時です。星空を眺めるのも好きで、最近は星景写真にも挑戦しているんですが、これがまた難しくて。でも、だからこそ撮れた時の達成感はひとしおなんです。登山では、山頂から見渡すパノラマに感動します。日頃のストレスなんて吹き飛んでしまいますね。読書は、主に戦国時代や幕末が舞台のものが好みです。登場人物たちの人生模様に思いを馳せると、まるで自分がその時代にいるような感覚になります。そういえば、最近読み始めた本に出てくる武将が、地元の歴史博物館にもゆかりのある人物で、なんだか親近感が湧きました。
さて、話は変わりますが、私の学歴について少しだけお付き合いください。私が高校を卒業したのは2013年3月のことでした。その前、2010年の4月には、私立〇〇高等学校に入学していましたね。高校生活はあっという間でした。そして、高校卒業後すぐに、つまり2013年の4月には、▲▲大学の工学部、その中でも情報科学科に進学しました。大学では、今の仕事に役立つプログラミングやデータ構造について集中的に学びました。レポート作成に追われた日々が懐かしいです。そうそう、▲▲大学を卒業したのは、2017年の3月になります。
'
uv --directory assets run main.py gemma3:1b "$PROMPT" "$(cat assets/example_schema.json)"
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプ", "登山", "写真撮影", "歴史小説", "武将の物語"], "education": [{"school": "〇〇高等学校", "graduation_date": "2013年3月"}, {"university": "▲▲大学", "major": "情報科学科", "graduation_date": "2017年3月"}]}
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプ", "登山", "写真撮影", "歴史小説", "武将の物語"], "education": [{"school": "〇〇高等学校", "graduation_date": "2013年3月"}, {"university": "▲▲大学", "major": "情報科学科", "graduation_date": "2017年3月"}]}
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプ", "登山", "写真撮影", "歴史小説", "武将の物語"], "education": [{"school": "〇〇高等学校", "graduation_date": "2013年3月"}, {"university": "▲▲大学", "major": "情報科学科", "graduation_date": "2017年3月"}]}
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプ", "登山", "写真撮影", "歴史小説", "武将の物語"], "education": [{"school": "〇〇高等学校", "graduation_date": "2013年3月"}, {"university": "▲▲大学", "major": "情報科学科", "graduation_date": "2017年3月"}]}
#> {"name": "山田 太郎", "current_occupation": "ソフトウェアエンジニア", "hobbies": ["キャンプ", "登山", "写真撮影", "歴史小説", "武将の物語"], "education": [{"school": "〇〇高等学校", "graduation_date": "2013年3月"}, {"university": "▲▲大学", "major": "情報科学科", "graduation_date": "2017年3月"}]}
ENJOY