OpenAIのStructured OutputsはStreamingで実行できます。
しかし、Streamingの途中では、LLMの生成したテキストは{"や{"nameなど、JSONとして不正な形式となります。
このような文字列をJSONとして扱えるように修正してパースし続けたい場合の対応方法をまとめます。
※この記事は、OpenAIのStructured Outputsを使う場合のみが対象です。他のプロバイダのモデルやStructured Outputs以外の構造化出力方法を使う場合は、この記事の内容とは異なる対応が必要な場合があります。
client.chat.completions.streamを使用する場合
実はOpenAIのStructured OutputsでStreamingを使う例は、OpenAIの公式ドキュメントに含まれています。
たとえば次のようなコードになります。
import json from dotenv import load_dotenv from openai import OpenAI from pydantic import BaseModel, Field load_dotenv() class Recipe(BaseModel): ingredients: list[str] = Field(description="料理の材料") steps: list[str] = Field(description="料理の作り方") client = OpenAI() with client.chat.completions.stream( model="gpt-5-nano", reasoning_effort="minimal", messages=[{"role": "user", "content": "カレーのレシピを考えて"}], response_format=Recipe, ) as stream: for event in stream: if hasattr(event, "parsed") and event.parsed is not None: print(json.dumps(event.parsed, ensure_ascii=False), flush=True)
このコードを実行すると、次のように表示されます。
{}
{}
{"ingredients": []}
{"ingredients": []}
{"ingredients": []}
{"ingredients": []}
{"ingredients": []}
{"ingredients": []}
{"ingredients": []}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本"]}
    :
OpenAIのPythonクライアントは内部でjiterというライブラリのfrom_json関数を使用して、{"ingredients":["といった不正なJSONを{"ingredients": []}のような正当なJSONに変換しています。
                choice_snapshot.message.parsed = from_json(
                    bytes(choice_snapshot.message.content, "utf-8"),
                    partial_mode=True,
                )
client.chat.completions.createを使用する場合
client.chat.completions.streamを使用する以外に、client.chat.completions.createを使うこともできます。
ただし、client.chat.completions.createには、不正なJSONを正当なJSONに変換する機能はありません。
次のコードでは、LangChainのJsonOutputParserを使用して{"ingredients":["といった不正なJSONを{"ingredients": []}のような正当なJSONにしています。
import json from dotenv import load_dotenv from langchain_core.output_parsers import JsonOutputParser from openai import OpenAI from pydantic import BaseModel, Field load_dotenv() class Recipe(BaseModel): ingredients: list[str] = Field(description="料理の材料") steps: list[str] = Field(description="料理の作り方") client = OpenAI() response = client.chat.completions.create( model="gpt-5-nano", reasoning_effort="minimal", messages=[{"role": "user", "content": "カレーのレシピを考えて"}], response_format={ "type": "json_schema", "json_schema": { "name": "Recipe", "schema": { **Recipe.model_json_schema(), "additionalProperties": False, }, "strict": True, }, }, stream=True, ) output_parser = JsonOutputParser() content = "" for chunk in response: if len(chunk.choices) == 0: continue chunk_content = chunk.choices[0].delta.content if chunk_content: content += chunk_content parsed_content = output_parser.invoke(content) print(json.dumps(parsed_content, ensure_ascii=False), flush=True)
実行すると、次のように表示されます。
{}
{}
{"ingredients": [""]}
{"ingredients": ["玉"]}
{"ingredients": ["玉ね"]}
{"ingredients": ["玉ねぎ"]}
{"ingredients": ["玉ねぎ"]}
{"ingredients": ["玉ねぎ 1"]}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個", ""]}
{"ingredients": ["玉ねぎ 1個", "に"]}
{"ingredients": ["玉ねぎ 1個", "にん"]}
{"ingredients": ["玉ねぎ 1個", "にんじ"]}
{"ingredients": ["玉ねぎ 1個", "にんじん"]}
{"ingredients": ["玉ねぎ 1個", "にんじん"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本", ""]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本", "じゃ"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本", "じゃが"]}
jiterのfrom_jsonとLangChainのJsonOutputParserで、{"ingredients": ["玉のような不正なJSONが{"ingredients": []}になるか{"ingredients": ["玉"]}になるかといった違いがあることも分かります。
ちなみに、Structured Outputsでよく使われるclient.chat.completions.parseはstreamに対応していません。
LangChainのwith_structured_outputを使用する場合
最後に、LangChainのwith_structured_outputを使用する場合です。
LangChainのwith_structured_outputは、スキーマとしてTypedDictを使用すれば、model_with_structure.streamのように実行するだけで、ここまでのような処理が可能です。
※現時点では、スキーマとしてPydanticのモデルを使う場合には対応していません。
サンプルコードは次のようになります。
import json from typing import Annotated from dotenv import load_dotenv from langchain.chat_models import init_chat_model from typing_extensions import TypedDict load_dotenv() class Recipe(TypedDict): ingredients: Annotated[list[str], ..., "料理の材料"] steps: Annotated[list[str], ..., "料理の作り方"] model = init_chat_model( model_provider="openai", model="gpt-5-nano", reasoning_effort="minimal", ) model_with_structure = model.with_structured_output(Recipe) for chunk in model_with_structure.stream("カレーのレシピを考えて"): print(json.dumps(chunk, ensure_ascii=False), flush=True)
実行すると、次のように表示されます。
{}
{"ingredients": [""]}
{"ingredients": ["玉"]}
{"ingredients": ["玉ね"]}
{"ingredients": ["玉ねぎ"]}
{"ingredients": ["玉ねぎ 1"]}
{"ingredients": ["玉ねぎ 1個"]}
{"ingredients": ["玉ねぎ 1個", ""]}
{"ingredients": ["玉ねぎ 1個", "に"]}
{"ingredients": ["玉ねぎ 1個", "にん"]}
{"ingredients": ["玉ねぎ 1個", "にんじ"]}
{"ingredients": ["玉ねぎ 1個", "にんじん"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本", ""]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本", "じゃ"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本", "じゃが"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本", "じゃがい"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本", "じゃがいも"]}
{"ingredients": ["玉ねぎ 1個", "にんじん 1本", "じゃがいも 2"]}
おわりに
以上、OpenAIのStructured OutputsをStreamingで実行してJSONをパースし続けたいときの対応方法をまとめました。
しばしば求められる処理なので、この記事が参考になる方がいれば幸いです。
この記事中のソースコードは以下のGitHubリポジトリでも公開しています。
 
  