# Structured OutputsとFunction Callingの違い：最終JSONとツール実行の使い分け

> 与えた情報を決まったJSONにするならStructured Outputs、最新情報を取得したり外部で操作したりするならFunction Callingです。取得結果から固定形式の画面データを作る場合は併用し、最終JSONと実際の結果を照合します。

- URL: https://blog.laozhang.ai/ja/posts/structured-outputs-vs-function-calling
- Published: 2026-07-28
- Updated: 2026-10-07
- Author: LaoZhang AI Team (https://blog.laozhang.ai/ja/about)
- Topic: ChatGPT と OpenAI
- Tags: Structured Outputs, Function Calling, Responses API, JSON Schema, OpenAI API

---
**Structured Outputsは、モデルが返すデータの形式を決めます。Function Callingは、モデルからアプリへ作業を依頼するための仕組みです。** 与えた文章から項目を抽出するだけなら前者、最新の在庫を照会したり予約を登録したりするなら後者を選びます。照会後の回答も画面用の固定JSONにしたい場合は、両方を使います。

Function Callingで関数を実行するのはアプリです。モデルが返したJSONや`function_call`を、操作が完了した証拠として扱わないことが大切です。[公式ガイド](https://developers.openai.com/api/docs/guides/function-calling)の流れも、呼び出し要求を受け取り、アプリで処理し、その結果をモデルへ返す構成になっています。

| 必要な結果 | 選ぶ仕組み | アプリが確認すること |
| --- | --- | --- |
| 渡した資料を、決まった項目のJSONにする | Structured Outputs | 形式と資料との一致、不明値の扱い |
| 外部の最新情報を取得する、操作する | Function Calling | 引数、実行権限、処理の成否と戻り値 |
| 取得した結果から、型の決まった画面データを作る | 両方を併用 | ツール結果に加えて、最終JSONとの一致 |
| 自由な文章で説明できればよい | 通常のテキスト応答 | 説明の正確さと用途に合う内容 |

このページではResponses APIを使います。2026年10月7日に確認した[公式のStructured Outputsガイド](https://developers.openai.com/api/docs/guides/structured-outputs)では、最終出力に`text.format`を指定します。Chat Completionsの`response_format`や、関数定義の入れ子と混ぜないでください。

## 同じJSON Schemaでも、制約する対象が違う

Structured Outputsの`text.format`は、最終回答を読むプログラムとの約束です。たとえば画面が`remaining`を数値として読むなら、モデルにもその形式で返させます。[関数のstrictモード](https://developers.openai.com/api/docs/guides/function-calling#strict-mode)も内部ではStructured Outputsを使いますが、制約するのは**実行を依頼する関数の引数**です。

どちらも、形式が合えば内容が正しいという意味にはなりません。「残り台数が整数である」と「台数が最新で正しい」は別の条件です。また、`reserve_equipment`という名前を含むJSONを返しても、機材は予約されません。

自前で構造化JSONを読み取り、`action`の値から関数を呼ぶ設計もできます。その場合、振り分け、引数検査、権限確認、実行、失敗処理を自分で用意します。JSON自体に実行能力が加わるわけではありません。モデルにアプリの関数を依頼させる目的なら、まずFunction Callingの呼び出し・結果対応を使うと、役割を明確にできます。

## 機材の貸出状況なら、情報の所在で選ぶ

架空の貸出サービスで「CAM-17は借りられますか」と聞かれた場合を考えます。

すでに確かめた貸出データを入力に渡しているなら、Structured Outputsで画面用のJSONを作れます。入力にない台数を推測させず、不明なら`unknown`と`null`を返す設計にします。

現在の貸出台数を調べる必要があるなら、`read_equipment`のFunction Callingを使います。呼び出し要求が返った後、アプリでその機材の照会を許可できるか確認し、データを取得します。読み取り結果をそのまま画面へ出せるなら、追加のモデル処理は必要ありません。

取得結果を別の固定形式に整える必要がある場合にだけ、最後に`text.format`を使います。この併用で形式を整えても、予約や貸出登録まで済むわけではありません。書き込みが必要なら、別途その処理と完了確認を設計します。

## strict schemaで不明値を無理に埋めない

strictで使用するschemaは、すべてのフィールドを`required`にし、各objectに`additionalProperties: false`を指定します。`required`はキーを出す指定であって、値が分かっていることを意味しません。次は、台数が不明でも成功を装わないためのschemaです。

```json
{
  "type": "object",
  "properties": {
    "equipment_id": {"type": ["string", "null"]},
    "availability": {
      "type": "string",
      "enum": ["available", "unavailable", "unknown"]
    },
    "remaining": {"type": ["integer", "null"]},
    "checked_at": {"type": ["string", "null"]}
  },
  "required": ["equipment_id", "availability", "remaining", "checked_at"],
  "additionalProperties": false
}
```

`remaining: null`は「不明」、`remaining: 0`は「確認した結果、在庫がない」です。この二つを同じ値で表すと、入力不足を貸出不可と誤表示します。機材番号自体が分からなければ、照会を強制せず利用者に確認します。

![機材の残り台数が2、0、nullの場合を、貸出可能・在庫なし・未確認に分けた概念図](https://blog.laozhang.ai/posts/ja/structured-outputs-vs-function-calling/img/nullable-availability.webp)

nullableなenumにも注意が必要です。`"type": ["string", "null"]`と`"enum": ["available", "unavailable"]`を組み合わせても、enumにない`null`は許されません。nullを値として使うならenumにもnullを含めるか、上の例のように不明状態を文字列で分けます。

[対応するJSON Schemaの範囲](https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas)は、JSON Schema全体ではありません。ルートはobjectで、ルートの`anyOf`は使えず、`allOf`や`if`・`then`・`else`などにも制約があります。通常モデルで使える`pattern`や数値の範囲指定にも、fine-tunedモデルでは追加制限があります。台数と状態の関係は、schemaに無理に詰めずアプリで確かめます。

関数の`strict`は明示しましょう。現行の[公式説明](https://developers.openai.com/api/docs/guides/function-calling#strict-mode)では、Responsesで省略すると互換性がある場合はstrictへ正規化を試み、できない場合はbest-effortへ戻ります。その際、応答のtoolには`strict: false`が示されます。Chat Completionsは既定でnon-strictです。「省略しても必ずstrictになる」とは扱えません。

## 最終JSONを読む前に、拒否と途中終了を分ける

次のPythonコードは、既存のResponses API接続へ組み込む関数です。呼び出し元が用意したPython SDKの`client`と、Structured Outputsに対応する`model`を受け取ります。認証やSDK導入の手順は含みません。コードの`record`は、照会済みのデータまたは明示的な不明データを渡す前提です。

`completed`でも`refusal`を含む場合があります。途中終了したJSONは補完して使わず、拒否、途中終了、空出力を先に分岐します。この応答の扱いは[公式のエッジケース説明](https://developers.openai.com/api/docs/guides/structured-outputs)に基づきます。

```python
import json

CARD_SCHEMA = {
    "type": "object",
    "properties": {
        "equipment_id": {"type": ["string", "null"]},
        "availability": {
            "type": "string",
            "enum": ["available", "unavailable", "unknown"],
        },
        "remaining": {"type": ["integer", "null"]},
        "checked_at": {"type": ["string", "null"]},
    },
    "required": ["equipment_id", "availability", "remaining", "checked_at"],
    "additionalProperties": False,
}

def ensure_completed(response):
    if response.status != "completed":
        details = getattr(response, "incomplete_details", None)
        reason = getattr(details, "reason", response.status)
        raise RuntimeError(f"応答を使用できません: {reason}")
    for item in response.output:
        if item.type == "message":
            for part in item.content:
                if part.type == "refusal":
                    raise RuntimeError(f"拒否: {part.refusal}")

def expected_card(record):
    if not isinstance(record, dict) or type(record.get("ok")) is not bool:
        raise ValueError("照会結果の成否が不正です")
    equipment_id = record.get("equipment_id")
    if equipment_id is not None and not isinstance(equipment_id, str):
        raise ValueError("照会結果の機材番号が不正です")
    if record["ok"]:
        if not equipment_id:
            raise ValueError("成功した照会の機材番号がありません")
        count = record["remaining"]
        if type(count) is not int or count < 0:
            raise ValueError("照会元の台数が不正です")
        stamp = record["checked_at"]
        if not isinstance(stamp, str) or not stamp.strip():
            raise ValueError("照会元の確認日時がありません")
        return {
            "equipment_id": record["equipment_id"],
            "availability": "available" if count > 0 else "unavailable",
            "remaining": count,
            "checked_at": stamp,
        }
    return {
        "equipment_id": record["equipment_id"],
        "availability": "unknown",
        "remaining": None,
        "checked_at": None,
    }

def read_card(response, record):
    ensure_completed(response)
    if any(item.type == "function_call" for item in response.output):
        raise RuntimeError("最終出力に未処理の関数呼び出しがあります")
    if not response.output_text:
        raise RuntimeError("最終JSONがありません")
    card = json.loads(response.output_text)
    if not isinstance(card, dict) or set(card) != set(CARD_SCHEMA["properties"]):
        raise ValueError("画面データの項目が違います")
    if card["equipment_id"] is not None and not isinstance(card["equipment_id"], str):
        raise ValueError("機材番号の型が違います")
    if card["availability"] not in {"available", "unavailable", "unknown"}:
        raise ValueError("貸出状態が不正です")
    if card["remaining"] is not None and type(card["remaining"]) is not int:
        raise ValueError("台数は整数またはnullで指定してください")
    if card["checked_at"] is not None and not isinstance(card["checked_at"], str):
        raise ValueError("確認日時の型が違います")
    if card != expected_card(record):
        raise ValueError("最終JSONが照会結果と一致しません")
    return card

def final_card(client, model, record):
    expected_card(record)
    response = client.responses.create(
        model=model,
        input=[{
            "role": "user",
            "content": (
                "この照会結果を画面用JSONにしてください。ok=falseなら"
                "availability=unknown、remainingとchecked_atはnullです。"
                "機材番号は維持し、台数や日時を推測しないでください。"
                + json.dumps(record, ensure_ascii=False)
            ),
        }],
        text={"format": {
            "type": "json_schema", "name": "equipment_card",
            "strict": True, "schema": CARD_SCHEMA,
        }},
    )
    return read_card(response, record)
```

たとえば入力が`{"ok": true, "equipment_id": "CAM-17", "remaining": 2, "checked_at": "2026-10-07T03:00:00Z"}`なら、受け入れる最終JSONは次です。**架空データに対する期待値**であり、モデルの実応答ではありません。

```json
{
  "equipment_id": "CAM-17",
  "availability": "available",
  "remaining": 2,
  "checked_at": "2026-10-07T03:00:00Z"
}
```

この例では表示する値がすべて照会元から決まるので、`expected_card(record)`だけでも画面データを作れます。実際の用途がこれだけならモデルへの整形依頼を省けます。複数資料の要約など、モデルによる処理が必要な項目を加えるときにも、機材番号や台数などの確定値はアプリで照合してください。

## Function Callingは、全呼び出しと結果を対応させる

`output[0]`が関数呼び出しとは限りません。応答にはテキスト、reasoning item、ゼロ件・一件・複数件の関数呼び出しが入り得ます。[公式ガイド](https://developers.openai.com/api/docs/guides/function-calling#handling-function-calls)に従い、全出力を保持してから各`function_call`を処理します。

`id`は出力itemの識別子です。戻り値を対応させるには`call_id`を使います。どちらも、利用者の承認や業務の完了を証明する番号ではありません。

次のコードは先ほどのコードの下に追加できます。架空の固定データだけを読むhandlerを含み、関数定義、最初の依頼、引数検査、実行、結果の返送、後続の呼び出し、終了までを示します。`selected_id`はアプリで確定した機材番号です。`allowed_ids`もモデルに決めさせず、アプリ側で照会を許可した番号を渡します。

```python
READ_TOOL = {
    "type": "function",
    "name": "read_equipment",
    "description": "指定した機材の貸出可能台数と確認日時を取得する。",
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {"equipment_id": {"type": "string"}},
        "required": ["equipment_id"],
        "additionalProperties": False,
    },
}

FIXTURES = {
    "CAM-17": {"remaining": 2, "checked_at": "2026-10-07T03:00:00Z"},
    "MIC-08": {"remaining": 0, "checked_at": "2026-10-07T03:00:00Z"},
}

def read_equipment(equipment_id):
    row = FIXTURES.get(equipment_id)
    if row is None:
        return {"ok": False, "equipment_id": equipment_id, "error": "not_found"}
    return {"ok": True, "equipment_id": equipment_id, **row}

def execute_call(call, selected_id, allowed_ids, handler):
    if call.name != "read_equipment":
        raise ValueError("許可していない関数です")
    args = json.loads(call.arguments)
    if not isinstance(args, dict) or set(args) != {"equipment_id"}:
        raise ValueError("照会引数が違います")
    equipment_id = args["equipment_id"]
    if not isinstance(equipment_id, str) or equipment_id != selected_id:
        raise ValueError("依頼した機材と照会対象が違います")
    if equipment_id not in allowed_ids:
        raise PermissionError("この機材は照会できません")
    try:
        result = handler(equipment_id)
    except TimeoutError:
        result = {"ok": False, "equipment_id": equipment_id, "error": "timeout"}
    if not isinstance(result, dict) or type(result.get("ok")) is not bool:
        raise ValueError("照会処理の戻り値が不正です")
    if result.get("equipment_id") != equipment_id:
        raise ValueError("戻り値の機材番号が違います")
    expected_card(result)
    return result

def lookup(client, model, selected_id, allowed_ids, structured=False,
           handler=read_equipment):
    if not isinstance(selected_id, str) or not selected_id.strip():
        raise ValueError("機材番号を先に確認してください")
    if selected_id not in allowed_ids:
        raise PermissionError("照会対象が許可されていません")
    inputs = [{"role": "user", "content": f"機材{selected_id}の貸出状況を照会して"}]
    seen = {}
    last_result = None
    for turn in range(4):
        response = client.responses.create(
            model=model, input=inputs, tools=[READ_TOOL],
            tool_choice="required" if turn == 0 else "auto",
            parallel_tool_calls=False,
        )
        ensure_completed(response)
        inputs.extend(response.output)
        calls = [item for item in response.output if item.type == "function_call"]
        if not calls:
            if last_result is None:
                raise RuntimeError("必要な照会結果がありません")
            if structured:
                return final_card(client, model, last_result)
            if not response.output_text:
                raise RuntimeError("最終説明がありません")
            return {"record": last_result, "explanation": response.output_text}
        for call in calls:
            if not isinstance(call.call_id, str) or not call.call_id:
                raise ValueError("call_idがありません")
            signature = (call.name, call.arguments)
            if call.call_id in seen:
                old_signature, result = seen[call.call_id]
                if old_signature != signature:
                    raise ValueError("同じcall_idで引数が変わりました")
            else:
                result = execute_call(call, selected_id, allowed_ids, handler)
                seen[call.call_id] = (signature, result)
            last_result = result
            inputs.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(result, ensure_ascii=False),
            })
    raise RuntimeError("照会の往復回数が上限に達しました")
```

接続済みの呼び出し元から`lookup(client, model, "CAM-17", {"CAM-17"})`を使うと、照会結果と最終説明を分けて受け取れます。説明に書かれた台数を業務データとして再利用せず、`record`を使用します。固定JSONまで必要なら`structured=True`を渡し、最終カードの各値を実行結果と照合します。最終整形のリクエストにはtoolsを渡していないため、整形中に追加の照会を依頼する構成にはしていません。

`tool_choice="required"`は一件以上の関数呼び出しを要求する設定です。`auto`はゼロ件も許し、特定の関数を強制する指定もあります。`parallel_tool_calls=False`はゼロ件または一件に制限する指定なので、それだけでは照会を必須にできません。これらは[呼び出し方の制御](https://developers.openai.com/api/docs/guides/function-calling#tool-choice)であり、引数の正しさや処理の成功を保証する設定ではありません。fine-tunedモデルで同一ターンに複数の関数が呼ばれる場合、strictが無効になるという公式の例外もあります。

## 併用しても、失敗を成功に変換しない

![機材の照会依頼と戻り値を同じcall_idで対応させ、最終JSONを取得結果と照合する概念図](https://blog.laozhang.ai/posts/ja/structured-outputs-vs-function-calling/img/call-result-verification.webp)

上の例でtimeoutやnot_foundが返れば、最終カードは`availability: "unknown"`、台数と確認日時はnullです。成功時の値を埋めて画面を整えるより、「確認できない」という状態を後続処理へ渡す方が安全です。これらは通常の拒否とは別の業務上の結果です。

| 観測した状態 | この例での扱い | 次に決めること |
| --- | --- | --- |
| `completed`内に`refusal` | 最終JSONを読まず停止 | 利用者に拒否を伝える、入力を見直す |
| `incomplete`、理由が`max_output_tokens` | 部分JSONを破棄 | 必要な出力量と上限を見直す |
| `incomplete`、理由が`content_filter` | 成功データにしない | 入力や用途を確認する |
| 機材番号が未確定 | tool要求の前に停止 | 利用者に対象を確認する |
| 照会がtimeout・not_found | 不明データとして返す | 再照会するか、確認手段を案内する |
| JSONの形式は正しいが台数が違う | アプリで拒否 | 確定値を直接表示するか、整形の指示を見直す |

最終JSONを返す段階で`output_parsed`が存在しても、業務上の成功判定は省けません。SDKのparse helperは便利ですが、Pythonでは`responses.parse(..., text_format=...)`、JavaScriptでは`text.format`に`zodTextFormat`を渡すという違いがあります。[公式のSDK例](https://developers.openai.com/api/docs/guides/structured-outputs)と使用中のSDKに合わせて選んでください。

書き込みtoolに広げる際は、この読み取り例の`seen`だけで重複実行を防いだことにはなりません。プロセスをまたぐ再送や、異なる`call_id`で同じ操作が要求される場合もあります。アプリで業務操作の識別子を発行し、永続的に結果を保存して、再試行前に処理済みか確認します。timeoutも「処理されなかった」という証拠ではないため、外部システムで完了状態を照会してから再実行を判断します。

## この例の検証範囲と、接続後の成功条件

この記事のコードは、架空データと合成応答を使って、Python構文、引数検査、結果対応、拒否・途中終了、最終カードの照合をローカルで確認した例です。実際のSDK呼び出し、モデルの応答、サーバー側のschema受理、外部データの取得は試していません。ローカル確認からモデル精度や処理速度を比較することはできません。

実サービスへ組み込む際は、まず対応するモデルとschemaが受理されることを確認し、呼び出し要求の`call_id`と返した結果が対応しているかを記録します。そのうえで、取得元の値が最終データと一致していること、書き込みなら外部で実際に反映されていることを確認します。HTTPの成功、JSONのparse成功、業務の成功を別々に判定してください。

APIへまだ接続していない場合は、[OpenAI APIキーの発行から最初の応答までの手順](https://blog.laozhang.ai/ja/posts/openai-api-key-free-trial)を先に進めます。既存のChat Completions実装を移す場合は、[Responses APIへ関数呼び出しを移行する手順](https://blog.laozhang.ai/ja/posts/chat-completions-to-responses-api-function-calling-migration)で、出力itemの保持と`call_id`の返送を一緒に見直せます。

## よくある質問

### Structured OutputsとFunction Callingは、どちらか一方だけですか？

両方を使えます。Function Callingで取得した結果を、Structured Outputsで固定JSONに整える構成です。ただし、ツール結果をそのまま表示できるなら整形のための追加呼び出しは省けます。最終出力の形式と関数引数の役割は、[公式の使い分け](https://developers.openai.com/api/docs/guides/structured-outputs)でも区別されています。

### strictを付ければ、不明な値や誤った結果を防げますか？

形式を制約できますが、内容の正しさは保証しません。不明値を許さないschemaだと、入力にない値をもっともらしく埋める可能性があります。nullや明示的な不明状態を設け、業務データと照合します。[公式ガイド](https://developers.openai.com/api/docs/guides/structured-outputs)も、無関係な入力や内容の誤りへの対処を説明しています。

### 必ずツールを使わせたい場合、autoで十分ですか？

十分ではありません。`auto`は呼び出しゼロ件を許します。`required`は一件以上、特定のfunction指定は指定した関数の呼び出しを要求します。ただし、拒否や途中終了、実行失敗は別に扱い、アプリで必要な処理結果が得られたことを確認します。[公式のtool_choice説明](https://developers.openai.com/api/docs/guides/function-calling#tool-choice)が設定の基準です。

### JSON modeからStructured Outputsへ変える理由は何ですか？

JSON modeはJSONとして読める出力のための機能で、指定schemaへの適合までは保証しません。対応する用途ではStructured Outputsを選び、JSON modeを使う場合はアプリでschemaを検証します。JSON modeでも不完全な出力は別扱いです。また、コンテキスト内にJSONの生成指示が必要です。[公式のJSON mode説明](https://developers.openai.com/api/docs/guides/structured-outputs#json-mode)を確認してください。

## 参考資料

本文で参照している外部ページを、登場順に並べています。最終更新日：2026-10-07。

- [公式ガイド](https://developers.openai.com/api/docs/guides/function-calling) (developers.openai.com)
- [公式のStructured Outputsガイド](https://developers.openai.com/api/docs/guides/structured-outputs) (developers.openai.com)
