JSON 模式约束 LLM 输出:Llama.cpp 与 Gemini API 实战

36

利用 JSON 模式从 LLM 获取结构化输出:实用指南

大型语言模型 (LLM) 在生成文本方面表现出色,但要获得像 JSON 这样的结构化输出,通常需要巧妙的提示。幸运的是,JSON 模式在 LLM 框架和服务中变得越来越普遍,允许您定义所需的精确输出架构。

本文探讨了使用 JSON 模式进行约束生成的方法。我们将通过一个复杂、嵌套且真实的 JSON 模式示例,指导 LLM 框架/API(如 Llama.cpp 或 Gemini API)生成结构化数据,特别是旅游地点信息。本文基于之前关于使用 Guidance 进行约束生成的文章,但重点介绍了更广泛采用的 JSON 模式。

虽然 JSON 模式比 Guidance 更有限,但其更广泛的支持使其更容易访问,尤其对于基于云的 LLM 提供商而言。

在个人项目中,我发现虽然 JSON 模式对于 Llama.cpp 来说很简单,但要使其与 Gemini API 配合使用则需要一些额外的步骤。本文分享了这些解决方案,以帮助您有效地利用 JSON 模式。

旅游地点文档的 JSON 模式

我们的示例模式代表一个 TouristLocation。这是一个非平凡的结构,具有嵌套对象、列表、枚举和各种数据类型(如字符串和数字)。

这是一个简化的版本:

{
  "name": "string",
  "location_long_lat": ["number", "number"],
  "climate_type": {"type": "string", "enum": ["热带", "沙漠", "温带", "大陆", "极地"]},
  "activity_types": ["string"],
  "attraction_list": [
    {
      "name": "string",
      "description": "string"
    }
  ],
  "tags": ["string"],
  "description": "string",
  "most_notably_known_for": "string",
  "location_type": {"type": "string", "enum": ["城市", "国家", "机构", "地标", "国家公园", "岛屿", "地区", "大陆"]},
  "parents": ["字符串"]
}

您可以手写这种类型的模式,也可以使用 Pydantic 库生成它。下面是一个简化的示例:

from typing import List
from pydantic import BaseModel, Field

class TouristLocation(BaseModel):
    """旅游地点模型"""

    high_season_months: List[int] = Field(
        [], description="该地点游客最多的月份列表(1-12)"
    )

    tags: List[str] = Field(
        ...,
        description="描述地点的标签列表(例如可访问、可持续、阳光充足、便宜、昂贵)",
        min_length=1,
    )
    description: str = Field(..., description="位置的文本描述")

location = TouristLocation(
    high_season_months=[6, 7, 8],
    tags=["海滩", "阳光充足", "家庭友好型"],
    description="美丽的海滩,拥有白色的沙滩和清澈的蓝色海水。",
)

schema = location.model_json_schema()
print(schema)

此代码使用 Pydantic 定义了数据类的简化版本 TouristLocation。它有三个字段:

  • high_season_months: 表示该地点访问量最大的月份(1-12)的整数列表。默认为空列表。
  • tags: 使用“可访问”、“可持续”等标签描述位置的字符串列表。此字段是必填项 (...),并且必须至少包含一个元素 (min_length=1)。
  • description: 包含位置文本描述的字符串字段。此字段也是必填的。

然后,代码会创建该类的一个实例 TouristLocation,并用它 model_json_schema() 来获取模型的 JSON Schema 表示。该 Schema 定义了该类所需的数据的结构和类型。

model_json_schema() 返回:

{ 'description':'旅游地点模型',
 'properties':{ 'description':{ 'description':'地点的文字描述',
                                                   'title':'描述',
                                                   'type':'string' },
                    'high_season_months':{ 'default':[],
                                           'description':'月份列表(1-12)',
                                                          '地点
                                                          访问量最大',
                                           'items':{ 'type':'integer' },
                                           'title':'旺季月份',
                                           'type':'array' },
                    'tags':{ 'description':'描述地点的标签列表'
                                            (例如可访问,可持续,阳光明媚,'
                                            '便宜,昂贵)',
                             'items':{ 'type' : 'string' },
                             'minItems' : 1,
                             'title' : '标签',
                             'type' : 'array' }},
     'required' : [ 'tags' , 'description' ],
     'title' : 'TouristLocation',
     'type' : 'object' }

现在我们有了模式,让我们看看如何实施它。首先在 Llama.cpp 中使用其 Python 包装器,其次使用 Gemini 的 API。

方法 1:使用 Llama.cpp 的简单方法

Llama.cpp 是一个用于在本地运行 Llama 模型的 C++ 库。它对初学者很友好,并且拥有一个活跃的社区。我们将通过其 Python 包装器使用它。

以下是使用它生成数据的方法 TouristLocation


checkpoint = “lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF”

model = Llama.from_pretrained(
    repo_id=checkpoint,
    n_gpu_layers=-1,
    filename= “*Q4_K_M.gguf”,
    verbose=False,
    n_ctx=12_000,
)

messages = [
    {
        “role”: “system”,
        “content”: “您是一位以 JSON 格式输出的有用助手。”
        f “遵循此模式 {TouristLocation.model_json_schema()}”,
    },
    {“role”: “user”, “content”: “生成有关美国夏威夷的信息。”},
    {"role": "assistant", "content": f "{location.model_dump_json()}"},
    {"role": "user", "content": "生成有关卡萨布兰卡的信息"},
]
response_format = {
    "type": "json_object",
    "schema": TouristLocation.model_json_schema(),
}

start = time.time()

output = model.create_chat_completion(
    messages=messages, max_tokens=1200, response_format=response_format
)

print(outputs["choices"][0]["message"]["content"])

print(f"时间:{time.time() - start}")

代码首先导入必要的库并初始化 LLM 模型。然后,它定义与模型对话的消息列表,包括指示模型根据特定架构以 JSON 格式输出的系统消息、用户对夏威夷和卡萨布兰卡信息的请求以及使用指定架构的助手响应。

Llama.cpp 在底层使用上下文无关语法来约束结构并为新城市生成有效的 JSON 输出。

在输出中我们得到以下生成的字符串:

{ 'activity_types' :  [ '购物' , '美食与美酒' , '文化' ] ,
 'attraction_list' :  [ { 'description' : '世界上最大的清真寺之一 '
                                         ',摩洛哥建筑的象征',
                           'name' : '哈桑二世清真寺' } ,
                         { 'description' : '一座历史悠久的城墙城市,拥有狭窄的 '
                                         '街道和传统商店',
                           'name' : '老麦地那' } ,
                         { 'description' : '一座历史悠久的广场,拥有美丽的 '
                                         '喷泉和周围的建筑',
                           'name' : '穆罕默德五世广场' } ,
                         { 'description' : '一座美丽的天主教堂,建于 ''20世纪
                                         初',                       'name' : '卡萨布兰卡大教堂' } , { 'description' : '风景秀丽的海滨长廊,'
                                      '可欣赏到城市和大海的美丽景色',                       'name' : 'Corniche' } ] , 'climate_type' : 'temperate' , 'description' : '一座拥有丰富历史和文化的繁华大城市' , 'location_type' : 'city' , 'most_notably_known_for' : '其历史建筑和文化'
                            '意义' , 'name' : '卡萨布兰卡' , 'parents' : [ '摩洛哥' , '非洲' ] , 'tags' : [ 'city' , 'cultural' , 'historical' ,'昂贵的' ] }

然后可以将其解析为我们的 Pydantic 类的一个实例。

哈桑二世清真寺

方法 2:克服 Gemini API 的局限性

Gemini API 是 Google 的托管 LLM 服务,其文档中声称 Gemini Flash 1.5 仅支持有限的 JSON 模式。不过,只需进行一些调整即可实现该功能。

以下是使其工作的一般说明:

schema = TouristLocation.model_json_schema()
schema = replace_value_in_dict(schema.copy(), schema.copy())
del schema[ "$defs" ]
delete_keys_recursive(schema, key_to_delete= "title")
delete_keys_recursive(schema, key_to_delete= "location_long_lat")
delete_keys_recursive(schema, key_to_delete= "default")
delete_keys_recursive(schema, key_to_delete= "default")
delete_keys_recursive(schema, key_to_delete= "minItems")

print (schema)

messages = [
    ContentDict(
        role= "user",
        parts=[
            "您是一位能以 JSON 格式输出的得力助手。"
            f"请遵循此模式{TouristLocation.model_json_schema()} "
         ],
    ),
    ContentDict(role= "user", parts=[ "生成有关美国夏威夷的信息。"]),
    ContentDict(role= "model", parts=[ f" {location.model_dump_json()} "]),
    ContentDict(role= "user", parts=[ "生成有关卡萨布兰卡的信息"]),
]

genai.configure(api_key=os.environ[ "GOOGLE_API_KEY" ])

model = genai.GenerativeModel(
    "gemini-1.5-flash",
    # 设置 `response_mime_type` 以输出 JSON
    # 将架构对象传递给 `response_schema` 字段
    generation_config={
        "response_mime_type": "application/json",
        "response_schema": schema,
    },
)

response =模型.生成内容(消息)
打印(响应.文本)

以下是如何克服 Gemini 的局限性的方法:

  1. 用完整定义替换 $ref: Gemini 偶然发现了架构引用($ref)。当您有嵌套对象定义时会用到它们。用架构中的完整定义替换它们。

    def replace_value_in_dict(item, original_schema):
        # 来源:https://github.com/pydantic/pydantic/issues/889
        if isinstance(item, list):
            return [replace_value_in_dict(i, original_schema) for i in item]
        elif isinstance(item, dict):
            if list(item.keys()) == ["$ref"]:
                definitions = item["$ref"][2:].split("/")
                res = original_schema.copy()
                for definition in definitions:
                    res = res[definition]
                return res
            else:
                return {
                    key: replace_value_in_dict(i, original_schema)
                    for key, i in item.items()
                }
        else:
            return item
  2. 删除不支持的键: Gemini 尚未处理“title”、“AnyOf”或“minItems”等键。请从您的架构中删除这些键。这会导致架构的可读性降低和限制性降低,但如果坚持使用 Gemini,我们别无选择。

    def delete_keys_recursive(d, key_to_delete):
        if isinstance(d, dict):
            # Delete the key if it exists
            if key_to_delete in d:
                del d[key_to_delete]
            # Recursively process all items in the dictionary
            for k, v in d.items():
                delete_keys_recursive(v, key_to_delete)
        elif isinstance(d, list):
            # Recursively process all items in the list
            for item in d:
                delete_keys_recursive(item, key_to_delete)
  3. 枚举的一次性或少量提示: Gemini 有时会遇到枚举问题,它会输出所有可能的值,而不是单个选择。这些值在单个字符串中也由“|”分隔,根据我们的架构,这些值是无效的。使用一次性提示,提供正确格式的示例,以引导它实现所需的行为。

通过应用这些转换并提供清晰的示例,您可以使用 Gemini API 成功生成结构化 JSON 输出。

结论

JSON 模式允许您直接从 LLM 获取结构化数据,从而使其更适合实际应用。虽然 Llama.cpp 等框架提供了简单的实现,但您可能会遇到 Gemini API 等云服务的问题。

希望本博客能让您更好地实际了解 JSON 模式的工作原理,以及即使在使用目前仅提供部分支持的 Gemini API 时如何使用它。

现在我能够让 Gemini 以 JSON 模式工作,我就可以完成我的 LLM 工作流程的实现,其中需要以特定方式构造数据。