モジュール 02 · 第 4 回

JSON を出力させる

モデルの回答をプログラムで処理するなら、形式の信頼できる JSON でなければなりません。JSON モード、Pydantic による検証と失敗時にモデルに直させる方法、そして strict モードのツール呼び出しでスキーマに沿った構造化出力を得る方法を説明します。

  • 約 40 分
  • 難易度:入門
  • 検証:2026-09-14 deepseek-flash、pydantic 2

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

ここまで、モデルの回答は人が読むためのものでした。しかし回答をプログラムで処理するとなると話は別です。たとえばユーザーの相談を自動で整理してデータベースに保存する、分類結果に応じて別々の担当者に振り分ける。そういうときに必要なのは、形式が決まっていて、フィールドがそろい、そのまま解析できる JSON です。

モデルに「JSON を出力して」と頼むのは簡単ですが、毎回正しい構文で、フィールドも正しい JSON を出力させるには、少しエンジニアリングの工夫が要ります。この課では三層の備えを扱います。JSON モードで構文を保証し、Pydantic の検証で内容を保証し、strict モードのツール呼び出しで構造を保証します。

プロンプトだけに頼るとどうなるか

最も直接的な方法は、プロンプトに「JSON を出力してください」と書くことです。たいていはうまくいきますが、ときどき次のようなことが起きます。

  • JSON の前に「わかりました、抽出結果は以下のとおりです:」と一言付けたり、```json のコードブロックで囲んだりして、json.loads がそのままエラーになる。
  • フィールド名がそろわず、今回は httpx_version、次は version になる。
  • 数値であるべきフィールドが文字列になり、リストであるべきフィールドがカンマ区切りの文字列になる。
  • 回答が長すぎて max_tokens で打ち切られ、JSON の最後の括号が欠ける。

1 日に数万回呼ばれるプログラムでは、失敗率 1% でも毎日数百回のエラーになります。ですから一層ずつ備えを加えていきます。

第一層:JSON モード

DeepSeek をはじめ、OpenAI 互換の API を持つ多くのサービスが JSON モードに対応しています。リクエストに response_format={"type": "json_object"} を加えると、モデルの出力は構文として正しい JSON であることが保証され、余計な前置きも付きません。

DeepSeek のドキュメント(2026 年 9 月時点)では、JSON モードに三つの要件があります。

  1. response_format={"type": "json_object"} を設定する。
  2. system か user のメッセージに「json」という語を含め、期待する形式の例を示す。
  3. JSON が打ち切られないよう、max_tokens を十分大きくする。

二つ目は必須の要件です。試しにプロンプトに json と書かなかったところ、サーバーはそのまま拒否しました。

提示词里没有 json,报错: Error code: 400 - {'error': {'message': "Prompt must contain the word 'json' in some form to use 'response_format' of type 'json_object'.", 'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}

ドキュメントには、API がまれに空の内容を返すことがあるという注意書きもあります。つまり JSON モードをオンにしても、コードは結果が必ず得られると仮定してはいけないということです。

第二層:Pydantic で検証する

JSON モードが保証するのは構文だけで、内容は保証しません。フィールドが欠けているかもしれませんし、型が違うかもしれません。ですから JSON を受け取ったら、プログラムで一通りチェックします。

Python で最も便利なツールは Pydantic です。openai をインストールしたときに依存関係として一緒に入っています。まず、ほしい構造をクラスで定義します。

from pydantic import BaseModel


class BugReport(BaseModel):
    title: str
    httpx_version: str | None  # 原话里没提就是 null
    python_version: str | None
    os: str | None
    error: str | None
    missing_info: list[str]

str | None は、このフィールドが文字列でも null でもよいことを表します。BugReport.model_validate_json(text) は JSON を解析して各フィールドをチェックし、フィールドの欠落や型の不一致があれば ValidationError を投げて、どのフィールドにどんな問題があるかをはっきり示します。

プロンプトで何がほしいかをはっきり書き、形式の例を示します。これで DeepSeek の「json という語を含める」という要件も同時に満たされます。

SYSTEM = """从用户的求助原话中提取信息,输出 JSON。原话里没有的字段填 null,不要猜。
JSON 格式示例:
{"title": "一句话概括问题", "httpx_version": "0.27", "python_version": "3.11",
 "os": "macOS", "error": "报错类型或信息", "missing_info": ["排查还需要知道的信息"]}"""

検証に失敗したら:エラーをモデルに伝える

検証に失敗したとき、最も簡単で効果的なのは、エラーメッセージをそのままモデルに送り返して直させることです。そのまま再利用できるコードを示します。

def extract(report, max_attempts=3):
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": report}]
    for attempt in range(1, max_attempts + 1):
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            response_format={"type": "json_object"},
            max_tokens=1000,
            extra_body={"thinking": {"type": "disabled"}},
        )
        content = response.choices[0].message.content
        try:
            return BugReport.model_validate_json(content), attempt
        except ValidationError as e:
            # 把错误原样告诉模型,让它改正。json 语法错误和字段错误都会走到这里
            print(f"第 {attempt} 次校验失败:{e.errors()[0]['msg']}")
            messages += [
                {"role": "assistant", "content": content or ""},
                {"role": "user", "content": f"你的输出没有通过校验:{e}\n请重新输出完整、正确的 JSON。"},
            ]
    raise RuntimeError(f"{max_attempts} 次都没有得到合法的输出")

いくつか細かい点があります。

  • リトライのときは、モデルが前回出した誤った出力を assistant メッセージとして戻し、user メッセージでどこが間違っているかを説明します。モデルは自分がどこを間違えたかを見られるので、最初から聞き直すより修正の成功率が高くなります。
  • 空の内容(contentNone か空文字列)も検証で失敗するので、同じくリトライが働きます。これでドキュメントにある「まれに空の内容を返す」ケースにも対処できます。
  • リトライの最大回数を決めます。3 回リトライしてもだめなら、たいていプロンプトかデータそのものに問題があり、それ以上リトライしてもお金の無駄なので、エラーにして人に見てもらうべきです。

第 1 課の httpx の相談で試してみましょう(完全なコードは code/02-prompting/json_output.py)。

第 1 次成功:
{
  "title": "httpx stream下载大文件中途ReadTimeout",
  "httpx_version": "0.27",
  "python_version": "3.11",
  "os": "macOS",
  "error": "ReadTimeout",
  "missing_info": [
    "具体的ReadTimeout异常堆栈",
    "当前timeout配置值",
    "重试逻辑或下载代码片段",
    "网络代理或内网限制情况"
  ]
}

今回は 1 回目で通りました。私のテストでは、JSON モードと形式の例があれば、検証に失敗することはまれでした。しかし「まれ」は「ない」ではありません。リトライのコードは、そのまれなケースのための保険です。

受け取った report は Python のオブジェクトなので、report.httpx_version のようにフィールドに直接アクセスでき、エディタの自動補完も効きます。辞書から文字列のキーで値を取り出すよりずっと確実です。

第三層:ツール呼び出しで構造化出力を得る

最初から構造を制約できる方法がもう一つあります。モデルに「ツールを呼び出させ」、そのツールの引数をほしい構造にするのです。

ツール呼び出し(function calling)は本来、モデルに外部の関数を呼び出させるためのもので、モジュール 03 第 3 課で詳しく扱います。ここではその特性の一つだけを借ります。ツールの引数を JSON Schema で記述すると、モデルが生成する引数はその構造に従います。DeepSeek にはさらに strict モードがあり、オンにするとモデルが出力する引数はスキーマに厳密に従い、列挙値も与えた選択肢の中からしか選べなくなります。

2026 年 9 月時点で、DeepSeek の strict モードはベータ機能です。base_urlhttps://api.deepseek.com/beta に替え、関数の定義に "strict": True を書き、スキーマに "additionalProperties": False を含める必要があります。

client = OpenAI(
    api_key=os.environ["LLM_API_KEY"],
    base_url="https://api.deepseek.com/beta",
)

tool = {
    "type": "function",
    "function": {
        "name": "save_bug_report",
        "description": "保存从用户原话中提取出的问题信息",
        "strict": True,
        "parameters": {
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "一句话概括问题"},
                "httpx_version": {"type": "string", "description": "原话里没有就填空字符串"},
                "os": {"type": "string", "enum": ["macOS", "Windows", "Linux", "未知"]},
                "severity": {"type": "string", "enum": ["阻塞", "严重", "一般"]},
            },
            "required": ["title", "httpx_version", "os", "severity"],
            "additionalProperties": False,
        },
    },
}

response = client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "user", "content": "提取这段求助里的信息并保存:\n" + REPORT}],
    tools=[tool],
    # 强制调用这个工具,而不是让模型自己决定要不要调用
    tool_choice={"type": "function", "function": {"name": "save_bug_report"}},
    extra_body={"thinking": {"type": "disabled"}},
)
call = response.choices[0].message.tool_calls[0]
print("模型调用了:", call.function.name)
print(json.dumps(json.loads(call.function.arguments), ensure_ascii=False, indent=2))

実行結果です。

模型调用了: save_bug_report
{
  "title": "用httpx stream下载2G大文件到一半报ReadTimeout连接中断",
  "httpx_version": "0.27",
  "os": "macOS",
  "severity": "严重"
}

osseverity はどちらも与えた列挙値の中に収まっています。モデルが本当に何かを「保存」したわけではなく、save_bug_report という関数はそもそも存在しません。その引数を借りて構造化データを受け取っているだけです。

tool_choice は、どのツールを必ず呼び出すかを指定しています。指定しないと、モデルはツールを呼び出さずに文章で答えることを選ぶかもしれません。

三つの方法の選び方

方法 何を保証するか 向いている場面
JSON モード + Pydantic 検証 + リトライ 構文はモードが保証し、内容は検証とリトライで守る ほとんどの場合の第一候補。どのサービスも対応している
strict モードのツール呼び出し 出力がスキーマに厳密に従う 構造が複雑、列挙値が多い、リトライのロジックを書きたくないとき
プロンプトだけに頼る 何も保証しない モデルやサービスが上の二つに対応していないとき。必ず検証と組み合わせる

どれを使うにしても、プログラムでの検証は省かないでください。strict モードは構造を保証しても、内容は保証しません。モデルがバージョン番号を誤って抽出したり、「普通」の問題を「重大」と判定したりすることは依然としてありえます。構造が正しいのは第一歩にすぎず、内容が正しいかどうかはモジュール 06 で扱う評価でチェックします。

ほかに小さな注意点を二つ。

  • フィールドは少ないほど安定する。一度に 20 個のフィールドを抽出すると、5 個のときよりずっと間違えやすくなります。フィールドが多いときは、何回かの呼び出しに分けることを検討します。
  • 「ない」を許す。「原文にこの情報はない」ことを表す手段を、必ずモデルに与えてください。たとえば null や空文字列にし、プロンプトでそう説明します。そうしないと、モデルはフィールドを埋めるためにでっち上げ始めます。

練習問題

  1. BugReportmissing_infolist[int] に変えて(わざと誤った定義にする)json_output.py を実行し、検証の失敗とリトライの過程がどうなるか見てください。
  2. BugReportseverity フィールドを加え、「阻塞」(ブロッカー)、「严重」(重大)、「一般」(普通)のいずれかに限定してください(ヒント:typing.Literal を使う)。わざと深刻度のわからない相談を渡して、モデルがどう扱うか見てください。
  3. json_strict.py の方法で、第 2 課の書き込み分類のためのツールを作り、カテゴリを enum で限定して 20 件の書き込みを処理し、形式がすべて正しいか確かめてください。

確認テスト

1. JSON モードをオンにしたのに、なぜ Pydantic で検証する必要があるのですか?

JSON モードが保証するのは出力が構文として正しい JSON であることだけで、フィールドがそろっていること、名前が正しいこと、型が正しいことは保証しません。さらに DeepSeek のドキュメントには、API がまれに空の内容を返すことがあるとも書かれています。Pydantic の検証でこうした問題を見つけ、リトライと組み合わせて処理します。

2. 検証に失敗してリトライするとき、モデルの前回の誤った出力もメッセージに戻すのはなぜですか?

そうするとモデルは前回自分が何を出力したかを見られ、あなたが示したエラーメッセージと合わせて、的を絞って修正できます。元の質問をもう一度聞くだけでは、モデルはどこが間違っていたかわからず、同じ間違いを繰り返す可能性が高くなります。

3. ツール呼び出しで構造化出力を得るとき、tool_choice はどんな役割をしますか?

モデルがどのツールを必ず呼び出すかを指定します。指定しないと、モデルはツールを呼ぶかどうかを自分で決め、文章で直接答えてしまうかもしれず、そうなると構造化された引数が得られません。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…