pydantic+openai+json: 控制大模型輸出的最佳正規化

studyinglover1發表於2024-06-18

呼叫大模型已經是如今做 ai 專案習以為常的工作的,但是大模型的輸出很多時候是不可控的,我們又需要使用大模型去做各種下游任務,實現可控可解析的輸出。我們探索了一種和 python 開發可以緊密合作的開發方法。

所有的程式碼都開源在了GitHub

大模型輸出是按照 token 逐個預測然後解碼成文字,就跟說話一樣,但是有的時候我們需要用大模型做一些垂直領域的工作,例如給定一段文字,我們想知道他屬於正向的還是負向的?最簡單的方法就是給大模型寫一段 prompt 告訴大模型請你告訴我這段文字是正向的還是負向的,只輸出正向的還是負向的不要輸出多餘的東西。這種方法其實有兩個問題

  1. 大模型有的時候挺犟的,你告訴他不要輸出多餘的他會說好的我不會輸出多餘的,這段文字的正向的/負向的
  2. 如果我們希望同時有多個輸出,例如正向的還是負向的,以及對應的分數,這樣的輸出會很麻煩

所以,我們需要一種格式,大模型很擅長寫,我們解析起來很方便,我們使用 python 開發的話也很方便,有沒有呢?還真有,python 有一個庫叫 pydantic,可以實現類->json->類的轉換。

這裡補充一個知識叫做 json scheme 是一種基於 JSON 的格式,用來描述 JSON 資料的結構。它提供了一種宣告性的方式來驗證 JSON 資料是否符合某種結構,這對於資料交換、資料儲存以及 API 的互動等方面都非常有用。一個 JSON Schema 本身也是一個 JSON 物件,它定義了一系列的規則,這些規則說明了 JSON 資料應該滿足的條件。例如,它可以指定一個 JSON 物件中必須包含哪些屬性,這些屬性的資料型別是什麼,是否有預設值,以及其他一些約束條件。下面是一個 json scheme 的例子。

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
    },
    "age": {
      "type": "integer",
      "minimum": 0
    },
    "email": {
      "type": "string",
      "format": "email"
    }
  },
  "required": ["name", "age"]
}

ok,那怎麼得到一個 json scheme,我們可以給描述或者一段 json 讓大模型寫,但是不夠優雅,每次需要開啟一個網頁寫寫寫然後複製貼上回來。一種更優雅的方式是用 pydantic 匯出,下面是一個例子, 定義一個Item 類然後使用Item.model_json_scheme()可以匯出這個類的 json scheme 描述

from pydantic import BaseModel
from typing import List

class Point(BaseModel):
    x: float
    y: float
    z: float


class Item(BaseModel):
    id: int
    name: str
    description: str
    number: int
    price: float
    position: List[Point]

print(Item.model_json_schema())

他的輸出是

{
  "$defs": {
    "Point": {
      "properties": {
        "x": {
          "title": "X",
          "type": "number"
        },
        "y": {
          "title": "Y",
          "type": "number"
        },
        "z": {
          "title": "Z",
          "type": "number"
        }
      },
      "required": ["x", "y", "z"],
      "title": "Point",
      "type": "object"
    }
  },
  "properties": {
    "id": {
      "title": "Id",
      "type": "integer"
    },
    "name": {
      "title": "Name",
      "type": "string"
    },
    "description": {
      "title": "Description",
      "type": "string"
    },
    "number": {
      "title": "Number",
      "type": "integer"
    },
    "price": {
      "title": "Price",
      "type": "number"
    },
    "position": {
      "items": {
        "$ref": "#/$defs/Point"
      },
      "title": "Position",
      "type": "array"
    }
  },
  "required": ["id", "name", "description", "number", "price", "position"],
  "title": "Item",
  "type": "object"
}

透過這種方式我們可以解決前面提出的第二個問題,將我們需要的多個答案寫成一個 pydantic 的類,然後將 json scheme 以及問題描述作為 prompt 給大模型例如下面的這個 prompt

user_prompt = f"""
請幫我把這個物品的描述轉換成json格式的資料,
json scheme格式如下:
{Item.model_json_schema()}
物品描述如下:
{item_desc}
請你分析上面的描述,按照json schema,填寫資訊。請一定要按照json schema的格式填寫,否則會導致資料無法解析,你會被狠狠地批評的。
只需要輸出可以被解析的json就夠了,不需要輸出其他內容。
"""

那第一個問題怎麼解決呢?首先是大模型不止輸出 json 還會輸出一堆廢話,我們可以觀察到 json 前後是大括號,這個符號是一般不會出現的,所有我們可以從輸出的字串前後開始遍歷,分別找到一個前大括號和一個後大括號,然後捨棄掉無關的

def extract_json(text):
    try:
        json_start = text.find("{")
        json_end = text.rfind("}") + 1
        json_content = text[json_start:json_end].replace("\\_", "_")
        return json_content
    except Exception as e:
        return f"Error extracting JSON: {e}"

獲取到 json 之後,使用Item.model_validate_json(json字串) 來構造一個實體類

當然我們也可以定義一個物件然後將他轉換成 json

from pydantic import BaseModel
from typing import List


class Point(BaseModel):
    x: float
    y: float
    z: float


class Item(BaseModel):
    id: int
    name: str
    description: str
    number: int
    price: float
    position: List[Point]


item = Item(
    id=1,
    name="example",
    description="example description",
    number=1,
    price=1.0,
    position=[Point(x=1.0, y=2.0, z=3.0)],
)
print(item.model_dump_json())

輸出是

{"id":1,"name":"example","description":"example description","number":1,"price":1.0,"position":[{"x":1.0,"y":2.0,"z":3.0}]}

下面我給出了一個完整的例子,使用質譜的 glm-4-air 模型,解析一個物體的描述

from enum import Enum
from typing import List
from openai import OpenAI
from pydantic import BaseModel
from dotenv import load_dotenv
from pydantic_settings import BaseSettings

load_dotenv()


class EnvSettings(BaseSettings):
    OPENAI_API_KEY: str
    OPENAI_API_BASE: str


class Point(BaseModel):
    x: float
    y: float
    z: float


class ChatRole(str, Enum):
    SYSTEM = "system"
    USER = "user"
    ASSISTANT = "assistant"


class Item(BaseModel):
    id: int
    name: str
    description: str
    number: int
    price: float
    position: List[Point]


def extract_json(text):
    try:
        json_start = text.find("{")
        json_end = text.rfind("}") + 1
        json_content = text[json_start:json_end].replace("\\_", "_")
        return json_content
    except Exception as e:
        return f"Error extracting JSON: {e}"


env_settings = EnvSettings()
client = OpenAI(
    api_key=env_settings.OPENAI_API_KEY, base_url=env_settings.OPENAI_API_BASE
)

item_desc = """
這個物品是戒指,它非常受人歡迎,它的價格是1000.7美元,編號是123456,現在還有23個庫存,他的位置在(1.0, 2.0, 3.0),非常值得購買。
"""

user_prompt = f"""
請幫我把這個物品的描述轉換成json格式的資料,
json scheme格式如下:
{Item.model_json_schema()}
物品描述如下:
{item_desc}
請你分析上面的描述,按照json schema,填寫資訊。請一定要按照json schema的格式填寫,否則會導致資料無法解析,你會被狠狠地批評的。
只需要輸出可以被解析的json就夠了,不需要輸出其他內容。
"""

resp = client.chat.completions.create(
    model="glm-4-air",
    messages=[
        {
            "role": ChatRole.SYSTEM,
            "content": "你是一個結構化資料的處理器,你精通json格式的資料,並且可以輸出結構化的json資料。你可以根據給定的文字和json scheme,輸出符合scheme的json資料。請注意,你的輸出會直接被解析,如果格式不正確,會導致解析失敗,你會被狠狠地批評的。",
        },
        {"role": ChatRole.USER, "content": user_prompt},
    ],
)

item = Item.model_validate_json(extract_json(resp.choices[0].message.content))
print(f"解析的物品資訊:{item}")

json_item = item.model_dump_json()
print(f"轉換成json格式:{json_item}")

輸出是

解析的物品資訊:id=123456 name='戒指' description='這個物品是戒指,它非常受人歡迎,它的價格是1000.7美元,編號是123456,現在還有23個庫存,他的位置在(1.0, 2.0, 3.0),非常值得購買。' number=23 price=1000.7 position=[Point(x=1.0, y=2.0, z=3.0)]
轉換成json格式:{"id":123456,"name":"戒指","description":"這個物品是戒指,它非常受人歡迎,它的價格是1000.7美元,編號是123456,現在還有23個庫存,他的位置在(1.0, 2.0, 3.0),非常值得購買。","number":23,"price":1000.7,"position":[{"x":1.0,"y":2.0,"z":3.0}]}

相關文章