OpenAI的結構化淺析

xindoo發表於2024-10-27

  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
orgCodeorgNameparentOrgCode
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%。這意味著你完全不需要為資料格式異常考慮任何處理邏輯,只需專注於實際的業務資料處理。

image.png

如何使用

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 結構化輸出介紹部落格

相關文章