快轉到主要內容

OpenAI 能輸出你想要的格式 (JSON Schema)

OpenAI 在八月推出了 “Structured Output” 的功能, 能夠 100% 讓模型以「你想要的 JSON 結構」的格式輸出,例如有一欄 “explanation”、另一欄 “output” 等等

這個只在 API 層支援;普羅大眾去 ChatGPT 網站聊天是沒有的

結構化輸出:為什麼重要? #

GPT 不只是聊天機器人

GPT 能處理自由、無結構的文字,做各種推測彙整。也可以是軟體內眾多環節的一步:他做了很聰明的處理後,再分工給後面的程式做其他的處理

System pipeline with LLM but the output is human language
大部分的程式不懂人類語言,所以用 LLM 處理資訊,後面還是很難接手

然而,程式通常是一板一眼、看不懂自由的文字。相反地,GPT 雖然看得懂自由的文字,但輸出也通常是自由的文字

OpenAI API 這次提供 “Structured Output” 的功能,就能把自由的 GPT 跟實際的程式銜接起來,讓開發者寫的軟體變得更厲害

System pipeline with LLM that can output structured output
當 LLM 能輸出固定格式,接手的程式就容易處理

以前就可以了不是嗎? #

  • 可以用 prompt 告訴 GPT 「請輸出 JSON 格式,第一欄要…」,但不見得 100% 會是合法的 JSON object
  • OpenAI 也支援 response_format{ "type": "json_object" },雖然回答會是 JSON,但裡面的欄位名字不一定是你期望的

身為一個工程師,當你真的很希望 GPT 輸出固定的結構、固定的欄位名字,就是賭骰子。根據 OpenAI 的評估 複雜的結構,用 prompt 或 JSON 模式有 85% ~ 93% 的成功率

… 你要賭那 7% 失敗率嗎?所以會做例外處理:retry, correction, fallback 等等

OpenAI 這次的 json_schema 就是讓工程師安心,讓例外處理變稍微簡單(還是有例外,請見後文)

等等,什麼是 JSON Schema? #

當你要描述輸出的 JSON 格式,你是用 JSON Schema 來描述之(繞口令)

例如這個 JSON Schema

{
  "type": "object",
  "properties": {
    "SCORES": {
      "type": "array",
      "items": { "type": "integer" }
    },
    "COMMENT": { "type": "string" }
  },
  "required": ["SCORES", "COMMENT"]
}

描述著類似像這樣的 JSON 物件

{
  "SCORES": [50, 70, 20],
  "COMMENT": "U SHALL NOT PASS"
}

題外話 JSON Schema https://json-schema.org/ 是業界標準,並不是 OpenAI 發明的。JSON Schema 本身的目的,就是去描述跟驗證一個 JSON 物件該長怎樣

怎麼讓 OpenAI API 的輸出結構化? #

兩個場景:Tool Use (function calling) 跟普通聊天 (chat completition)

模型必須是 gpt-4o-mini, gpt-4o-2024-08-06 或更之後的

要讓 OpenAI 輸出固定的格式,你要告訴他「格式」,以及兩三個重要的參數(見下)

Tool Use #

你本來就會指定輸出的格式了(亦即 function 參數的格式,所以 GPT 會盡量照著格式回傳),所以你只要

  1. function 物件內加 strict: true
  2. 確保 functionparameters 每個參數 (properties) 都是必須 required
  3. functionparameters 內要寫 "additionalProperties": False (因為 type 是 object)
{
  "type": "function",
  "function": {
    "strict": True,
    ...
    "parameters": {
      "type": "object",
      "properties": {
        "dim": {
          "type": "integer", ...
        },
        "perk": {
          "type": "string", ...
        }
      },
      "required": ["dim", "perk"],
      "additionalProperties": False
    }
  }
}

輸出則沒有改變,跟之前一樣

普通聊天 #

API 傳入的參數,最上層多傳個 response_format,其值為

{
  "type": "json_schema",
  "json_schema": {
    # 你期待輸出的 JSON Schema 
    "name": "...",
    "schema": { ... },
    "strict": True
  }
}

並且

  1. json_schema 物件內加 strict: true
  2. 確保所有 “object” type 的屬性,每個欄位 (properties) 都是必須 required,並且指定 "additionalProperties": False (表示不能輸出你沒定義的欄位)

官方文件的例子

POST /v1/chat/completions
{
  ...
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "math_response",
      "strict": true,
      "schema": {
        "type": "object",
        "properties": {
          "steps": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "explanation": { "type": "string" },
                "output": { "type": "string" }
              },
              "required": ["explanation", "output"],
              "additionalProperties": false
            }
          },
          "final_answer": { "type": "string" }
        },
        "required": ["steps", "final_answer"],
        "additionalProperties": false
      }
    }
  }
}

這 schema 描述著巢狀的 output, 每層 object 都有 required 跟 "additionalProperties": false

輸出就會照這格式

{
    "steps": [
        {
            "explanation": "We start with the equation...",
            "output": "8x + 7 = -23"
        },
        {
            "explanation": "To move the +7...",
            "output": "8x + 7 - 7 = -23 - 7"
        },
        (...以下省略)
    ],
    "final_answer": "x = -3.75"
}

輸出的訊息 (message.content) 型態仍是字串,只不過這個字串可以被解析成 JSON

Python SDK #

前面的例子雖然類似 HTTP / REST API 傳進去的參數,但就算用 openai python library 道理也差不多,就是在 client.chat.completions.createresponse_format= 或是 tools= 參數,裡面放個 python dict

建議安裝 openai 1.40.0 以後的(手寫 schema 用舊的 package 也沒問題,不過至少 release note 是 1.40.0 以後加的)

然而這都需要你「手寫 schema」,可能是個挑戰

根據官方文件,你可以用 pydantic BaseModel 描述你的輸出,且用另一個 API,就會讓整件事變得很容易

class Distance(BaseModel):
  amount: int
  unit: str

completion = client.beta.chat.completions.parse(
  model="gpt-4o-mini",
  messages=[
    {
      "role": "user",
      "content": "台北到高雄多遠?"
    }
  ],
  response_format=Distance
)

r = completion.choices[0].message.parsed
print(type(r))  # Distance
print(r)  # amount=344 unit='公里'

response_format 是傳入那個 BaseModel class,API 回傳直接是他的 object。不用處理又臭又長的 json / dict

… 只不過這 API 是 client.beta,我們期待他變成正式版本的一天

有什麼限制? #

可能會跑很久 #

OpenAI 要先了解你指定的格式:把你輸入的 JSON Schema 轉成內部的 parser,所以官方提到第一次呼叫會比較慢;之後如果用同一個 schema 的話因為有 cache 就不會額外花時間 (官方並沒有說 cache 多久)

我隨便拿兩層 object 實測,大概第一次會有額外 5 秒的延遲,但第二次以後就沒有了(5 這個數字不是重點,會跟 schema 複雜程度有關)。題外話就算架構一樣,key 名字改了也算是新的 schema

Schema 的限制 #

  1. 輸出裡,支援的型態:String, Number, Boolean, Object, Array, Enum, anyOf
  2. 如前面所說,「每個」type 是 object 的裡面,必須指定「每個欄位」是 “required”,以及 "additionalProperties": false

但邏輯上,如果某個欄位就真的不一定需要的話,欄位型態 type 裡可以多加 “null” 來代替

"parameters": {
  "type": "object",
  "properties": {
    "unit": {
      "type": ["string", "null"],
      "description": "The unit to return the temperature in",
      ...

更多請見官方文件: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas

例外:還是會害怕 #

雖然指定 JSON Schema 以後絕大部分都會照著輸出,但還是有極端例子要處理

Refusal (拒絕):模型可能基於某些原因無法符合你的格式限制,這時候他不會硬去輸出,而是有個提示 “refusal”

{
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "refusal": "I'm sorry, I cannot assist with that request."
      },
...
}

切掉:跟格式限制無關,OpenAI 輸出本來就有可能因為長度過長切掉 ("finish_reason": "length"),只不過輸出切掉的話,很大機率是不會符合你的格式

官方有建議怎麼處理例外: https://platform.openai.com/docs/guides/structured-outputs/how-to-use

一些神秘的 JSON Schema 功能不支援 #

業界的 JSON Schema 很泛用,甚至可以驗證屬性值的特性,例如數字可加 minimum 告訴其他人這個值不應該小於多少

然而 OpenAI 還沒辦法處理這種驗證(例如確保輸出的數字不會小於多少);不支援的清單請見此

OpenAI 用了什麼魔法? #

不,這其實不是 OpenAI 新發明的:這在其他 LLM 或服務也可以看到,例如 llama.cpp 很早就提供這個功能。不如說 OpenAI 還慢了一步

其中一種方法的背後原理是:

  • 把 JSON Schema 轉成語法規則 (CFG, context-free grammar)
  • 每次模型預測下一個字 (token) 的時候,把不合規則的字先拿掉

GPT 能夠回答問題、文字接龍的原理是,模型有預先定義的字彙,之後根據前文預測每個可能的字 (token) 適合接在後面的機率,再丟骰子決定下一個字選哪一個

How an LLM generate the next token by sampling
每次都有很多個 token 競爭「下一個」要出的,根據模型覺得的可能程度隨機挑

而如果你訂了規則,例如輸出的 key 名字一定是 "colour" (包含引號),那當前文是 { "colo 的時候,就算下個字是 r 的機率比較高,因為不符合規則,所以也會先拿掉 (當然 token 不是一個個字母,這只是舉例)

remove invalid tokens from sampling given the grammar
可根據語法規則,把不合法的競爭者去掉,就會必定輸出合法的格式

如果說 GPT 文字接龍是擲骰子,那輸出格式限制就是把不合法的那幾面擦掉 (Constrained Decoding)

上述的原理只提到 CFG ,不一定要是 JSON schema,所以以 llama.cpp 為舉例,只要你寫得出語法規則,他都可以把輸出變成你想要的格式(例如只輸出數字!)

但也因為這樣,如果有太多限制,導致合理的回答並無法用你指定的格式好好說出來,那模型就會亂說亂幻想了(例如你問巴黎旅遊計畫,但又限制他只能回答數字)

JSON Schema 會讓 GPT 變笨嗎? #

如果太多限制會讓模型亂說話,那 JSON Schema 呢?

以直覺來說,這或許也跟你怎麼設計 schema 有關:你的架構與 key 名字要跟問答的情境有關聯(或通用)

最近,台灣有篇研究剛好跟這有關: https://arxiv.org/abs/2408.02442 :他們研究各個模型在 JSON Mode (也是限制 token 只不過僅規定 JSON 就好) 以及用 prompt 軟性跟模型說想要的 schema,會不會讓模型變笨

好玩的是,這個你想要模型做什麼有關

  • 想要他「分類」:限制他反而比較厲害
  • 想要他「推理」:限制他就比較差

不過作者有在其他地方說明(配合 Appendix D / Figure 8 )當模型比較強大的時候,變笨的情況差異很小,雖然 Appendix D 是用 prompt 限制而不是限制 token,總之可以參考一下


以上只是對一些官方文件的整理(跟自己的實驗),如果真的有興趣,建議還是以官方文件為主


若您覺得有趣, 請 追蹤我的Facebook 或  Linkedin, 讓你獲得更多資訊!