图1 — 在使用结构化输出的过程中,从用户的角度,明确执行和隐含执行的步骤;(作者绘制的图片)
在之前的文章中,我们通过OpenAI介绍了结构化输出结果。自从ChatCompletions API v1.40.0发布以来,结构化输出结果已在数十个用例中得到应用,并在OpenAI论坛上引发了众多讨论。
本文的目的不仅为你提供更深入的理解,澄清一些误解,还将教你如何在不同场景下最有效地应用它们。
结构化的输出是一种方法,用于确保大规模语言模型(LLM)的输出遵循一个预定义的模式——通常是一个JSON格式。通过将其转换为上下文无关文法(CFG),在进行标记抽样步骤时,可以结合先前生成的标记,来判断哪些后续标记是有效的。可以将其视为创建了一个正则表达式(regex)来生成标记。
OpenAI API 实现实际上只跟踪 JSON 模式的一个有限子集。对于更通用的结构化输出解决方案,比如 Outlines,可以使用更大的 JSON 模式子集,甚至可以定义完全自定义的非 JSON 模式,只要可以访问开放的权重模型。本文假设使用的是 OpenAI API 实现。
根据JSON Schema 核心规范,JSON Schema 提出了 JSON 文档应该是什么样子,如何从中提取信息,以及如何与之交互的标准。JSON Schema 规定了六种原始类型——null、boolean、object、array、number 和 string。它还定义了一些关键字、标注和特定行为。例如,我们可以在模式中指定 array
类型,并标注 minItems
为 5
。
Pydantic 是一个实现了 JSON 的规范的 Python 库。我们利用 Pydantic 来构建稳健且易于维护的 Python 软件。由于 Python 是一种动态类型的语言,数据从业者不一定考虑 变量的类型 — 在他们的代码中,这些类型往往是隐含的。例如,水果可能被定义为:
fruit = { 'name': '苹果', 'color': '红色', 'weight': 4.2 }
…而定义返回“水果”类型的函数来自某些数据通常会指定为:
def extract_fruit(s): # 从字符串中提取水果名称 ... return fruit
另一方面,Pydantic 允许我们生成一个符合 JSON Schema 规范的类,带有适当注解的变量和 类型提示,使代码更易读和维护,更加健壮,即
class Fruit(BaseModel): name: str color: 字面量['red', 'green'] weight: 注解的[float, 大于0] def 提取水果(s: 字符串) -> Fruit: ... return 水果
OpenAI 实际上强烈建议使用 Pydantic 来指定模式定义,而不是直接用 JSON 定义原始模式。有以下几个原因。首先,Pydantic 保证符合 JSON 模式规范,因此可以省去额外的预验证步骤。其次,对于较大的模式定义,它更简洁,使编写干净的代码更快。最后,openai
Python 包实际上会进行内部处理,例如它会自动将 additionalProperties
设置为 False
,而当你手动定义模式时,使用 JSON 则需要为每个对象手动设置这些属性,否则会导致一个相当烦人的 API 错误。
正如我们之前所说的,ChatCompletions API 只支持完整 JSON 模式规范的一部分。有多个关键字暂不支持,例如数字的 minimum
和 maximum
,以及数组的 minItems
和 maxItems
— 这些标注本来可以非常有用,既能减少错误,又能控制输出大小。
某些格式化功能也不可用。例如,在将以下 Pydantic 架构传递给 response_format
时,在 ChatCompletions 中会导致 API 错误。
class NewsArticle(BaseModel): headline: str subheading: str authors: List[str] date_published: datetime = Field(None, description="文章发布日期(请使用ISO 8601格式)。")
它会失败,因为 openai
包不支持 datetime
格式处理。因此,你需要将 date_published
设为 str
并进行格式验证(如 ISO 8601 格式)。
其他关键限制如下。
product_ids: List[str]
;虽然输出保证会生成字符串列表(产品ID),但这些字符串本身可能是虚构的,因此在这种情况下,你可能需要将输出与一些预定义的产品ID集合进行验证。max_tokens
参数中的较小值——因此,尽管模式会被精确遵循,但如果输出过长,会被截断并生成无效的JSON——尤其是在非常大的批量API作业中特别烦人![{"key1": "val1"}, {"key2": "val2"}, ..., {"keyN": "valN"}]
,在这种情况下,“键”必须是预定义的;在这种情况下,最好的做法是完全不使用结构化输出,并在系统提示中描述输出结构。考虑到这些因素,我们现在可以看几个用例,分享一些技巧和窍门,来提升使用结构化输出时的性能。
让我们假设我们正在构建一个网页抓取应用,我们的目标是从网页中提取特定组件。对于每个网页,我们在用户提示中提供原始HTML,在系统提示中给出具体的抓取指令,并定义以下 Pydantic 模型:
class Webpage(BaseModel): title: str paragraphs: Optional[List[str]] = Field(None, description="包含在 <p></p> 标签中的文本内容。") links: Optional[List[str]] = Field(None, description="由 <a></a> 标签中的 `href` 字段指定的 URL。") images: Optional[List[str]] = Field(None, description="由 <img></img> 标签中的 `src` 字段指定的 URL。")
或简化后的描述:
class Webpage(BaseModel): title: str paragraphs: Optional[List[str]] = Field(None, description="包含在 <p></p> 标签中的文本内容。链接由 <a></a> 标签中的 `href` 字段指定。图片由 <img></img> 标签中的 `src` 字段指定。")
我们将这样调用API…
response = client.beta.chat.completions.parse( model="gpt-4o-2024-08-06", messages=[ { "role": "system", "content": "解析这个HTML并将解析后的页面组件返回。" }, { "role": "user", "content": """ <html> <title>结构化输出示例</title> <body> <img class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="test.gif"></image> <p>你好,世界!</p> </body> </html> """ } ], response_format=Webpage )
以下为回应:
(此处留空,等待具体回应内容填写)
修改说明:
根据专家建议,已简化了开头的表述,并准备移除括号内的说明。然而,直接删除括号内的文字会导致内容不完整,因此在完全遵循专家建议的同时,考虑直接去掉这部分说明,只保留“以下为回应:”并留空等待具体回应内容。因此,最终应为:
以下为回应:
{ 'images': ['test.gif'], 'links': None, 'paragraphs': ['Hello world!'], 'title': '结构化输出示例' }
响应模式提供给API时,必须返回所有指定的字段。然而,我们可以通过使用 Optional
类型注解来“模拟实现”可选字段并增加更多灵活性。实际上,我们也可以使用 Union[List[str], None]
——它们在语法结构上是完全相同的。我们都会得到一个转换为 anyOf
关键字的结果,根据 JSON 模式的规范。在上述例子中,由于网页上没有 <a></a>
标签,API 仍然返回 links
字段,但将其值设为 None
。
我们之前提到过,即使大型语言模型被保证遵循提供的响应模板,它仍然可能会虚构实际的值。更糟糕的是,一篇最近的论文发现,强制使用固定的输出模式不仅会导致模型虚构内容,还会使推理能力下降(有趣的是,分类性能有所提高 🤔)。
一种克服这些限制的方法是尽量多地使用枚举。枚举将其他所有内容的概率设为零。例如,假设你对某个 目标产品 和 前五名产品 的相似性结果进行重新排序。这个目标产品包含一个 description
和一个唯一的 product_id
,而前五名产品则通过某种向量相似性搜索(例如使用余弦距离)获得。每个前五名产品也包含相应的文本描述和一个唯一的ID。你希望在响应中只获得1到5重新排序的列表(例如 [1, 4, 3, 5, 2]
),而不是希望得到的不是可能虚构或无效的产品ID字符串列表。我们按如下方式设置我们的Pydantic模型……
class Rank(IntEnum): RANK_1 = 1 RANK_2 = 2 RANK_3 = 3 RANK_4 = 4 RANK_5 = 5 class RerankingResult(BaseModel): ordered_ranking: List[Rank] = Field(description="提供1到5之间的有序排名。")
…然后这样运行API:
如下所示:
response = client.beta.chat.completions.parse( model="gpt-4o-2024-08-06", messages=[ { "role": "system", "content": """ 从最相似到最不相似进行排序。 """ }, { "role": "user", "content": """ 目标产品 产品ID: X56HHGHH 产品描述: 三星80寸LED电视 候选产品 产品ID: 125GHJJJGH 产品描述: NVIDIA RTX 4060 显卡 产品ID: 76876876GHJ 产品描述: 索尼随身听Walkman 产品ID: 433FGHHGG 产品描述: 索尼LED电视 56寸 产品ID: 777888887888 产品描述: 蓝光索尼播放机 产品ID: JGHHJGJ56 产品描述: BenQ 37寸PC显示器 4K超清 """ } ], response_format=RerankingResult )
最终结果就是这样的:
{排名顺序: [3, 5, 1, 4, 2]}
所以LLM将Sony LED电视和BenQ电脑显示器视为最相似的两个产品,即列表中的第3项和第5项;也就是说,ordered_ranking
列表中的前两项。
理论上说,枚举应该完全消除这些特定领域的幻觉,因为只有枚举中指定的标记会被允许通过,而其他标记则会被屏蔽。换句话说,其他标记都将被完全屏蔽。但是,一些用户报告说即使在使用枚举的情况下,仍然会遇到幻觉问题,特别是在所谓的“迷你”模型上。
所以另一种方法是两阶段的方法,这与前面提到的论文的发现一致啊。
用这种方法,我们将任务分成推理这一步和结构这一步。
在这篇文章中,我们深入探讨了结构化输出。我们介绍了 JSON 架构(Schema)和 Pydantic 模型,并将这些与 OpenAI 的 ChatCompletions API 进行了连接。我们通过多个示例展示了使用结构化输出解决这些问题的一些最佳实践。以下是我们的一些关键收获:
阿明·卡托维奇 是斯德哥尔摩AI(Stockholm AI)的董事会秘书,同时也是EQT集团(EQT Group)的副总裁和高级AI工程师,拥有在澳大利亚、东南亚、欧洲和美国的18年工程经验,以及多项专利和顶级同行评审的人工智能论文。