模組 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 點。內容經審核後公開。

正在載入討論…