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