让模型输出 JSON
模型的回答要交给程序处理时,得是格式可靠的 JSON。讲 JSON 模式、用 Pydantic 校验并在失败时让模型改正,以及用严格模式的工具调用拿到符合 schema 的结构化输出。
- 约 40 分钟
- 难度:入门
- 实测:2026-09-14 deepseek-flash,pydantic 2
到目前为止,模型的回答都是给人看的。可一旦要把回答交给程序处理,比如把用户的求助自动整理后存进数据库、按分类结果分派给不同的人,你需要的就是一个格式固定、字段齐全、能直接解析的 JSON。
让模型"输出 JSON"很容易,让它每次都输出合法的、字段正确的 JSON,需要一点工程上的功夫。这一课讲三层保障:JSON 模式保证语法,Pydantic 校验保证内容,严格模式的工具调用保证结构。
只靠提示词会出什么问题
最直接的办法是在提示词里写"请输出 JSON"。大多数时候它能做到,但总有一些时候:
- 在 JSON 前面加一句"好的,以下是提取结果:",或者用 ```json 代码块包起来,
json.loads直接报错。 - 字段名不一致,这次叫
httpx_version,下次叫version。 - 该是数字的字段给了字符串,该是列表的给了一个逗号分隔的字符串。
- 回答太长,被
max_tokens截断,JSON 缺了最后的括号。
一个每天调用几万次的程序,1% 的失败率也意味着每天几百次出错。所以要一层层加保障。
第一层:JSON 模式
DeepSeek 和很多兼容 OpenAI 接口的服务都支持 JSON 模式:在请求里加上 response_format={"type": "json_object"},模型的输出保证是一段语法合法的 JSON,不会有多余的开场白。
DeepSeek 的文档(截至 2026 年 9 月)对 JSON 模式有三个要求:
- 设置
response_format={"type": "json_object"}。 - 在 system 或 user 消息里出现 "json" 这个词,并给出期望格式的示例。
max_tokens设得足够大,防止 JSON 被截断。
第二条是硬性要求。我试了一下,提示词里不写 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或空字符串)也会在校验时失败,同样会触发重试,这就处理了文档里说的"偶尔返回空内容"。 - 设置最大重试次数。重试三次还不行,多半是提示词或者数据本身有问题,继续重试只是浪费钱,应该报错让人来看。
用第 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配置值",
"重试逻辑或下载代码片段",
"网络代理或内网限制情况"
]
}
这次第一次就通过了。在我的测试中,有了 JSON 模式和格式示例之后,校验失败的情况很少见。但"很少"不等于"没有",重试的代码是给那少数情况准备的保险。
拿到的 report 是一个 Python 对象,可以直接用 report.httpx_version 访问字段,编辑器也能自动补全。这比在字典里用字符串取值可靠得多。
第三层:用工具调用拿结构化输出
还有一种办法,能让结构从一开始就受到约束:让模型"调用一个工具",工具的参数就是你想要的结构。
工具调用(function calling)本来是让模型调用外部函数用的,03 模块第 3 课会详细讲。这里只借用它的一个特性:你用 JSON Schema 描述工具的参数,模型生成的参数会按这个结构来。DeepSeek 还提供了严格模式(strict),开启后模型输出的参数会严格符合 schema,枚举值也只能从你给的选项里选。
截至 2026 年 9 月,DeepSeek 的严格模式是 Beta 功能,要把 base_url 换成 https://api.deepseek.com/beta,在函数定义里写 "strict": True,并且 schema 里要有 "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 校验 + 重试 | 语法由模式保证,内容由校验和重试兜底 | 大多数情况的首选,各家服务商都支持 |
| 严格模式的工具调用 | 输出严格符合 schema | 结构复杂、枚举值多、不想写重试逻辑时 |
| 只靠提示词 | 什么都不保证 | 模型或服务商不支持以上两种时,一定要配合校验 |
不管用哪种,程序里的校验都不要省。严格模式能保证结构,却保证不了内容:模型仍然可能把版本号提取错,把"一般"的问题标成"严重"。结构正确只是第一步,内容对不对,要靠 06 模块讲的评估来检查。
另外两个小提醒:
- 字段越少越稳定。一次提取二十个字段,出错的概率远高于五个字段。字段多时,考虑拆成几次调用。
- 允许"没有"。一定要给模型一个表达"原文里没有这个信息"的方式,比如
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 积分。内容经审核后公开。
正在加载讨论…