OpenAI於2024年8月6日在其新模型gpt-4o-2024-08-06上推出了結構化輸出功能(Structured Outputs)。截至本文撰寫日期(2024年8月25日),gpt-4o仍指向上一版本gpt-4o-2024-05-13,尚不支援結構化輸出。有趣的是,gpt-4o-mini反而已經支援了這一功能,這點值得大家注意。那麼,結構化輸出究竟是什麼?為什麼OpenAI要專門釋出一篇部落格來詳細介紹它呢?接下來,讓我們一起深入瞭解這個話題。
結構化輸出可以簡單地描述為讓大模型生成特定格式JSON的能力。OpenAI在其部落格中指出,使用大語言模型(LLM)將非標準資料轉化為特定格式的結構化資料是LLM的核心應用場景之一。然而,在早期階段,讓LLM直接輸出合法的JSON字串並非易事。某些模型在被要求輸出JSON字串時,會以Markdown程式碼塊的形式呈現結果。正因如此,著名的LLM開發框架Langchain特意提供了JSON輸出解析器(SimpleJsonOutputParser)來解決這一問題。
讓模型輸出JSON看似簡單,有人可能會說:"直接在提示中要求大模型輸出JSON不就行了嗎?"然而,這種方法並非萬無一失。如前所述,模型有時會以Markdown程式碼塊的形式返回結果,有時又會直接給出純JSON。若要使用這些輸出,你還需要相容這兩種情況。更棘手的是,在處理複雜的JSON格式時,模型可能會生成不合法的JSON字串。在這種情況下,這條資料就完全無法使用了。
OpenAI 後來推出了 json_object 輸出模式(DeepSeek 也跟進了)。使用這種輸出模式時,prompt 中必須包含json字樣。json_object 模式解決了輸出不一定是 JSON 字串的問題。為了便於理解,讓我們用一個從非結構化文字中提取結構化資料的場景為例,來演示這個簡單操作。
初探Json生成
假設我們要從一個人的自我介紹中提取各個維度的資訊,並將提取的結果以 JSON 形式組織起來。用prompt抽取的程式碼如下:
import openai
import json
client = openai.OpenAI()
system_prompt = """
請提取出內容中的姓名,地址,手機號,興趣愛好,對應的欄位名分別是‘name’,‘address’,‘phoneNumber’,‘interests’。
用json字串返回,格式參考下面這個json
{
"name":"張三",
"addres":"北京市朝陽區大望路108號",
"phoneNumber":"17000098734",
"interests":"打遊戲"
}
"""
user_prompt = "我叫李四,家住在杭州西湖區xx路18號,我的手機號是19876496862,我平時喜歡釣魚。"
messages = [{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
response_format={
'type': 'json_object'
}
)
print(json.loads(response.choices[0].message.content))
我個人測試這個簡單案例,執行效果相當不錯。對於我示例中這種功能簡單的資料結構化,json_object輸出模式完全足夠應對。然而,在處理複雜的JSON結構時,這種方法就顯得力不從心了。首先,用prompt準確描述複雜的JSON結構本身就是一個挑戰。其次,讓大語言模型嚴格遵循prompt格式輸出資料也是一個難題。為了具體說明這一點,讓我們來看一個樹形結構JSON的例子。
我現在有如下的組織架構,需要生成與之對應的json資料,每個節點有orgCode orgName 和children 三個欄位,我們嘗試用json_object生成試試。
orgCode: BJ1, orgName: 北京大區, parentOrgCode: null
orgCode: BJ2, orgName: 京北大區, parentOrgCode: BJ1
orgCode: BJ3, orgName: 京南大區, parentOrgCode: BJ1
orgCode: BJ4, orgName: 京北一部, parentOrgCode: BJ2
orgCode: BJ5, orgName: 京北二部, parentOrgCode: BJ2
orgCode: BJ6, orgName: 京北三部, parentOrgCode: BJ2
orgCode: BJ7, orgName: 京北四部, parentOrgCode: BJ2
orgCode: BJ8, orgName: 京南一部, parentOrgCode: BJ3
orgCode: BJ9, orgName: 京南二部, parentOrgCode: BJ3
orgCode: BJ10, orgName: 京南三部, parentOrgCode: BJ3
orgCode: BJ11, orgName: 京南四部, parentOrgCode: BJ3
orgCode | orgName | parentOrgCode |
---|---|---|
BJ1 | 北京大區 | null |
BJ2 | 京北大區 | BJ1 |
BJ3 | 京南大區 | BJ1 |
BJ4 | 京北一部 | BJ2 |
BJ5 | 京北二部 | BJ2 |
BJ6 | 京北三部 | BJ2 |
BJ7 | 京北四部 | BJ2 |
BJ8 | 京南一部 | BJ3 |
BJ9 | 京南二部 | BJ3 |
BJ10 | 京南三部 | BJ3 |
BJ11 | 京南四部 | BJ3 |
import openai
import json
client = openai.OpenAI()
system_prompt = """
我會給一些組織架構中的節點資訊,其中parentOrgCode是當前節點的父節點,如果是null表示沒有父節點。
請將這些節點資訊用JsonArray表示出來, 每個節點有orgCode、orgName、level、children四個欄位,
其中level是在組織樹中的層級,children是其所有子節點,沒有就不輸出這個欄位。
參考下面格式:
[{
"orgCode":"BJ1",
"orgName":"北京大區",
"children":{}
},{…}]
"""
user_prompt = """
orgCode: BJ1, orgName: 北京大區, parentOrgCode: null
orgCode: SH1, orgName: 上海大區, parentOrgCode: null
orgCode: BJ2, orgName: 京北大區, parentOrgCode: BJ1
orgCode: BJ3, orgName: 京南大區, parentOrgCode: BJ1
orgCode: BJ4, orgName: 京北一部, parentOrgCode: BJ2
orgCode: BJ5, orgName: 京北二部, parentOrgCode: BJ2
orgCode: BJ6, orgName: 京北三部, parentOrgCode: BJ2
orgCode: BJ7, orgName: 京北四部, parentOrgCode: BJ2
orgCode: BJ8, orgName: 京南一部, parentOrgCode: BJ3
orgCode: BJ9, orgName: 京南二部, parentOrgCode: BJ3
orgCode: BJ10, orgName: 京南三部, parentOrgCode: BJ3
orgCode: BJ11, orgName: 京南四部, parentOrgCode: BJ3
"""
messages = [{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}]
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
response_format={
'type': 'json_object'
}
)
res=json.loads(response.choices[0].message.content)
從上述程式碼的prompt中,我們可以看出我原本想要最外層返回一個JsonArray。然而,在多次嘗試中,gpt-4o始終未能給出正確答案。它返回的是一個JsonObject,而且還遺漏了上海大區這個節點。此外,它的輸出格式也不太穩定,有時會在最外層莫名其妙地包裹一個"orgs"或"nodes",如下所示:
{
"orgCode": "BJ1",
"orgName": "北京大區",
"level": 1,
"children": [{…}, {…}]
}
或者
{
"orgs": [{
"orgCode": "BJ1",
"orgName": "北京大區",
"children": {…}
}, {…}]
}
經過進一步研究,我發現OpenAI實際上無法直接輸出JsonArray。模型為了輸出JsonObject而吞掉節點或強行在外層新增包裝,這就導致了之前提到的問題。透過驗證,我發現只需在上述資料中新增一個根節點,輸出就能符合預期了。
orgCode: root, orgName: 根節點
orgCode: BJ1, orgName: 北京大區, parentOrgCode: root
orgCode: SH1, orgName: 上海大區, parentOrgCode: root
orgCode: BJ2, orgName: 京北大區, parentOrgCode: BJ1
…………
接下來,讓我們看看在 JSON Schema 模式下的效果。當我將輸出切換為 JSON Schema 的嚴格模式後,結果達到了 100% 的準確率。具體程式碼如下:
import openai
import json
client = openai.OpenAI()
system_prompt = """
我會給一些組織架構中的節點資訊,請將這些資料用json格式輸出出來
"""
user_prompt = """
orgCode: BJ1, orgName: 北京大區, parentOrgCode: null
orgCode: SH1, orgName: 上海大區, parentOrgCode: null
orgCode: BJ2, orgName: 京北大區, parentOrgCode: BJ1
orgCode: BJ3, orgName: 京南大區, parentOrgCode: BJ1
orgCode: BJ4, orgName: 京北一部, parentOrgCode: BJ2
orgCode: BJ5, orgName: 京北二部, parentOrgCode: BJ2
orgCode: BJ6, orgName: 京北三部, parentOrgCode: BJ2
orgCode: BJ7, orgName: 京北四部, parentOrgCode: BJ2
orgCode: BJ8, orgName: 京南一部, parentOrgCode: BJ3
orgCode: BJ9, orgName: 京南二部, parentOrgCode: BJ3
orgCode: BJ10, orgName: 京南三部, parentOrgCode: BJ3
orgCode: BJ11, orgName: 京南四部, parentOrgCode: BJ3
"""
messages = [{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}]
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=messages,
response_format={
"type": "json_schema",
"json_schema":
{
"name": "my_schema",
"schema":
{
"type": "object",
"properties":
{
"nodes":
{
"type": "array",
"description": "所有的子節點",
"items":
{
"$ref": "#/definitions/organization"
}
}
},
"required":["nodes"],
"additionalProperties": False,
"definitions":
{
"organization":
{
"type": "object",
"properties":
{
"orgCode":
{
"type": "string",
"description": "orgCode"
},
"orgName":
{
"type": "string",
"description": "orgName"
},
"level":
{
"type": "integer",
"description": "在組織樹中的層級"
},
"children":
{
"type": "array",
"description": "所有的子節點",
"items":
{
"$ref": "#/definitions/organization"
}
}
},
"required":
[
"orgCode", "orgName", "level", "children"
],
"additionalProperties": False
}
}
},
"strict": True
}
}
)
res2=json.loads(response.choices[0].message.content)
上面程式碼中prompt部分就很少了,僅包含一句簡單的需求描述和一些資料,主要的程式碼都在schema的定義上
注意:OpenAI要求json的root層必須是JsonObject,所以我在上面額外加了個nodes層將結果封裝起來了,還有一些其他的限制比如additionalProperties必須是false……,具體可以查閱下官網文件 https://platform.openai.com/docs/guides/structured-outputs
OpenAI在部落格中給出的資料顯示,gpt-4-0613使用prompt抽取複雜JSON格式資料時,結構的準確率僅為40%。即便是當前最強的gpt-4o模型,準確率也只達到85%。線上上系統中,哪怕只有1%的錯誤率,你也必須考慮這部分異常的處理邏輯,更何況是15%。在實際場景中,處理這15%的異常資料所花費的成本可能會超過處理另外85%正常資料的成本。
然而,OpenAI的強大之處在於gpt-4o-2024-08-06模型在JSON輸出方面的表現。在嚴格模式下,它的準確率能達到100%——沒錯,就是100%。這意味著你完全不需要為資料格式異常考慮任何處理邏輯,只需專注於實際的業務資料處理。
如何使用
OpenAI的結構化輸出呼叫相當簡單。核心在於使用JSON Schema描述你所需的輸出格式。雖然這需要一些學習,但成本不高,而且你還可以讓大語言模型幫你編寫JSON Schema。讓我們回到之前的場景,給出一個結構化輸出的程式碼示例。
import openai
import json
client = openai.OpenAI()
system_prompt = """
請提取出內容中的姓名,地址,手機號
"""
user_prompt = "我叫李四,家住在杭州西湖區xx路18號,我的手機號是19876496862,我平時喜歡釣魚。"
messages = [{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
response_format={
"type": "json_schema",
"json_schema":{
"name": "my_schema",
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description":"姓名"
},
"address": {
"type": "string",
"description":"地址"
},
"phoneNumber": {
"type": "string",
"description":"11位的手機號"
}
},
"required": ["name", "address", "phoneNumber"],
"additionalProperties": False
},
"strict": True
}
}
)
print(json.loads(response.choices[0].message.content))
OpenAI是如何實現的
OpenAI在官方部落格中表示,他們使用了上下文無關文法(CFG)來實現結構化輸出。有限狀態自動機(FSM)也可以實現,但表達能力較弱,無法支援遞迴結構定義。相比於程式語言,JSON的語法表示相對簡單。以下是使用ANTLR4表示的JSON語法:
grammar JSON;
json
: value
;
obj
: '{' pair (',' pair)* '}'
| '{' '}'
;
pair
: STRING ':' value
;
array
: '[' value (',' value)* ']'
| '[' ']'
;
value
: STRING
| NUMBER
| obj
| array
| 'true'
| 'false'
| 'null'
;
STRING
: '"' (ESC | SAFECODEPOINT)* '"'
;
fragment ESC
: '\\' (["\\/bfnrt] | UNICODE)
;
fragment UNICODE
: 'u' HEX HEX HEX HEX
;
fragment HEX
: [0-9a-fA-F]
;
fragment SAFECODEPOINT
: ~ ["\\\u0000-\u001F]
;
NUMBER
: '-'? INT ('.' [0-9] +)? EXP?
;
fragment INT
: '0' | [1-9] [0-9]*
;
// no leading zeros
fragment EXP
: [Ee] [+\-]? INT
;
// \- since - means "range" inside [...]
WS
: [ \t\n\r] + -> skip
;
熟悉AI的同學都知道,LLM的工作過程就是根據已有內容不斷預測下一個token,這與我們使用的輸入法預測下一個候選詞本質上相似。不過,LLM能利用更豐富的上下文資訊,從而推測出更多符合"邏輯"的可能性。
在預測過程中,如果僅關注當前token與之前token的語義關係,我們會得到一段符合前文語義的內容。但如果同時考慮與前文的結構關係,就能生成既符合語義又符合結構的內容。
這個概念可能有點抽象,讓我們用一個簡單的例子來說明。還記得大學時學習資料結構中的棧嗎?有一個經典示例就是判斷括號的合法性。如果你忘記了,不妨重溫一下LeetCode第20題。以LeetCode第20題Valid Parentheses為例,我們可以用Antlr4來表示合法的括號輸入:
expr: '(' expr ')'
| '{' expr '}'
| '[' expr ']'
| /* empty */
;
這實際上是一個上下文無關文法定義(CFG)。它可以用狀態轉移圖來表示,其中每條邊代表一個輸入符號:
上圖包含遞迴定義,其中expr邊的定義就是上圖本身。在這個圖中,只要一個輸入能從start節點順利到達end節點,就表示這個輸入是合法的括號表示式。在LeetCode第20題中,我們可以將上圖的遞迴展開,得到下面這個更復雜的圖(其中"|"表示"或")。雖然看起來略微複雜,但我們可以輕易看出:合法輸入的第一個字元只能是"("、"["或"{"三者之一。隨著輸入的持續,只要是合法輸入,狀態轉換一定在q0到q8之間進行,且在每個節點上都有明確的下一個預期合法輸入字元。
根據OpenAI的部落格介紹,我推測其實現原理與上述例子類似,但JSON結構的狀態轉移圖複雜度遠高於LeetCode第20題。OpenAI可能會將輸入的JSON Schema預編譯成類似的狀態圖。在預測每個新token時,系統處於特定狀態節點,透過狀態圖可以確定下一步的合法輸入。最終,系統會從這些合法輸入中選擇機率最高的作為下一個token。
結語
透過這篇文章,我們瞭解了OpenAI結構化輸出的基本用法,並深入探討了其可能的實現原理。希望這些內容對大家有所幫助。結構化輸出功能無疑是AI與現有系統對接的關鍵依賴,因為目前所有系統的輸入都有特定的格式要求。在沒有結構化輸出能力之前,我們不得不使用各種奇技淫巧來完成資料格式化。顯然,有了結構化輸出,這部分工作就會簡單得多。 不過,我還要提醒大家一點:不要把結構化輸出當成萬能工具。俗話說,"拿著錘子看什麼都像釘子",可別落入這個陷阱。根據我的實際測試,大多數資料格式相對簡單,使用json_object模式通常就足夠了。所以,要根據實際需求選擇合適的方法。
參考資料
- JSON Schema 官方介紹
- OpenAI 結構化輸出指南
- OpenAI JSON 模式文件
- OpenAI 結構化輸出介紹部落格