AI工具人
提示词工程师

OpenAI的结构化浅析


  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%。这意味着你完全不需要为数据格式异常考虑任何处理逻辑,只需专注于实际的业务数据处理。

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

参考资料

赞(0) 打赏
未经允许不得转载:XINDOO » OpenAI的结构化浅析

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫

微信扫一扫