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 モードに三つの要件があります。
response_format={"type": "json_object"}を設定する。- system か user のメッセージに「json」という語を含め、期待する形式の例を示す。
- 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メッセージでどこが間違っているかを説明します。モデルは自分がどこを間違えたかを見られるので、最初から聞き直すより修正の成功率が高くなります。 - 空の内容(
contentがNoneか空文字列)も検証で失敗するので、同じくリトライが働きます。これでドキュメントにある「まれに空の内容を返す」ケースにも対処できます。 - リトライの最大回数を決めます。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_url を https://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": "严重"
}
os と severity はどちらも与えた列挙値の中に収まっています。モデルが本当に何かを「保存」したわけではなく、save_bug_report という関数はそもそも存在しません。その引数を借りて構造化データを受け取っているだけです。
tool_choice は、どのツールを必ず呼び出すかを指定しています。指定しないと、モデルはツールを呼び出さずに文章で答えることを選ぶかもしれません。
三つの方法の選び方
| 方法 | 何を保証するか | 向いている場面 |
|---|---|---|
| JSON モード + Pydantic 検証 + リトライ | 構文はモードが保証し、内容は検証とリトライで守る | ほとんどの場合の第一候補。どのサービスも対応している |
| strict モードのツール呼び出し | 出力がスキーマに厳密に従う | 構造が複雑、列挙値が多い、リトライのロジックを書きたくないとき |
| プロンプトだけに頼る | 何も保証しない | モデルやサービスが上の二つに対応していないとき。必ず検証と組み合わせる |
どれを使うにしても、プログラムでの検証は省かないでください。strict モードは構造を保証しても、内容は保証しません。モデルがバージョン番号を誤って抽出したり、「普通」の問題を「重大」と判定したりすることは依然としてありえます。構造が正しいのは第一歩にすぎず、内容が正しいかどうかはモジュール 06 で扱う評価でチェックします。
ほかに小さな注意点を二つ。
- フィールドは少ないほど安定する。一度に 20 個のフィールドを抽出すると、5 個のときよりずっと間違えやすくなります。フィールドが多いときは、何回かの呼び出しに分けることを検討します。
- 「ない」を許す。「原文にこの情報はない」ことを表す手段を、必ずモデルに与えてください。たとえば
nullや空文字列にし、プロンプトでそう説明します。そうしないと、モデルはフィールドを埋めるためにでっち上げ始めます。
練習問題
BugReportのmissing_infoをlist[int]に変えて(わざと誤った定義にする)json_output.pyを実行し、検証の失敗とリトライの過程がどうなるか見てください。BugReportにseverityフィールドを加え、「阻塞」(ブロッカー)、「严重」(重大)、「一般」(普通)のいずれかに限定してください(ヒント:typing.Literalを使う)。わざと深刻度のわからない相談を渡して、モデルがどう扱うか見てください。json_strict.pyの方法で、第 2 課の書き込み分類のためのツールを作り、カテゴリをenumで限定して 20 件の書き込みを処理し、形式がすべて正しいか確かめてください。
確認テスト
1. JSON モードをオンにしたのに、なぜ Pydantic で検証する必要があるのですか?
JSON モードが保証するのは出力が構文として正しい JSON であることだけで、フィールドがそろっていること、名前が正しいこと、型が正しいことは保証しません。さらに DeepSeek のドキュメントには、API がまれに空の内容を返すことがあるとも書かれています。Pydantic の検証でこうした問題を見つけ、リトライと組み合わせて処理します。
2. 検証に失敗してリトライするとき、モデルの前回の誤った出力もメッセージに戻すのはなぜですか?
そうするとモデルは前回自分が何を出力したかを見られ、あなたが示したエラーメッセージと合わせて、的を絞って修正できます。元の質問をもう一度聞くだけでは、モデルはどこが間違っていたかわからず、同じ間違いを繰り返す可能性が高くなります。
3. ツール呼び出しで構造化出力を得るとき、tool_choice はどんな役割をしますか?
モデルがどのツールを必ず呼び出すかを指定します。指定しないと、モデルはツールを呼ぶかどうかを自分で決め、文章で直接答えてしまうかもしれず、そうなると構造化された引数が得られません。
質問と議論
このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。
質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。
議論を読み込んでいます…