仕様と突き合わせてStructured Outputs実装を仕上げたコーディングエージェント伴走記
最近、OpenAIのStructured Outputs機能を使って複雑なデータ構造を確実にLLMに生成させる処理を、コーディングエージェントに実装させようとしました。
ただ実際にコーディングエージェントに実装を任せてみると、仕様との齟齬が次々と浮かび上がり、軌道修正が必要でした。
この記事では、Structured Outputsの仕様と突き合わせながらコーディングエージェントの出力精度を高めたプロセスと、その過程で得た学びを共有します。
コーディングエージェントに投げた要件
Structured Outputsで扱う構造を、先に私が用意しました。本記事の説明用の実装題材として、ChatGPTに「架空の京都の都市交通ネットワーク」をテーマにしたYAMLを書いてもらい、それをreference/kyoto-transit.data.yamlとして保存しました。YAMLは以下のようにそれなりに複雑な構造を持っています。
apiVersion: city.transit/v1
kind: TransitNetwork
metadata:
id: "kyoto-metro-network"
name: "Kyoto Metropolitan Transit System"
operator: "Kyoto Transit Authority"
updated_at: "2025-10-15T09:00:00Z"
spec:
city:
name: "Kyoto"
population: 1460000
area_km2: 827.8
zones:
- id: "zone.north"
name: "北区エリア"
lines:
- id: "karasuma-line"
name: "烏丸線"
operation_hours: { start: "05:30", end: "23:45" }
retry_policy:
max_attempts: 3
backoff:
type: "exponential"
initial_ms: 1000
max_ms: 10000
jitter: true
stations:
- id: "kokusaikaikan"
name: "国際会館"
zone: "zone.north"
position: { lat: 35.068, lon: 135.785 }
# ... 省略
schedules:
weekday:
frequency_min:
karasuma-line: 7
tozai-line: 8
keihan-line: 6
peak_adjustments:
- { time: "07:30-09:00", multiplier: 0.8 }
- { time: "17:30-19:30", multiplier: 0.9 }
weekend:
frequency_min:
karasuma-line: 10
tozai-line: 12
keihan-line: 8
# ... 省略
(省略した箇所も含めたYAML全体をアコーディオンで添付しておきます)
reference/kyoto-transit.data.yaml 全体
apiVersion: city.transit/v1
kind: TransitNetwork
metadata:
id: "kyoto-metro-network"
name: "Kyoto Metropolitan Transit System"
operator: "Kyoto Transit Authority"
updated_at: "2025-10-15T09:00:00Z"
spec:
city:
name: "Kyoto"
population: 1460000
area_km2: 827.8
zones:
- id: "zone.north"
name: "北区エリア"
- id: "zone.center"
name: "中京区エリア"
- id: "zone.south"
name: "伏見区エリア"
lines:
- id: "karasuma-line"
name: "烏丸線"
type: "subway"
color: "#00693E"
operation_hours: { start: "05:30", end: "23:45" }
retry_policy:
max_attempts: 3
backoff:
type: "exponential"
initial_ms: 1000
max_ms: 10000
jitter: true
on_exceptions:
- "DelayDetected"
- "ServiceSuspended"
reroute_strategy: "adjacent_line"
stations:
- id: "kokusaikaikan"
name: "国際会館"
zone: "zone.north"
position: { lat: 35.068, lon: 135.785 }
- id: "kitayama"
name: "北山"
zone: "zone.north"
connections: ["tozai-line"]
- id: "karasuma-oike"
name: "烏丸御池"
zone: "zone.center"
connections: ["tozai-line"]
- id: "kyoto"
name: "京都"
zone: "zone.south"
facilities:
- { type: "bus-terminal", name: "Kyoto City Bus Central" }
- { type: "shinkansen", name: "Tokaido Line" }
- id: "tozai-line"
name: "東西線"
type: "subway"
color: "#8FC31F"
operation_hours: { start: "05:45", end: "00:10" }
retry_policy:
max_attempts: 2
backoff:
type: "linear"
interval_ms: 2000
on_exceptions:
- "SignalFailure"
- "WeatherDisruption"
reroute_strategy: "bus_replacement"
stations:
- id: "nijo"
name: "二条"
zone: "zone.center"
- id: "karasuma-oike"
name: "烏丸御池"
zone: "zone.center"
connections: ["karasuma-line"]
- id: "sanjokeihan"
name: "三条京阪"
zone: "zone.center"
connections: ["keihan-line"]
- id: "yamashina"
name: "山科"
zone: "zone.south"
connections: ["keihan-line"]
- id: "keihan-line"
name: "京阪本線"
type: "railway"
color: "#1E5AA8"
operator: "Keihan Electric Railway"
operation_hours: { start: "05:00", end: "23:55" }
retry_policy:
max_attempts: 1
on_exceptions: ["HeavyRainAlert"]
reroute_strategy: "bus_replacement"
stations:
- id: "demachiyanagi"
name: "出町柳"
zone: "zone.north"
- id: "gion-shijo"
name: "祇園四条"
zone: "zone.center"
connections: ["tozai-line"]
- id: "fushimi-inari"
name: "伏見稲荷"
zone: "zone.south"
fleet:
- id: "subway-1000-series"
type: "train"
line_ref: "karasuma-line"
capacity: 850
manufacturer: "Kinki Sharyo"
energy_source: "electric"
- id: "bus-eco-hybrid"
type: "bus"
line_ref: "tozai-line"
capacity: 60
energy_source: "hybrid"
schedules:
weekday:
frequency_min:
karasuma-line: 7
tozai-line: 8
keihan-line: 6
peak_adjustments:
- { time: "07:30-09:00", multiplier: 0.8 }
- { time: "17:30-19:30", multiplier: 0.9 }
weekend:
frequency_min:
karasuma-line: 10
tozai-line: 12
keihan-line: 8
incident_policies:
- id: "weather-alert"
trigger: "HeavyRain"
affected_lines: ["keihan-line"]
actions:
- type: "suspend"
duration_min: 60
- type: "reroute"
via: "tozai-line"
- id: "signal-failure"
trigger: "SignalFailure"
affected_lines: ["tozai-line"]
actions:
- type: "notify"
channels: ["system-log", "public-alert"]
- type: "reroute"
via: "bus_replacement"
metrics:
on_time_rate: { target: 0.95 }
average_delay_min: { target: "<= 3" }
passenger_satisfaction_score: { target: ">= 4.2" }
コーディングエージェントには次のような指示を出しました。
reference/kyoto-transit.data.yaml は、架空の京都の都市交通のネットワークシステムを表現したサンプルファイルです。
別の架空都市の都市交通ネットワークシステムのデータを生成させる処理をLLMに行わせたいです。
このyamlファイルから、必要な構造を読み取り、データ構造をPydanticのクラスとして実装してください。
LLMにはそのクラスを使ってstructured outputsで「別の架空都市 {都市名} の都市交通ネットワークシステムのデータを生成してください」という指示でデータ生成させる想定です。
LLMのコールについては example_simple.py で実装されているようにtext_formatでPydanticのクラスを指定してOpenAIのResponsesAPIをコールします(client.responses.parse)。
以上を、transit-network-generator.py に実装してください。
ここで、example_simple.pyはResponses APIをコールする簡単なサンプルコードを記述したものです。
example_simple.py
from dotenv import load_dotenv
from openai import OpenAI
from models import UserInfo
load_dotenv()
def extract_user_info():
"""テキストからユーザー情報を構造化して抽出"""
client = OpenAI()
# ユーザー情報を含むテキスト
text = """
こんにちは、私の名前は山田太郎です。
32歳でソフトウェアエンジニアをしています。
メールアドレスはtaro.yamada@example.comです。
"""
# Responses APIを使用してAPIを呼び出し
response = client.responses.parse(
model="gpt-4o-2024-08-06", # Responses APIをサポートするモデル
input=[
{
"role": "system",
"content": "あなたは与えられたテキストからユーザー情報を抽出するアシスタントです。",
},
{
"role": "user",
"content": f"以下のテキストからユーザー情報を抽出してください:\n\n{text}",
},
],
text_format=UserInfo,
)
return response.output_parsed
if __name__ == "__main__":
print(extract_user_info())
ちなみに上記でimportされているUserInfoは下記です。
class UserInfo(BaseModel):
name: str = Field(..., description="ユーザーの名前")
age: int = Field(..., description="ユーザーの年齢")
email: str = Field(..., description="メールアドレス")
occupation: Optional[str] = Field(None, description="職業")
要件は「複雑な構造をPydanticでモデリングし、そのままResponses APIでStructured Outputsとして利用する」ことです。
初期実装
コーディングエージェントが指示を受けて最初の実装を仕上げてくれました。
(話の流れのうえで本質的な情報ではありませんがコーディングエージェントとしてClaude CodeでSonnet 4.5を使用しました。)
実装されたコードを抜粋したものが下記です。
データ構造を記載したmodels.pyと、それを使用してOpenAI APIのコールをしているtransit-network-generator.pyの2ファイルが実装されました。
データ構造が実装されているmodels.pyより抜粋したものが下記です。
# ... 省略(他のクラスが実装されている)
class DaySchedule(BaseModel):
frequency_min: dict[str, int] = Field(..., description="路線ごとの運行間隔(分)")
peak_adjustments: Optional[List[PeakAdjustment]] = Field(
None, description="ピーク時間調整"
)
class Schedules(BaseModel):
weekday: DaySchedule = Field(..., description="平日スケジュール")
weekend: DaySchedule = Field(..., description="週末スケジュール")
# ... 省略(他のクラスが実装されている)
class TransitNetworkSpec(BaseModel):
city: City = Field(..., description="都市情報")
zones: List[Zone] = Field(..., description="エリアゾーン")
lines: List[Line] = Field(..., description="路線リスト")
fleet: List[Fleet] = Field(..., description="車両リスト")
schedules: Schedules = Field(..., description="運行スケジュール")
incident_policies: List[IncidentPolicy] = Field(..., description="インシデントポリシー")
metrics: Metrics = Field(..., description="メトリクス")
class TransitNetwork(BaseModel):
apiVersion: str = Field(..., description="APIバージョン")
kind: str = Field(..., description="リソースタイプ")
metadata: Metadata = Field(..., description="メタデータ")
spec: TransitNetworkSpec = Field(..., description="ネットワーク仕様")
このように、reference/kyoto-transit.data.yamlに記載された複雑な構造をPydanticでの実装に落とし込んでくれています。
ちなみに省略した部分も含めた全体の実装を下記にアコーディオンで貼っておきます。
models.py
from pydantic import BaseModel, Field
from typing import List, Optional
class Position(BaseModel):
"""地理的位置"""
lat: float = Field(..., description="緯度")
lon: float = Field(..., description="経度")
class Facility(BaseModel):
"""駅の施設"""
type: str = Field(..., description="施設タイプ")
name: str = Field(..., description="施設名")
class Station(BaseModel):
"""駅の情報"""
id: str = Field(..., description="駅ID")
name: str = Field(..., description="駅名")
zone: str = Field(..., description="所属ゾーン")
position: Optional[Position] = Field(None, description="地理的位置")
connections: List[str] = Field(default_factory=list, description="他路線への接続")
facilities: List[Facility] = Field(default_factory=list, description="駅施設")
class OperationHours(BaseModel):
"""運行時間"""
start: str = Field(..., description="始発時刻(HH:MM形式)")
end: str = Field(..., description="終電時刻(HH:MM形式)")
class Backoff(BaseModel):
"""リトライのバックオフ設定"""
type: str = Field(..., description="バックオフタイプ(exponential/linear)")
initial_ms: Optional[int] = Field(None, description="初期待機時間(ミリ秒)")
max_ms: Optional[int] = Field(None, description="最大待機時間(ミリ秒)")
interval_ms: Optional[int] = Field(None, description="間隔(ミリ秒)")
jitter: Optional[bool] = Field(None, description="ジッターを使用するか")
class RetryPolicy(BaseModel):
"""リトライポリシー"""
max_attempts: int = Field(..., description="最大試行回数")
backoff: Optional[Backoff] = Field(None, description="バックオフ設定")
on_exceptions: List[str] = Field(default_factory=list, description="対象例外")
reroute_strategy: Optional[str] = Field(None, description="代替ルート戦略")
class Line(BaseModel):
"""路線情報"""
id: str = Field(..., description="路線ID")
name: str = Field(..., description="路線名")
type: str = Field(..., description="路線タイプ(subway/railway/bus)")
color: str = Field(..., description="路線カラー(16進数)")
operator: Optional[str] = Field(None, description="運行事業者")
operation_hours: OperationHours = Field(..., description="運行時間")
retry_policy: Optional[RetryPolicy] = Field(None, description="リトライポリシー")
stations: List[Station] = Field(..., description="駅リスト")
class Zone(BaseModel):
"""エリアゾーン"""
id: str = Field(..., description="ゾーンID")
name: str = Field(..., description="ゾーン名")
class City(BaseModel):
"""都市情報"""
name: str = Field(..., description="都市名")
population: int = Field(..., description="人口")
area_km2: float = Field(..., description="面積(km²)")
class Fleet(BaseModel):
"""車両情報"""
id: str = Field(..., description="車両ID")
type: str = Field(..., description="車両タイプ(train/bus)")
line_ref: str = Field(..., description="所属路線ID")
capacity: int = Field(..., description="乗車定員")
manufacturer: Optional[str] = Field(None, description="製造メーカー")
energy_source: str = Field(..., description="エネルギー源(electric/hybrid/diesel)")
class PeakAdjustment(BaseModel):
"""ピーク時間調整"""
time: str = Field(..., description="時間帯")
multiplier: float = Field(..., description="頻度調整倍率")
class FrequencyMin(BaseModel):
"""運行頻度(分)"""
class Config:
extra = "allow" # 動的なキーを許可
class DaySchedule(BaseModel):
"""1日のスケジュール"""
frequency_min: dict[str, int] = Field(..., description="路線ごとの運行間隔(分)")
peak_adjustments: Optional[List[PeakAdjustment]] = Field(
None, description="ピーク時間調整"
)
class Schedules(BaseModel):
"""運行スケジュール"""
weekday: DaySchedule = Field(..., description="平日スケジュール")
weekend: DaySchedule = Field(..., description="週末スケジュール")
class IncidentAction(BaseModel):
"""インシデント対応アクション"""
type: str = Field(..., description="アクションタイプ(suspend/reroute/notify)")
duration_min: Optional[int] = Field(None, description="継続時間(分)")
via: Optional[str] = Field(None, description="代替経路")
channels: Optional[List[str]] = Field(None, description="通知チャンネル")
class IncidentPolicy(BaseModel):
"""インシデントポリシー"""
id: str = Field(..., description="ポリシーID")
trigger: str = Field(..., description="トリガー条件")
affected_lines: List[str] = Field(..., description="影響を受ける路線")
actions: List[IncidentAction] = Field(..., description="対応アクション")
class MetricTarget(BaseModel):
"""メトリクス目標"""
target: str | float = Field(..., description="目標値")
class Metrics(BaseModel):
"""メトリクス"""
on_time_rate: MetricTarget = Field(..., description="定時運行率")
average_delay_min: MetricTarget = Field(..., description="平均遅延時間(分)")
passenger_satisfaction_score: MetricTarget = Field(
..., description="乗客満足度スコア"
)
class TransitNetworkSpec(BaseModel):
"""交通ネットワーク仕様"""
city: City = Field(..., description="都市情報")
zones: List[Zone] = Field(..., description="エリアゾーン")
lines: List[Line] = Field(..., description="路線リスト")
fleet: List[Fleet] = Field(..., description="車両リスト")
schedules: Schedules = Field(..., description="運行スケジュール")
incident_policies: List[IncidentPolicy] = Field(..., description="インシデントポリシー")
metrics: Metrics = Field(..., description="メトリクス")
class Metadata(BaseModel):
"""メタデータ"""
id: str = Field(..., description="ネットワークID")
name: str = Field(..., description="ネットワーク名")
operator: str = Field(..., description="運行事業者")
updated_at: str = Field(..., description="更新日時(ISO 8601形式)")
class TransitNetwork(BaseModel):
"""都市交通ネットワークシステム"""
apiVersion: str = Field(..., description="APIバージョン")
kind: str = Field(..., description="リソースタイプ")
metadata: Metadata = Field(..., description="メタデータ")
spec: TransitNetworkSpec = Field(..., description="ネットワーク仕様")
transit-network-generator.pyでは、上記で実装したTransitNetworkクラスをtext_formatに指定してStructured Outputsでデータ生成する処理が実装されました。
# ... 省略
def generate_transit_network(city_name: str):
client = OpenAI()
# LLMへのプロンプト
prompt = f"""
架空の都市「{city_name}」の都市交通ネットワークシステムのデータを生成してください。
# ... 省略(細かい指示の記載)
"""
response = client.responses.parse(
model="gpt-4o-2024-08-06",
input=[
{
"role": "system",
"content": "あなたは架空の都市の交通ネットワークシステムデータを生成する専門家です。リアルで詳細なデータを生成してください。",
},
{
"role": "user",
"content": prompt,
},
],
text_format=TransitNetwork,
)
return response.output_parsed
# ... 省略
初期実装のエラーと守れていなかったOpenAIの仕様
初期実装のコードを実行すると、Responses APIから次のエラーが返ってきました。
openai.BadRequestError: Error code: 400 - {'error': {'message': "Invalid schema for response_format 'TransitNetwork': In context=(), 'required' is required to be supplied and to be an array including every key in properties. Extra required key 'frequency_min' supplied.", 'type': 'invalid_request_error', 'param': 'text.format.schema', 'code': 'invalid_json_schema'}}
調べていくと、OpenAIのStructured OutputsはJSON Schemaのサブセットをサポートしており、次のような条件を満たす必要があるとのことでした(OpenAIの公式ドキュメントを参照)。
- すべてのオブジェクトに
additionalProperties: falseを設定する - 各プロパティは必ず
requiredであること(オプショナルはtype: ["string", "null"]のように表現) -
anyOfも利用できるが、ルートオブジェクトをanyOfにすることはできない
等々...
初期実装では、dict[str, int]と実装されたDaySchedule.frequency_minの箇所がJSON schemaでadditionalProperties: {type: "integer"}として展開され、制約違反になっていました。
model_json_schema()で差分を可視化する
PydanticのBaseModelを継承したクラスでmodel_json_schema()を呼び出すと、JSON schemaを出力することができます。Structured Outputsの構造として指定するクラス(今回はTransitNetwork)のmodel_json_schema()を確認することで、どのオブジェクトにadditionalProperties: falseが付いていないか、anyOfがどのように展開されているか等を具体的にチェックできます。
人が読むにあたっては単純なmodel_json_schema()では可読性が悪いので下記のように整形して出力するとよいでしょう。
python -c "from models import TransitNetwork; import json; print(json.dumps(TransitNetwork.model_json_schema(), indent=2, ensure_ascii=False))"
長いのでアコーディオンにしておきますが、一応出力結果を貼っておきます。
TransitNetworkのmodels_json_schema()でJSON schema展開したもの
{
"$defs": {
"Backoff": {
"description": "リトライのバックオフ設定",
"properties": {
"type": {
"description": "バックオフタイプ(exponential/linear)",
"title": "Type",
"type": "string"
},
"initial_ms": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "初期待機時間(ミリ秒)",
"title": "Initial Ms"
},
"max_ms": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "最大待機時間(ミリ秒)",
"title": "Max Ms"
},
"interval_ms": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "間隔(ミリ秒)",
"title": "Interval Ms"
},
"jitter": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"description": "ジッターを使用するか",
"title": "Jitter"
}
},
"required": [
"type"
],
"title": "Backoff",
"type": "object"
},
"City": {
"description": "都市情報",
"properties": {
"name": {
"description": "都市名",
"title": "Name",
"type": "string"
},
"population": {
"description": "人口",
"title": "Population",
"type": "integer"
},
"area_km2": {
"description": "面積(km²)",
"title": "Area Km2",
"type": "number"
}
},
"required": [
"name",
"population",
"area_km2"
],
"title": "City",
"type": "object"
},
"DaySchedule": {
"description": "1日のスケジュール",
"properties": {
"frequency_min": {
"additionalProperties": {
"type": "integer"
},
"description": "路線ごとの運行間隔(分)",
"title": "Frequency Min",
"type": "object"
},
"peak_adjustments": {
"anyOf": [
{
"items": {
"$ref": "#/$defs/PeakAdjustment"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "ピーク時間調整",
"title": "Peak Adjustments"
}
},
"required": [
"frequency_min"
],
"title": "DaySchedule",
"type": "object"
},
"Facility": {
"description": "駅の施設",
"properties": {
"type": {
"description": "施設タイプ",
"title": "Type",
"type": "string"
},
"name": {
"description": "施設名",
"title": "Name",
"type": "string"
}
},
"required": [
"type",
"name"
],
"title": "Facility",
"type": "object"
},
"Fleet": {
"description": "車両情報",
"properties": {
"id": {
"description": "車両ID",
"title": "Id",
"type": "string"
},
"type": {
"description": "車両タイプ(train/bus)",
"title": "Type",
"type": "string"
},
"line_ref": {
"description": "所属路線ID",
"title": "Line Ref",
"type": "string"
},
"capacity": {
"description": "乗車定員",
"title": "Capacity",
"type": "integer"
},
"manufacturer": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "製造メーカー",
"title": "Manufacturer"
},
"energy_source": {
"description": "エネルギー源(electric/hybrid/diesel)",
"title": "Energy Source",
"type": "string"
}
},
"required": [
"id",
"type",
"line_ref",
"capacity",
"energy_source"
],
"title": "Fleet",
"type": "object"
},
"IncidentAction": {
"description": "インシデント対応アクション",
"properties": {
"type": {
"description": "アクションタイプ(suspend/reroute/notify)",
"title": "Type",
"type": "string"
},
"duration_min": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "継続時間(分)",
"title": "Duration Min"
},
"via": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "代替経路",
"title": "Via"
},
"channels": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "通知チャンネル",
"title": "Channels"
}
},
"required": [
"type"
],
"title": "IncidentAction",
"type": "object"
},
"IncidentPolicy": {
"description": "インシデントポリシー",
"properties": {
"id": {
"description": "ポリシーID",
"title": "Id",
"type": "string"
},
"trigger": {
"description": "トリガー条件",
"title": "Trigger",
"type": "string"
},
"affected_lines": {
"description": "影響を受ける路線",
"items": {
"type": "string"
},
"title": "Affected Lines",
"type": "array"
},
"actions": {
"description": "対応アクション",
"items": {
"$ref": "#/$defs/IncidentAction"
},
"title": "Actions",
"type": "array"
}
},
"required": [
"id",
"trigger",
"affected_lines",
"actions"
],
"title": "IncidentPolicy",
"type": "object"
},
"Line": {
"description": "路線情報",
"properties": {
"id": {
"description": "路線ID",
"title": "Id",
"type": "string"
},
"name": {
"description": "路線名",
"title": "Name",
"type": "string"
},
"type": {
"description": "路線タイプ(subway/railway/bus)",
"title": "Type",
"type": "string"
},
"color": {
"description": "路線カラー(16進数)",
"title": "Color",
"type": "string"
},
"operator": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "運行事業者",
"title": "Operator"
},
"operation_hours": {
"$ref": "#/$defs/OperationHours"
},
"retry_policy": {
"anyOf": [
{
"$ref": "#/$defs/RetryPolicy"
},
{
"type": "null"
}
],
"default": null,
"description": "リトライポリシー"
},
"stations": {
"description": "駅リスト",
"items": {
"$ref": "#/$defs/Station"
},
"title": "Stations",
"type": "array"
}
},
"required": [
"id",
"name",
"type",
"color",
"operation_hours",
"stations"
],
"title": "Line",
"type": "object"
},
"Metadata": {
"description": "メタデータ",
"properties": {
"id": {
"description": "ネットワークID",
"title": "Id",
"type": "string"
},
"name": {
"description": "ネットワーク名",
"title": "Name",
"type": "string"
},
"operator": {
"description": "運行事業者",
"title": "Operator",
"type": "string"
},
"updated_at": {
"description": "更新日時(ISO 8601形式)",
"title": "Updated At",
"type": "string"
}
},
"required": [
"id",
"name",
"operator",
"updated_at"
],
"title": "Metadata",
"type": "object"
},
"MetricTarget": {
"description": "メトリクス目標",
"properties": {
"target": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
}
],
"description": "目標値",
"title": "Target"
}
},
"required": [
"target"
],
"title": "MetricTarget",
"type": "object"
},
"Metrics": {
"description": "メトリクス",
"properties": {
"on_time_rate": {
"$ref": "#/$defs/MetricTarget",
"description": "定時運行率"
},
"average_delay_min": {
"$ref": "#/$defs/MetricTarget",
"description": "平均遅延時間(分)"
},
"passenger_satisfaction_score": {
"$ref": "#/$defs/MetricTarget",
"description": "乗客満足度スコア"
}
},
"required": [
"on_time_rate",
"average_delay_min",
"passenger_satisfaction_score"
],
"title": "Metrics",
"type": "object"
},
"OperationHours": {
"description": "運行時間",
"properties": {
"start": {
"description": "始発時刻(HH:MM形式)",
"title": "Start",
"type": "string"
},
"end": {
"description": "終電時刻(HH:MM形式)",
"title": "End",
"type": "string"
}
},
"required": [
"start",
"end"
],
"title": "OperationHours",
"type": "object"
},
"PeakAdjustment": {
"description": "ピーク時間調整",
"properties": {
"time": {
"description": "時間帯",
"title": "Time",
"type": "string"
},
"multiplier": {
"description": "頻度調整倍率",
"title": "Multiplier",
"type": "number"
}
},
"required": [
"time",
"multiplier"
],
"title": "PeakAdjustment",
"type": "object"
},
"Position": {
"description": "地理的位置",
"properties": {
"lat": {
"description": "緯度",
"title": "Lat",
"type": "number"
},
"lon": {
"description": "経度",
"title": "Lon",
"type": "number"
}
},
"required": [
"lat",
"lon"
],
"title": "Position",
"type": "object"
},
"RetryPolicy": {
"description": "リトライポリシー",
"properties": {
"max_attempts": {
"description": "最大試行回数",
"title": "Max Attempts",
"type": "integer"
},
"backoff": {
"anyOf": [
{
"$ref": "#/$defs/Backoff"
},
{
"type": "null"
}
],
"default": null,
"description": "バックオフ設定"
},
"on_exceptions": {
"description": "対象例外",
"items": {
"type": "string"
},
"title": "On Exceptions",
"type": "array"
},
"reroute_strategy": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "代替ルート戦略",
"title": "Reroute Strategy"
}
},
"required": [
"max_attempts"
],
"title": "RetryPolicy",
"type": "object"
},
"Schedules": {
"description": "運行スケジュール",
"properties": {
"weekday": {
"$ref": "#/$defs/DaySchedule",
"description": "平日スケジュール"
},
"weekend": {
"$ref": "#/$defs/DaySchedule",
"description": "週末スケジュール"
}
},
"required": [
"weekday",
"weekend"
],
"title": "Schedules",
"type": "object"
},
"Station": {
"description": "駅の情報",
"properties": {
"id": {
"description": "駅ID",
"title": "Id",
"type": "string"
},
"name": {
"description": "駅名",
"title": "Name",
"type": "string"
},
"zone": {
"description": "所属ゾーン",
"title": "Zone",
"type": "string"
},
"position": {
"anyOf": [
{
"$ref": "#/$defs/Position"
},
{
"type": "null"
}
],
"default": null,
"description": "地理的位置"
},
"connections": {
"description": "他路線への接続",
"items": {
"type": "string"
},
"title": "Connections",
"type": "array"
},
"facilities": {
"description": "駅施設",
"items": {
"$ref": "#/$defs/Facility"
},
"title": "Facilities",
"type": "array"
}
},
"required": [
"id",
"name",
"zone"
],
"title": "Station",
"type": "object"
},
"TransitNetworkSpec": {
"description": "交通ネットワーク仕様",
"properties": {
"city": {
"$ref": "#/$defs/City"
},
"zones": {
"description": "エリアゾーン",
"items": {
"$ref": "#/$defs/Zone"
},
"title": "Zones",
"type": "array"
},
"lines": {
"description": "路線リスト",
"items": {
"$ref": "#/$defs/Line"
},
"title": "Lines",
"type": "array"
},
"fleet": {
"description": "車両リスト",
"items": {
"$ref": "#/$defs/Fleet"
},
"title": "Fleet",
"type": "array"
},
"schedules": {
"$ref": "#/$defs/Schedules"
},
"incident_policies": {
"description": "インシデントポリシー",
"items": {
"$ref": "#/$defs/IncidentPolicy"
},
"title": "Incident Policies",
"type": "array"
},
"metrics": {
"$ref": "#/$defs/Metrics"
}
},
"required": [
"city",
"zones",
"lines",
"fleet",
"schedules",
"incident_policies",
"metrics"
],
"title": "TransitNetworkSpec",
"type": "object"
},
"Zone": {
"description": "エリアゾーン",
"properties": {
"id": {
"description": "ゾーンID",
"title": "Id",
"type": "string"
},
"name": {
"description": "ゾーン名",
"title": "Name",
"type": "string"
}
},
"required": [
"id",
"name"
],
"title": "Zone",
"type": "object"
}
},
"description": "都市交通ネットワークシステム",
"properties": {
"apiVersion": {
"description": "APIバージョン",
"title": "Apiversion",
"type": "string"
},
"kind": {
"description": "リソースタイプ",
"title": "Kind",
"type": "string"
},
"metadata": {
"$ref": "#/$defs/Metadata"
},
"spec": {
"$ref": "#/$defs/TransitNetworkSpec",
"description": "ネットワーク仕様"
}
},
"required": [
"apiVersion",
"kind",
"metadata",
"spec"
],
"title": "TransitNetwork",
"type": "object"
}
この出力を公式仕様と突き合わせることで、「どこを直せばResponses APIに通るか」が明確になります。
仕様と差分の確認方法をセットで渡してリトライ
実装されたPydanticのクラスのOpenAI仕様との差分を確認する方法が整理できたところで、エージェントに次のような追加指示を送りました。
現在のtransit-network-generator.pyおよびmodels.pyの実装では、Structured outputsに対応していないJSON schemaでLLMコールしているので動作しません。
reference/structured_outputs_document.md の「#### Supported types」に利用できるJSON schemaについて書かれています。
また、 TransitNetwork.model_json_schema() を実行することで現在の実装のJSON schemaが確認できるので、「#### Supported types」の情報と照らし合わせながら、動作するPydanticモデルに修正してください。
ここで、reference/structured_outputs_document.mdと参照しているファイルは、OpenAIのStructured Outputsの仕様ドキュメントをコピーしてきたものです。プロジェクト内に一時的なファイルとして格納しました。
この指示を渡したところ、エージェントが自律的にTransitNetwork.model_json_schema()を実行しながら仕様との差分を確認し、修正方針を組み立てて改修してくれるようになりました。
コーディングエージェントが修正を行った際のログから抜粋します。
* すべてのモデルで`additionalProperties: false`にするために、`StrictBaseModel`(`extra="forbid"`)を導入
* `dict[str, int]`を使っていた`DaySchedule.frequency_min`を、路線IDと間隔を持つ`FrequencyEntry`の配列に変更
* `MetricTarget.target`の`str | float`など、Responses APIが許容しづらいUnionを`str`にリライト
* `default_factory=list`などのデフォルト値をやめ、必須項目として定義
修正後のmodels.pyを抜粋します。
class StrictBaseModel(BaseModel):
model_config = ConfigDict(extra="forbid")
# ... 省略
class PeakAdjustment(StrictBaseModel):
time: str = Field(..., description="時間帯")
multiplier: float = Field(..., description="頻度調整倍率")
class FrequencyEntry(StrictBaseModel):
line_id: str = Field(..., description="路線ID")
interval_minutes: int = Field(..., description="運行間隔(分)")
class DaySchedule(StrictBaseModel):
frequency_entries: List[FrequencyEntry] = Field(..., description="路線ごとの運行間隔")
peak_adjustments: List[PeakAdjustment] = Field(..., description="ピーク時間調整")
class Schedules(StrictBaseModel):
weekday: DaySchedule = Field(..., description="平日スケジュール")
weekend: DaySchedule = Field(..., description="週末スケジュール")
# ... 省略
class TransitNetwork(StrictBaseModel):
apiVersion: str = Field(..., description="APIバージョン")
kind: str = Field(..., description="リソースタイプ")
metadata: Metadata = Field(..., description="メタデータ")
spec: TransitNetworkSpec = Field(..., description="ネットワーク仕様")
結果として、Structured Outputs対応のJSON Schemaが生成され、エラーなく動作するプログラムになりました。
コーディングエージェントの初期実装の不備を修正した流れをおさらいすると、下記のようになります。
-
仕様を手元に置く — OpenAI公式ドキュメント(
Supported schemas)で使える構文を確認した。 -
仕様と現在の実装との差分の確認方法を確立する — 生成されたデータ構造の実装のJSON schemaを
model_json_schema()で展開して、制約違反を直接見つけられることを確認した。 - 仕様と差分確認方法をまとめてエージェントに渡す — 仕様と実際のスキーマ出力の確認方法を共有し、エージェントが自律的に修正できるようにした。
まとめ
Structured Outputsは、LLMの出力を堅牢にするための強力な仕組みですが、仕様を押さえないままに実装するとすぐにエラーになります。今回のケースで得た学びは、 「人間側が構造と制約を理解し、その情報をエージェントに明示的に渡す」 ことの重要さでした。
この考え方はStructured Outputsに限らず、APIクライアントの生成や外部サービス統合など、仕様に沿った実装が求められる場面にも応用できます。エージェントに丸投げするのではなく、人間が仕様理解と検証のハンドルを握り、ときにエージェントのためのガイドを整えつつ伴走する。その姿勢が、エージェントを実務で活かすうえで欠かせないと感じています。
参考リンク
「プロダクトの力で、豊かな暮らしをつくる」をミッションに、法人向けに生成AIのPoC、コンサルティング〜開発を支援する事業を展開しております。 エンジニア募集しています。カジュアル面談応募はこちらから: herp.careers/careers/companies/explaza
Discussion