模块 02 · 第 4 课

让模型输出 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 模式有三个要求:

  1. 设置 response_format={"type": "json_object"}
  2. 在 system 或 user 消息里出现 "json" 这个词,并给出期望格式的示例。
  3. 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 表示这个字段可以是字符串,也可以是 nullBugReport.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 或空字符串)也会在校验时失败,同样会触发重试,这就处理了文档里说的"偶尔返回空内容"。
  • 设置最大重试次数。重试三次还不行,多半是提示词或者数据本身有问题,继续重试只是浪费钱,应该报错让人来看。

用第 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": "严重"
}

osseverity 都落在了给定的枚举值里。模型并没有真的去"保存"什么,save_bug_report 这个函数根本不存在,我们只是借它的参数拿到结构化数据。

tool_choice 指定了必须调用哪个工具。不指定的话,模型可能决定不调用工具,直接用文字回答。

三种方法怎么选

方法 保证什么 适合
JSON 模式 + Pydantic 校验 + 重试 语法由模式保证,内容由校验和重试兜底 大多数情况的首选,各家服务商都支持
严格模式的工具调用 输出严格符合 schema 结构复杂、枚举值多、不想写重试逻辑时
只靠提示词 什么都不保证 模型或服务商不支持以上两种时,一定要配合校验

不管用哪种,程序里的校验都不要省。严格模式能保证结构,却保证不了内容:模型仍然可能把版本号提取错,把"一般"的问题标成"严重"。结构正确只是第一步,内容对不对,要靠 06 模块讲的评估来检查。

另外两个小提醒:

  • 字段越少越稳定。一次提取二十个字段,出错的概率远高于五个字段。字段多时,考虑拆成几次调用。
  • 允许"没有"。一定要给模型一个表达"原文里没有这个信息"的方式,比如 null 或者空字符串,并在提示词里说明。否则模型为了填满字段,就会开始编。

练习

  1. BugReport 里的 missing_info 改成 list[int](故意定义错),运行 json_output.py,看看校验失败和重试的过程是什么样的。
  2. BugReport 加一个字段 severity,限定只能是"阻塞"、"严重"、"一般"之一(提示:用 typing.Literal)。故意给一段看不出严重程度的求助,看模型怎么处理。
  3. json_strict.py 的方法,给第 2 课的留言分类做一个工具,类别用 enum 限定,处理 20 条留言,看看格式是不是全部正确。

自测

1. 开启了 JSON 模式,为什么还要用 Pydantic 校验?

JSON 模式只保证输出是语法合法的 JSON,不保证字段齐全、名字正确、类型正确。另外,DeepSeek 文档也提到 API 偶尔会返回空内容。Pydantic 校验能发现这些问题,配合重试把它们处理掉。

2. 校验失败后重试时,为什么要把模型上一次的错误输出也放回消息里?

这样模型能看到自己上次输出了什么,结合你给出的错误信息,有针对性地改正。如果只是把原问题再问一遍,模型不知道哪里错了,很可能犯同样的错。

3. 用工具调用拿结构化输出时,tool_choice 起什么作用?

指定模型必须调用某个工具。不指定时,模型会自己决定调不调用工具,可能直接用文字回答,这样就拿不到结构化的参数了。

提问与讨论

这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。

提问 +3 积分,回答别人 +6 积分。内容经审核后公开。

正在加载讨论…