头图

  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模式通常就足够了。所以,要根据实际需求选择合适的方法。

参考资料


xindoo
714 声望3k 粉丝