OpenAIのStructured OutputsをStreamingで実行してJSONをパースし続けたいときの対応方法 – Generative Agents Tech Blog


OpenAIのStructured OutputsはStreamingで実行できます。
しかし、Streamingの途中では、LLMの生成したテキストは{"{"nameなど、JSONとして不正な形式となります。
このような文字列をJSONとして扱えるように修正してパースし続けたい場合の対応方法をまとめます。

※この記事は、OpenAIのStructured Outputsを使う場合のみが対象です。他のプロバイダのモデルやStructured Outputs以外の構造化出力方法を使う場合は、この記事の内容とは異なる対応が必要な場合があります。

client.chat.completions.streamを使用する場合

実はOpenAIのStructured OutputsでStreamingを使う例は、OpenAIの公式ドキュメントに含まれています。

platform.openai.com

たとえば次のようなコードになります。

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,
                )

コード引用元:https://github.com/openai/openai-python/blob/dff16b5e9964bf85157eb41181255ed5dde8dda4/src/openai/lib/streaming/chat/_completions.py#L444

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リポジトリでも公開しています。

github.com




元の記事を確認する

関連記事