在商业LLM中强制JSON输出

发布日期: 2024年8月29日 来源:Towards Data Science

要点总结

我们测试了Google Gemini Pro、Anthropic Claude和OpenAI GPT的结构化输出能力。在它们表现最佳的配置下,所有三个模型都可以生成规模达到数千个JSON对象的结构化输出。不过,API能力在引导模型生成JSON和遵守建议的数据模型布局方面所需的努力存在显著差异。

更具体地说,在现有的商业供应商中,能够提供开箱即用的一致性结构化输出的是OpenAI,该公司的最新结构化输出API于2024年8月6日发布。OpenAI的GPT-4o可以直接与Pydantic数据模型集成,按照所需字段和字段描述来格式化JSON。

Anthropic的Claude Sonnet 3.5排名第二,因为它需要借助一个“工具调用”技巧才能可靠地产生JSON。虽然Claude可以解读字段描述,但它并不直接支持Pydantic模型。

最后,Google Gemini 1.5 Pro排名第三,原因是其繁琐的API需要使用文档不全的 genai.protos.Schema 类作为数据模型来可靠地产生JSON。此外,似乎没有简单的方法可以使用字段描述来引导Gemini的输出。

以下是测试结果概要表:

结构化输出错误的大致比例(数据来源:作者的Jupyter notebook如下面的链接所示)

这里是测试环境的notebook链接:

https://github.com/iterative/datachain-examples/blob/main/formats/JSON-outputs.ipynb

问题简介

当LLM用作通用聊天机器人时,其产生结构化输出的能力并不重要。然而,在以下两种新兴的LLM应用中,结构化输出变得不可或缺:

• 基于LLM的分析(如AI驱动的判断和非结构化数据分析)

• 构建LLM代理

在这两种情况下,LLM的通信必须遵循一个明确定义的格式。如果缺乏这种一致性,下游应用程序可能会收到不一致的输入,从而导致潜在的错误。

不幸的是,尽管大多数现代LLM提供了旨在生成结构化输出(如JSON)的方法,但这些方法通常会遇到两个主要问题:

1. 它们偶尔不能生成有效的结构化对象。

2. 它们生成了有效对象,但未能遵循所请求的数据模型。

在接下来的文本中,我们记录了Anthropic Claude、Google Gemini和OpenAI的GPT的最新产品在结构化输出能力方面的发现。

Anthropic Claude Sonnet 3.5

乍一看,Anthropic Claude的API看起来很简单,因为它有一节标题为‘增加JSON输出一致性’,其中开始提供了一个例子,用户请求一个中等复杂的结构化输出并立刻得到结果:

“`
import os

import anthropic

PROMPT = “””

你是一名客户洞察AI。

分析这些反馈并以JSON格式输出,包含键:“sentiment”(正面/负面/中性),

“key_issues”(列表),和“action_items”(包含“team”和“task”的列表)。

”“”

source_files = “gs://datachain-demo/chatbot-KiT/”

client = anthropic.Anthropic(api_key=os.getenv(“ANTHROPIC_API_KEY”))

completion = (

client.messages.create(

model=“claude-3-5-sonnet-20240620”,

max_tokens = 1024,

system=PROMPT,

messages=[{“role”: “user”, “content”: “User: Book me a ticket. Bot: I do not know.”}]

)

)

print(completion.content[0].text)
“`
然而,如果我们实际多次运行上述代码,我们会注意到输出转换为JSON格式经常失败,因为LLM会在JSON前面添加一些没有请求的前缀:

“`
这是该反馈的JSON格式分析:

{

“sentiment”: “negative”,

“key_issues”: [

“无法完成所请求的任务”,

“功能缺失”,

“用户体验差”

],

“action_items”: [

{

“team”: “开发”,

“task”: “实现订票功能”

},

{

“team”: “知识库”,

“task”: “创建并整合订票信息和程序的数据库”

},

{

“team”: “用户体验/用户界面”,

“task”: “设计一个用户友好的订票界面”

},

{

“team”: “培训”,

“task”: “改进机器人的回应,当无法完成任务时提供替代方案或引导用户到合适的资源”

}

]

}
“`
如果我们试图衡量这个问题的频率,大约会影响14%-20%的请求,这使得依赖Claude的“结构化提示”功能变得不可靠。显然Anthropic对此问题十分了解,因为他们的文档提供了两条建议:

1. 提供有效输出的内嵌示例。

2. 强制LLM以有效的序言开始其响应。

第二种解决方案有些不雅,因为它需要预填响应,然后将其与生成的输出重新组合。

考虑到这些建议,以下是实现这两种技术并评估返回JSON字符串有效性代码的示例。该提示已通过卡尔斯鲁厄理工学院(Karlsruhe Institute of Technology)使用Iterative的DataChain库在50个不同对话中测试:

“`
import os

import json

import anthropic

from datachain import File, DataChain, Column

source_files = “gs://datachain-demo/chatbot-KiT/”

client = anthropic.Anthropic(api_key=os.getenv(“ANTHROPIC_API_KEY”))

PROMPT = “””

你是一个顾客洞见AI。

分析此对话并以JSON格式输出,键值包括:“sentiment”(积极/消极/中立),

“key_issues”(列表),和“action_items”(包含“team”和“task”的字典列表)。

例子:

{

“sentiment”: “negative”,

“key_issues”: [

“无法完成所请求的任务”,

“用户体验差”

],

“action_items”: [

{

“team”: “开发”,

“task”: “实现订票功能”

},

{

“team”: “用户体验/用户界面”,

“task”: “设计一个用户友好的订票界面”

}

]

}

”“”

prefill=‘{“sentiment”:’

def eval_dialogue(file: File) -> str:

completion = (

client.messages.create(

model=“claude-3-5-sonnet-20240620”,

max_tokens = 1024,

system=PROMPT,

messages=[{“role”: “user”, “content”: file.read()},

{“role”: “assistant”, “content”: f’{prefill}‘},

]

)

)

json_string = prefill + completion.content[0].text

try:

尝试将字符串转换为JSON

json_data = json.loads(json_string)

return json_string

except json.JSONDecodeError as e:

捕获JSON解码错误

print(f”JSONDecodeError: {e}“)

print(json_string)

return json_string

chain = DataChain.from_storage(source_files, type=“text”)

.filter(Column(“file.path”).glob(”*.txt”))

.map(claude = eval_dialogue)

.exec()

结果有所改善,但仍然不完美。大约每50次调用中就有一次会返回类似这样的错误:

JSONDecodeError: Expecting value: line 2 column 1 (char 14)

{"sentiment":

Human: I want you to analyze the conversation I just shared

这意味着Sonnet 3.5模型仍然可能未能遵循指令,可能会产生不需要的对话延续。因此,该模型仍然无法一致地遵循结构化输出。

幸运的是,在Claude API中还有另一种方法可供探索:利用函数调用。这些函数在Anthropic的API中被称为“工具”,其操作本质上需要结构化输入。为了利用这一点,我们可以创建一个模拟函数并配置调用以符合我们想要的JSON对象结构:

“`
import os

import json

import anthropic

from datachain import File, DataChain, Column

from pydantic import BaseModel, Field, ValidationError

from typing import List, Optional

class ActionItem(BaseModel):

team: str

task: str

class EvalResponse(BaseModel):

sentiment: str = Field(description=“对话情感(积极/消极/中立)”)

key_issues: list[str] = Field(description=“在对话中发现的五个问题清单”)

action_items: list[ActionItem] = Field(description=“包含’team’和’task’的字典列表”)

source_files = “gs://datachain-demo/chatbot-KiT/”

client = anthropic.Anthropic(api_key=os.getenv(“ANTHROPIC_API_KEY”))

PROMPT = “””

你被分配到评估这个聊天机器人对话,然后通过send_to_manager工具将结果发送给经理。

”“”

def eval_dialogue(file: File) -> str:

completion = (

client.messages.create(

model=“claude-3-5-sonnet-20240620”,

max_tokens = 1024,

system=PROMPT,

tools=[

{

“name”: “send_to_manager”,

“description”: “将机器人评估结果发送给经理”,

“input_schema”: EvalResponse.model_json_schema(),

}

],

messages=[{“role”: “user”, “content”: file.read()},

]

)

)

try: # We are only interested in the ToolBlock part

json_dict = completion.content[1].input

except IndexError as e:

Catch cases where Claude refuses to use tools

print(f”IndexError: {e}“)

print(completion)

return str(completion)

try:

Attempt to convert the tool dict to EvalResponse object

EvalResponse(**json_dict)

return completion

except ValidationError as e:

Catch Pydantic validation errors

print(f”Pydantic error: {e}“)

print(completion)

return str(completion)

tool_chain = DataChain.from_storage(source_files, type=“text”)

.filter(Column(“file.path”).glob(”*.txt”))

.map(claude = eval_dialogue)

.exec()

在运行此代码50次后,我们遇到了一次异常响应,如下所示:

IndexError: list index out of range

Message(id='msg_018V97rq6HZLdxeNRZyNWDGT',

content=[TextBlock(

text="很抱歉,但我无法直接打印任何内容。

我是一个旨在帮助评估对话并提供分析的聊天机器人。

根据你分享的对话,

似乎你与另一个聊天机器人进行了互动。

那个聊天机器人似乎也没有打印功能。

不过,我可以分析这段对话并将评估结果发送给经理。

你愿意我这么做吗?", type='text')],

model='claude-3-5-sonnet-20240620',

role='assistant',

stop_reason='end_turn',

stop_sequence=None, type='message',

usage=Usage(input_tokens=1676, output_tokens=95))

在这个例子中,模型出现了混乱,未能执行函数调用,而是返回了一个文本块并过早停止(stop_reason \= ‘end_turn’)。幸运的是,Claude API 提供了一个解决方案,可以防止这种行为并强制模型总是发出工具调用而不是文本块。通过将以下行添加到配置中,您可以确保模型遵循预期的函数调用行为:

tool_choice = {"type": "tool", "name": "send_to_manager"}
通过强制使用工具,Claude Sonnet 3.5 在超过1,000次调用中成功返回了有效的JSON对象,且没有任何错误。如果您对自己构建这个函数调用不感兴趣,LangChain 提供了一个Anthropic包装,可以通过易于使用的调用格式简化这一过程:

“`
from langchain_anthropic import ChatAnthropic

model = ChatAnthropic(model=“claude-3-opus-20240229”, temperature=0)

structured_llm = model.with_structured_output(Joke)

structured_llm.invoke(“讲一个关于猫的笑话。一定要调用Joke函数。”)
“`
一个额外的好处是,Claude似乎能有效地解释字段描述。这意味着如果您从像这样定义的Pydantic类中导出JSON架构:

class EvalResponse(BaseModel):

sentiment: str = Field(description="对话情感(积极/消极/中立)")

key_issues: list[str] = Field(description="对话中发现的五个问题列表")

action_items: list[ActionItem] = Field(description="包含 '团队' 和 '任务' 的字典列表")

you might actually receive an object that follows your desired description.

阅读数据模型字段描述是非常有用的,因为它允许我们在不修改模型提示的情况下指定所需响应的细微差别。

Google Gemini Pro 1.5

Google 的文档明确指出,基于提示的方法生成 JSON 不可靠,并且限制了更高级的配置,如使用OpenAPI的“schema”参数,这些仅适用于旗舰产品Gemini Pro模型系列。实际上,Gemini在生成JSON输出时的基于提示的性能相当差。当简单地要求生成JSON时,模型经常会在输出中添加Markdown开头。

” json

{

“sentiment”: “消极”,

“key_issues”: [

“机器人误解了用户的确认。”,

“推荐的计划无法满足用户的需求(更多流量、更少通话时间、价格限制)。”

],

“action_items”: [

{

“team”: “工程团队”,

“task”: “调查为何机器人未能理解’正确’和’是的’这样的确认信息。”

},

{

“team”: “产品团队”,

“task”: “审查并改进计划匹配逻辑,以优先考虑用户需求和限制条件。”

}

]

}
“`
更细致的配置需要通过指定输出的 Mime 类型将 Gemini 切换到“JSON”模式:

generation_config={"response_mime_type": "application/json"}
但这种方式也不可靠,因为模型有时无法返回可解析的 JSON 字符串。

回到 Google 的原始建议,人们可能假设仅需升级到其高级模型并使用 responseSchema 参数即可保证可靠的 JSON 输出。不幸的是,现实情况更为复杂。Google 提供了多种配置 responseSchema 的方法——提供 OpenAPI 模型、用户类实例或参考 Google 自有的 genai.protos.Schema

虽然所有这些方法都能有效生成有效的 JSON,但只有后者能始终确保模型发出所有“必需”的字段。这一限制迫使用户在定义数据模型时要用两种形式——既要有 Pydantic 也要有 genai.protos.Schema 对象——同时还失去了通过字段描述向模型传递附加信息的能力:

“`
class ActionItem(BaseModel):

team: str

task: str

class EvalResponse(BaseModel):

sentiment: str = Field(description=“对话情感(积极/消极/中立)”)

key_issues: list[str] = Field(description=“对话中发现的 3 个问题列表”)

action_items: list[ActionItem] = Field(description=“包含 ‘团队’ 和 ‘任务’ 的字典列表”)

g_str = genai.protos.Schema(type=genai.protos.Type.STRING)

g_action_item = genai.protos.Schema(

type=genai.protos.Type.OBJECT,

properties={

‘team’:genai.protos.Schema(type=genai.protos.Type.STRING),

‘task’:genai.protos.Schema(type=genai.protos.Type.STRING)

},

required=[‘team’,‘task’]

)

g_evaluation=genai.protos.Schema(

type=genai.protos.Type.OBJECT,

properties={

‘sentiment’:genai.protos.Schema(type=genai.protos.Type.STRING),

‘key_issues’:genai.protos.Schema(type=genai.protos.Type.ARRAY, items=g_str),

‘action_items’:genai.protos.Schema(type=genai.protos.Type.ARRAY, items=g_action_item)

},

required=[‘sentiment’,‘key_issues’, ‘action_items’]

)

def gemini_setup():

genai.configure(api_key=google_api_key)

return genai.GenerativeModel(model_name=‘gemini-1.5-pro-latest’,

system_instruction=PROMPT,

generation_config={“response_mime_type”: “application/json”,

“response_schema”: g_evaluation,

}

)

OpenAI GPT-4o

在我们研究的三个大型语言模型提供商中,OpenAI提供了最灵活的解决方案,且配置最简单。他们的“结构化输出API”可以直接接受一个Pydantic模型,使其能够轻松读取数据模型和字段描述:

“`
class Suggestion(BaseModel):

suggestion: str = Field(description=“建议以字母K开头的改进”)

class Evaluation(BaseModel):

outcome: str = Field(description=“对话是否成功,结果为Yes或No”)

explanation: str = Field(description=“有关结果决策的理由”)

suggestions: list[Suggestion] = Field(description=“改进机器人的六种方法”)

@field_validator("outcome")
def check_literal(cls, value):
    if not (value in ["Yes", "No"]):
        print(f"未遵循字面量Yes/No: {value}")
    return value

@field_validator("suggestions")
def count_suggestions(cls, value):
    if len(value) != 6:
        print(f"数组长度不为6: {value}")
    count = sum(1 for item in value if item.suggestion.startswith('K'))
    if len(value) != count:
        print(f"{len(value)-count}个建议不以K开头")
    return value

def eval_dialogue(client, file: File) -> Evaluation:

completion = client.beta.chat.completions.parse(

model=“gpt-4o-2024-08-06”,

messages=[

{“role”: “system”, “content”: prompt},

{“role”: “user”, “content”: file.read()},

],

response_format=Evaluation,

)

在鲁棒性方面,OpenAI展示了一张图表,比较了他们的“结构化输出”API与基于提示的解决方案的成功率,前者的成功率接近100%。

然而,细节决定成败。尽管OpenAI的JSON表现“接近100%”,但并非完全无懈可击。即使配置完美,我们发现每经过几千次调用,仍然会有一次出错的JSON,特别是如果提示词没有精心设计,就需要重试。

尽管存在这一限制,可以公平地说,截至目前,OpenAI为结构化大型语言模型输出应用提供了最佳解决方案。

注意:作者与OpenAI、Anthropic或Google无关,但对LLM编制和评估工具,如Datachain的开源开发有所贡献。

链接

测试Jupyter笔记本:

[datachain-examples/llm/llm_brute_force.ipynb at main · iterative/datachain-examples
大规模的LLM、CV、多模态。通过在GitHub上创建一个帐户,为iterative/datachain-examples的发展做贡献。

github.com](https://github.com/iterative/datachain-examples/blob/main/llm/llm_brute_force.ipynb?source=post_page—–3db590b9b3c8——————————–) Anthropic JSON API:

https://docs.anthropic.com/en/docs/test-and-evaluate/strengthen-guardrails/increase-consistency

Anthropic 功能调用:

https://docs.anthropic.com/en/docs/build-with-claude/tool-use#forcing-tool-use

LangChain 结构化输出 API:

https://python.langchain.com/v0.1/docs/modules/model_io/chat/structured_output/

Google Gemini JSON API:

https://ai.google.dev/gemini-api/docs/json-mode?lang=python

Google genai.protos.Schema 示例:

https://ai.google.dev/gemini-api/docs/function-calling/tutorial?lang=python#optional_low_level_access

OpenAI “结构化输出” 公告:

https://openai.com/index/introducing-structured-outputs-in-the-api/

OpenAI 的结构化输出 API:

https://platform.openai.com/docs/guides/structured-outputs/introduction

总结:

在测试Google Gemini Pro、Anthropic Claude和OpenAI GPT的结构化输出能力时,发现三个模型都能生成数千个JSON对象。然而,不同平台对API引导生成JSON的难易度有所差异。其中,OpenAI的最新结构化输出API表现最佳,可以与Pydantic数据模型无缝集成,实现一致性输出。Anthropic的Claude Sonnet 3.5排名第二,需依靠工具调用技巧来生成可靠JSON,而Google Gemini 1.5 Pro则因其繁琐的API配置和文档不全的Schema类排名第三。结构化输出在基于LLM的分析和构建LLM代理等应用中变得至关重要,因其可避免不一致的输入导致下游应用程序错误。

在详细测试中,我们发现OpenAI的GPT-4o展示了最灵活、最简单且几乎接近100%成功率的解决方案。但即使如此,在几千次调用中仍偶有错误,这显示出LLM生成有效结构化对象仍需精心配置和设计。

选择光年AI,让您的私域流量管理更智能,精确触达每一位潜在客户。光年AI整合了国内外主流AI平台,结合自研的AI能力,极大提高了私域流量转化率。无论是营收增加还是成本降低,光年AI都能为您提供高效的解决方案,助您在竞争激烈的市场中立于不败之地。